迭代批量安排酒店
This commit is contained in:
1 parent
4cfbefffe8
commit
c2c71d35af
48 files changed
+5670
-57
No files matched your search
@@ -11,9 +11,14 @@ Chrome Manifest V3 扩展,在用户已登录的 LTJT ERP 页面内执行经过
|
||||
|
||||
## 当前版本
|
||||
|
||||
当前源码版本为 `0.5.189`。版本化 ZIP、文件哈希和 Skill/DOCX 基线见 [`../../dist/release-manifest.json`](../../dist/release-manifest.json)。旧版本实现流水已冻结在 [`../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md`](../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md)。
|
||||
当前源码版本为 `0.5.190`。版本化 ZIP、文件哈希和 Skill/DOCX 基线见 [`../../dist/release-manifest.json`](../../dist/release-manifest.json)。旧版本实现流水已冻结在 [`../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md`](../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md)。
|
||||
|
||||
0.5.189 当前重点:
|
||||
0.5.190 当前重点:
|
||||
|
||||
- 同团 2—10 条酒店新增由程序解析为批量信封,插件按输入顺序复用单条原生保存/回查;首次失败或不确定即停止,逐条保存成功前缀、失败/待核验项与未执行项。每条确认成功才进入账号隔离的安排账本和团队进度;整批成功回执包含逐条结果及当前安排。只读回查只核验不确定项,不自动继续剩余安排。禁止跨团、混合动作、批量变更、整批自动重试。
|
||||
- 需同步更新控制面、平台和插件 0.5.190,无新增数据库迁移。单条输入及原生资源匹配规则保持;本地合成验证通过,真实 ERP 验收待部署方完成。
|
||||
|
||||
继续保留 0.5.189 的默认日期空行修复:
|
||||
|
||||
- 大交通、其他/备案新增支持带团队默认日期的备用空行:仅允许行日期等于页面有效发团日期 chufari(兼容补零格式),数量为空/0/1,且原有资源、备注、费用、付款、审核保护全部通过。其他日期或无法验证默认值仍阻断。只改选中行的目标日期,保留其他行与全局备案信息;酒店/用车/导游和变更/清除规则保持。本地合成回归通过,真实 ERP 验收待部署方完成;需更新插件至 0.5.189、同步平台最低版本并刷新 ERP 页面,无服务端逻辑变更或数据库迁移。
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use strict';
|
||||
|
||||
importScripts('source-region.js', 'operation-plans.js', 'execution-guard.js', 'lifecycle-result.js', 'operation-timing.js', 'erp-account-identity.js');
|
||||
importScripts('source-region.js', 'operation-plans.js', 'execution-guard.js', 'lifecycle-result.js', 'operation-timing.js', 'erp-account-identity.js', 'hotel-batch.js');
|
||||
|
||||
const hotelBatch = self.LTJTHotelBatch;
|
||||
const runningTasks = new Set();
|
||||
const executionWorkerKeepalives = new Map();
|
||||
const cancelledTasks = new Set();
|
||||
@@ -471,7 +472,8 @@ async function cacheResolvedExecutionOperation(taskId, operation) {
|
||||
const saved = await chrome.storage.local.get('businessTasks');
|
||||
const tasks = saved.businessTasks || {};
|
||||
if (!tasks[rootId]) return;
|
||||
tasks[rootId] = { ...tasks[rootId], operation };
|
||||
const key = taskId.includes('#hotel-') ? taskId : rootId;
|
||||
tasks[key] = { ...tasks[rootId], task_id: key, operation };
|
||||
await chrome.storage.local.set({ businessTasks: tasks });
|
||||
});
|
||||
}
|
||||
@@ -714,7 +716,7 @@ async function setResult(taskId, result, { allowCancelled = false } = {}) {
|
||||
const nextStatus = String(nextResult.status || '');
|
||||
const terminalResult = /^(completed|dry_run|blocked|failed|cancelled|saved_unverified|execution_uncertain|reconciliation_pending)$/;
|
||||
const resolvesReconciliation = /^(?:saved_unverified|execution_uncertain|reconciliation_pending)$/.test(currentStatus)
|
||||
&& nextStatus === 'completed'
|
||||
&& (nextStatus === 'completed' || (nextStatus === 'blocked' && hotelBatch.inspect(nextResult.hotel_batch)?.knownStopped))
|
||||
&& nextResult.reconciliation_resolved === true
|
||||
&& nextResult.write_attempted === true;
|
||||
if (terminalResult.test(currentStatus) && currentStatus !== nextStatus && !resolvesReconciliation) {
|
||||
@@ -734,7 +736,8 @@ async function setResult(taskId, result, { allowCancelled = false } = {}) {
|
||||
// happened before the ERP write boundary and must remain retryable as
|
||||
// a normal blocked task rather than being mislabeled uncertain.
|
||||
const knownNoWrite = nextResult.no_erp_write === true && nextResult.write_attempted !== true;
|
||||
const failureState = knownNoWrite ? 'blocked' : executionGuard.failureState(execution);
|
||||
const knownBatchStop = hotelBatch.inspect(nextResult.hotel_batch)?.knownStopped === true;
|
||||
const failureState = knownNoWrite || knownBatchStop ? 'blocked' : executionGuard.failureState(execution);
|
||||
executions[rootId] = { ...execution, state: failureState, updated_at: nowIso() };
|
||||
if (failureState === 'uncertain') {
|
||||
nextResult = {
|
||||
@@ -2623,7 +2626,9 @@ function lifecycleRouteFailure(operation, report = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
async function executeLifecycleOperation(taskId, operation, operationPlan) {
|
||||
async function executeLifecycleOperation(taskId, operation, operationPlan, options = {}) {
|
||||
const transitionExecution = (id, state, details) => options.batchItem && ['blocked', 'uncertain'].includes(state)
|
||||
? Promise.resolve() : transitionCurrentExecution(id, state, details);
|
||||
throwIfTaskCancelled(taskId);
|
||||
let executionOperation = operation;
|
||||
let executionPlan = operationPlan;
|
||||
@@ -2794,7 +2799,7 @@ async function executeLifecycleOperation(taskId, operation, operationPlan) {
|
||||
}
|
||||
|
||||
const frameTarget = preflight.frame_id ? { frameIds: [preflight.frame_id] } : { allFrames: false };
|
||||
await transitionCurrentExecution(taskId, 'write_started', {
|
||||
await transitionExecution(taskId, 'write_started', {
|
||||
write_started_at: nowIso(),
|
||||
current_task_id: taskId,
|
||||
browser_action: 'liveSubmitLifecycleOperation',
|
||||
@@ -2854,7 +2859,7 @@ async function executeLifecycleOperation(taskId, operation, operationPlan) {
|
||||
const message = businessFailure?.message
|
||||
|| linkageFailureMessage
|
||||
|| '生命周期适配器在提交前阻断,未写入 ERP。';
|
||||
await transitionCurrentExecution(taskId, 'blocked', {
|
||||
await transitionExecution(taskId, 'blocked', {
|
||||
blocked_at: nowIso(),
|
||||
current_task_id: taskId
|
||||
});
|
||||
@@ -2895,7 +2900,7 @@ async function executeLifecycleOperation(taskId, operation, operationPlan) {
|
||||
|| live.server_response?.business_blocked === true;
|
||||
if (businessBlocked) {
|
||||
const message = businessMessage || 'ERP 业务规则阻止了本次操作。';
|
||||
await transitionCurrentExecution(taskId, 'blocked', {
|
||||
await transitionExecution(taskId, 'blocked', {
|
||||
blocked_at: nowIso(),
|
||||
current_task_id: taskId,
|
||||
lifecycle_requery: live.requery || null
|
||||
@@ -2916,7 +2921,7 @@ async function executeLifecycleOperation(taskId, operation, operationPlan) {
|
||||
}
|
||||
|
||||
if (lifecycleResult?.isVerifiedResult?.(live) === true) {
|
||||
await transitionCurrentExecution(taskId, 'submitted', {
|
||||
await transitionExecution(taskId, 'submitted', {
|
||||
submitted_at: nowIso(),
|
||||
current_task_id: taskId,
|
||||
lifecycle_requery: live.requery
|
||||
@@ -2934,7 +2939,7 @@ async function executeLifecycleOperation(taskId, operation, operationPlan) {
|
||||
return { ok: true, status: 'completed', task_id: taskId, report: live };
|
||||
}
|
||||
|
||||
await transitionCurrentExecution(taskId, 'uncertain', {
|
||||
await transitionExecution(taskId, 'uncertain', {
|
||||
uncertain_at: nowIso(),
|
||||
current_task_id: taskId,
|
||||
lifecycle_requery: live.requery || null
|
||||
@@ -3422,6 +3427,71 @@ async function executeTeamBatchNativeTask(taskId, operation) {
|
||||
};
|
||||
}
|
||||
|
||||
async function publishHotelBatch(taskId, operation, batch, { final = false, reconciled = false, allowCancelled = false } = {}) {
|
||||
const stats = hotelBatch.inspect(batch);
|
||||
if (!stats) throw new Error('酒店批量结果结构不完整,禁止自动重试。');
|
||||
const status = !final ? 'running' : stats.allCompleted ? 'completed' : stats.knownStopped ? 'blocked' : 'execution_uncertain';
|
||||
// The parent is polled independently of child progress. While a child runs,
|
||||
// it may already have crossed the save boundary before its final result arrives.
|
||||
const writeAttempted = batch.items.some(item => item.result?.write_attempted === true || item.result?.no_erp_write === false || ['running', 'completed', 'uncertain'].includes(item.status));
|
||||
const title = stats.allCompleted ? '酒店批量安排成功' : stats.completed ? '酒店批量安排部分完成' : '酒店批量安排未完成';
|
||||
const message = `📢【${title}】\n\n团号:${batch.group_number}\n\n${hotelBatch.resultText(batch)}`;
|
||||
return setResult(taskId, { status, stage: 'hotel_batch', hotel_batch: batch,
|
||||
message: final ? message : `正在执行第 ${batch.items.findIndex(item => item.status !== 'completed') + 1} / ${stats.total} 条酒店安排。`,
|
||||
group_number: batch.group_number, summary: { action: 'arrangement_hotel' },
|
||||
report: { status: stats.allCompleted ? 'hotel_batch_completed' : 'hotel_batch_progress' },
|
||||
blockers: [], no_erp_write: !writeAttempted, write_attempted: writeAttempted,
|
||||
uncertain: status === 'execution_uncertain', manual_review_required: status === 'execution_uncertain',
|
||||
error_code: final && !stats.allCompleted ? 'hotel_batch_stopped' : '',
|
||||
failure_message: final && !stats.allCompleted ? message : '',
|
||||
...(reconciled ? { reconciliation_resolved: true, no_additional_erp_write: true } : {}) }, { allowCancelled });
|
||||
}
|
||||
|
||||
async function stopHotelBatch(taskId, operation, message) {
|
||||
const saved = await chrome.storage.local.get('businessTaskResults');
|
||||
const batch = saved.businessTaskResults?.[taskId]?.hotel_batch;
|
||||
if (!batch) return false;
|
||||
for (const item of batch.items) if (item.status === 'running') {
|
||||
const result = saved.businessTaskResults?.[`${taskId}#hotel-${item.index + 1}`];
|
||||
item.result = result || { status: 'execution_uncertain', no_erp_write: false, write_attempted: true, message };
|
||||
item.status = hotelBatch.verified(result, batch.group_number) ? 'completed'
|
||||
: result?.no_erp_write === true && result?.write_attempted !== true && result?.status === 'blocked' ? 'failed' : 'uncertain';
|
||||
}
|
||||
batch.stopped = true;
|
||||
await publishHotelBatch(taskId, operation, batch, { final: true, allowCancelled: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
async function executeHotelBatch(taskId, operation) {
|
||||
const operations = operationPlans.expandHotelBatch(operation);
|
||||
const batch = { version: 1, group_number: operation.data.existing_refs.identifier,
|
||||
items: operations.map((item, index) => ({ index, arrangement: item.data.arrangement, status: 'not_started' })) };
|
||||
for (const [index, item] of operations.entries()) {
|
||||
throwIfTaskCancelled(taskId);
|
||||
const childId = `${taskId}#hotel-${index + 1}`;
|
||||
batch.items[index].status = 'running';
|
||||
await publishHotelBatch(taskId, operation, batch);
|
||||
let result;
|
||||
try {
|
||||
await executeLifecycleOperation(childId, item, operationPlans.validateDispatchOperation(item), { batchItem: true });
|
||||
const saved = await chrome.storage.local.get('businessTaskResults');
|
||||
result = saved.businessTaskResults?.[childId];
|
||||
if (!result) throw new Error('未取得该条酒店安排结果。');
|
||||
} catch (error) {
|
||||
if (error?.code === 'task_cancelled') throw error;
|
||||
// A missing result is never proof of no write. Keep the completed prefix.
|
||||
result = { status: 'execution_uncertain', no_erp_write: false, write_attempted: true,
|
||||
message: error.message || String(error), report: {} };
|
||||
}
|
||||
batch.items[index].result = result;
|
||||
batch.items[index].status = hotelBatch.verified(result, batch.group_number) ? 'completed'
|
||||
: result.no_erp_write === true && result.write_attempted !== true ? 'failed' : 'uncertain';
|
||||
const final = batch.items[index].status !== 'completed' || index === operations.length - 1;
|
||||
await publishHotelBatch(taskId, operation, batch, { final });
|
||||
if (final) return { ok: batch.items.every(row => row.status === 'completed'), task_id: taskId, hotel_batch: batch };
|
||||
}
|
||||
}
|
||||
|
||||
async function runAutoTask(taskId, executionId) {
|
||||
const rootId = executionGuard.rootTaskId(taskId);
|
||||
if (isTaskCancelled(taskId)) {
|
||||
@@ -3465,6 +3535,8 @@ async function runAutoTask(taskId, executionId) {
|
||||
return { ok: false, status: 'validation_blocked', blockers };
|
||||
}
|
||||
|
||||
if (operationPlan.execution === 'browser_hotel_batch') return await executeHotelBatch(normalizedTaskId, operation);
|
||||
|
||||
if (operationPlan.execution === 'browser_team_batch_native') {
|
||||
return await executeTeamBatchNativeTask(normalizedTaskId, operation);
|
||||
}
|
||||
@@ -3567,6 +3639,9 @@ async function runAutoTask(taskId, executionId) {
|
||||
}
|
||||
return await executeTeamSingleTask(normalizedTaskId, operation);
|
||||
} catch (error) {
|
||||
if (operation?.data?.arrangements && await stopHotelBatch(rootId, operation, error.message || String(error))) {
|
||||
return { ok: false, task_id: rootId, status: 'hotel_batch_stopped' };
|
||||
}
|
||||
if (error?.code === 'task_cancelled') {
|
||||
await setResult(taskId, {
|
||||
stage: 'cancelled',
|
||||
@@ -3585,6 +3660,7 @@ async function runAutoTask(taskId, executionId) {
|
||||
async function timeoutAutoTask(taskId, executionId) {
|
||||
const rootId = executionGuard.rootTaskId(taskId);
|
||||
cancelledTasks.add(rootId);
|
||||
if (await stopHotelBatch(rootId, null, '酒店批量执行超时,已停止后续安排。')) return;
|
||||
const execution = await getExecutionRecord(rootId);
|
||||
const disposition = executionGuard.timeoutDisposition(execution);
|
||||
const message = disposition.reconciliation_required
|
||||
@@ -3771,6 +3847,31 @@ async function runLifecycleReadOnlyVerification(tab, rootId, operation, priorPre
|
||||
};
|
||||
}
|
||||
|
||||
async function reconcileHotelBatch(rootId, operation, existingResult, saved) {
|
||||
const batch = existingResult.hotel_batch;
|
||||
if (!hotelBatch.inspect(batch)) throw new Error('缺少完整批量结果,禁止重试写入。');
|
||||
const item = batch.items.find(row => row.status === 'uncertain');
|
||||
if (!item) throw new Error('没有需要回查的酒店条目。');
|
||||
const childId = `${rootId}#hotel-${item.index + 1}`;
|
||||
const childOperation = saved.businessTasks?.[childId]?.operation;
|
||||
const prior = item.result?.report;
|
||||
if (!childOperation || prior?.server_response?.completed !== true) throw new Error('该条缺少已保存成功的原生响应,请人工核对 ERP;禁止重新提交整批。');
|
||||
const gate = operationPlans.validateOperation(childOperation);
|
||||
if (!gate.ok || gate.execution !== 'browser_lifecycle') throw new Error('该条原始酒店 operation 校验未通过。');
|
||||
runningTasks.add(rootId);
|
||||
try {
|
||||
const tab = await findOrOpenErpTab(rootId);
|
||||
const readOnly = await runLifecycleReadOnlyVerification(tab, rootId, childOperation, prior.preflight);
|
||||
if (!readOnly.resolved) return { ok: false, status: 'reconciliation_pending', message: readOnly.message };
|
||||
item.result = { ...item.result, status: 'completed', blockers: [], uncertain: false, manual_review_required: false,
|
||||
report: { ...prior, status: 'lifecycle_completed', blockers: [], manual_review_required: false, requery: readOnly.verification.requery } };
|
||||
item.status = 'completed';
|
||||
batch.stopped = true;
|
||||
await publishHotelBatch(rootId, operation, batch, { final: true, reconciled: true, allowCancelled: true });
|
||||
return { ok: true, status: hotelBatch.inspect(batch)?.allCompleted ? 'completed' : 'blocked', task_id: rootId, no_additional_erp_write: true, reconciliation_resolved: true };
|
||||
} finally { runningTasks.delete(rootId); }
|
||||
}
|
||||
|
||||
async function reconcileLifecycleTask(taskId) {
|
||||
const rootId = executionGuard.rootTaskId(String(taskId || '').trim());
|
||||
if (!rootId) throw new Error('缺少待回查 task_id。');
|
||||
@@ -3788,6 +3889,7 @@ async function reconcileLifecycleTask(taskId) {
|
||||
if (!/^(?:saved_unverified|execution_uncertain|reconciliation_pending)$/.test(String(existingResult.status || ''))) {
|
||||
throw new Error('只允许对已跨越 ERP 写入边界且结果不确定的生命周期任务做只读回查。');
|
||||
}
|
||||
if (operation.data?.arrangements) return reconcileHotelBatch(rootId, operation, existingResult, saved);
|
||||
const validation = operationPlans.validateOperation(operation);
|
||||
if (validation.execution !== 'browser_lifecycle' || validation.blockers.length) {
|
||||
throw new Error(`原始生命周期 operation 已不符合当前契约:${validation.blockers.join(';') || validation.execution}。`);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
root.LTJTHotelBatch = api;
|
||||
})(typeof self !== 'undefined' ? self : globalThis, function () {
|
||||
'use strict';
|
||||
const object = value => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
const group = value => String(value || '').normalize('NFKC').replace(/\s+/gu, '').toUpperCase();
|
||||
function verified(result, identifier) {
|
||||
const report = object(result?.report), refs = object(report.resolved_refs);
|
||||
return result?.status === 'completed' && report.status === 'lifecycle_completed' && report.server_response?.completed === true
|
||||
&& report.requery?.matched === true && report.requery?.skipped !== true
|
||||
&& !result.uncertain && !result.manual_review_required && !report.manual_review_required
|
||||
&& ![result.blockers, report.blockers].some(value => Array.isArray(value) && value.length)
|
||||
&& refs.kind !== 'shared_child_order' && group(refs.identifier || refs.group_no) === group(identifier);
|
||||
}
|
||||
function inspect(value) {
|
||||
const batch = object(value), items = batch.items;
|
||||
if (batch.version !== 1 || !group(batch.group_number) || !Array.isArray(items)
|
||||
|| items.length < 2 || items.length > 10) return null;
|
||||
let stopped = false, pending = false, completed = 0, failed = 0, uncertain = 0;
|
||||
const rowIds = new Set();
|
||||
for (let index = 0; index < items.length; index++) {
|
||||
const item = object(items[index]);
|
||||
if (item.index !== index || item.arrangement?.mode !== 'create') return null;
|
||||
if (item.status === 'completed') {
|
||||
if (stopped || pending || !verified(item.result, batch.group_number)) return null;
|
||||
const subpage = object(item.result.report.requery.subpage), target = object(subpage.arrangement_target);
|
||||
const rowId = String(target.row_id || '');
|
||||
if (!rowId || rowId === '0' || rowIds.has(rowId)) return null;
|
||||
rowIds.add(rowId);
|
||||
const normalizeDate = value => String(value || '').replace(/^(\d{4})-(\d{1,2})-(\d{1,2})$/, (_, y, m, d) => `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`);
|
||||
const checks = Array.isArray(subpage.checks) ? subpage.checks : [];
|
||||
const actual = pattern => checks.filter(check => check.matched === true && pattern.test(check.field));
|
||||
for (const [pattern, expected] of [[/^riqi\d+$/, item.arrangement.start_date], [/^riqis\d+$/, item.arrangement.end_date]]) {
|
||||
const matches = actual(pattern);
|
||||
if (matches.length !== 1 || normalizeDate(matches[0].actual) !== expected) return null;
|
||||
}
|
||||
if (Number(target.quantity_value) !== item.arrangement.room_count) return null;
|
||||
completed++;
|
||||
} else if (item.status === 'failed') {
|
||||
if (stopped || pending || item.result?.no_erp_write !== true || item.result?.write_attempted === true) return null;
|
||||
stopped = true; failed++;
|
||||
} else if (item.status === 'uncertain') {
|
||||
if (stopped || pending) return null;
|
||||
stopped = true; uncertain++;
|
||||
} else if (item.status === 'running') {
|
||||
if (stopped || pending) return null;
|
||||
pending = true;
|
||||
} else if (item.status === 'not_started') pending = true;
|
||||
else return null;
|
||||
}
|
||||
return { completed, failed, uncertain, total: items.length,
|
||||
not_started: items.filter(item => item.status === 'not_started').length,
|
||||
allCompleted: completed === items.length, knownStopped: (failed === 1 || (batch.stopped === true && completed > 0 && !items.some(item => item.status === 'running'))) && uncertain === 0 };
|
||||
}
|
||||
function resultText(batch) {
|
||||
const stats = inspect(batch);
|
||||
if (!stats) return '';
|
||||
const lines = [`共 ${stats.total} 条:成功 ${stats.completed} 条,失败 ${stats.failed} 条,待核验 ${stats.uncertain} 条,未执行 ${stats.not_started} 条`];
|
||||
const labels = { completed: '已完成', failed: '失败', uncertain: '结果不确定,待只读回查', not_started: '未执行', running: '处理中' };
|
||||
batch.items.forEach(item => {
|
||||
const a = item.arrangement;
|
||||
lines.push('', `${item.index + 1}. ${String(a.start_date).replaceAll('-', '/')}—${String(a.end_date).replaceAll('-', '/')}`,
|
||||
` 酒店搜索:${a.resource?.keyword || a.resource?.name || ''};${a.room_count} 间`, ` ${labels[item.status]}`);
|
||||
if (item.status === 'failed') lines.push(` 原因:${String(item.result?.failure_message || item.result?.message || '执行阻断').slice(0, 250)}`);
|
||||
});
|
||||
if (!stats.allCompleted) lines.push('', '已完成的安排已保留,请勿整批重复提交。');
|
||||
return lines.join('\n');
|
||||
}
|
||||
return { verified, inspect, resultText };
|
||||
});
|
||||
@@ -6677,7 +6677,7 @@
|
||||
}
|
||||
|
||||
window.LTJTOrderAssistant = {
|
||||
version: '0.5.189',
|
||||
version: '0.5.190',
|
||||
resolveNativeListSearchValues,
|
||||
lookupKeywordMatchesText,
|
||||
inspectLifecycleSearchCriteria: lifecycleSearchCriteria,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "联泰下单助手",
|
||||
"version": "0.5.189",
|
||||
"version": "0.5.190",
|
||||
"description": "在已登录 LTJT ERP 页面内规划并受控执行联泰 ERP 业务操作。",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
|
||||
@@ -1315,8 +1315,38 @@
|
||||
if (types.includes('pickup-sign') && (typeof data.visitor_name !== 'string' || !data.visitor_name.trim())) blockers.push('接机牌必须提供游客姓名。');
|
||||
}
|
||||
|
||||
// A batch is an envelope of ordinary hotel creates, never an ERP form.
|
||||
function expandHotelBatch(operation) {
|
||||
const data = operation?.data;
|
||||
if (!data || !Object.hasOwn(data, 'arrangements')) return null;
|
||||
if (operation.action !== 'arrangement_hotel' || !Array.isArray(data.arrangements)
|
||||
|| data.arrangements.length < 2 || data.arrangements.length > 10
|
||||
|| Object.keys(data).some(key => !['existing_refs', 'arrangements'].includes(key))) {
|
||||
throw new Error('批量酒店只支持同团 2—10 条新增安排,不能混用其他字段。');
|
||||
}
|
||||
const seen = new Set();
|
||||
return data.arrangements.map((arrangement, index) => {
|
||||
if (!isPlainObject(arrangement) || arrangement.mode !== 'create') throw new Error(`第 ${index + 1} 条必须为酒店新增。`);
|
||||
const key = [arrangement.start_date, arrangement.end_date,
|
||||
String(arrangement.resource?.keyword || arrangement.resource?.name || '').normalize('NFKC').replace(/\s+/gu, '').toUpperCase(), arrangement.room_count].join('|');
|
||||
if (seen.has(key)) throw new Error(`第 ${index + 1} 条酒店安排重复,请核对后重新发送整批。`);
|
||||
seen.add(key);
|
||||
return { ...operation, data: { existing_refs: data.existing_refs, arrangement } };
|
||||
});
|
||||
}
|
||||
|
||||
function validateHotelBatch(operation) {
|
||||
let items;
|
||||
try { items = expandHotelBatch(operation); }
|
||||
catch (error) { return { ok: false, action: operation.action, execution: 'blocked', blockers: [error.message], warnings: [] }; }
|
||||
const plans = items.map(validateDispatchOperation);
|
||||
const blockers = plans.flatMap((plan, index) => plan.blockers.map(message => `第 ${index + 1} 条:${message}`));
|
||||
return { ...plans[0], ok: !blockers.length, execution: blockers.length ? 'blocked' : 'browser_hotel_batch', blockers };
|
||||
}
|
||||
|
||||
function validateDispatchOperation(operation = {}) {
|
||||
const normalized = normalizeOperation(operation);
|
||||
if (normalized?.data && Object.hasOwn(normalized.data, 'arrangements')) return validateHotelBatch(normalized);
|
||||
const action = actionForOperation(normalized);
|
||||
const definition = OPERATION_DEFINITIONS[action] || null;
|
||||
if (!definition) return {
|
||||
@@ -1885,6 +1915,7 @@
|
||||
targetDatesForLifecycle,
|
||||
LIFECYCLE_ACTIONS,
|
||||
ERP_RESOLUTION_ACTIONS,
|
||||
expandHotelBatch,
|
||||
normalizeOperation,
|
||||
operationSummary,
|
||||
publicPlan,
|
||||
|
||||
@@ -944,7 +944,7 @@
|
||||
|
||||
window.LTJTOrderAssistant = {
|
||||
...(window.LTJTOrderAssistant || {}),
|
||||
version: '0.5.189',
|
||||
version: '0.5.190',
|
||||
openTeamBatchForm,
|
||||
pingTeamBatchFrame,
|
||||
preflightTeamBatchNative,
|
||||
|
||||
Reference in new issue
Block a user