From 1f7be6b5aeab9a4e49b8f8f79ee51cd23b9638c2 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Mon, 14 Sep 2026 20:37:12 +0800 Subject: [PATCH] fix(cash): select cash-balance sync targets by explicit kpi_code whitelist The voucher-import linkage branch picked write targets by kpi_name LIKE '%cash%' and wrote the monetary-fund ledger balance (yuan) into derived KPIs (F_OP_CFLOW / F_FCF / F_BEP_REVENUE_CASH / F_BEP_COVER_CASH). Replace with an explicit kpi_code whitelist plus a unit/caliber gate that refuses writes on mismatch and reports them via kpi_sync_skipped. Read-only equivalence probe (real function + intercepted writes + rollback) before/after: before wrote 4 derived KPIs, after writes only F_CASH_SAFETY. tests: +9 new cases; pytest failure set identical to baseline (15/15 same ids). --- backend/app/api/cash.py | 88 +++++-- .../test_cash_kpi_sync_whitelist_20260914.py | 215 ++++++++++++++++++ docs/财务Bot网银流水自助入库流程.md | 7 +- 3 files changed, 292 insertions(+), 18 deletions(-) create mode 100644 backend/tests/test_cash_kpi_sync_whitelist_20260914.py diff --git a/backend/app/api/cash.py b/backend/app/api/cash.py index fafae6cb..eb82de1d 100644 --- a/backend/app/api/cash.py +++ b/backend/app/api/cash.py @@ -792,14 +792,53 @@ def _sync_cash_balance_from_ledger(db: Session, entity_id: int, batch: str) -> f return cash_wan -def _sync_cash_kpis(db: Session, entity_id: int, batch: str, periods: list) -> list: +# ── ① 现金余额联动白名单(2026-09-14 修复:口径错配)────────────────────────── +# 背景:原 `_sync_cash_kpis` ① 分支按 kpi_name LIKE '%现金%'/'%货币资金%' **语义模糊匹配** +# 选取写入目标,实测(entity 1)命中 F_OP_CFLOW / F_FCF / F_BEP_REVENUE_CASH / +# F_BEP_COVER_CASH 四个派生指标,会把「货币资金科目期末余额(元)」写进它们 +# (覆盖率还是 % 单位),且写入 data_status="verified" → 覆盖主值面。 +# 规则:**名称/关键词匹配一律不得作为写入判据**;只有在此显式登记用途的 kpi_code 才可被联动写值。 +# 键 = kpi_definitions.kpi_code(全库唯一);值 = (科目选择器, 期望单位) +# 科目选择器: "1001"=库存现金余额 / "1002"=银行存款余额 / None=货币资金合计(1001+1002) +# 新增目标:先把 KPI 用途(货币资金科目期末余额)登记到本表,再置 status=active 才会被写入。 +CASH_SYNC_KPI_WHITELIST: dict = { + "EXT_069": ("1001", "元"), # 库存现金(余额=1001 科目净额) + "EXT_089": ("1002", "元"), # 银行存款(余额=1002 科目净额) +} + +# 单位归一(等价写法不影响判据;不在此表内的单位视为口径不符 → 拒绝写入) +_UNIT_ALIASES = { + "元": "元", "人民币元": "元", "yuan": "元", "rmb": "元", "cny": "元", + "万元": "万元", "万": "万元", "%": "%", "百分比": "%", +} + + +def _norm_unit(unit) -> str: + return _UNIT_ALIASES.get(str(unit or "").strip().lower(), str(unit or "").strip()) + + +def _cash_value_by_selector(selector, cash_1001: float, cash_1002: float, balance_yuan: float) -> float: + """按白名单登记科目选择器取值(未知选择器直接报错,防静默写错口径)。""" + if selector == "1001": + return cash_1001 + if selector == "1002": + return cash_1002 + if selector is None: + return balance_yuan + raise ValueError(f"CASH_SYNC_KPI_WHITELIST 科目选择器非法: {selector!r}(仅允许 '1001'/'1002'/None)") + + +def _sync_cash_kpis(db: Session, entity_id: int, batch: str, periods: list) -> tuple: """现金流KPI联动: - ① EXT_现金类KPI → 货币资金科目期末余额(元,与存量口径一致,source_type=ledger) + ① 白名单内的货币资金余额类KPI → 货币资金科目期末余额(元,source_type=ledger) + (显式登记判据,禁名称模糊匹配;unit 与写入口径不符 → 拒绝写入并告警) ② F_CASH_SAFETY 现金安全垫(万元 = 货币资金余额 - 短期借款EXT_139,entity_id隔离,不存在则创建) + 返回 (updates, skipped):skipped = 命中白名单但被拒写的项(含原因,供告警/审计)。 """ from app.models import KPIDefinition, KPIValue, VoucherDetail from app.utils.voucher_guard import apply_voucher_guard updates = [] + skipped = [] period = periods[-1] if periods else datetime.now().strftime("%Y-%m") # 作废/撤单标记过滤(2026-09-14 收口): 已作废凭证不参与联动取数 @@ -817,21 +856,26 @@ def _sync_cash_kpis(db: Session, entity_id: int, batch: str, periods: list) -> l for r in monetary if r.subject_code.startswith("1002")), 2) cash_wan = round(balance_yuan / 10000, 2) - # ── ① EXT_现金类KPI(名称含'现金'/'货币资金',active;排除F_CASH_SAFETY,由②专用逻辑按万元口径处理)── + # ── ① 货币资金余额类KPI(**显式白名单** CASH_SYNC_KPI_WHITELIST,禁名称模糊匹配)── cash_kpis = db.query(KPIDefinition).filter( KPIDefinition.entity_id == entity_id, - or_(KPIDefinition.kpi_name.like("%现金%"), KPIDefinition.kpi_name.like("%货币资金%")), + KPIDefinition.kpi_code.in_(list(CASH_SYNC_KPI_WHITELIST)), KPIDefinition.status == "active", - KPIDefinition.kpi_code != "F_CASH_SAFETY", ).all() for k in cash_kpis: - if "库存现金" in k.kpi_name: - val = cash_1001 - elif "银行" in k.kpi_name: - val = cash_1002 - else: - val = balance_yuan - remark = f"网银流水导入[{batch}]联动: 货币资金科目期末余额{balance_yuan}元(库存现金{cash_1001}/银行存款{cash_1002})" + selector, expect_unit = CASH_SYNC_KPI_WHITELIST[k.kpi_code] + if _norm_unit(k.unit) != expect_unit: + reason = (f"unit_mismatch: kpi_definitions.unit={k.unit!r} 与写入口径不符" + f"(白名单登记期望 {expect_unit} / 写入值口径 元)") + logger.warning( + f"网银流水导入[{batch}] 拒绝联动写KPI {k.kpi_code}({k.kpi_name})[{k.unit}]: {reason}" + ) + skipped.append({"kpi_code": k.kpi_code, "kpi_name": k.kpi_name, + "unit": k.unit, "reason": reason}) + continue + val = _cash_value_by_selector(selector, cash_1001, cash_1002, balance_yuan) + remark = (f"网银流水导入[{batch}]联动: 货币资金科目期末余额{balance_yuan}元" + f"(库存现金{cash_1001}/银行存款{cash_1002}),白名单科目选择器={selector or '合计'}") existing = db.query(KPIValue).filter( KPIValue.kpi_id == k.id, KPIValue.period == period, @@ -855,10 +899,10 @@ def _sync_cash_kpis(db: Session, entity_id: int, batch: str, periods: list) -> l )) updates.append({"kpi_code": k.kpi_code, "kpi_name": k.kpi_name, "period": period, "value": val}) - # ── ② F_CASH_SAFETY 现金安全垫(万元)── + # ── ② F_CASH_SAFETY 现金安全垫(万元)——判据同样只用 kpi_code,不用名称模糊匹配 ── safety_kpi = db.query(KPIDefinition).filter( KPIDefinition.entity_id == entity_id, - or_(KPIDefinition.kpi_code == "F_CASH_SAFETY", KPIDefinition.kpi_name.like("%安全垫%")), + KPIDefinition.kpi_code == "F_CASH_SAFETY", ).first() if not safety_kpi: safety_kpi = KPIDefinition( @@ -885,6 +929,16 @@ def _sync_cash_kpis(db: Session, entity_id: int, batch: str, periods: list) -> l db.flush() logger.info(f"新增KPI F_CASH_SAFETY 现金安全垫 (entity_id={entity_id})") + # 口径校验:② 写入值口径为「万元」,unit 不符 → 拒绝写入并告警(不触碰主值面) + if _norm_unit(safety_kpi.unit) != "万元": + reason = (f"unit_mismatch: kpi_definitions.unit={safety_kpi.unit!r} 与写入口径不符" + f"(② 写入值口径 万元)") + logger.warning(f"网银流水导入[{batch}] 拒绝联动写KPI F_CASH_SAFETY(现金安全垫)" + f"[{safety_kpi.unit}]: {reason}") + skipped.append({"kpi_code": "F_CASH_SAFETY", "kpi_name": safety_kpi.kpi_name, + "unit": safety_kpi.unit, "reason": reason}) + return updates, skipped + short_debt_wan = None debt_kpi = db.query(KPIDefinition).filter( KPIDefinition.entity_id == entity_id, @@ -925,7 +979,7 @@ def _sync_cash_kpis(db: Session, entity_id: int, batch: str, periods: list) -> l remark=remark, )) updates.append({"kpi_code": "F_CASH_SAFETY", "kpi_name": "现金安全垫", "period": period, "value": safety_value}) - return updates + return updates, skipped @router.get("/import/template") @@ -1098,9 +1152,10 @@ async def api_import_vouchers( # ── 现金流联动 ── cash_balance = None kpi_updates = [] + kpi_sync_skipped = [] try: cash_balance = _sync_cash_balance_from_ledger(db, entity_id, batch) - kpi_updates = _sync_cash_kpis(db, entity_id, batch, periods_in) + kpi_updates, kpi_sync_skipped = _sync_cash_kpis(db, entity_id, batch, periods_in) db.commit() except Exception as e: db.rollback() @@ -1117,6 +1172,7 @@ async def api_import_vouchers( "carry_forward_count": carry_forward_count, "cash_balance": cash_balance, "kpi_updates": kpi_updates, + "kpi_sync_skipped": kpi_sync_skipped, "batch": batch, "entity_id": entity_id, } diff --git a/backend/tests/test_cash_kpi_sync_whitelist_20260914.py b/backend/tests/test_cash_kpi_sync_whitelist_20260914.py new file mode 100644 index 00000000..6e7a6253 --- /dev/null +++ b/backend/tests/test_cash_kpi_sync_whitelist_20260914.py @@ -0,0 +1,215 @@ +"""cash.py `_sync_cash_kpis` ① 分支白名单化 + 单位口径校验 单测 — 2026-09-14 + +任务: fullstack-20260914200226(`_sync_cash_kpis` ① 分支按名称模糊匹配选取 KPI → 口径错配) + +缺陷复现(生产 entity 1 只读实测):原判据 + kpi_name LIKE '%现金%' OR kpi_name LIKE '%货币资金%' AND status='active' +命中 4 个派生指标 —— F_OP_CFLOW(经营性现金流) / F_FCF(自由现金流) / +F_BEP_REVENUE_CASH(现金口径保本销售额) / F_BEP_COVER_CASH(现金口径保本覆盖率), +会把「货币资金科目期末余额(元)」写进它们(覆盖率还是 % 单位)且置 data_status='verified'。 + +本测试锁定修复后的判据(**只认显式白名单 kpi_code**)与单位口径闸门: + 1. 名称含「现金/货币资金」但未登记白名单的 KPI(含派生/保本/比率类)→ 零写入; + 2. 白名单命中且 unit 与写入口径一致 → 按登记科目选择器写对值(1001 / 1002 / 合计); + 3. unit 与写入口径不符 → 拒绝写入 + WARNING 告警 + 进 skipped; + 4. status != active / 跨账套(entity_id 隔离)→ 不写; + 5. 同 (kpi_id, period, source_type=ledger) 幂等:二次调用更新同一行不新增; + 6. 白名单科目选择器笔误 → 硬报错(不静默退化成合计口径); + 7. ② 分支 F_CASH_SAFETY 判据同样只用 kpi_code(名称兜底已移除)+ 单位闸门。 +""" +import datetime +import logging +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest # noqa: E402 + +from app.models import KPIValue, VoucherDetail # noqa: E402 +from app.api import cash as CASH # noqa: E402 +from tests.conftest import create_test_kpi # noqa: E402 + +D = datetime.datetime(2026, 8, 31) +PERIOD = "2026-08" + + +def _add_voucher(db, no, code, debit=0.0, credit=0.0, entity_id=1, period=PERIOD): + db.add(VoucherDetail(entity_id=entity_id, voucher_no=str(no), voucher_date=D, + subject_code=code, subject_name="测试科目", + debit_amount=debit, credit_amount=credit, + summary="白名单单测", carry_forward=0, period=period)) + db.flush() + + +def _rows(db, kpi_id): + return db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).all() + + +def _codes(items): + return {i["kpi_code"] for i in items} + + +# ── 1. 派生/保本/比率类:名称命中旧 LIKE 判据也不得被写入 ────────────────────── +def test_derived_cash_named_kpis_not_written(db): + """名称含「现金」的派生指标(旧判据的命中集)→ 零写入(白名单未登记即不写)。""" + derived = [ + create_test_kpi(db, kpi_code="T_F_OP_CFLOW", kpi_name="经营性现金流", unit="万元"), + create_test_kpi(db, kpi_code="T_F_FCF", kpi_name="自由现金流(FCF)", unit="万元"), + create_test_kpi(db, kpi_code="T_F_BEP_REVENUE_CASH", kpi_name="现金口径保本销售额", unit="万元"), + create_test_kpi(db, kpi_code="T_F_BEP_COVER_CASH", kpi_name="现金口径保本覆盖率", unit="%"), + create_test_kpi(db, kpi_code="T_EXT_CASH_MISC", kpi_name="货币资金合计(衍生)", unit="万元"), + ] + _add_voucher(db, 88001, "1001", debit=300.0) + _add_voucher(db, 88002, "1002", debit=700.0) + + updates, skipped = CASH._sync_cash_kpis(db, 1, "batch-whitelist-neg", [PERIOD]) + + hit = _codes(updates) & {k.kpi_code for k in derived} + assert hit == set(), f"派生/保本类被联动写入: {hit}" + for k in derived: + assert _rows(db, k.id) == [], f"{k.kpi_code} 不应有 kpi_values 行" + # 白名单本身不含这些派生指标 + assert not (_codes(derived) if False else {"T_F_OP_CFLOW"} & set(CASH.CASH_SYNC_KPI_WHITELIST)) + assert skipped == [], "未被白名单命中 ≠ 拒写项(skipped 只记命中白名单但口径不符的)" + + +# ── 2. 白名单命中:按登记科目选择器写对值 ────────────────────────────────── +def test_whitelisted_kpis_written_with_registered_selector(db): + """EXT_069(库存现金→1001) / EXT_089(银行存款→1002) 分别写各自科目净额。""" + k_1001 = create_test_kpi(db, kpi_code="EXT_069", kpi_name="库存现金", unit="元") + k_1002 = create_test_kpi(db, kpi_code="EXT_089", kpi_name="银行存款", unit="元") + _add_voucher(db, 88101, "1001", debit=1234.5) + _add_voucher(db, 88102, "100201", debit=7654.25) + _add_voucher(db, 88103, "100201", credit=1000.0) + + updates, skipped = CASH._sync_cash_kpis(db, 1, "batch-wl-pos", [PERIOD]) + + got = {u["kpi_code"]: u["value"] for u in updates} + assert got["EXT_069"] == 1234.5, got + assert got["EXT_089"] == 7654.25 - 1000.0, got # 1002 科目净额(借-贷) + assert skipped == [], skipped + + rows_1001 = _rows(db, k_1001.id) + rows_1002 = _rows(db, k_1002.id) + assert len(rows_1001) == 1 and rows_1001[0].actual_value == 1234.5 + assert len(rows_1002) == 1 and rows_1002[0].actual_value == 6654.25 + for r in rows_1001 + rows_1002: + assert r.source_type == "ledger" and r.period == PERIOD + assert r.source_batch == "batch-wl-pos" + + +# ── 3. 单位口径不符 → 拒写 + 告警(负例)──────────────────────────────── +def test_unit_mismatch_rejected_and_warned(db, caplog): + """unit=万元 的「库存现金」→ 元口径不得写入(防万元/百分比误写元值)。""" + k_bad = create_test_kpi(db, kpi_code="EXT_069", kpi_name="库存现金", unit="万元") + k_pct = create_test_kpi(db, kpi_code="EXT_089", kpi_name="银行存款", unit="%") + _add_voucher(db, 88201, "1001", debit=50000.0) + _add_voucher(db, 88202, "1002", debit=20000.0) + + with caplog.at_level(logging.WARNING, logger="cma.cash"): + updates, skipped = CASH._sync_cash_kpis(db, 1, "batch-unit-neg", [PERIOD]) + + assert "EXT_069" not in _codes(updates) and "EXT_089" not in _codes(updates) + assert _rows(db, k_bad.id) == [] and _rows(db, k_pct.id) == [] + assert _codes(skipped) == {"EXT_069", "EXT_089"}, skipped + for s in skipped: + assert "unit_mismatch" in s["reason"] and s["reason"] + msgs = [r.getMessage() for r in caplog.records] + assert any("拒绝联动写KPI EXT_069" in m and "unit_mismatch" in m for m in msgs), msgs + assert any("拒绝联动写KPI EXT_089" in m for m in msgs), msgs + + +# ── 4. 非 active / 跨账套 → 不写 ──────────────────────────────────────── +def test_inactive_and_cross_entity_not_written(db): + """archived 的 EXT_069 不写;entity 2 的 EXT_069 不被 entity 1 的调用写。""" + k_arch = create_test_kpi(db, kpi_code="EXT_069", kpi_name="库存现金", unit="元", + status="archived") + k_e2 = create_test_kpi(db, kpi_code="EXT_089", kpi_name="银行存款", unit="元", entity_id=2) + _add_voucher(db, 88301, "1001", debit=9999.0) + _add_voucher(db, 88302, "1002", debit=8888.0) + + updates, skipped = CASH._sync_cash_kpis(db, 1, "batch-status-neg", [PERIOD]) + + assert _codes(updates) & {"EXT_069", "EXT_089"} == set(), updates + assert _rows(db, k_arch.id) == [] and _rows(db, k_e2.id) == [] + assert skipped == [], skipped + + +# ── 5. 幂等:同批次重复调用不新增行 ───────────────────────────────────── +def test_idempotent_same_period_single_row(db): + k = create_test_kpi(db, kpi_code="EXT_069", kpi_name="库存现金", unit="元") + _add_voucher(db, 88401, "1001", debit=100.0) + + CASH._sync_cash_kpis(db, 1, "batch-idem-1", [PERIOD]) + db.flush() + CASH._sync_cash_kpis(db, 1, "batch-idem-2", [PERIOD]) + db.flush() + + rows = [r for r in _rows(db, k.id) if r.source_type == "ledger"] + assert len(rows) == 1, rows + assert rows[0].actual_value == 100.0 and rows[0].source_batch == "batch-idem-2" + + +# ── 6. 科目选择器笔误 → 硬报错(不静默退化成合计口径)────────────────────── +def test_invalid_selector_raises(db, monkeypatch): + monkeypatch.setitem(CASH.CASH_SYNC_KPI_WHITELIST, "EXT_069", ("9999", "元")) + create_test_kpi(db, kpi_code="EXT_069", kpi_name="库存现金", unit="元") + _add_voucher(db, 88501, "1001", debit=1.0) + with pytest.raises(ValueError): + CASH._sync_cash_kpis(db, 1, "batch-selector-neg", [PERIOD]) + + +def test_selector_none_means_total_balance(db): + """选择器 None = 货币资金合计(1001+1002)——演示「合计口径」的登记形态。""" + monkeypatch_free = CASH.CASH_SYNC_KPI_WHITELIST + CASH.CASH_SYNC_KPI_WHITELIST = dict(monkeypatch_free, **{"EXT_089": (None, "元")}) + try: + k = create_test_kpi(db, kpi_code="EXT_089", kpi_name="银行存款", unit="元", + data_source="测试-合计口径") + _add_voucher(db, 88601, "1001", debit=10.0) + _add_voucher(db, 88602, "1002", debit=25.0) + updates, _ = CASH._sync_cash_kpis(db, 1, "batch-total", [PERIOD]) + got = {u["kpi_code"]: u["value"] for u in updates} + assert got["EXT_089"] == 35.0, got + assert _rows(db, k.id)[0].actual_value == 35.0 + finally: + CASH.CASH_SYNC_KPI_WHITELIST = monkeypatch_free + + +# ── 8. 判据源级断言:①/② 选取不得再出现名称模糊匹配 ───────────────────── +def test_selection_has_no_fuzzy_name_matching(): + """源码级锁定:`_sync_cash_kpis` 内不得出现 kpi_name.like / LIKE '%现金%' 之类判据。""" + import inspect + src = inspect.getsource(CASH._sync_cash_kpis) + assert "kpi_name.like" not in src, "选取判据出现名称模糊匹配" + assert ".like(" not in src, src + assert "KPIDefinition.kpi_code.in_(list(CASH_SYNC_KPI_WHITELIST))" in src + assert 'KPIDefinition.kpi_code == "F_CASH_SAFETY"' in src + + +# ── 7. ② 分支:判据只用 kpi_code + 单位闸门 ───────────────────────────── +def test_safety_kpi_selected_by_code_only_and_unit_guarded(db, caplog): + """名称含「安全垫」但 code != F_CASH_SAFETY 的 KPI 不得被 ② 写入; + F_CASH_SAFETY 单位非万元 → 拒写 + 告警。""" + impostor = create_test_kpi(db, kpi_code="T_SAFETY_LOOKALIKE", kpi_name="现金安全垫(旧)", + unit="万元") + _add_voucher(db, 88701, "1001", debit=10000.0) + + # (a) 无 F_CASH_SAFETY → ② 创建自身行,impostor 不参与 + updates, skipped = CASH._sync_cash_kpis(db, 1, "batch-safety-a", [PERIOD]) + assert "T_SAFETY_LOOKALIKE" not in _codes(updates) + assert _rows(db, impostor.id) == [] + safety = [u for u in updates if u["kpi_code"] == "F_CASH_SAFETY"] + assert len(safety) == 1 and safety[0]["value"] == 1.0, updates # 10000元 = 1.0万元 + + # (b) F_CASH_SAFETY 单位被改成 % → 拒写 + 告警 + from app.models import KPIDefinition + db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_CASH_SAFETY").update( + {"unit": "%"}, synchronize_session=False) + db.flush() + with caplog.at_level(logging.WARNING, logger="cma.cash"): + updates2, skipped2 = CASH._sync_cash_kpis(db, 1, "batch-safety-b", [PERIOD]) + assert "F_CASH_SAFETY" not in _codes(updates2) + assert [s["kpi_code"] for s in skipped2] == ["F_CASH_SAFETY"], skipped2 + assert any("拒绝联动写KPI F_CASH_SAFETY" in r.getMessage() for r in caplog.records) diff --git a/docs/财务Bot网银流水自助入库流程.md b/docs/财务Bot网银流水自助入库流程.md index 589c7d75..1aea4f4c 100644 --- a/docs/财务Bot网银流水自助入库流程.md +++ b/docs/财务Bot网银流水自助入库流程.md @@ -122,6 +122,7 @@ curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8010/api/cma/cash/da "carry_forward_count": 2, "cash_balance": 88.88, "kpi_updates": [{"kpi_code": "EXT_069", "kpi_name": "库存现金", "period": "2026-08", "value": ...}, ...], + "kpi_sync_skipped": [{"kpi_code": "...", "kpi_name": "...", "unit": "万元", "reason": "unit_mismatch: ..."}], "batch": "2026年08月网银流水_20260828153000", "entity_id": 1 } @@ -132,8 +133,10 @@ curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8010/api/cma/cash/da ## 六、现金流联动(导入成功自动触发) 1. **现金余额自动更新**:汇总 1001/1002 开头科目(排除结转行)`Σ借 - Σ贷` 得货币资金期末余额(元)→ `set_current_cash_balance`(万元)→ `GET /api/cma/cash/balance` 的 `current_cash` 实时反映 -2. **EXT_现金类KPI**:名称含"现金"/"货币资金"的 active KPI(排除 F_CASH_SAFETY)写入/更新 `kpi_values`(source_type="ledger"、data_status="verified"、remark 记录批次与明细口径)——库存现金类→1001余额,银行类→1002余额,其余→货币资金总额 -3. **F_CASH_SAFETY 现金安全垫KPI**(万元):不存在则自动创建(formula=货币资金余额-短期借款);短期借款取 EXT_139 最新实际值(单位元÷10000),无数据时安全垫=货币资金余额 +2. **现金余额类KPI(显式白名单)**:只写 `app/api/cash.py` 中 `CASH_SYNC_KPI_WHITELIST` **显式登记的 kpi_code**(当前:`EXT_069` 库存现金→1001 科目净额、`EXT_089` 银行存款→1002 科目净额;登记形态为 `(科目选择器, 期望单位)`,选择器 `None` = 货币资金合计),且该 KPI 需 `status=active`;写入 `kpi_values`(`source_type="ledger"`、`data_status="verified"`、remark 记录批次/明细口径与选择器)。 + - **禁止名称模糊匹配**:原按 `kpi_name LIKE '%现金%'/'%货币资金%'` 选取写入目标,会命中 `F_OP_CFLOW`/`F_FCF`/`F_BEP_REVENUE_CASH`/`F_BEP_COVER_CASH` 等派生指标并写入元值(2026-09-14 修复)。新增联动目标必须先把用途登记进白名单。 + - **单位口径闸门**:目标 KPI 的 `unit` 与写入口径(元)不符 → **拒绝写入** + WARNING 日志 + 记入返回体 `kpi_sync_skipped`(防万元/百分比误写元值),不触碰主值面。 +3. **F_CASH_SAFETY 现金安全垫KPI**(万元):按 `kpi_code` 精确匹配(不再用名称兜底),不存在则自动创建(formula=货币资金余额-短期借款);`unit != 万元` 时拒写并告警;短期借款取 EXT_139 最新实际值(单位元÷10000),无数据时安全垫=货币资金余额 ---