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:
@@ -261,6 +261,16 @@
|
||||
{{ govScore }}
|
||||
</div>
|
||||
<div style="font-size:13px;color:#999;margin-top:4px;">质量评分(满分100)</div>
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="govScope.caliber === 'business' ? 'success' : 'info'"
|
||||
effect="plain"
|
||||
style="margin-top:6px;"
|
||||
>
|
||||
{{ govScope.caliber === 'business'
|
||||
? `经营口径(已排除账套:${(govScope.excluded_entities || []).join(', ') || '无'})`
|
||||
: `单账套口径${govExcludedThisEntity ? '(本账套不计入经营口径评分/播报)' : ''}` }}
|
||||
</el-tag>
|
||||
<div style="font-size:12px;color:#999;margin-top:12px;line-height:1.8;">
|
||||
检查时间: {{ govCheckedAt || '-' }}<br />
|
||||
通过 {{ govPassedCount }} / 7 条规则<br />
|
||||
@@ -336,7 +346,7 @@
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ── Tab3: 规则配置(只读)— ASK-20260928-05 最小实现 ── -->
|
||||
<!-- ── Tab3: 规则配置(ceo/it 可编辑入口;finance/business 只读)— ASK-20260928-05 + 尾项 ── -->
|
||||
<el-tab-pane label="规则配置" name="rulecfg">
|
||||
<div v-if="cfgLoaded">
|
||||
<el-card shadow="never" class="section-gap">
|
||||
@@ -344,7 +354,9 @@
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
|
||||
<span>财务七规则配置(DAMA 口径清单 · 任务 ASK-20260928-05)</span>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<el-tag size="small" type="info" effect="plain">只读</el-tag>
|
||||
<el-tag size="small" :type="cfgCanWrite ? 'success' : 'info'" effect="plain">
|
||||
{{ cfgCanWrite ? '可编辑(ceo/it)' : '只读' }}
|
||||
</el-tag>
|
||||
<el-tag size="small" type="primary" effect="plain">{{ entityLabel }}</el-tag>
|
||||
<el-button @click="loadRuleConfig" :loading="cfgLoading">刷新</el-button>
|
||||
</div>
|
||||
@@ -355,7 +367,9 @@
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="本轮为只读视图;写权限 ceo/it 经 PUT /api/cma/data-quality/rule-config(同一配置源)"
|
||||
:title="cfgCanWrite
|
||||
? '写权限 ceo / it(平台级项仅 it);每次变更必填「变更理由」(≥10 字),落 operation_logs 审计'
|
||||
: '只读视图:当前角色(finance / business)可查看生效值与来源,编辑权限见 ceo / it'"
|
||||
:description="cfgSummary"
|
||||
/>
|
||||
|
||||
@@ -390,7 +404,22 @@
|
||||
<el-table-column label="说明" min-width="260">
|
||||
<template #default="{ row }">{{ row.desc }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="cfgCanWrite" label="操作" width="96" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="!row.editable"
|
||||
:title="row.editHint"
|
||||
@click="openCfgEdit(row)"
|
||||
>编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="cfgCanWrite && !cfgEntityWritable" style="font-size:12px;color:#e6a23c;margin-top:8px;">
|
||||
当前账套按 ASK-20260928-05 口径②不开放配置(演示/测试账套只读);「编辑」按钮仅在有写权限的账套可用。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="section-gap">
|
||||
@@ -435,6 +464,57 @@
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 规则配置编辑(ceo/it)— F6/ASK-05 尾项;铁律:业务弹窗一律用 MyDialog -->
|
||||
<MyDialog v-model="cfgEditVisible" title="修改规则配置" :width="580">
|
||||
<el-form label-width="110px" label-position="left">
|
||||
<el-form-item label="配置项">
|
||||
<span class="dq-kv">{{ cfgEditForm.ruleName }} · {{ cfgEditForm.paramKey }}</span>
|
||||
<el-tag size="small" type="info" effect="plain" style="margin-left:8px;">
|
||||
{{ cfgEditForm.openLevelText }}
|
||||
</el-tag>
|
||||
</el-form-item>
|
||||
<el-form-item label="当前生效值">
|
||||
<span class="dq-kv">{{ cfgEditForm.beforeText }}</span>
|
||||
<el-tag size="small" type="info" effect="plain" style="margin-left:8px;">
|
||||
{{ cfgEditForm.sourceText }}
|
||||
</el-tag>
|
||||
</el-form-item>
|
||||
<el-form-item label="新值">
|
||||
<el-switch v-if="cfgEditForm.valueType === 'bool'" v-model="cfgEditForm.boolValue" />
|
||||
<el-input
|
||||
v-else
|
||||
v-model="cfgEditForm.valueText"
|
||||
:placeholder="cfgEditForm.placeholder"
|
||||
clearable
|
||||
/>
|
||||
<div v-if="cfgEditForm.hint" style="font-size:12px;color:#909399;line-height:18px;">
|
||||
{{ cfgEditForm.hint }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="变更理由">
|
||||
<el-input
|
||||
v-model="cfgEditForm.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="必填,不少于 10 字(落 operation_logs 审计,防无痕调参)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
v-if="cfgEditForm.error"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="cfgEditForm.error"
|
||||
/>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="cfgEditVisible = false" :disabled="cfgEditForm.saving">取消</el-button>
|
||||
<el-button type="primary" :loading="cfgEditForm.saving" @click="submitCfgEdit">提交</el-button>
|
||||
</template>
|
||||
</MyDialog>
|
||||
|
||||
<!-- 异常明细下钻(2026-09-23 P1):项目铁律 —— 一律用 MyDialog(Element 原生弹窗有已知 bug,禁用) -->
|
||||
<MyDialog v-model="govDetailVisible" :title="govDetailTitle" :width="980">
|
||||
<div class="dq-detail">
|
||||
@@ -616,6 +696,9 @@ const govScore = ref(0)
|
||||
const govTotalDeduct = ref(0)
|
||||
const govIssues = ref<any[]>([])
|
||||
const govCheckedAt = ref('')
|
||||
// 经营口径标签(F6 接线):business=全账套经营口径(已排除演示/测试账套);entity=单账套展示口径
|
||||
const govScope = ref<any>({})
|
||||
const govExcludedThisEntity = ref(false)
|
||||
// 企业只读标识(账套由登录窗口选定,页内无切换入口)
|
||||
const entityLabel = ref(sessionEntityName())
|
||||
const govChecking = ref(false)
|
||||
@@ -634,12 +717,18 @@ const govDetailPageSize = 50 // 与后端契约一致(默认 50、上限 200
|
||||
const govDetailLoading = ref(false)
|
||||
const govExporting = ref(false)
|
||||
|
||||
// ── 规则配置(只读)— ASK-20260928-05 ──
|
||||
// ── 规则配置(可编辑入口 ceo/it)— ASK-20260928-05 + 尾项 2026-09-29 ──
|
||||
// 角色口径与 router/index.ts:45(roles: ['ceo','finance','it'])及系统设置页(roles: ['ceo','it'])一致;
|
||||
// 写权限 = ceo/it(后端 PUT 的 require_role 同口径;平台级项仅 it),finance/business 只读。
|
||||
const userRole = ref<string>((JSON.parse(localStorage.getItem('cma_user') || '{}') as any).role || '')
|
||||
const cfgCanWrite = computed(() => ['ceo', 'it'].includes(userRole.value))
|
||||
const cfgLoaded = ref(false)
|
||||
const cfgLoading = ref(false)
|
||||
const cfgData = ref<any>({})
|
||||
const cfgHistory = ref<any[]>([])
|
||||
|
||||
const cfgEntityWritable = computed(() => !!cfgData.value?.entity_config_enabled)
|
||||
|
||||
function cfgFmt(v: any): string {
|
||||
if (v === null || v === undefined) return '-'
|
||||
if (Array.isArray(v)) return v.length ? v.join(', ') : '(空)'
|
||||
@@ -651,6 +740,17 @@ const SOURCE_TEXT: Record<string, string> = {
|
||||
entity: '账套覆盖', template: '全账套模板', builtin: '内置默认', 'builtin(global)': '内置默认',
|
||||
}
|
||||
|
||||
/** 区间/枚举提示(**仅提示**;越界/类型判定一律由后端写端点给出,前端不自造「校验通过」) */
|
||||
function cfgParamHint(p: any): string {
|
||||
if (p?.enum?.length) return `可选值:${p.enum.join(' / ')}(非法值由后端拒绝)`
|
||||
if (p?.bounds?.length === 2) {
|
||||
const tail = p.overflow === 'reject' ? '越界拒绝' : '越界自动钳制到端点'
|
||||
return `允许区间 [${p.bounds[0]}, ${p.bounds[1]}],${tail}`
|
||||
}
|
||||
if (p?.value_type === 'json') return '数组:JSON 形式 [a, b] 或逗号分隔'
|
||||
return ''
|
||||
}
|
||||
|
||||
const cfgSummary = computed(() => {
|
||||
const d: any = cfgData.value
|
||||
if (!d || !d.entity_id) return ''
|
||||
@@ -659,10 +759,31 @@ const cfgSummary = computed(() => {
|
||||
`配置指纹:${d.config_version || '-'}`,
|
||||
`开放参数:${d.rule_param_count ?? 0} 项规则级 + ${Object.keys(d.global_params || {}).length} 项全局`,
|
||||
`本账套可写:${d.entity_config_enabled ? '是' : '否(演示/测试账套只读)'}`,
|
||||
`写权限:${d.write_contract?.roles?.join(' / ') || 'ceo / it'}(平台级 ${d.write_contract?.platform_roles?.join(' / ') || 'it'})`,
|
||||
]
|
||||
return parts.join(' · ')
|
||||
})
|
||||
|
||||
function cfgPush(ruleKey: string, ruleName: string, paramKey: string, p: any) {
|
||||
const openLevel = p.open_level || 'entity'
|
||||
const canEdit = cfgCanWrite.value && cfgEntityWritable.value && (openLevel !== 'platform' || userRole.value === 'it')
|
||||
return {
|
||||
ruleKey, ruleName, paramKey,
|
||||
value: p.value,
|
||||
valueText: cfgFmt(p.value),
|
||||
sourceText: SOURCE_TEXT[p.source] || p.source,
|
||||
sourceTag: p.source === 'entity' ? 'warning' : (p.source === 'template' ? 'success' : 'info'),
|
||||
openLevel,
|
||||
openLevelText: openLevel === 'platform' ? '平台级(仅it)' : '账套级(ceo/it)',
|
||||
valueType: p.value_type || 'json',
|
||||
hint: cfgParamHint(p),
|
||||
updatedText: p.updated_at ? `${(p.updated_at || '').replace('T', ' ')}${p.updated_by ? ' · ' + p.updated_by : ''}` : '-',
|
||||
desc: p.desc || '',
|
||||
editable: canEdit,
|
||||
editHint: canEdit ? '' : (!cfgEntityWritable.value ? '演示/测试账套只读' : (openLevel === 'platform' ? '平台级项仅 it 可写' : '当前角色只读')),
|
||||
}
|
||||
}
|
||||
|
||||
const cfgRows = computed(() => {
|
||||
const d: any = cfgData.value
|
||||
const out: any[] = []
|
||||
@@ -670,35 +791,90 @@ const cfgRows = computed(() => {
|
||||
for (const ruleKey of Object.keys(d.rules)) {
|
||||
const rule = d.rules[ruleKey]
|
||||
for (const paramKey of Object.keys(rule.params || {})) {
|
||||
const p = rule.params[paramKey]
|
||||
out.push({
|
||||
ruleKey, ruleName: rule.name, paramKey,
|
||||
valueText: cfgFmt(p.value),
|
||||
sourceText: SOURCE_TEXT[p.source] || p.source,
|
||||
sourceTag: p.source === 'entity' ? 'warning' : (p.source === 'template' ? 'success' : 'info'),
|
||||
openLevelText: p.open_level === 'platform' ? '平台级(仅it)' : '账套级(ceo/it)',
|
||||
updatedText: p.updated_at ? `${(p.updated_at || '').replace('T', ' ')}${p.updated_by ? ' · ' + p.updated_by : ''}` : '-',
|
||||
desc: p.desc || '',
|
||||
})
|
||||
out.push(cfgPush(ruleKey, rule.name, paramKey, rule.params[paramKey]))
|
||||
}
|
||||
}
|
||||
for (const paramKey of Object.keys(d.global_params || {})) {
|
||||
const p = d.global_params[paramKey]
|
||||
out.push({
|
||||
ruleKey: 'global', ruleName: '全局项', paramKey,
|
||||
valueText: cfgFmt(p.value),
|
||||
sourceText: SOURCE_TEXT[p.source] || p.source,
|
||||
sourceTag: p.source === 'entity' ? 'warning' : (p.source === 'template' ? 'success' : 'info'),
|
||||
openLevelText: p.open_level === 'platform' ? '平台级(仅it)' : '账套级(ceo/it)',
|
||||
updatedText: p.updated_at ? `${(p.updated_at || '').replace('T', ' ')}${p.updated_by ? ' · ' + p.updated_by : ''}` : '-',
|
||||
desc: p.desc || '',
|
||||
})
|
||||
out.push(cfgPush('global', '全局项', paramKey, d.global_params[paramKey]))
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const cfgLocked = computed(() => cfgData.value?.locked || [])
|
||||
|
||||
// ── 编辑弹窗(ceo/it;一次只改一档)──
|
||||
const cfgEditVisible = ref(false)
|
||||
const cfgEditForm = ref<any>({
|
||||
ruleKey: '', paramKey: '', ruleName: '', openLevelText: '', beforeText: '', sourceText: '',
|
||||
valueType: 'json', placeholder: '', hint: '', valueText: '', boolValue: false,
|
||||
reason: '', saving: false, error: '',
|
||||
})
|
||||
|
||||
function openCfgEdit(row: any) {
|
||||
const type = row.valueType || 'json'
|
||||
cfgEditForm.value = {
|
||||
ruleKey: row.ruleKey, paramKey: row.paramKey, ruleName: row.ruleName,
|
||||
openLevelText: row.openLevelText, beforeText: row.valueText, sourceText: row.sourceText,
|
||||
valueType: type,
|
||||
boolValue: !!row.value,
|
||||
valueText: type === 'bool' ? '' : (Array.isArray(row.value) ? JSON.stringify(row.value) : String(row.value ?? '')),
|
||||
hint: row.hint || '',
|
||||
placeholder: type === 'json' ? 'JSON 数组,例:[3,4,6]'
|
||||
: (type === 'int' || type === 'float') ? '数字' : '文本',
|
||||
reason: '', saving: false, error: '',
|
||||
}
|
||||
cfgEditVisible.value = true
|
||||
}
|
||||
|
||||
/** 仅做「格式」解析(数字/数组/文本);**区间与类型合法性一律交后端判定**并如实展示其后端文案 */
|
||||
function cfgParseValue(): { ok: boolean; value?: any; error?: string } {
|
||||
const f = cfgEditForm.value
|
||||
const type = f.valueType
|
||||
const raw = String(f.valueText ?? '').trim()
|
||||
if (type === 'bool') return { ok: true, value: !!f.boolValue }
|
||||
if (type === 'int' || type === 'float') {
|
||||
if (!raw) return { ok: false, error: '请输入新值' }
|
||||
const n = Number(raw)
|
||||
if (Number.isNaN(n)) return { ok: false, error: '新值必须是数字' }
|
||||
return { ok: true, value: n }
|
||||
}
|
||||
if (type === 'json') {
|
||||
const body = raw.replace(/^\[/, '').replace(/\]$/, '').trim()
|
||||
if (!body) return { ok: true, value: [] }
|
||||
const parts = body.split(/[,,\s]+/).filter(Boolean)
|
||||
if (!parts.length) return { ok: false, error: '请输入数组元素,例:[3,4,6]' }
|
||||
return { ok: true, value: parts }
|
||||
}
|
||||
if (!raw) return { ok: false, error: '请输入新值' }
|
||||
return { ok: true, value: raw }
|
||||
}
|
||||
|
||||
async function submitCfgEdit() {
|
||||
const f = cfgEditForm.value
|
||||
const parsed = cfgParseValue()
|
||||
if (!parsed.ok) { f.error = parsed.error; return }
|
||||
if (String(f.reason || '').trim().length < 10) {
|
||||
f.error = '变更理由必填,且不少于 10 字'
|
||||
return
|
||||
}
|
||||
f.error = ''
|
||||
f.saving = true
|
||||
try {
|
||||
const r: any = await api.put('/data-quality/rule-config', {
|
||||
rule_key: f.ruleKey, param_key: f.paramKey, value: parsed.value,
|
||||
change_reason: String(f.reason).trim(),
|
||||
})
|
||||
ElMessage.success(`已生效:${f.ruleKey}.${f.paramKey} ${cfgFmt(r.before)} → ${cfgFmt(r.after)}`)
|
||||
if (r.warning) ElMessage.warning(r.warning) // 钳制类:后端返回的告警原文
|
||||
cfgEditVisible.value = false
|
||||
await loadRuleConfig()
|
||||
} catch (e: any) {
|
||||
// 越界 / 锁死 / 类型非法 / 权限不足:一律如实展示后端 detail(不前端自造校验通过)
|
||||
f.error = e?.response?.data?.detail || '提交失败,请稍后重试'
|
||||
}
|
||||
f.saving = false
|
||||
}
|
||||
|
||||
async function loadRuleConfig() {
|
||||
cfgLoading.value = true
|
||||
try {
|
||||
@@ -1075,7 +1251,7 @@ function handleTabChange(name: string | number) {
|
||||
govLoaded.value = true
|
||||
runGovernanceCheck()
|
||||
}
|
||||
// Tab3 懒加载:首次切换到"规则配置"(只读)时才请求
|
||||
// Tab3 懒加载:首次切换到"规则配置"(ceo/it 可编辑、其余只读)时才请求
|
||||
if (name === 'rulecfg' && !cfgLoaded.value) {
|
||||
cfgLoaded.value = true
|
||||
loadRuleConfig()
|
||||
@@ -1127,6 +1303,8 @@ async function runGovernanceCheck() {
|
||||
govScore.value = r.score ?? 0
|
||||
govTotalDeduct.value = r.total_deduct ?? 0
|
||||
govIssues.value = r.issues || []
|
||||
govScope.value = r.business_caliber || {}
|
||||
govExcludedThisEntity.value = !!r.excluded_from_business_caliber
|
||||
govCheckedAt.value = (r.checked_at || '').replace('T', ' ').slice(0, 19)
|
||||
govRuleMeta.value = r.rules_meta || {}
|
||||
const bad = (r.issues || []).filter((i: any) => i.count > 0).length
|
||||
|
||||
Reference in New Issue
Block a user