Add lightweight PChome review dashboard path
All checks were successful
CD Pipeline / deploy (push) Successful in 1m4s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m4s
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
================================================================================
|
||||
|
||||
【已完成】
|
||||
- V10.445 補 PChome 覆核頁輕量渲染路徑:`filter=pchome_review` 不再先建完整 Dashboard `unique_items`,改為只查覆核當頁 50 筆商品、當前價、昨日價與週前價,再沿用同一張新版表格與人工覆核按鈕;降低核心比價覆核頁的全站資料負載。
|
||||
- V10.444 瘦身 PChome 覆核頁查詢:`fetch_competitor_review_queue_page()` 將覆核隊列總數與當頁資料合併在單一 SQL 內取回,避免 `/?filter=pchome_review` 為 count/page 重複掃 `latest_momo`、`latest_attempt`、`valid_competitor` CTE;保留狀態分流、人工覆核與正式價格寫入保護不變。
|
||||
- V10.443 補 PChome rescore 人工覆核入隊:`audit_competitor_match_attempt_rescore.py --apply-accepted` 只追加 `rescore_accepted_current` attempt 進人工覆核隊列,不直接寫 `competitor_prices` / `competitor_price_history`;商品看板新增「重算可採用」分流與狀態文案,讓可救回候選先由人審確認再正式更新價差。
|
||||
- V10.442 降噪 `/cicd` 舊 GitLab 探測:沒有明確啟用 `GITLAB_ENABLED=true` 與 token 時,不再打退役的 `192.168.0.110:8929` 或 SSH fallback,正式 responsive smoke 造訪 `/cicd` 只呈現空 pipeline 狀態,不污染 app logs。
|
||||
|
||||
@@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.444"
|
||||
SYSTEM_VERSION = "V10.445"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **最後更新**: 2026-05-24 (台北時間)
|
||||
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 備援預設關閉
|
||||
> **適用版本**: V10.444
|
||||
> **適用版本**: V10.445
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
## 📅 詳細更新日誌 (考古存檔)
|
||||
|
||||
### 2026-05-24:PChome 近門檻身份回收第二輪
|
||||
- **V10.445 PChome 覆核頁輕量渲染路徑**: `filter=pchome_review` 直接走覆核隊列專用 render path,不再先建完整 Dashboard `unique_items`;當頁只查 50 筆覆核 SKU 的商品資料、最新價、昨日價與週前價,仍沿用同一張新版表格、狀態分流、PChome 候選說明與人工覆核按鈕,降低核心比價覆核頁的全站資料負載。
|
||||
- **V10.444 PChome 覆核頁查詢瘦身**: `fetch_competitor_review_queue_page()` 將原本 count + page 兩次重跑 review CTE 改成單次 SQL 的 `total_rows` + `paged_rows` 查詢,同步取得總數與頁面資料,降低 `/?filter=pchome_review` 對 `latest_momo` / `latest_attempt` / `valid_competitor` 的重複掃描;正式站小批次 rescore 入隊後,用於維持核心比價覆核頁可操作速度。
|
||||
- **V10.443 PChome rescore 人工覆核入隊**: `scripts/audit_competitor_match_attempt_rescore.py --apply-accepted` 只會把最新版 matcher 已通過門檻的舊低信心候選追加為 `rescore_accepted_current` attempt,進入商品看板人工覆核隊列;不寫 `competitor_prices`、不寫 `competitor_price_history`,必須由操作員按「採用同款」後才正式更新 PChome 價差。Dashboard 補上「重算可採用待審」分流與狀態文案,避免安全回刷候選混在一般低信心項目裡。
|
||||
- **V10.442 CI/CD legacy GitLab 探測降噪**: `/cicd` 舊 GitLab pipeline API 預設改為 disabled,除非明確設定 `GITLAB_ENABLED=true` 並提供 `GITLAB_TOKEN`,否則不再打 `192.168.0.110:8929` 或 SSH fallback;正式 responsive smoke 造訪 `/cicd` 時只呈現可診斷空狀態,不再把已退役 GitLab endpoint 的 connection refused / permission denied 寫成錯誤噪音。
|
||||
|
||||
@@ -14,6 +14,7 @@ import hashlib
|
||||
import pickle
|
||||
import threading
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from types import SimpleNamespace
|
||||
from flask import Blueprint, request, render_template, jsonify
|
||||
from sqlalchemy import func, and_, text, bindparam
|
||||
from sqlalchemy.orm import joinedload
|
||||
@@ -1483,6 +1484,248 @@ def load_scheduler_stats():
|
||||
return {}
|
||||
|
||||
|
||||
def _load_dashboard_system_status():
|
||||
system_status = {"status": "UNKNOWN", "message": "尚無執行紀錄", "timestamp": "-"}
|
||||
status_path = os.path.join(BASE_DIR, 'data/system_status.json')
|
||||
if os.path.exists(status_path):
|
||||
try:
|
||||
with open(status_path, 'r', encoding='utf-8') as f:
|
||||
system_status = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return system_status
|
||||
|
||||
|
||||
def _load_dashboard_categories(session):
|
||||
try:
|
||||
rows = session.execute(text("""
|
||||
SELECT COALESCE(NULLIF(category, ''), '未分類') AS category, COUNT(*) AS count
|
||||
FROM products
|
||||
WHERE status = 'ACTIVE'
|
||||
GROUP BY COALESCE(NULLIF(category, ''), '未分類')
|
||||
ORDER BY COALESCE(NULLIF(category, ''), '未分類')
|
||||
""")).mappings().all()
|
||||
return [f"{row.get('category')} ({int(row.get('count') or 0)}筆)" for row in rows]
|
||||
except Exception as exc:
|
||||
sys_log.warning(f"[Dashboard] 分類清單讀取略過: {exc}")
|
||||
return []
|
||||
|
||||
|
||||
def _build_review_dashboard_items(session, review_queue, today_start):
|
||||
"""只替 PChome 覆核當頁建立商品列,避免載入全站商品快取。"""
|
||||
sku_order = [str(row.get('sku') or '') for row in review_queue if row.get('sku')]
|
||||
if not sku_order:
|
||||
return []
|
||||
|
||||
today_cutoff = today_start.replace(tzinfo=None) if getattr(today_start, 'tzinfo', None) else today_start
|
||||
seven_day_cutoff = today_cutoff - timedelta(days=6)
|
||||
stmt = text("""
|
||||
SELECT
|
||||
p.id,
|
||||
p.i_code,
|
||||
p.name,
|
||||
p.category,
|
||||
p.url,
|
||||
p.image_url,
|
||||
p.created_at,
|
||||
latest_price.price AS current_price,
|
||||
latest_price.timestamp AS current_timestamp,
|
||||
yesterday_price.price AS yesterday_price,
|
||||
week_price.price AS week_price
|
||||
FROM products p
|
||||
JOIN LATERAL (
|
||||
SELECT pr.price, pr.timestamp
|
||||
FROM price_records pr
|
||||
WHERE pr.product_id = p.id
|
||||
ORDER BY pr.timestamp DESC, pr.id DESC
|
||||
LIMIT 1
|
||||
) latest_price ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT pr.price
|
||||
FROM price_records pr
|
||||
WHERE pr.product_id = p.id
|
||||
AND pr.timestamp < :today_cutoff
|
||||
ORDER BY pr.timestamp DESC, pr.id DESC
|
||||
LIMIT 1
|
||||
) yesterday_price ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT pr.price
|
||||
FROM price_records pr
|
||||
WHERE pr.product_id = p.id
|
||||
AND pr.timestamp < :seven_day_cutoff
|
||||
ORDER BY pr.timestamp DESC, pr.id DESC
|
||||
LIMIT 1
|
||||
) week_price ON TRUE
|
||||
WHERE p.i_code IN :skus
|
||||
""").bindparams(bindparam("skus", expanding=True))
|
||||
|
||||
rows = session.execute(
|
||||
stmt,
|
||||
{
|
||||
"skus": sku_order,
|
||||
"today_cutoff": today_cutoff,
|
||||
"seven_day_cutoff": seven_day_cutoff,
|
||||
},
|
||||
).mappings().all()
|
||||
row_map = {str(row.get('i_code') or ''): row for row in rows}
|
||||
review_map = {str(row.get('sku') or ''): row for row in review_queue if row.get('sku')}
|
||||
items = []
|
||||
for sku in sku_order:
|
||||
row = row_map.get(sku)
|
||||
review = review_map.get(sku) or {}
|
||||
if not row:
|
||||
continue
|
||||
price = _to_float(review.get('momo_price')) or _to_float(row.get('current_price')) or 0
|
||||
yesterday_price = _to_float(row.get('yesterday_price'))
|
||||
week_price = _to_float(row.get('week_price'))
|
||||
yesterday_diff = price - yesterday_price if yesterday_price is not None else 0
|
||||
week_diff = price - week_price if week_price is not None else 0
|
||||
product = SimpleNamespace(
|
||||
id=row.get('id'),
|
||||
i_code=sku,
|
||||
name=row.get('name') or review.get('name') or '',
|
||||
category=row.get('category') or review.get('category') or '',
|
||||
url=row.get('url'),
|
||||
image_url=row.get('image_url'),
|
||||
created_at=row.get('created_at'),
|
||||
)
|
||||
record = SimpleNamespace(
|
||||
product=product,
|
||||
product_id=row.get('id'),
|
||||
price=price,
|
||||
timestamp=row.get('current_timestamp'),
|
||||
)
|
||||
status = "PRICE_UP" if yesterday_diff > 0 else ("PRICE_DOWN" if yesterday_diff < 0 else "NONE")
|
||||
items.append({
|
||||
'record': record,
|
||||
'safe_product_url': normalize_momo_product_url(row.get('url'), sku) or _build_momo_product_url(sku),
|
||||
'stats': {'7d_diff': week_diff, '30d_diff': 0, '1d_diff': 0},
|
||||
'yesterday_diff': yesterday_diff,
|
||||
'today_changes': [],
|
||||
'status': status,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def _enrich_dashboard_items_for_competitor_review(session, paged_items, review_queue_map, ai_pick_map=None):
|
||||
ai_pick_map = ai_pick_map or {}
|
||||
for item in paged_items:
|
||||
item['safe_created_at'] = getattr(item['record'].product, 'created_at', None)
|
||||
sku = str(item['record'].product.i_code)
|
||||
item['ai_pick'] = ai_pick_map.get(sku)
|
||||
item['pchome_review'] = review_queue_map.get(sku)
|
||||
item['safe_momo_url'] = (
|
||||
item.get('safe_product_url')
|
||||
or normalize_momo_product_url(item['record'].product.url, sku)
|
||||
or _build_momo_product_url(sku)
|
||||
)
|
||||
item['category_color'] = get_color_for_string(item['record'].product.category)
|
||||
|
||||
skus = [item['record'].product.i_code for item in paged_items]
|
||||
pchome_map = _load_pchome_competitor_map(session, skus)
|
||||
pchome_attempt_map = _load_pchome_match_attempt_map(session, skus)
|
||||
pchome_ineligible_map = _load_pchome_ineligible_competitor_map(session, skus)
|
||||
for item in paged_items:
|
||||
product = item['record'].product
|
||||
sku = str(product.i_code)
|
||||
competitor = pchome_map.get(sku)
|
||||
attempt = pchome_attempt_map.get(sku)
|
||||
ineligible = pchome_ineligible_map.get(sku)
|
||||
match_status = _build_pchome_match_status(attempt, ineligible=ineligible)
|
||||
item['pchome_competitor'] = competitor
|
||||
item['pchome_match_attempt'] = attempt
|
||||
item['pchome_ineligible_competitor'] = ineligible
|
||||
item['pchome_match_status'] = match_status
|
||||
item['competitor_decision'] = _build_competitor_decision(
|
||||
item['record'].price,
|
||||
competitor.get('price') if competitor else None,
|
||||
match_status=match_status,
|
||||
)
|
||||
return paged_items
|
||||
|
||||
|
||||
def _render_pchome_review_dashboard(
|
||||
session,
|
||||
*,
|
||||
page,
|
||||
per_page,
|
||||
category_filter,
|
||||
sort_by,
|
||||
filter_type,
|
||||
order,
|
||||
review_status,
|
||||
search_query,
|
||||
now_taipei,
|
||||
today_start_db,
|
||||
):
|
||||
review_page = _load_competitor_review_page(
|
||||
session,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
search_query=search_query,
|
||||
category_filter=category_filter,
|
||||
review_status=review_status,
|
||||
)
|
||||
review_queue = review_page.get('items') or []
|
||||
review_queue_total = int(review_page.get('total') or len(review_queue))
|
||||
review_queue_map = {
|
||||
str(row.get('sku') or ''): row
|
||||
for row in review_queue
|
||||
if row.get('sku')
|
||||
}
|
||||
|
||||
paged_items = _build_review_dashboard_items(session, review_queue, today_start_db)
|
||||
_enrich_dashboard_items_for_competitor_review(session, paged_items, review_queue_map)
|
||||
|
||||
competitor_overview = _load_competitor_decision_overview(session)
|
||||
review_status_options = _build_review_status_options(competitor_overview)
|
||||
total_pages = math.ceil(review_queue_total / per_page) if review_queue_total else 0
|
||||
total_products_history = int(competitor_overview.get('total_active') or 0)
|
||||
|
||||
return render_template(
|
||||
'dashboard_v2.html',
|
||||
total_products=total_products_history,
|
||||
today_new_products=0,
|
||||
total_price_records=0,
|
||||
cnt_increase=0,
|
||||
cnt_decrease=0,
|
||||
today_delisted_count=0,
|
||||
today_delisted_items=[],
|
||||
system_status=_load_dashboard_system_status(),
|
||||
items=paged_items,
|
||||
categories=_load_dashboard_categories(session),
|
||||
current_page=page,
|
||||
total_pages=total_pages,
|
||||
total_items=review_queue_total,
|
||||
datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
today_date=now_taipei.strftime('%Y-%m-%d'),
|
||||
public_url=public_url,
|
||||
current_category=category_filter,
|
||||
current_filter=filter_type,
|
||||
current_review_status=review_status,
|
||||
review_status_options=review_status_options,
|
||||
search_query=search_query,
|
||||
current_sort=sort_by,
|
||||
current_order=order,
|
||||
ai_pick_summary=None,
|
||||
scheduler_stats=load_scheduler_stats(),
|
||||
avg_increase=0,
|
||||
avg_decrease=0,
|
||||
activity_rate=0,
|
||||
active_count=0,
|
||||
max_change_item=None,
|
||||
max_change_value=0,
|
||||
week_new_products=0,
|
||||
stable_count=0,
|
||||
most_active_category=None,
|
||||
most_active_count=0,
|
||||
competitor_overview=competitor_overview,
|
||||
ai_pick_list_limit=PRODUCT_PICK_LIST_LIMIT,
|
||||
build_momo_product_url=_build_momo_product_url,
|
||||
active_page='dashboard',
|
||||
)
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 核心數據函數
|
||||
# ==========================================
|
||||
@@ -1920,6 +2163,21 @@ def index():
|
||||
today_start_db = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0) # 保持台北時區
|
||||
|
||||
try:
|
||||
if filter_type == 'pchome_review':
|
||||
return _render_pchome_review_dashboard(
|
||||
session,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
category_filter=category_filter,
|
||||
sort_by=sort_by,
|
||||
filter_type=filter_type,
|
||||
order=order,
|
||||
review_status=review_status,
|
||||
search_query=search_query,
|
||||
now_taipei=now_taipei,
|
||||
today_start_db=today_start_db,
|
||||
)
|
||||
|
||||
# 使用深度快取獲取所有數據
|
||||
data = get_full_dashboard_data()
|
||||
if not data:
|
||||
@@ -1950,14 +2208,7 @@ def index():
|
||||
active_count = data.get('active_count', 0)
|
||||
|
||||
# 讀取系統狀態
|
||||
system_status = {"status": "UNKNOWN", "message": "尚無執行紀錄", "timestamp": "-"}
|
||||
status_path = os.path.join(BASE_DIR, 'data/system_status.json')
|
||||
if os.path.exists(status_path):
|
||||
try:
|
||||
with open(status_path, 'r', encoding='utf-8') as f:
|
||||
system_status = json.load(f)
|
||||
except:
|
||||
pass
|
||||
system_status = _load_dashboard_system_status()
|
||||
|
||||
# 後端篩選
|
||||
scheduler_stats = load_scheduler_stats()
|
||||
|
||||
@@ -133,6 +133,10 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data():
|
||||
assert "fetch_competitor_review_queue" in route_source
|
||||
assert "fetch_competitor_review_queue_page" in route_source
|
||||
assert "_load_competitor_review_page(" in route_source
|
||||
assert "def _render_pchome_review_dashboard(" in route_source
|
||||
assert "return _render_pchome_review_dashboard(" in route_source
|
||||
assert "_build_review_dashboard_items(session, review_queue, today_start_db)" in route_source
|
||||
assert "只替 PChome 覆核當頁建立商品列" in route_source
|
||||
assert "_load_competitor_decision_overview(session, unique_items)" in route_source
|
||||
assert "item_map = {}" in route_source
|
||||
assert "competitor_map = _load_pchome_competitor_map(session, item_map.keys())" in route_source
|
||||
|
||||
Reference in New Issue
Block a user