補齊比價覆核隊列分頁
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s

This commit is contained in:
OoO
2026-05-20 01:35:10 +08:00
parent b2f9ccf4f0
commit c9aa2efd8b
4 changed files with 244 additions and 32 deletions

View File

@@ -102,6 +102,28 @@ def _build_unit_comparison_for_attempt(row: dict[str, Any]) -> Optional[dict[str
return {"comparable": False, "reason": "build_error"}
def _format_competitor_review_item(row: dict[str, Any]) -> dict[str, Any]:
item = dict(row)
unit_comparison = _build_unit_comparison_for_attempt(item)
return {
"sku": str(item.get("sku") or ""),
"name": item.get("name") or "",
"category": item.get("category") or "",
"momo_price": _num(item.get("momo_price")),
"attempt_status": item.get("attempt_status") or "",
"status_label": _attempt_status_label(item.get("attempt_status")),
"action_label": _attempt_action_label(item.get("attempt_status")),
"candidate_count": int(item.get("candidate_count") or 0),
"candidate_pc_id": item.get("best_competitor_product_id"),
"candidate_pc_name": item.get("best_competitor_product_name") or "",
"candidate_pc_price": _num(item.get("best_competitor_price")),
"best_match_score": _num(item.get("best_match_score")),
"match_diagnostic": item.get("error_message") or "",
"attempted_at": _date_label(item.get("attempted_at")),
"unit_comparison": unit_comparison,
}
def clear_competitor_intel_cache() -> None:
"""Clear cached PChome/MOMO intelligence after crawler/import updates."""
with _CACHE_LOCK:
@@ -478,6 +500,177 @@ def fetch_competitor_review_queue(engine, limit: int = 12) -> list[dict]:
)
def fetch_competitor_review_queue_page(
engine,
page: int = 1,
per_page: int = 50,
search_query: str = "",
category: str = "",
) -> dict:
"""Paginated PChome review queue for operator-facing Dashboard pages."""
page = max(1, int(page or 1))
per_page = max(1, min(int(per_page or 50), 100))
search_query = (search_query or "").strip()
category = (category or "").strip()
cache_key = (
"review_queue_page:v1:"
f"page={page}:per={per_page}:q={search_query.lower()}:cat={category}:"
f"floor={PCHOME_MATCH_SCORE_FLOOR}"
)
return _cached_payload(
cache_key,
lambda: _fetch_competitor_review_queue_page_uncached(
engine,
page=page,
per_page=per_page,
search_query=search_query,
category=category,
),
ttl_seconds=min(COMPETITOR_INTEL_CACHE_TTL_SECONDS, 300),
)
def _review_queue_cte_and_filter(search_query: str = "", category: str = "") -> tuple[str, dict[str, Any]]:
params: dict[str, Any] = {}
filters = [
"lm.rn = 1",
"vc.sku IS NULL",
"""la.attempt_status IN (
'unit_comparable',
'refresh_unit_comparable',
'identity_veto',
'low_score',
'expired_match',
'refresh_no_result',
'no_result'
)""",
]
if search_query:
params["search_like"] = f"%{search_query.lower()}%"
filters.append("(LOWER(lm.name) LIKE :search_like OR LOWER(lm.sku) LIKE :search_like)")
if category:
params["category"] = category
filters.append("lm.category = :category")
where_sql = "\n AND ".join(filters)
cte = f"""
WITH latest_momo AS (
SELECT
p.id AS product_id,
p.i_code AS sku,
p.name,
p.category,
pr.price AS momo_price,
ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pr.timestamp DESC, pr.id DESC) AS rn
FROM products p
JOIN price_records pr ON pr.product_id = p.id
WHERE p.status = 'ACTIVE'
),
valid_competitor AS (
SELECT DISTINCT ON (cp.sku)
cp.sku
FROM competitor_prices cp
WHERE cp.source = 'pchome'
AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP)
AND cp.price IS NOT NULL
AND cp.price > 0
AND COALESCE(cp.match_score, 0) >= {PCHOME_MATCH_SCORE_FLOOR}
AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2'
ORDER BY cp.sku, cp.crawled_at DESC NULLS LAST
),
latest_attempt AS (
SELECT DISTINCT ON (cma.sku)
cma.sku,
cma.attempt_status,
cma.candidate_count,
cma.best_competitor_product_id,
cma.best_competitor_product_name,
cma.best_competitor_price,
cma.best_match_score,
cma.error_message,
cma.attempted_at
FROM competitor_match_attempts cma
WHERE cma.source = 'pchome'
ORDER BY cma.sku, cma.attempted_at DESC NULLS LAST
),
review_rows AS (
SELECT
lm.sku,
lm.name,
lm.category,
lm.momo_price,
la.attempt_status,
la.candidate_count,
la.best_competitor_product_id,
la.best_competitor_product_name,
la.best_competitor_price,
la.best_match_score,
la.error_message,
la.attempted_at,
CASE
WHEN la.attempt_status IN ('unit_comparable', 'refresh_unit_comparable') THEN 0
WHEN la.attempt_status = 'identity_veto' THEN 1
WHEN la.attempt_status = 'low_score' THEN 2
WHEN la.attempt_status = 'expired_match' THEN 3
ELSE 4
END AS priority_rank
FROM latest_momo lm
JOIN latest_attempt la ON la.sku = lm.sku
LEFT JOIN valid_competitor vc ON vc.sku = lm.sku
WHERE {where_sql}
)
"""
return cte, params
def _fetch_competitor_review_queue_page_uncached(
engine,
page: int = 1,
per_page: int = 50,
search_query: str = "",
category: str = "",
) -> dict:
inspector = inspect(engine)
if not (
inspector.has_table("products")
and inspector.has_table("price_records")
and inspector.has_table("competitor_prices")
and inspector.has_table("competitor_match_attempts")
):
return {"items": [], "total": 0, "page": max(1, int(page or 1)), "per_page": per_page}
page = max(1, int(page or 1))
per_page = max(1, min(int(per_page or 50), 100))
cte, params = _review_queue_cte_and_filter(search_query=search_query, category=category)
page_params = {
**params,
"limit": per_page,
"offset": (page - 1) * per_page,
}
count_sql = text(cte + " SELECT COUNT(*) AS total FROM review_rows")
page_sql = text(cte + """
SELECT *
FROM review_rows
ORDER BY
priority_rank ASC,
momo_price DESC NULLS LAST,
best_match_score DESC NULLS LAST,
attempted_at DESC NULLS LAST
LIMIT :limit OFFSET :offset
""")
with engine.connect() as conn:
total = int(conn.execute(count_sql, params).scalar() or 0)
rows = conn.execute(page_sql, page_params).mappings().all()
return {
"items": [_format_competitor_review_item(dict(row)) for row in rows],
"total": total,
"page": page,
"per_page": per_page,
}
def _fetch_competitor_review_queue_uncached(engine, limit: int = 12) -> list[dict]:
inspector = inspect(engine)
if not (
@@ -572,28 +765,7 @@ def _fetch_competitor_review_queue_uncached(engine, limit: int = 12) -> list[dic
with engine.connect() as conn:
rows = conn.execute(sql, {"limit": limit}).mappings().all()
queue = []
for row in rows:
item = dict(row)
unit_comparison = _build_unit_comparison_for_attempt(item)
queue.append({
"sku": str(item.get("sku") or ""),
"name": item.get("name") or "",
"category": item.get("category") or "",
"momo_price": _num(item.get("momo_price")),
"attempt_status": item.get("attempt_status") or "",
"status_label": _attempt_status_label(item.get("attempt_status")),
"action_label": _attempt_action_label(item.get("attempt_status")),
"candidate_count": int(item.get("candidate_count") or 0),
"candidate_pc_id": item.get("best_competitor_product_id"),
"candidate_pc_name": item.get("best_competitor_product_name") or "",
"candidate_pc_price": _num(item.get("best_competitor_price")),
"best_match_score": _num(item.get("best_match_score")),
"match_diagnostic": item.get("error_message") or "",
"attempted_at": _date_label(item.get("attempted_at")),
"unit_comparison": unit_comparison,
})
return queue
return [_format_competitor_review_item(dict(row)) for row in rows]
def fetch_competitor_comparison_results(