"""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)