164 lines
5.3 KiB
Python
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,
|
|
}
|