Files
AgentLab/backend/app/data/recipe_confirm.py
T
ThearchyHelios 9e1ff11278 交叉表支持按文字导入的指标(如「高峰时段」一行写着「18-19」),堵上占位符陷阱:配方的 measures 分段加 text_measures(标签 → text / hour_range),落成文字列(时段存规范写法),空格、公式、占位符照块的取值规则,数字、日期、解析不了的时段报 value_not_text / value_not_hour_range;规则起草遇到无标题指标块里整行是文字的行(标签不带词表单位)按文字指标起草,并出卡片和问题要人确认(「不是」就去掉,回到按数字导入、试运行拒收),确认清单加 text_measure 项,前后对照报类型和存法变化;数值区不合格的格一行合成一条起草失败(不再一行 30 条)。陷阱:以前照「请添加占位符」把「18-19」加成占位符,整格静默存成空值、干跑照样通过——静态校验拒收含数字的占位符(placeholder_digits,重放和沿用现行配方的放行),value_not_number 的下一步按格子情况写:整行文字的提示改「指标类型」并给修复按钮 declare_text_measure,含数字的不再提示加占位符;标签带单位的拒按文字导入(text_measure_unit),文字列不能写单位(unit_on_text)。配方面板每个指标多一个「指标类型」下拉(数字 / 时段 / 文字)。默认值不进规范形式,已存配方的哈希不变。测试 tests/test_recipe_text_measures.py(合成夹具,假名);check-manage 加指标类型三项
2026-10-20 11:41:28 +08:00

