1214 lines
39 KiB
JavaScript
1214 lines
39 KiB
JavaScript
#!/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,
|
||
};
|