geely-kaipiao/jizhi_fill.js
2026-08-11 21:58:36 +08:00

256 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();