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) #}
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') }}
+
+
+
+
+
+
{{ 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.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 }}
+
@@ -75,8 +64,13 @@
PCHOME MATCH BACKFILL
PChome 比價補強產線
-
@@ -101,6 +95,41 @@
+
+
+ COVERAGE WORKFLOW
+ 提升決策支援覆蓋率
+ 先處理最能轉成可決策資料的隊列,避免盲目降低門檻。
+
+
+
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);