feat(cma): 口径裁定 2026-09-17 落地(现金流投资=0 二维判定 / 预警单位+期间口径)
ci/woodpecker/push/main Pipeline failed

依据:wecom-finance/reports/CMA_口径裁定_20260917.md(任总 2026-09-17 23:14「按建议执行」)

裁定1(现金流「投资活动净额=0」参与判定)
- classify_enterprise():0 为合法输入;投资恒为 0 → 经营(±)×筹资(±) 二维映射出 2 个
  候选类型(candidate_type_nos/candidates),不再整体判「未判定」;投资栏 investing_note
  标注「本期无投资活动」;经营/筹资亦为 0 → 回退「未判定(无投资活动)」,不报错不静默
- /analysis 增 classification_dimension / investing_note / nets.investing.note;前端出
  二维横幅 + 候选行高亮 + 投资栏「本期无投资活动」(不给单一类型名)
- import_cashflow_statements.py:打印层修 2D(type_no=None)TypeError 崩溃;NA 措辞改为
  「按 0 参与判定」(机读短语 NA_PHRASE_TMPL/parse_na_activities 未动)

裁定2a(比率类单位统一「百分比(%)」口径,先做)
- 新增 app/utils/caliber_units.py:逐 KPI 对照表 RATIO_CALIBER_TABLE(唯一事实源)+ 判定侧
  唯一入口 resolve_caliber();3 项需归一 ×100(流动/速动/毛利率),12 项取数已百分数不归一;
  表外且命中「疑似错配」窄形态(阈值子句量级≥100 且 0<|实测|<10 且单位属比率类)→
  dimension_suspect:不落预警、不推送(唯一规则,防 586/587 假红灯复发)
- alert_generator:判定前归一,文案写「口径归一 ×100(原值 1.29 → 129%)」留审计线索
- 新增 scripts/kpi_unit_crosscheck.py(只读对照表产出器)

裁定2b(期间由「当月硬编码」→「最近可用期间」,后做)
- resolve_latest_period():取该 KPI 自己的最新 YYYY-MM(禁全局 MAX,防演示账套劫持);
  伪期间(2026-H1/H2、2025-FY)在 Python 侧过滤(保「单测=生产」语义)
- 过渡期覆盖度自检扩展:记录回落后的期间分布 + 当月 0 条归因提示
- --dry-run(零写入零推送)作放行前抽查证据;generate_and_push/run_alert_check 支持 dry_run

