对比
This commit is contained in:
parent
11451aac40
commit
477914858b
165
compare_kaipiao.js
Normal file
165
compare_kaipiao.js
Normal file
@ -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}`);
|
||||||
@ -50,6 +50,28 @@ function sheetToRows(sheet) {
|
|||||||
return XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null, blankrows: false });
|
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 })
|
const subDirs = fs.readdirSync(rootDir, { withFileTypes: true })
|
||||||
.filter(d => d.isDirectory() && d.name.startsWith('7.'))
|
.filter(d => d.isDirectory() && d.name.startsWith('7.'))
|
||||||
@ -132,6 +154,8 @@ const outWb = XLSX.utils.book_new();
|
|||||||
|
|
||||||
if (shangluHeader) {
|
if (shangluHeader) {
|
||||||
const data = [shangluHeader, ...shangluRows];
|
const data = [shangluHeader, ...shangluRows];
|
||||||
|
const n1 = stripColonInColumn(data, '结算账户');
|
||||||
|
if (n1) console.log(` 商旅"结算账户"列清理冒号: ${n1} 个单元格`);
|
||||||
const ws = XLSX.utils.aoa_to_sheet(data);
|
const ws = XLSX.utils.aoa_to_sheet(data);
|
||||||
XLSX.utils.book_append_sheet(outWb, ws, '商旅');
|
XLSX.utils.book_append_sheet(outWb, ws, '商旅');
|
||||||
} else {
|
} else {
|
||||||
@ -140,6 +164,8 @@ if (shangluHeader) {
|
|||||||
|
|
||||||
if (jizhiHeader) {
|
if (jizhiHeader) {
|
||||||
const data = [jizhiHeader, ...jizhiRows];
|
const data = [jizhiHeader, ...jizhiRows];
|
||||||
|
const n2 = stripColonInColumn(data, '*对账单/项目名称');
|
||||||
|
if (n2) console.log(` 吉智"*对账单/项目名称"列清理冒号: ${n2} 个单元格`);
|
||||||
const ws = XLSX.utils.aoa_to_sheet(data);
|
const ws = XLSX.utils.aoa_to_sheet(data);
|
||||||
XLSX.utils.book_append_sheet(outWb, ws, '吉智');
|
XLSX.utils.book_append_sheet(outWb, ws, '吉智');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user