Просмотр исходного кода

feat(ac15): 3a DR-1/DR-2 退回赠送额度(45→47 全绿)+ README §20.1 判据变更记录

一、两条 case(用真实欠费行的旧 UUID f2166ec3c06204401c19e6a9647b5ead)
  DR-1 正常部分退回:池行 used 30.00 → **20.00**、log 一条 **+10.00**(两侧同)
  DR-2 退超(申请 50.00 > 可退 30.00):池行 used 30.00 → **0.00**、log 一条 **+30.00**,剩余 20.00 不退也不报错
⚠ DR-2 的期望**不是**"退成负数" —— dump 是「取较小值」:只退到 0 为止。写成负数会与实现无关地红。

二、新增种子 `ActivitySeed.DiscountLogs`(额度流水)
`user_discount_refund` 的游标是 `WHERE courses_studentid=? AND discount_amount_used<0 ORDER BY id DESC`,
**不种负值行就进不去循环** ⇒ 两侧 no-op ⇒ 空过。两侧订单 id 各按自己口径填(§19 的老问题)。

三、`countOtherDiscountLogLegacy` 前置断言在这两条**第一次真正起作用**
(游标不带 userid)。实测:该订单号在旧库另有 **0** 条负额度流水 ⇒ 通过。
文案里写死「这是【数据碰撞】不是实现错」,因为这种红看起来像 SP 行为错。

四、修两处(都是"断言写错方向 ⇒ 把真实现判成 bug")
  ① `dKey` 用了 `c.ActivityID`,而 `user_discount_refund` 没有 activity 入参 ⇒ RunOne 不会补
     ActivityID(恒 0)⇒ 键永远匹配不上 ⇒ **整块额度断言被静默跳过**(DR-1/DR-2 会假绿)。
     改成按 `Discounts[0].ActivityIndex` 从 plan 取真实活动 id。
  ② 补 `wantDiscountUsed`:按种子声明的 `UsedCents` 与**第一条**负流水算期望,
     不写死数字(写死会让"期望"与"种子"脱钩,改种子就悄悄失效)。只对退回类生效。
     第一版没限定 Op,把 DU-1/DU-2 一起算了 ⇒ 立刻假红。

五、README §20.1 记下 `c5e3f5b` 的非空自检判据变更(旧写法两个方向的洞 + 影响面为零的依据)。

验收:47 条 47 PASS / 0 FAIL / Skipped 81→87 / 业务 269 | 基建 90 / 未分类 0
      代价 SQL 2652 次 / 17.3s / 56.4 次每 case
Q 1 час назад
Родитель
Сommit
8174c2c2b1

+ 18 - 0
internal/academic/dualrun/stage2/README.md

@@ -1304,3 +1304,21 @@ $sql = "SELECT SUM(t1.discount_amount-t1.discount_amount_used) as amount FROM ..
 **不先种一条负值 used_log,冲回根本进不去循环** ⇒ case 会退化成"两侧都 no-op"的空过 ——
 **不先种一条负值 used_log,冲回根本进不去循环** ⇒ case 会退化成"两侧都 no-op"的空过 ——
 而它照样全绿。这和 C2 的 `Baseline` 是同一个道理:**要测"后一个动作",必须先把"前一个状态"造出来**,
 而它照样全绿。这和 C2 的 `Baseline` 是同一个道理:**要测"后一个动作",必须先把"前一个状态"造出来**,
 并且**显式断言那个状态确实被造出来了**(否则空过的形态和正常形态无法区分)。
 并且**显式断言那个状态确实被造出来了**(否则空过的形态和正常形态无法区分)。
