소스 검색

feat(ac15): RS-5 账单右端语义(`t = to` 只进 FlowInclusiveEnd)

补掉 RS-2 未覆盖第 4 条:`t = to` 的流水。四条流水钉三个边界(窗口前 / 恰在 from /
窗口内 / 恰在 to),期望**先算**再跑:

  Prev −10.00 | End 60.00 | Recharge 100.00 | Spend −30.00
  FlowExclusiveEnd 70.00 | FlowInclusiveEnd 120.00 | 差 50.00(= `to` 当天那笔)
  恒等式:期初 + Exclusive = 期末(−10 + 70 = 60)

两处口径写进代码注释,防后来人"顺手优化":
- dump 的 create_time 是 VARCHAR,`BETWEEN` 是字符串比较;传日期串时
  `'2026-03-31 00:00:00' <= '2026-03-31'` 为 false ⇒ t=to 不进当期发生额;
- 因此 D 故意取 **type=200**(不在消费 IN 列表):若取 100/101,legacy 与 target
  的当期发生额会因字符串比较而差,掩盖右端语义本身。

实测(`resultsets`):RS-5 9 列 0 豁免、业务断言 25 条、基建 4 条;PASS。
变异(target 的 to 提前 1 小时)实测转红 2 项(Inclusive + 右端差),已还原。
全量 4 条 case:4 PASS / 0 FAIL(业务 69 / 基建 16 / Skipped 11 / 未分类 0)。

门禁:go build ./... | go vet ./internal/academic/... |
go test ./internal/academic/dualrun/stage2/ -count=1 全绿。
Q 5 시간 전
부모
커밋
a02b8addcc
2개의 변경된 파일과 338개의 추가작업 그리고 0개의 파일을 삭제
  1. 337 0
      internal/academic/dualrun/stage2/rs_bill_window.go
  2. 1 0
      internal/academic/dualrun/stage2/rs_cases.go

+ 337 - 0
internal/academic/dualrun/stage2/rs_bill_window.go

