chore: initial commit

This commit is contained in:
hailin
2026-05-18 13:52:47 +08:00
commit 0753129afe
148 changed files with 14202 additions and 0 deletions

493
src/utils/excelUtils.ts Normal file
View File

@@ -0,0 +1,493 @@
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');
};