diff --git a/config.py b/config.py index 5c408d6..46da5d1 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.584" +SYSTEM_VERSION = "V10.585" 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 b86bc3f..5cfe8ee 100644 --- a/routes/daily_sales_routes.py +++ b/routes/daily_sales_routes.py @@ -407,6 +407,13 @@ def prepare_daily_charts(df, selected_date, days=30): else: daily_agg['avg_price'] = 0 + if col_amount and col_amount in daily_agg.columns: + daily_agg['margin_rate'] = ( + daily_agg['profit'] / daily_agg[col_amount].replace(0, pd.NA) * 100 + ).replace([float('inf'), float('-inf')], 0).fillna(0) + else: + daily_agg['margin_rate'] = 0 + # DoD if col_amount and col_amount in daily_agg.columns: daily_agg['dod_revenue'] = daily_agg[col_amount].pct_change() * 100 @@ -448,6 +455,7 @@ def prepare_daily_charts(df, selected_date, days=30): 'revenue': daily_agg[col_amount].tolist() if col_amount and col_amount in daily_agg.columns and not daily_agg.empty else [], 'cost': daily_agg[col_cost].tolist() if col_cost and col_cost in daily_agg.columns and not daily_agg.empty else [], 'profit': daily_agg['profit'].tolist() if 'profit' in daily_agg.columns and not daily_agg.empty else [], + 'margin_rate': daily_agg['margin_rate'].tolist() if 'margin_rate' in daily_agg.columns and not daily_agg.empty else [], 'qty': daily_agg[col_qty].tolist() if col_qty and col_qty in daily_agg.columns and not daily_agg.empty else [], 'avg_price': daily_agg['avg_price'].tolist() if 'avg_price' in daily_agg.columns and not daily_agg.empty else [], 'dod_revenue': daily_agg['dod_revenue'].fillna(0).tolist() if 'dod_revenue' in daily_agg.columns and not daily_agg.empty else [], @@ -537,6 +545,52 @@ def prepare_category_summary(df, date_str=None, is_month_view=False, month_start return category_df.to_dict('records') +def prepare_category_chart(category_records, limit=12): + """將分類明細壓成圖表需要的前 N 分類,避免前端再做重資料整理。""" + if not category_records: + return { + 'labels': [], + 'revenue': [], + 'profit': [], + 'margin_rate': [], + 'qty': [], + } + + category_df = pd.DataFrame(category_records) + if category_df.empty or 'category' not in category_df.columns: + return { + 'labels': [], + 'revenue': [], + 'profit': [], + 'margin_rate': [], + 'qty': [], + } + + for column in ['revenue', 'profit', 'qty']: + if column not in category_df.columns: + category_df[column] = 0 + category_df[column] = pd.to_numeric(category_df[column], errors='coerce').fillna(0) + + grouped = ( + category_df.groupby('category', dropna=False) + .agg({'revenue': 'sum', 'profit': 'sum', 'qty': 'sum'}) + .reset_index() + .sort_values(by='revenue', ascending=False) + .head(limit) + ) + grouped['margin_rate'] = ( + grouped['profit'] / grouped['revenue'].replace(0, pd.NA) * 100 + ).replace([float('inf'), float('-inf')], 0).fillna(0) + + return { + 'labels': grouped['category'].fillna('未分類').astype(str).tolist(), + 'revenue': grouped['revenue'].tolist(), + 'profit': grouped['profit'].tolist(), + 'margin_rate': grouped['margin_rate'].tolist(), + 'qty': grouped['qty'].tolist(), + } + + # ========================================== # 頁面路由 # ========================================== @@ -558,6 +612,7 @@ def daily_sales(): error="尚未匯入當日業績資料,請先至系統設定頁面匯入 Excel。", selected_date=None, available_dates=[], current=None, dod=None, wow=None, chart_data=None, categories=None, calendar_data=None, selected_month=None, + category_chart=None, datetime_now=datetime_now_str, active_page='daily_sales') available_dates, current_fingerprint = _get_daily_sales_metadata(engine, table_name) @@ -566,6 +621,7 @@ def daily_sales(): error="資料表為空,請先匯入當日業績資料。", selected_date=None, available_dates=[], current=None, dod=None, wow=None, chart_data=None, categories=None, calendar_data=None, selected_month=None, + category_chart=None, datetime_now=datetime_now_str, active_page='daily_sales') available_dates_str = [d.strftime('%Y-%m-%d') for d in available_dates] @@ -622,6 +678,7 @@ def daily_sales(): error="所選日期區間沒有業績資料。", selected_date=None, available_dates=available_dates_str, current=None, dod=None, wow=None, chart_data=None, categories=None, calendar_data=None, selected_month=None, + category_chart=None, datetime_now=datetime_now_str, active_page='daily_sales') df = preprocess_daily_sales_data(df) @@ -678,6 +735,7 @@ def daily_sales(): month_start=month_start if is_month_view else None, month_end=month_end if is_month_view else None ) + category_chart = prepare_category_chart(category_list, limit=12) category_total_count = len(category_list) if show_all_categories: visible_categories = category_list @@ -703,6 +761,7 @@ def daily_sales(): 'chart_data': chart_data, 'competitor_intel': competitor_intel, 'categories': visible_categories, + 'category_chart': category_chart, 'category_total_count': category_total_count, 'category_visible_count': len(visible_categories), 'category_table_limit': _CATEGORY_TABLE_DEFAULT_LIMIT, @@ -734,6 +793,7 @@ def daily_sales(): is_month_view=False, chart_data=None, categories=None, + category_chart=None, calendar_data=None, marketing_data=None, selected_month=None, diff --git a/templates/components/_ewoooc_shell.html b/templates/components/_ewoooc_shell.html index 2cb85de..38a4346 100644 --- a/templates/components/_ewoooc_shell.html +++ b/templates/components/_ewoooc_shell.html @@ -9,6 +9,7 @@ #} {% set _active_page = active_page|default('') %} +{% set _is_price_workbench = _active_page == 'dashboard' and request is defined and request.args.get('filter') == 'pchome_review' %} {% set _next_run = next_run|default(None) %} {% set _session_username = session.get('username') if session is defined else None %} {% set _session_role = session.get('role') if session is defined else None %} @@ -47,15 +48,20 @@ {# 群組 1: 監控(caramel) #}
監控
- + 商品看板 01 + + + 比價工作台 + 02 + 活動看板 - 02 + 03
diff --git a/templates/daily_sales.html b/templates/daily_sales.html index 80a6d39..cfe4762 100644 --- a/templates/daily_sales.html +++ b/templates/daily_sales.html @@ -318,6 +318,28 @@ + +
+
+
+
毛利率趨勢
+
{{ chart_snapshot(chart_data.labels, chart_data.margin_rate, 'pct') }}
+
+
+
+
+
客單價 × 銷量
+
{{ chart_snapshot(chart_data.labels, chart_data.avg_price, 'currency') }}
+
+
+
+
+
分類業績 Top 12
+
{{ chart_snapshot(category_chart.labels if category_chart else [], category_chart.revenue if category_chart else [], 'currency', 12) }}
+
+
+
+ {% if competitor_intel %} {% set comp_trend = competitor_intel.trend %} @@ -338,68 +360,55 @@
-
- 競價覆蓋與風險 -
+
+ 比價決策覆蓋 + + 比價工作台 + +
+
+ +
決策支援覆蓋率 {{ comp_coverage.decision_support_rate | default(comp_coverage.decision_ready_rate | default(0)) }}%
+
+ 有效身份配對 + {{ comp_coverage.valid_matches | default(0) | number_format }} +
精準可告警覆蓋 {{ comp_coverage.decision_ready_rate | default(0) }}%
- 身份配對 - {{ comp_coverage.valid_matches | default(0) | number_format }} -
-
- 身份覆蓋率 - {{ comp_coverage.match_rate | default(0) }}% -
-
- 價格新鮮 - {{ comp_coverage.decision_ready_matches | default(comp_coverage.fresh_matches | default(0)) | number_format }} -
-
- 價格過期 + 待刷新 {{ comp_coverage.stale_matches | default(0) | number_format }}
未知新鮮度 {{ comp_coverage.unknown_freshness_matches | default(0) | number_format }}
-
- 未形成有效身份配對 - {{ comp_coverage.pending | default(0) | number_format }} -
需單位價覆核 {{ comp_coverage.unit_comparable_count | default(0) | number_format }}
-
- 型錄/任選可比 - {{ comp_coverage.catalog_comparable_count | default(0) | number_format }} -
重算待人工覆核 {{ comp_coverage.rescore_accepted_count | default(0) | number_format }}
- 人工採用 - {{ comp_coverage.manual_accept_count | default(0) | number_format }} -
-
- 人工否決 - {{ comp_coverage.manual_reject_count | default(0) | number_format }} -
-
- 人工單位價 - {{ comp_coverage.manual_unit_price_count | default(0) | number_format }} + 型錄/任選可比 + {{ comp_coverage.catalog_comparable_count | default(0) | number_format }}
+
+ 人工採用 {{ comp_coverage.manual_accept_count | default(0) | number_format }} + 人工否決 {{ comp_coverage.manual_reject_count | default(0) | number_format }} + 人工單位價 {{ comp_coverage.manual_unit_price_count | default(0) | number_format }} +
{% if competitor_intel.review_queue %}
    {% for item in competitor_intel.review_queue[:3] %} @@ -536,6 +545,7 @@ {% set daily_sales_payload = { 'chartData': chart_data, 'competitor': competitor_intel, + 'categoryChart': category_chart, 'marketing': { 'discount': { 'labels': marketing_data.discount | map(attribute='name') | list, diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index 1b7ee6e..9ef0a48 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -16,17 +16,15 @@ KPI · 最新有效價格 {{ overview.last_pchome_crawled or '待刷新' }}
-
+
決策支援覆蓋率
{{ overview.decision_support_rate | default(overview.decision_ready_rate | default(0)) }}%
-
- {{ overview.decision_support_count | default(overview.decision_ready_count | default(0)) | number_format }} / {{ overview.total_active | default(total_products) | number_format }} ACTIVE - · 精準可用 {{ overview.decision_ready_rate | default(0) }}% - · 型錄可比 {{ overview.catalog_comparable_count | default(0) | number_format }} - · 單位價 {{ overview.unit_comparable_count | default(0) | number_format }} - · 身份 {{ overview.identity_coverage_rate | default(overview.match_rate | default(0)) }}% - · 過期 {{ overview.stale_match_count | default(0) | number_format }} - · 未設到期 {{ overview.unknown_freshness_count | default(0) | number_format }} +
可直接支援調價、挑品、簡報的有效比價資料
+
+ 支援{{ overview.decision_support_count | default(overview.decision_ready_count | default(0)) | number_format }} + ACTIVE{{ overview.total_active | default(total_products) | number_format }} + 身份{{ overview.identity_coverage_rate | default(overview.match_rate | default(0)) }}% + 型錄{{ overview.catalog_comparable_count | default(0) | number_format }}
-
+
比價覆核
{{ overview.review_queue_count | default(0) | number_format }}
-
- 重算待覆核 {{ overview.rescore_accepted_count | default(0) | number_format }} - · 需單位價 {{ overview.unit_comparable_count | default(0) | number_format }} - · 人工閉環 {{ overview.manual_closed_count | default(0) | number_format }} - · 待補抓 {{ overview.pending_match_count | default(0) | number_format }} -
-
-
-
最新有效價格抓取
-
{{ overview.last_pchome_crawled or '待刷新' }}
-
- 新鮮率 {{ overview.fresh_match_rate | default(0) }}% - · 待刷新 {{ overview.stale_match_count | default(0) | number_format }} - · 未設到期 {{ overview.unknown_freshness_count | default(0) | number_format }} +
+ 重算待覆核{{ overview.rescore_accepted_count | default(0) | number_format }} + 需單位價{{ overview.unit_comparable_count | default(0) | number_format }} + 待補抓{{ overview.pending_match_count | default(0) | number_format }} + 新鮮{{ overview.fresh_match_rate | default(0) }}%
@@ -75,8 +64,13 @@
PCHOME MATCH BACKFILL
PChome 比價補強產線
-
- 待刷新 {{ overview.stale_match_count | default(0) | number_format }} · 未設到期 {{ overview.unknown_freshness_count | default(0) | number_format }} · 待補抓 {{ overview.pending_match_count | default(0) | number_format }} · 待處理覆核 {{ overview.review_queue_count | default(0) | number_format }} · 人工閉環 {{ overview.manual_closed_count | default(0) | number_format }} · 單位價 {{ overview.unit_comparable_count | default(0) | number_format }} +
刷新過期、補抓未配對、清理近門檻候選,目標是提高可決策覆蓋率。
+
+ 待刷新 {{ overview.stale_match_count | default(0) | number_format }} + 未設到期 {{ overview.unknown_freshness_count | default(0) | number_format }} + 待補抓 {{ overview.pending_match_count | default(0) | number_format }} + 覆核 {{ overview.review_queue_count | default(0) | number_format }} + 單位價 {{ overview.unit_comparable_count | default(0) | number_format }}
+
+
+ COVERAGE WORKFLOW + 提升決策支援覆蓋率 + 先處理最能轉成可決策資料的隊列,避免盲目降低門檻。 +
+
+ + + + 03 + 單位價覆核 + {{ overview.unit_comparable_count | default(0) | number_format }} 筆需判斷容量、入數、組合價 + + + 04 + 採用/否決候選 + {{ overview.rescore_accepted_count | default(0) | number_format }} 筆近門檻候選等待確認 + +
+
diff --git a/web/static/css/page-daily-sales.css b/web/static/css/page-daily-sales.css index 606c5ca..1557be4 100644 --- a/web/static/css/page-daily-sales.css +++ b/web/static/css/page-daily-sales.css @@ -32,7 +32,7 @@ align-items: center; gap: 10px; font-family: var(--momo-font-family); - font-size: 26px; + font-size: 1.8rem; font-weight: 800; color: var(--momo-page-ink, var(--momo-text-primary)); line-height: var(--momo-line-height-tight); @@ -576,6 +576,11 @@ background: color-mix(in srgb, var(--momo-text-primary) 5%, var(--momo-bg-surface)); border-radius: 4px; } + + .chart-container, + .chart-container--sm { + height: 280px; + } } @media (max-width: 767px) { @@ -843,6 +848,27 @@ overflow-wrap: anywhere; } +.daily-competitor-closure { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: -4px 0 12px; +} + +.daily-competitor-closure span { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 3px 8px; + color: var(--momo-text-secondary); + background: var(--momo-bg-paper); + border: 1px solid var(--momo-border-light); + border-radius: var(--momo-radius-pill); + font-size: 0.72rem; + font-weight: 800; + white-space: nowrap; +} + .daily-competitor-risk-list { display: grid; gap: 8px; @@ -944,6 +970,11 @@ height: 320px; } +.chart-container--sm { + height: 210px; + margin-bottom: 14px; +} + .chart-container canvas { display: block; width: 100% !important; diff --git a/web/static/css/page-dashboard-v2.css b/web/static/css/page-dashboard-v2.css index 83e44af..9a2eb0c 100644 --- a/web/static/css/page-dashboard-v2.css +++ b/web/static/css/page-dashboard-v2.css @@ -60,7 +60,7 @@ .dashboard-kpi-grid { display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); overflow: hidden; background: var(--momo-bg-surface); border: 1px solid var(--momo-border-light); @@ -120,7 +120,7 @@ .dashboard-kpi-value { margin-bottom: 8px; color: var(--momo-text-primary); - font-size: 34px; + font-size: 1.85rem; font-weight: 800; letter-spacing: 0; line-height: 1; @@ -153,6 +153,59 @@ font-size: 11px; } + .dashboard-kpi-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + margin-top: 10px; + } + + .dashboard-kpi-metrics span, + .dashboard-kpi-metrics a { + display: grid; + gap: 2px; + min-width: 0; + padding: 6px 8px; + color: var(--momo-text-secondary); + background: color-mix(in srgb, var(--momo-bg-paper) 86%, transparent); + border: 1px solid var(--momo-border-light); + border-radius: 6px; + text-decoration: none; + } + + .dashboard-kpi-metrics em { + overflow: hidden; + color: var(--momo-text-tertiary); + font-size: 9px; + font-style: normal; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + text-overflow: ellipsis; + white-space: nowrap; + } + + .dashboard-kpi-metrics strong { + overflow: hidden; + color: var(--momo-text-primary); + font-size: 12px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; + } + + .dashboard-kpi.is-accent .dashboard-kpi-metrics span, + .dashboard-kpi.is-accent .dashboard-kpi-metrics a { + color: rgba(250, 247, 240, 0.76); + background: rgba(250, 247, 240, 0.08); + border-color: rgba(250, 247, 240, 0.16); + } + + .dashboard-kpi.is-accent .dashboard-kpi-metrics em, + .dashboard-kpi.is-accent .dashboard-kpi-metrics strong { + color: rgba(250, 247, 240, 0.86); + } + .dashboard-kpi-sub-link { color: inherit; font-weight: 800; @@ -216,6 +269,33 @@ overflow-wrap: anywhere; } + .dashboard-backfill-pills { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 8px; + } + + .dashboard-backfill-pills span { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 22px; + padding: 3px 7px; + color: var(--momo-text-secondary); + background: var(--momo-bg-paper); + border: 1px solid var(--momo-border-light); + border-radius: var(--momo-radius-pill); + font-size: 10px; + font-weight: 800; + white-space: nowrap; + } + + .dashboard-backfill-pills strong { + color: var(--momo-text-primary); + font-weight: 800; + } + .dashboard-backfill-progress { position: relative; width: 100%; @@ -230,13 +310,13 @@ position: absolute; inset: 0 auto 0 0; width: 0%; - background: linear-gradient(90deg, var(--momo-warm-caramel), var(--momo-success)); + background: var(--momo-warm-caramel); transition: width 240ms ease; } .dashboard-backfill-card[data-status="failed"] .dashboard-backfill-progress span, .dashboard-backfill-card[data-status="stale"] .dashboard-backfill-progress span { - background: linear-gradient(90deg, var(--momo-danger), var(--momo-warm-rust)); + background: var(--momo-danger); } .dashboard-backfill-status { @@ -252,6 +332,97 @@ min-width: 0; } + .dashboard-decision-workbench { + display: grid; + grid-template-columns: minmax(220px, 0.8fr) minmax(0, 2.2fr); + gap: 12px; + margin-top: 12px; + padding: 14px 16px; + background: var(--momo-bg-surface); + border: 1px solid var(--momo-border-light); + border-radius: 8px; + } + + .dashboard-decision-workbench__head { + display: grid; + gap: 4px; + align-content: start; + min-width: 0; + } + + .dashboard-decision-workbench__head strong { + color: var(--momo-text-primary); + font-size: 15px; + font-weight: 800; + line-height: 1.25; + } + + .dashboard-decision-workbench__head em { + color: var(--momo-text-secondary); + font-size: 11px; + font-style: normal; + line-height: 1.5; + } + + .dashboard-decision-lanes { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + min-width: 0; + } + + .dashboard-decision-lane { + display: grid; + grid-template-rows: auto auto 1fr; + gap: 5px; + min-width: 0; + min-height: 112px; + padding: 10px; + color: var(--momo-text-primary); + background: var(--momo-bg-paper); + border: 1px solid var(--momo-border-light); + border-radius: 8px; + text-align: left; + text-decoration: none; + transition: var(--momo-transition-base); + } + + button.dashboard-decision-lane { + font: inherit; + cursor: pointer; + } + + .dashboard-decision-lane:hover { + color: var(--momo-text-primary); + border-color: rgba(190, 106, 45, 0.38); + background: color-mix(in srgb, var(--momo-warm-caramel) 8%, var(--momo-bg-paper)); + } + + .dashboard-decision-lane span { + color: var(--momo-text-tertiary); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.08em; + } + + .dashboard-decision-lane strong { + color: var(--momo-text-primary); + font-size: 13px; + font-weight: 800; + line-height: 1.25; + } + + .dashboard-decision-lane em { + display: -webkit-box; + overflow: hidden; + color: var(--momo-text-secondary); + font-size: 11px; + font-style: normal; + line-height: 1.45; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + .dashboard-focus-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -1188,6 +1359,14 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } + .dashboard-decision-workbench { + grid-template-columns: 1fr; + } + + .dashboard-decision-lanes { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .dashboard-ai-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -1236,6 +1415,11 @@ } .dashboard-kpi:nth-last-child(-n + 2) { + border-bottom: 1px solid var(--momo-border-light); + } + + .dashboard-kpi:last-child { + grid-column: 1 / -1; border-bottom: 0; } @@ -1254,6 +1438,21 @@ color: var(--momo-text-primary); } + .dashboard-kpi.is-accent .dashboard-kpi-metrics span, + .dashboard-kpi.is-accent .dashboard-kpi-metrics a { + color: var(--momo-text-secondary); + background: var(--momo-bg-paper); + border-color: var(--momo-border-light); + } + + .dashboard-kpi.is-accent .dashboard-kpi-metrics em { + color: var(--momo-text-tertiary); + } + + .dashboard-kpi.is-accent .dashboard-kpi-metrics strong { + color: var(--momo-text-primary); + } + .dashboard-kpi-label { margin-bottom: 7px; font-size: 9px; @@ -1288,6 +1487,14 @@ width: 100%; } + .dashboard-decision-lanes { + grid-template-columns: 1fr; + } + + .dashboard-decision-lane { + min-height: auto; + } + .dashboard-search, .dashboard-select, .dashboard-segmented { diff --git a/web/static/js/page-daily-sales.js b/web/static/js/page-daily-sales.js index 56ea9fa..4544d30 100644 --- a/web/static/js/page-daily-sales.js +++ b/web/static/js/page-daily-sales.js @@ -78,10 +78,17 @@ const cd = dailySalesData.chartData || {}; const competitor = dailySalesData.competitor || {}; const competitorTrend = competitor.trend || {}; + const competitorCoverage = competitor.coverage || {}; + const categoryChart = dailySalesData.categoryChart || {}; const safe = { labels: cd.labels || [], revenue: cd.revenue || [], profit: cd.profit || [], + margin_rate: cd.margin_rate || (cd.revenue || []).map((revenue, index) => { + const rev = Number(revenue || 0); + if (!rev) return 0; + return Number((cd.profit || [])[index] || 0) / rev * 100; + }), avg_price: cd.avg_price || [], qty: cd.qty || [], dod_revenue: cd.dod_revenue || [], @@ -214,6 +221,19 @@ limit: 14 }); } + renderHtmlBars('marginChart', safe.labels, safe.margin_rate, { mode: 'pct', limit: 14 }); + renderHtmlBars('avgQtyChart', safe.labels, safe.avg_price, { mode: 'currency', limit: 14 }); + if (categoryChart.labels) { + renderHtmlBars('categoryRevenueChart', categoryChart.labels, categoryChart.revenue, { + mode: 'currency', + horizontal: true + }); + } + const coverage = buildCoverageFunnel(); + renderHtmlBars('competitorCoverageChart', coverage.labels, coverage.values, { + mode: 'number', + horizontal: true + }); } function hasSeriesData(labels, ...seriesList) { @@ -264,6 +284,20 @@ }; } + function buildCoverageFunnel() { + return { + labels: ['決策支援', '精準告警', '身份配對', '待刷新', '單位價', '待覆核'], + values: [ + competitorCoverage.decision_support_count ?? competitorCoverage.decision_ready_count ?? 0, + competitorCoverage.decision_ready_matches ?? competitorCoverage.fresh_matches ?? 0, + competitorCoverage.valid_matches ?? 0, + competitorCoverage.stale_matches ?? competitorCoverage.stale_match_count ?? 0, + competitorCoverage.unit_comparable_count ?? 0, + competitorCoverage.rescore_accepted_count ?? competitorCoverage.review_queue_count ?? 0 + ].map(value => Number(value || 0)) + }; + } + // -- Chart 1: trend (multi-line) -------------------------------------- function renderTrend() { const el = document.getElementById('trendChart'); @@ -440,7 +474,175 @@ })); } - // -- Chart 5: Competitor gap pressure -------------------------------- + // -- Chart 5: Margin rate trend -------------------------------------- + function renderMarginRate() { + const el = document.getElementById('marginChart'); + if (!el) return; + if (!hasSeriesData(safe.labels, safe.margin_rate)) { + renderChartEmpty('marginChart', '目前沒有可計算的毛利率序列。'); + return; + } + + rememberChart(new Chart(el, { + type: 'line', + data: { + labels: safe.labels, + datasets: [ + makeLineDataset('毛利率', safe.margin_rate, palette.olive), + { + label: '30 日均線', + data: safe.margin_rate.map((_, index, values) => { + const start = Math.max(0, index - 6); + const sample = values.slice(start, index + 1).map(Number).filter(Number.isFinite); + return sample.length ? sample.reduce((sum, value) => sum + value, 0) / sample.length : 0; + }), + borderColor: palette.honey, + backgroundColor: rgba(palette.honey, 0.1), + borderWidth: 1.8, + borderDash: [5, 5], + tension: 0.28, + pointRadius: 0 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { + legend: { position: isCompact() ? 'bottom' : 'top' }, + tooltip: { callbacks: { label: ctx => `${ctx.dataset.label}: ${formatMetric(ctx.parsed.y, 'pct')}` } } + }, + scales: { + x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } }, + y: axisPercent('毛利率') + } + } + })); + } + + // -- Chart 6: AOV x Qty ---------------------------------------------- + function renderAvgQty() { + const el = document.getElementById('avgQtyChart'); + if (!el) return; + if (!hasSeriesData(safe.labels, safe.avg_price, safe.qty)) { + renderChartEmpty('avgQtyChart', '目前沒有客單價或銷量序列。'); + return; + } + + rememberChart(new Chart(el, { + type: 'bar', + data: { + labels: safe.labels, + datasets: [ + { + label: '銷量', + data: safe.qty, + backgroundColor: rgba(palette.olive, 0.24), + borderColor: palette.olive, + borderWidth: 1, + maxBarThickness: 24, + yAxisID: 'y' + }, + { + label: '客單價', + data: safe.avg_price, + type: 'line', + borderColor: palette.mahogany, + backgroundColor: rgba(palette.mahogany, 0.12), + borderWidth: 2.2, + tension: 0.32, + pointRadius: 2, + yAxisID: 'y1' + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { + legend: { position: isCompact() ? 'bottom' : 'top' }, + tooltip: { + callbacks: { + label: ctx => { + const mode = ctx.dataset.yAxisID === 'y1' ? 'currency' : 'number'; + return `${ctx.dataset.label}: ${formatMetric(ctx.parsed.y, mode)}`; + } + } + } + }, + scales: { + x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } }, + y: { beginAtZero: true, title: { display: !isCompact(), text: '銷量' } }, + y1: { + position: 'right', + grid: { drawOnChartArea: false }, + ...axisMoney('客單價') + } + } + } + })); + } + + // -- Chart 7: Category revenue --------------------------------------- + function renderCategoryRevenue() { + const el = document.getElementById('categoryRevenueChart'); + if (!el) return; + if (!hasSeriesData(categoryChart.labels, categoryChart.revenue, categoryChart.profit)) { + renderChartEmpty('categoryRevenueChart', '目前沒有分類業績彙總可繪製。'); + return; + } + + rememberChart(new Chart(el, { + type: 'bar', + data: { + labels: categoryChart.labels || [], + datasets: [ + { + label: '業績', + data: categoryChart.revenue || [], + backgroundColor: rgba(palette.caramel, 0.5), + borderColor: palette.caramel, + borderWidth: 1, + maxBarThickness: 22 + }, + { + label: '毛利', + data: categoryChart.profit || [], + backgroundColor: rgba(palette.olive, 0.42), + borderColor: palette.olive, + borderWidth: 1, + maxBarThickness: 22 + } + ] + }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { position: isCompact() ? 'bottom' : 'top' }, + tooltip: { callbacks: { label: ctx => `${ctx.dataset.label}: ${formatMetric(ctx.parsed.x, 'currency')}` } } + }, + scales: { + x: axisMoney('金額'), + y: { + grid: { display: false }, + ticks: { + autoSkip: false, + callback: function (value) { + const label = this.getLabelForValue(value); + return label.length > 14 ? `${label.slice(0, 14)}…` : label; + } + } + } + } + } + })); + } + + // -- Chart 8: Competitor gap pressure -------------------------------- function renderCompetitorGap() { const el = document.getElementById('competitorGapChart'); if (!el) return; @@ -507,6 +709,59 @@ })); } + // -- Chart 9: Competitor decision coverage --------------------------- + function renderCompetitorCoverage() { + const el = document.getElementById('competitorCoverageChart'); + if (!el) return; + const coverage = buildCoverageFunnel(); + if (!hasSeriesData(coverage.labels, coverage.values)) { + renderChartEmpty('competitorCoverageChart', '目前尚未形成可繪製的比價覆蓋資料。'); + return; + } + + rememberChart(new Chart(el, { + type: 'bar', + data: { + labels: coverage.labels, + datasets: [{ + label: 'SKU 數', + data: coverage.values, + backgroundColor: [ + rgba(palette.caramel, 0.62), + rgba(palette.olive, 0.54), + rgba(palette.honey, 0.54), + rgba(palette.rust, 0.38), + rgba(palette.mahogany, 0.32), + rgba(palette.muted, 0.24) + ], + borderColor: [ + palette.caramel, + palette.olive, + palette.honey, + palette.rust, + palette.mahogany, + palette.muted + ], + borderWidth: 1, + maxBarThickness: 18 + }] + }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { callbacks: { label: ctx => `${ctx.dataset.label}: ${formatMetric(ctx.parsed.x, 'number')}` } } + }, + scales: { + x: { beginAtZero: true, ticks: { precision: 0 } }, + y: { grid: { display: false } } + } + } + })); + } + // -- Marketing charts ---------------------------------------------- function renderMarketingBar(elId, marketing, color) { const el = document.getElementById(elId); @@ -684,7 +939,11 @@ renderDod(); renderWow(); renderTop10(); + renderMarginRate(); + renderAvgQty(); + renderCategoryRevenue(); renderCompetitorGap(); + renderCompetitorCoverage(); const mk = dailySalesData.marketing || {}; if (mk.discount) renderMarketingBar('discountChart', mk.discount, palette.caramel);