fix(daily_sales): 啟用 bp 版改進邏輯 + import 後跨 worker 清 cache,根除 #24 隱形 bug
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s
- 從 app.py 刪除 396 行的 /daily_sales、/daily_sales/export、/daily_sales/export_marketing 三條 @app.route(行 5911-6306),讓 routes/daily_sales_routes.py 的 daily_sales_bp 生效(first-registered wins,原 app.py 版本 shadow 了 bp)。 - bp 版改進點:_is_cache_valid() 帶 5 分鐘 TTL、/api/daily_sales/clear_cache 端點、 完整模板參數(datetime_now / active_page)。 - services/import_service.py process_daily_sales_import return True 前, 新增跨 gunicorn worker 清 daily_sales cache 邏輯:依 GUNICORN_WORKERS 次數呼叫 internal /api/daily_sales/clear_cache,避免 4 worker 各持 5 分鐘舊快取 導致「匯入 15323 筆但當日業績看不到」隱形 bug。 [P7-COMPLETION] - 方案正確: 雙重佐證(refactor-specialist + web-researcher)確認 Flask first-registered wins,刪 app.py 內 route 即可讓 bp 接管;helper 函式(preprocess_daily_sales_data 等) 為 dead code 但保守保留不影響執行。 - 影響完整: 全 repo grep 確認 _SALES_PROCESSED_CACHE 在 app.py 仍有 30+ 處使用 (sales_analysis 等其他路由),未動到;helper 函式無外部 caller。 - Regression 風險: 低,bp 版簽名與行為相容;新 cache 清除走 internal HTTP 帶 try/except 不影響主流程;若 GUNICORN_WORKERS 未設則默認 4 與生產一致。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
396
app.py
396
app.py
@@ -5908,402 +5908,6 @@ def growth_analysis():
|
||||
sys_log.error(f"Growth Analysis Error: {e}")
|
||||
return f"系統錯誤: {e}"
|
||||
|
||||
# ================= 📅 V-New: 當日業績看板 =================
|
||||
|
||||
@app.route('/daily_sales')
|
||||
def daily_sales():
|
||||
"""當日業績看板 (Day-over-Day 與 Week-over-Week 分析)"""
|
||||
try:
|
||||
db = DatabaseManager()
|
||||
engine = db.engine
|
||||
table_name = 'daily_sales_snapshot'
|
||||
|
||||
# 1. 檢查資料表是否存在
|
||||
inspector = inspect(engine)
|
||||
if table_name not in inspector.get_table_names():
|
||||
return render_template('daily_sales.html',
|
||||
error="尚未匯入當日業績資料,請先至系統設定頁面匯入 Excel。",
|
||||
selected_date=None, available_dates=[], current=None, dod=None, wow=None,
|
||||
chart_data=None, categories=None, calendar_data=None, selected_month=None)
|
||||
|
||||
# 2. 讀取資料(使用快取)
|
||||
cache_key = f'{table_name}_daily'
|
||||
if cache_key in _SALES_PROCESSED_CACHE:
|
||||
df = _SALES_PROCESSED_CACHE[cache_key]['df']
|
||||
else:
|
||||
df = pd.read_sql(f"SELECT * FROM {table_name}", 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)
|
||||
|
||||
# 3. 資料前處理(欄位識別、型別轉換)
|
||||
df = preprocess_daily_sales_data(df)
|
||||
_SALES_PROCESSED_CACHE[cache_key] = {'df': df}
|
||||
|
||||
# 4. 取得可用日期列表
|
||||
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]
|
||||
|
||||
# 5. 取得選擇的日期(從 URL 參數或使用最新日期)
|
||||
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()
|
||||
|
||||
# 6. 取得選擇的月份(用於行事曆顯示)
|
||||
selected_month_param = request.args.get('month')
|
||||
if selected_month_param:
|
||||
selected_month = pd.to_datetime(selected_month_param)
|
||||
else:
|
||||
selected_month = selected_date
|
||||
|
||||
# V-New 2026-01-15: 判斷是否為月概覽模式(沒有選擇特定日期)
|
||||
is_month_view = not selected_date_param and not request.args.get('month')
|
||||
# 如果只有 month 參數沒有 date 參數,也是月概覽模式
|
||||
if selected_month_param and not selected_date_param:
|
||||
is_month_view = True
|
||||
|
||||
# 7. 計算 KPI
|
||||
current_kpi = calculate_daily_kpis(df, selected_date)
|
||||
dod_kpi = calculate_dod(df, selected_date)
|
||||
wow_kpi = calculate_wow(df, selected_date)
|
||||
|
||||
# V-New 2026-01-15: 計算月度總計 KPI
|
||||
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)]
|
||||
|
||||
# V-Fix 2026-01-15: 使用 find_col 動態獲取正確欄位名稱
|
||||
cols = month_df.columns.tolist()
|
||||
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
|
||||
col_cost = find_col(cols, ['成本', 'Cost', '總成本'])
|
||||
col_profit = find_col(cols, ['毛利', 'Profit'])
|
||||
col_qty = find_col(cols, ['銷售數量', '銷量', '數量'])
|
||||
col_name = find_col(cols, ['商品名稱', '品名', 'Name'])
|
||||
|
||||
month_kpi = {
|
||||
'total_revenue': float(month_df[col_amount].sum()) if col_amount else 0,
|
||||
'total_cost': float(month_df[col_cost].sum()) if col_cost else 0,
|
||||
'gross_margin': float(month_df[col_profit].sum()) if col_profit else 0,
|
||||
'total_qty': float(month_df[col_qty].sum()) if col_qty else 0,
|
||||
'sku_count': int(month_df[col_name].nunique()) if col_name else 0,
|
||||
'days_with_data': int(month_df['snapshot_date'].nunique())
|
||||
}
|
||||
# 若無毛利欄位,用業績減成本計算
|
||||
if not col_profit and col_amount and col_cost:
|
||||
month_kpi['gross_margin'] = month_kpi['total_revenue'] - month_kpi['total_cost']
|
||||
# 計算月度毛利率
|
||||
if month_kpi['total_revenue'] > 0:
|
||||
month_kpi['margin_rate'] = month_kpi['gross_margin'] / month_kpi['total_revenue'] * 100
|
||||
else:
|
||||
month_kpi['margin_rate'] = 0
|
||||
# 計算月度客單價
|
||||
if month_kpi['total_qty'] > 0:
|
||||
month_kpi['avg_price'] = month_kpi['total_revenue'] / month_kpi['total_qty']
|
||||
else:
|
||||
month_kpi['avg_price'] = 0
|
||||
|
||||
# 8. 準備圖表數據(根據選擇的日期)
|
||||
chart_data = prepare_daily_charts(df, selected_date, days=30)
|
||||
|
||||
# 9. 準備分類聚合列表
|
||||
# V-Fix 2026-01-15: 根據檢視模式(單日/月度)決定聚合範圍
|
||||
category_list = prepare_category_summary(
|
||||
df,
|
||||
date_str=selected_date,
|
||||
is_month_view=is_month_view,
|
||||
month_start=month_start if is_month_view else None,
|
||||
month_end=month_end if is_month_view else None
|
||||
)
|
||||
|
||||
# 10. 準備行事曆數據
|
||||
calendar_data = prepare_calendar_data(df, selected_month)
|
||||
|
||||
# 11. V-New: 準備行銷活動業績數據
|
||||
marketing_data = prepare_marketing_summary(
|
||||
df,
|
||||
selected_date=selected_date if not is_month_view else None,
|
||||
is_month_view=is_month_view,
|
||||
month_start=month_start if is_month_view else None,
|
||||
month_end=month_end if is_month_view else None
|
||||
)
|
||||
|
||||
# 12. 回傳模板
|
||||
return render_template('daily_sales.html',
|
||||
selected_date=selected_date.strftime('%Y-%m-%d') if isinstance(selected_date, pd.Timestamp) else selected_date,
|
||||
available_dates=available_dates_str,
|
||||
current=current_kpi,
|
||||
dod=dod_kpi,
|
||||
wow=wow_kpi,
|
||||
month_kpi=month_kpi, # V-New: 月度總計
|
||||
is_month_view=is_month_view, # V-New: 月概覽模式標誌
|
||||
chart_data=chart_data,
|
||||
categories=category_list,
|
||||
calendar_data=calendar_data,
|
||||
marketing_data=marketing_data, # V-New: 行銷活動數據
|
||||
selected_month=selected_month.strftime('%Y-%m') if isinstance(selected_month, pd.Timestamp) else selected_month)
|
||||
|
||||
except Exception as e:
|
||||
sys_log.error(f"[Web] [DailySales] Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return render_template('daily_sales.html',
|
||||
error=f"系統錯誤: {str(e)}",
|
||||
selected_date=None,
|
||||
available_dates=[],
|
||||
current=None,
|
||||
dod=None,
|
||||
wow=None,
|
||||
month_kpi=None,
|
||||
is_month_view=False,
|
||||
chart_data=None,
|
||||
categories=None,
|
||||
calendar_data=None,
|
||||
marketing_data=None,
|
||||
selected_month=None)
|
||||
|
||||
@app.route('/daily_sales/export')
|
||||
def export_daily_sales_category():
|
||||
"""匯出當日業績分類明細為 Excel"""
|
||||
try:
|
||||
from datetime import datetime
|
||||
import io
|
||||
from flask import send_file
|
||||
|
||||
db = DatabaseManager()
|
||||
engine = db.engine
|
||||
table_name = 'daily_sales_snapshot'
|
||||
|
||||
# 檢查資料表是否存在
|
||||
inspector = inspect(engine)
|
||||
if table_name not in inspector.get_table_names():
|
||||
return "資料表不存在", 404
|
||||
|
||||
# 讀取資料
|
||||
cache_key = f'{table_name}_daily'
|
||||
if cache_key in _SALES_PROCESSED_CACHE:
|
||||
df = _SALES_PROCESSED_CACHE[cache_key]['df']
|
||||
else:
|
||||
df = pd.read_sql(f"SELECT * FROM {table_name}", engine)
|
||||
df = preprocess_daily_sales_data(df)
|
||||
_SALES_PROCESSED_CACHE[cache_key] = {'df': df}
|
||||
|
||||
# 取得選擇的日期
|
||||
selected_date = request.args.get('date')
|
||||
if not selected_date:
|
||||
available_dates = sorted(df['snapshot_date'].unique(), reverse=True)
|
||||
if available_dates:
|
||||
selected_date = str(available_dates[0])
|
||||
else:
|
||||
return "無可用日期", 404
|
||||
|
||||
# 準備分類資料
|
||||
categories = prepare_category_summary(df, selected_date)
|
||||
|
||||
if not categories:
|
||||
return "無資料可匯出", 404
|
||||
|
||||
# 轉為 DataFrame
|
||||
export_df = pd.DataFrame(categories)
|
||||
|
||||
# 重新排列欄位順序並重新命名為中文
|
||||
column_mapping = {
|
||||
'category': '分類',
|
||||
'vendor': '廠商',
|
||||
'revenue': '總業績',
|
||||
'cost': '總成本',
|
||||
'profit': '毛利',
|
||||
'margin_rate': '毛利率(%)',
|
||||
'qty': '總銷量',
|
||||
'sku_count': 'SKU數',
|
||||
'avg_price': '平均單價'
|
||||
}
|
||||
|
||||
# 只保留存在的欄位
|
||||
export_columns = [col for col in column_mapping.keys() if col in export_df.columns]
|
||||
export_df = export_df[export_columns]
|
||||
export_df = export_df.rename(columns=column_mapping)
|
||||
|
||||
# 格式化數值欄位
|
||||
for col in export_df.columns:
|
||||
if col in ['總業績', '總成本', '毛利', '總銷量', 'SKU數', '平均單價']:
|
||||
export_df[col] = export_df[col].apply(lambda x: f"{x:,.0f}" if pd.notna(x) else "0")
|
||||
elif col == '毛利率(%)':
|
||||
export_df[col] = export_df[col].apply(lambda x: f"{x:.1f}" if pd.notna(x) else "0.0")
|
||||
|
||||
# 產生檔案名稱
|
||||
filename = f"當日業績_分類明細_{selected_date}.xlsx"
|
||||
|
||||
# 寫入 Excel
|
||||
output = io.BytesIO()
|
||||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||||
export_df.to_excel(writer, index=False, sheet_name='分類業績明細')
|
||||
|
||||
# 調整欄寬
|
||||
worksheet = writer.sheets['分類業績明細']
|
||||
for idx, col in enumerate(export_df.columns, 1):
|
||||
max_length = max(
|
||||
export_df[col].astype(str).apply(len).max(),
|
||||
len(col)
|
||||
) + 2
|
||||
worksheet.column_dimensions[chr(64 + idx)].width = min(max_length, 50)
|
||||
|
||||
output.seek(0)
|
||||
|
||||
sys_log.info(f"[Web] [DailySales] Excel 匯出成功: {filename}")
|
||||
|
||||
return send_file(
|
||||
output,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
sys_log.error(f"[Web] [DailySales] Excel 匯出失敗: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return f"匯出失敗: {str(e)}", 500
|
||||
|
||||
# V-New 2026-01-15: 行銷活動業績匯出 API
|
||||
@app.route('/daily_sales/export_marketing')
|
||||
def export_marketing_summary_excel():
|
||||
"""匯出行銷活動業績明細為 Excel"""
|
||||
try:
|
||||
import io
|
||||
from flask import send_file
|
||||
|
||||
db = DatabaseManager()
|
||||
engine = db.engine
|
||||
table_name = 'daily_sales_snapshot'
|
||||
|
||||
# 讀取資料
|
||||
cache_key = f'{table_name}_daily'
|
||||
if cache_key in _SALES_PROCESSED_CACHE:
|
||||
df = _SALES_PROCESSED_CACHE[cache_key]['df']
|
||||
else:
|
||||
df = pd.read_sql(f"SELECT * FROM {table_name}", engine)
|
||||
df = preprocess_daily_sales_data(df)
|
||||
_SALES_PROCESSED_CACHE[cache_key] = {'df': df}
|
||||
|
||||
# 取得參數
|
||||
activity_type = request.args.get('type', 'all') # coupon, discount, bonus, click, all
|
||||
start_date = request.args.get('start_date')
|
||||
end_date = request.args.get('end_date')
|
||||
selected_date = request.args.get('date')
|
||||
|
||||
# 額外篩選參數 (與 sales_analysis 同步)
|
||||
selected_category = request.args.get('category', 'all')
|
||||
selected_brand = request.args.get('brand', 'all')
|
||||
selected_vendor = request.args.get('vendor', 'all')
|
||||
keyword = request.args.get('keyword', '')
|
||||
|
||||
# 決定日期範圍
|
||||
if start_date and end_date:
|
||||
df = df[(df['snapshot_date'] >= pd.to_datetime(start_date)) &
|
||||
(df['snapshot_date'] <= pd.to_datetime(end_date))]
|
||||
date_label = f"{start_date}_{end_date}"
|
||||
elif selected_date:
|
||||
df = df[df['snapshot_date'] == pd.to_datetime(selected_date)]
|
||||
date_label = selected_date
|
||||
else:
|
||||
date_label = "全部"
|
||||
|
||||
# 應用額外篩選
|
||||
cols = df.columns.tolist()
|
||||
col_category = find_col(cols, ['館別', '商品館', '分類', 'Category'])
|
||||
col_brand = find_col(cols, ['品牌', 'Brand'])
|
||||
col_vendor = find_col(cols, ['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier'])
|
||||
col_name = find_col(cols, ['商品名稱', '品名'])
|
||||
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
|
||||
col_qty = find_col(cols, ['銷售數量', '銷量', '數量'])
|
||||
|
||||
if selected_category != 'all' and col_category:
|
||||
df = df[df[col_category] == selected_category]
|
||||
if selected_brand != 'all' and col_brand:
|
||||
df = df[df[col_brand] == selected_brand]
|
||||
if selected_vendor != 'all' and col_vendor:
|
||||
df = df[df[col_vendor] == selected_vendor]
|
||||
if keyword and col_name:
|
||||
df = df[df[col_name].str.contains(keyword, case=False, na=False)]
|
||||
|
||||
# 定義行銷活動欄位
|
||||
marketing_cols = {
|
||||
'coupon': ('折價券活動名稱', '折價券活動'),
|
||||
'discount': ('折扣活動名稱', '折扣活動'),
|
||||
'bonus': ('滿額再折扣活動名稱', '滿額再折扣'),
|
||||
'click': ('點我再折扣', '點我再折扣')
|
||||
}
|
||||
|
||||
# 準備 Excel 輸出
|
||||
output = io.BytesIO()
|
||||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||||
# 如果是 all,循環所有類型
|
||||
types_to_export = [activity_type] if activity_type != 'all' else ['coupon', 'discount', 'bonus', 'click']
|
||||
|
||||
summary_rows = []
|
||||
|
||||
for t in types_to_export:
|
||||
if t not in marketing_cols: continue
|
||||
col_internal, sheet_label = marketing_cols[t]
|
||||
if col_internal not in df.columns:
|
||||
continue
|
||||
|
||||
# 聚合數據
|
||||
# V-Fix: 排除空值和 0
|
||||
m_df = df[df[col_internal].notna() & (df[col_internal] != '') & (df[col_internal] != '0') & (df[col_internal] != 0)]
|
||||
|
||||
if m_df.empty:
|
||||
continue
|
||||
|
||||
grouped = m_df.groupby(col_internal).agg({
|
||||
col_amount: 'sum',
|
||||
col_qty: 'sum',
|
||||
col_name: 'count' # 訂單筆數/商品筆數
|
||||
}).reset_index()
|
||||
|
||||
# 重命名
|
||||
grouped.columns = ['活動名稱', '總業績', '總銷量', '項目筆數']
|
||||
grouped = grouped.sort_values(by='總業績', ascending=False)
|
||||
|
||||
# 寫入工作表
|
||||
grouped.to_excel(writer, sheet_name=sheet_label[:31], index=False)
|
||||
|
||||
# 加入到總表數據
|
||||
grouped['活動類型'] = sheet_label
|
||||
summary_rows.append(grouped)
|
||||
|
||||
# 建立總表工作表 (如果有多個類型)
|
||||
if len(summary_rows) > 1:
|
||||
all_m_df = pd.concat(summary_rows).sort_values(by='總業績', ascending=False)
|
||||
all_m_df = all_m_df[['活動類型', '活動名稱', '總業績', '總銷量', '項目筆數']]
|
||||
all_m_df.to_excel(writer, sheet_name='合併總表', index=False)
|
||||
|
||||
output.seek(0)
|
||||
output.seek(0)
|
||||
|
||||
filename = f"行銷活動分析_{date_label}.xlsx"
|
||||
# 處理中文檔名編碼
|
||||
from urllib.parse import quote
|
||||
encoded_filename = quote(filename)
|
||||
|
||||
return send_file(
|
||||
output,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
as_attachment=True,
|
||||
download_name=filename,
|
||||
conditional=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
sys_log.error(f"[Web] [Marketing] Excel 匯出失敗: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return f"匯出失敗: {str(e)}", 500
|
||||
|
||||
def preprocess_daily_sales_data(df):
|
||||
"""前處理當日業績資料:欄位識別、型別轉換"""
|
||||
cols = df.columns.tolist()
|
||||
|
||||
@@ -637,6 +637,22 @@ class ImportService:
|
||||
session.close()
|
||||
|
||||
logger.info(f"任務 {job_id} 匯入成功: {total_rows} 筆")
|
||||
|
||||
# 跨 worker 清 daily_sales cache(gunicorn N worker 各持獨立 in-memory cache,
|
||||
# 需打 N 次 internal endpoint 確保每個 worker 的快取都被清掉)
|
||||
try:
|
||||
import requests
|
||||
base = os.getenv('INTERNAL_BASE_URL', 'http://127.0.0.1:5000')
|
||||
n = int(os.getenv('GUNICORN_WORKERS', '4'))
|
||||
for _ in range(n):
|
||||
try:
|
||||
requests.post(f"{base}/api/daily_sales/clear_cache", timeout=2)
|
||||
except Exception:
|
||||
break
|
||||
logger.info(f"任務 {job_id} 已清 daily_sales cache({n} workers)")
|
||||
except Exception as e:
|
||||
logger.warning(f"任務 {job_id} 清 cache 失敗(不影響主流程): {e}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user