fix(risk): RW-018 研判的两个口径缺陷——列表级不再无条件放行,详情级与扫描侧对齐

docs/25 P2。核实后发现这两条其实是**同一处的两个表现**,所以一起修。

**① 列表级无条件放行**
_assess_list_rule 收到 item 参数**却完全不看它**,对 RW-018 一律返回"可考虑放行",
连理由文本都是硬编码的"现有摘要显示交易来自有效定投工单"。于是渠道不匹配、或证据里
根本没有工单信息的预警,在列表层就被标成可放行 —— 而列表正是风控专员最先看到的一屏
(详情层另有判断,但那要等人点进去)。

现在按 item["evidence_snapshot"]["channel"] 判断。快照里确实有渠道:扫描侧写入了它
(risk_scan_service.py:308),列表行也带出了整个 snapshot(risk_repository.py:955),
所以这个校验是可行的,此前只是没做。

**② 详情级与扫描侧口径不一致**
扫描按 work_order.channel in {"定投", "自动定投"} 生成预警(risk_scan_service.py:294),
详情级却只认 "定投"。于是"自动定投"的预警会出现**"扫描认为有效、详情认为未确认"**的
自相矛盾。

抽出 DIRECT_INVESTMENT_CHANNELS = frozenset({"定投", "自动定投"}) 作为唯一口径,
列表级与详情级都改用它;改这份常量即同时影响两侧。

新增 tests/unit/service/test_risk_judgement_rw018.py(6 条):常量覆盖扫描侧全部渠道、
列表级在无渠道 / 渠道不符时**不得**放行、两个合法渠道在列表级与详情级都放行、
详情级对无关渠道不放行。

ruff / mypy(136 文件) / 629 unit+contract 全绿。
This commit is contained in:
2026-09-11 13:45:51 +08:00
parent d4e7e2672c
commit 85a1b33eff
2 changed files with 92 additions and 5 deletions
@@ -0,0 +1,65 @@
"""RW-018 研判的两个口径缺陷(docs/25 P2)。
1. **列表级无条件放行**:`_assess_list_rule` 收到 `item` 却完全不看它,对 RW-018
一律返回"可考虑放行",连理由文本都是硬编码的"现有摘要显示交易来自有效定投工单"。
于是渠道不匹配、或证据里根本没有工单信息的预警,在列表层就被标成可放行 ——
而列表正是风控专员最先看到的一屏。详情层另有判断,但那要等人点进去。
2. **详情级与扫描侧口径不一致**:扫描按 `work_order.channel in {"定投", "自动定投"}`
生成 RW-018 预警(`risk_scan_service.py:294`),详情级却只认 `"定投"`。于是
"自动定投"的预警会出现"扫描认为有效、详情认为未确认"的自相矛盾。
两条现在共用 `DIRECT_INVESTMENT_CHANNELS`,改这份常量即同时影响两侧。
"""
from typing import Any
from app.service.risk_judgement_service import (
DIRECT_INVESTMENT_CHANNELS,
VERDICT_RELEASE,
_assess_list_rule,
_assess_rw018,
)
def _item(**snapshot: Any) -> dict[str, Any]:
return {"alert_no": "ALTEST", "evidence_snapshot": snapshot}
def test_channels_cover_what_the_scanner_accepts() -> None:
"""常量必须覆盖扫描侧接受的全部渠道,否则又会两侧打架。"""
assert DIRECT_INVESTMENT_CHANNELS == {"定投", "自动定投"}
def test_list_level_does_not_release_without_evidence() -> None:
"""证据里没有渠道信息时不能放行 —— 这正是原先无条件放行的地方。"""
result = _assess_list_rule("RW-018", _item())
assert VERDICT_RELEASE not in str(result)
def test_list_level_does_not_release_for_an_unrelated_channel() -> None:
result = _assess_list_rule("RW-018", _item(channel="柜台"))
assert VERDICT_RELEASE not in str(result)
def test_list_level_releases_for_every_direct_investment_channel() -> None:
for channel in sorted(DIRECT_INVESTMENT_CHANNELS):
result = _assess_list_rule("RW-018", _item(channel=channel))
assert VERDICT_RELEASE in str(result), f"{channel} 应当判定为可放行"
def test_detail_level_accepts_the_same_channels_as_the_scanner() -> None:
"""详情级与扫描侧同口径 —— 原先详情只认"定投","自动定投"会被判成未确认。"""
for channel in sorted(DIRECT_INVESTMENT_CHANNELS):
result = _assess_rw018({"work_order": {"channel": channel}})
assert VERDICT_RELEASE in str(result), f"{channel} 在详情级也应判为可放行"
def test_detail_level_rejects_a_channel_the_scanner_would_not_use() -> None:
result = _assess_rw018({"work_order": {"channel": "柜台"}})
assert VERDICT_RELEASE not in str(result)