// =========================== // DEMO DATA GENERATOR // =========================== function generateDemoData() { const data = []; const rng = seed => { let s = seed; return () => { s = (s * 16807 + 0) % 2147483647; return s / 2147483647; }; }; const rand = rng(42); for (let y = 2025; y <= 2026; y++) { for (let m = 1; m <= 12; m++) { if (y === 2026 && m > 8) break; const monthStr = `${y}-${String(m).padStart(2,'0')}`; const seasonF = [0.85, 0.80, 0.95, 1.05, 1.00, 1.10, 1.05, 1.00, 1.15, 1.10, 1.05, 1.20][m - 1]; const yearGrowth = y === 2026 ? 1.08 : 1; DEPARTMENTS.forEach(dept => { CATEGORIES.forEach(cat => { const base = BASE_AMOUNTS[dept][cat]; const noise = 0.75 + rand() * 0.5; let amount = base * seasonF * yearGrowth * noise; if ((y === 2025 && m === 8 && dept === '销售部' && cat === '招待费') || (y === 2026 && m === 3 && dept === '市场部' && cat === '市场推广费') || (y === 2026 && m === 5 && dept === '研发部' && cat === '差旅费')) { amount *= 2.8 + rand(); } if (y === 2026 && dept === '行政部' && cat === '办公费') { amount *= 0.6; } amount = Math.round(amount * 100) / 100; const budget = Math.round(base * seasonF * 1.1 * 100) / 100; const recordCount = Math.max(1, Math.floor(rand() * 5)); for (let i = 0; i < recordCount; i++) { data.push({ month: monthStr, year: y, monthNum: m, department: dept, category: cat, amount: amount / recordCount, budget: i === 0 ? budget : 0, costNature: COST_NATURE[cat], controllable: CONTROLLABLE[cat] }); } }); }); } } return data; } // =========================== // EXCEL IMPORT / EXPORT // =========================== async function downloadTemplate() { try { await ensureLib('xlsx'); } catch (e) { showToast('表格引擎加载失败,请检查网络', 'error'); return; } const headers = ['月份', '部门', '费用科目', '金额', '预算金额']; let outputData = []; if (appState.rawData && appState.rawData.length > 0) { outputData = appState.rawData.map(d => [d.month, d.department, d.category, d.amount, d.budget]); } else { outputData = [ ['2025-01', '销售部', '差旅费', 125000, 137000], ['2026-01', '销售部', '差旅费', 145000, 150000], ['2025-01', '市场部', '市场推广费', 85000, 90000], ['2026-01', '市场部', '市场推广费', 112000, 115000], ['2025-02', '研发部', '办公费', 52000, 60000], ['2026-02', '研发部', '办公费', 48000, 55000] ]; } const ws = XLSX.utils.aoa_to_sheet([headers, ...outputData]); ws['!cols'] = [{ wch: 12 }, { wch: 12 }, { wch: 14 }, { wch: 14 }, { wch: 14 }]; const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, '费用明细同期模板'); XLSX.writeFile(wb, '企业费用分析联动数据模板.xlsx'); showToast(appState.rawData.length > 0 ? '当前系统数据导出成功' : '多期联动模板下载成功'); } // =========================== // WORD EXPORT(兼容降级模式:HTML 容器方案,仅在原生生成失败时兜底) // =========================== function exportWordLegacy() { const reportHtmlObj = document.getElementById('reportContent'); if (!reportHtmlObj || reportHtmlObj.innerHTML.includes('目前缺少基底数据')) { showToast('暂无完整的报告数据可供导出', 'error'); return; } showToast('正在高清压制图表并组装Word报告...', 'success'); setTimeout(() => { const cloneDiv = document.createElement('div'); cloneDiv.innerHTML = reportHtmlObj.innerHTML; // 1. 将所有 ECharts 图表转化为高清 Base64 图片 const chartContainers = reportHtmlObj.querySelectorAll('.rep-chart'); const cloneContainers = cloneDiv.querySelectorAll('.rep-chart'); chartContainers.forEach((dom, index) => { const chart = echarts.getInstanceByDom(dom); if (chart) { const imgData = chart.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#ffffff' }); const img = document.createElement('img'); img.src = imgData; img.setAttribute('width', '620'); img.style.cssText = 'display:block;margin:16px auto;max-width:100%;height:auto;border:1px solid #e2e8f0;'; if (cloneContainers[index]) { cloneContainers[index].parentNode.replaceChild(img, cloneContainers[index]); } } }); // 2. 核心排版修复:将 CSS Grid 转换为 Word 支持的 Table 布局(完美还原并排显示) const kpiGrids = cloneDiv.querySelectorAll('.rep-kpi-grid'); kpiGrids.forEach(grid => { const boxes = grid.querySelectorAll('.rep-kpi-box'); if(boxes.length > 0) { const table = document.createElement('table'); table.style.width = '100%'; table.style.borderCollapse = 'separate'; table.style.borderSpacing = '10px 0'; table.style.marginBottom = '25px'; const tr = document.createElement('tr'); const widthPct = (100 / boxes.length).toFixed(2) + '%'; boxes.forEach(box => { const td = document.createElement('td'); td.style.width = widthPct; td.style.backgroundColor = '#f8fafc'; td.style.border = '1px solid #e2e8f0'; td.style.borderRadius = '8px'; td.style.padding = '18px 10px'; td.style.textAlign = 'center'; td.style.verticalAlign = 'middle'; td.innerHTML = box.innerHTML; tr.appendChild(td); }); table.appendChild(tr); grid.parentNode.replaceChild(table, grid); } }); // 3. 构建 Word 专属的 HTML 头和内联样式 (深度还原页面的 CSS) const preHtml = ` 企业智能费用分析报告 `; const postHtml = ``; const fullHtml = preHtml + cloneDiv.innerHTML + postHtml; const blob = new Blob(['\ufeff', fullHtml], { type: 'application/msword' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `企业智能费用综合分析报告_${new Date().getTime()}.doc`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(link.href); }, 900); } document.getElementById('fileInput').addEventListener('change', async function(e) { const file = e.target.files[0]; if (!file) return; if (!LIB_REGISTRY.xlsx.check()) showToast('首次激活表格引擎(之后永久缓存)...', 'success'); try { await ensureLib('xlsx'); } catch (err) { showToast('表格引擎加载失败,请检查网络', 'error'); e.target.value = ''; return; } const reader = new FileReader(); reader.onload = async function(ev) { try { const wb = XLSX.read(ev.target.result, { type: 'array' }); const ws = wb.Sheets[wb.SheetNames[0]]; const json = XLSX.utils.sheet_to_json(ws, { defval: '' }); if (json.length === 0) { showToast('文件为空', 'error'); return; } const data = json.map((row, i) => { const month = String(row['月份'] || '').trim(); const parts = month.split(/[-\/]/); const amt = parseFloat(row['金额']) || 0; return { month: month, year: parseInt(parts[0]) || 2025, monthNum: parseInt(parts[1]) || 1, department: String(row['部门'] || '').trim(), category: String(row['费用科目'] || '').trim(), amount: amt, budget: parseFloat(row['预算金额']) || 0, costNature: COST_NATURE[String(row['费用科目'] || '').trim()] || '变动', controllable: CONTROLLABLE[String(row['费用科目'] || '').trim()] || '可控' }; }).filter(d => d.month && d.department && d.category && d.amount > 0); if (data.length === 0) { showToast('未找到有效数据,请检查列名', 'error'); return; } appState.rawData = data; try { await ensureLib('echarts'); } catch (err2) { showToast('渲染引擎加载失败,请检查网络', 'error'); return; } initDashboard(); showToast(`成功导入 ${data.length} 条有效记录`); } catch (err) { showToast('文件解析失败:' + err.message, 'error'); } }; reader.readAsArrayBuffer(file); e.target.value = ''; }); // =========================== // LOAD & FILTER // =========================== async function loadDemoData() { if (!LIB_REGISTRY.echarts.check()) showToast('首次激活渲染引擎(之后永久缓存,毫秒级就绪)...', 'success'); try { await ensureLib('echarts'); } catch (e) { showToast('渲染引擎加载失败,请检查网络', 'error'); return; } appState.rawData = generateDemoData(); initDashboard(); showToast(`已载入 ${appState.rawData.length} 条多期演示数据`); } function initDashboard() { document.getElementById('emptyState').style.display = 'none'; document.getElementById('panelsContainer').style.display = 'block'; populateFilters(); applyFilters(); document.getElementById('loading').classList.add('hidden'); } function populateFilters() { const data = appState.rawData; const months = [...new Set(data.map(d => d.month))].sort(); const depts = [...new Set(data.map(d => d.department))].sort(); const cats = [...new Set(data.map(d => d.category))].sort(); const fillSelect = (id, items, defaultVal) => { const sel = document.getElementById(id); sel.innerHTML = ''; items.forEach(v => { const opt = document.createElement('option'); opt.value = v; opt.textContent = v; sel.appendChild(opt); }); if (defaultVal) sel.value = defaultVal; }; if (months.length > 0) { fillSelect('filterStartMonth', months, months[0]); fillSelect('filterEndMonth', months, months[months.length - 1]); } const fillSelectAll = (id, items, label) => { const sel = document.getElementById(id); sel.innerHTML = ``; items.forEach(v => sel.innerHTML += ``); }; fillSelectAll('filterDept', depts, '全部部门'); fillSelectAll('filterCategory', cats, '全部科目'); } function applyFilters() { const startM = document.getElementById('filterStartMonth').value; const endM = document.getElementById('filterEndMonth').value; const dept = document.getElementById('filterDept').value; const cat = document.getElementById('filterCategory').value; if (startM > endM) { document.getElementById('filterEndMonth').value = startM; } const validStart = document.getElementById('filterStartMonth').value; const validEnd = document.getElementById('filterEndMonth').value; appState.filteredData = appState.rawData.filter(d => { if (d.month < validStart || d.month > validEnd) return false; if (dept !== 'all' && d.department !== dept) return false; if (cat !== 'all' && d.category !== cat) return false; return true; }); const fd = appState.filteredData; document.getElementById('dataInfo').textContent = `筛选结果:${fd.length} 条记录 | 总金额:${fmtWan(sum(fd.map(d=>d.amount)))}元`; _analyticsCache = null; // 筛选变化 → 深度分析缓存失效 renderAll(); } // =========================== // AGGREGATION HELPERS // =========================== function groupBy(data, key) { const map = {}; data.forEach(d => { const k = typeof key === 'function' ? key(d) : d[key]; if (!map[k]) map[k] = []; map[k].push(d); }); return map; } function aggBy(data, ...keys) { const map = {}; data.forEach(d => { const k = keys.map(k => typeof k === 'function' ? k(d) : d[k]).join('|'); if (!map[k]) { map[k] = { _key: k }; keys.forEach((kk, i) => map[k][typeof kk === 'function' ? `dim${i}` : kk] = typeof kk === 'function' ? kk(d) : d[kk]); map[k].amount = 0; map[k].budget = 0; map[k].count = 0; } map[k].amount += d.amount; map[k].budget += d.budget; map[k].count++; }); return Object.values(map); } // =========================== // RENDER ALL CHARTS // =========================== function renderAll() { renderKPIs(); renderTrend(); renderPie(); renderPareto(); renderDeptRank(); renderBudget(); renderBridge(); renderCum(); renderTreemap(); renderNature(); renderControl(); renderHeatmap(); renderStacked(); renderSankey(); renderSeason(); renderTornado(); renderYoYContrib(); renderQuadrant(); renderYoYMatrix(); renderAnomalyKPIs(); renderBoxplot(); renderAnomalyScatter(); renderAnomalyTable(); renderAnomalyDept(); renderRadar(); renderPerCapita(); renderDeptGrowth(); renderDeptTrend(); renderReport(); } function initChart(id) { const el = document.getElementById(id); if (!el) return null; if (appState.charts[id]) { appState.charts[id].resize(); return appState.charts[id]; } const chart = echarts.init(el, null, { renderer: 'canvas' }); appState.charts[id] = chart; return chart; } window.addEventListener('resize', () => { Object.values(appState.charts).forEach(c => c.resize()); Object.values(appState.reportCharts || {}).forEach(c => c.resize()); }); // =========================== // 1. KPI CARDS // =========================== function renderKPIs() { const fd = appState.filteredData; const total = sum(fd.map(d => d.amount)); const totalBudget = sum(fd.map(d => d.budget)); const months = [...new Set(fd.map(d => d.month))].sort(); const lastMonth = months[months.length - 1]; const prevMonth = months.length > 1 ? months[months.length - 2] : null; const currentMonthData = fd.filter(d => d.month === lastMonth); const prevMonthData = prevMonth ? fd.filter(d => d.month === prevMonth) : []; const currentTotal = sum(currentMonthData.map(d => d.amount)); const prevTotal = sum(prevMonthData.map(d => d.amount)); const momChange = prevTotal > 0 ? (currentTotal - prevTotal) / prevTotal : null; const budgetRate = totalBudget > 0 ? total / totalBudget : null; const anomalies = detectAnomalies(fd); const anomalyCount = anomalies.length; // ★ 升级指标:同比 / 人均效能 / 集中度HHI const monthsAll = [...new Set(fd.map(d => d.month))].sort(); const deptsIn = DEPARTMENTS.filter(d => fd.some(f => f.department === d)); const totalEmp = deptsIn.reduce((s, d) => s + (DEPT_EMP_COUNT[d] || 0), 0) || 1; const perCapita = monthsAll.length ? Math.round(total / totalEmp / monthsAll.length) : 0; const bcK = aggBy(fd, 'category'); const hhi = total > 0 ? sum(bcK.map(c => Math.pow(c.amount / total * 100, 2))) / 10000 : 0; const bmK = aggBy(fd, 'month').sort((a, b) => a.month.localeCompare(b.month)); const ratesK = []; for (let i = 1; i < bmK.length; i++) { if (bmK[i - 1].amount > 0) ratesK.push((bmK[i].amount - bmK[i - 1].amount) / bmK[i - 1].amount * 100); } const momVol = ratesK.length > 1 ? stddev(ratesK) : 0; const fDept = document.getElementById('filterDept').value, fCat = document.getElementById('filterCategory').value; const prevOf = m => (parseInt(m.split('-')[0]) - 1) + '-' + m.split('-')[1]; const pairedM = monthsAll.filter(m => appState.rawData.some(d => d.month === prevOf(m) && (fDept === 'all' || d.department === fDept) && (fCat === 'all' || d.category === fCat))); const pairedPrev = pairedM.map(prevOf); const yoyAmt = sum(appState.rawData.filter(d => pairedPrev.includes(d.month) && (fDept === 'all' || d.department === fDept) && (fCat === 'all' || d.category === fCat)).map(d => d.amount)); const curPaired = sum(fd.filter(d => pairedM.includes(d.month)).map(d => d.amount)); const yoyR = yoyAmt > 0 ? (curPaired - yoyAmt) / yoyAmt : null; const kpis = [ { label: '期间费用总额', value: fmtWan(total) + '元', sub: monthsAll.length + ' 个月度 / ' + fmt(fd.length) + ' 笔' }, { label: '同比变动', value: yoyR !== null ? (yoyR >= 0 ? '+' : '') + fmtPct(yoyR) : '—', change: yoyR, invertColor: true, note: yoyR !== null ? (yoyR >= 0 ? '↑ 较去年同期' : '↓ 较去年同期') : '无同期数据' }, { label: '区间末月环比', value: momChange !== null ? fmtPct(Math.abs(momChange)) : '-', change: momChange, note: momChange >= 0 ? '↑ 较上月' : (momChange < 0 ? '↓ 较上月' : ''), sub: ratesK.length ? '波动率 ' + momVol.toFixed(1) + '%' : '' }, { label: '区间预算达成率', value: budgetRate !== null ? fmtPct(budgetRate) : '无预算', change: budgetRate !== null ? budgetRate - 1 : null, note: budgetRate > 1 ? '已超支' : (budgetRate !== null ? '预算内' : ''), invertColor: true, sub: budgetRate !== null ? (budgetRate > 1 ? '超支 ' + fmtWan(total - totalBudget) + '元' : '边际 ' + fmtWan(totalBudget - total) + '元') : '' }, { label: '人均月费用', value: perCapita > 0 ? fmt(perCapita) + '元' : '-', sub: '在编 ' + totalEmp + ' 人' }, { label: '科目集中度 HHI', value: hhi.toFixed(3), sub: hhi > 0.25 ? '高度集中' : hhi > 0.15 ? '中度集中' : '较为分散' }, { label: '区间统计笔数', value: fmt(fd.length) + ' 笔', sub: '单笔均价 ' + (fd.length > 0 ? fmt(Math.round(total / fd.length)) : '-') + '元' }, { label: '区间高危异常', value: anomalyCount + ' 笔', change: anomalyCount > 0 ? 1 : -1, invertColor: true, sub: anomalyCount > 0 ? '建议逐笔溯源' : '风控健康' } ]; const grid = document.getElementById('kpiGrid'); grid.innerHTML = kpis.map(k => { let changeHtml = ''; if (k.change !== null && k.change !== undefined) { const isUp = k.invertColor ? k.change > 0 : k.change >= 0; const cls = isUp ? 'up' : 'down'; const arrow = k.change >= 0 ? '▲' : '▼'; changeHtml = `
${arrow} ${k.note || fmtPct(Math.abs(k.change))}
`; } const subHtml = k.sub ? `
${k.sub}
` : ''; return `
${k.label}
${k.value}
${changeHtml}${subHtml}
`; }).join(''); } // =========================== // 2. TREND CHART // =========================== function renderTrend() { const chart = initChart('chartTrend'); if (!chart) return; const fd = appState.filteredData; const monthsRaw = [...new Set(fd.map(d => d.month))].sort(); if (monthsRaw.length < 1) return showEmptyChart(chart); const byMonth = aggBy(fd, 'month').sort((a, b) => a.month.localeCompare(b.month)); const amounts = byMonth.map(d => Math.round(d.amount)); const budgets = byMonth.map(d => Math.round(d.budget)); const pastLen = amounts.length; // ★ 最小二乘线性回归(精确可复现,替代随机估算) const reg = linreg(amounts); const fitVals = amounts.map((_, i) => Math.round(reg.intercept + reg.slope * i)); const residuals = amounts.map((a, i) => a - fitVals[i]); const sigma = Math.max(stddev(residuals), mean(amounts) * 0.03); // 置信带宽(下限保护) const ma3 = amounts.map((_, i) => i >= 2 ? Math.round(mean(amounts.slice(i - 2, i + 1))) : null); const monthsAll = [...byMonth.map(d => d.month)]; const fitAll = [...fitVals]; const bandLow = Array(pastLen).fill(null); const bandDelta = Array(pastLen).fill(null); const budgetsAll = [...budgets]; for (let k = 1; k <= 3; k++) { monthsAll.push('预测+' + k + '期'); const v = Math.round(reg.forecast(k)); fitAll.push(v); bandLow.push(Math.max(0, v - Math.round(sigma))); bandDelta.push(Math.round(sigma * 2)); budgetsAll.push(null); } chart.setOption({ tooltip: { trigger: 'axis', backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 }, formatter: params => { let s = `${params[0].axisValue}
`; params.forEach(p => { if (p.value != null && p.value != 0 && p.seriesName !== '区间下界' && p.seriesName !== '置信区间') s += `${p.marker} ${p.seriesName}: ${fmt(p.value)}元
`; }); return s; } }, legend: { data: ['实际发生', '预算阈值', '回归拟合', '3期移动平均', '预测置信区间'], top: 0, textStyle: { color: '#8b9ab5', fontSize: 11 } }, grid: { left: 60, right: 30, top: 40, bottom: 40 }, xAxis: { type: 'category', data: monthsAll, axisLine: { lineStyle: { color: '#2a3a50' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, rotate: 45 } }, yAxis: { type: 'value', axisLine: { show: false }, splitLine: { lineStyle: { color: 'rgba(42,58,80,0.5)' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, formatter: v => fmtWan(v) } }, series: [ { name: '实际发生', type: 'bar', data: amounts, itemStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: '#3b82f6' }, { offset: 1, color: '#1e40af' }] }, borderRadius: [4, 4, 0, 0] }, barWidth: '38%', markPoint: { data: [{ type: 'max', name: '最大值' }], label: { fontSize: 10 } } }, { name: '预算阈值', type: 'line', data: budgetsAll, lineStyle: { color: '#f97316', width: 2, type: 'dashed' }, itemStyle: { color: '#f97316' }, symbol: 'diamond', symbolSize: 6, connectNulls: false }, { name: '回归拟合', type: 'line', data: fitAll, lineStyle: { color: '#22d3ee', width: 2, type: 'dotted' }, itemStyle: { color: '#22d3ee' }, symbol: 'circle', symbolSize: 4, connectNulls: false }, { name: '3期移动平均', type: 'line', data: ma3, lineStyle: { color: '#a78bfa', width: 2 }, itemStyle: { color: '#a78bfa' }, symbol: 'none', connectNulls: false }, { name: '区间下界', type: 'line', stack: 'band', data: bandLow, lineStyle: { opacity: 0 }, symbol: 'none', areaStyle: { opacity: 0 }, silent: true, tooltip: { show: false } }, { name: '预测置信区间', type: 'line', stack: 'band', data: bandDelta, lineStyle: { opacity: 0 }, symbol: 'none', silent: true, areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(34,211,238,0.18)' }, { offset: 1, color: 'rgba(34,211,238,0.04)' }] } }, tooltip: { show: false } } ] }); } // =========================== // 3. PIE CHART // =========================== function renderPie() { const chart = initChart('chartPie'); if (!chart) return; const fd = appState.filteredData; if (fd.length === 0) return showEmptyChart(chart); const byCat = aggBy(fd, 'category'); byCat.sort((a, b) => b.amount - a.amount); chart.setOption({ tooltip: { trigger: 'item', backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 }, formatter: p => `${p.name}
金额: ${fmt(p.value)}元
占比: ${p.percent}%` }, legend: { orient: 'vertical', right: 10, top: 'center', textStyle: { color: '#8b9ab5', fontSize: 11 } }, series: [{ type: 'pie', radius: ['42%', '72%'], center: ['35%', '50%'], data: byCat.map((d, i) => ({ name: d.category, value: Math.round(d.amount), itemStyle: { color: COLORS[i % COLORS.length] } })), label: { show: false }, emphasis: { label: { show: true, fontSize: 13, fontWeight: 'bold', color: '#e2e8f0' }, itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.3)' } }, itemStyle: { borderColor: '#1a2332', borderWidth: 2 } }] }); } // =========================== // 4. PARETO CHART // =========================== function renderPareto() { const chart = initChart('chartPareto'); if (!chart) return; const fd = appState.filteredData; if (fd.length === 0) return showEmptyChart(chart); const byCat = aggBy(fd, 'category'); byCat.sort((a, b) => b.amount - a.amount); const total = sum(byCat.map(d => d.amount)); if (total === 0) return showEmptyChart(chart); let cum = 0; const cumPct = []; const barColors = []; byCat.forEach(d => { const prevCumPct = (cum / total) * 100; cum += d.amount; const currentPct = (cum / total) * 100; cumPct.push(Math.round(currentPct * 10) / 10); if (prevCumPct < 80) barColors.push('#f43f5e'); else if (prevCumPct < 95) barColors.push('#3b82f6'); else barColors.push('#a78bfa'); }); chart.setOption({ title: { text: '■ A类(核心) ■ B类(重要) ■ C类(一般)', right: 'center', top: 0, textStyle: { color: '#8b9ab5', fontSize: 10, fontWeight: 'normal' } }, tooltip: { trigger: 'axis', backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 } }, legend: { data: ['费用金额', '累计占比'], top: 20, textStyle: { color: '#8b9ab5', fontSize: 11 } }, grid: { left: 60, right: 50, top: 60, bottom: 30 }, xAxis: { type: 'category', data: byCat.map(d => d.category), axisLine: { lineStyle: { color: '#2a3a50' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, rotate: 30 } }, yAxis: [ { type: 'value', name: '金额(元)', nameTextStyle: { color: '#8b9ab5' }, axisLine: { show: false }, splitLine: { lineStyle: { color: 'rgba(42,58,80,0.5)' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, formatter: v => fmtWan(v) } }, { type: 'value', min: 0, max: 100, name: '累计占比(%)', nameTextStyle: { color: '#8b9ab5' }, axisLine: { show: false }, splitLine: { show: false }, axisLabel: { color: '#f7b731', fontSize: 10, formatter: v => v + '%' } } ], series: [ { name: '费用金额', type: 'bar', data: byCat.map((d, i) => ({ value: Math.round(d.amount), itemStyle: { color: barColors[i], borderRadius: [4, 4, 0, 0] } })), barWidth: '50%' }, { name: '累计占比', type: 'line', yAxisIndex: 1, data: cumPct, lineStyle: { color: '#f7b731', width: 3 }, itemStyle: { color: '#f7b731' }, symbol: 'circle', symbolSize: 8, markLine: { data: [ { yAxis: 80, lineStyle: { color: '#10b981', type: 'dashed', width: 2 }, label: { formatter: 'A类界限 (80%)', color: '#10b981', position: 'middle', fontSize: 11, backgroundColor: '#1a2332', padding: [2, 4], borderRadius: 4 } }, { yAxis: 95, lineStyle: { color: '#f97316', type: 'dashed', width: 2 }, label: { formatter: 'B类界限 (95%)', color: '#f97316', position: 'middle', fontSize: 11, backgroundColor: '#1a2332', padding: [2, 4], borderRadius: 4 } } ], silent: true } } ] }); } // =========================== // 5. DEPARTMENT RANKING // =========================== function renderDeptRank() { const chart = initChart('chartDeptRank'); if (!chart) return; const fd = appState.filteredData; if (fd.length === 0) return showEmptyChart(chart); const byDept = aggBy(fd, 'department'); byDept.sort((a, b) => a.amount - b.amount); chart.setOption({ tooltip: { trigger: 'axis', backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 } }, grid: { left: 90, right: 30, top: 10, bottom: 20 }, xAxis: { type: 'value', axisLine: { show: false }, splitLine: { lineStyle: { color: 'rgba(42,58,80,0.5)' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, formatter: v => fmtWan(v) } }, yAxis: { type: 'category', data: byDept.map(d => d.department), axisLine: { lineStyle: { color: '#2a3a50' } }, axisLabel: { color: '#e2e8f0', fontSize: 11 } }, series: [{ type: 'bar', data: byDept.map((d, i) => ({ value: Math.round(d.amount), itemStyle: { color: { type: 'linear', x: 0, y: 0, x2: 1, y2: 0, colorStops: [{ offset: 0, color: COLORS[i % COLORS.length] + '88' }, { offset: 1, color: COLORS[i % COLORS.length] }] } } })), barWidth: '55%', itemStyle: { borderRadius: [0, 6, 6, 0] }, label: { show: true, position: 'right', color: '#8b9ab5', fontSize: 10, formatter: p => fmtWan(p .value) + '元' } }] }); } // =========================== // 6. BUDGET ANALYSIS // =========================== function renderBudget() { const chart = initChart('chartBudget'); if (!chart) return; const fd = appState.filteredData; if (fd.length === 0) return showEmptyChart(chart); const byDept = aggBy(fd, 'department'); byDept.sort((a, b) => b.amount - a.amount); const actual = byDept.map(d => Math.round(d.amount)); const budget = byDept.map(d => Math.round(d.budget)); if (sum(budget) === 0) return showEmptyChart(chart, '当前筛选区间内未包含预算数据'); const diff = byDept.map((d, i) => Math.round(d.amount - d.budget)); const rate = byDept.map(d => d.budget > 0 ? Math.round(d.amount / d.budget * 1000) / 10 : 0); chart.setOption({ tooltip: { trigger: 'axis', backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 }, formatter: params => { let s = `${params[0].axisValue}
`; params.forEach(p => { if (p.seriesName !== '达成率') s += `${p.marker} ${p.seriesName}: ${fmt(p.value)}元
`; }); const idx = params[0].dataIndex; s += `达成率: ${rate[idx]}%`; return s; } }, legend: { data: ['实际发生', '预算额', '超支偏差'], top: 0, textStyle: { color: '#8b9ab5', fontSize: 11 } }, grid: { left: 60, right: 30, top: 40, bottom: 30 }, xAxis: { type: 'category', data: byDept.map(d => d.department), axisLine: { lineStyle: { color: '#2a3a50' } }, axisLabel: { color: '#8b9ab5', fontSize: 10 } }, yAxis: { type: 'value', axisLine: { show: false }, splitLine: { lineStyle: { color: 'rgba(42,58,80,0.5)' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, formatter: v => fmtWan(v) } }, series: [ { name: '实际发生', type: 'bar', data: actual, itemStyle: { color: '#3b82f6', borderRadius: [4, 4, 0, 0] }, barWidth: '25%', barGap: '10%' }, { name: '预算额', type: 'bar', data: budget, itemStyle: { color: 'rgba(249,115,22,0.5)', borderRadius: [4, 4, 0, 0] }, barWidth: '25%' }, { name: '超支偏差', type: 'bar', data: diff.map(d => ({ value: d, itemStyle: { color: d > 0 ? '#f43f5e' : '#10b981' } })), barWidth: '25%', markLine: { data: [{ yAxis: 0, lineStyle: { color: '#4b5e78', type: 'solid', width: 1 } }], silent: true } } ] }); } // =========================== // 7. TREEMAP // =========================== function renderTreemap() { const chart = initChart('chartTreemap'); if (!chart) return; const fd = appState.filteredData; if (fd.length === 0) return showEmptyChart(chart); const byDeptCat = aggBy(fd, 'department', 'category'); const treeData = {}; byDeptCat.forEach(d => { if (!treeData[d.department]) treeData[d.department] = { name: d.department, children: [] }; treeData[d.department].children.push({ name: d.category, value: Math.round(d.amount) }); }); chart.setOption({ tooltip: { backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 }, formatter: p => p.data.children ? `${p.name}
小计: ${fmt(p.value)}元` : `${p.name}
金额: ${fmt(p.value)}元` }, series: [{ type: 'treemap', data: Object.values(treeData), width: '95%', height: '85%', top: 20, roam: false, nodeClick: false, breadcrumb: { show: true, bottom: 0, itemStyle: { color: '#1a2332', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0' } } }, levels: [ { itemStyle: { borderColor: '#1a2332', borderWidth: 3, gapWidth: 3 }, upperLabel: { show: true, height: 28, backgroundColor: 'rgba(26,35,50,0.9)', color: '#f7b731', fontSize: 12, fontWeight: 'bold' } }, { itemStyle: { borderColor: '#2a3a50', borderWidth: 1, gapWidth: 1 }, colorSaturation: [0.3, 0.7], label: { show: true, color: '#e2e8f0', fontSize: 11 } } ], colorMappingBy: 'value', visualMin: 0, color: COLORS, label: { show: true, formatter: '{b}\n{c}', fontSize: 11, color: '#e2e8f0' }, itemStyle: { borderColor: '#1a2332', borderWidth: 1 } }] }); } // =========================== // 8. COST NATURE // =========================== function renderNature() { const chart = initChart('chartNature'); if (!chart) return; const fd = appState.filteredData; if (fd.length === 0) return showEmptyChart(chart); const byNature = aggBy(fd, d => d.costNature || '变动'); byNature.sort((a, b) => b.amount - a.amount); const natureColors = { '固定': '#3b82f6', '变动': '#f97316', '半变动': '#a78bfa' }; chart.setOption({ tooltip: { trigger: 'item', backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 } }, series: [{ type: 'pie', radius: ['35%', '70%'], center: ['50%', '50%'], data: byNature.map(d => ({ name: d.dim0, value: Math.round(d.amount), itemStyle: { color: natureColors[d.dim0] || '#888' } })), label: { color: '#e2e8f0', fontSize: 12, formatter: '{b}\n{d}%' }, emphasis: { label: { fontSize: 14, fontWeight: 'bold' } }, itemStyle: { borderColor: '#1a2332', borderWidth: 2 } }] }); } // =========================== // 9. CONTROLLABLE // =========================== function renderControl() { const chart = initChart('chartControl'); if (!chart) return; const fd = appState.filteredData; if (fd.length === 0) return showEmptyChart(chart); const byCtrl = aggBy(fd, d => d.controllable || '可控'); const colors = { '可控': '#10b981', '不可控': '#f43f5e' }; chart.setOption({ tooltip: { trigger: 'item', backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 } }, series: [{ type: 'pie', radius: ['40%', '72%'], center: ['50%', '50%'], data: byCtrl.map(d => ({ name: d.dim0, value: Math.round(d.amount), itemStyle: { color: colors[d.dim0] || '#888' } })), label: { color: '#e2e8f0', fontSize: 13, formatter: '{b}\n{c}元\n{d}%', fontWeight: 'bold' }, itemStyle: { borderColor: '#1a2332', borderWidth: 3 }, emphasis: { label: { fontSize: 15 }, itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.5)' } } }] }); } // =========================== // 10. HEATMAP // =========================== function renderHeatmap() { const chart = initChart('chartHeatmap'); if (!chart) return; const fd = appState.filteredData; if (fd.length === 0) return showEmptyChart(chart); const byDC = aggBy(fd, 'department', 'category'); const depts = DEPARTMENTS.filter(d => fd.some(f => f.department === d)); const cats = CATEGORIES.filter(c => fd.some(f => f.category === c)); const dataMap = {}; byDC.forEach(d => { dataMap[d.department + '|' + d.category] = d.amount; }); const heatData = []; let maxVal = 0; depts.forEach((dept, di) => { cats.forEach((cat, ci) => { const v = Math.round(dataMap[dept + '|' + cat] || 0); if (v > maxVal) maxVal = v; heatData.push([ci, di, v]); }); }); chart.setOption({ tooltip: { backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 }, formatter: p => `${depts[p.value[1]]} × ${cats[p.value[0]]}
发生金额: ${fmt(p.value[2])}元` }, grid: { left: 90, right: 80, top: 10, bottom: 50 }, xAxis: { type: 'category', data: cats, axisLine: { lineStyle: { color: '#2a3a50' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, rotate: 30 } }, yAxis: { type: 'category', data: depts, axisLine: { lineStyle: { color: '#2a3a50' } }, axisLabel: { color: '#e2e8f0', fontSize: 11 } }, visualMap: { min: 0, max: maxVal, calculable: true, orient: 'vertical', right: 0, top: 'center', inRange: { color: ['#111827', '#1e3a5f', '#2563eb', '#f7b731', '#f43f5e'] }, textStyle: { color: '#8b9ab5', fontSize: 10 } }, series: [{ type: 'heatmap', data: heatData, label: { show: true, color: '#e2e8f0', fontSize: 9, formatter: p => fmtWan(p.value[2]) }, emphasis: { itemStyle: { shadowBlur: 6, shadowColor: 'rgba(0,0,0,0.5)' } }, itemStyle: { borderColor: '#1a2332', borderWidth: 1 } }] }); } // =========================== // 11. STACKED BAR // =========================== function renderStacked() { const chart = initChart('chartStacked'); if (!chart) return; const fd = appState.filteredData; if (fd.length === 0) return showEmptyChart(chart); const months = [...new Set(fd.map(d => d.month))].sort(); const cats = CATEGORIES.filter(c => fd.some(f => f.category === c)); const series = cats.map((cat, i) => ({ name: cat, type: 'bar', stack: 'total', barWidth: '60%', emphasis: { focus: 'series' }, itemStyle: { color: COLORS[i % COLORS.length] }, data: months.map(m => { const items = fd.filter(d => d.month === m && d.category === cat); return Math .round(sum(items.map(d => d.amount))); }) })); chart.setOption({ tooltip: { trigger: 'axis', backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 11 }, formatter: params => { let s = `${params[0].axisValue}
`; let t = 0; params.forEach(p => { if (p.value > 0) { s += `${p.marker}${p.seriesName}: ${fmt(p.value)}元
`; t += p.value; } }); s += `整体合计: ${fmt(t)}元`; return s; } }, legend: { data: cats, top: 0, textStyle: { color: '#8b9ab5', fontSize: 10 }, type: 'scroll' }, grid: { left: 60, right: 20, top: 40, bottom: 40 }, xAxis: { type: 'category', data: months, axisLine: { lineStyle: { color: '#2a3a50' } }, axisLabel: { color: '#8b9ab5', fontSize: 9, rotate: 45 } }, yAxis: { type: 'value', axisLine: { show: false }, splitLine: { lineStyle: { color: 'rgba(42,58,80,0.5)' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, formatter: v => fmtWan(v) } }, series }); } // =========================== // 12. TORNADO // =========================== function renderTornado() { const chart = initChart('chartTornado'); if (!chart) return; const fd = appState.filteredData; const months = [...new Set(fd.map(d => d.month))].sort(); if (months.length < 2) return showEmptyChart(chart, '进行动因拆解需至少筛选含有2个月度的单期数据'); const latest = months[months.length - 1]; const prev = months[months.length - 2]; const latestData = fd.filter(d => d.month === latest); const prevData = fd.filter(d => d.month === prev); const cats = CATEGORIES.filter(c => fd.some(f => f.category === c)); const factors = []; cats.forEach(cat => { const currCat = latestData.filter(d => d.category === cat); const prevCat = prevData.filter(d => d.category === cat); const currAmt = sum(currCat.map(d => d.amount)); const prevAmt = sum(prevCat.map(d => d.amount)); const currQty = currCat.length; const prevQty = prevCat.length; const currPrice = currQty > 0 ? currAmt / currQty : 0; const prevPrice = prevQty > 0 ? prevAmt / prevQty : 0; const qtyEffect = (currQty - prevQty) * prevPrice; const priceEffect = currQty * (currPrice - prevPrice); const totalDiff = currAmt - prevAmt; factors.push({ name: cat, diff: totalDiff, qtyEffect, priceEffect }); }); factors.sort((a, b) => Math.abs(b.diff) - Math.abs(a.diff)); const top8 = factors.slice(0, 8).filter(f => Math.abs(f.diff) > 0); if (top8.length === 0) return showEmptyChart(chart, '临近两期数据静态无任何变动差异'); chart.setOption({ tooltip: { trigger: 'axis', backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 }, formatter: p => { const f = top8[p[0].dataIndex]; return `${f.name}
总差额: ${fmt(Math.round(f.diff))}元
频率波幅影响: ${fmt(Math.round(f.qtyEffect))}元
单次价差影响: ${fmt(Math.round(f.priceEffect))}元`; } }, legend: { data: ['频率波幅影响', '单次价差影响'], top: 0, textStyle: { color: '#8b9ab5', fontSize: 11 } }, grid: { left: 100, right: 30, top: 40, bottom: 20 }, yAxis: { type: 'category', data: top8.map(f => f.name).reverse(), axisLine: { lineStyle: { color: '#2a3a50' } }, axisLabel: { color: '#e2e8f0', fontSize: 11 } }, xAxis: { type: 'value', axisLine: { show: false }, splitLine: { lineStyle: { color: 'rgba(42,58,80,0.5)' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, formatter: v => fmtWan(v) } }, series: [ { name: '频率波幅影响', type: 'bar', stack: 'tornado', data: top8.map(f => Math.round(f.qtyEffect)) .reverse(), itemStyle: { color: p => p.value >= 0 ? '#f43f5e' : '#10b981' } }, { name: '单次价差影响', type: 'bar', stack: 'tornado', data: top8.map(f => Math.round(f.priceEffect)) .reverse(), itemStyle: { color: p => p.value >= 0 ? '#f97316' : '#22d3ee' } } ] }); } // =========================== // 13. YoY CONTRIBUTION // =========================== function renderYoYContrib() { const chart = initChart('chartYoYContrib'); if (!chart) return; const fd = appState.filteredData; const months2025 = [...new Set(fd.filter(d => d.year === 2025).map(d => d.monthNum))]; const months2026 = [...new Set(fd.filter(d => d.year === 2026).map(d => d.monthNum))]; const commonMonths = months2025.filter(m => months2026.includes(m)); if (commonMonths.length === 0) return showEmptyChart(chart, '当前筛选区间缺乏可用于同环比匹配计算的同期跨年数据'); const cats = CATEGORIES.filter(c => fd.some(f => f.category === c)); const contributions = cats.map(cat => { let y25 = 0, y26 = 0; commonMonths.forEach(m => { y25 += sum(fd.filter(d => d.year === 2025 && d.monthNum === m && d .category === cat).map(d => d.amount)); y26 += sum(fd.filter(d => d.year === 2026 && d.monthNum === m && d.category === cat).map( d => d.amount)); }); return { name: cat, diff: y26 - y25 }; }); contributions.sort((a, b) => Math.abs(b.diff) - Math.abs(a.diff)); chart.setOption({ tooltip: { trigger: 'axis', backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 } }, grid: { left: 100, right: 30, top: 20, bottom: 20 }, yAxis: { type: 'category', data: contributions.map(c => c.name).reverse(), axisLine: { lineStyle: { color: '#2a3a50' } }, axisLabel: { color: '#e2e8f0', fontSize: 11 } }, xAxis: { type: 'value', axisLine: { show: false }, splitLine: { lineStyle: { color: 'rgba(42,58,80,0.5)' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, formatter: v => fmtWan(v) } }, series: [{ type: 'bar', data: contributions.map(c => ({ value: Math.round(c.diff), itemStyle: { color: c.diff >= 0 ? '#f43f5e' : '#10b981' } })).reverse(), barWidth: '50%', itemStyle: { borderRadius: [0, 4, 4, 0] }, label: { show: true, position: 'right', color: '#8b9ab5', fontSize: 10, formatter: p => (p .value >= 0 ? '+' : '') + fmtWan(p.value) + '元' } }] }); } // =========================== // 14. QUADRANT // =========================== function renderQuadrant() { const chart = initChart('chartQuadrant'); if (!chart) return; const fd = appState.filteredData; const months = [...new Set(fd.map(d => d.month))].sort(); if (months.length < 2) return showEmptyChart(chart, '需至少包含2个周期月度数据计算增幅矩阵'); const depts = DEPARTMENTS.filter(d => fd.some(f => f.department === d)); const half = Math.floor(months.length / 2); const data = depts.map(dept => { const firstHalf = fd.filter(d => d.department === dept && months.indexOf(d .month) < half); const secondHalf = fd.filter(d => d.department === dept && months.indexOf(d .month) >= half); const amt1 = sum(firstHalf.map(d => d.amount)); const amt2 = sum(secondHalf .map(d => d.amount)); const growth = amt1 > 0 ? (amt2 - amt1) / amt1 : 0; const avgAmount = (amt1 + amt2) / 2; return { name: dept, growth: growth * 100, amount: avgAmount, employees: DEPT_EMP_COUNT[ dept] || 20 }; }); chart.setOption({ tooltip: { backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 }, formatter: p => `${p.data[3]}
周期内增长率: ${p.data[0].toFixed(1)}%
核算均值费用: ${fmt(Math.round(p.data[1]))}元` }, grid: { left: 60, right: 30, top: 30, bottom: 50 }, xAxis: { type: 'value', name: '成长波幅(%)', nameLocation: 'center', nameGap: 30, nameTextStyle: { color: '#8b9ab5' }, axisLine: { lineStyle: { color: '#2a3a50' } }, splitLine: { lineStyle: { color: 'rgba(42,58,80,0.3)' } }, axisLabel: { color: '#8b9ab5', fontSize: 10 } }, yAxis: { type: 'value', name: '支出体量均值(元)', nameLocation: 'center', nameGap: 45, nameTextStyle: { color: '#8b9ab5' }, axisLine: { lineStyle: { color: '#2a3a50' } }, splitLine: { lineStyle: { color: 'rgba(42,58,80,0.3)' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, formatter: v => fmtWan(v) } }, series: [{ type: 'scatter', symbolSize: p => Math.max(30, Math.sqrt(p[1] / 1000) * 2), data: data.map((d, i) => [d.growth, Math.round(d.amount), Math.round(d.amount), d.name]), itemStyle: { color: p => COLORS[p.dataIndex % COLORS.length], opacity: 0.85 }, label: { show: true, formatter: p => p.data[3], color: '#e2e8f0', fontSize: 11, position: 'top' }, markLine: { data: [{ xAxis: 0 }, { yAxis: mean(data.map(d => d.amount)) }], lineStyle: { color: '#4b5e78', type: 'dashed', width: 1 }, label: { show: false }, silent: true } }], graphic: [ { type: 'text', left: '15%', top: 50, style: { text: '低增长·高基数\n(重点关注优化)', fill: '#f43f5e', fontSize: 11, textAlign: 'center', opacity: 0.6 } }, { type: 'text', right: 40, top: 50, style: { text: '高增长·高基数\n(业务极速驱动)', fill: '#f97316', fontSize: 11, textAlign: 'center', opacity: 0.6 } }, { type: 'text', left: '15%', bottom: 60, style: { text: '低增长·低基数\n(高效率稳健)', fill: '#8b9ab5', fontSize: 11, textAlign: 'center', opacity: 0.6 } }, { type: 'text', right: 40, bottom: 60, style: { text: '高增长·低基数\n(小量试错标杆)', fill: '#10b981', fontSize: 11, textAlign: 'center', opacity: 0.6 } } ] }); } // =========================== // 15. YoY MATRIX TABLE // =========================== function renderYoYMatrix() { const chart = initChart('chartYoYMatrix'); if (!chart) return; const fd = appState.filteredData; const depts = DEPARTMENTS.filter(d => fd.some(f => f.department === d)); const cats = CATEGORIES.filter(c => fd.some(f => f.category === c)); const months = [...new Set(fd.map(d => d.month))].sort(); if (months.length < 2) return showEmptyChart(chart, '需至少筛选2期区间历史数据对比'); const sliceSize = Math.max(1, Math.floor(months.length / 2)); const latest = months.slice(-sliceSize); const prev = months.slice(0, sliceSize); const matrixData = []; let maxAbs = 0; depts.forEach((dept, di) => { cats.forEach((cat, ci) => { const curr = sum(fd.filter(d => d.department === dept && d.category === cat && latest.includes(d.month)).map(d => d.amount)); const prevAmt = sum(fd.filter(d => d.department === dept && d.category === cat && prev.includes( d.month)).map(d => d.amount)); const yoy = prevAmt > 0 ? ((curr - prevAmt) / prevAmt * 100) : 0; const rounded = Math.round(yoy * 10) / 10; if (Math.abs(rounded) > maxAbs) maxAbs = Math.abs(rounded); matrixData.push([ci, di, rounded]); }); }); chart.setOption({ tooltip: { backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 }, formatter: p => `${depts[p.value[1]]} × ${cats[p.value[0]]}
区间浮动率: ${p.value[2]}%` }, grid: { left: 90, right: 80, top: 10, bottom: 50 }, xAxis: { type: 'category', data: cats, axisLine: { lineStyle: { color: '#2a3a50' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, rotate: 30 } }, yAxis: { type: 'category', data: depts, axisLine: { lineStyle: { color: '#2a3a50' } }, axisLabel: { color: '#e2e8f0', fontSize: 11 } }, visualMap: { min: -maxAbs, max: maxAbs, calculable: true, orient: 'vertical', right: 0, top: 'center', inRange: { color: ['#10b981', '#1a2332', '#f43f5e'] }, textStyle: { color: '#8b9ab5', fontSize: 10 } }, series: [{ type: 'heatmap', data: matrixData, label: { show: true, color: '#e2e8f0', fontSize: 10, formatter: p => p.value[2] + '%' }, itemStyle: { borderColor: '#1a2332', borderWidth: 1 } }] }); } // =========================== // ANOMALY DETECTION // =========================== function detectAnomalies(data) { const grouped = groupBy(data, d => d.department + '|' + d.category); const anomalies = []; Object.entries(grouped).forEach(([key, items]) => { if (items.length < 4) return; const amounts = items.map(d => d.amount); const q1 = percentile(amounts, 0.25); const q3 = percentile(amounts, 0.75); const iqr = q3 - q1; if (iqr === 0) return; const upper = q3 + 1.5 * iqr; const lower = q1 - 1.5 * iqr; const med = median(amounts); items.forEach(d => { if (d.amount > upper || d.amount < lower) { const deviation = Math.abs(d.amount - (d.amount > upper ? upper : lower)) / iqr; let reason = ''; if (d.amount > upper) { reason = `期内金额突破历史四分位上限(${fmt(Math.round(upper))}元),严重偏离集群中位数(${fmt(Math.round(med))}元)。
风控推断: 极有可能为违规超额报销或突发的大宗异常支出流出。`; } else { reason = `金额掉出统计学下界(${fmt(Math.round(lower))}元),异常低于主体核销带。
风控推断: 疑似财务漏单、拆单逃避大额审批。`; } anomalies.push({ ...d, q1, q3, iqr, upper, lower, deviation, median: med, riskLevel: deviation > 3 ? '高' : deviation > 2 ? '中' : '低', reason: reason }); } }); }); anomalies.sort((a, b) => b.deviation - a.deviation); return anomalies; } function getBoxplotData(data) { const grouped = groupBy(data, 'category'); const result = {}; Object.entries(grouped).forEach(([cat, items]) => { const amounts = items.map(d => d.amount); if (amounts .length < 4) return; result[cat] = { min: Math.min(...amounts), q1: percentile(amounts, 0.25), median: median(amounts), q3: percentile(amounts, 0.75), max: Math.max(...amounts), outliers: [] }; const iqr = result[cat] .q3 - result[cat].q1; amounts.forEach(a => { if (a > result[cat].q3 + 1.5 * iqr || a < result[cat].q1 - 1.5 * iqr) result[cat] .outliers.push(a); }); }); return result; } function renderBoxplot() { const chart = initChart('chartBoxplot'); if (!chart) return; const fd = appState.filteredData; if (fd.length < 10) return showEmptyChart(chart, '筛选区间数据点过少,无法聚合箱线'); const bpData = getBoxplotData(fd); const validCats = Object.keys(bpData); if (validCats.length === 0) return showEmptyChart(chart, '科目分布稀疏,未满足四分位计算要求'); const boxData = []; const outlierData = []; validCats.forEach((cat, i) => { const d = bpData[cat]; boxData.push([d.min, d.q1, d.median, d.q3, d.max]); d.outliers.forEach(o => outlierData.push([i, o])); }); chart.setOption({ tooltip: { trigger: 'item', backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 } }, grid: { left: 60, right: 20, top: 20, bottom: 40 }, xAxis: { type: 'category', data: validCats, axisLine: { lineStyle: { color: '#2a3a50' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, rotate: 30 } }, yAxis: { type: 'value', axisLine: { show: false }, splitLine: { lineStyle: { color: 'rgba(42,58,80,0.5)' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, formatter: v => fmtWan(v) } }, series: [ { type: 'boxplot', data: boxData, itemStyle: { color: 'rgba(59,130,246,0.3)', borderColor: '#3b82f6', borderWidth: 1.5 }, emphasis: { itemStyle: { borderColor: '#f7b731', borderWidth: 2 } } }, { type: 'scatter', data: outlierData, symbolSize: 8, itemStyle: { color: '#f43f5e', opacity: 0.8 }, label: { show: false } } ] }); } function renderAnomalyScatter() { const chart = initChart('chartAnomalyScatter'); if (!chart) return; const fd = appState.filteredData; if (fd.length === 0) return showEmptyChart(chart); const anomalies = detectAnomalies(fd); const normalData = fd.filter(d => !anomalies.find(a => a.month === d.month && a.department === d.department && a.category === d.category)); const catIndex = {}; CATEGORIES.forEach((c, i) => catIndex[c] = i); chart.setOption({ tooltip: { backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 }, formatter: p => `${p.data[3]} | ${p.data[4]}
科目: ${p.name}
金额: ${fmt(p.data[1])}元` }, legend: { data: ['稳健常态池', '高亮异常离群'], top: 0, textStyle: { color: '#8b9ab5', fontSize: 11 } }, grid: { left: 60, right: 30, top: 40, bottom: 40 }, xAxis: { type: 'category', data: CATEGORIES, axisLine: { lineStyle: { color: '#2a3a50' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, rotate: 30 } }, yAxis: { type: 'value', axisLine: { show: false }, splitLine: { lineStyle: { color: 'rgba(42,58,80,0.3)' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, formatter: v => fmtWan(v) } }, series: [ { name: '稳健常态池', type: 'scatter', symbolSize: 6, data: normalData.map(d => ({ name: d.category, value: [catIndex[d.category], d.amount, 1, d .month, d.department ] })), itemStyle: { color: '#3b82f6', opacity: 0.15 }, blendMode: 'lighter' }, { name: '高亮异常离群', type: 'scatter', symbolSize: 14, data: anomalies.map(d => ({ name: d.category, value: [catIndex[d.category], d.amount, 2, d .month, d.department ] })), itemStyle: { color: '#f43f5e', opacity: 0.95, borderColor: '#fff', borderWidth: 1.5, shadowBlur: 10, shadowColor: 'rgba(244,63,94,0.5)' }, label: { show: true, formatter: p => p.data[4], position: 'top', color: '#f43f5e', fontSize: 9 } } ] }); } function renderAnomalyTable() { const fd = appState.filteredData; const anomalies = detectAnomalies(fd); const tbody = document.querySelector('#anomalyTable tbody'); if (anomalies.length === 0) { tbody.innerHTML = '所选区间内未捕获统计学异常突变点'; return; } tbody.innerHTML = anomalies.slice(0, 50).map((a, i) => { const riskClass = a.riskLevel === '高' ? 'danger' : a.riskLevel === '中' ? 'warning' : 'info'; return ` ${i+1}${a.month}${a.department}${a.category} ${fmt(Math.round(a.amount))} ${fmt(Math.round(a.upper))} ${a.deviation.toFixed(1)} IQR ${a.reason} ${a.riskLevel}预警 `; }).join(''); } function renderAnomalyDept() { const chart = initChart('chartAnomalyDept'); if (!chart) return; const fd = appState.filteredData; const anomalies = detectAnomalies(fd); if (anomalies.length === 0) return showEmptyChart(chart, '全维度健康运行'); const byDept = {}; anomalies.forEach(a => { byDept[a.department] = (byDept[a.department] || 0) + 1; }); const depts = DEPARTMENTS.filter(d => fd.some(f => f.department === d)); const data = depts.map(d => ({ name: d, count: byDept[d] || 0 })); data.sort((a, b) => b.count - a.count); chart.setOption({ tooltip: { trigger: 'axis', backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 } }, grid: { left: 90, right: 30, top: 10, bottom: 20 }, xAxis: { type: 'value', axisLine: { show: false }, splitLine: { lineStyle: { color: 'rgba(42,58,80,0.5)' } }, axisLabel: { color: '#8b9ab5', fontSize: 10 } }, yAxis: { type: 'category', data: data.map(d => d.name), axisLine: { lineStyle: { color: '#2a3a50' } }, axisLabel: { color: '#e2e8f0', fontSize: 11 } }, series: [{ type: 'bar', data: data.map(d => ({ value: d.count, itemStyle: { color: d.count > 5 ? '#f43f5e' : d.count > 2 ? '#f97316' : '#10b981' } })), barWidth: '50%', itemStyle: { borderRadius: [0, 6, 6, 0] }, label: { show: true, position: 'right', color: '#8b9ab5', fontSize: 11 } }] }); } function renderAnomalyKPIs() { const fd = appState.filteredData; const anomalies = detectAnomalies(fd); const highRisk = anomalies.filter(a => a.riskLevel === '高').length; const medRisk = anomalies.filter(a => a.riskLevel === '中').length; const anomalyAmount = sum(anomalies.map(a => a.amount)); const totalAmount = sum(fd.map(d => d.amount)); const grid = document.getElementById('anomalyKpi'); grid.innerHTML = [ { label: '疑似区间风险数', value: anomalies.length + ' 笔', cls: '' }, { label: '高频异动触锚', value: highRisk + ' 笔', cls: 'danger' }, { label: '中频隐患边缘', value: medRisk + ' 笔', cls: '' }, { label: '风险金额卷入比', value: totalAmount > 0 ? fmtPct(anomalyAmount / totalAmount) : '0%', cls: '' } ].map(k => `
${k.label}
${k.value}
` ).join(''); } function renderRadar() { const chart = initChart('chartRadar'); if (!chart) return; const fd = appState.filteredData; if (fd.length === 0) return showEmptyChart(chart); const depts = DEPARTMENTS.filter(d => fd.some(f => f.department === d)); const cats = CATEGORIES.filter(c => fd.some(f => f.category === c)); const byDeptCat = {}; fd.forEach(d => { const key = d.department + '|' + d.category; byDeptCat[key] = (byDeptCat[key] || 0) + d.amount; }); const deptProfiles = depts.map(dept => { const total = sum(fd.filter(d => d.department === dept).map(d => d .amount)); return { name: dept, data: cats.map(cat => { const v = byDeptCat[dept + '|' + cat] || 0; return total > 0 ? Math.round(v / total * 100) : 0; }) }; }); const maxPerDim = cats.map((_, i) => Math.max(...deptProfiles.map(d => d.data[i]))); chart.setOption({ tooltip: { backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 } }, legend: { data: depts, top: 0, textStyle: { color: '#8b9ab5', fontSize: 11 }, type: 'scroll' }, radar: { indicator: cats.map((c, i) => ({ name: c, max: maxPerDim[i] || 100 })), shape: 'polygon', radius: '65%', center: ['50%', '55%'], axisName: { color: '#8b9ab5', fontSize: 10 }, splitArea: { areaStyle: { color: ['rgba(26,35,50,0.3)', 'rgba(26,35,50,0.5)'] } }, splitLine: { lineStyle: { color: 'rgba(42,58,80,0.5)' } }, axisLine: { lineStyle: { color: 'rgba(42,58,80,0.5)' } } }, series: [{ type: 'radar', data: deptProfiles.map((d, i) => ({ name: d.name, value: d.data, lineStyle: { color: COLORS[i % COLORS.length], width: 2 }, itemStyle: { color: COLORS[i % COLORS .length] }, areaStyle: { color: COLORS[i % COLORS.length] + '15' } })) }] }); } function renderPerCapita() { const chart = initChart('chartPerCapita'); if (!chart) return; const fd = appState.filteredData; if (fd.length === 0) return showEmptyChart(chart); const byDept = aggBy(fd, 'department'); const months = [...new Set(fd.map(d => d.month))].sort(); const monthCount = months.length || 1; const data = byDept.map(d => ({ name: d.department, total: d.amount, perCapita: Math.round(d.amount / ( DEPT_EMP_COUNT[d.department] || 20) / monthCount) })); data.sort((a, b) => b.perCapita - a.perCapita); chart.setOption({ tooltip: { trigger: 'axis', backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 } }, grid: { left: 90, right: 30, top: 10, bottom: 20 }, xAxis: { type: 'value', axisLine: { show: false }, splitLine: { lineStyle: { color: 'rgba(42,58,80,0.5)' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, formatter: v => fmt(v) + '元' } }, yAxis: { type: 'category', data: data.map(d => d.name), axisLine: { lineStyle: { color: '#2a3a50' } }, axisLabel: { color: '#e2e8f0', fontSize: 11 } }, series: [{ type: 'bar', data: data.map((d, i) => ({ value: d.perCapita, itemStyle: { color: COLORS[i % COLORS .length] } })), barWidth: '50%', itemStyle: { borderRadius: [0, 6, 6, 0] }, label: { show: true, position: 'right', color: '#8b9ab5', fontSize: 10, formatter: p => fmt(p .value) + '元/人/月' } }] }); } function renderDeptGrowth() { const chart = initChart('chartDeptGrowth'); if (!chart) return; const fd = appState.filteredData; const years = [...new Set(fd.map(d => d.year))].sort(); if (years.length < 2) return showEmptyChart(chart, '跨年度涨幅动荡矩阵需跨越两个年度区间'); const depts = DEPARTMENTS.filter(d => fd.some(f => f.department === d)); const growths = depts.map(dept => { const y1 = sum(fd.filter(d => d.department === dept && d.year === years[ 0]).map(d => d.amount)); const y2 = sum(fd.filter(d => d.department === dept && d.year === years[1]) .map(d => d.amount)); const rate = y1 > 0 ? (y2 - y1) / y1 * 100 : 0; return { name: dept, rate: Math.round(rate * 10) / 10 }; }); growths.sort((a, b) => b.rate - a.rate); chart.setOption({ tooltip: { trigger: 'axis', backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 } }, grid: { left: 90, right: 30, top: 10, bottom: 20 }, xAxis: { type: 'value', axisLine: { show: false }, splitLine: { lineStyle: { color: 'rgba(42,58,80,0.5)' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, formatter: v => v + '%' } }, yAxis: { type: 'category', data: growths.map(g => g.name), axisLine: { lineStyle: { color: '#2a3a50' } }, axisLabel: { color: '#e2e8f0', fontSize: 11 } }, series: [{ type: 'bar', data: growths.map(g => ({ value: g.rate, itemStyle: { color: g.rate > 0 ? '#f43f5e' : '#10b981' } })), barWidth: '50%', itemStyle: { borderRadius: [0, 6, 6, 0] }, label: { show: true, position: 'right', color: '#8b9ab5', fontSize: 10, formatter: p => p .value + '%' }, markLine: { data: [{ xAxis: 0, lineStyle: { color: '#4b5e78', type: 'solid', width: 1 } }], silent: true } }] }); } function renderDeptTrend() { const chart = initChart('chartDeptTrend'); if (!chart) return; const fd = appState.filteredData; if (fd.length === 0) return showEmptyChart(chart); const months = [...new Set(fd.map(d => d.month))].sort(); const depts = DEPARTMENTS.filter(d => fd.some(f => f.department === d)); const series = depts.map((dept, i) => ({ name: dept, type: 'line', smooth: true, lineStyle: { width: 2, color: COLORS[i % COLORS.length] }, itemStyle: { color: COLORS[i % COLORS.length] }, symbol: 'circle', symbolSize: 5, data: months.map(m => Math.round(sum(fd.filter(d => d.month === m && d.department === dept).map(d => d.amount)))) })); chart.setOption({ tooltip: { trigger: 'axis', backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 11 } }, legend: { data: depts, top: 0, textStyle: { color: '#8b9ab5', fontSize: 11 } }, grid: { left: 60, right: 20, top: 40, bottom: 40 }, xAxis: { type: 'category', data: months, axisLine: { lineStyle: { color: '#2a3a50' } }, axisLabel: { color: '#8b9ab5', fontSize: 9, rotate: 45 } }, yAxis: { type: 'value', axisLine: { show: false }, splitLine: { lineStyle: { color: 'rgba(42,58,80,0.5)' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, formatter: v => fmtWan(v) } }, series }); } // =========================== // ANALYSIS REPORT (重磅升级:同比环比深度融合) // =========================== function renderReport() { const fd = appState.filteredData; const container = document.getElementById('reportContent'); if (fd.length === 0) { container.innerHTML = '
📝

当前区间内缺少数据,系统无法推演智能报告,请调整区间或导入新数据。

'; return; } // ===== 核心数据预计算(统一走 computeAnalytics 深度引擎,报告/PPT/Word 三端同源同口径) ===== const A = computeAnalytics(); const { total, totalBudget, months, monthCount, yoyMonths, yoyData, yoyTotal, yoyRate, byMonthArr, momRates, avgMom, maxMomIdx, minMomIdx, momVolatility, byCat, yearsInData, monthNums, yoyByCat, yoyByCatArr, byDept, yoyByDept, yoyByDeptArr, anomalies, highRisk, medRisk, anomalyAmount, overBudgetDepts, goodDepts, budgetRate, perCapita, hhi, forecast, season } = A; // ===== 报告 HTML 构建 ===== let html = `
`; html += `

