427 lines
18 KiB
Python
427 lines
18 KiB
Python
# ============================================================
|
||
# R71.3 主数据 API 黄金测试(moduleId: golden-masterdata-api, 可重生 ✅)
|
||
# 独立 FastAPI + TestClient 挂载 server.gateway.masterdata_api.router,
|
||
# 不依赖 server.gateway.app,验证:CRUD / 停用隔离 / P2 未确认不落库 /
|
||
# BOM·工艺版本发布与回滚 / 班次日历周模板与节假日排除。
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
from typing import Any, ClassVar
|
||
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
from fastapi.testclient import TestClient
|
||
|
||
from server.agent_core import harness
|
||
from server.agent_core.approval_store import ApprovalStore
|
||
from server.aps_domain.workflow import execute_confirmed
|
||
from server.auth.context import (
|
||
IdentityContext,
|
||
bind_identity,
|
||
get_identity,
|
||
reset_identity,
|
||
)
|
||
from server.gateway.masterdata_api import MasterdataConfirmRequest
|
||
from server.gateway.masterdata_api import router as masterdata_router
|
||
from server.state.checkpoints import CheckpointStore
|
||
|
||
|
||
class _ProjectStore:
|
||
def active_world_key(self) -> str:
|
||
return "default"
|
||
|
||
def require_active_write(self) -> None:
|
||
pass
|
||
|
||
|
||
class _FakeStore:
|
||
_KIND_TO_TABLE: ClassVar[dict[str, str]] = {
|
||
"bom": "boms", "bomItem": "bomItems", "routing": "routings",
|
||
"routingStep": "routingSteps", "workstation": "workstations",
|
||
"equipment": "equipment", "shiftCalendar": "shiftCalendar",
|
||
"calendarTemplate": "calendarTemplates", "calendarHoliday": "calendarHolidays",
|
||
"audit": "auditEvents",
|
||
}
|
||
|
||
def __init__(self, world: dict[str, Any]) -> None:
|
||
self.data = world
|
||
self.path = "memory://masterdata-test"
|
||
self._counters: dict[str, int] = {}
|
||
self._reset_counters()
|
||
|
||
def _reset_counters(self) -> None:
|
||
for kind, table in self._KIND_TO_TABLE.items():
|
||
max_id = 0
|
||
for row in self.data.get(table, []) or []:
|
||
if isinstance(row.get("id"), int):
|
||
max_id = max(max_id, row["id"])
|
||
self._counters[kind] = max_id
|
||
|
||
def next_id(self, kind: str) -> int:
|
||
max_id = self._counters.get(kind, 0) + 1
|
||
self._counters[kind] = max_id
|
||
return max_id
|
||
|
||
def save(self) -> None:
|
||
pass
|
||
|
||
|
||
def _world_with_master() -> dict[str, Any]:
|
||
return {
|
||
"factories": [{"id": 1, "code": "F001", "name": "测试工厂", "status": "ACTIVE"}],
|
||
"workshops": [{"id": 1, "factoryId": 1, "code": "WS01", "name": "一车间", "status": "ACTIVE"}],
|
||
"lines": [{"id": 1, "workshopId": 1, "code": "L001", "name": "装配线A",
|
||
"capacityPerDay": 1000, "status": "ACTIVE"}],
|
||
"workstations": [{"id": 1, "lineId": 1, "code": "WS001", "name": "SMT-01",
|
||
"sequenceNo": 1, "status": "ACTIVE"}],
|
||
"equipment": [{"id": 1, "workstationId": 1, "code": "EQ001", "name": "SMT-01设备",
|
||
"model": "MDL-1", "status": "RUNNING"}],
|
||
"workstationOperations": [], "lineProducts": [],
|
||
"materials": [
|
||
{"id": 1, "code": "FIN-001", "name": "成品A", "type": "FINISHED_PRODUCT",
|
||
"unit": "件", "status": "ACTIVE"},
|
||
{"id": 2, "code": "RM-001", "name": "原材料A", "type": "RAW_MATERIAL",
|
||
"unit": "件", "status": "ACTIVE"},
|
||
],
|
||
"boms": [{"id": 1, "productId": 1, "version": "V1.0", "versionName": "V1.0",
|
||
"isDefault": True, "status": "ACTIVE"}],
|
||
"bomItems": [{"id": 1, "bomId": 1, "materialId": 2, "quantity": 2,
|
||
"operationId": None, "isKeyMaterial": True}],
|
||
"routings": [{"id": 1, "productId": 1, "version": "R1.0", "versionName": "R1.0",
|
||
"isDefault": True, "status": "ACTIVE"}],
|
||
"routingSteps": [{"id": 1, "routingId": 1, "operationId": 1, "sequenceNo": 1,
|
||
"setupTime": 10, "runTimePerUnit": 0.5, "waitTime": 0,
|
||
"transferTime": 0, "isExternal": False}],
|
||
"operations": [{"id": 1, "code": "OP10", "name": "贴片", "type": "INTERNAL",
|
||
"standardTime": 0.5}],
|
||
"shifts": [
|
||
{"id": 1, "code": "D", "name": "早班", "startTime": "08:00", "endTime": "16:00",
|
||
"breakPeriods": [], "status": "ACTIVE"},
|
||
{"id": 2, "code": "N", "name": "中班", "startTime": "16:00", "endTime": "23:59",
|
||
"breakPeriods": [], "status": "ACTIVE"},
|
||
],
|
||
"shiftCalendar": [], "maintenance": [], "teams": [],
|
||
"calendarTemplates": [], "calendarHolidays": [], "masterdataVersions": [],
|
||
"salesOrders": [], "productionOrders": [], "workOrders": [],
|
||
"purchaseOrders": [], "outsourceOrders": [], "forecastOrders": [],
|
||
"changeoverMatrix": [], "scheduleVersions": [], "conflicts": [],
|
||
"logs": [], "auditEvents": [], "scheduleParams": {}, "constraintProfile": {},
|
||
"flexZones": [], "flexOperations": [], "flexEquipment": [], "flexMolds": [],
|
||
"flexMaterials": [], "flexRoutings": [], "flexBom": [], "flexOrders": [],
|
||
"flexTeams": [], "flexCalendar": [], "flexParams": {}, "flexScheduleVersions": [],
|
||
"flexVirtualLines": [], "flexWorkOrders": [], "flexConflicts": [],
|
||
}
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _isolated_approval_store(tmp_path):
|
||
"""用临时 approval store 隔离测试,绝不改写 server/data/approvals.json。"""
|
||
original = harness._approval_store
|
||
harness.configure_approval_store(store=ApprovalStore(str(tmp_path / "approvals.json")))
|
||
yield
|
||
harness.configure_approval_store(store=original)
|
||
|
||
|
||
@pytest.fixture
|
||
def client_and_store(monkeypatch, tmp_path):
|
||
import server.aps_domain.workflow as workflow_module
|
||
import server.gateway.masterdata_api as api_module
|
||
import server.state.projects as projects_module
|
||
|
||
store = _FakeStore(_world_with_master())
|
||
checkpoints = CheckpointStore(str(tmp_path / "masterdata-checkpoints.json"))
|
||
store.checkpoints = checkpoints
|
||
monkeypatch.setattr(api_module, "get_store", lambda: store)
|
||
monkeypatch.setattr(api_module, "get_checkpoints", lambda: checkpoints)
|
||
monkeypatch.setattr(workflow_module, "get_checkpoints", lambda: checkpoints)
|
||
monkeypatch.setattr(projects_module, "get_project_store", lambda: _ProjectStore())
|
||
app = FastAPI(title="masterdata-router-test")
|
||
app.include_router(masterdata_router)
|
||
|
||
@app.post("/api/actions/confirm")
|
||
async def confirm(req: MasterdataConfirmRequest) -> dict[str, Any]:
|
||
identity = get_identity(required=True)
|
||
existed = harness.is_confirmation_pending(req.confirmId)
|
||
message = execute_confirmed(
|
||
store,
|
||
req.confirmId,
|
||
req.approve,
|
||
actor=identity.username,
|
||
note=req.note,
|
||
)
|
||
second = req.approve and harness.is_confirmation_pending(req.confirmId)
|
||
return {
|
||
"message": message,
|
||
"refresh": req.approve and existed and not second,
|
||
"secondConfirmRequired": second,
|
||
}
|
||
|
||
client = TestClient(app)
|
||
return client, store
|
||
|
||
|
||
@pytest.fixture
|
||
def identity() -> IdentityContext:
|
||
return IdentityContext(
|
||
user_id=1001, username="planner", fullname="计划员",
|
||
tenant_uuid="tenant-masterdata", roles=("planner",), auth_kind="test",
|
||
)
|
||
|
||
|
||
def _as_identity(identity: IdentityContext, callback):
|
||
token = bind_identity(identity)
|
||
try:
|
||
return callback()
|
||
finally:
|
||
reset_identity(token)
|
||
|
||
|
||
def _stage(client, identity: IdentityContext, action: str, payload: dict[str, Any]) -> str:
|
||
def call():
|
||
resp = client.post("/api/masterdata/stage",
|
||
json={"sessionId": "r71.3", "action": action, "payload": payload})
|
||
assert resp.status_code == 200, resp.text
|
||
return str(resp.json()["confirmId"])
|
||
return _as_identity(identity, call)
|
||
|
||
|
||
def _confirm(client, identity: IdentityContext, confirm_id: str, approve: bool = True) -> dict[str, Any]:
|
||
def call():
|
||
resp = client.post("/api/actions/confirm",
|
||
json={"sessionId": "r71.3", "confirmId": confirm_id, "approve": approve})
|
||
assert resp.status_code == 200, resp.text
|
||
return resp.json()
|
||
return _as_identity(identity, call)
|
||
|
||
|
||
def test_workstation_equipment_crud_and_disabled_isolated_from_active(client_and_store, identity):
|
||
client, store = client_and_store
|
||
world = store.data
|
||
|
||
# 新建工位(P2 未确认前不落库)
|
||
cid = _stage(client, identity, "master.workstation.upsert", {
|
||
"lineId": 1, "code": "WS099", "name": "新工位", "sequenceNo": 9,
|
||
})
|
||
assert [w["code"] for w in world["workstations"]] == ["WS001"]
|
||
result = _confirm(client, identity, cid)
|
||
assert result["refresh"] is True
|
||
ws = next(w for w in world["workstations"] if w["code"] == "WS099")
|
||
assert ws["status"] == "ACTIVE"
|
||
|
||
# 新建设备并挂到新工位
|
||
cid = _stage(client, identity, "master.equipment.upsert", {
|
||
"workstationId": ws["id"], "code": "EQ099", "name": "新设备", "status": "ACTIVE",
|
||
})
|
||
_confirm(client, identity, cid)
|
||
eq = next(e for e in world["equipment"] if e["code"] == "EQ099")
|
||
assert eq["status"] == "ACTIVE"
|
||
|
||
# 编辑设备为停用:资源视图仍可见,但 active 子集排除
|
||
cid = _stage(client, identity, "master.equipment.upsert", {
|
||
"id": eq["id"], "status": "DISABLED", "code": "EQ099", "name": "新设备",
|
||
})
|
||
_confirm(client, identity, cid)
|
||
assert next(e for e in world["equipment"] if e["id"] == eq["id"])["status"] == "DISABLED"
|
||
|
||
active = client.get("/api/masterdata/resources/active").json()
|
||
assert all(e["id"] != eq["id"] for e in active["equipment"])
|
||
assert any(e["id"] == eq["id"] for e in client.get("/api/masterdata/resources").json()["equipment"])
|
||
|
||
# 停用工位后同样从 active 子集消失
|
||
cid = _stage(client, identity, "master.workstation.upsert", {
|
||
"id": ws["id"], "status": "INACTIVE", "code": "WS099", "name": "新工位",
|
||
})
|
||
_confirm(client, identity, cid)
|
||
active = client.get("/api/masterdata/resources/active").json()
|
||
assert all(w["id"] != ws["id"] for w in active["workstations"])
|
||
_assert_audited_rollback_anchor(store, "master.workstation.upsert")
|
||
_assert_audited_rollback_anchor(store, "master.equipment.upsert")
|
||
|
||
|
||
def test_p2_reject_does_not_write_and_pending_is_visible(client_and_store, identity):
|
||
client, store = client_and_store
|
||
world = store.data
|
||
|
||
cid = _stage(client, identity, "master.workstation.upsert", {
|
||
"lineId": 1, "code": "WS-REJ", "name": "应驳回工位",
|
||
})
|
||
pending = _as_identity(identity, lambda: client.get("/api/masterdata/pending").json()["pending"])
|
||
assert any(c["confirmId"] == cid for c in pending)
|
||
result = _confirm(client, identity, cid, approve=False)
|
||
assert "已驳回" in result["message"]
|
||
assert all(w["code"] != "WS-REJ" for w in world["workstations"])
|
||
assert client.get("/api/masterdata/pending").json()["pending"] == []
|
||
|
||
|
||
def test_bom_release_edit_and_rollback_restores_snapshot(client_and_store, identity):
|
||
client, store = client_and_store
|
||
world = store.data
|
||
|
||
cid = _stage(client, identity, "master.bom.release", {"bomId": 1})
|
||
_confirm(client, identity, cid)
|
||
versions = client.get("/api/masterdata/versions").json()["versions"]
|
||
assert any(v["kind"] == "BOM" and v["version"] == "V1.0" and v["status"] == "RELEASED"
|
||
for v in versions)
|
||
|
||
# 修改默认 BOM 用量后回滚到 V1.0 快照
|
||
apply = _apply_direct(world, "master.bom.upsert", {"itemId": 1, "quantity": 5})
|
||
assert apply["id"] == 1
|
||
assert next(i for i in world["bomItems"] if i["id"] == 1)["quantity"] == 5
|
||
|
||
cid = _stage(client, identity, "master.bom.rollback", {"productId": 1, "version": "V1.0"})
|
||
_confirm(client, identity, cid)
|
||
active_bom = next(b for b in world["boms"] if b["isDefault"] and b["status"] == "ACTIVE")
|
||
restored = [i for i in world["bomItems"] if i["bomId"] == active_bom["id"]]
|
||
assert restored and restored[0]["quantity"] == 2
|
||
rollback_rows = [v for v in world["masterdataVersions"]
|
||
if v["kind"] == "BOM" and v["status"] == "ROLLED_BACK"]
|
||
assert rollback_rows and rollback_rows[0]["version"] == "V1.0"
|
||
_assert_audited_rollback_anchor(store, "master.bom.release")
|
||
_assert_audited_rollback_anchor(store, "master.bom.rollback")
|
||
|
||
|
||
def test_routing_release_edit_and_rollback_restores_snapshot(client_and_store, identity):
|
||
client, store = client_and_store
|
||
world = store.data
|
||
|
||
cid = _stage(client, identity, "master.routing.release", {"routingId": 1})
|
||
_confirm(client, identity, cid)
|
||
versions = client.get("/api/masterdata/versions").json()["versions"]
|
||
assert any(v["kind"] == "ROUTING" and v["version"] == "R1.0" and v["status"] == "RELEASED"
|
||
for v in versions)
|
||
|
||
step = next(s for s in world["routingSteps"] if s["id"] == 1)
|
||
step["runTimePerUnit"] = 9.9
|
||
|
||
cid = _stage(client, identity, "master.routing.rollback", {"productId": 1, "version": "R1.0"})
|
||
_confirm(client, identity, cid)
|
||
active_routing = next(r for r in world["routings"] if r["isDefault"] and r["status"] == "ACTIVE")
|
||
restored = [s for s in world["routingSteps"] if s["routingId"] == active_routing["id"]]
|
||
assert restored and restored[0]["runTimePerUnit"] == 0.5
|
||
_assert_audited_rollback_anchor(store, "master.routing.release")
|
||
_assert_audited_rollback_anchor(store, "master.routing.rollback")
|
||
|
||
|
||
def test_calendar_week_template_copy_multishift_holiday_exclusion(client_and_store, identity):
|
||
client, store = client_and_store
|
||
world = store.data
|
||
|
||
resp = _as_identity(identity, lambda: client.post(
|
||
"/api/masterdata/calendar/holidays",
|
||
json={"sessionId": "r71.3", "date": "2026-08-12", "name": "法定假日"},
|
||
))
|
||
assert resp.status_code == 200, resp.text
|
||
assert world["calendarHolidays"] == []
|
||
_confirm(client, identity, str(resp.json()["confirmId"]))
|
||
|
||
# 周一~周五早/中双班模板
|
||
cid = _stage(client, identity, "master.calendar.template.create", {
|
||
"name": "标准双班周", "lineId": 1,
|
||
"shifts": [
|
||
{"shiftId": 1, "workdays": [0, 1, 2, 3, 4]},
|
||
{"shiftId": 2, "workdays": [0, 1, 2, 3, 4]},
|
||
],
|
||
"holidayIds": [world["calendarHolidays"][0]["id"]],
|
||
})
|
||
_confirm(client, identity, cid)
|
||
template_id = world["calendarTemplates"][0]["id"]
|
||
|
||
cid = _stage(client, identity, "master.calendar.week.copy", {
|
||
"templateId": template_id, "startDate": "2026-08-10", "endDate": "2026-08-23",
|
||
})
|
||
result = _confirm(client, identity, cid)
|
||
assert result["refresh"] is True
|
||
|
||
rows = world["shiftCalendar"]
|
||
assert any(r["date"] == "2026-08-10" and r["shiftId"] == 1 for r in rows)
|
||
assert all(r["date"] != "2026-08-12" for r in rows) # 节假日排除
|
||
assert all(r["date"] not in ("2026-08-15", "2026-08-16", "2026-08-22", "2026-08-23") for r in rows)
|
||
assert len({(r["date"], r["shiftId"]) for r in rows if r["date"].startswith("2026-08")}) == 18
|
||
|
||
|
||
def _assert_audited_rollback_anchor(store: _FakeStore, action: str) -> None:
|
||
event = next(
|
||
row for row in reversed(store.data["auditEvents"])
|
||
if row.get("action") == action and row.get("category") == "WORLD_WRITE"
|
||
)
|
||
assert event["power"] == "P2"
|
||
assert event["beforeSnapshot"]
|
||
assert store.checkpoints.get(event["beforeSnapshot"]) is not None
|
||
assert any(str(ref).startswith("masterdata-world:") for ref in event["evidenceRefs"])
|
||
|
||
|
||
def test_holiday_reject_and_legacy_confirm_redirect(client_and_store, identity):
|
||
client, store = client_and_store
|
||
cid = _stage(client, identity, "master.calendar.holiday.upsert", {
|
||
"date": "2026-10-01", "name": "国庆节",
|
||
})
|
||
assert store.data["calendarHolidays"] == []
|
||
result = _confirm(client, identity, cid, approve=False)
|
||
assert "已驳回" in result["message"]
|
||
assert store.data["calendarHolidays"] == []
|
||
|
||
legacy = _as_identity(identity, lambda: client.post(
|
||
"/api/masterdata/confirm",
|
||
json={"sessionId": "r71.3", "confirmId": "legacy", "approve": True},
|
||
follow_redirects=False,
|
||
))
|
||
assert legacy.status_code == 307
|
||
assert legacy.headers["location"] == "/api/actions/confirm"
|
||
|
||
|
||
def test_holiday_approve_writes_once_with_audit_anchor(client_and_store, identity):
|
||
client, store = client_and_store
|
||
cid = _stage(client, identity, "master.calendar.holiday.upsert", {
|
||
"date": "2026-10-02", "name": "国庆假期", "note": "工厂停工",
|
||
})
|
||
assert store.data["calendarHolidays"] == []
|
||
result = _confirm(client, identity, cid)
|
||
assert result["refresh"] is True
|
||
assert store.data["calendarHolidays"] == [{
|
||
"id": 1, "date": "2026-10-02", "name": "国庆假期", "note": "工厂停工",
|
||
}]
|
||
_assert_audited_rollback_anchor(store, "master.calendar.holiday.upsert")
|
||
|
||
|
||
def test_missing_staged_checkpoint_rejects_masterdata_write(client_and_store, identity):
|
||
client, store = client_and_store
|
||
cid = _stage(client, identity, "master.workstation.upsert", {
|
||
"lineId": 1, "code": "WS-NO-EVIDENCE", "name": "缺证据工位",
|
||
})
|
||
pending = _as_identity(
|
||
identity,
|
||
lambda: next(row for row in harness.list_pending() if row["confirmId"] == cid),
|
||
)
|
||
assert store.checkpoints.delete(str(pending["beforeSnapshot"])) is True
|
||
|
||
result = _confirm(client, identity, cid)
|
||
assert "前置快照不存在" in result["message"]
|
||
assert all(row["code"] != "WS-NO-EVIDENCE" for row in store.data["workstations"])
|
||
|
||
|
||
def test_masterdata_drift_is_revalidated_before_apply(client_and_store, identity):
|
||
client, store = client_and_store
|
||
cid = _stage(client, identity, "master.workstation.upsert", {
|
||
"lineId": 1, "code": "WS-DRIFT", "name": "待确认工位",
|
||
})
|
||
store.data["workstations"].append({
|
||
"id": 99, "lineId": 1, "code": "WS-DRIFT", "name": "并发写入工位",
|
||
"sequenceNo": 99, "status": "ACTIVE",
|
||
})
|
||
|
||
result = _confirm(client, identity, cid)
|
||
assert "输入世界已漂移" in result["message"]
|
||
assert [row["id"] for row in store.data["workstations"] if row["code"] == "WS-DRIFT"] == [99]
|
||
|
||
|
||
def _apply_direct(world: dict[str, Any], action: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||
from server.aps_domain.masterdata import apply_master_action
|
||
|
||
counter: dict[str, int] = {}
|
||
|
||
def next_id(kind: str) -> int:
|
||
counter[kind] = counter.get(kind, 0) + 1
|
||
return counter[kind]
|
||
|
||
return apply_master_action(world, next_id, action, payload)
|