Просмотр исходного кода

docs(ac15): A21 结论改为结构性结论 + 真实用户只读测量(RS-4 撤销)

§22 的 A21 条目从"未覆盖(没做)"改成"结构性不可表达(做不到)",并补一次
**真实数据只读测量**,回答 A2 回答不了的问题:迁移丢掉那 1 行到底差多少钱。

- 结构性依据:uk_user_activity(user_id,activity_id) NON_UNIQUE=0;
  写第二条同活动报名报 1062;finance_bk 1 组重复 / yuansiwei_2026 0 组
- 测量(窗口 2020-01-01~2030-01-01,user 32596):
    legacy SP   : previousGiveCost 737.022 / currentGiveCost 170.223 / usedGive 0.000
    去重(合并)  : 1737.022 / 1170.223 / 566.799
    丢弃一行    : 737.022 / 170.223 / 566.799
    ⇒ A21 代价 = 1000.000,只落两个结余列;usedGive 上两处 −1000 相互抵消
- 两条**不能混报**的成因(写进文档,防三个月后误读):
    ① usedGive 的 566.799 不是 A21,是 legacy 的 NULL 传播(inc=0.006, act=NULL
       ⇒ SET usedGive=NULL ⇒ IFNULL=0),target 把 Gift 当 0 直接算
    ② "丢弃一行"会让两个结余列**恰好等于 legacy**,但成因不同(丢的 1000 与
       多算的 1000 相互抵消)⇒ 割接验收不能只比数字,还要核 signed 行数/金额合计
- README 顶部覆盖表不变(仍 3 条 case);RS-4 撤销已在 dbb1f80 记明

门禁:go build ./... | go vet ./internal/academic/... |
go test ./internal/academic/dualrun/stage2/ -count=1 | resultsets 3 PASS / 0 FAIL。
Q 5 часов назад
Родитель
Сommit
76492cf2ad
1 измененных файлов с 56 добавлено и 3 удалено
  1. 56 3
      internal/academic/dualrun/stage2/README.md

+ 56 - 3
internal/academic/dualrun/stage2/README.md

@@ -1665,9 +1665,14 @@ target  MIN(start_time) 折平 ⇒ 60 + 60 = 120.00,两行都核完,不报
   所以「不过滤」这件事**无法用结果集证伪** —— 只能按契约声明它不过滤。
 - **RS-2**:`FlowInclusiveEnd` 与 `FlowExclusiveEnd` 在本种子下相等(没有 `t = to` 的流水),
   「含右端 vs 不含右端」的差别**没有被区分**。要区分需再种一条 `create_time = end` 的流水。
-- **RS-2**:§3.8-A21 的扇出缺陷(同一活动多条 signed 会让 `used` 额重复计入)未覆盖 ——
-  种子每个活动只有 1 条 signed。Go **刻意不复刻** A21,所以补这条会得到**预期红**,
-  应单独做成「登记差异」case,而不是塞进本 case 求绿。
+- **RS-2**:§3.8-A21 的扇出缺陷**不能做成双跑 case,原因是结构性的**(不是"没做"):
+  target 的 `t_courses_activity_signed` 有唯一键 `uk_user_activity(user_id, activity_id)`(`NON_UNIQUE=0`),
+  写第二条同活动报名报 `1062 Duplicate entry '…-900011' for key 't_courses_activity_signed.uk_user_activity'`
+  ⇒「同一活动 k 条报名」在 target **不可表达** ⇒ 扇出在 target 恒不存在 ⇒ 两侧**必然相等**。
+  `finance_bk` 实测只有 **1 组**重复(`user 32596 / activity 1`,两条各 `1000.000`,多出 1 行),
+  `yuansiwei_2026` 为 **0 组**。详见下面「A21 的真实影响:一次只读测量」。
+  **RS-4 撤销**(曾提交 `baf1856` 声明体,已由 `dbb1f80` 整轮 revert),`Divergence` / `legacy-only`
+  模式也一并撤掉 —— 不为一条回答不了真问题的 case 留投机性扩展。
 - **RS-2**:`type=108` 双来源「只归因不裁剪」未覆盖。
 - **RS-3**:`coursePaymentDetailid>1` 的**有意差异**(dump 是「≥2 才短路」⇒ id=1 的规则永不生效;
   Go 是 `>0` ⇒ 会生效)未覆盖 —— 本 case 规则 id=990002。要验必须专门造 id=1。
