diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index b6cb11762..31b2b8965 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -90,6 +90,11 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( WATERSHED_LAYOUT, plan_kind_items, ) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Structure import ( + STRUCTURE_PREFIX, + read_structure_drawing, + structure_items, +) from B07_DesignDetail.B07_DesignDetail_Schema import ( DesignDrawingItem, ) @@ -242,7 +247,8 @@ def _drawing_list(project_root: Path, longitudinal_path: Path) -> list[DesignDra confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), ) ) - # 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — 사용자 확정 ③, PLAN 3장 ④. + # 구조물 도면(376) — 맨 뒤에 덧붙임(앞 도면번호 안 밀림) · 조합마다 한 장 · 더한장은 장마다. + drawings.extend(structure_items(project_root, manifest_drawings)) _store_drawing_numbers(project_root, drawings) return drawings @@ -480,6 +486,8 @@ def _read_drawing( kind = drawing_id # id와 kind가 같은 단장 도면 elif PLAN_KIND_ID.fullmatch(drawing_id): kind = "plan" + elif drawing_id.startswith(STRUCTURE_PREFIX): + kind = "structure" else: kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross" label = str(manifest_entry.get("label") or drawing_id) @@ -557,6 +565,9 @@ def _read_drawing( ) return "plan", label, drawing, False, None + if drawing_id.startswith(STRUCTURE_PREFIX): + return read_structure_drawing(project_root, drawing_id) + if _LONG_ID.fullmatch(drawing_id): source = _read_json(longitudinal_path) chunk = next( diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Structure.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Structure.py new file mode 100644 index 000000000..df1146ca7 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Structure.py @@ -0,0 +1,104 @@ +"""B07 구조물 도면(376 · 요구 241-1) — B05 · B06 배치에 연결된 구조물 조합 도면 목록 · 단건. + +조합 키 = 항목 사본 폴더 이름(`snapshot_folder_name`) — 같은 조합을 여러 배치가 써도 한 장 · +더한장은 장마다 · id `structure_{키}[_{장id}]` · 차례 = 도번 · 도면 = 프로젝트 사본 `도면.json` + +얹는 도각(M02 `structure_frame` · 도번 · 도면명). 고치기는 M02 · B08 — 여기는 읽기만. +""" + +from __future__ import annotations + +import logging +import re +from pathlib import Path +from typing import Any + +from B07_DesignDetail.B07_DesignDetail_Schema import DesignDrawingItem +from common_util.common_util_summary_item import snapshot_folder_name +from M02_MasterTemplete import M02_Item_Snapshot as snapshot +from M02_MasterTemplete.M02_Item_Source import read_json + +logger = logging.getLogger(__name__) + +STRUCTURE_PREFIX = "structure_" +_SHEET_ID = re.compile(r"^[A-Za-z0-9-]{1,40}$") + + +def _sheet_number(number: str, index: int) -> str: + """장 도번 — 1장 = 도번 · n 째 장 = 도번-n(M02 `sheetNumber` · B09 인쇄와 같음).""" + return "" if not number else number if index == 0 else f"{number}-{index + 1}" + + +def structure_sheets(project_root: Path) -> list[dict[str, Any]]: + """구조물 조합 도면 장 `[{id, label, number, drawing}]` — 도번 차례(도번 없음 = 뒤).""" + docs: dict[str, tuple[str, dict[str, Any]]] = {} + for copy in snapshot.linked_snapshots(project_root).values(): + item = copy.get("항목") or {} + if "도면" not in copy["부분"] or not item.get("key"): + continue + key = snapshot_folder_name(item) + if key in docs: + continue + doc = read_json(snapshot.structures_dir(project_root) / copy["폴더"] / "도면.json") + if isinstance(doc, dict) and isinstance(doc.get("도면"), dict): + docs[key] = (str(item.get("name") or key), doc) + sheets: list[dict[str, Any]] = [] + order = sorted(docs, key=lambda k: (not docs[k][1].get("도번"), str(docs[k][1].get("도번")), k)) + for key in order: + name, doc = docs[key] + number = str(doc.get("도번") or "") + pages = [{"장id": "", "이름": "", "도면": doc["도면"]}, *(doc.get("더한장") or [])] + for index, page in enumerate(pages): + if not isinstance(page, dict) or not isinstance(page.get("도면"), dict): + continue + sheet_id = str(page.get("장id") or "") + suffix = "" if index == 0 else f"_{sheet_id if _SHEET_ID.match(sheet_id) else index}" + sheets.append( + { + "id": f"{STRUCTURE_PREFIX}{key}{suffix}", + "label": f"{name} · {page['이름']}" if page.get("이름") else name, + "number": _sheet_number(number, index), + "drawing": page["도면"], + } + ) + return sheets + + +def structure_items( + project_root: Path, manifest_drawings: dict[str, Any] +) -> list[DesignDrawingItem]: + """좌측 「구조물 도면」 단추 — 자료가 깨졌으면 빈 목록(나머지 도면은 남김).""" + try: + sheets = structure_sheets(project_root) + except (OSError, ValueError, KeyError) as exc: + logger.warning("B07 구조물 도면 목록 실패 — 빼고 싣는다: %s", exc) + return [] + return [ + DesignDrawingItem( + id=sheet["id"], + kind="structure", + label=sheet["label"], + confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")), + ) + for sheet in sheets + ] + + +def read_structure_drawing( + project_root: Path, drawing_id: str +) -> tuple[str, str, dict[str, Any], bool, None]: + """구조물 도면 한 장 + 도각 — `_read_drawing` 과 같은 (kind, label, drawing, confirmed, 표).""" + # M02 라우터가 B07 라우터를 부름 — 여기서 늦게 불러 순환을 피함(B09 인쇄와 같음) + from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import structure_frame + + sheet = next((s for s in structure_sheets(project_root) if s["id"] == drawing_id), None) + if sheet is None: + raise FileNotFoundError("요청한 구조물 도면을 찾을 수 없습니다.") + drawing = sheet["drawing"] + frame = structure_frame(sheet["number"] or None, sheet["label"]) + layers = [*(drawing.get("layers") or []), *([frame["layer"]] if frame["layer"] else [])] + framed = { + **drawing, + "entities": [*frame["entities"], *(drawing.get("entities") or [])], + "layers": layers, + } + return "structure", sheet["label"], framed, False, None diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index 265cf5f6d..353b2a7af 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -20,7 +20,8 @@ class DesignDrawingItem(BaseModel): "landuse", "plan_lidar", "cross_standard", - # 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — kind 도 뺌. + # 구조물 도면(376) — B05 · B06 배치에 연결된 구조물 조합 도면. + "structure", "blank", ] label: str @@ -61,7 +62,8 @@ class DesignDrawingResponse(BaseModel): "landuse", "plan_lidar", "cross_standard", - # 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — kind 도 뺌. + # 구조물 도면(376) — B05 · B06 배치에 연결된 구조물 조합 도면. + "structure", "blank", ] label: str diff --git a/B09_Estimation/draft/B09_Draft_Print_Drawings_Source.py b/B09_Estimation/draft/B09_Draft_Print_Drawings_Source.py index 20beef2f6..b3bc11872 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Drawings_Source.py +++ b/B09_Estimation/draft/B09_Draft_Print_Drawings_Source.py @@ -192,6 +192,7 @@ async def drawing_items(project_id: UUID) -> tuple[list[dict[str, Any]], dict[st "images_skipped": None, # PDF 만들 때 셈 — 응답 머리 X-Aislo-Skipped-Images } for number, item in enumerate(b07, start=1) + if item["kind"] != "structure" # 구조물 도면(376)은 아래 도번 차례로 따로 ] sheets = await asyncio.to_thread(structure_sheets, root) items += [ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Drawing.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Drawing.py index c296b57a6..5bcced88b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Drawing.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Drawing.py @@ -105,10 +105,13 @@ def _on_frame_layer(entity: dict) -> dict: } -def structure_frame(number: str | None = None) -> dict: +def structure_frame(number: str | None = None, name: str | None = None) -> dict: """구조물 도면에 얹는 도각 — B07 시스템 양식 1:1 · 외곽 좌하단 (0,0) · 보여주기 층 · - `number` = 장 도번(82-2 · 도각 `{{도면번호}}`).""" - fields = {"도면번호": number} if number else None + `number` = 장 도번(82-2 · 도각 `{{도면번호}}`) · `name` = 도면명(376 · B07 구조물 도면).""" + fields = { + **({"도면번호": number} if number else {}), + **({"도면명": name} if name else {}), + } or None placed = frame_entities("m02-structure", usable_bbox(), fit=False) box = entities_bbox(placed) if not box: diff --git a/resources/tester/test_376_b07_structure_drawings.py b/resources/tester/test_376_b07_structure_drawings.py new file mode 100644 index 000000000..081d8a467 --- /dev/null +++ b/resources/tester/test_376_b07_structure_drawings.py @@ -0,0 +1,145 @@ +"""PLAN 376-1 B07 구조물 도면 — 조합마다 한 장 · 더한장 장마다 · 맨 뒤(도면번호 안 밀림) · +도각(도번 · 도면명) · 확정본 kind. + +임시 폴더 견본만 씀 — 연결된 사본(`linked_snapshots`) · 사본 폴더는 바꿔 끼움. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from B07_DesignDetail import B07_DesignDetail_Router_Support as support +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import DRAWING_FORMAT +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + use_project_templates, + use_title_fields, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _design_root, _write_manifest + +ITEM_A = {"layer": "system", "owner": None, "key": "SS000002", "name": "관보호공 조합"} +ITEM_B = {"layer": "company", "owner": "3", "key": "SC3-000001", "name": "BOX 조합"} +KEY_A = "system_none_SS000002" +KEY_B = "company_3_SC3-000001" + + +def _sheet(tag: str) -> dict: + text = { + "id": f"t-{tag}", + "type": "Text", + "lineColor": "#ffffff", + "lineWidth": 1, + "layerId": "기본", + "shapeData": {"basePoint": {"x": 300, "y": 300}, "label": tag, "options": {}}, + } + return {"format": "openwebcad", "entities": [text], "layers": [{"id": "기본"}]} + + +@pytest.fixture +def project(tmp_path: Path, monkeypatch) -> Path: + """조합 둘 — A 를 배치 둘이 씀(관 · 구조물) · A 에 더한장 하나 · B 는 도번 앞섬.""" + from M02_MasterTemplete import M02_Item_Snapshot as snapshot + + base = tmp_path / "structures" + docs = { + "pipe@10__grv6lny": { + "도번": "SD-000002", + "도면": _sheet("A1"), + "더한장": [{"장id": "s9x", "이름": "상세", "도면": _sheet("A2")}], + }, + "box1": {"도번": "SD-000002", "도면": _sheet("A-다른배치")}, + "box2": {"도번": "SD-000001", "도면": _sheet("B1")}, + } + items = {"pipe@10__grv6lny": ITEM_A, "box1": ITEM_A, "box2": ITEM_B} + linked = {} + for owner, doc in docs.items(): + folder = owner.replace("@", "_") + (base / folder).mkdir(parents=True) + (base / folder / "도면.json").write_text(json.dumps(doc, ensure_ascii=False), "utf-8") + linked[owner] = {"항목": items[owner], "폴더": folder, "부분": ["도면"], "경로": []} + linked["noshape"] = {"항목": ITEM_B, "폴더": "none", "부분": ["면적"], "경로": []} + monkeypatch.setattr(snapshot, "linked_snapshots", lambda root: linked) + monkeypatch.setattr(snapshot, "structures_dir", lambda root: base) + + # 노선 자료 쪽은 고정 — 종단 한 장 · 표지 등 단장만 + monkeypatch.setattr( + support, "longitudinal_chunks", lambda _l: [{"id": "longitudinal", "label": "종단도"}] + ) + monkeypatch.setattr(support, "_cross_sheet_plan", lambda *_: []) + monkeypatch.setattr(support, "plan_kind_items", lambda *_: []) + (tmp_path / "longitudinal.json").write_text(json.dumps({"stations": []}), "utf-8") + use_project_templates(None) + use_title_fields({}) + return tmp_path + + +def _list(root: Path): + return support._drawing_list(root, root / "longitudinal.json") + + +def test_조합마다_한장_더한장은_장마다_맨뒤(project: Path) -> None: + drawings = _list(project) + ids = [d.id for d in drawings] + assert ids[:4] == ["longitudinal", "cover", "cross_standard", "mass_haul"] + structure = [d for d in drawings if d.kind == "structure"] + # 도번 차례(B SD-000001 먼저) · 같은 조합 A 두 배치 = 한 장 + 더한장 + assert [d.id for d in structure] == [ + f"structure_{KEY_B}", + f"structure_{KEY_A}", + f"structure_{KEY_A}_s9x", + ] + assert [d.label for d in structure] == ["BOX 조합", "관보호공 조합", "관보호공 조합 · 상세"] + assert ids[4:] == [d.id for d in structure] + + +def test_도면번호_앞도면_안밀림(project: Path) -> None: + _list(project) + manifest = support._read_manifest(project)["drawings"] + assert manifest["longitudinal"]["number"] == 1 + assert manifest["mass_haul"]["number"] == 4 + assert manifest[f"structure_{KEY_B}"]["number"] == 5 + assert manifest[f"structure_{KEY_A}_s9x"]["number"] == 7 + + +def test_단건_도각_도번_도면명(project: Path) -> None: + _list(project) + kind, label, drawing, confirmed, table = support._read_drawing( + project, project / "longitudinal.json", f"structure_{KEY_A}_s9x" + ) + assert (kind, label, confirmed, table) == ("structure", "관보호공 조합 · 상세", False, None) + texts = [e["shapeData"].get("label") for e in drawing["entities"] if e["type"] == "Text"] + assert "A2" in texts and "A1" not in texts # 그 장 그림만 + assert "관보호공 조합 · 상세" in texts # 도면명 + assert "SD-000002-2" in texts # 장 도번 + assert any(layer.get("id") == "m02-structure-frame" for layer in drawing["layers"]) + + +def test_없는_구조물_도면은_404(project: Path) -> None: + with pytest.raises(FileNotFoundError): + support._read_drawing(project, project / "longitudinal.json", "structure_none_x") + + +def test_확정본_kind_structure(project: Path) -> None: + drawing_id = f"structure_{KEY_B}" + folder = _design_root(project) / "drawings" + folder.mkdir(parents=True, exist_ok=True) + saved = {"format": DRAWING_FORMAT, "entities": [], "layers": []} + (folder / f"{drawing_id}.json").write_text(json.dumps(saved), "utf-8") + _write_manifest( + project, + {"drawings": {drawing_id: {"confirmed": True, "label": "BOX 조합", "kind": "structure"}}}, + ) + kind, label, _drawing, confirmed, table = support._read_drawing( + project, project / "longitudinal.json", drawing_id + ) + assert (kind, label, confirmed, table) == ("structure", "BOX 조합", True, None) + assert next(d for d in _list(project) if d.id == drawing_id).confirmed + + +def test_연결_없으면_빈목록(project: Path, monkeypatch) -> None: + from M02_MasterTemplete import M02_Item_Snapshot as snapshot + + monkeypatch.setattr(snapshot, "linked_snapshots", lambda root: {}) + assert [d for d in _list(project) if d.kind == "structure"] == []