feat(data-quality): 规则配置可编辑入口 + 经营口径排除接线(ASK-20260928-05 尾项)
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:
wecom-fullstack
2026-09-29 13:00:48 +08:00
parent b8493f8d04
commit 0824efc8ed
4 changed files with 640 additions and 80 deletions
+85 -8
View File
@@ -19,10 +19,15 @@
口径(任总 2026-09-28 放行原文:「口径用清单默认值」)
----------------------------------------------------
* 默认值 = 清单 §7「建议默认值」总表,即「现行值,上线零行为差异」,
**唯一例外**:`virtual_pollution.suspect_keywords` 去掉 `sync`
(`zxt-sync` 是酣客生产同步来源,现行 134 行 100% 假阳性)——本单唯一的行为变更;
**例外一**:`virtual_pollution.suspect_keywords` 去掉 `sync`
(`zxt-sync` 是酣客生产同步来源,现行 134 行 100% 假阳性)——ASK-05 的行为变更;
* 演示/测试账套(3/4/5/6)**不开配置**(`CONFIG_ENTITIES = (1, 2)`,清单 §6.4 + ASK-05 口径②);
读路径仍按三层解析返回生效值(模板/内置默认),只是写端点拒绝。
* **例外二(2026-09-29 尾项,ASK-05 ③-2 拍板「排除」)**:`scope.excluded_entities`
出厂默认 `[3,4,5,6]`,且**已接线** —— 全账套(经营)口径 `entity_id=0` 的评分与明细
不再计入这些账套的行(判据见 `app/api/data_quality.py::_excluded_clause`)。
单账套查询仍可查看(DAMA §6.4「参与检查展示」),只计 `excluded_from_business_caliber=True`。
名单为**参数化**:改本配置值即可调整(零代码回退)。
配置值一律以 JSON 字符串落库(`param_value`),`value_type` 记录类型以便前端/审计呈现。
"""
@@ -150,13 +155,23 @@ SPEC: Dict[str, Dict[str, dict]] = {
},
}
#: 全局项 key(经营口径排除名单,F6 / ASK-05 ③-2)
EXCLUDED_ENTITIES_KEY = "scope.excluded_entities"
#: `resolve()["scope"]` / GET 响应里排除名单的**字段名**(短名)。
#: 两者不同名是历史形态:配置项 key 带 `scope.` 前缀(落库/写端点用),
#: 响应 `scope` 段是「已按前缀归组」后的字典 ⇒ 取值为短名。
EXCLUDED_ENTITIES_FIELD = "excluded_entities"
#: 全局项(2 项;清单 §3.9)
GLOBAL_SPEC: Dict[str, dict] = {
"detail_limit": dict(_DETAIL_LIMIT),
"scope.excluded_entities": dict(
type="json", default=[], open_level="platform", max_items=20, item_max_len=6,
desc="排除出「经营口径」的账套(演示/测试;默认空集 = 不排除)。"
"⚠️ 本项当前**只做标注不改评分语义**(F6 属业务口径,待任总/财务裁定后接线)"),
EXCLUDED_ENTITIES_KEY: dict(
type="json", default=[3, 4, 5, 6], open_level="platform", max_items=20, item_max_len=6,
desc="排除出「经营口径」评分与播报的账套(3=测试企业 / 4=VerifyCorp / 5=VerifyTest / "
"6=演示企业)。已接线:全账套口径(entity_id=0)的评分与明细不再计入这些账套的行;"
"单账套查询仍可查看(展示),但标记「不计入经营口径」。写权限仅 it(平台级);"
"改本配置值即可调整名单(零代码回退)"),
}
# ── 锁死面(清单 §4;仅用于给出可读文案,非白名单)─────────────────────────
@@ -427,6 +442,59 @@ def validate_param(rule_key: str, param_key: str, value: Any) -> dict:
return dict(ok=True, value=value, error=None, warning=None)
# ══════════════════════════════════════════════════════════════════
# 经营口径排除名单(F6 / ASK-05 ③-2「排除演示账套」)—— 单一事实源
# ══════════════════════════════════════════════════════════════════
def normalize_entity_ids(raw: Any) -> List[int]:
"""把配置里的账套号规范成 int 列表(去重保序;不可解析的元素忽略 = 视为不排除)。"""
out: List[int] = []
for v in raw or []:
try:
n = int(v)
except (TypeError, ValueError):
continue
if n < 0 or n in out:
continue
out.append(n)
return out
def excluded_entities_from_resolution(resolution: Optional[dict]) -> List[int]:
"""从 `resolve()` 结果里取经营口径排除名单(唯一取值口)。
⚠ 兼容两种字段形态:`scope` 段由 `_assemble()` 按前缀归组 ⇒ 短名
(`EXCLUDED_ENTITIES_FIELD`);直接取配置项全名的情况也接受。
缺失字段名会让本函数静默返回空集(= 排除名单形同未接线),故两种都查。
"""
scope = (resolution or {}).get("scope") or {}
raw = scope.get(EXCLUDED_ENTITIES_FIELD)
if raw is None:
raw = scope.get(EXCLUDED_ENTITIES_KEY)
return normalize_entity_ids(raw)
def excluded_entity_ids(db: Session, entity_id: int = TEMPLATE_ENTITY_ID) -> List[int]:
"""按账套三层解析后取排除名单(供引擎/播报侧单一来源引用)。
读路径绝不 500:解析异常 → 返回内置默认(`resolve()` 内部已做容错,此处再兜一层)。
"""
try:
return excluded_entities_from_resolution(resolve(db, entity_id))
except Exception as exc: # noqa: BLE001
logger.warning("DQ_SCOPE_EXCLUDED_RESOLVE_FAILED entity=%s: %s → 用内置默认",
entity_id, exc)
return normalize_entity_ids(GLOBAL_SPEC[EXCLUDED_ENTITIES_KEY].get("default"))
def is_excluded_from_business_caliber(entity_id: Any, excluded: Any) -> bool:
"""单个账套是否被排除出经营口径(entity_id 为空/0 → False,不排除默认账套)。"""
try:
eid = int(entity_id or 0)
except (TypeError, ValueError):
return False
return bool(eid) and eid in set(normalize_entity_ids(excluded))
# ══════════════════════════════════════════════════════════════════
# 读路径:三层解析
# ══════════════════════════════════════════════════════════════════
@@ -499,6 +567,12 @@ def _assemble(eid: int, ready: bool, rows: Dict[Tuple[str, str], dict]) -> dict:
entry = {
"value": val, "source": source, "open_level": spec.get("open_level"),
"desc": spec.get("desc"), "updated_by": None, "updated_at": None,
# 类型/取值区间对前端呈现(**仅提示**;越界/类型一律由写端点判定,
# 前端不得据本字段自造「校验通过」——清单 §5.1 反刷分约束)
"value_type": spec.get("type") or "json",
"bounds": list(spec["bounds"]) if spec.get("bounds") else None,
"enum": list(spec["enum"]) if spec.get("enum") else None,
"overflow": spec.get("overflow"),
}
if row is not None:
entry["updated_by"] = getattr(row, "updated_by", None)
@@ -554,8 +628,11 @@ def upsert_param(db: Session, entity_id: int, rule_key: str, param_key: str, val
from app.models import QualityRuleConfig
before = resolve(db, entity_id)
scope = before.get("global") if is_global_param(rule_key) else before.get("rules")
before_entry = (scope or {}).get(rule_key, {}).get(param_key, {}) or {}
if is_global_param(rule_key):
# 全局项:`resolve()["global"]` 直接以 param_key 为键(无 rule_key 一层)
before_entry = (before.get("global") or {}).get(param_key, {}) or {}
else:
before_entry = ((before.get("rules") or {}).get(rule_key, {}) or {}).get(param_key, {}) or {}
value_type = value_type_of(rule_key, param_key)
payload = dumps_value(value)