This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
================================================================================
|
||||
|
||||
【已完成】
|
||||
- V10.251 穩定 `/growth_analysis` 正式站速度:成長分析快取從單 worker memory 擴充為 `data/growth_analysis_cache.pkl` 跨 worker 共享快取,避免 Gunicorn 冷 worker 偶發掃明細表造成 5 秒級 TTFB;補 `tests/test_cache_manager.py` 覆蓋 shared file roundtrip 與清除行為。
|
||||
- V10.249 收斂 `/observability/ppt_audit_history` 手機與平板第一屏密度:將 4 個產線訊號從 hero 內移出成獨立狀態列,手機版維持 2 欄狀態卡並降低 hero 卡片間距;本機 10 個 AI 觀測台頁面 rendered visual contract 全數通過,PPT 頁 hero 高度 desktop/tablet/mobile 為 214/361/398px。
|
||||
- V10.246 強化 `/observability/ppt_audit_history` 視覺 QA runtime 可讀性:功能開關、轉檔器與視覺模型改成中文 checklist,Vision QA 狀態卡直接顯示 runtime 就緒資訊,DB 產出狀態統一為「已產出」。
|
||||
- V10.245 重整 `/observability/ppt_audit_history` 首屏資訊階層:改成簡報操作摘要、最新可預覽簡報、下一步動作與自適應報表類型 segmented grid;產線覆蓋矩陣改為下方驗收明細,避免一進頁只看到大量「產線狀態」或類型按鈕右側溢出。
|
||||
|
||||
@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.250"
|
||||
SYSTEM_VERSION = "V10.251"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ Sales/dashboard cache state lives in services.cache_manager. This module keeps
|
||||
older imports working and only owns growth-analysis and slow-query helpers.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pickle
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from services.cache_manager import (
|
||||
_SALES_DF_CACHE,
|
||||
_SALES_PROCESSED_CACHE,
|
||||
@@ -37,6 +40,8 @@ _GROWTH_ANALYSIS_CACHE = {
|
||||
'source_fingerprint': None,
|
||||
}
|
||||
_GROWTH_CACHE_TTL = 1800 # 成長分析快取: 30 分鐘
|
||||
_BASE_DIR = Path(__file__).resolve().parents[1]
|
||||
_GROWTH_SHARED_CACHE_FILE = _BASE_DIR / "data" / "growth_analysis_cache.pkl"
|
||||
|
||||
# ==========================================
|
||||
# 慢查詢監控
|
||||
@@ -84,6 +89,11 @@ def clear_growth_cache():
|
||||
'timestamp': None,
|
||||
'source_fingerprint': None,
|
||||
}
|
||||
try:
|
||||
if os.path.exists(_GROWTH_SHARED_CACHE_FILE):
|
||||
os.remove(_GROWTH_SHARED_CACHE_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def clear_all_cache():
|
||||
@@ -118,21 +128,66 @@ def get_growth_cache():
|
||||
return _GROWTH_ANALYSIS_CACHE
|
||||
|
||||
|
||||
def _is_growth_payload_valid(payload, source_fingerprint=None):
|
||||
if not payload:
|
||||
return False
|
||||
if not payload.get('chart_data') or not payload.get('kpi'):
|
||||
return False
|
||||
if not is_cache_valid(payload.get('timestamp'), _GROWTH_CACHE_TTL):
|
||||
return False
|
||||
if source_fingerprint is not None:
|
||||
return payload.get('source_fingerprint') == source_fingerprint
|
||||
return True
|
||||
|
||||
|
||||
def _load_growth_cache_file(source_fingerprint=None):
|
||||
"""讀取跨 worker 成長分析快取,避免冷 worker 重新掃明細表。"""
|
||||
global _GROWTH_ANALYSIS_CACHE
|
||||
try:
|
||||
if not os.path.exists(_GROWTH_SHARED_CACHE_FILE):
|
||||
return False
|
||||
with open(_GROWTH_SHARED_CACHE_FILE, 'rb') as f:
|
||||
payload = pickle.load(f)
|
||||
if not _is_growth_payload_valid(payload, source_fingerprint):
|
||||
return False
|
||||
_GROWTH_ANALYSIS_CACHE = payload
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _write_growth_cache_file(payload):
|
||||
"""原子寫入成長分析共享快取。"""
|
||||
cache_file = str(_GROWTH_SHARED_CACHE_FILE)
|
||||
tmp_file = f"{cache_file}.{os.getpid()}.tmp"
|
||||
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:
|
||||
try:
|
||||
if os.path.exists(tmp_file):
|
||||
os.remove(tmp_file)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def set_growth_cache(chart_data, kpi, source_fingerprint=None):
|
||||
"""設定成長分析快取"""
|
||||
global _GROWTH_ANALYSIS_CACHE
|
||||
_GROWTH_ANALYSIS_CACHE = {
|
||||
payload = {
|
||||
'chart_data': chart_data,
|
||||
'kpi': kpi,
|
||||
'timestamp': datetime.now(TAIPEI_TZ),
|
||||
'source_fingerprint': source_fingerprint,
|
||||
}
|
||||
_GROWTH_ANALYSIS_CACHE = payload
|
||||
_write_growth_cache_file(payload)
|
||||
|
||||
|
||||
def is_growth_cache_valid(source_fingerprint=None):
|
||||
"""檢查成長分析快取是否有效"""
|
||||
if not is_cache_valid(_GROWTH_ANALYSIS_CACHE.get('timestamp'), _GROWTH_CACHE_TTL):
|
||||
return False
|
||||
if source_fingerprint is not None:
|
||||
return _GROWTH_ANALYSIS_CACHE.get('source_fingerprint') == source_fingerprint
|
||||
return True
|
||||
if _is_growth_payload_valid(_GROWTH_ANALYSIS_CACHE, source_fingerprint):
|
||||
return True
|
||||
return _load_growth_cache_file(source_fingerprint)
|
||||
|
||||
@@ -120,6 +120,43 @@ def test_clear_sales_cache_removes_shared_page_cache_files(tmp_path, monkeypatch
|
||||
assert not cache_file.exists()
|
||||
|
||||
|
||||
def test_growth_cache_shared_file_roundtrip(tmp_path, monkeypatch):
|
||||
from services import cache_service
|
||||
|
||||
shared_cache = tmp_path / "growth_analysis_cache.pkl"
|
||||
monkeypatch.setattr(cache_service, "_GROWTH_SHARED_CACHE_FILE", shared_cache)
|
||||
|
||||
cache_service.clear_growth_cache()
|
||||
cache_service.set_growth_cache(
|
||||
{"labels": ["2026-05"], "revenue": [1000]},
|
||||
{"ytd_revenue": 1000},
|
||||
source_fingerprint=("2026-05-17", 10),
|
||||
)
|
||||
|
||||
cache_service._GROWTH_ANALYSIS_CACHE = {
|
||||
"chart_data": None,
|
||||
"kpi": None,
|
||||
"timestamp": None,
|
||||
"source_fingerprint": None,
|
||||
}
|
||||
|
||||
assert cache_service.is_growth_cache_valid(("2026-05-17", 10))
|
||||
assert cache_service.get_growth_cache()["chart_data"]["revenue"] == [1000]
|
||||
assert shared_cache.exists()
|
||||
|
||||
|
||||
def test_clear_growth_cache_removes_shared_file(tmp_path, monkeypatch):
|
||||
from services import cache_service
|
||||
|
||||
shared_cache = tmp_path / "growth_analysis_cache.pkl"
|
||||
shared_cache.write_bytes(b"stale")
|
||||
monkeypatch.setattr(cache_service, "_GROWTH_SHARED_CACHE_FILE", shared_cache)
|
||||
|
||||
cache_service.clear_growth_cache()
|
||||
|
||||
assert not shared_cache.exists()
|
||||
|
||||
|
||||
def test_daily_sales_shared_view_cache_roundtrip(tmp_path, monkeypatch):
|
||||
from routes import daily_sales_routes
|
||||
|
||||
|
||||
Reference in New Issue
Block a user