fix: align pchome growth comparison and promo watch
All checks were successful
CD Pipeline / deploy (push) Successful in 1m9s

This commit is contained in:
ogt
2026-06-26 11:49:07 +08:00
parent 2144ef2102
commit 2888bac597
12 changed files with 508 additions and 35 deletions

View File

@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.704"
SYSTEM_VERSION = "V10.705"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -782,3 +782,5 @@ POSTGRES_HOST=momo-db
| 2026-06-26 | 關鍵決策框架不得因資料不足整段消失 | V10.703 起 AI 流量頁的「情境 × 知識命中矩陣」改為永遠顯示;資料不足時顯示營運空狀態,避免使用者看不到頁面應該如何判讀。 |
| 2026-06-26 | 使用者頁不得把提交、分支、JSON/API 或 raw caller 當主訊息 | V10.704 起成本治理、AI 分工、AI 健康檢查與登入頁再收斂raw caller/server 改成使用情境與服務元件JSONL/API/PostgreSQL/Session/CSRF 等工程語改成檢查紀錄、健康檢查服務、資料服務與工作階段。 |
| 2026-06-26 | 全站 UI/UX 工作重點必須文件化並納入入口索引 | V10.704 起新增 `docs/guides/pchome_growth_ui_ux_guardrails.md` 並由 `AGENTS.md` 索引;所有前台頁面以「提升 PChome 業績、快速判斷、直接下一步」為共同目標,避免後續工作再偏回局部文案修補。 |
| 2026-06-26 | 待確認商品必須能並排比較兩家賣場 | V10.705 起 `ai_intelligence``price_comparison` 的 MOMO 待確認候選都要以 PChome/MOMO 兩欄比較卡呈現,並提供「同時開兩家賣場」主要操作;不得只顯示候選摘要或只放單一平台連結。 |
| 2026-06-26 | 外部促銷活動要進商業情報與 PChome 解法 | V10.705 起商業情報頁新增外部促銷活動監控,從 24h 外部價格/折扣訊號推導外部低價壓力或促銷訊號,並用守價、組合、曝光、會員四類 PChome 業績提升解法承接。 |

View File

@@ -11,6 +11,7 @@
3. 比價、缺貨、匯入、AI 建議、觀測與服務健康頁,都要用同一套 PChome 業績成長語言:主推、守價、補比價、供貨風險、成長缺口、建議路徑、使用情境、服務元件。
4. 資料不足時不能整段消失,要顯示可理解的空狀態與下一步。
5. 不得把工作視窗溝通、部署交接、工程判斷或維護工作摘要搬到前台。
6. 外部促銷活動、折扣、價格壓力與平台活動訊號必須被整理成「PChome 現況對比」與「業績提升解法」,不能只顯示外部事件本身。
## 每次 UI/UX 修改的驗收
@@ -20,6 +21,7 @@
- 相關業務測試:`tests/test_pchome_revenue_growth_service.py`
- 正式 smoke檢查 `/health` 版本、核心頁 HTTP 200、可見文案無 raw terms、靜態資源 HTTP 200
- 如果頁面有 PChome/MOMO 商品比較,必須能一眼看到兩平台價格與可同時開啟的外部賣場連結
- 如果頁面有外部促銷或競品活動訊號,必須至少提供守價、組合、曝光或會員回饋等 PChome 可執行解法
## 判斷標準

View File

