Ver código fonte

feat(ac15): 3d W4-C 老带新·两个用户各自有欠费(54→55)—— 两次核销各按本人上限

## 一、这条证明什么(W4-A/B 都证明不了)

账户侧八个增量在"把两个 CALL 写成一个"时**照样全对**(核销不动账户),
所以只有"两个用户各自有欠费"才能分辨:少了它,第二个 CALL 就算不存在也不会红。

实测(`m=50.00`、每人两行各 `剩余应收 60.00`):

```
ac15-w4c-0 (user 17735 第一行)  legacy +5000 / target +5000   ← 核 min(50,60)=50
ac15-w4c-1 (第二行)             legacy    +0 / target    +0   ← 额度用完,不动
ac15-w4c-2 (user 17738 第一行)  legacy +5000 / target +5000   ← **第二个人也有自己的一份**
ac15-w4c-3 (第二行)             legacy    +0 / target    +0
```

`+5000 / +0 / +5000 / +0` 这一个形状同时排除了两种错法:
①两人共用一个额度 ⇒ 第三行会变 0;②核销无上限 ⇒ 会核 60.00。

## 二、顺序:**不钉**(按 team-lead 的降档决定)

核销改 `t_courses_student`、入账改 `t_user_expense*`,两域互不依赖 ⇒ 换序终态相同,
**顺序只在 SQL trace 里可见**。Note 已写死那句"本 case 钉的是两件事都发生、金额各为 m,
不声称钉住了次序"。不建 SQL 序列 harness。

## 三、写这条 case 时踩到并修掉的三处(全部是我自己的错)

1. **`validate` 要求 `LegacyStudentID>0`** —— 那是课酬链的入参;老带新传 userid。
   改成**按 Op** 判断(和 `arrearsCallUserIDs` 同一个道理)。
2. **命名空间用错**:target 的 `t_courses_student.student_id` 我按**学生** ULID 落,
   而 SP18 传出去的第一个实参是 **userid**(SP 内部拿它当 studentid 查)⇒ 候选集为空
   ⇒ target 核销 +0,**看起来像"target 没实现核销"**。改成按 Op 选命名空间。
   这条正是 `FixtureCanonicalStudentID` 那段注释说的"两个命名空间不是装饰"。
3. **`arrearsExpectedDeltas` 的 `remaining` 是全局的** —— 我上一轮声称改成"按用户各自算",
   实际没改成功(那次替换的锚点没匹配上,而我又没验证结果)。
   症状:第二个用户的期望全变 0 ⇒ 红成"实现多核了 5000"。
   **这正是 team-lead 提醒过的那条,而我把它留在了"我以为改了"的状态里。**

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

