This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user