+
+### 20.1 「非空自检」判据变更(commit `c5e3f5b`)
+
+旧写法 `len(lNew) > 0 || Op == OpSignedAdd || Op == OpSignedCancel` 只看 **legacy** 一侧;
+新写法按 **Op 真写的那张表**数,且**两侧都要有**(`lCnt > 0 && tCnt > 0`)。
+
+**旧写法有两个方向的洞**:
+
+1. **只看 legacy** ⇒ target 一条没写也能绿 —— 那正是"target 漏写流水"这种最该抓的错;
+2. **表选错** ⇒ `user_discount_*` 写的是**额度流水**(`_discount_log`),不是用量流水(`used_log`),
+   按用量流水数会**假红**,而假红会把人引去改实现。
+
+**影响面为零的依据**:W1(`signed_add` / `signed_cancel`)**不写任何流水**,走的是"恒真/不适用"分支、
+等价旧行为;W2 两侧都写 `used_log`,两版判据同结论。45 条全绿已证这一点,
+47 条(加 DR-1/DR-2)复跑仍全绿。
+
+> 这类"自检本身假红"比"断言漏了"更危险:**断言写错方向会把真实现判成 bug**,
+> 看到红的人第一反应是去改实现。所以判据变更必须显式记录,不能留在"顺手改"里。

+ 57 - 4
internal/academic/dualrun/stage2/activity_compare.go

@@ -194,16 +194,26 @@ func activityCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	}
 	}
 
 
 	// ---- ④ 额度行与额度流水(W3 才用得到)----
 	// ---- ④ 额度行与额度流水(W3 才用得到)----
-	dKey := ActivitySignedKey{ActivityID: c.ActivityID, UserUID: uid}
+	// 额度行的键:**不能用 `c.ActivityID`** —— `user_discount_refund` 没有 activity 入参,
+	// `RunOne` 不会给它补 ActivityID(仍是 0),用它做键会永远匹配不上、整块额度断言被静默跳过。
+	// 所以按 case 声明的 `Discounts[0].ActivityIndex` 去 plan 里取真实活动 id。
+	dActID := c.ActivityID
+	if len(c.Activity.Discounts) > 0 {
+		dActID = c.ActivityPlan.Items[c.Activity.Discounts[0].ActivityIndex].ID
+	}
+	dKey := ActivitySignedKey{ActivityID: dActID, UserUID: uid}
 	if ld, lok := lb.Discounts[dKey]; lok || hasAnyDiscount(lb, ta, dKey) {
 	if ld, lok := lb.Discounts[dKey]; lok || hasAnyDiscount(lb, ta, dKey) {
 		td, tok := tb.Discounts[dKey]
 		td, tok := tb.Discounts[dKey]
 		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 			Name: "额度行 `discount_amount_used` 两侧一致(2 位口径;`discount_amount` 走 2 位、`used` 走 3 位,**不共用读函数**)",
 			Name: "额度行 `discount_amount_used` 两侧一致(2 位口径;`discount_amount` 走 2 位、`used` 走 3 位,**不共用读函数**)",
 			Pass: lok == tok && (!lok || ld.DiscountAmountUsed == td.DiscountAmountUsed) &&
 			Pass: lok == tok && (!lok || ld.DiscountAmountUsed == td.DiscountAmountUsed) &&
-				(!lok || ld.DiscountAmount == td.DiscountAmount),
-			Detail: fmt.Sprintf("legacy used=%s amount=%s | target used=%s amount=%s(存在 legacy=%v target=%v)",
+				(!lok || ld.DiscountAmount == td.DiscountAmount) &&
+				(!lok || !isRefundOp(c.Op) || ld.DiscountAmountUsed == wantDiscountUsed(c)),
+			Detail: fmt.Sprintf("legacy used=%s amount=%s | target used=%s amount=%s | 期望 used=%s"+
+				"(退回类才校验期望;使用类的扣减由两侧相等 + 额度流水保证)(存在 legacy=%v target=%v)",
 				yuan(ld.DiscountAmountUsed), yuan(ld.DiscountAmount),
 				yuan(ld.DiscountAmountUsed), yuan(ld.DiscountAmount),
-				yuan(td.DiscountAmountUsed), yuan(td.DiscountAmount), lok, tok),
+				yuan(td.DiscountAmountUsed), yuan(td.DiscountAmount),
+				yuan(wantDiscountUsed(c)), lok, tok),
 		})
 		})
 		// Q3:3a 不做 id 重映射 ⇒ 两侧该列都必须是 NULL,**显式断言**(不是"不写就不管")
 		// Q3:3a 不做 id 重映射 ⇒ 两侧该列都必须是 NULL,**显式断言**(不是"不写就不管")
 		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
 		asserts = append(asserts, Assertion{Kind: AssertionBusiness,
@@ -398,3 +408,46 @@ func activityChainVerdict(c Case) string {
 	}
 	}
 	return strings.Join(parts, " + ")
 	return strings.Join(parts, " + ")
 }
 }
