fix(analytics): harden period-linked sales queries
This commit is contained in:
207
services/sales_analysis_export_service.py
Normal file
207
services/sales_analysis_export_service.py
Normal file
@@ -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)
|
||||
391
services/sales_analysis_query_service.py
Normal file
391
services/sales_analysis_query_service.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user