From 13b29d620f95f952cc6dba97d6976af112fc524c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 18:32:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=20=EB=82=B4=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=EC=97=90=20=EC=A0=80=EC=9E=A5=C2=B7?= =?UTF-8?q?=EB=82=B4=20=EA=B2=83=20=EC=A7=80=EC=9A=B0=EA=B8=B0=20=E2=80=94?= =?UTF-8?q?=20=EA=B0=9C=EC=9D=B8=20=EB=8B=A8=EB=A7=8C=20=EC=94=80,=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=EB=B3=B8=EC=9D=80=20=EA=B7=B8=EB=8C=80=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [내 라이브러리에 저장]: 그 장의 양식 + 고친 식을 로그인한 사람 개인 단에 한 벌로 씀(같은 종류는 그 코드로 덮어씀) · 저장 안 한 식이 있으면 막음 - [내 것 지우기]: 개인 단의 그 종류만 지움 · 박힌 양식·고친 식은 안 바뀜 - 기본 찰쌓기 양식에 pum_edition 2026-01-01(명세 17장 ③) - 결함: 식 저장 뒤 다시 그린 표에 「저장 안 한 식」 표시가 남아 헛물음이 뜨던 것 — 장을 그릴 때 비움 - 시험 2개 추가 · 전체 1531 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_Engine_StructureLibrary.py | 38 +++++++++ .../B08_Quantity_Router_StructureSheet.py | 73 +++++++++++++++++ .../B08_Quantity_UI_StructureSheet.ts | 17 ++-- .../B08_Quantity_UI_StructureSheet_Library.ts | 81 ++++++++++++++++--- resources/library_structure/masonry_wet.json | 1 + .../tester/test_b08_structure_library.py | 47 +++++++++++ 6 files changed, 239 insertions(+), 18 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py b/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py index 68975862..1c54a93c 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py @@ -12,6 +12,7 @@ from __future__ import annotations import json import re +import secrets from pathlib import Path from typing import Any @@ -96,6 +97,43 @@ def import_item(project_root: str | Path, item: dict[str, Any], tier: str) -> No path.unlink() +def _write(folder: Path, item: dict[str, Any]) -> None: + folder.mkdir(parents=True, exist_ok=True) + text = json.dumps(item, ensure_ascii=False, indent=2) + (folder / f"{item['code']}.json").write_text(text, encoding="utf-8") + + +def save_personal(folder: Path, template: dict[str, Any], overrides: dict[str, Any] | None) -> str: + """[내 라이브러리에 저장] — 양식 + 프로젝트에서 고친 식을 **개인 단에 한 벌**로 씀. 코드. + + ⚠ 반대 방향(작업본 → 개인 단)이라 프로젝트는 안 바꿈(브레인 판정 Ⓑ). + ⚠ 개인 단에 같은 종류가 있으면 **그 코드로 덮어씀** — 지금은 종류당 하나(판정 Ⓐ). + """ + from B08_Quantity.B08_Quantity_Engine_StructureTemplate import overridden_rows + + type_id = template.get("type_id") + same = [item for item in _items(folder) if item.get("type_id") == type_id] + code = str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}" + # 고친 식이 곧 이 항목의 식 — 「사용자 식」 표시와 되돌릴 자리는 떼어 냄. + rows = [ + {**{k: v for k, v in row.items() if k != "default_formula"}, "source": "library"} + for row in overridden_rows(template, overrides) + ] + item = {k: v for k, v in template.items() if k != "imported_from"} + _write(folder, {**item, "code": code, "library_tier": "personal", "rows": rows}) + return code + + +def delete_personal(folder: Path, type_id: str) -> int: + """[내 것 지우기] — 개인 단의 그 종류만 지움. 프로젝트 작업본은 안 바꿈. 지운 수.""" + count = 0 + for path in folder.glob("*.json") if folder.is_dir() else []: + if json.loads(path.read_text(encoding="utf-8")).get("type_id") == type_id: + path.unlink() + count += 1 + return count + + def pin_program_templates(project_root: str | Path) -> int: """[확정] 때 — 아직 안 박힌 종류는 **그 시점 프로그램 기본**을 박음. 박은 수. diff --git a/B08_Quantity/B08_Quantity_Router_StructureSheet.py b/B08_Quantity/B08_Quantity_Router_StructureSheet.py index 9362c597..df2eb073 100644 --- a/B08_Quantity/B08_Quantity_Router_StructureSheet.py +++ b/B08_Quantity/B08_Quantity_Router_StructureSheet.py @@ -356,6 +356,79 @@ async def put_structure_library_import( ) +def _personal_dir(session: dict[str, Any]) -> Path | None: + """로그인한 사람의 개인 단. 회사·사람이 없으면(시스템 관리자 등) `None`.""" + from B08_Quantity.B08_Quantity_Engine_StructureLibrary import tier_dirs + + return tier_dirs(session.get("company_id"), session.get("user_id")).get("personal") + + +def _no_personal() -> JSONResponse: + return JSONResponse( + status_code=403, + content={"status": "error", "message": "개인 라이브러리는 회사에 속한 사용자만 씁니다."}, + ) + + +class LibrarySaveRequest(BaseModel): + """[내 라이브러리에 저장] — 어느 장의 양식을 쓸지.""" + + model_config = ConfigDict(extra="forbid") + + sheet_key: str + + +@router.put("/{project_id}/quantity/structure-sheets/library/personal") +async def put_structure_library_personal( + project_id: UUID, + payload: LibrarySaveRequest, + session: dict[str, Any] = Depends(verify_session), +) -> JSONResponse: + """그 장의 양식 + 이 프로젝트에서 고친 식을 **로그인한 사람 개인 단**에 한 벌로 씀. + + ⚠ 프로젝트 작업본은 안 바꿈 — 반대 방향(작업본 → 개인 단)이라(판정 Ⓑ). + """ + from B08_Quantity.B08_Quantity_Engine_StructureLibrary import ( + project_templates, + save_personal, + ) + from B08_Quantity.B08_Quantity_Engine_StructureTemplate import OVERRIDES_KEY, template_of + from common_util.common_util_project_settings import quantity_settings + + folder = _personal_dir(session) + if folder is None: + return _no_personal() + project_root = await _project_root(project_id) + if project_root is None: + 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) + type_id = str((picked or {}).get("type_id") or "") + template = template_of(type_id, project_templates(project_root)) if picked else None + if template is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "양식이 있는 구조물도 장을 찾지 못했습니다."}, + ) + overrides = (quantity_settings(project_root).get(OVERRIDES_KEY) or {}).get(type_id) + code = await asyncio.to_thread(save_personal, folder, template, overrides) + return JSONResponse(content={"status": "success", "code": code, "edited": len(overrides or {})}) + + +@router.delete("/{project_id}/quantity/structure-sheets/library/personal") +async def delete_structure_library_personal( + project_id: UUID, type_id: str, session: dict[str, Any] = Depends(verify_session) +) -> JSONResponse: + """[내 것 지우기] — 개인 단의 그 종류만 지움. ⛔ 프로젝트에 박힌 것은 안 바꿈(판정 Ⓑ).""" + from B08_Quantity.B08_Quantity_Engine_StructureLibrary import delete_personal + + folder = _personal_dir(session) + if folder is None: + return _no_personal() + deleted = await asyncio.to_thread(delete_personal, folder, type_id) + return JSONResponse(content={"status": "success", "deleted": deleted}) + + @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 4e3b6880..2ca5dd3d 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts @@ -532,6 +532,9 @@ export function renderStructureSheets(projectId: string | null): HTMLElement { const pane = el("div", "b08-sheet"); const buttons: HTMLButtonElement[] = []; const show = (index: number, initialNotes: string[] = []): void => { + // 새로 그린 표에는 고친 칸이 없음 — [식 저장] 뒤 다시 그릴 때 「저장 안 한 식」이 남으면 + // 나갈 때·다른 장으로 갈 때 헛물음이 뜨고 [내 라이브러리에 저장]이 막힘(2026-09-13 화면 실측). + dirty = false; buttons.forEach((button, i) => button.classList.toggle("is-active", i === index)); const sheet = sheets[index]; const aside = el("div", "b08-sheet__aside"); @@ -557,11 +560,13 @@ export function renderStructureSheets(projectId: string | null): HTMLElement { ); if (sheet.library_item) { aside.append( - buildLibraryPanel( + buildLibraryPanel({ projectId, - sheet.library_item.type_id, - sheet.library_item.code ?? null, - () => { + sheetKey: sheet.key, + typeId: sheet.library_item.type_id, + currentCode: sheet.library_item.code ?? null, + isDirty: () => dirty, + confirmTake: () => { const edited = editedRows(sheet); const lost = [edited ? `고친 식 ${edited}줄` : "", dirty ? "저장 안 한 식" : ""] .filter(Boolean) @@ -572,8 +577,8 @@ export function renderStructureSheets(projectId: string | null): HTMLElement { dirty = false; return true; }, - (after) => load(sheet.members[0]?.structure_id ?? null, after), - ), + onImported: (after) => load(sheet.members[0]?.structure_id ?? null, after), + }), ); } const editor = sheet.formula_sheet diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts index 5dcd4e8b..bc063301 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts @@ -38,17 +38,21 @@ async function readJson(response: Response): Promise { return payload; } -/** - * 가져오기 칸. `confirmTake` 가 거짓이면 안 가져옴 — 새 양식과 줄 차례가 안 맞을 수 있어 서버가 - * 그 종류의 고친 식을 비우므로, 고친 식·저장 안 한 식이 있으면 부르는 쪽이 먼저 물음. - */ -export function buildLibraryPanel( - projectId: string, - typeId: string, - currentCode: string | null, - confirmTake: () => boolean, - onImported: (notes: string[]) => Promise, -): HTMLElement { +export interface LibraryPanelOptions { + projectId: string; + sheetKey: string; + typeId: string; + currentCode: string | null; + /** 거짓이면 안 가져옴 — 서버가 그 종류의 고친 식을 비우므로 부르는 쪽이 먼저 물음. */ + confirmTake: () => boolean; + /** 저장 안 한 식이 있으면 [내 라이브러리에 저장]을 막음 — 저장된 식만 개인 단으로 감. */ + isDirty: () => boolean; + onImported: (notes: string[]) => Promise; +} + +/** 가져오기 · [내 라이브러리에 저장] · [내 것 지우기] 칸. 개인 단 두 단추는 프로젝트를 안 바꿈. */ +export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement { + const { projectId, sheetKey, typeId, currentCode, confirmTake, isDirty, onImported } = options; const panel = document.createElement("div"); panel.className = "b08-spec ui-sidebar-section"; const title = document.createElement("h3"); @@ -129,6 +133,59 @@ export function buildLibraryPanel( })(); }); - panel.append(title, scope, load, list, take, status); + // 개인 단 두 단추 — 목록을 다시 받아야 보이므로 끝나면 [목록 보기]를 한 번 누른 것처럼 갱신. + const personal = (label: string, run: () => Promise): HTMLButtonElement => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b08-quantity__tab"; + button.textContent = label; + button.addEventListener("click", () => { + void (async () => { + button.disabled = true; + try { + status.textContent = await run(); + if (!list.hidden) load.click(); + } catch (error) { + status.textContent = error instanceof Error ? error.message : `${label} 못함`; + } finally { + button.disabled = false; + } + })(); + }); + return button; + }; + const save = personal("내 라이브러리에 저장", async () => { + if (isDirty()) return "저장 안 한 식이 있음 — [식 저장] 먼저"; + if ( + !window.confirm("이 장의 양식과 고친 식을 내 라이브러리에 저장 — 같은 종류가 있으면 덮어씀") + ) { + return ""; + } + const result = await readJson<{ edited: number }>( + await fetch(libraryUrl(projectId, "/personal"), { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sheet_key: sheetKey }), + }), + ); + return `내 라이브러리에 저장함${result.edited ? ` · 고친 식 ${result.edited}줄 포함` : ""}`; + }); + const remove = personal("내 것 지우기", async () => { + if (!window.confirm("내 라이브러리의 이 종류 양식을 지움 — 이 프로젝트 값은 안 바뀜")) + return ""; + const result = await readJson<{ deleted: number }>( + await fetch(libraryUrl(projectId, `/personal?type_id=${encodeURIComponent(typeId)}`), { + method: "DELETE", + credentials: "include", + }), + ); + return result.deleted ? "내 라이브러리에서 지움" : "지울 내 양식이 없음"; + }); + const mine = document.createElement("div"); + mine.className = "b08-sheet__actions"; + mine.append(save, remove); + + panel.append(title, scope, load, list, take, mine, status); return panel; } diff --git a/resources/library_structure/masonry_wet.json b/resources/library_structure/masonry_wet.json index c15ec8f9..c4ac776a 100644 --- a/resources/library_structure/masonry_wet.json +++ b/resources/library_structure/masonry_wet.json @@ -1,6 +1,7 @@ { "schema_version": 1, "code": "AX-ST-56e81a2c", + "pum_edition": "2026-01-01", "library_tier": "program", "item_kind": "form", "type_id": "masonry_wet", diff --git a/resources/tester/test_b08_structure_library.py b/resources/tester/test_b08_structure_library.py index d28e9fb0..10f4b4aa 100644 --- a/resources/tester/test_b08_structure_library.py +++ b/resources/tester/test_b08_structure_library.py @@ -162,6 +162,53 @@ def test_없는_항목_가져오기는_404(client: TestClient) -> None: 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_회사_없는_사람은_개인_단을_못_쓴다(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"]