#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 快取單一來源模組。 ADR-017 Phase 3f-2: 將 sales/import/export/daily 會共同碰到的 module-level cache 收斂到這裡,避免各 route 各自持有一份 dict。 """ import os import logging import time from pathlib import Path logger = logging.getLogger(__name__) class FingerprintCache: """TTL + fingerprint 的小型 in-memory cache。""" def __init__(self, name, fingerprint_fn): self.name = name self._store = {} self._fp_fn = fingerprint_fn def get(self, key, ttl=300): entry = self._store.get(key) if not entry: return None if time.time() - entry['ts'] > ttl: return None try: if self._fp_fn() != entry['fp']: return None except Exception: logger.debug("cache fingerprint check failed for %s", self.name, exc_info=True) return entry['data'] def set(self, key, data): try: fp = self._fp_fn() except Exception: logger.debug("cache fingerprint write failed for %s", self.name, exc_info=True) fp = None self._store[key] = {'data': data, 'ts': time.time(), 'fp': fp} def clear(self): self._store.clear() _SALES_DF_CACHE = {} _SALES_PROCESSED_CACHE = {} _SALES_OPTIONS_CACHE = {} _SALES_ANALYSIS_RESULT_CACHE = {} _SALES_CACHE_MAX_ENTRIES = 10 _SALES_CACHE_TTL = 600 _DAILY_SALES_PROCESSED_CACHE = {} _DASHBOARD_DATA_CACHE = { 'consolidated_data': None, 'consolidated_timestamp': None, 'today_start': None, 'full_data': None, 'full_timestamp': None, } _DASHBOARD_CACHE_TTL = 1800 _BASE_DIR = Path(__file__).resolve().parents[1] _DASHBOARD_SHARED_CACHE_FILE = _BASE_DIR / "data" / "dashboard_full_cache.pkl" _DASHBOARD_STALE_CACHE_FILE = _BASE_DIR / "data" / "dashboard_full_cache_stale.pkl" _SALES_ANALYSIS_PAGE_CACHE_DIR = _BASE_DIR / "data" / "sales_analysis_page_cache" _DAILY_SALES_VIEW_CACHE_DIR = _BASE_DIR / "data" / "daily_sales_view_cache" def cleanup_sales_cache(): """清理 sales 處理後快取的過期與超額條目。""" current_time = time.time() expired_keys = [ key for key, value in _SALES_PROCESSED_CACHE.items() if value.get('time') and current_time - value['time'] > _SALES_CACHE_TTL ] for key in expired_keys: _SALES_PROCESSED_CACHE.pop(key, None) if len(_SALES_PROCESSED_CACHE) > _SALES_CACHE_MAX_ENTRIES: sorted_items = sorted( [(key, value.get('time', 0)) for key, value in _SALES_PROCESSED_CACHE.items()], key=lambda item: item[1], ) for key, _ in sorted_items[:-_SALES_CACHE_MAX_ENTRIES]: _SALES_PROCESSED_CACHE.pop(key, None) def set_sales_processed_cache(key, entry, aliases=()): """寫入 sales cache,並可同步寫入別名 key。""" entry.setdefault('time', time.time()) _SALES_PROCESSED_CACHE[key] = entry for alias in aliases: _SALES_PROCESSED_CACHE[alias] = entry cleanup_sales_cache() def clear_sales_cache_for_table(table_name): """匯入資料後清除指定表對應的所有 sales cache key。""" _SALES_DF_CACHE.pop(table_name, None) keys_to_delete = [ key for key in list(_SALES_PROCESSED_CACHE.keys()) if key == table_name or key.startswith(f"{table_name}_") ] for key in keys_to_delete: _SALES_PROCESSED_CACHE.pop(key, None) def clear_sales_cache(): """清除所有 sales cache。""" _SALES_DF_CACHE.clear() _SALES_PROCESSED_CACHE.clear() _SALES_OPTIONS_CACHE.clear() _SALES_ANALYSIS_RESULT_CACHE.clear() try: if _SALES_ANALYSIS_PAGE_CACHE_DIR.exists(): for cache_file in _SALES_ANALYSIS_PAGE_CACHE_DIR.glob("*.pkl"): try: cache_file.unlink() except OSError: logger.debug("sales analysis page cache file cleanup failed: %s", cache_file, exc_info=True) except OSError: logger.debug("sales analysis page cache cleanup failed", exc_info=True) def clear_daily_sales_cache(): """清除當日業績 cache。""" _DAILY_SALES_PROCESSED_CACHE.clear() try: if _DAILY_SALES_VIEW_CACHE_DIR.exists(): for cache_file in _DAILY_SALES_VIEW_CACHE_DIR.glob("*.pkl"): try: cache_file.unlink() except OSError: logger.debug("daily sales view cache file cleanup failed: %s", cache_file, exc_info=True) except OSError: logger.debug("daily sales view cache cleanup failed", exc_info=True) def clear_dashboard_cache(): """清除商品看板 cache。""" _DASHBOARD_DATA_CACHE.clear() _DASHBOARD_DATA_CACHE.update({ 'consolidated_data': None, 'consolidated_timestamp': None, 'today_start': None, 'full_data': None, 'full_timestamp': None, }) try: if os.path.exists(_DASHBOARD_SHARED_CACHE_FILE): try: os.replace(_DASHBOARD_SHARED_CACHE_FILE, _DASHBOARD_STALE_CACHE_FILE) except OSError: os.remove(_DASHBOARD_SHARED_CACHE_FILE) except FileNotFoundError: pass except OSError: logger.debug("dashboard shared cache cleanup failed", exc_info=True)