+
+// wantDiscountUsed 是"退回类 Op 之后池行 `discount_amount_used` 的期望值"。
+//
+// dump 的算法(`sp_courses_activity_user_discount_refund`):
+//
+//	tempAmount = min(inputAmount, |该订单下第一条负流水|)
+//	discount_amount_used -= tempAmount
+//
+// ⚠ 它**不是**"退成负数":申请退超时只退到 0 为止、剩余不退也不报错。
+// 这里按种子声明的 `UsedCents` 与负流水之和算,不写死数字 ——
+// 写死会让"期望"和"种子"脱钩,改种子就悄悄失效。
+func wantDiscountUsed(c Case) int64 {
+	if len(c.Activity.Discounts) == 0 {
+		return 0
+	}
+	// 只对"退回"两支算这个期望:`user_discount_use` 是**加**用量(且 type=3 会把入参覆盖成面额),
+	// 把它也算进来会让 DU-1/DU-2 假红。
+	switch c.Op {
+	case OpUserDiscountRefund, OpUserDiscountRefundByActivity:
+	default:
+		return c.Activity.Discounts[0].UsedCents // 使用类:期望不变(DU 的实际扣减由两侧相等 + 额度流水保证)
+	}
+	used := c.Activity.Discounts[0].UsedCents
+	var refundable int64
+	for _, dl := range c.Activity.DiscountLogs {
+		if dl.MoneyCents < 0 {
+			refundable += -dl.MoneyCents
+		}
+		if refundable > 0 {
+			break // 游标按 id DESC 取**第一条**,所以只看第一条负流水
+		}
+	}
+	take := c.AmountCents
+	if take > refundable {
+		take = refundable
+	}
+	return used - take
+}
+
+// isRefundOp 标记"退回类"的额度 Op(只有这两支要用 `wantDiscountUsed` 做期望校验)。
+func isRefundOp(op Op) bool {
+	return op == OpUserDiscountRefund || op == OpUserDiscountRefundByActivity
+}

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

@@ -74,6 +74,13 @@ type ActivitySeed struct {
 	// **没有这一行就进不去循环** ⇒ 两侧都 no-op ⇒ case 退化成空过。
 	// **没有这一行就进不去循环** ⇒ 两侧都 no-op ⇒ case 退化成空过。
 	Discounts []DiscountSeed
 	Discounts []DiscountSeed
 
 
+	// DiscountLogs 是要种的额度流水(负值 = 消费方向)。
+	//
+	// `user_discount_refund` 的游标是
+	// `WHERE courses_studentid=? AND discount_amount_used<0 ORDER BY id DESC`,
+	// **没有负值行就进不去循环** ⇒ 两侧都 no-op ⇒ 空过。
+	DiscountLogs []DiscountLogSeed
+
 	// Items 非 nil 时**整体替换** `C1ActivityPlan` 的默认矩阵(case 自定义构型)。
 	// Items 非 nil 时**整体替换** `C1ActivityPlan` 的默认矩阵(case 自定义构型)。
 	//
 	//
 	// `ID` 留 0 即可:由 `buildPlan` 按 `base+i` 分配。**case 里不许写死 id** ——
 	// `ID` 留 0 即可:由 `buildPlan` 按 `base+i` 分配。**case 里不许写死 id** ——
@@ -146,6 +153,17 @@ type DiscountSeed struct {
 	IncomeCents   int64
 	IncomeCents   int64
 }
 }
 
 
