feat(M02): 일위대가 라이브러리 화면 — 좌측 「일위대가 조합」 컨테이너 · 뿌리 셋 나무 · 조합 편집 · M01 컨테이너 뺌(PLAN 27-2b · 27-2c 화면)

- 좌측 「일위대가 조합」(구조물 조합 위) → 메인 칸 왼쪽 뿌리 셋 + 조합 나무(폴더 [구분, 상세구분] 깊이 2 · TreePanel 공용) · 오른쪽 목록 · 상세(옛 M01 조합 화면 옮김 · Labor_Edit)
- 나무 오른쪽 클릭: 폴더 더하기(2 단까지) · 조합 더하기(그 폴더 채운 빈 조합) · 이름 · 옮기기 · 지우기 · 조합 이름 바꾸기 서버 길 더함
- 해시 open=일위대가 로 진입 · 다른 뿌리를 누르면 그 뿌리 일위대가로
- 읽기만 뿌리는 저장 · 지우기 · 로직 더하기 없음
- TreePanel 에 maxDepth · item(부르는 말 · 새 항목 화면) 선택지 더함(구조물 라이브러리는 그대로)
- 조합 고르기(면적산출 옆 목록 · 일위대가 고르기)가 이 층이 쓸 수 있는 세 층 조합 · 알약은 /api/m02/combos/key/{키}/inputs
- M01 좌측 · 페이지에서 일위대가 조합 컨테이너 뺌
- 시험 test_m02_labor_tree(새) · test_m02_combo_layers 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
This commit is contained in:
2026-09-30 13:35:38 +09:00
co-authored by Claude Opus 5.5
parent ca946ebbf1
commit c8686ee3ad
17 changed files with 572 additions and 268 deletions
@@ -216,14 +216,17 @@ def _clean(row: Any, key: str, was: dict | None = None) -> dict[str, Any]:
return one
def _check(doc: dict, one: dict) -> None:
"""이름 길이 · 겹침 · 담은 로직(없는 키 · 두 번 · 빈 조합) — 막히면 422."""
def _name_errors(doc: dict, one: dict) -> list[str]:
name = one["이름"]
errors = [f"이름이 너무 김 — {mcb.MAX_이름}자까지"] if len(name) > mcb.MAX_이름 else []
if any(r.get("키") != one["키"] and str(r.get("이름", "")).strip() == name for r in doc["줄"]):
errors.append(f"「{name}」 이름의 조합이 이미 있음 — 다른 이름으로")
errors += mcb.check_row(m01.loaded()[1], one)
if errors:
return errors
def _check(doc: dict, one: dict) -> None:
"""이름 길이 · 겹침 · 담은 로직(없는 키 · 두 번 · 빈 조합) — 막히면 422."""
if errors := _name_errors(doc, one) + mcb.check_row(m01.loaded()[1], one):
raise store.StoreError(422, {"errors": errors})
@@ -316,7 +319,7 @@ def _fresh(name: Any) -> str:
def _apply(doc: dict, body: dict) -> tuple[dict, list[str]]:
"""나무 고침 한 번 → (새 표, 지운 조합 키). 조합 더하기 · 고치기는 `create` · `edit` 몫."""
"""나무 고침 한 번 → (새 표, 지운 조합 키) · 조합 더하기 · 담은 로직은 `create` · `edit` 몫."""
op, key = body.get("op"), body.get("키")
folders, rows = folders_of(doc), [dict(r) for r in doc.get("줄") or []]
gone: list[str] = []
@@ -330,6 +333,8 @@ def _apply(doc: dict, body: dict) -> tuple[dict, list[str]]:
if made in folders:
raise store.StoreError(409, f"이미 있는 폴더 「{' › '.join(made)}」")
folders.append(made)
elif op == "rename" and key:
_find({"줄": rows}, str(key))["이름"] = _fresh(body.get("이름"))
elif op == "move" and key:
_set_path(_find({"줄": rows}, str(key)), target)
elif op == "delete" and key:
@@ -360,14 +365,18 @@ def _apply(doc: dict, body: dict) -> tuple[dict, list[str]]:
def edit_tree(body: dict) -> dict[str, Any]:
"""폴더 더하기 · 이름 바꾸기 · 옮기기(폴더 · 조합) · 지우기(폴더 = 안 조합까지 · 조합)
→ 새 목록 · 쓰이는 조합이 들면 409 · 판 다르면 409."""
"""폴더 더하기 · 이름 바꾸기(폴더 · 조합) · 옮기기(폴더 · 조합) · 지우기(폴더 = 안 조합까지 ·
조합) → 새 목록 · 쓰이는 조합이 들면 409 · 판 다르면 409 · 조합 이름 겹침 422."""
doc, have = _load()
if have != (body.get("판") or ""):
raise store.StoreError(409, {"stale": [FILE], "판": have})
new, gone = _apply(doc, body)
_refuse_used(gone)
if heads()[-1] != MASTER:
if body.get("op") == "rename" and body.get("키"):
one = _find(new, str(body["키"]))
if errors := _name_errors(new, one):
raise store.StoreError(422, {"errors": errors})
_save(new, have)
return {**listing(), "지운조합": gone}
was = {str(r["키"]): r for r in doc["줄"]}