Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Tree.py
T
eomsangdonandClaude Opus 5.5 b5114ec17f feat(M02): 집계 항목 폴더 구조 서버 — 경로 · 일련번호 · 나무 고침 길(PLAN 26-2a)
- 항목 경로 = 폴더 이름 배열(깊이 자유) · 집계표 열 머리(빈 칸 건너뜀)가 곧 경로 · 열에 경로를 같이 적음 · 머리 줄 수(층)는 가장 깊은 경로에 맞춤(넷 밑으로는 안 줄임) · 빈 폴더는 집계표 폴더 칸(M02_MasterTemplete_Tree)
- 표 저장과 나무 고침 한 줄기 — 나무 고침은 집계표 문서를 고쳐 같은 집계표 저장(store.write)을 탐(번호 · SS 장부 · 세 파일 · 입력 연결 파일 지우기가 함께)
- 나무 고침 길 POST /api/m02/layers/{layer}/tree(add-folder · add-item · rename · move · delete · 판 409 · 422 · 404 · 층 권한) · 읽기 GET 같은 길 {판, 폴더, items}
- 번호 = 새 항목은 자리 뜻 없는 일련번호(있던 번호 · 지운 번호 가장 큰 수 + 1) · 있던 번호 그대로
- SS 장부 = M02_MasterTemplete_Book 로 떼어 냄(Store 543 → 478) · 줄에 경로 · 이름 · 옛 종류 · 분류1~3 = 경로 앞 넷 · 경로 없는 옛 줄은 읽을 때 채움 · 옮기는 함수 migrate_paths
- 목록 길 /api/m02/items 줄에 경로 · 이름(경로 이음) · 자재 조합 · 갈라 내기 이름도 경로 이음
- 마스터 집계표 · 장부를 경로 모양으로 옮김(51 항목 · 키 · 번호 그대로 · 빈 글 머리는 null)
- 시험 test_m02_tree 5(옛 51 항목 · 옮기기 두 번 같음 · 깊이 1 · 2 · 5 · 이름 · 옮기기 · 지우기 · 마지막 항목 · 409 · 403 · 표 저장 한 줄기) · 번호 시험을 일련번호 규칙으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-30 07:50:20 +09:00

304 lines
12 KiB
Python

