mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:21:58 +00:00
a137182145
为什么:每月一份的明细导出是最常见的场景,期 4 之前列表只能每期替换;夹在中间的小计会让规则起草失败;
表格对象这个现成的边界信号没用上;CSV 不能走配方。四项都复用现有的执行器、核对、累积、溯源,不另起一套。
- 列表按期累积:ListBlock.period 二选一——{column, check: within_context} 按日期列分期,试运行多一条结构类核对
D(每行的日期都在本期统计期内,空着也不行);{synthesize: 统计期} 每行加「统计期起」「统计期止」两列,列表在
统计期定下来之前就抽完了,这两格先放占位、写库时换成真值。accumulate_blockers 放行绑定了统计期的列表;合计行、
小计行另存的表各期主键分不开,照旧不能累积;同一订单两期都出现由并集的主键核对拒收。U3 按列表的分期方式核对。
说明里「按哪一列筛选」用分期列,加的两列有列说明;空行之后文字行的说法补了并集版本(列表能累积以后要用到)。
- 穿插的小计:ListRows.subtotals {label_column, pick, scope: since_previous | group_column, group_column, keep_as}。
小计行不进数据,每个数字格一条合计格(分段键是块 id + ·小计),核对模块按它所管那组明细的 rowid 区间用 SQL
求和比对(新 T 编号,标题「小计行按各组明细求和」),整行记进排除的行(reason=subtotal),可以另存成
reported_total 表(小计项、分组取值、各数字列);数字格全空报 subtotal_blank(结构类);合计行的公式引用了小计格
时按它管的明细换算。静态校验:标签列、分组列要在列里,按组小计要给分组列,小计和合计的开头词不能互为前缀。
规则起草把第一列同一个合计字样、下面还有明细的行提议成小计(以前记 stray_totals 失败),框选同一条规则。
- 表格对象起草:工作表有表格对象时按它出列表块:表头行、列范围、数据行、汇总行(映射成 total_row,标签取汇总行
第一列的文字)都由表格对象给,锚点仍按表头文字、不记坐标;表名优先用表格对象的名字(含数字的不用)。不调用 AI。
- CSV 走配方:新增 csv_grid,按内容认出 CSV(不是 zip、不是 OLE、开头没有 NUL),编码和分隔符复用 tabular 读 CSV
的那一段,适配成一张名为「CSV」的工作表(SheetScan.path=csv:,WorkbookScan.format=csv);只有写法毫无歧义的
整数、小数转成数,千分位、前导零、日期写法一律留作文字交给配方的规则。坐标沿用「工作表!A1」(偏离细案的
R{行}C{列},理由见 csv_grid 的模块说明)。CSV 不做稀疏判定。导入向导和简单上传的结构提示都放行 CSV。
- 每个列表一道题「每期怎么更新」(q_list_period):每期替换 / 按期累积并按某个日期列核对 / 按期累积并加统计期
起止两列,后两种同时把导入模式改成按期累积、缺统计期的工作表一并配上(问题集合按草稿生成、回答时不变,草稿里
的列表还不符合累积资格,q_mode 不会出现,所以并成一道题)。确认项新增 subtotals:<块>、list_period:<块>。
- 前端配方面板:列表块多「小计行」(标签列、开头词、管哪些明细、分组列、另存)和「按期累积时每一行属于哪一期」;
回执「排除的行」认 subtotal。
验证:新增 tests/test_csv_grid.py、test_recipe_p5.py(执行器、核对、校验、资格、并集物化、起草)、
test_recipe_p5_e2e.py(经接口:三份月度 CSV 累积、部分重叠拒收、同一期替换要确认;带穿插小计的 Excel 起草、
核对、提交)。去掉小计识别、统计期回填、D 核对分别回跑,新测试 10 条失败。旧测试里「夹在中间的小计一律失败」
「列表只能每期替换」的断言按新行为改写。
710 lines
34 KiB
Python
710 lines
34 KiB
Python
"""配方导入的核对(P2-SPEC 5.2):在执行器写好的临时库上用 SQL 重算,得出「通过 / 不一致 / 无法核对」。
|
||
|
||
为什么算术放在这里、而且只用 SQL:执行器只搬数、记来历,不做加法(H1)。核对用库里真正存下的值重算,
|
||
SQL 原文和参数原样进 CheckResult、进导入清单,复核的人拿同一条 SQL 在同一个库上能得到同一个数。
|
||
|
||
三种状态要分清(final.md 5 节、critic C6):
|
||
- 「通过」只在真的比上了、而且相等时给;
|
||
- 明细没铺满、合计没缓存、合计格是空的、明细里有空值、有隐藏行或分类汇总函数——这些情况下合计
|
||
「等于什么」本身说不准,判「无法核对」(可写理由接受),绝不退回「通过」,也不硬判「不一致」;
|
||
- 「不一致」只在比得上、确实不等、又没有上面那些口径疑点时给。
|
||
|
||
各项核对的编号(K / G / T 按出现顺序编号,R 沿用配方)由 plan_checks 一处决定:说明生成
|
||
(recipe_notes)要按编号找回某个分段的核对结果,两边必须是同一套编号。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import re
|
||
import sqlite3
|
||
from collections import Counter
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Callable, Iterable
|
||
|
||
from app.data.engine import open_checked_sqlite
|
||
from app.data.recipe_types import (
|
||
SUBTOTAL_KEY,
|
||
CheckResult,
|
||
ColumnOut,
|
||
CrosstabBlock,
|
||
DerivedItem,
|
||
DerivedSegment,
|
||
Extraction,
|
||
ListBlock,
|
||
NotComparable,
|
||
Recipe,
|
||
SumEq,
|
||
derive_tables,
|
||
list_period_columns,
|
||
)
|
||
|
||
#: 无法核对的原因(契约 CheckResult.reasons 的键)的固定顺序:细节、说明都按这个顺序列
|
||
REASON_ORDER = ("tiling", "uncached", "blank", "null_detail", "hidden", "unrecognized")
|
||
|
||
#: 无法核对的原因 → 给人看的说法(交叉表合计、公式引用)
|
||
REASON_TEXT = {
|
||
"tiling": "明细时段没有恰好铺满合计的范围",
|
||
"uncached": "文件未经计算保存",
|
||
"blank": "合计格为空",
|
||
"null_detail": "明细含无数据占位符",
|
||
"hidden": "存在隐藏行或使用了分类汇总函数,合计口径无法确定",
|
||
"unrecognized": "公式写法无法识别",
|
||
}
|
||
#: 列表合计行的说法:明细空值来自空格或占位符,不只是占位符;「没铺满」在列表上是合计行之上一行明细都没有
|
||
_LIST_REASON_TEXT = {**REASON_TEXT, "null_detail": "明细含空值", "tiling": "合计行之上没有明细行"}
|
||
|
||
#: 合计公式是这两种函数时,不等只能判「无法核对」:SUBTOTAL(109) 本来就不算隐藏行,SUM 却算
|
||
_SUBTOTAL_FUNCS = frozenset({"SUBTOTAL", "AGGREGATE"})
|
||
#: 细节最多逐条列出这么多格(其余写一句「另有 N 格」);cells 同样封顶
|
||
_DETAIL_MAX = 20
|
||
|
||
_FULL_CALC_NOTE = "工作簿设置了打开时重算,保存值与重算一致"
|
||
_A1 = re.compile(r"^\$?([A-Za-z]{1,3})\$?([1-9][0-9]*)$")
|
||
|
||
|
||
# ---------------------------------------------------------------- 编号
|
||
|
||
|
||
@dataclass
|
||
class CheckPlan:
|
||
"""K / G / T 的编号与各自要核对的合计格。run_checks 和 recipe_notes 共用。"""
|
||
|
||
#: derived 分段键 → K 编号(每个有合计格的 derived 段一条)
|
||
k: dict[str, str] = field(default_factory=dict)
|
||
#: derived 分段键 → G 编号(只给有公式格的 derived 段)
|
||
g: dict[str, str] = field(default_factory=dict)
|
||
#: 列表块键 → T 编号(每个有合计行的列表块一条);期 5:块键 + SUBTOTAL_KEY → 这个块的小计核对(一条)
|
||
t: dict[str, str] = field(default_factory=dict)
|
||
items: dict[str, list[DerivedItem]] = field(default_factory=dict)
|
||
|
||
|
||
def _recipe_keys(recipe: Recipe) -> tuple[list[str], list[str]]:
|
||
"""配方里 derived 分段键、带合计行的列表块键,按配方里的先后。期 5:带小计的列表块另有一个键(块键 +
|
||
SUBTOTAL_KEY),排在这个块的合计行后面:同一块的小计和合计各编一个 T,结论分开写。"""
|
||
derived: list[str] = []
|
||
totals: list[str] = []
|
||
for sheet in recipe.sheets:
|
||
for block in sheet.blocks:
|
||
if isinstance(block, CrosstabBlock):
|
||
derived += [s.id for s in block.segments if isinstance(s, DerivedSegment)]
|
||
continue
|
||
if block.rows.total_row is not None:
|
||
totals.append(block.id)
|
||
if block.rows.subtotals is not None:
|
||
totals.append(f"{block.id}{SUBTOTAL_KEY}")
|
||
return derived, totals
|
||
|
||
|
||
def is_subtotal_key(key: str) -> bool:
|
||
"""T 编号的键是不是一个块的小计核对(期 5)。"""
|
||
return key.endswith(SUBTOTAL_KEY)
|
||
|
||
|
||
def _ordered(recipe_keys: list[str], items: list[DerivedItem]) -> list[str]:
|
||
"""先按配方里的先后;配方里找不到的分段(不该出现)按执行结果里第一次出现的先后排在后面,不丢。"""
|
||
present: list[str] = []
|
||
for it in items:
|
||
if it.segment not in present:
|
||
present.append(it.segment)
|
||
known = set(recipe_keys)
|
||
return [k for k in recipe_keys if k in present] + [k for k in present if k not in known]
|
||
|
||
|
||
def plan_checks(recipe: Recipe, extraction: Extraction) -> CheckPlan:
|
||
"""按配方顺序给 K / G / T 编号。同一份配方和执行结果永远得到同一套编号。"""
|
||
derived_keys, total_keys = _recipe_keys(recipe)
|
||
sums = [d for d in extraction.derived if d.kind == "label_range_sum"]
|
||
cols = [d for d in extraction.derived if d.kind == "column_sum"]
|
||
plan = CheckPlan()
|
||
for key in _ordered(derived_keys, sums):
|
||
plan.items[key] = [d for d in sums if d.segment == key]
|
||
plan.k[key] = f"K{len(plan.k) + 1}"
|
||
if any(d.is_formula for d in plan.items[key]):
|
||
plan.g[key] = f"G{len(plan.g) + 1}"
|
||
for key in _ordered(total_keys, cols):
|
||
plan.items[key] = [d for d in cols if d.segment == key]
|
||
plan.t[key] = f"T{len(plan.t) + 1}"
|
||
return plan
|
||
|
||
|
||
# ---------------------------------------------------------------- 小工具
|
||
|
||
|
||
def _q(name: str) -> str:
|
||
"""SQL 标识符:一律双引号、内部双引号加倍(库是 DQS 关闭的连接,写错的名字会直接报错)。"""
|
||
return '"' + str(name).replace('"', '""') + '"'
|
||
|
||
|
||
def col_index(letters: str) -> int:
|
||
n = 0
|
||
for ch in letters.upper():
|
||
n = n * 26 + (ord(ch) - 64)
|
||
return n
|
||
|
||
|
||
def col_letters(index: int) -> str:
|
||
out = ""
|
||
while index > 0:
|
||
index, rem = divmod(index - 1, 26)
|
||
out = chr(65 + rem) + out
|
||
return out
|
||
|
||
|
||
def split_coord(coord: str) -> tuple[str | None, int, int] | None:
|
||
"""「工作表!C5」或「C5」→ (工作表或 None, 列号, 行号);认不出返回 None。"""
|
||
sheet, _, ref = str(coord).rpartition("!")
|
||
m = _A1.match(ref.strip())
|
||
if not m:
|
||
return None
|
||
return (sheet or None), col_index(m.group(1)), int(m.group(2))
|
||
|
||
|
||
def lineage_cell(lineage: dict[str, dict[str, list[list[Any]]]], table: str, column: str,
|
||
rowid: int) -> str | None:
|
||
"""按溯源段把 (表, 列, rowid) 反查成「工作表!A1」。溯源段:[起始 rowid, 工作表, 起始格, 个数, 方向]。"""
|
||
for run in (lineage.get(table) or {}).get(column) or []:
|
||
try:
|
||
start, sheet, cell, count, direction = run[0], run[1], run[2], run[3], run[4]
|
||
except (IndexError, TypeError):
|
||
continue
|
||
if not (start <= rowid < start + count):
|
||
continue
|
||
parsed = split_coord(cell)
|
||
if parsed is None:
|
||
return None
|
||
_, c, r = parsed
|
||
offset = rowid - start
|
||
if direction == "down":
|
||
r += offset
|
||
else:
|
||
c += offset
|
||
return f"{sheet}!{col_letters(c)}{r}"
|
||
return None
|
||
|
||
|
||
def _short(item: DerivedItem) -> str:
|
||
"""细节里写的格子:不带工作表名(「G28」),工作表在 cells 里。"""
|
||
return str(item.cell).rpartition("!")[2]
|
||
|
||
|
||
def _coord(item: DerivedItem) -> str:
|
||
return item.cell if "!" in str(item.cell) else f"{item.sheet}!{item.cell}"
|
||
|
||
|
||
def _fmt(x: Any) -> str:
|
||
if x is None:
|
||
return "空"
|
||
if isinstance(x, bool) or not isinstance(x, (int, float)):
|
||
return str(x)
|
||
if isinstance(x, float):
|
||
if math.isfinite(x) and x.is_integer() and abs(x) < 1e15:
|
||
return str(int(x))
|
||
return format(x, ".10g")
|
||
return str(x)
|
||
|
||
|
||
def _is_int_value(x: Any) -> bool:
|
||
return isinstance(x, int) or (isinstance(x, float) and math.isfinite(x) and x.is_integer())
|
||
|
||
|
||
def same_number(a: Any, b: Any) -> bool:
|
||
"""两边都是整数值时精确相等,否则相对容差 1e-9(P2-SPEC 5.2 的比较规则)。"""
|
||
if isinstance(a, bool) or isinstance(b, bool) or not isinstance(a, (int, float)) \
|
||
or not isinstance(b, (int, float)):
|
||
return False
|
||
if _is_int_value(a) and _is_int_value(b):
|
||
return int(a) == int(b)
|
||
return abs(a - b) <= 1e-9 * max(1.0, abs(a), abs(b))
|
||
|
||
|
||
def _range_text(item: DerivedItem) -> str:
|
||
return f"{item.start}-{item.end} 时"
|
||
|
||
|
||
class _Broken(Exception):
|
||
"""合计格缺了核对必需的信息(执行器的错):这一条核对判结构类不一致,不硬算。"""
|
||
|
||
|
||
class _Sql:
|
||
"""执行 SQL 并记下最后一条:出错时 CheckResult 里也要有那条 SQL 原文。"""
|
||
|
||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||
self.conn = conn
|
||
self.last: tuple[str | None, list[Any]] = (None, [])
|
||
|
||
def one(self, sql: str, params: list[Any] | tuple[Any, ...] = ()) -> tuple[Any, ...]:
|
||
self.last = (sql, list(params))
|
||
return self.conn.execute(sql, list(params)).fetchone()
|
||
|
||
def all(self, sql: str, params: list[Any] | tuple[Any, ...] = ()) -> list[tuple[Any, ...]]:
|
||
self.last = (sql, list(params))
|
||
return self.conn.execute(sql, list(params)).fetchall()
|
||
|
||
|
||
@dataclass
|
||
class _Tally:
|
||
"""逐格判定的汇总:K、T、G 共用。"""
|
||
|
||
checked: int = 0
|
||
mismatches: list[tuple[str, str]] = field(default_factory=list) # (格, 细节)
|
||
reasons: Counter = field(default_factory=Counter)
|
||
unverifiable_cells: list[str] = field(default_factory=list)
|
||
extra: dict[str, list[str]] = field(default_factory=dict) # 原因 → 附加说明(如没铺满的区间)
|
||
sql: str | None = None
|
||
params: list[Any] = field(default_factory=list)
|
||
_sql_rank: int = 3
|
||
|
||
def keep_sql(self, sql: str, params: list[Any], rank: int) -> None:
|
||
# 代表性的 SQL:优先取第一处不一致,其次第一处无法核对,再次第一格
|
||
if rank < self._sql_rank:
|
||
self.sql, self.params, self._sql_rank = sql, list(params), rank
|
||
|
||
def ok(self) -> None:
|
||
self.checked += 1
|
||
|
||
def bad(self, cell: str, detail: str) -> None:
|
||
self.checked += 1
|
||
self.mismatches.append((cell, detail))
|
||
|
||
def unverifiable(self, cell: str, reason: str, note: str | None = None) -> None:
|
||
self.reasons[reason] += 1
|
||
self.unverifiable_cells.append(cell)
|
||
if note and note not in self.extra.setdefault(reason, []):
|
||
self.extra[reason].append(note)
|
||
|
||
|
||
def _finish(cid: str, kind: Any, title: str, tally: _Tally, *, reason_text: dict[str, str],
|
||
full_calc: bool = False) -> CheckResult:
|
||
unverifiable = sum(tally.reasons.values())
|
||
details: list[str] = [d for _, d in tally.mismatches[:_DETAIL_MAX]]
|
||
if len(tally.mismatches) > _DETAIL_MAX:
|
||
details.append(f"另有 {len(tally.mismatches) - _DETAIL_MAX} 格不一致")
|
||
for reason in REASON_ORDER + tuple(r for r in tally.reasons if r not in REASON_ORDER):
|
||
n = tally.reasons.get(reason, 0)
|
||
if not n:
|
||
continue
|
||
text = reason_text.get(reason, reason)
|
||
if reason == "tiling" and tally.extra.get("tiling"):
|
||
text = "明细时段没有恰好铺满 " + "、".join(tally.extra["tiling"])
|
||
details.append(f"{n} 格无法核对:{text}")
|
||
if tally.mismatches:
|
||
status = "mismatch"
|
||
elif unverifiable:
|
||
status = "unverifiable"
|
||
else:
|
||
status = "passed"
|
||
details.append(f"{tally.checked} 格全部一致")
|
||
if full_calc:
|
||
details.append(_FULL_CALC_NOTE)
|
||
cells = [c for c, _ in tally.mismatches] + tally.unverifiable_cells
|
||
# category 一律 structure:不一致是结构类(拒收),无法核对时 acceptable=True、可写理由接受(进 waivers)。
|
||
# 所以判「拒收」要看 status != passed 且 category == structure 且 acceptable 为假,不能只看 category
|
||
return CheckResult(
|
||
id=cid, kind=kind, title=title, status=status, category="structure",
|
||
checked=tally.checked, failed=len(tally.mismatches), unverifiable=unverifiable,
|
||
sql=tally.sql, params=tally.params, details=details, cells=cells[:_DETAIL_MAX],
|
||
acceptable=status == "unverifiable",
|
||
reasons={r: n for r, n in tally.reasons.items() if n},
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------- K:合计按标签区间重算
|
||
|
||
|
||
def _require_range(item: DerivedItem) -> None:
|
||
if item.start is None or item.end is None or not item.start_col or not item.end_col \
|
||
or not item.base_table or not item.base_value:
|
||
raise _Broken(f"{_short(item)}:合计格缺少核对需要的区间或基表信息")
|
||
|
||
|
||
def _range_where(item: DerivedItem, filters: Iterable[tuple[str, Any]]) -> tuple[str, list[Any]]:
|
||
"""WHERE 各分组列 = ? AND 起始列 >= ? AND 结束列 <= ?,以及对应的参数。"""
|
||
filters = list(filters)
|
||
where = [f"{_q(k)} = ?" for k, _ in filters] + [f"{_q(item.start_col)} >= ?", f"{_q(item.end_col)} <= ?"]
|
||
return " AND ".join(where), [v for _, v in filters] + [item.start, item.end]
|
||
|
||
|
||
def _tiled(sql: _Sql, item: DerivedItem, filters: tuple[tuple[str, Any], ...]) -> bool:
|
||
"""区间 [start, end] 里的明细时段是否恰好铺满:首尾对上、相邻首尾相接、不重叠。"""
|
||
where, params = _range_where(item, filters)
|
||
rows = sql.all(f"SELECT DISTINCT {_q(item.start_col)}, {_q(item.end_col)} FROM {_q(item.base_table)} "
|
||
f"WHERE {where} ORDER BY {_q(item.start_col)}, {_q(item.end_col)}", params)
|
||
if not rows or rows[0][0] != item.start or rows[-1][1] != item.end:
|
||
return False
|
||
prev_end = None
|
||
for s, e in rows:
|
||
if s is None or e is None or s >= e or (prev_end is not None and s != prev_end):
|
||
return False
|
||
prev_end = e
|
||
return True
|
||
|
||
|
||
def _sum_sql(item: DerivedItem) -> tuple[str, list[Any]]:
|
||
# WHERE 带上 key 的全部列(轴列 + 前一分段的常量列):同表别的类别的行不会被加进来
|
||
where, params = _range_where(item, item.key.items())
|
||
return (f"SELECT TOTAL({_q(item.base_value)}), COUNT({_q(item.base_value)}), COUNT(*) "
|
||
f"FROM {_q(item.base_table)} WHERE {where}"), params
|
||
|
||
|
||
def _judge_value(tally: _Tally, item: DerivedItem, total: float, nonnull: int, count: int,
|
||
sql: str, params: list[Any], *, mismatch_detail: Callable[[], str]) -> None:
|
||
"""判定顺序第 2–6 条(第 1 条铺满由调用方先判)。K 与 T 共用。"""
|
||
cell = _coord(item)
|
||
if item.value is None:
|
||
tally.unverifiable(cell, "uncached" if item.is_formula else "blank")
|
||
tally.keep_sql(sql, params, 1)
|
||
elif nonnull < count:
|
||
# 明细缺数时合计等于什么都说不准:不能判一致,也不能判不一致
|
||
tally.unverifiable(cell, "null_detail")
|
||
tally.keep_sql(sql, params, 1)
|
||
elif same_number(item.value, total):
|
||
tally.ok()
|
||
tally.keep_sql(sql, params, 2)
|
||
elif item.hidden_in_range or (item.func or "").upper() in _SUBTOTAL_FUNCS:
|
||
tally.unverifiable(cell, "hidden")
|
||
tally.keep_sql(sql, params, 1)
|
||
else:
|
||
tally.bad(cell, mismatch_detail())
|
||
tally.keep_sql(sql, params, 0)
|
||
|
||
|
||
def _check_k(sql: _Sql, cid: str, title: str, items: list[DerivedItem], axis: str | None,
|
||
full_calc: bool) -> CheckResult:
|
||
tally = _Tally()
|
||
tiling: dict[tuple[Any, ...], bool] = {}
|
||
for item in items:
|
||
_require_range(item)
|
||
# 铺满只看区间和常量列(分段类别),不看日期:长表里每个日期的时段行是同一组
|
||
filters = tuple((k, v) for k, v in item.key.items() if k != axis)
|
||
tkey = (item.base_table, item.start_col, item.end_col, item.start, item.end, filters)
|
||
if tkey not in tiling:
|
||
tiling[tkey] = _tiled(sql, item, filters)
|
||
q, params = _sum_sql(item)
|
||
if not tiling[tkey]:
|
||
tally.unverifiable(_coord(item), "tiling", _range_text(item))
|
||
tally.keep_sql(q, params, 1)
|
||
continue
|
||
total, nonnull, count = sql.one(q, params)
|
||
if count == 0:
|
||
# 这个日期一行明细都没有:同样是没铺满
|
||
tally.unverifiable(_coord(item), "tiling", _range_text(item))
|
||
tally.keep_sql(q, params, 1)
|
||
continue
|
||
_judge_value(tally, item, total, nonnull, count, q, params, mismatch_detail=lambda: (
|
||
f"{_short(item)}:表内为 {_fmt(item.value)},按 {_range_text(item)}的明细重算为 {_fmt(total)}"))
|
||
return _finish(cid, "derived_sum", title, tally, reason_text=REASON_TEXT, full_calc=full_calc)
|
||
|
||
|
||
# ---------------------------------------------------------------- G:公式引用与标签一致
|
||
|
||
|
||
def _check_g(sql: _Sql, cid: str, title: str, items: list[DerivedItem]) -> CheckResult:
|
||
tally = _Tally()
|
||
for item in items:
|
||
if not item.is_formula:
|
||
continue
|
||
cell = _coord(item)
|
||
problem = item.ref_problem
|
||
if problem == "outside":
|
||
tally.bad(cell, f"{_short(item)}:公式引用了不在「{item.base_table}」明细里的格子")
|
||
continue
|
||
if problem == "hidden" or (problem is None and (item.func or "").upper() in _SUBTOTAL_FUNCS):
|
||
tally.unverifiable(cell, "hidden")
|
||
continue
|
||
if problem is not None or item.ref_rowids is None:
|
||
tally.unverifiable(cell, "unrecognized")
|
||
continue
|
||
_require_range(item)
|
||
where, params = _range_where(item, item.key.items())
|
||
q = f"SELECT rowid FROM {_q(item.base_table)} WHERE {where}"
|
||
got = {r[0] for r in sql.all(q, params)}
|
||
if got == set(item.ref_rowids):
|
||
tally.ok()
|
||
tally.keep_sql(q, params, 2)
|
||
else:
|
||
tally.bad(cell, f"{_short(item)}:标签「{item.label}」与公式引用的时段不一致")
|
||
tally.keep_sql(q, params, 0)
|
||
return _finish(cid, "formula_refs", title, tally, reason_text=REASON_TEXT)
|
||
|
||
|
||
# ---------------------------------------------------------------- T:列表合计行按列求和
|
||
|
||
|
||
def _check_t(sql: _Sql, cid: str, title: str, items: list[DerivedItem], full_calc: bool) -> CheckResult:
|
||
tally = _Tally()
|
||
for item in items:
|
||
if not item.base_table or not item.base_value:
|
||
raise _Broken(f"{_short(item)}:合计格缺少基表信息")
|
||
first, last = item.rowid_first, item.rowid_last
|
||
if (first is None) != (last is None):
|
||
raise _Broken(f"{_short(item)}:合计行对应的明细行区间只有一端")
|
||
q = (f"SELECT TOTAL({_q(item.base_value)}), COUNT({_q(item.base_value)}), COUNT(*) "
|
||
f"FROM {_q(item.base_table)} WHERE rowid BETWEEN ? AND ?")
|
||
params = [first, last]
|
||
total, nonnull, count = sql.one(q, params)
|
||
if count == 0:
|
||
# 区间里一行明细都没有:表头下面紧接着就是合计行(执行器用两端都是 None 表示空区间,
|
||
# BETWEEN NULL AND NULL 一行都选不到),或者区间对不上库。TOTAL 得 0,合计写 0 时会被判「一致」;
|
||
# 没有可比的明细,判无法核对
|
||
tally.unverifiable(_coord(item), "tiling")
|
||
tally.keep_sql(q, params, 1)
|
||
continue
|
||
_judge_value(tally, item, total, nonnull, count, q, params, mismatch_detail=lambda: (
|
||
f"{_short(item)}:表内为 {_fmt(item.value)},按明细求和为 {_fmt(total)}"))
|
||
return _finish(cid, "column_sum", title, tally, reason_text=_LIST_REASON_TEXT, full_calc=full_calc)
|
||
|
||
|
||
# ---------------------------------------------------------------- R:关系
|
||
|
||
|
||
def sum_eq_conditions(total: str, parts: list[str], types: dict[str, str]) -> tuple[str, str, str]:
|
||
"""关系核对「total = parts 之和」逐行判定用的三段 SQL 条件:(nonnull, anynull, mismatch)。
|
||
|
||
- nonnull:参与的列都不是空值(这一行比得上);
|
||
- anynull:任一列是空值(这一行无法核对);
|
||
- mismatch:合计与各部分之和不等。只在 nonnull 成立时有意义,调用方自己和 nonnull 组合(AND,或者 CASE 里
|
||
先判 anynull)。有一列是 REAL 时按相对误差 1e-9 比(浮点加法有舍入),全是整数时严格比。
|
||
|
||
为什么抽出来:导入时的核对(_check_sum_eq)和证据面板的行级核对(provenance_db.row_status,P4-SPEC 2.7)
|
||
必须用同一段代码生成的条件。面板说「这一天成立」,就得和导入时的结论是同一个判断,不能各写一份、日后改了一边。
|
||
types 是列名 → INTEGER / REAL / TEXT(回执里这一期的列类型),缺的列按非 REAL 处理。
|
||
"""
|
||
names = [total, *parts]
|
||
nonnull = " AND ".join(f"{_q(c)} IS NOT NULL" for c in names)
|
||
anynull = " OR ".join(f"{_q(c)} IS NULL" for c in names)
|
||
added = " + ".join(_q(p) for p in parts)
|
||
if any(types.get(c) == "REAL" for c in names):
|
||
mismatch = f"ABS({_q(total)} - ({added})) > 1e-9 * MAX(1, ABS({_q(total)}))"
|
||
else:
|
||
mismatch = f"{_q(total)} <> {added}"
|
||
return nonnull, anynull, mismatch
|
||
|
||
|
||
def _check_sum_eq(sql: _Sql, rel: SumEq, title: str, columns: list[ColumnOut], grain: list[str],
|
||
lineage: dict[str, dict[str, list[list[Any]]]]) -> CheckResult:
|
||
table, total, parts = rel.table, rel.total, list(rel.parts)
|
||
nonnull, anynull, cond = sum_eq_conditions(total, parts, {c.name: c.type for c in columns})
|
||
checked = sql.one(f"SELECT COUNT(*) FROM {_q(table)} WHERE {nonnull}")[0]
|
||
nulls = sql.one(f"SELECT COUNT(*) FROM {_q(table)} WHERE {anynull}")[0]
|
||
added = " + ".join(_q(p) for p in parts)
|
||
where = f"{nonnull} AND {cond}"
|
||
failed = sql.one(f"SELECT COUNT(*) FROM {_q(table)} WHERE {where}")[0]
|
||
picked = ", ".join(["rowid", *(_q(g) for g in grain), _q(total), f"({added})"])
|
||
rows_sql = f"SELECT {picked} FROM {_q(table)} WHERE {where} ORDER BY rowid"
|
||
rows = sql.all(rows_sql + f" LIMIT {_DETAIL_MAX}") if failed else []
|
||
details: list[str] = []
|
||
cells: list[str] = []
|
||
for row in rows:
|
||
rowid, keys, value, summed = row[0], row[1:1 + len(grain)], row[-2], row[-1]
|
||
where_text = "、".join(f"{g} {v}" for g, v in zip(grain, keys)) or f"第 {rowid} 条记录"
|
||
cell = lineage_cell(lineage, table, total, rowid)
|
||
if cell:
|
||
cells.append(cell)
|
||
at = f"({cell.rpartition('!')[2]})" if cell else ""
|
||
details.append(f"{where_text}{at}:{total} 为 {_fmt(value)},{'、'.join(parts)} 之和为 {_fmt(summed)}")
|
||
if failed > len(rows):
|
||
details.append(f"另有 {failed - len(rows)} 行不成立")
|
||
if nulls:
|
||
details.append(f"{nulls} 行含空值,未能核对")
|
||
if failed:
|
||
status = "mismatch"
|
||
elif nulls:
|
||
status = "unverifiable"
|
||
elif checked == 0:
|
||
# 一行都没有核对过(表头下面没有数据行):不能当「通过」,与 T 的空区间一样判无法核对(AU-1)。
|
||
# 重传时文件导出出错只剩表头,上一期的数据会被整表替换成 0 行,要让人写理由接受才能提交
|
||
status = "unverifiable"
|
||
details.append("本期这张表没有数据行,关系未能核对")
|
||
else:
|
||
status = "passed"
|
||
details.append(f"{checked} 行全部成立")
|
||
return CheckResult(
|
||
id=rel.id, kind="relation_sum_eq", title=title, status=status, category="data_quality",
|
||
checked=checked, failed=failed, unverifiable=nulls, sql=rows_sql, params=[],
|
||
details=details, cells=cells, acceptable=status != "passed",
|
||
)
|
||
|
||
|
||
def _check_not_comparable(sql: _Sql, rel: NotComparable, title: str, tables: dict[str, list[ColumnOut]],
|
||
grains: dict[str, list[str]], date_cols: dict[str, set[str]]) -> CheckResult:
|
||
a, b, by = rel.a, rel.b, rel.by
|
||
inner = (f"SELECT {_q(by)}, TOTAL({_q(a.value)}) AS __sum FROM {_q(a.table)} GROUP BY {_q(by)}")
|
||
joined = f"SELECT COUNT(*) FROM ({inner}) a JOIN {_q(b.table)} b ON a.{_q(by)} = b.{_q(by)}"
|
||
groups = sql.one(joined)[0]
|
||
b_type = next((c.type for c in tables.get(b.table, []) if c.name == b.value), "INTEGER")
|
||
if b_type == "REAL":
|
||
cond = f"ABS(a.__sum - b.{_q(b.value)}) <= 1e-9 * MAX(1, ABS(b.{_q(b.value)}))"
|
||
else:
|
||
cond = f"a.__sum = b.{_q(b.value)}"
|
||
equal_sql = f"{joined} WHERE {cond}"
|
||
equal = sql.one(equal_sql)[0]
|
||
dims = [g for g in grains.get(a.table, []) if g != by]
|
||
unit = "天" if by in date_cols.get(a.table, set()) else "组"
|
||
head = f"各{'、'.join(dims)}之和与{b.value}" if dims else f"{a.value}按{by}汇总与{b.value}"
|
||
if groups and equal == groups:
|
||
# 声明了口径不同、本期却处处相等:说明换不含「不等于」的写法,确认项要人看一眼(7.5)
|
||
detail = f"{head}:{groups} {unit}全部相等"
|
||
else:
|
||
detail = f"{head}:{groups} {unit}中 {equal} {unit}相等"
|
||
return CheckResult(
|
||
id=rel.id, kind="relation_not_comparable", title=title,
|
||
status="info", category="info", checked=groups, failed=groups - equal,
|
||
sql=equal_sql, params=[], details=[detail],
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------- N、P:落库
|
||
|
||
|
||
def _check_rows(sql: _Sql, cid: str, title: str, table: str, expected: int | None) -> CheckResult:
|
||
q = f"SELECT COUNT(*) FROM {_q(table)}"
|
||
n = sql.one(q)[0]
|
||
if expected is None:
|
||
status, detail = "mismatch", f"缺少按格子账推出的期望行数,库里 {n} 行"
|
||
elif n != expected:
|
||
status, detail = "mismatch", f"库里 {n} 行,按格子账应有 {expected} 行"
|
||
else:
|
||
status, detail = "passed", f"{n} 行,与格子账一致"
|
||
return CheckResult(id=cid, kind="row_count", title=title, status=status, category="structure",
|
||
checked=1, failed=int(status != "passed"), sql=q, params=[], details=[detail])
|
||
|
||
|
||
def _check_pk(sql: _Sql, cid: str, title: str, table: str, grain: list[str]) -> CheckResult:
|
||
q = (f"SELECT COUNT(*) FROM (SELECT 1 FROM {_q(table)} GROUP BY {', '.join(_q(g) for g in grain)} "
|
||
f"HAVING COUNT(*) > 1)")
|
||
dup = sql.one(q)[0]
|
||
detail = f"{dup} 组主键重复" if dup else "主键没有重复"
|
||
return CheckResult(id=cid, kind="pk_unique", title=title, status="passed" if not dup else "mismatch",
|
||
category="structure", checked=1, failed=int(bool(dup)), sql=q, params=[], details=[detail])
|
||
|
||
|
||
# ---------------------------------------------------------------- 入口
|
||
|
||
|
||
def _check_period_rows(sql: _Sql, cid: str, title: str, table: str, column: str, period: Any,
|
||
lineage: dict[str, dict[str, list[list[Any]]]]) -> CheckResult:
|
||
"""期 5:列表按日期列分期(ListPeriod.column、check=within_context)时,每行的这一列都落在本期统计期内。
|
||
日期为空的行也算不在:看不出它属于哪一期。不在的行按期累积会混进别的期(统计期互不重叠的前提就破了),
|
||
所以是结构类、不能写理由接受。统计期没定下来(要人录入)时不会走到这里:那时不建库。"""
|
||
if period is None or not period.start or not period.end:
|
||
raise _Broken("本期没有统计期,无法核对每行的日期")
|
||
q = (f"SELECT rowid FROM {_q(table)} WHERE {_q(column)} IS NULL OR {_q(column)} < ? OR {_q(column)} > ? "
|
||
"ORDER BY rowid")
|
||
params = [period.start, period.end]
|
||
bad = [r[0] for r in sql.all(q, params)]
|
||
total = sql.one(f"SELECT COUNT(*) FROM {_q(table)}")[0]
|
||
cells = [c for rowid in bad[:_DETAIL_MAX] if (c := lineage_cell(lineage, table, column, rowid))]
|
||
details = [f"有 {len(bad)} 行的「{column}」为空或不在 {period.start} 至 {period.end} 之内"] if bad else []
|
||
return CheckResult(id=cid, kind="period_rows", title=title, status="mismatch" if bad else "passed",
|
||
category="structure", checked=int(total), failed=len(bad), sql=q, params=params,
|
||
details=details, cells=cells)
|
||
|
||
|
||
def _axis_names(recipe: Recipe) -> dict[str, str]:
|
||
"""derived 分段键 → 所在交叉表块的轴列名(K 的铺满判定不按日期分)。"""
|
||
out: dict[str, str] = {}
|
||
for sheet in recipe.sheets:
|
||
for block in sheet.blocks:
|
||
if isinstance(block, CrosstabBlock):
|
||
for seg in block.segments:
|
||
if isinstance(seg, DerivedSegment):
|
||
out[seg.id] = block.axis.name
|
||
return out
|
||
|
||
|
||
def date_columns(recipe: Recipe) -> dict[str, set[str]]:
|
||
"""表 → 日期列(交叉表的轴列、列表里 type=DATE 的列)。「逐日」「天」的措辞按它定。"""
|
||
tables, _ = derive_tables(recipe)
|
||
out: dict[str, set[str]] = {t: {c.name for c in cols if c.role == "axis"} for t, cols in tables.items()}
|
||
for sheet in recipe.sheets:
|
||
for block in sheet.blocks:
|
||
if isinstance(block, ListBlock):
|
||
out.setdefault(block.table, set()).update(c.name for c in block.columns if c.type == "DATE")
|
||
return out
|
||
|
||
|
||
def _guarded(sql: _Sql, cid: str, kind: Any, title: str,
|
||
run: Callable[[str], CheckResult]) -> CheckResult:
|
||
"""核对 SQL 出错(表或列不存在)或合计格缺信息,都是执行器或配方的错:判结构类不一致,不让整次核对崩掉。"""
|
||
sql.last = (None, [])
|
||
try:
|
||
return run(title)
|
||
except (sqlite3.Error, _Broken) as e:
|
||
text, params = sql.last
|
||
reason = str(e) if isinstance(e, _Broken) else f"核对 SQL 执行失败:{e}"
|
||
return CheckResult(id=cid, kind=kind, title=title, status="mismatch", category="structure",
|
||
sql=text, params=params, details=[reason])
|
||
|
||
|
||
def run_checks(db_path: str, extraction: Extraction, recipe: Recipe) -> list[CheckResult]:
|
||
"""在临时库上跑全部核对。同步(调用方放进线程池)。
|
||
|
||
结果 = extraction.context_checks(C1、C2 原样排在最前)+ K… + G… + T… + R…(配方顺序)+ N… + P…,
|
||
顺序只取决于配方和执行结果。库以只读、DQS 关闭的连接打开(engine.open_checked_sqlite)。
|
||
"""
|
||
plan = plan_checks(recipe, extraction)
|
||
tables, _ = derive_tables(recipe)
|
||
grains = {t.name: list(t.grain) for t in recipe.tables}
|
||
axis = _axis_names(recipe)
|
||
dates = date_columns(recipe)
|
||
full_calc = extraction.full_calc_on_load
|
||
conn = open_checked_sqlite(db_path, readonly=True)
|
||
sql = _Sql(conn)
|
||
# (编号, 种类, 标题, 核对函数):标题在这里定一次,出错时的那条结果也用它
|
||
jobs: list[tuple[str, Any, str, Callable[[str], CheckResult]]] = []
|
||
for seg, cid in plan.k.items():
|
||
jobs.append((cid, "derived_sum", f"合计按明细重算(分段「{seg}」)",
|
||
lambda t, seg=seg, cid=cid: _check_k(sql, cid, t, plan.items[seg], axis.get(seg), full_calc)))
|
||
for seg, cid in plan.g.items():
|
||
jobs.append((cid, "formula_refs", f"合计公式引用(分段「{seg}」)",
|
||
lambda t, seg=seg, cid=cid: _check_g(sql, cid, t, plan.items[seg])))
|
||
for block, cid in plan.t.items():
|
||
title = (f"小计行按各组明细求和(块「{block.removesuffix(SUBTOTAL_KEY)}」)" if is_subtotal_key(block)
|
||
else f"合计行按列求和(块「{block}」)")
|
||
jobs.append((cid, "column_sum", title,
|
||
lambda t, block=block, cid=cid: _check_t(sql, cid, t, plan.items[block], full_calc)))
|
||
for rel in recipe.relations:
|
||
if isinstance(rel, SumEq):
|
||
jobs.append((rel.id, "relation_sum_eq", f"「{rel.table}」:{rel.total} = {' + '.join(rel.parts)}",
|
||
lambda t, rel=rel: _check_sum_eq(sql, rel, t, tables.get(rel.table, []),
|
||
grains.get(rel.table, []), extraction.lineage)))
|
||
elif isinstance(rel, NotComparable):
|
||
jobs.append((rel.id, "relation_not_comparable", f"口径不同:「{rel.a.table}」与「{rel.b.table}」",
|
||
lambda t, rel=rel: _check_not_comparable(sql, rel, t, tables, grains, dates)))
|
||
for i, table in enumerate(tables, 1):
|
||
jobs.append((f"N{i}", "row_count", f"行数:「{table}」",
|
||
lambda t, table=table, i=i: _check_rows(sql, f"N{i}", t, table,
|
||
extraction.expected_rows.get(table))))
|
||
for i, table in enumerate([t for t in tables if grains.get(t)], 1):
|
||
jobs.append((f"P{i}", "pk_unique", f"主键唯一:「{table}」",
|
||
lambda t, table=table, i=i: _check_pk(sql, f"P{i}", t, table, grains[table])))
|
||
period = extraction.period
|
||
dated = [(t, spec[1]) for t, spec in list_period_columns(recipe).items() if spec[0] == "column" and t in tables]
|
||
for i, (table, column) in enumerate(dated, 1):
|
||
jobs.append((f"D{i}", "period_rows", f"「{table}」每行的「{column}」都在本期统计期内",
|
||
lambda t, table=table, column=column, i=i: _check_period_rows(
|
||
sql, f"D{i}", t, table, column, period, extraction.lineage)))
|
||
try:
|
||
return list(extraction.context_checks) + [_guarded(sql, *job) for job in jobs]
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def column_null_counts(db_path: str, recipe: Recipe) -> dict[str, dict[str, int]]:
|
||
"""表 → 列 → 空值行数。给 recipe_notes.build_notes 的 null_counts:只有本期真有空值的值列才写空值说明。
|
||
|
||
说明生成拿不到库(契约签名里没有库路径),Extraction 里也没有按列的空值来源,所以单独查一次。
|
||
"""
|
||
tables, _ = derive_tables(recipe)
|
||
out: dict[str, dict[str, int]] = {}
|
||
conn = open_checked_sqlite(db_path, readonly=True)
|
||
try:
|
||
for table, cols in tables.items():
|
||
names = [c.name for c in cols]
|
||
row = conn.execute(
|
||
f"SELECT COUNT(*), {', '.join(f'COUNT({_q(n)})' for n in names)} FROM {_q(table)}").fetchone()
|
||
out[table] = {n: row[0] - row[i] for i, n in enumerate(names, 1)}
|
||
finally:
|
||
conn.close()
|
||
return out
|