Преглед изворни кода

refactor#明细展示调整.

hefuzheng пре 1 недеља
родитељ
комит
3729845027

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

@@ -1,10 +1,12 @@
 pyproject.toml
 src/post_clear_check/__init__.py
 src/post_clear_check/__main__.py
+src/post_clear_check/buy_execution.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/fund_chain.py
 src/post_clear_check/gate.py
 src/post_clear_check/hedge_link.py
 src/post_clear_check/issue_common.py
@@ -14,11 +16,13 @@ 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/note_chain.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/published.py
 src/post_clear_check/reader.py
 src/post_clear_check/sales_asset.py
 src/post_clear_check/sales_cash.py
@@ -28,8 +32,10 @@ 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/sell_execution.py
 src/post_clear_check/session.py
 src/post_clear_check/trs_account.py
+src/post_clear_check/trs_chain.py
 src/post_clear_check/trs_common.py
 src/post_clear_check/trs_flow.py
 src/post_clear_check/trs_reports.py
@@ -43,10 +49,12 @@ src/post_clear_check.egg-info/top_level.txt
 tests/test_chain.py
 tests/test_config.py
 tests/test_feishu.py
+tests/test_fund_chain.py
 tests/test_gate.py
 tests/test_hedge_link.py
 tests/test_issue_reports.py
 tests/test_main.py
+tests/test_note_chain.py
 tests/test_orchestrate.py
 tests/test_order_rules.py
 tests/test_progress.py
@@ -54,4 +62,5 @@ tests/test_reader.py
 tests/test_sales_reports.py
 tests/test_schema.py
 tests/test_session.py
+tests/test_trs_chain.py
 tests/test_trs_reports.py

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__/orchestrate.cpython-312.pyc


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

@@ -60,6 +60,13 @@ class Settings(BaseSettings):
     # 空串表示未配置,正确、错误、可疑都推。多选用逗号、顿号或空格分开。
     feishu_push: str = Field(default="", description="推送飞书的结论类型,空为全部")
 
+    # 每条链路本次最多发几张明细。空和 0 都表示不限制。
+    feishu_push_limit: int | None = Field(
+        default=None,
+        ge=0,
+        description="每条链路最多推送的明细张数,0 或空为全部",
+    )
+
     @field_validator("business_date", mode="before")
     @classmethod
     def _blank_business_date(cls, value: object) -> object:
@@ -95,6 +102,29 @@ class Settings(BaseSettings):
         chosen = [label for label in PUSH_LABELS if label in parts]
         return "、".join(chosen)
 