测试:backend/tests/test_caliber_rulings_20260917.py 17 项;全量 1158 passed/1 xfailed
This commit is contained in:
Hermes CI Fix
2026-09-18 00:34:04 +08:00
parent e567d1b94f
commit 3bb55189b1
8 changed files with 923 additions and 79 deletions
+43 -6
View File
@@ -25,14 +25,24 @@
<el-card shadow="never" class="verdict-card">
<div class="verdict-left" v-if="classification.classified">
<div class="verdict-badge">
<span class="verdict-type">类型{{ classification.type_no }}</span>
<span class="verdict-name">{{ classification.health_emoji }} {{ classification.name }}</span>
<span class="verdict-type" v-if="isTwoDim">二维判定</span>
<span class="verdict-type" v-else>类型{{ classification.type_no }}</span>
<span class="verdict-name" v-if="isTwoDim">🧭 按 经营(±) × 筹资(±) 二维映射</span>
<span class="verdict-name" v-else>{{ classification.health_emoji }} {{ classification.name }}</span>
</div>
<div class="verdict-health">
<el-tag :type="healthTagType" size="small">健康度:{{ classification.health_emoji }} {{ classification.health_label }}</el-tag>
<el-tag v-if="isTwoDim" size="small" type="warning">投资活动为 0:{{ investingNote }}</el-tag>
<el-tag v-else :type="healthTagType" size="small">健康度:{{ classification.health_emoji }} {{ classification.health_label }}</el-tag>
<el-tag v-if="classification.lifecycle" size="small" type="info" effect="plain" style="margin-left:8px">生命周期:{{ classification.lifecycle }}</el-tag>
<el-tag size="small" effect="plain" style="margin-left:8px">{{ source === 'official' ? '📄 官方现金流量表' : '✍️ 人工录入' }}</el-tag>
</div>
<!-- 裁定1:投资活动为 0 → 显式列出二维映射的候选类型(不得只给单一类型名) -->
<div v-if="isTwoDim" style="margin-top:8px">
<el-tag v-for="c in (classification.candidates || [])" :key="c.type_no"
size="small" effect="plain" style="margin-right:8px">
候选 类型{{ c.type_no }} {{ c.name }}(O{{ c.o_sign }} I{{ c.i_sign }} F{{ c.f_sign }})
</el-tag>
</div>
</div>
<div class="verdict-left" v-else>
<el-alert type="warning" :closable="false" :title="classification.reason || '净额数据不足,暂无法判定类型'" show-icon />
@@ -47,6 +57,8 @@
<div class="net-sign" :style="{ color: netColor(n) }">
{{ n.sign === '+' ? '进钱 ▲' : n.sign === '-' ? '出钱 ▼' : '为0' }}
</div>
<!-- 裁定1:投资活动=0 的栏位显式标注「本期无投资活动」 -->
<div class="net-note" v-if="n.note">{{ n.note }}</div>
<div class="net-hint">{{ n.hint }}</div>
</div>
</div>
@@ -67,6 +79,9 @@
<el-col :span="9">
<el-card shadow="never" header="判定说明(CMA P2 · 现金流量表分析)">
<template v-if="classification.classified">
<el-alert v-if="isTwoDim" type="warning" :closable="false" title="投资活动为 0(本期无投资活动)" show-icon style="margin-bottom:8px">
<span>投资活动栏位显式标注「本期无投资活动」;按「经营(±) × 筹资(±)」二维映射到候选类型:{{ candidateLabel }}——投资活动无正负号,需结合业务实质在候选中确认。</span>
</el-alert>
<p class="desc-p">{{ classification.profile }}</p>
<el-alert type="error" :closable="false" title="风险提示" show-icon>
<span>{{ classification.risk }}</span>
@@ -88,7 +103,7 @@
<el-table :data="rules" size="small" stripe :row-class-name="ruleRowClass">
<el-table-column prop="type_no" label="类型" width="60" align="center">
<template #default="{ row }">
<el-tag :type="row.type_no === currentType ? 'primary' : 'info'" size="small" effect="plain">
<el-tag :type="isHighlighted(row.type_no) ? 'primary' : 'info'" size="small" effect="plain">
{{ row.type_no }}
</el-tag>
</template>
@@ -245,7 +260,22 @@ const netList = computed(() => {
.map((k) => ({ key: k, ...meta[k], ...n[k] }))
})
const currentType = computed(() => (classification.value?.classified ? classification.value.type_no : -1))
// ── 裁定1(2026-09-17):投资活动=0 → 二维判定(候选 2 个 + 投资栏标注「本期无投资活动」)──
const isTwoDim = computed(() => classification.value?.dimension === '2D' && !!classification.value?.classified)
const investingNote = computed(() => classification.value?.investing_note || '本期无投资活动')
const candidateLabel = computed(() =>
(classification.value?.candidates || []).map((c: any) => `类型${c.type_no} ${c.name}`).join(' / ')
)
// 二维判定时高亮全部候选类型行(不给单一类型名)
const highlightTypes = computed<number[]>(() => {
if (!classification.value?.classified) return []
return isTwoDim.value
? (classification.value.candidate_type_nos || [])
: [classification.value.type_no]
})
function isHighlighted(typeNo: number): boolean {
return highlightTypes.value.includes(typeNo)
}
const healthTagType = computed(() => {
const label = classification.value?.health_label || ''
@@ -267,7 +297,7 @@ function netColor(n: any): string {
}
function ruleRowClass({ row }: any) {
return row.type_no === currentType.value ? 'rule-row-current' : ''
return isHighlighted(row.type_no) ? 'rule-row-current' : ''
}
async function loadPeriods() {
@@ -547,6 +577,13 @@ onMounted(async () => {
font-size: 13px;
font-weight: 600;
}
/* 裁定1:投资活动=0 → 「本期无投资活动」标注 */
.net-note {
font-size: 11px;
color: #e6a23c;
margin-top: 2px;
font-weight: 600;
}
.net-hint {
font-size: 11px;
color: #909399;