diff --git a/docs/memory/code_modularization_inventory_20260430.md b/docs/memory/code_modularization_inventory_20260430.md index 7abb5cc..44aa59e 100644 --- a/docs/memory/code_modularization_inventory_20260430.md +++ b/docs/memory/code_modularization_inventory_20260430.md @@ -74,6 +74,7 @@ - 2026-07-14 追記:`services/pchome_revenue_growth_service.py` 因新增可稽核比價覆蓋契約與 active-catalog scope 增至 1,366 行;此輪先完成 P0 正確性,後續 `ARCH-P1-001` 應拆出 catalog coverage query 與 metric-contract builder,主 service 保留 orchestration。 - 2026-07-15 追記:`services/pchome_growth_same_item_reconciliation.py` 已達 880 行;同商品 identity verifier、exact DB readback、coverage post-verifier 與 durable receipt persistence 應在 `ARCH-P1-001` 拆成獨立 policy/verifier/repository,主模組只保留 bounded orchestration。 - 2026-07-17 追記:Nemotron decision-only canary 的排程執行、Telegram acknowledgement 與 durable receipt 終局已移至 `services/nemotron_decision_canary_scheduler_task.py`(120 行);`run_scheduler.py` 僅保留薄委派與排程註冊,清冊同步為 1,684 行,下一步仍依序拆 task registration 與 runtime startup。 +- 2026-07-22 追記:業績分析的 canonical query、metric aggregate、Other-category contract 與 period-linked Excel export 已抽到 `services/sales_analysis_query_service.py`(391 行)及 `services/sales_analysis_export_service.py`(207 行);`routes/sales_routes.py` 降為 2,954 行,後續繼續拆 page context 與 legacy pandas API。 ## 達到或超過 800 行檔案清單 @@ -131,7 +132,7 @@ | 4158 | `routes/admin_observability_routes.py` | P0 觀測台巨型 Blueprint | query、action、render context 與 route glue 分離 | | 3763 | `services/competitor_price_feeder.py` | P0 feeder 巨型 service | acquisition、matching、refresh、persistence、receipt 分離 | | 3322 | `routes/dashboard_routes.py` | P0 Dashboard Blueprint | query、decision projection、review action 分離 | -| 3290 | `routes/sales_routes.py` | P0 Sales Blueprint | chart、query、calendar、export 分離 | +| 2954 | `routes/sales_routes.py` | P0 Sales Blueprint、拆分進行中 | canonical query 與 export 已外移;下一步拆 page context、calendar 與 legacy pandas API | | 3112 | `scheduler.py` | P0 排程總管 | task registry 與 domain jobs 分離 | | 2961 | `services/openclaw_strategist_service.py` | P1 strategist | prompt、query、report、notification 分離 | | 2383 | `services/competitor_intel_repository.py` | P1 repository | query、decision envelope、UI projection、cache 分離 | diff --git a/routes/sales_routes.py b/routes/sales_routes.py index 56bfd53..d6ac38c 100644 --- a/routes/sales_routes.py +++ b/routes/sales_routes.py @@ -44,6 +44,20 @@ from services.sales_analysis_chart_service import ( build_treemap_chart_data, resolve_yoy_year_options, ) +from services.sales_analysis_export_service import ( + combine_sales_marketing_export_frames, + query_sales_marketing_export_frames, + query_sales_vendor_export_frame, + sanitize_excel_dataframe, +) +from services.sales_analysis_query_service import ( + build_sales_metric_aggregate_sql, + build_sales_where_clause, + normalize_sales_query_args, + prepare_sales_query_context, + quote_identifier, + resolve_sales_date_bounds, +) from services.cache_manager import ( _SALES_PROCESSED_CACHE, _SALES_OPTIONS_CACHE, @@ -94,6 +108,7 @@ def _project_yoy_period( start = parse_iso_date(start_value) end = parse_iso_date(end_value) month_raw = str(month_value or '').strip().lower() + anchor = parse_iso_date(anchor_date) or datetime.now(TAIPEI_TZ).date() if start_value and not start: raise ValueError('start_date 必須是 YYYY-MM-DD') @@ -101,32 +116,24 @@ def _project_yoy_period( raise ValueError('end_date 必須是 YYYY-MM-DD') if start or end: - start = start or end - end = end or start + if start and not end: + end = anchor + elif end and not start: + start = date(end.year, 1, 1) if end < start: start, end = end, start wraps_year = end.year > start.year or (end.month, end.day) < (start.month, start.day) projected_start = _safe_period_date(target_year, start.month, start.day) projected_end = _safe_period_date(target_year + int(wraps_year), end.month, end.day) - return projected_start, projected_end - - if month_raw and month_raw != 'all': - selected_month = parse_month(month_raw) - if selected_month: - month = selected_month.month - elif month_raw.isdigit() and 1 <= int(month_raw) <= 12: - month = int(month_raw) - else: - raise ValueError('month 必須是 1 到 12 或 YYYY-MM') - return date(target_year, month, 1), date(target_year, month, monthrange(target_year, month)[1]) - - range_raw = str(data_range_value or '').strip() - if range_raw: + source_anchor_year = start.year + else: + range_raw = str(data_range_value or '').strip() + if not range_raw: + range_raw = '0' if not range_raw.isdigit() or int(range_raw) not in {0, 1, 3, 6, 12}: raise ValueError('data_range 必須是 0、1、3、6 或 12') range_months = int(range_raw) if range_months > 0: - anchor = parse_iso_date(anchor_date) or datetime.now(TAIPEI_TZ).date() range_start = anchor - timedelta(days=range_months * 30) projected_end = _safe_period_date(target_year, anchor.month, anchor.day) projected_start_year = target_year - int(range_start.year < anchor.year) @@ -135,9 +142,40 @@ def _project_yoy_period( range_start.month, range_start.day, ) - return projected_start, projected_end + source_anchor_year = anchor.year + else: + projected_start = date(target_year, 1, 1) + projected_end = date(target_year, 12, 31) + source_anchor_year = target_year - return date(target_year, 1, 1), date(target_year, 12, 31) + if month_raw and month_raw != 'all': + selected_month = parse_month(month_raw) + if selected_month: + has_projected_source_period = bool( + start + or end + or int(str(data_range_value or '0') or '0') > 0 + ) + year_offset = ( + selected_month.year - source_anchor_year + if has_projected_source_period + else 0 + ) + projected_month_year = target_year + year_offset + month = selected_month.month + elif month_raw.isdigit() and 1 <= int(month_raw) <= 12: + projected_month_year = target_year + month = int(month_raw) + else: + raise ValueError('month 必須是 1 到 12 或 YYYY-MM') + month_start = date(projected_month_year, month, 1) + month_finish = date(projected_month_year, month, monthrange(projected_month_year, month)[1]) + if month_finish < projected_start or month_start > projected_end: + raise ValueError('month 不在所選分析期間內') + projected_start = max(projected_start, month_start) + projected_end = min(projected_end, month_finish) + + return projected_start, projected_end def _period_month_keys(start, end): @@ -153,26 +191,31 @@ def _period_month_keys(start, end): return keys -def _query_yoy_month_totals(engine, aggregate_sql, start, end): +def _query_yoy_month_totals(engine, aggregate_sql, start, end, columns, filters): + quoted_date = quote_identifier(engine, columns.get('date')) if engine.dialect.name == 'postgresql': - date_expr = 'LEFT(REPLACE(CAST("日期" AS TEXT), \'/\', \'-\'), 10)' + date_expr = f"LEFT(REPLACE(CAST({quoted_date} AS TEXT), '/', '-'), 10)" month_expr = f'LEFT({date_expr}, 7)' else: - date_expr = 'substr(replace(CAST("日期" AS TEXT), \'/\', \'-\'), 1, 10)' + date_expr = f"substr(replace(CAST({quoted_date} AS TEXT), '/', '-'), 1, 10)" month_expr = f'substr({date_expr}, 1, 7)' + where_sql, params = build_sales_where_clause( + engine, + columns, + filters, + period_start=start, + period_end=end, + apply_month=False, + ) query = text(f""" SELECT {month_expr} AS period_month, {aggregate_sql} AS total FROM realtime_sales_monthly - WHERE {date_expr} >= :start_date AND {date_expr} <= :end_date + WHERE {where_sql} GROUP BY {month_expr} ORDER BY {month_expr} """) - frame = pd.read_sql( - query, - engine, - params={'start_date': start.isoformat(), 'end_date': end.isoformat()}, - ) + frame = pd.read_sql(query, engine, params=params) return { str(row.period_month): float(row.total or 0) for row in frame.itertuples(index=False) @@ -1016,6 +1059,8 @@ def _get_filtered_sales_data(cache_key): col_payment = cols_map.get('payment') col_price = cols_map.get('price') col_date = cols_map.get('date') + col_amount = cols_map.get('amount') + col_pid = cols_map.get('pid') col_return_qty = cols_map.get('return_qty') # V-New: 取得退貨欄位 # 2. 取得篩選參數 @@ -1036,21 +1081,6 @@ def _get_filtered_sales_data(cache_key): # 3. 執行篩選 target_df = df - # Top N 分類處理 (用於 '其他' 篩選) - TOP_N_CATS = 12 - top_cats_names = [] - if col_category: - # 注意:這裡為了效能,簡單重算一次 Top N,或可考慮也快取起來 - cat_group_all = df.groupby(col_category)[cols_map.get('amount')].sum().sort_values(ascending=False) - if len(cat_group_all) > TOP_N_CATS: - top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist() - - if selected_category != 'all' and col_category: - if selected_category == '其他' and top_cats_names: - target_df = target_df[~target_df[col_category].isin(top_cats_names)] - else: - target_df = target_df[target_df[col_category] == selected_category] - if selected_brand != 'all' and col_brand: target_df = target_df[target_df[col_brand] == selected_brand] if selected_vendor != 'all' and col_vendor: target_df = target_df[target_df[col_vendor] == selected_vendor] if selected_activity != 'all' and col_activity: target_df = target_df[target_df[col_activity] == selected_activity] @@ -1063,7 +1093,17 @@ def _get_filtered_sales_data(cache_key): if selected_month != 'all' and col_date and '_month_str' in target_df.columns: target_df = target_df[target_df['_month_str'] == selected_month] - if keyword: target_df = target_df[target_df[col_name].astype(str).str.contains(keyword, case=False, na=False)] + if keyword: + keyword_mask = pd.Series(False, index=target_df.index) + for column in (col_name, col_pid, col_brand, col_vendor): + if column and column in target_df.columns: + keyword_mask |= target_df[column].astype(str).str.contains( + keyword, + case=False, + na=False, + regex=False, + ) + target_df = target_df[keyword_mask] if col_price: if min_price: target_df = target_df[target_df[col_price] >= float(min_price)] @@ -1072,6 +1112,22 @@ def _get_filtered_sales_data(cache_key): if min_margin: target_df = target_df[target_df['calculated_margin_rate'] >= float(min_margin)] if max_margin: target_df = target_df[target_df['calculated_margin_rate'] <= float(max_margin)] + # 「其他」必須依目前其他篩選後的 Top 12 計算,才能與圓餅圖和 API 一致。 + if selected_category != 'all' and col_category: + if selected_category == '其他' and col_amount: + top_categories = ( + target_df.groupby(col_category)[col_amount] + .sum() + .nlargest(12) + .index + ) + target_df = target_df[ + target_df[col_category].notna() + & ~target_df[col_category].isin(top_categories) + ] + else: + target_df = target_df[target_df[col_category] == selected_category] + return target_df, cols_map, None @@ -1247,29 +1303,39 @@ def sales_analysis(): _set_sales_shared_page_context_cache(preview_cache_key, preview_context) return render_template('sales_analysis.html', **preview_context) - # 解析並正規化期間;所有圖、表與 API 共用同一組 canonical 值。 - try: - data_range_months = int(data_range_param or '0') - except (TypeError, ValueError) as exc: - raise ValueError('data_range 必須是 0、1、3、6 或 12') from exc - if data_range_months not in {0, 1, 3, 6, 12}: - raise ValueError('data_range 必須是 0、1、3、6 或 12') + # 解析並正規化期間;反向日期先導回 canonical URL,讓所有 AJAX 共用相同參數。 + normalized_filters = normalize_sales_query_args(request.args, default_data_range=0) + data_range_months = normalized_filters['data_range'] + parsed_start = normalized_filters['start_date'] + parsed_end = normalized_filters['end_date'] + if bool(parsed_start) != bool(parsed_end): + source_start, source_end = resolve_sales_date_bounds(db.engine, table_name) + if parsed_start and not parsed_end: + parsed_end = source_end + elif parsed_end and not parsed_start: + parsed_start = source_start + if not parsed_start or not parsed_end: + raise ValueError('無法取得資料來源日期邊界') + canonical_start = parsed_start.isoformat() if parsed_start else '' + canonical_end = parsed_end.isoformat() if parsed_end else '' + if start_date != canonical_start or end_date != canonical_end: + canonical_args = request.args.to_dict(flat=True) + canonical_args['start_date'] = canonical_start + canonical_args['end_date'] = canonical_end + return redirect(url_for('sales.sales_analysis', **canonical_args)) + start_date = canonical_start + end_date = canonical_end - parsed_start = parse_iso_date(start_date) - parsed_end = parse_iso_date(end_date) - if start_date and not parsed_start: - raise ValueError('start_date 必須是 YYYY-MM-DD') - if end_date and not parsed_end: - raise ValueError('end_date 必須是 YYYY-MM-DD') - if parsed_start and parsed_end and parsed_start > parsed_end: - parsed_start, parsed_end = parsed_end, parsed_start - start_date = parsed_start.isoformat() if parsed_start else '' - end_date = parsed_end.isoformat() if parsed_end else '' - - if start_date or end_date: - period_start = start_date or end_date - period_end = end_date or start_date + if start_date and end_date: + period_start = start_date + period_end = end_date analysis_period = make_analysis_period(period_start, period_end, mode='custom_range') + elif start_date: + analysis_period = make_analysis_period(None, mode='open_start', label=f'{start_date} 起') + analysis_period.update(start_date=start_date, start_month=start_date[:7]) + elif end_date: + analysis_period = make_analysis_period(None, mode='open_end', label=f'{end_date} 止') + analysis_period.update(end_date=end_date, end_month=end_date[:7]) elif data_range_months > 0: period_end = datetime.now(TAIPEI_TZ).date() period_start = period_end - timedelta(days=data_range_months * 30) @@ -1359,9 +1425,6 @@ def sales_analysis(): # 根據是否有日期欄位決定查詢方式 if date_col_name: - from datetime import datetime, timedelta, timezone - TAIPEI_TZ = timezone(timedelta(hours=8)) - # V-New: 優先處理自訂日期區間 if start_date or end_date: # V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01) @@ -1424,8 +1487,6 @@ def sales_analysis(): else: mv_where = f"WHERE sale_date <= '{end_date}'" elif data_range_months > 0: - from datetime import datetime, timedelta, timezone - TAIPEI_TZ = timezone(timedelta(hours=8)) cutoff = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y-%m-%d') mv_where = f"WHERE sale_date >= '{cutoff}'" @@ -2355,329 +2416,122 @@ def abc_analysis_detail(): @sales_bp.route('/api/sales_analysis/table_data') @login_required def api_sales_table_data(): - """API: 取得業績分析的詳細列表資料 (Server-side AJAX) - 使用 SQL 聚合優化""" + """Return the canonical, period-linked product worklist.""" try: - import hashlib - from datetime import datetime, timedelta, timezone - TAIPEI_TZ = timezone(timedelta(hours=8)) - - # V-Opt: 產生查詢快取 key (根據所有篩選條件) cache_params = request.args.to_dict() - cache_key = hashlib.md5(str(sorted(cache_params.items())).encode(), usedforsecurity=False).hexdigest() - - # V-Opt: 檢查快取 - if cache_key in _TABLE_DATA_CACHE: - cached = _TABLE_DATA_CACHE[cache_key] + response_cache_key = hashlib.md5( + str(sorted(cache_params.items())).encode(), usedforsecurity=False + ).hexdigest() + if response_cache_key in _TABLE_DATA_CACHE: + cached = _TABLE_DATA_CACHE[response_cache_key] if time.time() - cached['time'] < _TABLE_DATA_CACHE_TTL: - sys_log.debug(f"[API] Table Data: 使用快取 (key={cache_key[:8]})") + sys_log.debug(f"[API] Table Data: 使用快取 (key={response_cache_key[:8]})") return jsonify(cached['data']) - table_name = 'realtime_sales_monthly' - data_range_months = int(request.args.get('data_range', '1') or '1') - start_date = request.args.get('start_date', '') # V-New: 自訂開始日期 - end_date = request.args.get('end_date', '') # V-New: 自訂結束日期 + table_name = validate_table_name('realtime_sales_monthly') + engine = DatabaseManager().engine + metric = str(request.args.get('metric', 'amount')).strip().lower() + if metric not in {'amount', 'qty', 'profit'}: + raise ValueError('metric 僅允許 amount、qty、profit') + filters, columns = prepare_sales_query_context( + engine, + table_name, + request.args, + default_data_range=1, + ) + name = quote_identifier(engine, columns.get('name')) + amount = quote_identifier(engine, columns.get('amount')) + where_sql, params = build_sales_where_clause(engine, columns, filters) + where_suffix = f' AND {where_sql}' if where_sql else '' - # V-Fix: 取得所有篩選參數 - category_filter = request.args.get('category', 'all') - brand_filter = request.args.get('brand', 'all') # V-Fix: 品牌篩選 - vendor_filter = request.args.get('vendor', 'all') # V-Fix: 廠商篩選 - activity_filter = request.args.get('activity', 'all') # V-Fix: 活動篩選 - payment_filter = request.args.get('payment', 'all') # V-Fix: 付款方式篩選 - month_filter = request.args.get('month', 'all') - dow_filter = request.args.get('dow', 'all') # 星期篩選 - hour_filter = request.args.get('hour', 'all') # 小時篩選 - min_price_str = request.args.get('min_price', '') - max_price_str = request.args.get('max_price', '') - min_margin_str = request.args.get('min_margin', '') - max_margin_str = request.args.get('max_margin', '') - keyword = request.args.get('keyword', '').strip() - - db = DatabaseManager() - - # V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱 - if start_date or end_date: - cache_key = f"{table_name}_custom_{start_date}_{end_date}" - else: - cache_key = f"{table_name}_{data_range_months}m" - - # 嘗試從快取讀取欄位名稱 - cols_map = {} - if cache_key in _SALES_PROCESSED_CACHE: - cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {}) - elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key - cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {}) - - # 取得實際欄位名稱(如果快取中沒有,使用預設名稱) - # V-Fix (2026-01-23): 使用 or 確保不會得到 None 值 - col_name = cols_map.get('name') or '商品名稱' - col_pid = cols_map.get('pid') or '商品ID' - col_brand = cols_map.get('brand') or '品牌' - col_vendor = cols_map.get('vendor') or '廠商名稱' - col_category = cols_map.get('category') or '商品館' - col_amount = cols_map.get('amount') or '總業績' - col_qty = cols_map.get('qty') or '數量' - col_cost = cols_map.get('cost') or '總成本' - col_profit = cols_map.get('profit') or '毛利' - col_return_qty = cols_map.get('return_qty') or '退貨數量' - - # V-Opt: 使用純 SQL 聚合查詢,避免載入完整資料集 - # 建立日期篩選條件 - date_filter = "" - # V-New: 優先處理自訂日期區間 - if start_date or end_date: - # V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01) - start_date_slash = start_date.replace('-', '/') if start_date else '' - end_date_slash = end_date.replace('-', '/') if end_date else '' - - # V-Fix: 只使用「日期」欄位(「訂單日期」欄位是固定文字「訂單日期」,不是實際日期) - if start_date and end_date: - date_filter = f"""AND ("日期" BETWEEN '{start_date_slash}' AND '{end_date_slash}')""" - elif start_date: - date_filter = f"""AND ("日期" >= '{start_date_slash}')""" - else: # only end_date - date_filter = f"""AND ("日期" <= '{end_date_slash}')""" - elif data_range_months > 0: - # V-Fix: 使用斜線格式以匹配資料庫格式 - cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d') - # V-Fix: 只使用「日期」欄位進行篩選(「訂單日期」是固定文字,不是實際日期) - date_filter = f"""AND ("日期" >= '{cutoff_date}')""" - - # V-Fix: 建立其他篩選條件 - additional_filters = [] - - # 分類篩選 - if category_filter and category_filter != 'all' and col_category: - additional_filters.append(f""""{col_category}" = '{category_filter}'""") - - # V-Fix: 品牌篩選 - if brand_filter and brand_filter != 'all' and col_brand: - additional_filters.append(f""""{col_brand}" = '{brand_filter}'""") - - # V-Fix: 廠商篩選 - if vendor_filter and vendor_filter != 'all' and col_vendor: - additional_filters.append(f""""{col_vendor}" = '{vendor_filter}'""") - - # V-Fix: 活動篩選 - col_activity = cols_map.get('activity') - if activity_filter and activity_filter != 'all' and col_activity: - additional_filters.append(f""""{col_activity}" = '{activity_filter}'""") - - # V-Fix: 付款方式篩選 - col_payment = cols_map.get('payment') - if payment_filter and payment_filter != 'all' and col_payment: - additional_filters.append(f""""{col_payment}" = '{payment_filter}'""") - - # 月份篩選 - if month_filter and month_filter != 'all': - # V-Fix: 月份格式例如 "2025-01",但資料庫可能使用斜線格式 "2025/01" - # 只使用「日期」欄位(「訂單日期」是固定文字,「時間」只包含時間) - month_filter_slash = month_filter.replace('-', '/') # "2025-01" -> "2025/01" - # 同時匹配橫線和斜線格式 - additional_filters.append(f"""("日期" LIKE '{month_filter}%' OR "日期" LIKE '{month_filter_slash}%')""") - - # 星期篩選 (需要從日期計算) - if dow_filter and dow_filter != 'all': - # V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite 兩種資料庫 - # Pandas dt.dayofweek: 0=Monday, 6=Sunday - pandas_dow = int(dow_filter) - if DATABASE_TYPE == 'postgresql': - # PostgreSQL: EXTRACT(DOW FROM date) 0=Sunday, 6=Saturday - # Pandas 0(Mon) -> PostgreSQL 1(Mon), Pandas 6(Sun) -> PostgreSQL 0(Sun) - pg_dow = (pandas_dow + 1) % 7 - # 日期格式可能是 2025/01/01,需要轉換為 YYYY-MM-DD - additional_filters.append(f"""EXTRACT(DOW FROM TO_DATE(REPLACE("日期", '/', '-'), 'YYYY-MM-DD')) = {pg_dow}""") + select_fields = [] + group_fields = [] + for key, alias_name in ( + ('pid', 'product_id'), + ('name', 'name'), + ('brand', 'brand'), + ('vendor', 'vendor'), + ('category', 'category'), + ): + column = columns.get(key) + if column: + quoted = quote_identifier(engine, column) + select_fields.append(f'{quoted} AS {alias_name}') + group_fields.append(quoted) else: - # SQLite: strftime('%w', date) 0=Sunday, 6=Saturday - sqlite_dow = str((pandas_dow + 1) % 7) - additional_filters.append(f"""strftime('%w', replace("日期", '/', '-')) = '{sqlite_dow}'""") + select_fields.append(f"'' AS {alias_name}") - # 小時篩選 (需要從時間欄位提取) - if hour_filter and hour_filter != 'all': - # V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite 兩種資料庫 - hour_val = int(hour_filter) - if DATABASE_TYPE == 'postgresql': - # PostgreSQL: 使用 SUBSTRING 或 CAST - additional_filters.append(f"""CAST(SUBSTRING("時間" FROM 1 FOR 2) AS INTEGER) = {hour_val}""") - else: - # SQLite: 使用 substr - additional_filters.append(f"""CAST(substr("時間", 1, 2) AS INTEGER) = {hour_val}""") + qty_sql = ( + f'SUM(CAST({quote_identifier(engine, columns.get("qty"))} AS REAL))' + if columns.get('qty') else '0' + ) + cost_sql = ( + f'SUM(CAST({quote_identifier(engine, columns.get("cost"))} AS REAL))' + if columns.get('cost') else '0' + ) + return_sql = ( + f'SUM(CAST({quote_identifier(engine, columns.get("return_qty"))} AS REAL))' + if columns.get('return_qty') else '0' + ) + try: + profit_sql, _ = build_sales_metric_aggregate_sql(engine, columns, 'profit') + except ValueError: + profit_sql = '0' + if metric == 'qty' and not columns.get('qty'): + raise ValueError('目前資料來源沒有銷量欄位') + if metric == 'profit' and profit_sql == '0': + raise ValueError('目前資料來源沒有毛利或成本欄位') + order_column = {'amount': 'amount', 'qty': 'qty', 'profit': 'profit'}[metric] + query = text(f""" + SELECT {', '.join(select_fields)}, + SUM(CAST({amount} AS REAL)) AS amount, + {qty_sql} AS qty, + {cost_sql} AS cost, + {profit_sql} AS profit, + {return_sql} AS return_qty, + COUNT(*) AS order_count + FROM {table_name} + WHERE {name} IS NOT NULL {where_suffix} + GROUP BY {', '.join(group_fields)} + ORDER BY {order_column} DESC + LIMIT 300 + """) - # 關鍵字篩選 - if keyword: - keyword_escaped = keyword.replace("'", "''") # SQL 注入防護 - keyword_conditions = [] - if col_name: - keyword_conditions.append(f""""{col_name}" LIKE '%{keyword_escaped}%'""") - if col_pid: - keyword_conditions.append(f""""{col_pid}" LIKE '%{keyword_escaped}%'""") - if col_brand: - keyword_conditions.append(f""""{col_brand}" LIKE '%{keyword_escaped}%'""") - if col_vendor: - keyword_conditions.append(f""""{col_vendor}" LIKE '%{keyword_escaped}%'""") - if keyword_conditions: - additional_filters.append(f"({' OR '.join(keyword_conditions)})") - - # V-New: 價格區間篩選 (Price Range) - if (min_price_str or max_price_str) and col_qty and col_amount: - # 假設單價 = 總業績 / 數量 (防止除以零) - price_cal_sql = f'CAST("{col_amount}" AS FLOAT) / NULLIF("{col_qty}", 0)' - if min_price_str: - additional_filters.append(f"{price_cal_sql} >= {float(min_price_str)}") - if max_price_str: - additional_filters.append(f"{price_cal_sql} <= {float(max_price_str)}") - - # V-New: 毛利率區間篩選 (Margin Range) - if (min_margin_str or max_margin_str) and col_amount: - # 計算毛利額 SQL - if col_profit: - profit_cal_sql = f'"{col_profit}"' - elif col_cost: - profit_cal_sql = f'("{col_amount}" - "{col_cost}")' - else: - profit_cal_sql = "0" - - # 計算毛利率 SQL: (毛利 / 業績) * 100 - margin_cal_sql = f'({profit_cal_sql} * 100.0 / NULLIF("{col_amount}", 0))' - - if min_margin_str: - additional_filters.append(f"{margin_cal_sql} >= {float(min_margin_str)}") - if max_margin_str: - additional_filters.append(f"{margin_cal_sql} <= {float(max_margin_str)}") - - # 組合所有篩選條件 - all_filters = date_filter - if additional_filters: - all_filters += " AND " + " AND ".join(additional_filters) - - # SQL 聚合查詢 - 直接在資料庫層級完成聚合 - # V-Fix: 使用動態欄位名稱 - group_by_cols = [] - if col_pid: group_by_cols.append(f'"{col_pid}"') - if col_name: group_by_cols.append(f'"{col_name}"') - if col_brand: group_by_cols.append(f'"{col_brand}"') - if col_vendor: group_by_cols.append(f'"{col_vendor}"') - if col_category: group_by_cols.append(f'"{col_category}"') - group_by_clause = ', '.join(group_by_cols) if group_by_cols else '"商品ID"' - - sql_query = f""" - SELECT - {f'"{col_pid}" as product_id' if col_pid else "'未知' as product_id"}, - {f'"{col_name}" as name' if col_name else "'未知' as name"}, - {f'"{col_brand}" as brand' if col_brand else "'' as brand"}, - {f'"{col_vendor}" as vendor' if col_vendor else "'' as vendor"}, - {f'"{col_category}" as category' if col_category else "'' as category"}, - {f'SUM(CAST("{col_amount}" AS REAL)) as amount' if col_amount else '0 as amount'}, - {f'SUM(CAST("{col_qty}" AS REAL)) as qty' if col_qty else '0 as qty'}, - {f'SUM(CAST("{col_cost}" AS REAL)) as cost' if col_cost else '0 as cost'}, - {f'SUM(CAST("{col_return_qty}" AS REAL)) as return_qty' if col_return_qty else '0 as return_qty'}, - COUNT(*) as order_count - FROM {table_name} - WHERE 1=1 {all_filters} - GROUP BY {group_by_clause} - ORDER BY amount DESC - LIMIT 300 - """ - - df_agg = pd.read_sql(sql_query, db.engine) - sys_log.info(f"[API] Table Data: SQL聚合查詢返回 {len(df_agg)} 筆商品 (篩選: category={category_filter}, month={month_filter}, dow={dow_filter}, hour={hour_filter}, keyword={keyword})") + df_agg = pd.read_sql(query, engine, params=params) + sys_log.info(f"[API] Table Data: SQL 聚合返回 {len(df_agg)} 筆商品") if df_agg.empty: return jsonify({'data': []}) - # 計算衍生欄位 - df_agg['margin_rate'] = ((df_agg['amount'] - df_agg['cost']) / df_agg['amount'] * 100).fillna(0) + df_agg['margin_rate'] = (df_agg['profit'] / df_agg['amount'] * 100).fillna(0) df_agg['margin_rate'] = df_agg['margin_rate'].replace([np.inf, -np.inf], 0) df_agg['avg_price'] = (df_agg['amount'] / df_agg['qty']).fillna(0) df_agg['return_rate'] = (df_agg['return_qty'] / df_agg['qty'] * 100).fillna(0) - - # V-Fix: 應用價格區間篩選 (在計算欄位後才能篩選) - if min_price_str: - try: - min_price = float(min_price_str) - df_agg = df_agg[df_agg['avg_price'] >= min_price] - except ValueError: - pass - - if max_price_str: - try: - max_price = float(max_price_str) - df_agg = df_agg[df_agg['avg_price'] <= max_price] - except ValueError: - pass - - # V-Fix: 應用毛利區間篩選 (在計算欄位後才能篩選) - if min_margin_str: - try: - min_margin = float(min_margin_str) - df_agg = df_agg[df_agg['margin_rate'] >= min_margin] - except ValueError: - pass - - if max_margin_str: - try: - max_margin = float(max_margin_str) - df_agg = df_agg[df_agg['margin_rate'] <= max_margin] - except ValueError: - pass - - # 重新排序並限制到 300 筆 (減少前端渲染負擔) - df_agg = df_agg.sort_values('amount', ascending=False).head(300) - - # V-Opt: 使用向量化操作取代逐列迴圈 df_agg['rank'] = range(1, len(df_agg) + 1) - df_agg['month_str'] = '' # SQL聚合模式不需要月份字串 - - # 重新命名欄位以符合前端格式 - result_df = df_agg.rename(columns={ - 'product_id': 'product_id', - 'name': 'name', - 'brand': 'brand', - 'vendor': 'vendor', - 'category': 'category', - 'margin_rate': 'margin_rate', - 'avg_price': 'avg_price', - 'return_rate': 'return_rate', - 'qty': 'qty', - 'amount': 'amount' - }) - - # 選擇需要的欄位並轉換為字典列表 + df_agg['month_str'] = filters['month'] or '' columns = ['rank', 'product_id', 'name', 'brand', 'vendor', 'category', 'margin_rate', 'month_str', 'avg_price', 'return_rate', 'qty', 'amount'] - - # V-Fix (2026-01-23): 確保所有數值欄位無 NaN/Infinity,避免 JSON 序列化失敗 numeric_cols = ['margin_rate', 'avg_price', 'return_rate', 'qty', 'amount'] for col in numeric_cols: - if col in result_df.columns: - result_df[col] = result_df[col].replace([np.inf, -np.inf], 0).fillna(0) - - # V-Fix (2026-01-23): 確保字串欄位無 None,避免 JSON 序列化失敗 + df_agg[col] = df_agg[col].replace([np.inf, -np.inf], 0).fillna(0) string_cols = ['product_id', 'name', 'brand', 'vendor', 'category', 'month_str'] for col in string_cols: - if col in result_df.columns: - result_df[col] = result_df[col].fillna('').astype(str) - - data = result_df[columns].to_dict('records') - + df_agg[col] = df_agg[col].fillna('').astype(str) + data = df_agg[columns].to_dict('records') response_data = {'data': data} - - # V-Opt: 儲存到快取 - _TABLE_DATA_CACHE[cache_key] = {'data': response_data, 'time': time.time()} - - # V-Opt: 清理過期快取 (保留最近 50 個) + _TABLE_DATA_CACHE[response_cache_key] = {'data': response_data, 'time': time.time()} if len(_TABLE_DATA_CACHE) > 50: sorted_keys = sorted(_TABLE_DATA_CACHE.keys(), key=lambda k: _TABLE_DATA_CACHE[k]['time']) for old_key in sorted_keys[:-50]: del _TABLE_DATA_CACHE[old_key] - return jsonify(response_data) - - except Exception as e: - sys_log.error(f"[API] Table Data Error: {e}") - import traceback + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + sys_log.error(f"[API] Table Data Error: {exc}") traceback.print_exc() - return jsonify({'error': str(e)}), 500 + return jsonify({'error': '商品清單暫時無法載入'}), 500 @sales_bp.route('/api/sales_analysis/table_data_pandas') @@ -2769,547 +2623,230 @@ def api_sales_table_data_pandas(): except Exception as e: sys_log.error(f"Table Data API Error: {e}") - return jsonify({'error': str(e)}), 500 + return jsonify({'error': '商品清單暫時無法載入'}), 500 + + +def _query_sales_top_detail_frame(engine, args): + """Run one parameterized query shared by the modal and Excel export.""" + table_name = validate_table_name('realtime_sales_monthly') + metric = str(args.get('metric', 'amount')).strip().lower() + view_type = str(args.get('view', 'product')).strip().lower() + top_type = str(args.get('type', 'revenue')).strip().lower() + if metric not in {'amount', 'profit', 'qty'}: + raise ValueError('metric 僅允許 amount、profit、qty') + if view_type not in {'product', 'category'}: + raise ValueError('view 僅允許 product、category') + if top_type not in {'revenue', 'margin', 'quantity'}: + raise ValueError('type 僅允許 revenue、margin、quantity') + expected_metric = { + 'revenue': 'amount', + 'margin': 'profit', + 'quantity': 'qty', + }[top_type] + if metric != expected_metric: + raise ValueError('type 與 metric 組合不一致') + + filters, columns = prepare_sales_query_context( + engine, + table_name, + args, + default_data_range=1, + ) + amount = quote_identifier(engine, columns.get('amount')) + category = quote_identifier(engine, columns.get('category')) + if metric == 'qty': + value_sql = f'SUM(CAST({quote_identifier(engine, columns.get("qty"))} AS REAL))' + elif metric == 'profit': + value_sql, _ = build_sales_metric_aggregate_sql(engine, columns, 'profit') + else: + value_sql = f'SUM(CAST({amount} AS REAL))' + + try: + profit_sql, _ = build_sales_metric_aggregate_sql(engine, columns, 'profit') + margin_sql = ( + f'CASE WHEN SUM(CAST({amount} AS REAL)) > 0 ' + f'THEN ({profit_sql}) * 100.0 / SUM(CAST({amount} AS REAL)) ELSE 0 END' + ) + except ValueError: + margin_sql = '0' + + where_sql, params = build_sales_where_clause(engine, columns, filters) + where_suffix = f' AND {where_sql}' if where_sql else '' + if view_type == 'category': + query = text(f""" + SELECT '' AS product_id, {category} AS name, '' AS brand, '' AS vendor, + {category} AS category, {value_sql} AS value, {margin_sql} AS margin_rate + FROM {table_name} + WHERE {category} IS NOT NULL {where_suffix} + GROUP BY {category} + ORDER BY value DESC + LIMIT 50 + """) + else: + name = quote_identifier(engine, columns.get('name')) + grouped = [] + select_fields = [] + for key, alias_name in ( + ('pid', 'product_id'), + ('name', 'name'), + ('brand', 'brand'), + ('vendor', 'vendor'), + ('category', 'category'), + ): + column = columns.get(key) + if column: + quoted = quote_identifier(engine, column) + select_fields.append(f'{quoted} AS {alias_name}') + grouped.append(quoted) + else: + select_fields.append(f"'' AS {alias_name}") + query = text(f""" + SELECT {', '.join(select_fields)}, {value_sql} AS value, {margin_sql} AS margin_rate + FROM {table_name} + WHERE {name} IS NOT NULL {where_suffix} + GROUP BY {', '.join(grouped)} + ORDER BY value DESC + LIMIT 100 + """) + + frame = pd.read_sql(query, engine, params=params) + for column in ('value', 'margin_rate'): + if column in frame.columns: + frame[column] = frame[column].replace([np.inf, -np.inf], 0).fillna(0) + for column in ('product_id', 'name', 'brand', 'vendor', 'category'): + if column in frame.columns: + frame[column] = frame[column].fillna('').astype(str) + return frame, {'metric': metric, 'view': view_type, 'type': top_type} @sales_bp.route('/api/sales_analysis/top_detail') @login_required def api_sales_top_detail(): - """API: 取得 Top N 詳細列表(業績貢獻王/獲利金雞母/人氣引流款)""" + """Return the period-linked Top N detail used by the in-page modal.""" try: - from datetime import datetime, timedelta, timezone - TAIPEI_TZ = timezone(timedelta(hours=8)) - - table_name = 'realtime_sales_monthly' - data_range_months = int(request.args.get('data_range', '1') or '1') - start_date = request.args.get('start_date', '') # V-New: 自訂開始日期 - end_date = request.args.get('end_date', '') # V-New: 自訂結束日期 - top_type = request.args.get('type', 'revenue') # revenue/margin/quantity - metric = request.args.get('metric', 'amount') # amount/profit/qty - view_type = request.args.get('view', 'product') # product/category - - db = DatabaseManager() - - # V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱 - if start_date or end_date: - cache_key = f"{table_name}_custom_{start_date}_{end_date}" - else: - cache_key = f"{table_name}_{data_range_months}m" - - # 嘗試從快取讀取欄位名稱 - cols_map = {} - if cache_key in _SALES_PROCESSED_CACHE: - cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {}) - elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key - cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {}) - - # 取得實際欄位名稱(如果快取中沒有,使用預設名稱) - # V-Fix (2026-01-23): 使用 or 確保不會得到 None 值 - col_name = cols_map.get('name') or '商品名稱' - col_brand = cols_map.get('brand') or '品牌' - col_vendor = cols_map.get('vendor') or '廠商名稱' - col_category = cols_map.get('category') or '商品館' - col_amount = cols_map.get('amount') or '總業績' - col_qty = cols_map.get('qty') or '數量' - col_cost = cols_map.get('cost') or '總成本' - col_profit = cols_map.get('profit') # 可以為 None - col_activity = cols_map.get('activity') or '活動名稱' - col_payment = cols_map.get('payment') or '付款方式' - - # 建立日期篩選條件 - date_filter = "" - # V-New: 優先處理自訂日期區間 - if start_date or end_date: - # V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01) - start_date_slash = start_date.replace('-', '/') if start_date else '' - end_date_slash = end_date.replace('-', '/') if end_date else '' - - if start_date and end_date: - date_filter = f"""AND "日期" BETWEEN '{start_date_slash}' AND '{end_date_slash}'""" - elif start_date: - date_filter = f"""AND "日期" >= '{start_date_slash}'""" - else: # only end_date - date_filter = f"""AND "日期" <= '{end_date_slash}'""" - elif data_range_months > 0: - cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d') - date_filter = f"""AND "日期" >= '{cutoff_date}'""" - - # V-Fix: 補上其他所有篩選條件 (與 get_sales_table_data 一致) - category_filter = request.args.get('category', 'all') - brand_filter = request.args.get('brand', 'all') - vendor_filter = request.args.get('vendor', 'all') - activity_filter = request.args.get('activity', 'all') - payment_filter = request.args.get('payment', 'all') - month_filter = request.args.get('month', 'all') - dow_filter = request.args.get('dow', 'all') - hour_filter = request.args.get('hour', 'all') - min_price_str = request.args.get('min_price', '') - max_price_str = request.args.get('max_price', '') - min_margin_str = request.args.get('min_margin', '') - max_margin_str = request.args.get('max_margin', '') - keyword = request.args.get('keyword', '').strip() - - additional_filters = [] - - if category_filter and category_filter != 'all': - additional_filters.append(f""""{col_category}" = '{category_filter}'""") - if brand_filter and brand_filter != 'all': - additional_filters.append(f""""{col_brand}" = '{brand_filter}'""") - if vendor_filter and vendor_filter != 'all': - additional_filters.append(f""""{col_vendor}" = '{vendor_filter}'""") - if activity_filter and activity_filter != 'all': - additional_filters.append(f""""{col_activity}" = '{activity_filter}'""") - if payment_filter and payment_filter != 'all': - additional_filters.append(f""""{col_payment}" = '{payment_filter}'""") - - # 時間維度 - if month_filter and month_filter != 'all': - month_filter_slash = month_filter.replace('-', '/') - # 使用「日期」欄位 (這似乎是系統內部固定欄位,不需 dynamic map,除非資料表結構也變了) - # 假設 "日期" 是固定欄位 - additional_filters.append(f"""("日期" LIKE '{month_filter}%' OR "日期" LIKE '{month_filter_slash}%')""") - - if dow_filter and dow_filter != 'all': - # V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (top_detail API) - pandas_dow = int(dow_filter) - if DATABASE_TYPE == 'postgresql': - pg_dow = (pandas_dow + 1) % 7 - additional_filters.append(f"""EXTRACT(DOW FROM TO_DATE(REPLACE("日期", '/', '-'), 'YYYY-MM-DD')) = {pg_dow}""") - else: - sqlite_dow = str((pandas_dow + 1) % 7) - additional_filters.append(f"""strftime('%w', replace("日期", '/', '-')) = '{sqlite_dow}'""") - - if hour_filter and hour_filter != 'all': - # V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (top_detail API) - hour_val = int(hour_filter) - if DATABASE_TYPE == 'postgresql': - additional_filters.append(f"""CAST(SUBSTRING("時間" FROM 1 FOR 2) AS INTEGER) = {hour_val}""") - else: - additional_filters.append(f"""CAST(substr("時間", 1, 2) AS INTEGER) = {hour_val}""") - - # 關鍵字 - if keyword: - keyword_escaped = keyword.replace("'", "''") - k_conds = [] - for col in [col_name, cols_map.get("pid", "商品ID"), col_brand, col_vendor]: - k_conds.append(f""""{col}" LIKE '%{keyword_escaped}%'""") - additional_filters.append(f"({' OR '.join(k_conds)})") - - if (min_price_str or max_price_str): - price_sql = f'CAST("{col_amount}" AS FLOAT) / NULLIF("{col_qty}", 0)' - if min_price_str: additional_filters.append(f"{price_sql} >= {float(min_price_str)}") - if max_price_str: additional_filters.append(f"{price_sql} <= {float(max_price_str)}") - - if (min_margin_str or max_margin_str): - if col_profit: - profit_sql = f'"{col_profit}"' - else: - profit_sql = f'("{col_amount}" - "{col_cost}")' - - margin_sql = f'({profit_sql} * 100.0 / NULLIF("{col_amount}", 0))' - if min_margin_str: additional_filters.append(f"{margin_sql} >= {float(min_margin_str)}") - if max_margin_str: additional_filters.append(f"{margin_sql} <= {float(max_margin_str)}") - - if additional_filters: - date_filter += " AND " + " AND ".join(additional_filters) - - # V-New: 準備利潤計算 SQL 片段 (SELECT 子句使用 SUM 聚合) - if col_profit: - profit_select_sql = f'SUM(CAST("{col_profit}" AS REAL))' - else: - profit_select_sql = f'SUM(CAST("{col_amount}" AS REAL)) - SUM(CAST("{col_cost}" AS REAL))' - - # 根據檢視類型和指標建立 SQL 查詢 - if view_type == 'category': - # 分類排行 - if metric == 'qty': - sql_query = f""" - SELECT - "{col_category}" as name, - SUM(CAST("{col_qty}" AS REAL)) as value - FROM {table_name} - WHERE "{col_category}" IS NOT NULL {date_filter} - GROUP BY "{col_category}" - ORDER BY value DESC - LIMIT 50 - """ - elif metric == 'profit': - sql_query = f""" - SELECT - "{col_category}" as name, - {profit_select_sql} as value, - CASE - WHEN SUM(CAST("{col_amount}" AS REAL)) > 0 - THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100 - ELSE 0 - END as margin_rate - FROM {table_name} - WHERE "{col_category}" IS NOT NULL {date_filter} - GROUP BY "{col_category}" - ORDER BY value DESC - LIMIT 50 - """ - else: # amount - sql_query = f""" - SELECT - "{col_category}" as name, - SUM(CAST("{col_amount}" AS REAL)) as value - FROM {table_name} - WHERE "{col_category}" IS NOT NULL {date_filter} - GROUP BY "{col_category}" - ORDER BY value DESC - LIMIT 50 - """ - else: - # 商品排行(包含商品ID) - pid_col_sql = f'"{cols_map.get("pid", "商品ID")}"' # 商品ID 欄位 - if metric == 'qty': - sql_query = f""" - SELECT - {pid_col_sql} as product_id, - "{col_name}" as name, - "{col_brand}" as brand, - "{col_vendor}" as vendor, - "{col_category}" as category, - SUM(CAST("{col_qty}" AS REAL)) as value - FROM {table_name} - WHERE "{col_name}" IS NOT NULL {date_filter} - GROUP BY {pid_col_sql}, "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" - ORDER BY value DESC - LIMIT 100 - """ - elif metric == 'profit': - sql_query = f""" - SELECT - {pid_col_sql} as product_id, - "{col_name}" as name, - "{col_brand}" as brand, - "{col_vendor}" as vendor, - "{col_category}" as category, - {profit_select_sql} as value, - CASE - WHEN SUM(CAST("{col_amount}" AS REAL)) > 0 - THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100 - ELSE 0 - END as margin_rate - FROM {table_name} - WHERE "{col_name}" IS NOT NULL {date_filter} - GROUP BY {pid_col_sql}, "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" - ORDER BY value DESC - LIMIT 100 - """ - else: # amount - sql_query = f""" - SELECT - {pid_col_sql} as product_id, - "{col_name}" as name, - "{col_brand}" as brand, - "{col_vendor}" as vendor, - "{col_category}" as category, - SUM(CAST("{col_amount}" AS REAL)) as value - FROM {table_name} - WHERE "{col_name}" IS NOT NULL {date_filter} - GROUP BY {pid_col_sql}, "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" - ORDER BY value DESC - LIMIT 100 - """ - - # 執行查詢 - df = pd.read_sql(sql_query, db.engine) - sys_log.info(f"[API] Top Detail: {top_type}/{view_type} 返回 {len(df)} 筆資料") - - if df.empty: - return jsonify({'items': []}) - - # V-Fix (2026-01-23): 確保數值欄位無 NaN/Infinity,避免 JSON 序列化失敗 - numeric_cols = ['value', 'margin_rate'] - for col in numeric_cols: - if col in df.columns: - df[col] = df[col].replace([np.inf, -np.inf], 0).fillna(0) - - # V-Fix (2026-01-23): 確保字串欄位無 None - string_cols = ['product_id', 'name', 'brand', 'vendor', 'category'] - for col in string_cols: - if col in df.columns: - df[col] = df[col].fillna('').astype(str) - - # 轉換為 JSON - items = df.to_dict('records') - return jsonify({'items': items}) - - except Exception as e: - sys_log.error(f"[API] Top Detail Error: {e}") - import traceback + frame, meta = _query_sales_top_detail_frame(DatabaseManager().engine, request.args) + sys_log.info(f"[API] Top Detail: {meta['type']}/{meta['view']} 返回 {len(frame)} 筆資料") + return jsonify({'items': frame.to_dict('records')}) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + sys_log.error(f"[API] Top Detail Error: {exc}") traceback.print_exc() - return jsonify({'error': str(e)}), 500 + return jsonify({'error': '排行明細暫時無法載入'}), 500 @sales_bp.route('/api/sales_analysis/export_top_detail') @login_required def api_export_top_detail(): - """API: 匯出 Top N 詳細列表為 Excel""" + """Export the same canonical Top N result shown in the modal.""" try: - from datetime import datetime, timedelta, timezone - import io - TAIPEI_TZ = timezone(timedelta(hours=8)) + frame, meta = _query_sales_top_detail_frame(DatabaseManager().engine, request.args) + if frame.empty: + return '無資料可匯出', 400 - table_name = 'realtime_sales_monthly' - data_range_months = int(request.args.get('data_range', '1') or '1') - start_date = request.args.get('start_date', '') # V-New: 自訂開始日期 - end_date = request.args.get('end_date', '') # V-New: 自訂結束日期 - top_type = request.args.get('type', 'revenue') - metric = request.args.get('metric', 'amount') - view_type = request.args.get('view', 'product') - - db = DatabaseManager() - - # V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱 - if start_date or end_date: - cache_key = f"{table_name}_custom_{start_date}_{end_date}" + metric_labels = {'amount': '銷售金額', 'qty': '銷售數量', 'profit': '毛利金額'} + export_frame = sanitize_excel_dataframe(frame).rename(columns={ + 'product_id': '商品ID', + 'name': '分類名稱' if meta['view'] == 'category' else '商品名稱', + 'brand': '品牌', + 'vendor': '廠商名稱', + 'category': '分類', + 'value': metric_labels[meta['metric']], + 'margin_rate': '毛利率', + }) + if meta['view'] == 'category': + export_frame = export_frame[['分類名稱', metric_labels[meta['metric']], '毛利率']] else: - cache_key = f"{table_name}_{data_range_months}m" + export_frame = export_frame[ + ['商品ID', '商品名稱', '品牌', '廠商名稱', '分類', metric_labels[meta['metric']], '毛利率'] + ] - # 嘗試從快取讀取欄位名稱 - cols_map = {} - if cache_key in _SALES_PROCESSED_CACHE: - cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {}) - elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key - cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {}) - - # 取得實際欄位名稱(如果快取中沒有,使用預設名稱) - # V-Fix (2026-01-23): 使用 or 確保不會得到 None 值 - col_name = cols_map.get('name') or '商品名稱' - col_brand = cols_map.get('brand') or '品牌' - col_vendor = cols_map.get('vendor') or '廠商名稱' - col_category = cols_map.get('category') or '商品館' - col_amount = cols_map.get('amount') or '總業績' - col_qty = cols_map.get('qty') or '數量' - col_cost = cols_map.get('cost') or '總成本' - col_profit = cols_map.get('profit') # 可以為 None - col_activity = cols_map.get('activity') or '活動名稱' - col_payment = cols_map.get('payment') or '付款方式' - - # 建立日期篩選條件 - date_filter = "" - # V-New: 優先處理自訂日期區間 - if start_date or end_date: - # V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01) - start_date_slash = start_date.replace('-', '/') if start_date else '' - end_date_slash = end_date.replace('-', '/') if end_date else '' - - if start_date and end_date: - date_filter = f"""AND "日期" BETWEEN '{start_date_slash}' AND '{end_date_slash}'""" - elif start_date: - date_filter = f"""AND "日期" >= '{start_date_slash}'""" - else: # only end_date - date_filter = f"""AND "日期" <= '{end_date_slash}'""" - elif data_range_months > 0: - cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d') - date_filter = f"""AND "日期" >= '{cutoff_date}'""" - - # V-Fix: 補上其他所有篩選條件 (與 get_top_detail 一致) - category_filter = request.args.get('category', 'all') - brand_filter = request.args.get('brand', 'all') - vendor_filter = request.args.get('vendor', 'all') - activity_filter = request.args.get('activity', 'all') - payment_filter = request.args.get('payment', 'all') - month_filter = request.args.get('month', 'all') - dow_filter = request.args.get('dow', 'all') - hour_filter = request.args.get('hour', 'all') - min_price_str = request.args.get('min_price', '') - max_price_str = request.args.get('max_price', '') - min_margin_str = request.args.get('min_margin', '') - max_margin_str = request.args.get('max_margin', '') - keyword = request.args.get('keyword', '').strip() - - additional_filters = [] - - if category_filter and category_filter != 'all': - additional_filters.append(f""""{col_category}" = '{category_filter}'""") - if brand_filter and brand_filter != 'all': - additional_filters.append(f""""{col_brand}" = '{brand_filter}'""") - if vendor_filter and vendor_filter != 'all': - additional_filters.append(f""""{col_vendor}" = '{vendor_filter}'""") - if activity_filter and activity_filter != 'all': - additional_filters.append(f""""{col_activity}" = '{activity_filter}'""") - if payment_filter and payment_filter != 'all': - additional_filters.append(f""""{col_payment}" = '{payment_filter}'""") - - if month_filter and month_filter != 'all': - month_filter_slash = month_filter.replace('-', '/') - additional_filters.append(f"""("日期" LIKE '{month_filter}%' OR "日期" LIKE '{month_filter_slash}%')""") - - if dow_filter and dow_filter != 'all': - # V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (export API) - pandas_dow = int(dow_filter) - if DATABASE_TYPE == 'postgresql': - pg_dow = (pandas_dow + 1) % 7 - additional_filters.append(f"""EXTRACT(DOW FROM TO_DATE(REPLACE("日期", '/', '-'), 'YYYY-MM-DD')) = {pg_dow}""") - else: - sqlite_dow = str((pandas_dow + 1) % 7) - additional_filters.append(f"""strftime('%w', replace("日期", '/', '-')) = '{sqlite_dow}'""") - - if hour_filter and hour_filter != 'all': - # V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (export API) - hour_val = int(hour_filter) - if DATABASE_TYPE == 'postgresql': - additional_filters.append(f"""CAST(SUBSTRING("時間" FROM 1 FOR 2) AS INTEGER) = {hour_val}""") - else: - additional_filters.append(f"""CAST(substr("時間", 1, 2) AS INTEGER) = {hour_val}""") - - if keyword: - keyword_escaped = keyword.replace("'", "''") - k_conds = [] - for col in [col_name, cols_map.get("pid", "商品ID"), col_brand, col_vendor]: - k_conds.append(f""""{col}" LIKE '%{keyword_escaped}%'""") - additional_filters.append(f"({' OR '.join(k_conds)})") - - if (min_price_str or max_price_str): - price_sql = f'CAST("{col_amount}" AS FLOAT) / NULLIF("{col_qty}", 0)' - if min_price_str: additional_filters.append(f"{price_sql} >= {float(min_price_str)}") - if max_price_str: additional_filters.append(f"{price_sql} <= {float(max_price_str)}") - - if (min_margin_str or max_margin_str): - if col_profit: - profit_sql = f'"{col_profit}"' - else: - profit_sql = f'("{col_amount}" - "{col_cost}")' - - margin_sql = f'({profit_sql} * 100.0 / NULLIF("{col_amount}", 0))' - if min_margin_str: additional_filters.append(f"{margin_sql} >= {float(min_margin_str)}") - if max_margin_str: additional_filters.append(f"{margin_sql} <= {float(max_margin_str)}") - - if additional_filters: - date_filter += " AND " + " AND ".join(additional_filters) - - # V-New: 準備利潤計算 SQL 片段 (SELECT 子句使用 SUM 聚合) - if col_profit: - profit_select_sql = f'SUM(CAST("{col_profit}" AS REAL))' - else: - profit_select_sql = f'SUM(CAST("{col_amount}" AS REAL)) - SUM(CAST("{col_cost}" AS REAL))' - - # 根據檢視類型和指標建立 SQL 查詢(與上面相同) - if view_type == 'category': - if metric == 'qty': - sql_query = f""" - SELECT - "{col_category}" as 分類名稱, - SUM(CAST("{col_qty}" AS REAL)) as 銷售數量 - FROM {table_name} - WHERE "{col_category}" IS NOT NULL {date_filter} - GROUP BY "{col_category}" - ORDER BY 銷售數量 DESC - LIMIT 50 - """ - elif metric == 'profit': - sql_query = f""" - SELECT - "{col_category}" as 分類名稱, - {profit_select_sql} as 毛利金額, - CASE - WHEN SUM(CAST("{col_amount}" AS REAL)) > 0 - THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100 - ELSE 0 - END as 毛利率 - FROM {table_name} - WHERE "{col_category}" IS NOT NULL {date_filter} - GROUP BY "{col_category}" - ORDER BY 毛利金額 DESC - LIMIT 50 - """ - else: # amount - sql_query = f""" - SELECT - "{col_category}" as 分類名稱, - SUM(CAST("{col_amount}" AS REAL)) as 銷售金額 - FROM {table_name} - WHERE "{col_category}" IS NOT NULL {date_filter} - GROUP BY "{col_category}" - ORDER BY 銷售金額 DESC - LIMIT 50 - """ - else: - # 商品排行(包含商品ID) - if metric == 'qty': - sql_query = f""" - SELECT - "{cols_map.get("pid", "商品ID")}" as 商品ID, - "{col_name}" as 商品名稱, - "{col_brand}" as 品牌, - "{col_vendor}" as 廠商名稱, - "{col_category}" as 分類名稱, - SUM(CAST("{col_qty}" AS REAL)) as 銷售數量 - FROM {table_name} - WHERE "{col_name}" IS NOT NULL {date_filter} - GROUP BY "{cols_map.get("pid", "商品ID")}", "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" - ORDER BY 銷售數量 DESC - LIMIT 100 - """ - elif metric == 'profit': - sql_query = f""" - SELECT - "{cols_map.get("pid", "商品ID")}" as 商品ID, - "{col_name}" as 商品名稱, - "{col_brand}" as 品牌, - "{col_vendor}" as 廠商名稱, - "{col_category}" as 分類名稱, - {profit_select_sql} as 毛利金額, - CASE - WHEN SUM(CAST("{col_amount}" AS REAL)) > 0 - THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100 - ELSE 0 - END as 毛利率 - FROM {table_name} - WHERE "{col_name}" IS NOT NULL {date_filter} - GROUP BY "{cols_map.get("pid", "商品ID")}", "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" - ORDER BY 毛利金額 DESC - LIMIT 100 - """ - else: # amount - sql_query = f""" - SELECT - "{cols_map.get("pid", "商品ID")}" as 商品ID, - "{col_name}" as 商品名稱, - "{col_brand}" as 品牌, - "{col_vendor}" as 廠商名稱, - "{col_category}" as 分類名稱, - SUM(CAST("{col_amount}" AS REAL)) as 銷售金額 - FROM {table_name} - WHERE "{col_name}" IS NOT NULL {date_filter} - GROUP BY "{cols_map.get("pid", "商品ID")}", "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" - ORDER BY 銷售金額 DESC - LIMIT 100 - """ - - # 執行查詢並匯出 - df = pd.read_sql(sql_query, db.engine) - - if df.empty: - return "無資料可匯出", 400 - - # 生成 Excel output = io.BytesIO() with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Top排行') + export_frame.to_excel(writer, index=False, sheet_name='Top排行') output.seek(0) - - # 生成檔案名稱 - type_names = {'revenue': '業績貢獻王', 'margin': '獲利金雞母', 'quantity': '人氣引流款'} + type_names = {'revenue': '業績貢獻王', 'margin': '獲利守價', 'quantity': '人氣引流款'} view_names = {'product': '商品排行', 'category': '分類排行'} - filename = f"{type_names.get(top_type, '排行')}_{view_names.get(view_type, '')}_{datetime.now(TAIPEI_TZ).strftime('%Y%m%d_%H%M')}.xlsx" - - sys_log.info(f"[Export] Top Detail: {filename} ({len(df)} 筆)") - + filename = ( + f"{type_names[meta['type']]}_{view_names[meta['view']]}_" + f"{datetime.now(TAIPEI_TZ):%Y%m%d_%H%M}.xlsx" + ) + sys_log.info(f"[Export] Top Detail: {filename} ({len(export_frame)} 筆)") return send_file( output, as_attachment=True, download_name=filename, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ) + except ValueError as exc: + return f'匯出失敗: {exc}', 400 + except Exception as exc: + sys_log.error(f"[Export] Top Detail Error: {exc}") + return '匯出暫時無法完成', 500 - except Exception as e: - sys_log.error(f"[Export] Top Detail Error: {e}") - return f"匯出失敗: {e}", 500 + +@sales_bp.route('/api/sales_analysis/export_vendor') +@login_required +def api_export_sales_vendor(): + """Export the period-linked vendor table shown on the analysis page.""" + try: + frame = query_sales_vendor_export_frame(DatabaseManager().engine, request.args) + if frame.empty: + return jsonify({'error': '所選範圍沒有廠商資料'}), 400 + + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + frame.to_excel(writer, index=False, sheet_name='廠商分析') + output.seek(0) + filename = f"廠商分析_{datetime.now(TAIPEI_TZ):%Y%m%d_%H%M}.xlsx" + return send_file( + output, + as_attachment=True, + download_name=filename, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + sys_log.error(f"[Export] Vendor Analysis Error: {exc}") + return jsonify({'error': '廠商分析暫時無法匯出'}), 500 + + +@sales_bp.route('/api/sales_analysis/export_marketing') +@login_required +def api_export_sales_marketing(): + """Export marketing aggregates using the page's canonical filter contract.""" + try: + activity_type = str(request.args.get('type', 'all') or 'all').strip().lower() + frames = query_sales_marketing_export_frames( + DatabaseManager().engine, + request.args, + activity_type=activity_type, + ) + if not frames: + return jsonify({'error': '所選範圍沒有行銷活動資料'}), 400 + + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + for sheet_name, frame in frames.items(): + frame.to_excel(writer, index=False, sheet_name=sheet_name[:31]) + if len(frames) > 1: + metric = str(request.args.get('metric', 'amount') or 'amount').strip().lower() + combined = combine_sales_marketing_export_frames(frames, metric) + combined.to_excel(writer, index=False, sheet_name='合併總表') + output.seek(0) + filename = f"行銷活動分析_{datetime.now(TAIPEI_TZ):%Y%m%d_%H%M}.xlsx" + return send_file( + output, + as_attachment=True, + download_name=filename, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + sys_log.error(f"[Export] Marketing Analysis Error: {exc}") + return jsonify({'error': '行銷活動暫時無法匯出'}), 500 @sales_bp.route('/api/sales_analysis/yoy_comparison') @@ -3320,22 +2857,17 @@ def api_yoy_comparison(): year1 = _parse_yoy_year(request.args.get('year1'), 2024) year2 = _parse_yoy_year(request.args.get('year2'), 2025) metric = str(request.args.get('metric', 'revenue')).strip().lower() - metric_options = { - 'revenue': ('COALESCE(SUM(CAST("總業績" AS REAL)), 0)', '銷售金額'), - 'qty': ('COALESCE(SUM(CAST("數量" AS REAL)), 0)', '銷售數量'), - 'profit': ( - 'COALESCE(SUM(CAST("總業績" AS REAL)), 0) ' - '- COALESCE(SUM(CAST("總成本" AS REAL)), 0)', - '毛利金額', - ), - } - if metric not in metric_options: - raise ValueError('metric 僅允許 revenue、qty、profit') - - start_value = request.args.get('start_date') - end_value = request.args.get('end_date') - month_value = request.args.get('month') - data_range_value = request.args.get('data_range') + engine = DatabaseManager().engine + filters, columns = prepare_sales_query_context( + engine, + 'realtime_sales_monthly', + request.args, + default_data_range=0, + ) + start_value = filters['start_date'] + end_value = filters['end_date'] + month_value = filters['month'] + data_range_value = filters['data_range'] year1_start, year1_end = _project_yoy_period( year1, start_value, end_value, month_value, data_range_value ) @@ -3343,10 +2875,17 @@ def api_yoy_comparison(): year2, start_value, end_value, month_value, data_range_value ) - aggregate_sql, metric_label = metric_options[metric] - engine = DatabaseManager().engine - year1_values = _query_yoy_month_totals(engine, aggregate_sql, year1_start, year1_end) - year2_values = _query_yoy_month_totals(engine, aggregate_sql, year2_start, year2_end) + aggregate_sql, metric_label = build_sales_metric_aggregate_sql( + engine, + columns, + metric, + ) + year1_values = _query_yoy_month_totals( + engine, aggregate_sql, year1_start, year1_end, columns, filters + ) + year2_values = _query_yoy_month_totals( + engine, aggregate_sql, year2_start, year2_end, columns, filters + ) year1_keys = _period_month_keys(year1_start, year1_end) year2_keys = _period_month_keys(year2_start, year2_end) @@ -3390,8 +2929,10 @@ def api_yoy_comparison(): 'linked': bool( start_value or end_value - or (month_value and month_value != 'all') - or (data_range_value and data_range_value != '0') + or month_value + or data_range_value + or filters['dow'] is not None + or filters['hour'] is not None ), 'year1': { 'start_date': year1_start.isoformat(), @@ -3410,4 +2951,4 @@ def api_yoy_comparison(): except Exception as e: sys_log.error(f"[YoY] Error: {e}") traceback.print_exc() - return jsonify({'error': str(e)}), 500 + return jsonify({'error': '跨年比較暫時無法載入'}), 500 diff --git a/services/sales_analysis_export_service.py b/services/sales_analysis_export_service.py new file mode 100644 index 0000000..078b38d --- /dev/null +++ b/services/sales_analysis_export_service.py @@ -0,0 +1,207 @@ +"""Period-linked, Excel-safe export queries for the sales analysis page.""" + +from __future__ import annotations + +import re +from typing import Any, Mapping + +import pandas as pd +from sqlalchemy import text + +from services.sales_analysis_query_service import ( + build_sales_metric_aggregate_sql, + build_sales_where_clause, + prepare_sales_query_context, + quote_identifier, +) +from utils.security import validate_table_name + + +_EXCEL_ILLEGAL_CHAR_RE = re.compile(r"[\x00-\x08\x0B-\x0C\x0E-\x1F]") +_FORMULA_PREFIXES = ("=", "+", "-", "@") +_MARKETING_COLUMNS = { + "coupon": ("coupon_activity", "折價券活動"), + "discount": ("discount_activity", "折扣活動"), + "bonus": ("bonus_activity", "滿額再折扣"), + "click": ("click_activity", "點我再折扣"), +} + + +def _sanitize_excel_cell(value: Any) -> Any: + if not isinstance(value, str): + return value + cleaned = _EXCEL_ILLEGAL_CHAR_RE.sub("", value) + if cleaned.lstrip().startswith(_FORMULA_PREFIXES): + return f"'{cleaned}" + return cleaned + + +def sanitize_excel_dataframe(frame: pd.DataFrame) -> pd.DataFrame: + """Prevent control-character failures and spreadsheet formula injection.""" + cleaned = frame.copy() + for column in cleaned.columns: + dtype = cleaned[column].dtype + if pd.api.types.is_object_dtype(dtype) or pd.api.types.is_string_dtype(dtype): + cleaned[column] = cleaned[column].map(_sanitize_excel_cell) + return cleaned + + +def query_sales_vendor_export_frame( + engine, + args: Mapping[str, Any], + *, + table_name: str = "realtime_sales_monthly", +) -> pd.DataFrame: + """Return the same filtered vendor ranking represented on the page.""" + table_name = validate_table_name(table_name) + filters, columns = prepare_sales_query_context( + engine, + table_name, + args, + default_data_range=1, + ) + vendor = quote_identifier(engine, columns.get("vendor")) + amount_sql, _ = build_sales_metric_aggregate_sql(engine, columns, "amount") + try: + profit_sql, _ = build_sales_metric_aggregate_sql(engine, columns, "profit") + except ValueError: + profit_sql = "0" + qty_sql = ( + build_sales_metric_aggregate_sql(engine, columns, "qty")[0] + if columns.get("qty") + else "0" + ) + where_sql, params = build_sales_where_clause(engine, columns, filters) + where_suffix = f" AND {where_sql}" if where_sql else "" + table = quote_identifier(engine, table_name) + query = text(f""" + SELECT {vendor} AS vendor, + {amount_sql} AS amount, + {qty_sql} AS qty, + {profit_sql} AS profit, + CASE WHEN {amount_sql} > 0 + THEN ({profit_sql}) * 100.0 / ({amount_sql}) ELSE 0 END AS margin_rate + FROM {table} + WHERE {vendor} IS NOT NULL + AND TRIM(CAST({vendor} AS TEXT)) <> '' {where_suffix} + GROUP BY {vendor} + ORDER BY amount DESC + """) + frame = pd.read_sql(query, engine, params=params) + if frame.empty: + return frame + frame = frame.rename(columns={ + "vendor": "廠商", + "amount": "銷售金額", + "qty": "銷售數量", + "profit": "毛利金額", + "margin_rate": "毛利率(%)", + }) + return sanitize_excel_dataframe(frame) + + +def query_sales_marketing_export_frames( + engine, + args: Mapping[str, Any], + *, + activity_type: str = "all", + table_name: str = "realtime_sales_monthly", +) -> dict[str, pd.DataFrame]: + """Aggregate every requested marketing dimension with the active page filters.""" + if activity_type != "all" and activity_type not in _MARKETING_COLUMNS: + raise ValueError("type 僅允許 all、coupon、discount、bonus、click") + table_name = validate_table_name(table_name) + filters, columns = prepare_sales_query_context( + engine, + table_name, + args, + default_data_range=1, + ) + metric = str(args.get("metric", "amount") or "amount").strip().lower() + if metric not in {"amount", "qty", "profit"}: + raise ValueError("metric 僅允許 amount、qty、profit") + + amount_sql, _ = build_sales_metric_aggregate_sql(engine, columns, "amount") + qty_sql = ( + build_sales_metric_aggregate_sql(engine, columns, "qty")[0] + if columns.get("qty") + else "0" + ) + try: + profit_sql, _ = build_sales_metric_aggregate_sql(engine, columns, "profit") + except ValueError: + profit_sql = "0" + if metric == "qty" and qty_sql == "0": + raise ValueError("目前資料來源沒有銷量欄位") + if metric == "profit" and profit_sql == "0": + raise ValueError("目前資料來源沒有毛利或成本欄位") + + metric_alias = {"amount": "revenue", "qty": "qty", "profit": "profit"}[metric] + where_sql, params = build_sales_where_clause(engine, columns, filters) + where_suffix = f" AND {where_sql}" if where_sql else "" + table = quote_identifier(engine, table_name) + selected_types = ( + list(_MARKETING_COLUMNS) + if activity_type == "all" + else [activity_type] + ) + branches = [] + for key in selected_types: + column_key, sheet_name = _MARKETING_COLUMNS[key] + activity_column = columns.get(column_key) + if not activity_column: + continue + activity = quote_identifier(engine, activity_column) + type_param = f"activity_type_{key}" + params[type_param] = sheet_name + branches.append(f""" + SELECT :{type_param} AS activity_type, + {activity} AS activity_name, + {amount_sql} AS revenue, + {qty_sql} AS qty, + {profit_sql} AS profit, + COUNT(*) AS item_count + FROM {table} + WHERE {activity} IS NOT NULL + AND TRIM(CAST({activity} AS TEXT)) NOT IN ('', '0') {where_suffix} + GROUP BY {activity} + """) + if not branches: + return {} + + combined = pd.read_sql(text(" UNION ALL ".join(branches)), engine, params=params) + frames: dict[str, pd.DataFrame] = {} + for key in selected_types: + _column_key, sheet_name = _MARKETING_COLUMNS[key] + frame = combined[combined["activity_type"] == sheet_name].copy() + if frame.empty: + continue + frame = frame.sort_values(metric_alias, ascending=False) + frame = frame.rename(columns={ + "activity_type": "活動類型", + "activity_name": "活動名稱", + "revenue": "銷售金額", + "qty": "銷售數量", + "profit": "毛利金額", + "item_count": "項目筆數", + }) + frames[sheet_name] = sanitize_excel_dataframe(frame) + return frames + + +def combine_sales_marketing_export_frames( + frames: Mapping[str, pd.DataFrame], + metric: str, +) -> pd.DataFrame: + """Build the combined sheet in the same order as the active page metric.""" + if metric not in {"amount", "qty", "profit"}: + raise ValueError("metric 僅允許 amount、qty、profit") + if not frames: + return pd.DataFrame() + combined = pd.concat(frames.values(), ignore_index=True) + sort_column = { + "amount": "銷售金額", + "qty": "銷售數量", + "profit": "毛利金額", + }[metric] + return combined.sort_values(sort_column, ascending=False) diff --git a/services/sales_analysis_query_service.py b/services/sales_analysis_query_service.py new file mode 100644 index 0000000..b42aab1 --- /dev/null +++ b/services/sales_analysis_query_service.py @@ -0,0 +1,391 @@ +"""Canonical, parameterized query contract for sales-analysis APIs.""" + +from __future__ import annotations + +import math +from datetime import date, datetime, timedelta, timezone +from typing import Any, Mapping + +from sqlalchemy import inspect, text + +from services.analysis_period_service import parse_iso_date, parse_month +from utils.df_helpers import find_col +from utils.security import validate_table_name + + +ALLOWED_DATA_RANGES = {0, 1, 3, 6, 12} +TAIPEI_TZ = timezone(timedelta(hours=8)) +SALES_TIME_TEXT_PATTERN = ( + r"^([01][0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9](\.[0-9]+)?)?$" +) + + +def _get_value(args: Mapping[str, Any], key: str, default: Any = "") -> Any: + getter = getattr(args, "get", None) + return getter(key, default) if callable(getter) else default + + +def _bounded_text(value: Any, field: str, *, max_length: int = 500) -> str: + result = str(value or "").strip() + if len(result) > max_length: + raise ValueError(f"{field} 長度不可超過 {max_length} 字元") + return result + + +def _optional_number(args: Mapping[str, Any], key: str) -> float | None: + raw = str(_get_value(args, key, "") or "").strip() + if not raw: + return None + try: + result = float(raw) + except (TypeError, ValueError) as exc: + raise ValueError(f"{key} 必須是數字") from exc + if not math.isfinite(result): + raise ValueError(f"{key} 必須是有限數字") + return result + + +def _optional_dimension(args: Mapping[str, Any], key: str) -> str | None: + raw = _bounded_text(_get_value(args, key, "all"), key) + return None if not raw or raw.lower() == "all" else raw + + +def _optional_bounded_int( + args: Mapping[str, Any], + key: str, + *, + minimum: int, + maximum: int, +) -> int | None: + raw = str(_get_value(args, key, "all") or "all").strip().lower() + if raw == "all": + return None + if not raw.isdigit() or not minimum <= int(raw) <= maximum: + raise ValueError(f"{key} 必須介於 {minimum} 到 {maximum}") + return int(raw) + + +def normalize_sales_query_args( + args: Mapping[str, Any], + *, + default_data_range: int = 1, +) -> dict[str, Any]: + """Validate and canonicalize all filter values shared by page APIs.""" + range_raw = str(_get_value(args, "data_range", "") or "").strip() + if not range_raw: + data_range = default_data_range + elif not range_raw.isdigit() or int(range_raw) not in ALLOWED_DATA_RANGES: + raise ValueError("data_range 必須是 0、1、3、6 或 12") + else: + data_range = int(range_raw) + + raw_start = str(_get_value(args, "start_date", "") or "").strip() + raw_end = str(_get_value(args, "end_date", "") or "").strip() + start = parse_iso_date(raw_start) + end = parse_iso_date(raw_end) + if raw_start and not start: + raise ValueError("start_date 必須是 YYYY-MM-DD") + if raw_end and not end: + raise ValueError("end_date 必須是 YYYY-MM-DD") + if start and end and start > end: + start, end = end, start + + month_raw = str(_get_value(args, "month", "all") or "all").strip().lower() + month = None + if month_raw != "all": + parsed_month = parse_month(month_raw) + if not parsed_month: + raise ValueError("month 必須是 YYYY-MM") + month = parsed_month.strftime("%Y-%m") + + return { + "data_range": data_range, + "start_date": start, + "end_date": end, + "category": _optional_dimension(args, "category"), + "brand": _optional_dimension(args, "brand"), + "vendor": _optional_dimension(args, "vendor"), + "activity": _optional_dimension(args, "activity"), + "payment": _optional_dimension(args, "payment"), + "month": month, + "dow": _optional_bounded_int(args, "dow", minimum=0, maximum=6), + "hour": _optional_bounded_int(args, "hour", minimum=0, maximum=23), + "min_price": _optional_number(args, "min_price"), + "max_price": _optional_number(args, "max_price"), + "min_margin": _optional_number(args, "min_margin"), + "max_margin": _optional_number(args, "max_margin"), + "keyword": _bounded_text(_get_value(args, "keyword", ""), "keyword", max_length=200), + } + + +def resolve_sales_query_columns(engine, table_name: str) -> dict[str, str | None]: + """Resolve identifiers from the live table schema rather than request input.""" + validate_table_name(table_name) + names = [column["name"] for column in inspect(engine).get_columns(table_name)] + return { + "date": find_col(names, ["日期", "交易日期", "Date", "Day"]), + "time": find_col(names, ["訂單時間", "成立時間", "下單時間", "購買時間", "時間", "Time", "Created"]), + "pid": find_col(names, ["商品ID", "Product ID", "i_code", "Item Code", "ID"]), + "name": find_col(names, ["商品名稱", "品名", "Name", "Product"]), + "brand": find_col(names, ["品牌", "Brand"]), + "vendor": find_col(names, ["廠商名稱", "Vendor Name", "廠商", "供應商", "Vendor", "Supplier"]), + "category": find_col(names, ["商品館", "館別", "分類", "Category"]), + "activity": find_col(names, ["折扣活動名稱", "折價券活動名稱", "滿額再折扣活動名稱", "活動", "Activity", "Campaign", "Promotion", "專案"]), + "discount_activity": find_col(names, ["折扣活動名稱"]), + "coupon_activity": find_col(names, ["折價券活動名稱"]), + "bonus_activity": find_col(names, ["滿額再折扣活動名稱"]), + "click_activity": find_col(names, ["點我再折扣"]), + "payment": find_col(names, ["付款方式", "付款", "Payment", "Pay"]), + "amount": find_col(names, ["銷售金額", "總業績", "業績", "金額", "Amount", "Sales", "Total"]), + "qty": find_col(names, ["銷售數量", "銷量", "數量", "Qty", "Quantity"]), + "cost": find_col(names, ["總成本", "成本", "Cost", "進價", "Cost Price", "Wholesale"]), + "profit": find_col(names, ["毛利", "Profit", "利潤"]), + "return_qty": find_col(names, ["退貨數量", "Return Qty", "退貨"]), + } + + +def quote_identifier(engine, identifier: str | None) -> str: + if not identifier: + raise ValueError("查詢所需欄位不存在") + return engine.dialect.identifier_preparer.quote(str(identifier)) + + +def build_sales_metric_aggregate_sql( + engine, + columns: Mapping[str, str | None], + metric: str, +) -> tuple[str, str]: + """Build one allowlisted aggregate shared by APIs and exports.""" + amount = quote_identifier(engine, columns.get("amount")) + if metric in {"amount", "revenue"}: + return f"COALESCE(SUM(CAST({amount} AS REAL)), 0)", "銷售金額" + if metric == "qty": + qty = quote_identifier(engine, columns.get("qty")) + return f"COALESCE(SUM(CAST({qty} AS REAL)), 0)", "銷售數量" + if metric == "profit": + if columns.get("profit"): + profit = quote_identifier(engine, columns.get("profit")) + return f"COALESCE(SUM(CAST({profit} AS REAL)), 0)", "毛利金額" + cost = quote_identifier(engine, columns.get("cost")) + return ( + f"COALESCE(SUM(CAST({amount} AS REAL)), 0) " + f"- COALESCE(SUM(CAST({cost} AS REAL)), 0)", + "毛利金額", + ) + raise ValueError("metric 僅允許 amount、revenue、qty、profit") + + +def _normalised_date_sql(engine, quoted_date: str) -> tuple[str, str]: + if engine.dialect.name == "postgresql": + date_sql = f"LEFT(REPLACE(CAST({quoted_date} AS TEXT), '/', '-'), 10)" + return date_sql, f"LEFT({date_sql}, 7)" + date_sql = f"substr(replace(CAST({quoted_date} AS TEXT), '/', '-'), 1, 10)" + return date_sql, f"substr({date_sql}, 1, 7)" + + +def resolve_sales_date_bounds( + engine, + table_name: str, + columns: Mapping[str, str | None] | None = None, +) -> tuple[date | None, date | None]: + """Return canonical source bounds for closing one-sided page ranges.""" + table_name = validate_table_name(table_name) + resolved_columns = columns or resolve_sales_query_columns(engine, table_name) + quoted_date = quote_identifier(engine, resolved_columns.get("date")) + date_sql, _month_sql = _normalised_date_sql(engine, quoted_date) + table = quote_identifier(engine, table_name) + query = text(f"SELECT MIN({date_sql}), MAX({date_sql}) FROM {table}") + with engine.connect() as connection: + row = connection.execute(query).fetchone() + if not row: + return None, None + return parse_iso_date(row[0]), parse_iso_date(row[1]) + + +def build_sales_where_clause( + engine, + columns: Mapping[str, str | None], + filters: Mapping[str, Any], + *, + period_start: date | str | None = None, + period_end: date | str | None = None, + apply_month: bool = True, + include_numeric: bool = True, + now: date | None = None, +) -> tuple[str, dict[str, Any]]: + """Return a SQL fragment and bind parameters for the canonical filters.""" + conditions: list[str] = [] + params: dict[str, Any] = {} + + explicit_period = period_start is not None or period_end is not None + start = parse_iso_date(period_start) if period_start is not None else filters.get("start_date") + end = parse_iso_date(period_end) if period_end is not None else filters.get("end_date") + if start and end and start > end: + start, end = end, start + if not explicit_period and not start and not end and int(filters.get("data_range") or 0) > 0: + end = now or datetime.now(TAIPEI_TZ).date() + start = end - timedelta(days=int(filters["data_range"]) * 30) + + needs_date = bool(start or end or (apply_month and filters.get("month")) or filters.get("dow") is not None) + date_sql = month_sql = None + if needs_date: + quoted_date = quote_identifier(engine, columns.get("date")) + date_sql, month_sql = _normalised_date_sql(engine, quoted_date) + if start: + conditions.append(f"{date_sql} >= :filter_start_date") + params["filter_start_date"] = start.isoformat() + if end: + conditions.append(f"{date_sql} <= :filter_end_date") + params["filter_end_date"] = end.isoformat() + if apply_month and filters.get("month"): + conditions.append(f"{month_sql} = :filter_month") + params["filter_month"] = filters["month"] + + dimension_columns = { + "category": "category", + "brand": "brand", + "vendor": "vendor", + "activity": "activity", + "payment": "payment", + } + for filter_name, column_name in dimension_columns.items(): + value = filters.get(filter_name) + if value is None: + continue + quoted = quote_identifier(engine, columns.get(column_name)) + if filter_name == "category" and value == "其他": + exclusions = filters.get("category_other_exclusions") + if exclusions is None: + raise ValueError("其他分類缺少排行範圍契約") + if not exclusions: + conditions.append("1 = 0") + continue + placeholders = [] + for index, excluded in enumerate(exclusions): + param_name = f"filter_category_other_{index}" + placeholders.append(f":{param_name}") + params[param_name] = excluded + conditions.append(f"{quoted} NOT IN ({', '.join(placeholders)})") + continue + conditions.append(f"{quoted} = :filter_{filter_name}") + params[f"filter_{filter_name}"] = value + + if filters.get("dow") is not None: + database_dow = (int(filters["dow"]) + 1) % 7 + if engine.dialect.name == "postgresql": + conditions.append( + f"EXTRACT(DOW FROM TO_DATE({date_sql}, 'YYYY-MM-DD')) = :filter_db_dow" + ) + params["filter_db_dow"] = database_dow + else: + conditions.append(f"strftime('%w', {date_sql}) = :filter_db_dow") + params["filter_db_dow"] = str(database_dow) + + if filters.get("hour") is not None: + quoted_time = quote_identifier(engine, columns.get("time")) + if engine.dialect.name == "postgresql": + time_text = f"TRIM(CAST({quoted_time} AS TEXT))" + conditions.append( + "CASE WHEN " + f"{time_text} ~ '{SALES_TIME_TEXT_PATTERN}' " + f"THEN CAST(SUBSTRING({time_text} FROM 1 FOR 2) AS INTEGER) END " + "= :filter_hour" + ) + else: + time_text = f"TRIM(CAST({quoted_time} AS TEXT))" + conditions.append( + f"CASE WHEN {time_text} GLOB '[0-2][0-9]:[0-5][0-9]*' " + "THEN CAST(strftime('%H', '2000-01-01 ' || " + f"{time_text}) AS INTEGER) END = :filter_hour" + ) + params["filter_hour"] = int(filters["hour"]) + + keyword = filters.get("keyword") + if keyword: + keyword_columns = [columns.get(key) for key in ("name", "pid", "brand", "vendor")] + keyword_conditions = [ + f"LOWER(CAST({quote_identifier(engine, column)} AS TEXT)) " + "LIKE LOWER(:filter_keyword) ESCAPE '\\'" + for column in keyword_columns + if column + ] + if not keyword_conditions: + raise ValueError("目前資料來源沒有可搜尋欄位") + conditions.append(f"({' OR '.join(keyword_conditions)})") + escaped_keyword = ( + keyword.replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + ) + params["filter_keyword"] = f"%{escaped_keyword}%" + + if include_numeric: + amount = columns.get("amount") + qty = columns.get("qty") + if filters.get("min_price") is not None or filters.get("max_price") is not None: + price_sql = ( + f"CAST({quote_identifier(engine, amount)} AS REAL) / " + f"NULLIF(CAST({quote_identifier(engine, qty)} AS REAL), 0)" + ) + for bound in ("min_price", "max_price"): + value = filters.get(bound) + if value is not None: + operator = ">=" if bound == "min_price" else "<=" + conditions.append(f"{price_sql} {operator} :filter_{bound}") + params[f"filter_{bound}"] = value + + if filters.get("min_margin") is not None or filters.get("max_margin") is not None: + quoted_amount = quote_identifier(engine, amount) + if columns.get("profit"): + profit_sql = f"CAST({quote_identifier(engine, columns['profit'])} AS REAL)" + elif columns.get("cost"): + profit_sql = ( + f"CAST({quoted_amount} AS REAL) - " + f"CAST({quote_identifier(engine, columns['cost'])} AS REAL)" + ) + else: + raise ValueError("目前資料來源沒有毛利或成本欄位") + margin_sql = f"({profit_sql} * 100.0 / NULLIF(CAST({quoted_amount} AS REAL), 0))" + for bound in ("min_margin", "max_margin"): + value = filters.get(bound) + if value is not None: + operator = ">=" if bound == "min_margin" else "<=" + conditions.append(f"{margin_sql} {operator} :filter_{bound}") + params[f"filter_{bound}"] = value + + return " AND ".join(conditions), params + + +def prepare_sales_query_context( + engine, + table_name: str, + args: Mapping[str, Any], + *, + default_data_range: int = 1, +) -> tuple[dict[str, Any], dict[str, str | None]]: + """Resolve canonical filters, live columns and the dynamic Other bucket.""" + table_name = validate_table_name(table_name) + filters = normalize_sales_query_args(args, default_data_range=default_data_range) + columns = resolve_sales_query_columns(engine, table_name) + if filters.get("category") != "其他": + return filters, columns + + ranking_filters = dict(filters) + ranking_filters["category"] = None + where_sql, params = build_sales_where_clause(engine, columns, ranking_filters) + category = quote_identifier(engine, columns.get("category")) + amount = quote_identifier(engine, columns.get("amount")) + table = quote_identifier(engine, table_name) + where_suffix = f" AND {where_sql}" if where_sql else "" + query = text(f""" + SELECT {category} AS category_name + FROM {table} + WHERE {category} IS NOT NULL {where_suffix} + GROUP BY {category} + ORDER BY SUM(CAST({amount} AS REAL)) DESC, CAST({category} AS TEXT) ASC + LIMIT 12 + """) + with engine.connect() as connection: + rows = connection.execute(query, params).fetchall() + filters["category_other_exclusions"] = [str(row[0]) for row in rows] + return filters, columns diff --git a/templates/sales_analysis.html b/templates/sales_analysis.html index d628856..0ab51e6 100644 --- a/templates/sales_analysis.html +++ b/templates/sales_analysis.html @@ -551,7 +551,7 @@ 廠商毛利與主推優先序
@@ -578,7 +578,8 @@