Przeglądaj źródła

feat(ac15): 3a DU-1/DU-2 赠送额度使用(43→45 全绿)

两条:DU-1 正常部分使用(type≠3,入参原样用)/ DU-2 type=3 代金券(入参被覆盖成面额)。

一、新增最小种子:`ActivitySeed.Discounts`
`user_discount_use` 的游标是 `WHERE userid=? AND activityid=?`,**没有额度行就进不去循环**
⇒ 两侧都 no-op ⇒ case 退化成"全绿的空过"。所以必须种一行(只种三个金额,
`balance_requestid` 留 NULL —— 3a 不做 id 重映射,且 Q3 断言两侧都是 NULL)。
**不种报课表**:这两条参数里没有 `courses_studentid`。

二、修两处(都是"口径/表选错造成的假红",不是 SP 行为错)
  ① `orderToken` 补第三种形态:target 读不到 `biz_no` 时的兜底 `#<numeric>` 也归一成 `<order>`。
     第一版 DU-1 就是红在这里:legacy 写旧 UUID、target 写 `courses_student_id` 反查不到 →
     `#7`,两侧"看起来订单号不同",实际指向同一单。
  ② 非空自检要按**该 Op 真写的那张表**数:`user_discount_*` 写的是**额度流水**(`_discount_log`),
     不是用量流水(`used_log`)。数错表会让"两侧都 0"看起来像没写。
     顺带纠正:`signed_add` / `signed_cancel` **不写任何流水**(只动 signed 行与 number_sign),
     它们那条自检应说"本支不适用"而不是"没写"——第一版把它们算成写 used_log,SA-1/SC-1 立刻假红。

三、DU-2 的期望按你的要求写成「入参被忽略、按面额扣」而不是写死一个数:
它抓的是"把代金券当成可部分使用"这个错法,写死数会让下一个人看不出这条在验什么。

验收:45 条 45 PASS / 0 FAIL / Skipped 75→81 / 业务 255 | 基建 84 / 未分类 0
      代价 SQL 2514 次 / 17.2s / 55.9 次每 case
Q 1 godzinę temu
rodzic
commit
c5e3f5baf0

+ 19 - 5
internal/academic/dualrun/stage2/activity_compare.go

@@ -146,6 +146,10 @@ func activityCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	_ = lBOK
 	_ = tBOK
 
+	// 额度流水差集(非空自检与后面的比对都要用,所以先算)
+	lDNew := diffDiscountLogMultiset(lb.DiscountLog, la.DiscountLog, c)
+	tDNew := diffDiscountLogMultiset(tb.DiscountLog, ta.DiscountLog, c)
+
 	// ---- ③ used_log 行集(多重集)----
 	// 只比**本 case 期间新增**的行:before 的行不该参与(其它 case 的历史行)
 	lNew := diffMultiset(lb.UsedLog, la.UsedLog, c)
@@ -170,10 +174,22 @@ func activityCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 				signedUnchanged(ta.Signed[key], tb.Signed[key], tBOK, tOK)),
 		})
 	} else {
+		// 非空自检只对"本 Op 真的会写流水"的支有意义,且要按**该 Op 写的那张表**数:
+		// `user_discount_*` 写的是**额度流水**(`_discount_log`),不是用量流水(`used_log`)。
+		// 搞错表会让"两侧都 0"看起来像没写。
+		// 只有这两支真的写 `used_log`;`signed_add` / `signed_cancel` **不写**任何流水
+		// (它们只动 signed 行与 number_sign),所以它们那条非空自检要说"本支不适用"而不是"没写"。
+		writesUsedLog := c.Op == OpUseActivityRecharge || c.Op == OpRefundActivityRecharge
+		writesDiscountLog := c.Op == OpUserDiscountUse || c.Op == OpUserDiscountRefund ||
+			c.Op == OpUserDiscountRefundByActivity
+		lCnt, tCnt, which := len(lNew), len(tNew), "用量流水"
+		if writesDiscountLog {
+			lCnt, tCnt, which = len(lDNew), len(tDNew), "额度流水"
+		}
 		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
-			Name:   "非空自检:本 case 期望产生用量流水,两侧都必须真的产生了(`0 vs 0` 相等的绿不算证据)",
-			Pass:   len(lNew) > 0 || c.Op == OpSignedAdd || c.Op == OpSignedCancel,
-			Detail: fmt.Sprintf("新增流水 legacy=%d target=%d", len(lNew), len(tNew)),
+			Name:   "非空自检:本 case 期望产生" + which + ",两侧都必须真的产生了(`0 vs 0` 相等的绿不算证据)",
+			Pass:   !writesUsedLog && !writesDiscountLog || (lCnt > 0 && tCnt > 0),
+			Detail: fmt.Sprintf("新增%s legacy=%d target=%d", which, lCnt, tCnt),
 		})
 	}
 
