"""Web 版状态机纯逻辑。 只放和 UI/接口共享的计算,不碰数据库。 """ from __future__ import annotations from collections.abc import Mapping, Sequence NODE_ORDER = [ "ORDER_FETCH", "OCR_EXTRACT", "PRODUCT_MATCH", "SOURCE_ADD", "CONTRACT_CREATE", "CONTRACT_APPROVE_WAIT", "REBATE_HEADER", "REBATE_IMPORT", "REBATE_SUBMIT", "M5_CREATE_ORDER", "M5_MANUAL_CONFIRM", "M5_RECEIPT_NOTICE", "ARCHIVE_JOB", ] TRANSITIONS = { "ORDER_FETCH": "OCR_EXTRACT", "OCR_EXTRACT": "PRODUCT_MATCH", "PRODUCT_MATCH": "SOURCE_ADD", "SOURCE_ADD": "CONTRACT_CREATE", "CONTRACT_CREATE": "CONTRACT_APPROVE_WAIT", "CONTRACT_APPROVE_WAIT": "REBATE_HEADER", "REBATE_HEADER": "REBATE_IMPORT", "REBATE_IMPORT": "REBATE_SUBMIT", "REBATE_SUBMIT": "M5_CREATE_ORDER", "M5_CREATE_ORDER": "M5_MANUAL_CONFIRM", "M5_MANUAL_CONFIRM": "M5_RECEIPT_NOTICE", "M5_RECEIPT_NOTICE": "ARCHIVE_JOB", "ARCHIVE_JOB": None, } CONDITIONAL_NODES = {"OCR_EXTRACT", "SOURCE_ADD"} HUMAN_NODE_ROLES = { "SOURCE_ADD": "PURCHASER", "M5_MANUAL_CONFIRM": "PURCHASER", "M5_RECEIPT_NOTICE": "PURCHASER", } NODE_EXECUTOR = { "ORDER_FETCH": "RPA", "OCR_EXTRACT": "RPA", "PRODUCT_MATCH": "STATE_CENTER", "SOURCE_ADD": "HUMAN", "CONTRACT_CREATE": "RPA", "CONTRACT_APPROVE_WAIT": "RPA", "REBATE_HEADER": "RPA", "REBATE_IMPORT": "RPA", "REBATE_SUBMIT": "RPA", "M5_CREATE_ORDER": "RPA", "M5_MANUAL_CONFIRM": "HUMAN", "M5_RECEIPT_NOTICE": "HUMAN", "ARCHIVE_JOB": "STATE_CENTER", } NODE_LABELS_ZH = { "ORDER_FETCH": "订单获取", "OCR_EXTRACT": "OCR识别", "PRODUCT_MATCH": "对码", "SOURCE_ADD": "新增货源", "CONTRACT_CREATE": "合同建单", "CONTRACT_APPROVE_WAIT": "合同审批等待", "REBATE_HEADER": "返利总单", "REBATE_IMPORT": "返利细单导入", "REBATE_SUBMIT": "返利送审", "M5_CREATE_ORDER": "M5建单", "M5_MANUAL_CONFIRM": "M5人工确认", "M5_RECEIPT_NOTICE": "收货通知单", "ARCHIVE_JOB": "归档", } EXECUTOR_LABELS_ZH = { "STATE_CENTER": "状态中心", "RPA": "艺赛旗执行", "HUMAN": "人工处理", } STATUS_LABELS_ZH = { "PENDING": "待处理", "RUNNING": "执行中", "SUCCESS": "已完成", "FAILED": "已失败", "WAITING_USER": "等待人工", "WAITING_EXTERNAL": "等待外部", "SKIPPED": "已跳过", } def _should_skip(node_code: str, job: Mapping[str, object]) -> bool: if node_code == "OCR_EXTRACT": return job.get("source_type") != "OFFLINE" if node_code == "SOURCE_ADD": return not job.get("needs_source_add") return False def _node_row_map(job_nodes: Sequence[Mapping[str, object]]) -> dict[str, Mapping[str, object]]: out: dict[str, Mapping[str, object]] = {} for row in job_nodes: node_code = str(row.get("node_code") or "") if node_code and node_code not in out: out[node_code] = row return out def _display_status_for_node(job: Mapping[str, object], node_code: str, job_nodes_by_code: Mapping[str, Mapping[str, object]]) -> str: row = job_nodes_by_code.get(node_code) if row: row_status = str(row.get("status") or "PENDING") if row_status in {"SUCCESS", "FAILED", "RUNNING", "WAITING_USER", "WAITING_EXTERNAL"}: return row_status return "PENDING" if node_code == job.get("current_node_code"): job_status = str(job.get("status") or "PENDING") if job_status in {"RUNNING", "WAITING_USER", "WAITING_EXTERNAL", "FAILED_RETRYABLE", "FAILED_MANUAL"}: return "WAITING_USER" if job_status == "WAITING_USER" else ( "WAITING_EXTERNAL" if job_status == "WAITING_EXTERNAL" else ( "RUNNING" if job_status == "RUNNING" else "FAILED" ) ) if job_status == "SUCCESS": return "SUCCESS" return "PENDING" if node_code in CONDITIONAL_NODES and _should_skip(node_code, job): return "SKIPPED" return "PENDING" def build_timeline(job: Mapping[str, object], job_nodes: Sequence[Mapping[str, object]]) -> list[dict]: """Return all nodes with backend-computed display status. The frontend should render this directly and never infer node skipping itself. """ by_code = _node_row_map(job_nodes) timeline = [] for node_code in NODE_ORDER: row = by_code.get(node_code) timeline.append({ "node_code": node_code, "display_status": _display_status_for_node(job, node_code, by_code), "attempt_no": row.get("attempt_no") if row else None, "executor_type": row.get("executor_type") if row else None, "status": row.get("status") if row else None, "started_at": row.get("started_at") if row else None, "ended_at": row.get("ended_at") if row else None, }) return timeline def build_state_machine_meta() -> dict: return { "node_order": NODE_ORDER, "transitions": TRANSITIONS, "conditional_nodes": sorted(CONDITIONAL_NODES), "human_node_roles": HUMAN_NODE_ROLES, "node_executor": NODE_EXECUTOR, "node_labels": NODE_LABELS_ZH, "executor_labels": EXECUTOR_LABELS_ZH, "status_labels": STATUS_LABELS_ZH, }