excel唯一逻辑修复

This commit is contained in:
andy committed 2026-09-15 14:24:20 +08:00
1 parent ede1d47c47
commit af85ea22ec
24 files changed
+784 -34

No files matched your search

@@ -11,9 +11,13 @@ Chrome Manifest V3 扩展,在用户已登录的 LTJT ERP 页面内执行经过
## 当前版本
当前源码版本为 `0.5.184`。版本化 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.185`。版本化 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.184 当前重点:
0.5.185 当前重点:
- 目标只读查询遇到 Chrome 主框架移除时,在同一执行仍处于写入前运行状态下,最多等待 8 秒确认同一主文档连续两次加载完成,再重新注入适配器并只读重查一次。持续切换、权限错误、取消或写入边界变化时停止;业务无匹配和多匹配不重试,原生保存不重试。异常按实际查询、路由准备或预检阶段记录;查询失败保留步骤和当时自动恢复时间快照,不记录业务值或恢复正常心跳刷屏。本地合成回归覆盖,真实 ERP 验收待部署方执行。需更新插件及平台最低版本,无数据库迁移。
继续保留 0.5.184 的列表及回执修复:
- ERP 列表就绪等待跟随当前 iframe 文档,仍要求加载完成、原生搜索函数与可用按钮,切换期间不在旧文档中搜索;失败回执明确区分页面未就绪,并记录不含业务值的条件与耗时。平台接单等待为 10 秒;明确未写入的迟到检索失败可在同执行编号、原连接范围内同步回平台。需同步更新平台/服务端与插件,无数据库迁移;不重新下发任务,真实写入不确定结果继续人工核验。
@@ -870,6 +870,8 @@ async function readErpKeepaliveState() {
recovery_trigger: String(state.recovery_trigger || ''),
recovery_reason: String(state.recovery_reason || ''),
recovery_attempt_at: String(state.recovery_attempt_at || ''),
recovery_reload_requested_at: String(state.recovery_reload_requested_at || ''),
recovery_tab_id: Number(state.recovery_tab_id || 0),
recovery_completed_at: String(state.recovery_completed_at || ''),
recovery_error: String(state.recovery_error || ''),
recovery_count: Number(state.recovery_count || 0),
@@ -1147,6 +1149,8 @@ async function performErpLinkRecovery(reason, trigger) {
recovery_completed_at: '',
recovery_error: '',
recovery_count: state.recovery_count + 1,
recovery_reload_requested_at: '',
recovery_tab_id: tab.id,
last_skip_reason: '',
last_tab_url: tab.url || ''
});
@@ -1164,7 +1168,11 @@ async function performErpLinkRecovery(reason, trigger) {
return { ok: false, status: 'skipped', reason: 'active_erp_execution' };
}
if (typeof chrome.tabs?.reload !== 'function') throw new Error('erp_link_refresh_api_unavailable');
await chrome.tabs.reload(tab.id, { bypassCache: true });
const reloadRequestedAt = nowIso();
await Promise.all([
chrome.tabs.reload(tab.id, { bypassCache: true }),
tryUpdateErpKeepaliveState({ recovery_reload_requested_at: reloadRequestedAt })
]);
const ready = await waitForTabLoad(tab.id, ERP_LINK_RECOVERY_TIMEOUT_MS);
if (ready.status && ready.status !== 'complete') throw new Error('erp_link_refresh_timeout');
if (!isApprovedErpPageUrl(ready.url)) throw erpPageAccessError(ready.url, null);
@@ -1635,6 +1643,14 @@ async function canReusePageActionScripts(tabId, action, scriptTarget, taskId = '
}
}
function pageActionError(error, actionCode, step) {
const annotated = new Error(error?.message || String(error), { cause: error });
annotated.code = error?.code;
annotated.page_action = actionCode;
annotated.page_step = step;
return annotated;
}
async function runPageAction(tabId, action, args = [], target = {}, taskId = '') {
throwIfTaskCancelled(taskId);
const scriptTarget = target.frameIds
@@ -1664,7 +1680,7 @@ async function runPageAction(tabId, action, args = [], target = {}, taskId = '')
duration_ms: performance.now() - injectionStartedAt,
counters: { call_count: 1, failure_count: 1 }
}]);
throw error;
throw pageActionError(error, actionCode, 'script_injection');
}
const roundtripStartedAt = performance.now();
@@ -1711,7 +1727,7 @@ async function runPageAction(tabId, action, args = [], target = {}, taskId = '')
duration_ms: performance.now() - roundtripStartedAt,
counters: { call_count: 1, failure_count: 1 }
}]);
throw error;
throw pageActionError(error, actionCode, `action_roundtrip.${actionCode}`);
}
queueOperationTimingDetails(taskId, [{
@@ -2298,8 +2314,124 @@ function withoutResolvedOperation(report = {}) {
return publicReport;
}
function isRemovedResolutionFrame(error) {
return /^Frame with ID 0 was removed\.?$/i.test(String(error?.message || error));
}
async function resolutionRecoverySnapshot(tabId) {
try {
const state = await readErpKeepaliveState();
const code = (value) => /^[a-z_:\d-]{1,80}$/i.test(String(value || '')) ? String(value) : '';
const timestamp = (value) => /^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/.test(String(value || '')) ? String(value) : '';
return {
recovery_status: code(state.recovery_status),
recovery_reason: code(state.recovery_reason),
recovery_trigger: code(state.recovery_trigger),
recovery_attempt_at: timestamp(state.recovery_attempt_at),
recovery_reload_requested_at: timestamp(state.recovery_reload_requested_at),
recovery_completed_at: timestamp(state.recovery_completed_at),
same_tab: state.recovery_tab_id ? state.recovery_tab_id === tabId : null
};
} catch (error) {
return { unavailable: true };
}
}
async function assertResolutionRetrySafe(taskId, originalExecution) {
throwIfTaskCancelled(taskId);
const current = await getExecutionRecord(taskId);
throwIfTaskCancelled(taskId);
if (!originalExecution?.execution_id || originalExecution.state !== 'running'
|| current?.execution_id !== originalExecution.execution_id || current.state !== 'running'
|| current.write_started_at || originalExecution.write_started_at) {
throw new Error('erp_resolution_retry_not_safe');
}
}
async function waitForResolutionDocument(tabId, taskId, originalExecution) {
const deadline = Date.now() + 8000;
let previousDocument = '';
// Read-only probes only. Never use this retry around a save or an entire task.
while (Date.now() < deadline) {
await assertResolutionRetrySafe(taskId, originalExecution);
const tab = await chrome.tabs.get(tabId);
if (!isApprovedErpPageUrl(tab.url) || (tab.pendingUrl && !isApprovedErpPageUrl(tab.pendingUrl))) {
throw new Error('erp_resolution_retry_page_not_allowed');
}
let documentId = '';
if (tab.status === 'complete' && !tab.pendingUrl) {
try {
const results = await withDeadline(chrome.scripting.executeScript({
target: { tabId, frameIds: [0] },
world: 'MAIN',
func: () => ({ ready: document.readyState === 'complete', url: location.href })
}), Math.max(1, deadline - Date.now()), 'erp_resolution_document_not_stable');
const top = results?.find((item) => item.frameId === 0);
if (top?.result?.ready === true && isApprovedErpPageUrl(top.result.url)) {
documentId = String(top.documentId || '');
}
} catch (error) {
if (!isRemovedResolutionFrame(error)) throw error;
}
}
if (Date.now() < deadline && documentId && documentId === previousDocument) {
await assertResolutionRetrySafe(taskId, originalExecution);
return;
}
previousDocument = documentId;
await delay(250, taskId);
}
throw new Error('erp_resolution_document_not_stable');
}
function readonlyResolutionException(error, navigationRetry = null) {
return normalizeLifecycleReport({
status: 'erp_resolution_blocked',
blockers: [`erp_resolution_navigation_failed:${error?.message || String(error)}`],
preflight: {
stage: 'erp_readonly_resolution', no_erp_write: true, write_attempted: false,
...(navigationRetry ? { navigation_retry: navigationRetry } : {})
},
no_erp_write: true,
write_attempted: false,
manual_review_required: true,
side_effects: ['no_procurement', 'no_payment', 'no_notification', 'no_external_send']
});
}
async function resolveOperationInErp(tabId, taskId, operation) {
const results = await runPageAction(tabId, 'resolveLifecycleOperation', [operation], { allFrames: false }, taskId);
const originalExecution = await getExecutionRecord(taskId);
const navigationRetry = { attempts: 0, max_attempts: 2, failures: [] };
let results;
let failure;
for (let attempt = 0; attempt < 2; attempt += 1) {
navigationRetry.attempts += 1;
try {
results = await runPageAction(tabId, 'resolveLifecycleOperation', [operation], { allFrames: false }, taskId);
break;
} catch (error) {
if (error?.code === 'task_cancelled') throw error;
failure = error;
navigationRetry.failures.push({
attempt: attempt + 1,
observed_at: nowIso(),
main_frame_removed: isRemovedResolutionFrame(error),
step: error?.page_step || 'resolve_lifecycle_operation',
recovery: await resolutionRecoverySnapshot(tabId)
});
if (attempt !== 0 || !isRemovedResolutionFrame(error)) break;
try {
await waitForResolutionDocument(tabId, taskId, originalExecution);
} catch (waitError) {
if (waitError?.code === 'task_cancelled') throw waitError;
failure = waitError;
break;
}
}
}
if (!results) {
return { ok: false, report: readonlyResolutionException(failure, navigationRetry), operation: null };
}
const report = normalizeLifecycleReport(results[0]?.result || {
status: 'erp_resolution_blocked',
blockers: ['erp_resolution_missing_result'],
@@ -2307,6 +2439,9 @@ async function resolveOperationInErp(tabId, taskId, operation) {
write_attempted: false,
manual_review_required: true
});
if (navigationRetry.failures.length) {
report.preflight = { ...report.preflight, navigation_retry: navigationRetry };
}
const resolvedOperation = report.resolved_operation;
if (report.status !== 'erp_resolution_ready' || !resolvedOperation || typeof resolvedOperation !== 'object') {
return { ok: false, report: withoutResolvedOperation(report), operation: null };
@@ -2334,6 +2469,12 @@ function erpResolutionFailure(operation, report = {}) {
&& operation.data.departure_dates.length === 1;
const targetLabel = identifier ? `单号“${identifier}”` : '当前业务条件';
const blockers = Array.isArray(report.blockers) ? report.blockers.map(String) : [];
if (blockers.some((item) => /Frame with ID 0 was removed|erp_resolution_document_not_stable/.test(item))) {
return {
errorCode: 'erp_resolution_page_changed',
message: 'ERP 主页面在目标查询时失效,查询未能完成;未写入 ERP。请待页面加载稳定后再操作。'
};
}
if (operation?.action === 'shared_child_order_batch_create') {
if (blockers.includes('shared_child_batch_no_matching_parent')) {
return {
@@ -2472,6 +2613,7 @@ async function executeLifecycleOperation(taskId, operation, operationPlan) {
let tab;
let preflight;
let preparationStage = 'erp_resolution';
try {
tab = await findOrOpenErpTab(taskId);
const resolution = await resolveOperationInErp(tab.id, taskId, executionOperation);
@@ -2493,6 +2635,7 @@ async function executeLifecycleOperation(taskId, operation, operationPlan) {
return { ok: false, status: 'blocked', task_id: taskId, report: resolution.report };
}
executionOperation = resolution.operation;
preparationStage = 'lifecycle_route_preparation';
await setResult(taskId, {
stage: 'lifecycle_route_preparation',
status: 'running',
@@ -2556,9 +2699,30 @@ async function executeLifecycleOperation(taskId, operation, operationPlan) {
return { ok: false, status: 'blocked', task_id: taskId, report: strictGate };
}
await cacheResolvedExecutionOperation(taskId, executionOperation);
preparationStage = 'lifecycle_preflight';
preflight = await preflightLifecycleAfterRoute(tab.id, executionOperation, taskId, routeToken);
} catch (error) {
if (error?.code === 'task_cancelled') throw error;
if (preparationStage !== 'lifecycle_preflight') {
const report = preparationStage === 'erp_resolution'
? readonlyResolutionException(error)
: normalizeLifecycleReport({
status: 'lifecycle_route_blocked', blockers: [error?.message || String(error)],
no_erp_write: true, write_attempted: false, manual_review_required: true
});
const failure = preparationStage === 'erp_resolution'
? erpResolutionFailure(executionOperation, report)
: lifecycleRouteFailure(executionOperation, report);
await chrome.storage.local.set({
[preparationStage === 'erp_resolution' ? 'lastErpResolution' : 'lastLifecycleRoutePreparation']: report
});
await publish(taskId, executionOperation, preparationStage, report, {
status: 'blocked', error_code: failure.errorCode, failure_stage: preparationStage,
failure_source: 'plugin_executor', failure_message: failure.message, message: failure.message,
operation_plan: publicPlan, no_erp_write: true, write_attempted: false, blockers: report.blockers
});
return { ok: false, status: 'blocked', task_id: taskId, report };
}
preflight = normalizeLifecycleReport({
status: 'lifecycle_preflight_blocked',
blockers: [error.message || String(error)],
@@ -6600,7 +6600,7 @@
}
window.LTJTOrderAssistant = {
version: '0.5.184',
version: '0.5.185',
resolveNativeListSearchValues,
lookupKeywordMatchesText,
inspectLifecycleSearchCriteria: lifecycleSearchCriteria,
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "联泰下单助手",
"version": "0.5.184",
"version": "0.5.185",
"description": "在已登录 LTJT ERP 页面内规划并受控执行联泰 ERP 业务操作。",
"permissions": [
"activeTab",
@@ -944,7 +944,7 @@
window.LTJTOrderAssistant = {
...(window.LTJTOrderAssistant || {}),
version: '0.5.184',
version: '0.5.185',
openTeamBatchForm,
pingTeamBatchFrame,
preflightTeamBatchNative,