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).
This commit is contained in:
+72
-16
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user