Files
ThearchyHelios e4f8ee5d08 Excel 导入改造期 4:证据下钻——报告里引用上传表格的数,点开能看到它来自原表格的哪一格;裁判核对时看得到来源、口径、接受理由和区域外文字
推断的来源(新 engine/direct_select、data/provenance、data/provenance_db;api/evidence 新增推断来源接口)
- 只对直接引用一列的查询下钻:受限文法用和执行时相同的 SQLite 分词读法,别名互换、自连接、表达式、聚合、子查询、集合运算(含藏在注释、字符串、方括号、转义、BOM 之后的)一律只给表级来历;再用 EXPLAIN 字节码和授权回调兜底
- 结果带齐主键、值非空,按主键参数化回查只读快照、类型和值严格一致才给格子;不一致时整份重算库哈希,哈希不符标红
- 从导入清单和溯源找回格子:宽表、交叉表逆透视的长表(日期取自表头格、年份取自统计期、行标签、分段标题)、列表、合计表、按期累积的并集(换算到所属那一期);原文与规范写法不同时一并列出
- 清单读不到、登记哈希与清单不一致、快照已回收、遮罩列,都只给表级来历或不给,写明原因;原件已清除、接受已作废照实显示;未封存的运行照常推断并标明
- 相关核对:这一行、这一期的核对结论与接受理由

裁判摘录(engine/judge)
- 带期 4 标记的报告,上传表格的查询旁加来源行、口径行、接受理由、可见单元格里的区域外文字和人工录入的统计期;限定语写明这些数未经系统核对,长度有上限、截断时写明
- 隐藏行列里的区域外文字不送;设了遮罩的源只写条数

前端证据面板(run/EvidenceProvenance;ManifestBody 从版本页抽出复用)
- 「推断的来源」一节与已封存的证据分开标注:格子坐标、取自哪些格、年份来源、规范写法、回查、相关核对、数据版本与各期;标红与拒绝原因按状态显示

不变的部分
- 文档顶层加 provenance 标记,只有新报告走新路径;期 4 之前的运行、非上传源、手工源的面板、复核、裁判摘录逐字不变,由金样测试钉住
- 执行器给区域外文字记录是否在隐藏行列;不建「导入校验」系统表(D6)
2026-10-02 14:38:10 +08:00

