瀏覽代碼

feat(ac15): RS-2 学生账单结果集双跑(1 行 9 列全覆盖)

- rs_bill.go:流水 7 条(期初前 2 / 窗口内 4 / 窗口后 1)+ 充值工单 R1 +
  两条报名(一条挂工单、一条不挂)+ 三条用量流水,钉住 dump 的**三种比较语义**
  (`<start` / `<end` / BETWEEN 含两端)与「赠送结余三加数来自三个不同集合」
- 9 列全覆盖:dump 列 9 = 认领 9 + 豁免 0;每列两条断言(两侧比 + 与声明期望比)
- BillSummary 另 3 个字段(FlowExclusiveEnd / FlowInclusiveEnd / CampusIDs)
  **不属于 dump 9 列**:显式列入 target 独有并对声明断言,侧向覆盖 12/12
- 精度口径 PRECISION-CONVERGENCE(3→2):断言 dump 侧 9 个金额列第 3 位恒为 0
- 账单恒等式两侧各自成立(期末−期初 = 窗口流水合计 = 280.00)
- 期望值函数钉死字面量(rs_cases_test.go),防止期望与实现一起跑偏

实测(`go run ./cmd/dualrun resultsets`):RS-2 9 列 0 豁免,
previousBalance 900 / previousGiveCost 140 / income 500 / activity 100 /
recommend 30 / spend −250 / usedGive 5 / balance 1180 / give 235,
两侧逐列相等且 = 声明期望;业务断言 24 条、基建 4 条;PASS。
全量 2 条 case:2 PASS / 0 FAIL(业务 27 条、基建 8 条、Skipped 0、未分类 0)。

门禁:go build ./... | go vet ./internal/academic/... |
go test ./internal/academic/dualrun/stage2/ -count=1 全绿。
Q 6 小時之前
父節點
當前提交
326a9bc1c8

+ 544 - 0
internal/academic/dualrun/stage2/rs_bill.go

