This commit is contained in:
cheney 2026-07-25 20:33:34 +08:00
parent c1e378a631
commit 11451aac40
5 changed files with 442 additions and 0 deletions

283
compare.js Normal file
View File

@ -0,0 +1,283 @@
const XLSX = require('xlsx');
const fs = require('fs');
/**
* 功能读取Excel文件并解析为JSON
* 参数filePath - 文件路径
* 返回值解析后的工作簿对象
*/
function readExcel(filePath) {
return XLSX.readFile(filePath);
}
/**
* 功能判断是否为有效订单号FR开头的订单号
* 参数orderNo - 订单号
* 返回值是否有效
*/
function isValidOrderNo(orderNo) {
if (!orderNo) return false;
const str = String(orderNo).trim();
if (!str) return false;
if (str === '总计' || str === '合计' || str === '汇总') return false;
if (str.startsWith('FR')) return true;
return /^\d+$/.test(str);
}
/**
* 功能从酒店账单提取订单号和金额
* 参数workbook - Excel工作簿对象
* 返回值订单Map {订单号: {金额, 入住人, 入住时间, 离店时间}}
*/
function extractHotelOrders(workbook) {
const sheet = workbook.Sheets['.'];
const data = XLSX.utils.sheet_to_json(sheet);
const orders = new Map();
data.forEach(row => {
const orderNo = row['吉利订单号'];
if (isValidOrderNo(orderNo)) {
orders.set(String(orderNo).trim(), {
结算金额: parseFloat(row['结算金额']) || 0,
系统金额: parseFloat(row['系统金额']) || 0,
差异: parseFloat(row['差异']) || 0,
入住人: row['入住人姓名'] || '',
入住时间: row['入住时间'] || '',
离店时间: row['离店时间'] || '',
房型: row['房型'] || '',
房号: row['房号'] || '',
房间数: row['房间数'] || 0,
间夜数: row['间夜数'] || 0
});
}
});
return orders;
}
/**
* 功能从商旅账单Export表提取订单号和金额合并同一订单的正负金额
* 参数workbook - Excel工作簿对象
* 返回值订单Map {订单号: 金额对象}
*/
function extractGeelyExportOrders(workbook) {
const sheet = workbook.Sheets['Export'];
const data = XLSX.utils.sheet_to_json(sheet, { header: 1 }); // 使用数组索引访问
const orders = new Map();
// 从第2行开始第0行是标题第1行是表头
for (let i = 2; i < data.length; i++) {
const row = data[i];
const orderNo = row[5]; // 供应商订单号在第5列
if (isValidOrderNo(orderNo)) {
// 金额在第11列纯数字已包含正负值
const amount = parseFloat(row[11]) || 0;
const key = String(orderNo).trim();
// 合并同一订单号的金额
if (orders.has(key)) {
const existing = orders.get(key);
existing.金额 += amount;
// 保留最后一条记录的其他信息
existing.对账明细名称 = row[6] || existing.对账明细名称;
existing.结算状态 = row[9] || existing.结算状态;
existing.对账单号 = row[0] || existing.对账单号;
} else {
orders.set(key, {
金额: amount,
对账明细名称: row[6] || '',
结算状态: row[9] || '',
对账单号: row[0] || '',
订单号: row[4] || ''
});
}
}
}
return orders;
}
/**
* 功能对比两个订单集合的差异
* 参数hotelOrders - 酒店方订单Map, geelyOrders - 商旅方订单Map
* 返回值差异结果对象
*/
function compareOrders(hotelOrders, geelyOrders) {
const hotelOnly = [];
const geelyOnly = [];
const amountDiff = [];
const matched = [];
// 检查酒店有但商旅没有的订单
for (const [orderNo, hotelInfo] of hotelOrders) {
if (!geelyOrders.has(orderNo)) {
hotelOnly.push({ 订单号: orderNo, ...hotelInfo });
} else {
const geelyInfo = geelyOrders.get(orderNo);
const hotelAmount = hotelInfo.结算金额;
const geelyAmount = geelyInfo.金额;
if (Math.abs(hotelAmount - geelyAmount) > 0.01) {
amountDiff.push({
订单号: orderNo,
酒店金额: hotelAmount,
商旅金额: geelyAmount,
差额: hotelAmount - geelyAmount,
入住人: hotelInfo.入住人,
酒店明细: hotelInfo,
商旅明细: geelyInfo
});
} else {
matched.push({
订单号: orderNo,
金额: hotelAmount,
入住人: hotelInfo.入住人
});
}
}
}
// 检查商旅有但酒店没有的订单
for (const [orderNo, geelyInfo] of geelyOrders) {
if (!hotelOrders.has(orderNo)) {
geelyOnly.push({ 订单号: orderNo, ...geelyInfo });
}
}
return { hotelOnly, geelyOnly, amountDiff, matched };
}
/**
* 功能生成差异报告并输出
* 参数result - 对比结果对象
*/
function generateReport(result, hotelOrdersCount, geelyOrdersCount) {
console.log('\n' + '='.repeat(70));
console.log(' 吉利商旅 vs 酒店账单 差异报告');
console.log(' (仅使用Export表已合并同一订单的正负金额)');
console.log('='.repeat(70));
console.log(`\n【统计概览】`);
console.log(` 酒店账单有效订单数: ${hotelOrdersCount}`);
console.log(` 商旅账单有效订单数: ${geelyOrdersCount}`);
console.log(` ✓ 匹配成功订单数: ${result.matched.length}`);
console.log(` ⚠ 金额差异订单数: ${result.amountDiff.length}`);
console.log(` ✗ 酒店有-商旅无: ${result.hotelOnly.length}`);
console.log(` ? 商旅有-酒店无: ${result.geelyOnly.length}`);
if (result.hotelOnly.length > 0) {
console.log(`\n【酒店有但商旅没有的订单 - 共${result.hotelOnly.length}条】`);
console.log('-'.repeat(70));
result.hotelOnly.forEach((item, i) => {
console.log(`${i+1}. ${item.订单号} | ${item.入住人.padEnd(6)} | ¥${String(item.结算金额).padStart(6)} | ${item.房型}`);
});
}
if (result.geelyOnly.length > 0) {
console.log(`\n【商旅有但酒店没有的订单 - 共${result.geelyOnly.length}条】`);
console.log('-'.repeat(70));
result.geelyOnly.forEach((item, i) => {
console.log(`${i+1}. ${item.订单号} | ¥${String(item.金额).padStart(6)} | ${item.对账明细名称 || ''}`);
});
}
if (result.amountDiff.length > 0) {
console.log(`\n【金额存在差异的订单 - 共${result.amountDiff.length}条】`);
console.log('-'.repeat(70));
result.amountDiff.forEach((item, i) => {
console.log(`${i+1}. ${item.订单号} | ${item.入住人}`);
console.log(` 酒店: ¥${item.酒店金额} | 商旅: ¥${item.商旅金额} | 差额: ¥${item.差额}`);
});
}
console.log('\n' + '='.repeat(70));
// 计算总金额
let hotelTotal = 0;
let geelyTotal = 0;
for (const item of result.matched) {
hotelTotal += item.金额;
geelyTotal += item.金额;
}
for (const item of result.amountDiff) {
hotelTotal += item.酒店金额;
geelyTotal += item.商旅金额;
}
for (const item of result.hotelOnly) {
hotelTotal += item.结算金额;
}
for (const item of result.geelyOnly) {
geelyTotal += item.金额;
}
console.log(`\n【金额汇总】`);
console.log(` 酒店账单总金额: ¥${hotelTotal}`);
console.log(` 商旅账单总金额: ¥${geelyTotal}`);
console.log(` 总差额: ¥${hotelTotal - geelyTotal}`);
console.log('');
return {
统计: {
酒店订单总数: hotelOrdersCount,
商旅订单总数: geelyOrdersCount,
匹配成功: result.matched.length,
金额差异: result.amountDiff.length,
酒店有商旅无: result.hotelOnly.length,
商旅有酒店无: result.geelyOnly.length,
酒店总金额: hotelTotal,
商旅总金额: geelyTotal,
总差额: hotelTotal - geelyTotal
},
酒店有商旅无: result.hotelOnly,
商旅有酒店无: result.geelyOnly,
金额差异: result.amountDiff,
匹配成功: result.matched
};
}
/**
* 功能将结果保存为JSON文件
* 参数report - 报告对象, filename - 文件名
*/
function saveReportToJson(report, filename) {
const jsonReport = JSON.parse(JSON.stringify(report, (key, value) => {
if (typeof value === 'bigint') return value.toString();
return value;
}));
fs.writeFileSync(filename, JSON.stringify(jsonReport, null, 2), 'utf8');
console.log(`详细结果已保存到: ${filename}`);
}
// 主函数
function main() {
const geelyFile = '杭州碧丽酒店管理有限公司 2026年06月 对账单(0601-0630).xlsx';
const hotelFile = '杭州滨江希尔顿欢朋酒店-吉利商旅6月账单.xlsx';
console.log('正在读取Excel文件...');
const geelyWorkbook = readExcel(geelyFile);
const hotelWorkbook = readExcel(hotelFile);
console.log('正在提取订单数据...');
const hotelOrders = extractHotelOrders(hotelWorkbook);
const geelyOrders = extractGeelyExportOrders(geelyWorkbook);
console.log(`酒店有效订单数: ${hotelOrders.size}`);
console.log(`商旅Export表有效订单数(合并后): ${geelyOrders.size}`);
console.log('\n正在对比订单...');
const result = compareOrders(hotelOrders, geelyOrders);
const report = generateReport(result, hotelOrders.size, geelyOrders.size);
saveReportToJson(report, '账单差异报告.json');
console.log('\n对比完成');
}
main();

