This commit is contained in:
cheney 2026-08-11 21:58:36 +08:00
parent 477914858b
commit e51c262673
3 changed files with 262 additions and 6 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
node_modules/* node_modules/*
~$* ~$*
.reasonix/*

255
jizhi_fill.js Normal file
View File

@ -0,0 +1,255 @@
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();

View File

@ -1,6 +1,6 @@
{ {
"server": { "server": {
"url": "ws://localhost:18888/websocket_path", "url": "ws://bh.vps.honor3.com:18888/websocket_path",
"password": "helloworld" "password": "helloworld"
}, },
"client_name": "cheney" "client_name": "cheney"