This commit is contained in:
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 不可盲動
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user