Files
AgentLab/backend/app/data/recipe_diff.py
T
ThearchyHelios 2d5e50fa88 Excel 导入改造期 2:配方引擎与确认向导——表从哪一行哪一列开始、哪些是合计、日期怎么补年份,都写进一份可校验的配方;首次导入经人确认,之后每期按配方重放并核对
配方语言(agentlab-recipe/2,新 recipe_types、recipe)
- 封闭、不含正则、保存时静态校验:常量只能从表里的候选文字里选,占位符只能映射成空值,单位从固定词表里取
- 交叉表和列表两种版式;分段各自定位、不依赖先后顺序;合计按标签语义区间核对,关系恒等式、口径不同的声明都写在配方里
- 固定的解析器集合(parsers/2):全角、各类横线、千分位、月日写法宽容归一,规范写法存库、原文另记

执行器(新 xlsx_cells、recipe_engine)
- 两遍读取逐行对账,每个非空格都要有去向(格子账);交叉表逆透视成长表,按统计期补年份并断言覆盖整期
- 合计行改作核对并另存一张表,不和明细混在一列;区域外文字、隐藏行列、合并单元格、空行之后的文字都单独报出
- STRICT 加主键写临时库;按锚点段记溯源,供后续证据下钻用;大表流式读取,扫描在 sheetData 处停止

校验与说明(新 recipe_checks、recipe_notes)
- SQL 核对分通过、不一致、无法核对三种结果,失败分结构、数据质量、需确认三类;结构类直接拒收
- 数据质量类可以写理由接受,接受不改库、只改状态和说明
- 表的说明按核对结果生成、不含数字,经过数字检查;没核对过的不写「已核对」

起草(新 recipe_suggest、recipe_ai)
- 先用规则起草:建议卡片加待确认问题(关系要不要登记、占位符表示什么)
- 规则起草不完整时才提示 AI 兜底:界面先展示要发出的全部文字,用户同意才发;只发结构,数字、隐藏行列、遮罩列不发,公式先脱敏;同意绑定模型和服务方;AI 只写配方,有限次修订,记 token 和费用;截断单独识别
- 无论规则还是 AI 起草,都必须人工确认才能启用

导入流程(新 recipe_imports、recipe_confirm、recipe_diff、api/table_imports)
- 暂存 → 起草 → 回答问题或改配方 → 试运行 → 勾确认项、写接受理由 → 确认并启用;接入期 1 的构建、导入记录、快照和运行固定版本
- 上传新一期按已确认的配方重放,不调用模型,出回执和差异卡,手动启用
- 改配方报破坏性变更(breaking:*)、单位变化、工作表改名的兜底,都要逐项勾选确认

前端导入向导(新 pages/import)
- 原始网格(行列号、去向着色)、建议卡片、配方面板(表单,不露正则)、试运行回执、确认清单、差异卡、AI 起草的同意对话框
2026-10-01 22:00:07 +08:00

