|
@@ -177,13 +177,17 @@ func DefaultCases(_ string) []Case {
|
|
|
Note: "SP10 第二条守卫 `ISNULL(totalBalance)||totalBalance<=0 → LEAVE`",
|
|
Note: "SP10 第二条守卫 `ISNULL(totalBalance)||totalBalance<=0 → LEAVE`",
|
|
|
},
|
|
},
|
|
|
Case{
|
|
Case{
|
|
|
- Name: "C1-5 清零前置校验·校区余额 > 全局余额(legacy 静默 vs Go 3120)",
|
|
|
|
|
- Op: OpClearBalance, LegacyUserID: 40575, InstitutionID: 63317, CampusID: 4,
|
|
|
|
|
- RequestID: "ac15-c1-5", RunTimes: 1, SelfSupport: false,
|
|
|
|
|
- Baseline: &Baseline{GlobalBalance: 50000, CampusBalance: 80000},
|
|
|
|
|
|
|
+ // 这一条**用真实构型,不用 Baseline 造数**:
|
|
|
|
|
+ // `finance_bk` 里 `schoolid=63317 且 校区余额 > 全局余额 > 0` 有 21 行,
|
|
|
|
|
+ // 48170 是其中最干净的一个(campus 5 = 0.270,全局 = 0.269,都是 2 位小数)。
|
|
|
|
|
+ // 造数能造出"实际不会发生的组合",绿了也没意义;真实构型证明这个守卫在真数据上会被触发。
|
|
|
|
|
+ Name: "C1-5 清零前置校验·校区余额 > 全局余额(真实构型,legacy 静默 vs Go 3120)",
|
|
|
|
|
+ Op: OpClearBalance, LegacyUserID: 48170, InstitutionID: 63317, CampusID: 5,
|
|
|
|
|
+ RequestID: "ac15-c1-5", RunTimes: 1, SelfSupport: true,
|
|
|
ExpectTargetSuccess: false, ExpectLegacySuccess: true,
|
|
ExpectTargetSuccess: false, ExpectLegacySuccess: true,
|
|
|
ExpectTargetCode: 3120, IntentionalStrict: true,
|
|
ExpectTargetCode: 3120, IntentionalStrict: true,
|
|
|
- Note: "SP10 第三条守卫 `campusBalance>totalBalance → LEAVE`(数据异常,拒绝对负余额清零)",
|
|
|
|
|
|
|
+ Note: "SP10 第三条守卫 `campusBalance>totalBalance → LEAVE`。真实数据:校区 0.270 > 全局 0.269(差 1 分)," +
|
|
|
|
|
+ "正是「余额 ≠ 流水累计」那 6.6% 用户的一种表现",
|
|
|
},
|
|
},
|
|
|
)
|
|
)
|
|
|
|
|
|
|
@@ -527,19 +531,33 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
|
|
|
tCampus2 := sub(tg.After.Campus2, tg.Before.Campus2)
|
|
tCampus2 := sub(tg.After.Campus2, tg.Before.Campus2)
|
|
|
|
|
|
|
|
addDelta := func(name string, dl, dt, wl, wt Account) {
|
|
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))
|
|
|
|
|
|
|
+ addDeltaRow(&diffs, name+" Δbalance",
|
|
|
|
|
+ moneyStr(dl.Balance, dl.Nulls.Balance), moneyStr(dt.Balance, dt.Nulls.Balance),
|
|
|
|
|
+ sameMoney(dl.Balance, dl.Nulls.Balance, wl.Balance, wl.Nulls.Balance) &&
|
|
|
|
|
+ sameMoney(dt.Balance, dt.Nulls.Balance, wt.Balance, wt.Nulls.Balance),
|
|
|
|
|
+ "期望 "+moneyStr(wl.Balance, wl.Nulls.Balance)+" / "+
|
|
|
|
|
+ moneyStr(wt.Balance, wt.Nulls.Balance)+";容差 0 "+noteRuns)
|
|
|
|
|
+ addDeltaRow(&diffs, name+" Δspend",
|
|
|
|
|
+ moneyStr(dl.Spend, dl.Nulls.Spend), moneyStr(dt.Spend, dt.Nulls.Spend),
|
|
|
|
|
+ sameMoney(dl.Spend, dl.Nulls.Spend, wl.Spend, wl.Nulls.Spend) &&
|
|
|
|
|
+ sameMoney(dt.Spend, dt.Nulls.Spend, wt.Spend, wt.Nulls.Spend),
|
|
|
|
|
+ "期望 "+moneyStr(wl.Spend, wl.Nulls.Spend)+" / "+moneyStr(wt.Spend, wt.Nulls.Spend))
|
|
|
|
|
+ addDeltaRow(&diffs, name+" Δtotal_income",
|
|
|
|
|
+ moneyStr(dl.Income, dl.Nulls.Income), moneyStr(dt.Income, dt.Nulls.Income),
|
|
|
|
|
+ sameMoney(dl.Income, dl.Nulls.Income, wl.Income, wl.Nulls.Income) &&
|
|
|
|
|
+ sameMoney(dt.Income, dt.Nulls.Income, wt.Income, wt.Nulls.Income),
|
|
|
|
|
+ "期望 "+moneyStr(wl.Income, wl.Nulls.Income)+" / "+moneyStr(wt.Income, wt.Nulls.Income))
|
|
|
|
|
+ addDeltaRow(&diffs, name+" Δtotal_expenditure",
|
|
|
|
|
+ moneyStr(dl.Expenditure, dl.Nulls.Expenditure), moneyStr(dt.Expenditure, dt.Nulls.Expenditure),
|
|
|
|
|
+ sameMoney(dl.Expenditure, dl.Nulls.Expenditure, wl.Expenditure, wl.Nulls.Expenditure) &&
|
|
|
|
|
+ sameMoney(dt.Expenditure, dt.Nulls.Expenditure, wt.Expenditure, wt.Nulls.Expenditure),
|
|
|
|
|
+ "期望 "+moneyStr(wl.Expenditure, wl.Nulls.Expenditure)+" / "+
|
|
|
|
|
+ moneyStr(wt.Expenditure, wt.Nulls.Expenditure))
|
|
|
|
|
+ addDeltaRow(&diffs, name+" Δspecial_expenses",
|
|
|
|
|
+ moneyStr(dl.Special, dl.Nulls.Special), moneyStr(dt.Special, dt.Nulls.Special),
|
|
|
|
|
+ sameMoney(dl.Special, dl.Nulls.Special, wl.Special, wl.Nulls.Special) &&
|
|
|
|
|
+ sameMoney(dt.Special, dt.Nulls.Special, wt.Special, wt.Nulls.Special),
|
|
|
|
|
+ "期望 "+moneyStr(wl.Special, wl.Nulls.Special)+" / "+moneyStr(wt.Special, wt.Nulls.Special))
|
|
|
}
|
|
}
|
|
|
addDelta("全局", dGlobal, tGlobal, wgL, wgT)
|
|
addDelta("全局", dGlobal, tGlobal, wgL, wgT)
|
|
|
addDelta("转出校区", dCampus, tCampus, wfL, wfT)
|
|
addDelta("转出校区", dCampus, tCampus, wfL, wfT)
|
|
@@ -548,22 +566,25 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ②.5 「不动的列」必须**显式断言为 0**,不能只是"不比较"
|
|
// ②.5 「不动的列」必须**显式断言为 0**,不能只是"不比较"
|
|
|
|
|
+ //
|
|
|
|
|
+ // ⚠ 判"等于 0"必须用 `isZero`,不能写 `d.Balance == 0`:Δ 为 NULL 时值也是 0,
|
|
|
|
|
+ // 直接比 int64 会把「NULL(压根没算出来)」当成「确实是 0」,断言就空了。
|
|
|
if c.Op == OpSpend || c.Op == OpTransfer {
|
|
if c.Op == OpSpend || c.Op == OpTransfer {
|
|
|
names := map[Op]string{OpSpend: "累计消费(spend)", OpTransfer: "课费转结(transfer)"}[c.Op]
|
|
names := map[Op]string{OpSpend: "累计消费(spend)", OpTransfer: "课费转结(transfer)"}[c.Op]
|
|
|
- ok := dGlobal.Balance == 0 && tGlobal.Balance == 0
|
|
|
|
|
|
|
+ ok := isZero(dGlobal.Balance, dGlobal.Nulls.Balance) && isZero(tGlobal.Balance, tGlobal.Nulls.Balance)
|
|
|
if c.Op == OpTransfer {
|
|
if c.Op == OpTransfer {
|
|
|
- ok = ok && dCampus.Balance == 0 && tCampus.Balance == 0 &&
|
|
|
|
|
- dCampus2.Balance == 0 && tCampus2.Balance == 0
|
|
|
|
|
|
|
+ ok = ok && isZero(dCampus.Balance, dCampus.Nulls.Balance) && isZero(tCampus.Balance, tCampus.Nulls.Balance) &&
|
|
|
|
|
+ isZero(dCampus2.Balance, dCampus2.Nulls.Balance) && isZero(tCampus2.Balance, tCampus2.Nulls.Balance)
|
|
|
} else {
|
|
} else {
|
|
|
- ok = ok && dCampus.Balance == 0 && tCampus.Balance == 0
|
|
|
|
|
|
|
+ ok = ok && isZero(dCampus.Balance, dCampus.Nulls.Balance) && isZero(tCampus.Balance, tCampus.Nulls.Balance)
|
|
|
}
|
|
}
|
|
|
asserts = append(asserts, Assertion{
|
|
asserts = append(asserts, Assertion{
|
|
|
- Name: names + ":Δbalance 必须为 0(不是不比较,是断言它为 0)",
|
|
|
|
|
|
|
+ Name: names + ":Δbalance 必须**恰好是 0**(不是不比较,也不是 NULL)",
|
|
|
Pass: ok,
|
|
Pass: ok,
|
|
|
Detail: fmt.Sprintf("全局 %s/%s;转出校区 %s/%s;转入校区 %s/%s(都应为 0.00)",
|
|
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)),
|
|
|
|
|
|
|
+ moneyStr(dGlobal.Balance, dGlobal.Nulls.Balance), moneyStr(tGlobal.Balance, tGlobal.Nulls.Balance),
|
|
|
|
|
+ moneyStr(dCampus.Balance, dCampus.Nulls.Balance), moneyStr(tCampus.Balance, tCampus.Nulls.Balance),
|
|
|
|
|
+ moneyStr(dCampus2.Balance, dCampus2.Nulls.Balance), moneyStr(tCampus2.Balance, tCampus2.Nulls.Balance)),
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -766,28 +787,24 @@ func readLegacyNatural(ctx context.Context, cfg Config, legacyUserID int64) (Acc
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
return Account{}, 0, err
|
|
return Account{}, 0, err
|
|
|
}
|
|
}
|
|
|
- var bal, spend, inc, exp, spec string
|
|
|
|
|
|
|
+ var nat accountNS
|
|
|
err = db.Raw(`
|
|
err = db.Raw(`
|
|
|
SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
- CAST(ROUND(CAST(IFNULL(special_expenses,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
|
|
|
|
|
- FROM t_user_expense WHERE userid = ?`, legacyUserID).Row().Scan(&bal, &spend, &inc, &exp, &spec)
|
|
|
|
|
|
|
+ CAST(ROUND(CAST(special_expenses AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
|
|
|
|
|
+ FROM t_user_expense WHERE userid = ?`, legacyUserID).
|
|
|
|
|
+ Row().Scan(&nat.Balance, &nat.Spend, &nat.Income, &nat.Expenditure, &nat.Special)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
return Account{}, 0, fmt.Errorf("读 legacy 天然基线失败:%w", err)
|
|
return Account{}, 0, fmt.Errorf("读 legacy 天然基线失败:%w", err)
|
|
|
}
|
|
}
|
|
|
- b, _ := centsOf(bal)
|
|
|
|
|
- sp, _ := centsOf(spend)
|
|
|
|
|
- i, _ := centsOf(inc)
|
|
|
|
|
- e, _ := centsOf(exp)
|
|
|
|
|
- pc, _ := centsOf(spec)
|
|
|
|
|
var logCount int64
|
|
var logCount int64
|
|
|
if err := db.Raw(`SELECT COUNT(*) FROM t_user_expense_log WHERE userid = ?`, legacyUserID).
|
|
if err := db.Raw(`SELECT COUNT(*) FROM t_user_expense_log WHERE userid = ?`, legacyUserID).
|
|
|
Row().Scan(&logCount); err != nil {
|
|
Row().Scan(&logCount); err != nil {
|
|
|
return Account{}, 0, fmt.Errorf("读 legacy 天然流水条数失败:%w", err)
|
|
return Account{}, 0, fmt.Errorf("读 legacy 天然流水条数失败:%w", err)
|
|
|
}
|
|
}
|
|
|
- return Account{Balance: b, Spend: sp, Income: i, Expenditure: e, Special: pc}, logCount, nil
|
|
|
|
|
|
|
+ return nat.account(), logCount, nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// checkResidue 在两个事务都回滚之后,用**新连接**核对残留。
|
|
// checkResidue 在两个事务都回滚之后,用**新连接**核对残留。
|
|
@@ -811,22 +828,19 @@ func checkResidue(ctx context.Context, cfg Config, c Case, uid string, natural A
|
|
|
// legacy:用户本来就存在,核对"余额回到调用前"
|
|
// legacy:用户本来就存在,核对"余额回到调用前"
|
|
|
ldb, err := cfg.OpenWriteLegacy()
|
|
ldb, err := cfg.OpenWriteLegacy()
|
|
|
if err == nil {
|
|
if err == nil {
|
|
|
- var bal, spend, inc, exp, spec string
|
|
|
|
|
|
|
+ var cur accountNS
|
|
|
err := ldb.Raw(`
|
|
err := ldb.Raw(`
|
|
|
SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
CAST(ROUND(CAST(spend AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
CAST(ROUND(CAST(total_income AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
CAST(ROUND(CAST(total_expenditure AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
- CAST(ROUND(CAST(IFNULL(special_expenses,0) AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
|
|
|
|
|
- FROM t_user_expense WHERE userid = ?`, c.LegacyUserID).Row().Scan(&bal, &spend, &inc, &exp, &spec)
|
|
|
|
|
|
|
+ CAST(ROUND(CAST(special_expenses AS DECIMAL(20,3)),2) AS DECIMAL(12,2))
|
|
|
|
|
+ FROM t_user_expense WHERE userid = ?`, c.LegacyUserID).
|
|
|
|
|
+ Row().Scan(&cur.Balance, &cur.Spend, &cur.Income, &cur.Expenditure, &cur.Special)
|
|
|
if err == nil {
|
|
if err == nil {
|
|
|
- b, _ := centsOf(bal)
|
|
|
|
|
- s, _ := centsOf(spend)
|
|
|
|
|
- i, _ := centsOf(inc)
|
|
|
|
|
- e, _ := centsOf(exp)
|
|
|
|
|
- pc, _ := centsOf(spec)
|
|
|
|
|
- // special_expenses 也进"是否偏离"的比较:清零/坏账会动它,漏比就漏残留
|
|
|
|
|
- rep.LegacyGlobalExist = Account{Balance: b, Spend: s, Income: i, Expenditure: e, Special: pc} != natural
|
|
|
|
|
|
|
+ // 五列全比(含 special_expenses 与 **NULL 状态**):
|
|
|
|
|
+ // 清零/坏账会动 special,漏比就漏残留;而 NULL 与 0 不等价,漏比会漏掉"写到 NULL"。
|
|
|
|
|
+ rep.LegacyGlobalExist = cur.account() != natural
|
|
|
}
|
|
}
|
|
|
if c.RequestID != "" {
|
|
if c.RequestID != "" {
|
|
|
// 有请求号的 case:按 logid 精确找本 case 的残留
|
|
// 有请求号的 case:按 logid 精确找本 case 的残留
|
|
@@ -852,19 +866,60 @@ SELECT CAST(ROUND(CAST(balance AS DECIMAL(20,3)),2) AS DECIMAL(12,2)),
|
|
|
|
|
|
|
|
// ---------- 小工具 ----------
|
|
// ---------- 小工具 ----------
|
|
|
|
|
|
|
|
|
|
+// sub 计算 Δ = a − b,并**传播 NULL**:任一侧是 NULL,结果就是 NULL。
|
|
|
|
|
+//
|
|
|
|
|
+// 为什么不能简单相减:NULL 在 SQL 里不是 0 —— `NULL - 30` 是 NULL,`0 - 30` 是 −30。
|
|
|
|
|
+// 如果在这里把 NULL 当 0 算,C1-11 那种"建行后余额是 NULL、扣不动"的场景会被算成
|
|
|
|
|
+// "Δ = 0.00",在报告里和"余额本来就是 0、扣了 0"完全一样 —— 一次空过断言。
|
|
|
|
|
+//
|
|
|
|
|
+// 每个字段都要显式处理:漏掉一列(比如 Special)会让该列 Δ 恒为 0,坏账 case 会假红。
|
|
|
func sub(a, b Account) Account {
|
|
func sub(a, b Account) Account {
|
|
|
- return Account{
|
|
|
|
|
- Balance: a.Balance - b.Balance,
|
|
|
|
|
- Spend: a.Spend - b.Spend,
|
|
|
|
|
- Income: a.Income - b.Income,
|
|
|
|
|
- Expenditure: a.Expenditure - b.Expenditure,
|
|
|
|
|
- Special: a.Special - b.Special, // 漏掉这一行会让 Δspecial 恒为 0(坏账 case 假红)
|
|
|
|
|
|
|
+ d := Account{}
|
|
|
|
|
+ d.Balance, d.Nulls.Balance = subOne(a.Balance, a.Nulls.Balance, b.Balance, b.Nulls.Balance)
|
|
|
|
|
+ d.Spend, d.Nulls.Spend = subOne(a.Spend, a.Nulls.Spend, b.Spend, b.Nulls.Spend)
|
|
|
|
|
+ d.Income, d.Nulls.Income = subOne(a.Income, a.Nulls.Income, b.Income, b.Nulls.Income)
|
|
|
|
|
+ d.Expenditure, d.Nulls.Expenditure = subOne(a.Expenditure, a.Nulls.Expenditure, b.Expenditure, b.Nulls.Expenditure)
|
|
|
|
|
+ d.Special, d.Nulls.Special = subOne(a.Special, a.Nulls.Special, b.Special, b.Nulls.Special)
|
|
|
|
|
+ return d
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// subOne 是单列减法:任一侧 NULL → 结果 NULL(值取 0,由 null 标记说明它无意义)。
|
|
|
|
|
+func subOne(av int64, an bool, bv int64, bn bool) (int64, bool) {
|
|
|
|
|
+ if an || bn {
|
|
|
|
|
+ return 0, true
|
|
|
|
|
+ }
|
|
|
|
|
+ return av - bv, false
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// isZero 判断"三态金额恰好是数值 0(且不是 NULL)"。
|
|
|
|
|
+//
|
|
|
|
|
+// 为什么不能写 `v == 0`:NULL 的值也是 0,直接比 int64 会把"没算出来"当成"确实是 0"。
|
|
|
|
|
+func isZero(v int64, isNull bool) bool { return !isNull && v == 0 }
|
|
|
|
|
+
|
|
|
|
|
+// sameMoney 判断"两个三态金额相等":NULL 只等于 NULL,0 只等于 0。
|
|
|
|
|
+//
|
|
|
|
|
+// 直接写 `a == b` 也可以(int64 相等),但那样会漏掉 null 标记 ——
|
|
|
|
|
+// 于是 "NULL" 与 "0.00" 会被判成相等。所有金额比较都走这里。
|
|
|
|
|
+func sameMoney(av int64, an bool, bv int64, bn bool) bool {
|
|
|
|
|
+ if an || bn {
|
|
|
|
|
+ return an == bn
|
|
|
|
|
+ }
|
|
|
|
|
+ return av == bv
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// nullSuffix 在报告里把 NULL 列名附在账户后面(无 NULL 时不加任何东西)。
|
|
|
|
|
+func nullSuffix(n AccountNulls) string {
|
|
|
|
|
+ if !n.Any() {
|
|
|
|
|
+ return ""
|
|
|
}
|
|
}
|
|
|
|
|
+ return " NULL:" + n.String()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func fmtAcc(a Account) string {
|
|
func fmtAcc(a Account) string {
|
|
|
- return fmt.Sprintf("{bal=%s spend=%s income=%s exp=%s}",
|
|
|
|
|
- yuan(a.Balance), yuan(a.Spend), yuan(a.Income), yuan(a.Expenditure))
|
|
|
|
|
|
|
+ return fmt.Sprintf("{bal=%s spend=%s income=%s exp=%s%s}",
|
|
|
|
|
+ moneyStr(a.Balance, a.Nulls.Balance), moneyStr(a.Spend, a.Nulls.Spend),
|
|
|
|
|
+ moneyStr(a.Income, a.Nulls.Income), moneyStr(a.Expenditure, a.Nulls.Expenditure),
|
|
|
|
|
+ nullSuffix(a.Nulls))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func abs64(v int64) int64 {
|
|
func abs64(v int64) int64 {
|