|
@@ -0,0 +1,704 @@
|
|
|
|
|
+package stage2
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "math"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// 本文件是批 C2:`sp_user_expense_custom_refund`(SP9,7 参、**带 schoolid**、自带结果集)的双跑比对。
|
|
|
|
|
+//
|
|
|
|
|
+// # 一、这一支为什么不能"两侧互相比 Δ"
|
|
|
|
|
+//
|
|
|
|
|
+// SP9 正文里有三个 `DECLARE ... DECIMAL DEFAULT 0` 的变量 —— DECIMAL 不带精度参数时是
|
|
|
|
|
+// **DECIMAL(10,0)**,即**整数**。于是 legacy 把金额整数化了**两次**:
|
|
|
|
|
+//
|
|
|
|
|
+// _balance ← t_user_expense_school.balance ← 第一层(0.280 → 0、475.376 → 475、12.500 → 13)
|
|
|
|
|
+// _badDate ← inputMoney - _balance ← **第二层**(1.50 − 1 = 0.5 → 1)
|
|
|
|
|
+// _money ← _balance
|
|
|
|
|
+//
|
|
|
|
|
+// 而 target(`RefundService.CustomRefund`)用**精确十进制约**:
|
|
|
|
|
+//
|
|
|
|
|
+// refunded = min(requested, balance);badDebt = requested - refunded
|
|
|
|
|
+//
|
|
|
|
|
+// ⇒ 只要余额带小数,两侧就是**两个不同的算法**:扣减额、坏账额、流水金额都可能不同,
|
|
|
|
|
+// 甚至**分支都可能不同**(缝区间:`inputMoney ∈ (真实余额, 整数化余额]`,legacy 判
|
|
|
|
|
+// `inputMoney > _balance` 为假走 ELSE、target 走坏账分支)。
|
|
|
|
|
+//
|
|
|
|
|
+// # 二、归类:LEGACY-DEFECT / VARIABLE-ROUNDING
|
|
|
|
|
+//
|
|
|
|
|
+// - 顶层类 = **成因**:`DECLARE 变量声明时没写精度` ⇒ 赋值即按整数四舍五入。
|
|
|
|
|
+// 这是**旧系统自身的缺陷**,在旧库上原样跑就错,与迁移无关;
|
|
|
|
|
+// - 不是 `PRECISION-CONVERGENCE`:那是"迁移把 3 位收敛成 2 位"造成的差异,成因在迁移;
|
|
|
|
|
+// - 不是 `PREDICATE-COLLAPSE`(`PRECISION-CONVERGENCE` 的 subtype):那个专指
|
|
|
|
|
+// "判据的输入被抹平 ⇒ 分支不同",这里的成因是**变量类型**,而且 badDate / 流水金额
|
|
|
|
|
+// 也跟着变,不是单纯的分支塌陷;
|
|
|
|
|
+// - 也不是 `DECIMAL-TRUNCATION`:`CAST(0.5)=1`、`CAST(2.5)=3` 是**四舍五入**不是截断
|
|
|
|
|
+// (截断会得 0 / 2)。命名必须与实测语义一致,否则下一个人按名字做判断会做错。
|
|
|
|
|
+//
|
|
|
|
|
+// # 三、断言形态:per-side + 共同不变量
|
|
|
|
|
+//
|
|
|
|
|
+// "两侧之间不比对" 只对 **Δ 金额**成立;**每一侧自己仍要断言不变量**:
|
|
|
|
|
+//
|
|
|
|
|
+// 不变量 legacy target
|
|
|
|
|
+// 207.money == −inputMoney ✔ ✔
|
|
|
|
|
+// Δtotal_income == Δbalance ✔ ✔
|
|
|
|
|
+// 108.money == round0(inputMoney − round0(balance)) ✔ —
|
|
|
|
|
+// 坏账分支写 108、ELSE 分支不写 ✔ —
|
|
|
|
|
+// refunded + badDebt == inputMoney — ✔
|
|
|
|
|
+// refunded == min(inputMoney, balance) — ✔
|
|
|
|
|
+// balance_after >= 0 — ✔
|
|
|
|
|
+//
|
|
|
|
|
+// ★ 这张表是"**不比对的是两侧之间,但每一侧自己仍要断言**"的落地:
|
|
|
|
|
+// 如果因为"两侧本来就不一样"就把断言整体删掉,那这一支就成了**只打印数值、不做判定**,
|
|
|
|
|
+// 与"空过断言"是同一个后果。前两条两侧都成立,恰好证明**结构性复刻是对的,只是舍入不同**。
|
|
|
|
|
+
|
|
|
|
|
+// C2VerdictClass 是本批的归类标签(报告与台账共用一份文案,避免两处不一致)。
|
|
|
|
|
+const C2VerdictClass = "LEGACY-DEFECT / VARIABLE-ROUNDING"
|
|
|
|
|
+
|
|
|
|
|
+// 一次调用走到的分支(legacy 的 IF 链 / target 的校验顺序)。
|
|
|
|
|
+const (
|
|
|
|
|
+ // refundBranchReject 前置校验拒绝(legacy `ISNULL(_balance) OR _balance<=0`;target `balance<=0`)。
|
|
|
|
|
+ refundBranchReject = "reject"
|
|
|
|
|
+ // refundBranchBadDebt `_badDate>0` ⇒ 扣 `_money`、special_expenses 累加坏账、写 108。
|
|
|
|
|
+ refundBranchBadDebt = "baddebt"
|
|
|
|
|
+ // refundBranchElse 扣**原始 inputMoney**、不写 108。
|
|
|
|
|
+ //
|
|
|
|
|
+ // ⚠ 有两种情形会落到这里,不只是 "inputMoney <= _balance":
|
|
|
|
|
+ // ① `inputMoney <= _balance`;
|
|
|
|
|
+ // ② `inputMoney > _balance` 但 `round0(inputMoney − _balance) == 0`(差额不足 0.5),
|
|
|
|
|
+ // 此时 `_badDate = 0`,`IF(_badDate>0)` 为假 ⇒ **仍然走 ELSE、扣的是 inputMoney**。
|
|
|
|
|
+ // 漏掉 ② 会把期望算成"扣 _balance",实测扣的是 inputMoney,凭空制造一条假红。
|
|
|
|
|
+ refundBranchElse = "else"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// refundExpect 是一侧对「自定义退费」的完整期望(金额单位:分)。
|
|
|
|
|
+type refundExpect struct {
|
|
|
|
|
+ // Rejected 是否被前置校验拒绝(拒绝时下面金额字段全为 0、流水为空)。
|
|
|
|
|
+ Rejected bool
|
|
|
|
|
+ // Branches 是**逐次调用**走到的分支,长度 = 调用次数。
|
|
|
|
|
+ Branches []string
|
|
|
|
|
+ // Money 是实际从 balance 里扣掉的金额之和:legacy 是整数化的 `_money`(ELSE 分支为 inputMoney),
|
|
|
|
|
+ // target 是 `refunded`。
|
|
|
|
|
+ Money int64
|
|
|
|
|
+ // BadDebt 是累加到 special_expenses 的坏账之和:legacy 是整数化的 `_badDate`,target 是精确差额。
|
|
|
|
|
+ BadDebt int64
|
|
|
|
|
+ // Logs 是新增流水(按插入顺序,**同一侧内**有序;两侧之间不比较顺序,见 checkRefundLogs)。
|
|
|
|
|
+ Logs []refundLogExpect
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// refundLogExpect 是一条流水的期望。
|
|
|
|
|
+type refundLogExpect struct {
|
|
|
|
|
+ Type int32
|
|
|
|
|
+ Money int64
|
|
|
|
|
+ Remark string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// round0Cents 把"分"按 **元级四舍五入(半进远离零)** 收敛成整数元,再换回分。
|
|
|
|
|
+//
|
|
|
|
|
+// 这是 `DECIMAL(10,0)` 赋值 / 比较的语义,已在本机实测:
|
|
|
|
|
+//
|
|
|
|
|
+// CAST(0.4)=0 CAST(0.5)=1 CAST(2.5)=3 CAST(-0.5)=-1
|
|
|
|
|
+//
|
|
|
|
|
+// 用**整数运算**实现,不经过 float:`.xx5` 在二进制浮点里不是精确值,
|
|
|
|
|
+// 阶段 1 已经因为 `ROUND(float,2)` 与 `ROUND(CAST(float AS DECIMAL),2)` 不同栽过一次。
|
|
|
|
|
+//
|
|
|
|
|
+// ⚠ 必须是"半进远离零",不是 Go 的 `math.RoundToEven`(半进取偶):
|
|
|
|
|
+// 12.500 在两者下分别是 13 / 12,C2-5 就是这条的唯一哨兵。
|
|
|
|
|
+func round0Cents(cents int64) int64 {
|
|
|
|
|
+ q := cents / 100
|
|
|
|
|
+ r := cents % 100
|
|
|
|
|
+ switch {
|
|
|
|
|
+ case r*2 >= 100:
|
|
|
|
|
+ q++
|
|
|
|
|
+ case r*2 <= -100:
|
|
|
|
|
+ q--
|
|
|
|
|
+ }
|
|
|
|
|
+ return q * 100
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// mathRound0Cents 是 round0Cents 的"用 math.Round 表达同一语义"版本,**只给单测做交叉校验**。
|
|
|
|
|
+//
|
|
|
|
|
+// 生产路径一律用 round0Cents:整数运算无精度损失,且不依赖 float64 的舍入模式。
|
|
|
|
|
+func mathRound0Cents(cents int64) int64 {
|
|
|
|
|
+ return int64(math.Round(float64(cents)/100.0)) * 100
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// refundRemark 生成两侧的备注(dump 逐字)。
|
|
|
|
|
+//
|
|
|
|
|
+// dump:`IF inputRemark='' THEN SET inputRemark='余额退费'; END IF;`
|
|
|
|
|
+// 坏账分支 `_balanceRemark = CONCAT(inputRemark,'<br/>退费的金额大于可退费部分,超出部分为坏账部分。')`
|
|
|
|
|
+//
|
|
|
|
|
+// ⚠ 后缀是 **108 独有**:207 的备注只有 inputRemark 本身。
|
|
|
|
|
+// 把后缀也拼到 207 上会立刻红 —— 这正是这条断言存在的意义。
|
|
|
|
|
+// `<br/>` 原样保留(前端按 HTML 渲染),**不做任何转义或替换**。
|
|
|
|
|
+func refundRemark(inputRemark string) (remark207, remark108 string) {
|
|
|
|
|
+ remark207 = inputRemark
|
|
|
|
|
+ if strings.TrimSpace(inputRemark) == "" {
|
|
|
|
|
+ remark207 = "余额退费"
|
|
|
|
|
+ }
|
|
|
|
|
+ return remark207, remark207 + "<br/>退费的金额大于可退费部分,超出部分为坏账部分。"
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ⚠ 一处**已知但本批不覆盖**的细差(写在这里免得被当成"已验证"):
|
|
|
|
|
+//
|
|
|
|
|
+// dump SP9 :`IF inputRemark='' THEN SET inputRemark='余额退费'` —— 只认**空串**
|
|
|
|
|
+// target :`strings.TrimSpace(in.Remark) == ""` —— 认**全空白**
|
|
|
|
|
+//
|
|
|
|
|
+// 所以 `inputRemark=' '` 时 legacy 会把三个空格原样写进流水、target 会换成"余额退费"。
|
|
|
|
|
+// C2 里没有这条 case:本批 9 条要用真实账户,而"空白备注"不是真实构型。
|
|
|
|
|
+// 若将来要补,它属于 **INTENTIONAL-WIDEN**(target 的判据更宽),不是缺陷,别混进 C2VerdictClass。
|
|
|
|
|
+
|
|
|
|
|
+// legacyRefundExpect 按 **legacy 的两次整数化算法** 算期望。
|
|
|
|
|
+//
|
|
|
|
|
+// 逐次模拟,不能"乘一次":SP9 **没有幂等键**,同一 requestId 跑两次就是扣两次,
|
|
|
|
|
+// 而且第二次的 `_balance` 是第一次扣完之后的余额(收敛点不同,可能跳变)。
|
|
|
|
|
+//
|
|
|
|
|
+// 模拟在 **2 位口径**(harness 的读数口径)上进行:真实列是 3 位,Δ 由
|
|
|
|
|
+// "after − before(两侧都按迁移口径读到 2 位)"自动吸收,不影响期望的正确性。
|
|
|
|
|
+// 前提是选人时已核过 `ROUND(balance,0) == ROUND(ROUND(balance,2),0)`
|
|
|
|
|
+// —— 否则"迁移 3→2 位收敛"与"SP 整数化"两个成因会混在一起,红了也分不清是谁。
|
|
|
|
|
+func legacyRefundExpect(balanceBefore, requested int64, runs int, inputRemark string) refundExpect {
|
|
|
|
|
+ remark207, remark108 := refundRemark(inputRemark)
|
|
|
|
|
+ bal := balanceBefore
|
|
|
|
|
+ exp := refundExpect{}
|
|
|
|
|
+ for i := 0; i < maxInt(1, runs); i++ {
|
|
|
|
|
+ intBal := round0Cents(bal) // _balance ← DECIMAL(10,0):第一层整数化
|
|
|
|
|
+ if intBal <= 0 { // IF(ISNULL(_balance) OR _balance<=0) THEN ... LEAVE
|
|
|
|
|
+ exp.Rejected = true
|
|
|
|
|
+ exp.Branches = append(exp.Branches, refundBranchReject)
|
|
|
|
|
+ return exp
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 不写成 if/else:SP 是**两个独立的 IF**,第二个判的是 `_badDate>0` 而不是第一个的条件。
|
|
|
|
|
+ // 差额不足 0.5 时 `_badDate` 收敛成 0,于是"进了第一个 IF"却"走了 ELSE 分支"。
|
|
|
|
|
+ var badDate, money int64
|
|
|
|
|
+ if requested > intBal { // IF(inputMoney>_balance)
|
|
|
|
|
+ badDate = round0Cents(requested - intBal) // 第二层整数化
|
|
|
|
|
+ money = intBal // _money ← _balance
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if badDate > 0 { // IF(_badDate>0)
|
|
|
|
|
+ exp.Branches = append(exp.Branches, refundBranchBadDebt)
|
|
|
|
|
+ exp.BadDebt += badDate
|
|
|
|
|
+ exp.Money += money
|
|
|
|
|
+ bal -= money
|
|
|
|
|
+ exp.Logs = append(exp.Logs, refundLogExpect{Type: 108, Money: badDate, Remark: remark108})
|
|
|
|
|
+ } else {
|
|
|
|
|
+ exp.Branches = append(exp.Branches, refundBranchElse)
|
|
|
|
|
+ exp.Money += requested // ELSE 分支扣的是**原始 inputMoney**,不是 `_money`
|
|
|
|
|
+ bal -= requested
|
|
|
|
|
+ }
|
|
|
|
|
+ // 207 恒写全额、恒在 108 之后
|
|
|
|
|
+ exp.Logs = append(exp.Logs, refundLogExpect{Type: 207, Money: -requested, Remark: remark207})
|
|
|
|
|
+ }
|
|
|
|
|
+ return exp
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// targetRefundExpect 按 **target 的精确十进制约** 算期望。
|
|
|
|
|
+//
|
|
|
|
|
+// `RefundService.CustomRefund` ④:
|
|
|
|
|
+//
|
|
|
|
|
+// refundable = max(balance, 0);refunded = min(requested, refundable);badDebt = requested − refunded
|
|
|
|
|
+//
|
|
|
|
|
+// target **有幂等键**:同一 requestId 跑 N 次只落账 1 次(第 2 次起 `repeated=true`、零写入),
|
|
|
|
|
+// 所以这里不乘 runs —— 这正是与 legacy 最本质的一处差异。
|
|
|
|
|
+func targetRefundExpect(balanceBefore, requested int64, inputRemark string) refundExpect {
|
|
|
|
|
+ remark207, remark108 := refundRemark(inputRemark)
|
|
|
|
|
+ if balanceBefore <= 0 {
|
|
|
|
|
+ return refundExpect{Rejected: true, Branches: []string{refundBranchReject}}
|
|
|
|
|
+ }
|
|
|
|
|
+ refundable := balanceBefore
|
|
|
|
|
+ refunded := requested
|
|
|
|
|
+ if refunded > refundable {
|
|
|
|
|
+ refunded = refundable
|
|
|
|
|
+ }
|
|
|
|
|
+ badDebt := requested - refunded
|
|
|
|
|
+ exp := refundExpect{Money: refunded, Branches: []string{refundBranchElse}}
|
|
|
|
|
+ if badDebt > 0 {
|
|
|
|
|
+ exp.Branches = []string{refundBranchBadDebt}
|
|
|
|
|
+ exp.BadDebt = badDebt
|
|
|
|
|
+ exp.Logs = append(exp.Logs, refundLogExpect{Type: 108, Money: badDebt, Remark: remark108})
|
|
|
|
|
+ }
|
|
|
|
|
+ exp.Logs = append(exp.Logs, refundLogExpect{Type: 207, Money: -requested, Remark: remark207})
|
|
|
|
|
+ return exp
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// refundExpectDiff 打印一份期望(报告 Note 用)。
|
|
|
|
|
+func refundExpectBrief(exp refundExpect) string {
|
|
|
|
|
+ if exp.Rejected {
|
|
|
|
|
+ return "拒绝(零写入、0 条流水)"
|
|
|
|
|
+ }
|
|
|
|
|
+ branches := strings.Join(exp.Branches, "→")
|
|
|
|
|
+ if branches == "" {
|
|
|
|
|
+ branches = "-"
|
|
|
|
|
+ }
|
|
|
|
|
+ return fmt.Sprintf("扣 %s、坏账 %s、%d 条流水(分支 %s)",
|
|
|
|
|
+ yuan(exp.Money), yuan(exp.BadDebt), len(exp.Logs), branches)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// fmtRefundLogs 把流水渲染成 "108 +7.00 / 207 -20.00"(无流水时 "0 条")。
|
|
|
|
|
+func fmtRefundLogs(logs []LogRow) string {
|
|
|
|
|
+ if len(logs) == 0 {
|
|
|
|
|
+ return "0 条"
|
|
|
|
|
+ }
|
|
|
|
|
+ parts := make([]string, 0, len(logs))
|
|
|
|
|
+ for _, l := range logs {
|
|
|
|
|
+ parts = append(parts, fmt.Sprintf("%d %s", l.Type, yuan(l.Money)))
|
|
|
|
|
+ }
|
|
|
|
|
+ return strings.Join(parts, " / ")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// fmtRefundLogsExpect 同上,但渲染期望(期望是值类型切片)。
|
|
|
|
|
+func fmtRefundLogsExpect(logs []refundLogExpect) string {
|
|
|
|
|
+ if len(logs) == 0 {
|
|
|
|
|
+ return "0 条"
|
|
|
|
|
+ }
|
|
|
|
|
+ parts := make([]string, 0, len(logs))
|
|
|
|
|
+ for _, l := range logs {
|
|
|
|
|
+ parts = append(parts, fmt.Sprintf("%d %s", l.Type, yuan(l.Money)))
|
|
|
|
|
+ }
|
|
|
|
|
+ return strings.Join(parts, " / ")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// countLogType 数某一 type 的流水条数。
|
|
|
|
|
+func countLogType(logs []LogRow, typ int32) int {
|
|
|
|
|
+ n := 0
|
|
|
|
|
+ for _, l := range logs {
|
|
|
|
|
+ if l.Type == typ {
|
|
|
|
|
+ n++
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return n
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// sumLogType 求某一 type 的流水金额之和(分)。
|
|
|
|
|
+func sumLogType(logs []LogRow, typ int32) int64 {
|
|
|
|
|
+ var s int64
|
|
|
|
|
+ for _, l := range logs {
|
|
|
|
|
+ if l.Type == typ {
|
|
|
|
|
+ s += l.Money
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return s
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// checkRefundLogs 校验**某一侧**的流水与它自己的期望。
|
|
|
|
|
+//
|
|
|
|
|
+// 判据:条数相等、逐条 type 相等、逐条 money 相等、逐条 remark 逐字相等。
|
|
|
|
|
+//
|
|
|
|
|
+// ⚠ 只比"同一侧内"的集合,不跨侧比顺序 —— 两侧的顺序是各自 SP / Service 的实现细节,
|
|
|
|
|
+// 跨侧比顺序会在"同一侧换了实现"时制造无关的红。跨侧比**内容**才有意义,
|
|
|
|
|
+// 而内容差异正是本批要记录的结论(见 C2VerdictClass)。
|
|
|
|
|
+func checkRefundLogs(logs []LogRow, exp []refundLogExpect) (bool, string) {
|
|
|
|
|
+ if len(logs) != len(exp) {
|
|
|
|
|
+ return false, fmt.Sprintf("条数实测 %d、期望 %d(实测:%s | 期望:%s)",
|
|
|
|
|
+ len(logs), len(exp), fmtRefundLogs(logs), fmtRefundLogsExpect(exp))
|
|
|
|
|
+ }
|
|
|
|
|
+ for i := range logs {
|
|
|
|
|
+ if logs[i].Type != exp[i].Type {
|
|
|
|
|
+ return false, fmt.Sprintf("#%d type 实测 %d、期望 %d", i, logs[i].Type, exp[i].Type)
|
|
|
|
|
+ }
|
|
|
|
|
+ if logs[i].Money != exp[i].Money {
|
|
|
|
|
+ return false, fmt.Sprintf("#%d(type=%d) money 实测 %s、期望 %s",
|
|
|
|
|
+ i, logs[i].Type, yuan(logs[i].Money), yuan(exp[i].Money))
|
|
|
|
|
+ }
|
|
|
|
|
+ if logs[i].Remark != exp[i].Remark {
|
|
|
|
|
+ return false, fmt.Sprintf("#%d(type=%d) remark 实测 %q、期望 %q(逐字比对,含 <br/> 拼接)",
|
|
|
|
|
+ i, logs[i].Type, logs[i].Remark, exp[i].Remark)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return true, fmt.Sprintf("%d 条逐条相符(type / money / remark)", len(exp))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// legacySPReturn 取 legacy 侧 SP 自带的那个结果集(没有则空 map)。
|
|
|
|
|
+func legacySPReturn(lg *SideResult) map[string]string {
|
|
|
|
|
+ if len(lg.Calls) == 0 || lg.Calls[0].Returned == nil {
|
|
|
|
|
+ return map[string]string{}
|
|
|
|
|
+ }
|
|
|
|
|
+ return lg.Calls[0].Returned
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// targetRejected 判断 target 侧首次调用是否被拒(AppError)。
|
|
|
|
|
+func targetRejected(tg *SideResult) bool {
|
|
|
|
|
+ return len(tg.Calls) > 0 && !tg.Calls[0].Success
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// minI64 取两者较小值。
|
|
|
|
|
+func minI64(a, b int64) int64 {
|
|
|
|
|
+ if a < b {
|
|
|
|
|
+ return a
|
|
|
|
|
+ }
|
|
|
|
|
+ return b
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// customRefundCompare 是 SP9 的比对:**每一侧各自对着自己的期望**比,另加两侧共同不变量。
|
|
|
|
|
+//
|
|
|
|
|
+// 报告形态(每行都是"两侧实际值并排",不把标签塞进数值列):
|
|
|
|
|
+//
|
|
|
|
|
+// Δbalance legacy=-13.00 target=-12.50 ✘/✔ 期望 legacy=-13.00 | target=-12.50
|
|
|
|
|
+// 余额(调用后) legacy=-0.50 target=0.00 — 两侧算法不同 ⇒ 两侧之间不比
|
|
|
|
|
+func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
|
|
|
|
|
+ var diffs []Diff
|
|
|
|
|
+ var asserts []Assertion
|
|
|
|
|
+
|
|
|
|
|
+ lgB := lg.Before.Campus.Balance
|
|
|
|
|
+ tgB := tg.Before.Campus.Balance
|
|
|
|
|
+ lgExp := legacyRefundExpect(lgB, c.AmountCents, c.RunTimes, c.Remark)
|
|
|
|
|
+ tgExp := targetRefundExpect(tgB, c.AmountCents, c.Remark)
|
|
|
|
|
+
|
|
|
|
|
+ dL := sub(lg.After.Campus, lg.Before.Campus)
|
|
|
|
|
+ dT := sub(tg.After.Campus, tg.Before.Campus)
|
|
|
|
|
+ // 全局表的 Δ 也必须一起看:SP9 与 Service 都是**双表同额** UPDATE,
|
|
|
|
|
+ // 只比校区会让"只写了校区表"的实现静默通过。
|
|
|
|
|
+ dGL := sub(lg.After.Global, lg.Before.Global)
|
|
|
|
|
+ dGT := sub(tg.After.Global, tg.Before.Global)
|
|
|
|
|
+
|
|
|
|
|
+ lgRet := legacySPReturn(lg)
|
|
|
|
|
+ lgRejectedActual := lgRet["success"] == "0"
|
|
|
|
|
+ tgRejectedActual := targetRejected(tg)
|
|
|
|
|
+
|
|
|
|
|
+ // ① 前提:两侧 2 位基线逐分相等。不相等时下面的"分歧"可能来自基线没对齐,而不是算法差异。
|
|
|
|
|
+ diffs = append(diffs, Diff{
|
|
|
|
|
+ Item: "两侧 2 位基线(校区余额)",
|
|
|
|
|
+ Legacy: moneyStr(lgB, lg.Before.Campus.Nulls.Balance),
|
|
|
|
|
+ Target: moneyStr(tgB, tg.Before.Campus.Nulls.Balance),
|
|
|
|
|
+ Equal: sameMoney(lgB, lg.Before.Campus.Nulls.Balance, tgB, tg.Before.Campus.Nulls.Balance),
|
|
|
|
|
+ Note: "前提:2 位口径相同 ⇒ 下面的分歧只可能来自两侧算法(legacy 的两次整数化)",
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // ② 三个 Δ:两侧**实际值并排**,是否通过按"各侧是否符合自己的期望"算。
|
|
|
|
|
+ // 期望写进 Note —— 绝不能写进数值列(那会让"标签"和"实测值"长一样,C1-5b 就踩过)。
|
|
|
|
|
+ lgMoneyOK := !dL.Nulls.Balance && dL.Balance == -lgExp.Money
|
|
|
|
|
+ tgMoneyOK := !dT.Nulls.Balance && dT.Balance == -tgExp.Money
|
|
|
|
|
+ lgIncomeOK := sameMoney(dL.Income, dL.Nulls.Income, dL.Balance, dL.Nulls.Balance)
|
|
|
|
|
+ tgIncomeOK := sameMoney(dT.Income, dT.Nulls.Income, dT.Balance, dT.Nulls.Balance)
|
|
|
|
|
+ lgSpecialOK := !dL.Nulls.Special && dL.Special == lgExp.BadDebt
|
|
|
|
|
+ tgSpecialOK := !dT.Nulls.Special && dT.Special == tgExp.BadDebt
|
|
|
|
|
+
|
|
|
|
|
+ diffs = append(diffs, Diff{
|
|
|
|
|
+ Item: "Δbalance(校区)",
|
|
|
|
|
+ Legacy: moneyStr(dL.Balance, dL.Nulls.Balance),
|
|
|
|
|
+ Target: moneyStr(dT.Balance, dT.Nulls.Balance),
|
|
|
|
|
+ Equal: lgMoneyOK && tgMoneyOK,
|
|
|
|
|
+ Note: fmt.Sprintf("期望 legacy=%s(扣 _money/ELSE 时的 inputMoney)| target=%s(refunded)",
|
|
|
|
|
+ yuan(-lgExp.Money), yuan(-tgExp.Money)),
|
|
|
|
|
+ })
|
|
|
|
|
+ diffs = append(diffs, Diff{
|
|
|
|
|
+ Item: "Δbalance(全局)",
|
|
|
|
|
+ Legacy: moneyStr(dGL.Balance, dGL.Nulls.Balance),
|
|
|
|
|
+ Target: moneyStr(dGT.Balance, dGT.Nulls.Balance),
|
|
|
|
|
+ Equal: sameMoney(dGL.Balance, dGL.Nulls.Balance, dL.Balance, dL.Nulls.Balance) &&
|
|
|
|
|
+ sameMoney(dGT.Balance, dGT.Nulls.Balance, dT.Balance, dT.Nulls.Balance),
|
|
|
|
|
+ Note: "双表同额:全局 Δ 必须与校区 Δ 相同(只写一张表 → 红)",
|
|
|
|
|
+ })
|
|
|
|
|
+ diffs = append(diffs, Diff{
|
|
|
|
|
+ Item: "Δtotal_income(校区)",
|
|
|
|
|
+ Legacy: moneyStr(dL.Income, dL.Nulls.Income),
|
|
|
|
|
+ Target: moneyStr(dT.Income, dT.Nulls.Income),
|
|
|
|
|
+ Equal: lgIncomeOK && tgIncomeOK,
|
|
|
|
|
+ Note: "共同不变量:Δtotal_income == Δbalance(两侧都必须成立)",
|
|
|
|
|
+ })
|
|
|
|
|
+ diffs = append(diffs, Diff{
|
|
|
|
|
+ Item: "Δspecial_expenses(校区)",
|
|
|
|
|
+ Legacy: moneyStr(dL.Special, dL.Nulls.Special),
|
|
|
|
|
+ Target: moneyStr(dT.Special, dT.Nulls.Special),
|
|
|
|
|
+ Equal: lgSpecialOK && tgSpecialOK,
|
|
|
|
|
+ Note: fmt.Sprintf("期望 legacy=%s(_badDate,两层整数化)| target=%s(精确坏账)",
|
|
|
|
|
+ yuan(lgExp.BadDebt), yuan(tgExp.BadDebt)),
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // ③ 结果余额:**两侧之间不比**(算法本来就不同),但值要打出来给人看。
|
|
|
|
|
+ // 用 Skipped ⇒ 报告里打 "—" 而不是 "✔",避免"没比"伪装成"比过且相等"。
|
|
|
|
|
+ diffs = append(diffs, Diff{
|
|
|
|
|
+ Item: "余额(调用后,校区)",
|
|
|
|
|
+ Legacy: moneyStr(lg.After.Campus.Balance, lg.After.Campus.Nulls.Balance),
|
|
|
|
|
+ Target: moneyStr(tg.After.Campus.Balance, tg.After.Campus.Nulls.Balance),
|
|
|
|
|
+ Equal: true, Skipped: true,
|
|
|
|
|
+ Note: "两侧算法不同 ⇒ 两侧之间**不比对**;各自的结果已在上面的 Δ 行按其自身期望断言",
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // ④ 流水:条数 / 明细 / 备注,各自对自己的期望。
|
|
|
|
|
+ lgLogOK, lgLogDetail := checkRefundLogs(lg.After.Logs, lgExp.Logs)
|
|
|
|
|
+ tgLogOK, tgLogDetail := checkRefundLogs(tg.After.Logs, tgExp.Logs)
|
|
|
|
|
+ diffs = append(diffs, Diff{
|
|
|
|
|
+ Item: "流水条数",
|
|
|
|
|
+ Legacy: fmt.Sprint(len(lg.After.Logs)),
|
|
|
|
|
+ Target: fmt.Sprint(len(tg.After.Logs)),
|
|
|
|
|
+ Equal: len(lg.After.Logs) == len(lgExp.Logs) && len(tg.After.Logs) == len(tgExp.Logs),
|
|
|
|
|
+ Note: fmt.Sprintf("期望 legacy=%d(108+207,%d 次调用)| target=%d(幂等,只落账 1 次)",
|
|
|
|
|
+ len(lgExp.Logs), maxInt(1, c.RunTimes), len(tgExp.Logs)),
|
|
|
|
|
+ })
|
|
|
|
|
+ diffs = append(diffs, Diff{
|
|
|
|
|
+ Item: "流水明细(type + money)",
|
|
|
|
|
+ Legacy: fmtRefundLogs(lg.After.Logs),
|
|
|
|
|
+ Target: fmtRefundLogs(tg.After.Logs),
|
|
|
|
|
+ Equal: lgLogOK && tgLogOK,
|
|
|
|
|
+ Note: fmt.Sprintf("期望 legacy:%s | target:%s;逐条校验:legacy %s | target %s",
|
|
|
|
|
+ fmtRefundLogsExpect(lgExp.Logs), fmtRefundLogsExpect(tgExp.Logs), lgLogDetail, tgLogDetail),
|
|
|
|
|
+ })
|
|
|
|
|
+ diffs = append(diffs, Diff{
|
|
|
|
|
+ Item: "流水备注(逐字,108 含 <br/> 拼接)",
|
|
|
|
|
+ Legacy: refundRemarkBrief(lg.After.Logs, 108),
|
|
|
|
|
+ Target: refundRemarkBrief(tg.After.Logs, 108),
|
|
|
|
|
+ Equal: lgLogOK && tgLogOK,
|
|
|
|
|
+ Note: refundRemarkNote(c.Remark),
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // ⑤ 活动失效:本批**没有种活动**,两侧快照都是空的 ⇒ 打 "—"。
|
|
|
|
|
+ // 如果不打 "—" 而是打 "✔",那就是一次标准的空过断言(0 == 0)。
|
|
|
|
|
+ diffs = append(diffs, Diff{
|
|
|
|
|
+ Item: "活动失效(t_courses_activity_signed)",
|
|
|
|
|
+ Legacy: fmtSigned(lg.Before.Signed) + " → " + fmtSigned(lg.After.Signed),
|
|
|
|
|
+ Target: fmtSigned(tg.Before.Signed) + " → " + fmtSigned(tg.After.Signed),
|
|
|
|
|
+ Equal: true, Skipped: true,
|
|
|
|
|
+ Note: "本批不种活动 ⇒ 快照为空,0 vs 0 **不构成验证**;SP9 的这段与 cleared_balance 逐字相同,已在 C1 覆盖",
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 断言 ①:各侧前置校验的结论符合**各自**的期望 ----
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "两侧前置校验:各侧是否符合**自己**的期望(legacy `ISNULL(_balance) OR _balance<=0` / target `balance<=0`)",
|
|
|
|
|
+ Pass: lgRejectedActual == lgExp.Rejected && tgRejectedActual == tgExp.Rejected,
|
|
|
|
|
+ Detail: fmt.Sprintf("legacy 实测拒绝=%v(期望 %v,整数化余额=%s,success=%q msg=%q)| target 实测拒绝=%v(期望 %v,真实余额=%s,码=%d err=%q)",
|
|
|
|
|
+ lgRejectedActual, lgExp.Rejected, yuan(round0Cents(lgB)), lgRet["success"], lgRet["msg"],
|
|
|
|
|
+ tgRejectedActual, tgExp.Rejected, yuan(tgB), codeOf(tg.Calls), errOf(tg.Calls)),
|
|
|
|
|
+ })
|
|
|
|
|
+ if c.ExpectTargetCode != 0 {
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: fmt.Sprintf("target 业务码 == %d(码比文案强:同一文案可能被多处复用)", c.ExpectTargetCode),
|
|
|
|
|
+ Pass: targetRejected(tg) && codeOf(tg.Calls) == c.ExpectTargetCode,
|
|
|
|
|
+ Detail: fmt.Sprintf("实测码=%d err=%q", codeOf(tg.Calls), errOf(tg.Calls)),
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 断言 ②:legacy 拒绝时的契约(success=0 + msg + 零写入) ----
|
|
|
|
|
+ lgNoWrite := lg.Before.Campus == lg.After.Campus && lg.Before.Global == lg.After.Global &&
|
|
|
|
|
+ len(lg.After.Logs) == 0
|
|
|
|
|
+ tgNoWrite := tg.Before.Campus == tg.After.Campus && tg.Before.Global == tg.After.Global &&
|
|
|
|
|
+ len(tg.After.Logs) == 0
|
|
|
|
|
+ if lgExp.Rejected {
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "legacy 拒绝契约:`success=0` + `msg='余额不能≤0'` + **零写入 / 0 条流水**(不抛错)",
|
|
|
|
|
+ Pass: lgRejectedActual && lgRet["msg"] == "余额不能≤0" && lgNoWrite,
|
|
|
|
|
+ Detail: fmt.Sprintf("success=%q msg=%q 零写入=%v 流水=%d 条",
|
|
|
|
|
+ lgRet["success"], lgRet["msg"], lgNoWrite, len(lg.After.Logs)),
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "【不适用】legacy 本条未被拒绝 ⇒ 拒绝契约无从验证",
|
|
|
|
|
+ Pass: true, Skipped: true,
|
|
|
|
|
+ Detail: "整数化余额=" + yuan(round0Cents(lgB)) + " > 0",
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ if tgExp.Rejected {
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "target 拒绝契约:显式报错(3102)+ **零写入 / 0 条流水**",
|
|
|
|
|
+ Pass: tgRejectedActual && tgNoWrite,
|
|
|
|
|
+ Detail: fmt.Sprintf("被拒=%v 码=%d 零写入=%v 流水=%d 条", tgRejectedActual, codeOf(tg.Calls), tgNoWrite, len(tg.After.Logs)),
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 断言 ③:共同不变量(两侧都成立)----
|
|
|
|
|
+ // 这两条是本批最重要的"没删干净"的部分:它们**不比较两侧之间**,而是各自对着
|
|
|
|
|
+ // 一个与实现无关的恒等式。如果因为"两侧本来就不同"把断言整体删掉,这一支就只剩打印。
|
|
|
|
|
+ sharedSkip := lgExp.Rejected || tgExp.Rejected
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "共同不变量:`207.money == −inputMoney`(两侧都必须成立)",
|
|
|
|
|
+ Pass: !sharedSkip && sameLog207(lg.After.Logs, c.AmountCents) && sameLog207(tg.After.Logs, c.AmountCents),
|
|
|
|
|
+ Skipped: sharedSkip,
|
|
|
|
|
+ Detail: fmt.Sprintf("legacy 207 合计=%s | target 207 合计=%s(期望各 %s)",
|
|
|
|
|
+ yuan(sumLogType(lg.After.Logs, 207)), yuan(sumLogType(tg.After.Logs, 207)), yuan(-c.AmountCents)),
|
|
|
|
|
+ })
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "共同不变量:`Δtotal_income == Δbalance`(两侧都必须成立 —— 证明结构性复刻对了,只是舍入不同)",
|
|
|
|
|
+ Pass: !sharedSkip && lgIncomeOK && tgIncomeOK,
|
|
|
|
|
+ Skipped: sharedSkip,
|
|
|
|
|
+ Detail: fmt.Sprintf("legacy Δincome=%s Δbal=%s | target Δincome=%s Δbal=%s",
|
|
|
|
|
+ moneyStr(dL.Income, dL.Nulls.Income), moneyStr(dL.Balance, dL.Nulls.Balance),
|
|
|
|
|
+ moneyStr(dT.Income, dT.Nulls.Income), moneyStr(dT.Balance, dT.Nulls.Balance)),
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 断言 ④:legacy 专属不变量 ----
|
|
|
|
|
+ // `108.money == round0(inputMoney − round0(balance_before))`:把"两层整数化"这个成因
|
|
|
|
|
+ // 直接写成一条等式。如果哪天有人把 round0Cents 改成 RoundToEven,C2-5 会红在这条上。
|
|
|
|
|
+ lgBadDebtRuns := 0
|
|
|
|
|
+ for _, b := range lgExp.Branches {
|
|
|
|
|
+ if b == refundBranchBadDebt {
|
|
|
|
|
+ lgBadDebtRuns++
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ want108Each := round0Cents(c.AmountCents - round0Cents(lgB))
|
|
|
|
|
+ want108Total := int64(lgBadDebtRuns) * want108Each
|
|
|
|
|
+ lg108OK := lgBadDebtRuns > 0 &&
|
|
|
|
|
+ countLogType(lg.After.Logs, 108) == lgBadDebtRuns &&
|
|
|
|
|
+ sumLogType(lg.After.Logs, 108) == want108Total
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "legacy 专属不变量:`108.money == round0(inputMoney − round0(balance_before))`(第二次整数化只在这里可见)",
|
|
|
|
|
+ Pass: lg108OK,
|
|
|
|
|
+ Skipped: lgBadDebtRuns == 0,
|
|
|
|
|
+ Detail: fmt.Sprintf("108 实测 %d 条 / 合计 %s | 期望 %d 条 / 合计 %s(每条 = round0(%s − round0(%s)) = %s)",
|
|
|
|
|
+ countLogType(lg.After.Logs, 108), yuan(sumLogType(lg.After.Logs, 108)),
|
|
|
|
|
+ lgBadDebtRuns, yuan(want108Total),
|
|
|
|
|
+ yuan(c.AmountCents), yuan(lgB), yuan(want108Each)),
|
|
|
|
|
+ })
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "legacy 专属不变量:**坏账分支写 108、ELSE 分支不写**(108 条数 == 走坏账分支的次数)",
|
|
|
|
|
+ Pass: countLogType(lg.After.Logs, 108) == lgBadDebtRuns,
|
|
|
|
|
+ Detail: fmt.Sprintf("108 实测 %d 条 | 坏账分支 %d 次(分支序列:%s)",
|
|
|
|
|
+ countLogType(lg.After.Logs, 108), lgBadDebtRuns, strings.Join(lgExp.Branches, "→")),
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 断言 ⑤:target 专属不变量 ----
|
|
|
|
|
+ // 用**实测值**而不是 Service 返回值:返回值只能证明"它自己这么报",实测 Δ 才能证明"库真变了"。
|
|
|
|
|
+ tgSplitOK := !tgExp.Rejected && -dT.Balance+dT.Special == c.AmountCents
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "target 专属不变量:`refunded + badDebt == inputMoney`(实测:`−Δbalance + Δspecial == inputMoney`)",
|
|
|
|
|
+ Pass: tgSplitOK,
|
|
|
|
|
+ Skipped: tgExp.Rejected,
|
|
|
|
|
+ Detail: fmt.Sprintf("−Δbal=%s + Δspec=%s = %s | inputMoney=%s",
|
|
|
|
|
+ yuan(-dT.Balance), yuan(dT.Special), yuan(-dT.Balance+dT.Special), yuan(c.AmountCents)),
|
|
|
|
|
+ })
|
|
|
|
|
+ tgRefundedOK := !tgExp.Rejected && -dT.Balance == minI64(c.AmountCents, tgB)
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "target 专属不变量:`refunded == min(inputMoney, balance_before)`(实测:`−Δbalance` 等于两者较小值)",
|
|
|
|
|
+ Pass: tgRefundedOK,
|
|
|
|
|
+ Skipped: tgExp.Rejected,
|
|
|
|
|
+ Detail: fmt.Sprintf("实测 refunded=−Δbal=%s | min(%s, %s)=%s",
|
|
|
|
|
+ yuan(-dT.Balance), yuan(c.AmountCents), yuan(tgB), yuan(minI64(c.AmountCents, tgB))),
|
|
|
|
|
+ })
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "target 专属不变量:`balance_after >= 0`(精确口径下退费**不应该把余额退成负数**)",
|
|
|
|
|
+ Pass: tgExp.Rejected || tg.After.Campus.Balance >= 0,
|
|
|
|
|
+ Skipped: tgExp.Rejected,
|
|
|
|
|
+ Detail: "调用后校区余额=" + moneyStr(tg.After.Campus.Balance, tg.After.Campus.Nulls.Balance),
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 断言 ⑥:幂等(只有 RunTimes>=2 才有意义)----
|
|
|
|
|
+ if c.RunTimes >= 2 {
|
|
|
|
|
+ tgRepeated := len(tg.Calls) >= 2 && tg.Calls[1].Repeated
|
|
|
|
|
+ lgRuns := len(lg.Calls)
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "幂等:target 第 2 次 `repeated=true` 且**只扣 1 次**;legacy SP 无幂等键 ⇒ 真的扣 N 次",
|
|
|
|
|
+ Pass: tgRepeated && lgRuns == maxInt(1, c.RunTimes) &&
|
|
|
|
|
+ -dT.Balance == tgExp.Money && -dL.Balance == lgExp.Money,
|
|
|
|
|
+ Detail: fmt.Sprintf("target repeated=%v 实测扣 %s(期望 %s)| legacy 调用 %d 次 实测扣 %s(期望 %s)",
|
|
|
|
|
+ tgRepeated, yuan(-dT.Balance), yuan(tgExp.Money), lgRuns, yuan(-dL.Balance), yuan(lgExp.Money)),
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 断言 ⑦:判定行(**不是** PASS/FAIL,是结论登记)----
|
|
|
|
|
+ same := lgExp.Rejected == tgExp.Rejected && lgExp.Money == tgExp.Money &&
|
|
|
|
|
+ lgExp.BadDebt == tgExp.BadDebt && len(lgExp.Logs) == len(tgExp.Logs)
|
|
|
|
|
+ if same {
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "判定:**两侧一致**(整数余额 ⇒ legacy 的两次整数化不产生任何差异)",
|
|
|
|
|
+ Pass: true,
|
|
|
|
|
+ Detail: fmt.Sprintf("余额=%s 请求=%s ⇒ 两侧都 %s",
|
|
|
|
|
+ yuan(lgB), yuan(c.AmountCents), refundExpectBrief(lgExp)),
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "判定:**" + C2VerdictClass + "** —— 旧 SP 的 `DECLARE ... DECIMAL DEFAULT 0` 会把金额整数化两次,target 有意不复刻",
|
|
|
|
|
+ Pass: true,
|
|
|
|
|
+ Detail: fmt.Sprintf("余额=%s 请求=%s | legacy(整数化余额 %s):%s | target(精确余额 %s):%s",
|
|
|
|
|
+ yuan(lgB), yuan(c.AmountCents),
|
|
|
|
|
+ yuan(round0Cents(lgB)), refundExpectBrief(lgExp),
|
|
|
|
|
+ yuan(tgB), refundExpectBrief(tgExp)),
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ return diffs, asserts
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// sameLog207 判断"某一侧 207 流水合计恰好是 −inputMoney"。
|
|
|
|
|
+//
|
|
|
|
|
+// 单独拎出来是因为它要**两侧各算一次**:这条不是"两侧互相比",而是各自对一个
|
|
|
|
|
+// 与实现无关的恒等式。写成函数才能在两侧复用同一判据(避免"只给 legacy 断言了"这种
|
|
|
|
|
+// 半截覆盖 —— 那比不断言更容易骗人)。
|
|
|
|
|
+func sameLog207(logs []LogRow, requested int64) bool {
|
|
|
|
|
+ n := countLogType(logs, 207)
|
|
|
|
|
+ if n == 0 {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ return sumLogType(logs, 207) == -requested*int64(n)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// refundRemarkBrief 渲染某一 type 流水的备注(无该 type 时 "—")。
|
|
|
|
|
+func refundRemarkBrief(logs []LogRow, typ int32) string {
|
|
|
|
|
+ var parts []string
|
|
|
|
|
+ for _, l := range logs {
|
|
|
|
|
+ if l.Type == typ {
|
|
|
|
|
+ parts = append(parts, l.Remark)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(parts) == 0 {
|
|
|
|
|
+ return "—"
|
|
|
|
|
+ }
|
|
|
|
|
+ return strings.Join(parts, " | ")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// refundRemarkNote 说明备注的期望来源(逐字,不转义 <br/>)。
|
|
|
|
|
+func refundRemarkNote(inputRemark string) string {
|
|
|
|
|
+ r207, r108 := refundRemark(inputRemark)
|
|
|
|
|
+ return fmt.Sprintf("期望 207=%q(inputRemark 为空时落在 '余额退费')| 108=%q(207 + 固定后缀,**只有 108 拼**)",
|
|
|
|
|
+ r207, r108)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// nullBalanceRejectCompare 是 **C2-7(E 探针)** 的专用比对:legacy 侧校区余额为 NULL。
|
|
|
|
|
+//
|
|
|
|
|
+// 这条 case **非同构**:target 的 `t_user_expense_school.balance` 是 `not null default 0.00`,
|
|
|
|
|
+// **存不下 NULL**。所以两侧喂的是不同数据(legacy NULL / target 0.00),
|
|
|
|
|
+// 结论一致(**都拒绝**),但拒绝的**理由不同**:
|
|
|
|
|
+//
|
|
|
|
|
+// legacy:`SELECT ... INTO _balance` 把 NULL 读进来 ⇒ `ISNULL(_balance)` 短路 ⇒ LEAVE
|
|
|
|
|
+// (⚠ 不是"NULL 落到 ELSE 分支"—— 短路发生在 `OR` 的左边,右边根本不求值)
|
|
|
|
|
+// target:`balance <= 0` ⇒ 3102
|
|
|
|
|
+//
|
|
|
|
|
+// 它证明的是"**NULL 余额这个状态在新库不可表达**" —— 迁移从 schema 层修掉了一个数据缺陷。
|
|
|
|
|
+// ⚠ 它**不进 DefaultCases**:没有同构的第二侧,"两侧都拒绝"这条绿的含金量是 0
|
|
|
|
|
+// (换任何一条余额为 0 的记录都能绿),所以只在探针里登记。
|
|
|
|
|
+func nullBalanceRejectCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
|
|
|
|
|
+ var diffs []Diff
|
|
|
|
|
+ var asserts []Assertion
|
|
|
|
|
+
|
|
|
|
|
+ lgRet := legacySPReturn(lg)
|
|
|
|
|
+ lgNull := lg.Before.Campus.Nulls.Balance
|
|
|
|
|
+ lgRejected := lgRet["success"] == "0"
|
|
|
|
|
+ tgRejected := targetRejected(tg)
|
|
|
|
|
+ lgNoWrite := lg.Before.Campus == lg.After.Campus && len(lg.After.Logs) == 0
|
|
|
|
|
+ tgNoWrite := tg.Before.Campus == tg.After.Campus && len(tg.After.Logs) == 0
|
|
|
|
|
+
|
|
|
|
|
+ diffs = append(diffs,
|
|
|
|
|
+ Diff{
|
|
|
|
|
+ Item: "legacy 校区余额是否为 NULL(该侧前置)",
|
|
|
|
|
+ Legacy: fmt.Sprint(lgNull),
|
|
|
|
|
+ Target: "不可表达",
|
|
|
|
|
+ Equal: lgNull,
|
|
|
|
|
+ Note: "target 列是 `not null default 0.00` ⇒ NULL 存不下;本 case **非同构**,两侧故意喂不同数据",
|
|
|
|
|
+ },
|
|
|
|
|
+ Diff{
|
|
|
|
|
+ Item: "两侧是否都拒绝",
|
|
|
|
|
+ Legacy: fmt.Sprint(lgRejected),
|
|
|
|
|
+ Target: fmt.Sprint(tgRejected),
|
|
|
|
|
+ Equal: lgRejected && tgRejected,
|
|
|
|
|
+ Note: "拒绝**理由**不同(legacy `ISNULL` 短路 / target `balance<=0`),结论一致",
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "legacy:余额为 NULL ⇒ `ISNULL(_balance)` 短路 ⇒ 静默拒绝(`success=0` / `msg='余额不能≤0'`)+ 零写入",
|
|
|
|
|
+ Pass: lgNull && lgRejected && lgRet["msg"] == "余额不能≤0" && lgNoWrite,
|
|
|
|
|
+ Detail: fmt.Sprintf("NULL=%v success=%q msg=%q 零变化=%v 流水=%d 条",
|
|
|
|
|
+ lgNull, lgRet["success"], lgRet["msg"], lgNoWrite, len(lg.After.Logs)),
|
|
|
|
|
+ })
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "target:余额 0.00 ⇒ `balance<=0` ⇒ 显式拒绝(3102)+ 零写入",
|
|
|
|
|
+ Pass: tgRejected && codeOf(tg.Calls) == 3102 && tgNoWrite,
|
|
|
|
|
+ Detail: fmt.Sprintf("被拒=%v 码=%d err=%q 零变化=%v 流水=%d 条", tgRejected, codeOf(tg.Calls), errOf(tg.Calls), tgNoWrite, len(tg.After.Logs)),
|
|
|
|
|
+ })
|
|
|
|
|
+ asserts = append(asserts, Assertion{
|
|
|
|
|
+ Name: "判定:**迁移修掉的缺陷** —— NULL 余额在新库不可表达,两侧殊途同归(都拒绝);本探针**不构成验收**",
|
|
|
|
|
+ Pass: true,
|
|
|
|
|
+ Detail: "legacy 侧的 NULL 是数据缺陷(建行只写 3 列,见 C1-11);target 的 `not null default 0.00` " +
|
|
|
|
|
+ "从 schema 层消除了该状态。非同构 case 只能证明'都拒绝',不能证明'行为一致'",
|
|
|
|
|
+ })
|
|
|
|
|
+ return diffs, asserts
|
|
|
|
|
+}
|