777 lines
38 KiB
Python
Raw 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.
"""差异卡(期 2,WP-5b;P2-SPEC 7.6):上传新一期时,拿本期的导入回执和上一期的比,列出哪里和上次不一样。
**为什么要有差异卡。** 配方能通过试运行,只说明这一期的文件「对得上结构」;标签写法、块的先后、合计由
公式变成写死的数、区域外多了一句含数字的说明,这些都不会让试运行失败,却可能改变数据的口径(E3、E7、
R3)。差异卡把它们逐条摆出来:只是信息的(统计期、行数)标成 info,可能改变口径的标成「需确认」,
对应的确认项(`diff:<kind>:<键>`)由 recipe_confirm 收进必勾清单。
**输入形状。** prev / cur 都是「导入回执」``{"receipt": 回执, "checks": [核对摘要], "period": PeriodOut}``,
JSON 形状的 dict(dataclass 也收,先转成 dict)。回执就是 ``dataclasses.asdict(Extraction)`` 去掉
problems 之后的样子;逐项比较时用到其中的 RECEIPT_KEYS(tables、labels、block_order、derived_form、axes、
outside_text、ignored_columns、hidden、placeholders、canonicalized、sheets、lineage、full_calc_on_load;
列表的列顺序只能靠溯源里各列首格的列号推出来),外加顶层的 checks。
**缺键就报错(ReceiptIncomplete),不按空处理。** 早先的写法是「缺的键按空处理、比不了的项不出」,结果
调用方少带一个键(清单里没存 axes、cur 用了只有前 50 条规范写法对照的 TrialOut.receipt),需确认的
axis_form、label_writing、canon_values 就静默消失了(H2)。所以:
- prev、cur 都必须有 ``receipt``,回执里必须有 tables;
- 逐项比较时(上一期也是按配方导入的、配方没变),两边的回执都必须带齐 RECEIPT_KEYS、顶层带 checks;
规范写法对照被截断(canonicalized_total 大于条数)、或是部分干跑(partial)的结果,同样报错;
- 判断不了配方变没变(没传 recipe_changed,两边又没都带配方哈希)时报错,不默认「没变」去跨配方逐项比;
- 逐项比较时必须给本期配方:列表表头写法的确认项按**块**出,表 → 块只能从配方里查。
cur 取自本次试运行;prev 取自**现行导入的导入清单**(7.7),不取 TableBuild.report:构建可能被复用,
回执里没有 C1、C2,文件名也不是那一次导入的(评审甲-16)。上一期是简单导入(kind=switch,prev 是期 1 的
TableBuild.report,没有 ledger 键)或配方变了(redraft、改配方)时,两边的分段、块、表不一定对得上,
只给 info 类的 rows、period;破坏性变更由 recipe_confirm 按新旧 derive_tables 另算(breaking:*)。
**数字遮盖后的模板**(digit_template):canon 之后把每一段连续的 ASCII 数字换成 ``{}``。文件名、统计期格、
区域外文字的「写法变了」都按它比,每月必变的日期因此不算变化。
纯函数,不碰数据库、不读文件。
"""
from __future__ import annotations
import dataclasses
import re
from typing import Any
from app.data.names import canon
from app.data.recipe_parsers import match_key, period_residue
from app.data.recipe_types import DiffItem, ListBlock, Recipe
_DIGITS = re.compile(r"[0-9]+")
#: 差异卡的全部 kind,按界面上的先后(7.6 的表;outside_moved 是表里「只是位置变了的算 info」那一项)
DIFF_KINDS: tuple[str, ...] = (
"period", "rows", "placeholders", "checks", "file_name", "full_calc", "context_text",
"context_source_added", "column_order", "label_writing", "header_writing", "canon_values", "row_order",
"block_order", "derived_form", "axis_form", "outside_added", "outside_removed", "outside_changed",
"outside_moved", "ignored_columns", "hidden",
)
#: 需确认的 kind → 确认项 id 的前缀(id = 前缀 + 键,键的格式见契约 recipe_types 的模块 docstring)
CONFIRM_PREFIX: dict[str, str] = {
"label_writing": "diff:label_writing:",
"header_writing": "diff:header_writing:",
"canon_values": "diff:canon:",
"row_order": "diff:row_order:",
"block_order": "diff:block_order:",
"derived_form": "diff:derived_form:",
"axis_form": "diff:axis_form:",
"outside_added": "diff:outside:",
"outside_removed": "diff:outside:",
"outside_changed": "diff:outside:",
"ignored_columns": "diff:ignored:",
"hidden": "diff:hidden:",
}
_STATUS = {"passed": "通过", "mismatch": "不一致", "unverifiable": "无法核对", "info": "提示"}
_DERIVED_FORM = {"formula": "公式", "literal": "写死的数", "mixed": "公式与写死的数混用"}
_AXIS_FORM = {"text": "文字", "date": "日期格", "mixed": "文字与日期格混用"}
_SHORT = 40
_A1 = re.compile(r"\$?([A-Za-z]{1,3})\$?([0-9]{1,7})$")
_WS = re.compile(r"\s+")
#: 逐项比较时两边回执都必须有的键(7.7 的导入清单 receipt 照 asdict(Extraction) 去掉 problems 写就都有)
RECEIPT_KEYS: tuple[str, ...] = (
"tables", "labels", "block_order", "derived_form", "axes", "outside_text", "ignored_columns", "hidden",
"placeholders", "canonicalized", "sheets", "lineage", "full_calc_on_load",
)
class ReceiptIncomplete(ValueError):
"""导入回执缺了比较要用的键、被截断或只是部分干跑:调用方的错,报出来,不按空处理(见模块 docstring)。
side 是 prev / cur / extraction,missing 是缺的键(截断、部分干跑时写成 canonicalized_total、partial)。
"""
def __init__(self, side: str, missing: list[str]) -> None:
self.side = side
self.missing = list(missing)
super().__init__(f"{side} 不完整,缺少比较要用的内容:{'、'.join(self.missing)}")
# --------------------------------------------------------------------------
# 小工具(recipe_confirm 也用)
# --------------------------------------------------------------------------
def digit_template(text: Any) -> str:
"""数字遮盖后的模板:「统计时间范围:2026年9月1日至2026年9月30日」→「统计时间范围:{}年{}月{}日至{}年{}月{}日」。
先 canon(全角数字经 NFKC 变成 ASCII、横线统一、空白折叠),再把每一段连续的 ASCII 数字换成 {}。
"""
return _DIGITS.sub("{}", canon("" if text is None else str(text)))
def same_period_sentence(old: Any, new: Any) -> bool:
"""两期的统计期格是不是「每月重复的同一句」:数字遮盖后的模板相同,且只去掉统计期那一段之后剩下的文字
(period_residue:其余数字照留)相同。任一期认不出统计期(None)就不算同一句。
只比遮盖模板不够(AU-3):「注:2026年9月数据只含3个分区」与「注:2026年10月数据只含2个分区」模板相同,
但统计期以外的口径数字变了。确认项(outside_digits 免确认的条件 (b))与差异卡(outside_changed)共用这一个判定。
"""
if digit_template(old) != digit_template(new):
return False
ra, rb = period_residue(old), period_residue(new)
return ra is not None and rb is not None and match_key(ra) == match_key(rb)
def plain(x: Any) -> Any:
"""dataclass(含嵌套)转成 JSON 形状;dict、list 逐层处理,其余原样。调用方传 Extraction 或 asdict 都行。"""
if dataclasses.is_dataclass(x) and not isinstance(x, type):
return dataclasses.asdict(x)
if isinstance(x, dict):
return {k: plain(v) for k, v in x.items()}
if isinstance(x, (list, tuple)):
return [plain(v) for v in x]
return x
def coord(sheet: Any, cell: Any) -> str:
"""「工作表!A1」。cell 已经带工作表名的原样返回;不知道工作表时只给 A1。"""
cell = str(cell or "")
if "!" in cell or not sheet:
return cell
return f"{sheet}!{cell}"
def split_coord(text: Any) -> tuple[str | None, int | None, int | None]:
"""「工作表!C5」→ (工作表, 行, 列号);认不出的部分为 None。"""
s = str(text or "")
sheet, _, a1 = s.rpartition("!")
m = _A1.match(a1.strip())
if m is None:
return (sheet or None), None, None
col = 0
for ch in m.group(1).upper():
col = col * 26 + (ord(ch) - 64)
return (sheet or None), int(m.group(2)), col
def col_letter(n: int) -> str:
out = ""
while n > 0:
n, r = divmod(n - 1, 26)
out = chr(65 + r) + out
return out
def short(text: Any, limit: int = _SHORT) -> str:
"""给标题用的短写法:空白折叠,超长的截断加「…」,全文放 detail。不做 canon:界面上照原文显示,
canon 的 NFKC 会把全角标点变成半角。"""
s = _WS.sub(" ", "" if text is None else str(text)).strip()
return s if len(s) <= limit else s[:limit] + "…"
def rows_text(rows: Any) -> str:
"""[28, 29, 30] →「第 28–30 行」;不连续的用顿号;空的给空串。"""
nums = sorted({int(r) for r in rows or [] if isinstance(r, int) or str(r).isdigit()})
if not nums:
return ""
if len(nums) > 1 and nums[-1] - nums[0] == len(nums) - 1:
return f"第 {nums[0]}–{nums[-1]} 行"
return "第 " + "、".join(str(n) for n in nums) + " 行"
def receipt_of(doc: Any) -> dict[str, Any]:
doc = plain(doc) or {}
rec = doc.get("receipt") if isinstance(doc, dict) else None
return rec if isinstance(rec, dict) else {}
def require_receipt(doc: Any, side: str, keys: tuple[str, ...] = RECEIPT_KEYS, *,
top: tuple[str, ...] = ("checks",)) -> None:
"""检查导入回执带齐了 keys(回执里)和 top(顶层),没有被截断、不是部分干跑;不满足就抛 ReceiptIncomplete。"""
doc = plain(doc)
if not isinstance(doc, dict) or not isinstance(doc.get("receipt"), dict):
raise ReceiptIncomplete(side, ["receipt"])
rec = doc["receipt"]
missing = [k for k in top if k not in doc] + [k for k in keys if k not in rec]
if "canonicalized" in keys and "canonicalized" in rec and "canonicalized_total" in rec:
# TrialOut.receipt 里的 canonicalized 只有前 50 条;拿它比会漏掉新写法
if int(rec.get("canonicalized_total") or 0) > len(rec.get("canonicalized") or []):
missing.append("canonicalized_total")
if rec.get("partial"):
missing.append("partial")
if missing:
raise ReceiptIncomplete(side, missing)
def recipe_sha_of(doc: Any) -> str | None:
"""导入回执上的配方哈希:顶层 recipe_sha256,或导入清单的 recipe.sha256(7.7)。"""
doc = plain(doc) or {}
if not isinstance(doc, dict):
return None
sha = doc.get("recipe_sha256")
if not sha and isinstance(doc.get("recipe"), dict):
sha = doc["recipe"].get("sha256")
return str(sha) if sha else None
def period_of(doc: Any) -> dict[str, Any] | None:
"""导入回执的统计期:顶层 period 优先(人工录入只记在那里),没有再看回执里的。"""
doc = plain(doc) or {}
p = doc.get("period") if isinstance(doc, dict) else None
if not isinstance(p, dict):
p = receipt_of(doc).get("period")
return p if isinstance(p, dict) else None
def checks_of(doc: Any) -> list[dict[str, Any]]:
doc = plain(doc) or {}
rows = doc.get("checks") if isinstance(doc, dict) else None
return [c for c in rows or [] if isinstance(c, dict) and c.get("id")]
def check_status(doc: Any, check_id: str) -> str | None:
for c in checks_of(doc):
if c.get("id") == check_id:
return str(c.get("status") or "")
return None
def default_sheet(rec: dict[str, Any]) -> str | None:
"""只有一张配方工作表时它的实际名字:统计期格的坐标万一没带工作表名,靠它补上。"""
matched = (rec.get("sheets") or {}).get("matched") or {}
names = {str(v) for v in matched.values() if v}
if len(names) == 1:
return next(iter(names))
sheets = {str(o.get("sheet")) for o in rec.get("outside_text") or [] if isinstance(o, dict) and o.get("sheet")}
return next(iter(sheets)) if len(sheets) == 1 else None
def outside_of(rec: dict[str, Any]) -> list[dict[str, Any]]:
"""区域外文字(含带附加文字的统计期格),坐标统一成「工作表!A1」。"""
out = []
for o in rec.get("outside_text") or []:
if not isinstance(o, dict):
continue
out.append({"coord": coord(o.get("sheet"), o.get("cell")), "text": str(o.get("text") or ""),
"kind": str(o.get("kind") or "text"), "period_source": bool(o.get("period_source"))})
return out
def period_texts(doc: Any, rec: dict[str, Any]) -> dict[str, str]:
"""统计期来源格:坐标 → 全文。"""
p = period_of(doc) or {}
sheet = default_sheet(rec)
return {coord(sheet, k): str(v or "") for k, v in (p.get("texts") or {}).items()}
def period_annotated(doc: Any, rec: dict[str, Any]) -> dict[str, str]:
"""带附加文字的统计期格:坐标 → period_residue。"""
p = period_of(doc) or {}
sheet = default_sheet(rec)
return {coord(sheet, k): str(v or "") for k, v in (p.get("annotated") or {}).items()}
# --------------------------------------------------------------------------
# 差异卡
# --------------------------------------------------------------------------
def diff_reports(prev: dict[str, Any] | None, cur: dict[str, Any], *, prev_file_name: str | None,
cur_file_name: str, recipe: Recipe | dict[str, Any] | None = None,
recipe_changed: bool | None = None) -> list[DiffItem]:
"""上一期 → 本期的差异卡。prev 为 None(首次)返回 []。需确认的排在前面,同类按 DIFF_KINDS 的顺序。
上一期的回执没有 ledger 键(简单导入,kind=switch)或配方变了(redraft、改配方)时只给 rows、period;
否则逐项比较。规格之外多了两个参数:
- recipe:本期的配方,逐项比较时必须给。列表的表头写法变化要按**块**出确认项
(``diff:header_writing:<块>``),回执里只有表,「表 → 块」只能从配方里查。
- recipe_changed:配方与上一期是否不同。不传时看 prev、cur 两边的配方哈希(顶层 ``recipe_sha256``,
或导入清单的 ``recipe.sha256``);两边没都带、又没传时报错,不默认「没变」。传了、两边也都带了哈希
而两者矛盾时同样报错。
"""
if prev is None:
return []
prev, cur = plain(prev) or {}, plain(cur) or {}
for side, doc in (("prev", prev), ("cur", cur)):
require_receipt(doc, side, ("tables",), top=())
p_rec, c_rec = receipt_of(prev), receipt_of(cur)
items: list[DiffItem] = []
items += _diff_period(prev, cur)
items += _diff_rows(p_rec, c_rec)
if "ledger" in p_rec and not _recipe_changed(prev, cur, recipe_changed):
require_receipt(prev, "prev")
require_receipt(cur, "cur")
if recipe is None:
raise ValueError("逐项比较需要本期配方:列表表头写法的确认项按块出,表与块的对应只能从配方里查")
blocks = _list_blocks(recipe)
items += _diff_placeholders(p_rec, c_rec)
items += _diff_checks(prev, cur)
items += _diff_file_name(prev_file_name, cur_file_name)
items += _diff_full_calc(p_rec, c_rec)
items += _diff_context_text(prev, cur, p_rec, c_rec)
items += _diff_column_order(p_rec, c_rec)
items += _diff_labels(p_rec, c_rec)
items += _diff_header_writing(p_rec, c_rec, blocks)
items += _diff_canon(p_rec, c_rec)
items += _diff_block_order(p_rec, c_rec)
items += _diff_forms(p_rec, c_rec)
items += _diff_outside(prev, cur, p_rec, c_rec)
items += _diff_ignored(p_rec, c_rec)
items += _diff_hidden(p_rec, c_rec)
order = {k: i for i, k in enumerate(DIFF_KINDS)}
items.sort(key=lambda d: (not d.requires_confirm, order.get(d.kind, len(order))))
return items
def _recipe_changed(prev: dict[str, Any], cur: dict[str, Any], flag: bool | None) -> bool:
"""配方与上一期是否不同。传了 flag 就用它(两边都带哈希时还要对得上);没传就比两边的配方哈希。"""
a, b = recipe_sha_of(prev), recipe_sha_of(cur)
if a and b:
if flag is not None and flag != (a != b):
raise ValueError("recipe_changed 与 prev、cur 上的配方哈希不一致")
return a != b
if flag is None:
raise ValueError("无法判断配方是否与上一期相同:请传 recipe_changed,或在 prev、cur 上都带配方哈希")
return flag
def _item(kind: str, label: str, detail: str = "", key: str | None = None) -> DiffItem:
prefix = CONFIRM_PREFIX.get(kind)
if prefix is None:
return DiffItem(kind=kind, label=label, detail=detail)
return DiffItem(kind=kind, label=label, detail=detail, requires_confirm=True, confirm_id=f"{prefix}{key}")
def _period_span(p: dict[str, Any] | None) -> tuple[str, str] | None:
if not p or not p.get("start") or not p.get("end"):
return None
return str(p["start"]), str(p["end"])
def _diff_period(prev: dict[str, Any], cur: dict[str, Any]) -> list[DiffItem]:
a, b = _period_span(period_of(prev)), _period_span(period_of(cur))
if a == b:
return []
if a is None:
return [_item("period", f"统计期:本期 {b[0]} 至 {b[1]}", "上一期没有记录统计期")]
if b is None:
return [_item("period", f"统计期:上一期 {a[0]} 至 {a[1]},本期未能确定")]
return [_item("period", f"统计期 {a[0]} 至 {a[1]} → {b[0]} 至 {b[1]}")]
def _table_rows(rec: dict[str, Any]) -> dict[str, int]:
out: dict[str, int] = {}
for t in rec.get("tables") or []:
if isinstance(t, dict) and t.get("name") is not None:
out[str(t["name"])] = int(t.get("rows") or 0)
return out
def _placeholder_counts(rec: dict[str, Any]) -> dict[str, int]:
return {str(k): int(v or 0) for k, v in (rec.get("placeholders") or {}).items()}
def _diff_rows(p_rec: dict[str, Any], c_rec: dict[str, Any]) -> list[DiffItem]:
a, b = _table_rows(p_rec), _table_rows(c_rec)
if a == b:
return []
parts = []
for name in list(dict.fromkeys([*b, *a])):
if name in a and name in b:
if a[name] != b[name]:
parts.append(f"「{name}」{a[name]} → {b[name]}")
elif name in b:
parts.append(f"「{name}」本期新增,{b[name]} 行")
else:
parts.append(f"「{name}」本期没有,上一期 {a[name]} 行")
pa, pb = _placeholder_counts(p_rec), _placeholder_counts(c_rec)
notes = [f"占位符「{t}」{pa.get(t, 0)} → {pb.get(t, 0)} 格"
for t in dict.fromkeys([*pa, *pb]) if pa.get(t, 0) != pb.get(t, 0)]
return [_item("rows", "行数:" + ";".join(parts), ";".join(notes))]
def _diff_placeholders(p_rec: dict[str, Any], c_rec: dict[str, Any]) -> list[DiffItem]:
# 只比原文集合:每月个数都会变(31 天 → 30 天),个数写在 rows 的细节里
a = {t for t, n in _placeholder_counts(p_rec).items() if n > 0}
b = {t for t, n in _placeholder_counts(c_rec).items() if n > 0}
if a == b:
return []
parts = []
if b - a:
parts.append("新出现" + "".join(f"「{t}」" for t in sorted(b - a)))
if a - b:
parts.append("不再出现" + "".join(f"「{t}」" for t in sorted(a - b)))
return [_item("placeholders", "占位符:" + ";".join(parts))]
def _diff_checks(prev: dict[str, Any], cur: dict[str, Any]) -> list[DiffItem]:
# 比 (id, 状态) 的集合:核对数每期都变,不算
a = {c["id"]: str(c.get("status") or "") for c in checks_of(prev)}
b = {c["id"]: str(c.get("status") or "") for c in checks_of(cur)}
if set(a.items()) == set(b.items()):
return []
parts = []
for cid in dict.fromkeys([*b, *a]):
if cid in a and cid in b:
if a[cid] != b[cid]:
parts.append(f"{cid} {_STATUS.get(a[cid], a[cid])} → {_STATUS.get(b[cid], b[cid])}")
elif cid in b:
parts.append(f"{cid} 本期新出现({_STATUS.get(b[cid], b[cid])})")
else:
parts.append(f"{cid} 本期不再出现")
return [_item("checks", "核对结果:" + ";".join(parts))]
def _base_name(name: str | None) -> str:
return str(name or "").replace("\\", "/").rsplit("/", 1)[-1]
def _diff_file_name(prev_name: str | None, cur_name: str) -> list[DiffItem]:
if prev_name is None:
return []
a, b = _base_name(prev_name), _base_name(cur_name)
if digit_template(a) == digit_template(b):
return []
return [_item("file_name", f"文件名的写法变了:「{short(a)}」→「{short(b)}」")]
def _diff_full_calc(p_rec: dict[str, Any], c_rec: dict[str, Any]) -> list[DiffItem]:
"""本期设了打开时重算就出(规格 7.6「这一期设了」,不论上一期设没设):导入用的是保存值,每期都提醒。"""
if not c_rec.get("full_calc_on_load"):
return []
before = "上一期也设置了" if p_rec.get("full_calc_on_load") else "上一期没有设置"
return [_item("full_calc", "本期文件设置了打开时重新计算公式", f"导入使用文件里保存的值;{before}")]
def _diff_context_text(prev: dict[str, Any], cur: dict[str, Any], p_rec: dict[str, Any],
c_rec: dict[str, Any]) -> list[DiffItem]:
"""两期都是统计期来源、而且都只是在说统计期的格(不在区域外文字里):写法变了、C1 不是 mismatch 时出 info(D12)。
带附加文字的统计期格同时是区域外文字,写法变化归 outside_*(要确认),不在这里重复。
"""
if check_status(cur, "C1") == "mismatch":
return []
a, b = period_texts(prev, p_rec), period_texts(cur, c_rec)
in_outside = {o["coord"] for o in outside_of(p_rec)} | {o["coord"] for o in outside_of(c_rec)}
out = []
for cell in b:
if cell in a and cell not in in_outside and digit_template(a[cell]) != digit_template(b[cell]):
out.append(_item("context_text", f"统计期格 {cell} 的写法变了:「{short(a[cell])}」→「{short(b[cell])}」",
f"上一期:{a[cell]};本期:{b[cell]}"))
return out
def _is_list_table(t: dict[str, Any]) -> bool:
# 交叉表产出的表一定有轴列(宽表、长表、表内合计都以它开头);列表合计表是 reported_total
cols = [c for c in t.get("columns") or [] if isinstance(c, dict)]
return str(t.get("kind") or "data") == "data" and not any(c.get("role") == "axis" for c in cols)
def _tables(rec: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {str(t["name"]): t for t in rec.get("tables") or [] if isinstance(t, dict) and t.get("name") is not None}
def _column_positions(rec: dict[str, Any], table: str) -> dict[str, int]:
"""溯源里各列首格的列号:列表一列一段(向下),首格的列号就是这一列在工作表里的位置。"""
out: dict[str, int] = {}
for col, runs in ((rec.get("lineage") or {}).get(table) or {}).items():
for run in runs or []:
if isinstance(run, (list, tuple)) and len(run) >= 3:
_, _, c = split_coord(run[2])
if c is not None:
out[str(col)] = min(out.get(str(col), c), c)
return out
def _diff_column_order(p_rec: dict[str, Any], c_rec: dict[str, Any]) -> list[DiffItem]:
pa, pb = _tables(p_rec), _tables(c_rec)
out = []
for name, t in pb.items():
if name not in pa or not _is_list_table(t) or not _is_list_table(pa[name]):
continue
a, b = _column_positions(p_rec, name), _column_positions(c_rec, name)
common = set(a) & set(b)
if len(common) < 2:
continue
oa = sorted(common, key=lambda c: a[c])
ob = sorted(common, key=lambda c: b[c])
if oa != ob:
out.append(_item("column_order", f"表「{name}」的列顺序变了",
f"上一期:{'、'.join(oa)};本期:{'、'.join(ob)}"))
return out
def _segment_labels(rec: dict[str, Any]) -> dict[str, tuple[list[str], list[str]]]:
out: dict[str, tuple[list[str], list[str]]] = {}
for s in rec.get("labels") or []:
if isinstance(s, dict) and s.get("segment") is not None:
out[str(s["segment"])] = ([str(x) for x in s.get("raw") or []],
[str(x) for x in s.get("canonical") or []])
return out
def _diff_labels(p_rec: dict[str, Any], c_rec: dict[str, Any]) -> list[DiffItem]:
"""label_writing(同一规范写法的原文变了,D11、D22、D24)与 row_order(分段内顺序变了,D19)。
原文逐字比:en dash 的「7–8」和「7-8」canon 之后相同,正是要报的那种写法变化(E3)。
"""
a, b = _segment_labels(p_rec), _segment_labels(c_rec)
writing, order = [], []
for seg, (raw_b, can_b) in b.items():
if seg not in a:
continue
raw_a, can_a = a[seg]
map_a = dict(zip(reversed(can_a), reversed(raw_a)))
map_b = dict(zip(reversed(can_b), reversed(raw_b)))
pairs = [(map_a[c], map_b[c]) for c in dict.fromkeys(can_b) if c in map_a and map_a[c] != map_b[c]]
if pairs:
shown = ";".join(f"「{x}」→「{y}」" for x, y in pairs[:3]) + ("等" if len(pairs) > 3 else "")
writing.append(_item("label_writing", f"分段「{seg}」的标签写法变了:{shown}",
f"共 {len(pairs)} 个标签;按规范写法存储的值不变", key=seg))
common = set(can_a) & set(can_b)
seq_a = [c for c in dict.fromkeys(can_a) if c in common]
seq_b = [c for c in dict.fromkeys(can_b) if c in common]
if seq_a != seq_b:
order.append(_item("row_order", f"分段「{seg}」的行顺序变了",
f"上一期:{'、'.join(seq_a)};本期:{'、'.join(seq_b)}", key=seg))
return writing + order
def _list_blocks(recipe: Recipe | dict[str, Any]) -> dict[str, str]:
"""列表的表名 → 块 id(只看本期配方)。"""
r = recipe if isinstance(recipe, Recipe) else Recipe.model_validate(recipe)
out: dict[str, str] = {}
for sheet in r.sheets:
for block in sheet.blocks:
if isinstance(block, ListBlock):
out.setdefault(block.table, block.id)
return out
def _diff_header_writing(p_rec: dict[str, Any], c_rec: dict[str, Any], blocks: dict[str, str]) -> list[DiffItem]:
pa, pb = _tables(p_rec), _tables(c_rec)
out = []
for name, t in pb.items():
if name not in pa or not _is_list_table(t):
continue
if name not in blocks:
# 回执里的列表表在配方里找不到块:传进来的配方不是产出这份回执的那份
raise ValueError(f"本期配方里没有写入表「{name}」的列表块,配方与回执对不上")
ha = {str(c.get("name")): c.get("header") for c in pa[name].get("columns") or [] if isinstance(c, dict)}
pairs = []
for c in t.get("columns") or []:
if not isinstance(c, dict):
continue
old, new = ha.get(str(c.get("name"))), c.get("header")
if old is not None and new is not None and str(old) != str(new):
pairs.append((str(old), str(new)))
if pairs:
shown = ";".join(f"「{short(x)}」→「{short(y)}」" for x, y in pairs[:3]) + ("等" if len(pairs) > 3 else "")
out.append(_item("header_writing", f"表「{name}」的表头写法变了:{shown}",
f"共 {len(pairs)} 列;列名和导入的值不变", key=blocks[name]))
return out
def _diff_canon(p_rec: dict[str, Any], c_rec: dict[str, Any]) -> list[DiffItem]:
"""列表的主键列、canonical 存储的 TEXT 列出现新的「原文 ≠ 规范写法」对(来自标签的值不在 canonicalized 里)。"""
def pairs(rec: dict[str, Any]) -> list[tuple[str, str, str, str]]:
return [(str(e.get("table")), str(e.get("column")), str(e.get("raw")), str(e.get("canonical")))
for e in rec.get("canonicalized") or [] if isinstance(e, dict)]
seen = set(pairs(p_rec))
grouped: dict[tuple[str, str], list[tuple[str, str]]] = {}
for t, c, raw, can in pairs(c_rec):
if (t, c, raw, can) not in seen:
grouped.setdefault((t, c), []).append((raw, can))
out = []
for (t, c), new in grouped.items():
shown = ";".join(f"「{short(r)}」按「{short(k)}」保存" for r, k in new[:3]) + ("等" if len(new) > 3 else "")
out.append(_item("canon_values", f"表「{t}」列「{c}」出现新的写法:{shown}",
f"共 {len(new)} 种", key=f"{t}.{c}"))
return out
def _sheet_ids(rec: dict[str, Any]) -> dict[str, str]:
"""实际工作表名 → 配方工作表 id:工作表改了名,块的先后照样能和上一期对上。"""
return {str(v): str(k) for k, v in ((rec.get("sheets") or {}).get("matched") or {}).items()}
def _diff_block_order(p_rec: dict[str, Any], c_rec: dict[str, Any]) -> list[DiffItem]:
a = {str(k): [str(x) for x in v or []] for k, v in (p_rec.get("block_order") or {}).items()}
b = {str(k): [str(x) for x in v or []] for k, v in (c_rec.get("block_order") or {}).items()}
ida, idb = _sheet_ids(p_rec), _sheet_ids(c_rec)
by_id_a = {ida[k]: v for k, v in a.items() if k in ida}
out = []
for sheet, seq_b in b.items():
seq_a = a.get(sheet)
if seq_a is None and sheet in idb:
seq_a = by_id_a.get(idb[sheet])
if seq_a is None:
continue
common = set(seq_a) & set(seq_b)
fa = [k for k in seq_a if k in common]
fb = [k for k in seq_b if k in common]
if fa != fb:
out.append(_item("block_order", f"工作表「{sheet}」上各部分的先后变了",
f"上一期:{'、'.join(fa)};本期:{'、'.join(fb)}", key=sheet))
return out
def _diff_forms(p_rec: dict[str, Any], c_rec: dict[str, Any]) -> list[DiffItem]:
out = []
a = {str(k): str(v) for k, v in (p_rec.get("derived_form") or {}).items()}
for seg, form in ((c_rec.get("derived_form") or {}).items()):
seg, form = str(seg), str(form)
if seg in a and a[seg] != form:
out.append(_item("derived_form",
f"分段「{seg}」的合计由{_DERIVED_FORM.get(a[seg], a[seg])}改为"
f"{_DERIVED_FORM.get(form, form)}",
"合计仍按明细核对", key=seg))
def axes(rec: dict[str, Any]) -> dict[str, str]:
return {str(x.get("block")): str(x.get("form") or "") for x in rec.get("axes") or []
if isinstance(x, dict) and x.get("block") is not None}
aa, ab = axes(p_rec), axes(c_rec)
for blk, form in ab.items():
if blk in aa and aa[blk] != form:
out.append(_item("axis_form",
f"「{blk}」的日期表头由{_AXIS_FORM.get(aa[blk], aa[blk])}改为{_AXIS_FORM.get(form, form)}",
key=blk))
return out
def _diff_outside(prev: dict[str, Any], cur: dict[str, Any], p_rec: dict[str, Any],
c_rec: dict[str, Any]) -> list[DiffItem]:
"""区域外文字(含带附加文字的统计期格)的增、删、改,以及「区域外纯文字变成统计期来源」(D17、D27)。
顺序:先按全文配对(同格的先配;只是位置变了的出 info);再看同一格从纯文字变成统计期来源、满足条件的
归入 context_source_added;剩下同一格的按数字遮盖后的模板比(只是日期变了不算改);其余算增、删。
"""
p_out, c_out = outside_of(p_rec), outside_of(c_rec)
p_per, c_per = period_texts(prev, p_rec), period_texts(cur, c_rec)
c_ann = period_annotated(cur, c_rec)
c1_ok = check_status(cur, "C1") == "passed"
info: list[DiffItem] = []
confirm: list[DiffItem] = []
def take(pool: list[dict[str, Any]], pred: Any) -> dict[str, Any] | None:
for i, x in enumerate(pool):
if pred(x):
return pool.pop(i)
return None
# 1. 全文配对
p_left, c_left = list(p_out), []
for c in c_out:
if take(p_left, lambda p, c=c: p["coord"] == c["coord"] and canon(p["text"]) == canon(c["text"])) is None:
c_left.append(c)
rest = []
for c in c_left:
p = take(p_left, lambda p, c=c: canon(p["text"]) == canon(c["text"]))
if p is None:
rest.append(c)
else:
info.append(_item("outside_moved", f"区域外文字「{short(c['text'])}」从 {p['coord']} 移到 {c['coord']}"))
c_left = rest
# 2. 纯文字变成统计期来源
for p in list(p_left):
if p["kind"] != "text" or p["coord"] not in c_per:
continue
c = next((x for x in c_left if x["coord"] == p["coord"]), None)
# 本期这一格还在区域外文字里,按契约就是带附加文字的统计期格(period_source 漏标也一样)
annotated = p["coord"] in c_ann or c is not None
residue = c_ann.get(p["coord"])
if annotated and residue is None and c is not None:
residue = period_residue(c["text"])
if not c1_ok or (annotated and (residue is None or match_key(residue) != match_key(p["text"]))):
continue
p_left.remove(p)
if c is not None:
c_left.remove(c)
new_text = c_per[p["coord"]]
info.append(_item("context_source_added",
f"{p['coord']} 现在也写明了统计期:「{short(p['text'])}」→「{short(new_text)}」",
"统计期与其他来源一致;附加的文字与上一期这一格相同"))
# 3. 同一格:写法(数字遮盖后的模板)变了才算改。两期都是统计期来源的格,还要比去掉统计期之后的文字
# (其余数字照留):附加文字里的口径数字变了也算改(AU-3)
def changed(cell: str, old: str, new: str) -> None:
if cell in p_per and cell in c_per:
differ = not same_period_sentence(old, new)
else:
differ = digit_template(old) != digit_template(new)
if differ:
confirm.append(_item("outside_changed", f"{cell} 的区域外文字改了:「{short(old)}」→「{short(new)}」",
f"上一期:{old};本期:{new}", key=cell))
for p in list(p_left):
c = take(c_left, lambda x, p=p: x["coord"] == p["coord"])
if c is not None:
p_left.remove(p)
changed(p["coord"], p["text"], c["text"])
elif p["coord"] in c_per:
# 这一格本期只是在说统计期了,但不满足 context_source_added 的条件
p_left.remove(p)
changed(p["coord"], p["text"], c_per[p["coord"]])
for c in list(c_left):
if c["coord"] in p_per:
# 上一期只是统计期格,本期多了附加文字
c_left.remove(c)
changed(c["coord"], p_per[c["coord"]], c["text"])
# 4. 其余是增、删
for p in p_left:
confirm.append(_item("outside_removed", f"区域外文字「{short(p['text'])}」本期没有了(上一期在 {p['coord']})",
p["text"], key=p["coord"]))
for c in c_left:
confirm.append(_item("outside_added", f"区域外新增文字「{short(c['text'])}」({c['coord']})",
c["text"], key=c["coord"]))
return confirm + info
def _diff_ignored(p_rec: dict[str, Any], c_rec: dict[str, Any]) -> list[DiffItem]:
a = {str(k): [str(x) for x in v or []] for k, v in (p_rec.get("ignored_columns") or {}).items()}
b = {str(k): [str(x) for x in v or []] for k, v in (c_rec.get("ignored_columns") or {}).items()}
out = []
for blk in dict.fromkeys([*b, *a]):
ka = {match_key(x) for x in a.get(blk, [])}
kb = {match_key(x) for x in b.get(blk, [])}
if ka != kb:
now = "".join(f"「{short(x, 20)}」" for x in b.get(blk, [])) or "无"
before = "".join(f"「{short(x, 20)}」" for x in a.get(blk, [])) or "无"
out.append(_item("ignored_columns", f"「{blk}」忽略的列变了:本期忽略{now}",
f"上一期忽略{before}", key=blk))
return out
def _hidden_parts(h: dict[str, Any]) -> tuple[set[int] | None, set[str] | None]:
rows = cols = None
if str(h.get("policy_rows") or "") in ("include", "exclude"):
rows = {int(r) for r in h.get("rows") or [] if isinstance(r, int) or str(r).isdigit()}
if str(h.get("policy_cols") or "") == "include":
cols = {col_letter(c) if isinstance(c, int) else str(c).upper() for c in h.get("cols") or []}
return rows, cols
def _diff_hidden(p_rec: dict[str, Any], c_rec: dict[str, Any]) -> list[DiffItem]:
"""照常导入(include)或排除(exclude)隐藏行列时,隐藏的是哪些行列变了。
规格只写了 include;exclude 下被排除的行变了同样会静默改变导入的数据,一并报(见交付说明)。
reject_if_any 时有隐藏行列就拒收,不会走到这里。
"""
a = {str(k): v for k, v in (p_rec.get("hidden") or {}).items() if isinstance(v, dict)}
b = {str(k): v for k, v in (c_rec.get("hidden") or {}).items() if isinstance(v, dict)}
out = []
for sheet in dict.fromkeys([*b, *a]):
rb, cb = _hidden_parts(b.get(sheet, {}))
ra, ca = _hidden_parts(a.get(sheet, {}))
if rb is None and cb is None and ra is None and ca is None:
continue
parts = []
if (ra or set()) != (rb or set()) and (ra is not None or rb is not None):
parts.append("隐藏行:" + (rows_text(rb) or "本期没有"))
if (ca or set()) != (cb or set()) and (ca is not None or cb is not None):
parts.append("隐藏列:" + ("、".join(sorted(cb or ())) + " 列" if cb else "本期没有"))
if parts:
out.append(_item("hidden", f"工作表「{sheet}」的隐藏行列变了:" + ";".join(parts), key=sheet))
return out