Files
ewoooc/web/static/js/page-monthly-summary.js
ogt 911393190d
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
fix(analytics): enforce trustworthy chart rendering
2026-07-16 15:55:22 +08:00

798 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ════════════════════════════════════════════════════════
* page-monthly-summary.js — Turn C
* ────────────────────────────────────────────────────────
* monthly_summary_analysis.html 的 ECharts + DataTable 邏輯
*
* 注意:依賴 analysis-chart-theme.js 對 echarts.init 的攔截,
* 它會自動把 option.color 換成 .momo-app[data-page-group] 的
* --momo-page-chart-* 暖色調 palette因此本檔保留原 itemStyle.color
* 邏輯時,會被自動覆寫;分類/條件配色(如 BCG 象限)保持顯式指定。
* ════════════════════════════════════════════════════════ */
(function () {
'use strict';
let table;
let compareChart, yoyTrendChart, vendorRankingChart, divisionDistChart, priceRangeChart;
let bcgMatrixChart, priceVolumeScatterChart, seasonalityHeatmapChart, areaRankingChart;
let specialChart, bodyCareChart, makeupFragranceChart, privacyInfantChart;
let activeFetchController = null;
let fetchRunId = 0;
let lastMainData = null;
const lastSpecialData = new Map();
const REQUEST_TIMEOUT_MS = 20000;
const initialParams = new URLSearchParams(window.location.search);
let currentFilters = {
year: initialParams.get('year') || '',
month: initialParams.get('month') || '',
start_month: initialParams.get('start_month') || '',
end_month: initialParams.get('end_month') || '',
area: initialParams.get('area_name') || '',
vendor: initialParams.get('vendor') || '',
trade: initialParams.get('trade_type') || ''
};
const compactMediaQuery = window.matchMedia('(max-width: 768px)');
function isCompactViewport() {
return compactMediaQuery.matches;
}
function cssColor(name, fallback) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
}
function decodeText(value) {
const textarea = document.createElement('textarea');
textarea.innerHTML = String(value ?? '');
return textarea.value;
}
function safeHtmlText(value) {
const span = document.createElement('span');
span.textContent = decodeText(value);
return span.innerHTML;
}
$(function () {
compareChart = echarts.init(document.getElementById('compareChart'));
yoyTrendChart = echarts.init(document.getElementById('yoyTrendChart'));
vendorRankingChart = echarts.init(document.getElementById('vendorRankingChart'));
divisionDistChart = echarts.init(document.getElementById('divisionDistChart'));
priceRangeChart = echarts.init(document.getElementById('priceRangeChart'));
bcgMatrixChart = echarts.init(document.getElementById('bcgMatrixChart'));
priceVolumeScatterChart = echarts.init(document.getElementById('priceVolumeScatterChart'));
seasonalityHeatmapChart = echarts.init(document.getElementById('seasonalityHeatmapChart'));
areaRankingChart = echarts.init(document.getElementById('areaRankingChart'));
specialChart = echarts.init(document.getElementById('specialChart'));
bodyCareChart = echarts.init(document.getElementById('bodyCareChart'));
makeupFragranceChart = echarts.init(document.getElementById('makeupFragranceChart'));
privacyInfantChart = echarts.init(document.getElementById('privacyInfantChart'));
table = $('#summaryTable').DataTable({
language: typeof window.EwoooCDataTableLanguage === 'function'
? window.EwoooCDataTableLanguage()
: {},
autoWidth: false,
deferRender: true,
pageLength: 20,
order: [[0, 'desc'], [7, 'desc']],
columnDefs: [
{ targets: [7], className: 'text-end fw-bold' },
{ targets: 8, className: 'text-center' }
]
});
window.addEventListener('resize', () => {
[compareChart, yoyTrendChart, vendorRankingChart, divisionDistChart, priceRangeChart,
bcgMatrixChart, priceVolumeScatterChart, seasonalityHeatmapChart, areaRankingChart,
specialChart, bodyCareChart, makeupFragranceChart, privacyInfantChart]
.forEach(c => c && c.resize());
});
const refreshForViewport = () => {
if (lastMainData) updateUI(lastMainData);
lastSpecialData.forEach(({ chart, tbody, payload }) => {
renderExcelChart(chart, tbody, payload.trend, payload.period);
});
};
if (compactMediaQuery.addEventListener) {
compactMediaQuery.addEventListener('change', refreshForViewport);
} else if (compactMediaQuery.addListener) {
compactMediaQuery.addListener(refreshForViewport);
}
fetchData();
});
// ── API ───────────────────────────────────────────────
function buildFilterParams({ limit = '', areaOverride = null } = {}) {
const params = new URLSearchParams();
if (limit) params.set('limit', limit);
['year', 'month', 'start_month', 'end_month'].forEach(key => {
if (currentFilters[key]) params.set(key, currentFilters[key]);
});
const area = areaOverride === null ? currentFilters.area : areaOverride;
if (area) params.set('area_name', area);
if (currentFilters.vendor) params.set('vendor', currentFilters.vendor);
if (currentFilters.trade) params.set('trade_type', currentFilters.trade);
return params;
}
function syncFilterUrl() {
const params = buildFilterParams();
const query = params.toString();
window.history.replaceState({}, '', `${window.location.pathname}${query ? `?${query}` : ''}`);
}
function updateAnalysisTabLinks(period) {
if (!period || !period.start_date || !period.end_date) return;
const targets = {
sales: `/sales_analysis?start_date=${period.start_date}&end_date=${period.end_date}`,
daily_sales: `/daily_sales?month=${period.end_month}`,
growth: `/growth_analysis?start_month=${period.start_month}&end_month=${period.end_month}`,
monthly: `/monthly_summary_analysis?start_month=${period.start_month}&end_month=${period.end_month}`
};
Object.entries(targets).forEach(([target, href]) => {
const link = document.querySelector(`[data-analysis-target="${target}"]`);
if (link) link.setAttribute('href', href);
});
}
function setReportError(message = '') {
const state = document.getElementById('monthlyReportErrorState');
if (!state) return;
state.hidden = !message;
const detail = document.getElementById('monthlyReportErrorMessage');
if (detail) detail.textContent = message;
}
async function fetchJson(url, { signal, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
const controller = new AbortController();
const relayAbort = () => controller.abort();
if (signal) {
if (signal.aborted) controller.abort();
else signal.addEventListener('abort', relayAbort, { once: true });
}
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} finally {
window.clearTimeout(timeout);
if (signal) signal.removeEventListener('abort', relayAbort);
}
}
async function fetchData() {
const runId = ++fetchRunId;
if (activeFetchController) activeFetchController.abort();
const controller = new AbortController();
activeFetchController = controller;
document.documentElement.dataset.monthlyCharts = 'loading';
setReportError();
$('#loadingOverlay').css('display', 'flex');
const url = `/api/monthly_summary_data?${buildFilterParams({ limit: '2000' }).toString()}`;
const specialPromise = fetchSpecialChartData(controller.signal, runId);
try {
const data = await fetchJson(url, { signal: controller.signal });
if (runId !== fetchRunId) return;
if (data.status !== 'success') throw new Error(data.message || '月結資料回應失敗');
lastMainData = data;
updateUI(data);
updateFilters(data.filters);
updateAnalysisTabLinks(data.period);
document.documentElement.dataset.monthlyCharts = 'ready';
} catch (error) {
if (runId !== fetchRunId) return;
const timedOut = error && error.name === 'AbortError';
const message = timedOut
? '月結資料載入逾時,已停止本次請求;請稍後重新整理。'
: '月結資料暫時無法載入,圖表已停止更新。';
setReportError(message);
document.documentElement.dataset.monthlyCharts = 'error';
document.documentElement.dataset.monthlyChartsError = error && error.message ? error.message : String(error);
console.error('[monthly-summary] main data failed', error);
} finally {
if (runId === fetchRunId) $('#loadingOverlay').hide();
}
await specialPromise;
if (runId === fetchRunId) {
activeFetchController = null;
}
}
async function fetchSpecialChartData(signal, runId) {
document.documentElement.dataset.monthlySpecialCharts = 'loading';
const pairs = [
['開架保養,臉部清潔', specialChart, 'specialChartTableBody'],
['身體保養', bodyCareChart, 'bodyCareChartTableBody'],
['彩妝/指彩,精油擴香', makeupFragranceChart, 'makeupFragranceChartTableBody'],
['私密保養,嬰幼洗沐', privacyInfantChart, 'privacyInfantChartTableBody']
];
await Promise.all(pairs.map(async ([area, chart, tbody]) => {
const params = buildFilterParams({ areaOverride: area });
try {
const payload = await fetchJson(`/api/monthly_summary_trend?${params.toString()}`, {
signal,
timeoutMs: 12000
});
if (runId !== fetchRunId) return;
if (payload.status !== 'success') throw new Error(payload.message || '區域趨勢回應失敗');
lastSpecialData.set(area, { chart, tbody, payload });
renderExcelChart(chart, tbody, payload.trend, payload.period);
} catch (error) {
if (runId !== fetchRunId || (signal && signal.aborted)) return;
renderChartEmpty(chart, tbody, '此區域趨勢暫時無法載入。');
console.error('[monthly-summary] special trend failed', area, error);
}
}));
if (runId === fetchRunId) document.documentElement.dataset.monthlySpecialCharts = 'ready';
}
// ── Filter ────────────────────────────────────────────
function selectFilter(type, value) {
currentFilters[type] = value;
if (type === 'year' || type === 'month') {
currentFilters.start_month = '';
currentFilters.end_month = '';
}
const btnMap = { year:'btnYear', month:'btnMonth', area:'btnArea', vendor:'btnVendor', trade:'btnTrade' };
const defaultMap = { year:'最新年份', month:'全部月份', area:'所有區域', vendor:'所有廠商', trade:'所有類別' };
$(`#${btnMap[type]}`).text(decodeText(value || defaultMap[type]));
syncFilterUrl();
fetchData();
}
window.selectFilter = selectFilter;
function applyMonthRange() {
const start = document.getElementById('periodStartMonth').value;
const end = document.getElementById('periodEndMonth').value;
currentFilters.start_month = start || end;
currentFilters.end_month = end || start;
currentFilters.year = '';
currentFilters.month = '';
syncFilterUrl();
fetchData();
}
window.applyMonthRange = applyMonthRange;
function resetMonthRange() {
currentFilters.year = '';
currentFilters.month = '';
currentFilters.start_month = '';
currentFilters.end_month = '';
document.getElementById('periodStartMonth').value = '';
document.getElementById('periodEndMonth').value = '';
syncFilterUrl();
fetchData();
}
window.resetMonthRange = resetMonthRange;
function filterList(input) {
const filter = input.value.toLowerCase();
const list = input.closest('ul').querySelectorAll('li:not(:first-child)');
list.forEach(li => {
const t = (li.textContent || li.innerText).toLowerCase();
li.style.display = t.indexOf(filter) > -1 ? '' : 'none';
});
}
window.filterList = filterList;
function updateFilters(f) {
const populate = (id, list, type) => {
const el = $(id);
if (el.children().length > 1) return;
list.forEach(value => {
const item = document.createElement('li');
const link = document.createElement('a');
link.className = 'dropdown-item';
link.href = '#';
link.textContent = decodeText(value);
link.addEventListener('click', event => {
event.preventDefault();
selectFilter(type, value);
});
item.appendChild(link);
el.append(item);
});
};
populate('#listYear', f.years, 'year');
populate('#listMonth', f.months.map(String).sort((a,b) => a - b), 'month');
populate('#listArea', f.areas, 'area');
populate('#listTrade', f.trades, 'trade');
populate('#listVendor', f.vendors, 'vendor');
const labels = {
year: currentFilters.year || '最新年份',
month: currentFilters.month || '全部月份',
area: currentFilters.area || '所有區域',
vendor: currentFilters.vendor || '所有廠商',
trade: currentFilters.trade || '所有類別'
};
$('#btnYear').text(decodeText(labels.year));
$('#btnMonth').text(decodeText(labels.month));
$('#btnArea').text(decodeText(labels.area));
$('#btnVendor').text(decodeText(labels.vendor));
$('#btnTrade').text(decodeText(labels.trade));
}
// ── UI updates ────────────────────────────────────────
function updateUI(data) {
const period = data.period || {};
$('#activeReportPeriod').text(period.label || '全部資料');
const periodEmpty = Number(data.total_rows || 0) === 0;
const periodEmptyState = document.getElementById('monthlyPeriodEmptyState');
if (periodEmptyState) {
periodEmptyState.hidden = !periodEmpty;
const periodEmptyLabel = document.getElementById('monthlyPeriodEmptyLabel');
if (periodEmptyLabel) periodEmptyLabel.textContent = period.label || '所選期間';
}
if (!currentFilters.year && !currentFilters.month && !currentFilters.start_month && period.start_month) {
currentFilters.start_month = period.start_month;
currentFilters.end_month = period.end_month;
syncFilterUrl();
}
if (period.start_month) document.getElementById('periodStartMonth').value = period.start_month;
if (period.end_month) document.getElementById('periodEndMonth').value = period.end_month;
$('#totalRows').text(data.total_rows.toLocaleString());
$('#totalMonths').text(data.total_months);
const k = data.kpis || {};
$('#kpiSales').text('$' + (k.sales || 0).toLocaleString());
$('#kpiProfit').text('$' + (k.profit || 0).toLocaleString());
$('#kpiMargin').text(k.margin || '-');
$('#kpiVol').text((k.vol || 0).toLocaleString());
if (k.sales_yoa > 0) {
const yoy = ((k.sales - k.sales_yoa) / k.sales_yoa * 100).toFixed(1);
const cls = yoy >= 0 ? 'text-danger' : 'text-success';
$('#kpiSalesYoY').html(`<span class="${cls} fw-bold"><i class="fas fa-caret-up"></i> ${yoy}%</span> vs 去年同期`);
} else { $('#kpiSalesYoY').text('-'); }
table.clear();
(data.rows || []).forEach(row => {
const sales = row.sales_amt_curr || 0;
const prevYear = row.sales_amt_yoa || 0;
let yoyHtml = '-';
if (prevYear > 0) {
const yoy = ((sales - prevYear) / prevYear * 100).toFixed(1);
const cls = yoy >= 0 ? 'text-danger' : 'text-success';
yoyHtml = `<span class="${cls} fw-bold">${yoy > 0 ? '+' : ''}${yoy}%</span>`;
}
table.row.add([
`${row.year}/${row.month}`, safeHtmlText(row.division), safeHtmlText(row.area_name || '-'), safeHtmlText(row.pm_name || '-'),
`<span class="text-primary fw-600">${safeHtmlText(row.brand_name)}</span>`,
`<span class="small text-muted">${safeHtmlText(row.vendor_name)}</span>`,
`<span class="badge bg-light text-dark border">${safeHtmlText(row.trade_type || '-')}</span>`,
sales.toLocaleString(), yoyHtml
]);
});
table.draw();
renderExcelChart(compareChart, 'compareChartTableBody', data.trend, period);
updateHighlights(data.highlights);
renderYoYTrendChart(yoyTrendChart, 'yoyTrendChartTableBody', data.yoy_trend);
renderVendorRankingChart(vendorRankingChart, 'vendorRankingChartTableBody', data.vendor_ranking, period);
renderDivisionDistChart(divisionDistChart, 'divisionDistChartTableBody', data.division_dist, period);
renderPriceRangeChart(priceRangeChart, 'priceRangeChartTableBody', data.price_contribution, period);
renderBCGMatrixChart(bcgMatrixChart, data.bcg_data);
renderScatterChart(priceVolumeScatterChart, data.bcg_data);
renderSeasonalityHeatmap(seasonalityHeatmapChart, data.heatmap_data, period);
renderAreaRankingChart(areaRankingChart, 'areaRankingChartTableBody', data.area_ranking, period);
}
function renderChartEmpty(chart, tableBodyId, message) {
if (!chart) return;
chart.clear();
chart.setOption({
title: {
text: '尚無可繪製資料',
subtext: message,
left: 'center',
top: 'middle',
textStyle: { color: '#3f3a34', fontSize: 15, fontWeight: 700 },
subtextStyle: { color: '#746b61', fontSize: 12, lineHeight: 18 }
},
series: []
});
if (tableBodyId) {
$(`#${tableBodyId}`).html(`<tr><td class="text-muted py-3">${message}</td></tr>`);
}
}
// ── Highlights (Top 3) ────────────────────────────────
function updateHighlights(h) {
if (!h || (!h.rev_top.length && !h.profit_top.length)) { $('#highlightsRow').hide(); return; }
$('#highlightsRow').show();
const r = (id, list, prefix='') => {
const t = $(`#${id}`); t.empty();
list.forEach((it, i) => t.append(`<tr><td class="ps-3"><span class="badge bg-secondary rounded-pill me-2">${i+1}</span>${safeHtmlText(it.name)}</td><td class="text-end pe-3 fw-bold">${prefix}${it.value.toLocaleString()}</td></tr>`));
};
r('revHighlightsBody', h.rev_top, '$');
r('profitHighlightsBody', h.profit_top, '$');
r('volHighlightsBody', h.vol_top, '');
}
// ── Excel-style 主對比圖 ─────────────────────────────
function renderExcelChart(chart, tableBodyId, trend, period = {}) {
if (!trend || !trend.length) {
renderChartEmpty(chart, tableBodyId, '所選期間與去年同期皆無月結資料。');
return;
}
chart.clear();
const compact = isCompactViewport();
const years = [...new Set(trend.map(t => t.date.split('/')[0]))].sort().reverse();
const currYear = String(period.current_year || years[0] || new Date().getFullYear());
const prevYear = String(period.previous_year || (parseInt(currYear) - 1));
const currentSeriesLabel = period.label || currYear;
const previousSeriesLabel = period.previous_label || prevYear;
const hasPeriodRoles = trend.some(t => t.period_role && Number.isInteger(t.slot));
const slots = hasPeriodRoles
? [...new Set(trend.map(t => t.slot))].sort((a, b) => a - b)
: [...new Set(trend.map(t => parseInt(t.date.split('/')[1], 10)))].sort((a, b) => a - b);
const months = slots.map(slot => {
const item = trend.find(t => (hasPeriodRoles ? t.slot : parseInt(t.date.split('/')[1], 10)) === slot);
return hasPeriodRoles ? item.slot_label : String(slot);
});
const currData = new Array(months.length).fill(0);
const prevData = new Array(months.length).fill(0);
const yoyData = new Array(months.length).fill(0);
trend.forEach(t => {
const [y, m] = t.date.split('/');
const slot = hasPeriodRoles ? t.slot : parseInt(m, 10);
const monthIndex = slots.indexOf(slot);
if (monthIndex < 0) return;
if ((hasPeriodRoles && t.period_role === 'current') || (!hasPeriodRoles && y === currYear)) currData[monthIndex] = t.sales;
if ((hasPeriodRoles && t.period_role === 'previous') || (!hasPeriodRoles && y === prevYear)) prevData[monthIndex] = t.sales;
});
for (let i = 0; i < months.length; i++) {
if (prevData[i] > 0) yoyData[i] = parseFloat(((currData[i] - prevData[i]) / prevData[i] * 100).toFixed(1));
}
const C1 = cssColor('--momo-warm-caramel', '#c96442');
const C2 = cssColor('--momo-warm-olive', '#6f7a4a');
const CM = cssColor('--momo-text-tertiary', '#94a3b8');
const tableHtml = `
<tr><td class="ms-cmp__hd">月份</td>${months.map(m => `<td class="ms-cmp__mh">${m}</td>`).join('')}</tr>
<tr><td class="text-start ps-2"><span class="ms-cmp__sw" style="background:${C1}"></span>${currentSeriesLabel}</td>${currData.map(v => `<td>${v > 0 ? v.toLocaleString() : '-'}</td>`).join('')}</tr>
<tr><td class="text-start ps-2"><span class="ms-cmp__sw" style="background:${C2}"></span>${previousSeriesLabel}</td>${prevData.map(v => `<td>${v > 0 ? v.toLocaleString() : '-'}</td>`).join('')}</tr>
<tr><td class="text-start ps-2"><i class="fas fa-minus" style="color:${CM};margin-right:5px;"></i>YOY</td>${yoyData.map((v, i) => `<td>${(currData[i] > 0 || prevData[i] > 0) ? v + '%' : '-'}</td>`).join('')}</tr>`;
$(`#${tableBodyId}`).html(tableHtml);
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: [currentSeriesLabel, previousSeriesLabel, 'YOY'], bottom: 0, show: false },
grid: {
left: compact ? '42' : '80',
right: compact ? '16' : '50',
top: compact ? '26' : '50',
bottom: compact ? '28' : '20'
},
xAxis: {
type: 'category',
data: months,
axisLabel: { show: compact, interval: 0, fontSize: 10 },
axisTick: { show: true }
},
yAxis: [
{ type: 'value', name: compact ? '' : '業績', axisLabel: { formatter: v => compact ? (v/10000).toFixed(0) + '萬' : v.toLocaleString(), fontSize: compact ? 10 : 12 }, splitLine: { lineStyle: { type: 'dashed' } } },
{ type: 'value', name: compact ? '' : 'YoY', axisLabel: { formatter: '{value}%', fontSize: compact ? 10 : 12 }, splitLine: { show: false } }
],
series: [
{ name: currentSeriesLabel, type: 'bar', data: currData, barMaxWidth: compact ? 12 : 20,
label: { show: !compact, position: 'top', fontSize: 16, fontWeight: 'bold', formatter: p => p.value ? (p.value/10000).toFixed(0) + '萬' : '' } },
{ name: previousSeriesLabel, type: 'bar', data: prevData, barMaxWidth: compact ? 12 : 20,
label: { show: !compact, position: 'top', fontSize: 16, fontWeight: 'bold', formatter: p => p.value ? (p.value/10000).toFixed(0) + '萬' : '' } },
{ name: 'YOY', type: 'line', yAxisIndex: 1, data: yoyData, smooth: true,
lineStyle: { width: compact ? 1.5 : 2 }, symbol: 'circle', symbolSize: compact ? 4 : 6,
label: { show: !compact, position: 'bottom', fontSize: 16, fontWeight: 'bold', formatter: p => p.value ? p.value + '%' : '' } }
]
});
}
// ── YoY Trend ─────────────────────────────────────────
function renderYoYTrendChart(chart, tableId, data) {
if (!data || !data.length) {
renderChartEmpty(chart, tableId, '所選期間尚無本期業績,未以其他月份回填。');
return;
}
chart.clear();
const compact = isCompactViewport();
const dates = data.map(d => d.date.split('/')[1] + '月');
const currData = data.map(d => d.curr);
const yoaData = data.map(d => d.yoa);
const years = [...new Set(data.map(d => d.date.split('/')[0]))];
const currYearStr = years[0] || '本期';
let html = `
<tr><td class="ms-cmp__hd">月份</td>${dates.map(m => `<td class="ms-cmp__mh">${m}</td>`).join('')}</tr>
<tr><td class="text-start ps-2"><span class="ms-cmp__sw ms-cmp__sw--accent"></span>${currYearStr}</td>${currData.map(v => `<td>${v > 0 ? v.toLocaleString() : '-'}</td>`).join('')}</tr>
<tr><td class="text-start ps-2"><span class="ms-cmp__sw ms-cmp__sw--muted"></span>去年同期</td>${yoaData.map(v => `<td>${v > 0 ? v.toLocaleString() : '-'}</td>`).join('')}</tr>`;
$(`#${tableId}`).html(html);
chart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: compact ? '42' : '60', right: compact ? '14' : '30', top: compact ? '24' : '40', bottom: compact ? '26' : '20' },
legend: { data: [currYearStr, '去年同期'], bottom: 0, show: !compact },
xAxis: { type: 'category', data: dates, axisLabel: { fontSize: compact ? 10 : 12 } },
yAxis: { type: 'value', axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬', fontSize: compact ? 10 : 12 } },
series: [
{ name: currYearStr, type: 'line', data: currData, lineStyle: { width: 3 }, showSymbol: false, areaStyle: { opacity: 0.12 } },
{ name: '去年同期', type: 'line', data: yoaData, lineStyle: { type: 'dashed' }, showSymbol: false }
]
});
}
// ── 類別分佈 (雙圓餅) ─────────────────────────────────
function renderDivisionDistChart(chart, _tableId, data, period = {}) {
if (!data || !data.length) {
renderChartEmpty(chart, _tableId, '所選期間與去年同期皆無類別業績。');
return;
}
chart.clear();
const compact = isCompactViewport();
const previousLabel = period.previous_label || `${period.previous_year || ''}`;
const currentLabel = period.label || `${period.current_year || ''}`;
const pie = key => data.map(d => ({ name: decodeText(d.name), value: d[key] || 0 })).filter(d => d.value > 0);
chart.setOption({
title: [
{ text: previousLabel, left: '23%', top: compact ? '2%' : '5%', textAlign: 'center', textStyle: { fontSize: compact ? 12 : 16, fontWeight: 'bold' } },
{ text: currentLabel, left: '73%', top: compact ? '2%' : '5%', textAlign: 'center', textStyle: { fontSize: compact ? 12 : 16, fontWeight: 'bold' } }
],
tooltip: { trigger: 'item', formatter: p => `<strong>${safeHtmlText(p.name)}</strong><br/>業績: ${(p.value/10000).toFixed(0)}萬<br/>佔比: ${p.percent.toFixed(1)}%` },
legend: { type: 'scroll', orient: 'horizontal', bottom: 0, show: !compact, textStyle: { fontSize: 12, fontWeight: 'bold' } },
series: [
{ name: previousLabel, type: 'pie', radius: compact ? ['36%', '58%'] : ['30%', '60%'], center: ['25%', compact ? '53%' : '55%'], data: pie('sales_previous'),
label: { show: !compact, fontSize: 11, fontWeight: 'bold', formatter: p => `${p.name}\n${(p.value/10000).toFixed(0)}\n${p.percent.toFixed(1)}%` } },
{ name: currentLabel, type: 'pie', radius: compact ? ['36%', '58%'] : ['30%', '60%'], center: ['75%', compact ? '53%' : '55%'], data: pie('sales_current'),
label: { show: !compact, fontSize: 11, fontWeight: 'bold', formatter: p => `${p.name}\n${(p.value/10000).toFixed(0)}\n${p.percent.toFixed(1)}%` } }
]
});
}
// ── 價格帶 ────────────────────────────────────────────
function renderPriceRangeChart(chart, tableId, data, period = {}) {
if (!data || !data.length) {
renderChartEmpty(chart, tableId, '所選期間與去年同期皆無價格帶資料。');
return;
}
chart.clear();
const compact = isCompactViewport();
const order = ['0-499','500-999','1,000-1,999','2,000-4,999','5,000-9,999','10,000+'];
data.sort((a,b) => order.indexOf(a.range) - order.indexOf(b.range));
const previousLabel = period.previous_label || '去年同期';
const currentLabel = period.label || '本期';
const previousTotal = data.reduce((s, x) => s + (x.sales_previous || 0), 0);
const currentTotal = data.reduce((s, x) => s + (x.sales_current || 0), 0);
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' },
formatter: ps => safeHtmlText(ps[0].name) + '<br/>' + ps.map(p => {
const v = p.value || 0;
const total = p.seriesName === previousLabel ? previousTotal : currentTotal;
const pct = total > 0 ? (v/total*100).toFixed(1) + '%' : '0.0%';
return `${p.marker} ${safeHtmlText(p.seriesName)}: ${v.toLocaleString()} (${pct})`;
}).join('<br/>')
},
legend: { data: [previousLabel, currentLabel], bottom: 0, show: !compact, textStyle: { fontSize: 13, fontWeight: 'bold' } },
grid: { left: compact ? '2%' : '3%', right: compact ? '2%' : '4%', bottom: compact ? '18%' : '12%', top: compact ? '8%' : '15%', containLabel: true },
xAxis: { type: 'category', data: data.map(d => d.range), axisLabel: { interval: 0, rotate: compact ? 32 : 0, fontSize: compact ? 10 : 13, fontWeight: 'bold' } },
yAxis: { type: 'value', name: compact ? '' : '銷售額', nameTextStyle: { fontSize: 12, fontWeight: 'bold' },
axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬', fontSize: compact ? 10 : 12 } },
series: [
{ label: previousLabel, key: 'sales_previous', total: previousTotal },
{ label: currentLabel, key: 'sales_current', total: currentTotal }
].map((series) => ({
name: series.label, type: 'bar', barWidth: compact ? 13 : 30,
data: data.map(d => d[series.key] || 0),
label: { show: !compact, position: 'top', fontSize: 12, fontWeight: 'bold',
formatter: p => {
const v = p.value || 0;
const total = series.total;
const pct = total > 0 ? (v/total*100).toFixed(1) + '%' : '';
return pct + '\n' + (v/10000).toFixed(0) + '萬';
}
}
}))
});
$(`#${tableId}`).parent().html('');
}
// ── BCG 矩陣4 象限顯式配色) ───────────────────────
function renderBCGMatrixChart(chart, data) {
if (!data || !data.length) {
renderChartEmpty(chart, null, '所選期間尚無可建立品牌策略矩陣的本期資料。');
return;
}
chart.clear();
const compact = isCompactViewport();
const C_STAR = cssColor('--momo-warm-olive', '#6f7a4a');
const C_COW = cssColor('--momo-warm-honey', '#c89043');
const C_QUESTION = cssColor('--momo-warm-caramel', '#c96442');
const C_DOG = cssColor('--momo-warm-mahogany', '#7a3b2c');
chart.setOption({
tooltip: { formatter: p => `<div style="font-weight:bold">${safeHtmlText(p.value[3])}</div><div>毛利率: ${p.value[0]}%</div><div>銷售額: $${p.value[1].toLocaleString()}</div><div>銷量: ${p.value[2].toLocaleString()}</div>` },
grid: { left: compact ? '46' : '80', right: compact ? '16' : '50', top: compact ? '18' : '30', bottom: compact ? '34' : '30' },
xAxis: { name: compact ? '' : '毛利率(%)', type: 'value', axisLabel: { fontSize: compact ? 10 : 12 }, splitLine: { lineStyle: { type: 'dashed' } } },
yAxis: { name: compact ? '' : '業績($)', type: 'value', axisLabel: { formatter: v => compact ? (v/10000).toFixed(0) + '萬' : v.toLocaleString(), fontSize: compact ? 10 : 12 }, splitLine: { lineStyle: { type: 'dashed' } } },
series: [{
type: 'scatter',
data: data.map(d => [d.margin, d.sales, d.qty, decodeText(d.name)]),
symbolSize: d => Math.min(compact ? 52 : 96, Math.max(compact ? 8 : 10, Math.sqrt(Math.max(d[2], 1)) * (compact ? 0.35 : 0.75))),
itemStyle: {
color: p => {
if (p.value[0] >= 30 && p.value[1] >= 500000) return C_STAR;
if (p.value[0] < 30 && p.value[1] >= 500000) return C_COW;
if (p.value[0] >= 30 && p.value[1] < 500000) return C_QUESTION;
return C_DOG;
},
opacity: 0.78, borderColor: '#fff8ef', borderWidth: 1
},
markLine: { silent: true, data: [
{ xAxis: 30, lineStyle: { color: '#999' }, label: { show: !compact, formatter: '高獲利線 (30%)' } },
{ yAxis: 500000, lineStyle: { color: '#999' }, label: { show: !compact, formatter: '高營收線 ($50萬)' } }
] }
}]
});
}
// ── 散佈圖(價/量) ──────────────────────────────────
function renderScatterChart(chart, data) {
if (!data || !data.length) {
renderChartEmpty(chart, null, '所選期間尚無可比較價格與銷量的本期資料。');
return;
}
chart.clear();
const compact = isCompactViewport();
const series = data.map(d => {
const avg = d.qty > 0 ? Math.round(d.sales / d.qty) : 0;
return [avg, d.qty, d.sales, decodeText(d.name)];
});
chart.setOption({
tooltip: { formatter: p => `<div style="font-weight:bold">${safeHtmlText(p.value[3])}</div><div>均價: $${p.value[0]}</div><div>銷量: ${p.value[1].toLocaleString()}</div><div>業績: $${p.value[2].toLocaleString()}</div>` },
grid: { left: compact ? '42' : '60', right: compact ? '16' : '50', top: compact ? '18' : '30', bottom: compact ? '32' : '30' },
xAxis: { name: compact ? '' : '均價($)', type: 'value', axisLabel: { fontSize: compact ? 10 : 12 }, splitLine: { show: false } },
yAxis: { name: compact ? '' : '銷量', type: 'value', axisLabel: { fontSize: compact ? 10 : 12 }, splitLine: { lineStyle: { type: 'dashed' } } },
series: [{ type: 'scatter', data: series, symbolSize: d => Math.min(compact ? 36 : 64, Math.max(compact ? 6 : 8, Math.log(Math.max(d[2],1)) * (compact ? 1.35 : 2))), itemStyle: { opacity: 0.65 } }]
});
}
// ── 廠商排行 ──────────────────────────────────────────
function renderVendorRankingChart(chart, tableId, data, period = {}) {
if (!data || !data.length) {
renderChartEmpty(chart, tableId, '所選期間與去年同期皆無廠商業績。');
return;
}
chart.clear();
const compact = isCompactViewport();
const cd = data.slice().reverse();
const visibleCount = compact ? 10 : 18;
const zoomStart = cd.length > visibleCount
? Math.max(0, 100 - (visibleCount / cd.length * 100))
: 0;
const previousLabel = period.previous_label || '去年同期';
const currentLabel = period.label || '本期';
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: [previousLabel, currentLabel], bottom: 0, show: !compact, textStyle: { fontSize: 15, fontWeight: 'bold' } },
grid: { left: compact ? '86' : '160', right: compact ? '34' : '120', top: compact ? '10' : '20', bottom: compact ? '28' : '45' },
dataZoom: cd.length > visibleCount ? [
{ type: 'inside', yAxisIndex: 0, start: zoomStart, end: 100 },
{ type: 'slider', yAxisIndex: 0, start: zoomStart, end: 100, right: 3, width: 10, showDetail: false }
] : [],
xAxis: { type: 'value', axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬', fontSize: compact ? 10 : 14, fontWeight: 'bold' } },
yAxis: { type: 'category', data: cd.map(d => decodeText(d.name)), axisLabel: { width: compact ? 78 : 150, overflow: 'truncate', interval: 0, fontSize: compact ? 10 : 14, fontWeight: 'bold' } },
series: [
{ name: previousLabel, type: 'bar', data: cd.map(d => d.sales_previous || 0), barWidth: compact ? 10 : 18, barGap: '0%', barCategoryGap: compact ? '70%' : '75%',
label: { show: !compact, position: 'insideRight', fontSize: 13, fontWeight: 'bold', color: '#fff8ef', textShadowColor: 'rgba(42,37,32,0.5)', textShadowBlur: 2,
formatter: p => p.value > 0 ? (p.value/10000).toFixed(0) + '萬' : '' } },
{ name: currentLabel, type: 'bar', data: cd.map(d => d.sales_current || 0), barWidth: compact ? 10 : 18,
label: { show: !compact, position: 'right', fontSize: 14, fontWeight: 'bold',
formatter: p => p.value > 0 ? (p.value/10000).toFixed(0) + '萬' : '' } }
]
});
let html = `<thead class="table-light"><tr>
<th>排名</th><th>廠商名稱</th><th class="text-end">總銷售額</th><th class="text-end">銷售額 YoY</th>
<th class="text-end">${previousLabel}</th><th class="text-end">${currentLabel}</th>
<th class="text-end">總毛利額</th><th class="text-end">毛利額 YoY</th>
<th class="text-end">${previousLabel}毛利</th><th class="text-end">${currentLabel}毛利</th></tr></thead><tbody>`;
data.forEach((d, i) => {
const previousSales = d.sales_previous || 0, currentSales = d.sales_current || 0;
const previousProfit = d.profit_previous || 0, currentProfit = d.profit_current || 0;
const sYoY = previousSales > 0 ? ((currentSales-previousSales)/previousSales*100).toFixed(1)+'%' : '-';
const pYoY = previousProfit > 0 ? ((currentProfit-previousProfit)/previousProfit*100).toFixed(1)+'%' : '-';
const sCls = previousSales > 0 && (currentSales-previousSales) >= 0 ? 'text-danger' : (previousSales > 0 ? 'text-success' : '');
const pCls = previousProfit > 0 && (currentProfit-previousProfit) >= 0 ? 'text-danger' : (previousProfit > 0 ? 'text-success' : '');
html += `<tr>
<td>${i+1}</td>
<td class="text-start text-truncate" style="max-width:200px;">${safeHtmlText(d.name)}</td>
<td class="text-end fw-bold">${d.sales.toLocaleString()}</td>
<td class="text-end fw-bold ${sCls}">${sYoY}</td>
<td class="text-end text-muted">${previousSales.toLocaleString()}</td>
<td class="text-end text-primary fw-bold">${currentSales.toLocaleString()}</td>
<td class="text-end fw-bold">${d.profit.toLocaleString()}</td>
<td class="text-end fw-bold ${pCls}">${pYoY}</td>
<td class="text-end text-muted">${previousProfit.toLocaleString()}</td>
<td class="text-end text-success fw-bold">${currentProfit.toLocaleString()}</td></tr>`;
});
html += `</tbody>`;
$(`#${tableId}`).parent().html(`<table class="table table-bordered table-hover table-sm text-center mb-0 small" style="white-space:nowrap;">${html}</table>`);
}
// ── 季節熱力圖 ────────────────────────────────────────
function renderSeasonalityHeatmap(chart, data, _period = {}) {
if (!data || !data.length) {
renderChartEmpty(chart, null, '所選期間與去年同期皆無季節分佈資料。');
return;
}
chart.clear();
const compact = isCompactViewport();
const months = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
const areaSales = {};
data.forEach(d => {
const category = decodeText(d.category);
areaSales[category] = (areaSales[category] || 0) + d.sales;
});
const areas = Object.keys(areaSales).sort((a,b) => areaSales[b] - areaSales[a]);
const yLabels = [];
const years = [...new Set(data.map(d => Number(d.year)))].sort((a, b) => b - a);
years.forEach(y => areas.forEach(a => yLabels.push(`${y} ${a}`)));
const heatmap = [];
data.forEach(d => {
const xi = d.month - 1, yi = yLabels.indexOf(`${d.year} ${decodeText(d.category)}`);
if (xi >= 0 && xi < 12 && yi >= 0) heatmap.push([xi, yi, d.sales]);
});
const max = Math.max(...data.map(d => d.sales), 1);
chart.setOption({
tooltip: { position: 'top',
formatter: p => `<strong>${safeHtmlText(yLabels[p.value[1]])}</strong><br/>${months[p.value[0]]}<br/>業績: <strong>${(p.value[2]/10000).toFixed(0)}萬</strong>` },
grid: { height: compact ? '72%' : '75%', top: compact ? '3%' : '5%', bottom: compact ? '20%' : '18%', left: compact ? '72' : '15%', right: compact ? '5' : '3%' },
xAxis: { type: 'category', data: months, splitArea: { show: true }, axisLabel: { fontSize: compact ? 10 : 14, fontWeight: 'bold' } },
yAxis: { type: 'category', data: yLabels, splitArea: { show: true }, axisLabel: { width: compact ? 66 : 120, overflow: 'truncate', fontSize: compact ? 10 : 14, fontWeight: 'bold' } },
visualMap: { min: 0, max, calculable: true, orient: 'horizontal', left: 'center', bottom: '0%', textStyle: { fontSize: compact ? 10 : 12 },
inRange: { color: ['#fef3c7', '#f5c98a', '#e3a560', '#c96442', '#7a3b2c'] } },
series: [{ type: 'heatmap', data: heatmap,
label: { show: !compact, fontSize: 12, fontWeight: 'bold', formatter: p => (p.value[2]/10000).toFixed(0) + '萬' },
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.45)' } } }]
});
}
// ── 區域排行 ──────────────────────────────────────────
function renderAreaRankingChart(chart, tableId, data, period = {}) {
if (!data || !data.length) {
renderChartEmpty(chart, tableId, '所選期間與去年同期皆無區域業績。');
return;
}
chart.clear();
const compact = isCompactViewport();
const previousLabel = period.previous_label || '去年同期';
const currentLabel = period.label || '本期';
data.sort((a,b) => b.sales - a.sales);
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' },
formatter: ps => safeHtmlText(ps[0].name) + '<br/>' + ps.map(p => `${p.marker} ${safeHtmlText(p.seriesName)}: ${(p.value || 0).toLocaleString()}`).join('<br/>') },
legend: { data: [previousLabel, currentLabel], bottom: 0, show: !compact, textStyle: { fontSize: 14 } },
grid: { left: compact ? '2%' : '3%', right: compact ? '2%' : '4%', bottom: compact ? '20%' : '10%', top: compact ? '8%' : '10%', containLabel: true },
xAxis: { type: 'category', data: data.map(d => decodeText(d.name)), axisLabel: { interval: 0, rotate: compact ? 28 : 0, fontSize: compact ? 10 : 14, fontWeight: 'bold' } },
yAxis: { type: 'value', name: compact ? '' : '業績', axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬', fontSize: compact ? 10 : 13 } },
series: [
{ label: previousLabel, key: 'sales_previous' },
{ label: currentLabel, key: 'sales_current' }
].map(series => ({
name: series.label, type: 'bar', data: data.map(d => d[series.key] || 0),
label: { show: !compact, position: 'top', fontSize: 14, fontWeight: 'bold', formatter: p => (p.value/10000).toFixed(0) + '萬' }
}))
});
let html = `<thead class="table-light"><tr><th>排名</th><th>區名稱</th><th>業績</th></tr></thead><tbody>`;
data.forEach((d, i) => { html += `<tr><td>${i+1}</td><td class="text-start">${safeHtmlText(d.name)}</td><td class="text-end fw-bold">$${d.sales.toLocaleString()}</td></tr>`; });
html += `</tbody>`;
$(`#${tableId}`).parent().html(`<table class="table table-bordered table-hover table-sm text-center mb-0 small">${html}</table>`);
}
// 公開 fetchData 給 onclick 使用
window.fetchData = fetchData;
})();