AutoRpa/state_center/app/web_state_machine.py

164 lines
5.3 KiB
Python

"""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,
}