Files
cma-management/backend/tests/test_dq_rule_config_20260928.py
wecom-fullstack 0824efc8ed
ci/woodpecker/push/main Pipeline failed
feat(data-quality): 规则配置可编辑入口 + 经营口径排除接线(ASK-20260928-05 尾项)
依据:任总 2026-09-29 08:49「P1和P2批」放行 P1-cma-dq-rule-config-approval-20260923 ①③
     (派单件 0-P1-project-20260929-qrc-write-entry-and-demo-exclusion-fullstack)

三处尾项(本单):

1) 前端可编辑入口(补拍板件 ① 未被上轮覆盖的部分)
   - frontend/src/views/DataQuality.vue:规则配置 Tab 由「只读」改为
     ceo/it 可编辑、finance/business 只读(角色口径与 router/index.ts:45 及
     系统设置页一致);新增「操作/编辑」列(平台级项仅 it 可点、演示账套禁用);
     编辑走 MyDialog(业务弹窗铁律),必填变更理由(>=10 字),取值区间/枚举
     由后端返回字段呈现,越界/非法一律如实回显后端 detail,前端不自造校验通过。

2) scope.excluded_entities 接线 + 出厂默认 [3,4,5,6](拍板件 ③-2「排除」)
   - app/dq_rule_config.py:默认由 [] 改为 [3,4,5,6];新增单一事实源
     EXCLUDED_ENTITIES_KEY / EXCLUDED_ENTITIES_FIELD、normalize_entity_ids()、
     excluded_entities_from_resolution()、excluded_entity_ids()、
     is_excluded_from_business_caliber()。
   - app/api/data_quality.py:新增 _excluded_clause()(仅全账套口径 eid=0 生效,
     COALESCE(col,1) NOT IN (...) 参数化,NULL 归属不吞行)与 _business_excluded();
     七条规则的明细查询与 _detail_count 全部叠加排除子句;_plan_id_set /
     _pending_alert_plan_links 支持 excluded(播报口径同源)。
   - 响应新增 business_caliber{caliber,exclusion_applied,excluded_entities,included}
     与 scope_excluded_entities;单账套查询保持展示、仅标注不计入经营口径。
   - PUT /rule-config:平台级项改写全账套模板行(entity_id=0),审计 detail 与响应
     增 target_entity_id;GET 增 scope_note 与 write_contract{roles,platform_roles,
     min_reason_len,entity_config_entities,bot_channel_write,endpoint}。

3) 修复两处实现缺陷(本单测试新增用例的 RED 驱动)
   - excluded_entities_from_resolution() 取键与 resolve()["scope"] 的归组形态不一致
     (配了 scope. 全名去取已归组后的短名字典)⇒ 恒返回空集,接线静默失效;
     改为短名优先、全名兼容(EXCLUDED_ENTITIES_FIELD)。
   - upsert_param() 全局项 before 取值多一层 rule_key 查找 ⇒ before 恒 None;
     改为全局项直接以 param_key 为键。

4) 测试:tests/test_dq_rule_config_20260928.py 3 处既有断言按新口径更新 +
   新增 E 类 8 例(排除默认/单一事实源、全账套与单账套两种口径面、A/B 聚合差异
   只来自被排除账套、明细计数同源、平台级项改名单即时生效、平台级写落模板行)。

不改:判定表达式本体 / 级别归属 / 评分函数 / 规则集(DAMA §4 锁死 10 类)、
      unit_check.amount_threshold(保持 10000)、virtual_pollution 关键词表与来源白名单。

边界:本提交只含 4 文件(backend/app/dq_rule_config.py、
      backend/app/api/data_quality.py、backend/tests/test_dq_rule_config_20260928.py、
      frontend/src/views/DataQuality.vue);未裹挟他人在制件
      (audit_log.py / migrate_subjects_entity_id_20260923.py / package.json)。

证据:pending-tasks/_evidence/ask5-tail-20260929/
      (pytest 全量 1 failed/1516 passed + 失败项归因 A/B、vue-tsc rc=0、
       ab-exclusion-pre-write.json 只读 A/B 原始输出、gate dry-run 等)
2026-09-29 13:00:48 +08:00