1404 lines
83 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.5):试运行之后、提交之前必须逐项勾选的清单。
**为什么要有确认项。** 配方语言是封闭的,但仍有不少取值是「放宽」:占位符存空值、千分位文本按数字、公式按
保存值、跳过空行、忽略多出的列、不核对文件名里的日期……每一项都可能让本该拒收的文件被导入。原则(评审甲-5):
配方里每个放宽取值,**只要配置了**就出确认项,计数类写上本期个数;配方与现行的不同时(首次、切换、改配方后
的第一次提交,或配方哈希变了)列出全部配方类;每期都要重新看的(含数字的区域外文字、空行之后的文字、
统计期人工录入、工作表改名……)每次都按本期情况算;差异卡里需确认的项原样收进来。
提交时服务端按存下的试运行重算一遍必勾集合(同一个函数),缺一项就 422 confirm_required;界面不提供全选。
id 里的键一律按契约「键的格式」:分段、块用 id,工作表用实际工作表名(sheet_renamed 用配方里的工作表 id),
格子用「工作表!A1」。
纯函数,不碰数据库。输入的 Extraction、核对结果、差异卡收 dataclass,也收 JSON 形状的 dict(暂存区里存的
就是 asdict 之后的样子);配方收 Recipe,也收 dict(canonical 形式也行,按模型补全默认值)。
**输入不全就报错,不按空处理**:少一个键、少一个参数,对应的必勾项就会静默消失(extraction 没有 problems,
rows_after_stop 就没了;上传新一期漏传现行配方,破坏性变更和单位变化就没了),而提交时重算用的是同一个
函数,服务端也发现不了。所以 kind 只认四种;reupload、redraft 必须给现行配方和上一期回执;extraction
必须带齐 EXTRACTION_KEYS、不能是部分干跑;checks 必须给;有上一期时差异卡必须给(可以是空列表)。
期 3(P3-SPEC 6.2、9.5):
- **只列有变化的配方类确认项**:首次、切换、采用「按规则重新起草」(recipe_origin=rules_redraft)仍列全部;其余
上传新一期、修改配方只列 recipe_item_signatures 与现行配方不同的项。没变的项在现行配方提交时已经由人确认过
(TableRecipe.confirmations),重复勾选只会让人习惯性地全勾。例外:按本期数据才出、或内容随数据变的几项
(忽略的列、列表的公式按保存值、年份取自统计期、隐藏行列、忽略规则命中的行),实际内容与上一期回执不同也照列
(_data_signatures):配方变了时差异卡不再逐项比较,只看签名的话,上次没出、从没人确认过的这几项就漏了;
- 新确认项:修改记录(fix:*、select:*)、redraft_adopted、按期累积的计划(period_replace、accumulate_restart、
retire、mode_switch、accumulate_unit_risk)、忽略规则(ignore_rows、ignore_outside)。ConfirmItem.source 标出
来源(recipe / edit / accumulate / outside / sheet / context / diff / switch),界面按它分组。
"""
from __future__ import annotations
import json
import re
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from app.data.names import canon, to_sql_name
from app.data.recipe_diff import (
ReceiptIncomplete, col_letter, outside_of, period_annotated, period_of, period_texts, plain, receipt_of,
require_receipt, rows_text, same_period_sentence, short, split_coord,
)
from app.data.recipe_parsers import UNITS, match_key, period_residue, split_unit_suffix
from app.data.recipe_types import (
LIST_PERIOD_END, LIST_PERIOD_START, SUBTOTAL_KEY, ConfirmItem, CrosstabBlock, DerivedSegment, Dismissed, ListBlock, MeasuresSegment, NotComparable, Recipe,
SheetRecipe, SumEq, derive_tables,
)
_AXIS_CHECK = {"contiguous": "逐日连续", "covers_context": "恰好覆盖统计期"}
_TABLE_KIND = {"data": "明细数据", "reported_total": "原表合计"}
#: 统计期上下文的 id(契约 ContextSpec.id 只能是它)。确认项 id「context_human:统计期」由它拼出来
_PERIOD_ID = "统计期"
#: ConfirmContext.kind 的取值(P2-SPEC 7.2、7.3)
KINDS: tuple[str, ...] = ("first", "reupload", "redraft", "switch")
#: confirm_items 读到的 Extraction 字段:缺一个,对应的确认项就会静默消失或写错个数
EXTRACTION_KEYS: tuple[str, ...] = (
"problems", "outside_text", "ignored_columns", "sheets", "period", "placeholders", "formula_cells_accepted",
"blank_rows_skipped", "labels", "derived", "axes", "hidden", "lineage",
)
#: 有上一期时,本期类确认项读上一期回执里的这几个键(含数字文字的免确认、新增工作表)
_PREV_KEYS: tuple[str, ...] = ("outside_text", "sheets")
@dataclass
class ConfirmContext:
"""confirm_items 的输入(字段顺序同 P2-SPEC 7.5)。"""
#: first / reupload / redraft / switch
kind: str
#: 本次要提交的工作配方
recipe: Recipe | dict[str, Any]
#: 现行配方;首次、切换时为 None,reupload、redraft 必须给(漏传就报错:否则破坏性变更、单位变化会静默消失)
old_recipe: Recipe | dict[str, Any] | None = None
#: 切换时旧快照(简单导入)的 schema_cache,用来列出将消失或改变的表和列
old_schema_cache: dict[str, Any] | None = None
#: 本次试运行的 Extraction(或它的 asdict),必须带齐 EXTRACTION_KEYS;默认值只为保持字段顺序,不传会报错
extraction: Any = None
#: 本次的核对结果(CheckResult 或 dict);必须给(没有核对也传空列表)
checks: list[Any] | None = None
#: 本次的差异卡(DiffItem 或 dict);有上一期时必须给(没有差异也传空列表)
diff: list[Any] | None = None
#: 上一期的导入回执(diff_reports 的 prev 形状);首次为 None,reupload、redraft 必须给
prev: dict[str, Any] | None = None
#: rules / ai / manual / mixed。AI 起草后又人手改过的,调用方要传 mixed(暂存区里此时记的是 manual)
recipe_origin: str = "rules"
#: 系统发现(DraftFacts 或 dict),只用来给 dismissed 的标题写上那条关系的原话;不给也行
facts: Any = None
#: 数据源现在设的遮罩列(options.mask_columns)。切换、改配方之后列名变了,遮罩按列名匹配会静默失效(AU-6)
mask_columns: list[str] | None = None
#: 期 3:累积计划(AccumulatePlan 或它的 asdict)。新配方 mode=accumulate、或者现行是累积时由试运行给出;
#: period_replace、accumulate_restart、retire、mode_switch、accumulate_unit_risk 由它出
accumulate: Any = None
#: 期 3:暂存区里已应用的修改(StagingOut.edits 的形状),每条出 fix:<key> 或 select:<key>。调用方只传未被
#: 覆盖的;superseded 为真的这里也会跳过(PUT 之后它们的效果可能已经不在新配方里)
edits: list[dict[str, Any]] | None = None
#: 期 3:recipe_origin=rules_redraft 时 compare_recipes(现行, 采用的配方) 的结果,redraft_adopted 据此逐条列出
#: 变化;不给时按 old_recipe 现算
redraft_compare: dict[str, Any] | None = None
#: 期 3:区域外文字的差异项(accumulate_unit_risk 用);不给时从 diff 里取 outside_* 和统计期写法的项。
#: diff_reports 收到追加或替换该期的累积计划时,配方变了也比区域外文字,所以通常不用另给;另算时用
#: recipe_diff.outside_items(上一期, 本期),不能传空列表顶替(传了就只看它,单位变化会静默消失)
outside_diff: list[Any] | None = None
@dataclass(frozen=True)
class TableChange:
"""新旧配方之间一张表的一处破坏性变化。kind:table_removed / column_removed / type / unit / grain /
table_kind / placeholder_meaning / const_value / source / store。"""
table: str
kind: str
message: str
column: str | None = None
old: Any = None
new: Any = None
def as_recipe(data: Recipe | dict[str, Any]) -> Recipe:
return data if isinstance(data, Recipe) else Recipe.model_validate(data)
def recipe_canonical(recipe: Recipe) -> dict[str, Any]:
"""与 recipe.canonical_recipe 同一种写法(去掉等于默认值的字段):两份配方这个相等,哈希就相等。"""
return recipe.model_dump(mode="json", exclude_defaults=True)
def recipe_differs(kind: str, recipe: Recipe | dict[str, Any], old: Recipe | dict[str, Any] | None) -> bool:
"""「配方与现行的不同」:首次、切换,或者与现行配方的 canonical 形式不同。"""
if kind in ("first", "switch") or old is None:
return True
return recipe_canonical(as_recipe(recipe)) != recipe_canonical(as_recipe(old))
# --------------------------------------------------------------------------
# 破坏性变更
# --------------------------------------------------------------------------
def _declared_types(recipe: Recipe, tables: dict[str, list[Any]]) -> dict[tuple[str, str], str]:
"""(表, 列) → 配方声明的类型。DATE 存成 TEXT,只看 derive_tables 的 SQL 类型就分不出 TEXT ↔ DATE。"""
out = {(t, c.name): ("DATE" if c.role == "axis" else c.type) for t, cols in tables.items() for c in cols}
for sheet in recipe.sheets:
for block in sheet.blocks:
if isinstance(block, ListBlock):
for col in block.columns:
out[(block.table, col.name)] = col.type
return out
def _placeholder_meanings(recipe: Recipe) -> dict[str, dict[str, str]]:
"""表 → {占位符(canon): 含义}。占位符按块配置,作用于这个块写入的所有表。"""
out: dict[str, dict[str, str]] = {}
for sheet in recipe.sheets:
for block in sheet.blocks:
meanings = {canon(p.text): p.meaning for p in block.values.placeholders}
if not meanings:
continue
if isinstance(block, CrosstabBlock):
tables = [s.table for s in block.segments if not isinstance(s, DerivedSegment)]
tables += [s.keep_as.table for s in block.segments if isinstance(s, DerivedSegment) and s.keep_as]
else:
tables = [block.table]
if block.rows.total_row is not None and block.rows.total_row.keep_as:
tables.append(block.rows.total_row.keep_as)
for t in tables:
for text, meaning in meanings.items():
out.setdefault(t, {}).setdefault(text, meaning)
return out
def _unit_name(unit: str | None) -> str:
return unit or "未标注"
def _const_values(recipe: Recipe) -> dict[tuple[str, str, str], str]:
"""(表, 分段 id, 常量列) → pick。常量列的值整列取自 pick:pick 换了,这一列已有的值就换了。"""
out: dict[tuple[str, str, str], str] = {}
for sheet in recipe.sheets:
for block in sheet.blocks:
if not isinstance(block, CrosstabBlock):
continue
for seg in block.segments:
for col, const in (getattr(seg, "const", None) or {}).items():
out[(seg.table, seg.id, col)] = const.pick
return out
def _const_sets(recipe: Recipe) -> dict[tuple[str, str], set[str]]:
"""(表, 常量列) → 所有分段的 pick。不按分段 id 配对:id 和 pick 一起改(把新文件的规则草稿粘进来时,
规则起草把分段 id 设成 pick 本身)时按 id 配不上对,取值换了也比不到(AU-2)。"""
out: dict[tuple[str, str], set[str]] = {}
for (table, _seg, col), pick in _const_values(recipe).items():
out.setdefault((table, col), set()).add(pick)
return out
def _column_sources(recipe: Recipe) -> dict[tuple[str, str], str]:
"""(表, 列) → 这一列取自原表的哪个标签(交叉表 measures)或哪个表头(列表)。"""
out: dict[tuple[str, str], str] = {}
for sheet in recipe.sheets:
for block in sheet.blocks:
if isinstance(block, CrosstabBlock):
for seg in block.segments:
if isinstance(seg, MeasuresSegment):
for label in seg.labels.expect:
col = seg.measures.get(label)
if col:
out[(seg.table, col)] = label
else:
for c in block.columns:
out[(block.table, c.name)] = c.header
return out
def _text_stores(recipe: Recipe) -> dict[tuple[str, str], tuple[str, bool]]:
"""(表, 列表的 TEXT 列) → (实际生效的存法, 是否显式写了)。缺省时主键列 canonical、其余 raw。
交叉表按文字导入的指标也算:按时段解析的存规范写法,按文字的存原文,都是显式写的。"""
grains = {t.name: set(t.grain) for t in recipe.tables}
out: dict[tuple[str, str], tuple[str, bool]] = {}
for sheet in recipe.sheets:
for block in sheet.blocks:
if isinstance(block, ListBlock):
for c in block.columns:
if c.type == "TEXT":
eff = c.store or ("canonical" if c.name in grains.get(block.table, set()) else "raw")
out[(block.table, c.name)] = (eff, c.store is not None)
continue
for seg in block.segments:
if isinstance(seg, MeasuresSegment):
for label, tm in seg.text_measures.items():
if seg.measures.get(label):
out[(seg.table, seg.measures[label])] = (
"canonical" if tm.parser == "hour_range" else "raw", True)
return out
_STORE_NAME = {"canonical": "规范写法", "raw": "原文"}
def table_changes(old_recipe: Recipe | dict[str, Any], new_recipe: Recipe | dict[str, Any]) -> list[TableChange]:
"""新旧配方逐表比较(P2-SPEC 7.5)。新增表、新增列不算;比较范围:表删除或改名、列删除或改名、
类型(含 TEXT ↔ DATE 的写法)、单位、grain、kind、值列占位符的含义、常量列的取值,以及每列取自原表的
哪个标签或表头、列表文字列的存法(列名类型都没变、内容却整列换了的几种,AU-2)。
期 2 是替换模式,这些变化会让已启用的工作流和报告的口径静默改变;期 3 按期累积时,同一列前后各期单位
不一致是灾难性的。表改名和删除分不开(配方里没有改名记录),一律报「删除或改名」。
"""
old, new = as_recipe(old_recipe), as_recipe(new_recipe)
ot, _ = derive_tables(old)
nt, _ = derive_tables(new)
ospec = {t.name: t for t in old.tables}
nspec = {t.name: t for t in new.tables}
otypes, ntypes = _declared_types(old, ot), _declared_types(new, nt)
oph, nph = _placeholder_meanings(old), _placeholder_meanings(new)
oconst, nconst = _const_values(old), _const_values(new)
osets, nsets = _const_sets(old), _const_sets(new)
osrc, nsrc = _column_sources(old), _column_sources(new)
ostore, nstore = _text_stores(old), _text_stores(new)
out: list[TableChange] = []
for t, ocols in ot.items():
if t not in nt:
out.append(TableChange(t, "table_removed", f"表「{t}」不再产出(删除或改名)"))
continue
ncols = {c.name: c for c in nt[t]}
for oc in ocols:
nc = ncols.get(oc.name)
if nc is None:
out.append(TableChange(t, "column_removed", f"删除或改名了列「{oc.name}」", column=oc.name))
continue
ta, tb = otypes.get((t, oc.name), oc.type), ntypes.get((t, nc.name), nc.type)
if ta != tb:
if {ta, tb} == {"TEXT", "DATE"}:
msg = f"列「{oc.name}」的写法 {ta} → {tb}(日期存为 YYYY-MM-DD 文本)"
else:
msg = f"列「{oc.name}」类型 {ta} → {tb}"
out.append(TableChange(t, "type", msg, column=oc.name, old=ta, new=tb))
if (oc.unit or None) != (nc.unit or None):
out.append(TableChange(t, "unit", f"列「{oc.name}」的单位 {_unit_name(oc.unit)} → {_unit_name(nc.unit)}",
column=oc.name, old=oc.unit, new=nc.unit))
# 列名、类型、单位都没变,取自原表的标签或表头换了(两列对调、改成另一行):这一列的内容整列换了(AU-2)
sa, sb = osrc.get((t, oc.name)), nsrc.get((t, oc.name))
if sa is not None and sb is not None and match_key(sa) != match_key(sb):
out.append(TableChange(t, "source", f"列「{oc.name}」的来源「{sa}」→「{sb}」",
column=oc.name, old=sa, new=sb))
# 文字的存法(规范写法 / 原文)换了:按这一列分组、筛选的结果会变。两边都是缺省、只因主键变了而变的,
# 已由 grain 项报出
ea, eb = ostore.get((t, oc.name)), nstore.get((t, oc.name))
if ea is not None and eb is not None and ea[0] != eb[0] and (ea[1] or eb[1]):
out.append(TableChange(t, "store", f"列「{oc.name}」的文字存法 {_STORE_NAME[ea[0]]} → {_STORE_NAME[eb[0]]}",
column=oc.name, old=ea[0], new=eb[0]))
os_, ns_ = ospec.get(t), nspec.get(t)
if os_ is not None and ns_ is not None:
if set(os_.grain) != set(ns_.grain):
out.append(TableChange(t, "grain", f"主键 {'、'.join(os_.grain) or '无'} → {'、'.join(ns_.grain) or '无'}",
old=list(os_.grain), new=list(ns_.grain)))
if os_.kind != ns_.kind:
out.append(TableChange(t, "table_kind",
f"表的种类 {_TABLE_KIND[os_.kind]} → {_TABLE_KIND[ns_.kind]}",
old=os_.kind, new=ns_.kind))
pa, pb = oph.get(t, {}), nph.get(t, {})
for text in pa:
if text in pb and pa[text] != pb[text]:
out.append(TableChange(t, "placeholder_meaning", f"值列的占位符「{text}」含义 {pa[text]} → {pb[text]}",
old=pa[text], new=pb[text]))
# 常量列的取值换了(分段标题改了写法,常量只能从新标题的候选词里另选,D09):列名、类型都没变,
# 但这一列的值整列变了,按它筛选、分组的查询和报告会静默变口径
reported: set[str] = set()
for (table, seg, col), pick in oconst.items():
new_pick = nconst.get((table, seg, col))
if table == t and new_pick is not None and new_pick != pick:
out.append(TableChange(t, "const_value", f"分段「{seg}」的列「{col}」取值「{pick}」→「{new_pick}」",
column=col, old=pick, new=new_pick))
reported.add(col)
# 按 (表, 列) 比全部取值:分段 id 跟着 pick 一起改时上面配不上对(AU-2)
for (table, col), picks in osets.items():
new_picks = nsets.get((table, col))
if table == t and col not in reported and new_picks is not None and new_picks != picks:
out.append(TableChange(t, "const_value",
f"列「{col}」的取值「{'、'.join(sorted(picks))}」→「{'、'.join(sorted(new_picks))}」",
column=col, old=sorted(picks), new=sorted(new_picks)))
return out
def breaking_changes(old_recipe: Recipe | dict[str, Any], new_recipe: Recipe | dict[str, Any]) -> dict[str, list[str]]:
"""表名 → 破坏性变化的说明(含单位变化;确认项里单位另出 unit_changed:*,不在 breaking:* 里重复)。"""
out: dict[str, list[str]] = {}
for ch in table_changes(old_recipe, new_recipe):
out.setdefault(ch.table, []).append(ch.message)
return out
def _affinity(sql_type: str) -> str:
"""手工源、期 1 上传源的列类型写法 → INTEGER / REAL / TEXT(SQLite 的类型亲和规则,够比较用)。"""
t = sql_type.upper()
if "INT" in t:
return "INTEGER"
if any(k in t for k in ("CHAR", "CLOB", "TEXT")):
return "TEXT"
if any(k in t for k in ("REAL", "FLOA", "DOUB")):
return "REAL"
return t
def switch_changes(old_schema_cache: dict[str, Any] | None, new_recipe: Recipe | dict[str, Any]) -> list[str]:
"""从简单导入切换为按配方导入时,旧快照里哪些表和列会消失或改变(逐表一句)。"""
nt, _ = derive_tables(as_recipe(new_recipe))
lines = []
for name, meta in ((old_schema_cache or {}).get("tables") or {}).items():
if name not in nt:
lines.append(f"表「{name}」将消失")
continue
ncols = {c.name: c for c in nt[name]}
parts = []
for col in (meta or {}).get("columns") or []:
cname = str(col.get("name"))
otype = str(col.get("type") or "")
if cname not in ncols:
parts.append(f"列「{cname}」将消失")
elif otype and _affinity(otype) != ncols[cname].type:
parts.append(f"列「{cname}」类型 {_affinity(otype)} → {ncols[cname].type}")
if parts:
lines.append(f"表「{name}」:" + "、".join(parts))
return lines
# --------------------------------------------------------------------------
# 确认项
# --------------------------------------------------------------------------
class _Bag:
"""按出现顺序收集,id 重复的只留第一条。keep 不为 None 时只收 keep(id) 为真的(只列有变化的配方类项)。"""
def __init__(self, keep: Callable[[str], bool] | None = None) -> None:
self.items: list[ConfirmItem] = []
self._ids: set[str] = set()
self.keep = keep
def add(self, item_id: str, label: str, detail: str = "", source: str = "recipe") -> None:
if item_id in self._ids or (self.keep is not None and not self.keep(item_id)):
return
self._ids.add(item_id)
self.items.append(ConfirmItem(id=item_id, label=label, detail=detail, required=True, source=source))
def into(self, other: "_Bag") -> None:
for i in self.items:
other.add(i.id, i.label, i.detail, i.source)
def _validate(ctx: ConfirmContext) -> dict[str, Any]:
"""输入不全就报错(见模块 docstring),返回 extraction 的 dict 形状。"""
if ctx.kind not in KINDS:
raise ValueError(f"未知的导入方式:{ctx.kind}")
if ctx.kind in ("reupload", "redraft"):
if ctx.old_recipe is None:
raise ValueError(f"{ctx.kind} 必须给现行配方,否则破坏性变更和单位变化无从比较")
if ctx.prev is None:
raise ValueError(f"{ctx.kind} 必须给上一期的导入回执")
if ctx.checks is None:
raise ValueError("必须给本次的核对结果(没有核对时传空列表)")
if ctx.prev is not None and ctx.diff is None:
raise ValueError("有上一期时必须给差异卡(没有差异时传空列表)")
ex = plain(ctx.extraction)
if not isinstance(ex, dict):
raise ReceiptIncomplete("extraction", ["extraction"])
missing = [k for k in EXTRACTION_KEYS if k not in ex]
if ex.get("partial"):
# 部分干跑只看了前若干行,区域外文字、空行之后的文字都不全,不能拿来定必勾集合
missing.append("partial")
if missing:
raise ReceiptIncomplete("extraction", missing)
prev = plain(ctx.prev) if ctx.prev is not None else None
if prev is not None and "ledger" in receipt_of(prev):
require_receipt(prev, "prev", _PREV_KEYS, top=())
return ex
def confirm_items(ctx: ConfirmContext) -> list[ConfirmItem]:
"""生成必勾的确认项(P2-SPEC 7.5、P3-SPEC 6.2、9.5)。顺序:单位变化(排最前)、其余破坏性变更、切换、
按期累积(重新开始、切换模式、退役、替换该期、单位风险)、采用重新起草、配方类、修改记录、AI 类、本期类、差异卡。
配方类:首次、切换、采用按规则重新起草时列全部;其余只列签名与现行配方不同的(recipe_item_signatures)。
输入不全时抛 ValueError(缺键时是它的子类 ReceiptIncomplete),见模块 docstring。
"""
ex = _validate(ctx)
recipe = as_recipe(ctx.recipe)
old = as_recipe(ctx.old_recipe) if ctx.old_recipe is not None else None
plan = _plan(ctx.accumulate)
bag = _Bag()
differs = recipe_differs(ctx.kind, recipe, old)
if differs and old is not None and ctx.kind != "switch":
changes = table_changes(old, recipe)
for ch in changes:
if ch.kind == "unit":
bag.add(f"unit_changed:{ch.table}.{ch.column}",
f"表「{ch.table}」列「{ch.column}」的单位 {_unit_name(ch.old)} → {_unit_name(ch.new)}:"
"数量级或含义可能不同,引用它的报告口径会变")
retired_tables, retired_existing = _retired_scope(plan)
grouped: dict[str, list[str]] = {}
for ch in changes:
if ch.kind == "unit":
continue
# 同一张表已有 retire:*(按期累积时退役的列和表保留在并集里),删除或改名就不在 breaking 里再列一遍;
# 早已退役的同样不列(P3-SPEC 2.5)
if ch.kind in ("column_removed", "table_removed") and (
ch.table in retired_tables or (ch.table, ch.column) in retired_existing):
continue
grouped.setdefault(ch.table, []).append(ch.message)
for table, msgs in grouped.items():
bag.add(f"breaking:{table}", f"表「{table}」:" + ";".join(msgs))
if ctx.kind == "switch":
if ctx.old_schema_cache is None:
tail = "原有的表结构未知,切换后以本次导入的表为准"
else:
lines = switch_changes(ctx.old_schema_cache, recipe)
tail = ("以下表和列将消失或改变:" + ";".join(lines)) if lines else "原有的表和列都保留"
bag.add("switch_from_simple", "从简单导入切换为按配方导入:" + tail, source="switch")
if plan is not None:
_accumulate_items(bag, plan, ctx, recipe, old)
if ctx.recipe_origin == "rules_redraft":
_redraft_item(bag, ctx, recipe, old)
if differs and ctx.mask_columns:
_mask_items(bag, ctx, recipe, old)
if differs:
# 首次、切换、采用重新起草:列全部(重新起草的分段定位、标签集合、工作表匹配名不在签名里,任何一项都
# 兜不住,评审一-M6)。其余只列新增的、签名变了的
full = ctx.kind in ("first", "switch") or ctx.recipe_origin == "rules_redraft" or old is None
sub = _Bag(None if full else _changed_items(recipe, old, ex, ctx.prev))
_recipe_items(sub, recipe, ex, ctx.facts, ai=ctx.recipe_origin in ("ai", "mixed"))
sub.into(bag)
_edit_items(bag, ctx.edits)
if differs and ctx.recipe_origin in ("ai", "mixed"):
_ai_items(bag, recipe)
_period_items(bag, recipe, ex, ctx)
for d in ctx.diff or []:
d = plain(d)
if isinstance(d, dict) and d.get("requires_confirm") and d.get("confirm_id"):
bag.add(str(d["confirm_id"]), str(d.get("label") or ""), str(d.get("detail") or ""), source="diff")
return bag.items
# --------------------------------------------------------------------------
# 期 3:只列有变化的配方类确认项(P3-SPEC 6.2)
# --------------------------------------------------------------------------
def _sig(*parts: Any) -> str:
return json.dumps(parts, ensure_ascii=False, sort_keys=True, default=str)
def _signatures(recipe: Recipe, sheet_name: Callable[[SheetRecipe], str]) -> dict[str, str]:
"""确认项 id → 签名(只看配方内容,不看本期计数)。按工作表出的 id 用 sheet_name(工作表) 拼:比较时两份配方
按工作表 id 换成同一个名字(本期实际的工作表名),所以只改了匹配名(⑨ 更新工作表名)不算变化。"""
out: dict[str, str] = {}
for sheet in recipe.sheets:
name = sheet_name(sheet)
if sheet.context:
out[f"cross_check_off:{name}"] = _sig(sheet.context[0].cross_check)
out[f"hidden:{name}"] = _sig(sheet.hidden.rows, sheet.hidden.cols)
out[f"ignore_outside:{name}"] = _sig(sorted((match_key(r.anchor), r.reason) for r in sheet.ignore_outside))
for block in sheet.blocks:
for p in block.values.placeholders:
out.setdefault(f"placeholder:{p.text}", _sig(p.meaning))
out[f"text_number:{block.id}"] = _sig(block.values.text_number)
out[f"formula_cached:{block.id}"] = _sig(block.values.formula)
ignore_cols = sorted((match_key(r.header), r.reason) for r in block.ignore_columns)
if isinstance(block, CrosstabBlock):
out[f"blank_null:{block.id}"] = _sig(block.values.blank)
out[f"checks_relaxed:{block.id}"] = _sig(sorted(k for k in _AXIS_CHECK if k not in block.axis.checks))
out[f"year_from:{block.id}"] = _sig(block.axis.year_from)
out[f"ignored_columns:{block.id}"] = _sig(None, ignore_cols)
out[f"ignore_rows:{block.id}"] = _sig(sorted((match_key(r.label), r.reason) for r in block.ignore_rows))
for seg in block.segments:
if isinstance(seg, DerivedSegment):
keep = seg.keep_as.table if seg.keep_as else None
out[f"derived:{seg.id}"] = _sig(keep, sorted(match_key(x) for x in seg.labels.expect))
elif isinstance(seg, MeasuresSegment):
for label, tm in seg.text_measures.items():
out[f"text_measure:{seg.id}:{label}"] = _sig(tm.parser, seg.measures.get(label))
else:
out[f"merged_fill:{block.id}"] = _sig(block.merged_data)
out[f"ignored_columns:{block.id}"] = _sig(block.extra_columns, ignore_cols)
out[f"blank_skip:{block.id}"] = _sig(block.rows.blank_rows)
total = block.rows.total_row
out[f"total_row:{block.id}"] = _sig(
None if total is None else (total.label_column, total.pick, total.keep_as))
# 期 5:穿插的小计、每行属于哪一期
subt = block.rows.subtotals
out[f"subtotals:{block.id}"] = _sig(
None if subt is None else (subt.label_column, subt.pick, subt.scope, subt.group_column, subt.keep_as))
out[f"no_subtotals:{block.id}"] = _sig(block.rows.no_subtotals)
period = block.period
out[f"list_period:{block.id}"] = _sig(None if period is None else (period.column, period.synthesize))
for r in recipe.relations:
if isinstance(r, SumEq):
out[f"relation:{r.id}"] = _sig("sum_eq", r.table, r.total, sorted(r.parts))
elif isinstance(r, NotComparable):
out[f"relation:{r.id}"] = _sig("not_comparable", r.a.table, r.a.value, r.b.table, r.b.value, r.by)
elif isinstance(r, Dismissed):
out[f"dismissed:{r.claims}"] = _sig(r.reason)
tables, _ = derive_tables(recipe)
for t, cols in tables.items():
for c in cols:
out[f"unit:{t}.{c.name}"] = _sig(c.unit, c.header)
out["mode"] = _sig(recipe.mode)
return out
def recipe_item_signatures(recipe: Recipe | dict[str, Any]) -> dict[str, str]:
"""配方类确认项 id → 签名(P3-SPEC 6.2 的表)。改配方之后只列「新增的、签名变了的」配方类项。
签名只看配方内容:占位符的含义、空格与千分位与公式的处理、合并单元格、缺的日期断言、文件名核对、
忽略的列(多出的列怎么处理加按表头忽略的表头和理由)、跳过空行、合计段与合计行、年份来源、关系、不登记的理由、
单位与来源标签、隐藏行列的策略、导入模式、按行标签忽略和按同一行文字忽略的锚点加理由。
分段定位(标题、标签集合、after_title)、工作表匹配名、列的来源标签不在签名里:来自修复按钮时有 fix:*,来自
框选时有 select:*,来自手工修改时有破坏性变更和对照界面,来自按规则重新起草时按首次导入列全部。
按工作表出的 id(cross_check_off、hidden、ignore_outside)这里用配方里的工作表名拼;confirm_items 比较时
两份配方按工作表 id 换成本期实际的工作表名。
签名只是一半:confirm_items 另按本期数据和上一期回执比较几项按数据出的确认项(_data_signatures,不在这个公开
函数里,因为它要读回执)。
"""
return _signatures(as_recipe(recipe), lambda s: s.match.name)
def _changed_items(recipe: Recipe, old: Recipe, ex: dict[str, Any],
prev: dict[str, Any] | None) -> Callable[[str], bool]:
"""「这个配方类确认项要不要列出」:新配方里新增的 id,签名与现行配方不同的,或者按本期数据出的内容与上一期
不同的(_data_signatures)。没有签名定义的 id 照列(宁可多勾一项,不能漏)。工作表按 id 换成本期实际的名字,
两份配方用同一个名字比。"""
matched = (ex.get("sheets") or {}).get("matched") or {}
def name(sheet: SheetRecipe) -> str:
return str(matched.get(sheet.id) or sheet.match.name)
new, old_sig = _signatures(recipe, name), _signatures(old, name)
p_rec = receipt_of(prev) if prev is not None else {}
now, before = _data_signatures(recipe, ex), _data_signatures(recipe, p_rec)
def keep(item_id: str) -> bool:
if item_id not in new or old_sig.get(item_id) != new[item_id]:
return True
return item_id in now and before.get(item_id) != now[item_id]
return keep
def _data_signatures(recipe: Recipe, rec: dict[str, Any]) -> dict[str, str | None]:
"""按数据出的那几种配方类确认项 → 本期(或上一期回执)的实际内容(P3-SPEC 6.2 的补充,评审意见 H3)。
为什么要有:签名只看配方内容,但下面几项在期 2 里只在本期数据满足条件时才出,或者内容随数据变:
- ignored_columns:<块>:实际被忽略的表头集合(extra_columns=ignore 时本期没有多出的列就不出);
- formula_cached:<列表块>:本期有没有按保存值导入的公式格(列表的默认取值就是放宽,有公式格才出);
- year_from:<块>:日期表头的写法集合(表头都是完整日期时不出);
- hidden:<工作表>:照常导入或排除的是哪些隐藏行列;
- ignore_rows:<块> / ignore_outside:<工作表>:忽略规则命中的锚点和行数。
配方没变时这些变化由差异卡兜住(diff:ignored:、diff:axis_form:、diff:hidden:、diff:ignored_rows:),但配方变了时
差异卡只给行数和统计期(recipe_diff 的模块 docstring)。只比签名的话,同一暂存区里应用过一次修复,上一次提交时
没出、从未被人确认过的这几项就永远不用确认了。所以配方变了时,这几项的实际内容和上一期回执(ctx.prev,现行
导入的导入清单)不同也照列。
回执里缺对应字段(期 3 之前的导入没有 rows_excluded、手写的最小回执)时值为 None:一边 None、一边有值就算不同,
照列;两边都缺则相同。工作表按 id 换成各自回执里的实际工作表名(改了名的照样对得上)。
"""
out: dict[str, str | None] = {}
matched = (rec.get("sheets") or {}).get("matched") or {}
def name(sheet: SheetRecipe) -> str:
return str(matched.get(sheet.id) or sheet.match.name)
def has(field: str) -> bool:
return field in rec
ignored = rec.get("ignored_columns") or {}
forms: dict[str, list[str]] = {}
for a in rec.get("axes") or []:
if isinstance(a, dict):
forms.setdefault(str(a.get("block")), []).append(str(a.get("form") or ""))
excluded = [e for e in (plain(x) for x in rec.get("rows_excluded") or []) if isinstance(e, dict)]
def hits(reason: str, pred: Callable[[dict[str, Any]], bool]) -> str | None:
if not has("rows_excluded"):
return None
rows: dict[str, int] = {}
for e in excluded:
if e.get("reason") == reason and pred(e):
key = match_key(e.get("anchor") or "")
rows[key] = rows.get(key, 0) + sum(int(b) - int(a) + 1 for a, b in e.get("rows") or [])
return _sig(sorted(rows.items()))
hidden = rec.get("hidden") or {}
for sheet in recipe.sheets:
sname = name(sheet)
h = hidden.get(sname) if isinstance(hidden.get(sname), dict) else {}
out[f"hidden:{sname}"] = _sig(sorted(int(r) for r in h.get("rows") or [] if str(r).isdigit()),
sorted(str(c) for c in h.get("cols") or [])) if has("hidden") else None
out[f"ignore_outside:{sname}"] = hits("ignored_outside", lambda e, s=sname: e.get("sheet") == s)
for block in sheet.blocks:
out[f"ignored_columns:{block.id}"] = (_sig(sorted({match_key(h) for h in ignored.get(block.id) or []}))
if has("ignored_columns") else None)
if isinstance(block, CrosstabBlock):
out[f"year_from:{block.id}"] = _sig(sorted(set(forms.get(block.id, [])))) if has("axes") else None
out[f"ignore_rows:{block.id}"] = hits("ignored_rows", lambda e, b=block.id: e.get("block") == b)
else:
out[f"formula_cached:{block.id}"] = (_sig(int(rec.get("formula_cells_accepted") or 0) > 0)
if has("formula_cells_accepted") else None)
return out
# --------------------------------------------------------------------------
# 期 3:修改记录、重新起草、按期累积(P3-SPEC 9.5)
# --------------------------------------------------------------------------
def _edit_items(bag: _Bag, edits: list[dict[str, Any]] | None) -> None:
"""每条已应用、未被覆盖的修改出一项:修复 fix:<目标键>,框选 select:<as>:<块或分段 id>。文案是修改的标题,
细节是人话摘要和署名(未认证)。"""
for e in edits or []:
e = plain(e)
if not isinstance(e, dict) or e.get("superseded") or not e.get("key"):
continue
prefix = "select" if e.get("kind") == "selection" else "fix"
title = str(e.get("title") or "").strip() or ("按框选修改了配方" if prefix == "select" else "用修复按钮修改了配方")
summary = e.get("summary")
detail = ";".join(str(x) for x in summary) if isinstance(summary, list) else str(summary or "")
if e.get("signed_by"):
detail = ";".join(x for x in (detail, f"署名(未认证):{e['signed_by']}") if x)
bag.add(f"{prefix}:{e['key']}", title, detail, source="edit")
def _compare_lines(cmp: dict[str, Any]) -> list[str]:
"""compare_recipes 的结果里,分段、标签、工作表名、列来源的变化,逐条写成人话(redraft_adopted 用)。"""
lines: list[str] = []
for seg in cmp.get("segments") or []:
sid, status = seg.get("id"), seg.get("status")
title = seg.get("title") or {}
labels = seg.get("labels") or {}
if status in ("added", "removed"):
# 新增、去掉的分段写出标题和全部标签:起草器换了分段 id 时,新分段的定位规则只在这里经人确认
# (配方类确认项里没有分段的标题和标签)
new = status == "added"
head = title.get("new" if new else "old")
tags = labels.get("added" if new else "removed") or []
what = "".join([f",标题「{head}」" if head else "",
",标签" + "".join(f"「{x}」" for x in tags) if tags else ""])
lines.append(f"{'新增' if new else '去掉'}分段「{sid}」{what}")
continue
if status == "changed" and title.get("old") != title.get("new"):
lines.append(f"分段「{sid}」的标题「{title.get('old') or '无'}」→「{title.get('new') or '无'}」")
if labels.get("added"):
lines.append(f"分段「{sid}」加入标签" + "".join(f"「{x}」" for x in labels["added"]))
if labels.get("removed"):
lines.append(f"分段「{sid}」去掉标签" + "".join(f"「{x}」" for x in labels["removed"]))
ignore = seg.get("ignore") or {}
if ignore.get("added"):
lines.append(f"「{sid}」新增忽略规则:" + "、".join(str(x) for x in ignore["added"]))
if ignore.get("removed"):
lines.append(f"「{sid}」去掉忽略规则:" + "、".join(str(x) for x in ignore["removed"]))
for sh in cmp.get("sheets") or []:
nm = sh.get("name") or {}
if nm.get("old") != nm.get("new"):
lines.append(f"工作表名「{nm.get('old') or '无'}」→「{nm.get('new') or '无'}」")
for t in cmp.get("tables") or []:
for c in t.get("columns") or []:
o, n = c.get("old") or {}, c.get("new") or {}
if o.get("source") != n.get("source") and (o.get("source") or n.get("source")):
lines.append(f"表「{t.get('name')}」列「{c.get('name')}」的来源"
f"「{o.get('source') or '无'}」→「{n.get('source') or '无'}」")
return lines
def _redraft_item(bag: _Bag, ctx: ConfirmContext, recipe: Recipe, old: Recipe | None) -> None:
"""采用了按规则重新起草的配方(P3-SPEC 6.3):起草器识别出的定位规则要逐项经过人的确认,不能因为只列
有变化的配方类项就直接成为现行配方(H3)。"""
cmp = plain(ctx.redraft_compare)
if not isinstance(cmp, dict):
if old is None:
raise ValueError("采用按规则重新起草的配方时必须给现行配方或对照结果,否则无法列出变化")
from app.data.recipe_compare import compare_recipes # 循环导入:recipe_compare 用本模块的 table_changes
cmp = compare_recipes(old, recipe)
lines = _compare_lines(cmp)
head = "采用按规则重新起草的配方:"
if not lines:
bag.add("redraft_adopted", head + "分段、标签、工作表名和列的来源与现行配方相同", source="edit")
return
shown = ";".join(lines[:6]) + (f"等 {len(lines)} 处" if len(lines) > 6 else "")
bag.add("redraft_adopted", head + shown, ";".join(lines), source="edit")
def _plan(data: Any) -> dict[str, Any] | None:
plan = plain(data)
if plan is None:
return None
if not isinstance(plan, dict) or not plan.get("action"):
raise ValueError("累积计划不完整:缺少 action,无法确定要确认的项")
return plan
def _span(p: Any) -> tuple[str, str] | None:
if not isinstance(p, dict) or not p.get("start") or not p.get("end"):
return None
return str(p["start"]), str(p["end"])
def _spans(parts: list[Any]) -> str:
return "、".join(f"{s} 至 {e}" for s, e in (_span(p) for p in parts or []) if s) if parts else ""
def _blocker_text(part: dict[str, Any]) -> str:
msgs = [str(b.get("message") if isinstance(b, dict) else b) for b in part.get("blockers") or []]
return ";".join(m for m in msgs if m)
def _retired_scope(plan: dict[str, Any] | None) -> tuple[set[str], set[tuple[str, str | None]]]:
"""breaking 去重的范围:有 retire:* 的表(只在追加、替换该期时出),以及早已退役的 (表, 列)。"""
if plan is None or plan.get("mode") != "accumulate" or plan.get("action") not in ("append", "replace_period"):
return set(), set()
tables = {str(e.get("table")) for e in plan.get("retired_new") or [] if isinstance(e, dict)}
existing = {(str(e.get("table")), e.get("column")) for e in plan.get("retired_existing") or []
if isinstance(e, dict)}
return tables, existing
#: 区域外文字里提示单位或口径的词(P3-SPEC 2.5)
_SCOPE_WORDS = ("单位", "口径", "统计范围", "计量")
#: 含单位字、却不是在说单位的常见词。按规格「出现即算」,只把这些词先去掉再找单位词:单字单位(人、元、个、件、户)
#: 太容易出现在别的词里,「编制人:测试员甲」→「编制人:测试员乙」这种每期都可能变的落款不该要求确认单位。
#: 只列确定不是单位的词;拿不准的(人数、件数、户数)不列,宁可多确认一次
_UNIT_STOPWORDS = (
"编制人", "填报人", "填表人", "制表人", "负责人", "联系人", "审核人", "复核人", "审批人", "批准人", "经办人",
"统计人", "报送人", "签发人", "核对人", "人员", "人工", "单元", "元旦", "文件", "附件", "条件", "事件", "邮件",
"软件", "用户", "账户", "客户", "门户", "个别", "各个", "整个", "个人", "个月",
# 「单位」在这几个词里指填报的机构,不是计量单位
"填报单位", "编制单位", "报送单位", "制表单位", "责任单位",
)
def _unit_pattern() -> re.Pattern[str]:
"""单位词(P3-SPEC 2.5:新旧文字里出现 UNITS 词表中的词,或出现「单位」「口径」「统计范围」「计量」之一)。
出现就算,不论前后是什么:「货运量按吨统计」→「按件统计」、「每户」→「每人」、「元/件」→「元/个」都是单位真的变了。
误报靠 _UNIT_STOPWORDS 先去掉(_unit_tokens)。单位前面的数量级(万、千、百、亿)算进同一个词:「人次」→「万人次」
要能比出不同;多字的排在单字前面,「人次」不会被拆成「人」。"""
units = sorted(UNITS, key=len, reverse=True)
alt = "|".join(re.escape(u) for u in units)
scope = "|".join(map(re.escape, _SCOPE_WORDS))
return re.compile(rf"(?:{scope})|(?:[万千百亿]?(?:{alt}))")
_UNIT_RE = _unit_pattern()
#: 停用词按长度从长到短排进正则:长词先匹配,不会被其中的短词截断
_STOP_RE = re.compile("|".join(re.escape(w) for w in sorted(_UNIT_STOPWORDS, key=len, reverse=True)))
def _unit_tokens(text: str) -> list[str]:
"""文字里的单位、口径词(去掉停用词之后)。停用词换成「|」:它不是单位,也不会和前后的字拼成新词。"""
return sorted(m.group(0) for m in _UNIT_RE.finditer(_STOP_RE.sub("|", canon(text))))
#: 区域外文字的差异项,以及统计期格写法的变化(D12 那种):单位、口径的说明可能写在这些格里
_OUTSIDE_KINDS = ("outside_added", "outside_removed", "outside_changed", "context_text", "context_source_added")
def _unit_risks(ctx: ConfirmContext, recipe: Recipe, old: Recipe | None) -> list[str]:
"""按期累积时单位写在配方之外、只表现为区域外文字变化的那几种(评审一-M10)。返回涉及单位或口径的变化。"""
out: list[str] = []
source = ctx.outside_diff if ctx.outside_diff is not None else ctx.diff
for d in source or []:
d = plain(d)
if not isinstance(d, dict) or d.get("kind") not in _OUTSIDE_KINDS:
continue
# 只看差异项里的原文(detail:全文或「上一期:…;本期:…」),不看 label 里的坐标和工作表名
if _unit_tokens(str(d.get("detail") or "")):
out.append(str(d.get("label") or ""))
if old is not None:
# 分段标题(D09 那种改字):只在新旧标题里的单位、口径词不同时算。标题里常年写着「(人次)」,只改了别的字
# 就报,会让每次改标题都多一项必勾
titles = {s.id: s.locate.title for s in _segments(old) if getattr(s.locate, "title", None)}
for seg in _segments(recipe):
new_title, old_title = getattr(seg.locate, "title", None), titles.get(seg.id)
if new_title and old_title and _unit_tokens(new_title) != _unit_tokens(old_title):
out.append(f"分段「{seg.id}」的标题「{old_title}」→「{new_title}」")
return out
def _segments(recipe: Recipe) -> list[Any]:
return [s for sheet in recipe.sheets for b in sheet.blocks if isinstance(b, CrosstabBlock) for s in b.segments]
def _switch_to_accumulate_text(plan: dict[str, Any], ctx: ConfirmContext, dropped: list[dict[str, Any]]) -> str | None:
"""mode_switch:replace->accumulate 冒号后面那一句(P3-SPEC 2.4 表)。按 action 分开写,不能只看 parts:
- append:当前那一期原样保留,parts 里它是 new=False 的那一项;
- replace_period:当前那一期被本次替换(修改配方后重新导入同一个文件,或者同一统计期再传),parts 里只有本期,
被替换的那一期在 replaces。只看 parts 找不到「第一期」,早先因此落到「当前版本没有统计期」,与同一清单里的
period_replace 自相矛盾(评审意见);
- restart:配方变化不兼容;
- first:当前那一期没有统计期,或者它的配方不满足资格,只有这里才写这两种原因;
- rejected:试运行拒收(部分重叠),不能提交,不出这一项(拒收的原因在问题列表里),返回 None。
"""
action = plan.get("action")
if action == "append":
kept = next((p for p in plan.get("parts") or [] if isinstance(p, dict) and not p.get("new")), None)
sp = _span(kept)
if sp is None:
raise ValueError("累积计划是追加,但结果里没有可作为第一期保留的当前那一期")
return f"当前版本的 {sp[0]} 至 {sp[1]} 作为第一期保留,此前被替换的各期不会补回"
if action == "replace_period":
rep = plan.get("replaces") if isinstance(plan.get("replaces"), dict) else None
sp = _span(rep)
if sp is None:
raise ValueError("累积计划是替换该期,但没有给出被替换那一期的统计期")
if ctx.kind == "redraft":
return f"当前版本的 {sp[0]} 至 {sp[1]} 按修改后的配方重新导入,作为第一期,此前被替换的各期不会补回"
return f"本次导入替换当前版本的 {sp[0]} 至 {sp[1]},作为第一期,此前被替换的各期不会补回"
if action == "restart":
return "表结构有不兼容的变化,当前版本不能作为第一期:启用后当前版本只含本期"
if action == "first":
blocked = next((p for p in dropped if p.get("blockers")), None)
if blocked is not None:
return (f"当前版本的配方不满足按期累积的要求({_blocker_text(blocked)}),不能作为第一期:"
"启用后当前版本只含本期")
# 计划里当前那一期是 dropped 的最后一项(plan_accumulate 取最晚的一期判资格)
if not dropped or _span(dropped[-1]) is None:
return "当前版本没有统计期,不能作为第一期:启用后当前版本只含本期"
why = f"({plan['reason']})" if plan.get("reason") else ""
return f"当前版本不能作为第一期{why}:启用后当前版本只含本期"
return None
def _accumulate_items(bag: _Bag, plan: dict[str, Any], ctx: ConfirmContext, recipe: Recipe,
old: Recipe | None) -> None:
"""按期累积的必勾项(P3-SPEC 2.4、2.5、9.5),来源都是 accumulate。"""
action, switch = plan.get("action"), plan.get("mode_switch")
dropped = [p for p in plan.get("dropped") or [] if isinstance(p, dict)]
n = len(dropped)
before = f"此前 {n} 期" if n else "此前各期"
if action == "restart":
changes = [str(x) for x in plan.get("semantic") or []]
for p in dropped:
# 已是累积模式、某一期的配方不满足资格(blockers)也走 restart,变化一栏写明是哪一期、为什么
text, sp = _blocker_text(p), _span(p)
if text:
where = f"{sp[0]} 至 {sp[1]} 这一期" if sp else "此前的一期"
changes.append(f"{where}的配方不满足按期累积的要求:{text}")
what = "(" + ";".join(changes) + ")" if changes else ""
bag.add("accumulate_restart",
f"表结构有不兼容的变化{what}:启用后当前版本只含本期,{before}不再出现在当前版本中,之后各期在本期基础上"
"继续累积;可以在数据源卡片的「版本」中启用此前的版本",
"如果不想重新开始累积,请返回修改配方(例如保留原来的单位、列名或常量取值)", source="accumulate")
if switch == "accumulate->replace":
spans = _spans(dropped)
bag.add("mode_switch:accumulate->replace",
f"从按期累积改为每期替换:启用后当前版本只含本期,{before}" + (f"({spans})" if spans else "")
+ "不再出现在当前版本中,可以在数据源卡片的「版本」中启用此前的版本", source="accumulate")
elif switch == "replace->accumulate":
text = _switch_to_accumulate_text(plan, ctx, dropped)
if text is not None:
bag.add("mode_switch:replace->accumulate", "从每期替换改为按期累积:" + text, str(plan.get("reason") or ""),
source="accumulate")
accumulating = plan.get("mode") == "accumulate" and action in ("append", "replace_period")
if accumulating:
for e in plan.get("retired_new") or []:
if not isinstance(e, dict) or not e.get("table"):
continue
t, c = str(e["table"]), e.get("column")
if c:
bag.add(f"retire:{t}.{c}", f"表「{t}」的列「{c}」自本期起不再导入:早期各期保留原值,本期起为空值",
"如果是改名:旧列名保留早期各期的值,新列名从本期起有值,两列并存", source="accumulate")
else:
bag.add(f"retire:{t}", f"表「{t}」自本期起不再导入:早期各期的行保留,本期起没有新的行",
"如果是改名:旧表保留早期各期的行,新表从本期起有行,两张表并存", source="accumulate")
if action == "replace_period":
rep = plan.get("replaces") if isinstance(plan.get("replaces"), dict) else {}
sp = _span(rep) or _span(plan.get("period"))
if sp is None:
raise ValueError("累积计划是替换该期,但没有给出被替换那一期的统计期")
if ctx.kind == "redraft":
label = f"用修改后的配方重新导入最近一期 {sp[0]} 至 {sp[1]}:启用后该期以本次为准,其余各期不变"
else:
src = []
if rep.get("seq") is not None:
src.append(f"第 {rep['seq']} 次导入")
if rep.get("file_name"):
src.append(f"「{short(rep['file_name'])}」")
label = (f"替换已有的一期 {sp[0]} 至 {sp[1]}" + (f"({''.join(src)})" if src else "")
+ ":启用后该期以本次为准")
bag.add(f"period_replace:{sp[0]}~{sp[1]}", label, source="accumulate")
if accumulating:
risks = _unit_risks(ctx, recipe, old)
if risks:
shown = ";".join(risks[:3]) + (f"等 {len(risks)} 处" if len(risks) > 3 else "")
bag.add("accumulate_unit_risk",
f"区域外的说明文字有变化,且涉及单位或口径({shown}):确认后,本期与此前各期按同一单位累积在同一列中。"
"如果单位已经变了,请返回修改配方里的单位(这会重新开始累积)", ";".join(risks), source="accumulate")
def _mask_items(bag: _Bag, ctx: ConfirmContext, recipe: Recipe, old: Recipe | None) -> None:
"""遮罩列在新结构里找不到同名列(不区分大小写):出必勾 mask_lost:<列>(AU-6)。
遮罩按列名匹配:从简单导入切换过来,「金额(元)」的列名从期 1 的「金额_元」变成「金额」;改配方改了列名也一样。
只报这次变化弄丢的:旧结构里本来就没有这一列的遮罩(早就不生效了)不算。
"""
new_cols = {c.name.lower() for cols in derive_tables(recipe)[0].values() for c in cols}
old_cols: set[str] | None = None
if ctx.kind == "switch" and ctx.old_schema_cache is not None:
old_cols = {str(c.get("name")).lower() for meta in (ctx.old_schema_cache.get("tables") or {}).values()
for c in (meta or {}).get("columns") or [] if isinstance(c, dict)}
elif old is not None:
old_cols = {c.name.lower() for cols in derive_tables(old)[0].values() for c in cols}
for m in ctx.mask_columns or []:
key = str(m).strip()
if not key or key.lower() in new_cols or (old_cols is not None and key.lower() not in old_cols):
continue
bag.add(f"mask_lost:{key}",
f"遮罩列「{key}」在新的表结构里没有同名列:提交后这一列的遮罩不再生效,证据面板和裁判摘录会显示它的值。"
"如需继续遮罩,提交后请在数据源设置里改成新的列名",
source="switch" if ctx.kind == "switch" else "recipe")
def _sheet_name(ex: dict[str, Any], sheet: SheetRecipe) -> str:
"""配方工作表 → 本期实际的工作表名(改了名的按 fallback 认到的那张)。"""
matched = (ex.get("sheets") or {}).get("matched") or {}
return str(matched.get(sheet.id) or sheet.match.name)
def _blocks(recipe: Recipe, ex: dict[str, Any]) -> list[tuple[SheetRecipe, str, Any]]:
return [(sheet, _sheet_name(ex, sheet), block) for sheet in recipe.sheets for block in sheet.blocks]
def _count(counts: dict[str, Any], text: str) -> int:
if text in counts:
return int(counts[text] or 0)
return sum(int(v or 0) for k, v in counts.items() if canon(k) == canon(text))
def _recipe_items(bag: _Bag, recipe: Recipe, ex: dict[str, Any], facts: Any, *, ai: bool = False) -> None:
blocks = _blocks(recipe, ex)
crosstabs = [b for _, _, b in blocks if isinstance(b, CrosstabBlock)]
lists = [b for _, _, b in blocks if isinstance(b, ListBlock)]
# 占位符:配置了就出,写上本期个数(可以是 0)
counts = ex.get("placeholders") or {}
meanings: dict[str, str] = {}
for _, _, b in blocks:
for p in b.values.placeholders:
meanings.setdefault(p.text, p.meaning)
for text, meaning in meanings.items():
bag.add(f"placeholder:{text}", f"「{text}」存为空值(表示{meaning},本期 {_count(counts, text)} 格)")
for b in crosstabs:
if b.values.blank == "null":
bag.add(f"blank_null:{b.id}", f"「{b.id}」数据区的空格存为空值")
for _, _, b in blocks:
if b.values.text_number == "parse_thousands":
bag.add(f"text_number:{b.id}", f"「{b.id}」中千分位写法的数字文本按数字保存")
# 公式按保存值:交叉表配置了就出;列表的默认值就是放宽,本期确有公式格才出。回执只有全表一个计数,
# 只有一个块按保存值导入时才写个数(多个块分不开,宁可不写也不写错)
accepted = int(ex.get("formula_cells_accepted") or 0)
cached = [b for _, _, b in blocks if b.values.formula == "accept_cached"]
for b in cached:
if isinstance(b, CrosstabBlock) or accepted > 0:
n = f"(本期 {accepted} 格)" if len(cached) == 1 else ""
bag.add(f"formula_cached:{b.id}", f"「{b.id}」中的公式格按文件里保存的值导入{n}")
for b in lists:
if b.merged_data == "fill":
bag.add(f"merged_fill:{b.id}", f"「{b.id}」数据区的合并单元格按左上格的值填充")
for b in crosstabs:
missing = [name for key, name in _AXIS_CHECK.items() if key not in b.axis.checks]
if missing:
bag.add(f"checks_relaxed:{b.id}", f"「{b.id}」不检查日期" + "、".join(f"「{m}」" for m in missing))
for sheet in recipe.sheets:
if sheet.context and sheet.context[0].cross_check == "none":
name = _sheet_name(ex, sheet)
bag.add(f"cross_check_off:{name}", f"工作表「{name}」的统计期不与文件名里的日期核对")
ignored = ex.get("ignored_columns") or {}
for _, _, b in blocks:
heads = [str(h) for h in ignored.get(b.id) or []]
extra = isinstance(b, ListBlock) and b.extra_columns == "ignore"
if b.ignore_columns:
# 期 3 的按表头忽略(修复按钮 ⑥、框选):配置了就出,写明每条规则和理由,细节里是本期实际忽略的列
rules = "、".join(f"「{short(r.header, 20)}」(理由:{short(r.reason)})" for r in b.ignore_columns)
tail = ";其余多出的列也不导入" if extra else ""
now = "本期忽略的列:" + "、".join(heads) if heads else "本期没有命中的列"
bag.add(f"ignored_columns:{b.id}", f"「{b.id}」按表头忽略列:{rules}{tail}", now)
elif extra and heads:
bag.add(f"ignored_columns:{b.id}", f"「{b.id}」不导入这些列:" + "".join(f"「{short(h, 20)}」" for h in heads),
"、".join(heads))
_ignore_rule_items(bag, recipe, ex)
skips = [b for b in lists if b.rows.blank_rows == "skip"]
for b in skips:
n = f"(本期 {int(ex.get('blank_rows_skipped') or 0)} 行)" if len(skips) == 1 else ""
bag.add(f"blank_skip:{b.id}", f"「{b.id}」跳过数据中间的空行{n}")
# 合计段、合计行改作核对
seg_rows = {str(s.get("segment")): s.get("rows") for s in ex.get("labels") or [] if isinstance(s, dict)}
for b in crosstabs:
for seg in b.segments:
if isinstance(seg, DerivedSegment):
prefix = rows_text(seg_rows.get(seg.id)) or f"分段「{seg.id}」的合计行"
keep = f",原值另存表「{seg.keep_as.table}」" if seg.keep_as else ",原值不另存"
bag.add(f"derived:{seg.id}", f"{prefix}改作核对{keep}")
elif isinstance(seg, MeasuresSegment):
# 文字指标:这一行不再按数字核对类型,写错成别的文字也照样入库,所以配置了就要人确认
for label, tm in seg.text_measures.items():
how = "按时段导入,存规范写法(如 18-19)" if tm.parser == "hour_range" else "按文字原样导入"
bag.add(f"text_measure:{seg.id}:{label}",
f"「{label}」{how},不当数字:表「{seg.table}」的列「{seg.measures.get(label, '')}」是文字列")
derived_rows: dict[str, set[int]] = {}
for d in ex.get("derived") or []:
if isinstance(d, dict) and d.get("kind") == "column_sum":
_, r, _ = split_coord(_coord_of(d))
if r is not None:
derived_rows.setdefault(str(d.get("segment")), set()).add(r)
for b in lists:
total = b.rows.total_row
if total is not None:
rows = rows_text(derived_rows.get(b.id))
prefix = f"「{b.id}」的合计行" + (f"({rows})" if rows else "")
keep = f",原值另存表「{total.keep_as}」" if total.keep_as else ",原值不另存"
# 只有数字列按明细核对:文字列(AI 可能把数字列写成 TEXT)合计行里的数不核对,写在细节里(SE-10)
nums = [c.name for c in b.columns if c.type in ("INTEGER", "REAL")]
detail = (f"按明细核对的列:{'、'.join(nums)};其余列合计行里的内容不核对" if nums
else "这个块没有数字列,合计行里的内容不核对")
bag.add(f"total_row:{b.id}", f"{prefix}改作核对{keep}", detail)
for b in lists:
# 期 5:穿插在明细中间的小计行:不进数据,按各组明细核对。要人确认:这几行从此不在本表里
subt = b.rows.subtotals
if subt is not None:
rows = rows_text(derived_rows.get(f"{b.id}{SUBTOTAL_KEY}"))
prefix = f"「{b.id}」中间的小计行" + (f"({rows})" if rows else "")
keep = f",原值另存表「{subt.keep_as}」" if subt.keep_as else ",原值不另存"
scope = ("每行小计核对上一行小计之后的全部明细" if subt.scope == "since_previous"
else f"每行小计核对紧挨在它上面、「{subt.group_column}」相同的那几行明细")
nums = [c.name for c in b.columns if c.type in ("INTEGER", "REAL")]
detail = f"{scope};按明细核对的列:{'、'.join(nums)}" if nums else f"{scope};这个块没有数字列,不核对"
bag.add(f"subtotals:{b.id}", f"{prefix}改作核对、不进数据{keep}", detail)
elif b.rows.no_subtotals:
# 声明没有小计行是放宽:以合计字样开头的行从此照常进数据,不再报「像小计」。配上(或改过)的那一次确认
bag.add(f"no_subtotals:{b.id}", f"「{b.id}」没有小计行:以合计字样开头的行都按数据导入,不再提醒像小计的行",
"如果其实有穿插的小计,对这些列求和会把小计重复算进去")
period = b.period
if period is not None and period.column:
bag.add(f"list_period:{b.id}", f"「{b.id}」每一行的「{period.column}」都要在本期统计期内",
"按期累积时各期的行按这一列区分;有一行不在本期统计期内(或为空),这一期整份拒收")
elif period is not None and period.synthesize:
bag.add(f"list_period:{b.id}", f"「{b.id}」每一行加上本期统计期的起、止两列",
f"列名「{LIST_PERIOD_START}」「{LIST_PERIOD_END}」,取自表内的统计期或人工录入的统计期")
# 年份取自统计期:表头是日期格时年份来自格子本身,不算
forms: dict[str, set[str]] = {}
for a in ex.get("axes") or []:
if isinstance(a, dict):
forms.setdefault(str(a.get("block")), set()).add(str(a.get("form") or ""))
for b in crosstabs:
if b.axis.year_from is not None and forms.get(b.id) != {"date"}:
bag.add(f"year_from:{b.id}", f"「{b.id}」的日期年份取自统计期")
fact_text = _fact_texts(facts)
for r in recipe.relations:
if isinstance(r, SumEq):
formula = f"{r.total} = " + " + ".join(r.parts)
bag.add(f"relation:{r.id}", f"每期核对「{formula}」(表「{r.table}」)")
elif isinstance(r, NotComparable):
bag.add(f"relation:{r.id}", f"表「{r.a.table}」与表「{r.b.table}」口径不同,不互相推算或相加",
f"按「{r.by}」汇总的「{r.a.value}」不作为「{r.b.value}」的核对依据")
for r in recipe.relations:
if isinstance(r, Dismissed):
what = f"「{fact_text[r.claims]}」" if r.claims in fact_text else f"系统发现的关系 {r.claims}"
# AI 起草的配方里,理由可能出自模型(SE-10):标明来源,别让说服性的文字看起来像系统的结论
who = ";理由可能由 AI 填写,请核实" if ai else ""
bag.add(f"dismissed:{r.claims}", f"不登记{what}(理由:{r.reason}{who})")
tables, _ = derive_tables(recipe)
for t, cols in tables.items():
for c in cols:
if c.unit:
head = f"「{c.header}」→ " if c.header else ""
bag.add(f"unit:{t}.{c.name}", f"{head}表「{t}」的列「{c.name}」,单位 {c.unit}")
hidden = ex.get("hidden") or {}
for sheet in recipe.sheets:
name = _sheet_name(ex, sheet)
h = hidden.get(name) or {}
parts = []
if sheet.hidden.rows != "reject_if_any":
word = "包含" if sheet.hidden.rows == "include" else "排除"
parts.append(f"隐藏行:{word}({rows_text(h.get('rows')) or '本期没有隐藏行'})")
if sheet.hidden.cols != "reject_if_any":
cols = [col_letter(c) if isinstance(c, int) else str(c) for c in h.get("cols") or []]
parts.append(f"隐藏列:包含({'、'.join(cols) + ' 列' if cols else '本期没有隐藏列'})")
if parts:
bag.add(f"hidden:{name}", f"工作表「{name}」的" + ";".join(parts))
# 两种取值的后果(P3-SPEC 2.2,评审三-m4):首次导入时由人确认,这就是 D14 的「首次导入时由人确认」
bag.add("mode", "导入模式:每期替换" if recipe.mode == "replace" else "导入模式:按期累积", MODE_DETAIL[recipe.mode])
#: 导入模式两种取值的后果(q_mode 卡片和确认项 mode 用同一句)
MODE_DETAIL = {
"accumulate": "每期以统计期为键加入当前版本;统计期与已有各期部分重叠的文件会被拒收,同一统计期再传要确认替换",
"replace": "每次上传新一期,当前版本只含新的一期,此前各期留在历史版本中",
}
def _excluded_rows(ex: dict[str, Any], reason: str) -> list[dict[str, Any]] | None:
"""本期 rows_excluded 里某种原因的项;回执里没有这个字段(期 3 之前)时 None,个数就不写。"""
if "rows_excluded" not in ex:
return None
entries = (plain(x) for x in ex.get("rows_excluded") or [])
return [e for e in entries if isinstance(e, dict) and e.get("reason") == reason]
def _row_count(entries: list[dict[str, Any]]) -> int:
return sum(int(b) - int(a) + 1 for e in entries for a, b in (e.get("rows") or []))
def _rule_text(anchor: str, reason: str, entries: list[dict[str, Any]] | None) -> str:
"""「「补录(人次)」(理由:…;本期 1 行)」。本期命中几行按 rows_excluded 里锚点相同(match_key)的项数。"""
tail = ""
if entries is not None:
hit = [e for e in entries if match_key(e.get("anchor") or "") == match_key(anchor)]
tail = f";本期 {_row_count(hit)} 行"
return f"「{short(anchor, 20)}」(理由:{short(reason)}{tail})"
def _ignore_rule_items(bag: _Bag, recipe: Recipe, ex: dict[str, Any]) -> None:
"""期 3 的忽略规则(修复按钮 ⑥ 和框选写入):按行标签忽略 ignore_rows:<块>、按同一行文字忽略区域外的数字
ignore_outside:<工作表>。配置了就出,写上每条规则的理由和本期命中的行数(可以是 0)。"""
rows = _excluded_rows(ex, "ignored_rows")
for sheet in recipe.sheets:
for b in sheet.blocks:
if isinstance(b, CrosstabBlock) and b.ignore_rows:
mine = None if rows is None else [e for e in rows if e.get("block") == b.id]
rules = "、".join(_rule_text(r.label, r.reason, mine) for r in b.ignore_rows)
bag.add(f"ignore_rows:{b.id}", f"「{b.id}」按行标签忽略:{rules}",
";".join(f"「{r.label}」:{r.reason}" for r in b.ignore_rows))
outside = _excluded_rows(ex, "ignored_outside")
for sheet in recipe.sheets:
if not sheet.ignore_outside:
continue
name = _sheet_name(ex, sheet)
mine = None if outside is None else [e for e in outside if e.get("sheet") == name]
rules = "、".join(_rule_text(r.anchor, r.reason, mine) for r in sheet.ignore_outside)
bag.add(f"ignore_outside:{name}", f"工作表「{name}」按同一行的文字忽略导入区域之外的数字:{rules}",
";".join(f"「{r.anchor}」:{r.reason}" for r in sheet.ignore_outside))
def _coord_of(d: dict[str, Any]) -> str:
cell = str(d.get("cell") or "")
return cell if "!" in cell or not d.get("sheet") else f"{d['sheet']}!{cell}"
def _fact_texts(facts: Any) -> dict[str, str]:
data = plain(facts)
rows = data.get("facts") if isinstance(data, dict) else None
return {str(f["id"]): str(f.get("text") or "") for f in rows or [] if isinstance(f, dict) and f.get("id")}
_TYPE_NAME = {"TEXT": "文字", "INTEGER": "整数", "REAL": "小数", "DATE": "日期"}
def _col_desc(types: dict[tuple[str, str], str], table: str, c: Any) -> str:
t = types.get((table, c.name), c.type)
parts = [_TYPE_NAME.get(t, t)] + ([c.unit] if c.unit else [])
return f"{c.name}({','.join(parts)})"
def _ai_items(bag: _Bag, recipe: Recipe) -> None:
"""AI 起草的配方:列名偏离机械推导的逐列确认(rename),每张表的列(带类型、单位)再整体看一遍(columns)。"""
bag.add("ai_origin", "本配方由 AI 起草,已逐项核对")
tables, _ = derive_tables(recipe)
table_types = _declared_types(recipe, tables)
pos = {(t, c.name): i for t, cols in tables.items() for i, c in enumerate(cols, 1)}
def check(table: str, source: str, name: str, word: str) -> None:
expected = to_sql_name(split_unit_suffix(source)[0], fallback=f"列{pos.get((table, name), 1)}")
if name != expected:
bag.add(f"rename:{table}.{name}", f"原{word}「{source}」改名为列「{name}」",
f"按{word}推出的列名是「{expected}」")
for sheet in recipe.sheets:
for block in sheet.blocks:
if isinstance(block, CrosstabBlock):
for seg in block.segments:
if isinstance(seg, MeasuresSegment):
for label in seg.labels.expect:
check(seg.table, label, seg.measures.get(label, ""), "标签")
else:
for col in block.columns:
check(block.table, col.header, col.name, "表头")
# 每列带上类型和单位(SE-10):AI 把数字列写成 TEXT 能过静态校验,之后既不做数字检查、合计行也不核对这一列
for t, cols in tables.items():
bag.add(f"columns:{t}", f"表「{t}」的列:" + "、".join(_col_desc(table_types, t, c) for c in cols))
def _period_items(bag: _Bag, recipe: Recipe, ex: dict[str, Any], ctx: ConfirmContext) -> None:
"""本期类:每次都按本期情况算,与配方变没变无关。"""
prev = plain(ctx.prev) if ctx.prev is not None else None
p_rec = receipt_of(prev) if prev is not None else {}
# 上一期是简单导入时(回执里没有格子账),逐格文字比不了,按首次处理
has_prev = prev is not None and "ledger" in p_rec
p_out = {o["coord"]: o for o in outside_of(p_rec)} if has_prev else {}
p_per = period_texts(prev, p_rec) if has_prev else {}
c_per = period_texts(ex, ex)
c_ann = period_annotated(ex, ex)
for o in outside_of(ex):
if o["kind"] != "text_digits":
continue
if has_prev and _outside_exempt(o, p_out, p_per, c_per, c_ann):
continue
bag.add(f"outside_digits:{o['coord']}", f"区域外有含数字的文字「{short(o['text'])}」",
f"{o['coord']}:{o['text']}", source="outside")
grouped: dict[str, list[dict[str, Any]]] = {}
for p in ex.get("problems") or []:
p = plain(p)
if isinstance(p, dict) and p.get("code") == "rows_after_stop":
grouped.setdefault(_stop_block(p, recipe, ex), []).append(p)
for blk, probs in grouped.items():
cells = [str(c) for p in probs for c in p.get("cells") or []]
bag.add(f"rows_after_stop:{blk}", ";".join(str(p.get("message") or "") for p in probs),
"、".join(cells[:20]), source="outside")
# 期 5:没配小计的列表里像小计的数据行:照常导入了,要人确认它们确实是数据(不是的话用修复按钮改作小计核对)
for p in ex.get("problems") or []:
p = plain(p)
if isinstance(p, dict) and p.get("code") == "subtotal_suspect":
blk = str((p.get("fix_args") or {}).get("block") or "、".join(str(c) for c in p.get("cells") or []))
bag.add(f"subtotal_suspect:{blk}", str(p.get("message") or ""),
"、".join(str(c) for c in (p.get("cells") or [])[:20]), source="outside")
# 列表块只有表头、没有数据行(AU-1):问题对象里没有块键,按消息里的「列表「块」」认
for p in ex.get("problems") or []:
p = plain(p)
if isinstance(p, dict) and p.get("code") == "list_empty":
blk = next((b.id for _, _, b in _blocks(recipe, ex)
if isinstance(b, ListBlock) and f"列表「{b.id}」" in str(p.get("message") or "")), None)
table = next((b.table for _, _, b in _blocks(recipe, ex) if isinstance(b, ListBlock) and b.id == blk), "")
key = blk or "、".join(str(c) for c in p.get("cells") or [])
what = f"表「{table}」" if table else "这张表"
bag.add(f"empty_block:{key}", f"「{key}」表头之下没有数据行:本期{what}将导入 0 行(上一期的数据会被替换掉)",
"、".join(str(c) for c in p.get("cells") or []), source="outside")
relations = {r.id: r for r in recipe.relations}
for c in ctx.checks or []:
c = plain(c)
if not isinstance(c, dict) or c.get("kind") != "relation_not_comparable":
continue
checked, failed = int(c.get("checked") or 0), int(c.get("failed") or 0)
# 约定(P2-SPEC 5.2):failed 是不相等的分组数;全部相等时 failed 记 0、checked 记分组数
if checked > 0 and failed == 0:
r = relations.get(str(c.get("id")))
if isinstance(r, NotComparable):
label = (f"表「{r.a.table}」与表「{r.b.table}」声明为口径不同,"
f"但本期按「{r.by}」分组的 {checked} 组全部相等")
else:
label = f"{c.get('title') or c.get('id')}:声明为口径不同,但本期 {checked} 组全部相等"
bag.add(f"relation_observed:{c.get('id')}", label)
sheets = ex.get("sheets") or {}
p_other = {str(s) for s in (p_rec.get("sheets") or {}).get("other_visible") or []} if has_prev else set()
for name in sheets.get("other_visible") or []:
if str(name) not in p_other:
bag.add(f"sheet_extra:{name}", f"另有可见工作表「{name}」,不导入", source="sheet")
matched = {str(v): str(k) for k, v in (sheets.get("matched") or {}).items()}
for old_name, new_name in (sheets.get("renamed") or {}).items():
sid = next((s.id for s in recipe.sheets if s.match.name == old_name), None)
if sid is None:
sid = next((s.id for s in recipe.sheets if match_key(s.match.name) == match_key(old_name)), None)
sid = sid or matched.get(str(new_name)) or str(old_name)
bag.add(f"sheet_renamed:{sid}", f"工作表「{old_name}」现在叫「{new_name}」", source="sheet")
period = period_of(ex) or {}
if period.get("source") == "human":
signed = f"署名(未认证):{period['signed_by']}" if period.get("signed_by") else ""
bag.add(f"context_human:{_PERIOD_ID}", f"本期统计期为人工录入:{period.get('start')} 至 {period.get('end')}",
signed, source="context")
def _outside_exempt(o: dict[str, Any], p_out: dict[str, dict[str, Any]], p_per: dict[str, str],
c_per: dict[str, str], c_ann: dict[str, str]) -> bool:
"""带附加文字的统计期格免确认的两种情况(P2-SPEC 7.5 outside_digits):
(a) 上一期同一格是区域外纯文字,且它的 match_key 等于本期这格去掉统计期之后的文字(标题加了年月,D17、D27);
(b) 上一期同一格也是统计期来源,且两期是同一句(每月重复的同一句):数字遮盖后的模板相同,**并且**只去掉
统计期那一段之后的文字(period_residue,其余数字照留)也相同。只比遮盖模板的话,「注:2026年9月数据只含
3个分区」→「注:2026年10月数据只含2个分区」会被当成同一句,口径数字变了没人看(AU-3)。任一期认不出
统计期(period_residue 为 None)就不免。
不是统计期来源的含数字文字(D28 的「注:9月15日…」)每期都要确认;上一期没有这一格的(D28b)也要。
"""
cell = o["coord"]
if not (o["period_source"] or cell in c_per or cell in c_ann):
return False
prev = p_out.get(cell)
if prev is not None and prev["kind"] == "text":
residue = c_ann.get(cell)
if residue is None:
residue = period_residue(o["text"])
if residue is not None and match_key(prev["text"]) == match_key(residue):
return True
return cell in p_per and same_period_sentence(p_per[cell], o["text"])
def _stop_block(problem: dict[str, Any], recipe: Recipe, ex: dict[str, Any]) -> str:
"""rows_after_stop 属于哪个列表块。问题对象里没有块键,按工作表和溯源的列范围找:同一工作表上被空行停下的
列表块只有一个就是它;有多个时取列范围与问题格相交、数据在问题格之上、最靠近的那个。
执行器的消息里写着「列表「块」」,恰好认出一个块时直接用它。跳过空行的列表按表下说明收尾时也会报这个问题
(WP-8 修补 3 与评审意见 4),下面按 blank_rows=stop 找候选的办法找不到它。"""
message = str(problem.get("message") or "")
named = [b.id for _, _, b in _blocks(recipe, ex) if isinstance(b, ListBlock) and f"列表「{b.id}」" in message]
if len(named) == 1:
return named[0]
cells = [split_coord(c) for c in problem.get("cells") or []]
sheet = next((s for s, _, _ in cells if s), None)
rows = [r for _, r, _ in cells if r is not None]
cols = {c for _, _, c in cells if c is not None}
cands = [(name, b) for _, name, b in _blocks(recipe, ex)
if isinstance(b, ListBlock) and b.rows.blank_rows == "stop" and (sheet is None or name == sheet)]
if not cands:
lists = [b for _, _, b in _blocks(recipe, ex) if isinstance(b, ListBlock)]
return lists[0].id if lists else str(sheet or "")
if len(cands) == 1 or not rows:
return cands[0][1].id
best: tuple[int, str] | None = None
lineage = ex.get("lineage") or {}
for name, b in cands:
span_cols: set[int] = set()
last = 0
for runs in (lineage.get(b.table) or {}).values():
for run in runs or []:
if not isinstance(run, (list, tuple)) or len(run) < 4:
continue
s, r, c = split_coord(f"{run[1]}!{run[2]}" if "!" not in str(run[2]) else run[2])
if (s or name) != name or r is None or c is None:
continue
span_cols.add(c)
last = max(last, r + int(run[3] or 1) - 1 if (run[4] if len(run) > 4 else "down") == "down" else r)
if span_cols and (not cols or span_cols & cols) and last < min(rows):
if best is None or last > best[0]:
best = (last, b.id)
return best[1] if best else cands[0][1].id