Files
ewoooc/routes/dashboard_routes.py
OoO 3920701e1a
All checks were successful
CD Pipeline / deploy (push) Successful in 2m46s
feat(dashboard): optimize cache and AI pick confidence
2026-05-01 16:01:52 +08:00

1224 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
商品看板路由模組
包含:首頁儀表板、商品列表、統計數據
"""
import os
import json
import math
import time
import hashlib
import pickle
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,
)
# 時區設定
TAIPEI_TZ = timezone(timedelta(hours=8))
# Logger
sys_log = SystemLogger("DashboardRoutes").get_logger()
# Blueprint 定義
dashboard_bp = Blueprint('dashboard', __name__)
PRODUCT_PICK_LIST_LIMIT = 50
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):
if not i_code:
return None
return f"https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code={str(i_code).strip()}"
def _to_float(value):
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _build_competitor_decision(momo_price, pchome_price):
if not pchome_price:
return {
'label': '待比對',
'tone': 'neutral',
'gap_amount': None,
'gap_pct': None,
'summary': '尚無 PChome 對應商品或價格快取'
}
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)
""").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 {}
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 _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 _dashboard_decision_row(row, tone):
sku = str(row.get('sku') or '')
pchome_id = row.get('competitor_product_id')
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': row.get('momo_url') or _build_momo_product_url(sku),
'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')),
}
def _load_competitor_decision_overview(session):
"""讀取商品看板第一屏使用的 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': [],
}
latest_compared_cte = """
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) >= 0.42
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("""
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) >= 0.42
WHERE lm.rn = 1
AND cp.sku IS NULL
ORDER BY lm.momo_price DESC NULLS LAST
LIMIT 3
""")
picks_sql = text("""
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) >= 0.42
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.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': row.get('momo_url') 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,
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')),
}
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_gap_pct': 0,
'max_gap_pct': 0,
'total_gap_amount': 0,
'high_confidence_count': 0,
'generated_at': None,
}
confidence_values = [pick.get('confidence', 0) for pick in picks]
gap_values = [pick.get('gap_pct', 0) for pick in picks]
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_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),
'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_FILE_LOCK = FileLock(_DASHBOARD_LOCK_FILE)
def _load_shared_full_dashboard_cache(now):
"""讀取跨 worker 共享的商品看板深度快取。"""
cache_file = str(_DASHBOARD_SHARED_CACHE_FILE)
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
if age >= _DASHBOARD_CACHE_TTL:
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')
sys_log.debug(f"[Dashboard] [Cache] ✅ 使用共享完整看板快取 | 快取年齡: {age:.0f}")
return full_data
except Exception as exc:
sys_log.warning(f"[Dashboard] [Cache] 共享快取讀取失敗,改走資料庫重建: {exc}")
return None
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
# 慢查詢監控
_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
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,
'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():
"""獲取完整的看板資料,包含快取清單與全部 KPIs (深度快取)"""
global _DASHBOARD_DATA_CACHE
now = datetime.now(TAIPEI_TZ)
# V-Opt: 先檢查快取(無需鎖)
if _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 = _load_shared_full_dashboard_cache(now)
if shared_full_data:
return shared_full_data
# V-Opt: 使用檔案鎖避免多 gunicorn worker 同時計算
lock_acquired = _DASHBOARD_FILE_LOCK.acquire(blocking=False)
if not lock_acquired:
# 如果無法取得鎖,表示其他 worker 正在重建,等待並使用更新後的快取
sys_log.debug("[Dashboard] [Cache] ⏳ 等待其他 worker 重建快取...")
_DASHBOARD_FILE_LOCK.acquire() # 等待取得鎖
_DASHBOARD_FILE_LOCK.release() # 立即釋放
shared_full_data = _load_shared_full_dashboard_cache(now)
if shared_full_data:
return shared_full_data
if _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:
return _DASHBOARD_DATA_CACHE['full_data']
lock_acquired = _DASHBOARD_FILE_LOCK.acquire()
if not lock_acquired:
sys_log.warning("[Dashboard] [Cache] 共享鎖取得失敗,改用無鎖重建")
shared_full_data = _load_shared_full_dashboard_cache(now)
if shared_full_data:
return shared_full_data
try:
# 再次檢查快取(可能其他 worker 已經更新)
if _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 = _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]
}
# 更新快取
_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_FILE_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)
# 為當前頁面項目添加顏色
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]
)
for item in paged_items:
product = item['record'].product
competitor = pchome_map.get(str(product.i_code))
item['pchome_competitor'] = competitor
item['competitor_decision'] = _build_competitor_decision(
item['record'].price,
competitor.get('price') if competitor else None
)
competitor_overview = _load_competitor_decision_overview(session)
template_name = 'dashboard.html' if request.args.get('ui') == 'legacy' else '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()