From 99a79e8e0be96ef49ecedc5551d092d3b9bf25f0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 18:06:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=9F=AC=EB=A6=AC=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89=20=E2=80=94=20=ED=94=84=EB=A1=9C=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EB=8F=84=20storage=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=EB=B3=B8(git=20resources=20=EB=8A=94=20=EC=94=A8=EC=95=97=20?= =?UTF-8?q?=C2=B7=20=EC=97=86=EB=8A=94=20=EC=BD=94=EB=93=9C=EB=A7=8C=20?= =?UTF-8?q?=C2=B7=20=EB=8D=AE=EC=96=B4=EC=93=B0=EA=B8=B0=20=EA=B8=88?= =?UTF-8?q?=EC=A7=80)=20=C2=B7=20[=ED=9A=8C=EC=82=AC=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=EC=97=90=20=EB=B0=9C=ED=96=89](?= =?UTF-8?q?=EB=A7=88=EC=8A=A4=ED=84=B0=C2=B7=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90)=20=C2=B7=20[=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EB=9E=A8=20=EA=B8=B0=EB=B3=B8=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89](=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90)=20=E2=80=94=20[=EB=82=B4=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=EC=97=90=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5]=EA=B3=BC=20=EA=B0=99=EC=9D=80=20=EB=AA=A8=EC=96=91?= =?UTF-8?q?=20=C2=B7=20=EA=B6=8C=ED=95=9C=EC=9D=80=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EA=B0=80=20can=5Fpublish=20=EB=A1=9C=20=EB=82=B4=EB=A6=AC?= =?UTF-8?q?=EA=B3=A0=20=ED=99=94=EB=A9=B4=EC=9D=80=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=EB=A7=8C=20=EB=B3=B4=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 화면(시스템 관리자 계정): 두 단추 보임 · 확인창 「모든 회사가 쓰는 프로그램 기본에 발행 — 같은 종류가 있으면 덮어씀」 · 공용 storage 라 취소로 멈춤(쓰기 없음 확인) · 쓰기는 시험(임시 storage)으로 잼 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_Engine_StructureLibrary.py | 62 +++++++++++++----- .../B08_Quantity_Engine_StructureTemplate.py | 13 ++-- .../B08_Quantity_Router_StructureSheet.py | 30 +++++++-- .../B08_Quantity_UI_StructureSheet_Library.ts | 33 +++++++++- .../tester/test_b08_structure_library.py | 64 +++++++++++++++++++ 5 files changed, 175 insertions(+), 27 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py b/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py index 4d6b8a65..7878a655 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py @@ -26,6 +26,25 @@ CODE_PATTERN = re.compile(r"^AX-ST-[0-9a-f]{8}$") LIBRARY_SUBDIR = "library" +#: 프로그램 기본 작업본 자리 — 회사 번호 폴더와 안 겹치게 밑줄 이름. +PROGRAM_SUBDIR = "_program" + + +def program_library_dir() -> Path: + """프로그램 기본 **작업본**(시스템 관리자가 화면에서 고친 것) — storage(회사·개인 단과 같은 결). + + git `resources/library_structure`(`TEMPLATE_DIR`)는 **씨앗** — 쓰기 금지(데이터 3층의 초기값 자리). + """ + return Path(STORAGE_BASE_DIR).resolve() / PROGRAM_SUBDIR / LIBRARY_SUBDIR + + +def program_items() -> list[dict[str, Any]]: + """프로그램 기본 = 작업본 + 씨앗 중 **작업본에 없는 코드만**(관리자가 고친 것이 배포에 안 덮임).""" + stored = _items(program_library_dir()) + codes = {item.get("code") for item in stored} + return [*stored, *(item for item in _items(TEMPLATE_DIR) if item.get("code") not in codes)] + + def tier_dirs(company_id: Any, user_id: Any) -> dict[str, Path]: """로그인한 사람의 3단 폴더. 회사가 없으면(시스템 관리자) 개인·회사 단은 없음.""" dirs: dict[str, Path] = {} @@ -34,7 +53,7 @@ def tier_dirs(company_id: Any, user_id: Any) -> dict[str, Path]: if user_id is not None: dirs["personal"] = company / str(user_id) / LIBRARY_SUBDIR dirs["company"] = company / LIBRARY_SUBDIR - dirs["program"] = TEMPLATE_DIR + dirs["program"] = program_library_dir() return dirs @@ -44,6 +63,13 @@ def _items(folder: Path) -> list[dict[str, Any]]: return [json.loads(p.read_text(encoding="utf-8")) for p in sorted(folder.glob("*.json"))] +def _tier_items(dirs: dict[str, Path], tier: str) -> list[dict[str, Any]]: + """단의 항목 — 프로그램 기본은 작업본 + 씨앗, 나머지는 그 폴더.""" + if tier == "program": + return program_items() + return _items(dirs[tier]) if tier in dirs else [] + + def item_kind(item: dict[str, Any]) -> str: """양식형(`form`) · 고정형(`fixed`) — 명세 13장: 칸은 같고 **식이 한 줄이라도 있나**로만 가름. 저장된 `item_kind` 표시는 안 믿음 — 줄과 어긋나면 배지가 거짓이 됨.""" @@ -66,16 +92,15 @@ def list_items(dirs: dict[str, Path], type_id: str) -> list[dict[str, Any]]: } for tier in TIERS if tier in dirs - for item in _items(dirs[tier]) + for item in _tier_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: + if tier not in dirs: return None - return next((item for item in _items(folder) if item.get("code") == code), None) + return next((item for item in _tier_items(dirs, tier) if item.get("code") == code), None) def project_library_dir(project_root: str | Path) -> Path: @@ -98,7 +123,7 @@ def available_templates(project_root: str | Path | None) -> list[dict[str, Any]] ⛔ 개인·회사 단은 안 넣음 — 표를 그릴 때 라이브러리를 매번 읽지 않음(판정 Ⓑ). """ - return [*_items(TEMPLATE_DIR), *project_templates(project_root).values()] + return [*program_items(), *project_templates(project_root).values()] def import_item(project_root: str | Path, item: dict[str, Any], tier: str) -> None: @@ -127,9 +152,13 @@ def _write(folder: Path, item: dict[str, Any]) -> None: (folder / f"{item['code']}.json").write_text(text, encoding="utf-8") -def _personal_code(folder: Path, type_id: Any) -> str: - """개인 단 코드 — 같은 종류가 있으면 그 코드(덮어씀 · 판정 Ⓐ 종류당 하나), 없으면 새로.""" - same = [item for item in _items(folder) if item.get("type_id") == type_id] +def _personal_code(folder: Path, type_id: Any, existing: list[dict[str, Any]] | None = None) -> str: + """그 단 코드 — 같은 종류가 있으면 그 코드(덮어씀 · 판정 Ⓐ 종류당 하나), 없으면 새로. + + 프로그램 기본은 씨앗까지 봄(`existing`) — 씨앗 코드로 덮어써야 작업본이 씨앗을 이김. + """ + items = _items(folder) if existing is None else existing + same = [item for item in items if item.get("type_id") == type_id] return str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}" @@ -145,16 +174,19 @@ def save_personal( template: dict[str, Any], overrides: dict[str, Any] | None, unit_price_rows: list[dict[str, Any]] | None = None, + tier: str = "personal", ) -> str: - """[내 라이브러리에 저장] — 양식 + 고친 식·줄 조합을 **개인 단에 한 벌**로 씀. 코드. + """[내 라이브러리에 저장]·발행 — 양식 + 고친 식·줄 조합을 **그 단에 한 벌**로 씀. 코드. - ⚠ 반대 방향(작업본 → 개인 단)이라 프로젝트는 안 바꿈(브레인 판정 Ⓑ). - ⚠ 개인 단에 같은 종류가 있으면 **그 코드로 덮어씀** — 지금은 종류당 하나(판정 Ⓐ). + ⚠ 반대 방향(작업본 → 라이브러리)이라 프로젝트는 안 바꿈(브레인 판정 Ⓑ). + ⚠ 같은 종류가 있으면 **그 코드로 덮어씀** — 지금은 종류당 하나(판정 Ⓐ). + ⚠ `tier` — 회사(MASTER)·프로그램 기본(SYSTEM_ADMIN) 발행도 같은 모양(2026-09-14 브레인 승인). ⛔ 수동 단가는 안 실음 — 프로젝트의 값이라 양식에 실으면 남의 프로젝트로 감(브레인 판정). """ from B08_Quantity.B08_Quantity_Engine_StructureTemplate import overridden_rows - code = _personal_code(folder, template.get("type_id")) + existing = program_items() if tier == "program" else None + code = _personal_code(folder, template.get("type_id"), existing) # 고친 식·반올림이 곧 이 항목의 값 — 「사용자」 표시와 되돌릴 자리는 떼어 냄. dropped = {"default_formula", "default_rounding"} rows = [ @@ -164,7 +196,7 @@ def save_personal( item = {k: v for k, v in template.items() if k != "imported_from"} if unit_price_rows is not None: item["unit_price"] = {**(item.get("unit_price") or {}), "rows": unit_price_rows} - _write(folder, {**item, "code": code, "library_tier": "personal", "rows": rows}) + _write(folder, {**item, "code": code, "library_tier": tier, "rows": rows}) return code @@ -186,7 +218,7 @@ def pin_program_templates(project_root: str | Path) -> int: """ pinned = project_templates(project_root) count = 0 - for item in _items(TEMPLATE_DIR): + for item in program_items(): if item.get("type_id") and item.get("code") and item["type_id"] not in pinned: import_item(project_root, item, "program") count += 1 diff --git a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py index 149af2f9..1f8c704f 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureTemplate.py @@ -12,7 +12,6 @@ from __future__ import annotations -import json import re from pathlib import Path from typing import Any @@ -29,11 +28,13 @@ OVERRIDES_KEY = "structure_formula_overrides" def load_template(type_id: str) -> dict[str, Any] | None: - """프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬.""" - path = TEMPLATE_DIR / f"{type_id}.json" - if not path.is_file(): - return None - return json.loads(path.read_text(encoding="utf-8")) + """프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬. + + storage 작업본(시스템 관리자가 고친 것)이 먼저, 없으면 git 씨앗(`TEMPLATE_DIR`) — 2026-09-14 관리자 UI. + """ + from B08_Quantity.B08_Quantity_Engine_StructureLibrary import program_items + + return next((item for item in program_items() if item.get("type_id") == type_id), None) def template_of(type_id: str, templates: dict[str, Any] | None) -> dict[str, Any] | None: diff --git a/B08_Quantity/B08_Quantity_Router_StructureSheet.py b/B08_Quantity/B08_Quantity_Router_StructureSheet.py index a6b3cbed..f3128559 100644 --- a/B08_Quantity/B08_Quantity_Router_StructureSheet.py +++ b/B08_Quantity/B08_Quantity_Router_StructureSheet.py @@ -316,6 +316,7 @@ async def get_structure_library( "status": "success", "items": await asyncio.to_thread(list_items, dirs, type_id), "current": {"code": current.get("code"), "imported_from": current.get("imported_from")}, + "can_publish": {tier: _publish_dir(session, tier) is not None for tier in PUBLISH}, } ) @@ -394,15 +395,32 @@ def _no_personal() -> JSONResponse: ) +#: 발행 단 — 회사는 마스터·시스템 관리자, 프로그램 기본은 시스템 관리자(2026-09-14 브레인 승인). +PUBLISH = ("company", "program") + + +def _publish_dir(session: dict[str, Any], tier: str) -> Path | None: + from B08_Quantity.B08_Quantity_Engine_StructureLibrary import program_library_dir, tier_dirs + + admin = session.get("role") == "SYSTEM_ADMIN" + if tier == "program": + return program_library_dir() if admin else None + if tier == "company" and (admin or session.get("is_master")): + return tier_dirs(session.get("company_id"), None).get("company") + return _personal_dir(session) if tier == "personal" else None + + class LibrarySaveRequest(BaseModel): - """[내 라이브러리에 저장] — 어느 장의 양식을 쓸지.""" + """[내 라이브러리에 저장]·[발행] — 어느 장의 양식을 어느 단에 쓸지(같은 모양).""" model_config = ConfigDict(extra="forbid") sheet_key: str + tier: Literal["personal", "company", "program"] = "personal" @router.put("/{project_id}/quantity/structure-sheets/library/personal") +@router.put("/{project_id}/quantity/structure-sheets/library/publish") async def put_structure_library_personal( project_id: UUID, payload: LibrarySaveRequest, @@ -420,9 +438,13 @@ async def put_structure_library_personal( from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import ROWS_KEY from common_util.common_util_project_settings import quantity_settings - folder = _personal_dir(session) + folder = _publish_dir(session, payload.tier) if folder is None: - return _no_personal() + if payload.tier == "personal": + return _no_personal() + return JSONResponse( + status_code=403, content={"status": "error", "message": "발행 권한이 없습니다."} + ) project_root = await _project_root(project_id) if project_root is None: return _not_found() @@ -439,7 +461,7 @@ async def put_structure_library_personal( overrides = (settings.get(OVERRIDES_KEY) or {}).get(type_id) # ⛔ 수동 단가(`MANUAL_KEY`)는 안 넘김 — 프로젝트의 값(브레인 판정). rows = (settings.get(ROWS_KEY) or {}).get(type_id) - code = await asyncio.to_thread(save_personal, folder, template, overrides, rows) + code = await asyncio.to_thread(save_personal, folder, template, overrides, rows, payload.tier) return JSONResponse(content={"status": "success", "code": code, "edited": len(overrides or {})}) diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts index ae5aa259..e3bad0ed 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts @@ -129,7 +129,10 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement { load.disabled = true; status.textContent = "목록 받는 중…"; try { - const { items } = await readJson<{ items: LibraryItem[] }>( + const { items, can_publish: canPublish } = await readJson<{ + items: LibraryItem[]; + can_publish?: { company: boolean; program: boolean }; + }>( await fetch(libraryUrl(projectId, `?type_id=${encodeURIComponent(typeId)}`), { credentials: "include", }), @@ -146,6 +149,9 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement { ); list.hidden = take.hidden = clone.hidden = items.length === 0; syncClone(); + // 발행 단추 — 서버가 준 권한대로만 보임(회사 = 마스터 · 기본 = 시스템 관리자). + toCompany.hidden = !canPublish?.company; + toProgram.hidden = !canPublish?.program; status.textContent = items.length ? afterLoad : "가져올 항목이 없음"; afterLoad = ""; } catch (error) { @@ -232,9 +238,32 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement { ); return result.deleted ? "내 라이브러리에서 지움" : "지울 내 양식이 없음"; }); + // 발행 — [내 라이브러리에 저장]과 같은 모양으로 회사·프로그램 기본 단에(2026-09-14 브레인 승인). + const publish = (label: string, tier: "company" | "program"): HTMLButtonElement => { + const button = personal(label, async () => { + if (isDirty()) return "저장 안 한 식이 있음 — [식 저장] 먼저"; + const whom = tier === "program" ? "모든 회사가 쓰는 프로그램 기본" : "우리 회사 라이브러리"; + if (!window.confirm(`이 장의 양식과 고친 식을 ${whom}에 발행 — 같은 종류가 있으면 덮어씀`)) { + return ""; + } + await readJson( + await fetch(libraryUrl(projectId, "/publish"), { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sheet_key: sheetKey, tier }), + }), + ); + return `${whom}에 발행함`; + }); + button.hidden = true; + return button; + }; + const toCompany = publish("회사 라이브러리에 발행", "company"); + const toProgram = publish("프로그램 기본으로 발행", "program"); const mine = document.createElement("div"); mine.className = "b08-sheet__actions"; - mine.append(save, remove); + mine.append(save, remove, toCompany, toProgram); panel.append(title, scope, load, list, take, clone, mine, status); // 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장). diff --git a/resources/tester/test_b08_structure_library.py b/resources/tester/test_b08_structure_library.py index ce90943e..ef5af844 100644 --- a/resources/tester/test_b08_structure_library.py +++ b/resources/tester/test_b08_structure_library.py @@ -200,6 +200,67 @@ def test_내_라이브러리에_저장은_고친_식을_담고_프로젝트는_ 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_회사_없는_사람은_개인_단을_못_쓴다(client: TestClient) -> None: client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1} sheet = _sheet(client) @@ -277,6 +338,9 @@ def test_복제_단추는_개인_단_항목에선_안_눌린다() -> None: ) 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: