From 5b920df8a0f8230dedfdae9213d77b2f5bea3f38 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Sun, 30 Aug 2026 10:27:01 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=A2=84=E7=AE=97=E5=B9=B4=E5=BA=A6?= =?UTF-8?q?=E5=88=86=E8=A7=A3=E5=B9=82=E7=AD=89=E5=8A=A0=E5=9B=BA+?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E8=B4=A8=E9=87=8F=E4=B8=83=E8=A7=84=E5=88=99?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auto-decompose批量聚合只取年度行(period=YYYY-00)排除月度行,修复重复点击执行分解年底预算滚雪球 - 同一KPI多个version年度行时只取一行(优先请求version),避免多版本叠加总额虚高 - 新增tests/test_data_quality.py: 财务七规则(governance-check)3用例全过 --- backend/app/api/budget.py | 19 +++- backend/tests/test_data_quality.py | 141 +++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 backend/tests/test_data_quality.py diff --git a/backend/app/api/budget.py b/backend/app/api/budget.py index 4830284c..f986fa71 100644 --- a/backend/app/api/budget.py +++ b/backend/app/api/budget.py @@ -197,18 +197,26 @@ def auto_decompose_budget( # ── 批量模式:不传kpi_id → 分解该年所有有年度预算的KPI ── if not kpi_id: - # 找该年已存在的年度预算(period=YYYY-00 或已按月填的KPI汇总) - # 优先用 budget_plans 中该年的预算作为年度总额 + # 只取年度行(period=YYYY-00)作为年度总额,避免把月度行也加进来导致滚雪球(非幂等bug修复) year_budget_rows = db.query(BudgetPlan).filter( BudgetPlan.entity_id == 1, BudgetPlan.budget_year == year, + BudgetPlan.period == f"{year}-00", BudgetPlan.status == "active", ).all() - # 按KPI聚合年度预算总额 - kpi_annual = {} + # 按KPI聚合年度预算总额——同一KPI存在多个version年度行时只取一行 + # (优先匹配请求version,否则取第一条),避免多版本叠加导致总额虚高(幂等加固) + from collections import defaultdict + per_kpi = defaultdict(list) for r in year_budget_rows: - kpi_annual[r.kpi_id] = kpi_annual.get(r.kpi_id, 0) + (r.budget_value or 0) + per_kpi[r.kpi_id].append(r) + kpi_annual = {} + kpi_version_used = {} + for kid, rows in per_kpi.items(): + chosen = next((r for r in rows if r.version == version), rows[0]) + kpi_annual[kid] = chosen.budget_value or 0 + kpi_version_used[kid] = chosen.version if not kpi_annual: raise HTTPException(400, "该年度没有可分解的预算,请先在预算执行中录入年度预算") @@ -268,6 +276,7 @@ def auto_decompose_budget( "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name, "annual_budget": round(annual, 2), + "version_used": kpi_version_used.get(kid), "method": "equal" if not weights else "weighted", "monthly": monthly, "monthly_count": 12, diff --git a/backend/tests/test_data_quality.py b/backend/tests/test_data_quality.py new file mode 100644 index 00000000..4383bcb7 --- /dev/null +++ b/backend/tests/test_data_quality.py @@ -0,0 +1,141 @@ +"""DAMA数据治理规则检查(财务七规则)测试 — 2026-08-30 + +覆盖 data_quality.py 的 _run_governance_checks(governance-check 端点核心逻辑): +7条规则:unit_check/dup_alert/orphan_check/virtual_pollution/entity_check/kpi_completeness/reconciliation ++ 评分规则(error 扣 min(15,count*3),warning 扣 min(10,count*1),规则4 manual 附加扣) + +验收活数据对照(生产环境实测):reconciliation=3、kpi_completeness=53、其余0。 +""" + +import json +from datetime import datetime + +import pytest + +from app.models import ( + KPIDefinition, KPIValue, KPIAlert, CashPlan, BudgetPlan, +) +from app.api.data_quality import _run_governance_checks + + +def _mk_kpi(db, **kw): + defaults = { + "entity_id": 1, "kpi_code": "TEST_001", "kpi_name": "测试KPI", + "status": "active", "target_value": 100.0, "target_yearly": 100.0, + "formula": "x", "data_source": "test", "data_owner": "财务部", + "unit": "元", "kpi_level": "operational", + } + defaults.update(kw) + k = KPIDefinition(**defaults) + db.add(k) + db.commit() + db.refresh(k) + return k + + +def _mk_plan(db, **kw): + defaults = { + "entity_id": 1, "plan_type": "receive", "amount": 10.0, + "plan_date": datetime(2026, 8, 1), "source": "manual", "status": "pending", + } + defaults.update(kw) + p = CashPlan(**defaults) + db.add(p) + db.commit() + db.refresh(p) + return p + + +def _mk_alert(db, kpi_id, plan_id, **kw): + defaults = { + "kpi_id": kpi_id, "alert_type": "cash_plan", "status": "pending", + "alert_message": "应收预警", + "suggestion": json.dumps({"plan_id": plan_id}), + } + defaults.update(kw) + a = KPIAlert(**defaults) + db.add(a) + db.commit() + db.refresh(a) + return a + + +class TestGovernanceSevenRules: + def test_clean_db_all_pass(self, db): + """空库:7条规则全部通过,score=100""" + r = _run_governance_checks(db, 0) + assert r["total_rules"] == 7 + assert r["score"] == 100 + assert r["total_deduct"] == 0 + assert len(r["passed"]) == 7 + for item in r["issues"]: + assert item["count"] == 0 + + def test_all_rules_hit(self, db): + """构造数据触发全部7条规则""" + # KPI-1: active + 有值 + 年度目标100 但预算月度合计200(勾稽差异100%) + k1 = _mk_kpi(db, kpi_code="KPI_001", kpi_name="勾稽KPI", target_yearly=100.0) + db.add(KPIValue(kpi_id=k1.id, entity_id=1, period="2026-06", actual_value=50.0)) + for m in range(1, 13): + db.add(BudgetPlan(kpi_id=k1.id, entity_id=1, period=f"2026-{m:02d}", + budget_value=200.0 / 12, budget_year=2026, budget_month=m, + version="v1.0", status="active")) + db.commit() + + # KPI-2: active 无任何值(KPI完整性命中) + _mk_kpi(db, kpi_code="KPI_002", kpi_name="无值KPI", target_yearly=10.0) + + # KPI-3: 值实体=2 ≠ 定义实体=1(实体归属命中) + k3 = _mk_kpi(db, kpi_code="KPI_003", kpi_name="实体错乱KPI", target_yearly=10.0) + db.add(KPIValue(kpi_id=k3.id, entity_id=2, period="2026-06", actual_value=5.0)) + db.commit() + + # 单位校验:amount=50000 > 10000(单位错乱命中) + _mk_plan(db, id=1, amount=50000.0) + + # 虚拟污染:source=test_import(error)+ source=manual(待人工确认) + _mk_plan(db, id=2, amount=100.0, source="test_import") + _mk_plan(db, id=3, amount=100.0, source="manual") + + # 孤儿预警:plan_id=999 不存在(孤儿命中) + _mk_alert(db, k1.id, 999) + + # 重复预警:plan_id=5 两条 pending(重复命中) + _mk_plan(db, id=5, amount=100.0) + _mk_alert(db, k1.id, 5) + _mk_alert(db, k1.id, 5) + + r = _run_governance_checks(db, 0) + by_rule = {i["rule"]: i for i in r["issues"]} + + assert by_rule["unit_check"]["count"] == 1 + assert by_rule["dup_alert"]["count"] == 1 + assert by_rule["orphan_check"]["count"] == 1 + assert by_rule["virtual_pollution"]["count"] == 1 + assert by_rule["virtual_pollution"]["manual_count"] == 3 # plan#1/#3/#5 默认manual + assert by_rule["entity_check"]["count"] == 1 + assert by_rule["kpi_completeness"]["count"] == 1 + assert by_rule["reconciliation"]["count"] == 1 + + # 评分:5条error × min(15,3)=3 → 15;2条warning × 1 → 2;manual附加 min(10,3)=3 → 总扣20 + assert r["total_deduct"] == 20 + assert r["score"] == 80 + assert len(r["passed"]) == 0 + + def test_entity_scoped(self, db): + """entity_id 限定:只检查该实体数据""" + k1 = _mk_kpi(db, kpi_code="KPI_001", kpi_name="实体1KPI", entity_id=1, target_yearly=10.0) + k2 = _mk_kpi(db, kpi_code="KPI_002", kpi_name="实体2KPI", entity_id=2, target_yearly=10.0) + db.add(KPIValue(kpi_id=k2.id, entity_id=2, period="2026-06", actual_value=5.0)) + db.commit() + + r1 = _run_governance_checks(db, 1) + comp1 = {i["rule"]: i["count"] for i in r1["issues"]} + assert comp1["kpi_completeness"] == 1 # 实体1下KPI_001无值 + assert comp1["entity_check"] == 0 # 实体1下无实体错乱 + + r2 = _run_governance_checks(db, 2) + comp2 = {i["rule"]: i["count"] for i in r2["issues"]} + assert comp2["kpi_completeness"] == 0 # KPI_002有值 + assert comp2["entity_check"] == 0 # 值实体=2与定义实体=2一致 + assert comp2["reconciliation"] == 0 # 无预算行不参与勾稽