feat(data): #333 数据查看树五路原始表采纳状态只读展示
Some checks failed
gitleaks / gitleaks (push) Failing after 10s
backend-test / commit-message-lint (push) Successful in 3s
backend-test / analytics-ods-isolation (push) Failing after 2s
backend-test / backend-lint-test (push) Failing after 4m59s
backend-test / frontend-lint-test (push) Has been cancelled

- refund_adoption 新增 ADOPT_REASON_CN 规则码中文释义(17码, 单一来源紧贴
  规则码定义), ADOPT_REASON_ENUM/ADOPT_STATE_ENUM 经 fields API 下发前端
- data_source_registry: 经营五路 A~E 程序化补采纳四列 FIELD_OVERRIDES
  (adopt_state 三态枚举+可筛选 / adopt_reason 释义枚举不可筛 /
  adopt_rule_ver+adopted_at 仅展示); 启动 lifespan 自动注册生效
- Browser.vue 三层落地: ①设计说明补 #333 段(取舍: 不提供改采纳状态入口,
  人工治理一律走 adoption_overrides 留痕); ②页面层 adopt_state 列头 ⓘ
  三态口径说明(含"未判定=未跑作业"); ③行级 el-tag 三态徽标 + 规则码
  中文 tooltip(支持 manual: 前缀人工覆盖展示) + 采纳状态下拉筛选(待定
  治理跟进); 释义前端零硬编码, 规则升级只改后端
- 测试 test_adopt_state_display_333.py 5 用例: 注册四列齐全/三态元数据/
  释义与规则码全集一致(防两处漂移)/版本与时间不可筛
- ruff 全绿; 前端 build 通过; 全量 pytest 零失败

Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com
This commit is contained in:
2026-10-19 09:12:35 +08:00
committed by trae-solo-agent
parent b5b7b8aa75
commit ad8ed8661c
4 changed files with 270 additions and 1 deletions

View File

@@ -36,6 +36,9 @@ from app.models.shanmao_refund import ShanmaoRefund
from app.models.ticket import ServiceTicket
from app.models.wallet import WalletWithdrawal
# #333: 采纳三态/规则码枚举单一来源在采纳作业模块
from app.services.governance.refund_adoption import ADOPT_REASON_ENUM, ADOPT_STATE_ENUM
# ─────────────────────────────────────────────────────────────────────
# 数据查看树:分组口径与命名约束(工单 #100)
#
@@ -681,6 +684,38 @@ FIELD_OVERRIDES: dict[tuple[str, str], dict] = {
("device_flow_usage", "fetched_at"): {"role": "time", "name_cn": "抓取时间"},
}
# ── #333: 采纳治理四列展示配置(经营五路 A~E 共用, 程序化展开) ────────────────
# 目的: 数据查看树五路原始表行级展示「这行为什么进/没进统计」——
# adopt_state 三态徽标 + 规则码中文 tooltip + 待定行可筛选(工单 #333)。
# 口径单一来源: ADOPT_REASON_ENUM / ADOPT_STATE_ENUM 定义在采纳作业模块
# (app.services.governance.refund_adoption), 规则码变更必须同处升版。
# 只读约束: 本页仅展示, 人工覆盖一律走后端 admin API adoption_overrides(铁律 15)。
_ADOPT_SOURCE_CODES = ("refund", "wallet", "shanmao_refund", "manual_refund", "refund_record_report")
for _ac in _ADOPT_SOURCE_CODES:
FIELD_OVERRIDES[(_ac, "adopt_state")] = {
"role": "category",
"enum": list(ADOPT_STATE_ENUM),
"is_filterable": True,
"name_cn": "采纳状态(是否进统计)",
}
FIELD_OVERRIDES[(_ac, "adopt_reason")] = {
"role": "dimension",
# enum 下发 "规则码=中文释义" 列表, 供前端 tooltip; 不参与筛选
"enum": list(ADOPT_REASON_ENUM),
"is_filterable": False,
"name_cn": "采纳规则码",
}
FIELD_OVERRIDES[(_ac, "adopt_rule_ver")] = {
"role": "dimension",
"is_filterable": False,
"name_cn": "采纳规则版本",
}
FIELD_OVERRIDES[(_ac, "adopted_at")] = {
"role": "time",
"is_filterable": False,
"name_cn": "采纳判定时间",
}
# 反射时排除的系统字段(TimestampMixin 的系统时间 + raw_extra 冗余)
_EXCLUDED_FIELDS = {"id", "created_at", "updated_at", "raw_extra"}

View File

