diff --git a/routes/edm_routes.py b/routes/edm_routes.py index ce2ad5d..b8c49fb 100644 --- a/routes/edm_routes.py +++ b/routes/edm_routes.py @@ -7,12 +7,15 @@ 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 +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 @@ -32,6 +35,7 @@ _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' # ========================================== @@ -103,6 +107,52 @@ def _remember_promo_dashboard_data(cache_key, data): _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: + sys_log.debug("promo dashboard shared cache load failed", exc_info=True) + return None + + +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): + 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'] + 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 @@ -142,6 +192,10 @@ def _build_promo_dashboard_data(session, page_type, page_name, sort_by, order, r 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( @@ -374,6 +428,7 @@ def _build_promo_dashboard_data(session, page_type, page_name, sort_by, order, r 'current_batch_id': current_batch_id } _remember_promo_dashboard_data(cache_key, data) + _write_shared_promo_dashboard_cache(cache_key, data) return data diff --git a/tests/test_cache_manager.py b/tests/test_cache_manager.py index 177a62d..f12992e 100644 --- a/tests/test_cache_manager.py +++ b/tests/test_cache_manager.py @@ -1,4 +1,5 @@ from pathlib import Path +import pickle from flask import Flask, session @@ -182,6 +183,46 @@ def test_clear_daily_sales_cache_removes_shared_view_cache_files(tmp_path, monke assert not cache_file.exists() +def test_promo_dashboard_shared_cache_roundtrip(tmp_path, monkeypatch): + from routes import edm_routes + + shared_cache = tmp_path / "promo_dashboard_cache.pkl" + monkeypatch.setattr(edm_routes, "_PROMO_SHARED_CACHE_FILE", shared_cache) + edm_routes._PROMO_DASHBOARD_CACHE.clear() + cache_key = ("edm", "default", "desc", "", "2026-05-19-12", (10, "2026-05-19T12:00:00", 30)) + data = { + "sorted_grouped_items": {"11:00": []}, + "slot_stats": {"11:00": {"on_shelf": 0}}, + "items_in_batch": [], + "last_update_str": "2026-05-19 12:00", + "activity_time": "11:00", + "active_tab": "11:00", + "current_batch_id": "batch-1", + } + + edm_routes._write_shared_promo_dashboard_cache(cache_key, data) + edm_routes._PROMO_DASHBOARD_CACHE.clear() + + assert edm_routes._load_shared_promo_dashboard_cache(cache_key) == data + assert shared_cache.exists() + + +def test_promo_dashboard_shared_cache_ignores_other_versions(tmp_path, monkeypatch): + from routes import edm_routes + + shared_cache = tmp_path / "promo_dashboard_cache.pkl" + cache_key = ("edm", "default", "desc", "", "bucket", (1, "ts", 1)) + shared_cache.write_bytes( + pickle.dumps( + {"version": "older-version", "entries": {cache_key: {"stale": True}}}, + protocol=pickle.HIGHEST_PROTOCOL, + ) + ) + monkeypatch.setattr(edm_routes, "_PROMO_SHARED_CACHE_FILE", shared_cache) + + assert edm_routes._load_shared_promo_dashboard_cache(cache_key) is None + + def test_sales_analysis_preview_context_cache_avoids_reloading_options(tmp_path, monkeypatch): from routes import sales_routes diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index c3b6cad..c2acf93 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -423,9 +423,10 @@ def test_ai_product_pick_agent_uses_real_competitor_data_and_dashboard_action(): def test_edm_dashboard_v2_is_production_default_and_uses_real_campaign_data(): route_source = (ROOT / "routes/edm_routes.py").read_text(encoding="utf-8") template = (ROOT / "templates/edm_dashboard_v2.html").read_text(encoding="utf-8") + page_js = (ROOT / "web/static/js/page-edm-v2.js").read_text(encoding="utf-8") - assert route_source.count("request.args.get('ui') == 'legacy'") == 5 - assert "template_name = 'edm_dashboard.html' if request.args.get('ui') == 'legacy' else 'edm_dashboard_v2.html'" in route_source + assert "request.args.get('ui') == 'legacy'" not in route_source + assert route_source.count("template_name = 'edm_dashboard_v2.html'") == 5 assert "{% for slot, stats in slot_stats.items() %}" in template assert "{% for item in items %}" in template assert "scheduler_stats.get(task_key, [])" in template @@ -435,13 +436,13 @@ def test_edm_dashboard_v2_is_production_default_and_uses_real_campaign_data(): assert "data-campaign-filter=\"down\"" in template assert "data-campaign-filter=\"delisted\"" in template assert "data-campaign-history-trigger" in template - assert "showCampaignHistory(this.dataset.iCode, this.dataset.productName)" in template - assert "fetch(`/api/history/i-code/${encodeURIComponent(iCode)}?range=${activeCampaignHistoryRange}`)" in template + assert "showCampaignHistory(button.dataset.iCode, button.dataset.productName)" in page_js + assert "fetch(`/api/history/i-code/${encodeURIComponent(iCode)}?range=${activeCampaignHistoryRange}`)" in page_js assert "data-campaign-history-range=\"week\"" in template assert "data-campaign-history-range=\"month\"" in template assert "data-campaign-history-range=\"quarter\"" in template assert "data-campaign-history-range=\"year\"" in template - assert "applyCampaignFilter(card, button.dataset.campaignFilter)" in template + assert "applyCampaignFilter(card, button.dataset.campaignFilter)" in page_js assert "?ui=v2" not in template assert "ui='v2'" not in template assert "mock" not in template.lower()