|
|
@@ -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
|
|
|
+}
|