This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 += [
|
||||
"",
|
||||
"🚦 <b>通知分級</b>",
|
||||
f"• 判讀:<b>{escape(guidance_title)}</b>",
|
||||
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 += ["", "🧩 <b>比對證據</b>", *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 += ["", "⚖️ <b>差異提醒</b>", *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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -194,6 +194,9 @@ def test_dispatch_decision_envelope_skips_ai_handler(monkeypatch):
|
||||
assert "📊 <b>價格證據</b>" in message
|
||||
assert "🧩 <b>比對證據</b>" in message
|
||||
assert "✅ <b>人工下一步</b>" in message
|
||||
assert "🚦 <b>通知分級</b>" in message
|
||||
assert "直接價格威脅" in message
|
||||
assert "高信心同款 / 總價可比 / 可直接價格告警" in message
|
||||
assert "SKU:<code>SKU-1</code>" in message
|
||||
assert "MOMO:<b>NT$ 120</b>" in message
|
||||
assert "PChome:<b>NT$ 100</b>" 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 "🚦 <b>通知分級</b>" 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
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user