报告里标识列的数不分千位(产线编码 1203 不再写成「1,203」):新增 engine/cell_format.plain_columns,按序看查询当时冻结的数据目录给源列标的度量类型(identifier / status / attribute 不分,flow / stock / ratio 分)、源列名像编码编号(CODE、ID、NO 整词或结尾、驼峰的 Id/No/Code、编码、编号、代码)、是分组键(列、别名、序号都认)而不是聚合;SUM、COUNT、AVG 这类聚合是量,MIN、MAX 按那一列判;判断不了的照旧分千位。只看查询快照和它指向的表结构快照(内容寻址),组装、流式渲染、写作目录、出口复核一致。单元格显示规则 3 版:真有格子因此改了写法的文档记 cell_display: 3、引用上记 plain;离线核对页认 3 版、照 plain 渲染(解析不了 SQL),老文档按记下的版本复核照旧分千位。样本里订单号(order_no)随之不分组,check-bundle 跟着改

This commit is contained in:
ThearchyHelios
2026-10-19 17:19:23 +08:00
parent baf93ddfed
commit e9bb0afd4c
6 changed files with 380 additions and 26 deletions
+9 -6
View File
@@ -615,11 +615,14 @@
/**
* render_cell(shown(raw)):快照里的一格。kind 是查询时记下的列类型,display 是报告文档记下的单元格显示规则版本
* (2 版起 ISO 日期时间的 T 换成空格)。DECIMAL 落成的数字文本按数渲染、不经 float;文字压成一行,竖线换成 ¦
* (2 版起 ISO 日期时间的 T 换成空格;3 版起标识列的数不分千位)。plain 是引用上记下的「这一格按标识列显示」:
* 哪几列是标识列服务端按 SQL 和冻结的数据目录判(cell_format.plain_columns),本页解析不了 SQL,照引用上记的渲染——
* 它只决定分不分千位,值本身照样逐位比对。DECIMAL 落成的数字文本按数渲染、不经 float;文字压成一行,竖线换成 ¦
*/
function renderCell(raw, token, conv, kind, display) {
if (display !== 1 && display !== 2) throw new Unshown(T.shownDisplay(display))
function renderCell(raw, token, conv, kind, display, plain = false) {
if (display !== 1 && display !== 2 && display !== 3) throw new Unshown(T.shownDisplay(display))
if (!PY_NUMERIC || !ISO_DATETIME) throw new Unshown(T.shownOld)
const fmt = display >= 3 && plain === true ? 'plain' : 'thousands'
let value = raw
if (display >= 2 && typeof raw === 'string') {
const m = ISO_DATETIME.exec(pyStrip(raw))
@@ -630,12 +633,12 @@
// Python 把全角、阿拉伯文这些数字也当数(int('1٢٣') 是 123),本页只照 ASCII 算
if (/[^\x00-\x7f]/.test(text)) throw new Unshown(T.shownDigits)
if (!/^-?\d+$/.test(text) || text.replace('-', '').length <= INT_DIGITS) {
return renderNumeric(decimalOf(text), '', null, 'thousands', conv)
return renderNumeric(decimalOf(text), '', null, fmt, conv)
}
}
// 对象、列表:服务端按 Python 的 json.dumps 写(键的先后、浮点的写法),本页没有移植
if (isObj(value) || Array.isArray(value)) throw new Unshown(T.shownShape)
return renderNumber(value, token, { conv }).replace(/\|/g, '¦')
return renderNumber(value, token, { conv, fmt }).replace(/\|/g, '¦')
}
/**
@@ -1469,7 +1472,7 @@
if (!sameValue(cite.value, info.truth)) return { ...info, result: FAIL, reason: T.numCellDiffers }
const display = Object.prototype.hasOwnProperty.call(doc, 'cell_display') ? doc.cell_display : 1
return checkShown(info, seg, () => renderCell(cell.raw, info.truthToken, convOf(cite),
columnKind(art.json, cell.column), display))
columnKind(art.json, cell.column), display, own(cite, 'plain') === true))
}
/** 引用上记的换算(|万、|pct 这类),没写是 null */
+183
View File
@@ -0,0 +1,183 @@
"""查询结果里哪几列是标识列:报告里显示这几列的数不分千位(evidence.render_cell 的 plain,单元格显示规则 3 版起)。
部署上撞过:产线编码这类数值型的编码列(编码 1203)在报告的表格里写成「1,203」,读的人以为是个量。千分位只该给量用。
按序看三样信号,第一样说得出的算数;都说不出的照旧分千位:
1. 查询当时冻结的数据目录(查询快照的 schema_artifact 指向的表结构快照里的 catalog)给源列标的度量类型:identifier
(标识)、status(状态)、attribute(属性)不分千位;flow(流量)、stock(存量)、ratio(比率)是量,分千位;
2. 结果列取自的源列名像编码、编号:CODE、ID、NO 整个词或者在结尾(STATION_CODE、order_id、userNo),带「编码」「编号」
「代码」的;
3. 结果列是分组键(GROUP BY 里有它,写列、写别名、写序号都算),不是聚合。
SUM、COUNT、AVG 这类聚合出来的列是量,分千位,不看后面几样;MIN、MAX 取的是那一列本身的值,按那一列判。SQL 解析不了、
结果列在 SQL 里找不到(SELECT *、UNION)的,只按结果列名本身和目录里唯一那张表判。
只看查询快照(SQL、列名)和它指向的表结构快照(都按内容寻址、不会变):同一份快照什么时候判都是同一个结果,组装、
流式渲染、出口复核一致。离线核对页(bundle/verifier.js)没法解析 SQL,照引用上记下的 plain 渲染(见 evidence._resolve_cell)。
"""
from __future__ import annotations
import re
from collections.abc import Callable, Mapping
from typing import Any
from app.data.names import name_key
#: 目录里不是量的度量类型:这些列的数是编号、码值、属性,不分千位
PLAIN_MEASURES = frozenset({"identifier", "status", "attribute"})
#: 目录里是量的度量类型
COUNTED_MEASURES = frozenset({"flow", "stock", "ratio"})
#: 列名像编码、编号:CODE / ID / NO 整个词、或者以 _CODE、_ID、_NO 结尾(不分大小写);驼峰的 stationCode、orderId、userNo;
#: 带「编码」「编号」「代码」的
_CODE_NAME = re.compile(r"(?i:(?:^|_)(?:code|id|no)$)|[a-z0-9](?:Code|Id|ID|No|NO)$|编码|编号|代码")
#: 解析 SQL 按这几种读法依次试(快照里没记是哪种库):先通用读法,读不通再换,取第一种读得通的——同一条 SQL 结果固定
_READINGS = (None, "mysql", "postgres", "oracle", "sqlite")
def code_like(name: Any) -> bool:
"""列名像编码、编号(_CODE_NAME)。"""
return isinstance(name, str) and bool(_CODE_NAME.search(name.strip()))
def plain_columns(snapshot: Mapping[str, Any], schema: Callable[[str], Any] | None = None) -> frozenset[str]:
"""查询快照里要按标识列显示(数不分千位)的结果列名(照快照 columns 的写法)。
schema 取表结构快照的内容(参数是 schema_artifact,取不到返回 None),用来读冻结的数据目录;不给就不看目录。
不抛异常:分析出了意外就当判断不了(照旧分千位)。"""
columns = [str(c) for c in snapshot.get("columns") or []]
if not columns:
return frozenset()
try:
catalog = _frozen_catalog(snapshot, schema)
select = _outer_select(snapshot.get("sql"))
return frozenset(c for c in columns if _plain(c, select, catalog))
except Exception: # noqa: BLE001 - 判断不了就照旧分千位,不能让报告渲染崩掉
return frozenset()
def _frozen_catalog(snapshot: Mapping[str, Any], schema: Callable[[str], Any] | None) -> dict[str, dict[str, str]]:
"""冻结的数据目录里各列的度量类型:{表名的 name_key: {列名的 name_key: 度量类型}}。表名带库名的,最后一段也记一份。"""
artifact = snapshot.get("schema_artifact")
content = schema(artifact) if schema is not None and isinstance(artifact, str) and artifact else None
frozen = content.get("catalog") if isinstance(content, dict) else None
out: dict[str, dict[str, str]] = {}
for table, entry in (frozen or {}).items() if isinstance(frozen, dict) else ():
notes = entry.get("notes") if isinstance(entry, dict) else None
cols = notes.get("columns") if isinstance(notes, dict) else None
measures = {name_key(str(c)): str(item["measure"]["value"]) for c, item in (cols or {}).items()
if isinstance(item, dict) and isinstance(item.get("measure"), dict)
and isinstance(item["measure"].get("value"), str)}
if measures:
for key in {name_key(str(table)), name_key(str(table).rsplit(".", 1)[-1])}:
out.setdefault(key, {}).update(measures)
return out
def _outer_select(sql: Any) -> Any:
"""最外层的 SELECT(sqlglot 的节点);解析不了、最外层不是一个 SELECT(UNION 这类)的为 None。"""
from sqlglot import exp
from app.data.sql_semantics import _parse
for read in _READINGS:
root, _ = _parse(sql, read)
if root is not None:
return root if isinstance(root, exp.Select) else None
return None
def _plain(column: str, select: Any, catalog: dict[str, dict[str, str]]) -> bool:
from sqlglot import exp
item = _projection(select, column) if select is not None else None
if item is None:
# SELECT *、UNION、解析不了:只看结果列名本身,目录按唯一那张表找
tables = _tables(select) if select is not None else {}
measure = _measure(catalog, next(iter(tables.values())) if len(tables) == 1 else None, column)
return measure in PLAIN_MEASURES if measure else code_like(column)
value = _unwrap(item.unalias())
if isinstance(value, exp.AggFunc) and not isinstance(value, (exp.Min, exp.Max, exp.AnyValue)):
return False # SUM / COUNT / AVG……:量
if isinstance(value, (exp.Min, exp.Max, exp.AnyValue)):
value = _unwrap(value.this)
if isinstance(value, exp.Column):
tables = _tables(select)
table = tables.get(name_key(value.table)) if value.table else \
(next(iter(tables.values())) if len(tables) == 1 else None)
measure = _measure(catalog, table, value.name)
if measure in PLAIN_MEASURES:
return True
if measure in COUNTED_MEASURES:
return False
if code_like(value.name):
return True
return _grouped(select, item)
def _unwrap(node: Any) -> Any:
from sqlglot import exp
while isinstance(node, (exp.Paren, exp.Cast, exp.TryCast)):
node = node.this
return node
def _projection(select: Any, column: str) -> Any:
"""结果列 column 对应的那一项选取(按别名或列名,先一字不差、再按 name_key)。找不到、或者对上不止一项的为 None。"""
items = [p for p in select.expressions if p.alias_or_name]
for same in (lambda a: a == column, lambda a: name_key(a) == name_key(column)):
hits = [p for p in items if same(p.alias_or_name)]
if hits:
return hits[0] if len(hits) == 1 else None
return None
def _tables(select: Any) -> dict[str, str]:
"""这一层 FROM / JOIN 里的实表:{别名或表名的 name_key: 表名(带库名的照写)}。子查询、CTE 不算实表。"""
from sqlglot import exp
with_ = select.args.get("with_") or select.args.get("with")
ctes = {name_key(c.alias_or_name) for c in with_.expressions} if with_ is not None else set()
sources = [(select.args.get("from_") or select.args.get("from"))]
sources += [j for j in select.args.get("joins") or []]
out: dict[str, str] = {}
for source in sources:
table = getattr(source, "this", None)
if not isinstance(table, exp.Table) or name_key(table.name) in ctes:
continue
name = ".".join(p for p in (table.db, table.name) if p)
out[name_key(table.alias_or_name)] = name
return out
def _measure(catalog: dict[str, dict[str, str]], table: str | None, column: str) -> str | None:
if not table or not catalog:
return None
for key in (name_key(table), name_key(table.rsplit(".", 1)[-1])):
if key in catalog:
return catalog[key].get(name_key(column))
return None
def _grouped(select: Any, item: Any) -> bool:
"""这一项选取是分组键:GROUP BY 里写了同样的表达式、它的别名,或者它的序号(GROUP BY 1)。"""
from sqlglot import exp
group = select.args.get("group")
if group is None:
return False
value = _unwrap(item.unalias())
position = next((k for k, p in enumerate(select.expressions, start=1) if p is item), None)
alias = name_key(item.alias) if item.alias else None
for key in group.expressions:
key = _unwrap(key)
if isinstance(key, exp.Literal) and key.is_int and int(key.this) == position:
return True
if alias and isinstance(key, exp.Column) and not key.table and name_key(key.name) == alias:
return True
if key == value:
return True
return False
__all__ = ["COUNTED_MEASURES", "PLAIN_MEASURES", "code_like", "plain_columns"]
+39 -9
View File
@@ -79,9 +79,11 @@ ENTITY_SYNTAXES = frozenset({1, 2})
#: eid、证据下钻都不变
#: - 3:整表标记 rows= 的结束行越界、起始行在范围内时,截到最后一行(以前整张表算解析不了)。截完照样展开成一格格的
#: 单元格引用、按格复核,所以这一条只影响组装;复核 3 版以前的文档时,没展开的整表标记照老规矩判,不然老文档里那句
#: 「没有第 N 行」重新解析得了,会被当成文档被改过(render_mismatch)
#: 「没有第 N 行」重新解析得了,会被当成文档被改过(render_mismatch)。另外标识列(编码、编号、分组键,见
#: cell_format.plain_columns)的数不分千位:编码 1203 不写成「1,203」。真有格子因此改了写法的文档记 cell_display: 3,
#: 引用上记 plain: true(离线核对页解析不了 SQL,照它渲染)
#: 新组装的文档里真有格子因为显示规则改了写法时才记 cell_display(参与内容哈希),记的是渲染得出这些格子的最低一版
#: (只有日期时间被改写的记 2:3 版只多了组装时的整表截行,不改格子的写法),没有的文档和以前一字不差。复核必须按
#: (只有日期时间被改写的记 2:标识列没有因此改写法的,3 版的整表截行不改格子的写法),没有的文档和以前一字不差。复核必须按
#: 文档生成时的那一版:老文档存的是带 T 的字,拿新规则重新渲染对不上,会被当成文档被改过(render_mismatch)
CELL_DISPLAY = 3
CELL_DISPLAYS = frozenset({1, 2, 3})
@@ -704,10 +706,11 @@ def render_metric(metric: dict[str, Any], conv: str | None = None) -> str:
)
def render_cell(value: Any, conv: str | None = None, *, kind: str | None = None) -> str:
def render_cell(value: Any, conv: str | None = None, *, kind: str | None = None, plain: bool = False) -> str:
"""查询快照里的一格渲染成字。
- 数按千分位,小数位照值本来的样子(最多 10 位、去尾零);DECIMAL 落盘后的数字文本
- 数按千分位,小数位照值本来的样子(最多 10 位、去尾零);plain 时不分组(标识列:编码 1203 不写成「1,203」,
哪几列是标识列见 cell_format.plain_columns)。DECIMAL 落盘后的数字文本
("45678.50",小数位为 0 的 "45678",规则见 expressions.cell_value)按数算,用 Decimal
渲染,不经 float 丢精度
- kind 是这一列查询时记下的类型:text 列按文本,VARCHAR 的 "2026" 不渲染成「2,026」;
@@ -715,16 +718,17 @@ def render_cell(value: Any, conv: str | None = None, *, kind: str | None = None)
- NULL 显示「—」:这是快照里的事实(那一格就是空的),不是缺值,更不是 0
- 文本压成一行,竖线换成 ¦:它常常落在表格里,原样的 | 会多切出一格
"""
fmt = "plain" if plain else DEFAULT_FORMAT
if isinstance(value, str) and cell_value(value, kind) is not value:
try:
return _render_numeric(Decimal(value.strip()), unit="", decimals=None, fmt=DEFAULT_FORMAT, conv=conv)
return _render_numeric(Decimal(value.strip()), unit="", decimals=None, fmt=fmt, conv=conv)
except RenderError:
raise
except (ArithmeticError, ValueError) as e:
raise RenderError(f"「{value}」无法按规则显示") from e
if isinstance(value, (dict, list)):
value = json.dumps(value, ensure_ascii=False, default=str)
return render_number(value, fmt=DEFAULT_FORMAT, conv=conv).replace("|", "¦")
return render_number(value, fmt=fmt, conv=conv).replace("|", "¦")
# --------------------------------------------------------------------------
@@ -1321,6 +1325,19 @@ class _Snapshots:
#: 这一次解析里的单元格要按哪一版显示规则才渲染得出来(有格子因为某一版的规则改了写法,就至少是那一版):
#: 组装时据此决定文档记不记、记哪一版 cell_display
self.needed = 1
#: 每份查询快照里的标识列(cell_format.plain_columns),按快照的工件 id 记
self.plains: dict[str, frozenset[str]] = {}
def plain(self, artifact: Any, snapshot: Any) -> frozenset[str]:
"""这份查询快照里按标识列显示(数不分千位)的结果列。3 版以前的显示规则没有这一条,一律分千位。"""
if self.display < 3 or not isinstance(snapshot, dict):
return frozenset()
key = str(artifact or "")
if key not in self.plains:
from app.engine.cell_format import plain_columns
self.plains[key] = plain_columns(snapshot, lambda a: self._fetch(a, "表结构快照")[0])
return self.plains[key]
def shown(self, raw: Any) -> Any:
"""按显示规则要显示的原值:2 版起 ISO 日期时间的 T 换成空格。没改就是原值本身。"""
@@ -1343,6 +1360,7 @@ class _Snapshots:
return self
twin = _Snapshots(self.loader, entities=self.entities_on, syntax=syntax, display=display)
twin.memo = self.memo
twin.plains = self.plains
return twin
def get(self, alias: str, artifact: Any) -> tuple[Any, str | None]:
@@ -1441,7 +1459,14 @@ def _resolve_cell(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snaps
try:
raw, column = locate_cell(snapshot, parsed["row"], parsed["column"])
kind = column_kind(snapshot, column)
rendered = render_cell(snaps.shown(raw), conv, kind=kind)
shown = snaps.shown(raw)
rendered = render_cell(shown, conv, kind=kind)
plain = column in snaps.plain(entry.get("artifact"), snapshot) \
and render_cell(shown, conv, kind=kind, plain=True) != rendered
if plain:
# 标识列(3 版起):数不分千位。真改了写法才记——文档的 cell_display 记成 3,引用上记 plain,离线核对页照它渲染
rendered = render_cell(shown, conv, kind=kind, plain=True)
snaps.needed = max(snaps.needed, 3)
except CellError as e:
return _unresolved(marker, alias, "cell", f"{alias} {e}")
except RenderError as e:
@@ -1452,6 +1477,8 @@ def _resolve_cell(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snaps
"rendered": rendered}
if conv:
cite["conv"] = conv
if plain:
cite["plain"] = True
return cite
@@ -3636,6 +3663,7 @@ def _query_prompt(entry: dict[str, Any], snaps: _Snapshots, cells_allowed: bool,
return lines
columns = [str(c) for c in snapshot.get("columns") or []][:_PROMPT_COLS]
rows = snapshot["rows"]
plain = snaps.plain(entry.get("artifact"), snapshot)
example = None
for r, record in enumerate(rows[:shown_rows]):
cells = []
@@ -3643,7 +3671,8 @@ def _query_prompt(entry: dict[str, Any], snaps: _Snapshots, cells_allowed: bool,
try:
raw, _ = locate_cell(snapshot, r, c)
kind = column_kind(snapshot, c)
shown, numeric = render_cell(snaps.shown(raw), kind=kind), is_number(cell_value(raw, kind))
shown = render_cell(snaps.shown(raw), kind=kind, plain=c in plain)
numeric = is_number(cell_value(raw, kind))
except (CellError, RenderError):
shown, numeric = MISSING, False
cells.append(f"{c}={shown}")
@@ -3798,7 +3827,8 @@ def _field_prompt(path: str, value: Any, ev: dict[str, Any], aliases: dict[str,
if why:
raise CellError(why)
raw, column = locate_cell(snapshot, row, column)
shown = render_cell(snaps.shown(raw), kind=column_kind(snapshot, column))
shown = render_cell(snaps.shown(raw), kind=column_kind(snapshot, column),
plain=column in snaps.plain(ev.get("artifact"), snapshot))
except (CellError, RenderError):
try:
shown = render_cell(value)
+26 -10
View File
@@ -60,7 +60,7 @@ function render(c) {
try {
const value = v.parseJson(c.json)
const token = v.tokenAt(v.numberTokens(c.json), [])
if (c.cell) return { shown: v.renderCell(value, token, c.conv, c.kind, c.display) }
if (c.cell) return { shown: v.renderCell(value, token, c.conv, c.kind, c.display, c.plain) }
const decimals = v.parseJson(c.decimals)
return { shown: v.renderNumber(value, token, { unit: c.unit, decimals,
decimalsToken: v.tokenAt(v.numberTokens(c.decimals), []), fmt: c.format, conv: c.conv }) }
@@ -137,9 +137,10 @@ def py_number(value: Any, unit: str, decimals: Any, fmt: Any, conv: Any) -> str
return None
def py_cell(raw: Any, conv: Any, kind: Any, display: int) -> str | None:
def py_cell(raw: Any, conv: Any, kind: Any, display: int, plain: bool = False) -> str | None:
try:
return render_cell(_Snapshots(display=display).shown(raw), conv, kind=kind)
# 3 版起标识列的数不分千位:服务端按 SQL 判(cell_format),引用上记 plain,页面照它渲染
return render_cell(_Snapshots(display=display).shown(raw), conv, kind=kind, plain=plain and display >= 3)
except RenderError:
return None
@@ -190,9 +191,9 @@ def cell_cases() -> list[dict[str, Any]]:
text = json.dumps(raw, ensure_ascii=False)
for kind in CELL_KINDS:
for conv in CELL_CONVS:
for display in (1, 2):
for display, plain in ((1, False), (2, False), (3, False), (3, True), (2, True)):
cases.append({"cell": True, "json": text, "kind": kind, "conv": conv, "display": display,
"py": py_cell(json.loads(text), conv, kind, display)})
"plain": plain, "py": py_cell(json.loads(text), conv, kind, display, plain)})
return cases
@@ -331,7 +332,8 @@ def test_every_format_in_the_monthly_sample_passes_as_exported():
"m:conv_rate": "2.35%", "m:growth": "-8.2%", "m:orders": "12840单", "m:avg": "5.81元", "m:avg|.3": "5.811元",
"m:avg|int": "6元", "m:share|pct": "34.6%", "m:export": "15,230.4美元",
"v:Q1.r0.region": "华南", "v:Q1.r0.gmv": "45,678.5", "v:Q1.r0.gmv|万": "4.57万", "v:Q1.r1.region": "华东",
"v:Q1.r1.gmv": "-1,200.75", "v:Q1.r0.order_no": "202,609,071,234,567,890",
# 订单号是标识列(列名以 _no 结尾):3 版显示规则起不分千位,超过 2^53 也一位不差
"v:Q1.r1.gmv": "-1,200.75", "v:Q1.r0.order_no": "202609071234567890",
"v:Q1.r0.paid_at": "2026-09-07 08:21:39",
}
@@ -353,8 +355,8 @@ def test_every_format_in_the_monthly_sample_passes_as_exported():
("v:Q1.r0.gmv", "54,678.5", "45678.5", "45,678.5"), # DECIMAL 落成的数字文本
("v:Q1.r1.gmv", "1,200.75", "-1200.75", "-1,200.75"), # 负数单元格
("v:Q1.r0.gmv|万", "5.57万", "45678.5", "4.57万"), # 单元格换算成万
("v:Q1.r0.order_no", "202,609,071,234,567,891", "202609071234567890",
"202,609,071,234,567,890"), # 超过 2^53 的整数
# 超过 2^53 的整数;标识列不分组,应显示的字和引用的值一样,原因里不另写
("v:Q1.r0.order_no", "202609071234567891", "202609071234567890", None),
("v:Q1.r0.paid_at", "2026-09-08 08:21:39", "2026-09-07T08:21:39", "2026-09-07 08:21:39"), # 日期时间
])
def test_changing_only_the_shown_text_fails_and_names_both(ref, text, cited, expected):
@@ -391,13 +393,27 @@ def test_a_format_the_page_does_not_know_cannot_pass(ref, cite, text):
assert hit["result"] == "na" and hit["reason"].startswith("无法核对显示文字:"), hit
def test_identifier_cells_read_without_separators_and_the_page_follows():
"""标识列(订单号这类编号,列名以 _no 结尾)的数不分千位:文档记 cell_display: 3、引用上记 plain,页面照它渲染、
原样通过;把 plain 去掉的伪造页,页面按千分位渲染,和正文显示的字对不上,不通过。"""
from tests.bundle_samples import formats_bundle
bundle = formats_bundle()
art, doc = doc_of(bundle)
seg = segment(doc, "v:Q1.r0.order_no")
assert doc["cell_display"] == 3 and seg["text"] == "202609071234567890" and seg["cite"]["plain"] is True
assert item(verdicts(bundle), "v:Q1.r0.order_no")["result"] == "pass"
seg["cite"].pop("plain")
assert item(verdicts(reseal(bundle, art, doc)), "v:Q1.r0.order_no")["result"] == "fail"
def test_an_unknown_cell_display_version_cannot_pass():
"""文档记下的单元格显示规则版本认不出(服务端复核记 bad_schema):单元格的显示文字判无法核对;口径卡的照常核对。"""
from tests.bundle_samples import formats_bundle
bundle = formats_bundle()
art, doc = doc_of(bundle)
doc["cell_display"] = 3
doc["cell_display"] = 4
got = verdicts(reseal(bundle, art, doc))
cells = [n for n in got["numbers"] if n["ref"].startswith("v:")]
assert cells and all(n["result"] == "na" and n["reason"].startswith("无法核对显示文字:") for n in cells)
@@ -521,7 +537,7 @@ def test_numeric_text_cells_show_their_own_digits_in_the_panel():
table["rows"][0][2] = "202609071234567891"
got = verdicts(formats_bundle(table=table))
order = item(got, "v:Q1.r0.order_no")
assert order["result"] == "pass" and order["text"] == "202,609,071,234,567,891", order
assert order["result"] == "pass" and order["text"] == "202609071234567891", order
assert order["truthText"] == "202609071234567891", order
assert item(got, "v:Q1.r0.gmv")["truthText"] == "45678.50"
# 不是数字文本的格子不另写原文:照旧按值写
@@ -0,0 +1,121 @@
"""报告里标识列的数不分千位(engine/cell_format.plain_columns,单元格显示规则 3 版)。
部署上撞过:产线编码这类数值型的编码列在报告的表格里写成「1,203」,读的人以为是个量。现在按序看三样信号:查询当时冻结的
数据目录给源列标的度量类型(identifier / status / attribute 不分,flow / stock / ratio 分)、源列名像编码编号(CODE、
_ID、ID、NO、编码、编号、代码)、是分组键而不是聚合;SUM、COUNT、AVG 出来的是量,照旧分千位;判断不了的照旧分千位。
真有格子因此改了写法的文档记 cell_display: 3、引用上记 plain;老文档按记下的版本复核,照旧分千位。数据是虚构的。
"""
from __future__ import annotations
import pytest
from app.core import artifact_store
from app.engine import evidence as ev
from app.engine.cell_format import code_like, plain_columns
from app.engine.evidence import build_catalog, compose_doc, verify_doc, writer_catalog
SCHEMA = {"source": "plant", "tables": {"plant_output": {"columns": []}},
"catalog": {"plant_output": {"version": 2, "notes": {"columns": {
"batch": {"measure": {"value": "identifier", "source": "human", "status": "confirmed"}},
"capacity": {"measure": {"value": "stock", "source": "human", "status": "confirmed"}}}}}}}
SQL = ("SELECT p.STATION_CODE AS 产线, YEAR(p.made_on) AS 年份, p.capacity AS 产能, SUM(p.qty) AS 产量, "
"COUNT(*) AS 工单数, MAX(p.batch) AS 批次 FROM plant_output p "
"GROUP BY p.STATION_CODE, YEAR(p.made_on), p.capacity")
COLUMNS = ["产线", "年份", "产能", "产量", "工单数", "批次"]
def _store(obj: dict) -> str:
text = artifact_store.canonical_json(obj)
artifact = artifact_store.content_hash(text)
path = artifact_store._path_of(artifact)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return artifact
def _snap(schema_artifact: str) -> dict:
return {"columns": COLUMNS, "column_types": {c: "number" for c in COLUMNS},
"rows": [[1203, 2031, 7001, 15200, 1450, 88001], [1207, 2031, 6400, 9300, 1210, 88002]],
"row_count": 2, "truncated": False, "elapsed_ms": 2, "sql": SQL, "source": "plant",
"schema_artifact": schema_artifact}
AMOUNTS = {"columns": ["金额"], "column_types": {"金额": "number"}, "rows": [[12345]], "row_count": 1,
"truncated": False, "elapsed_ms": 1, "sql": "SELECT o.amount * 2 AS 金额 FROM orders o", "source": "plant"}
def _query(artifact: str, columns: list[str], call: str) -> dict:
return {"kind": "query", "node_id": "fetch", "exec": 1, "artifact": artifact, "via": "b" * 64, "call_id": call,
"tool": "db_query__plant", "source": "plant", "columns": columns, "rows": 2, "truncated": False}
@pytest.fixture
def catalog():
snap = _store(_snap(_store(SCHEMA)))
ledger = [_query(snap, COLUMNS, "call_1"), _query(_store(AMOUNTS), ["金额"], "call_2")]
return build_catalog(nodes={}, ledger=ledger, inputs={})
TEXT = ("[[v:Q1.r0.年份]] 年 [[v:Q1.r0.产线]] 线产量 [[v:Q1.r0.产量]],工单 [[v:Q1.r0.工单数]] 张,产能 [[v:Q1.r0.产能]],"
"批次 [[v:Q1.r0.批次]];另一项金额 [[v:Q2.r0.金额]]。[[see:Q1,Q2]]")
def _shown(doc) -> dict[str, str]:
return {s["ref"]: s["text"] for b in doc["blocks"] for u in b["units"] for s in u["segments"]
if s.get("ref", "").startswith("v:")}
def test_identifier_columns_read_without_separators(catalog):
doc = compose_doc(TEXT, catalog)
assert doc["violations"] == [], doc["violations"]
shown = _shown(doc)
assert shown["v:Q1.r0.产线"] == "1203" # 源列名像编码(STATION_CODE)
assert shown["v:Q1.r0.年份"] == "2031" # 分组键
assert shown["v:Q1.r0.批次"] == "88001" # 目录标的是标识(MAX 取的是那一列本身的值)
assert shown["v:Q1.r0.产能"] == "7,001" # 分组键,但目录标的是存量:是量,照旧分千位
assert shown["v:Q1.r0.产量"] == "15,200" # SUM:量
assert shown["v:Q1.r0.工单数"] == "1,450" # COUNT:量
assert shown["v:Q2.r0.金额"] == "12,345" # 判断不了:照旧分千位
assert doc["cell_display"] == 3
plain = {s["ref"] for b in doc["blocks"] for u in b["units"] for s in u["segments"]
if (s.get("cite") or {}).get("plain")}
assert plain == {"v:Q1.r0.产线", "v:Q1.r0.年份", "v:Q1.r0.批次"}
assert verify_doc(doc, catalog)["ok"]
def test_tables_and_the_writer_catalog_read_the_same_way(catalog):
doc = compose_doc("[[table:Q1 cols=产线,产量 rows=0-1]]", catalog)
assert doc["violations"] == [] and "| 1203 | 15,200 |" in doc["markdown"] and "| 1207 | 9,300 |" in doc["markdown"]
text = writer_catalog(catalog).text
assert "产线=1203" in text and "产量=15,200" in text, text
def test_documents_composed_before_still_verify_with_separators(catalog, monkeypatch):
monkeypatch.setattr(ev, "CELL_DISPLAY", 2)
old = compose_doc(TEXT, catalog)
monkeypatch.undo()
assert _shown(old)["v:Q1.r0.产线"] == "1,203" and "cell_display" not in old
assert verify_doc(old, catalog)["ok"], "按文档记下的版本复核:照旧分千位,不判成被改过"
def test_a_document_edited_back_to_separators_is_caught(catalog):
doc = compose_doc("产线 [[v:Q1.r0.产线]]。", catalog)
seg = next(s for b in doc["blocks"] for u in b["units"] for s in u["segments"] if s.get("ref") == "v:Q1.r0.产线")
start = seg["span"][0]
doc["markdown"] = doc["markdown"].replace("1203", "1,203")
seg["text"], seg["span"] = "1,203", [start, start + 5]
assert "render_mismatch" in {v["code"] for v in verify_doc(doc, catalog)["violations"]}
@pytest.mark.parametrize("name, yes", [
("STATION_CODE", True), ("order_id", True), ("ID", True), ("user_no", True), ("orderId", True), ("stationCode", True),
("产线编码", True), ("工单编号", True), ("物料代码", True),
("qty", False), ("piano", False), ("video", False), ("income", False), ("产量", False),
])
def test_code_like_names(name, yes):
assert code_like(name) is yes
def test_unparseable_sql_falls_back_to_the_column_name():
snap = {"columns": ["station_id", "total"], "sql": "this is not sql", "rows": []}
assert plain_columns(snap) == frozenset({"station_id"})
+2 -1
View File
@@ -864,7 +864,8 @@ await section('样本页:各种形状', async () => {
check('formats:点开换算成万的数字,引用的值、口径卡里的值照原值写,注明与按显示规则渲染的结果一致',
detail.includes('74613.65') && detail.includes('与按显示规则渲染的结果一致') && !detail.includes('按显示规则应为'),
detail.replace(/\s+/g, ' ').slice(0, 160))
const order = page.locator('button.num').filter({ hasText: '202,609,071,234,567,890' })
// 订单号是标识列(列名以 _no 结尾):3 版显示规则起不分千位
const order = page.locator('button.num').filter({ hasText: /^202609071234567890$/ })
detail = '找不到订单号'
let rowCell = ''
if (await order.count()) {