fix(analytics): harden period-linked sales queries

This commit is contained in:
ogt
2026-07-22 17:59:10 +08:00
parent 9b677ae616
commit f5df7bb1eb
8 changed files with 1529 additions and 909 deletions

View File

@@ -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 分離 |

File diff suppressed because it is too large Load Diff

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

View 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

View File

@@ -551,7 +551,7 @@
<span><i class="fas fa-industry me-2" aria-hidden="true"></i>廠商毛利與主推優先序</span>
<div class="d-flex align-items-center gap-2">
{{ period_badge(analysis_scope) }}
<a href="/api/export/excel/vendor?{{ request.query_string.decode() }}" class="btn btn-sm btn-outline-success">
<a href="/api/sales_analysis/export_vendor?{{ request.query_string.decode() }}" class="btn btn-sm btn-outline-success">
<i class="fas fa-file-excel me-1" aria-hidden="true"></i>匯出清單
</a>
</div>
@@ -578,7 +578,8 @@
<tr>
<td class="text-center text-muted">{{ loop.index }}</td>
<td class="fw-bold">
<a href="javascript:setFilter('vendor', '{{ v.name }}')" class="sa-vendor-link" title="篩選此廠商商品">
<a href="#" class="sa-vendor-link" title="篩選此廠商商品"
data-sales-filter-key="vendor" data-sales-filter-value="{{ v.name }}">
{{ v.name }}
<i class="fas fa-filter ms-1 sa-vendor-link__icon" aria-hidden="true"></i>
</a>

View File

@@ -1,4 +1,4 @@
from datetime import date
from datetime import date, datetime as real_datetime
from pathlib import Path
import pandas as pd
@@ -297,6 +297,9 @@ def test_sales_frontend_uses_live_period_linked_api_routes():
assert "/api/sales_analysis/yoy_comparison" in script
assert "/api/sales_analysis/table_data" in script
assert "/api/sales_analysis/top_detail" in script
assert "/api/sales_analysis/export_vendor" in template
assert "/api/sales_analysis/export_marketing" in script
assert "/api/export/excel/marketing" not in script
assert "topDetailModal" in script
assert "window.open(url.toString()" not in script
assert "form.requestSubmit()" in script
@@ -304,6 +307,8 @@ def test_sales_frontend_uses_live_period_linked_api_routes():
assert "if (end) end.value = '';" in script
assert "d.growth_rate" in script
assert "d.monthly_breakdown" in script
assert "marketingMetric === 'qty' ? fmtNum : fmtMoney" in script
assert "order: []" in script
assert "columns," in script
assert 'data-field="product_id"' in template
assert 'data-field="amount"' in template
@@ -343,6 +348,30 @@ def test_yoy_period_accepts_the_year_month_value_sent_by_the_page_filter():
assert end == date(2025, 4, 30)
def test_yoy_period_intersects_month_with_the_selected_custom_range():
start, end = _project_yoy_period(
2025,
start_value="2026-03-15",
end_value="2026-05-10",
month_value="2026-04",
)
assert start == date(2025, 4, 1)
assert end == date(2025, 4, 30)
def test_yoy_period_intersects_month_with_a_cross_year_custom_range():
start, end = _project_yoy_period(
2025,
start_value="2025-11-15",
end_value="2026-02-10",
month_value="2025-12",
)
assert start == date(2025, 12, 1)
assert end == date(2025, 12, 31)
def test_yoy_api_uses_only_the_selected_period_for_totals_and_chart(monkeypatch):
engine = create_engine("sqlite:///:memory:")
with engine.begin() as conn:
@@ -477,3 +506,19 @@ def test_sales_page_builds_every_chart_from_the_same_custom_period(monkeypatch):
assert len(table_payload["data"]) == 1
assert table_payload["data"][0]["product_id"] == "P1"
assert table_payload["data"][0]["amount"] == 100
class FixedDateTime(real_datetime):
@classmethod
def now(cls, tz=None):
value = cls(2026, 7, 22, 12, 0, 0)
return value.replace(tzinfo=tz) if tz else value
monkeypatch.setattr(sales_routes, "datetime", FixedDateTime)
sales_routes._SALES_PROCESSED_CACHE.clear()
sales_routes._SALES_ANALYSIS_RESULT_CACHE.clear()
with app.test_request_context("/sales_analysis?metric=amount&data_range=6"):
rolling_context = sales_routes.sales_analysis.__wrapped__()
assert rolling_context["analysis_period"]["mode"] == "rolling_months"
assert rolling_context["analysis_period"]["label"] == "最近 6 個月"
assert rolling_context["total_records"] == 3

