fix: align pchome growth comparison and promo watch
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.704"
|
||||
SYSTEM_VERSION = "V10.705"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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 業績提升解法承接。 |
|
||||
|
||||
@@ -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 可執行解法
|
||||
|
||||
## 判斷標準
|
||||
|
||||
|
||||
@@ -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 資料暫時不可用,已切換安全空狀態。',
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -49,7 +49,7 @@ OBSERVABILITY_PAGES = (
|
||||
"/observability/business_intel",
|
||||
"商業面 × AI",
|
||||
"商業",
|
||||
("商業面 × AI", "AI", "競品"),
|
||||
("外部促銷活動監控", "PChome 解法", "競品"),
|
||||
),
|
||||
ObservabilityPage(
|
||||
"templates/admin/host_health.html",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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="需要確認的重點">
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user