费用分析报告

`; html += `
数据提取范围:${months[0]} 至 ${months[months.length-1]}(共计 ${monthCount} 个核算月度)| 覆盖部门:${byDept.length} 个 | 穿透单据:${fmt(fd.length)} 笔 | 报告生成:${new Date().toLocaleDateString('zh-CN')}
`; // 管理层执行摘要(一屏速读) html += ``; // ───────── 一、总体概况与核心指标综览 ───────── html += `

一、总体概况与核心指标综览

`; // KPI 卡片 html += `
期间累计总额
${fmtWan(total)}元
${monthCount}个月度 · ${fmt(fd.length)}笔
同比变动幅度
${yoyRate!==null?(yoyRate>0?'+':'')+fmtPct(yoyRate):'暂无'}
${yoyRate!==null?(yoyRate>=0?'↑ 同期上升':'↓ 同期回落'):'—'}
区间平均环比
${avgMom!==0?(avgMom>0?'+':'')+avgMom.toFixed(1)+'%':'—'}
波动率 ${momVolatility.toFixed(1)}%
预算总体达成
${budgetRate!==null?fmtPct(budgetRate):'无预算'}
${budgetRate!==null?(budgetRate>1?'超支 '+fmtWan(total-totalBudget)+'元':'安全边际 '+fmtWan(totalBudget-total)+'元'):'—'}
人均月度费用
${perCapita>0?fmt(perCapita)+'元':'—'}
按在编人数折算
科目集中度 HHI
${hhi.toFixed(3)}
${hhi>0.25?'高度集中':hhi>0.15?'中度集中':'较为分散'}
回归预测·未来3期
${fmtWan(forecast.fcSum)}元
${forecast.fcChange!==null?(forecast.fcChange>=0?'+':'')+fmtPct(forecast.fcChange)+' vs 近3期':'—'} · R²=${forecast.r2.toFixed(2)}
风控异常捕获
${anomalies.length} 笔
高风险 ${highRisk.length} · 卷入 ${total>0?(anomalyAmount/total*100).toFixed(2):'0'}%
`; html += `

