Explorar o código

refactor(ac15): ArrearsSeed 改 ByUser 单一真相源(为 W4-C 的两用户核销铺路)

## 一、不留兼容字段(照 team-lead 的判定)

`Rows` 删掉,`ByUser map[int64][]ArrearsSeedRow` 是唯一字段。
两个字段能表达同一件事,早晚有人只填一个、另一个空着而**没有任何东西报错**
(`ActivitySnapshot.Students` 那次同族)。RA-1/RA-2 机械迁移过去,
**54 条当场证明没改坏** —— 让测试承担迁移风险,比留兼容字段长期付利息便宜。

## 二、新增校验:ByUser 的 key 必须是**这条 Op 的 CALL 会真正传出去的 id**

第一版我写成"`LegacyUserID` 或 `RecommendUserID`",**当场把 RA-1 判红**:

```
case "RA-1 …" 的 Arrears.ByUser 含 user=900001,
但本 case 只声明了 LegacyUserID=17735 / RecommendUserID=0
```

这条红**正好证明校验在干活**:两个 SP 的第一个实参**不是同一种东西** ——

- `sp_courses_student_counteract_arrears_course(studentid, …)` → **学生** id(RA 用 `LegacyStudentID`)
- `sp_courses_activity_use_activity_recommend(userid, …)` → 它内部把这个值当 studentid 用(W4 用 `LegacyUserID`/`RecommendUserID`)

所以合法集合**按 Op 算**(`arrearsCallUserIDs`)。写成"任一声明过的 user"会让
"给课酬 case 种了一个 userid 的欠费行"这种错静默通过 —— 行种了、快照也读得到,但没有一次 CALL 会看它。

## 三、三处配套(team-lead 要求的)

- `studentBizNos` 摊平后**去重**(`want` 与快照行数才对得上);
- `arrearsDeclaredRows` / `arrearsExpectedSnapshotRows` 按用户摊平后计数(仍含扇出);
- `arrearsExpectedDeltas` **按用户各自算 `remaining`** —— 用一个全局 remaining 会把两个人的额度串成一份,
  而那正是"看起来对、其实错"的形态(W4-C 会直接踩)。

## 四、报数(粘贴;两个门禁分两行)

```
分母:DefaultCases=54 | 应跑 54 | 实跑 54
总览:54 个 case,54 PASS,0 FAIL
Skipped 98 | 业务 329 | 基建 113 | 未分类 0
代价 SQL 3202 次 / 18.973s / 59.3 次 per case / 351ms per case
grep "本块比对了" = 4

go build OK | go vet OK | go test OK
```

未 push。
Q hai 8 horas
pai
achega
6ea8060b7a

+ 10 - 3
internal/academic/dualrun/stage2/activity_snapshot.go

@@ -1030,9 +1030,16 @@ func readRecommenderAccounts(ctx context.Context, tx *gorm.DB, c Case, target bo
 // 直接写 `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)
+		// 按用户摊平后**去重**:同一 biz_no 出现在两个用户下面是不可能的(主键),
+		// 但去重能保证"`want` 与快照行数对得上"这件事不被重复声明悄悄破坏。
+		seen := make(map[string]bool, 4)
+		out := make([]string, 0, len(c.Arrears.flatRows()))
+		for _, e := range c.Arrears.flatRows() {
+			if seen[e.Row.BizNo] {
+				continue
+			}
+			seen[e.Row.BizNo] = true
+			out = append(out, e.Row.BizNo)
 		}
 		return out
 	}

+ 4 - 2
internal/academic/dualrun/stage2/arrears_compare.go

@@ -123,7 +123,8 @@ func arrearsExpectedDeltas(c Case) map[string]arrearsRowDelta {
 		return out
 	}
 	remaining := c.AmountCents