View File

@@ -0,0 +1,423 @@
from io import BytesIO
import re
from flask import Flask
from openpyxl import load_workbook
import pandas as pd
from sqlalchemy import create_engine, event, text
from sqlalchemy.dialects import postgresql
import routes.sales_routes as sales_routes
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 (
SALES_TIME_TEXT_PATTERN,
build_sales_where_clause,
normalize_sales_query_args,
prepare_sales_query_context,
resolve_sales_query_columns,
)
def _build_sales_engine():
engine = create_engine("sqlite:///:memory:")
with engine.begin() as conn:
conn.exec_driver_sql(
'''
CREATE TABLE realtime_sales_monthly (
"日期" TEXT,
"時間" TEXT,
"商品ID" TEXT,
"商品名稱" TEXT,
"商品館" TEXT,
"品牌" TEXT,
"廠商名稱" TEXT,
"總業績" REAL,
"數量" REAL,
"總成本" REAL,
"折扣活動名稱" TEXT,
"折價券活動名稱" TEXT
)
'''
)
conn.exec_driver_sql(
'''
INSERT INTO realtime_sales_monthly
("日期", "時間", "商品ID", "商品名稱", "商品館", "品牌",
"廠商名稱", "總業績", "數量", "總成本", "折扣活動名稱", "折價券活動名稱")
VALUES
('2025/04/07', '10:00:00', 'P1', '商品 A', '美妝', '品牌 A', '廠商 A', 100, 2, 60, '母親節', ''),
('2025/04/07', '20:00:00', 'P2', '商品 B', '美妝', '品牌 B', '廠商 B', 300, 3, 200, '', '折價券'),
('2025/04/08', '10:00:00', 'P3', '商品 C', '美妝', '品牌 C', '廠商 C', 600, 4, 300, '會員日', ''),
('2026/04/06', '10:00:00', 'P1', '商品 A', '美妝', '品牌 A', '廠商 A', 150, 3, 80, '母親節', ''),
('2026/04/06', '20:00:00', 'P2', '商品 B', '美妝', '品牌 B', '廠商 B', 400, 4, 250, '', '折價券'),
('2026/04/07', '10:00:00', 'P3', '商品 C', '美妝', '品牌 C', '廠商 C', 800, 5, 400, '會員日', ''),
('2026/04/06', '10:00:00', 'P4', '商品 D', '3C', '品牌 D', '廠商 D', 9999, 1, 1, '清倉', '')
'''
)
return engine
def test_query_filter_values_are_bound_instead_of_interpolated():
engine = _build_sales_engine()
attack = "美妝' OR 1=1 --"
filters = normalize_sales_query_args({
"data_range": "0",
"category": attack,
"start_date": "2026-04-30",
"end_date": "2026-04-01",
})
columns = resolve_sales_query_columns(engine, "realtime_sales_monthly")
clause, params = build_sales_where_clause(engine, columns, filters)
assert attack not in clause
assert params["filter_category"] == attack
assert params["filter_start_date"] == "2026-04-01"
assert params["filter_end_date"] == "2026-04-30"
def test_keyword_search_is_case_insensitive_and_treats_wildcards_as_text():
engine = _build_sales_engine()
with engine.begin() as connection:
connection.exec_driver_sql(
"UPDATE realtime_sales_monthly SET \"商品名稱\" = 'Alpha%_Beta' "
"WHERE \"商品ID\" = 'P1'"
)
filters = normalize_sales_query_args({
"data_range": "0",
"keyword": "alpha%_beta",
})
columns = resolve_sales_query_columns(engine, "realtime_sales_monthly")
where_sql, params = build_sales_where_clause(engine, columns, filters)
with engine.connect() as connection:
product_ids = connection.execute(
text(f'SELECT "商品ID" FROM realtime_sales_monthly WHERE {where_sql}'),
params,
).scalars().all()
assert product_ids == ["P1", "P1"]
assert params["filter_keyword"] == "%alpha\\%\\_beta%"
def test_postgres_filter_contract_uses_bound_temporal_dimensions():
class PostgresEngine:
dialect = postgresql.dialect()
filters = normalize_sales_query_args({
"data_range": "0",
"start_date": "2026-04-01",
"end_date": "2026-04-30",
"dow": "0",
"hour": "10",
"keyword": "Alpha",
})
columns = {
"date": "日期",
"time": "時間",
"name": "商品名稱",
"pid": "商品ID",
"brand": "品牌",
"vendor": "廠商名稱",
}
where_sql, params = build_sales_where_clause(PostgresEngine(), columns, filters)
assert "EXTRACT(DOW FROM TO_DATE" in where_sql
assert "SUBSTRING(TRIM(CAST" in where_sql
assert "(\\.[0-9]+)?)?$" in where_sql
assert "LOWER(CAST" in where_sql
assert params["filter_db_dow"] == 1
assert params["filter_hour"] == 10
assert re.fullmatch(SALES_TIME_TEXT_PATTERN, "10:30:59")
assert not re.fullmatch(SALES_TIME_TEXT_PATTERN, "10:30junk")
def test_invalid_time_text_never_matches_midnight_filter():
engine = _build_sales_engine()
with engine.begin() as connection:
connection.exec_driver_sql(
"UPDATE realtime_sales_monthly SET \"時間\" = '' WHERE \"商品ID\" = 'P1'"
)
filters = normalize_sales_query_args({"data_range": "0", "hour": "0"})
columns = resolve_sales_query_columns(engine, "realtime_sales_monthly")
where_sql, params = build_sales_where_clause(engine, columns, filters)
with engine.connect() as connection:
count = connection.execute(
text(f"SELECT COUNT(*) FROM realtime_sales_monthly WHERE {where_sql}"),
params,
).scalar_one()
assert count == 0
def test_top_detail_rejects_injection_as_data_and_keeps_the_table_intact():
engine = _build_sales_engine()
frame, _meta = sales_routes._query_sales_top_detail_frame(engine, {
"data_range": "0",
"start_date": "2026-04-01",
"end_date": "2026-04-30",
"category": "美妝' OR 1=1 --",
"metric": "amount",
"view": "product",
"type": "revenue",
})
assert frame.empty
with engine.connect() as conn:
count = conn.exec_driver_sql("SELECT COUNT(*) FROM realtime_sales_monthly").scalar_one()
assert count == 7
def test_top_detail_rejects_a_mismatched_business_type_and_metric():
engine = _build_sales_engine()
try:
sales_routes._query_sales_top_detail_frame(engine, {
"data_range": "0",
"type": "margin",
"metric": "amount",
"view": "product",
})
except ValueError as exc:
assert "type 與 metric" in str(exc)
else:
raise AssertionError("mismatched type/metric must be rejected")
def test_yoy_applies_month_weekday_hour_and_category_together(monkeypatch):
engine = _build_sales_engine()
class FakeDatabaseManager:
def __init__(self):
self.engine = engine
monkeypatch.setattr(sales_routes, "DatabaseManager", FakeDatabaseManager)
app = Flask(__name__)
with app.test_request_context(
"/api/sales_analysis/yoy_comparison"
"?year1=2025&year2=2026&metric=revenue&data_range=0"
"&month=2026-04&dow=0&hour=10&category=美妝"
):
response = sales_routes.api_yoy_comparison.__wrapped__()
payload = response.get_json()
assert payload["year1"]["total"] == 100
assert payload["year2"]["total"] == 150
assert payload["growth_rate"] == 50
def test_table_api_normalizes_reverse_dates_and_preserves_metric_order(monkeypatch):
engine = _build_sales_engine()
class FakeDatabaseManager:
def __init__(self):
self.engine = engine
monkeypatch.setattr(sales_routes, "DatabaseManager", FakeDatabaseManager)
sales_routes._TABLE_DATA_CACHE.clear()
app = Flask(__name__)
with app.test_request_context(
"/api/sales_analysis/table_data?metric=qty&data_range=0"
"&start_date=2026-04-30&end_date=2026-04-01&category=美妝"
):
response = sales_routes.api_sales_table_data.__wrapped__()
payload = response.get_json()
assert [item["product_id"] for item in payload["data"]] == ["P3", "P2", "P1"]
assert [item["qty"] for item in payload["data"]] == [5, 4, 3]
def test_sales_page_redirects_reverse_dates_to_one_canonical_url(monkeypatch):
engine = _build_sales_engine()
class FakeDatabaseManager:
def __init__(self):
self.engine = engine
monkeypatch.setattr(sales_routes, "DatabaseManager", FakeDatabaseManager)
sales_routes._SALES_OPTIONS_CACHE.clear()
app = Flask(__name__)
app.register_blueprint(sales_routes.sales_bp)
with app.test_request_context(
"/sales_analysis?data_range=0&start_date=2026-04-30&end_date=2026-04-01"
):
response = sales_routes.sales_analysis.__wrapped__()
assert response.status_code == 302
assert "start_date=2026-04-01" in response.location
assert "end_date=2026-04-30" in response.location
def test_sales_page_closes_one_sided_dates_with_live_source_bounds(monkeypatch):
engine = _build_sales_engine()
class FakeDatabaseManager:
def __init__(self):
self.engine = engine
monkeypatch.setattr(sales_routes, "DatabaseManager", FakeDatabaseManager)
sales_routes._SALES_OPTIONS_CACHE.clear()
app = Flask(__name__)
app.register_blueprint(sales_routes.sales_bp)
with app.test_request_context(
"/sales_analysis?data_range=0&end_date=2026-04-10"
):
response = sales_routes.sales_analysis.__wrapped__()
assert response.status_code == 302
assert "start_date=2025-04-07" in response.location
assert "end_date=2026-04-10" in response.location
def test_other_category_uses_the_same_filtered_top_twelve_contract():
engine = create_engine("sqlite:///:memory:")
with engine.begin() as connection:
connection.exec_driver_sql(
'CREATE TABLE realtime_sales_monthly ('
'"日期" TEXT, "商品名稱" TEXT, "商品館" TEXT, "總業績" REAL)'
)
connection.execute(
text(
'INSERT INTO realtime_sales_monthly '
'("日期", "商品名稱", "商品館", "總業績") '
'VALUES (:sale_date, :name, :category, :amount)'
),
[
{
"sale_date": "2026/04/01",
"name": f"商品 {index}",
"category": f"分類 {index:02d}",
"amount": 100 - index if index <= 10 else 1,
}
for index in range(14)
] + [{
"sale_date": "2026/04/01",
"name": "未分類商品",
"category": None,
"amount": 1,
}],
)
filters, columns = prepare_sales_query_context(
engine,
"realtime_sales_monthly",
{"data_range": "0", "category": "其他"},
)
where_sql, params = build_sales_where_clause(engine, columns, filters)
with engine.connect() as connection:
categories = connection.execute(
text(
'SELECT "商品館" FROM realtime_sales_monthly '
f'WHERE {where_sql} ORDER BY "商品館"'
),
params,
).scalars().all()
assert categories == ["分類 12", "分類 13"]
sales_routes._SALES_PROCESSED_CACHE["other-contract"] = {
"df": pd.read_sql(text("SELECT * FROM realtime_sales_monthly"), engine),
"cols": {
"name": "商品名稱",
"category": "商品館",
"amount": "總業績",
},
}
app = Flask(__name__)
with app.test_request_context("/sales_analysis?category=其他"):
page_frame, _columns, error = sales_routes._get_filtered_sales_data(
"other-contract"
)
sales_routes._SALES_PROCESSED_CACHE.pop("other-contract", None)
assert error is None
assert page_frame["商品館"].tolist() == ["分類 12", "分類 13"]
def test_vendor_and_marketing_exports_apply_the_page_filters():
engine = _build_sales_engine()
statements = []
def capture_statement(_conn, _cursor, statement, _params, _context, _many):
statements.append(statement)
event.listen(engine, "before_cursor_execute", capture_statement)
args = {
"metric": "amount",
"data_range": "0",
"start_date": "2026-04-01",
"end_date": "2026-04-30",
"category": "美妝",
"hour": "10",
}
vendor_frame = query_sales_vendor_export_frame(engine, args)
marketing_frames = query_sales_marketing_export_frames(engine, args)
assert vendor_frame[["廠商", "銷售金額"]].to_dict("records") == [
{"廠商": "廠商 C", "銷售金額": 800.0},
{"廠商": "廠商 A", "銷售金額": 150.0},
]
assert marketing_frames["折扣活動"][["活動名稱", "銷售金額"]].to_dict("records") == [
{"活動名稱": "會員日", "銷售金額": 800.0},
{"活動名稱": "母親節", "銷售金額": 150.0},
]
assert "折價券活動" not in marketing_frames
assert sum("UNION ALL" in statement for statement in statements) == 1
def test_excel_export_sanitizer_blocks_formula_cells():
frame = sanitize_excel_dataframe(pd.DataFrame({"活動名稱": ["=2+2", "一般活動"]}))
assert frame["活動名稱"].tolist() == ["'=2+2", "一般活動"]
def test_marketing_combined_sheet_follows_the_active_metric():
frames = {
"折扣活動": pd.DataFrame([
{"活動名稱": "高營收", "銷售金額": 1000, "銷售數量": 1, "毛利金額": 20},
]),
"折價券活動": pd.DataFrame([
{"活動名稱": "高銷量", "銷售金額": 100, "銷售數量": 10, "毛利金額": 50},
]),
}
combined = combine_sales_marketing_export_frames(frames, "qty")
assert combined["活動名稱"].tolist() == ["高銷量", "高營收"]
def test_sales_export_routes_return_valid_period_linked_workbooks(monkeypatch):
engine = _build_sales_engine()
class FakeDatabaseManager:
def __init__(self):
self.engine = engine
monkeypatch.setattr(sales_routes, "DatabaseManager", FakeDatabaseManager)
app = Flask(__name__)
query = (
"?metric=amount&data_range=0&start_date=2026-04-01&end_date=2026-04-30"
"&category=美妝&hour=10"
)
routes = (
(sales_routes.api_export_sales_vendor, "/api/sales_analysis/export_vendor", "廠商分析"),
(sales_routes.api_export_sales_marketing, "/api/sales_analysis/export_marketing", "折扣活動"),
)
for route, path, expected_sheet in routes:
with app.test_request_context(f"{path}{query}"):
response = route.__wrapped__()
response.direct_passthrough = False
workbook = load_workbook(BytesIO(response.get_data()), read_only=True)
assert response.status_code == 200
assert expected_sheet in workbook.sheetnames

