Compare commits

..
33 Commits
Author SHA1 Message Date
cheney 75f15aeabb 火车票版本增加try试运行参数 2026-10-21 20:45:08 +08:00
cheney 801920fcde 新增只处理火车票版本 2026-10-21 20:23:11 +08:00
cheney 18941236af 移除随机延时 2026-10-21 20:10:19 +08:00
cheney 3b79054995 更新代理配置与对账单数据 2026-10-21 19:59:48 +08:00
cheney 9cf1aacd74 更新token并增加接口错误处理 2026-10-21 19:59:46 +08:00
cheney fd6a6067d4 随机延时移至开票初始化之后 2026-10-20 22:19:40 +08:00
cheney ef22374db7 开票初始化与随机间隔,新增无火车票版本 2026-10-20 20:58:48 +08:00
cheney 19680aaf90 跳过 2026-08-27 19:53:54 +08:00
cheney e51c262673 整理 2026-08-11 21:58:36 +08:00
cheney 477914858b 对比 2026-07-25 20:53:20 +08:00
cheney 11451aac40 合并 2026-07-25 20:33:34 +08:00
cheney c1e378a631 开票业务 2026-07-14 21:09:36 +08:00
cheney 5424d61de9 总进度 2026-07-13 21:47:00 +08:00
cheney 0ad25ce261 save 2026-07-13 21:44:53 +08:00
cheney 359fd38bd6 对账 2026-07-13 21:21:30 +08:00
cheney 01b4a000bd 对账 2026-07-13 21:21:05 +08:00
cheney 99c33220b8 test 2026-07-13 20:25:24 +08:00
cheney 7081eae1a3 格式 2026-07-13 20:05:27 +08:00
cheney 64f62be356 bun 2026-07-13 20:01:00 +08:00
cheney 73ace78017 不息屏 2026-07-13 19:57:08 +08:00
cheney 3d8fd41127 不息屏 2026-07-13 19:55:50 +08:00
cheney 887b68d259 代理工具 2026-05-28 10:20:52 +08:00
cheney f87061266a 配置文件编码问题 2026-05-26 20:54:11 +08:00
cheney cdfdd9b7e4 切换配置 2026-05-26 20:49:47 +08:00
cheney d11390e453 更新日志 2026-05-26 18:05:05 +08:00
cheney be710920e9 修复图片 2026-05-26 16:03:34 +08:00
cheney 6240e453b0 访问正常 2026-05-26 15:22:43 +08:00
cheney a7172bd3aa 资源类请求成功 2026-05-26 14:59:41 +08:00
cheney 1821ba1f2c 我还是习惯写 js 2026-05-25 18:08:11 +08:00
cheney e02c1f2b16 简单可用,复杂不可用 2026-05-25 14:29:42 +08:00
cheney acbe2d07bf 似乎首页能访问,其他的不行 2026-05-25 11:35:45 +08:00
cheney f73419e3eb AI 自测结束 2026-05-25 11:13:40 +08:00
cheney cfc009f60d 修改 proxy 方案 2026-05-25 10:28:57 +08:00
40 changed files with 3829 additions and 187 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/*
~$*
.reasonix/*
+11
View File
@@ -0,0 +1,11 @@
## 开票业务
excle 结算状态过滤
登录
总进度
## 避免锁屏
打开目录:D:\Cheney\2\geely-kaipiao\
地址栏删空,输入 cmd,打开黑框。
输入命令:bun.exe keep-awake.js
View File
BIN
View File
Binary file not shown.
+283
View File
@@ -0,0 +1,283 @@
const XLSX = require('xlsx');
const fs = require('fs');
/**
* 功能:读取Excel文件并解析为JSON
* 参数:filePath - 文件路径
* 返回值:解析后的工作簿对象
*/
function readExcel(filePath) {
return XLSX.readFile(filePath);
}
/**
* 功能:判断是否为有效订单号(FR开头的订单号)
* 参数:orderNo - 订单号
* 返回值:是否有效
*/
function isValidOrderNo(orderNo) {
if (!orderNo) return false;
const str = String(orderNo).trim();
if (!str) return false;
if (str === '总计' || str === '合计' || str === '汇总') return false;
if (str.startsWith('FR')) return true;
return /^\d+$/.test(str);
}
/**
* 功能:从酒店账单提取订单号和金额
* 参数:workbook - Excel工作簿对象
* 返回值:订单Map {订单号: {金额, 入住人, 入住时间, 离店时间}}
*/
function extractHotelOrders(workbook) {
const sheet = workbook.Sheets['.'];
const data = XLSX.utils.sheet_to_json(sheet);
const orders = new Map();
data.forEach(row => {
const orderNo = row['吉利订单号'];
if (isValidOrderNo(orderNo)) {
orders.set(String(orderNo).trim(), {
结算金额: parseFloat(row['结算金额']) || 0,
系统金额: parseFloat(row['系统金额']) || 0,
差异: parseFloat(row['差异']) || 0,
入住人: row['入住人姓名'] || '',
入住时间: row['入住时间'] || '',
离店时间: row['离店时间'] || '',
房型: row['房型'] || '',
房号: row['房号'] || '',
房间数: row['房间数'] || 0,
间夜数: row['间夜数'] || 0
});
}
});
return orders;
}
/**
* 功能:从商旅账单Export表提取订单号和金额(合并同一订单的正负金额)
* 参数:workbook - Excel工作簿对象
* 返回值:订单Map {订单号: 金额对象}
*/
function extractGeelyExportOrders(workbook) {
const sheet = workbook.Sheets['Export'];
const data = XLSX.utils.sheet_to_json(sheet, { header: 1 }); // 使用数组索引访问
const orders = new Map();
// 从第2行开始(第0行是标题,第1行是表头)
for (let i = 2; i < data.length; i++) {
const row = data[i];
const orderNo = row[5]; // 供应商订单号在第5列
if (isValidOrderNo(orderNo)) {
// 金额在第11列,纯数字,已包含正负值
const amount = parseFloat(row[11]) || 0;
const key = String(orderNo).trim();
// 合并同一订单号的金额
if (orders.has(key)) {
const existing = orders.get(key);
existing.金额 += amount;
// 保留最后一条记录的其他信息
existing.对账明细名称 = row[6] || existing.对账明细名称;
existing.结算状态 = row[9] || existing.结算状态;
existing.对账单号 = row[0] || existing.对账单号;
} else {
orders.set(key, {
金额: amount,
对账明细名称: row[6] || '',
结算状态: row[9] || '',
对账单号: row[0] || '',
订单号: row[4] || ''
});
}
}
}
return orders;
}
/**
* 功能:对比两个订单集合的差异
* 参数:hotelOrders - 酒店方订单Map, geelyOrders - 商旅方订单Map
* 返回值:差异结果对象
*/
function compareOrders(hotelOrders, geelyOrders) {
const hotelOnly = [];
const geelyOnly = [];
const amountDiff = [];
const matched = [];
// 检查酒店有但商旅没有的订单
for (const [orderNo, hotelInfo] of hotelOrders) {
if (!geelyOrders.has(orderNo)) {
hotelOnly.push({ 订单号: orderNo, ...hotelInfo });
} else {
const geelyInfo = geelyOrders.get(orderNo);
const hotelAmount = hotelInfo.结算金额;
const geelyAmount = geelyInfo.金额;
if (Math.abs(hotelAmount - geelyAmount) > 0.01) {
amountDiff.push({
订单号: orderNo,
酒店金额: hotelAmount,
商旅金额: geelyAmount,
差额: hotelAmount - geelyAmount,
入住人: hotelInfo.入住人,
酒店明细: hotelInfo,
商旅明细: geelyInfo
});
} else {
matched.push({
订单号: orderNo,
金额: hotelAmount,
入住人: hotelInfo.入住人
});
}
}
}
// 检查商旅有但酒店没有的订单
for (const [orderNo, geelyInfo] of geelyOrders) {
if (!hotelOrders.has(orderNo)) {
geelyOnly.push({ 订单号: orderNo, ...geelyInfo });
}
}
return { hotelOnly, geelyOnly, amountDiff, matched };
}
/**
* 功能:生成差异报告并输出
* 参数:result - 对比结果对象
*/
function generateReport(result, hotelOrdersCount, geelyOrdersCount) {
console.log('\n' + '='.repeat(70));
console.log(' 吉利商旅 vs 酒店账单 差异报告');
console.log(' (仅使用Export表,已合并同一订单的正负金额)');
console.log('='.repeat(70));
console.log(`\n【统计概览】`);
console.log(` 酒店账单有效订单数: ${hotelOrdersCount}`);
console.log(` 商旅账单有效订单数: ${geelyOrdersCount}`);
console.log(` ✓ 匹配成功订单数: ${result.matched.length}`);
console.log(` ⚠ 金额差异订单数: ${result.amountDiff.length}`);
console.log(` ✗ 酒店有-商旅无: ${result.hotelOnly.length}`);
console.log(` ? 商旅有-酒店无: ${result.geelyOnly.length}`);
if (result.hotelOnly.length > 0) {
console.log(`\n【酒店有但商旅没有的订单 - 共${result.hotelOnly.length}条】`);
console.log('-'.repeat(70));
result.hotelOnly.forEach((item, i) => {
console.log(`${i+1}. ${item.订单号} | ${item.入住人.padEnd(6)} | ¥${String(item.结算金额).padStart(6)} | ${item.房型}`);
});
}
if (result.geelyOnly.length > 0) {
console.log(`\n【商旅有但酒店没有的订单 - 共${result.geelyOnly.length}条】`);
console.log('-'.repeat(70));
result.geelyOnly.forEach((item, i) => {
console.log(`${i+1}. ${item.订单号} | ¥${String(item.金额).padStart(6)} | ${item.对账明细名称 || ''}`);
});
}
if (result.amountDiff.length > 0) {
console.log(`\n【金额存在差异的订单 - 共${result.amountDiff.length}条】`);
console.log('-'.repeat(70));
result.amountDiff.forEach((item, i) => {
console.log(`${i+1}. ${item.订单号} | ${item.入住人}`);
console.log(` 酒店: ¥${item.酒店金额} | 商旅: ¥${item.商旅金额} | 差额: ¥${item.差额}`);
});
}
console.log('\n' + '='.repeat(70));
// 计算总金额
let hotelTotal = 0;
let geelyTotal = 0;
for (const item of result.matched) {
hotelTotal += item.金额;
geelyTotal += item.金额;
}
for (const item of result.amountDiff) {
hotelTotal += item.酒店金额;
geelyTotal += item.商旅金额;
}
for (const item of result.hotelOnly) {
hotelTotal += item.结算金额;
}
for (const item of result.geelyOnly) {
geelyTotal += item.金额;
}
console.log(`\n【金额汇总】`);
console.log(` 酒店账单总金额: ¥${hotelTotal}`);
console.log(` 商旅账单总金额: ¥${geelyTotal}`);
console.log(` 总差额: ¥${hotelTotal - geelyTotal}`);
console.log('');
return {
统计: {
酒店订单总数: hotelOrdersCount,
商旅订单总数: geelyOrdersCount,
匹配成功: result.matched.length,
金额差异: result.amountDiff.length,
酒店有商旅无: result.hotelOnly.length,
商旅有酒店无: result.geelyOnly.length,
酒店总金额: hotelTotal,
商旅总金额: geelyTotal,
总差额: hotelTotal - geelyTotal
},
酒店有商旅无: result.hotelOnly,
商旅有酒店无: result.geelyOnly,
金额差异: result.amountDiff,
匹配成功: result.matched
};
}
/**
* 功能:将结果保存为JSON文件
* 参数:report - 报告对象, filename - 文件名
*/
function saveReportToJson(report, filename) {
const jsonReport = JSON.parse(JSON.stringify(report, (key, value) => {
if (typeof value === 'bigint') return value.toString();
return value;
}));
fs.writeFileSync(filename, JSON.stringify(jsonReport, null, 2), 'utf8');
console.log(`详细结果已保存到: ${filename}`);
}
// 主函数
function main() {
const geelyFile = '杭州碧丽酒店管理有限公司 2026年06月 对账单(0601-0630).xlsx';
const hotelFile = '杭州滨江希尔顿欢朋酒店-吉利商旅6月账单.xlsx';
console.log('正在读取Excel文件...');
const geelyWorkbook = readExcel(geelyFile);
const hotelWorkbook = readExcel(hotelFile);
console.log('正在提取订单数据...');
const hotelOrders = extractHotelOrders(hotelWorkbook);
const geelyOrders = extractGeelyExportOrders(geelyWorkbook);
console.log(`酒店有效订单数: ${hotelOrders.size}`);
console.log(`商旅Export表有效订单数(合并后): ${geelyOrders.size}`);
console.log('\n正在对比订单...');
const result = compareOrders(hotelOrders, geelyOrders);
const report = generateReport(result, hotelOrders.size, geelyOrders.size);
saveReportToJson(report, '账单差异报告.json');
console.log('\n对比完成!');
}
main();
+165
View File
@@ -0,0 +1,165 @@
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
View File
@@ -0,0 +1,200 @@
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
View File
@@ -0,0 +1,255 @@
const XLSX = require('xlsx');
const path = require('path');
/**
* 开票清单 -> 吉智对账单 填列脚本
*
* 输入 excel1: sheet="发票清单"
* col "资源类型" = 大类(火车票/国际机票/国内机票/国际酒店/酒店/附加项)
* col "开票明细" = 小类(包含字符: 机票/机票服务费/住宿费/住宿服务费/火车票/火车票服务费)
* col "开票金额" = 金额
* col "结算账户" = 归属 TEAM 编号(TEAM-XXXX 或 XXXXTEAM-XXXX, 结尾 : 去掉)
*
* 输出 excel2: sheet 名含"吉智"(只有一个)
* 有一行数据的 col "*对账单/项目名称" = TEAM 编号
* 填写以下列(每个项名对应一个 col):
* 吉智服务费 = 酒店服务费 + 机票服务费 + 火车票服务费
* 酒店房费 = 国际酒店房费 + 酒店房费
* 酒店服务费 = 国际酒店服务费 + 酒店服务费
* 机票服务费 = 国际机票服务费 + 国内机票服务费
* 火车票服务费 = 火车票服务费
*
* 中断条件: excel2 中找不到对应 TEAM -> 提示并中断
* 找到多个对应 TEAM -> 提示并中断
*
* 用法: node jizhi_fill.js [excel1路径] [excel2路径]
* 不带参数则使用下方配置常量
*/
// ======================== 配置 ========================
const EXCEL1 = path.join(__dirname, '开票清单.xlsx'); // excel1 路径可修改
const EXCEL2 = path.join(__dirname, '吉智对账单.xlsx'); // excel2 路径可修改
const SHEET1_NAME = '发票清单';
// 需要填入 excel2 的字段: [列名(兼容*与空格), 取值函数]
const FIELDS = [
{ name: '吉智服务费', get: s => s.totalService },
{ name: '酒店房费', get: s => s.hotelRoom },
{ name: '酒店服务费', get: s => s.hotelService },
{ name: '机票服务费', get: s => s.flightService },
{ name: '火车票服务费', get: s => s.trainService },
];
// ======================== 工具 ========================
function round2(n) {
return Math.round((Number(n) + Number.EPSILON) * 100) / 100;
}
// 去掉全角/半角冒号(仅结尾), 以及空格
function normalizeTeam(v) {
let s = String(v == null ? '' : v).trim();
s = s.replace(/[::]\s*$/, '').trim();
return s;
}
// 在 sheet 名中查找包含关键字的 sheet(恰好一个)
function findSheet(workbook, keyword, label) {
const names = workbook.SheetNames.filter(n => n.includes(keyword));
if (names.length === 0) {
console.error(`[中断] ${label}: 未找到名称包含"${keyword}"的 sheet`);
process.exit(1);
}
if (names.length > 1) {
console.error(`[中断] ${label}: 找到多个名称包含"${keyword}"的 sheet: ${names.join(', ')}`);
process.exit(1);
}
return names[0];
}
// 精确(去星号/空格)匹配列名 -> 列下标; 找不到返回 -1
function findColIndex(rows, targetName) {
const want = targetName.replace(/[*\s]/g, '');
const header = rows[0];
if (!header) return -1;
for (let c = 0; c < header.length; c++) {
const h = header[c];
if (h == null) continue;
if (String(h).replace(/[*\s]/g, '') === want) return c;
}
return -1;
}
// 读取一个 sheet 成二维数组(含表头)
function sheetToRows(sheet) {
return XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null, blankrows: false });
}
// ======================== 1. 读取 excel1 ========================
function readInvoice(excel1Path) {
const wb = XLSX.readFile(excel1Path);
const sheet = wb.Sheets[SHEET1_NAME];
if (!sheet) {
console.error(`[中断] excel1: 未找到 sheet "${SHEET1_NAME}"`);
process.exit(1);
}
const rows = sheetToRows(sheet);
const header = rows[0];
const col = name => findColIndex(rows, name);
const idxResource = col('资源类型');
const idxDetail = col('开票明细');
const idxAmount = col('开票金额');
const idxAccount = col('结算账户');
if ([idxResource, idxDetail, idxAmount, idxAccount].some(i => i === -1)) {
console.error(`[中断] excel1: 缺少必要列(资源类型/开票明细/开票金额/结算账户), 现有表头: ${JSON.stringify(header)}`);
process.exit(1);
}
// 每个 TEAM 的累加结果
const sums = new Map(); // key: teamId -> {totalService, hotelRoom, hotelService, flightService, trainService}
const newSum = () => ({ totalService: 0, hotelRoom: 0, hotelService: 0, flightService: 0, trainService: 0 });
for (let r = 1; r < rows.length; r++) {
const row = rows[r];
const resource = row[idxResource] == null ? '' : String(row[idxResource]).trim();
const detail = row[idxDetail] == null ? '' : String(row[idxDetail]).trim();
const amount = Number(row[idxAmount]);
const accountRaw = row[idxAccount];
if (resource === '' && detail === '') continue; // 空行
// 归属 TEAM
const match = normalizeTeam(accountRaw).match(/TEAM-\d+/);
if (!match) {
console.warn(` [警告] 第${r + 1}行: 无法从结算账户提取 TEAM (值: "${accountRaw}"),跳过`);
continue;
}
const teamId = match[0];
const s = sums.get(teamId) || newSum();
sums.set(teamId, s);
if (Number.isNaN(amount)) {
console.warn(` [警告] 第${r + 1}行: 开票金额无法解析 (值: "${row[idxAmount]}"),跳过`);
continue;
}
// 按"开票明细"小类包含字符归类(注意先匹配更具体的"服务费")
if (detail.includes('住宿服务费')) {
s.hotelService += amount;
} else if (detail.includes('住宿费')) {
s.hotelRoom += amount;
} else if (detail.includes('机票服务费')) {
s.flightService += amount;
} else if (detail.includes('火车票服务费')) {
s.trainService += amount;
} else {
console.warn(` [警告] 第${r + 1}行: 开票明细"${detail}"未识别到服务费/住宿费类别,跳过 (${teamId}, ${amount})`);
continue;
}
}
// 计算吉智服务费 = 各服务费之和
for (const s of sums.values()) {
s.totalService = round2(s.hotelService + s.flightService + s.trainService);
s.hotelRoom = round2(s.hotelRoom);
s.hotelService = round2(s.hotelService);
s.flightService = round2(s.flightService);
s.trainService = round2(s.trainService);
}
if (sums.size === 0) {
console.error('[中断] excel1: 未提取到任何 TEAM 数据');
process.exit(1);
}
console.log(`[excel1] 解析完成, 共 ${sums.size} 个 TEAM:`);
for (const [tid, s] of sums) {
console.log(` ${tid}: 吉智服务费=${s.totalService}, 酒店房费=${s.hotelRoom}, 酒店服务费=${s.hotelService}, 机票服务费=${s.flightService}, 火车票服务费=${s.trainService}`);
}
return sums;
}
// ======================== 2. 写入 excel2 ========================
function writeJizhi(excel2Path, sums) {
const wb = XLSX.readFile(excel2Path);
const sheetName = findSheet(wb, '吉智', 'excel2');
const sheet = wb.Sheets[sheetName];
const rows = sheetToRows(sheet);
const header = rows[0];
const nameIdx = findColIndex(rows, '对账单/项目名称');
if (nameIdx === -1) {
console.error(`[中断] excel2: 未找到列"对账单/项目名称", 现有表头: ${JSON.stringify(header)}`);
process.exit(1);
}
// 收集每个 TEAM 匹配到的行
const matched = new Map(); // teamId -> [rowIdx...]
for (let r = 1; r < rows.length; r++) {
const val = rows[r][nameIdx];
if (val == null || String(val).trim() === '') continue;
const teamId = normalizeTeam(val).match(/TEAM-\d+/);
if (!teamId) continue;
const tid = teamId[0];
if (!matched.has(tid)) matched.set(tid, []);
matched.get(tid).push(r);
}
// 校验: 每个需要填写的 TEAM 在 excel2 中必须恰好匹配一行
for (const [tid] of sums) {
const rowsFound = matched.get(tid) || [];
if (rowsFound.length === 0) {
console.error(`[中断] excel2: 找不到 TEAM "${tid}" 对应的行, 请检查"对账单/项目名称"列`);
process.exit(1);
}
if (rowsFound.length > 1) {
console.error(`[中断] excel2: TEAM "${tid}" 在 excel2 中找到 ${rowsFound.length} 行(第 ${rowsFound.map(x => x + 1).join(', ')} 行), 存在重复, 无法确定唯一目标`);
process.exit(1);
}
}
// 写入各字段
for (const field of FIELDS) {
const colIdx = findColIndex(rows, field.name);
if (colIdx === -1) {
console.error(`[中断] excel2: 未找到列"${field.name}", 现有表头: ${JSON.stringify(header)}`);
process.exit(1);
}
for (const [tid, s] of sums) {
const rowIdx = matched.get(tid)[0];
XLSX.utils.sheet_add_aoa(sheet, [[field.get(s)]], { origin: { r: rowIdx, c: colIdx } });
}
}
XLSX.writeFile(wb, excel2Path);
console.log(`\n[excel2] 已写入: ${excel2Path} (sheet: ${sheetName})`);
for (const [tid] of sums) {
console.log(` 填写完成: ${tid} (第 ${matched.get(tid)[0] + 1} 行)`);
}
}
// ======================== 主流程 ========================
function main() {
const excel1Path = process.argv[2] || EXCEL1;
const excel2Path = process.argv[3] || EXCEL2;
if (!require('fs').existsSync(excel1Path)) {
console.error(`[中断] excel1 文件不存在: ${excel1Path}`);
process.exit(1);
}
if (!require('fs').existsSync(excel2Path)) {
console.error(`[中断] excel2 文件不存在: ${excel2Path}`);
process.exit(1);
}
console.log(`excel1: ${excel1Path}`);
console.log(`excel2: ${excel2Path}\n`);
const sums = readInvoice(excel1Path);
writeJizhi(excel2Path, sums);
console.log('\n========== 完成 ==========');
}
main();
+374
View File
@@ -0,0 +1,374 @@
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.eyJzdWIiOiJKV1QtRC1TRVFfVVNFUl9UTUNfMDAwMDAxODMiLCJ1c2VyX2NvZGUiOiJTRVFfVVNFUl9UTUNfMDAwMDAxODMiLCJyb2xlcyI6WyLnu5PnrpfkuJPlkZgiLCLnu5PnrpfkuJPlkZgiXSwiZXhwIjoxNzk1MDkxMTI3LCJpYXQiOjE3OTI0OTkxMjd9.wPYA-ntdWfT86LsQIYEJdVUHEksbMM9VwE96VqPdGms';
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();
}
// ========== 4.5 初始化开票价格信息 ==========
async function initReceiveOrderPrice(settlementSeq, receiveBillId) {
const url = BASE_URL + '/api/tmc/tmc/settlement/receiveInvoice/initReceiveOrderPrice';
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 });
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 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);
}
// ========== 9. 映射机票开票项(国内+国际通用) ==========
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);
}
// ========== 10. 处理酒店 ==========
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 initResp = await initReceiveOrderPrice(settlementSeq, receiveBillId);
console.log(' -> initReceiveOrderPrice:', JSON.stringify(initResp));
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('');
}
// ========== 11. 处理国内机票 ==========
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 initResp = await initReceiveOrderPrice(settlementSeq, receiveBillId);
console.log(' -> initReceiveOrderPrice:', JSON.stringify(initResp));
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('');
}
// ========== 12. 处理国际机票 ==========
async function processInternationalFlightBill(bill, billIndex, totalBills) {
const settlementSeq = bill.settlementSeq;
const receiveBillId = bill.receiveBillId;
console.log(' --- 国际机票 [' + billIndex + '/' + totalBills + '] ' + bill.statementName + ' ---');
const initResp = await initReceiveOrderPrice(settlementSeq, receiveBillId);
console.log(' -> initReceiveOrderPrice:', JSON.stringify(initResp));
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('');
}
// ========== 接口错误检查 ==========
function ensureOk(resp, what) {
if (resp && resp.code && resp.code !== 'Z000') {
if (resp.code === 'E003') {
console.error(' ✗ token 已失效(E003: ' + (resp.msg || '') + '),请更新 AUTH_TOKEN');
process.exit(1);
}
throw new Error(what + ' 失败: [' + resp.code + '] ' + (resp.msg || resp.message || ''));
}
}
// ========== 主流程 ==========
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);
ensureOk(settlementResp, 'querySettlementAccountIds');
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);
ensureOk(billResp, 'queryBillList');
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');
if (trainBills.length > 0) {
console.log(' ⚠ 本版本跳过 ' + trainBills.length + ' 条火车票对账单');
trainBills.forEach(function (b) { console.log(' - ' + b.statementName); });
console.log('');
}
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);
});
+275
View File
@@ -0,0 +1,275 @@
const XLSX = require('xlsx');
const path = require('path');
// ========== 配置 ==========
const EXCEL_FILE = path.join(__dirname, '新建 XLSX 工作表.xlsx');
const SHEET_NAME = '2026';
// 试运行模式:运行时传入 try 参数激活(node kaipiao-train-only.js try),
// 只查询和判断状态,不执行任何真实开票动作(init/confirm/submit);不传则执行真实开票
const TRY = process.argv.includes('try');
const BASE_URL = 'https://superstar.geelytravel.com';
const AUTH_TOKEN = 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJKV1QtRC1TRVFfVVNFUl9UTUNfMDAwMDAxODMiLCJ1c2VyX2NvZGUiOiJTRVFfVVNFUl9UTUNfMDAwMDAxODMiLCJyb2xlcyI6WyLnu5PnrpfkuJPlkZgiLCLnu5PnrpfkuJPlkZgiXSwiZXhwIjoxNzk1MDkxMTI3LCJpYXQiOjE3OTI0OTkxMjd9.wPYA-ntdWfT86LsQIYEJdVUHEksbMM9VwE96VqPdGms';
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 initReceiveOrderPrice(settlementSeq, receiveBillId) {
const url = BASE_URL + '/api/tmc/tmc/settlement/receiveInvoice/initReceiveOrderPrice';
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 });
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. 处理火车票 ==========
async function processTrainBill(bill, billIndex, totalBills) {
const settlementSeq = bill.settlementSeq;
const receiveBillId = bill.receiveBillId;
console.log(' --- 火车票 [' + billIndex + '/' + totalBills + '] ' + bill.statementName + ' ---');
if (TRY) {
console.log(' [试运行] 跳过真实开票动作(init/confirm/submit)\n');
return;
}
const initResp = await initReceiveOrderPrice(settlementSeq, receiveBillId);
console.log(' -> initReceiveOrderPrice:', JSON.stringify(initResp));
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('');
}
// ========== 接口错误检查 ==========
function ensureOk(resp, what) {
if (resp && resp.code && resp.code !== 'Z000') {
if (resp.code === 'E003') {
console.error(' ✗ token 已失效(E003: ' + (resp.msg || '') + '),请更新 AUTH_TOKEN');
process.exit(1);
}
throw new Error(what + ' 失败: [' + resp.code + '] ' + (resp.msg || resp.message || ''));
}
}
// ========== 主流程 ==========
async function main() {
const teamIds = readTeamIds();
if (teamIds.length === 0) {
console.log('没有找到 TEAM ID,退出');
return;
}
const specialStatusTeamIds = [];
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);
ensureOk(settlementResp, 'querySettlementAccountIds');
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);
ensureOk(billResp, 'queryBillList');
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 || ''));
});
if (skippedBills.length > 0) {
specialStatusTeamIds.push(teamId);
console.log(' ⚠ TEAM ' + teamId + ' 存在特殊状态(settlementStatus=3)的对账单 ' + skippedBills.length + ' 条\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);
}
const skippedNonTrainBills = bills.filter(function (b) { return !(b.statementName || '').includes('火车票'); });
if (skippedNonTrainBills.length > 0) {
console.log(' ⚠ 本版本只处理火车票,跳过 ' + skippedNonTrainBills.length + ' 条非火车票对账单:');
skippedNonTrainBills.forEach(function (b) { console.log(' - ' + b.statementName); });
console.log('');
}
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('========== ' + (TRY ? '试运行完成(未执行真实开票)' : '完成') + ' ==========');
if (specialStatusTeamIds.length > 0) {
console.log('存在特殊状态(settlementStatus=3)的 TEAM: ' + specialStatusTeamIds.join(', '));
} else {
console.log('没有 TEAM 存在特殊状态(settlementStatus=3)的对账单');
}
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});
+417
View File
@@ -0,0 +1,417 @@
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.eyJzdWIiOiJKV1QtRC1TRVFfVVNFUl9UTUNfMDAwMDAxODMiLCJ1c2VyX2NvZGUiOiJTRVFfVVNFUl9UTUNfMDAwMDAxODMiLCJyb2xlcyI6WyLnu5PnrpfkuJPlkZgiLCLnu5PnrpfkuJPlkZgiXSwiZXhwIjoxNzk1MDkxMTI3LCJpYXQiOjE3OTI0OTkxMjd9.wPYA-ntdWfT86LsQIYEJdVUHEksbMM9VwE96VqPdGms';
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();
}
// ========== 4.5 初始化开票价格信息 ==========
async function initReceiveOrderPrice(settlementSeq, receiveBillId) {
const url = BASE_URL + '/api/tmc/tmc/settlement/receiveInvoice/initReceiveOrderPrice';
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 });
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 initResp = await initReceiveOrderPrice(settlementSeq, receiveBillId);
console.log(' -> initReceiveOrderPrice:', JSON.stringify(initResp));
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 initResp = await initReceiveOrderPrice(settlementSeq, receiveBillId);
console.log(' -> initReceiveOrderPrice:', JSON.stringify(initResp));
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 initResp = await initReceiveOrderPrice(settlementSeq, receiveBillId);
console.log(' -> initReceiveOrderPrice:', JSON.stringify(initResp));
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 initResp = await initReceiveOrderPrice(settlementSeq, receiveBillId);
console.log(' -> initReceiveOrderPrice:', JSON.stringify(initResp));
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('');
}
// ========== 接口错误检查 ==========
function ensureOk(resp, what) {
if (resp && resp.code && resp.code !== 'Z000') {
if (resp.code === 'E003') {
console.error(' ✗ token 已失效(E003: ' + (resp.msg || '') + '),请更新 AUTH_TOKEN');
process.exit(1);
}
throw new Error(what + ' 失败: [' + resp.code + '] ' + (resp.msg || resp.message || ''));
}
}
// ========== 主流程 ==========
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);
ensureOk(settlementResp, 'querySettlementAccountIds');
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);
ensureOk(billResp, 'queryBillList');
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);
});
+36
View File
@@ -0,0 +1,36 @@
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
View File
@@ -0,0 +1,185 @@
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
View File
@@ -0,0 +1,164 @@
{
"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
View File
@@ -0,0 +1,19 @@
{
"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"
}
}
+285
View File
@@ -0,0 +1,285 @@
# 代理通信协议设计文档
## 1. 架构概述
### 1.1 设备角色
| 设备 | 角色 | 能力 | 限制 |
|------|------|------|------|
| 电脑A | 代理客户端 | 可访问内网网站,作为HTTP客户端 | 无法监听端口 |
| 电脑B | 代理服务器 | 可监听端口,转发HTTP请求 | 无法直接访问内网 |
### 1.2 网络拓扑
```
浏览器 <--(端口8081)--> 电脑B <--(端口8080)--> 电脑A <--(HTTP)--> 目标网站
```
### 1.3 核心设计原则
1. **电脑A作为纯HTTP客户端**:不保存cookie、session等状态信息
2. **请求头替换**:电脑A强制替换Origin、Referer等参数
3. **日志对应**:server端每一条请求日志,client端对应一条请求日志
4. **目标网站硬编码**:目标网站地址写死在配置中
---
## 2. 端口分配
| 端口号 | 用途 | 连接方向 |
|--------|------|----------|
| 8080 | 设备A连接端口 | 电脑A → 电脑B |
| 8081 | 浏览器访问端口 | 浏览器 → 电脑B |
---
## 3. 通信协议
### 3.1 协议类型
采用 **自定义HTTP代理协议**,电脑B将浏览器请求转发给电脑A,电脑A作为HTTP客户端访问目标网站。
### 3.2 协议流程
#### 阶段1:设备A连接到设备B
```
电脑A → 电脑B: TCP连接建立
电脑B → 电脑A: "PROXY_CONNECTED\r\n"
电脑A → 电脑B: "READY\r\n"
```
#### 阶段2:浏览器发起请求
```
浏览器 → 电脑B: 完整HTTP请求
GET /max/js/jquery/lib.js HTTP/1.1
Host: localhost:8081
Origin: http://localhost:8081
Referer: http://localhost:8081/
[其他请求头...]
[请求体(如有)]
```
#### 阶段3:电脑B转发请求
```
电脑B → 电脑A: 完整HTTP请求(透传)
```
#### 阶段4:电脑A处理请求
1. **修改请求头**:替换Origin、Referer为目标网站地址
2. **修改Host头**:设置为目标网站地址
3. **移除不必要的头**:如Connection、Keep-Alive等
#### 阶段5:电脑A访问目标网站
```
电脑A → 内网目标: 修改后的HTTP请求
GET /max/js/jquery/lib.js HTTP/1.1
Host: zentao.sunyard.com.cn:9788
Origin: http://zentao.sunyard.com.cn:9788
Referer: http://zentao.sunyard.com.cn:9788/
[其他请求头...]
[请求体(如有)]
```
#### 阶段6:电脑A返回响应
```
内网目标 → 电脑A: HTTP响应
电脑A → 电脑B: HTTP响应(透传)
电脑B → 浏览器: HTTP响应(透传)
```
#### 阶段7:连接保持
- 电脑A与电脑B保持长连接
- 支持同一连接上的多个HTTP请求
- 连接空闲超时时间:300秒
---
## 4. 请求头处理规则
### 4.1 强制替换的请求头
| 请求头 | 替换值 |
|--------|--------|
| Origin | `http://{TARGET_HOST}:{TARGET_PORT}` |
| Referer | `http://{TARGET_HOST}:{TARGET_PORT}/` |
| Host | `{TARGET_HOST}:{TARGET_PORT}` |
### 4.2 移除的请求头
| 请求头 | 原因 |
|--------|------|
| Connection | 由电脑A管理连接 |
| Keep-Alive | 由电脑A管理连接 |
| Proxy-Connection | 不需要代理标识 |
| X-Forwarded-For | 不需要转发标识 |
### 4.3 保留的请求头
所有其他请求头保持不变,包括:
- Accept
- Accept-Encoding
- Accept-Language
- User-Agent
- Cookie(如果有)
- Content-Type
- Content-Length
---
## 5. 数据格式
### 5.1 请求格式
电脑B转发给电脑A的请求格式为**完整的HTTP请求**:
```
{HTTP_METHOD} {REQUEST_PATH} HTTP/1.1\r\n
{HEADER_NAME}: {HEADER_VALUE}\r\n
...
\r\n
{REQUEST_BODY}
```
### 5.2 响应格式
电脑A返回给电脑B的响应格式为**完整的HTTP响应**:
```
HTTP/1.1 {STATUS_CODE} {STATUS_MESSAGE}\r\n
{HEADER_NAME}: {HEADER_VALUE}\r\n
...
\r\n
{RESPONSE_BODY}
```
---
## 6. 日志规范
### 6.1 日志格式
| 字段 | 格式 | 示例 |
|------|------|------|
| 时间戳 | `YYYY-MM-DD HH:MM:SS` | `2024-01-15 10:30:45` |
| 级别 | `[INFO]` / `[ERROR]` | `[INFO]` |
| 来源 | `[Browser]` / `[DeviceA]` / `[System]` | `[Browser]` |
| 行号 | `(line N)` | `(line 45)` |
| 内容 | 描述信息 | 请求详情 |
### 6.2 请求日志(电脑B)
```
[时间戳] [INFO] [Browser] (line N) Request: {METHOD} {PATH} - Body: {BYTES} bytes
```
### 6.3 请求日志(电脑A)
```
[时间戳] [INFO] [System] (line N) Request: {METHOD} {PATH} - Body: {BYTES} bytes
```
### 6.4 响应日志(电脑B)
```
[时间戳] [INFO] [Browser] (line N) Response: {METHOD} {PATH} - Status: {CODE} - Time: {MS}ms
```
### 6.5 响应日志(电脑A)
```
[时间戳] [INFO] [System] (line N) Response: {METHOD} {PATH} - Status: {CODE} - Time: {MS}ms
```
### 6.6 日志对应原则
**server端有一条请求日志,client端必须有一条对应的请求日志**:
```
电脑B: [2024-01-15 10:30:45] [INFO] [Browser] (line 45) Request: GET /max/js/jquery/lib.js - Body: 0 bytes
电脑A: [2024-01-15 10:30:45] [INFO] [System] (line 35) Request: GET /max/js/jquery/lib.js - Body: 0 bytes
```
---
## 7. 错误处理
### 7.1 设备A未连接
当浏览器请求时设备A未连接:
```
HTTP/1.1 503 Service Unavailable
Content-Type: text/plain
Device A not connected
```
### 7.2 目标不可达
当电脑A无法连接目标网站:
```
HTTP/1.1 502 Bad Gateway
Content-Type: text/plain
Cannot connect to target: zentao.sunyard.com.cn:9788
```
### 7.3 重复连接
当已有设备A连接时,拒绝新连接:
```
ERROR: Another A device is already connected
```
### 7.4 请求超时
当请求处理超时:
```
HTTP/1.1 504 Gateway Timeout
Content-Type: text/plain
Request timeout
```
---
## 8. 超时设置
| 操作 | 超时时间 |
|------|----------|
| 设备A连接超时 | 10秒 |
| 目标网站连接超时 | 30秒 |
| 请求处理超时 | 60秒 |
| 连接空闲超时 | 300秒 |
---
## 9. 配置参数
### 9.1 电脑A配置
| 参数 | 说明 | 默认值 |
|------|------|--------|
| TARGET_HOST | 目标内网网站主机 | zentao.sunyard.com.cn |
| TARGET_PORT | 目标内网网站端口 | 9788 |
| B_HOST | 电脑B地址 | localhost |
| B_PORT | 电脑B端口 | 8080 |
### 9.2 电脑B配置
| 参数 | 说明 | 默认值 |
|------|------|--------|
| A_PORT | 设备A连接端口 | 8080 |
| BROWSER_PORT | 浏览器访问端口 | 8081 |
+28
View File
@@ -0,0 +1,28 @@
@echo off
echo ================================================
echo 清理8080端口进程脚本
echo ================================================
echo.
:: 查找并终止使用8080端口的进程
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":8080" ^| findstr LISTENING') do (
echo 终止进程: PID %%a
taskkill /F /PID %%a
)
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":8080" ^| findstr ESTABLISHED') do (
echo 终止进程: PID %%a
taskkill /F /PID %%a
)
:: 查找并终止Python代理进程
for /f "tokens=2" %%a in ('tasklist ^| findstr python.exe') do (
taskkill /F /PID %%a
echo 终止Python进程: PID %%a
)
echo.
echo ================================================
echo 清理完成
echo ================================================
pause
+27
View File
@@ -0,0 +1,27 @@
@echo off
echo ================================================
echo 清理8080端口进程脚本
echo ================================================
echo.
:: 查找并终止使用8080端口的进程
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":8080" ^| findstr LISTENING') do (
echo 终止进程: PID %%a
taskkill /F /PID %%a
)
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":8080" ^| findstr ESTABLISHED') do (
echo 终止进程: PID %%a
taskkill /F /PID %%a
)
:: 查找并终止Python代理进程
for /f "tokens=2" %%a in ('tasklist ^| findstr python.exe') do (
taskkill /F /PID %%a
echo 终止Python进程: PID %%a
)
echo.
echo ================================================
echo 清理完成
echo ================================================
+17
View File
@@ -0,0 +1,17 @@
[proxy]
# 目标服务器配置
# 协议支持: http, https
# protocol = https
# target_host = zentao.sunyard.com.cn
# target_port = 9788
# 备用配置示例(取消注释即可使用)
protocol = https
target_host = superstar.geelytravel.com
target_port = 443
# 另一个备用配置
# protocol = http
# target_host = 192.168.1.100
# target_port = 8080
+60 -8
View File
@@ -1,12 +1,50 @@
import socket
import threading
import sys
import configparser
import os
TARGET_HOST = 'superstar.geelytravel.com'
TARGET_PORT = 443
def handle_request(sock):
def load_config(config_path='config.ini'):
"""加载配置文件"""
config = configparser.ConfigParser()
# 如果配置文件不存在,创建默认配置
if not os.path.exists(config_path):
print(f"配置文件 {config_path} 不存在,使用默认配置")
return {
'protocol': 'https',
'target_host': 'zentao.sunyard.com.cn',
'target_port': 9788
}
config.read(config_path, encoding='utf-8')
if 'proxy' not in config:
print(f"配置文件格式错误,使用默认配置")
return {
'protocol': 'https',
'target_host': 'zentao.sunyard.com.cn',
'target_port': 9788
}
protocol = config['proxy'].get('protocol', 'https').lower()
target_host = config['proxy'].get('target_host', 'zentao.sunyard.com.cn')
target_port = config['proxy'].getint('target_port', 9788)
# 如果没有设置端口,根据协议设置默认端口
if target_port == 0:
target_port = 443 if protocol == 'https' else 80
return {
'protocol': protocol,
'target_host': target_host,
'target_port': target_port
}
def handle_request(sock, target_host, target_port):
"""处理从A设备收到的请求,转发到目标网站"""
try:
# 接收来自A设备的请求
request = b""
@@ -23,7 +61,7 @@ def handle_request(sock):
# 连接到目标网站
target_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
target_sock.connect((TARGET_HOST, TARGET_PORT))
target_sock.connect((target_host, target_port))
# 发送请求
target_sock.send(request)
@@ -43,16 +81,16 @@ def handle_request(sock):
except:
pass
def connect_to_a_device(a_host, a_port):
def connect_to_a_device(a_host, a_port, target_host, target_port):
"""连接到A设备"""
while True:
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((a_host, a_port))
print(f"成功连接到A设备: {a_host}:{a_port}")
print(f"准备转发请求到: {TARGET_HOST}:{TARGET_PORT}")
print(f"准备转发请求到: {target_host}:{target_port}")
handle_request(sock)
handle_request(sock, target_host, target_port)
sock.close()
print("与A设备的连接已断开")
@@ -66,15 +104,29 @@ def connect_to_a_device(a_host, a_port):
time.sleep(3)
def main():
# 加载配置
config = load_config()
# 解析命令行参数
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])
connect_to_a_device(a_host, a_port)
# 可选:通过命令行覆盖目标配置
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()
-112
View File
@@ -1,112 +0,0 @@
import sys
import winreg
def get_current_proxy():
"""获取当前系统代理设置"""
try:
internet_settings = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r'Software\Microsoft\Windows\CurrentVersion\Internet Settings',
0,
winreg.KEY_READ
)
proxy_server, _ = winreg.QueryValueEx(internet_settings, 'ProxyServer')
proxy_enable, _ = winreg.QueryValueEx(internet_settings, 'ProxyEnable')
winreg.CloseKey(internet_settings)
return {
'enabled': bool(proxy_enable),
'server': proxy_server
}
except FileNotFoundError:
return {'enabled': False, 'server': ''}
except Exception as e:
print(f"获取代理设置失败: {e}")
return {'enabled': False, 'server': ''}
def set_proxy(proxy_host, proxy_port):
"""设置系统代理"""
try:
internet_settings = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r'Software\Microsoft\Windows\CurrentVersion\Internet Settings',
0,
winreg.KEY_ALL_ACCESS
)
proxy_server = f'{proxy_host}:{proxy_port}'
winreg.SetValueEx(internet_settings, 'ProxyServer', 0, winreg.REG_SZ, proxy_server)
winreg.SetValueEx(internet_settings, 'ProxyEnable', 0, winreg.REG_DWORD, 1)
winreg.CloseKey(internet_settings)
print(f"✅ 系统代理已设置为: {proxy_server}")
return True
except Exception as e:
print(f"❌ 设置代理失败: {e}")
return False
def unset_proxy():
"""取消系统代理"""
try:
internet_settings = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r'Software\Microsoft\Windows\CurrentVersion\Internet Settings',
0,
winreg.KEY_ALL_ACCESS
)
winreg.SetValueEx(internet_settings, 'ProxyEnable', 0, winreg.REG_DWORD, 0)
winreg.CloseKey(internet_settings)
print("✅ 系统代理已取消")
return True
except Exception as e:
print(f"❌ 取消代理失败: {e}")
return False
def show_status():
"""显示当前代理状态"""
proxy = get_current_proxy()
print("\n当前代理状态:")
print(f" 代理已启用: {'是' if proxy['enabled'] else '否'}")
if proxy['enabled']:
print(f" 代理服务器: {proxy['server']}")
print()
def main():
if len(sys.argv) < 2:
print("=" * 50)
print(" Windows系统代理管理脚本")
print("=" * 50)
print("用法:")
print(f" {sys.argv[0]} status - 查看当前代理状态")
print(f" {sys.argv[0]} set <host> <port> - 设置代理服务器")
print(f" {sys.argv[0]} unset - 取消代理设置")
print(f" {sys.argv[0]} localhost - 快速设置为 localhost:8080")
print("\n示例:")
print(f" {sys.argv[0]} set 192.168.1.50 8080")
print(f" {sys.argv[0]} localhost")
print(f" {sys.argv[0]} unset")
print("=" * 50)
return
command = sys.argv[1].lower()
if command == 'status':
show_status()
elif command == 'set':
if len(sys.argv) < 4:
print("❌ 参数不足,请提供代理服务器地址和端口")
print(f" 示例: {sys.argv[0]} set 192.168.1.50 8080")
return
proxy_host = sys.argv[2]
proxy_port = sys.argv[3]
set_proxy(proxy_host, proxy_port)
elif command == 'unset':
unset_proxy()
elif command == 'localhost':
set_proxy('localhost', '8080')
else:
print(f"❌ 未知命令: {command}")
if __name__ == '__main__':
main()
+359 -67
View File
@@ -1,86 +1,378 @@
# -*- coding: utf-8 -*-
import socket
import threading
import select
import time
import sys
TARGET_HOST = 'superstar.geelytravel.com'
TARGET_PORT = 443
LISTEN_PORT = 8080
# Configuration
A_PORT = 8080
BROWSER_PORT = 8081
b_device_socket = None
client_socket = None
# Global state
a_device_conn = None
a_device_lock = threading.Lock()
a_device_connected = threading.Event()
def handle_b_device(conn):
"""处理B设备的连接"""
global b_device_socket
b_device_socket = conn
print(f"B设备已连接: {conn.getpeername()}")
# Request tracking
request_queue = {}
request_queue_lock = threading.Lock()
request_counter = 0
request_counter_lock = threading.Lock()
def get_thread_id():
"""Get thread identifier for logging"""
return threading.current_thread().getName()
def get_request_id():
"""Generate unique request ID"""
global request_counter
with request_counter_lock:
request_counter += 1
return "REQ-%06d" % request_counter
def log_info(source, message, line_num=0):
"""Log info message with thread ID"""
thread_id = get_thread_id()
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 log_error(source, message, line_num=0):
"""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 handle_a_device(conn, addr):
"""Handle device A connection - just store and hold"""
global a_device_conn
with a_device_lock:
if a_device_conn:
conn.send(b"ERROR: Another A device is already connected\r\n")
conn.close()
log_info("DeviceA", "Rejected connection from %s:%d" % addr)
return
a_device_conn = conn
log_info("DeviceA", "Sending PROXY_CONNECTED to %s:%d" % addr)
conn.send(b"PROXY_CONNECTED\r\n")
try:
while True:
data = conn.recv(4096)
if not data:
print("B设备断开连接")
b_device_socket = None
break
if client_socket:
try:
client_socket.send(data)
except:
pass
conn.settimeout(30)
log_info("DeviceA", "Waiting for READY from %s:%d" % addr)
response = conn.recv(1024)
log_info("DeviceA", "Received from %s:%d: %r" % (addr[0], addr[1], response))
if response and b"READY" in response:
a_device_connected.set()
log_info("DeviceA", "Connected: %s:%d" % addr)
else:
with a_device_lock:
a_device_conn = None
conn.close()
log_info("DeviceA", "Failed to receive READY from %s:%d" % addr)
return
except Exception as e:
print(f"B设备连接出错: {e}")
b_device_socket = None
def handle_client(conn):
"""处理用户请求"""
global client_socket
client_socket = conn
print(f"用户连接: {conn.getpeername()}")
log_error("DeviceA", "Error receiving READY from %s:%d: %s" % (addr[0], addr[1], str(e)))
with a_device_lock:
a_device_conn = None
conn.close()
return
try:
while True:
data = conn.recv(4096)
if not data:
print("用户断开连接")
client_socket = None
break
event = threading.Event()
event.wait()
except:
pass
finally:
with a_device_lock:
if a_device_conn == conn:
a_device_conn = None
a_device_connected.clear()
conn.close()
log_info("DeviceA", "Disconnected")
def process_response_from_a():
"""Process responses from device A and store in request queue"""
global a_device_conn
while True:
sock = None
try:
a_device_connected.wait()
if b_device_socket:
try:
b_device_socket.send(data)
except:
conn.send(b"HTTP/1.1 503 Service Unavailable\r\n\r\nDevice B not connected")
break
with a_device_lock:
if not a_device_conn:
time.sleep(0.1)
continue
sock = a_device_conn
response = b""
content_length = 0
is_chunked = False
request_id = None
headers_found = False
while True:
chunk = sock.recv(8192)
if not chunk:
if response:
break
log_info("System", "Device A connection closed in response processor")
with a_device_lock:
a_device_conn = None
a_device_connected.clear()
return
response += chunk
if b"\r\n\r\n" in response and not headers_found:
headers_end = response.find(b"\r\n\r\n")
headers = response[:headers_end].decode('utf-8', errors='replace')
headers_found = True
for line in headers.split("\r\n"):
if line.lower().startswith("x-proxy-request-id:"):
request_id = line.split(":", 1)[1].strip()
elif 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 and not is_chunked:
break
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
elif headers_found:
if content_length > 0:
headers_end = response.find(b"\r\n\r\n")
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:
with request_queue_lock:
if request_id in request_queue:
request_queue[request_id]['response'] = response
log_info("System", "Response received for request %s" % request_id)
else:
log_error("System", "No waiting browser for request %s" % request_id)
else:
conn.send(b"HTTP/1.1 503 Service Unavailable\r\n\r\nDevice B not connected")
break
except Exception as e:
print(f"用户连接出错: {e}")
client_socket = None
log_error("System", "Response without request ID")
except socket.timeout:
continue
except Exception as e:
log_error("System", "Error in response processor: %s" % str(e))
time.sleep(0.1)
def run_server():
def handle_browser_request(browser_conn, browser_addr):
"""Handle a request from browser"""
try:
browser_conn.settimeout(30)
data = b""
while True:
chunk = browser_conn.recv(8192)
if not chunk:
break
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
if not data:
return
request_lines = data.decode('utf-8', errors='replace').split("\r\n")
if request_lines:
first_line = request_lines[0]
parts = first_line.split()
if len(parts) >= 2:
method = parts[0]
path = parts[1]
body_size = len(data) - data.find(b"\r\n\r\n") - 4 if b"\r\n\r\n" in data else 0
request_id = get_request_id()
log_info("Browser", "Request: %s %s - Body: %d bytes - ID: %s" % (method, path, body_size, request_id))
with a_device_lock:
if not a_device_conn:
error_response = b"HTTP/1.1 503 Service Unavailable\r\nContent-Type: text/plain\r\n\r\nDevice A not connected\r\n"
browser_conn.sendall(error_response)
log_error("Browser", "Device A not connected for request %s" % request_id)
return
with request_queue_lock:
request_queue[request_id] = {
'browser_conn': browser_conn,
'timestamp': time.time()
}
if b"\r\n\r\n" in data:
headers_end = data.find(b"\r\n\r\n")
headers = data[:headers_end]
body = data[headers_end:]
modified_data = headers + ("\r\nX-Proxy-Request-ID: " + request_id).encode('utf-8') + body
else:
modified_data = data + ("\r\nX-Proxy-Request-ID: " + request_id + "\r\n\r\n").encode('utf-8')
with a_device_lock:
try:
a_device_conn.sendall(modified_data)
log_info("Browser", "Request %s forwarded to Device A" % request_id)
except Exception as e:
log_error("Browser", "Failed to send to Device A for request %s: %s" % (request_id, str(e)))
with request_queue_lock:
if request_id in request_queue:
del request_queue[request_id]
error_response = b"HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain\r\n\r\nFailed to forward request to Device A\r\n"
browser_conn.sendall(error_response)
return
timeout = 30
start_time = time.time()
response = None
while time.time() - start_time < timeout:
with request_queue_lock:
if request_id in request_queue and 'response' in request_queue[request_id]:
response = request_queue[request_id]['response']
del request_queue[request_id]
break
elif request_id not in request_queue:
break
time.sleep(0.1)
if response:
if isinstance(response, str):
response = response.encode('utf-8')
browser_conn.sendall(response)
log_info("Browser", "Response sent for request %s" % request_id)
else:
with request_queue_lock:
if request_id in request_queue:
del request_queue[request_id]
log_error("Browser", "Response timeout for request %s" % request_id)
error_response = b"HTTP/1.1 504 Gateway Timeout\r\nContent-Type: text/plain\r\n\r\nResponse timeout\r\n"
browser_conn.sendall(error_response)
except socket.timeout:
log_error("Browser", "Request timeout from %s:%d" % browser_addr)
error_response = b"HTTP/1.1 504 Gateway Timeout\r\nContent-Type: text/plain\r\n\r\nRequest timeout\r\n"
browser_conn.sendall(error_response)
except Exception as e:
log_error("Browser", "Error handling request: %s" % str(e))
with request_queue_lock:
if request_id in request_queue:
del request_queue[request_id]
def handle_browser_client(conn, addr):
"""Handle browser client connection"""
log_info("Browser", "Connected from: %s:%d" % addr)
handle_browser_request(conn, addr)
log_info("Browser", "Disconnected: %s:%d" % addr)
def run_a_server():
"""Start device A listening port"""
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('0.0.0.0', LISTEN_PORT))
server.listen(5)
print(f"A设备代理服务器已启动,监听端口 {LISTEN_PORT}")
print(f"等待B设备连接和用户请求...")
server.bind(('0.0.0.0', A_PORT))
server.listen(1)
log_info("System", "Device A port started on %d" % A_PORT)
while True:
conn, addr = server.accept()
print(f"新连接: {addr}")
# 判断是B设备还是用户(简单判断:先连接的是B设备)
if b_device_socket is None:
# 第一个连接作为B设备
t = threading.Thread(target=handle_b_device, args=(conn,))
t.start()
else:
# 后续连接作为用户
t = threading.Thread(target=handle_client, args=(conn,))
try:
conn, addr = server.accept()
t = threading.Thread(target=handle_a_device, args=(conn, addr), name="DeviceA")
t.daemon = True
t.start()
except:
break
server.close()
if __name__ == '__main__':
run_server()
def run_browser_server():
"""Start browser listening port"""
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('0.0.0.0', BROWSER_PORT))
server.listen(5)
log_info("System", "Browser port started on %d" % BROWSER_PORT)
while True:
try:
conn, addr = server.accept()
t = threading.Thread(target=handle_browser_client, args=(conn, addr), name="Browser-%d" % threading.activeCount())
t.daemon = True
t.start()
except:
break
server.close()
def main():
print("=" * 60)
print(" Proxy Server (Run on Computer B)")
print("=" * 60)
print("Device A port:", A_PORT)
print("Browser port:", BROWSER_PORT)
print("=" * 60)
print("Waiting for connections...")
print()
sys.stdout.flush()
t1 = threading.Thread(target=run_a_server)
t1.daemon = True
t1.start()
t2 = threading.Thread(target=run_browser_server)
t2.daemon = True
t2.start()
t3 = threading.Thread(target=process_response_from_a, name="ResponseProc")
t3.daemon = True
t3.start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\nShutting down...")
sys.exit(0)
if __name__ == "__main__":
main()
+33
View File
@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
import socket
import threading
def handle_client(conn, addr):
try:
data = conn.recv(8192)
if data:
lines = data.split("\r\n")
if lines:
method, path, _ = lines[0].split()
print("[%s:%d] %s %s" % (addr[0], addr[1], method, path))
response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 48\r\n\r\n<html><body>Hello from test server!</body></html>"
conn.sendall(response)
finally:
conn.close()
def main():
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('127.0.0.1', 8888))
server.listen(5)
print("Test server running on 127.0.0.1:8888")
while True:
conn, addr = server.accept()
t = threading.Thread(target=handle_client, args=(conn, addr))
t.daemon = True
t.start()
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
log/
+11
View File
@@ -0,0 +1,11 @@
pip install -U proxynt
nt_server -c config_s.json
nt_client -c config_c.json
http://<A电脑的IP>:18888/websocket_path/admin
+7
View File
@@ -0,0 +1,7 @@
{
"server": {
"url": "ws://localhost:18888/websocket_path",
"password": "helloworld"
},
"client_name": "fyx"
}
+7
View File
@@ -0,0 +1,7 @@
{
"server": {
"url": "ws://baishe.vps.honor3.com:18888/websocket_path",
"password": "helloworld"
},
"client_name": "cheney"
}
+21
View File
@@ -0,0 +1,21 @@
{
"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.22",
"speed_limit": 0.0,
"protocol": "tcp"
}
]
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"server": {
"url": "ws://121.43.151.143:18888/websocket_path",
"password": "helloworld"
},
"client_name": "fyx"
}
+39
View File
@@ -0,0 +1,39 @@
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));
+41
View File
@@ -0,0 +1,41 @@
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
View File
@@ -0,0 +1,65 @@
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
View File
File diff suppressed because one or more lines are too long
+52
View File
@@ -0,0 +1,52 @@
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 "测试完成"
+40
View File
@@ -0,0 +1,40 @@
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.
Binary file not shown.
+72
View File
@@ -0,0 +1,72 @@
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);
});