任总 2026-09-28 13:38 放行「4 和 5」→ 落笔最小实现: - 新表 quality_rule_config(39 项:37 规则级 + 2 全局)= 唯一配置源 - 三层解析:账套行 → 全账套模板(entity_id=0) → 代码内置默认(=清单 §7 建议值,零行为差异) - 端点 /api/cma/data-quality/rule-config(GET/PUT)+ /history + /meta:写入仅 ceo/it, platform 级仅 it,演示账套(functional)写禁,变更落 operation_logs 同事务 - 数据质量页新增「规则配置」只读 Tab(懒加载) - 唯一行为变更:污染判定去掉 sync —— 酣客 zxt-sync 系统迁入数据不再被误判(口径可配置) - 出厂默认落库脚本 scripts/seed_dq_rule_config.py(--check/--apply/--rollback,幂等 39/0) - 回归:新套件 35 例 + 存量 1508 passed(唯一 FAILED 为他人未完成 migrate_subjects_entity_id_20260923.py 改动所致,HEAD 干净树同例通过) 生产写(DDL/seed)与 push 均走审批门:DDL=54434656 push=3bd00774
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
# -*- 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 排除名单默认空集(不排除)→ 不为本单引入第二个行为变更
|
||||
assert dqcfg.GLOBAL_SPEC["scope.excluded_entities"]["default"] == []
|
||||
|
||||
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"] == []
|
||||
assert r["excluded_from_business_caliber"] is False
|
||||
|
||||
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"] == []
|
||||
assert len(body["locked"]) == 10
|
||||
assert body["readonly"] is True
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user