吸收数字员工审批跨端冲突可视化
This commit is contained in:
@@ -565,6 +565,154 @@ describe("digital employee state service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps local terminal approval status and records remote terminal conflicts", async () => {
|
||||
const { upsertDigitalEmployeeApprovalFromRemote } = await import("./stateService");
|
||||
mockState.db?.approvals.set("approval-conflict-1", {
|
||||
id: "approval-conflict-1",
|
||||
plan_id: "plan-1",
|
||||
task_id: "task-1",
|
||||
run_id: "run-1",
|
||||
title: "高风险审批",
|
||||
status: "approved",
|
||||
payload: JSON.stringify({ workflow: { current_step_id: "step-1", decision_history: [{ decision: "approved" }] } }),
|
||||
sync_status: "pending",
|
||||
created_at: "2026-06-07T07:50:00.000Z",
|
||||
updated_at: "2026-06-07T08:05:00.000Z",
|
||||
});
|
||||
|
||||
const result = upsertDigitalEmployeeApprovalFromRemote({
|
||||
approvalId: "approval-conflict-1",
|
||||
title: "高风险审批",
|
||||
status: "rejected",
|
||||
payload: {
|
||||
workflow: { current_step_id: "step-1", decision_history: [{ decision: "rejected" }] },
|
||||
remote_actor: "management",
|
||||
},
|
||||
updatedAt: "2026-06-07T08:06:00.000Z",
|
||||
source: "management-api",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: true, conflict: true, approvalId: "approval-conflict-1", reason: "terminal_status_mismatch" });
|
||||
const approval = mockState.db?.approvals.get("approval-conflict-1");
|
||||
expect(approval).toMatchObject({ status: "approved", updated_at: "2026-06-07T08:05:00.000Z" });
|
||||
expect(JSON.parse(approval?.payload ?? "{}")).toMatchObject({
|
||||
conflict: {
|
||||
active: true,
|
||||
reason: "terminal_status_mismatch",
|
||||
local_status: "approved",
|
||||
remote_status: "rejected",
|
||||
local_updated_at: "2026-06-07T08:05:00.000Z",
|
||||
remote_updated_at: "2026-06-07T08:06:00.000Z",
|
||||
local_current_step_id: "step-1",
|
||||
remote_current_step_id: "step-1",
|
||||
remote_snapshot: { status: "rejected", payload: { remote_actor: "management" } },
|
||||
detected_at: "2026-06-07T08:00:00.000Z",
|
||||
},
|
||||
});
|
||||
expect(Array.from(mockState.db?.events.values() ?? [])).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "approval_conflict_detected", plan_id: "plan-1", task_id: "task-1", run_id: "run-1" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("records local-newer remote approval conflicts instead of skipping silently", async () => {
|
||||
const { upsertDigitalEmployeeApprovalFromRemote } = await import("./stateService");
|
||||
mockState.db?.approvals.set("approval-conflict-local-newer", {
|
||||
id: "approval-conflict-local-newer",
|
||||
plan_id: "plan-1",
|
||||
task_id: "task-1",
|
||||
run_id: "run-1",
|
||||
title: "本地新审批",
|
||||
status: "pending",
|
||||
payload: JSON.stringify({}),
|
||||
sync_status: "pending",
|
||||
created_at: "2026-06-07T07:50:00.000Z",
|
||||
updated_at: "2026-06-07T08:10:00.000Z",
|
||||
});
|
||||
|
||||
const result = upsertDigitalEmployeeApprovalFromRemote({
|
||||
approvalId: "approval-conflict-local-newer",
|
||||
status: "approved",
|
||||
updatedAt: "2026-06-07T08:03:00.000Z",
|
||||
source: "management-api",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: true, conflict: true, reason: "local_newer_status_mismatch" });
|
||||
const approval = mockState.db?.approvals.get("approval-conflict-local-newer");
|
||||
expect(approval).toMatchObject({ status: "pending", updated_at: "2026-06-07T08:10:00.000Z" });
|
||||
expect(JSON.parse(approval?.payload ?? "{}")).toMatchObject({
|
||||
conflict: {
|
||||
active: true,
|
||||
reason: "local_newer_status_mismatch",
|
||||
local_status: "pending",
|
||||
remote_status: "approved",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mark matching remote approval status as a conflict", async () => {
|
||||
const { upsertDigitalEmployeeApprovalFromRemote } = await import("./stateService");
|
||||
mockState.db?.approvals.set("approval-no-conflict", {
|
||||
id: "approval-no-conflict",
|
||||
plan_id: "plan-1",
|
||||
task_id: "task-1",
|
||||
run_id: "run-1",
|
||||
title: "一致审批",
|
||||
status: "approved",
|
||||
payload: JSON.stringify({ conflict: { active: true, reason: "old" } }),
|
||||
sync_status: "pending",
|
||||
created_at: "2026-06-07T07:50:00.000Z",
|
||||
updated_at: "2026-06-07T08:01:00.000Z",
|
||||
});
|
||||
|
||||
const result = upsertDigitalEmployeeApprovalFromRemote({
|
||||
approvalId: "approval-no-conflict",
|
||||
status: "approved",
|
||||
payload: { summary: "远端已一致" },
|
||||
updatedAt: "2026-06-07T08:03:00.000Z",
|
||||
source: "management-api",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: true, approvalId: "approval-no-conflict" });
|
||||
expect(result.conflict).toBeUndefined();
|
||||
const approval = mockState.db?.approvals.get("approval-no-conflict");
|
||||
expect(approval).toMatchObject({ status: "approved", updated_at: "2026-06-07T08:03:00.000Z" });
|
||||
expect(JSON.parse(approval?.payload ?? "{}")).toMatchObject({ conflict: { active: false }, summary: "远端已一致" });
|
||||
});
|
||||
|
||||
it("records workflow step conflicts when decision history exists", async () => {
|
||||
const { upsertDigitalEmployeeApprovalFromRemote } = await import("./stateService");
|
||||
mockState.db?.approvals.set("approval-step-conflict", {
|
||||
id: "approval-step-conflict",
|
||||
plan_id: "plan-1",
|
||||
task_id: "task-1",
|
||||
run_id: "run-1",
|
||||
title: "多步审批",
|
||||
status: "pending",
|
||||
payload: JSON.stringify({ workflow: { current_step_id: "step-local", decision_history: [{ step_id: "step-local", decision: "approved" }] } }),
|
||||
sync_status: "pending",
|
||||
created_at: "2026-06-07T07:50:00.000Z",
|
||||
updated_at: "2026-06-07T08:01:00.000Z",
|
||||
});
|
||||
|
||||
const result = upsertDigitalEmployeeApprovalFromRemote({
|
||||
approvalId: "approval-step-conflict",
|
||||
status: "pending",
|
||||
payload: { workflow: { current_step_id: "step-remote", decision_history: [{ step_id: "step-remote", decision: "delegated" }] } },
|
||||
updatedAt: "2026-06-07T08:04:00.000Z",
|
||||
source: "management-api",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: true, conflict: true, reason: "workflow_step_mismatch" });
|
||||
expect(JSON.parse(mockState.db?.approvals.get("approval-step-conflict")?.payload ?? "{}")).toMatchObject({
|
||||
conflict: {
|
||||
active: true,
|
||||
reason: "workflow_step_mismatch",
|
||||
local_current_step_id: "step-local",
|
||||
remote_current_step_id: "step-remote",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("records and lists route decisions as syncable events", async () => {
|
||||
const {
|
||||
listDigitalEmployeeRouteDecisions,
|
||||
@@ -2713,7 +2861,7 @@ class TestDb {
|
||||
if (sql.includes("FROM digital_plan_steps") && sql.includes("WHERE id = ?")) {
|
||||
return this.planSteps.get(args[0] as string);
|
||||
}
|
||||
if (sql.startsWith("SELECT id, plan_id, task_id, run_id, title, status, payload, created_at FROM digital_approvals")) {
|
||||
if (sql.startsWith("SELECT id, plan_id, task_id, run_id, title, status, payload")) {
|
||||
return this.approvals.get(args[0] as string);
|
||||
}
|
||||
if (sql.includes("FROM digital_schedules") && sql.includes("WHERE id = ?")) {
|
||||
|
||||
@@ -2690,7 +2690,7 @@ export function recordDigitalEmployeeApprovalAction(
|
||||
|
||||
export function upsertDigitalEmployeeApprovalFromRemote(
|
||||
input: DigitalEmployeeApprovalUpdateInput,
|
||||
): { ok: boolean; skipped?: boolean; approvalId?: string; reason?: string } {
|
||||
): { ok: boolean; skipped?: boolean; conflict?: boolean; approvalId?: string; reason?: string } {
|
||||
const db = getDigitalEmployeeDb();
|
||||
if (!db) return { ok: false, reason: "database_unavailable" };
|
||||
const approvalId = stringValue(input.approvalId ?? input.remoteId) || "";
|
||||
@@ -2702,15 +2702,48 @@ export function upsertDigitalEmployeeApprovalFromRemote(
|
||||
`).get(approvalId) as Record<string, unknown> | undefined;
|
||||
const remoteUpdatedAt = stringValue(input.updatedAt) || new Date().toISOString();
|
||||
const existingUpdatedAt = existing ? stringField(existing, "updated_at") : "";
|
||||
const existingPayload = objectRecord(parseStoredPayload(existing?.payload)) ?? {};
|
||||
const remotePayload = objectRecord(input.payload) ?? {};
|
||||
const remoteStatus = stringValue(input.status) || (existing ? stringField(existing, "status") : "pending") || "pending";
|
||||
if (existing) {
|
||||
const conflict = detectApprovalRemoteConflict({
|
||||
approvalId,
|
||||
existing,
|
||||
existingPayload,
|
||||
remotePayload,
|
||||
remoteStatus,
|
||||
remoteUpdatedAt,
|
||||
source: stringValue(input.source) || stringValue(remotePayload.source) || "management-api",
|
||||
});
|
||||
if (conflict) {
|
||||
const title = stringValue(input.title) || stringField(existing, "title") || "待确认事项";
|
||||
const now = stringValue(conflict.detected_at) || new Date().toISOString();
|
||||
const payload = {
|
||||
...existingPayload,
|
||||
conflict,
|
||||
};
|
||||
upsertApproval({
|
||||
id: approvalId,
|
||||
planId: stringField(existing, "plan_id"),
|
||||
taskId: stringField(existing, "task_id"),
|
||||
runId: stringField(existing, "run_id"),
|
||||
title,
|
||||
status: stringField(existing, "status") || "pending",
|
||||
payload,
|
||||
now: existingUpdatedAt || now,
|
||||
});
|
||||
insertApprovalConflictEvent(approvalId, title, existing, conflict, now);
|
||||
return { ok: true, conflict: true, approvalId, reason: stringValue(conflict.reason) || "approval_conflict" };
|
||||
}
|
||||
}
|
||||
if (existingUpdatedAt && Date.parse(existingUpdatedAt) > Date.parse(remoteUpdatedAt)) {
|
||||
return { ok: true, skipped: true, approvalId, reason: "local_newer" };
|
||||
}
|
||||
const existingPayload = objectRecord(parseStoredPayload(existing?.payload)) ?? {};
|
||||
const remotePayload = objectRecord(input.payload) ?? {};
|
||||
const title = stringValue(input.title) || (existing ? stringField(existing, "title") : "") || "待确认事项";
|
||||
const payload = {
|
||||
...existingPayload,
|
||||
...remotePayload,
|
||||
...(objectRecord(existingPayload.conflict) ? { conflict: { active: false } } : {}),
|
||||
source: stringValue(input.source) || stringValue(remotePayload.source) || "management-api",
|
||||
remote_id: stringValue(input.remoteId) || stringValue(remotePayload.remote_id) || null,
|
||||
remote_updated_at: remoteUpdatedAt,
|
||||
@@ -2721,13 +2754,106 @@ export function upsertDigitalEmployeeApprovalFromRemote(
|
||||
taskId: stringValue(input.taskId) || (existing ? stringField(existing, "task_id") : ""),
|
||||
runId: stringValue(input.runId) || (existing ? stringField(existing, "run_id") : ""),
|
||||
title,
|
||||
status: stringValue(input.status) || (existing ? stringField(existing, "status") : "pending") || "pending",
|
||||
status: remoteStatus,
|
||||
payload,
|
||||
now: remoteUpdatedAt,
|
||||
});
|
||||
return { ok: true, approvalId };
|
||||
}
|
||||
|
||||
function detectApprovalRemoteConflict(input: {
|
||||
approvalId: string;
|
||||
existing: Record<string, unknown>;
|
||||
existingPayload: Record<string, unknown>;
|
||||
remotePayload: Record<string, unknown>;
|
||||
remoteStatus: string;
|
||||
remoteUpdatedAt: string;
|
||||
source: string;
|
||||
}): Record<string, unknown> | null {
|
||||
const localStatus = normalizeApprovalWorkflowStatus(stringField(input.existing, "status") || "pending");
|
||||
const remoteStatus = normalizeApprovalWorkflowStatus(input.remoteStatus || "pending");
|
||||
const localUpdatedAt = stringField(input.existing, "updated_at");
|
||||
const localCurrentStepId = approvalCurrentStepId(input.existingPayload);
|
||||
const remoteCurrentStepId = approvalCurrentStepId(input.remotePayload);
|
||||
let reason = "";
|
||||
if (isTerminalApprovalStatus(localStatus) && isTerminalApprovalStatus(remoteStatus) && localStatus !== remoteStatus) {
|
||||
reason = "terminal_status_mismatch";
|
||||
} else if (isAfter(localUpdatedAt, input.remoteUpdatedAt) && localStatus !== remoteStatus) {
|
||||
reason = "local_newer_status_mismatch";
|
||||
} else if (
|
||||
localCurrentStepId && remoteCurrentStepId && localCurrentStepId !== remoteCurrentStepId &&
|
||||
(approvalHasDecisionHistory(input.existingPayload) || approvalHasDecisionHistory(input.remotePayload))
|
||||
) {
|
||||
reason = "workflow_step_mismatch";
|
||||
}
|
||||
if (!reason) return null;
|
||||
return {
|
||||
active: true,
|
||||
reason,
|
||||
local_status: localStatus,
|
||||
remote_status: remoteStatus,
|
||||
local_updated_at: localUpdatedAt || null,
|
||||
remote_updated_at: input.remoteUpdatedAt || null,
|
||||
local_current_step_id: localCurrentStepId || null,
|
||||
remote_current_step_id: remoteCurrentStepId || null,
|
||||
remote_snapshot: {
|
||||
approval_id: input.approvalId,
|
||||
status: remoteStatus,
|
||||
source: input.source,
|
||||
updated_at: input.remoteUpdatedAt || null,
|
||||
payload: input.remotePayload,
|
||||
},
|
||||
detected_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function insertApprovalConflictEvent(
|
||||
approvalId: string,
|
||||
title: string,
|
||||
existing: Record<string, unknown>,
|
||||
conflict: Record<string, unknown>,
|
||||
now: string,
|
||||
): void {
|
||||
insertEvent(
|
||||
{
|
||||
event_id: `approval-conflict:${safeId(approvalId)}:${safeId(stringValue(conflict.reason) || "conflict")}:${safeId(now)}`,
|
||||
kind: "approval_conflict_detected",
|
||||
occurred_at: now,
|
||||
message: `审批跨端冲突:${title}`,
|
||||
plan_id: stringField(existing, "plan_id") || null,
|
||||
task_id: stringField(existing, "task_id") || null,
|
||||
},
|
||||
stringField(existing, "run_id") || null,
|
||||
{
|
||||
source: "qimingclaw-approval-sync",
|
||||
approval_id: approvalId,
|
||||
title,
|
||||
...conflict,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function approvalCurrentStepId(payload: Record<string, unknown>): string {
|
||||
const workflow = objectRecord(payload.workflow ?? payload.approval_workflow) ?? {};
|
||||
return stringValue(workflow.current_step_id ?? workflow.currentStepId ?? payload.current_step_id ?? payload.currentStepId);
|
||||
}
|
||||
|
||||
function approvalHasDecisionHistory(payload: Record<string, unknown>): boolean {
|
||||
const workflow = objectRecord(payload.workflow ?? payload.approval_workflow) ?? {};
|
||||
const history = workflow.decision_history ?? workflow.decisionHistory ?? payload.decision_history ?? payload.decisionHistory;
|
||||
return Array.isArray(history) && history.length > 0;
|
||||
}
|
||||
|
||||
function isTerminalApprovalStatus(status: string): boolean {
|
||||
return ["approved", "rejected", "cancelled"].includes(normalizeApprovalWorkflowStatus(status));
|
||||
}
|
||||
|
||||
function isAfter(left: string, right: string): boolean {
|
||||
const leftMs = Date.parse(left);
|
||||
const rightMs = Date.parse(right);
|
||||
return Number.isFinite(leftMs) && Number.isFinite(rightMs) && leftMs > rightMs;
|
||||
}
|
||||
|
||||
export function activeDigitalEmployeePlanStepLease(
|
||||
payload: unknown,
|
||||
now = new Date().toISOString(),
|
||||
|
||||
@@ -423,6 +423,59 @@ describe("digital employee sync service", () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it("counts conflicted remote approval updates and records the pull summary", async () => {
|
||||
mockState.db?.approvals.set("approval-conflict-1", {
|
||||
id: "approval-conflict-1",
|
||||
plan_id: "plan-1",
|
||||
task_id: "task-1",
|
||||
run_id: "run-1",
|
||||
title: "本地已同意审批",
|
||||
status: "approved",
|
||||
payload: JSON.stringify({ workflow: { current_step_id: "step-1", decision_history: [{ decision: "approved" }] } }),
|
||||
remote_id: null,
|
||||
sync_status: "pending",
|
||||
sync_error: null,
|
||||
last_synced_at: null,
|
||||
created_at: "2026-06-07T07:50:00.000Z",
|
||||
updated_at: "2026-06-07T08:05:00.000Z",
|
||||
});
|
||||
mockState.fetch.mockResolvedValue(jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
approvals: [
|
||||
{
|
||||
approval_id: "approval-conflict-1",
|
||||
title: "本地已同意审批",
|
||||
status: "rejected",
|
||||
updated_at: "2026-06-07T08:06:00.000Z",
|
||||
payload: { workflow: { current_step_id: "step-1", decision_history: [{ decision: "rejected" }] } },
|
||||
},
|
||||
{
|
||||
approval_id: "approval-remote-ok",
|
||||
title: "新审批",
|
||||
status: "pending",
|
||||
updated_at: "2026-06-07T08:04:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
const { pullDigitalEmployeeApprovalUpdates } = await import("./syncService");
|
||||
const result = await pullDigitalEmployeeApprovalUpdates({ force: true });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, applied: 1, ignored: 0, conflicted: 1 });
|
||||
expect(mockState.db?.approvals.get("approval-conflict-1")).toMatchObject({ status: "approved" });
|
||||
const pulledEvent = Array.from(mockState.db?.events.values() ?? [])
|
||||
.find((event) => event.kind === "digital_workday_approval_updates_pulled");
|
||||
expect(JSON.parse(pulledEvent?.payload ?? "{}")).toMatchObject({
|
||||
metadata: {
|
||||
applied: 1,
|
||||
ignored: 0,
|
||||
conflicted: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("submits approval decisions to management and records submission events", async () => {
|
||||
mockState.fetch.mockResolvedValue(jsonResponse({ success: true, data: { accepted: true } }));
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ export interface DigitalEmployeeApprovalUpdatesPullResult {
|
||||
pulled_at: string | null;
|
||||
applied: number;
|
||||
ignored: number;
|
||||
conflicted?: number;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
@@ -594,9 +595,11 @@ export async function pullDigitalEmployeeApprovalUpdates(
|
||||
const pulledAt = new Date().toISOString();
|
||||
let applied = 0;
|
||||
let ignored = 0;
|
||||
let conflicted = 0;
|
||||
for (const update of updates) {
|
||||
const result = upsertDigitalEmployeeApprovalFromRemote({ ...update, source: update.source ?? "management-api" });
|
||||
if (result.ok && !result.skipped) applied += 1;
|
||||
if (result.ok && result.conflict) conflicted += 1;
|
||||
else if (result.ok && !result.skipped) applied += 1;
|
||||
else ignored += 1;
|
||||
}
|
||||
recordApprovalSyncEvent("approval_updates_pulled", pulledAt, {
|
||||
@@ -605,9 +608,10 @@ export async function pullDigitalEmployeeApprovalUpdates(
|
||||
ok: true,
|
||||
applied,
|
||||
ignored,
|
||||
conflicted,
|
||||
source: "management-api",
|
||||
});
|
||||
return { ok: true, endpoint: config.approvalUpdatesEndpoint, pulled_at: pulledAt, applied, ignored, error: null };
|
||||
return { ok: true, endpoint: config.approvalUpdatesEndpoint, pulled_at: pulledAt, applied, ignored, conflicted, error: null };
|
||||
} catch (error) {
|
||||
const pulledAt = new Date().toISOString();
|
||||
const message = normalizeError(error);
|
||||
|
||||
Reference in New Issue
Block a user