const XLSX = require('xlsx'); const fs = require('fs'); const path = require('path'); /** * 对比脚本:商旅 vs 吉智 金额核对 * * 输入:一个包含「商旅」和「吉智」两个 sheet 的 Excel(即 merge_kaipiao.js 的合并结果)。 * * 逻辑: * 1. 商旅 sheet:按「结算账户」聚合,「账单总金额」相加。 * 2. 吉智 sheet:按「*对账单/项目名称」聚合,「*账单应收总金额」相加。 * (吉智的「*对账单/项目名称」对应商旅的「结算账户」) * 3. 对比两侧同一 key 的金额,找出不同: * - 仅商旅有的 key * - 仅吉智有的 key * - 两侧都有但金额不一致的 key * * 用法: * node compare_kaipiao.js <合并excel路径> [输出结果路径] * * 例如: * node compare_kaipiao.js "团队开票申请表-合并.xlsx" "对比结果.xlsx" */ // ---------- 参数 ---------- const inFile = process.argv[2]; const outFile = process.argv[3] || path.join(process.cwd(), '对比结果.xlsx'); if (!inFile) { console.error('用法: node compare_kaipiao.js <合并excel路径> [输出结果路径]'); process.exit(1); } if (!fs.existsSync(inFile)) { console.error(`输入文件不存在: ${inFile}`); process.exit(1); } // 金额比较容差(避免浮点误差) const EPS = 0.005; // ---------- 工具 ---------- // 按关键字找 sheet 名 function findSheetName(workbook, keyword) { return workbook.SheetNames.find(n => n.includes(keyword)); } // 找列下标(精确匹配去空格后的表头) function findColIndex(header, name) { return header.findIndex(h => h != null && String(h).trim() === name); } // 规范化 key(去首尾空格) function normKey(v) { return v == null ? '' : String(v).trim(); } // 解析金额为数字(去掉逗号/空格/货币符号) function toNumber(v) { if (v == null || v === '') return 0; if (typeof v === 'number') return v; const cleaned = String(v).replace(/[,\s\uFFE5\u00A5¥]/g, ''); const n = parseFloat(cleaned); return isNaN(n) ? 0 : n; } // 保留两位小数 function round2(n) { return Math.round(n * 100) / 100; } // 按 keyCol 聚合 amountCol function aggregate(sheet, keyColName, amountColName, label) { const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null, blankrows: false }); if (rows.length === 0) return new Map(); const header = rows[0]; const keyIdx = findColIndex(header, keyColName); const amtIdx = findColIndex(header, amountColName); if (keyIdx === -1) { console.error(`${label}: 未找到列 "${keyColName}"`); process.exit(1); } if (amtIdx === -1) { console.error(`${label}: 未找到列 "${amountColName}"`); process.exit(1); } const map = new Map(); for (let i = 1; i < rows.length; i++) { const key = normKey(rows[i][keyIdx]); if (key === '') continue; const amt = toNumber(rows[i][amtIdx]); const cur = map.get(key) || { sum: 0, count: 0 }; cur.sum += amt; cur.count += 1; map.set(key, cur); } return map; } // ---------- 读取 ---------- const wb = XLSX.readFile(inFile); const shangluName = findSheetName(wb, '商旅'); const jizhiName = findSheetName(wb, '吉智'); if (!shangluName) { console.error('未找到含"商旅"的 sheet'); process.exit(1); } if (!jizhiName) { console.error('未找到含"吉智"的 sheet'); process.exit(1); } const shangluMap = aggregate(wb.Sheets[shangluName], '结算账户', '账单总金额', '商旅'); const jizhiMap = aggregate(wb.Sheets[jizhiName], '*对账单/项目名称', '*账单应收总金额', '吉智'); // ---------- 对比 ---------- const allKeys = new Set([...shangluMap.keys(), ...jizhiMap.keys()]); const diffRows = [['结算账户/项目名称', '商旅账单总金额', '吉智账单应收总金额', '差额(商旅-吉智)', '差异类型']]; let onlyShanglu = 0, onlyJizhi = 0, mismatch = 0, matched = 0; for (const key of [...allKeys].sort()) { const s = shangluMap.get(key); const j = jizhiMap.get(key); if (s && !j) { diffRows.push([key, round2(s.sum), null, round2(s.sum), '仅商旅有']); onlyShanglu++; } else if (!s && j) { diffRows.push([key, null, round2(j.sum), round2(-j.sum), '仅吉智有']); onlyJizhi++; } else { const delta = s.sum - j.sum; if (Math.abs(delta) > EPS) { diffRows.push([key, round2(s.sum), round2(j.sum), round2(delta), '金额不一致']); mismatch++; } else { matched++; } } } // ---------- 输出 ---------- const outWb = XLSX.utils.book_new(); const ws = XLSX.utils.aoa_to_sheet(diffRows); XLSX.utils.book_append_sheet(outWb, ws, '差异'); // 汇总 sheet const summary = [ ['项目', '数量'], ['商旅去重结算账户数', shangluMap.size], ['吉智去重项目名称数', jizhiMap.size], ['仅商旅有', onlyShanglu], ['仅吉智有', onlyJizhi], ['金额不一致', mismatch], ['金额一致', matched], ]; XLSX.utils.book_append_sheet(outWb, XLSX.utils.aoa_to_sheet(summary), '汇总'); XLSX.writeFile(outWb, outFile); console.log('对比完成:'); console.log(` 商旅去重结算账户数: ${shangluMap.size}`); console.log(` 吉智去重项目名称数: ${jizhiMap.size}`); console.log(` 仅商旅有: ${onlyShanglu}`); console.log(` 仅吉智有: ${onlyJizhi}`); console.log(` 金额不一致: ${mismatch}`); console.log(` 金额一致: ${matched}`); console.log(` 输出文件: ${outFile}`);