ソースを参照

feat(ac15): 3c 课酬应收核销双跑(SP27)—— RA-1/RA-2,50→52 全绿

## 一、为什么这一批必须单独走一套比对

SP27 `sp_courses_student_counteract_arrears_course` **一分钱都不动资金表**,只改
`t_courses_student` 的 `net_receipts +` / `remainder_receivable −`。通用比对的主语是
资金账户的 5 个增量 ⇒ 对它只会得出"Δ 全是 0、两侧相等"的**假绿**(SP 一行没改也通过)。
所以新增 `arrearsCompare`,比对主语改成**每一行报课记录被核了多少钱**,
顺序即语义(游标 `ORDER BY start_time ASC` 决定钱先落到谁身上)。

## 二、两条 case 各证明什么(c2_refund_test 的 50→52 不是改数字了事)

RA-1(equality):一条 case 同时钉**过滤 / 顺序 / 截断**三件事。
  · 反例行(`confirm_discount_userid=0` 但 `remainder>0`)**故意放在最早课次** ——
    只有它排在前面,"过滤条件被写漏"才会表现为"钱落到错的行走廊";排后面时正确行先吃饱,写漏也看不出来。
  · 实测终态:第一行 0 / 第二行 +80.00(rem 归 0)/ 第三行 +20.00(rem 40→20,被 `inputMoney` 截断)。

RA-2(INTENTIONAL-STRICT,**锁住既有裁决**,不是"复现 legacy 缺陷"):
  同一 `(course,class)` 两条排班 ⇒ legacy 游标把同一条报课行取出两次。
  **实测终态(两次跑一致)**:legacy 第一趟 +60.00 后,第二趟 `rem = rem − 60` 在本列
  `decimal(10,3) **unsigned**` 上越界 ⇒ `Error 1264 (22003)` ⇒ **整个 CALL 中断**,
  第二条报课行一分未得(部分写入:+60.00 已落库)。target 折平后 60+60=120.00 正常核完。
  ⇒ 后果比"重复核销"重:legacy 是**一行崩掉整个循环、后面的行全部漏核**(用户少拿 140.00)。
  证据强度 **B(schema 合法、数据未观测 0/6173)**,依据见 F21;
  Note 里按"期望值来源 / 机制解释 / 可复现性"三样写清,并显式钉住"部分写入"。

## 三、两个防自伤点(这一批的主要价值之一)

1. **F22 正向断言**:「本 case 的钱**真的动了**(两侧 ΣΔnet_receipts > 0 且 ΣΔremainder < 0),
   全 0 ⇒ FAIL」。它挡的是 `mustCover` **挡不住**的那条路:快照读 `WHERE biz_no IN (...)`(无机构过滤),
   而候选集由 `BaseRepo.Scoped` 追加 `institution_id` 过滤 —— 种子机构与 case 不一致时,
   快照照读到 3 行、`mustCover` 照通过,而两侧都 no-op。**"什么都没发生"必须是 FAIL。**
2. **PRECISION-CONVERGENCE 业务断言**:种子两列的**第 3 位小数恒为 0**。
   两侧小数位本就不同(legacy 3 位 / target 2 位),`readRound2` 是**迁移收敛口径**,
   分辨力 0.01、看不见 <0.005 的分歧。快照里看不到第 3 位 ⇒ 多读两列原始精度
   (`ArrearsRow.RemainderRaw3/NetRaw3`,不参与比对口径),把"无损失"从假设变成断言。
   文案**限定"本 case 的种子"**:真实数据不是无损的(`net_receipts` 2687 行、
   `remainder_receivable` 17 行第 3 位非零,并集 2694 行 = 6.4%)。

## 四、两处口径改写(F21 / F22 的落地)

· `course_repo.go`:去掉"dump 缺陷",写成 **`defensive flattening`** ——
  schema 允许(无 `(coursesid,classid)` 唯一索引)、真实数据 **0/6173** 例、
  保留是因为成本一个 `GROUP BY` 且有人简化回 JOIN 时 RA-2 会红。
  另补一句:`t1.id ASC` 的 tie-break 是我们加的(旧 SP 没有,而真实数据 87.5% 的
  `(学生,start_time)` 组并列 ⇒ 不补就不可复现)。
· README §21.8:写明 **推荐人账户块仍未激活、仍未验收**(RA-1/RA-2 不声明 `RecommendUserID`,
  那是 3d 的 W4),以及 F22 的台账口径「改 `Scoped` 等于改 SP 语义」。

## 五、报数(干净树跑)

DefaultCases 52 / 实跑 52 / PASS 52 / FAIL 0 / 崩溃 0
Skipped 98 | 业务 310 | 基建 105 | 未分类 0
代价 SQL 执行 2970 次 / 18.264s / 平均 57.1 次 per case / 平均 351ms per case
grep "本块比对了" = 2 行(N 分别 3 / 3;声明报课行 3+2=5,加 RA-2 的一处扇出 = 6)
go build ./... OK | go vet stage2 OK | stage2 单测 OK | 未 push
Q 50分前
親
コミット
3744037dd5

+ 27 - 0
internal/academic/dualrun/fixture/mapping.go

@@ -40,6 +40,33 @@ func FixtureCanonicalUserID(legacyUserID int64) string {
 	return id.String()
 }
 
+// FixtureCanonicalStudentID 把 legacy 的**学生 int id**映射成确定性的学生 ULID。
+//
+// 与 `FixtureCanonicalUserID` 的关系:**同一个算法、不同的命名空间前缀**,因此
+// 同一个 int 在两侧得到的是**两个不同的 ULID**。
+//
+// 为什么必须分开(不是"装饰性"的两行常量):
+//
+//	`ac15-fixture:user:` 与 `ac15-fixture:student:` 是让"学生 id 与用户 id 串用"
+//	这类错**在 case 上变红**的唯一手段。ULID 本身不携带类型信息 —— 一旦两个命名空间
+//	合并成一个,"把 student_id 写成 user_id 的 ULID"在库里是**完全合法的 26 字符串**,
+//	查询照样命中、断言照样通过,这类错就**永久不可见**了。
+//
+// 所以:任何"这一列是学生"的种子都必须走本函数,任何"这一列是用户"的走
+// `FixtureCanonicalUserID`。混用会立刻表现为"JOIN 不上 / 候选集为空",
+// 而不是一个安静的错值。
+func FixtureCanonicalStudentID(legacyStudentID int64) string {
+	sum := sha256.Sum256([]byte(fmt.Sprintf("ac15-fixture:student:%d", legacyStudentID)))
+	var entropy [10]byte
+	copy(entropy[:], sum[:10])
+	id, err := ulid.New(fixtureEpochMs, bytes.NewReader(entropy[:]))
+	if err != nil {
+		// entropy 长度固定 10 字节,ulid.New 只会在熵不足时报错,这里不可达。
+		panic(fmt.Sprintf("fixture: 学生 ULID 生成失败:%v", err))
+	}
+	return id.String()
+}
+
 // moneyOrZero 把可空的 legacy 金额列统一成十进制字符串。
 //
 // 语义:legacy 侧该列 NULL(历史上没记过)→ 目标态 NOT NULL DEFAULT 0,

+ 61 - 1
internal/academic/dualrun/stage2/README.md

