Files
data-index-report/src/utils/excelUtils.ts
2026-05-18 13:52:47 +08:00

494 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ReportData['chartDescriptions']>;
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<ReportData> => {
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<BaseInfoRow>(baseSheet, { raw: true });
const baseObj: Record<string, unknown> = {};
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<MapRow>(mapSheet).map((row) => ({
name: row['省份'],
value: row['数值'] || 0,
}));
}
// 3. 地区统计
const regionSheet = workbook.Sheets['地区统计'];
if (regionSheet) {
reportData.regionStatistics = XLSX.utils.sheet_to_json<RegionRow>(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<DurationRow>(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<FunnelRow>(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<string, SunburstNode> = {};
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<PortraitRow>(allSheet));
}
const poorSheet = workbook.Sheets['劣质客户洞察'];
if (poorSheet) {
reportData.poorCustomerData = buildSunburst(XLSX.utils.sheet_to_json<PortraitRow>(poorSheet));
}
const goodSheet = workbook.Sheets['优质客户洞察'];
if (goodSheet) {
reportData.goodCustomerData = buildSunburst(XLSX.utils.sheet_to_json<PortraitRow>(goodSheet));
}
// 6. 风险占比
const riskPieSheet = workbook.Sheets['风险占比'];
if (riskPieSheet) {
const rows = XLSX.utils.sheet_to_json<RiskPieRow>(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<ExportTaxRow>(exportTaxSheet);
const groups: Record<string, BarChartCompareItem[]> = {};
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<AssociationRow>(associationSheet);
const groups: Record<string, BarChartCompareItem[]> = {};
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<RiskScanningRow>(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<BarChartDescRow>(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<string, unknown>[]) => {
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');
};