1149 lines
37 KiB
Python
1149 lines
37 KiB
Python
import importlib
|
|
import sys
|
|
import types
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
def _install_fake_pymysql():
|
|
pymysql = types.ModuleType("pymysql")
|
|
cursors = types.ModuleType("pymysql.cursors")
|
|
|
|
class DictCursor: # noqa: D401 - test double
|
|
pass
|
|
|
|
def connect(*args, **kwargs): # noqa: D401 - test double
|
|
raise AssertionError("connect should be monkeypatched in tests")
|
|
|
|
cursors.DictCursor = DictCursor
|
|
pymysql.cursors = cursors
|
|
pymysql.connect = connect
|
|
sys.modules["pymysql"] = pymysql
|
|
sys.modules["pymysql.cursors"] = cursors
|
|
|
|
|
|
def test_timeline_api_returns_backend_computed_nodes(monkeypatch):
|
|
_install_fake_pymysql()
|
|
main = importlib.import_module("state_center.app.main")
|
|
client = TestClient(main.app)
|
|
|
|
class FakeCursor:
|
|
def __init__(self):
|
|
self._result = []
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def execute(self, sql, params=None):
|
|
if "FROM job WHERE job_id" in sql:
|
|
self._result = [{
|
|
"job_id": "JOB-1",
|
|
"source_type": "YYC",
|
|
"current_node_code": "SOURCE_ADD",
|
|
"status": "WAITING_USER",
|
|
"needs_source_add": 1,
|
|
}]
|
|
elif "FROM job_node" in sql:
|
|
self._result = [
|
|
{"node_code": "ORDER_FETCH", "attempt_no": 1, "status": "SUCCESS"},
|
|
{"node_code": "PRODUCT_MATCH", "attempt_no": 1, "status": "SUCCESS"},
|
|
]
|
|
else:
|
|
self._result = []
|
|
|
|
def fetchone(self):
|
|
return self._result[0] if self._result else None
|
|
|
|
def fetchall(self):
|
|
return list(self._result)
|
|
|
|
class FakeConn:
|
|
def cursor(self):
|
|
return FakeCursor()
|
|
|
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
|
|
|
resp = client.get("/api/jobs/JOB-1/timeline")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
by_code = {item["node_code"]: item for item in body["timeline"]}
|
|
assert by_code["ORDER_FETCH"]["display_status"] == "SUCCESS"
|
|
assert by_code["OCR_EXTRACT"]["display_status"] == "SKIPPED"
|
|
assert by_code["SOURCE_ADD"]["display_status"] == "WAITING_USER"
|
|
|
|
|
|
def test_state_machine_api_returns_chinese_labels(monkeypatch):
|
|
_install_fake_pymysql()
|
|
main = importlib.import_module("state_center.app.main")
|
|
client = TestClient(main.app)
|
|
|
|
resp = client.get("/api/state-machine")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["node_labels"]["ORDER_FETCH"] == "订单获取"
|
|
assert body["executor_labels"]["RPA"] == "艺赛旗执行"
|
|
assert body["node_executor"]["PRODUCT_MATCH"] == "STATE_CENTER"
|
|
|
|
|
|
def test_health_reports_database_status(monkeypatch):
|
|
_install_fake_pymysql()
|
|
main = importlib.import_module("state_center.app.main")
|
|
client = TestClient(main.app)
|
|
|
|
class FakeCursor:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def execute(self, sql, params=None):
|
|
assert sql == "SELECT 1"
|
|
|
|
def fetchone(self):
|
|
return {"ok": 1}
|
|
|
|
class FakeConn:
|
|
def cursor(self):
|
|
return FakeCursor()
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
|
|
|
response = client.get("/health")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"ok": True, "database": "ok"}
|
|
|
|
|
|
def test_task_submit_confirms_source_add_and_is_idempotent(monkeypatch):
|
|
_install_fake_pymysql()
|
|
main = importlib.import_module("state_center.app.main")
|
|
client = TestClient(main.app)
|
|
|
|
state = {
|
|
"task": {
|
|
"task_id": "TASK-1",
|
|
"job_id": "JOB-1",
|
|
"node_code": "SOURCE_ADD",
|
|
"role_code": "PURCHASER",
|
|
"status": "OPEN",
|
|
"submit_token": None,
|
|
"submitted_action": None,
|
|
"submitted_payload_json": None,
|
|
"submitted_result_json": None,
|
|
},
|
|
"job": {
|
|
"job_id": "JOB-1",
|
|
"current_node_code": "SOURCE_ADD",
|
|
"status": "WAITING_USER",
|
|
"needs_source_add": 1,
|
|
"current_role": "PURCHASER",
|
|
"source_type": "YYC",
|
|
},
|
|
"job_nodes": [
|
|
{"node_code": "SOURCE_ADD", "attempt_no": 1, "status": "WAITING_USER"},
|
|
],
|
|
}
|
|
|
|
class FakeCursor:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def execute(self, sql, params=None):
|
|
self.last_sql = sql
|
|
self.last_params = params
|
|
if "FROM user_task WHERE task_id=%s" in sql:
|
|
self._result = [state["task"]]
|
|
elif "FROM job WHERE job_id=%s" in sql and "UPDATE" not in sql:
|
|
self._result = [state["job"]]
|
|
elif "submitted_result_json" in sql and "FROM user_task" in sql:
|
|
self._result = [state["task"]]
|
|
elif "SELECT node_code,attempt_no,status" in sql:
|
|
self._result = list(state["job_nodes"])
|
|
elif sql.startswith("UPDATE user_task SET status='SUBMITTED'"):
|
|
state["task"]["status"] = "SUBMITTED"
|
|
state["task"]["submit_token"] = params[0]
|
|
state["task"]["submitted_action"] = params[1]
|
|
state["task"]["submitted_payload_json"] = params[2]
|
|
state["task"]["submitted_result_json"] = params[3]
|
|
state["task"]["submitted_by"] = params[4]
|
|
state["task"]["submitted_at"] = params[5]
|
|
elif sql.startswith("UPDATE job SET needs_source_add=0"):
|
|
state["job"]["needs_source_add"] = 0
|
|
elif sql.startswith("UPDATE job SET current_node_code=%s,status='PENDING'"):
|
|
state["job"]["current_node_code"] = params[0]
|
|
state["job"]["status"] = "PENDING"
|
|
elif sql.startswith("UPDATE job_node SET status='SUCCESS'"):
|
|
state["job_nodes"][0]["status"] = "SUCCESS"
|
|
elif sql.startswith("INSERT INTO job_event"):
|
|
pass
|
|
else:
|
|
self._result = []
|
|
|
|
def fetchone(self):
|
|
return self._result[0] if self._result else None
|
|
|
|
def fetchall(self):
|
|
return list(self._result)
|
|
|
|
@property
|
|
def _result(self):
|
|
return getattr(self, "_result_data", [])
|
|
|
|
@_result.setter
|
|
def _result(self, value):
|
|
self._result_data = value
|
|
|
|
class FakeConn:
|
|
def cursor(self):
|
|
return FakeCursor()
|
|
|
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
|
|
|
headers = {"X-Agent-Token": main.settings.AGENT_TOKEN}
|
|
first = client.post(
|
|
"/api/tasks/TASK-1/submit",
|
|
headers=headers,
|
|
json={
|
|
"action": "CONFIRM_AND_CONTINUE",
|
|
"payload": {"sourceAdd": True},
|
|
"submitToken": "token-1",
|
|
"operator": "alice",
|
|
},
|
|
)
|
|
assert first.status_code == 200
|
|
first_body = first.json()
|
|
assert first_body["next_node"] == "CONTRACT_CREATE"
|
|
assert state["task"]["status"] == "SUBMITTED"
|
|
assert state["task"]["submitted_result_json"] is not None
|
|
assert state["job"]["current_node_code"] == "CONTRACT_CREATE"
|
|
assert state["job"]["needs_source_add"] == 0
|
|
|
|
second = client.post(
|
|
"/api/tasks/TASK-1/submit",
|
|
headers=headers,
|
|
json={
|
|
"action": "CONFIRM_AND_CONTINUE",
|
|
"payload": {"sourceAdd": True},
|
|
"submitToken": "token-1",
|
|
"operator": "alice",
|
|
},
|
|
)
|
|
assert second.status_code == 200
|
|
assert second.json()["idempotent"] is True
|
|
|
|
third = client.post(
|
|
"/api/tasks/TASK-1/submit",
|
|
headers=headers,
|
|
json={
|
|
"action": "CONFIRM_AND_CONTINUE",
|
|
"payload": {"sourceAdd": True},
|
|
"submitToken": "token-2",
|
|
"operator": "alice",
|
|
},
|
|
)
|
|
assert third.status_code == 409
|
|
|
|
|
|
def test_task_submit_source_add_creates_missing_node_history(monkeypatch):
|
|
_install_fake_pymysql()
|
|
main = importlib.import_module("state_center.app.main")
|
|
client = TestClient(main.app)
|
|
|
|
state = {
|
|
"task": {
|
|
"task_id": "TASK-1",
|
|
"job_id": "JOB-1",
|
|
"node_code": "SOURCE_ADD",
|
|
"role_code": "PURCHASER",
|
|
"status": "OPEN",
|
|
"submit_token": None,
|
|
"submitted_result_json": None,
|
|
},
|
|
"job": {
|
|
"job_id": "JOB-1",
|
|
"current_node_code": "SOURCE_ADD",
|
|
"status": "WAITING_USER",
|
|
"needs_source_add": 1,
|
|
"current_role": "PURCHASER",
|
|
"source_type": "YYC",
|
|
},
|
|
"job_nodes": [],
|
|
}
|
|
|
|
class FakeCursor:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def execute(self, sql, params=None):
|
|
if "FROM user_task WHERE task_id=%s" in sql:
|
|
self._result = [state["task"]]
|
|
elif "FROM job WHERE job_id=%s" in sql and "UPDATE" not in sql:
|
|
self._result = [state["job"]]
|
|
elif sql.startswith("UPDATE job SET needs_source_add=0"):
|
|
state["job"]["needs_source_add"] = 0
|
|
elif sql.startswith("INSERT INTO job_node"):
|
|
state["job_nodes"].append({
|
|
"node_code": params[1],
|
|
"attempt_no": params[3],
|
|
"status": "SUCCESS",
|
|
"executor_type": params[2],
|
|
})
|
|
elif sql.startswith("UPDATE job SET current_node_code=%s,status='PENDING'"):
|
|
state["job"]["current_node_code"] = params[0]
|
|
state["job"]["status"] = "PENDING"
|
|
elif sql.startswith("UPDATE user_task SET status='SUBMITTED'"):
|
|
state["task"]["status"] = "SUBMITTED"
|
|
state["task"]["submit_token"] = params[0]
|
|
state["task"]["submitted_result_json"] = params[3]
|
|
elif sql.startswith("INSERT INTO job_event"):
|
|
pass
|
|
else:
|
|
self._result = []
|
|
|
|
def fetchone(self):
|
|
return self._result[0] if self._result else None
|
|
|
|
@property
|
|
def _result(self):
|
|
return getattr(self, "_result_data", [])
|
|
|
|
@_result.setter
|
|
def _result(self, value):
|
|
self._result_data = value
|
|
|
|
class FakeConn:
|
|
def cursor(self):
|
|
return FakeCursor()
|
|
|
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
|
|
|
response = client.post(
|
|
"/api/tasks/TASK-1/submit",
|
|
headers={"X-Agent-Token": main.settings.AGENT_TOKEN},
|
|
json={
|
|
"action": "CONFIRM_AND_CONTINUE",
|
|
"payload": {},
|
|
"submitToken": "source-submit-1",
|
|
"operator": "采购张三",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert state["job_nodes"] == [{
|
|
"node_code": "SOURCE_ADD",
|
|
"attempt_no": 1,
|
|
"status": "SUCCESS",
|
|
"executor_type": "HUMAN",
|
|
}]
|
|
|
|
|
|
def test_advance_to_m5_human_node_creates_task():
|
|
_install_fake_pymysql()
|
|
main = importlib.import_module("state_center.app.main")
|
|
|
|
state = {
|
|
"job": {
|
|
"job_id": "JOB-1",
|
|
"supplier_name": "重庆亿昊药业",
|
|
"current_node_code": "M5_CREATE_ORDER",
|
|
"status": "RUNNING",
|
|
},
|
|
"job_nodes": [],
|
|
"tasks": [],
|
|
}
|
|
|
|
class FakeCursor:
|
|
def execute(self, sql, params=None):
|
|
if sql.startswith("INSERT INTO job_node"):
|
|
state["job_nodes"].append({
|
|
"node_code": params[1],
|
|
"executor_type": params[2],
|
|
"attempt_no": params[3],
|
|
"status": params[4],
|
|
})
|
|
elif sql.startswith("DELETE FROM user_task"):
|
|
pass
|
|
elif sql.startswith("UPDATE job SET current_node_code=%s,status='WAITING_USER'"):
|
|
state["job"]["current_node_code"] = params[0]
|
|
state["job"]["status"] = "WAITING_USER"
|
|
state["job"]["current_role"] = params[1]
|
|
elif sql.startswith("INSERT INTO user_task"):
|
|
state["tasks"].append({
|
|
"job_id": params[1],
|
|
"node_code": params[2],
|
|
"role_code": params[3],
|
|
"title": params[4],
|
|
})
|
|
elif sql.startswith("INSERT INTO job_event"):
|
|
pass
|
|
else:
|
|
raise AssertionError(sql)
|
|
|
|
nxt = main._advance(FakeCursor(), state["job"], "M5_CREATE_ORDER")
|
|
|
|
assert nxt == "M5_MANUAL_CONFIRM"
|
|
assert state["job"]["status"] == "WAITING_USER"
|
|
assert state["job"]["current_role"] == "PURCHASER"
|
|
assert state["job_nodes"] == [{
|
|
"node_code": "M5_MANUAL_CONFIRM",
|
|
"executor_type": "HUMAN",
|
|
"attempt_no": 1,
|
|
"status": "WAITING_USER",
|
|
}]
|
|
assert state["tasks"] == [{
|
|
"job_id": "JOB-1",
|
|
"node_code": "M5_MANUAL_CONFIRM",
|
|
"role_code": "PURCHASER",
|
|
"title": "M5人工确认:重庆亿昊药业",
|
|
}]
|
|
|
|
|
|
def test_node_complete_exports_artifacts(monkeypatch):
|
|
_install_fake_pymysql()
|
|
main = importlib.import_module("state_center.app.main")
|
|
client = TestClient(main.app)
|
|
|
|
state = {
|
|
"job": {
|
|
"job_id": "JOB-1",
|
|
"current_node_code": "CONTRACT_CREATE",
|
|
"status": "RUNNING",
|
|
"source_type": "YYC",
|
|
},
|
|
"job_node": {
|
|
"node_code": "CONTRACT_CREATE",
|
|
"attempt_no": 1,
|
|
"status": "RUNNING",
|
|
},
|
|
"artifacts": [],
|
|
}
|
|
|
|
class FakeCursor:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def execute(self, sql, params=None):
|
|
if "FROM job WHERE job_id=%s" in sql and "ORDER BY" not in sql:
|
|
self._result = [state["job"]]
|
|
elif "FROM job_node WHERE job_id=%s" in sql:
|
|
self._result = [state["job_node"]]
|
|
elif "FROM user_task WHERE job_id=%s" in sql:
|
|
self._result = []
|
|
elif "FROM job_artifact WHERE job_id=%s" in sql:
|
|
self._result = list(state["artifacts"])
|
|
elif sql.startswith("UPDATE job_node SET status='SUCCESS'"):
|
|
state["job_node"]["status"] = "SUCCESS"
|
|
state["job_node"]["ended_at"] = params[1]
|
|
if len(params) > 2:
|
|
state["job_node"]["callback_idempotency_key"] = params[2]
|
|
elif sql.startswith("INSERT INTO job_artifact"):
|
|
state["artifacts"].append({
|
|
"job_id": params[0],
|
|
"node_code": params[1],
|
|
"attempt_no": params[2],
|
|
"artifact_type": params[3],
|
|
"storage_path": params[4],
|
|
"file_hash": params[5],
|
|
"visible_roles": params[6],
|
|
"is_masked": params[7],
|
|
})
|
|
elif sql.startswith("INSERT INTO job_event"):
|
|
pass
|
|
else:
|
|
self._result = []
|
|
|
|
def fetchone(self):
|
|
return self._result[0] if self._result else None
|
|
|
|
def fetchall(self):
|
|
if self._result:
|
|
return list(self._result)
|
|
return []
|
|
|
|
@property
|
|
def _result(self):
|
|
return getattr(self, "_result_data", [])
|
|
|
|
@_result.setter
|
|
def _result(self, value):
|
|
self._result_data = value
|
|
|
|
class FakeConn:
|
|
def cursor(self):
|
|
return FakeCursor()
|
|
|
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
|
|
|
headers = {"X-Agent-Token": main.settings.AGENT_TOKEN}
|
|
done = client.post(
|
|
"/api/jobs/JOB-1/nodes/CONTRACT_CREATE/complete",
|
|
headers=headers,
|
|
json={
|
|
"attemptNo": 1,
|
|
"executorRunId": "RUN-1",
|
|
"idempotencyKey": "idem-1",
|
|
"businessRefs": {"contractSystemNo": "HT-001"},
|
|
"summary": {"done": True},
|
|
"artifacts": [
|
|
{
|
|
"artifactType": "CONTRACT_PDF",
|
|
"storagePath": "/data/jobs/JOB-1/contract.pdf",
|
|
"fileHash": "sha256:abc",
|
|
"visibleRoles": ["PURCHASER", "PLANNER"],
|
|
"isMasked": False,
|
|
}
|
|
],
|
|
},
|
|
)
|
|
|
|
assert done.status_code == 200
|
|
|
|
package = client.get("/api/jobs/JOB-1/evidence-package")
|
|
assert package.status_code == 200
|
|
body = package.json()
|
|
assert body["artifacts"][0]["artifact_type"] == "CONTRACT_PDF"
|
|
assert body["artifacts"][0]["storage_path"] == "/data/jobs/JOB-1/contract.pdf"
|
|
|
|
|
|
def test_job_list_api_returns_compact_rows(monkeypatch):
|
|
_install_fake_pymysql()
|
|
main = importlib.import_module("state_center.app.main")
|
|
client = TestClient(main.app)
|
|
|
|
class FakeCursor:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def execute(self, sql, params=None):
|
|
if "FROM job" in sql:
|
|
self._result = [{
|
|
"job_id": "JOB-1",
|
|
"supplier_name": "重庆亿昊药业",
|
|
"order_date": "2026-08-04",
|
|
"source_type": "YYC",
|
|
"status": "RUNNING",
|
|
"current_node_code": "PRODUCT_MATCH",
|
|
"current_role": "PLANNER",
|
|
"created_at": "2026-08-10 10:00:00",
|
|
"updated_at": "2026-08-10 10:05:00",
|
|
"finished_at": None,
|
|
}]
|
|
else:
|
|
self._result = []
|
|
|
|
def fetchall(self):
|
|
return list(self._result)
|
|
|
|
@property
|
|
def _result(self):
|
|
return getattr(self, "_result_data", [])
|
|
|
|
@_result.setter
|
|
def _result(self, value):
|
|
self._result_data = value
|
|
|
|
class FakeConn:
|
|
def cursor(self):
|
|
return FakeCursor()
|
|
|
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
|
|
|
resp = client.get("/api/jobs", params={"status": "RUNNING"})
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["items"][0]["job_id"] == "JOB-1"
|
|
assert body["items"][0]["supplier_name"] == "重庆亿昊药业"
|
|
assert body["items"][0]["current_node_code"] == "PRODUCT_MATCH"
|
|
|
|
|
|
def test_task_list_api_filters_by_role(monkeypatch):
|
|
_install_fake_pymysql()
|
|
main = importlib.import_module("state_center.app.main")
|
|
client = TestClient(main.app)
|
|
|
|
class FakeCursor:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def execute(self, sql, params=None):
|
|
if "FROM user_task" in sql:
|
|
self._result = [{
|
|
"task_id": "TASK-1",
|
|
"job_id": "JOB-1",
|
|
"node_code": "SOURCE_ADD",
|
|
"role_code": "PURCHASER",
|
|
"status": "OPEN",
|
|
"title": "新增货源:重庆亿昊药业",
|
|
"reason_type": "SOURCE_ADD",
|
|
"submitted_action": None,
|
|
"submitted_at": None,
|
|
"created_at": "2026-08-10 10:00:00",
|
|
}]
|
|
else:
|
|
self._result = []
|
|
|
|
def fetchall(self):
|
|
return list(self._result)
|
|
|
|
@property
|
|
def _result(self):
|
|
return getattr(self, "_result_data", [])
|
|
|
|
@_result.setter
|
|
def _result(self, value):
|
|
self._result_data = value
|
|
|
|
class FakeConn:
|
|
def cursor(self):
|
|
return FakeCursor()
|
|
|
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
|
|
|
resp = client.get("/api/tasks/my", params={"roleCode": "PURCHASER", "status": "open"})
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["items"][0]["task_id"] == "TASK-1"
|
|
assert body["items"][0]["role_code"] == "PURCHASER"
|
|
|
|
|
|
def test_ingest_order_items_before_match(monkeypatch):
|
|
_install_fake_pymysql()
|
|
main = importlib.import_module("state_center.app.main")
|
|
client = TestClient(main.app)
|
|
executed = []
|
|
|
|
class FakeCursor:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def execute(self, sql, params=None):
|
|
executed.append((sql, params))
|
|
if "SELECT * FROM job WHERE job_id=%s" in sql:
|
|
self._result = [{
|
|
"job_id": "JOB-1",
|
|
"current_node_code": "ORDER_FETCH",
|
|
"status": "RUNNING",
|
|
}]
|
|
elif "FROM product_match" in sql:
|
|
self._result = [{"c": 0}]
|
|
else:
|
|
self._result = []
|
|
|
|
def fetchone(self):
|
|
return self._result[0] if self._result else None
|
|
|
|
class FakeConn:
|
|
def cursor(self):
|
|
return FakeCursor()
|
|
|
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
|
response = client.post(
|
|
"/api/jobs/JOB-1/items",
|
|
headers={"X-Agent-Token": main.settings.AGENT_TOKEN},
|
|
json={
|
|
"replace": True,
|
|
"rows": [{
|
|
"orderNo": "DD-001",
|
|
"rawProductName": "阿卡波糖片",
|
|
"platformProductCode": "PT-001",
|
|
"spec": "50mg*30片",
|
|
"manufacturer": "示例制药",
|
|
"approvalNo": "国药准字H00000001",
|
|
"qty": 12,
|
|
"unitPrice": 9.8,
|
|
"sourceRowNo": 2,
|
|
}],
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"ok": True, "inserted": 1, "order_nos": ["DD-001"]}
|
|
assert any(sql.startswith("INSERT INTO order_item") for sql, _ in executed)
|
|
assert any("order_no_list_json" in sql for sql, _ in executed)
|
|
|
|
|
|
def test_ingest_order_items_rejects_replace_after_match(monkeypatch):
|
|
_install_fake_pymysql()
|
|
main = importlib.import_module("state_center.app.main")
|
|
client = TestClient(main.app)
|
|
|
|
class FakeCursor:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def execute(self, sql, params=None):
|
|
if "SELECT * FROM job WHERE job_id=%s" in sql:
|
|
self._result = [{"job_id": "JOB-1", "current_node_code": "PRODUCT_MATCH"}]
|
|
elif "FROM product_match" in sql:
|
|
self._result = [{"c": 1}]
|
|
else:
|
|
self._result = []
|
|
|
|
def fetchone(self):
|
|
return self._result[0] if self._result else None
|
|
|
|
class FakeConn:
|
|
def cursor(self):
|
|
return FakeCursor()
|
|
|
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
|
response = client.post(
|
|
"/api/jobs/JOB-1/items",
|
|
headers={"X-Agent-Token": main.settings.AGENT_TOKEN},
|
|
json={"replace": True, "rows": []},
|
|
)
|
|
|
|
assert response.status_code == 409
|
|
assert "对码结果" in response.json()["detail"]
|
|
|
|
|
|
def test_load_fixture_goods_for_local_demo(monkeypatch):
|
|
_install_fake_pymysql()
|
|
main = importlib.import_module("state_center.app.main")
|
|
client = TestClient(main.app)
|
|
|
|
monkeypatch.setattr(main.settings, "DEV_MODE", True, raising=False)
|
|
response = client.post(
|
|
"/api/dev/match/load-fixture-goods",
|
|
headers={"X-Agent-Token": main.settings.AGENT_TOKEN},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["loaded"] >= 3
|
|
assert main._ENGINE_LOADED is True
|
|
|
|
|
|
def test_select_candidate_requires_candidate_payload(monkeypatch):
|
|
_install_fake_pymysql()
|
|
main = importlib.import_module("state_center.app.main")
|
|
client = TestClient(main.app)
|
|
|
|
class FakeCursor:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def execute(self, sql, params=None):
|
|
if "FROM user_task WHERE task_id=%s" in sql:
|
|
self._result = [{
|
|
"task_id": "TASK-1",
|
|
"job_id": "JOB-1",
|
|
"node_code": "PRODUCT_MATCH",
|
|
"role_code": "PLANNER",
|
|
"status": "OPEN",
|
|
"submit_token": None,
|
|
"product_match_id": 7,
|
|
}]
|
|
elif "FROM job WHERE job_id=%s" in sql:
|
|
self._result = [{
|
|
"job_id": "JOB-1",
|
|
"current_node_code": "PRODUCT_MATCH",
|
|
"status": "WAITING_USER",
|
|
}]
|
|
else:
|
|
self._result = []
|
|
|
|
def fetchone(self):
|
|
return self._result[0] if self._result else None
|
|
|
|
class FakeConn:
|
|
def cursor(self):
|
|
return FakeCursor()
|
|
|
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
|
response = client.post(
|
|
"/api/tasks/TASK-1/submit",
|
|
headers={"X-Agent-Token": main.settings.AGENT_TOKEN},
|
|
json={
|
|
"action": "SELECT_CANDIDATE",
|
|
"payload": {},
|
|
"submitToken": "submit-1",
|
|
"operator": "计划员张三",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"] == "选择候选货品时必须提供 productMatchId 和 goodsId"
|
|
|
|
|
|
def test_advance_from_receipt_notice_auto_archives_job():
|
|
_install_fake_pymysql()
|
|
main = importlib.import_module("state_center.app.main")
|
|
|
|
state = {
|
|
"job": {
|
|
"job_id": "JOB-1",
|
|
"supplier_name": "重庆亿昊药业",
|
|
"current_node_code": "M5_RECEIPT_NOTICE",
|
|
"status": "WAITING_USER",
|
|
"finished_at": None,
|
|
},
|
|
"job_nodes": [],
|
|
"events": [],
|
|
}
|
|
|
|
class FakeCursor:
|
|
def execute(self, sql, params=None):
|
|
if sql.startswith("INSERT INTO job_node"):
|
|
state["job_nodes"].append({
|
|
"node_code": params[1],
|
|
"executor_type": params[2],
|
|
"attempt_no": params[3],
|
|
"status": params[4],
|
|
})
|
|
elif sql.startswith("UPDATE job SET status='SUCCESS'"):
|
|
state["job"]["status"] = "SUCCESS"
|
|
state["job"]["finished_at"] = params[0]
|
|
state["job"]["current_node_code"] = "ARCHIVE_JOB"
|
|
elif sql.startswith("INSERT INTO job_event"):
|
|
state["events"].append({"event_type": params[1], "node_code": params[2]})
|
|
else:
|
|
raise AssertionError(sql)
|
|
|
|
nxt = main._advance(FakeCursor(), state["job"], "M5_RECEIPT_NOTICE")
|
|
|
|
assert nxt is None
|
|
assert state["job"]["status"] == "SUCCESS"
|
|
assert state["job"]["current_node_code"] == "ARCHIVE_JOB"
|
|
assert state["job"]["finished_at"] is not None
|
|
assert state["job_nodes"] == [{
|
|
"node_code": "ARCHIVE_JOB",
|
|
"executor_type": "STATE_CENTER",
|
|
"attempt_no": 1,
|
|
"status": "SUCCESS",
|
|
}]
|
|
assert {"event_type": "JOB_FINISHED", "node_code": None} in state["events"]
|
|
|
|
|
|
def test_retry_failed_retryable_node_opens_next_attempt(monkeypatch):
|
|
_install_fake_pymysql()
|
|
main = importlib.import_module("state_center.app.main")
|
|
client = TestClient(main.app)
|
|
|
|
state = {
|
|
"job": {
|
|
"job_id": "JOB-1",
|
|
"current_node_code": "ORDER_FETCH",
|
|
"status": "FAILED_RETRYABLE",
|
|
"source_type": "YYC",
|
|
},
|
|
"attempts": [
|
|
{"attempt_no": 1, "status": "FAILED"},
|
|
],
|
|
"events": [],
|
|
}
|
|
|
|
class FakeCursor:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def execute(self, sql, params=None):
|
|
if "SELECT * FROM job WHERE job_id=%s" in sql:
|
|
self._result = [state["job"]]
|
|
elif "SELECT MAX(attempt_no)" in sql or "SELECT COALESCE(MAX(attempt_no)" in sql:
|
|
self._result = [{"max_attempt": state["attempts"][-1]["attempt_no"]}]
|
|
elif sql.startswith("UPDATE job SET status='PENDING'"):
|
|
state["job"]["status"] = "PENDING"
|
|
elif sql.startswith("INSERT INTO job_event"):
|
|
state["events"].append({"event_type": params[1], "node_code": params[2]})
|
|
else:
|
|
self._result = []
|
|
|
|
def fetchone(self):
|
|
return self._result[0] if self._result else None
|
|
|
|
@property
|
|
def _result(self):
|
|
return getattr(self, "_result_data", [])
|
|
|
|
@_result.setter
|
|
def _result(self, value):
|
|
self._result_data = value
|
|
|
|
class FakeConn:
|
|
def cursor(self):
|
|
return FakeCursor()
|
|
|
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
|
response = client.post(
|
|
"/api/jobs/JOB-1/retry",
|
|
headers={"X-Agent-Token": main.settings.AGENT_TOKEN},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["ok"] is True
|
|
assert body["node_code"] == "ORDER_FETCH"
|
|
assert body["attempt_no"] == 2
|
|
assert state["job"]["status"] == "PENDING"
|
|
assert state["job"]["current_node_code"] == "ORDER_FETCH"
|
|
assert state["attempts"] == [{"attempt_no": 1, "status": "FAILED"}]
|
|
|
|
|
|
def test_contract_approve_timeout_creates_ops_task(monkeypatch):
|
|
_install_fake_pymysql()
|
|
main = importlib.import_module("state_center.app.main")
|
|
client = TestClient(main.app)
|
|
|
|
state = {
|
|
"job": {
|
|
"job_id": "JOB-1",
|
|
"supplier_name": "重庆亿昊药业",
|
|
"current_node_code": "CONTRACT_APPROVE_WAIT",
|
|
"status": "WAITING_EXTERNAL",
|
|
"wait_check_count": main.settings.CONTRACT_WAIT_MAX_CHECKS - 1,
|
|
"source_type": "YYC",
|
|
},
|
|
"job_nodes": [],
|
|
"tasks": [],
|
|
}
|
|
|
|
class FakeCursor:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def execute(self, sql, params=None):
|
|
if "SELECT * FROM job WHERE job_id=%s" in sql:
|
|
self._result = [state["job"]]
|
|
elif sql.startswith("INSERT INTO job_node"):
|
|
state["job_nodes"].append({
|
|
"node_code": params[1],
|
|
"attempt_no": params[2],
|
|
"status": params[3],
|
|
})
|
|
elif sql.startswith("UPDATE job SET status='WAITING_USER'"):
|
|
state["job"]["status"] = "WAITING_USER"
|
|
state["job"]["current_role"] = "OPS"
|
|
state["job"]["wait_check_count"] = params[0]
|
|
elif sql.startswith("DELETE FROM user_task"):
|
|
pass
|
|
elif sql.startswith("INSERT INTO user_task"):
|
|
state["tasks"].append({
|
|
"job_id": params[1],
|
|
"node_code": params[2],
|
|
"role_code": params[3],
|
|
"title": params[4],
|
|
})
|
|
elif sql.startswith("INSERT INTO job_event"):
|
|
pass
|
|
else:
|
|
self._result = []
|
|
|
|
def fetchone(self):
|
|
return self._result[0] if self._result else None
|
|
|
|
@property
|
|
def _result(self):
|
|
return getattr(self, "_result_data", [])
|
|
|
|
@_result.setter
|
|
def _result(self, value):
|
|
self._result_data = value
|
|
|
|
class FakeConn:
|
|
def cursor(self):
|
|
return FakeCursor()
|
|
|
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
|
response = client.post(
|
|
"/api/jobs/JOB-1/nodes/CONTRACT_APPROVE_WAIT/check-result",
|
|
headers={"X-Agent-Token": main.settings.AGENT_TOKEN},
|
|
json={"approved": False, "operator": "agent", "detail": "仍未审批"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["escalated"] is True
|
|
assert body["job_status"] == "WAITING_USER"
|
|
assert state["job"]["current_role"] == "OPS"
|
|
assert state["tasks"] == [{
|
|
"job_id": "JOB-1",
|
|
"node_code": "CONTRACT_APPROVE_WAIT",
|
|
"role_code": "OPS",
|
|
"title": "合同审批超时:重庆亿昊药业",
|
|
}]
|
|
|
|
|
|
def test_contract_approve_timeout_task_confirm_advances_to_rebate(monkeypatch):
|
|
_install_fake_pymysql()
|
|
main = importlib.import_module("state_center.app.main")
|
|
client = TestClient(main.app)
|
|
|
|
state = {
|
|
"task": {
|
|
"task_id": "TASK-OPS-1",
|
|
"job_id": "JOB-1",
|
|
"node_code": "CONTRACT_APPROVE_WAIT",
|
|
"role_code": "OPS",
|
|
"status": "OPEN",
|
|
"submit_token": None,
|
|
"submitted_result_json": None,
|
|
},
|
|
"job": {
|
|
"job_id": "JOB-1",
|
|
"supplier_name": "重庆亿昊药业",
|
|
"current_node_code": "CONTRACT_APPROVE_WAIT",
|
|
"status": "WAITING_USER",
|
|
"current_role": "OPS",
|
|
"source_type": "YYC",
|
|
},
|
|
"job_nodes": [
|
|
{"node_code": "CONTRACT_APPROVE_WAIT", "attempt_no": 8, "status": "FAILED"},
|
|
],
|
|
}
|
|
|
|
class FakeCursor:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def execute(self, sql, params=None):
|
|
if "FROM user_task WHERE task_id=%s" in sql:
|
|
self._result = [state["task"]]
|
|
elif "SELECT * FROM job WHERE job_id=%s" in sql:
|
|
self._result = [state["job"]]
|
|
elif "SELECT attempt_no,status FROM job_node" in sql:
|
|
self._result = [state["job_nodes"][-1]]
|
|
elif sql.startswith("INSERT INTO job_node"):
|
|
state["job_nodes"].append({
|
|
"node_code": params[1],
|
|
"executor_type": params[2],
|
|
"attempt_no": params[3],
|
|
"status": "SUCCESS",
|
|
})
|
|
elif sql.startswith("UPDATE job_node SET status='SUCCESS'"):
|
|
raise AssertionError("timeout confirm must not overwrite check history")
|
|
elif sql.startswith("UPDATE job SET current_node_code=%s,status='PENDING'"):
|
|
state["job"]["current_node_code"] = params[0]
|
|
state["job"]["status"] = "PENDING"
|
|
elif sql.startswith("UPDATE user_task SET status='SUBMITTED'"):
|
|
state["task"]["status"] = "SUBMITTED"
|
|
state["task"]["submit_token"] = params[0]
|
|
state["task"]["submitted_result_json"] = params[3]
|
|
elif sql.startswith("INSERT INTO job_event"):
|
|
pass
|
|
else:
|
|
self._result = []
|
|
|
|
def fetchone(self):
|
|
return self._result[0] if self._result else None
|
|
|
|
@property
|
|
def _result(self):
|
|
return getattr(self, "_result_data", [])
|
|
|
|
@_result.setter
|
|
def _result(self, value):
|
|
self._result_data = value
|
|
|
|
class FakeConn:
|
|
def cursor(self):
|
|
return FakeCursor()
|
|
|
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
|
response = client.post(
|
|
"/api/tasks/TASK-OPS-1/submit",
|
|
headers={"X-Agent-Token": main.settings.AGENT_TOKEN},
|
|
json={
|
|
"action": "CONFIRM_AND_CONTINUE",
|
|
"payload": {},
|
|
"submitToken": "ops-confirm-1",
|
|
"operator": "ops",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["next_node"] == "REBATE_HEADER"
|
|
assert state["job"]["current_node_code"] == "REBATE_HEADER"
|
|
assert state["job_nodes"][-1]["node_code"] == "CONTRACT_APPROVE_WAIT"
|
|
assert state["job_nodes"][-1]["status"] == "SUCCESS"
|
|
assert state["job_nodes"][-1]["attempt_no"] == 9
|
|
assert state["job_nodes"][0]["status"] == "FAILED"
|
|
|
|
|
|
def test_human_node_rejects_rpa_complete(monkeypatch):
|
|
_install_fake_pymysql()
|
|
main = importlib.import_module("state_center.app.main")
|
|
client = TestClient(main.app)
|
|
|
|
class FakeCursor:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def execute(self, sql, params=None):
|
|
if "SELECT * FROM job WHERE job_id=%s" in sql:
|
|
self._result = [{
|
|
"job_id": "JOB-1",
|
|
"current_node_code": "SOURCE_ADD",
|
|
"status": "WAITING_USER",
|
|
"source_type": "YYC",
|
|
}]
|
|
else:
|
|
self._result = []
|
|
|
|
def fetchone(self):
|
|
return self._result[0] if self._result else None
|
|
|
|
@property
|
|
def _result(self):
|
|
return getattr(self, "_result_data", [])
|
|
|
|
@_result.setter
|
|
def _result(self, value):
|
|
self._result_data = value
|
|
|
|
class FakeConn:
|
|
def cursor(self):
|
|
return FakeCursor()
|
|
|
|
monkeypatch.setattr(main, "get_conn", lambda: FakeConn())
|
|
response = client.post(
|
|
"/api/jobs/JOB-1/nodes/SOURCE_ADD/complete",
|
|
headers={"X-Agent-Token": main.settings.AGENT_TOKEN},
|
|
json={"attemptNo": 1, "summary": {"sourceAdded": True}},
|
|
)
|
|
|
|
assert response.status_code == 409
|
|
assert "人工节点" in response.json()["detail"]
|