|
@@ -561,22 +561,108 @@ func TestRefundInstitutionFilterPreventsCrossInstitutionUpdate(t *testing.T) {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// TestRefundZeroMoneyIsNoop money<=0 → changed=false(对齐 dump `IF inputMoney>0`),不写流水。
|
|
|
|
|
-func TestRefundZeroMoneyIsNoop(t *testing.T) {
|
|
|
|
|
|
|
+// TestRefundRejectsNonPositiveMoney R6(§3.8-b):money<=0 → **2002**,且不写任何流水。
|
|
|
|
|
+//
|
|
|
|
|
+// 口径变更说明:dump(SP4)对 `inputMoney<=0` 是 `IF inputMoney>0` 静默跳过(本次之前实现为
|
|
|
|
|
+// changed=false 的 no-op),v0.1.14 的 R6 把五处入口(recharge/expenditure/baddebt/refund/transfer)
|
|
|
|
|
+// 统一为显式 2002,故此处期望值由"成功 + changed=false"改为错误码 2002。
|
|
|
|
|
+func TestRefundRejectsNonPositiveMoney(t *testing.T) {
|
|
|
db := newFundServiceDB(t)
|
|
db := newFundServiceDB(t)
|
|
|
svc := NewFundService(db, fundScope(), nil)
|
|
svc := NewFundService(db, fundScope(), nil)
|
|
|
user := fundUserID(9)
|
|
user := fundUserID(9)
|
|
|
- res, err := svc.Refund(context.Background(), RefundInput{
|
|
|
|
|
- UserID: user, InstitutionID: 1, CampusID: 10, Money: 0, Type: model.ExpenseLogTypeCourseCampus,
|
|
|
|
|
- })
|
|
|
|
|
- if err != nil {
|
|
|
|
|
- t.Fatalf("money=0 应返回成功(no-op),实得错误: %v", err)
|
|
|
|
|
- }
|
|
|
|
|
- if res.Changed || res.Repeated || res.LogID != 0 {
|
|
|
|
|
- t.Fatalf("money=0 应 changed=false 且无流水,实得 %+v", res)
|
|
|
|
|
|
|
+
|
|
|
|
|
+ for _, amount := range []string{"0.00", "-1.00"} {
|
|
|
|
|
+ _, err := svc.Refund(context.Background(), RefundInput{
|
|
|
|
|
+ UserID: user, InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ Money: mustAmount(t, amount), Type: model.ExpenseLogTypeCourseCampus,
|
|
|
|
|
+ })
|
|
|
|
|
+ requireAppCode(t, err, 2002)
|
|
|
}
|
|
}
|
|
|
if count := countLogsByType(t, db, user, model.ExpenseLogTypeCourseCampus); count != 0 {
|
|
if count := countLogsByType(t, db, user, model.ExpenseLogTypeCourseCampus); count != 0 {
|
|
|
- t.Fatalf("no-op 不应写流水,实得 %d 条", count)
|
|
|
|
|
|
|
+ t.Fatalf("非法金额不得写流水,实得 %d 条", count)
|
|
|
|
|
+ }
|
|
|
|
|
+ if count := countLogsByType(t, db, user, model.ExpenseLogTypeRecharge); count != 0 {
|
|
|
|
|
+ t.Fatalf("非法金额不得写充值流水,实得 %d 条", count)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestNonPositiveMoneyRejectedEverywhere R6 的**五处全覆盖**(charge/expenditure/baddebt/refund/transfer):
|
|
|
|
|
+// money<=0 → 2002,且**零副作用**(不建户、不改账户、不占 `(request_id,type)` 幂等键)。
|
|
|
|
|
+//
|
|
|
|
|
+// 为什么要显式钉"不占幂等键":dump 的 recharge/expenditure/baddebt 分支会把负值**归 0 后照常记账**,
|
|
|
|
|
+// 留下 money=0 的流水并把 `(request_id,type)` 占死,导致"同键后续真金额被幂等跳过"(资金静默丢失)。
|
|
|
|
|
+func TestNonPositiveMoneyRejectedEverywhere(t *testing.T) {
|
|
|
|
|
+ db := newFundServiceDB(t)
|
|
|
|
|
+ svc := NewFundService(db, fundSuperScope(), nil)
|
|
|
|
|
+ ctx := context.Background()
|
|
|
|
|
+ user := fundUserID(26)
|
|
|
|
|
+
|
|
|
|
|
+ // 预置账户,确保"零副作用"的断言不是"账户本来就不存在"导致的假绿
|
|
|
|
|
+ if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ Money: mustAmount(t, "100.00"), RequestID: "REQ-R6-SEED"}); err != nil {
|
|
|
|
|
+ t.Fatalf("准备余额失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ before := readSchool(t, db, user, 1, 10)
|
|
|
|
|
+ logsBefore := len(balanceLogs(t, db, user))
|
|
|
|
|
+
|
|
|
|
|
+ cases := []struct {
|
|
|
|
|
+ name string
|
|
|
|
|
+ call func(amount money.Amount) error
|
|
|
|
|
+ }{
|
|
|
|
|
+ {"recharge", func(a money.Amount) error {
|
|
|
|
|
+ _, e := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ Money: a, RequestID: "REQ-R6-RECHARGE"})
|
|
|
|
|
+ return e
|
|
|
|
|
+ }},
|
|
|
|
|
+ {"expenditure", func(a money.Amount) error {
|
|
|
|
|
+ _, e := svc.Expenditure(ctx, ExpenditureInput{UserID: user, InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ Money: a, Type: model.ExpenseLogTypeCourseCampus, RequestID: "REQ-R6-EXPEND"})
|
|
|
|
|
+ return e
|
|
|
|
|
+ }},
|
|
|
|
|
+ {"baddebt", func(a money.Amount) error {
|
|
|
|
|
+ _, e := svc.BadDebt(ctx, BadDebtInput{UserID: user, InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ Money: a, RequestID: "REQ-R6-BADDEBT"})
|
|
|
|
|
+ return e
|
|
|
|
|
+ }},
|
|
|
|
|
+ {"refund", func(a money.Amount) error {
|
|
|
|
|
+ _, e := svc.Refund(ctx, RefundInput{UserID: user, InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ Money: a, Type: model.ExpenseLogTypeCourseCampus, RequestID: "REQ-R6-REFUND"})
|
|
|
|
|
+ return e
|
|
|
|
|
+ }},
|
|
|
|
|
+ {"transfer", func(a money.Amount) error {
|
|
|
|
|
+ _, e := svc.Transfer(ctx, TransferInput{UserID: user,
|
|
|
|
|
+ FromInstitutionID: 1, FromCampusID: 10, ToInstitutionID: 1, ToCampusID: 20,
|
|
|
|
|
+ Money: a, RequestID: "REQ-R6-TRANSFER"})
|
|
|
|
|
+ return e
|
|
|
|
|
+ }},
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, amount := range []string{"0.00", "-10.00"} {
|
|
|
|
|
+ for _, c := range cases {
|
|
|
|
|
+ err := c.call(mustAmount(t, amount))
|
|
|
|
|
+ if err == nil {
|
|
|
|
|
+ t.Fatalf("%s(%s) 应返回 2002,实得成功", c.name, amount)
|
|
|
|
|
+ }
|
|
|
|
|
+ requireAppCode(t, err, 2002)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ after := readSchool(t, db, user, 1, 10)
|
|
|
|
|
+ if cents(t, after.Balance) != cents(t, before.Balance) ||
|
|
|
|
|
+ cents(t, after.TotalIncome) != cents(t, before.TotalIncome) ||
|
|
|
|
|
+ cents(t, after.TotalExpenditure) != cents(t, before.TotalExpenditure) ||
|
|
|
|
|
+ cents(t, after.SpecialExpenses) != cents(t, before.SpecialExpenses) {
|
|
|
|
|
+ t.Fatalf("R6 拒绝的请求不得改动账户:before=%+v after=%+v", before, after)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got := len(balanceLogs(t, db, user)); got != logsBefore {
|
|
|
|
|
+ t.Fatalf("R6 拒绝的请求不得写流水(幂等键不得被占):before=%d after=%d", logsBefore, got)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 幂等键未被占死:同键 + 真金额必须能正常入账
|
|
|
|
|
+ if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ Money: mustAmount(t, "10.00"), RequestID: "REQ-R6-RECHARGE"}); err != nil {
|
|
|
|
|
+ t.Fatalf("被拒请求不得占死幂等键,同键真金额应能入账: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 11000 {
|
|
|
|
|
+ t.Fatalf("真金额入账后余额应 11000 分,实得 %d", got)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -627,79 +713,108 @@ func seedGiftUsed(t *testing.T, db *gorm.DB, userID, bizNo string, activityID in
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// TestRefundableAmount SP5 的全部边界:账户缺失 / 欠费 / 正常全额 /
|
|
|
|
|
-// **campus>total 截断** / **赠送已用小余额** / 赠送用尽不扣 / 未审批不扣 / 下限 0 / 3140。
|
|
|
|
|
|
|
+// TestRefundableAmount SP5 门禁用例(§8 AC-04 ⑦ 的 5 组表驱动 + 退化分支)。
|
|
|
|
|
+//
|
|
|
|
|
+// 五组必须区分得开"单步实现"与"三步实现"(原 case 组区分不出,正是错口径活到交付的原因):
|
|
|
|
|
+//
|
|
|
|
|
+// ① 基线无赠送 → 只有"读余额"也能过
|
|
|
|
|
+// ② 截断 → 只有实现 ① 截断才能过(campus 200 > total 120)
|
|
|
|
|
+// ③ 减项 → 只有实现 ③ 减赠送额才能过(100 − 20 = 80)
|
|
|
|
|
+// ④ 归零 → 减项后为负必须取 0(不是负数透出)
|
|
|
|
|
+// ⑤ 截断+减项叠加 → 只有两步都实现且**顺序正确**才能过(min(200,120) − 30 = 90;
|
|
|
|
|
+// 若先减后截、或漏截断,会得到 170 / 150 / 70 等错值)
|
|
|
func TestRefundableAmount(t *testing.T) {
|
|
func TestRefundableAmount(t *testing.T) {
|
|
|
|
|
+ const (
|
|
|
|
|
+ inst = int64(1)
|
|
|
|
|
+ camp = int64(10)
|
|
|
|
|
+ )
|
|
|
|
|
+ type gift struct {
|
|
|
|
|
+ BizNo string
|
|
|
|
|
+ Activity int64
|
|
|
|
|
+ Approved int8
|
|
|
|
|
+ Money string
|
|
|
|
|
+ MoneyUsed string
|
|
|
|
|
+ }
|
|
|
|
|
+ cases := []struct {
|
|
|
|
|
+ name string
|
|
|
|
|
+ campusBal *string // nil = 校区账户不存在
|
|
|
|
|
+ globalBal *string // nil = 全局账户不存在
|
|
|
|
|
+ gifts []gift
|
|
|
|
|
+ wantCents int64
|
|
|
|
|
+ }{
|
|
|
|
|
+ {name: "①基线_无赠送", campusBal: strPtr("88.50"), globalBal: strPtr("100.00"), wantCents: 8850},
|
|
|
|
|
+ {name: "②截断_campus>total", campusBal: strPtr("200.00"), globalBal: strPtr("120.00"), wantCents: 12000},
|
|
|
|
|
+ {name: "③减项_赠送已用", campusBal: strPtr("100.00"), globalBal: strPtr("150.00"),
|
|
|
|
|
+ gifts: []gift{{"BR-SP5-3", 3, model.BalanceRequestApprovedPassed, "50.00", "20.00"}},
|
|
|
|
|
+ wantCents: 8000},
|
|
|
|
|
+ {name: "④归零_赠送>截断基数", campusBal: strPtr("50.00"), globalBal: strPtr("50.00"),
|
|
|
|
|
+ gifts: []gift{{"BR-SP5-4", 4, model.BalanceRequestApprovedPassed, "90.00", "80.00"}},
|
|
|
|
|
+ wantCents: 0},
|
|
|
|
|
+ {name: "⑤截断+减项叠加", campusBal: strPtr("200.00"), globalBal: strPtr("120.00"),
|
|
|
|
|
+ gifts: []gift{{"BR-SP5-5", 5, model.BalanceRequestApprovedPassed, "100.00", "30.00"}},
|
|
|
|
|
+ wantCents: 9000},
|
|
|
|
|
+ {name: "⑥赠送用尽不参与", campusBal: strPtr("100.00"), globalBal: strPtr("100.00"),
|
|
|
|
|
+ gifts: []gift{{"BR-SP5-6", 6, model.BalanceRequestApprovedPassed, "50.00", "50.00"}},
|
|
|
|
|
+ wantCents: 10000},
|
|
|
|
|
+ {name: "⑦未审批不参与", campusBal: strPtr("100.00"), globalBal: strPtr("100.00"),
|
|
|
|
|
+ gifts: []gift{{"BR-SP5-7", 7, model.BalanceRequestApprovedPending, "50.00", "20.00"}},
|
|
|
|
|
+ wantCents: 10000},
|
|
|
|
|
+ {name: "⑧多笔赠送累加", campusBal: strPtr("100.00"), globalBal: strPtr("100.00"),
|
|
|
|
|
+ gifts: []gift{
|
|
|
|
|
+ {"BR-SP5-8a", 8, model.BalanceRequestApprovedPassed, "50.00", "20.00"},
|
|
|
|
|
+ {"BR-SP5-8b", 9, model.BalanceRequestApprovedPassed, "50.00", "10.00"},
|
|
|
|
|
+ },
|
|
|
|
|
+ wantCents: 7000},
|
|
|
|
|
+ {name: "⑨校区账户不存在", globalBal: strPtr("100.00"), wantCents: 0},
|
|
|
|
|
+ {name: "⑩全局账户不存在", campusBal: strPtr("50.00"), wantCents: 0},
|
|
|
|
|
+ {name: "⑪校区欠费", campusBal: strPtr("-25.00"), globalBal: strPtr("100.00"), wantCents: 0},
|
|
|
|
|
+ {name: "⑫全局非正", campusBal: strPtr("50.00"), globalBal: strPtr("0.00"), wantCents: 0},
|
|
|
|
|
+ }
|
|
|
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 {
|
|
|
|
|
- t.Fatalf("账户不存在应返回 0 且无错,实得 (%d, %v)", got, 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 {
|
|
|
|
|
- t.Fatalf("负余额应返回 0,实得 (%d, %v)", got, 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 {
|
|
|
|
|
- t.Fatalf("正余额应返回 8850 分,实得 (%d, %v)", got, err)
|
|
|
|
|
|
|
+ for i, c := range cases {
|
|
|
|
|
+ user := fundUserID(200 + i)
|
|
|
|
|
+ if c.globalBal != nil {
|
|
|
|
|
+ if err := db.Create(&model.UserExpense{UserID: user, Balance: *c.globalBal}).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("%s: seed 全局账户失败: %v", c.name, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if c.campusBal != nil {
|
|
|
|
|
+ if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: inst, CampusID: camp,
|
|
|
|
|
+ Balance: *c.campusBal}).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("%s: seed 校区账户失败: %v", c.name, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, g := range c.gifts {
|
|
|
|
|
+ seedGiftUsed(t, db, user, g.BizNo, g.Activity, g.Approved, g.Money, g.MoneyUsed)
|
|
|
|
|
+ }
|
|
|
|
|
+ got, err := svc.RefundableAmount(ctx, user, inst, camp)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("%s: RefundableAmount 失败: %v", c.name, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got.Cents() != c.wantCents {
|
|
|
|
|
+ t.Fatalf("%s: 实得 %d 分,期望 %d 分", c.name, got.Cents(), c.wantCents)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got < 0 {
|
|
|
|
|
+ t.Fatalf("%s: 可退额不得为负,实得 %d", c.name, got.Cents())
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // ④ 校区缺失 → 3140
|
|
|
|
|
- if _, err := svc.RefundableAmount(ctx, fundUserID(13), 1, 0); err != nil {
|
|
|
|
|
|
|
+ // campusID <= 0 → 3140(入参校验,与账户数据无关)
|
|
|
|
|
+ if _, err := svc.RefundableAmount(ctx, fundUserID(200), inst, 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)
|
|
|
|
|
|
|
+ // 越界口径提醒:可退额只做"查询",不因赠送额而改动账户(只读断言)
|
|
|
|
|
+ if got := readSchool(t, db, fundUserID(201), inst, camp).Balance; cents(t, got) != 20000 {
|
|
|
|
|
+ t.Fatalf("RefundableAmount 必须只读(②用例校区余额应仍 20000 分),实得 %s", got)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// strPtr 取字符串指针(表驱动里表达"该账户存在且余额为 X")。
|
|
|
|
|
+func strPtr(s string) *string { return &s }
|
|
|
|
|
+
|
|
|
// ---------------------------------------------------------------- 坏账(SP7,双加)
|
|
// ---------------------------------------------------------------- 坏账(SP7,双加)
|
|
|
|
|
|
|
|
// TestBadDebtAddsBalanceAndSpecialExpenses dump 口径「余额+,特殊费用+」:
|
|
// TestBadDebtAddsBalanceAndSpecialExpenses dump 口径「余额+,特殊费用+」:
|
|
@@ -776,11 +891,11 @@ func sumLogMoney(t *testing.T, rows []model.UserExpenseLog) money.Amount {
|
|
|
|
|
|
|
|
// classifyBadDebtLogs 按 dump 兼容口径把 type=108 分成两类。
|
|
// classifyBadDebtLogs 按 dump 兼容口径把 type=108 分成两类。
|
|
|
//
|
|
//
|
|
|
-// 判别式(**唯一、可机械执行**):取 108 流水的 `logid`(= `request_id`)——
|
|
|
|
|
-// - **同 logid 存在 type=207 流水** → 差额型(`CustomRefund` 的退费差额):
|
|
|
|
|
|
|
+// 判别式(**唯一、可机械执行**,设计 §3.8-b (f) A18):取 108 流水的 `logid`(= `request_id`)——
|
|
|
|
|
+// - **同 logid 存在 type=207 且 logid 非空(`<> ”`)** → 差额型(`CustomRefund` 的退费差额):
|
|
|
// 该 108 的金额已含在 207 的 −requested 全额里,**不构成**一次独立的余额增加;
|
|
// 该 108 的金额已含在 207 的 −requested 全额里,**不构成**一次独立的余额增加;
|
|
|
-// - **不存在** → 独立坏账(`FundService.BadDebt`):balance 与 special_expenses 双加,
|
|
|
|
|
-// 该 108 **就是**一次余额增加。
|
|
|
|
|
|
|
+// - **不存在(含 logid 为空/NULL)** → 独立坏账(`FundService.BadDebt`):balance 与
|
|
|
|
|
+// special_expenses 双加,该 108 **就是**一次余额增加。
|
|
|
func classifyBadDebtLogs(t *testing.T, db *gorm.DB, userID string) (
|
|
func classifyBadDebtLogs(t *testing.T, db *gorm.DB, userID string) (
|
|
|
independent, differential []model.UserExpenseLog,
|
|
independent, differential []model.UserExpenseLog,
|
|
|
) {
|
|
) {
|
|
@@ -788,16 +903,18 @@ func classifyBadDebtLogs(t *testing.T, db *gorm.DB, userID string) (
|
|
|
all := balanceLogs(t, db, userID)
|
|
all := balanceLogs(t, db, userID)
|
|
|
refundKeys := make(map[string]bool)
|
|
refundKeys := make(map[string]bool)
|
|
|
for i := range all {
|
|
for i := range all {
|
|
|
- if all[i].Type != model.ExpenseLogTypeRefund || all[i].LogID == nil {
|
|
|
|
|
|
|
+ if all[i].Type != model.ExpenseLogTypeRefund {
|
|
|
continue
|
|
continue
|
|
|
}
|
|
}
|
|
|
- refundKeys[*all[i].LogID] = true
|
|
|
|
|
|
|
+ if key := logKeyOf(all[i]); key != "" {
|
|
|
|
|
+ refundKeys[key] = true
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
for i := range all {
|
|
for i := range all {
|
|
|
if all[i].Type != model.ExpenseLogTypeBadDebt {
|
|
if all[i].Type != model.ExpenseLogTypeBadDebt {
|
|
|
continue
|
|
continue
|
|
|
}
|
|
}
|
|
|
- if all[i].LogID != nil && refundKeys[*all[i].LogID] {
|
|
|
|
|
|
|
+ if key := logKeyOf(all[i]); key != "" && refundKeys[key] {
|
|
|
differential = append(differential, all[i])
|
|
differential = append(differential, all[i])
|
|
|
continue
|
|
continue
|
|
|
}
|
|
}
|
|
@@ -806,6 +923,14 @@ func classifyBadDebtLogs(t *testing.T, db *gorm.DB, userID string) (
|
|
|
return independent, differential
|
|
return independent, differential
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// logKeyOf 取流水的 logid(= request_id)原文;NULL/空 → ""(与 A18 判定 SQL 的 `log_id <> ”` 同口径)。
|
|
|
|
|
+func logKeyOf(row model.UserExpenseLog) string {
|
|
|
|
|
+ if row.LogID == nil {
|
|
|
|
|
+ return ""
|
|
|
|
|
+ }
|
|
|
|
|
+ return strings.TrimSpace(*row.LogID)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// TestBalanceIdentityIndependentBadDebtCounted 独立坏账(108 无 207 同伴)**参与**恒等式:
|
|
// TestBalanceIdentityIndependentBadDebtCounted 独立坏账(108 无 207 同伴)**参与**恒等式:
|
|
|
// 余额增量恰等于该 108 的金额,且「balance = Σ全部流水」成立(旧口径下这里是唯一破口)。
|
|
// 余额增量恰等于该 108 的金额,且「balance = Σ全部流水」成立(旧口径下这里是唯一破口)。
|
|
|
func TestBalanceIdentityIndependentBadDebtCounted(t *testing.T) {
|
|
func TestBalanceIdentityIndependentBadDebtCounted(t *testing.T) {
|