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