@@ -68,6 +68,36 @@ E_ADOPT_ALL = "E_ADOPT_ALL" # 全量采纳(refund_status 原样
# 未知枚举(不在任何白名单内)→ pending; 非业务规则码, 仅标记「机器无法归类」
PENDING_UNKNOWN = "PENDING_UNKNOWN"
# ── 规则码中文释义(#333) ─────────────────────────────────────────────────────
# 单一来源: 与上方规则码定义同处维护(改规则码必须同步改这里)。
# 消费方: data_source_registry 经 ADOPT_REASON_ENUM 下发前端, 数据查看树
# adopt_reason 列 tooltip 展示; 供业务看懂「这行为什么进/没进统计」。
ADOPT_REASON_CN: dict[str, str] = {
A_ADOPT_ONLINE: "采纳: 已退款且方式为线上(含空方式)",
A_EX_METHOD: "排除: 方式为转入提现账户/线下, 非线上直退",
A_EX_GARBAGE: "排除: 方式或状态错位值/长文本垃圾",
A_EX_STATUS: "排除: 状态为无需退款/驳回/申请中(未完结)",
A_EX_STATUS_NULL: "排除: 状态为空(且非已退款)",
B_ADOPT_INFLOW: "采纳: 订单退费转入(蓄水池进水)",
B_ADOPT_OUTFLOW: "采纳: 系统扣费/成功用户提现(出水)",
B_EX_FAIL_FROZEN: "排除: 用户提现备注含失败/冻结关键词",
B_EX_NULL_TYPE: "排除: 操作类型为空",
C_ADOPT: "采纳: 闪猫退款状态为已退款",
C_EX_NOT_REFUNDED: "排除: 审核中/退款中/失败/驳回/无需退款",
D_ADOPT: "采纳: 手动登记已退款(非重复/SN可解析)",
D_EX_DUP: "排除: 重复残留(dup_residual)",
D_EX_NO_SN: "排除: SN不可解析(sn_unresolvable)",
D_EX_NOT_REFUNDED: "排除: 登记为未退款(蓄水, 非出款)",
E_ADOPT_ALL: "采纳: E路全量(水单对账基准, 状态原样保留)",
PENDING_UNKNOWN: "待定: 规则白名单外的未知枚举, 待业务确认",
}
# 前端下发格式: "code=中文" 列表(经 DataSourceField.enum_values 走既有通道)
ADOPT_REASON_ENUM: list[str] = [f"{k}={v}" for k, v in ADOPT_REASON_CN.items()]
# adopt_state 三态枚举(含 NULL 语义说明见 AdoptionColumnsMixin 注释)
ADOPT_STATE_ENUM: list[str] = ["adopted", "excluded", "pending"]
ROUTE_A = "A"
ROUTE_B = "B"
ROUTE_C = "C"

View File