```
分母:DefaultCases=55 | 应跑 55 | 实跑 55
总览:55 个 case,55 PASS,0 FAIL
Skipped 98 | 业务 343 | 基建 118 | 未分类 0
代价 SQL 3350 次 / 20.099s / 60.9 次 per case / 365ms per case
grep "本块比对了" = 7
活动域 15(2/2/1/10)| 课酬 2(1/1)| 跨域 17 + 资金 38 = 55

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

未 push。
Q 7 horas atrás
pai
commit
ef1b02e2d0

+ 36 - 26
internal/academic/dualrun/stage2/arrears_compare.go

@@ -122,34 +122,44 @@ func arrearsExpectedDeltas(c Case) map[string]arrearsRowDelta {
 	if c.Arrears == nil {
 		return out
 	}
-	remaining := c.AmountCents
-	for _, e := range c.Arrears.flatRows() {
-		r := e.Row
-		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 {
+	// **每个用户各自一份 remaining**。
+	//
+	// 用一个全局 remaining 会出什么错(本轮真的这么写过,被自己的断言抓出来):
+	// 第一个用户把额度吃满后,第二个用户的**每一行**期望都变成 0 ——
+	// 而实际两个人各拿一份 ⇒ 断言会红在"第二个用户被核了 5000,期望 0",
+	// 看起来完全像"实现多核了"。
+	//
+	// 语义依据(dump 763389 / 763410):SP 在两个块里各调一次
+	// `counteract_arrears_course(userid, …, activityMoney)`,**各自的上限都是 activityMoney**。
+	for _, u := range c.Arrears.userIDs() {
+		remaining := c.AmountCents
+		for _, r := range c.Arrears.ByUser[u] {
+			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
-			continue
-		}
-		take := r.RemainderReceivableCents
-		if remaining < take {
-			take = remaining
+			remaining -= take
 		}
-		d.NetDelta += take
-		d.RemDelta -= take
-		out[r.BizNo] = d
-		remaining -= take
 	}
 	return out
 }

+ 18 - 3
internal/academic/dualrun/stage2/arrears_seed.go

@@ -179,9 +179,11 @@ func validateArrearsSeed(c Case) error {
 	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)
+	// `LegacyStudentID` 只有**课酬核销那一支**会用到(它是 CALL 的第一个实参)。
+	// 老带新(W4)传的是 userid ⇒ 那里 `LegacyStudentID` 允许为 0,**不要**在此处要求它。
+	if c.Op == OpCounteractArrears && c.LegacyStudentID <= 0 {
+		return fmt.Errorf("case %q 声明了 Arrears 但 LegacyStudentID=%d(Op=%s 的 CALL 第一个实参就是它,必须 >0)",
+			c.Name, c.LegacyStudentID, c.Op)
 	}
 	// ByUser 的 key 必须是**这条 Op 的 CALL 会真正传出去的那个 id** ——
 	// 否则"种了一个谁都不读的用户的欠费行"会静默存在:行种进去了、快照也读到了,
@@ -360,9 +362,22 @@ func seedTargetArrears(ctx context.Context, tx *gorm.DB, c Case) error {
 SELECT COUNT(*) FROM t_courses_class WHERE courses_id = ? AND class_id = ?`); err != nil {
 		return err
 	}
