diff --git a/app/admin/repositories/comparison_outcome.py b/app/admin/repositories/comparison_outcome.py new file mode 100644 index 0000000..5ad212f --- /dev/null +++ b/app/admin/repositories/comparison_outcome.py @@ -0,0 +1,61 @@ +"""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(nullif(raw.record_status,''), nullif(raw.status,''), status 列)。 + nullif('') 让空串与 Python `or` 口径一致地跳过(as_string 跨方言,#209 迁移已验证)。""" + return func.coalesce( + func.nullif(ComparisonRecord.raw_payload["record_status"].as_string(), ""), + func.nullif(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) + ) diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index c085445..28d10ed 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -5,13 +5,14 @@ """ from __future__ import annotations -from datetime import date, datetime, time, timedelta, timezone +from datetime import UTC, date, datetime, time, timedelta from decimal import ROUND_HALF_UP, Decimal from zoneinfo import ZoneInfo from sqlalchemy import Select, and_, asc, case, desc, func, or_, select from sqlalchemy.orm import Session +from app.admin.repositories.comparison_outcome import admin_success_sql, derive_admin_outcome from app.core import rewards from app.core.config import settings from app.models.ad_ecpm import AdEcpmRecord @@ -39,33 +40,15 @@ from app.repositories import activity, ad_ecpm # 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取") _NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct") -# comparison_record historically persisted a few granular business outcomes as -# top-level statuses. Admin filters and metrics expose lifecycle buckets while -# retaining the raw values until the data migration has run everywhere. -_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, -) - def _comparison_status_condition(status: str): - values = _COMPARISON_STATUS_ALIASES.get(status, (status,)) - return ComparisonRecord.status.in_(values) + """列表/概览「状态」筛选: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 # 信息流点位场景 → 金币记录「赚取途径」展示名;NULL/未知 = 历史未分类。 _FEED_SCENE_LABEL = { @@ -371,10 +354,10 @@ def _comparison_conditions( conditions.append(ComparisonRecord.product_names.like(f"%{product}%")) beijing = ZoneInfo("Asia/Shanghai") if date_from is not None: - start_utc = datetime.combine(date_from, time.min, tzinfo=beijing).astimezone(timezone.utc) + start_utc = datetime.combine(date_from, time.min, tzinfo=beijing).astimezone(UTC) conditions.append(ComparisonRecord.created_at >= start_utc) if date_to is not None: - end_utc = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=beijing).astimezone(timezone.utc) + end_utc = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=beijing).astimezone(UTC) conditions.append(ComparisonRecord.created_at < end_utc) return conditions @@ -414,6 +397,7 @@ def list_comparison_records( 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 @@ -437,8 +421,8 @@ def _round_duration_ms(value) -> int | None: return int(Decimal(str(value)).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) -def _comparison_duration_aggregate_stmt(conditions: list, status: str, quantiles: tuple[float, ...]): - """PostgreSQL 耗时聚合语句;每种状态只返回一行。""" +def _comparison_duration_aggregate_stmt(conditions: list, status_filter, quantiles: tuple[float, ...]): + """PostgreSQL 耗时聚合语句;status_filter 为已构造的口径条件表达式,每口径只返回一行。""" return select( func.avg(ComparisonRecord.total_ms), *( @@ -447,7 +431,7 @@ def _comparison_duration_aggregate_stmt(conditions: list, status: str, quantiles ), ).where( *conditions, - _comparison_status_condition(status), + status_filter, ComparisonRecord.total_ms.is_not(None), ) @@ -456,13 +440,13 @@ def _comparison_duration_aggregates( db: Session, *, conditions: list, - status: str, + 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, quantiles) + _comparison_duration_aggregate_stmt(conditions, status_filter, quantiles) ).one() return [_round_duration_ms(value) for value in row] @@ -472,7 +456,7 @@ def _comparison_duration_aggregates( select(ComparisonRecord.total_ms) .where( *conditions, - _comparison_status_condition(status), + status_filter, ComparisonRecord.total_ms.is_not(None), ) .order_by(ComparisonRecord.total_ms) @@ -499,16 +483,14 @@ def comparison_records_summary( user_id=user_id, phone=phone, status=status, business_type=business_type, store=store, product=product, date_from=date_from, date_to=date_to, ) + _success = admin_success_sql() 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.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(( - ComparisonRecord.status.in_(_COMPARISON_SUCCESS_STATUSES) - & (ComparisonRecord.saved_amount_cents > 0), 1 - ), else_=0)), + func.sum(case((_success & (ComparisonRecord.saved_amount_cents > 0), 1), else_=0)), func.sum(case((ComparisonRecord.status == "cancelled", 1), else_=0)), ).where(*conditions) ).one() @@ -520,13 +502,13 @@ def comparison_records_summary( success_duration_stats = _comparison_duration_aggregates( db, conditions=conditions, - status="success", + status_filter=admin_success_sql(), quantiles=(0.05, 0.5, 0.95, 0.99), ) cancelled_duration_stats = _comparison_duration_aggregates( db, conditions=conditions, - status="cancelled", + status_filter=(ComparisonRecord.status == "cancelled"), quantiles=(0.05, 0.5, 0.95), ) success_rate_denominator = started - cancelled @@ -551,12 +533,13 @@ def comparison_records_summary( def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None: - """admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。""" + """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 @@ -585,8 +568,8 @@ def _heartbeat_seconds_ago(last: datetime | None) -> int | None: if last is None: return None if last.tzinfo is None: - last = last.replace(tzinfo=timezone.utc) - return int((datetime.now(timezone.utc) - last).total_seconds()) + last = last.replace(tzinfo=UTC) + return int((datetime.now(UTC) - last).total_seconds()) def _device_model_from_id(device_id: str) -> str: @@ -639,7 +622,7 @@ def _attach_device_user_info(db: Session, devices: list[DeviceLiveness]) -> None def _liveness_cutoff() -> datetime: """掉线判定分界:此刻 - HEARTBEAT_TIMEOUT_MINUTES。心跳早于它 = 掉线(同 list_overdue 口径)。""" timeout_min = max(1, int(settings.HEARTBEAT_TIMEOUT_MINUTES)) - return datetime.now(timezone.utc) - timedelta(minutes=timeout_min) + return datetime.now(UTC) - timedelta(minutes=timeout_min) def list_device_liveness( @@ -815,11 +798,11 @@ def list_all_withdraw_orders( stmt = stmt.where(date_col <= _as_utc(date_to)) # tz-aware:列为 timestamptz,比较绝对时刻、与 DB 会话时区无关(同 _as_utc / stats.py) - now = datetime.now(timezone.utc) + now = datetime.now(UTC) today_start = ( datetime.now(ZoneInfo("Asia/Shanghai")) .replace(hour=0, minute=0, second=0, microsecond=0) - .astimezone(timezone.utc) + .astimezone(UTC) ) if quick_filter == "abnormal": stmt = stmt.where( @@ -866,8 +849,8 @@ def _as_utc(value: datetime) -> datetime: 生产会话非 UTC 时筛选边界会整体偏移——故统一 tz-aware(与 stats.py / withdraw_summary 一致)。 无时区入参按 UTC 解释。""" if value.tzinfo is None: - return value.replace(tzinfo=timezone.utc) - return value.astimezone(timezone.utc) + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) def withdraw_list_enrichment( @@ -1032,7 +1015,7 @@ def withdraw_summary(db: Session, *, source: str | None = None) -> dict: today_start = ( datetime.now(ZoneInfo("Asia/Shanghai")) .replace(hour=0, minute=0, second=0, microsecond=0) - .astimezone(timezone.utc) + .astimezone(UTC) ) def _today_count(status: str) -> int: @@ -1207,8 +1190,8 @@ def withdraw_risk_flags( if user and user.status != "active": flags.append(f"账号状态:{user.status}") if user and user.created_at: - created_at = user.created_at.replace(tzinfo=timezone.utc) if user.created_at.tzinfo is None else user.created_at - if datetime.now(timezone.utc) - created_at < timedelta(hours=24): + created_at = user.created_at.replace(tzinfo=UTC) if user.created_at.tzinfo is None else user.created_at + if datetime.now(UTC) - created_at < timedelta(hours=24): flags.append("新注册用户") # 历史异常提现拆「拒绝」「失败」两类(口径不同:拒绝=人工驳回退款,失败=打款失败退款) rejected_n = sum(1 for item in recent_withdraws if item.status == "rejected") @@ -1419,7 +1402,7 @@ def _cn_wall_to_utc(dt: datetime) -> datetime: """coin_transaction 存的是北京 wall-clock(naive,见 wallet.grant_coins「存北京 wall-clock」),转成 UTC naive, 与广告表(func.now() UTC)统一 —— 让本函数按同一绝对时刻排序、且前端 apiTime(把无时区时间当 UTC 再 +8 展示) 口径一致;否则签到会比实际多显示 8 小时(北京时间又被 +8)。""" - return dt.replace(tzinfo=rewards.CN_TZ).astimezone(timezone.utc).replace(tzinfo=None) + return dt.replace(tzinfo=rewards.CN_TZ).astimezone(UTC).replace(tzinfo=None) def _coin_record_sort_key(row: dict) -> datetime: diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index e2b1ba9..94b8beb 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -12,6 +12,7 @@ from decimal import ROUND_HALF_UP, Decimal, InvalidOperation from sqlalchemy import case, func, or_, select from sqlalchemy.orm import Session +from app.admin.repositories.comparison_outcome import admin_success_sql from app.admin.repositories.coupon_data import _percentile from app.models.ad_feed_reward import AdFeedRewardRecord from app.models.ad_reward import AdRewardRecord @@ -257,13 +258,14 @@ def dashboard_overview( ComparisonRecord.created_at >= start_local, ComparisonRecord.created_at < end_local, ) + _period_success = admin_success_sql() period_comparison_stats = db.execute( select( func.count(ComparisonRecord.id), func.coalesce( func.sum( case( - (ComparisonRecord.status.in_(("success", "failed")), 1), + (_period_success | (ComparisonRecord.status == "failed"), 1), else_=0, ) ), @@ -276,7 +278,7 @@ def dashboard_overview( 0, ), func.coalesce( - func.sum(case((ComparisonRecord.status == "success", 1), else_=0)), + func.sum(case((_period_success, 1), else_=0)), 0, ), func.coalesce(func.sum(ComparisonRecord.llm_cost_yuan), 0.0), diff --git a/app/admin/schemas/comparison.py b/app/admin/schemas/comparison.py index 5045f74..f9d7093 100644 --- a/app/admin/schemas/comparison.py +++ b/app/admin/schemas/comparison.py @@ -22,6 +22,10 @@ class AdminComparisonListItem(BaseModel): # admin 是 debug 工具,无条件下发 trace_url(不看 user.debug_trace_enabled) trace_url: str | None = None status: str # success / failed / cancelled / running;旧细分值由前端兼容映射 + # admin 展示口径(见 repositories/comparison_outcome):成功含「跑完但外部缺失」, + # 纯技术故障才 failed;outcome_hint 非空=有缺失,前端标感叹号。 + admin_status: str = "success" + outcome_hint: str | None = None information: str | None = None store_name: str | None = None product_names: str | None = None # 下单商品名派生串(顿号分隔;「商品」列展示 + 商品搜索) diff --git a/docs/guides/比价结果卡片-状态口径与交互参考.md b/docs/guides/比价结果卡片-状态口径与交互参考.md new file mode 100644 index 0000000..3c00ad1 --- /dev/null +++ b/docs/guides/比价结果卡片-状态口径与交互参考.md @@ -0,0 +1,156 @@ +# 比价结果卡片 · 状态口径与交互参考 + +> **定位**:把「比价结果页每张平台卡片」的 11 种展示分类,逐一映射到后端状态与判定字段,供 **App 端做互动/文案调整时参考**。 +> **适用**:外卖比价结果页(`CompResultScreen`)。 +> **数据源**:基于三仓代码梳理(pricebot 判定 → app-server 透传/落库 → android 映射/渲染)。 +> **整理日期**:2026-07-31。代码行号会漂移,改动后以实际代码为准。 + +--- + +## 0. 快速须知(三个容易踩的坑) + +1. **`ok` 不是 `success`**:逐平台卡片的成功态字符串是 **`ok`**(端侧 `rowToUi` 判 `row.status == "ok"`);`success/failed/cancelled/running` 是**记录级** `ComparisonRecord.status`(admin 后台用)。两层状态串不同,别混。 +2. **未知状态一律兜底成 `Failed`(#9)**:端侧映射用 `else -> CompareResultStatus.Failed`,所以后端 `unsupported`、`failed`、以及**任何端侧没显式处理的新状态**都会显示成 #9。pricebot 以后加状态,端侧不同步就会「消失」进 #9。 +3. **状态判定分三层来源**(见 §2):不是所有卡都由后端 status 决定,#10/#11 完全是端侧本地判定,后端零感知。 + +--- + +## 1. 主对照表:11 类卡片 ↔ 后端状态 + +映射函数:`CompareProgressRepository.kt` — 新路径 `rowToUi:339-375`、老路径 `summaryToUi:509-544`、端侧补齐 `onComparisonResultsReady:266-315`。 + +| # | 分类 | Android 枚举 | 后端逐平台 `status` | `Found` 内细分依据 | 状态来源 | 当前交互 | +|---|---|---|---|---|---|---| +| 1 | 全网最低赢家 | `Found` | `ok`(有价) | `is_best && !has_dish_diff` → isLowest | pricebot | 去购买(黄) | +| 2 | 没有可用优惠(原选择) | `Found` | `ok` | `role=source`/is_user_original + 无可用优惠 | pricebot | 查看(灰) | +| 3 | 其他成功 | `Found` | `ok` | `role=target`,非 best | pricebot | 查看(灰) | +| 4 | 少菜 / 部分缺 | `Found` + 提示条 | `ok` | `skipped_dish_names` 非空 / `skipped_dish_count>0` | pricebot | 随主卡 | +| 5 | 未满起送 | `BelowMinimum` | `below_minimum` | — | pricebot | 查看(灰) | +| 6 | 门店打烊 | `StoreClosed` | `store_closed` | — | pricebot | 查看(灰) | +| 7 | 没有您点的商品 | `ItemsNotFound` | `items_not_found` | — | pricebot | 查看(灰) | +| 8 | 无对应商家 | `StoreNotFound` | `store_not_found` | — | pricebot **或端侧补齐** | 查看(灰) | +| 9 | 比价失败兜底 | `Failed` | `failed`/`unsupported`/**任何未知值**(`else`) | — | pricebot **或端侧**(整场没跑成) | 无 | +| 10 | 未安装 | `NotInstalled` | **无**(后端零感知) | 端侧:该平台没装(`getPackageInfo`) | **端侧本地** | 去安装 | +| 11 | 未选择 / 本次未比 | `NotComparedThisTime` | **无**(后端零感知) | 端侧:`selectedPlatformIds` 没勾这家 | **端侧本地** | 无(设计要「重试」,未接) | +| 表外 | 单点不配送 | `NoDelivery` | `no_delivery` | — | pricebot | 查看(灰) | + +> **表外提醒**:`no_delivery`(单点不配送/需搭配主食)在代码里是独立状态(复用未起送灰框样式),但**不在原 11 类分类表内**——做分类时需给它安个位置或明确并入 #5/#7。 + +--- + +## 2. 状态来源三分层(做互动最该记住的) + +**A. pricebot 逐平台 `status` 直接决定(5/6/7/8/9 + no_delivery)** +端侧只做「字符串 → 枚举」映射,互动依附后端判定。 + +**B. `ok` 成功态再靠 `platforms[]` 字段细分(1/2/3/4)** +同一个 `Found`,靠 `is_best`、`has_dish_diff`、`role`、`skipped_dish_names` 分出四种。这些字段都是 pricebot 在 `platforms[]` 下发的。 + +**C. 纯端侧本地判定,后端完全无感知(10/11,以及 8 的补齐分支)** +依据:**装机态**(`getPackageInfo`)+ **勾选态**(`selectedPlatformIds`)+ **整场是否跑成**(`failureReason`)。 +见 `onComparisonResultsReady:266-315`:走了选平台弹窗没勾 → #11;装了 App 没找到店 → #8;都没装 → #10(`marketPackage` 指向子 App 包名,引导下载子 App)。 + +--- + +## 3. 菜品差异口径(有相似菜品 / 少菜 / 规格近似) + +「有相似菜品」**不是独立卡**,而是 `status=Found`(有价)成功卡上、因「菜品对不齐」触发的黄色提醒模块 `MismatchBox`。 + +### 3.1 开关:`hasDishDiff` +定义在 `CompareMockResults.kt:101-109`。`status=Found` 且满足**任一**即为 true: +- `items` 里有 `similar=true` 的菜(近似替代) +- `approxDishNames` 非空(规格近似) +- `skippedDishNames` 非空(完全缺失) +- `skippedDishCount > 0`(只有数量) + +**权威值优先取后端** `platforms[].has_dish_diff`(端侧 `dishDiffOverride`,`CompareMockResults.kt:91-93`),老路径才端侧本地算。与后端 `is_best` 竞选同判据。 + +### 3.2 `MismatchBox` 的三种行(`CompRowCard.kt:236-250` 触发 / `460-516` 渲染) + +| 展示文案 | 触发字段 | 语义 | +|---|---|---| +| **本平台没有「orig」 / 已换成近似「name」** | `items` 里 `similar=true && orig!=null`(similarPairs) | **已发生近似替换**(有 orig→name 替换对) | +| 规格近似「X」,请下单前核对 | `approxDishNames` | 已近似,但提示核对 | +| 本平台没有「X」,也无相似菜 | `skippedDishNames` | 完全缺失、无替代 | +| 本平台缺少 N 个菜品,已按可购买商品计价 | `skippedDishCount>0` 且无菜名 | 只有数量时的兜底 | + +> **易混近亲**:`complexSpecDishNames`(`CompareMockResults.kt:86-88`)走的是**另一个浅黄条** `ComplexSpecNotice`,文案「规格较复杂,请核对」,语义是"需人工核对规格、**不改价格有效性**",与"已换成近似"不是一回事。 + +### 3.3 连锁后果(为什么降级成"查看") +`has_dish_diff=true` → `isLowest = is_best && !has_dish_diff` 必为 false → **踢出「全网最低」评定**,排到"仅供参考"分组,不给绶带/省¥红章/去购买黄按钮,只能灰"查看"。 + +### 3.4 边界 +- **vs #4「完全缺失」**:同一个 `MismatchBox` 的不同行,**可并存**(一个平台既有近似替换又有完全缺失)。它们是同一张成功卡里分段列出,不是两张卡。 +- **vs #7「该店没有您点的商品(items_not_found)」**:分水岭是**有没有比出价**。能凑出单、有价 → `Found` + MismatchBox;整单找不到菜、无价 → `items_not_found` 灰框无价卡。 +- **vs #1 赢家**:互斥。 + +--- + +## 4. 交互调整参考要点 + +1. **文案/提示权已在前端**:卡片副文案由端侧 `fullReasonForStatus(status)` 按状态本地生成(`CompareProgressRepository.kt:51-59`,注释明写"标题/文案决策收回前端")。后端逐平台原因字段 `notFoundReason`(= `platform_results[].reason`)**线上常空**,端侧兜底。→ **改提示文案、加互动引导、加动效,纯端侧就能做,不用改 pricebot。** +2. **可空字段决定互动形态**:`price` 可空(6/7/8/9/10/11 无价 → `¥??` 或隐藏);`store_name` 可空(退化平台名)。`LOCATED_STATUSES`(`CompareProgressRepository.kt:46-47`:`ok/below_minimum/no_delivery/store_closed/items_not_found`)决定 header 用店名还是平台名。做点击/跳转互动前要判空。 +3. **#10/#11 端侧完全自主**:引导安装、引导勾选、重试这类互动无需后端配合,随便调。 +4. **区分"真没店"vs"没跑成"**:`failureReason` 非空(网络/上游 502)时,补齐卡统一兜底文案而非"未找到店"(`CompareProgressRepository.kt:104-107`,避免谎报)。"重试整场"的互动应挂这个场景,而不是 #8。 +5. **带动态数字的文案要后端补字段**:如"还差 ¥8 起送"——`platforms[]` 目前不下发起送差额,想要这种提示得让 pricebot 补字段。 +6. **#4 少菜 / 有相似菜品是成功卡上的模块,不是独立卡**:它们的互动依附主卡(1/2/3)。 + +--- + +## 5. 关键代码位置索引 + +### Android(`shaguabijia-app-android`) +| 关注点 | 文件:行 | +|---|---| +| 后端 status → 枚举映射(新/老路径) | `CompareProgressRepository.kt:339-375` / `509-544` | +| 端侧文案本地生成 | `CompareProgressRepository.kt:51-59`(`fullReasonForStatus`) | +| header 店名/平台名切换 | `CompareProgressRepository.kt:46-47`(`LOCATED_STATUSES`) | +| 端侧补齐 #8/#10/#11(装机+勾选+failureReason) | `CompareProgressRepository.kt:266-315` | +| 卡片 when 主分支 | `CompRowCard.kt:83-128` | +| `MismatchBox`(菜品差异条) | `CompRowCard.kt:236-250`(触发)/ `460-516`(渲染) | +| 未安装卡 / 本次未比卡 | `CompRowCard.kt:596-697` / `720-790` | +| 按钮(去购买/查看/重试) | `CompRowCard.kt:1148-1180` / `1210` / `1214`;枚举 `GrayCardCta:132` | +| 数据模型 `CompareResult` / `hasDishDiff` / `CompareDish` | `CompareMockResults.kt:34-110` / `101-109` / `116` | +| 网络 DTO 解析 | `Protocol.kt:530-549` / `576-665` | +| 生产结果页 / VM | `CompResultScreen.kt` / `CompResultViewModel.kt` | + +### 后端(`shaguabijia-app-server`) +| 关注点 | 文件:行 | +|---|---| +| 逐平台 `status` 枚举 | `app/schemas/compare_record.py:81-85` | +| 记录模型 / `status` / `skipped_dish_names` / `raw_payload` | `app/models/comparison.py:39-169` / `:101` / `:121` / `:123` | +| 落库与状态派生(running/done/abort) | `app/repositories/comparison.py`(`harvest_*`、`_derive*`) | +| `has_dish_diff` 兜底判 is_best | `app/repositories/comparison.py:379` | +| 记录级失败原因派生 | `app/repositories/comparison.py:118-127`(`_derive_fail_display`) | +| admin 读取接口 | `app/admin/routers/comparison.py` / `app/admin/repositories/queries.py` | + +--- + +## 6. 当前实现现状与「目标设计」的差异(待办) + +11 类骨架**都已实现、生产页 `CompResultScreen` 真实可达**(非 mock)。但对照原 11 类目标设计,仍有以下差异: + +| 项 | 现状 | 差异 / 待办 | +|---|---|---| +| #11「重试」按钮 | `NotComparedCard` 无任何 CTA;`GrayCardCta.Retry`+`RetryCta` 定义了但**没挂到任何卡**(死代码) | 唯一**功能级缺口**:要么接上,要么把设计改成"无按钮" | +| 各类文案措辞 | 偏功能陈述(如"该店当前已打烊,暂时无法比价") | 与目标口语+emoji("…打烊休息啦😴…")不一致;改端侧 `fullReasonForStatus` 即可(后端 `reason` 常空、无需动 pricebot) | +| #1 优惠区 | 已改**聚合**"已自动帮您应用优惠,共减 ¥X" | 与"优惠 tags(逐项标签)"不同(产品已决策聚合) | +| #4 少菜 | 成功卡上的 `MismatchBox` 提示条 | 非独立卡(按"原生为准"这多为有意) | +| `no_delivery` | 代码有独立状态 | 原 11 类表未含,需补位或并入 | + +--- + +## 7. admin 记录页口径(与 C 端不同,勿混) + +admin 比价记录页 / 概览 / 大盘用**技术完成率**口径:`below_minimum / store_closed / +store_not_found / items_not_found / no_delivery / unsupported`(流程跑完、只是外部原因致结果缺失) +**记为成功**(前端标绿「成功」+ 感叹号,hover 显示缺失原因),只有纯技术故障 `failed` 才算失败。 +派生见 `app/admin/repositories/comparison_outcome.py`(原始结局取 `raw_payload.record_status`, +`admin_success_sql()` 供概览/大盘/列表筛选共用,`derive_admin_outcome()` 供列表逐行下发 `outcome_hint`)。 + +这**刻意宽于** C 端 / #209 落库口径(那边 not_found 类归 `failed`)——admin 关心「系统有没有跑成」, +C 端关心「有没有省到钱」。故 **admin 成功率 ≠ C 端 / 首页轮播口径**,对不上是设计使然、非 bug。 + +--- + +*本文档为跨端口径参考,非契约。字段/行号以三仓实际代码为准。* diff --git a/tests/test_admin_comparison_outcome.py b/tests/test_admin_comparison_outcome.py new file mode 100644 index 0000000..37de0eb --- /dev/null +++ b/tests/test_admin_comparison_outcome.py @@ -0,0 +1,62 @@ +"""admin 展示口径派生单测:记录级原始结局 → (admin_status, outcome_hint)。""" +from __future__ import annotations + +import pytest +from sqlalchemy import select + +from app.admin.repositories.comparison_outcome import admin_success_sql, derive_admin_outcome +from app.db.session import SessionLocal +from app.models.comparison import ComparisonRecord + + +@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)), # 纯技术故障 + # 空字符串视作缺失,兜到下一级(与 SQL nullif 对齐) + ({"record_status": "", "status": "store_closed"}, "failed", ("success", "门店打烊")), + # 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 + + +def test_admin_success_sql_matches_python_on_empty_string() -> None: + """record_status 为空串时,SQL 侧(nullif)与 Python 侧(or)都应兜到 status 列结局、判为成功。""" + db = SessionLocal() + try: + rec = ComparisonRecord( + trace_id="outcome-empty-record-status", + status="failed", + raw_payload={"record_status": "", "status": "store_closed"}, + ) + db.add(rec) + db.flush() + matched = db.execute( + select(ComparisonRecord.id).where( + ComparisonRecord.trace_id == "outcome-empty-record-status", + admin_success_sql(), + ) + ).scalar_one_or_none() + assert matched is not None # SQL 侧判成功 + assert derive_admin_outcome(rec.raw_payload, rec.status) == ("success", "门店打烊") # Python 侧一致 + finally: + db.rollback() + db.close() diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index f5c4668..6b86c2e 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -82,21 +82,25 @@ 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-aggregate-success", "success", 101, 0.1), - ("dashboard-aggregate-failed", "failed", 200, 0.2), - ("dashboard-aggregate-cancelled", "cancelled", 300, 0.3), - ("dashboard-aggregate-running", "running", 400, 0.4), + ("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, total_ms, llm_cost_yuan in rows: + 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, ) ) @@ -111,14 +115,15 @@ def test_dashboard_period_comparison_is_aggregated_by_backend( ) assert response.status_code == 200, response.text comparison = response.json()["period"]["comparison"] - assert comparison["total"] == 4 - assert comparison["completed"] == 2 + 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 - assert comparison["success"] == 1 - assert comparison["success_rate"] == 0.3333 - assert comparison["median_duration_ms"] == 151 - assert comparison["p95_duration_ms"] == 195 - assert comparison["token_cost_total_yuan"] == pytest.approx(1.0) + # 分母 = total - cancelled = 5 + assert comparison["success_rate"] == 0.6 + assert comparison["token_cost_total_yuan"] == pytest.approx(1.5) def test_dashboard_coupon_success_rate_excludes_abandoned_sessions( @@ -1202,3 +1207,55 @@ def test_period_signin_boost_moves_to_reward_video_without_double_count( assert coins["reward_video_coin_total"] == 300 assert coins["regular_task_coin_total"] == 50 assert coins["signin_boost_coin_total"] == 200 + + +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 + gap_rec = 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(gap_rec) + # 纯技术故障 + 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 + gap_id = gap_rec.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 + + detail = admin_client.get( + f"/admin/api/comparison-records/{gap_id}", headers=_auth(admin_token) + ) + assert detail.status_code == 200, detail.text + assert detail.json()["admin_status"] == "success" + assert detail.json()["outcome_hint"] == "未找到店" diff --git a/tests/test_comparison_admin_summary.py b/tests/test_comparison_admin_summary.py index cbf43c4..403df96 100644 --- a/tests/test_comparison_admin_summary.py +++ b/tests/test_comparison_admin_summary.py @@ -1,4 +1,4 @@ -"""比价记录页后端分页与概览聚合。""" +"""比价记录页后端概览聚合(admin 口径:外部缺失记为成功)。""" from __future__ import annotations from datetime import UTC, date, datetime @@ -7,13 +7,14 @@ 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( - [], "success", (0.05, 0.5, 0.95, 0.99) + [], admin_success_sql(), (0.05, 0.5, 0.95, 0.99) ) sql = str( stmt.compile( @@ -21,82 +22,106 @@ def test_postgresql_duration_summary_uses_ordered_set_aggregates() -> None: compile_kwargs={"literal_binds": True}, ) ) - assert sql.count("percentile_cont") == 4 - assert "comparison_record.status IN ('success', 'below_minimum')" in sql + # 口径按原始结局 coalesce,而非直接读 status 列 + assert "coalesce" in sql.lower() -def test_summary_uses_only_success_durations_and_filters_beijing_date() -> None: +def test_summary_counts_external_gaps_as_success() -> None: db = SessionLocal() try: + # (trace_id, status 列[已 normalize], record_status[原始结局], total_ms, cost, saved) rows = [ - ("summary-success-a", "success", 1000, 1.0, 100), - ("summary-success-b", "success", 3000, 2.0, 0), - ("summary-failed", "failed", 100_000, 3.0, 0), - ("summary-cancelled", "cancelled", 5000, 4.0, 0), - ("summary-below-minimum", "below_minimum", 5000, None, 0), - ("summary-store-closed", "store_closed", 200_000, None, 0), - ("summary-running", "running", 4000, None, 0), + ("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, total_ms, cost, saved in rows: + 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.add(ComparisonRecord( - trace_id="summary-outside-day", - status="success", - total_ms=999_999, - created_at=datetime(2038, 1, 16, 16, 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["completed"] == 5 - assert summary["success"] == 3 - assert summary["success_rate"] == pytest.approx(3 / 6) - assert summary["avg_token_cost"] == pytest.approx(2.5) - assert summary["lower_price_rate"] == pytest.approx(1 / 3) - assert summary["avg_duration_ms"] == 3000 - assert summary["p5_duration_ms"] == 1200 - assert summary["p50_duration_ms"] == 3000 - assert summary["p95_duration_ms"] == 4800 - assert summary["p99_duration_ms"] == 4960 + assert summary["success"] == 4 + assert summary["completed"] == 5 # 4 成功 + 1 纯 failed assert summary["cancelled"] == 1 - assert summary["cancelled_rate"] == pytest.approx(1 / 7) + 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 - - items, _next_cursor, total = queries.list_comparison_records( - db, date_from=date(2038, 1, 15), date_to=date(2038, 1, 15), limit=20 - ) - assert total == 7 - assert {item.trace_id for item in items} == {row[0] for row in rows} - - success_items, _next_cursor, success_total = queries.list_comparison_records( - db, status="success", date_from=date(2038, 1, 15), date_to=date(2038, 1, 15) - ) - assert success_total == 3 - assert {item.status for item in success_items} == {"success", "below_minimum"} - - failed_items, _next_cursor, failed_total = queries.list_comparison_records( - db, status="failed", date_from=date(2038, 1, 15), date_to=date(2038, 1, 15) - ) - assert failed_total == 2 - assert {item.status for item in failed_items} == {"failed", "store_closed"} - - running_items, _next_cursor, running_total = queries.list_comparison_records( - db, status="running", date_from=date(2038, 1, 15), date_to=date(2038, 1, 15) - ) - assert running_total == 1 - assert running_items[0].status == "running" + finally: + db.rollback() + db.close() + + +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), + )) + # 日期边界:窗口外一条 admin 成功记录(次日),应被日期筛选排除 + db.add(ComparisonRecord( + trace_id="flt-out-of-window", + status="success", + raw_payload={"record_status": "success"}, + created_at=datetime(2039, 3, 11, 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) + ) + # 3 条当天 admin 成功(含 store_not_found);窗口外 flt-out-of-window 被日期排除 + 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()