773 lines
23 KiB
TypeScript
773 lines
23 KiB
TypeScript
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);
|
||
}
|