diff --git a/app/admin/repositories/coupon_data.py b/app/admin/repositories/coupon_data.py index f7f8179..eb022fa 100644 --- a/app/admin/repositories/coupon_data.py +++ b/app/admin/repositories/coupon_data.py @@ -166,6 +166,17 @@ def _session_to_row( point_stats: dict | None = None, ) -> dict: """CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。""" + # 中途退出可能发生在第一张券产生终态之前,此时没有逐券事件。 + # 明确返回 0/0,让前端区分「退出前无单券结果」与其它状态的埋点缺失。 + if point_stats is not None: + point_success_count = point_stats["succeeded"] + point_total_count = point_stats["tried"] + elif r.status == "abandoned": + point_success_count = 0 + point_total_count = 0 + else: + point_success_count = None + point_total_count = None return { "id": r.id, "trace_id": r.trace_id, @@ -182,8 +193,8 @@ def _session_to_row( "app_env": r.app_env, "started_at": r.started_at, "claimed_count": r.claimed_count, - "point_success_count": point_stats["succeeded"] if point_stats else None, - "point_total_count": point_stats["tried"] if point_stats else None, + "point_success_count": point_success_count, + "point_total_count": point_total_count, "trace_url": r.trace_url, "ad_revenue_yuan": ad_revenue_yuan, } @@ -400,12 +411,15 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict: total = db.execute( select(func.count()).select_from(CouponSession).where(CouponSession.user_id == user_id) ).scalar_one() - rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows]) + trace_ids = [r.trace_id for r in rows] + rev_map = crud_ecpm.revenue_yuan_by_trace(db, trace_ids) + point_stats_map = _point_scores_by_trace(db, trace_ids) return { "items": [ _session_to_row( r, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0), + point_stats=point_stats_map.get(r.trace_id), ) for r in rows ], diff --git a/app/admin/schemas/coupon_data.py b/app/admin/schemas/coupon_data.py index 6b19fd7..fc063e0 100644 --- a/app/admin/schemas/coupon_data.py +++ b/app/admin/schemas/coupon_data.py @@ -79,10 +79,12 @@ class CouponDataRow(BaseModel): started_at: datetime = Field(..., description="发起时刻(明细「时间」列)") claimed_count: int | None = None point_success_count: int | None = Field( - None, description="本次成功单券数(success+already_claimed);无逐券事件为空" + None, + description="本次成功单券数(success+already_claimed);中途退出且无逐券结果为0,其它无事件为空", ) point_total_count: int | None = Field( - None, description="本次尝试单券数(success+already_claimed+failed,不含 skipped);无逐券事件为空" + None, + description="本次尝试单券数(success+already_claimed+failed,不含 skipped);中途退出且无逐券结果为0,其它无事件为空", ) trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id") ad_revenue_yuan: float = Field( diff --git a/tests/test_coupon_point_score.py b/tests/test_coupon_point_score.py index e238f6d..d0fba6a 100644 --- a/tests/test_coupon_point_score.py +++ b/tests/test_coupon_point_score.py @@ -10,6 +10,7 @@ from app.admin.repositories.coupon_data import ( _point_scores_by_trace, coupon_data_report, coupon_point_details, + coupon_user_records, ) from app.admin.security import create_admin_token from app.db.session import SessionLocal @@ -114,6 +115,87 @@ def test_coupon_data_report_returns_scores_without_embedding_details() -> None: db.close() +def test_coupon_data_report_marks_abandoned_without_point_results() -> None: + """中途退出且没有逐券终态时返回0/0,其他状态缺埋点仍保持为空。""" + db = SessionLocal() + report_date = date(2020, 1, 6) + user_id = 910006 + try: + db.add_all([ + CouponSession( + trace_id="point-score-abandoned-without-result", + device_id="score-abandoned-device", + user_id=user_id, + status="abandoned", + app_env="prod", + platforms=["meituan-waimai"], + started_at=datetime(2020, 1, 6, tzinfo=UTC), + started_date=report_date, + ), + CouponSession( + trace_id="point-score-completed-without-result", + device_id="score-completed-device", + user_id=user_id, + status="completed", + app_env="prod", + platforms=["meituan-waimai"], + started_at=datetime(2020, 1, 6, 1, tzinfo=UTC), + started_date=report_date, + ), + CouponSession( + trace_id="point-score-abandoned-with-result", + device_id="score-abandoned-result-device", + user_id=user_id, + status="abandoned", + app_env="prod", + platforms=["meituan-waimai"], + started_at=datetime(2020, 1, 6, 2, tzinfo=UTC), + started_date=report_date, + ), + ]) + db.add_all([ + CouponClaimEvent( + trace_id="point-score-abandoned-with-result", + device_id="score-abandoned-result-device", + coupon_id=f"mt-abandoned-{status}", + claim_date=report_date, + status=status, + ) + for status in ("success", "failed") + ]) + db.flush() + + report = coupon_data_report( + db, + date_from=report_date.isoformat(), + date_to=report_date.isoformat(), + app_env="prod", + ) + rows = {item["trace_id"]: item for item in report["items"]} + abandoned = rows["point-score-abandoned-without-result"] + assert abandoned["point_success_count"] == 0 + assert abandoned["point_total_count"] == 0 + + abandoned_with_result = rows["point-score-abandoned-with-result"] + assert abandoned_with_result["point_success_count"] == 1 + assert abandoned_with_result["point_total_count"] == 2 + + completed = rows["point-score-completed-without-result"] + assert completed["point_success_count"] is None + assert completed["point_total_count"] is None + + user_rows = { + item["trace_id"]: item + for item in coupon_user_records(db, user_id=user_id)["items"] + } + assert user_rows["point-score-abandoned-without-result"]["point_total_count"] == 0 + assert user_rows["point-score-abandoned-with-result"]["point_total_count"] == 2 + assert user_rows["point-score-completed-without-result"]["point_total_count"] is None + finally: + db.rollback() + db.close() + + def test_coupon_point_details_endpoint() -> None: """前端点击使用的接口按约定返回 trace_id 和逐券 items。""" db = SessionLocal()