+// DiscountLogSeed 是一条要种的额度流水。
+//
+// `OrderNo` 留空时由种子按各侧口径填(legacy 旧 UUID / target 数字 id),与 `UsedLogSeed` 同理:
+// 两侧订单 id 空间不同(见 §19),填成同一个值会让一侧按自己口径查不到行。
+type DiscountLogSeed struct {
+	DiscountIndex int // plan 里 Discounts 的下标
+	OrderNo       string
+	MoneyCents    int64 // 负数 = 消费;正数 = 退回
+	Memo          string
+}
+
 // buildPlan 按 case 的声明构造实际 plan(id 由 base 决定)。
 // buildPlan 按 case 的声明构造实际 plan(id 由 base 决定)。
 //
 //
 // 两条路径:
 // 两条路径:
@@ -336,6 +354,23 @@ VALUES (?, ?, ?, ?, ?, ?, ?, NOW())`,
 			return fmt.Errorf("种子 legacy 赠送额度失败:%w", err)
 			return fmt.Errorf("种子 legacy 赠送额度失败:%w", err)
 		}
 		}
 	}
 	}
+	// 赠送额度流水(`user_discount_refund` 的游标依据)。
+	for _, dl := range c.Activity.DiscountLogs {
+		aid := p.Items[c.Activity.Discounts[dl.DiscountIndex].ActivityIndex].ID
+		var poolID int64
+		if err := tx.Raw(`SELECT id FROM t_courses_activity_user_discount
+		                   WHERE activityid=? AND userid=? ORDER BY id DESC LIMIT 1`, aid, uid).
+			Row().Scan(&poolID); err != nil {
+			return fmt.Errorf("读回 legacy 额度池 id 失败:%w", err)
+		}
+		if err := tx.Exec(`
+INSERT INTO t_courses_activity_user_discount_log
+  (activity_user_discountid, courses_studentid, discount_amount_used, create_time, memo)
+VALUES (?, ?, ?, NOW(), ?)`,
+			poolID, firstOr(dl.OrderNo, c.StuCourseID), yuan(dl.MoneyCents), dl.Memo).Error; err != nil {
+			return fmt.Errorf("种子 legacy 额度流水失败:%w", err)
+		}
+	}
 	// 用量流水(W2 冲回路径的定位依据)。两侧 `orderid` / `order_no` 写同一个字符串。
 	// 用量流水(W2 冲回路径的定位依据)。两侧 `orderid` / `order_no` 写同一个字符串。
 	for _, ul := range c.Activity.UsedLogs {
 	for _, ul := range c.Activity.UsedLogs {
 		aid := p.Items[ul.ActivityIndex].ID
 		aid := p.Items[ul.ActivityIndex].ID
@@ -405,6 +440,24 @@ VALUES (?, ?, ?, ?, ?, ?, ?, NOW(3))`,
 			return fmt.Errorf("种子 target 赠送额度失败:%w", err)
 			return fmt.Errorf("种子 target 赠送额度失败:%w", err)
 		}
 		}
 	}
 	}
