1004 lines
40 KiB
JavaScript
1004 lines
40 KiB
JavaScript
'use strict';
|
||
|
||
importScripts('operation-plans.js', 'execution-guard.js');
|
||
|
||
const runningTasks = new Set();
|
||
const cancelledTasks = new Set();
|
||
const operationPlans = self.LTJTOperationPlans;
|
||
const executionGuard = self.LTJTExecutionGuard;
|
||
let storageMutationQueue = Promise.resolve();
|
||
|
||
function nowIso() {
|
||
return new Date().toISOString();
|
||
}
|
||
|
||
async function delay(ms, taskId = '') {
|
||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||
throwIfTaskCancelled(taskId);
|
||
}
|
||
|
||
function taskRootId(taskId) {
|
||
return String(taskId || '').split('#')[0];
|
||
}
|
||
|
||
function isTaskCancelled(taskId) {
|
||
const normalized = String(taskId || '');
|
||
return Boolean(normalized)
|
||
&& (cancelledTasks.has(normalized) || cancelledTasks.has(taskRootId(normalized)));
|
||
}
|
||
|
||
function cancellationError(taskId) {
|
||
const error = new Error(`任务 ${taskId} 已取消。`);
|
||
error.code = 'task_cancelled';
|
||
return error;
|
||
}
|
||
|
||
function throwIfTaskCancelled(taskId) {
|
||
if (isTaskCancelled(taskId)) throw cancellationError(taskId);
|
||
}
|
||
|
||
function isBusinessSystemUrl(url) {
|
||
return /^http:\/\/(localhost|127\.0\.0\.1)(?::8765)?\//i.test(url || '');
|
||
}
|
||
|
||
function toNumber(value) {
|
||
const normalized = String(value ?? '').replace(/[,,]/g, '').trim();
|
||
const num = Number(normalized);
|
||
return Number.isFinite(num) ? num : 0;
|
||
}
|
||
|
||
function dateYyyyMD(value) {
|
||
if (typeof value !== 'string') return value;
|
||
const match = value.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
||
if (!match) return value;
|
||
return `${match[1]}-${Number(match[2])}-${Number(match[3])}`;
|
||
}
|
||
|
||
function validateOperation(operation) {
|
||
return operationPlans.validateOperation(operation).blockers;
|
||
}
|
||
|
||
function summaryFromOperation(operation) {
|
||
const operationSummary = operationPlans.operationSummary(operation);
|
||
const data = operation.data || {};
|
||
const counts = data.passenger_counts || {};
|
||
const rooms = data.room_counts || {};
|
||
const prices = data.prices || {};
|
||
const passengerTotal = ['adult', 'child_bed', 'child_no_bed', 'infant', 'leader'].reduce((sum, key) => sum + toNumber(counts[key]), 0);
|
||
const roomTotal = ['SGL', 'TWN', 'TRP', 'DBL', 'HNM', 'TL'].reduce((sum, key) => sum + toNumber(rooms[key]), 0);
|
||
const amount = (toNumber(counts.adult) * toNumber(prices.adult))
|
||
+ (toNumber(counts.child_bed) * toNumber(prices.child_bed))
|
||
+ (toNumber(counts.child_no_bed) * toNumber(prices.child_no_bed))
|
||
+ (toNumber(counts.infant) * toNumber(prices.infant))
|
||
+ (toNumber(counts.leader) * toNumber(prices.leader));
|
||
return {
|
||
action: operationSummary.action,
|
||
operation_label: operationSummary.label,
|
||
route: operationSummary.route,
|
||
order_nature: operation.order_nature,
|
||
task_id: operation.source?.task_id || operation.task_id || '',
|
||
product: data.product?.name || '',
|
||
departure_date: data.departure_dates?.[0] || '',
|
||
test_marker: data.test_marker || '',
|
||
passenger_total: passengerTotal,
|
||
room_total: roomTotal,
|
||
receivable_amount_total: amount,
|
||
op_user: data.op_user?.name || '',
|
||
sales_user: data.sales_user?.name || ''
|
||
};
|
||
}
|
||
|
||
function erpReceiptFromReport(report) {
|
||
if (report?.erp_receipt) return report.erp_receipt;
|
||
const alerts = Array.isArray(report?.alerts) ? report.alerts : [];
|
||
return alerts.map((alert) => alert.erp_receipt).find(Boolean) || null;
|
||
}
|
||
|
||
function summarizeReportForBusiness(operation, stage, report) {
|
||
const status = report?.status || 'unknown';
|
||
const erpReceipt = erpReceiptFromReport(report);
|
||
return {
|
||
stage,
|
||
status,
|
||
reason: report?.reason || '',
|
||
blockers: Array.isArray(report?.blockers) ? report.blockers : [],
|
||
warnings: Array.isArray(report?.warnings) ? report.warnings : [],
|
||
operation_plan: report?.operation_plan || undefined,
|
||
no_erp_write: report?.no_erp_write,
|
||
write_attempted: report?.write_attempted === true,
|
||
test_marker: report?.test_markers?.suffix_marker || operation?.data?.test_marker || '',
|
||
payload_sha256: report?.current_payload?.payload_sha256
|
||
|| report?.final_form?.submit_payload_sha256
|
||
|| report?.submit_intercept?.intercepted_submits?.[0]?.payload_sha256
|
||
|| '',
|
||
lookup_checks: Array.isArray(report?.lookup_checks)
|
||
? report.lookup_checks.map((check) => ({
|
||
name: check.name,
|
||
exact_match_count: check.exact_match_count,
|
||
contains_match_count: check.contains_match_count,
|
||
match_rule: check.match_rule
|
||
}))
|
||
: [],
|
||
erp_response_summary: report?.ajax_records?.[0] ? {
|
||
success_hint: Boolean(report.ajax_records[0].response_contains_success_hint),
|
||
login_timeout: Boolean(report.ajax_records[0].response_contains_login_timeout),
|
||
permission_error: Boolean(report.ajax_records[0].response_contains_permission_text),
|
||
error_hint: Boolean(report.ajax_records[0].response_contains_error_hint)
|
||
} : undefined,
|
||
erp_receipt: erpReceipt || undefined,
|
||
verification: report?.response ? {
|
||
status,
|
||
marker_occurrences: report.response.marker_occurrences || 0,
|
||
login_timeout: Boolean(report.response.contains_login_timeout_text),
|
||
permission_error: Boolean(report.response.contains_permission_text)
|
||
} : undefined
|
||
};
|
||
}
|
||
|
||
function withStorageMutation(callback) {
|
||
const run = storageMutationQueue.then(callback, callback);
|
||
storageMutationQueue = run.then(() => undefined, () => undefined);
|
||
return run;
|
||
}
|
||
|
||
async function getExecutionRecord(taskId) {
|
||
const rootId = executionGuard.rootTaskId(taskId);
|
||
const saved = await chrome.storage.local.get('businessTaskExecutions');
|
||
return saved.businessTaskExecutions?.[rootId] || null;
|
||
}
|
||
|
||
async function acceptExecution(taskId, executionId, taskPayload) {
|
||
const rootId = executionGuard.rootTaskId(taskId);
|
||
return withStorageMutation(async () => {
|
||
const saved = await chrome.storage.local.get(['businessTaskExecutions', 'businessTaskResults', 'businessTasks']);
|
||
const executions = saved.businessTaskExecutions || {};
|
||
const results = saved.businessTaskResults || {};
|
||
const tasks = saved.businessTasks || {};
|
||
const existing = executions[rootId] || null;
|
||
const decision = executionGuard.acceptanceDecision(existing, executionId);
|
||
if (!decision.accepted) {
|
||
return {
|
||
ok: true,
|
||
accepted: false,
|
||
task_id: rootId,
|
||
execution_id: executionId,
|
||
status: decision.status,
|
||
message: decision.reason,
|
||
result: results[rootId] || null
|
||
};
|
||
}
|
||
const acceptedAt = nowIso();
|
||
executions[rootId] = {
|
||
task_id: rootId,
|
||
execution_id: executionId,
|
||
state: 'running',
|
||
accepted_at: acceptedAt,
|
||
updated_at: acceptedAt
|
||
};
|
||
const acceptedTask = {
|
||
...(taskPayload || {}),
|
||
task_id: rootId,
|
||
execution_id: executionId,
|
||
received_at: taskPayload?.received_at || acceptedAt
|
||
};
|
||
if (!acceptedTask.operation) {
|
||
throw new Error('唯一执行领取成功前缺少 task.operation,已停止执行。');
|
||
}
|
||
tasks[rootId] = acceptedTask;
|
||
results[rootId] = {
|
||
...(results[rootId] || {}),
|
||
task_id: rootId,
|
||
execution_id: executionId,
|
||
stage: 'auto_executor',
|
||
status: 'accepted',
|
||
updated_at: acceptedAt,
|
||
message: '后台自动执行器已取得唯一执行权。'
|
||
};
|
||
await chrome.storage.local.set({
|
||
businessTaskExecutions: executions,
|
||
businessTaskResults: results,
|
||
businessTasks: tasks,
|
||
currentBusinessTask: acceptedTask,
|
||
currentBusinessTaskId: rootId,
|
||
lastOperation: acceptedTask.operation,
|
||
lastRawText: JSON.stringify(acceptedTask.operation, null, 2)
|
||
});
|
||
return {
|
||
ok: true,
|
||
accepted: true,
|
||
task_id: rootId,
|
||
execution_id: executionId,
|
||
status: 'accepted',
|
||
message: '后台自动执行器已取得唯一执行权。'
|
||
};
|
||
});
|
||
}
|
||
|
||
async function setExecutionState(taskId, executionId, state, details = {}) {
|
||
const rootId = executionGuard.rootTaskId(taskId);
|
||
return withStorageMutation(async () => {
|
||
const saved = await chrome.storage.local.get('businessTaskExecutions');
|
||
const executions = saved.businessTaskExecutions || {};
|
||
const existing = executions[rootId] || null;
|
||
if (!executionGuard.canTransition(existing, executionId)) {
|
||
throw new Error(`任务 ${rootId} 的持久化执行闸门与当前执行编号不一致。`);
|
||
}
|
||
if (executionGuard.TERMINAL_STATES.has(existing.state) && existing.state !== state) {
|
||
return existing;
|
||
}
|
||
const updated = {
|
||
...existing,
|
||
...details,
|
||
state,
|
||
updated_at: nowIso()
|
||
};
|
||
executions[rootId] = updated;
|
||
await chrome.storage.local.set({ businessTaskExecutions: executions });
|
||
return updated;
|
||
});
|
||
}
|
||
|
||
async function transitionCurrentExecution(taskId, state, details = {}) {
|
||
const execution = await getExecutionRecord(taskId);
|
||
if (!execution?.execution_id) {
|
||
throw new Error(`任务 ${executionGuard.rootTaskId(taskId)} 没有持久化执行编号。`);
|
||
}
|
||
return setExecutionState(taskId, execution.execution_id, state, details);
|
||
}
|
||
|
||
async function setResult(taskId, result, { allowCancelled = false } = {}) {
|
||
if (!allowCancelled && isTaskCancelled(taskId)) return;
|
||
const rootId = executionGuard.rootTaskId(taskId);
|
||
return withStorageMutation(async () => {
|
||
const saved = await chrome.storage.local.get(['businessTaskResults', 'businessTaskExecutions']);
|
||
const resultMap = saved.businessTaskResults || {};
|
||
const executions = saved.businessTaskExecutions || {};
|
||
const existingResult = resultMap[taskId] || {};
|
||
const execution = executions[rootId] || null;
|
||
const executionId = result.execution_id || existingResult.execution_id || execution?.execution_id || '';
|
||
let nextResult = {
|
||
...existingResult,
|
||
...result,
|
||
task_id: taskId,
|
||
execution_id: executionId,
|
||
updated_at: nowIso()
|
||
};
|
||
const currentStatus = String(existingResult.status || '');
|
||
const nextStatus = String(nextResult.status || '');
|
||
const terminalResult = /^(completed|dry_run|blocked|cancelled|saved_unverified|execution_uncertain|reconciliation_pending)$/;
|
||
if (terminalResult.test(currentStatus) && currentStatus !== nextStatus) return existingResult;
|
||
|
||
if (taskId === rootId && execution && execution.execution_id === executionId) {
|
||
if (nextStatus === 'completed' || nextStatus === 'dry_run') {
|
||
executions[rootId] = { ...execution, state: 'completed', updated_at: nowIso() };
|
||
} else if (/uncertain|saved_unverified|reconciliation/.test(nextStatus)) {
|
||
executions[rootId] = { ...execution, state: 'uncertain', updated_at: nowIso() };
|
||
} else if (/blocked|failed|error|cancelled|incomplete/.test(nextStatus)) {
|
||
const failureState = executionGuard.failureState(execution);
|
||
executions[rootId] = { ...execution, state: failureState, updated_at: nowIso() };
|
||
if (failureState === 'uncertain') {
|
||
nextResult = {
|
||
...nextResult,
|
||
status: 'execution_uncertain',
|
||
execution_phase: execution.state,
|
||
write_attempted: true,
|
||
no_erp_write: false,
|
||
message: 'ERP 写入已发起但未取得确定回执,已停止执行且不会自动重试。'
|
||
};
|
||
}
|
||
}
|
||
}
|
||
resultMap[taskId] = nextResult;
|
||
await chrome.storage.local.set({
|
||
businessTaskResults: resultMap,
|
||
businessTaskExecutions: executions
|
||
});
|
||
return nextResult;
|
||
});
|
||
}
|
||
|
||
async function publish(taskId, operation, stage, report, extra = {}) {
|
||
const businessSummary = summarizeReportForBusiness(operation, stage, report || {});
|
||
await setResult(taskId, {
|
||
...businessSummary,
|
||
...extra,
|
||
report
|
||
});
|
||
}
|
||
|
||
async function failTask(taskId, operation, stage, message, blockers = []) {
|
||
await publish(taskId, operation, stage, {
|
||
status: 'auto_execution_blocked',
|
||
blockers: blockers.length ? blockers : [message]
|
||
}, {
|
||
status: 'blocked',
|
||
message,
|
||
blockers: blockers.length ? blockers : [message],
|
||
no_erp_write: true,
|
||
write_attempted: false
|
||
});
|
||
}
|
||
|
||
async function erpAutomationEnabled() {
|
||
const saved = await chrome.storage.local.get('erpAutomationEnabled');
|
||
return saved.erpAutomationEnabled !== false;
|
||
}
|
||
|
||
async function waitForTabLoad(tabId, timeoutMs = 20000, taskId = '') {
|
||
const deadline = Date.now() + timeoutMs;
|
||
while (Date.now() < deadline) {
|
||
throwIfTaskCancelled(taskId);
|
||
const tab = await chrome.tabs.get(tabId);
|
||
if (tab.status === 'complete') return tab;
|
||
await delay(500, taskId);
|
||
}
|
||
throwIfTaskCancelled(taskId);
|
||
return chrome.tabs.get(tabId);
|
||
}
|
||
|
||
async function findOrOpenErpTab(taskId = '') {
|
||
throwIfTaskCancelled(taskId);
|
||
const tabs = await chrome.tabs.query({ url: 'https://ltjt.yunzhi.run/*' });
|
||
const existing = tabs.find((tab) => /\/System\/Mainlt\.asp/i.test(tab.url || '')) || tabs[0];
|
||
if (existing?.id) return waitForTabLoad(existing.id, 20000, taskId);
|
||
throwIfTaskCancelled(taskId);
|
||
const tab = await chrome.tabs.create({
|
||
url: 'https://ltjt.yunzhi.run/System/Mainlt.asp',
|
||
active: false
|
||
});
|
||
return waitForTabLoad(tab.id, 20000, taskId);
|
||
}
|
||
|
||
async function injectBridgeIntoBusinessTabs() {
|
||
const tabs = await chrome.tabs.query({
|
||
url: [
|
||
'http://localhost/*',
|
||
'http://127.0.0.1/*'
|
||
]
|
||
});
|
||
for (const tab of tabs) {
|
||
if (!tab.id) continue;
|
||
if (!isBusinessSystemUrl(tab.url)) continue;
|
||
try {
|
||
await chrome.scripting.executeScript({
|
||
target: { tabId: tab.id },
|
||
files: ['business-bridge.js']
|
||
});
|
||
} catch (error) {
|
||
console.warn('LTJT bridge injection skipped', tab.id, error);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function runPageAction(tabId, action, args = [], target = {}, taskId = '') {
|
||
throwIfTaskCancelled(taskId);
|
||
const scriptTarget = target.frameIds
|
||
? { tabId, frameIds: target.frameIds }
|
||
: { tabId, allFrames: Boolean(target.allFrames) };
|
||
await chrome.scripting.executeScript({
|
||
target: scriptTarget,
|
||
world: 'MAIN',
|
||
files: ['inpage.js']
|
||
});
|
||
const results = await chrome.scripting.executeScript({
|
||
target: scriptTarget,
|
||
world: 'MAIN',
|
||
func: async (actionName, actionArgs) => {
|
||
const assistant = window.LTJTOrderAssistant;
|
||
if (!assistant || typeof assistant[actionName] !== 'function') {
|
||
return { status: 'assistant_not_injected' };
|
||
}
|
||
return assistant[actionName](...(actionArgs || []));
|
||
},
|
||
args: [action, args]
|
||
});
|
||
throwIfTaskCancelled(taskId);
|
||
return results;
|
||
}
|
||
|
||
function pickResult(results, predicate) {
|
||
for (const item of results || []) {
|
||
if (predicate(item.result)) return item;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function openOrderForm(tabId, operation, taskId = '') {
|
||
const fabudanwei = operation?.data?.system_defaults?.fabudanwei || '老挝联泰';
|
||
const results = await runPageAction(tabId, 'openOrderForm', [{ fabudanwei }], { allFrames: false }, taskId);
|
||
const result = results[0]?.result;
|
||
if (!result || result.status !== 'order_form_open_requested') {
|
||
throw new Error(result?.blockers?.join('; ') || result?.status || '打开下单页失败。');
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async function waitForOrderFrame(tabId, entryStamp, taskId = '') {
|
||
const deadline = Date.now() + 30000;
|
||
let lastResults = [];
|
||
while (Date.now() < deadline) {
|
||
throwIfTaskCancelled(taskId);
|
||
const results = await runPageAction(tabId, 'pingOrderFrame', [], { allFrames: true }, taskId);
|
||
lastResults = results;
|
||
const exact = pickResult(results, (result) => result?.status === 'order_frame_ready' && (!entryStamp || result.entry_stamp === entryStamp));
|
||
const visible = entryStamp ? null : pickResult(results, (result) => result?.status === 'order_frame_ready' && result.visible);
|
||
const anyReady = entryStamp ? null : pickResult(results, (result) => result?.status === 'order_frame_ready');
|
||
const selected = exact || visible || anyReady;
|
||
if (selected) return selected;
|
||
await delay(600, taskId);
|
||
}
|
||
throwIfTaskCancelled(taskId);
|
||
throw new Error(`未等到本次新打开的完整下单表单。entry_stamp=${entryStamp || '<none>'};最近状态:${JSON.stringify(lastResults.map((item) => item.result).slice(0, 8))}`);
|
||
}
|
||
|
||
async function getTask(taskId) {
|
||
const saved = await chrome.storage.local.get(['businessTasks', 'currentBusinessTask', 'currentBusinessTaskId']);
|
||
const indexedTask = taskId ? saved.businessTasks?.[taskId] : null;
|
||
if (indexedTask?.operation) return indexedTask;
|
||
const task = saved.currentBusinessTask || null;
|
||
if (!task?.operation) throw new Error('没有找到待执行业务任务。');
|
||
if (taskId && task.task_id !== taskId) throw new Error(`当前任务 ${task.task_id} 与请求任务 ${taskId} 不一致。`);
|
||
return task;
|
||
}
|
||
|
||
async function publishOperationPlan(taskId, operation, plan) {
|
||
const publicPlan = operationPlans.publicPlan(plan);
|
||
const label = publicPlan.label || publicPlan.action || 'ERP 业务';
|
||
await publish(taskId, operation, 'capability_check', {
|
||
status: 'operation_plan_ready',
|
||
operation_plan: publicPlan,
|
||
blockers: publicPlan.blockers,
|
||
warnings: publicPlan.warnings,
|
||
no_erp_write: true,
|
||
}, {
|
||
status: 'dry_run',
|
||
reason: 'operation_not_connected',
|
||
message: `已识别${label}任务,当前完成规则校验和执行规划,暂未写入 ERP。`,
|
||
operation_plan: publicPlan,
|
||
no_erp_write: true,
|
||
next_step: publicPlan.fallback
|
||
? `后续接入 ${publicPlan.fallback} 浏览器执行路径。`
|
||
: '后续接入该业务的浏览器页面操作、提交和回查路径。',
|
||
});
|
||
return { ok: true, status: 'dry_run', task_id: taskId, operation_plan: publicPlan };
|
||
}
|
||
|
||
function selectReadOnlyProbeResult(results = []) {
|
||
const preferred = [
|
||
'login_required',
|
||
'erp_order_candidate_unique',
|
||
'erp_order_candidate_ambiguous',
|
||
'erp_order_candidate_not_found',
|
||
'split_parent_page_detected',
|
||
'split_child_page_detected',
|
||
'erp_page_mismatch',
|
||
];
|
||
for (const status of preferred) {
|
||
const match = (results || []).find((item) => item?.result?.status === status);
|
||
if (match) return match.result;
|
||
}
|
||
return (results || []).map((item) => item?.result).find(Boolean) || null;
|
||
}
|
||
|
||
async function executeReadOnlyOperationPlan(taskId, operation, operationPlan) {
|
||
throwIfTaskCancelled(taskId);
|
||
const publicPlan = operationPlans.publicPlan(operationPlan);
|
||
let browserProbe = null;
|
||
let probeError = '';
|
||
try {
|
||
const tab = await findOrOpenErpTab(taskId);
|
||
const results = await runPageAction(tab.id, 'inspectOperationContext', [operation], { allFrames: true }, taskId);
|
||
browserProbe = selectReadOnlyProbeResult(results);
|
||
} catch (error) {
|
||
if (error?.code === 'task_cancelled') throw error;
|
||
probeError = error.message || String(error);
|
||
}
|
||
|
||
const candidate = browserProbe?.candidates?.length === 1 ? browserProbe.candidates[0] : {};
|
||
const confirmationPlan = operation.action === 'confirmation_export'
|
||
? operationPlans.buildConfirmationExportPlan(operation, candidate)
|
||
: undefined;
|
||
const warnings = [...(operationPlan.warnings || [])];
|
||
if (probeError) warnings.push(`浏览器只读预检未完成:${probeError}`);
|
||
if (browserProbe?.status === 'login_required') warnings.push('ERP 当前需要人工登录;插件没有尝试绕过登录或验证码。');
|
||
if (browserProbe?.status === 'erp_page_mismatch') warnings.push('当前 ERP 标签页不是目标业务列表页;本次仅保留结构化计划。');
|
||
|
||
const report = {
|
||
status: 'operation_dry_run_completed',
|
||
reason: 'read_only_browser_probe',
|
||
operation_plan: publicPlan,
|
||
browser_probe: browserProbe || undefined,
|
||
confirmation_plan: confirmationPlan,
|
||
blockers: operationPlan.blockers || [],
|
||
warnings,
|
||
no_erp_write: true,
|
||
write_attempted: false,
|
||
};
|
||
await publish(taskId, operation, 'browser_dry_run', report, {
|
||
status: 'dry_run',
|
||
reason: probeError ? 'read_only_browser_probe_unavailable' : 'read_only_browser_probe',
|
||
message: `已完成${publicPlan.label || 'ERP 业务'}的业务校验和只读浏览器预检,未写入 ERP。`,
|
||
operation_plan: publicPlan,
|
||
browser_probe: browserProbe || undefined,
|
||
confirmation_plan: confirmationPlan,
|
||
no_erp_write: true,
|
||
warnings,
|
||
});
|
||
return {
|
||
ok: true,
|
||
status: 'dry_run',
|
||
task_id: taskId,
|
||
operation_plan: publicPlan,
|
||
browser_probe: browserProbe,
|
||
confirmation_plan: confirmationPlan,
|
||
};
|
||
}
|
||
|
||
function browserExecutionAction(execution) {
|
||
return {
|
||
browser_live_split_parent: 'createSplitParentLive',
|
||
browser_live_split_child: 'createSplitChildLive',
|
||
browser_export_source: 'exportConfirmationSources'
|
||
}[execution] || '';
|
||
}
|
||
|
||
function browserExecutionStatus(report = {}) {
|
||
if (/completed$/i.test(String(report.status || '')) || report.status === 'export_source_completed') return 'completed';
|
||
if (/uncertain/i.test(String(report.status || ''))) return 'execution_uncertain';
|
||
return 'blocked';
|
||
}
|
||
|
||
async function executeConnectedBrowserOperation(taskId, operation, operationPlan) {
|
||
throwIfTaskCancelled(taskId);
|
||
const publicPlan = operationPlans.publicPlan(operationPlan);
|
||
const action = browserExecutionAction(operationPlan.execution);
|
||
if (!action) return publishOperationPlan(taskId, operation, operationPlan);
|
||
await setResult(taskId, {
|
||
stage: 'browser_execution',
|
||
status: 'running',
|
||
message: `已通过规则校验,正在执行${publicPlan.label || 'ERP 浏览器操作'}。`,
|
||
operation_plan: publicPlan,
|
||
no_erp_write: publicPlan.no_erp_write,
|
||
summary: summaryFromOperation(operation)
|
||
});
|
||
let report;
|
||
try {
|
||
const tab = await findOrOpenErpTab(taskId);
|
||
if (publicPlan.no_erp_write === false) {
|
||
await transitionCurrentExecution(taskId, 'write_started', {
|
||
write_started_at: nowIso(),
|
||
current_task_id: taskId,
|
||
browser_action: action
|
||
});
|
||
}
|
||
const results = await runPageAction(tab.id, action, [operation], { allFrames: false }, taskId);
|
||
report = results.map((item) => item?.result).find((result) => result && result.status !== 'assistant_not_injected')
|
||
|| results[0]?.result
|
||
|| { status: 'browser_action_missing', blockers: ['浏览器执行器没有返回结果。'] };
|
||
} catch (error) {
|
||
if (error?.code === 'task_cancelled') throw error;
|
||
report = {
|
||
status: 'browser_execution_error',
|
||
blockers: [error.message || String(error)],
|
||
no_erp_write: publicPlan.no_erp_write
|
||
};
|
||
}
|
||
let finalStatus = browserExecutionStatus(report);
|
||
if (publicPlan.no_erp_write === false && finalStatus !== 'completed') {
|
||
finalStatus = 'execution_uncertain';
|
||
}
|
||
if (publicPlan.no_erp_write === false && finalStatus === 'completed') {
|
||
await transitionCurrentExecution(taskId, 'submitted', {
|
||
submitted_at: nowIso(),
|
||
current_task_id: taskId,
|
||
erp_receipt: report.erp_receipt || undefined
|
||
});
|
||
}
|
||
const warnings = [...(operationPlan.warnings || []), ...(Array.isArray(report.warnings) ? report.warnings : [])];
|
||
const message = finalStatus === 'completed'
|
||
? (report.no_erp_write
|
||
? `${publicPlan.label || 'ERP 导出'}源文件回执已确认;未重保存订单,浏览器未将文件落盘状态误报为已交付。`
|
||
: `${publicPlan.label || 'ERP 业务'}已提交并完成回查。`)
|
||
: (finalStatus === 'execution_uncertain'
|
||
? `${publicPlan.label || 'ERP 业务'}已发起但回执不确定,已停止,不会自动重试。`
|
||
: `${publicPlan.label || 'ERP 业务'}未完成,已停止后续写入。`);
|
||
await publish(taskId, operation, 'browser_execution', {
|
||
...report,
|
||
operation_plan: publicPlan,
|
||
warnings,
|
||
no_erp_write: publicPlan.no_erp_write === true,
|
||
write_attempted: publicPlan.no_erp_write === false
|
||
}, {
|
||
status: finalStatus,
|
||
message,
|
||
operation_plan: publicPlan,
|
||
no_erp_write: publicPlan.no_erp_write === true,
|
||
erp_receipt: report.erp_receipt,
|
||
execution_phase: publicPlan.no_erp_write === false ? (finalStatus === 'completed' ? 'submitted' : 'write_started') : 'read_only',
|
||
write_attempted: publicPlan.no_erp_write === false,
|
||
warnings,
|
||
blockers: report.blockers || []
|
||
});
|
||
return {
|
||
ok: finalStatus === 'completed',
|
||
status: finalStatus,
|
||
task_id: taskId,
|
||
operation_plan: publicPlan,
|
||
report
|
||
};
|
||
}
|
||
|
||
async function executeTeamSingleTask(taskId, operation, options = {}) {
|
||
throwIfTaskCancelled(taskId);
|
||
const batchLabel = options.batchIndex
|
||
? `批量 fallback 第 ${options.batchIndex}/${options.batchTotal} 个日期:`
|
||
: '';
|
||
|
||
await setResult(taskId, {
|
||
stage: 'queued',
|
||
status: 'running',
|
||
message: `${batchLabel}自动执行器已接单,正在寻找已登录 ERP 页面。`,
|
||
summary: summaryFromOperation(operation)
|
||
});
|
||
|
||
const tab = await findOrOpenErpTab(taskId);
|
||
await setResult(taskId, {
|
||
stage: 'open_order_form',
|
||
status: 'running',
|
||
message: `${batchLabel}已找到 ERP 页面,正在打开团队下单表单。`
|
||
});
|
||
const opened = await openOrderForm(tab.id, operation, taskId);
|
||
|
||
await setResult(taskId, {
|
||
stage: 'wait_order_form',
|
||
status: 'running',
|
||
message: `${batchLabel}正在等待 ERP 下单表单加载完成。`,
|
||
open: opened
|
||
});
|
||
const frame = await waitForOrderFrame(tab.id, opened.entry_stamp, taskId);
|
||
await chrome.storage.local.set({ lastFrameId: frame.frameId });
|
||
|
||
await setResult(taskId, {
|
||
stage: 'preflight',
|
||
status: 'running',
|
||
message: `${batchLabel}正在校验选择项、触发产品联动并拦截提交载荷。`
|
||
});
|
||
const preflightResults = await runPageAction(tab.id, 'preflightRawInstruction', [operation, { interceptSubmit: true }], { frameIds: [frame.frameId] }, taskId);
|
||
const preflight = preflightResults[0]?.result || { status: 'missing_result', blockers: ['预检没有返回结果。'] };
|
||
await chrome.storage.local.set({ lastPreflight: preflight });
|
||
await publish(taskId, operation, 'preflight', preflight, {
|
||
status: preflight.status === 'raw_instruction_test_passed' ? 'running' : 'blocked',
|
||
message: preflight.status === 'raw_instruction_test_passed'
|
||
? `${batchLabel}预检通过,准备自动真实保存测试订单。`
|
||
: `${batchLabel}预检阻断,未保存。`
|
||
});
|
||
if (preflight.status !== 'raw_instruction_test_passed') {
|
||
return { ok: false, status: 'preflight_blocked', task_id: taskId, blockers: preflight.blockers || [] };
|
||
}
|
||
|
||
const intercepted = preflight.submit_intercept?.intercepted_submits?.[0];
|
||
if (preflight.submit_intercept?.status !== 'submit_intercept_captured' || !intercepted?.payload_sha256) {
|
||
await failTask(taskId, operation, 'preflight', `${batchLabel}预检未拿到可批准的提交哈希,未保存。`, ['submit_intercept_missing_or_invalid']);
|
||
return { ok: false, status: 'submit_intercept_missing', task_id: taskId };
|
||
}
|
||
|
||
await setResult(taskId, {
|
||
stage: 'live_submit',
|
||
status: 'running',
|
||
message: `${batchLabel}提交哈希已校验,正在自动真实保存测试订单。`
|
||
});
|
||
throwIfTaskCancelled(taskId);
|
||
await transitionCurrentExecution(taskId, 'write_started', {
|
||
write_started_at: nowIso(),
|
||
current_task_id: taskId,
|
||
payload_sha256: intercepted.payload_sha256
|
||
});
|
||
const liveSubmitResults = await runPageAction(tab.id, 'liveSubmitApproved', [{
|
||
expectedPayloadSha256: intercepted.payload_sha256,
|
||
expectedFieldCount: intercepted.payload_summary?.field_count || preflight.final_form?.serialized_field_count || 0
|
||
}], { frameIds: [frame.frameId] }, taskId);
|
||
const liveSubmit = liveSubmitResults[0]?.result || { status: 'missing_result', blockers: ['真实保存没有返回结果。'] };
|
||
const erpReceipt = erpReceiptFromReport(liveSubmit);
|
||
await chrome.storage.local.set({ lastLiveSubmit: liveSubmit });
|
||
if (liveSubmit.status === 'live_submit_completed') {
|
||
await transitionCurrentExecution(taskId, 'submitted', {
|
||
submitted_at: nowIso(),
|
||
current_task_id: taskId,
|
||
erp_receipt: erpReceipt || undefined
|
||
});
|
||
}
|
||
await publish(taskId, operation, 'live_submit', liveSubmit, {
|
||
status: liveSubmit.status === 'live_submit_completed' ? 'running' : 'execution_uncertain',
|
||
message: erpReceipt?.group_number
|
||
? `${batchLabel}ERP 已返回保存回执,团号:${erpReceipt.group_number}。正在回查测试标记。`
|
||
: (liveSubmit.status === 'live_submit_completed' ? `${batchLabel}ERP 已返回保存完成,正在回查测试标记。` : `${batchLabel}ERP 保存失败或状态不确定。`),
|
||
erp_receipt: erpReceipt || undefined,
|
||
execution_phase: liveSubmit.status === 'live_submit_completed' ? 'submitted' : 'write_started',
|
||
write_attempted: true
|
||
});
|
||
if (liveSubmit.status !== 'live_submit_completed') {
|
||
return { ok: false, status: 'live_submit_blocked', task_id: taskId, blockers: liveSubmit.blockers || [] };
|
||
}
|
||
|
||
await setResult(taskId, {
|
||
stage: 'verification',
|
||
status: 'running',
|
||
message: `${batchLabel}正在按测试标记回查订单。`
|
||
});
|
||
const marker = operation.data?.test_marker;
|
||
const date = operation.data?.departure_dates?.[0];
|
||
const verifyResults = await runPageAction(tab.id, 'verifyOrderMarker', [{ marker, dateFrom: date, dateTo: date }], { allFrames: false }, taskId);
|
||
const verification = verifyResults[0]?.result || { status: 'missing_result', blockers: ['回查没有返回结果。'] };
|
||
await chrome.storage.local.set({ lastVerification: verification });
|
||
let returnToList = null;
|
||
if (verification.status === 'order_marker_found') {
|
||
const returnResults = await runPageAction(tab.id, 'returnToOrderList', [{
|
||
marker,
|
||
dateFrom: date,
|
||
dateTo: date
|
||
}], { frameIds: [frame.frameId] }, taskId);
|
||
returnToList = returnResults[0]?.result || null;
|
||
await chrome.storage.local.set({ lastReturnToOrderList: returnToList });
|
||
}
|
||
await publish(taskId, operation, 'verification', verification, {
|
||
status: verification.status === 'order_marker_found' ? 'completed' : 'saved_unverified',
|
||
message: erpReceipt?.group_number
|
||
? `${batchLabel}测试订单已保存,团号:${erpReceipt.group_number}。${verification.status === 'order_marker_found' ? '回查已命中。' : '回查暂未命中。'}`
|
||
: (verification.status === 'order_marker_found' ? `${batchLabel}测试订单已保存并回查命中。` : `${batchLabel}测试订单已提交,但回查暂未命中。`),
|
||
erp_receipt: erpReceipt || undefined,
|
||
return_to_order_list: returnToList
|
||
});
|
||
return {
|
||
ok: verification.status === 'order_marker_found',
|
||
status: verification.status === 'order_marker_found' ? 'completed' : 'saved_unverified',
|
||
task_id: taskId,
|
||
erp_receipt: erpReceipt || undefined,
|
||
};
|
||
}
|
||
|
||
async function runAutoTask(taskId, executionId) {
|
||
const rootId = executionGuard.rootTaskId(taskId);
|
||
if (isTaskCancelled(taskId)) {
|
||
return { ok: false, status: 'cancelled', task_id: taskId };
|
||
}
|
||
let operation = null;
|
||
try {
|
||
const durableExecution = await getExecutionRecord(rootId);
|
||
if (!executionGuard.canTransition(durableExecution, executionId) || durableExecution.state !== 'running') {
|
||
return { ok: false, status: 'duplicate_blocked', task_id: rootId };
|
||
}
|
||
throwIfTaskCancelled(taskId);
|
||
const task = await getTask(taskId);
|
||
if (String(task.execution_id || '') !== String(executionId || '')) {
|
||
throw new Error('任务载荷与持久化执行编号不一致。');
|
||
}
|
||
throwIfTaskCancelled(taskId);
|
||
operation = operationPlans.normalizeOperation(task.operation);
|
||
const normalizedTaskId = task.task_id || operation.source?.task_id || taskId;
|
||
if (!(await erpAutomationEnabled())) {
|
||
throwIfTaskCancelled(normalizedTaskId);
|
||
await publish(normalizedTaskId, operation, 'automation_disabled', {
|
||
status: 'erp_automation_disabled',
|
||
blockers: ['插件已关闭 ERP 操作。'],
|
||
no_erp_write: true,
|
||
write_attempted: false
|
||
}, {
|
||
status: 'blocked',
|
||
message: '插件已关闭 ERP 操作,未打开或填写 ERP。',
|
||
blockers: ['插件开关处于关闭状态。'],
|
||
no_erp_write: true,
|
||
write_attempted: false
|
||
});
|
||
return { ok: false, status: 'automation_disabled', task_id: normalizedTaskId };
|
||
}
|
||
const operationPlan = operationPlans.validateOperation(operation);
|
||
throwIfTaskCancelled(normalizedTaskId);
|
||
const blockers = operationPlan.blockers;
|
||
if (blockers.length) {
|
||
await failTask(normalizedTaskId, operation, 'validation', '任务未通过自动执行安全校验。', blockers);
|
||
return { ok: false, status: 'validation_blocked', blockers };
|
||
}
|
||
|
||
if (operationPlan.execution === 'browser_team_single_fallback') {
|
||
const expanded = operationPlans.expandTeamBatch(operation);
|
||
const batchResults = [];
|
||
throwIfTaskCancelled(normalizedTaskId);
|
||
await setResult(normalizedTaskId, {
|
||
stage: 'batch_fallback',
|
||
status: 'running',
|
||
message: `原生批量接口未启用,将按日期串行复用 team_single:共 ${expanded.length} 个日期。`,
|
||
operation_plan: operationPlans.publicPlan(operationPlan),
|
||
no_erp_write: false,
|
||
});
|
||
for (let index = 0; index < expanded.length; index += 1) {
|
||
throwIfTaskCancelled(normalizedTaskId);
|
||
const childTaskId = `${normalizedTaskId}#${index + 1}`;
|
||
const childOperation = expanded[index];
|
||
try {
|
||
const childResult = await executeTeamSingleTask(childTaskId, childOperation, {
|
||
batchIndex: index + 1,
|
||
batchTotal: expanded.length,
|
||
});
|
||
batchResults.push({
|
||
task_id: childTaskId,
|
||
date: childOperation.data?.departure_dates?.[0] || '',
|
||
status: childResult.status,
|
||
erp_receipt: childResult.erp_receipt,
|
||
});
|
||
if (childResult.status !== 'completed') {
|
||
await publish(normalizedTaskId, operation, 'batch_fallback', {
|
||
status: 'batch_fallback_incomplete',
|
||
operation_plan: operationPlans.publicPlan(operationPlan),
|
||
blockers: childResult.blockers || ['批量 fallback 中某个日期未完成回查。'],
|
||
no_erp_write: false,
|
||
}, {
|
||
status: childResult.status === 'saved_unverified' ? 'saved_unverified' : 'blocked',
|
||
message: `批量 fallback 已停止在第 ${index + 1} 个日期,未继续后续日期。`,
|
||
batch_results: batchResults,
|
||
no_erp_write: false,
|
||
});
|
||
return { ok: false, status: 'batch_fallback_incomplete', task_id: normalizedTaskId, batch_results: batchResults };
|
||
}
|
||
} catch (error) {
|
||
if (error?.code === 'task_cancelled') {
|
||
await setResult(normalizedTaskId, {
|
||
stage: 'cancelled',
|
||
status: 'cancelled',
|
||
message: '批量任务已取消,已停止后续日期。'
|
||
});
|
||
return { ok: false, status: 'cancelled', task_id: normalizedTaskId, batch_results: batchResults };
|
||
}
|
||
await failTask(childTaskId, childOperation, 'batch_fallback_error', error.message || String(error));
|
||
batchResults.push({
|
||
task_id: childTaskId,
|
||
date: childOperation.data?.departure_dates?.[0] || '',
|
||
status: 'blocked',
|
||
error: error.message || String(error),
|
||
});
|
||
await publish(normalizedTaskId, operation, 'batch_fallback', {
|
||
status: 'batch_fallback_incomplete',
|
||
operation_plan: operationPlans.publicPlan(operationPlan),
|
||
blockers: [error.message || String(error)],
|
||
no_erp_write: false,
|
||
}, {
|
||
status: 'blocked',
|
||
message: `批量 fallback 在第 ${index + 1} 个日期发生异常,已停止后续日期。`,
|
||
batch_results: batchResults,
|
||
no_erp_write: false,
|
||
});
|
||
return { ok: false, status: 'batch_fallback_incomplete', task_id: normalizedTaskId, batch_results: batchResults };
|
||
}
|
||
}
|
||
await publish(normalizedTaskId, operation, 'batch_fallback', {
|
||
status: 'batch_fallback_completed',
|
||
operation_plan: operationPlans.publicPlan(operationPlan),
|
||
warnings: operationPlan.warnings,
|
||
no_erp_write: false,
|
||
}, {
|
||
status: 'completed',
|
||
message: `批量 fallback 已按 ${batchResults.length} 个日期逐个完成并回查。`,
|
||
batch_results: batchResults,
|
||
no_erp_write: false,
|
||
});
|
||
return { ok: true, status: 'completed', task_id: normalizedTaskId, batch_results: batchResults };
|
||
}
|
||
|
||
if (['browser_live_split_parent', 'browser_live_split_child', 'browser_export_source'].includes(operationPlan.execution)) {
|
||
return executeConnectedBrowserOperation(normalizedTaskId, operation, operationPlan);
|
||
}
|
||
if (operationPlan.execution === 'browser_dry_run') {
|
||
return executeReadOnlyOperationPlan(normalizedTaskId, operation, operationPlan);
|
||
}
|
||
if (operationPlan.execution !== 'browser_team_single') {
|
||
return publishOperationPlan(normalizedTaskId, operation, operationPlan);
|
||
}
|
||
return executeTeamSingleTask(normalizedTaskId, operation);
|
||
} catch (error) {
|
||
if (error?.code === 'task_cancelled') {
|
||
await setResult(taskId, {
|
||
stage: 'cancelled',
|
||
status: 'cancelled',
|
||
message: '任务已取消,已停止后续自动执行。'
|
||
});
|
||
return { ok: false, status: 'cancelled', task_id: taskId };
|
||
}
|
||
await failTask(taskId, operation, 'extension_error', error.message || String(error));
|
||
return { ok: false, status: 'extension_error', task_id: taskId, message: error.message || String(error) };
|
||
} finally {
|
||
runningTasks.delete(rootId);
|
||
}
|
||
}
|
||
|
||
async function cancelAutoTask(taskId) {
|
||
const normalizedTaskId = String(taskId || '').trim();
|
||
if (!normalizedTaskId) {
|
||
return { ok: false, status: 'cancel_failed', message: '缺少 task_id。' };
|
||
}
|
||
const execution = await getExecutionRecord(normalizedTaskId);
|
||
if (execution && executionGuard.WRITE_STATES.has(execution.state)) {
|
||
await setResult(normalizedTaskId, {
|
||
stage: 'reconciliation',
|
||
status: 'execution_uncertain',
|
||
execution_phase: execution.state,
|
||
write_attempted: true,
|
||
no_erp_write: false,
|
||
message: 'ERP 写入已开始,取消不能作为回滚;任务已保留并等待人工回查。'
|
||
}, { allowCancelled: true });
|
||
return {
|
||
ok: false,
|
||
status: 'cancel_blocked_after_write',
|
||
task_id: normalizedTaskId,
|
||
cancelled: false,
|
||
message: 'ERP 写入已开始,不能通过删除或取消回滚。'
|
||
};
|
||
}
|
||
cancelledTasks.add(normalizedTaskId);
|
||
const running = Array.from(runningTasks).some((runningTaskId) => (
|
||
runningTaskId === normalizedTaskId || taskRootId(runningTaskId) === normalizedTaskId
|
||
));
|
||
await setResult(normalizedTaskId, {
|
||
stage: 'cancelled',
|
||
status: 'cancelled',
|
||
message: running ? '已收到取消请求,正在停止后续自动执行。' : '任务已取消,未继续自动执行。'
|
||
}, { allowCancelled: true });
|
||
if (execution?.execution_id) {
|
||
await setExecutionState(normalizedTaskId, execution.execution_id, 'cancelled', {
|
||
cancelled_at: nowIso()
|
||
});
|
||
}
|
||
return {
|
||
ok: true,
|
||
status: 'cancelled',
|
||
task_id: normalizedTaskId,
|
||
cancelled: true,
|
||
running
|
||
};
|
||
}
|
||
|
||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||
if (message?.type === 'LTJT_CANCEL_TASK') {
|
||
cancelAutoTask(message.task_id || '')
|
||
.then((result) => sendResponse(result))
|
||
.catch((error) => sendResponse({
|
||
ok: false,
|
||
status: 'cancel_failed',
|
||
message: error.message || String(error)
|
||
}));
|
||
return true;
|
||
}
|
||
if (message?.type !== 'LTJT_AUTO_EXECUTE_TASK') return false;
|
||
const taskId = message.task_id || '';
|
||
const executionId = message.execution_id || '';
|
||
acceptExecution(taskId, executionId, message.task)
|
||
.then((acceptance) => {
|
||
if (acceptance.accepted) {
|
||
runningTasks.add(executionGuard.rootTaskId(taskId));
|
||
runAutoTask(taskId, executionId).catch((error) => {
|
||
console.error('LTJT auto task failed', error);
|
||
});
|
||
}
|
||
sendResponse(acceptance);
|
||
})
|
||
.catch((error) => sendResponse({
|
||
ok: false,
|
||
accepted: false,
|
||
task_id: taskId,
|
||
execution_id: executionId,
|
||
status: 'execution_guard_error',
|
||
message: error.message || String(error)
|
||
}));
|
||
return true;
|
||
});
|
||
|
||
chrome.runtime.onInstalled.addListener(() => {
|
||
injectBridgeIntoBusinessTabs().catch((error) => {
|
||
console.warn('LTJT bridge injection failed after install', error);
|
||
});
|
||
});
|
||
|
||
chrome.runtime.onStartup.addListener(() => {
|
||
injectBridgeIntoBusinessTabs().catch((error) => {
|
||
console.warn('LTJT bridge injection failed after startup', error);
|
||
});
|
||
});
|