@@ -1679,3 +1684,51 @@ target  MIN(start_time) 折平 ⇒ 60 + 60 = 120.00,两行都核完,不报
   需软件侧确认 —— 它直接影响 `BETWEEN` 的命中集合。
 - **三支共同**:只覆盖「1 个结果集 / 1 行」形态;多结果集、多行未覆盖
   (这三支 SP 结构上只返回 1 行,所以这是结构性上限,不是遗漏)。
+
+### A21 的真实影响:一次只读测量(替代 RS-4)
+
+**为什么是测量而不是 case**:`uk_user_activity` 让两侧同构种子不可能(见上一条),
+而"合成种子上 legacy 280 / 去重 240"只证明机制存在 —— 这一点 DDL 已经给了更权威的答案。
+真正没有别的来源的,是**真实那 1 行合并/丢弃到底差多少钱**。所以量它。
+
+**方法**(只读,可复现):
+
+1. `spdump sql` 看重复行(`WHERE userid=32596 AND activityid=1`)与各活动 `used_log` 汇总;
+2. 对 `legacy_oracle` 跑一次 `CALL sp_user_expense_get_bill(32596,'','2020-01-01 00:00:00','2030-01-01 00:00:00')`
+   (窗口的选择只为"覆盖全部数据":期初 cutoff 落在 activity 1 最后一条 used 之后、activity 53 开始之前);
+3. 用**去重口径**(同活动只计一次 used,= Go `giveBalanceBefore`)在 SQL 里重算两个结余列;
+4. 两者相减。
+
+**数据**(`finance_bk` 实测):
+
+| 项 | 值 |
+| --- | --- |
+| 重复的两条 signed | id=69 `status=0` `activity_money=1000.000` `requestid=NULL` 2018-07-27 16:45:35;id=70 `status=2` `activity_money=1000.000` `requestid=f1f8fd…` 2018-07-27 17:41:08 |
+| activity 1 的 used_log | 12 行,合计 `−1000.000`(2018-09-14 ~ 2019-07-11) |
+| activity 53 的 used_log | 24 行,合计 `−1329.777`(2019-07-11 ~ 2024-12-03) |
+| 该用户 signed 合计 | activity 1:2 行 / `2000.000`;activity 53:1 行 / `1500.000` |
+
+**结果**(单位:元;窗口 `2020-01-01 ~ 2030-01-01`):
+
+| 列 | legacy SP(扇出) | 去重口径(Go / 合并迁移) | 丢弃一行迁移 | 差(去重 − legacy) |
+| --- | --- | --- | --- | --- |
+| previousGiveCost | 737.022 | 1737.022 | 737.022 | +1000.000 |
+| currentGiveCost | 170.223 | 1170.223 | 170.223 | +1000.000 |
+| currentUsedGiveCost | 0.000 | 566.799 | 566.799 | +566.799 |
+| 其余 6 列 | 3778.693 / 0.006 / 0.000 / 0.000 / −3778.701 / −0.002 | 同左 | 同左 | 0 |
+
+**三条结论(第 2 条最容易被误读)**:
+
+1. **A21 本身的代价 = 1000.000**,只落在两个**结余**列(`previousGiveCost` / `currentGiveCost`),
+   方向是 legacy 少 1000(used 被计入 2 次,每次 −1000)。
+   `currentUsedGiveCost` 上 A21 **不产生差**:`Gift + 期初 − 期末` 里两处 −1000 相互抵消。
+2. **`currentUsedGiveCost` 那 566.799 的差不是 A21**,是 **NULL 传播**:
+   该用户窗口内没有挂工单的报名 ⇒ 光标行的 `SUM(activity.activity_money)` 为 **NULL**
+   (实测 `inc=0.006, act=NULL`)⇒ `SET currentUsedGiveCost = NULL + …` 得 NULL ⇒ 随后
+   `IFNULL` 成 **0.000**;而 target 把 Gift 当 `0` 直接算 ⇒ `0 + 1737.022 − 1170.223 = 566.799`。
+   两者口径不同,**不能拿"差 566.799"去指 A21**。
+3. **迁移若"丢弃一行",两个结余列会恰好等于 legacy(737.022 / 170.223)** ——
+   但成因完全不同:丢掉的 `1000.000` 与多算的 `1000.000` **相互抵消**。
+   ⇒ **割接验收不能只比数字**:数值一致在这里恰恰是"错的理由碰巧撞对了数"。
+   要判"合并还是丢弃",必须同时核**该活动的 signed 行数 / `activity_money` 合计**
+   (合并 ⇒ 2000,丢弃 ⇒ 1000)。处置口径需迁移 owner 定,本文件只给数。