diff --git a/compare_kaipiao.js b/compare_kaipiao.js new file mode 100644 index 0000000..5eb9691 --- /dev/null +++ b/compare_kaipiao.js @@ -0,0 +1,165 @@ +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}`); diff --git a/merge_kaipiao.js b/merge_kaipiao.js index 3f98935..edd4fff 100644 --- a/merge_kaipiao.js +++ b/merge_kaipiao.js @@ -50,6 +50,28 @@ function sheetToRows(sheet) { return XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null, blankrows: false }); } +// 去掉指定列(按表头名精确匹配)单元格值中的所有冒号(含全角) +function stripColonInColumn(rows, columnName) { + if (!rows || rows.length === 0) return 0; + const header = rows[0]; + const colIndex = header.findIndex(h => h != null && String(h).trim() === columnName); + if (colIndex === -1) { + console.warn(` [警告] 未找到列 "${columnName}",跳过冒号清理`); + return 0; + } + let changed = 0; + for (let i = 1; i < rows.length; i++) { + const v = rows[i][colIndex]; + if (v == null) continue; + const str = String(v); + if (str.includes(':') || str.includes(':')) { + rows[i][colIndex] = str.replace(/[::]/g, ''); + changed++; + } + } + return changed; +} + // ---------- 收集子文件夹 ---------- const subDirs = fs.readdirSync(rootDir, { withFileTypes: true }) .filter(d => d.isDirectory() && d.name.startsWith('7.')) @@ -132,6 +154,8 @@ const outWb = XLSX.utils.book_new(); if (shangluHeader) { const data = [shangluHeader, ...shangluRows]; + const n1 = stripColonInColumn(data, '结算账户'); + if (n1) console.log(` 商旅"结算账户"列清理冒号: ${n1} 个单元格`); const ws = XLSX.utils.aoa_to_sheet(data); XLSX.utils.book_append_sheet(outWb, ws, '商旅'); } else { @@ -140,6 +164,8 @@ if (shangluHeader) { if (jizhiHeader) { const data = [jizhiHeader, ...jizhiRows]; + const n2 = stripColonInColumn(data, '*对账单/项目名称'); + if (n2) console.log(` 吉智"*对账单/项目名称"列清理冒号: ${n2} 个单元格`); const ws = XLSX.utils.aoa_to_sheet(data); XLSX.utils.book_append_sheet(outWb, ws, '吉智'); } else {