View File

@@ -159,8 +159,8 @@
};
window.exportMarketingExcel = function (campaign) {
const url = new URL('/api/export/excel/marketing', window.location.origin);
url.searchParams.set('campaign', campaign || 'all');
const url = new URL('/api/sales_analysis/export_marketing', window.location.origin);
url.searchParams.set('type', campaign || 'all');
new URLSearchParams(window.location.search).forEach((v, k) => {
if (!url.searchParams.has(k)) url.searchParams.set(k, v);
});
@@ -569,17 +569,21 @@
const discountCanvas = document.getElementById('mktDiscountChart');
const couponCanvas = document.getElementById('mktCouponChart');
if (data.marketingData) {
const marketingMetric = data.marketingData.metric || 'revenue';
const marketingFormatter = marketingMetric === 'qty' ? fmtNum : fmtMoney;
const marketingLabel = marketingMetric === 'qty' ? '活動銷量' :
(marketingMetric === 'profit' ? '活動毛利' : '活動業績');
if (data.marketingData.discount) {
horizontalBar(discountCanvas,
data.marketingData.discount.labels, data.marketingData.discount.values,
'折扣業績', fmtMoney);
marketingLabel, marketingFormatter);
} else {
renderChartEmpty(discountCanvas, '所選期間沒有折扣活動業績。');
}
if (data.marketingData.coupon) {
horizontalBar(couponCanvas,
data.marketingData.coupon.labels, data.marketingData.coupon.values,
'折價券業績', fmtMoney);
marketingLabel, marketingFormatter);
} else {
renderChartEmpty(couponCanvas, '所選期間沒有折價券活動業績。');
}
@@ -729,7 +733,7 @@
columns,
processing: true, serverSide: false,
pageLength: 25, lengthMenu: [10, 25, 50, 100],
order: [[$tbl.find('thead th').length - 1, 'desc']],
order: [],
language: typeof window.EwoooCDataTableLanguage === 'function'
? window.EwoooCDataTableLanguage()
: {},
@@ -761,6 +765,13 @@
}
};
document.addEventListener('click', (event) => {
const trigger = event.target.closest('[data-sales-filter-key]');
if (!trigger) return;
event.preventDefault();
window.setFilter(trigger.dataset.salesFilterKey, trigger.dataset.salesFilterValue);
});
// Show loading on form submit
document.addEventListener('submit', (e) => {
if (e.target.matches('form[action="/sales_analysis"]')) showLoading('正在查詢...');