|
|
@@ -67,6 +67,54 @@ func DefaultCases(_ string) []Case {
|
|
|
},
|
|
|
)
|
|
|
|
|
|
+ // ---- 批 B ----
|
|
|
+ out = append(out,
|
|
|
+ Case{
|
|
|
+ Name: "B1 坏账 sp_user_expense_baddebt(80.00)",
|
|
|
+ Op: OpBadDebt, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
|
|
|
+ AmountCents: 8000, RequestID: "ac15-b1", RunTimes: 1,
|
|
|
+ ExpectTargetSuccess: true, ExpectLegacySuccess: true,
|
|
|
+ Note: "**balance 与 special_expenses 双增**(不是只加特殊费用);流水 type=108;默认备注\"坏账处理\"",
|
|
|
+ },
|
|
|
+ Case{
|
|
|
+ Name: "B2 累计消费 sp_user_expense_school_spend(25.00)",
|
|
|
+ Op: OpSpend, LegacyUserID: 31477, InstitutionID: 63317, CampusID: 5,
|
|
|
+ AmountCents: 2500, RequestID: "ac15-b2", RunTimes: 1,
|
|
|
+ ExpectTargetSuccess: true, ExpectLegacySuccess: true,
|
|
|
+ Note: "**只动 spend,不动 balance,且 dump 不写流水**(SP11 签名里连请求号都没有)",
|
|
|
+ },
|
|
|
+ Case{
|
|
|
+ Name: "B3 课费转结 sp_user_expense_school_transfer_expenditure(50.00,campus 5→1)",
|
|
|
+ Op: OpTransfer, LegacyUserID: 31477, InstitutionID: 63317, CampusID: 5,
|
|
|
+ ToInstitutionID: 63317, ToCampusID: 1,
|
|
|
+ AmountCents: 5000, RequestID: "ac15-b3", RunTimes: 1,
|
|
|
+ Remark: "ac15 课费转结",
|
|
|
+ ExpectTargetSuccess: true, ExpectLegacySuccess: true,
|
|
|
+ Note: "**不动 balance**;双流水 106(+50)/206(−50) Σ=0;两校区 total_expenditure/total_income 各自反向。" +
|
|
|
+ "SP6 **没有默认备注**(inputRemark 直接用),所以这里显式传一条,顺带验证备注透传",
|
|
|
+ },
|
|
|
+ Case{
|
|
|
+ Name: "B4 课费转结·幂等(同一 requestId 跑 2 次)",
|
|
|
+ Op: OpTransfer, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
|
|
|
+ ToInstitutionID: 63317, ToCampusID: 1,
|
|
|
+ AmountCents: 5000, RequestID: "ac15-b4", RunTimes: 2,
|
|
|
+ Remark: "ac15 课费转结·幂等",
|
|
|
+ ExpectTargetSuccess: true, ExpectLegacySuccess: true,
|
|
|
+ Note: "106 是主流水,幂等按 (request_id, type=106) 判定",
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+ // ---- A7:R19(campusid=0)intentional-strict ----
|
|
|
+ out = append(out, Case{
|
|
|
+ Name: "A7 R19·campusid=0 调充值(legacy 静默 no-op vs Go 3140)",
|
|
|
+ Op: OpRecharge, LegacyUserID: 31477, InstitutionID: 63317, CampusID: 0,
|
|
|
+ AmountCents: 10000, Type: 100, RequestID: "ac15-a7", RunTimes: 1,
|
|
|
+ ExpectTargetSuccess: false, ExpectLegacySuccess: true,
|
|
|
+ IntentionalStrict: true,
|
|
|
+ Note: "R19:SP1 有 `schoolid<1||campusid<1 → LEAVE` 守卫,**静默不写、返回成功**;" +
|
|
|
+ "Go 走 validateUserScope 报 3140。两侧**本来就该不同**,本 case 只把差异钉死",
|
|
|
+ })
|
|
|
+
|
|
|
// ---- 消费(第二批已做,留作回归)----
|
|
|
base := Case{
|
|
|
Op: OpExpenditure, LegacyUserID: 17735, InstitutionID: 63317, CampusID: 5,
|
|
|
@@ -167,6 +215,37 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
|
|
|
diffs = append(diffs, Diff{Item: item, Legacy: l, Target: t, Equal: equal, Note: note})
|
|
|
}
|
|
|
|
|
|
+ // ⓪ intentional-strict(R19):两侧**本来就该不同**,不参与"两侧相等"判定
|
|
|
+ if c.IntentionalStrict {
|
|
|
+ lgOK := len(lg.Calls) > 0 && lg.Calls[0].Success
|
|
|
+ tgErr := errOf(tg.Calls)
|
|
|
+ tgRejected := len(tg.Calls) > 0 && !tg.Calls[0].Success
|
|
|
+ // 「没写库」必须两侧都比**全局+校区**两块:campusid=0 的用例里
|
|
|
+ // 如果只比全局,误写成"只写校区表"的实现会静默漏过去。
|
|
|
+ lgWrote := lg.Before.Global != lg.After.Global || lg.Before.Campus != lg.After.Campus
|
|
|
+ tgWrote := tg.Before.Global != tg.After.Global || tg.Before.Campus != tg.After.Campus
|
|
|
+ noWrite := !lgWrote && !tgWrote && len(lg.After.Logs) == 0 && len(tg.After.Logs) == 0
|
|
|
+ add("legacy 是否「成功返回」", fmt.Sprint(lgOK), "N/A", lgOK,
|
|
|
+ "R19:SP 静默 LEAVE,调用方看到的是成功")
|
|
|
+ add("target 是否显式拒绝", "N/A", fmt.Sprint(tgRejected), tgRejected,
|
|
|
+ "Go 应报 3140(unscoped),错误文本:"+tgErr)
|
|
|
+ asserts = append(asserts,
|
|
|
+ Assertion{
|
|
|
+ Name: "intentional-strict:legacy 静默成功 + target 显式拒绝(R19)",
|
|
|
+ Pass: lgOK && tgRejected,
|
|
|
+ Detail: fmt.Sprintf("legacy success=%v(期望 true,SP 静默 no-op);target 被拒=%v err=%q(期望 被拒=true + 3140)",
|
|
|
+ lgOK, tgRejected, tgErr),
|
|
|
+ },
|
|
|
+ Assertion{
|
|
|
+ Name: "intentional-strict:两侧都**没有写库**(全局与校区余额不变、流水 0 条)",
|
|
|
+ Pass: noWrite,
|
|
|
+ Detail: fmt.Sprintf("legacy 写库=%v(全局/校区)流水=%d | target 写库=%v(全局/校区)流水=%d —— 都应为 未写库/0 条",
|
|
|
+ lgWrote, len(lg.After.Logs), tgWrote, len(tg.After.Logs)),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ return diffs, asserts
|
|
|
+ }
|
|
|
+
|
|
|
// ① 基线逐分相等(case 有效性前提)
|
|
|
baseEqual := lg.Before.Global == tg.Before.Global && lg.Before.Campus == tg.Before.Campus
|
|
|
asserts = append(asserts, Assertion{
|
|
|
@@ -177,49 +256,71 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
|
|
|
fmtAcc(tg.Before.Global), fmtAcc(tg.Before.Campus)),
|
|
|
})
|
|
|
|
|
|
- // ② Δ 逐分相等(核心):拿每侧实测 Δ 去对**该侧应有的期望值**
|
|
|
- //
|
|
|
- // runsLegacy:legacy SP 每次都真入账(无幂等键)→ 调用几次就入账几次
|
|
|
- // runsTarget:Go 侧有 uk_request_type 幂等 → 无论调用几次只入账一次
|
|
|
+ // ② Δ 逐分相等(核心):三块账户(全局 / 转出校区 / 转入校区)各对各自的期望
|
|
|
runsLegacy := maxInt(1, c.RunTimes)
|
|
|
runsTarget := 1
|
|
|
noteRuns := ""
|
|
|
if c.RunTimes >= 2 {
|
|
|
noteRuns = fmt.Sprintf("预期差异:legacy 入账 %d 次(SP 无幂等)/ target 入账 1 次(幂等命中)", runsLegacy)
|
|
|
}
|
|
|
- wantL := expectedDeltaFor(c.Op, c.Type, c.AmountCents, runsLegacy)
|
|
|
- wantT := expectedDeltaFor(c.Op, c.Type, c.AmountCents, runsTarget)
|
|
|
+ wgL, wfL, wtL := expectedAccounts(c.Op, c.Type, c.AmountCents, runsLegacy)
|
|
|
+ wgT, wfT, wtT := expectedAccounts(c.Op, c.Type, c.AmountCents, runsTarget)
|
|
|
|
|
|
dGlobal := sub(lg.After.Global, lg.Before.Global)
|
|
|
dCampus := sub(lg.After.Campus, lg.Before.Campus)
|
|
|
+ dCampus2 := sub(lg.After.Campus2, lg.Before.Campus2)
|
|
|
tGlobal := sub(tg.After.Global, tg.Before.Global)
|
|
|
tCampus := sub(tg.After.Campus, tg.Before.Campus)
|
|
|
-
|
|
|
- add("Δbalance(全局)", yuan(dGlobal.Balance), yuan(tGlobal.Balance),
|
|
|
- dGlobal.Balance == wantL.Balance && tGlobal.Balance == wantT.Balance,
|
|
|
- "期望 "+yuan(wantL.Balance)+" / "+yuan(wantT.Balance)+";容差 0 "+noteRuns)
|
|
|
- add("Δbalance(校区)", yuan(dCampus.Balance), yuan(tCampus.Balance),
|
|
|
- dCampus.Balance == wantL.Balance && tCampus.Balance == wantT.Balance, "同上")
|
|
|
- add("Δspend(全局)", yuan(dGlobal.Spend), yuan(tGlobal.Spend),
|
|
|
- dGlobal.Spend == wantL.Spend && tGlobal.Spend == wantT.Spend,
|
|
|
- "只有 type=200 的消费才动 spend;期望 "+yuan(wantL.Spend)+" / "+yuan(wantT.Spend))
|
|
|
- add("Δspend(校区)", yuan(dCampus.Spend), yuan(tCampus.Spend),
|
|
|
- dCampus.Spend == wantL.Spend && tCampus.Spend == wantT.Spend, "同上")
|
|
|
- add("Δtotal_income(全局)", yuan(dGlobal.Income), yuan(tGlobal.Income),
|
|
|
- dGlobal.Income == wantL.Income && tGlobal.Income == wantT.Income,
|
|
|
- "充值才动 total_income;期望 "+yuan(wantL.Income)+" / "+yuan(wantT.Income))
|
|
|
- add("Δtotal_income(校区)", yuan(dCampus.Income), yuan(tCampus.Income),
|
|
|
- dCampus.Income == wantL.Income && tCampus.Income == wantT.Income, "同上")
|
|
|
- add("Δtotal_expenditure(全局)", yuan(dGlobal.Expenditure), yuan(tGlobal.Expenditure),
|
|
|
- dGlobal.Expenditure == wantL.Expenditure && tGlobal.Expenditure == wantT.Expenditure,
|
|
|
- "期望 "+yuan(wantL.Expenditure)+" / "+yuan(wantT.Expenditure))
|
|
|
- add("Δtotal_expenditure(校区)", yuan(dCampus.Expenditure), yuan(tCampus.Expenditure),
|
|
|
- dCampus.Expenditure == wantL.Expenditure && tCampus.Expenditure == wantT.Expenditure, "同上")
|
|
|
+ tCampus2 := sub(tg.After.Campus2, tg.Before.Campus2)
|
|
|
+
|
|
|
+ addDelta := func(name string, dl, dt, wl, wt Account) {
|
|
|
+ addDeltaRow(&diffs, name+" Δbalance", yuan(dl.Balance), yuan(dt.Balance),
|
|
|
+ dl.Balance == wl.Balance && dt.Balance == wt.Balance,
|
|
|
+ "期望 "+yuan(wl.Balance)+" / "+yuan(wt.Balance)+";容差 0 "+noteRuns)
|
|
|
+ addDeltaRow(&diffs, name+" Δspend", yuan(dl.Spend), yuan(dt.Spend),
|
|
|
+ dl.Spend == wl.Spend && dt.Spend == wt.Spend, "期望 "+yuan(wl.Spend)+" / "+yuan(wt.Spend))
|
|
|
+ addDeltaRow(&diffs, name+" Δtotal_income", yuan(dl.Income), yuan(dt.Income),
|
|
|
+ dl.Income == wl.Income && dt.Income == wt.Income, "期望 "+yuan(wl.Income)+" / "+yuan(wt.Income))
|
|
|
+ addDeltaRow(&diffs, name+" Δtotal_expenditure", yuan(dl.Expenditure), yuan(dt.Expenditure),
|
|
|
+ dl.Expenditure == wl.Expenditure && dt.Expenditure == wt.Expenditure,
|
|
|
+ "期望 "+yuan(wl.Expenditure)+" / "+yuan(wt.Expenditure))
|
|
|
+ addDeltaRow(&diffs, name+" Δspecial_expenses", yuan(dl.Special), yuan(dt.Special),
|
|
|
+ dl.Special == wl.Special && dt.Special == wt.Special,
|
|
|
+ "期望 "+yuan(wl.Special)+" / "+yuan(wt.Special))
|
|
|
+ }
|
|
|
+ addDelta("全局", dGlobal, tGlobal, wgL, wgT)
|
|
|
+ addDelta("转出校区", dCampus, tCampus, wfL, wfT)
|
|
|
+ if c.Op == OpTransfer {
|
|
|
+ addDelta("转入校区", dCampus2, tCampus2, wtL, wtT)
|
|
|
+ }
|
|
|
+
|
|
|
+ // ②.5 「不动的列」必须**显式断言为 0**,不能只是"不比较"
|
|
|
+ if c.Op == OpSpend || c.Op == OpTransfer {
|
|
|
+ names := map[Op]string{OpSpend: "累计消费(spend)", OpTransfer: "课费转结(transfer)"}[c.Op]
|
|
|
+ ok := dGlobal.Balance == 0 && tGlobal.Balance == 0
|
|
|
+ if c.Op == OpTransfer {
|
|
|
+ ok = ok && dCampus.Balance == 0 && tCampus.Balance == 0 &&
|
|
|
+ dCampus2.Balance == 0 && tCampus2.Balance == 0
|
|
|
+ } else {
|
|
|
+ ok = ok && dCampus.Balance == 0 && tCampus.Balance == 0
|
|
|
+ }
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
+ Name: names + ":Δbalance 必须为 0(不是不比较,是断言它为 0)",
|
|
|
+ Pass: ok,
|
|
|
+ Detail: fmt.Sprintf("全局 %s/%s;转出校区 %s/%s;转入校区 %s/%s(都应为 0.00)",
|
|
|
+ yuan(dGlobal.Balance), yuan(tGlobal.Balance),
|
|
|
+ yuan(dCampus.Balance), yuan(tCampus.Balance),
|
|
|
+ yuan(dCampus2.Balance), yuan(tCampus2.Balance)),
|
|
|
+ })
|
|
|
+ }
|
|
|
|
|
|
// ③ 流水:条数 / type / money / logId
|
|
|
+ // 期望流水条数按 Op 决定:spend 不写流水(0)、transfer 每次 2 条、其余每次 1 条
|
|
|
+ wantLogsL := expectedLogCount(c.Op, runsLegacy)
|
|
|
+ wantLogsT := expectedLogCount(c.Op, runsTarget)
|
|
|
add("流水条数", fmt.Sprint(len(lg.After.Logs)), fmt.Sprint(len(tg.After.Logs)),
|
|
|
- len(lg.After.Logs) == runsLegacy && len(tg.After.Logs) == runsTarget,
|
|
|
- fmt.Sprintf("期望 legacy=%d / target=%d", runsLegacy, runsTarget))
|
|
|
+ len(lg.After.Logs) == wantLogsL && len(tg.After.Logs) == wantLogsT,
|
|
|
+ fmt.Sprintf("期望 legacy=%d / target=%d", wantLogsL, wantLogsT))
|
|
|
|
|
|
n := len(lg.After.Logs)
|
|
|
if len(tg.After.Logs) < n {
|
|
|
@@ -282,7 +383,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
|
|
|
}
|
|
|
}
|
|
|
add("流水 remark(默认备注)", firstOr(detailJoin(remarkDetail), "逐字一致"), fmt.Sprint(n)+" 条",
|
|
|
- remarkOK, "SP1\"余额充值\"/SP2\"充值缴费\"/SP4\"报课的费用退回\"")
|
|
|
+ remarkOK, remarkNote(c.Op))
|
|
|
|
|
|
// ⑤ 返回码 / 成功与否
|
|
|
lgOK := len(lg.Calls) > 0 && lg.Calls[0].Success
|
|
|
@@ -318,14 +419,59 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
|
|
|
// **都** UPDATE 了 t_user_expense_school 和 t_user_expense(两段 UPDATE 都在)。
|
|
|
// 所以真正要防的错是"把充值写成只写全局"——那样校区表 Δ=0,这条会立刻红。
|
|
|
if c.Op == OpRecharge || c.Op == OpSchoolRecharge {
|
|
|
- okL := dCampus.Balance == wantL.Balance && dCampus.Income == wantL.Income
|
|
|
- okT := tCampus.Balance == wantT.Balance && tCampus.Income == wantT.Income
|
|
|
+ okL := dCampus.Balance == wfL.Balance && dCampus.Income == wfL.Income
|
|
|
+ okT := tCampus.Balance == wfT.Balance && tCampus.Income == wfT.Income
|
|
|
asserts = append(asserts, Assertion{
|
|
|
Name: "双表写入:校区表 Δbalance / Δtotal_income 必须等于充值额(若被写成\"只写全局\"会红)",
|
|
|
Pass: okL && okT,
|
|
|
Detail: fmt.Sprintf("校区 Δbalance legacy=%s target=%s(期望 %s / %s);Δincome legacy=%s target=%s(期望 %s / %s)",
|
|
|
- yuan(dCampus.Balance), yuan(tCampus.Balance), yuan(wantL.Balance), yuan(wantT.Balance),
|
|
|
- yuan(dCampus.Income), yuan(tCampus.Income), yuan(wantL.Income), yuan(wantT.Income)),
|
|
|
+ yuan(dCampus.Balance), yuan(tCampus.Balance), yuan(wfL.Balance), yuan(wfT.Balance),
|
|
|
+ yuan(dCampus.Income), yuan(tCampus.Income), yuan(wfL.Income), yuan(wfT.Income)),
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ // ⑥.6 批 B 的 op 专属断言
|
|
|
+ switch c.Op {
|
|
|
+ case OpSpend:
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
+ Name: "累计消费:**不写流水**(dump 的 SP11 全文没有 INSERT log)",
|
|
|
+ Pass: len(lg.After.Logs) == 0 && len(tg.After.Logs) == 0,
|
|
|
+ Detail: fmt.Sprintf("legacy %d 条 / target %d 条(都应为 0)", len(lg.After.Logs), len(tg.After.Logs)),
|
|
|
+ })
|
|
|
+ case OpTransfer:
|
|
|
+ sumL, sumT := sumMoney(lg.After.Logs), sumMoney(tg.After.Logs)
|
|
|
+ typesL, typesT := logTypes(lg.After.Logs), logTypes(tg.After.Logs)
|
|
|
+ okL := transferLogsOK(lg.After.Logs, runsLegacy)
|
|
|
+ okT := transferLogsOK(tg.After.Logs, runsTarget)
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
+ Name: "转结:每次**一对流水(106 +m / 206 −m)且 SUM(money)==0**(钱没消失,只是换了校区)",
|
|
|
+ Pass: okL && okT && sumL == 0 && sumT == 0,
|
|
|
+ Detail: fmt.Sprintf("legacy %d 条 types=%s SUM=%s(期望 %d 条)| target %d 条 types=%s SUM=%s(期望 %d 条)",
|
|
|
+ len(lg.After.Logs), typesL, yuan(sumL), 2*runsLegacy,
|
|
|
+ len(tg.After.Logs), typesT, yuan(sumT), 2*runsTarget),
|
|
|
+ })
|
|
|
+ // 转结的核心语义:两个校区**各自**变化,且互为反号
|
|
|
+ ok := dCampus.Expenditure == -dCampus2.Expenditure && tCampus.Expenditure == -tCampus2.Expenditure &&
|
|
|
+ dCampus.Expenditure != 0
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
+ Name: "转结:两校区 total_expenditure 变化量**互为反号**(转出 −m / 转入 +m)",
|
|
|
+ Pass: ok,
|
|
|
+ Detail: fmt.Sprintf("legacy 转出=%s 转入=%s | target 转出=%s 转入=%s",
|
|
|
+ yuan(dCampus.Expenditure), yuan(dCampus2.Expenditure),
|
|
|
+ yuan(tCampus.Expenditure), yuan(tCampus2.Expenditure)),
|
|
|
+ })
|
|
|
+ case OpBadDebt:
|
|
|
+ // 双增:balance 与 special_expenses 都必须 +amount(不能只加其中一个)
|
|
|
+ ok := dGlobal.Balance == c.AmountCents && dGlobal.Special == c.AmountCents &&
|
|
|
+ tGlobal.Balance == c.AmountCents && tGlobal.Special == c.AmountCents &&
|
|
|
+ dCampus.Balance == c.AmountCents && dCampus.Special == c.AmountCents &&
|
|
|
+ tCampus.Balance == c.AmountCents && tCampus.Special == c.AmountCents
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
+ Name: "坏账:balance 与 special_expenses **必须同时 +金额**(只加其中一个 → 红)",
|
|
|
+ Pass: ok,
|
|
|
+ Detail: fmt.Sprintf("legacy 全局 Δbal=%s Δspec=%s | target 全局 Δbal=%s Δspec=%s(期望各 +%s)",
|
|
|
+ yuan(dGlobal.Balance), yuan(dGlobal.Special),
|
|
|
+ yuan(tGlobal.Balance), yuan(tGlobal.Special), yuan(c.AmountCents)),
|
|
|
})
|
|
|
}
|
|
|
|
|
|
@@ -421,6 +567,7 @@ func sub(a, b Account) Account {
|
|
|
Spend: a.Spend - b.Spend,
|
|
|
Income: a.Income - b.Income,
|
|
|
Expenditure: a.Expenditure - b.Expenditure,
|
|
|
+ Special: a.Special - b.Special, // 漏掉这一行会让 Δspecial 恒为 0(坏账 case 假红)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -438,6 +585,83 @@ func abs64(v int64) int64 {
|
|
|
|
|
|
func detailJoin(ss []string) string { return strings.Join(ss, ";") }
|
|
|
|
|
|
+// expectedLogCount 按 Op 给出**期望流水条数**。
|
|
|
+//
|
|
|
+// dump 事实:SP11(spend)**不写流水**;SP6(transfer)每次写 2 条(106/206);其余每次 1 条。
|
|
|
+func expectedLogCount(op Op, runs int) int {
|
|
|
+ switch op {
|
|
|
+ case OpSpend:
|
|
|
+ return 0
|
|
|
+ case OpTransfer:
|
|
|
+ return 2 * runs
|
|
|
+ default:
|
|
|
+ return runs
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// sumMoney 求一批流水的 money 之和(分)。
|
|
|
+func sumMoney(logs []LogRow) int64 {
|
|
|
+ var sum int64
|
|
|
+ for _, l := range logs {
|
|
|
+ sum += l.Money
|
|
|
+ }
|
|
|
+ return sum
|
|
|
+}
|
|
|
+
|
|
|
+// transferLogsOK 校验转结流水:每对必须是 (106, +m) 然后 (206, −m),共 pairs 对。
|
|
|
+func transferLogsOK(logs []LogRow, pairs int) bool {
|
|
|
+ if len(logs) != 2*pairs {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ for i := 0; i+1 < len(logs); i += 2 {
|
|
|
+ a, b := logs[i], logs[i+1]
|
|
|
+ if a.Type != 106 || b.Type != 206 {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ if a.Money <= 0 || b.Money != -a.Money {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return true
|
|
|
+}
|
|
|
+
|
|
|
+// logTypes 把流水 type 按出现顺序拼成 "106,206" 形式。
|
|
|
+func logTypes(logs []LogRow) string {
|
|
|
+ parts := make([]string, len(logs))
|
|
|
+ for i, l := range logs {
|
|
|
+ parts[i] = fmt.Sprint(l.Type)
|
|
|
+ }
|
|
|
+ return strings.Join(parts, ",")
|
|
|
+}
|
|
|
+
|
|
|
+// remarkNote 说明该 Op 下两侧默认备注的出处(照 dump 抄的固定文案)。
|
|
|
+//
|
|
|
+// 默认备注是同一批 SP 之间少数"只能逐字比对、无法从列名推断"的东西:
|
|
|
+// 名字里都没有"备注"二字,值却各不相同,写错不会让任何金额断言变红。
|
|
|
+func remarkNote(op Op) string {
|
|
|
+ switch op {
|
|
|
+ case OpRecharge:
|
|
|
+ return "SP1 默认备注「余额充值」"
|
|
|
+ case OpSchoolRecharge:
|
|
|
+ return "SP2 默认备注「充值缴费」"
|
|
|
+ case OpRefund:
|
|
|
+ return "SP4 默认备注「报课的费用退回」"
|
|
|
+ case OpBadDebt:
|
|
|
+ return "SP3 默认备注「坏账处理」"
|
|
|
+ case OpTransfer:
|
|
|
+ return "SP6 **没有默认备注**(inputRemark 原样落库),case 里显式传入以验证透传"
|
|
|
+ case OpSpend:
|
|
|
+ return "SP11 不写流水,无备注可比(0 条)"
|
|
|
+ default:
|
|
|
+ return "消费 SP 的默认备注(照 dump 逐字)"
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// addDeltaRow 往 diffs 追加一行 Δ 比对。
|
|
|
+func addDeltaRow(diffs *[]Diff, item, l, t string, equal bool, note string) {
|
|
|
+ *diffs = append(*diffs, Diff{Item: item, Legacy: l, Target: t, Equal: equal, Note: note})
|
|
|
+}
|
|
|
+
|
|
|
func firstOr(s, def string) string {
|
|
|
if s == "" {
|
|
|
return def
|