"""구조물도 양식 라이브러리 — 가져오기·박기 (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_내_라이브러리에_저장은_고친_식을_담고_프로젝트는_안_바꾼다( client: TestClient, project: Path, storage: Path ) -> None: """PLAN 4장 ②③ — 개인 단에 한 벌(같은 종류는 덮어씀) · 지우기도 개인 단만.""" personal = storage / "7" / "42" / "library" sheet = _sheet(client) seq = _mortar(sheet)["no"] client.put( f"{SHEETS}/formulas", json={"sheet_key": sheet["key"], "rows": [{"seq": seq, "formula": "A*0.03"}]}, ) settings_before = (project / "project_settings.json").read_text(encoding="utf-8") saved = client.put(f"{SHEETS}/library/personal", json={"sheet_key": sheet["key"]}) assert saved.status_code == 200, saved.text assert saved.json()["code"] == PERSONAL_CODE # 같은 종류가 있어 그 코드로 덮어씀 files = list(personal.glob("*.json")) assert len(files) == 1 item = json.loads(files[0].read_text(encoding="utf-8")) mortar_row = next(row for row in item["rows"] if row["name"] == "모르터") assert mortar_row["formula"] == "A*0.03" and mortar_row["source"] == "library" assert "default_formula" not in mortar_row assert item["library_tier"] == "personal" and item["pum_edition"] == "2026-01-01" # 프로젝트는 그대로 — 박힌 양식 없음 · 고친 식 그대로. assert library_module.project_templates(project) == {} assert (project / "project_settings.json").read_text(encoding="utf-8") == settings_before # 가져온 뒤 지워도 프로젝트는 그대로. client.put( f"{SHEETS}/library/import", json={"type_id": "masonry_wet", "tier": "personal", "code": PERSONAL_CODE}, ) amount = _mortar(_sheet(client))["unit_amount"] deleted = client.delete(f"{SHEETS}/library/personal", params={"type_id": "masonry_wet"}) assert deleted.json()["deleted"] == 1 and not list(personal.glob("*.json")) assert _mortar(_sheet(client))["unit_amount"] == pytest.approx(amount) def test_프로그램_기본은_storage_작업본이_씨앗을_이긴다(storage: Path) -> None: """관리자 UI(2026-09-14 브레인) — 프로그램 기본도 storage 작업본 · git 파일은 씨앗(없는 코드만).""" seed = load_template("masonry_wet") assert library_module.program_items()[0]["code"] == seed["code"] # 작업본 없으면 씨앗 edited = {**seed, "name": "돌쌓기(찰) 관리자 고침"} folder = library_module.program_library_dir() folder.mkdir(parents=True) (folder / f"{seed['code']}.json").write_text(json.dumps(edited), encoding="utf-8") items = library_module.program_items() assert [i["name"] for i in items if i["type_id"] == "masonry_wet"] == ["돌쌓기(찰) 관리자 고침"] assert load_template("masonry_wet")["name"] == "돌쌓기(찰) 관리자 고침" assert str(folder).startswith(str(storage)) # 씨앗(git) 파일은 안 건드림 def test_발행은_권한대로_회사_기본_단에_같은_모양으로(client: TestClient, storage: Path) -> None: sheet = _sheet(client) denied = client.put( f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "program"} ) assert denied.status_code == 403 # 시스템 관리자만 assert client.get(f"{SHEETS}/library", params={"type_id": "masonry_wet"}).json()[ "can_publish" ] == {"company": False, "program": False} client.app.dependency_overrides[verify_session] = lambda: { "company_id": 7, "user_id": 42, "is_master": True, "role": "ADMIN", } company = client.put( f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "company"} ) assert company.status_code == 200, company.text saved = json.loads( (storage / "7" / "library" / f"{company.json()['code']}.json").read_text("utf-8") ) assert saved["library_tier"] == "company" assert ( client.put( f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "program"} ).status_code == 403 ) client.app.dependency_overrides[verify_session] = lambda: { "company_id": None, "user_id": 1, "role": "SYSTEM_ADMIN", } program = client.put( f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "program"} ) assert program.status_code == 200, program.text assert program.json()["code"] == load_template("masonry_wet")["code"] # 씨앗 코드로 덮어씀 assert (library_module.program_library_dir() / f"{program.json()['code']}.json").is_file() assert client.get(f"{SHEETS}/library", params={"type_id": "masonry_wet"}).json()["can_publish"][ "program" ] def test_프로그램_기본_발행본엔_원문_공사명_파일명을_안_싣는다(tmp_path: Path) -> None: """2026-09-14 브레인 ② — 모든 회사로 가는 발행본에서만 가림 · 원본·회사 단은 그대로(③).""" origin = { "kind": "stmate_xlsx", "file": "봉화.xlsx", "project": "2024년 봉화 임도", "hopyo_no": 6, } template = {**load_template("masonry_wet"), "origin": origin} program = library_module.save_personal(tmp_path / "program", template, None, None, "program") company = library_module.save_personal(tmp_path / "company", template, None, None, "company") published = json.loads((tmp_path / "program" / f"{program}.json").read_text(encoding="utf-8")) kept = json.loads((tmp_path / "company" / f"{company}.json").read_text(encoding="utf-8")) assert "project" not in published["origin"] and "file" not in published["origin"] assert published["origin"]["masked"] == "원문에서 뽑음 — 공사명은 발행 시 가림" assert published["origin"]["kind"] == "stmate_xlsx" assert kept["origin"] == origin assert template["origin"] == origin # 원본은 안 건드림 def test_발행_확인창이_가릴_공사명을_보인다() -> None: ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text( encoding="utf-8" ) assert "originProject" in ui and "공사명은 빼고 발행됩니다" in ui sheet_ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet.ts").read_text( encoding="utf-8" ) assert "originProject: sheet.library_item.origin_project" in sheet_ui template_py = (ROOT / "B08_Quantity" / "B08_Quantity_Engine_StructureTemplate.py").read_text( encoding="utf-8" ) assert '"origin_project"' in template_py def test_회사_없는_사람은_개인_단을_못_쓴다(client: TestClient) -> None: client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1} sheet = _sheet(client) assert ( client.put(f"{SHEETS}/library/personal", json={"sheet_key": sheet["key"]}).status_code == 403 ) 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_복제는_고른_항목을_개인_단에_베끼고_프로젝트는_안_바꾼다( client: TestClient, project: Path, storage: Path ) -> None: """PLAN 4장 「복제해서 내 것 만들기」 — 기본(또는 회사) 항목을 개인 단으로 한 번에 베낌.""" import B08_Quantity.B08_Quantity_Router_StmateLibrary as extra_router client.app.include_router(extra_router.router) base = load_template("masonry_wet") folder = storage / "7" / "42" / "library" for path in folder.glob("*.json"): path.unlink() # 개인 단을 비워 새로 생기는지 봄 response = client.put( f"{SHEETS}/library/clone", json={"type_id": "masonry_wet", "tier": "program", "code": base["code"]}, ) assert response.status_code == 200, response.text code = response.json()["code"] saved = json.loads((folder / f"{code}.json").read_text(encoding="utf-8")) assert saved["library_tier"] == "personal" and saved["code"] != base["code"] assert saved["cloned_from"] == {"tier": "program", "code": base["code"], "name": base["name"]} assert saved["rows"] == base["rows"] assert not (project / "B08_Quantity" / "library").exists() # 프로젝트 작업본은 그대로 missing = client.put( f"{SHEETS}/library/clone", json={"type_id": "masonry_wet", "tier": "program", "code": "AX-ST-00000000"}, ) assert missing.status_code == 404 client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1} denied = client.put( f"{SHEETS}/library/clone", json={"type_id": "masonry_wet", "tier": "program", "code": base["code"]}, ) assert denied.status_code == 403 def test_항목마다_양식형_고정형_종류가_붙는다(tmp_path: Path) -> None: """PLAN 4장 「항목마다 종류 배지」 — 명세 13장: 칸은 같고 `formula` 유무로만 갈림.""" folder = tmp_path / "personal" folder.mkdir() fixed = { "type_id": "masonry_wet", "code": "AX-ST-0000beef", "name": "뽑은 항목", "rows": [{"name": "돌쌓기", "formula": "", "amount": 20.9}], } (folder / "AX-ST-0000beef.json").write_text(json.dumps(fixed), encoding="utf-8") dirs = {"personal": folder, "program": library_module.TEMPLATE_DIR} kinds = {item["tier"]: item["kind"] for item in library_module.list_items(dirs, "masonry_wet")} assert kinds == {"personal": "fixed", "program": "form"} ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text( encoding="utf-8" ) assert "양식형" in ui and "고정형" in ui and "item.kind" in ui def test_복제_단추는_개인_단_항목에선_안_눌린다() -> None: ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text( encoding="utf-8" ) assert "/clone" in ui and "복제해서 내 것으로" in ui assert 'clone.disabled = tier === "personal"' in ui # 발행 단추 — 서버 권한(can_publish)대로만 보임 assert "/publish" in ui and "toCompany.hidden = !canPublish?.company" in ui assert "toProgram.hidden = !canPublish?.program" in ui def test_코드_모양이_아니면_박지_않는다(project: Path) -> None: with pytest.raises(ValueError): library_module.import_item(project, {"type_id": "masonry_wet", "code": "../x"}, "personal")