759 lines
31 KiB
Python
759 lines
31 KiB
Python
#!/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()
|