feat(data-quality): 规则配置可编辑入口 + 经营口径排除接线(ASK-20260928-05 尾项)
ci/woodpecker/push/main Pipeline failed
ci/woodpecker/push/main Pipeline failed
依据:任总 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 等)
This commit is contained in:
@@ -141,8 +141,8 @@ class TestSpecSurface:
|
||||
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"] == []
|
||||
# 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 十类锁死面完整登记(少一类即视为私自扩面)。"""
|
||||
@@ -215,8 +215,9 @@ class TestEngineWiring:
|
||||
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
|
||||
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 命中)。
|
||||
@@ -356,9 +357,23 @@ class TestConfigEndpoints:
|
||||
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 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")
|
||||
@@ -527,3 +542,146 @@ class TestTableMissingTolerance:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user