Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d0a29a465 | |||
| 60beb1f363 | |||
| 11f0f89727 | |||
| 021738a8bb |
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,3 +0,0 @@
|
|||||||
node_modules/*
|
|
||||||
~$*
|
|
||||||
.reasonix/*
|
|
||||||
11
Readme.md
11
Readme.md
@ -1,11 +0,0 @@
|
|||||||
## 开票业务
|
|
||||||
excle 结算状态过滤
|
|
||||||
登录
|
|
||||||
总进度
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 避免锁屏
|
|
||||||
打开目录:D:\Cheney\2\geely-kaipiao\
|
|
||||||
地址栏删空,输入 cmd,打开黑框。
|
|
||||||
输入命令:bun.exe keep-awake.js
|
|
||||||
0
Readme.txt
Normal file
0
Readme.txt
Normal file
283
compare.js
283
compare.js
@ -1,283 +0,0 @@
|
|||||||
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();
|
|
||||||
@ -1,165 +0,0 @@
|
|||||||
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}`);
|
|
||||||
200
duizhang.js
200
duizhang.js
@ -1,200 +0,0 @@
|
|||||||
const https = require('https');
|
|
||||||
const XLSX = require('xlsx');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// ======================== 配置 ========================
|
|
||||||
const EXCEL_FILE = '新建 XLSX 工作表.xlsx';
|
|
||||||
const SHEET_NAME = '2026';
|
|
||||||
|
|
||||||
const BASE_HOST = 'superstar.geelytravel.com';
|
|
||||||
const API_BASE = '/api/tmc/tmc';
|
|
||||||
const STEP_DELAY_MS = 3000;
|
|
||||||
|
|
||||||
const API_HEADERS = {
|
|
||||||
'accept': 'application/json, text/plain, */*',
|
|
||||||
'accept-language': 'zh-CN,zh;q=0.9',
|
|
||||||
'authorization': 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJKV1QtRC1TRVFfVVNFUl9UTUNfMDAwMDAxODMiLCJ1c2VyX2NvZGUiOiJTRVFfVVNFUl9UTUNfMDAwMDAxODMiLCJyb2xlcyI6WyLnu5PnrpfkuJPlkZgiLCLnu5PnrpfkuJPlkZgiXSwiZXhwIjoxNzg2NTMzNTUxLCJpYXQiOjE3ODM5NDE1NTF9.1Mcmf5IM07Scrry4FzPs6TAcfUu0Nqtjre_2iJlNd5c',
|
|
||||||
'cache-control': 'no-cache',
|
|
||||||
'pragma': 'no-cache',
|
|
||||||
'requestsource': 'D',
|
|
||||||
'usercode': 'SEQ_USER_TMC_00000183',
|
|
||||||
'cookie': '_c_WBKFRo=CefecIUfnnuqejh60diYtytjDPwI9Qptslcwakc4; acw_tc=731dc87617839414337367659e81455072769ade5ba31e7a9d3e4d5d817123',
|
|
||||||
'Referer': 'https://superstar.geelytravel.com/new-tmc/finance/account/saleBill/list',
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
||||||
};
|
|
||||||
|
|
||||||
function sleep(ms) {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
function httpRequest(method, path, headers, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const opts = {
|
|
||||||
hostname: BASE_HOST,
|
|
||||||
path,
|
|
||||||
method,
|
|
||||||
headers: headers || API_HEADERS,
|
|
||||||
rejectUnauthorized: false,
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
let data = '';
|
|
||||||
res.on('data', chunk => data += chunk);
|
|
||||||
res.on('end', () => {
|
|
||||||
try {
|
|
||||||
resolve({ status: res.statusCode, data: JSON.parse(data) });
|
|
||||||
} catch {
|
|
||||||
resolve({ status: res.statusCode, data });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
if (body) req.write(body);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ======================== 1. 读取 Excel ========================
|
|
||||||
function readTeamIds() {
|
|
||||||
const workbook = XLSX.readFile(path.join(__dirname, EXCEL_FILE));
|
|
||||||
const sheet = workbook.Sheets[SHEET_NAME];
|
|
||||||
if (!sheet) { console.error('Sheet not found'); process.exit(1); }
|
|
||||||
const rows = XLSX.utils.sheet_to_json(sheet, { defval: null });
|
|
||||||
const teamIds = [];
|
|
||||||
for (const row of rows) {
|
|
||||||
const val = row['团号'];
|
|
||||||
const kaipiaoStatus = row['开票状态'];
|
|
||||||
if (!val || !/^TEAM-\d+$/.test(String(val).trim())) continue;
|
|
||||||
if (kaipiaoStatus && String(kaipiaoStatus).trim() !== '未开票') {
|
|
||||||
console.log(` ⚠ ${String(val).trim()} 开票状态: "${kaipiaoStatus}",跳过`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
teamIds.push(String(val).trim());
|
|
||||||
}
|
|
||||||
return teamIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ======================== 2. 获取 settlementAccountIds ========================
|
|
||||||
async function querySettlementAccountIds(teamId) {
|
|
||||||
const path = `${API_BASE}/customerAccount/settlement/querySettlementAccountIds?customerSettlementAccountName=${encodeURIComponent(teamId)}`;
|
|
||||||
console.log(` [GET] ${path}`);
|
|
||||||
const res = await httpRequest('GET', path);
|
|
||||||
console.log(` [OK]`, JSON.stringify(res.data));
|
|
||||||
return (res.data && res.data.code === 'Z000' && Array.isArray(res.data.result)) ? res.data.result : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ======================== 3. 获取结算单列表 ========================
|
|
||||||
async function listReceiveStatements(settlementAccountId) {
|
|
||||||
const path = `${API_BASE}/settlement/receiveStatements/listPageReceiveStatements?settlementAccountId=${settlementAccountId}&sortType=1&pageIndex=1&pageSize=10`;
|
|
||||||
console.log(` [GET] ${path}`);
|
|
||||||
const res = await httpRequest('GET', path);
|
|
||||||
console.log(` [OK]`, JSON.stringify(res.data));
|
|
||||||
return (res.data && res.data.code === 'Z000' && res.data.result && res.data.result.list) ? res.data.result.list : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ======================== 4. 自动对账:POST ========================
|
|
||||||
async function autoReconciliation(statementSeq, id) {
|
|
||||||
const path = '/api/tmc/finance/frond/receiveStatements/autoReconciliation';
|
|
||||||
const headers = {
|
|
||||||
...API_HEADERS,
|
|
||||||
'content-type': 'application/json;charset=UTF-8',
|
|
||||||
'Referer': `https://superstar.geelytravel.com/new-tmc/finance/account/saleBill/detail/${id}/${statementSeq}`,
|
|
||||||
};
|
|
||||||
const body = JSON.stringify({ statementSeq });
|
|
||||||
console.log(` [POST] ${path} body=${body}`);
|
|
||||||
const res = await httpRequest('POST', path, headers, body);
|
|
||||||
console.log(` [OK]`, JSON.stringify(res.data));
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ======================== 5. 确认对账:POST ========================
|
|
||||||
async function confirmReconciliation(statementSeq, id) {
|
|
||||||
const path = `${API_BASE}/settlement/receiveStatementsGroup/confirmCompleteReceiveReconciliation?statementSeq=${statementSeq}`;
|
|
||||||
const headers = {
|
|
||||||
...API_HEADERS,
|
|
||||||
'Referer': `https://superstar.geelytravel.com/new-tmc/finance/account/saleBill/detail/${id}/${statementSeq}`,
|
|
||||||
};
|
|
||||||
console.log(` [POST] ${path}`);
|
|
||||||
const res = await httpRequest('POST', path, headers, null);
|
|
||||||
console.log(` [OK]`, JSON.stringify(res.data));
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ======================== 主流程 ========================
|
|
||||||
async function main() {
|
|
||||||
const teamIds = readTeamIds();
|
|
||||||
console.log(`
|
|
||||||
TEAM IDs: ${teamIds.join(', ')}
|
|
||||||
`);
|
|
||||||
|
|
||||||
// 预查询所有结算单,计算总任务数
|
|
||||||
console.log('预查询结算单列表...');
|
|
||||||
const allTasks = [];
|
|
||||||
for (const teamId of teamIds) {
|
|
||||||
const accounts = await querySettlementAccountIds(teamId);
|
|
||||||
if (!accounts.length) continue;
|
|
||||||
for (const account of accounts) {
|
|
||||||
const statements = await listReceiveStatements(account.id);
|
|
||||||
for (const st of statements) {
|
|
||||||
allTasks.push({ teamId, accountId: account.id, statement: st });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = allTasks.length;
|
|
||||||
const pendingTasks = allTasks.filter(t => t.statement.statementStatus !== '5');
|
|
||||||
const pendingTotal = pendingTasks.length;
|
|
||||||
const skipCount = total - pendingTotal;
|
|
||||||
console.log(`总任务: ${total} | 跳过(已完成): ${skipCount} | 待处理: ${pendingTotal}
|
|
||||||
`);
|
|
||||||
|
|
||||||
if (pendingTotal === 0) {
|
|
||||||
console.log('所有结算单已完成,无需处理。');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const allResults = [];
|
|
||||||
let completed = 0;
|
|
||||||
|
|
||||||
for (const task of allTasks) {
|
|
||||||
const { teamId, accountId, statement: st } = task;
|
|
||||||
const { id, statementSeq, statementName, receivableTotalPrice, statementStatus } = st;
|
|
||||||
|
|
||||||
const pct = Math.round((completed / pendingTotal) * 100);
|
|
||||||
console.log(`
|
|
||||||
${'='.repeat(50)}`);
|
|
||||||
console.log(` 进度: ${pct}% (${completed}/${pendingTotal}) | ${teamId} | ${statementSeq}`);
|
|
||||||
console.log(`${'='.repeat(50)}`);
|
|
||||||
console.log(` 结算单: ${statementName}`);
|
|
||||||
console.log(` 应收: \u00a5${receivableTotalPrice} | 状态: ${statementStatus}`);
|
|
||||||
|
|
||||||
if (statementStatus === '5') {
|
|
||||||
console.log(` \u26a0 已完成,跳过`);
|
|
||||||
allResults.push({ teamId, settlementAccountId: accountId, statementSeq, statementName, receivableTotalPrice, autoReconResult: { code: 'SKIP' }, confirmResult: { code: 'SKIP' } });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const autoResult = await autoReconciliation(statementSeq, id);
|
|
||||||
console.log(` \u23f3 等待 ${STEP_DELAY_MS / 1000}s...`);
|
|
||||||
await sleep(STEP_DELAY_MS);
|
|
||||||
const confirmResult = await confirmReconciliation(statementSeq, id);
|
|
||||||
|
|
||||||
allResults.push({ teamId, settlementAccountId: accountId, statementSeq, statementName, receivableTotalPrice, autoReconResult: autoResult, confirmResult });
|
|
||||||
completed++;
|
|
||||||
console.log(` >>> 进度: ${Math.round((completed / pendingTotal) * 100)}% (${completed}/${pendingTotal})`);
|
|
||||||
}
|
|
||||||
// ======================== 汇总 ========================
|
|
||||||
console.log('\n\n' + '='.repeat(70));
|
|
||||||
console.log(' 最终汇总');
|
|
||||||
console.log('='.repeat(70));
|
|
||||||
|
|
||||||
for (const item of allResults) {
|
|
||||||
const aOk = item.autoReconResult && (item.autoReconResult.code === 'Z000' || item.autoReconResult.code === 'SKIP');
|
|
||||||
const cOk = item.confirmResult && (item.confirmResult.code === 'Z000' || item.confirmResult.code === 'SKIP');
|
|
||||||
console.log(`\n${aOk ? '\u2705' : '\u274c'} 自动 | ${cOk ? '\u2705' : '\u274c'} 确认 | ${item.teamId} | ${item.statementSeq}`);
|
|
||||||
console.log(` ${item.statementName} \u00a5${item.receivableTotalPrice}`);
|
|
||||||
console.log(` 自动: ${JSON.stringify(item.autoReconResult)}`);
|
|
||||||
console.log(` 确认: ${JSON.stringify(item.confirmResult)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(err => { console.error('Error:', err); process.exit(1); });
|
|
||||||
255
jizhi_fill.js
255
jizhi_fill.js
@ -1,255 +0,0 @@
|
|||||||
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();
|
|
||||||
379
kaipiao.js
379
kaipiao.js
@ -1,379 +0,0 @@
|
|||||||
const XLSX = require('xlsx');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// ========== 配置 ==========
|
|
||||||
const EXCEL_FILE = path.join(__dirname, '新建 XLSX 工作表.xlsx');
|
|
||||||
const SHEET_NAME = '2026';
|
|
||||||
|
|
||||||
const BASE_URL = 'https://superstar.geelytravel.com';
|
|
||||||
const AUTH_TOKEN = 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJKV1QtRC1TRVFfVVNFUl9UTUNfMDAwMDAxODMiLCJ1c2VyX2NvZGUiOiJTRVFfVVNFUl9UTUNfMDAwMDAxODMiLCJyb2xlcyI6WyLnu5PnrpfkuJPlkZgiLCLnu5PnrpfkuJPlkZgiXSwiZXhwIjoxNzg2NTMzNTUxLCJpYXQiOjE3ODM5NDE1NTF9.1Mcmf5IM07Scrry4FzPs6TAcfUu0Nqtjre_2iJlNd5c';
|
|
||||||
|
|
||||||
const COMMON_HEADERS = {
|
|
||||||
'accept': 'application/json, text/plain, */*',
|
|
||||||
'accept-language': 'zh-CN,zh;q=0.9',
|
|
||||||
'authorization': AUTH_TOKEN,
|
|
||||||
'cache-control': 'no-cache',
|
|
||||||
'pragma': 'no-cache',
|
|
||||||
'requestsource': 'D',
|
|
||||||
'sec-fetch-dest': 'empty',
|
|
||||||
'sec-fetch-mode': 'cors',
|
|
||||||
'sec-fetch-site': 'same-origin',
|
|
||||||
'usercode': 'SEQ_USER_TMC_00000183',
|
|
||||||
'cookie': '_c_WBKFRo=CefecIUfnnuqejh60diYtytjDPwI9Qptslcwakc4; acw_tc=731dc87617840296932491914e815aef28b42e276e1a01eb53ed0f548bd5e3',
|
|
||||||
};
|
|
||||||
|
|
||||||
const INVOICE_APPLY_TITLE = '浙江吉利商务服务有限公司';
|
|
||||||
const INVOICE_TITLE = '吉智(杭州)文化创意有限公司';
|
|
||||||
const TAXPAYER_ID = '91330109MA2GM71J64';
|
|
||||||
const ADDRESS = '杭州市萧山区经纪技术开发区启迪路198号A-B102-126B室';
|
|
||||||
const TELEPHONE = '0571-85321518';
|
|
||||||
const DEPOSIT_BANK = '中国民生银行股份有限公司杭州分行';
|
|
||||||
const BANK_ACCOUNT = '631101003';
|
|
||||||
|
|
||||||
// ========== 1. 读取 Excel ==========
|
|
||||||
function readTeamIds() {
|
|
||||||
const workbook = XLSX.readFile(EXCEL_FILE);
|
|
||||||
const sheet = workbook.Sheets[SHEET_NAME];
|
|
||||||
if (!sheet) {
|
|
||||||
console.error('Sheet "' + SHEET_NAME + '" 不存在');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const rows = XLSX.utils.sheet_to_json(sheet, { defval: null });
|
|
||||||
const teamIds = [];
|
|
||||||
for (const row of rows) {
|
|
||||||
const val = row['团号'];
|
|
||||||
if (val && /^TEAM-\d+$/.test(String(val).trim())) {
|
|
||||||
teamIds.push(String(val).trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log('[Excel] 找到 ' + teamIds.length + ' 个 TEAM ID: ' + teamIds.join(', ') + '\n');
|
|
||||||
return teamIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 2. 查询 settlement account ==========
|
|
||||||
async function querySettlementAccountIds(teamId) {
|
|
||||||
const url = BASE_URL + '/api/tmc/tmc/customerAccount/settlement/querySettlementAccountIds?customerSettlementAccountName=' + teamId;
|
|
||||||
const resp = await fetch(url, { headers: COMMON_HEADERS, method: 'GET' });
|
|
||||||
return resp.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 3. 查询 bill list ==========
|
|
||||||
async function queryBillList(settlementAccountId) {
|
|
||||||
const url = BASE_URL + '/api/tmc/tmc/settlement/receiveBill/queryBillList?settlementAccountId=' + settlementAccountId + '&pageIndex=1&pageSize=10';
|
|
||||||
const resp = await fetch(url, { headers: COMMON_HEADERS, method: 'GET' });
|
|
||||||
return resp.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 4. 批量调整发票类型 ==========
|
|
||||||
async function batchAdjustInvoiceOrderPrice(settlementSeq, receiveBillId, originInvoiceType, targetInvoiceType) {
|
|
||||||
const url = BASE_URL + '/api/tmc/tmc/settlement/receiveInvoice/batchAdjustInvoiceOrderPrice';
|
|
||||||
const headers = {
|
|
||||||
...COMMON_HEADERS,
|
|
||||||
'content-type': 'application/json;charset=UTF-8',
|
|
||||||
'Referer': BASE_URL + '/new-tmc/finance/settlement/pay/invoice/billing/' + receiveBillId,
|
|
||||||
};
|
|
||||||
const body = JSON.stringify({
|
|
||||||
settlementSeq: settlementSeq,
|
|
||||||
orderMode: '1',
|
|
||||||
originInvoiceType: originInvoiceType,
|
|
||||||
targetInvoiceType: targetInvoiceType,
|
|
||||||
});
|
|
||||||
const resp = await fetch(url, { headers: headers, method: 'POST', body: body });
|
|
||||||
return resp.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 5. 确认开票 ==========
|
|
||||||
async function confirmInvoiceOrder(settlementSeq, receiveBillId) {
|
|
||||||
const url = BASE_URL + '/api/tmc/tmc/settlement/receiveInvoice/confirmInvoiceOrder';
|
|
||||||
const headers = {
|
|
||||||
...COMMON_HEADERS,
|
|
||||||
'content-type': 'application/json;charset=UTF-8',
|
|
||||||
'Referer': BASE_URL + '/new-tmc/finance/settlement/pay/invoice/billing/' + receiveBillId,
|
|
||||||
};
|
|
||||||
const body = JSON.stringify({ settlementSeq: settlementSeq, orderMode: '1' });
|
|
||||||
const resp = await fetch(url, { headers: headers, method: 'POST', body: body });
|
|
||||||
return resp.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 6. 提交开票 ==========
|
|
||||||
async function submitInvoiceApply(confirmInvoiceKey, invoiceInfoList, receiveBillId) {
|
|
||||||
const url = BASE_URL + '/api/tmc/tmc/settlement/receiveInvoice/submitInvoiceApply';
|
|
||||||
const headers = {
|
|
||||||
...COMMON_HEADERS,
|
|
||||||
'content-type': 'application/json;charset=UTF-8',
|
|
||||||
'Referer': BASE_URL + '/new-tmc/finance/settlement/pay/invoice/billing/' + receiveBillId,
|
|
||||||
};
|
|
||||||
const body = JSON.stringify({
|
|
||||||
confirmInvoiceKey: confirmInvoiceKey,
|
|
||||||
invoiceInfoList: invoiceInfoList,
|
|
||||||
});
|
|
||||||
const resp = await fetch(url, { headers: headers, method: 'POST', body: body });
|
|
||||||
return resp.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 7. 填充公司信息 ==========
|
|
||||||
function fillCompanyInfo(item) {
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
invoiceApplyTitle: INVOICE_APPLY_TITLE,
|
|
||||||
invoiceTitle: INVOICE_TITLE,
|
|
||||||
taxpayerIdentityNumber: TAXPAYER_ID,
|
|
||||||
address: ADDRESS,
|
|
||||||
telephone: TELEPHONE,
|
|
||||||
depositBank: DEPOSIT_BANK,
|
|
||||||
bankAccount: BANK_ACCOUNT,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 8. 映射火车票开票项 ==========
|
|
||||||
function mapTrainInvoice(serverItem) {
|
|
||||||
const invoiceType = serverItem.invoiceType || '';
|
|
||||||
const item = {
|
|
||||||
invoiceSerialNum: serverItem.invoiceSerialNum,
|
|
||||||
invoiceType: invoiceType,
|
|
||||||
invoicePrice: serverItem.invoicePrice,
|
|
||||||
taxRate: invoiceType === '13' ? 6 : 0,
|
|
||||||
invoiceSettlementDetail: serverItem.settlementDetails || '',
|
|
||||||
invoiceQuantity: serverItem.invoiceQuantity || 0,
|
|
||||||
ticketList: serverItem.ticketList || null,
|
|
||||||
};
|
|
||||||
if (invoiceType === '13') {
|
|
||||||
item.invoiceContent = '*生产生活服务*代订火车票服务费';
|
|
||||||
}
|
|
||||||
return fillCompanyInfo(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 9. 映射酒店开票项 ==========
|
|
||||||
function mapHotelInvoice(serverItem) {
|
|
||||||
const invoiceType = serverItem.invoiceType || '';
|
|
||||||
const item = {
|
|
||||||
invoiceSerialNum: serverItem.invoiceSerialNum,
|
|
||||||
invoiceType: invoiceType,
|
|
||||||
invoicePrice: serverItem.invoicePrice,
|
|
||||||
taxRate: 6,
|
|
||||||
invoiceSettlementDetail: serverItem.settlementDetails || '',
|
|
||||||
invoiceQuantity: serverItem.invoiceQuantity || 0,
|
|
||||||
ticketList: serverItem.ticketList || null,
|
|
||||||
};
|
|
||||||
if (invoiceType === '2' || invoiceType === '1') {
|
|
||||||
item.invoiceContent = '*生产生活服务*代订住宿费';
|
|
||||||
} else if (invoiceType === '13') {
|
|
||||||
item.invoiceContent = '*生产生活服务*代订住宿服务费';
|
|
||||||
}
|
|
||||||
return fillCompanyInfo(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 10. 映射机票开票项(国内+国际通用) ==========
|
|
||||||
function mapFlightInvoice(serverItem) {
|
|
||||||
const invoiceType = serverItem.invoiceType || '';
|
|
||||||
const item = {
|
|
||||||
invoiceSerialNum: serverItem.invoiceSerialNum,
|
|
||||||
invoiceType: invoiceType,
|
|
||||||
invoicePrice: serverItem.invoicePrice,
|
|
||||||
taxRate: 6,
|
|
||||||
invoiceSettlementDetail: serverItem.settlementDetails || '',
|
|
||||||
invoiceQuantity: serverItem.invoiceQuantity || 0,
|
|
||||||
ticketList: serverItem.ticketList || null,
|
|
||||||
};
|
|
||||||
if (invoiceType === '2') {
|
|
||||||
item.invoiceContent = '*生产生活服务*代订机票';
|
|
||||||
} else if (invoiceType === '13') {
|
|
||||||
item.invoiceContent = '*生产生活服务*代订机票服务费';
|
|
||||||
}
|
|
||||||
return fillCompanyInfo(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 11. 处理火车票 ==========
|
|
||||||
async function processTrainBill(bill, billIndex, totalBills) {
|
|
||||||
const settlementSeq = bill.settlementSeq;
|
|
||||||
const receiveBillId = bill.receiveBillId;
|
|
||||||
|
|
||||||
console.log(' --- 火车票 [' + billIndex + '/' + totalBills + '] ' + bill.statementName + ' ---');
|
|
||||||
|
|
||||||
const confirmResp = await confirmInvoiceOrder(settlementSeq, receiveBillId);
|
|
||||||
console.log(' -> confirmInvoiceOrder:', JSON.stringify(confirmResp));
|
|
||||||
|
|
||||||
const confirmResult = confirmResp?.result;
|
|
||||||
if (!confirmResult || !confirmResult.invoiceAble) {
|
|
||||||
console.log(' ⚠ 跳过: ' + (confirmResp?.message || '') + '\n');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmInvoiceKey = confirmResult.confirmInvoiceKey;
|
|
||||||
const submitList = (confirmResult.invoiceInfoList || []).map(mapTrainInvoice);
|
|
||||||
|
|
||||||
const submitResp = await submitInvoiceApply(confirmInvoiceKey, submitList, receiveBillId);
|
|
||||||
console.log(' -> submitInvoiceApply:', JSON.stringify(submitResp));
|
|
||||||
console.log('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 12. 处理酒店 ==========
|
|
||||||
async function processHotelBill(bill, billIndex, totalBills) {
|
|
||||||
const settlementSeq = bill.settlementSeq;
|
|
||||||
const receiveBillId = bill.receiveBillId;
|
|
||||||
|
|
||||||
console.log(' --- 酒店 [' + billIndex + '/' + totalBills + '] ' + bill.statementName + ' ---');
|
|
||||||
|
|
||||||
const adjustResp = await batchAdjustInvoiceOrderPrice(settlementSeq, receiveBillId, '1', '2');
|
|
||||||
console.log(' -> batchAdjust (1→2):', JSON.stringify(adjustResp));
|
|
||||||
if (adjustResp?.code !== 'Z000') {
|
|
||||||
console.log(' ⚠ 调整失败: ' + (adjustResp?.message || '') + '\n');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmResp = await confirmInvoiceOrder(settlementSeq, receiveBillId);
|
|
||||||
console.log(' -> confirmInvoiceOrder:', JSON.stringify(confirmResp));
|
|
||||||
|
|
||||||
const confirmResult = confirmResp?.result;
|
|
||||||
if (!confirmResult || !confirmResult.invoiceAble) {
|
|
||||||
console.log(' ⚠ 跳过: ' + (confirmResp?.message || '') + '\n');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmInvoiceKey = confirmResult.confirmInvoiceKey;
|
|
||||||
const submitList = (confirmResult.invoiceInfoList || []).map(mapHotelInvoice);
|
|
||||||
|
|
||||||
const submitResp = await submitInvoiceApply(confirmInvoiceKey, submitList, receiveBillId);
|
|
||||||
console.log(' -> submitInvoiceApply:', JSON.stringify(submitResp));
|
|
||||||
console.log('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 13. 处理国内机票 ==========
|
|
||||||
async function processFlightBill(bill, billIndex, totalBills) {
|
|
||||||
const settlementSeq = bill.settlementSeq;
|
|
||||||
const receiveBillId = bill.receiveBillId;
|
|
||||||
|
|
||||||
console.log(' --- 国内机票 [' + billIndex + '/' + totalBills + '] ' + bill.statementName + ' ---');
|
|
||||||
|
|
||||||
const adjust1Resp = await batchAdjustInvoiceOrderPrice(settlementSeq, receiveBillId, '4', '2');
|
|
||||||
console.log(' -> batchAdjust (4→2):', JSON.stringify(adjust1Resp));
|
|
||||||
if (adjust1Resp?.code !== 'Z000') {
|
|
||||||
console.log(' ⚠ 调整失败: ' + (adjust1Resp?.message || '') + '\n');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const adjust2Resp = await batchAdjustInvoiceOrderPrice(settlementSeq, receiveBillId, '30', '2');
|
|
||||||
console.log(' -> batchAdjust (30→2):', JSON.stringify(adjust2Resp));
|
|
||||||
if (adjust2Resp?.code !== 'Z000') {
|
|
||||||
console.log(' ⚠ 调整失败: ' + (adjust2Resp?.message || '') + '\n');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmResp = await confirmInvoiceOrder(settlementSeq, receiveBillId);
|
|
||||||
console.log(' -> confirmInvoiceOrder:', JSON.stringify(confirmResp));
|
|
||||||
|
|
||||||
const confirmResult = confirmResp?.result;
|
|
||||||
if (!confirmResult || !confirmResult.invoiceAble) {
|
|
||||||
console.log(' ⚠ 跳过: ' + (confirmResp?.message || '') + '\n');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmInvoiceKey = confirmResult.confirmInvoiceKey;
|
|
||||||
const submitList = (confirmResult.invoiceInfoList || []).map(mapFlightInvoice);
|
|
||||||
|
|
||||||
const submitResp = await submitInvoiceApply(confirmInvoiceKey, submitList, receiveBillId);
|
|
||||||
console.log(' -> submitInvoiceApply:', JSON.stringify(submitResp));
|
|
||||||
console.log('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 14. 处理国际机票 ==========
|
|
||||||
async function processInternationalFlightBill(bill, billIndex, totalBills) {
|
|
||||||
const settlementSeq = bill.settlementSeq;
|
|
||||||
const receiveBillId = bill.receiveBillId;
|
|
||||||
|
|
||||||
console.log(' --- 国际机票 [' + billIndex + '/' + totalBills + '] ' + bill.statementName + ' ---');
|
|
||||||
|
|
||||||
const confirmResp = await confirmInvoiceOrder(settlementSeq, receiveBillId);
|
|
||||||
console.log(' -> confirmInvoiceOrder:', JSON.stringify(confirmResp));
|
|
||||||
|
|
||||||
const confirmResult = confirmResp?.result;
|
|
||||||
if (!confirmResult || !confirmResult.invoiceAble) {
|
|
||||||
console.log(' ⚠ 跳过: ' + (confirmResp?.message || '') + '\n');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmInvoiceKey = confirmResult.confirmInvoiceKey;
|
|
||||||
const submitList = (confirmResult.invoiceInfoList || []).map(mapFlightInvoice);
|
|
||||||
|
|
||||||
const submitResp = await submitInvoiceApply(confirmInvoiceKey, submitList, receiveBillId);
|
|
||||||
console.log(' -> submitInvoiceApply:', JSON.stringify(submitResp));
|
|
||||||
console.log('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 主流程 ==========
|
|
||||||
async function main() {
|
|
||||||
const teamIds = readTeamIds();
|
|
||||||
if (teamIds.length === 0) {
|
|
||||||
console.log('没有找到 TEAM ID,退出');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < teamIds.length; i++) {
|
|
||||||
const teamId = teamIds[i];
|
|
||||||
console.log('[' + (i + 1) + '/' + teamIds.length + '] 处理 ' + teamId + ' ...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const settlementResp = await querySettlementAccountIds(teamId);
|
|
||||||
const resultList = settlementResp?.result || [];
|
|
||||||
if (resultList.length === 0) {
|
|
||||||
console.log(' ⚠ ' + teamId + ' 没有 settlement account,跳过\n');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const settlementAccountId = resultList[0]?.id;
|
|
||||||
console.log(' -> settlementAccountId: ' + settlementAccountId);
|
|
||||||
|
|
||||||
const billResp = await queryBillList(settlementAccountId);
|
|
||||||
const allBills = billResp?.result?.list || [];
|
|
||||||
const total = billResp?.result?.total || 0;
|
|
||||||
console.log(' -> 共 ' + total + ' 条对账单');
|
|
||||||
|
|
||||||
const skippedBills = allBills.filter(function (b) { return String(b.settlementStatus) === '3'; });
|
|
||||||
skippedBills.forEach(function (b) {
|
|
||||||
console.log(' ⚠ 跳过 settlementStatus=3 的对账单: ' + (b.statementName || b.receiveBillId || '') + '\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
const bills = allBills.filter(function (b) { return String(b.settlementStatus) !== '3'; });
|
|
||||||
console.log(' -> 实际处理 ' + bills.length + ' 条对账单\n');
|
|
||||||
|
|
||||||
const trainBills = bills.filter(function (b) { return (b.statementName || '').includes('火车票'); });
|
|
||||||
const hotelBills = bills.filter(function (b) { return (b.statementName || '').includes('酒店'); });
|
|
||||||
const flightBills = bills.filter(function (b) { return (b.statementName || '').includes('国内机票'); });
|
|
||||||
const intlFlightBills = bills.filter(function (b) { return (b.statementName || '').includes('国际机票'); });
|
|
||||||
const otherBills = bills.filter(function (b) {
|
|
||||||
const name = b.statementName || '';
|
|
||||||
return !name.includes('火车票') && !name.includes('酒店') && !name.includes('国内机票') && !name.includes('国际机票');
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(' -> 火车票: ' + trainBills.length + ', 酒店: ' + hotelBills.length + ', 国内机票: ' + flightBills.length + ', 国际机票: ' + intlFlightBills.length + ', 其他: ' + otherBills.length + '\n');
|
|
||||||
|
|
||||||
for (let j = 0; j < trainBills.length; j++) {
|
|
||||||
await processTrainBill(trainBills[j], j + 1, trainBills.length);
|
|
||||||
}
|
|
||||||
for (let j = 0; j < hotelBills.length; j++) {
|
|
||||||
await processHotelBill(hotelBills[j], j + 1, hotelBills.length);
|
|
||||||
}
|
|
||||||
for (let j = 0; j < flightBills.length; j++) {
|
|
||||||
await processFlightBill(flightBills[j], j + 1, flightBills.length);
|
|
||||||
}
|
|
||||||
for (let j = 0; j < intlFlightBills.length; j++) {
|
|
||||||
await processInternationalFlightBill(intlFlightBills[j], j + 1, intlFlightBills.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (otherBills.length > 0) {
|
|
||||||
console.log(' === 以下对账单未处理(未知类型) ===');
|
|
||||||
otherBills.forEach(function (b) { console.log(' - ' + b.statementName); });
|
|
||||||
console.log('');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(' ✗ ' + teamId + ' 出错:', err.message);
|
|
||||||
}
|
|
||||||
console.log('');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('========== 完成 ==========');
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(err => {
|
|
||||||
console.error('Fatal error:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
const { execSync } = require('child_process');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const SCRIPT = path.join(__dirname, 'send-move.ps1');
|
|
||||||
const PS = `powershell -NoProfile -ExecutionPolicy Bypass -File "${SCRIPT}"`;
|
|
||||||
|
|
||||||
function moveMouse(dx) {
|
|
||||||
execSync(`${PS} ${dx}`, { windowsHide: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('已启动防息屏,每 15 秒触发真实鼠标事件(SendInput)。Ctrl+C 停止。\n');
|
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
|
||||||
console.log('\n已停止。');
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
let tick = 0;
|
|
||||||
while (true) {
|
|
||||||
tick++;
|
|
||||||
moveMouse(1);
|
|
||||||
console.log(`【${tick}】→ 右移 1 像素 (SendInput)`);
|
|
||||||
await sleep(14000);
|
|
||||||
moveMouse(-1);
|
|
||||||
console.log(`【${tick}】← 移回原位 (SendInput)`);
|
|
||||||
await sleep(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error('出错:', err.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
185
merge_kaipiao.js
185
merge_kaipiao.js
@ -1,185 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 去掉指定列(按表头名精确匹配)单元格值中的所有冒号(含全角)
|
|
||||||
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 })
|
|
||||||
.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 n1 = stripColonInColumn(data, '结算账户');
|
|
||||||
if (n1) console.log(` 商旅"结算账户"列清理冒号: ${n1} 个单元格`);
|
|
||||||
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 n2 = stripColonInColumn(data, '*对账单/项目名称');
|
|
||||||
if (n2) console.log(` 吉智"*对账单/项目名称"列清理冒号: ${n2} 个单元格`);
|
|
||||||
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}`);
|
|
||||||
164
package-lock.json
generated
164
package-lock.json
generated
@ -1,164 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "geely-kaipiao",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "geely-kaipiao",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"playwright": "^1.61.0",
|
|
||||||
"xlsx": "^0.18.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/adler-32": {
|
|
||||||
"version": "1.3.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
|
|
||||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cfb": {
|
|
||||||
"version": "1.2.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz",
|
|
||||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"adler-32": "~1.3.0",
|
|
||||||
"crc-32": "~1.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/codepage": {
|
|
||||||
"version": "1.15.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
|
|
||||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/crc-32": {
|
|
||||||
"version": "1.2.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
|
|
||||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"crc32": "bin/crc32.njs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/frac": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fsevents": {
|
|
||||||
"version": "2.3.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
|
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/playwright": {
|
|
||||||
"version": "1.61.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.61.0.tgz",
|
|
||||||
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"playwright-core": "1.61.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"playwright": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "2.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/playwright-core": {
|
|
||||||
"version": "1.61.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.61.0.tgz",
|
|
||||||
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"playwright-core": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ssf": {
|
|
||||||
"version": "0.11.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
|
|
||||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"frac": "~1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/wmf": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/word": {
|
|
||||||
"version": "0.3.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz",
|
|
||||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/xlsx": {
|
|
||||||
"version": "0.18.5",
|
|
||||||
"resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz",
|
|
||||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"adler-32": "~1.3.0",
|
|
||||||
"cfb": "~1.2.1",
|
|
||||||
"codepage": "~1.15.0",
|
|
||||||
"crc-32": "~1.2.1",
|
|
||||||
"ssf": "~0.11.2",
|
|
||||||
"wmf": "~1.0.1",
|
|
||||||
"word": "~0.3.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"xlsx": "bin/xlsx.njs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
19
package.json
19
package.json
@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "geely-kaipiao",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "http://git.honor3.com/Cheney/geely-kaipiao.git"
|
|
||||||
},
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"description": "",
|
|
||||||
"dependencies": {
|
|
||||||
"playwright": "^1.61.0",
|
|
||||||
"xlsx": "^0.18.5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,17 +1,9 @@
|
|||||||
[proxy]
|
[proxy]
|
||||||
|
|
||||||
# 目标服务器配置
|
|
||||||
# 协议支持: http, https
|
|
||||||
# protocol = https
|
|
||||||
# target_host = zentao.sunyard.com.cn
|
|
||||||
# target_port = 9788
|
|
||||||
|
|
||||||
# 备用配置示例(取消注释即可使用)
|
|
||||||
protocol = https
|
|
||||||
target_host = superstar.geelytravel.com
|
target_host = superstar.geelytravel.com
|
||||||
target_port = 443
|
target_port = 443
|
||||||
|
target_protocol = https
|
||||||
# 另一个备用配置
|
; target_host = zentao.sunyard.com.cn
|
||||||
# protocol = http
|
; target_port = 9788
|
||||||
# target_host = 192.168.1.100
|
; target_protocol = http
|
||||||
# target_port = 8080
|
b_host = localhost
|
||||||
|
b_port = 8080
|
||||||
@ -1,132 +1,449 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import sys
|
import sys
|
||||||
import configparser
|
import configparser
|
||||||
import os
|
import os
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
# Configuration from config.ini
|
||||||
|
CONFIG_FILE = os.path.join(os.path.dirname(__file__), 'config.ini')
|
||||||
|
|
||||||
def load_config(config_path='config.ini'):
|
def load_config():
|
||||||
"""加载配置文件"""
|
|
||||||
config = configparser.ConfigParser()
|
config = configparser.ConfigParser()
|
||||||
|
config.read(CONFIG_FILE)
|
||||||
|
|
||||||
# 如果配置文件不存在,创建默认配置
|
target_host = config.get('proxy', 'target_host', fallback='zentao.sunyard.com.cn')
|
||||||
if not os.path.exists(config_path):
|
target_port = config.getint('proxy', 'target_port', fallback=9788)
|
||||||
print(f"配置文件 {config_path} 不存在,使用默认配置")
|
target_protocol = config.get('proxy', 'target_protocol', fallback='http').lower()
|
||||||
return {
|
b_host = config.get('proxy', 'b_host', fallback='localhost')
|
||||||
'protocol': 'https',
|
b_port = config.getint('proxy', 'b_port', fallback=8080)
|
||||||
'target_host': 'zentao.sunyard.com.cn',
|
|
||||||
'target_port': 9788
|
|
||||||
}
|
|
||||||
|
|
||||||
config.read(config_path, encoding='utf-8')
|
return target_host, target_port, target_protocol, b_host, b_port
|
||||||
|
|
||||||
if 'proxy' not in config:
|
TARGET_HOST, TARGET_PORT, TARGET_PROTOCOL, B_HOST, B_PORT = load_config()
|
||||||
print(f"配置文件格式错误,使用默认配置")
|
|
||||||
return {
|
|
||||||
'protocol': 'https',
|
|
||||||
'target_host': 'zentao.sunyard.com.cn',
|
|
||||||
'target_port': 9788
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol = config['proxy'].get('protocol', 'https').lower()
|
# Global lock for socket write operations
|
||||||
target_host = config['proxy'].get('target_host', 'zentao.sunyard.com.cn')
|
socket_write_lock = threading.Lock()
|
||||||
target_port = config['proxy'].getint('target_port', 9788)
|
|
||||||
|
|
||||||
# 如果没有设置端口,根据协议设置默认端口
|
def get_thread_id():
|
||||||
if target_port == 0:
|
"""Get thread identifier for logging"""
|
||||||
target_port = 443 if protocol == 'https' else 80
|
return threading.current_thread().getName()
|
||||||
|
|
||||||
return {
|
def log_info(source, message):
|
||||||
'protocol': protocol,
|
"""Log info message with thread ID"""
|
||||||
'target_host': target_host,
|
thread_id = get_thread_id()
|
||||||
'target_port': target_port
|
log_line = "[%s] [INFO] [%s] [Thread:%s] %s" % (
|
||||||
}
|
time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
source,
|
||||||
|
thread_id,
|
||||||
|
message
|
||||||
|
)
|
||||||
|
print(log_line)
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
def handle_request(sock, target_host, target_port):
|
def log_error(source, message):
|
||||||
"""处理从A设备收到的请求,转发到目标网站"""
|
"""Log error message with thread ID"""
|
||||||
|
thread_id = get_thread_id()
|
||||||
|
log_line = "[%s] [ERROR] [%s] [Thread:%s] %s" % (
|
||||||
|
time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
source,
|
||||||
|
thread_id,
|
||||||
|
message
|
||||||
|
)
|
||||||
|
print(log_line)
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
def extract_request_id(request_data):
|
||||||
|
"""Extract X-Proxy-Request-ID from request headers"""
|
||||||
|
if "\r\n\r\n" in request_data:
|
||||||
|
headers_end = request_data.find("\r\n\r\n")
|
||||||
|
headers = request_data[:headers_end]
|
||||||
|
for line in headers.split("\r\n"):
|
||||||
|
if line.lower().startswith("x-proxy-request-id:"):
|
||||||
|
return line.split(":", 1)[1].strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def modify_request_headers(request_data):
|
||||||
|
"""Modify request headers: replace Origin, Referer, Host; remove Connection headers"""
|
||||||
|
lines = request_data.split("\r\n")
|
||||||
|
new_lines = []
|
||||||
|
method = ""
|
||||||
|
path = ""
|
||||||
|
|
||||||
|
if lines:
|
||||||
|
first_line = lines[0]
|
||||||
|
parts = first_line.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
method = parts[0]
|
||||||
|
path = parts[1]
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line_str = line
|
||||||
|
|
||||||
|
if line_str.startswith("Origin:"):
|
||||||
|
new_lines.append("Origin: %s://%s:%d" % (TARGET_PROTOCOL, TARGET_HOST, TARGET_PORT))
|
||||||
|
elif line_str.startswith("Referer:"):
|
||||||
|
new_lines.append("Referer: %s://%s:%d/" % (TARGET_PROTOCOL, TARGET_HOST, TARGET_PORT))
|
||||||
|
elif line_str.startswith("Host:"):
|
||||||
|
new_lines.append("Host: %s:%d" % (TARGET_HOST, TARGET_PORT))
|
||||||
|
elif line_str.lower().startswith("connection:"):
|
||||||
|
continue
|
||||||
|
elif line_str.lower().startswith("keep-alive:"):
|
||||||
|
continue
|
||||||
|
elif line_str.lower().startswith("proxy-connection:"):
|
||||||
|
continue
|
||||||
|
elif line_str.lower().startswith("x-forwarded-for:"):
|
||||||
|
continue
|
||||||
|
elif line_str.lower().startswith("x-proxy-request-id:"):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
new_lines.append(line_str)
|
||||||
|
|
||||||
|
modified_data = "\r\n".join(new_lines)
|
||||||
|
|
||||||
|
if path.startswith("/"):
|
||||||
|
new_path = "%s://%s:%d%s" % (TARGET_PROTOCOL, TARGET_HOST, TARGET_PORT, path)
|
||||||
|
if method and modified_data:
|
||||||
|
modified_data = modified_data.replace(path, new_path, 1)
|
||||||
|
|
||||||
|
return modified_data, method, path
|
||||||
|
|
||||||
|
def send_request_to_target(host, port, protocol, request_data, request_id):
|
||||||
|
"""Send request to target server and return response"""
|
||||||
|
target_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
target_sock.settimeout(30)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 接收来自A设备的请求
|
target_sock.connect((host, port))
|
||||||
request = b""
|
log_info("System", "Connected to target: %s:%d" % (host, port))
|
||||||
while True:
|
|
||||||
data = sock.recv(4096)
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
request += data
|
|
||||||
if b"\r\n\r\n" in request:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not request:
|
if protocol == 'https':
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
target_sock = context.wrap_socket(target_sock, server_hostname=host)
|
||||||
|
log_info("System", "SSL connection established with target")
|
||||||
|
|
||||||
|
if isinstance(request_data, str):
|
||||||
|
request_data = request_data.encode('utf-8')
|
||||||
|
target_sock.sendall(request_data)
|
||||||
|
|
||||||
|
response = b""
|
||||||
|
content_length = 0
|
||||||
|
is_chunked = False
|
||||||
|
|
||||||
|
while True:
|
||||||
|
chunk = target_sock.recv(8192)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
response += chunk
|
||||||
|
|
||||||
|
if b"\r\n\r\n" in response and content_length == 0 and not is_chunked:
|
||||||
|
headers_end = response.find(b"\r\n\r\n")
|
||||||
|
headers = response[:headers_end].decode('utf-8', errors='replace')
|
||||||
|
|
||||||
|
for line in headers.split("\r\n"):
|
||||||
|
if line.lower().startswith("content-length:"):
|
||||||
|
content_length = int(line.split(":", 1)[1].strip())
|
||||||
|
elif line.lower().startswith("transfer-encoding:"):
|
||||||
|
if "chunked" in line.lower():
|
||||||
|
is_chunked = True
|
||||||
|
|
||||||
|
if content_length > 0:
|
||||||
|
body_start = headers_end + 4
|
||||||
|
if len(response) - body_start >= content_length:
|
||||||
|
break
|
||||||
|
elif is_chunked:
|
||||||
|
if response.endswith(b"0\r\n\r\n"):
|
||||||
|
break
|
||||||
|
|
||||||
|
if request_id:
|
||||||
|
if b"\r\n\r\n" in response:
|
||||||
|
headers_end = response.find(b"\r\n\r\n")
|
||||||
|
headers = response[:headers_end]
|
||||||
|
body = response[headers_end:]
|
||||||
|
response = headers + ("\r\nX-Proxy-Request-ID: " + request_id).encode('utf-8') + body
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
finally:
|
||||||
|
target_sock.close()
|
||||||
|
|
||||||
|
def parse_location(location):
|
||||||
|
"""Parse Location header to get protocol, host, port, and path"""
|
||||||
|
protocol = TARGET_PROTOCOL
|
||||||
|
host = TARGET_HOST
|
||||||
|
port = TARGET_PORT
|
||||||
|
path = "/"
|
||||||
|
|
||||||
|
if location.startswith("http://") or location.startswith("https://"):
|
||||||
|
# Full URL
|
||||||
|
protocol_end = location.find("://")
|
||||||
|
protocol = location[:protocol_end]
|
||||||
|
remaining = location[protocol_end + 3:]
|
||||||
|
path_start = remaining.find("/")
|
||||||
|
if path_start == -1:
|
||||||
|
host_part = remaining
|
||||||
|
path = "/"
|
||||||
|
else:
|
||||||
|
host_part = remaining[:path_start]
|
||||||
|
path = remaining[path_start:]
|
||||||
|
|
||||||
|
port_pos = host_part.find(":")
|
||||||
|
if port_pos != -1:
|
||||||
|
host = host_part[:port_pos]
|
||||||
|
port = int(host_part[port_pos + 1:])
|
||||||
|
else:
|
||||||
|
host = host_part
|
||||||
|
port = 443 if protocol == 'https' else 80
|
||||||
|
elif location.startswith("/"):
|
||||||
|
# Absolute path
|
||||||
|
path = location
|
||||||
|
else:
|
||||||
|
# Relative path
|
||||||
|
path = "/" + location
|
||||||
|
|
||||||
|
return protocol, host, port, path
|
||||||
|
|
||||||
|
def modify_redirect_request(original_request, new_protocol, new_host, new_port, new_path):
|
||||||
|
"""Modify request for redirect"""
|
||||||
|
lines = original_request.split("\r\n")
|
||||||
|
new_lines = []
|
||||||
|
|
||||||
|
first_line = lines[0]
|
||||||
|
parts = first_line.split()
|
||||||
|
if len(parts) >= 3:
|
||||||
|
method = parts[0]
|
||||||
|
http_version = parts[2]
|
||||||
|
new_first_line = "%s %s %s" % (method, new_path, http_version)
|
||||||
|
new_lines.append(new_first_line)
|
||||||
|
|
||||||
|
for line in lines[1:]:
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if line.lower().startswith("host:"):
|
||||||
|
new_lines.append("Host: %s:%d" % (new_host, new_port))
|
||||||
|
elif line.lower().startswith("origin:"):
|
||||||
|
new_lines.append("Origin: %s://%s:%d" % (new_protocol, new_host, new_port))
|
||||||
|
elif line.lower().startswith("referer:"):
|
||||||
|
new_lines.append("Referer: %s://%s:%d/" % (new_protocol, new_host, new_port))
|
||||||
|
elif line.lower().startswith("connection:"):
|
||||||
|
continue
|
||||||
|
elif line.lower().startswith("keep-alive:"):
|
||||||
|
continue
|
||||||
|
elif line.lower().startswith("proxy-connection:"):
|
||||||
|
continue
|
||||||
|
elif line.lower().startswith("x-forwarded-for:"):
|
||||||
|
continue
|
||||||
|
elif line.lower().startswith("x-proxy-request-id:"):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
new_lines.append(line)
|
||||||
|
|
||||||
|
new_lines.append("")
|
||||||
|
new_lines.append("")
|
||||||
|
|
||||||
|
if "\r\n\r\n" in original_request:
|
||||||
|
body_start = original_request.find("\r\n\r\n") + 4
|
||||||
|
body = original_request[body_start:]
|
||||||
|
modified_data = "\r\n".join(new_lines[:-2]) + "\r\n\r\n" + body
|
||||||
|
else:
|
||||||
|
modified_data = "\r\n".join(new_lines[:-2]) + "\r\n\r\n"
|
||||||
|
|
||||||
|
return modified_data
|
||||||
|
|
||||||
|
def handle_request(request_data):
|
||||||
|
"""Handle a single request from device B - completely independent thread"""
|
||||||
|
if isinstance(request_data, bytes):
|
||||||
|
request_data_str = request_data.decode('utf-8', errors='replace')
|
||||||
|
else:
|
||||||
|
request_data_str = request_data
|
||||||
|
|
||||||
|
request_id = extract_request_id(request_data_str)
|
||||||
|
|
||||||
|
modified_data, method, path = modify_request_headers(request_data_str)
|
||||||
|
|
||||||
|
body_size = 0
|
||||||
|
if "\r\n\r\n" in modified_data:
|
||||||
|
body_size = len(modified_data) - modified_data.find("\r\n\r\n") - 4
|
||||||
|
|
||||||
|
log_info("System", "Request: %s %s://%s:%d%s - Body: %d bytes - ID: %s" % (method, TARGET_PROTOCOL, TARGET_HOST, TARGET_PORT, path, body_size, request_id))
|
||||||
|
|
||||||
|
current_protocol = TARGET_PROTOCOL
|
||||||
|
current_host = TARGET_HOST
|
||||||
|
current_port = TARGET_PORT
|
||||||
|
current_request = modified_data
|
||||||
|
redirect_count = 0
|
||||||
|
max_redirects = 5
|
||||||
|
|
||||||
|
try:
|
||||||
|
while redirect_count <= max_redirects:
|
||||||
|
response = send_request_to_target(current_host, current_port, current_protocol, current_request, request_id)
|
||||||
|
|
||||||
|
status_code = "500"
|
||||||
|
location = None
|
||||||
|
|
||||||
|
if b"\r\n\r\n" in response:
|
||||||
|
headers_end = response.find(b"\r\n\r\n")
|
||||||
|
headers = response[:headers_end].decode('utf-8', errors='replace')
|
||||||
|
for line in headers.split("\r\n"):
|
||||||
|
if line.startswith("HTTP/"):
|
||||||
|
status_code = line.split()[1]
|
||||||
|
elif line.lower().startswith("location:"):
|
||||||
|
location = line.split(":", 1)[1].strip()
|
||||||
|
|
||||||
|
log_info("System", "Response: %s %s - Status: %s - ID: %s" % (method, path, status_code, request_id))
|
||||||
|
|
||||||
|
if status_code not in ("301", "302") or not location:
|
||||||
|
return response
|
||||||
|
|
||||||
|
redirect_count += 1
|
||||||
|
log_info("System", "Redirect %d: %s" % (redirect_count, location))
|
||||||
|
|
||||||
|
new_protocol, new_host, new_port, new_path = parse_location(location)
|
||||||
|
current_protocol = new_protocol
|
||||||
|
current_host = new_host
|
||||||
|
current_port = new_port
|
||||||
|
current_request = modify_redirect_request(request_data_str, new_protocol, new_host, new_port, new_path)
|
||||||
|
path = new_path
|
||||||
|
|
||||||
|
log_error("System", "Too many redirects (%d)" % max_redirects)
|
||||||
|
error_response = ("HTTP/1.1 508 Loop Detected\r\nX-Proxy-Request-ID: " + (request_id if request_id else "") + "\r\nContent-Type: text/plain\r\n\r\nToo many redirects\r\n").encode('utf-8')
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
except socket.error as e:
|
||||||
|
if e.errno == 111 or e.errno == 10061:
|
||||||
|
log_error("System", "Cannot connect to target: %s:%d" % (current_host, current_port))
|
||||||
|
error_response = ("HTTP/1.1 502 Bad Gateway\r\nX-Proxy-Request-ID: " + (request_id if request_id else "") + "\r\nContent-Type: text/plain\r\n\r\nCannot connect to target: %s:%d\r\n" % (current_host, current_port)).encode('utf-8')
|
||||||
|
return error_response
|
||||||
|
else:
|
||||||
|
log_error("System", "Socket error: %s" % str(e))
|
||||||
|
error_response = ("HTTP/1.1 502 Bad Gateway\r\nX-Proxy-Request-ID: " + (request_id if request_id else "") + "\r\nContent-Type: text/plain\r\n\r\nSocket error\r\n").encode('utf-8')
|
||||||
|
return error_response
|
||||||
|
except socket.timeout:
|
||||||
|
log_error("System", "Target connection timeout")
|
||||||
|
error_response = ("HTTP/1.1 504 Gateway Timeout\r\nX-Proxy-Request-ID: " + (request_id if request_id else "") + "\r\nContent-Type: text/plain\r\n\r\nConnection timeout\r\n").encode('utf-8')
|
||||||
|
return error_response
|
||||||
|
except Exception as e:
|
||||||
|
log_error("System", "Error handling request: %s" % str(e))
|
||||||
|
error_response = ("HTTP/1.1 500 Internal Server Error\r\nX-Proxy-Request-ID: " + (request_id if request_id else "") + "\r\nContent-Type: text/plain\r\n\r\nInternal error\r\n").encode('utf-8')
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
def process_request(b_socket, request_data):
|
||||||
|
"""Process a request in separate thread and send response back"""
|
||||||
|
response = handle_request(request_data)
|
||||||
|
|
||||||
|
with socket_write_lock:
|
||||||
|
try:
|
||||||
|
log_info("System", "Sending response back to B, length: %d bytes" % len(response))
|
||||||
|
b_socket.sendall(response)
|
||||||
|
log_info("System", "Response sent successfully")
|
||||||
|
except Exception as e:
|
||||||
|
log_error("System", "Failed to send response: %s" % str(e))
|
||||||
|
|
||||||
|
def listen_for_requests(sock):
|
||||||
|
"""Listen for requests from device B"""
|
||||||
|
sock.settimeout(300)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data = b""
|
||||||
|
while True:
|
||||||
|
chunk = sock.recv(8192)
|
||||||
|
if not chunk:
|
||||||
|
log_info("System", "Connection closed by device B")
|
||||||
|
return
|
||||||
|
data += chunk
|
||||||
|
if b"\r\n\r\n" in data:
|
||||||
|
headers_end = data.find(b"\r\n\r\n")
|
||||||
|
headers = data[:headers_end].decode('utf-8', errors='replace')
|
||||||
|
|
||||||
|
content_length = 0
|
||||||
|
for line in headers.split("\r\n"):
|
||||||
|
if line.lower().startswith("content-length:"):
|
||||||
|
content_length = int(line.split(":", 1)[1].strip())
|
||||||
|
break
|
||||||
|
|
||||||
|
body_start = headers_end + 4
|
||||||
|
if len(data) - body_start >= content_length:
|
||||||
|
break
|
||||||
|
|
||||||
|
t = threading.Thread(target=process_request, args=(sock, data), name="ReqHandler-%d" % threading.activeCount())
|
||||||
|
t.daemon = True
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
except socket.timeout:
|
||||||
|
log_info("System", "Connection idle timeout, continuing to wait")
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
log_error("System", "Connection error: %s" % str(e))
|
||||||
return
|
return
|
||||||
|
|
||||||
# 连接到目标网站
|
def connect_to_b_device(b_host, b_port):
|
||||||
target_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
"""Connect to device B and handle requests"""
|
||||||
target_sock.connect((target_host, target_port))
|
failure_count = 0
|
||||||
|
max_failures = 3
|
||||||
|
|
||||||
# 发送请求
|
|
||||||
target_sock.send(request)
|
|
||||||
|
|
||||||
# 接收响应并转发回A设备
|
|
||||||
while True:
|
|
||||||
response = target_sock.recv(4096)
|
|
||||||
if not response:
|
|
||||||
break
|
|
||||||
sock.send(response)
|
|
||||||
|
|
||||||
target_sock.close()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"处理请求出错: {e}")
|
|
||||||
try:
|
|
||||||
sock.send(b"HTTP/1.1 500 Internal Server Error\r\n\r\n")
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def connect_to_a_device(a_host, a_port, target_host, target_port):
|
|
||||||
"""连接到A设备"""
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
sock.connect((a_host, a_port))
|
sock.settimeout(10)
|
||||||
print(f"成功连接到A设备: {a_host}:{a_port}")
|
log_info("System", "Connecting to device B: %s:%d" % (b_host, b_port))
|
||||||
print(f"准备转发请求到: {target_host}:{target_port}")
|
sock.connect((b_host, b_port))
|
||||||
|
|
||||||
handle_request(sock, target_host, target_port)
|
log_info("System", "Connected to device B, waiting for PROXY_CONNECTED")
|
||||||
|
response = sock.recv(1024)
|
||||||
|
log_info("System", "Received from B: %r" % response)
|
||||||
|
|
||||||
sock.close()
|
if not response or b"PROXY_CONNECTED" not in response:
|
||||||
print("与A设备的连接已断开")
|
log_error("System", "Failed to receive connection confirmation")
|
||||||
except ConnectionRefusedError:
|
sock.close()
|
||||||
print(f"A设备未就绪,重试中...")
|
failure_count += 1
|
||||||
import time
|
if failure_count >= max_failures:
|
||||||
|
log_error("System", "Maximum connection attempts reached (%d), exiting..." % max_failures)
|
||||||
|
sys.exit(1)
|
||||||
|
time.sleep(3)
|
||||||
|
continue
|
||||||
|
|
||||||
|
log_info("System", "Sending READY to device B")
|
||||||
|
sock.sendall(b"READY\r\n")
|
||||||
|
log_info("System", "Successfully connected to device B: %s:%d" % (b_host, b_port))
|
||||||
|
|
||||||
|
failure_count = 0
|
||||||
|
listen_for_requests(sock)
|
||||||
|
|
||||||
|
except socket.error as e:
|
||||||
|
if e.errno == 111 or e.errno == 10061:
|
||||||
|
log_info("System", "Device B not ready (%s:%d), retrying in 3 seconds..." % (b_host, b_port))
|
||||||
|
else:
|
||||||
|
log_error("System", "Connection error: %s" % str(e))
|
||||||
|
failure_count += 1
|
||||||
|
if failure_count >= max_failures:
|
||||||
|
log_error("System", "Maximum connection attempts reached (%d), exiting..." % max_failures)
|
||||||
|
sys.exit(1)
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"连接出错: {e}")
|
log_error("System", "Unexpected error: %s" % str(e))
|
||||||
import time
|
failure_count += 1
|
||||||
|
if failure_count >= max_failures:
|
||||||
|
log_error("System", "Maximum connection attempts reached (%d), exiting..." % max_failures)
|
||||||
|
sys.exit(1)
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print(" Proxy Client (Run on Computer A)")
|
||||||
|
print("=" * 60)
|
||||||
|
print("Target website:", TARGET_PROTOCOL, "://", TARGET_HOST, ":", TARGET_PORT)
|
||||||
|
print("Device B address:", B_HOST, ":", B_PORT)
|
||||||
|
print("=" * 60)
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
# 加载配置
|
try:
|
||||||
config = load_config()
|
connect_to_b_device(B_HOST, B_PORT)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nShutting down...")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
# 解析命令行参数
|
|
||||||
if len(sys.argv) < 3:
|
|
||||||
print("用法: python proxy_client.py <A设备IP> <A设备端口>")
|
|
||||||
print(f"当前配置: {config['protocol']}://{config['target_host']}:{config['target_port']}")
|
|
||||||
print("示例: python proxy_client.py 192.168.1.50 8080")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
a_host = sys.argv[1]
|
|
||||||
a_port = int(sys.argv[2])
|
|
||||||
|
|
||||||
# 可选:通过命令行覆盖目标配置
|
|
||||||
if len(sys.argv) >= 4:
|
|
||||||
config['target_host'] = sys.argv[3]
|
|
||||||
if len(sys.argv) >= 5:
|
|
||||||
config['target_port'] = int(sys.argv[4])
|
|
||||||
|
|
||||||
print(f"使用配置: {config['protocol']}://{config['target_host']}:{config['target_port']}")
|
|
||||||
connect_to_a_device(a_host, a_port, config['target_host'], config['target_port'])
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
1
proxynt/.gitignore
vendored
1
proxynt/.gitignore
vendored
@ -1 +0,0 @@
|
|||||||
log/
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
pip install -U proxynt
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
nt_server -c config_s.json
|
|
||||||
nt_client -c config_c.json
|
|
||||||
|
|
||||||
|
|
||||||
http://<A电脑的IP>:18888/websocket_path/admin
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"server": {
|
|
||||||
"url": "ws://localhost:18888/websocket_path",
|
|
||||||
"password": "helloworld"
|
|
||||||
},
|
|
||||||
"client_name": "fyx"
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"server": {
|
|
||||||
"url": "ws://bh.vps.honor3.com:18888/websocket_path",
|
|
||||||
"password": "helloworld"
|
|
||||||
},
|
|
||||||
"client_name": "cheney"
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"port": 18888,
|
|
||||||
"password": "helloworld",
|
|
||||||
"path": "/websocket_path",
|
|
||||||
"admin": {
|
|
||||||
"enable": true,
|
|
||||||
"admin_password": "helloworld"
|
|
||||||
},
|
|
||||||
"client_config": {
|
|
||||||
"fyx": [
|
|
||||||
{
|
|
||||||
"name": "superstar",
|
|
||||||
"remote_port": 4443,
|
|
||||||
"local_port": 443,
|
|
||||||
"local_ip": "198.19.0.115",
|
|
||||||
"speed_limit": 0.0,
|
|
||||||
"protocol": "tcp"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"server": {
|
|
||||||
"url": "ws://121.43.151.143:18888/websocket_path",
|
|
||||||
"password": "helloworld"
|
|
||||||
},
|
|
||||||
"client_name": "fyx"
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
const XLSX = require('xlsx');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// 读取 Excel 文件(项目根目录)
|
|
||||||
const filePath = path.join(__dirname, '新建 XLSX 工作表.xlsx');
|
|
||||||
const workbook = XLSX.readFile(filePath);
|
|
||||||
|
|
||||||
// 目标 sheet
|
|
||||||
const sheetName = '2026';
|
|
||||||
const sheet = workbook.Sheets[sheetName];
|
|
||||||
|
|
||||||
if (!sheet) {
|
|
||||||
console.error(`Sheet "${sheetName}" 不存在`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将 sheet 转为 JSON 数组(以第一行为表头)
|
|
||||||
const rows = XLSX.utils.sheet_to_json(sheet, { defval: null });
|
|
||||||
|
|
||||||
// 查找"团号"列(兼容表头可能的空格差异)
|
|
||||||
const headerRow = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null })[0];
|
|
||||||
const tuanHaoIndex = headerRow.findIndex(h => h && String(h).trim() === '团号');
|
|
||||||
|
|
||||||
if (tuanHaoIndex === -1) {
|
|
||||||
console.error('未找到"团号"列');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取所有 TEAM-xxxx 格式的 ID
|
|
||||||
const teamIds = [];
|
|
||||||
for (const row of rows) {
|
|
||||||
const val = row['团号'];
|
|
||||||
if (val && /^TEAM-\d+$/.test(String(val).trim())) {
|
|
||||||
teamIds.push(String(val).trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`找到 ${teamIds.length} 个 TEAM ID:`);
|
|
||||||
teamIds.forEach(id => console.log(id));
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
param([int]$dx = 1)
|
|
||||||
|
|
||||||
Add-Type -TypeDefinition @"
|
|
||||||
using System;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
public class MouseInput {
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
public struct MOUSEINPUT {
|
|
||||||
public int dx;
|
|
||||||
public int dy;
|
|
||||||
public uint mouseData;
|
|
||||||
public uint dwFlags;
|
|
||||||
public uint time;
|
|
||||||
public IntPtr dwExtraInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
public struct INPUT {
|
|
||||||
public uint type;
|
|
||||||
public MOUSEINPUT mi;
|
|
||||||
}
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
|
|
||||||
|
|
||||||
public const uint INPUT_MOUSE = 0;
|
|
||||||
public const uint MOUSEEVENTF_MOVE = 0x0001;
|
|
||||||
}
|
|
||||||
"@
|
|
||||||
|
|
||||||
$input = New-Object MouseInput+INPUT
|
|
||||||
$input.type = [MouseInput]::INPUT_MOUSE
|
|
||||||
$input.mi.dx = $dx
|
|
||||||
$input.mi.dy = 0
|
|
||||||
$input.mi.dwFlags = [MouseInput]::MOUSEEVENTF_MOVE
|
|
||||||
$input.mi.time = 0
|
|
||||||
$input.mi.dwExtraInfo = [IntPtr]::Zero
|
|
||||||
|
|
||||||
$size = [Runtime.InteropServices.Marshal]::SizeOf($input)
|
|
||||||
[MouseInput]::SendInput(1, @($input), $size) | Out-Null
|
|
||||||
65
src/login.js
65
src/login.js
@ -1,65 +0,0 @@
|
|||||||
const { chromium } = require('playwright');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const LOGIN_URL = 'https://superstar.geelytravel.com/new-tmc/login';
|
|
||||||
const STATE_PATH = process.env.STATE_PATH || path.resolve(__dirname, 'state.json');
|
|
||||||
const USER_AGENT =
|
|
||||||
process.env.USER_AGENT ||
|
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
// 1. 启动本地 Chrome 浏览器,并添加排除自动化检测的参数
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
headless: false,
|
|
||||||
channel: 'chrome',
|
|
||||||
args: [
|
|
||||||
'--disable-blink-features=AutomationControlled',
|
|
||||||
'--start-maximized',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. 设置上下文,伪造 User-Agent
|
|
||||||
const context = await browser.newContext({
|
|
||||||
noViewport: true,
|
|
||||||
userAgent: USER_AGENT,
|
|
||||||
});
|
|
||||||
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 3. 访问登录页
|
|
||||||
await page.goto(LOGIN_URL);
|
|
||||||
|
|
||||||
// 输入信息:如需自动填写,可通过环境变量 USERNAME / PASSWORD 传入
|
|
||||||
if (process.env.USERNAME) {
|
|
||||||
await page.getByPlaceholder('用户名').fill(process.env.USERNAME);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.PASSWORD) {
|
|
||||||
await page.getByPlaceholder('密码').fill(process.env.PASSWORD);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 进入暂停状态,此时手动滑动验证码并完成登录
|
|
||||||
console.log('请在浏览器中手动完成滑块验证,登录进入系统首页后回到终端按 Enter...');
|
|
||||||
await page.pause();
|
|
||||||
|
|
||||||
// 5. 关键:等待页面跳转完成,确保已进入登录后的状态
|
|
||||||
// 如需按业务页面确认登录成功,可打开下面一行并按实际 URL 片段调整。
|
|
||||||
// await page.waitForURL('**/report/**', { timeout: 60000 });
|
|
||||||
|
|
||||||
// 额外等待 3 秒,确保 Cookie 和 LocalStorage 完整写入内存
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
// 6. 保存状态
|
|
||||||
await context.storageState({ path: STATE_PATH });
|
|
||||||
console.log(`登录状态已保存至:${STATE_PATH}`);
|
|
||||||
} finally {
|
|
||||||
await context.close();
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
process.exitCode = 1;
|
|
||||||
});
|
|
||||||
270
src/state.json
270
src/state.json
File diff suppressed because one or more lines are too long
@ -1,52 +0,0 @@
|
|||||||
Add-Type -TypeDefinition @"
|
|
||||||
using System;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
public class MouseInput {
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
public struct MOUSEINPUT {
|
|
||||||
public int dx;
|
|
||||||
public int dy;
|
|
||||||
public uint mouseData;
|
|
||||||
public uint dwFlags;
|
|
||||||
public uint time;
|
|
||||||
public IntPtr dwExtraInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
public struct INPUT {
|
|
||||||
public uint type;
|
|
||||||
public MOUSEINPUT mi;
|
|
||||||
}
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
|
|
||||||
|
|
||||||
public const uint INPUT_MOUSE = 0;
|
|
||||||
public const uint MOUSEEVENTF_MOVE = 0x0001;
|
|
||||||
}
|
|
||||||
"@
|
|
||||||
|
|
||||||
Add-Type -AssemblyName System.Windows.Forms
|
|
||||||
|
|
||||||
$pos = [System.Windows.Forms.Cursor]::Position
|
|
||||||
Write-Host "当前位置: X=$($pos.X), Y=$($pos.Y)"
|
|
||||||
|
|
||||||
$input = New-Object MouseInput+INPUT
|
|
||||||
$input.type = [MouseInput]::INPUT_MOUSE
|
|
||||||
$input.mi.dx = 1
|
|
||||||
$input.mi.dy = 0
|
|
||||||
$input.mi.dwFlags = [MouseInput]::MOUSEEVENTF_MOVE
|
|
||||||
$input.mi.time = 0
|
|
||||||
$input.mi.dwExtraInfo = [IntPtr]::Zero
|
|
||||||
|
|
||||||
$size = [Runtime.InteropServices.Marshal]::SizeOf($input)
|
|
||||||
$result = [MouseInput]::SendInput(1, @($input), $size)
|
|
||||||
Write-Host "SendInput 返回: $result (右移1像素)"
|
|
||||||
|
|
||||||
Start-Sleep -Seconds 2
|
|
||||||
|
|
||||||
$input.mi.dx = -1
|
|
||||||
$result = [MouseInput]::SendInput(1, @($input), $size)
|
|
||||||
Write-Host "SendInput 返回: $result (移回原位)"
|
|
||||||
Write-Host "测试完成"
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
Add-Type -TypeDefinition @"
|
|
||||||
using System;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
public class MI {
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
public struct MOUSEINPUT {
|
|
||||||
public int dx;
|
|
||||||
public int dy;
|
|
||||||
public uint mouseData;
|
|
||||||
public uint dwFlags;
|
|
||||||
public uint time;
|
|
||||||
public IntPtr dwExtraInfo;
|
|
||||||
}
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
public struct INPUT {
|
|
||||||
public uint type;
|
|
||||||
public MOUSEINPUT mi;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"@
|
|
||||||
|
|
||||||
$type = [MI+INPUT]
|
|
||||||
Write-Host "Type: $($type.FullName)"
|
|
||||||
Write-Host "IsValueType: $($type.IsValueType)"
|
|
||||||
|
|
||||||
try {
|
|
||||||
$s = [Runtime.InteropServices.Marshal]::SizeOf($type)
|
|
||||||
Write-Host "SizeOf(type): $s"
|
|
||||||
} catch {
|
|
||||||
Write-Host "SizeOf(type) failed: $($_.Exception.Message)"
|
|
||||||
}
|
|
||||||
|
|
||||||
$inst = [Activator]::CreateInstance($type)
|
|
||||||
try {
|
|
||||||
$s2 = [Runtime.InteropServices.Marshal]::SizeOf($inst)
|
|
||||||
Write-Host "SizeOf(inst): $s2"
|
|
||||||
} catch {
|
|
||||||
Write-Host "SizeOf(inst) failed: $($_.Exception.Message)"
|
|
||||||
}
|
|
||||||
Binary file not shown.
BIN
新建 XLSX 工作表.xlsx
BIN
新建 XLSX 工作表.xlsx
Binary file not shown.
Binary file not shown.
Binary file not shown.
72
脚本/unlock.js
72
脚本/unlock.js
@ -1,72 +0,0 @@
|
|||||||
const { exec } = require('child_process');
|
|
||||||
|
|
||||||
const CHECK_INTERVAL = 30 * 1000; // 每 30 秒检查一次
|
|
||||||
const IDLE_LIMIT = 60 * 1000; // 空闲超过 60 秒则触发
|
|
||||||
|
|
||||||
// 封装执行 PowerShell 的方法(使用 UTF-16LE 编码避免引号转义问题)
|
|
||||||
function runPs(script) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
|
||||||
exec(`powershell -NoProfile -EncodedCommand "${encoded}"`, (err, stdout) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(stdout.trim());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取当前系统空闲毫秒数
|
|
||||||
async function getIdleTime() {
|
|
||||||
const ps = `
|
|
||||||
Add-Type @"
|
|
||||||
using System;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
public class IdleChecker {
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
public static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);
|
|
||||||
public struct LASTINPUTINFO {
|
|
||||||
public uint cbSize;
|
|
||||||
public uint dwTime;
|
|
||||||
}
|
|
||||||
public static uint GetIdleMilliseconds() {
|
|
||||||
LASTINPUTINFO lii = new LASTINPUTINFO();
|
|
||||||
lii.cbSize = (uint)Marshal.SizeOf(typeof(LASTINPUTINFO));
|
|
||||||
GetLastInputInfo(ref lii);
|
|
||||||
return (uint)Environment.TickCount - lii.dwTime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"@
|
|
||||||
[IdleChecker]::GetIdleMilliseconds()
|
|
||||||
`;
|
|
||||||
const result = await runPs(ps);
|
|
||||||
return parseInt(result, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模拟按下 F15 键(无任何副作用)
|
|
||||||
async function pressF15() {
|
|
||||||
const ps = `
|
|
||||||
$wshell = New-Object -ComObject wscript.shell;
|
|
||||||
$wshell.SendKeys('{F15}');
|
|
||||||
`;
|
|
||||||
await runPs(ps);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ 防锁屏脚本已启动 (PowerShell 模式)');
|
|
||||||
console.log(`⏱️ 检测到空闲 ${IDLE_LIMIT/1000} 秒后,自动模拟按键防止锁屏。`);
|
|
||||||
|
|
||||||
setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const idleMs = await getIdleTime();
|
|
||||||
if (idleMs > IDLE_LIMIT) {
|
|
||||||
console.log(`🔄 已空闲 ${Math.round(idleMs/1000)} 秒,模拟 F15 重置计时器...`);
|
|
||||||
await pressF15();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('⚠️ 执行出错:', e.message);
|
|
||||||
}
|
|
||||||
}, CHECK_INTERVAL);
|
|
||||||
|
|
||||||
// 按 Ctrl+C 退出时提示
|
|
||||||
process.on('SIGINT', () => {
|
|
||||||
console.log('\n🛑 脚本已停止');
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
Loading…
Reference in New Issue
Block a user