@@ -1485,4 +1485,64 @@ target  ORDER BY t2.first_start ASC, t1.id ASC        (有 tie-break,见 cou
 > 一条纪律留在这儿:3b 的两块基建断言(推荐人账户 / 课酬应收)**至今在运行输出里 `grep = 0`**。
 > 实测原因是**没有一条 case 声明 `RecommendUserID>0` 或 `StudentBizNos`**,两处 `mustCover` 的
 > 入口条件都不成立、整块代码路径没被走到。这不是"不需要",是"还没通电"。
-> ⇒ 3c 的验收硬指标:跑完 `grep "本块比对了"` **必须 ≥ 2**(每块各一条),且数字与 case 声明条数对上。
+> ⇒ 3c 的验收硬指标:跑完 `grep "本块比对了"` **≥ 1**(只有课酬应收块;推荐人块要到 3d 的 W4 才会激活),
+> 且断言里的 N 要能与 RA-1/RA-2 的声明条数对上(含排班扇出)。
+
+### 21.8 3c 落地:RA-1 / RA-2(50 → 52)
+
+#### 验收状态(这一段是给下一个人看的)
+
+| 块 | 状态 |
+|---|---|
+| 课酬应收(`SideSnapshot.Arrears`) | **已激活、已验收**:`grep "本块比对了"` = 2 行,N 分别为 3 / 3 |
+| 推荐人账户(`SideSnapshot.Recommender`) | **仍未激活,仍未验收**(RA-1/RA-2 不声明 `RecommendUserID`,那是 3d 的 W4) |
+
+> 上一句必须留着。否则下一轮看到"3b 已验收"会以为**两块**都验过了 ——
+> 这正是 3b 当时只能记"已就位"的原因,不要在 3c 上重演。
+
+#### RA-2 的真实终态(实测,不是从代码推的)
+
+`(course=990004, class=990014)` 两条排班、剩余应收 60.00,第二行另有 60.00,入参 200.00:
+
+```
+legacy  第 1 趟:ac15-ra2-0  net +6000 / rem −6000 → rem 0
+        第 2 趟:同一行再 `rem = rem − 6000` ⇒ rem = −60
+                列是 decimal(10,3) **unsigned** ⇒
+                Error 1264 (22003) Out of range value for column 'remainder_receivable'
+                ⇒ **整个 CALL 中断**,ac15-ra2-1 一分钱没拿到
+        终态:net +6000(**部分写入**:报错前已落库)/ ra2-1 完全未动
+target  MIN(start_time) 折平 ⇒ 60 + 60 = 120.00,两行都核完,不报错
+```
+
+三条结论:
+
+1. **legacy 的后果比"重复核销"重得多**:它是**一行崩掉整个循环**,后面的报课行**全部漏核**。
+   所以这条 case 的判定标签是 INTENTIONAL-STRICT —— 我们不是"更严",是**避免了一次会让用户少拿 140.00 的中断**。
+2. **必须按"部分写入"写断言**:报错发生在第一趟 UPDATE **之后**,所以不能写成"legacy 没动"。
+   本 case 的形态是「legacy 报 1264、终态 = 第一趟写入后的状态」,并且逐行钉住 ra2-1 = 0。
+3. **可复现**:同配置连跑两次,终态逐字段一致(+6000 / 0)。不一致就该删掉这条 case。
+
+#### 这一批新增的两个"防自伤"点
+
+- **F22 的正向断言**:「本 case 的钱**真的动了**(两侧 `ΣΔnet_receipts > 0` 且 `ΣΔremainder < 0`),
+  全 0 ⇒ **FAIL**」。它挡的是 `mustCover` **挡不住**的那条路:快照读的是
+  `WHERE biz_no IN (...)`(无机构过滤),而候选集由 `BaseRepo.Scoped` 追加 `institution_id`
+  过滤 —— 种子机构与 case 不一致时,快照照读到 3 行、`mustCover` 照通过,而两侧都 no-op。
+  ⇒ **"什么都没发生"必须是 FAIL,不能是 PASS。**
+- **`PRECISION-CONVERGENCE` 的业务断言**:种子两列的第 3 位小数恒为 0。
+  它把"2 位比较无损失"从**假设**变成**断言**(快照里看不到第 3 位,不读原始精度就无法验证)。
+  文案**限定"本 case 的种子"**:对真实数据不是无损的(`net_receipts` 2687 行 /
+  `remainder_receivable` 17 行第 3 位非零,并集 2694 行 = 6.4%)。
+  实现上多读两列(`ArrearsRow.RemainderRaw3` / `NetRaw3`),**不参与比对口径**。
+
+#### F22 台账(结构性缺口,后续批次逐支补)
+
+> **改 `Scoped` 等于改 SP 语义。** `counteract_arrears_course` 的候选集过滤原本写在 dump 的
+> WHERE 里(`studentid/schoolid/campusid`),新实现里 `institution_id`/`campus_id` 那两条
+> 由 `repository/base.go:90` 的 `Scoped` 提供 —— 它是**所有仓储共用**的基类代码。
+> 语义等价(`institution_id` ↔ `schoolid` 迁移 1:1),但**执行位置变了**:哪天有人为别的端点
+> 放宽/收紧 `Scoped`,这支 SP 的候选集跟着变,而双跑**看不出来**(种子机构恰好等于 case 机构时,
+> 两种口径吃得下同一批行)。
+>
+> ⇒ **凡候选集由 repo 层过滤提供的 SP,双跑都缺一层"候选集非空/钱真的动了"的正向断言。**
+> 这是结构性缺口,不是 RA 两支的局部问题。

+ 46 - 4
internal/academic/dualrun/stage2/activity_snapshot.go

@@ -613,7 +613,22 @@ func parseInt64(s string) (int64, error) {
 
 // readRound2 是**2 位口径**的金额读法(迁移比较器:先 CAST 到 3 位定标,再 ROUND 到 2 位)。
 //
-// ⚠ 不要把它当成"通用金额读法":`t_courses_activity_user_discount.discount_amount_used`
+// 处置标签:**`PRECISION-CONVERGENCE`**(不是"两个函数碰巧一样")。
+//
+// 为什么这条注释必须说清损失,而不是写"无损":
+//
+//	legacy 侧 `t_courses_student.remainder_receivable` / `net_receipts` 是
+//	`decimal(10,3)`,target 侧是 `decimal(12,2)` —— **两侧小数位本来就不同**。
+//	`readArrears*` 对两侧都用本函数,是**迁移收敛口径**(3 位 → 2 位),不是巧合。
+//
+//	代价是可量化的:本函数的**分辨力是 0.01**,两侧相差 **<0.005** 的分歧看不见。
+//	在**本 case 的种子**上这没有损失(种子全是整分,第 3 位恒为 0,由
+//	`precisionNoLossAssertion` 显式钉住);但对**真实数据不是无损** ——
+//	实测 `net_receipts` 有 2687 行、`remainder_receivable` 有 17 行第 3 位非零
+//	(并集 2694 行 / 42050 = 6.4%,最大形态 `3236.545`)。
+//	所以这里既不能写"两侧碰巧一样",也不能写"无损"。
+//
+// ⚠ 另一件事:不要把它当成"通用金额读法":`t_courses_activity_user_discount.discount_amount_used`
 // 在旧库是 `float(10,3)`,必须走 `readRound3`。两列同表不同精度,
 // 抽一个公共函数就会把那列静默降成 2 位 —— 而且不会报错,只会让比对变松。
 func readRound2(col string) string {
@@ -829,6 +844,14 @@ type ArrearsRow struct {
 	ConfirmDiscountSet  bool
 	// StartTime 是 cursor 的排序键(`t_courses_class.start_time`),已格式化成可比较字符串。
 	StartTime string
+	// RemainderRaw3 / NetRaw3 是两列的**原始 3 位小数字符串**(不经 ROUND)。
+	//
+	// 为什么必须多读这两列:2 位快照**看不到第 3 位**,所以"2 位比较无信息损失"
+	// 这个前提在快照里无法被验证。多读一次原始精度,才能把它从"假设"变成"断言"
+	// (`arrearsCompare` 里的 `PRECISION-CONVERGENCE` 那条)。
+	// 只用于断言,**不参与任何比对口径** —— 参与就会把 3 位差异当成实现分歧。
+	RemainderRaw3 string
+	NetRaw3       string
 }
 
 // readArrearsLegacy 读 legacy 侧的课酬应收,**按核销 cursor 的顺序**。
@@ -852,6 +875,8 @@ func readArrearsLegacy(ctx context.Context, tx *gorm.DB, c Case) ([]ArrearsRow,
 SELECT t1.id,
        `+readRound2("t1.remainder_receivable")+`,
        `+readRound2("t1.net_receipts")+`,
+       CAST(t1.remainder_receivable AS DECIMAL(20,3)),
+       CAST(t1.net_receipts AS DECIMAL(20,3)),
        IF(IFNULL(t1.confirm_discount_userid,0) > 0, 1, 0),
        IFNULL(DATE_FORMAT(t2.start_time,'%Y-%m-%d %H:%i:%s'), '0000-00-00 00:00:00')
   FROM t_courses_student t1
@@ -866,12 +891,14 @@ SELECT t1.id,
 	for rows.Next() {
 		var r ArrearsRow
 		var rem, net sql.NullString
+		var rem3, net3 sql.NullString
 		var confirm int64
-		if err := rows.Scan(&r.AnchorBizNo, &rem, &net, &confirm, &r.StartTime); err != nil {
+		if err := rows.Scan(&r.AnchorBizNo, &rem, &net, &rem3, &net3, &confirm, &r.StartTime); err != nil {
 			return nil, err
 		}
 		r.RemainderReceivable, _ = centsOrNull(rem)
 		r.NetReceipts, _ = centsOrNull(net)
+		r.RemainderRaw3, r.NetRaw3 = rem3.String, net3.String
 		r.ConfirmDiscountSet = confirm == 1
 		out = append(out, r)
 	}
@@ -887,6 +914,8 @@ func readArrearsTarget(ctx context.Context, tx *gorm.DB, c Case) ([]ArrearsRow,
 SELECT t1.biz_no, t1.id,
        `+readRound2("t1.remainder_receivable")+`,
        `+readRound2("t1.net_receipts")+`,
+       CAST(t1.remainder_receivable AS DECIMAL(20,3)),
+       CAST(t1.net_receipts AS DECIMAL(20,3)),
        IF(t1.confirm_discount_user_id IS NULL OR t1.confirm_discount_user_id='', 0, 1),
        IFNULL(DATE_FORMAT(t2.start_time,'%Y-%m-%d %H:%i:%s'), '0000-00-00 00:00:00')
   FROM t_courses_student t1
@@ -901,12 +930,14 @@ SELECT t1.biz_no, t1.id,
 	for rows.Next() {
 		var r ArrearsRow
 		var rem, net sql.NullString
+		var rem3, net3 sql.NullString
 		var confirm int64
-		if err := rows.Scan(&r.AnchorBizNo, &r.CoursesStudentID, &rem, &net, &confirm, &r.StartTime); err != nil {
+		if err := rows.Scan(&r.AnchorBizNo, &r.CoursesStudentID, &rem, &net, &rem3, &net3, &confirm, &r.StartTime); err != nil {
 			return nil, err
 		}
 		r.RemainderReceivable, _ = centsOrNull(rem)
 		r.NetReceipts, _ = centsOrNull(net)
+		r.RemainderRaw3, r.NetRaw3 = rem3.String, net3.String
 		r.ConfirmDiscountSet = confirm == 1
 		out = append(out, r)
 	}
@@ -957,11 +988,22 @@ SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2)                     AS DECIM
 	return g.account(), true, nil
 }
 
-// studentBizNos 安全地取 case 声明的报课单号(`c.Activity` 为 nil 时返回 nil)。
+// studentBizNos 安全地取 case 声明的报课单号(两侧都为空时返回 nil)。
+//
+// 两个来源、**互斥**:
+//   - `c.Arrears`(课酬核销,SP27):锚是 `t_courses_student.biz_no`;
+//   - `c.Activity.StudentBizNos`(活动链,老带新):锚是同一张表,但由活动种子声明。
 //
 // 为什么要这个包装:`c.Activity` 只对"涉及活动域的 case"非 nil,
 // 直接写 `c.Activity.StudentBizNos` 会对另外 30 多条 case **空指针崩**(本轮真踩了一次)。
 func studentBizNos(c Case) []string {
+	if c.Arrears != nil {
+		out := make([]string, 0, len(c.Arrears.Rows))
+		for _, r := range c.Arrears.Rows {
+			out = append(out, r.BizNo)
+		}
+		return out
+	}
 	if c.Activity == nil {
 		return nil
 	}

+ 372 - 0
internal/academic/dualrun/stage2/arrears_compare.go

@@ -0,0 +1,372 @@
+package stage2
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+)
+
+// 本文件是**课酬应收核销**(SP27)的比对与断言。
+//
+// 为什么不能复用通用比对:通用比对的主语是"资金账户的 5 个增量"(Δbalance / Δspend /
+// Δtotal_income / Δtotal_expenditure / Δspecial_expenses),而这一支**一分钱都不动资金表**,
+// 它改的是 `t_courses_student` 的两列(`net_receipts +` / `remainder_receivable −`)。
+// 把这一支塞进通用比对,只会得到"Δ 全是 0 ⇒ 两侧相等"那种**假绿**:
+// SP 一行都没改,比对照样通过。
+//
+// 所以这里的比对主语句是:**每一行报课记录被核了多少钱**。顺序即语义 ——
+// 游标 `ORDER BY start_time ASC` 决定"先核哪一行",而"先核哪一行"决定钱落到谁身上。
+
+// isArrearsOp 标记走课酬域比对的 Op。
+func isArrearsOp(op Op) bool { return op == OpCounteractArrears }
+
+// arrearsRowDelta 是一条报课记录本次的两列增量(分)。
+type arrearsRowDelta struct {
+	NetDelta int64 // net_receipts 增量(SP 只加)
+	RemDelta int64 // remainder_receivable 增量(SP 只减)
+	// Occurrences 是该 biz_no 在快照里出现的次数(= 该 `(courses,class)` 的排班行数)。
+	//
+	// 为什么记它:`readArrears*` 是 `LEFT JOIN t_courses_class`,同一报课行有几条排班
+	// 就出现几次(RA-2 就是这么构造的)。值本身在每次出现时**都相同**(同一行),
+	// 所以增量不能按出现次数累加 —— 那会把"一行"算成"多行"。
+	Occurrences int
+}
+
+// arrearsSnapshotDeltas 从 before/after 两份快照里算出"每条报课行改了多少"。
+//
+// 三条实现约束:
+//  1. **按 `biz_no` 归并**,不按出现次数累加(见 `arrearsRowDelta.Occurrences`);
+//  2. 同一 `biz_no` 的多次出现必须**读数完全一致**,否则说明 JOIN 扇到了不同的行 ——
+//     那是更严重的错(种子写错),要直接报出来而不是取第一个了事;
+//  3. `before` 缺失(没种上)⇒ 报错。**不允许**把"before 没有"当成 0:
+//     那会把"种子没种进去"伪装成"增加了全额"。
+func arrearsSnapshotDeltas(snapBefore, snapAfter SideSnapshot, side string) (map[string]arrearsRowDelta, error) {
+	beforeByBiz, err := arrearsIndex(snapBefore.Arrears, side+" before")
+	if err != nil {
+		return nil, err
+	}
+	afterByBiz, err := arrearsIndex(snapAfter.Arrears, side+" after")
+	if err != nil {
+		return nil, err
+	}
+	out := make(map[string]arrearsRowDelta, len(beforeByBiz))
+	for biz, b := range beforeByBiz {
+		a, ok := afterByBiz[biz]
+		if !ok {
+			return nil, fmt.Errorf("%s 侧 after 快照里找不到 biz_no=%q(before 有)", side, biz)
+		}
+		out[biz] = arrearsRowDelta{
+			NetDelta:    a.net - b.net,
+			RemDelta:    a.rem - b.rem,
+			Occurrences: b.n,
+		}
+	}
+	for biz := range afterByBiz {
+		if _, ok := beforeByBiz[biz]; !ok {
+			return nil, fmt.Errorf("%s 侧 after 快照里多出 biz_no=%q(before 没有):"+
+				"说明本 case 期间**新建了报课行**,而 SP27 只 UPDATE 不 INSERT", side, biz)
+		}
+	}
+	return out, nil
+}
+
+// arrearsAgg 是同一 `biz_no` 的归并读数。
+type arrearsAgg struct {
+	net int64
+	rem int64
+	n   int
+}
+
+// arrearsIndex 把快照里的应收行按 `biz_no` 归并,并校验多次出现的读数一致。
+func arrearsIndex(rows []ArrearsRow, label string) (map[string]arrearsAgg, error) {
+	out := make(map[string]arrearsAgg, len(rows))
+	for _, r := range rows {
+		cur, seen := out[r.AnchorBizNo]
+		if !seen {
+			out[r.AnchorBizNo] = arrearsAgg{net: r.NetReceipts, rem: r.RemainderReceivable, n: 1}
+			continue
+		}
+		if cur.net != r.NetReceipts || cur.rem != r.RemainderReceivable {
+			return nil, fmt.Errorf("%s:biz_no=%q 出现多次但读数不同(net %d vs %d、rem %d vs %d)—— "+
+				"说明 JOIN 扇到了不同的行,种子写法有问题",
+				label, r.AnchorBizNo, cur.net, r.NetReceipts, cur.rem, r.RemainderReceivable)
+		}
+		cur.n++
+		out[r.AnchorBizNo] = cur
+	}
+	return out, nil
+}
+
+// arrearsExpectedDeltas 按**种子声明**模拟一遍 SP 语义,算出每行应被核多少。
+//
+// 为什么期望值从声明算、不写死:
+//
+//	写死数字的 case 只能证明"这一次跑出来了这个数",改种子以后期望值不会跟着变,
+//	于是 case 要么假红(期望没跟上),要么被顺手改成新数字(期望跟着实现跑)。
+//	从声明算,改种子后期望自动跟着变,而"算错了"才会红。
+//
+// 语义(逐行对齐 dump):
+//
+//	remaining = inputMoney
+//	每行:IF remaining<=0 THEN LEAVE(**停止整个循环**,不是 continue)
+//	      IF remaining < remainder THEN remainder = remaining(截到剩余额度)
+//	      net += remainder;rem -= remainder;remaining -= remainder
+//
+// `fanout=true` 表示"同一报课行因排班重复被游标取出多次"(legacy 的 JOIN 扇出)。
+// 那种情况下第二趟读到的是**游标物化时的旧值**还是**更新后的新值**,由存储引擎决定,
+// 读代码无法确定 ⇒ 本函数只对 `fanout=false`(target 折平)给期望,
+// 扇出形态的 legacy 期望由 case 显式声明(`ArrearsLegacyNetDelta`)。
+func arrearsExpectedDeltas(c Case) map[string]arrearsRowDelta {
+	out := make(map[string]arrearsRowDelta, arrearsDeclaredRows(c))
+	if c.Arrears == nil {
+		return out
+	}
+	remaining := c.AmountCents
+	for _, r := range c.Arrears.Rows {
+		d := out[r.BizNo]
+		d.Occurrences = len(r.classStartTimes())
+		if remaining <= 0 {
+			out[r.BizNo] = d
+			continue
+		}
+		// 游标的两个过滤条件。**顺序不影响结果**,但两条都必须在这里体现:
+		// 少写一条,期望就会把"本该被排除的行"算成被核销 ⇒ 假红;
+		// 写反(该排除的没排除)⇒ 假绿。两者都只在这一处收口。
+		if r.ConfirmDiscountUserID <= 0 {
+			out[r.BizNo] = d
+			continue
+		}
+		if r.RemainderReceivableCents <= 0 {
+			out[r.BizNo] = d
+			continue
+		}
+		take := r.RemainderReceivableCents
+		if remaining < take {
+			take = remaining
+		}
+		d.NetDelta += take
+		d.RemDelta -= take
+		out[r.BizNo] = d
+		remaining -= take
+	}
+	return out
+}
+
+// arrearsCompare 是课酬核销的比对入口。
+func arrearsCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
+	var diffs []Diff
+	var asserts []Assertion
+
+	add := func(item, l, t string, equal bool, note string) {
+		diffs = append(diffs, Diff{Item: item, Legacy: l, Target: t, Equal: equal, Note: note})
+	}
+
+	lgD, lgErr := arrearsSnapshotDeltas(lg.Before, lg.After, "legacy")
+	tgD, tgErr := arrearsSnapshotDeltas(tg.Before, tg.After, "target")
+	if lgErr != nil || tgErr != nil {
+		asserts = append(asserts, Assertion{Kind: AssertionInfrastructure,
+			Name:   "课酬域快照可归并(before/after 的 biz_no 集合一致、扇出读数一致)",
+			Pass:   false,
+			Detail: fmt.Sprintf("legacy=%v | target=%v", lgErr, tgErr),
+		})
+		return diffs, asserts
+	}
+
+	want := arrearsExpectedDeltas(c)
+
+	// ---- ① 逐行:两侧与声明 -------------------------------
+
+	// 按声明顺序遍历,报告里也是这个顺序(= 游标顺序 = 业务语义)。
+	for _, r := range c.Arrears.Rows {
+		w := want[r.BizNo]
+		l := lgD[r.BizNo]
+		t := tgD[r.BizNo]
+		add(fmt.Sprintf("核销明细 · %s(start=%s)Δnet_receipts", r.BizNo, r.StartTime),
+			fmt.Sprintf("%+d", l.NetDelta), fmt.Sprintf("%+d", t.NetDelta),
+			l.NetDelta == t.NetDelta || c.IntentionalStrict, "单位:分")
+		add(fmt.Sprintf("核销明细 · %s Δremainder_receivable", r.BizNo),
+			fmt.Sprintf("%+d", l.RemDelta), fmt.Sprintf("%+d", t.RemDelta),
+			l.RemDelta == t.RemDelta || c.IntentionalStrict, "单位:分")
+
+		// legacy 侧:扇出形态要按 case 声明的期望,而不是 target 的折平期望。
+		// 判据是"有没有**显式声明** legacy 期望",不是"这一行有几条排班" ——
+		// 因为扇出会**中断整个循环**:后面那些自己只有 1 条排班的行同样拿不到钱
+		// (RA-2 的 ac15-ra2-1 就是这种:排班 1 条,但期望是 0)。
+		lWantNet, lWantRem := w.NetDelta, w.RemDelta
+		fanout := len(r.classStartTimes()) > 1
+		if c.ArrearsLegacyNetDelta != nil {
+			lWantNet = c.ArrearsLegacyNetDelta[r.BizNo]
+			lWantRem = -lWantNet // SP 的净额与应收减额永远等额反向
+		}
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+			Name: fmt.Sprintf("legacy 逐行核销额 = 声明期望(%s)", r.BizNo),
+			Pass: l.NetDelta == lWantNet && l.RemDelta == lWantRem,
+			Detail: fmt.Sprintf("实测 net %+d / rem %+d | 期望 net %+d / rem %+d | 排班 %d 条%s",
+				l.NetDelta, l.RemDelta, lWantNet, lWantRem, len(r.classStartTimes()),
+				ternaryStr(fanout, "(1:N 扇出,期望来自 case 显式声明)", "")),
+		})
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+			Name: fmt.Sprintf("target 逐行核销额 = 声明期望(%s)", r.BizNo),
+			Pass: t.NetDelta == w.NetDelta && t.RemDelta == w.RemDelta,
+			Detail: fmt.Sprintf("实测 net %+d / rem %+d | 期望 net %+d / rem %+d(target 按 MIN(start_time) 折平,一行只核一次)",
+				t.NetDelta, t.RemDelta, w.NetDelta, w.RemDelta),
+		})
+	}
+
+	// ---- ② 两侧总量与"钱真的动了"(F22 的正向断言)-----------------
+	//
+	// 为什么必须是正向断言、而且必须判 FAIL:
+	//
+	//	"什么都没发生"在两侧对称时**长得和成功一模一样**。会让它发生的路径至少有两条:
+	//	  · 种子机构与 case 不一致 ⇒ `BaseRepo.Scoped` 追加的 institution_id 过滤把候选集清空;
+	//	  · `start_time` 全并列/全被过滤 ⇒ 游标一轮都不进。
+	//	这两条下 `mustCover`(读快照条数)照样通过,因为快照读的是 `WHERE biz_no IN (...)`,
+	//	根本没有机构过滤(见 F22)。所以必须单独断言"净资产真的动了"。
+	var lgNet, lgRem, tgNet, tgRem int64
+	for _, d := range lgD {
+		lgNet += d.NetDelta
+		lgRem += d.RemDelta
+	}
+	for _, d := range tgD {
+		tgNet += d.NetDelta
+		tgRem += d.RemDelta
+	}
+	add("核销总额 ΣΔnet_receipts", fmt.Sprintf("%+d", lgNet), fmt.Sprintf("%+d", tgNet),
+		lgNet == tgNet || c.IntentionalStrict, "两侧应等额(除非 1:N 扇出)")
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+		Name: "本 case 的钱**真的动了**(两侧 ΣΔnet_receipts > 0 且 ΣΔremainder < 0;全 0 ⇒ FAIL)",
+		Pass: lgNet > 0 && tgNet > 0 && lgRem < 0 && tgRem < 0,
+		Detail: fmt.Sprintf("legacy net %+d / rem %+d | target net %+d / rem %+d。"+
+			"若两侧全 0:先查种子机构与 case 是否一致(`Scoped` 会追加 institution_id 过滤),"+
+			"再查 start_time 是否被过滤掉 —— 这两种都是**空过**,不是通过",
+			lgNet, lgRem, tgNet, tgRem),
+	})
+
+	// ---- ③ 逐侧不变量:净额与应收减额等额反向,且不超投入 --------------
+	//
+	// `ΣΔnet == −ΣΔrem` 是 SP 那句 UPDATE 的定义(两列同一条语句),
+	// 它能在**任何**输入下成立,包括扇出、包括部分写入 ⇒ 与"两侧一致"正交,两条都要有。
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+		Name:   "逐侧守恒:ΣΔnet_receipts = −ΣΔremainder_receivable(两列同一条 UPDATE)",
+		Pass:   lgNet == -lgRem && tgNet == -tgRem,
+		Detail: fmt.Sprintf("legacy %+d vs %+d | target %+d vs %+d", lgNet, -lgRem, tgNet, -tgRem),
+	})
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+		Name: "逐侧不超投入:ΣΔnet_receipts ≤ 入参金额(SP 逐行截断,不会核超)",
+		Pass: lgNet <= c.AmountCents && tgNet <= c.AmountCents,
+		Detail: fmt.Sprintf("入参 %d 分 | legacy 核销 %d | target 核销 %d",
+			c.AmountCents, lgNet, tgNet),
+	})
+
+	// ---- ④ 快照形状:每条报课行的排班时间集合两侧一致 ------------------
+	//
+	// 它证明的是"种子两侧同构"(基建性质),但必须**在这里**存在:
+	// 两侧排班不一致会让顺序不同,而顺序不同就会让"钱落到哪行"不同 ——
+	// 那种红看起来像实现差异,实际是种子写错。
+	lgStarts := arrearsStartTimesByBiz(lg.Before)
+	tgStarts := arrearsStartTimesByBiz(tg.Before)
+	bizKeys := make([]string, 0, len(lgStarts))
+	for k := range lgStarts {
+		bizKeys = append(bizKeys, k)
+	}
+	sort.Strings(bizKeys)
+	shapeOK := true
+	var shapeDetail strings.Builder
+	for _, biz := range bizKeys {
+		l, t := lgStarts[biz], tgStarts[biz]
+		same := len(l) == len(t)
+		if same {
+			for i := range l {
+				if l[i] != t[i] {
+					same = false
+					break
+				}
+			}
+		}
+		shapeOK = shapeOK && same
+		fmt.Fprintf(&shapeDetail, "%s: legacy %v / target %v;", biz, l, t)
+	}
+	asserts = append(asserts, Assertion{Kind: AssertionInfrastructure,
+		Name: "种子同构:两侧每条报课行的排班时间集合一致(否则顺序不可比)",
+		Pass: shapeOK, Detail: shapeDetail.String(),
+	})
+
+	// ---- ⑤ 精度口径(PRECISION-CONVERGENCE)--------------------------
+	//
+	// 两侧小数位**本来就不同**(legacy 列 `decimal(10,3)`、target 列 `decimal(12,2)`),
+	// 而 `readRound2` 在两侧都用 —— 那是**迁移收敛口径**,不是"两个函数碰巧一样"。
+	//
+	// 代价可量化:2 位读法分辨力 0.01,**两侧相差 <0.005 的分歧看不见**。
+	// 在**本 case 的种子**上无损失(种子全是整分),所以这条断言把"无损失"从假设变成事实;
+	// 但对**真实数据**不是无损的(实测 `net_receipts` 2687 行 / `remainder_receivable` 17 行
+	// 第 3 位非零,并集 2694 行 = 6.4%)—— 所以文案必须限定"本 case 的种子",
+	// 写成通用命题就是拿恒真判据当发现(§18.3 第 1 条同族)。
+	lgNZ, lgN := thirdDecimalNonZero(lg.Before.Arrears)
+	tgNZ, tgN := thirdDecimalNonZero(tg.Before.Arrears)
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+		Name: "精度口径:**本 case 种子**的两列第 3 位小数恒为 0 ⇒ 2 位比较无信息损失(PRECISION-CONVERGENCE)",
+		Pass: lgNZ == 0 && tgNZ == 0 && lgN > 0 && tgN > 0,
+		Detail: fmt.Sprintf("legacy 非 2 位 %d/%d 行 | target 非 2 位 %d/%d 行 | 声明 %d 条报课行。"+
+			"两侧小数位本就不同(3 vs 2),本断言只覆盖**种子**;对真实数据 2 位读法有损(并集 2694 行 / 6.4%%)",
+			lgNZ, lgN, tgNZ, tgN, arrearsDeclaredRows(c)),
+	})
+
+	// ---- ⑥ 调用结果 ------------------------------------------------
+	lgOK := len(lg.Calls) > 0 && lg.Calls[0].Success
+	tgOK := len(tg.Calls) > 0 && tg.Calls[0].Success
+	add("首次调用是否成功", fmt.Sprint(lgOK), fmt.Sprint(tgOK), lgOK == tgOK || c.IntentionalStrict, "")
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+		Name: "首次调用结果与预期一致(success / 或按声明的报错)",
+		Pass: lgOK == c.ExpectLegacySuccess && tgOK == c.ExpectTargetSuccess,
+		Detail: fmt.Sprintf("legacy success=%v(期望 %v,err=%q);target success=%v(期望 %v,err=%q)",
+			lgOK, c.ExpectLegacySuccess, errOf(lg.Calls), tgOK, c.ExpectTargetSuccess, errOf(tg.Calls)),
+	})
+
+	return diffs, asserts
+}
+
+// arrearsStartTimesByBiz 取每条报课行在快照里出现的全部上课时间(升序)。
+//
+// 用 `before` 快照取(SP 不动 `t_courses_class`,before/after 应完全一致;
+// 用 before 是因为它一定在 CALL 之前,不会被 SP 的任何副作用影响)。
+func arrearsStartTimesByBiz(snap SideSnapshot) map[string][]string {
+	out := make(map[string][]string, len(snap.Arrears))
+	for _, r := range snap.Arrears {
+		out[r.AnchorBizNo] = append(out[r.AnchorBizNo], r.StartTime)
+	}
+	for k := range out {
+		sort.Strings(out[k])
+	}
+	return out
+}
+
+// thirdDecimalNonZero 数出"第 3 位小数非零"的行数(*2 列都算,按行计一次*)。
+//
+// 判据直接看 `DECIMAL(20,3)` 字符串的第 3 位,不做浮点比较:
+// 浮点比较会在 0.005 附近抖动,而这正是本函数要判断的那个位。
+func thirdDecimalNonZero(rows []ArrearsRow) (nonZero, total int64) {
+	total = int64(len(rows))
+	for _, r := range rows {
+		if hasNonZeroThirdDecimal(r.RemainderRaw3) || hasNonZeroThirdDecimal(r.NetRaw3) {
+			nonZero++
+		}
+	}
+	return nonZero, total
+}
+
+// hasNonZeroThirdDecimal 判断一个 `DECIMAL(20,3)` 形态的字符串第 3 位小数是否非零。
+func hasNonZeroThirdDecimal(raw string) bool {
+	i := strings.IndexByte(raw, '.')
+	if i < 0 || len(raw) < i+4 {
+		return false // 不是 3 位小数形态(含空串/NULL)⇒ 不声称"非零",由 total 覆盖
+	}
+	return raw[i+3] != '0'
+}
+
+// ternaryStr 是"字符串三元"的小工具(Go 没有三元表达式,而这个包里已经有两个同族函数了)。
+func ternaryStr(cond bool, a, b string) string {
+	if cond {
+		return a
+	}
+	return b
+}

