feat(b08): 내 라이브러리에 저장·내 것 지우기 — 개인 단만 씀, 프로젝트 작업본은 그대로

- [내 라이브러리에 저장]: 그 장의 양식 + 고친 식을 로그인한 사람 개인 단에 한 벌로 씀(같은 종류는 그 코드로 덮어씀) · 저장 안 한 식이 있으면 막음
- [내 것 지우기]: 개인 단의 그 종류만 지움 · 박힌 양식·고친 식은 안 바뀜
- 기본 찰쌓기 양식에 pum_edition 2026-01-01(명세 17장 ③)
- 결함: 식 저장 뒤 다시 그린 표에 「저장 안 한 식」 표시가 남아 헛물음이 뜨던 것 — 장을 그릴 때 비움
- 시험 2개 추가 · 전체 1531 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-13 18:32:31 +09:00
co-authored by Claude Opus 5
parent 8adeae769c
commit 13b29d620f
6 changed files with 239 additions and 18 deletions
@@ -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:
"""[확정] 때 — 아직 안 박힌 종류는 **그 시점 프로그램 기본**을 박음. 박은 수.
@@ -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:
"""구조물도(표준도) **장 목록 + 원단위 수량표**.
+11 -6
View File
@@ -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
@@ -38,17 +38,21 @@ async function readJson<T>(response: Response): Promise<T> {
return payload;
}
/**
* 가져오기 칸. `confirmTake` 가 거짓이면 안 가져옴 — 새 양식과 줄 차례가 안 맞을 수 있어 서버가
* 그 종류의 고친 식을 비우므로, 고친 식·저장 안 한 식이 있으면 부르는 쪽이 먼저 물음.
*/
export function buildLibraryPanel(
projectId: string,
typeId: string,
currentCode: string | null,
confirmTake: () => boolean,
onImported: (notes: string[]) => Promise<void>,
): HTMLElement {
export interface LibraryPanelOptions {
projectId: string;
sheetKey: string;
typeId: string;
currentCode: string | null;
/** 거짓이면 안 가져옴 — 서버가 그 종류의 고친 식을 비우므로 부르는 쪽이 먼저 물음. */
confirmTake: () => boolean;
/** 저장 안 한 식이 있으면 [내 라이브러리에 저장]을 막음 — 저장된 식만 개인 단으로 감. */
isDirty: () => boolean;
onImported: (notes: string[]) => Promise<void>;
}
/** 가져오기 · [내 라이브러리에 저장] · [내 것 지우기] 칸. 개인 단 두 단추는 프로젝트를 안 바꿈. */
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<string>): 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;
}
@@ -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",
@@ -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"]