688 lines
39 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""CMA 财务七规则可配置化(ASK-20260928-05)回归套件 —— 规格 / 三层解析 / 引擎接线 / 端点。
任务:任总 2026-09-28 13:38「放行 4 和 5」→ 最小实现 = 新表 `quality_rule_config` + 端点 + 只读 UI,
外加本单唯一的行为变更(去掉 `sync` 污染判定)与「口径用清单默认值」。
判据分四组:
A 规格面:37 规则级参数 + 2 全局项;默认值 == 清单 §7;边界「拒绝 / 钳制」两类;锁死项 400。
B 引擎接线(**真函数** `_run_governance_checks` + 夹具库,非重写逻辑):
· 默认配置 = 原硬编码口径(零行为差异,除 sync);
· **sync A/B 三态**:默认(0) / 恢复旧关键词表(命中) / 白名单豁免(0) —— 证明配置真的接线;
· 阈值、条数下限、开关、扣分上限系数、比较方向、明细条数逐项可配;
· `/rule-detail` total 与新口径一致性(两处同源,不漂移)。
C 端点面:GET 只读四角色可见;PUT 仅 ceo/it(平台级仅 it);Bot 通道禁写;演示账套拒写;
change_reason 必填;锁死/未知参数 400;越界拒绝或钳制;写行与 `operation_logs` 同事务。
D 韧性:配置表缺失(DDL 未落地)时读路径回落内置默认、不 500(部署顺序解耦)。
"""
import json
from datetime import datetime
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app import dq_rule_config as dqcfg
from app.api.data_quality import _run_governance_checks
from app.models import (
KPIDefinition, KPIValue, KPIAlert, CashPlan, BudgetPlan, QualityRuleConfig,
OperationLog, Entity,
)
from tests.conftest import create_test_user, get_token_for_user, auth_header
REASON = "回归用例:调整治理规则阈值以验证配置化接线(≥10 字)"
# ══════════════════════════════════════════════════════════════════
# 夹具
# ══════════════════════════════════════════════════════════════════
def _mk_kpi(db: Session, **kw) -> KPIDefinition:
defaults = {
"entity_id": 1, "kpi_code": "KPI_X", "kpi_name": "夹具KPI", "status": "active",
"target_value": 100.0, "target_yearly": 100.0, "formula": "x",
"data_source": "test", "data_owner": "财务部", "unit": "元",
}
defaults.update(kw)
k = KPIDefinition(**defaults)
db.add(k)
db.commit()
db.refresh(k)
return k
def _mk_plan(db: Session, **kw) -> CashPlan:
defaults = {
"entity_id": 1, "plan_type": "receive", "amount": 10.0, "source": "manual",
"status": "pending", "plan_date": datetime(2026, 8, 1),
}
defaults.update(kw)
p = CashPlan(**defaults)
db.add(p)
db.commit()
db.refresh(p)
return p
def _mk_row(db: Session, rule_key: str, param_key: str, value, **kw) -> QualityRuleConfig:
"""直接落/更新一行配置(模拟「已存在配置」;端点写路径另有用例)。"""
entity_id = kw.pop("entity_id", 1)
exist = (db.query(QualityRuleConfig)
.filter_by(entity_id=entity_id, rule_key=rule_key, param_key=param_key).first())
if exist is not None:
exist.param_value = json.dumps(value, ensure_ascii=False)
exist.value_type = dqcfg.value_type_of(rule_key, param_key)
db.commit()
db.refresh(exist)
return exist
row = QualityRuleConfig(
entity_id=entity_id, rule_key=rule_key, param_key=param_key,
param_value=json.dumps(value, ensure_ascii=False),
value_type=dqcfg.value_type_of(rule_key, param_key),
open_level=dqcfg.open_level_of(rule_key, param_key),
is_default=kw.pop("is_default", False), updated_by=kw.pop("updated_by", "fixture"),
**kw,
)
db.add(row)
db.commit()
db.refresh(row)
return row
def _counts(db: Session, eid: int = 0):
"""跑一次真函数,返回 (按规则聚合的 issues, 完整结果)。"""
r = _run_governance_checks(db, eid)
return {i["rule"]: i for i in r["issues"]}, r
def _vp_fixture(db: Session) -> None:
"""规则4 夹具:5 条正常同步来源(酣客形态)+ 2 条测试来源 + 1 条 manual + 1 条博海来源。"""
for i in range(5):
_mk_plan(db, id=100 + i, source="zxt-sync", amount=10.0 + i)
for i in range(2):
_mk_plan(db, id=200 + i, source="test_import", amount=20.0 + i)
_mk_plan(db, id=300, source="manual", amount=30.0)
_mk_plan(db, id=301, source="bohai_ar_20260630", amount=40.0)
# ══════════════════════════════════════════════════════════════════
# A 规格面
# ══════════════════════════════════════════════════════════════════
class TestSpecSurface:
def test_param_count_is_37_rule_level_plus_2_global(self):
"""清单 §10.2:37 个规则级参数 + 2 个全局项;且 (rule, param) 无重复。"""
assert dqcfg.count_rule_params() == 37
assert len(dqcfg.GLOBAL_SPEC) == 2
pairs = dqcfg.all_param_pairs()
assert len(pairs) == 39
assert len(set(pairs)) == 39
per_rule = {r: len(dqcfg.SPEC[r]) for r in dqcfg.RULE_KEYS}
assert per_rule == {"unit_check": 5, "dup_alert": 5, "orphan_check": 4,
"virtual_pollution": 9, "entity_check": 4,
"kpi_completeness": 4, "reconciliation": 6}
def test_defaults_match_inventory_table(self):
"""清单 §7 建议默认值逐项(口径「用清单默认值」);唯一行为变更 = 关键词表无 sync。"""
vp = dqcfg.SPEC["virtual_pollution"]
assert vp["suspect_keywords"]["default"] == ["test", "虚拟", "demo"]
assert "sync" not in vp["suspect_keywords"]["default"]
assert vp["source_whitelist"]["default"] == [
"zxt-sync", "bohai_ar", "bohai_ar_20260630", "bohai_prepay_20260630",
"bohai_ap_20260630", "receivables_migrate"]
assert vp["manual_source_value"]["default"] == "manual"
assert vp["manual_cap"]["default"] == 10 and vp["manual_factor"]["default"] == 1
assert dqcfg.SPEC["unit_check"]["amount_threshold"]["default"] == 10000
assert dqcfg.SPEC["dup_alert"]["min_count"]["default"] == 2
assert dqcfg.SPEC["reconciliation"]["diff_threshold_pct"]["default"] == 20
assert dqcfg.SPEC["reconciliation"]["compare_mode"]["default"] == "abs"
for rule in dqcfg.RULE_KEYS:
caps = dqcfg.SPEC[rule]
assert caps["enabled"]["default"] is True
assert caps["detail_limit"]["default"] == 10
assert caps["deduct_factor"]["default"] == (3 if rule in
("unit_check", "dup_alert", "orphan_check", "virtual_pollution", "entity_check") else 1)
assert dqcfg.GLOBAL_SPEC["detail_limit"]["default"] == 10
# F6 排除名单出厂默认 = [3,4,5,6](2026-09-29 尾项,拍板件 ③-2「排除」;DAMA §6.4)
assert dqcfg.GLOBAL_SPEC["scope.excluded_entities"]["default"] == [3, 4, 5, 6]
def test_locked_items_cover_inventory_ten(self):
"""清单 §4 十类锁死面完整登记(少一类即视为私自扩面)。"""
assert len(dqcfg.LOCKED_ITEMS) == 10
keys = {it["key"] for it in dqcfg.LOCKED_ITEMS}
assert {"expression", "level", "score_fn", "entity_scope", "rule_set",
"match_mode", "completeness_caliber", "null_entity_policy",
"bot_write", "weight_range"} == keys
@pytest.mark.parametrize("rule,param,value,expect_ok", [
("unit_check", "amount_threshold", 5, False), # 越界 → 拒绝
("unit_check", "amount_threshold", 1000, True),
("dup_alert", "min_count", 1, False), # 1 无意义 → 拒绝
("dup_alert", "min_count", 3, True),
("reconciliation", "diff_threshold_pct", 500, False), # 越界 → 拒绝
("reconciliation", "compare_mode", "both", False), # 非枚举 → 拒绝
("unit_check", "enabled", "yes-please", False), # 非布尔 → 拒绝
("virtual_pollution", "suspect_keywords", ["test", ""], False), # 空元素 → 拒绝
("virtual_pollution", "suspect_keywords", ["te%st"], False), # 通配符 → 拒绝
("virtual_pollution", "suspect_keywords", ["a" * 33], False), # 元素超长 → 拒绝
("virtual_pollution", "manual_source_value", "", False), # 空串 → 拒绝
])
def test_validate_reject_cases(self, rule, param, value, expect_ok):
r = dqcfg.validate_param(rule, param, value)
assert r["ok"] is expect_ok
if not expect_ok:
assert r["error"]
def test_validate_clamp_cases(self):
"""权重/明细条数越界 → 钳制到端点 + 告警(清单 §3 的「钳制 + 告警」)。"""
r = dqcfg.validate_param("kpi_completeness", "deduct_cap", 999)
assert r["ok"] and r["value"] == 50 and r["warning"]
r = dqcfg.validate_param("unit_check", "deduct_factor", -3)
assert r["ok"] and r["value"] == 0 and r["warning"]
r = dqcfg.validate_param("unit_check", "detail_limit", 0)
assert r["ok"] and r["value"] == 1 and r["warning"]
def test_locked_and_unknown_params_rejected(self):
for param in ("level", "expression", "score_fn", "regex", "exempt_kpi_codes"):
r = dqcfg.validate_param("virtual_pollution", param, "x")
assert not r["ok"]
assert "锁死" in r["error"]
r = dqcfg.validate_param("unit_check", "no_such_param", 1)
assert not r["ok"] and "未知参数" in r["error"]
# ══════════════════════════════════════════════════════════════════
# B 引擎接线(真函数 + 夹具库)
# ══════════════════════════════════════════════════════════════════
class TestEngineWiring:
def test_default_zero_behavior_change_except_sync(self, db):
"""默认配置 = 原硬编码口径:阈值 10000 / 条数 2 / 差异 20%;sync 来源不再算污染。"""
_vp_fixture(db)
_mk_plan(db, id=400, amount=50000.0, source="bohai_ar") # 单位校验命中(>10000)
k = _mk_kpi(db, kpi_code="K1", target_yearly=100.0)
db.add(KPIValue(kpi_id=k.id, entity_id=1, period="2026-06", actual_value=50.0))
for m in range(1, 13):
db.add(BudgetPlan(kpi_id=k.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"))
_mk_kpi(db, kpi_code="K2", target_yearly=10.0) # 无值 → KPI完整性
db.commit()
issues, r = _counts(db, 1)
assert issues["unit_check"]["count"] == 1
assert issues["virtual_pollution"]["count"] == 2 # 只剩 test_import 两条
assert issues["virtual_pollution"]["manual_count"] == 1
assert issues["kpi_completeness"]["count"] == 1
assert issues["reconciliation"]["count"] == 1
assert r["config_version"] and len(r["config_version"]) == 12
assert r["disabled"] == []
assert r["config_source"]["table_ready"] is True
assert r["scope_excluded_entities"] == [3, 4, 5, 6]
assert r["excluded_from_business_caliber"] is False # eid=1 不在名单
assert r["business_caliber"]["caliber"] == "entity"
def test_sync_ab_three_states(self, db):
"""A/B:默认(0 命中) → 恢复旧关键词表+空白名单(7 命中) → 只留白名单(2 命中)。
三态各跑一次**同一个** `_run_governance_checks`(不是重写逻辑),
证明「配置真的驱动引擎」+「去掉 sync 确实消除了 100% 假阳性」。
"""
_vp_fixture(db)
issues, _ = _counts(db, 1)
assert issues["virtual_pollution"]["count"] == 2, "默认:zxt-sync 不再误判"
legacy = _mk_row(db, "virtual_pollution", "suspect_keywords",
["test", "sync", "虚拟", "demo"]) # 改前口径
_mk_row(db, "virtual_pollution", "source_whitelist", []) # 改前:无白名单
issues, r = _counts(db, 1)
assert issues["virtual_pollution"]["count"] == 7, "恢复旧口径 → zxt-sync 5 条重新被误判"
# 扣分 = min(15, 7×3) + manual 附加 min(10, 1×1) = 16(夹具里有 1 条 manual)
assert issues["virtual_pollution"]["deducted"] == 16
db.query(QualityRuleConfig).filter(QualityRuleConfig.rule_key == "virtual_pollution").delete()
_mk_row(db, "virtual_pollution", "source_whitelist", ["zxt-sync"])
issues, _ = _counts(db, 1)
assert issues["virtual_pollution"]["count"] == 2, "白名单豁免优先于关键词(豁免即不算污染)"
db.delete(legacy)
db.commit()
def test_threshold_and_switch_and_caps(self, db):
_mk_plan(db, id=500, amount=150.0)
_mk_plan(db, id=501, amount=50000.0)
issues, _ = _counts(db, 1)
assert issues["unit_check"]["count"] == 1 # 默认 10000
_mk_row(db, "unit_check", "amount_threshold", 100) # 下调阈值 → 2 条
issues, _ = _counts(db, 1)
assert issues["unit_check"]["count"] == 2
_mk_row(db, "unit_check", "deduct_cap", 0) # 只报不计分
issues, r = _counts(db, 1)
assert issues["unit_check"]["count"] == 2 and issues["unit_check"]["deducted"] == 0
_mk_row(db, "unit_check", "enabled", False) # 关开关 → 零命中零扣分
issues, r = _counts(db, 1)
assert issues["unit_check"]["count"] == 0 and issues["unit_check"]["deducted"] == 0
assert issues["unit_check"]["enabled"] is False
assert "unit_check" in r["disabled"] and "unit_check" not in r["passed"]
assert r["total_rules"] == 7 # 规则集锁死 7 条
def test_dup_alert_min_count(self, db):
k = _mk_kpi(db, kpi_code="K9", target_yearly=10.0)
_mk_plan(db, id=600, source="manual")
for _ in range(2): # plan#600 两条 pending
db.add(KPIAlert(kpi_id=k.id, alert_type="cash_plan", status="pending",
alert_message="x", suggestion=json.dumps({"plan_id": 600})))
db.commit()
issues, _ = _counts(db, 1)
assert issues["dup_alert"]["count"] == 1 # 默认 ≥2 即重复
_mk_row(db, "dup_alert", "min_count", 3)
issues, _ = _counts(db, 1)
assert issues["dup_alert"]["count"] == 0 # 阈值抬到 3 → 不再重复
def test_reconciliation_threshold_and_direction(self, db):
k = _mk_kpi(db, kpi_code="K7", target_yearly=100.0)
for m in range(1, 13):
db.add(BudgetPlan(kpi_id=k.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")) # 合计 200 → +100%
k2 = _mk_kpi(db, kpi_code="K8", target_yearly=100.0)
for m in range(1, 13):
db.add(BudgetPlan(kpi_id=k2.id, entity_id=1, period=f"2026-{m:02d}",
budget_value=50.0 / 12, budget_year=2026, budget_month=m,
version="v1.0", status="active")) # 合计 50 → -50%
db.commit()
issues, _ = _counts(db, 1)
assert issues["reconciliation"]["count"] == 2 # 默认 abs 双侧 >20%
_mk_row(db, "reconciliation", "diff_threshold_pct", 150)
issues, _ = _counts(db, 1)
assert issues["reconciliation"]["count"] == 0 # 阈值抬高
_mk_row(db, "reconciliation", "diff_threshold_pct", 20)
_mk_row(db, "reconciliation", "compare_mode", "upper_only")
issues, _ = _counts(db, 1)
assert issues["reconciliation"]["count"] == 1 # 只看「超目标」→ 低于目标的 K8 不算
_mk_row(db, "reconciliation", "compare_mode", "lower_only")
issues, _ = _counts(db, 1)
assert issues["reconciliation"]["count"] == 1 # 只看「低于目标」→ K8
def test_detail_limit_configurable(self, db):
for i in range(4):
_mk_plan(db, id=700 + i, amount=50000.0)
issues, _ = _counts(db, 1)
assert len([d for d in issues["unit_check"]["detail"] if not d.startswith("…")]) == 4
_mk_row(db, "unit_check", "detail_limit", 2)
issues, _ = _counts(db, 1)
rows = [d for d in issues["unit_check"]["detail"] if not d.startswith("…")]
assert len(rows) == 2 and any(d.startswith("…") for d in issues["unit_check"]["detail"])
def test_entity_config_isolation_no_cross_entity_fallback(self, db):
"""账套隔离:账套 2 的行不得被账套 1 读到(禁止跨账套回退)。"""
_mk_plan(db, id=800, entity_id=1, amount=50000.0)
_mk_plan(db, id=801, entity_id=2, amount=50000.0)
_mk_row(db, "unit_check", "amount_threshold", 1000000, entity_id=2)
issues1, _ = _counts(db, 1)
issues2, _ = _counts(db, 2)
assert issues1["unit_check"]["count"] == 1 # 账套1 用默认 10000
assert issues2["unit_check"]["count"] == 0 # 账套2 用自己那行 1000000
# ══════════════════════════════════════════════════════════════════
# C 端点面
# ══════════════════════════════════════════════════════════════════
def _ceo_token(db: Session, client: TestClient) -> str:
create_test_user(db, username="cfgceo", role="ceo")
return get_token_for_user(client, "cfgceo")
class TestConfigEndpoints:
def test_get_rule_config_readonly_shape(self, db, client):
token = _ceo_token(db, client)
r = client.get("/api/cma/data-quality/rule-config", headers=auth_header(token))
assert r.status_code == 200, r.text
body = r.json()
assert body["entity_id"] == 1
assert body["entity_config_enabled"] is True
assert body["config_entities"] == [1, 2]
assert body["rule_param_count"] == 37
assert set(body["rules"].keys()) == set(dqcfg.RULE_KEYS)
vp = body["rules"]["virtual_pollution"]["params"]
assert vp["suspect_keywords"]["value"] == ["test", "虚拟", "demo"]
assert vp["suspect_keywords"]["source"] == "builtin"
assert vp["suspect_keywords"]["open_level"] == "entity"
assert body["global_params"]["detail_limit"]["value"] == 10
assert body["scope"]["excluded_entities"] == [3, 4, 5, 6] # F6 出厂默认(尾项 2026-09-29)
assert len(body["locked"]) == 10
assert body["readonly"] is True
# 写契约(前端编辑入口的判据来源,与 PUT 的 require_role 同口径)
wc = body["write_contract"]
assert wc["roles"] == ["ceo", "it"] and wc["platform_roles"] == ["it"]
assert wc["min_reason_len"] == dqcfg.MIN_REASON_LEN == 10
assert wc["bot_channel_write"] is False
assert wc["entity_config_entities"] == [1, 2]
assert wc["endpoint"] == "PUT /api/cma/data-quality/rule-config"
assert "已接线" in body["scope_note"]
# 前端呈现所需的类型/区间提示(仅提示,非校验通过依据)
th = body["rules"]["unit_check"]["params"]["amount_threshold"]
assert th["value_type"] == "int" and th["bounds"] == [100, 1000000]
assert th["overflow"] == "reject"
assert body["global_params"]["scope.excluded_entities"]["value"] == [3, 4, 5, 6]
assert body["global_params"]["scope.excluded_entities"]["open_level"] == "platform"
def test_get_rule_config_finance_readonly_allowed(self, db, client):
create_test_user(db, username="cfgfin", role="finance")
token = get_token_for_user(client, "cfgfin")
assert client.get("/api/cma/data-quality/rule-config",
headers=auth_header(token)).status_code == 200
def test_put_requires_ceo_or_it(self, db, client):
create_test_user(db, username="cfgfin2", role="finance")
token = get_token_for_user(client, "cfgfin2")
r = client.put("/api/cma/data-quality/rule-config", headers=auth_header(token),
json={"rule_key": "unit_check", "param_key": "amount_threshold",
"value": 5000, "change_reason": REASON})
assert r.status_code == 403, r.text
def test_put_writes_row_and_audit_same_txn(self, db, client):
token = _ceo_token(db, client)
before_version = client.get("/api/cma/data-quality/rule-config",
headers=auth_header(token)).json()["config_version"]
r = client.put("/api/cma/data-quality/rule-config", headers=auth_header(token),
json={"rule_key": "unit_check", "param_key": "amount_threshold",
"value": 5000, "change_reason": REASON})
assert r.status_code == 200, r.text
body = r.json()
assert body["success"] and body["before"] == 10000 and body["after"] == 5000
assert body["warning"] is None
row = db.query(QualityRuleConfig).filter_by(
entity_id=1, rule_key="unit_check", param_key="amount_threshold").one()
assert json.loads(row.param_value) == 5000 and row.updated_by == "cfgceo"
log = db.query(OperationLog).filter_by(action="update_dq_rule_config").one()
assert log.user_id is not None # 清单 §5.2:user_id 必写
assert log.target_type == "dq_rule_config" and log.target_id == row.id
assert log.detail["entity_id"] == 1 and log.detail["before"] == 10000
assert log.detail["after"] == 5000 and log.detail["reason"] == REASON
# 生效回读:GET 显示来源=账套覆盖;配置指纹随之变化
g = client.get("/api/cma/data-quality/rule-config", headers=auth_header(token)).json()
entry = g["rules"]["unit_check"]["params"]["amount_threshold"]
assert entry["value"] == 5000 and entry["source"] == "entity"
assert g["config_version"] == body["config_version"] != before_version
def test_put_locked_unknown_and_reason_rejected(self, db, client):
token = _ceo_token(db, client)
base = {"rule_key": "unit_check", "param_key": "level", "value": "warning",
"change_reason": REASON}
r = client.put("/api/cma/data-quality/rule-config", headers=auth_header(token), json=base)
assert r.status_code == 400 and "锁死" in r.json()["detail"]
base["param_key"] = "nope"
assert client.put("/api/cma/data-quality/rule-config", headers=auth_header(token),
json=base).status_code == 400
r = client.put("/api/cma/data-quality/rule-config", headers=auth_header(token),
json={"rule_key": "unit_check", "param_key": "amount_threshold",
"value": 5000, "change_reason": "太短"})
assert r.status_code == 400 and "change_reason" in r.json()["detail"]
def test_put_boundary_reject_and_clamp(self, db, client):
token = _ceo_token(db, client)
r = client.put("/api/cma/data-quality/rule-config", headers=auth_header(token),
json={"rule_key": "unit_check", "param_key": "amount_threshold",
"value": 5, "change_reason": REASON})
assert r.status_code == 400 and "越界" in r.json()["detail"]
r = client.put("/api/cma/data-quality/rule-config", headers=auth_header(token),
json={"rule_key": "unit_check", "param_key": "deduct_cap",
"value": 999, "change_reason": REASON})
assert r.status_code == 200 and r.json()["after"] == 50 and r.json()["warning"]
def test_platform_param_requires_it(self, db, client):
ceo = _ceo_token(db, client)
payload = {"rule_key": "unit_check", "param_key": "detail_limit", "value": 25,
"change_reason": REASON}
r = client.put("/api/cma/data-quality/rule-config", headers=auth_header(ceo), json=payload)
assert r.status_code == 403, r.text
assert "平台级" in r.json()["detail"]
create_test_user(db, username="cma", role="it")
it = get_token_for_user(client, "cma")
r = client.put("/api/cma/data-quality/rule-config", headers=auth_header(it), json=payload)
assert r.status_code == 200 and r.json()["after"] == 25
def test_bot_channel_write_forbidden(self, db, client):
token = _ceo_token(db, client)
headers = {**auth_header(token), "X-BOT-KEY": "dummy-bot-key"}
r = client.put("/api/cma/data-quality/rule-config", headers=headers,
json={"rule_key": "unit_check", "param_key": "amount_threshold",
"value": 5000, "change_reason": REASON})
assert r.status_code == 403 and "Bot" in r.json()["detail"]
def test_demo_entity_write_rejected(self, db, client):
"""口径②:演示/测试账套只读、不开配置(写 400,读仍 200)。"""
db.add(Entity(id=6, name="演示企业", short_name="演示", status="demo"))
db.commit()
u = create_test_user(db, username="cfgdemo", role="ceo")
from app.models import UserEntity
db.add(UserEntity(user_id=u.id, entity_id=1, granted_by=1))
db.add(UserEntity(user_id=u.id, entity_id=6, granted_by=1))
db.commit()
# 登录 entity 6(该账号已显式授权 1/6)
resp = client.post("/api/cma/auth/login", json={
"username": "cfgdemo", "password": "test-fixture-pass", "entity_id": 6})
assert resp.status_code == 200, resp.text
tok6 = resp.json()["token"]
assert client.get("/api/cma/data-quality/rule-config",
headers=auth_header(tok6)).status_code == 200
assert client.get("/api/cma/data-quality/rule-config",
headers=auth_header(tok6)).json()["entity_config_enabled"] is False
r = client.put("/api/cma/data-quality/rule-config", headers=auth_header(tok6),
json={"rule_key": "unit_check", "param_key": "amount_threshold",
"value": 5000, "change_reason": REASON})
assert r.status_code == 400 and "不开放规则配置" in r.json()["detail"]
def test_history_endpoint(self, db, client):
token = _ceo_token(db, client)
client.put("/api/cma/data-quality/rule-config", headers=auth_header(token),
json={"rule_key": "dup_alert", "param_key": "min_count", "value": 3,
"change_reason": REASON})
r = client.get("/api/cma/data-quality/rule-config/history", headers=auth_header(token))
assert r.status_code == 200
items = r.json()["items"]
assert items and items[0]["rule_key"] == "dup_alert"
assert items[0]["before"] == 2 and items[0]["after"] == 3
assert items[0]["reason"] == REASON and items[0]["user_id"] is not None
def test_rule_detail_total_matches_governance_check(self, db, client):
"""两处口径同源:改了阈值后 `/rule-detail` total == `governance-check` count。"""
_mk_plan(db, entity_id=1, id=900, amount=150.0)
token = _ceo_token(db, client)
client.put("/api/cma/data-quality/rule-config", headers=auth_header(token),
json={"rule_key": "unit_check", "param_key": "amount_threshold",
"value": 100, "change_reason": REASON})
gov = client.get("/api/cma/data-quality/governance-check",
headers=auth_header(token)).json()
count = {i["rule"]: i["count"] for i in gov["issues"]}["unit_check"]
detail = client.get("/api/cma/data-quality/rule-detail?rule=unit_check",
headers=auth_header(token)).json()
assert count == 1 and detail["total"] == count
assert detail["items"][0]["key_fields"]["threshold"] == ">100(万元口径可疑)"
# ══════════════════════════════════════════════════════════════════
# D 韧性:DDL 未落地时不 500
# ══════════════════════════════════════════════════════════════════
class TestTableMissingTolerance:
def test_read_falls_back_to_builtin_when_table_absent(self, db, client):
from tests import conftest as cf
token = _ceo_token(db, client)
_mk_plan(db, entity_id=1, id=950, amount=50000.0)
QualityRuleConfig.__table__.drop(bind=cf.TEST_ENGINE)
try:
assert dqcfg.table_ready(db) is False
r = client.get("/api/cma/data-quality/rule-config", headers=auth_header(token))
assert r.status_code == 200, r.text
assert r.json()["table_ready"] is False
assert r.json()["rules"]["unit_check"]["params"]["amount_threshold"]["value"] == 10000
gov = client.get("/api/cma/data-quality/governance-check",
headers=auth_header(token))
assert gov.status_code == 200, gov.text
assert {i["rule"]: i["count"] for i in gov.json()["issues"]}["unit_check"] == 1
finally:
QualityRuleConfig.__table__.create(bind=cf.TEST_ENGINE)
# ══════════════════════════════════════════════════════════════════
# E 经营口径排除(F6 / ASK-05 ③-2 接线,2026-09-29 尾项)
# 口径:名单只对**全账套口径(eid=0)**生效 —— 被排除账套的行不计入评分/明细;
# 单账套查询(eid!=0)保持展示,仅由响应字段标注「不计入经营口径」。
# ══════════════════════════════════════════════════════════════════
GLOBAL_KEY = dqcfg.GLOBAL_RULE_KEY # "global"
def _gov(db: Session, eid: int):
"""跑真函数,返回 (各规则 count 字典, 完整结果)。"""
r = _run_governance_checks(db, eid)
return {i["rule"]: i["count"] for i in r["issues"]}, r
def _seed_caliber_rows(db: Session) -> None:
"""账套1(计入经营口径)+ 账套6(演示企业,被排除)各一条 unit_check 命中行。"""
db.add(Entity(id=6, name="演示企业", short_name="演示", status="demo"))
db.commit()
_mk_plan(db, entity_id=1, id=8101, amount=50000.0, source="batch_import")
_mk_plan(db, entity_id=6, id=8102, amount=80000.0, source="batch_import")
class TestBusinessCaliberExclusion:
def test_default_is_346_and_single_source(self, db):
"""出厂默认 [3,4,5,6];编号规范化;判据函数与解析结果同源。"""
assert dqcfg.GLOBAL_SPEC["scope.excluded_entities"]["default"] == [3, 4, 5, 6]
assert dqcfg.excluded_entity_ids(db, 0) == [3, 4, 5, 6]
assert dqcfg.normalize_entity_ids([6, "5", 5, None, "x", -1, 3, 4]) == [6, 5, 3, 4]
assert dqcfg.is_excluded_from_business_caliber(6, [3, 4, 5, 6]) is True
assert dqcfg.is_excluded_from_business_caliber(1, [3, 4, 5, 6]) is False
assert dqcfg.is_excluded_from_business_caliber(0, [3, 4, 5, 6]) is False # 全账套标识本身不排除
def test_caliber_surface_full_scope(self, db):
"""全账套口径(eid=0):exclusion_applied=True,excluded_from_business_caliber=False。"""
_seed_caliber_rows(db)
counts, r = _gov(db, 0)
assert r["scope_excluded_entities"] == [3, 4, 5, 6]
assert r["business_caliber"] == {
"caliber": "business", "exclusion_applied": True,
"excluded_entities": [3, 4, 5, 6], "included": True,
}
assert r["excluded_from_business_caliber"] is False
assert counts["unit_check"] == 1 # 演示账套那条(80000)已被排除
def test_caliber_surface_single_entity_keeps_display(self, db):
"""单账套口径(eid=6):仍可查看自己的结果,仅标注不计入经营口径。"""
_seed_caliber_rows(db)
counts, r = _gov(db, 6)
assert counts["unit_check"] == 1 # 展示保留
assert r["business_caliber"]["caliber"] == "entity"
assert r["business_caliber"]["exclusion_applied"] is False
assert r["business_caliber"]["included"] is False
assert r["excluded_from_business_caliber"] is True # 标注=不计入
def test_ab_aggregate_only_excluded_entity_rows_drop(self, db):
"""A/B:名单 [] (关)vs [3,4,5,6](开)—— 差异**只能**来自被排除账套的行。"""
_seed_caliber_rows(db)
_mk_row(db, GLOBAL_KEY, dqcfg.EXCLUDED_ENTITIES_KEY, [], entity_id=0)
ca, ra = _gov(db, 0)
assert ra["business_caliber"]["exclusion_applied"] is False
assert ca["unit_check"] == 2
_mk_row(db, GLOBAL_KEY, dqcfg.EXCLUDED_ENTITIES_KEY, [3, 4, 5, 6], entity_id=0)
cb, rb = _gov(db, 0)
assert rb["business_caliber"]["exclusion_applied"] is True
assert cb["unit_check"] == 1
assert ra["score"] < rb["score"] # 排除后扣分减少 ⇒ 分数上升
for rule in ca: # 其余规则逐项不变(无越界影响)
if rule != "unit_check":
assert ca[rule] == cb[rule], rule
def test_ab_detail_count_same_source(self, db):
"""明细总数与评分同源:`_detail_count` 在同名单下的读数与 issues.count 一致。"""
from app.api.data_quality import _detail_count
_seed_caliber_rows(db)
with_exc = _detail_count(db, "unit_check", 0, excluded=[3, 4, 5, 6])
without = _detail_count(db, "unit_check", 0, excluded=[])
assert without == 2 and with_exc == 1
counts, _r = _gov(db, 0)
assert counts["unit_check"] == with_exc
# 单账套口径不受名单影响
assert _detail_count(db, "unit_check", 6, excluded=[3, 4, 5, 6]) == 1
def test_platform_write_of_excluded_list_changes_caliber(self, db, client):
"""平台级项(仅 it):写向全账套模板行;改名单纯读即改变经营口径(零代码回退)。"""
_seed_caliber_rows(db)
create_test_user(db, username="cmait2", role="it")
it = get_token_for_user(client, "cmait2")
r = client.put("/api/cma/data-quality/rule-config", headers=auth_header(it),
json={"rule_key": GLOBAL_KEY, "param_key": dqcfg.EXCLUDED_ENTITIES_KEY,
"value": [], "change_reason": REASON})
assert r.status_code == 200, r.text
assert r.json()["target_entity_id"] == 0 and r.json()["after"] == []
row = db.query(QualityRuleConfig).filter_by(
entity_id=0, rule_key=GLOBAL_KEY, param_key=dqcfg.EXCLUDED_ENTITIES_KEY).one()
assert row.updated_by == "cmait2"
counts, res = _gov(db, 0)
assert res["business_caliber"]["exclusion_applied"] is False and counts["unit_check"] == 2
# 恢复原型(幂等:同键原位更新,不新增行)
back = client.put("/api/cma/data-quality/rule-config", headers=auth_header(it),
json={"rule_key": GLOBAL_KEY, "param_key": dqcfg.EXCLUDED_ENTITIES_KEY,
"value": [3, 4, 5, 6], "change_reason": REASON})
assert back.status_code == 200 and back.json()["before"] == []
counts2, res2 = _gov(db, 0)
assert res2["business_caliber"]["exclusion_applied"] is True and counts2["unit_check"] == 1
assert db.query(QualityRuleConfig).filter_by(
entity_id=0, rule_key=GLOBAL_KEY, param_key=dqcfg.EXCLUDED_ENTITIES_KEY).count() == 1
class TestPlatformWriteTarget:
"""平台级项落全账套模板行(entity_id=0)——「一处写、全账套生效」。"""
def test_it_writes_template_row_and_audit_carries_target(self, db, client):
create_test_user(db, username="cmait3", role="it")
it = get_token_for_user(client, "cmait3")
r = client.put("/api/cma/data-quality/rule-config", headers=auth_header(it),
json={"rule_key": "unit_check", "param_key": "detail_limit", "value": 25,
"change_reason": REASON})
assert r.status_code == 200, r.text
body = r.json()
assert body["entity_id"] == 1 and body["target_entity_id"] == 0
assert "模板行" in body["note"]
assert db.query(QualityRuleConfig).filter_by(
entity_id=0, rule_key="unit_check", param_key="detail_limit").count() == 1
assert db.query(QualityRuleConfig).filter_by(
entity_id=1, rule_key="unit_check", param_key="detail_limit").count() == 0
log = db.query(OperationLog).filter_by(action="update_dq_rule_config").one()
assert log.detail["entity_id"] == 1 and log.detail["target_entity_id"] == 0
assert log.detail["open_level"] == "platform"
def test_entity_level_param_still_writes_own_row(self, db, client):
"""账套级项不受模板改写影响:仍写本账套行,target_entity_id == 本账套。"""
token = _ceo_token(db, client)
r = client.put("/api/cma/data-quality/rule-config", headers=auth_header(token),
json={"rule_key": "dup_alert", "param_key": "min_count", "value": 3,
"change_reason": REASON})
assert r.status_code == 200 and r.json()["target_entity_id"] == 1
assert db.query(QualityRuleConfig).filter_by(
entity_id=1, rule_key="dup_alert", param_key="min_count").count() == 1
assert db.query(QualityRuleConfig).filter_by(
entity_id=0, rule_key="dup_alert", param_key="min_count").count() == 0