1160 lines
60 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""证据下钻(期 4「推断的来源」)的清单与格子:WP-B(P4-SPEC 2.2、2.5、2.7、3.2、7.3)。
只做纯函数:输入是已经取回的查询快照、冻结的表结构快照,以及一个按内容哈希取工件的 loader(语义同
artifact_store.load:取不到返回 None,哈希不符抛 ValueError)。不连库、不读原件、不看当前时间,所以:
- 推断来源接口(WP-C)拿它算「这一格来自原表的哪一格」,回查数据文件、补当前状态都在接口那一侧;
- 裁判摘录拿 judge_lines 生成来历行,同样的工件永远得到同样的字(摘录进断点指纹,P4-SPEC 0.2 补充 3)。
**为什么一律沿内容哈希走**(0.2 补充 2):查询快照 → 冻结的表结构快照(import_manifests、snapshot_manifest)→
导入清单,每一跳都是内容寻址的工件,挂在封存范围内的表结构快照下面,改不了。数据库里的 manifest_artifact 列可以
被改,这里一次都不读。
**为什么宁可不下钻也不猜**(2.5):面板上给出的格子是让人去原表对照的,指错了比不指更糟。所以凡是要「猜」的地方
(锚格不在任何值区域里、同一块匹配到不止一条轴、期 2 的清单在一张工作表上有两块、常量列找不到分段标题……)一律判
no_lineage,只给表级来历。
不 import app.api、app.engine.judge、app.engine.direct_select(7.3):SQL 判据由调用方算好传进来(tables),
这里不依赖识别器。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
from app.data.names import name_key
from app.data.provenance_types import (
PROV_ACCEPT,
PROV_CHARS,
PROV_COLUMN_NOTES,
PROV_OUTSIDE,
PROV_OUTSIDE_CHARS,
PROV_PARTS,
AcceptanceView,
CanonicalView,
CellSource,
Chain,
ChainPart,
ChainProblem,
FromCell,
Loader,
Located,
LocateProblem,
PartView,
PeriodView,
RelatedCheck,
RelatedCheckPlan,
SumEqRule,
TableRef,
VersionView,
YearSource,
)
from app.data.recipe_checks import col_letters, lineage_cell, plan_checks, split_coord
from app.data.recipe_types import (
CrosstabBlock,
ListBlock,
NotComparable,
Recipe,
SumEq,
)
_log = logging.getLogger(__name__)
__all__ = ["chain_artifacts", "resolve_chain", "version_view", "locate", "related_checks", "judge_lines"]
#: 锚格所在的区域:值格、合计值格(交叉表合计行、列表合计行)。锚格的块取包含它的这类区域的 block
_VALUE_ROLES = frozenset({"value", "derived_value", "total_value"})
#: 行标签一类的区域:行标签、交叉表合计行的标签(回执里是 derived_label)、列表合计行的标签
_LABEL_ROLES = frozenset({"row_label", "derived_label", "total_label"})
#: 区域 role → FromCell.role(P4-SPEC 2.5 第 3 步,写死):derived_label、total_label 都是合计标签
_FROM_LABEL = {"row_label": "row_label", "derived_label": "total_label", "total_label": "total_label"}
#: 块内认领的去向(同 recipe_engine._BLOCK_ROLES)。期 2 的清单 regions 没有 block 键,算 S9 的区域时按它认块内区域
_BLOCK_ROLES = frozenset({"value", "derived_value", "derived_label", "col_header", "row_label", "section_title",
"ignored_column", "hidden_excluded", "total_label", "total_value"})
#: 交叉表的键列(本来就没有溯源段):锚格回退到同一行第一个有溯源段的列。measure / value / text 不回退:
#: 回退取到的是另一列的格子(P4-SPEC 2.5 第 1 步、评审甲 8)
_KEY_ROLES = frozenset({"axis", "dim", "derive", "const"})
_VALUE_COLUMN_ROLES = frozenset({"measure", "value", "text"})
#: K / G / T 的核对种类:格级状态只给它们
_TOTAL_KINDS = frozenset({"derived_sum", "formula_refs", "column_sum"})
_MODE_LABEL = {"replace": "每期替换", "accumulate": "按期累积"}
#: 裁判摘录的固定文字(P4-SPEC 3.2)。限定语写在小标题里,每块只写一次:JUDGE_RULES 第 1 条说「每个数都是系统
#: 从证据里取出来的」,接受理由和区域外文字里的数不是,不写明的话裁判可能拿它们支持或否定结论句
SOURCE_BROKEN = "来源:导入清单无法读取或与登记不一致,未列出来历"
_ACCEPT_HEAD = "已接受(用户填写的理由,未经系统核对):"
_OUTSIDE_HEAD = ("区域外文字(原表区域外的文字原文,未经系统核对;其中的数字不是查询结果,不能用来支持或否定报告中的"
"数字):")
_ACCEPT_MASKED = "(数据源设置了遮罩,理由未列出)"
_CUT = "…另有部分来历信息未列出,见证据面板"
_ACCEPT_STATE = {"override": "不成立", "waiver": "无法核对"}
# ==========================================================================
# 取工件
# ==========================================================================
def _fetch(load: Loader, aid: Any) -> tuple[dict[str, Any] | None, str | None]:
"""取回一件 JSON 对象工件:(内容, 问题)。问题为 None 表示取到了。
loader 抛 ValueError 是哈希不符;其他异常(读文件失败之类)同样算取不回。都不往外抛:清单坏了是要照实告诉人的
结论(R5 标红),不是 500。
"""
if not isinstance(aid, str) or not aid:
return None, "missing_id"
try:
got = load(aid)
except ValueError:
return None, "hash_mismatch"
except Exception: # noqa: BLE001 - 取回失败的任何原因都按「取不回」处理
_log.warning("provenance: 取回工件 %s 失败", aid[:12], exc_info=True)
return None, "unreadable"
if got is None:
return None, "missing"
if not isinstance(got, dict):
return None, "not_object"
return got, None
def chain_artifacts(schema: dict[str, Any], load: Loader) -> set[str]:
"""表结构快照经哈希链列出的清单 id:import_manifests、snapshot_manifest、快照清单的 parts[].manifest_artifact。
给按需裁判的 loader 用(P4-SPEC 2.8.3):这些 id 写在内容寻址、又在封存范围内的表结构快照里,属于封存链的延伸。
**永不抛异常**:表结构快照不是对象(取回为 None)、快照清单取不回或哈希不符时,就当它列出的东西不在链上(快照
清单本身也不算),只是少列。import_manifests 是表结构快照自己列出的,不用逐个取回就认。
"""
out: set[str] = set()
try:
if not isinstance(schema, dict):
return out
mids = schema.get("import_manifests")
if isinstance(mids, list):
out.update(m for m in mids if isinstance(m, str) and m)
sm_id = schema.get("snapshot_manifest")
if isinstance(sm_id, str) and sm_id:
sm, _ = _fetch(load, sm_id)
if sm is not None:
out.add(sm_id)
parts = sm.get("parts")
for p in parts if isinstance(parts, list) else []:
mid = p.get("manifest_artifact") if isinstance(p, dict) else None
if isinstance(mid, str) and mid:
out.add(mid)
except Exception: # noqa: BLE001 - 坏形状的表结构快照只会让链变短,不能让按需裁判报错
_log.warning("provenance: 列出链上的清单失败", exc_info=True)
return out
# ==========================================================================
# 清单链(P4-SPEC 2.2)
# ==========================================================================
def _is_int(x: Any) -> bool:
return isinstance(x, int) and not isinstance(x, bool)
def _recipe_mode(manifest: dict[str, Any]) -> str:
"""单期的 mode 取配方的 mode(规范形式省掉缺省值,没写就是 replace,同 Recipe.mode 的缺省)。"""
canonical = (manifest.get("recipe") or {}).get("canonical") if isinstance(manifest.get("recipe"), dict) else None
mode = canonical.get("mode") if isinstance(canonical, dict) else None
return "accumulate" if mode == "accumulate" else "replace"
def _union_rows(rows: Any) -> dict[str, list[int]] | None:
"""快照清单 parts[].rows(表 → {union: [起, 止], part: [起, 止]})摊平成 表 → [union 起, 止, part 起, 止]。
形状不对返回 None(链本身对不上)。"""
if not isinstance(rows, dict):
return None
out: dict[str, list[int]] = {}
for table, r in rows.items():
u = r.get("union") if isinstance(r, dict) else None
p = r.get("part") if isinstance(r, dict) else None
if not (isinstance(u, list) and isinstance(p, list) and len(u) == 2 and len(p) == 2
and all(_is_int(x) for x in [*u, *p])):
return None
out[str(table)] = [u[0], u[1], p[0], p[1]]
return out
def resolve_chain(query: dict[str, Any], schema: dict[str, Any], load: Loader) -> Chain | ChainProblem:
"""查询快照 + 冻结的表结构快照 → 清单链(P4-SPEC 2.2)。
先取回表结构快照列出的全部清单(R5:任何一份取不回或哈希不符 → manifest_unreadable),再核对链是否自洽(R6:
只看内容寻址的工件就能判的几项 → chain_mismatch)。要对照数据库、数据文件的(source_id、登记哈希,R13)由
调用方拿 Chain.source_id、expected_db_sha256 去比。不往外抛。
单期(没有 snapshot_manifest)**不比快照 id**:移除、作废之后只剩一期时,冻结的 import_manifests 沿用那一期
原来的清单(source_versions.py 的 schema_cache_for),清单里的 snapshot_id 是当初提交时的快照,不是现在这个。
并集比:快照清单的 snapshot_id 等于查询快照的 data_version;parts[].manifest_artifact 逐个等于冻结的
import_manifests;每期清单的 import_id、seq、source_id 与快照清单记的一致。
"""
version = query.get("data_version") if isinstance(query, dict) else None
source = str((query.get("source") if isinstance(query, dict) else None) or schema.get("source") or "")
mids = schema.get("import_manifests")
sm_id = schema.get("snapshot_manifest") or None
if not isinstance(mids, list):
return ChainProblem("chain_mismatch", "import_manifests_shape")
# R5:先全部取回、复验哈希。顺序固定(快照清单在前),同一个 id 只取一次
loaded: dict[str, dict[str, Any]] = {}
for aid in ([sm_id] if sm_id is not None else []) + list(mids):
if isinstance(aid, str) and aid in loaded:
continue
doc, problem = _fetch(load, aid)
if doc is None:
return ChainProblem("manifest_unreadable", f"{problem}:{str(aid)[:12]}")
loaded[aid] = doc
if not isinstance(version, str) or not version:
return ChainProblem("chain_mismatch", "no_data_version")
# R6:链本身是否自洽
if sm_id is None:
if len(mids) != 1:
return ChainProblem("chain_mismatch", "import_manifests_count")
m = loaded[mids[0]]
expected, source_id = m.get("db_sha256"), m.get("source_id")
if not isinstance(expected, str) or not expected:
return ChainProblem("chain_mismatch", "db_sha256_missing")
if not isinstance(source_id, str) or not source_id:
return ChainProblem("chain_mismatch", "source_id_missing")
if not _is_int(m.get("seq")) or not isinstance(m.get("import_id"), str):
return ChainProblem("chain_mismatch", "part_identity")
part = ChainPart(m["seq"], m["import_id"], mids[0], m, None)
return Chain(source, source_id, version, _recipe_mode(m), False, expected, [part], schema, None)
sm = loaded[sm_id]
if sm.get("snapshot_id") != version:
return ChainProblem("chain_mismatch", "snapshot_id")
parts = sm.get("parts")
if not isinstance(parts, list) or not parts or not all(isinstance(p, dict) for p in parts):
return ChainProblem("chain_mismatch", "parts_shape")
if [p.get("manifest_artifact") for p in parts] != list(mids):
return ChainProblem("chain_mismatch", "parts_vs_import_manifests")
union = sm.get("union") if isinstance(sm.get("union"), dict) else {}
expected, source_id = union.get("db_sha256"), sm.get("source_id")
if not isinstance(expected, str) or not expected:
return ChainProblem("chain_mismatch", "db_sha256_missing")
if not isinstance(source_id, str) or not source_id:
return ChainProblem("chain_mismatch", "source_id_missing")
out: list[ChainPart] = []
for p in parts:
m = loaded[p["manifest_artifact"]]
if m.get("import_id") != p.get("import_id") or m.get("seq") != p.get("seq") \
or not _is_int(p.get("seq")) or not isinstance(p.get("import_id"), str):
return ChainProblem("chain_mismatch", "part_identity")
if m.get("source_id") != source_id:
return ChainProblem("chain_mismatch", "source_id")
rows = _union_rows(p.get("rows"))
if rows is None:
return ChainProblem("chain_mismatch", "part_rows")
out.append(ChainPart(p["seq"], p["import_id"], p["manifest_artifact"], m, rows))
mode = sm.get("mode") if sm.get("mode") in _MODE_LABEL else "accumulate"
return Chain(source, source_id, version, mode, True, expected, out, schema, sm)
# ==========================================================================
# 数据版本(表级来历,P4-SPEC 2.8.1 的 version)
# ==========================================================================
def _rect(ref: Any) -> tuple[int, int, int, int] | None:
"""「C5:AG7」或「B9」→ (c1, r1, c2, r2);认不出返回 None。"""
if not isinstance(ref, str) or not ref:
return None
a, _, b = ref.partition(":")
pa, pb = split_coord(a), split_coord(b or a)
if pa is None or pb is None:
return None
return min(pa[1], pb[1]), min(pa[2], pb[2]), max(pa[1], pb[1]), max(pa[2], pb[2])
def _a1(r: int, c: int) -> str:
return f"{col_letters(c)}{r}"
def _str(x: Any) -> str | None:
return x if isinstance(x, str) and x else None
def _receipt(manifest: dict[str, Any]) -> dict[str, Any]:
r = manifest.get("receipt")
return r if isinstance(r, dict) else {}
def _period(manifest: dict[str, Any]) -> dict[str, Any] | None:
"""这一期的统计期:清单顶层的 period(与回执里的同一份),缺了退回回执里的。"""
per = manifest.get("period")
if not isinstance(per, dict):
per = _receipt(manifest).get("period")
return per if isinstance(per, dict) else None
def region_of(receipt: dict[str, Any]) -> str | None:
"""S9(P4-SPEC 调整 9):回执 regions 里块内各角色区域的外接矩形,按工作表,多张工作表用「、」连。
块内 = 区域带了 block(期 3 起的回执一律带 block 键,区域外文字、统计期为 None);期 2 的回执没有 block 键,
按块内认领的去向认。取不到为 None。
"""
boxes: dict[str, list[int]] = {}
for m in receipt.get("regions") or []:
if not isinstance(m, dict) or not isinstance(m.get("sheet"), str):
continue
inside = bool(m.get("block")) if "block" in m else m.get("role") in _BLOCK_ROLES
rect = _rect(m.get("ref")) if inside else None
if rect is None:
continue
b = boxes.setdefault(m["sheet"], list(rect))
b[0], b[1], b[2], b[3] = min(b[0], rect[0]), min(b[1], rect[1]), max(b[2], rect[2]), max(b[3], rect[3])
if not boxes:
return None
return "、".join(f"{s}!{_a1(b[1], b[0])}:{_a1(b[3], b[2])}" for s, b in boxes.items())
def excluded_rows_of(receipt: dict[str, Any]) -> int | None:
"""S9:回执 rows_excluded 的行数之和;期 3 之前的回执没有这个键时为 None(「未记录」不等于 0)。"""
if "rows_excluded" not in receipt:
return None
total = 0
for item in receipt.get("rows_excluded") or []:
for span in (item.get("rows") if isinstance(item, dict) else None) or []:
if isinstance(span, list) and len(span) == 2 and _is_int(span[0]) and _is_int(span[1]):
total += span[1] - span[0] + 1
return total
def _check_titles(manifest: dict[str, Any]) -> dict[str, str]:
return {str(c["id"]): str(c.get("title") or c["id"]) for c in manifest.get("checks") or []
if isinstance(c, dict) and c.get("id")}
def acceptances_of(manifest: dict[str, Any]) -> list[AcceptanceView]:
"""这一期清单里的全部接受理由:先 overrides(数据质量类不成立),再 waivers(无法核对),各按清单里的顺序。"""
titles = _check_titles(manifest)
acc = manifest.get("acceptances") if isinstance(manifest.get("acceptances"), dict) else {}
out: list[AcceptanceView] = []
for kind, key in (("override", "overrides"), ("waiver", "waivers")):
for a in acc.get(key) or []:
if not isinstance(a, dict) or not a.get("check_id"):
continue
cid = str(a["check_id"])
out.append(AcceptanceView(check_id=cid, title=titles.get(cid), kind=kind, # type: ignore[arg-type]
reason=str(a.get("reason") or ""), signed_by=_str(a.get("signed_by")),
at=_str(a.get("at"))))
return out
def _period_view(manifest: dict[str, Any]) -> PeriodView | None:
per = _period(manifest)
if per is None or not per.get("start") or not per.get("end"):
return None
return PeriodView(start=str(per["start"]), end=str(per["end"]),
source="human" if per.get("source") == "human" else "cells",
cells=[str(c) for c in per.get("cells") or []], signed_by=_str(per.get("signed_by")))
def _part_view(part: ChainPart) -> PartView:
m = part.manifest
f = m.get("file") if isinstance(m.get("file"), dict) else {}
recipe = m.get("recipe") if isinstance(m.get("recipe"), dict) else {}
signed = m.get("signed_by") if isinstance(m.get("signed_by"), dict) else {}
receipt = _receipt(m)
return PartView(
import_id=part.import_id, seq=part.seq, manifest=part.manifest_id, period=_period_view(m),
file_name=_str(f.get("name")), raw_sha256=_str(f.get("raw_sha256")), region=region_of(receipt),
excluded_rows=excluded_rows_of(receipt), recipe_sha256=_str(recipe.get("sha256")), recipe_seq=None,
committed_at=_str(m.get("created_at")), signed_by=_str(signed.get("name")), acceptances=acceptances_of(m))
def _frozen_tables(schema: dict[str, Any]) -> dict[str, Any]:
tables = schema.get("tables") if isinstance(schema, dict) else None
return tables if isinstance(tables, dict) else {}
def _frozen_name(frozen: dict[str, Any], name: str) -> str | None:
"""按 name_key 对冻结表结构的表名(原样逐字相同的优先)。"""
if name in frozen:
return name
key = name_key(name)
return next((t for t in frozen if name_key(t) == key), None)
def _table_kind(frozen: dict[str, Any], name: str) -> str:
meta = frozen.get(name)
return "reported_total" if isinstance(meta, dict) and meta.get("kind") == "reported_total" else "data"
def version_view(chain: Chain, *, tables: list[str]) -> VersionView:
"""数据版本(表级来历):全部来自清单链上的内容寻址工件。
tables 由调用方用 direct_select.tables_in 算好(SQL 里出现的冻结表结构的表名),这里与冻结表结构按 name_key
求交集、按出现顺序去重。recipe_seq、raw_state、purged、revoked 是当前状态,留 None 由接口一侧从数据库补;
has_row 全 False,由接口一侧按格子所在的一期标;manifest_view 一律 True,遮罩由接口一侧判断。
"""
frozen = _frozen_tables(chain.schema)
refs: list[TableRef] = []
seen: set[str] = set()
for name in tables:
hit = _frozen_name(frozen, str(name))
if hit is None or hit in seen:
continue
seen.add(hit)
refs.append(TableRef(name=hit, kind=_table_kind(frozen, hit))) # type: ignore[arg-type]
return VersionView(source=chain.source, snapshot_id=chain.snapshot_id, mode=chain.mode, # type: ignore[arg-type]
union=chain.union, tables=refs, manifest_view=True,
parts=[_part_view(p) for p in chain.parts])
# ==========================================================================
# 从 (表, 列, rowid) 找回格子(P4-SPEC 2.5)
# ==========================================================================
@dataclass(frozen=True)
class _Mark:
"""回执里的一个区域标记,坐标已解析。keyed:回执里有没有 block 键(期 2 的清单没有)。"""
sheet: str
role: str
block: str | None
keyed: bool
c1: int
r1: int
c2: int
r2: int
def has(self, r: int, c: int) -> bool:
return self.r1 <= r <= self.r2 and self.c1 <= c <= self.c2
def in_block(self, block: str) -> bool:
# 期 2 的清单没有 block 键:能走到这里,说明这张工作表上配方只有一块(_anchor_block),按工作表认
return self.block == block if self.keyed else True
def _marks(receipt: dict[str, Any]) -> list[_Mark]:
out: list[_Mark] = []
for m in receipt.get("regions") or []:
if not isinstance(m, dict) or not isinstance(m.get("sheet"), str):
continue
rect = _rect(m.get("ref"))
if rect is None:
continue
block = m.get("block")
out.append(_Mark(m["sheet"], str(m.get("role") or ""), block if isinstance(block, str) else None,
"block" in m, *rect))
return out
def _sheet_blocks(recipe: Recipe, receipt: dict[str, Any]) -> list[tuple[str, Any]]:
"""(实际工作表名, 块):配方工作表 id 经回执的 sheets.matched 换成实际的工作表名(没认到的退回配方里的名字)。"""
sheets = receipt.get("sheets") if isinstance(receipt.get("sheets"), dict) else {}
matched = sheets.get("matched") if isinstance(sheets.get("matched"), dict) else {}
out: list[tuple[str, Any]] = []
for sr in recipe.sheets:
actual = matched.get(sr.id) or sr.match.name
out += [(str(actual), b) for b in sr.blocks]
return out
def _anchor_block(marks: list[_Mark], sheet: str, r: int, c: int, recipe: Recipe,
receipt: dict[str, Any]) -> str | None:
"""锚格所在的块:包含锚格的值区域(value / derived_value / total_value)的 block。
期 2 生成的清单 regions 没有 block 键:同一工作表上配方恰好只有一块时认它,否则不认(不猜)。找不到包含锚格的
值区域、或者落在两块的区域里,都返回 None。
"""
hits = [m for m in marks if m.sheet == sheet and m.role in _VALUE_ROLES and m.has(r, c)]
if not hits:
return None
if all(m.keyed for m in hits):
blocks = {m.block for m in hits}
return blocks.pop() if len(blocks) == 1 and None not in blocks else None
on_sheet = [b for s, b in _sheet_blocks(recipe, receipt) if s == sheet]
return on_sheet[0].id if len(on_sheet) == 1 else None
def _find_block(recipe: Recipe, receipt: dict[str, Any], sheet: str, block_id: str) -> Any | None:
on_sheet = [b for s, b in _sheet_blocks(recipe, receipt) if s == sheet and b.id == block_id]
if len(on_sheet) == 1:
return on_sheet[0]
anywhere = [b for _, b in _sheet_blocks(recipe, receipt) if b.id == block_id]
return anywhere[0] if len(anywhere) == 1 else None
def _table_out(receipt: dict[str, Any], table: str) -> dict[str, Any] | None:
hits = [t for t in receipt.get("tables") or [] if isinstance(t, dict) and t.get("name") == table]
return hits[0] if len(hits) == 1 else None
def _columns(table_out: dict[str, Any]) -> list[dict[str, Any]]:
return [c for c in table_out.get("columns") or [] if isinstance(c, dict) and isinstance(c.get("name"), str)]
def _derived_at(receipt: dict[str, Any], coord: str) -> dict[str, Any] | None:
"""合计格(DerivedItem)里 cell 等于这一格的那一项:合计标签原文取它的 label_raw,规范写法取 label。"""
for d in receipt.get("derived") or []:
if isinstance(d, dict) and d.get("cell") == coord:
return d
return None
@dataclass
class _Ctx:
"""locate 一次要用到的东西(都来自格子所在那一期的导入清单)。"""
chain: Chain
part: ChainPart
part_rowid: int
rowid: int
table: str
column: str
role: str
meta: dict[str, Any]
table_out: dict[str, Any]
columns: list[dict[str, Any]]
receipt: dict[str, Any]
recipe: Recipe
marks: list[_Mark]
sheet: str
ar: int
ac: int
block_id: str
def _no(detail: str) -> LocateProblem:
return LocateProblem("no_lineage", detail)
def _part_of(chain: Chain, table: str, rowid: int) -> tuple[ChainPart, int] | LocateProblem:
"""快照库的 rowid → (格子所在的一期, 该期构建库的 rowid)。
单期:快照库就是那一期的构建库,rowid 直接是溯源段的 rowid。并集:rowid u 落在某一期 rows[表].union = [a, b]
里,该期的 rowid = u - a + rows[表].part[0](part[0] 恒为 1,物化时已断言)。落不进任何一期、或那一期没有这张
表:链和数据文件对不上,chain_mismatch(标红,R17)。
"""
if not chain.union:
return chain.parts[0], rowid
hits = [(p, rows) for p in chain.parts if (rows := (p.union_rows or {}).get(table)) is not None
and rows[0] <= rowid <= rows[1]]
if len(hits) != 1:
return LocateProblem("chain_mismatch", "rowid_out_of_parts" if not hits else "rowid_in_two_parts")
part, rows = hits[0]
return part, rowid - rows[0] + rows[2]
def locate(chain: Chain, *, table: str, column: str, rowid: int) -> Located | LocateProblem:
"""(表, 列, 快照库的 rowid) → 原表的格子和附带的格(P4-SPEC 2.5)。
并集时 rowid 是并集 rowid,先换算成某一期的 rowid(_part_of)。之后全部用那一期导入清单的回执:溯源段给锚格,
区域标记、轴行、标签原文、合计格给附带的格,配方(解析后的模型,规范形式会省掉缺省值)给分段标题和年份规则。
CellSource 的 pk、recheck、raw_purged 留给接口一侧(WP-C)回查后填;rowid 填调用方给的快照库 rowid(就是回查
得到的那一个)。
"""
if not _is_int(rowid) or rowid < 1:
return _no("rowid")
got = _part_of(chain, table, rowid)
if isinstance(got, LocateProblem):
return got
part, part_rowid = got
m = part.manifest
receipt = _receipt(m)
canonical = (m.get("recipe") or {}).get("canonical") if isinstance(m.get("recipe"), dict) else None
try:
recipe = Recipe.model_validate(canonical)
except Exception: # noqa: BLE001 - 配方解析不了就推不出附带的格,只给表级来历
return _no("recipe_invalid")
table_out = _table_out(receipt, table)
if table_out is None:
return _no("table_not_in_receipt")
columns = _columns(table_out)
meta = next((c for c in columns if c["name"] == column), None)
if meta is None:
return _no("column_not_in_receipt")
role = str(meta.get("role") or "value")
lineage = receipt.get("lineage") if isinstance(receipt.get("lineage"), dict) else {}
# 第 1 步:锚格。被引用的列有溯源段就取它;交叉表的键列(本来就没有溯源段)取同一行第一个有溯源段的列
if role in _VALUE_COLUMN_ROLES:
anchor = lineage_cell(lineage, table, column, part_rowid)
elif role in _KEY_ROLES:
anchor = next((a for c in columns if (a := lineage_cell(lineage, table, c["name"], part_rowid))), None)
else:
return _no("column_role")
if anchor is None:
return _no("no_lineage_for_row")
parsed = split_coord(anchor)
if parsed is None or parsed[0] is None:
return _no("anchor")
sheet, ac, ar = parsed[0], parsed[1], parsed[2]
marks = _marks(receipt)
block_id = _anchor_block(marks, sheet, ar, ac, recipe, receipt)
if block_id is None:
return _no("anchor_block")
block = _find_block(recipe, receipt, sheet, block_id)
if block is None:
return _no("block_not_in_recipe")
ctx = _Ctx(chain, part, part_rowid, rowid, table, column, role, meta, table_out, columns, receipt, recipe, marks,
sheet, ar, ac, block_id)
if isinstance(block, CrosstabBlock):
return _locate_crosstab(ctx, block)
if isinstance(block, ListBlock):
return _locate_list(ctx, block)
return _no("block_layout")
def _cell_source(ctx: _Ctx, cell: tuple[int, int], froms: list[FromCell], *, year: YearSource | None,
canonical: CanonicalView | None, merged_fill: bool) -> Located:
# 表的种类按冻结表结构认(同 datasource.reported_total_tables 的口径),冻结表结构里没有这张表时退回回执
frozen = _frozen_tables(ctx.chain.schema)
if ctx.table in frozen:
kind = _table_kind(frozen, ctx.table)
else:
kind = "reported_total" if ctx.table_out.get("kind") == "reported_total" else "data"
src = CellSource(
table=ctx.table, column=ctx.column, column_role=ctx.role, kind=kind, # type: ignore[arg-type]
rowid=ctx.rowid, part_seq=ctx.part.seq, part_rowid=ctx.part_rowid, sheet=ctx.sheet,
cell=_a1(cell[0], cell[1]), header=_str(ctx.meta.get("header")), unit=_str(ctx.meta.get("unit")),
from_=froms, year=year, canonical=canonical, merged_fill=merged_fill)
return Located(ctx.part, ctx.part_rowid, src)
def _canonical(raw: Any, canonical: Any) -> CanonicalView | None:
"""标签原文与库里的值(规范写法)不同时给出;任何一边拿不到都不给。"""
if isinstance(raw, str) and raw and isinstance(canonical, str) and canonical and raw != canonical:
return CanonicalView(raw=raw, canonical=canonical)
return None
def _year(ctx: _Ctx, block: CrosstabBlock, form: Any) -> YearSource | None:
"""年份:交叉表的轴是文本日期(text / mixed)且配方 axis.year_from 有值时取自统计期,其余(日期格)为 None。
mixed:清单只记每块的 form,不记哪一格是日期格、哪一格是文本,逐格判断不了,照实标 mixed(评审甲 7③)。
"""
if form not in ("text", "mixed") or not block.axis.year_from:
return None
per = _period(ctx.part.manifest)
if per is None:
return None
mixed = form == "mixed"
if per.get("source") == "human":
return YearSource(source="human", cells=[], signed_by=_str(per.get("signed_by")), mixed=mixed)
return YearSource(source="period", cells=[str(c) for c in per.get("cells") or []], signed_by=None, mixed=mixed)
def _locate_crosstab(ctx: _Ctx, block: CrosstabBlock) -> Located | LocateProblem:
"""交叉表:值列有溯源段;日期取轴行、维度和派生列取行标签、常量列取分段标题(2.5 第 2 步的表)。"""
receipt, sheet, ar, ac, role = ctx.receipt, ctx.sheet, ctx.ar, ctx.ac, ctx.role
axes = [a for a in receipt.get("axes") or [] if isinstance(a, dict) and a.get("block") == ctx.block_id]
if len(axes) != 1 or not _is_int(axes[0].get("row")):
return _no("axis")
axis = axes[0]
axis_col = next((c["name"] for c in ctx.columns if c.get("role") == "axis"), block.axis.name)
dim_col = next((c["name"] for c in ctx.columns if c.get("role") == "dim"), None)
const_cols = [c["name"] for c in ctx.columns if c.get("role") == "const"]
# 行标签:同块里 role 为 row_label / derived_label / total_label、矩形覆盖锚格那一行的区域
labels = [mk for mk in ctx.marks if mk.sheet == sheet and mk.role in _LABEL_ROLES and mk.in_block(ctx.block_id)
and mk.r1 <= ar <= mk.r2]
label: tuple[int, str] | None = None # (标签列, FromCell.role)
if len({mk.c1 for mk in labels}) == 1:
label = (labels[0].c1, _FROM_LABEL[labels[0].role])
# 这一行属于块里哪个分段:SegmentLabels 不带工作表,先按配方取这一块的分段,再找 rows 含这一行的那一项
seg_ids = {s.id for s in block.segments}
seg_hits = [s for s in receipt.get("labels") or [] if isinstance(s, dict) and s.get("segment") in seg_ids
and ar in (s.get("rows") or [])]
seg_labels = seg_hits[0] if len(seg_hits) == 1 else None
i = seg_labels["rows"].index(ar) if seg_labels is not None else -1
seg_raw = seg_canon = None
if seg_labels is not None:
raws, canons = seg_labels.get("raw") or [], seg_labels.get("canonical") or []
seg_raw = raws[i] if i < len(raws) else None
seg_canon = canons[i] if i < len(canons) else None
label_raw, label_canon = seg_raw, seg_canon
if label is not None and label[1] == "total_label":
# 交叉表的合计行:合计标签原文取这一格 DerivedItem 的 label_raw,规范写法(即库里合计项的值)取 label
item = _derived_at(receipt, f"{sheet}!{_a1(ar, ac)}")
if item is not None:
label_raw, label_canon = item.get("label_raw"), item.get("label")
# 分段标题:配方里这个分段按标题定位;同块 section_title 区域里行号小于这一段第一个标签行的,取最近的一个
title: tuple[int, int, str | None] | None = None
seg = next((s for s in block.segments if seg_labels is not None and s.id == seg_labels.get("segment")), None)
if seg is not None and seg.locate.by == "section_title" and seg_labels.get("rows"): # type: ignore[union-attr]
first = min(seg_labels["rows"]) # type: ignore[index]
above = sorted((mk.r1, mk.c1) for mk in ctx.marks if mk.sheet == sheet and mk.role == "section_title"
and mk.in_block(ctx.block_id) and mk.r1 < first)
if above:
best = max(r for r, _ in above)
title = (best, min(c for r, c in above if r == best), seg.locate.title)
# 被引用的格
if role in _VALUE_COLUMN_ROLES:
cell = (ar, ac)
elif role == "axis":
cell = (axis["row"], ac)
elif role in ("dim", "derive"):
if label is None:
return _no("row_label")
cell = (ar, label[0])
else: # const
if title is None:
return _no("section_title")
cell = (title[0], title[1])
# 附带的格:这一行其余键列各取一格,除了被引用的格本身
froms: list[FromCell] = []
if role != "axis":
froms.append(FromCell(role="axis_header", column=axis_col, sheet=sheet, cell=_a1(axis["row"], ac)))
label_in_from = False
# 有维度列的表:行标签给出维度的值;宽表(只有指标列):行标签是这一列的指标名,column 为 None
if (dim_col is not None or role == "measure") and role not in ("dim", "derive"):
if label is None:
return _no("row_label")
froms.append(FromCell(role=label[1], column=dim_col, sheet=sheet, cell=_a1(ar, label[0]), # type: ignore[arg-type]
text=label_raw if isinstance(label_raw, str) else None))
label_in_from = dim_col is not None
if const_cols and role != "const":
if title is None:
return _no("section_title")
# 分段标题只有配方里的定位文字(执行器按 match_key 比对,原表里的字可以不同),不当作格子原文
froms.append(FromCell(role="section_title", column=const_cols[0], sheet=sheet, cell=_a1(title[0], title[1]),
text=None, locate_title=title[2]))
# 规范写法:比的是这一行的标签列(维度列、合计项列)。被引用的就是它、或它在附带的格里时给
canonical = _canonical(label_raw, label_canon) if dim_col is not None and (role == "dim" or label_in_from) \
else None
return _cell_source(ctx, cell, froms, year=_year(ctx, block, axis.get("form")), canonical=canonical,
merged_fill=False)
def _list_label_col(ctx: _Ctx, block: ListBlock) -> int | None:
"""列表合计行的标签列:合计标签所在的列(total_row.label_column)在明细表里的溯源段给出它在哪一列;明细表没有
溯源段时(一行明细都没有),退回同块 total_label 区域(只有一列时)。"""
total = block.rows.total_row
lineage = ctx.receipt.get("lineage") if isinstance(ctx.receipt.get("lineage"), dict) else {}
runs = ((lineage.get(block.table) or {}).get(total.label_column) or []) if total is not None else []
for run in runs:
if isinstance(run, list) and len(run) > 2 and run[1] == ctx.sheet \
and (parsed := split_coord(str(run[2]))) is not None:
return parsed[1]
cols = {mk.c1 for mk in ctx.marks if mk.sheet == ctx.sheet and mk.role == "total_label"
and mk.in_block(ctx.block_id) and mk.r1 <= ctx.ar <= mk.r2 and mk.c1 == mk.c2}
return cols.pop() if len(cols) == 1 else None
def _list_header_cells(ctx: _Ctx, block: ListBlock, c: int, header: str) -> list[FromCell]:
"""列表第 c 列的表头格(2.5 第 3 步「多行表头给出每一格」),只列确知写着字的格。
区域标记是同一去向的相邻已认领格合成的段,段内没认领的空格一并覆盖(recipe_engine._row_spans:「中间的空格一并
覆盖,着色无妨」),而表头行只认领非空格(喂给 _ListRun 的只有非空格:Grid.row、xlsx_cells.sheet_rows)。所以:
- 一行表头:这一列是按 (表头行, c) 这一格的字认出来的(一行表头拼表头时不看合并区,_compose),这一格必定有字,
回执里列的 header 就是它的原文;
- 多行表头:上层常是横向合并的「2026年上半年」,合并区里除左上格以外的格是空的,却落在段的中间(lab.c04 的
D3 在 A3:E3 里),左边纵向合并的「地区」在下一层也是空格。清单不记合并区,段中间的格有没有字分不出,所以只列
c 恰好是段的端点(起列或止列,必定是认领了的非空格)的那几层,其余层不列:宁可少列一格,也不把空格说成表头。
多行表头的 header 是各层用「_」拼起来的,不是哪一格的原文,text 一律为 None。
"""
hits = [mk for mk in ctx.marks if mk.sheet == ctx.sheet and mk.role == "col_header"
and mk.in_block(ctx.block_id) and mk.c1 <= c <= mk.c2]
if block.header_rows == 1:
rows = sorted({r for mk in hits for r in range(mk.r1, mk.r2 + 1)})
text = header if len(rows) == 1 else None
else:
rows = sorted({r for mk in hits if c in (mk.c1, mk.c2) for r in range(mk.r1, mk.r2 + 1)})
text = None
return [FromCell(role="col_header", column=None, sheet=ctx.sheet, cell=_a1(r, c), text=text) for r in rows]
def _locate_list(ctx: _Ctx, block: ListBlock) -> Located | LocateProblem:
"""列表:每一列都有溯源段(合计表的合计项列除外);附带列表头,合计表另附合计标签(2.5 第 3 步)。"""
receipt, sheet, ar, ac, role = ctx.receipt, ctx.sheet, ctx.ar, ctx.ac, ctx.role
total = block.rows.total_row
is_total = total is not None and bool(total.keep_as) and total.keep_as == ctx.table and ctx.table != block.table
item = _derived_at(receipt, f"{sheet}!{_a1(ar, ac)}") if is_total else None
label_c = _list_label_col(ctx, block) if is_total else None
if role in _VALUE_COLUMN_ROLES:
cell = (ar, ac)
elif role == "dim" and is_total:
if label_c is None:
return _no("total_label")
cell = (ar, label_c)
else:
return _no("column_role")
froms: list[FromCell] = []
header = ctx.meta.get("header")
if is_total:
# 合计表的列头取明细表同名列的(原表表头原文);合计表这一列的 header 来自配方的写法
base = _table_out(receipt, block.table)
base_meta = next((c for c in _columns(base) if c["name"] == ctx.column), None) if base else None
header = base_meta.get("header") if base_meta is not None else header
if role != "dim" and isinstance(header, str) and header:
froms += _list_header_cells(ctx, block, cell[1], header)
dim_col = next((c["name"] for c in ctx.columns if c.get("role") == "dim"), None)
if is_total and role != "dim":
if label_c is None:
return _no("total_label")
froms.append(FromCell(role="total_label", column=dim_col, sheet=sheet, cell=_a1(ar, label_c),
text=_str(item.get("label_raw")) if item is not None else None))
canonical = _canonical(item.get("label_raw"), item.get("label")) if is_total and item is not None else None
# 列表这一块按合并单元格的左上格填充时,执行器给填充格记的溯源是填充位置,不是值所在的左上格;清单里没有合并区
# 的信息,逐格判断不了,只能照实给出条件说明(评审甲 7②)
return _cell_source(ctx, cell, froms, year=None, canonical=canonical, merged_fill=block.merged_data == "fill")
# ==========================================================================
# 相关核对(P4-SPEC 2.7,静态部分)
# ==========================================================================
def cell_status(check: dict[str, Any], coord: str, *, is_formula: bool, formula_check: bool) -> str | None:
"""合计核对(K、G、T)在这一格上的结论。check 是清单里的 CheckResult(dict),coord 是「工作表!A1」。
- G 只核对公式格,写死的数直接跳过(recipe_checks._check_g),而 plan_checks 只要段里有一个公式格就给整段编
G 号:所以这一格不是公式时为 not_formula,不能因为整段 G 通过就说「这一格一致」(评审甲 6);
- passed:ok;
- 无法核对(或不一致):格在 cells 里 → unverifiable(不一致的那几格为 None:CellStatus 没有这个取值,已提交的
导入里也不会出现,界面看本期结论);不在里面、而 failed + unverifiable 没超过 cells 的条数(清单列全了)→ ok;
否则 unknown:清单只记前 20 格,这一格是否在其中无法确定,照实说,不猜。
"""
if formula_check and not is_formula:
return "not_formula"
status = check.get("status")
if status == "passed":
return "ok"
if status not in ("unverifiable", "mismatch"):
return None
cells = [str(c) for c in check.get("cells") or []]
failed = check.get("failed") if _is_int(check.get("failed")) else 0
unverifiable = check.get("unverifiable") if _is_int(check.get("unverifiable")) else 0
if coord in cells:
# cells 先列不一致的格、再列无法核对的格(recipe_checks._finish)
return None if status == "mismatch" and cells.index(coord) < failed else "unverifiable"
if failed + unverifiable <= len(cells):
return "ok"
return "unknown"
def _plan(cid: str, check: dict[str, Any], acceptances: dict[str, AcceptanceView], *,
cell: str | None = None, rule: SumEqRule | None = None, detail: str | None = None) -> RelatedCheckPlan:
return RelatedCheckPlan(RelatedCheck(
id=cid, kind=str(check.get("kind") or ""), title=str(check.get("title") or cid),
part_status=check.get("status"), row_status=None, cell_status=cell, # type: ignore[arg-type]
acceptance=acceptances.get(cid), detail=detail), rule)
def related_checks(chain: Chain, located: Located, *, table: str, column: str) -> list[RelatedCheckPlan]:
"""这一格的相关核对(P4-SPEC 2.7):只取格子所在那一期的导入清单的 checks。
顺序:关系核对(清单里的顺序:sum_eq 是 table 等于这张表、被引用的列是 total 或 parts 之一的;not_comparable
是 a 或 b 为这张表的,只作口径说明),再 K、G、T(被引用的格就是某个合计格时,列出那一段的),最后统计期 C1、C2
(年份取自统计期时)。part_status、cell_status、acceptance 在这里定;row_status 留 None,由接口一侧按
RelatedCheckPlan.rule 在快照库上跑 SQL 后填。关系取格子所在那一期的配方。
"""
m = located.part.manifest
receipt = _receipt(m)
checks = [c for c in m.get("checks") or [] if isinstance(c, dict) and c.get("id")]
by_id = {str(c["id"]): c for c in checks}
acceptances: dict[str, AcceptanceView] = {}
for a in acceptances_of(m):
acceptances.setdefault(a.check_id, a)
canonical = (m.get("recipe") or {}).get("canonical") if isinstance(m.get("recipe"), dict) else None
try:
recipe: Recipe | None = Recipe.model_validate(canonical)
except Exception: # noqa: BLE001 - 配方解析不了:关系和合计核对认不出是哪一条,只列统计期
recipe = None
out: list[RelatedCheckPlan] = []
if recipe is not None:
relations = {r.id: r for r in recipe.relations}
types = {c["name"]: str(c.get("type") or "") for c in _columns(_table_out(receipt, table) or {})}
for c in checks:
rel = relations.get(str(c["id"]))
if c.get("kind") == "relation_sum_eq" and isinstance(rel, SumEq) and rel.table == table \
and column in (rel.total, *rel.parts):
names = [rel.total, *rel.parts]
rule = SumEqRule(table=rel.table, total=rel.total, parts=list(rel.parts),
types={n: types[n] for n in names if n in types})
out.append(_plan(rel.id, c, acceptances, rule=rule))
elif c.get("kind") == "relation_not_comparable" and isinstance(rel, NotComparable) \
and table in (rel.a.table, rel.b.table):
out.append(_plan(rel.id, c, acceptances))
out += _total_checks(recipe, receipt, located, by_id, acceptances)
if located.cell.year is not None:
out += [_plan(cid, by_id[cid], acceptances) for cid in ("C1", "C2") if cid in by_id]
return out
def _total_checks(recipe: Recipe, receipt: dict[str, Any], located: Located, by_id: dict[str, dict[str, Any]],
acceptances: dict[str, AcceptanceView]) -> list[RelatedCheckPlan]:
"""被引用的格就是某个合计格(DerivedItem.cell)时,那一段的 K、G(列表合计是 T)。编号由 plan_checks 定,
和导入时的核对、说明生成是同一套。"""
from app.data.recipe_imports import extraction_from_dict
coord = f"{located.cell.sheet}!{located.cell.cell}"
item = _derived_at(receipt, coord)
if item is None:
return []
try:
plan = plan_checks(recipe, extraction_from_dict(receipt))
except Exception: # noqa: BLE001 - 回执形状不对就认不出编号,不列合计核对
_log.warning("provenance: 回执还原失败,不列合计核对", exc_info=True)
return []
seg = item.get("segment")
is_formula = bool(item.get("is_formula"))
out: list[RelatedCheckPlan] = []
for cid, formula_check in ((plan.k.get(seg), False), (plan.g.get(seg), True), (plan.t.get(seg), False)):
check = by_id.get(cid) if cid else None
if check is None or check.get("kind") not in _TOTAL_KINDS:
continue
details = [str(d) for d in check.get("details") or []]
detail = details[0] if check.get("status") == "unverifiable" and details else None
out.append(_plan(cid, check, acceptances, detail=detail, # type: ignore[arg-type]
cell=cell_status(check, coord, is_formula=is_formula, formula_check=formula_check)))
return out
# ==========================================================================
# 裁判摘录的来历行(P4-SPEC 3.2)
# ==========================================================================
def _period_words(manifest: dict[str, Any]) -> str:
per = _period(manifest)
if per is None or not per.get("start") or not per.get("end"):
return "统计期未记录"
return f"统计期 {per['start']} 至 {per['end']}"
def _period_detail(manifest: dict[str, Any]) -> str:
"""来源行里的统计期:取自哪几格,或人工录入(R2 的人工录入上下文:署名照写,标「未认证」)。"""
words = _period_words(manifest)
per = _period(manifest)
if per is None or words == "统计期未记录":
return words
if per.get("source") == "human":
who = _str(per.get("signed_by"))
return f"{words}(人工录入,署名「{who}」,未认证)" if who else f"{words}(人工录入,未署名)"
cells = [str(c) for c in per.get("cells") or []]
return f"{words}(取自 {'、'.join(cells)})" if cells else words
def _part_line(part: ChainPart) -> str:
m = part.manifest
f = m.get("file") if isinstance(m.get("file"), dict) else {}
receipt = _receipt(m)
bits = [_period_detail(m)]
name, sha = _str(f.get("name")), _str(f.get("raw_sha256"))
if name:
bits.append(f"文件「{name}」" + (f"(sha256 {sha[:12]})" if sha else ""))
region = region_of(receipt)
if region:
bits.append(f"区域 {region}")
excluded = excluded_rows_of(receipt)
if excluded:
bits.append(f"排除 {excluded} 行")
return f"第 {part.seq} 次导入:" + ",".join(bits)
def _masked(name: str, hidden: set[str]) -> bool:
return str(name).lower() in hidden
@dataclass
class _Lines:
"""来历块的各部分。分开存,整体超长时按 3.2 的五步顺序删(_fit),删完再拼成行。"""
head: list[str]
parts: list[str] # 来源的分期行,按统计期升序
parts_more: int
tables: list[tuple[str, str]] # 表的说明:(行首, 说明)
columns: list[str]
accepts: list[tuple[int, str]] # (期的位置, 行),按期次升序
accepts_more: int
outside: list[tuple[int, str]] # (期的位置, 行),按送出的顺序
outside_more: int # 超出条数上限、被截断步骤删掉的
outside_unlisted: int # hidden 为 True 或未记录(None)的
outside_masked: int # 数据源设有遮罩时的总条数(一格都不送)
cut: bool = False
def render(self) -> list[str]:
out = list(self.head)
out += [f" {x}" for x in self.parts]
if self.parts_more:
out.append(f" 另有 {self.parts_more} 期,见证据面板")
out += [a + b for a, b in self.tables]
out += self.columns
if self.accepts:
out.append(_ACCEPT_HEAD)
out += [f" {x}" for _, x in self.accepts]
if self.accepts_more:
out.append(f" 另有 {self.accepts_more} 条,见证据面板")
if self.outside_masked:
out.append(f"区域外另有 {self.outside_masked} 处文字(数据源设置了遮罩,未列出),见证据面板")
else:
unlisted = (f"另有 {self.outside_unlisted} 处区域外文字未列出(所在行列被隐藏,或导入时未记录是否隐藏),"
"见证据面板")
if self.outside:
out.append(_OUTSIDE_HEAD)
out += [f" {x}" for _, x in self.outside]
if self.outside_more:
out.append(f" 另有 {self.outside_more} 处区域外文字未列出(超出条数上限),见证据面板")
if self.outside_unlisted:
out.append(f" {unlisted}")
elif self.outside_unlisted:
out.append(unlisted)
if self.cut:
out.append(_CUT)
return out
def size(self) -> int:
return len("\n".join(self.render()))
def _fit(block: _Lines, limit: int) -> None:
"""整块超过 limit 字时按固定顺序删(P4-SPEC 3.2),保证确定:
1. 来源的分期行,从最旧的期起(总述行保留);2. 列的说明,从最后一列起;3. 已接受,从最旧的期起;
4. 区域外文字,从最旧的期起;5. 表的说明截到剩余字数,末尾加「…」。删过任何一项,末尾加一行说明。"""
while block.size() > limit:
if block.parts:
block.parts.pop(0)
block.parts_more += 1
elif block.columns:
block.columns.pop()
elif block.accepts:
block.accepts.pop(0)
block.accepts_more += 1
elif block.outside:
oldest = min(k for k, _ in block.outside)
at = max(i for i, (k, _) in enumerate(block.outside) if k == oldest)
block.outside.pop(at)
block.outside_more += 1
elif block.tables:
if not block.cut:
block.cut = True
continue
over = block.size() - limit
lead, body = block.tables[-1]
keep = len(body) - over - 1
if keep > 0:
block.tables[-1] = (lead, body[:keep] + "…")
else:
block.tables.pop()
else:
break
block.cut = True
def judge_lines(query: dict[str, Any], schema: dict[str, Any] | None, load: Loader, *,
tables: list[str], hidden: set[str]) -> list[str]:
"""裁判摘录里查询快照 SQL 之后、「列:」之前的来历行(P4-SPEC 3.2)。纯函数,loader 注入,同样的工件永远得到
同样的行;不取数据库列、不取当前时间。
- tables:调用方用 direct_select.tables_in 算好的 SQL 用到的表(这里与冻结表结构按 name_key 求交集);
- hidden:_query_excerpt 现成的那份遮罩列(小写)。非空表示数据源设有遮罩:接受理由不写、区域外文字一格不送
(理由、注释里可能写着遮罩列的数),遮罩列的说明不写。
生效范围:来源、已接受、区域外文字要求表结构快照是按配方导入的、链能解析;口径(表和列的说明)有 data_version
和表结构快照即可,简单导入也写(调整 6)。查询快照没有 data_version(手工源)时返回空列表。链解析失败时写一行
「来源:导入清单无法读取…」,不中断摘录。
"""
if not isinstance(query, dict) or not query.get("data_version") or not isinstance(schema, dict):
return []
hidden = {str(h).lower() for h in hidden or ()}
frozen = _frozen_tables(schema)
used: list[str] = []
for name in tables or []:
hit = _frozen_name(frozen, str(name))
if hit is not None and hit not in used:
used.append(hit)
block = _Lines(head=[], parts=[], parts_more=0, tables=[], columns=[], accepts=[], accepts_more=0, outside=[],
outside_more=0, outside_unlisted=0, outside_masked=0)
_caliber(block, query, frozen, used, hidden)
if schema.get("import_mode") == "recipe":
chain = resolve_chain(query, schema, load)
if isinstance(chain, ChainProblem):
block.head = [SOURCE_BROKEN]
else:
_source(block, chain)
_accepts(block, chain, hidden)
_outside(block, chain, hidden)
_fit(block, PROV_CHARS)
return block.render()
def _source(block: _Lines, chain: Chain) -> None:
mode = _MODE_LABEL.get(chain.mode, "每期替换")
if not chain.union:
block.head = [f"来源:上传的表格,{mode},{_part_line(chain.parts[0])}"]
return
spans = [(str(p["start"]), str(p["end"])) for part in chain.parts
if (p := _period(part.manifest)) is not None and p.get("start") and p.get("end")]
span = f"({min(a for a, _ in spans)} 至 {max(b for _, b in spans)})" if spans else ""
block.head = [f"来源:上传的表格,{mode},共 {len(chain.parts)} 期{span}"]
shown = chain.parts[-PROV_PARTS:]
block.parts = [_part_line(p) for p in shown]
block.parts_more = len(chain.parts) - len(shown)
def _caliber(block: _Lines, query: dict[str, Any], frozen: dict[str, Any], used: list[str],
hidden: set[str]) -> None:
"""口径:SQL 用到的每张表的说明;查询结果列里按 name_key 对得上这些表某列、且列有注释的,列的说明(遮罩列不写)。
说明来自冻结的表结构快照,本来就是模型看得到的、不含数字的散文;未规整的表的说明就是 UNSHAPED_NOTE。"""
for name in used:
comment = frozen[name].get("comment") if isinstance(frozen[name], dict) else None
if isinstance(comment, str) and comment.strip():
block.tables.append((f"口径:表 {name} 的说明:", comment.strip()))
seen: set[str] = set()
for col in query.get("columns") or []:
key = name_key(str(col))
if len(block.columns) >= PROV_COLUMN_NOTES or _masked(str(col), hidden):
continue
for name in used:
meta = frozen[name] if isinstance(frozen[name], dict) else {}
hit = next((c for c in meta.get("columns") or [] if isinstance(c, dict)
and name_key(str(c.get("name"))) == key), None)
if hit is None:
continue
cname, comment = str(hit.get("name")), hit.get("comment")
if cname not in seen and not _masked(cname, hidden) and isinstance(comment, str) and comment.strip():
seen.add(cname)
block.columns.append(f"口径:列 {cname} 的说明:{comment.strip()}")
break
def _accepts(block: _Lines, chain: Chain, hidden: set[str]) -> None:
"""已接受:各期清单 acceptances,按期次排序,最多 PROV_ACCEPT 条(取最近的几期的),其余写「另有 n 条」。"""
rows: list[tuple[int, str]] = []
for k, part in enumerate(chain.parts):
where = f"第 {part.seq} 次导入({_period_words(part.manifest)})"
for a in acceptances_of(part.manifest):
title = a.title or a.check_id
head = f"{where}的核对「{title}」{_ACCEPT_STATE.get(a.kind, '不成立')}"
if hidden:
rows.append((k, f"{head}{_ACCEPT_MASKED}"))
continue
who = f"署名「{a.signed_by}」,未认证" if a.signed_by else "未署名"
rows.append((k, f"{head},理由:{a.reason}({who})"))
block.accepts = rows[-PROV_ACCEPT:] if rows else []
block.accepts_more = len(rows) - len(block.accepts)
def _outside(block: _Lines, chain: Chain, hidden: set[str]) -> None:
"""区域外文字:只送 hidden is False 的项(调整 10);为 True 或老清单没有这个字段的只计条数。先含数字的,
再按期次从新到旧;每格最多 PROV_OUTSIDE_CHARS 字,最多 PROV_OUTSIDE 格。每行写所属的导入和统计期:并集时 8 月
的注释不能被读成 9 月的。数据源设有遮罩时一格都不送,只写总条数。"""
items: list[tuple[int, int, int, int, str]] = [] # (含数字在前, 新的在前, 原顺序, 期的位置, 行)
total = unlisted = 0
for k, part in enumerate(chain.parts):
where = f"第 {part.seq} 次导入({_period_words(part.manifest)})"
for n, o in enumerate(_receipt(part.manifest).get("outside_text") or []):
if not isinstance(o, dict):
continue
total += 1
if o.get("hidden") is not False:
unlisted += 1
continue
text = str(o.get("text") or "")
shown = f"「{text}」" if len(text) <= PROV_OUTSIDE_CHARS else f"「{text[:PROV_OUTSIDE_CHARS]}…」(已截断)"
items.append((0 if o.get("kind") == "text_digits" else 1, -k, n, k, f"{where}{o.get('cell')}:{shown}"))
if hidden:
block.outside_masked = total
return
items.sort(key=lambda x: (x[0], x[1], x[2]))
block.outside = [(k, line) for *_, k, line in items[:PROV_OUTSIDE]]
block.outside_more = max(0, len(items) - PROV_OUTSIDE)
block.outside_unlisted = unlisted