+	// 赠送额度流水。target 的游标按 `courses_student_id`(数字)过滤 ⇒ 必须写数字 id;
+	// `courses_student_no` 留 NULL(该列「禁止新写入」,见 §19)。
+	for _, dl := range c.Activity.DiscountLogs {
+		aid := p.Items[c.Activity.Discounts[dl.DiscountIndex].ActivityIndex].ID
+		var poolID int64
+		if err := tx.Raw(`SELECT id FROM t_courses_activity_user_discount
+		                   WHERE activity_id=? AND user_id=? ORDER BY id DESC LIMIT 1`, aid, uid).
+			Row().Scan(&poolID); err != nil {
+			return fmt.Errorf("读回 target 额度池 id 失败:%w", err)
+		}
+		if err := tx.Exec(`
+INSERT INTO t_courses_activity_user_discount_log
+  (activity_user_discount_id, courses_student_id, discount_amount_used, create_time, memo)
+VALUES (?, ?, ?, NOW(3), ?)`,
+			poolID, c.StuCourseNumericID, yuan(dl.MoneyCents), dl.Memo).Error; err != nil {
+			return fmt.Errorf("种子 target 额度流水失败:%w", err)
+		}
+	}
 	// 用量流水:`order_no` 与 legacy 的 `orderid` 同一个字符串(两侧游标靠它定位)。
 	// 用量流水:`order_no` 与 legacy 的 `orderid` 同一个字符串(两侧游标靠它定位)。
 	for _, ul := range c.Activity.UsedLogs {
 	for _, ul := range c.Activity.UsedLogs {
 		aid := p.Items[ul.ActivityIndex].ID
 		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)
 	// 25(A/B/C1 期)+ 10(C2:C2-1、C2-1b、C2-2…C2-6、C2-8、C2-9、C2-10)
-	if len(all) != 45 {
-		t.Errorf("DefaultCases 长度应为 45,实得 %d —— 分母变了就要重新核一遍是不是漏了 case", len(all))
+	if len(all) != 47 {
+		t.Errorf("DefaultCases 长度应为 47,实得 %d —— 分母变了就要重新核一遍是不是漏了 case", len(all))
 	}
 	}
 	if c2 != 10 {
 	if c2 != 10 {
 		t.Errorf("C2 应恰好 10 条(E 探针不在此列),实得 %d", c2)
 		t.Errorf("C2 应恰好 10 条(E 探针不在此列),实得 %d", c2)

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

@@ -600,6 +600,57 @@ func DefaultCases(_ string) []Case {
 		},
 		},
 	)
 	)
 
 
+	// ---- 批 3a:W3 中两条(赠送额度的退回)----
+	//
+	// ⚠ 用**真实欠费行**的旧 UUID `f2166ec3c06204401c19e6a9647b5ead`
+	// (studentid 68556 / remainder 8280.000 / net_receipts 0.000 / **该单号负 log 数 0**)。
+	// ⚠ `user_discount_refund` 的游标**没有 userid 过滤**
+	// (`WHERE courses_studentid=? AND discount_amount_used<0 ORDER BY id DESC`)——
+	// 所以 `countOtherDiscountLogLegacy` 那条前置断言在这一支才第一次真正起作用。
+	// 它若红了,先读文案:那是「数据碰撞,不是实现错」,别去改实现。
+	drStuCourse := "f2166ec3c06204401c19e6a9647b5ead"
+	out = append(out,
+		Case{
+			Name: "DR-1 退回赠送额度·正常部分退回(log 一条 **+**m)",
+			Op:   OpUserDiscountRefund, LegacyUserID: actUser, InstitutionID: 63317, CampusID: 5,
+			SelfSupport: true, AmountCents: 1000,
+			StuCourseID: drStuCourse, StuCourseNumericID: 1, Remark: "W3 退回赠送",
+			Activity: &ActivitySeed{
+				Items: []ActivityItem{
+					{Type: 2, InstitutionID: 63317, CampusID: 5, SignedStatus: -1, MoneyCents: 0},
+				},
+				Discounts: []DiscountSeed{{ActivityIndex: 0, AmountCents: 10000, UsedCents: 3000}},
+				DiscountLogs: []DiscountLogSeed{
+					{DiscountIndex: 0, MoneyCents: -3000, Memo: "W3 使用赠送"},
+				},
+			},
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "已用 30.00(池行 used=30.00,另有一条 −30.00 的消费流水),退 10.00。" +
+				"dump:`discount_amount_used -= tempAmount` + `_log` 写 **+tempAmount**;默认备注「退回赠送金额」。" +
+				"⚠ 不种那条负值流水,游标空转 ⇒ 两侧都 no-op ⇒ 空过",
+		},
+		Case{
+			Name: "DR-2 退回赠送额度·退超(只退到 0 为止,剩余不退)",
+			Op:   OpUserDiscountRefund, LegacyUserID: actUser, InstitutionID: 63317, CampusID: 5,
+			SelfSupport: true, AmountCents: 5000,
+			StuCourseID: drStuCourse, StuCourseNumericID: 1, Remark: "W3 退超",
+			Activity: &ActivitySeed{
+				Items: []ActivityItem{
+					{Type: 2, InstitutionID: 63317, CampusID: 5, SignedStatus: -1, MoneyCents: 0},
+				},
+				Discounts: []DiscountSeed{{ActivityIndex: 0, AmountCents: 10000, UsedCents: 3000}},
+				DiscountLogs: []DiscountLogSeed{
+					{DiscountIndex: 0, MoneyCents: -3000, Memo: "W3 使用赠送"},
+				},
+			},
+			ExpectTargetSuccess: true, ExpectLegacySuccess: true,
+			Note: "已用 30.00,**申请退 50.00**(> 可退)。dump 的算法是「取较小值」:" +
+				"`IF discountAmountUsed>=inputAmount THEN tempAmount=inputAmount ELSE tempAmount=discountAmountUsed` " +
+				"⇒ tempAmount=30.00 ⇒ 池行 used 归 **0.00**、log 一条 **+30.00**,**剩余 20.00 不退、也不报错**。" +
+				"⚠ 注意它**不是**「退成负数」——期望要写成「只退到 0 为止」,写成负数会与实现无关地红",
+		},
+	)
+
 	// ---- 消费(第二批已做,留作回归)----
 	// ---- 消费(第二批已做,留作回归)----
 	base := Case{
 	base := Case{
 		Op: OpExpenditure, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
 		Op: OpExpenditure, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
@@ -771,6 +822,25 @@ func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
 		})
 		})
 	}
 	}
 
 
