#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 商品看板路由模組 包含:首頁儀表板、商品列表、統計數據 """ import os import json import math import time import hashlib import pickle import threading from datetime import datetime, timezone, timedelta from flask import Blueprint, request, render_template from sqlalchemy import func, and_, text, bindparam from sqlalchemy.orm import joinedload from auth import login_required from config import BASE_DIR, SYSTEM_VERSION, public_url from database.manager import DatabaseManager from database.models import Product, PriceRecord from services.logger_manager import SystemLogger from services.cache_manager import ( _DASHBOARD_DATA_CACHE, _DASHBOARD_CACHE_TTL, _DASHBOARD_SHARED_CACHE_FILE, _DASHBOARD_STALE_CACHE_FILE, ) from utils.momo_url_utils import build_momo_product_url, normalize_momo_product_url # 時區設定 TAIPEI_TZ = timezone(timedelta(hours=8)) # Logger sys_log = SystemLogger("DashboardRoutes").get_logger() # Blueprint 定義 dashboard_bp = Blueprint('dashboard', __name__) PRODUCT_PICK_LIST_LIMIT = 50 PCHOME_MATCH_SCORE_FLOOR = 0.76 def _build_pchome_product_url(product_id): if not product_id: return None return f"https://24h.pchome.com.tw/prod/{str(product_id).strip()}" def _build_momo_product_url(i_code): return build_momo_product_url(i_code) def _to_float(value): if value is None: return None try: return float(value) except (TypeError, ValueError): return None def _build_pchome_match_status(attempt=None, ineligible=None): if attempt: status = attempt.get('attempt_status') or 'unknown' if status == 'matched': score = _to_float(attempt.get('best_match_score')) score_text = f"最佳候選 {round(score * 100)}%" if score is not None else "已完成身份比對" return { 'label': '已配對待刷新', 'tone': 'watch', 'summary': '曾通過 identity_v2,但目前沒有有效價格快取,等待下一輪刷新', 'detail': score_text, } if status == 'expired_match': score = _to_float(attempt.get('best_match_score')) score_text = f"身份分數 {round(score * 100)}%" if score is not None else "已完成身份比對" return { 'label': '價格過期待刷新', 'tone': 'watch', 'summary': '同款身份已確認,但 PChome 價格快取過期,不顯示舊價避免誤判', 'detail': score_text, } if status == 'identity_veto': score = _to_float(attempt.get('best_match_score')) score_text = f"最佳候選 {round(score * 100)}%" if score is not None else "已拒絕候選" return { 'label': '身份否決', 'tone': 'neutral', 'summary': '新版 identity_v2 判定不是同款,已阻擋自動比價', 'detail': score_text, } if ineligible: reason = ineligible.get('reason') or 'not_eligible' score = _to_float(ineligible.get('match_score')) score_text = f"match {round(score * 100)}%" if score is not None else None if reason == 'expired_match': return { 'label': '價格過期待刷新', 'tone': 'watch', 'summary': '已有高信心同款配對,但 PChome 價格快取過期,等待補抓刷新', 'detail': score_text, } if reason == 'legacy_without_identity_v2': return { 'label': '舊版配對待重驗', 'tone': 'neutral', 'summary': '舊版 PChome 配對尚未通過 identity_v2,不進入正式決策', 'detail': score_text, } if reason == 'below_score_floor': return { 'label': '低分配對待審', 'tone': 'neutral', 'summary': '已有候選但低於高信心門檻,避免錯配所以暫不採用', 'detail': score_text, } if reason == 'invalid_price': return { 'label': '價格無效待刷新', 'tone': 'watch', 'summary': 'PChome 配對缺少有效價格,等待下一輪補抓', 'detail': None, } if not attempt: return { 'label': '尚未搜尋', 'tone': 'neutral', 'summary': '尚未進入 PChome 補抓佇列', 'detail': None, } status = attempt.get('attempt_status') or 'unknown' score = _to_float(attempt.get('best_match_score')) candidate_count = int(attempt.get('candidate_count') or 0) score_text = f"最佳候選 {round(score * 100)}%" if score is not None else "尚無候選分數" if status == 'low_score': diagnostic_text = attempt.get('error_message') or '' if 'brand_conflict' in diagnostic_text: label = '品牌衝突待審' summary = f'{score_text},品牌不一致,已停止自動採用' elif any(token in diagnostic_text for token in ('volume_conflict', 'weight_conflict', 'count_conflict')): label = '規格衝突待審' summary = f'{score_text},容量/件數不一致,已停止自動採用' elif 'type_conflict' in diagnostic_text: label = '品類衝突待審' summary = f'{score_text},品類不一致,已停止自動採用' else: label = '低信心待審' summary = f'{score_text},不自動採用以避免錯配' return { 'label': label, 'tone': 'neutral', 'summary': summary, 'detail': f'{candidate_count} 筆候選', } if status == 'needs_review': return { 'label': '配對衝突待審', 'tone': 'neutral', 'summary': '新候選與既有配對不同,需人工確認後再覆蓋', 'detail': f'{score_text} / {candidate_count} 筆候選', } if status in {'no_result', 'no_match'}: return { 'label': '找不到同款', 'tone': 'neutral', 'summary': 'PChome 搜尋無可信候選,需補關鍵字或人工覆核', 'detail': f'{candidate_count} 筆候選', } if status == 'error': return { 'label': '抓取異常', 'tone': 'risk', 'summary': 'PChome 比對流程發生錯誤,請查看嘗試紀錄', 'detail': attempt.get('error_message'), } return { 'label': '待比對', 'tone': 'neutral', 'summary': '尚無有效 PChome 對應商品或價格快取', 'detail': score_text, } def _build_competitor_decision(momo_price, pchome_price, match_status=None): if not pchome_price: status = match_status or _build_pchome_match_status() return { 'label': status['label'], 'tone': status['tone'], 'gap_amount': None, 'gap_pct': None, 'summary': status['summary'] } momo_price = float(momo_price or 0) pchome_price = float(pchome_price) gap_amount = momo_price - pchome_price gap_pct = (gap_amount / pchome_price * 100) if pchome_price else 0 if gap_pct >= 5: return { 'label': 'PChome 優勢', 'tone': 'win', 'gap_amount': gap_amount, 'gap_pct': gap_pct, 'summary': 'PChome 較便宜,可加強曝光與轉換' } if gap_pct <= -5: return { 'label': 'MOMO 威脅', 'tone': 'risk', 'gap_amount': gap_amount, 'gap_pct': gap_pct, 'summary': 'MOMO 較便宜,需評估價格或促銷因應' } return { 'label': '價格接近', 'tone': 'watch', 'gap_amount': gap_amount, 'gap_pct': gap_pct, 'summary': '價差有限,建議主打服務、到貨或回饋' } def _load_pchome_competitor_map(session, skus): sku_list = [str(sku) for sku in skus if sku] if not sku_list: return {} try: stmt = text(""" SELECT sku, price, original_price, discount_pct, competitor_product_id, competitor_product_name, match_score, tags, crawled_at, expires_at FROM competitor_prices WHERE source = 'pchome' AND sku IN :skus AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) AND COALESCE(match_score, 0) >= :match_score_floor AND COALESCE(tags, '[]'::jsonb) ? 'identity_v2' """).bindparams(bindparam("skus", expanding=True)) rows = session.execute( stmt, {"skus": sku_list, "match_score_floor": PCHOME_MATCH_SCORE_FLOOR}, ).mappings().all() except Exception as exc: sys_log.warning(f"[Dashboard] PChome 競品價格資料讀取略過: {exc}") return {} result = {} for row in rows: competitor_product_id = row.get('competitor_product_id') result[str(row.get('sku'))] = { 'source': 'pchome', 'price': _to_float(row.get('price')), 'original_price': _to_float(row.get('original_price')), 'discount_pct': row.get('discount_pct'), 'product_id': competitor_product_id, 'product_name': row.get('competitor_product_name'), 'product_url': _build_pchome_product_url(competitor_product_id), 'match_score': _to_float(row.get('match_score')), 'tags': row.get('tags'), 'crawled_at': row.get('crawled_at'), 'expires_at': row.get('expires_at'), } return result def _load_pchome_ineligible_competitor_map(session, skus): """Read non-decision PChome rows so the UI can explain why a SKU is pending.""" sku_list = [str(sku) for sku in skus if sku] if not sku_list: return {} try: stmt = text(""" WITH ineligible AS ( SELECT sku, price, competitor_product_id, competitor_product_name, match_score, tags, crawled_at, expires_at, CASE WHEN price IS NULL OR price <= 0 THEN 'invalid_price' WHEN (expires_at IS NOT NULL AND expires_at <= CURRENT_TIMESTAMP) AND COALESCE(match_score, 0) >= :match_score_floor AND COALESCE(tags, '[]'::jsonb) ? 'identity_v2' THEN 'expired_match' WHEN NOT (COALESCE(tags, '[]'::jsonb) ? 'identity_v2') THEN 'legacy_without_identity_v2' WHEN COALESCE(match_score, 0) < :match_score_floor THEN 'below_score_floor' ELSE 'not_eligible' END AS reason, ROW_NUMBER() OVER ( PARTITION BY sku ORDER BY CASE WHEN (expires_at IS NOT NULL AND expires_at <= CURRENT_TIMESTAMP) AND COALESCE(match_score, 0) >= :match_score_floor AND COALESCE(tags, '[]'::jsonb) ? 'identity_v2' THEN 0 WHEN NOT (COALESCE(tags, '[]'::jsonb) ? 'identity_v2') THEN 1 WHEN COALESCE(match_score, 0) < :match_score_floor THEN 2 ELSE 3 END, crawled_at DESC NULLS LAST, match_score DESC NULLS LAST ) AS rn FROM competitor_prices WHERE source = 'pchome' AND sku IN :skus AND NOT ( (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) AND price IS NOT NULL AND price > 0 AND COALESCE(match_score, 0) >= :match_score_floor AND COALESCE(tags, '[]'::jsonb) ? 'identity_v2' ) ) SELECT * FROM ineligible WHERE rn = 1 """).bindparams(bindparam("skus", expanding=True)) rows = session.execute( stmt, {"skus": sku_list, "match_score_floor": PCHOME_MATCH_SCORE_FLOOR}, ).mappings().all() except Exception as exc: sys_log.warning(f"[Dashboard] PChome 非有效配對原因讀取略過: {exc}") return {} result = {} for row in rows: result[str(row.get('sku'))] = { 'reason': row.get('reason'), 'price': _to_float(row.get('price')), 'product_id': row.get('competitor_product_id'), 'product_name': row.get('competitor_product_name'), 'match_score': _to_float(row.get('match_score')), 'tags': row.get('tags'), 'crawled_at': row.get('crawled_at'), 'expires_at': row.get('expires_at'), } return result def _load_pchome_match_attempt_map(session, skus): sku_list = [str(sku) for sku in skus if sku] if not sku_list: return {} try: stmt = text(""" WITH ranked AS ( SELECT sku, attempt_status, candidate_count, best_competitor_product_id, best_competitor_product_name, best_competitor_price, best_match_score, error_message, attempted_at, ROW_NUMBER() OVER (PARTITION BY sku ORDER BY attempted_at DESC) AS rn FROM competitor_match_attempts WHERE source = 'pchome' AND sku IN :skus ) SELECT * FROM ranked WHERE rn = 1 """).bindparams(bindparam("skus", expanding=True)) rows = session.execute(stmt, {"skus": sku_list}).mappings().all() except Exception as exc: sys_log.warning(f"[Dashboard] PChome 比對嘗試資料讀取略過: {exc}") return {} return {str(row.get('sku')): dict(row) for row in rows} def _format_dashboard_dt(value): if not value: return None if hasattr(value, "strftime"): return value.strftime("%Y-%m-%d %H:%M") return str(value) def _parse_agent_footprint(value): if not value: return {} if isinstance(value, str): try: value = json.loads(value) except Exception: return {} if not isinstance(value, dict): return {} agent = value.get('agent') return agent if isinstance(agent, dict) else {} def _ai_pick_evidence_fields(model_footprint): agent = _parse_agent_footprint(model_footprint) missing_evidence = agent.get('missing_evidence') or [] if isinstance(missing_evidence, str): missing_evidence = [missing_evidence] missing_evidence = [str(item) for item in missing_evidence if item] return { 'opportunity_score': _to_float(agent.get('opportunity_score')) or 0, 'evidence_quality': _to_float(agent.get('evidence_quality')) or 0, 'confidence_band': agent.get('confidence_band') or 'needs_evidence', 'missing_evidence': missing_evidence, 'missing_evidence_text': '、'.join(missing_evidence), 'margin_rate': _to_float(agent.get('margin_rate')), } def _dashboard_decision_row(row, tone): sku = str(row.get('sku') or '') pchome_id = row.get('competitor_product_id') momo_url = normalize_momo_product_url(row.get('momo_url'), sku) or _build_momo_product_url(sku) return { 'sku': sku, 'name': row.get('name') or '', 'category': row.get('category') or '', 'momo_price': _to_float(row.get('momo_price')) or 0, 'pchome_price': _to_float(row.get('pchome_price')) or 0, 'gap_pct': _to_float(row.get('gap_pct')) or 0, 'gap_amount': _to_float(row.get('gap_amount')) or 0, 'confidence': _to_float(row.get('confidence')), 'reason': row.get('reason') or '', 'tone': tone, 'momo_url': momo_url, 'pchome_id': pchome_id, 'pchome_name': row.get('competitor_product_name') or '', 'pchome_url': _build_pchome_product_url(pchome_id), 'crawled_at': _format_dashboard_dt(row.get('crawled_at') or row.get('created_at')), **_ai_pick_evidence_fields(row.get('model_footprint')), } def _load_competitor_decision_overview(session, latest_items=None): """讀取商品看板第一屏使用的 PChome 比價決策摘要。全部來自正式 DB。""" cache_key = 'competitor_decision_overview' cache_ts_key = 'competitor_decision_overview_timestamp' cached = _DASHBOARD_DATA_CACHE.get(cache_key) cached_ts = _DASHBOARD_DATA_CACHE.get(cache_ts_key) if cached and cached_ts: age = time.time() - cached_ts if age < min(_DASHBOARD_CACHE_TTL, 300): return cached default = { 'total_active': 0, 'matched_count': 0, 'match_rate': 0, 'pchome_advantage_count': 0, 'momo_threat_count': 0, 'near_count': 0, 'pending_match_count': 0, 'ai_pick_count': 0, 'avg_advantage_gap': 0, 'last_pchome_crawled': None, 'top_picks': [], 'top_pchome_advantages': [], 'top_momo_threats': [], 'pending_priority': [], } if latest_items: try: item_map = {} for item in latest_items: record = item.get('record') product = getattr(record, 'product', None) sku = str(getattr(product, 'i_code', '') or '') if not sku: continue safe_product_url = normalize_momo_product_url(getattr(product, 'url', None), sku) item_map[sku] = { 'sku': sku, 'name': getattr(product, 'name', '') or '', 'category': getattr(product, 'category', '') or '', 'momo_url': safe_product_url or _build_momo_product_url(sku), 'momo_price': _to_float(getattr(record, 'price', None)) or 0, } competitor_map = _load_pchome_competitor_map(session, item_map.keys()) compared = [] for sku, item in item_map.items(): competitor = competitor_map.get(sku) pchome_price = _to_float(competitor.get('price')) if competitor else None if not pchome_price: continue gap_amount = item['momo_price'] - pchome_price gap_pct = gap_amount / pchome_price * 100 if pchome_price else 0 compared.append({ **item, 'pchome_price': pchome_price, 'competitor_product_id': competitor.get('product_id'), 'competitor_product_name': competitor.get('product_name'), 'match_score': competitor.get('match_score'), 'crawled_at': competitor.get('crawled_at'), 'gap_amount': gap_amount, 'gap_pct': gap_pct, }) picks_rows = session.execute(text(""" SELECT sku, name, momo_price, pchome_price, gap_pct, confidence, reason, model_footprint, created_at FROM ai_price_recommendations WHERE strategy = 'product_pick' AND status = 'pending' ORDER BY confidence DESC NULLS LAST, gap_pct DESC NULLS LAST, created_at DESC LIMIT 3 """)).mappings().all() ai_pick_count = session.execute(text(""" SELECT COUNT(*) FROM ai_price_recommendations WHERE strategy = 'product_pick' AND status = 'pending' """)).scalar() or 0 total_active = len(item_map) matched_count = len(compared) pchome_advantages = [row for row in compared if row['gap_pct'] >= 5] momo_threats = [row for row in compared if row['gap_pct'] <= -5] near_items = [row for row in compared if -5 < row['gap_pct'] < 5] pending_items = [ row for sku, row in item_map.items() if sku not in competitor_map ] last_crawled = max( (row.get('crawled_at') for row in compared if row.get('crawled_at')), default=None, ) overview = dict(default) overview.update({ 'total_active': total_active, 'matched_count': matched_count, 'match_rate': round(matched_count / max(total_active, 1) * 100, 1), 'pchome_advantage_count': len(pchome_advantages), 'momo_threat_count': len(momo_threats), 'near_count': len(near_items), 'pending_match_count': max(total_active - matched_count, 0), 'ai_pick_count': int(ai_pick_count), 'avg_advantage_gap': round( sum(row['gap_pct'] for row in pchome_advantages) / len(pchome_advantages), 1, ) if pchome_advantages else 0, 'last_pchome_crawled': _format_dashboard_dt(last_crawled), }) overview['top_pchome_advantages'] = [ _dashboard_decision_row(row, 'win') for row in sorted(pchome_advantages, key=lambda row: row['gap_pct'], reverse=True)[:3] ] overview['top_momo_threats'] = [ _dashboard_decision_row(row, 'risk') for row in sorted(momo_threats, key=lambda row: row['gap_pct'])[:3] ] overview['top_picks'] = [] for row in picks_rows: pick = dict(row) competitor = competitor_map.get(str(pick.get('sku') or '')) or {} pick['competitor_product_id'] = competitor.get('product_id') pick['competitor_product_name'] = competitor.get('product_name') pick['crawled_at'] = competitor.get('crawled_at') overview['top_picks'].append(_dashboard_decision_row(pick, 'pick')) overview['pending_priority'] = [ { 'sku': row['sku'], 'name': row['name'], 'category': row['category'], 'momo_price': row['momo_price'], 'momo_url': normalize_momo_product_url(row.get('momo_url'), row.get('sku')) or _build_momo_product_url(row.get('sku')), } for row in sorted(pending_items, key=lambda row: row['momo_price'], reverse=True)[:3] ] _DASHBOARD_DATA_CACHE[cache_key] = overview _DASHBOARD_DATA_CACHE[cache_ts_key] = time.time() return overview except Exception as exc: sys_log.warning(f"[Dashboard] PChome 比價快取摘要讀取略過,改用 SQL 後備: {exc}") try: session.rollback() except Exception: pass latest_compared_cte = f""" WITH latest_momo AS ( SELECT p.id AS product_id, p.i_code AS sku, p.name, p.url AS momo_url, 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' ), latest_products AS ( SELECT * FROM latest_momo WHERE rn = 1 ), valid_competitor AS ( SELECT DISTINCT ON (cp.sku) cp.sku, cp.price AS pchome_price, cp.competitor_product_id, cp.competitor_product_name, cp.match_score, cp.crawled_at 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 ), compared AS ( SELECT lp.*, vc.pchome_price, vc.competitor_product_id, vc.competitor_product_name, vc.match_score, vc.crawled_at, (lp.momo_price - vc.pchome_price) AS gap_amount, ((lp.momo_price - vc.pchome_price) / vc.pchome_price * 100) AS gap_pct FROM latest_products lp JOIN valid_competitor vc ON vc.sku = lp.sku ) """ stats_sql = text(latest_compared_cte + """ SELECT (SELECT COUNT(*) FROM products WHERE status = 'ACTIVE') AS total_active, (SELECT COUNT(*) FROM compared) AS matched_count, (SELECT COUNT(*) FROM compared WHERE gap_pct >= 5) AS pchome_advantage_count, (SELECT COUNT(*) FROM compared WHERE gap_pct <= -5) AS momo_threat_count, (SELECT COUNT(*) FROM compared WHERE gap_pct > -5 AND gap_pct < 5) AS near_count, (SELECT COALESCE(ROUND(AVG(gap_pct)::numeric, 1), 0) FROM compared WHERE gap_pct >= 5) AS avg_advantage_gap, (SELECT COUNT(*) FROM ai_price_recommendations WHERE strategy = 'product_pick' AND status = 'pending') AS ai_pick_count, (SELECT MAX(crawled_at) FROM competitor_prices WHERE source = 'pchome') AS last_pchome_crawled """) advantage_sql = text(latest_compared_cte + """ SELECT * FROM compared WHERE gap_pct >= 5 ORDER BY gap_pct DESC NULLS LAST, crawled_at DESC NULLS LAST LIMIT 3 """) threat_sql = text(latest_compared_cte + """ SELECT * FROM compared WHERE gap_pct <= -5 ORDER BY gap_pct ASC NULLS LAST, crawled_at DESC NULLS LAST LIMIT 3 """) pending_sql = text(f""" WITH latest_momo AS ( SELECT p.i_code AS sku, p.name, p.url AS momo_url, 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' ) SELECT lm.* FROM latest_momo lm LEFT JOIN competitor_prices cp ON cp.sku = lm.sku AND 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' WHERE lm.rn = 1 AND cp.sku IS NULL ORDER BY lm.momo_price DESC NULLS LAST LIMIT 3 """) picks_sql = text(f""" WITH valid_competitor AS ( SELECT DISTINCT ON (cp.sku) cp.sku, cp.competitor_product_id, cp.competitor_product_name, cp.crawled_at 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 ) SELECT ar.sku, ar.name, ar.momo_price, ar.pchome_price, ar.gap_pct, ar.confidence, ar.reason, ar.model_footprint, ar.created_at, vc.competitor_product_id, vc.competitor_product_name, vc.crawled_at FROM ai_price_recommendations ar LEFT JOIN valid_competitor vc ON vc.sku = ar.sku WHERE ar.strategy = 'product_pick' AND ar.status = 'pending' ORDER BY ar.confidence DESC NULLS LAST, ar.gap_pct DESC NULLS LAST, ar.created_at DESC LIMIT 3 """) try: stats = session.execute(stats_sql).mappings().first() overview = dict(default) if stats: total_active = int(stats.get('total_active') or 0) matched_count = int(stats.get('matched_count') or 0) overview.update({ 'total_active': total_active, 'matched_count': matched_count, 'match_rate': round(matched_count / max(total_active, 1) * 100, 1), 'pchome_advantage_count': int(stats.get('pchome_advantage_count') or 0), 'momo_threat_count': int(stats.get('momo_threat_count') or 0), 'near_count': int(stats.get('near_count') or 0), 'pending_match_count': max(total_active - matched_count, 0), 'ai_pick_count': int(stats.get('ai_pick_count') or 0), 'avg_advantage_gap': _to_float(stats.get('avg_advantage_gap')) or 0, 'last_pchome_crawled': _format_dashboard_dt(stats.get('last_pchome_crawled')), }) overview['top_pchome_advantages'] = [ _dashboard_decision_row(row, 'win') for row in session.execute(advantage_sql).mappings().all() ] overview['top_momo_threats'] = [ _dashboard_decision_row(row, 'risk') for row in session.execute(threat_sql).mappings().all() ] overview['top_picks'] = [ _dashboard_decision_row(row, 'pick') for row in session.execute(picks_sql).mappings().all() ] overview['pending_priority'] = [ { 'sku': str(row.get('sku') or ''), 'name': row.get('name') or '', 'category': row.get('category') or '', 'momo_price': _to_float(row.get('momo_price')) or 0, 'momo_url': normalize_momo_product_url(row.get('momo_url'), row.get('sku')) or _build_momo_product_url(row.get('sku')), } for row in session.execute(pending_sql).mappings().all() ] _DASHBOARD_DATA_CACHE[cache_key] = overview _DASHBOARD_DATA_CACHE[cache_ts_key] = time.time() return overview except Exception as exc: sys_log.warning(f"[Dashboard] PChome 比價決策摘要讀取略過: {exc}") try: session.rollback() except Exception: pass _DASHBOARD_DATA_CACHE[cache_key] = default _DASHBOARD_DATA_CACHE[cache_ts_key] = time.time() return default def _load_ai_pick_selection(session, limit=PRODUCT_PICK_LIST_LIMIT): """讀取商品看板 AI 挑品清單排序,供列表篩選使用。""" sql = text(""" SELECT sku, confidence, reason, momo_price, pchome_price, gap_pct, model_footprint, created_at FROM ai_price_recommendations WHERE strategy = 'product_pick' AND status = 'pending' ORDER BY confidence DESC NULLS LAST, gap_pct DESC NULLS LAST, created_at DESC LIMIT :limit """) try: rows = session.execute(sql, {"limit": limit}).mappings().all() except Exception as exc: sys_log.warning(f"[Dashboard] AI 挑品清單讀取略過: {exc}") try: session.rollback() except Exception: pass return [], {} skus = [] pick_map = {} for idx, row in enumerate(rows, start=1): sku = str(row.get('sku') or '') if not sku or sku in pick_map: continue skus.append(sku) pick_map[sku] = { 'rank': idx, 'confidence': _to_float(row.get('confidence')) or 0, 'reason': row.get('reason') or '', 'momo_price': _to_float(row.get('momo_price')) or 0, 'pchome_price': _to_float(row.get('pchome_price')) or 0, 'gap_pct': _to_float(row.get('gap_pct')) or 0, 'created_at': _format_dashboard_dt(row.get('created_at')), **_ai_pick_evidence_fields(row.get('model_footprint')), } return skus, pick_map def _summarize_ai_pick_selection(ai_pick_map): """彙整目前 AI 挑品清單的可操作摘要,全部來自 ai_price_recommendations。""" picks = list(ai_pick_map.values()) if not picks: return { 'count': 0, 'avg_confidence': 0, 'avg_evidence_quality': 0, 'avg_opportunity_score': 0, 'avg_gap_pct': 0, 'max_gap_pct': 0, 'total_gap_amount': 0, 'high_confidence_count': 0, 'needs_evidence_count': 0, 'top_missing_evidence': [], 'generated_at': None, } confidence_values = [pick.get('confidence', 0) for pick in picks] evidence_values = [pick.get('evidence_quality', 0) for pick in picks] opportunity_values = [pick.get('opportunity_score', 0) for pick in picks] gap_values = [pick.get('gap_pct', 0) for pick in picks] missing_counts = {} for pick in picks: for label in pick.get('missing_evidence', []): missing_counts[label] = missing_counts.get(label, 0) + 1 total_gap_amount = sum( max((pick.get('momo_price') or 0) - (pick.get('pchome_price') or 0), 0) for pick in picks ) return { 'count': len(picks), 'avg_confidence': round(sum(confidence_values) / len(confidence_values), 3), 'avg_evidence_quality': round(sum(evidence_values) / len(evidence_values), 1), 'avg_opportunity_score': round(sum(opportunity_values) / len(opportunity_values), 1), 'avg_gap_pct': round(sum(gap_values) / len(gap_values), 1), 'max_gap_pct': round(max(gap_values), 1), 'total_gap_amount': round(total_gap_amount), 'high_confidence_count': sum(1 for value in confidence_values if value >= 0.65), 'needs_evidence_count': sum(1 for pick in picks if pick.get('confidence_band') == 'needs_evidence'), 'top_missing_evidence': [ {'label': label, 'count': count} for label, count in sorted(missing_counts.items(), key=lambda item: item[1], reverse=True)[:3] ], 'generated_at': max( (pick.get('created_at') for pick in picks if pick.get('created_at')), default=None, ), } # ========================================== # 快取與監控變數 # ========================================== import fcntl _DASHBOARD_LOCK_FILE = os.path.join(BASE_DIR, 'data', '.dashboard_cache.lock') # V-Opt: 檔案鎖(跨進程) class FileLock: """簡單的檔案鎖,用於 gunicorn 多進程環境""" def __init__(self, lock_file): self.lock_file = lock_file self.fd = None def acquire(self, blocking=True): """取得鎖""" try: self.fd = open(self.lock_file, 'w') if blocking: fcntl.flock(self.fd, fcntl.LOCK_EX) else: fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB) return True except (IOError, OSError): if self.fd: self.fd.close() self.fd = None return False def release(self): """釋放鎖""" if self.fd: fcntl.flock(self.fd, fcntl.LOCK_UN) self.fd.close() self.fd = None _DASHBOARD_STALE_CACHE_MAX_AGE = 86400 _DASHBOARD_REFRESH_STATE = { 'started_at': 0, 'running': False, } def _new_dashboard_file_lock(): return FileLock(_DASHBOARD_LOCK_FILE) def _load_dashboard_cache_file(now, cache_path, *, allow_stale=False, label='共享'): """讀取跨 worker 商品看板深度快取;必要時可讀舊快取救援首屏。""" cache_file = str(cache_path) if not os.path.exists(cache_file): return None try: with open(cache_file, 'rb') as f: payload = pickle.load(f) full_timestamp = payload.get('full_timestamp') full_data = payload.get('full_data') if not full_timestamp or not full_data: return None age = now.timestamp() - full_timestamp max_age = _DASHBOARD_STALE_CACHE_MAX_AGE if allow_stale else _DASHBOARD_CACHE_TTL if age >= max_age: return None _DASHBOARD_DATA_CACHE['full_data'] = full_data _DASHBOARD_DATA_CACHE['full_timestamp'] = full_timestamp _DASHBOARD_DATA_CACHE['consolidated_data'] = payload.get('consolidated_data') _DASHBOARD_DATA_CACHE['consolidated_timestamp'] = payload.get('consolidated_timestamp') _DASHBOARD_DATA_CACHE['today_start'] = payload.get('today_start') level = sys_log.info if allow_stale and age >= _DASHBOARD_CACHE_TTL else sys_log.debug level(f"[Dashboard] [Cache] ✅ 使用{label}完整看板快取 | 快取年齡: {age:.0f}秒") return full_data except Exception as exc: sys_log.warning(f"[Dashboard] [Cache] {label}快取讀取失敗,改走資料庫重建: {exc}") return None def _load_shared_full_dashboard_cache(now): return _load_dashboard_cache_file(now, _DASHBOARD_SHARED_CACHE_FILE) def _load_stale_full_dashboard_cache(now): stale_data = _load_dashboard_cache_file( now, _DASHBOARD_STALE_CACHE_FILE, allow_stale=True, label='舊版救援', ) if stale_data: return stale_data return _load_dashboard_cache_file( now, _DASHBOARD_SHARED_CACHE_FILE, allow_stale=True, label='過期救援', ) def _load_expired_shared_full_dashboard_cache(now): """只讀原 shared cache 檔的過期版本;不讀 clear_cache 後搬走的 stale 檔。""" return _load_dashboard_cache_file( now, _DASHBOARD_SHARED_CACHE_FILE, allow_stale=True, label='過期共享', ) def _trigger_dashboard_background_refresh(reason): """使用過期 shared cache 先回首頁時,背景補一次 fresh cache,避免使用者卡 10s。""" now_ts = time.time() if _DASHBOARD_REFRESH_STATE['running']: return if now_ts - _DASHBOARD_REFRESH_STATE['started_at'] < 60: return def _refresh(): _DASHBOARD_REFRESH_STATE['running'] = True try: warm_full_dashboard_cache(reason=reason, force_rebuild=True) except Exception as exc: sys_log.warning(f"[Dashboard] [Cache] 背景預熱失敗: {exc}") finally: _DASHBOARD_REFRESH_STATE['running'] = False _DASHBOARD_REFRESH_STATE['started_at'] = now_ts thread = threading.Thread(target=_refresh, name='dashboard-cache-refresh', daemon=True) thread.start() def _write_shared_full_dashboard_cache(full_data): """原子寫入跨 worker 共享的商品看板深度快取。""" cache_file = str(_DASHBOARD_SHARED_CACHE_FILE) tmp_file = f"{cache_file}.{os.getpid()}.tmp" payload = { 'full_data': full_data, 'full_timestamp': _DASHBOARD_DATA_CACHE.get('full_timestamp'), 'consolidated_data': _DASHBOARD_DATA_CACHE.get('consolidated_data'), 'consolidated_timestamp': _DASHBOARD_DATA_CACHE.get('consolidated_timestamp'), 'today_start': _DASHBOARD_DATA_CACHE.get('today_start'), } try: os.makedirs(os.path.dirname(cache_file), exist_ok=True) with open(tmp_file, 'wb') as f: pickle.dump(payload, f, protocol=pickle.HIGHEST_PROTOCOL) os.replace(tmp_file, cache_file) except Exception as exc: sys_log.warning(f"[Dashboard] [Cache] 共享快取寫入失敗,仍保留記憶體快取: {exc}") try: if os.path.exists(tmp_file): os.remove(tmp_file) except OSError: pass def warm_full_dashboard_cache(reason='manual', force_rebuild=False): """供 API、排程與 Gunicorn worker 啟動時預熱商品看板完整快取。""" started = time.time() data = get_full_dashboard_data(force_rebuild=force_rebuild) duration_ms = (time.time() - started) * 1000 sys_log.info( f"[Dashboard] [Cache] ✅ 預熱完成 | reason={reason} | " f"items={len(data.get('unique_items', [])) if data else 0} | 耗時={duration_ms:.0f}ms" ) return data # 慢查詢監控 _SLOW_QUERY_STATS = { 'total_queries': 0, 'slow_queries': 0, 'very_slow_queries': 0, 'total_query_time_ms': 0, 'last_slow_query': None, 'last_slow_query_time': None, } _SLOW_QUERY_THRESHOLD_MS = 1000 _VERY_SLOW_QUERY_THRESHOLD_MS = 5000 def track_query_time(query_name, duration_ms): """追蹤查詢時間,更新慢查詢統計""" global _SLOW_QUERY_STATS _SLOW_QUERY_STATS['total_queries'] += 1 _SLOW_QUERY_STATS['total_query_time_ms'] += duration_ms if duration_ms >= _VERY_SLOW_QUERY_THRESHOLD_MS: _SLOW_QUERY_STATS['very_slow_queries'] += 1 _SLOW_QUERY_STATS['slow_queries'] += 1 _SLOW_QUERY_STATS['last_slow_query'] = query_name _SLOW_QUERY_STATS['last_slow_query_time'] = datetime.now(TAIPEI_TZ).isoformat() elif duration_ms >= _SLOW_QUERY_THRESHOLD_MS: _SLOW_QUERY_STATS['slow_queries'] += 1 _SLOW_QUERY_STATS['last_slow_query'] = query_name _SLOW_QUERY_STATS['last_slow_query_time'] = datetime.now(TAIPEI_TZ).isoformat() # ========================================== # 輔助函數 # ========================================== def get_color_for_string(s): """為字串生成一個穩定且美觀的 HSL 顏色""" if not s: return "hsl(0, 0%, 85%)" hash_val = int(hashlib.md5(s.encode('utf-8'), usedforsecurity=False).hexdigest(), 16) hue = hash_val % 360 return f"hsl({hue}, 60%, 88%)" def load_scheduler_stats(): """讀取排程統計資料""" stats_path = os.path.join(BASE_DIR, 'data', 'scheduler_stats.json') if os.path.exists(stats_path): try: with open(stats_path, 'r', encoding='utf-8') as f: return json.load(f) except (IOError, json.JSONDecodeError): return {} return {} # ========================================== # 核心數據函數 # ========================================== def get_consolidated_data(): """統一封裝:獲取全分類去重後的當前數據、昨日對比及差值 (帶快取)""" global _DASHBOARD_DATA_CACHE now = datetime.now(TAIPEI_TZ) # V-Opt: 先檢查快取(無需鎖) if (_DASHBOARD_DATA_CACHE['consolidated_data'] is not None and _DASHBOARD_DATA_CACHE['consolidated_timestamp'] is not None): cache_age = (now.timestamp() - _DASHBOARD_DATA_CACHE['consolidated_timestamp']) if cache_age < _DASHBOARD_CACHE_TTL: sys_log.debug(f"[Dashboard] [Cache] ✅ 使用快取資料 | 快取年齡: {cache_age:.1f}秒") return _DASHBOARD_DATA_CACHE['consolidated_data'], _DASHBOARD_DATA_CACHE['today_start'] # V-Opt: 使用檔案鎖避免多 gunicorn worker 同時重建快取 # 注意: get_consolidated_data 通常由 get_full_dashboard_data 調用, # 後者已持有 _DASHBOARD_FILE_LOCK,因此這裡可以不重複鎖定 # 但為避免直接調用時的競爭問題,仍保留快取檢查邏輯 # 再次檢查快取(可能其他 worker 已經更新) if (_DASHBOARD_DATA_CACHE['consolidated_data'] is not None and _DASHBOARD_DATA_CACHE['consolidated_timestamp'] is not None): cache_age = (now.timestamp() - _DASHBOARD_DATA_CACHE['consolidated_timestamp']) if cache_age < _DASHBOARD_CACHE_TTL: sys_log.debug(f"[Dashboard] [Cache] ✅ 使用快取資料 (其他 worker 已更新) | 快取年齡: {cache_age:.1f}秒") return _DASHBOARD_DATA_CACHE['consolidated_data'], _DASHBOARD_DATA_CACHE['today_start'] sys_log.debug("[Dashboard] [Cache] 🔄 快取過期或不存在,重新查詢資料庫") query_start_time = time.time() db = DatabaseManager() session = db.get_session() today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) # 保持台北時區 seven_days_ago = today_start - timedelta(days=7) thirty_days_ago = today_start - timedelta(days=30) try: # Query 1: Get the latest price record for every product latest_price_subq = session.query( func.max(PriceRecord.id).label('max_id') ).group_by(PriceRecord.product_id).subquery() latest_records = session.query(PriceRecord).options( joinedload(PriceRecord.product) ).join(latest_price_subq, PriceRecord.id == latest_price_subq.c.max_id).all() product_ids = [r.product_id for r in latest_records] if not product_ids: session.close() return [], today_start # Query 2: Get yesterday's closing prices for all products yesterday_prices_subq = session.query( PriceRecord.product_id, func.max(PriceRecord.id).label('max_id') ).filter( PriceRecord.product_id.in_(product_ids), PriceRecord.timestamp < today_start ).group_by(PriceRecord.product_id).subquery() yesterday_prices_q = session.query( PriceRecord.product_id, PriceRecord.price ).join( yesterday_prices_subq, PriceRecord.id == yesterday_prices_subq.c.max_id ) yesterday_prices_map = {pid: price for pid, price in yesterday_prices_q} # Query 3: Get specific historical price points (7 days ago and 30 days ago) def get_price_map_before(target_date): subq = session.query( PriceRecord.product_id, func.max(PriceRecord.timestamp).label('max_ts') ).filter( PriceRecord.product_id.in_(product_ids), PriceRecord.timestamp < target_date ).group_by(PriceRecord.product_id).subquery() q = session.query(PriceRecord.product_id, PriceRecord.price).join( subq, and_(PriceRecord.product_id == subq.c.product_id, PriceRecord.timestamp == subq.c.max_ts) ) return {pid: price for pid, price in q} prices_7d_ago_map = get_price_map_before(seven_days_ago + timedelta(days=1)) prices_30d_ago_map = get_price_map_before(thirty_days_ago + timedelta(days=1)) # Query 4: Get TODAY's records only (for sparkline/intraday change) today_records_q = session.query(PriceRecord).filter( PriceRecord.product_id.in_(product_ids), PriceRecord.timestamp >= today_start ).order_by(PriceRecord.product_id, PriceRecord.timestamp).all() today_map = {} for r in today_records_q: if r.product_id not in today_map: today_map[r.product_id] = [] today_map[r.product_id].append(r) # Final Assembly unique_items = [] for r in latest_records: pid = r.product_id product = r.product safe_product_url = normalize_momo_product_url(getattr(product, 'url', None), getattr(product, 'i_code', '')) price_7d = prices_7d_ago_map.get(pid) price_30d = prices_30d_ago_map.get(pid) stats_7d_diff = r.price - price_7d if price_7d is not None else 0 stats_30d_diff = r.price - price_30d if price_30d is not None else 0 today_records = today_map.get(pid, []) today_diff = 0 today_changes = [] if len(today_records) > 1: today_diff = today_records[-1].price - today_records[0].price y_price = yesterday_prices_map.get(pid) yesterday_diff = r.price - y_price if y_price is not None else 0 status = "NONE" if yesterday_diff > 0: status = "PRICE_UP" elif yesterday_diff < 0: status = "PRICE_DOWN" last_p = y_price if y_price is not None else (today_records[0].price if today_records else r.price) for tr in today_records: if tr.price != last_p: diff = tr.price - last_p today_changes.append({ 'time': tr.timestamp.strftime('%H:%M'), 'price': tr.price, 'diff': diff }) last_p = tr.price unique_items.append({ 'record': r, 'safe_product_url': safe_product_url or _build_momo_product_url(getattr(product, 'i_code', '')), 'stats': {'7d_diff': stats_7d_diff, '30d_diff': stats_30d_diff, '1d_diff': today_diff}, 'yesterday_diff': yesterday_diff, 'today_changes': today_changes, 'status': status }) # 更新快取 _DASHBOARD_DATA_CACHE['consolidated_data'] = unique_items _DASHBOARD_DATA_CACHE['consolidated_timestamp'] = now.timestamp() _DASHBOARD_DATA_CACHE['today_start'] = today_start query_duration_ms = (time.time() - query_start_time) * 1000 track_query_time('get_consolidated_data', query_duration_ms) sys_log.debug(f"[Dashboard] [Cache] 快取已更新 | 商品數: {len(unique_items)} | 耗時: {query_duration_ms:.0f}ms") return unique_items, today_start finally: session.close() def get_full_dashboard_data(force_rebuild=False): """獲取完整的看板資料,包含快取清單與全部 KPIs (深度快取)""" global _DASHBOARD_DATA_CACHE now = datetime.now(TAIPEI_TZ) # V-Opt: 先檢查快取(無需鎖) if not force_rebuild and _DASHBOARD_DATA_CACHE.get('full_data') and _DASHBOARD_DATA_CACHE.get('full_timestamp'): age = now.timestamp() - _DASHBOARD_DATA_CACHE['full_timestamp'] if age < _DASHBOARD_CACHE_TTL: sys_log.debug(f"[Dashboard] [Cache] ✅ 使用完整看板快取 | 快取年齡: {age:.0f}秒") return _DASHBOARD_DATA_CACHE['full_data'] shared_full_data = None if force_rebuild else _load_shared_full_dashboard_cache(now) if shared_full_data: return shared_full_data expired_shared_data = None if force_rebuild else _load_expired_shared_full_dashboard_cache(now) if expired_shared_data: _trigger_dashboard_background_refresh('expired_shared_cache') return expired_shared_data # V-Opt: 使用檔案鎖避免多 gunicorn worker 同時計算 dashboard_lock = _new_dashboard_file_lock() lock_acquired = dashboard_lock.acquire(blocking=False) if not lock_acquired: # 其他 worker 正在重建時,先用舊快取救首屏,避免使用者第一次打開卡住。 stale_full_data = None if force_rebuild else _load_stale_full_dashboard_cache(now) if stale_full_data: return stale_full_data sys_log.info("[Dashboard] [Cache] ⏳ 等待其他 worker 重建快取...") deadline = time.time() + 5 while time.time() < deadline: time.sleep(0.25) shared_full_data = None if force_rebuild else _load_shared_full_dashboard_cache(now) if shared_full_data: return shared_full_data dashboard_lock = _new_dashboard_file_lock() lock_acquired = dashboard_lock.acquire(blocking=False) if lock_acquired: break shared_full_data = None if force_rebuild else _load_shared_full_dashboard_cache(now) if shared_full_data: return shared_full_data if not lock_acquired: stale_full_data = None if force_rebuild else _load_stale_full_dashboard_cache(now) if stale_full_data: return stale_full_data dashboard_lock = _new_dashboard_file_lock() lock_acquired = dashboard_lock.acquire(blocking=True) try: # 再次檢查快取(可能其他 worker 已經更新) if not force_rebuild and _DASHBOARD_DATA_CACHE.get('full_data') and _DASHBOARD_DATA_CACHE.get('full_timestamp'): age = now.timestamp() - _DASHBOARD_DATA_CACHE['full_timestamp'] if age < _DASHBOARD_CACHE_TTL: sys_log.debug(f"[Dashboard] [Cache] ✅ 使用完整看板快取 (其他 worker 已更新) | 快取年齡: {age:.0f}秒") return _DASHBOARD_DATA_CACHE['full_data'] shared_full_data = None if force_rebuild else _load_shared_full_dashboard_cache(now) if shared_full_data: return shared_full_data sys_log.info("[Dashboard] [Cache] 🔄 完整快取過期,重新計算所有 KPIs 與統計數據...") query_start_time = time.time() unique_items, today_start = get_consolidated_data() today_start_db = today_start # 保持台北時區 db = DatabaseManager() session = db.get_session() try: # A. 基礎清單統計 increase_items = [item for item in unique_items if item['yesterday_diff'] > 0] decrease_items = [item for item in unique_items if item['yesterday_diff'] < 0] # B. 分類筆數統計 cat_counts = {} for item in unique_items: c = item['record'].product.category if c: cat_counts[c] = cat_counts.get(c, 0) + 1 all_categories = [f"{cat} ({count}筆)" for cat, count in sorted(cat_counts.items())] # C. 核心 KPI 統計 total_products_history = session.query(Product).count() total_price_records = session.query(PriceRecord).count() today_updates = session.query(PriceRecord).filter(PriceRecord.timestamp >= today_start_db).count() # 今日新增商品 new_pids_query = session.query(PriceRecord.product_id).group_by( PriceRecord.product_id ).having(func.min(PriceRecord.timestamp) >= today_start_db) new_product_ids = {r[0] for r in new_pids_query.all()} today_new_products = len(new_product_ids) # D. 今日下架商品處理 raw_delisted_items = session.query(Product).filter( Product.status == 'INACTIVE', Product.updated_at >= today_start_db ).all() today_delisted_items = [] if raw_delisted_items: delisted_ids = [p.id for p in raw_delisted_items] last_prices_subq = session.query( PriceRecord.product_id, func.max(PriceRecord.id).label('max_id') ).filter(PriceRecord.product_id.in_(delisted_ids)).group_by(PriceRecord.product_id).subquery() last_prices_q = session.query(PriceRecord.product_id, PriceRecord.price).join( last_prices_subq, PriceRecord.id == last_prices_subq.c.max_id).all() price_map = {pid: price for pid, price in last_prices_q} for p in raw_delisted_items: today_delisted_items.append({'product': p, 'last_price': price_map.get(p.id, 0)}) # E. 週增長 week_ago_db = now.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=7) week_new_products = session.query(func.count(Product.id)).filter( Product.id.in_( session.query(PriceRecord.product_id) .group_by(PriceRecord.product_id) .having(func.min(PriceRecord.timestamp) >= week_ago_db) ) ).scalar() or 0 # F. 價格穩定商品數 try: stable_count = session.query(PriceRecord.product_id).filter( PriceRecord.timestamp >= week_ago_db ).group_by(PriceRecord.product_id).having( func.count(func.distinct(PriceRecord.price)) == 1 ).count() except Exception: stable_count = 0 # G. 最大變動計算 max_change_item = None max_change_value = 0 for item in unique_items: if abs(item['yesterday_diff']) > abs(max_change_value): max_change_value = item['yesterday_diff'] max_change_item = item # H. 最活躍分類 category_activity = {} for item in increase_items + decrease_items: cat = item['record'].product.category if cat: category_activity[cat] = category_activity.get(cat, 0) + 1 most_active_category_item = max(category_activity.items(), key=lambda x: x[1]) if category_activity else (None, 0) # I. 組合結果 full_data = { 'unique_items': unique_items, 'today_start': today_start, 'today_start_db': today_start_db, 'increase_items_all': increase_items, 'decrease_items_all': decrease_items, 'all_categories': all_categories, 'new_product_ids': new_product_ids, 'total_products_history': total_products_history, 'total_price_records': total_price_records, 'today_updates': today_updates, 'today_new_products': today_new_products, 'today_delisted_count': len(raw_delisted_items), 'today_delisted_items': today_delisted_items, 'max_change_item': max_change_item, 'max_change_value': max_change_value, 'avg_increase': sum(item['yesterday_diff'] for item in increase_items) / len(increase_items) if increase_items else 0, 'avg_decrease': sum(item['yesterday_diff'] for item in decrease_items) / len(decrease_items) if decrease_items else 0, 'activity_rate': (len(increase_items) + len(decrease_items)) / total_products_history * 100 if total_products_history > 0 else 0, 'active_count': len(increase_items) + len(decrease_items), 'week_new_products': week_new_products, 'stable_count': stable_count, 'most_active_category': most_active_category_item[0], 'most_active_count': most_active_category_item[1] } full_data['competitor_overview'] = _load_competitor_decision_overview(session, unique_items) # 更新快取 _DASHBOARD_DATA_CACHE['full_data'] = full_data _DASHBOARD_DATA_CACHE['full_timestamp'] = now.timestamp() _write_shared_full_dashboard_cache(full_data) query_duration_ms = (time.time() - query_start_time) * 1000 track_query_time('get_full_dashboard_data', query_duration_ms) sys_log.info(f"[Dashboard] [Cache] ✅ 完整看板快取已更新 | 耗時: {query_duration_ms:.0f}ms") return full_data except Exception as e: sys_log.error(f"[Dashboard] KPI 計算失敗: {e}") import traceback traceback.print_exc() return None finally: session.close() finally: # V-Opt: 確保釋放檔案鎖 if lock_acquired: dashboard_lock.release() def get_dashboard_stats(): """計算看板統計數據 (供通知使用) — backward-compat wrapper""" from services.dashboard_service import get_dashboard_stats as _get_dashboard_stats return _get_dashboard_stats() # ========================================== # 頁面路由 # ========================================== @dashboard_bp.route('/') @login_required def index(): """商品看板首頁""" db = DatabaseManager() session = db.get_session() page = request.args.get('page', 1, type=int) category_filter = request.args.get('category', 'all') sort_by = request.args.get('sort_by', 'timestamp') filter_type = request.args.get('filter', 'all') order = request.args.get('order', 'desc') search_query = request.args.get('q', '').strip() per_page = 50 now_taipei = datetime.now(TAIPEI_TZ) today_start_db = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0) # 保持台北時區 try: # 使用深度快取獲取所有數據 data = get_full_dashboard_data() if not data: return render_template('index.html', error="無法載入數據,請檢查資料庫。") unique_items = data['unique_items'] today_start = data['today_start'] today_start_db = data['today_start_db'] increase_items = data['increase_items_all'] decrease_items = data['decrease_items_all'] all_categories = data['all_categories'] new_product_ids = data['new_product_ids'] total_products_history = data['total_products_history'] today_new_products = data['today_new_products'] total_price_records = data['total_price_records'] today_updates = data['today_updates'] today_delisted_count = data['today_delisted_count'] today_delisted_items = data['today_delisted_items'] max_change_item = data['max_change_item'] max_change_value = data['max_change_value'] avg_increase = data['avg_increase'] avg_decrease = data['avg_decrease'] activity_rate = data['activity_rate'] week_new_products = data['week_new_products'] stable_count = data['stable_count'] most_active_category = data['most_active_category'] most_active_count = data['most_active_count'] 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 # 後端篩選 scheduler_stats = load_scheduler_stats() # Handle old scheduler stats format if scheduler_stats.get('momo_task') and isinstance(scheduler_stats.get('momo_task'), dict): scheduler_stats['momo_task'] = [scheduler_stats['momo_task']] if scheduler_stats.get('edm_task') and isinstance(scheduler_stats.get('edm_task'), dict): scheduler_stats['edm_task'] = [scheduler_stats['edm_task']] filtered_items = [] ai_pick_skus = [] ai_pick_map = {} ai_pick_summary = None if filter_type == 'ai_picks': ai_pick_skus, ai_pick_map = _load_ai_pick_selection(session, PRODUCT_PICK_LIST_LIMIT) ai_pick_summary = _summarize_ai_pick_selection(ai_pick_map) # 先處理搜尋 if search_query: search_lower = search_query.lower() base_items = [ item for item in unique_items if (item['record'].product.name and search_lower in item['record'].product.name.lower()) or (item['record'].product.i_code and search_lower in str(item['record'].product.i_code)) ] else: base_items = unique_items # 處理狀態篩選 if filter_type == 'increase': filtered_items = [i for i in base_items if i in increase_items] elif filter_type == 'decrease': filtered_items = [i for i in base_items if i in decrease_items] elif filter_type == 'new': filtered_items = [i for i in base_items if i['record'].product_id in new_product_ids] elif filter_type == 'ai_picks': pick_set = set(ai_pick_skus) filtered_items = [ i for i in base_items if str(i['record'].product.i_code) in pick_set ] elif filter_type == 'delisted': for item in today_delisted_items: class DelistedRecord: def __init__(self, p, price): self.product = p self.price = price self.timestamp = p.updated_at if not search_query or search_query.lower() in item['product'].name.lower(): filtered_items.append({ 'record': DelistedRecord(item['product'], item['last_price']), 'stats': {'1d_diff': 0, '7d_diff': 0, '30d_diff': 0}, 'yesterday_diff': 0, 'today_changes': [], 'status': 'DELISTED' }) else: if category_filter != 'all': real_category = category_filter if "(" in category_filter and "筆)" in category_filter: real_category = category_filter.rsplit(" (", 1)[0] filtered_items = [item for item in base_items if item['record'].product.category == real_category] else: filtered_items = base_items # 後端排序 reverse = (order == 'desc') def get_sort_key(item): def safe_get(value, default=0): return default if value is None else value if sort_by == 'i_code': return int(safe_get(item['record'].product.i_code, 0)) if sort_by == 'category': return safe_get(item['record'].product.category, '') if sort_by == 'name': return safe_get(item['record'].product.name, '') if sort_by == 'price': return safe_get(item['record'].price, 0) if sort_by == 'today_change': return safe_get(item['stats']['1d_diff'], 0) if sort_by == 'yesterday_change': return safe_get(item['yesterday_diff'], 0) if sort_by == 'week_change': return safe_get(item['stats']['7d_diff'], 0) if filter_type == 'ai_picks': sku = str(item['record'].product.i_code) return -ai_pick_map.get(sku, {}).get('rank', 9999) return item['record'].timestamp sorted_items = sorted(filtered_items, key=get_sort_key, reverse=reverse) # 分頁 total_items = len(sorted_items) total_pages = math.ceil(total_items / per_page) start_idx = (page - 1) * per_page paged_items = sorted_items[start_idx: start_idx + per_page] # 為前端準備安全的 created_at 屬性 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['safe_momo_url'] = ( item.get('safe_product_url') or normalize_momo_product_url(item['record'].product.url, sku) or _build_momo_product_url(sku) ) # 為當前頁面項目添加顏色 for item in paged_items: category_name = item['record'].product.category item['category_color'] = get_color_for_string(category_name) pchome_map = _load_pchome_competitor_map( session, [item['record'].product.i_code for item in paged_items] ) pchome_attempt_map = _load_pchome_match_attempt_map( session, [item['record'].product.i_code for item in paged_items] ) pchome_ineligible_map = _load_pchome_ineligible_competitor_map( session, [item['record'].product.i_code for item in paged_items] ) 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, ) competitor_overview = data.get('competitor_overview') if not competitor_overview: competitor_overview = _load_competitor_decision_overview(session, unique_items) data['competitor_overview'] = competitor_overview _DASHBOARD_DATA_CACHE['full_data'] = data _write_shared_full_dashboard_cache(data) template_name = 'dashboard_v2.html' return render_template(template_name, total_products=total_products_history, today_new_products=today_new_products, total_price_records=total_price_records, cnt_increase=len(increase_items), cnt_decrease=len(decrease_items), today_delisted_count=today_delisted_count, today_delisted_items=today_delisted_items, system_status=system_status, items=paged_items, categories=all_categories, current_page=page, total_pages=total_pages, total_items=total_items, 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, search_query=search_query, current_sort=sort_by, current_order=order, ai_pick_summary=ai_pick_summary, scheduler_stats=scheduler_stats, avg_increase=avg_increase, avg_decrease=avg_decrease, activity_rate=activity_rate, active_count=active_count, max_change_item=max_change_item, max_change_value=max_change_value, week_new_products=week_new_products, stable_count=stable_count, most_active_category=most_active_category, most_active_count=most_active_count, competitor_overview=competitor_overview, ai_pick_list_limit=PRODUCT_PICK_LIST_LIMIT, active_page='dashboard') except Exception as e: sys_log.error(f"[Web] [Dashboard] 渲染錯誤 | Error: {e}") return f"系統維護中,錯誤詳情:{e}" finally: session.close()