Переглянути джерело

feat(ac15): RS-6 账单·空用户(9 列 0.00 且非 NULL)

`SUM` 无行是 NULL,dump 末尾逐个 IFNULL 才变 0;target 是 Go 定点累加(零值即 0)。
这条钉的不是"都是 0",而是**空用户也返回 1 行、9 列都不是 NULL**:
期望写 `"0.00"`,一旦 legacy 返回 NULL 就会被归一成 `"NULL"` ⇒ 逐列断言直接红。

- 种子只做清理(不写流水),读回断言**四张来源表各 0 行** ——
  若残留一行,"空用户"前提就不成立,本 case 会静默测成另一件事;
- 落一条具名断言「9 列没有一列是 NULL」,把 NULL 与 0 的差别归因到 NULL,
  而不是让它在逐列比对上表现为"算错了";
- 形状断言 ExpectRows=1(聚合查询恒一行,0 行 = FAIL)。

实测:9 列 0.00;业务断言 23 条、基建 4 条;PASS。
变异(种子里塞一行流水)实测 FATAL:「t_user_expense_log 还有 1 行 —— 空用户前提不成立」,已还原。
全量 5 条 case:5 PASS / 0 FAIL(业务 92 / 基建 20 / Skipped 11 / 未分类 0)。

门禁:go build ./... | go vet ./internal/academic/... |
go test ./internal/academic/dualrun/stage2/ -count=1 全绿。
Q 5 годин тому
батько
коміт
6a578b688c

+ 182 - 0
internal/academic/dualrun/stage2/rs_bill_empty.go

