吸收数字员工审批历史联动
This commit is contained in:
@@ -497,6 +497,70 @@ describe("digital employee state service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("records approval action history as syncable sanitized events", async () => {
|
||||
const { recordDigitalEmployeeApprovalAction } = await import("./stateService");
|
||||
const longComment = `${"需要业务复核。".repeat(20)} sensitive-tail-token`;
|
||||
|
||||
const recorded = recordDigitalEmployeeApprovalAction({
|
||||
approvalId: "approval-1",
|
||||
action: "delegate",
|
||||
comment: longComment,
|
||||
delegateTo: "ops-lead",
|
||||
delegateLabel: "运营负责人",
|
||||
actor: "operator",
|
||||
source: "test",
|
||||
planId: "plan-1",
|
||||
taskId: "task-1",
|
||||
runId: "run-1",
|
||||
occurredAt: "2026-06-07T08:37:00.000Z",
|
||||
});
|
||||
const rejected = recordDigitalEmployeeApprovalAction({
|
||||
approvalId: "approval-1",
|
||||
action: "delete_approval",
|
||||
occurredAt: "2026-06-07T08:38:00.000Z",
|
||||
});
|
||||
|
||||
expect(recorded?.eventId).toBe("approval-action:approval-1:delegate:2026-06-07T08-37-00-000Z");
|
||||
expect(mockState.db?.events.get(recorded!.eventId)).toMatchObject({
|
||||
kind: "approval_action_recorded",
|
||||
message: "审批动作已记录:转交 approval-1",
|
||||
plan_id: "plan-1",
|
||||
task_id: "task-1",
|
||||
run_id: "run-1",
|
||||
});
|
||||
const payload = JSON.parse(mockState.db?.events.get(recorded!.eventId)?.payload ?? "{}");
|
||||
expect(payload).toMatchObject({
|
||||
approval_id: "approval-1",
|
||||
action: "delegate",
|
||||
status: "recorded",
|
||||
comment_summary: {
|
||||
length: longComment.length,
|
||||
},
|
||||
delegate_summary: {
|
||||
target: "ops-lead",
|
||||
label: "运营负责人",
|
||||
},
|
||||
actor: "operator",
|
||||
source: "test",
|
||||
plan_id: "plan-1",
|
||||
task_id: "task-1",
|
||||
run_id: "run-1",
|
||||
});
|
||||
expect(payload.comment_summary.preview.length).toBeLessThanOrEqual(160);
|
||||
expect(JSON.stringify(payload)).not.toContain("sensitive-tail-token");
|
||||
expect(mockState.db?.outbox.get(`event:insert:${recorded!.eventId}`)).toMatchObject({
|
||||
entity_type: "event",
|
||||
entity_id: recorded!.eventId,
|
||||
operation: "insert",
|
||||
status: "pending",
|
||||
});
|
||||
expect(rejected?.kind).toBe("approval_action_rejected");
|
||||
expect(JSON.parse(mockState.db?.events.get(rejected!.eventId)?.payload ?? "{}")).toMatchObject({
|
||||
action: "delete_approval",
|
||||
status: "rejected",
|
||||
});
|
||||
});
|
||||
|
||||
it("records and lists route decisions as syncable events", async () => {
|
||||
const {
|
||||
listDigitalEmployeeRouteDecisions,
|
||||
|
||||
@@ -263,6 +263,32 @@ export interface DigitalEmployeeArtifactLifecycleRecord {
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type DigitalEmployeeApprovalActionKind = "comment" | "delegate" | "mark_timeout" | "mark_risk" | "clear_risk";
|
||||
|
||||
export interface DigitalEmployeeApprovalActionInput {
|
||||
approvalId?: string | null;
|
||||
action: DigitalEmployeeApprovalActionKind | string;
|
||||
comment?: string | null;
|
||||
delegateTo?: string | null;
|
||||
delegateLabel?: string | null;
|
||||
dueAt?: string | null;
|
||||
riskLevel?: string | null;
|
||||
reason?: string | null;
|
||||
actor?: string | null;
|
||||
source?: string | null;
|
||||
planId?: string | null;
|
||||
taskId?: string | null;
|
||||
runId?: string | null;
|
||||
occurredAt?: string | null;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeApprovalActionRecord {
|
||||
eventId: string;
|
||||
kind: "approval_action_recorded" | "approval_action_rejected";
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeRouteDecisionRecord {
|
||||
id: string;
|
||||
route_kind: string;
|
||||
@@ -1155,6 +1181,104 @@ export function recordDigitalEmployeeArtifactLifecycle(
|
||||
return { eventId, kind, payload };
|
||||
}
|
||||
|
||||
export function recordDigitalEmployeeApprovalAction(
|
||||
input: DigitalEmployeeApprovalActionInput,
|
||||
): DigitalEmployeeApprovalActionRecord | null {
|
||||
const db = getDigitalEmployeeDb();
|
||||
if (!db) return null;
|
||||
const now = input.occurredAt || new Date().toISOString();
|
||||
const approvalId = stringValue(input.approvalId) || "unknown-approval";
|
||||
const action = stringValue(input.action) || "unknown_action";
|
||||
const accepted = isDigitalEmployeeApprovalAction(action);
|
||||
const kind: DigitalEmployeeApprovalActionRecord["kind"] = accepted
|
||||
? "approval_action_recorded"
|
||||
: "approval_action_rejected";
|
||||
const eventId = `approval-action:${safeId(approvalId)}:${safeId(action)}:${safeId(now)}`;
|
||||
const payload: Record<string, unknown> = {
|
||||
approval_id: approvalId,
|
||||
action,
|
||||
status: accepted ? "recorded" : "rejected",
|
||||
comment_summary: sanitizeApprovalComment(input.comment),
|
||||
delegate_summary: sanitizeApprovalDelegate(input.delegateTo, input.delegateLabel),
|
||||
sla_summary: sanitizeApprovalSla(input.dueAt, action),
|
||||
risk_summary: sanitizeApprovalRisk(input.riskLevel, action),
|
||||
reason: stringValue(input.reason) || null,
|
||||
actor: stringValue(input.actor) || null,
|
||||
source: stringValue(input.source) || "qimingclaw-approval-action",
|
||||
plan_id: stringValue(input.planId) || null,
|
||||
task_id: stringValue(input.taskId) || null,
|
||||
run_id: stringValue(input.runId) || null,
|
||||
};
|
||||
insertEvent(
|
||||
{
|
||||
event_id: eventId,
|
||||
kind,
|
||||
occurred_at: now,
|
||||
message: input.message || approvalActionMessage(kind, action, approvalId),
|
||||
plan_id: stringValue(input.planId) || null,
|
||||
task_id: stringValue(input.taskId) || null,
|
||||
},
|
||||
stringValue(input.runId) || null,
|
||||
payload,
|
||||
);
|
||||
return { eventId, kind, payload };
|
||||
}
|
||||
|
||||
function isDigitalEmployeeApprovalAction(action: string): action is DigitalEmployeeApprovalActionKind {
|
||||
return ["comment", "delegate", "mark_timeout", "mark_risk", "clear_risk"].includes(action);
|
||||
}
|
||||
|
||||
function sanitizeApprovalComment(comment: string | null | undefined): Record<string, unknown> | null {
|
||||
const text = stringValue(comment);
|
||||
if (!text) return null;
|
||||
return {
|
||||
preview: text.slice(0, 160),
|
||||
length: text.length,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeApprovalDelegate(delegateTo: string | null | undefined, delegateLabel: string | null | undefined): Record<string, unknown> | null {
|
||||
const target = stringValue(delegateTo);
|
||||
const label = stringValue(delegateLabel);
|
||||
if (!target && !label) return null;
|
||||
return {
|
||||
target: target || null,
|
||||
label: label || target || null,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeApprovalSla(dueAt: string | null | undefined, action: string): Record<string, unknown> {
|
||||
const due = stringValue(dueAt);
|
||||
return {
|
||||
due_at: due || null,
|
||||
sla_status: action === "mark_timeout" ? "timeout" : due ? "tracked" : "untracked",
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeApprovalRisk(riskLevel: string | null | undefined, action: string): Record<string, unknown> {
|
||||
const explicit = stringValue(riskLevel);
|
||||
const level = action === "mark_risk" ? explicit || "high" : action === "clear_risk" ? "normal" : explicit || null;
|
||||
return { risk_level: level };
|
||||
}
|
||||
|
||||
function approvalActionMessage(
|
||||
kind: DigitalEmployeeApprovalActionRecord["kind"],
|
||||
action: string,
|
||||
approvalId: string,
|
||||
): string {
|
||||
if (kind === "approval_action_rejected") return `审批动作已拒绝:${approvalId}`;
|
||||
const actionLabel = action === "comment"
|
||||
? "评论"
|
||||
: action === "delegate"
|
||||
? "转交"
|
||||
: action === "mark_timeout"
|
||||
? "标记超时"
|
||||
: action === "mark_risk"
|
||||
? "标记风险"
|
||||
: "清除风险";
|
||||
return `审批动作已记录:${actionLabel} ${approvalId}`;
|
||||
}
|
||||
|
||||
function isDigitalEmployeeArtifactRetentionAction(action: string): action is DigitalEmployeeArtifactRetentionAction {
|
||||
return ["mark_keep", "mark_expire", "mark_review", "clear_retention"].includes(action);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user