const XLSX = require('xlsx'); const path = require('path'); /** * 开票清单 -> 吉智对账单 填列脚本 * * 输入 excel1: sheet="发票清单" * col "资源类型" = 大类(火车票/国际机票/国内机票/国际酒店/酒店/附加项) * col "开票明细" = 小类(包含字符: 机票/机票服务费/住宿费/住宿服务费/火车票/火车票服务费) * col "开票金额" = 金额 * col "结算账户" = 归属 TEAM 编号(TEAM-XXXX 或 XXXXTEAM-XXXX, 结尾 : 去掉) * * 输出 excel2: sheet 名含"吉智"(只有一个) * 有一行数据的 col "*对账单/项目名称" = TEAM 编号 * 填写以下列(每个项名对应一个 col): * 吉智服务费 = 酒店服务费 + 机票服务费 + 火车票服务费 * 酒店房费 = 国际酒店房费 + 酒店房费 * 酒店服务费 = 国际酒店服务费 + 酒店服务费 * 机票服务费 = 国际机票服务费 + 国内机票服务费 * 火车票服务费 = 火车票服务费 * * 中断条件: excel2 中找不到对应 TEAM -> 提示并中断 * 找到多个对应 TEAM -> 提示并中断 * * 用法: node jizhi_fill.js [excel1路径] [excel2路径] * 不带参数则使用下方配置常量 */ // ======================== 配置 ======================== const EXCEL1 = path.join(__dirname, '开票清单.xlsx'); // excel1 路径可修改 const EXCEL2 = path.join(__dirname, '吉智对账单.xlsx'); // excel2 路径可修改 const SHEET1_NAME = '发票清单'; // 需要填入 excel2 的字段: [列名(兼容*与空格), 取值函数] const FIELDS = [ { name: '吉智服务费', get: s => s.totalService }, { name: '酒店房费', get: s => s.hotelRoom }, { name: '酒店服务费', get: s => s.hotelService }, { name: '机票服务费', get: s => s.flightService }, { name: '火车票服务费', get: s => s.trainService }, ]; // ======================== 工具 ======================== function round2(n) { return Math.round((Number(n) + Number.EPSILON) * 100) / 100; } // 去掉全角/半角冒号(仅结尾), 以及空格 function normalizeTeam(v) { let s = String(v == null ? '' : v).trim(); s = s.replace(/[::]\s*$/, '').trim(); return s; } // 在 sheet 名中查找包含关键字的 sheet(恰好一个) function findSheet(workbook, keyword, label) { const names = workbook.SheetNames.filter(n => n.includes(keyword)); if (names.length === 0) { console.error(`[中断] ${label}: 未找到名称包含"${keyword}"的 sheet`); process.exit(1); } if (names.length > 1) { console.error(`[中断] ${label}: 找到多个名称包含"${keyword}"的 sheet: ${names.join(', ')}`); process.exit(1); } return names[0]; } // 精确(去星号/空格)匹配列名 -> 列下标; 找不到返回 -1 function findColIndex(rows, targetName) { const want = targetName.replace(/[*\s]/g, ''); const header = rows[0]; if (!header) return -1; for (let c = 0; c < header.length; c++) { const h = header[c]; if (h == null) continue; if (String(h).replace(/[*\s]/g, '') === want) return c; } return -1; } // 读取一个 sheet 成二维数组(含表头) function sheetToRows(sheet) { return XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null, blankrows: false }); } // ======================== 1. 读取 excel1 ======================== function readInvoice(excel1Path) { const wb = XLSX.readFile(excel1Path); const sheet = wb.Sheets[SHEET1_NAME]; if (!sheet) { console.error(`[中断] excel1: 未找到 sheet "${SHEET1_NAME}"`); process.exit(1); } const rows = sheetToRows(sheet); const header = rows[0]; const col = name => findColIndex(rows, name); const idxResource = col('资源类型'); const idxDetail = col('开票明细'); const idxAmount = col('开票金额'); const idxAccount = col('结算账户'); if ([idxResource, idxDetail, idxAmount, idxAccount].some(i => i === -1)) { console.error(`[中断] excel1: 缺少必要列(资源类型/开票明细/开票金额/结算账户), 现有表头: ${JSON.stringify(header)}`); process.exit(1); } // 每个 TEAM 的累加结果 const sums = new Map(); // key: teamId -> {totalService, hotelRoom, hotelService, flightService, trainService} const newSum = () => ({ totalService: 0, hotelRoom: 0, hotelService: 0, flightService: 0, trainService: 0 }); for (let r = 1; r < rows.length; r++) { const row = rows[r]; const resource = row[idxResource] == null ? '' : String(row[idxResource]).trim(); const detail = row[idxDetail] == null ? '' : String(row[idxDetail]).trim(); const amount = Number(row[idxAmount]); const accountRaw = row[idxAccount]; if (resource === '' && detail === '') continue; // 空行 // 归属 TEAM const match = normalizeTeam(accountRaw).match(/TEAM-\d+/); if (!match) { console.warn(` [警告] 第${r + 1}行: 无法从结算账户提取 TEAM (值: "${accountRaw}"),跳过`); continue; } const teamId = match[0]; const s = sums.get(teamId) || newSum(); sums.set(teamId, s); if (Number.isNaN(amount)) { console.warn(` [警告] 第${r + 1}行: 开票金额无法解析 (值: "${row[idxAmount]}"),跳过`); continue; } // 按"开票明细"小类包含字符归类(注意先匹配更具体的"服务费") if (detail.includes('住宿服务费')) { s.hotelService += amount; } else if (detail.includes('住宿费')) { s.hotelRoom += amount; } else if (detail.includes('机票服务费')) { s.flightService += amount; } else if (detail.includes('火车票服务费')) { s.trainService += amount; } else { console.warn(` [警告] 第${r + 1}行: 开票明细"${detail}"未识别到服务费/住宿费类别,跳过 (${teamId}, ${amount})`); continue; } } // 计算吉智服务费 = 各服务费之和 for (const s of sums.values()) { s.totalService = round2(s.hotelService + s.flightService + s.trainService); s.hotelRoom = round2(s.hotelRoom); s.hotelService = round2(s.hotelService); s.flightService = round2(s.flightService); s.trainService = round2(s.trainService); } if (sums.size === 0) { console.error('[中断] excel1: 未提取到任何 TEAM 数据'); process.exit(1); } console.log(`[excel1] 解析完成, 共 ${sums.size} 个 TEAM:`); for (const [tid, s] of sums) { console.log(` ${tid}: 吉智服务费=${s.totalService}, 酒店房费=${s.hotelRoom}, 酒店服务费=${s.hotelService}, 机票服务费=${s.flightService}, 火车票服务费=${s.trainService}`); } return sums; } // ======================== 2. 写入 excel2 ======================== function writeJizhi(excel2Path, sums) { const wb = XLSX.readFile(excel2Path); const sheetName = findSheet(wb, '吉智', 'excel2'); const sheet = wb.Sheets[sheetName]; const rows = sheetToRows(sheet); const header = rows[0]; const nameIdx = findColIndex(rows, '对账单/项目名称'); if (nameIdx === -1) { console.error(`[中断] excel2: 未找到列"对账单/项目名称", 现有表头: ${JSON.stringify(header)}`); process.exit(1); } // 收集每个 TEAM 匹配到的行 const matched = new Map(); // teamId -> [rowIdx...] for (let r = 1; r < rows.length; r++) { const val = rows[r][nameIdx]; if (val == null || String(val).trim() === '') continue; const teamId = normalizeTeam(val).match(/TEAM-\d+/); if (!teamId) continue; const tid = teamId[0]; if (!matched.has(tid)) matched.set(tid, []); matched.get(tid).push(r); } // 校验: 每个需要填写的 TEAM 在 excel2 中必须恰好匹配一行 for (const [tid] of sums) { const rowsFound = matched.get(tid) || []; if (rowsFound.length === 0) { console.error(`[中断] excel2: 找不到 TEAM "${tid}" 对应的行, 请检查"对账单/项目名称"列`); process.exit(1); } if (rowsFound.length > 1) { console.error(`[中断] excel2: TEAM "${tid}" 在 excel2 中找到 ${rowsFound.length} 行(第 ${rowsFound.map(x => x + 1).join(', ')} 行), 存在重复, 无法确定唯一目标`); process.exit(1); } } // 写入各字段 for (const field of FIELDS) { const colIdx = findColIndex(rows, field.name); if (colIdx === -1) { console.error(`[中断] excel2: 未找到列"${field.name}", 现有表头: ${JSON.stringify(header)}`); process.exit(1); } for (const [tid, s] of sums) { const rowIdx = matched.get(tid)[0]; XLSX.utils.sheet_add_aoa(sheet, [[field.get(s)]], { origin: { r: rowIdx, c: colIdx } }); } } XLSX.writeFile(wb, excel2Path); console.log(`\n[excel2] 已写入: ${excel2Path} (sheet: ${sheetName})`); for (const [tid] of sums) { console.log(` 填写完成: ${tid} (第 ${matched.get(tid)[0] + 1} 行)`); } } // ======================== 主流程 ======================== function main() { const excel1Path = process.argv[2] || EXCEL1; const excel2Path = process.argv[3] || EXCEL2; if (!require('fs').existsSync(excel1Path)) { console.error(`[中断] excel1 文件不存在: ${excel1Path}`); process.exit(1); } if (!require('fs').existsSync(excel2Path)) { console.error(`[中断] excel2 文件不存在: ${excel2Path}`); process.exit(1); } console.log(`excel1: ${excel1Path}`); console.log(`excel2: ${excel2Path}\n`); const sums = readInvoice(excel1Path); writeJizhi(excel2Path, sums); console.log('\n========== 完成 =========='); } main();