迭代批量安排酒店

This commit is contained in:
andy committed 2026-09-24 14:43:08 +08:00
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,