fix: improve review candidate store comparison
All checks were successful
CD Pipeline / deploy (push) Successful in 1m9s

This commit is contained in:
ogt
2026-06-25 14:09:05 +08:00
parent 14f8ba05ec
commit c351bd51b5
5 changed files with 242 additions and 11 deletions

View File

@@ -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 # 用於模板顯示

View File

@@ -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。 |

View File

@@ -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": "待確認同款或色號",

View File

@@ -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 ? `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener">看 MOMO</a>` : '';
const pchomeUrl = safeHttpUrl(row.pchome_url);
const momoUrl = safeHttpUrl(row.momo_url || row.product_url);
const pchomeLink = pchomeUrl ? `<a href="${escapeHtml(pchomeUrl)}" target="_blank" rel="noopener">開賣場</a>` : '<span class="text-muted">待補連結</span>';
const momoLink = momoUrl ? `<a href="${escapeHtml(momoUrl)}" target="_blank" rel="noopener">開賣場</a>` : '<span class="text-muted">待補連結</span>';
const compareButton = pchomeUrl && momoUrl
? `<button type="button" class="btn btn-sm btn-outline-primary" data-pchome-url="${escapeHtml(pchomeUrl)}" data-momo-url="${escapeHtml(momoUrl)}" onclick="openReviewCandidateStores(this)">同時開兩個賣場</button>`
: '';
return `<article class="review-candidate-row" data-pchome-id="${escapeHtml(row.pchome_product_id || '')}">
<div>
<h3 class="review-candidate-title">${escapeHtml(row.pchome_product_name || row.pchome_product_id || 'PChome 商品')}</h3>
<p class="review-candidate-meta">
PChome ${escapeHtml(pchomePrice)} · MOMO ${escapeHtml(momoPrice)}${escapeHtml(gap)}
</p>
<p class="review-candidate-meta">
MOMO${escapeHtml(row.momo_title || row.momo_sku || '未命名候選')} ${url}
</p>
<div class="review-candidate-compare" aria-label="兩家賣場比對">
<section class="review-candidate-store">
<strong>PChome ${pchomeLink}</strong>
<span class="review-candidate-store-price">${escapeHtml(pchomePrice)}</span>
<p class="review-candidate-store-title">${escapeHtml(row.pchome_product_name || row.pchome_product_id || 'PChome 商品')}</p>
</section>
<section class="review-candidate-store">
<strong>MOMO ${momoLink}</strong>
<span class="review-candidate-store-price">${escapeHtml(momoPrice)}</span>
<p class="review-candidate-store-title">${escapeHtml(row.momo_title || row.momo_sku || '未命名候選')}</p>
</section>
</div>
<p class="review-candidate-reason">
可信度 ${score}% · ${escapeHtml(reasons)}
</p>
</div>
<div class="review-candidate-actions">
${compareButton}
<button type="button" class="btn btn-sm btn-success" onclick="updateGrowthReviewCandidate(${Number(row.id)}, 'confirm', this)">確認同款</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="updateGrowthReviewCandidate(${Number(row.id)}, 'reject', this)">不是同款</button>
</div>
@@ -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' ? '確認同款' : '排除候選';

View File

@@ -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