diff --git a/config.py b/config.py index 87ed2c8..d0d72cb 100644 --- a/config.py +++ b/config.py @@ -414,7 +414,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.790" +SYSTEM_VERSION = "V10.791" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/routes/ai_routes.py b/routes/ai_routes.py index dcc4c81..5ae2f2e 100644 --- a/routes/ai_routes.py +++ b/routes/ai_routes.py @@ -6444,6 +6444,35 @@ def api_pchome_growth_backfill_momo_candidates(): engine.dispose() +@ai_bp.route('/api/ai/pchome-growth/same-item-reconciliation') +@login_required +def api_pchome_growth_same_item_reconciliation(): + """Return public-safe durable readback for the latest bounded runs.""" + try: + limit = max(1, min(int(request.args.get('limit', 20)), 100)) + except (TypeError, ValueError): + limit = 20 + + engine = None + try: + from config import DATABASE_PATH + from services.pchome_growth_same_item_reconciliation import ( + read_latest_reconciliation_receipts, + ) + + engine = _create_icaim_dashboard_engine(DATABASE_PATH) + return jsonify(read_latest_reconciliation_receipts(engine, limit=limit)) + except Exception as exc: + logger.error("[PChomeGrowth] 同商品 reconciliation readback 失敗: %s", exc, exc_info=True) + return jsonify({ + "success": False, + "error": "同商品自動化回讀暫時無法取得。", + }), 500 + finally: + if engine is not None: + engine.dispose() + + @ai_bp.route('/api/ai/pchome-growth/source-contract') @login_required def api_pchome_growth_source_contract(): diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index c4d1df8..664f597 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -1355,6 +1355,8 @@ def _load_pchome_growth_command_center(session): 'mapped_count': 0, 'mapping_rate': 0, 'mapping_rate_width': 0, + 'comparison_revenue_coverage_rate': 0, + 'comparison_ready_sales_7d': 0, 'needs_mapping_count': 0, 'mapping_backlog': { 'direct_mapping_count': 0, @@ -1449,7 +1451,7 @@ def _load_pchome_growth_command_center(session): ) engine = session.get_bind() - payload = build_pchome_growth_opportunities(engine, limit=16) + payload = build_pchome_growth_opportunities(engine, limit=50) stats = payload.get('stats') or {} opportunities = payload.get('opportunities') or [] mapping_summary = summarize_pchome_mapping_backlog(payload) @@ -1521,6 +1523,18 @@ def _load_pchome_growth_command_center(session): sales_delta_value = _to_float(sales_delta_pct) max_sales = max(sales_7d, sales_prev_7d, 1) mapping_rate = _to_float(stats.get('mapping_rate')) or 0 + comparison_contract = stats.get('comparison_metric_contract') or {} + revenue_coverage = comparison_contract.get('revenue_coverage') or {} + comparison_revenue_coverage_rate = ( + _to_float(revenue_coverage.get('rate')) + or _to_float(stats.get('comparison_revenue_coverage_rate')) + or 0 + ) + comparison_ready_sales_7d = ( + _to_float(revenue_coverage.get('numerator')) + or _to_float(stats.get('comparison_ready_sales_7d')) + or 0 + ) action_code_counts = stats.get('action_code_counts') or {} candidate_count = int(stats.get('candidate_count') or 0) mapped_count = int(stats.get('mapped_count') or 0) @@ -1776,6 +1790,11 @@ def _load_pchome_growth_command_center(session): 'mapped_count': mapped_count, 'mapping_rate': round(mapping_rate, 1), 'mapping_rate_width': round(max(0, min(100, mapping_rate)), 1), + 'comparison_revenue_coverage_rate': round( + comparison_revenue_coverage_rate, + 1, + ), + 'comparison_ready_sales_7d': comparison_ready_sales_7d, 'needs_mapping_count': needs_mapping, 'mapping_backlog': mapping_backlog, 'ai_automation_readiness': ai_automation_readiness, diff --git a/run_scheduler.py b/run_scheduler.py index f022adf..c006293 100644 --- a/run_scheduler.py +++ b/run_scheduler.py @@ -93,7 +93,7 @@ def _pchome_growth_backfill_due(*, now=None) -> tuple[bool, str]: status = str(latest.get("status") or "").lower() cooldown_hours = float(os.getenv("PCHOME_GROWTH_BACKFILL_COOLDOWN_HOURS", "4")) - if status == "failed": + if status in {"failed", "degraded", "partial"}: cooldown_hours = float(os.getenv("PCHOME_GROWTH_BACKFILL_RETRY_HOURS", "1")) elapsed_hours = max(0.0, (current - last_run).total_seconds() / 3600) if elapsed_hours >= cooldown_hours: diff --git a/scheduler.py b/scheduler.py index a42fdfa..9441f02 100644 --- a/scheduler.py +++ b/scheduler.py @@ -2517,8 +2517,26 @@ def run_pchome_growth_momo_backfill_task(): before_stats = data.get("before_stats") or {} after_stats = data.get("after_stats") or {} sync_result = data.get("external_offer_sync") or {} + post_verifier = data.get("post_verifier") or {} + receipt_persistence = data.get("receipt_persistence") or {} + identity = data.get("identity") or {} + terminal_status = str(data.get("terminal_status") or "") stats = { - "status": "Success" if result.get("success") else "Skipped", + "status": ( + "Success" + if result.get("success") and terminal_status.startswith("verified") + else "Degraded" + if terminal_status.startswith(("degraded", "partial")) + else "Failed" + if not result.get("success") + else "Skipped" + ), + "contract_version": data.get("contract_version"), + "trace_id": identity.get("trace_id"), + "run_id": identity.get("run_id"), + "work_item_id": identity.get("work_item_id"), + "terminal_status": terminal_status, + "next_machine_action": data.get("next_machine_action"), "scanned_products": data.get("scanned_products", 0), "candidate_count": data.get("candidate_count", 0), "auto_compare_count": data.get("auto_compare_count", 0), @@ -2530,10 +2548,21 @@ def run_pchome_growth_momo_backfill_task(): "mapped_count_before": before_stats.get("mapped_count"), "mapped_count_after": after_stats.get("mapped_count"), "needs_mapping_count_after": after_stats.get("needs_mapping_count"), + "comparison_ready_delta": post_verifier.get("comparison_ready_delta", 0), + "count_coverage_delta": post_verifier.get("count_coverage_delta", 0), + "revenue_coverage_before": (post_verifier.get("before") or {}).get("revenue_coverage_rate"), + "revenue_coverage_after": (post_verifier.get("after") or {}).get("revenue_coverage_rate"), + "revenue_coverage_delta": post_verifier.get("revenue_coverage_delta", 0), + "post_verifier_status": post_verifier.get("status"), + "post_verifier_passed": post_verifier.get("all_checks_passed", False), + "receipt_written_count": receipt_persistence.get("written_count", 0), + "receipt_readback_count": receipt_persistence.get("readback_count", 0), + "receipt_status": receipt_persistence.get("status"), "message": result.get("message"), } logging.info( - "[Scheduler] [PChomeGrowthMomoBackfill] ✅ 完成 | scanned=%s candidates=%s auto=%s written=%s review=%s mapping=%s%%→%s%%", + "[Scheduler] [PChomeGrowthMomoBackfill] 完成 | terminal=%s scanned=%s candidates=%s auto=%s written=%s review=%s mapping=%s%%→%s%% revenue_coverage=%s%%→%s%% receipts=%s/%s", + stats["terminal_status"], stats["scanned_products"], stats["candidate_count"], stats["auto_compare_count"], @@ -2541,6 +2570,10 @@ def run_pchome_growth_momo_backfill_task(): stats["review_count"], stats["mapping_rate_before"], stats["mapping_rate_after"], + stats["revenue_coverage_before"], + stats["revenue_coverage_after"], + stats["receipt_readback_count"], + stats["receipt_written_count"], ) _save_stats('pchome_growth_momo_backfill', stats) diff --git a/services/external_market_offer_service.py b/services/external_market_offer_service.py index 912e629..8e5ccdd 100644 --- a/services/external_market_offer_service.py +++ b/services/external_market_offer_service.py @@ -1107,6 +1107,10 @@ def _targeted_candidate_to_external_offer( "target_gap_pct": candidate.get("target_gap_pct"), "unit_price_comparison": unit_price_comparison, "search_term": candidate.get("target_search_term"), + "same_item_reconciliation": candidate.get("same_item_reconciliation") or {}, + "same_item_candidate_fingerprint": candidate.get( + "same_item_candidate_fingerprint" + ), "tags": [ "identity_v2", "source_targeted_momo_search", @@ -1193,6 +1197,7 @@ def _targeted_review_candidate_to_external_offer( "target_gap_pct": candidate.get("target_gap_pct"), "unit_price_comparison": unit_price_comparison, "search_term": candidate.get("target_search_term"), + "same_item_reconciliation": candidate.get("same_item_reconciliation") or {}, "tags": [ "identity_v2", "source_targeted_momo_search", diff --git a/services/momo_crawler.py b/services/momo_crawler.py index ee6cd0b..a41e3fe 100644 --- a/services/momo_crawler.py +++ b/services/momo_crawler.py @@ -708,7 +708,8 @@ def search_momo_products_for_pchome_products( product_id = str(row.get("product_id") or row.get("goodsCode") or row.get("id") or "").strip() if not product_id: product_id = f"momo_candidate_{len(candidates_by_id)}" - existing = candidates_by_id.get(product_id) + candidate_key = f"{pchome_id}:{product_id}" + existing = candidates_by_id.get(candidate_key) if existing and float(existing.get("target_match_score") or 0.0) >= score: continue @@ -732,7 +733,7 @@ def search_momo_products_for_pchome_products( "target_review_status": review_status, "source_strategy": "pchome_targeted_momo_search", }) - candidates_by_id[product_id] = row + candidates_by_id[candidate_key] = row candidates = sorted( candidates_by_id.values(), diff --git a/services/pchome_growth_momo_backfill_service.py b/services/pchome_growth_momo_backfill_service.py index c1835aa..227a7e8 100644 --- a/services/pchome_growth_momo_backfill_service.py +++ b/services/pchome_growth_momo_backfill_service.py @@ -5,8 +5,19 @@ from __future__ import annotations import os +from datetime import datetime, timezone from typing import Any, Callable +from services.pchome_growth_same_item_reconciliation import ( + CONTRACT_VERSION, + build_coverage_post_verifier, + build_revenue_weighted_targets, + ensure_evidence_receipt_table, + new_reconciliation_identity, + persist_reconciliation_receipts, + verify_same_item_candidates, +) + def _int_env(name: str, default: int, *, minimum: int, maximum: int) -> int: try: @@ -34,28 +45,7 @@ def candidate_auto_compare_type(candidate: dict[str, Any]) -> str: def build_momo_backfill_targets(payload: dict[str, Any], limit: int) -> list[dict[str, Any]]: - opportunities = list((payload or {}).get("opportunities") or []) - targets: list[dict[str, Any]] = [] - for item in opportunities: - action = item.get("recommended_action") or {} - if item.get("external_price"): - continue - if action.get("code") != "map_external_product": - continue - product_id = str(item.get("pchome_product_id") or "").strip() - product_name = str(item.get("product_name") or "").strip() - if not product_id or not product_name: - continue - targets.append({ - "product_id": product_id, - "name": product_name, - "price": item.get("pchome_price"), - "sales_7d": item.get("sales_7d"), - "priority_score": item.get("priority_score"), - }) - if len(targets) >= limit: - break - return targets + return build_revenue_weighted_targets(payload, limit) def _default_build_payload(engine, limit: int) -> dict[str, Any]: @@ -112,64 +102,61 @@ def run_pchome_growth_momo_backfill( sync_func: Callable[[Any, list[dict[str, Any]]], dict[str, Any]] | None = None, sync_review_func: Callable[[Any, list[dict[str, Any]]], dict[str, Any]] | None = None, ) -> dict[str, Any]: - """補高業績 PChome 商品的 MOMO 對應。 - - 不呼叫 LLM,只搜尋 MOMO 候選,並只把可自動判斷的 total_price / unit_price - 寫入 external_offers;需 AI 自動驗證確認的候選會以 needs_review 保存,不進價格判斷。 - """ + """Run a bounded search, independent verifier, apply, and readback loop.""" limit = max(1, min(int(limit or 12), 20)) build_payload = build_payload_func or _default_build_payload search_candidates = search_func or _default_search_candidates sync_candidates = sync_func or _default_sync_candidates sync_review_candidates = sync_review_func or _default_sync_review_candidates + identity = new_reconciliation_identity() + generated_at = datetime.now(timezone.utc).isoformat() - before_payload = build_payload(engine, max(limit, 16)) + # The command center contract is top-50. Ranking a smaller pre-sorted slice + # can hide the highest-revenue unresolved products. + before_payload = build_payload(engine, 50) targets = build_momo_backfill_targets(before_payload, limit) - if not targets: + schema_receipt = ensure_evidence_receipt_table(engine) + if not schema_receipt.get("success"): return { - "success": True, - "message": "目前高業績清單沒有需要補 MOMO 對應的商品。", + "success": False, + "message": "同商品收據資料層尚未就緒,本輪已安全停止且沒有寫入價格。", "data": { + "contract_version": CONTRACT_VERSION, + "identity": identity, + "terminal_status": "blocked_receipt_schema_unavailable", + "target_count": len(targets), "scanned_products": 0, - "target_count": 0, "candidate_count": 0, - "exact_compare_count": 0, - "unit_compare_count": 0, "auto_compare_count": 0, "review_count": 0, - "external_offer_sync": { - "success": True, - "status": "not_needed", - "written_count": 0, - "message": "沒有需要同步的自動候選。", - }, - "review_candidate_sync": { - "success": True, - "status": "not_needed", - "written_count": 0, - "message": "沒有需要保存的待確認候選。", - }, + "external_offer_sync": {"status": "not_executed", "written_count": 0}, + "review_candidate_sync": {"status": "not_executed", "written_count": 0}, + "receipt_schema": schema_receipt, "before_stats": before_payload.get("stats") or {}, "after_stats": before_payload.get("stats") or {}, - "targets": [], + "targets": targets, "review_candidates": [], }, } - search_success, search_message, candidates = search_candidates(targets, limit) + if targets: + search_success, search_message, candidates = search_candidates(targets, limit) + else: + search_success, search_message, candidates = ( + True, + "目前 TOP 50 沒有未解決的同商品對應。", + [], + ) candidates = list(candidates or []) - exact_candidates = [ - item for item in candidates - if candidate_auto_compare_type(item) == "total_price" - ] - unit_candidates = [ - item for item in candidates - if candidate_auto_compare_type(item) == "unit_price" - ] - review_candidates = [ - item for item in candidates - if candidate_auto_compare_type(item) not in {"total_price", "unit_price"} - ] + candidate_verification = verify_same_item_candidates( + targets, + candidates, + identity=identity, + ) + auto_candidates = list(candidate_verification.get("verified_candidates") or []) + exact_candidates = [item for item in auto_candidates if candidate_auto_compare_type(item) == "total_price"] + unit_candidates = [item for item in auto_candidates if candidate_auto_compare_type(item) == "unit_price"] + review_candidates = list(candidate_verification.get("review_candidates") or []) auto_candidates = [*exact_candidates, *unit_candidates] external_offer_sync = { "success": True, @@ -188,19 +175,118 @@ def run_pchome_growth_momo_backfill( if review_candidates: review_candidate_sync = sync_review_candidates(engine, review_candidates) - after_payload = build_payload(engine, max(limit, 16)) + after_payload = build_payload(engine, 50) + post_verifier = build_coverage_post_verifier( + before_payload=before_payload, + after_payload=after_payload, + verified_candidates=auto_candidates, + sync_result=external_offer_sync, + ) written_count = int(external_offer_sync.get("written_count") or 0) + after_snapshot = post_verifier.get("after") or {} + unresolved_after = int(after_snapshot.get("candidate_validation_count") or 0) + int( + after_snapshot.get("unmatched_count") or 0 + ) + if not post_verifier.get("all_checks_passed"): + terminal_status = "partial_post_verifier_failed" + next_machine_action = "retry_bounded_batch_after_drift_diagnosis" + elif written_count > 0: + terminal_status = "verified_with_coverage_gain" + next_machine_action = ( + "continue_next_revenue_weighted_batch" + if unresolved_after + else "maintain_same_item_coverage" + ) + elif unresolved_after: + terminal_status = "degraded_no_safe_candidate" + next_machine_action = "retry_unresolved_batch_with_fresh_source_evidence" + else: + terminal_status = "verified_no_work_remaining" + next_machine_action = "maintain_same_item_coverage" + + run_receipt = { + "contract_version": CONTRACT_VERSION, + "generated_at": generated_at, + "identity": identity, + "source_receipt": { + "source": "momo_targeted_search", + "search_success": bool(search_success), + "search_message": search_message, + "target_count": len(targets), + "candidate_count": len(candidates), + }, + "normalization": { + "target_count": len(targets), + "candidate_count": len(candidates), + "candidate_fingerprint_count": len({ + str(item.get("same_item_candidate_fingerprint") or "") + for item in auto_candidates + if item.get("same_item_candidate_fingerprint") + }), + }, + "candidate_verification": candidate_verification, + "coverage_diff": { + "comparison_ready_delta": post_verifier.get("comparison_ready_delta"), + "count_coverage_delta": post_verifier.get("count_coverage_delta"), + "revenue_coverage_delta": post_verifier.get("revenue_coverage_delta"), + }, + "risk_policy": { + "risk": "medium", + "bounded_target_limit": limit, + "formal_price_write_requires_independent_verifier": True, + "pixelrag_direct_price_write_allowed": False, + }, + "check_mode": { + "receipt_schema_ready": bool(schema_receipt.get("success")), + "verified_candidate_count": len(auto_candidates), + "blocked_candidate_count": int(candidate_verification.get("blocked_candidate_count") or 0), + }, + "execution": { + "idempotent_bounded_execution": True, + "written_count": written_count, + "review_candidate_written_count": int(review_candidate_sync.get("written_count") or 0), + "sync_status": external_offer_sync.get("status"), + }, + "post_verifier": post_verifier, + "terminal": { + "status": terminal_status, + "next_machine_action": next_machine_action, + "unresolved_after": unresolved_after, + }, + } + receipt_persistence = persist_reconciliation_receipts( + engine, + targets=targets, + run_receipt=run_receipt, + ) + if not receipt_persistence.get("success"): + terminal_status = "partial_receipt_persistence_failed" + next_machine_action = "retry_receipt_persistence_before_next_apply" + run_receipt["terminal"] = { + "status": terminal_status, + "next_machine_action": next_machine_action, + "unresolved_after": unresolved_after, + } + + success = bool( + post_verifier.get("all_checks_passed") + and receipt_persistence.get("success") + ) message = ( - f"已掃描 {len(targets)} 個高業績商品,找到 {len(candidates)} 筆 MOMO 候選," - f"自動寫入 {written_count} 筆。" + f"已依業績權重掃描 {len(targets)} 個商品,找到 {len(candidates)} 筆候選," + f"獨立驗證 {len(auto_candidates)} 筆,正式寫入並回讀 {post_verifier.get('readback_pass_count', 0)} 筆。" ) if not search_success and not candidates: - message = search_message or "已搜尋 MOMO,但沒有找到可用候選。" + message = search_message or "已完成 bounded 搜尋,但本輪沒有安全候選。" return { - "success": True, + "success": success, "message": message, "data": { + "contract_version": CONTRACT_VERSION, + "identity": identity, + "terminal_status": terminal_status, + "next_machine_action": next_machine_action, "search_success": bool(search_success), "search_message": search_message, "scanned_products": len(targets), @@ -214,7 +300,11 @@ def run_pchome_growth_momo_backfill( "review_candidate_sync": review_candidate_sync, "before_stats": before_payload.get("stats") or {}, "after_stats": after_payload.get("stats") or {}, - "targets": targets[:8], + "post_verifier": post_verifier, + "receipt_schema": schema_receipt, + "receipt_persistence": receipt_persistence, + "reconciliation_receipt": run_receipt, + "targets": targets, "review_candidates": review_candidates[:8], }, } diff --git a/services/pchome_growth_same_item_reconciliation.py b/services/pchome_growth_same_item_reconciliation.py new file mode 100644 index 0000000..e5ea3c9 --- /dev/null +++ b/services/pchome_growth_same_item_reconciliation.py @@ -0,0 +1,718 @@ +"""Revenue-weighted same-item reconciliation for PChome growth automation.""" + +from __future__ import annotations + +import hashlib +import json +import uuid +from datetime import datetime, timezone +from typing import Any, Callable + +from sqlalchemy import bindparam, inspect, text + + +CONTRACT_VERSION = "pchome_same_item_reconciliation_v1" +WORK_ITEM_ID = "GROWTH-P0-001-B" +ALLOWED_TARGET_ACTIONS = { + "map_external_product", + "review_external_candidate", + "validate_external_source", +} + + +def _float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _canonical_hash(payload: Any) -> str: + encoded = json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + default=str, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def new_reconciliation_identity() -> dict[str, str]: + run_id = uuid.uuid4().hex + return { + "trace_id": uuid.uuid4().hex, + "run_id": run_id, + "work_item_id": WORK_ITEM_ID, + } + + +def build_revenue_weighted_targets( + payload: dict[str, Any], + limit: int, +) -> list[dict[str, Any]]: + """Rank unresolved products by revenue at risk before generic priority.""" + unresolved: list[dict[str, Any]] = [] + for item in list((payload or {}).get("opportunities") or []): + action = item.get("recommended_action") or {} + if item.get("external_price"): + continue + if str(action.get("code") or "") not in ALLOWED_TARGET_ACTIONS: + continue + product_id = str(item.get("pchome_product_id") or "").strip() + product_name = str(item.get("product_name") or "").strip() + if not product_id or not product_name: + continue + sales_7d = max(0.0, _float(item.get("sales_7d"))) + sales_prev_7d = max(0.0, _float(item.get("sales_prev_7d"))) + unresolved.append({ + "product_id": product_id, + "name": product_name, + "price": item.get("pchome_price"), + "sales_7d": sales_7d, + "sales_prev_7d": sales_prev_7d, + "revenue_at_risk": max(sales_7d, sales_prev_7d), + "priority_score": _float(item.get("priority_score")), + "target_state": ( + "candidate_validation" + if item.get("review_candidate") or item.get("market_offers") + else "unmatched" + ), + "existing_review_candidate": item.get("review_candidate") or None, + }) + + if not unresolved: + return [] + + total_revenue_at_risk = sum(item["revenue_at_risk"] for item in unresolved) + max_revenue_at_risk = max(item["revenue_at_risk"] for item in unresolved) or 1.0 + for item in unresolved: + revenue_index = item["revenue_at_risk"] / max_revenue_at_risk * 100 + validation_boost = 5.0 if item["target_state"] == "candidate_validation" else 0.0 + item["revenue_share_pct"] = round( + item["revenue_at_risk"] / max(total_revenue_at_risk, 1.0) * 100, + 3, + ) + item["execution_priority_score"] = round( + revenue_index * 0.75 + item["priority_score"] * 0.20 + validation_boost, + 3, + ) + + unresolved.sort( + key=lambda item: ( + item["execution_priority_score"], + item["revenue_at_risk"], + item["priority_score"], + item["product_id"], + ), + reverse=True, + ) + bounded_limit = max(1, min(int(limit or 12), 20)) + selected = unresolved[:bounded_limit] + for rank, item in enumerate(selected, start=1): + item["execution_rank"] = rank + return selected + + +def _candidate_source_id(candidate: dict[str, Any]) -> str: + return str( + candidate.get("product_id") + or candidate.get("goodsCode") + or candidate.get("id") + or "" + ).strip() + + +def _candidate_name(candidate: dict[str, Any]) -> str: + return str(candidate.get("name") or candidate.get("title") or "").strip() + + +def _verification_rank(candidate: dict[str, Any]) -> tuple[int, float, str]: + compare_type = str(candidate.get("auto_compare_type") or "") + type_rank = 2 if compare_type == "total_price" else 1 + return ( + type_rank, + _float(candidate.get("target_match_score")), + _candidate_source_id(candidate), + ) + + +def verify_same_item_candidates( + targets: list[dict[str, Any]], + candidates: list[dict[str, Any]], + *, + identity: dict[str, str], + score_func: Callable[..., Any] | None = None, + unit_price_func: Callable[..., dict[str, Any]] | None = None, + min_exact_score: float = 0.76, +) -> dict[str, Any]: + """Recompute identity and unit evidence without trusting crawler labels.""" + if score_func is None or unit_price_func is None: + from services.marketplace_product_matcher import ( + build_unit_price_comparison, + score_marketplace_match, + ) + + score_func = score_func or score_marketplace_match + unit_price_func = unit_price_func or build_unit_price_comparison + + target_map = {str(item.get("product_id") or ""): item for item in targets} + verified: list[dict[str, Any]] = [] + blocked: list[dict[str, Any]] = [] + + for candidate in list(candidates or []): + target_id = str(candidate.get("target_pchome_product_id") or "").strip() + target = target_map.get(target_id) + source_id = _candidate_source_id(candidate) + source_name = _candidate_name(candidate) + if not target: + blocked.append({ + "target_pchome_product_id": target_id, + "source_product_id": source_id, + "reason_code": "target_not_in_bounded_batch", + }) + continue + if not source_id or not source_name: + blocked.append({ + "target_pchome_product_id": target_id, + "source_product_id": source_id, + "reason_code": "source_identity_missing", + }) + continue + + source_price = _float(candidate.get("price")) + target_price = _float(target.get("price")) + if source_price <= 0 or target_price <= 0: + blocked.append({ + "target_pchome_product_id": target_id, + "source_product_id": source_id, + "reason_code": "positive_price_evidence_missing", + }) + continue + + diagnostics = score_func( + source_name, + str(target.get("name") or ""), + momo_price=source_price, + competitor_price=target_price, + ) + exact_ready = bool( + not getattr(diagnostics, "hard_veto", True) + and _float(getattr(diagnostics, "score", 0.0)) >= min_exact_score + and getattr(diagnostics, "comparison_mode", "") == "exact_identity" + and getattr(diagnostics, "match_type", "") == "exact" + and getattr(diagnostics, "price_basis", "") == "total_price" + and getattr(diagnostics, "alert_tier", "") == "price_alert_exact" + ) + unit_comparison: dict[str, Any] = {} + unit_ready = False + if ( + getattr(diagnostics, "comparison_mode", "") == "unit_comparable" + or getattr(diagnostics, "price_basis", "") == "unit_price" + ): + unit_comparison = unit_price_func( + source_name, + str(target.get("name") or ""), + momo_price=source_price, + competitor_price=target_price, + ) + unit_ready = bool( + unit_comparison.get("comparable") + and unit_comparison.get("unit_label") + and _float(unit_comparison.get("momo_total_quantity")) > 0 + and _float(unit_comparison.get("competitor_total_quantity")) > 0 + ) + + if exact_ready: + compare_type = "total_price" + price_basis = "total_price" + review_status = "independent_verifier_exact" + elif unit_ready: + compare_type = "unit_price" + price_basis = "unit_price" + review_status = "independent_verifier_unit" + else: + blocked.append({ + "target_pchome_product_id": target_id, + "source_product_id": source_id, + "reason_code": "same_item_verifier_rejected", + "match_score": _float(getattr(diagnostics, "score", 0.0)), + "hard_veto": bool(getattr(diagnostics, "hard_veto", False)), + "reasons": list(getattr(diagnostics, "reasons", ()) or ())[:8], + }) + continue + + claimed_type = str(candidate.get("auto_compare_type") or "manual_review") + fingerprint_payload = { + "target_id": target_id, + "source_id": source_id, + "target_name": target.get("name"), + "source_name": source_name, + "target_price": target_price, + "source_price": source_price, + "compare_type": compare_type, + "match_score": _float(getattr(diagnostics, "score", 0.0)), + } + enriched = dict(candidate) + enriched.update({ + "target_pchome_product_id": target_id, + "target_pchome_name": target.get("name"), + "target_pchome_price": target_price, + "target_match_score": round(_float(getattr(diagnostics, "score", 0.0)), 3), + "target_match_reasons": list(getattr(diagnostics, "reasons", ()) or ()), + "target_comparison_mode": getattr(diagnostics, "comparison_mode", ""), + "target_match_type": getattr(diagnostics, "match_type", ""), + "target_alert_tier": getattr(diagnostics, "alert_tier", ""), + "target_hard_veto": bool(getattr(diagnostics, "hard_veto", False)), + "target_price_basis": price_basis, + "target_unit_price_comparison": unit_comparison, + "target_review_status": review_status, + "auto_compare_type": compare_type, + "can_auto_compare": True, + "same_item_candidate_fingerprint": _canonical_hash(fingerprint_payload)[:24], + "same_item_reconciliation": { + **identity, + "contract_version": CONTRACT_VERSION, + "execution_rank": target.get("execution_rank"), + "execution_priority_score": target.get("execution_priority_score"), + "revenue_share_pct": target.get("revenue_share_pct"), + "target_state_before": target.get("target_state"), + "candidate_claimed_type": claimed_type, + "candidate_claim_drift": claimed_type != compare_type, + "independent_verifier_passed": True, + "candidate_fingerprint": _canonical_hash(fingerprint_payload)[:24], + }, + }) + verified.append(enriched) + + selected_by_target: dict[str, dict[str, Any]] = {} + for candidate in verified: + target_id = str(candidate.get("target_pchome_product_id") or "") + current = selected_by_target.get(target_id) + if current is None or _verification_rank(candidate) > _verification_rank(current): + selected_by_target[target_id] = candidate + + selected = list(selected_by_target.values()) + selected.sort( + key=lambda item: int( + (item.get("same_item_reconciliation") or {}).get("execution_rank") or 999 + ) + ) + + review_by_target: dict[str, dict[str, Any]] = {} + selected_ids = set(selected_by_target) + for candidate in list(candidates or []): + target_id = str(candidate.get("target_pchome_product_id") or "").strip() + if not target_id or target_id in selected_ids or target_id not in target_map: + continue + current = review_by_target.get(target_id) + if current is None or _float(candidate.get("target_match_score")) > _float( + current.get("target_match_score") + ): + review_candidate = dict(candidate) + review_candidate["same_item_reconciliation"] = { + **identity, + "contract_version": CONTRACT_VERSION, + "independent_verifier_passed": False, + "target_state_before": target_map[target_id].get("target_state"), + } + review_by_target[target_id] = review_candidate + + return { + "contract_version": CONTRACT_VERSION, + "candidate_count": len(candidates or []), + "verified_candidate_count": len(verified), + "selected_candidate_count": len(selected), + "blocked_candidate_count": len(blocked), + "claim_drift_count": sum( + 1 + for item in selected + if (item.get("same_item_reconciliation") or {}).get("candidate_claim_drift") + ), + "verified_candidates": selected, + "review_candidates": list(review_by_target.values()), + "blocked_candidates": blocked, + } + + +def build_coverage_snapshot(payload: dict[str, Any]) -> dict[str, Any]: + opportunities = list((payload or {}).get("opportunities") or []) + total_revenue = sum(max(0.0, _float(item.get("sales_7d"))) for item in opportunities) + ready_revenue = sum( + max(0.0, _float(item.get("sales_7d"))) + for item in opportunities + if item.get("external_price") + ) + validation_revenue = sum( + max(0.0, _float(item.get("sales_7d"))) + for item in opportunities + if not item.get("external_price") + and (item.get("review_candidate") or item.get("market_offers")) + ) + unmatched_revenue = max(0.0, total_revenue - ready_revenue - validation_revenue) + stats = (payload or {}).get("stats") or {} + 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", + "comparison_ready_count": int( + partition.get("comparison_ready") or stats.get("mapped_count") or 0 + ), + "candidate_validation_count": int( + partition.get("candidate_validation") or stats.get("candidate_validation_count") or 0 + ), + "unmatched_count": int(partition.get("unmatched") or stats.get("unmatched_count") or 0), + "total_count": int(partition.get("total") or len(opportunities)), + "count_coverage_rate": round(_float(contract.get("rate") or stats.get("mapping_rate")), 3), + "comparison_ready_sales_7d": round(ready_revenue, 2), + "candidate_validation_sales_7d": round(validation_revenue, 2), + "unmatched_sales_7d": round(unmatched_revenue, 2), + "total_sales_7d": round(total_revenue, 2), + "revenue_coverage_rate": round(ready_revenue / max(total_revenue, 1.0) * 100, 3), + } + + +def build_coverage_post_verifier( + *, + before_payload: dict[str, Any], + after_payload: dict[str, Any], + verified_candidates: list[dict[str, Any]], + sync_result: dict[str, Any], +) -> dict[str, Any]: + before = build_coverage_snapshot(before_payload) + after = build_coverage_snapshot(after_payload) + after_by_id = { + str(item.get("pchome_product_id") or ""): item + for item in list((after_payload or {}).get("opportunities") or []) + } + readback: list[dict[str, Any]] = [] + for candidate in verified_candidates: + target_id = str(candidate.get("target_pchome_product_id") or "") + source_id = _candidate_source_id(candidate) + external_price = (after_by_id.get(target_id) or {}).get("external_price") or {} + observed_source_id = str(external_price.get("momo_sku") or "") + passed = bool(external_price and observed_source_id == source_id) + readback.append({ + "target_pchome_product_id": target_id, + "source_product_id": source_id, + "observed_source_product_id": observed_source_id, + "passed": passed, + }) + + selected_count = len(verified_candidates) + written_count = int((sync_result or {}).get("written_count") or 0) + readback_pass_count = sum(1 for item in readback if item["passed"]) + no_write_verified = selected_count == 0 and written_count == 0 + apply_verified = bool( + selected_count > 0 + and written_count == selected_count + and readback_pass_count == selected_count + ) + no_regression = ( + after["comparison_ready_count"] >= before["comparison_ready_count"] + and after["count_coverage_rate"] >= before["count_coverage_rate"] + and after["revenue_coverage_rate"] >= before["revenue_coverage_rate"] + ) + all_checks_passed = bool((no_write_verified or apply_verified) and no_regression) + return { + "contract_version": CONTRACT_VERSION, + "status": ( + "verified" + if apply_verified and no_regression + else "no_write_verified" + if no_write_verified and no_regression + else "failed" + ), + "selected_candidate_count": selected_count, + "written_count": written_count, + "readback_pass_count": readback_pass_count, + "count_coverage_delta": round( + after["count_coverage_rate"] - before["count_coverage_rate"], 3 + ), + "comparison_ready_delta": ( + after["comparison_ready_count"] - before["comparison_ready_count"] + ), + "revenue_coverage_delta": round( + after["revenue_coverage_rate"] - before["revenue_coverage_rate"], 3 + ), + "no_regression": no_regression, + "all_checks_passed": all_checks_passed, + "before": before, + "after": after, + "readback": readback, + } + + +def ensure_evidence_receipt_table(engine) -> dict[str, Any]: + """Idempotently apply the existing additive Migration 045 schema contract.""" + table_name = "external_offer_evidence_receipts" + if inspect(engine).has_table(table_name): + return { + "success": True, + "status": "already_ready", + "table": table_name, + "created": False, + } + + dialect = engine.dialect.name + if dialect == "postgresql": + create_sql = """ + CREATE TABLE IF NOT EXISTS external_offer_evidence_receipts ( + receipt_id TEXT PRIMARY KEY, + pchome_product_id TEXT NOT NULL, + automation_decision TEXT NOT NULL, + payload_hash TEXT NOT NULL, + evidence_delta_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + applied_at TIMESTAMPTZ, + apply_status TEXT NOT NULL DEFAULT 'previewed' + ) + """ + else: + create_sql = """ + CREATE TABLE IF NOT EXISTS external_offer_evidence_receipts ( + receipt_id TEXT PRIMARY KEY, + pchome_product_id TEXT NOT NULL, + automation_decision TEXT NOT NULL, + payload_hash TEXT NOT NULL, + evidence_delta_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + applied_at TEXT, + apply_status TEXT NOT NULL DEFAULT 'previewed' + ) + """ + index_sql = ( + "CREATE INDEX IF NOT EXISTS idx_external_offer_evidence_receipts_pchome_product_id " + "ON external_offer_evidence_receipts (pchome_product_id)", + "CREATE INDEX IF NOT EXISTS idx_external_offer_evidence_receipts_payload_hash " + "ON external_offer_evidence_receipts (payload_hash)", + "CREATE INDEX IF NOT EXISTS idx_external_offer_evidence_receipts_apply_status " + "ON external_offer_evidence_receipts (apply_status)", + ) + try: + with engine.begin() as conn: + conn.execute(text(create_sql)) + for statement in index_sql: + conn.execute(text(statement)) + ready = inspect(engine).has_table(table_name) + return { + "success": ready, + "status": "created_and_verified" if ready else "verification_failed", + "table": table_name, + "created": ready, + } + except Exception as exc: + return { + "success": False, + "status": "apply_failed", + "table": table_name, + "created": False, + "error_type": type(exc).__name__, + } + + +def _target_receipt_decision( + target: dict[str, Any], + run_receipt: dict[str, Any], +) -> tuple[str, str]: + target_id = str(target.get("product_id") or "") + verification = run_receipt.get("candidate_verification") or {} + selected_ids = { + str(item.get("target_pchome_product_id") or "") + for item in verification.get("verified_candidates") or [] + } + blocked_ids = { + str(item.get("target_pchome_product_id") or "") + for item in verification.get("blocked_candidates") or [] + } + passed_ids = { + str(item.get("target_pchome_product_id") or "") + for item in (run_receipt.get("post_verifier") or {}).get("readback") or [] + if item.get("passed") + } + if target_id in passed_ids: + return "promoted_verified", "verified" + if target_id in selected_ids: + return "apply_not_verified", "failed" + if target_id in blocked_ids: + return "candidate_rejected", "no_write" + return "no_candidate", "no_write" + + +def persist_reconciliation_receipts( + engine, + *, + targets: list[dict[str, Any]], + run_receipt: dict[str, Any], +) -> dict[str, Any]: + """Persist one bounded, replayable receipt for each targeted product.""" + identity = run_receipt.get("identity") or {} + run_id = str(identity.get("run_id") or uuid.uuid4().hex) + generated_at = str(run_receipt.get("generated_at") or datetime.now(timezone.utc).isoformat()) + receipt_targets = list(targets or []) or [{ + "product_id": "__run__", + "target_state": "not_needed", + "execution_rank": 0, + "revenue_share_pct": 0, + }] + rows: list[dict[str, Any]] = [] + for target in receipt_targets: + target_id = str(target.get("product_id") or "__run__") + decision, apply_status = _target_receipt_decision(target, run_receipt) + evidence = { + "contract_version": CONTRACT_VERSION, + "identity": identity, + "generated_at": generated_at, + "target": { + "pchome_product_id": target_id, + "target_state_before": target.get("target_state"), + "execution_rank": target.get("execution_rank"), + "execution_priority_score": target.get("execution_priority_score"), + "revenue_share_pct": target.get("revenue_share_pct"), + }, + "source_receipt": run_receipt.get("source_receipt") or {}, + "source_of_truth_diff": run_receipt.get("coverage_diff") or {}, + "ai_decision": decision, + "risk_policy": run_receipt.get("risk_policy") or {}, + "check_mode": run_receipt.get("check_mode") or {}, + "execution": run_receipt.get("execution") or {}, + "post_verifier": run_receipt.get("post_verifier") or {}, + "terminal": run_receipt.get("terminal") or {}, + } + payload_hash = _canonical_hash(evidence) + rows.append({ + "receipt_id": f"sameitem-{_canonical_hash([run_id, target_id])[:24]}", + "pchome_product_id": target_id, + "automation_decision": decision, + "payload_hash": payload_hash, + "evidence_delta_json": json.dumps(evidence, ensure_ascii=False, default=str), + "applied_at": generated_at if apply_status == "verified" else None, + "apply_status": apply_status, + }) + + if engine.dialect.name == "postgresql": + insert_sql = """ + INSERT INTO external_offer_evidence_receipts ( + receipt_id, pchome_product_id, automation_decision, payload_hash, + evidence_delta_json, applied_at, apply_status + ) VALUES ( + :receipt_id, :pchome_product_id, :automation_decision, :payload_hash, + CAST(:evidence_delta_json AS JSONB), CAST(:applied_at AS TIMESTAMPTZ), :apply_status + ) + ON CONFLICT (receipt_id) DO UPDATE SET + automation_decision = EXCLUDED.automation_decision, + payload_hash = EXCLUDED.payload_hash, + evidence_delta_json = EXCLUDED.evidence_delta_json, + applied_at = EXCLUDED.applied_at, + apply_status = EXCLUDED.apply_status + """ + else: + insert_sql = """ + INSERT INTO external_offer_evidence_receipts ( + receipt_id, pchome_product_id, automation_decision, payload_hash, + evidence_delta_json, applied_at, apply_status + ) VALUES ( + :receipt_id, :pchome_product_id, :automation_decision, :payload_hash, + :evidence_delta_json, :applied_at, :apply_status + ) + ON CONFLICT (receipt_id) DO UPDATE SET + automation_decision = excluded.automation_decision, + payload_hash = excluded.payload_hash, + evidence_delta_json = excluded.evidence_delta_json, + applied_at = excluded.applied_at, + apply_status = excluded.apply_status + """ + try: + with engine.begin() as conn: + conn.execute(text(insert_sql), rows) + ids = [row["receipt_id"] for row in rows] + readback_stmt = text( + "SELECT receipt_id, apply_status FROM external_offer_evidence_receipts " + "WHERE receipt_id IN :receipt_ids" + ).bindparams(bindparam("receipt_ids", expanding=True)) + readback = conn.execute(readback_stmt, {"receipt_ids": ids}).mappings().all() + readback_ids = {str(row.get("receipt_id") or "") for row in readback} + return { + "success": len(readback_ids) == len(rows), + "status": "persisted_and_verified" if len(readback_ids) == len(rows) else "readback_mismatch", + "written_count": len(rows), + "readback_count": len(readback_ids), + "receipt_ids": [row["receipt_id"] for row in rows], + } + except Exception as exc: + return { + "success": False, + "status": "persistence_failed", + "written_count": 0, + "readback_count": 0, + "receipt_ids": [], + "error_type": type(exc).__name__, + } + + +def read_latest_reconciliation_receipts(engine, *, limit: int = 20) -> dict[str, Any]: + bounded_limit = max(1, min(int(limit or 20), 100)) + if not inspect(engine).has_table("external_offer_evidence_receipts"): + return { + "contract_version": CONTRACT_VERSION, + "status": "schema_missing", + "receipt_count": 0, + "receipts": [], + } + with engine.connect() as conn: + rows = conn.execute( + text( + "SELECT receipt_id, pchome_product_id, automation_decision, payload_hash, " + "evidence_delta_json, created_at, applied_at, apply_status " + "FROM external_offer_evidence_receipts " + "WHERE receipt_id LIKE 'sameitem-%' " + "ORDER BY created_at DESC LIMIT :limit" + ), + {"limit": bounded_limit}, + ).mappings().all() + receipts: list[dict[str, Any]] = [] + for row in rows: + evidence = row.get("evidence_delta_json") + if isinstance(evidence, str): + try: + evidence = json.loads(evidence) + except json.JSONDecodeError: + evidence = {} + evidence = evidence if isinstance(evidence, dict) else {} + receipts.append({ + "receipt_id": row.get("receipt_id"), + "pchome_product_id": row.get("pchome_product_id"), + "automation_decision": row.get("automation_decision"), + "apply_status": row.get("apply_status"), + "payload_hash": row.get("payload_hash"), + "created_at": str(row.get("created_at") or ""), + "applied_at": str(row.get("applied_at") or ""), + "trace_id": (evidence.get("identity") or {}).get("trace_id"), + "run_id": (evidence.get("identity") or {}).get("run_id"), + "work_item_id": (evidence.get("identity") or {}).get("work_item_id"), + "post_verifier_status": (evidence.get("post_verifier") or {}).get("status"), + }) + return { + "contract_version": CONTRACT_VERSION, + "status": "ready", + "receipt_count": len(receipts), + "latest_run_id": receipts[0].get("run_id") if receipts else None, + "verified_count": sum(1 for item in receipts if item.get("apply_status") == "verified"), + "no_write_count": sum(1 for item in receipts if item.get("apply_status") == "no_write"), + "failed_count": sum(1 for item in receipts if item.get("apply_status") == "failed"), + "receipts": receipts, + } + + +__all__ = ( + "CONTRACT_VERSION", + "WORK_ITEM_ID", + "build_coverage_post_verifier", + "build_coverage_snapshot", + "build_revenue_weighted_targets", + "ensure_evidence_receipt_table", + "new_reconciliation_identity", + "persist_reconciliation_receipts", + "read_latest_reconciliation_receipts", + "verify_same_item_candidates", +) diff --git a/services/pchome_revenue_growth_service.py b/services/pchome_revenue_growth_service.py index 1249fad..89e2426 100644 --- a/services/pchome_revenue_growth_service.py +++ b/services/pchome_revenue_growth_service.py @@ -1013,6 +1013,7 @@ def _score_opportunity( "gap_pct": review_candidate.get("gap_pct"), "match_reasons": review_candidate.get("match_reasons") or [], "product_url": review_candidate.get("product_url"), + "observed_at": review_candidate.get("observed_at"), } data_quality_score = max(data_quality_score, 62) action_code = "review_external_candidate" @@ -1247,6 +1248,30 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] ) mapping_rate = round(mapped_count / max(len(opportunities), 1) * 100, 1) observed_mapping_rate = round(observed_mapping_count / max(len(opportunities), 1) * 100, 1) + total_opportunity_sales_7d = sum( + _to_float(item.get("sales_7d")) for item in opportunities + ) + comparison_ready_sales_7d = sum( + _to_float(item.get("sales_7d")) + for item in opportunities + if item.get("external_price") + ) + candidate_validation_sales_7d = sum( + _to_float(item.get("sales_7d")) + for item in opportunities + if not item.get("external_price") + and (item.get("review_candidate") or item.get("market_offers")) + ) + unmatched_sales_7d = max( + 0.0, + total_opportunity_sales_7d + - comparison_ready_sales_7d + - candidate_validation_sales_7d, + ) + comparison_revenue_coverage_rate = round( + comparison_ready_sales_7d / max(total_opportunity_sales_7d, 1.0) * 100, + 1, + ) action_counts: dict[str, int] = {} action_code_counts: dict[str, int] = {} external_data_source_counts: dict[str, int] = {} @@ -1312,6 +1337,18 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] "rate": _to_float(catalog_mapping_summary.get("active_catalog_mapping_rate")), "scope": "products_with_positive_sales_in_latest_7_day_window", }, + "revenue_coverage": { + "numerator": round(comparison_ready_sales_7d, 2), + "denominator": round(total_opportunity_sales_7d, 2), + "rate": comparison_revenue_coverage_rate, + "meaning": "高優先商品近 7 天業績中,已有正式同商品外部價格證據的占比", + "state_partition": { + "comparison_ready": round(comparison_ready_sales_7d, 2), + "candidate_validation": round(candidate_validation_sales_7d, 2), + "unmatched": round(unmatched_sales_7d, 2), + "total": round(total_opportunity_sales_7d, 2), + }, + }, "platform_runtime": { "numerator": runtime_external_source_count, "denominator": registered_external_source_count, @@ -1342,8 +1379,12 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] "candidate_validation_count": candidate_validation_count, "unmatched_count": unmatched_count, "unresolved_count": max(0, len(opportunities) - mapped_count), - "total_sales_7d": round(sum(_to_float(item.get("sales_7d")) for item in opportunities), 2), - "opportunity_sales_7d": round(sum(_to_float(item.get("sales_7d")) for item in opportunities), 2), + "total_sales_7d": round(total_opportunity_sales_7d, 2), + "opportunity_sales_7d": round(total_opportunity_sales_7d, 2), + "comparison_ready_sales_7d": round(comparison_ready_sales_7d, 2), + "candidate_validation_sales_7d": round(candidate_validation_sales_7d, 2), + "unmatched_sales_7d": round(unmatched_sales_7d, 2), + "comparison_revenue_coverage_rate": comparison_revenue_coverage_rate, "action_counts": action_counts, "action_code_counts": action_code_counts, "external_data_source_counts": external_data_source_counts, diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index f3b0589..2f2cbc2 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -47,10 +47,10 @@ {{ growth.active_product_count | default(0) | number_format }} 個有銷售商品