Files
ewoooc/tests/test_analysis_period_linkage.py
ogt 9cffe9f8e4
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
fix(analytics): restore period-linked report APIs
2026-07-15 16:54:33 +08:00

294 lines
11 KiB
Python

from datetime import date
from pathlib import Path
import pandas as pd
from flask import Flask
from sqlalchemy import create_engine, func
from sqlalchemy.orm import Session
from database.models import MonthlySummaryAnalysis
from routes.daily_sales_routes import prepare_daily_charts
import routes.daily_sales_routes as daily_routes
from routes.monthly_routes import (
_apply_monthly_period,
_resolve_monthly_period,
get_monthly_summary_data,
)
from routes.sales_routes import _filter_growth_payload, _sorted_valid_month_labels
from services.analysis_period_service import make_analysis_period, make_month_period
ROOT = Path(__file__).resolve().parents[1]
def test_daily_month_charts_and_top_products_use_the_selected_month_only():
frame = pd.DataFrame([
{"snapshot_date": "2026-04-01", "商品名稱": "A", "總業績": 100, "總成本": 60, "數量": 1},
{"snapshot_date": "2026-04-30", "商品名稱": "B", "總業績": 300, "總成本": 180, "數量": 3},
{"snapshot_date": "2026-07-01", "商品名稱": "C", "總業績": 9999, "總成本": 1, "數量": 1},
])
frame["snapshot_date"] = pd.to_datetime(frame["snapshot_date"])
charts = prepare_daily_charts(
frame,
pd.Timestamp("2026-04-30"),
start_date=pd.Timestamp("2026-04-01"),
end_date=pd.Timestamp("2026-04-30"),
top10_scope="range",
)
assert charts["labels"] == ["04/01", "04/30"]
assert charts["revenue"] == [100, 300]
assert charts["top10_labels"] == ["B", "A"]
assert charts["top10_values"] == [300, 100]
def test_daily_page_month_query_drives_kpis_charts_and_tables_from_same_month(monkeypatch):
engine = create_engine("sqlite:///:memory:")
with engine.begin() as conn:
conn.exec_driver_sql(
"""
CREATE TABLE daily_sales_snapshot (
snapshot_date TEXT,
"商品名稱" TEXT,
"館別" TEXT,
"總業績" TEXT,
"總成本" TEXT,
"數量" TEXT
)
"""
)
conn.exec_driver_sql(
"""
INSERT INTO daily_sales_snapshot
(snapshot_date, "商品名稱", "館別", "總業績", "總成本", "數量")
VALUES
('2026-04-01', 'A', '美妝', '100', '60', '1'),
('2026-04-30', 'B', '保養', '300', '180', '3'),
('2026-07-01', 'C', '3C', '9999', '1', '1')
"""
)
class FakeDatabaseManager:
def __init__(self):
self.engine = engine
monkeypatch.setattr(daily_routes, "DatabaseManager", FakeDatabaseManager)
monkeypatch.setattr(daily_routes, "_get_shared_daily_view_cache", lambda _key: None)
monkeypatch.setattr(daily_routes, "_set_shared_daily_view_cache", lambda _key, _context: None)
monkeypatch.setattr(
daily_routes,
"build_competitor_intel_payload",
lambda *_args, **_kwargs: {"trend": {"labels": []}, "snapshot_in_period": False},
)
monkeypatch.setattr(daily_routes, "render_template", lambda _name, **context: context)
daily_routes._DAILY_SALES_VIEW_CACHE.clear()
daily_routes._SALES_PROCESSED_CACHE.clear()
app = Flask(__name__)
app.register_blueprint(daily_routes.daily_sales_bp)
with app.test_request_context("/daily_sales?month=2026-04"):
context = daily_routes.daily_sales.__wrapped__()
assert context["selected_date"] == "2026-04-30"
assert context["analysis_period"]["start_date"] == "2026-04-01"
assert context["analysis_period"]["end_date"] == "2026-04-30"
assert context["month_kpi"]["total_revenue"] == 400
assert context["chart_data"]["labels"] == ["04/01", "04/30"]
assert {item["category"] for item in context["categories"]} == {"美妝", "保養"}
def test_canonical_period_contract_exposes_day_month_and_range_keys():
month = make_month_period("2026-04")
custom = make_analysis_period(date(2026, 3, 15), date(2026, 4, 20))
assert month == {
"mode": "month",
"label": "2026 年 04 月",
"start_date": "2026-04-01",
"end_date": "2026-04-30",
"start_month": "2026-04",
"end_month": "2026-04",
"is_single_day": False,
"is_single_month": True,
}
assert custom["start_month"] == "2026-03"
assert custom["end_month"] == "2026-04"
assert custom["label"] == "2026-03-15 至 2026-04-20"
def test_monthly_period_query_includes_only_current_and_aligned_previous_period():
engine = create_engine("sqlite:///:memory:")
MonthlySummaryAnalysis.__table__.create(engine)
with Session(engine) as session:
session.add_all([
MonthlySummaryAnalysis(year=2024, month=4, sales_amt_curr=100),
MonthlySummaryAnalysis(year=2025, month=4, sales_amt_curr=200),
MonthlySummaryAnalysis(year=2026, month=4, sales_amt_curr=300),
])
session.commit()
period = _resolve_monthly_period(session, start_month="2026-04", end_month="2026-04")
month_only_period = _resolve_monthly_period(session, month=4)
current = _apply_monthly_period(
session.query(func.sum(MonthlySummaryAnalysis.sales_amt_curr)),
period,
).scalar()
compared = _apply_monthly_period(
session.query(MonthlySummaryAnalysis.year).order_by(MonthlySummaryAnalysis.year),
period,
include_previous=True,
).all()
assert period["label"] == "2026 年 04 月"
assert period["previous_label"] == "2025 年 04 月"
assert month_only_period["label"] == "2026 年 04 月"
assert current == 300
assert [row.year for row in compared] == [2025, 2026]
def test_monthly_api_links_kpis_tables_and_comparison_charts_to_one_period(monkeypatch):
engine = create_engine("sqlite:///:memory:")
MonthlySummaryAnalysis.__table__.create(engine)
with Session(engine) as session:
session.add_all([
MonthlySummaryAnalysis(
year=2025,
month=4,
division="美妝",
area_name="臉部保養",
brand_name="A",
vendor_name="Vendor A",
trade_type="B2C",
unit_price=100,
sales_amt_curr=200,
sales_amt_prev=180,
sales_amt_yoa=150,
profit_amt_curr=40,
sales_vol_curr=2,
views_curr=20,
),
MonthlySummaryAnalysis(
year=2026,
month=4,
division="美妝",
area_name="臉部保養",
brand_name="A",
vendor_name="Vendor A",
trade_type="B2C",
unit_price=100,
sales_amt_curr=300,
sales_amt_prev=250,
sales_amt_yoa=200,
profit_amt_curr=60,
sales_vol_curr=3,
views_curr=30,
),
])
session.commit()
class FakeDatabaseManager:
def __init__(self):
self.engine = engine
def get_session(self):
return Session(engine)
monkeypatch.setattr("routes.monthly_routes.DatabaseManager", FakeDatabaseManager)
monkeypatch.setattr("routes.monthly_routes.DATABASE_TYPE", "sqlite")
app = Flask(__name__)
with app.test_request_context(
"/api/monthly_summary_data?start_month=2026-04&end_month=2026-04"
):
response = get_monthly_summary_data.__wrapped__()
payload = response.get_json()
assert payload["status"] == "success"
assert payload["period"]["label"] == "2026 年 04 月"
assert payload["kpis"]["sales"] == 300
assert payload["total_rows"] == 1
assert [(item["date"], item["sales"]) for item in payload["trend"]] == [
("2025/4", 200),
("2026/4", 300),
]
assert payload["vendor_ranking"][0]["sales_previous"] == 200
assert payload["vendor_ranking"][0]["sales_current"] == 300
assert {row["year"] for row in payload["rows"]} == {2026}
def test_growth_period_slices_every_series_and_recomputes_period_kpis():
chart_data = {
"labels": ["2025-04", "2026-03", "2026-04", "2026-05"],
"revenue": [100, 200, 300, 400],
"profit": [10, 20, 30, 40],
"orders": [1, 2, 3, 4],
"aov": [100, 100, 100, 100],
"mom": [0, 100, 50, 33.3],
"yoy": [0, 0, 200, 0],
"margin_rate": [10, 10, 10, 10],
"competitor_gap_pct": [1, 2, 3, 4],
"competitor_coverage": {"decision_support_rate": 25},
}
filtered, kpi, period, is_filtered = _filter_growth_payload(
chart_data,
{"ytd_revenue": 900, "total_orders": 10},
"2026-04",
"2026-05",
)
assert is_filtered is True
assert filtered["labels"] == ["2026-04", "2026-05"]
assert filtered["revenue"] == [300, 400]
assert filtered["profit"] == [30, 40]
assert filtered["competitor_gap_pct"] == [3, 4]
assert filtered["competitor_snapshot_in_period"] is False
assert filtered["competitor_coverage"] == {}
assert kpi["ytd_revenue"] == 700
assert kpi["previous_period_revenue"] == 100
assert kpi["total_orders"] == 7
assert period["start_month"] == "2026-04"
assert period["end_month"] == "2026-05"
def test_sales_seasonality_month_axis_ignores_nan_and_invalid_values():
assert _sorted_valid_month_labels([
"2026-04",
float("nan"),
None,
"invalid",
"2025-12",
"2026-04-30",
]) == ["2025-12", "2026-04"]
def test_monthly_frontend_has_replayable_period_and_no_fixed_years():
script = (ROOT / "web/static/js/page-monthly-summary.js").read_text(encoding="utf-8")
route = (ROOT / "routes/monthly_routes.py").read_text(encoding="utf-8")
tabs = (ROOT / "templates/components/_analysis_report_tabs.html").read_text(encoding="utf-8")
assert "buildFilterParams" in script
assert "syncFilterUrl" in script
assert "start_month" in script
assert "end_month" in script
assert "areaOverride" in script
assert "sales_current" in script
assert "sales_previous" in script
assert "2024" not in script
assert "2025" not in script
assert "2024" not in route
assert "2025" not in route
assert "data-analysis-target" in tabs
assert "renderChartEmpty" in script
assert "未以其他月份回填" in script
def test_sales_frontend_uses_live_period_linked_api_routes():
script = (ROOT / "web/static/js/page-sales-analysis.js").read_text(encoding="utf-8")
assert "buildPeriodApiUrl" in script
assert "new URLSearchParams(window.location.search)" in script
assert "/api/sales_analysis/yoy_comparison" in script
assert "/api/sales_analysis/table_data" in script
assert "fetch(`/api/yoy?" not in script
assert "'/api/sales_table?'" not in script