+	// ⚠ **同一个数字在两条链上属于两个命名空间**(这不是洁癖,是这批 case 的真实约束):
+	//
+	//   · 课酬核销(RA):CALL 第一实参是**学生** id ⇒ `student_id` 用**学生** ULID;
+	//   · 老带新(W4):CALL 第一实参是 **userid**(SP 内部拿它当 studentid 查),
+	//     而旧库 `t_courses_student.studentid` 存的就是 userid ⇒ target 侧必须用**用户** ULID。
+	//
+	// 用错命名空间**不会报错**:`student_id` 是一列合法字符串,只是永远匹配不上
+	// ⇒ 候选集为空 ⇒ 核销一分不做 ⇒ 看起来像"target 没实现核销"。
+	// W4-C 第一次跑就是 legacy +5000 / target +0,靠的正是这两个命名空间是分开的才定位到。
 	studentUID := fixture.FixtureCanonicalStudentID(c.LegacyStudentID)
 	for _, e := range c.Arrears.flatRows() {
 		r := e.Row
+		if c.Op == OpUseActivityRecommend {
+			// 老带新:第一个实参是 userid,按用户各自的**用户** ULID 落 `student_id`。
+			studentUID = fixture.FixtureCanonicalUserID(e.User)
+		}
 		for _, st := range r.classStartTimes() {
 			t, err := parseArrearsTime(st)
 			if err != nil {

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

@@ -318,8 +318,14 @@ func TestCustomRefundCaseSet(t *testing.T) {
 	// W4-B 多出来的这一条证明什么:**建行分支**(推荐人两张表都没有账户行)。
 	// 它是 `AccountSeed` 三态里唯一**改变执行路径**的那个(不是"值不同",是"走另一段代码"),
 	// 也是唯一能证明「两张表金额列默认值不对称」的 case —— 结论是 LEGACY-DEFECT(全局到了、校区没到)。
-	if len(all) != 54 {
-		t.Errorf("DefaultCases 长度应为 54(50 + RA-1 + RA-2 + W4-A + W4-B),实得 %d —— 分母变了就要重新核一遍是不是漏了 case", len(all))
+	// 55 = 50 + RA-1 + RA-2 + W4-A + W4-B + W4-C
+	//
+	// W4-C 证明的是"两次核销各自按本人上限":SP 在两个块里各嵌调一次
+	// `counteract_arrears_course`,两次的 `inputMoney` 都是 `activityMoney` 本身。
+	// 缺了它,把两个 CALL 写成一个(或让第二人共用第一人的额度)也能全绿 ——
+	// 因为**账户侧八个增量照样对**(核销不动账户)。
+	if len(all) != 55 {
+		t.Errorf("DefaultCases 长度应为 55(50 + RA-1 + RA-2 + W4-A + W4-B + W4-C),实得 %d —— 分母变了就要重新核一遍是不是漏了 case", len(all))
 	}
 	if c2 != 10 {
 		t.Errorf("C2 应恰好 10 条(E 探针不在此列),实得 %d", c2)

+ 62 - 0
internal/academic/dualrun/stage2/recommend_compare.go

@@ -210,6 +210,68 @@ func recommendCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 			lgOK, c.ExpectLegacySuccess, errOf(lg.Calls), tgOK, c.ExpectTargetSuccess, errOf(tg.Calls)),
 	})
 
+	// ---- 课酬域:两个用户**各自**被核销一次 ----
+	if c.Arrears != nil {
+		d2, a2 := recommendArrearsAssertions(c, lg, tg)
+		diffs = append(diffs, d2...)
+		asserts = append(asserts, a2...)
+	}
+
+	return diffs, asserts
+}
+
+// recommendArrearsAssertions 是老带新里"两次核销"的断言(W4-C)。
+//
+// 期望值由 `arrearsExpectedDeltas` 按**每个用户各自的 `remaining`** 算 ——
+// 两个 CALL 的上限都是 `activityMoney`(同一个 m),但候选行互不相干。
+// 用一个全局 remaining 会把两人的额度串成一份:第一人吃满后第二人一分不核,
+// 而那**看起来完全像"第二个 CALL 没执行"**(又一个"红得像实现缺陷"的形态)。
+//
+// ⚠ **不钉顺序**:核销改 `t_courses_student`、入账改 `t_user_expense*`,两域互不依赖,
+// 换序终态相同 ⇒ 顺序只在 SQL trace 里可见。本块钉的是"两件事都发生、金额各为 m"。
+func recommendArrearsAssertions(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)
+	var lgNet, tgNet int64
+	for _, e := range c.Arrears.flatRows() {
+		biz := e.Row.BizNo
+		w := want[biz]
+		l, t := lgD[biz], tgD[biz]
+		lgNet += l.NetDelta
+		tgNet += t.NetDelta
+		add("W4-C·核销 "+biz+" Δnet_receipts",
+			fmt.Sprintf("%+d", l.NetDelta), fmt.Sprintf("%+d", t.NetDelta),
+			l.NetDelta == t.NetDelta, "分")
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+			Name: fmt.Sprintf("W4-C 核销 %s:两侧都按 `min(剩余应收, 本人上限)` 核(user=%d)", biz, e.User),
+			Pass: l.NetDelta == w.NetDelta && t.NetDelta == w.NetDelta &&
+				l.RemDelta == w.RemDelta && t.RemDelta == w.RemDelta,
+			Detail: fmt.Sprintf("实测 legacy net %+d / rem %+d | target net %+d / rem %+d | 期望 net %+d / rem %+d"+
+				"(剩余应收 %d,本人上限 m=%d)",
+				l.NetDelta, l.RemDelta, t.NetDelta, t.RemDelta, w.NetDelta, w.RemDelta,
+				e.Row.RemainderReceivableCents, c.AmountCents),
+		})
+	}
+	// 正向断言:**两个用户各核了一笔**。少了它,"两侧都 0"会全绿 ——
+	// 而那正是"第二个 CALL 没执行"或"两个用户共用一个额度"的形态。
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness,
+		Name: "W4-C 课酬核销总额两侧都 > 0(两个用户**各自**被核销过;全 0 ⇒ FAIL)",
+		Pass: lgNet > 0 && tgNet > 0,
+		Detail: fmt.Sprintf("legacy ΣΔnet=%+d | target ΣΔnet=%+d。"+
+			"若为 0:先查两个用户是否都有 `ByUser` 行、`confirm_discount_userid>0` 是否都为真", lgNet, tgNet),
+	})
 	return diffs, asserts
 }
 

+ 55 - 0
internal/academic/dualrun/stage2/run.go

@@ -888,6 +888,61 @@ func DefaultCases(_ string) []Case {
 		},
 	)
 
