import { BUSINESS_ROUTES, businessRouteById, firstNonEmptyLine, normalizeDirectiveText, resolveBusinessRoute, resolveRouteFieldLabel, type BusinessRouteDefinition, type BusinessRouteId, type ProgramFieldDefinition } from './business-routes.js'; import { validateParsedOperation } from './parser-contract.js'; export const PROGRAM_PARSER_FALLBACK_CODES = Object.freeze(new Set([ 'program_unsupported_syntax', 'program_unknown_field', 'program_internal_error', 'program_timeout', 'program_contract_invalid' ])); const PASSENGER_HEADERS = Object.freeze([ '序号', '姓名', 'NAME', '性别', '出生日期', '出生地', '证件类型', '证件号码', '签发地', '签发日', '有效期', '电话', '备注' ]); export const ALL_EXPORT_TYPES = Object.freeze([ 'xingyou-confirm', 'liantai-confirm', 'job-order', 'visitor-list', 'guide-confirm', 'hotel-preorder', 'transport-preorder', 'filing-current', 'filing-history', 'pickup-sign' ]); const EXPORT_TYPE_ALIASES = new Map([ ['星游客户确认单', 'xingyou-confirm'], ['星游确认单', 'xingyou-confirm'], ['联泰客户确认单', 'liantai-confirm'], ['联泰确认单', 'liantai-confirm'], ['团队申请书', 'job-order'], ['游客名单', 'visitor-list'], ['游客信息', 'visitor-list'], ['导游通知书', 'guide-confirm'], ['导游确认单', 'guide-confirm'], ['酒店预订单', 'hotel-preorder'], ['团体票申请书', 'transport-preorder'], ['大交通预订单', 'transport-preorder'], ['备案确认书', 'filing-current'], ['历史备案确认书', 'filing-history'], ['接机牌', 'pickup-sign'] ]); const SHARED_PLAN_VISITOR_EXPORT_ALIASES = new Set([ '整团游客信息' ]); const NEXT_OCCURRENCE_DATE_ROUTES = new Set([ 'team_order_create', 'team_order_batch_create', 'shared_plan_create', 'shared_child_order_create', 'order_update_shared_plan', 'order_update_shared_child', 'order_update_independent', 'arrangement_hotel_update', 'order_cancel', 'order_restore' ]); type JsonRecord = Record; export interface ProgramHistoryTurn { text: string; receivedAt?: string | Date; } export interface ProgramParseInput { rawText: string; receivedAt?: string | Date; routeId?: BusinessRouteId | null; history?: readonly ProgramHistoryTurn[]; timeoutMs?: number; } export type ProgramParseResult = JsonRecord & { status: 'agent_parse_passed' | 'agent_parse_needs_input' | 'agent_parse_blocked'; blockers: string[]; operation: JsonRecord | null; business_route_id: BusinessRouteId | null; program_parser_version: string; }; interface DateContext { year: number; month: number; day: number; } interface FieldParseSuccess { ok: true; value: unknown; } interface FieldParseFailure { ok: false; message: string; } type FieldParseResult = FieldParseSuccess | FieldParseFailure; interface ExtractedFields { values: Map; conflicts: Map; unknownFields: string[]; ambiguousFields: string[]; unsupportedLines: string[]; multipleRoute: BusinessRouteId | null; } interface BuildSuccess { ok: true; operation: JsonRecord; } interface BuildFailure { ok: false; code: string; message: string; field?: string; } type BuildResult = BuildSuccess | BuildFailure; function isRecord(value: unknown): value is JsonRecord { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); } function compactText(value: unknown): string { return String(value || '').normalize('NFKC').trim(); } function isPlaceholder(value: string): boolean { const normalized = compactText(value); return !normalized || (/^<[^>]+>$/u.test(normalized)) || (/^请(?:填写|输入|补充)/u.test(normalized)); } function dateContext(value: string | Date | undefined): DateContext { const parsed = value instanceof Date ? value : value ? new Date(value) : new Date(); const safe = Number.isNaN(parsed.getTime()) ? new Date() : parsed; const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(safe); const part = (type: string) => Number(parts.find((item) => item.type === type)?.value || 0); return { year: part('year'), month: part('month'), day: part('day') }; } function pad2(value: number): string { return String(value).padStart(2, '0'); } function validDate(year: number, month: number, day: number): boolean { if (year < 2000 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) return false; const date = new Date(Date.UTC(year, month - 1, day)); return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; } function formatDate(year: number, month: number, day: number): string { return `${year}-${pad2(month)}-${pad2(day)}`; } function parseDateParts(rawValue: string): { year: number | null; month: number; day: number } | null { const value = compactText(rawValue) .replace(/[年月]/gu, '-') .replace(/日/gu, '') .replace(/[/.]/gu, '-') .replace(/\s+/gu, ''); const full = value.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/u); if (full) return { year: Number(full[1]), month: Number(full[2]), day: Number(full[3]) }; const short = value.match(/^(\d{1,2})-(\d{1,2})$/u); if (short) return { year: null, month: Number(short[1]), day: Number(short[2]) }; return null; } function parseDate(rawValue: string, context: DateContext, { nextOccurrence = false, referenceYear }: { nextOccurrence?: boolean; referenceYear?: number } = {}): FieldParseResult { const parts = parseDateParts(rawValue); if (!parts) return { ok: false, message: '请使用 YYYY-MM-DD 或 MM-DD 日期格式。' }; let year = parts.year ?? referenceYear ?? context.year; if (parts.year === null && referenceYear === undefined && nextOccurrence) { if (parts.month < context.month || (parts.month === context.month && parts.day < context.day)) year += 1; } if (!validDate(year, parts.month, parts.day)) return { ok: false, message: '日期不存在或超出支持范围。' }; return { ok: true, value: formatDate(year, parts.month, parts.day) }; } function parseDateRange(rawValue: string, context: DateContext, nextOccurrence: boolean): FieldParseResult { const pieces = compactText(rawValue).split(/\s*(?:到|至|~|~|—|–)\s*/u).filter(Boolean); if (pieces.length !== 2) return { ok: false, message: '日期范围必须包含明确的开始日期和结束日期。' }; const startResult = parseDate(pieces[0], context, { nextOccurrence }); if (!startResult.ok) return startResult; const start = String(startResult.value); const startYear = Number(start.slice(0, 4)); const endParts = parseDateParts(pieces[1]); if (!endParts) return { ok: false, message: '结束日期格式无效。' }; let endYear = endParts.year ?? startYear; let endResult = parseDate(pieces[1], context, { referenceYear: endYear }); if (!endResult.ok) return endResult; if (String(endResult.value) < start && endParts.year === null) { endYear += 1; endResult = parseDate(pieces[1], context, { referenceYear: endYear }); if (!endResult.ok) return endResult; } if (String(endResult.value) < start) return { ok: false, message: '结束日期不能早于开始日期。' }; return { ok: true, value: { start_date: start, end_date: String(endResult.value) } }; } function parseDateOrRange(rawValue: string, context: DateContext, nextOccurrence: boolean): FieldParseResult { if (/(?:到|至|~|~|—|–)/u.test(rawValue)) return parseDateRange(rawValue, context, nextOccurrence); const single = parseDate(rawValue, context, { nextOccurrence }); return single.ok ? { ok: true, value: [single.value] } : single; } function parseDateList(rawValue: string, context: DateContext, nextOccurrence: boolean, range?: JsonRecord): FieldParseResult { const pieces = compactText(rawValue).split(/[\/、,,;;\s]+/u).filter(Boolean); if (!pieces.length) return { ok: false, message: '请至少填写一个日期。' }; const values: string[] = []; const rangeStart = typeof range?.start_date === 'string' ? range.start_date : ''; const rangeEnd = typeof range?.end_date === 'string' ? range.end_date : ''; for (const piece of pieces) { const parts = parseDateParts(piece); let result = parseDate(piece, context, { nextOccurrence: !rangeStart && nextOccurrence, referenceYear: rangeStart && parts?.year === null ? Number(rangeStart.slice(0, 4)) : undefined }); if (result.ok && rangeStart && rangeEnd && String(result.value) < rangeStart && parts?.year === null) { result = parseDate(piece, context, { referenceYear: Number(rangeStart.slice(0, 4)) + 1 }); } if (!result.ok) return result; const date = String(result.value); if (rangeStart && (date < rangeStart || date > rangeEnd)) { return { ok: false, message: `日期 ${date} 不在填写的发团范围内。` }; } if (values.includes(date)) return { ok: false, message: `日期 ${date} 重复。` }; values.push(date); } values.sort(); return { ok: true, value: values }; } function namedRef(rawValue: string): JsonRecord { const keyword = compactText(rawValue); return { name: keyword.replace(/人名币/gu, '人民币'), keyword }; } function positiveInteger(rawValue: string): FieldParseResult { const normalized = compactText(rawValue).replace(/(?:人|位|个|间|辆|份|张)$/u, ''); if (!/^\d+$/u.test(normalized) || Number(normalized) < 1) return { ok: false, message: '必须填写正整数。' }; return { ok: true, value: Number(normalized) }; } function booleanValue(rawValue: string): FieldParseResult { const value = compactText(rawValue).toLowerCase(); if (['是', '确认', '已确认', 'true', 'yes', 'y', '1'].includes(value)) return { ok: true, value: true }; if (['否', '不', '不覆盖', 'false', 'no', 'n', '0'].includes(value)) return { ok: true, value: false }; return { ok: false, message: '请明确填写“是”或“否”。' }; } function parsePassengerCounts(rawValue: string): FieldParseResult { const value = compactText(rawValue) .replace(/[((]\s*占床?\s*[))]/gu, '占') .replace(/[((]\s*不占床?\s*[))]/gu, '不占') .replace(/\s+/gu, ''); const explicit: JsonRecord = {}; const explicitPattern = /(不占床儿童|儿童不占床|小孩不占床|占床儿童|儿童占床|小孩占床|成人|大人|婴儿|领队)[::]?(\d+)(?:人|位)?/gu; const explicitKeys = new Map([ ['成人', 'adult'], ['大人', 'adult'], ['占床儿童', 'child_bed'], ['儿童占床', 'child_bed'], ['小孩占床', 'child_bed'], ['不占床儿童', 'child_no_bed'], ['儿童不占床', 'child_no_bed'], ['小孩不占床', 'child_no_bed'], ['婴儿', 'infant'], ['领队', 'leader'] ]); const explicitMatches = [...value.matchAll(explicitPattern)]; for (const match of explicitMatches) { const key = explicitKeys.get(match[1])!; if (explicit[key] !== undefined) return { ok: false, message: `人数分类 ${match[1]} 重复。` }; const count = Number(match[2]); if (!Number.isSafeInteger(count)) return { ok: false, message: `人数分类 ${match[1]} 必须是安全范围内的非负整数。` }; explicit[key] = count; } if (Object.keys(explicit).length) { const residue = value.replace(explicitPattern, '').replace(/[++、,,;;]/gu, ''); if (residue) return { ok: false, message: `包含无法识别的人数内容:${residue}。` }; const total = Object.values(explicit).reduce((sum, item) => sum + Number(item), 0); return total > 0 && Number.isSafeInteger(total) ? { ok: true, value: explicit } : { ok: false, message: '人数合计必须大于 0 且在安全整数范围内。' }; } const pieces = value.split(/[++]/u); if (!pieces.length || pieces.length > 5 || pieces.some((piece) => !piece)) { return { ok: false, message: '无法识别人数分类格式。' }; } if (pieces.some((piece) => !/^\d+(?:占|不占)?$/u.test(piece))) { return { ok: false, message: '人数只能使用数字及“占/不占”分类。' }; } const childKind = (piece: string): 'child_bed' | 'child_no_bed' | null => { if (/不占$/u.test(piece)) return 'child_no_bed'; if (/占$/u.test(piece)) return 'child_bed'; return null; }; const taggedChildPair = pieces.length >= 4 && childKind(pieces[1]) !== null && childKind(pieces[2]) !== null && childKind(pieces[1]) !== childKind(pieces[2]); const validLongDistribution = pieces.length >= 4 && (/^\d+$/u.test(pieces[0])) && (pieces.every((piece) => /^\d+$/u.test(piece)) || (taggedChildPair && pieces.slice(3).every((piece) => /^\d+$/u.test(piece)))); if (!/^\d+$/u.test(pieces[0]) || (pieces.length === 3 && (!/^\d+(?:占|不占)$/u.test(pieces[1]) || !/^\d+$/u.test(pieces[2]))) || (pieces.length >= 4 && !validLongDistribution)) { return { ok: false, message: '人数分类位置无效,请按成人+占/不占儿童+领队,或成人+占床儿童+不占床儿童+领队填写。' }; } const numbers = pieces.map((piece) => Number(piece.match(/^\d+/u)?.[0])); if (numbers.some((number) => !Number.isSafeInteger(number) || number < 0)) return { ok: false, message: '人数必须是安全范围内的非负整数。' }; const result: JsonRecord = { adult: numbers[0] }; if (pieces.length === 2) { if (/不占/u.test(pieces[1])) result.child_no_bed = numbers[1]; else if (/占/u.test(pieces[1])) result.child_bed = numbers[1]; else result.leader = numbers[1]; } else if (pieces.length === 3) { if (/不占/u.test(pieces[1])) result.child_no_bed = numbers[1]; else if (/占/u.test(pieces[1])) result.child_bed = numbers[1]; else return { ok: false, message: '三段人数的第二段必须注明“占”或“不占”。' }; result.leader = numbers[2]; } else if (pieces.length === 4) { if (taggedChildPair) { result[childKind(pieces[1])!] = numbers[1]; result[childKind(pieces[2])!] = numbers[2]; } else { result.child_bed = numbers[1]; result.child_no_bed = numbers[2]; } result.leader = numbers[3]; } else if (pieces.length === 5) { if (taggedChildPair) { result[childKind(pieces[1])!] = numbers[1]; result[childKind(pieces[2])!] = numbers[2]; } else { result.child_bed = numbers[1]; result.child_no_bed = numbers[2]; } result.infant = numbers[3]; result.leader = numbers[4]; } const total = Object.values(result).reduce((sum, item) => sum + Number(item), 0); return total > 0 && Number.isSafeInteger(total) ? { ok: true, value: result } : { ok: false, message: '人数合计必须大于 0 且在安全整数范围内。' }; } const ROOM_CODES = new Map([ ['SGL', 'SGL'], ['单', 'SGL'], ['单间', 'SGL'], ['单人间', 'SGL'], ['TWN', 'TWN'], ['标', 'TWN'], ['标间', 'TWN'], ['双标间', 'TWN'], ['TRP', 'TRP'], ['三', 'TRP'], ['三人间', 'TRP'], ['DBL', 'DBL'], ['双', 'DBL'], ['双人间', 'DBL'], ['大床', 'DBL'], ['大床房', 'DBL'] ]); function parseRoomCounts(rawValue: string): FieldParseResult { const value = compactText(rawValue).replace(/\s+/gu, ''); const result: JsonRecord = {}; const consumed: string[] = []; const pattern = /(\d+)\s*(SGL|TWN|TRP|DBL|单人间|单间|单|双标间|标间|标|三人间|三|双人间|大床房|大床|双)/giu; for (const match of value.matchAll(pattern)) { const code = ROOM_CODES.get(match[2].toUpperCase()) || ROOM_CODES.get(match[2]); if (!code) continue; const count = Number(match[1]); if (count < 1 || result[code] !== undefined) return { ok: false, message: '房型数量必须是正整数且同一房型不能重复。' }; result[code] = count; consumed.push(match[0]); } if (!Object.keys(result).length) return { ok: false, message: '无法识别房型,请使用“8标1单”或“3SGL+2TWN”。' }; let residue = value; for (const item of consumed) residue = residue.replace(item, ''); residue = residue.replace(/[++、,,;;间房]/gu, ''); if (residue) return { ok: false, message: `包含无法识别的房型内容:${residue}。` }; return { ok: true, value: result }; } function parsePassengerTsv(rawValue: string): FieldParseResult { const text = String(rawValue || '').replace(/\r\n?/gu, '\n').replace(/^\n+|\n+$/gu, ''); const lines = text.split('\n').filter((line) => line.length > 0); if (lines.length < 2) return { ok: false, message: '名单必须包含规定表头和至少一行游客。' }; const headers = lines[0].split('\t').map((cell) => cell.trim()); if (headers.length !== PASSENGER_HEADERS.length || headers.some((header, index) => header !== PASSENGER_HEADERS[index])) { return { ok: false, message: '名单必须使用规定的 13 列 TSV 表头和顺序。' }; } const sequence = new Set(); let leaderContact: JsonRecord | undefined; for (let index = 1; index < lines.length; index += 1) { const cells = lines[index].split('\t'); if (cells.length !== PASSENGER_HEADERS.length) return { ok: false, message: `名单第 ${index} 行不是完整 13 列。` }; const rowNo = Number(cells[0].trim()); if (!Number.isInteger(rowNo) || rowNo < 1) return { ok: false, message: `名单第 ${index} 行序号必须是正整数。` }; if (sequence.has(rowNo)) return { ok: false, message: `名单序号 ${rowNo} 重复。` }; sequence.add(rowNo); if (compactText(cells[12]) === '领队') { if (leaderContact) return { ok: false, message: '名单备注列只能标记一位领队。' }; const name = String(cells[1] || '').trim(); const phone = String(cells[11] || '').trim(); if (!name || !phone) return { ok: false, message: `名单第 ${index} 行标记为领队时,姓名和电话都必须填写。` }; if (name.length > 200 || phone.length > 200) return { ok: false, message: `名单第 ${index} 行领队姓名或电话超过长度限制。` }; leaderContact = { sequence: rowNo, name, phone }; } } return { ok: true, value: { text, row_count: lines.length - 1, ...(leaderContact ? { leader_contact: leaderContact } : {}) } }; } function parseExportType(rawValue: string): FieldParseResult { const pieces = compactText(rawValue).split(/[\/、,,;;++]/u).map((item) => item.trim()).filter(Boolean); if (pieces.some((piece) => SHARED_PLAN_VISITOR_EXPORT_ALIASES.has(piece))) { if (pieces.length !== 1) return { ok: false, message: '整团游客信息必须单独导出,不能与游客名单或其他文件类型混用。' }; return { ok: true, value: { types: ['visitor-list'], target_kind: 'shared_plan' } }; } if (pieces.length === 1 && pieces[0] === '全部') return { ok: true, value: { types: [...ALL_EXPORT_TYPES] } }; const values: string[] = []; for (const piece of pieces) { const mapped = EXPORT_TYPE_ALIASES.get(piece) || (ALL_EXPORT_TYPES.includes(piece) ? piece : ''); if (!mapped) return { ok: false, message: `无法识别文件类型“${piece}”。` }; if (!values.includes(mapped)) values.push(mapped); } return values.length ? { ok: true, value: { types: values } } : { ok: false, message: '请填写文件类型。' }; } function splitControlledClauses(rawValue: string): string[] { const primary = compactText(rawValue).split(/[;;\n]+/u).map((item) => item.trim()).filter(Boolean); const output: string[] = []; for (const item of primary) { const commaPieces = item.split(/[,,]/u).map((piece) => piece.trim()).filter(Boolean); if (commaPieces.length > 1 && commaPieces.every((piece) => /(?:改为|设为|调整为|=|:|:)/u.test(piece))) output.push(...commaPieces); else output.push(item); } return output; } function controlledAssignment(clause: string): { target: string; value: string } | null { const match = clause.match(/^(.+?)(?:调整为|变更为|修改为|改为|设为|=|:|:)\s*(.+)$/u); const target = compactText(match?.[1] || '').replace(/\s+/gu, ''); const value = compactText(match?.[2] || ''); return target && value ? { target, value } : null; } function parseIndependentUpdates(rawValue: string, context: DateContext, nextOccurrence = false): BuildResult { const actions: JsonRecord[] = []; const push = (target: string, operation: string, value: unknown): BuildFailure | null => { if (actions.some((item) => item.target === target)) return { ok: false, code: 'program_field_conflict', message: `修改目标 ${target} 重复。`, field: 'update_targets' }; actions.push({ target, operation, value }); return null; }; for (const clauseValue of splitControlledClauses(rawValue)) { const clause = clauseValue.replace(/^将/u, '').trim(); const assignment = controlledAssignment(clause); if (!assignment) return { ok: false, code: 'program_unsupported_syntax', message: `无法识别修改表达“${clause}”。`, field: 'update_targets' }; const { target, value } = assignment; if (/^(?:人数|预估人数|游客人数)$/u.test(target)) { const counts = parsePassengerCounts(value); if (!counts.ok) return { ok: false, code: 'program_invalid_value', message: counts.message, field: 'update_targets' }; for (const [key, count] of Object.entries(counts.value as JsonRecord)) { const conflict = push(`pax.${key}`, 'set', count); if (conflict) return conflict; } continue; } if (/^(?:用房数量|房型数量|用房|房间)$/u.test(target)) { const rooms = parseRoomCounts(value); if (!rooms.ok) return { ok: false, code: 'program_invalid_value', message: rooms.message, field: 'update_targets' }; for (const [key, count] of Object.entries(rooms.value as JsonRecord)) { const conflict = push(`rooms.${key}`, 'set', count); if (conflict) return conflict; } continue; } const numberTargets: Array<[RegExp, string, number]> = [ [/^(?:标间数|双标间数|twin_room_count)$/iu, 'twin_room_count', 1], [/^(?:行程天数|天数|trip_days)$/iu, 'trip_days', 1], [/^(?:成人数|成人|pax\.adult)$/iu, 'pax.adult', 0], [/^(?:占床儿童数|占床儿童|pax\.child_bed)$/iu, 'pax.child_bed', 0], [/^(?:不占床儿童数|不占床儿童|pax\.child_no_bed)$/iu, 'pax.child_no_bed', 0], [/^(?:婴儿数|婴儿|pax\.infant)$/iu, 'pax.infant', 0], [/^(?:领队人数|pax\.leader)$/iu, 'pax.leader', 0], [/^(?:预计总人数|pax\.expected_total)$/iu, 'pax.expected_total', 0], [/^(?:单人间数|SGL|rooms\.SGL)$/iu, 'rooms.SGL', 0], [/^(?:双标间数|TWN|rooms\.TWN)$/iu, 'rooms.TWN', 0], [/^(?:三人间数|TRP|rooms\.TRP)$/iu, 'rooms.TRP', 0], [/^(?:大床房数|DBL|rooms\.DBL)$/iu, 'rooms.DBL', 0], [/^(?:不占床房数|HNM|rooms\.HNM)$/iu, 'rooms.HNM', 0], [/^(?:领队房数|TL|rooms\.TL)$/iu, 'rooms.TL', 0] ]; const numberTarget = numberTargets.find(([pattern]) => pattern.test(target)); if (numberTarget) { const normalized = compactText(value).replace(/(?:人|位|个|间|天)$/u, ''); if (!/^\d+$/u.test(normalized) || Number(normalized) < numberTarget[2]) { return { ok: false, code: 'program_invalid_value', message: `修改目标必须是大于等于 ${numberTarget[2]} 的整数。`, field: 'update_targets' }; } const conflict = push(numberTarget[1], 'set', Number(normalized)); if (conflict) return conflict; continue; } if (/^(?:出发日期|发团日期|departure_date)$/iu.test(target)) { const parsed = parseDate(value, context, { nextOccurrence }); if (!parsed.ok) return { ok: false, code: 'program_invalid_value', message: parsed.message, field: 'update_targets' }; const conflict = push('departure_date', 'set', parsed.value); if (conflict) return conflict; continue; } const namedTargets: Array<[RegExp, string]> = [ [/^(?:预订客户|预定客户|客户|customer)$/iu, 'customer'], [/^(?:产品名称|产品|product)$/iu, 'product'], [/^(?:领队|leader)$/iu, 'leader'], [/^(?:线路|行程线路|route)$/iu, 'route'] ]; const namedTarget = namedTargets.find(([pattern]) => pattern.test(target)); if (namedTarget) { const conflict = push(namedTarget[1], 'set', namedRef(value)); if (conflict) return conflict; continue; } const textTargets: Array<[RegExp, string]> = [ [/^(?:状态|status)(?:追加|补充)?$/iu, 'status'], [/^(?:备注|remark)(?:追加|补充)?$/iu, 'remark'], [/^(?:下单备注|xiadanbeizhu)(?:追加|补充)?$/iu, 'xiadanbeizhu'], [/^(?:订房说明|booking_note)(?:追加|补充)?$/iu, 'booking_note'] ]; const textTarget = textTargets.find(([pattern]) => pattern.test(target)); if (textTarget) { const operation = /(?:追加|补充)/u.test(target) ? 'append' : 'set'; const conflict = push(textTarget[1], operation, value); if (conflict) return conflict; continue; } return { ok: false, code: 'program_unsupported_syntax', message: `未登记的独立团修改目标:“${clause}”。`, field: 'update_targets' }; } return actions.length ? actions.length <= 32 ? { ok: true, operation: { actions } } : { ok: false, code: 'program_invalid_value', message: '一次最多允许 32 个修改目标。', field: 'update_targets' } : { ok: false, code: 'program_unsupported_syntax', message: '目标修改中没有可执行字段。', field: 'update_targets' }; } function parseHotelChanges(rawValue: string, context: DateContext, nextOccurrence = false): BuildResult { const changes: JsonRecord = {}; for (const clause of splitControlledClauses(rawValue)) { const assignment = controlledAssignment(clause); if (!assignment) return { ok: false, code: 'program_unsupported_syntax', message: `无法识别变更表达“${clause}”。`, field: 'hotel_changes' }; const { target, value } = assignment; if (/^(?:离店日期|退房日期)$/u.test(target)) { if (changes.end_date !== undefined) return { ok: false, code: 'program_field_conflict', message: '离店日期重复。', field: 'hotel_changes' }; const parsed = parseDate(value, context, { nextOccurrence }); if (!parsed.ok) return { ok: false, code: 'program_invalid_value', message: parsed.message, field: 'hotel_changes' }; changes.end_date = parsed.value; } else if (/^(?:房间数|房间总数|间数)$/u.test(target)) { if (changes.room_count !== undefined) return { ok: false, code: 'program_field_conflict', message: '房间数重复。', field: 'hotel_changes' }; const parsed = positiveInteger(value); if (!parsed.ok) return { ok: false, code: 'program_invalid_value', message: parsed.message, field: 'hotel_changes' }; changes.room_count = parsed.value; } else { return { ok: false, code: 'program_business_rule_blocked', message: '安排变更当前只支持酒店离店日期和房间数。', field: 'hotel_changes' }; } } return Object.keys(changes).length ? { ok: true, operation: changes } : { ok: false, code: 'program_invalid_value', message: '请填写离店日期和/或目标房间数。', field: 'hotel_changes' }; } function normalizedComparableValue(value: string): string { return compactText(value).replace(/\r\n?/gu, '\n'); } function simpleRequiredKeys(route: BusinessRouteDefinition, values: Map): string[] { const missing = route.fields.filter((field) => field.required && isPlaceholder(values.get(field.key) || '')).map((field) => field.key); if (['passenger_list_import_independent', 'passenger_list_import_shared_child', 'order_update_shared_child', 'order_update_independent', 'order_cancel', 'order_restore', 'confirmation_export'].includes(route.routeId) && !isPlaceholder(values.get('order_no') || '')) { return missing.filter((key) => !['customer', 'departure_date'].includes(key)); } if (route.routeId === 'order_update_shared_plan' && !isPlaceholder(values.get('order_no') || '')) { return missing.filter((key) => key !== 'departure_date'); } return missing; } function extractFields(route: BusinessRouteDefinition, turns: readonly ProgramHistoryTurn[]): ExtractedFields { const values = new Map(); const conflicts = new Map(); const unknownFields: string[] = []; const ambiguousFields: string[] = []; const unsupportedLines: string[] = []; let multipleRoute: BusinessRouteId | null = null; for (const turn of turns) { const lines = String(turn.text || '').replace(/^\uFEFF/u, '').replace(/\r\n?/gu, '\n').split('\n'); const nonEmptyIndex = lines.findIndex((line) => line.trim()); if (nonEmptyIndex >= 0) { const resolution = resolveBusinessRoute(lines[nonEmptyIndex]); if (resolution.routeId) { if (resolution.routeId !== route.routeId) multipleRoute = resolution.routeId; lines.splice(nonEmptyIndex, 1); } else if (normalizeDirectiveText(lines[nonEmptyIndex]) === normalizeDirectiveText(route.directive)) { lines.splice(nonEmptyIndex, 1); } } const turnValues = new Map(); const turnConflicts = new Map(); const unlabeled: string[] = []; for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; if (!line.trim()) continue; const delimiter = line.search(/[::]/u); if (delimiter < 0) { const embeddedRoute = resolveBusinessRoute(line); if (embeddedRoute.routeId) { if (embeddedRoute.routeId !== route.routeId) multipleRoute = embeddedRoute.routeId; continue; } unlabeled.push(line); continue; } const label = line.slice(0, delimiter); const resolution = resolveRouteFieldLabel(route, label); if (!resolution.field) { const embeddedRoute = resolveBusinessRoute(label); if (embeddedRoute.routeId && embeddedRoute.routeId !== route.routeId) { multipleRoute = embeddedRoute.routeId; continue; } if (resolution.match === 'ambiguous') ambiguousFields.push(label.trim()); else unknownFields.push(label.trim()); continue; } let rawValue = line.slice(delimiter + 1).trim(); if (resolution.field.kind === 'passenger_tsv') { const rows: string[] = rawValue ? [rawValue] : []; let nextIndex = index + 1; for (; nextIndex < lines.length; nextIndex += 1) { const nextLine = lines[nextIndex]; const nextDelimiter = nextLine.search(/[::]/u); if (nextDelimiter >= 0) { const nextResolution = resolveRouteFieldLabel(route, nextLine.slice(0, nextDelimiter)); if (nextResolution.field) break; } if (nextLine.length) rows.push(nextLine); } rawValue = rows.join('\n').replace(/^\n+|\n+$/gu, ''); index = nextIndex - 1; } const previous = turnValues.get(resolution.field.key); if (previous !== undefined && normalizedComparableValue(previous) !== normalizedComparableValue(rawValue)) { turnConflicts.set(resolution.field.key, [previous, rawValue]); } else { turnValues.set(resolution.field.key, rawValue); } } const meaningfulUnlabeled = unlabeled.map((line) => line.trim()).filter(Boolean); if (meaningfulUnlabeled.length) { const merged = new Map(values); for (const [key, value] of turnValues) merged.set(key, value); const missing = simpleRequiredKeys(route, merged); if (meaningfulUnlabeled.length === 1 && missing.length === 1 && !turnValues.has(missing[0])) { turnValues.set(missing[0], meaningfulUnlabeled[0]); } else { unsupportedLines.push(...meaningfulUnlabeled); } } for (const [key, value] of turnValues) { values.set(key, value); // A later explicit value resolves a conflict from an earlier turn. A // conflict created inside this same turn is restored immediately below. conflicts.delete(key); } for (const [key, conflictValues] of turnConflicts) conflicts.set(key, conflictValues); } return { values, conflicts, unknownFields, ambiguousFields, unsupportedLines, multipleRoute }; } function commonLookupData(values: Map, kind?: string): JsonRecord { const data: JsonRecord = {}; const identifier = compactText(values.get('order_no')); if (identifier || kind) data.existing_refs = { ...(kind ? { kind } : {}), ...(identifier ? { identifier } : {}) }; if (values.get('customer')) data.customer = values.get('customer'); if (values.get('product')) data.product = values.get('product'); if (values.get('leader')) data.leader = values.get('leader'); if (values.get('departure_date')) data.departure_dates = [values.get('departure_date')]; return data; } function specialRequests(values: Map): string | undefined { const pieces: string[] = []; if (values.get('special_requests')) pieces.push(String(values.get('special_requests'))); if (values.get('business_reason')) pieces.push(`业务原因:${String(values.get('business_reason'))}`); if (values.get('expected_time')) pieces.push(`期望完成时间:${String(values.get('expected_time'))}`); return pieces.length ? pieces.join(';') : undefined; } function operation(action: string, data: JsonRecord): JsonRecord { return { action, order_nature: 'formal', submit_mode: 'dry_run', data }; } function buildOperation(route: BusinessRouteDefinition, values: Map, context: DateContext): BuildResult { const special = specialRequests(values); switch (route.routeId) { case 'team_order_create': return { ok: true, operation: operation(route.action, { customer: values.get('customer'), product: values.get('product'), departure_dates: [values.get('departure_date')], passenger_counts: values.get('passenger_counts'), room_counts: values.get('room_counts'), ...(special ? { special_requests: special } : {}) }) }; case 'team_order_batch_create': if (!Array.isArray(values.get('departure_dates')) || (values.get('departure_dates') as unknown[]).length < 2) { return { ok: false, code: 'program_invalid_value', message: '独立团批量下单至少需要两个发团周期日期。', field: 'departure_dates' }; } return { ok: true, operation: operation(route.action, { customer: values.get('customer'), product: values.get('product'), departure_dates: values.get('departure_dates'), recurrence: { ...(values.get('departure_range') as JsonRecord), pattern: 'specified_dates' }, passenger_counts: values.get('passenger_counts'), room_counts: values.get('room_counts'), ...(special ? { special_requests: special } : {}) }) }; case 'shared_plan_create': { const source = values.get('departure_source'); const sourceIsRange = isRecord(source); const dates = values.get('departure_dates') || (Array.isArray(source) ? source : undefined); if (sourceIsRange && !dates) return { ok: false, code: 'program_missing_required', message: '日期范围下必须填写发团周期。', field: 'departure_dates' }; const splitOrder = values.get('split_customer') || values.get('split_passenger_counts') ? { ...(values.get('split_customer') ? { customer: values.get('split_customer') } : {}), ...(values.get('split_passenger_counts') ? { passenger_counts: values.get('split_passenger_counts') } : {}) } : undefined; return { ok: true, operation: operation(route.action, { product: values.get('product'), ...(dates ? { departure_dates: dates } : {}), ...(sourceIsRange ? { recurrence: { ...(source as JsonRecord), pattern: 'specified_dates' } } : {}), planned_capacity: values.get('planned_capacity'), room_counts: values.get('room_counts'), ...(splitOrder ? { split_order: splitOrder } : {}) }) }; } case 'shared_child_order_create': { const data: JsonRecord = { ...commonLookupData(new Map([ ['customer', values.get('customer')], ['product', values.get('product')], ['leader', values.get('leader')], ['departure_date', values.get('departure_date')] ].filter((entry) => entry[1] !== undefined) as Array<[string, unknown]>)), passenger_counts: values.get('passenger_counts'), ...(special ? { special_requests: special } : {}) }; const parent = compactText(values.get('parent_group_no')); if (parent) data.existing_refs = { kind: 'shared_plan', identifier: parent, parent_group_no: parent }; return { ok: true, operation: operation(route.action, data) }; } case 'passenger_list_import_independent': case 'passenger_list_import_shared_child': { const parsedList = values.get('passenger_tsv') as JsonRecord; const overwrite = values.get('overwrite_confirmed') === true; return { ok: true, operation: operation(route.action, { ...commonLookupData(values, route.routeId === 'passenger_list_import_independent' ? 'independent_order' : 'shared_child_order'), passenger_list: { operation: overwrite ? 'full_replace' : 'import', ...(overwrite ? { confirmed: true } : {}), row_count: parsedList.row_count, text: parsedList.text, ...(parsedList.leader_contact ? { leader_contact: parsedList.leader_contact } : {}) } }) }; } case 'arrangement_guide_create': return { ok: true, operation: operation(route.action, { existing_refs: { identifier: values.get('group_no') }, arrangement: { mode: 'create', resource: values.get('resource'), ...(values.get('remark') ? { remark: values.get('remark') } : {}) } }) }; case 'arrangement_vehicle_create': { const range = values.get('vehicle_range') as JsonRecord; return { ok: true, operation: operation(route.action, { existing_refs: { identifier: values.get('group_no') }, arrangement: { mode: 'create', supplier: values.get('supplier'), start_date: range.start_date, end_date: range.end_date, quantity: values.get('quantity'), ...(values.get('remark') ? { remark: values.get('remark') } : {}) } }) }; } case 'arrangement_hotel_create': if (String(values.get('end_date')) <= String(values.get('start_date'))) { return { ok: false, code: 'program_invalid_value', message: '酒店离店日期必须晚于入住日期。', field: 'end_date' }; } return { ok: true, operation: operation(route.action, { existing_refs: { identifier: values.get('group_no') }, arrangement: { mode: 'create', resource: values.get('resource'), start_date: values.get('start_date'), end_date: values.get('end_date'), room_count: values.get('room_count'), ...(values.get('remark') ? { remark: values.get('remark') } : {}) } }) }; case 'arrangement_transport_create': return { ok: true, operation: operation(route.action, { existing_refs: { identifier: values.get('group_no') }, arrangement: { mode: 'create', supplier: values.get('supplier'), quantity: values.get('quantity'), date: values.get('date'), ...(values.get('item') ? { item: values.get('item') } : {}), ...(values.get('remark') ? { remark: values.get('remark') } : {}) } }) }; case 'arrangement_other_create': { const filing = { ...(values.get('filing_number') ? { number: values.get('filing_number') } : {}), ...(values.get('filing_date') ? { date: values.get('filing_date') } : {}), ...(values.get('entry_port') ? { entry_port: values.get('entry_port') } : {}), ...(values.get('exit_port') ? { exit_port: values.get('exit_port') } : {}) }; return { ok: true, operation: operation(route.action, { existing_refs: { identifier: values.get('group_no') }, arrangement: { mode: 'create', supplier: values.get('supplier'), date: values.get('date'), ...(values.get('item') ? { item: values.get('item') } : {}), ...(values.get('quantity') ? { quantity: values.get('quantity') } : {}), ...(values.get('remark') ? { remark: values.get('remark') } : {}), ...(Object.keys(filing).length ? { filing } : {}) } }) }; } case 'order_update_shared_plan': return { ok: true, operation: operation(route.action, { ...commonLookupData(values, 'shared_plan'), updates: { actions: [{ target: 'planned_capacity', operation: 'set', value: values.get('planned_capacity') }] } }) }; case 'order_update_shared_child': if (String(values.get('lodging_note') || '').length > 50) { return { ok: false, code: 'program_invalid_value', message: '单次追加订房说明不得超过 50 个字符。', field: 'lodging_note' }; } return { ok: true, operation: operation(route.action, { ...commonLookupData(values, 'shared_child_order'), updates: { actions: [{ target: 'lodging_note', operation: 'append', value: values.get('lodging_note') }] } }) }; case 'order_update_independent': { const updates = parseIndependentUpdates(String(values.get('update_targets') || ''), context, true); if (!updates.ok) return updates; return { ok: true, operation: operation(route.action, { ...commonLookupData(values, 'independent_order'), updates: updates.operation, ...(special ? { special_requests: special } : {}) }) }; } case 'arrangement_hotel_update': { const type = compactText(values.get('arrangement_type')); if (type && !['酒店', '安排酒店'].includes(type)) { return { ok: false, code: 'program_business_rule_blocked', message: '安排变更程序模式当前只支持酒店。', field: 'arrangement_type' }; } const changes = parseHotelChanges(String(values.get('hotel_changes') || ''), context, true); if (!changes.ok) return changes; return { ok: true, operation: operation(route.action, { existing_refs: { identifier: values.get('group_no') }, arrangement: { mode: 'update', changes: changes.operation }, ...(special ? { special_requests: special } : {}) }) }; } case 'order_cancel': return { ok: true, operation: operation(route.action, commonLookupData(values)) }; case 'order_restore': { const status = compactText(values.get('restore_status')); if (status && !['预订', '已确认', '收客中'].includes(status)) { return { ok: false, code: 'program_invalid_value', message: '恢复状态只允许预订、已确认或收客中。', field: 'restore_status' }; } return { ok: true, operation: operation(route.action, { ...commonLookupData(values), ...(status ? { transition: { to_status: status } } : {}) }) }; } case 'confirmation_export': { const selection = values.get('export_type') as JsonRecord; const types = selection.types as string[]; const targetKind = selection.target_kind === 'shared_plan' ? 'shared_plan' : undefined; const data: JsonRecord = { ...commonLookupData(values, targetKind) }; if (types.length === 1) data.confirmation = { type: types[0] }; else data.export_types = types; if (types.includes('pickup-sign')) { const visitor = compactText(values.get('visitor_name')); if (!visitor) return { ok: false, code: 'program_missing_required', message: '接机牌需要填写游客姓名。', field: 'visitor_name' }; data.visitor_name = visitor; } else if (values.get('visitor_name')) data.visitor_name = values.get('visitor_name'); return { ok: true, operation: operation(route.action, data) }; } } } function parseField(field: ProgramFieldDefinition, rawValue: string, route: BusinessRouteDefinition, context: DateContext, parsed: Map): FieldParseResult { if (isPlaceholder(rawValue)) return { ok: false, message: '该字段尚未填写。' }; const nextOccurrence = NEXT_OCCURRENCE_DATE_ROUTES.has(route.routeId); switch (field.kind) { case 'text': return { ok: true, value: compactText(rawValue) }; case 'named_ref': return { ok: true, value: namedRef(rawValue) }; case 'date': return parseDate(rawValue, context, { nextOccurrence }); case 'date_range': return parseDateRange(rawValue, context, nextOccurrence); case 'date_or_range': return parseDateOrRange(rawValue, context, nextOccurrence); case 'date_list': return parseDateList(rawValue, context, nextOccurrence, isRecord(parsed.get('departure_range')) ? parsed.get('departure_range') as JsonRecord : isRecord(parsed.get('departure_source')) ? parsed.get('departure_source') as JsonRecord : undefined); case 'passenger_counts': return parsePassengerCounts(rawValue); case 'room_counts': return parseRoomCounts(rawValue); case 'positive_integer': return positiveInteger(rawValue); case 'boolean': return booleanValue(rawValue); case 'passenger_tsv': return parsePassengerTsv(rawValue); case 'export_type': return parseExportType(rawValue); case 'update_targets': case 'hotel_changes': return { ok: true, value: rawValue }; } } function baseEnvelope(route: BusinessRouteDefinition | null): { parse_mode: 'program'; business_route_id: BusinessRouteId | null; program_parser_version: string; input_contract_version: string | null; external_request: JsonRecord; } { return { parse_mode: 'program', business_route_id: route?.routeId || null, program_parser_version: route?.programVersion || BUSINESS_ROUTES[0].programVersion, input_contract_version: route?.inputContractVersion || null, external_request: { provider: 'program', external_call_made: false, attempt_count: 1 } }; } function blocked(route: BusinessRouteDefinition | null, code: string, message: string, extra: JsonRecord = {}): ProgramParseResult { return { ...baseEnvelope(route), status: 'agent_parse_blocked', blockers: [message], operation: null, reply: message, error_code: code, failure_stage: 'program_parse', failure_source: 'program_parser', failure_message: message, agent_returned: true, plugin_dispatch_started: false, erp_write_started: false, no_plugin_dispatch: true, no_erp_write: true, ...extra } as ProgramParseResult; } function needsInput(route: BusinessRouteDefinition, code: string, fields: string[], messages: Map, captured: JsonRecord): ProgramParseResult { const unique = [...new Set(fields)]; const questions = unique.map((key) => { const field = route.fields.find((candidate) => candidate.key === key); return { field: key, prompt: messages.get(key) || `请补充${field?.label || key}。` }; }); const reply = questions.map((question) => question.prompt).join(';'); return { ...baseEnvelope(route), status: 'agent_parse_needs_input', blockers: [], operation: null, missing_fields: unique, questions, captured_facts: captured, reply, error_code: code, no_plugin_dispatch: true, no_erp_write: true } as ProgramParseResult; } async function parseProgramInputInner(input: ProgramParseInput): Promise { const suppliedRoute = input.routeId ? businessRouteById(input.routeId) : null; const routeMessage = input.rawText || input.history?.[0]?.text || ''; const routeResolution = suppliedRoute ? null : resolveBusinessRoute(routeMessage); // If the complete message contains two explicit actions, retain the first // directive as the primary route so extractFields() can return the stronger // program_multiple_actions blocker. Signature ambiguity remains unresolved. const firstLineRoute = routeResolution?.match === 'ambiguous' ? resolveBusinessRoute(firstNonEmptyLine(routeMessage)).route : null; const route = suppliedRoute || routeResolution?.route || firstLineRoute || null; if (!route) { const code = routeResolution?.match === 'ambiguous' ? 'program_ambiguous_input' : 'program_unsupported_syntax'; return blocked(null, code, '无法唯一识别业务路由,已阻止程序解析。'); } const turns: ProgramHistoryTurn[] = [ ...(input.history || []).map((turn) => ({ text: String(turn.text || ''), receivedAt: turn.receivedAt })), { text: String(input.rawText || ''), receivedAt: input.receivedAt } ].filter((turn, index, all) => turn.text.trim() && !(index === all.length - 1 && index > 0 && turn.text === all[index - 1].text)); const extracted = extractFields(route, turns); if (extracted.multipleRoute) return blocked(route, 'program_multiple_actions', '同一任务补充中出现了另一个业务动作,不能合并解析。'); if (extracted.unknownFields.length) { return blocked(route, 'program_unknown_field', `当前业务不认识字段:${[...new Set(extracted.unknownFields)].join('、')}。`, { unknown_field_count: new Set(extracted.unknownFields).size }); } if (extracted.ambiguousFields.length) { return blocked(route, 'program_ambiguous_input', `字段标签无法唯一纠错:${[...new Set(extracted.ambiguousFields)].join('、')}。`); } if (extracted.unsupportedLines.length) { return blocked(route, 'program_unsupported_syntax', '存在无法安全归入唯一缺失字段的无标签内容。', { unsupported_line_count: extracted.unsupportedLines.length }); } if (extracted.conflicts.size) { const messages = new Map(); for (const key of extracted.conflicts.keys()) messages.set(key, `同一轮中${route.fields.find((field) => field.key === key)?.label || key}出现冲突值,请明确一个值。`); return needsInput(route, 'program_field_conflict', [...extracted.conflicts.keys()], messages, {}); } const missing = simpleRequiredKeys(route, extracted.values); if (missing.length) { const messages = new Map(missing.map((key) => [key, `请补充${route.fields.find((field) => field.key === key)?.label || key}。`])); return needsInput(route, 'program_missing_required', missing, messages, Object.fromEntries(extracted.values)); } const context = dateContext(input.receivedAt || turns.at(-1)?.receivedAt); const parsed = new Map(); const invalid: string[] = []; const messages = new Map(); for (const field of route.fields) { const rawValue = extracted.values.get(field.key); if (isPlaceholder(rawValue || '')) continue; const result = parseField(field, rawValue!, route, context, parsed); if (!result.ok) { invalid.push(field.key); messages.set(field.key, `${field.label}无效:${result.message}`); } else parsed.set(field.key, result.value); } if (invalid.length) return needsInput(route, 'program_invalid_value', invalid, messages, Object.fromEntries(parsed)); const built = buildOperation(route, parsed, context); if (!built.ok) { if (built.code === 'program_invalid_value' || built.code === 'program_missing_required' || built.code === 'program_field_conflict') { const field = built.field || 'business_input'; return needsInput(route, built.code, [field], new Map([[field, built.message]]), Object.fromEntries(parsed)); } return blocked(route, built.code, built.message); } const validationErrors = await validateParsedOperation(built.operation); if (validationErrors.length) { return blocked(route, 'program_contract_invalid', '程序解析结果未通过统一 operation 契约。', { validation_errors: validationErrors, contract_error_count: validationErrors.length }); } return { ...baseEnvelope(route), status: 'agent_parse_passed', blockers: [], operation: built.operation, reply: '系统已完成确定性解析。', no_plugin_dispatch: true, no_erp_write: true } as ProgramParseResult; } export async function parseProgramInput(input: ProgramParseInput): Promise { const timeoutMs = Math.max(10, Math.min(Number(input.timeoutMs || 2_000), 30_000)); let timer: NodeJS.Timeout | undefined; try { return await Promise.race([ parseProgramInputInner(input), new Promise((resolve) => { timer = setTimeout(() => { const route = input.routeId ? businessRouteById(input.routeId) : null; resolve(blocked(route, 'program_timeout', '程序解析超过时间限制。')); }, timeoutMs); }) ]); } catch (error) { const route = input.routeId ? businessRouteById(input.routeId) : null; return blocked(route, 'program_internal_error', '程序解析发生内部错误。', { internal_error_name: error instanceof Error ? error.name : 'UnknownError' }); } finally { if (timer) clearTimeout(timer); } } export function programFallbackAllowed(result: unknown): boolean { return isRecord(result) && PROGRAM_PARSER_FALLBACK_CODES.has(String(result.error_code || '')); } export function programParserVersion(routeId: unknown): string | null { return businessRouteById(routeId)?.programVersion || null; } export function programRouteForMessage(message: unknown): BusinessRouteId | null { return resolveBusinessRoute(message).routeId; }