@@ -0,0 +1,544 @@
+package stage2
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"time"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/port"
+	"yuansiwei/unified-backend/internal/academic/service"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// 本文件是 **端点 17 学生账单 `sp_user_expense_get_bill` ↔ `BillService.GetBill`** 的结果集双跑。
+//
+// 取法(可复现):`go run ./cmd/spdump proc sp_user_expense_get_bill`
+// 过程体要点(原文摘录):
+//
+//	4 张 `CREATE TEMPORARY TABLE`(balance_request / expense_log / activity_signed / activity_used_log)
+//	→ 末尾 `DROP TEMPORARY TABLE` ×4 ⇒ **只读、不碰永久表**,可反复调用
+//	游标:SELECT SUM(log.money), SUM(activity.activity_money) FROM temp_user_expense_log log
+//	       LEFT JOIN temp_user_balance_request request ON log.logid=request.id
+//	       LEFT JOIN temp_courses_activity_signed activity ON activity.requestid=request.id
+//	       WHERE log.type=100 AND log.create_time BETWEEN inStartTime AND inEndTime
+//	推荐额 / 消费 / 期末 / 期初:四条 `SELECT (SELECT SUM(money) ... ) INTO ...`
+//	赠送结余:`signed RIGHT JOIN (used_log GROUP BY activityid)`(**A21 扇出缺陷**,Go 刻意不复刻)
+//	`SET currentUsedGiveCost = currentTotalActivityMoney + previousGiveCost - currentGiveCost;`
+//	末尾 SELECT 一行 9 列(顺序固定,逐字照抄):
+//	  previousBalance, previousGiveCost, currentTotalIncome, currentTotalActivityMoney,
+//	  currentTotalRecommendedAmount, currentSpend, currentUsedGiveCost, currentBalance, currentGiveCost
+//
+// ⚠ **`inCampusids` 在 SP 体内从未被引用**(只在声明行出现)⇒ 只校验/回显、不过滤。
+// 这是已登记事实(契约 v1.3),两侧都不该据此过滤 —— 本 case 传真实校区串,不做"过滤与否"的判定。
+//
+// 目标侧:`BillService.GetBill` → `BillResult.Summary`(`service/bill_service.go`),9 列映射见 `rsBillColumns`。
+
+// rsBillWindowStart / End 是查询窗口(字符串形态给 SP,`time.Time` 给 service)。
+//
+// 用「整秒」而不是 `23:59:59.999`:dump 的 `create_time` 在临时表里是 **VARCHAR**,
+// `BETWEEN '2026-01-01 00:00:00' AND '2026-01-31 23:59:59'` 是**字符串比较**,
+// 带毫秒的边界会让"含不含右端"取决于字面量格式 —— 那不是我们要在本 case 验的东西。
+const (
+	rsBillWindowStart = "2026-01-01 00:00:00"
+	rsBillWindowEnd   = "2026-01-31 23:59:59"
+	rsBillReqID       = "RS-BILL-R1"
+)
+
+// rsBillLog 是一条资金流水(两侧同一份声明)。
+type rsBillLog struct {
+	Type       int32
+	MoneyCents int64
+	At         string
+	LogID      string
+}
+
+// rsBillLogs 是账单的**单一声明**。
+//
+// 设计意图(每条都钉一件事):
+//
+//	L1 期初前充值    → 进期初/期末,不进"当期充值"
+//	L2 期初前消费    → 同上(期初 = L1+L2 = 900)
+//	L3 窗口内充值    → 进"当期充值",且 logid 指向 R1 ⇒ 带出赠送额(Gift=100)
+//	L4 窗口内推荐    → 进"当期推荐额"
+//	L5 窗口内消费101 → 进"当期消费"
+//	L6 窗口内消费104 → 同集合,验证 IN 列表不止一个类型
+//	L7 窗口后充值    → **不进期末**(期末按 `< inEndTime`),用来钉"期末不含右端"
+func rsBillLogs() []rsBillLog {
+	return []rsBillLog{
+		{Type: 100, MoneyCents: 100000, At: "2025-12-01 10:00:00"},
+		{Type: 101, MoneyCents: -10000, At: "2025-12-20 10:00:00"},
+		{Type: 100, MoneyCents: 50000, At: "2026-01-05 10:00:00", LogID: rsBillReqID},
+		{Type: 107, MoneyCents: 3000, At: "2026-01-10 10:00:00"},
+		{Type: 101, MoneyCents: -20000, At: "2026-01-15 10:00:00"},
+		{Type: 104, MoneyCents: -5000, At: "2026-01-20 10:00:00"},
+		{Type: 100, MoneyCents: 70000, At: "2026-02-01 10:00:00"},
+	}
+}
+
+// rsBillSigned 是一条报名行(赠送额来源)。
+type rsBillSigned struct {
+	ActivityID int64
+	MoneyCents int64 // activity_money
+	RequestID  string
+	// Used 是该活动的用量流水(`activity_money_used` + 发生时间)。
+	Used []rsBillUsed
+}
+
+// rsBillUsed 是一条用量流水。
+type rsBillUsed struct {
+	MoneyCents int64
+	At         string
+}
+
+// rsBillSigneds 钉住 `giveBalanceBefore` 的三个行为:
+//
+//	900001:有 signed(requestid=R1) + 两条 used_log(一条在期初前、一条在窗口内)
+//	        ⇒ 期初结余 100+40=140、期末结余 100+(40+25)=165
+//	900002:有 signed(无 requestid)+ 一条 used_log(窗口内)
+//	        ⇒ 它**不进** Gift(requestid 为空,不与充值工单关联),但**进**赠送结余(235−165=70)
+//	这正是 dump 的不对称:`GiveUsed = Gift + 期初 − 期末` 的三个加数来自**三个不同的集合**。
+func rsBillSigneds() []rsBillSigned {
+	return []rsBillSigned{
+		{ActivityID: 900001, MoneyCents: 10000, RequestID: rsBillReqID, Used: []rsBillUsed{
+			{MoneyCents: 4000, At: "2025-12-15 10:00:00"},
+			{MoneyCents: 2500, At: "2026-01-15 10:00:00"},
+		}},
+		{ActivityID: 900002, MoneyCents: 6000, Used: []rsBillUsed{
+			{MoneyCents: 1000, At: "2026-01-20 10:00:00"},
+		}},
+	}
+}
+
+// rsBillExpected 由**声明**算出 9 列(逐条照 dump 的四个 cutoff / 三个类型集合)。
+//
+// 这不是"重跑一遍实现":它读的是上面那份声明,且把 dump 的**三种比较语义**分开写
+// (`< start` / `< end` / BETWEEN 含两端)—— 口径混在一起正是这个 SP 最容易错的地方。
+func rsBillExpected() map[string]int64 {
+	start, _ := parseArrearsTime(rsBillWindowStart)
+	end, _ := parseArrearsTime(rsBillWindowEnd)
+	logs := rsBillLogs()
+
+	var prev, cur, income, recommend, spend int64
+	for _, l := range logs {
+		at, err := parseArrearsTime(l.At)
+		if err != nil {
+			panic(err) // 声明是常量,解析失败 = 写错了
+		}
+		if at.Before(start) {
+			prev += l.MoneyCents
+		}
+		if at.Before(end) {
+			cur += l.MoneyCents
+		}
+		if at.Before(start) || at.After(end) {
+			continue
+		}
+		switch l.Type {
+		case 100:
+			income += l.MoneyCents
+		case 107:
+			recommend += l.MoneyCents
+		case 101, 201, 104, 204, 105, 205:
+			spend += l.MoneyCents
+		}
+	}
+
+	var gift, givePrev, giveEnd int64
+	for _, s := range rsBillSigneds() {
+		// Gift:只有"挂在窗口内充值工单上"的报名额才计入当期赠送
+		if s.RequestID == rsBillReqID {
+			gift += s.MoneyCents
+		}
+		// 赠送结余:**只有出现过 used_log(cutoff 之前)的活动**才把 signed 额计进来
+		//(dump 的 `signed RIGHT JOIN (... WHERE create_time<cutoff GROUP BY activityid)`)。
+		var usedPrev, usedEnd int64
+		hitPrev, hitEnd := false, false
+		for _, u := range s.Used {
+			at, err := parseArrearsTime(u.At)
+			if err != nil {
+				panic(err)
+			}
+			if at.Before(start) {
+				usedPrev += u.MoneyCents
+				hitPrev = true
+			}
+			if at.Before(end) {
+				usedEnd += u.MoneyCents
+				hitEnd = true
+			}
+		}
+		if hitPrev {
+			givePrev += s.MoneyCents + usedPrev
+		}
+		if hitEnd {
+			giveEnd += s.MoneyCents + usedEnd
+		}
+	}
+	usedGive := gift + givePrev - giveEnd
+
+	return map[string]int64{
+		"previousBalance":               prev,
+		"previousGiveCost":              givePrev,
+		"currentTotalIncome":            income,
+		"currentTotalActivityMoney":     gift,
+		"currentTotalRecommendedAmount": recommend,
+		"currentSpend":                  spend,
+		"currentUsedGiveCost":           usedGive,
+		"currentBalance":                cur,
+		"currentGiveCost":               giveEnd,
+	}
+}
+
+// rsBillExpectedNorm 把期望值渲染成 2 位小数字符串(比对口径)。
+func rsBillExpectedNorm() map[string]string {
+	out := make(map[string]string, 9)
+	for k, v := range rsBillExpected() {
+		out[k] = yuan(v)
+	}
+	return out
+}
+
+// seedLegacyBill 种 legacy 侧(流水 + 充值工单 + 报名 + 用量流水)。
+func seedLegacyBill(ctx context.Context, tx *gorm.DB) error {
+	cleanup := []struct {
+		stmt string
+		args []any
+	}{
+		{`DELETE FROM t_user_expense_log WHERE userid = ?`, []any{rsUserID}},
+		{`DELETE FROM t_user_balance_request WHERE userid = ?`, []any{rsUserID}},
+		{`DELETE FROM t_courses_activity_signed WHERE userid = ?`, []any{rsUserID}},
+		{`DELETE FROM t_courses_activity_used_log WHERE userid = ?`, []any{rsUserID}},
+	}
+	for _, c := range cleanup {
+		if err := tx.Exec(c.stmt, c.args...).Error; err != nil {
+			return fmt.Errorf("清理 legacy 账单残留失败(%s):%w", c.stmt, err)
+		}
+	}
+	for i, l := range rsBillLogs() {
+		var logID any
+		if l.LogID != "" {
+			logID = l.LogID
+		}
+		if err := requireRows(fmt.Sprintf("种 legacy 流水 #%d", i+1), tx.Exec(`
+INSERT INTO t_user_expense_log (userid, type, logid, money, create_userid, create_time, remark)
+VALUES (?, ?, ?, ?, 0, ?, 'rs-bill')`,
+			rsUserID, l.Type, logID, yuan(l.MoneyCents), l.At), 1); err != nil {
+			return err
+		}
+	}
+	if err := requireRows("种 legacy 充值工单 "+rsBillReqID, tx.Exec(`
+INSERT INTO t_user_balance_request
+  (id, type, userid, schoolid, campusid, money, request_userid, request_time, response_time, aproved)
+VALUES (?, 100, ?, ?, ?, ?, ?, '2026-01-05 09:00:00', '2026-01-06 09:00:00', 1)`,
+		rsBillReqID, rsUserID, rsInstID, rsCampusID, yuan(50000), rsUserID), 1); err != nil {
+		return err
+	}
+	for _, s := range rsBillSigneds() {
+		var reqID any
+		if s.RequestID != "" {
+			reqID = s.RequestID
+		}
+		if err := requireRows(fmt.Sprintf("种 legacy 报名 %d", s.ActivityID), tx.Exec(`
+INSERT INTO t_courses_activity_signed
+  (activityid, userid, status, activity_money, activity_money_used, requestid, create_time)
+VALUES (?, ?, 2, ?, 0, ?, '2026-01-05 09:00:00')`,
+			s.ActivityID, rsUserID, yuan(s.MoneyCents), reqID), 1); err != nil {
+			return err
+		}
+		for i, u := range s.Used {
+			if err := requireRows(fmt.Sprintf("种 legacy 用量流水 %d#%d", s.ActivityID, i+1), tx.Exec(`
+INSERT INTO t_courses_activity_used_log (activityid, userid, recommend_userid, orderid, activity_money_used, create_time, memo)
+VALUES (?, ?, 0, '', ?, ?, 'rs-bill')`,
+				s.ActivityID, rsUserID, yuan(u.MoneyCents), u.At), 1); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+// seedTargetBill 种 target 侧(列名是目标态命名)。
+func seedTargetBill(ctx context.Context, tx *gorm.DB, uid string) error {
+	if err := seedResultSetIdentityTarget(ctx, tx, uid, rsUserID, rsInstID, rsCampusID); err != nil {
+		return err
+	}
+	cleanup := []struct {
+		stmt string
+		args []any
+	}{
+		{`DELETE FROM t_user_expense_log WHERE user_id = ?`, []any{uid}},
+		{`DELETE FROM t_user_balance_request WHERE user_id = ?`, []any{uid}},
+		{`DELETE FROM t_courses_activity_signed WHERE user_id = ?`, []any{uid}},
+		{`DELETE FROM t_courses_activity_used_log WHERE user_id = ?`, []any{uid}},
+	}
+	for _, c := range cleanup {
+		if err := tx.Exec(c.stmt, c.args...).Error; err != nil {
+			return fmt.Errorf("清理 target 账单残留失败(%s):%w", c.stmt, err)
+		}
+	}
+	for i, l := range rsBillLogs() {
+		var logID any
+		if l.LogID != "" {
+			logID = l.LogID
+		}
+		if err := requireRows(fmt.Sprintf("种 target 流水 #%d", i+1), tx.Exec(`
+INSERT INTO t_user_expense_log (user_id, type, log_id, money, create_user_id, create_time, remark)
+VALUES (?, ?, ?, ?, NULL, ?, 'rs-bill')`,
+			uid, l.Type, logID, yuan(l.MoneyCents), l.At), 1); err != nil {
+			return err
+		}
+	}
+	if err := requireRows("种 target 充值工单 "+rsBillReqID, tx.Exec(`
+INSERT INTO t_user_balance_request
+  (biz_no, type, user_id, institution_id, campus_id, money, request_user_id, request_time, response_time, approved)
+VALUES (?, 100, ?, ?, ?, ?, ?, '2026-01-05 09:00:00', '2026-01-06 09:00:00', 1)`,
+		rsBillReqID, uid, rsInstID, rsCampusID, yuan(50000), uid), 1); err != nil {
+		return err
+	}
+	for _, s := range rsBillSigneds() {
+		var reqID any
+		if s.RequestID != "" {
+			reqID = s.RequestID
+		}
+		if err := requireRows(fmt.Sprintf("种 target 报名 %d", s.ActivityID), tx.Exec(`
+INSERT INTO t_courses_activity_signed
+  (activity_id, user_id, institution_id, campus_id, status, activity_money, activity_money_used, request_no, create_time)
+VALUES (?, ?, ?, ?, 2, ?, 0, ?, '2026-01-05 09:00:00')`,
+			s.ActivityID, uid, rsInstID, rsCampusID, yuan(s.MoneyCents), reqID), 1); err != nil {
+			return err
+		}
+		for i, u := range s.Used {
+			if err := requireRows(fmt.Sprintf("种 target 用量流水 %d#%d", s.ActivityID, i+1), tx.Exec(`
+INSERT INTO t_courses_activity_used_log (activity_id, user_id, order_no, activity_money_used, create_time, memo)
+VALUES (?, ?, '', ?, ?, 'rs-bill')`,
+				s.ActivityID, uid, yuan(u.MoneyCents), u.At), 1); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+// verifyLegacyBillSeed / verifyTargetBillSeed:读回"流水条数 + 窗口内 Σmoney + 用量条数"。
+func verifyLegacyBillSeed(ctx context.Context, tx *gorm.DB) (string, error) {
+	return verifyBillSeed(ctx, tx, "legacy", rsUserID, false)
+}
+
+func verifyTargetBillSeed(ctx context.Context, tx *gorm.DB, uid string) (string, error) {
+	return verifyBillSeed(ctx, tx, "target", uid, true)
+}
+
+// verifyBillSeed 只核对种子事实,不重算账单(理由同 refundable)。
+func verifyBillSeed(ctx context.Context, tx *gorm.DB, side string, who any, target bool) (string, error) {
+	logUser, sigUser, usedUser := "userid", "userid", "userid"
+	if target {
+		logUser, sigUser, usedUser = "user_id", "user_id", "user_id"
+	}
+	var logCount, sigCount, usedCount int
+	if err := tx.Raw(fmt.Sprintf(`SELECT COUNT(*) FROM t_user_expense_log WHERE %s = ?`, logUser), who).
+		Row().Scan(&logCount); err != nil {
+		return "", fmt.Errorf("[%s] 读回流水条数失败:%w", side, err)
+	}
+	if err := tx.Raw(fmt.Sprintf(`SELECT COUNT(*) FROM t_courses_activity_signed WHERE %s = ?`, sigUser), who).
+		Row().Scan(&sigCount); err != nil {
+		return "", fmt.Errorf("[%s] 读回报名条数失败:%w", side, err)
+	}
+	if err := tx.Raw(fmt.Sprintf(`SELECT COUNT(*) FROM t_courses_activity_used_log WHERE %s = ?`, usedUser), who).
+		Row().Scan(&usedCount); err != nil {
+		return "", fmt.Errorf("[%s] 读回用量流水条数失败:%w", side, err)
+	}
+	wantUsed := 0
+	for _, s := range rsBillSigneds() {
+		wantUsed += len(s.Used)
+	}
+	if logCount != len(rsBillLogs()) || sigCount != len(rsBillSigneds()) || usedCount != wantUsed {
+		return "", fmt.Errorf("[%s] 种子条数与声明不符:流水 %d/%d、报名 %d/%d、用量 %d/%d",
+			side, logCount, len(rsBillLogs()), sigCount, len(rsBillSigneds()), usedCount, wantUsed)
+	}
+	// 九列里最容易"看起来对"的是期初/期末:它们来自全量流水,不来自窗口。
+	// 所以读回时把两条总额也核一遍(Σ 全量 = 1180.00)。
+	var total string
+	if err := tx.Raw(fmt.Sprintf(`SELECT CAST(SUM(CAST(money AS DECIMAL(20,3))) AS DECIMAL(20,3))
+  FROM t_user_expense_log WHERE %s = ?`, logUser), who).Row().Scan(&total); err != nil {
+		return "", fmt.Errorf("[%s] 读回全量流水合计失败:%w", side, err)
+	}
+	if c, err := centsOf(total); err != nil || c != rsBillExpectedNormTotalCents() {
+		return "", fmt.Errorf("[%s] 全量流水合计读回 %q,声明 %s", side, total, yuan(rsBillExpectedNormTotalCents()))
+	}
+	return fmt.Sprintf("流水 %d 条 / 报名 %d 条 / 用量 %d 条 / Σmoney=%s",
+		logCount, sigCount, usedCount, total), nil
+}
+
+// rsBillExpectedNormTotalCents 是**表里全部流水**的合计(种子读回口径),由声明直接累加。
+//
+// 它与"期末余额"不同:期末是 `create_time < end`,而 L7 在 end 之后 —— 所以这里刻意不写
+// `= 期末`,否则读回校验会与 SP 的口径混为一谈(那正是本 case 要分开验的东西)。
+func rsBillExpectedNormTotalCents() int64 {
+	var sum int64
+	for _, l := range rsBillLogs() {
+		sum += l.MoneyCents
+	}
+	return sum
+}
+
+// rsBillColumns 是 9 列映射(Legacy 列名 ← SP 末尾 SELECT;Target ← BillSummary 字段)。
+func rsBillColumns() []ResultColumn {
+	return []ResultColumn{
+		{Legacy: "previousBalance", Target: "PrevBalance", Kind: CellMoney},
+		{Legacy: "previousGiveCost", Target: "GivePrevBalance", Kind: CellMoney},
+		{Legacy: "currentTotalIncome", Target: "Recharge", Kind: CellMoney},
+		{Legacy: "currentTotalActivityMoney", Target: "Gift", Kind: CellMoney},
+		{Legacy: "currentTotalRecommendedAmount", Target: "RecommendAmount", Kind: CellMoney},
+		{Legacy: "currentSpend", Target: "Spend", Kind: CellMoney},
+		{Legacy: "currentUsedGiveCost", Target: "GiveUsed", Kind: CellMoney},
+		{Legacy: "currentBalance", Target: "EndBalance", Kind: CellMoney},
+		{Legacy: "currentGiveCost", Target: "GiveEndBalance", Kind: CellMoney},
+	}
+}
+
+// rsBillCase 组装账单 case。
+func rsBillCase() ResultSetCase {
+	from, _ := parseArrearsTime(rsBillWindowStart)
+	to, _ := parseArrearsTime(rsBillWindowEnd)
+	return ResultSetCase{
+		Name: "RS-2 学生账单 sp_user_expense_get_bill(一行 9 列)",
+		Note: "9 列全覆盖;三条不同集合(Gift / 期初结余 / 期末结余)各自有独立数据," +
+			"`GiveUsed = Gift + 期初 − 期末` 因此不是恒等式能凑出来的;窗口后流水钉「期末不含右端」",
+		LegacyUserID: rsUserID, InstitutionID: rsInstID, CampusID: rsCampusID,
+		SeedLegacy:   seedLegacyBill,
+		SeedTarget:   seedTargetBill,
+		VerifyLegacy: verifyLegacyBillSeed,
+		VerifyTarget: verifyTargetBillSeed,
+		LegacyCall:   "CALL sp_user_expense_get_bill(?, ?, ?, ?)",
+		LegacyArgs:   []any{rsUserID, "990101", rsBillWindowStart, rsBillWindowEnd},
+		CallTarget: func(ctx context.Context, tx *gorm.DB, uid string, sc *appscope.Scope) (TargetPayload, error) {
+			svc := service.NewBillService(tx, sc).WithMembershipChecker(port.NewOrgMembershipChecker(tx))
+			res, err := svc.GetBill(ctx, service.GetBillInput{
+				UserID: uid, CampusIDs: "990101", From: from, To: to, Page: 1, PageSize: 20,
+			})
+			if err != nil {
+				return TargetPayload{}, err
+			}
+			s := res.Summary
+			campusIDs := make([]string, 0, len(s.CampusIDs))
+			for _, id := range s.CampusIDs {
+				campusIDs = append(campusIDs, fmt.Sprintf("%d", id))
+			}
+			return TargetPayload{
+				Label: "BillService.GetBill",
+				Values: map[string]Cell{
+					"PrevBalance":      MoneyCell(int64(s.PrevBalance)),
+					"GivePrevBalance":  MoneyCell(int64(s.GivePrevBalance)),
+					"Recharge":         MoneyCell(int64(s.Recharge)),
+					"Gift":             MoneyCell(int64(s.Gift)),
+					"RecommendAmount":  MoneyCell(int64(s.RecommendAmount)),
+					"Spend":            MoneyCell(int64(s.Spend)),
+					"GiveUsed":         MoneyCell(int64(s.GiveUsed)),
+					"EndBalance":       MoneyCell(int64(s.EndBalance)),
+					"GiveEndBalance":   MoneyCell(int64(s.GiveEndBalance)),
+					"FlowExclusiveEnd": MoneyCell(int64(s.FlowExclusiveEnd)),
+					"FlowInclusiveEnd": MoneyCell(int64(s.FlowInclusiveEnd)),
+					"CampusIDs":        TextCell(strings.Join(campusIDs, ",")),
+				},
+				Evidence: fmt.Sprintf("total=%d 明细 %d 条(明细不属于 9 列)", res.Total, len(res.Details)),
+			}, nil
+		},
+		Compare: func(lg ResultSet, tg TargetPayload) ResultSetInput {
+			expNorm := rsBillExpectedNorm()
+			exp := rsBillExpected()
+			return ResultSetInput{
+				CaseName:       "RS-2",
+				Legacy:         lg,
+				ExpectRows:     1,
+				Target:         tg,
+				Columns:        rsBillColumns(),
+				Expect:         expNorm,
+				PrecisionCheck: true,
+				// BillSummary 的另外三个字段**不属于 dump 的 9 列**:它们不进 columnCoverage,
+				// 但必须显式列出来(侧向覆盖要求 target 的每个字段都被看过),并各自对声明断言。
+				TargetOnly: []TargetOnlyColumn{
+					{Target: "FlowExclusiveEnd", Kind: CellMoney,
+						Expect: MoneyCell(exp["currentBalance"] - exp["previousBalance"]),
+						Note:   "target 独有:窗口内全类型流水(`from <= t < to`),恒等于 期末 − 期初"},
+					{Target: "FlowInclusiveEnd", Kind: CellMoney,
+						Expect: MoneyCell(exp["currentBalance"] - exp["previousBalance"]),
+						Note: "target 独有:窗口内全类型流水(`from <= t <= to`);本种子无落在 t=to 的流水," +
+							"故与 Exclusive 相等(若要区分两者,得再种一条 `t = to` 的流水 —— 见报告「未覆盖」)"},
+					{Target: "CampusIDs", Kind: CellText, Expect: TextCell("990101"),
+						Note: "target 独有回显:dump 的第二入参**全程未引用**,这里只验「解析回显」"},
+				},
+				ExtraAssertions: func(lg ResultSet, tg TargetPayload) []Assertion {
+					// 账单恒等式:期末 − 期初 == 窗口内流水合计。
+					// 两侧各用自己的列算(不是拿两侧互相比),所以它能抓住"两侧同时错"。
+					var out []Assertion
+					lPrev, _ := normalizeCell(CellMoney, mustCell(lg, 0, "previousBalance"))
+					lCur, _ := normalizeCell(CellMoney, mustCell(lg, 0, "currentBalance"))
+					lEnd, _ := normalizeCell(CellMoney, mustCell(lg, 0, "currentGiveCost"))
+					windowSum := int64(0)
+					ws, we := mustParse(rsBillWindowStart), mustParse(rsBillWindowEnd)
+					for _, l := range rsBillLogs() {
+						at := mustParse(l.At)
+						if !at.Before(ws) && !at.After(we) {
+							windowSum += l.MoneyCents
+						}
+					}
+					wantDelta := yuan(exp["currentBalance"] - exp["previousBalance"])
+					lgDelta := yuan(mustCents(lCur) - mustCents(lPrev))
+					out = append(out, Assertion{
+						Kind: AssertionBusiness,
+						Name: "账单恒等式(legacy 自洽):期末 − 期初 = 窗口内流水合计;且 = 声明期望",
+						Pass: lgDelta == wantDelta && wantDelta == yuan(windowSum),
+						Detail: fmt.Sprintf("legacy 期末−期初=%s | 声明期望 %s | 声明流水窗口合计 %s。"+
+							"(赠送结余期末 %s 与本恒等式无关,单列)", lgDelta, wantDelta, yuan(windowSum), lEnd),
+					})
+					tgPrev, _ := tg.TargetColumnValue("PrevBalance")
+					tgCur, _ := tg.TargetColumnValue("EndBalance")
+					tgFlow, _ := tg.TargetColumnValue("FlowExclusiveEnd")
+					out = append(out, Assertion{
+						Kind: AssertionBusiness,
+						Name: "账单恒等式(target 自洽):EndBalance − PrevBalance = FlowExclusiveEnd",
+						Pass: diffCents(tgCur, tgPrev) == mustCents(tgFlow.String()),
+						Detail: fmt.Sprintf("target 期末−期初=%s | FlowExclusiveEnd=%s | 期望 %s",
+							yuan(diffCents(tgCur, tgPrev)), tgFlow.String(), wantDelta),
+					})
+					return out
+				},
+			}
+		},
+	}
+}
+
+// mustCell 取单元格(缺列 = 编程错误,直接 panic:这是 case 自己的映射 bug)。
+func mustCell(rs ResultSet, row int, col string) Cell {
+	c, ok := RawCellOf(rs, row, col)
+	if !ok {
+		panic("结果集里没有列 " + col)
+	}
+	return c
+}
+
+// mustParse 解析声明里的时间常量(失败 = 声明写错,panic 比静默零值安全)。
+func mustParse(s string) time.Time {
+	t, err := parseArrearsTime(s)
+	if err != nil {
+		panic(err)
+	}
+	return t
+}
+
+// mustCents 把 2 位小数字符串转成"分"(失败返回 0 —— 只用于恒等式展示,判定另有归一函数把关)。
+func mustCents(s string) int64 {
+	c, err := centsOf(s)
+	if err != nil {
+		return 0
+	}
+	return c
+}
+
+// diffCents 用两个单元格算差(分)。
+func diffCents(a, b Cell) int64 {
+	return mustCents(a.String()) - mustCents(b.String())
+}

+ 1 - 0
internal/academic/dualrun/stage2/rs_cases.go

@@ -10,5 +10,6 @@ package stage2
 func ResultSetCases() []ResultSetCase {
 	return []ResultSetCase{
 		rsRefundableCase(),
+		rsBillCase(),
 	}
 }

+ 48 - 0
internal/academic/dualrun/stage2/rs_cases_test.go

@@ -0,0 +1,48 @@
+package stage2
+
+import "testing"
+
+// 本文件钉住三条结果集 case 的**期望值函数**。
+//
+// 为什么期望值本身要测:它是"第三腿"(两侧一致 ≠ 都对)的唯一来源。
+// 期望函数算错时,两侧一致会**照样绿**,只有"与声明期望比"这一条会红 ——
+// 但如果期望函数错得恰好与实现一致,它就永远红不了。所以这里用**写死的字面量**再钉一次:
+// 改种子 / 改口径时,必须回来解释这两个数为什么变。
+
+func TestRefundableExpected(t *testing.T) {
+	if got := rsRefundableUsedSum(); got != 11500 {
+		t.Fatalf("参与扣减的已用赠送额 = %d 分,期望 11500(70.00 + 20.00 + 25.00;"+
+			"B 已用尽 / C 别的校区 / D 非充值 / E 未审批 各应被排除)", got)
+	}
+	if got := rsRefundableExpected(); got != 68500 {
+		t.Fatalf("可退金额 = %d 分,期望 68500(min(800,1000) − 115 = 685)", got)
+	}
+}
+
+func TestBillExpected(t *testing.T) {
+	got := rsBillExpected()
+	want := map[string]int64{
+		"previousBalance":               90000,  // 1000 − 100(期初前两条)
+		"previousGiveCost":              14000,  // 900001: 100 signed + 40 used
+		"currentTotalIncome":            50000,  // L3
+		"currentTotalActivityMoney":     10000,  // 只有挂着 R1 的那条 signed
+		"currentTotalRecommendedAmount": 3000,   // L4
+		"currentSpend":                  -25000, // L5 + L6
+		"currentUsedGiveCost":           500,    // 100 + 140 − 235
+		"currentBalance":                118000, // 期初 900 + 窗口内 280
+		"currentGiveCost":               23500,  // 900001: 165 + 900002: 70
+	}
+	for k, w := range want {
+		if got[k] != w {
+			t.Errorf("%s = %d 分,期望 %d 分", k, got[k], w)
+		}
+	}
+	if len(got) != len(want) {
+		t.Errorf("期望值列数 = %d,应为 9(dump 的 9 列一列都不能少)", len(got))
+	}
+	// 赠送结余的三个加数来自三个不同集合:Gift 只含 R1 关联的 100,
+	// 结余却把"没挂工单"的 900002 也算进去 —— 这正是 dump 的不对称,不能"顺手统一"。
+	if got["currentUsedGiveCost"] == got["currentTotalActivityMoney"] {
+		t.Error("GiveUsed 恰好等于 Gift:说明种子没把「不同集合」这件事显出来,这条断言变成恒等式")
+	}
+}