1512 lines
63 KiB
Python
1512 lines
63 KiB
Python
"""
|
||
状态中心 · FastAPI 入口(第1步骨架)。
|
||
|
||
提供:
|
||
- 建单 POST /api/jobs
|
||
- 节点开始 POST /api/jobs/{job_id}/nodes/{node_code}/start
|
||
- 节点成功 POST /api/jobs/{job_id}/nodes/{node_code}/complete
|
||
- 节点失败 POST /api/jobs/{job_id}/nodes/{node_code}/fail
|
||
- 合同审批查一次 POST /api/jobs/{job_id}/nodes/CONTRACT_APPROVE_WAIT/check-result
|
||
- 到点待查列表(调度器扫) GET /api/scheduler/due-contract-checks
|
||
- 对码运行(内部算) POST /api/jobs/{job_id}/match/run
|
||
- 对码人工确认 POST /api/match/{match_id}/confirm
|
||
- 装载货品主数据 POST /api/match/reload-goods
|
||
- 整单查询(画树状图用) GET /api/jobs/{job_id}
|
||
|
||
鉴权:RPA 回调带 header X-Agent-Token。
|
||
|
||
节点编排(13 个 = 11 主节点 + 2 条件节点):
|
||
- 主链 11 步:ORDER_FETCH → PRODUCT_MATCH → CONTRACT_CREATE → CONTRACT_APPROVE_WAIT
|
||
→ REBATE_HEADER → REBATE_IMPORT → REBATE_SUBMIT → M5_CREATE_ORDER
|
||
→ M5_MANUAL_CONFIRM → M5_RECEIPT_NOTICE → ARCHIVE_JOB
|
||
- 2 条件节点:OCR_EXTRACT(仅线下单)、SOURCE_ADD(仅对码判定缺货源)。
|
||
条件不满足则自动跳过,不占流程。
|
||
|
||
转移不是"数组下一个",是显式转移表 + 条件节点判定(见 _next_node)。
|
||
每个节点标了执行方(STATE_CENTER 编排 / RPA 执行 / HUMAN 人工挂起),见 NODE_EXECUTOR。
|
||
回调做了幂等(重复 complete 不二次推进)和乱序拒绝(非当前节点回调返回 409)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import uuid
|
||
from datetime import datetime, timedelta
|
||
from typing import Optional
|
||
|
||
from fastapi import FastAPI, Header, HTTPException
|
||
from pydantic import BaseModel
|
||
|
||
from .config import settings
|
||
from .db import get_conn
|
||
from .match_engine import GoodsRecord, MatchEngine, build_match_key, load_goods_from_oracle
|
||
from .web_state_machine import build_state_machine_meta, build_timeline
|
||
|
||
app = FastAPI(title="采购主链RPA状态中心", version="0.1")
|
||
|
||
# 全部 13 个节点(11 主 + 2 条件),仅用于展示口径 / 前端画图 / 校验节点名合法。
|
||
# 顺序不代表实际转移,实际转移看 TRANSITIONS + _next_node。
|
||
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",
|
||
]
|
||
|
||
# 显式转移表:node -> 下一个"直连"节点(不含条件跳过逻辑,跳过在 _should_skip 处理)。
|
||
# None = 整单终点。条件节点(OCR_EXTRACT/SOURCE_ADD)也在链里,但可能被跳过。
|
||
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,
|
||
}
|
||
|
||
# 执行方标注:谁真正干这个节点。
|
||
# STATE_CENTER = 状态中心内部算(对码),不派 RPA、不挂人工。
|
||
# RPA = 艺赛旗点页面。
|
||
# HUMAN = 一期人工做(状态中心只挂待办等回填)。
|
||
NODE_EXECUTOR = {
|
||
"ORDER_FETCH": "RPA",
|
||
"OCR_EXTRACT": "RPA", # 线下单触发 OCR;线上单跳过
|
||
"PRODUCT_MATCH": "STATE_CENTER", # 对码是状态中心内部算
|
||
"SOURCE_ADD": "HUMAN", # 缺货源时人工在 M5 新增货源
|
||
"CONTRACT_CREATE": "RPA",
|
||
"CONTRACT_APPROVE_WAIT": "RPA", # 到点由调度器触发 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",
|
||
}
|
||
|
||
HUMAN_NODE_ROLES = {
|
||
"SOURCE_ADD": "PURCHASER",
|
||
"M5_MANUAL_CONFIRM": "PURCHASER",
|
||
"M5_RECEIPT_NOTICE": "PURCHASER",
|
||
}
|
||
|
||
HUMAN_TASK_TITLES = {
|
||
"M5_MANUAL_CONFIRM": "M5人工确认",
|
||
"M5_RECEIPT_NOTICE": "生成收货通知单",
|
||
"CONTRACT_APPROVE_WAIT": "合同审批超时",
|
||
}
|
||
|
||
HUMAN_TASK_REASONS = {
|
||
"M5_MANUAL_CONFIRM": "M5采购单已录入待保存,需要人工核对、保存并签订。",
|
||
"M5_RECEIPT_NOTICE": "M5采购单已签订,需要生成收货通知单并回填单号。",
|
||
"CONTRACT_APPROVE_WAIT": "合同审批已超过等待上限,需要运维确认已通过或终止本单。",
|
||
}
|
||
|
||
# 条件节点:满足条件才执行,否则自动跳过下一节点。
|
||
CONDITIONAL_NODES = {"OCR_EXTRACT", "SOURCE_ADD"}
|
||
|
||
# 业务编号回写:哪个节点产出哪个 job 字段
|
||
BUSINESS_REF_FIELD = {
|
||
"contractSystemNo": "contract_system_no",
|
||
"rebateOrderNo": "rebate_order_no",
|
||
"m5OrderNo": "m5_order_no",
|
||
"receiptNoticeNo": "receipt_notice_no",
|
||
}
|
||
|
||
|
||
def _now():
|
||
return datetime.now()
|
||
|
||
|
||
def _check_agent(token: Optional[str]):
|
||
if token != settings.AGENT_TOKEN:
|
||
raise HTTPException(status_code=401, detail="bad agent token")
|
||
|
||
|
||
def _should_skip(node_code: str, job: dict) -> bool:
|
||
"""条件节点是否跳过。
|
||
- OCR_EXTRACT:只有线下单(OFFLINE)才做 OCR,线上单跳过。
|
||
- SOURCE_ADD:只有对码判定缺货源(needs_source_add=1)才做,否则跳过。
|
||
"""
|
||
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 _next_node(node_code: str, job: dict) -> Optional[str]:
|
||
"""按显式转移表找下一节点;遇到不满足条件的条件节点自动继续往后跳。"""
|
||
nxt = TRANSITIONS.get(node_code)
|
||
# 一路跳过不满足条件的条件节点
|
||
while nxt is not None and nxt in CONDITIONAL_NODES and _should_skip(nxt, job):
|
||
nxt = TRANSITIONS.get(nxt)
|
||
return nxt
|
||
|
||
|
||
def _advance(cur, job: dict, node_code: str) -> Optional[str]:
|
||
"""把 job 从 node_code 推进到下一节点,处理终点/合同审批等待/普通三种落法。
|
||
返回 next_node(None=整单结束)。调用方需保证 node_code 已记 SUCCESS。"""
|
||
job_id = job["job_id"]
|
||
nxt = _next_node(node_code, job)
|
||
if nxt is None:
|
||
cur.execute("UPDATE job SET status='SUCCESS',finished_at=%s,updated_at=%s WHERE job_id=%s",
|
||
(_now(), _now(), job_id))
|
||
_log_event(cur, job_id, "JOB_FINISHED", None, "SYSTEM", None)
|
||
elif nxt == "ARCHIVE_JOB":
|
||
# 归档是状态中心自己的收口,进入即完成,不派 RPA、不挂人工。
|
||
now = _now()
|
||
cur.execute(
|
||
"INSERT INTO job_node(job_id,node_code,executor_type,attempt_no,status,started_at,ended_at,created_at)"
|
||
" VALUES(%s,%s,%s,%s,%s,%s,%s,%s)"
|
||
" ON DUPLICATE KEY UPDATE status='SUCCESS',ended_at=VALUES(ended_at)",
|
||
(job_id, "ARCHIVE_JOB", NODE_EXECUTOR["ARCHIVE_JOB"], 1, "SUCCESS", now, now, now),
|
||
)
|
||
cur.execute(
|
||
"UPDATE job SET status='SUCCESS',finished_at=%s,updated_at=%s,current_node_code=%s WHERE job_id=%s",
|
||
(now, now, "ARCHIVE_JOB", job_id),
|
||
)
|
||
_log_event(cur, job_id, "JOB_FINISHED", None, "SYSTEM", None)
|
||
return None
|
||
elif nxt == "CONTRACT_APPROVE_WAIT":
|
||
# 进等待外部态:不立刻派 RPA,挂 WAITING_EXTERNAL,到点由调度器触发查一次。
|
||
next_check = _now() + timedelta(minutes=settings.CONTRACT_WAIT_INTERVAL_MIN)
|
||
cur.execute(
|
||
"UPDATE job SET current_node_code=%s,status='WAITING_EXTERNAL',"
|
||
"next_check_at=%s,wait_check_count=0,updated_at=%s WHERE job_id=%s",
|
||
(nxt, next_check, _now(), job_id),
|
||
)
|
||
_log_event(cur, job_id, "NODE_STARTED", nxt, "SYSTEM", None,
|
||
{"wait": "CONTRACT_APPROVE", "next_check_at": next_check.isoformat()})
|
||
elif nxt in HUMAN_NODE_ROLES:
|
||
role_code = HUMAN_NODE_ROLES[nxt]
|
||
_mark_node_waiting_user(cur, job_id, nxt)
|
||
cur.execute(
|
||
"UPDATE job SET current_node_code=%s,status='WAITING_USER',"
|
||
"current_role=%s,updated_at=%s WHERE job_id=%s",
|
||
(nxt, role_code, _now(), job_id),
|
||
)
|
||
if nxt != "SOURCE_ADD":
|
||
_create_human_task(cur, job, nxt, role_code)
|
||
_log_event(cur, job_id, "MANUAL_HANDOFF", nxt, "SYSTEM", None,
|
||
{"role_code": role_code})
|
||
else:
|
||
cur.execute("UPDATE job SET current_node_code=%s,status='PENDING',updated_at=%s WHERE job_id=%s",
|
||
(nxt, _now(), job_id))
|
||
return nxt
|
||
|
||
|
||
def _log_event(cur, job_id, event_type, node_code, operator_type, operator, detail=None):
|
||
cur.execute(
|
||
"INSERT INTO job_event(job_id,event_type,node_code,operator_type,operator,detail_json,created_at)"
|
||
" VALUES(%s,%s,%s,%s,%s,%s,%s)",
|
||
(job_id, event_type, node_code, operator_type, operator,
|
||
json.dumps(detail, ensure_ascii=False) if detail else None, _now()),
|
||
)
|
||
|
||
|
||
def _serialize_visible_roles(visible_roles: Optional[list[str]]) -> Optional[str]:
|
||
if visible_roles is None:
|
||
return None
|
||
return json.dumps(visible_roles, ensure_ascii=False)
|
||
|
||
|
||
def _parse_visible_roles(value):
|
||
if value in (None, ""):
|
||
return []
|
||
if isinstance(value, list):
|
||
return [str(item) for item in value]
|
||
try:
|
||
parsed = json.loads(value)
|
||
except (TypeError, ValueError):
|
||
parsed = None
|
||
if isinstance(parsed, list):
|
||
return [str(item) for item in parsed]
|
||
return [part.strip() for part in str(value).split(",") if part.strip()]
|
||
|
||
|
||
def _record_job_artifacts(cur, job_id: str, node_code: str, attempt_no: int,
|
||
artifacts: Optional[list["NodeArtifactReq"]]):
|
||
if not artifacts:
|
||
return
|
||
for artifact in artifacts:
|
||
cur.execute(
|
||
"INSERT INTO job_artifact(job_id,node_code,attempt_no,artifact_type,storage_path,"
|
||
"file_hash,visible_roles,is_masked,created_at)"
|
||
" VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s)",
|
||
(
|
||
job_id,
|
||
node_code,
|
||
attempt_no,
|
||
artifact.artifactType,
|
||
artifact.storagePath,
|
||
artifact.fileHash,
|
||
_serialize_visible_roles(artifact.visibleRoles),
|
||
1 if artifact.isMasked else 0,
|
||
_now(),
|
||
),
|
||
)
|
||
|
||
|
||
def _fetch_job_artifacts(cur, job_id: str) -> list[dict]:
|
||
cur.execute(
|
||
"SELECT node_code,attempt_no,artifact_type,storage_path,file_hash,visible_roles,"
|
||
"is_masked,created_at FROM job_artifact WHERE job_id=%s ORDER BY id",
|
||
(job_id,),
|
||
)
|
||
rows = cur.fetchall()
|
||
out = []
|
||
for row in rows:
|
||
out.append({
|
||
"node_code": row.get("node_code"),
|
||
"attempt_no": row.get("attempt_no"),
|
||
"artifact_type": row.get("artifact_type"),
|
||
"storage_path": row.get("storage_path"),
|
||
"file_hash": row.get("file_hash"),
|
||
"visible_roles": _parse_visible_roles(row.get("visible_roles")),
|
||
"is_masked": bool(row.get("is_masked")),
|
||
"created_at": row.get("created_at"),
|
||
})
|
||
return out
|
||
|
||
|
||
def _mark_node_waiting_user(cur, job_id: str, node_code: str):
|
||
cur.execute(
|
||
"INSERT INTO job_node(job_id,node_code,executor_type,attempt_no,status,started_at,created_at)"
|
||
" VALUES(%s,%s,%s,%s,%s,%s,%s)"
|
||
" ON DUPLICATE KEY UPDATE status=VALUES(status),started_at=COALESCE(started_at,VALUES(started_at))",
|
||
(job_id, node_code, NODE_EXECUTOR.get(node_code, "HUMAN"), 1, "WAITING_USER", _now(), _now()),
|
||
)
|
||
|
||
|
||
def _mark_node_success(cur, job_id: str, node_code: str):
|
||
"""把当前人工/内部节点记 SUCCESS。
|
||
|
||
已有 WAITING_USER 行则原地收口;合同审批超时这类已有 FAILED 检查历史的,
|
||
另起 attempt,不覆盖查审批记录。
|
||
"""
|
||
cur.execute(
|
||
"SELECT attempt_no,status FROM job_node WHERE job_id=%s AND node_code=%s"
|
||
" ORDER BY attempt_no DESC LIMIT 1",
|
||
(job_id, node_code),
|
||
)
|
||
latest = cur.fetchone()
|
||
if latest and latest.get("status") == "WAITING_USER":
|
||
cur.execute(
|
||
"UPDATE job_node SET status='SUCCESS',ended_at=%s"
|
||
" WHERE job_id=%s AND node_code=%s AND attempt_no=%s",
|
||
(_now(), job_id, node_code, latest["attempt_no"]),
|
||
)
|
||
return
|
||
|
||
next_attempt = int((latest or {}).get("attempt_no") or 0) + 1
|
||
now = _now()
|
||
cur.execute(
|
||
"INSERT INTO job_node(job_id,node_code,executor_type,attempt_no,status,started_at,ended_at,created_at)"
|
||
" VALUES(%s,%s,%s,%s,'SUCCESS',%s,%s,%s)",
|
||
(job_id, node_code, NODE_EXECUTOR.get(node_code, "HUMAN"), next_attempt, now, now, now),
|
||
)
|
||
|
||
|
||
def _create_human_task(cur, job: dict, node_code: str, role_code: str):
|
||
job_id = job["job_id"]
|
||
cur.execute(
|
||
"DELETE FROM user_task WHERE job_id=%s AND node_code=%s AND status='OPEN'",
|
||
(job_id, node_code),
|
||
)
|
||
title_prefix = HUMAN_TASK_TITLES.get(node_code, node_code)
|
||
cur.execute(
|
||
"INSERT INTO user_task(task_id,job_id,node_code,role_code,title,reason_type,"
|
||
"reason_user_friendly,why_me_text,status,created_at)"
|
||
" VALUES(%s,%s,%s,%s,%s,%s,%s,%s,'OPEN',%s)",
|
||
(
|
||
uuid.uuid4().hex,
|
||
job_id,
|
||
node_code,
|
||
role_code,
|
||
f"{title_prefix}:{job['supplier_name']}",
|
||
node_code,
|
||
HUMAN_TASK_REASONS.get(node_code),
|
||
"该节点一期由人工处理,确认完成后流程继续。",
|
||
_now(),
|
||
),
|
||
)
|
||
|
||
|
||
def _supplier_has_source(cur, supplier_name: str, goods_id: str, current_job_id: str) -> bool:
|
||
"""用状态中心历史结果判断该供应商是否已有该货品货源。
|
||
|
||
一期没有 M5 货源 API,所以用历史已确认/已命中的 supplier+goods_id 作为明确触发源。
|
||
"""
|
||
cur.execute(
|
||
"SELECT 1 FROM job j JOIN product_match pm ON pm.job_id=j.job_id"
|
||
" WHERE j.supplier_name=%s AND pm.goods_id=%s AND j.job_id<>%s"
|
||
" AND pm.match_status IN ('EXACT','ALIAS') LIMIT 1",
|
||
(supplier_name, goods_id, current_job_id),
|
||
)
|
||
return cur.fetchone() is not None
|
||
|
||
|
||
def _source_add_needed(
|
||
cur,
|
||
job: dict,
|
||
goods_id: Optional[str],
|
||
spec: str = "",
|
||
manufacturer: str = "",
|
||
) -> int:
|
||
if not goods_id:
|
||
return 0
|
||
from .config import settings
|
||
from .m5_client import (
|
||
FUJIAN_PATH,
|
||
_load_json,
|
||
evaluate_priority_source,
|
||
load_connection,
|
||
query_priority_source,
|
||
recorded_source_rows,
|
||
)
|
||
|
||
if spec and manufacturer:
|
||
recorded = recorded_source_rows(str(goods_id))
|
||
if recorded is not None and settings.DEV_MODE:
|
||
entry_id = int(_load_json(FUJIAN_PATH)["entryId"])
|
||
judged = evaluate_priority_source(
|
||
recorded,
|
||
entry_id=entry_id,
|
||
spec=spec,
|
||
manufacturer=manufacturer,
|
||
)
|
||
return 1 if judged["source_add_needed"] else 0
|
||
|
||
cfg = load_connection()
|
||
if cfg.get("user") and cfg.get("password") and spec and manufacturer:
|
||
try:
|
||
entry_id = int(_load_json(FUJIAN_PATH)["entryId"])
|
||
rows = query_priority_source(str(goods_id), cfg)
|
||
judged = evaluate_priority_source(
|
||
rows,
|
||
entry_id=entry_id,
|
||
spec=spec,
|
||
manufacturer=manufacturer,
|
||
)
|
||
return 1 if judged["source_add_needed"] else 0
|
||
except Exception:
|
||
pass
|
||
return 0 if _supplier_has_source(cur, job["supplier_name"], goods_id, job["job_id"]) else 1
|
||
|
||
|
||
def _build_source_add_candidates(cur, job_id: str) -> list[dict]:
|
||
"""从 product_match 当前态生成 SOURCE_ADD 待办明细,按 goods_id 去重。"""
|
||
cur.execute(
|
||
"SELECT pm.id AS product_match_id,pm.order_item_id,pm.goods_id,pm.goods_code,"
|
||
"pm.base_unit,pm.match_key,oi.raw_product_name,oi.spec,oi.manufacturer"
|
||
" FROM product_match pm JOIN order_item oi ON oi.id=pm.order_item_id"
|
||
" WHERE pm.job_id=%s AND pm.source_add_needed=1 AND pm.goods_id IS NOT NULL"
|
||
" ORDER BY pm.id",
|
||
(job_id,),
|
||
)
|
||
out = []
|
||
seen_goods = set()
|
||
for row in cur.fetchall():
|
||
goods_id = row["goods_id"]
|
||
if goods_id in seen_goods:
|
||
continue
|
||
seen_goods.add(goods_id)
|
||
out.append({
|
||
"goods_id": goods_id,
|
||
"goods_code": row["goods_code"],
|
||
"base_unit": row["base_unit"],
|
||
"raw_product_name": row["raw_product_name"],
|
||
"spec": row["spec"],
|
||
"manufacturer": row["manufacturer"],
|
||
"order_item_id": row["order_item_id"],
|
||
"product_match_id": row["product_match_id"],
|
||
"match_key": row["match_key"],
|
||
})
|
||
return out
|
||
|
||
|
||
def _sync_source_add_task(cur, job: dict) -> list[dict]:
|
||
"""把 product_match.source_add_needed 同步到 job.needs_source_add 和一条聚合待办。"""
|
||
job_id = job["job_id"]
|
||
candidates = _build_source_add_candidates(cur, job_id)
|
||
cur.execute(
|
||
"DELETE FROM user_task WHERE job_id=%s AND node_code='SOURCE_ADD' AND status='OPEN'",
|
||
(job_id,),
|
||
)
|
||
cur.execute(
|
||
"UPDATE job SET needs_source_add=%s,updated_at=%s WHERE job_id=%s",
|
||
(1 if candidates else 0, _now(), job_id),
|
||
)
|
||
if not candidates:
|
||
return []
|
||
|
||
detail = {
|
||
"supplier_name": job["supplier_name"],
|
||
"items": candidates,
|
||
}
|
||
cur.execute(
|
||
"INSERT INTO user_task(task_id,job_id,node_code,role_code,title,reason_type,"
|
||
"reason_user_friendly,why_me_text,comparison_snapshot_json,candidates_json,status,created_at)"
|
||
" VALUES(%s,%s,'SOURCE_ADD','PURCHASER',%s,'SOURCE_ADD',%s,%s,%s,%s,'OPEN',%s)",
|
||
(
|
||
uuid.uuid4().hex,
|
||
job_id,
|
||
f"新增货源:{job['supplier_name']}",
|
||
"该供应商未维护这些品种的货源,需要在 M5 新增后继续。",
|
||
"供应商货源由采购/业务在 M5 维护。",
|
||
json.dumps(detail, ensure_ascii=False),
|
||
json.dumps(candidates, ensure_ascii=False),
|
||
_now(),
|
||
),
|
||
)
|
||
_log_event(cur, job_id, "TASK_CREATED", "SOURCE_ADD", "SYSTEM", None, detail)
|
||
return candidates
|
||
|
||
|
||
# ---------------- 建单 ----------------
|
||
|
||
class CreateJobReq(BaseModel):
|
||
source_type: str # YYC/XYY/OFFLINE
|
||
order_date: str # YYYY-MM-DD
|
||
supplier_name: str
|
||
created_by: str = "system"
|
||
|
||
|
||
class OrderItemRowReq(BaseModel):
|
||
orderNo: str
|
||
rawProductName: str
|
||
platformProductCode: Optional[str] = None
|
||
spec: Optional[str] = None
|
||
manufacturer: Optional[str] = None
|
||
approvalNo: Optional[str] = None
|
||
qty: float
|
||
unitPrice: Optional[float] = None
|
||
sourceRowNo: Optional[int] = None
|
||
|
||
|
||
class OrderItemsIngestReq(BaseModel):
|
||
replace: bool = True
|
||
rows: list[OrderItemRowReq]
|
||
|
||
|
||
@app.post("/api/jobs")
|
||
def create_job(req: CreateJobReq):
|
||
job_id = f"{req.source_type}-{req.supplier_name}-{req.order_date.replace('-', '')}-{uuid.uuid4().hex[:6]}"
|
||
business_key = f"{req.source_type}|{req.supplier_name}|{req.order_date}"
|
||
need_pdf = 1 if req.source_type == "YYC" else 0
|
||
first_node = NODE_ORDER[0]
|
||
|
||
conn = get_conn()
|
||
with conn.cursor() as cur:
|
||
# 幂等:同 business_key 已有未结束 job 就不重复建
|
||
cur.execute(
|
||
"SELECT job_id FROM job WHERE business_key=%s AND status NOT IN"
|
||
" ('SUCCESS','CANCELLED','FAILED_MANUAL') LIMIT 1",
|
||
(business_key,),
|
||
)
|
||
exist = cur.fetchone()
|
||
if exist:
|
||
return {"job_id": exist["job_id"], "reused": True}
|
||
|
||
cur.execute(
|
||
"INSERT INTO job(job_id,business_key,source_type,order_date,supplier_name,"
|
||
"need_pdf_upload,current_node_code,status,auto_save_enabled,created_by,created_at,updated_at)"
|
||
" VALUES(%s,%s,%s,%s,%s,%s,%s,'PENDING',0,%s,%s,%s)",
|
||
(job_id, business_key, req.source_type, req.order_date, req.supplier_name,
|
||
need_pdf, first_node, req.created_by, _now(), _now()),
|
||
)
|
||
_log_event(cur, job_id, "JOB_CREATED", None, "SYSTEM", req.created_by,
|
||
{"business_key": business_key})
|
||
return {"job_id": job_id, "first_node": first_node, "reused": False}
|
||
|
||
|
||
@app.post("/api/jobs/{job_id}/items")
|
||
def ingest_order_items(job_id: str, req: OrderItemsIngestReq,
|
||
x_agent_token: Optional[str] = Header(default=None)):
|
||
"""开发/接入侧写入订单明细。已有对码结果后禁止改写,避免破坏下游状态。"""
|
||
_check_agent(x_agent_token)
|
||
conn = get_conn()
|
||
with conn.cursor() as cur:
|
||
_load_job(cur, job_id)
|
||
cur.execute("SELECT COUNT(*) AS c FROM product_match WHERE job_id=%s", (job_id,))
|
||
if cur.fetchone()["c"]:
|
||
raise HTTPException(status_code=409, detail="已有对码结果,不能覆盖订单明细")
|
||
|
||
if req.replace:
|
||
cur.execute("DELETE FROM order_item WHERE job_id=%s", (job_id,))
|
||
|
||
order_nos = []
|
||
for row in req.rows:
|
||
if row.orderNo not in order_nos:
|
||
order_nos.append(row.orderNo)
|
||
cur.execute(
|
||
"INSERT INTO order_item(job_id,order_no,raw_product_name,platform_product_code,"
|
||
"spec,manufacturer,approval_no,qty,unit_price,source_row_no,created_at)"
|
||
" VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
|
||
(
|
||
job_id,
|
||
row.orderNo,
|
||
row.rawProductName,
|
||
row.platformProductCode,
|
||
row.spec,
|
||
row.manufacturer,
|
||
row.approvalNo,
|
||
row.qty,
|
||
row.unitPrice,
|
||
row.sourceRowNo,
|
||
_now(),
|
||
),
|
||
)
|
||
|
||
cur.execute(
|
||
"UPDATE job SET order_no_list_json=%s,updated_at=%s WHERE job_id=%s",
|
||
(json.dumps(order_nos, ensure_ascii=False), _now(), job_id),
|
||
)
|
||
_log_event(cur, job_id, "ORDER_ITEMS_INGESTED", "ORDER_FETCH", "ROBOT", None,
|
||
{"replace": req.replace, "inserted": len(req.rows), "order_nos": order_nos})
|
||
return {"ok": True, "inserted": len(req.rows), "order_nos": order_nos}
|
||
|
||
|
||
@app.get("/api/jobs")
|
||
def list_jobs(status: Optional[str] = None, source_type: Optional[str] = None,
|
||
supplier_name: Optional[str] = None, date_from: Optional[str] = None,
|
||
date_to: Optional[str] = None):
|
||
conn = get_conn()
|
||
sql = (
|
||
"SELECT job_id,supplier_name,order_date,source_type,status,current_node_code,"
|
||
"current_role,created_at,updated_at,finished_at FROM job WHERE 1=1"
|
||
)
|
||
params = []
|
||
if status:
|
||
sql += " AND status=%s"
|
||
params.append(status)
|
||
if source_type:
|
||
sql += " AND source_type=%s"
|
||
params.append(source_type)
|
||
if supplier_name:
|
||
sql += " AND supplier_name LIKE %s"
|
||
params.append(f"%{supplier_name}%")
|
||
if date_from:
|
||
sql += " AND order_date>=%s"
|
||
params.append(date_from)
|
||
if date_to:
|
||
sql += " AND order_date<=%s"
|
||
params.append(date_to)
|
||
sql += " ORDER BY created_at DESC"
|
||
with conn.cursor() as cur:
|
||
cur.execute(sql, tuple(params))
|
||
rows = cur.fetchall()
|
||
return {"items": rows}
|
||
|
||
|
||
@app.get("/api/tasks/my")
|
||
def list_my_tasks(roleCode: Optional[str] = None, status: Optional[str] = None):
|
||
conn = get_conn()
|
||
sql = (
|
||
"SELECT task_id,job_id,node_code,order_item_id,product_match_id,role_code,status,title,"
|
||
"reason_type,reason_user_friendly,why_me_text,candidates_json,submitted_action,"
|
||
"submitted_at,created_at FROM user_task WHERE 1=1"
|
||
)
|
||
params = []
|
||
if roleCode:
|
||
sql += " AND role_code=%s"
|
||
params.append(roleCode)
|
||
if status:
|
||
sql += " AND status=%s"
|
||
params.append(status.upper())
|
||
sql += " ORDER BY created_at DESC"
|
||
with conn.cursor() as cur:
|
||
cur.execute(sql, tuple(params))
|
||
rows = cur.fetchall()
|
||
return {"items": rows}
|
||
|
||
|
||
# ---------------- 节点开始 ----------------
|
||
|
||
class NodeStartReq(BaseModel):
|
||
attemptNo: int = 1
|
||
executorPlatform: Optional[str] = None
|
||
operator: Optional[str] = "agent"
|
||
|
||
|
||
def _load_job(cur, job_id: str) -> dict:
|
||
cur.execute("SELECT * FROM job WHERE job_id=%s", (job_id,))
|
||
job = cur.fetchone()
|
||
if not job:
|
||
raise HTTPException(status_code=404, detail="job not found")
|
||
return job
|
||
|
||
|
||
def _guard_current_node(job: dict, node_code: str):
|
||
"""乱序拒绝:回调的节点必须是该 job 当前节点,否则 409。
|
||
避免 RPA 端网络重发/顺序错乱把状态推乱。"""
|
||
if node_code not in NODE_ORDER:
|
||
raise HTTPException(status_code=400, detail=f"unknown node {node_code}")
|
||
if job.get("current_node_code") != node_code:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=f"out-of-order: job 当前在 {job.get('current_node_code')},拒绝 {node_code} 回调",
|
||
)
|
||
|
||
|
||
@app.post("/api/jobs/{job_id}/nodes/{node_code}/start")
|
||
def node_start(job_id: str, node_code: str, req: NodeStartReq,
|
||
x_agent_token: Optional[str] = Header(default=None)):
|
||
_check_agent(x_agent_token)
|
||
conn = get_conn()
|
||
with conn.cursor() as cur:
|
||
job = _load_job(cur, job_id)
|
||
_guard_current_node(job, node_code)
|
||
|
||
# 幂等:该节点该次尝试已 RUNNING/SUCCESS,直接回 ok,不重复起点
|
||
cur.execute(
|
||
"SELECT status FROM job_node WHERE job_id=%s AND node_code=%s AND attempt_no=%s",
|
||
(job_id, node_code, req.attemptNo),
|
||
)
|
||
row = cur.fetchone()
|
||
if row and row["status"] in ("RUNNING", "SUCCESS"):
|
||
return {"ok": True, "idempotent": True}
|
||
|
||
cur.execute(
|
||
"INSERT INTO job_node(job_id,node_code,executor_type,attempt_no,status,executor_platform,"
|
||
"executor_run_id,started_at,created_at) VALUES(%s,%s,%s,%s,'RUNNING',%s,NULL,%s,%s)"
|
||
" ON DUPLICATE KEY UPDATE status='RUNNING',started_at=VALUES(started_at)",
|
||
(job_id, node_code, NODE_EXECUTOR.get(node_code, "RPA"), req.attemptNo,
|
||
req.executorPlatform, _now(), _now()),
|
||
)
|
||
cur.execute(
|
||
"UPDATE job SET status='RUNNING',updated_at=%s WHERE job_id=%s",
|
||
(_now(), job_id),
|
||
)
|
||
_log_event(cur, job_id, "NODE_STARTED", node_code, "ROBOT", req.operator)
|
||
return {"ok": True}
|
||
|
||
|
||
# ---------------- 节点成功 ----------------
|
||
|
||
class NodeArtifactReq(BaseModel):
|
||
artifactType: str
|
||
storagePath: str
|
||
fileHash: Optional[str] = None
|
||
visibleRoles: Optional[list[str]] = None
|
||
isMasked: bool = False
|
||
|
||
|
||
class NodeCompleteReq(BaseModel):
|
||
attemptNo: int = 1
|
||
executorRunId: Optional[str] = None
|
||
idempotencyKey: Optional[str] = None # RPA 端生成的回调幂等键,重发同键不二次推进
|
||
businessRefs: Optional[dict] = None # {contractSystemNo:..., rebateOrderNo:...}
|
||
summary: Optional[dict] = None
|
||
artifacts: Optional[list[NodeArtifactReq]] = None
|
||
endedAt: Optional[str] = None
|
||
|
||
|
||
@app.post("/api/jobs/{job_id}/nodes/{node_code}/complete")
|
||
def node_complete(job_id: str, node_code: str, req: NodeCompleteReq,
|
||
x_agent_token: Optional[str] = Header(default=None)):
|
||
_check_agent(x_agent_token)
|
||
|
||
if NODE_EXECUTOR.get(node_code) == "HUMAN":
|
||
raise HTTPException(status_code=409, detail="人工节点只能通过待办提交,拒绝 RPA complete")
|
||
|
||
# 约束:要求业务编号却没回传的节点,不许记 SUCCESS
|
||
required = {"CONTRACT_CREATE": "contractSystemNo", "REBATE_HEADER": "rebateOrderNo",
|
||
"M5_RECEIPT_NOTICE": "receiptNoticeNo"}
|
||
if node_code in required:
|
||
key = required[node_code]
|
||
if not (req.businessRefs or {}).get(key):
|
||
raise HTTPException(status_code=400, detail=f"{node_code} 必须回传 {key}")
|
||
|
||
conn = get_conn()
|
||
with conn.cursor() as cur:
|
||
job = _load_job(cur, job_id)
|
||
|
||
# 幂等:该节点该次尝试已 SUCCESS,直接回上次算出的 next,不二次推进
|
||
cur.execute(
|
||
"SELECT status,callback_idempotency_key FROM job_node"
|
||
" WHERE job_id=%s AND node_code=%s AND attempt_no=%s",
|
||
(job_id, node_code, req.attemptNo),
|
||
)
|
||
prev = cur.fetchone()
|
||
if prev and prev["status"] == "SUCCESS":
|
||
return {"ok": True, "next_node": _next_node(node_code, job), "idempotent": True}
|
||
|
||
# 乱序拒绝:非当前节点的 complete 一律 409(幂等重发已在上面放行)
|
||
_guard_current_node(job, node_code)
|
||
|
||
cur.execute(
|
||
"UPDATE job_node SET status='SUCCESS',executor_run_id=%s,ended_at=%s,"
|
||
"callback_idempotency_key=%s,output_json=%s"
|
||
" WHERE job_id=%s AND node_code=%s AND attempt_no=%s",
|
||
(req.executorRunId, _now(), req.idempotencyKey,
|
||
json.dumps({"businessRefs": req.businessRefs, "summary": req.summary}, ensure_ascii=False),
|
||
job_id, node_code, req.attemptNo),
|
||
)
|
||
# 回写业务编号(三把钥匙等)
|
||
if req.businessRefs:
|
||
for k, v in req.businessRefs.items():
|
||
col = BUSINESS_REF_FIELD.get(k)
|
||
if col and v:
|
||
cur.execute(f"UPDATE job SET {col}=%s,updated_at=%s WHERE job_id=%s",
|
||
(v, _now(), job_id))
|
||
_record_job_artifacts(cur, job_id, node_code, req.attemptNo, req.artifacts)
|
||
_log_event(cur, job_id, "NODE_SUCCESS", node_code, "ROBOT", None, req.summary)
|
||
|
||
# 决定下一步(显式转移表 + 条件节点跳过)
|
||
nxt = _advance(cur, job, node_code)
|
||
return {"ok": True, "next_node": nxt}
|
||
|
||
|
||
# ---------------- 节点失败 ----------------
|
||
|
||
class NodeFailReq(BaseModel):
|
||
attemptNo: int = 1
|
||
errorCode: str
|
||
errorSummary: Optional[str] = None
|
||
retryable: bool = False
|
||
routeRole: Optional[str] = None
|
||
artifacts: Optional[list[NodeArtifactReq]] = None
|
||
|
||
|
||
@app.post("/api/jobs/{job_id}/nodes/{node_code}/fail")
|
||
def node_fail(job_id: str, node_code: str, req: NodeFailReq,
|
||
x_agent_token: Optional[str] = Header(default=None)):
|
||
_check_agent(x_agent_token)
|
||
|
||
# M5 质管缺失:不重试、不等质管,直接转人工手工做
|
||
force_manual = req.errorCode == "M5_QC_MISSING"
|
||
|
||
conn = get_conn()
|
||
with conn.cursor() as cur:
|
||
job = _load_job(cur, job_id)
|
||
_guard_current_node(job, node_code) # 只有当前节点能报失败,乱序 409
|
||
|
||
cur.execute(
|
||
"UPDATE job_node SET status='FAILED',error_code=%s,error_summary=%s,"
|
||
"role_at_failure=%s,ended_at=%s WHERE job_id=%s AND node_code=%s AND attempt_no=%s",
|
||
(req.errorCode, req.errorSummary, req.routeRole, _now(),
|
||
job_id, node_code, req.attemptNo),
|
||
)
|
||
_record_job_artifacts(cur, job_id, node_code, req.attemptNo, req.artifacts)
|
||
if force_manual:
|
||
job_status = "FAILED_MANUAL"
|
||
elif req.retryable:
|
||
job_status = "FAILED_RETRYABLE"
|
||
else:
|
||
job_status = "WAITING_USER" # 业务类转人工待办
|
||
cur.execute("UPDATE job SET status=%s,current_role=%s,updated_at=%s WHERE job_id=%s",
|
||
(job_status, req.routeRole, _now(), job_id))
|
||
_log_event(cur, job_id, "NODE_FAILED", node_code, "ROBOT", None,
|
||
{"errorCode": req.errorCode, "retryable": req.retryable})
|
||
return {"ok": True, "job_status": job_status}
|
||
|
||
|
||
@app.post("/api/jobs/{job_id}/retry")
|
||
def retry_job(job_id: str, x_agent_token: Optional[str] = Header(default=None)):
|
||
"""把 FAILED_RETRYABLE 的当前节点放回可 start 状态。
|
||
|
||
不覆盖失败历史:下一轮 start 用 attempt_no+1 另起 job_node 行。
|
||
"""
|
||
_check_agent(x_agent_token)
|
||
conn = get_conn()
|
||
with conn.cursor() as cur:
|
||
job = _load_job(cur, job_id)
|
||
if job.get("status") != "FAILED_RETRYABLE":
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=f"job 不在可重试状态(当前 {job.get('status')})",
|
||
)
|
||
node_code = job["current_node_code"]
|
||
cur.execute(
|
||
"SELECT COALESCE(MAX(attempt_no), 0) AS max_attempt FROM job_node"
|
||
" WHERE job_id=%s AND node_code=%s",
|
||
(job_id, node_code),
|
||
)
|
||
row = cur.fetchone() or {}
|
||
next_attempt = int(row.get("max_attempt") or 0) + 1
|
||
cur.execute(
|
||
"UPDATE job SET status='PENDING',updated_at=%s WHERE job_id=%s",
|
||
(_now(), job_id),
|
||
)
|
||
_log_event(
|
||
cur, job_id, "NODE_STARTED", node_code, "SYSTEM", None,
|
||
{"retry": True, "attempt_no": next_attempt},
|
||
)
|
||
return {"ok": True, "node_code": node_code, "attempt_no": next_attempt}
|
||
|
||
|
||
# ---------------- 合同审批等待(定时查一次,不连续轮询) ----------------
|
||
|
||
@app.get("/api/scheduler/due-contract-checks")
|
||
def due_contract_checks(x_agent_token: Optional[str] = Header(default=None)):
|
||
"""调度器/RPA 定时扫这个:到点该查审批的 job 列表。
|
||
业务方案:送审后每 CONTRACT_WAIT_INTERVAL_MIN 分钟查一次,不死等。"""
|
||
_check_agent(x_agent_token)
|
||
conn = get_conn()
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"SELECT job_id,contract_system_no,wait_check_count,next_check_at FROM job"
|
||
" WHERE status='WAITING_EXTERNAL' AND current_node_code='CONTRACT_APPROVE_WAIT'"
|
||
" AND next_check_at IS NOT NULL AND next_check_at<=%s ORDER BY next_check_at",
|
||
(_now(),),
|
||
)
|
||
rows = cur.fetchall()
|
||
return {"due": rows, "interval_min": settings.CONTRACT_WAIT_INTERVAL_MIN,
|
||
"max_checks": settings.CONTRACT_WAIT_MAX_CHECKS}
|
||
|
||
|
||
class ContractCheckReq(BaseModel):
|
||
approved: bool # RPA 查完:审批是否已通过
|
||
operator: Optional[str] = "agent"
|
||
detail: Optional[str] = None
|
||
|
||
|
||
@app.post("/api/jobs/{job_id}/nodes/CONTRACT_APPROVE_WAIT/check-result")
|
||
def contract_check_result(job_id: str, req: ContractCheckReq,
|
||
x_agent_token: Optional[str] = Header(default=None)):
|
||
"""RPA 到点登录查了一次审批状态,把结果回给状态中心。
|
||
- 通过 → 该节点 SUCCESS,推进到 REBATE_HEADER。
|
||
- 未通过 → 次数+1;没超上限则排下一次查(next_check_at);超了升级 OPS(WAITING_USER)。
|
||
"""
|
||
_check_agent(x_agent_token)
|
||
conn = get_conn()
|
||
with conn.cursor() as cur:
|
||
job = _load_job(cur, job_id)
|
||
if job.get("current_node_code") != "CONTRACT_APPROVE_WAIT" or job.get("status") != "WAITING_EXTERNAL":
|
||
raise HTTPException(status_code=409,
|
||
detail=f"job 不在合同审批等待态(当前 {job.get('current_node_code')}/{job.get('status')})")
|
||
|
||
# 本次是第几次查(1-based)。每次查都单独落一行 job_node(不覆盖历史),
|
||
# attempt_no 递增 → 命中 uniq_job_node_attempt 也不冲突,审计轨迹完整。
|
||
attempt = int(job.get("wait_check_count") or 0) + 1
|
||
|
||
if req.approved:
|
||
cur.execute(
|
||
"INSERT INTO job_node(job_id,node_code,executor_type,attempt_no,status,"
|
||
"started_at,ended_at,created_at) VALUES(%s,'CONTRACT_APPROVE_WAIT',%s,%s,'SUCCESS',%s,%s,%s)",
|
||
(job_id, NODE_EXECUTOR["CONTRACT_APPROVE_WAIT"], attempt, _now(), _now(), _now()),
|
||
)
|
||
nxt = _next_node("CONTRACT_APPROVE_WAIT", job) # REBATE_HEADER
|
||
cur.execute(
|
||
"UPDATE job SET current_node_code=%s,status='PENDING',wait_check_count=%s,"
|
||
"next_check_at=NULL,updated_at=%s WHERE job_id=%s",
|
||
(nxt, attempt, _now(), job_id),
|
||
)
|
||
_log_event(cur, job_id, "NODE_SUCCESS", "CONTRACT_APPROVE_WAIT", "ROBOT", req.operator,
|
||
{"approved": True, "checks": attempt})
|
||
return {"ok": True, "next_node": nxt}
|
||
|
||
# 未通过:本次查也单独落一行(记为一次尝试),再决定重排还是升级
|
||
over_limit = attempt >= settings.CONTRACT_WAIT_MAX_CHECKS
|
||
cur.execute(
|
||
"INSERT INTO job_node(job_id,node_code,executor_type,attempt_no,status,error_code,"
|
||
"error_summary,role_at_failure,started_at,ended_at,created_at)"
|
||
" VALUES(%s,'CONTRACT_APPROVE_WAIT',%s,%s,%s,%s,%s,%s,%s,%s,%s)",
|
||
(job_id, NODE_EXECUTOR["CONTRACT_APPROVE_WAIT"], attempt,
|
||
"FAILED" if over_limit else "WAITING_EXTERNAL",
|
||
"CONTRACT_APPROVE_TIMEOUT" if over_limit else None,
|
||
req.detail, "OPS" if over_limit else None, _now(), _now(), _now()),
|
||
)
|
||
if over_limit:
|
||
# 超上限:升级 OPS 人工介入,并建一条可提交的待办。
|
||
cur.execute(
|
||
"UPDATE job SET status='WAITING_USER',current_role='OPS',"
|
||
"wait_check_count=%s,next_check_at=NULL,updated_at=%s WHERE job_id=%s",
|
||
(attempt, _now(), job_id),
|
||
)
|
||
_create_human_task(cur, job, "CONTRACT_APPROVE_WAIT", "OPS")
|
||
_log_event(cur, job_id, "MANUAL_HANDOFF", "CONTRACT_APPROVE_WAIT", "SYSTEM", None,
|
||
{"reason": "approve_timeout", "checks": attempt})
|
||
return {"ok": True, "escalated": True, "job_status": "WAITING_USER"}
|
||
|
||
# 没超:排下一次查
|
||
next_check = _now() + timedelta(minutes=settings.CONTRACT_WAIT_INTERVAL_MIN)
|
||
cur.execute(
|
||
"UPDATE job SET wait_check_count=%s,next_check_at=%s,updated_at=%s WHERE job_id=%s",
|
||
(attempt, next_check, _now(), job_id),
|
||
)
|
||
_log_event(cur, job_id, "NODE_STARTED", "CONTRACT_APPROVE_WAIT", "ROBOT", req.operator,
|
||
{"approved": False, "checks": attempt, "next_check_at": next_check.isoformat()})
|
||
return {"ok": True, "approved": False, "checks": attempt, "next_check_at": next_check.isoformat()}
|
||
|
||
|
||
# ---------------- 对码(PRODUCT_MATCH,状态中心内部算) ----------------
|
||
#
|
||
# 骨架说明:对码是状态中心自己算的节点(executor=STATE_CENTER),不派 RPA。
|
||
# 依赖两样东西,拿到后本节点才真正跑得动:
|
||
# 1) order_item 已落库(建单/ORDER_FETCH 后把订单明细写进来)——Trae 任务;
|
||
# 2) 货品主数据装载进 MatchEngine(开工时从 Oracle 全量拉)——需 Oracle 真连。
|
||
# 当前给出可推进的骨架:有明细就跑 match_engine 落 product_match、判 needs_source_add、
|
||
# MULTI/NONE 建 user_task 转人工,全命中则推进下一节点;没明细/引擎没装载则明确报错,不静默卡。
|
||
|
||
# 进程内对码引擎(开工 reload 一次,后续内存匹配)。多进程部署时各进程各自 reload。
|
||
_ENGINE = MatchEngine()
|
||
_ENGINE_LOADED = False
|
||
|
||
|
||
@app.post("/api/match/reload-goods")
|
||
def reload_goods(x_agent_token: Optional[str] = Header(default=None)):
|
||
"""从 Oracle 全量拉货品主数据装载进内存引擎。需先配好 ORACLE_* 且 pip install oracledb。"""
|
||
_check_agent(x_agent_token)
|
||
if not settings.ORACLE_DSN:
|
||
raise HTTPException(status_code=400, detail="未配置 ORACLE_DSN,无法装载货品主数据")
|
||
global _ENGINE_LOADED
|
||
records = load_goods_from_oracle(settings)
|
||
_ENGINE.load_goods(records)
|
||
_ENGINE_LOADED = True
|
||
return {"ok": True, "loaded": len(records)}
|
||
|
||
|
||
@app.post("/api/dev/match/load-fixture-goods")
|
||
def load_fixture_goods(x_agent_token: Optional[str] = Header(default=None)):
|
||
"""仅本地联调用,不依赖 Oracle 装载一组可重复的货品样例。"""
|
||
_check_agent(x_agent_token)
|
||
if not settings.DEV_MODE:
|
||
raise HTTPException(status_code=404, detail="开发接口未启用")
|
||
|
||
records = [
|
||
GoodsRecord("阿卡波糖片", "国药准字H00000001", "50mg*30片", "盒", "示例制药", "G-001", "YP-001"),
|
||
GoodsRecord("阿莫西林胶囊", "国药准字H00000002", "0.25g*24粒", "盒", "示例医药", "G-002", "YP-002"),
|
||
GoodsRecord("氯化钠注射液", "国药准字H00000003", "100ml", "瓶", "示例药业", "G-003", "YP-003"),
|
||
GoodsRecord(
|
||
"哈药牌钙铁锌口服液",
|
||
"食健备G201923000558",
|
||
"10ml*90支",
|
||
"盒",
|
||
"哈药集团三精制药有限公司",
|
||
"4000109983",
|
||
"4000109983",
|
||
),
|
||
]
|
||
global _ENGINE_LOADED
|
||
_ENGINE.load_goods(records)
|
||
_ENGINE_LOADED = True
|
||
return {"ok": True, "loaded": len(records)}
|
||
|
||
|
||
def _load_aliases_into_engine(cur) -> int:
|
||
"""把 mapping_config(PRODUCT,active)里的累积别名装进引擎 alias_map。
|
||
target_value 存的是 {"goods_id":..,"goods_code":..,"base_unit":..} 的 JSON。"""
|
||
cur.execute(
|
||
"SELECT config_key,target_value FROM mapping_config"
|
||
" WHERE config_type='PRODUCT' AND is_active=1"
|
||
" ORDER BY config_key,updated_at,id",
|
||
)
|
||
alias = {}
|
||
for row in cur.fetchall():
|
||
try:
|
||
tv = json.loads(row["target_value"]) if row["target_value"] else {}
|
||
except (TypeError, ValueError):
|
||
continue
|
||
gid = tv.get("goods_id")
|
||
if gid:
|
||
alias[row["config_key"]] = (gid, tv.get("goods_code", ""), tv.get("base_unit", ""))
|
||
_ENGINE.load_aliases(alias)
|
||
return len(alias)
|
||
|
||
|
||
@app.post("/api/jobs/{job_id}/match/run")
|
||
def match_run(job_id: str, x_agent_token: Optional[str] = Header(default=None)):
|
||
"""对该 job 的 order_item 全量跑对码,落 product_match。
|
||
- 全部 EXACT/ALIAS(且不缺货源)→ 推进下一节点。
|
||
- 有 MULTI/NONE → 逐条建 user_task,job 转 WAITING_USER(单条挂起,整批继续落库)。
|
||
"""
|
||
_check_agent(x_agent_token)
|
||
if not _ENGINE_LOADED:
|
||
raise HTTPException(status_code=409, detail="对码引擎未装载货品主数据,先调 /api/match/reload-goods")
|
||
|
||
conn = get_conn()
|
||
try:
|
||
conn.begin()
|
||
with conn.cursor() as cur:
|
||
job = _load_job(cur, job_id)
|
||
_guard_current_node(job, "PRODUCT_MATCH")
|
||
|
||
cur.execute(
|
||
"SELECT id,raw_product_name,approval_no,spec,manufacturer FROM order_item"
|
||
" WHERE job_id=%s ORDER BY id", (job_id,),
|
||
)
|
||
items = cur.fetchall()
|
||
if not items:
|
||
# 明确报错,不静默推进(否则等于跳过对码)
|
||
raise HTTPException(status_code=400, detail=f"job {job_id} 没有 order_item,无法对码")
|
||
|
||
_load_aliases_into_engine(cur)
|
||
|
||
# 对码结果是当前态,重跑时用 job_id+order_item_id upsert,避免重复 product_match/user_task。
|
||
cur.execute(
|
||
"DELETE FROM user_task WHERE job_id=%s AND node_code IN ('PRODUCT_MATCH','SOURCE_ADD')"
|
||
" AND status='OPEN'",
|
||
(job_id,),
|
||
)
|
||
|
||
stat = {"EXACT": 0, "ALIAS": 0, "MULTI": 0, "NONE": 0, "SOURCE_ADD": 0}
|
||
need_match_human = False
|
||
for it in items:
|
||
r = _ENGINE.match_one(it["id"], it["raw_product_name"], it["approval_no"] or "",
|
||
it["spec"] or "", it["manufacturer"] or "")
|
||
stat[r.match_status] = stat.get(r.match_status, 0) + 1
|
||
source_add_needed = _source_add_needed(
|
||
cur, job, r.goods_id, it.get("spec") or "", it.get("manufacturer") or ""
|
||
)
|
||
cur.execute(
|
||
"INSERT INTO product_match(job_id,order_item_id,match_key,match_status,"
|
||
"goods_id,goods_code,base_unit,candidates_json,source_add_needed,created_at)"
|
||
" VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)"
|
||
" ON DUPLICATE KEY UPDATE match_key=VALUES(match_key),match_status=VALUES(match_status),"
|
||
"goods_id=VALUES(goods_id),goods_code=VALUES(goods_code),base_unit=VALUES(base_unit),"
|
||
"candidates_json=VALUES(candidates_json),source_add_needed=VALUES(source_add_needed),"
|
||
"confirmed_by=NULL,confirmed_at=NULL,id=LAST_INSERT_ID(id)",
|
||
(job_id, it["id"], r.match_key, r.match_status, r.goods_id, r.goods_code,
|
||
r.base_unit,
|
||
json.dumps(r.candidates, ensure_ascii=False) if r.candidates else None,
|
||
source_add_needed, _now()),
|
||
)
|
||
match_id = cur.lastrowid
|
||
if r.match_status in ("MULTI", "NONE"):
|
||
need_match_human = True
|
||
reason = "MATCH_MULTI" if r.match_status == "MULTI" else "MATCH_NONE"
|
||
cur.execute(
|
||
"INSERT INTO user_task(task_id,job_id,node_code,order_item_id,product_match_id,"
|
||
"role_code,title,reason_type,candidates_json,status,created_at)"
|
||
" VALUES(%s,%s,'PRODUCT_MATCH',%s,%s,'PLANNER',%s,%s,%s,'OPEN',%s)",
|
||
(uuid.uuid4().hex, job_id, it["id"], match_id,
|
||
f"对码待确认:{it['raw_product_name']}", reason,
|
||
json.dumps(r.candidates, ensure_ascii=False) if r.candidates else None, _now()),
|
||
)
|
||
|
||
source_add_candidates = _sync_source_add_task(cur, job)
|
||
stat["SOURCE_ADD"] = len(source_add_candidates)
|
||
|
||
cur.execute(
|
||
"INSERT INTO job_node(job_id,node_code,executor_type,attempt_no,status,started_at,ended_at,created_at)"
|
||
" VALUES(%s,'PRODUCT_MATCH',%s,1,%s,%s,%s,%s)"
|
||
" ON DUPLICATE KEY UPDATE status=VALUES(status),ended_at=VALUES(ended_at)",
|
||
(job_id, NODE_EXECUTOR["PRODUCT_MATCH"], "WAITING_USER" if need_match_human else "SUCCESS",
|
||
_now(), _now(), _now()),
|
||
)
|
||
_log_event(cur, job_id, "NODE_SUCCESS" if not need_match_human else "TASK_CREATED",
|
||
"PRODUCT_MATCH", "SYSTEM", None, stat)
|
||
|
||
if need_match_human:
|
||
# 有待确认:job 转人工待办,不推进(人工 confirm 后再推进)。
|
||
cur.execute(
|
||
"UPDATE job SET status='WAITING_USER',current_role='PLANNER',updated_at=%s WHERE job_id=%s",
|
||
(_now(), job_id),
|
||
)
|
||
conn.commit()
|
||
return {"ok": True, "stat": stat, "waiting_user": True,
|
||
"source_add_needed": bool(source_add_candidates)}
|
||
|
||
# 全部已明确匹配:按 needs_source_add 决定是否进入 SOURCE_ADD,否则进合同建单。
|
||
job = _load_job(cur, job_id)
|
||
nxt = _advance(cur, job, "PRODUCT_MATCH")
|
||
conn.commit()
|
||
return {"ok": True, "stat": stat, "next_node": nxt,
|
||
"source_add_needed": bool(source_add_candidates)}
|
||
except Exception:
|
||
conn.rollback()
|
||
raise
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
class MatchConfirmReq(BaseModel):
|
||
goods_id: str # 人工选定/新增的货品ID
|
||
goods_code: Optional[str] = None
|
||
base_unit: Optional[str] = None
|
||
confirmed_by: str = "planner"
|
||
save_as_alias: bool = True # 是否把这次选择沉淀成累积别名(下次自动命中)
|
||
|
||
|
||
@app.post("/api/match/{match_id}/confirm")
|
||
def match_confirm(match_id: int, req: MatchConfirmReq,
|
||
x_agent_token: Optional[str] = Header(default=None)):
|
||
"""人工确认一条 MULTI/NONE 对码结果:
|
||
- 回写 product_match(goods_id/编码/单位、确认人)。
|
||
- save_as_alias=1 时把 match_key→货品ID 沉淀进 mapping_config(PRODUCT),下次自动 ALIAS 命中。
|
||
- 关掉对应 user_task;若该 job 已无待确认,推进 PRODUCT_MATCH 到下一节点。
|
||
注意:只写 mapping_config 累积别名,绝不回写 Oracle 货品主数据(主数据只读)。
|
||
"""
|
||
_check_agent(x_agent_token)
|
||
conn = get_conn()
|
||
try:
|
||
conn.begin()
|
||
with conn.cursor() as cur:
|
||
cur.execute("SELECT * FROM product_match WHERE id=%s", (match_id,))
|
||
pm = cur.fetchone()
|
||
if not pm:
|
||
raise HTTPException(status_code=404, detail="product_match not found")
|
||
job_id = pm["job_id"]
|
||
job = _load_job(cur, job_id)
|
||
oi = None
|
||
if pm.get("order_item_id"):
|
||
cur.execute(
|
||
"SELECT spec,manufacturer FROM order_item WHERE id=%s",
|
||
(pm["order_item_id"],),
|
||
)
|
||
oi = cur.fetchone()
|
||
source_add_needed = _source_add_needed(
|
||
cur,
|
||
job,
|
||
req.goods_id,
|
||
(oi or {}).get("spec") or "",
|
||
(oi or {}).get("manufacturer") or "",
|
||
)
|
||
|
||
cur.execute(
|
||
"UPDATE product_match SET match_status='ALIAS',goods_id=%s,goods_code=%s,base_unit=%s,"
|
||
"source_add_needed=%s,confirmed_by=%s,confirmed_at=%s WHERE id=%s",
|
||
(req.goods_id, req.goods_code, req.base_unit, source_add_needed,
|
||
req.confirmed_by, _now(), match_id),
|
||
)
|
||
|
||
if req.save_as_alias:
|
||
target = json.dumps({"goods_id": req.goods_id, "goods_code": req.goods_code,
|
||
"base_unit": req.base_unit}, ensure_ascii=False)
|
||
cur.execute(
|
||
"UPDATE mapping_config SET is_active=0,effective_to=%s,updated_by=%s,updated_at=%s"
|
||
" WHERE config_type='PRODUCT' AND config_key=%s AND is_active=1",
|
||
(_now(), req.confirmed_by, _now(), pm["match_key"]),
|
||
)
|
||
cur.execute(
|
||
"INSERT INTO mapping_config(config_type,config_key,target_value,is_active,"
|
||
"effective_from,updated_by,updated_at)"
|
||
" VALUES('PRODUCT',%s,%s,1,%s,%s,%s)",
|
||
(pm["match_key"], target, _now(), req.confirmed_by, _now()),
|
||
)
|
||
|
||
# 精确关掉这条对码结果对应的待办(按 product_match_id 关联,不再靠"最早 OPEN"猜)
|
||
cur.execute(
|
||
"UPDATE user_task SET status='SUBMITTED',submitted_by=%s,submitted_at=%s"
|
||
" WHERE product_match_id=%s AND status='OPEN'",
|
||
(req.confirmed_by, _now(), match_id),
|
||
)
|
||
_log_event(cur, job_id, "TASK_SUBMITTED", "PRODUCT_MATCH", "USER", req.confirmed_by,
|
||
{"match_id": match_id, "goods_id": req.goods_id,
|
||
"source_add_needed": bool(source_add_needed)})
|
||
|
||
source_add_candidates = _sync_source_add_task(cur, job)
|
||
|
||
# 还有没有未解决的对码?(product_match 里仍是 MULTI/NONE 的)
|
||
cur.execute(
|
||
"SELECT COUNT(*) AS c FROM product_match WHERE job_id=%s AND match_status IN ('MULTI','NONE')",
|
||
(job_id,),
|
||
)
|
||
remaining = cur.fetchone()["c"]
|
||
if remaining == 0:
|
||
job = _load_job(cur, job_id)
|
||
if job.get("current_node_code") == "PRODUCT_MATCH":
|
||
cur.execute(
|
||
"UPDATE job_node SET status='SUCCESS',ended_at=%s"
|
||
" WHERE job_id=%s AND node_code='PRODUCT_MATCH' AND attempt_no=1",
|
||
(_now(), job_id),
|
||
)
|
||
nxt = _advance(cur, job, "PRODUCT_MATCH")
|
||
conn.commit()
|
||
return {"ok": True, "all_resolved": True, "next_node": nxt,
|
||
"source_add_needed": bool(source_add_candidates)}
|
||
conn.commit()
|
||
return {"ok": True, "all_resolved": remaining == 0, "remaining": remaining,
|
||
"source_add_needed": bool(source_add_candidates)}
|
||
except Exception:
|
||
conn.rollback()
|
||
raise
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
# ---------------- 人工待办提交(Web/H5 用) ----------------
|
||
|
||
class TaskSubmitReq(BaseModel):
|
||
action: str
|
||
payload: Optional[dict] = None
|
||
submitToken: str
|
||
operator: Optional[str] = "user"
|
||
|
||
|
||
def _submit_candidate_selection(cur, task: dict, job: dict, payload: dict, operator: str) -> dict:
|
||
"""在当前事务/连接中完成一条对码候选确认。"""
|
||
match_id = payload.get("productMatchId")
|
||
goods_id = payload.get("goodsId")
|
||
if not match_id or not goods_id:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="选择候选货品时必须提供 productMatchId 和 goodsId",
|
||
)
|
||
if task.get("node_code") != "PRODUCT_MATCH":
|
||
raise HTTPException(status_code=400, detail="该待办不是对码待办")
|
||
if task.get("product_match_id") and int(match_id) != int(task["product_match_id"]):
|
||
raise HTTPException(status_code=409, detail="候选货品与当前待办不匹配")
|
||
if job.get("current_node_code") != "PRODUCT_MATCH":
|
||
raise HTTPException(status_code=409, detail="当前单据已不在对码节点")
|
||
|
||
cur.execute("SELECT * FROM product_match WHERE id=%s", (match_id,))
|
||
pm = cur.fetchone()
|
||
if not pm or pm.get("job_id") != task["job_id"]:
|
||
raise HTTPException(status_code=404, detail="对码结果不存在")
|
||
|
||
oi = None
|
||
if pm.get("order_item_id"):
|
||
cur.execute(
|
||
"SELECT spec,manufacturer FROM order_item WHERE id=%s",
|
||
(pm["order_item_id"],),
|
||
)
|
||
oi = cur.fetchone()
|
||
source_add_needed = _source_add_needed(
|
||
cur,
|
||
job,
|
||
goods_id,
|
||
(oi or {}).get("spec") or "",
|
||
(oi or {}).get("manufacturer") or "",
|
||
)
|
||
goods_code = payload.get("goodsCode")
|
||
base_unit = payload.get("baseUnit")
|
||
cur.execute(
|
||
"UPDATE product_match SET match_status='ALIAS',goods_id=%s,goods_code=%s,base_unit=%s,"
|
||
"source_add_needed=%s,confirmed_by=%s,confirmed_at=%s WHERE id=%s",
|
||
(goods_id, goods_code, base_unit, source_add_needed, operator, _now(), match_id),
|
||
)
|
||
|
||
if payload.get("saveAsAlias", True):
|
||
target = json.dumps(
|
||
{"goods_id": goods_id, "goods_code": goods_code, "base_unit": base_unit},
|
||
ensure_ascii=False,
|
||
)
|
||
cur.execute(
|
||
"UPDATE mapping_config SET is_active=0,effective_to=%s,updated_by=%s,updated_at=%s"
|
||
" WHERE config_type='PRODUCT' AND config_key=%s AND is_active=1",
|
||
(_now(), operator, _now(), pm["match_key"]),
|
||
)
|
||
cur.execute(
|
||
"INSERT INTO mapping_config(config_type,config_key,target_value,is_active,"
|
||
"effective_from,updated_by,updated_at) VALUES('PRODUCT',%s,%s,1,%s,%s,%s)",
|
||
(pm["match_key"], target, _now(), operator, _now()),
|
||
)
|
||
|
||
source_add_candidates = _sync_source_add_task(cur, job)
|
||
cur.execute(
|
||
"SELECT COUNT(*) AS c FROM product_match WHERE job_id=%s AND match_status IN ('MULTI','NONE')",
|
||
(task["job_id"],),
|
||
)
|
||
remaining = cur.fetchone()["c"]
|
||
result = {
|
||
"ok": True,
|
||
"all_resolved": remaining == 0,
|
||
"remaining": remaining,
|
||
"source_add_needed": bool(source_add_candidates),
|
||
}
|
||
if remaining == 0:
|
||
cur.execute(
|
||
"UPDATE job_node SET status='SUCCESS',ended_at=%s"
|
||
" WHERE job_id=%s AND node_code='PRODUCT_MATCH' AND attempt_no=1",
|
||
(_now(), task["job_id"]),
|
||
)
|
||
result["next_node"] = _advance(cur, job, "PRODUCT_MATCH")
|
||
return result
|
||
|
||
|
||
@app.post("/api/tasks/{task_id}/submit")
|
||
def task_submit(task_id: str, req: TaskSubmitReq,
|
||
x_agent_token: Optional[str] = Header(default=None)):
|
||
_check_agent(x_agent_token)
|
||
if not req.submitToken or len(req.submitToken) > 64:
|
||
raise HTTPException(status_code=400, detail="submitToken 最长 64")
|
||
|
||
conn = get_conn()
|
||
began = False
|
||
try:
|
||
begin = getattr(conn, "begin", None)
|
||
if callable(begin):
|
||
begin()
|
||
began = True
|
||
result = _task_submit_body(conn, task_id, req)
|
||
if began:
|
||
conn.commit()
|
||
return result
|
||
except Exception:
|
||
rollback = getattr(conn, "rollback", None)
|
||
if callable(rollback):
|
||
rollback()
|
||
raise
|
||
finally:
|
||
close = getattr(conn, "close", None)
|
||
if callable(close):
|
||
close()
|
||
|
||
|
||
def _task_submit_body(conn, task_id: str, req: TaskSubmitReq):
|
||
with conn.cursor() as cur:
|
||
cur.execute("SELECT * FROM user_task WHERE task_id=%s", (task_id,))
|
||
task = cur.fetchone()
|
||
if not task:
|
||
raise HTTPException(status_code=404, detail="task not found")
|
||
|
||
existing_token = task.get("submit_token")
|
||
if existing_token:
|
||
if existing_token != req.submitToken:
|
||
raise HTTPException(status_code=409, detail="task already submitted with another token")
|
||
try:
|
||
result = json.loads(task.get("submitted_result_json") or "{}")
|
||
except (TypeError, ValueError):
|
||
result = {"ok": True}
|
||
result["idempotent"] = True
|
||
return result
|
||
|
||
if task.get("status") != "OPEN":
|
||
raise HTTPException(status_code=409, detail=f"task status is {task.get('status')}")
|
||
|
||
job = _load_job(cur, task["job_id"])
|
||
node_code = task["node_code"]
|
||
payload = req.payload or {}
|
||
|
||
if req.action == "SELECT_CANDIDATE":
|
||
result = _submit_candidate_selection(cur, task, job, payload, req.operator or "user")
|
||
elif req.action == "CONFIRM_AND_CONTINUE":
|
||
if job.get("current_node_code") != node_code:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=f"job 当前在 {job.get('current_node_code')},不能提交 {node_code}",
|
||
)
|
||
|
||
if node_code == "M5_RECEIPT_NOTICE":
|
||
receipt_no = payload.get("receiptNoticeNo")
|
||
if not receipt_no:
|
||
raise HTTPException(status_code=400, detail="M5_RECEIPT_NOTICE 需要 receiptNoticeNo")
|
||
cur.execute(
|
||
"UPDATE job SET receipt_notice_no=%s,updated_at=%s WHERE job_id=%s",
|
||
(receipt_no, _now(), task["job_id"]),
|
||
)
|
||
|
||
if node_code == "SOURCE_ADD":
|
||
cur.execute(
|
||
"UPDATE job SET needs_source_add=0,updated_at=%s WHERE job_id=%s",
|
||
(_now(), task["job_id"]),
|
||
)
|
||
|
||
_mark_node_success(cur, task["job_id"], node_code)
|
||
job = _load_job(cur, task["job_id"])
|
||
nxt = _advance(cur, job, node_code)
|
||
result = {"ok": True, "next_node": nxt}
|
||
elif req.action == "TERMINATE_JOB":
|
||
cur.execute(
|
||
"UPDATE job SET status='CANCELLED',finished_at=%s,updated_at=%s WHERE job_id=%s",
|
||
(_now(), _now(), task["job_id"]),
|
||
)
|
||
_log_event(cur, task["job_id"], "TASK_SUBMITTED", node_code, "USER", req.operator,
|
||
{"action": req.action, "payload": payload})
|
||
result = {"ok": True, "job_status": "CANCELLED"}
|
||
elif req.action in ("TRANSFER_MANUAL", "RETRY_CURRENT_NODE"):
|
||
if req.action == "TRANSFER_MANUAL":
|
||
target_role = payload.get("targetRole")
|
||
if target_role:
|
||
cur.execute(
|
||
"UPDATE job SET current_role=%s,updated_at=%s WHERE job_id=%s",
|
||
(target_role, _now(), task["job_id"]),
|
||
)
|
||
_log_event(cur, task["job_id"], "TASK_SUBMITTED", node_code, "USER", req.operator,
|
||
{"action": req.action, "payload": payload})
|
||
result = {"ok": True, "job_status": job.get("status"), "node_code": node_code}
|
||
else:
|
||
raise HTTPException(status_code=400, detail=f"unsupported task action {req.action}")
|
||
|
||
result_json = json.dumps(result, ensure_ascii=False)
|
||
cur.execute(
|
||
"UPDATE user_task SET status='SUBMITTED',submit_token=%s,submitted_action=%s,"
|
||
"submitted_payload_json=%s,submitted_result_json=%s,submitted_by=%s,submitted_at=%s"
|
||
" WHERE task_id=%s",
|
||
(
|
||
req.submitToken,
|
||
req.action,
|
||
json.dumps(payload, ensure_ascii=False),
|
||
result_json,
|
||
req.operator,
|
||
_now(),
|
||
task_id,
|
||
),
|
||
)
|
||
_log_event(cur, task["job_id"], "TASK_SUBMITTED", node_code, "USER", req.operator,
|
||
{"action": req.action, "payload": payload, "result": result})
|
||
return result
|
||
|
||
|
||
# ---------------- 整单查询(前端画树状图用) ----------------
|
||
|
||
@app.get("/api/jobs/{job_id}")
|
||
def get_job(job_id: str):
|
||
conn = get_conn()
|
||
with conn.cursor() as cur:
|
||
cur.execute("SELECT * FROM job WHERE job_id=%s", (job_id,))
|
||
job = cur.fetchone()
|
||
if not job:
|
||
raise HTTPException(status_code=404, detail="job not found")
|
||
cur.execute("SELECT node_code,attempt_no,status,started_at,ended_at,duration_ms,"
|
||
"error_code,error_summary,output_json FROM job_node WHERE job_id=%s"
|
||
" ORDER BY id", (job_id,))
|
||
nodes = cur.fetchall()
|
||
cur.execute("SELECT task_id,node_code,role_code,reason_type,status FROM user_task"
|
||
" WHERE job_id=%s ORDER BY id", (job_id,))
|
||
tasks = cur.fetchall()
|
||
artifacts = _fetch_job_artifacts(cur, job_id)
|
||
return {"job": job, "nodes": nodes, "tasks": tasks, "artifacts": artifacts,
|
||
"node_order": NODE_ORDER, "node_executor": NODE_EXECUTOR,
|
||
"conditional_nodes": sorted(CONDITIONAL_NODES)}
|
||
|
||
|
||
@app.get("/api/jobs/{job_id}/timeline")
|
||
def get_job_timeline(job_id: str):
|
||
conn = get_conn()
|
||
with conn.cursor() as cur:
|
||
cur.execute("SELECT * FROM job WHERE job_id=%s", (job_id,))
|
||
job = cur.fetchone()
|
||
if not job:
|
||
raise HTTPException(status_code=404, detail="job not found")
|
||
cur.execute(
|
||
"SELECT node_code,attempt_no,status,executor_type,started_at,ended_at "
|
||
"FROM job_node WHERE job_id=%s ORDER BY id",
|
||
(job_id,),
|
||
)
|
||
nodes = cur.fetchall()
|
||
return {"job": job, "timeline": build_timeline(job, nodes)}
|
||
|
||
|
||
@app.get("/api/jobs/{job_id}/evidence-package")
|
||
def get_evidence_package(job_id: str):
|
||
conn = get_conn()
|
||
with conn.cursor() as cur:
|
||
cur.execute("SELECT job_id FROM job WHERE job_id=%s", (job_id,))
|
||
job = cur.fetchone()
|
||
if not job:
|
||
raise HTTPException(status_code=404, detail="job not found")
|
||
artifacts = _fetch_job_artifacts(cur, job_id)
|
||
return {"job_id": job_id, "artifacts": artifacts}
|
||
|
||
|
||
@app.get("/health")
|
||
def health():
|
||
conn = None
|
||
try:
|
||
conn = get_conn()
|
||
with conn.cursor() as cur:
|
||
cur.execute("SELECT 1")
|
||
cur.fetchone()
|
||
return {"ok": True, "database": "ok"}
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=503, detail=f"database unavailable: {exc}") from exc
|
||
finally:
|
||
if conn is not None:
|
||
conn.close()
|
||
|
||
|
||
@app.get("/api/state-machine")
|
||
def get_state_machine():
|
||
return build_state_machine_meta()
|