diff --git a/config.py b/config.py index 0385f65..cc13afe 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.98" +SYSTEM_VERSION = "V10.99" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/memory/claude_inventory_validation_20260513.md b/docs/memory/claude_inventory_validation_20260513.md index c038a97..7004756 100644 --- a/docs/memory/claude_inventory_validation_20260513.md +++ b/docs/memory/claude_inventory_validation_20260513.md @@ -30,6 +30,8 @@ - 0-byte `database/momo*.db` 迷惑檔已不存在;真實 SQLite 僅在 `data/momo_database.db`。 - `.gitignore` 已涵蓋 `.claude/worktrees/`、`.tmp_*`、`MOMO Pro/`、uploads/screenshots。 - `cache_service.py` 已成為 `cache_manager.py` 的相容 shim,`_SALES_CACHE_TTL` 單一來源有測試鎖住。 +- `aiops-core/requirements.txt` 已不存在,`aiops-core/README.md` 已標記此目錄只保留歷史 stub,不應安裝或部署。 +- V2 提到的「死依賴」不可整批刪:`beautifulsoup4` 用於多個 crawler、`google-api-python-client` 用於 Google Drive、`google-generativeai` 用於 Gemini paths、`python-pptx` 用於 PPT generator、`matplotlib` 用於 Telegram/圖表/PPT。 ## 不可盲動 diff --git a/routes/daily_sales_routes.py b/routes/daily_sales_routes.py index c78d130..beb6c8c 100644 --- a/routes/daily_sales_routes.py +++ b/routes/daily_sales_routes.py @@ -61,10 +61,13 @@ def _get_data_fingerprint(engine, table_name='daily_sales_snapshot'): """DB 指紋 (max_snapshot_date, row_count):資料一變動指紋就跳號, 讓 4 worker 的 in-memory cache 在下一次 request 自然失效。""" try: + validate_table_name(table_name) + if engine.dialect.name == 'postgresql': + fingerprint_sql = f'SELECT MAX(snapshot_date)::text, COUNT(*) FROM "{table_name}"' + else: + fingerprint_sql = f'SELECT CAST(MAX(snapshot_date) AS TEXT), COUNT(*) FROM "{table_name}"' with engine.connect() as conn: - row = conn.execute(text( - f'SELECT MAX(snapshot_date)::text, COUNT(*) FROM "{table_name}"' - )).fetchone() + row = conn.execute(text(fingerprint_sql)).fetchone() return (row[0], row[1]) if row else (None, 0) except Exception as e: sys_log.warning(f"[Cache] fingerprint 查詢失敗(保守維持現有 cache): {e}") @@ -89,6 +92,45 @@ def _is_cache_valid(cache_key, engine=None, table_name='daily_sales_snapshot'): return True +def _get_available_daily_dates(engine, table_name='daily_sales_snapshot'): + """取得可選日期清單,避免為了 date selector 載入整張業績表。""" + validate_table_name(table_name) + if engine.dialect.name == 'postgresql': + date_expr = 'snapshot_date::date' + else: + date_expr = 'date(snapshot_date)' + + query = text( + f'SELECT DISTINCT {date_expr} AS snapshot_date ' + f'FROM "{table_name}" ' + 'WHERE snapshot_date IS NOT NULL ' + 'ORDER BY snapshot_date DESC' + ) + with engine.connect() as conn: + rows = conn.execute(query).fetchall() + + dates = [] + for row in rows: + try: + dates.append(pd.to_datetime(row[0]).normalize()) + except Exception: + continue + return dates + + +def _read_daily_sales_window(engine, table_name, start_date, end_date): + """只讀取畫面需要的日期窗口,降低冷 worker 首頁等待時間。""" + return safe_read_sql( + table_name, + engine=engine, + where_clause='"snapshot_date" >= :start_date AND "snapshot_date" <= :end_date', + params={ + 'start_date': start_date.strftime('%Y-%m-%d'), + 'end_date': end_date.strftime('%Y-%m-%d'), + }, + ) + + # ========================================== # 輔助函數 # ========================================== @@ -373,35 +415,21 @@ def daily_sales(): chart_data=None, categories=None, calendar_data=None, selected_month=None, datetime_now=datetime_now_str, active_page='daily_sales') - cache_key = f'{table_name}_daily' - # TTL + DB fingerprint 雙閘檢查(資料變動即自動失效,跨 worker 強一致) - if _is_cache_valid(cache_key, engine, table_name): - df = _SALES_PROCESSED_CACHE[cache_key]['df'] - sys_log.debug(f"使用緩存數據,剩餘有效時間: {_CACHE_EXPIRY_SECONDS - (datetime.now() - _SALES_PROCESSED_CACHE[cache_key]['timestamp']).total_seconds():.0f}秒") - else: - df = safe_read_sql(table_name, engine=engine) - if df.empty: - return render_template('daily_sales.html', - error="資料表為空,請先匯入當日業績資料。", - selected_date=None, available_dates=[], current=None, dod=None, wow=None, - chart_data=None, categories=None, calendar_data=None, selected_month=None, - datetime_now=datetime_now_str, active_page='daily_sales') + available_dates = _get_available_daily_dates(engine, table_name) + if not available_dates: + return render_template('daily_sales.html', + error="資料表為空,請先匯入當日業績資料。", + selected_date=None, available_dates=[], current=None, dod=None, wow=None, + chart_data=None, categories=None, calendar_data=None, selected_month=None, + datetime_now=datetime_now_str, active_page='daily_sales') - df = preprocess_daily_sales_data(df) - _SALES_PROCESSED_CACHE[cache_key] = { - 'df': df, 'timestamp': datetime.now(), - 'fingerprint': _get_data_fingerprint(engine, table_name), - } - sys_log.info(f"已重新載入數據並更新緩存,共 {len(df)} 筆記錄") - - available_dates = sorted(df['snapshot_date'].unique(), reverse=True) - available_dates_str = [d.strftime('%Y-%m-%d') if isinstance(d, pd.Timestamp) else str(d) for d in available_dates] + available_dates_str = [d.strftime('%Y-%m-%d') for d in available_dates] selected_date_param = request.args.get('date') if selected_date_param: selected_date = pd.to_datetime(selected_date_param) else: - selected_date = df['snapshot_date'].max() + selected_date = available_dates[0] selected_month_param = request.args.get('month') if selected_month_param: @@ -413,12 +441,42 @@ def daily_sales(): if selected_month_param and not selected_date_param: is_month_view = True + month_start = selected_month.replace(day=1) + month_end = (month_start + pd.DateOffset(months=1)) - pd.Timedelta(days=1) + data_start = min( + selected_date - pd.Timedelta(days=30), + selected_date - pd.Timedelta(days=7), + month_start - pd.Timedelta(days=1), + ) + data_end = max(selected_date, month_end) + + cache_key = f"{table_name}_daily_{data_start.strftime('%Y%m%d')}_{data_end.strftime('%Y%m%d')}" + # TTL + DB fingerprint 雙閘檢查(資料變動即自動失效,跨 worker 強一致) + if _is_cache_valid(cache_key, engine, table_name): + df = _SALES_PROCESSED_CACHE[cache_key]['df'] + sys_log.debug(f"使用緩存數據,剩餘有效時間: {_CACHE_EXPIRY_SECONDS - (datetime.now() - _SALES_PROCESSED_CACHE[cache_key]['timestamp']).total_seconds():.0f}秒") + else: + df = _read_daily_sales_window(engine, table_name, data_start, data_end) + if df.empty: + return render_template('daily_sales.html', + error="所選日期區間沒有業績資料。", + selected_date=None, available_dates=available_dates_str, current=None, dod=None, wow=None, + chart_data=None, categories=None, calendar_data=None, selected_month=None, + datetime_now=datetime_now_str, active_page='daily_sales') + + df = preprocess_daily_sales_data(df) + _SALES_PROCESSED_CACHE[cache_key] = { + 'df': df, 'timestamp': datetime.now(), + 'fingerprint': _get_data_fingerprint(engine, table_name), + } + sys_log.info( + f"已載入當日業績窗口 {data_start.strftime('%Y-%m-%d')}~{data_end.strftime('%Y-%m-%d')},共 {len(df)} 筆記錄" + ) + current_kpi = calculate_daily_kpis(df, selected_date) dod_kpi = calculate_dod(df, selected_date) wow_kpi = calculate_wow(df, selected_date) - month_start = selected_month.replace(day=1) - month_end = (month_start + pd.DateOffset(months=1)) - pd.Timedelta(days=1) month_df = df[(df['snapshot_date'] >= month_start) & (df['snapshot_date'] <= month_end)] cols = month_df.columns.tolist()