#!/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, };