1080 lines
52 KiB
TypeScript
1080 lines
52 KiB
TypeScript
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<string, string>([
|
||
['星游客户确认单', '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<BusinessRouteId>([
|
||
'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<string, unknown>;
|
||
|
||
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<string, string>;
|
||
conflicts: Map<string, string[]>;
|
||
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<string, string>([
|
||
['成人', '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<number>((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<number>((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<string, string>([
|
||
['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<number>();
|
||
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, string>): 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<string, string>();
|
||
const conflicts = new Map<string, string[]>();
|
||
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<string, string>();
|
||
const turnConflicts = new Map<string, string[]>();
|
||
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<string, unknown>, 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, unknown>): 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<string, unknown>, 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<string, unknown>): 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<string, string>, 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<ProgramParseResult> {
|
||
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<string, string>();
|
||
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<string, unknown>();
|
||
const invalid: string[] = [];
|
||
const messages = new Map<string, string>();
|
||
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<ProgramParseResult> {
|
||
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<ProgramParseResult>((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;
|
||
}
|