Explorar el Código

docs(ac15): 3c 前置探测 t_courses_class(只读,0 行 Go 代码 / case 数仍 50)

## 一、探了 5 项,5 项都有实测数(不是按命名猜)

1. 两侧 DDL:legacy 18 列 / target 19 列,**字面 9 处不同构、语义 1:1 同构**。
   唯一需要判断的映射 `schoolid → institution_id` 已由 target 查询证实。
   两侧外键 0、触发器 0、唯一键只有 PK(target 另有 `uk_biz_no`)。
2. 最小插入集(NOT NULL 且无默认值):class **4 / 2** 列,student **10 / 3** 列;
   加上本 case 必须显式控制的列,实际是 class **5 / 3**、student **13 / 8**。
3. 现成行**不能复用**,两个独立理由:
   · target `t_courses_class` = 0 行;
   · legacy 候选 24133 行 / 1919 学生,但按 `(学生, start_time)` 分组的 **5535 组里 4846 组并列(87.5%)**。
4. `start_time` 两侧都完全可控(无触发器/ON UPDATE),但**必须两两不同**:
   legacy `ORDER BY start_time` 无 tie-break,target 是 `first_start, t1.id` ⇒ 并列即不可复现。
5. 反例行:`confirm_discount_userid=0 且 remainder>0` **可行且可观测**;
   但 `remainder_receivable=0` 单开 case 是**恒真断言**(0 元 UPDATE 是恒等变换、inputMoney 不减),
   故不写第 3 条,改为塞进 RA-1 的最早课次。

## 二、顺带挖出三件事

· `(coursesid, classid)` 无唯一索引但实际 0 重复 ⇒ 1:N 只在构造时出现;
· target 的 `MIN(start_time)` 折平是**既有裁决**(`course_repo.go` 注释已声明旧 SP 会重复核销),
  天然一条 INTENTIONAL-STRICT —— 但**终态必须真跑**:legacy 是 `decimal(10,3) unsigned`,
  减到负数是 1690 越界还是重复累加,读代码只能给假设;
· `Remaining` 是 target 独有出参,但 `inputMoney − ΣΔnet_receipts` 两侧都算得出 ⇒ 做成逐侧不变量。

## 三、3c 定档:写 2 条(equality + intentional-strict)

## 四、顺手确认了 3b 那条"保留"

`grep "本块比对了"` 在**运行输出**里 = 0,实测原因是**没有一条 case 声明
`RecommendUserID>0` 或 `StudentBizNos`** ⇒ 两处 `mustCover` 的入口条件都不成立、
整块代码路径没被走到。3c 的验收硬指标因此定为:`grep` **≥ 2** 且数字与声明条数逐一对上。

基线未变:50 / 50 PASS / 0 FAIL,Skipped 98,业务 290,基建 99,未分类 0,
代价 SQL 2870 次 / 16.489s / 57.4 次每 case。
Q hace 2 horas
padre
commit
715fee32f1
Se han modificado 1 ficheros con 143 adiciones y 0 borrados
  1. 143 0
      internal/academic/dualrun/stage2/README.md

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

@@ -1343,3 +1343,146 @@ $sql = "SELECT SUM(t1.discount_amount-t1.discount_amount_used) as amount FROM ..
 
 > 这类"自检本身假红"比"断言漏了"更危险:**断言写错方向会把真实现判成 bug**,
 > 看到红的人第一反应是去改实现。所以判据变更必须显式记录,不能留在"顺手改"里。