+ 336 - 0
internal/academic/dualrun/stage2/arrears_seed.go

@@ -0,0 +1,336 @@
+package stage2
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"time"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/dualrun/fixture"
+)
+
+// 本文件是**课酬应收核销**(`sp_courses_student_counteract_arrears_course`)的两侧种子。
+//
+// 为什么要单独一个文件、而不是并进 `c1_activity.go`:
+// 活动域的种子是"一张活动矩阵 + 报名行",这里的种子是"两条**互为键**的行"
+// (`t_courses_student` 靠 `(coursesid,classid)` JOIN `t_courses_class`)。
+// 两者唯一的共同点是"都要在 before 快照之前种下去",逻辑没有交集。
+//
+// 三个硬约束(都来自 §21 的实测,不是约定):
+//
+//  1. **两侧都要种**:target 库整库为空,比对锚是 `biz_no`。只种一侧 ⇒ 另一侧读不到 ⇒
+//     `mustCover` FAIL(这是对的,见 §21.3)。
+//  2. **同一学生下 `start_time` 必须两两不同**:legacy 的 `ORDER BY t2.start_time ASC`
+//     **没有 tie-break**,target 是 `first_start, t1.id`。并列时两侧的循环顺序可能不同,
+//     而顺序决定"钱落到哪一行" ⇒ 并列等于不可复现(§21.3 实测真实数据 87.5% 并列)。
+//  3. **`(coursesid, classid)` 必须与 legacy 真实数据不撞**:撞了 JOIN 会扇出,
+//     而扇出正好是 RA-2 要单独验的形态 —— 不能在别的 case 里偷偷发生。
+//     所以种子用 9xxxxx 高位号段,并在插入前**显式查一次**(撞了就报错,不静默)。
+
+// ArrearsSeed 是一组"欠费报课行"的声明。
+//
+// 语义:每一行 = 一条 `t_courses_student` + 它对应的 `t_courses_class` 排班行。
+type ArrearsSeed struct {
+	Rows []ArrearsSeedRow
+}
+
+// ArrearsSeedRow 是一条报课行(`t_courses_student` 一行)。
+type ArrearsSeedRow struct {
+	// BizNo 是 `t_courses_student` 的业务号,两侧**同一个字符串**。
+	//
+	// legacy 落 `id`(varchar(32),旧 UUID 空间),target 落 `biz_no`。
+	// 快照比对就以它为锚(§19:不能用 target 的自增 `id`,两侧不可比)。
+	BizNo string
+	// CoursesID / ClassID 是 JOIN 键(**两个都要相等**才算同一门课的排班)。
+	//
+	// ⚠ 它们不是 `t_courses_class.id`:SP 的 JOIN 条件是
+	// `t1.coursesid=t2.coursesid AND t1.classid=t2.classid`,所以两侧的 class 行
+	// 自增 id 不需要对齐(§21.2)。
+	CoursesID int64
+	ClassID   int64
+	// StartTime 是首条排班的上课时间,格式 `2006-01-02 15:04:05`(整秒,两侧同精度读回)。
+	StartTime string
+	// ExtraStartTimes 是**同一 `(CoursesID, ClassID)` 的额外排班行**(RA-2 专用)。
+	//
+	// 非空 ⇒ 该 `(courses,class)` 对在 `t_courses_class` 里有多行 ⇒ legacy 游标会
+	// **把同一条报课行取出多次**(JOIN 扇出)。target 用 `MIN(start_time)` 折平,只取一次。
+	// 这是 `defensive flattening`(证据强度 B:schema 合法、真实数据 0/6173),
+	// 不是"已知缺陷"—— 见 §21.6 与 F21。
+	ExtraStartTimes []string
+	// RemainderReceivableCents 是剩余应收(分)。SP 只减不加。
+	RemainderReceivableCents int64
+	// NetReceiptsCents 是已实收(分)。SP 只加不减。
+	NetReceiptsCents int64
+	// ConfirmDiscountUserID 是"确认学生折扣的用户"(legacy int / target ULID)。
+	//
+	// **0 = 没有确认人**:legacy 落 0,target 落 NULL。
+	// 游标的 `confirm_discount_userid>0` 会把这种行排除掉 —— 这是本 case 要测的两个过滤条件之一。
+	ConfirmDiscountUserID int64
+}
+
+// classStartTimes 返回该行要插的**全部**排班时间(首条 + 额外)。
+func (r ArrearsSeedRow) classStartTimes() []string {
+	out := make([]string, 0, 1+len(r.ExtraStartTimes))
+	out = append(out, r.StartTime)
+	out = append(out, r.ExtraStartTimes...)
+	return out
+}
+
+// arrearsDeclaredRows 返回声明的报课行数(`t_courses_student` 行数)。
+func arrearsDeclaredRows(c Case) int {
+	if c.Arrears == nil {
+		return 0
+	}
+	return len(c.Arrears.Rows)
+}
+
+// arrearsExpectedSnapshotRows 返回**快照期望行数**(含 JOIN 扇出)。
+//
+// ⚠ 它和 `arrearsDeclaredRows` 在 RA-2 上**不相等**:`readArrears*` 是
+// `LEFT JOIN t_courses_class`,同一报课行有几条排班就出现几次。
+// `mustCover` 要用这个数,否则 RA-2 会因为"多出来两行"而假红 ——
+// 而如果为了消红把判据放宽成 `n > 0`,就等于放弃了"条数对得上"这条检出能力。
+func arrearsExpectedSnapshotRows(c Case) int {
+	if c.Arrears == nil {
+		return 0
+	}
+	n := 0
+	for _, r := range c.Arrears.Rows {
+		n += len(r.classStartTimes())
+	}
+	return n
+}
+
+// arrearsSnapshotRowsOrDefault 返回 `mustCover` 该用的期望条数:
+// 课酬 case 用"含扇出的快照行数",其它走 `studentBizNos`(活动链沿用旧口径,不改)。
+func arrearsSnapshotRowsOrDefault(c Case) int {
+	if c.Arrears != nil {
+		return arrearsExpectedSnapshotRows(c)
+	}
+	return len(studentBizNos(c))
+}
+
+// arrearsDeclaredRowsOrDefault 返回报告文案里的"声明条数"(不含扇出)。
+func arrearsDeclaredRowsOrDefault(c Case) int {
+	if c.Arrears != nil {
+		return arrearsDeclaredRows(c)
+	}
+	return len(studentBizNos(c))
+}
+
+// validateArrearsSeed 在**动库之前**把种子的自洽性查一遍。
+//
+// 为什么要在 Go 里查、不靠 MySQL 报错:
+//   - `start_time` 并列**不会**报错,只会让顺序不确定 ⇒ 必须自己拒绝;
+//   - `BizNo` 空会让两侧的锚都变成空串,快照照样"相等"(都是空)⇒ 必须自己拒绝。
+func validateArrearsSeed(c Case) error {
+	if c.Arrears == nil {
+		return nil
+	}
+	if len(c.Arrears.Rows) == 0 {
+		return fmt.Errorf("case %q 声明了 Arrears 但没有任何行", c.Name)
+	}
+	if c.LegacyStudentID <= 0 {
+		return fmt.Errorf("case %q 声明了 Arrears 但 LegacyStudentID=%d(必须 >0)",
+			c.Name, c.LegacyStudentID)
+	}
+	seenBiz := make(map[string]bool, len(c.Arrears.Rows))
+	seenStart := make(map[string]string, len(c.Arrears.Rows))
+	var prevStart time.Time
+	for i, r := range c.Arrears.Rows {
+		if strings.TrimSpace(r.BizNo) == "" {
+			return fmt.Errorf("case %q 第 %d 行 BizNo 为空(锚不能是空串)", c.Name, i)
+		}
+		if seenBiz[r.BizNo] {
+			return fmt.Errorf("case %q 第 %d 行 BizNo=%q 重复", c.Name, i, r.BizNo)
+		}
+		seenBiz[r.BizNo] = true
+		if r.CoursesID <= 0 || r.ClassID <= 0 {
+			return fmt.Errorf("case %q 第 %d 行 (coursesid,classid)=(%d,%d) 必须都 >0",
+				c.Name, i, r.CoursesID, r.ClassID)
+		}
+		if r.RemainderReceivableCents < 0 {
+			return fmt.Errorf("case %q 第 %d 行 remainder=%d 为负;legacy 列是 "+
+				"decimal(10,3) **unsigned**,存不下负数", c.Name, i, r.RemainderReceivableCents)
+		}
+		for _, st := range r.classStartTimes() {
+			t, err := parseArrearsTime(st)
+			if err != nil {
+				return fmt.Errorf("case %q 第 %d 行 start_time=%q 解析失败:%w", c.Name, i, st, err)
+			}
+			key := t.Format("2006-01-02 15:04:05")
+			if prev, dup := seenStart[key]; dup {
+				// 并列 ⇒ legacy 侧顺序由执行计划决定 ⇒ 本 case 不可复现(§21.3)。
+				return fmt.Errorf("case %q 里 start_time=%q 出现两次(%s 与第 %d 行):"+
+					"legacy `ORDER BY t2.start_time ASC` **没有 tie-break**,并列时顺序不确定 ⇒ 不可复现",
+					c.Name, key, prev, i)
+			}
+			// 声明顺序必须**严格升序**:`arrearsExpectedDeltas` 是按声明顺序模拟游标的,
+			// 声明顺序错了,期望值就会算到另一行头上 —— 而那种红看起来像实现错。
+			if !prevStart.IsZero() && !t.After(prevStart) {
+				return fmt.Errorf("case %q 第 %d 行 start_time=%q 没有严格晚于上一行的 %q:"+
+					"种子必须按游标顺序(start_time ASC)声明,否则从声明推期望会算错行",
+					c.Name, i, key, prevStart.Format("2006-01-02 15:04:05"))
+			}
+			prevStart = t
+			seenStart[key] = fmt.Sprintf("第 %d 行 %s", i, r.BizNo)
+		}
+	}
+	return nil
+}
+
+// parseArrearsTime 解析排班时间(整秒,两侧都按 `%Y-%m-%d %H:%i:%s` 读回)。
+func parseArrearsTime(s string) (time.Time, error) {
+	return time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(s), time.Local)
+}
+
+// arrearsMoney 把分转成两位小数字符串。
+//
+// 为什么是 2 位而不是 3 位:target 那两列是 `decimal(12,2)`,写 3 位会被 MySQL 静默收敛;
+// 而我们的种子全是整分,2 位已无损。**这与"读回用 2 位"是同一件事的两端**,见
+// `activity_snapshot.go` 的 `PRECISION-CONVERGENCE` 注释。
+func arrearsMoney(cents int64) string {
+	sign := ""
+	if cents < 0 {
+		sign, cents = "-", -cents
+	}
+	return fmt.Sprintf("%s%d.%02d", sign, cents/100, cents%100)
+}
+
+// arrearsCreateTime 是种子的 `create_time`(固定值,保证两侧与多次运行逐列可比)。
+const arrearsCreateTime = "2001-01-01 00:00:00"
+
+// seedLegacyArrears 在 legacy 事务内种 `t_courses_class` + `t_courses_student`。
+//
+// 必须在 before 快照**之前**调用,否则 before 读不到这些行,"变化量"会算错。
+func seedLegacyArrears(ctx context.Context, tx *gorm.DB, c Case) error {
+	if c.Arrears == nil {
+		return nil
+	}
+	if err := validateArrearsSeed(c); err != nil {
+		return err
+	}
+	if err := guardArrearsPairsUnused(ctx, tx, c, "legacy", `
+SELECT COUNT(*) FROM t_courses_class WHERE coursesid = ? AND classid = ?`); err != nil {
+		return err
+	}
+	for _, r := range c.Arrears.Rows {
+		for _, st := range r.classStartTimes() {
+			t, err := parseArrearsTime(st)
+			if err != nil {
+				return err
+			}
+			// `end_time` 在 legacy 是 NOT NULL 无默认 ⇒ 必须给;「上课 1.5 小时」只是个
+			// 不会参与任何判据的占位值(游标只读 start_time)。
+			if err := tx.WithContext(ctx).Exec(
+				`INSERT INTO t_courses_class (coursesid, classid, start_time, end_time) VALUES (?, ?, ?, ?)`,
+				r.CoursesID, r.ClassID, t.Format("2006-01-02 15:04:05"),
+				t.Add(90*time.Minute).Format("2006-01-02 15:04:05")).Error; err != nil {
+				return fmt.Errorf("种 legacy t_courses_class 失败(course=%d class=%d start=%s):%w",
+					r.CoursesID, r.ClassID, st, err)
+			}
+		}
+		// legacy 的金额列是 NOT NULL **无默认**(receivable/net_receipts/unpaid/lesson_cost/
+		// discount_cash/remainder_cost/remainder_receivable 共 7 列)⇒ 必须逐个给值。
+		// `unpaid` 与 `lesson_cost` / `discount_cash` / `remainder_cost` SP 都不碰,给 0 即可
+		// —— 但它们仍要写,否则 INSERT 报 1364。
+		if err := tx.WithContext(ctx).Exec(`
+INSERT INTO t_courses_student
+  (id, coursesid, classid, schoolid, campusid, studentid, receivable, net_receipts, unpaid,
+   lesson_cost, discount_cash, remainder_cost, remainder_receivable, confirm_discount_userid,
+   number, status, type, create_time)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+			r.BizNo, r.CoursesID, r.ClassID, c.InstitutionID, c.CampusID, c.LegacyStudentID,
+			arrearsMoney(r.RemainderReceivableCents), arrearsMoney(r.NetReceiptsCents),
+			"0.00", "0.00", "0.00", "0.00", arrearsMoney(r.RemainderReceivableCents),
+			r.ConfirmDiscountUserID, 0, 1, 1, arrearsCreateTime).Error; err != nil {
+			return fmt.Errorf("种 legacy t_courses_student 失败(biz_no=%s):%w", r.BizNo, err)
+		}
+	}
+	return nil
+}
+
+// seedTargetArrears 在 target 事务内种同一批行(`biz_no` 两侧同值)。
+func seedTargetArrears(ctx context.Context, tx *gorm.DB, c Case) error {
+	if c.Arrears == nil {
+		return nil
+	}
+	if err := validateArrearsSeed(c); err != nil {
+		return err
+	}
+	if err := guardArrearsPairsUnused(ctx, tx, c, "target", `
+SELECT COUNT(*) FROM t_courses_class WHERE courses_id = ? AND class_id = ?`); err != nil {
+		return err
+	}
+	studentUID := fixture.FixtureCanonicalStudentID(c.LegacyStudentID)
+	for _, r := range c.Arrears.Rows {
+		for _, st := range r.classStartTimes() {
+			t, err := parseArrearsTime(st)
+			if err != nil {
+				return err
+			}
+			if err := tx.WithContext(ctx).Exec(
+				`INSERT INTO t_courses_class (courses_id, class_id, start_time, end_time) VALUES (?, ?, ?, ?)`,
+				r.CoursesID, r.ClassID, t.Format("2006-01-02 15:04:05"),
+				t.Add(90*time.Minute).Format("2006-01-02 15:04:05")).Error; err != nil {
+				return fmt.Errorf("种 target t_courses_class 失败(course=%d class=%d start=%s):%w",
+					r.CoursesID, r.ClassID, st, err)
+			}
+		}
+		// `confirm_discount_user_id` 是 **ULID 字符串且可空**:
+		//   0(legacy 的"没有确认人")⇒ target 落 **NULL**(不是空串、不是 "0")。
+		// 空串与 NULL 在 target 的查询里都被判成"无确认人",但落库值不同,
+		// 用 NULL 与迁移口径一致(迁移器对 0 的处理就是 NULL)。
+		var confirm any
+		if r.ConfirmDiscountUserID > 0 {
+			confirm = fixture.FixtureCanonicalUserID(r.ConfirmDiscountUserID)
+		}
+		if err := tx.WithContext(ctx).Exec(`
+INSERT INTO t_courses_student
+  (biz_no, courses_id, class_id, institution_id, campus_id, student_id, receivable, net_receipts,
+   unpaid, lesson_cost, discount_cash, remainder_cost, remainder_receivable,
+   confirm_discount_user_id, number, status, type, create_time)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+			r.BizNo, r.CoursesID, r.ClassID, c.InstitutionID, c.CampusID, studentUID,
+			arrearsMoney(r.RemainderReceivableCents), arrearsMoney(r.NetReceiptsCents),
+			"0.00", "0.00", "0.00", "0.00", arrearsMoney(r.RemainderReceivableCents),
+			confirm, 0, 1, 1, arrearsCreateTime).Error; err != nil {
+			return fmt.Errorf("种 target t_courses_student 失败(biz_no=%s):%w", r.BizNo, err)
+		}
+	}
+	return nil
+}
+
+// guardArrearsPairsUnused 确认本 case 要用的 `(coursesid, classid)` 在库里**还不存在**。
+//
+// 为什么必须查(不能只靠"用高位号段"这个约定):
+//
+//	`t_courses_class` 没有 `(coursesid,classid)` 唯一索引(§21.6 实测:6173 行 0 组重复,
+//	是**数据**层面的唯一,不是 schema 层面的)。一旦撞上真实行,JOIN 立刻扇出,
+//	而扇出正是 RA-2 要单独验的形态 —— 撞在别的 case 里会让那条 case 悄悄变成第二条 RA-2,
+//	并且没人知道为什么钱多核了一份。
+//
+// 参数:countSQL 必须是 `SELECT COUNT(*) ... WHERE <pair>` 的单条语句(两侧列名不同)。
+func guardArrearsPairsUnused(ctx context.Context, tx *gorm.DB, c Case, side, countSQL string) error {
+	seen := make(map[[2]int64]bool, len(c.Arrears.Rows))
+	for _, r := range c.Arrears.Rows {
+		pair := [2]int64{r.CoursesID, r.ClassID}
+		if seen[pair] {
+			continue // 同一对多条排班是 RA-2 的**声明意图**,由 validate 的 start_time 唯一性保证
+		}
+		seen[pair] = true
+		var n int64
+		if err := tx.WithContext(ctx).Raw(countSQL, r.CoursesID, r.ClassID).Scan(&n).Error; err != nil {
+			return fmt.Errorf("查 %s 侧 (course=%d class=%d) 是否已存在失败:%w",
+				side, r.CoursesID, r.ClassID, err)
+		}
+		if n > 0 {
+			return fmt.Errorf("%s 侧 (coursesid,classid)=(%d,%d) 已有 %d 行排班:"+
+				"JOIN 会扇出,本 case 的期望值不再成立。换一对号段,或删掉这条 case",
+				side, r.CoursesID, r.ClassID, n)
+		}
+	}
+	return nil
+}

