208 lines
7.2 KiB
Python
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)
|