diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt
index 462cfae..1c359c3 100644
--- a/TODO_NEXT_STEPS.txt
+++ b/TODO_NEXT_STEPS.txt
@@ -4,6 +4,7 @@
================================================================================
【已完成】
+ - V10.582 補 PChome 比價通知專業分級與 Nick 副標身份證據:NemoTron 價格決策信封現在保留 `momo_price`、`competitor_price`、`candidate_gap_pct` 與 `sales_7d_delta_pct`,EventRouter / Telegram 模板會把 `match_type / price_basis / alert_tier` 翻成「直接價格威脅、單位價覆核、身份覆核、壓制告警」與操作邊界;PChome crawler 會保留 `Nick` 副標為 `match_name` 給 matcher 使用,UI/DB 顯示仍維持原品名,讓容量、入數、濃度資訊可參與比對。
- V10.581 將 V10.580 的重複單品組安全線接進 PChome retryable revalidation 窄門:只允許 `true_low_confidence`、舊診斷為 `match_type=exact / price_basis=manual_review`、無阻擋原因且命中具名安全商品線(Bioneo 150ml x2、Cetaphil 150ml x2、Avene 300ml x4、Schick 2+1 入、KOSE 雪肌精 500ml x2 禮盒)的候選進小批次重評;仍由最新版 matcher 最終判斷是否寫入正式 `competitor_prices`。
- V10.580 補 PChome 重複單品組 total-price 窄門:同品牌、同入數、同基礎規格且名稱高度對齊的 150ml x2、300ml x4、2+1 入等候選,可由 `exact / manual_review` 進 `exact / total_price / price_alert_exact`,正式部署前估算 213 筆高分 `true_low_confidence` 中只有 7 筆會轉自動寫入。同步新增 NEW DIRECTIONS 甜杏仁油 vs 杏桃核仁油核心油種 hard veto,避免規格一樣但油種不同的錯配污染正式價差;Paula's Choice 這類 PChome 端缺 30ml 規格的雙入組仍保留 manual review,不放寬全域門檻。
- V10.579 補 PChome 高信心 total-price safe family:SAB 私密防護舒緩噴霧 30ml 與 Herbacin 小甘菊 20ml 護手霜在同款式、同規格、無 variant/commercial gap 時可由 focused matcher 進 `exact / total_price / price_alert_exact`,讓近門檻重評能真正寫入正式比價;Herbacin 柔皙 vs 野生玫瑰等跨 variant 仍保留在 review,不放寬全域門檻。同版將 Code Review GCP-B secondary timeout 預設由 60 秒收斂到 25 秒,GCP-A preflight 不通且 GCP-B 生成卡住時更快回 deterministic local degraded,不呼叫 Gemini/111。
diff --git a/config.py b/config.py
index bae9ee1..27b34ed 100644
--- a/config.py
+++ b/config.py
@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
-SYSTEM_VERSION = "V10.581"
+SYSTEM_VERSION = "V10.582"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示
diff --git a/docs/memory/current_execution_queue_20260524.md b/docs/memory/current_execution_queue_20260524.md
index 7e1f33c..611aa2d 100644
--- a/docs/memory/current_execution_queue_20260524.md
+++ b/docs/memory/current_execution_queue_20260524.md
@@ -107,6 +107,7 @@
- 2026-06-04 起,`V10.579` 補 PChome 高信心 total-price safe family:SAB 私密防護舒緩噴霧 30ml、Herbacin 小甘菊 20ml 護手霜在同款式同規格且無 variant/commercial gap 時可進 `exact / total_price / price_alert_exact`;跨款式反測仍擋在 review,`MIN_MATCH_SCORE` 不變。同版將 Code Review GCP-B secondary timeout 收斂到 25 秒,GCP-A/GCP-B 都慢時更快回 local degraded。
- 2026-06-04 起,`V10.580` 補 PChome 重複單品組 total-price 窄門與核心油種 veto:同品牌、同入數、同基礎規格且名稱高度對齊的重複單品組(例如 Bioneo 150ml x2、Cetaphil 150ml x2、Avene 300ml x4、Schick 2+1 入)可進 `exact / total_price / price_alert_exact`;正式部署前估算 213 筆高分 `true_low_confidence` 中僅 7 筆會被自動寫入。NEW DIRECTIONS 甜杏仁油 vs 杏桃核仁油改 hard veto,Paula's Choice 缺 30ml 規格的雙入組仍留 manual review;`MIN_MATCH_SCORE` 不變。
- 2026-06-04 起,`V10.581` 將重複單品組安全線接進 retryable revalidation:只收 `true_low_confidence` 中舊診斷為 `match_type=exact / price_basis=manual_review`、無 commercial / variant / count / bundle 等阻擋,且命中具名安全商品線的候選;最後仍由最新版 matcher 與 overwrite protection 決定是否寫正式比價。
+- 2026-06-04 起,`V10.582` 補 PChome 比價通知專業分級與 Nick 副標身份證據:NemoTron 決策信封保留 MOMO / PChome 價格、價差與 7 日業績變化;Telegram decision envelope 將 `exact / total_price / price_alert_exact` 等工程路徑翻成直接價格威脅、單位價覆核、身份覆核或壓制告警,並把「單位價/身份未確認不得用總價直接告警」寫進操作邊界。PChome `Nick` 副標會以 `match_name` 參與 matcher,比價可用到容量、入數、濃度資訊,但不改 UI/DB 正式顯示品名。
- 2026-06-04 起,`V10.578` 修正 Code Review deterministic scan 的 timeout 判定,多行 `requests.*(... timeout=...)` 不再被誤報為未設定 timeout。
- 2026-06-04 起,`V10.577` Code Review OpenClaw 會在 explicit Ollama host generate 前先做短 `/api/version` preflight;GCP-A 不通時快速跳 GCP-B,避免 15 秒 timeout 後才降級,且仍不呼叫 Gemini / 111。
- 2026-06-04 起,`V10.576` 修正 GCP-only Ollama retry:caller 禁用 111 fallback 時,resolver 若回到 111 會改試 GCP-A/GCP-B allowlist,不再讓 Hermes / Code Review 類任務因 resolver 快取到 111 而 `all 0 hosts failed`。
diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md
index 281d7c1..f25089a 100644
--- a/docs/memory/history_logs.md
+++ b/docs/memory/history_logs.md
@@ -13,6 +13,7 @@
## 📅 詳細更新日誌 (考古存檔)
### 2026-06-01:PChome 比價新鮮度操作閉環
+- **V10.582 PChome 比價通知專業分級 + Nick 副標身份證據**: NemoTron 價格決策信封補齊 `momo_price`、`competitor_price`、`candidate_gap_pct` 與 `sales_7d_delta_pct`,避免 EventRouter / Telegram 模板拿不到核心價格事實。價格決策模板新增「通知分級」,將 `match_type / price_basis / alert_tier` 翻成直接價格威脅、單位價覆核、身份覆核或壓制告警,並同步顯示操作邊界;單位價或 identity 未確認時明確禁止以總價直接判定價格威脅。PChome crawler 另保留 `Nick` 副標為 `match_name` 給 matcher 使用,UI/DB 顯示仍用原品名,讓容量、入數、濃度資訊可參與比對。
- **V10.581 重複單品組 retryable revalidation 接線**: 將 V10.580 的安全 matcher 線接到 `run_retryable_candidate_revalidation()`,但只收 `true_low_confidence` 中舊診斷已是 `match_type=exact / price_basis=manual_review`、沒有 review block,且命中具名安全商品線的候選。這讓 Bioneo / Cetaphil / Avene / Schick / KOSE 等重複單品組可以小批次重評並由最新版 matcher 決定是否寫入正式 `competitor_prices`,仍不開放泛用高分掃描。
- **V10.580 PChome 重複單品組 total-price 窄門 + 核心油種 veto**: matcher 將同品牌、同入數、同基礎規格且名稱高度對齊的重複單品組,從 `exact / manual_review` 收斂到 `exact / total_price / price_alert_exact`;正式部署前以最新 `true_low_confidence` 重算,213 筆高分候選中僅 7 筆符合自動寫入安全條件。同步新增 NEW DIRECTIONS 甜杏仁油 vs 杏桃核仁油 hard veto,避免同容量同按壓頭但核心油種不同的候選被誤放行;PChome 端缺規格的 Paula's Choice 雙入組仍停在 manual review。
- **V10.579 PChome 高信心 total-price safe family + Code Review timeout 收斂**: matcher 新增 SAB 私密防護舒緩噴霧 30ml 與 Herbacin 小甘菊 20ml 護手霜的窄範圍 total-price safe 路徑。這些候選仍必須通過既有 score、hard veto、variant/commercial gap 與 overwrite protection;Herbacin 柔皙 vs 野生玫瑰跨 variant 反測維持不進正式價差。目的在不放寬 `MIN_MATCH_SCORE` 的前提下,把可證明同款的高信心 `true_low_confidence` 轉進正式比價覆蓋。同版將 Code Review GCP-B secondary timeout 預設由 60 秒收斂到 25 秒,GCP-A preflight 不通且 GCP-B 生成卡住時更快回 deterministic local degraded,不呼叫 Gemini/111。
diff --git a/services/competitor_price_feeder.py b/services/competitor_price_feeder.py
index 47597b9..2183d30 100644
--- a/services/competitor_price_feeder.py
+++ b/services/competitor_price_feeder.py
@@ -832,6 +832,11 @@ def _product_id_key(product_id: str) -> str:
return re.sub(r"[^A-Z0-9]", "", str(product_id or "").upper())
+def _candidate_match_name(product) -> str:
+ """Return the identity-rich PChome text used for scoring, falling back to display name."""
+ return (getattr(product, "match_name", None) or getattr(product, "name", None) or "").strip()
+
+
def _find_best_match_detail(
momo_name: str,
pchome_products: list,
@@ -863,7 +868,7 @@ def _rank_match_details(
for p in pchome_products:
diagnostics = score_marketplace_match(
momo_name,
- p.name,
+ _candidate_match_name(p),
momo_price=momo_price,
competitor_price=getattr(p, "price", None),
)
diff --git a/services/nemoton_dispatcher_service.py b/services/nemoton_dispatcher_service.py
index 526b619..5437670 100644
--- a/services/nemoton_dispatcher_service.py
+++ b/services/nemoton_dispatcher_service.py
@@ -543,6 +543,8 @@ def _build_price_decision_envelope(
"sku": sku,
"name": name,
"event_type": "price_competition",
+ "momo_price": momo_value if momo_value > 0 else None,
+ "competitor_price": comp_value if comp_value > 0 else None,
"competitor_product_id": competitor_product_id,
"competitor_product_name": str(competitor_product_name or "")[:120],
},
@@ -554,6 +556,10 @@ def _build_price_decision_envelope(
"requires_hitl": True,
},
"expected_impact": {
+ "momo_price": momo_value if momo_value > 0 else None,
+ "competitor_price": comp_value if comp_value > 0 else None,
+ "candidate_gap_pct": round(gap_value, 1),
+ "sales_7d_delta_pct": round(sales_value, 1),
"revenue_loss_7d": round(loss_value, 2),
"gap_amount": gap_amount,
"recommended_price": recommended_price,
diff --git a/services/pchome_crawler.py b/services/pchome_crawler.py
index bb554e1..f8cc43f 100644
--- a/services/pchome_crawler.py
+++ b/services/pchome_crawler.py
@@ -39,6 +39,8 @@ class PChomeProduct:
review_count: int # 評論數
is_on_sale: bool # 是否特價中
crawled_at: datetime # 爬取時間
+ subtitle: str = '' # PChome Nick / 副標,常含容量、入數與濃度
+ match_name: str = '' # 給 matcher 使用的身份文字;UI/DB 顯示仍用 name
def to_dict(self) -> dict:
"""轉換為字典"""
@@ -47,6 +49,17 @@ class PChomeProduct:
return data
+def _build_match_name(name: str, subtitle: str) -> str:
+ """Build an identity-rich title without duplicating the PChome display name."""
+ display_name = str(name or '').strip()
+ nick = str(subtitle or '').strip()
+ if not nick or nick == display_name:
+ return display_name
+ if display_name and nick.startswith(display_name):
+ return nick
+ return f"{display_name} {nick}".strip()
+
+
class PChomeCrawler:
"""PChome 24h 爬蟲"""
@@ -334,9 +347,12 @@ class PChomeCrawler:
image_url = f"{self.IMAGE_BASE_URL}{pic_path}" if pic_path else ''
+ name = data.get('Name', '') or ''
+ subtitle = data.get('Nick', '') or ''
+
return PChomeProduct(
product_id=product_id,
- name=data.get('Name', ''),
+ name=name,
price=price,
original_price=original_price,
discount=discount,
@@ -347,7 +363,9 @@ class PChomeCrawler:
rating=data.get('RatingValue'),
review_count=data.get('ReviewCount', 0),
is_on_sale=data.get('isOnSale', False),
- crawled_at=crawled_at
+ crawled_at=crawled_at,
+ subtitle=subtitle,
+ match_name=_build_match_name(name, subtitle),
)
except Exception as e:
diff --git a/services/telegram_templates.py b/services/telegram_templates.py
index e5b55c3..960221c 100644
--- a/services/telegram_templates.py
+++ b/services/telegram_templates.py
@@ -776,7 +776,9 @@ def _action_label(action_code: str) -> str:
labels = {
"price_follow_review": "確認是否跟價或改用促銷防守",
"review_accept_identity": "人工確認同款後採納 identity",
+ "review_catalog_comparable": "依型錄證據覆核可比性",
"unit_price_required": "改用單位價覆核,不寫總價型價差",
+ "identity_or_price_review": "先確認身份,再判斷價格處置",
"verify_or_reject_identity": "確認候選是否同款;非同款即駁回",
"compare_existing_identity": "比較既有正式 identity 與新候選",
"refresh_or_compare_identity": "刷新過期 identity 後再覆核",
@@ -786,6 +788,55 @@ def _action_label(action_code: str) -> str:
return labels.get(action_code or "", action_code or "人工覆核")
+_PRICE_MATCH_TYPE_LABELS = {
+ "exact": "高信心同款",
+ "same_product_different_pack": "同商品不同包裝",
+ "same_line_variant": "同系列不同款",
+ "comparable": "可比但需覆核",
+ "no_match": "非同款",
+}
+_PRICE_BASIS_LABELS = {
+ "total_price": "總價可比",
+ "unit_price": "單位價可比",
+ "manual_review": "人工覆核後可比",
+ "none": "不可比",
+}
+_PRICE_ALERT_TIER_LABELS = {
+ "price_alert_exact": "可直接價格告警",
+ "unit_price_review": "單位價覆核",
+ "identity_review": "身份覆核",
+ "suppress": "壓制告警",
+}
+
+
+def _price_match_path(envelope: Dict[str, Any]) -> tuple[str, str, str]:
+ guardrails = envelope.get("guardrails") if isinstance(envelope.get("guardrails"), dict) else {}
+ match_type = str(guardrails.get("match_type") or "")
+ price_basis = str(guardrails.get("price_basis") or "")
+ alert_tier = str(guardrails.get("alert_tier") or "")
+ if match_type and price_basis and alert_tier:
+ return match_type, price_basis, alert_tier
+
+ match_evidence = _find_evidence(envelope, "match_score") or {}
+ basis = str(match_evidence.get("basis") or "")
+ parts = [part.strip() for part in basis.split("/") if part.strip()]
+ if len(parts) >= 3:
+ return match_type or parts[0], price_basis or parts[1], alert_tier or parts[2]
+ return match_type, price_basis, alert_tier
+
+
+def _price_notification_guidance(match_type: str, price_basis: str, alert_tier: str) -> tuple[str, str]:
+ if match_type == "exact" and price_basis == "total_price" and alert_tier == "price_alert_exact":
+ return "直接價格威脅", "可用總價比較;先確認庫存與促銷期,再人工決定跟價或促銷防守。"
+ if price_basis == "unit_price" or alert_tier == "unit_price_review":
+ return "單位價覆核", "先換算單位價與入數,禁止用總價直接判定價格威脅。"
+ if alert_tier == "identity_review" or price_basis == "manual_review":
+ return "身份覆核", "先確認同款、規格、組合與前台狀態,人工採納後才可寫入正式價差。"
+ if alert_tier == "suppress" or match_type == "no_match" or price_basis == "none":
+ return "壓制告警", "目前不可作為價格威脅;保留診斷紀錄,避免誤報。"
+ return "可比性待判讀", "依比對證據人工覆核,未確認前不自動調價、不覆蓋正式 identity。"
+
+
def _format_price_decision_envelope(envelope: Dict[str, Any]) -> List[str]:
"""將價格/競品決策信封排成可讀的專業 brief。"""
severity = escape(str(envelope.get("severity") or "info"))
@@ -809,6 +860,22 @@ def _format_price_decision_envelope(envelope: Dict[str, Any]) -> List[str]:
if blocked_reason:
lines.append(f"• 邊界:{blocked_reason}")
+ match_type, price_basis, alert_tier = _price_match_path(envelope)
+ if match_type or price_basis or alert_tier:
+ guidance_title, guidance_text = _price_notification_guidance(match_type, price_basis, alert_tier)
+ path_labels = [
+ _PRICE_MATCH_TYPE_LABELS.get(match_type, match_type) if match_type else "",
+ _PRICE_BASIS_LABELS.get(price_basis, price_basis) if price_basis else "",
+ _PRICE_ALERT_TIER_LABELS.get(alert_tier, alert_tier) if alert_tier else "",
+ ]
+ lines += [
+ "",
+ "🚦 通知分級",
+ f"• 判讀:{escape(guidance_title)}",
+ f"• 路徑:{escape(' / '.join(part for part in path_labels if part))}",
+ f"• 邊界:{escape(guidance_text)}",
+ ]
+
sku = escape(str(subject.get("sku") or ""))
name = escape(_short_text(subject.get("name") or "", 96))
competitor_id = escape(str(subject.get("competitor_product_id") or subject.get("pchome_id") or ""))
@@ -891,6 +958,24 @@ def _format_price_decision_envelope(envelope: Dict[str, Any]) -> List[str]:
if evidence_lines:
lines += ["", "🧩 比對證據", *evidence_lines]
+ difference_highlights = envelope.get("difference_highlights")
+ if isinstance(difference_highlights, list) and difference_highlights:
+ diff_lines = []
+ for row in difference_highlights[:3]:
+ if not isinstance(row, dict):
+ continue
+ dimension = escape(str(row.get("dimension") or row.get("label") or "差異"))
+ left = escape(_short_text(row.get("left") or row.get("momo") or "", 42))
+ right = escape(_short_text(row.get("right") or row.get("pchome") or "", 42))
+ if left or right:
+ diff_lines.append(f"• {dimension}:MOMO {left or '—'} / PChome {right or '—'}")
+ else:
+ note = escape(_short_text(row.get("note") or row.get("summary") or "", 84))
+ if note:
+ diff_lines.append(f"• {dimension}:{note}")
+ if diff_lines:
+ lines += ["", "⚖️ 差異提醒", *diff_lines]
+
action_code = str(recommended_action.get("action") or "human_review")
owner = escape(str(recommended_action.get("owner") or "未指定"))
requires_hitl = bool(recommended_action.get("requires_hitl", True))
diff --git a/tests/test_competitor_match_attempts_persistence.py b/tests/test_competitor_match_attempts_persistence.py
index 0e247ce..f0a8603 100644
--- a/tests/test_competitor_match_attempts_persistence.py
+++ b/tests/test_competitor_match_attempts_persistence.py
@@ -84,6 +84,42 @@ def test_competitor_review_queue_starts_from_latest_attempts_not_all_products():
assert "valid_competitor AS" not in review_cte_body
+def test_competitor_feeder_scores_with_pchome_match_name(monkeypatch):
+ from services.competitor_price_feeder import _rank_match_details
+ from services.pchome_crawler import PChomeProduct
+
+ product = PChomeProduct(
+ product_id="DDDE15-A900JZ4GR",
+ name="【寶拉珍選】水楊酸身體乳雙入組",
+ price=1777,
+ original_price=2640,
+ discount=33,
+ image_url="",
+ product_url="https://24h.pchome.com.tw/prod/DDDE15-A900JZ4GR",
+ stock=20,
+ store="DDDE15",
+ rating=None,
+ review_count=0,
+ is_on_sale=True,
+ crawled_at=datetime.now(),
+ subtitle="【寶拉珍選】水楊酸身體乳雙入組 (2%水楊酸身體乳 210ml x2)",
+ match_name="【寶拉珍選】水楊酸身體乳雙入組 (2%水楊酸身體乳 210ml x2)",
+ )
+ captured = {}
+
+ def fake_score(momo_name, competitor_name, **kwargs):
+ captured["competitor_name"] = competitor_name
+ return SimpleNamespace(score=0.91)
+
+ monkeypatch.setattr("services.marketplace_product_matcher.score_marketplace_match", fake_score)
+
+ ranked = _rank_match_details("【Paulas Choice 寶拉珍選】2%水楊酸身體乳210ml二入", [product])
+
+ assert ranked[0][0] is product
+ assert ranked[0][1] == 0.91
+ assert "210ml x2" in captured["competitor_name"]
+
+
def test_competitor_feeder_persists_all_match_attempt_outcomes():
source = (ROOT / "services/competitor_price_feeder.py").read_text(encoding="utf-8")
migration = (ROOT / "migrations/023_competitor_match_attempts.sql").read_text(encoding="utf-8")
diff --git a/tests/test_event_router.py b/tests/test_event_router.py
index f9cf941..e24d4e6 100644
--- a/tests/test_event_router.py
+++ b/tests/test_event_router.py
@@ -194,6 +194,9 @@ def test_dispatch_decision_envelope_skips_ai_handler(monkeypatch):
assert "📊 價格證據" in message
assert "🧩 比對證據" in message
assert "✅ 人工下一步" in message
+ assert "🚦 通知分級" in message
+ assert "直接價格威脅" in message
+ assert "高信心同款 / 總價可比 / 可直接價格告警" in message
assert "SKU:SKU-1" in message
assert "MOMO:NT$ 120" in message
assert "PChome:NT$ 100" in message
@@ -204,6 +207,49 @@ def test_dispatch_decision_envelope_skips_ai_handler(monkeypatch):
assert kwargs["reply_markup"]["inline_keyboard"][0][0]["callback_data"].startswith("momo:eig:")
+def test_price_decision_template_marks_unit_price_review_as_non_direct_alert():
+ from services.telegram_templates import _format_decision_envelope
+
+ envelope = {
+ "decision_id": "review_queue:SKU-UNIT",
+ "decision_type": "pchome_match_review",
+ "severity": "P2",
+ "confidence": 0.82,
+ "subject": {
+ "sku": "SKU-UNIT",
+ "name": "測試乳液 500ml x2",
+ "momo_price": 1200,
+ "competitor_price": 950,
+ "competitor_product_id": "PCH-UNIT",
+ "competitor_product_name": "測試乳液 1000ml",
+ },
+ "evidence": [
+ {
+ "type": "match",
+ "metric": "match_score",
+ "value": 0.82,
+ "basis": "same_product_different_pack/unit_price/unit_price_review",
+ }
+ ],
+ "recommended_action": {"action": "unit_price_required", "owner": "營運", "requires_hitl": True},
+ "guardrails": {
+ "can_auto_execute": False,
+ "data_quality": "partial",
+ "match_type": "same_product_different_pack",
+ "price_basis": "unit_price",
+ "alert_tier": "unit_price_review",
+ },
+ }
+
+ message = "\n".join(_format_decision_envelope(envelope))
+
+ assert "🚦 通知分級" in message
+ assert "單位價覆核" in message
+ assert "同商品不同包裝 / 單位價可比 / 單位價覆核" in message
+ assert "禁止用總價直接判定價格威脅" in message
+ assert "改用單位價覆核,不寫總價型價差" in message
+
+
def test_replay_failed_deliveries_removes_successful_records(tmp_path, monkeypatch):
import services.event_router as event_router
diff --git a/tests/test_nemotron_decision_envelope.py b/tests/test_nemotron_decision_envelope.py
index c303d51..90f59a8 100644
--- a/tests/test_nemotron_decision_envelope.py
+++ b/tests/test_nemotron_decision_envelope.py
@@ -29,6 +29,8 @@ def test_price_decision_envelope_has_hilt_guardrails():
assert envelope["severity"] == "P1"
assert envelope["confidence"] == 0.91
assert envelope["subject"]["sku"] == "10413050"
+ assert envelope["subject"]["momo_price"] == 1200
+ assert envelope["subject"]["competitor_price"] == 999
assert envelope["subject"]["competitor_product_id"] == "PCHOME-1"
assert envelope["guardrails"]["can_auto_execute"] is False
assert envelope["guardrails"]["data_quality"] == "complete"
@@ -38,6 +40,10 @@ def test_price_decision_envelope_has_hilt_guardrails():
assert envelope["recommended_action"]["requires_hitl"] is True
assert envelope["expected_impact"]["revenue_loss_7d"] == 65000
assert envelope["expected_impact"]["gap_amount"] == 201
+ assert envelope["expected_impact"]["momo_price"] == 1200
+ assert envelope["expected_impact"]["competitor_price"] == 999
+ assert envelope["expected_impact"]["candidate_gap_pct"] == 16.7
+ assert envelope["expected_impact"]["sales_7d_delta_pct"] == -31.2
assert any(e["metric"] == "match_score" and e["value"] == 0.93 for e in envelope["evidence"])
diff --git a/tests/test_pchome_crawler_search.py b/tests/test_pchome_crawler_search.py
index ed73e86..598b10e 100644
--- a/tests/test_pchome_crawler_search.py
+++ b/tests/test_pchome_crawler_search.py
@@ -107,6 +107,7 @@ def test_pchome_fetch_product_details_accepts_list_payload():
{
"Id": "DDABCD-12345678",
"Name": "測試商品 50ml",
+ "Nick": "測試商品 50ml x2 限量組",
"Price": {"P": 799, "M": 999},
"Pic": {"B": "/items/DDABCD12345678.jpg"},
"Qty": 8,
@@ -124,6 +125,16 @@ def test_pchome_fetch_product_details_accepts_list_payload():
assert len(calls) == 1
assert [product.product_id for product in products] == ["DDABCD-12345678"]
assert products[0].price == 799
+ assert products[0].subtitle == "測試商品 50ml x2 限量組"
+ assert products[0].match_name == "測試商品 50ml x2 限量組"
+
+
+def test_pchome_match_name_combines_non_duplicate_nick():
+ from services.pchome_crawler import _build_match_name
+
+ assert _build_match_name("水楊酸身體乳雙入組", "2% 水楊酸身體乳 210ml x2") == (
+ "水楊酸身體乳雙入組 2% 水楊酸身體乳 210ml x2"
+ )
def test_feeder_search_cleanup_preserves_bracket_brand_and_specs():