-	for _, r := range c.Arrears.Rows {
+	for _, e := range c.Arrears.flatRows() {
+		r := e.Row
 		d := out[r.BizNo]
 		d.Occurrences = len(r.classStartTimes())
 		if remaining <= 0 {
@@ -178,7 +179,8 @@ func arrearsCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	// ---- ① 逐行:两侧与声明 -------------------------------
 
 	// 按声明顺序遍历,报告里也是这个顺序(= 游标顺序 = 业务语义)。
-	for _, r := range c.Arrears.Rows {
+	for _, e := range c.Arrears.flatRows() {
+		r := e.Row
 		w := want[r.BizNo]
 		l := lgD[r.BizNo]
 		t := tgD[r.BizNo]

+ 118 - 21
internal/academic/dualrun/stage2/arrears_seed.go

@@ -3,6 +3,7 @@ package stage2
 import (
 	"context"
 	"fmt"
+	"sort"
 	"strings"
 	"time"
 
@@ -33,7 +34,53 @@ import (
 //
 // 语义:每一行 = 一条 `t_courses_student` + 它对应的 `t_courses_class` 排班行。
 type ArrearsSeed struct {
-	Rows []ArrearsSeedRow
+	// ByUser 是**唯一**真相源:user legacy id → 该用户的欠费行。
+	//
+	// 为什么不保留一个 `Rows` 兼容字段:两个字段能表达同一件事,
+	// 早晚有人只填一个、另一个空着,而且**不会有任何东西报错**
+	//(`ActivitySnapshot.Students` 那次就是有序/无序两个真相源,后来删掉了)。
+	//
+	// 为什么必须是"按用户"而不是"平铺 + 一个 LegacyStudentID":
+	//   · 课酬核销(RA-1/RA-2)是**单用户**,`inputMoney` 只有一份;
+	//   · 老带新(W4-C)**两个用户各调一次** `counteract_arrears_course`,
+	//     各自按各自的 `activityMoney` 上限核销 —— 平铺会把两个人的行混成一个候选集。
+	ByUser map[int64][]ArrearsSeedRow
+}
+
+// userIDs 返回声明过的用户(**升序**,保证遍历确定)。
+func (s *ArrearsSeed) userIDs() []int64 {
+	if s == nil {
+		return nil
+	}
+	out := make([]int64, 0, len(s.ByUser))
+	for u := range s.ByUser {
+		out = append(out, u)
+	}
+	sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
+	return out
+}
+
+// flatRows 把"用户 + 行"摊平,顺序 = 用户升序 × 该用户内的声明顺序。
+//
+// 摊平只用于"要遍历所有行"的场景(数条数、校验、落库);
+// **按用户的期望值**必须用 `ByUser[u]` 单独算 —— 否则两个人的 `inputMoney` 会串。
+func (s *ArrearsSeed) flatRows() []arrearsSeedEntry {
+	if s == nil {
+		return nil
+	}
+	var out []arrearsSeedEntry
+	for _, u := range s.userIDs() {
+		for _, r := range s.ByUser[u] {
+			out = append(out, arrearsSeedEntry{User: u, Row: r})
+		}
+	}
+	return out
+}
+
+// arrearsSeedEntry 是"某用户的一行欠费"。
+type arrearsSeedEntry struct {
+	User int64
+	Row  ArrearsSeedRow
 }
 
 // ArrearsSeedRow 是一条报课行(`t_courses_student` 一行)。
@@ -83,7 +130,7 @@ func arrearsDeclaredRows(c Case) int {
 	if c.Arrears == nil {
 		return 0
 	}
-	return len(c.Arrears.Rows)
+	return len(c.Arrears.flatRows())
 }
 
 // arrearsExpectedSnapshotRows 返回**快照期望行数**(含 JOIN 扇出)。
@@ -97,8 +144,8 @@ func arrearsExpectedSnapshotRows(c Case) int {
 		return 0
 	}
 	n := 0
-	for _, r := range c.Arrears.Rows {
-		n += len(r.classStartTimes())
+	for _, e := range c.Arrears.flatRows() {
+		n += len(e.Row.classStartTimes())
 	}
 	return n
 }
@@ -129,17 +176,43 @@ func validateArrearsSeed(c Case) error {
 	if c.Arrears == nil {
 		return nil
 	}
-	if len(c.Arrears.Rows) == 0 {
+	if len(c.Arrears.flatRows()) == 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 {
+	// ByUser 的 key 必须是**这条 Op 的 CALL 会真正传出去的那个 id** ——
+	// 否则"种了一个谁都不读的用户的欠费行"会静默存在:行种进去了、快照也读到了,
+	// 但没有任何一次 CALL 会看它。
+	//
+	// 注意这里**不是**"随便哪个声明过的 user 都行":两个 Op 的第一个实参根本不是同一种东西 ——
+	//   · `sp_courses_student_counteract_arrears_course(studentid, …)` → **学生** id(RA-1/RA-2 用 LegacyStudentID)
+	//   · `sp_courses_activity_use_activity_recommend(userid, …)` → 它内部把这个值当 studentid 用(W4 用 LegacyUserID / RecommendUserID)
+	// 所以合法集合**按 Op 算**。我第一版写成"LegacyUserID 或 RecommendUserID",
+	// 当场把 RA-1 判红 —— 那条错正好证明这条校验在干活。
+	allowed := arrearsCallUserIDs(c)
+	for _, u := range c.Arrears.userIDs() {
+		ok := false
+		for _, a := range allowed {
+			if u == a && a > 0 {
+				ok = true
+				break
+			}
+		}
+		if !ok {
+			return fmt.Errorf("case %q(Op=%s)的 Arrears.ByUser 含 id=%d,但本 case 的 CALL 只会传 %v —— "+
+				"种了也不会被任何一次 CALL 读到", c.Name, c.Op, u, allowed)
+		}
+	}
+	seenBiz := make(map[string]bool, len(c.Arrears.flatRows()))
+	seenStart := make(map[string]string, len(c.Arrears.flatRows()))
+	// start_time 的升序**按用户各自校验**:游标带 `studentid=?`,两个用户的排班时间
+	// 互不影响,跨用户比较会报出假错。
+	prevStart := make(map[int64]time.Time, len(c.Arrears.ByUser))
+	for i, e := range c.Arrears.flatRows() {
+		r := e.Row
 		if strings.TrimSpace(r.BizNo) == "" {
 			return fmt.Errorf("case %q 第 %d 行 BizNo 为空(锚不能是空串)", c.Name, i)
 		}
@@ -169,12 +242,13 @@ func validateArrearsSeed(c Case) error {
 			}
 			// 声明顺序必须**严格升序**:`arrearsExpectedDeltas` 是按声明顺序模拟游标的,
 			// 声明顺序错了,期望值就会算到另一行头上 —— 而那种红看起来像实现错。
-			if !prevStart.IsZero() && !t.After(prevStart) {
-				return fmt.Errorf("case %q 第 %d 行 start_time=%q 没有严格晚于上一行的 %q:"+
+			prev := prevStart[e.User]
+			if !prev.IsZero() && !t.After(prev) {
+				return fmt.Errorf("case %q(user=%d)第 %d 行 start_time=%q 没有严格晚于上一行的 %q:"+
 					"种子必须按游标顺序(start_time ASC)声明,否则从声明推期望会算错行",
-					c.Name, i, key, prevStart.Format("2006-01-02 15:04:05"))
+					c.Name, e.User, i, key, prev.Format("2006-01-02 15:04:05"))
 			}
-			prevStart = t
+			prevStart[e.User] = t
 			seenStart[key] = fmt.Sprintf("第 %d 行 %s", i, r.BizNo)
 		}
 	}
@@ -185,17 +259,18 @@ func validateArrearsSeed(c Case) error {
 	// 它就**静默通过**了,而实际上我们**从未记录过**这一行的观测值。
 	// 这正是 §18.3 第 0 条的同族:断言键写错 ⇒ 断言块整体"成立"但毫无意义。
 	if c.LegacyObservedNetDelta != nil {
-		for _, r := range c.Arrears.Rows {
+		for _, e := range c.Arrears.flatRows() {
+			r := e.Row
 			if _, ok := c.LegacyObservedNetDelta[r.BizNo]; !ok {
 				return fmt.Errorf("case %q 的 LegacyObservedNetDelta 缺少 biz_no=%q:"+
 					"tripwire 必须覆盖**全部**声明行(缺键会取到零值 0,等于静默把期望写成 0)",
 					c.Name, r.BizNo)
 			}
 		}
-		if len(c.LegacyObservedNetDelta) != len(c.Arrears.Rows) {
+		if len(c.LegacyObservedNetDelta) != len(c.Arrears.flatRows()) {
 			return fmt.Errorf("case %q 的 LegacyObservedNetDelta 有 %d 个键,但声明了 %d 行报课"+
 				"(多出来的键说明有 case 没删干净,留着会让人以为还有一行没被覆盖)",
-				c.Name, len(c.LegacyObservedNetDelta), len(c.Arrears.Rows))
+				c.Name, len(c.LegacyObservedNetDelta), len(c.Arrears.flatRows()))
 		}
 	}
 	return nil
@@ -236,7 +311,8 @@ func seedLegacyArrears(ctx context.Context, tx *gorm.DB, c Case) error {
 SELECT COUNT(*) FROM t_courses_class WHERE coursesid = ? AND classid = ?`); err != nil {
 		return err
 	}
-	for _, r := range c.Arrears.Rows {
+	for _, e := range c.Arrears.flatRows() {
+		r := e.Row
 		for _, st := range r.classStartTimes() {
 			t, err := parseArrearsTime(st)
 			if err != nil {
@@ -262,7 +338,7 @@ INSERT INTO t_courses_student
    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,
+			r.BizNo, r.CoursesID, r.ClassID, c.InstitutionID, c.CampusID, e.User,
 			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 {
@@ -285,7 +361,8 @@ SELECT COUNT(*) FROM t_courses_class WHERE courses_id = ? AND class_id = ?`); er
 		return err
 	}
 	studentUID := fixture.FixtureCanonicalStudentID(c.LegacyStudentID)
-	for _, r := range c.Arrears.Rows {
+	for _, e := range c.Arrears.flatRows() {
+		r := e.Row
 		for _, st := range r.classStartTimes() {
 			t, err := parseArrearsTime(st)
 			if err != nil {
@@ -334,8 +411,9 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
 //
 // 参数: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 {
+	seen := make(map[[2]int64]bool, len(c.Arrears.flatRows()))
+	for _, e := range c.Arrears.flatRows() {
+		r := e.Row
 		pair := [2]int64{r.CoursesID, r.ClassID}
 		if seen[pair] {
 			continue // 同一对多条排班是 RA-2 的**声明意图**,由 validate 的 start_time 唯一性保证
@@ -354,3 +432,22 @@ func guardArrearsPairsUnused(ctx context.Context, tx *gorm.DB, c Case, side, cou
 	}
 	return nil
 }
+
+// arrearsCallUserIDs 返回"这条 Op 的 CALL 会真正当作 `studentid` 传出去的那些 id"。
+//
+// 为什么要按 Op 分开:两个 SP 的第一个实参**不是同一种东西** ——
+// 课酬核销传的是学生 id,老带新传的是 userid(SP 内部把它当 studentid 用)。
+// 把它们并成一个集合,就会让"给课酬 case 种了一个 userid 的欠费行"这种错静默通过。
+func arrearsCallUserIDs(c Case) []int64 {
+	switch c.Op {
+	case OpUseActivityRecommend:
+		return []int64{c.LegacyUserID, c.RecommendUserID}
+	case OpCounteractArrears:
+		return []int64{c.LegacyStudentID}
+	default:
+		if c.Arrears != nil {
+			return []int64{c.LegacyStudentID, c.LegacyUserID, c.RecommendUserID}
+		}
+		return nil
+	}
+}

+ 33 - 29
internal/academic/dualrun/stage2/run.go

@@ -739,23 +739,25 @@ func DefaultCases(_ string) []Case {
 			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,
+			Arrears: &ArrearsSeed{ByUser: map[int64][]ArrearsSeedRow{
+				900001: []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,
@@ -776,18 +778,20 @@ func DefaultCases(_ string) []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,
+			Arrears: &ArrearsSeed{ByUser: map[int64][]ArrearsSeedRow{
+				900002: []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,
+					},
 				},
 			}},
 			// 真实终态(实测,两次跑一致):