export type StateMachineMeta = { node_order: string[]; transitions: Record; conditional_nodes: string[]; human_node_roles: Record; node_executor: Record; node_labels: Record; executor_labels: Record; status_labels: Record; }; 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; }; }; export type NodeCallbackKind = 'start' | 'complete' | 'fail'; type JsonObject = Record; 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>( (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); }