@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
"""采纳治理四列在数据查看树的展示配置(#333)
锁定:
1. 经营五路 A~E 注册条目含 adopt_state/adopt_reason/adopt_rule_ver/adopted_at
四列, 且中文名/枚举/可筛选属性正确(经 ensure_data_sources_registered 反射落库);
2. adopt_reason enum 下发 "规则码=中文释义" 格式, 与采纳作业模块规则码全集一致
(改规则码不改释义 → 本测试失败, 防两处漂移);
3. 采纳作业模块 ADOPT_REASON_CN 覆盖 ADOPT_STATE 相关全部规则码常量。
为什么测注册结果而不只测 FIELD_OVERRIDES 常量: #100 教训是「配置对不代表渲染对」
—— 前端完全依赖 /data-sources/{code}/fields 下发的字段元数据, 注册漏了列, 界面
就没有该列; enum 断在注册层, tooltip 就没有释义。
"""
from __future__ import annotations
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.data_source import DataSource, DataSourceField
from app.services.data_source_registry import ensure_data_sources_registered
from app.services.governance.refund_adoption import (
ADOPT_REASON_CN,
ADOPT_STATE_ENUM,
)
_ADOPT_CODES = ("refund", "wallet", "shanmao_refund", "manual_refund", "refund_record_report")
_ADOPT_FIELDS = ("adopt_state", "adopt_reason", "adopt_rule_ver", "adopted_at")
@pytest.fixture()
def registered(db: Session):
"""跑一次幂等注册, 返回 code → {field_name: DataSourceField} 映射。"""
ensure_data_sources_registered(db)
db.commit()
out: dict[str, dict[str, DataSourceField]] = {}
for code in _ADOPT_CODES:
ds = db.scalar(select(DataSource).where(DataSource.code == code))
fields = list(db.scalars(select(DataSourceField).where(DataSourceField.source_id == ds.id)))
out[code] = {f.field_name: f for f in fields}
return out
class TestAdoptFieldRegistration:
def test_all_five_sources_have_adopt_fields(self, registered):
"""★ 五路原始表都必须注册采纳四列(漏注册 = 界面没有该列)"""
for code, fmap in registered.items():
missing = [f for f in _ADOPT_FIELDS if f not in fmap]
assert not missing, f"{code} 缺采纳字段: {missing}"
def test_adopt_state_meta(self, registered):
"""adopt_state: 三态枚举 + 可筛选(待定行可过滤的依据) + 中文名"""
for code, fmap in registered.items():
f = fmap["adopt_state"]
assert f.enum_values == ADOPT_STATE_ENUM
assert f.is_filterable is True
assert "采纳状态" in (f.field_name_cn or "")
def test_adopt_reason_meta(self, registered):
"""adopt_reason: enum 为 '规则码=中文释义' 格式且覆盖规则码全集; 不可筛选"""
for code, fmap in registered.items():
f = fmap["adopt_reason"]
assert f.is_filterable is False
enums = f.enum_values or []
parsed = dict(e.split("=", 1) for e in enums)
assert set(parsed) == set(ADOPT_REASON_CN), f"{code} 规则码释义与全集不一致"
def test_adopt_reason_covers_all_rule_constants(self):
"""★ 规则码常量全集必须都有中文释义(新增规则码忘加释义 → 在此拦截)"""
import re
import app.services.governance.refund_adoption as m
consts = {
v for k, v in vars(m).items()
if re.fullmatch(r"[ABCDE]_(ADOPT|EX)_[A-Z_]+", k or "")
}
consts.add(m.PENDING_UNKNOWN)
assert consts <= set(ADOPT_REASON_CN), f"缺释义的规则码: {consts - set(ADOPT_REASON_CN)}"
def test_adopt_ver_and_time_not_filterable(self, registered):
"""adopt_rule_ver/adopted_at 仅展示, 不进筛选(避免误把版本当业务条件)"""
for code, fmap in registered.items():
assert fmap["adopt_rule_ver"].is_filterable is False
assert fmap["adopted_at"].is_filterable is False

View File

