/* ════════════════════════════════════════════════════════ * 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; 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 () { 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: { url: '//cdn.datatables.net/plug-ins/1.11.5/i18n/zh-HANT.json' }, 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 = () => fetchData(); 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 fetchData() { $('#loadingOverlay').css('display', 'flex'); const url = `/api/monthly_summary_data?${buildFilterParams({ limit: '2000' }).toString()}`; fetch(url).then(r => r.json()).then(data => { if (data.status === 'success') { updateUI(data); updateFilters(data.filters); updateAnalysisTabLinks(data.period); } $('#loadingOverlay').hide(); }).catch(err => { console.error(err); $('#loadingOverlay').hide(); }); fetchSpecialChartData(); } function fetchSpecialChartData() { const pairs = [ ['開架保養,臉部清潔', specialChart, 'specialChartTableBody'], ['身體保養', bodyCareChart, 'bodyCareChartTableBody'], ['彩妝/指彩,精油擴香', makeupFragranceChart, 'makeupFragranceChartTableBody'], ['私密保養,嬰幼洗沐', privacyInfantChart, 'privacyInfantChartTableBody'] ]; pairs.forEach(([area, chart, tbody]) => { const params = buildFilterParams({ areaOverride: area }); fetch(`/api/monthly_summary_trend?${params.toString()}`) .then(r => r.json()) .then(d => { if (d.status === 'success') renderExcelChart(chart, tbody, d.trend, d.period); }) .catch(err => console.error('monthly special trend failed', err)); }); } // ── 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(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(v => el.append(`
  • ${v}
  • `)); }; 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(labels.year); $('#btnMonth').text(labels.month); $('#btnArea').text(labels.area); $('#btnVendor').text(labels.vendor); $('#btnTrade').text(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(` ${yoy}% 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 = `${yoy > 0 ? '+' : ''}${yoy}%`; } table.row.add([ `${row.year}/${row.month}`, row.division, row.area_name || '-', row.pm_name || '-', `${row.brand_name}`, `${row.vendor_name}`, `${row.trade_type || '-'}`, 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(`${message}`); } } // ── 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(`${i+1}${it.name}${prefix}${it.value.toLocaleString()}`)); }; 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 = 'var(--momo-warm-caramel, #c96442)'; const C2 = 'var(--momo-warm-olive, #6f7a4a)'; const CM = 'var(--momo-text-tertiary, #94a3b8)'; const tableHtml = ` 月份${months.map(m => `${m}`).join('')} ${currentSeriesLabel}${currData.map(v => `${v > 0 ? v.toLocaleString() : '-'}`).join('')} ${previousSeriesLabel}${prevData.map(v => `${v > 0 ? v.toLocaleString() : '-'}`).join('')} YOY${yoyData.map((v, i) => `${(currData[i] > 0 || prevData[i] > 0) ? v + '%' : '-'}`).join('')}`; $(`#${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 = ` 月份${dates.map(m => `${m}`).join('')} ${currYearStr}${currData.map(v => `${v > 0 ? v.toLocaleString() : '-'}`).join('')} 去年同期${yoaData.map(v => `${v > 0 ? v.toLocaleString() : '-'}`).join('')}`; $(`#${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: 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 => `${p.name}
    業績: ${(p.value/10000).toFixed(0)}萬
    佔比: ${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 => ps[0].name + '
    ' + 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} ${p.seriesName}: ${v.toLocaleString()} (${pct})`; }).join('
    ') }, 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 = 'var(--momo-warm-olive, #6f7a4a)'; const C_COW = 'var(--momo-warm-honey, #c89043)'; const C_QUESTION = 'var(--momo-warm-caramel, #c96442)'; const C_DOG = 'var(--momo-warm-mahogany, #7a3b2c)'; chart.setOption({ tooltip: { formatter: p => `
    ${p.value[3]}
    毛利率: ${p.value[0]}%
    銷售額: $${p.value[1].toLocaleString()}
    銷量: ${p.value[2].toLocaleString()}
    ` }, 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, 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, d.name]; }); chart.setOption({ tooltip: { formatter: p => `
    ${p.value[3]}
    均價: $${p.value[0]}
    銷量: ${p.value[1].toLocaleString()}
    業績: $${p.value[2].toLocaleString()}
    ` }, 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 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 ? '18' : '110', top: compact ? '10' : '20', bottom: compact ? '28' : '45' }, xAxis: { type: 'value', axisLabel: { formatter: v => (v/10000).toFixed(0) + '萬', fontSize: compact ? 10 : 14, fontWeight: 'bold' } }, yAxis: { type: 'category', data: cd.map(d => 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 = ` 排名廠商名稱總銷售額銷售額 YoY ${previousLabel}${currentLabel} 總毛利額毛利額 YoY ${previousLabel}毛利${currentLabel}毛利`; 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 += ` ${i+1} ${d.name} ${d.sales.toLocaleString()} ${sYoY} ${previousSales.toLocaleString()} ${currentSales.toLocaleString()} ${d.profit.toLocaleString()} ${pYoY} ${previousProfit.toLocaleString()} ${currentProfit.toLocaleString()}`; }); html += ``; $(`#${tableId}`).parent().html(`${html}
    `); } // ── 季節熱力圖 ──────────────────────────────────────── 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 => { areaSales[d.category] = (areaSales[d.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} ${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 => `${yLabels[p.value[1]]}
    ${months[p.value[0]]}
    業績: ${(p.value[2]/10000).toFixed(0)}萬` }, 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 => ps[0].name + '
    ' + ps.map(p => `${p.marker} ${p.seriesName}: ${(p.value || 0).toLocaleString()}`).join('
    ') }, 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 => 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 = `排名區名稱業績`; data.forEach((d, i) => { html += `${i+1}${d.name}$${d.sales.toLocaleString()}`; }); html += ``; $(`#${tableId}`).parent().html(`${html}
    `); } // 公開 fetchData 給 onclick 使用 window.fetchData = fetchData; })();