Files
ewoooc/services/sales_analysis_export_service.py

208 lines
7.2 KiB
Python

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