@@ -821,7 +821,7 @@ def business_intel_dashboard():
sa_text("""
SELECT cph.sku, cph.competitor_product_name, cph.price,
cph.momo_price, cph.discount_pct, cph.match_score,
cph.crawled_at
cph.crawled_at, cph.source
FROM competitor_price_history cph
WHERE cph.crawled_at >= NOW() - INTERVAL '24 hours'
AND cph.match_score >= 0.7
@@ -839,10 +839,35 @@ def business_intel_dashboard():
'match_score': round(float(r[5] or 0), 3),
'gap': (float(r[3]) - float(r[2])) if (r[2] and r[3]) else None,
'crawled_at': r[6].strftime('%m-%d %H:%M') if r[6] else '',
'source': r[7] or '外部電商',
}
for r in recent_competitor
]
promo_watch_rows = []
for item in recent_competitor_prices:
discount_pct = float(item.get('discount_pct') or 0)
gap = item.get('gap')
gap_value = float(gap) if gap is not None else 0.0
is_external_pressure = gap_value < 0
is_discount_signal = discount_pct >= 5
if not (is_external_pressure or is_discount_signal):
continue
if is_external_pressure:
pressure_label = '外部低價壓力'
recommended_action = '檢查 PChome 售價、折扣券、組合包與商品頁主賣點'
else:
pressure_label = '外部促銷訊號'
recommended_action = '比對活動條件後,安排 PChome 主推曝光或會員回饋'
promo_watch_rows.append({
**item,
'pressure_label': pressure_label,
'recommended_action': recommended_action,
'gap_abs': abs(gap_value),
})
if len(promo_watch_rows) >= 8:
break
# 7. 高 confidence 但未 follow-through (recommendation 沒對應 action_plan)
unfollowed = session.execute(
sa_text(f"""
@@ -870,6 +895,7 @@ def business_intel_dashboard():
verdict_stats=verdict_stats,
match_stats=match_stats,
recent_competitor_prices=recent_competitor_prices,
promo_watch_rows=promo_watch_rows,
unfollowed_count=unfollowed_count,
error=None,
)
@@ -879,6 +905,7 @@ def business_intel_dashboard():
active_page='obs_business_intel', days=days,
rec_by_strategy=[], latest_recommendations=[], loop_records=[],
verdict_stats=[], match_stats=[], recent_competitor_prices=[],
promo_watch_rows=[],
unfollowed_count=0,
error='商業 AI 資料暫時不可用,已切換安全空狀態。',
)

View File