@@ -0,0 +1,337 @@
+package stage2
+
+import (
+	"context"
+	"fmt"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/port"
+	"yuansiwei/unified-backend/internal/academic/service"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// 本文件是 **右端语义**(case RS-5)的结果集双跑。
+//
+// # 为什么单开一条
+//
+// `BillSummary` 有两个字段就是为这件事定义的(契约 v1.3):
+//
+//	FlowExclusiveEnd  窗口内全类型流水,`from <= t <  to`(右开)
+//	FlowInclusiveEnd  窗口内全类型流水,`from <= t <= to`(右闭)
+//
+// 二者之差 **恰好是落在 `t = to` 的那一笔**。账单页最容易错、且错得最隐蔽的就是这里
+//(少算/多算一天),而 RS-2 的种子里没有 `t = to` 的流水 ⇒ 两个字段恒相等 ⇒ **零覆盖**。
+//
+// # 一个必须知道的口径细节(它决定 D 的 type 怎么选)
+//
+// dump 的 `create_time` 在临时表里是 **VARCHAR**,`BETWEEN inStartTime AND inEndTime` 是
+// **字符串比较**;而 target 是 `datetime(3)` + `time.Time`。按文档口径,前端传 `YYYY-MM-DD`
+//(本 case 传 `2026-03-01` / `2026-03-31`):
+//
+//	'2026-03-31 00:00:00' <= '2026-03-31'  →  **false**(前者是后者的延长串,字典序更大)
+//
+// ⇒ 在 dump 里,`t = to` 的那笔**既不进当期发生额、也不进期末**。
+// 本 case 的 D 因此**故意取 type=200**(不在 `(101,201,104,204,105,205)` 的消费集合里):
+// 若取 type=100,target 的 `Recharge` 会含 D 而 legacy 不含 —— 那是**入参格式**造成的
+// 字符串比较差,不是右端语义本身。把 D 放在"不参与当期发生额"的类型上,
+// 分歧就只出现在**该出现的地方**:target 的两个 flow 字段与两侧的期末余额。
+//
+// # 种子的四条流水(单位:分)
+//
+//	A 2026-02-28 12:00  −10.00  type=200  只进 PrevBalance
+//	B 2026-03-01 00:00  +100.00 type=100  **恰在 from**(左闭)⇒ 进当期充值 + 期末
+//	C 2026-03-15 12:00  −30.00  type=101  窗口内 ⇒ 进当期消费 + 期末
+//	D 2026-03-31 00:00  +50.00  type=200  **恰在 to**(右开)⇒ 不进期末/Exclusive,只进 Inclusive
+//
+// 期望(先算再跑):Prev −10.00;End 60.00;Exclusive 70.00;Inclusive 120.00;
+// 二者之差 50.00;期末 − 期初 = 70.00。
+
+const (
+	rsWinStart = "2026-03-01"
+	rsWinEnd   = "2026-03-31"
+)
+
+// rsWindowLog 是一条流水(与 `rsBillLog` 同构,但本 case 的声明要独立可读)。
+type rsWindowLog struct {
+	Type       int32
+	MoneyCents int64
+	At         string
+	Note       string
+}
+
+// rsWindowLogs 是上面那张表的代码形态。
+func rsWindowLogs() []rsWindowLog {
+	return []rsWindowLog{
+		{Type: 200, MoneyCents: -1000, At: "2026-02-28 12:00:00", Note: "窗口前(只进期初)"},
+		{Type: 100, MoneyCents: 10000, At: "2026-03-01 00:00:00", Note: "恰在 from(左闭)"},
+		{Type: 101, MoneyCents: -3000, At: "2026-03-15 12:00:00", Note: "窗口内"},
+		{Type: 200, MoneyCents: 5000, At: "2026-03-31 00:00:00", Note: "恰在 to(右开)"},
+	}
+}
+
+// rsWindowExpected 由声明算出 9 列 + 两个 flow 字段(**先算**)。
+func rsWindowExpected() (cols map[string]string, exclusive, inclusive int64) {
+	var prev, cur, income, spend int64
+	start, end := rsWinStart+" 00:00:00", rsWinEnd+" 00:00:00"
+	for _, l := range rsWindowLogs() {
+		// 三种比较语义(dump 的口径,逐条分开写,不合并):
+		if l.At < start {
+			prev += l.MoneyCents
+		}
+		if l.At < end {
+			cur += l.MoneyCents
+		}
+		// 「当期发生额」= BETWEEN 含两端;本 case 传日期串,字符串比较下 t=to 不含(见文件头)
+		if l.At >= start && l.At <= rsWinEnd {
+			switch l.Type {
+			case 100:
+				income += l.MoneyCents
+			case 101, 201, 104, 204, 105, 205:
+				spend += l.MoneyCents
+			}
+		}
+		// 两个 flow 字段:右开 / 右闭(target 独有)
+		if l.At >= start && l.At < end {
+			exclusive += l.MoneyCents
+		}
+		if l.At >= start && l.At <= end {
+			inclusive += l.MoneyCents
+		}
+	}
+	cols = map[string]string{
+		"previousBalance":               yuan(prev),
+		"previousGiveCost":              yuan(0),
+		"currentTotalIncome":            yuan(income),
+		"currentTotalActivityMoney":     yuan(0),
+		"currentTotalRecommendedAmount": yuan(0),
+		"currentSpend":                  yuan(spend),
+		"currentUsedGiveCost":           yuan(0),
+		"currentBalance":                yuan(cur),
+		"currentGiveCost":               yuan(0),
+	}
+	return cols, exclusive, inclusive
+}
+
+// rsWindowAtToCents 取落在 `t = to` 的那笔金额(用于"差额就是它"的断言)。
+func rsWindowAtToCents() int64 {
+	end := rsWinEnd + " 00:00:00"
+	var sum int64
+	for _, l := range rsWindowLogs() {
+		if l.At == end {
+			sum += l.MoneyCents
+		}
+	}
+	return sum
+}
+
+// seedLegacyWindow 种 legacy 侧 4 条流水。
+func seedLegacyWindow(ctx context.Context, tx *gorm.DB) error {
+	if err := tx.Exec(`DELETE FROM t_user_expense_log WHERE userid = ?`, rsUserID).Error; err != nil {
+		return fmt.Errorf("清理 legacy 右端语义种子失败:%w", err)
+	}
+	for i, l := range rsWindowLogs() {
+		if err := requireRows(fmt.Sprintf("种 legacy 流水 #%d(%s)", i+1, l.Note), tx.Exec(`
+INSERT INTO t_user_expense_log (userid, type, logid, money, create_userid, create_time, remark)
+VALUES (?, ?, NULL, ?, 0, ?, 'rs-window')`,
+			rsUserID, l.Type, yuan(l.MoneyCents), l.At), 1); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// seedTargetWindow 种 target 侧同一批流水。
+func seedTargetWindow(ctx context.Context, tx *gorm.DB, uid string) error {
+	if err := seedResultSetIdentityTarget(ctx, tx, uid, rsUserID, rsInstID, rsCampusID); err != nil {
+		return err
+	}
+	if err := tx.Exec(`DELETE FROM t_user_expense_log WHERE user_id = ?`, uid).Error; err != nil {
+		return fmt.Errorf("清理 target 右端语义种子失败:%w", err)
+	}
+	for i, l := range rsWindowLogs() {
+		if err := requireRows(fmt.Sprintf("种 target 流水 #%d(%s)", i+1, l.Note), tx.Exec(`
+INSERT INTO t_user_expense_log (user_id, type, log_id, money, create_user_id, create_time, remark)
+VALUES (?, ?, NULL, ?, NULL, ?, 'rs-window')`,
+			uid, l.Type, yuan(l.MoneyCents), l.At), 1); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// verifyLegacyWindowSeed / verifyTargetWindowSeed:条数 + 总数 + `t = to` 的那一笔。
+func verifyLegacyWindowSeed(ctx context.Context, tx *gorm.DB) (string, error) {
+	return verifyWindowSeed(ctx, tx, "legacy", rsUserID, false)
+}
+
+func verifyTargetWindowSeed(ctx context.Context, tx *gorm.DB, uid string) (string, error) {
+	return verifyWindowSeed(ctx, tx, "target", uid, true)
+}
+
+func verifyWindowSeed(ctx context.Context, tx *gorm.DB, side string, who any, target bool) (string, error) {
+	userCol := "userid"
+	if target {
+		userCol = "user_id"
+	}
+	var n int
+	var total string
+	if err := tx.Raw(fmt.Sprintf(`SELECT COUNT(*) FROM t_user_expense_log WHERE %s = ?`, userCol), who).
+		Row().Scan(&n); err != nil {
+		return "", fmt.Errorf("[%s] 读回流水条数失败:%w", side, err)
+	}
+	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 = ?`, userCol), who).Row().Scan(&total); err != nil {
+		return "", fmt.Errorf("[%s] 读回流水合计失败:%w", side, err)
+	}
+	var atTo int
+	if err := tx.Raw(fmt.Sprintf(`SELECT COUNT(*) FROM t_user_expense_log WHERE %s = ? AND create_time = ?`,
+		userCol), who, rsWinEnd+" 00:00:00").Row().Scan(&atTo); err != nil {
+		return "", fmt.Errorf("[%s] 读回 `t = to` 条数失败:%w", side, err)
+	}
+	var wantTotal int64
+	for _, l := range rsWindowLogs() {
+		wantTotal += l.MoneyCents
+	}
+	if n != len(rsWindowLogs()) {
+		return "", fmt.Errorf("[%s] 流水 %d 条,声明 %d 条", side, n, len(rsWindowLogs()))
+	}
+	if c, err := centsOf(total); err != nil || c != wantTotal {
+		return "", fmt.Errorf("[%s] 流水合计读回 %q,声明 %s", side, total, yuan(wantTotal))
+	}
+	if atTo != 1 {
+		return "", fmt.Errorf("[%s] `t = to` 的流水 %d 条,声明 1 条(右端语义全靠它)", side, atTo)
+	}
+	return fmt.Sprintf("流水 %d 条 / Σ=%s / `t=to` 1 条", n, total), nil
+}
+
+// rsWindowCase 组装 RS-5。
+func rsWindowCase() ResultSetCase {
+	from := mustParse(rsWinStart + " 00:00:00")
+	to := mustParse(rsWinEnd + " 00:00:00")
+	cols, exclusive, inclusive := rsWindowExpected()
+	return ResultSetCase{
+		Name: "RS-5 账单右端语义:`t = to` 的那一笔只进 FlowInclusiveEnd(不进期末/Exclusive)",
+		Note: "四条流水钉三个边界:窗口前 / 恰在 from(左闭)/ 窗口内 / 恰在 to(右开)。" +
+			"期初 −10、期末 60、Exclusive 70、Inclusive 120、差 50 = `to` 当天那笔。" +
+			"`t=to` 的流水故意取 type=200:若取 100/101,legacy 的字符串比较会让当期发生额也差," +
+			"那会掩盖右端语义本身",
+		LegacyUserID: rsUserID, InstitutionID: rsInstID, CampusID: rsCampusID,
+		SeedLegacy:   seedLegacyWindow,
+		SeedTarget:   seedTargetWindow,
+		VerifyLegacy: verifyLegacyWindowSeed,
+		VerifyTarget: verifyTargetWindowSeed,
+		LegacyCall:   "CALL sp_user_expense_get_bill(?, ?, ?, ?)",
+		LegacyArgs:   []any{rsUserID, "990101", rsWinStart, rsWinEnd},
+		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
+			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("990101"),
+				},
+				Evidence: fmt.Sprintf("total=%d(窗口明细 %d 条)", res.Total, len(res.Details)),
+			}, nil
+		},
+		Compare: func(lg ResultSet, tg TargetPayload) ResultSetInput {
+			return ResultSetInput{
+				CaseName:       "RS-5",
+				Legacy:         lg,
+				ExpectRows:     1,
+				Target:         tg,
+				Columns:        rsBillColumns(),
+				Expect:         cols, // 9 列逐列与声明比
+				PrecisionCheck: true,
+				TargetOnly: []TargetOnlyColumn{
+					{Target: "FlowExclusiveEnd", Kind: CellMoney, Expect: MoneyCell(exclusive),
+						Note: "`from <= t < to`:**不含** `t=to` 的那 50.00"},
+					{Target: "FlowInclusiveEnd", Kind: CellMoney, Expect: MoneyCell(inclusive),
+						Note: "`from <= t <= to`:**含** `t=to` 的那 50.00;契约里「两张口径」的另一半"},
+					{Target: "CampusIDs", Kind: CellText, Expect: TextCell("990101"),
+						Note: "target 独有回显(dump 第二入参为死参数)"},
+				},
+				ExtraAssertions: func(lg ResultSet, tg TargetPayload) []Assertion {
+					atTo := rsWindowAtToCents()
+					inc := winPayloadCents(tg, "FlowInclusiveEnd")
+					exc := winPayloadCents(tg, "FlowExclusiveEnd")
+					end := winPayloadCents(tg, "EndBalance")
+					prev := winPayloadCents(tg, "PrevBalance")
+					lgEnd := winCellCents(lg, "currentBalance")
+					lgPrev := winCellCents(lg, "previousBalance")
+					return []Assertion{
+						{
+							Kind: AssertionBusiness,
+							Name: "右端差:FlowInclusiveEnd − FlowExclusiveEnd == `t=to` 那笔(**算出来的 50.00**)",
+							Pass: inc-exc == atTo && atTo != 0,
+							Detail: fmt.Sprintf("inclusive %s − exclusive %s = %s | `t=to` 声明 %s。"+
+								"若两边相等,说明右端语义没被区分(RS-2 的盲区就是这样)",
+								yuan(inc), yuan(exc), yuan(inc-exc), yuan(atTo)),
+						},
+						{
+							Kind: AssertionBusiness,
+							Name: "恒等式(target 自洽):期初 + Exclusive == 期末(右开口径闭合)",
+							Pass: prev+exc == end,
+							Detail: fmt.Sprintf("%s + %s = %s | 期末 %s。"+
+								"这条同时说明「期末不含 `t=to`」与「Exclusive 不含 `t=to`」是同一件事",
+								yuan(prev), yuan(exc), yuan(prev+exc), yuan(end)),
+						},
+						{
+							Kind: AssertionBusiness,
+							Name: "恒等式(legacy 自洽):期末 − 期初 == 声明的窗口流水(右开)70.00",
+							Pass: lgEnd-lgPrev == exclusive,
+							Detail: fmt.Sprintf("legacy 期末 %s − 期初 %s = %s | 声明 %s(dump 没有 flow 字段,"+
+								"只能用差值验同一件事)", yuan(lgEnd), yuan(lgPrev), yuan(lgEnd-lgPrev), yuan(exclusive)),
+						},
+					}
+				},
+			}
+		},
+	}
+}
+
+// winCellCents / winPayloadCents 取"分"(本 case 专用小工具)。
+//
+// 不放进骨架:这一轮明确"不改骨架",而它们只在断言文案里做算术。
+// 解析失败返回 0 不会造成假绿 —— 列级比对由 `normalizeCell` 把关,解析不了的金额在那里就红了。
+func winCellCents(rs ResultSet, col string) int64 {
+	c, ok := RawCellOf(rs, 0, col)
+	if !ok {
+		return 0
+	}
+	v, err := centsOf(c.String())
+	if err != nil {
+		return 0
+	}
+	return v
+}
+
+// winPayloadCents 取 target payload 某列的分。
+func winPayloadCents(tg TargetPayload, col string) int64 {
+	c, ok := tg.TargetColumnValue(col)
+	if !ok {
+		return 0
+	}
+	v, err := centsOf(c.String())
+	if err != nil {
+		return 0
+	}
+	return v
+}

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

@@ -12,5 +12,6 @@ func ResultSetCases() []ResultSetCase {
 		rsRefundableCase(),
 		rsBillCase(),
 		rsTeacherDetailCase(),
+		rsWindowCase(),
 	}
 }