|
@@ -27,6 +27,7 @@ import (
|
|
|
"yuansiwei/unified-backend/internal/academic/apperr"
|
|
"yuansiwei/unified-backend/internal/academic/apperr"
|
|
|
"yuansiwei/unified-backend/internal/academic/model"
|
|
"yuansiwei/unified-backend/internal/academic/model"
|
|
|
"yuansiwei/unified-backend/internal/academic/money"
|
|
"yuansiwei/unified-backend/internal/academic/money"
|
|
|
|
|
+ "yuansiwei/unified-backend/internal/academic/port"
|
|
|
"yuansiwei/unified-backend/internal/academic/repository"
|
|
"yuansiwei/unified-backend/internal/academic/repository"
|
|
|
infraerrors "yuansiwei/unified-backend/internal/infra/errors"
|
|
infraerrors "yuansiwei/unified-backend/internal/infra/errors"
|
|
|
appscope "yuansiwei/unified-backend/internal/infra/scope"
|
|
appscope "yuansiwei/unified-backend/internal/infra/scope"
|
|
@@ -160,6 +161,9 @@ func newFundServiceDBWithRecorder(t *testing.T) (*gorm.DB, *sqlRecorder) {
|
|
|
migrateFundTables(t, db,
|
|
migrateFundTables(t, db,
|
|
|
&model.UserExpense{}, &model.UserExpenseSchool{}, &model.UserExpenseLog{},
|
|
&model.UserExpense{}, &model.UserExpenseSchool{}, &model.UserExpenseLog{},
|
|
|
&model.CoursesActivity{}, &model.CoursesActivitySigned{},
|
|
&model.CoursesActivity{}, &model.CoursesActivitySigned{},
|
|
|
|
|
+ // t_user_balance_request:SP5 可退额第二步(已用赠送额)要按「充值工单 × 活动报名」汇总,
|
|
|
|
|
+ // 缺表会让 SP5 用例退化成"永远查不到赠送额"的假绿。
|
|
|
|
|
+ &model.UserBalanceRequest{},
|
|
|
)
|
|
)
|
|
|
// 跨域只读表(org 域 t_campus 的最小列集;self_support 为 1=自营 / 0=加盟)
|
|
// 跨域只读表(org 域 t_campus 的最小列集;self_support 为 1=自营 / 0=加盟)
|
|
|
if err := db.Exec(`CREATE TABLE t_campus (
|
|
if err := db.Exec(`CREATE TABLE t_campus (
|
|
@@ -587,44 +591,120 @@ func TestRefundAccountMissing(t *testing.T) {
|
|
|
requireAppCode(t, err, 3001)
|
|
requireAppCode(t, err, 3001)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// ---------------------------------------------------------------- 可退金额(SP5)
|
|
|
|
|
|
|
+// ---------------------------------------------------------------- 可退金额(SP5,两步修正)
|
|
|
|
|
|
|
|
-// TestRefundableAmount 账户不存在 → 0;负余额 → 0;正余额 → 余额。
|
|
|
|
|
|
|
+// seedExpenseAccounts 落全局 + 校区两个账户行(SP5 第一步截断需要两张表同时有值)。
|
|
|
|
|
+func seedExpenseAccounts(t *testing.T, db *gorm.DB, userID, campusBalance, globalBalance string) {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ if err := db.Create(&model.UserExpense{UserID: userID, Balance: globalBalance}).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed 全局账户(%s) 失败: %v", userID, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := db.Create(&model.UserExpenseSchool{UserID: userID, InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ Balance: campusBalance}).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed 校区账户(%s) 失败: %v", userID, err)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// seedGiftUsed 落一对「充值工单 + 活动报名」行(SP5 第二步「已用赠送额」的唯一数据来源)。
|
|
|
|
|
+//
|
|
|
|
|
+// 关键映射(照 dump 改写的依据):工单的 `id`(旧 UUID)→ 目标态 `biz_no`;
|
|
|
|
|
+// 报名的 `requestid` → 目标态 `request_no`;JOIN 走 `biz_no = request_no`。
|
|
|
|
|
+func seedGiftUsed(t *testing.T, db *gorm.DB, userID, bizNo string, activityID int64,
|
|
|
|
|
+ approved int8, activityMoney, activityMoneyUsed string) {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ if err := db.Create(&model.UserBalanceRequest{
|
|
|
|
|
+ BizNo: bizNo, Type: model.BalanceRequestTypeRecharge, UserID: userID,
|
|
|
|
|
+ InstitutionID: 1, CampusID: 10, Approved: approved,
|
|
|
|
|
+ }).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed 充值工单(%s) 失败: %v", bizNo, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ reqNo := bizNo
|
|
|
|
|
+ if err := db.Create(&model.CoursesActivitySigned{
|
|
|
|
|
+ ActivityID: activityID, UserID: userID, InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ ActivityMoney: activityMoney, ActivityMoneyUsed: activityMoneyUsed, RequestNo: &reqNo,
|
|
|
|
|
+ }).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed 活动报名(%s) 失败: %v", bizNo, err)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestRefundableAmount SP5 的全部边界:账户缺失 / 欠费 / 正常全额 /
|
|
|
|
|
+// **campus>total 截断** / **赠送已用小余额** / 赠送用尽不扣 / 未审批不扣 / 下限 0 / 3140。
|
|
|
func TestRefundableAmount(t *testing.T) {
|
|
func TestRefundableAmount(t *testing.T) {
|
|
|
db := newFundServiceDB(t)
|
|
db := newFundServiceDB(t)
|
|
|
svc := NewFundService(db, fundScope(), nil)
|
|
svc := NewFundService(db, fundScope(), nil)
|
|
|
ctx := context.Background()
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
- // 账户不存在
|
|
|
|
|
|
|
+ // ① 账户不存在 → 0
|
|
|
if got, err := svc.RefundableAmount(ctx, fundUserID(11), 1, 10); err != nil || got != 0 {
|
|
if got, err := svc.RefundableAmount(ctx, fundUserID(11), 1, 10); err != nil || got != 0 {
|
|
|
t.Fatalf("账户不存在应返回 0 且无错,实得 (%d, %v)", got, err)
|
|
t.Fatalf("账户不存在应返回 0 且无错,实得 (%d, %v)", got, err)
|
|
|
}
|
|
}
|
|
|
- // 负数余额(欠费)
|
|
|
|
|
- if err := db.Create(&model.UserExpenseSchool{UserID: fundUserID(12), InstitutionID: 1, CampusID: 10,
|
|
|
|
|
- Balance: "-25.00"}).Error; err != nil {
|
|
|
|
|
- t.Fatalf("seed 欠费账户失败: %v", err)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // ② 欠费(校区余额为负)→ 0(总额再大也不可退)
|
|
|
|
|
+ seedExpenseAccounts(t, db, fundUserID(12), "-25.00", "100.00")
|
|
|
if got, err := svc.RefundableAmount(ctx, fundUserID(12), 1, 10); err != nil || got != 0 {
|
|
if got, err := svc.RefundableAmount(ctx, fundUserID(12), 1, 10); err != nil || got != 0 {
|
|
|
t.Fatalf("负余额应返回 0,实得 (%d, %v)", got, err)
|
|
t.Fatalf("负余额应返回 0,实得 (%d, %v)", got, err)
|
|
|
}
|
|
}
|
|
|
- // 正余额
|
|
|
|
|
- if err := db.Create(&model.UserExpenseSchool{UserID: fundUserID(13), InstitutionID: 1, CampusID: 10,
|
|
|
|
|
- Balance: "88.50"}).Error; err != nil {
|
|
|
|
|
- t.Fatalf("seed 正余额账户失败: %v", err)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // ③ 正常:校区 88.50 ≤ 全局 100.00,且无赠送 → 全额 88.50
|
|
|
|
|
+ seedExpenseAccounts(t, db, fundUserID(13), "88.50", "100.00")
|
|
|
if got, err := svc.RefundableAmount(ctx, fundUserID(13), 1, 10); err != nil || got.Cents() != 8850 {
|
|
if got, err := svc.RefundableAmount(ctx, fundUserID(13), 1, 10); err != nil || got.Cents() != 8850 {
|
|
|
t.Fatalf("正余额应返回 8850 分,实得 (%d, %v)", got, err)
|
|
t.Fatalf("正余额应返回 8850 分,实得 (%d, %v)", got, err)
|
|
|
}
|
|
}
|
|
|
- // 校区缺失 → 3140
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // ④ 校区缺失 → 3140
|
|
|
if _, err := svc.RefundableAmount(ctx, fundUserID(13), 1, 0); err != nil {
|
|
if _, err := svc.RefundableAmount(ctx, fundUserID(13), 1, 0); err != nil {
|
|
|
requireAppCode(t, err, 3140)
|
|
requireAppCode(t, err, 3140)
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ // ⑤ 全局账户缺失(totalBalance 视为 0)→ 0
|
|
|
|
|
+ if err := db.Create(&model.UserExpenseSchool{UserID: fundUserID(18), InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ Balance: "50.00"}).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed 校区账户失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got, err := svc.RefundableAmount(ctx, fundUserID(18), 1, 10); err != nil || got != 0 {
|
|
|
|
|
+ t.Fatalf("全局账户缺失应返回 0,实得 (%d, %v)", got, err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ⑥ **第一步修正**:campusBalance > totalBalance → 按 totalBalance 截断(不得回 200)
|
|
|
|
|
+ seedExpenseAccounts(t, db, fundUserID(19), "200.00", "120.00")
|
|
|
|
|
+ if got, err := svc.RefundableAmount(ctx, fundUserID(19), 1, 10); err != nil || got.Cents() != 12000 {
|
|
|
|
|
+ t.Fatalf("campus(200)>total(120) 应截断为 12000 分,实得 (%d, %v)", got, err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ⑦ **第二步修正**:赠送已用 → 可退额必须**小于**余额(100 − 20 = 80)
|
|
|
|
|
+ seedExpenseAccounts(t, db, fundUserID(20), "100.00", "100.00")
|
|
|
|
|
+ seedGiftUsed(t, db, fundUserID(20), "BR-GIFT-1", 1, model.BalanceRequestApprovedPassed, "50.00", "20.00")
|
|
|
|
|
+ if got, err := svc.RefundableAmount(ctx, fundUserID(20), 1, 10); err != nil || got.Cents() != 8000 {
|
|
|
|
|
+ t.Fatalf("赠送已用 20 应扣减(8000 分 < 余额 10000),实得 (%d, %v)", got, err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ⑧ 赠送**已用尽**(used == activity_money)→ 按 dump 的 `<` 条件不参与统计 → 不扣
|
|
|
|
|
+ seedExpenseAccounts(t, db, fundUserID(21), "100.00", "100.00")
|
|
|
|
|
+ seedGiftUsed(t, db, fundUserID(21), "BR-GIFT-2", 2, model.BalanceRequestApprovedPassed, "50.00", "50.00")
|
|
|
|
|
+ if got, err := svc.RefundableAmount(ctx, fundUserID(21), 1, 10); err != nil || got.Cents() != 10000 {
|
|
|
|
|
+ t.Fatalf("赠送用尽行不参与统计(应 10000),实得 (%d, %v)", got, err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ⑨ 工单**未审批**(approved != 1)→ 不参与统计
|
|
|
|
|
+ seedExpenseAccounts(t, db, fundUserID(22), "100.00", "100.00")
|
|
|
|
|
+ seedGiftUsed(t, db, fundUserID(22), "BR-GIFT-3", 3, model.BalanceRequestApprovedPending, "50.00", "20.00")
|
|
|
|
|
+ if got, err := svc.RefundableAmount(ctx, fundUserID(22), 1, 10); err != nil || got.Cents() != 10000 {
|
|
|
|
|
+ t.Fatalf("未审批工单不参与统计(应 10000),实得 (%d, %v)", got, err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ⑩ 截断后再扣赠送,仍**下限 0**(120 − 150 < 0 → 0)
|
|
|
|
|
+ seedExpenseAccounts(t, db, fundUserID(23), "200.00", "120.00")
|
|
|
|
|
+ seedGiftUsed(t, db, fundUserID(23), "BR-GIFT-4", 4, model.BalanceRequestApprovedPassed, "200.00", "150.00")
|
|
|
|
|
+ if got, err := svc.RefundableAmount(ctx, fundUserID(23), 1, 10); err != nil || got != 0 {
|
|
|
|
|
+ t.Fatalf("截断后扣赠送仍为负 → 应返回 0,实得 (%d, %v)", got, err)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// ---------------------------------------------------------------- 坏账(SP7)
|
|
|
|
|
|
|
+// ---------------------------------------------------------------- 坏账(SP7,双加)
|
|
|
|
|
|
|
|
-// TestBadDebtOnlySpecialExpenses 只加 special_expenses(**不动 balance**),108 流水为正。
|
|
|
|
|
-func TestBadDebtOnlySpecialExpenses(t *testing.T) {
|
|
|
|
|
|
|
+// TestBadDebtAddsBalanceAndSpecialExpenses dump 口径「余额+,特殊费用+」:
|
|
|
|
|
+// **两列同增**(全局 + 校区都是),108 流水为正。
|
|
|
|
|
+func TestBadDebtAddsBalanceAndSpecialExpenses(t *testing.T) {
|
|
|
db := newFundServiceDB(t)
|
|
db := newFundServiceDB(t)
|
|
|
svc := NewFundService(db, fundScope(), nil)
|
|
svc := NewFundService(db, fundScope(), nil)
|
|
|
ctx := context.Background()
|
|
ctx := context.Background()
|
|
@@ -639,29 +719,196 @@ func TestBadDebtOnlySpecialExpenses(t *testing.T) {
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
t.Fatalf("BadDebt 失败: %v", err)
|
|
t.Fatalf("BadDebt 失败: %v", err)
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ // 校区:balance 100 → 130(双加),special_expenses 0 → 30
|
|
|
school := readSchool(t, db, user, 1, 10)
|
|
school := readSchool(t, db, user, 1, 10)
|
|
|
- if got := cents(t, school.Balance); got != 10000 {
|
|
|
|
|
- t.Fatalf("坏账不应改 balance(应仍 10000 分),实得 %d", got)
|
|
|
|
|
|
|
+ if got := cents(t, school.Balance); got != 13000 {
|
|
|
|
|
+ t.Fatalf("校区 balance 应 10000+3000=13000 分(dump 双加),实得 %d", got)
|
|
|
}
|
|
}
|
|
|
if got := cents(t, school.SpecialExpenses); got != 3000 {
|
|
if got := cents(t, school.SpecialExpenses); got != 3000 {
|
|
|
- t.Fatalf("special_expenses 应 3000 分,实得 %d", got)
|
|
|
|
|
|
|
+ t.Fatalf("校区 special_expenses 应 3000 分,实得 %d", got)
|
|
|
}
|
|
}
|
|
|
|
|
+ // 全局:同样双加
|
|
|
global := readGlobal(t, db, user)
|
|
global := readGlobal(t, db, user)
|
|
|
- if got := cents(t, global.SpecialExpenses); got != 3000 {
|
|
|
|
|
- t.Fatalf("全局 special_expenses 应 3000 分(双表同步),实得 %d", got)
|
|
|
|
|
|
|
+ if got := cents(t, global.Balance); got != 13000 {
|
|
|
|
|
+ t.Fatalf("全局 balance 应 13000 分(双表同步双加),实得 %d", got)
|
|
|
}
|
|
}
|
|
|
- if got := cents(t, global.Balance); got != 10000 {
|
|
|
|
|
- t.Fatalf("全局 balance 不应变(应仍 10000 分),实得 %d", got)
|
|
|
|
|
|
|
+ if got := cents(t, global.SpecialExpenses); got != 3000 {
|
|
|
|
|
+ t.Fatalf("全局 special_expenses 应 3000 分,实得 %d", got)
|
|
|
}
|
|
}
|
|
|
- if res.SpecialExpenses.Cents() != 3000 {
|
|
|
|
|
- t.Fatalf("返回 specialExpenses 应 3000 分,实得 %d", res.SpecialExpenses.Cents())
|
|
|
|
|
|
|
+ // 返回体:余额已是 +Money 之后的值
|
|
|
|
|
+ if res.Balance.Cents() != 13000 || res.SpecialExpenses.Cents() != 3000 {
|
|
|
|
|
+ t.Fatalf("返回体应 balance=13000/special=3000,实得 %d/%d",
|
|
|
|
|
+ res.Balance.Cents(), res.SpecialExpenses.Cents())
|
|
|
}
|
|
}
|
|
|
|
|
+ // 流水:108 = +3000 一条
|
|
|
logs := logsByType(t, db, user, model.ExpenseLogTypeBadDebt)
|
|
logs := logsByType(t, db, user, model.ExpenseLogTypeBadDebt)
|
|
|
if len(logs) != 1 || cents(t, logs[0].Money) != 3000 {
|
|
if len(logs) != 1 || cents(t, logs[0].Money) != 3000 {
|
|
|
t.Fatalf("108 流水应为 +3000 分一条,实得 %+v", logs)
|
|
t.Fatalf("108 流水应为 +3000 分一条,实得 %+v", logs)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// ---------------------------------------------------------------- 账务恒等式(AC-17 ④ 口径)
|
|
|
|
|
+
|
|
|
|
|
+// balanceLogs 取某用户全部流水(按 id ASC)。
|
|
|
|
|
+func balanceLogs(t *testing.T, db *gorm.DB, userID string) []model.UserExpenseLog {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ var rows []model.UserExpenseLog
|
|
|
|
|
+ if err := db.Where("user_id = ?", userID).Order("id ASC").Find(&rows).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("读全部流水(%s) 失败: %v", userID, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return rows
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// sumLogMoney 流水金额**代数和**(Go 侧定点累加;禁 SQL SUM(decimal) 回 REAL)。
|
|
|
|
|
+func sumLogMoney(t *testing.T, rows []model.UserExpenseLog) money.Amount {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ total := money.Amount(0)
|
|
|
|
|
+ for i := range rows {
|
|
|
|
|
+ amount, err := money.Parse(rows[i].Money)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("流水(%d) 金额非法: %v", rows[i].ID, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ total = total.Add(amount)
|
|
|
|
|
+ }
|
|
|
|
|
+ return total
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// classifyBadDebtLogs 按 dump 兼容口径把 type=108 分成两类。
|
|
|
|
|
+//
|
|
|
|
|
+// 判别式(**唯一、可机械执行**):取 108 流水的 `logid`(= `request_id`)——
|
|
|
|
|
+// - **同 logid 存在 type=207 流水** → 差额型(`CustomRefund` 的退费差额):
|
|
|
|
|
+// 该 108 的金额已含在 207 的 −requested 全额里,**不构成**一次独立的余额增加;
|
|
|
|
|
+// - **不存在** → 独立坏账(`FundService.BadDebt`):balance 与 special_expenses 双加,
|
|
|
|
|
+// 该 108 **就是**一次余额增加。
|
|
|
|
|
+func classifyBadDebtLogs(t *testing.T, db *gorm.DB, userID string) (
|
|
|
|
|
+ independent, differential []model.UserExpenseLog,
|
|
|
|
|
+) {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ all := balanceLogs(t, db, userID)
|
|
|
|
|
+ refundKeys := make(map[string]bool)
|
|
|
|
|
+ for i := range all {
|
|
|
|
|
+ if all[i].Type != model.ExpenseLogTypeRefund || all[i].LogID == nil {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ refundKeys[*all[i].LogID] = true
|
|
|
|
|
+ }
|
|
|
|
|
+ for i := range all {
|
|
|
|
|
+ if all[i].Type != model.ExpenseLogTypeBadDebt {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ if all[i].LogID != nil && refundKeys[*all[i].LogID] {
|
|
|
|
|
+ differential = append(differential, all[i])
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ independent = append(independent, all[i])
|
|
|
|
|
+ }
|
|
|
|
|
+ return independent, differential
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestBalanceIdentityIndependentBadDebtCounted 独立坏账(108 无 207 同伴)**参与**恒等式:
|
|
|
|
|
+// 余额增量恰等于该 108 的金额,且「balance = Σ全部流水」成立(旧口径下这里是唯一破口)。
|
|
|
|
|
+func TestBalanceIdentityIndependentBadDebtCounted(t *testing.T) {
|
|
|
|
|
+ db := newFundServiceDB(t)
|
|
|
|
|
+ svc := NewFundService(db, fundScope(), nil)
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ user := fundUserID(24)
|
|
|
|
|
+
|
|
|
|
|
+ if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ Money: mustAmount(t, "100.00"), RequestID: "REQ-ID-BAL-RECHARGE"}); err != nil {
|
|
|
|
|
+ t.Fatalf("准备余额失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ before := cents(t, readSchool(t, db, user, 1, 10).Balance)
|
|
|
|
|
+ if before != 10000 {
|
|
|
|
|
+ t.Fatalf("准备余额应为 10000 分,实得 %d", before)
|
|
|
|
|
+ }
|
|
|
|
|
+ if _, err := svc.BadDebt(ctx, BadDebtInput{UserID: user, InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ Money: mustAmount(t, "30.00"), RequestID: "REQ-ID-BAL-BADDEBT"}); err != nil {
|
|
|
|
|
+ t.Fatalf("BadDebt 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ after := cents(t, readSchool(t, db, user, 1, 10).Balance)
|
|
|
|
|
+
|
|
|
|
|
+ independent, differential := classifyBadDebtLogs(t, db, user)
|
|
|
|
|
+ if len(independent) != 1 || len(differential) != 0 {
|
|
|
|
|
+ t.Fatalf("独立坏账应 1 条且无差额型,实得 independent=%d differential=%d",
|
|
|
|
|
+ len(independent), len(differential))
|
|
|
|
|
+ }
|
|
|
|
|
+ // ① 108 参与:余额增量 == 108 金额(+3000)
|
|
|
|
|
+ if after-before != cents(t, independent[0].Money) {
|
|
|
|
|
+ t.Fatalf("独立坏账应使余额 +%d 分,实得增量 %d",
|
|
|
|
|
+ cents(t, independent[0].Money), after-before)
|
|
|
|
|
+ }
|
|
|
|
|
+ // ② 全局口径「balance = Σ全部流水」
|
|
|
|
|
+ all := balanceLogs(t, db, user)
|
|
|
|
|
+ if sum := sumLogMoney(t, all).Cents(); sum != after {
|
|
|
|
|
+ t.Fatalf("恒等式 balance=Σ流水 不成立:Σ=%d balance=%d", sum, after)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestBalanceIdentityDifferentialBadDebtExcluded 退费差额型(108 有 207 同伴)**不参与**
|
|
|
|
|
+// 「108 → 余额增量」的归因:坏账部分已含在 207 的 −requested 全额里,
|
|
|
|
|
+// 若把它再算一次独立余额增加,余额会凭空多出 badDebt(正是旧口径/新口径最容易混的一处)。
|
|
|
|
|
+func TestBalanceIdentityDifferentialBadDebtExcluded(t *testing.T) {
|
|
|
|
|
+ db := newFundServiceDB(t)
|
|
|
|
|
+ fund := NewFundService(db, fundSuperScope(), nil)
|
|
|
|
|
+ svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ user := fundUserID(25)
|
|
|
|
|
+
|
|
|
|
|
+ seedCampus(t, db, 10, 1, true)
|
|
|
|
|
+ if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ Money: mustAmount(t, "100.00"), RequestID: "REQ-ID-DIFF-RECHARGE"}); err != nil {
|
|
|
|
|
+ t.Fatalf("准备余额失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ before := cents(t, readSchool(t, db, user, 1, 10).Balance)
|
|
|
|
|
+ // 150 > 可退 100 → refunded 100、badDebt 50
|
|
|
|
|
+ if _, err := svc.CustomRefund(ctx, CustomRefundInput{
|
|
|
|
|
+ UserID: user, InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ Money: mustAmount(t, "150.00"), RequestID: "REQ-ID-DIFF-REFUND",
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("CustomRefund 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ after := cents(t, readSchool(t, db, user, 1, 10).Balance)
|
|
|
|
|
+
|
|
|
|
|
+ independent, differential := classifyBadDebtLogs(t, db, user)
|
|
|
|
|
+ if len(differential) != 1 || len(independent) != 0 {
|
|
|
|
|
+ t.Fatalf("退费差额应 1 条差额型且无独立坏账,实得 independent=%d differential=%d",
|
|
|
|
|
+ len(independent), len(differential))
|
|
|
|
|
+ }
|
|
|
|
|
+ logs207 := logsByType(t, db, user, model.ExpenseLogTypeRefund)
|
|
|
|
|
+ if len(logs207) != 1 {
|
|
|
|
|
+ t.Fatalf("207 应 1 条,实得 %d", len(logs207))
|
|
|
|
|
+ }
|
|
|
|
|
+ // ① 差额型 108 的 logid 必须与 207 相同(判别式的实现载体)
|
|
|
|
|
+ if differential[0].LogID == nil || logs207[0].LogID == nil ||
|
|
|
|
|
+ *differential[0].LogID != *logs207[0].LogID {
|
|
|
|
|
+ t.Fatalf("差额型 108 应与 207 同 logid,实得 108=%+v 207=%+v",
|
|
|
|
|
+ differential[0].LogID, logs207[0].LogID)
|
|
|
|
|
+ }
|
|
|
|
|
+ // ② 不参与:光看 207 就已经把这次余额变动记完了
|
|
|
|
|
+ // before + 207 = after + badDebt > after → 差额恰是 badDebt,且必须靠 108 补平;
|
|
|
|
|
+ // 但把该 108 再当成「一次独立余额增加」就会重复计入 → 合计比真实余额多一个 badDebt。
|
|
|
|
|
+ onlyRefund := before + cents(t, logs207[0].Money)
|
|
|
|
|
+ if onlyRefund == after {
|
|
|
|
|
+ t.Fatalf("207 单独记账不应等于余额变化(差额型 108 必须存在):before=%d 207=%d after=%d",
|
|
|
|
|
+ before, cents(t, logs207[0].Money), after)
|
|
|
|
|
+ }
|
|
|
|
|
+ pair := onlyRefund + cents(t, differential[0].Money)
|
|
|
|
|
+ if pair != after {
|
|
|
|
|
+ t.Fatalf("差额型 108 与 207 的代数和应等于净扣减:before(%d)+207(%d)+108(%d)=%d,after=%d",
|
|
|
|
|
+ before, cents(t, logs207[0].Money), cents(t, differential[0].Money), pair, after)
|
|
|
|
|
+ }
|
|
|
|
|
+ if pair+cents(t, differential[0].Money) == after {
|
|
|
|
|
+ t.Fatalf("差额型 108 不得再算一次独立的余额增加(重复计入会凭空多出 %d 分)",
|
|
|
|
|
+ cents(t, differential[0].Money))
|
|
|
|
|
+ }
|
|
|
|
|
+ // ③ 但「balance = Σ全部流水」整体仍成立(108 与 207 成对出现,代数和 = 净扣减)
|
|
|
|
|
+ all := balanceLogs(t, db, user)
|
|
|
|
|
+ if sum := sumLogMoney(t, all).Cents(); sum != after {
|
|
|
|
|
+ t.Fatalf("恒等式 balance=Σ流水 不成立:Σ=%d balance=%d", sum, after)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// ---------------------------------------------------------------- 累计消费(SP11,BUG#1)
|
|
// ---------------------------------------------------------------- 累计消费(SP11,BUG#1)
|
|
|
|
|
|
|
|
// TestAccumulateSpendOnlySpend 只更新 spend(不动 balance),且**不写流水**;负数归 0。
|
|
// TestAccumulateSpendOnlySpend 只更新 spend(不动 balance),且**不写流水**;负数归 0。
|