Some checks failed
CD Pipeline / deploy (push) Failing after 59s
- 建立 Gitea Actions CD pipeline (.gitea/workflows/cd.yaml) - 部署模式: rsync Python 檔案至 188 → docker restart (volume mount) - Dockerfile/requirements 變動時自動重建 Docker image - 部署通知: Telegram (開始/成功/失敗) - 健康檢查: https://mo.wooo.work/health (最多 5 次重試) - 同步最新 CLAUDE.md / ADR-008 / memory (2026-04-19) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
682 lines
28 KiB
Python
682 lines
28 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
商品看板路由模組
|
||
包含:首頁儀表板、商品列表、統計數據
|
||
"""
|
||
|
||
import os
|
||
import json
|
||
import math
|
||
import time
|
||
import hashlib
|
||
from datetime import datetime, timezone, timedelta
|
||
from flask import Blueprint, request, render_template
|
||
from sqlalchemy import func, and_
|
||
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
|
||
|
||
# 時區設定
|
||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||
|
||
# Logger
|
||
sys_log = SystemLogger("DashboardRoutes").get_logger()
|
||
|
||
# Blueprint 定義
|
||
dashboard_bp = Blueprint('dashboard', __name__)
|
||
|
||
|
||
# ==========================================
|
||
# 快取與監控變數
|
||
# ==========================================
|
||
import fcntl
|
||
|
||
_DASHBOARD_DATA_CACHE = {
|
||
'consolidated_data': None, # get_consolidated_data() 結果
|
||
'consolidated_timestamp': None,
|
||
'today_start': None,
|
||
'full_data': None, # 包含統計數據的完整結果
|
||
'full_timestamp': None
|
||
}
|
||
_DASHBOARD_CACHE_TTL = 1800 # 快取有效期 30 分鐘
|
||
_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)
|
||
|
||
# 慢查詢監控
|
||
_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']
|
||
|
||
# V-Opt: 使用檔案鎖避免多 gunicorn worker 同時計算
|
||
if not _DASHBOARD_FILE_LOCK.acquire(blocking=False):
|
||
# 如果無法取得鎖,表示其他 worker 正在重建,等待並使用更新後的快取
|
||
sys_log.debug("[Dashboard] [Cache] ⏳ 等待其他 worker 重建快取...")
|
||
_DASHBOARD_FILE_LOCK.acquire() # 等待取得鎖
|
||
_DASHBOARD_FILE_LOCK.release() # 立即釋放
|
||
# 返回更新後的快取
|
||
if _DASHBOARD_DATA_CACHE.get('full_data'):
|
||
return _DASHBOARD_DATA_CACHE['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']
|
||
|
||
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()
|
||
|
||
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: 確保釋放檔案鎖
|
||
_DASHBOARD_FILE_LOCK.release()
|
||
|
||
|
||
def get_dashboard_stats():
|
||
"""計算看板統計數據 (供通知使用)"""
|
||
data = get_full_dashboard_data()
|
||
if data:
|
||
return {
|
||
'new': data['today_new_products'],
|
||
'up': len(data['increase_items_all']),
|
||
'down': len(data['decrease_items_all']),
|
||
'delisted': data['today_delisted_count']
|
||
}
|
||
return {'new': 0, 'up': 0, 'down': 0, 'delisted': 0}
|
||
|
||
|
||
# ==========================================
|
||
# 頁面路由
|
||
# ==========================================
|
||
|
||
@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 = []
|
||
|
||
# 先處理搜尋
|
||
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 == 'delisted':
|
||
for item in today_delisted_items:
|
||
class MockRecord:
|
||
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': MockRecord(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)
|
||
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)
|
||
|
||
# 為當前頁面項目添加顏色
|
||
for item in paged_items:
|
||
category_name = item['record'].product.category
|
||
item['category_color'] = get_color_for_string(category_name)
|
||
|
||
return render_template('dashboard.html',
|
||
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,
|
||
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,
|
||
active_page='dashboard')
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Dashboard] 渲染錯誤 | Error: {e}")
|
||
return f"系統維護中,錯誤詳情:{e}"
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
@dashboard_bp.route('/brand_assets')
|
||
@login_required
|
||
def brand_assets():
|
||
"""顯示品牌資產庫"""
|
||
return render_template('brand_assets.html')
|