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