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
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
"""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 []
|
||||
]
|
||||
Reference in New Issue
Block a user