Files
LWLT-AI/archive/handoff/2026-07-12/legacy-erp-handoff/tools/erp_task_dispatcher.js
2026-07-13 19:57:46 +08:00

1214 lines
39 KiB
JavaScript
Raw 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.

#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const orderRegistry = require('./erp_order_registry');
const defaultTaskLock = require('./erp_task_lock');
const travelerLists = require('./erp_traveler_list');
const { validateTaskEnvelope } = require('./erp_task_contract');
const ROOT = path.resolve(__dirname, '..');
const DEFAULT_CONFIG = path.join(ROOT, 'config', 'erp-deployment.example.json');
const DEFAULT_AUDIT_DIR = path.join(ROOT, 'runtime', 'erp-order-entry', 'audit');
const KNOWN_ROUTES = new Set(['team_single', 'team_batch', 'split_parent', 'split_child']);
const KNOWN_OPERATIONS = new Set(['create_order', 'update_order', 'export_confirmation']);
const DEFAULT_NO_SAVE_BLOCK_WINDOW_MS = 60 * 60 * 1000;
const DEFAULT_UPDATE_DUPLICATE_WINDOW_MS = 2 * 60 * 60 * 1000;
function timestamp() {
return new Date().toISOString().replace(/[-:T.Z]/g, '').slice(0, 14);
}
function safeFilePart(value) {
return String(value || 'task')
.replace(/[^a-zA-Z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'task';
}
function resolvePath(filePath, cwd = process.cwd()) {
if (!filePath) return '';
return path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
}
function readJson(filePath) {
const text = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
return JSON.parse(text);
}
function readConfig(configPath) {
const resolved = configPath ? resolvePath(configPath) : DEFAULT_CONFIG;
if (!fs.existsSync(resolved)) return { safety: { allowRealSubmit: false } };
return readJson(resolved);
}
function liveSessionConfig(config = {}) {
return (config.browser && config.browser.liveSession) || {};
}
function liveSessionEnabled(config = {}) {
return Boolean(liveSessionConfig(config).enabled);
}
function liveSessionLockPath(config = {}, auditDir = DEFAULT_AUDIT_DIR) {
const paths = config.paths || {};
const workRoot = paths.workRoot ? resolvePath(paths.workRoot) : path.dirname(auditDir);
return path.join(workRoot, 'erp-live-session.lock');
}
async function runExecutionHandler(handler, task, handlerOptions, dispatcherOptions, auditDir) {
const config = handlerOptions.config || {};
if (!liveSessionEnabled(config)) return handler(task, handlerOptions);
const live = liveSessionConfig(config);
const taskLock = dispatcherOptions.taskLock || defaultTaskLock;
return taskLock.withFileLock(
liveSessionLockPath(config, auditDir),
() => handler(task, handlerOptions),
{
timeoutMs: Number(live.lockTimeoutMs || 10 * 60 * 1000),
pollMs: Number(live.lockPollMs || 1000),
}
);
}
function normalizeMode(mode) {
const normalized = String(mode || 'dry-run').trim().toLowerCase();
if (normalized === 'execute' || normalized === 'dry-run') return normalized;
throw new Error(`Unsupported dispatcher mode: ${mode}`);
}
function normalizeEnvelope(input) {
if (!input || typeof input !== 'object') {
return { status: 'invalid', task: null, reason: 'invalid_input' };
}
if (input.status) return input;
if (input.route) {
return {
status: 'ready',
operation: input.operation || 'create_order',
route: input.route,
task: input,
};
}
if (input.operation) {
return {
status: 'ready',
operation: input.operation,
route: input.route || '',
task: input.task || input,
};
}
return { status: 'invalid', task: null, reason: 'missing_task_operation' };
}
function auditFileName(result) {
const task = result.task || {};
const taskId = task.taskId || result.taskId || result.route || result.reason || result.status;
return `dispatch-${timestamp()}-${safeFilePart(taskId)}.json`;
}
function writeAudit(result, auditDir) {
const targetDir = auditDir || DEFAULT_AUDIT_DIR;
fs.mkdirSync(targetDir, { recursive: true });
const auditPath = path.join(targetDir, auditFileName(result));
const payload = { ...result, auditPath };
fs.writeFileSync(auditPath, JSON.stringify(cloneForAudit(payload), null, 2), 'utf8');
return payload;
}
const TRAVELER_PII_FIELDS = new Set([
'name',
'chineseName',
'englishName',
'idNo',
'birthDate',
'passportNo',
'phone',
'remark',
'raw',
]);
function redactValue(value) {
return value ? '[redacted]' : '';
}
function sanitizeTravelerRows(rows = []) {
if (!Array.isArray(rows)) return rows;
return rows.map((row) => {
if (!row || typeof row !== 'object') return row;
const safe = { ...row };
for (const field of TRAVELER_PII_FIELDS) {
if (Object.prototype.hasOwnProperty.call(safe, field)) safe[field] = redactValue(safe[field]);
}
return safe;
});
}
function redactTravelerRowInPlace(row) {
if (!row || typeof row !== 'object') return row;
for (const field of TRAVELER_PII_FIELDS) {
if (Object.prototype.hasOwnProperty.call(row, field)) row[field] = redactValue(row[field]);
}
return row;
}
function looksLikeTravelerList(value) {
return value && typeof value === 'object' && Array.isArray(value.rows)
&& value.rows.some((row) => row && typeof row === 'object' && (
Object.prototype.hasOwnProperty.call(row, 'passportNo')
|| Object.prototype.hasOwnProperty.call(row, 'chineseName')
|| Object.prototype.hasOwnProperty.call(row, 'englishName')
));
}
function sanitizeAuditNode(value, seen = new Set()) {
if (!value || typeof value !== 'object' || seen.has(value)) return value;
seen.add(value);
if (Array.isArray(value)) {
value.forEach((item) => sanitizeAuditNode(item, seen));
return value;
}
if (looksLikeTravelerList(value)) {
Object.assign(value, travelerLists.sanitizeTravelerListForAudit(value));
}
redactTravelerRowInPlace(value);
if (value.travelerList && typeof value.travelerList === 'object') {
value.travelerList = travelerLists.sanitizeTravelerListForAudit(value.travelerList);
}
if (Array.isArray(value.travelers)) {
value.travelers = sanitizeTravelerRows(value.travelers);
}
for (const item of Object.values(value)) sanitizeAuditNode(item, seen);
return value;
}
function cloneForAudit(value) {
const clone = JSON.parse(JSON.stringify(value || {}));
return sanitizeAuditNode(clone);
}
function blocked(reason, customerMessage, extra = {}) {
return {
status: 'blocked',
reason,
customerMessage,
...extra,
};
}
function dispatcherErrorCustomerMessage(operation, route) {
if (operation === 'update_order') {
return 'ERP 修改未完成,系统已记录原因,请稍后重试或由管理员继续处理。';
}
if (operation === 'export_confirmation') {
return 'ERP 确认件导出未完成,系统已记录原因,请稍后重试或由管理员继续处理。';
}
const label = routeLabel(route);
return `ERP 执行未完成(${label}),系统已记录原因,请稍后重试或由管理员继续处理。`;
}
function routeLabel(route) {
return {
team_single: 'team single order',
team_batch: 'team batch order',
split_parent: 'split parent plan',
split_child: 'split child order',
}[route] || route || 'unknown route';
}
function taskOperation(task, envelope = {}) {
return task && task.operation
? task.operation
: envelope.operation || 'create_order';
}
function operationHandlerKey(task, envelope = {}) {
const operation = taskOperation(task, envelope);
return operation === 'create_order' ? task.route : operation;
}
function operationLabel(operation, route) {
if (operation === 'create_order') return `ERP execution: ${routeLabel(route)}`;
return {
update_order: 'ERP order update',
export_confirmation: 'ERP confirmation export',
}[operation] || `ERP operation: ${operation}`;
}
function orderTextForbidsRealSubmit(task) {
const fields = (task && task.fields) || {};
const text = [
task && task.originalText,
fields.remark,
fields.notes,
fields.note,
].filter(Boolean).join('\n');
return /dry[-\s]?run|干跑|只解析|仅解析|不要真实(?:保存|提交|操作)|不(?:要)?真实(?:保存|提交|操作)|不要(?:保存|提交).*ERP|不要.*ERP.*(?:保存|提交|操作)|不要触碰\s*ERP/i.test(text);
}
function orderTextConfirmsRealSubmit(task) {
const fields = (task && task.fields) || {};
const text = [
task && task.originalText,
fields.remark,
fields.notes,
fields.note,
].filter(Boolean).join('\n');
return /confirm\s+real\s+(?:erp\s+)?(?:submit|save)|确认真实(?:保存|提交|操作)\s*ERP|确认(?:保存|提交|操作).*ERP/i.test(text);
}
function taskTextForSafety(task) {
const fields = (task && task.fields) || {};
return [
task && task.originalText,
fields.remark,
fields.notes,
fields.note,
].filter(Boolean).join('\n');
}
function normalizeWhitelistValue(value) {
return String(value || '').trim().replace(/\s+/g, ' ').toLowerCase();
}
function arrayFromConfig(value) {
if (!value) return [];
if (Array.isArray(value)) return value;
if (typeof value === 'string') {
return value.split(/[,\n;]/).map((item) => item.trim()).filter(Boolean);
}
return [];
}
function configuredWhitelist(config) {
const safety = (config && config.safety) || {};
const whitelist = safety.realSubmitWhitelist || safety.realSubmitAllowlist || {};
return {
enabled: Boolean(whitelist.enabled),
allowedSenders: [
...arrayFromConfig(whitelist.allowedSenders),
...arrayFromConfig(whitelist.allowedSenderIds),
...arrayFromConfig(whitelist.allowedSenderNames),
...arrayFromConfig(whitelist.allowedWechatIds),
...arrayFromConfig(whitelist.allowedWeChatIds),
],
allowedChannels: [
...arrayFromConfig(whitelist.allowedChannels),
...arrayFromConfig(whitelist.allowedChannelIds),
...arrayFromConfig(whitelist.allowedChatIds),
],
authorizationCodes: arrayFromConfig(whitelist.authorizationCodes || whitelist.allowedAuthorizationCodes),
allowAuthorizationCodeFallback: whitelist.allowAuthorizationCodeFallback === true,
};
}
function collectSourceValues(source) {
const values = [];
const add = (value) => {
if (value === undefined || value === null) return;
if (Array.isArray(value)) {
value.forEach(add);
return;
}
if (typeof value === 'object') return;
const normalized = normalizeWhitelistValue(value);
if (normalized) values.push(normalized);
};
if (typeof source === 'string') {
add(source);
return [...new Set(values)];
}
const scan = (object) => {
if (!object || typeof object !== 'object') return;
[
'sender',
'senderId',
'senderName',
'from',
'fromUser',
'fromUserId',
'fromUserName',
'user',
'userId',
'userName',
'author',
'authorId',
'authorName',
'wechatId',
'weChatId',
'wxid',
'openId',
'unionId',
'contactId',
'contactName',
'chatId',
'channelId',
'conversationId',
'roomId',
].forEach((key) => add(object[key]));
};
scan(source);
scan(source && source.wechat);
scan(source && source.weChat);
scan(source && source.message);
scan(source && source.contact);
scan(source && source.channel);
return [...new Set(values)];
}
function sourceFromEnvelope(envelope, task, options = {}) {
return {
...(envelope && envelope.source && typeof envelope.source === 'object' ? envelope.source : {}),
...(task && task.source && typeof task.source === 'object' ? task.source : {}),
...(task && task.metadata && task.metadata.source && typeof task.metadata.source === 'object' ? task.metadata.source : {}),
...(options.source && typeof options.source === 'object' ? options.source : {}),
sender: options.sender || (options.source && options.source.sender) || undefined,
senderId: options.senderId || (options.source && options.source.senderId) || undefined,
senderName: options.senderName || (options.source && options.source.senderName) || undefined,
chatId: options.chatId || (options.source && options.source.chatId) || undefined,
channelId: options.channelId || (options.source && options.source.channelId) || undefined,
};
}
function extractAuthorizationCodes(task) {
const text = taskTextForSafety(task);
const codes = [];
const regex = /(?:真实保存授权码|ERP\s*授权码|授权码)\s*[:]\s*([A-Za-z0-9._-]{4,80})/gi;
for (const match of text.matchAll(regex)) {
codes.push(normalizeWhitelistValue(match[1]));
}
return [...new Set(codes)];
}
function checkRealSubmitWhitelist(task, config, envelope, options = {}) {
const whitelist = configuredWhitelist(config);
if (!whitelist.enabled) {
return { ok: true, enabled: false };
}
const allowed = new Set([
...whitelist.allowedSenders,
...whitelist.allowedChannels,
].map(normalizeWhitelistValue).filter(Boolean));
const sourceValues = collectSourceValues(sourceFromEnvelope(envelope, task, options));
const matchedSource = sourceValues.find((value) => allowed.has(value));
if (matchedSource) {
return {
ok: true,
enabled: true,
method: 'sender',
matchedSource,
sourceValues,
};
}
const allowedCodes = new Set(whitelist.authorizationCodes.map(normalizeWhitelistValue).filter(Boolean));
const submittedCodes = extractAuthorizationCodes(task);
if (whitelist.allowAuthorizationCodeFallback && submittedCodes.some((code) => allowedCodes.has(code))) {
return {
ok: true,
enabled: true,
method: 'authorization_code',
sourceValues,
authorizationCodeProvided: true,
};
}
return {
ok: false,
enabled: true,
sourceValues,
configuredSenderCount: whitelist.allowedSenders.length,
configuredChannelCount: whitelist.allowedChannels.length,
authorizationCodeProvided: submittedCodes.length > 0,
};
}
function normalizeIdentityPart(value) {
return String(value || '').trim().replace(/\s+/g, '').toLowerCase();
}
function uniqueSorted(values) {
return [...new Set(values.filter(Boolean).map(String))].sort();
}
function extractTaskDates(task) {
const fields = (task && task.fields) || {};
if (Array.isArray(fields.departureDates) && fields.departureDates.length) {
return uniqueSorted(fields.departureDates);
}
if (fields.departureDate) return uniqueSorted([fields.departureDate]);
if (fields.dateRange && (fields.dateRange.start || fields.dateRange.end)) {
return uniqueSorted([`${fields.dateRange.start || ''}..${fields.dateRange.end || ''}`]);
}
return [];
}
function taskIdentity(task) {
const fields = (task && task.fields) || {};
const customer = normalizeIdentityPart(fields.bookingCustomer || fields.channelCustomer || fields.customer);
const product = normalizeIdentityPart(fields.productName || fields.productRoute || fields.routeProduct);
const parentGroupNo = normalizeIdentityPart(fields.parentGroupNo);
return {
route: normalizeIdentityPart(task && task.route),
dates: extractTaskDates(task),
primary: parentGroupNo || [customer, product].filter(Boolean).join('|') || product || customer,
};
}
function sameTaskIdentity(leftTask, rightTask) {
const left = taskIdentity(leftTask);
const right = taskIdentity(rightTask);
if (!left.route || left.route !== right.route) return false;
if (!left.primary || left.primary !== right.primary) return false;
if (left.dates.length !== right.dates.length) return false;
return left.dates.every((date, index) => date === right.dates[index]);
}
function normalizeUpdateText(value) {
return String(value || '')
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.split('\n')
.map((line) => line.trim().replace(/\s+/g, ' '))
.filter(Boolean)
.join('\n');
}
function canonicalValue(value) {
if (value === null || value === undefined) return value;
if (typeof value === 'number') return Number.isFinite(value) ? value : String(value);
if (typeof value === 'boolean') return value;
if (typeof value === 'string') return value.trim().replace(/\s+/g, ' ');
if (Array.isArray(value)) return value.map(canonicalValue);
if (typeof value === 'object') {
return Object.keys(value).sort().reduce((acc, key) => {
acc[key] = canonicalValue(value[key]);
return acc;
}, {});
}
return String(value);
}
function stableJson(value) {
return JSON.stringify(canonicalValue(value));
}
function canonicalUpdateActions(updatePlan = {}) {
return (Array.isArray(updatePlan.actions) ? updatePlan.actions : [])
.map((action) => ({
target: normalizeIdentityPart(action && action.target),
operation: normalizeIdentityPart(action && action.operation),
value: canonicalValue(action && action.value),
}))
.sort((left, right) => stableJson(left).localeCompare(stableJson(right)));
}
function updateTaskFingerprint(task = {}) {
const actions = canonicalUpdateActions(task.updatePlan || {});
const payload = {
operation: 'update_order',
identifier: normalizeIdentityPart(task.identifier),
actions,
};
if (!actions.length) {
payload.updateText = normalizeUpdateText(task.updateText || task.originalText || '');
}
return stableJson(payload);
}
function updateTaskRequestsDuplicateOverride(task = {}) {
const text = normalizeUpdateText([
task.updateText,
task.originalText,
].filter(Boolean).join('\n'));
return /force[_\s-]?duplicate[_\s-]?update|again[_\s-]?same[_\s-]?update|再次执行相同修改|确认再次执行相同修改/i.test(text);
}
function auditCreatedAtMs(auditPath, payload) {
const createdAt = payload && payload.task && payload.task.createdAt;
const parsed = createdAt ? Date.parse(createdAt) : NaN;
if (Number.isFinite(parsed)) return parsed;
try {
return fs.statSync(auditPath).mtimeMs;
} catch {
return 0;
}
}
function findRecentNoSaveBlockForSameOrder(task, auditDir, options = {}) {
if (!auditDir || !fs.existsSync(auditDir)) return null;
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
const windowMs = Number.isFinite(options.windowMs) ? options.windowMs : DEFAULT_NO_SAVE_BLOCK_WINDOW_MS;
const files = fs.readdirSync(auditDir)
.filter((file) => file.endsWith('.json'))
.map((file) => path.join(auditDir, file))
.sort((left, right) => {
try {
return fs.statSync(right).mtimeMs - fs.statSync(left).mtimeMs;
} catch {
return 0;
}
})
.slice(0, 200);
for (const filePath of files) {
let payload;
try {
payload = readJson(filePath);
} catch {
continue;
}
if (!payload || payload.status !== 'blocked') continue;
if (payload.reason !== 'order_text_forbids_real_submit') continue;
if (!payload.task || !sameTaskIdentity(task, payload.task)) continue;
const createdAtMs = auditCreatedAtMs(filePath, payload);
if (!createdAtMs || nowMs - createdAtMs > windowMs) continue;
return {
auditPath: payload.auditPath || filePath,
createdAt: payload.task.createdAt || new Date(createdAtMs).toISOString(),
};
}
return null;
}
function findRecentCompletedUpdateForSameChange(task, auditDir, options = {}) {
if (!auditDir || !fs.existsSync(auditDir)) return null;
const targetFingerprint = updateTaskFingerprint(task);
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
const windowMs = Number.isFinite(options.windowMs) ? options.windowMs : DEFAULT_UPDATE_DUPLICATE_WINDOW_MS;
const files = fs.readdirSync(auditDir)
.filter((file) => file.endsWith('.json'))
.map((file) => path.join(auditDir, file))
.sort((left, right) => {
try {
return fs.statSync(right).mtimeMs - fs.statSync(left).mtimeMs;
} catch {
return 0;
}
})
.slice(0, 300);
for (const filePath of files) {
let payload;
try {
payload = readJson(filePath);
} catch {
continue;
}
if (!payload || payload.status !== 'completed') continue;
if (payload.operation !== 'update_order') continue;
if (!payload.task || updateTaskFingerprint(payload.task) !== targetFingerprint) continue;
const createdAtMs = auditCreatedAtMs(filePath, payload);
if (!createdAtMs || (windowMs >= 0 && nowMs - createdAtMs > windowMs)) continue;
return {
auditPath: payload.auditPath || filePath,
createdAt: payload.task.createdAt || new Date(createdAtMs).toISOString(),
identifier: payload.task.identifier || '',
};
}
return null;
}
function formatIdentifierLines(identifiers) {
if (!identifiers || typeof identifiers !== 'object') return [];
if (identifiers.dateToGroupNo) {
return Object.entries(identifiers.dateToGroupNo).map(([date, groupNo]) => `- ${date}: ${groupNo}`);
}
if (identifiers.dateToParentGroupNo) {
return Object.entries(identifiers.dateToParentGroupNo).map(([date, groupNo]) => `- ${date}: ${groupNo}`);
}
if (identifiers.groupNo) return [`- ERP group no: ${identifiers.groupNo}`];
if (identifiers.parentGroupNo || identifiers.childOrderNo) {
const lines = [];
if (identifiers.parentGroupNo) lines.push(`- Parent group no: ${identifiers.parentGroupNo}`);
if (identifiers.childOrderNo) lines.push(`- Child order no: ${identifiers.childOrderNo}`);
return lines;
}
return Object.entries(identifiers).map(([key, value]) => `- ${key}: ${value}`);
}
function collectWarningMessages(executionResult = {}) {
const warnings = [];
const add = (items) => {
if (!Array.isArray(items)) return;
for (const item of items) {
const message = item && (item.message || item.customerMessage || item.reason);
if (message) warnings.push(String(message));
}
};
add(executionResult.warnings);
add(executionResult.rawExecutionResult && executionResult.rawExecutionResult.warnings);
add(executionResult.rawExecutionResult
&& executionResult.rawExecutionResult.stageResults
&& executionResult.rawExecutionResult.stageResults.submit
&& executionResult.rawExecutionResult.stageResults.submit.warnings);
return [...new Set(warnings)];
}
function formatCompletionMessage(task, executionResult) {
const lines = [
'ERP execution completed.',
`Route: ${routeLabel(task.route)}`,
];
const warningMessages = collectWarningMessages(executionResult);
if (warningMessages.length) {
lines.push('提醒:');
lines.push(...warningMessages.map((message) => `- ${message}`));
}
const identifierLines = formatIdentifierLines(executionResult.identifiers);
if (identifierLines.length) {
lines.push('Identifiers:');
lines.push(...identifierLines);
}
if (executionResult.artifacts && executionResult.artifacts.length) {
lines.push('PDF files are ready or queued for delivery.');
}
return lines.join('\n');
}
function defaultHandlers() {
const handlers = {};
try {
const operationHandlers = require('./erp_operation_handlers');
handlers.update_order = operationHandlers.executeUpdateOrder;
handlers.export_confirmation = operationHandlers.executeExportConfirmation;
} catch {
// Keep dispatcher usable when operation handlers are absent.
}
try {
handlers.team_single = require('./erp_team_single_executor').executeTeamSingle;
} catch {
// Keep dispatcher usable when optional route executors are absent.
}
try {
handlers.team_batch = require('./erp_team_batch_executor').executeTeamBatch;
} catch {
// Keep dispatcher usable when optional route executors are absent.
}
try {
handlers.split_parent = require('./erp_split_parent_executor').executeSplitParent;
} catch {
// Keep dispatcher usable when optional route executors are absent.
}
try {
handlers.split_child = require('./erp_split_child_executor').executeSplitChild;
} catch {
// Keep dispatcher usable when optional route executors are absent.
}
return handlers;
}
async function dispatchTask(input, options = {}) {
const mode = normalizeMode(options.mode || 'dry-run');
const config = options.config || {};
const auditDir = options.auditDir || (config.paths && config.paths.auditDir) || DEFAULT_AUDIT_DIR;
const handlers = options.handlers || defaultHandlers();
let envelope = normalizeEnvelope(input);
let result;
try {
if (envelope.status !== 'ready') {
result = blocked(
envelope.status === 'invalid' ? envelope.reason || 'invalid_input' : 'adapter_not_ready',
envelope.customerMessage || 'The incoming order is not ready for ERP execution.',
{
mode,
route: envelope.route || null,
adapterStatus: envelope.status,
}
);
return writeAudit(result, auditDir);
}
const taskValidation = validateTaskEnvelope(envelope);
if (!taskValidation.valid) {
result = blocked(
taskValidation.status === 'needs_clarification' ? 'adapter_not_ready' : 'invalid_task',
taskValidation.status === 'needs_clarification'
? 'The incoming order is not ready for ERP execution.'
: 'The ERP task JSON is invalid and was not sent to an executor.',
{
mode,
operation: taskValidation.task && taskValidation.task.operation || envelope.operation || null,
route: taskValidation.task && taskValidation.task.route || envelope.route || null,
task: taskValidation.task,
violations: taskValidation.violations,
}
);
return writeAudit(result, auditDir);
}
envelope = taskValidation.envelope;
const task = taskValidation.task;
if (!task) {
result = blocked('missing_task', 'The ready envelope is missing a task.', {
mode,
route: null,
operation: envelope.operation || null,
task: null,
});
return writeAudit(result, auditDir);
}
const operation = taskOperation(task, envelope);
const handlerKey = operationHandlerKey(task, envelope);
const route = task.route || envelope.route || null;
if (!KNOWN_OPERATIONS.has(operation)) {
result = blocked('unsupported_operation', `Unsupported ERP operation: ${operation}.`, {
mode,
operation,
route,
task,
});
return writeAudit(result, auditDir);
}
if (operation === 'create_order' && !task.route) {
result = blocked('missing_task_route', 'The ready task is missing a route.', {
mode,
operation,
route: null,
task,
});
return writeAudit(result, auditDir);
}
if (mode === 'dry-run') {
result = {
status: 'dry_run',
mode,
operation,
route,
plannedExecutor: handlerKey,
task,
customerMessage: `Task is ready for ${operationLabel(operation, task.route)}.`,
};
return writeAudit(result, auditDir);
}
const allowRealSubmit = Boolean(config.safety && config.safety.allowRealSubmit);
if (!allowRealSubmit) {
result = blocked('real_submit_disabled', 'Real ERP submit is disabled by config safety.allowRealSubmit.', {
mode,
operation,
route,
task,
});
return writeAudit(result, auditDir);
}
if (orderTextForbidsRealSubmit(task)) {
result = blocked('order_text_forbids_real_submit', 'The order text forbids real ERP submit. Run dry-run or remove the no-save instruction before execute.', {
mode,
operation,
route,
task,
});
return writeAudit(result, auditDir);
}
const whitelistCheck = checkRealSubmitWhitelist(task, config, envelope, options);
if (!whitelistCheck.ok) {
result = blocked(
'real_submit_sender_not_authorized',
'真实保存 ERP 已被白名单拦截:当前微信发送人不在允许名单中。请使用已授权微信号发送,或联系管理员加入白名单。',
{
mode,
operation,
route,
task,
whitelist: whitelistCheck,
}
);
return writeAudit(result, auditDir);
}
if (!orderTextConfirmsRealSubmit(task)) {
const safety = (config && config.safety) || {};
const windowMs = Number.isFinite(Number(safety.noSaveBlockWindowMinutes))
? Number(safety.noSaveBlockWindowMinutes) * 60 * 1000
: DEFAULT_NO_SAVE_BLOCK_WINDOW_MS;
const recentNoSaveBlock = findRecentNoSaveBlockForSameOrder(task, auditDir, { windowMs });
if (recentNoSaveBlock) {
result = blocked(
'recent_no_save_block_for_same_order',
'A recent no-save block exists for the same order. Stop instead of retrying a rewritten execute task; add an explicit real-save confirmation before executing.',
{
mode,
operation,
route,
task,
recentNoSaveBlock,
}
);
return writeAudit(result, auditDir);
}
}
if (operation === 'update_order' && !updateTaskRequestsDuplicateOverride(task)) {
const safety = (config && config.safety) || {};
const windowMs = Number.isFinite(Number(safety.updateDuplicateBlockWindowMinutes))
? Number(safety.updateDuplicateBlockWindowMinutes) * 60 * 1000
: DEFAULT_UPDATE_DUPLICATE_WINDOW_MS;
const recentDuplicateUpdate = findRecentCompletedUpdateForSameChange(task, auditDir, { windowMs });
if (recentDuplicateUpdate) {
result = blocked(
'recent_duplicate_update_order',
'检测到同一个订单号和相同修改内容近期已经执行成功,已拦截本次重复修改,避免相同增量被执行两次。如确实要再次执行同样修改,请在指令中加入“再次执行相同修改”。',
{
mode,
operation,
route,
task,
recentDuplicateUpdate,
}
);
return writeAudit(result, auditDir);
}
}
const handler = handlers[handlerKey];
const knownHandlerKey = operation === 'create_order' ? KNOWN_ROUTES.has(task.route) : KNOWN_OPERATIONS.has(operation);
if (!knownHandlerKey || typeof handler !== 'function') {
result = blocked('executor_missing', `No executor is connected for operation: ${handlerKey}.`, {
mode,
operation,
route,
plannedExecutor: handlerKey,
task,
});
return writeAudit(result, auditDir);
}
const executionResult = await runExecutionHandler(
handler,
task,
{ config, mode, operation },
options,
auditDir
);
if (executionResult.status === 'blocked') {
result = {
...executionResult,
mode,
operation,
route,
task,
};
return writeAudit(result, auditDir);
}
const completed = {
status: executionResult.status || 'completed',
mode,
operation,
route,
task,
identifiers: executionResult.identifiers || {},
artifacts: executionResult.artifacts || [],
warnings: executionResult.warnings || [],
rawExecutionResult: executionResult,
};
completed.customerMessage = executionResult.customerMessage || formatCompletionMessage(task, completed);
const audited = writeAudit(completed, auditDir);
const registryPath = options.registryPath || (config.paths && config.paths.orderRegistry) || '';
if (operation === 'create_order' && registryPath) {
audited.registryRecords = orderRegistry.appendOrderRecord(audited, { registryPath });
fs.writeFileSync(audited.auditPath, JSON.stringify(cloneForAudit(audited), null, 2), 'utf8');
}
return audited;
} catch (error) {
const operation = envelope.operation || (envelope.task && envelope.task.operation) || null;
const route = envelope.route || (envelope.task && envelope.task.route) || null;
result = blocked('dispatcher_error', dispatcherErrorCustomerMessage(operation, route), {
mode,
operation,
route,
error: {
name: error.name,
code: error.code,
message: error.message,
details: error.details,
stack: error.stack,
},
});
return writeAudit(result, auditDir);
}
}
function parseArgs(argv) {
const args = {
task: '',
order: '',
config: '',
auditDir: '',
sender: '',
senderId: '',
senderName: '',
chatId: '',
channelId: '',
attachments: [],
mode: 'dry-run',
json: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--task') {
args.task = argv[index + 1];
index += 1;
} else if (arg.startsWith('--task=')) {
args.task = arg.slice('--task='.length);
} else if (arg === '--order') {
args.order = argv[index + 1];
index += 1;
} else if (arg.startsWith('--order=')) {
args.order = arg.slice('--order='.length);
} else if (arg === '--config') {
args.config = argv[index + 1];
index += 1;
} else if (arg.startsWith('--config=')) {
args.config = arg.slice('--config='.length);
} else if (arg === '--audit-dir') {
args.auditDir = argv[index + 1];
index += 1;
} else if (arg.startsWith('--audit-dir=')) {
args.auditDir = arg.slice('--audit-dir='.length);
} else if (arg === '--sender') {
args.sender = argv[index + 1];
index += 1;
} else if (arg.startsWith('--sender=')) {
args.sender = arg.slice('--sender='.length);
} else if (arg === '--sender-id') {
args.senderId = argv[index + 1];
index += 1;
} else if (arg.startsWith('--sender-id=')) {
args.senderId = arg.slice('--sender-id='.length);
} else if (arg === '--sender-name') {
args.senderName = argv[index + 1];
index += 1;
} else if (arg.startsWith('--sender-name=')) {
args.senderName = arg.slice('--sender-name='.length);
} else if (arg === '--chat-id') {
args.chatId = argv[index + 1];
index += 1;
} else if (arg.startsWith('--chat-id=')) {
args.chatId = arg.slice('--chat-id='.length);
} else if (arg === '--channel-id') {
args.channelId = argv[index + 1];
index += 1;
} else if (arg.startsWith('--channel-id=')) {
args.channelId = arg.slice('--channel-id='.length);
} else if (arg === '--attachment') {
args.attachments.push(argv[index + 1]);
index += 1;
} else if (arg.startsWith('--attachment=')) {
args.attachments.push(arg.slice('--attachment='.length));
} else if (arg === '--mode') {
args.mode = argv[index + 1];
index += 1;
} else if (arg.startsWith('--mode=')) {
args.mode = arg.slice('--mode='.length);
} else if (arg === '--json') {
args.json = true;
} else if (arg === '--help' || arg === '-h') {
args.help = true;
}
}
return args;
}
function helpText() {
return [
'Usage:',
' node tools/erp_task_dispatcher.js --order order.txt [--mode dry-run|execute] [--config config.json] [--sender-id wxid] [--json]',
' node tools/erp_task_dispatcher.js --task adapter-output.json [--mode dry-run|execute] [--config config.json] [--sender-id wxid] [--json]',
'',
'Dispatches a normalized WeChat ERP task to the route executor layer.',
'By default this is a dry-run. Real ERP submit requires config.safety.allowRealSubmit=true, an authorized sender when whitelist is enabled, and a connected executor.',
].join('\n');
}
function pickKeys(source = {}, keys = []) {
const target = {};
for (const key of keys) {
if (source[key] !== undefined && source[key] !== null && source[key] !== '') {
target[key] = source[key];
}
}
return target;
}
function summarizeTravelerImport(importResult = {}) {
return pickKeys(importResult, [
'attempted',
'imported',
'method',
'expectedRows',
'beforeRows',
'beforeFilledRows',
'afterRows',
'afterFilledRows',
'pasteRows',
'reason',
]);
}
function collectTravelerImports(value, results = [], seen = new Set(), depth = 0) {
if (!value || typeof value !== 'object' || depth > 12 || seen.has(value)) return results;
seen.add(value);
if (value.travelerImport && typeof value.travelerImport === 'object') {
results.push(summarizeTravelerImport(value.travelerImport));
}
if (Array.isArray(value)) {
for (const item of value) collectTravelerImports(item, results, seen, depth + 1);
return results;
}
for (const [key, item] of Object.entries(value)) {
if (key === 'task' || key === 'travelerList' || key === 'travelers') continue;
collectTravelerImports(item, results, seen, depth + 1);
}
return results;
}
function summarizeRegistryRecords(records = []) {
if (!Array.isArray(records)) return [];
return records.map((record) => pickKeys(record, [
'operation',
'route',
'identifier',
'groupNo',
'childOrderNo',
'parentGroupNo',
'departureDate',
'ddid',
'did',
'tid',
'parentTid',
'childDid',
'updatedAt',
]));
}
function summarizeCliResult(result = {}) {
const summary = pickKeys(result, [
'status',
'reason',
'mode',
'operation',
'route',
'plannedExecutor',
'customerMessage',
'auditPath',
]);
if (result.identifiers) summary.identifiers = result.identifiers;
if (Array.isArray(result.artifacts)) summary.artifacts = result.artifacts;
if (Array.isArray(result.warnings)) summary.warnings = result.warnings;
const travelerImports = collectTravelerImports(result)
.filter((item) => Object.keys(item).length > 0);
if (travelerImports.length) summary.travelerImports = travelerImports;
const registryRecords = summarizeRegistryRecords(result.registryRecords);
if (registryRecords.length) summary.registryRecords = registryRecords;
if (result.error) summary.error = pickKeys(result.error, ['name', 'code', 'message']);
return summary;
}
async function loadInputEnvelope(args) {
if (args.order) {
const adapter = require('./erp_operation_adapter');
const orderPath = resolvePath(args.order);
const text = fs.readFileSync(orderPath, 'utf8');
return adapter.handleIncomingMessage(text, {
auditDir: args.auditDir ? resolvePath(args.auditDir) : undefined,
attachments: (args.attachments || []).map((item) => resolvePath(item)),
});
}
if (args.task) {
return readJson(resolvePath(args.task));
}
throw new Error('Missing --order or --task.');
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help || (!args.order && !args.task)) {
console.log(helpText());
process.exitCode = args.help ? 0 : 1;
return;
}
const config = readConfig(args.config);
const auditDir = args.auditDir ? resolvePath(args.auditDir) : undefined;
const envelope = await loadInputEnvelope(args);
const result = await dispatchTask(envelope, {
mode: args.mode,
config,
auditDir,
source: {
sender: args.sender,
senderId: args.senderId,
senderName: args.senderName,
chatId: args.chatId,
channelId: args.channelId,
},
});
if (args.json) {
console.log(JSON.stringify(summarizeCliResult(result), null, 2));
} else {
console.log(result.customerMessage);
console.log(`audit: ${result.auditPath}`);
}
if (result.status === 'blocked') process.exitCode = 2;
await new Promise((resolve) => process.stdout.write('', resolve));
process.exit(process.exitCode || 0);
}
if (require.main === module) {
main().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});
}
module.exports = {
KNOWN_ROUTES,
KNOWN_OPERATIONS,
normalizeEnvelope,
readConfig,
dispatchTask,
formatCompletionMessage,
collectWarningMessages,
taskOperation,
operationHandlerKey,
summarizeCliResult,
orderTextForbidsRealSubmit,
orderTextConfirmsRealSubmit,
checkRealSubmitWhitelist,
collectSourceValues,
extractAuthorizationCodes,
findRecentNoSaveBlockForSameOrder,
findRecentCompletedUpdateForSameChange,
updateTaskFingerprint,
updateTaskRequestsDuplicateOverride,
loadInputEnvelope,
parseArgs,
summarizeCliResult,
};