diff --git a/B08_Quantity/B08_Quantity_Router_StmateLibrary.py b/B08_Quantity/B08_Quantity_Router_StmateLibrary.py index 096407cd..604fba01 100644 --- a/B08_Quantity/B08_Quantity_Router_StmateLibrary.py +++ b/B08_Quantity/B08_Quantity_Router_StmateLibrary.py @@ -6,15 +6,18 @@ ② [넣기] 같은 파일 + 고른 호표 차례 + 우리 구조물 종류 → 서버가 **파일을 다시 읽어** 개인 단에 씀. ⚠ 브라우저가 보낸 줄을 받아 적지 않음(CLAUDE.md 5장) · 개인 단만(판정 Ⓗ) · 종류당 하나라 같은 종류 내 것은 덮어씀(판정 Ⓐ) · 종류는 사용자가 고름 — 이름으로 자동으로 안 붙임(판정 Ⓒ). +③ [복제해서 내 것으로](PLAN 4장) — 기본·회사 단 항목을 개인 단에 베낌. 프로젝트 작업본은 안 바꿈. + (구조물도 라우터가 700줄에 닿아 개인 단으로 넣는 창구를 이 파일에 모음.) """ import asyncio import io -from typing import Any +from typing import Any, Literal from uuid import UUID from fastapi import APIRouter, Depends, File, Form, UploadFile from fastapi.responses import JSONResponse +from pydantic import BaseModel, ConfigDict, Field from common_util.common_util_auth import verify_session @@ -110,3 +113,39 @@ async def save_stmate_recipe( "note": item["note"], } ) + + +class LibraryCloneRequest(BaseModel): + """베낄 항목 — 단과 코드(이름은 겹칠 수 있음). 개인 단 것은 이미 내 것이라 안 받음.""" + + model_config = ConfigDict(extra="forbid") + + type_id: str = Field(min_length=1, max_length=100) + tier: Literal["company", "program"] + code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$") + + +@router.put("/{project_id}/quantity/structure-sheets/library/clone") +async def clone_library_item( + project_id: UUID, + payload: LibraryCloneRequest, + session: dict[str, Any] = Depends(verify_session), +) -> JSONResponse: + """③ 기본·회사 항목을 **로그인한 사람 개인 단**에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]할 길을 한 번에.""" + from B08_Quantity.B08_Quantity_Engine_StructureLibrary import ( + find_item, + save_item_personal, + tier_dirs, + ) + + dirs = tier_dirs(session.get("company_id"), session.get("user_id")) + folder = dirs.get("personal") + if folder is None: + return _error(403, "개인 라이브러리는 회사에 속한 사용자만 씁니다.") + 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 _error(404, "베낄 항목을 찾지 못했습니다.") + body = {k: v for k, v in item.items() if k not in ("code", "imported_from", "library_tier")} + body["cloned_from"] = {"tier": payload.tier, "code": payload.code, "name": item.get("name")} + code = await asyncio.to_thread(save_item_personal, folder, body) + return JSONResponse(content={"status": "success", "code": code, "name": item.get("name")}) diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts index bc2a91a0..ae5aa259 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts @@ -80,6 +80,49 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement { take.className = "b08-spec__save"; take.textContent = "가져오기"; take.hidden = true; + // 복제 — 기본·회사 항목을 개인 단에 베낌(PLAN 4장). 개인 단 것은 이미 내 것이라 안 눌림. + const clone = document.createElement("button"); + clone.type = "button"; + clone.className = "b08-quantity__tab"; + clone.textContent = "복제해서 내 것으로"; + clone.hidden = true; + /** 목록을 다시 받은 뒤 보일 한 줄 — 받는 동안 상태 줄이 지워져 복제 결과가 사라지지 않게. */ + let afterLoad = ""; + const syncClone = (): void => { + const [tier] = list.value.split("|"); + clone.disabled = tier === "personal"; + }; + list.addEventListener("change", syncClone); + clone.addEventListener("click", () => { + const [tier, code] = list.value.split("|"); + const label = list.selectedOptions[0]?.textContent ?? ""; + if (!tier || !code || tier === "personal") return; + if ( + !window.confirm( + `「${label}」을 내 라이브러리로 베낌 — 같은 종류 내 것이 있으면 덮어씀 · 프로젝트 값은 안 바뀜`, + ) + ) { + return; + } + void (async () => { + clone.disabled = true; + try { + await readJson( + await fetch(libraryUrl(projectId, "/clone"), { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type_id: typeId, tier, code }), + }), + ); + afterLoad = "내 라이브러리에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]"; + load.click(); + } catch (error) { + status.textContent = error instanceof Error ? error.message : "복제 못함"; + syncClone(); + } + })(); + }); load.addEventListener("click", () => { void (async () => { @@ -101,8 +144,10 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement { return option; }), ); - list.hidden = take.hidden = items.length === 0; - status.textContent = items.length ? "" : "가져올 항목이 없음"; + list.hidden = take.hidden = clone.hidden = items.length === 0; + syncClone(); + status.textContent = items.length ? afterLoad : "가져올 항목이 없음"; + afterLoad = ""; } catch (error) { status.textContent = error instanceof Error ? error.message : "목록을 받지 못함"; } finally { @@ -191,7 +236,7 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement { mine.className = "b08-sheet__actions"; mine.append(save, remove); - panel.append(title, scope, load, list, take, mine, status); + panel.append(title, scope, load, list, take, clone, mine, status); // 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장). panel.append( buildStmatePanel({ diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts index f61944a7..548dc2cd 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts @@ -233,7 +233,16 @@ export function unitPriceEditor(options: EditorOptions): HTMLElement { if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); const items = payload.items ?? []; results.replaceChildren( - ...(items.length ? items.map(choice) : [el("span", "b08-grid__caption", "없음")]), + ...(items.length + ? items.map(choice) + : [ + // 후보 0 — 조용히 막히지 않게 다음 길을 적음(갈래 바꿔 찾기 · 수동 단가는 미확정으로 셈). + el( + "span", + "b08-grid__caption", + "단가표에 후보 없음 — 낱말·갈래(품셈/자원)를 바꿔 찾거나, 이 줄에 수동 단가를 넣을 것(미확정으로 셈)", + ), + ]), ); } catch (error) { results.replaceChildren( diff --git a/resources/tester/test_b08_stmate_fixed_chain.py b/resources/tester/test_b08_stmate_fixed_chain.py index d7d96d63..6bce200f 100644 --- a/resources/tester/test_b08_stmate_fixed_chain.py +++ b/resources/tester/test_b08_stmate_fixed_chain.py @@ -105,6 +105,8 @@ def test_찾기는_코드_없는_줄_이름으로_후보만_띄운다() -> None: encoding="utf-8" ) assert "query.value = row.name" in ui and "!row.ref_code && !row.work_item_code" in ui + # 후보 0 이면 다음 길(갈래 바꾸기 · 수동 단가)을 적음 — 조용히 「없음」으로 막히지 않게(2026-09-14 브레인) + assert "수동 단가를 넣을 것" in ui def test_사용자가_코드를_고르면_금액이_선다() -> None: diff --git a/resources/tester/test_b08_structure_library.py b/resources/tester/test_b08_structure_library.py index 5a6acf0c..ce90943e 100644 --- a/resources/tester/test_b08_structure_library.py +++ b/resources/tester/test_b08_structure_library.py @@ -216,6 +216,41 @@ def test_확정_때_기본_양식을_박는다(project: Path) -> None: 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" @@ -236,6 +271,14 @@ def test_항목마다_양식형_고정형_종류가_붙는다(tmp_path: Path) -> 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 + + def test_코드_모양이_아니면_박지_않는다(project: Path) -> None: with pytest.raises(ValueError): library_module.import_item(project, {"type_id": "masonry_wet", "code": "../x"}, "personal")