+ 14 - 2
internal/academic/dualrun/stage2/c2_refund_test.go

@@ -295,8 +295,20 @@ func TestCustomRefundCaseSet(t *testing.T) {
 		}
 	}
 	// 25(A/B/C1 期)+ 10(C2:C2-1、C2-1b、C2-2…C2-6、C2-8、C2-9、C2-10)
-	if len(all) != 50 {
-		t.Errorf("DefaultCases 长度应为 50,实得 %d —— 分母变了就要重新核一遍是不是漏了 case", len(all))
+	// + 15(3a 活动链:W1 报名 2 / W2 额度消耗 2 / W3 赠送额度 3,加上 C1 期已并入的 8 条)
+	// + 2(3c 课酬核销:RA-1 / RA-2)
+	//
+	// 为什么 52 是对的(改这个数字的人必须能说清多出来的两条各证明什么):
+	//   · RA-1 证明**游标的三个行为**同时成立 —— `confirm_discount_userid>0` 过滤、
+	//     `ORDER BY start_time ASC` 顺序、`IF inputMoney<remainder` 截断;
+	//     它的反例行(无确认人)**故意放在最早课次**,因为只有排在前面,
+	//     "过滤条件被写漏"才会表现为"钱落到错的行走廊"。
+	//   · RA-2 证明 target 的 `MIN(start_time)` 折平这处**既有裁决**被钉住了:
+	//     同一 `(course,class)` 两条排班时 legacy 会扇出、target 只核一次。
+	//     它的定位是「锁住裁决」,**不是**「复现 legacy 缺陷」——
+	//     该形态在 6173 行真实数据里 0 例(证据强度 B,F21)。
+	if len(all) != 52 {
+		t.Errorf("DefaultCases 长度应为 52(50 + RA-1 + RA-2),实得 %d —— 分母变了就要重新核一遍是不是漏了 case", len(all))
 	}
 	if c2 != 10 {
 		t.Errorf("C2 应恰好 10 条(E 探针不在此列),实得 %d", c2)

+ 33 - 0
internal/academic/dualrun/stage2/case.go

@@ -107,6 +107,12 @@ const (
 	// OpCancelRechargeActivity 取消充值活动(SP8):balance−、total_income−(双表),
 	// 报名状态置 **0**(不是 4),1 条流水 type=209;机构 id 由**校区**反查(SP 无 schoolid 入参)。
 	OpCancelRechargeActivity
+	// OpCounteractArrears 课酬应收核销(SP27,4 参):把"被负数抵消"的钱补回欠费报课行。
+	//
+	// 与前 21 支的根本区别:它**不动资金表**(`t_user_expense*` 一分不动),
+	// 只改 `t_courses_student` 的 `net_receipts` / `remainder_receivable` 两列。
+	// 所以比对必须走 `arrearsCompare`,通用比对的 Δbalance 对它结构性不适用。
+	OpCounteractArrears
 )
 
 // String 返回可读名字(报告用)。
@@ -144,6 +150,8 @@ func (o Op) String() string {
 		return "sp_courses_activity_user_discount_refund_user_activity"
 	case OpCancelRechargeActivity:
 		return "sp_user_expense_cancel_recharge_activity"
+	case OpCounteractArrears:
+		return "sp_courses_student_counteract_arrears_course"
 	default:
 		return "sp_user_expense_school_expenditure"
 	}
@@ -261,6 +269,31 @@ type Case struct {
 	// **不要在外面设它**:由 RunOne 填,避免两边用不同 id。
 	ActivityPlan *ActivityPlan
 
+	// LegacyStudentID 是 legacy `t_courses_student.studentid`(学生 int id),仅课酬核销用。
+	//
+	// 它与 `LegacyUserID` 是**两个不同的实体**:核销按"学生"定位,不按"用户"。
+	// target 侧要把它换成**学生** ULID(`fixture.FixtureCanonicalStudentID`),
+	// 不能复用 `CanonicalUIDFor`(那是用户命名空间,见 fixture/mapping.go 的注释)。
+	LegacyStudentID int64
+	// Arrears 非 nil 时,两侧各自在事务内种 `t_courses_student` + `t_courses_class`。
+	//
+	// 为什么必须**两侧都种**:target 库整库为空,而比对锚是 `biz_no`。
+	// 只种一侧 ⇒ 另一侧读不到行 ⇒ `mustCover` 会 FAIL(这正是我们要的,见 §21.3)。
+	Arrears *ArrearsSeed
+	// ArrearsLegacyNetDelta 是"1:N 扇出形态下 legacy 侧每行的 net_receipts 增量(分)"。
+	//
+	// 只在这两种情况下填:
+	//   - 某条报课行的 `ExtraStartTimes` 非空(同一 `(courses,class)` 有多条排班 ⇒ 游标扇出);
+	//   - 该形态下 legacy 的终态**不能从声明推出**(第二趟读到的是游标物化旧值还是更新后新值,
+	//     由存储引擎决定;也可能因 `remainder_receivable` 是 **unsigned** 而直接 1690 越界)。
+	//
+	// 所以这不是"期望值",是**观测记录**:值来自第一次真实运行,机制解释写在 case 的 Note 里,
+	// 并且必须能连跑两次复现。`nil` = 与 target 折平后的期望相同。
+	//
+	// ⚠ 不许把"legacy 实测值"直接当成"正确值"来写实现 —— 它只用来**钉住分歧的形态**,
+	// 判定标签是 INTENTIONAL-STRICT(我们更严/更稳),不是"legacy 是对的"。
+	ArrearsLegacyNetDelta map[string]int64
+
 	// IntentionalStrict true = 这个 case **两侧不应一致**,而是"legacy 静默 / Go 显式拒绝"。
 	// 用于 R19(campusid<1 → SP LEAVE 静默 no-op,Go 报 3140)。
 	// 这类 case 不参与"两侧相等"判定,只断言双方各自的既定行为都发生。

+ 17 - 0
internal/academic/dualrun/stage2/legacy.go

@@ -67,6 +67,12 @@ const (
 	callUserDiscountRefund = "CALL sp_courses_activity_user_discount_refund(?, ?, ?)"
 	// 按活动退回:(userid, activityid, amount, memo) —— 4 参
 	callUserDiscountRefundByActivity = "CALL sp_courses_activity_user_discount_refund_user_activity(?, ?, ?, ?)"
+
+	// SP27 课酬应收核销:(studentid, schoolid, campusid, money) —— 4 参,**没有结果集**。
+	//
+	// ⚠ 第一个参数是**学生** id,不是 userid。这一支与资金域全部 21 支的根本区别就在这里:
+	//   钱一分不流动,只改 `t_courses_student` 的两列。
+	callCounteractArrears = "CALL sp_courses_student_counteract_arrears_course(?, ?, ?, ?)"
 )
 
 // returnsResultSet 标记"该 SP 会 SELECT 出结果集"。
@@ -140,6 +146,9 @@ func callArgs(c Case) []any {
 		return []any{c.StuCourseID, moneyYuan, c.Remark}
 	case OpUserDiscountRefundByActivity:
 		return []any{c.LegacyUserID, c.ActivityID, moneyYuan, c.Remark}
+	case OpCounteractArrears:
+		// SP27:(studentid, schoolid, campusid, money) —— 第一个是**学生**,不是 userid。
+		return []any{c.LegacyStudentID, c.InstitutionID, c.CampusID, moneyYuan}
 	default:
 		return []any{c.LegacyUserID, c.InstitutionID, c.CampusID, moneyYuan,
 			c.Type, c.RequestID, c.Remark, c.LegacyUserID}
@@ -181,6 +190,8 @@ func callStmt(op Op) string {
 		return callUserDiscountRefund
 	case OpUserDiscountRefundByActivity:
 		return callUserDiscountRefundByActivity
+	case OpCounteractArrears:
+		return callCounteractArrears
 	default:
 		return callExpenditure
 	}
@@ -217,6 +228,12 @@ UPDATE t_user_expense_school SET balance = NULL
 		return nil, err
 	}
 
+	// 课酬应收种子(SP27)。同样必须在 before 快照之前 ——
+	// 这条 case 比的就是"两列的增量",before 读不到行就等于把增量算成"凭空出现的余额"。
+	if err := seedLegacyArrears(ctx, tx, c); err != nil {
+		return nil, err
+	}
+
 	before, maxLogID, err := snapshotLegacy(ctx, tx, c)
 	if err != nil {
 		return nil, err

+ 108 - 3
internal/academic/dualrun/stage2/run.go

@@ -723,6 +723,98 @@ func DefaultCases(_ string) []Case {
 		},
 	)
 
+	// ---- 批 3c:W4 课酬应收核销(SP27),2 条 ----
+	//
+	// 为什么这一批必须**两侧都种**:target 库整库为空,而比对锚是 `biz_no`;
+	// 只种 legacy 一侧 ⇒ target 读到 0 行 ⇒ `mustCover` FAIL(这正是想要的结果,见 §21.3)。
+	// 为什么 `start_time` 全是 2001 年、且两两不同:legacy 侧 `ORDER BY t2.start_time ASC`
+	// **没有 tie-break**,并列就不可复现(§21.3 实测真实数据 87.5% 并列)。
+	out = append(out,
+		Case{
+			// RA-1 = 一条 case 同时钉三件事:**过滤条件 / 顺序 / 截断**。
+			//
+			// 反例行(`confirm=0` 但 `remainder>0`)**故意放在最早课次**:
+			// 只有当它排在前面时,"过滤条件被写漏"才会表现为"钱落到错的行走廊";
+			// 排在后面的话正确行先吃饱,过滤写漏也看不出来(§21.5)。
+			Name: "RA-1 课酬核销·过滤+顺序+截断(无确认人最早课次必须被跳过错)",
+			Op:   OpCounteractArrears, LegacyUserID: 17735, LegacyStudentID: 900001,
+			InstitutionID: 63317, CampusID: 5, AmountCents: 10000,
+			Arrears: &ArrearsSeed{Rows: []ArrearsSeedRow{
+				{
+					// 最早课次,但没有确认人 ⇒ 游标 `confirm_discount_userid>0` 必须把它排除。
+					// 若这一条被纳入,它会先吃掉 50.00,后面两行拿到的钱全变 ⇒ 一眼可辨。
+					BizNo: "ac15-ra1-0", CoursesID: 990001, ClassID: 990011,
+					StartTime:                "2001-03-01 08:00:00",
+					RemainderReceivableCents: 5000, ConfirmDiscountUserID: 0,
+				},
+				{
+					BizNo: "ac15-ra1-1", CoursesID: 990002, ClassID: 990012,
+					StartTime:                "2001-03-02 08:00:00",
+					RemainderReceivableCents: 8000, ConfirmDiscountUserID: 17735,
+				},
+				{
+					BizNo: "ac15-ra1-2", CoursesID: 990003, ClassID: 990013,
+					StartTime:                "2001-03-03 08:00:00",
+					RemainderReceivableCents: 4000, ConfirmDiscountUserID: 17735,
+				},
+			}},
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "入参 100.00,三行剩余应收 50.00(**无确认人**)/ 80.00 / 40.00,按 start_time 升序。" +
+				"逐行对齐 dump 的游标语义:① 第一行被 `confirm_discount_userid>0` 排除(**过滤**);" +
+				"② 第二行 80.00 全额核销(**顺序**:最早的可核销课次先拿钱);" +
+				"③ 第二行吃完只剩 20.00,第三行应收 40.00 ⇒ `IF inputMoney<remainder THEN remainder=inputMoney` 截断为 20.00(**截断**)。" +
+				"终态:第一行 net/rem 不动,第二行 net +80.00 / rem 0,第三行 net +20.00 / rem 20.00。" +
+				"`BaseRepo.Scoped` 会追加 `institution_id`/`campus_id` 过滤(F22),故种子机构必须与 case 一致",
+		},
+		Case{
+			// RA-2 = 锁住 target 的 `MIN(start_time)` 折平这处**既有裁决**。
+			//
+			// 定位是「锁住裁决」,**不是**「复现 legacy 缺陷」:唯一能让同一条报课行被核两次的
+			// 路径是 `t_courses_class` 同 `(coursesid,classid)` 多行(schema 允许、无唯一索引),
+			// 而该形态在 6173 行真实数据里 **0 组** ⇒ 证据强度 B(schema 合法、数据未观测,F21)。
+			// 价值在于:有人把 target 简化回普通 JOIN 时,这条 case 会红。
+			Name: "RA-2 课酬核销·同一(course,class)两条排班(legacy 扇出 vs target 折平 == INTENTIONAL-STRICT)",
+			Op:   OpCounteractArrears, LegacyUserID: 17735, LegacyStudentID: 900002,
+			InstitutionID: 63317, CampusID: 5, AmountCents: 20000,
+			Arrears: &ArrearsSeed{Rows: []ArrearsSeedRow{
+				{
+					// 同一 `(coursesid,classid)` 两条排班 ⇒ legacy 游标把**这一条报课行**取出两次。
+					BizNo: "ac15-ra2-0", CoursesID: 990004, ClassID: 990014,
+					StartTime:                "2001-04-01 08:00:00",
+					ExtraStartTimes:          []string{"2001-04-02 08:00:00"},
+					RemainderReceivableCents: 6000, ConfirmDiscountUserID: 17735,
+				},
+				{
+					BizNo: "ac15-ra2-1", CoursesID: 990005, ClassID: 990015,
+					StartTime:                "2001-04-03 08:00:00",
+					RemainderReceivableCents: 6000, ConfirmDiscountUserID: 17735,
+				},
+			}},
+			// 真实终态(实测,两次跑一致):
+			//   legacy 第一趟 ac15-ra2-0 net +60.00 / rem 0;第二趟同一行再减 60.00 时
+			//   `remainder_receivable` 是 **decimal(10,3) unsigned** ⇒
+			//   `Error 1264 (22003): Out of range value for column 'remainder_receivable' at row 1`
+			//   ⇒ **整个 CALL 中断**,`ac15-ra2-1` 一分钱没拿到(**部分写入**:ra2-0 的 +60.00 已落)。
+			//   即:legacy 不只"重复核销",它是**一行崩掉整个循环,后面的报课行全部漏核**。
+			// target 折平后正常核完两行(60 + 60 = 120.00),不报错。
+			ArrearsLegacyNetDelta: map[string]int64{"ac15-ra2-0": 6000, "ac15-ra2-1": 0},
+			ExpectTargetSuccess:   true,
+			// legacy 侧**必然失败**(1264),且这是"这条 case 要钉住的分歧"本身,不是偶发错误。
+			ExpectLegacySuccess: false,
+			IntentionalStrict:   true,
+			Note: "**证据强度 B(schema 合法、数据未观测 0/6173)**,定位 = 锁住既有裁决,不是验证 legacy 缺陷(F21)。" +
+				"第一行 `(course=990004,class=990014)` 有两条不同 `start_time` 的排班行 ⇒ legacy 游标把它**取出两次**。" +
+				"【期望值来源】legacy 实测(本条落地那次运行,两次复跑终态一致)。" +
+				"【机制解释】第二趟对同一行再执行 `SET remainder_receivable = remainder_receivable - 60.00`," +
+				"而该列在旧库是 `decimal(10,3) **unsigned**`(`t_courses_student` 第 22 列)⇒ 减到 −60 越界 ⇒ " +
+				"`Error 1264 (22003)` 抛出,游标循环**中断**,`ac15-ra2-1` 完全没被处理。" +
+				"【可复现性】同配置连跑两次终态逐字段一致(legacy net +60.00 / ra2-1 0)。" +
+				"⚠ **部分写入**:ra2-0 的 +60.00 已经落库后才报错 —— 断言不能写成「legacy 没动」," +
+				"本 case 按「legacy 报 1264、终态 = 第一趟写入后的状态」判定。" +
+				"target 用 `MIN(start_time) GROUP BY (courses_id,class_id)` 折平 ⇒ 只核一次、两行都处理完。",
+		},
+	)
+
 	// ---- 消费(第二批已做,留作回归)----
 	base := Case{
 		Op: OpExpenditure, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
@@ -931,15 +1023,21 @@ func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
 			fmt.Sprintf("legacy 读到=%v target 读到=%v(推荐人 userid=%d)",
 				lg.Before.HasRecommender, tg.Before.HasRecommender, c.RecommendUserID)))
 	}
-	if want := len(studentBizNos(c)); want > 0 {
+	if want := arrearsSnapshotRowsOrDefault(c); want > 0 {
 		n := minInt(len(lg.Before.Arrears), len(tg.Before.Arrears))
 		res.Assertions = append(res.Assertions, mustCover(
 			"case 有效性前提:**课酬应收两侧都读到且条数与声明一致**(按核销 cursor 顺序)",
 			n, want,
-			fmt.Sprintf("声明 %d 条,legacy 读到 %d、target 读到 %d",
-				want, len(lg.Before.Arrears), len(tg.Before.Arrears))))
+			fmt.Sprintf("声明 %d 条报课行、期望快照 %d 行(含排班扇出),legacy 读到 %d、target 读到 %d",
+				arrearsDeclaredRowsOrDefault(c), want, len(lg.Before.Arrears), len(tg.Before.Arrears))))
 	}
 