@@ -30,6 +30,55 @@ def _candidate_auto_compare_type(item: dict) -> str:
return "manual_review"
def _build_pchome_product_url(product_id: str) -> str:
product_id = str(product_id or "").strip()
return f"https://24h.pchome.com.tw/prod/{product_id}" if product_id else ""
def _humanize_targeted_review_reasons(candidate: dict) -> list[str]:
labels = []
score = candidate.get("target_match_score")
try:
score_pct = round(float(score or 0) * 100)
except (TypeError, ValueError):
score_pct = 0
if score_pct > 0:
labels.append(f"可信度 {score_pct}%")
reason_map = {
"variant_selection_review": "需確認色號",
"strong_exact_spec_match": "規格接近",
"strong_product_line_match": "系列接近",
"count_conflict": "組合數量需確認",
"unit_comparable": "可用單位價判斷",
"makeup_catalog_selection_gap": "款式需確認",
}
for reason in candidate.get("target_match_reasons") or []:
label = reason_map.get(str(reason or "").strip())
if label and label not in labels:
labels.append(label)
return labels or ["需人工確認同款"]
def _present_momo_review_candidate(candidate: dict) -> dict:
"""Return only user-facing review candidate fields for the UI."""
pchome_product_id = str(candidate.get("target_pchome_product_id") or "").strip()
return {
"product_id": candidate.get("product_id") or candidate.get("goodsCode") or candidate.get("id"),
"name": candidate.get("name") or candidate.get("title") or "",
"price": candidate.get("price"),
"product_url": candidate.get("product_url") or candidate.get("url") or "",
"image_url": candidate.get("image_url") or "",
"target_pchome_product_id": pchome_product_id,
"target_pchome_name": candidate.get("target_pchome_name") or pchome_product_id,
"target_pchome_price": candidate.get("target_pchome_price"),
"target_pchome_url": candidate.get("target_pchome_url") or _build_pchome_product_url(pchome_product_id),
"target_gap_pct": candidate.get("target_gap_pct"),
"target_match_score": candidate.get("target_match_score"),
"target_match_reason_labels": _humanize_targeted_review_reasons(candidate),
}
def _sync_targeted_candidates_to_external_offers(candidates: list[dict]) -> dict:
from database.manager import DatabaseManager
from services.external_market_offer_service import sync_targeted_momo_candidates_to_external_offers
@@ -239,10 +288,14 @@ def fetch_momo_for_pchome_products():
item for item in products
if _candidate_auto_compare_type(item) == "unit_price"
]
review_candidates = [
raw_review_candidates = [
item for item in products
if _candidate_auto_compare_type(item) not in {"total_price", "unit_price"}
]
review_candidates = [
_present_momo_review_candidate(item)
for item in raw_review_candidates
]
external_offer_sync = {
"success": True,
"status": "not_requested",

View File

@@ -49,7 +49,7 @@ OBSERVABILITY_PAGES = (
"/observability/business_intel",
"商業面 × AI",
"商業",
("商業面 × AI", "AI", "競品"),
("外部促銷活動監控", "PChome 解法", "競品"),
),
ObservabilityPage(
"templates/admin/host_health.html",

View File

@@ -177,6 +177,101 @@
font-weight: 850;
}
.biz-promo-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: .75rem;
}
.biz-solution-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: .6rem;
margin-bottom: .85rem;
}
.biz-solution-card {
min-height: 92px;
padding: .72rem;
border: 1px solid rgba(42, 37, 32, 0.09);
border-radius: 16px;
background: rgba(250, 247, 240, 0.68);
}
.biz-solution-card strong {
display: block;
color: var(--biz-ink);
font-size: .82rem;
font-weight: 950;
}
.biz-solution-card span {
display: block;
margin-top: .35rem;
color: var(--biz-muted);
font-size: .72rem;
font-weight: 780;
line-height: 1.35;
}
.biz-promo-card {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(150px, .42fr);
gap: .8rem;
align-items: start;
padding: .95rem;
border: 1px solid rgba(201, 100, 66, 0.16);
border-radius: 18px;
background: rgba(255, 250, 243, 0.9);
}
.biz-promo-source {
display: flex;
flex-wrap: wrap;
gap: .45rem;
align-items: center;
margin-bottom: .45rem;
}
.biz-promo-title {
color: var(--biz-ink);
font-size: .95rem;
font-weight: 950;
line-height: 1.35;
}
.biz-promo-solution {
margin-top: .55rem;
color: var(--biz-muted);
font-size: .82rem;
font-weight: 780;
line-height: 1.5;
}
.biz-promo-metrics {
display: grid;
gap: .45rem;
}
.biz-promo-metric {
padding: .55rem;
border-radius: 14px;
background: rgba(201, 100, 66, 0.07);
}
.biz-promo-metric strong {
display: block;
color: var(--biz-ink);
font-family: var(--momo-font-mono, monospace);
font-size: .92rem;
}
.biz-promo-metric span {
color: var(--biz-muted);
font-size: .68rem;
font-weight: 900;
}
.biz-layout {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(340px, .9fr);
@@ -400,7 +495,10 @@
.biz-signal-grid,
.biz-strategy-grid,
.biz-decision-card {
.biz-solution-grid,
.biz-decision-card,
.biz-promo-grid,
.biz-promo-card {
grid-template-columns: 1fr;
}
@@ -500,6 +598,48 @@
</section>
{% endif %}
<section class="biz-panel">
<div class="biz-panel-head">
<div>
<h3>外部促銷活動監控</h3>
<p>AI Agent 以外部價格與折扣訊號監控促銷壓力,先對照 PChome 現況,再提出業績提升解法。</p>
</div>
<span class="biz-badge {% if promo_watch_rows %}warn{% else %}good{% endif %}">{{ promo_watch_rows|length }} 筆訊號</span>
</div>
<div class="biz-panel-body">
<div class="biz-solution-grid" aria-label="PChome 業績提升解法矩陣">
<div class="biz-solution-card"><strong>守價</strong><span>確認售價、折扣券與毛利底線,避免被外部促銷壓住。</span></div>
<div class="biz-solution-card"><strong>組合</strong><span>用組合包、贈品或加價購拉高感知價值,不只跟價。</span></div>
<div class="biz-solution-card"><strong>曝光</strong><span>把 PChome 有利商品推到搜尋、首頁、EDM 或活動入口。</span></div>
<div class="biz-solution-card"><strong>會員</strong><span>用會員回饋、免運或售後服務對抗短期低價。</span></div>
</div>
{% if promo_watch_rows %}
<div class="biz-promo-grid">
{% for r in promo_watch_rows %}
<article class="biz-promo-card">
<div>
<div class="biz-promo-source">
<span class="biz-badge warn">{{ r.pressure_label }}</span>
<span class="biz-badge">{{ r.source or '外部電商' }}</span>
<span class="text-muted small">{{ r.crawled_at or '-' }}</span>
</div>
<div class="biz-promo-title">{{ r.product_name or r.sku }}</div>
<div class="biz-promo-solution">PChome 解法:{{ r.recommended_action }}</div>
</div>
<div class="biz-promo-metrics">
<div class="biz-promo-metric"><strong>{{ '%.0f'|format(r.discount_pct or 0) }}%</strong><span>外部折扣</span></div>
<div class="biz-promo-metric"><strong>{{ '%.0f'|format(r.gap_abs or 0) }}</strong><span>價差壓力</span></div>
<div class="biz-promo-metric"><strong>{{ '%.2f'|format(r.match_score or 0) }}</strong><span>同款可信度</span></div>
</div>
</article>
{% endfor %}
</div>
{% else %}
<div class="biz-empty">目前尚未捕捉到可判讀的外部促銷壓力AI Agent 會持續觀察外部價格、折扣與同款可信度,捕捉到訊號後先進這裡,再進作戰清單。</div>
{% endif %}
</div>
</section>
<div class="biz-layout">
<main>
<section class="biz-panel">

View File

@@ -2129,12 +2129,60 @@
gap: 8px;
}
.review-candidate-flow {
display: grid;
gap: 8px;
}
.review-candidate-flow-step {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
gap: 9px;
align-items: center;
min-height: 54px;
padding: 9px 10px;
border: 1px solid rgba(42, 37, 32, 0.09);
border-radius: 8px;
background: rgba(250, 247, 240, 0.72);
}
.review-candidate-flow-index {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 999px;
background: rgba(172, 92, 58, 0.13);
color: var(--momo-warm-rust);
font-family: var(--momo-font-mono);
font-size: 0.74rem;
font-weight: 900;
}
.review-candidate-flow-step strong {
display: block;
color: var(--momo-text-strong);
font-size: 0.78rem;
font-weight: 900;
line-height: 1.2;
}
.review-candidate-flow-step span:last-child {
display: block;
margin-top: 2px;
color: var(--momo-text-muted);
font-size: 0.7rem;
font-weight: 780;
line-height: 1.25;
}
.review-candidate-result {
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.76);
min-height: 212px;
max-height: 360px;
min-height: 246px;
max-height: 520px;
overflow: auto;
padding: 10px;
}
@@ -2144,7 +2192,7 @@
grid-template-columns: minmax(0, 1fr) minmax(148px, auto);
gap: 14px;
border-bottom: 1px solid rgba(42, 37, 32, 0.08);
padding: 12px 0;
padding: 14px 0;
}
.review-candidate-row:last-child {
@@ -2184,18 +2232,28 @@
.review-candidate-compare {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 8px;
gap: 10px;
margin-top: 10px;
}
.review-candidate-store {
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.68);
padding: 10px;
padding: 11px;
min-width: 0;
}
.review-candidate-store.is-pchome {
border-color: rgba(46, 125, 91, 0.2);
background: rgba(235, 248, 241, 0.62);
}
.review-candidate-store.is-momo {
border-color: rgba(201, 100, 66, 0.2);
background: rgba(255, 244, 239, 0.58);
}
.review-candidate-store strong {
display: flex;
align-items: center;
@@ -2260,6 +2318,16 @@
font-weight: 800;
}
.review-candidate-store-label {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--momo-text-muted);
font-size: 0.68rem;
font-weight: 900;
letter-spacing: 0.02em;
}
.review-candidate-pill {
display: inline-flex;
align-items: center;
@@ -2310,6 +2378,13 @@
white-space: nowrap;
}
.review-candidate-actions .review-candidate-primary {
border-color: rgba(49, 113, 234, 0.32);
background: rgba(49, 113, 234, 0.08);
color: #2557b8;
font-weight: 900;
}
@media (max-width: 1320px) {
.growth-command-kpi-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -3129,7 +3204,7 @@
<div class="card-header d-flex justify-content-between align-items-center py-2 bg-white border-bottom">
<span class="ai-panel-title">
<i class="fas fa-list-check"></i>MOMO 待確認候選
<small class="text-muted fw-normal ms-2">先確認同款,再進價格判斷</small>
<small class="text-muted fw-normal ms-2">並排看兩家賣場,確認同款後才進作戰</small>
</span>
<button class="btn btn-outline-secondary btn-sm ai-action-btn" onclick="loadGrowthReviewCandidates(true)">
<i class="fas fa-redo me-1"></i>更新候選
@@ -3146,8 +3221,19 @@
<strong id="reviewCandidateReadyHint">0</strong>
<span>確認後可進作戰</span>
</div>
<div class="growth-action-hint">
確認同款後才會進入 MOMO 價格參考;不確定色號、容量或組合時請先排除。
<div class="review-candidate-flow" aria-label="待確認候選處理流程">
<div class="review-candidate-flow-step">
<span class="review-candidate-flow-index">1</span>
<span><strong>同時看賣場</strong><span>先開 PChome 與 MOMO 商品頁</span></span>
</div>
<div class="review-candidate-flow-step">
<span class="review-candidate-flow-index">2</span>
<span><strong>確認規格</strong><span>比對品名、容量、色號與組合</span></span>
</div>
<div class="review-candidate-flow-step">
<span class="review-candidate-flow-index">3</span>
<span><strong>送進作戰</strong><span>同款才進價格壓力與主推判斷</span></span>
</div>
</div>
</div>
<div class="review-candidate-result" id="growthReviewCandidateList">
@@ -5096,7 +5182,7 @@ function renderGrowthReviewCandidates(rows) {
? `<img src="${escapeHtml(momoImageUrl)}" alt="MOMO 商品圖" loading="lazy">`
: '<i class="fas fa-store"></i>';
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)"><i class="fas fa-up-right-from-square me-1"></i>雙開賣場</button>`
? `<button type="button" class="btn btn-sm review-candidate-primary" data-pchome-url="${escapeHtml(pchomeUrl)}" data-momo-url="${escapeHtml(momoUrl)}" onclick="openReviewCandidateStores(this)"><i class="fas fa-up-right-from-square me-1"></i>同時開兩家賣場</button>`
: '';
return `<article class="review-candidate-row" data-pchome-id="${escapeHtml(row.pchome_product_id || '')}">
<div>
@@ -5106,25 +5192,27 @@ function renderGrowthReviewCandidates(rows) {
<span class="review-candidate-pill">同款可信度 ${score}%</span>
</div>
<div class="review-candidate-compare" aria-label="兩家賣場比對">
<section class="review-candidate-store">
<section class="review-candidate-store is-pchome">
<div class="review-candidate-store-head">
<span class="review-candidate-thumb"><i class="fas fa-store"></i></span>
<div>
<strong>PChome ${pchomeLink}</strong>
<span class="review-candidate-store-label"><i class="fas fa-bolt"></i>PChome</span>
<span class="review-candidate-store-price">${escapeHtml(pchomePrice)}</span>
</div>
</div>
<p class="review-candidate-store-title" title="${escapeHtml(row.pchome_product_name || '')}">${escapeHtml(row.pchome_product_name || row.pchome_product_id || 'PChome 商品')}</p>
<div class="mt-2">${pchomeLink}</div>
</section>
<section class="review-candidate-store">
<section class="review-candidate-store is-momo">
<div class="review-candidate-store-head">
<span class="review-candidate-thumb">${momoThumb}</span>
<div>
<strong>MOMO ${momoLink}</strong>
<span class="review-candidate-store-label"><i class="fas fa-store"></i>MOMO</span>
<span class="review-candidate-store-price">${escapeHtml(momoPrice)}</span>
</div>
</div>
<p class="review-candidate-store-title" title="${escapeHtml(row.momo_title || '')}">${escapeHtml(row.momo_title || row.momo_sku || '未命名候選')}</p>
<div class="mt-2">${momoLink}</div>
</section>
</div>
<div class="review-candidate-reason-chips" aria-label="需要確認的重點">

View File

@@ -473,7 +473,14 @@
color: var(--momo-success-text);
}
.price-unit-list {
.price-note.is-review {
background: rgba(255, 248, 231, 0.95);
border-color: rgba(210, 158, 58, 0.28);
color: #7a5209;
}
.price-unit-list,
.price-review-list {
display: grid;
gap: 8px;
margin-top: 8px;
@@ -512,6 +519,80 @@
white-space: nowrap;
}
.price-review-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: start;
padding: 10px;
border: 1px solid rgba(210, 158, 58, 0.24);
border-radius: var(--momo-radius-sm);
background: rgba(255, 255, 255, 0.58);
}
.price-review-compare {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
min-width: 0;
}
.price-review-store {
min-width: 0;
padding: 8px;
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: var(--momo-radius-sm);
background: rgba(250, 247, 240, 0.64);
}
.price-review-store.is-pchome {
border-color: rgba(46, 125, 91, 0.2);
background: rgba(235, 248, 241, 0.62);
}
.price-review-store.is-momo {
border-color: rgba(201, 100, 66, 0.2);
background: rgba(255, 244, 239, 0.58);
}
.price-review-platform {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--momo-text-secondary);
font-size: 0.68rem;
font-weight: 900;
}
.price-review-name {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
min-height: 2.5em;
margin-top: 5px;
overflow: hidden;
color: var(--momo-text-primary);
font-size: 0.78rem;
font-weight: 850;
line-height: 1.25;
}
.price-review-price {
display: block;
margin-top: 6px;
color: var(--momo-text-primary);
font-family: var(--momo-font-mono, monospace);
font-size: 0.88rem;
font-weight: 900;
}
.price-review-actions {
display: grid;
gap: 7px;
min-width: 128px;
}
.price-table-head th {
background: var(--momo-bg-paper) !important;
color: var(--momo-text-secondary) !important;
@@ -610,6 +691,12 @@
grid-template-columns: 1fr;
}
.price-review-item,
.price-review-compare,
.price-review-actions {
grid-template-columns: 1fr;
}
.price-unit-metric {
justify-content: flex-start;
white-space: normal;
@@ -789,7 +876,7 @@
<span class="price-count-badge is-muted ms-1" id="momoReviewCount">0 筆需確認</span>
</div>
<div class="price-note is-unit mt-3 py-2" id="momoUnitComparePanel" style="display:none;"></div>
<div class="price-note mt-3 py-2" id="momoReviewPanel" style="display:none;"></div>
<div class="price-note is-review mt-3 py-2" id="momoReviewPanel" style="display:none;"></div>
</div>
</div>
</div>
@@ -1230,6 +1317,18 @@ La Roche-Posay 安得利防曬液 50ml,920
panel.replaceChildren(heading, list);
}
function buildPchomeProductUrl(productId) {
const id = String(productId || '').trim();
return id ? `https://24h.pchome.com.tw/prod/${encodeURIComponent(id)}` : '';
}
function reviewCandidateReasonLabels(item) {
const labels = Array.isArray(item?.target_match_reason_labels)
? item.target_match_reason_labels.filter(Boolean)
: [];
return labels.length ? labels : ['需人工確認同款'];
}
function renderMomoReviewPanel() {
const countBadge = document.getElementById('momoReviewCount');
const panel = document.getElementById('momoReviewPanel');
@@ -1240,16 +1339,56 @@ La Roche-Posay 安得利防曬液 50ml,920
if (!momoReviewCandidates.length) {
panel.style.display = 'none';
panel.textContent = '';
panel.replaceChildren();
return;
}
const sampleNames = momoReviewCandidates
.slice(0, 3)
.map(item => item.name || item.title || '未命名商品')
.join('、');
panel.style.display = 'block';
panel.textContent = `找到 ${momoReviewCandidates.length} 筆需人工確認候選,可能是單品、組合或單位價差異:${sampleNames}`;
const heading = document.createElement('strong');
heading.textContent = `待確認 ${momoReviewCandidates.length} 筆:先並排看兩家賣場`;
const list = document.createElement('div');
list.className = 'price-review-list';
momoReviewCandidates.slice(0, 5).forEach(item => {
const pchomeId = String(item.target_pchome_product_id || '').trim();
const pchomeName = item.target_pchome_name || pchomeId || 'PChome 商品';
const momoName = item.name || item.title || item.product_id || 'MOMO 候選';
const pchomeUrl = toSafeUrl(item.target_pchome_url || buildPchomeProductUrl(pchomeId));
const momoUrl = toSafeUrl(item.product_url || item.url || '');
const pchomePrice = item.target_pchome_price ? formatMoney(item.target_pchome_price) : '待補價格';
const momoPrice = item.price ? formatMoney(item.price) : '待補價格';
const reasons = reviewCandidateReasonLabels(item).slice(0, 2).join('、') || '請確認規格';
const row = document.createElement('div');
row.className = 'price-review-item';
row.innerHTML = `
<div class="price-review-compare">
<section class="price-review-store is-pchome">
<div class="price-review-platform">
<span>PChome</span>
${pchomeUrl ? `<a href="${escapeHtml(pchomeUrl)}" target="_blank" rel="noopener noreferrer">賣場</a>` : '<span>待補連結</span>'}
</div>
<div class="price-review-name" title="${escapeHtml(pchomeName)}">${escapeHtml(pchomeName)}</div>
<span class="price-review-price">${escapeHtml(pchomePrice)}</span>
</section>
<section class="price-review-store is-momo">
<div class="price-review-platform">
<span>MOMO</span>
${momoUrl ? `<a href="${escapeHtml(momoUrl)}" target="_blank" rel="noopener noreferrer">賣場</a>` : '<span>待補連結</span>'}
</div>
<div class="price-review-name" title="${escapeHtml(momoName)}">${escapeHtml(momoName)}</div>
<span class="price-review-price">${escapeHtml(momoPrice)}</span>
</section>
</div>
<div class="price-review-actions">
${pchomeUrl && momoUrl ? `<button type="button" class="btn btn-sm btn-outline-primary" data-pchome-url="${escapeHtml(pchomeUrl)}" data-momo-url="${escapeHtml(momoUrl)}" onclick="openComparisonStores(this)"><i class="fas fa-up-right-from-square me-1"></i>同時開兩家賣場</button>` : ''}
<span class="price-action-pill is-watch">${escapeHtml(reasons)}</span>
</div>
`;
list.appendChild(row);
});
panel.replaceChildren(heading, list);
}
async function parseMomoExcel() {
@@ -1721,7 +1860,7 @@ La Roche-Posay 安得利防曬液 50ml,920
</small>
<small class="text-muted">${pchomeId}</small>
</div>
${pchomeUrl ? `<a href="${pchomeUrl}" target="_blank" class="btn btn-sm btn-outline-info ms-1" title="前往 PChome 查看"><i class="fas fa-external-link-alt"></i></a>` : ''}
${pchomeUrl ? `<a href="${escapeHtml(pchomeUrl)}" target="_blank" class="btn btn-sm btn-outline-info ms-1" title="前往 PChome 查看"><i class="fas fa-external-link-alt"></i></a>` : ''}
</div>
</td>
<td>
@@ -1733,11 +1872,11 @@ La Roche-Posay 安得利防曬液 50ml,920
<small class="text-muted">${momoId}</small>
</div>
${momoUrl ? `<a
href="${momoUrl}"
href="${escapeHtml(momoUrl)}"
target="_blank"
class="btn btn-sm btn-outline-warning ms-1 momo-tracked-link"
title="前往 MOMO 查看"
data-momo-original-url="${momoUrl}"
data-momo-original-url="${escapeHtml(momoUrl)}"
data-track-platform="momo"
data-track-source="price-comparison"
data-track-product-id="${momoId}"
@@ -1865,7 +2004,7 @@ La Roche-Posay 安得利防曬液 50ml,920
try {
const parsed = new URL(target, location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return escapeHtml(parsed.href);
return parsed.href;
}
} catch (error) {
return '';

View File

@@ -597,6 +597,11 @@ def test_price_comparison_page_is_action_oriented_and_plain_chinese():
assert "renderMomoReviewPanel" in template
assert "/api/price_comparison/fetch_momo_for_pchome" in template
assert "MOMO 候選待確認" in template
assert "待確認 ${momoReviewCandidates.length} 筆:先並排看兩家賣場" in template
assert "price-review-compare" in template
assert "price-review-store is-pchome" in template
assert "price-review-store is-momo" in template
assert "同時開兩家賣場" in template
assert "確認 MOMO 單品/組合候選" in template
assert "比價結果判讀" in template
assert "需檢查價格" in template

View File

@@ -492,14 +492,16 @@ 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 "同時開兩家賣場" in template
assert "並排看兩家賣場" in template
assert "review-candidate-flow" in template
assert "review-candidate-store is-pchome" in template
assert "review-candidate-store is-momo" in template
assert "openReviewCandidateStores" in template
assert "data-pchome-url" in template
assert "data-momo-url" in template
assert "PChome 賣場" in template
assert "MOMO 賣場" in template
assert "開 PChome" in template
assert "開 MOMO" in template
assert "row.match_reason_labels" in template
assert "row.match_reasons" not in template
assert "variant_selection_review" not in template
@@ -914,6 +916,7 @@ def test_visible_operations_pages_hide_internal_runtime_terms():
"templates/admin/agent_orchestration.html": ["AI 分工指揮台", "建議路徑、工具與知識命中矩陣", "工具協作明細", "工具協作 × 使用情境"],
"templates/admin/ai_calls_dashboard.html": ["用量", "最近作戰素材", "情境 × 知識命中矩陣"],
"templates/admin/observability_overview.html": ["用量", "知識與工具矩陣"],
"templates/admin/business_intel.html": ["外部促銷活動監控", "PChome 解法", "AI Agent 會持續觀察", "PChome 業績提升解法矩陣", "守價", "組合", "曝光", "會員"],
"templates/cicd_dashboard.html": ["最新更新流程", "更新歷史", "修復服務", "查看更新紀錄"],
"templates/admin/budget.html": ["Top 5 成本使用情境", "尚未建立預算線"],
"templates/ai_automation_smoke.html": ["下載檢查紀錄", "健康檢查服務"],
@@ -942,6 +945,7 @@ def test_visible_operations_pages_hide_internal_runtime_terms():
],
"templates/admin/ai_calls_dashboard.html": ["權杖量", "權杖/次", ">權杖<", "Agent 上下文", "RAG × MCP"],
"templates/admin/observability_overview.html": ["權杖量", "RAG × MCP"],
"templates/admin/business_intel.html": ["candidate queue", "資料表"],
"templates/cicd_dashboard.html": ["Pipeline Flow", "Pipeline History", "完整修復", "一鍵修復", "重啟 Registry", "舊叢集"],
"templates/admin/budget.html": ["燒錢呼叫端", "migrations/025", "<code>{{ c.caller }}</code>"],
"templates/ai_automation_smoke.html": ["JSONL", "健康檢查 API", "JSON.stringify(item.details"],

View File

@@ -141,8 +141,14 @@ def test_fetch_momo_for_pchome_endpoint_splits_auto_and_review_candidates(monkey
"name": "組合需確認商品",
"price": 468,
"product_id": "REVIEW-1",
"product_url": "https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=REVIEW-1",
"can_auto_compare": False,
"target_review_status": "需人工確認",
"target_pchome_product_id": "PCH-1",
"target_pchome_name": "PChome B5 修復霜",
"target_pchome_price": 920,
"target_match_score": 0.97,
"target_match_reasons": ["variant_selection_review"],
},
]
@@ -171,7 +177,14 @@ def test_fetch_momo_for_pchome_endpoint_splits_auto_and_review_candidates(monkey
assert payload["data"]["candidate_count"] == 3
assert payload["data"]["products"][0]["product_id"] == "AUTO-1"
assert payload["data"]["unit_compare_candidates"][0]["product_id"] == "UNIT-1"
assert payload["data"]["review_candidates"][0]["product_id"] == "REVIEW-1"
review_candidate = payload["data"]["review_candidates"][0]
assert review_candidate["product_id"] == "REVIEW-1"
assert review_candidate["target_pchome_product_id"] == "PCH-1"
assert review_candidate["target_pchome_name"] == "PChome B5 修復霜"
assert review_candidate["target_pchome_price"] == 920
assert review_candidate["target_pchome_url"] == "https://24h.pchome.com.tw/prod/PCH-1"
assert review_candidate["target_match_reason_labels"] == ["可信度 97%", "需確認色號"]
assert "target_match_reasons" not in review_candidate
assert payload["data"]["external_offer_sync"]["status"] == "not_requested"