Files
2026-07-13 19:57:46 +08:00

744 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const travelerLists = require('./erp_traveler_list');
const ROUTES = [
{
alias: 'team_single',
chinese: '团队-单个下单',
patterns: [/团队\s*[-/]\s*单个下单/, /独立团\s*[-/]\s*单个下单/],
},
{
alias: 'team_batch',
chinese: '团队-批量下单',
patterns: [/团队\s*[-/]\s*批量下单/, /独立团\s*[-/]\s*批量下单/],
},
{
alias: 'split_parent',
chinese: '散拼-创建母团计划',
patterns: [/散拼\s*[-/]\s*创建母团计划/, /散拼.*母团/],
},
{
alias: 'split_child',
chinese: '散拼-拼单/录入子单',
patterns: [/散拼\s*[-/]\s*拼单\s*\/\s*录入子单/, /散拼\s*[-/]\s*拼单/, /散拼.*子单/],
},
];
const FIELD_LABELS = [
'下单模式',
'订单性质',
'预订客户',
'客户/渠道',
'产品名称',
'产品/线路',
'发团日期范围',
'发团周期',
'默认人数',
'默认用房',
'默认单价',
'特殊日期调整',
'特殊调整',
'计调OP',
'跟单人',
'跟团人/OP',
'销售人',
'母团号',
'出发日期',
'渠道/预订客户',
'人数',
'用房',
'单价',
'航班信息',
'大交通/航班信息',
'酒店信息',
'接送机信息',
'游客名单',
'游客信息',
'特殊要求',
'计划收客数',
'每个日期创建母团数量',
'备注',
];
const PAX_KEYS = [
['adult', ['成人', '成人团费']],
['childBed', ['小孩占床', '小孩占床费', '小占']],
['childNoBed', ['小孩不占床', '小孩不占床费', '小不占']],
['infant', ['婴儿']],
['leader', ['领队']],
];
const ROOM_KEYS = [
['SGL', ['SGL']],
['TWN', ['TWN']],
['TRP', ['TRP']],
['DBL', ['DBL']],
['HNM', ['HNM']],
['TL', ['TL']],
];
function normalizeText(input) {
return String(input || '')
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/[﹕︰]/g, '')
.trim();
}
function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getLineValue(text, labels) {
const labelList = Array.isArray(labels) ? labels : [labels];
const lines = normalizeText(text).split('\n');
for (const line of lines) {
for (const label of labelList) {
const regex = new RegExp(`^\\s*${escapeRegex(label)}\\s*[:]\\s*(.*)\\s*$`);
const match = line.match(regex);
if (match) return cleanValue(match[1]);
}
}
return '';
}
function getBlockValue(text, labels) {
const labelList = Array.isArray(labels) ? labels : [labels];
const normalized = normalizeText(text);
for (const label of labelList) {
const nextLabels = FIELD_LABELS.filter((item) => item !== label).map(escapeRegex).join('|');
const regex = new RegExp(`${escapeRegex(label)}\\s*[:]\\s*([\\s\\S]*?)(?=\\n\\s*(?:${nextLabels})\\s*[:]|$)`);
const match = normalized.match(regex);
if (match) return cleanValue(match[1]);
}
return '';
}
function cleanValue(value) {
return String(value || '').trim().replace(/[。;;]+$/g, '').trim();
}
function detectRoute(text) {
const normalized = normalizeText(text);
const modeMatches = [...normalized.matchAll(/^\s*下单模式\s*[:]\s*(.+?)\s*$/gm)];
if (modeMatches.length > 1) {
return { ok: false, conflict: 'multiple_routes', route: null, routeCount: modeMatches.length };
}
const candidates = [];
const source = modeMatches.length === 1 ? modeMatches[0][1] : normalized;
for (const route of ROUTES) {
if (route.patterns.some((pattern) => pattern.test(source))) {
candidates.push(route);
}
}
if (candidates.length === 1) {
return { ok: true, route: candidates[0] };
}
if (candidates.length > 1) {
return { ok: false, conflict: 'multiple_routes', route: null, routeCount: candidates.length };
}
return { ok: false, conflict: 'route_missing', route: null, routeCount: 0 };
}
function numberForLabels(text, labels) {
for (const label of labels) {
const regex = new RegExp(`${escapeRegex(label)}\\s*[:]?\\s*(-?\\d+(?:\\.\\d+)?)`);
const match = String(text || '').match(regex);
if (match) return Number(match[1]);
}
return 0;
}
function parseNumberMap(text, keyDefs) {
const result = {};
for (const [key, labels] of keyDefs) {
result[key] = numberForLabels(text, labels);
}
return result;
}
function parsePax(text) {
return parseNumberMap(text, PAX_KEYS);
}
function parseRooms(text) {
return parseNumberMap(text, ROOM_KEYS);
}
function parsePrices(text) {
return parseNumberMap(text, PAX_KEYS);
}
function nonEmptyLines(text) {
return String(text || '')
.split('\n')
.map((line) => cleanValue(line))
.filter(Boolean);
}
function stripListPrefix(line) {
return cleanValue(String(line || '').replace(/^\s*\d+\s*[.、]\s*/, ''));
}
function parseFlightLine(line) {
const raw = cleanValue(line);
if (!raw) return null;
const match = raw.match(/^\s*(去程|回程|第\d+程)?\s*[:]?\s*(\d{4}-\d{1,2}-\d{1,2})\s*[,:]\s*([^,:\s]+)\s*[,:]\s*([^,:]+?)\s*[,:]\s*(\d{1,2}:\d{2})\s*[-~至]\s*(\d{1,2}:\d{2})/);
if (!match) return { raw };
const route = cleanValue(match[4]);
const routeParts = route.split(/\s*[-—–至到]\s*/).map((part) => cleanValue(part)).filter(Boolean);
return {
label: match[1] || '',
date: match[2].replace(/-(\d)\b/g, '-0$1'),
flightNo: match[3],
from: routeParts[0] || '',
to: routeParts[1] || '',
departTime: match[5],
arriveTime: match[6],
raw,
};
}
function parseFlights(text) {
return nonEmptyLines(text)
.map(parseFlightLine)
.filter(Boolean);
}
function parseTransfer(text) {
const pickupPlace = getLineValue(text, ['接机地点', '接团地点/标志', '接团地点']);
const dropoffPlace = getLineValue(text, ['送机地点', '送机地点/说明']);
const contact = getLineValue(text, ['联系人', '联系人/领队人', '联系人/送机人']);
const phone = getLineValue(text, ['电话', '联系电话']);
return {
pickupPlace,
dropoffPlace,
contact,
phone,
raw: cleanValue(text),
};
}
function parseTravelerLine(line) {
const raw = cleanValue(line);
if (!raw) return null;
const body = stripListPrefix(raw);
const parts = body.split(/\s*\/\s*/).map((part) => cleanValue(part)).filter(Boolean);
const passportMatch = body.match(/护照\s*([A-Z0-9]+)/i);
const phoneMatch = body.match(/电话\s*(\d[\d\s-]*)/);
return {
name: parts[0] || '',
gender: parts[1] || '',
type: parts[2] || '',
passportNo: passportMatch ? passportMatch[1].toUpperCase() : '',
phone: phoneMatch ? phoneMatch[1].replace(/\s+/g, '') : '',
raw,
};
}
function parseTravelers(text) {
return nonEmptyLines(text)
.map(parseTravelerLine)
.filter((traveler) => traveler && (traveler.name || traveler.passportNo || traveler.phone));
}
function parseSpecialRequests(text) {
return nonEmptyLines(text).map(stripListPrefix).filter(Boolean);
}
function hasSupplementalData(supplemental) {
return Boolean(
(supplemental.flights && supplemental.flights.length)
|| (supplemental.hotels && supplemental.hotels.length)
|| (supplemental.travelers && supplemental.travelers.length)
|| (supplemental.specialRequests && supplemental.specialRequests.length)
|| Object.values(supplemental.transfer || {}).some((value) => Boolean(value))
);
}
function extractSupplementalFields(text) {
const supplemental = {
flights: parseFlights(getBlockValue(text, ['航班信息', '大交通/航班信息'])),
hotels: nonEmptyLines(getBlockValue(text, '酒店信息')),
transfer: parseTransfer(getBlockValue(text, '接送机信息')),
travelers: parseTravelers(getBlockValue(text, ['游客名单', '游客信息'])),
specialRequests: parseSpecialRequests(getBlockValue(text, '特殊要求')),
};
return hasSupplementalData(supplemental) ? supplemental : null;
}
function allDates(text) {
return [...String(text || '').matchAll(/\d{4}-\d{2}-\d{2}/g)].map((match) => match[0]);
}
function parseDateRange(text) {
const dates = allDates(text);
if (dates.length >= 2) return { start: dates[0], end: dates[1] };
if (dates.length === 1) return { start: dates[0], end: dates[0] };
return { start: '', end: '' };
}
function addDays(date, days) {
const next = new Date(`${date}T00:00:00Z`);
next.setUTCDate(next.getUTCDate() + days);
return next.toISOString().slice(0, 10);
}
function expandDates(start, end, cycle) {
if (!start || !end) return [];
const dates = [];
let current = start;
while (current <= end) {
if (matchesCycle(current, cycle)) dates.push(current);
current = addDays(current, 1);
}
return dates;
}
function matchesCycle(date, cycle) {
const value = String(cycle || '').trim();
if (!value || /天天|每天/.test(value)) return true;
const day = new Date(`${date}T00:00:00Z`).getUTCDay();
if (/单日/.test(value)) return Number(date.slice(-2)) % 2 === 1;
if (/双日/.test(value)) return Number(date.slice(-2)) % 2 === 0;
const weekMap = [
['日', 0],
['天', 0],
['一', 1],
['二', 2],
['三', 3],
['四', 4],
['五', 5],
['六', 6],
];
const weekly = value.match(/每周([日天一二三四五六])/);
if (weekly) {
const pair = weekMap.find(([label]) => label === weekly[1]);
return pair ? day === pair[1] : false;
}
return true;
}
function hasMultipleProducts(productName) {
return /[、,;/]| 和 | 及 /.test(String(productName || ''));
}
function hasAnyPrice(prices) {
return Object.values(prices || {}).some((value) => {
const number = Number(value);
return Number.isFinite(number) && number !== 0;
});
}
function missingIfEmpty(missing, label, value) {
if (value === undefined || value === null || String(value).trim() === '') missing.push(label);
}
function missingIfNoPositive(missing, label, values) {
if (!values || !Object.values(values).some((value) => Number(value) > 0)) missing.push(label);
}
function extractFields(routeAlias, text) {
const orderNature = getLineValue(text, '订单性质');
const common = {
orderNature,
remark: getBlockValue(text, '备注'),
};
if (routeAlias === 'team_single') {
return {
...common,
bookingCustomer: getLineValue(text, ['预订客户', '客户/渠道', '渠道/预订客户']),
productName: getLineValue(text, '产品名称'),
departureDate: getLineValue(text, '出发日期') || allDates(getLineValue(text, '出发日期'))[0] || '',
pax: parsePax(getBlockValue(text, '人数')),
rooms: parseRooms(getBlockValue(text, '用房')),
prices: parsePrices(getBlockValue(text, '单价')),
op: getLineValue(text, '计调OP'),
salesperson: getLineValue(text, '销售人'),
};
}
if (routeAlias === 'team_batch') {
const rangeText = getLineValue(text, '发团日期范围') || getBlockValue(text, '发团日期范围');
const explicitDateText = getBlockValue(text, '出发日期');
const explicitDates = [...new Set(allDates(explicitDateText))];
const range = explicitDates.length
? { start: explicitDates[0], end: explicitDates[explicitDates.length - 1] }
: parseDateRange(rangeText);
const cycle = getLineValue(text, '发团周期') || (explicitDates.length ? '指定日期' : '');
return {
...common,
bookingCustomer: getLineValue(text, ['预订客户', '客户/渠道', '渠道/预订客户']),
productName: getLineValue(text, '产品名称'),
dateRange: range,
cycle,
departureDates: explicitDates.length ? explicitDates : expandDates(range.start, range.end, cycle),
defaultPax: parsePax(getBlockValue(text, ['默认人数', '人数'])),
defaultRooms: parseRooms(getBlockValue(text, ['默认用房', '用房'])),
defaultPrices: parsePrices(getBlockValue(text, ['默认单价', '单价'])),
op: getLineValue(text, '计调OP'),
follower: getLineValue(text, '跟单人'),
salesperson: getLineValue(text, '销售人'),
specialAdjustmentsText: getBlockValue(text, ['特殊日期调整', '特殊调整']),
};
}
if (routeAlias === 'split_parent') {
const rangeText = getLineValue(text, '发团日期范围') || getBlockValue(text, '发团日期范围');
const range = parseDateRange(rangeText);
const cycle = getLineValue(text, '发团周期');
return {
...common,
productRoute: getLineValue(text, ['产品/线路', '产品名称']),
dateRange: range,
cycle,
departureDates: expandDates(range.start, range.end, cycle),
plannedGuests: Number(getLineValue(text, '计划收客数') || 0),
parentPlansPerDate: Number(getLineValue(text, '每个日期创建母团数量') || 1),
followOp: getLineValue(text, ['跟团人/OP', '跟团人OP']),
};
}
if (routeAlias === 'split_child') {
return {
...common,
parentGroupNo: getLineValue(text, '母团号'),
productRoute: getLineValue(text, ['产品/线路', '产品名称']),
departureDate: getLineValue(text, '出发日期') || allDates(getLineValue(text, '出发日期'))[0] || '',
channelCustomer: getLineValue(text, '渠道/预订客户'),
pax: parsePax(getBlockValue(text, '人数')),
prices: parsePrices(getBlockValue(text, '单价')),
op: getLineValue(text, '计调OP'),
salesperson: getLineValue(text, '销售人'),
supplemental: extractSupplementalFields(text),
};
}
return common;
}
function validate(routeAlias, fields) {
const missing = [];
missingIfEmpty(missing, '订单性质', fields.orderNature);
if (routeAlias === 'team_single') {
missingIfEmpty(missing, '产品名称', fields.productName);
missingIfEmpty(missing, '出发日期', fields.departureDate);
missingIfNoPositive(missing, '人数', fields.pax);
if (!hasAnyPrice(fields.prices)) missing.push('单价');
missingIfEmpty(missing, '计调OP', fields.op);
missingIfEmpty(missing, '销售人', fields.salesperson);
}
if (routeAlias === 'team_batch') {
if (hasMultipleProducts(fields.productName)) {
return { ok: false, conflict: 'team_batch_multiple_products', missingFields: [] };
}
missingIfEmpty(missing, '预订客户', fields.bookingCustomer);
missingIfEmpty(missing, '产品名称', fields.productName);
missingIfEmpty(missing, '发团日期范围或出发日期', fields.departureDates && fields.departureDates.length ? 'ok' : '');
if (!fields.departureDates || !fields.departureDates.length) missingIfEmpty(missing, '发团周期', fields.cycle);
missingIfNoPositive(missing, '默认人数', fields.defaultPax);
missingIfNoPositive(missing, '默认用房', fields.defaultRooms);
if (!hasAnyPrice(fields.defaultPrices)) missing.push('默认单价');
missingIfEmpty(missing, '计调OP', fields.op);
missingIfEmpty(missing, '销售人', fields.salesperson);
}
if (routeAlias === 'split_parent') {
missingIfEmpty(missing, '产品/线路', fields.productRoute);
missingIfEmpty(missing, '发团日期范围', fields.dateRange.start && fields.dateRange.end ? 'ok' : '');
missingIfEmpty(missing, '发团周期', fields.cycle);
if (!fields.plannedGuests) missing.push('计划收客数');
missingIfEmpty(missing, '跟团人/OP', fields.followOp);
}
if (routeAlias === 'split_child') {
if (!fields.parentGroupNo && !fields.productRoute) missing.push('母团号或产品/线路');
missingIfEmpty(missing, '出发日期', fields.departureDate);
missingIfEmpty(missing, '渠道/预订客户', fields.channelCustomer);
missingIfNoPositive(missing, '人数', fields.pax);
if (!hasAnyPrice(fields.prices)) missing.push('单价');
missingIfEmpty(missing, '计调OP', fields.op);
missingIfEmpty(missing, '销售人', fields.salesperson);
}
return { ok: missing.length === 0, missingFields: missing };
}
function buildTask(route, fields, originalText) {
return {
schemaVersion: 'erp-task-v1',
operation: 'create_order',
route: route.alias,
routeName: route.chinese,
orderNature: fields.orderNature,
fields,
erpIdentifiers: {},
delivery: {
returnIdentifiersFirst: true,
customerReceivesPdfOnly: true,
sendPdfFollowUp: false,
deferConfirmationExport: true,
},
originalText,
createdAt: new Date().toISOString(),
};
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function cloneForAudit(result) {
const clone = JSON.parse(JSON.stringify(result || {}));
if (clone.travelerList) {
clone.travelerList = travelerLists.sanitizeTravelerListForAudit(clone.travelerList);
}
const fields = clone.task && clone.task.fields;
if (fields && fields.travelerList) {
fields.travelerList = travelerLists.sanitizeTravelerListForAudit(fields.travelerList);
}
if (fields && fields.supplemental && fields.supplemental.travelerList) {
fields.supplemental.travelerList = travelerLists.sanitizeTravelerListForAudit(fields.supplemental.travelerList);
}
return clone;
}
function writeAudit(result, auditDir) {
ensureDir(auditDir);
const safeRoute = result.route || 'unknown';
const fileName = `${new Date().toISOString().replace(/[:.]/g, '-')}_${safeRoute}.json`;
const auditPath = path.join(auditDir, fileName);
fs.writeFileSync(auditPath, JSON.stringify(cloneForAudit(result), null, 2), 'utf8');
return auditPath;
}
function formatMissingMessage(missingFields) {
return [
'这条订单还缺 ERP 保存必填字段:',
...missingFields.map((field) => `- ${field}`),
'',
'请补充后我再继续自动下单。',
].join('\n');
}
function formatConflictMessage(conflict) {
if (conflict === 'multiple_routes') {
return '这条消息里包含多个下单模式。请一次只发一个下单模式,我再继续自动下单。';
}
if (conflict === 'route_missing') {
return '我还不能判断这条订单的下单模式。请补充“下单模式”,例如:团队-批量下单。';
}
if (conflict === 'team_batch_multiple_products') {
return '这条团队批量下单包含多个产品,但 ERP 批量下单一次只能选择一个产品。请确认本次只下哪个产品,或拆成多个批量下单批次。';
}
return '这条订单存在冲突,请补充确认后我再继续。';
}
function readyMessage(route) {
return `已收到 ${route.chinese} 订单,字段完整,可以进入 ERP 自动下单。`;
}
function normalizeAttachments(value) {
return Array.isArray(value) ? value.filter(Boolean).map(String) : [];
}
function paxForTravelerValidation(routeAlias, fields = {}) {
if (routeAlias === 'team_batch') return fields.defaultPax || {};
return fields.pax || {};
}
function attachTravelerListToFields(fields, list) {
if (!list) return fields;
fields.travelerList = list;
fields.supplemental = fields.supplemental || {};
fields.supplemental.travelerList = list;
if (!Array.isArray(fields.supplemental.travelers) || !fields.supplemental.travelers.length) {
fields.supplemental.travelers = travelerLists.travelerRowsForManualEntry(list);
}
return fields;
}
function handleIncomingOrder(input, options = {}) {
const originalText = normalizeText(input);
const auditDir = options.auditDir || path.join(process.cwd(), 'runtime', 'erp-order-entry', 'audit');
const attachments = normalizeAttachments(options.attachments);
let result;
try {
const routeResult = detectRoute(originalText);
if (!routeResult.ok) {
result = {
status: 'needs_clarification',
route: null,
conflict: routeResult.conflict,
missingFields: [],
task: null,
customerMessage: formatConflictMessage(routeResult.conflict),
originalText,
};
result.auditPath = writeAudit(result, auditDir);
return result;
}
const route = routeResult.route;
const fields = extractFields(route.alias, originalText);
const validation = validate(route.alias, fields);
if (!validation.ok) {
result = {
status: 'needs_clarification',
route: route.alias,
conflict: validation.conflict || null,
missingFields: validation.missingFields || [],
task: null,
customerMessage: validation.conflict
? formatConflictMessage(validation.conflict)
: formatMissingMessage(validation.missingFields),
originalText,
};
result.auditPath = writeAudit(result, auditDir);
return result;
}
if (attachments.length && route.alias !== 'split_parent') {
const traveler = travelerLists.buildTravelerListFromAttachments(
attachments,
paxForTravelerValidation(route.alias, fields)
);
if (traveler.list && !traveler.validation.ok) {
result = {
status: 'needs_clarification',
route: route.alias,
conflict: traveler.validation.reason,
missingFields: [],
task: null,
travelerList: {
...travelerLists.sanitizeTravelerListForAudit(traveler.list),
totalCount: traveler.list.totalCount,
expectedTotal: traveler.validation.orderTotal,
},
customerMessage: traveler.validation.customerMessage,
originalText,
};
result.auditPath = writeAudit(result, auditDir);
return result;
}
if (traveler.list) attachTravelerListToFields(fields, traveler.list);
}
result = {
status: 'ready',
route: route.alias,
conflict: null,
missingFields: [],
task: buildTask(route, fields, originalText),
customerMessage: readyMessage(route),
originalText,
};
result.auditPath = writeAudit(result, auditDir);
return result;
} catch (error) {
result = {
status: 'adapter_error',
route: null,
conflict: null,
missingFields: [],
task: null,
customerMessage: '订单入口处理失败,请稍后重试或转人工处理。',
error: error.message,
originalText,
};
result.auditPath = writeAudit(result, auditDir);
return result;
}
}
function parseArgs(argv) {
const args = {
input: '',
auditDir: '',
attachments: [],
json: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--input') {
args.input = argv[index + 1];
index += 1;
} else if (arg.startsWith('--input=')) {
args.input = arg.slice('--input='.length);
} else if (arg === '--audit-dir') {
args.auditDir = argv[index + 1];
index += 1;
} else if (arg.startsWith('--audit-dir=')) {
args.auditDir = arg.slice('--audit-dir='.length);
} else if (arg === '--attachment') {
args.attachments.push(argv[index + 1]);
index += 1;
} else if (arg.startsWith('--attachment=')) {
args.attachments.push(arg.slice('--attachment='.length));
} else if (arg === '--json') {
args.json = true;
} else if (arg === '--help' || arg === '-h') {
args.help = true;
}
}
return args;
}
function helpText() {
return [
'Usage: node tools/erp_wechat_adapter.js --input order.txt [--attachment traveler.xls] [--audit-dir dir] [--json]',
'',
'Parses one WeChat ERP order message and returns a normalized task or clarification message.',
].join('\n');
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help || !args.input) {
console.log(helpText());
process.exitCode = args.help ? 0 : 1;
return;
}
const inputPath = path.isAbsolute(args.input) ? args.input : path.join(process.cwd(), args.input);
const text = fs.readFileSync(inputPath, 'utf8');
const result = handleIncomingOrder(text, {
auditDir: args.auditDir ? (path.isAbsolute(args.auditDir) ? args.auditDir : path.join(process.cwd(), args.auditDir)) : undefined,
attachments: args.attachments.map((item) => path.isAbsolute(item) ? item : path.join(process.cwd(), item)),
});
if (args.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(result.customerMessage);
console.log(`audit: ${result.auditPath}`);
}
if (result.status === 'adapter_error') process.exitCode = 1;
}
if (require.main === module) {
main();
}
module.exports = {
ROUTES,
normalizeText,
detectRoute,
extractFields,
validate,
handleIncomingOrder,
parsePax,
parseRooms,
parsePrices,
expandDates,
};