@@ -0,0 +1,182 @@
+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-6)的结果集双跑。
+//
+// # 为什么单开一条
+//
+// `sp_user_expense_get_bill` 的 9 列全是标量子查询 + `IFNULL` 兜底:
+//
+//	SELECT(SELECT SUM(money) FROM temp_user_expense_log WHERE …) INTO currentSpend;
+//	…
+//	IF(ISNULL(currentSpend)) THEN SET currentSpend = 0; END IF;
+//
+// `SUM` 无行时是 **NULL**,不是 0。target 侧是 Go 定点累加(零值即 0)。
+// 若把 NULL 折成 0 而不声明,这条 case 看起来会"两侧都是 0 ⇒ 一致",
+// 但那是**没测**:真正要钉的是"空用户下 legacy 也返回 0.00 而不是 NULL"。
+//
+// 所以本 case 的判据只有一句话:**无任何流水时,两侧都返回 1 行、9 列全是 0.00(非 NULL)**。
+// 期望值写成 `"0.00"` 而不是"空":一旦 legacy 返回 NULL,`normalizeCell` 会把 NULL 归一成
+// `"NULL"`,与 `"0.00"` 不等 ⇒ 逐列断言直接红(这就是"别糊"的可判定形式)。
+
+// seedLegacyEmptyUser 只做清理(不写任何流水)。
+//
+// 为什么清理也算种子:合成用户 880001 是三个 case 共用的;虽然每条 case 都在独立事务里
+// 且结束后回滚,但"先删干净"能把"上一个 case 的残留"这类失效模式挡在外面。
+func seedLegacyEmptyUser(ctx context.Context, tx *gorm.DB) error {
+	for _, stmt := range []string{
+		`DELETE FROM t_user_expense_log WHERE userid = ?`,
+		`DELETE FROM t_courses_activity_signed WHERE userid = ?`,
+		`DELETE FROM t_courses_activity_used_log WHERE userid = ?`,
+		`DELETE FROM t_user_balance_request WHERE userid = ?`,
+	} {
+		if err := tx.Exec(stmt, rsUserID).Error; err != nil {
+			return fmt.Errorf("清理 legacy 空用户种子失败(%s):%w", stmt, err)
+		}
+	}
+	return nil
+}
+
+// seedTargetEmptyUser 建身份(越权断言需要),不写流水。
+func seedTargetEmptyUser(ctx context.Context, tx *gorm.DB, uid string) error {
+	if err := seedResultSetIdentityTarget(ctx, tx, uid, rsUserID, rsInstID, rsCampusID); err != nil {
+		return err
+	}
+	for _, stmt := range []string{
+		`DELETE FROM t_user_expense_log WHERE user_id = ?`,
+		`DELETE FROM t_courses_activity_signed WHERE user_id = ?`,
+		`DELETE FROM t_courses_activity_used_log WHERE user_id = ?`,
+		`DELETE FROM t_user_balance_request WHERE user_id = ?`,
+	} {
+		if err := tx.Exec(stmt, uid).Error; err != nil {
+			return fmt.Errorf("清理 target 空用户种子失败(%s):%w", stmt, err)
+		}
+	}
+	return nil
+}
+
+// verifyLegacyEmptyUserSeed / verifyTargetEmptyUserSeed:断言"确实一条流水都没有"。
+//
+// 这条读回**必须存在**:空用户 case 的全部意义是"无行时返回什么",
+// 若种子没删干净(或别的 case 残留),测的就变成了"有流水时的账单" —— 一条彻底的假绿。
+func verifyLegacyEmptyUserSeed(ctx context.Context, tx *gorm.DB) (string, error) {
+	return verifyEmptyUserSeed(ctx, tx, "legacy", rsUserID, false)
+}
+
+func verifyTargetEmptyUserSeed(ctx context.Context, tx *gorm.DB, uid string) (string, error) {
+	return verifyEmptyUserSeed(ctx, tx, "target", uid, true)
+}
+
+func verifyEmptyUserSeed(ctx context.Context, tx *gorm.DB, side string, who any, target bool) (string, error) {
+	userCol := "userid"
+	if target {
+		userCol = "user_id"
+	}
+	tables := []string{"t_user_expense_log", "t_courses_activity_signed",
+		"t_courses_activity_used_log", "t_user_balance_request"}
+	for _, t := range tables {
+		var n int
+		if err := tx.Raw(fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE %s = ?`, t, userCol), who).
+			Row().Scan(&n); err != nil {
+			return "", fmt.Errorf("[%s] 读回 %s 条数失败:%w", side, t, err)
+		}
+		if n != 0 {
+			return "", fmt.Errorf("[%s] %s 还有 %d 行 —— 空用户前提不成立,本 case 会测成另一件事", side, t, n)
+		}
+	}
+	return "四张来源表均为 0 行", nil
+}
+
+// rsEmptyUserCase 组装 RS-6。
+func rsEmptyUserCase() ResultSetCase {
+	from := mustParse(rsWinStart + " 00:00:00")
+	to := mustParse(rsWinEnd + " 00:00:00")
+	zeros := map[string]string{}
+	for _, c := range rsBillColumns() {
+		zeros[c.Legacy] = "0.00"
+	}
+	return ResultSetCase{
+		Name: "RS-6 账单·空用户:无流水时 9 列全是 0.00(不是 NULL)",
+		Note: "`SUM` 无行是 NULL,dump 末尾逐个 IFNULL 才变 0;target 是 Go 定点累加(零值即 0)。" +
+			"本 case 钉的是「空用户也返回 1 行、且 9 列非 NULL」——若把 NULL 与 0 混为一谈,这条就退化成空过",
+		LegacyUserID: rsUserID, InstitutionID: rsInstID, CampusID: rsCampusID,
+		SeedLegacy:   seedLegacyEmptyUser,
+		SeedTarget:   seedTargetEmptyUser,
+		VerifyLegacy: verifyLegacyEmptyUserSeed,
+		VerifyTarget: verifyTargetEmptyUserSeed,
+		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(应为 0)", res.Total),
+			}, nil
+		},
+		Compare: func(lg ResultSet, tg TargetPayload) ResultSetInput {
+			return ResultSetInput{
+				CaseName:       "RS-6",
+				Legacy:         lg,
+				ExpectRows:     1, // 空用户也必须返回 1 行(聚合查询恒一行)
+				Target:         tg,
+				Columns:        rsBillColumns(),
+				Expect:         zeros,
+				PrecisionCheck: true,
+				TargetOnly: []TargetOnlyColumn{
+					{Target: "FlowExclusiveEnd", Kind: CellMoney, Expect: MoneyCell(0), Note: "空窗口 ⇒ 0.00"},
+					{Target: "FlowInclusiveEnd", Kind: CellMoney, Expect: MoneyCell(0), Note: "空窗口 ⇒ 0.00"},
+					{Target: "CampusIDs", Kind: CellText, Expect: TextCell("990101"), Note: "回显不受数据影响"},
+				},
+				ExtraAssertions: func(lg ResultSet, tg TargetPayload) []Assertion {
+					var nulls []string
+					for _, c := range rsBillColumns() {
+						cell, ok := RawCellOf(lg, 0, c.Legacy)
+						if !ok || cell.Null {
+							nulls = append(nulls, c.Legacy)
+						}
+					}
+					return []Assertion{{
+						Kind: AssertionBusiness,
+						Name: "空用户:dump 的 9 列**没有一列是 NULL**(`SUM` 无行 + 末尾 IFNULL 兜底的结果)",
+						Pass: len(nulls) == 0,
+						Detail: fmt.Sprintf("NULL 的列 %v | 声明:9 列全为 0.00。"+
+							"若这里出现 NULL,说明 dump 的 IFNULL 兜底没覆盖到该列 —— "+
+							"而 target 只有 0,两者会在逐列比对上同时红(本断言把它归因到 NULL,而不是「算错了」)",
+							nulls),
+					}}
+				},
+			}
+		},
+	}
+}

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

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