hefuzheng 2 주 전
커밋
6cc1b67c29
100개의 변경된 파일과 13952개의 추가작업 그리고 0개의 파일을 삭제
  1. 33 0
      code/post_clear_check/pyproject.toml
  2. 121 0
      code/post_clear_check/sql/schema.sql
  3. 13 0
      code/post_clear_check/src/post_clear_check.egg-info/PKG-INFO
  4. 57 0
      code/post_clear_check/src/post_clear_check.egg-info/SOURCES.txt
  5. 1 0
      code/post_clear_check/src/post_clear_check.egg-info/dependency_links.txt
  6. 2 0
      code/post_clear_check/src/post_clear_check.egg-info/entry_points.txt
  7. 9 0
      code/post_clear_check/src/post_clear_check.egg-info/requires.txt
  8. 1 0
      code/post_clear_check/src/post_clear_check.egg-info/top_level.txt
  9. 3 0
      code/post_clear_check/src/post_clear_check/__init__.py
  10. 6 0
      code/post_clear_check/src/post_clear_check/__main__.py
  11. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/__init__.cpython-311.pyc
  12. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/__init__.cpython-312.pyc
  13. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/__main__.cpython-312.pyc
  14. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/buy_execution.cpython-312.pyc
  15. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/chain.cpython-312.pyc
  16. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/columns.cpython-311.pyc
  17. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/columns.cpython-312.pyc
  18. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/config.cpython-311.pyc
  19. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/config.cpython-312.pyc
  20. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/feishu.cpython-312.pyc
  21. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/fund_chain.cpython-312.pyc
  22. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/gate.cpython-311.pyc
  23. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/gate.cpython-312.pyc
  24. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/hedge_link.cpython-312.pyc
  25. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/issue_common.cpython-311.pyc
  26. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/issue_common.cpython-312.pyc
  27. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/issue_event.cpython-311.pyc
  28. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/issue_event.cpython-312.pyc
  29. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/issue_otc003.cpython-311.pyc
  30. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/issue_otc003.cpython-312.pyc
  31. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/issue_otc004.cpython-311.pyc
  32. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/issue_otc004.cpython-312.pyc
  33. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/issue_reports.cpython-311.pyc
  34. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/issue_reports.cpython-312.pyc
  35. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/issue_settle.cpython-311.pyc
  36. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/issue_settle.cpython-312.pyc
  37. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/main.cpython-311.pyc
  38. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/main.cpython-312.pyc
  39. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/note_chain.cpython-312.pyc
  40. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/orchestrate.cpython-312.pyc
  41. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/order_cell.cpython-311.pyc
  42. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/order_cell.cpython-312.pyc
  43. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/order_decimal.cpython-311.pyc
  44. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/order_decimal.cpython-312.pyc
  45. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/order_rules.cpython-312.pyc
  46. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/progress.cpython-312.pyc
  47. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/published.cpython-312.pyc
  48. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/reader.cpython-311.pyc
  49. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/reader.cpython-312.pyc
  50. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/sales_asset.cpython-311.pyc
  51. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/sales_asset.cpython-312.pyc
  52. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/sales_cash.cpython-311.pyc
  53. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/sales_cash.cpython-312.pyc
  54. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/sales_common.cpython-311.pyc
  55. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/sales_common.cpython-312.pyc
  56. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/sales_event.cpython-311.pyc
  57. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/sales_event.cpython-312.pyc
  58. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/sales_otc003.cpython-311.pyc
  59. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/sales_otc003.cpython-312.pyc
  60. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/sales_otc004.cpython-311.pyc
  61. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/sales_otc004.cpython-312.pyc
  62. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/sales_reports.cpython-311.pyc
  63. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/sales_reports.cpython-312.pyc
  64. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/sales_settle.cpython-311.pyc
  65. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/sales_settle.cpython-312.pyc
  66. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/sell_execution.cpython-312.pyc
  67. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/session.cpython-311.pyc
  68. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/session.cpython-312.pyc
  69. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/trs_account.cpython-311.pyc
  70. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/trs_account.cpython-312.pyc
  71. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/trs_chain.cpython-312.pyc
  72. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/trs_common.cpython-311.pyc
  73. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/trs_common.cpython-312.pyc
  74. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/trs_flow.cpython-311.pyc
  75. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/trs_flow.cpython-312.pyc
  76. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/trs_reports.cpython-311.pyc
  77. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/trs_reports.cpython-312.pyc
  78. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/trs_trade.cpython-311.pyc
  79. BIN
      code/post_clear_check/src/post_clear_check/__pycache__/trs_trade.cpython-312.pyc
  80. 974 0
      code/post_clear_check/src/post_clear_check/buy_execution.py
  81. 638 0
      code/post_clear_check/src/post_clear_check/chain.py
  82. 1540 0
      code/post_clear_check/src/post_clear_check/columns.py
  83. 256 0
      code/post_clear_check/src/post_clear_check/config.py
  84. 1027 0
      code/post_clear_check/src/post_clear_check/feishu.py
  85. 1006 0
      code/post_clear_check/src/post_clear_check/fund_chain.py
  86. 897 0
      code/post_clear_check/src/post_clear_check/gate.py
  87. 127 0
      code/post_clear_check/src/post_clear_check/hedge_link.py
  88. 558 0
      code/post_clear_check/src/post_clear_check/issue_common.py
  89. 23 0
      code/post_clear_check/src/post_clear_check/issue_event.py
  90. 896 0
      code/post_clear_check/src/post_clear_check/issue_otc003.py
  91. 813 0
      code/post_clear_check/src/post_clear_check/issue_otc004.py
  92. 137 0
      code/post_clear_check/src/post_clear_check/issue_reports.py
  93. 481 0
      code/post_clear_check/src/post_clear_check/issue_settle.py
  94. 73 0
      code/post_clear_check/src/post_clear_check/main.py
  95. 871 0
      code/post_clear_check/src/post_clear_check/note_chain.py
  96. 1333 0
      code/post_clear_check/src/post_clear_check/orchestrate.py
  97. 371 0
      code/post_clear_check/src/post_clear_check/order_cell.py
  98. 195 0
      code/post_clear_check/src/post_clear_check/order_decimal.py
  99. 1259 0
      code/post_clear_check/src/post_clear_check/order_rules.py
  100. 231 0
      code/post_clear_check/src/post_clear_check/progress.py

+ 33 - 0
code/post_clear_check/pyproject.toml

@@ -0,0 +1,33 @@
+[project]
+name = "post-clear-check"
+version = "0.1.0"
+description = "清算后核对"
+requires-python = ">=3.11.8"
+dependencies = [
+    "asyncmy==0.2.10",
+    "httpx==0.28.1",
+    "mysql-connector-python==9.7.0",
+    "loguru==0.7.2",
+    "pydantic==2.12.3",
+    "pydantic-settings==2.11.0",
+]
+
+[project.optional-dependencies]
+test = ["pytest"]
+
+[project.scripts]
+post-clear-check = "post_clear_check.main:main"
+
+[build-system]
+requires = ["setuptools>=69"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[tool.pytest.ini_options]
+pythonpath = ["src"]
+testpaths = ["tests"]
+markers = [
+    "allow_unregistered: 放开依据登记守卫,只用于验证运行期未登记依据进未归链路",
+]

+ 121 - 0
code/post_clear_check/sql/schema.sql

@@ -0,0 +1,121 @@
+-- 自有库建库脚本。只创建 db_post_clear_check。
+-- 由人在目标实例上执行。进程启动时不执行本脚本。
+-- 重建脚本:三张表已存在时先删除再创建,已有的轮次、卡片和结论行全部清空。
+
+CREATE DATABASE IF NOT EXISTS db_post_clear_check
+  DEFAULT CHARACTER SET utf8mb4
+  DEFAULT COLLATE utf8mb4_0900_ai_ci;
+
+USE db_post_clear_check;
+
+-- 三张表之间没有外键,删除顺序不受约束。按结论行、卡片、轮次的顺序删。
+DROP TABLE IF EXISTS check_line;
+DROP TABLE IF EXISTS check_result;
+DROP TABLE IF EXISTS check_run;
+
+-- 一轮核对,对应一个市场、一个交易日、一轮完成时间。
+-- trs_end_time 允许为空。无对冲时写入方填哨兵 1000-01-01 00:00:00 后再参与唯一键。
+CREATE TABLE check_run (
+  id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+  market VARCHAR(8) NOT NULL COMMENT '市场,本期 a_share',
+  trade_date DATE NOT NULL COMMENT '交易日',
+  business_date DATE NOT NULL COMMENT '营业日',
+  issue_batch_id BIGINT NOT NULL COMMENT '发行 clearing_batch.id',
+  sales_batch_id BIGINT NOT NULL COMMENT '销售 clearing_batch.id',
+  trs_batch_id BIGINT NOT NULL DEFAULT 0 COMMENT '无对冲时为 0',
+  issue_end_time DATETIME NOT NULL COMMENT '发行批次 end_time',
+  sales_end_time DATETIME NOT NULL COMMENT '销售批次 end_time',
+  trs_end_time DATETIME NULL COMMENT '无对冲时为空;写入唯一键时填哨兵 1000-01-01 00:00:00',
+  issue_applied_revision INT NOT NULL COMMENT '发行 applied_revision',
+  sales_applied_revision INT NOT NULL COMMENT '销售 applied_revision',
+  trs_applied_revision INT NOT NULL DEFAULT 0 COMMENT '无对冲时为 0',
+  rule_pack_version VARCHAR(64) NOT NULL COMMENT '规则模块源文件的 sha256,文件变化才开新一轮',
+  correct_count INT NOT NULL DEFAULT 0 COMMENT '结论为 correct 的明细卡片数,不含链路概况',
+  error_count INT NOT NULL DEFAULT 0 COMMENT '结论为 error 的明细卡片数,不含链路概况',
+  suspect_count INT NOT NULL DEFAULT 0 COMMENT '结论为 suspect 的明细卡片数,不含链路概况',
+  create_time DATETIME NOT NULL COMMENT '创建时间',
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_check_run_scope (
+    market,
+    trade_date,
+    issue_batch_id,
+    sales_batch_id,
+    trs_batch_id,
+    issue_end_time,
+    sales_end_time,
+    trs_end_time,
+    rule_pack_version
+  )
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='一轮核对';
+
+-- 一张飞书卡片对应一行。明细卡是一条链路、一个归属对象。
+-- 概况卡 card_kind 为 chain_summary,一条链路一行,biz_key 写链路代码,没有 check_line。
+-- 同一 dedupe_key 已是 sent 则不再发送。failed 只重发这一条,不新插一行。
+-- chain_name 存链路英文代码,卡片上显示中文名。空串只允许回滚后的旧代码写入,新代码不写空串。
+CREATE TABLE check_result (
+  id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键,卡片正文里的记录编号',
+  run_id BIGINT NOT NULL COMMENT 'check_run.id',
+  card_kind VARCHAR(32) NOT NULL COMMENT '归属种类:master_order、fund_account、trs_user、clearing_batch;chain_summary 为链路概况',
+  chain_name VARCHAR(32) NOT NULL DEFAULT '' COMMENT '业务链路代码,见业务链路核对设计 4.1;unassigned 为未归链路',
+  biz_key VARCHAR(64) NOT NULL COMMENT '主订单编号、资金账户、TRS 用户账号、批次号或营业日;概况卡为链路代码',
+  verdict VARCHAR(16) NOT NULL COMMENT 'correct、error、suspect',
+  dedupe_key VARCHAR(191) NOT NULL COMMENT '交易日、卡片种类、链路代码、业务键、三端批次、三端 end_time、规则版本',
+  notify_status VARCHAR(16) NOT NULL COMMENT 'pending、sent、failed',
+  create_time DATETIME NOT NULL COMMENT '创建时间',
+  update_time DATETIME NOT NULL COMMENT '更新时间',
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_check_result_dedupe (dedupe_key),
+  KEY idx_check_result_run_id (run_id, notify_status),
+  CONSTRAINT chk_check_result_card_kind CHECK (
+    card_kind COLLATE utf8mb4_bin IN ('master_order', 'fund_account', 'trs_user', 'clearing_batch', 'chain_summary')
+  ),
+  CONSTRAINT chk_check_result_chain_name CHECK (
+    chain_name COLLATE utf8mb4_bin IN (
+      '',
+      'buy_execution',
+      'sell_execution',
+      'cash_change',
+      'cash_settlement',
+      'account_asset',
+      'note_holding',
+      'topup',
+      'extension',
+      'trade_execution',
+      'user_asset',
+      'check_gate',
+      'unassigned'
+    )
+  ),
+  CONSTRAINT chk_check_result_verdict CHECK (
+    verdict COLLATE utf8mb4_bin IN ('correct', 'error', 'suspect')
+  ),
+  CONSTRAINT chk_check_result_notify CHECK (
+    notify_status COLLATE utf8mb4_bin IN ('pending', 'sent', 'failed')
+  )
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='核对卡片';
+
+-- 卡片内的一行公式。百分比在 calc_value 里按百分数存。
+-- 非数字结论两边的值为 NULL,说明写在 calc_text、check_text。
+-- 归属列给收卡和事后 SQL 用。没有该键时为空串。
+-- basis 存三种依据之一的定位;原说明接在 calc_text 的代入式后面。
+CREATE TABLE check_line (
+  id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+  result_id BIGINT NOT NULL COMMENT 'check_result.id',
+  report_code VARCHAR(16) NOT NULL DEFAULT '' COMMENT '报表编号,如 OTC003、TRS002',
+  master_order_no VARCHAR(64) NOT NULL DEFAULT '' COMMENT '主订单编号,没有则为空',
+  fund_account VARCHAR(64) NOT NULL DEFAULT '' COMMENT '资金账户,没有则为空',
+  user_account VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'TRS 用户账号,没有则为空',
+  formula_text VARCHAR(500) NOT NULL COMMENT '公式',
+  calc_text VARCHAR(1000) NOT NULL COMMENT '被计算的对象',
+  calc_value DECIMAL(24, 6) DEFAULT NULL COMMENT '计算结果,缺输入时为 NULL;百分比按百分数存',
+  check_text VARCHAR(1000) NOT NULL COMMENT '被核对的对象',
+  check_value DECIMAL(24, 6) DEFAULT NULL COMMENT '已落库值,缺行时为 NULL',
+  basis VARCHAR(500) NOT NULL COMMENT '依据',
+  verdict VARCHAR(16) NOT NULL COMMENT 'correct、error、suspect',
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  PRIMARY KEY (id),
+  KEY idx_check_line_result_id (result_id),
+  CONSTRAINT chk_check_line_verdict CHECK (
+    verdict COLLATE utf8mb4_bin IN ('correct', 'error', 'suspect')
+  )
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='核对结论行';

+ 13 - 0
code/post_clear_check/src/post_clear_check.egg-info/PKG-INFO

@@ -0,0 +1,13 @@
+Metadata-Version: 2.4
+Name: post-clear-check
+Version: 0.1.0
+Summary: 清算后核对
+Requires-Python: >=3.11.8
+Requires-Dist: asyncmy==0.2.10
+Requires-Dist: httpx==0.28.1
+Requires-Dist: mysql-connector-python==9.7.0
+Requires-Dist: loguru==0.7.2
+Requires-Dist: pydantic==2.12.3
+Requires-Dist: pydantic-settings==2.11.0
+Provides-Extra: test
+Requires-Dist: pytest; extra == "test"

+ 57 - 0
code/post_clear_check/src/post_clear_check.egg-info/SOURCES.txt

@@ -0,0 +1,57 @@
+pyproject.toml
+src/post_clear_check/__init__.py
+src/post_clear_check/__main__.py
+src/post_clear_check/chain.py
+src/post_clear_check/columns.py
+src/post_clear_check/config.py
+src/post_clear_check/feishu.py
+src/post_clear_check/gate.py
+src/post_clear_check/hedge_link.py
+src/post_clear_check/issue_common.py
+src/post_clear_check/issue_event.py
+src/post_clear_check/issue_otc003.py
+src/post_clear_check/issue_otc004.py
+src/post_clear_check/issue_reports.py
+src/post_clear_check/issue_settle.py
+src/post_clear_check/main.py
+src/post_clear_check/orchestrate.py
+src/post_clear_check/order_cell.py
+src/post_clear_check/order_decimal.py
+src/post_clear_check/order_rules.py
+src/post_clear_check/progress.py
+src/post_clear_check/reader.py
+src/post_clear_check/sales_asset.py
+src/post_clear_check/sales_cash.py
+src/post_clear_check/sales_common.py
+src/post_clear_check/sales_event.py
+src/post_clear_check/sales_otc003.py
+src/post_clear_check/sales_otc004.py
+src/post_clear_check/sales_reports.py
+src/post_clear_check/sales_settle.py
+src/post_clear_check/session.py
+src/post_clear_check/trs_account.py
+src/post_clear_check/trs_common.py
+src/post_clear_check/trs_flow.py
+src/post_clear_check/trs_reports.py
+src/post_clear_check/trs_trade.py
+src/post_clear_check.egg-info/PKG-INFO
+src/post_clear_check.egg-info/SOURCES.txt
+src/post_clear_check.egg-info/dependency_links.txt
+src/post_clear_check.egg-info/entry_points.txt
+src/post_clear_check.egg-info/requires.txt
+src/post_clear_check.egg-info/top_level.txt
+tests/test_chain.py
+tests/test_config.py
+tests/test_feishu.py
+tests/test_gate.py
+tests/test_hedge_link.py
+tests/test_issue_reports.py
+tests/test_main.py
+tests/test_orchestrate.py
+tests/test_order_rules.py
+tests/test_progress.py
+tests/test_reader.py
+tests/test_sales_reports.py
+tests/test_schema.py
+tests/test_session.py
+tests/test_trs_reports.py

+ 1 - 0
code/post_clear_check/src/post_clear_check.egg-info/dependency_links.txt

@@ -0,0 +1 @@
+

+ 2 - 0
code/post_clear_check/src/post_clear_check.egg-info/entry_points.txt

@@ -0,0 +1,2 @@
+[console_scripts]
+post-clear-check = post_clear_check.main:main

+ 9 - 0
code/post_clear_check/src/post_clear_check.egg-info/requires.txt

@@ -0,0 +1,9 @@
+asyncmy==0.2.10
+httpx==0.28.1
+mysql-connector-python==9.7.0
+loguru==0.7.2
+pydantic==2.12.3
+pydantic-settings==2.11.0
+
+[test]
+pytest

+ 1 - 0
code/post_clear_check/src/post_clear_check.egg-info/top_level.txt

@@ -0,0 +1 @@
+post_clear_check

+ 3 - 0
code/post_clear_check/src/post_clear_check/__init__.py

@@ -0,0 +1,3 @@
+"""清算后核对工具。"""
+
+__version__ = "0.1.0"

+ 6 - 0
code/post_clear_check/src/post_clear_check/__main__.py

@@ -0,0 +1,6 @@
+"""python -m post_clear_check 入口。"""
+
+from post_clear_check.main import main
+
+if __name__ == "__main__":
+    main()

BIN
code/post_clear_check/src/post_clear_check/__pycache__/__init__.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/__init__.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/__main__.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/buy_execution.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/chain.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/columns.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/columns.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/config.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/config.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/feishu.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/fund_chain.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/gate.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/gate.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/hedge_link.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/issue_common.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/issue_common.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/issue_event.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/issue_event.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/issue_otc003.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/issue_otc003.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/issue_otc004.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/issue_otc004.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/issue_reports.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/issue_reports.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/issue_settle.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/issue_settle.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/main.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/main.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/note_chain.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/orchestrate.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/order_cell.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/order_cell.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/order_decimal.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/order_decimal.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/order_rules.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/progress.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/published.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/reader.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/reader.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/sales_asset.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/sales_asset.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/sales_cash.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/sales_cash.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/sales_common.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/sales_common.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/sales_event.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/sales_event.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/sales_otc003.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/sales_otc003.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/sales_otc004.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/sales_otc004.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/sales_reports.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/sales_reports.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/sales_settle.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/sales_settle.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/sell_execution.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/session.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/session.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/trs_account.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/trs_account.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/trs_chain.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/trs_common.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/trs_common.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/trs_flow.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/trs_flow.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/trs_reports.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/trs_reports.cpython-312.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/trs_trade.cpython-311.pyc


BIN
code/post_clear_check/src/post_clear_check/__pycache__/trs_trade.cpython-312.pyc


+ 974 - 0
code/post_clear_check/src/post_clear_check/buy_execution.py

@@ -0,0 +1,974 @@
+"""买入执行的步骤核对。
+
+执行事实读 input,并与实时源表的成交名义本金、订单成交均价对照。
+结算口径读 work/3。work/4 与 cleared 应与 work/3 一致。
+不读实时表上的结算口径列、买入股数和结算汇率当输入,也不用回写 after_val。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from decimal import Decimal
+from typing import Any
+
+from post_clear_check.columns import ORDER_SUB_TABLE
+from post_clear_check.order_cell import (
+    Cell,
+    CheckLine,
+    finish,
+    finish_cell,
+    manual_flag,
+    row_label,
+)
+from post_clear_check.order_decimal import (
+    SCALE_AMT,
+    SCALE_FX,
+    SCALE_PX,
+    SCALE_QTY,
+    VERDICT_ERROR,
+    VERDICT_SUSPECT,
+    as_decimal,
+    equal_verdict,
+    format_decimal,
+    join_decimals,
+    judge_convert,
+    judge_rate,
+    quantize,
+)
+
+# 这些公式左侧已经在买入链路登记。新增的四条同样登记在 chain。
+FACT_FORMULA = "执行事实列与源表一致"
+RESULT_FORMULA = "T日订单结算与工作快照一致"
+LIVE_FORMULA = "客户终态订单的结算口径等于已发布结果"
+FEE_FORMULA = "发行费用 = 按封存的A类买入发行费标准计算"
+_FX_FORMULA = "结算汇率由累计发生金额(交易币种)与累计发生金额(结算币种)反算"
+_HUNDRED = Decimal("100")
+
+_FACT_COLUMNS = (
+    ("exec_notional_trade", "成交名义本金", SCALE_AMT),
+    ("order_avg_px", "订单成交均价", SCALE_PX),
+)
+_SETTLE_COLUMNS = (
+    ("cum_filled_qty", "累计成交股数", SCALE_QTY),
+    ("avg_px_with_fee", "对冲含费成交均价", SCALE_PX),
+    ("cum_amt_trade", "累计发生金额(交易币种)", SCALE_AMT),
+    ("cum_amt_settle", "累计发生金额(结算币种)", SCALE_AMT),
+    ("fx_settle", "结算汇率", SCALE_FX),
+    ("order_qty", "订单买入股数", SCALE_QTY),
+    ("exec_notional_settle", "执行订单名义本金(结算币种)", SCALE_AMT),
+    ("exec_face_value_settle", "票据面值", SCALE_AMT),
+    ("exec_issue_fee_settle", "发行费用", SCALE_AMT),
+    ("exec_issue_price_settle", "票据发行价", SCALE_AMT),
+)
+_DETAIL_FORMULAS = (
+    "累计发生金额(交易币种) = 对冲记录发生金额(交易币种)之和",
+    "累计发生金额(结算币种) = 对冲记录发生金额(结算币种)之和",
+    "累计成交股数 = 对冲记录成交股数之和",
+    "对冲含费成交均价 = 累计发生金额(交易币种) / 累计成交股数",
+    _FX_FORMULA,
+    "执行订单名义本金(结算币种) = 执行订单名义本金(交易币种)按结算汇率换算",
+    "订单买入股数 = 执行订单名义本金(交易币种) / 订单成交均价",
+    "订单成交均价 = 对冲含费成交均价",
+    "票据面值 = 执行订单名义本金(结算币种) × A 类报价",
+    FEE_FORMULA,
+    "票据发行价 = 票据面值 + 发行费用",
+)
+
+
+class _Sums:
+    """input 对冲汇总。股数和金额只来自冻结的对冲记录。"""
+
+    def __init__(
+        self,
+        trade_amt: Decimal | None,
+        settle_amt: Decimal | None,
+        hedge_qty: Decimal | None,
+        amounts_ready: bool,
+        qty_ready: bool,
+        trade_text: str,
+        settle_text: str,
+        qty_text: str,
+    ) -> None:
+        self.trade_amt = trade_amt
+        self.settle_amt = settle_amt
+        self.hedge_qty = hedge_qty
+        self.amounts_ready = amounts_ready
+        self.qty_ready = qty_ready
+        self.trade_text = trade_text
+        self.settle_text = settle_text
+        self.qty_text = qty_text
+
+
+def append_buy(
+    lines: list[CheckLine],
+    order: Mapping[str, Any],
+    subs: Sequence[Mapping[str, Any]],
+) -> None:
+    """核对一张买入主单。subs 是 cleared 子单,其余阶段在订单字典上。"""
+
+    frozen_rows = _rows(order.get("input_sub"))
+    work3_rows = _rows(order.get("work3_sub"))
+    work4_rows = _rows(order.get("work4_sub"))
+    live_rows = order.get("live_sub")
+    cleared = [row for row in subs if isinstance(row, Mapping)]
+    label = _label(order, frozen_rows, work3_rows, cleared)
+    _fact_node(lines, label, frozen_rows, live_rows)
+    _result_node(lines, label, work3_rows, work4_rows, cleared)
+    _live_node(lines, label, live_rows, cleared)
+    if len(frozen_rows) != 1 or len(work3_rows) != 1:
+        reason = _stage_reason(frozen_rows, work3_rows)
+        for formula in _DETAIL_FORMULAS:
+            _missing(lines, formula, reason)
+        return
+    frozen = frozen_rows[0]
+    work = work3_rows[0]
+    sums = _summarize(_with_trs_amounts(order.get("input_hedges"), order.get("trs_amounts")))
+    _amount_lines(lines, work, label, sums)
+    _avg_line(lines, work, label, sums)
+    fx = _fx_line(lines, work, label, sums)
+    _convert_line(lines, frozen, work, label, fx)
+    _qty_line(lines, frozen, work, label)
+    _order_avg_line(lines, frozen, label, sums)
+    _face_line(lines, frozen, work, label, fx)
+    notional = _input_number(frozen, "exec_notional_trade")
+    settle = quantize(notional * fx, SCALE_AMT) if notional is not None and fx is not None else None
+    _fee_line(lines, order, work, label, settle)
+    _issue_price_line(lines, work, label)
+
+
+def _fact_node(
+    lines: list[CheckLine],
+    label: str,
+    frozen_rows: Sequence[Mapping[str, Any]],
+    live_rows: object,
+) -> None:
+    """input 的两列执行事实必须与实时源表一致。"""
+
+    problems = _pair_problems("input", frozen_rows, "源表", live_rows, _FACT_COLUMNS)
+    _node(lines, FACT_FORMULA, label, problems)
+
+
+def _result_node(
+    lines: list[CheckLine],
+    label: str,
+    work3_rows: Sequence[Mapping[str, Any]],
+    work4_rows: Sequence[Mapping[str, Any]],
+    cleared: Sequence[Mapping[str, Any]],
+) -> None:
+    """第 3 步写出的结算口径,到第 4 步和 T 日结果都不变。"""
+
+    problems = _pair_problems("work/3", work3_rows, "work/4", work4_rows, _SETTLE_COLUMNS)
+    problems.extend(_pair_problems("work/3", work3_rows, "cleared", cleared, _SETTLE_COLUMNS))
+    _node(lines, RESULT_FORMULA, label, problems)
+
+
+def _live_node(
+    lines: list[CheckLine],
+    label: str,
+    live_rows: object,
+    cleared: Sequence[Mapping[str, Any]],
+) -> None:
+    """实时结算口径应等于已发布结果。执行事实另由执行事实节点核对。"""
+
+    problems = _pair_problems("cleared", cleared, "实时", live_rows, _SETTLE_COLUMNS)
+    _node(lines, LIVE_FORMULA, label, problems)
+
+
+def _pair_problems(
+    left_name: str,
+    left_rows: object,
+    right_name: str,
+    right_rows: object,
+    columns: Sequence[tuple[str, str, int]],
+) -> list[str]:
+    left_problem = _row_problem(left_name, left_rows)
+    right_problem = _row_problem(right_name, right_rows)
+    if left_problem or right_problem:
+        return [item for item in (left_problem, right_problem) if item]
+    left = _only(left_rows)
+    right = _only(right_rows)
+    found: list[str] = []
+    for column, title, scale in columns:
+        gap = _gap(left_name, left, right_name, right, column, title, scale)
+        if gap:
+            found.append(gap)
+    return found
+
+
+def _row_problem(name: str, rows: object) -> str | None:
+    if rows is None:
+        return f"{name}没有读到"
+    if not isinstance(rows, Sequence) or isinstance(rows, (str, bytes)):
+        return f"{name}没有读到"
+    count = sum(1 for row in rows if isinstance(row, Mapping))
+    if count == 0:
+        return f"{name}没有这张子单"
+    if count > 1:
+        return f"{name}子单不止一行"
+    return None
+
+
+def _only(rows: object) -> Mapping[str, Any]:
+    assert isinstance(rows, Sequence)
+    return next(row for row in rows if isinstance(row, Mapping))
+
+
+def _gap(
+    left_name: str,
+    left: Mapping[str, Any],
+    right_name: str,
+    right: Mapping[str, Any],
+    column: str,
+    title: str,
+    scale: int,
+) -> str | None:
+    left_number, left_ready = _ready_number(left, column)
+    right_number, right_ready = _ready_number(right, column)
+    if not left_ready or not right_ready:
+        return f"{title}在{left_name}或{right_name}缺失"
+    if quantize(left_number, scale) != quantize(right_number, scale):
+        return (
+            f"{title} {left_name} {format_decimal(left_number, scale)}"
+            f",{right_name} {format_decimal(right_number, scale)}"
+        )
+    return None
+
+
+def _ready_number(row: Mapping[str, Any], column: str) -> tuple[Decimal, bool]:
+    if column not in row:
+        return Decimal(0), False
+    number = as_decimal(row.get(column))
+    if number is None:
+        return Decimal(0), False
+    return number, True
+
+
+def _stage_reason(
+    frozen_rows: Sequence[Mapping[str, Any]],
+    work3_rows: Sequence[Mapping[str, Any]],
+) -> str:
+    if len(frozen_rows) == 0:
+        return "没有 input 子单"
+    if len(frozen_rows) > 1:
+        return "input 子单不止一行"
+    if len(work3_rows) == 0:
+        return "没有 work/3 子单"
+    return "work/3 子单不止一行"
+
+
+def _amount_lines(
+    lines: list[CheckLine],
+    work: Mapping[str, Any],
+    label: str,
+    sums: _Sums | None,
+) -> None:
+    _sum_line(
+        lines, work, label, sums, "cum_amt_trade",
+        "累计发生金额(交易币种) = 对冲记录发生金额(交易币种)之和",
+        None if sums is None else sums.trade_amt,
+        False if sums is None else sums.amounts_ready,
+        "对冲输入没有读到" if sums is None else sums.trade_text,
+        SCALE_AMT,
+    )
+    _sum_line(
+        lines, work, label, sums, "cum_amt_settle",
+        "累计发生金额(结算币种) = 对冲记录发生金额(结算币种)之和",
+        None if sums is None else sums.settle_amt,
+        False if sums is None else sums.amounts_ready,
+        "对冲输入没有读到" if sums is None else sums.settle_text,
+        SCALE_AMT,
+    )
+    _sum_line(
+        lines, work, label, sums, "cum_filled_qty",
+        "累计成交股数 = 对冲记录成交股数之和",
+        None if sums is None else sums.hedge_qty,
+        False if sums is None else sums.qty_ready,
+        "对冲输入没有读到" if sums is None else sums.qty_text,
+        SCALE_QTY,
+    )
+
+
+def _sum_line(
+    lines: list[CheckLine],
+    work: Mapping[str, Any],
+    label: str,
+    sums: _Sums | None,
+    column: str,
+    formula: str,
+    calc: Decimal | None,
+    ready: bool,
+    text: str,
+    scale: int,
+) -> None:
+    del sums
+    finish_cell(
+        lines,
+        formula=formula,
+        basis=formula,
+        calc_text=text,
+        calc_value=calc if ready else None,
+        cell=_cell(work, column),
+        table=ORDER_SUB_TABLE,
+        label=label,
+        column=column,
+        scale=scale,
+    )
+
+
+def _avg_line(
+    lines: list[CheckLine],
+    work: Mapping[str, Any],
+    label: str,
+    sums: _Sums | None,
+) -> None:
+    formula = "对冲含费成交均价 = 累计发生金额(交易币种) / 累计成交股数"
+    calc, text = _avg_value(sums)
+    finish_cell(
+        lines,
+        formula=formula,
+        basis="对冲含费成交均价 = 累计发生金额(交易币种) / 累计成交股数,分母为 0 时可疑",
+        calc_text=text,
+        calc_value=calc,
+        cell=_cell(work, "avg_px_with_fee"),
+        table=ORDER_SUB_TABLE,
+        label=label,
+        column="avg_px_with_fee",
+        scale=SCALE_PX,
+    )
+
+
+def _avg_value(sums: _Sums | None) -> tuple[Decimal | None, str]:
+    if sums is None:
+        return None, "对冲输入没有读到"
+    if not sums.amounts_ready or not sums.qty_ready or sums.trade_amt is None or sums.hedge_qty is None:
+        return None, "累计发生金额或累计成交股数无法相加"
+    if sums.hedge_qty == 0:
+        return None, "累计成交股数为 0"
+    calc = sums.trade_amt / sums.hedge_qty
+    text = (
+        f"{format_decimal(sums.trade_amt, SCALE_AMT)} / "
+        f"{format_decimal(sums.hedge_qty, SCALE_QTY)} = {format_decimal(calc, SCALE_PX)}"
+    )
+    return calc, text
+
+
+def _fx_line(
+    lines: list[CheckLine],
+    work: Mapping[str, Any],
+    label: str,
+    sums: _Sums | None,
+) -> Decimal | None:
+    """返回反推出的汇率,供名义本金和面值使用。不读 work 上的 fx_settle 当输入。"""
+
+    cell = _cell(work, "fx_settle")
+    if sums is None or not sums.amounts_ready or sums.trade_amt is None or sums.settle_amt is None:
+        verdict = VERDICT_ERROR if cell.absent else VERDICT_SUSPECT
+        finish(
+            lines,
+            formula=_FX_FORMULA,
+            basis="汇率两个方向都对得上为可疑,都对不上为错误,都无法算为可疑",
+            calc_text="对冲输入没有读到" if sums is None else "累计发生金额无法相加,结算汇率不能反算",
+            calc_value=None,
+            check_text=_check_text(label, "fx_settle", cell, SCALE_FX),
+            check_value=None if cell.absent else cell.number,
+            verdict=verdict,
+            live=False,
+            scale=SCALE_FX,
+        )
+        return None
+    if cell.absent or cell.number is None:
+        finish(
+            lines,
+            formula=_FX_FORMULA,
+            basis="汇率两个方向都对得上为可疑,都对不上为错误,都无法算为可疑",
+            calc_text="结算汇率缺失",
+            calc_value=None,
+            check_text=_check_text(label, "fx_settle", cell, SCALE_FX),
+            check_value=None,
+            verdict=VERDICT_ERROR,
+            live=False,
+        )
+        return _derived_fx(sums)
+    verdict, calc, text = judge_rate(sums.trade_amt, sums.settle_amt, cell.number)
+    finish(
+        lines,
+        formula=_FX_FORMULA,
+        basis="汇率两个方向都对得上为可疑,都对不上为错误,都无法算为可疑",
+        calc_text=text,
+        calc_value=calc,
+        check_text=_check_text(label, "fx_settle", cell, SCALE_FX),
+        check_value=cell.number,
+        verdict=verdict,
+        live=False,
+        scale=SCALE_FX,
+    )
+    return _derived_fx(sums)
+
+
+def _derived_fx(sums: _Sums) -> Decimal | None:
+    if not sums.amounts_ready or sums.trade_amt is None or sums.settle_amt is None:
+        return None
+    if sums.trade_amt == 0:
+        return Decimal(0)
+    return quantize(sums.settle_amt / sums.trade_amt, SCALE_FX)
+
+
+def _convert_line(
+    lines: list[CheckLine],
+    frozen: Mapping[str, Any],
+    work: Mapping[str, Any],
+    label: str,
+    fx: Decimal | None,
+) -> None:
+    formula = "执行订单名义本金(结算币种) = 执行订单名义本金(交易币种)按结算汇率换算"
+    notional = _input_number(frozen, "exec_notional_trade")
+    check = _cell(work, "exec_notional_settle")
+    if notional is None or fx is None:
+        verdict = VERDICT_ERROR if check.absent else VERDICT_SUSPECT
+        finish(
+            lines,
+            formula=formula,
+            basis=formula,
+            calc_text="input 的执行名义本金或由对冲反推的汇率缺失",
+            calc_value=None,
+            check_text=_check_text(label, "exec_notional_settle", check, SCALE_AMT),
+            check_value=None if check.absent or check.number is None else check.number,
+            verdict=verdict,
+            live=False,
+            scale=SCALE_AMT,
+        )
+        return
+    if check.absent or check.number is None:
+        finish(
+            lines,
+            formula=formula,
+            basis=formula,
+            calc_text="被核对的结算币种金额缺失",
+            calc_value=None,
+            check_text=_check_text(label, "exec_notional_settle", check, SCALE_AMT),
+            check_value=None,
+            verdict=VERDICT_ERROR,
+            live=False,
+        )
+        return
+    verdict, calc, text = judge_convert(notional, fx, check.number)
+    finish(
+        lines,
+        formula=formula,
+        basis="用交易币种金额、结算汇率、结算币种金额看方向,两种都相等为可疑,都不等为错误",
+        calc_text=text,
+        calc_value=calc,
+        check_text=_check_text(label, "exec_notional_settle", check, SCALE_AMT),
+        check_value=check.number,
+        verdict=verdict,
+        live=False,
+        scale=SCALE_AMT,
+    )
+
+
+def _qty_line(
+    lines: list[CheckLine],
+    frozen: Mapping[str, Any],
+    work: Mapping[str, Any],
+    label: str,
+) -> None:
+    formula = "订单买入股数 = 执行订单名义本金(交易币种) / 订单成交均价"
+    notional = _input_number(frozen, "exec_notional_trade")
+    price = _input_number(frozen, "order_avg_px")
+    check = _cell(work, "order_qty")
+    if price is not None and price == 0:
+        finish_cell(
+            lines,
+            formula=formula,
+            basis="订单买入股数 = 执行订单名义本金(交易币种) / 订单成交均价,均价为 0 时可疑",
+            calc_text="订单成交均价为 0",
+            calc_value=None,
+            cell=check,
+            table=ORDER_SUB_TABLE,
+            label=label,
+            column="order_qty",
+            scale=SCALE_QTY,
+        )
+        if not check.absent:
+            lines[-1] = _force(lines[-1], VERDICT_SUSPECT)
+        return
+    if notional is None or price is None:
+        verdict = VERDICT_ERROR if check.absent else VERDICT_SUSPECT
+        finish(
+            lines,
+            formula=formula,
+            basis=formula,
+            calc_text="input 的执行名义本金或订单成交均价缺失",
+            calc_value=None,
+            check_text=_check_text(label, "order_qty", check, SCALE_QTY),
+            check_value=None if check.absent or check.number is None else check.number,
+            verdict=verdict,
+            live=False,
+            scale=SCALE_QTY,
+        )
+        return
+    calc = notional / price
+    text = (
+        f"{format_decimal(notional, SCALE_AMT)} / {format_decimal(price, SCALE_PX)}"
+        f" = {format_decimal(calc, SCALE_QTY)}"
+    )
+    finish_cell(
+        lines,
+        formula=formula,
+        basis=formula,
+        calc_text=text,
+        calc_value=calc,
+        cell=check,
+        table=ORDER_SUB_TABLE,
+        label=label,
+        column="order_qty",
+        scale=SCALE_QTY,
+    )
+
+
+def _order_avg_line(
+    lines: list[CheckLine],
+    frozen: Mapping[str, Any],
+    label: str,
+    sums: _Sums | None,
+) -> None:
+    """人工改过成交均价时没有公式。被核对的值在 input。"""
+
+    flag = manual_flag(frozen)
+    if flag == 1:
+        return
+    formula = "订单成交均价 = 对冲含费成交均价"
+    check = _cell(frozen, "order_avg_px")
+    if flag is None:
+        text = (
+            "订单成交均价的人工编辑标志缺失"
+            if "order_avg_px_manual_flag" not in frozen
+            else "订单成交均价的人工编辑标志无法读取"
+        )
+        verdict = VERDICT_ERROR if check.absent else VERDICT_SUSPECT
+        finish(
+            lines,
+            formula=formula,
+            basis="order_avg_px_manual_flag 为 0 时,订单成交均价等于对冲含费成交均价",
+            calc_text=text,
+            calc_value=None,
+            check_text=_check_text(label, "order_avg_px", check, SCALE_PX),
+            check_value=None if check.absent or check.number is None else check.number,
+            verdict=verdict,
+            live=False,
+            scale=SCALE_PX,
+        )
+        return
+    calc, text = _avg_value(sums)
+    finish_cell(
+        lines,
+        formula=formula,
+        basis="order_avg_px_manual_flag 为 0 时,订单成交均价等于对冲含费成交均价",
+        calc_text=text,
+        calc_value=calc,
+        cell=check,
+        table=ORDER_SUB_TABLE,
+        label=label,
+        column="order_avg_px",
+        scale=SCALE_PX,
+    )
+
+
+def _face_line(
+    lines: list[CheckLine],
+    frozen: Mapping[str, Any],
+    work: Mapping[str, Any],
+    label: str,
+    fx: Decimal | None,
+) -> Decimal | None:
+    """返回算出的面值。报价只读 input,缺失不当成 100%。"""
+
+    formula = "票据面值 = 执行订单名义本金(结算币种) × A 类报价"
+    check = _cell(work, "exec_face_value_settle")
+    quote = _input_number(frozen, "a_quote_ratio")
+    notional = _input_number(frozen, "exec_notional_trade")
+    if quote is None:
+        finish(
+            lines,
+            formula=formula,
+            basis="报价不在 input 子单上时为错误,不默认 100%",
+            calc_text="A 类报价不在 input 子单上",
+            calc_value=None,
+            check_text=_check_text(label, "exec_face_value_settle", check, SCALE_AMT),
+            check_value=None if check.absent or check.number is None else check.number,
+            verdict=VERDICT_ERROR,
+            live=False,
+            scale=SCALE_AMT,
+        )
+        return None
+    if notional is None or fx is None:
+        verdict = VERDICT_ERROR if check.absent else VERDICT_SUSPECT
+        finish(
+            lines,
+            formula=formula,
+            basis=formula,
+            calc_text="input 的执行名义本金或由对冲反推的汇率缺失",
+            calc_value=None,
+            check_text=_check_text(label, "exec_face_value_settle", check, SCALE_AMT),
+            check_value=None if check.absent or check.number is None else check.number,
+            verdict=verdict,
+            live=False,
+            scale=SCALE_AMT,
+        )
+        return None
+    settle = quantize(notional * fx, SCALE_AMT)
+    calc = quantize(settle * quote / _HUNDRED, SCALE_AMT)
+    text = (
+        f"{format_decimal(settle, SCALE_AMT)} × {format_decimal(quote, 4)} / 100"
+        f" = {format_decimal(calc, SCALE_AMT)}"
+    )
+    finish_cell(
+        lines,
+        formula=formula,
+        basis=formula,
+        calc_text=text,
+        calc_value=calc,
+        cell=check,
+        table=ORDER_SUB_TABLE,
+        label=label,
+        column="exec_face_value_settle",
+        scale=SCALE_AMT,
+    )
+    return calc
+
+
+def _fee_line(
+    lines: list[CheckLine],
+    order: Mapping[str, Any],
+    work: Mapping[str, Any],
+    label: str,
+    settle: Decimal | None,
+) -> None:
+    """发行费用按封存标准计算。基数是 input 名义本金乘反推汇率,不读 work 上的结算金额当输入。"""
+    formula = FEE_FORMULA
+    check = _cell(work, "exec_issue_fee_settle")
+    standards = order.get("fee_standards") if "fee_standards" in order else None
+    master = order.get("master")
+    ccy = master.get("settle_ccy") if isinstance(master, Mapping) else None
+    ccy_text = ccy.strip() if isinstance(ccy, str) else ""
+    problem = _fee_problem(standards, ccy_text)
+    if problem is not None:
+        verdict = VERDICT_ERROR if check.absent else VERDICT_SUSPECT
+        if problem != "收费策略无法识别":
+            verdict = VERDICT_ERROR
+        finish(
+            lines,
+            formula=formula,
+            basis=formula,
+            calc_text=problem,
+            calc_value=None,
+            check_text=_check_text(label, "exec_issue_fee_settle", check, SCALE_AMT),
+            check_value=None if check.absent or check.number is None else check.number,
+            verdict=verdict,
+            live=False,
+            scale=SCALE_AMT,
+        )
+        return
+    assert isinstance(standards, Sequence)
+    matched = _matched_fee(standards, ccy_text)
+    assert matched is not None
+    if settle is None:
+        finish(
+            lines,
+            formula=formula,
+            basis=formula,
+            calc_text="input 的执行名义本金或由对冲反推的汇率缺失,发行费用不能计算",
+            calc_value=None,
+            check_text=_check_text(label, "exec_issue_fee_settle", check, SCALE_AMT),
+            check_value=None if check.absent or check.number is None else check.number,
+            verdict=VERDICT_ERROR if check.absent else VERDICT_SUSPECT,
+            live=False,
+            scale=SCALE_AMT,
+        )
+        return
+    calc = _calc_fee(settle, matched)
+    if calc is None:
+        finish(
+            lines,
+            formula=formula,
+            basis=formula,
+            calc_text="收费策略无法识别",
+            calc_value=None,
+            check_text=_check_text(label, "exec_issue_fee_settle", check, SCALE_AMT),
+            check_value=None if check.absent or check.number is None else check.number,
+            verdict=VERDICT_ERROR,
+            live=False,
+            scale=SCALE_AMT,
+        )
+        return
+    text = f"按封存标准 {format_decimal(calc, SCALE_AMT)}"
+    finish_cell(
+        lines,
+        formula=formula,
+        basis=formula,
+        calc_text=text,
+        calc_value=calc,
+        cell=check,
+        table=ORDER_SUB_TABLE,
+        label=label,
+        column="exec_issue_fee_settle",
+        scale=SCALE_AMT,
+    )
+
+
+def _fee_problem(standards: object, ccy: str) -> str | None:
+    if standards is None:
+        return "发行费标准没有读到"
+    if not isinstance(standards, Sequence) or isinstance(standards, (str, bytes)):
+        return "发行费标准没有读到"
+    if not ccy:
+        return "主单缺少结算币种"
+    matched = _matched_fee(standards, ccy)
+    if matched is None:
+        rows = [row for row in standards if isinstance(row, Mapping) and _is_buy_issue_fee(row, ccy)]
+        if len(rows) > 1:
+            return "发行费标准不唯一"
+        return "没有封存的A类买入发行费标准"
+    return None
+
+
+def _matched_fee(standards: Sequence[Any], ccy: str) -> Mapping[str, Any] | None:
+    found = [row for row in standards if isinstance(row, Mapping) and _is_buy_issue_fee(row, ccy)]
+    if len(found) != 1:
+        return None
+    return found[0]
+
+
+def _is_buy_issue_fee(row: Mapping[str, Any], ccy: str) -> bool:
+    return (
+        str(row.get("category") or "") == "note_a"
+        and str(row.get("fee_type") or "") == "issue_fee"
+        and str(row.get("charge_node") or "") == "buy"
+        and str(row.get("settle_ccy") or "") == ccy
+    )
+
+
+def _calc_fee(base: Decimal, row: Mapping[str, Any]) -> Decimal | None:
+    """与发行端 calc_fee 相同:百分比的 fee_value 是百分数,先四舍五入再套最低最高。"""
+
+    value = as_decimal(row.get("fee_value"))
+    if value is None:
+        return None
+    strategy = str(row.get("charge_strategy") or "")
+    if strategy == "fixed":
+        amount = value
+    elif strategy == "percentage":
+        amount = base * value / _HUNDRED
+    else:
+        return None
+    amount = quantize(amount, SCALE_AMT)
+    floor = as_decimal(row.get("min_fee")) if row.get("min_fee") is not None else None
+    ceiling = as_decimal(row.get("max_fee")) if row.get("max_fee") is not None else None
+    if floor is not None and amount < floor:
+        amount = quantize(floor, SCALE_AMT)
+    if ceiling is not None and amount > ceiling:
+        amount = quantize(ceiling, SCALE_AMT)
+    return amount
+
+
+def _issue_price_line(lines: list[CheckLine], work: Mapping[str, Any], label: str) -> None:
+    """发行价核 work/3 上的面值加发行费,两列都是结算口径。"""
+
+    formula = "票据发行价 = 票据面值 + 发行费用"
+    face = _cell(work, "exec_face_value_settle")
+    fee = _cell(work, "exec_issue_fee_settle")
+    check = _cell(work, "exec_issue_price_settle")
+    if face.absent or face.number is None or fee.absent or fee.number is None:
+        verdict = VERDICT_ERROR if check.absent else VERDICT_SUSPECT
+        finish(
+            lines,
+            formula=formula,
+            basis=formula,
+            calc_text="票据面值或发行费用缺失",
+            calc_value=None,
+            check_text=_check_text(label, "exec_issue_price_settle", check, SCALE_AMT),
+            check_value=None if check.absent or check.number is None else check.number,
+            verdict=verdict,
+            live=False,
+            scale=SCALE_AMT,
+        )
+        return
+    calc = face.number + fee.number
+    text = (
+        f"{format_decimal(face.number, SCALE_AMT)} + {format_decimal(fee.number, SCALE_AMT)}"
+        f" = {format_decimal(calc, SCALE_AMT)}"
+    )
+    finish_cell(
+        lines,
+        formula=formula,
+        basis="票据发行价 = 票据面值 + 发行费用",
+        calc_text=text,
+        calc_value=calc,
+        cell=check,
+        table=ORDER_SUB_TABLE,
+        label=label,
+        column="exec_issue_price_settle",
+        scale=SCALE_AMT,
+    )
+
+
+def _with_trs_amounts(hedges: object, amounts: object) -> object:
+    """有 TRS 对账结果时,用其发生金额绝对值替换 input。没有结果的记录保持原值。"""
+
+    if not isinstance(amounts, Mapping) or not amounts:
+        return hedges
+    if not isinstance(hedges, Sequence) or isinstance(hedges, (str, bytes)):
+        return hedges
+    found: list[object] = []
+    for row in hedges:
+        if not isinstance(row, Mapping):
+            found.append(row)
+            continue
+        ident = row.get("id")
+        hit = amounts.get(ident)
+        if not isinstance(hit, Mapping):
+            found.append(row)
+            continue
+        copied = dict(row)
+        trade = as_decimal(hit.get("amt_trade"))
+        settle = as_decimal(hit.get("amt_settle"))
+        # TRS 卖出金额带负号,发行端对冲发生金额恒为非负。
+        if trade is not None:
+            copied["amt_trade"] = abs(trade)
+        if settle is not None:
+            copied["amt_settle"] = abs(settle)
+        found.append(copied)
+    return found
+
+
+def _summarize(hedges: object) -> _Sums | None:
+    if hedges is None:
+        return None
+    if not isinstance(hedges, Sequence) or isinstance(hedges, (str, bytes)):
+        return None
+    rows = [row for row in hedges if isinstance(row, Mapping)]
+    if not rows:
+        return _Sums(
+            Decimal(0), Decimal(0), Decimal(0), True, True, "0.00", "0.00", "0.000000",
+        )
+    trade_parts: list[Decimal] = []
+    settle_parts: list[Decimal] = []
+    qty_parts: list[Decimal] = []
+    amounts_ready = True
+    qty_ready = True
+    for hedge in rows:
+        qty = as_decimal(hedge.get("filled_qty"))
+        if qty is None:
+            qty_ready = False
+        else:
+            qty_parts.append(qty)
+        trade_amt = as_decimal(hedge.get("amt_trade"))
+        settle_amt = as_decimal(hedge.get("amt_settle"))
+        if trade_amt is None or settle_amt is None:
+            amounts_ready = False
+            continue
+        trade_parts.append(trade_amt)
+        settle_parts.append(settle_amt)
+    return _Sums(
+        sum(trade_parts, Decimal(0)) if amounts_ready else None,
+        sum(settle_parts, Decimal(0)) if amounts_ready else None,
+        sum(qty_parts, Decimal(0)) if qty_ready else None,
+        amounts_ready,
+        qty_ready,
+        join_decimals(trade_parts, SCALE_AMT) if amounts_ready else "对冲记录发生金额(交易币种)无法相加",
+        join_decimals(settle_parts, SCALE_AMT) if amounts_ready else "对冲记录发生金额(结算币种)无法相加",
+        join_decimals(qty_parts, SCALE_QTY) if qty_ready else "对冲记录成交股数无法相加",
+    )
+
+
+def _input_number(row: Mapping[str, Any], column: str) -> Decimal | None:
+    if column not in row:
+        return None
+    return as_decimal(row.get(column))
+
+
+def _cell(row: Mapping[str, Any], column: str) -> Cell:
+    if column not in row:
+        return Cell(None, True, False, False, False)
+    number = as_decimal(row.get(column))
+    if number is None:
+        return Cell(None, True, False, False, False)
+    return Cell(number, False, False, False, False)
+
+
+def _rows(value: object) -> list[Mapping[str, Any]]:
+    if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
+        return []
+    return [row for row in value if isinstance(row, Mapping)]
+
+
+def _label(
+    order: Mapping[str, Any],
+    frozen_rows: Sequence[Mapping[str, Any]],
+    work3_rows: Sequence[Mapping[str, Any]],
+    cleared: Sequence[Mapping[str, Any]],
+) -> str:
+    for rows in (work3_rows, frozen_rows, cleared):
+        if rows:
+            return row_label(rows[0])
+    master = order.get("master")
+    if isinstance(master, Mapping):
+        order_no = master.get("order_no")
+        if isinstance(order_no, str) and order_no.strip():
+            return order_no.strip()
+    return "-"
+
+
+def _node(lines: list[CheckLine], formula: str, label: str, problems: list[str]) -> None:
+    if problems:
+        finish(
+            lines,
+            formula=formula,
+            basis=formula,
+            calc_text=";".join(problems),
+            calc_value=None,
+            check_text=label,
+            check_value=None,
+            verdict=VERDICT_ERROR,
+            live=False,
+        )
+        return
+    finish(
+        lines,
+        formula=formula,
+        basis=formula,
+        calc_text="一致",
+        calc_value=None,
+        check_text=label,
+        check_value=None,
+        verdict="correct",
+        live=False,
+    )
+
+
+def _missing(lines: list[CheckLine], formula: str, reason: str) -> None:
+    finish(
+        lines,
+        formula=formula,
+        basis=formula,
+        calc_text=reason,
+        calc_value=None,
+        check_text=f"{ORDER_SUB_TABLE} 缺少被核对的行",
+        check_value=None,
+        verdict=VERDICT_ERROR,
+        live=False,
+    )
+
+
+def _check_text(label: str, column: str, cell: Cell, scale: int) -> str:
+    from post_clear_check.order_cell import cell_text
+
+    return cell_text(ORDER_SUB_TABLE, label, column, cell, scale)
+
+
+def _force(line: CheckLine, verdict: str) -> CheckLine:
+    from dataclasses import replace
+
+    if line.verdict == VERDICT_ERROR:
+        return line
+    return replace(line, verdict=verdict)

+ 638 - 0
code/post_clear_check/src/post_clear_check/chain.py

@@ -0,0 +1,638 @@
+"""业务链路登记表。
+
+口径见 doc/业务链路核对设计.md 第 3、4、8 节。改某条链路先改设计文档里的该条,再改这里。
+结论行在调用核对函数的地方挂链,不从正文或报表编号反推链路。
+挂链时把原来的说明挪到代入式后面,依据换成登记的三种依据之一:产品原型、计算公式、研发设计。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass, replace
+from typing import Any
+
+from post_clear_check.buy_execution import FACT_FORMULA, LIVE_FORMULA, RESULT_FORMULA
+from post_clear_check.gate import (
+    FORMULA_DAYCUT,
+    FORMULA_EARLY_REPORT,
+    FORMULA_ISSUE_BASELINE,
+    FORMULA_ISSUE_PUBLISH,
+    FORMULA_SALES_PUBLISH,
+    FORMULA_SALES_UPSTREAM,
+    FORMULA_TRS_BLOCKED,
+    FORMULA_TRS_DATE,
+    FORMULA_TRS_VERSION,
+)
+from post_clear_check.fund_chain import (
+    CANDIDATE,
+    CLEARED_SETTLE,
+    DELAYED,
+    HISTORY_UNSETTLED,
+    ISSUE_DERIVE,
+    OPENING as FUND_OPENING,
+    RAW_LEDGER,
+    SALES_SETTLE,
+    T_LEDGER,
+    T_WALLET,
+)
+from post_clear_check.issue_common import side_of
+from post_clear_check.note_chain import (
+    CLEARED_MATCH,
+    EXTENSION_DATE,
+    EXTENSION_SET,
+    OPENING,
+    PROJECTION,
+    SALES_CURRENT,
+    TOPUP_SET,
+    TOPUP_SUM,
+    YESTERDAY,
+)
+from post_clear_check.order_cell import CheckLine
+from post_clear_check.order_decimal import VERDICT_CORRECT, VERDICT_ERROR, VERDICT_SUSPECT
+
+# 归属种类。与 check_result.card_kind 的取值一致。
+OWNER_MASTER = "master_order"
+OWNER_FUND = "fund_account"
+OWNER_USER = "trs_user"
+OWNER_BATCH = "clearing_batch"
+
+# 卡片标题上的归属名称。主键值本身已经说明是编号,不再写「主订单编号」。
+OWNER_NAME = {
+    OWNER_MASTER: "主订单",
+    OWNER_FUND: "资金账户",
+    OWNER_USER: "用户账号",
+    OWNER_BATCH: "清算批次",
+}
+
+# 归属种类对应结论行上的字段。清算批次没有行上字段,由编排直接给出营业日或批次号。
+_OWNER_FIELD = {
+    OWNER_MASTER: "master_order_no",
+    OWNER_FUND: "fund_account",
+    OWNER_USER: "user_account",
+}
+
+# 链路代码,写进 check_result.chain_name。
+CHAIN_BUY = "buy_execution"
+CHAIN_SELL = "sell_execution"
+CHAIN_CASH_CHANGE = "cash_change"
+CHAIN_CASH_SETTLEMENT = "cash_settlement"
+CHAIN_ACCOUNT_ASSET = "account_asset"
+CHAIN_NOTE_HOLDING = "note_holding"
+CHAIN_TOPUP = "topup"
+CHAIN_EXTENSION = "extension"
+CHAIN_TRADE = "trade_execution"
+CHAIN_USER_ASSET = "user_asset"
+CHAIN_GATE = "check_gate"
+# 收容桶,不是业务链路。挂不上链路或没有归属键的行进这里,结论一律记错误。
+CHAIN_UNASSIGNED = "unassigned"
+
+# 报表所在的端。决定报表编号查哪一端的原型路径。
+SIDE_ISSUE = "issue"
+SIDE_SALES = "sales"
+SIDE_TRS = "trs"
+# 订单规则和门禁行没有报表编号,不查原型路径。
+SIDE_NONE = ""
+
+# 涉及行里核对门禁、未归链路的写法。
+SELF_INVOLVED = "核对工具自身"
+
+# 未归链路的公式前缀。
+UNASSIGNED_MARK = "结论行没有归属:"
+# 报表行引用的主订单在取数后仍没有主单:是核对工具取数范围的问题,不是业务错误。
+GAP_REASON_ORDER = "取数缺口:报表引用的主订单不在已读取范围"
+
+_VERDICT_LABEL = {VERDICT_CORRECT: "正确", VERDICT_ERROR: "错误", VERDICT_SUSPECT: "可疑"}
+
+# 三种依据的写法前缀。卡片上的依据只能以这三者之一开头。
+BASIS_PROTOTYPE = "产品原型 "
+BASIS_EXCEL = "共赢票据重要字段、计算公式等说明V2 工作表"
+BASIS_DESIGN = "研发设计 "
+
+# 报表行没有「字段 = 公式」时,口径名称取原型里说明报表用途的段落。各份原型写法不同,按原文登记。
+_PURPOSE_BY_SIDE = {"issue": "页面功能概述", "sales": "报表用途", "trs": "报表定位与功能"}
+_PURPOSE_OVERRIDE: Mapping[tuple[str, str], str] = {
+    ("issue", "OTC009"): "报表基本信息",
+    ("sales", "OTC001"): "报表属性说明",
+    ("sales", "OTC010"): "报表属性说明",
+    ("trs", "TRS001"): "查询条件与前置校验",
+    ("trs", "TRS010"): "查询条件与时效性",
+    ("trs", "TRS012"): "查询条件与报表特征",
+    ("trs", "TRS013"): "查询条件与时效特征",
+    ("trs", "TRS014"): "查询条件与数据补足规则",
+}
+# 代码里的报表列名与原型原文写法不同的,口径名称按原型原文。空串表示原型没有这一列,改指报表用途段落。
+_COLUMN_IN_PROTOTYPE: Mapping[tuple[str, str], Mapping[str, str]] = {
+    ("issue", "OTC003"): {"卖出总金额": "总金额(结算币种)"},
+    ("issue", "OTC007"): {"买入总金额": "总金额(结算币种)"},
+    ("issue", "OTC008"): {"名义本金(交易币种)": "名义本金", "票据面值(结算币种)": "票据面值", "买入总金额": "总金额(结算币种)"},
+    ("sales", "OTC003"): {"卖出增额金额(结算币种)": "卖出增额金额", "实现分红(结算币种)": "实现分红"},
+    ("sales", "OTC005"): {"存续名义本金(交易币种)": "存续名义本金"},
+    ("sales", "OTC007"): {"买入总金额": "总金额(结算币种)"},
+    ("sales", "OTC008"): {"买入总金额": "总金额(结算币种)"},
+    ("trs", "TRS002"): {"成交加权均价": "成交均价", "主订单的数量、价格、金额": ""},
+    ("trs", "TRS004"): {"成交金额": ""},
+    ("trs", "TRS006"): {"变动金额": "发生金额"},
+    ("trs", "TRS012"): {"变动后利率": "变更后年化融资利率"},
+}
+
+
+@dataclass(frozen=True)
+class Step:
+    """一个清算步骤。名称以三端代码里的步骤名为准。"""
+
+    system: str
+    number: int
+    name: str
+
+    def text(self) -> str:
+        return f"{self.system}清算步骤{self.number} {self.name}"
+
+
+@dataclass(frozen=True)
+class Chain:
+    """一条链路的合同:代码、中文名、归属优先顺序、涉及步骤、依据登记。
+
+    formulas 的键是公式左侧的中文字段名;公式没有左侧时用整条公式。
+    没登记的报表行按报表编号查原型路径,口径名称取公式左侧的报表列名。
+    """
+
+    code: str
+    name: str
+    owners: tuple[str, ...]
+    steps: tuple[Step, ...]
+    formulas: Mapping[str, str]
+    # 只有未归链路用:所有行的依据都指向设计文档里收容桶的定义。
+    fixed_basis: str = ""
+
+    def involved(self) -> str:
+        """飞书正文「涉及」行。没有清算步骤的写核对工具自身。
+
+        核对门禁同时有工具自身的检查和已经挂上的步骤,两段都写。
+        """
+
+        names = [step.text() for step in self.steps]
+        if self.code == CHAIN_GATE:
+            names.insert(0, SELF_INVOLVED)
+        if not names:
+            return SELF_INVOLVED
+        return ";".join(names)
+
+
+def _excel(sheet: str, row: int, name: str) -> str:
+    return f"{BASIS_EXCEL}「{sheet}」第 {row} 行「{name}」"
+
+
+def _design(path: str, section: str) -> str:
+    return f"{BASIS_DESIGN}{path} 「{section}」"
+
+
+def _prototype(path: str, *names: str) -> str:
+    return f"{BASIS_PROTOTYPE}{path} " + "".join(f"「{name}」" for name in names)
+
+
+# 清算步骤。名称以设计文档和改造方案第 5 节的全称为准。
+ISSUE_1 = Step("发行端", 1, "开启清算")
+ISSUE_2 = Step("发行端", 2, "备份清算前数据")
+ISSUE_3 = Step("发行端", 3, "订单及票据处理")
+ISSUE_4 = Step("发行端", 4, "交易交收处理")
+ISSUE_5 = Step("发行端", 5, "备份清算完成的数据、生成营业日日终数据与核心日常报表、更新T+1实时数据")
+ISSUE_6 = Step("发行端", 6, "确认交易日切换、推进营业日、票据激活、清算完成")
+SALES_1 = Step("销售端", 1, "开启清算")
+SALES_2 = Step("销售端", 2, "备份清算前数据")
+SALES_3 = Step("销售端", 3, "订单及票据处理")
+SALES_4 = Step("销售端", 4, "交易交收处理")
+SALES_5 = Step("销售端", 5, "备份清算完成的数据")
+SALES_6 = Step("销售端", 6, "更新T+1交易日的票据详情实时数据")
+SALES_7 = Step("销售端", 7, "营业日日切")
+TRS_1 = Step("TRS", 1, "开启清算与清算前备份")
+TRS_3 = Step("TRS", 3, "计算用户汇率")
+TRS_4 = Step("TRS", 4, "确认应付利息")
+TRS_5 = Step("TRS", 5, "订单与资金清算")
+TRS_6 = Step("TRS", 6, "持仓清算")
+TRS_7 = Step("TRS", 7, "生成已清算数据集")
+TRS_8 = Step("TRS", 8, "生成清算报表")
+TRS_9 = Step("TRS", 9, "确认交易日切换并推进营业日")
+TRS_10 = Step("TRS", 10, "完成清算")
+
+# 设计文档 4.13。前缀 docs/tmp/product_prototype/ 省略。
+_ISSUE_DAILY = "Note发行/报表管理(发行平台)/日常报表 (2)"
+_SALES_DAILY = "Note销售/报表管理(销售后台)/日常报表"
+_TRS_DAILY = "TRS/报表管理/TRS报表"
+REPORT_PROTOTYPES: Mapping[tuple[str, str], str] = {
+    (SIDE_ISSUE, "OTC003"): f"{_ISSUE_DAILY}/3 Share Linked Notes Trade Order Report (2)/3 Share Linked Notes Trade Order Report.md",
+    (SIDE_ISSUE, "OTC004"): f"{_ISSUE_DAILY}/4 Share Linked Notes Daily Details Report (2)/4 Share Linked Notes Daily Details Report.md",
+    (SIDE_ISSUE, "OTC005"): f"{_ISSUE_DAILY}/5 Share Linked Notes Real Time Details Report (2)/5 Share Linked Notes Real Time Details Report.md",
+    (SIDE_ISSUE, "OTC007"): f"{_ISSUE_DAILY}/7 Notes Trade Order Settlement Report (2)/7 Notes Trade Order Settlement Report.md",
+    (SIDE_ISSUE, "OTC008"): f"{_ISSUE_DAILY}/8 Notes Trade Order Non-Settlement Report (2)/8 Notes Trade Order Non-Settlement Report.md",
+    (SIDE_ISSUE, "OTC009"): f"{_ISSUE_DAILY}/9 Share Linked Notes Top-up payment Report (2)/9 Share Linked Notes Top-up payment Report.md",
+    (SIDE_ISSUE, "OTC012"): f"{_ISSUE_DAILY}/12 Share Linked Notes Extension Fee Report (2)/12 Share Linked Notes Extension Fee Report.md",
+    (SIDE_SALES, "OTC001"): f"{_SALES_DAILY}/1 Account Asset Report/1 Account Asset Report.md",
+    (SIDE_SALES, "OTC002"): f"{_SALES_DAILY}/2 Cash Change Report/2 Cash Change Report.md",
+    (SIDE_SALES, "OTC003"): f"{_SALES_DAILY}/3 Share Linked Notes Trade Order Report/3 Share Linked Notes Trade Order Report.md",
+    (SIDE_SALES, "OTC004"): f"{_SALES_DAILY}/4 Share Linked Notes Daily Details Report/4 Share Linked Notes Daily Details Report.md",
+    (SIDE_SALES, "OTC005"): f"{_SALES_DAILY}/5 Share Linked Notes Real Time Details Report/5 Share Linked Notes Real Time Details Report.md",
+    (SIDE_SALES, "OTC007"): f"{_SALES_DAILY}/7 Notes Trade Order Settlement Report/7 Notes Trade Order Settlement Report.md",
+    (SIDE_SALES, "OTC008"): f"{_SALES_DAILY}/8 Notes Trade Order Non-Settlement Report/8 Notes Trade Order Non-Settlement Report.md",
+    (SIDE_SALES, "OTC009"): f"{_SALES_DAILY}/9 Share Linked Notes Top-up payment Report/9 Share Linked Notes Top-up payment Report.md",
+    (SIDE_SALES, "OTC010"): f"{_SALES_DAILY}/10 Cash Settlement Report/10 Cash Settlement Report.md",
+    (SIDE_SALES, "OTC012"): f"{_SALES_DAILY}/12 Share Linked Notes Extension Fee Report/12 Share Linked Notes Extension Fee Report.md",
+    (SIDE_TRS, "TRS001"): f"{_TRS_DAILY}/1 Valuable Capital Limited TRS Report/1 Valuable Capital Limited TRS Report.md",
+    (SIDE_TRS, "TRS002"): f"{_TRS_DAILY}/2 Master Transaction Order Report/2 Master Transaction Order Report.md",
+    (SIDE_TRS, "TRS003"): f"{_TRS_DAILY}/3 Sub Transaction Order Report/3 Sub Transaction Order Report.md",
+    (SIDE_TRS, "TRS004"): f"{_TRS_DAILY}/4 Details Transaction Order Report/4 Details Transaction Order Report.md",
+    (SIDE_TRS, "TRS005"): f"{_TRS_DAILY}/5 Stock Holdings Report/5 Stock Holdings Report.md",
+    (SIDE_TRS, "TRS006"): f"{_TRS_DAILY}/6 Available Amount Change Report/6 Available Amount Change Report.md",
+    (SIDE_TRS, "TRS007"): f"{_TRS_DAILY}/7 Interest procedural statement/7 Interest procedural statement.md",
+    # 目录名里的 P/L 在原型目录里被拆成了两级。
+    (SIDE_TRS, "TRS008"): f"{_TRS_DAILY}/8 Stock Realized P/Report/8 Stock Realized P/Report.md",
+    (SIDE_TRS, "TRS009"): f"{_TRS_DAILY}/9 Fund-in out Report/9 Fund-in out Report.md",
+    (SIDE_TRS, "TRS010"): f"{_TRS_DAILY}/10 Client Balance Summary/10 Client Balance Summary.md",
+    (SIDE_TRS, "TRS012"): f"{_TRS_DAILY}/12 Margin rate change statement/12 Margin rate change statement.md",
+    (SIDE_TRS, "TRS013"): f"{_TRS_DAILY}/13 Detailed statement of interest payable/13 Detailed statement of interest payable.md",
+    (SIDE_TRS, "TRS014"): f"{_TRS_DAILY}/14 Daily Net Asset Report/14 Daily Net Asset Report.md",
+    (SIDE_TRS, "TRS016"): f"{_TRS_DAILY}/16 Holdings Amount Interest Report/16 Holdings Amount Interest Report.md",
+}
+
+# TRS 字段公式不在 Excel 里,在这份原型的两节表格里。
+_TRS_FORMULA_MD = "TRS/4 业务引起数据变化及计算公式/4 业务引起数据变化及计算公式.md"
+_TRS_KEY_FIELDS = "重点字段与计算公式"
+_TRS_OPERATION = "用户交易或后台操作引起的字段变化及计算公式"
+_TRS_POSITION = "持仓与交易引起的字段变化及计算公式"
+
+_CLEARING_PLAN = "docs/清算/三端清算最终实施方案.md"
+_STEP_PLAN = "docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md"
+_USAGE = "docs/tmp/data_check_tool/doc/使用说明.md"
+_CHAIN_DESIGN = "docs/tmp/data_check_tool/doc/业务链路核对设计.md"
+
+# 订单规则与门禁的公式原文。这些公式没有「字段 = 」左侧,按整条登记。
+FX_FORMULA = "结算汇率由累计发生金额(交易币种)与累计发生金额(结算币种)反算"
+GATE_MISSING_TABLE = "业务表应存在"
+
+# 对冲执行根不在 Excel 里,口径在研发设计 5.4。
+_HEDGE_ROOT = _design(_CLEARING_PLAN, "5.4 TRS 核对、订单和交收计算")
+
+_BUY_FORMULAS: Mapping[str, str] = {
+    "对冲记录成交股数": _HEDGE_ROOT,
+    "累计成交股数": _excel("买入订单", 14, "累计成交股数"),
+    "对冲含费成交均价": _excel("买入订单", 15, "对冲含费成交均价"),
+    "累计发生金额(交易币种)": _excel("买入订单", 17, "累计发生金额(交易币种)"),
+    "累计发生金额(结算币种)": _excel("买入订单", 18, "累计发生金额(结算币种)"),
+    FX_FORMULA: _excel("买入订单", 19, "结算汇率"),
+    "执行订单名义本金(结算币种)": _excel("买入订单", 21, "执行订单名义本金(结算币种)"),
+    "订单成交均价": _excel("买入订单", 22, "订单成交均价"),
+    "订单买入股数": _excel("买入订单", 23, "订单买入股数"),
+    "票据面值": _excel("买入订单", 24, "票据面值"),
+    "发行费用": _excel("买入订单", 25, "发行费用"),
+    "票据发行价": _excel("买入订单", 26, "票据发行价"),
+    FACT_FORMULA: _design(_STEP_PLAN, "5.1 发行端(6 步)"),
+    RESULT_FORMULA: _design(_STEP_PLAN, "5.1 发行端(6 步)"),
+    LIVE_FORMULA: _design(_STEP_PLAN, "5.1 发行端(6 步)"),
+}
+
+_SELL_FORMULAS: Mapping[str, str] = {
+    "对冲记录成交股数": _HEDGE_ROOT,
+    "累计成交股数": _excel("卖出订单", 12, "累计成交股数"),
+    "对冲含费成交均价": _excel("卖出订单", 13, "对冲含费成交均价"),
+    "累计发生金额(交易币种)": _excel("卖出订单", 14, "累计发生金额(交易币种)"),
+    "累计发生金额(结算币种)": _excel("卖出订单", 15, "累计发生金额(结算币种)"),
+    FX_FORMULA: _excel("卖出订单", 16, "结算汇率"),
+    "执行名义本金(交易币种)": _excel("卖出订单", 17, "执行名义本金(交易币种)"),
+    "订单成交均价": _excel("卖出订单", 18, "订单成交均价"),
+    "订单卖出股数": _excel("卖出订单", 19, "订单卖出股数"),
+    "卖出票据面值": _excel("卖出订单", 21, "卖出票据面值"),
+    "卖出增额金额": _excel("卖出订单", 22, "卖出增额金额"),
+    "实现分红": _excel("卖出订单", 23, "实现分红"),
+    "底层资产盈亏": _excel("卖出订单", 24, "底层资产盈亏"),
+    "总金额": _excel("卖出订单", 25, "总金额"),
+    "最终盈亏": _excel("卖出订单", 26, "最终盈亏"),
+    FACT_FORMULA: _design(_STEP_PLAN, "5.1 发行端(6 步)"),
+    RESULT_FORMULA: _design(_STEP_PLAN, "5.1 发行端(6 步)"),
+    LIVE_FORMULA: _design(_STEP_PLAN, "5.1 发行端(6 步)"),
+}
+
+# 账户资产只用 Excel「资金账户钱包信息」的两条等式。其余列按 OTC001 原型。
+_ASSET_FORMULAS: Mapping[str, str] = {
+    "现金余额": _excel("资金账户钱包信息", 3, "现金余额"),
+    "现金可用": _excel("资金账户钱包信息", 12, "现金可用"),
+    FUND_OPENING: _design(_STEP_PLAN, "5.2 销售端(7 步)"),
+    T_WALLET: _design(_STEP_PLAN, "5.2 销售端(7 步)"),
+    CANDIDATE: _design(_STEP_PLAN, "5.2 销售端(7 步)"),
+}
+
+_CASH_FORMULAS: Mapping[str, str] = {
+    RAW_LEDGER: _design(_STEP_PLAN, "5.2 销售端(7 步)"),
+    T_LEDGER: _design(_STEP_PLAN, "5.2 销售端(7 步)"),
+    DELAYED: _design(_STEP_PLAN, "5.2 销售端(7 步)"),
+}
+
+_SETTLEMENT_FORMULAS: Mapping[str, str] = {
+    ISSUE_DERIVE: _design(_STEP_PLAN, "5.1 发行端(6 步)"),
+    CLEARED_SETTLE: _design(_STEP_PLAN, "5.1 发行端(6 步)"),
+    HISTORY_UNSETTLED: _design(_STEP_PLAN, "5.2 销售端(7 步)"),
+    SALES_SETTLE: _design(_STEP_PLAN, "5.2 销售端(7 步)"),
+}
+
+# 两端 OTC004、OTC005 的列名不同,按各自列名登记同一行 Excel。
+_NOTE_FORMULAS: Mapping[str, str] = {
+    "当前股票数量": _excel("票据", 6, "票据股票数量"),
+    "当前票据面值(结算币种)": _excel("票据", 9, "票据面值"),
+    "当前票面面值(结算币种)": _excel("票据", 9, "票据面值"),
+    "票据发行价(结算币种)": _excel("票据", 10, "票据发行价"),
+    "增额金额(结算币种)": _excel("票据", 14, "增额金额"),
+    "分红金额": _excel("票据", 15, "分红金额"),
+    "分红金额(结算币种)": _excel("票据", 15, "分红金额"),
+    "票据估值(结算币种)": _excel("票据", 23, "A类票据估值(结算币种)"),
+    "浮动盈亏(结算币种)": _excel("票据", 24, "A类票据浮动盈亏"),
+    "票据价格/净值": _excel("票据", 26, "A类票据净值"),
+    "票据价格": _excel("票据", 26, "A类票据净值"),
+    "已卖出面值": _excel("票据", 27, "已卖出票据面值"),
+    "已卖出票据(结算币种)": _excel("票据", 27, "已卖出票据面值"),
+    "已卖出底层资产盈亏": _excel("票据", 28, "已卖出底层资产盈亏"),
+    "已卖出底仓资产盈亏(结算币种)": _excel("票据", 28, "已卖出底层资产盈亏"),
+    "合计卖出金额": _excel("票据", 29, "合计卖出金额"),
+    "合计卖出金额(结算币种)": _excel("票据", 29, "合计卖出金额"),
+    OPENING: _design(_STEP_PLAN, "5.1 发行端(6 步)"),
+    CLEARED_MATCH: _design(_STEP_PLAN, "5.1 发行端(6 步)"),
+    PROJECTION: _design(_STEP_PLAN, "5.1 发行端(6 步)"),
+    YESTERDAY: _design(_STEP_PLAN, "5.1 发行端(6 步)"),
+    SALES_CURRENT: _design(_STEP_PLAN, "5.2 销售端(7 步)"),
+}
+
+_TOPUP_FORMULAS: Mapping[str, str] = {
+    TOPUP_SET: _design(_STEP_PLAN, "5.1 发行端(6 步)"),
+    TOPUP_SUM: _design(_STEP_PLAN, "5.1 发行端(6 步)"),
+}
+
+_EXTENSION_FORMULAS: Mapping[str, str] = {
+    EXTENSION_SET: _design(_STEP_PLAN, "5.1 发行端(6 步)"),
+    EXTENSION_DATE: _design(_STEP_PLAN, "5.1 发行端(6 步)"),
+}
+
+# 该节只有股票持仓数量和股票成本价,没有持仓成本金额;持仓成本金额按 TRS005 原型的报表列。
+_TRADE_FORMULAS: Mapping[str, str] = {
+    "持仓数量": _prototype(_TRS_FORMULA_MD, _TRS_POSITION, "股票持仓数量"),
+    "封存成交": _design(_STEP_PLAN, "5.3 TRS(10 步)"),
+    "T日委托": _design(_STEP_PLAN, "5.3 TRS(10 步)"),
+    "持仓行": _design(_STEP_PLAN, "5.3 TRS(10 步)"),
+    "候选成交": _design(_STEP_PLAN, "5.3 TRS(10 步)"),
+}
+
+# 授信、信用四项本期不做,净资产不单造公式,仍指向原型的净资产定义。
+_USER_ASSET_FORMULAS: Mapping[str, str] = {
+    "基础总资产": _prototype(_TRS_FORMULA_MD, _TRS_KEY_FIELDS, "基础总资产"),
+    "总资产": _prototype(_TRS_FORMULA_MD, _TRS_KEY_FIELDS, "总资产"),
+    "净资产": _prototype(_TRS_FORMULA_MD, _TRS_KEY_FIELDS, "净资产"),
+    "当日计息金额": _prototype(_TRS_FORMULA_MD, _TRS_KEY_FIELDS, "应付利息"),
+    "杠杆倍数": _prototype(_TRS_FORMULA_MD, _TRS_OPERATION, "融资金额"),
+    "封存资金": _design(_STEP_PLAN, "5.3 TRS(10 步)"),
+    "封存汇率": _design(_STEP_PLAN, "5.3 TRS(10 步)"),
+    "T日债务": _design(_STEP_PLAN, "5.3 TRS(10 步)"),
+    "汇率调整": _design(_STEP_PLAN, "5.3 TRS(10 步)"),
+    "资金账户": _design(_STEP_PLAN, "5.3 TRS(10 步)"),
+    "候选资金": _design(_STEP_PLAN, "5.3 TRS(10 步)"),
+}
+
+_GATE_FORMULAS: Mapping[str, str] = {
+    FORMULA_EARLY_REPORT: _design(_CLEARING_PLAN, "9. 报表与日切最终口径"),
+    FORMULA_TRS_BLOCKED: _design(_USAGE, "一轮里做什么"),
+    GATE_MISSING_TABLE: _design(_USAGE, "一轮里做什么"),
+    FORMULA_ISSUE_BASELINE: _design(_STEP_PLAN, "5.1 发行端(6 步)"),
+    FORMULA_ISSUE_PUBLISH: _design(_STEP_PLAN, "5.1 发行端(6 步)"),
+    FORMULA_SALES_UPSTREAM: _design(_STEP_PLAN, "5.2 销售端(7 步)"),
+    FORMULA_SALES_PUBLISH: _design(_STEP_PLAN, "5.2 销售端(7 步)"),
+    FORMULA_TRS_DATE: _design(_STEP_PLAN, "5.3 TRS(10 步)"),
+    FORMULA_TRS_VERSION: _design(_STEP_PLAN, "5.3 TRS(10 步)"),
+    FORMULA_DAYCUT: _design(_STEP_PLAN, "6. 现有工具会读错的三处"),
+}
+
+UNASSIGNED_BASIS = _design(_CHAIN_DESIGN, "4.1 总表")
+
+_EXECUTION_STEPS = (ISSUE_3, ISSUE_5, SALES_3, SALES_7, TRS_5)
+# 买入和卖出都挂发行端第 2 步。公式仍分开。
+_ORDER_STEPS = (ISSUE_2, *_EXECUTION_STEPS)
+
+# 设计文档 4.1 总表的顺序。卡片排序也按这个顺序。
+CHAINS: Mapping[str, Chain] = {
+    chain.code: chain
+    for chain in (
+        Chain(CHAIN_BUY, "买入执行", (OWNER_MASTER,), _ORDER_STEPS, _BUY_FORMULAS),
+        Chain(CHAIN_SELL, "卖出执行", (OWNER_MASTER,), _ORDER_STEPS, _SELL_FORMULAS),
+        Chain(
+            CHAIN_CASH_CHANGE,
+            "资金变动",
+            (OWNER_FUND,),
+            (SALES_2, SALES_3, SALES_4, SALES_6, SALES_7),
+            _CASH_FORMULAS,
+        ),
+        Chain(
+            CHAIN_CASH_SETTLEMENT,
+            "资金交收",
+            (OWNER_FUND,),
+            (ISSUE_4, ISSUE_5, SALES_2, SALES_3, SALES_4, SALES_7),
+            _SETTLEMENT_FORMULAS,
+        ),
+        Chain(
+            CHAIN_ACCOUNT_ASSET,
+            "账户资产",
+            (OWNER_FUND,),
+            (SALES_2, SALES_3, SALES_4, SALES_5, SALES_6, SALES_7),
+            _ASSET_FORMULAS,
+        ),
+        Chain(
+            CHAIN_NOTE_HOLDING,
+            "票据存续",
+            (OWNER_FUND,),
+            (ISSUE_2, ISSUE_3, ISSUE_5, SALES_3, SALES_6, SALES_7),
+            _NOTE_FORMULAS,
+        ),
+        Chain(CHAIN_TOPUP, "增额", (OWNER_FUND,), (ISSUE_2, ISSUE_3), _TOPUP_FORMULAS),
+        Chain(CHAIN_EXTENSION, "展期", (OWNER_FUND,), (ISSUE_2, ISSUE_3), _EXTENSION_FORMULAS),
+        Chain(
+            CHAIN_TRADE,
+            "委托执行",
+            (OWNER_MASTER, OWNER_USER),
+            (TRS_1, TRS_5, TRS_6, TRS_7, TRS_8),
+            _TRADE_FORMULAS,
+        ),
+        Chain(
+            CHAIN_USER_ASSET,
+            "用户资产",
+            (OWNER_USER,),
+            (TRS_1, TRS_3, TRS_4, TRS_5, TRS_6, TRS_7, TRS_8),
+            _USER_ASSET_FORMULAS,
+        ),
+        Chain(
+            CHAIN_GATE,
+            "核对门禁",
+            (OWNER_BATCH,),
+            (ISSUE_1, ISSUE_6, SALES_1, SALES_7, TRS_9, TRS_10),
+            _GATE_FORMULAS,
+        ),
+        Chain(CHAIN_UNASSIGNED, "未归链路", (OWNER_BATCH,), (), {}, fixed_basis=UNASSIGNED_BASIS),
+    )
+}
+CHAIN_ORDER = {code: index for index, code in enumerate(CHAINS)}
+
+
+def left_name(formula: str) -> str | None:
+    """公式左侧的中文字段名。没有「 = 」时取「:」前的部分;两者都没有返回空。"""
+
+    for mark in (" = ", ":"):
+        if mark in formula:
+            head = formula.split(mark, 1)[0].strip()
+            if head:
+                return head
+    return None
+
+
+def resolve_basis(chain_code: str, side: str, line: CheckLine) -> str | None:
+    """按链路登记查依据。先查公式左侧字段名,再按报表编号查原型路径。查不到返回空。"""
+
+    chain = CHAINS.get(chain_code)
+    if chain is None:
+        return None
+    if chain.fixed_basis:
+        return chain.fixed_basis
+    left = left_name(line.formula_text)
+    found = chain.formulas.get(left or line.formula_text)
+    if found:
+        return found
+    key = (side, line.report_code)
+    path = REPORT_PROTOTYPES.get(key) if line.report_code else None
+    if path is None:
+        return None
+    # 报表列的复制和存在性核对:口径名称取原型里的报表列名,没有列名时取报表用途段落。
+    name = _COLUMN_IN_PROTOTYPE.get(key, {}).get(left, left) if left else None
+    if not name:
+        name = _PURPOSE_OVERRIDE.get(key) or _PURPOSE_BY_SIDE[side]
+    return _prototype(path, name)
+
+
+def hang(lines: Sequence[CheckLine], chain_code: str, side: str) -> list[CheckLine]:
+    """给一段结论行挂链。原说明挪到代入式后面,依据换成登记的依据。查不到登记的进未归链路。"""
+
+    chain = CHAINS[chain_code]
+    found: list[CheckLine] = []
+    for line in lines:
+        basis = resolve_basis(chain_code, side, line)
+        if basis is None:
+            found.append(_unregistered(line, chain))
+            continue
+        found.append(
+            replace(
+                line,
+                chain_name=chain_code,
+                basis=basis,
+                calc_text=_with_note(line.calc_text, _old_note(line)),
+            )
+        )
+    return found
+
+
+def hang_by_side(
+    lines: Sequence[CheckLine],
+    orders: Mapping[str, Any],
+    side: str,
+) -> list[CheckLine]:
+    """按主订单方向挂到买入执行或卖出执行。强平、到期走卖出。方向取不到的进未归链路。
+
+    未归原因分开写:读到了主单但方向不是买卖,是业务原因;行上引用的主订单没有读到主单,是工具取数缺口。
+    """
+
+    found: list[CheckLine] = []
+    for line in lines:
+        bucket = orders.get(line.master_order_no) if line.master_order_no else None
+        code = order_chain(bucket)
+        if code is None:
+            found.append(unassign(line, _side_gap_reason(line, bucket)))
+            continue
+        found.extend(hang((line,), code, side))
+    return found
+
+
+def _side_gap_reason(line: CheckLine, bucket: object) -> str:
+    """挂不上买入、卖出执行的原因。"""
+
+    if not line.master_order_no:
+        return "结论行没有主订单编号"
+    master = bucket.get("master") if isinstance(bucket, Mapping) else None
+    if not isinstance(master, Mapping):
+        return GAP_REASON_ORDER
+    return "主订单方向取不到"
+
+
+def order_chain(bucket: object) -> str | None:
+    """取数包里一张主单的链路。没有主单行或方向不是买卖时返回空,不猜。"""
+
+    if not isinstance(bucket, Mapping):
+        return None
+    master = bucket.get("master")
+    if not isinstance(master, Mapping):
+        return None
+    side = side_of(master.get("direction"), master.get("op_type"))
+    if side == "buy":
+        return CHAIN_BUY
+    if side == "sell":
+        return CHAIN_SELL
+    return None
+
+
+def owner_of(line: CheckLine) -> tuple[str, str] | None:
+    """按链路登记的归属优先顺序取第一个非空键。清算批次不在行上,返回空由编排处理。"""
+
+    chain = CHAINS.get(line.chain_name)
+    if chain is None:
+        return None
+    for kind in chain.owners:
+        field = _OWNER_FIELD.get(kind)
+        if field is None:
+            continue
+        value = getattr(line, field)
+        if value:
+            return kind, value
+    return None
+
+
+def unassign(line: CheckLine, reason: str) -> CheckLine:
+    """挂不上链路的行进未归链路。结论改为错误,原因、原结论和原依据写进代入式后面。"""
+
+    if line.chain_name == CHAIN_UNASSIGNED:
+        return line
+    label = _VERDICT_LABEL.get(line.verdict, line.verdict)
+    note = f"{reason},原结论为{label}"
+    if line.basis:
+        note = f"{note},原依据:{line.basis}"
+    formula = line.formula_text
+    if not formula.startswith(UNASSIGNED_MARK):
+        formula = f"{UNASSIGNED_MARK}{formula}"
+    return replace(
+        line,
+        chain_name=CHAIN_UNASSIGNED,
+        formula_text=formula,
+        calc_text=_with_note(line.calc_text, note),
+        basis=UNASSIGNED_BASIS,
+        verdict=VERDICT_ERROR,
+    )
+
+
+def _unregistered(line: CheckLine, chain: Chain) -> CheckLine:
+    """依据没有登记。运行期不编一个依据,整行进未归链路记错误;测试里由守卫直接判失败。"""
+
+    return unassign(line, f"{chain.name}没有登记这条公式的依据")
+
+
+def _old_note(line: CheckLine) -> str:
+    """原 basis 文字不是三种依据之一,作说明保留。与公式相同的不重复。"""
+
+    text = line.basis.strip()
+    if not text or text == line.formula_text.strip():
+        return ""
+    return text
+
+
+def _with_note(calc_text: str, note: str) -> str:
+    if not note or note in calc_text:
+        return calc_text
+    if not calc_text:
+        return note
+    return f"{calc_text};{note}"

+ 1540 - 0
code/post_clear_check/src/post_clear_check/columns.py

@@ -0,0 +1,1540 @@
+"""列清单。名字来自本地库的 SHOW,运行时不再读 DDL。"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+
+# 表名、列名只允许这些字符,拼进语句前再验一次。
+IDENT = re.compile(r"^[A-Za-z0-9_]+$")
+
+ORDER_MASTER_TABLE = "db_issue.order_master"
+ORDER_SUB_TABLE = "db_issue.order_sub_a"
+HEDGE_TABLE = "db_issue.hedge_record"
+NOTE_MASTER_TABLE = "db_issue.note_master"
+NOTE_SUB_TABLE = "db_issue.note_sub_a"
+SETTLEMENT_TABLE = "db_issue.settlement_ledger"
+TRADE_ORDER_TABLE = "db_trs.trade_order"
+TRADE_ALGO_MASTER_TABLE = "db_trs.trade_algo_master"
+TRADE_USER_TABLE = "db_trs.trade_user"
+FUND_ACCOUNT_TABLE = "db_sales.fund_account"
+FUND_LEDGER_TABLE = "db_sales.fund_ledger"
+BACKUP_TABLE = "db_issue_clear.clearing_table_backup"
+
+# 施工包写的是 db_issue.clearing_writeback_log。实库这张表在 db_issue_clear。
+# 没有单独的「被改列」字段,前后值在 before_val、after_val 的 JSON 里。entity_type 是被改的表类型。
+WRITEBACK_TABLE = "db_issue_clear.clearing_writeback_log"
+
+COPY_DATABASE = "db_issue_clear"
+LIVE_DATABASE = "db_issue"
+# 订单和票据优先用这些逻辑表的 cleared 副本。
+COPY_LOGICAL_TABLES = ("order_master", "order_sub_a", "note_master", "note_sub_a")
+
+# 实库 backup_stage 的取值是 pre_work、pre_hold、redo_hold、cleared。读到什么就原样返回。
+CLEARED_STAGE = "cleared"
+AVAILABLE_STATUS = "available"
+LIVE_SOURCE = "live"
+
+HR_PREFIX = "HR"
+ORDER_NO_LIKE = "ORD%"
+ORDER_STATUSES = ("completed", "settled")
+NOT_DELETED = 0
+ACTIVE_ROW = "active"
+
+# 主单列,顺序与施工包一致。市场列是 sec_market。
+ORDER_MASTER_COLUMNS = (
+    "id",
+    "order_no",
+    "note_no",
+    "investor_ref",
+    "investor_account",
+    "fund_account_ref",
+    "sec_code",
+    "sec_market",
+    "direction",
+    "op_type",
+    "status",
+    "trade_date",
+    "business_date",
+    "settle_ccy",
+    "trade_ccy",
+)
+
+ORDER_SUB_COLUMNS = (
+    "id",
+    "order_id",
+    "sub_order_no",
+    "cum_filled_qty",
+    "avg_px_with_fee",
+    "cum_amt_trade",
+    "cum_amt_settle",
+    "fx_settle",
+    "exec_notional_trade",
+    "exec_notional_settle",
+    "order_avg_px",
+    "a_quote_ratio",
+    "order_avg_px_manual_flag",
+    "order_qty",
+    "face_value_settle",
+    "issue_fee_settle",
+    "issue_price_settle",
+    "exec_face_value_settle",
+    "exec_issue_fee_settle",
+    "exec_issue_price_settle",
+    "exec_total_amt_settle",
+    "sold_face_value",
+    "sold_add_amt",
+    "realized_dividend",
+    "underlying_pnl",
+    "final_pnl",
+)
+
+# 买入执行读 input 对冲和封存发行费。列与源表一致,归档表多出的范围列不在这里。
+HEDGE_ARCHIVE_TABLE = "db_issue_clear.clr_hedge_record"
+# 发行第 3 步以 TRS 为准覆盖对冲发生金额。汇率列不作为反推输入。
+TRS_RECON_TABLE = "db_issue_clear.clearing_trs_result"
+TRS_RECON_COLUMNS = ("hedge_record_id", "amt_trade", "amt_settle")
+FEE_ARCHIVE_TABLE = "db_issue_clear.clr_fee_issue_standard"
+FEE_STANDARD_COLUMNS = (
+    "id",
+    "category",
+    "fee_type",
+    "charge_node",
+    "charge_strategy",
+    "settle_ccy",
+    "fee_value",
+    "min_fee",
+    "max_fee",
+)
+
+HEDGE_COLUMNS = (
+    "id",
+    "src_order_no",
+    "sub_type",
+    "hedge_system",
+    "hedge_account_no",
+    "order_type",
+    "trs_entrust_no",
+    "trs_algo_no",
+    "status",
+    "filled_qty",
+    "fee_amt",
+    "amt_trade",
+    "amt_settle",
+)
+
+# 成交股数是 filled_qty。实库没有名为成交金额的列,金额用 amount_trade、amount_settle。
+# user_id 用来定位 TRS 用户,账号在 trade_user.account。
+TRADE_ORDER_COLUMNS = (
+    "id",
+    "ext_ref",
+    "entrust_no",
+    "user_id",
+    "price",
+    "entrust_qty",
+    "parent_order_id",
+    "algo_child_id",
+    "filled_qty",
+    "filled_avg_px",
+    "amount_trade",
+    "amount_settle",
+)
+
+# 算法母单才带 HR 幂等键。子单委托的 ext_ref 为空,成交股数以母单 filled_qty 为准。
+TRADE_ALGO_MASTER_COLUMNS = (
+    "id",
+    "algo_no",
+    "ext_ref",
+    "user_id",
+    "price",
+    "master_qty",
+    "filled_qty",
+    "filled_avg_px",
+    "filled_amount",
+    "amount_trade",
+    "amount_settle",
+)
+
+# 成交回报没有用户列,按委托编号回挂到用户。报表列名与这里不同,读入时改名。
+TRADE_ALGO_CHILD_TABLE = "db_trs.trade_algo_child"
+TRADE_ALGO_CHILD_COLUMNS = (
+    "id",
+    "algo_master_id",
+    "algo_no",
+    "child_clordid",
+)
+# 销售端资金流水是 db_sales.fund_ledger。这张是 TRS 用户的可用金额流水,TRS006 按主键对金额。
+TRS_FUND_LEDGER_TABLE = "db_trs.fund_ledger"
+TRS_FUND_LEDGER_COLUMNS = (
+    "id",
+    "user_id",
+    "amount",
+    "business_date",
+)
+TRADE_FILL_TABLE = "db_trs.trade_fill"
+TRADE_FILL_COLUMNS = (
+    "id",
+    "fill_no",
+    "entrust_no",
+    "fill_qty",
+    "fill_px",
+    "fill_amount",
+)
+
+TRADE_USER_COLUMNS = (
+    "id",
+    "account",
+)
+
+NOTE_MASTER_COLUMNS = (
+    "id",
+    "note_no",
+    "sec_code",
+    "sec_market",
+    "status",
+    "maturity_date",
+)
+
+# 当前票据切片:名义本金 cur_notional_trade,股票数量 cur_qty,面值 cur_face_value,
+# 增额 add_amt,分红 dividend_amt,期初价格 cur_px,初始汇率 init_fx,收益比例 a_yield_ratio。
+NOTE_SUB_COLUMNS = (
+    "id",
+    "sub_note_no",
+    "note_id",
+    "note_no",
+    "src_order_no",
+    "fund_account_ref",
+    "cur_notional_trade",
+    # 全部卖出后当前名义本金和当前面值被扣成 0,比例和卖出面值改用卖出前留存的初始值。
+    "init_notional_trade",
+    "cur_qty",
+    "cur_face_value",
+    "init_face_value",
+    "add_amt",
+    "dividend_amt",
+    "cur_px",
+    "init_fx",
+    "a_yield_ratio",
+    # 第 2 步新票据的期初用初始数量和初始价格,状态用来区分待生效。
+    "init_qty",
+    "init_px",
+    "note_status",
+)
+
+# 增额、展期只读本批 input 冻结的事实,不读实时余额当起点。
+TOPUP_ARCHIVE_TABLE = "db_issue_clear.clr_note_topup_log"
+TOPUP_LIVE_TABLE = "db_issue.note_topup_log"
+TOPUP_COLUMNS = (
+    "id",
+    "note_id",
+    "note_no",
+    "a_sub_note_no",
+    "change_type",
+    "change_amt",
+    "business_date",
+)
+EXTENSION_ARCHIVE_TABLE = "db_issue_clear.clr_extension_task"
+EXTENSION_LIVE_TABLE = "db_issue.extension_task"
+EXTENSION_COLUMNS = (
+    "id",
+    "note_id",
+    "note_no",
+    "a_sub_note_no",
+    "fund_account_ref",
+    "status",
+    "approve_after_maturity",
+    "approve_business_date",
+)
+# 当前投影和销售版本比对用实时票据,不拿它当 T 日结果。
+LIVE_NOTE_SUB_TABLE = "db_issue.note_sub_a"
+LIVE_NOTE_MASTER_TABLE = "db_issue.note_master"
+LIVE_NOTE_SUB_COLUMNS = (
+    "id",
+    "sub_note_no",
+    "note_id",
+    "note_no",
+    "fund_account_ref",
+    "cur_notional_trade",
+    "cur_qty",
+    "cur_face_value",
+    "add_amt",
+    "dividend_amt",
+    "note_status",
+    "sync_version",
+)
+LIVE_NOTE_MASTER_COLUMNS = (
+    "id",
+    "note_no",
+    "status",
+    "maturity_date",
+)
+# 昨基线在对冲持仓上。cleared 的日终列滚入实时 yesterday_*。
+LIVE_HEDGE_POSITION_TABLE = "db_issue.hedge_position"
+HEDGE_BASELINE_COLUMNS = (
+    "id",
+    "note_id",
+    "note_no",
+    "position_qty",
+    "position_cost_settle",
+    "unsettled_realized_pnl",
+    "settled_realized_pnl",
+    "realized_dividend",
+    "yesterday_position_qty",
+    "yesterday_cost_settle",
+    "yesterday_unsettled_realized_pnl",
+    "yesterday_settled_realized_pnl",
+    "yesterday_realized_dividend",
+)
+SALES_NOTE_VIEW_TABLE = "db_sales.note_view"
+SALES_NOTE_CLEARED_TABLE = "db_sales.clr_note_view_cleared"
+SALES_NOTE_VIEW_COLUMNS = (
+    "sub_note_no",
+    "note_no",
+    "sync_version",
+    "cur_notional_trade",
+    "cur_qty",
+    "cur_face_value",
+    "add_amt",
+    "dividend_amt",
+    "maturity_date",
+    "note_status",
+)
+
+# actual_settle_date 是实际交收日。
+SETTLEMENT_COLUMNS = (
+    "id",
+    "source_batch_id",
+    "order_no",
+    "sub_order_id",
+    "sub_order_no",
+    "actual_settle_date",
+    "status",
+    "settle_ccy",
+    "settle_amount",
+    "trade_date",
+    "expect_settle_date",
+    "direction",
+    "settled_business_date",
+)
+# 发行交收的 T 日结果在固定归档,不读实时台账。
+ISSUE_SETTLEMENT_ARCHIVE = "db_issue_clear.clr_settlement_ledger"
+
+BACKUP_COLUMNS = (
+    "batch_id",
+    "backup_stage",
+    "backup_no",
+    "source_table",
+    "backup_table",
+    "status",
+)
+
+WRITEBACK_COLUMNS = (
+    "batch_id",
+    "entity_type",
+    "biz_key",
+    "before_val",
+    "after_val",
+)
+
+FUND_ACCOUNT_COLUMNS = (
+    "id",
+    "fund_account_no",
+    "investor_id",
+)
+
+# 流水编号用主键 id。关联主订单的列是 ref_no,注释为关联订单、票据或出入金编号。
+# source_entity_type、source_biz_key 只作为结构化来源带回,不拿来冒充订单号。
+FUND_LEDGER_COLUMNS = (
+    "id",
+    "fund_account_id",
+    "business_type",
+    "amount",
+    "ccy",
+    "business_date",
+    "ref_no",
+    "source_entity_type",
+    "source_biz_key",
+)
+FUND_LEDGER_ORDER_COLUMN = "ref_no"
+
+# 销售端营业日日切的资金账户日终版本。一行是「本批 × 资金账户 × 币种」,科目桶都是正数。
+# 买入待交收在钱包上是正数,OTC001 报表展示为负值,比较时由规则换号。
+DAYCUT_FUND_EOD_TABLE = "db_sales.daycut_fund_eod"
+DAYCUT_FUND_EOD_COLUMNS = (
+    "id",
+    "clearing_batch_id",
+    "fund_account_id",
+    "ccy",
+    "cash_settled",
+    "cash_buy_unsettled",
+    "cash_sell_unsettled",
+    "buy_today",
+    "sell_today",
+    "cash_balance",
+    "frozen_amount",
+    "cash_available",
+    "status",
+)
+
+# 销售 T 日钱包读已发布 kind=cleared 的镜像,只取科目桶。派生列由取数时按日终口径算出。
+CLEARED_WALLET_TABLE = "db_sales.clr_fund_wallet_cleared"
+# 销售 input 与 cleared 分表。copy1 按 dataset_id 只读 kind=input,不读 pre_clearing。钱包核对不读实时 fund_wallet。
+INPUT_LEDGER_TABLE = "db_sales.clr_fund_ledger_copy1"
+CLEARED_LEDGER_TABLE = "db_sales.clr_fund_ledger_cleared"
+CLEARED_ORDER_TABLE = "db_sales.clr_order_view_cleared"
+SALES_SNAPSHOT_TABLE = "db_sales.clearing_snapshot"
+SALES_WRITEBACK_TABLE = "db_sales.clearing_writeback_log"
+MIRROR_LEDGER_COLUMNS = FUND_LEDGER_COLUMNS + (
+    "trade_date",
+    "market",
+    "status",
+    "clearing_step_no",
+    "idem_key",
+    "idem_hash",
+    "effect_json",
+    "effect_hash",
+    "src_id",
+)
+# 承销费用在已发布销售订单上,不在发行子单。sub_order_no 用来对上报表子单。
+SALES_ORDER_COLUMNS = (
+    "order_no",
+    "sub_order_no",
+    "investor_id",
+    "direction",
+    "status",
+    "settle_ccy",
+    "frozen_settle",
+    "exec_total_amt",
+    "exec_underwrite_fee_settle",
+    "trade_date",
+    "actual_settle_date",
+)
+SALES_SNAPSHOT_COLUMNS = (
+    "entity_type",
+    "payload",
+)
+SALES_WRITEBACK_COLUMNS = (
+    "biz_key",
+    "source_row_id",
+)
+WALLET_BUCKET_COLUMNS = (
+    "id",
+    "fund_account_id",
+    "ccy",
+    "cash_settled",
+    "cash_buy_unsettled",
+    "cash_sell_unsettled",
+    "buy_today",
+    "sell_today",
+    "trade_frozen",
+    "nontrade_frozen",
+)
+
+# TRS006 读本批快照。payload 是整行 JSON,取数后再收成流水列。
+TRS_SNAPSHOT_TABLE = "db_trs.clearing_snapshot"
+TRS_SNAPSHOT_COLUMNS = (
+    "entity_type",
+    "payload",
+)
+TRS_SNAPSHOT_LEDGER = "fund_ledger"
+TRS_SNAPSHOT_CLEARING_LEDGER = "clearing_fund_ledger"
+# 步骤节点用的其余封存实体。名字与 TRS 清算写入的 entity_type 一致。
+TRS_SNAPSHOT_INTEREST = "interest_input"
+TRS_SNAPSHOT_FINANCING = "stock_financing_limit_input"
+TRS_SNAPSHOT_JOURNAL = "user_position_journal"
+TRS_SNAPSHOT_ADJUST = "fund_base_asset_adjust"
+TRS_SNAPSHOT_ALGO = "trade_algo_master"
+
+# 一条 IN 列表最多放这么多个值,超过就分几条语句读,避免语句过长。
+IN_CHUNK = 500
+
+# OTC009、OTC012 是实时导出,不落库。发行 report_topup_payment、两端 report_extension_fee
+# 在三端代码里没有写入方,清单里不收,取数不访问。
+
+
+@dataclass(frozen=True)
+class ReportSpec:
+    """一张日报。列顺序与实库一致。中文名由规则包对照,这里不翻译。"""
+
+    table: str
+    audience: str
+    label: str
+    columns: tuple[str, ...]
+    date_column: str
+    filter_row_status: bool
+    market_column: str | None
+    order_column: str | None
+    note_column: str | None
+    fund_column: str | None
+    user_id_column: str | None
+    user_column: str | None
+
+
+def reports_for(audience: str) -> tuple[ReportSpec, ...]:
+    """按发行、销售或 TRS 取出报表清单。"""
+
+    return tuple(spec for spec in REPORTS if spec.audience == audience)
+
+
+REPORTS: tuple[ReportSpec, ...] = (
+    ReportSpec(
+        table='db_issue.report_trade_order',
+        audience='issue',
+        label='OTC003',
+        columns=(
+            'id',
+            'daycut_batch_id',
+            'market',
+            'status',
+            'org_id',
+            'business_date',
+            'master_order_no',
+            'sub_order_no',
+            'order_status',
+            'trade_type',
+            'direction',
+            'op_type',
+            'note_type',
+            'investor_no',
+            'investor_account',
+            'investor_name',
+            'fund_account_no',
+            'org_name',
+            'sec_code',
+            'sec_name',
+            'note_direction',
+            'period',
+            'notional_trade',
+            'share_qty',
+            'deal_price',
+            'trade_ccy',
+            'order_time',
+            'trade_date',
+            'settle_date',
+            'settle_ccy',
+            'fx_settle',
+            'face_value_settle',
+            'price_pct',
+            'deal_amt_settle',
+            'issue_fee_settle',
+            'total_amt_settle',
+            'realized_dividend',
+            'underlying_pnl',
+            'sold_add_amt',
+            'sell_finance_interest',
+            'hedge_position_qty',
+            'hedge_realized_pnl',
+            'daily_hedge_pnl_diff',
+            'investor_daily_pnl_ratio',
+            'investor_daily_pnl_amt',
+            'pre_share_sell_amt',
+            'share_ratio',
+            'final_pnl',
+            'rel_master_note_no',
+            'rel_sub_note_no',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=False,
+        market_column='market',
+        order_column='master_order_no',
+        note_column=None,
+        fund_column='fund_account_no',
+        user_id_column=None,
+        user_column=None,
+    ),
+    ReportSpec(
+        table='db_issue.report_daily_details',
+        audience='issue',
+        label='OTC004',
+        columns=(
+            'id',
+            'daycut_batch_id',
+            'market',
+            'status',
+            'org_id',
+            'business_date',
+            'master_note_no',
+            'sub_note_no',
+            'note_status',
+            'trade_type',
+            'note_type',
+            'investor_no',
+            'investor_account',
+            'investor_name',
+            'fund_account_no',
+            'org_name',
+            'sec_code',
+            'sec_name',
+            'direction',
+            'issue_date',
+            'subscription_date',
+            'maturity_date',
+            'trade_ccy',
+            'init_notional_trade',
+            'init_qty',
+            'init_px',
+            'cur_notional_trade',
+            'cur_qty',
+            'cur_hedge_qty',
+            'cur_px',
+            'quote_ratio',
+            'yield_ratio',
+            'share_ratio',
+            'finance_rate',
+            'settle_ccy',
+            'init_fx',
+            'fx_book',
+            'init_face_value',
+            'issue_price_settle',
+            'cur_face_value',
+            'add_amt',
+            'dividend_amt',
+            'interest_payable',
+            'valuation',
+            'nav',
+            'float_pnl',
+            'sold_face_value',
+            'sold_underlying_pnl',
+            'investor_daily_pnl_amt',
+            'sold_finance_interest',
+            'sold_total_amt',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=False,
+        market_column='market',
+        order_column=None,
+        note_column='master_note_no',
+        fund_column='fund_account_no',
+        user_id_column=None,
+        user_column=None,
+    ),
+    ReportSpec(
+        table='db_issue.report_order_settlement',
+        audience='issue',
+        label='OTC007',
+        columns=(
+            'id',
+            'daycut_batch_id',
+            'market',
+            'status',
+            'org_id',
+            'business_date',
+            'investor_no',
+            'investor_account',
+            'investor_name',
+            'fund_account_no',
+            'org_name',
+            'master_note_no',
+            'sub_note_no',
+            'biz_type',
+            'trade_type',
+            'note_type',
+            'trade_ccy',
+            'notional_trade',
+            'settle_ccy',
+            'face_value_settle',
+            'note_price_pct',
+            'deal_amt_settle',
+            'issue_fee_settle',
+            'total_amt_settle',
+            'trade_date',
+            'expect_settle_date',
+            'actual_settle_date',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=False,
+        market_column='market',
+        order_column=None,
+        note_column='master_note_no',
+        fund_column='fund_account_no',
+        user_id_column=None,
+        user_column=None,
+    ),
+    ReportSpec(
+        table='db_issue.report_order_non_settlement',
+        audience='issue',
+        label='OTC008',
+        columns=(
+            'id',
+            'daycut_batch_id',
+            'market',
+            'status',
+            'org_id',
+            'business_date',
+            'investor_no',
+            'investor_account',
+            'investor_name',
+            'fund_account_no',
+            'org_name',
+            'master_note_no',
+            'sub_note_no',
+            'biz_type',
+            'trade_type',
+            'note_type',
+            'trade_ccy',
+            'notional_trade',
+            'settle_ccy',
+            'face_value_settle',
+            'note_price_pct',
+            'deal_amt_settle',
+            'issue_fee_settle',
+            'total_amt_settle',
+            'trade_date',
+            'expect_settle_date',
+            'actual_settle_date',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=False,
+        market_column='market',
+        order_column=None,
+        note_column='master_note_no',
+        fund_column='fund_account_no',
+        user_id_column=None,
+        user_column=None,
+    ),
+    ReportSpec(
+        table='db_sales.report_trade_order',
+        audience='sales',
+        label='OTC003',
+        columns=(
+            'id',
+            'clearing_batch_id',
+            'market',
+            'status',
+            'business_date',
+            'master_order_no',
+            'sub_order_no',
+            'order_status',
+            'trade_type',
+            'direction',
+            'op_type',
+            'note_type',
+            'investor_no',
+            'investor_account',
+            'investor_name',
+            'fund_account_no',
+            'org_name',
+            'sec_code',
+            'sec_name',
+            'sec_market',
+            'note_direction',
+            'period',
+            'notional_trade',
+            'share_qty',
+            'deal_price',
+            'trade_ccy',
+            'order_time',
+            'trade_date',
+            'settle_date',
+            'settle_ccy',
+            'fx_settle',
+            'face_value_settle',
+            'deal_price_pct',
+            'deal_amt_settle',
+            'issue_fee_settle',
+            'underwrite_fee_settle',
+            'total_amt_settle',
+            'realized_dividend',
+            'sold_add_amt',
+            'sell_finance_interest',
+            'hedge_realized_pnl',
+            'investor_daily_pnl_ratio',
+            'investor_daily_pnl_amt',
+            'pre_share_sell_amt',
+            'share_ratio',
+            'rel_master_note_no',
+            'rel_sub_note_no',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=False,
+        market_column='market',
+        order_column='master_order_no',
+        note_column=None,
+        fund_column='fund_account_no',
+        user_id_column=None,
+        user_column=None,
+    ),
+    ReportSpec(
+        table='db_sales.report_daily_details',
+        audience='sales',
+        label='OTC004',
+        columns=(
+            'id',
+            'clearing_batch_id',
+            'market',
+            'status',
+            'business_date',
+            'master_note_no',
+            'sub_note_no',
+            'note_status',
+            'trade_type',
+            'note_type',
+            'investor_no',
+            'investor_account',
+            'investor_name',
+            'fund_account_no',
+            'org_name',
+            'sec_code',
+            'sec_name',
+            'sec_market',
+            'direction',
+            'issue_date',
+            'subscription_date',
+            'maturity_date',
+            'trade_ccy',
+            'init_notional_trade',
+            'init_qty',
+            'init_px',
+            'cur_notional_trade',
+            'cur_qty',
+            'cur_px',
+            'quote_ratio',
+            'yield_ratio',
+            'share_ratio',
+            'finance_rate',
+            'settle_ccy',
+            'init_fx',
+            'fx_book',
+            'init_face_value',
+            'issue_price_settle',
+            'cur_face_value',
+            'add_amt',
+            'dividend_amt',
+            'interest_payable',
+            'valuation',
+            'nav',
+            'float_pnl',
+            'sold_face_value',
+            'sold_underlying_pnl',
+            'referral_fee_amt',
+            'sold_finance_interest',
+            'sold_total_amt',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=False,
+        market_column='market',
+        order_column=None,
+        note_column='master_note_no',
+        fund_column='fund_account_no',
+        user_id_column=None,
+        user_column=None,
+    ),
+    ReportSpec(
+        table='db_sales.report_order_settlement',
+        audience='sales',
+        label='OTC007',
+        columns=(
+            'id',
+            'clearing_batch_id',
+            'market',
+            'status',
+            'business_date',
+            'investor_no',
+            'investor_account',
+            'investor_name',
+            'fund_account_no',
+            'org_name',
+            'master_note_no',
+            'sub_note_no',
+            'sec_market',
+            'biz_type',
+            'trade_type',
+            'note_type',
+            'trade_ccy',
+            'notional_trade',
+            'settle_ccy',
+            'face_value_settle',
+            'note_price_pct',
+            'deal_amt_settle',
+            'issue_fee_settle',
+            'underwrite_fee_settle',
+            'total_amt_settle',
+            'trade_date',
+            'expect_settle_date',
+            'actual_settle_date',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=False,
+        market_column='market',
+        order_column=None,
+        note_column='master_note_no',
+        fund_column='fund_account_no',
+        user_id_column=None,
+        user_column=None,
+    ),
+    ReportSpec(
+        table='db_sales.report_order_non_settlement',
+        audience='sales',
+        label='OTC008',
+        columns=(
+            'id',
+            'clearing_batch_id',
+            'market',
+            'status',
+            'business_date',
+            'investor_no',
+            'investor_account',
+            'investor_name',
+            'fund_account_no',
+            'org_name',
+            'master_note_no',
+            'sub_note_no',
+            'sec_market',
+            'biz_type',
+            'trade_type',
+            'note_type',
+            'trade_ccy',
+            'notional_trade',
+            'settle_ccy',
+            'face_value_settle',
+            'note_price_pct',
+            'deal_amt_settle',
+            'issue_fee_settle',
+            'underwrite_fee_settle',
+            'total_amt_settle',
+            'trade_date',
+            'expect_settle_date',
+            'actual_settle_date',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=False,
+        market_column='market',
+        order_column=None,
+        note_column='master_note_no',
+        fund_column='fund_account_no',
+        user_id_column=None,
+        user_column=None,
+    ),
+    ReportSpec(
+        table='db_sales.report_account_asset',
+        audience='sales',
+        label='OTC001',
+        columns=(
+            'id',
+            'clearing_batch_id',
+            'market',
+            'status',
+            'business_date',
+            'investor_no',
+            'investor_account',
+            'investor_name',
+            'fund_account_no',
+            'ccy',
+            'cash_available',
+            'frozen_amt',
+            'cash_balance',
+            'cash_settled',
+            'cash_buy_unsettled',
+            'cash_buy_unsettled_a',
+            'cash_buy_unsettled_hk',
+            'cash_buy_unsettled_us',
+            'cash_sell_unsettled',
+            'cash_sell_unsettled_a',
+            'cash_sell_unsettled_hk',
+            'cash_sell_unsettled_us',
+            'note_valuation',
+            'net_asset',
+            'fx_book',
+            'net_asset_hkd',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=False,
+        market_column='market',
+        order_column=None,
+        note_column=None,
+        fund_column='fund_account_no',
+        user_id_column=None,
+        user_column=None,
+    ),
+    ReportSpec(
+        table='db_sales.report_cash_change',
+        audience='sales',
+        label='OTC002',
+        columns=(
+            'id',
+            'clearing_batch_id',
+            'market',
+            'status',
+            'business_date',
+            'investor_no',
+            'investor_account',
+            'investor_name',
+            'fund_account_no',
+            'ccy',
+            'trade_date',
+            'journal_id',
+            'sec_market',
+            'biz_type',
+            'amount',
+            'biz_type_code',
+            'biz_code',
+            'narrative',
+            'change_time',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=False,
+        market_column='market',
+        order_column=None,
+        note_column=None,
+        fund_column='fund_account_no',
+        user_id_column=None,
+        user_column=None,
+    ),
+    ReportSpec(
+        table='db_sales.report_cash_settlement',
+        audience='sales',
+        label='OTC010',
+        columns=(
+            'id',
+            'clearing_batch_id',
+            'market',
+            'status',
+            'journal_id',
+            'business_date',
+            'investor_no',
+            'investor_account',
+            'investor_name',
+            'fund_account_no',
+            'ccy',
+            'value_date',
+            'sec_market',
+            'biz_type',
+            'amount',
+            'biz_type_code',
+            'biz_code',
+            'narrative',
+            'change_time',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=False,
+        market_column='market',
+        order_column=None,
+        note_column=None,
+        fund_column='fund_account_no',
+        user_id_column=None,
+        user_column=None,
+    ),
+    # 实表只有警戒线、平仓线。没有距离警戒线、距离平仓线这两列。
+    ReportSpec(
+        table='db_trs.report_account_asset',
+        audience='trs',
+        label='',
+        columns=(
+            'id',
+            'clearing_batch_id',
+            'row_status',
+            'business_date',
+            'user_id',
+            'user_account',
+            'user_name',
+            'trade_status',
+            'ccy',
+            'available_amt',
+            'frozen_amt',
+            'principal_amt',
+            'financing_amt',
+            'base_total_asset',
+            'credit_principal',
+            'credit_loan',
+            'credit_credit_principal',
+            'credit_credit',
+            'position_mv',
+            'total_asset',
+            'total_float_pnl',
+            'leverage',
+            'warn_line',
+            'close_line',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=True,
+        market_column=None,
+        order_column=None,
+        note_column=None,
+        fund_column=None,
+        user_id_column='user_id',
+        user_column='user_account',
+    ),
+    ReportSpec(
+        table='db_trs.report_master_order',
+        audience='trs',
+        label='',
+        columns=(
+            'id',
+            'clearing_batch_id',
+            'row_status',
+            'business_date',
+            'user_id',
+            'account',
+            'user_name',
+            'sec_code',
+            'sec_name',
+            'channel_name',
+            'trade_channel',
+            'direction',
+            'price_type',
+            'order_status',
+            'entrust_qty',
+            'entrust_price',
+            'entrust_time',
+            'filled_qty',
+            'filled_price',
+            'trade_ccy',
+            'filled_amount',
+            'deal_time',
+            'trade_date',
+            'commission',
+            'stamp_duty',
+            'regulatory_fee',
+            'total_fee',
+            'occur_amount_trade',
+            'settle_ccy',
+            'occur_amount_settle',
+            'master_order_no',
+            'operator',
+            'remark',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=True,
+        market_column=None,
+        order_column=None,
+        note_column=None,
+        fund_column=None,
+        user_id_column='user_id',
+        user_column='account',
+    ),
+    ReportSpec(
+        table='db_trs.report_sub_order',
+        audience='trs',
+        label='',
+        columns=(
+            'id',
+            'clearing_batch_id',
+            'row_status',
+            'business_date',
+            'user_id',
+            'account',
+            'user_name',
+            'sec_code',
+            'sec_name',
+            'channel_name',
+            'trade_channel',
+            'direction',
+            'price_type',
+            'order_status',
+            'entrust_qty',
+            'entrust_price',
+            'entrust_time',
+            'filled_qty',
+            'filled_price',
+            'trade_ccy',
+            'filled_amount',
+            'deal_time',
+            'deal_date',
+            'commission',
+            'stamp_duty',
+            'regulatory_fee',
+            'total_fee',
+            'occur_amount_trade',
+            'settle_ccy',
+            'occur_amount_settle',
+            'master_order_no',
+            'sub_order_no',
+            'user_fx',
+            'commission_settle',
+            'signed_occur_amount_settle',
+            'operator',
+            'order_type',
+            'order_mode',
+            'remark',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=True,
+        market_column=None,
+        order_column=None,
+        note_column=None,
+        fund_column=None,
+        user_id_column='user_id',
+        user_column='account',
+    ),
+    ReportSpec(
+        table='db_trs.report_details_order',
+        audience='trs',
+        label='',
+        columns=(
+            'id',
+            'clearing_batch_id',
+            'row_status',
+            'business_date',
+            'user_id',
+            'account',
+            'user_name',
+            'channel_name',
+            'trade_channel',
+            'sec_code',
+            'sec_name',
+            'direction',
+            'filled_qty',
+            'filled_price',
+            'deal_time',
+            'deal_date',
+            'deal_no',
+            'master_order_no',
+            'sub_order_no',
+            'remark',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=True,
+        market_column=None,
+        order_column=None,
+        note_column=None,
+        fund_column=None,
+        user_id_column='user_id',
+        user_column='account',
+    ),
+    ReportSpec(
+        table='db_trs.report_stock_holdings',
+        audience='trs',
+        label='',
+        columns=(
+            'id',
+            'clearing_batch_id',
+            'row_status',
+            'business_date',
+            'user_id',
+            'account',
+            'user_name',
+            'channel_name',
+            'trade_channel',
+            'sec_code',
+            'sec_name',
+            'position_qty',
+            'available_qty',
+            'close_price',
+            'trade_ccy',
+            'position_mv_trade',
+            'settle_ccy',
+            'fx_book',
+            'position_mv_settle',
+            'position_cost_settle',
+            'float_pnl_settle',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=True,
+        market_column=None,
+        order_column=None,
+        note_column=None,
+        fund_column=None,
+        user_id_column='user_id',
+        user_column='account',
+    ),
+    ReportSpec(
+        table='db_trs.report_available_change',
+        audience='trs',
+        label='',
+        columns=(
+            'id',
+            'clearing_batch_id',
+            'row_status',
+            'business_date',
+            'ledger_id',
+            'user_id',
+            'account',
+            'user_name',
+            'ccy',
+            'change_type',
+            'amount',
+            'occur_date',
+            'occur_time',
+            'remark',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=True,
+        market_column=None,
+        order_column=None,
+        note_column=None,
+        fund_column=None,
+        user_id_column='user_id',
+        user_column='account',
+    ),
+    ReportSpec(
+        table='db_trs.report_interest_procedural',
+        audience='trs',
+        label='',
+        columns=(
+            'id',
+            'clearing_batch_id',
+            'row_status',
+            'business_date',
+            'user_id',
+            'account',
+            'user_name',
+            'ccy',
+            'period_start',
+            'period_end',
+            'interest_days',
+            'finance_rate',
+            'period_interest',
+            'remark',
+            'operator',
+            'operate_time',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=True,
+        market_column=None,
+        order_column=None,
+        note_column=None,
+        fund_column=None,
+        user_id_column='user_id',
+        user_column='account',
+    ),
+    ReportSpec(
+        table='db_trs.report_stock_realized_pnl',
+        audience='trs',
+        label='',
+        columns=(
+            'id',
+            'clearing_batch_id',
+            'row_status',
+            'business_date',
+            'user_id',
+            'account',
+            'user_name',
+            'sec_code',
+            'sec_name',
+            'channel_name',
+            'trade_channel',
+            'direction',
+            'deal_date',
+            'filled_qty',
+            'filled_price',
+            'trade_ccy',
+            'filled_amount_trade',
+            'occur_amount_trade',
+            'settle_ccy',
+            'user_fx',
+            'occur_amount_settle',
+            'holding_buy_amount_settle',
+            'realized_pnl_settle',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=True,
+        market_column=None,
+        order_column=None,
+        note_column=None,
+        fund_column=None,
+        user_id_column='user_id',
+        user_column='account',
+    ),
+    ReportSpec(
+        table='db_trs.report_fund_in_out',
+        audience='trs',
+        label='',
+        columns=(
+            'id',
+            'clearing_batch_id',
+            'row_status',
+            'business_date',
+            'client_code',
+            'client_fund_account',
+            'client_name',
+            'type',
+            'ae_code',
+            'inst_date',
+            'bank_name',
+            'bank_account',
+            'value_date',
+            'business_type',
+            'ccy',
+            'ref',
+            'amount',
+            'dw',
+            'biz_code',
+            'narrative',
+            'input_person',
+            'operation_time',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=True,
+        market_column=None,
+        order_column=None,
+        note_column=None,
+        fund_column=None,
+        user_id_column=None,
+        user_column='client_code',
+    ),
+    ReportSpec(
+        table='db_trs.report_client_balance_summary',
+        audience='trs',
+        label='',
+        columns=(
+            'id',
+            'clearing_batch_id',
+            'row_status',
+            'business_date',
+            'user_id',
+            'user_account',
+            'user_name',
+            'ccy',
+            'prev_cash',
+            'fund_in_out',
+            'realized_pnl',
+            'paid_interest',
+            'cash_balance',
+            'total_float_profit',
+            'total_float_loss',
+            'payable_interest',
+            'net_asset',
+            'margin_requirement',
+            'margin_diff',
+            'mca_issue_date',
+            'client_funds',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=True,
+        market_column=None,
+        order_column=None,
+        note_column=None,
+        fund_column=None,
+        user_id_column='user_id',
+        user_column='user_account',
+    ),
+    ReportSpec(
+        table='db_trs.report_margin_rate_change',
+        audience='trs',
+        label='',
+        columns=(
+            'id',
+            'clearing_batch_id',
+            'row_status',
+            'business_date',
+            'user_id',
+            'account',
+            'user_name',
+            'prev_annual_rate',
+            'new_annual_rate',
+            'value_date',
+            'change_date',
+            'change_time',
+            'changer',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=True,
+        market_column=None,
+        order_column=None,
+        note_column=None,
+        fund_column=None,
+        user_id_column='user_id',
+        user_column='account',
+    ),
+    ReportSpec(
+        table='db_trs.report_interest_payable_detail',
+        audience='trs',
+        label='',
+        columns=(
+            'id',
+            'clearing_batch_id',
+            'row_status',
+            'business_date',
+            'user_id',
+            'account',
+            'user_name',
+            'interest_date',
+            'settle_ccy',
+            'financing_amt',
+            'credit_loan',
+            'credit_credit',
+            'annual_rate',
+            'payable_interest',
+            'status',
+            'remark',
+            'source',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=True,
+        market_column=None,
+        order_column=None,
+        note_column=None,
+        fund_column=None,
+        user_id_column='user_id',
+        user_column='account',
+    ),
+    ReportSpec(
+        table='db_trs.report_daily_net_asset',
+        audience='trs',
+        label='',
+        columns=(
+            'id',
+            'clearing_batch_id',
+            'row_status',
+            'business_date',
+            'account',
+            'user_name',
+            'nature_date',
+            'settle_ccy',
+            'credit_principal',
+            'total_asset',
+            'position_mv',
+            'net_asset',
+            'payable_interest',
+            'create_time',
+        ),
+        date_column='business_date',
+        filter_row_status=True,
+        market_column=None,
+        order_column=None,
+        note_column=None,
+        fund_column=None,
+        user_id_column=None,
+        user_column='account',
+    ),
+    ReportSpec(
+        table='db_trs.report_holdings_amount_interest',
+        audience='trs',
+        label='',
+        columns=(
+            'id',
+            'clearing_batch_id',
+            'row_status',
+            'nature_date',
+            'account',
+            'channel_account_id',
+            'channel_account',
+            'settle_ccy',
+            'holding_cost',
+            'finance_rate',
+            'interest',
+            'create_time',
+        ),
+        date_column='nature_date',
+        filter_row_status=True,
+        market_column=None,
+        order_column=None,
+        note_column=None,
+        fund_column=None,
+        user_id_column=None,
+        user_column='account',
+    ),
+)

+ 256 - 0
code/post_clear_check/src/post_clear_check/config.py

@@ -0,0 +1,256 @@
+"""配置。只从工具目录外的环境文件读取,不读仓库里的环境文件。"""
+
+from __future__ import annotations
+
+import os
+import re
+import stat
+import traceback
+from collections.abc import Callable
+from datetime import date, datetime
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+from pydantic import Field, SecretStr, ValidationError, field_validator
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+# 进程用这个环境变量指向配置文件。文件本身不在工具目录内。
+CONFIG_ENV = "POST_CLEAR_CHECK_CONFIG"
+# 营业日按 A 股所在时区取「今天」,不跟服务器的 UTC 日历。
+BUSINESS_ZONE = ZoneInfo("Asia/Shanghai")
+
+# 飞书推送类型。配置文件里写中文,库内结论仍是英文。
+PUSH_LABELS = ("正确", "错误", "可疑")
+PUSH_VERDICT = {"正确": "correct", "错误": "error", "可疑": "suspect"}
+
+# 异常文本里的 Webhook 地址。主机名不是连接串,不能一起抹掉。
+_URL_TEXT = re.compile(r"https?://\S+", re.IGNORECASE)
+
+
+class ConfigError(Exception):
+    """配置文件缺失、位置不对或内容无法解析。"""
+
+
+class Settings(BaseSettings):
+    """连接与飞书字段。不含仓库路径。
+
+    口令和 Webhook 使用 SecretStr,对象的默认字符串形式不展开明文。
+    """
+
+    model_config = SettingsConfigDict(
+        env_prefix="POST_CLEAR_CHECK_",
+        extra="ignore",
+        # 禁止默认去当前目录找 .env,避免读到仓库里的环境文件。
+        env_file=None,
+    )
+
+    business_host: str = Field(min_length=1, description="业务库主机")
+    business_port: int = Field(default=3306, ge=1, le=65535, description="业务库端口")
+    business_user: str = Field(min_length=1, description="业务库只读账号")
+    business_password: SecretStr = Field(description="业务库口令")
+
+    own_host: str = Field(min_length=1, description="自有库主机")
+    own_port: int = Field(default=3306, ge=1, le=65535, description="自有库端口")
+    own_user: str = Field(min_length=1, description="自有库账号")
+    own_password: SecretStr = Field(description="自有库口令")
+
+    # 只核这一营业日。空着则在启动时取上海时区的今天,不扫描其他营业日。
+    business_date: date | None = Field(default=None, description="营业日,空着表示今天")
+
+    # 空串表示未配置,正确、错误、可疑都推。多选用逗号、顿号或空格分开。
+    feishu_push: str = Field(default="", description="推送飞书的结论类型,空为全部")
+
+    @field_validator("business_date", mode="before")
+    @classmethod
+    def _blank_business_date(cls, value: object) -> object:
+        """配置文件里的空串当成没填,避免日期解析失败。"""
+
+        if isinstance(value, str) and not value.strip():
+            return None
+        return value
+
+    @field_validator("feishu_push", mode="before")
+    @classmethod
+    def _blank_feishu_push(cls, value: object) -> object:
+        """空串和空白都当成没配推送类型。"""
+
+        if value is None:
+            return ""
+        if isinstance(value, str):
+            return value.strip()
+        return value
+
+    @field_validator("feishu_push")
+    @classmethod
+    def _known_feishu_push(cls, value: str) -> str:
+        """只接受正确、错误、可疑。写成稳定顺序,方便日志对照。"""
+
+        if not value:
+            return ""
+        parts = [part for part in re.split(r"[,,、\s]+", value) if part]
+        unknown = [part for part in parts if part not in PUSH_VERDICT]
+        if unknown:
+            joined = "、".join(unknown)
+            raise ValueError(f"推送飞书类型无法识别:{joined}")
+        chosen = [label for label in PUSH_LABELS if label in parts]
+        return "、".join(chosen)
+
+    # 飞书自定义机器人。发送时才取出明文,日志和异常文本里不出现。
+    feishu_webhook: SecretStr = Field(default_factory=lambda: SecretStr(""), description="飞书 Webhook")
+    feishu_sign_secret: SecretStr = Field(
+        default_factory=lambda: SecretStr(""),
+        description="飞书签名密钥,可空",
+    )
+
+
+def tool_root() -> Path:
+    """定位工具目录:同时放着 pyproject.toml 和建库脚本的那一层。"""
+
+    here = Path(__file__).resolve()
+    for parent in here.parents:
+        if (parent / "pyproject.toml").is_file() and (parent / "sql" / "schema.sql").is_file():
+            return parent
+    raise ConfigError("找不到工具目录")
+
+
+def load_settings() -> Settings:
+    """读取配置文件。失败时的异常文本不含口令、Webhook 和连接串。"""
+
+    raw = os.environ.get(CONFIG_ENV)
+    if not raw:
+        raise ConfigError("未设置配置文件路径")
+    path = _checked_config_path(Path(raw))
+    try:
+        return Settings(_env_file=path, _env_file_encoding="utf-8")
+    except ValidationError as exc:
+        raise _invalid_config(exc) from None
+
+
+def _checked_config_path(path: Path) -> Path:
+    """配置文件必须在工具目录外,且权限正好是 0600。"""
+
+    resolved = path.resolve()
+    root = tool_root()
+    if resolved == root or root in resolved.parents:
+        raise ConfigError("配置文件必须放在工具目录之外")
+    if not resolved.is_file():
+        raise ConfigError("配置文件不存在")
+    mode = stat.S_IMODE(resolved.stat().st_mode)
+    if mode != 0o600:
+        raise ConfigError("配置文件权限必须为 0600")
+    return resolved
+
+
+def push_verdicts(settings: Settings) -> frozenset[str] | None:
+    """未配置时返回空,调用方推送全部。配了则只含 correct、error、suspect。"""
+
+    if not settings.feishu_push:
+        return None
+    return frozenset(PUSH_VERDICT[label] for label in settings.feishu_push.split("、"))
+
+
+def push_log_suffix(settings: Settings) -> str:
+    """未配置推送类型时,挂到已有飞书日志后面。配了类型就留空。"""
+
+    if settings.feishu_push:
+        return ""
+    # 前面留空格,避免和日志里的 URL 粘在一起被整段抹掉。
+    # 空配置表示三类都推,不能写成「未配置推送」,否则和「已发送」矛盾。
+    return " ,推送类型未配置,三类都推"
+
+
+def shanghai_wall_now(clock: Callable[[], datetime] | None = None) -> datetime:
+    """上海墙钟,去掉时区后写入 DATETIME。传入的时钟可以是别的时区。"""
+
+    current = datetime.now(BUSINESS_ZONE) if clock is None else clock()
+    if current.tzinfo is None:
+        current = current.replace(tzinfo=BUSINESS_ZONE)
+    return current.astimezone(BUSINESS_ZONE).replace(tzinfo=None, microsecond=0)
+
+
+def resolve_business_date(settings: Settings, *, today: date | None = None) -> date:
+    """配置了营业日就用配置。没配置时用上海时区的今天。"""
+
+    if settings.business_date is not None:
+        return settings.business_date
+    if today is not None:
+        return today
+    return datetime.now(BUSINESS_ZONE).date()
+
+
+def secret_values(settings: Settings) -> tuple[str, ...]:
+    """口令、Webhook 和签名密钥。空串不参与抹除,避免把原因整段删掉。"""
+
+    values = (
+        settings.business_password.get_secret_value(),
+        settings.own_password.get_secret_value(),
+        settings.feishu_webhook.get_secret_value(),
+        settings.feishu_sign_secret.get_secret_value(),
+    )
+    return tuple(item for item in values if item)
+
+
+def describe_exception(exc: BaseException, *secrets: str) -> str:
+    """日志用的失败说明:本工具源码位置、异常类型、去掉口令后的原因。"""
+
+    location = _exception_location(exc)
+    reason = _public_reason(exc, secrets)
+    head = f"{location} " if location else ""
+    if reason:
+        return f"{head}{type(exc).__name__}: {reason}"
+    return f"{head}{type(exc).__name__}"
+
+
+def _exception_location(exc: BaseException) -> str:
+    """取最靠近抛出点、且属于本工具的那一帧。库内部帧退回抛出点。"""
+
+    frames = traceback.extract_tb(exc.__traceback__)
+    chosen = None
+    for frame in frames:
+        if _is_our_file(frame.filename):
+            chosen = frame
+    if chosen is None and frames:
+        chosen = frames[-1]
+    if chosen is None:
+        return ""
+    return f"{Path(chosen.filename).name}:{chosen.lineno} {chosen.name}"
+
+
+def _is_our_file(filename: str) -> bool:
+    """本工具源码,含安装到 site-packages 后的包目录。其他第三方包不算。"""
+
+    parts = Path(filename).parts
+    if "post_clear_check" not in parts:
+        return False
+    if "site-packages" not in parts:
+        return True
+    after = parts[parts.index("site-packages") + 1 :]
+    return "post_clear_check" in after
+
+
+def _public_reason(exc: BaseException, secrets: tuple[str, ...]) -> str:
+    """异常文本压成一行,并去掉 URL 和调用方给出的口令。"""
+
+    try:
+        text = str(exc)
+    except Exception:
+        return ""
+    cleaned = _URL_TEXT.sub("", " ".join(text.split()))
+    for secret in secrets:
+        if secret:
+            cleaned = cleaned.replace(secret, "")
+    return cleaned.strip()
+
+
+def _invalid_config(exc: ValidationError) -> ConfigError:
+    """只带出字段名。校验详情里的 input 可能含口令,不能写进异常文本。"""
+
+    names: set[str] = set()
+    for err in exc.errors():
+        loc = err.get("loc") or ()
+        if loc:
+            names.add(str(loc[0]))
+    if not names:
+        return ConfigError("配置无效")
+    joined = "、".join(sorted(names))
+    return ConfigError(f"配置缺少或无法解析:{joined}")

+ 1027 - 0
code/post_clear_check/src/post_clear_check/feishu.py

@@ -0,0 +1,1027 @@
+"""把一张已定稿的卡片发到飞书自定义机器人。
+
+不读取业务库,不决定哪张卡该不该发,不写自有库。
+一次调用可以拆成多条请求,单条失败是否重试交给调用方。
+链路概况和明细卡是两种版式:概况只列各档归属;明细仍是逐节点的依据和代入。
+"""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import hmac
+import json
+import re
+import time
+from collections import deque
+from collections.abc import Awaitable, Callable, Sequence
+from dataclasses import dataclass
+from datetime import date, datetime
+from decimal import Decimal, InvalidOperation
+from typing import Any, Protocol
+
+import httpx
+from loguru import logger
+
+from post_clear_check.chain import (
+    CHAINS,
+    OWNER_BATCH,
+    OWNER_FUND,
+    OWNER_MASTER,
+    OWNER_NAME,
+    OWNER_USER,
+)
+from post_clear_check.config import Settings, push_log_suffix
+from post_clear_check.order_decimal import VERDICT_CORRECT, VERDICT_ERROR, VERDICT_SUSPECT
+
+# 飞书卡片正文的字符上限。超出后只删正确行,错误和可疑全部保留。
+BODY_LIMIT = 3500
+# 卡片请求体官方上限是 30KB。留出余量,按 json.dumps 的默认编码计算。
+PAYLOAD_BYTE_LIMIT = 28 * 1024
+# 测量体积时用和真实签名差不多长的占位,避免签完又超限。
+_TS_PLACEHOLDER = "1700000000"
+_SIGN_PLACEHOLDER = "A" * 44
+# 制表符和其他控制字符。换行留给正文分段。
+_CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
+# 自定义机器人的默认超时。调用方可以改这一次请求的超时。
+DEFAULT_TIMEOUT_SECONDS = 10.0
+# 进程内共享的发送配额。超出就等,不丢弃。
+MAX_PER_SECOND = 5
+MAX_PER_MINUTE = 100
+
+# 卡片标题上的归属名称,与链路登记表一致。概况正文用同一套名称。
+KEY_NAME_BY_KIND = OWNER_NAME
+# 概况卡的 card_kind。一条链路一张,不占明细卡的归属种类。
+CARD_SUMMARY = "chain_summary"
+# 概况正文里归属的排列。与明细卡收卡时的种类顺序一致。
+_SUMMARY_KIND_ORDER = (OWNER_MASTER, OWNER_FUND, OWNER_USER, OWNER_BATCH)
+
+# 每段第四行的结论短语。可疑只在期望值算得出时补一句无法判定。
+_MATCH = "符合预期。"
+_MISMATCH = "不符合预期。"
+_UNDECIDED = "无法判定。"
+
+VERDICT_LABEL = {
+    VERDICT_CORRECT: "正确",
+    VERDICT_ERROR: "错误",
+    VERDICT_SUSPECT: "可疑",
+}
+
+# 标题颜色跟着卡片总结论,不看单行。
+TEMPLATE_BY_VERDICT = {
+    VERDICT_CORRECT: "blue",
+    VERDICT_SUSPECT: "orange",
+    VERDICT_ERROR: "red",
+}
+
+# 签名校验失败。这些码重投也不会成功。其余非 0 业务码同样不重试。
+SIGN_FAILURE_CODES = frozenset({19021, 19022, 19024})
+
+_URL_TEXT = re.compile(r"https?://\S+", re.IGNORECASE)
+
+
+class FeishuError(Exception):
+    """发送前就能确定的问题。文本里没有 Webhook 和密钥。"""
+
+
+class ConclusionView(Protocol):
+    """结论行上飞书正文要用的字段。"""
+
+    formula_text: str
+    calc_text: str
+    calc_value: Decimal | None
+    check_text: str
+    check_value: Decimal | None
+    basis: str
+    verdict: str
+
+
+@dataclass(frozen=True)
+class SummaryOwner:
+    """概况里的一个归属。verdict 是这张明细卡的总结论,不是单行结论。
+
+    概况正文只用种类、主键和总结论。lines 不参与渲染。
+    """
+
+    card_kind: str
+    biz_key: str
+    verdict: str
+    lines: tuple[ConclusionView, ...] = ()
+
+
+@dataclass(frozen=True)
+class OutboundCard:
+    """调用方已经决定要发的一张卡。记录编号由调用方传入,这里不查库。
+
+    明细卡是一条链路、一个归属对象,owners 留空。
+    概况卡 card_kind 为 chain_summary,owners 是这条链路当时的全部明细卡。
+    """
+
+    card_kind: str
+    chain_name: str
+    biz_key: str
+    business_date: date
+    trade_date: date
+    batch_no: str
+    lines: Sequence[ConclusionView]
+    record_no: str
+    # 上次发送失败后再发时标题带「重发」。首次 pending 不加。
+    resend: bool = False
+    owners: tuple[SummaryOwner, ...] = ()
+
+
+@dataclass
+class _NameList:
+    """某一档结论下、一种归属的主键。total 是名单原长,keys 是这次正文还留着的前缀。"""
+
+    kind: str
+    keys: list[str]
+    total: int
+
+
+@dataclass(frozen=True)
+class SendResult:
+    """一次发送的结果。retryable 为真时,调用方自己决定隔多久再发。"""
+
+    ok: bool
+    retryable: bool
+    attempted: bool
+
+
+class RateGate:
+    """滑动窗口限速。时钟用单调钟,避免墙上时间回拨把配额打穿。"""
+
+    def __init__(
+        self,
+        *,
+        clock: Callable[[], float] | None = None,
+        sleep: Callable[[float], Awaitable[None]] | None = None,
+    ) -> None:
+        self._clock = clock or time.monotonic
+        self._sleep = sleep or asyncio.sleep
+        self._times: deque[float] = deque()
+        self._lock: asyncio.Lock | None = None
+
+    async def acquire(self) -> None:
+        """占一个发送名额。窗口已满时等到有空位。"""
+
+        if self._lock is None:
+            self._lock = asyncio.Lock()
+        while True:
+            async with self._lock:
+                now = self._clock()
+                while self._times and now - self._times[0] >= 60:
+                    self._times.popleft()
+                in_second = [item for item in self._times if now - item < 1]
+                if len(in_second) < MAX_PER_SECOND and len(self._times) < MAX_PER_MINUTE:
+                    self._times.append(now)
+                    return
+                wait = 0.0
+                if len(in_second) >= MAX_PER_SECOND:
+                    wait = 1 - (now - in_second[0])
+                if len(self._times) >= MAX_PER_MINUTE:
+                    wait = max(wait, 60 - (now - self._times[0]))
+                # 浮点误差可能算出 0,仍然要让出事件循环,避免空转。
+                if wait <= 0:
+                    wait = 0.001
+            await self._sleep(wait)
+
+
+# 同一进程里的发送共用这一份配额。
+DEFAULT_GATE = RateGate()
+
+
+def sign_payload(timestamp: str, secret: str) -> str:
+    """签名。密钥是「秒级时间戳、换行、密钥」,消息是空字节。"""
+
+    key = f"{timestamp}\n{secret}".encode("utf-8")
+    digest = hmac.new(key, b"", hashlib.sha256).digest()
+    return base64.b64encode(digest).decode("ascii")
+
+
+def redact(text: str, *secrets: str) -> str:
+    """去掉日志和异常文本里的地址、Webhook 和密钥。"""
+
+    cleaned = _URL_TEXT.sub("", text)
+    for secret in secrets:
+        if secret:
+            cleaned = cleaned.replace(secret, "")
+    return cleaned
+
+
+def card_text(value: str) -> str:
+    """去掉控制字符,尖括号改成全角,避免 lark_md 当成标签。"""
+
+    cleaned = _CONTROL.sub("", value.replace("\r", "").replace("\t", ""))
+    return cleaned.replace("<", "<").replace(">", ">")
+
+
+def render_title(
+    verdict: str,
+    chain_name: str,
+    card_kind: str,
+    biz_key: str,
+    *,
+    resend: bool = False,
+) -> str:
+    """一行标题:结论、链路名称、归属名称、主键值。不写系统和清算步骤。失败重发时末尾加「重发」。"""
+
+    label = VERDICT_LABEL.get(verdict)
+    key_name = KEY_NAME_BY_KIND.get(card_kind)
+    if label is None or key_name is None:
+        raise FeishuError("卡片结论或主键名称无法生成标题")
+    chain = CHAINS.get(chain_name)
+    if chain is None:
+        raise FeishuError("卡片链路不在登记表之内")
+    key = _one_line(biz_key, "业务主键")
+    title = f"{label} {chain.name} {key_name} {key}"
+    if resend:
+        return f"{title} 重发"
+    return title
+
+
+def render_summary_title(
+    chain_name: str,
+    owners: Sequence[SummaryOwner],
+    *,
+    resend: bool = False,
+) -> str:
+    """概况标题:链路名,以及正确、错误、可疑的归属条数。一条明细卡计一条。"""
+
+    chain = CHAINS.get(chain_name)
+    if chain is None:
+        raise FeishuError("卡片链路不在登记表之内")
+    counts = _summary_counts(_summary_buckets(owners))
+    title = (
+        f"链路-{chain.name} "
+        f"正确{counts[VERDICT_CORRECT]}条,"
+        f"错误{counts[VERDICT_ERROR]}条,"
+        f"可疑{counts[VERDICT_SUSPECT]}条"
+    )
+    if resend:
+        return f"{title} 重发"
+    return title
+
+
+def render_summary_body(owners: Sequence[SummaryOwner]) -> str:
+    """概况正文:正确、错误、可疑三档只列归属。没有的那一档写「无」。
+
+    飞书 lark_md 把单个换行显示成空格,所以档与档之间空一行。
+    超长时先删正确名单末尾,再删可疑,最后删错误。概况不拆成多张,也不附示例解读。
+    """
+
+    return _fit_summary(_summary_buckets(owners))
+
+
+def summary_verdict(owners: Sequence[SummaryOwner]) -> str:
+    """概况卡的颜色:这条链路有错误就是红,否则有可疑就是橙,否则是蓝。"""
+
+    buckets = _summary_buckets(owners)
+    if buckets[VERDICT_ERROR]:
+        return VERDICT_ERROR
+    if buckets[VERDICT_SUSPECT]:
+        return VERDICT_SUSPECT
+    return VERDICT_CORRECT
+
+
+def _summary_buckets(owners: Sequence[SummaryOwner]) -> dict[str, list[_NameList]]:
+    """按结论和归属归堆。主键去重后按字典序。没有可用归属就报错,不发空卡。"""
+
+    grouped: dict[tuple[str, str], list[str]] = {}
+    for owner in owners:
+        if owner.verdict not in VERDICT_LABEL:
+            raise FeishuError("结论不在正确、错误、可疑之内")
+        if owner.card_kind not in KEY_NAME_BY_KIND:
+            raise FeishuError("归属种类无法生成概况")
+        key = card_text(owner.biz_key).strip()
+        if not key or "\n" in key:
+            raise FeishuError("业务主键不能为空或换行")
+        grouped.setdefault((owner.verdict, owner.card_kind), []).append(key)
+    buckets: dict[str, list[_NameList]] = {}
+    for verdict in VERDICT_LABEL:
+        groups: list[_NameList] = []
+        for kind in _SUMMARY_KIND_ORDER:
+            keys = sorted(set(grouped.get((verdict, kind), ())))
+            if keys:
+                groups.append(_NameList(kind, keys, len(keys)))
+        buckets[verdict] = groups
+    if not any(buckets.values()):
+        raise FeishuError("概况没有归属")
+    return buckets
+
+
+def _summary_counts(buckets: dict[str, list[_NameList]]) -> dict[str, int]:
+    return {
+        verdict: sum(group.total for group in groups)
+        for verdict, groups in buckets.items()
+    }
+
+
+def _summary_sections(buckets: dict[str, list[_NameList]]) -> str:
+    """三档都保留。没有归属的那一档写「无」。有归属时只写名单。"""
+
+    sections: list[str] = []
+    for verdict in (VERDICT_CORRECT, VERDICT_ERROR, VERDICT_SUSPECT):
+        label = VERDICT_LABEL[verdict]
+        groups = [group for group in buckets[verdict] if group.total]
+        if not groups:
+            sections.append(f"{label}:无")
+            continue
+        parts: list[str] = []
+        for group in groups:
+            shown = "、".join(group.keys)
+            if len(group.keys) < group.total:
+                # 条数仍是原长。读者用标题对总数,用这里的「等共」知道名单被截过。
+                mark = f"…等共{group.total}个"
+                shown = f"{shown}{mark}" if shown else mark
+            parts.append(f"{KEY_NAME_BY_KIND[group.kind]}:{shown}")
+        sections.append(f"{label}:" + ";".join(parts))
+    return "\n\n".join(sections)
+
+
+def _fit_summary(buckets: dict[str, list[_NameList]]) -> str:
+    """先整段放。放不下就按正确、可疑、错误的顺序缩短名单,错误名单最后动。"""
+
+    text = _summary_sections(buckets)
+    if len(text) <= BODY_LIMIT:
+        return text
+    for verdict in (VERDICT_CORRECT, VERDICT_SUSPECT, VERDICT_ERROR):
+        text = _fit_summary_verdict(buckets, verdict)
+        if len(text) <= BODY_LIMIT:
+            return text
+    # 三档都缩成「等共」仍超长时截断,避免请求被拒。正常主键走不到这里。
+    if len(text) <= BODY_LIMIT:
+        return text
+    return text[: BODY_LIMIT - 1] + "…"
+
+
+def _fit_summary_verdict(buckets: dict[str, list[_NameList]], verdict: str) -> str:
+    """在这一档里二分保留前缀。其它档保持调用前的样子。"""
+
+    groups = buckets[verdict]
+    original = [(list(group.keys), group.total) for group in groups]
+    total = sum(len(keys) for keys, _total in original)
+    if total == 0:
+        return _summary_sections(buckets)
+    # 满名单已经在外层判定过放不下。只在带「等共」的前缀里二分:
+    # 满名单没有这几个字,有时比少一个主键、却带标记的前缀更短,放进同一个二分会找错。
+    best = 0
+    lo = 0
+    hi = total - 1
+    while lo <= hi:
+        mid = (lo + hi) // 2
+        _keep_summary_front(groups, original, mid)
+        if len(_summary_sections(buckets)) <= BODY_LIMIT:
+            best = mid
+            lo = mid + 1
+        else:
+            hi = mid - 1
+    _keep_summary_front(groups, original, best)
+    return _summary_sections(buckets)
+
+
+def _keep_summary_front(
+    groups: list[_NameList],
+    original: Sequence[tuple[list[str], int]],
+    keep: int,
+) -> None:
+    """按归属顺序、主键字典序保留前 keep 个。total 不动,正文才能写出「等共 N 个」。"""
+
+    left = keep
+    for group, (keys, total) in zip(groups, original, strict=True):
+        group.total = total
+        if left <= 0:
+            group.keys = []
+            continue
+        if left >= len(keys):
+            group.keys = list(keys)
+            left -= len(keys)
+            continue
+        group.keys = list(keys[:left])
+        left = 0
+
+
+def render_line(line: ConclusionView) -> str:
+    """一个节点一段。依据、公式、代入式、说明、被核对的对象、期望结论各占一行,相邻行之间空一行。
+
+    飞书 lark_md 把单个换行显示成空格,这段若只用一个换行会从依据一直挤到期望结论。
+    """
+
+    if line.verdict not in VERDICT_LABEL:
+        raise FeishuError("结论不在正确、错误、可疑之内")
+    rows = [
+        f"依据:{card_text(line.basis)}",
+        card_text(line.formula_text),
+        *_clause_rows(card_text(line.calc_text)),
+        f"被核对的对象:{card_text(line.check_text)}",
+        expectation(line),
+    ]
+    return "\n\n".join(row for row in rows if row.strip())
+
+
+def _clause_rows(text: str) -> tuple[str, ...]:
+    """第一段是代入式,分号后面的每段是说明。两行都带前缀,拆开后不会只剩一个数字或一句裸说明。"""
+
+    parts = [part.strip() for part in text.split(";") if part.strip()]
+    if not parts:
+        return ()
+    rows = [f"代入:{parts[0]}"]
+    rows.extend(f"说明:{part}" for part in parts[1:])
+    return tuple(rows)
+
+
+def expectation(line: ConclusionView) -> str:
+    """期望那一行:期望值与当前值,以及符合预期或不符合预期。可疑不下符合与否的结论。"""
+
+    expected = _value_text(line.calc_value)
+    current = _value_text(line.check_value)
+    if expected is None and current is None:
+        if line.verdict == VERDICT_SUSPECT:
+            return "期望值无法计算,当前值缺失。"
+        if line.verdict == VERDICT_CORRECT:
+            return f"期望与当前一致。{_MATCH}"
+        return f"期望与当前不一致。{_MISMATCH}"
+    left = "期望值无法计算" if expected is None else f"期望值是 {expected}"
+    right = "当前值缺失" if current is None else f"当前值是 {current}"
+    if line.verdict == VERDICT_CORRECT:
+        tail = _MATCH
+    elif line.verdict == VERDICT_ERROR:
+        tail = _MISMATCH
+    else:
+        tail = "" if expected is None else _UNDECIDED
+    return f"{left},{right}。{tail}"
+
+
+def render_body(
+    *,
+    business_date: date,
+    trade_date: date,
+    batch_no: str,
+    chain_name: str,
+    lines: Sequence[ConclusionView],
+    record_no: str,
+    extra: str = "",
+) -> str:
+    """营业日、交易日、批次号、涉及在前,其后每个节点一段。超长时从末尾删正确段,开头四行不删。"""
+
+    chain = CHAINS.get(chain_name)
+    if chain is None:
+        raise FeishuError("卡片链路不在登记表之内")
+    header = (
+        f"营业日:{_day_text(business_date)}\n"
+        f"交易日:{_day_text(trade_date)}\n"
+        f"批次号:{_one_line(batch_no, '批次号')}\n"
+        f"涉及:{chain.involved()}"
+    )
+    tail = card_text(extra).strip()
+    blocks = [render_line(line) for line in lines]
+    full = _join([header, *blocks, tail] if tail else [header, *blocks])
+    if len(full) <= BODY_LIMIT:
+        return full
+    # 拆开发送时记录编号写在 extra 里,这里不再加第二行。
+    record = "" if tail else f"记录编号:{_one_line(record_no, '记录编号')}"
+    kept = list(range(len(lines)))
+    while True:
+        pieces = [header, *[blocks[index] for index in kept]]
+        if record:
+            pieces.append(record)
+        if tail:
+            pieces.append(tail)
+        current = _join(pieces)
+        if len(current) <= BODY_LIMIT:
+            return current
+        drop_at = _last_correct(lines, kept)
+        if drop_at is None:
+            # 错误和可疑已经全部保留,不能再删。
+            return current
+        del kept[drop_at]
+
+
+def build_payload(
+    *,
+    title: str,
+    body: str,
+    verdict: str,
+    timestamp: str | None = None,
+    sign: str | None = None,
+) -> dict[str, Any]:
+    """飞书 interactive 卡片。没有密钥时不带 timestamp 和 sign。"""
+
+    template = TEMPLATE_BY_VERDICT.get(verdict)
+    if template is None:
+        raise FeishuError("卡片结论无法对应颜色")
+    payload: dict[str, Any] = {
+        "msg_type": "interactive",
+        "card": {
+            "config": {"wide_screen_mode": True},
+            "header": {
+                "template": template,
+                "title": {"tag": "plain_text", "content": title},
+            },
+            "elements": [
+                {"tag": "div", "text": {"tag": "lark_md", "content": body}},
+            ],
+        },
+    }
+    if timestamp is not None and sign is not None:
+        payload["timestamp"] = timestamp
+        payload["sign"] = sign
+    return payload
+
+
+def body_lines(lines: Sequence[ConclusionView], verdict: str) -> tuple[ConclusionView, ...]:
+    """飞书正文实际写出的结论行。
+
+    主题为错误时只留错误行。同一张卡上的正确行和可疑行仍在库里,不写进这张卡的正文。
+    """
+
+    if verdict == VERDICT_ERROR:
+        return tuple(line for line in lines if line.verdict == VERDICT_ERROR)
+    return tuple(line for line in lines if line.verdict in VERDICT_LABEL)
+
+
+def card_verdict(lines: Sequence[ConclusionView]) -> str:
+    """有错误就是错误,否则有可疑就是可疑,否则是正确。"""
+
+    if not lines:
+        raise FeishuError("没有结论行")
+    found: set[str] = set()
+    for line in lines:
+        if line.verdict not in VERDICT_LABEL:
+            raise FeishuError("结论不在正确、错误、可疑之内")
+        found.add(line.verdict)
+    if VERDICT_ERROR in found:
+        return VERDICT_ERROR
+    if VERDICT_SUSPECT in found:
+        return VERDICT_SUSPECT
+    return VERDICT_CORRECT
+
+
+@dataclass(frozen=True)
+class _PlainLine:
+    """截短后仍能交给 render_line 的结论行。数值不截,期望那一行照常写期望值与当前值。"""
+
+    formula_text: str
+    calc_text: str
+    calc_value: Decimal | None
+    check_text: str
+    check_value: Decimal | None
+    basis: str
+    verdict: str
+
+
+def pack_bodies(
+    *,
+    title: str,
+    verdict: str,
+    business_date: date,
+    trade_date: date,
+    batch_no: str,
+    chain_name: str,
+    record_no: str,
+    lines: Sequence[ConclusionView],
+    signed: bool,
+    limit: int = PAYLOAD_BYTE_LIMIT,
+) -> tuple[str, ...]:
+    """按真实请求体字节数装箱。错误主题只装错误行;放不下就拆成多张,单行仍超限再截短。"""
+
+    usable = body_lines(lines, verdict)
+    if not usable:
+        return ()
+    common = {
+        "title": title,
+        "verdict": verdict,
+        "business_date": business_date,
+        "trade_date": trade_date,
+        "batch_no": batch_no,
+        "chain_name": chain_name,
+        "record_no": record_no,
+        "signed": signed,
+        "limit": limit,
+    }
+    if _fits(usable, extra="", **common):
+        return (
+            render_body(
+                business_date=business_date,
+                trade_date=trade_date,
+                batch_no=batch_no,
+                chain_name=chain_name,
+                lines=usable,
+                record_no=record_no,
+            ),
+        )
+    # 张数还不知道。先按三位数字留出「第999张,共999张」,真实页码更短。
+    reserve = f"第999张,共999张\n记录编号:{_one_line(record_no, '记录编号')}"
+    groups: list[list[ConclusionView]] = []
+    current: list[ConclusionView] = []
+    for line in usable:
+        trial = [*current, line]
+        if current and not _fits(trial, extra=reserve, **common):
+            groups.append(current)
+            current = [line]
+        else:
+            current = trial
+    if current:
+        groups.append(current)
+    fitted: list[tuple[ConclusionView, ...]] = []
+    for group in groups:
+        if _fits(group, extra=reserve, **common):
+            fitted.append(tuple(group))
+            continue
+        # 走到这里的组只有一行,整行放不进 30KB,截短公式和依据。
+        fitted.append((_clip_line(group[0], extra=reserve, **common),))
+    total = len(fitted)
+    bodies: list[str] = []
+    for index, group in enumerate(fitted, start=1):
+        extra = f"第{index}张,共{total}张\n记录编号:{_one_line(record_no, '记录编号')}"
+        bodies.append(
+            render_body(
+                business_date=business_date,
+                trade_date=trade_date,
+                batch_no=batch_no,
+                chain_name=chain_name,
+                lines=group,
+                record_no=record_no,
+                extra=extra,
+            )
+        )
+    return tuple(bodies)
+
+
+def _fits(
+    lines: Sequence[ConclusionView],
+    *,
+    extra: str,
+    title: str,
+    verdict: str,
+    business_date: date,
+    trade_date: date,
+    batch_no: str,
+    chain_name: str,
+    record_no: str,
+    signed: bool,
+    limit: int,
+) -> bool:
+    """用和 httpx 相同的 JSON 编码量字节。"""
+
+    body = render_body(
+        business_date=business_date,
+        trade_date=trade_date,
+        batch_no=batch_no,
+        chain_name=chain_name,
+        lines=lines,
+        record_no=record_no,
+        extra=extra,
+    )
+    payload = build_payload(
+        title=title,
+        body=body,
+        verdict=verdict,
+        timestamp=_TS_PLACEHOLDER if signed else None,
+        sign=_SIGN_PLACEHOLDER if signed else None,
+    )
+    return len(json.dumps(payload).encode("utf-8")) <= limit
+
+
+def _clip_line(
+    line: ConclusionView,
+    *,
+    extra: str,
+    title: str,
+    verdict: str,
+    business_date: date,
+    trade_date: date,
+    batch_no: str,
+    chain_name: str,
+    record_no: str,
+    signed: bool,
+    limit: int,
+) -> _PlainLine:
+    """单行超限时从长文本里砍到能装进一张卡。"""
+
+    values = {
+        "formula_text": line.formula_text,
+        "calc_text": line.calc_text,
+        "calc_value": line.calc_value,
+        "check_text": line.check_text,
+        "check_value": line.check_value,
+        "basis": line.basis,
+        "verdict": line.verdict,
+    }
+    for name in ("calc_text", "check_text", "formula_text", "basis"):
+        text = values[name]
+        lo = 0
+        hi = len(text)
+        best = "…"
+        while lo <= hi:
+            mid = (lo + hi) // 2
+            values[name] = text[:mid] + "…" if mid < len(text) else text
+            candidate = _PlainLine(**values)
+            if _fits(
+                (candidate,),
+                extra=extra,
+                title=title,
+                verdict=verdict,
+                business_date=business_date,
+                trade_date=trade_date,
+                batch_no=batch_no,
+                chain_name=chain_name,
+                record_no=record_no,
+                signed=signed,
+                limit=limit,
+            ):
+                best = values[name]
+                lo = mid + 1
+            else:
+                hi = mid - 1
+        values[name] = best
+        if _fits(
+            (_PlainLine(**values),),
+            extra=extra,
+            title=title,
+            verdict=verdict,
+            business_date=business_date,
+            trade_date=trade_date,
+            batch_no=batch_no,
+            chain_name=chain_name,
+            record_no=record_no,
+            signed=signed,
+            limit=limit,
+        ):
+            return _PlainLine(**values)
+    return _PlainLine(
+        formula_text="…",
+        calc_text="…",
+        calc_value=line.calc_value,
+        check_text="…",
+        check_value=line.check_value,
+        basis="…",
+        verdict=line.verdict,
+    )
+
+
+async def send_card(
+    settings: Settings,
+    card: OutboundCard,
+    *,
+    timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
+    transport: httpx.AsyncBaseTransport | None = None,
+    gate: RateGate | None = None,
+    clock: Callable[[], float] | None = None,
+) -> SendResult:
+    """发送一张明细卡。成功要 HTTP 200 且 code 或 StatusCode 为 0。"""
+
+    if timeout_seconds <= 0:
+        raise FeishuError("飞书超时必须大于 0")
+    if not card.lines:
+        _log_info("没有结论行,不发送")
+        return SendResult(ok=False, retryable=False, attempted=False)
+
+    secret = settings.feishu_sign_secret.get_secret_value()
+    verdict = card_verdict(card.lines)
+    title = render_title(verdict, card.chain_name, card.card_kind, card.biz_key, resend=card.resend)
+    bodies = pack_bodies(
+        title=title,
+        verdict=verdict,
+        business_date=card.business_date,
+        trade_date=card.trade_date,
+        batch_no=card.batch_no,
+        chain_name=card.chain_name,
+        record_no=card.record_no,
+        lines=card.lines,
+        signed=secret != "",
+    )
+    return await _deliver(
+        settings,
+        title,
+        bodies,
+        verdict,
+        timeout_seconds=timeout_seconds,
+        transport=transport,
+        gate=gate,
+        clock=clock,
+    )
+
+
+async def send_summary(
+    settings: Settings,
+    card: OutboundCard,
+    *,
+    timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
+    transport: httpx.AsyncBaseTransport | None = None,
+    gate: RateGate | None = None,
+    clock: Callable[[], float] | None = None,
+) -> SendResult:
+    """发送一条链路的概况。版式与明细卡分开,推送过滤不在这里做。"""
+
+    if timeout_seconds <= 0:
+        raise FeishuError("飞书超时必须大于 0")
+    if card.card_kind != CARD_SUMMARY:
+        raise FeishuError("不是链路概况")
+    if not card.owners:
+        _log_info("没有归属,不发送概况")
+        return SendResult(ok=False, retryable=False, attempted=False)
+    title = render_summary_title(card.chain_name, card.owners, resend=card.resend)
+    body = render_summary_body(card.owners)
+    return await _deliver(
+        settings,
+        title,
+        (body,),
+        summary_verdict(card.owners),
+        timeout_seconds=timeout_seconds,
+        transport=transport,
+        gate=gate,
+        clock=clock,
+    )
+
+
+async def _deliver(
+    settings: Settings,
+    title: str,
+    bodies: Sequence[str],
+    verdict: str,
+    *,
+    timeout_seconds: float,
+    transport: httpx.AsyncBaseTransport | None,
+    gate: RateGate | None,
+    clock: Callable[[], float] | None,
+) -> SendResult:
+    """按限速逐张 POST。某一张失败就停,前面已发出的下次会再发一遍。"""
+
+    webhook = settings.feishu_webhook.get_secret_value().strip()
+    secret = settings.feishu_sign_secret.get_secret_value()
+    if not webhook.strip():
+        raise FeishuError("未配置飞书 Webhook")
+    # 没配推送类型时,挂在已有发送日志上,不另起一条。
+    push_note = push_log_suffix(settings)
+    limiter = gate or DEFAULT_GATE
+    total = len(bodies)
+    for index, body in enumerate(bodies, start=1):
+        await limiter.acquire()
+        # 签名用墙上时间的秒,限速等待结束后再取,避免排队后时间戳过期。
+        timestamp = str(int((clock or time.time)()))
+        signed = secret != ""
+        payload = build_payload(
+            title=title,
+            body=body,
+            verdict=verdict,
+            timestamp=timestamp if signed else None,
+            sign=sign_payload(timestamp, secret) if signed else None,
+        )
+        try:
+            status, raw = await _post(webhook, payload, timeout_seconds, transport)
+        except httpx.RequestError as exc:
+            return _network_result(exc, webhook, secret, push_note)
+        ok, retryable, business_code = classify_response(status, raw)
+        label = title if total == 1 else f"{title}(第{index}张,共{total}张)"
+        if ok:
+            _log_info(f"飞书卡片已发送,{label}{push_note}", webhook, secret)
+            continue
+        retry_text = "可重试" if retryable else "不可重试"
+        code_text = "-" if business_code is None else str(business_code)
+        note = _response_note(raw)
+        suffix = f":{note}" if note else ""
+        _log_error(
+            f"飞书发送未成功,HTTP {status},业务码 {code_text},{retry_text}{suffix}{push_note}",
+            webhook,
+            secret,
+        )
+        # 前面几张已经发出。整条记录记失败,下次启动会把这张卡的各部分再发一遍。
+        return SendResult(ok=False, retryable=retryable, attempted=True)
+    return SendResult(ok=True, retryable=False, attempted=True)
+
+
+def classify_response(status: int, raw: bytes) -> tuple[bool, bool, int | None]:
+    """区分成功、可重试和不可重试。HTTP 429 与 5xx 优先于正文里的业务码。"""
+
+    business_code = _business_code(raw)
+    # 429 和 5xx 先于正文。签名失败码和其他非 0 业务码都不重试。
+    if status == 429 or 500 <= status <= 599:
+        return False, True, business_code
+    if business_code in SIGN_FAILURE_CODES:
+        return False, False, business_code
+    if status == 200 and business_code == 0:
+        return True, False, 0
+    return False, False, business_code
+
+
+async def _post(
+    webhook: str,
+    payload: dict[str, Any],
+    timeout_seconds: float,
+    transport: httpx.AsyncBaseTransport | None,
+) -> tuple[int, bytes]:
+    """POST 一次。不跟随重定向,不读取进程里的代理环境变量。"""
+
+    timeout = httpx.Timeout(timeout_seconds)
+    async with httpx.AsyncClient(
+        timeout=timeout,
+        follow_redirects=False,
+        trust_env=False,
+        transport=transport,
+    ) as client:
+        response = await client.post(webhook, json=payload)
+        return response.status_code, response.content
+
+
+def _network_result(exc: httpx.RequestError, webhook: str, secret: str, push_note: str = "") -> SendResult:
+    """网络失败可重试。地址类错误重投没有意义。两边都先抹掉地址和密钥。"""
+
+    detail = redact(str(exc), webhook, secret)
+    retryable = isinstance(exc, (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError))
+    if retryable:
+        _log_error(f"飞书发送失败,网络错误,可重试:{detail}{push_note}", webhook, secret)
+    else:
+        _log_error(f"飞书发送失败,请求无法发出,不可重试:{detail}{push_note}", webhook, secret)
+    return SendResult(ok=False, retryable=retryable, attempted=True)
+
+
+def _response_note(raw: bytes) -> str:
+    """取出飞书返回的 msg,压成一行。密钥和地址由调用方再抹掉。"""
+
+    try:
+        payload = json.loads(raw.decode("utf-8"))
+    except (UnicodeDecodeError, json.JSONDecodeError):
+        return ""
+    if not isinstance(payload, dict):
+        return ""
+    for key in ("msg", "StatusMessage", "message"):
+        value = payload.get(key)
+        if isinstance(value, str) and value.strip():
+            return card_text(value).replace("\n", " ").strip()[:200]
+    return ""
+
+
+def _business_code(raw: bytes) -> int | None:
+    """只认整数。有 code 时用 code,否则用 StatusCode。"""
+
+    try:
+        payload = json.loads(raw.decode("utf-8"))
+    except (UnicodeDecodeError, json.JSONDecodeError):
+        return None
+    if not isinstance(payload, dict):
+        return None
+    if "code" in payload:
+        value = payload["code"]
+        return value if type(value) is int else None
+    if "StatusCode" in payload:
+        value = payload["StatusCode"]
+        return value if type(value) is int else None
+    return None
+
+
+def _last_correct(lines: Sequence[ConclusionView], kept: Sequence[int]) -> int | None:
+    """kept 里最后一条正确行的下标。没有正确行就返回空。"""
+
+    for position in range(len(kept) - 1, -1, -1):
+        if lines[kept[position]].verdict == VERDICT_CORRECT:
+            return position
+    return None
+
+
+def _join(parts: Sequence[str]) -> str:
+    return "\n\n".join(parts)
+
+
+def _value_text(value: object) -> str | None:
+    """期望值、当前值的写法:去掉多余的尾零,至少两位小数。首发与读库重发写出来一样。"""
+
+    if value is None or isinstance(value, bool):
+        return None
+    try:
+        number = value if isinstance(value, Decimal) else Decimal(str(value))
+    except InvalidOperation:
+        return None
+    if not number.is_finite():
+        return None
+    if number == 0:
+        number = Decimal(0)
+    reduced = number.normalize()
+    exponent = reduced.as_tuple().exponent
+    if isinstance(exponent, int) and exponent > -2:
+        reduced = reduced.quantize(Decimal("0.01"))
+    return format(reduced, "f")
+
+
+def _day_text(value: date) -> str:
+    if isinstance(value, datetime):
+        return value.date().isoformat()
+    if isinstance(value, date):
+        return value.isoformat()
+    raise FeishuError("营业日或交易日不是日期")
+
+
+def _one_line(value: str, label: str) -> str:
+    text = card_text(value).strip()
+    if not text or "\n" in text:
+        raise FeishuError(f"{label}不能为空或换行")
+    return text
+
+
+def _log_info(text: str, *secrets: str) -> None:
+    logger.info("{}", redact(text, *secrets))
+
+
+def _log_error(text: str, *secrets: str) -> None:
+    logger.error("{}", redact(text, *secrets))

+ 1006 - 0
code/post_clear_check/src/post_clear_check/fund_chain.py

@@ -0,0 +1,1006 @@
+"""账户资产、资金变动、资金交收的步骤节点。
+
+销售只读本批 kind=input 与 kind=cleared。第 3 步和第 4 步没有快照,
+用 input 加本批 cleared 订单上的发行结果重算,再与 cleared 比。
+不读 pre_clearing。发行交收台账读 work/4,cleared/0 应与它一致。
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from collections.abc import Mapping, Sequence
+from datetime import date, datetime
+from decimal import Decimal, ROUND_HALF_UP
+from typing import Any
+
+from post_clear_check.issue_common import as_date, mappings
+from post_clear_check.order_decimal import SCALE_AMT, VERDICT_CORRECT, VERDICT_ERROR, format_decimal
+from post_clear_check.sales_common import emit
+
+OPENING = "期初钱包"
+T_WALLET = "T日钱包"
+CANDIDATE = "候选范围"
+RAW_LEDGER = "原始流水"
+T_LEDGER = "T日流水"
+DELAYED = "延后流水"
+ISSUE_DERIVE = "交收推演"
+CLEARED_SETTLE = "T日交收结果"
+HISTORY_UNSETTLED = "历史未交收"
+SALES_SETTLE = "销售交收"
+
+FORMULA_OPENING = f"{OPENING} = 上一已发布 cleared 的科目,且当日买卖为 0"
+FORMULA_T_WALLET = f"{T_WALLET} = 期初加 input 的 T 日流水效果、订单解冻与待交收、到期交收,当日买卖为 0"
+FORMULA_CANDIDATE = f"{CANDIDATE} = cleared 不含营业日晚于 T 的流水,也不含后续当日买卖"
+FORMULA_RAW = (
+    f"{RAW_LEDGER} = 营业日不早于 T 的 input 流水步骤号不是 3 或 4;"
+    "早于 T 的第 3 步买卖待交收是未交收来源。状态只允许 posted 或 reversed"
+)
+FORMULA_T_LEDGER = f"{T_LEDGER} = input 的 T 日原始流水加步骤 3、4 重算流水,等于 cleared"
+FORMULA_DELAYED = f"{DELAYED} = 营业日晚于 T 且不在 input 的实时流水不得进入 cleared"
+FORMULA_ISSUE = f"{ISSUE_DERIVE} = work/4 按子订单唯一,历史未交收仍在,金额与状态符合交收规则"
+FORMULA_CLEARED_SETTLE = f"{CLEARED_SETTLE} = cleared/0 交收台账与 work/4 的状态、日期、金额一致"
+FORMULA_HISTORY = f"{HISTORY_UNSETTLED} = input 中早于 T 的买卖来源合计等于期初待交收"
+FORMULA_SALES_SETTLE = f"{SALES_SETTLE} = 到期来源恰好生成一笔交收流水,未到期不生成"
+
+_STEP_SALES = "研发设计 docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md 「5.2 销售端(7 步)」"
+_STEP_ISSUE = "研发设计 docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md 「5.1 发行端(6 步)」"
+
+_MONEY = Decimal("0.01")
+_ZERO = Decimal("0.00")
+# 总钱包科目。分市场钱包不在本窗口另建节点。
+_BUCKETS = (
+    "cash_settled",
+    "cash_buy_unsettled",
+    "cash_sell_unsettled",
+    "buy_today",
+    "sell_today",
+    "trade_frozen",
+    "nontrade_frozen",
+)
+_NON_NEGATIVE = frozenset(_BUCKETS) - {"cash_settled"}
+_LEDGER_STATUS = frozenset({"posted", "reversed"})
+_DONE = frozenset({"completed", "settled"})
+_TERMINAL = _DONE | frozenset({"canceled", "rejected"})
+_NOTE_TYPES = frozenset({"buy_note", "sell_note"})
+
+
+def check_account_asset(ctx: Any, lines: list) -> None:
+    """账户资产的第 2、3、4、5 步。只核本批归档里的 T 日钱包,不读实时钱包。"""
+
+    if not _loaded(ctx):
+        return
+    trade_day = _trade_day(ctx)
+    for fund_no in _population(ctx):
+        bucket = ctx.fund_accounts[fund_no]
+        for ccy in _currencies(ctx, fund_no):
+            opening, open_problems = _opening_buckets(ctx, bucket, ccy)
+            _one(lines, FORMULA_OPENING, _STEP_SALES, fund_no, open_problems, f"{ccy} 期初")
+            wallet_problems, rebuilt = _rebuild_wallet(ctx, bucket, fund_no, ccy, opening, trade_day)
+            cleared = _wallet_row(bucket, "cleared_wallet_raw", ccy)
+            if cleared is None:
+                wallet_problems.append("cleared 没有该币种钱包")
+            elif rebuilt is not None:
+                wallet_problems.extend(_bucket_gaps(rebuilt, cleared, "重算", "cleared"))
+            _one(lines, FORMULA_T_WALLET, _STEP_SALES, fund_no, wallet_problems, f"{ccy} T日钱包")
+            _one(
+                lines,
+                FORMULA_CANDIDATE,
+                _STEP_SALES,
+                fund_no,
+                _candidate_problems(ctx, bucket, ccy),
+                f"{ccy} 候选",
+            )
+
+
+def check_cash_change(ctx: Any, lines: list) -> None:
+    """资金变动的原始流水、T 日重算流水和延后流水。OTC002 仍对 cleared 的 T 日流水。"""
+
+    if not _loaded(ctx):
+        return
+    trade_day = _trade_day(ctx)
+    for fund_no in _population(ctx):
+        bucket = ctx.fund_accounts[fund_no]
+        _one(lines, FORMULA_RAW, _STEP_SALES, fund_no, _raw_problems(ctx, bucket), "原始流水")
+        _one(
+            lines,
+            FORMULA_T_LEDGER,
+            _STEP_SALES,
+            fund_no,
+            _t_ledger_problems(ctx, bucket, fund_no, trade_day),
+            "T日流水",
+        )
+        _one(lines, FORMULA_DELAYED, _STEP_SALES, fund_no, _delayed_problems(ctx, bucket), "延后流水")
+
+
+def check_sales_settlement(ctx: Any, lines: list) -> None:
+    """销售资金交收的期初来源和到期交收。不读 pre_clearing。"""
+
+    if not _loaded(ctx):
+        return
+    trade_day = _trade_day(ctx)
+    for fund_no in _population(ctx):
+        bucket = ctx.fund_accounts[fund_no]
+        for ccy in _currencies(ctx, fund_no):
+            _one(
+                lines,
+                FORMULA_HISTORY,
+                _STEP_SALES,
+                fund_no,
+                _history_problems(ctx, bucket, ccy),
+                f"{ccy} 历史未交收",
+            )
+        _one(
+            lines,
+            FORMULA_SALES_SETTLE,
+            _STEP_SALES,
+            fund_no,
+            _sales_settle_problems(ctx, bucket, fund_no, trade_day),
+            "销售交收",
+        )
+
+
+def check_issue_settlement(ctx: Any, lines: list) -> None:
+    """发行交收读 work/4,并要求 cleared/0 与它一致。"""
+
+    stages = getattr(ctx, "issue_stages", None)
+    if not isinstance(stages, Mapping) or "settlement_ledger" not in _scope(stages, "work/4"):
+        return
+    trade_day = _trade_day(ctx)
+    business_day = ctx.business_date
+    work_rows = _scope_rows(stages, "work/4", "settlement_ledger")
+    cleared_rows = _scope_rows(stages, "cleared/0", "settlement_ledger")
+    input_rows = _scope_rows(stages, "input/0", "settlement_ledger")
+    work_by_sub = _by_sub(work_rows)
+    cleared_by_sub = _by_sub(cleared_rows)
+    fund_of = _order_funds(ctx)
+    subs = sorted(set(work_by_sub) | set(cleared_by_sub) | {_sub_id(row) for row in input_rows if _sub_id(row)})
+    for sub_id in subs:
+        fund_no = _fund_for_sub(work_by_sub.get(sub_id, ()) + cleared_by_sub.get(sub_id, ()), fund_of)
+        _one(
+            lines,
+            FORMULA_ISSUE,
+            _STEP_ISSUE,
+            fund_no,
+            _derive_problems(ctx, work_by_sub.get(sub_id, ()), input_rows, sub_id, trade_day, business_day),
+            f"子订单 {sub_id} 交收推演",
+        )
+        _one(
+            lines,
+            FORMULA_CLEARED_SETTLE,
+            _STEP_ISSUE,
+            fund_no,
+            _cleared_settle_problems(work_by_sub.get(sub_id, ()), cleared_by_sub.get(sub_id, ())),
+            f"子订单 {sub_id} T日交收",
+        )
+
+
+def _loaded(ctx: Any) -> bool:
+    return getattr(ctx, "sales_fund_loaded", False) is True and _trade_day(ctx) is not None
+
+
+def _trade_day(ctx: Any) -> date | None:
+    value = getattr(ctx, "trade_date", None)
+    if isinstance(value, datetime):
+        return value.date()
+    if isinstance(value, date):
+        return value
+    return None
+
+
+def _population(ctx: Any) -> list[str]:
+    """本批 input 或 cleared 能落到的资金账户。只有报表、没有这些行的账户不跑步骤节点。"""
+
+    found: list[str] = []
+    funds = ctx.fund_accounts
+    if not isinstance(funds, Mapping):
+        return found
+    for fund_no in sorted(funds):
+        bucket = funds[fund_no]
+        if not isinstance(bucket, Mapping):
+            continue
+        if any(mappings(bucket.get(key)) for key in ("input_ledger", "cleared_ledger", "cleared_wallet_raw", "baseline_wallet")):
+            found.append(fund_no)
+    return found
+
+
+def _currencies(ctx: Any, fund_no: str) -> list[str]:
+    bucket = ctx.fund_accounts[fund_no]
+    found: set[str] = set()
+    for key in ("baseline_wallet", "cleared_wallet_raw"):
+        for row in mappings(bucket.get(key)):
+            ccy = _text(row.get("ccy"))
+            if ccy:
+                found.add(ccy)
+    for key in ("input_ledger", "cleared_ledger", "later_ledger"):
+        for row in mappings(bucket.get(key)):
+            ccy = _text(row.get("ccy"))
+            if ccy:
+                found.add(ccy)
+    for order in _orders_of(ctx, fund_no):
+        ccy = _text(order.get("settle_ccy"))
+        if ccy:
+            found.add(ccy)
+    return sorted(found)
+
+
+def _opening_buckets(ctx: Any, bucket: Mapping[str, Any], ccy: str) -> tuple[dict[str, Decimal] | None, list[str]]:
+    """期初取上一已发布 cleared。没有上一批时从 0 起,不能拿实时余额倒减。"""
+
+    state = getattr(ctx, "sales_baseline_state", "")
+    prior = [
+        row for row in mappings(bucket.get("input_ledger"))
+        if _day(row.get("business_date")) is not None
+        and _day(row.get("business_date")) < ctx.business_date
+        and row.get("ccy") == ccy
+    ]
+    if state == "invalid":
+        return None, ["上一已发布 cleared 不可用"]
+    row = _wallet_row(bucket, "baseline_wallet", ccy)
+    if state == "none":
+        if prior:
+            return None, ["没有上一已发布 cleared,但 input 有更早营业日的流水"]
+        return {name: _ZERO for name in _BUCKETS}, []
+    if row is None:
+        if prior:
+            return None, ["上一已发布 cleared 没有该币种,但 input 有更早营业日的流水"]
+        return {name: _ZERO for name in _BUCKETS}, []
+    problems: list[str] = []
+    amounts = _read_buckets(row, problems, "期初")
+    if amounts is None:
+        return None, problems
+    if amounts["buy_today"] != _ZERO or amounts["sell_today"] != _ZERO:
+        problems.append("上一已发布 cleared 的当日买卖不是 0")
+    return amounts, problems
+
+
+def _rebuild_wallet(
+    ctx: Any,
+    bucket: Mapping[str, Any],
+    fund_no: str,
+    ccy: str,
+    opening: Mapping[str, Decimal] | None,
+    trade_day: date | None,
+) -> tuple[list[str], dict[str, Decimal] | None]:
+    """用 input 的 T 日效果和订单重算 T 日钱包。算完后当日买卖归零。"""
+
+    problems: list[str] = []
+    if opening is None or trade_day is None:
+        return ["期初不能用,T日钱包无法重算"], None
+    current = dict(opening)
+    # 按主键升序重放。查询走 idem_hash 唯一索引,返回顺序不是入账顺序。
+    for row in _in_posting_order(mappings(bucket.get("input_ledger"))):
+        if row.get("ccy") != ccy or _day(row.get("business_date")) != ctx.business_date:
+            continue
+        effect = _ledger_effect(row, problems, "input")
+        if effect is None:
+            continue
+        _apply(current, effect, problems)
+    released = _released_orders(bucket)
+    for order in _orders_of(ctx, fund_no):
+        if order.get("settle_ccy") != ccy or _day(order.get("trade_date")) != trade_day:
+            continue
+        for effect in _order_effects(order, problems, released):
+            _apply(current, effect, problems)
+    if getattr(ctx, "sales_settle_day", None) is True:
+        for source in _due_sources(ctx, bucket, fund_no, ccy, trade_day, problems):
+            _apply(current, _settlement_effect(source), problems)
+    elif getattr(ctx, "sales_settle_day", None) is None and _settle_sources(bucket, fund_no, ctx, ccy):
+        problems.append("封存交收日历缺失或不唯一")
+    current["buy_today"] = _ZERO
+    current["sell_today"] = _ZERO
+    return problems, current
+
+
+def _candidate_problems(ctx: Any, bucket: Mapping[str, Any], ccy: str) -> list[str]:
+    """cleared 是 T 日候选,不能混入后续流水或后续当日买卖。"""
+
+    problems: list[str] = []
+    for row in mappings(bucket.get("cleared_ledger")):
+        if row.get("ccy") != ccy:
+            continue
+        day = _day(row.get("business_date"))
+        if day is not None and day > ctx.business_date:
+            problems.append(f"cleared 流水 {row.get('idem_key') or row.get('id')} 营业日晚于 T")
+    row = _wallet_row(bucket, "cleared_wallet_raw", ccy)
+    if row is not None:
+        buy = _money(row.get("buy_today"))
+        sell = _money(row.get("sell_today"))
+        if buy not in (None, _ZERO) or sell not in (None, _ZERO):
+            problems.append("cleared 当日买卖不是 0")
+    return problems
+
+
+def _raw_problems(ctx: Any, bucket: Mapping[str, Any]) -> list[str]:
+    problems: list[str] = []
+    for row in mappings(bucket.get("input_ledger")):
+        step = row.get("clearing_step_no")
+        # 上一日未交收的第 3 步买卖流水会原样封进本日 input,步骤号仍是 3。
+        if step in (3, 4) and not _carried_unsettled(row, ctx.business_date):
+            problems.append(f"input 流水 {row.get('idem_key') or row.get('id')} 的步骤号是 {step}")
+        status = row.get("status")
+        if status not in _LEDGER_STATUS:
+            problems.append(f"input 流水 {row.get('idem_key') or row.get('id')} 状态 {status}")
+    return problems
+
+
+def _carried_unsettled(row: Mapping[str, Any], business_day: date) -> bool:
+    """上一批写下、本批封存的第 3 步买卖待交收。金额为 0 或当日流水都不是。"""
+
+    if row.get("clearing_step_no") != 3:
+        return False
+    if row.get("business_type") not in _NOTE_TYPES or row.get("status") != "posted":
+        return False
+    day = _day(row.get("business_date"))
+    if day is None or day >= business_day:
+        return False
+    amount = _money(row.get("amount"))
+    return amount is not None and amount != _ZERO
+
+
+def _t_ledger_problems(ctx: Any, bucket: Mapping[str, Any], fund_no: str, trade_day: date | None) -> list[str]:
+    """cleared 的 T 日流水应等于 input 的 T 日原始流水加上重算的步骤 3、4。"""
+
+    problems: list[str] = []
+    expected: dict[str, tuple[str, Decimal]] = {}
+    for row in mappings(bucket.get("input_ledger")):
+        if _day(row.get("business_date")) != ctx.business_date:
+            continue
+        key = _text(row.get("idem_key"))
+        amount = _money(row.get("amount"))
+        kind = _text(row.get("business_type"))
+        if key is None or amount is None or kind is None:
+            problems.append(f"input 的 T 日流水 {row.get('id')} 缺少幂等键、金额或业务类型")
+            continue
+        expected[key] = (kind, amount)
+    for order in _orders_of(ctx, fund_no):
+        if trade_day is None or _day(order.get("trade_date")) != trade_day:
+            continue
+        for item in _order_ledgers(order, problems):
+            expected[item[0]] = (item[1], item[2])
+    if getattr(ctx, "sales_settle_day", None) is True:
+        for source in _due_sources(ctx, bucket, fund_no, None, trade_day, problems):
+            key, kind, amount = _settlement_ledger(source)
+            expected[key] = (kind, amount)
+    actual: dict[str, tuple[str, Decimal]] = {}
+    for row in mappings(bucket.get("cleared_ledger")):
+        if _day(row.get("business_date")) != ctx.business_date:
+            continue
+        key = _text(row.get("idem_key"))
+        amount = _money(row.get("amount"))
+        kind = _text(row.get("business_type"))
+        if key is None or amount is None or kind is None:
+            problems.append(f"cleared 的 T 日流水 {row.get('id')} 缺少幂等键、金额或业务类型")
+            continue
+        if key in actual:
+            problems.append(f"cleared 幂等键重复 {key}")
+        actual[key] = (kind, amount)
+    for key in sorted(set(expected) | set(actual)):
+        if key not in expected:
+            problems.append(f"cleared 多出 T 日流水 {key}")
+        elif key not in actual:
+            problems.append(f"cleared 缺少 T 日流水 {key}")
+        elif expected[key] != actual[key]:
+            problems.append(f"T 日流水 {key} 与重算不一致")
+    return problems
+
+
+def _delayed_problems(ctx: Any, bucket: Mapping[str, Any]) -> list[str]:
+    """延后入站和回调不在 input 里,营业日晚于 T,也不能写进 cleared。"""
+
+    problems: list[str] = []
+    frozen = {_int_id(row.get("src_id")) for row in mappings(bucket.get("input_ledger"))}
+    frozen.discard(None)
+    cleared = {_int_id(row.get("src_id")) for row in mappings(bucket.get("cleared_ledger"))}
+    cleared.discard(None)
+    for row in mappings(bucket.get("later_ledger")):
+        ident = _int_id(row.get("id"))
+        if ident is not None and ident in frozen:
+            continue
+        day = _day(row.get("business_date"))
+        if day is None or day <= ctx.business_date:
+            problems.append(f"延后流水 {ident} 营业日不晚于 T")
+        if ident is not None and ident in cleared:
+            problems.append(f"延后流水 {ident} 出现在 cleared")
+    return problems
+
+
+def _history_problems(ctx: Any, bucket: Mapping[str, Any], ccy: str) -> list[str]:
+    """早于 T 的买卖来源合计必须等于期初待交收。第 4 步交收流水不算来源。"""
+
+    problems: list[str] = []
+    totals = {"cash_buy_unsettled": _ZERO, "cash_sell_unsettled": _ZERO}
+    for row in mappings(bucket.get("input_ledger")):
+        if row.get("ccy") != ccy:
+            continue
+        step = row.get("clearing_step_no")
+        # 第 4 步是交收结果。第 3 步只有早于 T 的买卖待交收才是期初来源。
+        if step == 4 or (step == 3 and not _carried_unsettled(row, ctx.business_date)):
+            continue
+        day = _day(row.get("business_date"))
+        if day is None or day >= ctx.business_date or row.get("status") != "posted":
+            continue
+        kind = row.get("business_type")
+        amount = _money(row.get("amount"))
+        if kind not in _NOTE_TYPES or amount is None or amount == _ZERO:
+            continue
+        field = "cash_buy_unsettled" if kind == "buy_note" else "cash_sell_unsettled"
+        totals[field] = _round(totals[field] + abs(amount))
+    state = getattr(ctx, "sales_baseline_state", "")
+    row = _wallet_row(bucket, "baseline_wallet", ccy)
+    if state == "none":
+        expected = {"cash_buy_unsettled": _ZERO, "cash_sell_unsettled": _ZERO}
+    elif row is None:
+        if totals["cash_buy_unsettled"] == _ZERO and totals["cash_sell_unsettled"] == _ZERO:
+            return problems
+        return ["期初没有该币种,历史未交收来源无法对上"]
+    else:
+        expected_row = _read_buckets(row, problems, "期初")
+        if expected_row is None:
+            return problems
+        expected = expected_row
+    for field in ("cash_buy_unsettled", "cash_sell_unsettled"):
+        if totals[field] != expected[field]:
+            problems.append(
+                f"{field} 期初 {format_decimal(expected[field], SCALE_AMT)} "
+                f"来源合计 {format_decimal(totals[field], SCALE_AMT)}"
+            )
+    return problems
+
+
+def _sales_settle_problems(
+    ctx: Any,
+    bucket: Mapping[str, Any],
+    fund_no: str,
+    trade_day: date | None,
+) -> list[str]:
+    """到期来源恰好一条交收流水。未到期的来源不能在 cleared 里出现交收。"""
+
+    problems: list[str] = []
+    settle_day = getattr(ctx, "sales_settle_day", None)
+    sources = _settle_sources(bucket, fund_no, ctx, None)
+    if settle_day is None and sources:
+        return ["封存交收日历缺失或不唯一"]
+    due_keys: set[str] = set()
+    if settle_day is True:
+        for source in _due_sources(ctx, bucket, fund_no, None, trade_day, problems):
+            due_keys.add(_settlement_ledger(source)[0])
+    actual = {
+        _text(row.get("idem_key"))
+        for row in mappings(bucket.get("cleared_ledger"))
+        if row.get("clearing_step_no") == 4 and _text(row.get("idem_key"))
+    }
+    for source in sources:
+        key = _settlement_ledger(source)[0]
+        order = _order_by_no(ctx, _text(source.get("ref_no")) or "")
+        due = settle_day is True and _is_due(order, ctx.business_date)
+        if due and key not in actual:
+            problems.append(f"到期来源 {source.get('idem_key') or source.get('ref_no')} 没有交收流水")
+        if not due and key in actual:
+            problems.append(f"未到期来源 {source.get('idem_key') or source.get('ref_no')} 生成了交收流水")
+    return problems
+
+
+def _derive_problems(
+    ctx: Any,
+    rows: Sequence[Mapping[str, Any]],
+    input_rows: Sequence[Mapping[str, Any]],
+    sub_id: str,
+    trade_day: date | None,
+    business_day: date,
+) -> list[str]:
+    """work/4 同一子订单一行。历史空交收日的台账不能在重算后消失。"""
+
+    problems: list[str] = []
+    if len(rows) > 1:
+        problems.append("work/4 同一子订单有多行")
+    carried = [
+        row for row in input_rows
+        if _sub_id(row) == sub_id and _day(row.get("actual_settle_date")) is None and _before_trade(row, trade_day)
+    ]
+    if carried and not rows:
+        problems.append("input 中的历史未交收台账在 work/4 消失")
+    if len(rows) != 1 or trade_day is None:
+        return problems
+    row = rows[0]
+    order_day = _day(row.get("trade_date"))
+    actual = _day(row.get("actual_settle_date"))
+    status = _text(row.get("status"))
+    if order_day is not None and order_day < trade_day and actual == business_day:
+        if status != "settled":
+            problems.append("历史单本批交收后状态不是 settled")
+        problems.extend(_amount_problems(ctx, row))
+    if order_day == trade_day:
+        if status != "pending":
+            problems.append("当日新台账状态不是 pending")
+        if actual is not None:
+            problems.append("当日新台账不应有实际交收日")
+    return problems
+
+
+def _cleared_settle_problems(
+    work_rows: Sequence[Mapping[str, Any]],
+    cleared_rows: Sequence[Mapping[str, Any]],
+) -> list[str]:
+    if len(work_rows) != 1 or len(cleared_rows) != 1:
+        if not work_rows and not cleared_rows:
+            return []
+        return ["cleared/0 与 work/4 的台账行数不一致"]
+    problems: list[str] = []
+    left, right = work_rows[0], cleared_rows[0]
+    for field, label in (
+        ("status", "状态"),
+        ("expect_settle_date", "预计交收日"),
+        ("actual_settle_date", "实际交收日"),
+    ):
+        if field.endswith("date"):
+            if _day(left.get(field)) != _day(right.get(field)):
+                problems.append(f"{label}不一致")
+        elif left.get(field) != right.get(field):
+            problems.append(f"{label}不一致")
+    if _money(left.get("settle_amount")) != _money(right.get("settle_amount")):
+        problems.append("交收金额不一致")
+    return problems
+
+
+def _amount_problems(ctx: Any, row: Mapping[str, Any]) -> list[str]:
+    """买入金额等于执行发行价,卖出等于执行总金额。对不上订单时不能当成 0。"""
+
+    order_no = _text(row.get("order_no"))
+    orders = getattr(ctx, "orders", {})
+    bucket = orders.get(order_no) if isinstance(orders, Mapping) and order_no else None
+    if not isinstance(bucket, Mapping):
+        return ["缺少订单,交收金额无法重算"]
+    subs = [item for item in mappings(bucket.get("order_sub_a")) if str(item.get("id")) == _sub_id(row)]
+    if len(subs) != 1:
+        return ["缺少子订单执行金额"]
+    direction = _text(row.get("direction")) or _text(_master(bucket).get("direction"))
+    field = "exec_issue_price_settle" if direction == "buy" else "exec_total_amt_settle"
+    expected = _money(subs[0].get(field))
+    actual = _money(row.get("settle_amount"))
+    if expected is None or actual is None:
+        return ["交收金额或执行金额缺失"]
+    if expected != actual:
+        return [f"交收金额 {format_decimal(actual, SCALE_AMT)} 与执行金额 {format_decimal(expected, SCALE_AMT)} 不一致"]
+    return []
+
+
+def _due_sources(
+    ctx: Any,
+    bucket: Mapping[str, Any],
+    fund_no: str,
+    ccy: str | None,
+    trade_day: date | None,
+    problems: list[str],
+) -> list[Mapping[str, Any]]:
+    found: list[Mapping[str, Any]] = []
+    for source in _settle_sources(bucket, fund_no, ctx, ccy):
+        order = _order_by_no(ctx, _text(source.get("ref_no")) or "")
+        if order is None:
+            problems.append(f"交收来源 {source.get('ref_no')} 对不上订单")
+            continue
+        if _is_due(order, ctx.business_date):
+            found.append(source)
+    return found
+
+
+def _settle_sources(
+    bucket: Mapping[str, Any],
+    fund_no: str,
+    ctx: Any,
+    ccy: str | None,
+) -> list[dict[str, Any]]:
+    """历史未交收来源加上本批步骤 3 重算的买卖待交收。金额为 0 的解冻不是来源。"""
+
+    found: list[dict[str, Any]] = []
+    for row in mappings(bucket.get("input_ledger")):
+        if ccy is not None and row.get("ccy") != ccy:
+            continue
+        if row.get("business_type") not in _NOTE_TYPES or row.get("status") != "posted":
+            continue
+        step = row.get("clearing_step_no")
+        if step == 4 or (step == 3 and not _carried_unsettled(row, ctx.business_date)):
+            continue
+        amount = _money(row.get("amount"))
+        if amount is None or amount == _ZERO:
+            continue
+        if not _text(row.get("idem_hash")):
+            continue
+        found.append(dict(row))
+    trade_day = _trade_day(ctx)
+    for order in _orders_of(ctx, fund_no):
+        if ccy is not None and order.get("settle_ccy") != ccy:
+            continue
+        # 步骤 3 的待交收只来自本交易日订单。往日来源已经在 input 里,不能再造一笔。
+        if trade_day is None or _day(order.get("trade_date")) != trade_day:
+            continue
+        for item in _order_ledgers(order, []):
+            key, kind, amount = item
+            if amount == _ZERO:
+                continue
+            found.append({
+                "ref_no": order.get("order_no"),
+                "business_type": kind,
+                "amount": amount,
+                "idem_key": key,
+                "idem_hash": _hash_text(key),
+                "ccy": order.get("settle_ccy"),
+            })
+    return found
+
+
+def _is_due(order: Mapping[str, Any] | None, business_day: date) -> bool:
+    if order is None or order.get("status") not in _DONE:
+        return False
+    actual = _day(order.get("actual_settle_date"))
+    return actual is not None and actual <= business_day
+
+
+def _in_posting_order(rows: Sequence[Mapping[str, Any]]) -> list[Mapping[str, Any]]:
+    """按流水主键升序重放。没有主键的行放在最后,避免和真实入账抢顺序。"""
+
+    return sorted(rows, key=lambda row: (_int_id(row.get("id")) is None, _int_id(row.get("id")) or 0))
+
+
+def _released_orders(bucket: Mapping[str, Any]) -> set[str]:
+    """input 里仍为 posted 的解冻凭证。步骤 3 撞上同一幂等键时不再扣交易冻结。"""
+
+    found: set[str] = set()
+    suffix = ":unfreeze"
+    for row in mappings(bucket.get("input_ledger")):
+        if row.get("status") != "posted":
+            continue
+        key = _text(row.get("idem_key"))
+        if key is None or not key.endswith(suffix):
+            continue
+        order_no = key[: -len(suffix)]
+        if order_no:
+            found.add(order_no)
+    return found
+
+
+def _order_effects(
+    order: Mapping[str, Any],
+    problems: list[str],
+    released: set[str],
+) -> list[dict[str, Decimal]]:
+    """步骤 3:买入解冻,已完成订单把执行总金额记入待交收。
+
+    盘中解冻已经在 input 里时,清算复用同一幂等键,钱包效果不能再扣一次。
+    """
+
+    if order.get("status") not in _TERMINAL:
+        return []
+    effects: list[dict[str, Decimal]] = []
+    frozen = _money(order.get("frozen_settle"))
+    order_no = _text(order.get("order_no"))
+    if (
+        order.get("direction") == "buy"
+        and frozen is not None
+        and frozen > _ZERO
+        and order_no not in released
+    ):
+        effects.append({"trade_frozen": _round(-frozen)})
+    if order.get("status") not in _DONE:
+        return effects
+    amount = _money(order.get("exec_total_amt"))
+    if amount is None:
+        problems.append(f"订单 {order.get('order_no')} 缺少执行总金额")
+        return effects
+    if order.get("direction") == "buy":
+        if amount == _ZERO:
+            problems.append(f"已完成买入 {order.get('order_no')} 的执行总金额为 0")
+        elif amount < _ZERO:
+            problems.append(f"订单 {order.get('order_no')} 的执行总金额为负")
+        else:
+            effects.append({"cash_buy_unsettled": amount})
+    elif order.get("direction") == "sell" and amount > _ZERO:
+        effects.append({"cash_sell_unsettled": amount})
+    return effects
+
+
+def _order_ledgers(order: Mapping[str, Any], problems: list[str]) -> list[tuple[str, str, Decimal]]:
+    """步骤 3 流水的幂等键、业务类型和带符号金额。"""
+
+    if order.get("status") not in _TERMINAL:
+        return []
+    order_no = _text(order.get("order_no"))
+    if order_no is None:
+        return []
+    found: list[tuple[str, str, Decimal]] = []
+    frozen = _money(order.get("frozen_settle"))
+    if order.get("direction") == "buy" and frozen is not None and frozen > _ZERO:
+        found.append((f"{order_no}:unfreeze", "buy_note", _ZERO))
+    if order.get("status") not in _DONE:
+        return found
+    amount = _money(order.get("exec_total_amt"))
+    if amount is None:
+        return found
+    if order.get("direction") == "buy" and amount > _ZERO:
+        found.append((f"{order_no}:buy_today", "buy_note", _round(-amount)))
+    elif order.get("direction") == "sell" and amount > _ZERO:
+        found.append((f"{order_no}:sell_today", "sell_note", amount))
+    return found
+
+
+def _settlement_effect(source: Mapping[str, Any]) -> dict[str, Decimal]:
+    amount = abs(_money(source.get("amount")) or _ZERO)
+    if source.get("business_type") == "buy_note":
+        return {"cash_buy_unsettled": _round(-amount), "cash_settled": _round(-amount)}
+    return {"cash_sell_unsettled": _round(-amount), "cash_settled": amount}
+
+
+def _settlement_ledger(source: Mapping[str, Any]) -> tuple[str, str, Decimal]:
+    digest = _text(source.get("idem_hash")) or ""
+    amount = _money(source.get("amount")) or _ZERO
+    if source.get("business_type") == "buy_note":
+        return f"SETTLE:{digest}", "buy_settle", _round(-abs(amount))
+    return f"SETTLE:{digest}", "sell_settle", abs(amount)
+
+
+def _ledger_effect(row: Mapping[str, Any], problems: list[str], label: str) -> dict[str, Decimal] | None:
+    """流水效果以 effect_json 为准,并核对摘要。缺失时不用金额猜测。"""
+
+    if row.get("clearing_step_no") in (3, 4):
+        return None
+    if row.get("status") not in _LEDGER_STATUS:
+        return None
+    raw = _json_object(row.get("effect_json"))
+    if raw is None:
+        problems.append(f"{label} {row.get('idem_key') or row.get('id')} 缺少钱包效果")
+        return None
+    try:
+        effect = _normalize_effect(raw)
+    except ValueError as exc:
+        problems.append(f"{label} {row.get('idem_key') or row.get('id')} {exc}")
+        return None
+    digest = _text(row.get("effect_hash"))
+    if digest is None or digest != _effect_hash(effect):
+        problems.append(f"{label} {row.get('idem_key') or row.get('id')} 效果摘要不一致")
+        return None
+    return effect
+
+
+def _apply(balances: dict[str, Decimal], effect: Mapping[str, Decimal], problems: list[str]) -> None:
+    for field, value in effect.items():
+        if field not in balances:
+            continue
+        balances[field] = _round(balances[field] + value)
+    negative = [field for field in sorted(_NON_NEGATIVE) if balances.get(field, _ZERO) < _ZERO]
+    if negative:
+        problems.append("钱包科目为负: " + ",".join(negative))
+
+
+def _bucket_gaps(
+    expected: Mapping[str, Decimal],
+    actual: Mapping[str, Any],
+    left: str,
+    right: str,
+) -> list[str]:
+    problems: list[str] = []
+    for field in _BUCKETS:
+        value = _money(actual.get(field))
+        if value is None:
+            problems.append(f"{right} 缺少 {field}")
+        elif value != expected[field]:
+            problems.append(
+                f"{field} {left} {format_decimal(expected[field], SCALE_AMT)} "
+                f"{right} {format_decimal(value, SCALE_AMT)}"
+            )
+    return problems
+
+
+def _read_buckets(row: Mapping[str, Any], problems: list[str], label: str) -> dict[str, Decimal] | None:
+    amounts: dict[str, Decimal] = {}
+    missing = False
+    for field in _BUCKETS:
+        value = _money(row.get(field))
+        if value is None:
+            problems.append(f"{label} 缺少 {field}")
+            missing = True
+        else:
+            amounts[field] = value
+    if missing:
+        return None
+    return amounts
+
+
+def _orders_of(ctx: Any, fund_no: str) -> list[Mapping[str, Any]]:
+    investor = ctx.fund_accounts[fund_no].get("investor_id") if isinstance(ctx.fund_accounts.get(fund_no), Mapping) else None
+    found: list[Mapping[str, Any]] = []
+    owners = _investor_funds(ctx)
+    for order in mappings(getattr(ctx, "sales_orders", ())):
+        owner = owners.get(_int_id(order.get("investor_id")))
+        if owner == fund_no or (investor is not None and _int_id(order.get("investor_id")) == _int_id(investor) and owner == fund_no):
+            found.append(order)
+    return found
+
+
+def _investor_funds(ctx: Any) -> dict[int, str]:
+    """一个投资者只对应一个正常资金账户。多个时不把订单随便归到其中一个。"""
+
+    groups: dict[int, list[str]] = {}
+    funds = ctx.fund_accounts if isinstance(ctx.fund_accounts, Mapping) else {}
+    for fund_no, bucket in funds.items():
+        if not isinstance(bucket, Mapping):
+            continue
+        ident = _int_id(bucket.get("investor_id"))
+        if ident is None:
+            continue
+        groups.setdefault(ident, []).append(str(fund_no))
+    return {ident: names[0] for ident, names in groups.items() if len(names) == 1}
+
+
+def _order_by_no(ctx: Any, order_no: str) -> Mapping[str, Any] | None:
+    for order in mappings(getattr(ctx, "sales_orders", ())):
+        if order.get("order_no") == order_no:
+            return order
+    return None
+
+
+def _wallet_row(bucket: Mapping[str, Any], key: str, ccy: str) -> Mapping[str, Any] | None:
+    matched = [row for row in mappings(bucket.get(key)) if row.get("ccy") == ccy]
+    if len(matched) != 1:
+        return None
+    return matched[0]
+
+
+def _normalize_effect(raw: Mapping[str, Any]) -> dict[str, Decimal]:
+    normalized: dict[str, Decimal] = {}
+    for field, value in raw.items():
+        if field not in _BUCKETS:
+            raise ValueError(f"不支持的钱包科目 {field}")
+        if isinstance(value, float):
+            raise ValueError("钱包效果禁止使用 float")
+        amount = _money(value)
+        if amount is None:
+            raise ValueError("钱包效果不是金额")
+        if amount != _ZERO:
+            normalized[str(field)] = amount
+    if not normalized:
+        raise ValueError("钱包效果不能为空")
+    return dict(sorted(normalized.items()))
+
+
+def _effect_hash(effect: Mapping[str, Decimal]) -> str:
+    payload = json.dumps(
+        {key: format(value, "f") for key, value in effect.items()},
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+    )
+    return hashlib.sha256(payload.encode("utf-8")).hexdigest()
+
+
+def _hash_text(value: str) -> str:
+    return hashlib.sha256(value.encode("utf-8")).hexdigest()
+
+
+def _json_object(value: object) -> dict[str, Any] | None:
+    if isinstance(value, str):
+        try:
+            value = json.loads(value)
+        except json.JSONDecodeError:
+            return None
+    if isinstance(value, Mapping):
+        return dict(value)
+    return None
+
+
+def _scope(stages: Mapping[str, Any], scope: str) -> Mapping[str, Any]:
+    value = stages.get(scope)
+    return value if isinstance(value, Mapping) else {}
+
+
+def _scope_rows(stages: Mapping[str, Any], scope: str, table: str) -> list[Mapping[str, Any]]:
+    return mappings(_scope(stages, scope).get(table))
+
+
+def _by_sub(rows: Sequence[Mapping[str, Any]]) -> dict[str, list[Mapping[str, Any]]]:
+    found: dict[str, list[Mapping[str, Any]]] = {}
+    for row in rows:
+        sub_id = _sub_id(row)
+        if sub_id:
+            found.setdefault(sub_id, []).append(row)
+    return found
+
+
+def _sub_id(row: Mapping[str, Any]) -> str:
+    value = row.get("sub_order_id")
+    if value is None or isinstance(value, bool):
+        return ""
+    return str(value)
+
+
+def _before_trade(row: Mapping[str, Any], trade_day: date | None) -> bool:
+    order_day = _day(row.get("trade_date"))
+    return trade_day is not None and order_day is not None and order_day < trade_day
+
+
+def _order_funds(ctx: Any) -> dict[str, str]:
+    found: dict[str, str] = {}
+    orders = getattr(ctx, "orders", {})
+    if not isinstance(orders, Mapping):
+        return found
+    for order_no, bucket in orders.items():
+        if not isinstance(bucket, Mapping):
+            continue
+        master = _master(bucket)
+        fund_no = _text(master.get("fund_account_ref"))
+        if fund_no:
+            found[str(order_no)] = fund_no
+    return found
+
+
+def _fund_for_sub(rows: Sequence[Mapping[str, Any]], fund_of: Mapping[str, str]) -> str:
+    for row in rows:
+        order_no = _text(row.get("order_no"))
+        if order_no and order_no in fund_of:
+            return fund_of[order_no]
+    return ""
+
+
+def _master(bucket: Mapping[str, Any]) -> Mapping[str, Any]:
+    master = bucket.get("master")
+    return master if isinstance(master, Mapping) else {}
+
+
+def _one(lines: list, formula: str, basis: str, fund_no: str, problems: list[str], label: str) -> None:
+    verdict = VERDICT_ERROR if problems else VERDICT_CORRECT
+    text = f"{label}:" + (";".join(problems) if problems else "一致")
+    emit(
+        lines,
+        formula=formula,
+        basis=basis,
+        calc_text=text,
+        calc_value=None,
+        check_text=text,
+        check_value=None,
+        verdict=verdict,
+        scale=SCALE_AMT,
+        fund_account=fund_no,
+    )
+
+
+def _money(value: object) -> Decimal | None:
+    if isinstance(value, bool) or value is None:
+        return None
+    if isinstance(value, Decimal):
+        number = value
+    elif isinstance(value, int):
+        number = Decimal(value)
+    elif isinstance(value, str):
+        try:
+            number = Decimal(value)
+        except Exception:
+            return None
+    else:
+        return None
+    if not number.is_finite():
+        return None
+    return _round(number)
+
+
+def _round(value: Decimal) -> Decimal:
+    return value.quantize(_MONEY, rounding=ROUND_HALF_UP)
+
+
+def _day(value: object) -> date | None:
+    return as_date(value)
+
+
+def _text(value: object) -> str | None:
+    if value is None or isinstance(value, bool):
+        return None
+    text = str(value).strip()
+    return text or None
+
+
+def _int_id(value: object) -> int | None:
+    if isinstance(value, bool) or value is None:
+        return None
+    if isinstance(value, int):
+        return value
+    if isinstance(value, str) and value.strip().isdigit():
+        return int(value.strip())
+    return None

+ 897 - 0
code/post_clear_check/src/post_clear_check/gate.py

@@ -0,0 +1,897 @@
+"""批次门禁。决定哪个市场、哪个交易日可以开始核对。
+
+不取订单和报表明细,不写自有库,不发飞书。
+同一组 end_time 与 applied_revision 是否已经开跑过,由编排包用返回值做幂等。
+"""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from datetime import date, datetime
+from decimal import Decimal
+from typing import Any, Protocol
+
+# 只有这个状态算清算完成。
+COMPLETED = "completed"
+
+# 无对冲时 TRS 没有批次。id 与修订号用 0,结束时间用唯一键哨兵。
+NO_HEDGE_BATCH_ID = 0
+NO_HEDGE_REVISION = 0
+NO_HEDGE_END_TIME = datetime(1000, 1, 1, 0, 0, 0)
+
+CARD_KIND_BATCH = "clearing_batch"
+VERDICT_ERROR = "error"
+FORMULA_EARLY_REPORT = "日切报表出现时,对应清算批次的 status 应为 completed"
+BASIS_EARLY_REPORT = "日切报表在清算批次完成后生成。门禁快照,只表示本次启动时批次尚未 completed"
+FORMULA_TRS_BLOCKED = "有对冲时,TRS 清算批次的 status 应为 completed"
+BASIS_TRS_BLOCKED = "有对冲的交易日要等 TRS 完成后再核对发行和销售"
+# 挂在已完成轮次上的是/否检查。不一致才出结论,不把 status=completed 再写一张卡。
+FORMULA_ISSUE_BASELINE = "已发布数据集的期初基线与本批固定的 TRS 版本一致"
+FORMULA_ISSUE_PUBLISH = "该交易日只有一个 published_flag=1,且营业日已推进"
+FORMULA_SALES_UPSTREAM = "销售清单锁定的发行版本等于该日当前已发布轮次"
+FORMULA_SALES_PUBLISH = "销售已发布数据集为 kind=cleared 且 status=active"
+FORMULA_TRS_DATE = "TRS 营业日已推进到本批之后"
+FORMULA_TRS_VERSION = "TRS 批次已切换到本批 kind=cleared 且 status=available 的数据集"
+FORMULA_DAYCUT = "日终资金快照与已发布候选钱包一致"
+_CHECK_NOTE = "不一致才记错误"
+
+# 表名只来自施工包清单,不接受外部拼接。
+_TABLE_NAME = re.compile(r"^db_(issue|sales|trs)\.[a-z_]+$")
+
+ISSUE_BATCH_TABLE = "db_issue.clearing_batch"
+SALES_BATCH_TABLE = "db_sales.clearing_batch"
+TRS_BATCH_TABLE = "db_trs.clearing_batch"
+HEDGE_TABLE = "db_issue.hedge_record"
+
+# 日切报表。某一营业日这些表里已有行,而对应交易日还没有完成批次,就是错误。
+ISSUE_REPORT_TABLES = (
+    "db_issue.report_trade_order",
+    "db_issue.report_daily_details",
+    "db_issue.report_order_settlement",
+    "db_issue.report_order_non_settlement",
+)
+SALES_REPORT_TABLES = (
+    "db_sales.report_account_asset",
+    "db_sales.report_cash_change",
+    "db_sales.report_trade_order",
+    "db_sales.report_daily_details",
+    "db_sales.report_order_settlement",
+    "db_sales.report_order_non_settlement",
+)
+
+# 三端 clearing_batch 的列不一样,不能共用一条字段清单。
+# 发行有 market、applied_revision。
+# 销售有 market,没有 applied_revision,修订号用 0,不拿乐观锁 version 顶替。
+# TRS 两列都没有。本期只接 A 股,市场写成字面量 a_share,修订号同样用 0。
+_ISSUE_BATCH_COLUMNS = (
+    "id, batch_no, market, trade_date, business_date, status, end_time, applied_revision"
+)
+_SALES_BATCH_COLUMNS = (
+    "id, batch_no, market, trade_date, business_date, status, end_time, 0 AS applied_revision"
+)
+_TRS_BATCH_COLUMNS = (
+    "id, batch_no, 'a_share' AS market, trade_date, business_date, status, end_time, "
+    "0 AS applied_revision"
+)
+_BATCH_COLUMNS = {
+    ISSUE_BATCH_TABLE: _ISSUE_BATCH_COLUMNS,
+    SALES_BATCH_TABLE: _SALES_BATCH_COLUMNS,
+    TRS_BATCH_TABLE: _TRS_BATCH_COLUMNS,
+}
+
+# 对冲只认发行库。订单号前缀与删除标记用绑定参数,避免把 % 写进语句格式化。
+HEDGE_SQL = (
+    "SELECT 1 FROM db_issue.hedge_record "
+    "WHERE trade_date = %s AND src_order_no LIKE %s AND is_deleted = %s "
+    "LIMIT 1"
+)
+HEDGE_PREFIX = "ORD%"
+
+
+class QueryGateway(Protocol):
+    """业务连接上的查询入口。实现必须先拒绝非只读语句。"""
+
+    async def execute(self, sql: str, args: Any = None) -> list[tuple[Any, ...]]:
+        """执行一条 SELECT。"""
+
+
+class BatchRowError(Exception):
+    """clearing_batch 的列无法按门禁所需的类型读取。"""
+
+
+@dataclass(frozen=True)
+class Batch:
+    """一端的一条清算批次。end_time 在未完成时可以为空。"""
+
+    id: int
+    batch_no: str
+    market: str
+    trade_date: date
+    business_date: date
+    status: str
+    end_time: datetime | None
+    applied_revision: int | None
+
+
+@dataclass(frozen=True)
+class ReadyRun:
+    """可以开跑的一轮。字段与 check_run 的批次列一致,不含规则版本和计数。"""
+
+    market: str
+    trade_date: date
+    business_date: date
+    issue_batch_id: int
+    sales_batch_id: int
+    trs_batch_id: int
+    issue_end_time: datetime
+    sales_end_time: datetime
+    trs_end_time: datetime
+    issue_applied_revision: int
+    sales_applied_revision: int
+    trs_applied_revision: int
+
+
+@dataclass(frozen=True)
+class ConclusionLine:
+    """一条结论。本包只产生批次级 error,不发送。"""
+
+    formula_text: str
+    calc_text: str
+    calc_value: Decimal | None
+    check_text: str
+    check_value: Decimal | None
+    basis: str
+    verdict: str
+    card_kind: str
+    biz_key: str
+    # 收卡和快照幂等用这些字段,不再从 calc_text 里抠。
+    market: str = ""
+    trade_date: date | None = None
+    business_date: date | None = None
+    side_name: str = ""
+    report_tables: tuple[str, ...] = ()
+
+
+@dataclass(frozen=True)
+class TrsBlocked:
+    """发行和销售已完成,但有对冲而 TRS 未完成。这一天不开跑。"""
+
+    market: str
+    trade_date: date
+    business_date: date
+
+
+@dataclass(frozen=True)
+class GateScan:
+    """一轮门禁的全部输出。ready、lines、blocked 都按稳定顺序排列。
+
+    checks 是已完成轮次上的是/否检查,由编排并进该轮的核对门禁卡。
+    不放进 lines,避免再开一轮只有门禁的合成批次。
+    """
+
+    ready: tuple[ReadyRun, ...]
+    lines: tuple[ConclusionLine, ...]
+    blocked: tuple[TrsBlocked, ...] = ()
+    checks: tuple[ConclusionLine, ...] = ()
+
+
+def batch_sql(table: str) -> str:
+    """读取一端 clearing_batch 里指定营业日的行。列按该表真实结构取。"""
+
+    _require_table(table)
+    columns = _BATCH_COLUMNS.get(table)
+    if columns is None:
+        raise BatchRowError("不是清算批次表")
+    return f"SELECT {columns} FROM {table} WHERE business_date = %s"
+
+
+def report_date_sql(table: str) -> str:
+    """看指定营业日有没有报表行。别的营业日不读。"""
+
+    _require_table(table)
+    return f"SELECT DISTINCT business_date FROM {table} WHERE business_date = %s"
+
+
+async def scan_gate(gateway: QueryGateway, business_date: date) -> GateScan:
+    """只读这一个营业日的批次、日切报表和对冲,再做开跑判断。"""
+
+    issue = await _load_batches(gateway, ISSUE_BATCH_TABLE, business_date)
+    sales = await _load_batches(gateway, SALES_BATCH_TABLE, business_date)
+    trs = await _load_batches(gateway, TRS_BATCH_TABLE, business_date)
+    issue_dates = await _load_report_dates(gateway, ISSUE_REPORT_TABLES, business_date)
+    sales_dates = await _load_report_dates(gateway, SALES_REPORT_TABLES, business_date)
+    hedge_dates = await _load_hedge_dates(gateway, _ready_trade_dates(issue, sales))
+    scanned = evaluate_gate(issue, sales, trs, hedge_dates, issue_dates, sales_dates)
+    checks: list[ConclusionLine] = []
+    for run in scanned.ready:
+        facts = await _load_published_facts(gateway, run)
+        checks.extend(evaluate_published(run, facts))
+    return GateScan(
+        ready=scanned.ready,
+        lines=scanned.lines,
+        blocked=scanned.blocked,
+        checks=tuple(checks),
+    )
+
+
+def evaluate_gate(
+    issue_batches: Sequence[Batch],
+    sales_batches: Sequence[Batch],
+    trs_batches: Sequence[Batch],
+    hedge_trade_dates: set[date],
+    issue_report_dates: set[date] | Mapping[date, Sequence[str]],
+    sales_report_dates: set[date] | Mapping[date, Sequence[str]],
+) -> GateScan:
+    """根据已取到的批次和对冲结果决定开跑结构或结论行。相同输入得到相同输出。"""
+
+    issue_hits = _report_map(issue_report_dates)
+    sales_hits = _report_map(sales_report_dates)
+    issue_done = _latest_completed(issue_batches)
+    sales_done = _latest_completed(sales_batches)
+    trs_done = _latest_completed(trs_batches)
+    ready: list[ReadyRun] = []
+    blocked: list[TrsBlocked] = []
+    for market, trade_date in sorted(set(issue_done) & set(sales_done)):
+        issue = issue_done[(market, trade_date)]
+        sales = sales_done[(market, trade_date)]
+        if trade_date in hedge_trade_dates:
+            trs = trs_done.get((market, trade_date))
+            # 有对冲而 TRS 未完成:不开跑。是否落卡由编排按营业日决定。
+            if trs is None:
+                blocked.append(
+                    TrsBlocked(
+                        market=market,
+                        trade_date=trade_date,
+                        business_date=issue.business_date,
+                    )
+                )
+                continue
+            trs_end, trs_revision = _completed_identity(trs, "TRS")
+            trs_id = trs.id
+        else:
+            trs_id = NO_HEDGE_BATCH_ID
+            trs_end = NO_HEDGE_END_TIME
+            trs_revision = NO_HEDGE_REVISION
+        issue_end, issue_revision = _completed_identity(issue, "发行")
+        sales_end, sales_revision = _completed_identity(sales, "销售")
+        ready.append(
+            ReadyRun(
+                market=market,
+                trade_date=trade_date,
+                # 营业日取发行完成批次。销售批次不另开一轮。
+                business_date=issue.business_date,
+                issue_batch_id=issue.id,
+                sales_batch_id=sales.id,
+                trs_batch_id=trs_id,
+                issue_end_time=issue_end,
+                sales_end_time=sales_end,
+                trs_end_time=trs_end,
+                issue_applied_revision=issue_revision,
+                sales_applied_revision=sales_revision,
+                trs_applied_revision=trs_revision,
+            )
+        )
+    lines = _early_report_lines(
+        issue_batches,
+        issue_hits,
+        database="db_issue",
+        side_name="发行",
+    )
+    lines.extend(
+        _early_report_lines(
+            sales_batches,
+            sales_hits,
+            database="db_sales",
+            side_name="销售",
+        )
+    )
+    return GateScan(ready=tuple(ready), lines=tuple(lines), blocked=tuple(blocked))
+
+
+def parse_batch_row(row: tuple[Any, ...]) -> Batch:
+    """把 clearing_batch 的一行转成批次。列顺序与 batch_sql 一致。"""
+
+    if len(row) != 8:
+        raise BatchRowError("clearing_batch 列数不对")
+    batch_no = row[1] if isinstance(row[1], str) else ""
+    market = row[2] if isinstance(row[2], str) else ""
+    status = row[5] if isinstance(row[5], str) else ""
+    if not market or not status:
+        raise BatchRowError("clearing_batch 缺少 market 或 status")
+    return Batch(
+        id=_as_int(row[0], "id"),
+        batch_no=batch_no,
+        market=market,
+        trade_date=_as_date(row[3], "trade_date"),
+        business_date=_as_date(row[4], "business_date"),
+        status=status,
+        end_time=None if row[6] is None else _as_datetime(row[6], "end_time"),
+        applied_revision=None if row[7] is None else _as_int(row[7], "applied_revision"),
+    )
+
+
+async def _load_batches(gateway: QueryGateway, table: str, business_date: date) -> tuple[Batch, ...]:
+    rows = await gateway.execute(batch_sql(table), (business_date,))
+    return tuple(parse_batch_row(row) for row in rows)
+
+
+async def _load_report_dates(
+    gateway: QueryGateway,
+    tables: tuple[str, ...],
+    business_date: date,
+) -> dict[date, tuple[str, ...]]:
+    """营业日到当天有行的报表表名。表名变化会让门禁快照另开一张卡。"""
+
+    found: dict[date, set[str]] = {}
+    for table in tables:
+        rows = await gateway.execute(report_date_sql(table), (business_date,))
+        for row in rows:
+            if not row or row[0] is None:
+                continue
+            day = _as_date(row[0], "business_date")
+            found.setdefault(day, set()).add(table)
+    return {day: tuple(sorted(names)) for day, names in found.items()}
+
+
+def _report_map(value: set[date] | Mapping[date, Sequence[str]]) -> dict[date, tuple[str, ...]]:
+    """测试可以只给日期集合。生产路径带上有行的表名。"""
+
+    if isinstance(value, Mapping):
+        return {day: tuple(sorted({str(table) for table in value[day]})) for day in value}
+    return {day: () for day in value}
+
+
+async def _load_hedge_dates(gateway: QueryGateway, trade_dates: Sequence[date]) -> set[date]:
+    """发行该交易日存在 ORD 开头且未删除的对冲记录,才把这一天放进集合。"""
+
+    found: set[date] = set()
+    for trade_date in trade_dates:
+        rows = await gateway.execute(HEDGE_SQL, (trade_date, HEDGE_PREFIX, 0))
+        if rows:
+            found.add(trade_date)
+    return found
+
+
+def _ready_trade_dates(issue_batches: Sequence[Batch], sales_batches: Sequence[Batch]) -> tuple[date, ...]:
+    """只有发行和销售都已完成的交易日才需要查对冲。"""
+
+    issue_days = {batch.trade_date for batch in issue_batches if batch.status == COMPLETED}
+    sales_days = {batch.trade_date for batch in sales_batches if batch.status == COMPLETED}
+    return tuple(sorted(issue_days & sales_days))
+
+
+def _latest_completed(batches: Sequence[Batch]) -> dict[tuple[str, date], Batch]:
+    """同一市场、同一交易日只保留 id 最大的完成批次。"""
+
+    chosen: dict[tuple[str, date], Batch] = {}
+    for batch in batches:
+        if batch.status != COMPLETED:
+            continue
+        key = (batch.market, batch.trade_date)
+        current = chosen.get(key)
+        if current is None or batch.id > current.id:
+            chosen[key] = batch
+    return chosen
+
+
+def _latest_any(batches: Sequence[Batch], market: str, trade_date: date) -> Batch | None:
+    """该市场该交易日 id 最大的批次,不限状态。用来在未完成时带出 batch_no。"""
+
+    chosen: Batch | None = None
+    for batch in batches:
+        if batch.market != market or batch.trade_date != trade_date:
+            continue
+        if chosen is None or batch.id > chosen.id:
+            chosen = batch
+    return chosen
+
+
+def _completed_identity(batch: Batch, side_name: str) -> tuple[datetime, int]:
+    """完成批次必须带结束时间和修订号,否则无法交给编排包做幂等。"""
+
+    if batch.end_time is None or batch.applied_revision is None:
+        raise BatchRowError(f"{side_name}完成批次缺少 end_time 或 applied_revision")
+    return batch.end_time, batch.applied_revision
+
+
+def _early_report_lines(
+    batches: Sequence[Batch],
+    report_hits: Mapping[date, Sequence[str]],
+    *,
+    database: str,
+    side_name: str,
+) -> list[ConclusionLine]:
+    """报表营业日还没有对应的完成批次时,每个市场一条 error。没有批次也算一条。"""
+
+    completed = _latest_completed(batches)
+    lines: list[ConclusionLine] = []
+    for business_date in sorted(report_hits):
+        tables = tuple(report_hits[business_date])
+        markets = _trade_date_by_market(batches, business_date)
+        if not markets:
+            lines.append(
+                _early_report_line(
+                    business_date=business_date,
+                    database=database,
+                    side_name=side_name,
+                    market=None,
+                    batch=None,
+                    trade_date=business_date,
+                    tables=tables,
+                )
+            )
+            continue
+        for market, trade_date in sorted(markets.items()):
+            if (market, trade_date) in completed:
+                continue
+            lines.append(
+                _early_report_line(
+                    business_date=business_date,
+                    database=database,
+                    side_name=side_name,
+                    market=market,
+                    batch=_latest_any(batches, market, trade_date),
+                    trade_date=trade_date,
+                    tables=tables,
+                )
+            )
+    return lines
+
+
+def _trade_date_by_market(batches: Sequence[Batch], business_date: date) -> dict[str, date]:
+    """报表营业日对应到哪个交易日。
+
+    先用 business_date 相等的批次里 id 最大的那条。没有这种行时,再用 trade_date
+    等于该营业日的批次。两边都没有,调用方按「没有完成批次」出结论。
+    """
+
+    by_business: dict[str, Batch] = {}
+    by_trade: dict[str, Batch] = {}
+    for batch in batches:
+        if batch.business_date == business_date:
+            current = by_business.get(batch.market)
+            if current is None or batch.id > current.id:
+                by_business[batch.market] = batch
+        if batch.trade_date == business_date:
+            current = by_trade.get(batch.market)
+            if current is None or batch.id > current.id:
+                by_trade[batch.market] = batch
+    markets = set(by_business) | set(by_trade)
+    linked: dict[str, date] = {}
+    for market in markets:
+        anchored = by_business.get(market)
+        linked[market] = anchored.trade_date if anchored is not None else business_date
+    return linked
+
+
+def _early_report_line(
+    *,
+    business_date: date,
+    database: str,
+    side_name: str,
+    market: str | None,
+    batch: Batch | None,
+    trade_date: date,
+    tables: tuple[str, ...],
+) -> ConclusionLine:
+    """日切报表已出现,但该侧清算批次还不是 completed。"""
+
+    day = business_date.isoformat()
+    if market is None:
+        calc_text = f"营业日 {day} 已有报表行"
+    else:
+        calc_text = f"营业日 {day} 已有报表行,market = {market}"
+    batch_no = batch.batch_no.strip() if batch is not None else ""
+    if batch is None or not batch_no:
+        check_text = f"{database}.clearing_batch.status,{side_name},没有完成批次"
+        biz_key = day
+    else:
+        check_text = (
+            f"{database}.clearing_batch.status = {batch.status},{side_name},batch_no = {batch_no}"
+        )
+        biz_key = batch_no
+    return ConclusionLine(
+        formula_text=FORMULA_EARLY_REPORT,
+        calc_text=calc_text,
+        calc_value=None,
+        check_text=check_text,
+        check_value=None,
+        basis=BASIS_EARLY_REPORT,
+        verdict=VERDICT_ERROR,
+        card_kind=CARD_KIND_BATCH,
+        biz_key=biz_key,
+        market="" if market is None else market,
+        trade_date=trade_date,
+        business_date=business_date,
+        side_name=side_name,
+        report_tables=tables,
+    )
+
+
+def trs_blocked_line(blocked: TrsBlocked) -> ConclusionLine:
+    """历史营业日仍被 TRS 挡住时的错误卡。当天等待只打日志,不调用本函数。"""
+
+    trade = blocked.trade_date.isoformat()
+    day = blocked.business_date.isoformat()
+    return ConclusionLine(
+        formula_text=FORMULA_TRS_BLOCKED,
+        calc_text=(
+            f"交易日 {trade} 营业日 {day} 已有发行和销售完成批次,market = {blocked.market}"
+        ),
+        calc_value=None,
+        check_text="db_trs.clearing_batch.status,TRS,没有完成批次",
+        check_value=None,
+        basis=BASIS_TRS_BLOCKED,
+        verdict=VERDICT_ERROR,
+        card_kind=CARD_KIND_BATCH,
+        biz_key=trade,
+        market=blocked.market,
+        trade_date=blocked.trade_date,
+        business_date=blocked.business_date,
+        side_name="TRS",
+    )
+
+
+def _require_table(table: str) -> None:
+    if _TABLE_NAME.fullmatch(table) is None:
+        raise BatchRowError("表名不在门禁清单内")
+
+
+def _as_int(value: object, column: str) -> int:
+    """只要真正的 int。bool 是 int 的子类,不能当成批次号。"""
+
+    if isinstance(value, bool) or not isinstance(value, int):
+        raise BatchRowError(f"{column} 不是整数")
+    return value
+
+
+def _as_date(value: object, column: str) -> date:
+    if isinstance(value, datetime):
+        return value.date()
+    if isinstance(value, date):
+        return value
+    raise BatchRowError(f"{column} 不是日期")
+
+
+def _as_datetime(value: object, column: str) -> datetime:
+    if isinstance(value, datetime):
+        if value.tzinfo is not None:
+            return value.replace(tzinfo=None)
+        return value
+    raise BatchRowError(f"{column} 不是时间")
+
+
+@dataclass(frozen=True)
+class IssueRound:
+    """发行该日的一条 published_flag=1。基线行读不到时 base_missing 为真。"""
+
+    dataset_id: int
+    batch_id: int
+    revision: int
+    baseline_kind: str
+    base_dataset_id: int | None
+    base_trade_date: date | None
+    base_row_trade_date: date | None
+    base_missing: bool
+    upstream_fixed: int
+    upstream_batch_id: int | None
+    upstream_dataset_id: int | None
+
+
+@dataclass(frozen=True)
+class PublishedFacts:
+    """一个已完成轮次的门禁取数。空值表示没读到,由检查函数记错误。"""
+
+    issue_rounds: tuple[IssueRound, ...]
+    issue_business_date: date | None
+    sales_versions: tuple[str, ...]
+    sales_dataset_id: int | None
+    sales_kind: str
+    sales_status: str
+    sales_dataset_batch_id: int | None
+    trs_dataset_id: int | None
+    trs_kind: str
+    trs_copy_no: int | None
+    trs_status: str
+    trs_dataset_batch_id: int | None
+    trs_business_date: date | None
+
+
+def evaluate_published(run: ReadyRun, facts: PublishedFacts) -> tuple[ConclusionLine, ...]:
+    """已完成轮次上的是/否检查。只在不一致时返回错误行。"""
+
+    lines: list[ConclusionLine] = []
+    matched = _matching_round(run, facts.issue_rounds)
+    if len(facts.issue_rounds) != 1 or matched is None:
+        lines.append(_check(run, FORMULA_ISSUE_PUBLISH, "该交易日没有唯一已发布数据集", "published_flag=1 应恰好一行且属于本批"))
+        lines.append(_check(run, FORMULA_ISSUE_BASELINE, "没有唯一已发布数据集,无法核对基线和 TRS 版本", "期初基线与上游 TRS 版本"))
+    else:
+        reason = _baseline_reason(matched, run.trade_date) or _upstream_reason(matched, run, facts.trs_dataset_id)
+        if reason:
+            lines.append(_check(run, FORMULA_ISSUE_BASELINE, reason, "期初基线与上游 TRS 版本"))
+    if facts.issue_business_date is None or facts.issue_business_date <= run.business_date:
+        shown = "没有进行中的营业日" if facts.issue_business_date is None else facts.issue_business_date.isoformat()
+        lines.append(_check(run, FORMULA_ISSUE_PUBLISH, f"发行当前营业日 {shown}", f"应晚于本批营业日 {run.business_date.isoformat()}"))
+    version_reason = _sales_version_reason(matched, facts.sales_versions)
+    if version_reason:
+        lines.append(_check(run, FORMULA_SALES_UPSTREAM, version_reason, "manifest 的 batch_id:cleared_dataset_id"))
+    if not _sales_published(run, facts):
+        lines.append(_check(
+            run,
+            FORMULA_SALES_PUBLISH,
+            f"kind={facts.sales_kind or '无'} status={facts.sales_status or '无'}",
+            "本批 kind=cleared 且 status=active",
+        ))
+    if run.trs_batch_id != NO_HEDGE_BATCH_ID:
+        if facts.trs_business_date is None or facts.trs_business_date <= run.business_date:
+            shown = "没有营业日指针" if facts.trs_business_date is None else facts.trs_business_date.isoformat()
+            lines.append(_check(run, FORMULA_TRS_DATE, f"TRS 当前营业日 {shown}", f"应晚于本批营业日 {run.business_date.isoformat()}"))
+        if not _trs_switched(run, facts):
+            lines.append(_check(
+                run,
+                FORMULA_TRS_VERSION,
+                f"kind={facts.trs_kind or '无'} status={facts.trs_status or '无'} copy_no={facts.trs_copy_no}",
+                "本批 kind=cleared、copy_no=1 且 status=available",
+            ))
+    return tuple(lines)
+
+
+def daycut_line(run: ReadyRun, problems: Sequence[str]) -> ConclusionLine | None:
+    """日终快照和候选不一致时的一条错误。没有差异不出行。"""
+
+    if not problems:
+        return None
+    shown = ";".join(problems[:5])
+    if len(problems) > 5:
+        shown = f"{shown};另有 {len(problems) - 5} 处"
+    return _check(run, FORMULA_DAYCUT, shown, "日终资金快照等于已发布候选")
+
+
+def _matching_round(run: ReadyRun, rounds: Sequence[IssueRound]) -> IssueRound | None:
+    if len(rounds) != 1:
+        return None
+    row = rounds[0]
+    if row.batch_id != run.issue_batch_id:
+        return None
+    return row
+
+
+def _baseline_reason(row: IssueRound, trade_date: date) -> str:
+    kind = row.baseline_kind
+    if kind == "opening":
+        if row.base_dataset_id is not None or row.base_trade_date is not None:
+            return "opening 仍带有基线数据集"
+        return ""
+    if kind != "published":
+        return f"baseline_kind={kind or '空'}"
+    if row.base_dataset_id is None:
+        return "published 缺少 base_dataset_id"
+    if row.base_missing:
+        return "base_dataset_id 指向的数据集不存在"
+    if row.base_trade_date is not None and row.base_row_trade_date != row.base_trade_date:
+        return "基线交易日与 base_trade_date 不一致"
+    if row.base_row_trade_date is None or row.base_row_trade_date >= trade_date:
+        return "基线交易日不早于本交易日"
+    return ""
+
+
+def _upstream_reason(row: IssueRound, run: ReadyRun, trs_dataset_id: int | None) -> str:
+    if row.upstream_fixed != 1:
+        return "upstream_trs_fixed 不是 1"
+    if run.trs_batch_id == NO_HEDGE_BATCH_ID:
+        if row.upstream_batch_id is not None or row.upstream_dataset_id is not None:
+            return "无对冲仍写了 TRS 批次或数据集"
+        return ""
+    if row.upstream_batch_id != run.trs_batch_id:
+        return "固定的 TRS 批次不是本批"
+    if trs_dataset_id is None or row.upstream_dataset_id != trs_dataset_id:
+        return "固定的 TRS 数据集不是本批 cleared_dataset_id"
+    return ""
+
+
+def _sales_version_reason(matched: IssueRound | None, versions: Sequence[str]) -> str:
+    if matched is None:
+        return "发行没有唯一已发布轮次,无法比对清单"
+    if len(versions) != 1:
+        return "发行结果版本不是恰好一行"
+    parts = versions[0].split(":", 2)
+    if len(parts) != 3:
+        return "发行结果版本无法拆成 batch_id:cleared_dataset_id:batch_no"
+    try:
+        batch_id = int(parts[0])
+        revision = int(parts[1])
+    except ValueError:
+        return "发行结果版本的批次或数据集不是整数"
+    if batch_id != matched.batch_id or revision != matched.revision:
+        return f"清单锁定 {batch_id}:{revision},已发布轮次是 {matched.batch_id}:{matched.revision}"
+    return ""
+
+
+def _sales_published(run: ReadyRun, facts: PublishedFacts) -> bool:
+    return (
+        facts.sales_dataset_id is not None
+        and facts.sales_kind == "cleared"
+        and facts.sales_status == "active"
+        and facts.sales_dataset_batch_id == run.sales_batch_id
+    )
+
+
+def _trs_switched(run: ReadyRun, facts: PublishedFacts) -> bool:
+    return (
+        facts.trs_dataset_id is not None
+        and facts.trs_kind == "cleared"
+        and facts.trs_copy_no == 1
+        and facts.trs_status == "available"
+        and facts.trs_dataset_batch_id == run.trs_batch_id
+    )
+
+
+def _check(run: ReadyRun, formula: str, calc: str, check: str) -> ConclusionLine:
+    return ConclusionLine(
+        formula_text=formula,
+        calc_text=calc,
+        calc_value=None,
+        check_text=check,
+        check_value=None,
+        basis=_CHECK_NOTE,
+        verdict=VERDICT_ERROR,
+        card_kind=CARD_KIND_BATCH,
+        biz_key=run.business_date.isoformat(),
+        market=run.market,
+        trade_date=run.trade_date,
+        business_date=run.business_date,
+    )
+
+
+_ISSUE_PUBLISHED_SQL = (
+    "SELECT id, batch_id, dataset_revision, baseline_kind, base_dataset_id, base_trade_date, "
+    "upstream_trs_fixed, upstream_trs_batch_id, upstream_trs_dataset_id "
+    "FROM db_issue_clear.clearing_dataset "
+    "WHERE market = %s AND trade_date = %s AND published_flag = %s"
+)
+_ISSUE_BASE_SQL = "SELECT trade_date FROM db_issue_clear.clearing_dataset WHERE id = %s"
+_ISSUE_DAY_SQL = (
+    "SELECT business_date FROM db_issue.business_day "
+    "WHERE status = %s ORDER BY business_date DESC LIMIT 1"
+)
+_SALES_VERSION_SQL = (
+    "SELECT source_version FROM db_sales.clearing_input_manifest "
+    "WHERE batch_id = %s AND step_no = %s AND input_type = %s AND source_ref = %s"
+)
+_SALES_BATCH_SQL = "SELECT cleared_dataset_id FROM db_sales.clearing_batch WHERE id = %s"
+_SALES_DATASET_SQL = "SELECT batch_id, kind, status FROM db_sales.clearing_dataset WHERE id = %s"
+_TRS_BATCH_SQL = "SELECT cleared_dataset_id FROM db_trs.clearing_batch WHERE id = %s"
+_TRS_DATASET_SQL = "SELECT batch_id, kind, copy_no, status FROM db_trs.clearing_dataset WHERE id = %s"
+_TRS_DAY_SQL = "SELECT business_date FROM db_trs.business_day WHERE id = %s"
+
+
+async def _load_published_facts(gateway: QueryGateway, run: ReadyRun) -> PublishedFacts:
+    """只读这一轮已发布数据集、清单和营业日指针。"""
+
+    issue_rows = await gateway.execute(_ISSUE_PUBLISHED_SQL, (run.market, run.trade_date, 1))
+    rounds: list[IssueRound] = []
+    for row in issue_rows:
+        parsed = _parse_issue_round(row)
+        if parsed.base_dataset_id is not None:
+            base_rows = await gateway.execute(_ISSUE_BASE_SQL, (parsed.base_dataset_id,))
+            if len(base_rows) != 1:
+                parsed = IssueRound(
+                    dataset_id=parsed.dataset_id,
+                    batch_id=parsed.batch_id,
+                    revision=parsed.revision,
+                    baseline_kind=parsed.baseline_kind,
+                    base_dataset_id=parsed.base_dataset_id,
+                    base_trade_date=parsed.base_trade_date,
+                    base_row_trade_date=None,
+                    base_missing=True,
+                    upstream_fixed=parsed.upstream_fixed,
+                    upstream_batch_id=parsed.upstream_batch_id,
+                    upstream_dataset_id=parsed.upstream_dataset_id,
+                )
+            else:
+                parsed = IssueRound(
+                    dataset_id=parsed.dataset_id,
+                    batch_id=parsed.batch_id,
+                    revision=parsed.revision,
+                    baseline_kind=parsed.baseline_kind,
+                    base_dataset_id=parsed.base_dataset_id,
+                    base_trade_date=parsed.base_trade_date,
+                    base_row_trade_date=_optional_date(base_rows[0][0]),
+                    base_missing=False,
+                    upstream_fixed=parsed.upstream_fixed,
+                    upstream_batch_id=parsed.upstream_batch_id,
+                    upstream_dataset_id=parsed.upstream_dataset_id,
+                )
+        rounds.append(parsed)
+    day_rows = await gateway.execute(_ISSUE_DAY_SQL, ("in_progress",))
+    sales_rows = await gateway.execute(
+        _SALES_VERSION_SQL,
+        (run.sales_batch_id, 1, "upstream", f"issue:{run.market}:{run.trade_date.isoformat()}"),
+    )
+    sales_batch = await gateway.execute(_SALES_BATCH_SQL, (run.sales_batch_id,))
+    sales_dataset_id = _optional_int(sales_batch[0][0]) if len(sales_batch) == 1 else None
+    sales_kind = ""
+    sales_status = ""
+    sales_batch_id: int | None = None
+    if sales_dataset_id is not None:
+        dataset_rows = await gateway.execute(_SALES_DATASET_SQL, (sales_dataset_id,))
+        if len(dataset_rows) == 1:
+            sales_batch_id = _optional_int(dataset_rows[0][0])
+            sales_kind = dataset_rows[0][1] if isinstance(dataset_rows[0][1], str) else ""
+            sales_status = dataset_rows[0][2] if isinstance(dataset_rows[0][2], str) else ""
+    trs_dataset_id: int | None = None
+    trs_kind = ""
+    trs_copy_no: int | None = None
+    trs_status = ""
+    trs_dataset_batch: int | None = None
+    trs_day: date | None = None
+    if run.trs_batch_id != NO_HEDGE_BATCH_ID:
+        trs_batch = await gateway.execute(_TRS_BATCH_SQL, (run.trs_batch_id,))
+        trs_dataset_id = _optional_int(trs_batch[0][0]) if len(trs_batch) == 1 else None
+        if trs_dataset_id is not None:
+            dataset_rows = await gateway.execute(_TRS_DATASET_SQL, (trs_dataset_id,))
+            if len(dataset_rows) == 1:
+                trs_dataset_batch = _optional_int(dataset_rows[0][0])
+                trs_kind = dataset_rows[0][1] if isinstance(dataset_rows[0][1], str) else ""
+                trs_copy_no = _optional_int(dataset_rows[0][2])
+                trs_status = dataset_rows[0][3] if isinstance(dataset_rows[0][3], str) else ""
+        day_pointer = await gateway.execute(_TRS_DAY_SQL, (1,))
+        if len(day_pointer) == 1:
+            trs_day = _optional_date(day_pointer[0][0])
+    return PublishedFacts(
+        issue_rounds=tuple(rounds),
+        issue_business_date=_optional_date(day_rows[0][0]) if len(day_rows) == 1 else None,
+        sales_versions=tuple(row[0] for row in sales_rows if isinstance(row[0], str)),
+        sales_dataset_id=sales_dataset_id,
+        sales_kind=sales_kind,
+        sales_status=sales_status,
+        sales_dataset_batch_id=sales_batch_id,
+        trs_dataset_id=trs_dataset_id,
+        trs_kind=trs_kind,
+        trs_copy_no=trs_copy_no,
+        trs_status=trs_status,
+        trs_dataset_batch_id=trs_dataset_batch,
+        trs_business_date=trs_day,
+    )
+
+
+def _parse_issue_round(row: Sequence[Any]) -> IssueRound:
+    if len(row) != 9:
+        raise BatchRowError("clearing_dataset 列数不对")
+    return IssueRound(
+        dataset_id=_as_int(row[0], "id"),
+        batch_id=_as_int(row[1], "batch_id"),
+        revision=_as_int(row[2], "dataset_revision"),
+        baseline_kind=row[3] if isinstance(row[3], str) else "",
+        base_dataset_id=_optional_int(row[4]),
+        base_trade_date=_optional_date(row[5]),
+        base_row_trade_date=None,
+        base_missing=False,
+        upstream_fixed=_flag(row[6]),
+        upstream_batch_id=_optional_int(row[7]),
+        upstream_dataset_id=_optional_int(row[8]),
+    )
+
+
+def _optional_int(value: object) -> int | None:
+    if value is None:
+        return None
+    return _as_int(value, "id")
+
+
+def _optional_date(value: object) -> date | None:
+    if value is None:
+        return None
+    return _as_date(value, "date")
+
+
+def _flag(value: object) -> int:
+    """0/1 或布尔。别的类型当成没固定。"""
+
+    if isinstance(value, bool):
+        return 1 if value else 0
+    if isinstance(value, int):
+        return value
+    return 0

+ 127 - 0
code/post_clear_check/src/post_clear_check/hedge_link.py

@@ -0,0 +1,127 @@
+"""对冲记录到 TRS 执行根的唯一判定。
+
+自动普通单的幂等键在 trade_order.ext_ref,自动算法单在 trade_algo_master.ext_ref。
+补录不带 HR 键,普通单认委托号,算法单认母单号。卖出拆单和算法子单的 ext_ref 为空,
+成交已经汇总在执行根上,这里不返回子单。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+
+from post_clear_check.order_decimal import as_decimal
+
+KIND_SKIP = "skip"
+KIND_NEVER = "never"
+KIND_ROOT = "root"
+KIND_UNDECIDED = "undecided"
+
+TABLE_ORDER = "trade_order"
+TABLE_ALGO = "trade_algo_master"
+
+COLUMN_EXT_REF = "ext_ref"
+COLUMN_ENTRUST_NO = "entrust_no"
+COLUMN_ALGO_NO = "algo_no"
+
+_SYSTEM_TRS = "trs"
+_SUB_MANUAL = "manual"
+_ORDER_NORMAL = "normal"
+_NEVER_STATUSES = frozenset({"rejected", "canceled"})
+_ZERO_FIELDS = ("filled_qty", "amt_trade", "amt_settle", "fee_amt")
+
+
+@dataclass(frozen=True)
+class HedgeLink:
+    """一条对冲的取数结果。只有 kind 为 root 时表、列、键才有值。"""
+
+    kind: str
+    table: str = ""
+    column: str = ""
+    key: str = ""
+
+
+def classify_hedge(row: Mapping[str, object]) -> HedgeLink:
+    """给出这条对冲该怎么到 TRS。判定不了时不假装没有订单。"""
+
+    system = _optional_text(row, "hedge_system")
+    if system is None:
+        return HedgeLink(KIND_UNDECIDED)
+    if system != _SYSTEM_TRS:
+        return HedgeLink(KIND_SKIP)
+    sub_type = _optional_text(row, "sub_type")
+    if sub_type is None:
+        return HedgeLink(KIND_UNDECIDED)
+    if sub_type == _SUB_MANUAL:
+        return _manual_root(row)
+    if _never_created(row):
+        return HedgeLink(KIND_NEVER)
+    return _auto_root(row)
+
+
+def hedge_label(row: Mapping[str, object]) -> str:
+    """对冲主键拼成 HR 加数字。主键不是整数时不编一个假键。"""
+
+    ident = row.get("id")
+    if isinstance(ident, bool) or not isinstance(ident, int):
+        return "对冲记录"
+    return f"HR{ident}"
+
+
+def _manual_root(row: Mapping[str, object]) -> HedgeLink:
+    """补录绑定的是已有委托。号缺失、两个号并存或和下单类型不符,都不猜。"""
+
+    order_type = _optional_text(row, "order_type")
+    entrust_no = _optional_text(row, "trs_entrust_no")
+    algo_no = _optional_text(row, "trs_algo_no")
+    if order_type is None:
+        return HedgeLink(KIND_UNDECIDED)
+    if order_type == _ORDER_NORMAL and entrust_no and not algo_no:
+        return HedgeLink(KIND_ROOT, TABLE_ORDER, COLUMN_ENTRUST_NO, entrust_no)
+    if order_type != _ORDER_NORMAL and algo_no and not entrust_no:
+        return HedgeLink(KIND_ROOT, TABLE_ALGO, COLUMN_ALGO_NO, algo_no)
+    return HedgeLink(KIND_UNDECIDED)
+
+
+def _auto_root(row: Mapping[str, object]) -> HedgeLink:
+    """自动单用 HR 加对冲主键。普通单落委托,其余下单类型落算法母单。"""
+
+    order_type = _optional_text(row, "order_type")
+    if order_type is None:
+        return HedgeLink(KIND_UNDECIDED)
+    ident = row.get("id")
+    if isinstance(ident, bool) or not isinstance(ident, int):
+        return HedgeLink(KIND_UNDECIDED)
+    key = f"HR{ident}"
+    if order_type == _ORDER_NORMAL:
+        return HedgeLink(KIND_ROOT, TABLE_ORDER, COLUMN_EXT_REF, key)
+    return HedgeLink(KIND_ROOT, TABLE_ALGO, COLUMN_EXT_REF, key)
+
+
+def _never_created(row: Mapping[str, object]) -> bool:
+    """下单被拒或被撤、TRS 从未回号,且成交和金额费用都是 0。这种缺失是合法结局。"""
+
+    if _optional_text(row, "trs_entrust_no") or _optional_text(row, "trs_algo_no"):
+        return False
+    status = _optional_text(row, "status")
+    if status not in _NEVER_STATUSES:
+        return False
+    for field in _ZERO_FIELDS:
+        number = as_decimal(row.get(field))
+        if number is None or number != 0:
+            return False
+    return True
+
+
+def _optional_text(row: Mapping[str, object], column: str) -> str | None:
+    """列不在或值是空,表示这条信息没有,不把空串当成一种业务取值。"""
+
+    if column not in row or row.get(column) is None:
+        return None
+    value = row.get(column)
+    if not isinstance(value, str):
+        return None
+    text = value.strip()
+    if text == "":
+        return None
+    return text

+ 558 - 0
code/post_clear_check/src/post_clear_check/issue_common.py

@@ -0,0 +1,558 @@
+"""发行报表结论行的共用比较。
+
+只服务发行端 OTC003、OTC004、OTC005、OTC007、OTC008。OTC009、OTC012 是实时导出,不落库,不核报表行。
+金额 2 位,价格 6 位,汇率 6 位,百分比 2 位,比较前四舍五入。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass, field
+from datetime import date, datetime
+from decimal import Decimal
+from typing import Any
+
+from post_clear_check.columns import reports_for
+from post_clear_check.order_cell import CheckLine, biz_text, finish, read_place
+from post_clear_check.order_decimal import (
+    SCALE_AMT,
+    VERDICT_ERROR,
+    VERDICT_SUSPECT,
+    as_decimal,
+    equal_verdict,
+    format_decimal,
+    quantize,
+)
+
+# 百分比与价格不是同一标度。价格列用 6 位,价格(%)、净值用 2 位。
+SCALE_PCT = 2
+
+OTC003_NAME = "OTC003 共赢票据订单报表"
+OTC004_NAME = "OTC004 共赢票据每日切片详情报表"
+OTC005_NAME = "OTC005 共赢票据实时详情报表"
+OTC007_NAME = "OTC007 票据交易交收报表"
+OTC008_NAME = "OTC008 票据交易未交收报表"
+
+_ORDER_STATUSES = frozenset({"completed", "settled"})
+_SELL_OP_TYPES = frozenset({"force", "maturity"})
+
+
+def _issue_table(label: str) -> str:
+    """发行日报的物理表名,与取数清单里的 label 对齐。"""
+
+    for spec in reports_for("issue"):
+        if spec.label == label:
+            return spec.table
+    raise RuntimeError(f"发行报表清单缺少 {label}")
+
+
+T_OTC003 = _issue_table("OTC003")
+T_OTC004 = _issue_table("OTC004")
+T_OTC007 = _issue_table("OTC007")
+T_OTC008 = _issue_table("OTC008")
+
+
+@dataclass(frozen=True)
+class IssueContext:
+    """一轮发行报表核对的输入。报表行按营业日,来源行挂在主单上。"""
+
+    business_date: date
+    orders: Mapping[str, Mapping[str, Any]]
+    sources: Mapping[str, str]
+    latest_prices: Mapping[str, Any]
+    loose: Mapping[str, Sequence[Mapping[str, Any]]]
+    logs: tuple[tuple[str, str, Mapping[str, Any]], ...]
+    topup_records: tuple[Mapping[str, Any], ...]
+    extension_tasks: tuple[Mapping[str, Any], ...]
+    extension_ledgers: tuple[Mapping[str, Any], ...]
+    realtime_notes: tuple[Mapping[str, Any], ...]
+    realtime_rows: tuple[Mapping[str, Any], ...]
+    # 日报引用、但订单上没有的票据副本。往日票据先读清算副本,没有再读实时表。
+    note_copies: tuple[Mapping[str, Any], ...] = field(default_factory=tuple)
+    # 票据号到该票据已完成或已结算的订单(含往日,交易日不晚于本轮),只供已卖出合计累计。
+    note_orders: Mapping[str, Sequence[Mapping[str, Any]]] = field(default_factory=dict)
+    # 本批发行步骤快照和当前投影。没有快照时不跑步骤节点。
+    issue_stages: Mapping[str, Any] = field(default_factory=dict)
+    baseline_kind: str = ""
+    baseline_notes: Mapping[str, Any] = field(default_factory=dict)
+    live_notes_loaded: bool = False
+    live_note_subs: tuple[Mapping[str, Any], ...] = ()
+    live_note_masters: tuple[Mapping[str, Any], ...] = ()
+    live_topups: tuple[Mapping[str, Any], ...] = ()
+    live_extensions: tuple[Mapping[str, Any], ...] = ()
+    live_positions: tuple[Mapping[str, Any], ...] = ()
+    cleared_positions: tuple[Mapping[str, Any], ...] = ()
+    sales_note_loaded: bool = False
+    sales_current_notes: tuple[Mapping[str, Any], ...] = ()
+    sales_cleared_notes: tuple[Mapping[str, Any], ...] = ()
+    trade_date: date | None = None
+
+
+def mappings(value: object) -> list[Mapping[str, Any]]:
+    """忽略字符串。只留下字典行。"""
+
+    if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
+        return []
+    return [row for row in value if isinstance(row, Mapping)]
+
+
+def as_date(value: object) -> date | None:
+    """接受日期、日期时间和 ISO 日期前缀。不把空串当成日期。"""
+
+    if isinstance(value, datetime):
+        return value.date()
+    if isinstance(value, date):
+        return value
+    if isinstance(value, str):
+        text = value.strip()
+        if len(text) < 10:
+            return None
+        try:
+            return date.fromisoformat(text[:10])
+        except ValueError:
+            return None
+    return None
+
+
+def on_business_date(row: Mapping[str, Any], business_date: date) -> bool:
+    """行上没有营业日就保留。有营业日但不属于这一天的行不核。"""
+
+    if "business_date" not in row or row.get("business_date") is None:
+        return True
+    parsed = as_date(row.get("business_date"))
+    return parsed == business_date
+
+
+def present_number(row: Mapping[str, Any], column: str) -> tuple[bool, Decimal | None]:
+    """列不存在或不是十进制数,都视为没有被核对的值。二进制浮点不算数。"""
+
+    if column not in row:
+        return False, None
+    number = as_decimal(row.get(column))
+    if number is None:
+        return False, None
+    return True, number
+
+
+def order_no_of(order_no: str | None, row: Mapping[str, Any]) -> str | None:
+    """报表行上的主订单编号优先于桶键。"""
+
+    text = row.get("master_order_no")
+    if isinstance(text, str) and text.strip():
+        return text.strip()
+    if isinstance(order_no, str) and order_no.strip():
+        return order_no.strip()
+    return None
+
+
+def note_no_of(row: Mapping[str, Any]) -> str | None:
+    text = row.get("master_note_no")
+    if isinstance(text, str) and text.strip():
+        return text.strip()
+    text = row.get("note_no")
+    if isinstance(text, str) and text.strip():
+        return text.strip()
+    return None
+
+
+def fund_no_of(row: Mapping[str, Any] | None) -> str | None:
+    """报表行的 fund_account_no;主单、票据上是销售侧资金账户编号 fund_account_ref。都没有返回空。"""
+
+    if row is None:
+        return None
+    for field in ("fund_account_no", "fund_account_ref"):
+        text = row.get(field)
+        if isinstance(text, str) and text.strip():
+            return text.strip()
+    return None
+
+
+def first_fund(rows: Sequence[Mapping[str, Any] | None]) -> str | None:
+    """按给定顺序取第一个有资金账户的行。报表行在前,来源记录在后。"""
+
+    for row in rows:
+        found = fund_no_of(row)
+        if found is not None:
+            return found
+    return None
+
+
+def side_of(direction: object, op_type: object) -> str | None:
+    """强平、到期走卖出公式。方向不是买入或卖出时不猜。"""
+
+    if op_type in _SELL_OP_TYPES:
+        return "sell"
+    if direction == "buy":
+        return "buy"
+    if direction == "sell":
+        return "sell"
+    return None
+
+
+def side_of_row(row: Mapping[str, Any], master: Mapping[str, Any] | None) -> str | None:
+    side = side_of(row.get("direction"), row.get("op_type"))
+    if side is not None or master is None:
+        return side
+    return side_of(master.get("direction"), master.get("op_type"))
+
+
+def in_order_population(master: Mapping[str, Any] | None) -> bool:
+    """缺报表行这条只覆盖 ORD、已完成或已结算的主单。"""
+
+    if master is None:
+        return False
+    order_no = master.get("order_no")
+    if not isinstance(order_no, str) or not order_no.strip().startswith("ORD"):
+        return False
+    return master.get("status") in _ORDER_STATUSES
+
+
+# OTC007、OTC008 的业务类型与主订单方向的对应。
+_NOTE_BIZ_SIDE = {"buy_note": "buy", "sell_note": "sell"}
+
+
+def settlement_row_fits(row: Mapping[str, Any], master: Mapping[str, Any] | None) -> bool:
+    """OTC007、OTC008 行按票据号挂桶,同一票据的买单和卖单会拿到彼此的行,这里只留属于这张主单的。
+
+    业务类型(buy_note、sell_note)必须与主订单方向一致;两边都有交易日时必须相等。
+    主单缺失、方向或业务类型认不出时不排除,交给存在性核对暴露。
+    """
+
+    if master is None:
+        return True
+    expected = _NOTE_BIZ_SIDE.get(row.get("biz_type")) if isinstance(row.get("biz_type"), str) else None
+    side = side_of(master.get("direction"), master.get("op_type"))
+    if expected is not None and side is not None and expected != side:
+        return False
+    row_day = as_date(row.get("trade_date"))
+    master_day = as_date(master.get("trade_date"))
+    if row_day is not None and master_day is not None and row_day != master_day:
+        return False
+    return True
+
+
+def settlement_row_owners(
+    orders: Mapping[str, Mapping[str, Any]],
+    tables: Sequence[str],
+    business_date: date,
+) -> dict[str, set[str]]:
+    """同一票据当天有多张订单时,把交收报表行认领到唯一的一张订单。
+
+    报表行只有票据号,同一票据的几张卖单会各自拿到全部行。认领依据是子单的名义本金、面值、总金额,
+    三者都对得上且在这张票据里唯一的,报表行才属于这张订单。只有一张订单的票据不认领,金额对错仍逐列报。
+    卖出的面值用卖出票据面值、总金额用执行总金额;买入的执行总金额恒为 0,不参与认领。
+    """
+
+    rows_by_note: dict[str, dict[str, Mapping[str, Any]]] = {}
+    for bucket in orders.values():
+        reports = bucket.get("reports") if isinstance(bucket, Mapping) else None
+        if not isinstance(reports, Mapping):
+            continue
+        for table in tables:
+            for row in mappings(reports.get(table)):
+                if not on_business_date(row, business_date):
+                    continue
+                note_no = note_no_of(row)
+                if note_no is None:
+                    continue
+                rows_by_note.setdefault(note_no, {})[_row_key(table, row)] = row
+    grouped: dict[str, list[tuple[str, tuple[Decimal, ...]]]] = {}
+    for order_no, bucket in orders.items():
+        if not isinstance(bucket, Mapping):
+            continue
+        master = bucket.get("master") if isinstance(bucket.get("master"), Mapping) else None
+        if master is None:
+            continue
+        note_no = master.get("note_no")
+        if not isinstance(note_no, str) or not note_no.strip():
+            continue
+        signature = _order_signature(bucket)
+        if signature is None:
+            continue
+        grouped.setdefault(note_no.strip(), []).append((str(order_no), signature))
+    owners: dict[str, set[str]] = {}
+    for note_no, items in grouped.items():
+        if len(items) < 2:
+            continue
+        counts: dict[tuple[Decimal, ...], int] = {}
+        for _order_no, signature in items:
+            counts[signature] = counts.get(signature, 0) + 1
+        for order_no, signature in items:
+            if counts[signature] != 1:
+                continue
+            # 买入的执行总金额恒为 0,签名只有名义本金和面值,只比对行的前两列;卖出连总金额一起比对。
+            owners[order_no] = {
+                key
+                for key, row in rows_by_note.get(note_no, {}).items()
+                if _row_matches(row, signature)
+            }
+    return owners
+
+
+def _order_signature(bucket: Mapping[str, Any]) -> tuple[Decimal, ...] | None:
+    subs = mappings(bucket.get("order_sub_a"))
+    if len(subs) != 1:
+        return None
+    sub = subs[0]
+    master = bucket.get("master") if isinstance(bucket.get("master"), Mapping) else None
+    side = side_of_row(sub, master)
+    notional = _money(sub, "exec_notional_trade")
+    if side == "sell":
+        face = _money(sub, "sold_face_value")
+        total = _money(sub, "exec_total_amt_settle")
+    elif side == "buy":
+        face = _money(sub, "exec_face_value_settle")
+        total = None
+    else:
+        return None
+    if notional is None or face is None:
+        return None
+    if side == "sell":
+        if total is None:
+            return None
+        return (notional, face, total)
+    return (notional, face)
+
+
+def _row_matches(row: Mapping[str, Any], signature: tuple[Decimal, ...]) -> bool:
+    found = _row_signature(row)
+    if found is None or found[: len(signature)] != signature:
+        return False
+    biz = row.get("biz_type")
+    if not isinstance(biz, str) or not biz.strip():
+        return True
+    sell = len(signature) == 3
+    return biz.strip() == ("sell_note" if sell else "buy_note")
+
+
+def _row_signature(row: Mapping[str, Any]) -> tuple[Decimal, ...] | None:
+    notional = _money(row, "notional_trade")
+    face = _money(row, "face_value_settle")
+    total = _money(row, "total_amt_settle")
+    if notional is None or face is None or total is None:
+        return None
+    return (notional, face, total)
+
+
+def _money(row: Mapping[str, Any], field: str) -> Decimal | None:
+    present, number = present_number(row, field)
+    if not present or number is None:
+        return None
+    return quantize(number, SCALE_AMT)
+
+
+def _row_key(table: str, row: Mapping[str, Any]) -> str:
+    """表名加主键。两端报表各自编号,主键单独用会把销售行和发行行当成同一行。"""
+
+    ident = row.get("id")
+    mark = f"obj:{id(row)}" if ident is None else f"id:{ident}"
+    return f"{table}:{mark}"
+
+
+def is_population_bucket(bucket: Mapping[str, Any] | None) -> bool:
+    """主单桶是不是本轮核对人口。
+
+    取数把报表行引用到的往日主订单标成 in_population=False,只作参照:挂链路、取方向和子单执行值,
+    不跑订单执行规则,也不要求必须有 OTC003 行。没有这个键的桶(调用方直接给的数据)按人口处理。
+    """
+
+    if not isinstance(bucket, Mapping):
+        return False
+    return bucket.get("in_population", True) is not False
+
+
+def note_order_buckets(
+    orders: Mapping[str, Mapping[str, Any]],
+    note_orders: Mapping[str, Sequence[Mapping[str, Any]]],
+    note_no: str,
+) -> list[Mapping[str, Any]]:
+    """该票据关联的订单桶:取数单独补读的往日订单在前,再接 orders 里的订单,按主单主键去重。
+
+    取数补读已按交易日不晚于本轮封顶。orders 里只剩核对人口和报表引用的参照订单,同一张主单不重复计。
+    """
+
+    found: list[Mapping[str, Any]] = []
+    seen: set[str] = set()
+
+    def add(bucket: Mapping[str, Any]) -> None:
+        master = bucket.get("master")
+        if not isinstance(master, Mapping):
+            return
+        ident = master.get("id")
+        token = f"id:{ident}" if ident is not None else f"obj:{id(master)}"
+        if token in seen:
+            return
+        seen.add(token)
+        found.append(bucket)
+
+    for bucket in mappings(note_orders.get(note_no)):
+        add(bucket)
+    for bucket in orders.values():
+        if isinstance(bucket, Mapping) and _bucket_has_note(bucket, note_no):
+            add(bucket)
+    return found
+
+
+def _bucket_has_note(bucket: Mapping[str, Any], note_no: str) -> bool:
+    """主单上的票据号,或桶里子票据的主票据号、子票据号等于该票据。"""
+
+    master = bucket.get("master")
+    if isinstance(master, Mapping) and master.get("note_no") == note_no:
+        return True
+    for note in mappings(bucket.get("note_sub_a")):
+        if note.get("note_no") == note_no or note.get("sub_note_no") == note_no or note.get("master_note_no") == note_no:
+            return True
+    return False
+
+
+def is_sell_master(master: Mapping[str, Any] | None) -> bool:
+    if master is None:
+        return False
+    return side_of(master.get("direction"), master.get("op_type")) == "sell"
+
+
+def report_text(
+    report_name: str,
+    column: str,
+    present: bool,
+    number: Decimal | None,
+    scale: int,
+    order_no: str | None,
+) -> str:
+    suffix = f"(主订单编号 {order_no})" if order_no else ""
+    if not present or number is None:
+        return f"{report_name}.{column} 缺失{suffix}"
+    return f"{report_name}.{column} = {format_decimal(number, scale)}{suffix}"
+
+
+def emit(
+    lines: list[CheckLine],
+    *,
+    formula: str,
+    basis: str,
+    calc_text: str,
+    calc_value: Decimal | None,
+    check_text: str,
+    check_value: Decimal | None,
+    verdict: str,
+    scale: int | None = None,
+    live: bool = False,
+    report_code: str = "",
+    master_order_no: str = "",
+    fund_account: str = "",
+    user_account: str = "",
+) -> None:
+    finish(
+        lines,
+        formula=formula,
+        basis=basis,
+        calc_text=calc_text,
+        calc_value=calc_value,
+        check_text=check_text,
+        check_value=check_value,
+        verdict=verdict,
+        live=live,
+        scale=scale,
+        report_code=report_code,
+        master_order_no=master_order_no,
+        fund_account=fund_account,
+        user_account=user_account,
+    )
+
+
+def emit_amount(
+    lines: list[CheckLine],
+    *,
+    formula: str,
+    basis: str,
+    calc_text: str,
+    calc_value: Decimal | None,
+    report_name: str,
+    column: str,
+    row: Mapping[str, Any],
+    field: str,
+    order_no: str | None,
+    scale: int,
+    live: bool = False,
+    force: str | None = None,
+) -> None:
+    """按被核对列标度比较。force 用于规则明确要求的可疑或错误,不再改判。"""
+
+    present, number = present_number(row, field)
+    check_text = report_text(report_name, column, present, number, scale, order_no)
+    if force is not None:
+        verdict = force
+        check_value = number if present else None
+    elif not present:
+        verdict = VERDICT_ERROR
+        check_value = None
+    elif calc_value is None:
+        verdict = VERDICT_SUSPECT
+        check_value = number
+    else:
+        verdict = equal_verdict(calc_value, number, scale)
+        check_value = number
+    report_code, _parsed_order, _parsed_fund, _parsed_user = read_place("", report_name)
+    emit(
+        lines,
+        formula=formula,
+        basis=basis,
+        calc_text=calc_text,
+        calc_value=calc_value,
+        check_text=check_text,
+        check_value=check_value,
+        verdict=verdict,
+        scale=scale,
+        live=live,
+        report_code=report_code,
+        master_order_no=order_no or "",
+    )
+
+
+def unique_rows(ctx: IssueContext, table: str) -> list[tuple[str | None, Mapping[str, Any]]]:
+    """同一张报表、同一个主键只核一次。没有主键时按对象区分。"""
+
+    seen: set[tuple[str, str]] = set()
+    found: list[tuple[str | None, Mapping[str, Any]]] = []
+
+    def add(order_no: str | None, row: Mapping[str, Any]) -> None:
+        if not on_business_date(row, ctx.business_date):
+            return
+        ident = row.get("id")
+        if ident is None:
+            key = ("obj", str(id(row)))
+        else:
+            key = ("id", biz_text(ident))
+        if key in seen:
+            return
+        seen.add(key)
+        found.append((order_no, row))
+
+    for order_no in sorted(ctx.orders):
+        reports = ctx.orders[order_no].get("reports")
+        if not isinstance(reports, Mapping):
+            continue
+        for row in mappings(reports.get(table)):
+            add(order_no, row)
+    for row in mappings(ctx.loose.get(table)):
+        add(None, row)
+    return found
+
+
+def order_bucket(ctx: IssueContext, order_no: str | None) -> Mapping[str, Any] | None:
+    if order_no is None:
+        return None
+    bucket = ctx.orders.get(order_no)
+    if isinstance(bucket, Mapping):
+        return bucket
+    return None
+
+
+def index_by_note(rows: Sequence[Mapping[str, Any]]) -> dict[str, list[Mapping[str, Any]]]:
+    grouped: dict[str, list[Mapping[str, Any]]] = {}
+    for row in rows:
+        note_no = note_no_of(row)
+        if note_no is None:
+            continue
+        grouped.setdefault(note_no, []).append(row)
+    return grouped

+ 23 - 0
code/post_clear_check/src/post_clear_check/issue_event.py

@@ -0,0 +1,23 @@
+"""发行端增额、展期两条链路的入口。
+
+OTC009、OTC012 是实时导出,没有落库结果。节点读本批 input 的冻结主键和 work/3 结果。
+"""
+
+from __future__ import annotations
+
+from post_clear_check.issue_common import IssueContext
+from post_clear_check.note_chain import check_extension as check_extension_steps
+from post_clear_check.note_chain import check_topup as check_topup_steps
+from post_clear_check.order_cell import CheckLine
+
+
+def check_topup(ctx: IssueContext, lines: list[CheckLine]) -> None:
+    """增额链路。没有步骤快照时不产生结论行。"""
+
+    check_topup_steps(ctx, lines)
+
+
+def check_extension(ctx: IssueContext, lines: list[CheckLine]) -> None:
+    """展期链路。没有步骤快照时不产生结论行。"""
+
+    check_extension_steps(ctx, lines)

+ 896 - 0
code/post_clear_check/src/post_clear_check/issue_otc003.py

@@ -0,0 +1,896 @@
+"""发行端 OTC003 共赢票据订单报表。
+
+买入、卖出各有同行等式。卖出总金额另有一条跨源等式,和写入等式同时保留。
+发行端总金额不含承销费用,不再为缺列另出可疑行。销售端仍核承销费用。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from decimal import Decimal
+from typing import Any
+
+from post_clear_check.issue_common import (
+    SCALE_PCT,
+    OTC003_NAME,
+    T_OTC003,
+    IssueContext,
+    emit,
+    emit_amount,
+    in_order_population,
+    is_population_bucket,
+    mappings,
+    order_bucket,
+    order_no_of,
+    present_number,
+    side_of_row,
+    unique_rows,
+)
+from post_clear_check.order_cell import CheckLine, read_cell, read_note_cell, stored_cell
+from post_clear_check.order_decimal import (
+    SCALE_AMT,
+    SCALE_FX,
+    SCALE_PX,
+    SCALE_QTY,
+    VERDICT_ERROR,
+    VERDICT_SUSPECT,
+    format_decimal,
+    join_decimals,
+    quantize,
+    total_amount,
+)
+
+_BASIS_ROW = "发行端 OTC003 共赢票据订单报表同行等式"
+_BASIS_CROSS = "同主订单 order_sub_a 的清算后值,回写 after_val 优先"
+_FACE_BUY = "票据面值(结算币种) = 执行票据面值"
+_FACE_SELL = "票据面值(结算币种) = 卖出票据面值"
+_SELL_TOTAL = (
+    "卖出总金额:底层资产盈亏 > 0 时为卖出票据面值 + 卖出增额金额 + "
+    "底层资产盈亏 × 收益比例,否则为 MAX(0, 面值 + 增额 + 底层资产盈亏)"
+)
+_WRITE_DEAL = "成交金额(结算币种) = 总金额(结算币种)"
+_DEAL_GAP = "说明中的成交金额(结算币种)与发行端写入不一致"
+
+# 子单列在结论文字里用中文,不把物理列名写进卡片。
+_SOURCE_CN = {
+    "exec_notional_trade": "执行订单名义本金(交易币种)",
+    "order_avg_px": "订单成交均价",
+    "fx_settle": "子单结算汇率",
+    "order_qty": "订单买入股数或订单卖出股数",
+    "exec_issue_fee_settle": "执行发行费用",
+    "realized_dividend": "子单实现分红",
+    "underlying_pnl": "子单底层资产盈亏",
+    "exec_face_value_settle": "执行票据面值",
+    "sold_face_value": "卖出票据面值",
+    "sold_add_amt": "子单卖出增额金额",
+}
+
+# 买卖共用的跨源列。面值、卖出增额和卖出总金额按方向另核。
+_SHARED_CROSS: tuple[tuple[str, str, str, str, int], ...] = (
+    ("名义本金(交易币种) = 执行订单名义本金(交易币种)", "exec_notional_trade", "notional_trade", "名义本金(交易币种)", SCALE_AMT),
+    ("成交股价 = 订单成交均价", "order_avg_px", "deal_price", "成交股价", SCALE_PX),
+    ("结算汇率 = 子单结算汇率", "fx_settle", "fx_settle", "结算汇率", SCALE_FX),
+    ("股票数量 = 订单买入股数或订单卖出股数", "order_qty", "share_qty", "股票数量", SCALE_QTY),
+    ("发行费用(结算币种) = 执行发行费用", "exec_issue_fee_settle", "issue_fee_settle", "发行费用(结算币种)", SCALE_AMT),
+    ("实现分红(结算币种) = 子单实现分红", "realized_dividend", "realized_dividend", "实现分红(结算币种)", SCALE_AMT),
+    ("底层资产盈亏(结算币种) = 子单底层资产盈亏", "underlying_pnl", "underlying_pnl", "底层资产盈亏(结算币种)", SCALE_AMT),
+)
+
+
+def check_otc003(ctx: IssueContext, lines: list[CheckLine]) -> None:
+    """核当日订单报表行,并在子单已经存在却没有报表行时记错误。"""
+
+    covered: set[str] = set()
+    for bucket_no, row in unique_rows(ctx, T_OTC003):
+        order_no = order_no_of(bucket_no, row)
+        if order_no is not None:
+            covered.add(order_no)
+        _check_row(ctx, lines, bucket_no, row)
+    _missing_rows(ctx, lines, covered)
+
+
+def _check_row(
+    ctx: IssueContext,
+    lines: list[CheckLine],
+    bucket_no: str | None,
+    row: Mapping[str, Any],
+) -> None:
+    order_no = order_no_of(bucket_no, row)
+    bucket = order_bucket(ctx, order_no)
+    master = None if bucket is None else bucket.get("master")
+    if not isinstance(master, Mapping):
+        master = None
+    side = side_of_row(row, master)
+    if side == "buy":
+        _same_buy(lines, row, order_no)
+    elif side == "sell":
+        _same_sell(lines, row, order_no)
+    else:
+        emit(
+            lines,
+            formula="订单方向决定 OTC003 共赢票据订单报表的同行公式",
+            basis=_BASIS_ROW,
+            calc_text="订单方向不是买入、卖出、强平或到期",
+            calc_value=None,
+            check_text=f"{OTC003_NAME}(主订单编号 {order_no})" if order_no else OTC003_NAME,
+            check_value=None,
+            verdict=VERDICT_SUSPECT,
+        )
+    _cross(ctx, lines, row, order_no, bucket, side)
+    _hedge(lines, row, order_no, bucket)
+
+
+def _same_buy(lines: list[CheckLine], row: Mapping[str, Any], order_no: str | None) -> None:
+    _ratio_qty(lines, row, order_no)
+    emit_amount(
+        lines,
+        formula="价格 = 100",
+        basis=_BASIS_ROW,
+        calc_text="100",
+        calc_value=Decimal(100),
+        report_name=OTC003_NAME,
+        column="价格",
+        row=row,
+        field="price_pct",
+        order_no=order_no,
+        scale=SCALE_PCT,
+    )
+    _equal_columns(
+        lines,
+        formula="成交金额(结算币种) = 票据面值(结算币种)",
+        left_field="face_value_settle",
+        left_name="票据面值(结算币种)",
+        right_field="deal_amt_settle",
+        right_name="成交金额(结算币种)",
+        row=row,
+        order_no=order_no,
+        scale=SCALE_AMT,
+    )
+    _sum_two(
+        lines,
+        formula="总金额(结算币种) = 票据面值(结算币种) + 发行费用(结算币种)",
+        left_field="face_value_settle",
+        left_name="票据面值(结算币种)",
+        right_field="issue_fee_settle",
+        right_name="发行费用(结算币种)",
+        check_field="total_amt_settle",
+        check_name="总金额(结算币种)",
+        row=row,
+        order_no=order_no,
+    )
+    # 发行端总金额不含承销费用,买入承销费发行方不计算。不再另出一条永远可疑的行。销售端仍核承销费用。
+
+
+def _same_sell(lines: list[CheckLine], row: Mapping[str, Any], order_no: str | None) -> None:
+    _sell_price(lines, row, order_no)
+    _equal_columns(
+        lines,
+        formula=_WRITE_DEAL,
+        left_field="total_amt_settle",
+        left_name="总金额(结算币种)",
+        right_field="deal_amt_settle",
+        right_name="成交金额(结算币种)",
+        row=row,
+        order_no=order_no,
+        scale=SCALE_AMT,
+    )
+    present, number = present_number(row, "deal_amt_settle")
+    emit(
+        lines,
+        formula=_DEAL_GAP,
+        basis="发行端写入为成交金额(结算币种) = 总金额(结算币种),与说明不一致,不改写入等式",
+        calc_text="发行端写入为成交金额(结算币种) = 总金额(结算币种)",
+        calc_value=None,
+        check_text=_named(present, number, "成交金额(结算币种)", order_no, SCALE_AMT),
+        check_value=number if present else None,
+        verdict=VERDICT_SUSPECT,
+        scale=SCALE_AMT,
+    )
+    _subtract(
+        lines,
+        formula="总金额(结算币种) = 成交金额(结算币种) − 发行费用(结算币种)",
+        basis="发行端写入等式",
+        left_field="deal_amt_settle",
+        left_name="成交金额(结算币种)",
+        right_field="issue_fee_settle",
+        right_name="发行费用(结算币种)",
+        check_field="total_amt_settle",
+        check_name="总金额(结算币种)",
+        row=row,
+        order_no=order_no,
+    )
+
+
+def _ratio_qty(lines: list[CheckLine], row: Mapping[str, Any], order_no: str | None) -> None:
+    formula = "股票数量 = 名义本金(交易币种) / 成交股价"
+    notional_ok, notional = present_number(row, "notional_trade")
+    price_ok, price = present_number(row, "deal_price")
+    if price_ok and price is not None and quantize(price, SCALE_PX) == 0:
+        emit_amount(
+            lines,
+            formula=formula,
+            basis="成交股价为 0 时不除",
+            calc_text="成交股价为 0",
+            calc_value=None,
+            report_name=OTC003_NAME,
+            column="股票数量",
+            row=row,
+            field="share_qty",
+            order_no=order_no,
+            scale=SCALE_QTY,
+            force=VERDICT_SUSPECT,
+        )
+        return
+    if not notional_ok or notional is None or not price_ok or price is None:
+        emit_amount(
+            lines,
+            formula=formula,
+            basis=_BASIS_ROW,
+            calc_text="名义本金(交易币种)或成交股价缺失",
+            calc_value=None,
+            report_name=OTC003_NAME,
+            column="股票数量",
+            row=row,
+            field="share_qty",
+            order_no=order_no,
+            scale=SCALE_QTY,
+        )
+        return
+    calc = notional / price
+    text = (
+        f"{format_decimal(notional, SCALE_AMT)} / {format_decimal(price, SCALE_PX)} "
+        f"= {format_decimal(calc, SCALE_QTY)}"
+    )
+    emit_amount(
+        lines,
+        formula=formula,
+        basis=_BASIS_ROW,
+        calc_text=text,
+        calc_value=calc,
+        report_name=OTC003_NAME,
+        column="股票数量",
+        row=row,
+        field="share_qty",
+        order_no=order_no,
+        scale=SCALE_QTY,
+    )
+
+
+def _sell_price(lines: list[CheckLine], row: Mapping[str, Any], order_no: str | None) -> None:
+    formula = "价格 = 总金额(结算币种) / (票据面值(结算币种) + 卖出增额金额(结算币种)) × 100"
+    total_ok, total = present_number(row, "total_amt_settle")
+    face_ok, face = present_number(row, "face_value_settle")
+    add_ok, added = present_number(row, "sold_add_amt")
+    if not total_ok or total is None or not face_ok or face is None or not add_ok or added is None:
+        emit_amount(
+            lines,
+            formula=formula,
+            basis=_BASIS_ROW,
+            calc_text="总金额(结算币种)、票据面值(结算币种)或卖出增额金额(结算币种)缺失",
+            calc_value=None,
+            report_name=OTC003_NAME,
+            column="价格",
+            row=row,
+            field="price_pct",
+            order_no=order_no,
+            scale=SCALE_PCT,
+        )
+        return
+    denom = face + added
+    if denom == 0:
+        emit_amount(
+            lines,
+            formula=formula,
+            basis="分母为 0 时不除",
+            calc_text="票据面值(结算币种) + 卖出增额金额(结算币种) = 0",
+            calc_value=None,
+            report_name=OTC003_NAME,
+            column="价格",
+            row=row,
+            field="price_pct",
+            order_no=order_no,
+            scale=SCALE_PCT,
+            force=VERDICT_SUSPECT,
+        )
+        return
+    calc = total / denom * Decimal(100)
+    text = (
+        f"{format_decimal(total, SCALE_AMT)} / ({format_decimal(face, SCALE_AMT)} + "
+        f"{format_decimal(added, SCALE_AMT)}) × 100 = {format_decimal(calc, SCALE_PCT)}"
+    )
+    emit_amount(
+        lines,
+        formula=formula,
+        basis=_BASIS_ROW,
+        calc_text=text,
+        calc_value=calc,
+        report_name=OTC003_NAME,
+        column="价格",
+        row=row,
+        field="price_pct",
+        order_no=order_no,
+        scale=SCALE_PCT,
+    )
+
+
+def _cross(
+    ctx: IssueContext,
+    lines: list[CheckLine],
+    row: Mapping[str, Any],
+    order_no: str | None,
+    bucket: Mapping[str, Any] | None,
+    side: str | None,
+) -> None:
+    sub, sub_text = _match_sub(row, bucket)
+    note, note_ambiguous = _one_note(None if bucket is None else bucket.get("note_sub_a"))
+    for formula, source_field, report_field, column, scale in _SHARED_CROSS:
+        _from_sub(
+            lines,
+            formula=formula,
+            source_field=source_field,
+            report_field=report_field,
+            column=column,
+            scale=scale,
+            row=row,
+            order_no=order_no,
+            sub=sub,
+            sub_text=sub_text,
+            ctx=ctx,
+            cleared_only=True,
+        )
+    if side == "buy":
+        _from_sub(
+            lines,
+            formula=_FACE_BUY,
+            source_field="exec_face_value_settle",
+            report_field="face_value_settle",
+            column="票据面值(结算币种)",
+            scale=SCALE_AMT,
+            row=row,
+            order_no=order_no,
+            sub=sub,
+            sub_text=sub_text,
+            ctx=ctx,
+            cleared_only=True,
+        )
+    elif side == "sell":
+        _from_sub(
+            lines,
+            formula=_FACE_SELL,
+            source_field="sold_face_value",
+            report_field="face_value_settle",
+            column="票据面值(结算币种)",
+            scale=SCALE_AMT,
+            row=row,
+            order_no=order_no,
+            sub=sub,
+            sub_text=sub_text,
+            ctx=ctx,
+            cleared_only=True,
+        )
+        _from_sub(
+            lines,
+            formula="卖出增额金额(结算币种) = 子单卖出增额金额",
+            source_field="sold_add_amt",
+            report_field="sold_add_amt",
+            column="卖出增额金额(结算币种)",
+            scale=SCALE_AMT,
+            row=row,
+            order_no=order_no,
+            sub=sub,
+            sub_text=sub_text,
+            ctx=ctx,
+            cleared_only=True,
+        )
+        _sell_total(ctx, lines, row, order_no, sub, sub_text, note, note_ambiguous)
+
+
+def _from_sub(
+    lines: list[CheckLine],
+    *,
+    formula: str,
+    source_field: str,
+    report_field: str,
+    column: str,
+    scale: int,
+    row: Mapping[str, Any],
+    order_no: str | None,
+    sub: Mapping[str, Any] | None,
+    sub_text: str | None,
+    ctx: IssueContext,
+    cleared_only: bool = False,
+) -> None:
+    if sub is None:
+        emit_amount(
+            lines,
+            formula=formula,
+            basis=_BASIS_CROSS,
+            calc_text=sub_text or "子单缺失",
+            calc_value=None,
+            report_name=OTC003_NAME,
+            column=column,
+            row=row,
+            field=report_field,
+            order_no=order_no,
+            scale=scale,
+        )
+        return
+    # 报表对 cleared 子单,不用回写 after_val。
+    cell = stored_cell(sub, source_field) if cleared_only else read_cell(
+        sub, source_field, "order_sub_a", ctx.logs, ctx.sources
+    )
+    if cell.conflict:
+        emit_amount(
+            lines,
+            formula=formula,
+            basis=_BASIS_CROSS,
+            calc_text="回写 after_val 不一致",
+            calc_value=None,
+            report_name=OTC003_NAME,
+            column=column,
+            row=row,
+            field=report_field,
+            order_no=order_no,
+            scale=scale,
+            force=VERDICT_SUSPECT,
+        )
+        return
+    if cell.absent or cell.number is None:
+        emit_amount(
+            lines,
+            formula=formula,
+            basis=_BASIS_CROSS,
+            calc_text=f"{_SOURCE_CN.get(source_field, source_field)}缺失",
+            calc_value=None,
+            report_name=OTC003_NAME,
+            column=column,
+            row=row,
+            field=report_field,
+            order_no=order_no,
+            scale=scale,
+            live=cell.live,
+        )
+        return
+    emit_amount(
+        lines,
+        formula=formula,
+        basis=_BASIS_CROSS,
+        calc_text=format_decimal(cell.number, scale),
+        calc_value=cell.number,
+        report_name=OTC003_NAME,
+        column=column,
+        row=row,
+        field=report_field,
+        order_no=order_no,
+        scale=scale,
+        live=cell.live,
+    )
+
+
+def _sell_total(
+    ctx: IssueContext,
+    lines: list[CheckLine],
+    row: Mapping[str, Any],
+    order_no: str | None,
+    sub: Mapping[str, Any] | None,
+    sub_text: str | None,
+    note: Mapping[str, Any] | None,
+    note_ambiguous: bool,
+) -> None:
+    """跨源卖出总金额。写入等式相等时,这一条仍可以是错误或可疑。"""
+
+    basis = "说明中的卖出总金额。被核对对象仍是本行总金额(结算币种)"
+    if sub is None:
+        emit_amount(
+            lines,
+            formula=_SELL_TOTAL,
+            basis=basis,
+            calc_text=sub_text or "子单缺失",
+            calc_value=None,
+            report_name=OTC003_NAME,
+            column="总金额(结算币种)",
+            row=row,
+            field="total_amt_settle",
+            order_no=order_no,
+            scale=SCALE_AMT,
+        )
+        return
+    face = stored_cell(sub, "sold_face_value")
+    added = stored_cell(sub, "sold_add_amt")
+    pnl = stored_cell(sub, "underlying_pnl")
+    live = face.live or added.live or pnl.live
+    if face.conflict or added.conflict or pnl.conflict:
+        emit_amount(
+            lines,
+            formula=_SELL_TOTAL,
+            basis=basis,
+            calc_text="回写 after_val 不一致",
+            calc_value=None,
+            report_name=OTC003_NAME,
+            column="总金额(结算币种)",
+            row=row,
+            field="total_amt_settle",
+            order_no=order_no,
+            scale=SCALE_AMT,
+            live=live,
+            force=VERDICT_SUSPECT,
+        )
+        return
+    if any(cell.absent or cell.number is None for cell in (face, added, pnl)):
+        emit_amount(
+            lines,
+            formula=_SELL_TOTAL,
+            basis=basis,
+            calc_text="卖出票据面值、卖出增额金额或底层资产盈亏缺失",
+            calc_value=None,
+            report_name=OTC003_NAME,
+            column="总金额(结算币种)",
+            row=row,
+            field="total_amt_settle",
+            order_no=order_no,
+            scale=SCALE_AMT,
+            live=live,
+        )
+        return
+    face_q = quantize(face.number, SCALE_AMT)
+    add_q = quantize(added.number, SCALE_AMT)
+    pnl_q = quantize(pnl.number, SCALE_AMT)
+    yield_cell = read_note_cell(note, "a_yield_ratio", ctx.logs, ctx.sources)
+    if note_ambiguous:
+        yield_ratio = None
+        yield_text = "票据不唯一,收益比例缺失"
+        yield_live = False
+    elif yield_cell.absent or yield_cell.number is None:
+        yield_ratio = None
+        yield_text = "收益比例缺失"
+        yield_live = yield_cell.live
+    else:
+        yield_ratio = yield_cell.number
+        yield_text = ""
+        yield_live = yield_cell.live
+    if pnl_q > 0 and yield_ratio is None:
+        emit_amount(
+            lines,
+            formula=_SELL_TOTAL,
+            basis="收益比例缺失时卖出总金额可疑",
+            calc_text=yield_text,
+            calc_value=None,
+            report_name=OTC003_NAME,
+            column="总金额(结算币种)",
+            row=row,
+            field="total_amt_settle",
+            order_no=order_no,
+            scale=SCALE_AMT,
+            live=live or yield_live,
+            force=VERDICT_SUSPECT,
+        )
+        return
+    calc = total_amount(face_q, add_q, pnl_q, yield_ratio)
+    if calc is None:
+        text = "卖出总金额无法计算"
+    elif pnl_q > 0:
+        text = (
+            f"{format_decimal(face_q, SCALE_AMT)} + {format_decimal(add_q, SCALE_AMT)} + "
+            f"{format_decimal(pnl_q, SCALE_AMT)} × {format_decimal(yield_ratio, SCALE_AMT)} "
+            f"= {format_decimal(calc, SCALE_AMT)}"
+        )
+    else:
+        text = (
+            f"MAX(0, {format_decimal(face_q, SCALE_AMT)} + {format_decimal(add_q, SCALE_AMT)} + "
+            f"{format_decimal(pnl_q, SCALE_AMT)}) = {format_decimal(calc, SCALE_AMT)}"
+        )
+    emit_amount(
+        lines,
+        formula=_SELL_TOTAL,
+        basis=basis,
+        calc_text=text,
+        calc_value=calc,
+        report_name=OTC003_NAME,
+        column="总金额(结算币种)",
+        row=row,
+        field="total_amt_settle",
+        order_no=order_no,
+        scale=SCALE_AMT,
+        live=live or yield_live,
+    )
+
+
+def _hedge(
+    lines: list[CheckLine],
+    row: Mapping[str, Any],
+    order_no: str | None,
+    bucket: Mapping[str, Any] | None,
+) -> None:
+    """对冲仓位不在子单上。持仓行缺失时不把 0 填进去。"""
+
+    positions = [] if bucket is None else mappings(bucket.get("hedge_position"))
+    _position_line(
+        lines,
+        formula="对冲仓位股数 = 对冲持仓股数",
+        column="对冲仓位股数",
+        field="hedge_position_qty",
+        scale=SCALE_QTY,
+        row=row,
+        order_no=order_no,
+        positions=positions,
+    )
+    _position_line(
+        lines,
+        formula="对冲仓位实现盈亏 = 对冲持仓实现盈亏",
+        column="对冲仓位实现盈亏",
+        field="hedge_realized_pnl",
+        scale=SCALE_AMT,
+        row=row,
+        order_no=order_no,
+        positions=positions,
+    )
+
+
+def _position_line(
+    lines: list[CheckLine],
+    *,
+    formula: str,
+    column: str,
+    field: str,
+    scale: int,
+    row: Mapping[str, Any],
+    order_no: str | None,
+    positions: Sequence[Mapping[str, Any]],
+) -> None:
+    basis = "对冲仓位股数、对冲仓位实现盈亏取自对冲持仓行"
+    if not positions:
+        emit_amount(
+            lines,
+            formula=formula,
+            basis=basis,
+            calc_text="对冲持仓行缺失",
+            calc_value=None,
+            report_name=OTC003_NAME,
+            column=column,
+            row=row,
+            field=field,
+            order_no=order_no,
+            scale=scale,
+            force=VERDICT_SUSPECT,
+        )
+        return
+    parts: list[Decimal] = []
+    for position in positions:
+        present, number = present_number(position, field)
+        if not present or number is None:
+            emit_amount(
+                lines,
+                formula=formula,
+                basis=basis,
+                calc_text="对冲持仓行缺少数字",
+                calc_value=None,
+                report_name=OTC003_NAME,
+                column=column,
+                row=row,
+                field=field,
+                order_no=order_no,
+                scale=scale,
+                force=VERDICT_SUSPECT,
+            )
+            return
+        parts.append(number)
+    total = sum(parts, Decimal(0))
+    emit_amount(
+        lines,
+        formula=formula,
+        basis=basis,
+        calc_text=join_decimals(parts, scale),
+        calc_value=total,
+        report_name=OTC003_NAME,
+        column=column,
+        row=row,
+        field=field,
+        order_no=order_no,
+        scale=scale,
+    )
+
+
+def _missing_rows(ctx: IssueContext, lines: list[CheckLine], covered: set[str]) -> None:
+    for order_no in sorted(ctx.orders):
+        bucket = ctx.orders[order_no]
+        master = bucket.get("master")
+        # 往日参照订单不要求本营业日必须有 OTC003 行。
+        if not isinstance(master, Mapping) or not in_order_population(master) or not is_population_bucket(bucket):
+            continue
+        if not mappings(bucket.get("order_sub_a")):
+            continue
+        key = master.get("order_no")
+        label = key.strip() if isinstance(key, str) and key.strip() else order_no
+        if label in covered:
+            continue
+        emit(
+            lines,
+            formula="OTC003 共赢票据订单报表应有该主订单编号的行",
+            basis="主单在子单存在但本报表无行",
+            calc_text="子单存在",
+            calc_value=None,
+            check_text=f"{OTC003_NAME} 无行(主订单编号 {label})",
+            check_value=None,
+            verdict=VERDICT_ERROR,
+        )
+
+
+def _match_sub(
+    row: Mapping[str, Any],
+    bucket: Mapping[str, Any] | None,
+) -> tuple[Mapping[str, Any] | None, str | None]:
+    """按子订单编号对上子单。对不上且不止一张时不猜。"""
+
+    subs = [] if bucket is None else mappings(bucket.get("order_sub_a"))
+    sub_no = row.get("sub_order_no")
+    if isinstance(sub_no, str) and sub_no.strip():
+        matched = [sub for sub in subs if sub.get("sub_order_no") == sub_no.strip()]
+        if len(matched) == 1:
+            return matched[0], None
+        if len(matched) > 1:
+            return None, "无法对应到唯一子单"
+    if len(subs) == 1:
+        return subs[0], None
+    if not subs:
+        return None, "子单缺失"
+    return None, "无法对应到唯一子单"
+
+
+def _one_note(value: object) -> tuple[Mapping[str, Any] | None, bool]:
+    notes = mappings(value)
+    if len(notes) == 1:
+        return notes[0], False
+    if len(notes) > 1:
+        return None, True
+    return None, False
+
+
+def _equal_columns(
+    lines: list[CheckLine],
+    *,
+    formula: str,
+    left_field: str,
+    left_name: str,
+    right_field: str,
+    right_name: str,
+    row: Mapping[str, Any],
+    order_no: str | None,
+    scale: int,
+) -> None:
+    present, number = present_number(row, left_field)
+    if not present or number is None:
+        emit_amount(
+            lines,
+            formula=formula,
+            basis=_BASIS_ROW,
+            calc_text=f"{left_name}缺失",
+            calc_value=None,
+            report_name=OTC003_NAME,
+            column=right_name,
+            row=row,
+            field=right_field,
+            order_no=order_no,
+            scale=scale,
+        )
+        return
+    emit_amount(
+        lines,
+        formula=formula,
+        basis=_BASIS_ROW,
+        calc_text=format_decimal(number, scale),
+        calc_value=number,
+        report_name=OTC003_NAME,
+        column=right_name,
+        row=row,
+        field=right_field,
+        order_no=order_no,
+        scale=scale,
+    )
+
+
+def _sum_two(
+    lines: list[CheckLine],
+    *,
+    formula: str,
+    left_field: str,
+    left_name: str,
+    right_field: str,
+    right_name: str,
+    check_field: str,
+    check_name: str,
+    row: Mapping[str, Any],
+    order_no: str | None,
+) -> None:
+    left_ok, left = present_number(row, left_field)
+    right_ok, right = present_number(row, right_field)
+    if not left_ok or left is None or not right_ok or right is None:
+        emit_amount(
+            lines,
+            formula=formula,
+            basis=_BASIS_ROW,
+            calc_text=f"{left_name}或{right_name}缺失",
+            calc_value=None,
+            report_name=OTC003_NAME,
+            column=check_name,
+            row=row,
+            field=check_field,
+            order_no=order_no,
+            scale=SCALE_AMT,
+        )
+        return
+    calc = left + right
+    text = f"{format_decimal(left, SCALE_AMT)} + {format_decimal(right, SCALE_AMT)} = {format_decimal(calc, SCALE_AMT)}"
+    emit_amount(
+        lines,
+        formula=formula,
+        basis=_BASIS_ROW,
+        calc_text=text,
+        calc_value=calc,
+        report_name=OTC003_NAME,
+        column=check_name,
+        row=row,
+        field=check_field,
+        order_no=order_no,
+        scale=SCALE_AMT,
+    )
+
+
+def _subtract(
+    lines: list[CheckLine],
+    *,
+    formula: str,
+    basis: str,
+    left_field: str,
+    left_name: str,
+    right_field: str,
+    right_name: str,
+    check_field: str,
+    check_name: str,
+    row: Mapping[str, Any],
+    order_no: str | None,
+) -> None:
+    left_ok, left = present_number(row, left_field)
+    right_ok, right = present_number(row, right_field)
+    if not left_ok or left is None or not right_ok or right is None:
+        emit_amount(
+            lines,
+            formula=formula,
+            basis=basis,
+            calc_text=f"{left_name}或{right_name}缺失",
+            calc_value=None,
+            report_name=OTC003_NAME,
+            column=check_name,
+            row=row,
+            field=check_field,
+            order_no=order_no,
+            scale=SCALE_AMT,
+        )
+        return
+    calc = left - right
+    text = f"{format_decimal(left, SCALE_AMT)} - {format_decimal(right, SCALE_AMT)} = {format_decimal(calc, SCALE_AMT)}"
+    emit_amount(
+        lines,
+        formula=formula,
+        basis=basis,
+        calc_text=text,
+        calc_value=calc,
+        report_name=OTC003_NAME,
+        column=check_name,
+        row=row,
+        field=check_field,
+        order_no=order_no,
+        scale=SCALE_AMT,
+    )
+
+
+def _named(
+    present: bool,
+    number: Decimal | None,
+    column: str,
+    order_no: str | None,
+    scale: int,
+) -> str:
+    suffix = f"(主订单编号 {order_no})" if order_no else ""
+    if not present or number is None:
+        return f"{OTC003_NAME}.{column} 缺失{suffix}"
+    return f"{OTC003_NAME}.{column} = {format_decimal(number, scale)}{suffix}"

+ 813 - 0
code/post_clear_check/src/post_clear_check/issue_otc004.py

@@ -0,0 +1,813 @@
+"""发行端 OTC004 与 OTC005。
+
+OTC004 有日切宽表。发行费用不在这张表上,发行价只走跨源,同行不硬算。
+OTC005 没有日切宽表,列与 OTC004 相同,输入用实时票据。没有存续或待生效票据时不产生结论行。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from decimal import Decimal
+from typing import Any
+
+from post_clear_check.issue_common import (
+    SCALE_PCT,
+    OTC004_NAME,
+    OTC005_NAME,
+    T_OTC004,
+    IssueContext,
+    emit,
+    emit_amount,
+    first_fund,
+    fund_no_of,
+    is_sell_master,
+    mappings,
+    note_no_of,
+    note_order_buckets,
+    on_business_date,
+    present_number,
+    side_of,
+    unique_rows,
+)
+from post_clear_check.note_chain import stage_has_note
+from post_clear_check.order_cell import CheckLine, own_fund, read_cell, read_note_cell
+from post_clear_check.order_decimal import (
+    SCALE_AMT,
+    SCALE_FX,
+    SCALE_PX,
+    SCALE_QTY,
+    VERDICT_ERROR,
+    VERDICT_SUSPECT,
+    as_decimal,
+    format_decimal,
+    quantize,
+    total_amount,
+)
+
+_ORDER_STATUSES = frozenset({"completed", "settled"})
+_ISSUE_PRICE = "票据发行价(结算币种) = 当前票据面值(结算币种) + 发行费用(结算币种)"
+_FLOAT = "浮动盈亏(结算币种) = 票据估值(结算币种) − 当前票据面值(结算币种) − 增额金额(结算币种)"
+_NAV = "票据价格/净值 = 票据估值(结算币种) / (当前票据面值(结算币种) + 增额金额(结算币种)) × 100"
+_VALUATION = (
+    "票据估值(结算币种) = 底层资产浮动盈亏 > 0 时当前票据面值 + 增额金额 + "
+    "底层资产浮动盈亏 × 收益比例,否则 MAX(0, 当前票据面值 + 增额金额 + 底层资产浮动盈亏)"
+)
+_DIFF = "OTC005 共赢票据实时详情报表与当日 OTC004 共赢票据每日切片详情报表同列"
+
+# 副本列与报表列同名。标度按金额、股数、汇率、价格分开。
+_COPY_COLUMNS: tuple[tuple[str, str, str, int], ...] = (
+    ("存续名义本金 = 票据副本的存续名义本金", "cur_notional_trade", "存续名义本金", SCALE_AMT),
+    ("当前股票数量 = 票据副本的当前股票数量", "cur_qty", "当前股票数量", SCALE_QTY),
+    ("当前票据面值(结算币种) = 票据副本的当前票据面值(结算币种)", "cur_face_value", "当前票据面值(结算币种)", SCALE_AMT),
+    ("增额金额(结算币种) = 票据副本的增额金额(结算币种)", "add_amt", "增额金额(结算币种)", SCALE_AMT),
+    ("分红金额 = 票据副本的分红金额", "dividend_amt", "分红金额", SCALE_AMT),
+    ("初始结算汇率 = 票据副本的初始结算汇率", "init_fx", "初始结算汇率", SCALE_FX),
+    ("当前期初价格 = 票据副本的当前期初价格", "cur_px", "当前期初价格", SCALE_PX),
+)
+
+_SOLD_COLUMNS: tuple[tuple[str, str, str, str], ...] = (
+    ("已卖出面值 = 已完成或已结算卖出单的卖出票据面值之和", "sold_face_value", "sold_face_value", "已卖出面值"),
+    ("已卖出底层资产盈亏 = 已完成或已结算卖出单的底层资产盈亏之和", "underlying_pnl", "sold_underlying_pnl", "已卖出底层资产盈亏"),
+    ("合计卖出金额 = 已完成或已结算卖出单的执行总金额之和", "exec_total_amt_settle", "sold_total_amt", "合计卖出金额"),
+)
+
+# 行情和持仓会继续变的列。其余列变了就不能只归因于日切之后的行情。
+_SENSITIVE = frozenset({"valuation", "float_pnl", "nav", "cur_qty"})
+_COMPARE: tuple[tuple[str, str, int], ...] = (
+    ("valuation", "票据估值(结算币种)", SCALE_AMT),
+    ("float_pnl", "浮动盈亏(结算币种)", SCALE_AMT),
+    ("nav", "票据价格/净值", SCALE_PCT),
+    ("cur_qty", "当前股票数量", SCALE_QTY),
+    ("cur_face_value", "当前票据面值(结算币种)", SCALE_AMT),
+    ("add_amt", "增额金额(结算币种)", SCALE_AMT),
+    ("dividend_amt", "分红金额", SCALE_AMT),
+    ("cur_notional_trade", "存续名义本金", SCALE_AMT),
+    ("init_fx", "初始结算汇率", SCALE_FX),
+    ("cur_px", "当前期初价格", SCALE_PX),
+    ("issue_price_settle", "票据发行价(结算币种)", SCALE_AMT),
+    ("sold_face_value", "已卖出面值", SCALE_AMT),
+    ("sold_underlying_pnl", "已卖出底层资产盈亏", SCALE_AMT),
+    ("sold_total_amt", "合计卖出金额", SCALE_AMT),
+)
+
+
+def check_otc004(ctx: IssueContext, lines: list[CheckLine]) -> None:
+    for _, row in unique_rows(ctx, T_OTC004):
+        note_no = note_no_of(row)
+        note, ambiguous = _resolve_note(ctx, note_no)
+        produced: list[CheckLine] = []
+        _append_detail(
+            produced,
+            report_name=OTC004_NAME,
+            row=row,
+            note=note,
+            ambiguous=ambiguous,
+            note_no=note_no,
+            ctx=ctx,
+            sources=ctx.sources,
+        )
+        # 票据存续归资金账户。报表行只有 fund_account_no,没有主订单编号列。
+        own_fund(lines, produced, fund_no_of(row))
+
+
+def check_otc005(ctx: IssueContext, lines: list[CheckLine]) -> None:
+    """没有实时票据就不产生结论行。实时值与当日切片不一致时另记一条。
+
+    存续或待生效由调用方筛进 realtime_notes。这里不猜票据状态枚举。
+    """
+
+    notes = [row for row in ctx.realtime_notes if on_business_date(row, ctx.business_date)]
+    if not notes:
+        return
+    rows = [row for row in ctx.realtime_rows if on_business_date(row, ctx.business_date)]
+    by_note = _rows_by_note(rows)
+    daycut = _rows_by_note(row for _, row in unique_rows(ctx, T_OTC004))
+    # 实时票据是这张表规定的输入,不是清算副本缺失后的回退。
+    sources: dict[str, str] = {}
+    for note in notes:
+        note_no = note_no_of(note)
+        matched = [] if note_no is None else by_note.get(note_no, [])
+        produced: list[CheckLine] = []
+        if len(matched) != 1:
+            label = note_no or "-"
+            reason = "实时计算后的列缺失" if not matched else "实时计算后的列不唯一"
+            emit(
+                produced,
+                formula="OTC005 共赢票据实时详情报表有实时计算后的列",
+                basis="被核对对象是实时计算后的列",
+                calc_text=reason,
+                calc_value=None,
+                check_text=f"{OTC005_NAME}({label})",
+                check_value=None,
+                verdict=VERDICT_ERROR if not matched else VERDICT_SUSPECT,
+            )
+            # 没有实时行时只剩实时票据这条来源记录。
+            own_fund(lines, produced, fund_no_of(note))
+            continue
+        row = matched[0]
+        _append_detail(
+            produced,
+            report_name=OTC005_NAME,
+            row=row,
+            note=note,
+            ambiguous=False,
+            note_no=note_no,
+            ctx=ctx,
+            sources=sources,
+        )
+        # 已有步骤快照时,实时和切片的差额由当前投影解释,不再记成切片错误。
+        if not stage_has_note(ctx.issue_stages, note_no):
+            _compare_daycut(produced, row, note_no, daycut)
+        # 实时行优先,其次当日切片行,最后实时票据。
+        same_day = daycut.get(note_no, []) if note_no is not None else []
+        own_fund(lines, produced, first_fund([row, *same_day, note]))
+
+
+def _append_detail(
+    lines: list[CheckLine],
+    *,
+    report_name: str,
+    row: Mapping[str, Any],
+    note: Mapping[str, Any] | None,
+    ambiguous: bool,
+    note_no: str | None,
+    ctx: IssueContext,
+    sources: Mapping[str, str],
+) -> None:
+    _float_pnl(lines, report_name, row)
+    _nav(lines, report_name, row)
+    _issue_price(lines, report_name, row, note_no, ctx)
+    _copy_columns(lines, report_name, row, note, ambiguous, sources, ctx)
+    _valuation(lines, report_name, row, note, ambiguous, sources, ctx)
+    _sold_sums(lines, report_name, row, note_no, ctx)
+
+
+def _float_pnl(lines: list[CheckLine], report_name: str, row: Mapping[str, Any]) -> None:
+    # 已卖出后当前面值被置 0,浮动盈亏保留的是卖出盈亏,不再等于估值减当前面值。
+    if row.get("note_status") == "sold":
+        return
+    value_ok, valuation = present_number(row, "valuation")
+    face_ok, face = present_number(row, "cur_face_value")
+    add_ok, added = present_number(row, "add_amt")
+    if not value_ok or valuation is None or not face_ok or face is None or not add_ok or added is None:
+        emit_amount(
+            lines,
+            formula=_FLOAT,
+            basis="同行公式",
+            calc_text="票据估值(结算币种)、当前票据面值(结算币种)或增额金额(结算币种)缺失",
+            calc_value=None,
+            report_name=report_name,
+            column="浮动盈亏(结算币种)",
+            row=row,
+            field="float_pnl",
+            order_no=None,
+            scale=SCALE_AMT,
+        )
+        return
+    calc = valuation - face - added
+    text = (
+        f"{format_decimal(valuation, SCALE_AMT)} - {format_decimal(face, SCALE_AMT)} - "
+        f"{format_decimal(added, SCALE_AMT)} = {format_decimal(calc, SCALE_AMT)}"
+    )
+    emit_amount(
+        lines,
+        formula=_FLOAT,
+        basis="同行公式",
+        calc_text=text,
+        calc_value=calc,
+        report_name=report_name,
+        column="浮动盈亏(结算币种)",
+        row=row,
+        field="float_pnl",
+        order_no=None,
+        scale=SCALE_AMT,
+    )
+
+
+def _nav(lines: list[CheckLine], report_name: str, row: Mapping[str, Any]) -> None:
+    value_ok, valuation = present_number(row, "valuation")
+    face_ok, face = present_number(row, "cur_face_value")
+    add_ok, added = present_number(row, "add_amt")
+    if not value_ok or valuation is None or not face_ok or face is None or not add_ok or added is None:
+        emit_amount(
+            lines,
+            formula=_NAV,
+            basis="同行公式",
+            calc_text="票据估值(结算币种)、当前票据面值(结算币种)或增额金额(结算币种)缺失",
+            calc_value=None,
+            report_name=report_name,
+            column="票据价格/净值",
+            row=row,
+            field="nav",
+            order_no=None,
+            scale=SCALE_PCT,
+        )
+        return
+    denom = face + added
+    if denom == 0:
+        emit_amount(
+            lines,
+            formula=_NAV,
+            basis="分母为 0 时不除",
+            calc_text="当前票据面值(结算币种) + 增额金额(结算币种) = 0",
+            calc_value=None,
+            report_name=report_name,
+            column="票据价格/净值",
+            row=row,
+            field="nav",
+            order_no=None,
+            scale=SCALE_PCT,
+            force=VERDICT_SUSPECT,
+        )
+        return
+    calc = valuation / denom * Decimal(100)
+    text = (
+        f"{format_decimal(valuation, SCALE_AMT)} / ({format_decimal(face, SCALE_AMT)} + "
+        f"{format_decimal(added, SCALE_AMT)}) × 100 = {format_decimal(calc, SCALE_PCT)}"
+    )
+    emit_amount(
+        lines,
+        formula=_NAV,
+        basis="同行公式",
+        calc_text=text,
+        calc_value=calc,
+        report_name=report_name,
+        column="票据价格/净值",
+        row=row,
+        field="nav",
+        order_no=None,
+        scale=SCALE_PCT,
+    )
+
+
+def _issue_price(
+    lines: list[CheckLine],
+    report_name: str,
+    row: Mapping[str, Any],
+    note_no: str | None,
+    ctx: IssueContext,
+) -> None:
+    """发行费用不在本表。只拿订单上的执行发行费用,同行不硬算。"""
+
+    # 已卖出后当前面值是 0,发行价仍是发行时的面值加发行费用,不能用置 0 后的当前面值。
+    if row.get("note_status") == "sold":
+        return
+    face_ok, face = present_number(row, "cur_face_value")
+    fee, fee_text, live = _issue_fee(ctx, note_no)
+    if not face_ok or face is None or fee is None:
+        reason = fee_text if face_ok else "当前票据面值(结算币种)缺失"
+        if face_ok and fee is None:
+            reason = fee_text
+        emit_amount(
+            lines,
+            formula=_ISSUE_PRICE,
+            basis="发行费用不在本表,改到跨源",
+            calc_text=reason,
+            calc_value=None,
+            report_name=report_name,
+            column="票据发行价(结算币种)",
+            row=row,
+            field="issue_price_settle",
+            order_no=None,
+            scale=SCALE_AMT,
+            live=live,
+        )
+        return
+    calc = face + fee
+    text = f"{format_decimal(face, SCALE_AMT)} + {format_decimal(fee, SCALE_AMT)} = {format_decimal(calc, SCALE_AMT)}"
+    emit_amount(
+        lines,
+        formula=_ISSUE_PRICE,
+        basis="发行费用不在本表,取订单上的执行发行费用",
+        calc_text=text,
+        calc_value=calc,
+        report_name=report_name,
+        column="票据发行价(结算币种)",
+        row=row,
+        field="issue_price_settle",
+        order_no=None,
+        scale=SCALE_AMT,
+        live=live,
+    )
+
+
+def _copy_columns(
+    lines: list[CheckLine],
+    report_name: str,
+    row: Mapping[str, Any],
+    note: Mapping[str, Any] | None,
+    ambiguous: bool,
+    sources: Mapping[str, str],
+    ctx: IssueContext,
+) -> None:
+    for formula, field, column, scale in _COPY_COLUMNS:
+        if ambiguous:
+            emit_amount(
+                lines,
+                formula=formula,
+                basis="票据副本不唯一",
+                calc_text="票据副本不唯一",
+                calc_value=None,
+                report_name=report_name,
+                column=column,
+                row=row,
+                field=field,
+                order_no=None,
+                scale=scale,
+                force=VERDICT_SUSPECT,
+            )
+            continue
+        cell = read_note_cell(note, field, ctx.logs, sources)
+        # 副本列缺失按本表规则记可疑,不把缺失填成 0。
+        if cell.conflict or cell.absent or cell.number is None:
+            reason = "回写 after_val 不一致" if cell.conflict else "票据副本列缺失"
+            emit_amount(
+                lines,
+                formula=formula,
+                basis="票据副本对应列",
+                calc_text=reason,
+                calc_value=None,
+                report_name=report_name,
+                column=column,
+                row=row,
+                field=field,
+                order_no=None,
+                scale=scale,
+                live=cell.live,
+                force=VERDICT_SUSPECT,
+            )
+            continue
+        emit_amount(
+            lines,
+            formula=formula,
+            basis="票据副本对应列",
+            calc_text=format_decimal(cell.number, scale),
+            calc_value=cell.number,
+            report_name=report_name,
+            column=column,
+            row=row,
+            field=field,
+            order_no=None,
+            scale=scale,
+            live=cell.live,
+        )
+
+
+def _valuation(
+    lines: list[CheckLine],
+    report_name: str,
+    row: Mapping[str, Any],
+    note: Mapping[str, Any] | None,
+    ambiguous: bool,
+    sources: Mapping[str, str],
+    ctx: IssueContext,
+) -> None:
+    calc, text, live = _underlying_pnl(ctx, row, note, ambiguous, sources)
+    if calc is None:
+        emit_amount(
+            lines,
+            formula=_VALUATION,
+            basis="汇率方向无法从三列唯一确定,或最新价、票据副本列缺失时,票据估值可疑",
+            calc_text=text,
+            calc_value=None,
+            report_name=report_name,
+            column="票据估值(结算币种)",
+            row=row,
+            field="valuation",
+            order_no=None,
+            scale=SCALE_AMT,
+            live=live,
+            force=VERDICT_SUSPECT,
+        )
+        return
+    face = read_note_cell(note, "cur_face_value", ctx.logs, sources)
+    added = read_note_cell(note, "add_amt", ctx.logs, sources)
+    ratio = read_note_cell(note, "a_yield_ratio", ctx.logs, sources)
+    live = live or face.live or added.live or ratio.live
+    if face.absent or face.number is None or added.absent or added.number is None:
+        emit_amount(
+            lines,
+            formula=_VALUATION,
+            basis="票据副本的当前票据面值或增额金额缺失",
+            calc_text="当前票据面值或增额金额缺失",
+            calc_value=None,
+            report_name=report_name,
+            column="票据估值(结算币种)",
+            row=row,
+            field="valuation",
+            order_no=None,
+            scale=SCALE_AMT,
+            live=live,
+            force=VERDICT_SUSPECT,
+        )
+        return
+    face_q = quantize(face.number, SCALE_AMT)
+    add_q = quantize(added.number, SCALE_AMT)
+    pnl_q = quantize(calc, SCALE_AMT)
+    yield_ratio = None if ratio.absent else ratio.number
+    if pnl_q > 0 and yield_ratio is None:
+        emit_amount(
+            lines,
+            formula=_VALUATION,
+            basis="收益比例缺失时票据估值可疑",
+            calc_text="收益比例缺失",
+            calc_value=None,
+            report_name=report_name,
+            column="票据估值(结算币种)",
+            row=row,
+            field="valuation",
+            order_no=None,
+            scale=SCALE_AMT,
+            live=live,
+            force=VERDICT_SUSPECT,
+        )
+        return
+    value = total_amount(face_q, add_q, pnl_q, yield_ratio)
+    if value is None:
+        shown = "票据估值无法计算"
+    elif pnl_q > 0:
+        shown = (
+            f"{format_decimal(face_q, SCALE_AMT)} + {format_decimal(add_q, SCALE_AMT)} + "
+            f"{format_decimal(pnl_q, SCALE_AMT)} × {format_decimal(yield_ratio, SCALE_AMT)} "
+            f"= {format_decimal(value, SCALE_AMT)}"
+        )
+    else:
+        shown = (
+            f"MAX(0, {format_decimal(face_q, SCALE_AMT)} + {format_decimal(add_q, SCALE_AMT)} + "
+            f"{format_decimal(pnl_q, SCALE_AMT)}) = {format_decimal(value, SCALE_AMT)}"
+        )
+    emit_amount(
+        lines,
+        formula=_VALUATION,
+        basis="底层资产浮动盈亏换到结算币种后再按收益比例得到票据估值",
+        calc_text=f"{text};{shown}",
+        calc_value=value,
+        report_name=report_name,
+        column="票据估值(结算币种)",
+        row=row,
+        field="valuation",
+        order_no=None,
+        scale=SCALE_AMT,
+        live=live,
+    )
+
+
+def _underlying_pnl(
+    ctx: IssueContext,
+    row: Mapping[str, Any],
+    note: Mapping[str, Any] | None,
+    ambiguous: bool,
+    sources: Mapping[str, str],
+) -> tuple[Decimal | None, str, bool]:
+    """最新价 × 股数 × 账面汇率 − 期初价格 × 股数 × 初始汇率 + 分红。"""
+
+    if ambiguous or note is None:
+        return None, "票据副本缺失" if note is None and not ambiguous else "票据副本不唯一", False
+    qty = read_note_cell(note, "cur_qty", ctx.logs, sources)
+    init_px = read_note_cell(note, "cur_px", ctx.logs, sources)
+    init_fx = read_note_cell(note, "init_fx", ctx.logs, sources)
+    dividend = read_note_cell(note, "dividend_amt", ctx.logs, sources)
+    latest = _latest_price(ctx, row, note)
+    book_ok, book_fx = present_number(row, "fx_book")
+    live = any(cell.live for cell in (qty, init_px, init_fx, dividend))
+    if latest is None:
+        return None, "最新价缺失", live
+    needed = (qty, init_px, init_fx, dividend)
+    if any(cell.absent or cell.number is None for cell in needed) or not book_ok or book_fx is None:
+        return None, "票据股票数量、期初价格、初始汇率、分红金额或账面汇率缺失", live
+    # Excel 第 21 行:结算金额 = 交易金额 × 汇率。面值含报价比例,不能用来判断乘除。
+    gross = latest * qty.number * book_fx
+    cost = qty.number * init_px.number * init_fx.number
+    raw = gross - cost + dividend.number
+    ya = quantize(raw, SCALE_AMT)
+    text = (
+        f"最新价 × 票据股票数量 × 账面汇率 − 票据期初价格 × 票据股票数量 × 初始汇率 + 分红金额 "
+        f"= {format_decimal(ya, SCALE_AMT)}"
+    )
+    return ya, text, live
+
+
+def _sold_sums(
+    lines: list[CheckLine],
+    report_name: str,
+    row: Mapping[str, Any],
+    note_no: str | None,
+    ctx: IssueContext,
+) -> None:
+    # 没有票据编号时不能把当日全部卖出单加总。
+    if note_no is None:
+        for formula, _source_field, report_field, column in _SOLD_COLUMNS:
+            emit_amount(
+                lines,
+                formula=formula,
+                basis="无法对应票据,不把其他票据的卖出单加进来",
+                calc_text="无法对应票据",
+                calc_value=None,
+                report_name=report_name,
+                column=column,
+                row=row,
+                field=report_field,
+                order_no=None,
+                scale=SCALE_AMT,
+                force=VERDICT_SUSPECT,
+            )
+        return
+    subs = _sell_subs(ctx, note_no)
+    for formula, source_field, report_field, column in _SOLD_COLUMNS:
+        _one_sold(lines, report_name, row, formula, source_field, report_field, column, subs, ctx)
+
+
+def _one_sold(
+    lines: list[CheckLine],
+    report_name: str,
+    row: Mapping[str, Any],
+    formula: str,
+    source_field: str,
+    report_field: str,
+    column: str,
+    subs: list[Mapping[str, Any]],
+    ctx: IssueContext,
+) -> None:
+    if not subs:
+        emit_amount(
+            lines,
+            formula=formula,
+            basis="该票据没有已完成或已结算的卖出单,和为 0",
+            calc_text=format_decimal(Decimal(0), SCALE_AMT),
+            calc_value=Decimal(0),
+            report_name=report_name,
+            column=column,
+            row=row,
+            field=report_field,
+            order_no=None,
+            scale=SCALE_AMT,
+        )
+        return
+    parts: list[Decimal] = []
+    live = False
+    for sub in subs:
+        cell = read_cell(sub, source_field, "order_sub_a", ctx.logs, ctx.sources)
+        live = live or cell.live
+        if cell.conflict or cell.absent or cell.number is None:
+            reason = "回写 after_val 不一致" if cell.conflict else "卖出单执行列缺失"
+            emit_amount(
+                lines,
+                formula=formula,
+                basis="卖出单执行列缺失时不把缺失当成 0",
+                calc_text=reason,
+                calc_value=None,
+                report_name=report_name,
+                column=column,
+                row=row,
+                field=report_field,
+                order_no=None,
+                scale=SCALE_AMT,
+                live=live,
+                force=VERDICT_SUSPECT,
+            )
+            return
+        parts.append(cell.number)
+    total = sum(parts, Decimal(0))
+    shown = " + ".join(format_decimal(part, SCALE_AMT) for part in parts)
+    emit_amount(
+        lines,
+        formula=formula,
+        basis="已完成或已结算卖出单的对应执行列之和",
+        calc_text=f"{shown} = {format_decimal(total, SCALE_AMT)}",
+        calc_value=total,
+        report_name=report_name,
+        column=column,
+        row=row,
+        field=report_field,
+        order_no=None,
+        scale=SCALE_AMT,
+        live=live,
+    )
+
+
+def _compare_daycut(
+    lines: list[CheckLine],
+    row: Mapping[str, Any],
+    note_no: str | None,
+    daycut: dict[str, list[Mapping[str, Any]]],
+) -> None:
+    if note_no is None:
+        return
+    matched = daycut.get(note_no, [])
+    if not matched:
+        emit(
+            lines,
+            formula=_DIFF,
+            basis="输入缺失",
+            calc_text="当日 OTC004 共赢票据每日切片详情报表无行",
+            calc_value=None,
+            check_text=OTC005_NAME,
+            check_value=None,
+            verdict=VERDICT_SUSPECT,
+        )
+        return
+    if len(matched) > 1:
+        emit(
+            lines,
+            formula=_DIFF,
+            basis="当日切片不唯一",
+            calc_text="当日 OTC004 共赢票据每日切片详情报表不唯一",
+            calc_value=None,
+            check_text=OTC005_NAME,
+            check_value=None,
+            verdict=VERDICT_SUSPECT,
+        )
+        return
+    diffs = _column_diffs(row, matched[0])
+    if not diffs:
+        return
+    sensitive_only = all(field in _SENSITIVE for field, _text in diffs)
+    body = ";".join(text for _field, text in diffs)
+    if sensitive_only:
+        emit(
+            lines,
+            formula=_DIFF,
+            basis="一个是日切切片、一个是实时",
+            calc_text=body,
+            calc_value=None,
+            check_text=OTC005_NAME,
+            check_value=None,
+            verdict=VERDICT_SUSPECT,
+        )
+        return
+    emit(
+        lines,
+        formula=_DIFF,
+        basis="实时值与当日切片不一致,且不只是清算后行情或实时持仓",
+        calc_text=body,
+        calc_value=None,
+        check_text=OTC005_NAME,
+        check_value=None,
+        verdict=VERDICT_ERROR,
+    )
+
+
+def _column_diffs(
+    realtime: Mapping[str, Any],
+    daycut: Mapping[str, Any],
+) -> list[tuple[str, str]]:
+    found: list[tuple[str, str]] = []
+    for field, column, scale in _COMPARE:
+        left_ok, left = present_number(daycut, field)
+        right_ok, right = present_number(realtime, field)
+        if not left_ok and not right_ok:
+            continue
+        if left_ok and right_ok and left is not None and right is not None and quantize(left, scale) == quantize(right, scale):
+            continue
+        left_text = "缺失" if not left_ok or left is None else format_decimal(left, scale)
+        right_text = "缺失" if not right_ok or right is None else format_decimal(right, scale)
+        found.append((field, f"{column} 切片 {left_text},实时 {right_text}"))
+    return found
+
+
+def _resolve_note(ctx: IssueContext, note_no: str | None) -> tuple[Mapping[str, Any] | None, bool]:
+    if note_no is None:
+        return None, False
+    found: list[Mapping[str, Any]] = []
+    seen: set[str] = set()
+    for note in mappings(ctx.note_copies):
+        if note.get("note_no") == note_no or note.get("sub_note_no") == note_no:
+            ident = note.get("id")
+            key = str(id(note)) if ident is None else str(ident)
+            if key not in seen:
+                seen.add(key)
+                found.append(note)
+    for bucket in ctx.orders.values():
+        for note in mappings(bucket.get("note_sub_a")):
+            if note.get("note_no") != note_no and note.get("sub_note_no") != note_no:
+                continue
+            ident = note.get("id")
+            key = str(id(note)) if ident is None else str(ident)
+            if key in seen:
+                continue
+            seen.add(key)
+            found.append(note)
+    if len(found) == 1:
+        return found[0], False
+    if len(found) > 1:
+        return None, True
+    return None, False
+
+
+def _issue_fee(ctx: IssueContext, note_no: str | None) -> tuple[Decimal | None, str, bool]:
+    if note_no is None:
+        return None, "无法对应票据", False
+    numbers: list[Decimal] = []
+    live = False
+    seen = False
+    for bucket in ctx.orders.values():
+        master = bucket.get("master")
+        if not isinstance(master, Mapping):
+            continue
+        if side_of(master.get("direction"), master.get("op_type")) != "buy":
+            continue
+        if not _linked(bucket, note_no):
+            continue
+        for sub in mappings(bucket.get("order_sub_a")):
+            seen = True
+            cell = read_cell(sub, "exec_issue_fee_settle", "order_sub_a", ctx.logs, ctx.sources)
+            live = live or cell.live
+            if cell.conflict:
+                return None, "回写 after_val 不一致", live
+            if cell.absent or cell.number is None:
+                return None, "订单上的发行费用缺失", live
+            numbers.append(cell.number)
+    if not seen or not numbers:
+        return None, "订单上的发行费用缺失", live
+    folded = {quantize(number, SCALE_AMT) for number in numbers}
+    if len(folded) != 1:
+        return None, "订单上的发行费用不唯一", live
+    return numbers[0], "", live
+
+
+def _sell_subs(ctx: IssueContext, note_no: str) -> list[Mapping[str, Any]]:
+    """该票据全部已完成或已结算卖单的子单,含往日卖单(取数已按交易日不晚于本轮封顶)。"""
+
+    found: list[Mapping[str, Any]] = []
+    for bucket in note_order_buckets(ctx.orders, ctx.note_orders, note_no):
+        master = bucket.get("master")
+        if not isinstance(master, Mapping):
+            continue
+        if master.get("status") not in _ORDER_STATUSES or not is_sell_master(master):
+            continue
+        found.extend(mappings(bucket.get("order_sub_a")))
+    return found
+
+
+def _linked(bucket: Mapping[str, Any], note_no: str) -> bool:
+    master = bucket.get("master")
+    if isinstance(master, Mapping) and master.get("note_no") == note_no:
+        return True
+    for note in mappings(bucket.get("note_sub_a")):
+        if note.get("note_no") == note_no or note.get("sub_note_no") == note_no:
+            return True
+    return False
+
+
+def _latest_price(ctx: IssueContext, row: Mapping[str, Any], note: Mapping[str, Any]) -> Decimal | None:
+    """日终收盘价。票据上的 latest_px 是盘中价,不拿来冻结估值。"""
+
+    sec_code = row.get("sec_code")
+    if not isinstance(sec_code, str) or sec_code.strip() == "":
+        sec_code = note.get("sec_code")
+    if not isinstance(sec_code, str) or sec_code.strip() == "":
+        return None
+    return as_decimal(ctx.latest_prices.get(sec_code.strip()))
+
+
+def _rows_by_note(rows: object) -> dict[str, list[Mapping[str, Any]]]:
+    grouped: dict[str, list[Mapping[str, Any]]] = {}
+    source = rows if isinstance(rows, list) else list(rows)  # type: ignore[arg-type]
+    for row in source:
+        if not isinstance(row, Mapping):
+            continue
+        note_no = note_no_of(row)
+        if note_no is None:
+            continue
+        grouped.setdefault(note_no, []).append(row)
+    return grouped

+ 137 - 0
code/post_clear_check/src/post_clear_check/issue_reports.py

@@ -0,0 +1,137 @@
+"""发行端报表规则。
+
+人口是该营业日的发行报表行,以及能对上这些行的主单、票据和交收台账。
+输出结论行。不发飞书,不写库。
+结论行在这里按调用的核对函数挂链,OTC003 按主订单方向分到买入执行或卖出执行。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Mapping
+from datetime import date, datetime
+from typing import Any
+
+from post_clear_check.chain import (
+    CHAIN_CASH_SETTLEMENT,
+    CHAIN_EXTENSION,
+    CHAIN_NOTE_HOLDING,
+    CHAIN_TOPUP,
+    SIDE_ISSUE,
+    hang,
+    hang_by_side,
+)
+from post_clear_check.issue_common import IssueContext, mappings
+from post_clear_check.issue_event import check_extension, check_topup
+from post_clear_check.note_chain import check_note_holding, stage_kwargs
+from post_clear_check.issue_otc003 import check_otc003
+from post_clear_check.issue_otc004 import check_otc004, check_otc005
+from post_clear_check.fund_chain import check_issue_settlement
+from post_clear_check.issue_settle import check_settlement
+from post_clear_check.order_cell import CheckLine, parse_logs
+
+
+def check_issue_reports(data: Mapping[str, Any]) -> list[CheckLine]:
+    """核对一轮发行报表。data 里没有的业务不产生结论行。"""
+
+    ctx = build_context(data)
+    lines: list[CheckLine] = []
+    lines.extend(hang_by_side(_run(check_otc003, ctx), ctx.orders, SIDE_ISSUE))
+    lines.extend(hang(_run(check_otc004, ctx), CHAIN_NOTE_HOLDING, SIDE_ISSUE))
+    lines.extend(hang(_run(check_otc005, ctx), CHAIN_NOTE_HOLDING, SIDE_ISSUE))
+    lines.extend(hang(_run(check_note_holding, ctx), CHAIN_NOTE_HOLDING, SIDE_ISSUE))
+    lines.extend(hang(_run(check_settlement, ctx), CHAIN_CASH_SETTLEMENT, SIDE_ISSUE))
+    lines.extend(hang(_run(check_issue_settlement, ctx), CHAIN_CASH_SETTLEMENT, SIDE_ISSUE))
+    lines.extend(hang(_run(check_topup, ctx), CHAIN_TOPUP, SIDE_ISSUE))
+    lines.extend(hang(_run(check_extension, ctx), CHAIN_EXTENSION, SIDE_ISSUE))
+    return lines
+
+
+def _run(check: Callable[[IssueContext, list[CheckLine]], None], ctx: IssueContext) -> list[CheckLine]:
+    """调一个核对函数,只收它这一次的产出,便于按链路打标签。"""
+
+    produced: list[CheckLine] = []
+    check(ctx, produced)
+    return produced
+
+
+def build_context(data: Mapping[str, Any]) -> IssueContext:
+    """把调用方的字典收成只读上下文。业务日期必须给出。"""
+
+    if not isinstance(data, Mapping):
+        raise TypeError("data 必须是字典")
+    orders = data.get("orders") or {}
+    if not isinstance(orders, Mapping):
+        raise TypeError("orders 必须是字典")
+    sources = data.get("sources") or {}
+    if not isinstance(sources, Mapping):
+        raise TypeError("sources 必须是字典")
+    latest = data.get("latest_prices") or {}
+    if not isinstance(latest, Mapping):
+        raise TypeError("latest_prices 必须是字典")
+    loose = data.get("loose_reports") or {}
+    if not isinstance(loose, Mapping):
+        raise TypeError("loose_reports 必须是字典")
+    logs: list[Mapping[str, Any]] = []
+    for bucket in orders.values():
+        if isinstance(bucket, Mapping):
+            logs.extend(mappings(bucket.get("clearing_writeback_log")))
+    return IssueContext(
+        business_date=_business_date(data.get("business_date")),
+        orders=orders,
+        sources={str(key): str(value) for key, value in sources.items()},
+        latest_prices=latest,
+        loose=loose,
+        logs=tuple(parse_logs(logs)),
+        topup_records=_collect(data, orders, "topup_records"),
+        extension_tasks=_collect(data, orders, "extension_tasks"),
+        extension_ledgers=_collect(data, orders, "extension_ledgers"),
+        realtime_notes=tuple(mappings(data.get("realtime_notes"))),
+        realtime_rows=tuple(mappings(data.get("realtime_rows"))),
+        note_orders=_note_orders(data.get("note_orders")),
+        note_copies=tuple(mappings(data.get("note_copies"))),
+        **stage_kwargs(data),
+    )
+
+
+def _note_orders(value: object) -> Mapping[str, Any]:
+    """取数补读的票据关联订单。没有给出时为空,已卖出合计只看 orders。"""
+
+    if value is None:
+        return {}
+    if not isinstance(value, Mapping):
+        raise TypeError("note_orders 必须是字典")
+    return value
+
+
+def _business_date(value: object) -> date:
+    if isinstance(value, datetime):
+        return value.date()
+    if isinstance(value, date):
+        return value
+    raise TypeError("business_date 必须是日期")
+
+
+def _collect(
+    data: Mapping[str, Any],
+    orders: Mapping[str, Any],
+    key: str,
+) -> tuple[Mapping[str, Any], ...]:
+    """顶层和主单上的来源行合并。同一个主键只留一次。"""
+
+    rows = mappings(data.get(key))
+    for bucket in orders.values():
+        if isinstance(bucket, Mapping):
+            rows.extend(mappings(bucket.get(key)))
+    seen: set[str] = set()
+    unique: list[Mapping[str, Any]] = []
+    for row in rows:
+        ident = row.get("id")
+        if ident is None:
+            unique.append(row)
+            continue
+        token = str(ident)
+        if token in seen:
+            continue
+        seen.add(token)
+        unique.append(row)
+    return tuple(unique)

+ 481 - 0
code/post_clear_check/src/post_clear_check/issue_settle.py

@@ -0,0 +1,481 @@
+"""发行端 OTC007 票据交易交收报表与 OTC008 票据交易未交收报表。
+
+两张表都没有金额同行公式。同一主订单编号只能落在其中一张。
+落在哪一张只看交收台账的实际交收日和营业日,不用订单状态已结算。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from datetime import date
+from typing import Any
+
+from post_clear_check.issue_common import (
+    OTC007_NAME,
+    OTC008_NAME,
+    T_OTC007,
+    T_OTC008,
+    IssueContext,
+    as_date,
+    emit,
+    emit_amount,
+    first_fund,
+    fund_no_of,
+    mappings,
+    on_business_date,
+    present_number,
+    settlement_row_fits,
+    settlement_row_owners,
+    side_of_row,
+)
+from post_clear_check.order_cell import CheckLine, own_fund, read_cell
+from post_clear_check.order_decimal import (
+    SCALE_AMT,
+    VERDICT_CORRECT,
+    VERDICT_ERROR,
+    VERDICT_SUSPECT,
+    format_decimal,
+)
+
+_EXISTENCE = "同一主订单编号只出现在 OTC007 票据交易交收报表与 OTC008 票据交易未交收报表之一"
+_BASIS = "按实际交收日与营业日的先后决定落表;订单状态已结算不参与判断"
+
+# 台账没有这些列名,用子单执行列。
+_AMOUNT_FIELDS: tuple[tuple[str, str, str], ...] = (
+    ("名义本金(交易币种)", "notional_trade", "exec_notional_trade"),
+    ("票据面值(结算币种)", "face_value_settle", "exec_face_value_settle"),
+    ("发行费用(结算币种)", "issue_fee_settle", "exec_issue_fee_settle"),
+)
+# 卖出的执行票据面值恒为 0,报表面值等于卖出票据面值。
+_SELL_FACE_FIELD = ("票据面值(结算币种)", "face_value_settle", "sold_face_value")
+# 子单执行总金额只在卖出时写入,买入恒为 0,只有卖出单才拿它对总金额。
+_SELL_TOTAL_FIELD = ("总金额(结算币种)", "total_amt_settle", "exec_total_amt_settle")
+# 原型 OTC007、OTC008「总金额(结算币种)」:买入为成交金额 + 发行费用。
+_BUY_TOTAL = "买入总金额 = 成交金额(结算币种) + 发行费用(结算币种)"
+
+
+def check_settlement(ctx: IssueContext, lines: list[CheckLine]) -> None:
+    seen: set[str] = set()
+    # 已经对上某张主单的报表行。挂了桶却不属于任何一张主单的行,最后按无法对应主订单记可疑,不静默丢掉。
+    fitted: set[str] = set()
+    # 同一票据多张订单时,报表行按名义本金、面值、总金额认领到唯一的一张。
+    owners = settlement_row_owners(ctx.orders, (T_OTC007, T_OTC008), ctx.business_date)
+    for order_no in sorted(ctx.orders):
+        bucket = ctx.orders[order_no]
+        ledgers = [
+            row
+            for row in mappings(bucket.get("settlement_ledger"))
+            if on_business_date(row, ctx.business_date)
+        ]
+        settled = _dated_reports(bucket, T_OTC007, ctx.business_date, owners.get(order_no))
+        unsettled = _dated_reports(bucket, T_OTC008, ctx.business_date, owners.get(order_no))
+        fitted.update(_row_keys(settled) | _row_keys(unsettled))
+        label = _order_label(order_no, bucket)
+        if label in seen:
+            continue
+        if not ledgers and not settled and not unsettled:
+            continue
+        seen.add(label)
+        produced: list[CheckLine] = []
+        # 资金交收归资金账户:报表行的 fund_account_no 优先,没有报表行时取主单上的资金账户。
+        master = bucket.get("master") if isinstance(bucket.get("master"), Mapping) else None
+        fund_no = first_fund([*settled, *unsettled, master])
+        if not ledgers:
+            emit(
+                produced,
+                formula=_EXISTENCE,
+                basis=_BASIS,
+                calc_text="交收台账无行",
+                calc_value=None,
+                check_text=_count_text(label, len(settled), len(unsettled)),
+                check_value=None,
+                verdict=VERDICT_SUSPECT,
+            )
+            own_fund(lines, produced, fund_no)
+            continue
+        expected = _expected_side(ledgers, ctx.business_date)
+        verdict, calc_text = _existence_verdict(expected, len(settled), len(unsettled), ctx.business_date)
+        emit(
+            produced,
+            formula=_EXISTENCE,
+            basis=_BASIS,
+            calc_text=calc_text,
+            calc_value=None,
+            check_text=_count_text(label, len(settled), len(unsettled)),
+            check_value=None,
+            verdict=verdict,
+        )
+        _amounts_on_present_side(ctx, produced, bucket, settled, unsettled, label)
+        own_fund(lines, produced, fund_no)
+    _loose(ctx, lines)
+    _unfitted(ctx, lines, fitted)
+
+
+def _row_keys(rows: list[Mapping[str, Any]]) -> set[str]:
+    return {f"id:{row.get('id')}" if row.get("id") is not None else f"obj:{id(row)}" for row in rows}
+
+
+def _unfitted(ctx: IssueContext, lines: list[CheckLine], fitted: set[str]) -> None:
+    """按票据号挂到桶上、但业务类型或交易日与桶里每张主单都对不上的行。"""
+
+    emitted: set[str] = set()
+    for table, name in ((T_OTC007, OTC007_NAME), (T_OTC008, OTC008_NAME)):
+        for order_no in sorted(ctx.orders):
+            reports = ctx.orders[order_no].get("reports")
+            if not isinstance(reports, Mapping):
+                continue
+            for row in mappings(reports.get(table)):
+                if not on_business_date(row, ctx.business_date):
+                    continue
+                key = next(iter(_row_keys([row])))
+                if key in fitted or key in emitted:
+                    continue
+                emitted.add(key)
+                produced: list[CheckLine] = []
+                emit(
+                    produced,
+                    formula=_EXISTENCE,
+                    basis=_BASIS,
+                    calc_text="业务类型或交易日与同一票据上的主订单都对不上,无法对应主订单编号",
+                    calc_value=None,
+                    check_text=f"{name} 有行但无法对应主订单编号",
+                    check_value=None,
+                    verdict=VERDICT_SUSPECT,
+                )
+                own_fund(lines, produced, fund_no_of(row))
+
+
+def _amounts_on_present_side(
+    ctx: IssueContext,
+    lines: list[CheckLine],
+    bucket: Mapping[str, Any],
+    settled: list[Mapping[str, Any]],
+    unsettled: list[Mapping[str, Any]],
+    order_no: str,
+) -> None:
+    """两张都有行时只保留存在性错误。只有一张有行时核那一张的金额和实际交收日。"""
+
+    if settled and unsettled:
+        return
+    if settled:
+        report_name = OTC007_NAME
+        rows = settled
+        empty_date = False
+    elif unsettled:
+        report_name = OTC008_NAME
+        rows = unsettled
+        empty_date = True
+    else:
+        return
+    subs = mappings(bucket.get("order_sub_a"))
+    ledgers = mappings(bucket.get("settlement_ledger"))
+    sub, sub_text = _only_sub(subs)
+    master = bucket.get("master") if isinstance(bucket.get("master"), Mapping) else None
+    side = side_of_row(sub or {}, master)
+    for row in rows:
+        _amount_lines(ctx, lines, report_name, row, order_no, sub, sub_text, side)
+        _date_line(lines, report_name, row, order_no, ledgers, empty_date)
+
+
+def _amount_lines(
+    ctx: IssueContext,
+    lines: list[CheckLine],
+    report_name: str,
+    row: Mapping[str, Any],
+    order_no: str,
+    sub: Mapping[str, Any] | None,
+    sub_text: str | None,
+    side: str | None,
+) -> None:
+    fields = _AMOUNT_FIELDS + ((_SELL_TOTAL_FIELD,) if side == "sell" else ())
+    if side == "sell":
+        # 执行票据面值只在买入时写入。卖出改用卖出票据面值,不再拿恒为 0 的执行面值去比。
+        fields = tuple(item for item in fields if item[2] != "exec_face_value_settle") + (_SELL_FACE_FIELD,)
+    if side == "buy":
+        _buy_total(lines, report_name, row, order_no)
+    for column, report_field, source_field in fields:
+        formula = f"{column} = 子单{ _source_name(source_field) }"
+        if sub is None:
+            emit_amount(
+                lines,
+                formula=formula,
+                basis="台账没有同名金额,用子单执行列",
+                calc_text=sub_text or "子单缺失",
+                calc_value=None,
+                report_name=report_name,
+                column=column,
+                row=row,
+                field=report_field,
+                order_no=order_no,
+                scale=SCALE_AMT,
+            )
+            continue
+        cell = read_cell(sub, source_field, "order_sub_a", ctx.logs, ctx.sources)
+        if cell.conflict or cell.absent or cell.number is None:
+            reason = "回写 after_val 不一致" if cell.conflict else f"{_source_name(source_field)}缺失"
+            emit_amount(
+                lines,
+                formula=formula,
+                basis="台账没有同名金额,用子单执行列",
+                calc_text=reason,
+                calc_value=None,
+                report_name=report_name,
+                column=column,
+                row=row,
+                field=report_field,
+                order_no=order_no,
+                scale=SCALE_AMT,
+                live=cell.live,
+                force=VERDICT_SUSPECT if cell.conflict else None,
+            )
+            continue
+        emit_amount(
+            lines,
+            formula=formula,
+            basis="台账没有同名金额,用子单执行列",
+            calc_text=format_decimal(cell.number, SCALE_AMT),
+            calc_value=cell.number,
+            report_name=report_name,
+            column=column,
+            row=row,
+            field=report_field,
+            order_no=order_no,
+            scale=SCALE_AMT,
+            live=cell.live,
+        )
+
+
+def _buy_total(lines: list[CheckLine], report_name: str, row: Mapping[str, Any], order_no: str) -> None:
+    """买入总金额按同一行的成交金额与发行费用复算。任一列缺失时可疑,不用 0 代入。"""
+
+    deal_ok, deal = present_number(row, "deal_amt_settle")
+    fee_ok, fee = present_number(row, "issue_fee_settle")
+    if not deal_ok or deal is None or not fee_ok or fee is None:
+        emit_amount(
+            lines,
+            formula=_BUY_TOTAL,
+            basis=_BUY_TOTAL,
+            calc_text="成交金额(结算币种)或发行费用(结算币种)缺失",
+            calc_value=None,
+            report_name=report_name,
+            column="总金额(结算币种)",
+            row=row,
+            field="total_amt_settle",
+            order_no=order_no,
+            scale=SCALE_AMT,
+            force=VERDICT_SUSPECT,
+        )
+        return
+    calc = deal + fee
+    emit_amount(
+        lines,
+        formula=_BUY_TOTAL,
+        basis=_BUY_TOTAL,
+        calc_text=f"{format_decimal(deal, SCALE_AMT)} + {format_decimal(fee, SCALE_AMT)} = {format_decimal(calc, SCALE_AMT)}",
+        calc_value=calc,
+        report_name=report_name,
+        column="总金额(结算币种)",
+        row=row,
+        field="total_amt_settle",
+        order_no=order_no,
+        scale=SCALE_AMT,
+    )
+
+
+def _date_line(
+    lines: list[CheckLine],
+    report_name: str,
+    row: Mapping[str, Any],
+    order_no: str,
+    ledgers: list[Mapping[str, Any]],
+    empty_date: bool,
+) -> None:
+    actuals = {_date_text(ledger.get("actual_settle_date")) for ledger in ledgers}
+    suffix = f"(主订单编号 {order_no})"
+    if empty_date:
+        formula = "OTC008 票据交易未交收报表的实际交收日为空"
+        filled = _has_date(row.get("actual_settle_date"))
+        emit(
+            lines,
+            formula=formula,
+            basis=_BASIS,
+            calc_text="实际交收日为空",
+            calc_value=None,
+            check_text=(
+                f"{report_name}.实际交收日 = {_show_date(row.get('actual_settle_date'))}{suffix}"
+                if filled
+                else f"{report_name}.实际交收日为空{suffix}"
+            ),
+            check_value=None,
+            verdict=VERDICT_ERROR if filled else VERDICT_CORRECT,
+        )
+        return
+    formula = "实际交收日 = 交收台账的实际交收日"
+    if len(actuals) != 1:
+        emit(
+            lines,
+            formula=formula,
+            basis=_BASIS,
+            calc_text="实际交收日不唯一",
+            calc_value=None,
+            check_text=f"{report_name}.实际交收日{suffix}",
+            check_value=None,
+            verdict=VERDICT_SUSPECT,
+        )
+        return
+    expected = next(iter(actuals))
+    found = _date_text(row.get("actual_settle_date"))
+    emit(
+        lines,
+        formula=formula,
+        basis=_BASIS,
+        calc_text=f"交收台账的实际交收日 = {expected}",
+        calc_value=None,
+        check_text=f"{report_name}.实际交收日 = {found}{suffix}",
+        check_value=None,
+        verdict=VERDICT_CORRECT if found == expected else VERDICT_ERROR,
+    )
+
+
+def _loose(ctx: IssueContext, lines: list[CheckLine]) -> None:
+    for table, name in ((T_OTC007, OTC007_NAME), (T_OTC008, OTC008_NAME)):
+        for row in mappings(ctx.loose.get(table)):
+            if not on_business_date(row, ctx.business_date):
+                continue
+            produced: list[CheckLine] = []
+            emit(
+                produced,
+                formula=_EXISTENCE,
+                basis=_BASIS,
+                calc_text="无法对应主订单编号",
+                calc_value=None,
+                check_text=f"{name} 有行但无法对应主订单编号",
+                check_value=None,
+                verdict=VERDICT_SUSPECT,
+            )
+            own_fund(lines, produced, fund_no_of(row))
+
+
+def _existence_verdict(
+    expected: str,
+    settled_count: int,
+    unsettled_count: int,
+    business_date: date,
+) -> tuple[str, str]:
+    both = settled_count > 0 and unsettled_count > 0
+    neither = settled_count == 0 and unsettled_count == 0
+    if expected == "split":
+        return VERDICT_ERROR, "同一主订单的实际交收日指向两张表"
+    if both:
+        return VERDICT_ERROR, "两张表都有该主订单编号"
+    if neither:
+        return VERDICT_ERROR, "交收台账有行,两张表都没有该主订单编号"
+    actual = "OTC007" if settled_count else "OTC008"
+    name = OTC007_NAME if expected == "OTC007" else OTC008_NAME
+    if actual != expected:
+        return VERDICT_ERROR, f"应落在{name},实际落在另一张"
+    if expected == "OTC007":
+        return VERDICT_CORRECT, f"实际交收日不晚于营业日 {business_date.isoformat()},应落在{name}"
+    return VERDICT_CORRECT, f"实际交收日晚于营业日或为空,应落在{name}"
+
+
+def _expected_side(ledgers: list[Mapping[str, Any]], business_date: date) -> str:
+    sides: set[str] = set()
+    for ledger in ledgers:
+        actual = as_date(ledger.get("actual_settle_date"))
+        if actual is None or actual > business_date:
+            sides.add("OTC008")
+        else:
+            sides.add("OTC007")
+    if len(sides) == 1:
+        return next(iter(sides))
+    return "split"
+
+
+def _dated_reports(
+    bucket: Mapping[str, Any],
+    table: str,
+    business_date: date,
+    owned: set[str] | None = None,
+) -> list[Mapping[str, Any]]:
+    """本营业日、且业务类型和交易日属于这张主单的行。同一票据的另一张订单的行不算进来。
+
+    owned 不为空时,只留认领给这张订单的行。空集合表示这张订单一张都没认领到。
+    """
+
+    reports = bucket.get("reports")
+    if not isinstance(reports, Mapping):
+        return []
+    master = bucket.get("master") if isinstance(bucket.get("master"), Mapping) else None
+    rows = [
+        row
+        for row in mappings(reports.get(table))
+        if on_business_date(row, business_date) and settlement_row_fits(row, master)
+    ]
+    if owned is None:
+        return rows
+    return [row for row in rows if _owned_key(table, row) in owned]
+
+
+def _owned_key(table: str, row: Mapping[str, Any]) -> str:
+    ident = row.get("id")
+    mark = f"obj:{id(row)}" if ident is None else f"id:{ident}"
+    return f"{table}:{mark}"
+
+
+def _order_label(order_no: str, bucket: Mapping[str, Any]) -> str:
+    master = bucket.get("master")
+    if isinstance(master, Mapping):
+        text = master.get("order_no")
+        if isinstance(text, str) and text.strip():
+            return text.strip()
+    return order_no
+
+
+def _count_text(order_no: str, settled_count: int, unsettled_count: int) -> str:
+    return (
+        f"{OTC007_NAME} 行数 = {settled_count};{OTC008_NAME} 行数 = {unsettled_count}"
+        f"(主订单编号 {order_no})"
+    )
+
+
+def _only_sub(subs: list[Mapping[str, Any]]) -> tuple[Mapping[str, Any] | None, str | None]:
+    if len(subs) == 1:
+        return subs[0], None
+    if not subs:
+        return None, "子单缺失"
+    return None, "无法对应到唯一子单"
+
+
+def _source_name(field: str) -> str:
+    names = {
+        "exec_notional_trade": "执行订单名义本金(交易币种)",
+        "exec_face_value_settle": "执行票据面值",
+        "sold_face_value": "卖出票据面值",
+        "exec_issue_fee_settle": "执行发行费用",
+        "exec_total_amt_settle": "执行总金额",
+    }
+    return names[field]
+
+
+def _has_date(value: object) -> bool:
+    if value is None:
+        return False
+    if isinstance(value, str) and value.strip() == "":
+        return False
+    return True
+
+
+def _show_date(value: object) -> str:
+    parsed = as_date(value)
+    if parsed is not None:
+        return parsed.isoformat()
+    if isinstance(value, str):
+        return value.strip()
+    return str(value)
+
+
+def _date_text(value: object) -> str:
+    if not _has_date(value):
+        return "空"
+    return _show_date(value)

+ 73 - 0
code/post_clear_check/src/post_clear_check/main.py

@@ -0,0 +1,73 @@
+"""进程入口。检查只读会话后做一轮核对并退出。"""
+
+from __future__ import annotations
+
+import asyncio
+from typing import Any
+
+from loguru import logger
+
+from post_clear_check.config import ConfigError, Settings, describe_exception, load_settings, secret_values
+from post_clear_check.orchestrate import run_once
+from post_clear_check.progress import log_sink
+from post_clear_check.session import (
+    BusinessGateway,
+    SessionNotReadOnly,
+    close_connection,
+    connect_business,
+    connect_own,
+)
+
+
+def configure_logging() -> None:
+    """日志带模块、函数和行号。关闭变量展开,避免异常栈带出口令。
+
+    输出口仍是标准错误,经进度模块转写:终端里有进度状态行时,先擦掉再写日志。
+    """
+
+    logger.remove()
+    logger.add(
+        log_sink,
+        level="INFO",
+        format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level} | {name}:{function}:{line} | {message}",
+        backtrace=False,
+        diagnose=False,
+    )
+
+
+def main() -> None:
+    """启动进程。只读检查通过后做一轮核对,然后退出。"""
+
+    configure_logging()
+    raise SystemExit(asyncio.run(async_main()))
+
+
+async def async_main() -> int:
+    """建立两条连接,核对业务会话只读,再跑一轮。不在进程内轮询。"""
+
+    business: Any = None
+    own: Any = None
+    settings: Settings | None = None
+    try:
+        settings = load_settings()
+        business = await connect_business(settings)
+        own = await connect_own(settings)
+        gateway = BusinessGateway(business)
+        await gateway.enforce_read_only()
+        await run_once(gateway, own, settings)
+        logger.info("本轮核对已结束")
+        return 0
+    except SessionNotReadOnly:
+        logger.error("业务连接不是只读会话,退出")
+        return 1
+    except ConfigError as exc:
+        # ConfigError 的文本在构造时已经去掉口令。
+        logger.error("{}", exc)
+        return 1
+    except Exception as exc:
+        secrets = secret_values(settings) if settings is not None else ()
+        logger.error("启动失败,退出:{}", describe_exception(exc, *secrets))
+        return 1
+    finally:
+        await close_connection(business)
+        await close_connection(own)

+ 871 - 0
code/post_clear_check/src/post_clear_check/note_chain.py

@@ -0,0 +1,871 @@
+"""票据存续、增额、展期的步骤节点。
+
+T 日只读本批发行 input 和 cleared,以及同批 work/2、work/3、work/4。
+实时表只用于第 5 步当前投影、昨基线和销售第 6 步版本比对,不当期初。
+不核 OTC009、OTC012 落库表,不重算买卖执行公式。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from datetime import date, datetime
+from decimal import Decimal
+from typing import Any
+
+from post_clear_check.issue_common import (
+    as_date,
+    fund_no_of,
+    is_sell_master,
+    mappings,
+)
+from post_clear_check.order_cell import CheckLine, own_fund
+from post_clear_check.order_decimal import (
+    SCALE_AMT,
+    SCALE_QTY,
+    VERDICT_CORRECT,
+    VERDICT_ERROR,
+    as_decimal,
+    format_decimal,
+    quantize,
+)
+
+SCOPE_INPUT = "input/0"
+SCOPE_WORK2 = "work/2"
+SCOPE_WORK3 = "work/3"
+SCOPE_WORK4 = "work/4"
+SCOPE_CLEARED = "cleared/0"
+
+OPENING = "期初结转"
+CLEARED_MATCH = "T日票据结果"
+PROJECTION = "当前投影"
+YESTERDAY = "昨基线"
+SALES_CURRENT = "销售当前票据"
+TOPUP_SET = "增额冻结集合"
+TOPUP_SUM = "增额累计"
+EXTENSION_SET = "展期冻结集合"
+EXTENSION_DATE = "展期后到期日"
+
+FORMULA_OPENING = (
+    f"{OPENING} = work/2 的当前数量、面值、增额、分红等于上一已发布 cleared 的结转;"
+    "新票据等于 input 的初始列且增额分红为 0"
+)
+FORMULA_CLEARED = f"{CLEARED_MATCH} = cleared/0 的数量、面值、增额、分红、到期日、状态与 work/4 一致"
+FORMULA_PROJECTION = (
+    f"{PROJECTION} = 实时票据等于 cleared 加上归属晚于本营业日的增额与展期;"
+    "没有后续卖出时数量和面值等于 cleared"
+)
+FORMULA_YESTERDAY = f"{YESTERDAY} = 实时对冲持仓的 yesterday 列等于本批 cleared 持仓的日终列"
+FORMULA_SALES = (
+    f"{SALES_CURRENT} = note_view 按发行当前同步版本写入,低于该版本必须覆盖;"
+    "当前值不得写入 clr_note_view_cleared"
+)
+FORMULA_TOPUP_SET = f"{TOPUP_SET} = input 中归属本营业日的增额主键集合等于实时同日增额主键"
+FORMULA_TOPUP_SUM = (
+    f"{TOPUP_SUM} = work/3 增额等于 work/2 增额加上 input 中本营业日增额发生额,"
+    "且 work/4 与 cleared 保持该增额"
+)
+FORMULA_EXTENSION_SET = f"{EXTENSION_SET} = input 中审批归属本营业日的已通过展期主键集合等于实时同日主键"
+FORMULA_EXTENSION_DATE = (
+    f"{EXTENSION_DATE} = work/3 到期日等于这些展期的审批后到期日;没有则保持 work/2;"
+    "work/4 与 cleared 保持该到期日"
+)
+
+_STEP_ISSUE = "研发设计 docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md 「5.1 发行端(6 步)」"
+_STEP_SALES = "研发设计 docs/tmp/data_check_tool/doc/清算步骤核对改造方案.md 「5.2 销售端(7 步)」"
+
+# 结转要比的当前列。新票据的当前列改对初始列。
+_CARRY_AMOUNTS = (
+    ("cur_notional_trade", "存续名义本金", SCALE_AMT),
+    ("cur_qty", "当前股票数量", SCALE_QTY),
+    ("cur_face_value", "当前票据面值", SCALE_AMT),
+    ("add_amt", "增额金额", SCALE_AMT),
+    ("dividend_amt", "分红金额", SCALE_AMT),
+)
+_NEW_FROM_INIT = (
+    ("cur_notional_trade", "init_notional_trade", "存续名义本金", SCALE_AMT),
+    ("cur_qty", "init_qty", "当前股票数量", SCALE_QTY),
+    ("cur_face_value", "init_face_value", "当前票据面值", SCALE_AMT),
+)
+_YESTERDAY_FROM = (
+    ("yesterday_position_qty", "position_qty", "昨日持仓股数", 0),
+    ("yesterday_cost_settle", "position_cost_settle", "昨日持仓成本", SCALE_AMT),
+    ("yesterday_unsettled_realized_pnl", "unsettled_realized_pnl", "昨日未结算实现盈亏", SCALE_AMT),
+    ("yesterday_settled_realized_pnl", "settled_realized_pnl", "昨日已结算实现盈亏", SCALE_AMT),
+    ("yesterday_realized_dividend", "realized_dividend", "昨日已实现分红", SCALE_AMT),
+)
+_VIEW_AMOUNTS = (
+    ("cur_notional_trade", "存续名义本金", SCALE_AMT),
+    ("cur_qty", "当前股票数量", SCALE_QTY),
+    ("cur_face_value", "当前票据面值", SCALE_AMT),
+    ("add_amt", "增额金额", SCALE_AMT),
+    ("dividend_amt", "分红金额", SCALE_AMT),
+)
+
+
+def stage_has_note(stages: Mapping[str, Any], note_no: str | None) -> bool:
+    """这张票据已经在本批步骤快照里。有快照时不再把实时和切片的差额记成切片错误。"""
+
+    if not note_no or not stages:
+        return False
+    for scope in (SCOPE_WORK2, SCOPE_WORK3, SCOPE_WORK4, SCOPE_CLEARED):
+        for row in _table(stages, scope, "note_sub_a"):
+            if row.get("note_no") == note_no or row.get("sub_note_no") == note_no:
+                return True
+    return False
+
+
+def check_note_holding(ctx: Any, lines: list[CheckLine]) -> None:
+    """票据存续的第 2、5 步。第 3 步的报表仍由 OTC004 对 cleared。"""
+
+    stages = _stages(ctx)
+    if not stages:
+        return
+    produced: list[tuple[str | None, CheckLine]] = []
+    seen: set[int] = set()
+    for scope in (SCOPE_WORK2, SCOPE_CLEARED):
+        for sub in _table(stages, scope, "note_sub_a"):
+            ident = _ident(sub.get("id"))
+            if ident is None or ident in seen:
+                continue
+            seen.add(ident)
+            _opening(ctx, stages, ident, produced)
+            _cleared_match(ctx, stages, ident, produced)
+            if getattr(ctx, "live_notes_loaded", False):
+                _projection(ctx, stages, ident, produced)
+    if getattr(ctx, "live_notes_loaded", False):
+        _yesterday(ctx, stages, produced)
+    _flush(lines, produced)
+
+
+def check_topup(ctx: Any, lines: list[CheckLine]) -> None:
+    """增额的冻结主键集合,以及发生额记入 work/3。"""
+
+    stages = _stages(ctx)
+    if not stages or not _facts_ready(stages, "note_topup_log"):
+        return
+    day = ctx.business_date
+    frozen = [row for row in _table(stages, SCOPE_INPUT, "note_topup_log") if _is_topup(row)]
+    live = [row for row in _seq(getattr(ctx, "live_topups", ())) if _is_topup(row)]
+    produced: list[tuple[str | None, CheckLine]] = []
+    note_nos = _note_numbers(frozen, live)
+    for note_no in note_nos:
+        fund = _fund_for_note(stages, note_no)
+        frozen_day = [row for row in frozen if row.get("note_no") == note_no and as_date(row.get("business_date")) == day]
+        live_day = [row for row in live if row.get("note_no") == note_no and as_date(row.get("business_date")) == day]
+        wrong_day = [row for row in frozen if row.get("note_no") == note_no and as_date(row.get("business_date")) != day]
+        problems = _id_gap("冻结", "实时", frozen_day, live_day)
+        for row in wrong_day:
+            problems.append(f"主键 {_ident(row.get('id'))} 的归属日不是本营业日")
+        _emit(produced, fund, FORMULA_TOPUP_SET, _STEP_ISSUE, problems, f"票据 {note_no} 增额主键")
+        _topup_sum(stages, note_no, frozen_day, fund, produced)
+    _flush(lines, produced)
+
+
+def check_extension(ctx: Any, lines: list[CheckLine]) -> None:
+    """展期的冻结主键集合,以及 work/3 的到期日。"""
+
+    stages = _stages(ctx)
+    if not stages or not _facts_ready(stages, "extension_task"):
+        return
+    day = ctx.business_date
+    frozen = [row for row in _table(stages, SCOPE_INPUT, "extension_task") if _is_approved(row)]
+    live = [row for row in _seq(getattr(ctx, "live_extensions", ())) if _is_approved(row)]
+    produced: list[tuple[str | None, CheckLine]] = []
+    for note_no in _note_numbers(frozen, live):
+        fund = _fund_for_note(stages, note_no) or _fund_on(frozen + live, note_no)
+        frozen_day = [row for row in frozen if row.get("note_no") == note_no and as_date(row.get("approve_business_date")) == day]
+        live_day = [row for row in live if row.get("note_no") == note_no and as_date(row.get("approve_business_date")) == day]
+        wrong_day = [
+            row for row in frozen
+            if row.get("note_no") == note_no and as_date(row.get("approve_business_date")) != day
+        ]
+        problems = _id_gap("冻结", "实时", frozen_day, live_day)
+        for row in wrong_day:
+            problems.append(f"主键 {_ident(row.get('id'))} 的审批归属日不是本营业日")
+        _emit(produced, fund, FORMULA_EXTENSION_SET, _STEP_ISSUE, problems, f"票据 {note_no} 展期主键")
+        _extension_date(stages, note_no, frozen_day, fund, produced)
+    _flush(lines, produced)
+
+
+def check_sales_note_view(ctx: Any, lines: list[CheckLine]) -> None:
+    """销售第 6 步:当前票据按发行版本写入,候选里不得是当前投影。"""
+
+    stages = _stages(ctx)
+    if not stages or not getattr(ctx, "sales_note_loaded", False):
+        return
+    produced: list[tuple[str | None, CheckLine]] = []
+    current = {_text(row.get("sub_note_no")): row for row in _seq(getattr(ctx, "sales_current_notes", ()))}
+    cleared_view = {_text(row.get("sub_note_no")): row for row in _seq(getattr(ctx, "sales_cleared_notes", ()))}
+    live_by_sub = {_text(row.get("sub_note_no")): row for row in _seq(getattr(ctx, "live_note_subs", ()))}
+    for sub in _table(stages, SCOPE_CLEARED, "note_sub_a"):
+        sub_no = _text(sub.get("sub_note_no"))
+        note_no = _text(sub.get("note_no")) or sub_no or "-"
+        if sub_no is None:
+            continue
+        fund = fund_no_of(sub)
+        problems: list[str] = []
+        view = cleared_view.get(sub_no)
+        if view is None:
+            problems.append("clr_note_view_cleared 没有该子票据")
+        else:
+            problems.extend(_amount_gaps(view, sub, _VIEW_AMOUNTS, "候选", "发行 cleared"))
+            problems.extend(_text_gap(view, sub, "note_status", "状态", "候选", "发行 cleared"))
+            master = _master(_table(stages, SCOPE_CLEARED, "note_master"), sub)
+            if master is not None:
+                problems.extend(_date_gap(view, master, "maturity_date", "到期日", "候选", "发行 cleared"))
+            live_for_mirror = live_by_sub.get(sub_no)
+            if live_for_mirror is not None and _amount_changed(live_for_mirror, sub):
+                mirror_add = as_decimal(view.get("add_amt"))
+                live_add = as_decimal(live_for_mirror.get("add_amt"))
+                cleared_add = as_decimal(sub.get("add_amt"))
+                if (
+                    mirror_add is not None
+                    and live_add is not None
+                    and cleared_add is not None
+                    and quantize(mirror_add, SCALE_AMT) == quantize(live_add, SCALE_AMT)
+                    and quantize(live_add, SCALE_AMT) != quantize(cleared_add, SCALE_AMT)
+                ):
+                    problems.append("clr_note_view_cleared 写成了当前投影")
+        live = live_by_sub.get(sub_no)
+        sales = current.get(sub_no)
+        if sales is None:
+            problems.append("note_view 没有该子票据")
+        elif live is None:
+            problems.append("发行实时票据缺失,无法比对同步版本")
+        else:
+            problems.extend(_version_gaps(sales, live))
+        _emit(produced, fund, FORMULA_SALES, _STEP_SALES, problems, f"票据 {note_no} 子票据 {sub_no}")
+    _flush(lines, produced)
+
+
+def _opening(ctx: Any, stages: Mapping[str, Any], ident: int, produced: list[tuple[str | None, CheckLine]]) -> None:
+    work = _by_id(_table(stages, SCOPE_WORK2, "note_sub_a")).get(ident)
+    frozen = _by_id(_table(stages, SCOPE_INPUT, "note_sub_a")).get(ident)
+    label = _sub_label(work or frozen, ident)
+    fund = fund_no_of(work or frozen)
+    if work is None:
+        _emit(produced, fund, FORMULA_OPENING, _STEP_ISSUE, ["work/2 没有这张子票据"], label)
+        return
+    if frozen is not None:
+        problems = _new_opening(work, frozen, stages)
+    else:
+        problems = _carry_opening(ctx, work, stages)
+    _emit(produced, fund, FORMULA_OPENING, _STEP_ISSUE, problems, label)
+
+
+def _new_opening(work: Mapping[str, Any], frozen: Mapping[str, Any], stages: Mapping[str, Any]) -> list[str]:
+    """新票据的 work/2 当前列来自 input 的初始列,增额和分红清零。"""
+
+    problems: list[str] = []
+    for current, initial, title, scale in _NEW_FROM_INIT:
+        problems.extend(_amount_pair(work, frozen, current, initial, title, scale, "work/2", "input 初始"))
+    for field, title, scale in (("add_amt", "增额金额", SCALE_AMT), ("dividend_amt", "分红金额", SCALE_AMT)):
+        number = as_decimal(work.get(field))
+        if number is None or quantize(number, scale) != quantize(Decimal(0), scale):
+            problems.append(f"{title} 应为 0")
+    if work.get("note_status") != "pending":
+        problems.append("新票据状态应为待生效")
+    master = _master(_table(stages, SCOPE_WORK2, "note_master"), work)
+    if master is None:
+        problems.append("新票据主表缺失")
+    elif master.get("status") != "pending":
+        problems.append("新票据主表状态应为待生效")
+    return problems
+
+
+def _carry_opening(ctx: Any, work: Mapping[str, Any], stages: Mapping[str, Any]) -> list[str]:
+    """结转票据的 work/2 等于上一已发布 cleared,不读实时余额。"""
+
+    kind = str(getattr(ctx, "baseline_kind", "") or "")
+    if kind == "opening":
+        return ["首日开账不应有结转票据"]
+    if kind != "published":
+        return ["期初基线种类缺失"]
+    baseline_notes = getattr(ctx, "baseline_notes", {}) or {}
+    base_sub = _by_id(mappings(baseline_notes.get("note_sub_a"))).get(_ident(work.get("id")) or -1)
+    if base_sub is None:
+        return ["上一已发布 cleared 没有这张子票据"]
+    problems = _amount_gaps(work, base_sub, _CARRY_AMOUNTS, "work/2", "上一已发布 cleared")
+    problems.extend(_text_gap(work, base_sub, "note_status", "状态", "work/2", "上一已发布 cleared"))
+    work_master = _master(_table(stages, SCOPE_WORK2, "note_master"), work)
+    base_master = _by_id(mappings(baseline_notes.get("note_master"))).get(_ident(work.get("note_id")) or -1)
+    if work_master is None or base_master is None:
+        problems.append("主票据缺失,无法比对到期日")
+    else:
+        problems.extend(_date_gap(work_master, base_master, "maturity_date", "到期日", "work/2", "上一已发布 cleared"))
+    return problems
+
+
+def _cleared_match(ctx: Any, stages: Mapping[str, Any], ident: int, produced: list[tuple[str | None, CheckLine]]) -> None:
+    del ctx
+    cleared = _by_id(_table(stages, SCOPE_CLEARED, "note_sub_a")).get(ident)
+    work = _by_id(_table(stages, SCOPE_WORK4, "note_sub_a")).get(ident)
+    label = _sub_label(cleared or work, ident)
+    fund = fund_no_of(cleared or work)
+    if cleared is None or work is None:
+        missing = "cleared/0" if cleared is None else "work/4"
+        _emit(produced, fund, FORMULA_CLEARED, _STEP_ISSUE, [f"{missing} 没有这张子票据"], label)
+        return
+    problems = _amount_gaps(cleared, work, _CARRY_AMOUNTS, "cleared", "work/4")
+    problems.extend(_text_gap(cleared, work, "note_status", "状态", "cleared", "work/4"))
+    cleared_master = _master(_table(stages, SCOPE_CLEARED, "note_master"), cleared)
+    work_master = _master(_table(stages, SCOPE_WORK4, "note_master"), work)
+    if cleared_master is None or work_master is None:
+        problems.append("主票据缺失,无法比对到期日")
+    else:
+        problems.extend(_date_gap(cleared_master, work_master, "maturity_date", "到期日", "cleared", "work/4"))
+        problems.extend(_text_gap(cleared_master, work_master, "status", "主表状态", "cleared", "work/4"))
+    _emit(produced, fund, FORMULA_CLEARED, _STEP_ISSUE, problems, label)
+
+
+def _projection(ctx: Any, stages: Mapping[str, Any], ident: int, produced: list[tuple[str | None, CheckLine]]) -> None:
+    cleared = _by_id(_table(stages, SCOPE_CLEARED, "note_sub_a")).get(ident)
+    if cleared is None:
+        return
+    label = _sub_label(cleared, ident)
+    fund = fund_no_of(cleared)
+    sub_no = _text(cleared.get("sub_note_no"))
+    live = _by_id(_seq(getattr(ctx, "live_note_subs", ()))).get(ident)
+    if live is None and sub_no is not None:
+        for row in _seq(getattr(ctx, "live_note_subs", ())):
+            if row.get("sub_note_no") == sub_no:
+                live = row
+                break
+    if live is None:
+        _emit(produced, fund, FORMULA_PROJECTION, _STEP_ISSUE, ["实时票据缺失"], label)
+        return
+    later_add = _later_amount(ctx, cleared, ctx.business_date)
+    expected_add = _plus(cleared.get("add_amt"), later_add)
+    problems: list[str] = []
+    if expected_add is None or as_decimal(live.get("add_amt")) is None:
+        problems.append("增额金额缺失")
+    elif quantize(as_decimal(live.get("add_amt")) or Decimal(0), SCALE_AMT) != expected_add:
+        problems.append(
+            f"增额金额 实时 {format_decimal(as_decimal(live.get('add_amt')) or Decimal(0), SCALE_AMT)},"
+            f"应为 {format_decimal(expected_add, SCALE_AMT)}"
+        )
+    problems.extend(_projected_maturity(ctx, stages, cleared, live))
+    if not _has_later_sell(ctx, _text(cleared.get("note_no"))):
+        problems.extend(_amount_gaps(live, cleared, (
+            ("cur_qty", "当前股票数量", SCALE_QTY),
+            ("cur_face_value", "当前票据面值", SCALE_AMT),
+        ), "实时", "cleared"))
+    _emit(produced, fund, FORMULA_PROJECTION, _STEP_ISSUE, problems, label)
+
+
+def _projected_maturity(ctx: Any, stages: Mapping[str, Any], cleared: Mapping[str, Any], live: Mapping[str, Any]) -> list[str]:
+    note_no = _text(cleared.get("note_no"))
+    later = [
+        row for row in _seq(getattr(ctx, "live_extensions", ()))
+        if _is_approved(row) and row.get("note_no") == note_no and _later(row.get("approve_business_date"), ctx.business_date)
+    ]
+    live_master = _master(_seq(getattr(ctx, "live_note_masters", ())), cleared)
+    cleared_master = _master(_table(stages, SCOPE_CLEARED, "note_master"), cleared)
+    if live_master is None or cleared_master is None:
+        return ["主票据缺失,无法比对到期日"]
+    if later:
+        last = max(later, key=lambda row: _ident(row.get("id")) or 0)
+        expected = as_date(last.get("approve_after_maturity"))
+        actual = as_date(live_master.get("maturity_date"))
+        if expected is None or actual != expected:
+            return [f"到期日应为更晚展期后的 {expected}"]
+        return []
+    return _date_gap(live_master, cleared_master, "maturity_date", "到期日", "实时", "cleared")
+
+
+def _yesterday(ctx: Any, stages: Mapping[str, Any], produced: list[tuple[str | None, CheckLine]]) -> None:
+    cleared_rows = _seq(getattr(ctx, "cleared_positions", ()))
+    if not cleared_rows:
+        return
+    live_by_id = _by_id(_seq(getattr(ctx, "live_positions", ())))
+    funds = _fund_by_note_id(stages)
+    for row in cleared_rows:
+        ident = _ident(row.get("id"))
+        note_no = _text(row.get("note_no")) or str(_ident(row.get("note_id")) or "-")
+        fund = funds.get(_ident(row.get("note_id")) or -1)
+        live = None if ident is None else live_by_id.get(ident)
+        if live is None:
+            _emit(produced, fund, FORMULA_YESTERDAY, _STEP_ISSUE, ["实时持仓缺失"], f"票据 {note_no} 持仓 {ident}")
+            continue
+        problems: list[str] = []
+        for live_field, cleared_field, title, scale in _YESTERDAY_FROM:
+            problems.extend(_amount_pair(live, row, live_field, cleared_field, title, scale, "实时昨基线", "cleared 日终"))
+        _emit(produced, fund, FORMULA_YESTERDAY, _STEP_ISSUE, problems, f"票据 {note_no} 持仓 {ident}")
+
+
+def _topup_sum(
+    stages: Mapping[str, Any],
+    note_no: str,
+    frozen_day: Sequence[Mapping[str, Any]],
+    fund: str | None,
+    produced: list[tuple[str | None, CheckLine]],
+) -> None:
+    if not frozen_day:
+        return
+    by_sub: dict[str, list[Mapping[str, Any]]] = {}
+    bare: list[Mapping[str, Any]] = []
+    for row in frozen_day:
+        sub_no = _text(row.get("a_sub_note_no"))
+        if sub_no is None:
+            bare.append(row)
+        else:
+            by_sub.setdefault(sub_no, []).append(row)
+    problems: list[str] = []
+    if bare:
+        problems.append("增额没有子票据编号")
+    work2 = _table(stages, SCOPE_WORK2, "note_sub_a")
+    work3 = _table(stages, SCOPE_WORK3, "note_sub_a")
+    for sub_no, rows in sorted(by_sub.items()):
+        before = _find_sub(work2, note_no, sub_no)
+        after = _find_sub(work3, note_no, sub_no)
+        if before is None or after is None:
+            problems.append(f"子票据 {sub_no} 缺少 work/2 或 work/3")
+            continue
+        added = _sum_amount(rows)
+        expected = _plus(before.get("add_amt"), added)
+        actual = as_decimal(after.get("add_amt"))
+        if added is None or expected is None or actual is None:
+            problems.append(f"子票据 {sub_no} 增额金额缺失")
+        elif quantize(actual, SCALE_AMT) != expected:
+            problems.append(
+                f"子票据 {sub_no} work/3 增额 {format_decimal(actual, SCALE_AMT)},"
+                f"应为 {format_decimal(expected, SCALE_AMT)}"
+            )
+        else:
+            # 第 4 步不重算增额。T 日候选若和 work/3 不一致,发生额没有进入清算结果。
+            for scope, title in ((SCOPE_WORK4, "work/4"), (SCOPE_CLEARED, "cleared")):
+                later = _find_sub(_table(stages, scope, "note_sub_a"), note_no, sub_no)
+                later_add = None if later is None else as_decimal(later.get("add_amt"))
+                if later is None or later_add is None or quantize(later_add, SCALE_AMT) != expected:
+                    problems.append(f"子票据 {sub_no} {title} 增额没有保持 work/3")
+    _emit(produced, fund, FORMULA_TOPUP_SUM, _STEP_ISSUE, problems, f"票据 {note_no} 增额累计")
+
+
+def _extension_date(
+    stages: Mapping[str, Any],
+    note_no: str,
+    frozen_day: Sequence[Mapping[str, Any]],
+    fund: str | None,
+    produced: list[tuple[str | None, CheckLine]],
+) -> None:
+    work2 = _master_by_note(_table(stages, SCOPE_WORK2, "note_master"), note_no)
+    work3 = _master_by_note(_table(stages, SCOPE_WORK3, "note_master"), note_no)
+    if work2 is None or work3 is None:
+        if frozen_day:
+            _emit(produced, fund, FORMULA_EXTENSION_DATE, _STEP_ISSUE, ["work/2 或 work/3 没有这张主票据"], f"票据 {note_no} 到期日")
+        return
+    if not frozen_day:
+        problems = _date_gap(work3, work2, "maturity_date", "到期日", "work/3", "work/2")
+    else:
+        last = max(frozen_day, key=lambda row: _ident(row.get("id")) or 0)
+        expected = as_date(last.get("approve_after_maturity"))
+        actual = as_date(work3.get("maturity_date"))
+        problems = [] if expected is not None and actual == expected else [f"到期日应为 {expected}"]
+    if not problems:
+        # 交收步骤不改到期日。后面的快照必须带上第 3 步的结果。
+        for scope, title in ((SCOPE_WORK4, "work/4"), (SCOPE_CLEARED, "cleared")):
+            later = _master_by_note(_table(stages, scope, "note_master"), note_no)
+            if later is None or as_date(later.get("maturity_date")) != as_date(work3.get("maturity_date")):
+                problems.append(f"{title} 到期日没有保持 work/3")
+    _emit(produced, fund, FORMULA_EXTENSION_DATE, _STEP_ISSUE, problems, f"票据 {note_no} 到期日")
+
+
+def _version_gaps(sales: Mapping[str, Any], live: Mapping[str, Any]) -> list[str]:
+    """销售版本低于发行实时版本就必须已被覆盖;相等时当前列等于实时票据。"""
+
+    sales_version = _ident(sales.get("sync_version"))
+    live_version = _ident(live.get("sync_version"))
+    if sales_version is None or live_version is None:
+        # 版本 0 也是合法版本,_ident 把 0 丢掉了,这里改认非负整数。
+        sales_version = _version(sales.get("sync_version"))
+        live_version = _version(live.get("sync_version"))
+    if sales_version is None or live_version is None:
+        return ["同步版本缺失"]
+    if sales_version < live_version:
+        return [f"销售同步版本 {sales_version} 低于发行实时版本 {live_version}"]
+    problems: list[str] = []
+    if sales_version == live_version:
+        problems.extend(_amount_gaps(sales, live, _VIEW_AMOUNTS, "note_view", "发行实时"))
+        problems.extend(_text_gap(sales, live, "note_status", "状态", "note_view", "发行实时"))
+    # 版本更高表示发行之后又有更新的同步,不要求覆盖。
+    return problems
+
+
+def _amount_changed(live: Mapping[str, Any], cleared: Mapping[str, Any]) -> bool:
+    left = as_decimal(live.get("add_amt"))
+    right = as_decimal(cleared.get("add_amt"))
+    if left is None or right is None:
+        return False
+    return quantize(left, SCALE_AMT) != quantize(right, SCALE_AMT)
+
+
+def _later_amount(ctx: Any, cleared: Mapping[str, Any], day: date) -> Decimal | None:
+    sub_no = _text(cleared.get("sub_note_no"))
+    note_no = _text(cleared.get("note_no"))
+    total = Decimal(0)
+    seen = False
+    for row in _seq(getattr(ctx, "live_topups", ())):
+        if not _is_topup(row) or not _later(row.get("business_date"), day):
+            continue
+        if sub_no is not None and _text(row.get("a_sub_note_no")) not in {None, sub_no}:
+            continue
+        if row.get("note_no") != note_no:
+            continue
+        number = as_decimal(row.get("change_amt"))
+        if number is None:
+            return None
+        total += number
+        seen = True
+    if not seen:
+        return Decimal(0)
+    return quantize(total, SCALE_AMT)
+
+
+def _has_later_sell(ctx: Any, note_no: str | None) -> bool:
+    if note_no is None:
+        return False
+    day = ctx.business_date
+    buckets = list(ctx.orders.values())
+    note_orders = getattr(ctx, "note_orders", {}) or {}
+    if isinstance(note_orders, Mapping):
+        buckets.extend(note_orders.get(note_no, ()))
+    for bucket in buckets:
+        if not isinstance(bucket, Mapping):
+            continue
+        master = bucket.get("master")
+        if not isinstance(master, Mapping) or not is_sell_master(master):
+            continue
+        trade_day = as_date(master.get("trade_date"))
+        if trade_day is None or trade_day <= day:
+            continue
+        if master.get("note_no") == note_no:
+            return True
+    return False
+
+
+def _id_gap(left_name: str, right_name: str, left_rows: Sequence[Mapping[str, Any]], right_rows: Sequence[Mapping[str, Any]]) -> list[str]:
+    left_ids = {_ident(row.get("id")) for row in left_rows}
+    right_ids = {_ident(row.get("id")) for row in right_rows}
+    if None in left_ids or None in right_ids:
+        return ["主键缺失"]
+    problems: list[str] = []
+    for ident in sorted(left_ids - right_ids):
+        problems.append(f"主键 {ident} 只在{left_name}")
+    for ident in sorted(right_ids - left_ids):
+        problems.append(f"主键 {ident} 只在{right_name}")
+    return problems
+
+
+def _amount_gaps(
+    left: Mapping[str, Any],
+    right: Mapping[str, Any],
+    fields: Sequence[tuple[str, str, int]],
+    left_name: str,
+    right_name: str,
+) -> list[str]:
+    found: list[str] = []
+    for field, title, scale in fields:
+        found.extend(_amount_pair(left, right, field, field, title, scale, left_name, right_name))
+    return found
+
+
+def _amount_pair(
+    left: Mapping[str, Any],
+    right: Mapping[str, Any],
+    left_field: str,
+    right_field: str,
+    title: str,
+    scale: int,
+    left_name: str,
+    right_name: str,
+) -> list[str]:
+    if left_field not in left or right_field not in right:
+        return [f"{title}缺失"]
+    left_number = as_decimal(left.get(left_field))
+    right_number = as_decimal(right.get(right_field))
+    if left_number is None or right_number is None:
+        return [f"{title}不是数字"]
+    if scale == 0:
+        if int(left_number) != int(right_number):
+            return [f"{title} {left_name} {int(left_number)},{right_name} {int(right_number)}"]
+        return []
+    if quantize(left_number, scale) != quantize(right_number, scale):
+        return [
+            f"{title} {left_name} {format_decimal(left_number, scale)},"
+            f"{right_name} {format_decimal(right_number, scale)}"
+        ]
+    return []
+
+
+def _text_gap(
+    left: Mapping[str, Any],
+    right: Mapping[str, Any],
+    field: str,
+    title: str,
+    left_name: str,
+    right_name: str,
+) -> list[str]:
+    if field not in left or field not in right:
+        return [f"{title}缺失"]
+    if left.get(field) != right.get(field):
+        return [f"{title} {left_name} {left.get(field)},{right_name} {right.get(field)}"]
+    return []
+
+
+def _date_gap(
+    left: Mapping[str, Any],
+    right: Mapping[str, Any],
+    field: str,
+    title: str,
+    left_name: str,
+    right_name: str,
+) -> list[str]:
+    if field not in left or field not in right:
+        return [f"{title}缺失"]
+    left_day = as_date(left.get(field))
+    right_day = as_date(right.get(field))
+    if left_day != right_day:
+        return [f"{title} {left_name} {left_day},{right_name} {right_day}"]
+    return []
+
+
+def _plus(origin: object, added: Decimal | None) -> Decimal | None:
+    base = as_decimal(origin)
+    if base is None or added is None:
+        return None
+    return quantize(base + added, SCALE_AMT)
+
+
+def _sum_amount(rows: Sequence[Mapping[str, Any]]) -> Decimal | None:
+    total = Decimal(0)
+    for row in rows:
+        number = as_decimal(row.get("change_amt"))
+        if number is None:
+            return None
+        total += number
+    return quantize(total, SCALE_AMT)
+
+
+def _emit(
+    produced: list[tuple[str | None, CheckLine]],
+    fund: str | None,
+    formula: str,
+    basis: str,
+    problems: list[str],
+    label: str,
+) -> None:
+    if problems:
+        text = ";".join(problems)
+        verdict = VERDICT_ERROR
+    else:
+        text = "一致"
+        verdict = VERDICT_CORRECT
+    produced.append((
+        fund,
+        CheckLine(
+            formula_text=formula,
+            calc_text=text,
+            calc_value=None,
+            check_text=label,
+            check_value=None,
+            basis=basis,
+            verdict=verdict,
+        ),
+    ))
+
+
+def _flush(lines: list[CheckLine], produced: list[tuple[str | None, CheckLine]]) -> None:
+    grouped: dict[str | None, list[CheckLine]] = {}
+    for fund, line in produced:
+        grouped.setdefault(fund, []).append(line)
+    for fund in sorted(grouped, key=lambda item: "" if item is None else item):
+        own_fund(lines, grouped[fund], fund)
+
+
+def _facts_ready(stages: Mapping[str, Any], table: str) -> bool:
+    """归档表没读到时不拿空列表冒充「本营业日没有事实」。"""
+
+    bucket = stages.get(SCOPE_INPUT)
+    return isinstance(bucket, Mapping) and table in bucket
+
+
+def _stages(ctx: Any) -> Mapping[str, Any]:
+    value = getattr(ctx, "issue_stages", None) or {}
+    if not isinstance(value, Mapping):
+        return {}
+    return value
+
+
+def _table(stages: Mapping[str, Any], scope: str, table: str) -> list[Mapping[str, Any]]:
+    bucket = stages.get(scope)
+    if not isinstance(bucket, Mapping):
+        return []
+    return mappings(bucket.get(table))
+
+
+def _seq(value: object) -> list[Mapping[str, Any]]:
+    return mappings(value)
+
+
+def _by_id(rows: Sequence[Mapping[str, Any]]) -> dict[int, Mapping[str, Any]]:
+    found: dict[int, Mapping[str, Any]] = {}
+    for row in rows:
+        ident = _ident(row.get("id"))
+        if ident is not None and ident not in found:
+            found[ident] = row
+    return found
+
+
+def _master(rows: Sequence[Mapping[str, Any]], sub: Mapping[str, Any]) -> Mapping[str, Any] | None:
+    ident = _ident(sub.get("note_id"))
+    if ident is not None:
+        matched = _by_id(rows).get(ident)
+        if matched is not None:
+            return matched
+    note_no = _text(sub.get("note_no"))
+    for row in rows:
+        if note_no is not None and row.get("note_no") == note_no:
+            return row
+    return None
+
+
+def _master_by_note(rows: Sequence[Mapping[str, Any]], note_no: str) -> Mapping[str, Any] | None:
+    for row in rows:
+        if row.get("note_no") == note_no:
+            return row
+    return None
+
+
+def _find_sub(rows: Sequence[Mapping[str, Any]], note_no: str, sub_no: str) -> Mapping[str, Any] | None:
+    for row in rows:
+        if row.get("note_no") == note_no and row.get("sub_note_no") == sub_no:
+            return row
+    return None
+
+
+def _fund_for_note(stages: Mapping[str, Any], note_no: str) -> str | None:
+    for scope in (SCOPE_CLEARED, SCOPE_WORK3, SCOPE_WORK2, SCOPE_INPUT):
+        for row in _table(stages, scope, "note_sub_a"):
+            if row.get("note_no") == note_no:
+                fund = fund_no_of(row)
+                if fund is not None:
+                    return fund
+    return None
+
+
+def _fund_on(rows: Sequence[Mapping[str, Any]], note_no: str) -> str | None:
+    for row in rows:
+        if row.get("note_no") == note_no:
+            fund = fund_no_of(row)
+            if fund is not None:
+                return fund
+    return None
+
+
+def _fund_by_note_id(stages: Mapping[str, Any]) -> dict[int, str]:
+    found: dict[int, str] = {}
+    for scope in (SCOPE_CLEARED, SCOPE_WORK2):
+        for row in _table(stages, scope, "note_sub_a"):
+            ident = _ident(row.get("note_id"))
+            fund = fund_no_of(row)
+            if ident is not None and fund is not None and ident not in found:
+                found[ident] = fund
+    return found
+
+
+def _note_numbers(*groups: Sequence[Mapping[str, Any]]) -> list[str]:
+    found: set[str] = set()
+    for rows in groups:
+        for row in rows:
+            note_no = _text(row.get("note_no"))
+            if note_no is not None:
+                found.add(note_no)
+    return sorted(found)
+
+
+def _sub_label(row: Mapping[str, Any] | None, ident: int) -> str:
+    if row is None:
+        return f"子票据 {ident}"
+    note_no = _text(row.get("note_no")) or str(ident)
+    sub_no = _text(row.get("sub_note_no"))
+    if sub_no is None:
+        return f"票据 {note_no}"
+    return f"票据 {note_no} 子票据 {sub_no}"
+
+
+def _is_topup(row: Mapping[str, Any]) -> bool:
+    return row.get("change_type") == "top_up"
+
+
+def _is_approved(row: Mapping[str, Any]) -> bool:
+    return row.get("status") == "approved"
+
+
+def _later(value: object, day: date) -> bool:
+    parsed = as_date(value)
+    return parsed is not None and parsed > day
+
+
+def _ident(value: object) -> int | None:
+    if isinstance(value, bool) or value is None:
+        return None
+    if isinstance(value, int):
+        return value if value > 0 else None
+    if isinstance(value, str) and value.strip().isdigit():
+        number = int(value.strip())
+        return number if number > 0 else None
+    return None
+
+
+def _version(value: object) -> int | None:
+    """同步版本允许 0。主键仍必须大于 0。"""
+
+    if isinstance(value, bool) or value is None:
+        return None
+    if isinstance(value, int):
+        return value if value >= 0 else None
+    if isinstance(value, str) and value.strip().isdigit():
+        return int(value.strip())
+    return None
+
+
+def _text(value: object) -> str | None:
+    if not isinstance(value, str):
+        return None
+    stripped = value.strip()
+    return stripped or None
+
+
+def stage_kwargs(data: Mapping[str, Any]) -> dict[str, Any]:
+    """从取数结果里取出三条链路共用的步骤快照。缺省为空,旧用例不产生步骤结论。"""
+
+    def as_map(key: str) -> Mapping[str, Any]:
+        value = data.get(key) or {}
+        if not isinstance(value, Mapping):
+            raise TypeError(f"{key} 必须是字典")
+        return value
+
+    kind = data.get("baseline_kind")
+    raw_day = data.get("trade_date")
+    if isinstance(raw_day, datetime):
+        trade_day = raw_day.date()
+    elif isinstance(raw_day, date):
+        trade_day = raw_day
+    else:
+        trade_day = None
+    return {
+        "trade_date": trade_day,
+        "issue_stages": as_map("issue_stages"),
+        "baseline_kind": kind if isinstance(kind, str) else "",
+        "baseline_notes": as_map("baseline_notes"),
+        "live_notes_loaded": data.get("live_notes_loaded") is True,
+        "live_note_subs": tuple(mappings(data.get("live_note_subs"))),
+        "live_note_masters": tuple(mappings(data.get("live_note_masters"))),
+        "live_topups": tuple(mappings(data.get("live_topups"))),
+        "live_extensions": tuple(mappings(data.get("live_extensions"))),
+        "live_positions": tuple(mappings(data.get("live_positions"))),
+        "cleared_positions": tuple(mappings(data.get("cleared_positions"))),
+        "sales_note_loaded": data.get("sales_note_loaded") is True,
+        "sales_current_notes": tuple(mappings(data.get("sales_current_notes"))),
+        "sales_cleared_notes": tuple(mappings(data.get("sales_cleared_notes"))),
+    }

+ 1333 - 0
code/post_clear_check/src/post_clear_check/orchestrate.py

@@ -0,0 +1,1333 @@
+"""把一轮开跑收成卡片,写入自有库,再交给发送。
+
+不改结论行的字段,不重新定义公式。取数、规则和发送都调用已经存在的函数。
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
+from dataclasses import dataclass, replace
+from datetime import date, datetime
+from pathlib import Path
+from typing import Any
+
+from asyncmy.errors import IntegrityError
+from loguru import logger
+
+from post_clear_check.config import (
+    Settings,
+    describe_exception,
+    push_log_suffix,
+    push_verdicts,
+    resolve_business_date,
+    secret_values,
+    shanghai_wall_now,
+)
+from post_clear_check.feishu import (
+    CARD_SUMMARY,
+    FeishuError,
+    OutboundCard,
+    SendResult,
+    SummaryOwner,
+    send_card,
+    send_summary,
+)
+from post_clear_check.gate import (
+    NO_HEDGE_END_TIME,
+    ConclusionLine,
+    GateScan,
+    QueryGateway,
+    ReadyRun,
+    TrsBlocked,
+    daycut_line,
+    scan_gate,
+    trs_blocked_line,
+)
+from post_clear_check.published import daycut_mismatches
+from post_clear_check.chain import (
+    CHAIN_GATE,
+    CHAIN_ORDER,
+    CHAIN_UNASSIGNED,
+    CHAINS,
+    GATE_MISSING_TABLE,
+    OWNER_BATCH,
+    OWNER_FUND,
+    OWNER_MASTER,
+    OWNER_USER,
+    SIDE_NONE,
+    hang,
+    hang_by_side,
+    owner_of,
+    unassign,
+)
+from post_clear_check.issue_common import is_population_bucket
+from post_clear_check.issue_reports import check_issue_reports
+from post_clear_check.order_cell import CheckLine
+from post_clear_check.order_decimal import VERDICT_CORRECT, VERDICT_ERROR, VERDICT_SUSPECT
+from post_clear_check.progress import Stage, WatchedGateway
+
+# 规则版本只哈希这些模块。编排和飞书变化不重算已核对的批次。链路登记表决定挂链和依据,算进规则。
+_RULE_FILE_NAMES = frozenset(
+    {
+        "order_cell.py",
+        "order_decimal.py",
+        "order_rules.py",
+        "hedge_link.py",
+        "reader.py",
+        "columns.py",
+        "chain.py",
+        "fund_chain.py",
+    }
+)
+_RULE_PREFIXES = ("issue_", "sales_", "trs_")
+_CLIP_MARK = "…已截断"
+_VERDICT_LABEL = {VERDICT_CORRECT: "正确", VERDICT_ERROR: "错误", VERDICT_SUSPECT: "可疑"}
+from post_clear_check.order_rules import check_order
+from post_clear_check.reader import ReadRequest, ReadResult, read_rows
+from post_clear_check.sales_reports import check_sales_reports
+from post_clear_check.trs_reports import check_trs_reports
+
+NOTIFY_PENDING = "pending"
+NOTIFY_SENT = "sent"
+NOTIFY_FAILED = "failed"
+
+# 归属种类,与链路登记表一致。
+CARD_MASTER = OWNER_MASTER
+CARD_FUND = OWNER_FUND
+CARD_USER = OWNER_USER
+CARD_BATCH = OWNER_BATCH
+
+_KIND_RANK = {CARD_MASTER: 0, CARD_FUND: 1, CARD_USER: 2, CARD_BATCH: 3}
+# 结论行每次 executemany 的行数。只影响进度推进的粒度,单条语句长度由驱动按 1MB 切分。
+_LINE_BATCH = 2000
+_VERDICTS = frozenset({VERDICT_CORRECT, VERDICT_ERROR, VERDICT_SUSPECT})
+_KINDS = frozenset(set(_KIND_RANK))
+
+# 自有库语句。业务连接不执行这些写入。
+_FIND_RUN_SQL = (
+    "SELECT id FROM check_run "
+    "WHERE market = %s AND trade_date = %s AND issue_batch_id = %s "
+    "AND sales_batch_id = %s AND trs_batch_id = %s AND issue_end_time = %s "
+    "AND sales_end_time = %s AND trs_end_time = %s AND rule_pack_version = %s"
+)
+_INSERT_RUN_SQL = (
+    "INSERT INTO check_run ("
+    "market, trade_date, business_date, issue_batch_id, sales_batch_id, trs_batch_id, "
+    "issue_end_time, sales_end_time, trs_end_time, issue_applied_revision, "
+    "sales_applied_revision, trs_applied_revision, rule_pack_version, "
+    "correct_count, error_count, suspect_count, create_time"
+    ") VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
+)
+_INSERT_RESULT_SQL = (
+    "INSERT INTO check_result ("
+    "run_id, card_kind, chain_name, biz_key, verdict, dedupe_key, notify_status, create_time, update_time"
+    ") VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)"
+)
+_INSERT_LINE_SQL = (
+    "INSERT INTO check_line ("
+    "result_id, formula_text, calc_text, calc_value, check_text, check_value, basis, verdict, "
+    "report_code, master_order_no, fund_account, user_account"
+    ") VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
+)
+_SELECT_UNSENT_SQL = (
+    "SELECT id, card_kind, chain_name, biz_key, verdict, notify_status FROM check_result "
+    "WHERE run_id = %s AND notify_status IN (%s, %s) ORDER BY id"
+)
+# 概况按这条结果现编归属名单。明细和已有概况都要读到,发送时再丢掉概况行。
+_SELECT_RUN_RESULTS_SQL = (
+    "SELECT card_kind, chain_name, biz_key, verdict FROM check_result "
+    "WHERE run_id = %s ORDER BY id"
+)
+_SELECT_LINES_SQL = (
+    "SELECT formula_text, calc_text, calc_value, check_text, check_value, basis, verdict, "
+    "report_code, master_order_no, fund_account, user_account "
+    "FROM check_line WHERE result_id = %s ORDER BY id"
+)
+_UPDATE_NOTIFY_SQL = "UPDATE check_result SET notify_status = %s, update_time = %s WHERE id = %s"
+_SELECT_DEDUPE_SQL = "SELECT dedupe_key FROM check_result WHERE run_id = %s"
+_ADD_COUNTS_SQL = (
+    "UPDATE check_run SET correct_count = correct_count + %s, error_count = error_count + %s, "
+    "suspect_count = suspect_count + %s WHERE id = %s"
+)
+_FIND_SNAPSHOT_SQL = (
+    "SELECT id FROM check_run WHERE market = %s AND business_date = %s "
+    "AND issue_batch_id = 0 AND sales_batch_id = 0 AND trs_batch_id = 0"
+)
+_SILENCE_SNAPSHOT_SQL = (
+    "UPDATE check_result SET notify_status = %s, update_time = %s "
+    "WHERE run_id = %s AND notify_status IN (%s, %s)"
+)
+_BATCH_NO_SQL = "SELECT batch_no FROM db_issue.clearing_batch WHERE id = %s"
+
+_OrderFn = Callable[[ReadResult], Mapping[str, Sequence[CheckLine]]]
+_ReportFn = Callable[[Mapping[str, Any]], Sequence[CheckLine]]
+_SendFn = Callable[[Settings, OutboundCard], Awaitable[SendResult]]
+
+
+def rule_pack_version() -> str:
+    """当前规则模块源文件的 sha256。文件没变则版本不变,已核对的轮次不重算。"""
+
+    root = Path(__file__).resolve().parent
+    files = {
+        path.name: path.read_bytes()
+        for path in root.glob("*.py")
+        if path.name in _RULE_FILE_NAMES or path.name.startswith(_RULE_PREFIXES)
+    }
+    return rule_pack_version_from(files)
+
+
+def rule_pack_version_from(files: Mapping[str, bytes]) -> str:
+    """按文件名排序哈希。测试用内存字节,不读磁盘。"""
+
+    digest = hashlib.sha256()
+    for name in sorted(files):
+        digest.update(name.encode("utf-8"))
+        digest.update(b"\0")
+        digest.update(files[name])
+        digest.update(b"\0")
+    return digest.hexdigest()
+
+
+@dataclass(frozen=True)
+class CardDraft:
+    """还没落库的一张卡:一条链路、一个归属对象。没有结论行的卡不会被构造出来。"""
+
+    chain_name: str
+    card_kind: str
+    biz_key: str
+    verdict: str
+    lines: tuple[CheckLine, ...]
+    # 门禁快照用。表名或「TRS未完成」变化时另开一张卡,不改三端结束时间。
+    snapshot_token: str = ""
+
+
+@dataclass(frozen=True)
+class StoredCard:
+    """自有库里待发送或待重发的一张卡。"""
+
+    id: int
+    card_kind: str
+    chain_name: str
+    biz_key: str
+    verdict: str
+    notify_status: str
+    lines: tuple[CheckLine, ...]
+
+
+def summarize(lines: Sequence[CheckLine]) -> str:
+    """有 error 就是 error,否则有 suspect 就是 suspect,否则是 correct。"""
+
+    return _worst_verdict(line.verdict for line in lines)
+
+
+def _worst_verdict(verdicts: Iterable[str]) -> str:
+    """明细卡和概况卡共用:错误高于可疑,可疑高于正确。"""
+
+    found = set(verdicts)
+    if not found <= _VERDICTS:
+        raise ValueError("卡片结论不在 correct、error、suspect 之内")
+    if VERDICT_ERROR in found:
+        return VERDICT_ERROR
+    if VERDICT_SUSPECT in found:
+        return VERDICT_SUSPECT
+    return VERDICT_CORRECT
+
+
+def _summary_drafts(details: Sequence[tuple[str, str, str, str]]) -> tuple[CardDraft, ...]:
+    """每条有明细的链路一张概况。没有结论行。biz_key 用链路代码,便于幂等。
+
+    details 的四列是链路、归属、主键、明细卡总结论。
+    """
+
+    grouped: dict[str, list[str]] = {}
+    for chain_name, _kind, _biz_key, verdict in details:
+        grouped.setdefault(chain_name, []).append(verdict)
+    unknown = sorted(code for code in grouped if code not in CHAIN_ORDER)
+    if unknown:
+        logger.error("概况跳过未登记链路:{}", "、".join(unknown))
+    drafts: list[CardDraft] = []
+    for chain_name in sorted(grouped, key=lambda code: CHAIN_ORDER.get(code, len(CHAIN_ORDER))):
+        if chain_name not in CHAIN_ORDER:
+            continue
+        drafts.append(
+            CardDraft(
+                chain_name,
+                CARD_SUMMARY,
+                chain_name,
+                _worst_verdict(grouped[chain_name]),
+                (),
+            )
+        )
+    return tuple(drafts)
+
+
+def _attach_summaries(cards: Sequence[CardDraft]) -> tuple[CardDraft, ...]:
+    """概况排在明细前面,这样新轮次的主键也是概况在先。已带概况的再收一次不会重复。"""
+
+    details = tuple(card for card in cards if card.card_kind != CARD_SUMMARY)
+    rows = tuple((card.chain_name, card.card_kind, card.biz_key, card.verdict) for card in details)
+    return _summary_drafts(rows) + details
+
+
+def _pending_order(cards: Sequence[StoredCard]) -> tuple[StoredCard, ...]:
+    """先发概况,概况之间按链路登记顺序;再按主键升序发明细。旧轮次补插的概况主键更大,不能靠主键排序。"""
+
+    summaries = [card for card in cards if card.card_kind == CARD_SUMMARY]
+    details = [card for card in cards if card.card_kind != CARD_SUMMARY]
+    summaries.sort(key=lambda card: CHAIN_ORDER.get(card.chain_name, len(CHAIN_ORDER)))
+    return tuple(summaries) + tuple(details)
+
+
+def dedupe_key(
+    ready: ReadyRun,
+    card_kind: str,
+    biz_key: str,
+    *,
+    chain_name: str,
+    snapshot_token: str = "",
+) -> str:
+    """按库表把一轮和一张卡拼成幂等键。放不下或业务键里有分隔符时改存摘要。
+
+    链路代码紧跟卡片种类:同一主订单上的两条链路是两张卡,幂等键必须不同。
+    """
+
+    parts = [
+        ready.trade_date.isoformat(),
+        card_kind,
+        chain_name,
+        biz_key,
+        str(ready.issue_batch_id),
+        str(ready.sales_batch_id),
+        str(ready.trs_batch_id),
+        _clock_text(ready.issue_end_time),
+        _clock_text(ready.sales_end_time),
+        _clock_text(ready.trs_end_time),
+        rule_pack_version(),
+    ]
+    # 快照令牌只加在门禁卡上,避免普通卡的幂等键因空字段变化。
+    if snapshot_token:
+        parts.append(snapshot_token)
+    # 分隔符不出现在订单号和账号里。业务键若自带这个字符,拼接会撞车,改走摘要。
+    raw = "\x1f".join(parts)
+    if "\x1f" not in biz_key and "\x1f" not in snapshot_token and len(raw) <= 191:
+        return raw
+    return hashlib.sha256(raw.encode("utf-8")).hexdigest()
+
+
+def assemble_cards(
+    order_lines: Mapping[str, Sequence[CheckLine]],
+    issue_lines: Sequence[CheckLine],
+    sales_lines: Sequence[CheckLine],
+    trs_lines: Sequence[CheckLine],
+    *,
+    business_date: date | None = None,
+    missing_tables: Sequence[str] = (),
+    gate_lines: Sequence[CheckLine] = (),
+) -> tuple[CardDraft, ...]:
+    """按「链路 + 归属 + 主键」收卡。归属按链路登记的优先顺序取第一个非空键。
+
+    没挂链、链路要求的归属键为空的行进未归链路,结论为错误。缺表进核对门禁。
+    """
+
+    buckets: dict[tuple[str, str, str], list[CheckLine]] = {}
+    loose: list[CheckLine] = []
+
+    def add(chain_name: str, kind: str, biz_key: str, line: CheckLine) -> None:
+        buckets.setdefault((chain_name, kind, biz_key), []).append(line)
+
+    def place(line: CheckLine) -> None:
+        if line.chain_name == CHAIN_UNASSIGNED:
+            loose.append(line)
+            return
+        chain = CHAINS.get(line.chain_name)
+        if chain is None:
+            loose.append(unassign(line, "没有挂上链路"))
+            return
+        owner = owner_of(line)
+        if owner is None:
+            loose.append(unassign(line, f"{chain.name}要求的归属键为空"))
+            return
+        add(chain.code, owner[0], owner[1], line)
+
+    for order_no in sorted(order_lines):
+        for line in order_lines[order_no]:
+            place(line)
+    for line in tuple(issue_lines) + tuple(sales_lines) + tuple(trs_lines):
+        place(line)
+    if loose or missing_tables:
+        if business_date is None:
+            raise ValueError("没有归属的结论行缺少营业日")
+        day = business_date.isoformat()
+        for line in loose:
+            add(CHAIN_UNASSIGNED, CARD_BATCH, day, line)
+        for table in missing_tables:
+            add(CHAIN_GATE, CARD_BATCH, day, _missing_table_line(table))
+    if gate_lines:
+        if business_date is None:
+            raise ValueError("核对门禁结论行缺少营业日")
+        day = business_date.isoformat()
+        for line in gate_lines:
+            add(CHAIN_GATE, CARD_BATCH, day, line)
+    drafts: list[CardDraft] = []
+    # 同一归属对象的几条链路相邻,群里按主键能连着看。
+    order = sorted(buckets, key=lambda item: (_KIND_RANK[item[1]], item[2], CHAIN_ORDER[item[0]]))
+    for chain_name, kind, biz_key in order:
+        rows = tuple(buckets[(chain_name, kind, biz_key)])
+        if not rows:
+            continue
+        drafts.append(CardDraft(chain_name, kind, biz_key, summarize(rows), rows))
+    return tuple(drafts)
+
+
+def assemble_batch_cards(lines: Sequence[ConclusionLine]) -> tuple[CardDraft, ...]:
+    """批次门禁一条一个业务键。同一个批次号上的多条放在同一张卡,归核对门禁。"""
+
+    buckets: dict[str, list[ConclusionLine]] = {}
+    for line in lines:
+        buckets.setdefault(line.biz_key, []).append(line)
+    drafts: list[CardDraft] = []
+    for biz_key in sorted(buckets):
+        source = tuple(buckets[biz_key])
+        rows = tuple(_from_conclusion(line) for line in source)
+        if not rows:
+            continue
+        drafts.append(
+            CardDraft(
+                CHAIN_GATE,
+                CARD_BATCH,
+                biz_key,
+                summarize(rows),
+                rows,
+                snapshot_token=_snapshot_token(source),
+            )
+        )
+    return tuple(drafts)
+
+
+def split_batch_runs(lines: Sequence[ConclusionLine]) -> tuple[tuple[ReadyRun, tuple[CardDraft, ...]], ...]:
+    """还没有完成批次的门禁行,按市场和营业日合成一轮。"""
+
+    grouped: dict[tuple[str, date, date], list[ConclusionLine]] = {}
+    for line in lines:
+        grouped.setdefault(_batch_identity(line), []).append(line)
+    found: list[tuple[ReadyRun, tuple[CardDraft, ...]]] = []
+    for market, trade_date, business_date in sorted(grouped):
+        ready = _synthetic_run(market, trade_date, business_date)
+        found.append((ready, assemble_batch_cards(grouped[(market, trade_date, business_date)])))
+    return tuple(found)
+
+
+def collect_order_lines(result: ReadResult) -> dict[str, list[CheckLine]]:
+    """每张主单单独跑订单规则,按主订单方向挂到买入执行或卖出执行。sources 是逻辑表到来源。
+
+    只跑核对人口。报表行引用的往日主订单是参照数据,执行公式已在它的交易日核过,不再每天重复出卡。
+    """
+
+    found: dict[str, list[CheckLine]] = {}
+    for order_no in sorted(result.orders):
+        bucket = result.orders[order_no]
+        if not isinstance(bucket, Mapping) or not is_population_bucket(bucket):
+            continue
+        # 订单结论的主订单编号以字典键为准,不看正文里有没有写。
+        lines = [
+            replace(line, master_order_no=str(order_no))
+            for line in check_order(bucket, result.sources)
+        ]
+        found[str(order_no)] = hang_by_side(lines, {str(order_no): bucket}, SIDE_NONE)
+    return found
+
+
+class SqlRunStore:
+    """自有库上的三张表。一轮的插入包在同一个事务里。"""
+
+    def __init__(self, connection: Any) -> None:
+        self._connection = connection
+
+    async def find_run_id(self, ready: ReadyRun) -> int | None:
+        """按唯一键找已经写过的一轮。没有则返回空。"""
+
+        rows = await self._query(_FIND_RUN_SQL, _run_key(ready))
+        if not rows:
+            return None
+        return int(rows[0][0])
+
+    async def save_new_run(
+        self,
+        ready: ReadyRun,
+        cards: Sequence[CardDraft],
+        now: datetime,
+        *,
+        tick: Callable[[int], None] | None = None,
+    ) -> int | None:
+        """插入一轮和它的卡片。唯一键冲突时整段回滚,返回空。tick 每写一条结论行回报一次,只用于进度显示。"""
+
+        correct, error, suspect = _counts(cards)
+        await self._connection.begin()
+        try:
+            run_id = await self._insert_run(ready, correct, error, suspect, now)
+            if run_id is None:
+                await self._connection.rollback()
+                return None
+            for card in cards:
+                await self._insert_card(run_id, ready, card, now, tick)
+            await self._connection.commit()
+        except Exception:
+            await self._rollback_quietly()
+            raise
+        return run_id
+
+    async def add_missing_cards(
+        self,
+        run_id: int,
+        ready: ReadyRun,
+        cards: Sequence[CardDraft],
+        now: datetime,
+    ) -> None:
+        """快照范围变了才追加卡。已有幂等键的卡不复制结论行。"""
+
+        existing = await self._query(_SELECT_DEDUPE_SQL, (run_id,))
+        known = {str(row[0]) for row in existing}
+        fresh = [card for card in cards if _card_dedupe(ready, card) not in known]
+        if not fresh:
+            return
+        correct, error, suspect = _counts(fresh)
+        await self._connection.begin()
+        try:
+            for card in fresh:
+                await self._insert_card(run_id, ready, card, now, None)
+            await self._execute(_ADD_COUNTS_SQL, (correct, error, suspect, run_id))
+            await self._connection.commit()
+        except Exception:
+            await self._rollback_quietly()
+            raise
+
+    async def silence_snapshots(self, market: str, business_date: date, now: datetime) -> None:
+        """真实轮次已经落下后,未发出的门禁快照不再补发。"""
+
+        rows = await self._query(_FIND_SNAPSHOT_SQL, (market, business_date))
+        for row in rows:
+            run_id = int(row[0])
+            await self._connection.begin()
+            try:
+                await self._execute(
+                    _SILENCE_SNAPSHOT_SQL,
+                    (NOTIFY_SENT, now, run_id, NOTIFY_PENDING, NOTIFY_FAILED),
+                )
+                await self._connection.commit()
+            except Exception:
+                await self._rollback_quietly()
+                raise
+
+    async def unsent_cards(self, run_id: int) -> tuple[StoredCard, ...]:
+        """pending 和 failed,按主键升序。已 sent 的不返回。"""
+
+        rows = await self._query(_SELECT_UNSENT_SQL, (run_id, NOTIFY_PENDING, NOTIFY_FAILED))
+        cards: list[StoredCard] = []
+        for row in rows:
+            result_id = int(row[0])
+            chain_name = str(row[2])
+            line_rows = await self._query(_SELECT_LINES_SQL, (result_id,))
+            cards.append(
+                StoredCard(
+                    id=result_id,
+                    card_kind=str(row[1]),
+                    chain_name=chain_name,
+                    biz_key=str(row[3]),
+                    verdict=str(row[4]),
+                    notify_status=str(row[5]),
+                    # 链路记在卡上,读回的结论行沿用卡的链路,与落库前一致。
+                    lines=tuple(replace(_line_from_row(item), chain_name=chain_name) for item in line_rows),
+                )
+            )
+        return tuple(cards)
+
+    async def detail_rows(self, run_id: int) -> tuple[tuple[str, str, str, str], ...]:
+        """这一轮全部卡片的归属四列:种类、链路、主键、总结论。含概况行,调用方自己丢掉。"""
+
+        rows = await self._query(_SELECT_RUN_RESULTS_SQL, (run_id,))
+        return tuple((str(row[0]), str(row[1]), str(row[2]), str(row[3])) for row in rows)
+
+    async def ensure_summaries(self, run_id: int, ready: ReadyRun, now: datetime) -> None:
+        """旧轮次没有概况卡时补插。不改三个计数,不复制明细的结论行。
+
+        和明细写在不同的事务里:补插失败不会把已经落下的明细卷走。
+        幂等键撞上说明另一个进程刚补过,回滚后继续发。
+        """
+
+        rows = await self.detail_rows(run_id)
+        have = {chain for kind, chain, _biz, _verdict in rows if kind == CARD_SUMMARY}
+        details = tuple(
+            (chain, kind, biz, verdict)
+            for kind, chain, biz, verdict in rows
+            if kind != CARD_SUMMARY
+        )
+        fresh = [card for card in _summary_drafts(details) if card.chain_name not in have]
+        if not fresh:
+            return
+        await self._connection.begin()
+        try:
+            for card in fresh:
+                await self._insert_card(run_id, ready, card, now, None)
+            await self._connection.commit()
+        except IntegrityError as exc:
+            await self._rollback_quietly()
+            if _duplicate(exc):
+                return
+            raise
+        except Exception:
+            await self._rollback_quietly()
+            raise
+        logger.info("补插链路概况 {} 张", len(fresh))
+
+    async def set_notify(self, result_id: int, status: str, now: datetime) -> None:
+        """把一张卡改成 sent 或 failed。只按主键更新。"""
+
+        if status not in {NOTIFY_SENT, NOTIFY_FAILED}:
+            raise ValueError("发送状态只能是 sent 或 failed")
+        await self._connection.begin()
+        try:
+            await self._execute(_UPDATE_NOTIFY_SQL, (status, now, result_id))
+            await self._connection.commit()
+        except Exception:
+            await self._rollback_quietly()
+            raise
+
+    async def _insert_run(
+        self,
+        ready: ReadyRun,
+        correct: int,
+        error: int,
+        suspect: int,
+        now: datetime,
+    ) -> int | None:
+        args = (
+            ready.market,
+            ready.trade_date,
+            ready.business_date,
+            ready.issue_batch_id,
+            ready.sales_batch_id,
+            ready.trs_batch_id,
+            ready.issue_end_time,
+            ready.sales_end_time,
+            ready.trs_end_time,
+            ready.issue_applied_revision,
+            ready.sales_applied_revision,
+            ready.trs_applied_revision,
+            rule_pack_version(),
+            correct,
+            error,
+            suspect,
+            now,
+        )
+        async with self._connection.cursor() as cursor:
+            try:
+                await cursor.execute(_INSERT_RUN_SQL, args)
+            except IntegrityError as exc:
+                if _duplicate(exc):
+                    return None
+                raise
+            return int(cursor.lastrowid)
+
+    async def _insert_card(
+        self,
+        run_id: int,
+        ready: ReadyRun,
+        card: CardDraft,
+        now: datetime,
+        tick: Callable[[int], None] | None,
+    ) -> None:
+        result_args = (
+            run_id,
+            card.card_kind,
+            card.chain_name,
+            card.biz_key,
+            card.verdict,
+            _card_dedupe(ready, card),
+            NOTIFY_PENDING,
+            now,
+            now,
+        )
+        async with self._connection.cursor() as cursor:
+            await cursor.execute(_INSERT_RESULT_SQL, result_args)
+            result_id = int(cursor.lastrowid)
+        rows = [
+            (
+                result_id,
+                line.formula_text,
+                line.calc_text,
+                line.calc_value,
+                line.check_text,
+                line.check_value,
+                line.basis,
+                line.verdict,
+                line.report_code,
+                line.master_order_no,
+                line.fund_account,
+                line.user_account,
+            )
+            for line in card.lines
+        ]
+        # 结论行批量写入:驱动把同一条 INSERT 合并成多行 VALUES,并按语句长度自动再切。
+        # 先按块调用,只为让进度按块推进;一张卡仍在本轮同一个事务里。
+        for start in range(0, len(rows), _LINE_BATCH):
+            chunk = rows[start : start + _LINE_BATCH]
+            async with self._connection.cursor() as cursor:
+                await cursor.executemany(_INSERT_LINE_SQL, chunk)
+            if tick is not None:
+                tick(len(chunk))
+
+    async def _query(self, sql: str, args: tuple[Any, ...]) -> list[tuple[Any, ...]]:
+        async with self._connection.cursor() as cursor:
+            await cursor.execute(sql, args)
+            if cursor.description is None:
+                return []
+            return list(await cursor.fetchall())
+
+    async def _execute(self, sql: str, args: tuple[Any, ...]) -> None:
+        async with self._connection.cursor() as cursor:
+            await cursor.execute(sql, args)
+
+    async def _rollback_quietly(self) -> None:
+        try:
+            await self._connection.rollback()
+        except Exception as exc:
+            logger.error("回滚自有库事务失败:{}", describe_exception(exc))
+
+
+async def issue_batch_no(gateway: QueryGateway, batch_id: int) -> str:
+    """卡片头上的批次号取发行完成批次。读不到时用批次 id,不编一个号。"""
+
+    if isinstance(batch_id, bool) or not isinstance(batch_id, int) or batch_id <= 0:
+        return "0"
+    rows = await gateway.execute(_BATCH_NO_SQL, (batch_id,))
+    if not rows:
+        return str(batch_id)
+    value = rows[0][0]
+    if isinstance(value, bytes):
+        value = value.decode("utf-8", errors="replace")
+    if not isinstance(value, str):
+        return str(batch_id)
+    text = value.strip().splitlines()[0] if value.strip() else ""
+    return text or str(batch_id)
+
+
+async def run_once(
+    business: QueryGateway,
+    own: Any,
+    settings: Settings,
+    *,
+    store: SqlRunStore | None = None,
+    scan: Callable[[QueryGateway], Awaitable[GateScan]] | None = None,
+    read: Callable[[QueryGateway, ReadRequest], Awaitable[ReadResult]] | None = None,
+    orders: _OrderFn | None = None,
+    issue: _ReportFn | None = None,
+    sales: _ReportFn | None = None,
+    trs: _ReportFn | None = None,
+    sender: _SendFn | None = None,
+    today: date | None = None,
+) -> None:
+    """做一轮。没有开跑且没有批次级结论行就返回。today 只给测试注入上海今天。"""
+
+    read_fn = read or read_rows
+    order_fn = orders or collect_order_lines
+    issue_fn = issue or check_issue_reports
+    sales_fn = sales or check_sales_reports
+    trs_fn = trs or check_trs_reports
+    send_fn = sender or _send_outbound
+    owned = store or SqlRunStore(own)
+    # 没配置营业日时只核上海时区的今天。注入的扫描只在测试里使用,不改它的入参。
+    day = resolve_business_date(settings)
+    wall_today = today or shanghai_wall_now().date()
+    logger.info("本轮只核对营业日 {}", day.isoformat())
+    async with Stage("检查三端清算批次") as stage:
+        watched = WatchedGateway(business, stage)
+        if scan is None:
+            scanned = await scan_gate(watched, day)
+        else:
+            scanned = await scan(watched)
+        stage.summary = f"{watched.count} 条查询,可核对 {len(scanned.ready)} 轮"
+    _log_trs_blocked(scanned)
+    historical = day < wall_today
+    if not scanned.ready and not scanned.lines and not (historical and scanned.blocked):
+        # 当天被 TRS 挡住已经另有日志,不再写成「没有开跑」。
+        if not scanned.blocked:
+            logger.info("没有开跑,本轮结束")
+        return
+    for ready in scanned.ready:
+        matching = tuple(
+            line
+            for line in scanned.checks
+            if line.market == ready.market
+            and line.trade_date == ready.trade_date
+            and line.business_date == ready.business_date
+        )
+        await _process_ready(
+            business,
+            settings,
+            owned,
+            ready,
+            read_fn,
+            order_fn,
+            issue_fn,
+            sales_fn,
+            trs_fn,
+            send_fn,
+            matching,
+        )
+        await owned.silence_snapshots(ready.market, ready.business_date, _now())
+    for ready, cards in split_batch_runs(scanned.lines):
+        normalized = tuple(item for item in (_normalize_card(card) for card in cards) if item is not None)
+        await _process_existing_or_save(
+            settings,
+            owned,
+            ready,
+            normalized,
+            send_fn,
+            batch_no=None,
+            allow_new_cards=True,
+        )
+    if historical:
+        for blocked in scanned.blocked:
+            await _store_trs_blocked(settings, owned, blocked, send_fn)
+
+
+async def _process_ready(
+    business: QueryGateway,
+    settings: Settings,
+    store: SqlRunStore,
+    ready: ReadyRun,
+    read_fn: Callable[[QueryGateway, ReadRequest], Awaitable[ReadResult]],
+    order_fn: _OrderFn,
+    issue_fn: _ReportFn,
+    sales_fn: _ReportFn,
+    trs_fn: _ReportFn,
+    send_fn: _SendFn,
+    gate_checks: Sequence[ConclusionLine] = (),
+) -> None:
+    """已有同一轮次时不取数、不重算。新轮次先算完再一次性落库。"""
+
+    existing = await store.find_run_id(ready)
+    if existing is not None:
+        # 唯一键已在:不重新计算。已送达的卡跳过,失败和未送达的用原行重发。
+        logger.info("轮次已存在,不重新计算")
+        batch_no = await issue_batch_no(business, ready.issue_batch_id)
+        await _send_unsent(settings, store, existing, ready, send_fn, batch_no)
+        return
+    async with Stage("从业务库取数") as stage:
+        watched = WatchedGateway(business, stage)
+        result = await read_fn(watched, _request(ready))
+        stage.summary = f"{watched.count} 条查询,主订单 {len(result.orders)} 张"
+    payload = _payload(result)
+    # 规则是纯计算,放到线程里跑,事件循环空出来刷新进度。线程内不 await,输入已全部取齐。
+    async with Stage("核对订单和三端报表", total=4) as stage:
+        stage.note("订单规则")
+        order_lines = await asyncio.to_thread(order_fn, result)
+        stage.advance()
+        stage.note("发行端报表")
+        issue_lines = await asyncio.to_thread(issue_fn, payload)
+        stage.advance()
+        stage.note("销售端报表")
+        sales_lines = await asyncio.to_thread(sales_fn, payload)
+        stage.advance()
+        stage.note("TRS 报表")
+        trs_lines = await asyncio.to_thread(trs_fn, payload)
+        stage.advance()
+        gate_lines = tuple(_from_conclusion(line) for line in gate_checks)
+        compared = daycut_line(ready, daycut_mismatches(result.fund_accounts, result.missing_tables))
+        if compared is not None:
+            gate_lines = gate_lines + (_from_conclusion(compared),)
+        cards = assemble_cards(
+            order_lines,
+            issue_lines,
+            sales_lines,
+            trs_lines,
+            business_date=ready.business_date,
+            missing_tables=result.missing_tables,
+            gate_lines=gate_lines,
+        )
+        normalized = _attach_summaries(
+            tuple(item for item in (_normalize_card(card) for card in cards) if item is not None)
+        )
+        detail_count = sum(1 for card in normalized if card.card_kind != CARD_SUMMARY)
+        stage.summary = (
+            f"明细 {detail_count} 张,概况 {len(normalized) - detail_count} 张,"
+            f"结论行 {sum(len(card.lines) for card in normalized)} 条"
+        )
+    await _process_existing_or_save(settings, store, ready, normalized, send_fn, business=business)
+
+
+async def _process_existing_or_save(
+    settings: Settings,
+    store: SqlRunStore,
+    ready: ReadyRun,
+    cards: Sequence[CardDraft],
+    send_fn: _SendFn,
+    *,
+    business: QueryGateway | None = None,
+    batch_no: str | None = None,
+    allow_new_cards: bool = False,
+) -> None:
+    """先落库。并发下唯一键冲突则改为重发已有卡,不再插一行结论。概况和明细一起落,计数不含概况。"""
+
+    cards = _attach_summaries(cards)
+    existing = await store.find_run_id(ready)
+    if existing is not None:
+        logger.info("轮次已存在,不重新计算")
+        # 门禁快照的表名变了才追加卡。已完成轮次不在这里补结论。
+        if allow_new_cards:
+            await store.add_missing_cards(existing, ready, cards, _now())
+        shown = batch_no
+        if shown is None and business is not None:
+            shown = await issue_batch_no(business, ready.issue_batch_id)
+        await _send_unsent(settings, store, existing, ready, send_fn, shown)
+        return
+    total_lines = sum(len(card.lines) for card in cards)
+    async with Stage("写入自有库", total=total_lines) as stage:
+        run_id = await store.save_new_run(ready, cards, _now(), tick=stage.advance)
+        detail_count = sum(1 for card in cards if card.card_kind != CARD_SUMMARY)
+        stage.summary = f"明细 {detail_count} 张,概况 {len(cards) - detail_count} 张,结论行 {total_lines} 条"
+    if run_id is None:
+        found = await store.find_run_id(ready)
+        if found is None:
+            return
+        logger.info("轮次已存在,不重新计算")
+        run_id = found
+        if allow_new_cards:
+            await store.add_missing_cards(run_id, ready, cards, _now())
+    shown = batch_no
+    if shown is None and business is not None:
+        shown = await issue_batch_no(business, ready.issue_batch_id)
+    await _send_unsent(settings, store, run_id, ready, send_fn, shown)
+
+
+async def _send_unsent(
+    settings: Settings,
+    store: SqlRunStore,
+    run_id: int,
+    ready: ReadyRun,
+    send_fn: _SendFn,
+    batch_no: str | None,
+) -> None:
+    """先补齐并发送概况,再按主键升序发送明细。成功改 sent,失败改 failed,不新插结论行。"""
+
+    async with Stage("发送飞书卡片") as stage:
+        stage.note("补齐链路概况")
+        await store.ensure_summaries(run_id, ready, _now())
+        stage.note("读取待发送的卡片")
+        cards = _pending_order(await store.unsent_cards(run_id))
+        rows = await store.detail_rows(run_id)
+        owners = _owners_by_chain(rows)
+        stage.total = len(cards) or None
+        allowed = push_verdicts(settings)
+        tally = {NOTIFY_SENT: 0, NOTIFY_FAILED: 0, "skipped": 0}
+        for card in cards:
+            stage.note(f"{card.chain_name} {card.biz_key}")
+            tally[
+                await _send_one(
+                    settings,
+                    store,
+                    card,
+                    ready,
+                    send_fn,
+                    batch_no,
+                    allowed=allowed,
+                    owners=owners.get(card.chain_name, ()),
+                )
+            ] += 1
+            stage.advance()
+        stage.summary = (
+            f"已发送 {tally[NOTIFY_SENT]} 张,失败 {tally[NOTIFY_FAILED]} 张,"
+            f"未推送 {tally['skipped']} 张(无结论行、无归属或不在推送配置内)"
+        )
+
+
+async def _send_outbound(settings: Settings, card: OutboundCard) -> SendResult:
+    """明细走原版式。概况另走概况标题和正文,避免空结论行走明细的「没有结论行,不发送」。"""
+
+    if card.card_kind == CARD_SUMMARY:
+        return await send_summary(settings, card)
+    return await send_card(settings, card)
+
+
+def _owners_by_chain(
+    rows: Sequence[tuple[str, str, str, str]],
+) -> dict[str, tuple[SummaryOwner, ...]]:
+    """明细卡按链路收成概况用的归属。概况行自己不进名单。正文只列主键,不带结论行。"""
+
+    grouped: dict[str, list[SummaryOwner]] = {}
+    for kind, chain_name, biz_key, verdict in rows:
+        if kind == CARD_SUMMARY:
+            continue
+        grouped.setdefault(chain_name, []).append(SummaryOwner(kind, biz_key, verdict))
+    return {chain_name: tuple(items) for chain_name, items in grouped.items()}
+
+
+async def _send_one(
+    settings: Settings,
+    store: SqlRunStore,
+    card: StoredCard,
+    ready: ReadyRun,
+    send_fn: _SendFn,
+    batch_no: str | None,
+    *,
+    allowed: frozenset[str] | None,
+    owners: Sequence[SummaryOwner] = (),
+) -> str:
+    """发一张卡。成功改 sent,失败改 failed;没发时返回 skipped。返回值只给进度汇总用。
+
+    概况卡不看推送配置:配置成只推错误时,正确链路的概况仍然先发。过滤只留在明细卡上。
+    """
+
+    summary = card.card_kind == CARD_SUMMARY
+    if summary:
+        if not owners:
+            return "skipped"
+    elif not card.lines:
+        return "skipped"
+    elif allowed is not None and card.verdict not in allowed:
+        label = _VERDICT_LABEL.get(card.verdict, card.verdict)
+        logger.info(
+            "飞书卡片不推送,{} {} {},结论 {} 不在推送配置内",
+            card.chain_name,
+            card.card_kind,
+            card.biz_key,
+            label,
+        )
+        return "skipped"
+    shown = batch_no if batch_no is not None else card.biz_key
+    outbound = OutboundCard(
+        card_kind=card.card_kind,
+        chain_name=card.chain_name,
+        biz_key=card.biz_key,
+        business_date=ready.business_date,
+        trade_date=ready.trade_date,
+        batch_no=shown,
+        lines=card.lines,
+        record_no=str(card.id),
+        resend=card.notify_status == NOTIFY_FAILED,
+        owners=tuple(owners) if summary else (),
+    )
+    try:
+        result = await send_fn(settings, outbound)
+    except FeishuError as exc:
+        logger.error("飞书卡片未能发送:{}{}", exc, push_log_suffix(settings))
+        await store.set_notify(card.id, NOTIFY_FAILED, _now())
+        return NOTIFY_FAILED
+    except Exception as exc:
+        logger.error(
+            "飞书卡片未能发送:{}{}",
+            describe_exception(exc, *secret_values(settings)),
+            push_log_suffix(settings),
+        )
+        await store.set_notify(card.id, NOTIFY_FAILED, _now())
+        return NOTIFY_FAILED
+    status = NOTIFY_SENT if result.ok else NOTIFY_FAILED
+    await store.set_notify(card.id, status, _now())
+    return status
+
+
+def _card_dedupe(ready: ReadyRun, card: CardDraft) -> str:
+    return dedupe_key(
+        ready,
+        card.card_kind,
+        card.biz_key,
+        chain_name=card.chain_name,
+        snapshot_token=card.snapshot_token,
+    )
+
+
+def _missing_table_line(table: str) -> CheckLine:
+    """1146 单独成行,归核对门禁。不改各张报表的公式。"""
+
+    line = CheckLine(
+        formula_text=GATE_MISSING_TABLE,
+        calc_text="读取该表时返回 1146",
+        calc_value=None,
+        check_text=table,
+        check_value=None,
+        basis="1146",
+        verdict=VERDICT_ERROR,
+    )
+    return hang((line,), CHAIN_GATE, SIDE_NONE)[0]
+
+
+def _snapshot_token(lines: Sequence[ConclusionLine]) -> str:
+    """发行或销售加上有行的表名。集合变化则幂等键变化。"""
+
+    bits: list[str] = []
+    for line in lines:
+        if not line.side_name and not line.report_tables:
+            continue
+        tables = ",".join(line.report_tables)
+        bits.append(f"{line.side_name}:{tables}")
+    return "|".join(sorted(bits))
+
+
+def _log_trs_blocked(scanned: GateScan) -> None:
+    """每次跳过都写明是 TRS 未完成,不和批次还没跑完混成同一句。"""
+
+    for blocked in scanned.blocked:
+        logger.info(
+            "市场 {} 交易日 {} 营业日 {} 有对冲,TRS 批次未完成,发行和销售本轮不核对",
+            blocked.market,
+            blocked.trade_date.isoformat(),
+            blocked.business_date.isoformat(),
+        )
+
+
+async def _store_trs_blocked(
+    settings: Settings,
+    store: SqlRunStore,
+    blocked: TrsBlocked,
+    send_fn: _SendFn,
+) -> None:
+    """早于今天的挡住记一张错误卡。同一市场同一交易日只保留一轮。"""
+
+    line = trs_blocked_line(blocked)
+    draft = CardDraft(
+        CHAIN_GATE,
+        CARD_BATCH,
+        line.biz_key,
+        VERDICT_ERROR,
+        (_from_conclusion(line),),
+        snapshot_token="TRS未完成",
+    )
+    ready = _synthetic_run(blocked.market, blocked.trade_date, blocked.business_date)
+    normalized = _normalize_card(draft)
+    if normalized is None:
+        return
+    await _process_existing_or_save(
+        settings,
+        store,
+        ready,
+        (normalized,),
+        send_fn,
+        batch_no=None,
+        allow_new_cards=True,
+    )
+
+
+def _from_conclusion(line: ConclusionLine) -> CheckLine:
+    """门禁结论行挂核对门禁。依据换成研发设计里的门禁口径,原说明挪到代入式后面。"""
+
+    row = CheckLine(
+        formula_text=line.formula_text,
+        calc_text=line.calc_text,
+        calc_value=line.calc_value,
+        check_text=line.check_text,
+        check_value=line.check_value,
+        basis=line.basis,
+        verdict=line.verdict,
+    )
+    return hang((row,), CHAIN_GATE, SIDE_NONE)[0]
+
+
+def _batch_identity(line: ConclusionLine) -> tuple[str, date, date]:
+    """市场和营业日来自结论行字段。没有交易日时用营业日。"""
+
+    if line.business_date is None:
+        raise ValueError("批次结论行没有营业日")
+    market = line.market
+    if len(market) > 8:
+        raise ValueError("批次结论行的市场超过 8 位")
+    trade_date = line.trade_date or line.business_date
+    return market, trade_date, line.business_date
+
+
+def _synthetic_run(market: str, trade_date: date, business_date: date) -> ReadyRun:
+    """门禁还没有完成批次。三端 id 用 0,结束时间用哨兵,避免和已完成轮次撞键。"""
+
+    return ReadyRun(
+        market=market,
+        trade_date=trade_date,
+        business_date=business_date,
+        issue_batch_id=0,
+        sales_batch_id=0,
+        trs_batch_id=0,
+        issue_end_time=NO_HEDGE_END_TIME,
+        sales_end_time=NO_HEDGE_END_TIME,
+        trs_end_time=NO_HEDGE_END_TIME,
+        issue_applied_revision=0,
+        sales_applied_revision=0,
+        trs_applied_revision=0,
+    )
+
+
+def _normalize_card(card: CardDraft) -> CardDraft | None:
+    if card.card_kind not in _KINDS:
+        raise ValueError("卡片种类不在四种之内")
+    chain = CHAINS.get(card.chain_name)
+    if chain is None:
+        raise ValueError("卡片链路不在登记表之内")
+    if card.card_kind not in chain.owners:
+        raise ValueError("卡片归属不在该链路登记的归属之内")
+    stored, original = _fit_biz_key(card.biz_key)
+    raw_lines = card.lines
+    if original and raw_lines:
+        first = raw_lines[0]
+        raw_lines = (replace(first, formula_text=f"原业务键:{original}\n{first.formula_text}"),) + tuple(raw_lines[1:])
+    lines = tuple(_normalize_line(line) for line in raw_lines)
+    if not lines:
+        return None
+    return CardDraft(
+        card.chain_name,
+        card.card_kind,
+        stored,
+        summarize(lines),
+        lines,
+        snapshot_token=card.snapshot_token,
+    )
+
+
+def _normalize_line(line: CheckLine) -> CheckLine:
+    if line.verdict not in _VERDICTS:
+        raise ValueError("结论不在 correct、error、suspect 之内")
+    # 正文不是幂等键。超长时按列宽截断并标明,避免这一行写不进去把整轮退回重试。
+    return replace(
+        line,
+        formula_text=_clip(line.formula_text, 500),
+        calc_text=_clip(line.calc_text, 1000),
+        check_text=_clip(line.check_text, 1000),
+        basis=_clip(line.basis, 500),
+    )
+
+
+def _fit_biz_key(value: str) -> tuple[str, str]:
+    """放得下时原键为空。超长时入库键稳定,原键写进卡片正文。"""
+
+    if len(value) <= 64:
+        return value, ""
+    digest = hashlib.sha256(value.encode("utf-8")).hexdigest()
+    return f"{value[:39]}~{digest[:24]}", value
+
+
+def _clip(text: str, limit: int) -> str:
+    if len(text) <= limit:
+        return text
+    mark = _CLIP_MARK
+    if limit <= len(mark):
+        return text[:limit]
+    return text[: limit - len(mark)] + mark
+
+
+def _counts(cards: Sequence[CardDraft]) -> tuple[int, int, int]:
+    """按明细卡片数计,不按结论行数,也不把链路概况算进去。"""
+
+    correct = error = suspect = 0
+    for card in cards:
+        if card.card_kind == CARD_SUMMARY:
+            continue
+        if card.verdict == VERDICT_CORRECT:
+            correct += 1
+        elif card.verdict == VERDICT_ERROR:
+            error += 1
+        elif card.verdict == VERDICT_SUSPECT:
+            suspect += 1
+        else:
+            raise ValueError("卡片结论不在 correct、error、suspect 之内")
+    return correct, error, suspect
+
+
+def _request(ready: ReadyRun) -> ReadRequest:
+    return ReadRequest(
+        market=ready.market,
+        trade_date=ready.trade_date,
+        business_date=ready.business_date,
+        issue_batch_id=ready.issue_batch_id,
+        sales_batch_id=ready.sales_batch_id,
+        trs_batch_id=ready.trs_batch_id,
+    )
+
+
+def _payload(result: ReadResult) -> dict[str, Any]:
+    """把取数结果交给报表规则。没有读到的来源不在这里补。"""
+
+    return {
+        "market": result.market,
+        "trade_date": result.trade_date,
+        "business_date": result.business_date,
+        "orders": result.orders,
+        "fund_accounts": result.fund_accounts,
+        "trs_users": result.trs_users,
+        "sources": result.sources,
+        "loose_reports": result.loose_reports,
+        "unmapped_ledgers": result.unmapped_ledgers,
+        # 票据已卖出合计用的往日订单,不在 orders 里。
+        "note_orders": result.note_orders,
+        "note_copies": result.note_copies,
+        "latest_prices": result.latest_prices,
+        "missing_tables": result.missing_tables,
+        "locations": result.locations,
+        "stages": result.stages,
+        "issue_stages": result.issue_stages,
+        "baseline_kind": result.baseline_kind,
+        "baseline_notes": result.baseline_notes,
+        "live_notes_loaded": result.live_notes_loaded,
+        "live_note_subs": result.live_note_subs,
+        "live_note_masters": result.live_note_masters,
+        "live_topups": result.live_topups,
+        "live_extensions": result.live_extensions,
+        "live_positions": result.live_positions,
+        "cleared_positions": result.cleared_positions,
+        "sales_note_loaded": result.sales_note_loaded,
+        "sales_current_notes": result.sales_current_notes,
+        "sales_cleared_notes": result.sales_cleared_notes,
+        "sales_fund_loaded": result.sales_fund_loaded,
+        "sales_orders": result.sales_orders,
+        "sales_settle_day": result.sales_settle_day,
+        "sales_baseline_state": result.sales_baseline_state,
+        "trs_steps_loaded": result.trs_steps_loaded,
+    }
+
+
+def _run_key(ready: ReadyRun) -> tuple[Any, ...]:
+    return (
+        ready.market,
+        ready.trade_date,
+        ready.issue_batch_id,
+        ready.sales_batch_id,
+        ready.trs_batch_id,
+        ready.issue_end_time,
+        ready.sales_end_time,
+        ready.trs_end_time,
+        rule_pack_version(),
+    )
+
+
+def _line_from_row(row: tuple[Any, ...]) -> CheckLine:
+    report_code = str(row[7]) if len(row) > 7 else ""
+    master_order_no = str(row[8]) if len(row) > 8 else ""
+    fund_account = str(row[9]) if len(row) > 9 else ""
+    user_account = str(row[10]) if len(row) > 10 else ""
+    return CheckLine(
+        formula_text=str(row[0]),
+        calc_text=str(row[1]),
+        calc_value=row[2],
+        check_text=str(row[3]),
+        check_value=row[4],
+        basis=str(row[5]),
+        verdict=str(row[6]),
+        report_code=report_code,
+        master_order_no=master_order_no,
+        fund_account=fund_account,
+        user_account=user_account,
+    )
+
+
+def _clock_text(value: datetime) -> str:
+    return value.strftime("%Y-%m-%d %H:%M:%S")
+
+
+def _now() -> datetime:
+    """自有库的创建时间用上海墙钟,不跟容器的 UTC。"""
+
+    return shanghai_wall_now()
+
+
+def _duplicate(exc: IntegrityError) -> bool:
+    args = getattr(exc, "args", ())
+    if not args:
+        return False
+    return args[0] == 1062

+ 371 - 0
code/post_clear_check/src/post_clear_check/order_cell.py

@@ -0,0 +1,371 @@
+"""结论行,以及从子单、票据和回写日志里取被核对的数字。"""
+
+from __future__ import annotations
+
+import json
+import re
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass, replace
+from decimal import Decimal
+from typing import Any
+
+from loguru import logger
+
+from post_clear_check.columns import LIVE_SOURCE, ORDER_SUB_TABLE
+from post_clear_check.order_decimal import (
+    VERDICT_CORRECT,
+    VERDICT_ERROR,
+    VERDICT_SUSPECT,
+    as_decimal,
+    equal_verdict,
+    format_decimal,
+    quantize_optional,
+)
+
+NOTE_SUB = "note_sub_a"
+ORDER_SUB = "order_sub_a"
+
+# 收卡只读这些字段。正文里的同名短语只在构造时用来补空字段。
+_ORDER_MARK = re.compile(r"主订单编号 ([^()()\s,,;;]+)")
+_FUND_MARK = re.compile(r"资金账户 ([^()()\s,,;;]+)")
+_USER_MARK = re.compile(r"TRS用户账号 ([^()()\s,,;;]+)")
+_REPORT_CODE = re.compile(r"(?<![A-Za-z0-9])(OTC\d{3}|TRS\d{3})(?!\d)")
+
+
+@dataclass(frozen=True)
+class CheckLine:
+    """一条公式核对。verdict 只用 correct、error、suspect。
+
+    归属字段给收卡用。空串表示这条正文里没有该键。
+    chain_name 是业务链路代码,在调用核对函数的地方打上;空串表示还没挂链。
+    """
+
+    formula_text: str
+    calc_text: str
+    calc_value: Decimal | None
+    check_text: str
+    check_value: Decimal | None
+    basis: str
+    verdict: str
+    report_code: str = ""
+    master_order_no: str = ""
+    fund_account: str = ""
+    user_account: str = ""
+    chain_name: str = ""
+
+
+def own_fund(lines: list[CheckLine], produced: Sequence[CheckLine], fund_no: str | None) -> None:
+    """把一段结论行接到 lines 后面。没有资金账户的行补上报表行或来源记录的资金账户,已有的不改。
+
+    只补归属字段,不动公式、数值和结论。
+    """
+
+    for line in produced:
+        if fund_no and not line.fund_account:
+            line = replace(line, fund_account=fund_no)
+        lines.append(line)
+
+
+def read_place(check_text: str, formula_text: str = "") -> tuple[str, str, str, str]:
+    """从刚写好的说明里取出报表编号和业务键。同一个键出现多次则留空,避免收进两张卡。"""
+
+    orders = list(dict.fromkeys(_ORDER_MARK.findall(check_text)))
+    funds = list(dict.fromkeys(_FUND_MARK.findall(check_text)))
+    users = list(dict.fromkeys(_USER_MARK.findall(check_text)))
+    matched = _REPORT_CODE.search(f"{check_text}\n{formula_text}")
+    report = matched.group(1) if matched else ""
+    master = orders[0] if len(orders) == 1 else ""
+    fund = funds[0] if len(funds) == 1 else ""
+    user = users[0] if len(users) == 1 else ""
+    return report, master, fund, user
+
+
+@dataclass(frozen=True)
+class Cell:
+    """被核对列或公式输入。absent 表示列上没有可用数字。"""
+
+    number: Decimal | None
+    absent: bool
+    live: bool
+    conflict: bool
+    writeback: bool = False
+
+
+def finish_cell(
+    lines: list[CheckLine],
+    *,
+    formula: str,
+    basis: str,
+    calc_text: str,
+    calc_value: Decimal | None,
+    cell: Cell,
+    table: str,
+    label: str,
+    column: str,
+    scale: int,
+) -> None:
+    if cell.conflict:
+        verdict = VERDICT_SUSPECT
+        check_value = None
+    elif cell.absent or cell.number is None:
+        verdict = VERDICT_ERROR
+        check_value = None
+    elif calc_value is None:
+        verdict = VERDICT_SUSPECT
+        check_value = cell.number
+    else:
+        verdict = equal_verdict(calc_value, cell.number, scale)
+        check_value = cell.number
+    finish(
+        lines,
+        formula=formula,
+        basis=basis,
+        calc_text=calc_text,
+        calc_value=calc_value,
+        check_text=cell_text(table, label, column, cell, scale),
+        check_value=check_value,
+        verdict=verdict,
+        live=cell.live,
+        scale=scale,
+    )
+
+
+def finish_number(
+    lines: list[CheckLine],
+    *,
+    formula: str,
+    basis: str,
+    calc_text: str,
+    calc_value: Decimal | None,
+    cell: Cell,
+    label: str,
+    column: str,
+    scale: int,
+    extra_live: bool = False,
+    force: str | None = None,
+) -> None:
+    if force is not None:
+        verdict = force
+        check_value = None if cell.absent or cell.number is None or cell.conflict else cell.number
+    elif cell.conflict:
+        verdict = VERDICT_SUSPECT
+        check_value = None
+    elif cell.absent or cell.number is None:
+        verdict = VERDICT_ERROR
+        check_value = None
+    elif calc_value is None:
+        verdict = VERDICT_SUSPECT
+        check_value = cell.number
+    else:
+        verdict = equal_verdict(calc_value, cell.number, scale)
+        check_value = cell.number
+    finish(
+        lines,
+        formula=formula,
+        basis=basis,
+        calc_text=calc_text,
+        calc_value=calc_value,
+        check_text=cell_text(ORDER_SUB_TABLE, label, column, cell, scale),
+        check_value=check_value,
+        verdict=verdict,
+        live=cell.live or extra_live,
+        scale=scale,
+    )
+
+
+def finish(
+    lines: list[CheckLine],
+    *,
+    formula: str,
+    basis: str,
+    calc_text: str,
+    calc_value: Decimal | None,
+    check_text: str,
+    check_value: Decimal | None,
+    verdict: str,
+    live: bool,
+    scale: int | None = None,
+    report_code: str = "",
+    master_order_no: str = "",
+    fund_account: str = "",
+    user_account: str = "",
+) -> None:
+    # 副本没读到时,live 上的数字不能当成清算结果。
+    if live and verdict in {VERDICT_CORRECT, VERDICT_ERROR}:
+        verdict = VERDICT_SUSPECT
+    if scale is not None:
+        calc_value = quantize_optional(calc_value, scale)
+        check_value = quantize_optional(check_value, scale)
+    # 调用方已经传入的键优先。没传的再从本次说明里补,收卡不再读正文。
+    parsed_report, parsed_order, parsed_fund, parsed_user = read_place(check_text, formula)
+    lines.append(
+        CheckLine(
+            formula_text=formula,
+            calc_text=calc_text,
+            calc_value=calc_value,
+            check_text=check_text,
+            check_value=check_value,
+            basis=basis,
+            verdict=verdict,
+            report_code=report_code or parsed_report,
+            master_order_no=master_order_no or parsed_order,
+            fund_account=fund_account or parsed_fund,
+            user_account=user_account or parsed_user,
+        )
+    )
+
+
+def read_cell(
+    row: Mapping[str, Any],
+    column: str,
+    table: str,
+    logs: Sequence[tuple[str, str, Mapping[str, Any]]],
+    sources: Mapping[str, str],
+) -> Cell:
+    """回写日志有该列时用 after_val,否则用行上的值。"""
+
+    numbers: list[Decimal | None] = []
+    for entity, biz, payload in logs:
+        if entity != table and not entity.endswith("." + table):
+            continue
+        if biz == "" or biz not in row_keys(row):
+            continue
+        if column not in payload:
+            continue
+        numbers.append(as_decimal(payload.get(column)))
+    if numbers:
+        if not _same(numbers):
+            return Cell(None, False, False, True, False)
+        number = numbers[-1]
+        if number is None:
+            return Cell(None, True, False, False, True)
+        return Cell(number, False, False, False, True)
+    live = sources.get(table) == LIVE_SOURCE
+    number = as_decimal(row.get(column)) if column in row else None
+    if number is None:
+        return Cell(None, True, live, False, False)
+    return Cell(number, False, live, False, False)
+
+
+def stored_cell(row: Mapping[str, Any], column: str) -> Cell:
+    """只用行上的数字。不读回写,也不把来源标成 live。"""
+
+    if column not in row:
+        return Cell(None, True, False, False, False)
+    number = as_decimal(row.get(column))
+    if number is None:
+        return Cell(None, True, False, False, False)
+    return Cell(number, False, False, False, False)
+
+
+def read_note_cell(
+    note: Mapping[str, Any] | None,
+    column: str,
+    logs: Sequence[tuple[str, str, Mapping[str, Any]]],
+    sources: Mapping[str, str],
+) -> Cell:
+    if note is None:
+        return Cell(None, True, False, False, False)
+    return read_cell(note, column, NOTE_SUB, logs, sources)
+
+
+def parse_logs(
+    logs: Sequence[Mapping[str, Any]],
+) -> list[tuple[str, str, Mapping[str, Any]]]:
+    parsed: list[tuple[str, str, Mapping[str, Any]]] = []
+    for log in logs:
+        entity = log.get("entity_type")
+        payload = _parse_after(log.get("after_val"))
+        if not isinstance(entity, str) or payload is None:
+            continue
+        parsed.append((entity, biz_text(log.get("biz_key")), payload))
+    return parsed
+
+
+def row_label(row: Mapping[str, Any]) -> str:
+    sub_no = row.get("sub_order_no")
+    if isinstance(sub_no, str) and sub_no.strip():
+        return sub_no.strip()
+    ident = row.get("id")
+    if isinstance(ident, int) and not isinstance(ident, bool):
+        return str(ident)
+    return "-"
+
+
+def cell_text(table: str, label: str, column: str, cell: Cell, scale: int) -> str:
+    head = f"{table}.{label}.{column}"
+    if cell.conflict:
+        return f"{head} 回写 after_val 不一致"
+    suffix = "(回写 after_val)" if cell.writeback else ""
+    if cell.absent or cell.number is None:
+        return f"{head} 缺失{suffix}"
+    return f"{head} = {format_decimal(cell.number, scale)}{suffix}"
+
+
+def plain_text(table: str, label: str, column: str, number: Decimal | None, scale: int) -> str:
+    head = f"{table}.{label}.{column}"
+    if number is None:
+        return f"{head} 缺失"
+    return f"{head} = {format_decimal(number, scale)}"
+
+
+def manual_flag(sub: Mapping[str, Any]) -> int | None:
+    """1 表示订单成交均价被人工改过。读不到 0 或 1 就当没有标志。"""
+
+    if "order_avg_px_manual_flag" not in sub:
+        return None
+    value = sub.get("order_avg_px_manual_flag")
+    if isinstance(value, bool) or value is None:
+        return None
+    if isinstance(value, int) and value in {0, 1}:
+        return value
+    if isinstance(value, Decimal) and value in {Decimal(0), Decimal(1)}:
+        return int(value)
+    if isinstance(value, str) and value.strip() in {"0", "1"}:
+        return int(value.strip())
+    return None
+
+
+def _parse_after(value: object) -> Mapping[str, Any] | None:
+    if isinstance(value, Mapping):
+        return value
+    if isinstance(value, bytes):
+        value = value.decode("utf-8", errors="replace")
+    if not isinstance(value, str) or value.strip() == "":
+        return None
+    try:
+        # 金额必须是十进制。默认 json 会把小数读成二进制浮点。
+        payload = json.loads(value, parse_float=Decimal, parse_int=Decimal)
+    except json.JSONDecodeError:
+        logger.warning("回写 after_val 不是 JSON 对象")
+        return None
+    if not isinstance(payload, dict):
+        logger.warning("回写 after_val 不是 JSON 对象")
+        return None
+    return payload
+
+
+def row_keys(row: Mapping[str, Any]) -> set[str]:
+    found: set[str] = set()
+    for field in ("id", "sub_order_no", "order_no", "note_no", "sub_note_no", "src_order_no"):
+        text = biz_text(row.get(field))
+        if text:
+            found.add(text)
+    return found
+
+
+def biz_text(value: object) -> str:
+    if isinstance(value, bool) or value is None:
+        return ""
+    if isinstance(value, int):
+        return str(value)
+    if isinstance(value, Decimal) and value == value.to_integral_value():
+        return str(int(value))
+    if isinstance(value, str):
+        return value.strip()
+    return ""
+
+
+def _same(values: Sequence[Decimal | None]) -> bool:
+    first = values[0]
+    return all(item == first for item in values[1:])

+ 195 - 0
code/post_clear_check/src/post_clear_check/order_decimal.py

@@ -0,0 +1,195 @@
+"""订单金额的十进制运算。比较前按列标度四舍五入,不用二进制浮点。"""
+
+from __future__ import annotations
+
+from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
+
+# 金额 2 位,价格、汇率、股数 6 位。
+SCALE_AMT = 2
+SCALE_PX = 6
+SCALE_FX = 6
+SCALE_QTY = 6
+
+VERDICT_CORRECT = "correct"
+VERDICT_ERROR = "error"
+VERDICT_SUSPECT = "suspect"
+
+
+def underlying_amount(
+    sell_qty: Decimal,
+    avg_px: Decimal,
+    fx_settle: Decimal,
+    fx_direction: str,
+    init_px: Decimal,
+    init_fx: Decimal,
+    init_direction: str,
+    dividend: Decimal,
+) -> Decimal | None:
+    """底层资产盈亏。两个汇率各用各的方向,方向不明时返回空。"""
+
+    gross = apply_fx(sell_qty * avg_px, fx_settle, fx_direction)
+    cost = apply_fx(sell_qty * init_px, init_fx, init_direction)
+    if gross is None or cost is None:
+        return None
+    return gross - cost + dividend
+
+
+def total_amount(
+    sold_face: Decimal,
+    sold_add: Decimal,
+    pnl: Decimal,
+    yield_ratio: Decimal | None,
+) -> Decimal | None:
+    """总金额。盈利才乘收益比例;收益比例缺失时返回空。亏损时不低于 0。
+
+    yield_ratio 是百分数(库里 a_yield_ratio 的单位是 %,10 表示 10%),乘之前除以 100。
+    """
+
+    if pnl > 0:
+        if yield_ratio is None:
+            return None
+        return sold_face + sold_add + pnl * yield_ratio / Decimal(100)
+    return max(Decimal(0), sold_face + sold_add + pnl)
+
+
+def judge_rate(
+    trade: Decimal,
+    settle: Decimal,
+    fx: Decimal,
+) -> tuple[str, Decimal | None, str]:
+    """由两边累计发生金额反算汇率。两个方向都对得上则可疑,都对不上则错误。"""
+
+    stored = quantize(fx, SCALE_FX)
+    mul = None if trade == 0 else settle / trade
+    div = None if settle == 0 else trade / settle
+    parts: list[str] = []
+    matches: list[Decimal] = []
+    if mul is None:
+        parts.append("结算币种金额 / 交易币种金额无法计算")
+    else:
+        mul_q = quantize(mul, SCALE_FX)
+        parts.append(f"结算币种金额 / 交易币种金额 = {format_decimal(mul_q, SCALE_FX)}")
+        if mul_q == stored:
+            matches.append(mul_q)
+    if div is None:
+        parts.append("交易币种金额 / 结算币种金额无法计算")
+    else:
+        div_q = quantize(div, SCALE_FX)
+        parts.append(f"交易币种金额 / 结算币种金额 = {format_decimal(div_q, SCALE_FX)}")
+        if div_q == stored:
+            matches.append(div_q)
+    text = ";".join(parts)
+    if mul is None and div is None:
+        return VERDICT_SUSPECT, None, f"{text};两个方向都无法算"
+    if len(matches) >= 2:
+        return VERDICT_SUSPECT, stored, f"{text};两个方向都对得上"
+    if len(matches) == 1:
+        return VERDICT_CORRECT, matches[0], text
+    return VERDICT_ERROR, None, f"{text};两个方向都对不上"
+
+
+def judge_convert(
+    trade: Decimal,
+    fx: Decimal,
+    settle: Decimal,
+) -> tuple[str, Decimal | None, str]:
+    """换算结果在金额标度内只有一个方向相等才算正确。"""
+
+    stored = quantize(settle, SCALE_AMT)
+    mul_q = quantize(trade * fx, SCALE_AMT)
+    mul_text = f"交易币种金额 × 结算汇率 = {format_decimal(mul_q, SCALE_AMT)}"
+    if fx == 0:
+        div_q = None
+        div_text = "交易币种金额 / 结算汇率无法计算"
+    else:
+        div_q = quantize(trade / fx, SCALE_AMT)
+        div_text = f"交易币种金额 / 结算汇率 = {format_decimal(div_q, SCALE_AMT)}"
+    mul_ok = mul_q == stored
+    div_ok = div_q is not None and div_q == stored
+    if mul_ok and div_ok:
+        return VERDICT_SUSPECT, mul_q, f"{mul_text};{div_text};两个方向都对得上"
+    if mul_ok:
+        return VERDICT_CORRECT, mul_q, mul_text
+    if div_ok:
+        return VERDICT_CORRECT, div_q, div_text
+    return VERDICT_ERROR, None, f"{mul_text};{div_text};两个方向都对不上"
+
+
+def direction_of(trade: Decimal, fx: Decimal, settle: Decimal, scale: int) -> str:
+    """三列只能确定一个方向时返回 mul 或 div。"""
+
+    stored = quantize(settle, scale)
+    mul_ok = quantize(trade * fx, scale) == stored
+    div_ok = fx != 0 and quantize(trade / fx, scale) == stored
+    if mul_ok and div_ok:
+        return "both"
+    if mul_ok:
+        return "mul"
+    if div_ok:
+        return "div"
+    return "neither"
+
+
+def apply_fx(amount: Decimal, fx: Decimal, fx_direction: str) -> Decimal | None:
+    if fx_direction == "mul":
+        return amount * fx
+    if fx_direction == "div" and fx != 0:
+        return amount / fx
+    return None
+
+
+def equal_verdict(calc: Decimal | None, check: Decimal | None, scale: int) -> str:
+    if calc is None or check is None:
+        return VERDICT_SUSPECT
+    if quantize(calc, scale) == quantize(check, scale):
+        return VERDICT_CORRECT
+    return VERDICT_ERROR
+
+
+def quantize(value: Decimal, scale: int) -> Decimal:
+    exponent = Decimal(1).scaleb(-scale)
+    return value.quantize(exponent, rounding=ROUND_HALF_UP)
+
+
+def quantize_optional(value: Decimal | None, scale: int) -> Decimal | None:
+    if value is None:
+        return None
+    return quantize(value, scale)
+
+
+def format_decimal(value: Decimal | None, scale: int) -> str:
+    if value is None:
+        return "缺失"
+    return format(quantize(value, scale), "f")
+
+
+def join_decimals(parts: list[Decimal] | tuple[Decimal, ...], scale: int) -> str:
+    if not parts:
+        return format_decimal(Decimal(0), scale)
+    body = " + ".join(format_decimal(part, scale) for part in parts)
+    total = sum(parts, Decimal(0))
+    return f"{body} = {format_decimal(total, scale)}"
+
+
+def as_decimal(value: object) -> Decimal | None:
+    """只接受十进制、整数和数字字符串。二进制浮点不能参与比较。"""
+
+    if isinstance(value, bool) or value is None:
+        return None
+    if isinstance(value, Decimal):
+        number = value
+    elif isinstance(value, int):
+        number = Decimal(value)
+    elif isinstance(value, str):
+        text = value.strip()
+        if text == "":
+            return None
+        try:
+            number = Decimal(text)
+        except InvalidOperation:
+            return None
+    else:
+        return None
+    if not number.is_finite():
+        return None
+    return number

+ 1259 - 0
code/post_clear_check/src/post_clear_check/order_rules.py

@@ -0,0 +1,1259 @@
+"""订单规则。
+
+输入是取数包里的一张主单。输出是这张主单的结论行。
+不读报表,不发飞书,不写库。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+
+from loguru import logger
+from dataclasses import dataclass
+from decimal import Decimal
+from typing import Any
+
+from post_clear_check.buy_execution import append_buy
+from post_clear_check.sell_execution import append_sell
+from post_clear_check.columns import (
+    HEDGE_TABLE,
+    ORDER_SUB_TABLE,
+    TRADE_ALGO_MASTER_TABLE,
+    TRADE_ORDER_TABLE,
+)
+from post_clear_check.hedge_link import (
+    KIND_NEVER,
+    KIND_ROOT,
+    KIND_SKIP,
+    TABLE_ALGO,
+    TABLE_ORDER,
+    HedgeLink,
+    classify_hedge,
+    hedge_label,
+)
+from post_clear_check.order_cell import (
+    Cell,
+    CheckLine,
+    finish,
+    finish_cell,
+    finish_number,
+    manual_flag,
+    cell_text,
+    plain_text,
+    read_cell,
+    read_note_cell,
+    row_label,
+)
+from post_clear_check.order_decimal import (
+    SCALE_AMT,
+    SCALE_FX,
+    SCALE_PX,
+    SCALE_QTY,
+    VERDICT_ERROR,
+    VERDICT_SUSPECT,
+    as_decimal,
+    equal_verdict,
+    format_decimal,
+    join_decimals,
+    judge_convert,
+    judge_rate,
+    quantize,
+    quantize_optional,
+    total_amount,
+)
+
+_ORDER_PREFIX = "ORD"
+_ORDER_STATUSES = frozenset({"completed", "settled"})
+_SELL_OP_TYPES = frozenset({"force", "maturity"})
+# 取数结果里对冲记录使用 filled 表示这笔对冲已经完成。说明没有另给状态枚举。
+_HEDGE_DONE = "filled"
+_HEDGE_QTY_FORMULA = "对冲记录成交股数 = TRS 执行根的成交股数"
+_QUALIFIED_ROOT = {
+    TABLE_ORDER: TRADE_ORDER_TABLE,
+    TABLE_ALGO: TRADE_ALGO_MASTER_TABLE,
+}
+
+
+@dataclass(frozen=True)
+class _Sums:
+    """对冲腿汇总。股数和金额都来自对冲记录本身,不取 TRS 执行根。"""
+
+    trade_amt: Decimal | None
+    settle_amt: Decimal | None
+    hedge_qty: Decimal | None
+    amounts_ready: bool
+    qty_ready: bool
+    trade_text: str
+    settle_text: str
+    qty_text: str
+
+
+def check_order(
+    order: Mapping[str, Any],
+    sources: Mapping[str, str] | None = None,
+) -> list[CheckLine]:
+    """核对一张主单。买卖的结算口径不再按 sources 把对上的行降成可疑。"""
+
+    if not isinstance(order, Mapping):
+        raise TypeError("order 必须是主单字典")
+    master = order.get("master")
+    if not isinstance(master, Mapping) or not _in_population(master):
+        return []
+    side = _side(master)
+    if side is None:
+        return []
+    del sources
+    subs = _rows(order.get("order_sub_a"))
+    hedges = _rows(order.get("hedge_record"))
+    trades = _rows(order.get("trade_order"))
+    algos = _rows(order.get("trade_algo_master"))
+    notes = _rows(order.get("note_sub_a"))
+    lines: list[CheckLine] = []
+    _append_hedge_lines(lines, hedges, trades, algos)
+    # 两条都是执行事实读 input、结算口径读 work/3,公式不同,分开走。
+    if side == "buy":
+        append_buy(lines, order, subs)
+        return lines
+    append_sell(lines, order, subs)
+    return lines
+
+
+def _in_population(master: Mapping[str, Any]) -> bool:
+    """只核对 ORD 开头、已完成或已结算的主单。FS、MS 不出结论。"""
+
+    order_no = master.get("order_no")
+    if not isinstance(order_no, str) or not order_no.strip().startswith(_ORDER_PREFIX):
+        return False
+    return master.get("status") in _ORDER_STATUSES
+
+
+def _side(master: Mapping[str, Any]) -> str | None:
+    """强平和到期走卖出公式。其余只认买入、卖出。"""
+
+    if master.get("op_type") in _SELL_OP_TYPES:
+        return "sell"
+    direction = master.get("direction")
+    if direction == "buy":
+        return "buy"
+    if direction == "sell":
+        return "sell"
+    return None
+
+
+def _rows(value: object) -> list[Mapping[str, Any]]:
+    if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
+        return []
+    return [row for row in value if isinstance(row, Mapping)]
+
+
+def _one_note(notes: Sequence[Mapping[str, Any]]) -> tuple[Mapping[str, Any] | None, bool]:
+    """同一主单只能用它自己定位到的那一张票据。多张就不猜。"""
+
+    if len(notes) == 1:
+        return notes[0], False
+    if len(notes) > 1:
+        return None, True
+    return None, False
+
+
+def _summarize(hedges: Sequence[Mapping[str, Any]]) -> _Sums:
+    """没有对冲记录时,金额和股数的和是 0。缺金额列时不算 0。"""
+
+    if not hedges:
+        return _Sums(
+            trade_amt=Decimal(0),
+            settle_amt=Decimal(0),
+            hedge_qty=Decimal(0),
+            amounts_ready=True,
+            qty_ready=True,
+            trade_text="0.00",
+            settle_text="0.00",
+            qty_text="0.000000",
+        )
+    trade_parts: list[Decimal] = []
+    settle_parts: list[Decimal] = []
+    qty_parts: list[Decimal] = []
+    amounts_ready = True
+    qty_ready = True
+    for hedge in hedges:
+        qty = as_decimal(hedge.get("filled_qty"))
+        if qty is None:
+            qty_ready = False
+        else:
+            qty_parts.append(qty)
+        trade_amt = as_decimal(hedge.get("amt_trade"))
+        settle_amt = as_decimal(hedge.get("amt_settle"))
+        if trade_amt is None or settle_amt is None:
+            amounts_ready = False
+            continue
+        trade_parts.append(trade_amt)
+        settle_parts.append(settle_amt)
+    return _Sums(
+        trade_amt=sum(trade_parts, Decimal(0)) if amounts_ready else None,
+        settle_amt=sum(settle_parts, Decimal(0)) if amounts_ready else None,
+        hedge_qty=sum(qty_parts, Decimal(0)) if qty_ready else None,
+        amounts_ready=amounts_ready,
+        qty_ready=qty_ready,
+        trade_text=join_decimals(trade_parts, SCALE_AMT) if amounts_ready else "对冲记录发生金额(交易币种)无法相加",
+        settle_text=join_decimals(settle_parts, SCALE_AMT) if amounts_ready else "对冲记录发生金额(结算币种)无法相加",
+        qty_text=join_decimals(qty_parts, SCALE_QTY) if qty_ready else "对冲记录成交股数无法相加",
+    )
+
+
+def _append_hedge_lines(
+    lines: list[CheckLine],
+    hedges: Sequence[Mapping[str, Any]],
+    trades: Sequence[Mapping[str, Any]],
+    algos: Sequence[Mapping[str, Any]],
+) -> None:
+    """每条需要 TRS 执行根的对冲对一次。自定义和从未建单不产生这行。"""
+
+    for hedge in hedges:
+        link = classify_hedge(hedge)
+        if link.kind in {KIND_SKIP, KIND_NEVER}:
+            continue
+        label = link.key or hedge_label(hedge)
+        check_number = as_decimal(hedge.get("filled_qty"))
+        check_text = plain_text(HEDGE_TABLE, label, "filled_qty", check_number, SCALE_QTY)
+        check_value = quantize_optional(check_number, SCALE_QTY)
+        if link.kind != KIND_ROOT:
+            finish(
+                lines,
+                formula=_HEDGE_QTY_FORMULA,
+                basis="无法确定对冲对应哪张 TRS 单",
+                calc_text=f"{label} 无法确定对冲对应哪张 TRS 单",
+                calc_value=None,
+                check_text=check_text,
+                check_value=check_value,
+                verdict=VERDICT_SUSPECT,
+                live=False,
+            )
+            continue
+        matched = _matched_roots(link, trades, algos)
+        where = f"{_QUALIFIED_ROOT[link.table]}.{link.column}={link.key}"
+        if not matched:
+            finish(
+                lines,
+                formula=_HEDGE_QTY_FORMULA,
+                basis="应有 TRS 执行根但没有这一行",
+                calc_text=f"{where} 没有行",
+                calc_value=None,
+                check_text=check_text,
+                check_value=check_value,
+                verdict=VERDICT_ERROR,
+                live=False,
+            )
+            continue
+        if len(matched) > 1:
+            finish(
+                lines,
+                formula=_HEDGE_QTY_FORMULA,
+                basis="同一键对应多行,只接受唯一执行根",
+                calc_text=f"{where} 对应 {len(matched)} 行",
+                calc_value=None,
+                check_text=check_text,
+                check_value=check_value,
+                verdict=VERDICT_SUSPECT,
+                live=False,
+            )
+            continue
+        qty = as_decimal(matched[0].get("filled_qty"))
+        if qty is None:
+            finish(
+                lines,
+                formula=_HEDGE_QTY_FORMULA,
+                basis=f"{where} 的成交股数等于对冲记录成交股数",
+                calc_text=f"{where} 的成交股数缺失",
+                calc_value=None,
+                check_text=check_text,
+                check_value=check_value,
+                verdict=VERDICT_SUSPECT if check_number is not None else VERDICT_ERROR,
+                live=False,
+            )
+            continue
+        finish(
+            lines,
+            formula=_HEDGE_QTY_FORMULA,
+            basis=f"{where} 的成交股数等于对冲记录成交股数",
+            calc_text=f"{where} {format_decimal(qty, SCALE_QTY)}",
+            calc_value=qty,
+            check_text=check_text,
+            check_value=check_number,
+            verdict=VERDICT_ERROR if check_number is None else equal_verdict(qty, check_number, SCALE_QTY),
+            live=False,
+            scale=SCALE_QTY,
+        )
+
+
+def _matched_roots(
+    link: HedgeLink,
+    trades: Sequence[Mapping[str, Any]],
+    algos: Sequence[Mapping[str, Any]],
+) -> list[Mapping[str, Any]]:
+    """只认判定指定的那一列。同一键多行不合并,避免把拆单子单再加一遍。"""
+
+    source = trades if link.table == TABLE_ORDER else algos
+    found: list[Mapping[str, Any]] = []
+    seen: set[int] = set()
+    for row in source:
+        value = row.get(link.column)
+        if not isinstance(value, str) or value.strip() != link.key:
+            continue
+        ident = row.get("id")
+        mark = ident if isinstance(ident, int) and not isinstance(ident, bool) else id(row)
+        if mark in seen:
+            continue
+        seen.add(mark)
+        found.append(row)
+    return found
+
+
+def _append_missing_sub(lines: list[CheckLine], side: str) -> None:
+    """主单已经成立,子单行不在,被核对的行缺失。"""
+
+    formulas = _shared_formulas()
+    if side == "buy":
+        formulas.extend(_buy_formulas())
+    else:
+        formulas.extend(_sell_formulas())
+    for formula in formulas:
+        finish(
+            lines,
+            formula=formula,
+            basis=formula,
+            calc_text="没有 order_sub_a",
+            calc_value=None,
+            check_text=f"{ORDER_SUB_TABLE} 缺少被核对的行",
+            check_value=None,
+            verdict=VERDICT_ERROR,
+            live=False,
+        )
+
+
+def _append_shared(
+    lines: list[CheckLine],
+    sub: Mapping[str, Any],
+    logs: Sequence[tuple[str, str, Mapping[str, Any]]],
+    sources: Mapping[str, str],
+    sums: _Sums,
+) -> None:
+    label = row_label(sub)
+    _amount_line(
+        lines,
+        sub,
+        logs,
+        sources,
+        label,
+        "cum_amt_trade",
+        "累计发生金额(交易币种) = 对冲记录发生金额(交易币种)之和",
+        sums.trade_amt,
+        sums.amounts_ready,
+        sums.trade_text,
+    )
+    _amount_line(
+        lines,
+        sub,
+        logs,
+        sources,
+        label,
+        "cum_amt_settle",
+        "累计发生金额(结算币种) = 对冲记录发生金额(结算币种)之和",
+        sums.settle_amt,
+        sums.amounts_ready,
+        sums.settle_text,
+    )
+    qty_cell = read_cell(sub, "cum_filled_qty", "order_sub_a", logs, sources)
+    finish_cell(
+        lines,
+        formula="累计成交股数 = 对冲记录成交股数之和",
+        basis="累计成交股数 = 对冲记录成交股数之和",
+        calc_text=sums.qty_text,
+        calc_value=sums.hedge_qty if sums.qty_ready else None,
+        cell=qty_cell,
+        table=ORDER_SUB_TABLE,
+        label=label,
+        column="cum_filled_qty",
+        scale=SCALE_QTY,
+    )
+    _avg_line(lines, sub, logs, sources, label, sums)
+    _fx_line(lines, sub, logs, sources, label, sums)
+
+
+def _append_buy(
+    lines: list[CheckLine],
+    sub: Mapping[str, Any],
+    logs: Sequence[tuple[str, str, Mapping[str, Any]]],
+    sources: Mapping[str, str],
+    sums: _Sums,
+) -> None:
+    label = row_label(sub)
+    _convert_line(
+        lines,
+        sub,
+        logs,
+        sources,
+        label,
+        left_column="exec_notional_trade",
+        fx_column="fx_settle",
+        check_column="exec_notional_settle",
+        formula="执行订单名义本金(结算币种) = 执行订单名义本金(交易币种)按结算汇率换算",
+    )
+    _buy_qty_line(lines, sub, logs, sources, label)
+    _order_avg_line(lines, sub, logs, sources, label, sums)
+    _face_line(lines, sub, logs, sources, label)
+    _issue_price_line(lines, sub, logs, sources, label)
+
+
+def _append_sell(
+    lines: list[CheckLine],
+    sub: Mapping[str, Any],
+    note: Mapping[str, Any] | None,
+    note_ambiguous: bool,
+    logs: Sequence[tuple[str, str, Mapping[str, Any]]],
+    sources: Mapping[str, str],
+    sums: _Sums,
+    hedges: Sequence[Mapping[str, Any]],
+) -> None:
+    label = row_label(sub)
+    _exec_trade_line(lines, sub, logs, sources, label, sums, hedges)
+    _sell_qty_line(lines, sub, logs, sources, label)
+    ratio, ratio_mode, ratio_live, ratio_error = _ratio(sub, note, note_ambiguous, logs, sources)
+    face_calc, add_calc, dividend_calc = None, None, None
+    face_live = add_live = dividend_live = ratio_live
+    if ratio_mode == "skip":
+        # 没有执行名义本金,比例三列不核。日志留一行,结论里不出现。
+        logger.info("卖出比例不核:没有执行名义本金,子单 {}", label)
+    else:
+        # 全部卖出后当前面值已扣成 0,卖出面值改乘初始面值。增额、分红没有初始列,扣成 0 后不能再拿 0 去比。
+        fully_sold = ratio_mode == "full"
+        if fully_sold:
+            face_base = read_note_cell(note, "init_face_value", logs, sources)
+            face_name = "初始票据面值"
+            face_formula = "卖出票据面值 = 比例 × 初始票据面值"
+        else:
+            face_base = read_note_cell(note, "cur_face_value", logs, sources)
+            face_name = "当前票据面值"
+            face_formula = "卖出票据面值 = 比例 × 当前票据面值"
+        face_calc, face_text, face_live = _sell_share(ratio, ratio_mode, ratio_live, face_base, face_name, ratio_error)
+        add_calc, add_text, add_live = _sold_amount_share(
+            sub, logs, sources, ratio, ratio_mode, ratio_live, ratio_error,
+            read_note_cell(note, "add_amt", logs, sources), "当前增额金额", "sold_add_amt", fully_sold,
+        )
+        dividend_calc, dividend_text, dividend_live = _sold_amount_share(
+            sub, logs, sources, ratio, ratio_mode, ratio_live, ratio_error,
+            read_note_cell(note, "dividend_amt", logs, sources), "当前分红金额", "realized_dividend", fully_sold,
+        )
+        _finish_share(lines, sub, logs, sources, label, face_formula, "sold_face_value", face_text, face_calc, face_live, ratio_mode)
+        _finish_share(lines, sub, logs, sources, label, "卖出增额金额 = 比例 × 当前增额金额", "sold_add_amt", add_text, add_calc, add_live, ratio_mode)
+        _finish_share(lines, sub, logs, sources, label, "实现分红 = 比例 × 当前分红金额", "realized_dividend", dividend_text, dividend_calc, dividend_live, ratio_mode)
+    pnl_calc, pnl_text, pnl_live = _pnl_for_sub(
+        sub,
+        note,
+        logs,
+        sources,
+        dividend_calc,
+        dividend_live,
+    )
+    finish_number(
+        lines,
+        formula="底层资产盈亏 = 订单卖出股数 × 订单成交均价(结算汇率换到结算币种) − 订单卖出股数 × 票据期初价格(初始汇率换到结算币种) + 本次实现分红",
+        basis="两个汇率各自看交易币种金额、汇率、结算币种金额,不能共用一个方向",
+        calc_text=pnl_text,
+        calc_value=pnl_calc,
+        cell=read_cell(sub, "underlying_pnl", "order_sub_a", logs, sources),
+        label=label,
+        column="underlying_pnl",
+        scale=SCALE_AMT,
+        extra_live=pnl_live,
+    )
+    _total_line(lines, sub, note, logs, sources, label, face_calc, add_calc, pnl_calc, face_live or add_live or pnl_live)
+    _final_line(lines, sub, logs, sources, label)
+
+
+def _amount_line(
+    lines: list[CheckLine],
+    sub: Mapping[str, Any],
+    logs: Sequence[tuple[str, str, Mapping[str, Any]]],
+    sources: Mapping[str, str],
+    label: str,
+    column: str,
+    formula: str,
+    calc: Decimal | None,
+    ready: bool,
+    text: str,
+) -> None:
+    finish_cell(
+        lines,
+        formula=formula,
+        basis=formula,
+        calc_text=text,
+        calc_value=calc if ready else None,
+        cell=read_cell(sub, column, "order_sub_a", logs, sources),
+        table=ORDER_SUB_TABLE,
+        label=label,
+        column=column,
+        scale=SCALE_AMT,
+    )
+
+
+def _avg_line(
+    lines: list[CheckLine],
+    sub: Mapping[str, Any],
+    logs: Sequence[tuple[str, str, Mapping[str, Any]]],
+    sources: Mapping[str, str],
+    label: str,
+    sums: _Sums,
+) -> None:
+    calc, text = _avg_value(sums)
+    finish_cell(
+        lines,
+        formula="对冲含费成交均价 = 累计发生金额(交易币种) / 累计成交股数",
+        basis="对冲含费成交均价 = 累计发生金额(交易币种) / 累计成交股数,分母为 0 时可疑",
+        calc_text=text,
+        calc_value=calc,
+        cell=read_cell(sub, "avg_px_with_fee", "order_sub_a", logs, sources),
+        table=ORDER_SUB_TABLE,
+        label=label,
+        column="avg_px_with_fee",
+        scale=SCALE_PX,
+    )
+
+
+def _avg_value(sums: _Sums) -> tuple[Decimal | None, str]:
+    if not sums.amounts_ready or not sums.qty_ready or sums.trade_amt is None or sums.hedge_qty is None:
+        return None, "累计发生金额或累计成交股数无法相加"
+    if sums.hedge_qty == 0:
+        return None, "累计成交股数为 0"
+    calc = sums.trade_amt / sums.hedge_qty
+    text = f"{format_decimal(sums.trade_amt, SCALE_AMT)} / {format_decimal(sums.hedge_qty, SCALE_QTY)} = {format_decimal(calc, SCALE_PX)}"
+    return calc, text
+
+
+def _fx_line(
+    lines: list[CheckLine],
+    sub: Mapping[str, Any],
+    logs: Sequence[tuple[str, str, Mapping[str, Any]]],
+    sources: Mapping[str, str],
+    label: str,
+    sums: _Sums,
+) -> None:
+    cell = read_cell(sub, "fx_settle", "order_sub_a", logs, sources)
+    formula = "结算汇率由累计发生金额(交易币种)与累计发生金额(结算币种)反算"
+    if cell.conflict:
+        finish_cell(
+            lines,
+            formula=formula,
+            basis="汇率两个方向都对得上为可疑,都对不上为错误,都无法算为可疑",
+            calc_text="回写 after_val 不一致",
+            calc_value=None,
+            cell=cell,
+            table=ORDER_SUB_TABLE,
+            label=label,
+            column="fx_settle",
+            scale=SCALE_FX,
+        )
+        return
+    if not sums.amounts_ready or sums.trade_amt is None or sums.settle_amt is None:
+        verdict = VERDICT_ERROR if cell.absent else VERDICT_SUSPECT
+        finish(
+            lines,
+            formula=formula,
+            basis="汇率两个方向都对得上为可疑,都对不上为错误,都无法算为可疑",
+            calc_text="累计发生金额无法相加,结算汇率不能反算",
+            calc_value=None,
+            check_text=cell_text(ORDER_SUB_TABLE, label, "fx_settle", cell, SCALE_FX),
+            check_value=None if cell.absent else quantize_optional(cell.number, SCALE_FX),
+            verdict=verdict,
+            live=cell.live,
+            scale=SCALE_FX,
+        )
+        return
+    if cell.absent or cell.number is None:
+        finish(
+            lines,
+            formula=formula,
+            basis="汇率两个方向都对得上为可疑,都对不上为错误,都无法算为可疑",
+            calc_text="结算汇率缺失",
+            calc_value=None,
+            check_text=cell_text(ORDER_SUB_TABLE, label, "fx_settle", cell, SCALE_FX),
+            check_value=None,
+            verdict=VERDICT_ERROR,
+            live=cell.live,
+        )
+        return
+    verdict, calc, text = judge_rate(sums.trade_amt, sums.settle_amt, cell.number)
+    finish(
+        lines,
+        formula=formula,
+        basis="汇率两个方向都对得上为可疑,都对不上为错误,都无法算为可疑",
+        calc_text=text,
+        calc_value=calc,
+        check_text=cell_text(ORDER_SUB_TABLE, label, "fx_settle", cell, SCALE_FX),
+        check_value=cell.number,
+        verdict=verdict,
+        live=cell.live,
+        scale=SCALE_FX,
+    )
+
+
+def _convert_line(
+    lines: list[CheckLine],
+    sub: Mapping[str, Any],
+    logs: Sequence[tuple[str, str, Mapping[str, Any]]],
+    sources: Mapping[str, str],
+    label: str,
+    *,
+    left_column: str,
+    fx_column: str,
+    check_column: str,
+    formula: str,
+) -> None:
+    """用三列看乘、除哪一个方向在金额标度内相等。不默认乘或除。"""
+
+    left = read_cell(sub, left_column, "order_sub_a", logs, sources)
+    fx = read_cell(sub, fx_column, "order_sub_a", logs, sources)
+    check = read_cell(sub, check_column, "order_sub_a", logs, sources)
+    live = left.live or fx.live or check.live
+    if check.conflict or left.conflict or fx.conflict:
+        finish_number(
+            lines,
+            formula=formula,
+            basis=formula,
+            calc_text="回写 after_val 不一致",
+            calc_value=None,
+            cell=check,
+            label=label,
+            column=check_column,
+            scale=SCALE_AMT,
+            extra_live=live,
+            force=VERDICT_SUSPECT,
+        )
+        return
+    if left.absent or left.number is None or fx.absent or fx.number is None:
+        verdict = VERDICT_ERROR if check.absent else VERDICT_SUSPECT
+        finish(
+            lines,
+            formula=formula,
+            basis=formula,
+            calc_text="交易币种金额或结算汇率缺失",
+            calc_value=None,
+            check_text=cell_text(ORDER_SUB_TABLE, label, check_column, check, SCALE_AMT),
+            check_value=None if check.absent or check.number is None else check.number,
+            verdict=verdict,
+            live=live,
+            scale=SCALE_AMT,
+        )
+        return
+    if check.absent or check.number is None:
+        finish(
+            lines,
+            formula=formula,
+            basis=formula,
+            calc_text="被核对的结算币种金额缺失",
+            calc_value=None,
+            check_text=cell_text(ORDER_SUB_TABLE, label, check_column, check, SCALE_AMT),
+            check_value=None,
+            verdict=VERDICT_ERROR,
+            live=live,
+        )
+        return
+    verdict, calc, text = judge_convert(left.number, fx.number, check.number)
+    finish(
+        lines,
+        formula=formula,
+        basis="用交易币种金额、结算汇率、结算币种金额看方向,两种都相等为可疑,都不等为错误",
+        calc_text=text,
+        calc_value=calc,
+        check_text=cell_text(ORDER_SUB_TABLE, label, check_column, check, SCALE_AMT),
+        check_value=check.number,
+        verdict=verdict,
+        live=live,
+        scale=SCALE_AMT,
+    )
+
+
+def _buy_qty_line(
+    lines: list[CheckLine],
+    sub: Mapping[str, Any],
+    logs: Sequence[tuple[str, str, Mapping[str, Any]]],
+    sources: Mapping[str, str],
+    label: str,
+) -> None:
+    notional = read_cell(sub, "exec_notional_trade", "order_sub_a", logs, sources)
+    price = read_cell(sub, "order_avg_px", "order_sub_a", logs, sources)
+    check = read_cell(sub, "order_qty", "order_sub_a", logs, sources)
+    formula = "订单买入股数 = 执行订单名义本金(交易币种) / 订单成交均价"
+    if price.number is not None and price.number == 0:
+        finish_number(
+            lines,
+            formula=formula,
+            basis="订单买入股数 = 执行订单名义本金(交易币种) / 订单成交均价,均价为 0 时可疑",
+            calc_text="订单成交均价为 0",
+            calc_value=None,
+            cell=check,
+            label=label,
+            column="order_qty",
+            scale=SCALE_QTY,
+            extra_live=price.live or notional.live,
+            force=VERDICT_SUSPECT if not check.absent else VERDICT_ERROR,
+        )
+        return
+    if notional.absent or notional.number is None or price.absent or price.number is None:
+        verdict = VERDICT_ERROR if check.absent else VERDICT_SUSPECT
+        finish_number(
+            lines,
+            formula=formula,
+            basis=formula,
+            calc_text="执行订单名义本金(交易币种)或订单成交均价缺失",
+            calc_value=None,
+            cell=check,
+            label=label,
+            column="order_qty",
+            scale=SCALE_QTY,
+            extra_live=notional.live or price.live,
+            force=verdict,
+        )
+        return
+    calc = notional.number / price.number
+    text = (
+        f"{format_decimal(notional.number, SCALE_AMT)} / {format_decimal(price.number, SCALE_PX)} = {format_decimal(calc, SCALE_QTY)}"
+    )
+    finish_number(
+        lines,
+        formula=formula,
+        basis=formula,
+        calc_text=text,
+        calc_value=calc,
+        cell=check,
+        label=label,
+        column="order_qty",
+        scale=SCALE_QTY,
+        extra_live=notional.live or price.live,
+    )
+
+
+def _order_avg_line(
+    lines: list[CheckLine],
+    sub: Mapping[str, Any],
+    logs: Sequence[tuple[str, str, Mapping[str, Any]]],
+    sources: Mapping[str, str],
+    label: str,
+    sums: _Sums,
+) -> None:
+    """人工改过成交均价时没有公式,不产生结论行。"""
+
+    flag = manual_flag(sub)
+    if flag == 1:
+        return
+    check = read_cell(sub, "order_avg_px", "order_sub_a", logs, sources)
+    formula = "订单成交均价 = 对冲含费成交均价"
+    if flag is None:
+        text = (
+            "订单成交均价的人工编辑标志缺失"
+            if "order_avg_px_manual_flag" not in sub
+            else "订单成交均价的人工编辑标志无法读取"
+        )
+        verdict = VERDICT_ERROR if check.absent else VERDICT_SUSPECT
+        finish_number(
+            lines,
+            formula=formula,
+            basis="order_avg_px_manual_flag 为 0 时,订单成交均价等于对冲含费成交均价",
+            calc_text=text,
+            calc_value=None,
+            cell=check,
+            label=label,
+            column="order_avg_px",
+            scale=SCALE_PX,
+            force=verdict,
+        )
+        return
+    calc, text = _avg_value(sums)
+    finish_number(
+        lines,
+        formula=formula,
+        basis="order_avg_px_manual_flag 为 0 时,订单成交均价等于对冲含费成交均价",
+        calc_text=text,
+        calc_value=calc,
+        cell=check,
+        label=label,
+        column="order_avg_px",
+        scale=SCALE_PX,
+    )
+
+
+def _face_line(
+    lines: list[CheckLine],
+    sub: Mapping[str, Any],
+    logs: Sequence[tuple[str, str, Mapping[str, Any]]],
+    sources: Mapping[str, str],
+    label: str,
+) -> None:
+    """报价不在子单列上。不把报价当成 100%。"""
+
+    check = read_cell(sub, "exec_face_value_settle", "order_sub_a", logs, sources)
+    verdict = VERDICT_ERROR if check.absent else VERDICT_SUSPECT
+    finish_number(
+        lines,
+        formula="票据面值 = 执行订单名义本金(结算币种) × A 类报价",
+        basis="报价不在子单列上时可疑,不默认 100%",
+        calc_text="A 类报价不在子单列上",
+        calc_value=None,
+        cell=check,
+        label=label,
+        column="exec_face_value_settle",
+        scale=SCALE_AMT,
+        force=verdict,
+    )
+
+
+def _issue_price_line(
+    lines: list[CheckLine],
+    sub: Mapping[str, Any],
+    logs: Sequence[tuple[str, str, Mapping[str, Any]]],
+    sources: Mapping[str, str],
+    label: str,
+) -> None:
+    face = read_cell(sub, "exec_face_value_settle", "order_sub_a", logs, sources)
+    fee = read_cell(sub, "exec_issue_fee_settle", "order_sub_a", logs, sources)
+    check = read_cell(sub, "exec_issue_price_settle", "order_sub_a", logs, sources)
+    formula = "票据发行价 = 票据面值 + 发行费用"
+    if face.conflict or fee.conflict or check.conflict:
+        finish_number(
+            lines,
+            formula=formula,
+            basis="票据发行价 = 票据面值 + 发行费用",
+            calc_text="回写 after_val 不一致",
+            calc_value=None,
+            cell=check,
+            label=label,
+            column="exec_issue_price_settle",
+            scale=SCALE_AMT,
+            extra_live=face.live or fee.live,
+            force=VERDICT_SUSPECT,
+        )
+        return
+    if face.absent or face.number is None or fee.absent or fee.number is None:
+        verdict = VERDICT_ERROR if check.absent else VERDICT_SUSPECT
+        finish_number(
+            lines,
+            formula=formula,
+            basis=formula,
+            calc_text="票据面值或发行费用缺失",
+            calc_value=None,
+            cell=check,
+            label=label,
+            column="exec_issue_price_settle",
+            scale=SCALE_AMT,
+            extra_live=face.live or fee.live,
+            force=verdict,
+        )
+        return
+    calc = face.number + fee.number
+    text = f"{format_decimal(face.number, SCALE_AMT)} + {format_decimal(fee.number, SCALE_AMT)} = {format_decimal(calc, SCALE_AMT)}"
+    finish_number(
+        lines,
+        formula=formula,
+        basis="票据发行价 = 票据面值 + 发行费用",
+        calc_text=text,
+        calc_value=calc,
+        cell=check,
+        label=label,
+        column="exec_issue_price_settle",
+        scale=SCALE_AMT,
+        extra_live=face.live or fee.live,
+    )
+
+
+def _exec_trade_line(
+    lines: list[CheckLine],
+    sub: Mapping[str, Any],
+    logs: Sequence[tuple[str, str, Mapping[str, Any]]],
+    sources: Mapping[str, str],
+    label: str,
+    sums: _Sums,
+    hedges: Sequence[Mapping[str, Any]],
+) -> None:
+    check = read_cell(sub, "exec_notional_trade", "order_sub_a", logs, sources)
+    formula = "执行名义本金(交易币种) = 对冲完成且累计成交股数为 0 时为 0,否则为订单名义本金(交易币种)"
+    if _hedge_flat(hedges, sums):
+        finish_number(
+            lines,
+            formula=formula,
+            basis="对冲完成且累计成交股数为 0 时,执行名义本金(交易币种)为 0",
+            calc_text="对冲完成且累计成交股数为 0",
+            calc_value=Decimal(0),
+            cell=check,
+            label=label,
+            column="exec_notional_trade",
+            scale=SCALE_AMT,
+        )
+        return
+    # 订单名义本金(交易币种)只作为输入,不在这张主单的列上,不能用票据当前名义本金代替。
+    finish_number(
+        lines,
+        formula=formula,
+        basis=formula,
+        calc_text="订单名义本金(交易币种)不在主单上",
+        calc_value=None,
+        cell=check,
+        label=label,
+        column="exec_notional_trade",
+        scale=SCALE_AMT,
+        force=VERDICT_ERROR if check.absent else VERDICT_SUSPECT,
+    )
+
+
+def _hedge_flat(hedges: Sequence[Mapping[str, Any]], sums: _Sums) -> bool:
+    if not hedges or not sums.qty_ready or sums.hedge_qty is None or sums.hedge_qty != 0:
+        return False
+    return all(hedge.get("status") == _HEDGE_DONE for hedge in hedges)
+
+
+def _sell_qty_line(
+    lines: list[CheckLine],
+    sub: Mapping[str, Any],
+    logs: Sequence[tuple[str, str, Mapping[str, Any]]],
+    sources: Mapping[str, str],
+    label: str,
+) -> None:
+    """最后一笔才改用剩余股数。一张主单看不出是不是最后一笔。"""
+
+    check = read_cell(sub, "order_qty", "order_sub_a", logs, sources)
+    finish_number(
+        lines,
+        formula="订单卖出股数 = 比例 × 当前票据股票数量",
+        basis="最后一笔卖出用剩余股票数量;无法判断是不是最后一笔时,该条可疑",
+        calc_text="无法判断是不是最后一笔卖出",
+        calc_value=None,
+        cell=check,
+        label=label,
+        column="order_qty",
+        scale=SCALE_QTY,
+        force=VERDICT_SUSPECT if not check.absent else VERDICT_ERROR,
+    )
+
+
+def _sell_share(
+    ratio: Decimal | None,
+    mode: str,
+    ratio_live: bool,
+    base: Cell,
+    base_name: str,
+    error_text: str,
+) -> tuple[Decimal | None, str, bool]:
+    """比例三列。名义本金列缺失时三条都记错误,错误原因由比例计算给出。"""
+
+    if mode == "error":
+        return None, error_text, ratio_live
+    return _scaled_product(ratio, base, base_name, ratio_live)
+
+
+def _sold_amount_share(
+    sub: Mapping[str, Any],
+    logs: Sequence[tuple[str, str, Mapping[str, Any]]],
+    sources: Mapping[str, str],
+    ratio: Decimal | None,
+    mode: str,
+    ratio_live: bool,
+    error_text: str,
+    base: Cell,
+    base_name: str,
+    column: str,
+    fully_sold: bool,
+) -> tuple[Decimal | None, str, bool]:
+    """全部卖出后增额、分红已扣成 0。订单上仍有金额时,卖出前的数不在票据上,不拿 0 去比。"""
+
+    if fully_sold and not base.absent and base.number == 0:
+        sold = read_cell(sub, column, "order_sub_a", logs, sources)
+        if sold.number not in (None, 0):
+            live = ratio_live or base.live or sold.live
+            return None, f"全部卖出后{base_name}已是 0,卖出前金额没有留在票据上", live
+    return _sell_share(ratio, mode, ratio_live, base, base_name, error_text)
+
+
+def _finish_share(
+    lines: list[CheckLine],
+    sub: Mapping[str, Any],
+    logs: Sequence[tuple[str, str, Mapping[str, Any]]],
+    sources: Mapping[str, str],
+    label: str,
+    formula: str,
+    column: str,
+    text: str,
+    calc: Decimal | None,
+    live: bool,
+    mode: str,
+) -> None:
+    finish_number(
+        lines,
+        formula=formula,
+        basis=formula,
+        calc_text=text,
+        calc_value=calc,
+        cell=read_cell(sub, column, "order_sub_a", logs, sources),
+        label=label,
+        column=column,
+        scale=SCALE_AMT,
+        extra_live=live,
+        force=VERDICT_ERROR if mode == "error" else None,
+    )
+
+
+def _ratio(
+    sub: Mapping[str, Any],
+    note: Mapping[str, Any] | None,
+    note_ambiguous: bool,
+    logs: Sequence[tuple[str, str, Mapping[str, Any]]],
+    sources: Mapping[str, str],
+) -> tuple[Decimal | None, str, bool, str]:
+    """卖出比例。第二项是 ok、full、error、skip 或 suspect。第四项是错误原因。
+
+    部分卖出用当前名义本金。全部卖出后当前名义本金被扣成 0,改用初始名义本金,
+    执行名义本金等于初始名义本金时比例就是 100%。当前名义本金缺失(不是 0)仍是错误。
+    执行名义本金也没有时不核比例,由调用方打日志。票据对不上不止一张时仍记可疑。
+    """
+
+    if note_ambiguous:
+        return None, "suspect", False, ""
+    current = read_note_cell(note, "cur_notional_trade", logs, sources)
+    exec_trade = read_cell(sub, "exec_notional_trade", "order_sub_a", logs, sources)
+    live = current.live or exec_trade.live
+    if exec_trade.absent or exec_trade.number is None:
+        return None, "skip", live, ""
+    if current.absent or current.number is None:
+        return None, "error", live, "已有执行名义本金,票据当前名义本金缺失"
+    if current.number == 0:
+        # 全部卖出:执行名义本金是这笔卖出前分到的名义本金,分母是卖出前的初始名义本金。
+        previous = read_note_cell(note, "init_notional_trade", logs, sources)
+        live = live or previous.live
+        if previous.absent or previous.number is None or previous.number == 0:
+            return None, "error", live, "票据已全部卖出,初始名义本金缺失或为 0"
+        return exec_trade.number / previous.number, "full", live, ""
+    return exec_trade.number / current.number, "ok", live, ""
+
+
+def _scaled_product(
+    ratio: Decimal | None,
+    base: Cell,
+    base_name: str,
+    ratio_live: bool,
+) -> tuple[Decimal | None, str, bool]:
+    live = ratio_live or base.live
+    if ratio is None:
+        return None, "比例无法计算", live
+    if base.absent or base.number is None:
+        return None, f"{base_name}缺失", live
+    calc = ratio * base.number
+    text = f"比例 × {format_decimal(base.number, SCALE_AMT)} = {format_decimal(calc, SCALE_AMT)}"
+    return calc, text, live
+
+
+def _pnl_for_sub(
+    sub: Mapping[str, Any],
+    note: Mapping[str, Any] | None,
+    logs: Sequence[tuple[str, str, Mapping[str, Any]]],
+    sources: Mapping[str, str],
+    dividend: Decimal | None,
+    dividend_live: bool,
+) -> tuple[Decimal | None, str, bool]:
+    qty = read_cell(sub, "order_qty", "order_sub_a", logs, sources)
+    price = read_cell(sub, "order_avg_px", "order_sub_a", logs, sources)
+    fx = read_cell(sub, "fx_settle", "order_sub_a", logs, sources)
+    init_px = read_note_cell(note, "cur_px", logs, sources)
+    init_fx = read_note_cell(note, "init_fx", logs, sources)
+    live = any(cell.live for cell in (qty, price, fx, init_px, init_fx)) or dividend_live
+    needed = (qty, price, fx, init_px, init_fx)
+    if dividend is None or any(cell.absent or cell.number is None for cell in needed):
+        return None, "订单卖出股数、成交均价、汇率、期初价格或本次实现分红缺失", live
+    # 初始汇率没有交易币种金额和结算币种金额这一组三列,不能借用结算汇率的方向。
+    return None, "初始汇率无法从交易币种金额、初始汇率、结算币种金额确定方向", live
+
+
+def _total_line(
+    lines: list[CheckLine],
+    sub: Mapping[str, Any],
+    note: Mapping[str, Any] | None,
+    logs: Sequence[tuple[str, str, Mapping[str, Any]]],
+    sources: Mapping[str, str],
+    label: str,
+    sold_face: Decimal | None,
+    sold_add: Decimal | None,
+    pnl: Decimal | None,
+    extra_live: bool,
+) -> None:
+    check = read_cell(sub, "exec_total_amt_settle", "order_sub_a", logs, sources)
+    formula = "总金额 = 若底层资产盈亏 > 0,则卖出票据面值 + 卖出增额金额 + 底层资产盈亏 × 收益比例,否则取与 0 的较大者"
+    if sold_face is None or sold_add is None or pnl is None:
+        verdict = VERDICT_ERROR if check.absent else VERDICT_SUSPECT
+        finish_number(
+            lines,
+            formula=formula,
+            basis=formula,
+            calc_text="卖出票据面值、卖出增额金额或底层资产盈亏未能算出",
+            calc_value=None,
+            cell=check,
+            label=label,
+            column="exec_total_amt_settle",
+            scale=SCALE_AMT,
+            extra_live=extra_live,
+            force=verdict,
+        )
+        return
+    face_q = quantize(sold_face, SCALE_AMT)
+    add_q = quantize(sold_add, SCALE_AMT)
+    pnl_q = quantize(pnl, SCALE_AMT)
+    yield_cell = read_note_cell(note, "a_yield_ratio", logs, sources)
+    yield_ratio = None if yield_cell.absent else yield_cell.number
+    if pnl_q > 0 and yield_ratio is None:
+        finish_number(
+            lines,
+            formula=formula,
+            basis="收益比例缺失时总金额可疑",
+            calc_text="收益比例缺失",
+            calc_value=None,
+            cell=check,
+            label=label,
+            column="exec_total_amt_settle",
+            scale=SCALE_AMT,
+            extra_live=extra_live or yield_cell.live,
+            force=VERDICT_ERROR if check.absent else VERDICT_SUSPECT,
+        )
+        return
+    calc = total_amount(face_q, add_q, pnl_q, yield_ratio)
+    if calc is None:
+        text = "总金额无法计算"
+    elif pnl_q > 0:
+        text = (
+            f"{format_decimal(face_q, SCALE_AMT)} + {format_decimal(add_q, SCALE_AMT)} + "
+            f"{format_decimal(pnl_q, SCALE_AMT)} × {format_decimal(yield_ratio, SCALE_AMT)} = {format_decimal(calc, SCALE_AMT)}"
+        )
+    else:
+        text = (
+            f"max(0, {format_decimal(face_q, SCALE_AMT)} + {format_decimal(add_q, SCALE_AMT)} + {format_decimal(pnl_q, SCALE_AMT)}) "
+            f"= {format_decimal(calc, SCALE_AMT)}"
+        )
+    finish_number(
+        lines,
+        formula=formula,
+        basis=formula,
+        calc_text=text,
+        calc_value=calc,
+        cell=check,
+        label=label,
+        column="exec_total_amt_settle",
+        scale=SCALE_AMT,
+        extra_live=extra_live or yield_cell.live,
+    )
+
+
+def _final_line(
+    lines: list[CheckLine],
+    sub: Mapping[str, Any],
+    logs: Sequence[tuple[str, str, Mapping[str, Any]]],
+    sources: Mapping[str, str],
+    label: str,
+) -> None:
+    """最终盈亏只用同一行的总金额、卖出面值、卖出增额。"""
+
+    total = read_cell(sub, "exec_total_amt_settle", "order_sub_a", logs, sources)
+    face = read_cell(sub, "sold_face_value", "order_sub_a", logs, sources)
+    add = read_cell(sub, "sold_add_amt", "order_sub_a", logs, sources)
+    check = read_cell(sub, "final_pnl", "order_sub_a", logs, sources)
+    formula = "最终盈亏 = 总金额 − 本次卖出票据面值 − 本次卖出增额金额"
+    live = total.live or face.live or add.live
+    if total.conflict or face.conflict or add.conflict or check.conflict:
+        finish_number(
+            lines,
+            formula=formula,
+            basis=formula,
+            calc_text="回写 after_val 不一致",
+            calc_value=None,
+            cell=check,
+            label=label,
+            column="final_pnl",
+            scale=SCALE_AMT,
+            extra_live=live,
+            force=VERDICT_SUSPECT,
+        )
+        return
+    if check.absent:
+        finish_number(
+            lines,
+            formula=formula,
+            basis=formula,
+            calc_text="最终盈亏缺失",
+            calc_value=None,
+            cell=check,
+            label=label,
+            column="final_pnl",
+            scale=SCALE_AMT,
+            extra_live=live,
+            force=VERDICT_ERROR,
+        )
+        return
+    if total.absent or total.number is None or face.absent or face.number is None or add.absent or add.number is None:
+        finish_number(
+            lines,
+            formula=formula,
+            basis=formula,
+            calc_text="总金额、本次卖出票据面值或本次卖出增额金额缺失",
+            calc_value=None,
+            cell=check,
+            label=label,
+            column="final_pnl",
+            scale=SCALE_AMT,
+            extra_live=live,
+            force=VERDICT_SUSPECT,
+        )
+        return
+    calc = total.number - face.number - add.number
+    text = (
+        f"{format_decimal(total.number, SCALE_AMT)} - {format_decimal(face.number, SCALE_AMT)} - "
+        f"{format_decimal(add.number, SCALE_AMT)} = {format_decimal(calc, SCALE_AMT)}"
+    )
+    finish_number(
+        lines,
+        formula=formula,
+        basis="最终盈亏 = 总金额 − 本次卖出票据面值 − 本次卖出增额金额",
+        calc_text=text,
+        calc_value=calc,
+        cell=check,
+        label=label,
+        column="final_pnl",
+        scale=SCALE_AMT,
+        extra_live=live,
+    )
+
+
+def _shared_formulas() -> list[str]:
+    return [
+        "累计发生金额(交易币种) = 对冲记录发生金额(交易币种)之和",
+        "累计发生金额(结算币种) = 对冲记录发生金额(结算币种)之和",
+        "累计成交股数 = 对冲记录成交股数之和",
+        "对冲含费成交均价 = 累计发生金额(交易币种) / 累计成交股数",
+        "结算汇率由累计发生金额(交易币种)与累计发生金额(结算币种)反算",
+    ]
+
+
+def _buy_formulas() -> list[str]:
+    return [
+        "执行订单名义本金(结算币种) = 执行订单名义本金(交易币种)按结算汇率换算",
+        "订单买入股数 = 执行订单名义本金(交易币种) / 订单成交均价",
+        "订单成交均价 = 对冲含费成交均价",
+        "票据面值 = 执行订单名义本金(结算币种) × A 类报价",
+        "票据发行价 = 票据面值 + 发行费用",
+    ]
+
+
+def _sell_formulas() -> list[str]:
+    return [
+        "执行名义本金(交易币种) = 对冲完成且累计成交股数为 0 时为 0,否则为订单名义本金(交易币种)",
+        "订单卖出股数 = 比例 × 当前票据股票数量",
+        "卖出票据面值 = 比例 × 当前票据面值",
+        "卖出增额金额 = 比例 × 当前增额金额",
+        "实现分红 = 比例 × 当前分红金额",
+        "底层资产盈亏 = 订单卖出股数 × 订单成交均价(结算汇率换到结算币种) − 订单卖出股数 × 票据期初价格(初始汇率换到结算币种) + 本次实现分红",
+        "总金额 = 若底层资产盈亏 > 0,则卖出票据面值 + 卖出增额金额 + 底层资产盈亏 × 收益比例,否则取与 0 的较大者",
+        "最终盈亏 = 总金额 − 本次卖出票据面值 − 本次卖出增额金额",
+    ]
+

+ 231 - 0
code/post_clear_check/src/post_clear_check/progress.py

@@ -0,0 +1,231 @@
+"""长步骤的进度提示。
+
+在终端里运行时,标准错误上保留一行会刷新的状态:总数未知显示循环的「...」,总数已知显示进度条。
+不在终端时(cron、重定向到文件)不刷新状态行,改为按固定间隔打一条「仍在…」的心跳日志。
+只做显示,不参与核对结论,不写库,不在规则版本的哈希范围内。
+"""
+
+from __future__ import annotations
+
+import asyncio
+import re
+import shutil
+import sys
+import threading
+import time
+import unicodedata
+from collections.abc import Callable
+from types import TracebackType
+from typing import Any, TextIO
+
+from loguru import logger
+
+# 终端里状态行的刷新间隔。
+REFRESH_SECONDS = 0.5
+# 不在终端时,长步骤每隔多久打一条心跳日志。
+HEARTBEAT_SECONDS = 10.0
+_BAR_WIDTH = 24
+_DOTS = (".  ", ".. ", "...")
+_CLEAR = "\r\033[K"
+# 业务查询里的表名,只用来提示正在读哪张表。
+_TABLE = re.compile(r"\bFROM\s+([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)?)", re.IGNORECASE)
+
+
+class Screen:
+    """标准错误上的一行状态。日志写出前先擦掉状态行,写完再画回去,两者不会叠在一起。
+
+    规则在线程里跑时也会打日志,写流统一加锁。
+    """
+
+    def __init__(self, stream: TextIO | None = None) -> None:
+        self._stream = stream
+        self._line = ""
+        self._lock = threading.Lock()
+
+    @property
+    def stream(self) -> TextIO:
+        # 不在构造时绑定 sys.stderr,测试替换标准错误后仍写到替换后的流。
+        return self._stream or sys.stderr
+
+    def interactive(self) -> bool:
+        isatty = getattr(self.stream, "isatty", None)
+        try:
+            return bool(isatty and isatty())
+        except ValueError:
+            # 流已关闭时 isatty 会抛错,按不在终端处理。
+            return False
+
+    def show(self, text: str) -> None:
+        with self._lock:
+            self._line = _fit(text, _columns())
+            self.stream.write(_CLEAR + self._line)
+            self.stream.flush()
+
+    def clear(self) -> None:
+        with self._lock:
+            if not self._line:
+                return
+            self._line = ""
+            self.stream.write(_CLEAR)
+            self.stream.flush()
+
+    def write_log(self, message: str) -> None:
+        with self._lock:
+            line = self._line
+            if line:
+                self.stream.write(_CLEAR)
+            self.stream.write(message)
+            if line:
+                self.stream.write(line)
+            self.stream.flush()
+
+
+# 进程内唯一的状态行。日志输出口和各步骤共用它。
+SCREEN = Screen()
+
+
+def log_sink(message: Any) -> None:
+    """loguru 的输出口。终端里有状态行时先擦掉,写完日志再画回去。"""
+
+    SCREEN.write_log(str(message))
+
+
+class Stage:
+    """一个步骤。total 为空时显示循环的「...」,给了总数显示进度条。
+
+    用法:``async with Stage("从业务库取数") as stage: ...``。步骤里调 ``advance`` 推进计数,
+    调 ``note`` 写当前在做什么,结束前可以给 ``summary`` 写一句结果,完成日志会带上。
+    """
+
+    def __init__(
+        self,
+        name: str,
+        total: int | None = None,
+        *,
+        screen: Screen | None = None,
+        clock: Callable[[], float] | None = None,
+        refresh_seconds: float = REFRESH_SECONDS,
+        heartbeat_seconds: float = HEARTBEAT_SECONDS,
+    ) -> None:
+        self.name = name
+        self.total = total if total is not None and total > 0 else None
+        self.done = 0
+        self.detail = ""
+        self.summary = ""
+        self._screen = screen or SCREEN
+        self._clock = clock or time.monotonic
+        self._refresh = refresh_seconds
+        self._heartbeat = heartbeat_seconds
+        self._start = 0.0
+        self._task: asyncio.Task[None] | None = None
+
+    def advance(self, count: int = 1) -> None:
+        self.done += count
+
+    def note(self, text: str) -> None:
+        self.detail = text
+
+    def elapsed(self) -> float:
+        return self._clock() - self._start
+
+    def status(self) -> str:
+        """心跳日志用的一句话:已用时间、进度和当前在做什么。"""
+
+        parts = [f"已用 {self.elapsed():.0f} 秒"]
+        if self.total is not None:
+            parts.append(f"进度 {self.done}/{self.total}({self._percent()}%)")
+        if self.detail:
+            parts.append(f"当前:{self.detail}")
+        return ",".join(parts)
+
+    def render(self, tick: int) -> str:
+        """终端状态行。总数已知画进度条,否则画循环的点。"""
+
+        seconds = f"已用 {self.elapsed():.0f}s"
+        tail = f"  {self.detail}" if self.detail else ""
+        if self.total is not None:
+            filled = min(_BAR_WIDTH, self.done * _BAR_WIDTH // self.total)
+            bar = "█" * filled + "░" * (_BAR_WIDTH - filled)
+            return f"{self.name} [{bar}] {self.done}/{self.total} {self._percent()}%  {seconds}{tail}"
+        dots = _DOTS[tick % len(_DOTS)]
+        return f"{self.name}{dots}  {seconds}{tail}"
+
+    async def __aenter__(self) -> Stage:
+        self._start = self._clock()
+        size = f",共 {self.total} 项" if self.total is not None else ""
+        # depth=1:日志位置写调用方(编排里哪一步),不写进度模块自己。
+        logger.opt(depth=1).info("开始{}{}", self.name, size)
+        self._task = asyncio.create_task(self._loop())
+        return self
+
+    async def __aexit__(
+        self,
+        exc_type: type[BaseException] | None,
+        exc: BaseException | None,
+        tb: TracebackType | None,
+    ) -> bool:
+        task = self._task
+        self._task = None
+        if task is not None:
+            task.cancel()
+            # 只吞刷新任务自己的取消;外层被取消时 gather 照常抛出,不拦截。
+            await asyncio.gather(task, return_exceptions=True)
+        self._screen.clear()
+        if exc_type is None:
+            tail = f",{self.summary}" if self.summary else ""
+            logger.opt(depth=1).info("{}完成,用时 {:.1f} 秒{}", self.name, self.elapsed(), tail)
+        else:
+            logger.opt(depth=1).info("{}中断,已用 {:.1f} 秒", self.name, self.elapsed())
+        return False
+
+    async def _loop(self) -> None:
+        interactive = self._screen.interactive()
+        tick = 0
+        while True:
+            await asyncio.sleep(self._refresh if interactive else self._heartbeat)
+            tick += 1
+            if interactive:
+                self._screen.show(self.render(tick))
+            else:
+                logger.info("仍在{},{}", self.name, self.status())
+
+    def _percent(self) -> int:
+        if self.total is None:
+            return 0
+        return min(100, self.done * 100 // self.total)
+
+
+class WatchedGateway:
+    """包住业务查询入口,把第几条查询、读哪张表报给步骤。只转发,不改语句、参数和结果。"""
+
+    def __init__(self, inner: Any, stage: Stage) -> None:
+        self._inner = inner
+        self._stage = stage
+        self.count = 0
+
+    async def execute(self, sql: str, args: Any = None) -> list[tuple[Any, ...]]:
+        self.count += 1
+        found = _TABLE.search(sql)
+        table = found.group(1) if found else "业务库"
+        self._stage.advance()
+        self._stage.note(f"第 {self.count} 条查询,读 {table}")
+        return await self._inner.execute(sql, args)
+
+
+def _columns() -> int:
+    return shutil.get_terminal_size(fallback=(120, 24)).columns
+
+
+def _fit(text: str, columns: int) -> str:
+    """按显示宽度截短,中文算两格。状态行折行后 \\r 就擦不干净了。"""
+
+    limit = max(10, columns - 1)
+    width = 0
+    kept: list[str] = []
+    for char in text:
+        size = 2 if unicodedata.east_asian_width(char) in {"W", "F"} else 1
+        if width + size > limit:
+            break
+        kept.append(char)
+        width += size
+    return "".join(kept)

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.