159
merge_kaipiao.js Normal file
View File

@ -0,0 +1,159 @@
const XLSX = require('xlsx');
const fs = require('fs');
const path = require('path');
/**
* 合并团队开票申请表脚本
*
* 目录结构假设
* <根目录>/
* 7.xxx/
* *团队开票申请表*.xlsx (含4个sheet其中一个含"商旅"一个含"吉智")
* 其它两个表格...
* 7.yyy/
* ...
*
* 用法
* node merge_kaipiao.js <根目录> [输出文件路径]
*
* 例如
* node merge_kaipiao.js "D:\\某个目录" "D:\\合并结果.xlsx"
*/
// ---------- 参数 ----------
const rootDir = process.argv[2];
const outFile = process.argv[3] || path.join(process.cwd(), '团队开票申请表-合并.xlsx');
if (!rootDir) {
console.error('用法: node merge_kaipiao.js <根目录> [输出文件路径]');
process.exit(1);
}
if (!fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) {
console.error(`根目录不存在或不是文件夹: ${rootDir}`);
process.exit(1);
}
// ---------- 工具函数 ----------
// 判断是否为 Excel 文件(排除临时文件 ~$ 开头)
function isExcelFile(name) {
return /\.(xlsx|xls)$/i.test(name) && !name.startsWith('~$');
}
// 在一个工作簿里按关键字查找 sheet 名
function findSheetName(workbook, keyword) {
return workbook.SheetNames.find(n => n.includes(keyword));
}
// 把一个 sheet 读成二维数组(含表头行)
function sheetToRows(sheet) {
return XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null, blankrows: false });
}
// ---------- 收集子文件夹 ----------
const subDirs = fs.readdirSync(rootDir, { withFileTypes: true })
.filter(d => d.isDirectory() && d.name.startsWith('7.'))
.map(d => d.name)
.sort();
if (subDirs.length === 0) {
console.error('未找到以 "7." 开头的子文件夹');
process.exit(1);
}
console.log(`找到 ${subDirs.length} 个 "7." 子文件夹`);
// 合并结果:分别累积 商旅 / 吉智 两组数据
let shangluHeader = null;
const shangluRows = [];
let jizhiHeader = null;
const jizhiRows = [];
let processedCount = 0;
for (const sub of subDirs) {
const subPath = path.join(rootDir, sub);
// 找到名字含「团队开票申请表」的表格
const files = fs.readdirSync(subPath).filter(isExcelFile);
const targetFile = files.find(f => f.includes('团队开票申请表'));
if (!targetFile) {
console.warn(` [跳过] ${sub}: 未找到含"团队开票申请表"的表格`);
continue;
}
const filePath = path.join(subPath, targetFile);
let workbook;
try {
workbook = XLSX.readFile(filePath);
} catch (e) {
console.warn(` [跳过] ${sub}: 读取失败 - ${e.message}`);
continue;
}
const shangluName = findSheetName(workbook, '商旅');
const jizhiName = findSheetName(workbook, '吉智');
if (!shangluName) console.warn(` [警告] ${sub}: 未找到含"商旅"的sheet`);
if (!jizhiName) console.warn(` [警告] ${sub}: 未找到含"吉智"的sheet`);
// 处理 商旅
if (shangluName) {
const rows = sheetToRows(workbook.Sheets[shangluName]);
if (rows.length > 0) {
const [header, ...body] = rows;
if (!shangluHeader) shangluHeader = header;
shangluRows.push(...body);
}
}
// 处理 吉智
if (jizhiName) {
const rows = sheetToRows(workbook.Sheets[jizhiName]);
if (rows.length > 0) {
const [header, ...body] = rows;
if (!jizhiHeader) jizhiHeader = header;
jizhiRows.push(...body);
}
}
processedCount++;
console.log(` [完成] ${sub}: ${targetFile}`);
}
if (processedCount === 0) {
console.error('没有可合并的数据');
process.exit(1);
}
// ---------- 生成输出工作簿 ----------
const outWb = XLSX.utils.book_new();
if (shangluHeader) {
const data = [shangluHeader, ...shangluRows];
const ws = XLSX.utils.aoa_to_sheet(data);
XLSX.utils.book_append_sheet(outWb, ws, '商旅');
} else {
console.warn('未收集到任何"商旅"数据');
}
if (jizhiHeader) {
const data = [jizhiHeader, ...jizhiRows];
const ws = XLSX.utils.aoa_to_sheet(data);
XLSX.utils.book_append_sheet(outWb, ws, '吉智');
} else {
console.warn('未收集到任何"吉智"数据');
}
if (outWb.SheetNames.length === 0) {
console.error('没有生成任何 sheet未写出文件');
process.exit(1);
}
XLSX.writeFile(outWb, outFile);
console.log('\n合并完成:');
console.log(` 商旅: ${shangluRows.length} 行数据`);
console.log(` 吉智: ${jizhiRows.length} 行数据`);
console.log(` 输出文件: ${outFile}`);

Binary file not shown.