+	// ①-d 精度口径的**业务断言**在 `arrearsCompare` 里(它已经拿到了 `Before` 快照的原始 3 位列)。
+	//
+	// 为什么不在 run.go 里再查一次库:这些种子行在**事务内**,而 run.go 的通用断言块跑在
+	// 两侧各自 `Rollback()` 之后 ⇒ 再查只会得到 0 行(本轮真踩了:`SUM(...)` 返回 NULL,
+	// 断言红成了"读第 3 位失败",而 case 本身是绿的)。快照里多读两列才是对的位置。
+
 	// ①-b 赠送额度的退回:游标**按订单号**、**不带 userid**
 	//(`WHERE courses_studentid=? AND discount_amount_used<0 ORDER BY id DESC`)。
 	// 旧库真有 162 行额度流水,只要订单号撞上真实行,两侧看到的行集天然不同 ——
@@ -1078,6 +1176,13 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		return activityCompare(c, lg, tg)
 	}
 
+	// ⓪-1b 课酬核销(SP27):同样**不写资金**,只改 `t_courses_student` 两列 ⇒ 走课酬域比对。
+	// 顺序在活动链之后无所谓(两组 Op 不相交),但放在这里是为了让"哪一类走哪条比对"
+	// 在一屏内可读完。
+	if isArrearsOp(c.Op) {
+		return arrearsCompare(c, lg, tg)
+	}
+
 	// ⓪-1 SP9 自定义退费:legacy 把金额整数化、target 用精确值 ⇒ **per-side 比对**
 	// (通用比对的"两侧 Δ 相等"在这一支上必然红,因为两侧本来就是两个算法)。
 	//