本核心考核周期(${months[0]} 至 ${months[months.length-1]},共 ${monthCount} 个核算月度),企业全域累计财务核销发生额定格为 ${fmt(Math.round(total))} 元(折合 ${fmtWan(total)})。`; if (totalBudget > 0) html += `同期设立预算防线总额为 ${fmt(Math.round(totalBudget))} 元,总体预算达成消耗度为 ${(total/totalBudget*100).toFixed(1)}%,${total>totalBudget?'已形成预算外净流出,需要管理层高度关注':'表明整体支出管控处于合理区间'}。`; if (yoyRate !== null) html += `对比上年同期可比区间(${yoyMonths[0]} 至 ${yoyMonths[yoyMonths.length-1]},严格同月对齐口径)的发生额 ${fmt(Math.round(yoyTotal))} 元,本期出现了 ${Math.abs(yoyRate*100).toFixed(1)}% 的同比${yoyRate>=0?'增长':'下降'},${Math.abs(yoyRate)>0.1?'变动幅度较大,建议结合业务量指标做交叉验证':'整体变动幅度在可控范围之内'}。`; html += `从环比维度审视,期间内各月度平均环比变动率为 ${avgMom.toFixed(1)}%,标准波动率为 ${momVolatility.toFixed(1)}%,`; html += momVolatility > 15 ? '月度间波动较为剧烈,费用发生的规律性较弱,建议关注业务节奏的均匀性。' : '月度间波动相对平缓,费用发生呈现较好的节奏可控性。'; if (maxMomIdx >= 0) html += `其中,${momRates[maxMomIdx].month} 环比涨幅最为突出(${momRates[maxMomIdx].rate>0?'+':''}${momRates[maxMomIdx].rate.toFixed(1)}%),`; if (minMomIdx >= 0 && minMomIdx !== maxMomIdx) html += `${momRates[minMomIdx].month} 环比回落最为明显(${momRates[minMomIdx].rate.toFixed(1)}%)。`; html += `

