From c351bd51b5e80102e5038dd3ba98db7d964f3802 Mon Sep 17 00:00:00 2001 From: ogt Date: Thu, 25 Jun 2026 14:09:05 +0800 Subject: [PATCH] fix: improve review candidate store comparison --- config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 1 + services/external_market_offer_service.py | 69 +++++++++++- templates/ai_intelligence.html | 112 ++++++++++++++++++-- tests/test_pchome_revenue_growth_service.py | 69 ++++++++++++ 5 files changed, 242 insertions(+), 11 deletions(-) diff --git a/config.py b/config.py index 2632fdc..31d8db8 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.671" +SYSTEM_VERSION = "V10.672" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index 0ccaa2a..accb1dc 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -748,3 +748,4 @@ POSTGRES_HOST=momo-db | 2026-06-25 | 匯入頁不可把資料表流程當成使用者主訊息 | V10.669 起雲端匯入與系統匯入完成訊息改說明業績資料新鮮度與更新筆數,不再用「下載→匯入資料庫→刪除」或資料表名稱作為前台重點。 | | 2026-06-25 | 可見操作頁不可把權杖、DB、Agent、Pipeline 當成主語 | V10.670 起 AI 助手、日報、銷售分析、缺貨、部署監控與觀測台頁面進一步改用「用量、產出紀錄、AI 分工、部署流程、知識命中」等營運可讀語言。 | | 2026-06-25 | Google Drive 自動匯入不可在正式排程開瀏覽器 | V10.671 起背景匯入缺少 `config/google_token.json` 時 fail-closed 並提示一次性授權檔轉換;正式 scheduler 不再嘗試 `run_local_server()`,且 token refresh 必須能寫回共用 `config/` 掛載,避免主機重啟後再次出現 `could not locate runnable browser` 或授權檔遺失。 | +| 2026-06-25 | 待確認候選必須能一眼比對雙平台賣場 | V10.672 起 MOMO 待確認候選回傳 PChome/MOMO 兩個賣場連結與白話檢核點,前台改成雙欄比對並提供「同時開兩個賣場」,不再顯示 `variant_selection_review` 等工程 matcher tag。 | diff --git a/services/external_market_offer_service.py b/services/external_market_offer_service.py index fa86c8f..da82ea0 100644 --- a/services/external_market_offer_service.py +++ b/services/external_market_offer_service.py @@ -230,6 +230,64 @@ def _load_json_dict(value: Any) -> dict[str, Any]: return {} +_PCHOME_PRODUCT_URL_BASE = "https://24h.pchome.com.tw/prod/" + +_REVIEW_REASON_LABELS = { + "makeup_catalog_selection_gap": "色號或款式需確認", + "variant_selection_review": "款式、色號或組合需確認", + "focused_exact_identity_ysl_blush_catalog": "品名接近,請確認色號", + "strong_product_line_match": "商品系列接近", + "strong_exact_spec_match": "規格看起來接近", + "identity_review": "同款證據待確認", + "manual_review": "需要人工比對賣場", + "unit_price_review": "容量或單位價需確認", + "unit_price_gap": "容量或單位價需確認", + "catalog_selection_gap": "任選或型錄款需確認", + "commercial_condition_gap": "活動條件或組合內容需確認", + "bundle_review": "組合件數需確認", + "count_review": "件數需確認", +} + + +def _build_pchome_product_url(product_id: Any) -> str | None: + product_id = str(product_id or "").strip() + if not product_id: + return None + if product_id.startswith(("http://", "https://")): + return product_id + return f"{_PCHOME_PRODUCT_URL_BASE}{product_id}" + + +def _review_reason_label(reason: Any) -> str: + key = str(reason or "").strip() + if not key: + return "" + label = _REVIEW_REASON_LABELS.get(key) + if label: + return label + lowered = key.lower() + if any(token in lowered for token in ("variant", "catalog", "selection", "color", "shade")): + return "款式、色號或組合需確認" + if any(token in lowered for token in ("unit", "capacity", "spec")): + return "容量或規格需確認" + if any(token in lowered for token in ("bundle", "count", "set")): + return "組合或件數需確認" + if any(token in lowered for token in ("exact", "identity", "match")): + return "品名或規格接近,仍需人工確認" + if any(token in lowered for token in ("gap", "conflict", "condition")): + return "候選資訊有差異,請比對賣場" + return "候選需要人工確認" + + +def _humanize_review_reasons(reasons: list[Any]) -> list[str]: + labels: list[str] = [] + for reason in reasons: + label = _review_reason_label(reason) + if label and label not in labels: + labels.append(label) + return labels[:3] or ["請比對兩個賣場的品名、容量、色號與組合"] + + def _has_table(conn, table_name: str) -> bool: try: return inspect(conn).has_table(table_name) @@ -1503,25 +1561,32 @@ def list_momo_review_candidates(engine, *, limit: int = 20) -> dict[str, Any]: ] if not reasons: reasons = [str(note) for note in quality_notes if str(note or "").strip()] + reason_labels = _humanize_review_reasons(reasons) + pchome_product_id = row.get("pchome_product_id") + momo_url = row.get("product_url") pchome_price = _to_float(raw_payload.get("pchome_public_price")) momo_price = _to_float(row.get("price")) gap_pct = _to_float(raw_payload.get("target_gap_pct")) items.append({ "id": int(row.get("id")), - "pchome_product_id": row.get("pchome_product_id"), + "pchome_product_id": pchome_product_id, "pchome_product_name": raw_payload.get("pchome_public_name") or "", + "pchome_url": _build_pchome_product_url(pchome_product_id), "pchome_price": pchome_price, "momo_sku": row.get("momo_sku") or row.get("source_product_id"), "momo_title": row.get("title"), "momo_price": momo_price, - "product_url": row.get("product_url"), + "momo_url": momo_url, + "product_url": momo_url, "image_url": row.get("image_url"), "quality_score": round(_to_float(row.get("quality_score")) or 0.0, 2), "alert_tier": raw_payload.get("alert_tier") or "identity_review", "price_basis": raw_payload.get("price_basis") or "manual_review", "gap_pct": gap_pct, "match_reasons": reasons[:5], + "match_reason_labels": reason_labels, + "reason_summary": "、".join(reason_labels), "observed_at": str(row.get("observed_at") or ""), "updated_at": str(row.get("updated_at") or ""), "plain_status": "待確認同款或色號", diff --git a/templates/ai_intelligence.html b/templates/ai_intelligence.html index 66711bb..d4aa552 100644 --- a/templates/ai_intelligence.html +++ b/templates/ai_intelligence.html @@ -2141,7 +2141,7 @@ .review-candidate-row { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr) minmax(124px, auto); gap: 12px; border-bottom: 1px solid rgba(42, 37, 32, 0.08); padding: 10px 0; @@ -2174,11 +2174,67 @@ line-height: 1.4; } + .review-candidate-compare { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 8px; + } + + .review-candidate-store { + border: 1px solid rgba(42, 37, 32, 0.1); + border-radius: 8px; + background: rgba(255, 255, 255, 0.68); + padding: 8px; + min-width: 0; + } + + .review-candidate-store strong { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--momo-text-strong); + font-size: 0.72rem; + line-height: 1.2; + } + + .review-candidate-store-price { + display: block; + color: var(--momo-text-strong); + font-family: var(--momo-font-mono); + font-size: 0.86rem; + font-weight: 900; + margin-top: 4px; + } + + .review-candidate-store-title { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + min-height: 2.5em; + margin: 4px 0 0; + overflow: hidden; + color: var(--momo-text-muted); + font-size: 0.72rem; + line-height: 1.25; + } + + .review-candidate-store a { + white-space: nowrap; + font-size: 0.7rem; + font-weight: 800; + } + .review-candidate-actions { display: grid; gap: 7px; align-content: start; - min-width: 104px; + min-width: 124px; + } + + .review-candidate-actions .btn { + white-space: nowrap; } @media (max-width: 1320px) { @@ -2418,6 +2474,21 @@ grid-template-columns: 1fr; } + .review-candidate-row, + .review-candidate-compare { + grid-template-columns: 1fr; + } + + .review-candidate-actions { + grid-template-columns: 1fr 1fr; + min-width: 0; + width: 100%; + } + + .review-candidate-actions .btn:first-child:nth-last-child(3) { + grid-column: 1 / -1; + } + .growth-detail-action { justify-items: stretch; } @@ -4901,27 +4972,45 @@ function renderGrowthReviewCandidates(rows) { } box.innerHTML = rows.map((row) => { - const reasons = (row.match_reasons || []).slice(0, 3).join('、') || '候選已找到,需確認同款、色號或組合'; + const reasonLabels = Array.isArray(row.match_reason_labels) && row.match_reason_labels.length + ? row.match_reason_labels.slice(0, 3) + : ['請比對兩個賣場的品名、容量、色號與組合']; + const reasons = reasonLabels.join('、'); const gap = row.gap_pct === null || row.gap_pct === undefined ? '' : ` · 參考差距 ${Number(row.gap_pct).toFixed(1)}%`; const score = Number(row.quality_score || 0).toFixed(0); const momoPrice = row.momo_price ? formatMoney(row.momo_price) : '未取得 MOMO 價格'; const pchomePrice = row.pchome_price ? formatMoney(row.pchome_price) : '未取得 PChome 價格'; - const safeUrl = safeHttpUrl(row.product_url); - const url = safeUrl ? `看 MOMO` : ''; + const pchomeUrl = safeHttpUrl(row.pchome_url); + const momoUrl = safeHttpUrl(row.momo_url || row.product_url); + const pchomeLink = pchomeUrl ? `開賣場` : '待補連結'; + const momoLink = momoUrl ? `開賣場` : '待補連結'; + const compareButton = pchomeUrl && momoUrl + ? `` + : ''; return `