@@ -198,8 +214,6 @@ func activityCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		})
 	}
 
-	lDNew := diffDiscountLogMultiset(lb.DiscountLog, la.DiscountLog, c)
-	tDNew := diffDiscountLogMultiset(tb.DiscountLog, ta.DiscountLog, c)
 	okD, detailD := multisetDiff(lDNew, tDNew, "额度流水")
 	diffs = append(diffs, Diff{
 		Item:   "额度流水(多重集,仅本次新增行;父行 id 已归一成 (活动, 用户))",

+ 5 - 0
internal/academic/dualrun/stage2/activity_snapshot.go

@@ -728,6 +728,11 @@ func orderToken(orderNo string, c Case) string {
 	if c.StuCourseNumericID > 0 && orderNo == fmt.Sprint(c.StuCourseNumericID) {
 		return "<order>"
 	}
+	// 第三种形态:target 读不到 biz_no 时的兜底 `#<numeric>`(`t_courses_student` 没 JOIN 上)。
+	// 它**仍然指向同一个订单**,只是缺了旧 UUID ⇒ 也归一,否则会报一条纯口径造成的假红。
+	if c.StuCourseNumericID > 0 && orderNo == "#"+fmt.Sprint(c.StuCourseNumericID) {
+		return "<order>"
+	}
 	return orderNo
 }
 

+ 42 - 0
internal/academic/dualrun/stage2/c1_activity.go

@@ -67,6 +67,13 @@ type ActivitySeed struct {
 	// 没有这些行,冲回**根本进不去循环**,那条 case 就成了"两侧都 no-op"的空过。
 	UsedLogs []UsedLogSeed
 
+	// Discounts 是要种的赠送额度行(`t_courses_activity_user_discount`)。
+	//
+	// `user_discount_use` 的游标是
+	// `WHERE userid=? AND activityid=?`(读 `discount_amount` / `discount_amount_used`),
+	// **没有这一行就进不去循环** ⇒ 两侧都 no-op ⇒ case 退化成空过。
+	Discounts []DiscountSeed
+
 	// Items 非 nil 时**整体替换** `C1ActivityPlan` 的默认矩阵(case 自定义构型)。
 	//
 	// `ID` 留 0 即可:由 `buildPlan` 按 `base+i` 分配。**case 里不许写死 id** ——
@@ -128,6 +135,17 @@ type UsedLogSeed struct {
 	Memo          string
 }
 
+// DiscountSeed 是一条要种的赠送额度行。
+//
+// 只种 `discount_amount` / `discount_amount_used` / `income` 三个金额,其余留库默认:
+//   - `balance_requestid` 留 NULL(3a 不做 varchar→bigint 的 id 重映射,且 Q3 断言两侧都是 NULL)
+type DiscountSeed struct {
+	ActivityIndex int
+	AmountCents   int64 // discount_amount(2 位口径列)
+	UsedCents     int64 // discount_amount_used(**3 位口径列**,与上一行不共用 readRound)
+	IncomeCents   int64
+}
+
 // buildPlan 按 case 的声明构造实际 plan(id 由 base 决定)。
 //
 // 两条路径:
@@ -306,6 +324,18 @@ VALUES (?, ?, ?, ?, ?, ?, NOW())`,
 			return fmt.Errorf("种子 legacy 报名 %d 失败:%w", it.ID, err)
 		}
 	}
+	// 赠送额度行(`user_discount_*` 的游标依据;不种就进不去循环)。
+	for _, d := range c.Activity.Discounts {
+		aid := p.Items[d.ActivityIndex].ID
+		if err := tx.Exec(`
+INSERT INTO t_courses_activity_user_discount
+  (activityid, schoolid, campusid, userid, income, discount_amount, discount_amount_used, create_time)
+VALUES (?, ?, ?, ?, ?, ?, ?, NOW())`,
+			aid, c.InstitutionID, c.CampusID, uid,
+			yuan(d.IncomeCents), yuan(d.AmountCents), yuan(d.UsedCents)).Error; err != nil {
+			return fmt.Errorf("种子 legacy 赠送额度失败:%w", err)
+		}
+	}
 	// 用量流水(W2 冲回路径的定位依据)。两侧 `orderid` / `order_no` 写同一个字符串。
 	for _, ul := range c.Activity.UsedLogs {
 		aid := p.Items[ul.ActivityIndex].ID
@@ -363,6 +393,18 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(3))`,
 			return fmt.Errorf("种子 target 报名 %d 失败:%w", it.ID, err)
 		}
 	}
+	// 赠送额度行(同 legacy)。`balance_request_id` 留 NULL —— 3a 不做 id 重映射。
+	for _, d := range c.Activity.Discounts {
+		aid := p.Items[d.ActivityIndex].ID
+		if err := tx.Exec(`
+INSERT INTO t_courses_activity_user_discount
+  (activity_id, institution_id, campus_id, user_id, income, discount_amount, discount_amount_used, create_time)
+VALUES (?, ?, ?, ?, ?, ?, ?, NOW(3))`,
+			aid, c.InstitutionID, c.CampusID, uid,
+			yuan(d.IncomeCents), yuan(d.AmountCents), yuan(d.UsedCents)).Error; err != nil {
+			return fmt.Errorf("种子 target 赠送额度失败:%w", err)
+		}
+	}
 	// 用量流水:`order_no` 与 legacy 的 `orderid` 同一个字符串(两侧游标靠它定位)。
 	for _, ul := range c.Activity.UsedLogs {
 		aid := p.Items[ul.ActivityIndex].ID

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

@@ -295,8 +295,8 @@ 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) != 43 {
-		t.Errorf("DefaultCases 长度应为 43,实得 %d —— 分母变了就要重新核一遍是不是漏了 case", len(all))
+	if len(all) != 45 {
+		t.Errorf("DefaultCases 长度应为 45,实得 %d —— 分母变了就要重新核一遍是不是漏了 case", len(all))
 	}
 	if c2 != 10 {
 		t.Errorf("C2 应恰好 10 条(E 探针不在此列),实得 %d", c2)

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

@@ -559,6 +559,47 @@ func DefaultCases(_ string) []Case {
 		},
 	)
 
+	// ---- 批 3a:W3 前两条(赠送额度的使用)----
+	//
+	// 这两条**不需要报课表种子**:`user_discount_use` 的参数里没有 `courses_studentid`
+	// (它只按 userid+activityid 游标),`StuCourseID` 只被写进 `_log` 当订单参考。
+	duOrderNo := "ac15-w3-du-1"
+	out = append(out,
+		Case{
+			Name: "DU-1 使用赠送额度·正常部分使用(2 位口径列)",
+			Op:   OpUserDiscountUse, LegacyUserID: actUser, InstitutionID: 63317, CampusID: 5,
+			SelfSupport: true, AmountCents: 3000,
+			StuCourseID: duOrderNo, StuCourseNumericID: 7, Remark: "W3 使用赠送",
+			Activity: &ActivitySeed{
+				Items: []ActivityItem{
+					{Type: 2, InstitutionID: 63317, CampusID: 5, SignedStatus: -1, MoneyCents: 0},
+				},
+				Discounts: []DiscountSeed{{ActivityIndex: 0, AmountCents: 10000, UsedCents: 0}},
+			},
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "额度 100.00、用 30.00(type≠3 ⇒ **入参原样使用**)。dump:`discount_amount_used += inputAmount` + " +
+				"`_log` 一条 **−inputAmount**。排除「没累加」与「log 符号反了」。" +
+				"⚠ 必须先种那条额度行 —— 游标 `WHERE userid=? AND activityid=?` 没行就进不去循环,两侧都 no-op 也就全绿了",
+		},
+		Case{
+			Name: "DU-2 使用赠送额度·type=3 代金券(入参被忽略、按面额扣)",
+			Op:   OpUserDiscountUse, LegacyUserID: actUser, InstitutionID: 63317, CampusID: 5,
+			SelfSupport: true, AmountCents: 3000,
+			StuCourseID: duOrderNo, StuCourseNumericID: 7, Remark: "W3 代金券",
+			Activity: &ActivitySeed{
+				Items: []ActivityItem{
+					{Type: 3, InstitutionID: 63317, CampusID: 5, SignedStatus: -1, MoneyCents: 0},
+				},
+				Discounts: []DiscountSeed{{ActivityIndex: 0, AmountCents: 10000, UsedCents: 0}},
+			},
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "代金券(type=3)、额度 100.00、**入参只传 30.00**。dump:`IF activityType=3 THEN SET inputAmount=discountAmount` " +
+				"⇒ 入参被**覆盖成面额**,实际扣 100.00 而不是 30.00。" +
+				"本条抓的是「把代金券当成可部分使用」这个错法 —— 所以期望写成**入参被忽略、按面额扣**," +
+				"不是写死一个数(写死数会让下一个人看不出这条在验什么)",
+		},
+	)
+
 	// ---- 消费(第二批已做,留作回归)----
 	base := Case{
 		Op: OpExpenditure, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,