#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ EDM 與節慶促銷路由模組 包含:限時搶購儀表板、節慶活動儀表板 """ import hashlib import math import os import pickle from datetime import datetime, timezone, timedelta from pathlib import Path from flask import Blueprint, request, render_template, url_for from auth import login_required from sqlalchemy import func, desc from config import BASE_DIR, public_url, DATABASE_TYPE, SYSTEM_VERSION from database.manager import DatabaseManager from database.models import Product from database.edm_models import PromoProduct from services.logger_manager import SystemLogger from utils.momo_url_utils import build_momo_product_url, normalize_momo_product_url # 時區設定 TAIPEI_TZ = timezone(timedelta(hours=8)) # Logger sys_log = SystemLogger("EDMRoutes").get_logger() # Blueprint 定義 edm_bp = Blueprint('edm', __name__) _PROMO_DASHBOARD_CACHE = {} _PROMO_DASHBOARD_CACHE_MAX = 32 _PROMO_PAGE_SIZE_DEFAULT = 24 _PROMO_PAGE_SIZE_MAX = 200 _PROMO_SHARED_CACHE_FILE = Path(BASE_DIR) / 'data' / 'promo_dashboard_cache.pkl' # ========================================== # 輔助函數 # ========================================== def slugify(text): """將文字轉換為 URL 友善格式""" if not text: return "" return str(text).replace(' ', '_').replace(':', '').replace('!', '').replace('?', '').replace('/', '').replace('&', '').replace('(', '').replace(')', '').replace('+', '_').replace('.', '_').replace('%', '').replace("'", "") 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(): """讀取排程統計資料""" import os import json 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_promo_dashboard_fingerprint(session, page_type): """取得促銷資料指紋,用於判斷快取是否仍可復用。""" max_id, max_crawled_at, row_count = session.query( func.max(PromoProduct.id), func.max(PromoProduct.crawled_at), func.count(PromoProduct.id) ).filter(PromoProduct.page_type == page_type).one() return ( max_id or 0, max_crawled_at.isoformat() if max_crawled_at else '', row_count or 0 ) def _get_promo_dashboard_cache_key(session, page_type, sort_by, order, requested_slot): """建立 dashboard data 快取鍵;日期/小時避免自動時段跨時段後仍命中舊首屏。""" now_taipei = datetime.now(TAIPEI_TZ) auto_slot_bucket = now_taipei.strftime('%Y-%m-%d-%H') if not requested_slot else '' return ( page_type, sort_by, order, requested_slot or '', auto_slot_bucket, _get_promo_dashboard_fingerprint(session, page_type) ) def _remember_promo_dashboard_data(cache_key, data): """保留少量活動 dashboard data,避免常用時段每次重算。""" if len(_PROMO_DASHBOARD_CACHE) >= _PROMO_DASHBOARD_CACHE_MAX: _PROMO_DASHBOARD_CACHE.pop(next(iter(_PROMO_DASHBOARD_CACHE))) _PROMO_DASHBOARD_CACHE[cache_key] = data def _load_shared_promo_dashboard_cache(cache_key): """讀取跨 worker 促銷 dashboard 快取,讓冷 worker 不必重算首屏資料。""" try: if not os.path.exists(_PROMO_SHARED_CACHE_FILE): return None with open(_PROMO_SHARED_CACHE_FILE, 'rb') as f: payload = pickle.load(f) if payload.get('version') != SYSTEM_VERSION: return None entries = payload.get('entries') if not isinstance(entries, dict): return None return entries.get(cache_key) except Exception as exc: _discard_shared_promo_dashboard_cache("load_corrupt", exc) return None def _discard_shared_promo_dashboard_cache(reason, exc=None): """刪除損毀的跨 worker 快取,避免每個 worker 重複噴 traceback。""" try: os.remove(_PROMO_SHARED_CACHE_FILE) sys_log.info("promo dashboard shared cache discarded | reason=%s | error=%s", reason, exc) except FileNotFoundError: return except OSError: sys_log.debug("promo dashboard shared cache discard failed", exc_info=True) def _write_shared_promo_dashboard_cache(cache_key, data): """原子寫入促銷 dashboard 共享快取;失敗不阻斷頁面回應。""" cache_file = str(_PROMO_SHARED_CACHE_FILE) tmp_file = f"{cache_file}.{os.getpid()}.tmp" entries = {} try: if os.path.exists(_PROMO_SHARED_CACHE_FILE): try: with open(_PROMO_SHARED_CACHE_FILE, 'rb') as f: payload = pickle.load(f) if payload.get('version') == SYSTEM_VERSION and isinstance(payload.get('entries'), dict): entries = payload['entries'] except Exception as exc: _discard_shared_promo_dashboard_cache("write_corrupt", exc) entries = {} entries[cache_key] = data while len(entries) > _PROMO_DASHBOARD_CACHE_MAX: entries.pop(next(iter(entries))) os.makedirs(os.path.dirname(cache_file), exist_ok=True) with open(tmp_file, 'wb') as f: pickle.dump({'version': SYSTEM_VERSION, 'entries': entries}, f, protocol=pickle.HIGHEST_PROTOCOL) os.replace(tmp_file, cache_file) except Exception: try: if os.path.exists(tmp_file): os.remove(tmp_file) except OSError: pass sys_log.debug("promo dashboard shared cache write failed", exc_info=True) def _get_promo_page_window_args(): """讀取促銷商品清單分頁參數,限制首屏 HTML 重量。""" page = request.args.get('page', 1, type=int) or 1 per_page = request.args.get('per_page', _PROMO_PAGE_SIZE_DEFAULT, type=int) or _PROMO_PAGE_SIZE_DEFAULT return max(1, page), max(20, min(per_page, _PROMO_PAGE_SIZE_MAX)) def _paginate_active_slot(data, page, per_page): """只裁切目前顯示時段;其他統計仍保留完整資料。""" active_tab = data.get('active_tab') grouped_items = dict(data.get('sorted_grouped_items') or {}) active_items = list(grouped_items.get(active_tab, [])) total_items = len(active_items) total_pages = max(1, math.ceil(total_items / per_page)) if total_items else 1 page = min(max(1, page), total_pages) start_idx = (page - 1) * per_page end_idx = min(start_idx + per_page, total_items) grouped_items[active_tab] = active_items[start_idx:end_idx] return grouped_items, { 'page': page, 'per_page': per_page, 'total_pages': total_pages, 'total_items': total_items, 'start_item': start_idx + 1 if total_items else 0, 'end_item': end_idx, 'has_prev': page > 1, 'has_next': page < total_pages, } def _build_promo_dashboard_data(session, page_type, page_name, sort_by, order, requested_slot=None): """ 通用的促銷儀表板數據建構函數 用於 edm 和 festival 兩種頁面類型 """ cache_key = _get_promo_dashboard_cache_key(session, page_type, sort_by, order, requested_slot) cached_data = _PROMO_DASHBOARD_CACHE.get(cache_key) if cached_data is not None: return cached_data cached_data = _load_shared_promo_dashboard_cache(cache_key) if cached_data is not None: _remember_promo_dashboard_data(cache_key, cached_data) return cached_data # 1. 基礎統計 last_update = session.query(PromoProduct.crawled_at).filter( PromoProduct.page_type == page_type ).order_by(desc(PromoProduct.crawled_at)).first() last_update_str = last_update[0].strftime('%Y-%m-%d %H:%M') if last_update else "尚無資料" latest_entry = session.query(PromoProduct).filter( PromoProduct.page_type == page_type ).order_by(desc(PromoProduct.crawled_at)).first() activity_time = getattr(latest_entry, 'activity_time_text', page_name) if latest_entry else page_name # 2. 查詢最新批次 latest_batch = session.query(PromoProduct.batch_id).filter( PromoProduct.page_type == page_type ).order_by(desc(PromoProduct.crawled_at)).first() current_batch_id = latest_batch[0] if latest_batch else None # 3. 查詢「全商品的最新狀態快照」 subq = session.query( func.max(PromoProduct.id).label('max_id') ).filter(PromoProduct.page_type == page_type).group_by( PromoProduct.i_code, PromoProduct.time_slot ).subquery() latest_records = session.query(PromoProduct).join( subq, PromoProduct.id == subq.c.max_id ).all() # 4. 過濾顯示列表 items_in_batch = [] today_start = datetime.now(TAIPEI_TZ).replace( hour=0, minute=0, second=0, microsecond=0 ) # 保持時區資訊 (台北 +8) for item in latest_records: # 確保 crawled_at 有時區資訊,若無則假設為台北時區 item_crawled_at = item.crawled_at if item_crawled_at.tzinfo is None: item_crawled_at = item_crawled_at.replace(tzinfo=TAIPEI_TZ) if item.status_change == 'SLOT_END' and item_crawled_at < today_start: continue if item.status_change == 'DELISTED' and item_crawled_at < today_start: continue items_in_batch.append(item) # 5. 按時段分組 grouped_items = {} for item in items_in_batch: if item.time_slot not in grouped_items: grouped_items[item.time_slot] = [] grouped_items[item.time_slot].append(item) sorted_grouped_items = dict(sorted(grouped_items.items())) # 6. 決定預設顯示的頁籤 def get_current_time_slot(): hour = datetime.now(TAIPEI_TZ).hour available_slots = sorted([ int(s.split(':')[0]) for s in sorted_grouped_items.keys() if s and ':' in s ]) if sorted_grouped_items else [0, 7, 11, 14, 18, 22] current_slot_hour = 0 for s in available_slots: if hour >= s: current_slot_hour = s return f"{current_slot_hour:02d}:00" active_tab = get_current_time_slot() if requested_slot in sorted_grouped_items: active_tab = requested_slot if active_tab not in sorted_grouped_items and sorted_grouped_items: active_tab = next(iter(sorted_grouped_items)) # 7. 僅為首屏目前時段補齊列級資訊;其他時段以連結切換後再載入。 visible_items = sorted_grouped_items.get(active_tab, []) visible_icodes = [item.i_code for item in visible_items] product_categories = {} days_on_shelf_map = {} total_sold_map = {} history_map = {} if visible_icodes: # 從主商品表查詢分類 main_products = session.query(Product.i_code, Product.category).filter( Product.i_code.in_(visible_icodes) ).all() product_categories = {p.i_code: p.category for p in main_products} # 計算上架天數 (兼容 SQLite 和 PostgreSQL) if DATABASE_TYPE == 'postgresql': # PostgreSQL: 使用 DATE() 函數 days_on_shelf_q = session.query( PromoProduct.i_code, func.count(func.distinct(func.date(PromoProduct.crawled_at))) ).filter( PromoProduct.i_code.in_(visible_icodes), PromoProduct.page_type == page_type ).group_by(PromoProduct.i_code).all() else: # SQLite: 使用 strftime days_on_shelf_q = session.query( PromoProduct.i_code, func.count(func.distinct(func.strftime('%Y-%m-%d', PromoProduct.crawled_at))) ).filter( PromoProduct.i_code.in_(visible_icodes), PromoProduct.page_type == page_type ).group_by(PromoProduct.i_code).all() days_on_shelf_map = {r[0]: r[1] for r in days_on_shelf_q} # 只有 edm 頁面需要計算總銷量和庫存歷程 if page_type == 'edm': # 計算總銷量 first_qty_subq = session.query( PromoProduct.i_code, func.min(PromoProduct.id).label('min_id') ).filter( PromoProduct.i_code.in_(visible_icodes), PromoProduct.remain_qty.isnot(None), PromoProduct.page_type == 'edm' ).group_by(PromoProduct.i_code).subquery() first_qty_records = session.query( PromoProduct.i_code, PromoProduct.remain_qty ).join(first_qty_subq, PromoProduct.id == first_qty_subq.c.min_id).all() first_qty_map = {r[0]: r[1] for r in first_qty_records} for item in visible_items: if item.i_code in first_qty_map and item.remain_qty is not None: initial_qty = first_qty_map[item.i_code] current_qty = item.remain_qty if initial_qty > current_qty: total_sold_map[item.i_code] = initial_qty - current_qty # 準備銷售歷程資料 all_history_records = session.query( PromoProduct.i_code, PromoProduct.time_slot, PromoProduct.remain_qty, PromoProduct.crawled_at ).filter( PromoProduct.i_code.in_(visible_icodes), PromoProduct.crawled_at >= today_start ).order_by(PromoProduct.crawled_at).all() for rec in all_history_records: key = (rec.i_code, rec.time_slot) if key not in history_map: history_map[key] = [] if rec.remain_qty is not None: if not history_map[key] or (history_map[key] and history_map[key][-1]['qty'] != rec.remain_qty): history_map[key].append({'time': rec.crawled_at.strftime('%H:%M'), 'qty': rec.remain_qty}) # 8. 附加分類資訊到首屏可見 item for item in visible_items: item.safe_product_url = normalize_momo_product_url(item.url, item.i_code) or build_momo_product_url(item.i_code) item.main_category = product_categories.get(item.i_code) if item.main_category: item.category_color = get_color_for_string(item.main_category) item.days_on_shelf = days_on_shelf_map.get(item.i_code, 1) item.total_sold = total_sold_map.get(item.i_code, 0) item.qty_history = history_map.get((item.i_code, item.time_slot), []) # 9. 排序邏輯:首屏只需要排序目前時段 reverse = (order == 'desc') for time_slot in [active_tab]: if time_slot not in sorted_grouped_items: continue if sort_by == 'name': sorted_grouped_items[time_slot].sort(key=lambda x: x.name or '', reverse=reverse) elif sort_by == 'remain_qty': sorted_grouped_items[time_slot].sort( key=lambda x: x.remain_qty if x.remain_qty is not None else -1, reverse=reverse ) elif sort_by == 'price': sorted_grouped_items[time_slot].sort( key=lambda x: x.price if x.price is not None else -1, reverse=reverse ) else: # 預設排序 sorted_grouped_items[time_slot].sort(key=lambda x: ( 1 if x.main_category else 0, 2 if x.status_change in ['NEW', 'PRICE_UP', 'PRICE_DOWN'] else (1 if x.status_change == 'DELISTED' else 0), x.price if x.price is not None else -1 ), reverse=True) # 10. 時段統計 slot_stats = {} today_change_records = session.query(PromoProduct).filter( PromoProduct.crawled_at >= today_start, PromoProduct.page_type == page_type ).all() slots_from_changes = {rec.time_slot for rec in today_change_records} slots_from_display = set(sorted_grouped_items.keys()) all_relevant_slots = sorted(list(slots_from_changes.union(slots_from_display))) for slot in all_relevant_slots: slot_stats[slot] = { 'new': 0, 'up': 0, 'down': 0, 'delisted_last_run': 0, 'on_shelf': 0, 'delisted_total': 0 } for rec in today_change_records: if rec.time_slot in slot_stats: if rec.status_change == 'NEW': slot_stats[rec.time_slot]['new'] += 1 elif rec.status_change == 'PRICE_UP': slot_stats[rec.time_slot]['up'] += 1 elif rec.status_change == 'PRICE_DOWN': slot_stats[rec.time_slot]['down'] += 1 elif rec.status_change in ['DELISTED', 'SLOT_END']: slot_stats[rec.time_slot]['delisted_last_run'] += 1 for slot, items in sorted_grouped_items.items(): if slot in slot_stats: on_shelf_count = sum(1 for item in items if item.status_change not in ['DELISTED', 'SLOT_END']) delisted_total_count = len(items) - on_shelf_count slot_stats[slot]['on_shelf'] = on_shelf_count slot_stats[slot]['delisted_total'] = delisted_total_count data = { 'sorted_grouped_items': sorted_grouped_items, 'slot_stats': slot_stats, 'items_in_batch': items_in_batch, 'last_update_str': last_update_str, 'activity_time': activity_time, 'active_tab': active_tab, 'current_batch_id': current_batch_id } _remember_promo_dashboard_data(cache_key, data) _write_shared_promo_dashboard_cache(cache_key, data) return data def warm_promo_dashboard_cache(reason='manual'): """預熱常用活動看板 shared cache,避免部署後第一個使用者承擔冷 worker。""" db = DatabaseManager() session = db.get_session() page_configs = [ ('edm', '限時搶購'), ('festival', '1.1狂歡購物節'), ('mothers_day', '母親節超值限時購'), ('valentine_520', '520情人節限定購物'), ('labor_day', '勞動節購物優惠'), ] started = datetime.now(TAIPEI_TZ) warmed = 0 try: for page_type, page_name in page_configs: data = _build_promo_dashboard_data(session, page_type, page_name, 'default', 'desc', None) if data: warmed += 1 elapsed_ms = (datetime.now(TAIPEI_TZ) - started).total_seconds() * 1000 sys_log.info( f"[EDM] [Cache] ✅ 活動看板預熱完成 | reason={reason} | pages={warmed} | 耗時={elapsed_ms:.0f}ms" ) return warmed finally: session.close() # ========================================== # EDM 儀表板路由 # ========================================== @edm_bp.route('/edm') @login_required def edm_dashboard(): """MOMO 限時搶購 (EDM) 專屬儀表板""" db = DatabaseManager() session = db.get_session() sort_by = request.args.get('sort_by', 'default') order = request.args.get('order', 'desc') requested_slot = request.args.get('slot') page, per_page = _get_promo_page_window_args() try: data = _build_promo_dashboard_data(session, 'edm', '限時搶購', sort_by, order, requested_slot) grouped_items, page_window = _paginate_active_slot(data, page, per_page) # 建立儀表板頁籤 promo_pages = [ {'url': url_for('edm.edm_dashboard'), 'name': '限時搶購', 'id': 'edm'}, {'url': url_for('edm.festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'}, {'url': url_for('edm.mothers_day_dashboard'), 'name': '母親節', 'id': 'mothers_day'}, {'url': url_for('edm.valentine_520_dashboard'), 'name': '520情人節', 'id': 'valentine_520'}, {'url': url_for('edm.labor_day_dashboard'), 'name': '勞動節', 'id': 'labor_day'} ] scheduler_stats = load_scheduler_stats() now_taipei = datetime.now(TAIPEI_TZ) template_name = 'edm_dashboard_v2.html' return render_template(template_name, promo_pages=promo_pages, current_promo_page='edm', page_title='MOMO 限時搶購', grouped_items=grouped_items, slot_stats=data['slot_stats'], page_window=page_window, total_edm_products=len(data['items_in_batch']), last_update=data['last_update_str'], activity_time=data['activity_time'], active_tab=data['active_tab'], current_batch_id=data['current_batch_id'], public_url=public_url, scheduler_stats=scheduler_stats, current_sort=sort_by, current_order=order, slugify=slugify, datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'), active_page='edm') except Exception as e: sys_log.error(f"EDM Dashboard 渲染錯誤: {e}") return f"系統錯誤: {e}" finally: session.close() @edm_bp.route('/festival') @login_required def festival_dashboard(): """1.1 狂歡購物節專屬儀表板""" db = DatabaseManager() session = db.get_session() PAGE_TYPE = "festival" PAGE_NAME = "1.1狂歡購物節" sort_by = request.args.get('sort_by', 'default') order = request.args.get('order', 'desc') requested_slot = request.args.get('slot') page, per_page = _get_promo_page_window_args() try: data = _build_promo_dashboard_data(session, PAGE_TYPE, PAGE_NAME, sort_by, order, requested_slot) grouped_items, page_window = _paginate_active_slot(data, page, per_page) # 建立儀表板頁籤 promo_pages = [ {'url': url_for('edm.edm_dashboard'), 'name': '限時搶購', 'id': 'edm'}, {'url': url_for('edm.festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'}, {'url': url_for('edm.mothers_day_dashboard'), 'name': '母親節', 'id': 'mothers_day'}, {'url': url_for('edm.valentine_520_dashboard'), 'name': '520情人節', 'id': 'valentine_520'}, {'url': url_for('edm.labor_day_dashboard'), 'name': '勞動節', 'id': 'labor_day'} ] scheduler_stats = load_scheduler_stats() template_name = 'edm_dashboard_v2.html' return render_template(template_name, promo_pages=promo_pages, current_promo_page='festival', page_title=PAGE_NAME, grouped_items=grouped_items, slot_stats=data['slot_stats'], page_window=page_window, total_edm_products=len(data['items_in_batch']), last_update=data['last_update_str'], activity_time=data['activity_time'], active_tab=data['active_tab'], public_url=public_url, scheduler_stats=scheduler_stats, current_sort=sort_by, current_order=order, slugify=slugify, active_page='edm') except Exception as e: sys_log.error(f"{PAGE_NAME} Dashboard 渲染錯誤: {e}") return f"系統錯誤: {e}" finally: session.close() @edm_bp.route('/mothers_day') @login_required def mothers_day_dashboard(): """母親節促銷活動專屬儀表板""" db = DatabaseManager() session = db.get_session() PAGE_TYPE = "mothers_day" PAGE_NAME = "母親節超值限時購" sort_by = request.args.get('sort_by', 'default') order = request.args.get('order', 'desc') requested_slot = request.args.get('slot') page, per_page = _get_promo_page_window_args() try: data = _build_promo_dashboard_data(session, PAGE_TYPE, PAGE_NAME, sort_by, order, requested_slot) grouped_items, page_window = _paginate_active_slot(data, page, per_page) # 建立儀表板頁籤 promo_pages = [ {'url': url_for('edm.edm_dashboard'), 'name': '限時搶購', 'id': 'edm'}, {'url': url_for('edm.festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'}, {'url': url_for('edm.mothers_day_dashboard'), 'name': '母親節', 'id': 'mothers_day'}, {'url': url_for('edm.valentine_520_dashboard'), 'name': '520情人節', 'id': 'valentine_520'}, {'url': url_for('edm.labor_day_dashboard'), 'name': '勞動節', 'id': 'labor_day'} ] scheduler_stats = load_scheduler_stats() template_name = 'edm_dashboard_v2.html' return render_template(template_name, promo_pages=promo_pages, current_promo_page='mothers_day', page_title=PAGE_NAME, grouped_items=grouped_items, slot_stats=data['slot_stats'], page_window=page_window, total_edm_products=len(data['items_in_batch']), last_update=data['last_update_str'], activity_time=data['activity_time'], active_tab=data['active_tab'], public_url=public_url, scheduler_stats=scheduler_stats, current_sort=sort_by, current_order=order, slugify=slugify, active_page='edm') except Exception as e: sys_log.error(f"{PAGE_NAME} Dashboard 渲染錯誤: {e}") return f"系統錯誤: {e}" finally: session.close() @edm_bp.route('/valentine_520') @login_required def valentine_520_dashboard(): """520情人節促銷活動專屬儀表板""" db = DatabaseManager() session = db.get_session() PAGE_TYPE = "valentine_520" PAGE_NAME = "520情人節限定購物" sort_by = request.args.get('sort_by', 'default') order = request.args.get('order', 'desc') requested_slot = request.args.get('slot') page, per_page = _get_promo_page_window_args() try: data = _build_promo_dashboard_data(session, PAGE_TYPE, PAGE_NAME, sort_by, order, requested_slot) grouped_items, page_window = _paginate_active_slot(data, page, per_page) # 建立儀表板頁籤 promo_pages = [ {'url': url_for('edm.edm_dashboard'), 'name': '限時搶購', 'id': 'edm'}, {'url': url_for('edm.festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'}, {'url': url_for('edm.mothers_day_dashboard'), 'name': '母親節', 'id': 'mothers_day'}, {'url': url_for('edm.valentine_520_dashboard'), 'name': '520情人節', 'id': 'valentine_520'}, {'url': url_for('edm.labor_day_dashboard'), 'name': '勞動節', 'id': 'labor_day'} ] scheduler_stats = load_scheduler_stats() template_name = 'edm_dashboard_v2.html' return render_template(template_name, promo_pages=promo_pages, current_promo_page='valentine_520', page_title=PAGE_NAME, grouped_items=grouped_items, slot_stats=data['slot_stats'], page_window=page_window, total_edm_products=len(data['items_in_batch']), last_update=data['last_update_str'], activity_time=data['activity_time'], active_tab=data['active_tab'], public_url=public_url, scheduler_stats=scheduler_stats, current_sort=sort_by, current_order=order, slugify=slugify, active_page='edm') except Exception as e: sys_log.error(f"{PAGE_NAME} Dashboard 渲染錯誤: {e}") return f"系統錯誤: {e}" finally: session.close() @edm_bp.route('/labor_day') @login_required def labor_day_dashboard(): """勞動節促銷活動專屬儀表板""" db = DatabaseManager() session = db.get_session() PAGE_TYPE = "labor_day" PAGE_NAME = "勞動節購物優惠" sort_by = request.args.get('sort_by', 'default') order = request.args.get('order', 'desc') requested_slot = request.args.get('slot') page, per_page = _get_promo_page_window_args() try: data = _build_promo_dashboard_data(session, PAGE_TYPE, PAGE_NAME, sort_by, order, requested_slot) grouped_items, page_window = _paginate_active_slot(data, page, per_page) # 建立儀表板頁籤 promo_pages = [ {'url': url_for('edm.edm_dashboard'), 'name': '限時搶購', 'id': 'edm'}, {'url': url_for('edm.festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'}, {'url': url_for('edm.mothers_day_dashboard'), 'name': '母親節', 'id': 'mothers_day'}, {'url': url_for('edm.valentine_520_dashboard'), 'name': '520情人節', 'id': 'valentine_520'}, {'url': url_for('edm.labor_day_dashboard'), 'name': '勞動節', 'id': 'labor_day'} ] scheduler_stats = load_scheduler_stats() template_name = 'edm_dashboard_v2.html' return render_template(template_name, promo_pages=promo_pages, current_promo_page='labor_day', page_title=PAGE_NAME, grouped_items=grouped_items, slot_stats=data['slot_stats'], page_window=page_window, total_edm_products=len(data['items_in_batch']), last_update=data['last_update_str'], activity_time=data['activity_time'], active_tab=data['active_tab'], public_url=public_url, scheduler_stats=scheduler_stats, current_sort=sort_by, current_order=order, slugify=slugify, active_page='edm') except Exception as e: sys_log.error(f"{PAGE_NAME} Dashboard 渲染錯誤: {e}") return f"系統錯誤: {e}" finally: session.close()