Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6003d91fc1 |
@@ -0,0 +1,56 @@
|
||||
"""count completed below-minimum comparisons as success
|
||||
|
||||
Revision ID: comparison_below_min_success
|
||||
Revises: limit_policy_global_bundle
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "comparison_below_min_success"
|
||||
down_revision: str | Sequence[str] | None = "limit_policy_global_bundle"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||
|
||||
|
||||
def _comparison_record() -> sa.TableClause:
|
||||
return sa.table(
|
||||
"comparison_record",
|
||||
sa.column("status", sa.String(16)),
|
||||
sa.column("fail_reason", sa.String(256)),
|
||||
sa.column("raw_payload", _JSON),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
comparison_record = _comparison_record()
|
||||
op.execute(
|
||||
comparison_record.update()
|
||||
.where(comparison_record.c.status == "below_minimum")
|
||||
.values(status="success", fail_reason=None)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
comparison_record = _comparison_record()
|
||||
# The write paths deliberately preserve the granular outcome. Restore only
|
||||
# rows that this change normalized, without touching ordinary successes.
|
||||
raw_outcome = sa.func.coalesce(
|
||||
comparison_record.c.raw_payload["record_status"].as_string(),
|
||||
comparison_record.c.raw_payload["status"].as_string(),
|
||||
)
|
||||
op.execute(
|
||||
comparison_record.update()
|
||||
.where(
|
||||
comparison_record.c.status == "success",
|
||||
raw_outcome == "below_minimum",
|
||||
)
|
||||
.values(status="below_minimum")
|
||||
)
|
||||
@@ -97,7 +97,7 @@ class ComparisonRecord(Base):
|
||||
total_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
skipped_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# success(拿到有效对比)/ failed(出错或没采到目标价)
|
||||
# success(流程正常完成,含 below_minimum)/ failed(技术异常或未形成可信结论)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="success")
|
||||
# done 帧 information 文案。成功:"在美团找到同店,到手价 ¥X…";
|
||||
# 失败:具体原因(如"美团、京东外卖均未找到该商品")。前端在比价失败时当原因展示。
|
||||
|
||||
@@ -76,6 +76,19 @@ _BIZ_STATUS_PRIORITY = (
|
||||
)
|
||||
|
||||
|
||||
def _normalize_record_status(status: str | None) -> str | None:
|
||||
"""Map a completed below-minimum outcome onto persisted ``success``.
|
||||
|
||||
``record_status`` describes the business outcome, while
|
||||
``comparison_record.status`` is also the completed-comparison flag used by
|
||||
milestones, stats and idempotent rewards. Reaching the target cart and
|
||||
conclusively reading that it is below the delivery minimum is a completed
|
||||
comparison, not a technical failure. The granular outcome remains in
|
||||
``raw_payload`` and ``platform_results`` for result rendering.
|
||||
"""
|
||||
return "success" if status == "below_minimum" else status
|
||||
|
||||
|
||||
def _store_closed_text(reason: str | None) -> str:
|
||||
"""打烊/暂停营业/休息类 reason 常带脏店名元数据 → 只留结论,套简短模板。"""
|
||||
r = reason or ""
|
||||
@@ -164,9 +177,10 @@ def _derive(payload: ComparisonRecordIn) -> dict:
|
||||
|
||||
is_source_best = best.is_source if best is not None else None
|
||||
|
||||
# status:优先 pricebot record_status(区分 below_minimum/store_closed) → 客户端显式 status
|
||||
# → 兜底"非源且有价"=success/否则 failed。record_status 让"未满起送"不再塌缩成 failed。
|
||||
status = payload.record_status or payload.status
|
||||
# status:优先 pricebot record_status → 客户端显式 status → 兜底派生。
|
||||
# below_minimum 是已形成可信结论的正常完成态,记录级归 success;细分结局仍完整保留在
|
||||
# raw_payload/platform_results,供结果卡展示"未满起送"。
|
||||
status = _normalize_record_status(payload.record_status or payload.status)
|
||||
if status is None:
|
||||
has_valid_target = any(
|
||||
(not r.is_source) and r.price is not None for r in results
|
||||
@@ -201,7 +215,9 @@ def upsert_record(
|
||||
# 单源派生: 与 harvest_done 一致, payload 带 platforms 时从它派生(唯一真相源
|
||||
# _derive_from_platforms), 老客户端不带 platforms 时回退 _derive(从 comparison_results)。
|
||||
if payload.platforms:
|
||||
derived = _derive_from_platforms(payload.platforms, payload.record_status)
|
||||
derived = _derive_from_platforms(
|
||||
payload.platforms, payload.record_status or payload.status
|
||||
)
|
||||
# 对齐 _derive 返回键(#189 fail_reason): 两路径 fields 键集一致, 覆盖已有行时不残留旧值
|
||||
derived["fail_reason"] = (
|
||||
_derive_fail_display(payload.information, payload.platform_results or {})
|
||||
@@ -356,10 +372,10 @@ def _derive_from_results(
|
||||
"saved_amount_cents": saved_amount_cents,
|
||||
"is_source_best": best.get("is_source") if best else None,
|
||||
"store_name": (src_row or {}).get("store_name") or None,
|
||||
# 记录级结局: 优先用 pricebot 下发的 record_status(区分 below_minimum/store_closed,
|
||||
# 不再把"未满起送"塌缩成 failed → 记录页不再误报"网络开小差"); 旧 pricebot 未下发时
|
||||
# 回退老的 success/failed 二态派生, 向后兼容。
|
||||
"status": record_status or ("success" if has_valid_target else "failed"),
|
||||
# below_minimum 已完成到购物车并形成可信结论,记录级计 success;细分结局仍在
|
||||
# raw_payload/platform_results。旧 pricebot 未下发 record_status 时回退二态派生。
|
||||
"status": _normalize_record_status(record_status)
|
||||
or ("success" if has_valid_target else "failed"),
|
||||
}
|
||||
|
||||
|
||||
@@ -412,7 +428,8 @@ def _derive_from_platforms(
|
||||
"saved_amount_cents": saved_amount_cents,
|
||||
"is_source_best": (best.get("role") == "source") if best else None,
|
||||
"store_name": store_name or None,
|
||||
"status": record_status or ("success" if has_valid_target else "failed"),
|
||||
"status": _normalize_record_status(record_status)
|
||||
or ("success" if has_valid_target else "failed"),
|
||||
}
|
||||
|
||||
|
||||
@@ -584,7 +601,8 @@ def harvest_done(
|
||||
行不存在(理论上帧0已建;防御)则新建。"""
|
||||
results = done_params.get("comparison_results") or []
|
||||
# 展示模型统一数组(pricebot 新增, 每平台一行自带 status/is_best): 原样存, 记录页据此直渲染。
|
||||
# record_status: 记录级结局(success/below_minimum/store_closed/failed), 覆盖老二态派生。
|
||||
# record_status: 记录级业务结局(success/below_minimum/store_closed/failed)。其中
|
||||
# below_minimum 是正常完成态,持久化 status 归 success,原值仍随 done_params 落 raw_payload。
|
||||
platforms = done_params.get("platforms") or []
|
||||
record_status = done_params.get("record_status")
|
||||
# 单源派生: platforms(含 pricebot 权威 is_best)是唯一真相源, best_*/source_*/saved/status
|
||||
|
||||
@@ -111,8 +111,9 @@ class ComparisonRecordIn(BaseModel):
|
||||
# status/is_best/display/display_order,记录页据此直渲染。宽松 list[dict] 存(结构由
|
||||
# pricebot 定,server 只原样落库),前端读它、老记录空时回退 comparison_results。
|
||||
platforms: list[dict] = Field(default_factory=list)
|
||||
# 记录级结局(pricebot 下发): success/below_minimum/store_closed/failed。让"未满起送"不再
|
||||
# 被塌缩成 failed。_derive 优先用它、其次客户端 status、再兜底二态派生。
|
||||
# 记录级业务结局(pricebot 下发): success/below_minimum/store_closed/failed。
|
||||
# below_minimum 表示流程正常完成,持久化主状态归 success;原值仍随 raw_payload 落库,
|
||||
# admin/记录页从 platform_results 展示"未满起送"的细分结论。
|
||||
record_status: str | None = None
|
||||
# 逐平台结局摘要(含失败平台的细分原因 status: store_not_found/items_not_found/below_minimum/
|
||||
# unsupported/...)。来自 done.params.platform_results,客户端透传;落 raw_payload(不单列),
|
||||
|
||||
@@ -111,6 +111,116 @@ def test_harvest_done_derives_and_newly_success_once(client) -> None:
|
||||
assert newly2 is False
|
||||
|
||||
|
||||
def test_harvest_done_below_minimum_counts_as_completed_success(client) -> None:
|
||||
"""未达起送是可信业务结论:主状态/完成奖励归 success,细分结局仍留在 raw_payload。"""
|
||||
tid = _tid()
|
||||
done_below_minimum = {
|
||||
"record_status": "below_minimum",
|
||||
"comparison_results": [
|
||||
{
|
||||
"platform_id": "meituan",
|
||||
"platform_name": "美团",
|
||||
"package": "com.sankuai.meituan",
|
||||
"price": 59.0,
|
||||
"is_source": True,
|
||||
"rank": 1,
|
||||
"store_name": "测试店",
|
||||
"items": [{"name": "红乌苏", "qty": 1}],
|
||||
},
|
||||
],
|
||||
"platform_results": {
|
||||
"meituan": {"is_source": True, "status": "source", "price": 59.0},
|
||||
"taobao_flash": {
|
||||
"is_source": False,
|
||||
"status": "below_minimum",
|
||||
"reason": "购物车未达起送门槛(差 ¥25.2)",
|
||||
},
|
||||
},
|
||||
"information": "淘宝未达起送门槛,可加菜凑单后下单",
|
||||
}
|
||||
with SessionLocal() as db:
|
||||
crud.harvest_running(db, trace_id=tid, user_id=None)
|
||||
rec, newly = crud.harvest_done(
|
||||
db, trace_id=tid, user_id=None, done_params=done_below_minimum
|
||||
)
|
||||
|
||||
assert rec.status == "success"
|
||||
assert newly is True
|
||||
assert rec.fail_reason is None
|
||||
assert rec.raw_payload["record_status"] == "below_minimum"
|
||||
assert (
|
||||
rec.raw_payload["platform_results"]["taobao_flash"]["status"]
|
||||
== "below_minimum"
|
||||
)
|
||||
|
||||
|
||||
def test_harvest_done_platforms_below_minimum_counts_as_success(client) -> None:
|
||||
"""新 platforms 单源派生路径也必须执行同一 below_minimum → success 归一化。"""
|
||||
tid = _tid()
|
||||
with SessionLocal() as db:
|
||||
rec, newly = crud.harvest_done(
|
||||
db,
|
||||
trace_id=tid,
|
||||
user_id=None,
|
||||
done_params={
|
||||
"record_status": "below_minimum",
|
||||
"platforms": [
|
||||
{
|
||||
"role": "source",
|
||||
"platform_id": "meituan",
|
||||
"platform_name": "美团",
|
||||
"price": 59.0,
|
||||
"store_name": "测试店",
|
||||
"items": [{"name": "红乌苏", "qty": 1}],
|
||||
},
|
||||
{
|
||||
"role": "target",
|
||||
"platform_id": "taobao_flash",
|
||||
"platform_name": "淘宝",
|
||||
"status": "below_minimum",
|
||||
"price": None,
|
||||
},
|
||||
],
|
||||
"platform_results": {
|
||||
"taobao_flash": {
|
||||
"is_source": False,
|
||||
"status": "below_minimum",
|
||||
"reason": "购物车未达起送门槛",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert rec.status == "success"
|
||||
assert newly is True
|
||||
assert rec.raw_payload["record_status"] == "below_minimum"
|
||||
|
||||
|
||||
def test_legacy_upsert_below_minimum_counts_as_success(client) -> None:
|
||||
"""灰度期客户端直报路径无论走 status 还是 record_status 都不能落第四种主状态。"""
|
||||
tid = _tid()
|
||||
payload = ComparisonRecordIn(
|
||||
trace_id=tid,
|
||||
business_type="food",
|
||||
status="below_minimum",
|
||||
comparison_results=[],
|
||||
platform_results={
|
||||
"taobao_flash": {
|
||||
"is_source": False,
|
||||
"status": "below_minimum",
|
||||
"reason": "购物车未达起送门槛",
|
||||
}
|
||||
},
|
||||
information="淘宝未达起送门槛,可加菜凑单后下单",
|
||||
)
|
||||
with SessionLocal() as db:
|
||||
rec = crud.upsert_record(db, user_id=987654, payload=payload)
|
||||
|
||||
assert rec.status == "success"
|
||||
assert rec.fail_reason is None
|
||||
assert rec.raw_payload["status"] == "below_minimum"
|
||||
|
||||
|
||||
def test_harvest_done_failed_derives_fail_reason(client) -> None:
|
||||
"""failed 记录:记录级 information 笼统,但 fail_reason 从 platform_results 救出具体原因
|
||||
(id 3030 型:美团系统失败 + 京东 items_not_found → 展示京东那条)。"""
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Regression coverage for the below-minimum status data migration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
|
||||
def _migration_module():
|
||||
path = (
|
||||
Path(__file__).parents[1]
|
||||
/ "alembic"
|
||||
/ "versions"
|
||||
/ "comparison_below_minimum_as_success.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location(path.stem, path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_below_minimum_migration_upgrade_and_downgrade() -> None:
|
||||
engine = sa.create_engine("sqlite:///:memory:")
|
||||
metadata = sa.MetaData()
|
||||
records = sa.Table(
|
||||
"comparison_record",
|
||||
metadata,
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("status", sa.String(16), nullable=False),
|
||||
sa.Column("fail_reason", sa.String(256)),
|
||||
sa.Column("raw_payload", sa.JSON, nullable=False),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
module = _migration_module()
|
||||
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
records.insert(),
|
||||
[
|
||||
{
|
||||
"status": "below_minimum",
|
||||
"fail_reason": "旧失败原因",
|
||||
"raw_payload": {"record_status": "below_minimum"},
|
||||
},
|
||||
{
|
||||
"status": "success",
|
||||
"fail_reason": None,
|
||||
"raw_payload": {"record_status": "success"},
|
||||
},
|
||||
{
|
||||
"status": "failed",
|
||||
"fail_reason": "技术异常",
|
||||
"raw_payload": {"status": "failed"},
|
||||
},
|
||||
],
|
||||
)
|
||||
module.op = Operations(MigrationContext.configure(connection))
|
||||
|
||||
module.upgrade()
|
||||
upgraded = connection.execute(
|
||||
sa.select(records.c.status, records.c.fail_reason).order_by(records.c.id)
|
||||
).all()
|
||||
assert upgraded == [
|
||||
("success", None),
|
||||
("success", None),
|
||||
("failed", "技术异常"),
|
||||
]
|
||||
|
||||
module.downgrade()
|
||||
downgraded = connection.execute(
|
||||
sa.select(records.c.status, records.c.fail_reason).order_by(records.c.id)
|
||||
).all()
|
||||
assert downgraded == [
|
||||
("below_minimum", None),
|
||||
("success", None),
|
||||
("failed", "技术异常"),
|
||||
]
|
||||
Reference in New Issue
Block a user