|
@@ -506,3 +506,68 @@ func TestGetBillPagesFollowsWindowedTotal(t *testing.T) {
|
|
|
t.Fatalf("pageSize/pages = %d/%d,期望 200/1(按钳制后的 200 算,不是请求的 5000)", big.PageSize, big.Pages)
|
|
t.Fatalf("pageSize/pages = %d/%d,期望 200/1(按钳制后的 200 算,不是请求的 5000)", big.PageSize, big.Pages)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+// TestGetBillGiveBalanceDeduplicatesSignedRows 赠送结余必须按活动**去重**(设计 §3.8-A21,DUMP-BUGFIX)。
|
|
|
|
|
+//
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+// dump 的缺陷(**刻意不复刻**,见 giveBalanceBefore 注释第 3 条)
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+//
|
|
|
|
|
+// dump 764182-764189 的结余是 `SUM(activity_money_used)` 建在
|
|
|
|
|
+// `signed RIGHT JOIN (SELECT … GROUP BY activityid)` 上:同一活动有 **k 条 signed** 时,
|
|
|
|
|
+// 那条 used 汇总值会被 JOIN 扇出成 k 行 ⇒ 被加 k 次(`activity_money` 因 GROUP BY 只出现一次,
|
|
|
|
|
+// used 却被放大)⇒ `giveEndBalance`/`givePrevBalance` 双计 ⇒ `giveUsed` 虚增。
|
|
|
|
|
+//
|
|
|
|
|
+// 真实数据仅 1 例(`user 32596 / activity 1`,两条各 1000.000;cutoff 2020-01-01 时
|
|
|
|
|
+// dump=737.022 / Go=1737.022,差 1000.000)。本用例用"1 活动 2 条 signed"把这个形态造出来:
|
|
|
|
|
+//
|
|
|
|
|
+// expected(Go,去重):giveEnd = signed(1000+1000) + used(−100) = 1900.00
|
|
|
|
|
+// dump(扇出,错误) :giveEnd = signed(1000+1000) + used(−100 × 2 条 signed) = 1800.00
|
|
|
|
|
+//
|
|
|
|
|
+// 断言两者**不相等**,并锁定 1900.00 —— 若有人"按注释把第三条补回去",本用例立刻红。
|
|
|
|
|
+//
|
|
|
|
|
+// 注:目标态 `t_courses_activity_signed` 有 UK(user_id, activity_id),造不出重复行,
|
|
|
|
|
+// 故本用例先 DROP 该唯一索引来模拟**旧库形态**(老库无此 UK,正是 A21 那 1 例脏数据的成因)。
|
|
|
|
|
+func TestGetBillGiveBalanceDeduplicatesSignedRows(t *testing.T) {
|
|
|
|
|
+ db := newBillServiceDB(t)
|
|
|
|
|
+ userID := fundUserID(950)
|
|
|
|
|
+ from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
+ to := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
|
+
|
|
|
|
|
+ // 去掉 UK 以复现旧库形态(老库允许同 (user, activity) 多行 → A21 的来源)
|
|
|
|
|
+ if err := db.Exec("DROP INDEX IF EXISTS uk_user_activity").Error; err != nil {
|
|
|
|
|
+ t.Fatalf("DROP INDEX uk_user_activity 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ no := "REQ-DUP"
|
|
|
|
|
+ for i := 0; i < 2; i++ { // 同一活动 2 条 signed,各 1000.00
|
|
|
|
|
+ if err := db.Create(&model.CoursesActivitySigned{
|
|
|
|
|
+ ActivityID: 9002, UserID: userID, ActivityMoney: "1000.00", RequestNo: &no,
|
|
|
|
|
+ }).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed 重复报名(第 %d 条) 失败: %v", i+1, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := db.Create(&model.CoursesActivityUsedLog{
|
|
|
|
|
+ ActivityID: 9002, UserID: userID, ActivityMoneyUsed: "-100.00",
|
|
|
|
|
+ CreateTime: time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC),
|
|
|
|
|
+ }).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed used_log 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ svc := NewBillService(db, fundSuperScope())
|
|
|
|
|
+ res, err := svc.GetBill(context.Background(), GetBillInput{UserID: userID, From: from, To: to})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("GetBill 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 去重口径:signed 2000 + used 只计一次 −100 = 1900
|
|
|
|
|
+ if got, want := res.Summary.GiveEndBalance.String(), "1900.00"; got != want {
|
|
|
|
|
+ t.Fatalf("期末赠送结余 = %s,期望 %s(used 只计一次;若得 1800.00 说明复刻了 A21 的扇出缺陷)",
|
|
|
|
|
+ got, want)
|
|
|
|
|
+ }
|
|
|
|
|
+ if res.Summary.GiveEndBalance.String() == "1800.00" {
|
|
|
|
|
+ t.Fatal("复刻了 dump 的 RIGHT JOIN 扇出(A21,DUMP-BUGFIX)——该缺陷已明确不复刻")
|
|
|
|
|
+ }
|
|
|
|
|
+ // giveUsed = Gift(0) + GivePrev(0) − GiveEnd(1900) = −1900(本用例只关心去重,不构造 gift 链)
|
|
|
|
|
+ if got, want := res.Summary.GiveUsed.String(), "-1900.00"; got != want {
|
|
|
|
|
+ t.Fatalf("当期使用赠送 = %s,期望 %s", got, want)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|