Эх сурвалжийг харах

docs(ac15): RS-5 Note 点明"隔离字符串比较";§22 补 `to` 的口径说明

按 team-lead 收口要求(F30):
- RS-5 的 Note 明确写「**D 取 type=200 是为了隔离字符串比较的影响**」——
  否则 `BETWEEN`(含等号)在 `to` 的不同写法下会让当期发生额也差一笔,掩盖本 case 要钉的边界;
  同时把"右开"改成更准确的"不计入期末"(legacy 侧其实是 VARCHAR 字典序 + 严格 `<`)。
- §22 新增「`to` 的口径(端点 17;是措辞问题,不是代码 bug)」:
  纯日期 ⇒ 当日 00:00、不含该日;端点 17 不归一 `to`、端点 60 归一(不对称,不得互相推广);
  legacy 的 VARCHAR 字典序与 `BETWEEN` 含等号的差别;**并写明时区前提**:
  target `time.Parse` 是 UTC 零点,DB 以 loc=Local 读,仅当进程时区=UTC 时"两侧一致"才成立,
  dev(+08:00) 实测 `to='2026-03-31'` 的 endBalance 110 vs 60。

注:工作树里 `internal/academic/handler/handler.go` 有他人**进行中**的改动(F31 修复,
当前 `undefined: tz` 未编译通过),本轮未触碰;本提交只含上述两个文件,
门禁按改动范围跑:build/vet 仅 `dualrun/...`、test 仅 `stage2`、resultsets 5 PASS。
Q 5 цаг өмнө
parent
commit
b4362316fc

+ 14 - 0
internal/academic/dualrun/stage2/README.md

@@ -1756,6 +1756,20 @@ target  MIN(start_time) 折平 ⇒ 60 + 60 = 120.00,两行都核完,不报
    要判"合并还是丢弃",必须同时核**该活动的 signed 行数 / `activity_money` 合计**
    (合并 ⇒ 2000,丢弃 ⇒ 1000)。处置口径需迁移 owner 定,本文件只给数。
 
+### `to` 的口径(端点 17;是**措辞**问题,不是代码 bug)
+
+1. **纯日期 `to` ⇒ 当日 00:00,区间止于该 00:00 ⇒ 不含该日**。要含当日必须传次日或 `T23:59:59`。
+2. 端点 17 **有意不归一 `to`**(`bill_handler.go` 直接用 `parseTimeParam`);
+   端点 60 则有 `normalizeToUpperBound`(纯日期 → 次日 00:00)。**两者不对称,不得互相推广。**
+3. legacy 侧 `create_time` 在临时表里是 `VARCHAR(100)`(`CREATE TEMPORARY TABLE temp_user_expense_log`),
+   与 VARCHAR 入参做**字典序**比较 ⇒ 纯日期与 `00:00:00` 对结余列**同效**(都不含当天),`23:59:59` 才含;
+   而「当期发生额」用 `BETWEEN`(含等号)⇒ `t=to` 落在消费/充值类型时,两种写法会差一笔。
+   RS-5 的 D 取 `type=200` 正是为隔离这一点。
+4. ⚠ **时区前提**:target 的 `time.Parse("2006-01-02", …)` 得到 **UTC 零点**,而 DB 按 `loc=Local` 读。
+   仅当进程时区 = UTC 时,"两侧一致、都不含当天"才成立;dev 是 **+08:00**,
+   target 的边界实际晚 8 小时(实测 `to='2026-03-31'` 时 `endBalance` 110.00 vs legacy 60.00,
+   整月窗口 `recharge` 0.00 vs 100.00)。⇒ **F30 的"两侧一致"需按部署时区限定**,见下节实测。
+
 ### `to` 的格式敏感性:一次只读测量(RS-5 之后追加)
 
 **问题**:RS-5 里那句注释(dump 的 `create_time` 是 VARCHAR、`BETWEEN` 是字符串比较)会不会意味着

+ 4 - 3
internal/academic/dualrun/stage2/rs_bill_window.go

@@ -212,10 +212,11 @@ func rsWindowCase() ResultSetCase {
 	cols, exclusive, inclusive := rsWindowExpected()
 	return ResultSetCase{
 		Name: "RS-5 账单右端语义:`t = to` 的那一笔只进 FlowInclusiveEnd(不进期末/Exclusive)",
-		Note: "四条流水钉三个边界:窗口前 / 恰在 from(左闭)/ 窗口内 / 恰在 to(右开)。" +
+		Note: "四条流水钉三个边界:窗口前 / 恰在 from(左闭)/ 窗口内 / 恰在 to(不计入期末)。" +
 			"期初 −10、期末 60、Exclusive 70、Inclusive 120、差 50 = `to` 当天那笔。" +
-			"`t=to` 的流水故意取 type=200:若取 100/101,legacy 的字符串比较会让当期发生额也差," +
-			"那会掩盖右端语义本身",
+			"**D 取 `type=200` 是为了隔离字符串比较的影响**:dump 的 create_time 是 VARCHAR," +
+			"`BETWEEN`(含等号)与 `<`(严格)在 `to` 的不同写法下表现不同;若 D 取 100/101," +
+			"当期发生额也会差一笔,掩盖本 case 要钉的那条边界",
 		LegacyUserID: rsUserID, InstitutionID: rsInstID, CampusID: rsCampusID,
 		SeedLegacy:   seedLegacyWindow,
 		SeedTarget:   seedTargetWindow,