+    @field_validator("feishu_push_limit", mode="before")
+    @classmethod
+    def _blank_push_limit(cls, value: object) -> object:
+        """空串当成没填。0 留给后面收成不限制。"""
+
+        if value is None:
+            return None
+        if isinstance(value, str):
+            text = value.strip()
+            if not text:
+                return None
+            return text
+        return value
+
+    @field_validator("feishu_push_limit")
+    @classmethod
+    def _zero_push_limit_means_all(cls, value: int | None) -> int | None:
+        """0 和空都是全部推送。正数才是张数上限。"""
+
+        if value is None or value == 0:
+            return None
+        return value
+
     # 飞书自定义机器人。发送时才取出明文,日志和异常文本里不出现。
     feishu_webhook: SecretStr = Field(default_factory=lambda: SecretStr(""), description="飞书 Webhook")
     feishu_sign_secret: SecretStr = Field(
@@ -149,6 +179,12 @@ def push_verdicts(settings: Settings) -> frozenset[str] | None:
     return frozenset(PUSH_VERDICT[label] for label in settings.feishu_push.split("、"))
 
 
+def push_card_limit(settings: Settings) -> int | None:
+    """每条链路本次最多发几张明细。空和 0 已经收成不限制。"""
+
+    return settings.feishu_push_limit
+
+
 def push_log_suffix(settings: Settings) -> str:
     """未配置推送类型时,挂到已有飞书日志后面。配了类型就留空。"""
 

+ 562 - 47
code/post_clear_check/src/post_clear_check/feishu.py

@@ -2,7 +2,7 @@
 
 不读取业务库,不决定哪张卡该不该发,不写自有库。
 一次调用可以拆成多条请求,单条失败是否重试交给调用方。
-链路概况和明细卡是两种版式:概况只列各档归属;明细仍是逐节点的依据和代入。
+链路概况和明细卡是两种版式:概况只列各档归属;明细按摘要和五章写,正确列收成覆盖块。
 """
 
 from __future__ import annotations
@@ -25,6 +25,8 @@ import httpx
 from loguru import logger
 
 from post_clear_check.chain import (
+    CHAIN_BUY,
+    CHAIN_SELL,
     CHAINS,
     OWNER_BATCH,
     OWNER_FUND,
@@ -35,7 +37,7 @@ from post_clear_check.chain import (
 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
@@ -129,6 +131,8 @@ class OutboundCard:
     # 上次发送失败后再发时标题带「重发」。首次 pending 不加。
     resend: bool = False
     owners: tuple[SummaryOwner, ...] = ()
+    # 配了推送条数时,这条链路本次抽样的序号,从 1 起。没配时为空,正文不写抽样行。
+    sample_index: int | None = None
 
 
 @dataclass
@@ -402,33 +406,424 @@ def _keep_summary_front(
         left = 0
 
 
-def render_line(line: ConclusionView) -> str:
-    """一个节点一段。依据、公式、代入式、说明、被核对的对象、期望结论各占一行,相邻行之间空一行。
+# 被核对对象末尾的归属。主订单编号收成「主订单」,避免和标题重复一整句。
+_OWNER_TAIL = re.compile(r"(([^)]+))\s*$")
+_BASIS_COLUMN = re.compile(r"「[^」]*」\s*$")
+_OWNER_LABELS = (
+    ("主订单编号 ", "主订单 "),
+    ("TRS用户账号 ", "用户账号 "),
+    ("用户账号 ", "用户账号 "),
+    ("资金账户编号 ", "资金账户 "),
+    ("资金账户 ", "资金账户 "),
+)
+# 正确行进「不适用」章的标记。只看公式和说明里已经写过的字,不改结论。
+_NA_MARKS = ("不出现", "不适用", "未触发")
+
+
+@dataclass(frozen=True)
+class _CorrectGroup:
+    """同一报表、同一依据的正确列。超长时整块删,不拆开。"""
+
+    report: str
+    basis: str
+    lines: tuple[ConclusionView, ...]
+
+
+def _show_substitution(formula: str, subst: str) -> bool:
+    """代入式和公式右侧相同,或整段就是公式时,不再写一行代入。"""
+
+    if not subst or subst == formula:
+        return False
+    if "=" in formula and subst == formula.split("=", 1)[1].strip():
+        return False
+    return True
+
+
+def _keep_note(note: str) -> bool:
+    """同行等式已经由公式和报表标题表达。回写、口径差异这类说明留下。"""
+
+    compact = re.sub(r"\s+", "", note).rstrip("。")
+    return not compact.endswith("同行等式")
+
+
+def _check_head(check: str) -> tuple[str, str]:
+    """从被核对对象里取出报表名和归属。分不出报表名时只返回归属。"""
+
+    text = card_text(check).strip()
+    owner = ""
+    match = _OWNER_TAIL.search(text)
+    if match:
+        owner = _owner_line(match.group(1))
+        text = text[: match.start()].rstrip()
+    left = text
+    for separator in (" = ", " 缺失", " 无行", " 对不上"):
+        if separator in left:
+            left = left.split(separator, 1)[0]
+            break
+    if "." not in left:
+        return "", owner
+    report, _column = left.rsplit(".", 1)
+    return report.strip(), owner
+
+
+def _owner_line(inner: str) -> str:
+    text = inner.strip()
+    for prefix, label in _OWNER_LABELS:
+        if text.startswith(prefix):
+            return label + text[len(prefix) :].strip()
+    return text
+
+
+def _basis_doc(basis: str) -> str:
+    """去掉依据末尾的列名,同一份原型或同一行 Excel 只写一次。"""
+
+    return _BASIS_COLUMN.sub("", card_text(basis)).strip()
+
+
+def _line_parts(line: ConclusionView) -> tuple[str, str, tuple[str, ...]]:
+    """拆出公式、代入式,以及分号后面要留下的说明。"""
+
+    formula = card_text(line.formula_text).strip()
+    parts = [part.strip() for part in card_text(line.calc_text).split(";") if part.strip()]
+    subst = parts[0] if parts else ""
+    notes = tuple(part for part in parts[1:] if _keep_note(part))
+    return formula, subst, notes
+
+
+def _is_not_applicable(line: ConclusionView) -> bool:
+    """公式或说明里已经写了不出现、不适用、未触发的正确行,进不适用章。"""
+
+    if line.verdict != VERDICT_CORRECT:
+        return False
+    formula, _subst, notes = _line_parts(line)
+    blob = formula + "\n" + "\n".join(notes)
+    return any(mark in blob for mark in _NA_MARKS)
+
+
+def _place(line: ConclusionView) -> tuple[str, str, str]:
+    """报表名、归属、去掉列名后的依据。筛选和正确覆盖块都用这三项。"""
+
+    report, owner = _check_head(line.check_text)
+    return report, owner, _basis_doc(line.basis)
 
-    飞书 lark_md 把单个换行显示成空格,这段若只用一个换行会从依据一直挤到期望结论。
-    """
+
+def _scope_rows(chain_name: str, report: str, owners: Sequence[str]) -> list[str]:
+    """报表名和归属。没有报表名时用链路名称,避免筛选空着。"""
+
+    rows: list[str] = []
+    if report:
+        rows.append(f"报表:{report}")
+    scope: list[str] = []
+    if not report:
+        chain = CHAINS.get(chain_name)
+        if chain is None:
+            raise FeishuError("卡片链路不在登记表之内")
+        scope.append(chain.name)
+    seen: list[str] = []
+    for owner in owners:
+        if owner and owner not in seen:
+            seen.append(owner)
+    if seen:
+        scope.append("、".join(seen))
+    if scope:
+        rows.append("筛选:" + " · ".join(scope))
+    return rows
+
+
+def _expect_label(line: ConclusionView, formula: str) -> str:
+    """期望行:有公式写公式。期望值算不出的可疑写无法计算。"""
+
+    if line.calc_value is None and line.verdict == VERDICT_SUSPECT:
+        return "期望值无法计算"
+    if formula:
+        return formula
+    expected = _value_text(line.calc_value)
+    if expected is None:
+        return "期望与当前一致"
+    return f"期望 {expected}"
+
+
+def _actual_text(line: ConclusionView, formula: str, subst: str) -> str:
+    """实据:代入式和当前值。代入式与公式重复时不写。"""
+
+    current = _value_text(line.check_value)
+    current_text = "当前缺失" if current is None else f"当前 {current}"
+    if subst and _show_substitution(formula, subst):
+        return f"{subst}。{current_text}"
+    if line.calc_value is None:
+        return current_text
+    expected = _value_text(line.calc_value)
+    if expected is None:
+        return current_text
+    return f"期望 {expected},{current_text}"
+
+
+def _conclusion(line: ConclusionView, notes: Sequence[str]) -> str:
+    """结论句接上说明和原来的期望句。说明不再单独成行。"""
+
+    bits = [VERDICT_LABEL[line.verdict]]
+    for note in notes:
+        cleaned = note.strip().rstrip("。")
+        if cleaned:
+            bits.append(cleaned)
+    tail = expectation(line).strip().rstrip("。")
+    if tail:
+        bits.append(tail)
+    return "结论:" + "。".join(bits) + "。"
+
+
+def _detail_block(line: ConclusionView, chain_name: str) -> str:
+    """错误、可疑、无法展开、不适用:一行六句。相邻句之间空一行。"""
 
     if line.verdict not in VERDICT_LABEL:
         raise FeishuError("结论不在正确、错误、可疑之内")
+    formula, subst, notes = _line_parts(line)
+    report, owner, basis = _place(line)
+    rows = [f"节点:{formula or '核对'}"]
+    rows.extend(_scope_rows(chain_name, report, (owner,) if owner else ()))
+    rows.append(f"期望:{_expect_label(line, formula)}")
+    rows.append(f"实据:{_actual_text(line, formula, subst)}")
+    if basis:
+        rows.append(f"依据:{basis}")
+    rows.append(_conclusion(line, notes))
+    return "\n\n".join(rows)
+
+
+def _correct_groups(lines: Sequence[ConclusionView]) -> tuple[_CorrectGroup, ...]:
+    """其余正确行按报表和依据归块。同一块在正文里只出现一次。"""
+
+    order: list[tuple[str, str]] = []
+    grouped: dict[tuple[str, str], list[ConclusionView]] = {}
+    for line in lines:
+        if line.verdict != VERDICT_CORRECT or _is_not_applicable(line):
+            continue
+        report, _owner, basis = _place(line)
+        key = (report, basis)
+        bucket = grouped.get(key)
+        if bucket is None:
+            order.append(key)
+            bucket = []
+            grouped[key] = bucket
+        bucket.append(line)
+    return tuple(_CorrectGroup(report, basis, tuple(grouped[(report, basis)])) for report, basis in order)
+
+
+def _correct_block(group: _CorrectGroup, chain_name: str) -> str:
+    """同一报表、同一依据的正确列收成一块,实据只写列数。"""
+
+    formulas: list[str] = []
+    owners: list[str] = []
+    notes: list[str] = []
+    only_subst = ""
+    only_formula = ""
+    for line in group.lines:
+        formula, subst, line_notes = _line_parts(line)
+        if formula and formula not in formulas:
+            formulas.append(formula)
+        _report, owner, _basis = _place(line)
+        if owner and owner not in owners:
+            owners.append(owner)
+        for note in line_notes:
+            if note not in notes:
+                notes.append(note)
+        if len(group.lines) == 1:
+            only_subst = subst
+            only_formula = formula
+    rows = _scope_rows(chain_name, group.report, owners)
+    rows.append("节点:" + ("、".join(formulas) if formulas else "核对"))
+    rows.append("期望:与节点公式一致" if len(formulas) <= 1 else "期望:各列与节点公式一致")
+    actual = f"{len(group.lines)} 列无差异"
+    # 只有一列时仍把代入式写进实据。多列不再逐列展开。
+    if len(group.lines) == 1 and only_subst and _show_substitution(only_formula, only_subst):
+        actual = f"{only_subst}。{actual}"
+    rows.append(f"实据:{actual}")
+    if group.basis:
+        rows.append(f"依据:{group.basis}")
+    bits = ["正确"]
+    for note in notes:
+        cleaned = note.strip().rstrip("。")
+        if cleaned:
+            bits.append(cleaned)
+    if len(bits) == 1:
+        rows.append("结论:正确")
+    else:
+        rows.append("结论:" + "。".join(bits) + "。")
+    return "\n\n".join(rows)
+
+
+def _chapter(title: str, blocks: Sequence[str]) -> str:
+    """一章。没有行时写「无」,章标题和内容之间空一行。"""
+
+    if not blocks:
+        return f"{title}\n\n无"
+    return "\n\n".join((title, *blocks))
+
+
+def _summary_text(*, correct: int, error: int, suspect: int, omitted: int, error_theme: bool) -> str:
+    """摘要只数这张正文里的行。删掉的正确列另写一句,错误主题说明其余留在核对明细。"""
+
+    text = f"本卡正确 {correct},错误 {error},可疑 {suspect}。"
+    if omitted:
+        text += f"正确另有 {omitted} 列未写入本卡。"
+    if error_theme:
+        text += "正确与可疑留在核对明细。"
+    return "摘要\n\n" + text
+
+
+# 买入执行、卖出执行按巡检的四章排,其它链路仍用摘要和清单。
+_ORDER_CHAINS = frozenset({CHAIN_BUY, CHAIN_SELL})
+_ORDER_CALC_SECTIONS = (
+    ("intraday", "盘中公式"),
+    ("clear", "清算对照"),
+    ("report", "关联报表"),
+)
+_ORDER_SEVERITY = {VERDICT_ERROR: 0, VERDICT_SUSPECT: 1, VERDICT_CORRECT: 2}
+
+
+def _order_part(line: ConclusionView) -> str:
+    """订单卡上的一行归到哪一章。只看公式和被核对对象里已有的字。"""
+
+    formula = card_text(line.formula_text)
+    report, _owner, _basis = _place(line)
+    if report or "报表" in formula or formula.startswith("OTC"):
+        return "report"
+    if "工作快照" in formula or "已发布结果" in formula or "当前投影" in formula:
+        return "clear"
+    if formula.startswith("执行事实") or "执行根" in formula:
+        return "link"
+    return "intraday"
+
+
+def _rank_lines(lines: Sequence[ConclusionView]) -> tuple[ConclusionView, ...]:
+    """错误在前,其次可疑,正确留在后面。同一档保持原来的顺序。"""
+
+    ranked = sorted(enumerate(lines), key=lambda item: (_ORDER_SEVERITY.get(item[1].verdict, 9), item[0]))
+    return tuple(line for _index, line in ranked)
+
+
+def _order_actual(line: ConclusionView) -> str:
+    """实据写被核对对象。对象里没有当前值时再补当前值。"""
+
+    check = card_text(line.check_text).strip()
+    current = _value_text(line.check_value)
+    if check and current is not None and current not in check:
+        return f"{check}。当前 {current}"
+    if check:
+        return check
+    if current is not None:
+        return f"当前 {current}"
+    return "当前缺失"
+
+
+def _order_expect(line: ConclusionView, formula: str, subst: str) -> str:
+    """期望写代入式。没有代入式时写公式右侧。"""
+
+    if line.calc_value is None and line.verdict == VERDICT_SUSPECT:
+        return "期望值无法计算"
+    if subst and _show_substitution(formula, subst):
+        return subst
+    if "=" in formula:
+        return formula.split("=", 1)[1].strip()
+    if formula:
+        return "与字段公式一致"
+    expected = _value_text(line.calc_value)
+    return expected or "与字段公式一致"
+
+
+def _order_conclusion(line: ConclusionView, notes: Sequence[str]) -> str:
+    """正确只写「正确」。错误和可疑带上原来的判定词,说明接在后面。"""
+
+    if line.verdict == VERDICT_ERROR:
+        tail = "错误。不符合预期"
+    elif line.verdict == VERDICT_SUSPECT and line.calc_value is None:
+        tail = "可疑。期望值无法计算"
+    elif line.verdict == VERDICT_SUSPECT:
+        tail = "可疑。无法判定"
+    else:
+        tail = "正确"
+    bits = [tail]
+    for note in notes:
+        cleaned = note.strip().rstrip("。")
+        if cleaned:
+            bits.append(cleaned)
+    text = "。".join(bits)
+    if not text.endswith("。"):
+        text += "。"
+    return f"结论:{text}"
+
+
+def _order_field_block(line: ConclusionView) -> str:
+    """一个计算:字段、期望、实据、依据、结论。相邻行空一行,飞书才不会粘成一句。"""
+
+    formula, subst, notes = _line_parts(line)
+    field = formula.split("=", 1)[0].strip() if "=" in formula else (formula or "核对")
     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),
+        f"字段:{field}",
+        f"期望:{_order_expect(line, formula, subst)}",
+        f"实据:{_order_actual(line)}",
     ]
-    return "\n\n".join(row for row in rows if row.strip())
+    _report, _owner, basis = _place(line)
+    if basis:
+        rows.append(f"依据:{basis}")
+    rows.append(_order_conclusion(line, notes))
+    return "\n\n".join(rows)
 
 
-def _clause_rows(text: str) -> tuple[str, ...]:
-    """第一段是代入式,分号后面的每段是说明。两行都带前缀,拆开后不会只剩一个数字或一句裸说明。"""
+def _link_sentence(line: ConclusionView) -> 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)
+    formula = card_text(line.formula_text).strip().replace(" = ", "等于 ")
+    return formula.rstrip("。") or "链路对照一致"
+
+
+def _link_section(lines: Sequence[ConclusionView]) -> str:
+    """每单链路只给结论。都正确时收成一段;有问题的行才按字段展开。"""
+
+    correct = [line for line in lines if line.verdict == VERDICT_CORRECT]
+    bad = [line for line in lines if line.verdict != VERDICT_CORRECT]
+    bits: list[str] = []
+    if bad:
+        label = "错误" if any(line.verdict == VERDICT_ERROR for line in bad) else "可疑"
+        sentences = [_link_sentence(line) for line in correct]
+        head = f"结论:{label}。"
+        if sentences:
+            head += "。".join(sentences) + "。"
+        bits.append(head)
+        bits.extend(_order_field_block(line) for line in _rank_lines(bad))
+        return "\n\n".join(bits)
+    sentences = [_link_sentence(line) for line in correct]
+    return "结论:正确。" + "。".join(sentences) + "。"
+
+
+def _order_chapters(
+    lines: Sequence[ConclusionView],
+    *,
+    error_theme: bool,
+    omitted: int,
+) -> list[str]:
+    """四章。空章不写。删掉的正确计算在最后补一句。"""
+
+    buckets: dict[str, list[ConclusionView]] = {"link": [], "intraday": [], "clear": [], "report": []}
+    for line in lines:
+        buckets[_order_part(line)].append(line)
+    pieces: list[str] = []
+    if error_theme:
+        pieces.append("正确与可疑留在核对明细。")
+    if buckets["link"]:
+        pieces.append("每单链路\n\n" + _link_section(buckets["link"]))
+    for key, title in _ORDER_CALC_SECTIONS:
+        blocks = [_order_field_block(line) for line in _rank_lines(buckets[key])]
+        if blocks:
+            pieces.append(title + "\n\n" + "\n\n".join(blocks))
+    if omitted:
+        pieces.append(f"另有 {omitted} 项未写入本卡。")
+    return pieces
+
+
+def _order_calc_lines(lines: Sequence[ConclusionView]) -> list[ConclusionView]:
+    """可以因超长删掉的计算行。每单链路不在这里,错误和可疑后面再跳过。"""
+
+    return [line for line in lines if _order_part(line) != "link"]
 
 
 def expectation(line: ConclusionView) -> str:
@@ -438,12 +833,12 @@ def expectation(line: ConclusionView) -> str:
     current = _value_text(line.check_value)
     if expected is None and current is None:
         if line.verdict == VERDICT_SUSPECT:
-            return "期望值无法计算,当前值缺失。"
+            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}"
+    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:
@@ -462,40 +857,156 @@ def render_body(
     lines: Sequence[ConclusionView],
     record_no: str,
     extra: str = "",
+    sample_index: int | None = None,
+    theme: str | None = None,
 ) -> str:
-    """营业日、交易日、批次号、涉及在前,其后每个节点一段。超长时从末尾删正确段,开头四行不删。"""
+    """开头四行之后按章写。超长时从正确章末尾删覆盖块,开头不删。
+
+    theme 是整张卡的总结论。错误主题只写错误清单。没传时,只有全部都是错误行才按错误主题写。
+    """
 
     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()}"
+    for line in lines:
+        if line.verdict not in VERDICT_LABEL:
+            raise FeishuError("结论不在正确、错误、可疑之内")
+    # 抽样行放在营业日之前。没配条数时不写,避免和全量推送的卡片混在一起。
+    opening = [f"抽样明细第 {sample_index} 条"] if sample_index is not None else []
+    header = "\n".join(
+        [
+            *opening,
+            f"营业日:{_day_text(business_date)}",
+            f"交易日:{_day_text(trade_date)}",
+            f"批次号:{_one_line(batch_no, '批次号')}",
+            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])
+    # 拆开的一张里可能只剩错误行。整张卡不是错误主题时,仍按五章写,不把其余行说成留在库里。
+    error_theme = theme == VERDICT_ERROR
+    if theme is None:
+        error_theme = bool(lines) and all(line.verdict == VERDICT_ERROR for line in lines)
+
+    if chain_name in _ORDER_CHAINS:
+        return _render_order_body(
+            header=header,
+            tail=tail,
+            lines=lines,
+            record_no=record_no,
+            error_theme=error_theme,
+        )
+
+    def finish(pieces: list[str], record: str) -> str:
+        if record:
+            pieces.append(record)
+        if tail:
+            pieces.append(tail)
+        return _join(pieces)
+
+    if error_theme:
+        shown = [line for line in lines if line.verdict == VERDICT_ERROR]
+
+        def compose_error(record: str) -> str:
+            pieces = [
+                header,
+                _summary_text(correct=0, error=len(shown), suspect=0, omitted=0, error_theme=True),
+                _chapter("错误清单", [_detail_block(line, chain_name) for line in shown]),
+            ]
+            return finish(pieces, record)
+
+        full = compose_error("")
+        if len(full) <= BODY_LIMIT:
+            return full
+        # 拆开发送时记录编号写在 extra 里,这里不再加第二行。错误行不删。
+        record = "" if tail else f"记录编号:{_one_line(record_no, '记录编号')}"
+        return compose_error(record)
+
+    errors = [line for line in lines if line.verdict == VERDICT_ERROR]
+    suspects = [line for line in lines if line.verdict == VERDICT_SUSPECT and line.calc_value is not None]
+    blocked = [line for line in lines if line.verdict == VERDICT_SUSPECT and line.calc_value is None]
+    na_lines = [line for line in lines if _is_not_applicable(line)]
+    groups = list(_correct_groups(lines))
+
+    def compose_full(kept: Sequence[_CorrectGroup], omitted: int, record: str) -> str:
+        correct = len(na_lines) + sum(len(group.lines) for group in kept)
+        pieces = [
+            header,
+            _summary_text(
+                correct=correct,
+                error=len(errors),
+                suspect=len(suspects) + len(blocked),
+                omitted=omitted,
+                error_theme=False,
+            ),
+            _chapter("错误清单", [_detail_block(line, chain_name) for line in errors]),
+            _chapter("可疑清单", [_detail_block(line, chain_name) for line in suspects]),
+            _chapter("无法展开", [_detail_block(line, chain_name) for line in blocked]),
+            _chapter("正确", [_correct_block(group, chain_name) for group in kept]),
+            _chapter("不适用", [_detail_block(line, chain_name) for line in na_lines]),
+        ]
+        return finish(pieces, record)
+
+    full = compose_full(groups, 0, "")
     if len(full) <= BODY_LIMIT:
         return full
-    # 拆开发送时记录编号写在 extra 里,这里不再加第二行。
     record = "" if tail else f"记录编号:{_one_line(record_no, '记录编号')}"
-    kept = list(range(len(lines)))
+    omitted = 0
     while True:
-        pieces = [header, *[blocks[index] for index in kept]]
+        current = compose_full(groups, omitted, record)
+        if len(current) <= BODY_LIMIT or not groups:
+            return current
+        # 从正确章末尾整块删。不适用、错误、可疑、无法展开留下。
+        dropped = groups.pop()
+        omitted += len(dropped.lines)
+
+
+def _render_order_body(
+    *,
+    header: str,
+    tail: str,
+    lines: Sequence[ConclusionView],
+    record_no: str,
+    error_theme: bool,
+) -> str:
+    """买入、卖出明细:每单链路一句结论,其余三章按字段列出。超长只删正确计算。"""
+
+    def finish(pieces: list[str], record: str) -> str:
         if record:
             pieces.append(record)
         if tail:
             pieces.append(tail)
-        current = _join(pieces)
+        return _join(pieces)
+
+    kept = list(lines)
+
+    def compose(omitted: int, record: str) -> str:
+        return finish([header, *_order_chapters(kept, error_theme=error_theme, omitted=omitted)], record)
+
+    full = compose(0, "")
+    if len(full) <= BODY_LIMIT:
+        return full
+    record = "" if tail else f"记录编号:{_one_line(record_no, '记录编号')}"
+    omitted = 0
+    while True:
+        current = compose(omitted, record)
         if len(current) <= BODY_LIMIT:
             return current
-        drop_at = _last_correct(lines, kept)
+        drop_at = _last_order_correct(kept)
         if drop_at is None:
-            # 错误和可疑已经全部保留,不能再删。
             return current
         del kept[drop_at]
+        omitted += 1
+
+
+def _last_order_correct(lines: list[ConclusionView]) -> int | None:
+    """计算章里最后一条正确行。每单链路和错误、可疑留下。"""
+
+    for index in range(len(lines) - 1, -1, -1):
+        line = lines[index]
+        if line.verdict == VERDICT_CORRECT and _order_part(line) != "link":
+            return index
+    return None
 
 
 def build_payload(
@@ -560,7 +1071,7 @@ def card_verdict(lines: Sequence[ConclusionView]) -> str:
 
 @dataclass(frozen=True)
 class _PlainLine:
-    """截短后仍能交给 render_line 的结论行。数值不截,期望那一行照常写期望值与当前值。"""
+    """截短后仍能交给正文渲染的结论行。数值不截,期望句照常写期望值与当前值。"""
 
     formula_text: str
     calc_text: str
@@ -583,6 +1094,7 @@ def pack_bodies(
     lines: Sequence[ConclusionView],
     signed: bool,
     limit: int = PAYLOAD_BYTE_LIMIT,
+    sample_index: int | None = None,
 ) -> tuple[str, ...]:
     """按真实请求体字节数装箱。错误主题只装错误行;放不下就拆成多张,单行仍超限再截短。"""
 
@@ -599,6 +1111,7 @@ def pack_bodies(
         "record_no": record_no,
         "signed": signed,
         "limit": limit,
+        "sample_index": sample_index,
     }
     if _fits(usable, extra="", **common):
         return (
@@ -609,6 +1122,8 @@ def pack_bodies(
                 chain_name=chain_name,
                 lines=usable,
                 record_no=record_no,
+                sample_index=sample_index,
+                theme=verdict,
             ),
         )
     # 张数还不知道。先按三位数字留出「第999张,共999张」,真实页码更短。
@@ -644,6 +1159,8 @@ def pack_bodies(
                 lines=group,
                 record_no=record_no,
                 extra=extra,
+                sample_index=sample_index,
+                theme=verdict,
             )
         )
     return tuple(bodies)
@@ -662,6 +1179,7 @@ def _fits(
     record_no: str,
     signed: bool,
     limit: int,
+    sample_index: int | None = None,
 ) -> bool:
     """用和 httpx 相同的 JSON 编码量字节。"""
 
@@ -673,6 +1191,8 @@ def _fits(
         lines=lines,
         record_no=record_no,
         extra=extra,
+        sample_index=sample_index,
+        theme=verdict,
     )
     payload = build_payload(
         title=title,
@@ -697,6 +1217,7 @@ def _clip_line(
     record_no: str,
     signed: bool,
     limit: int,
+    sample_index: int | None = None,
 ) -> _PlainLine:
     """单行超限时从长文本里砍到能装进一张卡。"""
 
@@ -730,6 +1251,7 @@ def _clip_line(
                 record_no=record_no,
                 signed=signed,
                 limit=limit,
+                sample_index=sample_index,
             ):
                 best = values[name]
                 lo = mid + 1
@@ -748,6 +1270,7 @@ def _clip_line(
             record_no=record_no,
             signed=signed,
             limit=limit,
+            sample_index=sample_index,
         ):
             return _PlainLine(**values)
     return _PlainLine(
@@ -791,6 +1314,7 @@ async def send_card(
         record_no=card.record_no,
         lines=card.lines,
         signed=secret != "",
+        sample_index=card.sample_index,
     )
     return await _deliver(
         settings,
@@ -971,15 +1495,6 @@ def _business_code(raw: bytes) -> int | 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)
 

+ 29 - 1
code/post_clear_check/src/post_clear_check/orchestrate.py

@@ -19,6 +19,7 @@ from loguru import logger
 from post_clear_check.config import (
     Settings,
     describe_exception,
+    push_card_limit,
     push_log_suffix,
     push_verdicts,
     resolve_business_date,
@@ -921,7 +922,10 @@ async def _send_unsent(
         owners = _owners_by_chain(rows)
         stage.total = len(cards) or None
         allowed = push_verdicts(settings)
-        tally = {NOTIFY_SENT: 0, NOTIFY_FAILED: 0, "skipped": 0}
+        limit = push_card_limit(settings)
+        # 按链路分别计数。只统计这次真正发出的明细,概况和被类型过滤掉的不占名额。
+        used: dict[str, int] = {}
+        tally = {NOTIFY_SENT: 0, NOTIFY_FAILED: 0, "skipped": 0, "capped": 0}
         for card in cards:
             stage.note(f"{card.chain_name} {card.biz_key}")
             tally[
@@ -933,6 +937,8 @@ async def _send_unsent(
                     send_fn,
                     batch_no,
                     allowed=allowed,
+                    limit=limit,
+                    used=used,
                     owners=owners.get(card.chain_name, ()),
                 )
             ] += 1
@@ -941,6 +947,8 @@ async def _send_unsent(
             f"已发送 {tally[NOTIFY_SENT]} 张,失败 {tally[NOTIFY_FAILED]} 张,"
             f"未推送 {tally['skipped']} 张(无结论行、无归属或不在推送配置内)"
         )
+        if tally["capped"]:
+            stage.summary += f",超出条数 {tally['capped']} 张仍待下次发送"
 
 
 async def _send_outbound(settings: Settings, card: OutboundCard) -> SendResult:
@@ -973,11 +981,14 @@ async def _send_one(
     batch_no: str | None,
     *,
     allowed: frozenset[str] | None,
+    limit: int | None = None,
+    used: dict[str, int] | None = None,
     owners: Sequence[SummaryOwner] = (),
 ) -> str:
     """发一张卡。成功改 sent,失败改 failed;没发时返回 skipped。返回值只给进度汇总用。
 
     概况卡不看推送配置:配置成只推错误时,正确链路的概况仍然先发。过滤只留在明细卡上。
+    条数上限按链路分别取待发列表的前几张。超出的保持 pending,下次启动再从该链路剩余列表前面取。
     """
 
     summary = card.card_kind == CARD_SUMMARY
@@ -996,7 +1007,23 @@ async def _send_one(
             label,
         )
         return "skipped"
+    elif not summary and limit is not None:
+        taken = 0 if used is None else used.get(card.chain_name, 0)
+        if taken >= limit:
+            logger.info(
+                "飞书卡片超出推送条数,{} {} {},本链路本次只推前 {} 张",
+                card.chain_name,
+                card.card_kind,
+                card.biz_key,
+                limit,
+            )
+            return "capped"
+        if used is not None:
+            used[card.chain_name] = taken + 1
     shown = batch_no if batch_no is not None else card.biz_key
+    sample_index = None
+    if not summary and limit is not None and used is not None:
+        sample_index = used[card.chain_name]
     outbound = OutboundCard(
         card_kind=card.card_kind,
         chain_name=card.chain_name,
@@ -1008,6 +1035,7 @@ async def _send_one(
         record_no=str(card.id),
         resend=card.notify_status == NOTIFY_FAILED,
         owners=tuple(owners) if summary else (),
+        sample_index=sample_index,
     )
     try:
         result = await send_fn(settings, outbound)

BIN
code/post_clear_check/tests/__pycache__/test_config.cpython-312-pytest-9.1.1.pyc


BIN
code/post_clear_check/tests/__pycache__/test_feishu.cpython-312-pytest-9.1.1.pyc


BIN
code/post_clear_check/tests/__pycache__/test_orchestrate.cpython-312-pytest-9.1.1.pyc


+ 39 - 0
code/post_clear_check/tests/test_config.py

@@ -37,6 +37,7 @@ EXPECTED_FIELDS = {
     "own_password",
     "business_date",
     "feishu_push",
+    "feishu_push_limit",
     "feishu_webhook",
     "feishu_sign_secret",
 }
@@ -76,6 +77,7 @@ def test_settings_has_no_leopard_path_fields() -> None:
 
     assert set(Settings.model_fields) == EXPECTED_FIELDS
     assert sample_settings().feishu_push == ""
+    assert sample_settings().feishu_push_limit is None
     for name, field in Settings.model_fields.items():
         lowered = name.lower()
         assert "leopard" not in lowered
@@ -112,6 +114,43 @@ def test_feishu_push_accepts_empty_and_multiple_labels() -> None:
     )
     assert chosen.feishu_push == "错误、可疑"
     assert push_verdicts(chosen) == frozenset({"error", "suspect"})
+    assert Settings(
+        business_host="127.0.0.1",
+        business_user="business_ro",
+        business_password=SecretStr(BUSINESS_PASSWORD),
+        own_host="127.0.0.1",
+        own_user="own_rw",
+        own_password=SecretStr(OWN_PASSWORD),
+        feishu_push_limit="0",
+    ).feishu_push_limit is None
+    assert Settings(
+        business_host="127.0.0.1",
+        business_user="business_ro",
+        business_password=SecretStr(BUSINESS_PASSWORD),
+        own_host="127.0.0.1",
+        own_user="own_rw",
+        own_password=SecretStr(OWN_PASSWORD),
+        feishu_push_limit="  ",
+    ).feishu_push_limit is None
+    assert Settings(
+        business_host="127.0.0.1",
+        business_user="business_ro",
+        business_password=SecretStr(BUSINESS_PASSWORD),
+        own_host="127.0.0.1",
+        own_user="own_rw",
+        own_password=SecretStr(OWN_PASSWORD),
+        feishu_push_limit="2",
+    ).feishu_push_limit == 2
+    with pytest.raises(ValidationError):
+        Settings(
+            business_host="127.0.0.1",
+            business_user="business_ro",
+            business_password=SecretStr(BUSINESS_PASSWORD),
+            own_host="127.0.0.1",
+            own_user="own_rw",
+            own_password=SecretStr(OWN_PASSWORD),
+            feishu_push_limit="-1",
+        )
     with pytest.raises(ValidationError):
         Settings(
             business_host="127.0.0.1",

+ 239 - 38
code/post_clear_check/tests/test_feishu.py

@@ -18,6 +18,7 @@ from post_clear_check.chain import (
     CHAIN_BUY,
     CHAIN_CASH_CHANGE,
     CHAIN_GATE,
+    CHAIN_SELL,
     CHAIN_TRADE,
     CHAIN_UNASSIGNED,
     CHAINS,
@@ -52,6 +53,7 @@ DAY = date(2026, 10, 15)
 ORDER_NO = "ORD001202610150006"
 WEBHOOK = "https://hook.invalid/marker"
 BUY_INVOLVED = CHAINS[CHAIN_BUY].involved()
+CASH_INVOLVED = CHAINS[CHAIN_CASH_CHANGE].involved()
 
 
 @dataclass(frozen=True)
@@ -73,6 +75,7 @@ def _line(
     check: str = "列 = 1",
     calc_value: Decimal | None = Decimal("1"),
     check_value: Decimal | None = Decimal("1"),
+    basis: str = "依据",
 ) -> Line:
     return Line(
         formula_text=formula,
@@ -80,7 +83,7 @@ def _line(
         calc_value=calc_value,
         check_text=check,
         check_value=check_value,
-        basis="依据",
+        basis=basis,
         verdict=verdict,
     )
 
@@ -261,12 +264,36 @@ def test_http_5xx_is_retryable_and_4xx_is_not() -> None:
 
 
 def test_long_body_keeps_every_error() -> None:
-    """正确行从末尾删。错误和可疑都留下,并写上调用方给的记录编号。"""
-
-    head = _line("头正确", VERDICT_CORRECT)
+    """正确覆盖块从末尾整块删。错误、可疑和开头四行留下,并写上记录编号。"""
+
+    head = Line(
+        formula_text="头正确",
+        calc_text="1 = 1",
+        calc_value=Decimal("1"),
+        check_text="列 = 1",
+        check_value=Decimal("1"),
+        basis="依据甲",
+        verdict=VERDICT_CORRECT,
+    )
     first_error = _line("错误甲", VERDICT_ERROR)
-    long_correct = _line("长正确" + "测" * 4000, VERDICT_CORRECT)
-    tail = _line("尾正确", VERDICT_CORRECT)
+    long_correct = Line(
+        formula_text="长正确" + "测" * 4000,
+        calc_text="1 = 1",
+        calc_value=Decimal("1"),
+        check_text="列 = 1",
+        check_value=Decimal("1"),
+        basis="依据乙",
+        verdict=VERDICT_CORRECT,
+    )
+    tail = Line(
+        formula_text="尾正确",
+        calc_text="1 = 1",
+        calc_value=Decimal("1"),
+        check_text="列 = 1",
+        check_value=Decimal("1"),
+        basis="依据乙",
+        verdict=VERDICT_CORRECT,
+    )
     second_error = _line("错误乙", VERDICT_ERROR)
     suspect = _line("可疑甲", VERDICT_SUSPECT)
     body = render_body(
@@ -284,12 +311,13 @@ def test_long_body_keeps_every_error() -> None:
     assert "头正确" in body
     assert "尾正确" not in body
     assert "长正确" not in body
+    assert "另有 2 项未写入本卡。" in body
     assert "记录编号:81" in body
-    # 删段以段为单位,开头四行(含「涉及」)保留。
+    # 删块以覆盖块为单位,开头四行(含「涉及」)保留。
     assert body.startswith(f"营业日:2026-10-15\n交易日:2026-10-15\n批次号:B001\n涉及:{BUY_INVOLVED}\n\n")
     assert "不符合预期。" in body
     assert "无法判定。" in body
-    assert "。符合预期。" in body
+    assert "结论:正确" in body
 
     huge_errors = tuple(_line(f"错误{index}-" + "错" * 500, VERDICT_ERROR) for index in range(10))
     huge = render_body(
@@ -307,7 +335,7 @@ def test_long_body_keeps_every_error() -> None:
 
 
 def test_short_body_matches_template_without_record_no() -> None:
-    """没超长时不写记录编号。开头四行含「涉及」。代入和说明各自带前缀并空行。"""
+    """订单卡没超长时不写记录编号。报表行进关联报表,五行之间空一行。"""
 
     line = Line(
         formula_text="总金额 = 票据发行价 + 承销费用",
@@ -330,21 +358,19 @@ def test_short_body_matches_template_without_record_no() -> None:
         "营业日:2026-10-15\n"
         "交易日:2026-10-15\n"
         "批次号:B001\n"
-        "涉及:发行端清算步骤3 订单及票据处理;"
-        "发行端清算步骤5 备份清算完成的数据、生成营业日日终数据与核心日常报表、更新T+1实时数据;"
-        "销售端清算步骤3 订单及票据处理;销售端清算步骤7 营业日日切;TRS清算步骤5 订单与资金清算\n"
+        f"涉及:{BUY_INVOLVED}\n"
         "\n"
-        "依据:共赢票据重要字段、计算公式等说明V2 工作表「买入订单」第 28 行「总金额」\n"
+        "关联报表\n"
         "\n"
-        "总金额 = 票据发行价 + 承销费用\n"
+        "字段:总金额\n"
         "\n"
-        "代入:1001000.00 + 0.00 = 1001000.00\n"
+        "期望:1001000.00 + 0.00 = 1001000.00\n"
         "\n"
-        "说明:同一主订单的执行值\n"
+        "实据:OTC003 共赢票据订单报表.总金额(结算币种) = 1001000.00\n"
         "\n"
-        "被核对的对象:OTC003 共赢票据订单报表.总金额(结算币种) = 1001000.00\n"
+        "依据:共赢票据重要字段、计算公式等说明V2 工作表「买入订单」第 28 行\n"
         "\n"
-        "期望值是 1001000.00,当前值是 1001000.00。符合预期。"
+        "结论:正确。同一主订单的执行值。"
     )
     assert "记录编号" not in body
 
@@ -378,19 +404,19 @@ def test_fourth_line_sentences() -> None:
     """第四行四种句式:正确、错误、期望值无法计算、只看出现与否。可疑不下符合与否的结论。"""
 
     correct = _line("甲", VERDICT_CORRECT, calc_value=Decimal("779578.24"), check_value=Decimal("779578.240000"))
-    assert expectation(correct) == "期望值是 779578.24,当前值是 779578.24。符合预期。"
+    assert expectation(correct) == "期望 779578.24,当前 779578.24。符合预期。"
     wrong = _line("乙", VERDICT_ERROR, calc_value=Decimal("-220421.76"), check_value=Decimal("-220400"))
-    assert expectation(wrong) == "期望值是 -220421.76,当前值是 -220400.00。不符合预期。"
+    assert expectation(wrong) == "期望 -220421.76,当前 -220400.00。不符合预期。"
     blind = _line("丙", VERDICT_SUSPECT, calc_value=None, check_value=Decimal("580"))
-    assert expectation(blind) == "期望值无法计算,当前值是 580.00。"
+    assert expectation(blind) == "期望值无法计算,当前 580.00。"
     present = _line("丁", VERDICT_CORRECT, calc_value=None, check_value=None)
     assert expectation(present) == "期望与当前一致。符合预期。"
     absent = _line("戊", VERDICT_ERROR, calc_value=Decimal("5"), check_value=None)
-    assert expectation(absent) == "期望值是 5.00,当前值缺失。不符合预期。"
+    assert expectation(absent) == "期望 5.00,当前缺失。不符合预期。"
     undecided = _line("己", VERDICT_SUSPECT, calc_value=Decimal("1.234567"), check_value=Decimal("1.2346"))
-    assert expectation(undecided) == "期望值是 1.234567,当前值是 1.2346。无法判定。"
+    assert expectation(undecided) == "期望 1.234567,当前 1.2346。无法判定。"
     nothing = _line("庚", VERDICT_SUSPECT, calc_value=None, check_value=None)
-    assert expectation(nothing) == "期望值无法计算,当前值缺失。"
+    assert expectation(nothing) == "期望值无法计算,当前缺失。"
 
 
 def test_chinese_under_character_limit_is_not_cut() -> None:
@@ -689,7 +715,7 @@ def test_rate_gate_waits_instead_of_dropping() -> None:
 
 
 def test_error_theme_posts_only_error_rows() -> None:
-    """主题是错误时,发出的正文只有错误段。正确和可疑不出现,依据到期望结论之间按内容空行。"""
+    """主题是错误时,正文只有错误清单。正确和可疑不出现,摘要说明它们留在核对明细。"""
 
     seen: list[dict[str, object]] = []
 
@@ -727,18 +753,21 @@ def test_error_theme_posts_only_error_rows() -> None:
     assert "可疑公式" not in text
     assert "。符合预期。" not in text
     assert "无法判定。" not in text
+    assert "正确与可疑留在核对明细。" in text
+    assert "\n\n每单链路\n\n" not in text
+    assert "\n\n盘中公式\n\n" not in text
     assert (
+        "关联报表\n\n"
+        "字段:错误公式\n\n"
+        "期望:3 + 1 = 4\n\n"
+        "实据:报表.乙 = 9。当前 9.00\n\n"
         "依据:依据\n\n"
-        "错误公式\n\n"
-        "代入:3 + 1 = 4\n\n"
-        "说明:这是错误说明\n\n"
-        "被核对的对象:报表.乙 = 9\n\n"
-        "期望值是 4.00,当前值是 9.00。不符合预期。"
+        "结论:错误。不符合预期。这是错误说明。"
     ) in text
 
 
 def test_suspect_theme_still_includes_correct_rows() -> None:
-    """主题是可疑时,正文仍带上正确段。只对错误主题收成错误段。"""
+    """主题是可疑时,正文仍带上收敛后的正确行。只对错误主题收成错误清单。"""
 
     lines = (
         _line("正确公式", VERDICT_CORRECT),
@@ -756,9 +785,12 @@ def test_suspect_theme_still_includes_correct_rows() -> None:
         signed=False,
     )
     assert len(bodies) == 1
-    assert "正确公式" in bodies[0]
-    assert "可疑公式" in bodies[0]
-    assert "。符合预期。" in bodies[0]
+    assert "\n\n盘中公式\n\n" in bodies[0]
+    assert "字段:正确公式" in bodies[0]
+    assert "字段:可疑公式" in bodies[0]
+    assert "结论:正确" in bodies[0]
+    assert "结论:可疑。无法判定。" in bodies[0]
+    assert "正确与可疑留在核对明细。" not in bodies[0]
 
 
 def test_send_posts_json_shape() -> None:
@@ -799,12 +831,181 @@ def test_send_posts_json_shape() -> None:
     ]
     text = card["elements"][0]["text"]["content"]
     assert text.startswith(f"营业日:2026-10-15\n交易日:2026-10-15\n批次号:B001\n涉及:{BUY_INVOLVED}\n")
-    assert "\n票据面值(结算币种) + 发行费用(结算币种)\n" in text
-    assert "符合预期。" in text
+    assert "字段:票据面值(结算币种) + 发行费用(结算币种)" in text
+    assert "\n\n关联报表\n\n" in text
+    assert "结论:正确" in text
     assert seen[0]["card"]["header"]["title"]["content"] == f"正确 买入执行 主订单 {ORDER_NO}"
     assert len(seen) == 1
 
 
+def test_same_report_is_one_section_and_errors_come_first() -> None:
+    """错误清单在正确覆盖块前面。同一依据去掉列名。同行等式说明不重复。"""
+
+    basis = "产品原型 报表.md 「列」"
+    correct = Line(
+        formula_text="价格 = 100",
+        calc_text="100;发行端 OTC003 共赢票据订单报表同行等式",
+        calc_value=Decimal("100"),
+        check_text="OTC003 共赢票据订单报表.价格 = 100.00(主订单编号 ORD1)",
+        check_value=Decimal("100"),
+        basis=basis,
+        verdict=VERDICT_CORRECT,
+    )
+    wrong = Line(
+        formula_text="股票数量 = 名义本金(交易币种) / 成交股价",
+        calc_text="1 / 2 = 0.50;回写 after_val 优先",
+        calc_value=Decimal("0.50"),
+        check_text="OTC003 共赢票据订单报表.股票数量 = 9.00(主订单编号 ORD1)",
+        check_value=Decimal("9"),
+        basis="产品原型 报表.md 「股票数量」",
+        verdict=VERDICT_ERROR,
+    )
+    body = render_body(
+        business_date=DAY,
+        trade_date=DAY,
+        batch_no="B001",
+        chain_name=CHAIN_BUY,
+        lines=(correct, wrong),
+        record_no="81",
+    )
+    assert body.index("股票数量") < body.index("价格 = 100")
+    assert "\n\n关联报表\n\n" in body
+    assert body.count("依据:产品原型 报表.md") == 2
+    assert "主订单编号 ORD1" in body
+    assert "同行等式" not in body
+    assert "回写 after_val 优先" in body
+    assert "代入:100" not in body
+    assert "被核对的对象" not in body
+
+
+def test_chapters_keep_blocked_rows_and_not_applicable_rows() -> None:
+    """期望值为空的可疑进行无法展开。公式里写了不出现的正确行进行不适用。开头四行仍在。"""
+
+    correct = _line(
+        "价格 = 100",
+        VERDICT_CORRECT,
+        check="OTC003 订单.价格 = 100(主订单编号 ORD1)",
+        basis="产品原型 报表.md 「价格」",
+    )
+    other = _line(
+        "数量 = 2",
+        VERDICT_CORRECT,
+        check="OTC003 订单.数量 = 2(主订单编号 ORD1)",
+        basis="产品原型 报表.md 「数量」",
+    )
+    absent = _line(
+        "金额为 0 的冻结必须不出现",
+        VERDICT_CORRECT,
+        calc="未出现",
+        calc_value=None,
+        check_value=None,
+    )
+    suspect = _line("结算汇率", VERDICT_SUSPECT, calc_value=Decimal("1.56"), check_value=Decimal("1.17"))
+    blocked = _line("实时估值", VERDICT_SUSPECT, calc="最新价缺失", calc_value=None, check_value=None)
+    wrong = _line("总金额", VERDICT_ERROR, calc_value=Decimal("1"), check_value=Decimal("2"))
+    body = render_body(
+        business_date=DAY,
+        trade_date=DAY,
+        batch_no="B001",
+        chain_name=CHAIN_CASH_CHANGE,
+        lines=(correct, other, absent, suspect, blocked, wrong),
+        record_no="81",
+        sample_index=2,
+    )
+    assert body.startswith(
+        f"抽样明细第 2 条\n营业日:2026-10-15\n交易日:2026-10-15\n批次号:B001\n涉及:{CASH_INVOLVED}\n\n"
+    )
+    assert body.index("\n\n错误清单\n\n") < body.index("\n\n可疑清单\n\n")
+    assert body.index("\n\n可疑清单\n\n") < body.index("\n\n无法展开\n\n")
+    assert body.index("\n\n无法展开\n\n") < body.index("\n\n正确\n\n")
+    assert body.index("\n\n正确\n\n") < body.index("\n\n不适用\n\n")
+    suspect_part = body.split("\n\n可疑清单\n\n", 1)[1].split("\n\n无法展开\n\n", 1)[0]
+    blocked_part = body.split("\n\n无法展开\n\n", 1)[1].split("\n\n正确\n\n", 1)[0]
+    correct_part = body.split("\n\n正确\n\n", 1)[1].split("\n\n不适用\n\n", 1)[0]
+    absent_part = body.split("\n\n不适用\n\n", 1)[1]
+    assert "总金额" in body.split("\n\n错误清单\n\n", 1)[1].split("\n\n可疑清单\n\n", 1)[0]
+    assert "结算汇率" in suspect_part
+    assert "实时估值" not in suspect_part
+    assert "实时估值" in blocked_part
+    assert "期望值无法计算" in blocked_part
+    assert "价格 = 100" in correct_part
+    assert "数量 = 2" in correct_part
+    assert "2 列无差异" in correct_part
+    assert "筛选:主订单 ORD1" in correct_part
+    assert "必须不出现" not in correct_part
+    assert "必须不出现" in absent_part
+    assert "结论:正确" in absent_part
+    assert "本卡正确 3,错误 1,可疑 2。" in body
+
+
+def test_order_chain_groups_link_formula_clear_and_report() -> None:
+    """买入、卖出明细按四章排。每单链路只写结论,计算按字段列出。"""
+
+    lines = (
+        _line("执行事实列与源表一致", VERDICT_CORRECT, calc="一致", check="源表一致"),
+        _line(
+            "对冲记录成交股数 = TRS 执行根的成交股数",
+            VERDICT_CORRECT,
+            calc="600 = 600",
+            check="成交股数 = 600",
+        ),
+        _line(
+            "票据面值 = 执行订单名义本金(结算币种) × A 类报价",
+            VERDICT_CORRECT,
+            calc="100 × 10% = 10",
+            check="面值 = 10",
+        ),
+        _line("T日订单结算与工作快照一致", VERDICT_CORRECT, calc="一致", check="cleared 一致"),
+        _line(
+            "总金额 = 票据发行价 + 承销费用",
+            VERDICT_CORRECT,
+            calc="10 + 1 = 11",
+            check="OTC003 共赢票据订单报表.总金额 = 11",
+        ),
+    )
+    body = render_body(
+        business_date=DAY,
+        trade_date=DAY,
+        batch_no="B001",
+        chain_name=CHAIN_BUY,
+        lines=lines,
+        record_no="81",
+    )
+    assert body.index("\n\n每单链路\n\n") < body.index("\n\n盘中公式\n\n")
+    assert body.index("\n\n盘中公式\n\n") < body.index("\n\n清算对照\n\n")
+    assert body.index("\n\n清算对照\n\n") < body.index("\n\n关联报表\n\n")
+    assert "结论:正确。执行事实列与源表一致。对冲记录成交股数等于 TRS 执行根的成交股数。" in body
+    assert "字段:执行事实列与源表一致" not in body
+    assert "字段:票据面值" in body
+    assert "字段:T日订单结算与工作快照一致" in body
+    assert "字段:总金额" in body
+    sell = render_body(
+        business_date=DAY,
+        trade_date=DAY,
+        batch_no="B001",
+        chain_name=CHAIN_SELL,
+        lines=lines[2:3],
+        record_no="81",
+    )
+    assert "\n\n盘中公式\n\n" in sell
+    assert "\n\n每单链路\n\n" not in sell
+
+
+def test_sample_line_is_the_first_body_line() -> None:
+    """配了推送条数时,抽样序号写在营业日前面。"""
+
+    body = render_body(
+        business_date=DAY,
+        trade_date=DAY,
+        batch_no="B001",
+        chain_name=CHAIN_BUY,
+        lines=(_line("公式", VERDICT_CORRECT),),
+        record_no="81",
+        sample_index=1,
+    )
+    assert body.startswith("抽样明细第 1 条\n营业日:2026-10-15\n")
+
+
 def test_body_strips_control_characters_and_angle_brackets() -> None:
     """制表符去掉,尖括号改成全角,避免 lark_md 当成标签。"""
 
@@ -813,14 +1014,14 @@ def test_body_strips_control_characters_and_angle_brackets() -> None:
         trade_date=DAY,
         batch_no="B001",
         chain_name=CHAIN_BUY,
-        lines=(_line("a<b>\tc", VERDICT_ERROR, calc="1\t2", check="x<at>y"),),
+        lines=(_line("a<b>\tc", VERDICT_ERROR, calc="1\t2", check="报<表>.甲 = 1"),),
         record_no="81",
     )
     assert "\t" not in body
     assert "<" not in body
     assert ">" not in body
     assert "a<b>c" in body
-    assert "x<at>y" in body
+    assert "报<表>" in body
 
 
 def test_failure_log_includes_feishu_msg_without_webhook() -> None:

+ 77 - 0
code/post_clear_check/tests/test_orchestrate.py

@@ -1136,6 +1136,83 @@ def test_push_filter_leaves_other_verdicts_pending() -> None:
     assert status[CHAIN_BUY] == "sent"
 
 
+def test_push_limit_samples_each_chain() -> None:
+    """每条链路只发待发列表最前面的张数。另一条链路各自再抽,不共用名额。"""
+
+    conn = _Own()
+    sent: list[object] = []
+    settings = sample_settings().model_copy(update={"feishu_push_limit": 1})
+
+    def orders(_result: object) -> dict[str, list[CheckLine]]:
+        return {
+            "ORD-A": [_order_line("买1", "correct", "ORD-A", CHAIN_BUY)],
+            "ORD-B": [_order_line("买2", "correct", "ORD-B", CHAIN_BUY)],
+            "ORD-C": [_order_line("卖1", "correct", "ORD-C", CHAIN_SELL)],
+            "ORD-D": [_order_line("卖2", "error", "ORD-D", CHAIN_SELL)],
+        }
+
+    async def scenario() -> None:
+        script = _script()
+        script["orders"] = orders
+        script["issue"] = lambda _data: []
+        script["sales"] = lambda _data: []
+        script["trs"] = lambda _data: []
+        await _run(conn, script, sent=sent, settings=settings)
+
+    asyncio.run(scenario())
+    details = [card for card in sent if card.card_kind != CARD_SUMMARY]
+    assert [(card.chain_name, card.biz_key, card.sample_index) for card in details] == [
+        (CHAIN_BUY, "ORD-A", 1),
+        (CHAIN_SELL, "ORD-C", 1),
+    ]
+    status = {row["biz"]: row["status"] for row in conn.results if row["kind"] == "master_order"}
+    assert status == {
+        "ORD-A": "sent",
+        "ORD-B": "pending",
+        "ORD-C": "sent",
+        "ORD-D": "pending",
+    }
+
+    async def again() -> None:
+        script = _script()
+        script["orders"] = orders
+        script["issue"] = lambda _data: []
+        script["sales"] = lambda _data: []
+        script["trs"] = lambda _data: []
+        await _run(conn, script, sent=sent, settings=settings)
+
+    asyncio.run(again())
+    details = [card for card in sent if card.card_kind != CARD_SUMMARY]
+    assert [(card.chain_name, card.biz_key, card.sample_index) for card in details] == [
+        (CHAIN_BUY, "ORD-A", 1),
+        (CHAIN_SELL, "ORD-C", 1),
+        (CHAIN_BUY, "ORD-B", 1),
+        (CHAIN_SELL, "ORD-D", 1),
+    ]
+
+
+def test_push_limit_above_count_sends_all() -> None:
+    """条数大于现有张数时,这一结论的明细全部发送。"""
+
+    conn = _Own()
+    sent: list[object] = []
+    settings = sample_settings().model_copy(update={"feishu_push": "错误", "feishu_push_limit": 9})
+
+    async def scenario() -> None:
+        script = _script()
+        script["orders"] = lambda _result: {
+            "ORD-A": [_order_line("错1", "error", "ORD-A")],
+            "ORD-B": [_order_line("错2", "error", "ORD-B")],
+        }
+        script["issue"] = lambda _data: []
+        script["sales"] = lambda _data: []
+        script["trs"] = lambda _data: []
+        await _run(conn, script, sent=sent, settings=settings)
+
+    asyncio.run(scenario())
+    assert [card.biz_key for card in sent if card.card_kind != CARD_SUMMARY] == ["ORD-A", "ORD-B"]
+
+
 def test_correct_chain_summary_is_sent_when_push_filter_is_error_only() -> None:
     """概况卡的总结论是正确时,只推错误的配置仍然先发概况。明细保持 pending。"""
 

+ 113 - 33
doc/业务链路核对设计.md

@@ -466,7 +466,7 @@ TRS 的字段公式不在 Excel 里,在产品原型 `TRS/4 业务引起数据
 
 ### 7.1 结构
 
-以下是明细卡。概况卡见 7.0。
+以下是明细卡。概况卡见 7.0,概况不增加章节。
 
 ```text
 营业日:2026-10-21
@@ -474,41 +474,65 @@ TRS 的字段公式不在 Excel 里,在产品原型 `TRS/4 业务引起数据
 批次号:CB20261021001
 涉及:发行端清算步骤3 订单及票据处理;销售端清算步骤3 订单及票据处理;TRS清算步骤5 订单与资金清算
 
-<第一段>
+摘要
+本卡正确 12,错误 1,可疑 1。
 
-<第二段>
+错误清单
+……
+
+可疑清单
+……
+
+无法展开
+……
+
+正确
+……
+
+不适用
 ……
 ```
 
-- 开头三行沿用现在的营业日、交易日、批次号。
-- 第四行是「涉及」,写这条链路经过的系统和清算步骤,用清算步骤名,不写 Python 模块路径。核对门禁和未归链路写「涉及:核对工具自身」。标成不支持的步骤不出现在这行。
-- 其后每段是一个节点的一次计算。长公式拆开,先展示先算的一段,再展示用它的下一段,中间量出现在前一段的代入式里。
+- 配置了推送条数时,正文第一行是「抽样明细第 n 条」。n 是这条链路本次抽样的序号,从 1 起。没配条数或配 0 时不写这行。拆成多张发送时每一张都保留这行。
+- 开头接着是营业日、交易日、批次号。
+- 其后一行是「涉及」,写这条链路经过的系统和清算步骤,用清算步骤名,不写 Python 模块路径。核对门禁和未归链路写「涉及:核对工具自身」。标成不支持的步骤不出现在这行。抽样行和这四行在删段、拆分时都不丢。
+- 其后按章写:摘要、错误清单、可疑清单、无法展开、正确、不适用。章与章之间空一行。某一章没有行时写「无」。主题为错误时只写摘要和错误清单,不写其余四章。
+- 飞书 `lark_md` 把单个换行显示成空格。六句之间、章标题和内容之间都空一行。开头四行仍用单个换行连在一起。
+
+摘要只数这张正文实际写上的结论行:正确、错误、可疑。无法展开算在可疑里,不适用算在正确里。库里的 `verdict` 仍只有这三档,不新增结论。
 
-### 7.2 每个节点一段
+### 7.2 章节里的六句
 
-飞书 `lark_md` 把单个换行显示成空格。下面这些内容各自成行,相邻两行之间空一行。主题为错误的卡片,这段里只出现结论为错误的节点;正确节点和可疑节点留在 `check_line`,不写进这张卡。
+错误行、期望值算得出的可疑行、期望值为空的可疑行、不适用的正确行,各自展开成六句。同一报表、同一依据(去掉列名之后)的其余正确行收成一块,不逐列展开。
 
 | 行 | 内容 |
 |---|---|
-| 依据 | `依据:` 加三种依据之一的完整定位 |
-| 公式 | 中文公式,或人口说明(应出现、不应出现) |
-| 代入 | `代入:` 加代入式 |
-| 说明 | 分号后面的每一句前缀 `说明:`。没有说明则不出现这一行 |
-| 对象 | `被核对的对象:` 加库里已经落下的那一列 |
-| 期望 | 期望值与当前值,以及「符合预期」或「不符合预期」 |
+| 节点 | 中文公式。没有公式时写「核对」 |
+| 报表 | 被核对对象里的报表名。没有报表名则不写 |
+| 筛选 | 有报表名时只写归属。没有报表名时写链路名称,有归属再以「 · 」接上。归属和报表名都来自这张卡已有的被核对对象,不另查范围 |
+| 期望 | 中文公式。期望值算不出时写「期望值无法计算」 |
+| 实据 | 代入式(与公式右侧相同或整段就是公式时不写)和当前值。正确覆盖块写「n 列无差异」;只有一列时,代入式仍写在这句前面 |
+| 依据 | 三种依据之一的定位,去掉列名 |
+| 结论 | `结论:` 加「正确」「错误」或「可疑」,再接保留下来的说明和期望句 |
 
-期望那一行的句式:
+说明仍是代入式分号后面的句子。只是「同行等式」的不写。回写、口径差异留在结论句里,不再单独成行。
 
-- 正确:`期望值是 X,当前值是 X。符合预期。`
-- 错误:`期望值是 X,当前值是 Y。不符合预期。`
-- 字段缺失、无法代入:代入那一行写缺失字段的中文名,期望那一行写 `期望值无法计算,当前值是 Y。`,结论为可疑。
-- 只看应不应该出现的节点:`期望不出现,当前未出现。符合预期。`、`期望出现,当前缺失。不符合预期。`
+期望句仍是这四种,写在结论里,不另作一档结论:
+
+- 正确:`期望 X,当前 X。符合预期。` 正确覆盖块不重复这句,结论只写「正确」,有说明再接上。
+- 错误:`期望 X,当前 Y。不符合预期。`
+- 字段缺失、无法代入:`期望值无法计算,当前 Y。` 这种可疑行放进「无法展开」,结论仍是可疑。
+- 期望值算得出但仍是可疑:`期望 X,当前 Y。无法判定。` 放进「可疑清单」。
+
+公式或说明里已经有「不出现」「不适用」「未触发」的正确行放进「不适用」,不放进正确覆盖块。没有这些字的正确行不为了分章改公式。
 
 中文名只用原型和代码里该端已有的列名。两端同编号报表的列名不完全相同,卡片写该端自己的列名。
 
+主题为错误时,正文在分章之前就只留错误行。摘要写成 `本卡正确 0,错误 n,可疑 0。正确与可疑留在核对明细。` 正确行和可疑行留在 `check_line`,不出现在这张卡的其余章节里。
+
 ### 7.3 示例
 
-主题为错误的资金变动卡。同一资金账户上如果还有符合预期的节点,那些行留在 `check_line`,不出现在下面这张卡里:
+主题为错误的资金变动卡。同一资金账户上如果还有符合预期或可疑的节点,那些行留在 `check_line`,不出现在下面这张卡里:
 
 ```text
 错误 资金变动 资金账户 FA00012345
@@ -518,36 +542,92 @@ TRS 的字段公式不在 Excel 里,在产品原型 `TRS/4 业务引起数据
 批次号:CB20261013001
 涉及:销售端清算步骤3 订单及票据处理;销售端清算步骤7 营业日日切
 
-依据:共赢票据重要字段、计算公式等说明V2 工作表「买入订单」第 28 行「总金额」
+摘要
+
+本卡正确 0,错误 1,可疑 0。正确与可疑留在核对明细。
 
-买入待交收流水金额 = −订单总金额
+错误清单
 
-代入:主订单编号 ORD001202610130004 的总金额 220421.76
+节点:买入待交收流水金额 = −订单总金额
 
-说明:清算当批新流水没有流水编号,按订单总金额对
+筛选:资金变动 · 主订单 ORD001202610130004
 
-被核对的对象:买入待交收流水金额 = −220400.00
+期望:买入待交收流水金额 = −订单总金额
 
-期望值是 −220421.76,当前值是 −220400.00。不符合预期。
+实据:主订单编号 ORD001202610130004 的总金额 220421.76。当前 -220400.00
+
+依据:共赢票据重要字段、计算公式等说明V2 工作表「买入订单」第 28 行
+
+结论:错误。清算当批新流水没有流水编号,按订单总金额对。期望 -220421.76,当前 -220400.00。不符合预期。
 ```
 
-账户资产一张卡里的一段:
+同一报表、同一依据的正确列收成一块。账户资产卡上若只剩这类正确列,正确章是:
 
 ```text
-依据:共赢票据重要字段、计算公式等说明V2 工作表「资金账户钱包信息」第 3 行「现金余额」
+正确
+
+报表:OTC001 资金账户资金报表
+
+筛选:资金账户 FA00012345
 
-现金余额 = 现金余额(已交收) − 现金余额(买入待交收) + 现金余额(卖出待交收)
+节点:现金余额 = 现金余额(已交收) − 现金余额(买入待交收) + 现金余额(卖出待交收)
 
-代入:1000000.00 − 220421.76 + 0.00 = 779578.24
+期望:与节点公式一致
 
-被核对的对象:现金余额 = 580.00
+实据:1 列无差异
 
-期望值是 779578.24,当前值是 580.00。不符合预期。
+依据:共赢票据重要字段、计算公式等说明V2 工作表「资金账户钱包信息」第 3 行
+
+结论:正确
 ```
 
+期望值算不出的可疑行在「无法展开」,例如最新价缺失。结论仍是可疑,不单开第四种结论。
+
 ### 7.4 超长与拆分
 
-主题为错误时,正文在计长度之前就只含错误段。其余主题超过 3500 字时从末尾删正确段;请求体超过约 28KB 拆成多张,错误段和可疑段不丢,每张带同一个记录编号和「第几张,共几张」。删除和拆分以节点为单位,开头四行(含「涉及」)每张都保留。节点内部的空行不单独成段。
+主题为错误时,正文在计长度之前就只含错误行,摘要带「正确与可疑留在核对明细」。其余主题超过 3500 字时,从正确章末尾整块删掉覆盖块(同一报表、同一依据的一块一起删)。不适用、错误、可疑和无法展开不删。摘要的三个计数只算还写在正文里的行;删掉的正确列写成 `正确另有 k 列未写入本卡`。
+
+请求体超过约 28KB 时按结论行拆成多张。每一张按自己带上的行重新分章,错误行和可疑行不丢。每张都带抽样行、开头四行、同一个记录编号和「第几张,共几张」。章节内部的空行不单独成段。
+
+### 7.5 订单链路
+
+买入执行、卖出执行不用 7.1 的摘要和五档清单。开头四行之后按这个顺序写,没有行的章不写:
+
+```text
+每单链路
+
+结论:正确。执行事实列与源表一致。对冲记录成交股数等于 TRS 执行根的成交股数。
+
+盘中公式
+
+字段:票据面值
+期望:1000000.00 × 10% = 100000.00
+实据:执行票据面值 = 100000.00
+依据:共赢票据重要字段、计算公式等说明V2 工作表「买入订单」第 24 行
+结论:正确
+
+清算对照
+
+字段:T日订单结算与工作快照一致
+期望:与字段公式一致
+实据:work/3 与 cleared/0 一致
+依据:研发设计 清算步骤核对改造方案「5.1 发行端(6 步)」
+结论:正确
+
+关联报表
+
+字段:总金额
+期望:票据发行价 + 承销费用
+实据:OTC003 共赢票据订单报表.总金额(结算币种) = 118304.88
+依据:产品原型 OTC003
+结论:正确
+```
+
+- 每单链路只写结论。这些行都正确时,等号写成「等于」,接在「结论:正确。」后面。有错误或可疑时,结论用最严重的一档,并且只展开这些行。
+- 盘中公式、清算对照、关联报表每个字段五行:字段、期望、实据、依据、结论。飞书里单个换行会变成空格,所以这五行之间空一行,这是能各自成行的最紧凑排法。章与章、字段与字段之间也只空这一行。
+- 正确行的结论只写「正确」。错误行写「错误。不符合预期」,可疑行写「可疑。无法判定」或「可疑。期望值无法计算」。说明仍接在结论后面。
+- 分章只看这张卡上已有的公式和被核对对象:执行事实、对冲执行根进每单链路;含「工作快照」「已发布结果」的进清算对照;带报表名的进关联报表;其余计算进盘中公式。
+- 主题为错误时仍只带错误行,开头加「正确与可疑留在核对明细。」超长时从这三章末尾删正确字段,每单链路、错误和可疑不删,并写「另有 k 项未写入本卡」。
 
 ## 8. 这一步实现什么
 

+ 11 - 8
doc/使用说明.md

@@ -74,6 +74,10 @@ POST_CLEAR_CHECK_BUSINESS_DATE=
 # 多选用逗号、顿号或空格分开,例如只推错误:错误
 # 也可以写成:错误、可疑
 POST_CLEAR_CHECK_FEISHU_PUSH=
+
+# 每条链路本次最多推送的明细张数。按该链路待发送列表从前往后取。
+# 大于该链路现有张数、填 0 或留空,则这条链路全部推送。概况卡不占这个名额。
+POST_CLEAR_CHECK_FEISHU_PUSH_LIMIT=
 ```
 
 进程不循环。两次启动隔多久由 cron 决定,本工具不提供轮询间隔配置。
@@ -84,6 +88,8 @@ POST_CLEAR_CHECK_FEISHU_PUSH=
 
 `FEISHU_PUSH` 留空时三类都推。只写 `错误` 时,正确和可疑的卡片仍写入 `check_result`,状态保持 `pending`,不调用飞书。之后把类型加回配置,下次启动会补发这些 `pending` 卡。日志里「飞书卡片已发送」「飞书发送未成功」「飞书卡片未能发送」在未配置推送类型时会带「推送类型未配置,三类都推」。这表示没有按结论类型过滤,不是没配 Webhook。配了类型之后,被跳过的卡打「不在推送配置内」。
 
+`FEISHU_PUSH_LIMIT` 是每条链路的明细张数上限,和上面的类型过滤叠在一起。每条链路自己的待发明细按主键从前往后排,只发前 N 张。买入执行抽 1 张,不会占用卖出执行或资金变动的名额。概况卡不计数。N 大于该链路现有的待发张数、填 0 或留空时,这条链路的待发明细全部发送。配了 N 时,明细卡正文第一行写「抽样明细第 n 条」,n 是这条链路本次抽样的序号,从 1 起。超出的卡保持 `pending`,下次启动再从该链路剩下的最前面取 N 张。
+
 路径本身用环境变量指出,不要写进配置文件里:
 
 ```bash
@@ -119,6 +125,7 @@ python -m post_clear_check
 | `飞书卡片已发送,…` | 这一张卡已送到。未配置推送类型时,这句后面带「推送类型未配置,三类都推」,表示正确、错误、可疑都发 |
 | `飞书卡片未能发送:…` | 这一张卡改为 `failed`,下次启动再发 |
 | `飞书卡片不推送,…不在推送配置内` | 这张卡的结论不在 `FEISHU_PUSH` 里,状态仍是 `pending` |
+| `飞书卡片超出推送条数,…` | 这条链路本次已发满 `FEISHU_PUSH_LIMIT` 张。这张没发,状态仍是 `pending`,下次启动再从该链路剩余列表前面取 |
 | `有对冲,TRS 批次未完成,发行和销售本轮不核对` | 这一天先等 TRS。核对日早于今天时另落一张错误卡 |
 | `开始<步骤>` / `<步骤>完成,用时 N 秒,…` | 每一步的开始和结束。步骤依次是检查三端清算批次、从业务库取数、核对订单和三端报表、写入自有库、发送飞书卡片。完成句后面带查询条数、明细张数、概况张数、结论行数或发送结果 |
 | `仍在<步骤>,已用 N 秒,…` | 不在终端里运行时(cron、重定向到文件),长步骤每 10 秒打一条,带进度和当前在读哪张表,表示程序没有卡住 |
@@ -142,7 +149,7 @@ python -m post_clear_check
    - OTC001 的钱包比对和现金可用读 T 日已发布候选派生出的钱包(`kind=cleared`,当日买卖先置 0,再算现金余额、冻结和可用)。科目都是正数;报表买入待交收展示为负值,比较和代入公式时取相反数。没有已发布候选或缺本币种行时这几条记可疑。账户资产、资金变动、资金交收的步骤节点另读销售 `kind=input` 与 `kind=cleared`:第 3、4 步用 input 重算后与 cleared 比,不读 `pre_clearing`。input 里已经有该订单 posted 的解冻凭证时,不再把交易冻结扣第二次。重放 input 流水按主键 id 升序,不按查询返回的幂等哈希顺序。发行和销售日报只读 `status=active`,同一营业日的 superseded、deprecated 旧行不核对。等值港币资产净值在乘、除四舍五入到分后结果相同时,方向视为已确定。钱包只重算本批 `input` 与 `cleared` 的 T 日结果,不读实时钱包。发行交收台账读 `work/4`,`cleared/0` 应与它一致,不读实时台账。日终 `daycut_fund_eod` 只在核对门禁上与这份候选比,不一致才记错误。
    - 同一 IN 列表超过 500 个值时分几条语句读。
    - OTC007、OTC008 行按票据号挂到订单上,只核业务类型(`buy_note`、`sell_note`)与主订单方向一致、交易日相同的行。同一票据另一张订单的行不算进来;哪张已读主单都对不上的行按「有行但无法对应主订单编号」记可疑。
-6. 结论行在调用核对函数的地方挂到业务链路(见下表),收成卡片写入自有库,再发给飞书。发送时先发每条有明细的链路的概况,再发明细。一张明细卡上有多条时,总结论取最严重的一档:`error` 高于 `suspect`,`suspect` 高于 `correct`。飞书上显示为错误、可疑、正确。主题为错误的明细卡,正文只写结论为错误的段;同一张卡上的正确段和可疑段仍留在 `check_line`,不出现在这张飞书卡的正文里。`check_run` 上的三个计数是明细卡片张数,不是结论行数,也不含概况卡。
+6. 结论行在调用核对函数的地方挂到业务链路(见下表),收成卡片写入自有库,再发给飞书。发送时先发每条有明细的链路的概况,再发明细。一张明细卡上有多条时,总结论取最严重的一档:`error` 高于 `suspect`,`suspect` 高于 `correct`。飞书上显示为错误、可疑、正确。主题为错误的明细卡,正文只写错误清单,摘要写明正确与可疑留在核对明细;同一张卡上的正确行和可疑行仍留在 `check_line`,不出现在这张飞书卡的正文里。`check_run` 上的三个计数是明细卡片张数,不是结论行数,也不含概况卡。
 
 一张卡只承载一条链路、一个归属对象,按「链路 + 归属 + 主键」分卡。同一主订单上有几条链路就是几张卡。归属按链路登记的优先顺序取结论行上第一个非空的键,不从正文反推。链路的口径和节点以 `doc/业务链路核对设计.md` 为准。
 
@@ -198,15 +205,11 @@ ALTER TABLE check_result ADD CONSTRAINT chk_check_result_card_kind CHECK (
 
 明细卡标题一行,顺序是结论、链路名称、归属名称、主键值,形如 `错误 买入执行 主订单 ORD001202610210004`、`错误 未归链路 清算批次 2026-10-21`。失败重发在末尾加「重发」。标题不写系统和清算步骤。
 
-正文开头四行是营业日、交易日、批次号和「涉及」。「涉及」写这条链路经过的系统和清算步骤。核对门禁写「核对工具自身」,并写已经挂上的清算步骤。未归链路只写「核对工具自身」。其后每个节点一段。飞书 `lark_md` 把单个换行显示成空格,所以下面这些内容各自成行,相邻两行之间空一行:
+正文在抽样行之后是营业日、交易日、批次号和「涉及」。「涉及」写这条链路经过的系统和清算步骤。核对门禁写「核对工具自身」,并写已经挂上的清算步骤。未归链路只写「核对工具自身」。配了推送条数时,正文第一行是「抽样明细第 n 条」。其后按章写:摘要、错误清单、可疑清单、无法展开、正确、不适用。摘要只数这张正文里的行。错误行、期望值算得出的可疑行、期望值为空的可疑行(无法展开)、公式或说明里带「不出现 / 不适用 / 未触发」的正确行(不适用),各自写成节点、报表、筛选、期望、实据、依据、结论。同一报表、同一依据的其余正确行收成一块,实据写「n 列无差异」。筛选只用来自被核对对象的报表名和归属,没有报表名时写链路名称。飞书 `lark_md` 把单个换行显示成空格,章与章、六句与六句之间空一行。期望句仍是 `期望 X,当前 Y。符合预期。`、`不符合预期。` 或 `无法判定。`,写在结论里。期望值算不出时写「期望值无法计算」,结论仍是可疑,放在无法展开。说明只保留回写、口径差异,不重复「同行等式」,接在结论里。
 
-1. `依据:` 加三种依据之一的完整定位:产品原型(`docs/tmp/product_prototype/` 下的相对路径加口径名称或列名)、计算公式(`共赢票据重要字段、计算公式等说明V2` 的工作表与行号)、研发设计(`docs/` 下的方案加章节标题)。
-2. 中文公式。
-3. `代入:` 加代入式。原来的说明如果用分号接在代入式后面,每一句单独成行,前缀是 `说明:`。
-4. `被核对的对象:` 加库里已经落下的那一列。
-5. 期望值与当前值,以及「符合预期」或「不符合预期」。期望值算不出时写「期望值无法计算」,结论为可疑;期望值算得出但仍是可疑的写「无法判定」。
+买入执行和卖出执行不用上面的清单,改成四章:每单链路、盘中公式、清算对照、关联报表。没有行的章不写。每单链路只写一句结论。另外三章每个字段是字段、期望、实据、依据、结论,五行之间空一行。正确行的结论只写「正确」。
 
-主题为错误时,正文在计长度之前就只含错误段。其余主题的卡片超过 3500 字时,从末尾整段删掉正确段,错误段和可疑段保留。开头四行不删,并附上 `check_result.id` 作为记录编号。请求体按飞书 30KB 上限按段拆成多张,每张都带开头四行、同一记录编号和「第几张,共几张」。公式里的制表符会去掉,`<` `>` 会改成全角,避免卡片内容被拒。发送失败时日志带上飞书返回的 `msg`。
+主题为错误时,正文在计长度之前就只含错误行,摘要加「正确与可疑留在核对明细」,不写可疑、无法展开、正确、不适用四章。买入执行和卖出执行同样只含错误行,这句话写在四章前面。其余主题超过 3500 字时,从正确章末尾整块删掉覆盖块,不适用、错误、可疑和无法展开保留;摘要另写「正确另有 k 列未写入本卡」。订单链路则从盘中公式、清算对照、关联报表的末尾删正确字段,写「另有 k 项未写入本卡」。开头四行不删,并附上 `check_result.id` 作为记录编号。请求体按飞书 30KB 上限按结论行拆成多张,每张按自己的行重新分章,都带开头四行、同一记录编号和「第几张,共几张」。公式里的制表符会去掉,`<` `>` 会改成全角,避免卡片内容被拒。发送失败时日志带上飞书返回的 `msg`。
 
 同一轮次由唯一键识别:市场、交易日、三端批次 id、三端 `end_time`、规则版本。规则版本是订单和三端报表规则源文件、列清单 `columns.py`、取数 `reader.py`、销售资金步骤 `fund_chain.py`、链路登记表 `chain.py` 的 sha256。卡片幂等键在卡片种类后面加链路代码,同一主订单的两条链路是两张卡、两个幂等键。这些文件有一个字节变化,就新开一轮,旧轮次保留。第一次换成这个版本时,已经核对过的营业日会再跑一遍并发卡。命中已有轮次时不重新取数、不新插结论行;缺概况卡时只补插概况卡。`notify_status` 为 `pending` 或 `failed` 的卡片会重发:概况卡不看推送配置,排在前面,概况之间按链路登记顺序;明细卡仍要求结论在推送配置内,按主键升序。成功改为 `sent`。从 `failed` 再发时,标题末尾加「重发」。发行的 `applied_revision` 会记在 `check_run` 上,销售和 TRS 记 0。修订号不单独构成新的一轮;三端 `end_time` 或批次 id 没变、规则文件也没变时,再次启动仍走重发。