`; // 摘要对比柱状图 html += `
`; html += `

上图直观呈现了本期各月度实际发生额与同期预算阈值的对照关系,以及上期同月度的同比参照线。柱体超出黄色虚线的部分即为预算溢出区段,需要逐月追溯业务成因;蓝色实线为同比参照基准,柱体明显高于蓝线的月份则代表同比扩张态势,应纳入重点关注清单。

`; // ───────── 二、费用趋势穿透与环比动态推演 ───────── html += `

二、费用趋势穿透与环比动态推演

`; html += `
`; html += `

趋势图完整映射了各月度的费用动能走势。整体走势${monthCount>6?'呈阶梯式波动态势':'相对平稳'},${byMonthArr[byMonthArr.length-1].amount > byMonthArr[0].amount * 1.15 ? '近期出现了显著的阶梯式拉升,通常伴随业务扩容或集中清账行为' : '未见极端脉冲,整体节奏基本受控'}。若走势出现明显阶梯式跳跃,通常伴随着业务扩容、集中清账、或周期性采购叠加;系统生成的 AI 预测长尾(浅蓝虚线区域)则提前暴露出下一个季度的潜在洪峰走向,建议管理层尽早储备现金流以对抗资金挤压风险。

`; // 环比率图 html += `
`; html += `

上图为各月度环比变动率趋势图。环比变动率是衡量费用增长节奏是否健康的核心微观指标。稳定在 ±10% 以内属于正常经营波动;突破 +15% 的月度需要业务部门出具合理的费用动因说明;低于 -15% 的月度则可能意味着费用确认延迟或预算执行不充分。本次分析周期内,环比波动率为 ${momVolatility.toFixed(1)}%,处于${momVolatility>15?'偏高':'合理'}水平。`; if (maxMomIdx >= 0) html += `峰值出现在 ${momRates[maxMomIdx].month}(${momRates[maxMomIdx].rate>0?'+':''}${momRates[maxMomIdx].rate.toFixed(1)}%),该月环比增量绝对额为 ${fmt(Math.round(momRates[maxMomIdx].diff))} 元,建议对该月重点回溯费用审批链条。`; html += `

`; // ───────── 三、财务结构解耦与同比深度剖析 ───────── html += `

三、财务结构解耦与同比深度剖析

`; const topLimit = Math.min(3, byCat.length); const topCat = byCat.slice(0, topLimit); html += `

依据帕累托二八定律(Pareto Principle)对费用科目进行 ABC 分层归类。本分析周期内,为企业锁定的核心吞金科目如下:

上述 ${topLimit} 大 A 类科目合计吞噬了全局 ${topCatPctSum.toFixed(1)}% 的财务弹药储备,是费用管控中应重点聚焦的核心领域。

`; html += `
`; // 同比瀑布桥接归因(新增) if (A.bridge.hasYoY) { html += `

瀑布桥接图进一步回答一个关键问题:同口径可比区间内,费用总额的同比变化究竟由哪些科目贡献?下图中蓝色基座代表上年同期总额,红/绿柱自上而下依次累加各科目的同比增减额,金色柱即为本期同口径总额落点(起点+全部红绿柱严格等于终点,构成闭环验证)。红色柱越长代表该科目"吃掉"的预算弹药越多,是总额攀升的直接推手;绿色柱则代表管控见效的科目。

`; html += `
`; } // 同比结构对比 html += `

各科目同比增减深度对比

`; html += `

以下图表将本期各费用科目与上一自然年度的同期数据进行逐项对比,从绝对额差异和变动率两个维度刻画结构性变迁:

`; html += `
`; html += ``; yoyByCatArr.forEach(c => { const judge = Math.abs(c.rate) > 20 ? (c.rate > 0 ? '显著扩张' : '大幅收缩') : Math.abs(c.rate) > 10 ? (c.rate > 0 ? '温和上升' : '温和下降') : '基本持平'; const color = Math.abs(c.rate) > 20 ? (c.rate > 0 ? '#dc2626' : '#15803d') : '#334155'; html += ``; }); html += `
费用科目本期发生额同期发生额同比差额同比变动率趋势研判
${c.category}${fmt(Math.round(c.y1||0))}${fmt(Math.round(c.y0))}${c.diff>=0?'+':''}${fmt(Math.round(c.diff))}${c.rate>=0?'+':''}${c.rate.toFixed(1)}%${judge}
`; const topGrowthCat = yoyByCatArr.find(c => c.rate > 0); const topShrinkCat = yoyByCatArr.find(c => c.rate < 0); if (topGrowthCat) html += `

从同比增减贡献来看,${topGrowthCat.category} 以 ${topGrowthCat.rate.toFixed(1)}% 的同比增幅居于扩张榜首,净增 ${fmt(Math.round(topGrowthCat.diff))} 元,是拉动费用总额上升的首要动因。`; if (topShrinkCat) html += `与之对应,${topShrinkCat.category} 同比下降 ${Math.abs(topShrinkCat.rate).toFixed(1)}%,体现了该项费用在管控力度上的积极成效。`; html += `

`; // ───────── 四、各部门预算执行与资金效能评估 ───────── html += `

四、各部门预算执行与资金效能评估

`; html += `

部门不应仅是费用支出的发源地,更应是精细化运营管理的落脚点。以下从预算达成、同比变动、人均效能三个维度对公司各业务战区进行综合审视:

`; // 部门预算表 html += ``; byDept.forEach(d => { let b = d.budget; let rate = b > 0 ? (d.amount / b * 100).toFixed(1) : '-'; const yd = yoyByDept[d.department]; let evalStr = ''; let evalColor = ''; if (b > 0) { if (d.amount > b) { evalStr = '溢出超限'; evalColor = '#dc2626'; } else if (d.amount < b * 0.8) { evalStr = '极端缩水'; evalColor = '#15803d'; } else { evalStr = '运行稳健'; evalColor = '#0f172a'; } } else { evalStr = '缺预算底座'; evalColor = '#94a3b8'; } html += ``; }); html += `
部门归属本期实际消耗批复总预算综合达成率同比变动率效能评价
${d.department}${fmt(Math.round(d.amount))}${b>0 ? fmt(Math.round(b)) : '未建账'}${rate}%${yd?(yd.rate>=0?'+':'')+yd.rate.toFixed(1)+'%':'—'}${evalStr}
`; // 部门同比图 html += `
`; if (overBudgetDepts.length > 0) html += `

超支预警:【${overBudgetDepts.map(d=>d.department).join('、')}】 的费用节奏已突破预算防线,形成了预算外净流出,建议责令部门负责人复核审批流程的合规性与合理性。

`; if (goodDepts.length > 0) html += `

效能表彰:【${goodDepts.map(d=>d.department).join('、')}】 成本控制力表现突出,保留了 15% 以上的安全边际,实现了同等业务产出下的最优节流效能。

`; // 部门雷达 html += `

部门费用结构画像雷达

`; html += `

雷达图从八大费用科目维度对各部门的费用结构占比进行全景对比。外轮廓越饱满的部门在对应科目的支出占比越高,图形的差异度直接反映了各部门职能属性带来的费用结构特征。

`; html += `
`; // 同比部门排行 html += `

部门同比变动率排行

`; html += `

以下排行展示了各部门与上一自然年度同期相比的费用增长幅度,帮助管理层识别出费用增速最快的业务单元:

`; html += ``; yoyByDeptArr.forEach((d, i) => { html += ``; }); html += `
排名部门本期累计同期累计同比差额变动率
${i+1}${d.department}${fmt(Math.round(d.y1||0))}${fmt(Math.round(d.y0))}${d.diff>=0?'+':''}${fmt(Math.round(d.diff))}${d.rate>=0?'+':''}${d.rate.toFixed(1)}%
`; if (yoyByDeptArr.length > 0) html += `

数据显示,${yoyByDeptArr[0].department} 以 ${yoyByDeptArr[0].rate.toFixed(1)}% 的同比增幅位居首位,${yoyByDeptArr[0].rate>20?'增速明显偏高,建议深入排查是否存在一次性或非常规支出':'增幅在合理区间内'}。${yoyByDeptArr.length>1?`排名末位的 ${yoyByDeptArr[yoyByDeptArr.length-1].department} 同比变动为 ${yoyByDeptArr[yoyByDeptArr.length-1].rate.toFixed(1)}%,${yoyByDeptArr[yoyByDeptArr.length-1].rate<0?'表明该部门在费用压缩上取得了显著成效':'增幅相对温和'}。`:''}

`; // ───────── 五、业财底层风控探针反馈与异常溯源 ───────── html += `

五、业财底层风控探针反馈与异常溯源

`; // 异常 KPI html += `
疑似异常笔数
${anomalies.length}
占总笔数 ${fd.length>0?(anomalies.length/fd.length*100).toFixed(2):'0'}%
高风险预警
${highRisk.length}
${highRisk.length>0?'需立即介入':'暂无'}
中风险隐患
${medRisk.length}
建议定期跟踪
风险金额卷入比
${total>0?(anomalyAmount/total*100).toFixed(2):'0'}%
异常金额占比
`; if (anomalies.length > 0) { html += `

全视野风控扫描通过 IQR(四分位距)箱型边缘试探法对全部 ${fd.length} 笔记录进行了统计学异常检测,共捕捉到 ${anomalies.length} 份涉嫌脱轨的异常账单,其中高风险 ${highRisk.length} 笔、中风险 ${medRisk.length} 笔,涉及风险金额 ${fmt(Math.round(anomalyAmount))} 元,占总额的 ${(anomalyAmount/total*100).toFixed(2)}%。

`; html += `

以下为高风险异常明细清单(仅展示前 5 笔最具代表性的偏离项):

`; html += ``; anomalies.slice(0, 5).forEach(a => { html += ``; }); html += `
期份溯源部门穿透科目单体爆点合理容忍度偏离幅度风控白盒解读
${a.month}${a.department}${a.category}${fmt(Math.round(a.amount))}${fmt(Math.round(a.upper))}${a.deviation.toFixed(1)}IQR${a.reason.replace(/<[^>]+>/g, '')}
`; html += `

以上断崖式拉升偏离了常规运营的统计分布规律,极大概率为合规真空区滋生的虚增做账或突发性失控支出。建议将 IQR 算法前置部署于业务侧 OA 提交端,当单笔金额越过风控阈值时自动进入锁定待审状态,由人工进行逐笔背书确认。

`; } else { html += `

✅ 极优良内控评定:风控筛网经过上万次四分位距核对,未发现任何超出 1.5IQR 界限的统计脉冲点。企业每一根费用毛细血管均流转顺畅合规,内控体系运行状态健康。

`; } // ───────── 六、前瞻预测与季节性洞察 ───────── html += `

六、前瞻预测与季节性洞察

`; html += `

系统对期间月度序列执行最小二乘线性回归(替代传统滑动均值估算,结果精确可复现),拟合斜率为 ${forecast.slope>=0?'+':''}${fmt(Math.round(forecast.slope))} 元/月,拟合优度 R²=${forecast.r2.toFixed(2)}。${forecast.r2>=0.5?'序列趋势性显著,预测结果可信度较高,可作为现金流排布的直接依据。':'序列受季节或一次性事项扰动较大,预测仅供参考,建议结合业务排期人工校准。'}据此外推,未来 3 期费用合计约 ${fmtWan(forecast.fcSum)} 元${forecast.fcChange!==null?`,较最近 3 期实际值(${fmtWan(forecast.last3Sum)} 元)${forecast.fcChange>=0?'扩张':'收缩'} ${fmtPct(Math.abs(forecast.fcChange))}`:''}。${forecast.fcChange>10?'若该斜率延续,建议提前安排授信额度与付款排期,防范资金链承压。':'当前趋势下资金排布压力可控,维持现有节奏即可。'}

`; html += `
`; if (season.available) { html += `

季节性指数热力洞察

`; html += `

季节性指数 = 当月科目发生额 ÷ 该科目区间月均值 × 100。指数 ≥130 的红色单元格为"高峰月",提示费用具有显著季节脉冲,应提前锁定供应商价格并预留备付金;指数 ≤70 的蓝色单元格为"低谷月",适合安排培训、盘点等刚性支出的错峰执行。本次识别出的 Top 高峰组合:${season.hot.length?season.hot.slice(0,3).map(h=>`${h.cat}(${h.mn}月,指数${h.idx})`).join('、'):'暂无显著高峰月'}。

`; html += `
`; } // ───────── 七、行动指令与改进建议 ───────── html += `

七、行动指令与改进建议

`; html += `

基于以上多维度数据穿透分析结果,提出以下管理行动建议:

