refactor(refund): #334 退款域业务代码零读 ods —— 全状态场景迁 std_*_all 镜像视图
Some checks failed
gitleaks / gitleaks (push) Failing after 11s
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 15m29s
backend-test / frontend-lint-test (push) Has been cancelled

- 新增 5 个全量镜像视图(std_refund_online_all/std_wallet_flow_all/
  std_refund_shanmao_all/std_refund_manual_all/std_refund_record_all,
  SELECT * 透传 ods 含采纳四列), 单一来源 STD_ALL_VIEWS 供迁移/conftest 共用
- 迁移范围(业务读取→视图, 写入一律不动仍落 ods):
  analytics/refund.py 18 处(挽回专项/状态分布/工作量/导出明细/登记水位)
  analytics/refund_chain.py 3 处(链路台账, ORM→table()/column()轻量句柄
  避免对只读视图 autoflush)  today/_staff.py 2 处(退款工作量)
  device_profile 5 / audit_timeline 7 / reconcile_check 24 /
  matcher 8 / reconcile 7(读; backfill_d_op_time 写路径保留)
- quality_disposition_service 保留 ods 读(质量治理本职, 铁律第三本职)
- ods 回归三本职: 采集写入 / 采纳作业读取 / 质量治理标记
- 口径文档+AGENTS.md 铁律15: 全状态语义一律读 std_*_all, 业务代码禁直读 ods
- ruff app/ tests/ 全绿; 全量 pytest 2513 全绿(首轮 5 个 flake 重跑收敛)

Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com
This commit is contained in:
2026-10-19 08:30:32 +08:00
committed by trae-solo-agent
parent 687a817f6e
commit b5b7b8aa75
13 changed files with 400 additions and 244 deletions

View File

@@ -83,7 +83,9 @@
- ods 原始表**只同步不治理**:采集 upsert 不得覆盖采纳标识四列(`adopt_state` / `adopt_reason` /
`adopt_rule_ver` / `adopted_at`);质量治理入口 = 人工覆盖 API(`adoption_overrides`),禁止直接改原始数据。
- 采纳规则变更必须**升版 `ADOPT_RULE_VER`** 并同步修订口径文档;全状态/全方式语义例外
(工作量统计、导出原始明细自筛)允许读 ods 原始层,但必须在注释中声明例外理由。
(挽回漏斗、状态分布、工作量统计、导出原始明细、链路台账、设备档案、对账勾稽)
一律读 `std_*_all` 全量镜像视图(ods 同构透传),**业务代码禁止直读 ods**;
ods 仅剩采集写入、采纳作业读取、质量治理标记三个本职消费(#334)。
- 企业商户维度统一经 `dim_device_merchant`(as-of 分段维表)回填,禁止各处自行实现商户回落逻辑。
## 输出风格

View File

@@ -0,0 +1,46 @@
"""add std_*_all full-mirror views over ods refund tables
Revision ID: b443c1a2d3e4
Revises: a332b05d6e7f
Create Date: 2026-10-19
#334: 退款域业务代码零读 ods —— 全状态语义场景统一迁 std 层全量镜像视图。
五个视图(public schema, SELECT * 透传 ods, 含 T1 已加的采纳四列):
std_refund_online_all → ods.refunds (A 路全量)
std_wallet_flow_all → ods.wallet_withdrawals (B 路全量)
std_refund_shanmao_all → ods.shanmao_refunds (C 路全量)
std_refund_manual_all → ods.manual_refunds (D 路全量)
std_refund_record_all → ods.refund_record_reports (E 路全量)
定位:
- 经营统计口径仍读 std_* 采纳集物理表(不动);
- 全状态语义(挽回漏斗/状态分布/工作量/导出明细/链路台账/设备档案/对账勾稽)
一律读本视图,业务代码不再直读 ods;
- ods 回归三本职:采集写入、采纳作业读取、质量治理标记。
视图零存储、随 ods 实时一致,采纳作业无需改动。
"""
from typing import Sequence, Union
from alembic import op
from app.models.refund_standard import STD_ALL_VIEWS
revision: str = "b443c1a2d3e4"
down_revision: Union[str, None] = "a332b05d6e7f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# 视图名 → ods 源表: 单一来源 STD_ALL_VIEWS(代码常量白名单, 与 conftest 逐字一致)
_VIEWS = STD_ALL_VIEWS
def upgrade() -> None:
for view, src in _VIEWS.items():
# CREATE OR REPLACE: 幂等, 重跑安全; 显式 ods 限定不受 search_path 影响
op.execute(f"CREATE OR REPLACE VIEW {view} AS SELECT * FROM {src}")
def downgrade() -> None:
for view in _VIEWS:
op.execute(f"DROP VIEW IF EXISTS {view}")

View File

@@ -279,3 +279,17 @@ class AdoptionOverride(Base):
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=text("now()"), nullable=False, comment="提交时间"
)
# ── #334: std_*_all 全量镜像视图(单一来源,Alembic 迁移与 tests/conftest 共用)────────
# 键=视图名(public schema), 值=ods 源表全限定名。SELECT * 透传, 含 T1 采纳四列。
# 定位: 全状态语义场景(挽回漏斗/状态分布/工作量/导出明细/链路台账/设备档案/对账勾稽)
# 统一读本视图; 经营统计仍读 std_* 采纳集物理表; 业务代码禁止直读 ods。
# ⚠️ 只读: 视图不可写; 任何写入(采集 upsert / 质量标记 / 人工覆盖)必须落 ods 源表。
STD_ALL_VIEWS: dict[str, str] = {
"std_refund_online_all": "ods.refunds",
"std_wallet_flow_all": "ods.wallet_withdrawals",
"std_refund_shanmao_all": "ods.shanmao_refunds",
"std_refund_manual_all": "ods.manual_refunds",
"std_refund_record_all": "ods.refund_record_reports",
}

View File

