import * as XLSX from 'xlsx'; import type { ReportData, SunburstNode, RegionData, DurationStats, FunnelItem, BarChartCompareItem } from '../types/data'; /** * Excel Sheets Structure: * 1. 基础信息: Key-Value pairs for cover page * 2. 地图展示: Columns [省份, 数值] * 3. 地区统计: Columns [地区, 采集数量, 转换率, 成功率, 采集时长] * 4. 时长分布: Columns [地区, 0-5分钟, 5-10分钟, 10-20分钟, 20分钟以上] * 5. 页面漏损: Columns [环节, 占比] * 6. 企业全景洞察: Columns [一级行业, 二级行业, 数值] * 7. 劣质客户洞察: Columns [一级行业, 二级行业, 数值] * 8. 优质客户洞察: Columns [一级行业, 二级行业, 数值] * 9. 风险占比: Columns [指标类型, 标签, 数值, 全量大盘数值] * 10. 出口退税分布: Columns [指标类型, 标签, 本机构数值, 全量客户数值] * 11. 关联企业分布: Columns [指标类型, 标签, 本机构数值, 全量客户数值] * 12. 风险扫描分布: Columns [标签, 本机构数值, 全量客户数值] * 13. 图表描述: Columns [页面模块, 指标类型, 描述, 填写说明] */ interface MapRow { 省份: string; 数值: number; } interface BaseInfoRow { 字段: string; 数值: string | number; } interface RegionRow { 地区: string; 采集数量: number; 转换率: string; 成功率: string; 采集时长: string | number; } interface DurationRow { 地区: string; '0-5分钟': string; '5-10分钟': string; '10-20分钟': string; '20分钟以上': string; } interface FunnelRow { 环节: string; 占比: number; } interface PortraitRow { 一级行业: string; 二级行业: string; 数值: number; } interface RiskPieRow { 指标类型: 'invoiceRisk' | 'highTech' | 'importExport' | 'prepaidTax'; 标签: string; 数值: number; 全量大盘数值: number; 描述?: string; } interface ExportTaxRow { 指标类型: 'exportFreeTaxRebate' | 'exportTaxRebate'; 标签: string; 本机构数值: number; 全量客户数值: number; 描述?: string; } interface AssociationRow { 指标类型: 'loginPersonAssociation' | 'legalPersonAssociation'; 标签: string; 本机构数值: number; 全量客户数值: number; 描述?: string; } interface RiskScanningRow { 指标类型?: 'riskScanning'; 标签: string; 本机构数值: number; 全量客户数值: number; 描述?: string; } interface BarChartDescRow { 指标类型: 'exportFreeTaxRebate' | 'exportTaxRebate' | 'loginPersonAssociation' | 'legalPersonAssociation' | 'riskScanning'; 描述: string; } import { defaultReportData } from '../data/defaultData'; type ChartDescriptionKey = keyof NonNullable; const chartDescriptionKeys: ChartDescriptionKey[] = [ 'exportFreeTaxRebate', 'exportTaxRebate', 'loginPersonAssociation', 'legalPersonAssociation', 'riskScanning', ]; const ensurePercent = (val: unknown): string => { if (val === undefined || val === null) return '0%'; if (typeof val === 'number') { return `${(val * 100).toFixed(2)}%`; } const str = String(val).trim(); if (!str) return '0%'; if (str.includes('%')) return str; const num = parseFloat(str); if (!isNaN(num)) { return `${(num * 100).toFixed(2)}%`; } return str.endsWith('%') ? str : `${str}%`; }; const hasBarChartLabel = (row: { 标签?: unknown }): boolean => { return String(row['标签'] ?? '').trim().length > 0; }; const isChartDescriptionKey = (value: unknown): value is ChartDescriptionKey => { return chartDescriptionKeys.includes(value as ChartDescriptionKey); }; const setChartDescription = ( reportData: ReportData, key: unknown, description: unknown ): void => { if (!isChartDescriptionKey(key)) return; const text = String(description ?? '').trim(); if (!text) return; if (!reportData.chartDescriptions) reportData.chartDescriptions = {}; reportData.chartDescriptions[key] = text; }; const formatNumber = (val: unknown): number => { if (val === undefined || val === null) return 0; const num = parseFloat(String(val)); if (isNaN(num)) return 0; return Math.round(num * 100) / 100; }; export const parseExcelReportData = async (file: File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => { try { const data = new Uint8Array(e.target?.result as ArrayBuffer); const workbook = XLSX.read(data, { type: 'array' }); // Use default as base to ensure all keys exist const reportData = JSON.parse(JSON.stringify(defaultReportData)) as ReportData; // 1. 基础信息 const baseSheet = workbook.Sheets['基础信息']; if (baseSheet) { const baseRows = XLSX.utils.sheet_to_json(baseSheet, { raw: true }); const baseObj: Record = {}; baseRows.forEach(row => { baseObj[row['字段']] = row['数值']; }); reportData.companyName = String(baseObj['公司名称'] || '未知企业'); reportData.stats = { successRate: ensurePercent(baseObj['采集成功率']), collectedCount: parseInt(String(baseObj['累计采集户数'])) || 0, growthRate: ensurePercent(baseObj['月增长率']), fiveMinRate: ensurePercent(baseObj['5分钟内完成比例']), avgDuration: formatNumber(baseObj['平均采集时长']), }; reportData.portraitDescriptions = { all: String(baseObj['企业全景洞察描述'] || ''), poor: String(baseObj['风险客户洞察描述'] || ''), good: String(baseObj['优质客户洞察描述'] || ''), }; } // 2. 地图展示 const mapSheet = workbook.Sheets['地图展示']; if (mapSheet) { reportData.mapData = XLSX.utils.sheet_to_json(mapSheet).map((row) => ({ name: row['省份'], value: row['数值'] || 0, })); } // 3. 地区统计 const regionSheet = workbook.Sheets['地区统计']; if (regionSheet) { reportData.regionStatistics = XLSX.utils.sheet_to_json(regionSheet, { raw: true }).map((row): RegionData => ({ region: row['地区'], count: parseInt(String(row['采集数量'])) || 0, conversion: ensurePercent(row['转换率']), success: ensurePercent(row['成功率']), duration: String(formatNumber(row['采集时长'] || '0')), })); } // 3. 时长分布 const durationSheet = workbook.Sheets['时长分布']; if (durationSheet) { reportData.durationStatistics = XLSX.utils.sheet_to_json(durationSheet, { raw: true }).map((row): DurationStats => ({ region: row['地区'], p0_5: ensurePercent(row['0-5分钟']), p5_10: ensurePercent(row['5-10分钟']), p10_20: ensurePercent(row['10-20分钟']), p20plus: ensurePercent(row['20分钟以上']), })); } // 4. 页面漏损 const funnelSheet = workbook.Sheets['页面漏损']; if (funnelSheet) { reportData.funnelData = XLSX.utils.sheet_to_json(funnelSheet).map((row): FunnelItem => ({ label: row['环节'], value: row['占比'] * 100 || 0, })); } // 5. 企业全景洞察 const BLUE_PALETTE = ['#1B65B5', '#2386EE', '#52A3FF', '#85C1FF', '#B8DEFF']; const buildSunburst = (rows: PortraitRow[]) => { const categories: Record = {}; rows.forEach(row => { const l1 = row['一级行业']; const l2 = row['二级行业']; const val = row['数值'] || 0; if (!categories[l1]) { categories[l1] = { name: l1, children: [] }; } categories[l1].children?.push({ name: l2, value: val }); }); return Object.values(categories).map((cat, idx) => { const color = BLUE_PALETTE[idx % BLUE_PALETTE.length]; return { ...cat, itemStyle: { color }, children: cat.children?.map(child => ({ ...child, itemStyle: { color } })) }; }); }; const allSheet = workbook.Sheets['企业全景洞察']; if (allSheet) { reportData.sunburstData = buildSunburst(XLSX.utils.sheet_to_json(allSheet)); } const poorSheet = workbook.Sheets['劣质客户洞察']; if (poorSheet) { reportData.poorCustomerData = buildSunburst(XLSX.utils.sheet_to_json(poorSheet)); } const goodSheet = workbook.Sheets['优质客户洞察']; if (goodSheet) { reportData.goodCustomerData = buildSunburst(XLSX.utils.sheet_to_json(goodSheet)); } // 6. 风险占比 const riskPieSheet = workbook.Sheets['风险占比']; if (riskPieSheet) { const rows = XLSX.utils.sheet_to_json(riskPieSheet); rows.forEach(row => { const key = row['指标类型']; if (key) { reportData.riskIndicators[key] = { label: row['标签'], value: row['数值'] || 0, marketValue: row['全量大盘数值'] || 0, description: row['描述'] || '', }; } }); } // 7. 出口退税分布 const exportTaxSheet = workbook.Sheets['出口退税分布']; if (exportTaxSheet) { const rows = XLSX.utils.sheet_to_json(exportTaxSheet); const groups: Record = {}; rows.forEach(row => { const cat = row['指标类型']; setChartDescription(reportData, cat, row['描述']); if (cat && hasBarChartLabel(row)) { if (!groups[cat]) groups[cat] = []; groups[cat].push({ label: String(row['标签']).trim(), customerValue: formatNumber(row['本机构数值']), marketValue: formatNumber(row['全量客户数值']), }); } }); if (groups['exportFreeTaxRebate']) reportData.exportFreeTaxRebate = groups['exportFreeTaxRebate']; if (groups['exportTaxRebate']) reportData.exportTaxRebate = groups['exportTaxRebate']; } // 8. 关联企业分布 const associationSheet = workbook.Sheets['关联企业分布']; if (associationSheet) { const rows = XLSX.utils.sheet_to_json(associationSheet); const groups: Record = {}; rows.forEach(row => { const cat = row['指标类型']; setChartDescription(reportData, cat, row['描述']); if (cat && hasBarChartLabel(row)) { if (!groups[cat]) groups[cat] = []; groups[cat].push({ label: String(row['标签']).trim(), customerValue: formatNumber(row['本机构数值']), marketValue: formatNumber(row['全量客户数值']), }); } }); if (groups['loginPersonAssociation']) reportData.loginPersonAssociation = groups['loginPersonAssociation']; if (groups['legalPersonAssociation']) reportData.legalPersonAssociation = groups['legalPersonAssociation']; } // 9. 风险扫描分布 const riskScanningSheet = workbook.Sheets['风险扫描分布']; if (riskScanningSheet) { const rows = XLSX.utils.sheet_to_json(riskScanningSheet); rows.forEach(row => { setChartDescription(reportData, row['指标类型'], row['描述']); }); reportData.riskScanning = rows.filter(hasBarChartLabel).map((row): BarChartCompareItem => ({ label: String(row['标签']).trim(), customerValue: formatNumber(row['本机构数值']), marketValue: formatNumber(row['全量客户数值']), })); } // 10. 图表描述(新模板优先) const chartDescriptionSheet = workbook.Sheets['图表描述']; if (chartDescriptionSheet) { const rows = XLSX.utils.sheet_to_json(chartDescriptionSheet); rows.forEach(row => { setChartDescription(reportData, row['指标类型'], row['描述']); }); } resolve(reportData); } catch (err) { reject(err); } }; reader.onerror = reject; reader.readAsArrayBuffer(file); }); }; export const generateExcelTemplate = () => { const wb = XLSX.utils.book_new(); // Helper to ensure cells are treated as strings to avoid auto-formatting const toSheet = (data: Record[]) => { const ws = XLSX.utils.json_to_sheet(data); // Force all cells to be strings to avoid Excel's "helpful" formatting Object.keys(ws).forEach(key => { if (key[0] === '!') return; const cell = ws[key]; if (cell.v !== undefined) { cell.t = 's'; // Set type to string cell.v = String(cell.v); } }); return ws; }; // 1. 基础信息 const baseWS = toSheet([ { '字段': '公司名称', '数值': '某某科技公司' }, { '字段': '采集成功率', '数值': '99.99%' }, { '字段': '累计采集户数', '数值': '1200' }, { '字段': '月增长率', '数值': '12%' }, { '字段': '5分钟内完成比例', '数值': '95%' }, { '字段': '平均采集时长', '数值': '3.5' }, { '字段': '企业全景洞察描述', '数值': '基于行业分布与企业特征,全面展示企业画像数据。' }, { '字段': '风险客户洞察描述', '数值': '识别可能存在合作风险的客户群体,帮助您提前制定应对策略。' }, { '字段': '优质客户洞察描述', '数值': '展示高价值优质客户特征,帮助您优化服务策略。' }, ]); XLSX.utils.book_append_sheet(wb, baseWS, '基础信息'); // 2. 地图展示 const mapWS = XLSX.utils.json_to_sheet([ { '省份': '广东省', '数值': 9058 }, { '省份': '江苏省', '数值': 4712 }, { '省份': '四川省', '数值': 4560 }, ]); XLSX.utils.book_append_sheet(wb, mapWS, '地图展示'); // 3. 地区统计 const regionWS = toSheet([ { '地区': '广东省', '采集数量': '5000', '转换率': '85.5%', '成功率': '99.9%', '采集时长': '5.2' }, { '地区': '江苏省', '采集数量': '4000', '转换率': '88.2%', '成功率': '99.5%', '采集时长': '4.8' }, ]); XLSX.utils.book_append_sheet(wb, regionWS, '地区统计'); // 3. 时长分布 const durationWS = toSheet([ { '地区': '广东省', '0-5分钟': '70%', '5-10分钟': '20%', '10-20分钟': '5%', '20分钟以上': '5%' }, ]); XLSX.utils.book_append_sheet(wb, durationWS, '时长分布'); // 4. 页面漏损 const funnelWS = XLSX.utils.json_to_sheet([ // Numbers are fine for charts { '环节': '登录税局', '占比': 100 }, { '环节': '签署授权', '占比': 85 }, ]); XLSX.utils.book_append_sheet(wb, funnelWS, '页面漏损'); // 5. 企业全景洞察 const allWS = XLSX.utils.json_to_sheet([ { '一级行业': '制造业', '二级行业': '通用设备', '数值': 150 }, { '一级行业': '制造业', '二级行业': '金属加工', '数值': 100 }, { '一级行业': '批发业', '二级行业': '食品批发', '数值': 200 }, ]); XLSX.utils.book_append_sheet(wb, allWS, '企业全景洞察'); // 6. 劣质客户洞察 const poorWS = XLSX.utils.json_to_sheet([ { '一级行业': '制造业', '二级行业': '通用设备', '数值': 30 }, { '一级行业': '制造业', '二级行业': '金属加工', '数值': 20 }, { '一级行业': '批发业', '二级行业': '食品批发', '数值': 50 }, ]); XLSX.utils.book_append_sheet(wb, poorWS, '劣质客户洞察'); // 7. 优质客户洞察 const goodWS = XLSX.utils.json_to_sheet([ { '一级行业': '制造业', '二级行业': '通用设备', '数值': 120 }, { '一级行业': '制造业', '二级行业': '金属加工', '数值': 80 }, { '一级行业': '批发业', '二级行业': '食品批发', '数值': 150 }, ]); XLSX.utils.book_append_sheet(wb, goodWS, '优质客户洞察'); // 9. 风险占比 const riskPieWS = toSheet([ { '指标类型': 'invoiceRisk', '标签': '发票高风险记录', '数值': '0.5', '全量大盘数值': '0.3', '描述': '统计近1年有效开票记录中,存在发票高风险的企业占比情况。' }, { '指标类型': 'highTech', '标签': '高新技术企业', '数值': '5.2', '全量大盘数值': '4.8', '描述': '统计近1年有效开票记录中,被认定为高新技术企业的占比情况。' }, { '指标类型': 'importExport', '标签': '进出口企业', '数值': '2.1', '全量大盘数值': '1.8', '描述': '统计近1年有效开票记录中,具有进出口资质的企业占比情况。' }, { '指标类型': 'prepaidTax', '标签': '预缴税款企业', '数值': '15.0', '全量大盘数值': '12.5', '描述': '统计近1年有效开票记录中,存在预缴税款行为的企业占比情况。' }, ]); XLSX.utils.book_append_sheet(wb, riskPieWS, '风险占比'); // 10. 出口退税分布 const exportTaxWS = XLSX.utils.json_to_sheet([ { '指标类型': 'exportFreeTaxRebate', '标签': '出口免抵退税额0到100万', '本机构数值': 71.43, '全量客户数值': 75 }, { '指标类型': 'exportFreeTaxRebate', '标签': '出口免抵退税额100到500万', '本机构数值': 16.67, '全量客户数值': 18 }, { '指标类型': 'exportFreeTaxRebate', '标签': '出口免抵退税额500到1000万', '本机构数值': 0, '全量客户数值': 2 }, { '指标类型': 'exportFreeTaxRebate', '标签': '出口免抵退税额1000万以上', '本机构数值': 11.9, '全量客户数值': 5 }, { '指标类型': 'exportTaxRebate', '标签': '出口退税额0到100万', '本机构数值': 68.33, '全量客户数值': 70 }, { '指标类型': 'exportTaxRebate', '标签': '出口退税额100到500万', '本机构数值': 21.67, '全量客户数值': 20 }, { '指标类型': 'exportTaxRebate', '标签': '出口退税额500到1000万', '本机构数值': 8.33, '全量客户数值': 7 }, { '指标类型': 'exportTaxRebate', '标签': '出口退税额1000万以上', '本机构数值': 1.67, '全量客户数值': 3 }, ]); XLSX.utils.book_append_sheet(wb, exportTaxWS, '出口退税分布'); // 11. 关联企业分布 const associationWS = XLSX.utils.json_to_sheet([ { '指标类型': 'loginPersonAssociation', '标签': '登录人关联企业数量: 0~3', '本机构数值': 90.06, '全量客户数值': 92 }, { '指标类型': 'loginPersonAssociation', '标签': '登录人关联企业数量: 4~10', '本机构数值': 8.58, '全量客户数值': 7 }, { '指标类型': 'loginPersonAssociation', '标签': '登录人关联企业数量: 10+', '本机构数值': 1.36, '全量客户数值': 1 }, { '指标类型': 'legalPersonAssociation', '标签': '法人代表关联企业数量: 0~3', '本机构数值': 92.91, '全量客户数值': 90 }, { '指标类型': 'legalPersonAssociation', '标签': '法人代表关联企业数量: 4~10', '本机构数值': 6.49, '全量客户数值': 8 }, { '指标类型': 'legalPersonAssociation', '标签': '法人代表关联企业数量: 10+', '本机构数值': 0.61, '全量客户数值': 2 }, ]); XLSX.utils.book_append_sheet(wb, associationWS, '关联企业分布'); // 12. 风险扫描分布 const riskScanningWS = XLSX.utils.json_to_sheet([ { '标签': '无风险', '本机构数值': 77.58, '全量客户数值': 75 }, { '标签': '低风险', '本机构数值': 12.41, '全量客户数值': 13 }, { '标签': '中风险', '本机构数值': 10.01, '全量客户数值': 9 }, { '标签': '高风险', '本机构数值': 0, '全量客户数值': 3 }, ]); XLSX.utils.book_append_sheet(wb, riskScanningWS, '风险扫描分布'); // 13. 图表描述 const chartDescriptionWS = toSheet([ { '页面模块': '不同出口免抵退税金额企业分布', '指标类型': 'exportFreeTaxRebate', '描述': '近1年有效开票金额最大月份占比', '填写说明': '请修改“描述”列;“指标类型”请勿修改。' }, { '页面模块': '不同出口退税金额企业分布', '指标类型': 'exportTaxRebate', '描述': '近1年有效开票金额最大月份占比', '填写说明': '请修改“描述”列;“指标类型”请勿修改。' }, { '页面模块': '登录人关联企业数量分布', '指标类型': 'loginPersonAssociation', '描述': '近1年有效开票金额最大月份占比', '填写说明': '请修改“描述”列;“指标类型”请勿修改。' }, { '页面模块': '法定代表人关联企业数量分布', '指标类型': 'legalPersonAssociation', '描述': '近1年有效开票金额最大月份占比', '填写说明': '请修改“描述”列;“指标类型”请勿修改。' }, { '页面模块': '企业风险扫描结果分布', '指标类型': 'riskScanning', '描述': '近1年有效开票金额最大月份占比', '填写说明': '请修改“描述”列;“指标类型”请勿修改。' }, ]); XLSX.utils.book_append_sheet(wb, chartDescriptionWS, '图表描述'); XLSX.writeFile(wb, '报告数据导入模板.xlsx'); };