${escapeHtml(row.pchome_product_name || row.pchome_product_id || 'PChome 商品')}

PChome ${escapeHtml(pchomePrice)} · MOMO ${escapeHtml(momoPrice)}${escapeHtml(gap)}

-

- MOMO:${escapeHtml(row.momo_title || row.momo_sku || '未命名候選')} ${url} -

+
+
+ PChome ${pchomeLink} + ${escapeHtml(pchomePrice)} +

${escapeHtml(row.pchome_product_name || row.pchome_product_id || 'PChome 商品')}

+
+
+ MOMO ${momoLink} + ${escapeHtml(momoPrice)} +

${escapeHtml(row.momo_title || row.momo_sku || '未命名候選')}

+
+

可信度 ${score}% · ${escapeHtml(reasons)}

+ ${compareButton}
@@ -4929,6 +5018,13 @@ function renderGrowthReviewCandidates(rows) { }).join(''); } +function openReviewCandidateStores(button) { + const pchomeUrl = safeHttpUrl(button?.dataset?.pchomeUrl); + const momoUrl = safeHttpUrl(button?.dataset?.momoUrl); + if (pchomeUrl) window.open(pchomeUrl, '_blank', 'noopener'); + if (momoUrl) window.open(momoUrl, '_blank', 'noopener'); +} + async function updateGrowthReviewCandidate(id, action, button) { if (!id || !action) return; const actionText = action === 'confirm' ? '確認同款' : '排除候選'; diff --git a/tests/test_pchome_revenue_growth_service.py b/tests/test_pchome_revenue_growth_service.py index b6e6e82..57adb35 100644 --- a/tests/test_pchome_revenue_growth_service.py +++ b/tests/test_pchome_revenue_growth_service.py @@ -129,6 +129,51 @@ def _seed_growth_unit_price_external_offer(engine): """)) +def _seed_growth_review_candidate_offer(engine): + with engine.begin() as conn: + conn.execute(text(""" + CREATE TABLE external_offers ( + id INTEGER PRIMARY KEY, + source_code TEXT, + platform_code TEXT, + source_product_id TEXT, + source_offer_key TEXT, + title TEXT, + product_url TEXT, + image_url TEXT, + price REAL, + observed_at TEXT, + expires_at TEXT, + ingestion_method TEXT, + pchome_product_id TEXT, + momo_sku TEXT, + match_status TEXT, + quality_score REAL, + data_quality_status TEXT, + quality_notes_json TEXT, + raw_payload_json TEXT, + updated_at TEXT + ) + """)) + conn.execute(text(""" + INSERT INTO external_offers ( + id, source_code, platform_code, source_product_id, source_offer_key, + title, product_url, image_url, price, observed_at, expires_at, ingestion_method, + pchome_product_id, momo_sku, match_status, quality_score, + data_quality_status, quality_notes_json, raw_payload_json, updated_at + ) + VALUES ( + 1, 'momo_reference', 'momo', 'MOMO-REVIEW', 'momo_reference:MOMO-REVIEW:PCH-REVIEW', + 'MOMO 候選商品', 'https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=MOMO-REVIEW', + NULL, 2300, '2026-06-25 12:00:00', NULL, 'targeted_momo_review', + 'PCH-REVIEW', 'MOMO-REVIEW', 'needs_review', 97, + 'needs_review', '[]', + '{"pchome_public_price": 1430, "pchome_public_name": "PChome 待確認商品", "target_gap_pct": 60.8, "match_reasons": ["variant_selection_review", "focused_exact_identity_ysl_blush_catalog"]}', + '2026-06-25 12:10:00' + ) + """)) + + def test_pchome_growth_opportunities_use_plain_language_and_pause_shopee_coupang(): from services.pchome_revenue_growth_service import build_pchome_growth_opportunities @@ -160,6 +205,24 @@ def test_pchome_growth_opportunities_use_plain_language_and_pause_shopee_coupang assert all("identity" not in " ".join(item["reason_lines"]).lower() for item in payload["opportunities"]) +def test_momo_review_candidates_return_dual_store_links_and_plain_reasons(): + from services.external_market_offer_service import list_momo_review_candidates + + engine = create_engine("sqlite:///:memory:") + _seed_growth_review_candidate_offer(engine) + + payload = list_momo_review_candidates(engine) + + assert payload["success"] is True + assert payload["count"] == 1 + row = payload["rows"][0] + assert row["pchome_url"] == "https://24h.pchome.com.tw/prod/PCH-REVIEW" + assert row["momo_url"] == "https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=MOMO-REVIEW" + assert row["match_reason_labels"] + assert all("_" not in label for label in row["match_reason_labels"]) + assert "色號" in row["reason_summary"] or "款式" in row["reason_summary"] + + def test_pchome_growth_prefers_external_offers_over_legacy_competitor_cache(): from services.pchome_revenue_growth_service import build_pchome_growth_opportunities @@ -428,6 +491,12 @@ def test_ai_intelligence_template_uses_pchome_growth_name_and_endpoint(): assert "MOMO 待確認候選" in template assert "確認同款" in template assert "不是同款" in template + assert "同時開兩個賣場" in template + assert "openReviewCandidateStores" in template + assert "row.match_reason_labels" in template + assert "row.match_reasons" not in template + assert "variant_selection_review" not in template + assert "review-candidate-compare" in template assert "review_external_candidate" in template assert "focusReviewCandidate" in template assert "handleDrilldownKey" in template