fix: improve review candidate store comparison
All checks were successful
CD Pipeline / deploy (push) Successful in 1m9s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m9s
This commit is contained in:
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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。 |
|
||||
|
||||
@@ -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": "待確認同款或色號",
|
||||
|
||||
@@ -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' ? '確認同款' : '排除候選';
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user