|
|
@@ -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 声明条数对上。
|