744 lines
24 KiB
JavaScript
744 lines
24 KiB
JavaScript
#!/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,
|
||
};
|