from __future__ import annotations import os from copy import deepcopy from pathlib import Path import pytest from fastapi.testclient import TestClient from server.aps_domain.drawing_dxf import build_drawing_master_candidates, inspect_dxf from server.gateway.app import create_app from server.state.seed import seed_world from tests.auth_provider import install_test_auth from tests.external_data import external_dir _DEFAULT_SAMPLE = external_dir("RUIYANG_DEMO_DIR", "ruiyang") / "5060102101-001-e(1).dxf" def _sample_dxf() -> Path: try: import ezdxf # noqa: F401 except ImportError as exc: pytest.skip(f"DXF 运行依赖未完整安装:{exc}; 需要 ezdxf 及其 fontTools 依赖") path = Path(os.environ.get("APS_DXF_SAMPLE", str(_DEFAULT_SAMPLE))) if not path.is_file(): pytest.skip(f"真实 DXF 样例不存在:{path};可通过 APS_DXF_SAMPLE 指定") return path def _master_snapshot(world: dict) -> dict: return { key: deepcopy(world.get(key, [])) for key in ("materials", "boms", "bom", "routing", "routings", "operations") } class _WorldStore: def __init__(self, data: dict, tenant_uuid: str, world_key: str = "default") -> None: self.data = data self.world_key = world_key self.tenant_uuid = tenant_uuid def next_id(self, _kind: str) -> int: return len(self.data.get("auditEvents") or []) + 1 def save(self) -> None: pass def _auth_client(monkeypatch: pytest.MonkeyPatch, tenant: str) -> TestClient: install_test_auth(monkeypatch, tenant) monkeypatch.setenv("APS_DRAWING_ALLOWED_ROOTS", str(_sample_dxf().parent)) client = TestClient(create_app()) login = client.post("/api/auth/login", json={"username": "planner", "password": "test"}) assert login.status_code == 200, login.text return client def _synthetic_dxf(path: Path, text: str) -> Path: payload = ( "0\nSECTION\n2\nHEADER\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n" "0\nTEXT\n8\n0\n10\n0.0\n20\n0.0\n40\n2.5\n1\n" f"{text}\n0\nENDSEC\n0\nEOF\n" ) path.write_text(payload, encoding="ascii") return path def _bind_project(monkeypatch: pytest.MonkeyPatch, work_dir: Path) -> None: class FakeProjectStore: def snapshot(self, include_messages: bool = False): return { "projects": [{"id": "p-dxf-batch", "name": "批量验收项目", "workDir": str(work_dir)}], "sessions": [{"id": "s-dxf-batch", "projectId": "p-dxf-batch"}], "files": [], } def active_world_key(self) -> str: return "default" def ensure_session(self, session_id: str, project_id: str | None = None, title: str = ""): return {"id": session_id, "projectId": project_id or "p-dxf-batch", "title": title} def require_active_write(self) -> None: pass monkeypatch.setattr("server.state.projects.get_project_store", lambda: FakeProjectStore()) def test_dxf_candidates_are_review_only_and_do_not_mutate_master_data(): world = seed_world() before = _master_snapshot(world) candidates = build_drawing_master_candidates(inspect_dxf(_sample_dxf())) assert candidates["contractVersion"] == "drawing-master-candidates.v1" assert candidates["status"] == "PENDING_REVIEW" assert candidates["reviewRequired"] is True assert candidates["materials"] assert candidates["materials"][0]["code"] == "5060102101-001" assert candidates["materials"][0]["reviewRequired"] is True assert candidates["bomReferences"] assert all(row["resolutionStatus"] == "UNRESOLVED" for row in candidates["bomReferences"]) assert all(row["quantity"] is None for row in candidates["bomReferences"]) assert candidates["routingOperations"] assert all(row["standardTime"] is None and row["resourceCode"] is None for row in candidates["routingOperations"]) assert _master_snapshot(world) == before, "解析和候选生成阶段不得直接写主数据" def test_dxf_p2_confirmation_is_required_before_master_commit_contract(monkeypatch): import server.gateway.app as gateway_module app = create_app() paths = app.openapi().get("paths", {}) stage_path = "/api/drawings/{drawing_id}/stage" confirm_path = "/api/actions/confirm" if stage_path not in paths or confirm_path not in paths: pytest.skip( "依赖未合并:需要 Drawing stage 接口接入现有 P2 /api/actions/confirm 通道," "并在确认前保持主数据不变、确认后原子提交变更集。" ) store = _WorldStore(seed_world(), "tenant-dxf-master-flow", "personal-1001") monkeypatch.setattr( gateway_module, "_drawing_project_store", lambda project_id, write=False: store, ) monkeypatch.setattr(gateway_module, "get_store", lambda: store) _bind_project(monkeypatch, _sample_dxf().parent) client = _auth_client(monkeypatch, "tenant-dxf-master-flow") inspect_resp = client.post("/api/drawings/inspect", json={"path": str(_sample_dxf())}) assert inspect_resp.status_code == 200, inspect_resp.text parsed = inspect_resp.json()["parsed"] drawing_id = parsed["asset"]["id"] before = _master_snapshot(store.data) stage = client.post(f"/api/drawings/{drawing_id}/stage", json={}) assert stage.status_code == 200, stage.text staged = stage.json() assert staged["status"] in {"staged", "PENDING_REVIEW"} assert staged.get("confirmId"), "候选必须生成 P2 确认卡" assert staged.get("masterCommitted", False) is False assert _master_snapshot(store.data) == before, "P2 暂存阶段不得直接写主数据" confirm = client.post("/api/actions/confirm", json={ "confirmId": staged["confirmId"], "approve": True, }) assert confirm.status_code == 200, confirm.text result = confirm.json() assert "Drawing review applied" in result["message"] world = store.data assert any(row.get("code") == "5060102101-001" for row in world.get("materials") or []) assert any(link.get("drawingId") == drawing_id for link in world.get("drawingLinks") or []) asset = next(row for row in world.get("drawingAssets") or [] if row.get("id") == drawing_id) assert asset.get("status") == "COMMITTED" def test_batch_inspect_keeps_independent_candidate_change_sets(monkeypatch, tmp_path): import server.gateway.app as gateway_module p1 = _synthetic_dxf(tmp_path / "E2E-001-a.dxf", "E2E-DXF-ONE") p2 = _synthetic_dxf(tmp_path / "E2E-002-a.dxf", "E2E-DXF-TWO") _bind_project(monkeypatch, tmp_path) install_test_auth(monkeypatch, "tenant-dxf-batch") store = _WorldStore(seed_world(), "tenant-dxf-batch") monkeypatch.setattr( gateway_module, "_drawing_project_store", lambda project_id, write=False: store, ) client = TestClient(create_app()) login = client.post("/api/auth/login", json={"username": "planner", "password": "test"}) assert login.status_code == 200, login.text resp = client.post("/api/drawings/inspect-batch", json={ "projectId": "p-dxf-batch", "paths": [str(p1), str(p2)], }) assert resp.status_code == 200, resp.text body = resp.json() assert body["count"] == 2 assert body["errors"] == [] assert len({row["id"] for row in body["drawings"]}) == 2 world = store.data changes = world.get("drawingCandidates") or [] assets = world.get("drawingAssets") or [] assert len(changes) == 2 assert len(assets) == 2 assert len({row.get("drawingAssetId") for row in changes}) == 2 assert all(row.get("projectId") == "p-dxf-batch" for row in changes) again = client.post("/api/drawings/inspect-batch", json={ "projectId": "p-dxf-batch", "paths": [str(p1), str(p2)], }) assert again.status_code == 200, again.text world = store.data changes = world.get("drawingCandidates") or [] assets = world.get("drawingAssets") or [] assert len(changes) == 2 assert len(assets) == 2 codes = [ {row.get("code") for row in (change.get("materials") or [])} for change in changes ] assert codes[0] != codes[1]