From 4f1521eea99a2f7ae6542902c7f463a47c0dcb95 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 18:21:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EB=8F=84=20=EC=96=91=EC=8B=9D=20=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=20=E2=80=94=203=EB=8B=A8=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9D=80=20=EA=B3=A0=EB=A5=BC=20=EB=95=8C=EB=A7=8C,=20?= =?UTF-8?q?=ED=91=9C=EB=8A=94=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8?= =?UTF-8?q?=EC=97=90=20=EB=B0=95=ED=9E=8C=20=EC=96=91=EC=8B=9D=EB=A7=8C=20?= =?UTF-8?q?=EC=9D=BD=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 라이브러리 항목을 목록으로(항목마다 AX-ST-<8hex>.json) · 기본 찰쌓기에 코드 부여 - 가져오기: 로그인한 사람의 개인·회사 단 + 프로그램 기본에서 골라 {프로젝트}/B08_Quantity/library 에 박음 · 그 종류의 고친 식은 비움 - 표·원단위·자재총괄·인계는 박힌 양식 → 없으면 프로그램 기본만 읽음(여는 사람마다 값이 안 갈림) - [확정] 때 아직 안 박힌 기본 양식을 박음 · 장 머리에 「어느 단에서 가져왔나 · 고친 식 N줄」 - 시험 test_b08_structure_library.py 6개 · 전체 1524 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_Engine_StructureLibrary.py | 111 +++++++++++ .../B08_Quantity_Engine_StructureTemplate.py | 31 +++- .../B08_Quantity_Engine_UnitQuantity.py | 4 + B08_Quantity/B08_Quantity_Router.py | 21 ++- B08_Quantity/B08_Quantity_Router_Material.py | 5 + .../B08_Quantity_Router_StructureSheet.py | 107 ++++++++++- .../B08_Quantity_UI_StructureSheet.ts | 39 +++- .../B08_Quantity_UI_StructureSheet_Library.ts | 134 ++++++++++++++ resources/library_structure/masonry_wet.json | 7 +- .../tester/test_b08_structure_library.py | 174 ++++++++++++++++++ 10 files changed, 618 insertions(+), 15 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Engine_StructureLibrary.py create mode 100644 B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts create mode 100644 resources/tester/test_b08_structure_library.py diff --git a/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py b/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py new file mode 100644 index 00000000..68975862 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py @@ -0,0 +1,111 @@ +"""구조물도 양식 **라이브러리** — 3단(개인·회사·프로그램) 목록과 프로젝트로 **가져오기** (PLAN 4장). + +⛔ 표를 그릴 때 라이브러리를 읽지 않음 — 가져온 양식은 **프로젝트 작업본에 박히고** + (`{프로젝트}/B08_Quantity/library/.json`) 표는 그것만 읽음. 3단은 가져오기 고르개에서만 돎. + 여는 사람마다 값이 갈리지 않게 하려는 것(브레인 판정 Ⓑ — 개인 = 로그인한 사람). +⚠ 아직 아무것도 안 가져온 종류는 프로그램 기본을 읽음 — 보는 사람과 무관한 한 벌. +⚠ 저장은 **목록**(항목마다 파일 한 개 · 코드 `AX-ST-<8자리 16진>`, 명세 2장 ②) — + 지금은 종류당 하나만 씀(판정 Ⓐ). 고르기 칸은 뒤에 붙임. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +from B08_Quantity.B08_Quantity_Engine_StructureTemplate import TEMPLATE_DIR +from config.config_system import STORAGE_BASE_DIR + +#: 단 이름 — 고르개에 이 차례로 보임(가까운 것부터). +TIERS = ("personal", "company", "program") +#: 항목 코드 — 파일 이름이 되므로 모양을 먼저 봄(경로 벗어남 막이). +CODE_PATTERN = re.compile(r"^AX-ST-[0-9a-f]{8}$") +LIBRARY_SUBDIR = "library" + + +def tier_dirs(company_id: Any, user_id: Any) -> dict[str, Path]: + """로그인한 사람의 3단 폴더. 회사가 없으면(시스템 관리자) 개인·회사 단은 없음.""" + dirs: dict[str, Path] = {} + if company_id is not None: + company = Path(STORAGE_BASE_DIR).resolve() / str(company_id) + if user_id is not None: + dirs["personal"] = company / str(user_id) / LIBRARY_SUBDIR + dirs["company"] = company / LIBRARY_SUBDIR + dirs["program"] = TEMPLATE_DIR + return dirs + + +def _items(folder: Path) -> list[dict[str, Any]]: + if not folder.is_dir(): + return [] + return [json.loads(p.read_text(encoding="utf-8")) for p in sorted(folder.glob("*.json"))] + + +def list_items(dirs: dict[str, Path], type_id: str) -> list[dict[str, Any]]: + """가져오기 고르개 — 단마다 그 종류의 항목(단·코드·이름).""" + return [ + {"tier": tier, "code": item.get("code"), "name": item.get("name"), "type_id": type_id} + for tier in TIERS + if tier in dirs + for item in _items(dirs[tier]) + if item.get("type_id") == type_id and item.get("code") + ] + + +def find_item(dirs: dict[str, Path], tier: str, code: str) -> dict[str, Any] | None: + folder = dirs.get(tier) + if folder is None: + return None + return next((item for item in _items(folder) if item.get("code") == code), None) + + +def project_library_dir(project_root: str | Path) -> Path: + return Path(project_root) / "B08_Quantity" / LIBRARY_SUBDIR + + +def project_templates(project_root: str | Path | None) -> dict[str, dict[str, Any]]: + """프로젝트에 박힌 양식 — 종류(type_id)별 하나. **표를 그릴 때 읽는 유일한 양식 자리.**""" + if not project_root: + return {} + return { + str(item["type_id"]): item + for item in _items(project_library_dir(project_root)) + if item.get("type_id") + } + + +def import_item(project_root: str | Path, item: dict[str, Any], tier: str) -> None: + """고른 항목을 프로젝트 작업본에 박음 — 같은 종류의 옛 것은 지움(종류당 하나). + + ⚠ 새 것을 먼저 쓰고 옛 것을 지움 — 쓰다 실패하면 옛 양식이 남아 조용히 기본으로 안 떨어짐. + """ + code = str(item.get("code") or "") + if not CODE_PATTERN.match(code): + raise ValueError(f"항목 코드 모양이 아님: {code!r}") + folder = project_library_dir(project_root) + folder.mkdir(parents=True, exist_ok=True) + body = {**item, "imported_from": tier} + target = folder / f"{code}.json" + target.write_text(json.dumps(body, ensure_ascii=False, indent=2), encoding="utf-8") + for path in folder.glob("*.json"): + if path == target: + continue + if json.loads(path.read_text(encoding="utf-8")).get("type_id") == item.get("type_id"): + path.unlink() + + +def pin_program_templates(project_root: str | Path) -> int: + """[확정] 때 — 아직 안 박힌 종류는 **그 시점 프로그램 기본**을 박음. 박은 수. + + 확정은 정본을 굳히는 자리라, 뒤에 프로그램 기본을 고쳐도 확정한 값이 안 흔들려야 함 + (브레인 판정). 확정 전(GET 만 한 프로젝트)은 기본을 그대로 읽음 — 정본이 아님. + """ + pinned = project_templates(project_root) + count = 0 + for item in _items(TEMPLATE_DIR): + if item.get("type_id") and item.get("code") and item["type_id"] not in pinned: + import_item(project_root, item, "program") + count += 1 + return count diff --git a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py index 7bc01918..44ed635e 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py @@ -31,6 +31,14 @@ def load_template(type_id: str) -> dict[str, Any] | None: return json.loads(path.read_text(encoding="utf-8")) +def template_of(type_id: str, templates: dict[str, Any] | None) -> dict[str, Any] | None: + """프로젝트에 박힌 양식(4장 가져오기) → 없으면 프로그램 기본. + + ⛔ 개인·회사 단은 여기서 안 읽음 — 가져온 것만 `templates` 로 옴(여는 사람마다 안 갈리게). + """ + return (templates or {}).get(type_id) or load_template(type_id) + + def _typed(value: Any, default: Any) -> Any: """제원 값을 양식 기본값과 같은 꼴로 — 수 칸은 수, 글 칸은 글.""" if isinstance(default, (int, float)) and not isinstance(default, bool): @@ -150,6 +158,7 @@ def replace_with_templates( section_modes: dict[float, str] | None, rubble_base_thickness_m: float | None, formula_overrides: dict[str, Any] | None = None, + templates: dict[str, Any] | None = None, ) -> None: """`build_table` 이 부름 — 양식이 있는 종류의 성분을 **양식 풀이 값으로 갈음**(자리에서). @@ -166,7 +175,7 @@ def replace_with_templates( settings = {"rubble_base_thickness_m": rubble_base_thickness_m} targets: list[tuple[Any, dict[str, Any]]] = [] for quantity, item in zip(quantities, inputs): - template = load_template(quantity.type_id) + template = template_of(quantity.type_id, templates) if template is None or not quantity.components: continue options = item.get("options") or {} @@ -196,7 +205,7 @@ def replace_with_templates( if solved is None: quantity.notes.append("⚠ 식 풀이기를 못 돌려 양식 대신 지금 전개 값으로 섰음") continue - template = load_template(quantity.type_id) or {} + template = template_of(quantity.type_id, templates) or {} by_seq = {row["seq"]: row for row in body["rows"]} # 값의 **자료 출처**(야면석 무게 = 울진 관측 등)는 전개가 붙인 그대로. # 양식이냐는 구조물 단위(`library_item`)로 따로 둠. @@ -236,7 +245,11 @@ def replace_with_templates( quantity.library_item = str(template.get("name") or quantity.type_id) -def apply_templates(payload: dict[str, Any], settings: dict[str, Any] | None = None) -> None: +def apply_templates( + payload: dict[str, Any], + settings: dict[str, Any] | None = None, + templates: dict[str, Any] | None = None, +) -> None: """구조물도 장마다 **양식이 있으면 양식으로 줄을 다시 세움**(자리에서 고침). ⚠ 양식이 없는 종류는 지금 전개 줄 그대로 — `formula` 빈칸 = 고정형 모양(명세 13장). @@ -250,7 +263,7 @@ def apply_templates(payload: dict[str, Any], settings: dict[str, Any] | None = N all_overrides = (settings or {}).get(OVERRIDES_KEY) or {} targets: list[tuple[dict[str, Any], dict[str, Any]]] = [] for sheet in payload.get("sheets") or []: - template = load_template(str(sheet.get("type_id") or "")) + template = template_of(str(sheet.get("type_id") or ""), templates) if template is None or sheet.get("billing_unit") not in _PER_LENGTH_UNITS: for row in sheet.get("rows") or []: row.setdefault("formula", "") @@ -269,7 +282,7 @@ def apply_templates(payload: dict[str, Any], settings: dict[str, Any] | None = N return solved = evaluate_sheets([body for _sheet, body in targets]) for index, (sheet, body) in enumerate(targets): - template = load_template(str(sheet.get("type_id") or "")) or {} + template = template_of(str(sheet.get("type_id") or ""), templates) or {} if solved is None: sheet["notes"].append("⚠ 식 풀이기를 못 돌려 양식 대신 지금 전개 값으로 섰음") continue @@ -281,7 +294,13 @@ def apply_templates(payload: dict[str, Any], settings: dict[str, Any] | None = N for row in sheet["rows"] if row["unit_amount"] is None and not row["skipped"] ] - sheet["library_item"] = {"type_id": template.get("type_id"), "name": template.get("name")} + # 「어느 단에서 가져왔나」 — 안 가져왔으면 `imported_from` 없음(= 기본 · 가져오기 전). + sheet["library_item"] = { + "type_id": template.get("type_id"), + "name": template.get("name"), + "code": template.get("code"), + "imported_from": template.get("imported_from"), + } sheet["formula_sheet"] = body diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 12ddea71..7fef8c6f 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -1511,6 +1511,7 @@ def build_table( rubble_base_thickness_m: float | None = None, use_templates: bool = True, structure_formulas: dict[str, Any] | None = None, + structure_templates: dict[str, Any] | None = None, ) -> dict[str, Any]: """화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다. @@ -1519,6 +1520,8 @@ def build_table( 구조물도와 같은 값을 보게 함. `use_templates=False` 는 대조 시험이 **전개만** 볼 때 씀. ⭐ `structure_formulas` — 사용자가 양식마다 고친 식(산출 조건 `structure_formula_overrides`, PLAN 3장 ⑤). 부르는 쪽이 산출 조건에서 넘김 — 안 넘기면 구조물도와 값이 갈림. + ⭐ `structure_templates` — 프로젝트에 박힌 양식(PLAN 4장 가져오기, `project_templates`). + 없는 종류는 프로그램 기본. 같은 까닭으로 부르는 쪽이 넘김. """ observed = load_observed_table() # 딸린 줄(배수관의 유입부 집수정 등)을 원본 뒤에 세운다 — 한 줄로 합치지 않는다. @@ -1545,6 +1548,7 @@ def build_table( section_modes, rubble_base_thickness_m, structure_formulas, + structure_templates, ) violations = verify_no_mix_components(quantities) diff --git a/B08_Quantity/B08_Quantity_Router.py b/B08_Quantity/B08_Quantity_Router.py index 65af9f4b..1f7415dd 100644 --- a/B08_Quantity/B08_Quantity_Router.py +++ b/B08_Quantity/B08_Quantity_Router.py @@ -5,6 +5,7 @@ 구현할 때 이 라우터를 확장한다. """ +import asyncio import logging from uuid import UUID @@ -20,7 +21,25 @@ router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"]) @router.post("/{project_id}/quantity/confirm") async def confirm_quantity(project_id: UUID) -> JSONResponse: - """수량 단계 확정 — stage 5(QUANTITY)를 COMPLETE로 전이한다 (본문 미구현).""" + """수량 단계 확정 — stage 5(QUANTITY)를 COMPLETE로 전이한다 (본문 미구현). + + ⚠ 전이 전에 **아직 안 박힌 구조물도 양식을 프로젝트에 박음**(PLAN 4장) — 확정 뒤 프로그램 + 기본을 고쳐도 확정한 값이 안 흔들리게. 못 박으면 확정하지 않음. + """ + from B08_Quantity.B08_Quantity_Engine_StructureLibrary import pin_program_templates + from B08_Quantity.B08_Quantity_Router_StructureSheet import _project_root + + project_root = await _project_root(project_id) + try: + if project_root is None: + raise FileNotFoundError("프로젝트 저장 폴더 없음") + await asyncio.to_thread(pin_program_templates, project_root) + except Exception: + logger.exception("B08 수량 확정 실패(양식 박기): project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "구조물도 양식을 프로젝트에 박지 못했습니다."}, + ) pool = get_db_pool() async with pool.acquire() as connection: try: diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index ae27db24..6508c37a 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -32,6 +32,7 @@ from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping, summarize from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material_table from B08_Quantity.B08_Quantity_Provenance import quantity_provenance +from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( ground_types_from_designs, @@ -157,6 +158,8 @@ async def get_material_summary(project_id: UUID) -> JSONResponse: settings.get("rubble_base_thickness_m"), # 구조물도에서 고친 식 — 안 넘기면 원단위·자재총괄이 구조물도와 갈림(PLAN 3장 ⑤). structure_formulas=settings.get("structure_formula_overrides"), + # 프로젝트에 박힌 양식(PLAN 4장) — 같은 까닭. + structure_templates=project_templates(project_root), ) material_table = build_material_table( unit_table, @@ -210,6 +213,7 @@ async def project_haul_inputs(project_id: UUID) -> dict[str, Any]: await _ground_types(project_id), settings.get("rubble_base_thickness_m"), structure_formulas=settings.get("structure_formula_overrides"), + structure_templates=project_templates(project_root), ) except Exception: logger.exception("B08 유토곡선 입력 조회 실패: project_id=%s", project_id) @@ -252,6 +256,7 @@ async def get_handoff(project_id: UUID) -> JSONResponse: await _ground_types(project_id), settings.get("rubble_base_thickness_m"), structure_formulas=settings.get("structure_formula_overrides"), + structure_templates=project_templates(project_root), ) material_table = build_material_table( unit_table, diff --git a/B08_Quantity/B08_Quantity_Router_StructureSheet.py b/B08_Quantity/B08_Quantity_Router_StructureSheet.py index 6591bf85..9362c597 100644 --- a/B08_Quantity/B08_Quantity_Router_StructureSheet.py +++ b/B08_Quantity/B08_Quantity_Router_StructureSheet.py @@ -14,14 +14,15 @@ import asyncio import logging from pathlib import Path -from typing import Any +from typing import Any, Literal from uuid import UUID -from fastapi import APIRouter +from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse from pydantic import BaseModel, ConfigDict, Field from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from common_util.common_util_auth import verify_session from common_util.common_util_storage import resolve_stored_project_path from config.config_db import run_with_connection @@ -51,6 +52,7 @@ def project_structure_sheets( ⚠ 늦게 부른다(함수 안 import) — B08 은 B05 를 부르고 B05 는 다시 B07 을 부를 수 있어 모듈 맨 위에서 부르면 맞물린다. """ + from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets from B08_Quantity.B08_Quantity_Engine_StructureTemplate import apply_templates from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table @@ -59,6 +61,7 @@ def project_structure_sheets( structures, names, skipped = _collect_structures(project_root) settings = quantity_settings(project_root) + templates = project_templates(project_root) unit_table = build_unit_table( structures, names, @@ -66,10 +69,11 @@ def project_structure_sheets( ground_types, settings.get("rubble_base_thickness_m"), structure_formulas=settings.get("structure_formula_overrides"), + structure_templates=templates, ) payload = build_standard_sheets(unit_table, section_modes) # 양식이 있는 종류는 줄마다 식·설명·반올림·갈 곳을 실음(PLAN 3장 ④ · 명세 13장). - apply_templates(payload, settings) + apply_templates(payload, settings, templates) # 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다. payload["skipped_structures"] = skipped return payload @@ -141,10 +145,11 @@ async def put_structure_sheet_formulas( ⚠ 개인 라이브러리에 저장하지 않음 — 그것은 [내 라이브러리에 저장] 단추 몫(명세 13장 Ⓒ, 4장). ⚠ 식이 풀리지 않아도 **막지 않고 저장**하되 줄마다 오류를 돌려줌 — 어디가 틀렸는지 보임. """ + from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates from B08_Quantity.B08_Quantity_Engine_StructureTemplate import ( OVERRIDES_KEY, - load_template, save_sheet_overrides, + template_of, ) from common_util.common_util_project_settings import quantity_settings, save_section @@ -153,7 +158,11 @@ async def put_structure_sheet_formulas( return _not_found() sheets = (await _sheets_of(project_id, project_root)).get("sheets") or [] picked = next((s for s in sheets if s.get("key") == payload.sheet_key), None) - template = load_template(str((picked or {}).get("type_id") or "")) if picked else None + template = ( + template_of(str(picked.get("type_id") or ""), project_templates(project_root)) + if picked + else None + ) if picked is None or template is None: return JSONResponse( status_code=404, @@ -259,6 +268,94 @@ async def put_structure_sheet_spec( ) +@router.get("/{project_id}/quantity/structure-sheets/library") +async def get_structure_library( + project_id: UUID, type_id: str, session: dict[str, Any] = Depends(verify_session) +) -> JSONResponse: + """가져오기 고르개 — **로그인한 사람**의 개인·회사 단과 프로그램 기본에서 그 종류의 항목. + + ⛔ 3단을 읽는 곳은 여기와 가져오기뿐 — 표를 그릴 때는 안 읽음(PLAN 4장 · 판정 Ⓑ). + """ + from B08_Quantity.B08_Quantity_Engine_StructureLibrary import ( + list_items, + project_templates, + tier_dirs, + ) + + project_root = await _project_root(project_id) + if project_root is None: + return _not_found() + dirs = tier_dirs(session.get("company_id"), session.get("user_id")) + current = project_templates(project_root).get(type_id) or {} + return JSONResponse( + content={ + "status": "success", + "items": await asyncio.to_thread(list_items, dirs, type_id), + "current": {"code": current.get("code"), "imported_from": current.get("imported_from")}, + } + ) + + +class LibraryImportRequest(BaseModel): + """가져올 항목 하나 — 단과 코드로 고름(이름은 겹칠 수 있음).""" + + model_config = ConfigDict(extra="forbid") + + type_id: str = Field(min_length=1, max_length=100) + tier: Literal["personal", "company", "program"] + code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$") + + +@router.put("/{project_id}/quantity/structure-sheets/library/import") +async def put_structure_library_import( + project_id: UUID, + payload: LibraryImportRequest, + session: dict[str, Any] = Depends(verify_session), +) -> JSONResponse: + """고른 항목을 **프로젝트 작업본에 박고** 그 종류의 고친 식을 비움. + + ⚠ 고친 식을 비우는 까닭 — 새 양식과 줄 차례가 안 맞을 수 있음. 묻는 것은 화면 몫. + """ + from B08_Quantity.B08_Quantity_Engine_StructureLibrary import ( + find_item, + import_item, + tier_dirs, + ) + from B08_Quantity.B08_Quantity_Engine_StructureTemplate import OVERRIDES_KEY + from common_util.common_util_project_settings import quantity_settings, save_section + + project_root = await _project_root(project_id) + if project_root is None: + return _not_found() + dirs = tier_dirs(session.get("company_id"), session.get("user_id")) + item = await asyncio.to_thread(find_item, dirs, payload.tier, payload.code) + if item is None or item.get("type_id") != payload.type_id: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "가져올 양식 항목을 찾지 못했습니다."}, + ) + await asyncio.to_thread(import_item, project_root, item, payload.tier) + current = quantity_settings(project_root).get(OVERRIDES_KEY) or {} + cleared = len(current.get(payload.type_id) or {}) + if cleared: + rest = {key: rows for key, rows in current.items() if key != payload.type_id} + await asyncio.to_thread( + save_section, + project_root, + "quantity", + {OVERRIDES_KEY: rest}, + replace_keys=[OVERRIDES_KEY], + ) + return JSONResponse( + content={ + "status": "success", + "code": payload.code, + "imported_from": payload.tier, + "cleared_formulas": cleared, + } + ) + + @router.get("/{project_id}/quantity/structure-sheets") async def get_structure_sheets(project_id: UUID) -> JSONResponse: """구조물도(표준도) **장 목록 + 원단위 수량표**. diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts index ac9da798..4e3b6880 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts @@ -21,6 +21,7 @@ import { type StandardSheetSpec, type StandardSpecResult, } from "./B08_Quantity_UI_StructureSheet_Spec"; +import { buildLibraryPanel, libraryLabel } from "./B08_Quantity_UI_StructureSheet_Library"; export interface StructureSheetRow { no: number; @@ -104,7 +105,13 @@ export interface StructureSheet extends StandardSheetSpec { notes: string[]; unpriced_rows: string[]; /** 양식으로 선 장이면 그 양식 — 없으면 지금 전개 줄(고정형 모양). */ - library_item?: { type_id: string; name: string }; + /** `imported_from` 없음 = 프로그램 기본을 읽는 중(가져오기 전). */ + library_item?: { + type_id: string; + name: string; + code?: string | null; + imported_from?: string | null; + }; /** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */ formula_sheet?: FormulaSheet; } @@ -394,6 +401,11 @@ function table(head: string[], rows: string[][], extraClass = ""): HTMLElement { return scroller; } +/** 사용자가 고친 식 줄 수 — 장 머리 「그 뒤 고쳤나」와 가져오기 전 물음에 씀. */ +function editedRows(sheet: StructureSheet): number { + return sheet.rows.filter((row) => row.source === "user").length; +} + /** 장 한 벌의 가운데 — 머리 · 원단위 수량표(양식 장은 식 칸) · 막힌 사유 · 개소 목록. */ function sheetBody(sheet: StructureSheet, editor: HTMLElement | null): HTMLElement { const main = el("div", "b08-sheet__main"); @@ -407,7 +419,9 @@ function sheetBody(sheet: StructureSheet, editor: HTMLElement | null): HTMLEleme "", `${sheet.title} — ${sheet.member_count}개소${total} · ` + // 양식 있음/없음을 머리에서 바로 가림 — 조용히 섞이면 왜 값이 다른지 못 찾음. - (sheet.library_item ? `양식 「${sheet.library_item.name}」` : "양식 없음(지금 전개)"), + (sheet.library_item + ? libraryLabel(sheet.library_item, editedRows(sheet)) + : "양식 없음(지금 전개)"), ), // 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음). el("span", "b08-grid__caption", sheet.unit_label), @@ -541,6 +555,27 @@ export function renderStructureSheets(projectId: string | null): HTMLElement { initialNotes, ), ); + if (sheet.library_item) { + aside.append( + buildLibraryPanel( + projectId, + sheet.library_item.type_id, + sheet.library_item.code ?? null, + () => { + const edited = editedRows(sheet); + const lost = [edited ? `고친 식 ${edited}줄` : "", dirty ? "저장 안 한 식" : ""] + .filter(Boolean) + .join(" · "); + if (!lost) return true; + if (!window.confirm(`가져오면 이 종류의 ${lost}이 비워짐 — 가져올까요?`)) + return false; + dirty = false; + return true; + }, + (after) => load(sheet.members[0]?.structure_id ?? null, after), + ), + ); + } const editor = sheet.formula_sheet ? formulaTable( sheet, diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts new file mode 100644 index 00000000..5dcd4e8b --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts @@ -0,0 +1,134 @@ +/* ============================================================================= + * B08_Quantity_UI_StructureSheet_Library.ts + * 구조물도 양식 가져오기 칸 (PLAN 4장) — 로그인한 사람의 개인·회사 단 + 프로그램 기본에서 + * 골라 **프로젝트 작업본에 박음**. + * + * ⛔ 표를 그릴 때는 라이브러리를 안 읽음 — [목록 보기]를 눌렀을 때만 목록을 받음(판정 Ⓑ). + * ⚠ 모양(클래스)은 옆 제원 칸(`b08-spec*`) 것을 그대로 씀 — 그 칸이 스타일을 넣음. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; + +const TIER_LABELS: Record = { personal: "개인", company: "회사", program: "기본" }; + +interface LibraryItem { + tier: string; + code: string; + name: string; +} + +/** 장 머리의 양식 표시 — **어느 단에서 가져왔나** + **그 뒤 고쳤나**(지금 읽는 단이 아님). */ +export function libraryLabel( + item: { name: string; imported_from?: string | null }, + editedRows: number, +): string { + const from = item.imported_from + ? `${TIER_LABELS[item.imported_from] ?? item.imported_from}에서 가져옴` + : "기본 · 가져오기 전"; + return `양식 「${item.name}」(${from})${editedRows ? ` · 고친 식 ${editedRows}줄` : ""}`; +} + +function libraryUrl(projectId: string, tail: string): string { + return `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/library${tail}`; +} + +async function readJson(response: Response): Promise { + const payload = (await response.json().catch(() => ({}))) as T & { message?: string }; + if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); + return payload; +} + +/** + * 가져오기 칸. `confirmTake` 가 거짓이면 안 가져옴 — 새 양식과 줄 차례가 안 맞을 수 있어 서버가 + * 그 종류의 고친 식을 비우므로, 고친 식·저장 안 한 식이 있으면 부르는 쪽이 먼저 물음. + */ +export function buildLibraryPanel( + projectId: string, + typeId: string, + currentCode: string | null, + confirmTake: () => boolean, + onImported: (notes: string[]) => Promise, +): HTMLElement { + const panel = document.createElement("div"); + panel.className = "b08-spec ui-sidebar-section"; + const title = document.createElement("h3"); + title.className = "b08-spec__title"; + title.textContent = "양식 가져오기"; + const scope = document.createElement("p"); + scope.className = "b08-spec__scope"; + scope.textContent = "가져온 양식은 이 프로젝트에 박혀 누가 열어도 같은 값으로 섭니다."; + const status = document.createElement("p"); + status.className = "b08-spec__scope"; + + const list = document.createElement("select"); + list.className = "b08-spec__input"; + list.hidden = true; + const load = document.createElement("button"); + load.type = "button"; + load.className = "b08-quantity__tab"; + load.textContent = "목록 보기"; + const take = document.createElement("button"); + take.type = "button"; + take.className = "b08-spec__save"; + take.textContent = "가져오기"; + take.hidden = true; + + load.addEventListener("click", () => { + void (async () => { + load.disabled = true; + status.textContent = "목록 받는 중…"; + try { + const { items } = await readJson<{ items: LibraryItem[] }>( + await fetch(libraryUrl(projectId, `?type_id=${encodeURIComponent(typeId)}`), { + credentials: "include", + }), + ); + list.replaceChildren( + ...items.map((item) => { + const option = document.createElement("option"); + option.value = `${item.tier}|${item.code}`; + const now = item.code === currentCode ? " (지금)" : ""; + option.textContent = `${TIER_LABELS[item.tier] ?? item.tier} · ${item.name}${now}`; + return option; + }), + ); + list.hidden = take.hidden = items.length === 0; + status.textContent = items.length ? "" : "가져올 항목이 없음"; + } catch (error) { + status.textContent = error instanceof Error ? error.message : "목록을 받지 못함"; + } finally { + load.disabled = false; + } + })(); + }); + + take.addEventListener("click", () => { + const [tier, code] = list.value.split("|"); + if (!tier || !code || !confirmTake()) return; + void (async () => { + take.disabled = true; + status.textContent = "가져오는 중…"; + try { + const result = await readJson<{ cleared_formulas: number }>( + await fetch(libraryUrl(projectId, "/import"), { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type_id: typeId, tier, code }), + }), + ); + const cleared = result.cleared_formulas + ? ` · 고친 식 ${result.cleared_formulas}줄 비움` + : ""; + // 프로젝트 작업본이 바뀌었으니 표를 **다시 받아** 그림. + await onImported([`양식을 가져왔습니다${cleared}.`]); + } catch (error) { + status.textContent = error instanceof Error ? error.message : "가져오지 못함"; + take.disabled = false; + } + })(); + }); + + panel.append(title, scope, load, list, take, status); + return panel; +} diff --git a/resources/library_structure/masonry_wet.json b/resources/library_structure/masonry_wet.json index 375832bc..c15ec8f9 100644 --- a/resources/library_structure/masonry_wet.json +++ b/resources/library_structure/masonry_wet.json @@ -1,5 +1,6 @@ { "schema_version": 1, + "code": "AX-ST-56e81a2c", "library_tier": "program", "item_kind": "form", "type_id": "masonry_wet", @@ -31,7 +32,11 @@ "default": 0 }, "기초": { "label": "기초(기초유·기초버림)", "option": "foundation", "default": "" }, - "BLIND": { "label": "버림 콘크리트(넣음·안 넣음)", "option": "blinding_concrete", "default": "넣음" }, + "BLIND": { + "label": "버림 콘크리트(넣음·안 넣음)", + "option": "blinding_concrete", + "default": "넣음" + }, "SUPPLY": { "label": "돌 조달(채집·구입)", "option": "stone_supply", "default": "채집" }, "MPA": { "label": "채움 콘크리트 강도(MPa)", "option": "fill_concrete_mpa", "default": "210" }, "HOLE_DIA": { "label": "물구멍 지름(㎜)", "option": "weep_hole_diameter_mm", "default": 50 }, diff --git a/resources/tester/test_b08_structure_library.py b/resources/tester/test_b08_structure_library.py new file mode 100644 index 00000000..d28e9fb0 --- /dev/null +++ b/resources/tester/test_b08_structure_library.py @@ -0,0 +1,174 @@ +"""구조물도 양식 라이브러리 — 가져오기·박기 (2026-09-13, PLAN 4장 ①). + +겨누는 것 + ① 가져오기 전은 프로그램 기본 · 머리 「가져오기 전」 + ② 로그인한 사람의 개인 단에서 가져오면 **프로젝트에 박히고** 원단위·자재총괄도 그 양식을 봄 + ③ ⛔ 박힌 뒤에는 라이브러리를 안 읽음 — 개인 파일을 지우거나 다른 사람이 열어도 값이 같음 + ④ 가져오면 그 종류의 고친 식이 비워짐 + ⑤ [확정] 때 안 박힌 기본 양식을 박음 · 코드 모양이 아니면 거절 +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +import B08_Quantity.B08_Quantity_Engine_StructureLibrary as library_module # noqa: E402 +import B08_Quantity.B08_Quantity_Router_Material as material_module # noqa: E402 +import B08_Quantity.B08_Quantity_Router_StructureSheet as router_module # noqa: E402 +from B05_Profile.B05_Profile_Structures_Repository import save_structures # noqa: E402 +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_StructureTemplate import load_template # noqa: E402 +from common_util.common_util_auth import verify_session # noqa: E402 + +PROJECT_ID = "33333333-3333-3333-3333-333333333333" +SHEETS = f"/api/projects/{PROJECT_ID}/quantity/structure-sheets" +PERSONAL_CODE = "AX-ST-0badc0de" + + +@pytest.fixture() +def storage(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + base = tmp_path / "storage" + monkeypatch.setattr(library_module, "STORAGE_BASE_DIR", str(base)) + # 개인 단(회사 7 · 사람 42)에 모르터 식을 두 배로 고친 찰쌓기 한 벌. + mine = load_template("masonry_wet") + for row in mine["rows"]: + if row["name"] == "모르터": + row["formula"] = "A*0.018" + mine.update(code=PERSONAL_CODE, library_tier="personal", name="돌쌓기(찰) 내 것") + folder = base / "7" / "42" / "library" + folder.mkdir(parents=True) + (folder / f"{PERSONAL_CODE}.json").write_text(json.dumps(mine), encoding="utf-8") + return base + + +@pytest.fixture() +def project(tmp_path: Path) -> Path: + root = tmp_path / "project" + root.mkdir() + wall = StructureInstance.model_validate( + { + "type_id": "masonry_wet", + "placement": "interval", + "start_m": 100.0, + "end_m": 110.0, + "options": {"height_m": 2.5, "back_len_cm": 45}, + } + ) + save_structures(str(root), [wall], base_revision=0) + return root + + +@pytest.fixture() +def client(project: Path, storage: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: + async def fake_root(project_id): + return str(project) + + async def no_route(project_id): + return {} + + monkeypatch.setattr(router_module, "_project_root", fake_root) + monkeypatch.setattr(material_module, "_section_modes", no_route) + monkeypatch.setattr(material_module, "_ground_types", no_route) + app = FastAPI() + app.include_router(router_module.router) + app.dependency_overrides[verify_session] = lambda: {"company_id": 7, "user_id": 42} + return TestClient(app) + + +def _sheet(client: TestClient) -> dict: + response = client.get(SHEETS) + assert response.status_code == 200, response.text + return response.json()["sheets"][0] + + +def _mortar(sheet: dict) -> dict: + return next(row for row in sheet["rows"] if row["name"] == "모르터") + + +def test_가져오기_전은_프로그램_기본(client: TestClient) -> None: + item = _sheet(client)["library_item"] + assert item["imported_from"] is None + assert item["code"] == load_template("masonry_wet")["code"] + + +def test_개인_단에서_가져오면_박히고_라이브러리를_다시_안_읽는다( + client: TestClient, project: Path, storage: Path +) -> None: + before = _mortar(_sheet(client))["unit_amount"] + listed = client.get(f"{SHEETS}/library", params={"type_id": "masonry_wet"}).json()["items"] + assert [item["tier"] for item in listed] == ["personal", "program"] + + taken = client.put( + f"{SHEETS}/library/import", + json={"type_id": "masonry_wet", "tier": "personal", "code": PERSONAL_CODE}, + ) + assert taken.status_code == 200, taken.text + sheet = _sheet(client) + assert sheet["library_item"]["imported_from"] == "personal" + assert _mortar(sheet)["unit_amount"] == pytest.approx(before * 2) + # 가져온 식은 「사용자 식」이 아니라 그 양식의 식. + assert _mortar(sheet)["source"] == "library" + + # 원단위·자재총괄 창구도 박힌 양식을 봄. + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table + + structures, names, _ = material_module._collect_structures(str(project)) + table = build_table( + structures, + names, + structure_templates=library_module.project_templates(project), + ) + mortar = next(c for c in table["structures"][0]["components"] if c["name"] == "모르터") + assert mortar["amount"] == pytest.approx(_mortar(sheet)["unit_amount"] * 10.0) + + # ⛔ 개인 파일이 사라지고 다른 사람이 열어도 값이 그대로. + (storage / "7" / "42" / "library" / f"{PERSONAL_CODE}.json").unlink() + client.app.dependency_overrides[verify_session] = lambda: {"company_id": 7, "user_id": 99} + assert _mortar(_sheet(client))["unit_amount"] == pytest.approx(before * 2) + + +def test_가져오면_그_종류의_고친_식이_비워진다(client: TestClient, project: Path) -> None: + sheet = _sheet(client) + seq = _mortar(sheet)["no"] + client.put( + f"{SHEETS}/formulas", + json={"sheet_key": sheet["key"], "rows": [{"seq": seq, "formula": "A*0.03"}]}, + ) + program_code = load_template("masonry_wet")["code"] + taken = client.put( + f"{SHEETS}/library/import", + json={"type_id": "masonry_wet", "tier": "program", "code": program_code}, + ) + assert taken.json()["cleared_formulas"] == 1 + stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8")) + assert stored["quantity"]["structure_formula_overrides"] == {} + assert _sheet(client)["library_item"]["imported_from"] == "program" + + +def test_없는_항목_가져오기는_404(client: TestClient) -> None: + missing = client.put( + f"{SHEETS}/library/import", + json={"type_id": "masonry_wet", "tier": "company", "code": "AX-ST-12345678"}, + ) + assert missing.status_code == 404 + + +def test_확정_때_기본_양식을_박는다(project: Path) -> None: + assert library_module.pin_program_templates(project) >= 1 + pinned = library_module.project_templates(project)["masonry_wet"] + assert pinned["imported_from"] == "program" + assert library_module.pin_program_templates(project) == 0 # 이미 박힌 것은 그대로 + + +def test_코드_모양이_아니면_박지_않는다(project: Path) -> None: + with pytest.raises(ValueError): + library_module.import_item(project, {"type_id": "masonry_wet", "code": "../x"}, "personal")