`; // 结语 html += ``; html += `
`; container.innerHTML = html; setTimeout(() => { renderReportSpecialCharts( fd, byMonthArr, byCat, byDept, yoyData, yoyByCatArr, yoyByDeptArr, yoyTotal > 0, months, months[months.length - 1], months.length > 1 ? months[months.length - 2] : null ); }, 60); } function byMonthData(fd) { return aggBy(fd, 'month').sort((a, b) => a.month.localeCompare(b.month)); } // =========================== // Report Exclusive High-Res Charts (全面升级重构版) // =========================== function renderReportSpecialCharts(fd, byMonth, byCat, byDept, yoyData, yoyByCat, yoyByDept, hasYoY, months, lastMonth, prevMonth) { const textBase = '#334155'; const gridLine = '#e2e8f0'; const totalFd = sum(fd.map(d => d.amount)); const A = computeAnalytics(); // 深度引擎同源数据(桥接/预测/季节性) // 安全初始化图表实例 function safeInitRepChart(id, width, height) { const el = document.getElementById(id); if (!el) return null; const existing = echarts.getInstanceByDom(el); if (existing) existing.dispose(); const chart = echarts.init(el, null, { renderer: 'canvas' }); appState.reportCharts[id] = chart; return chart; } // 1. 摘要对比柱状图 rep_chart_summary (补齐:实际 vs 预算 vs 同比) const cSummary = safeInitRepChart('rep_chart_summary', 850, 380); if (cSummary && byMonth.length > 0) { const trendMonths = byMonth.map(d => d.month); const trendAmounts = byMonth.map(d => Math.round(d.amount)); const trendBudgets = byMonth.map(d => Math.round(d.budget)); const yoyByMonth = aggBy(yoyData, 'month').sort((a, b) => a.month.localeCompare(b.month)); const yoyMonthMap = {}; yoyByMonth.forEach(d => { const origMonth = (parseInt(d.month.split('-')[0]) + 1) + '-' + d.month.split('-')[1]; yoyMonthMap[origMonth] = Math.round(d.amount); }); const yoyTrendData = trendMonths.map(m => yoyMonthMap[m] || null); cSummary.setOption({ backgroundColor: '#ffffff', tooltip: { trigger: 'axis' }, legend: { data: ['本期发生额', '预算防线', '去年同期'], textStyle: { color: textBase }, top: 0 }, grid: { left: 80, right: 40, top: 40, bottom: 40 }, xAxis: { type: 'category', data: trendMonths, axisLabel: { color: textBase, rotate: 30, fontSize: 10 }, axisLine: { lineStyle: { color: textBase } } }, yAxis: { type: 'value', splitLine: { lineStyle: { color: gridLine } }, axisLabel: { color: textBase, formatter: v => fmtWan(v) } }, series: [ { name: '本期发生额', type: 'bar', data: trendAmounts, itemStyle: { color: '#3b82f6', borderRadius: [4, 4, 0, 0] }, barWidth: '40%' }, { name: '预算防线', type: 'line', data: trendBudgets, lineStyle: { color: '#f59e0b', type: 'dashed', width: 2 }, itemStyle: { color: '#f59e0b' }, symbolSize: 8, connectNulls: false }, { name: '去年同期', type: 'line', data: yoyTrendData, lineStyle: { color: '#94a3b8', type: 'dotted', width: 2 }, itemStyle: { color: '#94a3b8' }, symbolSize: 6, connectNulls: false } ] }); } // 2. 费用趋势穿透图 rep_chart_trend (纯动能面积折线) const cTrend = safeInitRepChart('rep_chart_trend', 850, 380); if (cTrend && byMonth.length > 0) { const trendMonths = byMonth.map(d => d.month); const trendAmounts = byMonth.map(d => Math.round(d.amount)); cTrend.setOption({ backgroundColor: '#ffffff', tooltip: { trigger: 'axis' }, grid: { left: 80, right: 40, top: 20, bottom: 40 }, xAxis: { type: 'category', data: trendMonths, axisLabel: { color: textBase, rotate: 30, fontSize: 10 }, axisLine: { lineStyle: { color: textBase } }, boundaryGap: false }, yAxis: { type: 'value', splitLine: { lineStyle: { color: gridLine } }, axisLabel: { color: textBase, formatter: v => fmtWan(v) } }, series: [{ name: '发生额', type: 'line', data: trendAmounts, smooth: true, lineStyle: { color: '#10b981', width: 3 }, itemStyle: { color: '#10b981' }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: 'rgba(16,185,129,0.4)' }, { offset: 1, color: 'rgba(16,185,129,0.05)' } ]) } }] }); } // 3. 环比月度变动图 rep_chart_mom (修复了错误的ID绑定) const cMom = safeInitRepChart('rep_chart_mom', 850, 320); if (cMom && months.length >= 2) { const momPairs = []; for (let i = 1; i < months.length; i++) { const currAmt = sum(fd.filter(d => d.month === months[i]).map(d => d.amount)); const prevAmt = sum(fd.filter(d => d.month === months[i - 1]).map(d => d.amount)); momPairs.push({ label: months[i - 1] + '→' + months[i], curr: Math.round(currAmt), prev: Math.round(prevAmt), rate: prevAmt > 0 ? ((currAmt - prevAmt) / prevAmt * 100) : 0 }); } cMom.setOption({ backgroundColor: '#ffffff', tooltip: { trigger: 'axis', formatter: p => { const d = momPairs[p[0].dataIndex]; return `${d.label}
前月: ${fmt(d.prev)}元
当月: ${fmt(d.curr)}元
环比: ${d.rate>=0?'+':''}${d.rate.toFixed(1)}%`; } }, grid: { left: 80, right: 40, top: 40, bottom: 40 }, xAxis: { type: 'category', data: momPairs.map(d => d.label), axisLabel: { color: textBase, rotate: 30, fontSize: 10 }, axisLine: { lineStyle: { color: textBase } } }, yAxis: { type: 'value', splitLine: { lineStyle: { color: gridLine } }, axisLabel: { color: textBase, formatter: v => fmtWan(v) } }, series: [ { name: '前月金额', type: 'bar', data: momPairs.map(d => d.prev), itemStyle: { color: '#cbd5e1', borderRadius: [4, 4, 0, 0] }, barWidth: '30%', barGap: '10%' }, { name: '当月金额', type: 'bar', data: momPairs.map(d => d.curr), itemStyle: { color: '#3b82f6', borderRadius: [4, 4, 0, 0] }, barWidth: '30%' }, { name: '环比变化率', type: 'line', data: momPairs.map(d => Math.round(d.rate * 10) / 10), lineStyle: { color: '#f59e0b', width: 2.5 }, itemStyle: { color: '#f59e0b' }, symbolSize: 8, label: { show: true, position: 'top', color: textBase, fontSize: 10, formatter: p => (p.value >= 0 ? '+' : '') + p.value + '%' } } ] }); } // 4. 帕累托ABC rep_chart_pareto const cPareto = safeInitRepChart('rep_chart_pareto', 850, 380); if (cPareto && byCat.length > 0) { let cum = 0; const cumPct = []; const barCols = []; const tAll = sum(byCat.map(d => d.amount)); byCat.forEach(d => { const pCum = (cum / tAll) * 100; cum += d.amount; cumPct.push(Math.round((cum / tAll) * 100 * 10) / 10); if (pCum < 80) barCols.push('#f43f5e'); else if (pCum < 95) barCols.push('#3b82f6'); else barCols.push('#a78bfa'); }); cPareto.setOption({ backgroundColor: '#ffffff', tooltip: { trigger: 'axis' }, grid: { left: 80, right: 60, top: 40, bottom: 40 }, xAxis: { type: 'category', data: byCat.map(d => d.category), axisLabel: { color: textBase, rotate: 30, fontSize: 10 }, axisLine: { lineStyle: { color: textBase } } }, yAxis: [ { type: 'value', splitLine: { lineStyle: { color: gridLine } }, axisLabel: { color: textBase, formatter: v => fmtWan(v) } }, { type: 'value', max: 100, splitLine: { show: false }, axisLabel: { color: '#f59e0b', formatter: v => v + '%' } } ], series: [ { name: '核销额', type: 'bar', data: byCat.map((d, i) => ({ value: Math.round(d.amount), itemStyle: { color: barCols[i], borderRadius: [4, 4, 0, 0] } })), barWidth: '50%' }, { name: '权重', type: 'line', yAxisIndex: 1, data: cumPct, lineStyle: { color: '#f59e0b', width: 3 }, itemStyle: { color: '#f59e0b' }, markLine: { data: [{ yAxis: 80, lineStyle: { color: '#10b981', type: 'dashed', width: 2 }, label: { formatter: 'A类(80%)', color: '#10b981', position: 'middle', backgroundColor: '#fff', padding: 4 } }, { yAxis: 95, lineStyle: { color: '#f97316', type: 'dashed', width: 2 }, label: { formatter: 'B类(95%)', color: '#f97316', position: 'middle', backgroundColor: '#fff', padding: 4 } }], silent: true } } ] }); } // 5. 同比瀑布图 rep_chart_yoy_waterfall const cWaterfall = safeInitRepChart('rep_chart_yoy_waterfall', 850, 380); if (cWaterfall && hasYoY && byCat.length > 0) { const waterfallData = byCat.map(c => { const yoyItem = yoyByCat.find(y => y.category === c.category); const yoyAmt = yoyItem ? yoyItem.amount : 0; return { name: c.category, curr: Math.round(c.amount), prev: Math.round(yoyAmt), diff: Math.round(c.amount - yoyAmt) }; }).filter(d => Math.abs(d.diff) > 0); waterfallData.sort((a, b) => Math.abs(b.diff) - Math.abs(a.diff)); if (waterfallData.length > 0) { cWaterfall.setOption({ backgroundColor: '#ffffff', tooltip: { trigger: 'axis', formatter: p => { const d = waterfallData[p[0].dataIndex]; return `${d.name}
本期: ${fmt(d.curr)}元
去年同期: ${fmt(d.prev)}元
同比增减: ${d.diff>=0?'+':''}${fmt(d.diff)}元`; } }, grid: { left: 100, right: 40, top: 20, bottom: 40 }, xAxis: { type: 'value', splitLine: { lineStyle: { color: gridLine } }, axisLabel: { color: textBase, formatter: v => fmtWan(v) } }, yAxis: { type: 'category', data: waterfallData.map(d => d.name).reverse(), axisLabel: { color: textBase, fontSize: 11 }, axisLine: { lineStyle: { color: textBase } } }, series: [{ type: 'bar', data: waterfallData.map(d => ({ value: d.diff, itemStyle: { color: d.diff >= 0 ? '#f43f5e' : '#10b981' } })).reverse(), barWidth: '50%', itemStyle: { borderRadius: [0, 6, 6, 0] }, label: { show: true, position: 'right', color: textBase, fontSize: 10, formatter: p => (p.value >= 0 ? '+' : '') + fmtWan(p.value) + '元' } }] }); } } // 6. 部门同比图 rep_chart_dept_yoy const cDeptYoy = safeInitRepChart('rep_chart_dept_yoy', 850, 380); if (cDeptYoy && hasYoY && byDept.length > 0) { const deptYoyData = byDept.map(d => { const yoyItem = yoyByDept.find(y => y.department === d.department); const yoyAmt = yoyItem ? yoyItem.amount : 0; const rate = yoyAmt > 0 ? ((d.amount - yoyAmt) / yoyAmt * 100) : 0; return { name: d.department, curr: Math.round(d.amount), prev: Math.round(yoyAmt), rate: Math.round(rate * 10) / 10 }; }); deptYoyData.sort((a, b) => b.rate - a.rate); cDeptYoy.setOption({ backgroundColor: '#ffffff', tooltip: { trigger: 'axis' }, grid: { left: 100, right: 40, top: 20, bottom: 20 }, xAxis: { type: 'value', splitLine: { lineStyle: { color: gridLine } }, axisLabel: { color: textBase, formatter: v => v + '%' } }, yAxis: { type: 'category', data: deptYoyData.map(d => d.name), axisLabel: { color: textBase, fontSize: 11 }, axisLine: { lineStyle: { color: textBase } } }, series: [{ type: 'bar', data: deptYoyData.map(d => ({ value: d.rate, itemStyle: { color: d.rate >= 0 ? '#f43f5e' : '#10b981' } })), barWidth: '50%', itemStyle: { borderRadius: [0, 6, 6, 0] }, label: { show: true, position: 'right', color: textBase, fontSize: 10, formatter: p => (p.value >= 0 ? '+' : '') + p.value + '%' }, markLine: { data: [{ xAxis: 0, lineStyle: { color: '#94a3b8', type: 'solid', width: 1 } }], silent: true } }] }); } // 8. 同比瀑布桥接 rep_chart_bridge(白底报告版) const cBridge = safeInitRepChart('rep_chart_bridge', 850, 380); if (cBridge && A.bridge.hasYoY && A.bridge.deltas.length > 0) { const b = A.bridge; const labels = ['上年同期', ...b.deltas.map(d => d.name), '本期合计']; const baseArr = [0], valArr = [b.start]; const colors = ['#3b82f6']; let run = b.start; b.deltas.forEach(d => { baseArr.push(d.diff >= 0 ? run : run + d.diff); valArr.push(Math.abs(d.diff)); colors.push(d.diff >= 0 ? '#f43f5e' : '#10b981'); run += d.diff; }); baseArr.push(0); valArr.push(b.end); colors.push('#f59e0b'); cBridge.setOption({ backgroundColor: '#ffffff', tooltip: { trigger: 'axis', formatter: p => { const i = p[0].dataIndex; const raw = i === 0 ? b.start : (i === labels.length - 1 ? b.end : b.deltas[i - 1].diff); return `${labels[i]}
${i === 0 || i === labels.length - 1 ? '总额' : '同比增减'}: ${raw >= 0 ? '+' : ''}${fmt(raw)}元`; } }, grid: { left: 90, right: 40, top: 30, bottom: 40 }, xAxis: { type: 'category', data: labels, axisLabel: { color: textBase, fontSize: 10, rotate: 30, interval: 0 }, axisLine: { lineStyle: { color: textBase } } }, yAxis: { type: 'value', splitLine: { lineStyle: { color: gridLine } }, axisLabel: { color: textBase, formatter: v => fmtWan(v) } }, series: [ { name: '基座', type: 'bar', stack: 'wf', data: baseArr, barWidth: '55%', itemStyle: { color: 'transparent' }, silent: true, tooltip: { show: false } }, { name: '增减额', type: 'bar', stack: 'wf', data: valArr.map((v, i) => ({ value: v, itemStyle: { color: colors[i], borderRadius: i === 0 || i === valArr.length - 1 ? [4, 4, 0, 0] : 0 } })), label: { show: true, position: 'top', color: textBase, fontSize: 9, formatter: p => { const i = p.dataIndex; const raw = i === 0 ? b.start : (i === valArr.length - 1 ? b.end : b.deltas[i - 1].diff); return i === 0 || i === valArr.length - 1 ? fmtWan(raw) : ((raw >= 0 ? '+' : '') + fmtWan(raw)); } } } ] }); } // 9. 回归预测 rep_chart_forecast(白底报告版:历史 + 拟合 + 置信带) const cFc = safeInitRepChart('rep_chart_forecast', 850, 380); if (cFc && byMonth.length >= 3) { const hist = byMonth.map(d => Math.round(d.amount)); const reg = linreg(hist); const fitPast = hist.map((_, i) => Math.round(reg.intercept + reg.slope * i)); const sigma = Math.max(stddev(hist.map((a, i) => a - fitPast[i])), mean(hist) * 0.03); const fLabels = A.forecast.labels, fVals = A.forecast.values; const allLabels = [...byMonth.map(d => d.month), ...fLabels]; const histSeries = hist.concat(fLabels.map(() => null)); const fitAll = [...fitPast, ...fVals]; const bandLow = Array(hist.length).fill(null).concat(fVals.map(v => Math.max(0, v - Math.round(sigma)))); const bandDelta = Array(hist.length).fill(null).concat(fVals.map(v => Math.round(sigma * 2))); cFc.setOption({ backgroundColor: '#ffffff', tooltip: { trigger: 'axis' }, legend: { data: ['历史发生额', '回归拟合线', '预测置信区间'], textStyle: { color: textBase, fontSize: 11 }, top: 0 }, grid: { left: 90, right: 40, top: 40, bottom: 40 }, xAxis: { type: 'category', data: allLabels, axisLabel: { color: textBase, fontSize: 10, rotate: 30 }, axisLine: { lineStyle: { color: textBase } } }, yAxis: { type: 'value', splitLine: { lineStyle: { color: gridLine } }, axisLabel: { color: textBase, formatter: v => fmtWan(v) } }, series: [ { name: '历史发生额', type: 'bar', data: histSeries, itemStyle: { color: '#3b82f6', borderRadius: [4, 4, 0, 0] }, barWidth: '40%' }, { name: '回归拟合线', type: 'line', data: fitAll, lineStyle: { color: '#f59e0b', width: 2.5, type: 'dashed' }, itemStyle: { color: '#f59e0b' }, symbol: 'circle', symbolSize: 5 }, { name: '区间下界', type: 'line', stack: 'band', data: bandLow, lineStyle: { opacity: 0 }, symbol: 'none', areaStyle: { opacity: 0 }, silent: true, tooltip: { show: false } }, { name: '预测置信区间', type: 'line', stack: 'band', data: bandDelta, lineStyle: { opacity: 0 }, symbol: 'none', silent: true, tooltip: { show: false }, areaStyle: { color: 'rgba(34,211,238,0.18)' }, endLabel: { show: true, formatter: 'R²=' + reg.r2.toFixed(2), color: '#0891b2', fontSize: 11 } } ] }); } // 10. 季节性热力 rep_chart_season(白底报告版) const cSeasonR = safeInitRepChart('rep_chart_season', 850, 380); if (cSeasonR && A.season.available) { const S = A.season; const data = []; let mn = 60, mx = 140; S.cats.forEach((c, yi) => S.rows[c].forEach((v, xi) => { mn = Math.min(mn, v); mx = Math.max(mx, v); data.push([xi, yi, v]); })); cSeasonR.setOption({ backgroundColor: '#ffffff', tooltip: { formatter: p => `${S.cats[p.value[1]]} · ${S.monthNums[p.value[0]]}月
季节指数: ${p.value[2]}(100=月均)` }, grid: { left: 100, right: 90, top: 20, bottom: 40 }, xAxis: { type: 'category', data: S.monthNums.map(m => m + '月'), axisLabel: { color: textBase, fontSize: 10 }, axisLine: { lineStyle: { color: textBase } } }, yAxis: { type: 'category', data: S.cats, axisLabel: { color: textBase, fontSize: 11 }, axisLine: { lineStyle: { color: textBase } } }, visualMap: { min: mn, max: mx, calculable: true, orient: 'vertical', right: 0, top: 'center', inRange: { color: ['#1e3a8a', '#22d3ee', '#10b981', '#f7b731', '#f43f5e'] }, textStyle: { color: textBase, fontSize: 10 } }, series: [{ type: 'heatmap', data, label: { show: true, color: '#0f172a', fontSize: 9, formatter: p => p.value[2] }, itemStyle: { borderColor: '#e2e8f0', borderWidth: 1 } }] }); } // 7. 部门雷达图 rep_chart_dept_radar (移除脆弱的 Copy 逻辑,进行独立安全渲染) const cRadar = safeInitRepChart('rep_chart_dept_radar', 850, 420); if (cRadar && byDept.length > 0) { const depts = DEPARTMENTS.filter(d => fd.some(f => f.department === d)); const cats = CATEGORIES.filter(c => fd.some(f => f.category === c)); const byDeptCat = {}; fd.forEach(d => { const key = d.department + '|' + d.category; byDeptCat[key] = (byDeptCat[key] || 0) + d.amount; }); const deptProfiles = depts.map(dept => { const totalD = sum(fd.filter(d => d.department === dept).map(d => d.amount)); return { name: dept, data: cats.map(cat => { const v = byDeptCat[dept + '|' + cat] || 0; return totalD > 0 ? Math.round(v / totalD * 100) : 0; }) }; }); const maxPerDim = cats.map((_, i) => Math.max(...deptProfiles.map(d => d.data[i]))); cRadar.setOption({ backgroundColor: '#ffffff', tooltip: { trigger: 'item' }, legend: { data: depts, top: 0, textStyle: { color: textBase, fontSize: 11 } }, radar: { indicator: cats.map((c, i) => ({ name: c, max: maxPerDim[i] || 100 })), shape: 'polygon', radius: '60%', center: ['50%', '55%'], axisName: { color: textBase, fontSize: 11 }, splitArea: { areaStyle: { color: ['#f8fafc', '#f1f5f9'] } }, splitLine: { lineStyle: { color: '#cbd5e1' } }, axisLine: { lineStyle: { color: '#cbd5e1' } } }, series: [{ type: 'radar', data: deptProfiles.map((d, i) => ({ name: d.name, value: d.data, lineStyle: { color: COLORS[i % COLORS.length], width: 2 }, itemStyle: { color: COLORS[i % COLORS.length] }, areaStyle: { color: COLORS[i % COLORS.length] + '20' } })) }] }); } } // ================================================================== // ★ 深度分析引擎(报告 / PPT / Word 三端共用统一计算底座) // ================================================================== function linreg(ys) { const n = ys.length; if (n < 2) return { slope: 0, intercept: ys[0] || 0, r2: 0, forecast: () => (ys[0] || 0) }; const xs = ys.map((_, i) => i); const mx = mean(xs), my = mean(ys); const sxx = sum(xs.map(x => (x - mx) ** 2)); const sxy = sum(xs.map((x, i) => (x - mx) * (ys[i] - my))); const slope = sxx > 0 ? sxy / sxx : 0; const intercept = my - slope * mx; const ssT = sum(ys.map(y => (y - my) ** 2)); const ssR = sum(xs.map((x, i) => (ys[i] - (intercept + slope * x)) ** 2)); const r2 = ssT > 0 ? Math.max(0, 1 - ssR / ssT) : 0; return { slope, intercept, r2, forecast: k => intercept + slope * (n - 1 + k) }; } function computeAnalytics() { if (_analyticsCache) return _analyticsCache; const fd = appState.filteredData; const total = sum(fd.map(d => d.amount)); const totalBudget = sum(fd.map(d => d.budget)); const budgetRate = totalBudget > 0 ? total / totalBudget : null; const months = [...new Set(fd.map(d => d.month))].sort(); const monthCount = months.length; const byMonthArr = aggBy(fd, 'month').sort((a, b) => a.month.localeCompare(b.month)); // 同比(★严格同口径配对:仅比较"上一年同月存在数据"的月份,杜绝跨期错位虚增) const fDept = document.getElementById('filterDept').value; const fCat = document.getElementById('filterCategory').value; const prevMonthOf = m => (parseInt(m.split('-')[0]) - 1) + '-' + m.split('-')[1]; const pairedMonths = months.filter(m => appState.rawData.some(d => d.month === prevMonthOf(m) && (fDept === 'all' || d.department === fDept) && (fCat === 'all' || d.category === fCat))); const yoyMonths = pairedMonths.map(prevMonthOf); const yoyData = appState.rawData.filter(d => yoyMonths.includes(d.month) && (fDept === 'all' || d.department === fDept) && (fCat === 'all' || d.category === fCat)); const yoyTotal = sum(yoyData.map(d => d.amount)); const pairedCurTotal = sum(fd.filter(d => pairedMonths.includes(d.month)).map(d => d.amount)); const yoyRate = yoyTotal > 0 ? (pairedCurTotal - yoyTotal) / yoyTotal : null; const hasYoY = yoyTotal > 0; // 环比 const momRates = []; for (let i = 1; i < byMonthArr.length; i++) { const prev = byMonthArr[i - 1].amount, curr = byMonthArr[i].amount; momRates.push({ month: byMonthArr[i].month, rate: prev > 0 ? (curr - prev) / prev * 100 : 0, curr, prev, diff: curr - prev }); } const avgMom = momRates.length ? mean(momRates.map(m => m.rate)) : 0; const momVolatility = momRates.length > 1 ? stddev(momRates.map(m => m.rate)) : 0; const maxMomIdx = momRates.length ? momRates.reduce((mx, m, i, arr) => m.rate > arr[mx].rate ? i : mx, 0) : -1; const minMomIdx = momRates.length ? momRates.reduce((mn, m, i, arr) => m.rate < arr[mn].rate ? i : mn, 0) : -1; // 科目 / 部门 const byCat = aggBy(fd, 'category').sort((a, b) => b.amount - a.amount); const byDept = aggBy(fd, 'department').sort((a, b) => b.amount - a.amount); const cats = CATEGORIES.filter(c => fd.some(f => f.category === c)); const depts = DEPARTMENTS.filter(d => fd.some(f => f.department === d)); const yearsInData = [...new Set(fd.map(d => d.year))].sort(); const monthNums = [...new Set(fd.map(d => d.monthNum))].sort(); // 科目同比 const yoyByCat = {}; const yoyByCatArr = []; byCat.forEach(c => { let y0 = 0, y1 = 0; monthNums.forEach(mn => { y0 += sum(appState.rawData.filter(r => r.year === yearsInData[0] && r.monthNum === mn && r.category === c.category).map(r => r.amount)); if (yearsInData.length > 1) y1 += sum(appState.rawData.filter(r => r.year === yearsInData[1] && r.monthNum === mn && r.category === c.category).map(r => r.amount)); }); const diff = y1 - y0, rate = y0 > 0 ? diff / y0 * 100 : 0; yoyByCat[c.category] = { y0, y1, diff, rate, amount: y0 }; yoyByCatArr.push({ category: c.category, y0, y1, diff, rate, amount: y0 }); }); yoyByCatArr.sort((a, b) => Math.abs(b.diff) - Math.abs(a.diff)); // 部门同比 const yoyByDept = {}; const yoyByDeptArr = []; byDept.forEach(d => { let y0 = 0, y1 = 0; monthNums.forEach(mn => { y0 += sum(appState.rawData.filter(r => r.year === yearsInData[0] && r.monthNum === mn && r.department === d.department).map(r => r.amount)); if (yearsInData.length > 1) y1 += sum(appState.rawData.filter(r => r.year === yearsInData[1] && r.monthNum === mn && r.department === d.department).map(r => r.amount)); }); const diff = y1 - y0, rate = y0 > 0 ? diff / y0 * 100 : 0; yoyByDept[d.department] = { y0, y1, diff, rate, amount: y0 }; yoyByDeptArr.push({ department: d.department, y0, y1, diff, rate, amount: y0 }); }); yoyByDeptArr.sort((a, b) => b.rate - a.rate); // 异常 const anomalies = detectAnomalies(fd); const highRisk = anomalies.filter(a => a.riskLevel === '高'); const medRisk = anomalies.filter(a => a.riskLevel === '中'); const anomalyAmount = sum(anomalies.map(a => a.amount)); // 预算 const overBudgetDepts = byDept.filter(d => d.budget > 0 && d.amount > d.budget); const goodDepts = byDept.filter(d => d.budget > 0 && d.amount <= d.budget * 0.85); // 人均效能 / 集中度 const empTotal = depts.reduce((s, d) => s + (DEPT_EMP_COUNT[d] || 20), 0) || 1; const perCapita = monthCount > 0 ? Math.round(total / empTotal / monthCount) : 0; const hhi = total > 0 ? sum(byCat.map(c => Math.pow(c.amount / total * 100, 2))) / 10000 : 0; // ★ 最小二乘回归预测(含置信区间) const histVals = byMonthArr.map(d => d.amount); const reg = linreg(histVals); const fitVals = histVals.map((_, i) => reg.intercept + reg.slope * i); const sigma = histVals.length >= 2 ? Math.max(stddev(histVals.map((a, i) => a - fitVals[i])), mean(histVals) * 0.03) : 0; const fLabels = [], fVals = [], fBands = []; for (let k = 1; k <= 3; k++) { fLabels.push('预测+' + k + '期'); const v = Math.max(0, Math.round(reg.forecast(k))); fVals.push(v); fBands.push([Math.max(0, Math.round(v - sigma)), Math.round(v + sigma)]); } const last3Sum = sum(histVals.slice(-3)); const fcSum = sum(fVals); const fcChange = last3Sum > 0 ? (fcSum - last3Sum) / last3Sum : null; // ★ 同比瀑布桥接(严格同口径:上年配对期总额 → 各科目增减 → 本期配对期总额,严格闭环) const bridgeDeltas = cats.map(c => { const prevAmt = sum(yoyData.filter(d => d.category === c).map(d => d.amount)); const curAmt = sum(fd.filter(d => pairedMonths.includes(d.month) && d.category === c).map(d => d.amount)); return { name: c, diff: Math.round(curAmt - prevAmt) }; }).filter(d => d.diff !== 0); bridgeDeltas.sort((a, b) => b.diff - a.diff); // ★ 季节性指数(100 = 科目区间月均) const seasonRows = {}; cats.forEach(c => { const monthTotals = {}; monthNums.forEach(mn => { monthTotals[mn] = sum(fd.filter(d => d.monthNum === mn && d.category === c).map(d => d.amount)); }); const avg = mean(monthNums.map(mn => monthTotals[mn])); seasonRows[c] = monthNums.map(mn => avg > 0 ? Math.round(monthTotals[mn] / avg * 100) : 0); }); const seasonAvailable = monthNums.length >= 3 && cats.length > 0; let seasonHot = [], seasonCold = []; if (seasonAvailable) { const flat = []; cats.forEach(c => seasonRows[c].forEach((v, i) => flat.push({ cat: c, mn: monthNums[i], idx: v }))); seasonHot = flat.filter(x => x.idx >= 130).sort((a, b) => b.idx - a.idx).slice(0, 5); seasonCold = flat.filter(x => x.idx <= 70).sort((a, b) => a.idx - b.idx).slice(0, 5); } // 管理层摘要(一句话结论集) const execSummary = []; execSummary.push(`本期 ${months[0]} 至 ${months[months.length - 1]} 累计发生费用 ${fmtWan(total)} 元,覆盖 ${byDept.length} 个部门、${cats.length} 个科目、${fmt(fd.length)} 笔单据。`); if (yoyRate !== null) execSummary.push(`同比${yoyRate >= 0 ? '上升' : '下降'} ${fmtPct(Math.abs(yoyRate))};环比月均 ${avgMom.toFixed(1)}%(波动率 ${momVolatility.toFixed(1)}%)。`); if (budgetRate !== null) execSummary.push(`预算达成率 ${fmtPct(budgetRate)},${budgetRate > 1 ? '已超支 ' + fmtWan(total - totalBudget) + ' 元,需立即管控' : '整体处于预算安全区间'}。`); execSummary.push(`费用集中度 HHI ${hhi.toFixed(3)}(${hhi > 0.25 ? '高度集中,头部科目主导全局' : hhi > 0.15 ? '中度集中' : '较为分散'}),人均月费用 ${fmt(perCapita)} 元。`); if (anomalies.length) execSummary.push(`IQR 风控捕获异常 ${anomalies.length} 笔(高风险 ${highRisk.length} 笔),卷入金额占比 ${fmtPct(anomalyAmount / (total || 1))}。`); if (fcChange !== null) execSummary.push(`回归预测未来 3 期合计约 ${fmtWan(fcSum)} 元,较近 3 期${fcChange >= 0 ? '扩张' : '收缩'} ${fmtPct(Math.abs(fcChange))}(拟合优度 R²=${reg.r2.toFixed(2)})。`); _analyticsCache = { fd, total, totalBudget, budgetRate, months, monthCount, byMonthArr, momRates, avgMom, momVolatility, maxMomIdx, minMomIdx, maxMom: maxMomIdx >= 0 ? momRates[maxMomIdx] : null, minMom: minMomIdx >= 0 ? momRates[minMomIdx] : null, yoyMonths, yoyData, yoyTotal, yoyRate, hasYoY, byCat, byDept, cats, depts, yearsInData, monthNums, yoyByCat, yoyByCatArr, yoyByDept, yoyByDeptArr, anomalies, highRisk, medRisk, anomalyAmount, overBudgetDepts, goodDepts, empTotal, perCapita, hhi, forecast: { labels: fLabels, values: fVals, bands: fBands, slope: reg.slope, r2: reg.r2, fcSum, fcChange, last3Sum }, bridge: { start: Math.round(yoyTotal), deltas: bridgeDeltas, end: Math.round(pairedCurTotal), hasYoY, pairedMonths }, season: { rows: seasonRows, monthNums, cats, available: seasonAvailable, hot: seasonHot, cold: seasonCold }, execSummary }; return _analyticsCache; } function buildRecommendations(A) { const list = []; list.push(['零基预算重构', '对头部科目【' + (A.byCat[0] ? A.byCat[0].category : '-') + '】【' + (A.byCat[1] ? A.byCat[1].category : '-') + '】实行零基预算,推翻"增量加码"惯性,逐笔倒推业务动因。']); if (A.overBudgetDepts.length) list.push(['超支熔断机制', A.overBudgetDepts.map(d => d.department).join('、') + ' 已突破预算防线,建议暂停弹性报销额度并启动月度预算质询会。']); if (A.anomalies.length) list.push(['IQR风控前置', '将四分位异常检测嵌入 OA 提单端,单笔超阈值自动锁定,财务+审计双背书放行。']); if (A.momVolatility > 15) list.push(['平滑支出节奏', '环比波动率 ' + A.momVolatility.toFixed(1) + '% 偏高,建议部门建立月度费用排布计划,杜绝突击花钱。']); if (A.yoyByDeptArr.length && A.yoyByDeptArr[0].rate > 15) list.push(['增速专项审计', A.yoyByDeptArr[0].department + ' 同比增长 ' + A.yoyByDeptArr[0].rate.toFixed(1) + '%,建议内审穿透核查业务量与费用增长的匹配性。']); if (A.season.available && A.season.hot.length) list.push(['季节性资金排布', A.season.hot[0].cat + ' 在 ' + A.season.hot[0].mn + ' 月季节指数高达 ' + A.season.hot[0].idx + ',建议提前储备现金流并锁定供应商价格。']); if (A.forecast.fcChange !== null && A.forecast.fcChange > 10) list.push(['现金流前瞻预警', '回归预测未来3期费用 ' + fmtWan(A.forecast.fcSum) + ' 元(较近3期 +' + fmtPct(A.forecast.fcChange) + '),建议提前安排授信与付款排期。']); list.push(['业财融合转型', '以本报告为数据底座,推动财务 BP 深入业务一线,从核算记账转向价值创造与数据驱动决策。']); return list; } // ---------- 新增深度图表:同比瀑布桥接 ---------- function renderBridge() { const chart = initChart('chartBridge'); if (!chart) return; const A = computeAnalytics(); const b = A.bridge; if (!b.hasYoY || b.deltas.length === 0) return showEmptyChart(chart, '当前筛选缺乏可匹配的去年同期数据,无法构建同比桥接'); const labels = ['上年同期', ...b.deltas.map(d => d.name), '本期合计']; const baseArr = [0], valArr = [b.start]; const colors = ['#3b82f6']; let run = b.start; b.deltas.forEach(d => { baseArr.push(d.diff >= 0 ? run : run + d.diff); valArr.push(Math.abs(d.diff)); colors.push(d.diff >= 0 ? '#f43f5e' : '#10b981'); run += d.diff; }); baseArr.push(0); valArr.push(b.end); colors.push('#f7b731'); chart.setOption({ tooltip: { trigger: 'axis', backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 }, formatter: p => { const i = p[0].dataIndex; const raw = i === 0 ? b.start : (i === labels.length - 1 ? b.end : b.deltas[i - 1].diff); return `${labels[i]}
${i === 0 || i === labels.length - 1 ? '总额' : '同比增减'}: ${raw >= 0 ? '+' : ''}${fmt(raw)}元`; } }, grid: { left: 70, right: 30, top: 30, bottom: 50 }, xAxis: { type: 'category', data: labels, axisLine: { lineStyle: { color: '#2a3a50' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, rotate: 30, interval: 0 } }, yAxis: { type: 'value', axisLine: { show: false }, splitLine: { lineStyle: { color: 'rgba(42,58,80,0.5)' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, formatter: v => fmtWan(v) } }, series: [ { name: '基座', type: 'bar', stack: 'wf', data: baseArr, barWidth: '55%', itemStyle: { color: 'transparent' }, silent: true, tooltip: { show: false } }, { name: '增减额', type: 'bar', stack: 'wf', data: valArr.map((v, i) => ({ value: v, itemStyle: { color: colors[i], borderRadius: i === 0 || i === valArr.length - 1 ? [4, 4, 0, 0] : 0 } })), label: { show: true, position: 'top', color: '#8b9ab5', fontSize: 9, formatter: p => { const i = p.dataIndex; const raw = i === 0 ? b.start : (i === valArr.length - 1 ? b.end : b.deltas[i - 1].diff); return i === 0 || i === valArr.length - 1 ? fmtWan(raw) : ((raw >= 0 ? '+' : '') + fmtWan(raw)); } } } ] }); } // ---------- 新增深度图表:累计费用曲线对比 ---------- function renderCum() { const chart = initChart('chartCum'); if (!chart) return; const A = computeAnalytics(); if (A.months.length < 2) return showEmptyChart(chart, '累计曲线需至少2个月度数据'); const ms = A.months; let c1 = 0; const cur = ms.map(m => { c1 += A.byMonthArr.find(x => x.month === m).amount; return Math.round(c1); }); let c2 = 0; const bud = ms.map(m => { c2 += A.byMonthArr.find(x => x.month === m).budget; return c2 > 0 ? Math.round(c2) : null; }); const hasBud = bud.some(v => v !== null); let c3 = 0; const yoy = ms.map(m => { const mn = parseInt(m.split('-')[1]); c3 += sum(A.yoyData.filter(d => d.monthNum === mn).map(d => d.amount)); return A.hasYoY ? Math.round(c3) : null; }); chart.setOption({ tooltip: { trigger: 'axis', backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 } }, legend: { data: ['本期累计', '去年同期累计', '预算累计线'].filter((_, i) => i !== 1 || A.hasYoY).filter((_, i) => i !== 2 || hasBud), top: 0, textStyle: { color: '#8b9ab5', fontSize: 11 } }, grid: { left: 70, right: 30, top: 40, bottom: 40 }, xAxis: { type: 'category', data: ms, axisLine: { lineStyle: { color: '#2a3a50' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, rotate: 45 } }, yAxis: { type: 'value', axisLine: { show: false }, splitLine: { lineStyle: { color: 'rgba(42,58,80,0.5)' } }, axisLabel: { color: '#8b9ab5', fontSize: 10, formatter: v => fmtWan(v) } }, series: [ { name: '本期累计', type: 'line', data: cur, smooth: true, lineStyle: { color: '#3b82f6', width: 3 }, itemStyle: { color: '#3b82f6' }, symbol: 'circle', symbolSize: 5, areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(59,130,246,0.2)' }, { offset: 1, color: 'rgba(59,130,246,0)' }] } } }, { name: '去年同期累计', type: 'line', data: yoy, lineStyle: { color: '#8b9ab5', width: 2, type: 'dashed' }, itemStyle: { color: '#8b9ab5' }, symbol: 'none', connectNulls: false }, { name: '预算累计线', type: 'line', data: bud, lineStyle: { color: '#f97316', width: 2, type: 'dotted' }, itemStyle: { color: '#f97316' }, symbol: 'none', connectNulls: false } ] }); } // ---------- 新增深度图表:季节性指数热力 ---------- function renderSeason() { const chart = initChart('chartSeason'); if (!chart) return; const A = computeAnalytics(); const S = A.season; if (!S.available) return showEmptyChart(chart, '季节性指数需至少3个不同月份的数据'); const cats = S.cats, mns = S.monthNums; const data = []; let mn = 60, mx = 140; cats.forEach((c, yi) => S.rows[c].forEach((v, xi) => { mn = Math.min(mn, v); mx = Math.max(mx, v); data.push([xi, yi, v]); })); chart.setOption({ tooltip: { backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 }, formatter: p => `${cats[p.value[1]]} · ${mns[p.value[0]]}月
季节指数: ${p.value[2]}(100=月均)
${p.value[2] >= 130 ? '⚠ 高峰月:提前备付资金' : p.value[2] <= 70 ? '✓ 低谷月:适合错峰执行刚性支出' : '常态月'}` }, grid: { left: 90, right: 90, top: 10, bottom: 50 }, xAxis: { type: 'category', data: mns.map(m => m + '月'), axisLine: { lineStyle: { color: '#2a3a50' } }, axisLabel: { color: '#8b9ab5', fontSize: 10 } }, yAxis: { type: 'category', data: cats, axisLine: { lineStyle: { color: '#2a3a50' } }, axisLabel: { color: '#e2e8f0', fontSize: 11 } }, visualMap: { min: mn, max: mx, calculable: true, orient: 'vertical', right: 0, top: 'center', inRange: { color: ['#1e3a8a', '#22d3ee', '#10b981', '#f7b731', '#f43f5e'] }, textStyle: { color: '#8b9ab5', fontSize: 10 } }, series: [{ type: 'heatmap', data, label: { show: true, color: '#e2e8f0', fontSize: 9, formatter: p => p.value[2] }, itemStyle: { borderColor: '#1a2332', borderWidth: 1 } }] }); } // ---------- 新增深度图表:部门→科目桑基图 ---------- function renderSankey() { const chart = initChart('chartSankey'); if (!chart) return; const fd = appState.filteredData; if (fd.length === 0) return showEmptyChart(chart); const links = aggBy(fd, 'department', 'category') .map(d => ({ source: d.department, target: d.category, value: Math.round(d.amount) })) .filter(l => l.value > 0); if (links.length === 0) return showEmptyChart(chart); // ★ 逐节点显式着色(sankey 不支持 series 级 itemStyle.color 回调,须写入节点数据, // 流带的 'gradient' 渐变才会跟随节点颜色呈现彩色,否则黑底画黑带不可见) const nodes = []; const seen = {}; let colorIdx = 0; links.forEach(l => { if (!seen[l.source]) { seen[l.source] = 1; nodes.push({ name: l.source, itemStyle: { color: COLORS[colorIdx++ % COLORS.length] } }); } if (!seen[l.target]) { seen[l.target] = 1; nodes.push({ name: l.target, itemStyle: { color: COLORS[colorIdx++ % COLORS.length] } }); } }); chart.setOption({ tooltip: { backgroundColor: 'rgba(26,35,50,0.95)', borderColor: '#2a3a50', textStyle: { color: '#e2e8f0', fontSize: 12 }, formatter: p => p.dataType === 'edge' ? `${p.data.source} → ${p.data.target}
流金额: ${fmt(p.data.value)}元` : `${p.name}
节点合计: ${fmt(p.value)}元` }, series: [{ type: 'sankey', left: 20, top: 20, right: 150, bottom: 20, nodeWidth: 16, nodeGap: 14, nodeAlign: 'justify', data: nodes, links, itemStyle: { borderColor: '#0b1120', borderWidth: 1 }, lineStyle: { color: 'gradient', curveness: 0.5, opacity: 0.55 }, label: { color: '#e2e8f0', fontSize: 12, fontWeight: 500 }, emphasis: { focus: 'adjacency', lineStyle: { opacity: 0.85 } } }] }); } // ================================================================== // ★ 导出联动底座(PPT / Word 共用) // ================================================================== function ensureTabVisible(name) { return new Promise(res => { const panel = document.getElementById('panel-' + name); const tab = document.querySelector('.tab[data-tab="' + name + '"]'); if (panel && panel.classList.contains('active')) { setTimeout(res, 150); return; } if (tab) tab.click(); setTimeout(res, 750); }); } function chartImage(id, pixelRatio, bg) { const dom = document.getElementById(id); const c = dom && echarts.getInstanceByDom(dom); if (!c) return null; const w = c.getWidth(), h = c.getHeight(); if (!w || !h) return null; try { return c.getDataURL({ type: 'png', pixelRatio: pixelRatio || 2, backgroundColor: bg || '#ffffff' }); } catch (e) { return null; } } // ★ 关键修复:必须在"图表所属页签可见"的状态下 激活→强制resize→等待重绘→再捕获, // 否则 ECharts 在隐藏容器中会拿到异常宽度,导致导出图片被压成窄条。 async function captureChartsForTab(tabName, ids, store, pixelRatio, bg) { await ensureTabVisible(tabName); ids.forEach(id => { const dom = document.getElementById(id); const c = dom && echarts.getInstanceByDom(dom); if (c) { try { c.resize(); } catch (e) { } } }); await new Promise(r => setTimeout(r, 350)); // 等待逐帧重绘完成 ids.forEach(id => { const url = chartImage(id, pixelRatio || 2, bg); if (!url) return; const c = echarts.getInstanceByDom(document.getElementById(id)); if (!c) return; const ratio = c.getHeight() / Math.max(1, c.getWidth()); store[id] = { data: url.replace('data:', ''), ratio: Math.min(1.5, Math.max(0.2, ratio)) }; }); } const REP_CHART_IDS = ['rep_chart_summary', 'rep_chart_trend', 'rep_chart_mom', 'rep_chart_forecast', 'rep_chart_season', 'rep_chart_pareto', 'rep_chart_bridge', 'rep_chart_yoy_waterfall', 'rep_chart_dept_yoy', 'rep_chart_dept_radar']; async function prepareExportImages() { const imgs = {}; await captureChartsForTab('overview', ['chartBridge', 'chartCum'], imgs, 2, '#0b1120'); // 暗色底:无缝融入PPT深色幻灯片 await captureChartsForTab('structure', ['chartHeatmap', 'chartSankey', 'chartSeason'], imgs, 2, '#0b1120'); await captureChartsForTab('report', REP_CHART_IDS, imgs, 2, '#ffffff'); // 报告图为白底主题 return imgs; } function u8FromDataURL(u) { const b64 = u.split(',')[1]; const bin = atob(b64); const arr = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); return arr; } function dl(blob, name) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(a.href), 4000); } function stamp() { const d = new Date(); const p = n => String(n).padStart(2, '0'); return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}`; } const PPT_COLORS = ['F7B731', '3B82F6', 'A78BFA', 'F43F5E', '10B981', 'F97316', '22D3EE', 'EC4899', '84CC16', '06B6D4']; // ================================================================== // ★ 一键导出:可编辑 PPT(原生图表 / 原生表格,PowerPoint 内可直接改) // ================================================================== async function exportPPT() { try { await ensureLib('pptx'); } catch (e) { showToast('PPT组件加载失败,请检查网络后重试', 'error'); return; } if (appState.filteredData.length === 0) { showToast('暂无数据可导出', 'error'); return; } showToast('正在生成可编辑PPT(原生图表·原生表格·可改文字)...', 'success'); try { const A = computeAnalytics(); const pptx = new PptxGenJS(); pptx.defineLayout({ name: 'WIDE', width: 13.333, height: 7.5 }); pptx.layout = 'WIDE'; const DARK = '0B1120', CARD = '16213A', ACCENT = 'F7B731', TXT = 'E2E8F0', DIM = '8B9AB5', BLUE = '3B82F6', GREEN = '10B981', ROSE = 'F43F5E', ORANGE = 'F97316', GRID = '22304A'; const F = '微软雅黑'; const header = (s, title, sub) => { s.background = { color: DARK }; s.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: 13.333, h: 0.14, fill: { color: ACCENT } }); s.addText(title, { x: 0.55, y: 0.3, w: 10, h: 0.55, fontSize: 21, bold: true, color: TXT, fontFace: F }); if (sub) s.addText(sub, { x: 0.55, y: 0.88, w: 12.2, h: 0.32, fontSize: 10.5, color: DIM, fontFace: F }); s.addText('费用智能分析系统 · 自动生成 · 全部元素可编辑', { x: 8.3, y: 7.12, w: 4.7, h: 0.3, fontSize: 8, color: '4B5E78', align: 'right', fontFace: F }); }; const chartBase = { chartArea: { fill: { color: CARD } }, plotArea: { fill: { color: CARD } }, catAxisLabelColor: DIM, valAxisLabelColor: DIM, catAxisLabelFontSize: 9, valAxisLabelFontSize: 9, valGridLine: { style: 'solid', color: GRID, pt: 0.5 }, legendColor: DIM, legendFontSize: 10, showLegend: true, legendPos: 't' }; // 按页签顺序激活→逐图强制resize→高清捕获(暗色底融入幻灯片,杜绝隐藏态窄幅捕获) const imgs = await prepareExportImages(); // 单图大版式:一张图几乎满幅显示 + 卡片描边 + 底部洞察字幕 const bigImgSlide = (title, sub, o, takeaway) => { const s = pptx.addSlide(); header(s, title, sub); if (o && o.data) { let w = 11.9, h = w * o.ratio; if (h > 5.05) { h = 5.05; w = h / o.ratio; } s.addShape(pptx.ShapeType.roundRect, { x: (13.333 - w) / 2 - 0.14, y: 1.25 + (5.05 - h) / 2 - 0.14, w: w + 0.28, h: h + 0.28, fill: { color: CARD }, line: { color: '2A3A50' }, rectRadius: 0.05 }); s.addImage({ data: o.data, x: (13.333 - w) / 2, y: 1.25 + (5.05 - h) / 2, w, h }); s.addText(takeaway, { x: 0.7, y: 6.55, w: 11.93, h: 0.42, fontSize: 11, color: DIM, align: 'center', fontFace: F, fill: { color: CARD } }); } else { s.addText('当前筛选数据不足,图表未渲染。', { x: 0.5, y: 3, w: 12.3, h: 1, fontSize: 14, color: DIM, align: 'center', fontFace: F }); } }; // ---- S1 封面 ---- { const s = pptx.addSlide(); s.background = { color: DARK }; s.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: 0.28, h: 7.5, fill: { color: ACCENT } }); s.addShape(pptx.ShapeType.rect, { x: 10.5, y: 6.2, w: 3.5, h: 3.5, fill: { color: '111C33' }, rotate: 25 }); s.addText('企业费用智能分析报告', { x: 0.9, y: 1.6, w: 11.5, h: 1.1, fontSize: 40, bold: true, color: TXT, fontFace: F }); s.addText('多维穿透 · 同比环比 · 回归预测 · 白盒风控', { x: 0.95, y: 2.85, w: 11, h: 0.5, fontSize: 16, color: ACCENT, fontFace: F }); s.addText(`数据区间:${A.months[0]} 至 ${A.months[A.months.length - 1]} | 生成时间:${new Date().toLocaleString('zh-CN')}`, { x: 0.95, y: 3.5, w: 11, h: 0.4, fontSize: 12, color: DIM, fontFace: F }); const chips = [ ['期间费用总额', fmtWan(A.total) + '元'], ['同比变动', A.yoyRate !== null ? (A.yoyRate >= 0 ? '+' : '') + fmtPct(A.yoyRate) : '—'], ['预算达成', A.budgetRate !== null ? fmtPct(A.budgetRate) : '无预算'], ['异常捕获', A.anomalies.length + ' 笔'] ]; chips.forEach((c, i) => { const x = 0.95 + i * 3.05; s.addShape(pptx.ShapeType.roundRect, { x, y: 4.5, w: 2.8, h: 1.3, fill: { color: CARD }, line: { color: '2A3A50' }, rectRadius: 0.08 }); s.addText(c[0], { x, y: 4.62, w: 2.8, h: 0.35, fontSize: 11, color: DIM, align: 'center', fontFace: F }); s.addText(c[1], { x, y: 4.98, w: 2.8, h: 0.6, fontSize: 19, bold: true, color: ACCENT, align: 'center', fontFace: F }); }); } // ---- S2 核心指标一览(原生表格) ---- { const s = pptx.addSlide(); header(s, '一 · 核心指标一览', '本页为原生 PowerPoint 表格:双击任意单元格即可修改数值与解读'); const hd = t => ({ text: t, options: { bold: true, color: '0B1120', fill: { color: ACCENT }, align: 'center' } }); const rows = [ [hd('指标'), hd('数值'), hd('管理解读')], ['期间费用总额', fmt(Math.round(A.total)) + ' 元', A.monthCount + ' 个月度汇总 / ' + fmt(A.fd.length) + ' 笔单据'], ['同比变动', A.yoyRate !== null ? (A.yoyRate >= 0 ? '+' : '') + fmtPct(A.yoyRate) : '—', A.hasYoY ? '对比 ' + A.yoyMonths[0] + ' 起同期口径' : '无同期数据可比'], ['平均环比', (A.avgMom > 0 ? '+' : '') + A.avgMom.toFixed(1) + '%', '波动率 ' + A.momVolatility.toFixed(1) + '%'], ['预算达成率', A.budgetRate !== null ? fmtPct(A.budgetRate) : '无预算', A.overBudgetDepts.length + ' 个部门超支 / ' + A.goodDepts.length + ' 个节余优秀'], ['人均月费用', fmt(A.perCapita) + ' 元', '在编 ' + A.empTotal + ' 人折算'], ['科目集中度 HHI', A.hhi.toFixed(3), A.hhi > 0.25 ? '高度集中,头部科目主导' : A.hhi > 0.15 ? '中度集中' : '较为分散'], ['风控异常', A.anomalies.length + ' 笔', '卷入金额 ' + fmtWan(A.anomalyAmount) + ' 元'], ['回归预测·未来3期', fmtWan(A.forecast.fcSum) + ' 元', 'R² = ' + A.forecast.r2.toFixed(2)] ]; s.addTable(rows, { x: 0.5, y: 1.15, w: 12.3, colW: [3.2, 3.2, 5.9], fontSize: 11.5, fontFace: F, color: TXT, fill: { color: CARD }, border: { type: 'solid', color: '2A3A50', pt: 0.5 }, rowH: 0.5, valign: 'middle', align: 'center', margin: 0.06 }); } // ---- S3 趋势与回归预测(原生组合图表) ---- { const s = pptx.addSlide(); header(s, '二 · 费用趋势与回归预测', '原生PPT图表:右键图表 → 编辑数据,即可直接修改数值 / 换配色'); const labels = [...A.byMonthArr.map(d => d.month), ...A.forecast.labels]; const actual = A.byMonthArr.map(d => Math.round(d.amount)).concat(A.forecast.labels.map(() => null)); const bud = A.byMonthArr.map(d => Math.round(d.budget)).concat(A.forecast.labels.map(() => null)); const fc = A.byMonthArr.map(() => null).concat(A.forecast.values); s.addChart([ { type: pptx.ChartType.bar, data: [{ name: '实际发生', labels, values: actual }], options: { chartColors: [BLUE], barGapWidthPct: 50 } }, { type: pptx.ChartType.line, data: [{ name: '预算阈值', labels, values: bud }, { name: '回归预测', labels, values: fc }], options: { chartColors: [ORANGE, ACCENT], lineSize: 2, lineDataSymbol: 'circle', lineDataSymbolSize: 5 } } ], Object.assign({ x: 0.5, y: 1.3, w: 8.5, h: 5.5, valAxisLabelFormatCode: '#,##0' }, chartBase)); s.addText('关键读数', { x: 9.25, y: 1.45, w: 3.6, h: 0.4, fontSize: 13, bold: true, color: ACCENT, fontFace: F }); const tips = [ '回归斜率 ' + (A.forecast.slope >= 0 ? '+' : '') + fmt(Math.round(A.forecast.slope)) + ' 元/月', '拟合优度 R² = ' + A.forecast.r2.toFixed(2), '未来3期预测 ' + fmtWan(A.forecast.fcSum) + ' 元' ]; if (A.forecast.fcChange !== null) tips.push((A.forecast.fcChange >= 0 ? '较近3期扩张 ' : '较近3期收缩 ') + fmtPct(Math.abs(A.forecast.fcChange))); s.addText(tips.map(t => ({ text: t, options: { bullet: { characterCode: '25AA', indent: 12 }, color: TXT, breakLine: true } })), { x: 9.25, y: 1.95, w: 3.6, h: 2.6, fontSize: 11, fontFace: F, lineSpacingMultiple: 1.4 }); s.addText(A.forecast.fcChange !== null && A.forecast.fcChange > 10 ? '⚠ 预测扩张超10%,建议提前安排授信与付款排期' : '✓ 扩张节奏可控,维持现有资金排布', { x: 9.25, y: 4.7, w: 3.6, h: 1.4, fontSize: 11, bold: true, color: (A.forecast.fcChange !== null && A.forecast.fcChange > 10) ? ROSE : GREEN, fontFace: F, fill: { color: CARD }, lineSpacingMultiple: 1.3 }); } // ---- S4 科目结构(原生环形图 + 明细表) ---- { const s = pptx.addSlide(); header(s, '三 · 科目结构与集中度', '左:原生环形图(可编辑) 右:Top科目明细表(可编辑)'); s.addChart(pptx.ChartType.doughnut, [{ name: '科目结构', labels: A.byCat.map(c => c.category), values: A.byCat.map(c => Math.round(c.amount)) }], Object.assign({}, chartBase, { x: 0.5, y: 1.35, w: 6.2, h: 5.3, chartColors: PPT_COLORS, holeSize: 55, showPercent: true, dataLabelColor: '0B1120', legendPos: 'r', dataBorder: { pt: 1, color: CARD } })); const tot = A.total || 1; const hd2 = t => ({ text: t, options: { bold: true, color: '0B1120', fill: { color: ACCENT }, align: 'center' } }); const rows = [[hd2('科目'), hd2('金额(元)'), hd2('占比')]]; A.byCat.forEach(c => rows.push([ { text: c.category, options: { align: 'left' } }, { text: fmt(Math.round(c.amount)), options: { align: 'right' } }, { text: (c.amount / tot * 100).toFixed(1) + '%', options: { align: 'right', color: c.amount / tot > 0.2 ? ROSE : TXT, bold: c.amount / tot > 0.2 } } ])); s.addTable(rows, { x: 7.0, y: 1.4, w: 5.8, colW: [2.4, 2.0, 1.4], fontSize: 10.5, fontFace: F, color: TXT, fill: { color: CARD }, border: { type: 'solid', color: '2A3A50', pt: 0.5 }, rowH: 0.33, valign: 'middle' }); s.addText('集中度 HHI = ' + A.hhi.toFixed(3) + '(' + (A.hhi > 0.25 ? '高度集中,头部科目主导全局' : A.hhi > 0.15 ? '中度集中' : '较为分散') + ')', { x: 7.0, y: 6.3, w: 5.8, h: 0.4, fontSize: 11, bold: true, color: ACCENT, fontFace: F }); } // ---- S5 ABC帕累托(原生双轴组合图) ---- { const s = pptx.addSlide(); header(s, '四 · ABC帕累托分层', '双轴组合图:柱=科目金额,线=累计占比;80% / 95% 为 A/B 类分界'); let cum = 0; const cumPct = A.byCat.map(c => { cum += c.amount; return Math.round(cum / (A.total || 1) * 1000) / 10; }); const labels = A.byCat.map(c => c.category); s.addChart([ { type: pptx.ChartType.bar, data: [{ name: '科目金额', labels, values: A.byCat.map(c => Math.round(c.amount)) }], options: { chartColors: [ROSE], barGapWidthPct: 40 } }, { type: pptx.ChartType.line, data: [{ name: '累计占比(%)', labels, values: cumPct }], options: { chartColors: [ACCENT], secondaryValAxis: true, secondaryCatAxis: true, lineSize: 2.5, lineDataSymbol: 'circle', lineDataSymbolSize: 7, showLabel: true, dataLabelPosition: 't', dataLabelColor: ACCENT, dataLabelFontSize: 9, dataLabelFormatCode: '0.0"%"' } } ], Object.assign({ x: 0.5, y: 1.3, w: 12.3, h: 5.4 }, { valAxes: [ { valAxisLabelColor: DIM, valAxisLabelFontSize: 9, valAxisLabelFormatCode: '#,##0', valGridLine: { style: 'solid', color: GRID, pt: 0.5 } }, { valAxisLabelColor: ACCENT, valAxisLabelFontSize: 9, valAxisMaxVal: 100, valGridLine: { style: 'none' } } ], catAxes: [{ catAxisLabelColor: DIM, catAxisLabelFontSize: 9 }, { catAxisHidden: true }], showLegend: true, legendPos: 't', legendColor: DIM, legendFontSize: 10, chartArea: { fill: { color: CARD } }, plotArea: { fill: { color: CARD } } })); } // ---- S6 同比增减归因(红绿双系列柱) ---- { const s = pptx.addSlide(); header(s, '五 · 同比增减归因(瀑布拆解)', A.bridge.hasYoY ? ('同口径可比区间:上年同期 ' + fmtWan(A.bridge.start) + ' 元 → 本期 ' + fmtWan(A.bridge.end) + ' 元,净变动 ' + (A.bridge.end - A.bridge.start >= 0 ? '+' : '') + fmtWan(Math.abs(A.bridge.end - A.bridge.start)) + ' 元') : '当前筛选缺乏可匹配的去年同期数据'); if (A.bridge.hasYoY && A.bridge.deltas.length > 0) { const labels = A.bridge.deltas.map(d => d.name); const up = A.bridge.deltas.map(d => d.diff > 0 ? d.diff : null); const down = A.bridge.deltas.map(d => d.diff < 0 ? d.diff : null); s.addChart([ { type: pptx.ChartType.bar, data: [{ name: '同比增加', labels, values: up }], options: { chartColors: [ROSE] } }, { type: pptx.ChartType.bar, data: [{ name: '同比减少', labels, values: down }], options: { chartColors: [GREEN] } } ], Object.assign({ x: 0.5, y: 1.35, w: 8.4, h: 5.3, valAxisLabelFormatCode: '#,##0', barGapWidthPct: 30, barOverlapPct: 100 }, chartBase)); const topD = [...A.bridge.deltas].sort((a, b) => Math.abs(b.diff) - Math.abs(a.diff)).slice(0, 5); s.addText('贡献 Top5', { x: 9.2, y: 1.5, w: 3.6, h: 0.4, fontSize: 13, bold: true, color: ACCENT, fontFace: F }); s.addText(topD.map(d => ({ text: d.name + ' ' + (d.diff >= 0 ? '+' : '') + fmtWan(d.diff), options: { color: d.diff >= 0 ? ROSE : GREEN, breakLine: true } })), { x: 9.2, y: 2.0, w: 3.6, h: 2.6, fontSize: 11.5, fontFace: F, lineSpacingMultiple: 1.5 }); s.addText('红=推高费用的科目,绿=管控见效科目;建议对 Top 红色科目启动零基预算复核。', { x: 9.2, y: 4.8, w: 3.6, h: 1.8, fontSize: 10.5, color: DIM, fontFace: F, fill: { color: CARD }, lineSpacingMultiple: 1.3 }); } else { s.addText('当前筛选区间缺乏可匹配的去年同期数据,无法构建同比桥接。', { x: 0.5, y: 3, w: 12.3, h: 1, fontSize: 14, color: DIM, align: 'center', fontFace: F }); } } // ---- S7 预算达成(原生分组柱 + 达成率表) ---- { const s = pptx.addSlide(); header(s, '六 · 预算达成分析', '分组柱:实际 vs 预算;右侧列出各部门达成率与执行状态'); const labels = A.byDept.map(d => d.department); s.addChart([ { type: pptx.ChartType.bar, data: [{ name: '实际发生', labels, values: A.byDept.map(d => Math.round(d.amount)) }], options: { chartColors: [BLUE] } }, { type: pptx.ChartType.bar, data: [{ name: '批复预算', labels, values: A.byDept.map(d => Math.round(d.budget)) }], options: { chartColors: [ORANGE] } } ], Object.assign({ x: 0.5, y: 1.35, w: 8.4, h: 5.3, valAxisLabelFormatCode: '#,##0' }, chartBase)); const hd3 = t => ({ text: t, options: { bold: true, color: '0B1120', fill: { color: ACCENT }, align: 'center' } }); const rows = [[hd3('部门'), hd3('达成率'), hd3('状态')]]; A.byDept.forEach(d => { if (d.budget <= 0) return; const rate = d.amount / d.budget * 100; const st = rate > 100 ? '超支' : rate < 85 ? '节余优秀' : '稳健'; rows.push([{ text: d.department, options: { align: 'left' } }, { text: rate.toFixed(1) + '%', options: { align: 'right' } }, { text: st, options: { color: st === '超支' ? ROSE : st === '节余优秀' ? GREEN : TXT } }]); }); if (rows.length > 1) s.addTable(rows, { x: 9.2, y: 1.5, w: 3.7, colW: [1.6, 1.1, 1.0], fontSize: 10, fontFace: F, color: TXT, fill: { color: CARD }, border: { type: 'solid', color: '2A3A50', pt: 0.5 }, rowH: 0.32, valign: 'middle' }); } // ---- S8 白盒风控(KPI 芯片 + 原生异常表) ---- { const s = pptx.addSlide(); header(s, '七 · 白盒风控异常溯源', 'IQR 四分位距法 · 统计学离群捕获 · 高/中/低三级预警'); const chips = [['疑似异常', A.anomalies.length + ' 笔'], ['高风险', A.highRisk.length + ' 笔'], ['中风险', A.medRisk.length + ' 笔'], ['卷入金额占比', fmtPct(A.anomalyAmount / (A.total || 1))]]; chips.forEach((c, i) => { const x = 0.55 + i * 3.15; s.addShape(pptx.ShapeType.roundRect, { x, y: 1.25, w: 2.95, h: 0.95, fill: { color: CARD }, line: { color: '2A3A50' }, rectRadius: 0.06 }); s.addText([{ text: c[0], options: { fontSize: 10, color: DIM, breakLine: true } }, { text: c[1], options: { fontSize: 16, bold: true, color: i === 1 ? ROSE : ACCENT } }], { x, y: 1.32, w: 2.95, h: 0.85, align: 'center', fontFace: F }); }); if (A.anomalies.length) { const hd4 = t => ({ text: t, options: { bold: true, color: '0B1120', fill: { color: ACCENT }, align: 'center' } }); const rows = [[hd4('期份'), hd4('部门'), hd4('科目'), hd4('异常金额'), hd4('合理上限'), hd4('偏离'), hd4('评级')]]; A.anomalies.slice(0, 8).forEach(a => rows.push([ a.month, a.department, a.category, { text: fmt(Math.round(a.amount)), options: { color: ROSE, bold: true, align: 'right' } }, { text: fmt(Math.round(a.upper)), options: { align: 'right' } }, { text: a.deviation.toFixed(1) + ' IQR', options: { align: 'center' } }, { text: a.riskLevel, options: { color: a.riskLevel === '高' ? ROSE : a.riskLevel === '中' ? ORANGE : BLUE, bold: true } } ])); s.addTable(rows, { x: 0.55, y: 2.45, w: 12.2, colW: [1.4, 1.7, 1.7, 2.2, 2.2, 1.4, 1.6], fontSize: 10, fontFace: F, color: TXT, fill: { color: CARD }, border: { type: 'solid', color: '2A3A50', pt: 0.5 }, rowH: 0.34, valign: 'middle' }); } else { s.addText('✓ 全维度健康:未捕获任何统计学离群脉冲,内控体系运行优良。', { x: 0.55, y: 2.6, w: 12, h: 0.6, fontSize: 13, color: GREEN, fontFace: F }); } } // ---- S9 部门画像(原生雷达 + 人均柱) ---- { const s = pptx.addSlide(); header(s, '八 · 部门画像', '左:八维费用结构雷达(原生) 右:人均月度费用排行(原生)'); const byDeptCat = {}; A.fd.forEach(d => { const k = d.department + '|' + d.category; byDeptCat[k] = (byDeptCat[k] || 0) + d.amount; }); const radar = A.depts.map(dp => { const t = sum(A.fd.filter(d => d.department === dp).map(d => d.amount)); return { name: dp, labels: A.cats, values: A.cats.map(c => t > 0 ? Math.round((byDeptCat[dp + '|' + c] || 0) / t * 100) : 0) }; }); s.addChart(pptx.ChartType.radar, radar, Object.assign({ x: 0.4, y: 1.35, w: 6.3, h: 5.4, radarStyle: 'standard', chartColors: PPT_COLORS, lineSize: 2 }, chartBase)); const pc = A.depts.map(dp => ({ name: dp, v: Math.round(sum(A.fd.filter(d => d.department === dp).map(d => d.amount)) / ((DEPT_EMP_COUNT[dp] || 20) * (A.monthCount || 1))) })).sort((a, b) => b.v - a.v); s.addChart(pptx.ChartType.bar, [{ name: '人均月费用', labels: pc.map(p => p.name), values: pc.map(p => p.v) }], Object.assign({ x: 6.9, y: 1.35, w: 6.0, h: 5.4, chartColors: ['22D3EE'], barGapWidthPct: 40, valAxisLabelFormatCode: '#,##0', catAxisLabelFontSize: 9 }, chartBase)); } // ---- S10~S13 多维结构洞察(单图大版式,完美满幅显示) ---- bigImgSlide('九 · 部门 × 科目热力矩阵', '颜色越暖金额越高:快速定位费用在"部门 × 科目"上的集中落点', imgs.chartHeatmap, '热点单元格 = 该部门在该科目的重仓区,建议列为预算质询与管控的第一优先级'); bigImgSlide('十 · 部门 → 科目资金流向桑基图', '流带宽度与金额成正比:识别各部门通往科目的主力资金通道', imgs.chartSankey, '带宽最粗的 2~3 条通道通常承载全局过半费用,是零基预算重构的核心对象'); bigImgSlide('十一 · 季节性指数热力图', '红=高峰月(≥130)需提前备付资金;蓝=低谷月(≤70)适合错峰执行刚性支出', imgs.chartSeason, '高峰月前 1 个月锁定供应商价格并预留备付金,低谷月集中安排培训与盘点类支出'); bigImgSlide('十二 · 同比瀑布桥接', '蓝色基座=上年同期,红/绿柱=各科目增减,金色柱=本期落点(严格闭环)', imgs.chartBridge || imgs.chartCum, '红色柱=推高费用的科目,绿色柱=管控见效科目;对 Top 红色科目启动零基复核'); // ---- S14 行动指令(原生文本卡片) ---- { const s = pptx.addSlide(); header(s, '十三 · 行动指令与改进建议', '基于数据穿透结论生成的管理行动清单(文本可编辑)'); const recs = buildRecommendations(A).slice(0, 6); recs.forEach((r, i) => { const col = i % 2, row = Math.floor(i / 2); const x = 0.55 + col * 6.35, y = 1.3 + row * 1.9; s.addShape(pptx.ShapeType.roundRect, { x, y, w: 6.1, h: 1.7, fill: { color: CARD }, line: { color: '2A3A50' }, rectRadius: 0.06 }); s.addText([{ text: r[0], options: { fontSize: 13, bold: true, color: ACCENT, breakLine: true } }, { text: r[1], options: { fontSize: 10.5, color: TXT } }], { x: x + 0.25, y: y + 0.12, w: 5.6, h: 1.5, fontFace: F, lineSpacingMultiple: 1.25, valign: 'top' }); }); } await pptx.writeFile({ fileName: `企业费用智能分析_${stamp()}.pptx` }); showToast('可编辑PPT导出成功(原生图表/表格,可自由修改)'); } catch (err) { showToast('PPT生成失败:' + err.message, 'error'); console.error(err); } } // ================================================================== // ★ 一键导出:高端可编辑 Word(docx.js 原生 OOXML:真标题样式+原生表格+高清图表) // ================================================================== async function exportWord() { if (appState.filteredData.length === 0) { showToast('暂无数据可导出', 'error'); return; } try { await ensureLib('docx'); } catch (e) { showToast('原生Word组件不可用,自动切换兼容模式', 'error'); exportWordLegacy(); return; } showToast('正在组装高端可编辑Word报告(原生样式+原生表格+高清图表)...', 'success'); // ★ 激活报告页签并逐图强制resize后捕获,确保每张图满幅高清(修复隐藏态窄幅问题) await captureChartsForTab('report', REP_CHART_IDS, {}, 2, '#ffffff'); try { const A = computeAnalytics(); const { Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell, WidthType, ImageRun, AlignmentType, BorderStyle, VerticalAlign } = docx; const bd = c => ({ style: BorderStyle.SINGLE, size: 4, color: c }); const cellBorders = { top: bd('CBD5E1'), bottom: bd('CBD5E1'), left: bd('CBD5E1'), right: bd('CBD5E1') }; const cellMargins = { top: 80, bottom: 80, left: 120, right: 120 }; const alignOf = a => a === 'left' ? AlignmentType.LEFT : a === 'right' ? AlignmentType.RIGHT : AlignmentType.CENTER; const cell = (t, o = {}) => new TableCell({ borders: cellBorders, verticalAlign: VerticalAlign.CENTER, shading: o.fill ? { fill: o.fill } : undefined, margins: cellMargins, children: [new Paragraph({ alignment: alignOf(o.align), spacing: { after: 0 }, children: [new TextRun({ text: String(t), bold: o.bold || false, size: o.size || 20, color: o.color || '334155' })] })] }); const mkTable = (heads, rows) => new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [ new TableRow({ tableHeader: true, children: heads.map(h => cell(h, { bold: true, fill: 'EAF0F8', color: '0F172A' })) }), ...rows.map(r => new TableRow({ children: r.map(c => (c && typeof c === 'object' && !Array.isArray(c)) ? cell(c.t, c) : cell(c)) })) ] }); const H = txt => new Paragraph({ spacing: { before: 340, after: 140 }, border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: '1E3A8A' } }, children: [new TextRun({ text: txt, bold: true, size: 26, color: '1E3A8A' })] }); const P = (runs, o = {}) => new Paragraph({ alignment: o.align === 'center' ? AlignmentType.CENTER : AlignmentType.JUSTIFIED, indent: o.noIndent ? undefined : { firstLine: 480 }, spacing: { after: 140, line: 330 }, children: (Array.isArray(runs) ? runs : [{ t: runs }]).map(r => new TextRun({ text: r.t, bold: r.b || false, color: r.c || '1E293B', size: r.s || 21 })) }); const CAP = t => new Paragraph({ alignment: AlignmentType.CENTER, spacing: { before: 60, after: 220 }, children: [new TextRun({ text: t, size: 18, color: '64748B', italics: true })] }); const img = (id, wpx) => { const url = chartImage(id, 2); if (!url) return null; const c = echarts.getInstanceByDom(document.getElementById(id)); const ratio = c.getHeight() / c.getWidth(); const W = wpx || 600; return new Paragraph({ alignment: AlignmentType.CENTER, spacing: { before: 120, after: 40 }, children: [new ImageRun({ data: u8FromDataURL(url), transformation: { width: W, height: Math.round(W * ratio) } })] }); }; const children = []; // 封面标题区 children.push(new Paragraph({ alignment: AlignmentType.CENTER, spacing: { before: 240, after: 80 }, children: [new TextRun({ text: '企业费用智能分析报告', bold: true, size: 52, color: '0F172A' })] })); children.push(new Paragraph({ alignment: AlignmentType.CENTER, spacing: { after: 140 }, children: [new TextRun({ text: '多维穿透 · 同比环比 · 回归预测 · 白盒风控', size: 24, color: 'B45309', bold: true })] })); children.push(mkTable(['数据区间', '覆盖部门', '穿透单据', '生成时间'], [[A.months[0] + ' 至 ' + A.months[A.months.length - 1], A.byDept.length + ' 个', fmt(A.fd.length) + ' 笔', new Date().toLocaleString('zh-CN')]])); // 一、管理层摘要 children.push(H('一、管理层摘要(Executive Summary)')); A.execSummary.forEach(sg => children.push(P([{ t: '● ', c: 'B45309', b: true }, { t: sg }], { noIndent: true }))); const kc = (l, v, c) => new TableCell({ borders: cellBorders, shading: { fill: 'F8FAFC' }, margins: cellMargins, verticalAlign: VerticalAlign.CENTER, children: [ new Paragraph({ alignment: AlignmentType.CENTER, spacing: { after: 40 }, children: [new TextRun({ text: l, size: 17, color: '475569', bold: true })] }), new Paragraph({ alignment: AlignmentType.CENTER, children: [new TextRun({ text: v, size: 26, bold: true, color: c || '1E3A8A' })] }) ] }); children.push(new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [ new TableRow({ children: [kc('期间费用总额', fmtWan(A.total) + '元'), kc('同比变动', A.yoyRate !== null ? (A.yoyRate >= 0 ? '+' : '') + fmtPct(A.yoyRate) : '—', A.yoyRate !== null && A.yoyRate > 0.05 ? 'DC2626' : '1E3A8A'), kc('平均环比', (A.avgMom > 0 ? '+' : '') + A.avgMom.toFixed(1) + '%'), kc('预算达成率', A.budgetRate !== null ? fmtPct(A.budgetRate) : '无预算', A.budgetRate !== null && A.budgetRate > 1 ? 'DC2626' : '15803D')] }), new TableRow({ children: [kc('人均月费用', fmt(A.perCapita) + '元'), kc('集中度 HHI', A.hhi.toFixed(3), A.hhi > 0.25 ? 'DC2626' : '1E3A8A'), kc('预测·未来3期', fmtWan(A.forecast.fcSum) + '元'), kc('风控异常', A.anomalies.length + ' 笔', A.anomalies.length ? 'DC2626' : '15803D')] }) ] })); // 二、总体概况与趋势推演 children.push(H('二、总体概况与趋势推演')); children.push(P(`本考核周期(${A.months[0]} 至 ${A.months[A.months.length - 1]},共 ${A.monthCount} 个核算月度)累计发生费用 ${fmt(Math.round(A.total))} 元(折合 ${fmtWan(A.total)})。` + (A.totalBudget > 0 ? `预算防线总额 ${fmt(Math.round(A.totalBudget))} 元,总体达成率 ${fmtPct(A.budgetRate)},${A.total > A.totalBudget ? '已形成预算外净流出,需管理层高度关注' : '支出管控处于合理区间'}。` : '') + (A.yoyRate !== null ? `对比去年同期(${A.yoyMonths[0]} 至 ${A.yoyMonths[A.yoyMonths.length - 1]})的 ${fmt(Math.round(A.yoyTotal))} 元,本期同比${A.yoyRate >= 0 ? '增长' : '下降'} ${fmtPct(Math.abs(A.yoyRate))}。` : '') + `月度平均环比 ${(A.avgMom > 0 ? '+' : '') + A.avgMom.toFixed(1)}%,波动率 ${A.momVolatility.toFixed(1)}%,${A.momVolatility > 15 ? '月度间波动剧烈,建议关注支出节奏的均匀性' : '节奏总体平稳可控'}。`)); const i1 = img('rep_chart_summary', 620); if (i1) { children.push(i1); children.push(CAP('图2-1 各月度发生额 vs 预算 vs 去年同期对照')); } const i2 = img('rep_chart_trend', 620); if (i2) { children.push(i2); children.push(CAP('图2-2 费用动能趋势(回归拟合 + 3期移动平均 + 预测带)')); } const i3 = img('rep_chart_mom', 620); if (i3) { children.push(i3); children.push(CAP('图2-3 环比变动推演(前月 / 当月 / 环比变化率)')); } // 三、前瞻预测与季节性 children.push(H('三、前瞻预测与现金流提示')); children.push(P(`系统对期间月度序列执行最小二乘线性回归,拟合斜率 ${(A.forecast.slope >= 0 ? '+' : '') + fmt(Math.round(A.forecast.slope))} 元/月,拟合优度 R²=${A.forecast.r2.toFixed(2)}。` + `${A.forecast.r2 >= 0.5 ? '序列趋势性显著,预测结果可信度较高,可作为现金流排布的直接依据。' : '序列受季节或一次性事项扰动较大,预测仅供参考,建议结合业务排期人工校准。'}` + `据此外推,未来 3 期费用合计约 ${fmtWan(A.forecast.fcSum)} 元` + (A.forecast.fcChange !== null ? `,较最近 3 期实际值(${fmtWan(A.forecast.last3Sum)} 元)${A.forecast.fcChange >= 0 ? '扩张' : '收缩'} ${fmtPct(Math.abs(A.forecast.fcChange))}` : '') + `。${A.forecast.fcChange > 10 ? '若该斜率延续,建议提前安排授信额度与付款排期,防范资金链承压。' : '当前趋势下资金排布压力可控,维持现有节奏即可。'}`)); children.push(mkTable(['预测期次', '预测金额(元)', '置信下界', '置信上界'], A.forecast.labels.map((l, i) => [l, fmt(A.forecast.values[i]), fmt(A.forecast.bands[i][0]), fmt(A.forecast.bands[i][1])]))); const i4 = img('rep_chart_forecast', 620); if (i4) { children.push(i4); children.push(CAP('图3-1 线性回归拟合与置信区间预测')); } if (A.season.available) { children.push(P(`季节性指数(100=科目区间月均)显示:高峰组合 ${A.season.hot.slice(0, 3).map(h => `${h.cat}(${h.mn}月,指数${h.idx})`).join('、') || '暂无'};低谷组合 ${A.season.cold.slice(0, 3).map(h => `${h.cat}(${h.mn}月,指数${h.idx})`).join('、') || '暂无'}。高峰月应提前锁定供应商价格并预留备付金,低谷月适合安排刚性支出的错峰执行。`)); const i5 = img('rep_chart_season', 620); if (i5) { children.push(i5); children.push(CAP('图3-2 月度 × 科目季节性指数热力图')); } } // 四、科目结构剖析与同比归因 children.push(H('四、科目结构剖析与同比归因')); const topN = Math.min(3, A.byCat.length); let topPct = 0; A.byCat.slice(0, topN).forEach(c => topPct += c.amount / (A.total || 1) * 100); children.push(P(`依据帕累托法则,${A.byCat.slice(0, topN).map(c => c.category).join('、')} 三大 A 类科目合计吞噬全局 ${topPct.toFixed(1)}% 的费用,是管控核心抓手。科目同比结构变迁如下:`)); children.push(mkTable(['费用科目', '本期发生额', '同期发生额', '同比差额', '变动率', '趋势研判'], A.yoyByCatArr.map(c => { const j = Math.abs(c.rate) > 20 ? (c.rate > 0 ? '显著扩张' : '大幅收缩') : Math.abs(c.rate) > 10 ? (c.rate > 0 ? '温和上升' : '温和下降') : '基本持平'; return [{ t: c.category, b: true, align: 'left' }, fmt(Math.round(c.y1 || 0)), fmt(Math.round(c.y0)), { t: (c.diff >= 0 ? '+' : '') + fmt(Math.round(c.diff)), c: c.diff >= 0 ? 'DC2626' : '15803D', b: true }, { t: (c.rate >= 0 ? '+' : '') + c.rate.toFixed(1) + '%', c: Math.abs(c.rate) > 20 ? (c.rate > 0 ? 'DC2626' : '15803D') : '334155' }, { t: j }]; }))); const i6 = img('rep_chart_pareto', 620); if (i6) { children.push(i6); children.push(CAP('图4-1 ABC帕累托分层(80% / 95% 分界)')); } if (A.bridge.hasYoY) { children.push(P(`瀑布桥接归因:上年同期 ${fmtWan(A.bridge.start)} 元 → 本期 ${fmtWan(A.bridge.end)} 元,净变动 ${(A.bridge.end - A.bridge.start >= 0 ? '+' : '') + fmtWan(Math.abs(A.bridge.end - A.bridge.start))} 元。头部增减动因:${A.bridge.deltas.slice(0, 3).map(d => `${d.name}(${d.diff >= 0 ? '+' : ''}${fmtWan(d.diff)})`).join('、')}。`)); const i7 = img('rep_chart_bridge', 620); if (i7) { children.push(i7); children.push(CAP('图4-2 同比增减瀑布桥接图')); } } const i8 = img('rep_chart_yoy_waterfall', 620); if (i8) { children.push(i8); children.push(CAP('图4-3 科目同比增减贡献排行')); } // 五、部门预算执行与效能评估 children.push(H('五、部门预算执行与效能评估')); children.push(mkTable(['部门', '实际消耗', '批复预算', '达成率', '同比变动', '效能评价'], A.byDept.map(d => { const rate = d.budget > 0 ? (d.amount / d.budget * 100).toFixed(1) + '%' : '—'; const yd = A.yoyByDept[d.department]; let ev = '缺预算底座', ec = '94A3B8'; if (d.budget > 0) { if (d.amount > d.budget) { ev = '溢出超限'; ec = 'DC2626'; } else if (d.amount < d.budget * 0.8) { ev = '运行稳健'; ec = '15803D'; } else { ev = '预算内运行'; ec = '0F172A'; } } return [{ t: d.department, b: true, align: 'left' }, fmt(Math.round(d.amount)), d.budget > 0 ? fmt(Math.round(d.budget)) : '未建账', { t: rate }, { t: yd ? (yd.rate >= 0 ? '+' : '') + yd.rate.toFixed(1) + '%' : '—', c: yd && yd.rate >= 0 ? 'DC2626' : '15803D' }, { t: ev, c: ec, b: true }]; }))); const i9 = img('rep_chart_dept_yoy', 620); if (i9) { children.push(i9); children.push(CAP('图5-1 部门同比变动率排行')); } const i10 = img('rep_chart_dept_radar', 620); if (i10) { children.push(i10); children.push(CAP('图5-2 部门费用结构画像雷达')); } // 六、风控异常溯源 children.push(H('六、风控异常溯源')); children.push(P(`基于 IQR 四分位距法对 ${fmt(A.fd.length)} 笔单据进行全量扫描:捕获疑似异常 ${A.anomalies.length} 笔(高风险 ${A.highRisk.length}、中风险 ${A.medRisk.length}),卷入金额 ${fmt(Math.round(A.anomalyAmount))} 元,占总费用 ${fmtPct(A.anomalyAmount / (A.total || 1))}。${A.anomalies.length ? '建议将检测算法前置至 OA 提单端,超阈值自动锁定并双背书放行。' : '未发现统计学离群点,内控运行健康。'}`)); if (A.anomalies.length) { children.push(mkTable(['期份', '部门', '科目', '异常金额', '合理上限', '偏离幅度', '评级'], A.anomalies.slice(0, 8).map(a => [a.month, a.department, a.category, { t: fmt(Math.round(a.amount)), c: 'DC2626', b: true }, fmt(Math.round(a.upper)), a.deviation.toFixed(1) + ' IQR', { t: a.riskLevel + '预警', c: a.riskLevel === '高' ? 'DC2626' : a.riskLevel === '中' ? 'EA580C' : '2563EB', b: true }]))); } // 七、行动建议 children.push(H('七、行动指令与改进建议')); buildRecommendations(A).forEach((r, i) => children.push(P([{ t: (i + 1) + '. ' + r[0] + ':', b: true, c: '1E3A8A' }, { t: r[1] }], { noIndent: true }))); children.push(new Paragraph({ spacing: { before: 320 }, shading: { fill: 'FFFBEB' }, border: { left: { style: BorderStyle.SINGLE, size: 24, color: 'F59E0B' } }, children: [new TextRun({ text: ' 结语:利润不仅依靠市场创造,更依托于精细的内部管控。建议每季度迭代一次本报告,持续追踪费用治理成效,让每一个数字的跳动都成为经营决策的基石。', size: 20, color: '92400E' })] })); const doc = new Document({ creator: '费用智能分析系统', title: '企业费用智能分析报告', styles: { default: { document: { run: { font: '微软雅黑', size: 21, color: '1E293B' }, paragraph: { spacing: { line: 300 } } } } }, sections: [{ properties: { page: { margin: { top: 1250, bottom: 1250, left: 1100, right: 1100 } } }, children }] }); const blob = await Packer.toBlob(doc); dl(blob, `企业费用智能分析报告_${stamp()}.docx`); showToast('高端可编辑Word报告导出成功(原生 .docx)'); } catch (err) { console.error('[Word导出]', err); showToast('Word生成失败(' + err.message + '),已回退兼容模式', 'error'); exportWordLegacy(); } } // =========================== // TAB SWITCHING (含报告图表resize) // =========================== document.querySelectorAll('.tab').forEach(tab => { tab.addEventListener('click', function() { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); this.classList.add('active'); const panel = document.getElementById('panel-' + this.dataset.tab); if (panel) panel.classList.add('active'); setTimeout(() => { Object.values(appState.charts).forEach(c => c.resize()); Object.values(appState.reportCharts || {}).forEach(c => c.resize()); }, 150); }); }); // =========================== // INITIALIZATION // =========================== document.addEventListener('DOMContentLoaded', () => { setTimeout(() => { document.getElementById('loading').classList.add('hidden'); }, 400); // ★ 后台静默预热渲染引擎:页面秒开后引擎已在位,用户点任意按钮都是毫秒级响应 ensureLib('echarts').catch(() => { }); });