From 4dd6889e69947a2949856178367362d4f5cf94f5 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 21 Oct 2026 13:07:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=A5=E5=91=8A=E5=AF=BC=E5=87=BA=E8=AE=A4?= =?UTF-8?q?=E5=88=86=E6=9E=90=E7=BB=93=E8=AE=BA=EF=BC=9A=E7=82=B9=E5=BC=80?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E8=AF=AD=E3=80=81=E5=85=B7=E5=90=8D=E7=9A=84?= =?UTF-8?q?=E6=95=B0=E3=80=81=E7=BB=93=E6=9E=9C=E8=A1=A8=E7=9A=84=E6=A0=BC?= =?UTF-8?q?=EF=BC=8C=E9=9D=A2=E6=9D=BF=E4=BE=9D=E6=AC=A1=E6=98=AF=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=EF=BC=88=E5=88=A4=E5=AE=9A=E8=AF=AD=E3=80=81=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E6=A0=87=E5=87=86=E3=80=81=E7=82=B9=E5=90=8D=E7=9A=84?= =?UTF-8?q?=E6=AE=B5=E4=B8=8E=E6=96=B9=E5=90=91=E3=80=81=E8=BF=99=E4=B8=80?= =?UTF-8?q?=E5=A4=84=E5=BC=95=E7=94=A8=E7=9A=84=E6=98=AF=E5=93=AA=E4=B8=80?= =?UTF-8?q?=E9=A1=B9=E3=80=81=E8=B0=81=E5=81=9A=E7=9A=84=E3=80=81=E9=99=84?= =?UTF-8?q?=E5=B8=A6=E8=AF=B4=E6=98=8E=E3=80=81Agent=20=E5=86=99=E7=9A=84?= =?UTF-8?q?=E5=81=87=E8=AE=BE=EF=BC=89=E3=80=81=E6=96=B9=E6=B3=95=EF=BC=88?= =?UTF-8?q?=E6=96=B9=E6=B3=95=E4=B8=8E=E7=89=88=E6=9C=AC=E3=80=81=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E3=80=81=E5=AE=9E=E9=99=85=E7=94=A8=E7=9A=84=E9=98=88?= =?UTF-8?q?=E5=80=BC=E3=80=81=E7=8E=B0=E6=9F=A5=E7=9A=84=E8=A1=A8=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=EF=BC=89=E3=80=81=E8=AE=A1=E7=AE=97=EF=BC=88=E5=85=B3?= =?UTF-8?q?=E9=94=AE=E6=95=B0=E7=9A=84=E4=BB=A3=E5=85=A5=E5=BC=8F=E3=80=81?= =?UTF-8?q?=E5=85=B7=E5=90=8D=E7=9A=84=E6=95=B0=E3=80=81=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E8=A1=A8=EF=BC=8C=E5=BC=95=E7=94=A8=E7=9A=84=E6=A0=BC=E9=AB=98?= =?UTF-8?q?=E4=BA=AE=EF=BC=89=E3=80=81=E8=BE=93=E5=85=A5=EF=BC=88=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E3=80=81=E8=90=BD=E5=88=B0=E5=93=AA=E4=B8=AA=20Q?= =?UTF-8?q?=E3=80=81=E7=94=A8=E5=88=B0=E7=9A=84=E5=88=97=E5=92=8C=E8=A1=8C?= =?UTF-8?q?=E3=80=81=E6=80=BB=E6=95=B0=E9=82=A3=E4=B8=80=E6=A0=BC=E7=9A=84?= =?UTF-8?q?=E9=97=AD=E5=90=88=E6=A0=B8=E5=AF=B9=E3=80=81=E4=B8=8B=E9=92=BB?= =?UTF-8?q?=E8=87=AA=E5=93=AA=E4=B8=AA=20A=EF=BC=8C=E5=90=8E=E9=9D=A2?= =?UTF-8?q?=E6=8E=A5=E8=BE=93=E5=85=A5=E5=BF=AB=E7=85=A7=E7=9A=84=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E6=AD=A5=E9=AA=A4=EF=BC=89=E3=80=81=E5=A4=8D=E7=AE=97?= =?UTF-8?q?=E3=80=81=E5=B0=81=E5=AD=98=EF=BC=9B=E6=8C=87=E6=A0=87=E9=82=A3?= =?UTF-8?q?=E4=B8=80=E6=AD=A5=E5=8F=A6=E5=B1=95=E7=A4=BA=E4=B8=A4=E6=9C=9F?= =?UTF-8?q?=E5=8F=AF=E6=AF=94=E6=80=A7=E3=80=82=E6=8A=A5=E5=91=8A=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=B8=A6=E7=B3=BB=E7=BB=9F=E5=86=99=E7=9A=84=E3=80=8C?= =?UTF-8?q?=E5=88=86=E6=9E=90=E8=8C=83=E5=9B=B4=E3=80=8D=E6=97=B6=EF=BC=8C?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E5=90=8E=E9=9D=A2=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E8=BF=99=E4=B8=80=E6=AE=B5=EF=BC=88=E7=8E=B0?= =?UTF-8?q?=E8=B1=A1=E3=80=81=E6=8B=86=E8=BF=87=E6=8C=89=E4=B8=8B=E9=92=BB?= =?UTF-8?q?=E7=BC=A9=E8=BF=9B=E3=80=81=E6=8E=92=E9=99=A4=E3=80=81=E7=AE=97?= =?UTF-8?q?=E4=B8=8D=E4=BA=86=E3=80=81=E6=B2=A1=E6=8B=86=E3=80=81=E6=A3=80?= =?UTF-8?q?=E9=AA=8C=E6=AC=A1=E6=95=B0=EF=BC=89=EF=BC=8C=E7=BC=96=E5=8F=B7?= =?UTF-8?q?=E7=82=B9=E5=BE=97=E5=BC=80=E5=90=8C=E4=B8=80=E6=9D=A1=E5=87=BA?= =?UTF-8?q?=E5=A4=84=E9=93=BE=EF=BC=9B=E6=B2=A1=E6=9C=89=E8=BF=99=E4=B8=AA?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E7=9A=84=E6=96=87=E6=A1=A3=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E5=AD=97=E9=83=BD=E4=B8=8D=E5=A4=9A=E3=80=82=E9=A1=BA=E6=89=8B?= =?UTF-8?q?=E4=BF=AE=E6=8E=89=E6=9F=A5=E8=AF=A2=E6=AD=A5=E9=AA=A4=E3=80=8C?= =?UTF-8?q?=E5=85=B1=20N=20=E8=A1=8C=E3=80=8D=E9=82=A3=E4=B8=80=E8=A1=8C?= =?UTF-8?q?=E6=8A=8A=E5=87=BD=E6=95=B0=E5=8E=9F=E6=96=87=E5=BD=93=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E6=98=BE=E7=A4=BA=E7=9A=84=E8=80=81=E6=AF=9B=E7=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/report_export/build.py | 63 ++++++++- backend/app/report_export/runtime.js | 172 +++++++++++++++++++++++- backend/app/report_export/template.html | 9 +- 3 files changed, 235 insertions(+), 9 deletions(-) diff --git a/backend/app/report_export/build.py b/backend/app/report_export/build.py index cf353fc6..7cbbb079 100644 --- a/backend/app/report_export/build.py +++ b/backend/app/report_export/build.py @@ -4,7 +4,9 @@ 执行脚本后,带出处的数字、无证据的数字、可疑的表名字段名都点得开——面板里是网页上证据面板同一个拼法的 出处链(api/evidence._chain):指标步骤(口径卡、口径与版本、原式、代入式、输入、复算、双盲复算)、 输入步骤(值取自哪个节点、哪次查询的哪一格)、查询步骤(SQL、被引用的行、遮罩列、SQL 检查), -以及运行输入、表名字段名、引文各自的步骤。封存状态照网页上的样子逐项标明(已封存 / 工件哈希核对)。 +以及运行输入、表名字段名、引文各自的步骤;分析结论(判定语、具名的数、结果表的格)点开是判定 → 方法 → 计算 → 输入 → +复算 → 封存。封存状态照网页上的样子逐项标明(已封存 / 工件哈希核对)。报告文档带着系统写的「分析范围」 +(analysis_scope)时,正文后面另渲染这一段,编号点得开同一条出处链;没有这个字段的文档一个字都不多。 这份文件不是核对件:口径、出处和封存状态都是导出那一刻系统里的记录,拿去和系统对照要用封存哈希 (可验证报告,app/bundle)。页面页脚写明这一点。 @@ -77,7 +79,7 @@ _STATE_CLASS = { "deterministic": "ok", "none": "na", "suspect": "bad", "unverified": "bad", "unjudged": "na", "candidate": "na", } -_KIND_LABEL = {"number": "数字", "value": "取值", "entity": "名称", "quote": "引文"} +_KIND_LABEL = {"number": "数字", "value": "取值", "entity": "名称", "quote": "引文", "analysis": "判定"} def _esc(text: Any) -> str: @@ -293,6 +295,61 @@ def render_body(doc: Mapping[str, Any]) -> str: return f'
{"".join(_render_block(b) for b in blocks)}
' +#: 「分析范围」各组的叫法(00-总方案 §3.6),按这个顺序写 +SCOPE_TITLES = (("phenomena", "现象"), ("decomposed", "拆过"), ("excluded", "排除"), ("refused", "算不了")) +SCOPE_NOTE = "这一段由系统按这次运行的分析结论写成,写作者改不了;点开编号看判定标准、计算、输入和复算。" +#: 分析范围里一个编号的片段键(api/evidence.SCOPE_REF 同一个写法):点开时按它找出处链 +_SCOPE_REF = "scope:{alias}" + + +def _scope_item(item: Mapping[str, Any], *, depth: int = 0) -> str: + """分析范围里的一条:编号(按钮,点开出处链)、维度、下钻自哪一条、判定语或算不了的原因。""" + alias = str(item.get("alias") or "") + title = f"分析结论 {_esc(alias)}:点开查看出处" + button = (f'") if alias else "" + dims = [str(d) for d in item.get("dims") or [] if d] + bits = ([f"按{'、'.join(dims)}"] if dims else []) + ([f"下钻自 {item['parent']}"] if item.get("parent") else []) + lead = f"({','.join(_esc(b) for b in bits)})" if bits else "" + said = item.get("reason") if item.get("reason") and not item.get("phrase") else item.get("phrase") + margin = f' style="margin-left:{depth * 14}px"' if depth else "" + return f"{button}{lead}{':' if said else ''}{_esc(said or '')}" + + +def render_scope(doc: Mapping[str, Any]) -> str: + """报告文档里系统写的「分析范围」(analysis.contract.ScopeBlock):现象、拆过(下钻按链缩进)、排除、算不了、没拆、 + 检验了几次。文档没有这个字段(老文档、这次没有分析结论、报告节点关了这一段)时是空串,页面一个字都不多。""" + scope = doc.get("analysis_scope") + if not isinstance(scope, Mapping): + return "" + rows: list[str] = [] + for key, title in SCOPE_TITLES: + items = [i for i in scope.get(key) or [] if isinstance(i, Mapping)] + if not items: + continue + depth: dict[str, int] = {} + lines = [] + for item in items: + level = depth.get(str(item.get("parent") or ""), -1) + 1 if item.get("parent") else 0 + depth[str(item.get("alias") or "")] = level + lines.append(_scope_item(item, depth=level)) + rows.append(f"
{title}
    {''.join(lines)}
") + missing = scope.get("not_decomposed") + if isinstance(missing, list): + names = "、".join(_esc(n) for n in missing if n) + rows.append(f"
没拆
{names or '候选维度都拆过了'}
") + elif "not_decomposed" in scope: + rows.append(f"
没拆
{_esc(scope.get('note') or '候选维度核对不了')}
") + tests = scope.get("tests") + if isinstance(tests, int) and not isinstance(tests, bool) and tests > 0: + rows.append(f"
检验
这次做了 {tests} 次差异检验
") + if not rows: + return "" + return (f'

分析范围

' + f'

{SCOPE_NOTE}

{"".join(rows)}
') + + # -------------------------------------------------------------------------- # 数据块和页面 # -------------------------------------------------------------------------- @@ -390,7 +447,7 @@ def render_html(data: Mapping[str, Any], doc: Mapping[str, Any]) -> str: "NAME": _esc(name), "SEAL": seal_text, "RUN": _esc(str(meta.get("run_id") or "")), - "BODY": render_body(doc), + "BODY": render_body(doc) + render_scope(doc), "CSP": f"'sha256-{digest}'", "DATA": _json_text(data), "SCRIPT": script, diff --git a/backend/app/report_export/runtime.js b/backend/app/report_export/runtime.js index f88755e7..2f5ca262 100644 --- a/backend/app/report_export/runtime.js +++ b/backend/app/report_export/runtime.js @@ -3,9 +3,10 @@ * 原生 JS,无依赖,不连网。正文是服务端按报告文档渲染好的 HTML,这里只做三件事: * ① 填页面头部(报告节点、文档工件、封存状态、运行信息); * ② 给带状态的片段接上点击和键盘(事件委托在正文容器上),点开右侧的出处面板; - * ③ 按数据块里的出处链渲染面板:指标步骤(口径卡、口径与版本、原式、代入式、输入、复算、双盲复算)、 + * ③ 按数据块里的出处链渲染面板:指标步骤(口径卡、口径与版本、原式、代入式、输入、复算、双盲复算、可比性)、 * 输入步骤(值、来自哪个节点哪次查询的哪一格)、查询步骤(SQL、被引用的行、遮罩列、SQL 检查)、 - * 运行输入、表名字段名、引文,和网页上的证据面板(api/evidence 的片段接口)同一个拼法。 + * 运行输入、表名字段名、引文,分析结论的六步(判定、方法、计算、输入、复算、封存),和网页上的证据面板 + * (api/evidence 的片段接口)同一个拼法。正文后面系统写的「分析范围」里的编号同样点得开(data-seg 是 scope:A1)。 * 链里的每一步带着导出那一刻的封存与哈希状态,照实标明;这份文件不是核对件(页脚写明)。 * 来自数据的文字一律经 textContent 写进页面,不拼 HTML。 */ @@ -20,7 +21,7 @@ deterministic: '有出处', none: '无证据', suspect: '疑似不存在的名称', unverified: '无法核实', unjudged: '未裁判', candidate: '猜测的来源', }, - kindLabel: { number: '数字', value: '取值', entity: '名称', quote: '引文' }, + kindLabel: { number: '数字', value: '取值', entity: '名称', quote: '引文', analysis: '判定' }, segTitle: (kind, text) => `${kind}「${text}」`, metaRun: '运行 ID', metaClass: '运行类别', metaStatus: '运行状态', metaReport: '报告节点', metaStarted: '开始', metaFinished: '结束', metaExported: '导出时间', @@ -53,6 +54,7 @@ truncated: '查询结果已截断,该字段只含取回的部分行', // 查询步骤 query: '查询', dSql: 'SQL', dSourceName: '数据源', dTool: '工具', dRows: '被引用的行', dAllRows: (n) => `共 ${n} 行`, + dTotalRows: '行数', dColumnsMasked: '遮罩列', dChecks: 'SQL 检查', windowCut: '被引用的行前后行数太多,只列出其中一部分', truncatedRows: '查询结果被截断,取回的行不完整', merge: '合并查询', mergeInputs: '合并的输入', mergeTraced: '被引用格的来历', mergeWarnings: '执行时的警告', @@ -76,6 +78,23 @@ ledgerRelated: '相关口径', ledgerReasoning: '这次的推理:没有相关的已发布口径', ledgerUnknown: '没有比对', dBinding: '参数', dDiffers: '差在哪', dHow: '怎么认的', dColumnOf: '对应口径的列', unnamed: '已删除的工作流', + // 口径卡指标上的可比性(跨两份同形查询算的环比、同比) + dComparability: '两期可比性', comparabilityLabel: { comparable: '两期可比', conditional: '两期有条件可比', incomparable: '两期不可比' }, + // 分析结论:判定 → 方法 → 计算 → 输入 → 复算 → 封存 + analysis: '分析结论', dPhrase: '判定语', dCriterion: '判定标准', dSubjects: '点名的段', + dDirection: '方向', directionLabel: { up: '上升', down: '下降' }, dCaveats: '附带说明', dReason: '算不了的原因', + dCitedPart: '这一处引用的是', citedPhrase: '判定语', citedValue: (k) => `具名的数「${k}」`, + citedCell: (r, c) => `结果表第 ${r + 1} 行「${c}」`, dWho: '谁做的', originLabel: { card: '分析卡(模板定好的分析)', agent: 'Agent 现场做的分析' }, + dHypothesis: 'Agent 写的假设', refused: '这次分析没有做成', renderOk: '与按结论渲染的结果一致', renderBad: '与按结论渲染的结果不一致', + method: '方法', dMethod: '方法与版本', dParams: '参数', dSettings: '实际用的阈值', dProbes: '现查的表范围', + probeBasis: { live: '现查', profile: '剖析', observed: '取值观察' }, + calc: '计算', dValues: '具名的数', dFormulas: '关键数的代入式', dResultTable: '结果表', + resultRows: (shown, total) => `列出 ${shown} 行,共 ${total} 行`, + analysisInput: '输入', dRole: '角色', dRef: '调用里写的名字', dColumnsUsed: '用到的列', dRowsUsed: '用到的行', + allRows: '全部行', dHeadline: '总数那一格', dClosure: '闭合核对', + closureOk: (h, t) => `总数 ${h} 与各段之和 ${t} 闭合`, closureBad: (h, t) => `总数 ${h} 与各段之和 ${t} 对不上`, + dParent: '下钻自', recompute: '复算', seal: '封存', inputOfAnalysis: (alias) => `分析结论 ${alias} 的输入`, + inLedger: '封存范围内的台账记过这份分析结论', notInLedger: '封存范围内的台账里没有这份分析结论', } const MASKED = '已遮罩' @@ -211,6 +230,16 @@ : T.originUnknown(String(u.path || NONE)))).join(';') : T.originTraced] : null, ]))) + // 可比性:口径卡执行时自动做的可比性检查(跨两份同形查询算的环比、同比),做过才有 + if (isObj(step.comparability)) { + const c = step.comparability + const why = Array.isArray(c.reasons) && c.reasons.length ? `:${c.reasons.join(';')}` : '' + body.push(h('div', { style: 'margin-top:8px' }, kv([ + [T.dComparability, `${T.comparabilityLabel[c.verdict] || c.phrase || NONE}${c.verdict === 'comparable' ? '' : why}`], + c.criterion ? [T.dCriterion, String(c.criterion)] : null, + c.artifact ? [T.dArtifact, h('code', {}, short(c.artifact))] : null, + ]))) + } // 双盲复算(期 4):复核过这个指标才有 if (isObj(step.dual_check)) { const d = step.dual_check @@ -254,7 +283,7 @@ step.tool ? [T.dTool, h('code', {}, String(step.tool))] : null, step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null, step.alias ? [T.dQuery, String(step.alias)] : null, - step.total_rows != null ? [T.dAllRows, String(step.total_rows)] : null, + step.total_rows != null ? [T.dTotalRows, T.dAllRows(step.total_rows)] : null, ] body.push(kv(rows.filter(Boolean))) if (typeof step.sql === 'string' && step.sql) body.push(h('p', { class: 'sub' }, T.dSql), h('pre', { class: 'sql' }, step.sql)) @@ -314,7 +343,7 @@ if (step.hash_ok === false) bits.push(badge('bad', T.hashBad)) if (bits.length) body.push(h('div', { class: 'chips' }, ...bits)) return h('section', { class: 'part', 'data-step': 'query' }, - h('h3', {}, `${T.query}${step.alias ? ` ${step.alias}` : ''}`), + h('h3', {}, `${T.query}${step.alias ? ` ${step.alias}` : ''}${step.input_of ? ` · ${T.inputOfAnalysis(step.input_of)}` : ''}`), h('div', { class: 'part-body' }, ...body)) } @@ -397,9 +426,142 @@ h('div', { class: 'part-body' }, ...body)) } + /** 完整性徽标:已封存、哈希核对失败(每一步都可带) */ + function integrity(step) { + const bits = [] + if ('sealed' in step) bits.push(step.sealed ? badge('ok', T.sealedOk) : badge('na', T.sealedNo)) + if (step.hash_ok === false) bits.push(badge('bad', T.hashBad)) + return bits.length ? h('div', { class: 'chips' }, ...bits) : null + } + + const muted = (text) => h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' }, String(text)) + const waiting = (text) => h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, String(text)) + + /** 一张小表:表头 heads,行 rows(每行一组格),高亮的格(行号:列 id)和列 id 对上 */ + function smallTable(heads, ids, rows, index, hits) { + const hit = new Set((hits || []).map((c) => `${c[0]}:${c[1]}`)) + return h('div', { class: 'table-wrap' }, h('table', {}, + h('thead', {}, h('tr', {}, heads.map((c) => h('th', {}, String(c))))), + h('tbody', {}, rows.map((row, i) => h('tr', {}, (Array.isArray(row) ? row : ids.map((c) => own(row, c))).map((v, j) => h('td', { + class: [hit.has(`${index[i]}:${ids[j]}`) ? 'hit' : '', typeof v === 'number' ? 'n' : ''].filter(Boolean).join(' ') || null, + }, v == null ? NONE : textOf(v)))))))) + } + + /** 分析结论 · 判定:判定语、判定标准、判定码和点名的段、附带说明、算不了的原因、这一处引用的是哪一项、谁做的、Agent 写的假设 */ + function analysisPart(step) { + const body = [] + const verdict = isObj(step.verdict) ? step.verdict : null + const cited = isObj(step.cited) ? step.cited : {} + const citedText = cited.what === 'value' ? T.citedValue(cited.key) : cited.what === 'cell' + ? T.citedCell(Number(cited.row) || 0, String(cited.column ?? '')) : T.citedPhrase + body.push(kv([ + [T.dPhrase, String(step.phrase || NONE)], + step.criterion ? [T.dCriterion, String(step.criterion)] : null, + verdict && Array.isArray(verdict.subjects) && verdict.subjects.length + ? [T.dSubjects, verdict.subjects.map((s) => (Array.isArray(s) ? s.join(' · ') : String(s))).join('、')] : null, + verdict && verdict.direction ? [T.dDirection, T.directionLabel[verdict.direction] || String(verdict.direction)] : null, + [T.dCitedPart, citedText], + [T.dWho, `${T.originLabel[step.origin] || NONE}${step.node_id ? ` · ${step.label || nodeLabel(step.node_id)}` : ''}`], + ])) + if (step.status === 'refused') body.push(waiting(`${T.refused}${step.reason ? `:${step.reason}` : ''}`)) + if (Array.isArray(step.caveats) && step.caveats.length) { + body.push(h('p', { class: 'sub' }, T.dCaveats), + h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' }, step.caveats.map((c) => h('li', {}, String(c.text || ''))))) + } + if (step.hypothesis) { + body.push(h('p', { class: 'sub' }, T.dHypothesis), h('p', { style: 'margin:4px 0 0;font-size:13px' }, String(step.hypothesis))) + if (step.hypothesis_note) body.push(muted(step.hypothesis_note)) + } + if (step.note) body.push(waiting(step.note)) + if (typeof step.render_ok === 'boolean') body.push(muted(step.render_ok ? T.renderOk : T.renderBad)) + return h('section', { class: 'part', 'data-step': 'analysis' }, + h('h3', {}, `${T.analysis} ${step.alias || ''} · ${step.method_label || NONE}`), h('div', { class: 'part-body' }, ...body)) + } + + /** 分析结论 · 方法:方法与版本、参数、实际用的阈值、现查的表范围 */ + function analysisMethodPart(step) { + const body = [kv([[T.dMethod, `${step.method_label || NONE} · ${step.method || NONE}`]])] + if (isObj(step.params) && Object.keys(step.params).length) { + body.push(h('p', { class: 'sub' }, T.dParams), h('pre', { class: 'sql' }, JSON.stringify(step.params, null, 2))) + } + if (Array.isArray(step.settings) && step.settings.length) { + body.push(h('p', { class: 'sub' }, T.dSettings), kv(step.settings.map((x) => [String(x.label || x.key), textOf(x.value)]))) + } + if (Array.isArray(step.probes) && step.probes.length) { + body.push(h('p', { class: 'sub' }, T.dProbes), h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' }, + step.probes.map((p) => h('li', {}, `${p.table || NONE}.${p.column || NONE}:${p.min || NONE} 至 ${p.max || NONE}` + + `(${T.probeBasis[p.basis] || p.basis || NONE}${p.at ? ` · ${p.at}` : ''})`)))) + } + return h('section', { class: 'part', 'data-step': 'analysis_method' }, h('h3', {}, T.method), h('div', { class: 'part-body' }, ...body)) + } + + /** 分析结论 · 计算:关键数的代入式、具名的数、结果表(引用的格高亮) */ + function analysisCalcPart(step) { + const body = [] + if (Array.isArray(step.formulas) && step.formulas.length) { + body.push(h('p', { class: 'sub' }, T.dFormulas), h('pre', { class: 'sql' }, step.formulas.map((f) => String(f.text || '')).join('\n'))) + } + if (Array.isArray(step.values) && step.values.length) { + body.push(h('p', { class: 'sub' }, T.dValues), kv(step.values.map((v) => [String(v.key), + h('span', { class: v.cited ? 'hit' : null }, String(v.rendered ?? NONE))]))) + } + const table = isObj(step.table) ? step.table : null + if (table && Array.isArray(table.columns) && Array.isArray(table.rows)) { + const labels = isObj(table.labels) ? table.labels : {} + const index = Array.isArray(table.row_index) ? table.row_index : table.rows.map((_, i) => i) + body.push(h('p', { class: 'sub' }, `${T.dResultTable}(${T.resultRows(table.rows.length, table.total_rows ?? table.rows.length)})`), + smallTable(table.columns.map((c) => labels[c] || c), table.columns, table.rows.map((r) => (Array.isArray(r) + ? r.map((v, j) => ((table.masked || []).includes(table.columns[j]) ? MASKED : v)) : r)), index, + isObj(table.highlight) ? table.highlight.cells : [])) + if (table.window_truncated) body.push(muted(T.windowCut)) + } + return h('section', { class: 'part', 'data-step': 'analysis_calc' }, h('h3', {}, T.calc), h('div', { class: 'part-body' }, ...body)) + } + + /** 分析结论 · 输入:角色、落到哪个 Q、用到的列和行、总数那一格和闭合核对、下钻的上一层 */ + function analysisInputPart(step) { + const rows = Array.isArray(step.rows) ? step.rows.map((r) => `第 ${Number(r) + 1} 行`).join('、') : step.rows === 'all' ? T.allRows : null + const closure = isObj(step.closure) ? step.closure : null + const body = [kv([ + [T.dRole, String(step.role_label || step.role || NONE)], + step.ref ? [T.dRef, h('code', {}, String(step.ref))] : null, + step.query ? [T.dQuery, String(step.query)] : null, + step.alias ? [T.dParent, String(step.alias)] : null, + step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null, + Array.isArray(step.columns) && step.columns.length ? [T.dColumnsUsed, step.columns.join('、')] : null, + rows ? [T.dRowsUsed, rows] : null, + 'value' in step ? [T.dHeadline, `${step.column ? `第 ${Number(step.row) + 1} 行「${step.column}」 = ` : ''}${valueText(step.value)}`] : null, + closure ? [T.dClosure, closure.closed ? T.closureOk(closure.headline, closure.total) : T.closureBad(closure.headline, closure.total)] : null, + step.phrase ? [T.dPhrase, String(step.phrase)] : null, + ])] + const bits = integrity(step) + if (bits) body.push(bits) + return h('section', { class: 'part', 'data-step': 'analysis_input' }, + h('h3', {}, `${T.analysisInput}${step.query || step.alias ? ` ${step.query || step.alias}` : ''}`), h('div', { class: 'part-body' }, ...body)) + } + + /** 分析结论 · 复算:按封存的输入重算、和封存的比(服务端算好的那一句) */ + function analysisRecomputePart(step) { + const kind = step.status === 'same' ? 'ok' : step.status === 'different' ? 'bad' : 'na' + return h('section', { class: 'part', 'data-step': 'analysis_recompute' }, h('h3', {}, T.recompute), + h('div', { class: 'part-body' }, h('div', { class: 'chips' }, badge(kind, String(step.text || NONE))))) + } + + /** 分析结论 · 封存:台账记没记过、哈希核对 */ + function analysisSealPart(step) { + const body = [kv([[T.dArtifact, step.artifact ? h('code', {}, short(step.artifact)) : NONE], + [T.seal, step.ledger ? T.inLedger : T.notInLedger]])] + if (step.note) body.push(waiting(step.note)) + const bits = integrity(step) + if (bits) body.push(bits) + return h('section', { class: 'part', 'data-step': 'analysis_seal' }, h('h3', {}, T.seal), h('div', { class: 'part-body' }, ...body)) + } + const PARTS = { metric: metricPart, input: inputPart, query: queryPart, run_input: runInputPart, entity: entityPart, quote: quotePart, + analysis: analysisPart, analysis_method: analysisMethodPart, analysis_calc: analysisCalcPart, + analysis_input: analysisInputPart, analysis_recompute: analysisRecomputePart, analysis_seal: analysisSealPart, } /** 口径一步(口径账本给的标签,服务端已按遮罩给):来自口径的写明哪一版、级别、怎么认的、参数;相关口径写差在哪; diff --git a/backend/app/report_export/template.html b/backend/app/report_export/template.html index d2ef850c..44a2da5f 100644 --- a/backend/app/report_export/template.html +++ b/backend/app/report_export/template.html @@ -97,6 +97,13 @@ h1 { margin: 2px 0 0; font-size: 24px; line-height: 1.35; } .chipbtn { font: inherit; font-size: 12px; color: var(--accent); background: none; border: 0; padding: 0; cursor: pointer; text-align: left; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; } .hit { background: var(--mark); } +.scope { margin-top: 16px; padding: 14px 18px; border: 1px dashed var(--border); border-radius: 10px; background: var(--panel); } +.scope h3 { margin: 0 0 4px; font-size: 16px; } +.scope > .muted { margin: 0 0 8px; font-size: 12px; } +.scope-list { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 14px; margin: 0; font-size: 14px; } +.scope-list dt { color: var(--faint); } +.scope-list dd { margin: 0; min-width: 0; } +.scope-list ul { margin: 0; padding-left: 1.2em; } .foot { margin-top: 40px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 12px; color: var(--faint); } .foot p { margin: 4px 0; } @media print { @@ -115,7 +122,7 @@ h1 { margin: 2px 0 0; font-size: 24px; line-height: 1.35; } body { font-size: 14px; } .page { padding: 20px 16px 56px; } h1 { font-size: 20px; } - .meta { grid-template-columns: minmax(0, 1fr); gap: 0; } + .meta, .scope-list { grid-template-columns: minmax(0, 1fr); gap: 0; } .meta dt { margin-top: 6px; } .doc { padding: 12px; } }