+	// ①-b 赠送额度的退回:游标**按订单号**、**不带 userid**
+	//(`WHERE courses_studentid=? AND discount_amount_used<0 ORDER BY id DESC`)。
+	// 旧库真有 162 行额度流水,只要订单号撞上真实行,两侧看到的行集天然不同 ——
+	// 那种红是【数据碰撞】,不是 SP 行为错。所以在 case 开始就把"污染源为 0"钉住,
+	// 并且**文案里必须写清"这是数据碰撞,不是实现错"**,否则看红的人第一反应是去改实现。
+	if c.Op == OpUserDiscountRefund || c.Op == OpUserDiscountRefundByActivity {
+		n, err := countOtherDiscountLogLegacy(ctx, legacyDB, c.StuCourseID, nil)
+		if err != nil {
+			return nil, err
+		}
+		res.Assertions = append(res.Assertions, Assertion{Kind: AssertionInfrastructure,
+			Name: "case 有效性前提:该订单号在旧库里**没有别的负额度流水**(游标不带 userid)",
+			Pass: n == 0,
+			Detail: fmt.Sprintf("订单号 %q 在旧库另有 %d 条负额度流水 ⇒ 若 >0,两侧游标看到的行集天然不同 —— "+
+				"这是【数据碰撞】不是实现错;处置:换一个订单号,或把那些流水一并纳入本 case 的 plan",
+				c.StuCourseID, n),
+		})
+	}
+
 	// ② 旧库里如果还有"本 case 之外、同样可被失效"的报名行,legacy 会多失效几条,
 	// ② 旧库里如果还有"本 case 之外、同样可被失效"的报名行,legacy 会多失效几条,
 	// 而 target 侧没有那些活动(活动域还没迁移)—— 两侧失效集合天然不同。
 	// 而 target 侧没有那些活动(活动域还没迁移)—— 两侧失效集合天然不同。
 	// 那种差异是"数据没搬全",不是"SP 行为不对"。与其让它以一个看不懂的差异出现,
 	// 那种差异是"数据没搬全",不是"SP 行为不对"。与其让它以一个看不懂的差异出现,