@@ -29,6 +29,14 @@
因为投诉口径是 `service_category` 空白或='投诉'(BMS 导出未选类别即空白),
预置筛很容易与「投诉分析」页的口径打架。要看投诉口径请去投诉分析页。
· **不做树内搜索/拖拽排序**。当前 21 项,五分组可一屏扫完;加搜索是过度设计。
· **#333 采纳状态只读展示**。经营五路原始表行级展示采纳三态徽标 + 规则码
中文 tooltip,回答"这行原始数据到底进没进统计"(口径权威:
docs/standards/退款五路数据标准与采纳规则.md)。规则码释义由后端
/fields 的 enum_values 下发(单一来源在采纳作业模块 refund_adoption),
前端零硬编码,规则升级只改后端。
取舍:**不提供改采纳状态的任何入口** —— 人工治理一律走后端 admin API
(adoption_overrides)留痕 + 幂等重算(铁律 15),界面直改会绕过覆盖
审计与规则版本化,故本页四列全部只读。
-->
<div class="flex-row">
<!-- 左侧数据源列表 -->
@@ -181,6 +189,20 @@
>只读投影 · 真身「{{ currentSource.true_body_name }}」</el-tag>
</div>
<div class="header-actions">
<!-- #333: 采纳状态筛选(仅经营五路原始表显示) —— 待定行治理跟进入口 -->
<el-select
v-if="hasAdoptState"
v-model="filters.adopt_state"
size="small"
style="width: 160px"
placeholder="采纳状态: 全部"
clearable
@change="handleFilterChange"
>
<el-option label="采纳(进统计)" value="adopted" />
<el-option label="排除(不进统计)" value="excluded" />
<el-option label="待定(需治理跟进)" value="pending" />
</el-select>
<el-button
type="primary"
size="small"
@@ -216,7 +238,50 @@
:sortable="col.sortable"
show-overflow-tooltip
>
<template #default="{ row }">{{ fmtCell(row[col.prop], col.dataType) }}</template>
<!-- #333: 采纳状态列表头带口径说明(ⓘ) —— 页面层可见说明 -->
<template v-if="col.prop === 'adopt_state'" #header>
<span>{{ col.label }}</span>
<el-tooltip placement="top" effect="light" :width="340">
<template #content>
<div style="line-height: 1.7">
<b>这行原始数据进没进统计?</b>(口径:退款五路数据标准与采纳规则.md)<br>
<el-tag size="small" type="success">采纳</el-tag> 进入 std_* 标准表,计入经营统计<br>
<el-tag size="small" type="info">排除</el-tag> 规则排除,不计入统计(原因见「采纳规则码」列)<br>
<el-tag size="small" type="warning">待定</el-tag> 未知枚举,待业务确认,暂不计入<br>
<span style="opacity:.7">未判定 = 尚未跑过采纳作业(新同步行)。<br>
本页只读:改判走后台人工覆盖 API 留痕,规则版本化可重算。</span>
</div>
</template>
<el-icon class="adopt-info-icon"><InfoFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">
<!-- #333: 采纳三态徽标 -->
<template v-if="col.prop === 'adopt_state'">
<el-tag
v-if="row.adopt_state"
size="small"
:type="adoptStateMeta[row.adopt_state]?.type || 'info'"
>{{ adoptStateMeta[row.adopt_state]?.label || row.adopt_state }}</el-tag>
<span v-else class="cell-dim">未判定</span>
</template>
<!-- #333: 规则码 + 中文释义 tooltip(释义来自后端 enum 下发) -->
<template v-else-if="col.prop === 'adopt_reason'">
<el-tooltip v-if="row.adopt_reason" placement="top" effect="light" :width="320">
<template #content>
<div style="max-width: 300px; line-height: 1.6">
{{ adoptReasonCn(row.adopt_reason) }}
<div v-if="row.adopt_rule_ver" style="margin-top: 4px; opacity: .7">
规则版本: {{ row.adopt_rule_ver }}
</div>
</div>
</template>
<span class="adopt-reason-cell">{{ row.adopt_reason }}</span>
</el-tooltip>
<span v-else>-</span>
</template>
<template v-else>{{ fmtCell(row[col.prop], col.dataType) }}</template>
</template>
</el-table-column>
</el-table>
</div>
@@ -434,6 +499,10 @@ const tableColumns = computed(() => {
if (f.data_type === 'timestamp') width = 170
if (f.data_type === 'numeric') width = 120
if (f.semantic_role === 'sn') width = 180
// #333: 采纳治理四列固定列宽(徽标/规则码/版本/时间)
if (f.field_name === 'adopt_state') width = 150
if (f.field_name === 'adopt_reason') width = 150
if (f.field_name === 'adopt_rule_ver') width = 130
return {
prop: f.field_name,
label: f.field_name_cn || f.field_name,
@@ -444,6 +513,40 @@ const tableColumns = computed(() => {
})
})
// ── #333: 采纳治理四列展示辅助 ────────────────────────────────────────────
// 三态徽标文案与颜色(纯展示映射; 枚举值本身由后端权威定义)
const adoptStateMeta = {
adopted: { label: '采纳', type: 'success' },
excluded: { label: '排除', type: 'info' },
pending: { label: '待定', type: 'warning' },
}
// 当前数据源是否含采纳状态列(经营五路原始表; 其它表不显示筛选与徽标逻辑)
const hasAdoptState = computed(() =>
currentFields.value.some((f) => f.field_name === 'adopt_state')
)
// 规则码 → 中文释义: 从后端 /fields 的 adopt_reason enum_values("code=中文")解析,
// 前端零硬编码 —— 规则码升级只改采纳作业模块, 界面自动跟随。
const adoptReasonMap = computed(() => {
const f = currentFields.value.find((x) => x.field_name === 'adopt_reason')
const map = {}
for (const e of f?.enum_values || []) {
const i = e.indexOf('=')
if (i > 0) map[e.slice(0, i)] = e.slice(i + 1)
}
return map
})
function adoptReasonCn(code) {
// 人工覆盖行带 manual: 前缀(机器规则码仍保留, 见采纳作业模块人工覆盖约定)
if (code?.startsWith('manual:')) {
const raw = code.slice(7)
return `人工覆盖(原规则: ${adoptReasonMap.value[raw] || raw})`
}
return adoptReasonMap.value[code] || code
}
function fmtCell(value, dataType) {
if (value == null) return '-'
if (dataType === 'numeric') {
@@ -714,4 +817,18 @@ onMounted(() => {
color: #909399;
}
.placeholder-tag { margin-left: 6px; }
/* #333: 采纳治理列展示样式 */
.adopt-info-icon {
margin-left: 2px;
vertical-align: -2px;
color: #909399;
cursor: help;
}
.adopt-reason-cell {
font-family: monospace;
font-size: 12px;
cursor: help;
}
.cell-dim { color: #c0c4cc; }
</style>