Files
LWLT-AI/mock-business-system/external-agent-client.mjs
2026-07-13 19:57:46 +08:00

825 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

import { randomBytes } from 'node:crypto';
const DEFAULT_BASE_URL = 'https://superagent.nianxx.cn';
const DEFAULT_TIMEOUT_MS = 120_000;
const DEFAULT_TOTAL_TIMEOUT_MS = 180_000;
export class OpenAgentAPIError extends Error {
constructor(message, { statusCode = 0, detail = null, responseText = '' } = {}) {
super(message);
this.name = 'OpenAgentAPIError';
this.statusCode = statusCode;
this.detail = detail;
this.responseText = responseText;
}
}
export class ExternalAgentParser {
constructor({
baseUrl = DEFAULT_BASE_URL,
apiKey,
fetchImpl = globalThis.fetch,
timeoutMs = DEFAULT_TIMEOUT_MS,
totalTimeoutMs = DEFAULT_TOTAL_TIMEOUT_MS,
now = () => new Date(),
csrfToken = randomBytes(32).toString('base64url')
} = {}) {
this.baseUrl = normalizeBaseUrl(baseUrl);
this.apiKey = normalizeApiKey(apiKey);
this.fetchImpl = fetchImpl;
this.timeoutMs = timeoutMs;
this.totalTimeoutMs = totalTimeoutMs;
this.now = now;
this.csrfToken = csrfToken;
if (typeof this.fetchImpl !== 'function') {
throw new Error('当前 Node.js 运行时没有可用的 fetch。');
}
}
isConfigured() {
return Boolean(this.baseUrl && this.apiKey);
}
async checkConnection({ timeoutMs = Math.min(this.timeoutMs, 8_000) } = {}) {
if (!this.isConfigured()) {
return {
ok: false,
configured: false,
reachable: false,
error_code: 'external_service_not_configured'
};
}
try {
// The open session endpoint is state-changing on some deployments even
// when the payload is empty. Use the provider health endpoint here so a
// browser status refresh cannot create orphaned Agent sessions.
const response = await this.requestRaw('GET', '/health', {
accept: 'application/json',
timeoutMs
});
const httpStatus = response.status;
await response.text().catch(() => '');
if (response.ok) {
return {
ok: true,
configured: true,
reachable: true,
authenticated: null,
authentication_checked: false,
http_status: httpStatus,
probe: 'health_endpoint'
};
}
return {
ok: false,
configured: true,
reachable: true,
authenticated: null,
authentication_checked: false,
http_status: httpStatus,
error_code: 'external_health_unhealthy'
};
} catch (error) {
return {
ok: false,
configured: true,
reachable: false,
error_code: errorCodeFor(error)
};
}
}
async parse({ rawText, taskId, receivedAt = this.now().toISOString(), signal: parentSignal } = {}) {
let sessionId = '';
let requestTrace = makeRequestTrace(this.now());
const normalizedRawText = String(rawText || '').trim();
const normalizedTaskId = String(taskId || '').trim();
if (!normalizedRawText) return blockedResult(['原始指令不能为空。']);
if (!normalizedTaskId) return blockedResult(['缺少任务 ID无法建立独立解析会话。']);
if (!this.isConfigured()) {
return blockedResult([
'解析服务未配置:请在服务端设置 DEERFLOW_BASE_URL 和 DEERFLOW_OPEN_API_KEY。'
], {
error_code: 'external_service_not_configured',
external_request: updateRequestTrace(requestTrace, 'not_configured', this.now())
});
}
const controller = new AbortController();
const unlinkParentSignal = linkAbortSignal(parentSignal, controller);
const deadlineTimer = setTimeout(() => controller.abort(), this.totalTimeoutMs);
const deadline = Date.now() + this.totalTimeoutMs;
const remainingTimeout = () => {
if (controller.signal.aborted) {
throw new OpenAgentAPIError('外部解析服务已取消。', { detail: 'parse_cancelled' });
}
const remaining = deadline - Date.now();
if (remaining <= 0) {
throw new OpenAgentAPIError(`外部解析服务总超时(>${this.totalTimeoutMs}ms`, {
detail: 'parse_timeout'
});
}
return Math.min(this.timeoutMs, remaining);
};
const sessionKey = `business-parser-session:${normalizedTaskId}`;
const messageKey = `business-parser-message:${normalizedTaskId}`;
const metadata = {
source: 'business_system',
module: 'input_parser',
task_id: normalizedTaskId
};
try {
requestTrace = updateRequestTrace(requestTrace, 'creating_session', this.now());
const session = await this.createSession({
externalSubjectId: `business-task:${normalizedTaskId}`,
idempotencyKey: sessionKey,
metadata,
timeoutMs: remainingTimeout(),
signal: controller.signal
});
sessionId = String(session?.session_id || '').trim();
if (!sessionId) {
throw new OpenAgentAPIError('外部服务创建会话成功但未返回 session_id。', {
detail: session
});
}
requestTrace = updateRequestTrace(requestTrace, 'session_created', this.now(), {
session_id_present: true
});
const message = buildParserMessage({
rawText: normalizedRawText,
taskId: normalizedTaskId,
receivedAt
});
const stream = await this.streamMessage(sessionId, message, {
idempotencyKey: messageKey,
metadata: {
...metadata,
session_id: sessionId
},
timeoutMs: remainingTimeout(),
signal: controller.signal
});
requestTrace = updateRequestTrace(requestTrace, 'stream_connecting', this.now());
const streamed = await collectSseOutputWithTimeout(
stream.body,
remainingTimeout(),
() => controller.abort(),
controller.signal
);
requestTrace = updateRequestTrace(requestTrace, 'stream_completed', this.now(), {
event_count: streamed.eventCount
});
const parsed = streamed.result || parseJsonObject(streamed.text);
if (!parsed) {
return blockedResult(['外部解析服务未返回可识别的 JSON 结果。'], {
error_code: 'external_result_not_json',
session_id: sessionId,
event_count: streamed.eventCount,
external_request: requestTrace
});
}
return normalizeParseResult(parsed, {
taskId: normalizedTaskId,
receivedAt,
sessionId,
requestTrace
});
} catch (error) {
requestTrace = updateRequestTrace(requestTrace, 'failed', this.now(), {
session_id_present: Boolean(sessionId),
error_code: errorCodeFor(error)
});
return blockedResult([formatExternalError(error)], {
error_code: errorCodeFor(error),
external_status: error?.statusCode || undefined,
external_request: requestTrace
});
} finally {
clearTimeout(deadlineTimer);
unlinkParentSignal();
}
}
async createSession({ externalSubjectId, idempotencyKey, metadata, timeoutMs = this.timeoutMs, signal } = {}) {
return this.requestJson('POST', '/api/open/agent-sessions', {
body: compactPayload({
external_subject_id: externalSubjectId,
idempotency_key: idempotencyKey,
metadata
}),
timeoutMs,
signal
});
}
async streamMessage(sessionId, message, { idempotencyKey, metadata, timeoutMs = this.timeoutMs, signal } = {}) {
return this.request('POST', `/api/open/agent-sessions/${encodeURIComponent(sessionId)}/messages/stream`, {
accept: 'text/event-stream',
body: compactPayload({
message,
idempotency_key: idempotencyKey,
metadata
}),
timeoutMs,
signal
});
}
async requestJson(method, path, { timeoutMs = this.timeoutMs, ...options } = {}) {
const response = await this.request(method, path, {
...options,
accept: 'application/json',
timeoutMs
});
const text = await response.text();
if (!text.trim()) return {};
try {
return JSON.parse(text);
} catch (error) {
throw new OpenAgentAPIError('外部服务返回了无效 JSON。', {
statusCode: response.status,
responseText: text
});
}
}
async request(method, path, { body, accept = 'application/json', timeoutMs = this.timeoutMs, signal } = {}) {
const response = await this.requestRaw(method, path, { body, accept, timeoutMs, signal });
if (response.status >= 400) {
const responseText = await response.text();
throw new OpenAgentAPIError(formatHttpError(response.status), {
statusCode: response.status,
detail: parseErrorDetail(responseText),
responseText
});
}
return response;
}
async requestRaw(method, path, {
body,
accept = 'application/json',
timeoutMs = this.timeoutMs,
signal
} = {}) {
const requestController = new AbortController();
const requestSignal = signal
? AbortSignal.any([signal, requestController.signal])
: requestController.signal;
const timeout = setTimeout(() => requestController.abort(), timeoutMs);
try {
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
method,
headers: {
Accept: accept,
Authorization: `Bearer ${this.apiKey}`,
...(isStateChangingMethod(method)
? {
'X-CSRF-Token': this.csrfToken,
Cookie: `csrf_token=${this.csrfToken}`
}
: {}),
...(body === undefined ? {} : { 'Content-Type': 'application/json' })
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal: requestSignal
});
return response;
} catch (error) {
if (error?.name === 'AbortError') {
throw new OpenAgentAPIError(
signal?.aborted ? '外部解析服务请求已取消。' : `外部解析服务超时(>${timeoutMs}ms`,
{
detail: signal?.aborted ? 'parse_cancelled' : 'request_timeout'
}
);
}
throw error;
} finally {
clearTimeout(timeout);
}
}
}
export function normalizeParseResult(result, { taskId, receivedAt, sessionId, requestTrace } = {}) {
const normalized = normalizeResultShape(result);
if (!normalized || typeof normalized !== 'object') {
return blockedResult(['外部解析服务返回的内容不是 JSON 对象。'], {
error_code: 'external_result_not_object',
session_id: sessionId,
external_request: requestTrace
});
}
const status = normalized.status;
if (!status && !normalized.operation && !normalized.blockers) {
return blockedResult([
'外部 Profile 返回的 JSON 不符合标准 operation 契约,请检查已发布 Profile 的输出配置。'
], {
error_code: 'external_result_contract_mismatch',
response_keys: Object.keys(normalized).slice(0, 30),
session_id: sessionId,
external_request: requestTrace
});
}
if (status !== 'agent_parse_passed') {
return {
...normalized,
status: status || 'agent_parse_blocked',
blockers: Array.isArray(normalized.blockers) ? normalized.blockers : ['外部解析服务未通过输入校验。'],
operation: normalized.operation && typeof normalized.operation === 'object'
? normalized.operation
: null,
session_id: sessionId,
external_request: requestTrace
};
}
const operation = normalized.operation;
if (!operation || typeof operation !== 'object') {
return blockedResult(['外部解析服务返回通过状态,但缺少 operation。'], {
error_code: 'external_operation_missing',
session_id: sessionId,
external_request: requestTrace
});
}
operation.action = operation.action || 'team_order_create';
operation.submit_mode = 'dry_run';
operation.source = {
...(operation.source || {}),
task_id: taskId,
instruction_id: operation.source?.instruction_id || taskId,
received_at: operation.source?.received_at || receivedAt,
operator: operation.source?.operator || 'business-system-parser',
parser_prompt_version: operation.source?.parser_prompt_version || 'external-profile'
};
operation.data = operation.data || {};
operation.data.passenger_list = operation.data.passenger_list || {
operation: 'none',
passenger_count: 0,
file_count: 0
};
operation.data.attachments = Array.isArray(operation.data.attachments)
? operation.data.attachments
: [];
operation.data.system_defaults = {
fabudanwei: '老挝联泰',
business_status: '预订',
filing_required: true,
...(operation.data.system_defaults || {})
};
return {
...normalized,
status: 'agent_parse_passed',
blockers: Array.isArray(normalized.blockers) ? normalized.blockers : [],
operation,
session_id: sessionId,
external_request: requestTrace
};
}
export async function collectSseOutput(body) {
if (!body) return { text: '', result: null, eventCount: 0 };
let text = '';
let finalText = '';
let result = null;
let eventCount = 0;
for await (const event of iterateSseEvents(body)) {
eventCount += 1;
const structured = findStructuredResult(event.data);
if (structured) result = structured;
const fragment = event.event === 'messages'
? extractAssistantMessageText(event.data)
: extractEventText(event.data);
if (!fragment) continue;
if (isFinalEvent(event.event)) finalText = fragment;
else text += fragment;
}
return {
text: finalText || text,
result,
eventCount
};
}
async function collectSseOutputWithTimeout(body, timeoutMs, onTimeout, signal) {
let timer;
let abortHandler;
const cancellation = signal
? new Promise((resolve, reject) => {
abortHandler = () => reject(new OpenAgentAPIError('外部解析服务请求已取消。', {
detail: 'parse_cancelled'
}));
if (signal.aborted) abortHandler();
else signal.addEventListener('abort', abortHandler, { once: true });
})
: null;
try {
const races = [
collectSseOutput(body),
new Promise((resolve, reject) => {
timer = setTimeout(() => {
onTimeout?.();
reject(new OpenAgentAPIError(`外部解析服务超时(>${timeoutMs}ms`, {
detail: 'request_timeout'
}));
}, timeoutMs);
})
];
if (cancellation) races.push(cancellation);
return await Promise.race(races);
} catch (error) {
await cancelBodyWithBoundedWait(body);
throw error;
} finally {
clearTimeout(timer);
if (abortHandler) signal.removeEventListener('abort', abortHandler);
}
}
export async function* iterateSseEvents(body) {
const decoder = new TextDecoder();
let buffer = '';
for await (const chunk of body) {
buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
while (true) {
const separator = findEventSeparator(buffer);
if (separator < 0) break;
const raw = buffer.slice(0, separator);
buffer = buffer.slice(separator + eventSeparatorLength(buffer, separator));
const event = parseSseBlock(raw);
if (!event || event.done) return;
yield event;
if (isFinalEvent(event.event)) return;
}
}
buffer += decoder.decode();
if (buffer.trim()) {
const event = parseSseBlock(buffer);
if (event && !event.done) yield event;
}
}
export function parseSseBlock(raw) {
let event;
const dataLines = [];
for (const line of String(raw).split(/\r?\n/)) {
if (!line || line.startsWith(':')) continue;
if (line.startsWith('event:')) {
event = line.slice('event:'.length).trim();
continue;
}
if (line.startsWith('data:')) dataLines.push(line.slice('data:'.length).replace(/^ /, ''));
}
if (event === undefined && dataLines.length === 0) return null;
const dataText = dataLines.join('\n');
if (!dataText) return { event: event || null, data: null };
if (dataText.trim() === '[DONE]') return { event: event || null, data: null, done: true };
try {
return { event: event || null, data: JSON.parse(dataText) };
} catch (error) {
return { event: event || null, data: dataText };
}
}
export function parseJsonObject(text) {
const raw = stripThinkBlocks(text);
if (!raw) return null;
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
const candidate = fenced ? fenced[1].trim() : raw;
try {
return JSON.parse(candidate);
} catch (error) {
const first = candidate.indexOf('{');
const last = candidate.lastIndexOf('}');
if (first < 0 || last <= first) return null;
try {
return JSON.parse(candidate.slice(first, last + 1));
} catch (nestedError) {
return null;
}
}
}
export function buildParserMessage({ rawText, taskId, receivedAt }) {
return [
`任务 ID${taskId}`,
`当前时间:${receivedAt}`,
'',
'请将以下原始业务指令解析为既定标准 operation JSON只输出 JSON',
rawText
].join('\n');
}
export function blockedResult(blockers, extra = {}) {
return {
status: 'agent_parse_blocked',
blockers: Array.isArray(blockers) ? blockers : [String(blockers)],
operation: null,
...extra
};
}
export function normalizeBaseUrl(value) {
const raw = String(value || DEFAULT_BASE_URL).trim().replace(/\/+$/, '');
try {
const url = new URL(raw);
if (!['http:', 'https:'].includes(url.protocol)) return '';
return url.toString().replace(/\/+$/, '');
} catch (error) {
return '';
}
}
export function normalizeApiKey(value) {
return String(value || '')
.trim()
.replace(/^['"]|['"]$/g, '')
.replace(/^Bearer\s+/i, '')
.trim();
}
function normalizeResultShape(result) {
if (!result || typeof result !== 'object') return null;
if (result.result && typeof result.result === 'object') return normalizeResultShape(result.result);
if (result.output && typeof result.output === 'object') return normalizeResultShape(result.output);
if (result.data && typeof result.data === 'object') return normalizeResultShape(result.data);
if (result.status || result.operation || result.blockers) return result;
if (result.action && result.data) return { status: 'agent_parse_passed', blockers: [], operation: result };
if (looksLikeFlatParserResult(result)) {
return {
status: 'agent_parse_passed',
blockers: [],
operation: flatParserResultToOperation(result)
};
}
return result;
}
function findStructuredResult(value) {
if (!value || typeof value !== 'object') return null;
if (value.status === 'agent_parse_passed' || value.status === 'agent_parse_blocked') return value;
if (value.operation && typeof value.operation === 'object') {
return {
status: value.status || 'agent_parse_passed',
blockers: value.blockers || [],
operation: value.operation
};
}
if (value.result && typeof value.result === 'object') return findStructuredResult(value.result);
if (value.output && typeof value.output === 'object') return findStructuredResult(value.output);
if (value.data && typeof value.data === 'object') return findStructuredResult(value.data);
if (value.payload && typeof value.payload === 'object') return findStructuredResult(value.payload);
if (value.action && value.data) return { status: 'agent_parse_passed', blockers: [], operation: value };
return null;
}
function extractEventText(value) {
if (typeof value === 'string') return value;
if (!value || typeof value !== 'object') return '';
if (findStructuredResult(value)) return '';
for (const key of ['content', 'text', 'delta', 'output_text', 'final_output', 'answer', 'data', 'payload']) {
if (value[key] === undefined || value[key] === null) continue;
const nested = extractEventText(value[key]);
if (nested) return nested;
}
if (value.message !== undefined) return extractEventText(value.message);
return '';
}
function extractAssistantMessageText(value) {
if (!Array.isArray(value)) return '';
return value
.filter((item) => isAssistantMessage(item))
.map((item) => extractEventText(item))
.join('');
}
function isAssistantMessage(value) {
const type = String(value?.type || '').toLowerCase();
return type.includes('ai') || type.includes('assistant');
}
function isFinalEvent(eventName) {
return /(^|[._-])(completed|complete|final|done|finished)([._-]|$)/i.test(String(eventName || ''));
}
function compactPayload(payload) {
return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== undefined && value !== null));
}
function makeRequestTrace(now) {
return {
service: 'external_parse_api',
transport: 'sse',
stage: 'not_started',
session_id_present: false,
event_count: 0,
updated_at: now.toISOString()
};
}
function updateRequestTrace(trace, stage, now, patch = {}) {
return {
...trace,
...patch,
stage,
updated_at: now.toISOString()
};
}
function linkAbortSignal(parentSignal, controller) {
if (!parentSignal) return () => {};
const abort = () => controller.abort();
if (parentSignal.aborted) abort();
else parentSignal.addEventListener('abort', abort, { once: true });
return () => parentSignal.removeEventListener('abort', abort);
}
async function cancelBodyWithBoundedWait(body) {
if (typeof body?.cancel !== 'function') return;
try {
const cancellation = Promise.resolve(body.cancel()).catch(() => undefined);
await Promise.race([
cancellation,
new Promise((resolve) => setTimeout(resolve, 1_000))
]);
} catch {
// The request has already been aborted. A provider that does not resolve
// cancel() must not hold the business task open indefinitely.
}
}
function isStateChangingMethod(method) {
return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(String(method || '').toUpperCase());
}
function stripThinkBlocks(text) {
return String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
}
function findEventSeparator(buffer) {
const lf = buffer.indexOf('\n\n');
const crlf = buffer.indexOf('\r\n\r\n');
if (lf < 0) return crlf;
if (crlf < 0) return lf;
return Math.min(lf, crlf);
}
function eventSeparatorLength(buffer, index) {
return buffer.startsWith('\r\n\r\n', index) ? 4 : 2;
}
function parseErrorDetail(responseText) {
try {
const body = JSON.parse(responseText);
if (typeof body === 'string') return body;
if (body && typeof body === 'object') return body.detail || body.message || body.error || body;
} catch (error) {
// Keep the text path below intentionally short and redacted.
}
return redactSensitiveText(String(responseText || '').slice(0, 500));
}
function formatHttpError(statusCode) {
if (statusCode === 401) return '外部解析服务鉴权失败401。';
if (statusCode === 403) return '外部解析服务权限不足403请检查外部应用 scope。';
if (statusCode === 404) return '外部解析服务未找到可用的已发布 Profile404。';
if (statusCode === 409) return '外部解析服务报告当前 session 有运行中的任务409。';
return `外部解析服务请求失败HTTP ${statusCode})。`;
}
function formatExternalError(error) {
if (error instanceof OpenAgentAPIError) {
const detail = error.detail && typeof error.detail === 'string'
? redactSensitiveText(error.detail).slice(0, 300)
: '';
return detail ? `${error.message} ${detail}` : error.message;
}
if (error?.code === 'ENOTFOUND' || error?.code === 'ECONNREFUSED') {
return '无法连接外部解析服务,请检查服务地址和网络。';
}
return `外部解析服务调用异常:${redactSensitiveText(error?.message || String(error)).slice(0, 300)}`;
}
function errorCodeFor(error) {
if (error?.statusCode === 401) return 'external_unauthorized';
if (error?.statusCode === 403) return 'external_forbidden';
if (error?.statusCode === 404) return 'external_profile_not_published';
if (error?.statusCode === 409) return 'external_active_run';
if (error?.detail === 'parse_cancelled') return 'external_cancelled';
if (error?.detail === 'request_timeout') return 'external_timeout';
return 'external_request_failed';
}
function redactSensitiveText(value) {
return String(value || '').replace(/df_open_[A-Za-z0-9_-]+/g, '[redacted-open-api-key]');
}
function looksLikeFlatParserResult(result) {
return Boolean(
result.order_mode
|| result.orderMode
|| result.product_name
|| result.productName
|| result.departure_date
|| result.departureDate
|| result.op
|| result.operator
|| result.sales
|| result.salesperson
);
}
function flatParserResultToOperation(result) {
const counts = result.passenger_counts || result.passengers || {};
const rooms = result.room_counts || result.rooms || {};
const prices = result.prices || result.unit_prices || {};
const passengerCounts = {
adult: numberValue(pick(result, ['adult', 'adults'], counts.adult)),
child_bed: numberValue(pick(result, ['child_with_bed', 'child_bed', 'childrenWithBed'], pick(counts, ['child_with_bed', 'child_bed', 'childrenWithBed']))),
child_no_bed: numberValue(pick(result, ['child_without_bed', 'child_no_bed', 'childrenWithoutBed'], pick(counts, ['child_without_bed', 'child_no_bed', 'childrenWithoutBed']))),
infant: numberValue(pick(result, ['infant', 'infants'], counts.infant)),
leader: numberValue(pick(result, ['tour_leader', 'leader', 'tourLeader'], pick(counts, ['tour_leader', 'leader', 'tourLeader'])))
};
passengerCounts.expected_total = numberValue(
pick(result, ['expected_total', 'passenger_total'], pick(counts, ['expected_total', 'passenger_total']))
) || Object.values(passengerCounts).reduce((sum, value) => sum + value, 0);
const productName = pick(result, ['product_name', 'productName'], result.product?.name || '');
const departureDate = pick(result, ['departure_date', 'departureDate'], result.departure_dates?.[0] || '');
const source = result.source && typeof result.source === 'object' ? { ...result.source } : {};
if (result.notes && !source.notes) source.notes = result.notes;
return {
action: result.action || 'team_order_create',
order_nature: normalizeOrderNature(pick(result, ['order_nature', 'orderNature'])),
submit_mode: 'dry_run',
source,
data: {
order_mode: pick(result, ['order_mode', 'orderMode'], '团队-单个下单'),
test_marker: pick(result, ['test_marker', 'testMarker', 'marker'], ''),
product: { name: productName },
departure_dates: departureDate ? [String(departureDate)] : [],
passenger_counts: passengerCounts,
room_counts: {
SGL: numberValue(pick(result, ['SGL', 'sgl', 'single_room'], pick(rooms, ['SGL', 'sgl', 'single_room']))),
TWN: numberValue(pick(result, ['TWN', 'twn', 'twin_room'], pick(rooms, ['TWN', 'twn', 'twin_room']))),
TRP: numberValue(pick(result, ['TRP', 'trp', 'triple_room'], pick(rooms, ['TRP', 'trp', 'triple_room']))),
DBL: numberValue(pick(result, ['DBL', 'dbl', 'double_room'], pick(rooms, ['DBL', 'dbl', 'double_room']))),
HNM: numberValue(pick(result, ['HNM', 'hnm', 'honeymoon_room'], pick(rooms, ['HNM', 'hnm', 'honeymoon_room']))),
TL: numberValue(pick(result, ['TL', 'tl', 'leader_room'], pick(rooms, ['TL', 'tl', 'leader_room'])))
},
prices: {
adult: numberValue(pick(result, ['adult_price', 'price_adult'], pick(prices, ['adult', 'adult_price']))),
child_bed: numberValue(pick(result, ['child_with_bed_price', 'child_bed_price'], pick(prices, ['child_with_bed', 'child_bed']))),
child_no_bed: numberValue(pick(result, ['child_without_bed_price', 'child_no_bed_price'], pick(prices, ['child_without_bed', 'child_no_bed']))),
infant: numberValue(pick(result, ['infant_price'], prices.infant)),
leader: numberValue(pick(result, ['tour_leader_price', 'leader_price'], pick(prices, ['tour_leader', 'leader'])))
},
op_user: { name: pick(result, ['op', 'operator'], result.op_user?.name || '') },
sales_user: { name: pick(result, ['sales', 'salesperson'], result.sales_user?.name || '') },
special_requests: pick(result, ['remarks', 'special_requests', 'specialRequests'], ''),
passenger_list: result.passenger_list || result.passengerList || {
operation: 'none',
passenger_count: 0,
file_count: 0
},
attachments: Array.isArray(result.attachments) ? result.attachments : [],
system_defaults: result.system_defaults || result.systemDefaults || {}
}
};
}
function pick(object, keys, fallback = '') {
if (!object || typeof object !== 'object') return fallback;
for (const key of keys) {
const value = object[key];
if (value !== undefined && value !== null && String(value).trim() !== '') return value;
}
return fallback;
}
function numberValue(value) {
const number = Number(String(value ?? '').replace(/[,\s]/g, ''));
return Number.isFinite(number) ? number : 0;
}
function normalizeOrderNature(value) {
const text = String(value || '').trim().toLowerCase();
if (/test|测试/.test(text)) return 'test';
if (/formal|正式/.test(text)) return 'formal';
return value || 'test';
}
export { DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, DEFAULT_TOTAL_TIMEOUT_MS };