AutoRpa/state_center_web/src/stateMachine.ts

773 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export type StateMachineMeta = {
node_order: string[];
transitions: Record<string, string | null>;
conditional_nodes: string[];
human_node_roles: Record<string, string>;
node_executor: Record<string, string>;
node_labels: Record<string, string>;
executor_labels: Record<string, string>;
status_labels: Record<string, string>;
};
export type JobRow = {
job_id: string;
supplier_name: string;
order_date: string;
source_type: string;
status: string;
current_node_code: string;
current_role?: string | null;
order_no_list_json?: string | null;
contract_system_no?: string | null;
rebate_order_no?: string | null;
m5_order_no?: string | null;
receipt_notice_no?: string | null;
created_at?: string | null;
updated_at?: string | null;
finished_at?: string | null;
};
export type JobArtifactRow = {
node_code?: string | null;
attempt_no?: number | null;
artifact_type: string;
storage_path: string;
file_hash?: string | null;
visible_roles?: string[] | null;
is_masked?: boolean | null;
created_at?: string | null;
};
export type JobStatusBucket = 'ALL' | 'ACTIVE' | 'WAITING' | 'FAILED' | 'SUCCESS';
export type TimelineNode = {
node_code: string;
display_status: string;
attempt_no?: number | null;
executor_type?: string | null;
status?: string | null;
started_at?: string | null;
ended_at?: string | null;
};
export type JobNodeRow = {
node_code: string;
attempt_no: number;
status: string;
started_at?: string | null;
ended_at?: string | null;
duration_ms?: number | null;
error_code?: string | null;
error_summary?: string | null;
output_json?: unknown;
};
export type TaskRow = {
task_id: string;
job_id: string;
node_code: string;
order_item_id?: number | null;
product_match_id?: number | null;
role_code: string;
status: string;
title: string;
reason_type: string;
reason_user_friendly?: string | null;
why_me_text?: string | null;
candidates_json?: unknown;
submitted_action?: string | null;
submitted_at?: string | null;
created_at?: string | null;
};
export type GoodsCandidate = {
goods_id: string;
goods_code?: string | null;
common_name?: string | null;
spec?: string | null;
base_unit?: string | null;
manufacturer?: string | null;
approval_no?: string | null;
};
export type NodeDetail = TimelineNode & {
nodeRow?: JobNodeRow;
artifacts: JobArtifactRow[];
openTask?: TaskRow;
summaryText: string;
errorText: string;
durationText: string;
};
export type ReferenceChainItem = {
key: string;
label: string;
value: string;
status: 'DONE' | 'MISSING';
};
export type AcceptanceChecklistItem = {
key: string;
label: string;
status: 'DONE' | 'PARTIAL' | 'TODO' | 'NA';
detail: string;
};
export type BusinessOverviewHeader = {
supplierName: string;
jobId: string;
orderDateText: string;
sourceTypeText: string;
currentStatusText: string;
currentNodeText: string;
currentRoleText: string;
};
export type BusinessOverviewFlowNode = {
node_code: string;
label: string;
executorText: string;
statusText: string;
state: 'DONE' | 'CURRENT' | 'WAITING' | 'FAILED' | 'SKIPPED' | 'TODO';
isCurrent: boolean;
};
export type BusinessOverviewTask = {
task_id: string;
title: string;
nodeText: string;
roleText: string;
statusText: string;
reasonText: string;
};
export type BusinessOverview = {
header: BusinessOverviewHeader;
referenceChain: ReferenceChainItem[];
primaryTask: BusinessOverviewTask | null;
flow: {
currentIndex: number;
totalCount: number;
completedCount: number;
waitingCount: number;
failedCount: number;
skippedCount: number;
visibleNodes: BusinessOverviewFlowNode[];
};
summary: {
evidenceCount: number;
nodeDetailCount: number;
openTaskCount: number;
referenceDoneCount: number;
acceptanceCounts: Record<AcceptanceChecklistItem['status'], number>;
};
};
export type NodeCallbackKind = 'start' | 'complete' | 'fail';
type JsonObject = Record<string, unknown>;
export function nodeLabel(meta: StateMachineMeta | null, nodeCode: string) {
return meta?.node_labels[nodeCode] || nodeCode;
}
export function executorLabel(meta: StateMachineMeta | null, executorCode?: string | null) {
if (!executorCode) return '';
return meta?.executor_labels[executorCode] || executorCode;
}
export function statusLabel(meta: StateMachineMeta | null, statusCode?: string | null) {
if (!statusCode) return '';
return meta?.status_labels[statusCode] || statusCode;
}
export function taskStatusLabel(status: string) {
return {
OPEN: '待处理',
SUBMITTED: '已提交',
CANCELLED: '已取消',
EXPIRED: '已过期',
}[status] || status;
}
export function jobStatusLabel(status: string) {
return {
PENDING: '待执行',
RUNNING: '执行中',
WAITING_USER: '等待人工',
WAITING_EXTERNAL: '等待外部',
SUCCESS: '已完成',
FAILED_RETRYABLE: '可重试失败',
FAILED_MANUAL: '人工失败',
CANCELLED: '已取消',
}[status] || status;
}
export function jobStatusBucketLabel(bucket: JobStatusBucket) {
return {
ALL: '全部',
ACTIVE: '进行中',
WAITING: '等人处理',
FAILED: '失败',
SUCCESS: '完成',
}[bucket];
}
export function jobStatusBucketMatches(bucket: JobStatusBucket, status: string) {
if (bucket === 'ALL') return true;
if (bucket === 'ACTIVE') {
return ['PENDING', 'RUNNING', 'WAITING_USER', 'WAITING_EXTERNAL'].includes(status);
}
if (bucket === 'WAITING') {
return ['WAITING_USER', 'WAITING_EXTERNAL'].includes(status);
}
if (bucket === 'FAILED') {
return ['FAILED', 'FAILED_RETRYABLE', 'FAILED_MANUAL'].includes(status);
}
if (bucket === 'SUCCESS') {
return status === 'SUCCESS';
}
return true;
}
export function jobMatchesQuery(job: JobRow, query: string, meta: StateMachineMeta | null = null): boolean {
const q = query.trim().toLowerCase();
if (!q) return true;
const haystack = [
job.job_id,
job.supplier_name,
job.order_date,
job.source_type,
sourceTypeLabel(job.source_type),
job.status,
jobStatusLabel(job.status),
job.current_node_code,
nodeLabel(meta, job.current_node_code),
job.current_role || '',
roleLabel(job.current_role),
job.order_no_list_json || '',
job.contract_system_no || '',
job.rebate_order_no || '',
job.m5_order_no || '',
job.receipt_notice_no || '',
]
.join('\n')
.toLowerCase();
return haystack.includes(q);
}
export function roleLabel(roleCode?: string | null) {
return {
PURCHASER: '采购',
PLANNER: '计划员',
QC: '质管',
OPS: '运维',
}[roleCode || ''] || (roleCode || '');
}
export function sourceTypeLabel(sourceType?: string | null) {
return {
YYC: '医药城',
XYY: '小药药',
OFFLINE: '线下单',
}[sourceType || ''] || (sourceType || '');
}
export function actionLabel(action: string) {
return {
CONFIRM_AND_CONTINUE: '确认并继续',
SELECT_CANDIDATE: '选择候选货品',
RETRY_CURRENT_NODE: '重试当前节点',
TRANSFER_MANUAL: '转派人工',
TERMINATE_JOB: '终止流程',
}[action] || action;
}
export function nodeCallbackActionLabel(kind: NodeCallbackKind) {
return {
start: '开始',
complete: '成功',
fail: '失败',
}[kind];
}
export function buildNodeCallbackTemplate(nodeCode: string, kind: NodeCallbackKind): JsonObject {
if (kind === 'start') {
return {
attemptNo: 1,
executorPlatform: 'iS-RPA',
operator: 'agent',
};
}
if (kind === 'fail') {
return {
attemptNo: 1,
errorCode: 'SYS_PAGE_TIMEOUT',
errorSummary: '页面超时,联调模拟失败',
retryable: true,
routeRole: 'OPS',
artifacts: [],
};
}
const businessRefs = buildNodeBusinessRefs(nodeCode);
const artifacts = buildNodeArtifacts(nodeCode);
return {
attemptNo: 1,
executorPlatform: 'iS-RPA',
operator: 'agent',
executorRunId: `YSQ-${nodeCode}-RUN-001`,
businessRefs,
summary: {
nodeCode,
result: 'ok',
},
artifacts,
};
}
export function formatNodeCallbackTemplate(nodeCode: string, kind: NodeCallbackKind) {
return JSON.stringify(buildNodeCallbackTemplate(nodeCode, kind), null, 2);
}
export function buildNodeCallbackRequestBody(
kind: NodeCallbackKind,
nodeCode: string,
overrides: JsonObject,
idempotencyKey?: string,
): JsonObject {
const body = mergeJsonObjects(buildNodeCallbackTemplate(nodeCode, kind), overrides);
if (kind === 'complete' && !body.idempotencyKey && idempotencyKey) {
body.idempotencyKey = idempotencyKey;
}
return body;
}
export function reasonLabel(reasonType?: string | null) {
return {
MATCH_MULTI: '存在多个候选货品',
MATCH_NONE: '未找到匹配货品',
SOURCE_ADD: '需要新增货源',
OCR_LOW_CONF: '识别结果置信度低',
REBATE_DUP: '返利单疑似重复',
QC_MISSING: '质管信息缺失',
CONTRACT_DISABLED: '合同功能不可用',
}[reasonType || ''] || (reasonType || '');
}
export function artifactTypeLabel(artifactType?: string | null) {
return {
SOURCE_EXCEL: '订单导出',
SOURCE_IMAGE: '订单截图',
OCR_JSON: '识别结果',
CONTRACT_PDF: '合同文件',
MERGED_PDF: '合并文件',
SCREENSHOT: '截图',
TEMPLATE: '模板文件',
LOG: '日志',
}[artifactType || ''] || (artifactType || '');
}
function buildNodeBusinessRefs(nodeCode: string): JsonObject {
if (nodeCode === 'CONTRACT_CREATE') {
return { contractSystemNo: 'HT-联调-001' };
}
if (nodeCode === 'REBATE_HEADER') {
return { rebateOrderNo: 'FL-联调-001' };
}
if (nodeCode === 'M5_CREATE_ORDER') {
return { m5OrderNo: 'M5-联调-001' };
}
if (nodeCode === 'M5_RECEIPT_NOTICE') {
return { receiptNoticeNo: 'SH-联调-001' };
}
return {};
}
function buildNodeArtifacts(nodeCode: string): JsonObject[] {
if (nodeCode === 'ORDER_FETCH') {
return [
{
artifactType: 'SOURCE_EXCEL',
storagePath: '/data/jobs/联调/orders.xlsx',
fileHash: 'sha256:dev-order-export',
visibleRoles: ['PURCHASER', 'PLANNER'],
isMasked: false,
},
];
}
if (nodeCode === 'OCR_EXTRACT') {
return [
{
artifactType: 'OCR_JSON',
storagePath: '/data/jobs/联调/ocr.json',
fileHash: 'sha256:dev-ocr-json',
visibleRoles: ['PLANNER'],
isMasked: false,
},
];
}
if (nodeCode === 'CONTRACT_CREATE') {
return [
{
artifactType: 'CONTRACT_PDF',
storagePath: '/data/jobs/联调/contract.pdf',
fileHash: 'sha256:dev-contract-pdf',
visibleRoles: ['PURCHASER'],
isMasked: false,
},
];
}
return [];
}
function mergeJsonObjects(base: JsonObject, overrides: JsonObject): JsonObject {
const merged: JsonObject = { ...base };
for (const [key, value] of Object.entries(overrides)) {
const existing = merged[key];
if (isPlainObject(existing) && isPlainObject(value)) {
merged[key] = mergeJsonObjects(existing, value);
} else {
merged[key] = value;
}
}
return merged;
}
function isPlainObject(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
export function buildNodeDetails(
timeline: TimelineNode[],
nodes: JobNodeRow[],
artifacts: JobArtifactRow[],
tasks: TaskRow[],
): NodeDetail[] {
return timeline.map((node) => {
const nodeRow = nodes.find(
(row) => row.node_code === node.node_code && (
node.attempt_no == null || row.attempt_no === node.attempt_no
),
);
const attemptNo = nodeRow?.attempt_no ?? node.attempt_no;
const nodeArtifacts = artifacts.filter((artifact) =>
artifact.node_code === node.node_code && (
attemptNo == null || artifact.attempt_no == null || artifact.attempt_no === attemptNo
),
);
const openTask = tasks.find((task) => task.node_code === node.node_code && task.status === 'OPEN');
return {
...node,
nodeRow,
artifacts: nodeArtifacts,
openTask,
summaryText: formatSummary(nodeRow?.output_json),
errorText: [nodeRow?.error_code, nodeRow?.error_summary].filter(Boolean).join(':'),
durationText: formatDuration(nodeRow?.duration_ms, nodeRow?.started_at, nodeRow?.ended_at),
};
});
}
export function buildJobReferenceChain(job: JobRow): ReferenceChainItem[] {
const items = [
{ key: 'order_no_list_json', label: '订单号', value: formatOrderNos(job.order_no_list_json) },
{ key: 'contract_system_no', label: '合同编号', value: job.contract_system_no || '' },
{ key: 'rebate_order_no', label: '返利总单号', value: job.rebate_order_no || '' },
{ key: 'm5_order_no', label: 'M5单号', value: job.m5_order_no || '' },
{ key: 'receipt_notice_no', label: '收货通知单号', value: job.receipt_notice_no || '' },
];
return items.map((item) => ({
...item,
value: item.value || '-',
status: item.value && item.value !== '-' ? 'DONE' : 'MISSING',
}));
}
export function buildAcceptanceChecklist(
job: JobRow,
timeline: TimelineNode[],
nodes: JobNodeRow[],
artifacts: JobArtifactRow[],
tasks: TaskRow[],
): AcceptanceChecklistItem[] {
const nodeDetails = buildNodeDetails(timeline, nodes, artifacts, tasks);
const completedNodes = nodeDetails.filter((node) => node.display_status === 'SUCCESS');
const completedWithEvidence = completedNodes.filter(
(node) => node.artifacts.length > 0 && Boolean(node.summaryText),
);
const referenceChain = buildJobReferenceChain(job);
const referenceDoneCount = referenceChain.filter((item) => item.status === 'DONE').length;
const failedNodes = nodeDetails.filter((node) => node.display_status === 'FAILED');
const openTasks = tasks.filter((task) => task.status === 'OPEN');
return [
{
key: 'e2e_job',
label: '真库一单已跑通',
status: job.status === 'SUCCESS' ? 'DONE' : job.status === 'WAITING_USER' ? 'PARTIAL' : 'TODO',
detail: `当前状态:${jobStatusLabel(job.status)};当前节点:${job.current_node_code}`,
},
{
key: 'page_zh',
label: '页面全中文',
status: 'DONE',
detail: '界面文案、按钮、筛选和详情均使用中文。',
},
{
key: 'browser_common',
label: 'Windows/macOS 通用浏览器',
status: 'DONE',
detail: '当前实现走标准浏览器,不依赖 Electron。',
},
{
key: 'timeline_complete',
label: '节点链完整',
status: timeline.length >= 13 ? 'DONE' : timeline.length > 0 ? 'PARTIAL' : 'TODO',
detail: `已展示 ${timeline.length}/13 个节点。`,
},
{
key: 'node_detail_coverage',
label: '完成节点有摘要和证据',
status:
completedNodes.length === 0
? 'TODO'
: completedWithEvidence.length === completedNodes.length
? 'DONE'
: completedWithEvidence.length > 0
? 'PARTIAL'
: 'TODO',
detail: `完成节点 ${completedWithEvidence.length}/${completedNodes.length} 个带摘要和证据。`,
},
{
key: 'reference_chain',
label: '编号链完整',
status: referenceDoneCount === referenceChain.length
? 'DONE'
: referenceDoneCount > 0
? 'PARTIAL'
: 'TODO',
detail: `已回填 ${referenceDoneCount}/${referenceChain.length} 个关键编号。`,
},
{
key: 'evidence_package',
label: '证据包可导出',
status: artifacts.length > 0 ? 'DONE' : 'TODO',
detail: `当前单据已有 ${artifacts.length} 条证据。`,
},
{
key: 'open_tasks_closed',
label: '待办已收口',
status: openTasks.length === 0 ? 'DONE' : 'PARTIAL',
detail: openTasks.length === 0 ? '当前没有未关闭待办。' : `仍有 ${openTasks.length} 条开放待办。`,
},
{
key: 'failure_visibility',
label: '失败节点可追溯',
status: failedNodes.length === 0
? 'NA'
: failedNodes.every((node) => node.errorText && node.artifacts.length > 0)
? 'DONE'
: 'PARTIAL',
detail:
failedNodes.length === 0
? '当前单据无失败节点。'
: `失败节点 ${failedNodes.length} 个,已覆盖 ${failedNodes.filter((node) => node.errorText).length} 个错误原因。`,
},
];
}
export function buildBusinessOverview(
job: JobRow,
timeline: TimelineNode[],
tasks: TaskRow[],
artifacts: JobArtifactRow[],
meta: StateMachineMeta | null = null,
nodes: JobNodeRow[] = [],
): BusinessOverview {
const referenceChain = buildJobReferenceChain(job);
const nodeDetails = buildNodeDetails(timeline, nodes, artifacts, tasks);
const checklist = buildAcceptanceChecklist(job, timeline, nodes, artifacts, tasks);
const openTasks = tasks.filter((task) => task.status === 'OPEN');
const primaryTask = pickPrimaryTask(openTasks, job.current_node_code, meta);
const currentIndex = pickCurrentNodeIndex(timeline, job.current_node_code);
const visibleNodes = timeline.map((node, index) => {
const isCurrent = index === currentIndex;
const executorCode = node.executor_type || meta?.node_executor[node.node_code];
const normalizedState = normalizeOverviewState(node.display_status);
return {
node_code: node.node_code,
label: nodeLabel(meta, node.node_code),
executorText: executorLabel(meta, executorCode),
statusText: statusLabel(meta, node.display_status),
state: isCurrent && normalizedState !== 'FAILED' && normalizedState !== 'SKIPPED' ? 'CURRENT' : normalizedState,
isCurrent,
} satisfies BusinessOverviewFlowNode;
});
const acceptanceCounts = checklist.reduce<Record<AcceptanceChecklistItem['status'], number>>(
(acc, item) => {
acc[item.status] += 1;
return acc;
},
{ DONE: 0, PARTIAL: 0, TODO: 0, NA: 0 },
);
return {
header: {
supplierName: job.supplier_name,
jobId: job.job_id,
orderDateText: job.order_date,
sourceTypeText: sourceTypeLabel(job.source_type),
currentStatusText: jobStatusLabel(job.status),
currentNodeText: nodeLabel(meta, job.current_node_code),
currentRoleText: roleLabel(job.current_role),
},
referenceChain,
primaryTask,
flow: {
currentIndex,
totalCount: timeline.length,
completedCount: nodeDetails.filter((node) => node.display_status === 'SUCCESS').length,
waitingCount: nodeDetails.filter((node) => ['PENDING', 'RUNNING', 'WAITING_USER', 'WAITING_EXTERNAL'].includes(node.display_status)).length,
failedCount: nodeDetails.filter((node) => node.display_status === 'FAILED').length,
skippedCount: nodeDetails.filter((node) => node.display_status === 'SKIPPED').length,
visibleNodes,
},
summary: {
evidenceCount: artifacts.length,
nodeDetailCount: nodeDetails.length,
openTaskCount: openTasks.length,
referenceDoneCount: referenceChain.filter((item) => item.status === 'DONE').length,
acceptanceCounts,
},
};
}
function formatSummary(value: unknown) {
const parsed = parseJsonish(value);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return '';
const summary = 'summary' in parsed ? (parsed as { summary?: unknown }).summary : parsed;
if (!summary || typeof summary !== 'object' || Array.isArray(summary)) return '';
return Object.entries(summary)
.filter(([, entryValue]) => entryValue !== undefined && entryValue !== null && entryValue !== '')
.map(([key, entryValue]) => `${key}:${formatSummaryValue(entryValue)}`)
.join(',');
}
function formatSummaryValue(value: unknown) {
if (Array.isArray(value)) return value.join('、');
if (typeof value === 'object' && value !== null) return JSON.stringify(value);
return String(value);
}
function parseJsonish(value: unknown) {
if (!value) return null;
if (typeof value !== 'string') return value;
try {
return JSON.parse(value);
} catch {
return null;
}
}
function formatDuration(durationMs?: number | null, startedAt?: string | null, endedAt?: string | null) {
const duration = durationMs ?? inferDurationMs(startedAt, endedAt);
if (duration == null || !Number.isFinite(duration)) return '-';
if (duration < 1000) return `${duration}毫秒`;
return `${Math.round(duration / 1000)}秒`;
}
function inferDurationMs(startedAt?: string | null, endedAt?: string | null) {
if (!startedAt || !endedAt) return null;
const start = Date.parse(startedAt);
const end = Date.parse(endedAt);
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return null;
return end - start;
}
function formatOrderNos(value?: string | null) {
if (!value) return '-';
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) {
return parsed.length ? parsed.join('、') : '-';
}
} catch {
return value;
}
return value;
}
function pickPrimaryTask(tasks: TaskRow[], currentNodeCode: string, meta: StateMachineMeta | null): BusinessOverviewTask | null {
if (!tasks.length) return null;
const matchedTask = tasks.find((task) => task.node_code === currentNodeCode) || tasks[0];
if (!matchedTask) return null;
return {
task_id: matchedTask.task_id,
title: matchedTask.title,
nodeText: nodeLabel(meta, matchedTask.node_code),
roleText: roleLabel(matchedTask.role_code),
statusText: taskStatusLabel(matchedTask.status),
reasonText: matchedTask.reason_user_friendly || reasonLabel(matchedTask.reason_type),
};
}
function pickCurrentNodeIndex(timeline: TimelineNode[], currentNodeCode: string) {
const exactIndex = timeline.findIndex((node) => node.node_code === currentNodeCode);
if (exactIndex >= 0) return exactIndex;
const activeIndex = timeline.findIndex((node) => normalizeOverviewState(node.display_status) === 'CURRENT');
if (activeIndex >= 0) return activeIndex;
return timeline.findIndex((node) => normalizeOverviewState(node.display_status) === 'WAITING') >= 0
? timeline.findIndex((node) => normalizeOverviewState(node.display_status) === 'WAITING')
: 0;
}
function normalizeOverviewState(displayStatus?: string | null): BusinessOverviewFlowNode['state'] {
switch (displayStatus) {
case 'SUCCESS':
return 'DONE';
case 'RUNNING':
case 'WAITING_USER':
case 'WAITING_EXTERNAL':
case 'PENDING':
return 'WAITING';
case 'FAILED':
return 'FAILED';
case 'SKIPPED':
return 'SKIPPED';
default:
return 'TODO';
}
}
export function allowedTaskActions(task: TaskRow) {
if (task.node_code === 'PRODUCT_MATCH') {
return ['SELECT_CANDIDATE', 'TRANSFER_MANUAL', 'TERMINATE_JOB'];
}
return ['CONFIRM_AND_CONTINUE', 'TRANSFER_MANUAL', 'RETRY_CURRENT_NODE', 'TERMINATE_JOB'];
}
export function taskCandidates(task: TaskRow | null): GoodsCandidate[] {
if (!task?.candidates_json) return [];
let value = task.candidates_json;
if (typeof value === 'string') {
try {
value = JSON.parse(value);
} catch {
return [];
}
}
if (!Array.isArray(value)) return [];
return value.filter(
(candidate): candidate is GoodsCandidate =>
Boolean(candidate && typeof candidate === 'object' && 'goods_id' in candidate),
);
}
export function formatDateTime(value?: string | null) {
if (!value) return '-';
const text = String(value);
return text.replace('T', ' ').replace('.000000', '').slice(0, 19);
}