From 5f3e88c9ca07f5a0f19e06644a97ae0f39536dbe Mon Sep 17 00:00:00 2001 From: ogt Date: Wed, 15 Jul 2026 15:54:35 +0800 Subject: [PATCH] fix(analytics): link report data to selected period --- config.py | 2 +- routes/daily_sales_routes.py | 78 +++-- routes/monthly_routes.py | 312 ++++++++++++++---- routes/sales_routes.py | 119 ++++++- services/analysis_period_service.py | 89 +++++ services/competitor_intel_repository.py | 70 +++- .../components/_analysis_report_tabs.html | 29 +- templates/daily_sales.html | 30 +- templates/growth_analysis.html | 37 ++- templates/monthly_summary_analysis.html | 18 +- tests/test_analysis_period_linkage.py | 269 +++++++++++++++ web/static/css/page-growth-bem.css | 69 ++++ web/static/css/page-monthly-summary-bem.css | 36 ++ web/static/js/page-daily-sales.js | 6 +- web/static/js/page-monthly-summary.js | 254 ++++++++++---- 15 files changed, 1228 insertions(+), 190 deletions(-) create mode 100644 services/analysis_period_service.py create mode 100644 tests/test_analysis_period_linkage.py diff --git a/config.py b/config.py index 76a5a79..9edb6a6 100644 --- a/config.py +++ b/config.py @@ -414,7 +414,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.801" +SYSTEM_VERSION = "V10.802" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/routes/daily_sales_routes.py b/routes/daily_sales_routes.py index 5cfe8ee..876b50c 100644 --- a/routes/daily_sales_routes.py +++ b/routes/daily_sales_routes.py @@ -27,6 +27,7 @@ from services.daily_sales_service import ( prepare_marketing_summary, ) from services.competitor_intel_repository import build_competitor_intel_payload +from services.analysis_period_service import make_analysis_period, make_month_period from services.cache_manager import ( _DAILY_SALES_PROCESSED_CACHE as _SALES_PROCESSED_CACHE, _DAILY_SALES_VIEW_CACHE_DIR, @@ -371,10 +372,19 @@ def calculate_wow(df, current_date): return wow -def prepare_daily_charts(df, selected_date, days=30): - """準備 4 個圖表的數據(根據選擇的日期)""" - start_date = selected_date - timedelta(days=days) - df_range = df[(df['snapshot_date'] >= start_date) & (df['snapshot_date'] <= selected_date)] +def prepare_daily_charts( + df, + selected_date, + days=30, + *, + start_date=None, + end_date=None, + top10_scope="day", +): + """Prepare chart data from one canonical, inclusive report window.""" + chart_end = pd.to_datetime(end_date if end_date is not None else selected_date) + chart_start = pd.to_datetime(start_date) if start_date is not None else chart_end - timedelta(days=days) + df_range = df[(df['snapshot_date'] >= chart_start) & (df['snapshot_date'] <= chart_end)] cols = df_range.columns.tolist() col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績']) @@ -435,7 +445,7 @@ def prepare_daily_charts(df, selected_date, days=30): daily_agg['wow_qty'] = daily_agg[col_qty].pct_change(periods=7) * 100 # Top 10 商品 - selected_df = df[df['snapshot_date'] == selected_date] + selected_df = df_range if top10_scope == "range" else df[df['snapshot_date'] == selected_date] top10_labels = [] top10_values = [] @@ -626,31 +636,41 @@ def daily_sales(): available_dates_str = [d.strftime('%Y-%m-%d') for d in available_dates] + selected_month_param = request.args.get('month') selected_date_param = request.args.get('date') + selected_month = pd.to_datetime(selected_month_param) if selected_month_param else None if selected_date_param: selected_date = pd.to_datetime(selected_date_param) + elif selected_month is not None: + dates_in_month = [ + candidate for candidate in available_dates + if candidate.year == selected_month.year and candidate.month == selected_month.month + ] + selected_date = dates_in_month[0] if dates_in_month else selected_month else: selected_date = available_dates[0] - - selected_month_param = request.args.get('month') - if selected_month_param: - selected_month = pd.to_datetime(selected_month_param) - else: + if selected_month is None: selected_month = selected_date - is_month_view = not selected_date_param and not request.args.get('month') - if selected_month_param and not selected_date_param: - is_month_view = True + is_month_view = not bool(selected_date_param) show_all_categories = request.args.get('detail') == 'all' month_start = selected_month.replace(day=1) month_end = (month_start + pd.DateOffset(months=1)) - pd.Timedelta(days=1) - data_start = min( - selected_date - pd.Timedelta(days=30), - selected_date - pd.Timedelta(days=7), - month_start - pd.Timedelta(days=1), + chart_start = month_start if is_month_view else selected_date - pd.Timedelta(days=30) + chart_end = month_end if is_month_view else selected_date + data_start = min(chart_start - pd.Timedelta(days=7), month_start - pd.Timedelta(days=1)) + data_end = max(chart_end, month_end) + analysis_period = ( + make_month_period(month_start, mode="month") + if is_month_view + else make_analysis_period(selected_date, mode="day") + ) + chart_period = make_analysis_period( + chart_start, + chart_end, + mode="month" if is_month_view else "rolling_30_day", ) - data_end = max(selected_date, month_end) view_cache_key = "|".join([ table_name, @@ -722,9 +742,25 @@ def daily_sales(): else: month_kpi['avg_price'] = 0 - chart_data = prepare_daily_charts(df, selected_date, days=30) + chart_data = prepare_daily_charts( + df, + selected_date, + start_date=chart_start, + end_date=chart_end, + top10_scope="range" if is_month_view else "day", + ) try: - competitor_intel = build_competitor_intel_payload(engine, days=30) + include_current_snapshot = ( + chart_start.date() <= now_taipei.date() + and chart_end.date() >= (now_taipei.date() - timedelta(days=1)) + ) + competitor_intel = build_competitor_intel_payload( + engine, + days=30, + start_date=chart_start.date(), + end_date=chart_end.date(), + include_current_snapshot=include_current_snapshot, + ) except Exception as exc: sys_log.warning(f"[DailySales] PChome 競價情報讀取失敗,略過圖表串接: {exc}") competitor_intel = None @@ -771,6 +807,8 @@ def daily_sales(): 'calendar_data': calendar_data, 'marketing_data': marketing_data, 'selected_month': selected_month.strftime('%Y-%m') if isinstance(selected_month, pd.Timestamp) else selected_month, + 'analysis_period': analysis_period, + 'chart_period': chart_period, 'datetime_now': datetime_now_str, 'active_page': 'daily_sales', } diff --git a/routes/monthly_routes.py b/routes/monthly_routes.py index 87062d3..3f87c14 100644 --- a/routes/monthly_routes.py +++ b/routes/monthly_routes.py @@ -8,12 +8,13 @@ from datetime import datetime, timezone, timedelta from flask import Blueprint, request, jsonify, render_template from auth import login_required -from sqlalchemy import func, desc, text, case +from sqlalchemy import func, desc, text, case, and_, or_ from config import BASE_DIR, SYSTEM_VERSION, DATABASE_TYPE from database.manager import DatabaseManager from database.models import MonthlySummaryAnalysis from services.logger_manager import SystemLogger +from services.analysis_period_service import make_analysis_period, month_end, parse_month def get_group_concat_sql(): @@ -84,6 +85,128 @@ def _apply_monthly_filters(query, *, year=None, month=None, division=None, pm_na return query +def _monthly_key_expr(): + return MonthlySummaryAnalysis.year * 100 + MonthlySummaryAnalysis.month + + +def _resolve_monthly_period(session, *, year=None, month=None, start_month=None, end_month=None): + latest = session.query( + MonthlySummaryAnalysis.year, + MonthlySummaryAnalysis.month, + ).order_by( + MonthlySummaryAnalysis.year.desc(), + MonthlySummaryAnalysis.month.desc(), + ).first() + latest_month = ( + datetime(int(latest.year), int(latest.month), 1).date() + if latest else datetime.now(TAIPEI_TZ).date().replace(day=1) + ) + + parsed_start = parse_month(start_month) + parsed_end = parse_month(end_month) + if parsed_start or parsed_end: + period_start = parsed_start or parsed_end + period_end = parsed_end or parsed_start + elif year or month: + selected_year = int(year) if year else latest_month.year + selected_month = int(month) if month else 1 + period_start = datetime(selected_year, selected_month, 1).date() + period_end = period_start if month else datetime(selected_year, 12, 1).date() + else: + period_start = latest_month.replace(month=1) + period_end = latest_month + + if period_start > period_end: + period_start, period_end = period_end, period_start + previous_start = period_start.replace(year=period_start.year - 1) + previous_end = period_end.replace(year=period_end.year - 1) + analysis_period = make_analysis_period( + period_start, + month_end(period_end), + mode='month' if period_start == period_end else 'month_range', + ) + previous_period = make_analysis_period( + previous_start, + month_end(previous_end), + mode='comparison', + ) + return { + 'start_key': period_start.year * 100 + period_start.month, + 'end_key': period_end.year * 100 + period_end.month, + 'previous_start_key': previous_start.year * 100 + previous_start.month, + 'previous_end_key': previous_end.year * 100 + previous_end.month, + 'current_year': period_end.year, + 'previous_year': period_end.year - 1, + 'analysis_period': analysis_period, + 'previous_period': previous_period, + 'label': analysis_period['label'], + 'previous_label': previous_period['label'], + } + + +def _apply_monthly_period(query, period, *, include_previous=False): + key_expr = _monthly_key_expr() + current_clause = and_( + key_expr >= period['start_key'], + key_expr <= period['end_key'], + ) + if not include_previous: + return query.filter(current_clause) + previous_clause = and_( + key_expr >= period['previous_start_key'], + key_expr <= period['previous_end_key'], + ) + return query.filter(or_(current_clause, previous_clause)) + + +def _apply_monthly_dimensions(query, *, division=None, pm_name=None, brand_name=None, + vendor_name=None, area_name=None, trade_type=None): + return _apply_monthly_filters( + query, + division=division, + pm_name=pm_name, + brand_name=brand_name, + vendor_name=vendor_name, + area_name=area_name, + trade_type=trade_type, + ignore_year=True, + ) + + +def _month_key_to_date(month_key): + return datetime(int(month_key) // 100, int(month_key) % 100, 1).date() + + +def _month_distance(start_key, target_key): + start = _month_key_to_date(start_key) + target = _month_key_to_date(target_key) + return (target.year - start.year) * 12 + target.month - start.month + + +def _serialize_comparison_trend(rows, period): + serialized = [] + for row in rows: + key = int(row.year) * 100 + int(row.month) + if period['start_key'] <= key <= period['end_key']: + role = 'current' + slot = _month_distance(period['start_key'], key) + slot_date = _month_key_to_date(key) + elif period['previous_start_key'] <= key <= period['previous_end_key']: + role = 'previous' + slot = _month_distance(period['previous_start_key'], key) + slot_date = _month_key_to_date(key).replace(year=int(row.year) + 1) + else: + continue + serialized.append({ + 'date': f"{row.year}/{row.month}", + 'sales': int(row.sales or 0), + 'period_role': role, + 'slot': slot, + 'slot_label': slot_date.strftime('%Y/%m'), + }) + return serialized + + # ========================================== # 頁面路由 # ========================================== @@ -92,9 +215,23 @@ def _apply_monthly_filters(query, *, year=None, month=None, division=None, pm_na @login_required def monthly_summary_analysis_page(): """月份總表數據分析展示頁 (Phase 9)""" + start_month = request.args.get('start_month') + end_month = request.args.get('end_month') + year = request.args.get('year', type=int) + month = request.args.get('month', type=int) + if not start_month and year: + start_month = f"{year:04d}-{month:02d}" if month else f"{year:04d}-01" + end_month = f"{year:04d}-{month:02d}" if month else f"{year:04d}-12" + period_start = parse_month(start_month) + period_end = parse_month(end_month) or period_start + analysis_period = ( + make_analysis_period(period_start, month_end(period_end), mode='month_range') + if period_start else make_analysis_period(None) + ) return render_template('monthly_summary_analysis.html', datetime_now=datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M:%S'), system_version=SYSTEM_VERSION, + analysis_period=analysis_period, active_page='monthly') @@ -114,10 +251,19 @@ def get_monthly_summary_trend(): vendor_name = request.args.get('vendor') area_name = request.args.get('area_name') trade_type = request.args.get('trade_type') + start_month = request.args.get('start_month') + end_month = request.args.get('end_month') db = DatabaseManager() session = db.get_session() try: + period = _resolve_monthly_period( + session, + year=year, + month=month, + start_month=start_month, + end_month=end_month, + ) trend_query = session.query( MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month, @@ -126,10 +272,8 @@ def get_monthly_summary_trend(): MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month ).order_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month) - trend_query = _apply_monthly_filters( + trend_query = _apply_monthly_dimensions( trend_query, - year=year, - month=month, division=division, pm_name=pm_name, brand_name=brand_name, @@ -137,14 +281,19 @@ def get_monthly_summary_trend(): area_name=area_name, trade_type=trade_type, ) + trend_query = _apply_monthly_period(trend_query, period, include_previous=True) trend_results = trend_query.all() return jsonify({ 'status': 'success', - 'trend': [ - {'date': f"{r.year}/{r.month}", 'sales': int(r.sales or 0)} - for r in trend_results - ], + 'period': { + 'label': period['label'], + 'previous_label': period['previous_label'], + 'current_year': period['current_year'], + 'previous_year': period['previous_year'], + **period['analysis_period'], + }, + 'trend': _serialize_comparison_trend(trend_results, period), }) except Exception as e: sys_log.error(f"取得月份總表趨勢資料失敗: {e}") @@ -166,10 +315,22 @@ def get_monthly_summary_data(): area_name = request.args.get('area_name') trade_type = request.args.get('trade_type') limit = request.args.get('limit', default=1000, type=int) + start_month = request.args.get('start_month') + end_month = request.args.get('end_month') db = DatabaseManager() session = db.get_session() try: + period = _resolve_monthly_period( + session, + year=year, + month=month, + start_month=start_month, + end_month=end_month, + ) + if start_month or end_month: + year = None + month = None # 基礎查詢 query = session.query(MonthlySummaryAnalysis) @@ -193,6 +354,7 @@ def get_monthly_summary_data(): query = query.filter(MonthlySummaryAnalysis.area_name == area_name) if trade_type: query = query.filter(MonthlySummaryAnalysis.trade_type == trade_type) + query = _apply_monthly_period(query, period) # 取得統計數據 (KPIs) kpi_query = session.query( @@ -224,6 +386,7 @@ def get_monthly_summary_data(): kpi_query = kpi_query.filter(MonthlySummaryAnalysis.area_name == area_name) if trade_type: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.trade_type == trade_type) + kpi_query = _apply_monthly_period(kpi_query, period) kpi_res = kpi_query.one() @@ -233,11 +396,26 @@ def get_monthly_summary_data(): MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month ).distinct() - if year: - total_rows = total_rows.filter(MonthlySummaryAnalysis.year == year) - total_months_query = total_months_query.filter(MonthlySummaryAnalysis.year == year) - if month: - total_rows = total_rows.filter(MonthlySummaryAnalysis.month == month) + total_rows = _apply_monthly_dimensions( + total_rows, + division=division, + pm_name=pm_name, + brand_name=brand_name, + vendor_name=vendor_name, + area_name=area_name, + trade_type=trade_type, + ) + total_months_query = _apply_monthly_dimensions( + total_months_query, + division=division, + pm_name=pm_name, + brand_name=brand_name, + vendor_name=vendor_name, + area_name=area_name, + trade_type=trade_type, + ) + total_rows = _apply_monthly_period(total_rows, period) + total_months_query = _apply_monthly_period(total_months_query, period) total_rows = total_rows.scalar() total_months = total_months_query.count() @@ -266,6 +444,7 @@ def get_monthly_summary_data(): trend_query = trend_query.filter(MonthlySummaryAnalysis.area_name == area_name) if trade_type: trend_query = trend_query.filter(MonthlySummaryAnalysis.trade_type == trade_type) + trend_query = _apply_monthly_period(trend_query, period, include_previous=True) # 取得排行榜 (Top 10 Brands) rank_query = session.query( @@ -292,6 +471,7 @@ def get_monthly_summary_data(): rank_query = rank_query.filter(MonthlySummaryAnalysis.area_name == area_name) if trade_type: rank_query = rank_query.filter(MonthlySummaryAnalysis.trade_type == trade_type) + rank_query = _apply_monthly_period(rank_query, period) rank_query = rank_query.order_by(desc('sales')).limit(10) @@ -303,51 +483,48 @@ def get_monthly_summary_data(): ).limit(limit) # --- 進階分析子查詢 (Phase 17) --- - def apply_filters(q, ignore_year=False): - if year and not ignore_year: - q = q.filter(MonthlySummaryAnalysis.year == year) - if month: - q = q.filter(MonthlySummaryAnalysis.month == month) - if division: - q = q.filter(MonthlySummaryAnalysis.division == division) - if pm_name: - q = q.filter(MonthlySummaryAnalysis.pm_name == pm_name) - if brand_name: - q = q.filter(MonthlySummaryAnalysis.brand_name == brand_name) - if vendor_name: - q = q.filter(MonthlySummaryAnalysis.vendor_name == vendor_name) - if area_name: - if ',' in area_name: - q = q.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(','))) - else: - q = q.filter(MonthlySummaryAnalysis.area_name == area_name) - if trade_type: - q = q.filter(MonthlySummaryAnalysis.trade_type == trade_type) - return q + def apply_filters(q, include_previous=False): + q = _apply_monthly_dimensions( + q, + division=division, + pm_name=pm_name, + brand_name=brand_name, + vendor_name=vendor_name, + area_name=area_name, + trade_type=trade_type, + ) + return _apply_monthly_period(q, period, include_previous=include_previous) + + month_key = _monthly_key_expr() + current_period_condition = and_( + month_key >= period['start_key'], + month_key <= period['end_key'], + ) + previous_period_condition = and_( + month_key >= period['previous_start_key'], + month_key <= period['previous_end_key'], + ) # 廠商排行 (Top 20, 分年度) vendor_rank_q = session.query( MonthlySummaryAnalysis.vendor_name, - func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'), - func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'), - func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025'), - func.sum(MonthlySummaryAnalysis.profit_amt_curr).label('profit'), - func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.profit_amt_curr), else_=0)).label('profit_2024'), - func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.profit_amt_curr), else_=0)).label('profit_2025'), + func.sum(case((current_period_condition, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales'), + func.sum(case((previous_period_condition, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_previous'), + func.sum(case((current_period_condition, MonthlySummaryAnalysis.profit_amt_curr), else_=0)).label('profit'), + func.sum(case((previous_period_condition, MonthlySummaryAnalysis.profit_amt_curr), else_=0)).label('profit_previous'), ).group_by(MonthlySummaryAnalysis.vendor_name) - vendor_rank_q = apply_filters(vendor_rank_q, ignore_year=True) + vendor_rank_q = apply_filters(vendor_rank_q, include_previous=True) vendor_rank_q = vendor_rank_q.order_by(desc('sales')).limit(20) # 區域分佈 (按 area_name, Top 12, 分年度) div_dist_q = session.query( MonthlySummaryAnalysis.area_name, - func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'), - func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'), - func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025') + func.sum(case((current_period_condition, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales'), + func.sum(case((previous_period_condition, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_previous') ).group_by(MonthlySummaryAnalysis.area_name) - div_dist_q = apply_filters(div_dist_q, ignore_year=True) + div_dist_q = apply_filters(div_dist_q, include_previous=True) div_dist_q = div_dist_q.order_by(desc('sales')).limit(12) # 價格帶貢獻 (分年度) @@ -360,11 +537,10 @@ def get_monthly_summary_data(): (MonthlySummaryAnalysis.unit_price < 10000, '5,000-9,999'), else_='10,000+' ).label('price_range'), - func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'), - func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'), - func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025') + func.sum(case((current_period_condition, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales'), + func.sum(case((previous_period_condition, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_previous') ).group_by('price_range') - price_cont_q = apply_filters(price_cont_q, ignore_year=True) + price_cont_q = apply_filters(price_cont_q, include_previous=True) # BCG 矩陣 (品牌 x 區域) bcg_q = session.query( @@ -391,7 +567,7 @@ def get_monthly_summary_data(): ).filter(MonthlySummaryAnalysis.area_name.in_(top_12_areas))\ .group_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month, MonthlySummaryAnalysis.area_name)\ .order_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month) - heatmap_q = apply_filters(heatmap_q, ignore_year=True) + heatmap_q = apply_filters(heatmap_q, include_previous=True) # Highlights (Top 3) def get_highlights_q(metric_col): @@ -407,12 +583,11 @@ def get_monthly_summary_data(): # 區域排行 area_rank_q = session.query( MonthlySummaryAnalysis.area_name, - func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'), - func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'), - func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025') + func.sum(case((current_period_condition, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales'), + func.sum(case((previous_period_condition, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_previous') ).group_by(MonthlySummaryAnalysis.area_name) - area_rank_q = apply_filters(area_rank_q, ignore_year=True) + area_rank_q = apply_filters(area_rank_q, include_previous=True) area_rank_q = area_rank_q.order_by(desc('sales')) # 年度對比趨勢 @@ -472,6 +647,13 @@ def get_monthly_summary_data(): return jsonify({ 'status': 'success', + 'period': { + 'label': period['label'], + 'previous_label': period['previous_label'], + 'current_year': period['current_year'], + 'previous_year': period['previous_year'], + **period['analysis_period'], + }, 'total_rows': total_rows, 'total_months': total_months, 'kpis': { @@ -483,15 +665,15 @@ def get_monthly_summary_data(): 'views': int(kpi_res.total_views or 0), 'margin': round((kpi_res.total_profit / kpi_res.total_sales * 100), 2) if kpi_res.total_sales and kpi_res.total_profit else 0 }, - 'trend': [{'date': f"{r.year}/{r.month}", 'sales': int(r.sales or 0)} for r in trend_results], + 'trend': _serialize_comparison_trend(trend_results, period), 'yoy_trend': [{'date': f"{r.year}/{r.month}", 'curr': int(r.sales_curr or 0), 'yoa': int(r.sales_yoa or 0)} for r in yoy_trend_results], 'rankings': [{'brand': r.brand_name, 'sales': int(r.sales or 0)} for r in rank_results], 'area_ranking': [ { 'name': r.area_name, 'sales': int(r.sales or 0), - 'sales_2024': int(r.sales_2024 or 0), - 'sales_2025': int(r.sales_2025 or 0) + 'sales_current': int(r.sales or 0), + 'sales_previous': int(r.sales_previous or 0) } for r in area_rank_results ], @@ -499,11 +681,11 @@ def get_monthly_summary_data(): { 'name': r.vendor_name, 'sales': int(r.sales or 0), - 'sales_2024': int(r.sales_2024 or 0), - 'sales_2025': int(r.sales_2025 or 0), + 'sales_current': int(r.sales or 0), + 'sales_previous': int(r.sales_previous or 0), 'profit': int(r.profit or 0), - 'profit_2024': int(r.profit_2024 or 0), - 'profit_2025': int(r.profit_2025 or 0), + 'profit_current': int(r.profit or 0), + 'profit_previous': int(r.profit_previous or 0), 'margin': round((r.profit / r.sales * 100), 2) if r.sales and r.profit else 0 } for r in vendor_rank_results @@ -512,8 +694,8 @@ def get_monthly_summary_data(): { 'name': r.area_name, 'value': int(r.sales or 0), - 'sales_2024': int(r.sales_2024 or 0), - 'sales_2025': int(r.sales_2025 or 0) + 'sales_current': int(r.sales or 0), + 'sales_previous': int(r.sales_previous or 0) } for r in div_dist_results ], @@ -521,8 +703,8 @@ def get_monthly_summary_data(): { 'range': r.price_range, 'sales': int(r.sales or 0), - 'sales_2024': int(r.sales_2024 or 0), - 'sales_2025': int(r.sales_2025 or 0) + 'sales_current': int(r.sales or 0), + 'sales_previous': int(r.sales_previous or 0) } for r in price_cont_results ], diff --git a/routes/sales_routes.py b/routes/sales_routes.py index 2eeb7ad..62ecc5e 100644 --- a/routes/sales_routes.py +++ b/routes/sales_routes.py @@ -26,6 +26,12 @@ from config import BASE_DIR, DATABASE_TYPE, SYSTEM_VERSION from database.manager import DatabaseManager from services.logger_manager import SystemLogger from services.daily_sales_service import prepare_marketing_summary +from services.analysis_period_service import ( + make_analysis_period, + make_month_period, + month_end, + parse_month, +) from services.cache_manager import ( _SALES_PROCESSED_CACHE, _SALES_OPTIONS_CACHE, @@ -444,6 +450,76 @@ def _attach_growth_competitor_intel(engine, chart_data): return enriched +def _filter_growth_payload(chart_data, kpi, start_month=None, end_month=None): + """Slice every growth series to one period and recompute period KPIs.""" + labels = list((chart_data or {}).get('labels') or []) + if not labels: + return chart_data, kpi, make_analysis_period(None), False + + available_start = parse_month(labels[0]) + available_end = parse_month(labels[-1]) + requested_start = parse_month(start_month) or available_start + requested_end = parse_month(end_month) or available_end + if requested_start > requested_end: + requested_start, requested_end = requested_end, requested_start + + selected_indices = [ + index for index, label in enumerate(labels) + if requested_start <= parse_month(label) <= requested_end + ] + filtered = dict(chart_data or {}) + for key, values in list(filtered.items()): + if isinstance(values, list) and len(values) == len(labels): + filtered[key] = [values[index] for index in selected_indices] + + analysis_period = make_analysis_period( + requested_start, + month_end(requested_end), + mode='month_range' if requested_start != requested_end else 'month', + ) + is_filtered = bool(start_month or end_month) + current_month = datetime.now(TAIPEI_TZ).date().replace(day=1) + competitor_snapshot_in_period = ( + not is_filtered or requested_start <= current_month <= requested_end + ) + filtered['competitor_snapshot_in_period'] = competitor_snapshot_in_period + if not competitor_snapshot_in_period: + filtered['competitor_coverage'] = {} + if not is_filtered: + return filtered, dict(kpi or {}), analysis_period, False + + revenue_by_month = { + label: _growth_number(value) + for label, value in zip(labels, chart_data.get('revenue') or []) + } + selected_labels = filtered.get('labels') or [] + selected_revenue = sum(_growth_number(value) for value in filtered.get('revenue') or []) + previous_revenue = 0.0 + for label in selected_labels: + current_month = parse_month(label) + previous_label = current_month.replace(year=current_month.year - 1).strftime('%Y-%m') + previous_revenue += revenue_by_month.get(previous_label, 0) + + selected_orders = sum(_growth_number(value) for value in filtered.get('orders') or []) + latest_revenue = _growth_number((filtered.get('revenue') or [0])[-1]) if selected_labels else 0 + latest_orders = _growth_number((filtered.get('orders') or [0])[-1]) if selected_labels else 0 + filtered_kpi = dict(kpi or {}) + filtered_kpi.update({ + 'ytd_revenue': selected_revenue, + 'ytd_growth': ( + (selected_revenue - previous_revenue) / previous_revenue * 100 + if previous_revenue > 0 else 0 + ), + 'current_year': requested_end.year, + 'recent_aov': latest_revenue / latest_orders if latest_orders > 0 else 0, + 'total_orders': int(selected_orders), + 'period_label': analysis_period['label'], + 'period_revenue': selected_revenue, + 'previous_period_revenue': previous_revenue, + }) + return filtered, filtered_kpi, analysis_period, True + + def _build_growth_kpi(kpi_row): if not kpi_row: return None @@ -1047,6 +1123,21 @@ def sales_analysis(): # 解析 data_range_months(有篩選時才處理) data_range_months = int(data_range_param or '0') + if start_date or end_date: + period_start = start_date or end_date + period_end = end_date or start_date + analysis_period = make_analysis_period(period_start, period_end, mode='custom_range') + elif data_range_months > 0: + period_end = datetime.now(TAIPEI_TZ).date() + period_start = period_end - timedelta(days=data_range_months * 30) + analysis_period = make_analysis_period( + period_start, + period_end, + mode='rolling_months', + label=f"最近 {data_range_months} 個月", + ) + else: + analysis_period = make_analysis_period(None) # V-New: 如果有自訂日期區間,則優先使用 if start_date or end_date: @@ -1906,6 +1997,7 @@ def sales_analysis(): 'total_records': len(df), 'active_page': 'sales', 'db_data_range': db_data_range, + 'analysis_period': analysis_period, } _set_sales_page_context_cache(page_cache_key, context) _set_sales_shared_page_context_cache(page_cache_key, context) @@ -1961,9 +2053,16 @@ def growth_analysis(): get_growth_cache, set_growth_cache, is_growth_cache_valid ) + start_month_param = request.args.get('start_month') or request.args.get('month') + end_month_param = request.args.get('end_month') or request.args.get('month') + def _render_growth_empty(message=None): now_taipei = datetime.now(TAIPEI_TZ) empty_chart_data, empty_kpi = _growth_empty_payload(now_taipei) + empty_period = ( + make_month_period(start_month_param, mode='month') + if start_month_param else make_analysis_period(None) + ) return render_template('growth_analysis.html', chart_data=empty_chart_data, kpi=empty_kpi, @@ -1972,6 +2071,8 @@ def growth_analysis(): cache_hit=False, cache_age=0, is_empty_state=True, + analysis_period=empty_period, + period_filtered=bool(start_month_param or end_month_param), empty_message=message) try: @@ -1993,12 +2094,20 @@ def growth_analysis(): now_taipei = datetime.now(TAIPEI_TZ) chart_data = _attach_growth_competitor_intel(db.engine, cache['chart_data']) + chart_data, filtered_kpi, analysis_period, period_filtered = _filter_growth_payload( + chart_data, + cache['kpi'], + start_month_param, + end_month_param, + ) return render_template('growth_analysis.html', chart_data=chart_data, - kpi=cache['kpi'], + kpi=filtered_kpi, datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'), cache_hit=True, active_page='growth', + analysis_period=analysis_period, + period_filtered=period_filtered, cache_age=cache_age) # 快取失效,重新計算 @@ -2024,6 +2133,12 @@ def growth_analysis(): # 儲存快取 set_growth_cache(chart_data, kpi, source_fingerprint) chart_data = _attach_growth_competitor_intel(db.engine, chart_data) + chart_data, kpi, analysis_period, period_filtered = _filter_growth_payload( + chart_data, + kpi, + start_month_param, + end_month_param, + ) elapsed = time.time() - start_time sys_log.debug(f"[GrowthAnalysis] [Cache] 數據計算完成 | 耗時: {elapsed:.3f}秒") @@ -2034,6 +2149,8 @@ def growth_analysis(): kpi=kpi, datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'), active_page='growth', + analysis_period=analysis_period, + period_filtered=period_filtered, cache_hit=False) except Exception as e: diff --git a/services/analysis_period_service.py b/services/analysis_period_service.py new file mode 100644 index 0000000..48224be --- /dev/null +++ b/services/analysis_period_service.py @@ -0,0 +1,89 @@ +"""Canonical period helpers shared by analytics report pages.""" + +from __future__ import annotations + +from calendar import monthrange +from datetime import date, datetime + + +def parse_iso_date(value) -> date | None: + if not value: + return None + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + try: + return datetime.strptime(str(value)[:10], "%Y-%m-%d").date() + except (TypeError, ValueError): + return None + + +def parse_month(value) -> date | None: + if not value: + return None + if isinstance(value, datetime): + return value.date().replace(day=1) + if isinstance(value, date): + return value.replace(day=1) + try: + return datetime.strptime(str(value)[:7], "%Y-%m").date() + except (TypeError, ValueError): + return None + + +def month_end(value) -> date | None: + month_start = parse_month(value) + if not month_start: + return None + return month_start.replace(day=monthrange(month_start.year, month_start.month)[1]) + + +def make_analysis_period(start, end=None, *, mode="range", label=None) -> dict: + start_date = parse_iso_date(start) + end_date = parse_iso_date(end) if end else start_date + if not start_date and end_date: + start_date = end_date + if not start_date or not end_date: + return { + "mode": mode, + "label": label or "全部資料", + "start_date": "", + "end_date": "", + "start_month": "", + "end_month": "", + "is_single_day": False, + "is_single_month": False, + } + if start_date > end_date: + start_date, end_date = end_date, start_date + + start_month = start_date.strftime("%Y-%m") + end_month_value = end_date.strftime("%Y-%m") + is_single_day = start_date == end_date + is_single_month = start_month == end_month_value + if not label: + if is_single_day: + label = start_date.strftime("%Y-%m-%d") + elif is_single_month and start_date.day == 1 and end_date == month_end(start_date): + label = start_date.strftime("%Y 年 %m 月") + else: + label = f"{start_date:%Y-%m-%d} 至 {end_date:%Y-%m-%d}" + + return { + "mode": mode, + "label": label, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "start_month": start_month, + "end_month": end_month_value, + "is_single_day": is_single_day, + "is_single_month": is_single_month, + } + + +def make_month_period(value, *, mode="month") -> dict: + start = parse_month(value) + if not start: + return make_analysis_period(None, mode=mode) + return make_analysis_period(start, month_end(start), mode=mode) diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py index 20b6555..779978c 100644 --- a/services/competitor_intel_repository.py +++ b/services/competitor_intel_repository.py @@ -1533,20 +1533,49 @@ def _fetch_manual_review_summary(engine) -> dict[str, Any]: return summary -def fetch_competitor_gap_trend(engine, days: int = 30) -> dict: +def fetch_competitor_gap_trend( + engine, + days: int = 30, + *, + start_date=None, + end_date=None, +) -> dict: days = max(7, min(int(days or 30), 120)) + start_label = _date_label(start_date) if start_date else "" + end_label = _date_label(end_date) if end_date else "" return _cached_payload( - f"gap_trend:v2:days={days}:floor={PCHOME_MATCH_SCORE_FLOOR}", - lambda: _fetch_competitor_gap_trend_uncached(engine, days=days), + f"gap_trend:v3:days={days}:start={start_label}:end={end_label}:floor={PCHOME_MATCH_SCORE_FLOOR}", + lambda: _fetch_competitor_gap_trend_uncached( + engine, + days=days, + start_date=start_label or None, + end_date=end_label or None, + ), ) -def _fetch_competitor_gap_trend_uncached(engine, days: int = 30) -> dict: - """近 N 天 PChome 價差壓力趨勢。""" +def _fetch_competitor_gap_trend_uncached( + engine, + days: int = 30, + *, + start_date=None, + end_date=None, +) -> dict: + """PChome gap trend for an explicit period or a trailing N-day window.""" if not inspect(engine).has_table("competitor_price_history"): return {"labels": [], "avg_gap_pct": [], "risk_count": [], "momo_advantage_count": [], "match_count": []} days = max(7, min(int(days or 30), 120)) + if start_date or end_date: + period_clause = """ + AND (:start_date IS NULL OR cph.crawled_at >= CAST(:start_date AS date)) + AND (:end_date IS NULL OR cph.crawled_at < CAST(:end_date AS date) + INTERVAL '1 day') + """ + params = {"start_date": start_date, "end_date": end_date} + else: + period_clause = "AND cph.crawled_at >= CURRENT_DATE - (:days * INTERVAL '1 day')" + params = {"days": days} + sql = text(f""" WITH latest_history AS ( SELECT @@ -1560,7 +1589,7 @@ def _fetch_competitor_gap_trend_uncached(engine, days: int = 30) -> dict: ) AS rn FROM competitor_price_history cph WHERE cph.source = 'pchome' - AND cph.crawled_at >= CURRENT_DATE - (:days * INTERVAL '1 day') + {period_clause} AND cph.momo_price IS NOT NULL AND cph.momo_price > 0 AND cph.price IS NOT NULL @@ -1580,7 +1609,7 @@ def _fetch_competitor_gap_trend_uncached(engine, days: int = 30) -> dict: ORDER BY bucket_date """) with engine.connect() as conn: - rows = conn.execute(sql, {"days": days}).mappings().all() + rows = conn.execute(sql, params).mappings().all() return { "labels": [_date_label(row.get("bucket_date")) for row in rows], @@ -2370,14 +2399,31 @@ def fetch_competitor_comparison_results( return results -def build_competitor_intel_payload(engine, days: int = 30) -> dict: +def build_competitor_intel_payload( + engine, + days: int = 30, + *, + start_date=None, + end_date=None, + include_current_snapshot: bool = True, +) -> dict: """頁面、AI、PPT 可共用的摘要 payload。""" - review_queue = fetch_competitor_review_queue(engine, limit=12) + review_queue = fetch_competitor_review_queue(engine, limit=12) if include_current_snapshot else [] return { - "coverage": fetch_competitor_coverage(engine), - "trend": fetch_competitor_gap_trend(engine, days=days), - "top_risks": fetch_top_competitor_risks(engine, limit=10), + "coverage": fetch_competitor_coverage(engine) if include_current_snapshot else {}, + "trend": fetch_competitor_gap_trend( + engine, + days=days, + start_date=start_date, + end_date=end_date, + ), + "top_risks": fetch_top_competitor_risks(engine, limit=10) if include_current_snapshot else [], "review_queue": review_queue, "review_decision_brief": summarize_review_decision_envelopes(review_queue, limit=5), "match_score_floor": PCHOME_MATCH_SCORE_FLOOR, + "snapshot_in_period": include_current_snapshot, + "period": { + "start_date": _date_label(start_date) if start_date else "", + "end_date": _date_label(end_date) if end_date else "", + }, } diff --git a/templates/components/_analysis_report_tabs.html b/templates/components/_analysis_report_tabs.html index c951546..def1972 100644 --- a/templates/components/_analysis_report_tabs.html +++ b/templates/components/_analysis_report_tabs.html @@ -1,24 +1,45 @@ {# 分析報表第二層分頁:保留頁面內容與圖表邏輯,只提供一致的報表切換入口。 #} {% set _analysis_active = active_page|default('') %} +{% set _period = analysis_period|default({}) %} +{% set _period_start = _period.start_date|default('') %} +{% set _period_end = _period.end_date|default('') %} +{% set _period_start_month = _period.start_month|default('') %} +{% set _period_end_month = _period.end_month|default('') %} +{% set _sales_href = '/sales_analysis' %} +{% set _daily_href = '/daily_sales' %} +{% set _growth_href = '/growth_analysis' %} +{% set _monthly_href = '/monthly_summary_analysis' %} +{% if _period_start and _period_end %} + {% set _sales_href = '/sales_analysis?start_date=' ~ _period_start ~ '&end_date=' ~ _period_end %} +{% endif %} +{% if _period.is_single_day|default(false) and _period_end %} + {% set _daily_href = '/daily_sales?date=' ~ _period_end %} +{% elif _period_end_month %} + {% set _daily_href = '/daily_sales?month=' ~ _period_end_month %} +{% endif %} +{% if _period_start_month and _period_end_month %} + {% set _growth_href = '/growth_analysis?start_month=' ~ _period_start_month ~ '&end_month=' ~ _period_end_month %} + {% set _monthly_href = '/monthly_summary_analysis?start_month=' ~ _period_start_month ~ '&end_month=' ~ _period_end_month %} +{% endif %}