diff --git a/docs/superpowers/plans/2026-08-04-admin-comparison-outcome-display.md b/docs/superpowers/plans/2026-08-04-admin-comparison-outcome-display.md new file mode 100644 index 0000000..543491d --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-admin-comparison-outcome-display.md @@ -0,0 +1,1033 @@ +# admin 比价记录「技术成功/失败」口径与缺失提示 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 比价记录页把「流程跑完、外部原因致结果缺失」的 6 类记录从「失败」改判为「成功 ⚠」(hover 看缺失原因),让「失败」只剩技术故障;admin 后台内部(列表 + 概览 + 大盘)口径统一。 + +**Architecture:** 方案 A——纯 admin 层派生,不动 DB schema、不迁移、不碰 #209 落库与 C 端。原始业务结局取自 `raw_payload.record_status`(coalesce 兜底 `raw.status` / `status` 列)。新建共享模块 `comparison_outcome.py` 提供 Python 派生(列表用)+ SQL 判定(概览/大盘用),供 `queries.py` 与 `stats.py` 复用。 + +**Tech Stack:** 后端 FastAPI + SQLAlchemy + pytest(SQLite 测试库);前端 Next.js 15 + Ant Design 5 + TypeScript。 + +**仓库与分支:** +- 后端改动在 `shaguabijia-app-server`——先建分支:`git checkout -b feat/admin-comparison-outcome-display origin/main`(app-server 目录)。 +- 前端改动在 `shaguabijia-admin-web`,分支 `feat/admin-comparison-outcome-display`(已存在,spec/plan 在此)。 +- 后端 pytest 命令统一用仓库自带 venv:`./.venv/Scripts/python.exe -m pytest ...`(在 app-server 目录执行)。 + +--- + +## Task 1: 共享口径模块 `comparison_outcome.py`(后端) + +**Files:** +- Create: `app/admin/repositories/comparison_outcome.py` +- Test: `tests/test_admin_comparison_outcome.py` + +- [ ] **Step 1: 写失败测试** + +Create `tests/test_admin_comparison_outcome.py`: + +```python +"""admin 展示口径派生单测:记录级原始结局 → (admin_status, outcome_hint)。""" +from __future__ import annotations + +import pytest + +from app.admin.repositories.comparison_outcome import derive_admin_outcome + + +@pytest.mark.parametrize( + ("raw_payload", "status", "expected"), + [ + # 真实形态:status 已 normalize,细分在 raw_payload.record_status + ({"record_status": "success"}, "success", ("success", None)), + ({"record_status": "below_minimum"}, "success", ("success", "未满起送")), + ({"record_status": "store_closed"}, "failed", ("success", "门店打烊")), + ({"record_status": "store_not_found"}, "failed", ("success", "未找到店")), + ({"record_status": "items_not_found"}, "failed", ("success", "未找到菜")), + ({"record_status": "no_delivery"}, "failed", ("success", "单点不配送")), + ({"record_status": "unsupported"}, "failed", ("success", "平台·场景不支持")), + ({"record_status": "failed"}, "failed", ("failed", None)), # 纯技术故障 + # POST 路径:细分在 raw_payload.status + ({"status": "store_not_found"}, "failed", ("success", "未找到店")), + # 兜底 status 列:raw_payload 缺失(极老记录)或残留细分值 + (None, "success", ("success", None)), + (None, "failed", ("failed", None)), + (None, "store_closed", ("success", "门店打烊")), # 迁移未覆盖的残留 + # 生命周期态优先,不看结局 + ({}, "cancelled", ("cancelled", None)), + ({"record_status": "success"}, "running", ("running", None)), + ], +) +def test_derive_admin_outcome(raw_payload, status, expected): + assert derive_admin_outcome(raw_payload, status) == expected +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd e:/project/shaguabijia-app-server && ./.venv/Scripts/python.exe -m pytest tests/test_admin_comparison_outcome.py -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.admin.repositories.comparison_outcome'` + +- [ ] **Step 3: 写模块实现** + +Create `app/admin/repositories/comparison_outcome.py`: + +```python +"""admin 比价记录展示口径:把「流程跑完、外部原因致结果缺失」的记录判为成功。 + +#209 / C 端落库把 store_not_found / items_not_found / store_closed / no_delivery / +unsupported 归一化成 status='failed';admin 排查视角改按记录级原始业务结局 +(raw_payload.record_status)重判——这些「外部缺失」算成功(附缺失提示), +只有纯技术故障才是失败。仅 admin 用,不碰 C 端 / #209 落库。 +""" +from __future__ import annotations + +from sqlalchemy import func + +from app.models.comparison import ComparisonRecord + +# 记录级原始业务结局里算「成功(流程跑完)」的集合;其余(failed / 未知)才是技术故障。 +ADMIN_SUCCESS_OUTCOMES = frozenset({ + "success", "below_minimum", "store_closed", + "store_not_found", "items_not_found", "no_delivery", "unsupported", +}) +# 有缺失的成功 → 感叹号 hover 提示;success 本身无提示。 +OUTCOME_HINTS = { + "below_minimum": "未满起送", + "store_closed": "门店打烊", + "store_not_found": "未找到店", + "items_not_found": "未找到菜", + "no_delivery": "单点不配送", + "unsupported": "平台·场景不支持", +} + + +def derive_admin_outcome(raw_payload: dict | None, status: str) -> tuple[str, str | None]: + """(admin_status, outcome_hint)。列表 Python 层派生(raw_payload 已随 ORM 加载)。""" + if status in ("cancelled", "running"): + return status, None + raw = raw_payload or {} + # 原始结局:优先 raw.record_status,其次 raw.status,兜底 status 列 + # (兼容迁移未覆盖、细分值残留在 status 列的老记录;与下方 SQL 口径一致)。 + original = raw.get("record_status") or raw.get("status") or status + if original in ADMIN_SUCCESS_OUTCOMES: + return "success", OUTCOME_HINTS.get(original) + return "failed", None + + +def _original_expr(): + """SQL:原始结局 = coalesce(raw.record_status, raw.status, status 列)。跨方言(as_string,#209 迁移已验证)。""" + return func.coalesce( + ComparisonRecord.raw_payload["record_status"].as_string(), + ComparisonRecord.raw_payload["status"].as_string(), + ComparisonRecord.status, + ) + + +def admin_success_sql(): + """SQL 层 admin 成功判定(概览 / 大盘的 case / where 共用)。 + + 排除 cancelled / running(生命周期态,不看结局);其余按原始结局 ∈ S。 + coalesce 兜底 status 列 → original 永非 NULL、且兼容 status 列残留的细分值。 + """ + return ComparisonRecord.status.notin_(("cancelled", "running")) & _original_expr().in_( + tuple(ADMIN_SUCCESS_OUTCOMES) + ) +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd e:/project/shaguabijia-app-server && ./.venv/Scripts/python.exe -m pytest tests/test_admin_comparison_outcome.py -q` +Expected: PASS(14 passed) + +- [ ] **Step 5: 提交** + +```bash +cd e:/project/shaguabijia-app-server +git add app/admin/repositories/comparison_outcome.py tests/test_admin_comparison_outcome.py +git commit -m "feat(admin): 比价记录展示口径共享模块(外部缺失判为成功) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: 列表 / 详情下发 `admin_status` + `outcome_hint`(后端) + +**Files:** +- Modify: `app/admin/schemas/comparison.py`(`AdminComparisonListItem` 加两字段) +- Modify: `app/admin/repositories/queries.py`(`list_comparison_records` 尾部瞬态挂载 + `get_comparison_record` 挂载 + import) +- Test: `tests/test_admin_read.py`(新增一个列表用例) + +- [ ] **Step 1: 写失败测试** + +追加到 `tests/test_admin_read.py` 末尾: + +```python +def test_comparison_records_expose_admin_outcome( + admin_client: TestClient, admin_token: str +) -> None: + """列表 / 详情下发 admin_status + outcome_hint:外部缺失=成功⚠,纯故障=失败。""" + db = SessionLocal() + try: + user = user_repo.upsert_user_for_login( + db, phone="13800009051", register_channel="sms" + ) + # 未找到店:status 已 normalize 成 failed,细分在 raw_payload.record_status + db.add(ComparisonRecord( + user_id=user.id, trace_id="admin-outcome-store-not-found", + status="failed", store_name="缺店排查店ZZZ", + raw_payload={"record_status": "store_not_found"}, + )) + # 纯技术故障 + db.add(ComparisonRecord( + user_id=user.id, trace_id="admin-outcome-tech-failed", + status="failed", store_name="缺店排查店ZZZ", + raw_payload={"record_status": "failed"}, + )) + db.commit() + uid = user.id + finally: + db.close() + + resp = admin_client.get( + "/admin/api/comparison-records", + params={"user_id": uid, "store": "缺店排查店ZZZ"}, + headers=_auth(admin_token), + ) + assert resp.status_code == 200, resp.text + by_trace = {it["trace_id"]: it for it in resp.json()["items"]} + + gap = by_trace["admin-outcome-store-not-found"] + assert gap["admin_status"] == "success" + assert gap["outcome_hint"] == "未找到店" + + tech = by_trace["admin-outcome-tech-failed"] + assert tech["admin_status"] == "failed" + assert tech["outcome_hint"] is None +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd e:/project/shaguabijia-app-server && ./.venv/Scripts/python.exe -m pytest tests/test_admin_read.py::test_comparison_records_expose_admin_outcome -q` +Expected: FAIL — `KeyError: 'admin_status'`(schema 尚无此字段) + +- [ ] **Step 3a: schema 加字段** + +`app/admin/schemas/comparison.py`,在 `AdminComparisonListItem` 的 `status` 字段(第 23 行)后插入: + +```python + status: str # success / failed / cancelled / running;旧细分值由前端兼容映射 + # admin 展示口径(见 repositories/comparison_outcome):成功含「跑完但外部缺失」, + # 纯技术故障才 failed;outcome_hint 非空=有缺失,前端标感叹号。 + admin_status: str = "success" + outcome_hint: str | None = None +``` + +- [ ] **Step 3b: queries 挂载** + +`app/admin/repositories/queries.py`,在文件 import 区加: + +```python +from app.admin.repositories.comparison_outcome import derive_admin_outcome +``` + +`list_comparison_records` 尾部循环(现为): + +```python + rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items]) + for it in items: + it.ad_revenue_yuan = rev.get(it.trace_id, 0.0) + return items, next_cursor, total +``` + +改为: + +```python + rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items]) + for it in items: + it.ad_revenue_yuan = rev.get(it.trace_id, 0.0) + it.admin_status, it.outcome_hint = derive_admin_outcome(it.raw_payload, it.status) + return items, next_cursor, total +``` + +`get_comparison_record`(现为): + +```python +def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None: + """admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。""" + rec = db.get(ComparisonRecord, record_id) + if rec is not None: + _attach_user_info(db, [rec]) + _attach_comparison_order_status(db, [rec]) + _attach_comparison_device_details([rec]) + return rec +``` + +改为(挂载 admin 口径): + +```python +def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None: + """admin 取单条比价记录(任意用户,不限本人;附 phone/nickname + admin 口径瞬态)。""" + rec = db.get(ComparisonRecord, record_id) + if rec is not None: + _attach_user_info(db, [rec]) + _attach_comparison_order_status(db, [rec]) + _attach_comparison_device_details([rec]) + rec.admin_status, rec.outcome_hint = derive_admin_outcome(rec.raw_payload, rec.status) + return rec +``` + +> 注:本 Task 只 import `derive_admin_outcome`;`admin_success_sql` 由 Task 3 使用时再补 import(避免 ruff F401 未使用告警)。 + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd e:/project/shaguabijia-app-server && ./.venv/Scripts/python.exe -m pytest tests/test_admin_read.py::test_comparison_records_expose_admin_outcome -q` +Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +cd e:/project/shaguabijia-app-server +git add app/admin/schemas/comparison.py app/admin/repositories/queries.py tests/test_admin_read.py +git commit -m "feat(admin): 比价记录列表/详情下发 admin_status + outcome_hint + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: 概览 `comparison_records_summary` 改 admin 口径(后端) + +**Files:** +- Modify: `app/admin/repositories/queries.py`(`_comparison_duration_aggregate_stmt` / `_comparison_duration_aggregates` 签名 `status`→`status_filter`;`comparison_records_summary` 的 case + 耗时调用) +- Test: `tests/test_comparison_admin_summary.py`(更新两个既有测试为真实形态数据 + admin 口径断言) + +- [ ] **Step 1: 更新测试(写新断言,先失败)** + +整体替换 `tests/test_comparison_admin_summary.py` 内容为: + +```python +"""比价记录页后端概览聚合(admin 口径:外部缺失记为成功)。""" +from __future__ import annotations + +from datetime import UTC, date, datetime + +import pytest +from sqlalchemy.dialects import postgresql + +from app.admin.repositories import queries +from app.admin.repositories.comparison_outcome import admin_success_sql +from app.db.session import SessionLocal +from app.models.comparison import ComparisonRecord + + +def test_postgresql_duration_summary_uses_ordered_set_aggregates() -> None: + stmt = queries._comparison_duration_aggregate_stmt( + [], admin_success_sql(), (0.05, 0.5, 0.95, 0.99) + ) + sql = str( + stmt.compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ) + assert sql.count("percentile_cont") == 4 + # 口径按原始结局 coalesce,而非直接读 status 列 + assert "coalesce" in sql.lower() + + +def test_summary_counts_external_gaps_as_success() -> None: + db = SessionLocal() + try: + # (trace_id, status 列[已 normalize], record_status[原始结局], total_ms, cost, saved) + rows = [ + ("sum-success", "success", "success", 1000, 1.0, 100), + ("sum-below-min", "success", "below_minimum", 2000, 2.0, 0), + ("sum-store-closed", "failed", "store_closed", 3000, None, 0), + ("sum-store-not-found", "failed", "store_not_found", 4000, None, 0), + ("sum-failed", "failed", "failed", 100_000, 3.0, 0), + ("sum-cancelled", "cancelled", None, 5000, None, 0), + ("sum-running", "running", None, 6000, None, 0), + ] + for trace_id, status, record_status, total_ms, cost, saved in rows: + db.add(ComparisonRecord( + trace_id=trace_id, + status=status, + total_ms=total_ms, + llm_cost_yuan=cost, + saved_amount_cents=saved, + raw_payload={"record_status": record_status} if record_status else None, + created_at=datetime(2038, 1, 15, 12, tzinfo=UTC), + )) + db.flush() + + summary = queries.comparison_records_summary( + db, date_from=date(2038, 1, 15), date_to=date(2038, 1, 15) + ) + + # admin 成功 = success + below_minimum + store_closed + store_not_found = 4 + assert summary["started"] == 7 + assert summary["success"] == 4 + assert summary["completed"] == 5 # 4 成功 + 1 纯 failed + assert summary["cancelled"] == 1 + assert summary["success_rate"] == pytest.approx(4 / 6) # 分母 started - cancelled + assert summary["avg_token_cost"] == pytest.approx(2.0) # (1+2+3)/3 + assert summary["lower_price_rate"] == pytest.approx(1 / 4) # 仅 sum-success saved>0 + # 耗时统计集 = admin 成功的 total_ms [1000,2000,3000,4000] + assert summary["avg_duration_ms"] == 2500 + assert summary["p5_duration_ms"] == 1150 + assert summary["p50_duration_ms"] == 2500 + assert summary["p95_duration_ms"] == 3850 + assert summary["p99_duration_ms"] == 3970 + assert summary["cancelled_p50_ms"] == 5000 + finally: + db.rollback() + db.close() +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd e:/project/shaguabijia-app-server && ./.venv/Scripts/python.exe -m pytest tests/test_comparison_admin_summary.py -q` +Expected: FAIL — `_comparison_duration_aggregate_stmt()` 收到表达式而非 `status` 字符串、且 `comparison_records_summary` 仍按 status 列聚合(success 断言不符)。 + +- [ ] **Step 3a: 耗时聚合改 `status_filter`** + +`app/admin/repositories/queries.py`,`_comparison_duration_aggregate_stmt`(现为): + +```python +def _comparison_duration_aggregate_stmt(conditions: list, status: str, quantiles: tuple[float, ...]): + """PostgreSQL 耗时聚合语句;每种状态只返回一行。""" + return select( + func.avg(ComparisonRecord.total_ms), + *( + func.percentile_cont(q).within_group(ComparisonRecord.total_ms) + for q in quantiles + ), + ).where( + *conditions, + _comparison_status_condition(status), + ComparisonRecord.total_ms.is_not(None), + ) +``` + +改为: + +```python +def _comparison_duration_aggregate_stmt(conditions: list, status_filter, quantiles: tuple[float, ...]): + """PostgreSQL 耗时聚合语句;status_filter 为已构造的口径条件表达式,每口径只返回一行。""" + return select( + func.avg(ComparisonRecord.total_ms), + *( + func.percentile_cont(q).within_group(ComparisonRecord.total_ms) + for q in quantiles + ), + ).where( + *conditions, + status_filter, + ComparisonRecord.total_ms.is_not(None), + ) +``` + +`_comparison_duration_aggregates`(现为): + +```python +def _comparison_duration_aggregates( + db: Session, + *, + conditions: list, + status: str, + quantiles: tuple[float, ...], +) -> list[int | None]: + """返回平均值和各分位数;生产 PG 在数据库内聚合,SQLite 仅作测试回退。""" + if db.bind is not None and db.bind.dialect.name == "postgresql": + row = db.execute( + _comparison_duration_aggregate_stmt(conditions, status, quantiles) + ).one() + return [_round_duration_ms(value) for value in row] + + # SQLite 没有 percentile_cont;本地/测试只回退读取耗时单列,不加载完整记录。 + values = list( + db.execute( + select(ComparisonRecord.total_ms) + .where( + *conditions, + _comparison_status_condition(status), + ComparisonRecord.total_ms.is_not(None), + ) + .order_by(ComparisonRecord.total_ms) + ).scalars() + ) + average = _round_duration_ms(sum(values) / len(values)) if values else None + return [average, *(_comparison_percentile(values, q) for q in quantiles)] +``` + +改为(两处 `status`→`status_filter`): + +```python +def _comparison_duration_aggregates( + db: Session, + *, + conditions: list, + status_filter, + quantiles: tuple[float, ...], +) -> list[int | None]: + """返回平均值和各分位数;生产 PG 在数据库内聚合,SQLite 仅作测试回退。""" + if db.bind is not None and db.bind.dialect.name == "postgresql": + row = db.execute( + _comparison_duration_aggregate_stmt(conditions, status_filter, quantiles) + ).one() + return [_round_duration_ms(value) for value in row] + + # SQLite 没有 percentile_cont;本地/测试只回退读取耗时单列,不加载完整记录。 + values = list( + db.execute( + select(ComparisonRecord.total_ms) + .where( + *conditions, + status_filter, + ComparisonRecord.total_ms.is_not(None), + ) + .order_by(ComparisonRecord.total_ms) + ).scalars() + ) + average = _round_duration_ms(sum(values) / len(values)) if values else None + return [average, *(_comparison_percentile(values, q) for q in quantiles)] +``` + +- [ ] **Step 3b: `comparison_records_summary` 的 case 与耗时调用改 admin 口径** + +`comparison_records_summary` 里聚合 `row` 与耗时调用(现为): + +```python + row = db.execute( + select( + func.count(ComparisonRecord.id), + func.sum(case((ComparisonRecord.status.in_(_COMPARISON_COMPLETED_STATUSES), 1), else_=0)), + func.sum(case((ComparisonRecord.status.in_(_COMPARISON_SUCCESS_STATUSES), 1), else_=0)), + func.avg(ComparisonRecord.llm_cost_yuan), + func.sum(case(( + ComparisonRecord.status.in_(_COMPARISON_SUCCESS_STATUSES) + & (ComparisonRecord.saved_amount_cents > 0), 1 + ), else_=0)), + func.sum(case((ComparisonRecord.status == "cancelled", 1), else_=0)), + ).where(*conditions) + ).one() + started = int(row[0] or 0) + completed = int(row[1] or 0) + success = int(row[2] or 0) + lower_price = int(row[4] or 0) + cancelled = int(row[5] or 0) + success_duration_stats = _comparison_duration_aggregates( + db, + conditions=conditions, + status="success", + quantiles=(0.05, 0.5, 0.95, 0.99), + ) + cancelled_duration_stats = _comparison_duration_aggregates( + db, + conditions=conditions, + status="cancelled", + quantiles=(0.05, 0.5, 0.95), + ) +``` + +改为(`_success = admin_success_sql()`;completed = 成功 ∪ 纯 failed): + +```python + _success = admin_success_sql() + row = db.execute( + select( + func.count(ComparisonRecord.id), + func.sum(case((_success | (ComparisonRecord.status == "failed"), 1), else_=0)), + func.sum(case((_success, 1), else_=0)), + func.avg(ComparisonRecord.llm_cost_yuan), + func.sum(case((_success & (ComparisonRecord.saved_amount_cents > 0), 1), else_=0)), + func.sum(case((ComparisonRecord.status == "cancelled", 1), else_=0)), + ).where(*conditions) + ).one() + started = int(row[0] or 0) + completed = int(row[1] or 0) + success = int(row[2] or 0) + lower_price = int(row[4] or 0) + cancelled = int(row[5] or 0) + success_duration_stats = _comparison_duration_aggregates( + db, + conditions=conditions, + status_filter=admin_success_sql(), + quantiles=(0.05, 0.5, 0.95, 0.99), + ) + cancelled_duration_stats = _comparison_duration_aggregates( + db, + conditions=conditions, + status_filter=(ComparisonRecord.status == "cancelled"), + quantiles=(0.05, 0.5, 0.95), + ) +``` + +> 在 `queries.py` import 区把 Task 2 加的那行 import 替换为(补上 `admin_success_sql`): +> `from app.admin.repositories.comparison_outcome import admin_success_sql, derive_admin_outcome` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd e:/project/shaguabijia-app-server && ./.venv/Scripts/python.exe -m pytest tests/test_comparison_admin_summary.py -q` +Expected: PASS(2 passed) + +- [ ] **Step 5: 提交** + +```bash +cd e:/project/shaguabijia-app-server +git add app/admin/repositories/queries.py tests/test_comparison_admin_summary.py +git commit -m "feat(admin): 概览成功率/耗时统计改按 admin 口径(外部缺失记为成功) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: 大盘 `dashboard_overview` 改 admin 口径(后端) + +**Files:** +- Modify: `app/admin/repositories/stats.py`(`period_comparison_stats` 的 completed / success 两个 case) +- Test: `tests/test_admin_read.py`(更新 `test_dashboard_period_comparison_is_aggregated_by_backend`) + +- [ ] **Step 1: 更新测试(先失败)** + +`tests/test_admin_read.py` 里 `test_dashboard_period_comparison_is_aggregated_by_backend`(现造 4 条:success/failed/cancelled/running)。整体替换该测试函数体的 `rows` 构造与断言为: + +```python +def test_dashboard_period_comparison_is_aggregated_by_backend( + admin_client: TestClient, admin_token: str +) -> None: + created_at = datetime(2037, 1, 15, 12) + # (trace_id, status 列, record_status, total_ms, cost) + rows = [ + ("dashboard-success", "success", "success", 101, 0.1), + ("dashboard-below-min", "success", "below_minimum", 120, 0.2), + ("dashboard-store-not-found", "failed", "store_not_found", 130, 0.0), + ("dashboard-failed", "failed", "failed", 200, 0.3), + ("dashboard-cancelled", "cancelled", None, 300, 0.4), + ("dashboard-running", "running", None, 400, 0.5), + ] + db = SessionLocal() + try: + for trace_id, status, record_status, total_ms, llm_cost_yuan in rows: + db.add( + ComparisonRecord( + trace_id=trace_id, + status=status, + total_ms=total_ms, + llm_cost_yuan=llm_cost_yuan, + raw_payload={"record_status": record_status} if record_status else None, + created_at=created_at, + ) + ) + db.commit() + finally: + db.close() + + response = admin_client.get( + "/admin/api/stats/overview", + params={"date_from": "2037-01-15", "date_to": "2037-01-15"}, + headers=_auth(admin_token), + ) + assert response.status_code == 200, response.text + comparison = response.json()["period"]["comparison"] + assert comparison["total"] == 6 + # admin 成功 = success + below_minimum + store_not_found = 3 + assert comparison["success"] == 3 + # completed = 3 成功 + 1 纯 failed = 4 + assert comparison["completed"] == 4 + assert comparison["cancelled"] == 1 + # 分母 = total - cancelled = 5 + assert comparison["success_rate"] == 0.6 + assert comparison["token_cost_total_yuan"] == pytest.approx(1.5) +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd e:/project/shaguabijia-app-server && ./.venv/Scripts/python.exe -m pytest tests/test_admin_read.py::test_dashboard_period_comparison_is_aggregated_by_backend -q` +Expected: FAIL — `success` 仍按 `status=='success'` 算(=1,不含 below_minimum / store_not_found),断言 3 不符。 + +- [ ] **Step 3: 大盘 case 改 admin 口径** + +`app/admin/repositories/stats.py`,import 区加: + +```python +from app.admin.repositories.comparison_outcome import admin_success_sql +``` + +`period_comparison_stats` 聚合(现为): + +```python + period_comparison_stats = db.execute( + select( + func.count(ComparisonRecord.id), + func.coalesce( + func.sum( + case( + (ComparisonRecord.status.in_(("success", "failed")), 1), + else_=0, + ) + ), + 0, + ), + func.coalesce( + func.sum( + case((ComparisonRecord.status == "cancelled", 1), else_=0) + ), + 0, + ), + func.coalesce( + func.sum(case((ComparisonRecord.status == "success", 1), else_=0)), + 0, + ), + func.coalesce(func.sum(ComparisonRecord.llm_cost_yuan), 0.0), + ).where(*period_comparison_conds) + ).one() +``` + +改为(completed = 成功 ∪ 纯 failed;success = admin 成功): + +```python + _period_success = admin_success_sql() + period_comparison_stats = db.execute( + select( + func.count(ComparisonRecord.id), + func.coalesce( + func.sum( + case( + (_period_success | (ComparisonRecord.status == "failed"), 1), + else_=0, + ) + ), + 0, + ), + func.coalesce( + func.sum( + case((ComparisonRecord.status == "cancelled", 1), else_=0) + ), + 0, + ), + func.coalesce( + func.sum(case((_period_success, 1), else_=0)), + 0, + ), + func.coalesce(func.sum(ComparisonRecord.llm_cost_yuan), 0.0), + ).where(*period_comparison_conds) + ).one() +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd e:/project/shaguabijia-app-server && ./.venv/Scripts/python.exe -m pytest tests/test_admin_read.py::test_dashboard_period_comparison_is_aggregated_by_backend -q` +Expected: PASS + +- [ ] **Step 5: 后端全量回归 + 提交** + +Run: `cd e:/project/shaguabijia-app-server && ./.venv/Scripts/python.exe -m pytest tests/test_admin_read.py tests/test_comparison_admin_summary.py tests/test_admin_comparison_outcome.py -q` +Expected: PASS(全绿) + +```bash +cd e:/project/shaguabijia-app-server +git add app/admin/repositories/stats.py tests/test_admin_read.py +git commit -m "feat(admin): 数据大盘比价成功率改按 admin 口径(对齐概览) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 5: 列表 / 概览「状态」筛选对齐 admin 口径(后端) + +**Files:** +- Modify: `app/admin/repositories/queries.py`(`_comparison_status_condition` 改 admin 口径;删除不再引用的 #209 常量) +- Test: `tests/test_comparison_admin_summary.py`(新增筛选用例) + +- [ ] **Step 1: 写失败测试** + +追加到 `tests/test_comparison_admin_summary.py` 末尾: + +```python +def test_list_status_filter_uses_admin_outcome() -> None: + """列表「状态」筛选走 admin 口径:筛成功含 6 类,筛失败仅纯技术故障。""" + db = SessionLocal() + try: + rows = [ + ("flt-success", "success", "success"), + ("flt-below-min", "success", "below_minimum"), + ("flt-store-not-found", "failed", "store_not_found"), + ("flt-failed", "failed", "failed"), + ("flt-cancelled", "cancelled", None), + ] + for trace_id, status, record_status in rows: + db.add(ComparisonRecord( + trace_id=trace_id, + status=status, + raw_payload={"record_status": record_status} if record_status else None, + created_at=datetime(2039, 3, 10, 12, tzinfo=UTC), + )) + db.flush() + + succ, _c1, succ_total = queries.list_comparison_records( + db, status="success", date_from=date(2039, 3, 10), date_to=date(2039, 3, 10) + ) + assert succ_total == 3 + assert {it.trace_id for it in succ} == { + "flt-success", "flt-below-min", "flt-store-not-found", + } + + fail, _c2, fail_total = queries.list_comparison_records( + db, status="failed", date_from=date(2039, 3, 10), date_to=date(2039, 3, 10) + ) + assert fail_total == 1 + assert {it.trace_id for it in fail} == {"flt-failed"} + + _canc, _c3, canc_total = queries.list_comparison_records( + db, status="cancelled", date_from=date(2039, 3, 10), date_to=date(2039, 3, 10) + ) + assert canc_total == 1 + finally: + db.rollback() + db.close() +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cd e:/project/shaguabijia-app-server && ./.venv/Scripts/python.exe -m pytest tests/test_comparison_admin_summary.py::test_list_status_filter_uses_admin_outcome -q` +Expected: FAIL — 筛 success 仍按 status 列别名(漏掉 store_not_found),`succ_total == 3` 不符。 + +- [ ] **Step 3a: 改 `_comparison_status_condition`** + +`queries.py`(现为,约 66-68 行): + +```python +def _comparison_status_condition(status: str): + values = _COMPARISON_STATUS_ALIASES.get(status, (status,)) + return ComparisonRecord.status.in_(values) +``` + +改为(此时 `admin_success_sql` 已在 Task 3 import 到 `queries.py`): + +```python +def _comparison_status_condition(status: str): + """列表/概览「状态」筛选:success/failed 按 admin 口径(与显示/统计一致); + cancelled/running 按 status 列生命周期。""" + if status == "success": + return admin_success_sql() + if status == "failed": + return ~admin_success_sql() & ComparisonRecord.status.notin_(("cancelled", "running")) + return ComparisonRecord.status == status +``` + +- [ ] **Step 3b: 删除不再引用的 #209 常量** + +Task 3(概览 case 改 `admin_success_sql`)+ 本 Task(`_comparison_status_condition` 改 admin 口径)后,下列 #209 常量已无任何引用(`_comparison_status_condition` 耗时调用在 Task 3 已改走 `status_filter`)。删除 `queries.py` 里这整段(`_COMPARISON_STATUS_ALIASES` 定义起,至 `_COMPARISON_COMPLETED_STATUSES` 定义结束,约 45-62 行): + +```python +_COMPARISON_STATUS_ALIASES = { + "success": ("success", "below_minimum"), + "failed": ( + "failed", + "store_closed", + "store_not_found", + "items_not_found", + "no_delivery", + "unsupported", + ), + "cancelled": ("cancelled",), + "running": ("running",), +} +_COMPARISON_SUCCESS_STATUSES = _COMPARISON_STATUS_ALIASES["success"] +_COMPARISON_FAILED_STATUSES = _COMPARISON_STATUS_ALIASES["failed"] +_COMPARISON_COMPLETED_STATUSES = ( + *_COMPARISON_SUCCESS_STATUSES, + *_COMPARISON_FAILED_STATUSES, +) +``` + +> 删除前用 `grep -rn "_COMPARISON_SUCCESS_STATUSES\|_COMPARISON_COMPLETED_STATUSES\|_COMPARISON_STATUS_ALIASES\|_COMPARISON_FAILED_STATUSES" app/` 复核确无残留引用(应仅剩本段自身)。 + +- [ ] **Step 4: 运行确认通过 + 概览回归** + +Run: `cd e:/project/shaguabijia-app-server && ./.venv/Scripts/python.exe -m pytest tests/test_comparison_admin_summary.py -q` +Expected: PASS(3 passed) + +- [ ] **Step 5: 提交** + +```bash +cd e:/project/shaguabijia-app-server +git add app/admin/repositories/queries.py tests/test_comparison_admin_summary.py +git commit -m "feat(admin): 比价记录「状态」筛选对齐 admin 口径 + 清理 #209 死常量 + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 6: 前端类型 `types.ts`(admin-web) + +> 前端无测试框架(见 CLAUDE.md),用 `npx tsc --noEmit` 类型校验 + 手动核对。 + +**Files:** +- Modify: `src/lib/types.ts`(`ComparisonRecordListItem` 加两字段) + +- [ ] **Step 1: 加字段** + +`src/lib/types.ts`,`ComparisonRecordListItem` 里 `status: string;` 之后加: + +```typescript + status: string; + admin_status: string; // admin 展示口径:success/failed/cancelled/running + outcome_hint: string | null; // 有缺失的成功提示(未找到店/未满起送...);null=无缺失 +``` + +- [ ] **Step 2: 类型校验** + +Run: `cd e:/project/shaguabijia-admin-web && npx tsc --noEmit` +Expected: exit 0(无类型错误) + +- [ ] **Step 3: 提交** + +```bash +cd e:/project/shaguabijia-admin-web +git add src/lib/types.ts +git commit -m "feat: 比价记录类型加 admin_status/outcome_hint + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 7: 前端状态列 + 详情 + 标签映射(admin-web) + +**Files:** +- Modify: `src/app/(main)/comparison-records/page.tsx`(`STATUS_LABEL`/`STATUS_COLOR` 精简、状态列 render、详情状态、import) + +- [ ] **Step 1: 精简 `STATUS_LABEL` / `STATUS_COLOR`** + +`src/app/(main)/comparison-records/page.tsx` 第 22-46 行,两个映射改为只留 admin_status 的 4 个值: + +```typescript +// admin_status 只有四个生命周期值;外部缺失由 outcome_hint(感叹号)承载。 +const STATUS_COLOR: Record = { + success: 'green', + failed: 'red', + cancelled: 'default', + running: 'blue', +}; +const STATUS_LABEL: Record = { + success: '成功', + failed: '失败', + cancelled: '中途退出', + running: '进行中', +}; +``` + +- [ ] **Step 2: import 图标 / Tooltip** + +确认文件顶部 antd import 含 `Tooltip`,`@ant-design/icons` import 含 `ExclamationCircleOutlined`(缺则补): + +```typescript +import { ExclamationCircleOutlined } from '@ant-design/icons'; +``` + +- [ ] **Step 3: 状态列 render 改 admin_status + 感叹号** + +状态列(现为第 416 行): + +```typescript + { title: '状态', dataIndex: 'status', width: 72, render: (s: string) => {STATUS_LABEL[s] || s} }, +``` + +改为: + +```typescript + { title: '状态', dataIndex: 'admin_status', width: 88, render: (_: string, r) => ( + + {STATUS_LABEL[r.admin_status] || r.admin_status} + {r.outcome_hint && ( + + + + )} + + ) }, +``` + +- [ ] **Step 4: 详情状态同步** + +详情「状态」项(现为第 627 行): + +```typescript + {STATUS_LABEL[detail.status] || detail.status} +``` + +改为: + +```typescript + + {STATUS_LABEL[detail.admin_status] || detail.admin_status} + {detail.outcome_hint && } + +``` + +- [ ] **Step 5: 类型校验** + +Run: `cd e:/project/shaguabijia-admin-web && npx tsc --noEmit` +Expected: exit 0 + +> 若 `dataIndex: 'admin_status'` 的 render 第二参数 `r` 报类型错误,确认 `columns` 的泛型是 `ColumnsType`(既有),`r` 即该行类型。 + +- [ ] **Step 6: 手动核对(可选,需本地起服务)** + +起后端 admin API + 前端(`bash start.sh` 或分别启),打开比价记录页:外部缺失记录显示绿「成功」+ 黄色感叹号,hover 显示「未找到店」等;纯故障显示红「失败」无感叹号;概览成功率较改动前上升。 + +- [ ] **Step 7: 提交** + +```bash +cd e:/project/shaguabijia-admin-web +git add "src/app/(main)/comparison-records/page.tsx" +git commit -m "feat: 比价记录状态列按 admin 口径显示,外部缺失标成功+感叹号 + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 8: 状态口径文档补 admin 说明(后端,收尾) + +**Files:** +- Modify: `docs/guides/比价结果卡片-状态口径与交互参考.md`(app-server) + +- [ ] **Step 1: 追加一节** + +在该文档末尾(`*本文档为跨端口径参考*` 之前)加: + +```markdown +## 7. admin 记录页口径(与 C 端不同,勿混) + +admin 比价记录页 / 概览 / 大盘用**技术完成率**口径:`store_closed / store_not_found / +items_not_found / no_delivery / unsupported / below_minimum`(流程跑完、外部原因致结果缺失) +**记为成功**(前端标绿「成功」+ 感叹号,hover 显示缺失原因),只有纯技术故障 `failed` 才算失败。 +派生见 `app/admin/repositories/comparison_outcome.py`(原始结局取 `raw_payload.record_status`)。 + +这**刻意宽于** C 端 / #209 落库口径(那边 not_found 类归 `failed`)——admin 关心「系统有没有跑成」, +C 端关心「有没有省到钱」。故 **admin 成功率 ≠ C 端 / 首页轮播口径**,对不上是设计使然、非 bug。 +``` + +- [ ] **Step 2: 提交** + +```bash +cd e:/project/shaguabijia-app-server +git add "docs/guides/比价结果卡片-状态口径与交互参考.md" +git commit -m "docs: 补 admin 记录页技术完成率口径说明 + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## 验收(全部完成后) + +- 后端:`cd e:/project/shaguabijia-app-server && ./.venv/Scripts/python.exe -m pytest tests/test_admin_read.py tests/test_comparison_admin_summary.py tests/test_admin_comparison_outcome.py -q` 全绿。 +- 前端:`cd e:/project/shaguabijia-admin-web && npx tsc --noEmit` exit 0。 +- 手动:比价记录页外部缺失记录 = 绿「成功」+ 感叹号(hover 原因);纯故障 = 红「失败」;概览与大盘成功率较前上升(口径调整,非异常)。 diff --git a/docs/superpowers/specs/2026-08-04-admin-comparison-outcome-display-design.md b/docs/superpowers/specs/2026-08-04-admin-comparison-outcome-display-design.md new file mode 100644 index 0000000..670ea1a --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-admin-comparison-outcome-display-design.md @@ -0,0 +1,176 @@ +# admin 比价记录「技术成功/失败」口径与缺失提示设计 + +- **日期**:2026-08-04 +- **范围**:后端 `shaguabijia-app-server`(admin 层,**方案 A**:无 schema 变更、无迁移、不碰 #209 落库与 C 端)+ 前端 `shaguabijia-admin-web` +- **涉及文件**: + - 后端:`app/admin/repositories/comparison_outcome.py`(**新建**:共享常量 + Python 派生 + SQL 判定)、`app/admin/repositories/queries.py`(列表派生 + 概览口径)、`app/admin/schemas/comparison.py`(新增两字段)、`app/admin/repositories/stats.py`(大盘成功率分子口径) + - 前端:`src/app/(main)/comparison-records/page.tsx`、`src/lib/types.ts` + - 文档:`shaguabijia-app-server/docs/guides/比价结果卡片-状态口径与交互参考.md`(补 admin 口径说明) + +## 目标 + +比价记录页(管理员排查工具)把「流程正常跑完、只是外部原因导致结果缺失」的记录——未找到店 / 未找到菜 / 门店打烊 / 单点不配送 / 平台·场景不支持 / 未满起送——从「失败」改判为 **🟢 成功 ⚠**(hover 看具体缺失原因)。让「失败」红标签**只剩真正的技术故障**,管理员一眼定位系统问题。改动范围:**admin 后台内部统一**(比价记录页状态列 + 概览成功率 + 数据大盘),不动 C 端。 + +## 背景与现状冲突 + +- **#209** 把 `store_closed / store_not_found / items_not_found / no_delivery / unsupported` 归一化成记录级 `failed` 落库,`below_minimum` 归 `success`;成功率按此算。 +- 前端 `STATUS_LABEL` 把这些细分值 + `failed` 全显示「失败」([page.tsx:35](../../src/app/(main)/comparison-records/page.tsx))。 +- 问题:这些是「跑完流程、外部结果缺失」,不是系统故障。全归「失败」粒度太粗,管理员无法区分「系统的锅」vs「目标平台本来就没有这家店 / 这些菜」。 +- **数据来源已坐实**:原始业务结局保存在 `raw_payload["record_status"]`(优先)或 `raw_payload["status"]`(兜底),**每条记录都有**(harvest 与 POST 两条写路径都落,见 `repositories/comparison.py:810` 注释)。admin 列表用裸 `select(ComparisonRecord)`、**本就全量加载 `raw_payload`**(C 端 `list_records` 才 `defer`),故列表派生**零额外查询**。概览/大盘用 `raw_payload["record_status"].as_string()` 在 SQL 里分类(跨方言写法,#209 迁移已验证可用)。 + +## admin 口径(核心) + +**原始结局** `original = raw_payload["record_status"] or raw_payload["status"]`。 + +| original | admin_status | outcome_hint(hover) | +|---|---|---| +| success | success | (无) | +| below_minimum | success | 未满起送 | +| store_closed | success | 门店打烊 | +| store_not_found | success | 未找到店 | +| items_not_found | success | 未找到菜 | +| no_delivery | success | 单点不配送 | +| unsupported | success | 平台·场景不支持 | +| failed / 其他未知 | failed | (无,这才是要排查的技术故障) | +| (cancelled 记录,status 列) | cancelled | (无) | +| (running 记录,status 列) | running | (无) | + +**派生规则**: +- 记录 `status == "cancelled"` → `admin_status=cancelled`;`status == "running"` → `admin_status=running`(生命周期状态直接取 `status`,不看 original)。 +- 否则看 original:∈ **admin 成功集** `S` → `admin_status=success`,`outcome_hint=_OUTCOME_HINTS.get(original)`(`success` 本身 → `None`)。 +- original 为 `failed` 或未知非 `S` → `admin_status=failed`,`outcome_hint=None`。 +- **兜底**:`raw_payload` 缺 original(极老记录)→ 用 `status` 列(`success→success` / `failed→failed`),`outcome_hint=None`。 + +其中 `S = {success, below_minimum, store_closed, store_not_found, items_not_found, no_delivery, unsupported}`。 + +## 数据契约(新增两字段) + +`AdminComparisonListItem`(`AdminComparisonDetail` 继承)新增: + +```python +admin_status: str # success / failed / cancelled / running(admin 口径) +outcome_hint: str | None = None # 缺失提示文案;None = 无缺失 +``` + +原 `status` 字段**保留原样下发**(排查时可看后端落库原值)。前端只认 `admin_status` + `outcome_hint`,不自己算口径。 + +## 后端实现(方案 A) + +### 1. 新模块 `app/admin/repositories/comparison_outcome.py`(供 `queries.py` 与 `stats.py` 共用,避免循环 import) + +```python +from sqlalchemy import func +from app.models.comparison import ComparisonRecord + +ADMIN_SUCCESS_OUTCOMES = frozenset({ + "success", "below_minimum", "store_closed", + "store_not_found", "items_not_found", "no_delivery", "unsupported", +}) +OUTCOME_HINTS = { + "below_minimum": "未满起送", "store_closed": "门店打烊", + "store_not_found": "未找到店", "items_not_found": "未找到菜", + "no_delivery": "单点不配送", "unsupported": "平台·场景不支持", +} + +def derive_admin_outcome(raw_payload: dict | None, status: str) -> tuple[str, str | None]: + """Python 层派生(列表用;raw_payload 已随 ORM 加载,零额外查询)。""" + if status in ("cancelled", "running"): + return status, None + raw = raw_payload or {} + original = raw.get("record_status") or raw.get("status") + if original is None: # 极老记录兜底 + return ("success" if status == "success" else "failed"), None + if original in ADMIN_SUCCESS_OUTCOMES: + return "success", OUTCOME_HINTS.get(original) + return "failed", None + +def _original_expr(): + return func.coalesce( + ComparisonRecord.raw_payload["record_status"].as_string(), + ComparisonRecord.raw_payload["status"].as_string(), + ) + +def admin_success_sql(): + """SQL 层 admin 成功判定(概览/大盘的 case/where 共用)。""" + original = _original_expr() + return original.in_(tuple(ADMIN_SUCCESS_OUTCOMES)) | ( + original.is_(None) & (ComparisonRecord.status == "success") + ) +``` + +### 2. 列表 `list_comparison_records` + +在现有瞬态挂载段(`_attach_*` / `ad_revenue_yuan` 之后)对每条 `record`: + +```python +record.admin_status, record.outcome_hint = _derive_admin_outcome(record.raw_payload, record.status) +``` + +`raw_payload` 列表已加载,无 N+1。schema 加上述两字段(`from_attributes` 读出)。 + +### 3. 概览 `comparison_records_summary` + +把 `success` / `completed` 的 `case` 改用 original 口径: + +```python +_original_expr = func.coalesce( + ComparisonRecord.raw_payload["record_status"].as_string(), + ComparisonRecord.raw_payload["status"].as_string(), +) +# admin 成功 = original ∈ S OR (original IS NULL AND status == 'success') +# completed = admin 成功 + 纯 failed(展示字段;admin 口径) +# success_rate = admin 成功 / (started - cancelled) # 分母不变,见 queries.py:532 +``` + +**耗时分位**(avg / p50 / p95,`_comparison_duration_aggregate*`):统计集从「`status == "success"`」改为「admin 成功集」——用户已确认**统一口径纳入**这 6 类。改动点:`_comparison_status_condition` 系的耗时过滤条件改用 `_original_expr ∈ S`(或复用一个 `_admin_success_condition()` 表达式,列表/概览/大盘共用)。 + +### 4. 大盘 `stats.py` `dashboard_overview` + +`period_comparison_stats` 的 success 分子([stats.py:278-281](../../../shaguabijia-app-server/app/admin/repositories/stats.py) 的 `case(status=='success')`)现在**不含 below_minimum**(#209 只改了概览、没同步大盘)。改用 `admin_success_sql()`(`original ∈ S`)口径,与概览一致。 + +**范围界定**:两页成功率**分母都是 `total - cancelled`**(概览 [queries.py:532](../../src/../../../shaguabijia-app-server/app/admin/repositories/queries.py)、大盘 [stats.py:290](../../../shaguabijia-app-server/app/admin/repositories/stats.py)),口径一致、本次不动。真正的既有差异在**分子**:概览 success 含 `below_minimum`、大盘不含。本次把两处分子都统一为 `original ∈ S`,改完两页口径**完全一致**。副作用:大盘成功率因补上 `below_minimum` + 5 类而上升,概览因补上 5 类上升——均属口径调整、非数据异常。 + +## 前端实现 + +### 1. 状态列([page.tsx:416](../../src/app/(main)/comparison-records/page.tsx)) + +`render` 改用 `admin_status` 出标签(`STATUS_LABEL/COLOR`:success→绿「成功」/ failed→红「失败」/ cancelled→「中途退出」/ running→「进行中」);`outcome_hint` 非空 → 标签后跟 `` 包一个 ⚠(antd `WarningOutlined`)。 + +### 2. 详情页状态([page.tsx:627](../../src/app/(main)/comparison-records/page.tsx)) + +同步用 `admin_status` + `outcome_hint`。 + +### 3. `STATUS_LABEL` / `STATUS_COLOR`([page.tsx:35](../../src/app/(main)/comparison-records/page.tsx)) + +精简:移除把 `store_closed/store_not_found/items_not_found/no_delivery/unsupported` 直接映射「失败」的行(统一走 `admin_status`);保留 `success/failed/cancelled/running`。`below_minimum` 同理不再单列。 + +### 4. `types.ts` + +`ComparisonRecordListItem` 加 `admin_status: string`、`outcome_hint: string | null`。 + +### 5. 概览成功率 + +前端只展示后端返回的数字,口径变化对前端透明;检查概览区有无「成功率」口径说明文案需同步。 + +## 不改 / 一致性 + +- `status` 列语义、#209 落库、完成奖励幂等、C 端「我的比价」/ 首页轮播 / 省钱战绩口径:**全不动**。 +- admin 口径(跑完即成功)**刻意宽于** C 端业务口径;因大盘也一并改,**admin 后台内部自洽**。 +- 在状态口径文档补一段「admin 记录页口径」:说明 admin 成功率是「技术完成率」,与 C 端业务口径不同,避免以后有人拿两者对不上而误判为 bug。 + +## 边界 + +- `raw_payload` 缺 `record_status/status`(极老记录):`admin_status` 取 `status` 列,`outcome_hint=None`,不加 ⚠。 +- 多平台 `platform_results`:以记录级 `record_status` 为准(pricebot 已归纳),不逐平台判。 +- 迁移未覆盖、`status` 列仍是细分值的老记录:`original` 优先,仍正确归类(细分值本身 ∈ S)。 + +## 测试 + +- 后端新增 `_derive_admin_outcome` 单测:各 original + cancelled/running + raw_payload 缺失兜底,验 `(admin_status, outcome_hint)`。 +- `tests/test_comparison_admin_summary.py`:造含 6 类 + success + failed + cancelled 的记录,验 `success` / `completed` / `success_rate` / 耗时分位按新口径。 +- `tests/test_admin_read.py`:列表返回 `admin_status` / `outcome_hint`(成功·有缺失、纯失败、纯成功各一条)。 + +## 影响面 / 风险 + +- 概览/大盘 SQL 读 JSONB(`raw_payload['record_status'].as_string()`):PG 上是 JSONB,跨方言 `.as_string()` #209 迁移已用;SQLite 测试走 JSON1。admin 低频、P0 量级,性能可忽略。 +- **admin 成功率数字会上升**(这 6 类从失败转成功):需知会运营这是**口径调整、非数据异常**。C 端 / 大盘之外的成功率不受影响。 diff --git a/src/app/(main)/comparison-records/page.tsx b/src/app/(main)/comparison-records/page.tsx index a5311ac..a7dc9f4 100644 --- a/src/app/(main)/comparison-records/page.tsx +++ b/src/app/(main)/comparison-records/page.tsx @@ -4,8 +4,9 @@ import { useEffect, useState } from 'react'; import type { CSSProperties, ReactNode } from 'react'; import { App, Button, Card, Col, Collapse, DatePicker, Descriptions, Divider, Drawer, Input, InputNumber, - Row, Select, Space, Spin, Statistic, Table, Tag, Typography, + Row, Select, Space, Spin, Statistic, Table, Tag, Tooltip, Typography, } from 'antd'; +import { ExclamationCircleOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import dayjs, { type Dayjs } from 'dayjs'; import { api, errMsg } from '@/lib/api'; @@ -19,28 +20,16 @@ import type { const { RangePicker } = DatePicker; -// 主状态只对外呈现生命周期口径;below_minimum/store_closed 是迁移前历史兼容值。 +// admin_status 只有四个生命周期值;外部缺失由 outcome_hint(感叹号)承载。 const STATUS_COLOR: Record = { success: 'green', - below_minimum: 'green', failed: 'red', - store_closed: 'red', - store_not_found: 'red', - items_not_found: 'red', - no_delivery: 'red', - unsupported: 'red', cancelled: 'default', running: 'blue', }; const STATUS_LABEL: Record = { success: '成功', - below_minimum: '成功', failed: '失败', - store_closed: '失败', - store_not_found: '失败', - items_not_found: '失败', - no_delivery: '失败', - unsupported: '失败', cancelled: '中途退出', running: '进行中', }; @@ -413,7 +402,16 @@ export default function ComparisonRecordsPage() { ), }, - { title: '状态', dataIndex: 'status', width: 72, render: (s: string) => {STATUS_LABEL[s] || s} }, + { title: '状态', dataIndex: 'admin_status', width: 88, render: (_: string, r) => ( + + {STATUS_LABEL[r.admin_status] || r.admin_status} + {r.outcome_hint && ( + + + + )} + + ) }, // 产品文档指定展示“原平台”;字段名仍沿用后端 source_platform_name。 { title: '原平台', dataIndex: 'source_platform_name', width: 90, render: (v) => v || '-' }, { @@ -624,7 +622,10 @@ export default function ComparisonRecordsPage() { {detail.phone || (detail.user_id != null ? `#${detail.user_id}` : '匿名')}{detail.nickname ? `(${detail.nickname})` : ''} - {STATUS_LABEL[detail.status] || detail.status} + + {STATUS_LABEL[detail.admin_status] || detail.admin_status} + {detail.outcome_hint && } + {detail.business_type} {fmtMs(detail.total_ms)} {detail.step_count ?? '-'} diff --git a/src/lib/types.ts b/src/lib/types.ts index 4776905..f041bc0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -450,6 +450,8 @@ export interface ComparisonRecordListItem { trace_id: string; trace_url: string | null; status: string; + admin_status: string; // admin 展示口径:success/failed/cancelled/running + outcome_hint: string | null; // 有缺失的成功提示(未找到店/未满起送...);null=无缺失 information: string | null; store_name: string | null; product_names: string | null; // 下单商品名派生串(顿号分隔),「商品」列展示