+
+## §21 3c 前置探测:`t_courses_class`(只读,未写任何 case)
+
+`sp_courses_student_counteract_arrears_course`(W4)的游标要 JOIN `t_courses_class`
+按 `t2.start_time ASC` 排序,而这张表我们**没有种子能力**。所以 3c 动手前先只读探了 5 项。
+下面每条都是 `cmd/spdump` 的实测输出,不是按命名猜的。
+
+### 21.1 两侧 DDL:18 列 vs 19 列,语义同构、字面几乎全不同构
+
+| # | legacy `legacy_oracle` | target `yuansiwei_2026` | 差异性质 |
+|---|---|---|---|
+| 1 | `id int` PK auto_increment | `id bigint` PK auto_increment | 宽度不同 |
+| 2 | `status int NOT NULL DEFAULT 1` | 同 | — |
+| 3 | `coursesid int NOT NULL` | `courses_id bigint NOT NULL` | 改命名 + 宽 |
+| 4 | `classid int NOT NULL` | `class_id bigint NOT NULL` | 改命名 + 宽 |
+| 5 | `teacherid int NOT NULL DEFAULT 0` | `teacher_id varchar(26) NULL` | **int → ULID 且 NOT NULL → NULL** |
+| 6 | `schoolid int unsigned NOT NULL DEFAULT 0` | `institution_id bigint NOT NULL DEFAULT 0` | 改名(学校 → 机构) |
+| 7 | `campusid int unsigned DEFAULT 0` | `campus_id bigint DEFAULT 0` | 改名 |
+| 8 | `standard_lesson_cost float(10,3) NULL` | `decimal(12,2) NOT NULL DEFAULT 0.00` | **3 位 → 2 位;可空 → 非空** |
+| 9 | `number int DEFAULT 0` | 同 | — |
+| 10 | `lesson_cost float(10,3) NULL` | `decimal(12,2) NOT NULL DEFAULT 0.00` | 同 #8 |
+| 11 | `discount_cash float(10,3) NOT NULL DEFAULT 0` | `decimal(12,2) NOT NULL DEFAULT 0.00` | 3 位 → 2 位 |
+| 12 | `finished_number int DEFAULT 0` | 同 | — |
+| 13 | `confirm tinyint DEFAULT 0` | 同 | — |
+| 14 | `week varchar(50) NOT NULL DEFAULT '-1'` | `varchar(50) NOT NULL DEFAULT '-1'` | — |
+| 15 | `start_time datetime NOT NULL` | `start_time datetime(3) NULL` | **精度 3 位;NOT NULL → NULL** |
+| 16 | `end_time datetime NOT NULL` | `end_time datetime(3) NULL` | 同 #15 |
+| 17 | `address text NULL` | `longtext NULL` | — |
+| 18 | `remark text NULL` | `longtext NULL` | — |
+| 19 | — | `created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)` | target 独有 |
+
+**结论:字面上"几乎全不同构"(9 处差异),但语义上同构** —— 列集合是 1:1 对应的
+(`schoolid→institution_id` 是唯一需要判断的映射,`t_courses_student` 的 target 查询
+`WHERE t1.institution_id = ? AND t1.campus_id = ?` 已经证实这条映射)。
+**没有任何一列在两侧含义不同**,所以种子可以逐列对照地写。
+
+**没有的东西同样重要**(都实测过):两侧 `t_courses_class` / `t_courses_student`
+**外键 0 个、触发器 0 个**;唯一键只有 PK,target 另有 `t_courses_student.uk_biz_no`。
+⇒ 插入不会被参照完整性挡住,`start_time` 也不会被 `ON UPDATE` 之类偷改。
+
+### 21.2 最小插入集(只数"NOT NULL 且无默认值",auto_increment 除外)
+
+| 表 | legacy | target |
+|---|---|---|
+| `t_courses_class` | **4**:`coursesid, classid, start_time, end_time` | **2**:`courses_id, class_id`(`start_time` 可空) |
+| `t_courses_student` | **10**:`id, coursesid, studentid, receivable, net_receipts, unpaid, lesson_cost, discount_cash, remainder_cost, remainder_receivable` | **3**:`biz_no, courses_id, student_id` |
+
+target 少得多的原因很实在:**所有金额列都收敛成 `NOT NULL DEFAULT 0.00`**,
+而 legacy 的金额列是"NOT NULL 无默认"(必须显式给)。
+
+但"能插进去"不等于"能测" —— 本 case 还要显式控制的列(两侧都要):
+`start_time`(排序键)、`remainder_receivable` + `net_receipts`(被改的列)、
+`confirm_discount_userid` / `confirm_discount_user_id`(过滤键)、
+legacy `schoolid` / target `institution_id` + `campus_id`(游标的三个 WHERE 之一)。
+⇒ 实际每条 case 的种子列数:class **5 / 3**,student **13 / 8**。
+
+**一个省掉整类麻烦的事实**:游标 JOIN 用的是 `(coursesid, classid)` 这一对,
+**不是 `t_courses_class.id`** ⇒ 两侧 class 行的自增 id **不需要对齐**,
+§19 那种"同一个 id 两套空间"的问题在这条链上不存在。
+
+### 21.3 现成行能复用吗:**不能**,两个独立理由(任一条都够)
+
+**理由一(结构性)**:target 库 `t_courses_class` **0 行**(`COUNT(*) = 0`,实测)。
+target 侧必须构造,而两侧要按同一锚比对(`biz_no`)⇒ 只能两侧同构构造。
+
+**理由二(更致命,且是实测出来的)**:legacy 侧候选行**大量在 `start_time` 上并列**。
+
+- 满足游标过滤(`remainder_receivable>0 AND confirm_discount_userid>0`)的行:**24133 行**,分属 **1919** 个 `(studentid, schoolid, campusid)`。
+- 按 `(学生, start_time)` 分组:**5535 组**,其中 **4846 组有 >1 行 = 87.5%**。
+- 实测学生 `398`(schoolid=63324 campus=15)的游标集合里就有 **8 行**挤在 `2020-02-18 14:30:00`;
+  学生 `45718` 的前 4 行全是 `2018-10-11 17:40:00`(`coursesid` 673/671/672/669)。
+
+而两侧的排序**不对称**:
+
+```
+legacy  ORDER BY t2.start_time ASC                    (无 tie-break)
+target  ORDER BY t2.first_start ASC, t1.id ASC        (有 tie-break,见 course_repo.go)
+```
+
+⇒ 真实的并列行会让 legacy 侧顺序**由执行计划决定**,重复跑都可能不同。
+拿真实行做双跑,"先核哪一行"这个被验对象本身就不可复现 —— **这种红/绿都不算数**。
+
+### 21.4 `start_time` 两侧可控性:都可控,但**必须互不相同**
+
+| | legacy | target |
+|---|---|---|
+| 可空性 | `datetime NOT NULL`,无默认 | `datetime(3) NULL`,无默认 |
+| 能否显式插值 | 能(**必须**给) | 能(给了就用) |
+| 触发器/ON UPDATE | 无 | 无 |
+| 精度 | 秒 | 毫秒 |
+
+两侧都受我们控制,**但种子必须保证"同一学生下所有 class 行的 start_time 两两不同"** ——
+不是为了排序好看,是因为 §21.3 那条 tie-break 不对称:一旦并列,两侧的循环顺序就可能不同,
+而**循环顺序决定钱落到哪一行**。精度取整秒即可(两侧都按 `%Y-%m-%d %H:%i:%s` 读回)。
+
+### 21.5 反例行可行性:一个可行、一个是**假测**
+
+游标有 4 个过滤条件,逐个看"去掉它能不能被观测到":
+
+| 过滤条件 | 真实库存量 | 构造可行性 | 去掉后是否可观测 |
+|---|---|---|---|
+| `remainder_receivable > 0` | 17917 行 `=0` | 易 | **不可观测**:0 元 UPDATE 是恒等变换(`net +0` / `rem −0`),且 `inputMoney` 不减 ⇒ 终态一模一样。Go 侧还专门有 `if !amount.GreaterThan(0) { continue }` 兜这个。 |
+| `confirm_discount_userid > 0` | 15 行 `=0`,**其中 remainder>0 的 0 行** | 易(两侧都能造) | **可观测**:该行若被纳入,会**吃掉本该给别的行的钱** ⇒ `net_receipts` 落错行。 |
+| `INNER JOIN t_courses_class` | **孤儿行 0 行**(LEFT JOIN 反查实测) | 易(种一条没有 class 的报课行) | **可观测**:被 JOIN 排掉的行拿不到钱。 |
+| `studentid/schoolid/campusid` | — | 易(其余行放别的 school/campus) | 可观测:作用域串了就是别的学生的钱被动。 |
+
+⇒ **`confirm_discount_userid=0` 与 `remainder_receivable=0` 能在同一学生下同时造出来**(答案:**能**),
+但只有前者值得写成断言。后者单独写一条 case 是**恒真断言**——
+和 §18.3 第 0 条同一族:**断言恒成立 = 没测**。
+
+而且要能被观测,反例行必须放在**最早 `start_time`** 上:先被过滤掉、钱才能落到正确行;
+如果反例行排在后面,正确行先吃饱了,过滤条件写错也看不出来。
+
+### 21.6 顺带挖出的三件事(都不在原 5 问里,但影响 3c 怎么写)
+
+1. **`(coursesid, classid)` 没有唯一索引,但实际 0 重复**(6173 行实测 `COUNT=0` 组)。
+   ⇒ "同一 `(courses_id, class_id)` 多排班"这种 1:N 形态**只在构造时出现**,真实数据里不存在。
+2. **target 侧已经把 1:N 折平了,且是既有裁决**:`course_repo.go` 用
+   `MIN(start_time) GROUP BY (courses_id, class_id)` + `t1.id ASC`,注释写明理由是
+   "旧 SP 游标会重复核销同一报课行(dump 缺陷)"。⇒ 这天然是一条 **INTENTIONAL-STRICT**,
+   但**终态到底是什么必须真跑**:第二趟 `remainder_receivable - x` 在 legacy 是
+   `decimal(10,3) unsigned`,减到负数可能直接报 1690 越界、也可能重复累加 ——
+   **读代码只能给假设,不能给期望值**。
+3. **`ArrearsRow` 的"有序"目前只在 legacy 侧有意义**:target 的 `Remaining`(未用完额)
+   是 Go 侧新增的出参,legacy SP 不返回它;但 `inputMoney − ΣΔnet_receipts` 两侧都算得出来
+   ⇒ 这条可以做成"逐侧不变量",不需要 target 独有字段。
+
+### 21.7 3c 走哪一档:**写 2 条**
+
+判据(按 §21.1~21.5 实测):
+
+- 两侧**语义**同构、插入集小(5/3 列)、无 FK/无唯一键/无触发器 ⇒ **种子可控**;
+- `start_time` 可控且能保证唯一 ⇒ **顺序可复现**;
+- 有一条 **equality**(多行按课次先后核销、金额不够时截断)和一条 **INTENTIONAL-STRICT**
+  (1:N 折平)都成立 ⇒ 够写 2 条。
+
+**不写第 3 条(`remainder_receivable=0` 的单开 case)**,理由见 §21.5:它是恒真断言。
+反例行改为**塞进 RA-1 的最早期次**,这样"过滤条件写错"会直接表现为"钱落错行"。
+
+> 一条纪律留在这儿:3b 的两块基建断言(推荐人账户 / 课酬应收)**至今在运行输出里 `grep = 0`**。
+> 实测原因是**没有一条 case 声明 `RecommendUserID>0` 或 `StudentBizNos`**,两处 `mustCover` 的
+> 入口条件都不成立、整块代码路径没被走到。这不是"不需要",是"还没通电"。
+> ⇒ 3c 的验收硬指标:跑完 `grep "本块比对了"` **必须 ≥ 2**(每块各一条),且数字与 case 声明条数对上。