Files
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

696 lines
34 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 -*-
"""DAMA 数据质量治理规则可配置项(财务七规则)—— 单一事实源 + 三层解析
任务:ASK-20260928-05(任总 2026-09-28 13:38「放行 4 和 5」,本项目 Bot 转呈)
来源清单:wecom-dama《财务七规则可配置化 — 可配置项清单 + 建议默认值 + 权限/留痕建议》
(`wecom-dama/pending-tasks/_reports/project-20260923-quality-rules-config-inventory-dama.md`)
本模块承担三件事(唯一来源,勿在别处复制常量)
----------------------------------------------
1. **开放面**:37 个规则级参数(`SPEC`)+ 2 个全局项(`GLOBAL_SPEC`)。清单 §3 逐条落成代码;
测试断言项数 = 37 / 2,防后续会话漏项或私自扩面。
2. **锁死面**:清单 §4 的 10 类(判定表达式本体 / 级别归属 / 评分函数 / 实体作用域 /
规则集与数量 / 匹配方式自由化 / kpi_completeness 口径源 / `entity_id IS NULL` 处置 /
Bot 通道写配置 / 越界权重)——不在 SPEC 里的键一律 400(写端点),
`LOCKED_PARAM_KEYS` 仅用于把「锁死」讲清楚(可读文案),不是白名单。
3. **三层解析**:账套行(`entity_id`)→ 模板行(`entity_id=0`)→ 代码内置默认;
**禁止跨账套回退**(历史事故:`cash.current_balance` 全局键被全账套共用)。
口径(任总 2026-09-28 放行原文:「口径用清单默认值」)
----------------------------------------------------
* 默认值 = 清单 §7「建议默认值」总表,即「现行值,上线零行为差异」,
**例外一**:`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` 记录类型以便前端/审计呈现。
"""
from __future__ import annotations
import hashlib
import json
import logging
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import inspect
from sqlalchemy.orm import Session
logger = logging.getLogger("cma.dq.rule_config")
#: 表名(DDL 走部署链特权迁移步 `backend/scripts/db_migrate.py --apply`,属生产写审批范围)
TABLE_NAME = "quality_rule_config"
#: 全账套模板行的 entity_id(0 = 模板,不参与具体账套取数)
TEMPLATE_ENTITY_ID = 0
#: 开放配置写入的真实账套(口径②:演示/测试账套不开配置)
CONFIG_ENTITIES: Tuple[int, ...] = (1, 2)
#: 内置默认明细展示条数(清单 §3.9 全局项 `detail_limit` 的出厂值)
DEFAULT_DETAIL_LIMIT = 10
#: 写配置必须带变更理由,最短长度(清单 §5.1「反刷分约束」第 1 条)
MIN_REASON_LEN = 10
RULE_KEYS: Tuple[str, ...] = (
"unit_check", "dup_alert", "orphan_check", "virtual_pollution",
"entity_check", "kpi_completeness", "reconciliation",
)
# 全局项(清单 §3.9):platform 级,仅 it 可写
GLOBAL_RULE_KEY = "global"
# 规则级参数统一三件套(每条规则都有;`detail_limit` 为 platform 级)
_CAPS = {
"deduct_cap": dict(type="int", default=15, open_level="entity",
bounds=(0, 50), overflow="clamp",
desc="本规则扣分上限(0 = 只报不计分)"),
"deduct_factor": dict(type="int", default=3, open_level="entity",
bounds=(0, 10), overflow="clamp",
desc="本规则每命中一条的扣分系数"),
}
_WARN_CAPS = {
"deduct_cap": dict(type="int", default=10, open_level="entity",
bounds=(0, 50), overflow="clamp",
desc="本规则扣分上限(0 = 只报不计分)"),
"deduct_factor": dict(type="int", default=1, open_level="entity",
bounds=(0, 10), overflow="clamp",
desc="本规则每命中一条的扣分系数"),
}
_DETAIL_LIMIT = dict(type="int", default=DEFAULT_DETAIL_LIMIT, open_level="platform",
bounds=(1, 100), overflow="clamp",
desc="本规则明细展示条数(平台级,仅 it 可写)")
_ENABLED = dict(type="bool", default=True, open_level="entity",
desc="启用开关(关闭后该规则不参与评分,明细不再展示)")
#: 7 条规则的开放参数(37 项;清单 §3.1–§3.7 逐条落成)
SPEC: Dict[str, Dict[str, dict]] = {
"unit_check": {
"enabled": dict(_ENABLED),
"amount_threshold": dict(type="int", default=10000, open_level="entity",
bounds=(100, 1000000), overflow="reject",
desc="金额阈值(万元):cash_plans.amount > 阈值 即命中(越界拒绝)"),
**_CAPS,
"detail_limit": dict(_DETAIL_LIMIT),
},
"dup_alert": {
"enabled": dict(_ENABLED),
"min_count": dict(type="int", default=2, open_level="entity",
bounds=(2, 20), overflow="reject",
desc="同一 plan_id 的 pending 预警条数下限(≥ 该值即重复;越界拒绝)"),
**_CAPS,
"detail_limit": dict(_DETAIL_LIMIT),
},
"orphan_check": {
"enabled": dict(_ENABLED),
**_CAPS,
"detail_limit": dict(_DETAIL_LIMIT),
},
"virtual_pollution": {
"enabled": dict(_ENABLED),
"suspect_keywords": dict(
type="json", default=["test", "虚拟", "demo"], open_level="entity",
max_items=20, item_max_len=32, forbidden_chars="%_",
desc="嫌疑关键词表(大小写不敏感包含式匹配;不含 sync —— 本单唯一行为变更)"),
"source_whitelist": dict(
type="json",
default=["zxt-sync", "bohai_ar", "bohai_ar_20260630", "bohai_prepay_20260630",
"bohai_ap_20260630", "receivables_migrate"],
open_level="entity", max_items=20, item_max_len=32, forbidden_chars="%_",
desc="来源白名单(豁免优先于关键词:命中即不算污染)"),
"manual_source_value": dict(type="str", default="manual", open_level="entity",
max_len=50,
desc="「待人工确认」的来源值(非污染,按 warning 附加扣分)"),
**_CAPS,
"manual_cap": dict(type="int", default=10, open_level="entity",
bounds=(0, 30), overflow="clamp",
desc="待人工确认(manual)附加上限"),
"manual_factor": dict(type="int", default=1, open_level="entity",
bounds=(0, 5), overflow="clamp",
desc="待人工确认(manual)附加系数"),
"detail_limit": dict(_DETAIL_LIMIT),
},
"entity_check": {
"enabled": dict(_ENABLED),
**_CAPS,
"detail_limit": dict(_DETAIL_LIMIT),
},
"kpi_completeness": {
"enabled": dict(_ENABLED),
**_WARN_CAPS,
"detail_limit": dict(_DETAIL_LIMIT),
},
"reconciliation": {
"enabled": dict(_ENABLED),
"diff_threshold_pct": dict(type="float", default=20, open_level="entity",
bounds=(1, 200), overflow="reject",
desc="预算月度合计 vs 年度目标差异阈值(%);越界拒绝"),
"compare_mode": dict(type="enum", default="abs", open_level="entity",
enum=("abs", "upper_only", "lower_only"),
desc="比较方向:abs=双侧 / upper_only=仅超目标 / lower_only=仅低于目标"),
**_WARN_CAPS,
"detail_limit": dict(_DETAIL_LIMIT),
},
}
#: 全局项 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),
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;仅用于给出可读文案,非白名单)─────────────────────────
LOCKED_ITEMS: Tuple[dict, ...] = (
dict(key="expression", item="判定表达式本体(字段/算子/SQL 片段/正则)",
reason="表达式可配 = 治理规则可被一次配置改成永久绿灯"),
dict(key="level", item="级别归属(error/warning)",
reason="级别直接决定扣分上限,降级是最廉价的绕分路径;确有差异请用已开放的扣分权重表达"),
dict(key="score_fn", item="评分函数(max(0,100−Σ)、7 条固定、各规则独立封顶后累加、无总封顶)",
reason="评分是跨规则/跨期可比的口径基线"),
dict(key="entity_scope", item="实体作用域语义(规则 1 用 cash_plans.entity_id;2/3/6/7 用 kpi_definitions.entity_id;5 用 kpi_values.entity_id)",
reason="作用域 = 安全边界,开放会引入跨账套借用通路"),
dict(key="rule_set", item="规则 key 集合与规则数量(固定 7 条)",
reason="规则集是治理骨架,新增规则必须走代码评审"),
dict(key="match_mode", item="匹配方式自由化(关键词升级为正则/SQL 片段)",
reason="注入面 + 不可验证;匹配方式固定为大小写不敏感包含式"),
dict(key="completeness_caliber", item="kpi_completeness 口径源 kpi_definitions.status='active'(含豁免名单 exempt_kpi_codes)",
reason="KPI 存废开关已是 status,再加豁免名单 = 双开关口径分裂"),
dict(key="null_entity_policy", item="规则5 kpi_values.entity_id IS NULL 行的处置",
reason="NULL 语义未裁定前保持 ignore"),
dict(key="bot_write", item="Bot 通道(X-BOT-KEY)写配置",
reason="Bot 通道无 users 行 → 无操作人可留痕 = 审计断链"),
dict(key="weight_range", item="负系数 / 超大上限 / 非整数权重",
reason="负系数会变成加分(可刷分);一律钳制到开放区间"),
)
#: 键名 → 锁死文案(写端点遇到这些 param_key 时给「锁死」提示而非笼统的未知参数)
LOCKED_PARAM_KEYS = {it["key"]: it for it in LOCKED_ITEMS}
# 常见别名(历史/前端习惯叫法)→ 锁死项 key
_LOCKED_ALIASES = {
"expr": "expression", "predicate": "expression", "sql": "expression",
"rule_level": "level", "severity": "level",
"score": "score_fn", "score_func": "score_fn", "total_deduct_cap": "score_fn",
"exempt_kpi_codes": "completeness_caliber", "status_source": "completeness_caliber",
"entity_id_source": "entity_scope", "scope_field": "entity_scope",
"rules": "rule_set", "rule_keys": "rule_set", "rule_count": "rule_set",
"match_regex": "match_mode", "regex": "match_mode", "pattern": "match_mode",
"null_policy": "null_entity_policy",
}
OPEN_LEVEL_ENTITY = "entity"
OPEN_LEVEL_PLATFORM = "platform"
# ══════════════════════════════════════════════════════════════════
# 参数查找 / 校验
# ══════════════════════════════════════════════════════════════════
def is_global_param(rule_key: str) -> bool:
return rule_key == GLOBAL_RULE_KEY
def spec_of(rule_key: str) -> Dict[str, dict]:
"""返回某 rule_key 的参数规格表(全局项用 rule_key='global')。"""
if is_global_param(rule_key):
return GLOBAL_SPEC
return SPEC.get(rule_key) or {}
def param_spec(rule_key: str, param_key: str) -> Optional[dict]:
return spec_of(rule_key).get(param_key)
def all_param_pairs() -> List[Tuple[str, str]]:
"""(rule_key, param_key) 全量清单——37 规则级 + 2 全局项。"""
pairs = [(r, p) for r in RULE_KEYS for p in SPEC[r]]
pairs += [(GLOBAL_RULE_KEY, p) for p in GLOBAL_SPEC]
return pairs
def count_rule_params() -> int:
"""规则级开放参数项数(断言用,清单 §10.2 = 37)。"""
return sum(len(SPEC[r]) for r in RULE_KEYS)
def open_level_of(rule_key: str, param_key: str) -> str:
spec = param_spec(rule_key, param_key) or {}
return spec.get("open_level") or OPEN_LEVEL_ENTITY
def value_type_of(rule_key: str, param_key: str) -> str:
spec = param_spec(rule_key, param_key) or {}
return spec.get("type") or "json"
def dumps_value(value: Any) -> str:
"""统一序列化(落库格式;bool/int/float/str/数组一律 JSON)。"""
return json.dumps(value, ensure_ascii=False)
def loads_value(raw: Any, value_type: str = "") -> Any:
"""反序列化;非 JSON 的历史/手写行容错(返回 None 表示不可用 → 上层回落)。"""
if raw is None:
return None
if isinstance(raw, (int, float, bool, list, dict)):
return raw
try:
return json.loads(str(raw))
except (ValueError, TypeError):
text = str(raw)
if value_type == "int":
try:
return int(float(text))
except (ValueError, TypeError):
return None
if value_type == "float":
try:
return float(text)
except (ValueError, TypeError):
return None
if value_type == "bool":
if text.strip().lower() in ("1", "true", "yes"):
return True
if text.strip().lower() in ("0", "false", "no"):
return False
return None
if value_type == "str":
return text
return None
def _coerce_type(value: Any, spec: dict) -> Tuple[bool, Any]:
"""类型强转(拒绝型:类型不符 → 不可用)。bool 与 int 严格区分(Python 里 bool 是 int 子类)。"""
t = spec.get("type") or "json"
if t == "bool":
if isinstance(value, bool):
return True, value
if isinstance(value, str) and value.strip().lower() in ("true", "false"):
return True, value.strip().lower() == "true"
if isinstance(value, int) and value in (0, 1):
return True, bool(value)
return False, None
if t == "int":
if isinstance(value, bool):
return False, None
if isinstance(value, int):
return True, value
if isinstance(value, float) and float(value).is_integer():
return True, int(value)
if isinstance(value, str):
s = value.strip()
try:
return True, int(s)
except ValueError:
return False, None
return False, None
if t == "float":
if isinstance(value, bool):
return False, None
if isinstance(value, (int, float)):
return True, float(value)
if isinstance(value, str):
try:
return True, float(value.strip())
except ValueError:
return False, None
return False, None
if t == "str":
if not isinstance(value, str):
return False, None
return True, value
if t == "enum":
if not isinstance(value, str):
return False, None
return True, value.strip()
if t == "json":
if not isinstance(value, list):
return False, None
return True, value
return False, None
def validate_param(rule_key: str, param_key: str, value: Any) -> dict:
"""参数级校验(清单 §3 的「取值边界 / 越界处理」逐条落地)。
返回 ``{"ok": bool, "value": <规范化值>, "error": str|None, "warning": str|None}``;
越界处理两类:``reject``(400,附区间文案)/ ``clamp``(钳制到端点 + 响应告警)。
"""
spec = param_spec(rule_key, param_key)
if spec is None:
locked = LOCKED_PARAM_KEYS.get(
param_key, LOCKED_PARAM_KEYS.get(_LOCKED_ALIASES.get(param_key, ""))
)
if locked:
return dict(ok=False, value=None,
error=f"参数 {param_key} 属锁死项「{locked['item']}」,不允许配置:{locked['reason']}",
warning=None)
return dict(ok=False, value=None,
error=f"未知参数 rule={rule_key} param={param_key};"
f"可选: {', '.join(spec_of(rule_key).keys()) or '(该规则无开放参数)'}",
warning=None)
passed, value = _coerce_type(value, spec)
if not passed:
return dict(ok=False, value=None,
error=f"{rule_key}.{param_key} 类型非法:要求 {spec.get('type')}", warning=None)
caution = None
if spec.get("type") == "enum":
if value not in tuple(spec.get("enum") or ()):
return dict(ok=False, value=None,
error=f"{rule_key}.{param_key} 取值非法:仅支持 {', '.join(spec['enum'])}",
warning=None)
return dict(ok=True, value=value, error=None, warning=None)
if spec.get("type") == "json":
max_items = int(spec.get("max_items") or 0)
item_max_len = int(spec.get("item_max_len") or 0)
forbidden = spec.get("forbidden_chars") or ""
if max_items and len(value) > max_items:
return dict(ok=False, value=None,
error=f"{rule_key}.{param_key} 元素过多:上限 {max_items} 项(当前 {len(value)})",
warning=None)
cleaned: List[str] = []
for it in value:
if not isinstance(it, str):
if spec.get("item_max_len") == 6 and isinstance(it, int) and not isinstance(it, bool):
it = str(it) # scope.excluded_entities:账套号允许传 int
else:
return dict(ok=False, value=None,
error=f"{rule_key}.{param_key} 仅允许字符串数组元素", warning=None)
s = it.strip()
if not s:
return dict(ok=False, value=None,
error=f"{rule_key}.{param_key} 不允许空串元素", warning=None)
if item_max_len and len(s) > item_max_len:
return dict(ok=False, value=None,
error=f"{rule_key}.{param_key} 元素过长:单元素上限 {item_max_len} 字符({s})",
warning=None)
if any(ch in s for ch in forbidden):
return dict(ok=False, value=None,
error=f"{rule_key}.{param_key} 元素含非法字符 {forbidden}(不支持通配符/自由模式)",
warning=None)
cleaned.append(s)
# 去重保序(重复项无意义且会让 LIKE 子句膨胀)
seen, uniq = set(), []
for s in cleaned:
if s not in seen:
seen.add(s)
uniq.append(s)
if len(uniq) != len(cleaned):
caution = f"{rule_key}.{param_key} 已去重:{len(cleaned)} → {len(uniq)} 项"
return dict(ok=True, value=uniq, error=None, warning=caution)
if spec.get("type") == "str":
max_len = int(spec.get("max_len") or 0)
s = value
if not s.strip():
return dict(ok=False, value=None, error=f"{rule_key}.{param_key} 不允许空串", warning=None)
if max_len and len(s) > max_len:
return dict(ok=False, value=None,
error=f"{rule_key}.{param_key} 过长:上限 {max_len} 字符", warning=None)
return dict(ok=True, value=s, error=None, warning=None)
bounds = spec.get("bounds")
if bounds:
lo, hi = bounds
if value < lo or value > hi:
if spec.get("overflow") == "clamp":
clamped = min(max(value, lo), hi)
logger.warning("DQ_RULE_CONFIG_CLAMPED %s.%s %s → %s(区间 [%s, %s])",
rule_key, param_key, value, clamped, lo, hi)
return dict(ok=True, value=clamped, error=None,
warning=f"{rule_key}.{param_key} 越界已钳制到区间端点:{value} → {clamped}"
f"(允许区间 [{lo}, {hi}])")
return dict(ok=False, value=None,
error=f"{rule_key}.{param_key} 越界:允许区间 [{lo}, {hi}],收到 {value}",
warning=None)
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))
# ══════════════════════════════════════════════════════════════════
# 读路径:三层解析
# ══════════════════════════════════════════════════════════════════
def table_ready(db: Session) -> bool:
"""配置表是否就绪(DDL 未落地时**不得**让读路径 500:回落内置默认)。"""
try:
return bool(inspect(db.get_bind()).has_table(TABLE_NAME))
except Exception as exc: # noqa: BLE001
logger.warning("DQ_RULE_CONFIG_TABLE_PROBE_FAILED %s: %s", TABLE_NAME, exc)
return False
def _load_rows(db: Session, entity_id: int) -> Dict[Tuple[str, str], dict]:
"""读模板行 + 本账套行(一次查询);返回 {(rule_key, param_key): {entity_id, value, raw, row}}。"""
from app.models import QualityRuleConfig
eids = [TEMPLATE_ENTITY_ID]
if entity_id != TEMPLATE_ENTITY_ID:
eids.append(entity_id)
rows = db.query(QualityRuleConfig).filter(QualityRuleConfig.entity_id.in_(eids)).all()
out: Dict[Tuple[str, str], dict] = {}
for row in rows:
vt = value_type_of(row.rule_key, row.param_key)
val = loads_value(row.param_value, vt)
if val is None:
logger.warning("DQ_RULE_CONFIG_ROW_UNPARSABLE id=%s %s.%s value=%r → 忽略该行(回落下一层)",
row.id, row.rule_key, row.param_key, row.param_value)
continue
out[(row.rule_key, row.param_key)] = {
"entity_id": row.entity_id, "value": val, "row": row,
}
return out
def resolve(db: Session, entity_id: int) -> dict:
"""三层解析当前生效配置(账套行 → 模板行 → 内置默认;**不跨账套回退**)。
返回结构(前端/审计/引擎共用)::
{"entity_id": 1, "config_version": "…", "table_ready": True,
"rules": {rule_key: {param_key: {"value":…, "source": "entity|template|builtin",
"open_level":…, "desc":…, "updated_by":…, "updated_at":…}}},
"global": {...同 rules 的形状…},
"values": {rule_key: {param_key: <裸值>}}, # 引擎侧取值口
"scope": {"excluded_entities": [...]}}
"""
eid = int(entity_id or 0)
ready = table_ready(db)
rows = _load_rows(db, eid) if ready else {}
return _assemble(eid, ready, rows)
def builtin_resolution(entity_id: int = 0) -> dict:
"""纯内置默认的解析结果(配置表缺失/读异常时的回落形态,字段与 `resolve()` 逐字一致)。"""
return _assemble(int(entity_id or 0), False, {})
def _assemble(eid: int, ready: bool, rows: Dict[Tuple[str, str], dict]) -> dict:
"""把「行 + 内置默认」组装成对外结构(resolve / builtin_resolution 共用)。"""
def _one(rule_key: str, param_key: str, spec: dict) -> dict:
val, source, row = spec.get("default"), "builtin", None
tmpl = rows.get((rule_key, param_key))
if tmpl and tmpl["entity_id"] == TEMPLATE_ENTITY_ID:
val, source, row = tmpl["value"], "template", tmpl["row"]
elif eid != TEMPLATE_ENTITY_ID:
own = rows.get((rule_key, param_key))
if own and own["entity_id"] == eid:
val, source, row = own["value"], "entity", own["row"]
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)
ua = getattr(row, "updated_at", None)
entry["updated_at"] = ua.isoformat(timespec="seconds") if ua else None
entry["row_id"] = row.id
return entry
rules: Dict[str, Dict[str, dict]] = {}
values: Dict[str, Dict[str, Any]] = {}
for rule_key in RULE_KEYS:
rules[rule_key] = {p: _one(rule_key, p, s) for p, s in SPEC[rule_key].items()}
values[rule_key] = {p: e["value"] for p, e in rules[rule_key].items()}
glob: Dict[str, dict] = {p: _one(GLOBAL_RULE_KEY, p, s) for p, s in GLOBAL_SPEC.items()}
values[GLOBAL_RULE_KEY] = {p: e["value"] for p, e in glob.items()}
# 规则级 detail_limit 未配置时,回落全局 detail_limit(清单 §3.9)
global_limit = values[GLOBAL_RULE_KEY].get("detail_limit") or DEFAULT_DETAIL_LIMIT
for rule_key in RULE_KEYS:
entry = rules[rule_key]["detail_limit"]
if entry["source"] == "builtin":
entry["value"] = global_limit
entry["source"] = "builtin(global)"
values[rule_key]["detail_limit"] = entry["value"]
version = hashlib.sha1(
json.dumps(values, sort_keys=True, ensure_ascii=False).encode("utf-8")
).hexdigest()[:12]
return {
"entity_id": eid,
"config_entity_id": eid if eid in CONFIG_ENTITIES else TEMPLATE_ENTITY_ID,
"entity_config_enabled": eid in CONFIG_ENTITIES,
"table_ready": ready,
"config_version": version,
"rules": rules,
"global": glob,
"values": values,
"scope": {"excluded_entities": values[GLOBAL_RULE_KEY].get("scope.excluded_entities") or []},
}
def engine_values(db: Session, entity_id: int) -> dict:
"""引擎取值口(data_quality 规则引擎唯一的配置读取入口)。"""
return resolve(db, entity_id)["values"]
# ══════════════════════════════════════════════════════════════════
# 写路径:单参数 upsert(逐项「一次只改一档」)
# ══════════════════════════════════════════════════════════════════
def upsert_param(db: Session, entity_id: int, rule_key: str, param_key: str, value: Any,
*, username: Optional[str] = None) -> dict:
"""写一行配置(调用方负责权限/校验/审计/事务边界);返回 {id, before, after, source_before}。"""
from app.models import QualityRuleConfig
before = resolve(db, entity_id)
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)
row = (db.query(QualityRuleConfig)
.filter(QualityRuleConfig.entity_id == int(entity_id),
QualityRuleConfig.rule_key == rule_key,
QualityRuleConfig.param_key == param_key)
.first())
if row is None:
row = QualityRuleConfig(
entity_id=int(entity_id), rule_key=rule_key, param_key=param_key,
param_value=payload, value_type=value_type,
open_level=open_level_of(rule_key, param_key), is_default=False,
description=(param_spec(rule_key, param_key) or {}).get("desc"),
updated_by=username,
)
db.add(row)
else:
row.param_value = payload
row.value_type = value_type
row.is_default = False
row.updated_by = username
db.flush() # 拿到 id,且撞唯一键时当场暴露
return {
"id": row.id,
"before": before_entry.get("value"),
"before_source": before_entry.get("source"),
"after": value,
}
def history(db: Session, entity_id: int, limit: int = 20) -> List[dict]:
"""本账套的配置变更历史(读 `operation_logs`;该表无 entity_id 列 → 按 detail JSON 过滤)。"""
from app.models import OperationLog
rows = (db.query(OperationLog)
.filter(OperationLog.action == ACTION_UPDATE)
.order_by(OperationLog.id.desc())
.limit(max(limit * 5, 50))
.all())
out: List[dict] = []
for r in rows:
detail = r.detail if isinstance(r.detail, dict) else {}
if detail.get("entity_id") != int(entity_id):
continue
out.append({
"id": r.id, "at": r.created_at.isoformat(timespec="seconds") if r.created_at else None,
"user_id": r.user_id,
"rule_key": detail.get("rule_key"), "param_key": detail.get("param_key"),
"before": detail.get("before"), "after": detail.get("after"),
"reason": detail.get("reason"), "target_id": r.target_id,
})
if len(out) >= limit:
break
return out
#: 审计动作名(清单 §5.2 逐字)
ACTION_UPDATE = "update_dq_rule_config"
TARGET_TYPE = "dq_rule_config"