+	out = append(out,
+		Case{
+			// W4-C:**两个用户各自有欠费** ⇒ SP 在两个块里各嵌调一次 `counteract_arrears_course`。
+			//
+			// 这是唯一能证明"两次核销都发生、且各自按各自的上限算"的 case。
+			// 缺了它,把两个 CALL 写成一个(或让第二个人共用第一个人的额度)也能全绿 ——
+			// 因为账户侧八个增量**照样对**(核销不动账户)。
+			Name:         "W4-C 老带新·两个用户各自有欠费(两次核销,各按本人上限)",
+			Op:           OpUseActivityRecommend,
+			LegacyUserID: 17735, RecommendUserID: 17738,
+			InstitutionID: 63317, CampusID: 5, AmountCents: 5000,
+			StuCourseID: "ac15-w4c-order",
+			Activity: &ActivitySeed{Items: []ActivityItem{{
+				Type: 4, InstitutionID: 63317, CampusID: 5,
+				SignedStatus: 1, MoneyCents: 5000, RecommendUserID: 17738,
+			}}},
+			Recommend: &RecommendSeed{
+				Referred: AccountSeed{Balance: i64(10000), Recommended: i64(0)},
+				Referrer: AccountSeed{Balance: i64(0), Recommended: i64(0)},
+			},
+			// 两个用户各两行欠费(剩余应收 60.00 / 60.00),`start_time` **每人各自**升序且不并列。
+			// 额度 m=50.00 ⇒ 每人只核**第一行 50.00**(`min(50, 60) = 50`),第二行完全不动。
+			// 这正好能分辨两种错法:①把两人的额度串成一份 ⇒ 第二人 0;②核销无上限 ⇒ 核 60。
+			Arrears: &ArrearsSeed{ByUser: map[int64][]ArrearsSeedRow{
+				17735: {
+					{BizNo: "ac15-w4c-0", CoursesID: 990101, ClassID: 990111,
+						StartTime: "2001-05-01 08:00:00", RemainderReceivableCents: 6000,
+						ConfirmDiscountUserID: 17735},
+					{BizNo: "ac15-w4c-1", CoursesID: 990102, ClassID: 990112,
+						StartTime: "2001-05-02 08:00:00", RemainderReceivableCents: 6000,
+						ConfirmDiscountUserID: 17735},
+				},
+				17738: {
+					{BizNo: "ac15-w4c-2", CoursesID: 990103, ClassID: 990113,
+						StartTime: "2001-05-03 08:00:00", RemainderReceivableCents: 6000,
+						ConfirmDiscountUserID: 17738},
+					{BizNo: "ac15-w4c-3", CoursesID: 990104, ClassID: 990114,
+						StartTime: "2001-05-04 08:00:00", RemainderReceivableCents: 6000,
+						ConfirmDiscountUserID: 17738},
+				},
+			}},
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "活动 type=4、`money=50.00`;两个用户各 2 行欠费、剩余应收各 60.00。" +
+				"SP 结构:被推荐人整块 6 步(建户 → 双表双列 +50 → 107 → CALL 核销)**做完**," +
+				"才开始推荐人同一套 6 步;两次 CALL 的 `inputMoney` 都是 **`activityMoney`(50.00)本身**," +
+				"不是余额、也不是「奖励后的余额」。" +
+				"⇒ 每人核 `min(50.00, 60.00) = 50.00`,第二行不动。" +
+				"⚠ **顺序不可由终态观测**(核销改 `t_courses_student`、入账改 `t_user_expense*`,两域互不依赖,换序终态相同);" +
+				"本 case 钉的是「两件事都发生、金额各为 m」,**不声称钉住了次序**。" +
+				"⚠ F24 前提:两条 107 的 `request_id` 目前都是 **NULL**(`newExpenseLog(…, 空串, …)` ⇒ `optString` 对空串返 nil)," +
+				"MySQL 唯一索引对 NULL 不生效,所以同单号两条不会撞 `uk_request_type(request_id,type)`;" +
+				"**若将来有人给 107 补 `request_id`,这两条会立刻撞键**(丢凭证或半写),届时要么给唯一键加 user 维度、要么给 request_id 加用户后缀。",
+		},
+	)
+
 	// ---- 消费(第二批已做,留作回归)----
 	base := Case{
 		Op: OpExpenditure, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,