+ 32 - 0
internal/academic/dualrun/stage2/target.go

@@ -58,6 +58,10 @@ func (s *targetSide) Run(ctx context.Context, c Case, canonicalUID string) (*Sid
 	if err := seedTargetActivities(ctx, tx, c, canonicalUID); err != nil {
 		return nil, err
 	}
+	// 课酬应收种子(SP27):两侧同一批 `biz_no`,target 侧还要把学生 int id 换成**学生** ULID。
+	if err := seedTargetArrears(ctx, tx, c); err != nil {
+		return nil, err
+	}
 
 	before, maxLogID, err := snapshotTarget(ctx, tx, canonicalUID, c)
 	if err != nil {
@@ -93,6 +97,10 @@ func (s *targetSide) Run(ctx context.Context, c Case, canonicalUID string) (*Sid
 	// 两者都绑定**同一个 tx**,读写落在本次事务里,读完统一回滚。
 	actSvc := service.NewActivityService(tx, sc)
 	discSvc := service.NewActivityDiscountService(tx, sc)
+	// 课酬核销(SP27)挂在 MaintenanceService 上。它**不是**超管端点(端点 56),
+	// 所以不需要 `SuperAdmin=true`;但 `sc` 必须非 nil —— `BaseRepo.Scoped` 在
+	// `Sc == nil` 时直接 panic(fail-fast,无作用域禁止进数据访问层)。
+	maintSvc := service.NewMaintenanceService(tx, sc)
 
 	for i := 0; i < maxInt(1, c.RunTimes); i++ {
 		oc := CallOutcome{}
@@ -285,6 +293,30 @@ func (s *targetSide) Run(ctx context.Context, c Case, canonicalUID string) (*Sid
 				oc.Balance = int64(out.Balance)
 				oc.LogID = out.LogID
 			}
+		case OpCounteractArrears:
+			// SP27 → MaintenanceService.CounteractArrears。
+			//
+			// ⚠ 三处**必须知道**的口径:
+			//  1. `StudentID` 要的是**学生** ULID(`FixtureCanonicalStudentID`),
+			//     不是用户的 `canonicalUID` —— 用错会得到一个合法但查不到人的 ULID,
+			//     候选集为空 ⇒ 两侧都 no-op ⇒ 假绿(见 F22 / §21.6)。
+			//  2. 候选集由 `BaseRepo.Scoped` **追加** institution_id/campus_id 过滤,
+			//     所以种子的作用域必须与 case 一致(`seedTargetArrears` 直接用 c 的值)。
+			//  3. 返回的 `Remaining`(未用完额)legacy SP **不返回**,不能在两侧直接比;
+			//     比对口径用 `inputMoney − ΣΔnet_receipts`(两侧都算得出,见 arrears_compare.go)。
+			out, err := maintSvc.CounteractArrears(ctx, service.CounteractArrearsInput{
+				StudentID:     fixture.FixtureCanonicalStudentID(c.LegacyStudentID),
+				InstitutionID: c.InstitutionID,
+				CampusID:      c.CampusID,
+				Money:         money.Amount(c.AmountCents),
+			})
+			if err != nil {
+				setCallErr(&oc, err)
+			} else {
+				oc.Success = true
+				oc.Balance = int64(out.Applied)
+				oc.Repeated = !out.Remaining.IsZero()
+			}
 		default:
 			out, err := svc.Expenditure(ctx, service.ExpenditureInput{
 				UserID: canonicalUID, InstitutionID: c.InstitutionID, CampusID: c.CampusID,

+ 16 - 2
internal/academic/repository/course_repo.go

@@ -493,9 +493,23 @@ type ArrearsCandidate struct {
 //  1. `confirm_discount_userid > 0`:新模型该列是 ULID 字符串(*string),"有一个确认操作人"的
 //     等价判据是 `IS NOT NULL AND <> ”`;**NULL/空串 = 不可充值**(模型红线),故不能用 0 替代。
 //  2. `INNER JOIN t_courses_class` 是 1:N —— 同一 (courses_id, class_id) 有多条排班规则时会把
-//     **同一报课行**重复取出;旧 SP 游标物化快照后逐行核销,第二行会按"旧剩余应收"再核销一次
-//     → net_receipts 被重复累加(dump 缺陷,同类于 A9 的不递减类问题)。Go 侧改为
+//     **同一报课行**重复取出,旧 SP 游标逐行核销就会把这一行核多次。Go 侧改为
 //     `MIN(start_time)` 聚合子查询:**同一报课行只出现一次**,排序依据仍是课程开课时分(顺序不变)。
+//
+//     这一处是 **`defensive flattening`(防御性折平)**,不是对**已观测缺陷**的修正。依据(AC-15 实测):
+//     · 机制成立:`t_courses_class` 上**没有** `(coursesid, classid)` 唯一索引,
+//       所以"同一对多条排班"在 schema 上是合法形态;
+//     · 但真实数据 **0 例**:6173 行 `t_courses_class` 里 `(coursesid,classid)` 重复组数 = 0,
+//       且 `t_courses_student` 里 `(coursesid,classid,studentid)` 重复组数也是 0
+//       ⇒ "旧 SP 重复核销"这件事**从未在真实数据上发生过**;
+//     · 保留理由:schema 未禁止该形态,折平成本只是一个 `GROUP BY`;而如果哪天有人把这里
+//       简化回普通 JOIN,双跑 case RA-2 会红(`internal/academic/dualrun/stage2`)。
+//
+//     ⚠ 措辞上的红线:不要把它写成"dump 缺陷"。写"缺陷"会让人以为有真实受害数据、
+//     进而去别处找"修复前/后"的账目差 —— 那是找不到的,因为根本没有实例(F21)。
+//  3. 排序额外补了 `t1.id ASC`:旧 SP 只有 `ORDER BY t2.start_time ASC`,**没有 tie-break**。
+//     真实数据里 `(学生, start_time)` 分组 5535 组中有 4846 组并列(87.5%),
+//     并列时旧 SP 的"先核哪一行"由执行计划决定 ⇒ 补一个确定性次序,才能让结果可复现。
 func (r *CourseRepo) ListArrearsCandidates(ctx context.Context, studentID string, institutionID, campusID int64) ([]ArrearsCandidate, error) {
 	if strings.TrimSpace(studentID) == "" {
 		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListArrearsCandidates 需要 student_id"))