aps-agent/tests/golden/test_masterdata_api.py

427 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 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)