@@ -63,7 +63,8 @@ def _refund_recovery_core(db: Session, ts, te) -> dict:
与 refund_recovery_analysis 第1-5步逻辑完全一致, 提取为公共函数供深度分析接口复用.
#332 例外声明(铁律15): 本函数全部退款读取走 ods 原始层 refunds, 不切 std ——
#334 例外声明(铁律15): 本函数全部退款读取走 std_refund_online_all 全量镜像视图
(ods 同构透传, 含被采纳排除行), 不切 std 采纳集 ——
挽回漏斗是全状态语义, 需追「申请中/无需退款/驳回」等未完结工单
(std 只含已退款采纳行, 读 std 会漏掉正在挽回的活单, 成功率/待挽回清单全错).
@@ -240,7 +241,7 @@ def _refund_recovery_core(db: Session, ts, te) -> dict:
COALESCE(r.actual_amount, r.refund_amount, 0) AS amount,
r.status AS status, r.refund_method AS method,
r.created_at_raw AS event_time
FROM refunds r
FROM std_refund_online_all r
JOIN host_sns hs ON r.device_sn = hs.sn
WHERE r.created_at_raw IS NOT NULL
AND r.device_sn IS NOT NULL
@@ -320,7 +321,7 @@ def _refund_recovery_core(db: Session, ts, te) -> dict:
"""
SELECT r.device_sn AS sn, r.operator AS op,
COALESCE(r.actual_amount, r.refund_amount, 0) AS amt
FROM refunds r
FROM std_refund_online_all r
WHERE r.created_at_raw IS NOT NULL
AND r.created_at_raw >= :ts AND r.created_at_raw <= :te7
AND r.device_sn IS NOT NULL
@@ -519,7 +520,8 @@ def refund_person_devices(
) -> dict[str, Any]:
"""接口2: 人员接待设备清单 - 某人员周期内接待的所有SN + 成功/失败明细.
#332 例外声明(铁律15): 退款读取走 ods 原始层 refunds —— 全状态语义,
#334 例外声明(铁律15): 退款读取走 std_refund_online_all 全量镜像视图
(ods 同构透传, 含被排除行) —— 全状态语义,
理由见 _refund_recovery_core docstring。
"""
cur_s, cur_e, _, _, _, _ = _resolve_windows(None, start, end)
@@ -578,7 +580,7 @@ def refund_person_devices(
"""
SELECT r.device_sn AS sn, r.refund_no, r.created_at_raw, r.status,
COALESCE(r.actual_amount, r.refund_amount, 0) AS amt
FROM refunds r
FROM std_refund_online_all r
WHERE r.created_at_raw IS NOT NULL
AND r.created_at_raw >= :ts AND r.created_at_raw <= :te7
AND r.device_sn = ANY(:sn_arr)
@@ -726,7 +728,8 @@ def refund_device_analysis(
) -> dict[str, Any]:
"""接口5: 设备分析 - Top风险设备 + 时差分布 + 接待次数分布.
#332 例外声明(铁律15): 退款读取走 ods 原始层 refunds —— 全状态语义,
#334 例外声明(铁律15): 退款读取走 std_refund_online_all 全量镜像视图
(ods 同构透传, 含被排除行) —— 全状态语义,
理由见 _refund_recovery_core docstring。
"""
cur_s, cur_e, _, _, _, _ = _resolve_windows(None, start, end)
@@ -757,7 +760,7 @@ def refund_device_analysis(
r_row = db.execute(text(
"""
SELECT r.refund_no, r.created_at_raw, r.status
FROM refunds r
FROM std_refund_online_all r
WHERE r.device_sn = :sn AND r.created_at_raw IS NOT NULL
AND COALESCE(r.status, '') NOT IN ('无需退款', '驳回', '申请中')
ORDER BY r.created_at_raw DESC LIMIT 1
@@ -879,7 +882,8 @@ def refund_device_detail(
) -> dict[str, Any]:
"""接口6: 设备详情 - 接待时间线 + 退款记录 + 工单 + 钱包.
#332 例外声明(铁律15): 退款/钱包读取走 ods 原始层 refunds/wallet_withdrawals ——
#334 例外声明(铁律15): 退款/钱包读取走 std_refund_online_all / std_wallet_flow_all
全量镜像视图(ods 同构透传, 含被排除行) ——
设备详情需展示该 SN 全状态退款记录与全量钱包流水(含失败/冻结), 非经营统计口径。
"""
cur_s, cur_e, _, _, _, _ = _resolve_windows(None, start, end)
@@ -926,7 +930,7 @@ def refund_device_detail(
"""
SELECT r.refund_no, r.refund_amount, r.actual_amount, r.refund_method,
r.status, r.operator, r.auditor, r.created_at_raw, r.refund_reason
FROM refunds r
FROM std_refund_online_all r
WHERE r.device_sn = :sn AND r.created_at_raw IS NOT NULL
ORDER BY r.created_at_raw DESC
LIMIT 10
@@ -965,7 +969,7 @@ def refund_device_detail(
for r in db.execute(text(
"""
SELECT w.id, w.op_time, w.op_amount, w.op_type
FROM wallet_withdrawals w
FROM std_wallet_flow_all w
WHERE w.device_sn = :sn AND w.op_time IS NOT NULL
ORDER BY w.op_time DESC LIMIT 10
"""
@@ -1320,7 +1324,8 @@ def refund_decision_overview(
# 作为防御性保留, 防采纳规则未来放宽导致口径漂移)
# 采纳规则版本: ADOPT_RULE_VER=r20261016_v1; 采纳行数/金额基线见 #332 T7 对账报告。
# 明细/失败归因/全方式语义查询(设备详情/退款失败分析/操作人全方式统计)不属
# 口径统计, 读 ods 原始层 refunds/wallet_withdrawals(见各处 #332 注释)。
# 口径统计, 读 std_*_all 全量镜像视图(std_refund_online_all / std_wallet_flow_all
# 等, ods 同构透传含被排除行; 见各处 #334 注释)。
# 口径权威: docs/standards/退款五路数据标准与采纳规则.md
# ═══════════════════════════════════════════════════════════════════════
# 线下退款、转入提现账户(钱包内部流转)不属于四路, 不计入现金流全景
@@ -1477,11 +1482,12 @@ def refund_decision_overview(
reconcile_diff = round(cur_cash - actual_cash, 2) # 对账偏差 = 经营四路 − E
# ── 4. 手动退款登记水位 (与钱包水位同构: 登记 vs 已退款完成 = 蓄水) ──
# 登记进水=全部登记(含未退/蓄水) → #332 读 ods 原始表 manual_refunds(std 只含已退款采纳行)
# 登记进水=全部登记(含未退/蓄水) → #334 读 std_refund_manual_all 全量镜像
# (ods 同构透传, 含被排除行; std 采纳集只含已退款行)
mreg_rows = db.execute(text(f"""
SELECT {_day("m.register_time")} AS d,
count(*) AS cnt, COALESCE(sum(m.refund_amount), 0) AS amt
FROM manual_refunds m
FROM std_refund_manual_all m
WHERE m.register_time >= :ts AND m.register_time <= :te
GROUP BY 1 ORDER BY 1
"""), {"ts": cur_s, "te": cur_e}).fetchall()
@@ -1875,8 +1881,8 @@ def refund_export_original(
"""
cur_s, cur_e, _, _, _, _ = _resolve_windows(None, start, end)
# #332: 经营口径 → std_refund_online(采纳口径表层固化, 无需再拼 method/status 条件);
# 原始全量(默认) → ods 原始表 refunds(全状态全方式供人工自筛)
_table = "std_refund_online" if online_refunded_only else "refunds"
# #334: 原始全量(默认) → std_refund_online_all 全量镜像(ods 同构透传, 全状态全方式供人工自筛)
_table = "std_refund_online" if online_refunded_only else "std_refund_online_all"
# 涉诉分类 (与决策概览口径一致; 三源关键词与 _complaint_source_type_case 约定一致)
comp_map: dict[str, str] = {}
@@ -1979,16 +1985,16 @@ def refund_export_original(
def refund_export_all(
db: Session, start: str | None = None, end: str | None = None,
) -> dict[str, list[dict[str, Any]]]:
"""导出退款分析 Excel(多 sheet): 明细(ods 原始全量, 供人工分析) + 汇总(std 采纳口径对账).
"""导出退款分析 Excel(多 sheet): 明细(std_*_all 全量镜像, 供人工分析) + 汇总(std 采纳口径对账).
明细 sheet(2026-09-09 放宽为台账原始, 人工分析用, 不砍状态/类型行, 附状态列自筛;
#332: 明细维持「原始全量自筛」语义不切 std —— std 表只含采纳行, 会丢人工自筛所需
的驳回/失败/登记未退等行; 明细直读 ods 原始表):
- A退费单明细(全量): refunds 全部(线上直退/线下/转入提现账户/未填写 + 驳回/无需退款/空白), 时间=created_at_raw
- B钱包流水明细(全量): wallet_withdrawals 全部 op_type(用户提现/订单退费转入/系统扣费), 时间=op_time
- C闪猫退款明细(全量): shanmao_refunds 全部状态, 时间=COALESCE(audit_time, created_at)
- D手动退款明细(全量): manual_refunds 全部(is_refunded 是/否), 时间=COALESCE(op_time, register_time)
- E退款记录报表(全量): refund_record_reports 全部 refund_status, 时间=refund_time
#334: 明细维持「全量自筛」语义不切 std 采纳集 —— std 表只含采纳行, 会丢人工自筛所需
的驳回/失败/登记未退等行; 明细读 std_*_all 全量镜像视图(ods 同构透传, 含被排除行)):
- A退费单明细(全量): std_refund_online_all 全部(线上直退/线下/转入提现账户/未填写 + 驳回/无需退款/空白), 时间=created_at_raw
- B钱包流水明细(全量): std_wallet_flow_all 全部 op_type(用户提现/订单退费转入/系统扣费), 时间=op_time
- C闪猫退款明细(全量): std_refund_shanmao_all 全部状态, 时间=COALESCE(audit_time, created_at)
- D手动退款明细(全量): std_refund_manual_all 全部(is_refunded 是/否), 时间=COALESCE(op_time, register_time)
- E退款记录报表(全量): std_refund_record_all 全部 refund_status, 时间=refund_time
汇总 sheet(五路汇总(A-E))保持成功口径, #332 起取数 std 标准表(采纳作业固化, r20261016_v1),
与 refund_decision_overview.routes_daily 一致:
@@ -2003,7 +2009,7 @@ def refund_export_all(
# A 退费单明细 (原始全量: 所有退费单行含线下/转入提现/未填写 + 驳回/无需退款/空白状态,
# 供人工分析自筛; 2026-09-09 由'仅线上已退款'放宽回全量;
# #332: 明细维持原始全量读 ods 原始表 refunds, 不切 std)
# #334: 明细维持全量语义读 std_refund_online_all 全量镜像(ods 同构透传, 含被排除行), 不切 std 采纳集)
sheets["A退费单明细(全量)"] = refund_export_original(db, start, end)
def _sh(dt) -> str:
@@ -2013,10 +2019,10 @@ def refund_export_all(
# B 钱包流水明细 (台账原始全量: 用户提现/订单退费转入/系统扣费等全部 op_type,
# 含失败/冻结备注; 附"操作类型/备注"列供人工筛出真实出款; 2026-09-09 放宽;
# #332: 读 ods 原始表 wallet_withdrawals, 不切 std)
# #334: 读 std_wallet_flow_all 全量镜像(ods 同构透传, 含被排除行), 不切 std 采纳集)
b_rows = db.execute(text("""
SELECT account_id, device_sn, op_type, op_amount, wallet_balance, operator, op_time, remark
FROM wallet_withdrawals w
FROM std_wallet_flow_all w
WHERE w.op_time >= :ts AND w.op_time <= :te
ORDER BY w.op_time
"""), {"ts": cur_s, "te": cur_e}).fetchall()
@@ -2032,12 +2038,12 @@ def refund_export_all(
} for r in b_rows]
# C 闪猫退款明细 (台账原始全量: 全部状态含申请中/已驳回/已退款; 附"状态"列自筛; 2026-09-09 放宽;
# #332: 读 ods 原始表 shanmao_refunds, 不切 std)
# #334: 读 std_refund_shanmao_all 全量镜像(ods 同构透传, 含被排除行), 不切 std 采纳集)
c_rows = db.execute(text("""
SELECT refund_no, order_no, device_sn, phone, paid_amount, refund_amount, actual_amount,
package_name, merchant_name, refund_reason, refund_method, status,
applicant, auditor, operator, apply_time, audit_time, refund_time, remark, audit_remark
FROM shanmao_refunds
FROM std_refund_shanmao_all
WHERE COALESCE(audit_time, created_at) >= :ts AND COALESCE(audit_time, created_at) <= :te
ORDER BY COALESCE(audit_time, created_at)
"""), {"ts": cur_s, "te": cur_e}).fetchall()
@@ -2065,11 +2071,11 @@ def refund_export_all(
} for r in c_rows]
# D 手动退款明细 (台账原始全量: 含 is_refunded=否 的登记(蓄水/未退)与已治理重复行;
# 附"是否已退款/质量状态"列自筛; 2026-09-09 放宽; #332: 读 ods 原始表 manual_refunds, 不切 std)
# 附"是否已退款/质量状态"列自筛; 2026-09-09 放宽; #334: 读 std_refund_manual_all 全量镜像(ods 同构透传, 含被排除行), 不切 std 采纳集)
d_rows = db.execute(text("""
SELECT register_time, device_sn, order_no, merchant_id, order_amount, refund_amount,
registrant, is_refunded, refund_operator, op_time, remark, quality_status
FROM manual_refunds
FROM std_refund_manual_all
WHERE COALESCE(op_time, register_time) >= :ts AND COALESCE(op_time, register_time) <= :te
ORDER BY COALESCE(op_time, register_time)
"""), {"ts": cur_s, "te": cur_e}).fetchall()
@@ -2089,11 +2095,11 @@ def refund_export_all(
} for r in d_rows]
# E 退款记录报表明细 (台账原始全量: 全部 refund_status; 附"退款状态"列自筛; 2026-09-09 放宽;
# #332: 读 ods 原始表 refund_record_reports, 不切 std)
# #334: 读 std_refund_record_all 全量镜像(ods 同构透传, 含被排除行), 不切 std 采纳集)
e_rows = db.execute(text("""
SELECT platform_order_no, wx_order_no, merchant_id, merchant_name, device_sn,
product_name, refund_time, apply_refund_amount, refund_amount, refund_status
FROM refund_record_reports
FROM std_refund_record_all
WHERE refund_time >= :ts AND refund_time <= :te
ORDER BY refund_time
"""), {"ts": cur_s, "te": cur_e}).fetchall()
@@ -2297,7 +2303,8 @@ def refund_recovery_analysis(
) -> dict[str, Any]:
"""退款挽回专项分析: 总体KPI + 原因分布 + 按人员明细 + 周趋势.
#332 例外声明(铁律15): 本函数全部退款读取(含各 CTE)走 ods 原始层 refunds ——
#334 例外声明(铁律15): 本函数全部退款读取(含各 CTE)走 std_refund_online_all
全量镜像视图(ods 同构透传, 含被排除行) ——
全状态语义, 理由见 _refund_recovery_core docstring。
返回结构:
@@ -2534,7 +2541,7 @@ def refund_recovery_analysis(
r.status AS status,
r.refund_method AS method,
r.created_at_raw AS event_time
FROM refunds r
FROM std_refund_online_all r
JOIN host_sns hs ON r.device_sn = hs.sn
WHERE r.created_at_raw IS NOT NULL
AND r.device_sn IS NOT NULL
@@ -2632,13 +2639,13 @@ def refund_recovery_analysis(
# 注意: 这只是 "退款操作工作量" 独立字段, 不影响成功率计算的分子分母
refund_op_sns: dict[str, set[str]] = {} # operator alias -> set of 退款操作 SN
refund_op_amt: dict[str, Decimal] = {} # operator alias -> 操作金额合计
# 从 refunds 拉 operator (统计真人操作的所有退款方式:线上/线下/转入提现账户)
# 从 std_refund_online_all 拉 operator (统计真人操作的所有退款方式:线上/线下/转入提现账户)
for r in db.execute(text(
"""
SELECT r.device_sn AS sn,
r.operator AS op,
COALESCE(r.actual_amount, r.refund_amount, 0) AS amt
FROM refunds r
FROM std_refund_online_all r
WHERE r.created_at_raw IS NOT NULL
AND r.created_at_raw >= :ts AND r.created_at_raw <= :te7
AND r.device_sn IS NOT NULL
@@ -3003,7 +3010,7 @@ def refund_recovery_analysis(
COALESCE(r.actual_amount, r.refund_amount, 0) AS amount,
r.status AS status, r.refund_method AS method,
r.created_at_raw AS event_time
FROM refunds r
FROM std_refund_online_all r
WHERE r.created_at_raw IS NOT NULL
AND r.created_at_raw >= :ts AND r.created_at_raw <= :te7
AND r.device_sn IS NOT NULL
@@ -3073,7 +3080,7 @@ def refund_recovery_analysis(
FROM (
SELECT r.device_sn AS sn,
DATE(r.created_at_raw AT TIME ZONE 'Asia/Shanghai') AS day
FROM refunds r
FROM std_refund_online_all r
WHERE r.created_at_raw IS NOT NULL
AND r.created_at_raw >= :ts AND r.created_at_raw <= :te7
AND r.device_sn = ANY(:sn_arr)
@@ -3169,12 +3176,12 @@ def refund_recovery_analysis(
key = str(r.d)
day_to_host.setdefault(key, set()).add(r.sn)
# 日粒度 失败SN (T+7, 修正口径: 不限制operator/refund_method, 工单用register_time)
# 与 KPI global_fail_sns 一致: refunds + 退款工单 + cs_record关键词 三来源
# 与 KPI global_fail_sns 一致: std_refund_online_all + 退款工单 + cs_record关键词 三来源
ck = _RR_CS_FAIL_KW
day_fail_rows = db.execute(text(
f"""
SELECT r.device_sn AS sn
FROM refunds r
FROM std_refund_online_all r
WHERE r.created_at_raw IS NOT NULL
AND r.created_at_raw >= :ts AND r.created_at_raw <= :te7
AND COALESCE(r.status, '') NOT IN ('无需退款', '驳回', '申请中')
@@ -3256,12 +3263,12 @@ def refund_recovery_analysis(
today_all_sns.update(s)
today_fail_sns: set[str] = set()
if today_all_sns:
# 与 KPI global_fail_sns 口径一致: refunds + 退款工单 + cs_record关键词 三来源
# 与 KPI global_fail_sns 口径一致: std_refund_online_all + 退款工单 + cs_record关键词 三来源
ck = _RR_CS_FAIL_KW
tfr = db.execute(text(
"""
WITH arr AS (SELECT unnest(CAST(:sn_arr AS text[])) AS sn)
SELECT DISTINCT r.device_sn AS sn FROM refunds r JOIN arr a ON r.device_sn = a.sn
SELECT DISTINCT r.device_sn AS sn FROM std_refund_online_all r JOIN arr a ON r.device_sn = a.sn
WHERE COALESCE(r.status, '') NOT IN ('无需退款', '驳回', '申请中')
UNION
SELECT DISTINCT st.device_sn AS sn FROM service_tickets st JOIN arr a ON st.device_sn = a.sn

View File

@@ -4,15 +4,15 @@ import re as _re
from datetime import datetime, timedelta
from typing import Any
from sqlalchemy import func, select, text
from sqlalchemy import column, func, select, table, text
from sqlalchemy.orm import Session
# #332: 链路分析是按单业务台账语义(非蓄水池核算)—— A 路径=「转入提现账户」退款
# #334: 链路分析是按单业务台账语义(非蓄水池核算)—— A 路径=「转入提现账户」退款
# 工单(被 std A 路采纳规则排除), 按单时效(D1发起→D2入钱包→D4提现)需要工单锚点
# (B 路进水 remark 无 RF 单号, 生产实测无法纯 B 还按单还原) → 退款单读 ods 原始层;
# (B 路进水 remark 无 RF 单号, 生产实测无法纯 B 还按单还原) → 退款单读
# std_refund_online_all 全量镜像(ods.refunds 同构透传, 含被排除行);
# B 成功提现与 D 手动退款取 std 表(采纳口径表层固化)。
# 蓄水池/水位核算与链路分析无关 —— 那是纯 B 一本账(口径文档 §四)。
from app.models.refund import Refund
from app.models.refund_standard import StdRefundManual, StdWalletFlow
# 跨子模块引用(拆分自 app.services.analytics, 原同文件内直接调用)
@@ -38,6 +38,18 @@ from app.services.analytics._core import (
_RFN_RE = _re.compile(r'退费单\s*([A-Za-z0-9]+)')
# #334: 全状态语义读 std_refund_online_all 全量镜像(ods.refunds 同构透传, 含被采纳规则排除行)。
# 用轻量 table()/column() 而非 ORM 实体: 视图只读, 且下方会对行内时间字段原地 naive 归一,
# ORM 实体挂载 Session 后赋值会在 autoflush 时对只读视图发 UPDATE 报错。
_REFUND_ALL = table(
"std_refund_online_all",
column("refund_no"), column("platform_order_no"), column("device_sn"),
column("refund_method"), column("refund_reason"), column("refund_amount"), column("actual_amount"),
column("status"), column("package_name"), column("merchant_name"),
column("auditor"), column("operator"),
column("created_at_raw"), column("created_at"), column("updated_at_raw"), column("updated_at"),
)
def _build_chain_rows(db: Session, start: datetime | None = None, end: datetime | None = None):
"""构建"一笔实际出款"的全链路记录 (只有 A=转入钱包+B=线上直退两条有效路径, 其他无效剔除)。
@@ -46,9 +58,10 @@ def _build_chain_rows(db: Session, start: datetime | None = None, end: datetime
- B 路径 D4 = 退费单 updated_at_raw (接口回调成功 ≈ 实际到账)
"""
# 1. 取全量数据 (66k rows, 内存可容纳)
# #332: 读 ods 原始层 —— 链路 A 路径是「转入提现账户」退款, std_refund_online
# 不含该方式(采纳规则排除), 也不能只含已退款行(链路要全状态)
all_rf = list(db.execute(select(Refund)).scalars().all())
# #334: 读 std_refund_online_all 全量镜像(ods 同构透传, 含被排除行) —— 链路 A 路径是
# 「转入提现账户」退款, std_refund_online 不含该方式(采纳规则排除), 也不能只含已退款行(链路要全状态)
from types import SimpleNamespace as _SN
all_rf = [_SN(**dict(r._mapping)) for r in db.execute(select(_REFUND_ALL)).all()]
# 2026-08-28 → #332: 只取"成功提现"—— std_wallet_flow 已在表层固化
# (失败/冻结/空 op_type 不进表, 原 _WITHDRAW_FAIL_REMARK 关键词机制删除)
all_wd = list(db.execute(
@@ -296,10 +309,13 @@ def refund_withdrawal_overview(db: Session, start: str | None = None, end: str |
}
# A路径未提现率 (全局口径, 不随D4窗口变化: 统计全量转入提现账户工单 vs 已匹配提现)
# #332: 读 ods 原始层 —— 按单台账口径(转入提现账户行被 std A 路采纳规则排除,
# std 里查不到); 蓄水池/水位的纯 B 核算见决策概览 wallet_water, 与本指标无关
rf_all = list(db.execute(select(Refund.refund_method, func.count(), func.sum(Refund.refund_amount))
.group_by(Refund.refund_method)).all())
# #334: 读 std_refund_online_all 全量镜像(ods 同构透传, 含被排除行) —— 按单台账口径
# (转入提现账户行被 std A 路采纳规则排除, std 采纳集里查不到);
# 蓄水池/水位的纯 B 核算见决策概览 wallet_water, 与本指标无关
rf_all = list(db.execute(
select(_REFUND_ALL.c.refund_method, func.count(), func.sum(_REFUND_ALL.c.refund_amount))
.group_by(_REFUND_ALL.c.refund_method)
).all())
a_total_cnt = 0; a_total_amt = 0.0
for m, c, am in rf_all:
if m and "转入" in m:
@@ -624,9 +640,11 @@ def refund_withdrawal_sleeping(
"""
s = datetime.fromisoformat(start) if start else None
e = datetime.fromisoformat(end) if end else None
# #334: 全状态全方式语义(转入提现账户行被 std A 路采纳规则排除) →
# 读 std_refund_online_all 全量镜像(ods 同构透传, 含被排除行)
rf_rows = db.execute(text("""
SELECT refund_no, device_sn, refund_amount, created_at_raw, operator
FROM refunds
FROM std_refund_online_all
WHERE refund_method LIKE '%转入提现%'
AND created_at_raw >= :ts AND created_at_raw <= :te
ORDER BY created_at_raw

View File

@@ -12,7 +12,7 @@ from decimal import Decimal
from datetime import timedelta
from typing import Any
from sqlalchemy import and_, case, func, literal, or_, select, text, union
from sqlalchemy import and_, case, column, func, literal, or_, select, table, text, union
from sqlalchemy.orm import Session
from app.models.ods_clean import CardComplaintClean
@@ -22,7 +22,9 @@ from app.models.group import Group, PersonGroupMembership
from app.models.incident import Incident
from app.models.person import Person, PersonAlias
from app.models.ods_clean import RefundRecordReportClean # noqa: F401 (#332: 预留 import, 当前无取数)
from app.models.refund import Refund # #332: 退款挽回工作量=全状态口径, 读 ods 原始层(std 只含已退款采纳行)
# #334: 退款挽回工作量=全状态口径, 读 std_refund_online_all 全量镜像
# (ods.refunds 同构透传, 含被采纳规则排除行; std 采纳集只含已退款行)。
# 轻量 table()/column() 即可 —— 本文件只做聚合/明细查询, 不需要 ORM 实体。
from app.models.specialty import PersonSpecialtyAssignment, Specialty
from app.models.task import SpecialTask
from app.models.ticket import ServiceTicket
@@ -58,7 +60,6 @@ from app.services.analytics._core import (
_parse_date,
_pct_delta,
_resolve_windows,
_route_a_online_any_status_filter,
_route_a_online_refund_filter,
_route_b_withdraw_filter,
_route_e_cashflow_filter,
@@ -79,6 +80,12 @@ from app.services.analytics.reception import (
_operator_to_person_cte,
)
# #334: std_refund_online_all 全量镜像的轻量表句柄(见上方 import 处注释)
_REFUND_ONLINE_ALL = table(
"std_refund_online_all",
column("operator"), column("device_sn"), column("created_at_raw"), column("refund_method"),
)
def today_staff_efficiency(
db: Session,
@@ -179,15 +186,17 @@ def today_staff_efficiency(
# 已有 sys/system 排除, 保留
# ⚠️ 2026-09-08: 工作量口径(无金额)保持全状态(any_status), 不随经营口径收紧到已退款,
# 否则会低估"申请中/驳回"等已投入处理动作的工作量。
rf_q = (select(Refund.operator.label("op"),
# #334: 读 std_refund_online_all 全量镜像(ods 同构透传, 含被排除行);
# 原 _route_a_online_any_status_filter()(仅 refund_method='线上')改绑到镜像视图
rf_q = (select(_REFUND_ONLINE_ALL.c.operator.label("op"),
func.count().label("sc"),
func.count(func.distinct(func.nullif(func.trim(Refund.device_sn), ""))).label("pc"))
.where(Refund.created_at_raw.isnot(None),
Refund.created_at_raw >= ts, Refund.created_at_raw <= te,
Refund.operator.isnot(None),
Refund.operator.notin_(["sys", "system", "SYS", "System"]),
*_route_a_online_any_status_filter())
.group_by(Refund.operator)).cte("rf_q")
func.count(func.distinct(func.nullif(func.trim(_REFUND_ONLINE_ALL.c.device_sn), ""))).label("pc"))
.where(_REFUND_ONLINE_ALL.c.created_at_raw.isnot(None),
_REFUND_ONLINE_ALL.c.created_at_raw >= ts, _REFUND_ONLINE_ALL.c.created_at_raw <= te,
_REFUND_ONLINE_ALL.c.operator.isnot(None),
_REFUND_ONLINE_ALL.c.operator.notin_(["sys", "system", "SYS", "System"]),
_REFUND_ONLINE_ALL.c.refund_method == "线上")
.group_by(_REFUND_ONLINE_ALL.c.operator)).cte("rf_q")
# 4. 投诉类工单 处理人完工: 笔数 + 去重设备个数
cm_q = (select(ServiceTicket.handler.label("op"),
func.count().label("sc"),
@@ -373,12 +382,13 @@ def today_staff_efficiency(
)
),
-- 子查询2: refund SN → "退款处理" 专项 (实际退款操作)
-- #332: 全状态工作量语义(只看经手, 不管经营出款) → 读 ods 原始层,
-- #334: 全状态工作量语义(只看经手, 不管经营出款) → 读 std_refund_online_all
-- 全量镜像(ods 同构透传, 含被排除行);
-- std_refund_online 只含已退款采纳行会漏掉申请中/驳回的处理工作量
rf_sns AS (
SELECT r.operator AS op, '退款处理' AS spec,
r.device_sn AS sn
FROM refunds r
FROM std_refund_online_all r
WHERE r.created_at_raw IS NOT NULL
AND r.created_at_raw >= :ts AND r.created_at_raw <= :te
AND r.operator IS NOT NULL

View File

@@ -33,7 +33,7 @@
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy import or_, select, text
from sqlalchemy.orm import Session
from app.models.bms_order import BmsOrder
@@ -651,10 +651,14 @@ def build_device_timeline(
since = _resolve_time_window(days)
per_table_limit = max(limit, 200)
refund_stmt = select(Refund).where(Refund.device_sn == sn)
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
refund_sql = "SELECT * FROM std_refund_online_all WHERE device_sn = :sn"
refund_params: dict = {"sn": sn, "lim": per_table_limit}
if since:
refund_stmt = refund_stmt.where(Refund.created_at_raw >= since)
refunds = list(db.scalars(refund_stmt.order_by(Refund.created_at_raw.desc()).limit(per_table_limit)))
refund_sql += " AND created_at_raw >= :since"
refund_params["since"] = since
refund_sql += " ORDER BY created_at_raw DESC LIMIT :lim"
refunds = list(db.execute(text(refund_sql), refund_params).all())
ticket_stmt = select(ServiceTicket).where(ServiceTicket.device_sn == sn)
if since:
@@ -663,10 +667,14 @@ def build_device_timeline(
)
tickets = list(db.scalars(ticket_stmt.order_by(ServiceTicket.finish_time.desc().nullslast()).limit(per_table_limit)))
wallet_stmt = select(WalletWithdrawal).where(WalletWithdrawal.device_sn == sn)
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
wallet_sql = "SELECT * FROM std_wallet_flow_all WHERE device_sn = :sn"
wallet_params: dict = {"sn": sn, "lim": per_table_limit}
if since:
wallet_stmt = wallet_stmt.where(WalletWithdrawal.op_time >= since)
wallets = list(db.scalars(wallet_stmt.order_by(WalletWithdrawal.op_time.desc()).limit(per_table_limit)))
wallet_sql += " AND op_time >= :since"
wallet_params["since"] = since
wallet_sql += " ORDER BY op_time DESC LIMIT :lim"
wallets = list(db.execute(text(wallet_sql), wallet_params).all())
mr_stmt = select(MerchantRefund).where(MerchantRefund.device_no == sn)
if since:
@@ -697,16 +705,24 @@ def build_device_timeline(
device_combos = list(db.scalars(combo_stmt.order_by(DeviceCombo.created_at_raw.desc().nullslast()).limit(per_table_limit)))
# 手动退款登记(manual_refunds): 主时间=register_time
manual_stmt = select(ManualRefund).where(ManualRefund.device_sn == sn)
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
manual_sql = "SELECT * FROM std_refund_manual_all WHERE device_sn = :sn"
manual_params: dict = {"sn": sn, "lim": per_table_limit}
if since:
manual_stmt = manual_stmt.where(ManualRefund.register_time >= since)
manual_refunds = list(db.scalars(manual_stmt.order_by(ManualRefund.register_time.desc().nullslast()).limit(per_table_limit)))
manual_sql += " AND register_time >= :since"
manual_params["since"] = since
manual_sql += " ORDER BY register_time DESC NULLS LAST LIMIT :lim"
manual_refunds = list(db.execute(text(manual_sql), manual_params).all())
# 闪猫退款(shanmao_refunds): 主时间=apply_time
shanmao_stmt = select(ShanmaoRefund).where(ShanmaoRefund.device_sn == sn)
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
shanmao_sql = "SELECT * FROM std_refund_shanmao_all WHERE device_sn = :sn"
shanmao_params: dict = {"sn": sn, "lim": per_table_limit}
if since:
shanmao_stmt = shanmao_stmt.where(ShanmaoRefund.apply_time >= since)
shanmao_refunds = list(db.scalars(shanmao_stmt.order_by(ShanmaoRefund.apply_time.desc().nullslast()).limit(per_table_limit)))
shanmao_sql += " AND apply_time >= :since"
shanmao_params["since"] = since
shanmao_sql += " ORDER BY apply_time DESC NULLS LAST LIMIT :lim"
shanmao_refunds = list(db.execute(text(shanmao_sql), shanmao_params).all())
# 订单管理(bms_orders): 主时间=create_date(订单创建/同步锚点), 退 create_date
order_stmt = select(BmsOrder).where(BmsOrder.device_sn == sn)
@@ -729,10 +745,14 @@ def build_device_timeline(
return_trackings = list(db.scalars(rt_stmt.order_by(ReturnTracking.return_time.desc().nullslast()).limit(per_table_limit)))
# 线上退款水单(refund_record_reports, E路): 主时间=refund_time
rrr_stmt = select(RefundRecordReport).where(RefundRecordReport.device_sn == sn)
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
rrr_sql = "SELECT * FROM std_refund_record_all WHERE device_sn = :sn"
rrr_params: dict = {"sn": sn, "lim": per_table_limit}
if since:
rrr_stmt = rrr_stmt.where(RefundRecordReport.refund_time >= since)
refund_record_reports = list(db.scalars(rrr_stmt.order_by(RefundRecordReport.refund_time.desc().nullslast()).limit(per_table_limit)))
rrr_sql += " AND refund_time >= :since"
rrr_params["since"] = since
rrr_sql += " ORDER BY refund_time DESC NULLS LAST LIMIT :lim"
refund_record_reports = list(db.execute(text(rrr_sql), rrr_params).all())
items = _merge_and_sort(
[
@@ -801,10 +821,14 @@ def build_person_timeline(
since = _resolve_time_window(days)
per_table_limit = max(limit, 200)
refund_stmt = select(Refund).where(or_(Refund.operator.in_(alias_vals), Refund.auditor.in_(alias_vals)))
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
refund_sql = "SELECT * FROM std_refund_online_all WHERE (operator = ANY(:aliases) OR auditor = ANY(:aliases))"
refund_params = {"aliases": alias_vals, "lim": per_table_limit}
if since:
refund_stmt = refund_stmt.where(Refund.created_at_raw >= since)
refunds = list(db.scalars(refund_stmt.order_by(Refund.created_at_raw.desc()).limit(per_table_limit)))
refund_sql += " AND created_at_raw >= :since"
refund_params["since"] = since
refund_sql += " ORDER BY created_at_raw DESC LIMIT :lim"
refunds = list(db.execute(text(refund_sql), refund_params).all())
ticket_stmt = select(ServiceTicket).where(
or_(ServiceTicket.handler.in_(alias_vals), ServiceTicket.registrant.in_(alias_vals))
@@ -815,10 +839,14 @@ def build_person_timeline(
)
tickets = list(db.scalars(ticket_stmt.order_by(ServiceTicket.finish_time.desc().nullslast()).limit(per_table_limit)))
wallet_stmt = select(WalletWithdrawal).where(WalletWithdrawal.operator.in_(alias_vals))
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
wallet_sql = "SELECT * FROM std_wallet_flow_all WHERE operator = ANY(:aliases)"
wallet_params = {"aliases": alias_vals, "lim": per_table_limit}
if since:
wallet_stmt = wallet_stmt.where(WalletWithdrawal.op_time >= since)
wallets = list(db.scalars(wallet_stmt.order_by(WalletWithdrawal.op_time.desc()).limit(per_table_limit)))
wallet_sql += " AND op_time >= :since"
wallet_params["since"] = since
wallet_sql += " ORDER BY op_time DESC LIMIT :lim"
wallets = list(db.execute(text(wallet_sql), wallet_params).all())
cs_stmt = select(CsRecord).where(CsRecord.operator.in_(alias_vals))
if since:

View File

@@ -12,7 +12,7 @@
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy import func, select, text
from sqlalchemy.orm import Session
from app.models.bms_order import BmsOrder
@@ -22,13 +22,8 @@ from app.models.device import Device
from app.models.device_combo import DeviceCombo
from app.models.device_flow_usage import DeviceFlowUsage
from app.models.external_complaint import ExternalComplaint
from app.models.manual_refund import ManualRefund
from app.models.refund import Refund
from app.models.refund_record_report import RefundRecordReport
from app.models.return_tracking import ReturnTracking
from app.models.shanmao_refund import ShanmaoRefund
from app.models.ticket import ServiceTicket
from app.models.wallet import WalletWithdrawal
def build_device_profile(db: Session, device: Device) -> dict:
@@ -79,10 +74,17 @@ def build_device_profile(db: Session, device: Device) -> dict:
remaining = _sum(DeviceCombo, DeviceCombo.remaining_traffic, DeviceCombo.device_sn,
DeviceCombo.remaining_traffic != -1)
total_flow = _sum(DeviceFlowUsage, DeviceFlowUsage.flow_gb, DeviceFlowUsage.device_sn)
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
def _cnt_std(view_name: str) -> int:
return int(db.scalar(
text(f"SELECT count(*) FROM {view_name} WHERE device_sn = :sn"), {"sn": sn}
) or 0)
profile["kpi"] = {
"refund_count": _cnt(Refund, Refund.device_sn),
"refund_count": _cnt_std("std_refund_online_all"),
"ticket_count": _cnt(ServiceTicket, ServiceTicket.device_sn),
"wallet_count": _cnt(WalletWithdrawal, WalletWithdrawal.device_sn),
"wallet_count": _cnt_std("std_wallet_flow_all"),
"cs_count": _cnt(CsRecord, CsRecord.device_sn),
"external_complaint_count": _cnt(ExternalComplaint, ExternalComplaint.device_sn),
"card_complaint_count": _cnt(CardComplaint, CardComplaint.device_sn),
@@ -92,11 +94,11 @@ def build_device_profile(db: Session, device: Device) -> dict:
_sum(DeviceCombo, DeviceCombo.actual_amount, DeviceCombo.device_sn,
DeviceCombo.package_name.notilike("%质检%")), 2),
"combo_remaining_bytes": int(remaining),
"manual_refund_count": _cnt(ManualRefund, ManualRefund.device_sn),
"shanmao_refund_count": _cnt(ShanmaoRefund, ShanmaoRefund.device_sn),
"manual_refund_count": _cnt_std("std_refund_manual_all"),
"shanmao_refund_count": _cnt_std("std_refund_shanmao_all"),
"order_count": _cnt(BmsOrder, BmsOrder.device_sn),
"return_tracking_count": _cnt(ReturnTracking, ReturnTracking.device_sn),
"refund_record_report_count": _cnt(RefundRecordReport, RefundRecordReport.device_sn),
"refund_record_report_count": _cnt_std("std_refund_record_all"),
"flow_month_count": _cnt(DeviceFlowUsage, DeviceFlowUsage.device_sn),
"flow_total_gb": round(total_flow, 4),
}

View File

@@ -20,15 +20,13 @@ import logging
import re
import time
from sqlalchemy import func, select
from sqlalchemy import func, select, text
from sqlalchemy.orm import Session
from app.models.merchant import Merchant
from app.models.merchant_refund import MerchantRefund
from app.models.refund import Refund
from app.models.ticket import ServiceTicket
from app.models.upload import DataMatchRelation
from app.models.wallet import WalletWithdrawal
logger = logging.getLogger(__name__)
@@ -101,7 +99,10 @@ def _preload_existing_relations(db: Session, src_table: str, dst_table: str, mat
def _match_refund_merchant_refund(db: Session, only_unmatched: bool) -> tuple[int, set[str]]:
"""退费单.platform_order_no == 商户退款.merchant_order_no"""
refunds = db.scalars(select(Refund).where(Refund.platform_order_no.isnot(None))).all()
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
refunds = db.execute(text(
"SELECT refund_no, platform_order_no FROM std_refund_online_all WHERE platform_order_no IS NOT NULL"
)).all()
mr_map = {}
for mr in db.scalars(select(MerchantRefund).where(MerchantRefund.merchant_order_no.isnot(None))).all():
key = (mr.merchant_order_no or "").strip()
@@ -133,7 +134,10 @@ def _match_refund_merchant_refund(db: Session, only_unmatched: bool) -> tuple[in
def _match_refund_ticket_by_order(db: Session, only_unmatched: bool) -> tuple[int, set[str]]:
"""退费单.platform_order_no == 工单.order_no"""
refunds = db.scalars(select(Refund).where(Refund.platform_order_no.isnot(None))).all()
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
refunds = db.execute(text(
"SELECT refund_no, platform_order_no FROM std_refund_online_all WHERE platform_order_no IS NOT NULL"
)).all()
ticket_map = {}
for t in db.scalars(select(ServiceTicket).where(ServiceTicket.order_no.isnot(None))).all():
key = (t.order_no or "").strip()
@@ -165,7 +169,10 @@ def _match_refund_ticket_by_order(db: Session, only_unmatched: bool) -> tuple[in
def _match_refund_ticket_by_sn(db: Session, only_unmatched: bool) -> tuple[int, set[str]]:
"""退费单.device_sn == 工单.device_sn"""
refunds = db.scalars(select(Refund).where(Refund.device_sn.isnot(None))).all()
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
refunds = db.execute(text(
"SELECT refund_no, device_sn FROM std_refund_online_all WHERE device_sn IS NOT NULL"
)).all()
ticket_sn_map = {}
for t in db.scalars(select(ServiceTicket).where(ServiceTicket.device_sn.isnot(None))).all():
ticket_sn_map.setdefault(t.device_sn, []).append(t.ticket_no)
@@ -202,7 +209,10 @@ def _match_refund_ticket_by_sn(db: Session, only_unmatched: bool) -> tuple[int,
def _match_refund_wallet_by_remark(db: Session, only_unmatched: bool) -> tuple[int, set[str]]:
"""钱包提现.remark 含退费单号 RF..."""
wallets = db.scalars(select(WalletWithdrawal).where(WalletWithdrawal.remark.isnot(None))).all()
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
wallets = db.execute(text(
"SELECT id, remark FROM std_wallet_flow_all WHERE remark IS NOT NULL"
)).all()
existing = _preload_existing_relations(db, "refund", "wallet", "refund_no_in_remark") if only_unmatched else set()
@@ -232,9 +242,14 @@ def _match_refund_wallet_by_remark(db: Session, only_unmatched: bool) -> tuple[i
def _match_refund_wallet_by_sn(db: Session, only_unmatched: bool) -> tuple[int, set[str]]:
"""退费单.device_sn == 钱包提现.device_sn"""
refunds = db.scalars(select(Refund).where(Refund.device_sn.isnot(None))).all()
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
refunds = db.execute(text(
"SELECT refund_no, device_sn FROM std_refund_online_all WHERE device_sn IS NOT NULL"
)).all()
wallet_sn_map = {}
for w in db.scalars(select(WalletWithdrawal).where(WalletWithdrawal.device_sn.isnot(None))).all():
for w in db.execute(text(
"SELECT id, device_sn FROM std_wallet_flow_all WHERE device_sn IS NOT NULL"
)).all():
wallet_sn_map.setdefault(w.device_sn, []).append(w.id)
existing = _preload_existing_relations(db, "refund", "wallet", "device_sn") if only_unmatched else set()
@@ -267,7 +282,10 @@ def _match_refund_wallet_by_sn(db: Session, only_unmatched: bool) -> tuple[int,
def _match_refund_merchant(db: Session, only_unmatched: bool) -> tuple[int, set[str]]:
"""退费单.merchant_name == 商户.sub_name"""
refunds = db.scalars(select(Refund).where(Refund.merchant_name.isnot(None))).all()
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
refunds = db.execute(text(
"SELECT refund_no, merchant_name FROM std_refund_online_all WHERE merchant_name IS NOT NULL"
)).all()
merchant_map = {m.sub_name: m.id for m in db.scalars(select(Merchant)).all()}
existing = _preload_existing_relations(db, "refund", "merchant", "merchant_name") if only_unmatched else set()
@@ -296,7 +314,8 @@ def _match_refund_merchant(db: Session, only_unmatched: bool) -> tuple[int, set[
def get_unmatched_summary(db: Session) -> dict[str, int]:
"""获取各表待关联数据统计"""
refund_count = db.scalar(select(func.count()).select_from(Refund)) or 0
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
refund_count = db.scalar(text("SELECT count(*) FROM std_refund_online_all")) or 0
refund_with_mr = db.scalar(
select(func.count(func.distinct(DataMatchRelation.src_id)))
.where(

View File

@@ -20,13 +20,10 @@ import logging
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from sqlalchemy import and_, func, or_, select
from sqlalchemy import and_, func, or_, select, text
from app.models.manual_refund import ManualRefund
from app.models.refund import Refund
from app.models.refund_record_report import RefundRecordReport
from app.models.shanmao_refund import ShanmaoRefund
from app.models.wallet import WalletWithdrawal
from app.services.analytics._core import D
logger = logging.getLogger(__name__)
@@ -304,18 +301,18 @@ def _build_route_maps(db, start, end):
C 路(闪猫 shanmao_refunds): refund_no/device_sn
D 路(手动 manual_refunds): order_no/device_sn+金额
"""
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
# A: 平台订单号 → refund_no(退费单)
a_by_order: dict[str, set[str]] = {}
rows = db.execute(
select(Refund.platform_order_no, Refund.refund_no)
.where(Refund.platform_order_no.is_not(None))
).all()
rows = db.execute(text(
"SELECT platform_order_no, refund_no FROM std_refund_online_all WHERE platform_order_no IS NOT NULL"
)).all()
for order_no, refund_no in rows:
if order_no:
a_by_order.setdefault(str(order_no).strip(), set()).add(str(refund_no))
# A2: refund_no 本身(可能 report.platform_order_no 直接是 RF 退费单号)
a_by_no: set[str] = set()
for (rno,) in db.execute(select(Refund.refund_no)).all():
for (rno,) in db.execute(text("SELECT refund_no FROM std_refund_online_all")).all():
if rno:
a_by_no.add(str(rno).strip())
@@ -323,44 +320,34 @@ def _build_route_maps(db, start, end):
# ⚠️ 2026-09-09 (M-4, A-20260909-001 §2.8.3): C 路 device_sn 覆盖 0%,
# 原 c_sns 匹配实际全漏配 → 改用 order_no(WXALR)为主锚点。
c_order_nos: set[str] = set()
for (ono,) in db.execute(
select(ShanmaoRefund.order_no).where(
ShanmaoRefund.order_no.is_not(None),
ShanmaoRefund.status == "已退款",
)
).all():
for (ono,) in db.execute(text(
"SELECT order_no FROM std_refund_shanmao_all WHERE order_no IS NOT NULL AND status = '已退款'"
)).all():
if ono:
c_order_nos.add(str(ono).strip())
c_sns: set[str] = set()
for (sn,) in db.execute(
select(ShanmaoRefund.device_sn).where(ShanmaoRefund.device_sn.is_not(None))
).all():
for (sn,) in db.execute(text(
"SELECT device_sn FROM std_refund_shanmao_all WHERE device_sn IS NOT NULL"
)).all():
if sn:
c_sns.add(str(sn).strip())
# B: wallet 用户提现 设备+金额集合
b_keys: set[tuple[str, Decimal]] = set()
for sn, amt in db.execute(
select(WalletWithdrawal.device_sn, WalletWithdrawal.op_amount)
.where(
WalletWithdrawal.op_type == "用户提现",
WalletWithdrawal.device_sn.is_not(None),
WalletWithdrawal.op_amount.is_not(None),
)
).all():
for sn, amt in db.execute(text(
"SELECT device_sn, op_amount FROM std_wallet_flow_all "
"WHERE op_type = '用户提现' AND device_sn IS NOT NULL AND op_amount IS NOT NULL"
)).all():
if sn and amt is not None:
b_keys.add((str(sn).strip(), D(amt)))
# D: manual 已退款 设备+金额 + order_no 集合
d_keys: set[tuple[str, Decimal]] = set()
d_order_nos: set[str] = set()
for sn, amt, ono in db.execute(
select(ManualRefund.device_sn, ManualRefund.refund_amount, ManualRefund.order_no)
.where(
ManualRefund.is_refunded == "是",
ManualRefund.refund_amount.is_not(None),
)
).all():
for sn, amt, ono in db.execute(text(
"SELECT device_sn, refund_amount, order_no FROM std_refund_manual_all "
"WHERE is_refunded = '是' AND refund_amount IS NOT NULL"
)).all():
if sn and amt is not None:
d_keys.add((str(sn).strip(), D(amt)))
if ono:
@@ -426,25 +413,25 @@ def water_statement(
# 时间窗归一化: 参数按北京时间传入 → 统一 UTC aware 过滤 (2026-09-05 修复偏 8h)
start = _to_utc_aware(start)
end = _to_utc_aware(end)
base = select(RefundRecordReport).where(
RefundRecordReport.refund_status.in_(_SUCCESS_STATUSES),
RefundRecordReport.refund_time >= start,
RefundRecordReport.refund_time <= end,
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
_win_sql = (
"FROM std_refund_record_all "
"WHERE refund_status IN ('退款成功','成功','SUCCESS') "
"AND refund_time >= :s AND refund_time <= :e"
)
total = db.scalar(select(func.count()).select_from(base.subquery())) or 0
_win_params = {"s": start, "e": end}
total = db.scalar(text(f"SELECT count(*) {_win_sql}"), _win_params) or 0
total_amt = (
db.scalar(select(func.coalesce(func.sum(RefundRecordReport.refund_amount), 0)).where(
RefundRecordReport.refund_status.in_(_SUCCESS_STATUSES),
RefundRecordReport.refund_time >= start,
RefundRecordReport.refund_time <= end,
))
db.scalar(text(f"SELECT COALESCE(SUM(refund_amount), 0) {_win_sql}"), _win_params)
or 0.0
)
maps = _build_route_maps(db, start, end)
# 逐行分类(水单量通常可控; 如需更大可改 SQL 关联, 一期 Python 逐行)
rows = list(db.scalars(base.order_by(RefundRecordReport.refund_time.desc())))
rows = list(db.execute(
text(f"SELECT * {_win_sql} ORDER BY refund_time DESC"), _win_params
).all())
by_route = {"A": 0, "B": 0, "C": 0, "D": 0, "unmatched": 0}
classified = []
for r in rows:

View File

@@ -19,15 +19,11 @@ import logging
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from sqlalchemy import func, select
from sqlalchemy import func, select, text
from app.models.manual_refund import ManualRefund
from app.models.reconcile_check import ReconcileCheck
from app.models.reconcile_match import ReconcileMatch
from app.models.refund import Refund
from app.models.refund_record_report import RefundRecordReport
from app.models.shanmao_refund import ShanmaoRefund
from app.models.wallet import WalletWithdrawal
from app.services.analytics._core import D
# #201 S-P0-1:对账复核 D 路排除口径与经营四路同源(禁止第二份字面量)
@@ -79,12 +75,14 @@ def _e_by_route(db, start: datetime, end: datetime) -> dict:
"""E 水单勾稽归属(A/B/C/D/unmatched) 笔数金额(复用 water_statement 分类逻辑)"""
from app.services.reconcile import _build_route_maps, _classify_report
base_rows = db.scalars(
select(RefundRecordReport).where(
RefundRecordReport.refund_status.in_(_E_ROUTE_SUCCESS_STATUSES),
RefundRecordReport.refund_time >= start,
RefundRecordReport.refund_time <= end,
)
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
base_rows = db.execute(
text(
"SELECT * FROM std_refund_record_all "
"WHERE refund_status IN ('退款成功','成功','SUCCESS') "
"AND refund_time >= :s AND refund_time <= :e"
),
{"s": start, "e": end},
).all()
maps = _build_route_maps(db, start, end)
by_route: dict[str, dict] = {
@@ -107,24 +105,21 @@ def _e_by_route(db, start: datetime, end: datetime) -> dict:
def _dupe_detect(db, start: datetime, end: datetime) -> dict:
"""D 路重复/脏数据检出(辅助复核; 不阻塞)"""
# 同 order_no 多行(窗口内已退款, 按 COALESCE(op_time,register_time) 落窗)
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
rows = db.execute(
select(
ManualRefund.order_no,
func.count().label("n"),
func.count(ManualRefund.device_sn).label("with_sn"),
func.coalesce(func.sum(ManualRefund.refund_amount), 0).label("amt"),
)
.where(
ManualRefund.is_refunded == "是",
ManualRefund.order_no.is_not(None),
ManualRefund.order_no != "",
func.coalesce(ManualRefund.op_time, ManualRefund.register_time).is_not(None),
# 落窗过滤: 与第二段 no_sn_weird 口径一致(均在所选时间窗内), 避免"全域 vs 窗口"口径矛盾 (#21/#22 Reviewer W2)
func.coalesce(ManualRefund.op_time, ManualRefund.register_time) >= start,
func.coalesce(ManualRefund.op_time, ManualRefund.register_time) <= end,
)
.group_by(ManualRefund.order_no)
.having(func.count() > 1)
text("""
SELECT order_no, count(*) AS n, count(device_sn) AS with_sn,
COALESCE(SUM(refund_amount), 0) AS amt
FROM std_refund_manual_all
WHERE is_refunded = '是'
AND order_no IS NOT NULL AND order_no != ''
AND COALESCE(op_time, register_time) IS NOT NULL
AND COALESCE(op_time, register_time) >= :s
AND COALESCE(op_time, register_time) <= :e
GROUP BY order_no
HAVING count(*) > 1
"""),
{"s": start, "e": end},
).all()
dup_amt = Decimal("0")
dup_groups = 0
@@ -132,16 +127,19 @@ def _dupe_detect(db, start: datetime, end: datetime) -> dict:
dup_groups += 1
dup_amt += D(amt) * Decimal(n - 1) / Decimal(n) # 超额占比估算(每单多出 n-1/n)
# 无SN+年份异常残留行(窗口内, 与治理脚本同口径; 行数可控, 逐行判年份可接受)
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
residual_rows = 0
residual_amt = Decimal("0")
for m in db.scalars(
select(ManualRefund).where(
ManualRefund.is_refunded == "是",
(ManualRefund.device_sn.is_(None)) | (ManualRefund.device_sn == ""),
func.coalesce(ManualRefund.op_time, ManualRefund.register_time).is_not(None),
func.coalesce(ManualRefund.op_time, ManualRefund.register_time) >= start,
func.coalesce(ManualRefund.op_time, ManualRefund.register_time) <= end,
)
for m in db.execute(
text("""
SELECT * FROM std_refund_manual_all
WHERE is_refunded = '是'
AND (device_sn IS NULL OR device_sn = '')
AND COALESCE(op_time, register_time) IS NOT NULL
AND COALESCE(op_time, register_time) >= :s
AND COALESCE(op_time, register_time) <= :e
"""),
{"s": start, "e": end},
).all():
if m.register_time is None:
continue
@@ -192,7 +190,6 @@ def per_route_alignment(db, start: datetime, end: datetime) -> dict:
⚠️ SQL 全部参数化(:s/:e 绑定), 不拼接用户/时间输入(AGENTS 铁律 2)。
"""
from sqlalchemy import text
start_u, end_u = _to_utc_aware(start), _to_utc_aware(end)
e_ok = "r.refund_status IN ('退款成功','成功','SUCCESS')"
@@ -204,54 +201,58 @@ def per_route_alignment(db, start: datetime, end: datetime) -> dict:
return int(db.execute(text(sql_tpl), params).scalar() or 0)
# ── A 线上直退(仅 refund_method='线上'; 窗口按实际扣款/最新更新, 非登记日) ──
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
a_total = _count("""
SELECT count(*) FROM refunds a
SELECT count(*) FROM std_refund_online_all a
WHERE a.status='已退款' AND a.refund_method='线上'
AND COALESCE(a.actual_refund_time, a.updated_at_raw, a.created_at_raw) >= :s
AND COALESCE(a.actual_refund_time, a.updated_at_raw, a.created_at_raw) <= :e""")
a_in_e = _count(f"""
SELECT count(*) FROM refunds a
SELECT count(*) FROM std_refund_online_all a
WHERE a.status='已退款' AND a.refund_method='线上'
AND COALESCE(a.actual_refund_time, a.updated_at_raw, a.created_at_raw) >= :s
AND COALESCE(a.actual_refund_time, a.updated_at_raw, a.created_at_raw) <= :e
AND EXISTS (SELECT 1 FROM refund_record_reports r
AND EXISTS (SELECT 1 FROM std_refund_record_all r
WHERE r.platform_order_no = a.platform_order_no AND {e_ok})""")
# ── B 用户提现(链路: remark 退费单号 → A转入单 refund_no → platform_order_no → E) ──
# ⚠️ 2026-09-09 修复: 退费单号前缀含 RF/RE/RB, 原只认 RF 漏 RE(用户案例 99元) → 扩 R[FEB]
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
b_total = _count("""
SELECT count(*) FROM wallet_withdrawals w
SELECT count(*) FROM std_wallet_flow_all w
WHERE w.op_type='用户提现' AND w.op_time >= :s AND w.op_time <= :e
AND (w.remark IS NULL OR NOT (
w.remark LIKE '%冻结%' OR w.remark LIKE '%提现失败%' OR w.remark LIKE '%退款失败%'
OR w.remark LIKE '%驳回%' OR w.remark LIKE '%拒绝%' OR w.remark LIKE '%未到账%'
OR w.remark LIKE '%超期订单不允许退款%'))""")
b_chain_ok = _count(f"""
SELECT count(*) FROM wallet_withdrawals w
SELECT count(*) FROM std_wallet_flow_all w
WHERE w.op_type='用户提现' AND w.op_time >= :s AND w.op_time <= :e
AND w.remark ~ 'R[FEB][0-9]+'
AND EXISTS (SELECT 1 FROM refunds a
JOIN refund_record_reports r ON r.platform_order_no = a.platform_order_no
AND EXISTS (SELECT 1 FROM std_refund_online_all a
JOIN std_refund_record_all r ON r.platform_order_no = a.platform_order_no
WHERE a.refund_no = substring(w.remark from 'R[FEB][0-9]+')
AND a.refund_method='转入提现账户' AND a.status='已退款' AND {e_ok})""")
# ── C 闪猫已退款(order_no(WXALR) → E) ──
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
c_total = _count("""
SELECT count(*) FROM shanmao_refunds c
SELECT count(*) FROM std_refund_shanmao_all c
WHERE c.status='已退款'
AND COALESCE(c.audit_time, c.created_at) >= :s AND COALESCE(c.audit_time, c.created_at) <= :e""")
c_in_e = _count(f"""
SELECT count(*) FROM shanmao_refunds c
SELECT count(*) FROM std_refund_shanmao_all c
WHERE c.status='已退款'
AND COALESCE(c.audit_time, c.created_at) >= :s AND COALESCE(c.audit_time, c.created_at) <= :e
AND c.order_no IS NOT NULL
AND EXISTS (SELECT 1 FROM refund_record_reports r
AND EXISTS (SELECT 1 FROM std_refund_record_all r
WHERE r.platform_order_no = c.order_no AND {e_ok})""")
# ── D 手动已退款(剔 dup/ignored; order_no → E) ──
# #201 S-P0-1:d_base 被 d_total/d_in_e/d_overlap_a 复用,排除片段走 canonical
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
d_base = f"""
SELECT count(*) FROM manual_refunds m
SELECT count(*) FROM std_refund_manual_all m
WHERE m.is_refunded='是'
AND {stats_quality_excluded_sql('m.quality_status')}
AND COALESCE(m.op_time, m.register_time) >= :s AND COALESCE(m.op_time, m.register_time) <= :e
@@ -259,13 +260,13 @@ def per_route_alignment(db, start: datetime, end: datetime) -> dict:
d_total = _count(d_base)
d_in_e = _count(d_base + f"""
AND m.order_no IS NOT NULL
AND EXISTS (SELECT 1 FROM refund_record_reports r
AND EXISTS (SELECT 1 FROM std_refund_record_all r
WHERE r.platform_order_no = m.order_no AND {e_ok})""")
# D 中与 A线上已退款重叠(order_no 也记在 A) = A∩D 归属歧义(单列, 不并入 D 差异)
d_overlap_a = _count(d_base + """
AND m.order_no IS NOT NULL
AND EXISTS (SELECT 1 FROM refunds a
AND EXISTS (SELECT 1 FROM std_refund_online_all a
WHERE a.platform_order_no = m.order_no AND a.status='已退款' AND a.refund_method='线上')""")
# ── issues: E 空悬(窗口内 E 单无任何路承接) ──
@@ -273,15 +274,16 @@ def per_route_alignment(db, start: datetime, end: datetime) -> dict:
# - WXALR 前缀 → C 路(闪猫)采集缺/漏记
# - 近2天 → E 采集延迟, 待复查
# - 历史非WXALR → 疑 D 路(手动)少登记(用户: ABC 无空悬且非近2天 → 大概率 D 漏登)
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
e_total_win = _count(f"""
SELECT count(*) FROM refund_record_reports r
SELECT count(*) FROM std_refund_record_all r
WHERE {e_ok} AND r.refund_time >= :s AND r.refund_time <= :e""")
_orphan_where = """
AND NOT EXISTS (SELECT 1 FROM refunds a WHERE a.platform_order_no = r.platform_order_no AND a.status='已退款')
AND NOT EXISTS (SELECT 1 FROM manual_refunds m WHERE m.order_no = r.platform_order_no AND m.is_refunded='是')
AND NOT EXISTS (SELECT 1 FROM shanmao_refunds c WHERE c.order_no = r.platform_order_no AND c.status='已退款')"""
AND NOT EXISTS (SELECT 1 FROM std_refund_online_all a WHERE a.platform_order_no = r.platform_order_no AND a.status='已退款')
AND NOT EXISTS (SELECT 1 FROM std_refund_manual_all m WHERE m.order_no = r.platform_order_no AND m.is_refunded='是')
AND NOT EXISTS (SELECT 1 FROM std_refund_shanmao_all c WHERE c.order_no = r.platform_order_no AND c.status='已退款')"""
_orphan_base = f"""
SELECT count(*) FROM refund_record_reports r
SELECT count(*) FROM std_refund_record_all r
WHERE {e_ok} AND r.refund_time >= :s AND r.refund_time <= :e""" + _orphan_where
e_orphan = _count(_orphan_base)
e_orphan_wxalr = _count(_orphan_base + " AND r.platform_order_no LIKE 'WXALR%'")
@@ -327,37 +329,37 @@ def alignment_detail(db, route: str, start: datetime, end: datetime,
- orphan: E 成功水单 窗内, 无任何路承接
匹配 E 不限窗。返回分页明细(供前端下钻展示)。
"""
from sqlalchemy import text
start_u, end_u = _to_utc_aware(start), _to_utc_aware(end)
e_ok = "r.refund_status IN ('退款成功','成功','SUCCESS')"
params = {"s": start_u, "e": end_u, "lim": page_size, "off": (page - 1) * page_size}
route = (route or "").strip().upper()
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
if route == "A":
sql = f"""
SELECT a.refund_no AS biz_no, a.platform_order_no AS order_no, a.device_sn,
a.refund_amount AS amount,
COALESCE(a.actual_refund_time, a.updated_at_raw, a.created_at_raw) AS occur_time,
a.operator AS who
FROM refunds a
FROM std_refund_online_all a
WHERE a.status='已退款' AND a.refund_method='线上'
AND COALESCE(a.actual_refund_time, a.updated_at_raw, a.created_at_raw) >= :s
AND COALESCE(a.actual_refund_time, a.updated_at_raw, a.created_at_raw) <= :e
AND NOT EXISTS (SELECT 1 FROM refund_record_reports r WHERE r.platform_order_no=a.platform_order_no AND {e_ok})
AND NOT EXISTS (SELECT 1 FROM std_refund_record_all r WHERE r.platform_order_no=a.platform_order_no AND {e_ok})
ORDER BY COALESCE(a.actual_refund_time, a.updated_at_raw, a.created_at_raw) DESC LIMIT :lim OFFSET :off"""
elif route == "B":
sql = """
SELECT w.id AS biz_no, w.remark AS order_no, w.device_sn,
w.op_amount AS amount, w.op_time AS occur_time, w.operator AS who
FROM wallet_withdrawals w
FROM std_wallet_flow_all w
WHERE w.op_type='用户提现' AND w.op_time >= :s AND w.op_time <= :e
AND (w.remark IS NULL OR NOT (
w.remark LIKE '%冻结%' OR w.remark LIKE '%提现失败%' OR w.remark LIKE '%退款失败%'
OR w.remark LIKE '%驳回%' OR w.remark LIKE '%拒绝%' OR w.remark LIKE '%未到账%'
OR w.remark LIKE '%超期订单不允许退款%'))
AND NOT (w.remark ~ 'R[FEB][0-9]+' AND EXISTS (
SELECT 1 FROM refunds a JOIN refund_record_reports r ON r.platform_order_no=a.platform_order_no
SELECT 1 FROM std_refund_online_all a JOIN std_refund_record_all r ON r.platform_order_no=a.platform_order_no
WHERE a.refund_no=substring(w.remark from 'R[FEB][0-9]+')
AND a.refund_method='转入提现账户' AND a.status='已退款' AND """ + e_ok + """))
ORDER BY w.op_time DESC LIMIT :lim OFFSET :off"""
@@ -365,31 +367,31 @@ def alignment_detail(db, route: str, start: datetime, end: datetime,
sql = f"""
SELECT c.refund_no AS biz_no, c.order_no AS order_no, c.device_sn,
c.actual_amount AS amount, COALESCE(c.audit_time, c.created_at) AS occur_time, c.operator AS who
FROM shanmao_refunds c
FROM std_refund_shanmao_all c
WHERE c.status='已退款'
AND COALESCE(c.audit_time, c.created_at) >= :s AND COALESCE(c.audit_time, c.created_at) <= :e
AND NOT EXISTS (SELECT 1 FROM refund_record_reports r WHERE r.platform_order_no=c.order_no AND {e_ok})
AND NOT EXISTS (SELECT 1 FROM std_refund_record_all r WHERE r.platform_order_no=c.order_no AND {e_ok})
ORDER BY COALESCE(c.audit_time, c.created_at) DESC LIMIT :lim OFFSET :off"""
elif route == "D":
sql = f"""
SELECT m.id AS biz_no, m.order_no AS order_no, m.device_sn,
m.refund_amount AS amount, COALESCE(m.op_time, m.register_time) AS occur_time,
COALESCE(m.refund_operator, m.registrant) AS who, m.quality_status AS q
FROM manual_refunds m
FROM std_refund_manual_all m
WHERE m.is_refunded='是'
AND {stats_quality_excluded_sql('m.quality_status')}
AND COALESCE(m.op_time, m.register_time) >= :s AND COALESCE(m.op_time, m.register_time) <= :e
AND NOT EXISTS (SELECT 1 FROM refund_record_reports r WHERE r.platform_order_no=m.order_no AND {e_ok})
AND NOT EXISTS (SELECT 1 FROM std_refund_record_all r WHERE r.platform_order_no=m.order_no AND {e_ok})
ORDER BY COALESCE(m.op_time, m.register_time) DESC LIMIT :lim OFFSET :off"""
elif route == "ORPHAN":
sql = f"""
SELECT r.id AS biz_no, r.platform_order_no AS order_no, r.device_sn,
r.refund_amount AS amount, r.refund_time AS occur_time, r.merchant_name AS who
FROM refund_record_reports r
FROM std_refund_record_all r
WHERE {e_ok} AND r.refund_time >= :s AND r.refund_time <= :e
AND NOT EXISTS (SELECT 1 FROM refunds a WHERE a.platform_order_no=r.platform_order_no AND a.status='已退款')
AND NOT EXISTS (SELECT 1 FROM manual_refunds m WHERE m.order_no=r.platform_order_no AND m.is_refunded='是')
AND NOT EXISTS (SELECT 1 FROM shanmao_refunds c WHERE c.order_no=r.platform_order_no AND c.status='已退款')
AND NOT EXISTS (SELECT 1 FROM std_refund_online_all a WHERE a.platform_order_no=r.platform_order_no AND a.status='已退款')
AND NOT EXISTS (SELECT 1 FROM std_refund_manual_all m WHERE m.order_no=r.platform_order_no AND m.is_refunded='是')
AND NOT EXISTS (SELECT 1 FROM std_refund_shanmao_all c WHERE c.order_no=r.platform_order_no AND c.status='已退款')
ORDER BY r.refund_time DESC LIMIT :lim OFFSET :off"""
else:
raise ValueError(f"route 非法: {route}, 应为 A/B/C/D/orphan")
@@ -436,10 +438,10 @@ def build_check_payload(db, start: datetime, end: datetime, grain: str) -> dict:
def _e_data_until(db) -> str | None:
"""E 路(refund_record_reports)退款成功数据最新日期(北京)。"""
from sqlalchemy import text
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
e_max = db.execute(text(
"SELECT max(refund_time) FROM refund_record_reports "
"SELECT max(refund_time) FROM std_refund_record_all "
"WHERE refund_status IN ('退款成功', '成功', 'SUCCESS')"
)).scalar()
if e_max is None:
@@ -591,12 +593,13 @@ def _route_candidates(db, report: RefundRecordReport) -> dict:
auto_route = _classify_report(report, maps)
candidates: list[dict] = []
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
# A 路: platform_order_no 命中退费单
if order_no:
a_rows = db.execute(
select(Refund.refund_no, Refund.refund_amount, Refund.device_sn)
.where(Refund.platform_order_no == order_no)
.limit(10)
text("SELECT refund_no, refund_amount, device_sn FROM std_refund_online_all "
"WHERE platform_order_no = :ono LIMIT 10"),
{"ono": order_no},
).all()
for rno, ramt, rsn in a_rows:
candidates.append({
@@ -606,10 +609,9 @@ def _route_candidates(db, report: RefundRecordReport) -> dict:
# D 路: order_no 命中手动台账
if order_no:
d_rows = db.execute(
select(ManualRefund.id, ManualRefund.refund_amount, ManualRefund.device_sn,
ManualRefund.registrant)
.where(ManualRefund.order_no == order_no, ManualRefund.is_refunded == "是")
.limit(10)
text("SELECT id, refund_amount, device_sn, registrant FROM std_refund_manual_all "
"WHERE order_no = :ono AND is_refunded = '是' LIMIT 10"),
{"ono": order_no},
).all()
for mid, mamt, msn, mreg in d_rows:
candidates.append({
@@ -620,9 +622,9 @@ def _route_candidates(db, report: RefundRecordReport) -> dict:
# B 路: 设备+金额 命中钱包提现
if sn and amt is not None:
b_rows = db.execute(
select(WalletWithdrawal.id, WalletWithdrawal.op_amount, WalletWithdrawal.remark)
.where(WalletWithdrawal.device_sn == sn, WalletWithdrawal.op_type == "用户提现")
.limit(10)
text("SELECT id, op_amount, remark FROM std_wallet_flow_all "
"WHERE device_sn = :sn AND op_type = '用户提现' LIMIT 10"),
{"sn": sn},
).all()
for wid, wamt, wremark in b_rows:
if wamt is not None and abs(D(wamt) - amt) <= Decimal("0.01"):
@@ -634,9 +636,9 @@ def _route_candidates(db, report: RefundRecordReport) -> dict:
# ⚠️ 2026-09-09 (M-4): C 路 device_sn 覆盖 0%, 原仅 SN 匹配全漏配 → 优先 order_no(WXALR)
if order_no:
c_rows = db.execute(
select(ShanmaoRefund.refund_no, ShanmaoRefund.actual_amount, ShanmaoRefund.device_sn)
.where(ShanmaoRefund.order_no == order_no, ShanmaoRefund.status == "已退款")
.limit(10)
text("SELECT refund_no, actual_amount, device_sn FROM std_refund_shanmao_all "
"WHERE order_no = :ono AND status = '已退款' LIMIT 10"),
{"ono": order_no},
).all()
for cno, camt, csn in c_rows:
candidates.append({
@@ -645,9 +647,9 @@ def _route_candidates(db, report: RefundRecordReport) -> dict:
})
if sn:
c_rows = db.execute(
select(ShanmaoRefund.refund_no, ShanmaoRefund.actual_amount)
.where(ShanmaoRefund.device_sn == sn, ShanmaoRefund.status == "已退款")
.limit(10)
text("SELECT refund_no, actual_amount FROM std_refund_shanmao_all "
"WHERE device_sn = :sn AND status = '已退款' LIMIT 10"),
{"sn": sn},
).all()
for cno, camt in c_rows:
candidates.append({
@@ -736,7 +738,10 @@ def confirm_line(db, user, check_id: int, report_id: int, confirm_status: str,
check = db.get(ReconcileCheck, check_id)
if not check:
raise ValueError("复核批次不存在")
report = db.get(RefundRecordReport, report_id)
# #334: 读 std_*_all 全量镜像(ods 同构透传), 业务代码零读 ods
report = db.execute(
text("SELECT * FROM std_refund_record_all WHERE id = :rid"), {"rid": report_id}
).first()
if not report:
raise ValueError("E 退款记录报表不存在")

View File

@@ -214,6 +214,17 @@ def _engine():
conn.execute(text(f'CREATE VIEW public."{_view}" AS SELECT {_cols} FROM {_src}'))
conn.commit()
# #334: std_*_all 全量镜像视图(over ods 五路, 含采纳四列)。
# 全状态语义读点已迁这些视图; 测试库不跑迁移, 在此显式建视图,
# 与迁移 b443c1a2d3e4 共用单一来源 STD_ALL_VIEWS(SELECT * 透传)。
from app.models.refund_standard import STD_ALL_VIEWS
with engine.connect() as conn:
for _view, _src in STD_ALL_VIEWS.items():
conn.execute(text(f'DROP VIEW IF EXISTS public."{_view}" CASCADE'))
conn.execute(text(f'CREATE VIEW public."{_view}" AS SELECT * FROM {_src}'))
conn.commit()
# ⚠️ 2026-09-07 修复: create_all 建表后 stamp alembic 到 head。
# 否则 app lifespan(_run_migrations_on_startup) 在 TestClient 启动时会再次
# alembic upgrade head, 对全新测试库(无 alembic_version)从 d9b7f1 重跑全部

View File

@@ -21,15 +21,22 @@ ods.* 信源原样同步(upsert 幂等),追加 adopt_state 标识
├─ 幂等采纳作业(规则版本化,可全量重算)
▼
public.std_* 五路标准物理表(analytics 唯一允许的统计取数源)
public.std_*_all 五路全量镜像视图(#334,SELECT * 透传 ods 含采纳四列;
全状态语义场景唯一允许的全量取数源,业务代码禁直读 ods)
public.dim_* 企业商户维表(E 路沉淀,供各路 JOIN 新增维度)
│
▼
services/analytics 只读 std_* / dim_*(沿用 #322 的 C2 契约,禁直读 ods)
services/analytics 只读 std_* / std_*_all / dim_*(沿用 #322 的 C2 契约,禁直读 ods)
```
> #322 的 `public.*_clean` 薄视图**保留**,作为「同步原样 → 标准表」之间的对账中间层,
> 但其"统计取数源"身份由本标准定义的 `std_*` 表接替。
> **#334 分层补全**:全状态语义场景(退款挽回漏斗、状态分布、工作量统计、导出原始明细、
> 链路台账、设备档案、对账勾稽匹配)一律读 `std_*_all` 镜像视图,不再直读 ods。
> 此后 ods 仅剩三个本职消费:采集写入、采纳作业读取、质量治理标记。
> 读 `std_*_all` 的代码须在注释声明全状态理由(防被误改为 std_* 采纳集)。
---
## 一、采纳标识(三态,非布尔)