diff --git a/config.py b/config.py index ac9d407..a13e31a 100644 --- a/config.py +++ b/config.py @@ -414,7 +414,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.798" +SYSTEM_VERSION = "V10.799" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/services/pchome_growth_same_item_reconciliation.py b/services/pchome_growth_same_item_reconciliation.py index 323fd92..5beed88 100644 --- a/services/pchome_growth_same_item_reconciliation.py +++ b/services/pchome_growth_same_item_reconciliation.py @@ -496,7 +496,7 @@ def build_coverage_snapshot(payload: dict[str, Any]) -> dict[str, Any]: contract = stats.get("comparison_metric_contract") or {} partition = contract.get("state_partition") or {} return { - "scope": contract.get("scope") or f"top_{len(opportunities)}_revenue_opportunities", + "scope": contract.get("scope") or f"top_{len(opportunities)}_revenue_products", "comparison_ready_count": int( partition.get("comparison_ready") or stats.get("mapped_count") or 0 ), diff --git a/services/pchome_revenue_growth_service.py b/services/pchome_revenue_growth_service.py index 722db31..906129f 100644 --- a/services/pchome_revenue_growth_service.py +++ b/services/pchome_revenue_growth_service.py @@ -4,6 +4,7 @@ from __future__ import annotations +import hashlib import json import logging from datetime import date, datetime, time @@ -202,7 +203,7 @@ def _fetch_sales_rows(conn, limit: int) -> tuple[list[dict[str, Any]], str | Non SELECT * FROM sales WHERE sales_7d > 0 OR sales_prev_7d > 0 - ORDER BY {order_metric} DESC, qty_7d DESC + ORDER BY {order_metric} DESC, qty_7d DESC, pchome_product_id ASC LIMIT :limit """), {"limit": candidate_limit}).mappings().all() @@ -1178,6 +1179,9 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] "opportunities": [], "message": str(exc), } + # Coverage must use a stable revenue-ranked cohort. Mapping status may + # reorder work inside the cohort, but must never remove a success from it. + sales_rows = sales_rows[:limit] sales_ids = [str(row.get("pchome_product_id") or "") for row in sales_rows] external_map, market_offer_map = _fetch_external_offer_maps( conn, @@ -1281,13 +1285,24 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] if runtime_external_source_count < registered_external_source_count: blocked_reason_codes.append("platform_runtime_coverage_gap") + scope_ids = [str(row.get("pchome_product_id") or "") for row in sales_rows] + scope_fingerprint = hashlib.sha256( + json.dumps(scope_ids, ensure_ascii=True, separators=(",", ":")).encode("utf-8") + ).hexdigest()[:24] comparison_metric_contract = { - "version": "pchome_comparison_coverage_v2", - "scope": f"top_{len(opportunities)}_revenue_opportunities", + "version": "pchome_comparison_coverage_v3", + "scope": f"top_{len(opportunities)}_revenue_products", + "scope_membership": { + "selection_stage": "before_action_priority_scoring", + "ranking": "max_current_or_previous_7d_revenue_desc_then_qty_desc_then_product_id_asc", + "member_count": len(scope_ids), + "fingerprint_sha256": scope_fingerprint, + "mapping_status_can_change_membership": False, + }, "numerator": mapped_count, "denominator": len(opportunities), "rate": mapping_rate, - "meaning": "通過同款與價格品質門檻、具正式外部價格證據的高優先商品", + "meaning": "營收排名母體中,通過同款與價格品質門檻且具正式外部價格證據的商品", "state_partition": { "comparison_ready": mapped_count, "candidate_validation": candidate_validation_count, @@ -1314,7 +1329,7 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] "numerator": round(comparison_ready_sales_7d, 2), "denominator": round(total_opportunity_sales_7d, 2), "rate": comparison_revenue_coverage_rate, - "meaning": "高優先商品近 7 天業績中,已有正式同商品外部價格證據的占比", + "meaning": "營收排名母體近 7 天業績中,已有正式同商品外部價格證據的占比", "state_partition": { "comparison_ready": round(comparison_ready_sales_7d, 2), "candidate_validation": round(candidate_validation_sales_7d, 2), diff --git a/tests/test_pchome_revenue_growth_service.py b/tests/test_pchome_revenue_growth_service.py index b495b44..3ce95fd 100644 --- a/tests/test_pchome_revenue_growth_service.py +++ b/tests/test_pchome_revenue_growth_service.py @@ -311,6 +311,45 @@ def test_pchome_growth_coverage_states_are_mutually_exclusive(): assert stats["comparison_metric_contract"]["state_partition"]["mutually_exclusive"] is True +def test_pchome_growth_coverage_scope_is_fixed_before_mapping_priority(): + from services.pchome_revenue_growth_service import build_pchome_growth_opportunities + + engine = create_engine("sqlite:///:memory:") + _seed_growth_tables(engine) + with engine.begin() as conn: + conn.execute(text(""" + INSERT INTO daily_sales_snapshot + ("商品ID", "商品名稱", snapshot_date, "總業績", "數量", "商品館") + VALUES + ('PCH-3', '營收第三名', '2026-06-14', 150000, 30, '保養'), + ('PCH-4', '營收第四名', '2026-06-14', 145000, 29, '保養'), + ('PCH-5', '營收第五名', '2026-06-14', 140000, 28, '保養'), + ('PCH-6', '營收第六名', '2026-06-14', 135000, 27, '保養') + """)) + + payload = build_pchome_growth_opportunities(engine, limit=5) + product_ids = { + item["pchome_product_id"] for item in payload["opportunities"] + } + contract = payload["stats"]["comparison_metric_contract"] + + assert "PCH-1" in product_ids + assert "PCH-2" not in product_ids + assert contract["version"] == "pchome_comparison_coverage_v3" + assert contract["scope"] == "top_5_revenue_products" + assert contract["numerator"] == 1 + assert contract["denominator"] == 5 + assert contract["rate"] == 20.0 + assert contract["scope_membership"] == { + "selection_stage": "before_action_priority_scoring", + "ranking": "max_current_or_previous_7d_revenue_desc_then_qty_desc_then_product_id_asc", + "member_count": 5, + "fingerprint_sha256": contract["scope_membership"]["fingerprint_sha256"], + "mapping_status_can_change_membership": False, + } + assert len(contract["scope_membership"]["fingerprint_sha256"]) == 24 + + def test_momo_review_candidates_return_dual_store_links_and_plain_reasons(): from services.external_market_offer_service import list_momo_review_candidates