"""M02 집계 항목 **폴더 구조**(PLAN 26-1 · 26-2a) — 항목 경로 · 집계표 맞춤 · 나무 고침.
항목 = 구조물집계표의 구조물 열(꼴=수) 하나 · 경로 = 폴더 이름 배열(깊이 자유 · 마지막이 항목 이름)
· 이름 = 경로를 이은 글. 자료의 자리는 집계표 한 파일 — 열 `머리`(제목행)가 곧 경로(빈 칸은 건너뜀)
이고, 열에 `경로` 를 같이 적어 둠. 머리 줄 수(`층`)는 가장 깊은 경로에 맞춤(넷보다 적게는 안 줄임 —
옛 코드가 종류 · 분류1~3 넷을 읽음). 빈 폴더는 집계표 `폴더`(경로 목록)에 적음.
나무 고침(폴더 더하기 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기)은 집계표 문서를 고쳐
돌려줄 뿐 — 저장은 부르는 쪽이 집계표 저장(`store.write`)으로 해서 표 저장과 한 줄기
(번호 · SS 장부 · 세 파일 · 입력 연결 파일 지우기)를 탐. 이 파일은 저장소를 부르지 않음(순수 함수).
"""
from __future__ import annotations
import copy
import re
from typing import Any
MIN_LEVELS = 4 # 옛 약속 — 종류 · 분류1 · 분류2 · 분류3
_OLD_NAMES = ("종류", "분류1", "분류2", "분류3")
OPS = ("add-folder", "add-item", "rename", "move", "delete")
MAX_NAME = 60
_BAD = re.compile(r"[\x00-\x1f]")
class TreeError(Exception):
def __init__(self, status: int, detail: Any):
super().__init__(detail)
self.status, self.detail = status, detail
# ── 경로 읽기 ─────────────────────────────────────────
def _clean(parts: Any) -> list[str]:
if not isinstance(parts, list):
return []
return [str(p).strip() for p in parts if p is not None and str(p).strip()]
def path_of(col: dict[str, Any]) -> list[str]:
"""열의 경로 — 머리(제목행)의 빈 칸을 건너뛴 이름들 · 머리가 없으면 `경로`."""
heads = col.get("머리")
return _clean(heads) if isinstance(heads, list) else _clean(col.get("경로"))
def row_path(row: dict[str, Any]) -> list[str]:
"""SS 장부 줄의 경로 — `경로` 가 없던 옛 줄은 종류 · 분류1~3 에서(빈 분류 건너뜀)."""
got = _clean(row.get("경로"))
return got or _clean([row.get(k) for k in _OLD_NAMES])
def name_of(path: list[str]) -> str:
return " ".join(path)
def old_fields(path: list[str]) -> dict[str, str | None]:
"""옛 칸(종류 · 분류1~3) — 경로 앞 넷(다른 코드가 읽음)."""
padded = [*path, *[None] * MIN_LEVELS][:MIN_LEVELS]
return dict(zip(_OLD_NAMES, padded))
def is_item(col: Any) -> bool:
return isinstance(col, dict) and bool(col.get("id")) and col.get("꼴") == "수"
def _items(doc: dict[str, Any]) -> list[dict[str, Any]]:
return [c for c in doc.get("열") or [] if is_item(c)]
# ── 집계표 맞춤 ───────────────────────────────────────
def _trimmed(heads: Any) -> list[Any]:
out = list(heads) if isinstance(heads, list) else []
while out and (out[-1] is None or not str(out[-1]).strip()):
out.pop()
return out
def normalize_table(doc: dict[str, Any]) -> dict[str, Any]:
"""저장 앞 맞춤 — 항목 열: `경로` = 머리의 빈 칸 건너뛴 이름 · 머리 = 경로를 머리 줄 수만큼
채움 · 머리 줄 수(`층`) = 가장 깊은 경로(넷보다 적게는 안 줄임) ·
`폴더` = 경로 목록(겹침 · 빈 것 뺌).
글 열(NO · 측점 · 비고)은 머리 줄 수만 맞춤."""
out = copy.deepcopy(doc)
cols = [c for c in out.get("열") or [] if isinstance(c, dict)]
depth = max(
[MIN_LEVELS, *(len(path_of(c)) for c in cols if is_item(c))]
+ [len(_trimmed(c.get("머리"))) for c in cols if not is_item(c)]
)
names = [str(n) for n in (out.get("층") or [])][:depth]
names += [_OLD_NAMES[i] if i < MIN_LEVELS else f"분류{i}" for i in range(len(names), depth)]
out["층"] = names
for col in cols:
if is_item(col):
path = path_of(col)
col["경로"] = path
col["머리"] = [*path, *[None] * depth][:depth]
else:
col["머리"] = [*_trimmed(col.get("머리")), *[None] * depth][:depth]
folders: list[list[str]] = []
for raw in out.get("폴더") or []:
path = _clean(raw)
if path and path not in folders:
folders.append(path)
out["폴더"] = folders
return out
def folders_of(doc: dict[str, Any]) -> list[list[str]]:
"""나무의 폴더 전부 — 적어 둔 빈 폴더 + 항목 경로의 앞마디(차례 = 표 차례)."""
out: list[list[str]] = []
for col in _items(doc):
path = path_of(col)
for n in range(1, len(path)):
if path[:n] not in out:
out.append(path[:n])
for path in doc.get("폴더") or []:
path = _clean(path)
if path and path not in out:
out.append(path)
return out
# ── 나무 고침 ─────────────────────────────────────────
def _name(value: Any, what: str = "이름") -> str:
text = str(value).strip() if isinstance(value, str) else ""
if not text or len(text) > MAX_NAME or _BAD.search(text):
raise TreeError(422, f"「{what}」 — 빈 글 · {MAX_NAME}자 넘음 · 조절 문자는 못 씀")
return text
def _path(value: Any, what: str = "경로") -> list[str]:
if value is None:
return []
if not isinstance(value, list):
raise TreeError(422, f"「{what}」 이 폴더 이름 목록이 아님")
return [_name(v, what) for v in value]
def _find(doc: dict[str, Any], key: str | None, ids: dict[str, str]) -> dict[str, Any]:
"""SS 키(또는 열 id)로 항목 열 — 없으면 404."""
cid = ids.get(str(key), str(key)) if key else None
col = next((c for c in _items(doc) if c["id"] == cid), None)
if col is None:
raise TreeError(404, f"없는 항목 「{key}」")
return col
def _item_paths(doc: dict[str, Any], skip: list[dict[str, Any]] | None = None) -> list[list[str]]:
return [path_of(c) for c in _items(doc) if not skip or all(c is not s for s in skip)]
def _taken_item(doc: dict[str, Any], path: list[str], skip=None) -> None:
if path in _item_paths(doc, skip):
raise TreeError(409, f"이미 있는 항목 「{name_of(path)}」")
def _set_path(col: dict[str, Any], path: list[str]) -> None:
col["경로"] = path
col["머리"] = list(path) # 머리 줄 수는 저장 맞춤(`normalize_table`)이 채움
def _common(a: list[str], b: list[str]) -> int:
n = 0
while n < min(len(a), len(b)) and a[n] == b[n]:
n += 1
return n
def _place(doc: dict[str, Any], col: dict[str, Any]) -> None:
"""열을 가장 가까운 형제 뒤에 끼움(같은 앞마디가 긴 항목 중 마지막) — 제목행 병합이 이어지게.
항목이 없으면 글 열 가운데 마지막 앞(비고 앞) · 그것도 없으면 끝."""
cols = doc.setdefault("열", [])
path = path_of(col)
best, at = -1, None
for i, other in enumerate(cols):
if is_item(other):
share = _common(path, path_of(other))
if share >= best:
best, at = share, i
if at is None:
at = len(cols) - 2 if len(cols) >= 2 else len(cols) - 1
cols.insert(at + 1, col)
def _fresh_id(doc: dict[str, Any]) -> str:
used = {c.get("id") for c in doc.get("열") or [] if isinstance(c, dict)}
n = 1
while f"item{n}" in used:
n += 1
return f"item{n}"
def _under(path: list[str], folder: list[str]) -> bool:
return path[: len(folder)] == folder
def _folder(doc: dict[str, Any], where: list[str], what: str) -> None:
"""고칠 폴더가 있는지 — 경로가 비었으면 422 · 없으면 404."""
if not where:
raise TreeError(422, f"{what} 폴더 「경로」 가 없음")
if where not in folders_of(doc) and not any(_under(p, where) for p in _item_paths(doc)):
raise TreeError(404, f"없는 폴더 「{name_of(where)}」")
def apply(
doc: dict[str, Any], body: dict[str, Any], ids: dict[str, str] | None = None
) -> tuple[dict[str, Any], dict[str, Any]]:
"""나무 고침 하나 → (새 집계표 문서, 알림). `ids` = SS 키 → 열 id(항목을 키로 부를 때).
본문 `{op, 경로, 이름?, 새경로?, 키?, 단위?}` — 26-1 약속 모양 · 판은 부르는 쪽이 봄."""
out = copy.deepcopy(doc)
ids = ids or {}
op = body.get("op")
if op not in OPS:
raise TreeError(422, f"모르는 고침 「{op}」 — {' · '.join(OPS)}")
where = _path(body.get("경로"))
key = body.get("키")
note: dict[str, Any] = {"op": op}
folders = out.setdefault("폴더", [])
if op == "add-folder":
made = [*where, _name(body.get("이름"))]
if made in folders_of(out) or made in _item_paths(out):
raise TreeError(409, f"이미 있는 폴더 · 항목 「{name_of(made)}」")
folders.append(made)
note["경로"] = made
elif op == "add-item":
made = [*where, _name(body.get("이름"))]
_taken_item(out, made)
col: dict[str, Any] = {"id": _fresh_id(out), "단위": body.get("단위"), "꼴": "수"}
col.update({"일위대가": None, "자재조합": None})
_set_path(col, made)
_place(out, col)
note.update({"경로": made, "열": col["id"]})
elif op == "rename":
new = _name(body.get("이름"))
if key:
col = _find(out, key, ids)
path = path_of(col)
moved = [*path[:-1], new]
_taken_item(out, moved, [col])
_set_path(col, moved)
note["경로"] = moved
else:
_folder(out, where, "바꿀")
moved = [*where[:-1], new]
if moved != where and (
moved in folders_of(out) or any(_under(p, moved) for p in _item_paths(out))
):
raise TreeError(409, f"이미 있는 폴더 「{name_of(moved)}」")
_move_folder(out, where, moved)
note["경로"] = moved
elif op == "move":
to = _path(body.get("새경로"), "새경로")
if key:
col = _find(out, key, ids)
moved = [*to, path_of(col)[-1]]
_taken_item(out, moved, [col])
out["열"] = [c for c in out["열"] if c is not col]
_set_path(col, moved)
_place(out, col)
note["경로"] = moved
else:
_folder(out, where, "옮길")
if _under(to, where):
raise TreeError(422, "폴더를 제 안으로 옮길 수 없음")
moved = [*to, where[-1]]
if moved in folders_of(out) or any(_under(p, moved) for p in _item_paths(out)):
raise TreeError(409, f"이미 있는 폴더 「{name_of(moved)}」")
_move_folder(out, where, moved, replace=True)
note["경로"] = moved
else: # delete
if key:
gone = [_find(out, key, ids)]
else:
_folder(out, where, "지울")
gone = [c for c in _items(out) if _under(path_of(c), where)]
out["폴더"] = [f for f in folders if not _under(_clean(f), where)]
out["열"] = [c for c in out["열"] if all(c is not g for g in gone)]
note["지운항목"] = [g["id"] for g in gone]
return out, note
def _move_folder(
doc: dict[str, Any], old: list[str], new: list[str], replace: bool = False
) -> None:
"""폴더 `old` 아래 항목 · 빈 폴더의 앞마디를 `new` 로 — 이름 바꾸기는 제자리 ·
옮기기(`replace`)는 새 형제 곁으로 열 차례도 옮김."""
moving = [c for c in _items(doc) if _under(path_of(c), old)]
if replace:
doc["열"] = [c for c in doc["열"] if all(c is not m for m in moving)]
for col in moving:
_set_path(col, [*new, *path_of(col)[len(old) :]])
if replace:
_place(doc, col)
doc["폴더"] = [
[*new, *_clean(f)[len(old) :]] if _under(_clean(f), old) else f
for f in doc.get("폴더") or []
]