#!/usr/bin/env python3 # -*- coding: utf-8 -*- """PChome / MOMO 比價人工覆核決策服務。""" from __future__ import annotations import json from datetime import datetime, timedelta, timezone from typing import Any from sqlalchemy import text VALID_REVIEW_ACTIONS = { "accept_identity": { "label": "採用同款", "attempt_status": "manual_accepted", "message": "已採用候選商品為正式 PChome 同款配對", }, "reject_identity": { "label": "否決候選", "attempt_status": "manual_rejected", "message": "已否決候選商品,本輪覆核已關閉", }, "unit_price_required": { "label": "標記單位價", "attempt_status": "manual_unit_price_required", "message": "已標記為需單位價比較,不寫入正式總價差", }, "needs_research": { "label": "需補搜尋", "attempt_status": "manual_needs_research", "message": "已標記為需補搜尋詞或重新抓取", }, } def _num(value: Any) -> float | None: try: if value is None: return None return float(value) except (TypeError, ValueError): return None def _json_array_expr(conn, bind_name: str) -> str: return f"CAST(:{bind_name} AS jsonb)" if conn.dialect.name == "postgresql" else f":{bind_name}" def _ensure_competitor_match_reviews_table(conn) -> None: if conn.dialect.name == "postgresql": conn.execute(text(""" CREATE TABLE IF NOT EXISTS competitor_match_reviews ( id BIGSERIAL PRIMARY KEY, sku VARCHAR(50) NOT NULL, source VARCHAR(30) NOT NULL DEFAULT 'pchome', review_action VARCHAR(40) NOT NULL, review_reason TEXT, reviewer_identity VARCHAR(120), momo_product_id INTEGER, momo_product_name TEXT, momo_price NUMERIC(10,2), candidate_product_id VARCHAR(100), candidate_product_name TEXT, candidate_price NUMERIC(10,2), candidate_match_score NUMERIC(4,3), candidate_diagnostic TEXT, resulting_attempt_status VARCHAR(40), reviewed_at TIMESTAMP NOT NULL DEFAULT NOW() ) """)) else: conn.execute(text(""" CREATE TABLE IF NOT EXISTS competitor_match_reviews ( id INTEGER PRIMARY KEY AUTOINCREMENT, sku VARCHAR(50) NOT NULL, source VARCHAR(30) NOT NULL DEFAULT 'pchome', review_action VARCHAR(40) NOT NULL, review_reason TEXT, reviewer_identity VARCHAR(120), momo_product_id INTEGER, momo_product_name TEXT, momo_price NUMERIC(10,2), candidate_product_id VARCHAR(100), candidate_product_name TEXT, candidate_price NUMERIC(10,2), candidate_match_score NUMERIC(4,3), candidate_diagnostic TEXT, resulting_attempt_status VARCHAR(40), reviewed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """)) conn.execute(text(""" CREATE INDEX IF NOT EXISTS idx_comp_match_reviews_sku_source_time ON competitor_match_reviews (sku, source, reviewed_at DESC) """)) conn.execute(text(""" CREATE INDEX IF NOT EXISTS idx_comp_match_reviews_action_time ON competitor_match_reviews (review_action, reviewed_at DESC) """)) conn.execute(text(""" CREATE INDEX IF NOT EXISTS idx_comp_match_reviews_candidate ON competitor_match_reviews (candidate_product_id) """)) def _ensure_competitor_price_history_table(conn) -> None: tags_type = "JSONB" if conn.dialect.name == "postgresql" else "TEXT" pk_type = "BIGSERIAL PRIMARY KEY" if conn.dialect.name == "postgresql" else "INTEGER PRIMARY KEY AUTOINCREMENT" conn.execute(text(f""" CREATE TABLE IF NOT EXISTS competitor_price_history ( id {pk_type}, sku VARCHAR(50) NOT NULL, source VARCHAR(30) NOT NULL DEFAULT 'pchome', momo_product_id INTEGER, momo_price NUMERIC(10,2), price NUMERIC(10,2) NOT NULL, original_price NUMERIC(10,2), discount_pct INTEGER, competitor_product_id VARCHAR(100), competitor_product_name TEXT, match_score NUMERIC(4,3), tags {tags_type} DEFAULT '[]', crawled_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """)) conn.execute(text(""" CREATE INDEX IF NOT EXISTS idx_comp_price_history_sku_source_time ON competitor_price_history (sku, source, crawled_at DESC) """)) def _fetch_latest_attempt(conn, sku: str, source: str) -> dict[str, Any] | None: latest_price_sql = """ pr.price AS current_momo_price FROM competitor_match_attempts cma LEFT JOIN products p ON p.i_code = cma.sku LEFT JOIN LATERAL ( SELECT price FROM price_records WHERE product_id = p.id ORDER BY timestamp DESC, id DESC LIMIT 1 ) pr ON TRUE """ if conn.dialect.name != "postgresql": latest_price_sql = """ ( SELECT price FROM price_records WHERE product_id = p.id ORDER BY timestamp DESC, id DESC LIMIT 1 ) AS current_momo_price FROM competitor_match_attempts cma LEFT JOIN products p ON p.i_code = cma.sku """ row = conn.execute(text(f""" SELECT cma.*, p.id AS current_momo_product_id, p.name AS current_momo_product_name, {latest_price_sql} WHERE cma.sku = :sku AND cma.source = :source ORDER BY cma.attempted_at DESC, cma.id DESC LIMIT 1 """), {"sku": sku, "source": source}).mappings().first() return dict(row) if row else None def _insert_manual_attempt(conn, attempt: dict[str, Any], action_meta: dict[str, str], source: str) -> None: search_terms_expr = _json_array_expr(conn, "search_terms") conn.execute(text(f""" INSERT INTO competitor_match_attempts (sku, source, momo_product_id, momo_product_name, momo_price, search_terms, candidate_count, attempt_status, best_competitor_product_id, best_competitor_product_name, best_competitor_price, best_match_score, error_message, attempted_at) VALUES (:sku, :source, :momo_product_id, :momo_product_name, :momo_price, {search_terms_expr}, :candidate_count, :attempt_status, :best_id, :best_name, :best_price, :best_score, :error_message, CURRENT_TIMESTAMP) """), { "sku": attempt.get("sku"), "source": source, "momo_product_id": attempt.get("momo_product_id") or attempt.get("current_momo_product_id"), "momo_product_name": attempt.get("momo_product_name") or attempt.get("current_momo_product_name"), "momo_price": attempt.get("momo_price") or attempt.get("current_momo_price"), "search_terms": json.dumps([f"manual_review:{action_meta['attempt_status']}"], ensure_ascii=False), "candidate_count": attempt.get("candidate_count") or 0, "attempt_status": action_meta["attempt_status"], "best_id": attempt.get("best_competitor_product_id"), "best_name": attempt.get("best_competitor_product_name"), "best_price": attempt.get("best_competitor_price"), "best_score": attempt.get("best_match_score"), "error_message": action_meta["message"], }) def _promote_manual_match(conn, attempt: dict[str, Any], source: str) -> None: candidate_id = str(attempt.get("best_competitor_product_id") or "").strip() candidate_name = str(attempt.get("best_competitor_product_name") or "").strip() candidate_price = _num(attempt.get("best_competitor_price")) if not candidate_id or not candidate_name or not candidate_price or candidate_price <= 0: raise ValueError("採用同款需要候選 PChome 商品 ID、名稱與有效價格") _ensure_competitor_price_history_table(conn) _taipei = timezone(timedelta(hours=8)) expires_at = (datetime.now(_taipei) + timedelta(hours=6)).strftime("%Y-%m-%d %H:%M:%S") match_score = max(_num(attempt.get("best_match_score")) or 0, 0.76) tags = [ "identity_v2", "manual_review", "manual_accept", "comparison_exact_identity", ] tags_json = json.dumps(tags, ensure_ascii=False) tags_expr = _json_array_expr(conn, "tags") momo_product_id = attempt.get("momo_product_id") or attempt.get("current_momo_product_id") momo_price = attempt.get("momo_price") or attempt.get("current_momo_price") conn.execute(text(f""" INSERT INTO competitor_prices (sku, source, price, original_price, discount_pct, competitor_product_id, competitor_product_name, match_score, tags, crawled_at, expires_at) VALUES (:sku, :source, :price, NULL, NULL, :candidate_id, :candidate_name, :match_score, {tags_expr}, CURRENT_TIMESTAMP, :expires_at) ON CONFLICT (sku, source) DO UPDATE SET price = EXCLUDED.price, original_price = EXCLUDED.original_price, discount_pct = EXCLUDED.discount_pct, competitor_product_id = EXCLUDED.competitor_product_id, competitor_product_name = EXCLUDED.competitor_product_name, match_score = EXCLUDED.match_score, tags = EXCLUDED.tags, crawled_at = CURRENT_TIMESTAMP, expires_at = EXCLUDED.expires_at """), { "sku": attempt.get("sku"), "source": source, "price": candidate_price, "candidate_id": candidate_id, "candidate_name": candidate_name[:200], "match_score": match_score, "tags": tags_json, "expires_at": expires_at, }) conn.execute(text(f""" INSERT INTO competitor_price_history (sku, source, momo_product_id, momo_price, price, original_price, discount_pct, competitor_product_id, competitor_product_name, match_score, tags, crawled_at) VALUES (:sku, :source, :momo_product_id, :momo_price, :price, NULL, NULL, :candidate_id, :candidate_name, :match_score, {tags_expr}, CURRENT_TIMESTAMP) """), { "sku": attempt.get("sku"), "source": source, "momo_product_id": momo_product_id, "momo_price": momo_price, "price": candidate_price, "candidate_id": candidate_id, "candidate_name": candidate_name[:200], "match_score": match_score, "tags": tags_json, }) def _expire_current_manual_candidate(conn, attempt: dict[str, Any], source: str) -> None: """Expire a current official match when the operator rejects its candidate.""" candidate_id = str(attempt.get("best_competitor_product_id") or "").strip() sku = str(attempt.get("sku") or "").strip() if not sku or not candidate_id: return conn.execute(text(""" UPDATE competitor_prices SET expires_at = CURRENT_TIMESTAMP, crawled_at = CURRENT_TIMESTAMP WHERE sku = :sku AND source = :source AND competitor_product_id = :candidate_id """), { "sku": sku, "source": source, "candidate_id": candidate_id, }) def record_competitor_match_review( engine, sku: str, review_action: str, reviewer_identity: str = "dashboard", review_reason: str = "", source: str = "pchome", ) -> dict[str, Any]: """Record a human decision for the latest PChome match attempt.""" sku = str(sku or "").strip() source = str(source or "pchome").strip() or "pchome" review_action = str(review_action or "").strip() review_reason = str(review_reason or "").strip()[:1000] reviewer_identity = str(reviewer_identity or "dashboard").strip()[:120] or "dashboard" if not sku: return {"success": False, "message": "缺少 MOMO 商品 ID"} action_meta = VALID_REVIEW_ACTIONS.get(review_action) if not action_meta: return {"success": False, "message": "不支援的覆核動作"} with engine.begin() as conn: _ensure_competitor_match_reviews_table(conn) attempt = _fetch_latest_attempt(conn, sku, source) if not attempt: return {"success": False, "message": "找不到可覆核的 PChome 比對嘗試"} if review_action == "accept_identity": _promote_manual_match(conn, attempt, source) else: _expire_current_manual_candidate(conn, attempt, source) _insert_manual_attempt(conn, attempt, action_meta, source) conn.execute(text(""" INSERT INTO competitor_match_reviews (sku, source, review_action, review_reason, reviewer_identity, momo_product_id, momo_product_name, momo_price, candidate_product_id, candidate_product_name, candidate_price, candidate_match_score, candidate_diagnostic, resulting_attempt_status, reviewed_at) VALUES (:sku, :source, :review_action, :review_reason, :reviewer_identity, :momo_product_id, :momo_product_name, :momo_price, :candidate_id, :candidate_name, :candidate_price, :candidate_score, :candidate_diagnostic, :resulting_attempt_status, CURRENT_TIMESTAMP) """), { "sku": sku, "source": source, "review_action": review_action, "review_reason": review_reason, "reviewer_identity": reviewer_identity, "momo_product_id": attempt.get("momo_product_id") or attempt.get("current_momo_product_id"), "momo_product_name": attempt.get("momo_product_name") or attempt.get("current_momo_product_name"), "momo_price": attempt.get("momo_price") or attempt.get("current_momo_price"), "candidate_id": attempt.get("best_competitor_product_id"), "candidate_name": attempt.get("best_competitor_product_name"), "candidate_price": attempt.get("best_competitor_price"), "candidate_score": attempt.get("best_match_score"), "candidate_diagnostic": attempt.get("error_message"), "resulting_attempt_status": action_meta["attempt_status"], }) return { "success": True, "message": action_meta["message"], "sku": sku, "review_action": review_action, "attempt_status": action_meta["attempt_status"], }