From f8d024fc28172cb38984a7eb711bf0c5e9a2af87 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:44:31 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor(M02):=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EC=A1=B0=ED=95=A9=20=EB=82=98=EB=AC=B4=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EA=B8=B8=20=EC=A7=80=EC=9B=80=20=E2=80=94?= =?UTF-8?q?=20/tree=20=C2=B7=20/tree-many=20=C2=B7=20/paste(39-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Combo_Store 나무 고침 함수(_apply · edit_tree(_many) · commit · _write_master_folders · _set_path) 지움 · 목록 폴더는 읽기만 - Combo_Copy.py(층 사이 붙여넣기) 지움 · Labor_Api 나무 · 붙여넣기 함수 지움 - 옛 나무 · 붙여넣기 시험을 「길 없음」 검사로 갈음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../M02_MasterTemplete_Combo_Copy.py | 98 ---------- .../M02_MasterTemplete_Combo_Store.py | 135 +------------- .../M02_MasterTemplete_Labor_Api.ts | 39 ---- .../M02_MasterTemplete_Router_Combos.py | 66 ------- resources/tester/test_m02_combo_layers.py | 170 +----------------- 5 files changed, 10 insertions(+), 498 deletions(-) delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Combo_Copy.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Copy.py b/M02_MasterTemplete/M02_MasterTemplete_Combo_Copy.py deleted file mode 100644 index 85b5d499f..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Copy.py +++ /dev/null @@ -1,98 +0,0 @@ -"""일위대가 조합 층 사이 복사(PLAN 30-3) — 읽을 수 있는 층의 조합 · 폴더를 쓸 수 있는 층에 붙여넣기. - -정함(30장): 복사본 = 대상 층의 새 키(마스터 UA · 회사 UC · 개인 UP) · 본뜬키 = 원본 키 · -같은 이름이면 「이름 (2)」 · 담은 로직 그대로 · 폴더 복사는 안의 조합 · 빈 폴더까지. -붙는 자리 = `경로`(대상 폴더 · 깊이 2 안) — 고른 조합은 그 폴더에 · 고른 폴더는 그 아래 한 단으로. -마스터에 붙이면 M01 저장 길(대장 UA) · 본뜬키는 UA 만(마스터 틀) — 다른 층 원본이면 비움. -권한은 라우터 몫(원본 층 읽기 · 대상 층 쓰기) · 대상 층 = `store.at(대상)`. -""" - -from __future__ import annotations - -from typing import Any - -from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos -from M02_MasterTemplete import M02_MasterTemplete_Store as store - - -def _free_name(name: str, taken: set[str]) -> str: - out, n = name, 2 - while out in taken: - out, n = f"{name} ({n})", n + 1 - taken.add(out) - return out - - -def _paths(value: Any, what: str) -> list[list[str]]: - if not isinstance(value, list) or not all(isinstance(p, list) for p in value): - raise store.StoreError(422, f"「{what}」 이 폴더 경로 목록이 아님") - return [[str(x) for x in p] for p in value] - - -def picked(source: dict, keys: list[str], folders: list[list[str]], where: list[str]): - """(붙일 조합 [(원본 줄, 새 경로)], 새 빈 폴더) — 깊이 2 넘으면 422 · 없는 키 · 폴더 404.""" - have = combos.folders_of(source) - rows = {str(r.get("키")): r for r in source.get("줄") or []} - out: dict[str, tuple[dict, list[str]]] = {} - made: list[list[str]] = [] - for folder in folders: - if folder not in have: - raise store.StoreError(404, f"없는 폴더 「{' › '.join(folder)}」") - cut = len(folder) - 1 # 고른 폴더부터 대상 아래로 - made += [where + f[cut:] for f in have if f[: len(folder)] == folder] - for key, row in rows.items(): - path = combos.path_of(row) - if path[: len(folder)] == folder: - out[key] = (row, where + path[cut:]) - for key in keys: - if key not in rows: - raise store.StoreError(404, f"없는 조합 「{key}」") - out.setdefault(key, (rows[key], list(where))) - if any(len(p) > combos.DEPTH for p in [*made, *(p for _, p in out.values())]): - raise store.StoreError(422, f"폴더는 {combos.DEPTH} 단까지 — 더 얕은 폴더에 붙여 넣음") - return list(out.values()), made - - -def paste(source: dict, body: dict) -> dict[str, Any]: - """`source` = 원본 층 표(부르는 쪽이 원본 층에서 읽음) · `body` = `{키?, 폴더?, 경로?, 판}` → - `{…대상 층 목록, 새키: [원본 → 새 키]}`.""" - keys = [str(k) for k in body.get("키") or []] - folders = _paths(body.get("폴더") or [], "폴더") - where = [str(x) for x in body.get("경로") or []] - if not keys and not folders: - raise store.StoreError(422, "붙여 넣을 조합 · 폴더가 없음") - doc, have = combos._load() - if have != (body.get("판") or ""): - raise store.StoreError(409, {"stale": [combos.FILE], "판": have}) - if where and where not in combos.folders_of(doc): - raise store.StoreError(404, f"없는 폴더 「{' › '.join(where)}」") - items, made = picked(source, keys, folders, where) - taken = {str(r.get("이름") or "").strip() for r in doc["줄"]} - master = combos.heads()[-1] == combos.MASTER - rows = [] - for row, path in items: - one = {k: row.get(k) for k in combos.mcb.COLS if k != "키"} - one["이름"] = _free_name(str(row.get("이름") or "").strip(), taken) - one["본뜬키"] = str(row.get("키")) - if master and not one["본뜬키"].startswith(combos.MASTER): - one["본뜬키"] = None # 마스터 틀은 UA 계보만 - combos._set_path(one, path) - rows.append(one) - folders_after = combos.folders_of({**doc, "폴더": [*combos.folders_of(doc), *made]}) - if master: - new = [combos.create(one)["키"] for one in rows] # M01 저장 길(검사 · 대장) — 하나씩 - _, version = combos._load() - combos._write_master_folders(folders_after, version) - else: - number = int(doc.get("다음") or 1) - fixed = [] - for at, one in enumerate(rows): - fixed.append(combos._clean(one, f"{combos.heads()[-1]}{number + at:06d}")) - new_doc = {**doc, "다음": number + len(fixed), "폴더": folders_after} - new_doc["줄"] = [*doc["줄"], *fixed] - for one in fixed: - combos._check(new_doc, one) - combos._save(new_doc, have) - new = [one["키"] for one in fixed] - pairs = [{"원본": str(row.get("키")), "새키": key} for (row, _), key in zip(items, new)] - return {**combos.listing(), "새키": pairs} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py index 409fb154c..ef0abae0d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py @@ -7,13 +7,12 @@ 개인 `storage/{회사}/{사용자}/templates/일위대가조합.json` · 키 `UP{사용자}-######` 회사 · 개인 파일 = 마스터와 같은 머리 · 열 한 벌 + 머리 `다음`(키 장부 · 지운 키는 다시 안 씀). 지금 층 = `store.at(층 폴더)`(`store.root()`) — 키 머리도 그 자리에서 셈 · 권한은 라우터 몫. -나무 = 폴더 [구분] · [구분, 상세구분] 깊이 2 · 머리 `폴더` = 폴더 전부(빈 폴더 포함). +목록 `폴더` = [구분] · [구분, 상세구분](읽기만 · 나무 고침 · 붙여넣기 길은 39-2 에서 지움). """ from __future__ import annotations import json -import os import re from datetime import date from pathlib import Path @@ -127,18 +126,13 @@ def _find(doc: dict, key: str) -> dict[str, Any]: return row -# ── 나무 폴더 ───────────────────────────────────────────────────────── +# ── 폴더(읽기만) ─────────────────────────────────────────────────────── def path_of(row: dict) -> list[str]: """조합의 폴더 — [구분] · [구분, 상세구분] · 구분이 없으면 뿌리.""" a, b = (str(row.get(k) or "").strip() for k in ("구분", "상세구분")) return [a, b] if a and b else [a] if a else [] -def _set_path(row: dict, path: list[str]) -> None: - row["구분"] = path[0] if path else None - row["상세구분"] = path[1] if len(path) > 1 else None - - def folders_of(doc: dict) -> list[list[str]]: """폴더 전부 — 적어 둔 것 + 조합 자리 · 부모까지.""" out: list[list[str]] = [] @@ -312,128 +306,3 @@ def delete(key: str, version: str) -> dict[str, Any]: _find(doc, key) _refuse_used([key]) return {"판": _save({**doc, "줄": [r for r in doc["줄"] if r.get("키") != key]}, version)} - - -# ── 나무 고침 ───────────────────────────────────────────────────────── -def _moved(path: list[str], old: list[str], new: list[str]) -> list[str]: - return new + path[len(old) :] if path[: len(old)] == old else path - - -def _folder(folders: list[list[str]], path: list[str] | None, what: str) -> list[str]: - path = [str(x) for x in path or []] - if not path or path not in folders: - raise store.StoreError(404, f"{what} 폴더 「{' › '.join(path)}」 없음") - return path - - -def _fresh(name: Any) -> str: - text = str(name or "").strip() - if not text or "›" in text or len(text) > mcb.MAX_이름: - raise store.StoreError(422, "폴더 이름 — 빈 이름 · 「›」 · 너무 긴 이름은 못 씀") - return text - - -def _apply(doc: dict, body: dict) -> tuple[dict, list[str]]: - """나무 고침 한 번 → (새 표, 지운 조합 키) · 조합 더하기 · 담은 로직은 `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] = [] - target = [str(x) for x in body.get("새경로") or []] - if op in ("move", "add-folder") and target and target not in folders: - raise store.StoreError(404, f"없는 폴더 「{' › '.join(target)}」") - if op == "add-folder": - made = [*[str(x) for x in body.get("경로") or []], _fresh(body.get("이름"))] - if len(made) > DEPTH or (made[:-1] and made[:-1] not in folders): - raise store.StoreError(422, f"폴더는 {DEPTH} 단까지 · 있는 폴더 아래에만") - 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: - _find({"줄": rows}, str(key)) - gone = [str(key)] - elif op in ("rename", "move", "delete"): - old = _folder(folders, body.get("경로"), "고칠") - if op == "delete": - gone = [str(r["키"]) for r in rows if path_of(r)[: len(old)] == old] - folders = [f for f in folders if f[: len(old)] != old] - else: - new = [*old[:-1], _fresh(body.get("이름"))] if op == "rename" else [*target, old[-1]] - deepest = max(len(f) for f in folders if f[: len(old)] == old) - if new[: len(old)] == old or deepest - len(old) + len(new) > DEPTH: - raise store.StoreError(422, f"폴더는 {DEPTH} 단까지 · 제 안으로 못 옮김") - if new in folders: - raise store.StoreError(409, f"이미 있는 폴더 「{' › '.join(new)}」") - folders = [_moved(f, old, new) for f in folders] - for r in rows: - if path_of(r)[: len(old)] == old: - _set_path(r, _moved(path_of(r), old, new)) - else: - raise store.StoreError( - 422, f"모르는 나무 고침 「{op}」 — add-folder · rename · move · delete" - ) - rows = [r for r in rows if str(r.get("키")) not in gone] - return {**doc, "폴더": folders, "줄": rows}, gone - - -def edit_tree(body: dict) -> dict[str, Any]: - """폴더 더하기 · 이름 바꾸기(폴더 · 조합) · 옮기기(폴더 · 조합) · 지우기(폴더 = 안 조합까지 · - 조합) → 새 목록 · 쓰이는 조합이 들면 409 · 판 다르면 409 · 조합 이름 겹침 422.""" - return edit_tree_many({"판": body.get("판"), "ops": [body]}) - - -def edit_tree_many(body: dict) -> dict[str, Any]: - """나무 고침 여럿을 한 번에(PLAN 30-3 · 여러 개 옮기기 · 지우기) — `{판, ops: [고침 …]}` · - 차례대로 적용 · 하나라도 막히면 아무것도 안 씀 · 저장 한 번.""" - doc, have = _load() - if have != (body.get("판") or ""): - raise store.StoreError(409, {"stale": [FILE], "판": have}) - ops = body.get("ops") - if not isinstance(ops, list) or not ops: - raise store.StoreError(422, "나무 고침 목록(ops)이 비었음") - new, gone = doc, [] - for op in ops: - new, got = _apply(new, op if isinstance(op, dict) else {}) - gone += got - _refuse_used(gone) - renamed = [str(op["키"]) for op in ops if op.get("op") == "rename" and op.get("키")] - commit(doc, have, new, gone, renamed) - return {**listing(), "지운조합": gone} - - -def commit(doc: dict, have: str, new: dict, gone: list[str], checked: list[str] = ()) -> None: - """고친 표 한 벌 저장 — 회사 · 개인 = 파일 한 번(`checked` 조합은 이름 검사) · - 마스터 = 줄은 M01 저장 길 · 머리 `폴더` 따로.""" - if heads()[-1] != MASTER: - for key in checked: - if key in {str(r.get("키")) for r in new["줄"]}: - if errors := _name_errors(new, _find(new, key)): - raise store.StoreError(422, {"errors": errors}) - _save(new, have) - return - was = {str(r["키"]): r for r in doc["줄"]} - changes = [{"op": "delete", "key": k} for k in gone] + [ - {"op": "edit", "key": str(r["키"]), "row": r} for r in new["줄"] if was[str(r["키"])] != r - ] - version = _m01(combo_store._write, changes, have)["version"] if changes else have - _write_master_folders(new["폴더"], version) - - -def _write_master_folders(folders: list[list[str]], version: str) -> None: - """마스터 조합 파일 머리 `폴더` — 줄은 안 건드림(줄 고침은 M01 저장 길).""" - path = Path(m01.FOLDER) / FILE - with m01._LOCK: - doc, have = m01.read(FILE) - if have != version: - raise store.StoreError(409, {"stale": [FILE], "판": have}) - if doc.get("폴더") == folders: - return - head = {k: v for k, v in doc.items() if k != "줄"} - text = m01.dump({**head, "폴더": folders, "줄": doc.get("줄") or []}) - temporary = path.with_name(f".{FILE}.tmp") - temporary.write_text(text, encoding="utf-8", newline="\n") - os.replace(temporary, path) - m01.cache.stale([FILE]) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 4dfb87e84..64dd9784c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -95,15 +95,6 @@ export interface ComboLayerList extends ComboList { layer: LaborScope["layer"]; owner: string | null; } -export interface ComboTreeEdit { - op: "add-folder" | "rename" | "move" | "delete"; - 경로?: string[]; - 이름?: string; - 새경로?: string[]; - 키?: string; - 판: string; -} - async function request(url: string, method = "GET", body?: unknown): Promise { const response = await fetch(`${API_BASE_URL}/m02${url}`, { method, @@ -197,36 +188,6 @@ export const deleteCombo = ( scope: LaborScope = SYSTEM_SCOPE, ): Promise => request(at(scope, `/${enc(key)}`, { 판: version }), "DELETE"); -/** 나무 고침 — 폴더 더하기 · 이름 · 옮기기(폴더 · 조합) · 지우기 → 새 목록 */ -export const editComboTree = ( - body: ComboTreeEdit, - scope: LaborScope = SYSTEM_SCOPE, -): Promise => request(at(scope, "/tree"), "POST", body); - -/** 여러 개 옮기기 · 지우기(PLAN 30-3) — 나무 고침 여럿을 차례대로 · 저장 한 번 */ -export const editComboTreeMany = ( - ops: Omit[], - 판: string, - scope: LaborScope = SYSTEM_SCOPE, -): Promise => - request(at(scope, "/tree-many"), "POST", { 판, ops }); - -/** 층 사이 복사(PLAN 30-3) — `from` 층의 조합 · 폴더를 이 층 `경로` 폴더에 새 키로 */ -export const pasteCombos = ( - from: LaborScope, - pick: { 키?: string[]; 폴더?: string[][] }, - 경로: string[], - 판: string, - scope: LaborScope = SYSTEM_SCOPE, -): Promise => - request(at(scope, "/paste"), "POST", { - from_layer: from.layer, - from_owner: from.owner, - ...pick, - 경로, - 판, - }); - /** 보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 */ export const previewCombo = ( combo: Combo | string, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index ad8fb8ad4..293ae2054 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -19,7 +19,6 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Layer_Access as access -from M02_MasterTemplete import M02_MasterTemplete_Combo_Copy as combo_copy from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root @@ -31,36 +30,6 @@ class ComboBody(BaseModel): 판: str | None = None # 새로 만들 때 빈 글 = 지금 판 · 고칠 때 어긋나면 409 -class ComboTreeBody(BaseModel): - """`{op: add-folder|rename|move|delete, 경로?, 이름?, 새경로?, 키?, 판}` — 폴더 깊이 2.""" - - op: str - 경로: list[str] | None = None - 이름: str | None = None - 새경로: list[str] | None = None - 키: str | None = None - 판: str - - -class TreeManyBody(BaseModel): - """여러 개 옮기기 · 지우기(PLAN 30-3) — `ops` = 나무 고침(`ComboTreeBody` 모양) · 저장 한 번.""" - - 판: str - ops: list[dict[str, Any]] - - -class PasteBody(BaseModel): - """층 사이 복사(PLAN 30-3) — 원본 층(`from_layer` · `from_owner`)의 조합 `키` · 폴더 `폴더` → - 이 층 `경로` 폴더에 새 키로. `판` = 이 층 조합 표 판.""" - - from_layer: Layer - from_owner: str | None = None - 키: list[str] = [] - 폴더: list[list[str]] = [] - 경로: list[str] = [] - 판: str = "" - - class PreviewBody(BaseModel): """저장 전 미리 보기 — `combo` 는 조합 줄 통째 또는 그 층 키 · `inputs` 는 로직마다.""" @@ -166,41 +135,6 @@ def delete_practice(판: str = "", session: Session = Depends(verify_session)) - return _call(_root(session, "system", None, write=True), combos.drop_practice, 판) -@router.post("/layers/{layer}/combos/tree") -def post_tree( - layer: Layer, - body: ComboTreeBody, - owner: str | None = None, - session: Session = Depends(verify_session), -): - folder = _root(session, layer, owner, write=True) - return _call(folder, combos.edit_tree, body.model_dump()) - - -@router.post("/layers/{layer}/combos/tree-many") -def post_tree_many( - layer: Layer, - body: TreeManyBody, - owner: str | None = None, - session: Session = Depends(verify_session), -): - folder = _root(session, layer, owner, write=True) - return _call(folder, combos.edit_tree_many, body.model_dump()) - - -@router.post("/layers/{layer}/combos/paste") -def post_paste( - layer: Layer, - body: PasteBody, - owner: str | None = None, - session: Session = Depends(verify_session), -): - """원본 층 읽기 권한 · 이 층 쓰기 권한(M02_Layer_Access) — 마스터 붙이기 = 시스템 관리자.""" - source = _call(_root(session, body.from_layer, body.from_owner), lambda: combos._load()[0]) - folder = _root(session, layer, owner, write=True) - return _call(folder, combo_copy.paste, source, body.model_dump()) - - @router.post("/layers/{layer}/combos/preview") def post_preview( layer: Layer, diff --git a/resources/tester/test_m02_combo_layers.py b/resources/tester/test_m02_combo_layers.py index 7796b80d3..66b83195a 100644 --- a/resources/tester/test_m02_combo_layers.py +++ b/resources/tester/test_m02_combo_layers.py @@ -4,8 +4,7 @@ - 키 = 회사 `UC{회사}-######` · 개인 `UP{사용자}-######` · 지운 번호 다시 안 줌 · 마스터 UA. - 권한 = 층 규칙(마스터 쓰기 시스템 관리자 · 회사 쓰기 회사 관리자 · 개인 쓰기 본인 · 읽기 규칙). - 검사 = 마스터와 같음(빈 조합 · 이름 겹침 · 없는 본뜬키) · 판 어긋남 409. - - 나무 = 폴더 [구분, 상세구분] 깊이 2 · 더하기 · 이름 · 옮기기 · 지우기(쓰는 조합 409). - - 마스터 나무 고침 = 줄은 M01 저장 길 · 빈 폴더는 머리 `폴더` · 틀 검사 그대로 통과. + - 나무 고침 · 층 사이 붙여넣기 길은 없음(PLAN 39-2 · 일위대가는 구조물 열마다). - 고르기(use-combo) · 갈라 내기 = 세 층 조합 · 남의 층 것 403 · 갈라 낸 새 조합은 그 층에. - 조합 이름 · 알약 · 스냅샷 원본이 회사 · 개인 조합도 읽음. 정본(마스터 · 마스터 템플릿)은 읽기만 · 쓰기는 임시 storage 와 마스터 사본. @@ -78,13 +77,6 @@ def _list(client: TestClient, layer: str, **params) -> dict: return res.json() -def _tree(client: TestClient, layer: str, want: int = 200, **body) -> dict: - body.setdefault("판", _list(client, layer)["판"]) - res = client.post(URL.format(layer) + "/tree", json=body) - assert res.status_code == want, res.text - return res.json() - - # ── 키 · 자리 ───────────────────────────────────────────────────────── def test_회사_개인_키_장부_지운_번호는_다시_안_줌(world): boss = _as(world, "ADMIN") @@ -135,60 +127,7 @@ def test_검사_빈_조합_이름_겹침_본뜬키_판(world): assert res.status_code == 200 and res.json()["combo"]["담은로직"][0]["로직"] == B -# ── 나무 ───────────────────────────────────────────────────────────── -def test_나무_폴더_깊이_2_이름_옮기기_지우기(world): - me = _as(world) - key = _new(me, "personal", _combo("돌쌓기 한 벌"))["키"] - _tree(me, "personal", op="add-folder", 경로=[], 이름="돌쌓기") - _tree(me, "personal", op="add-folder", 경로=["돌쌓기"], 이름="찰쌓기") - _tree(me, "personal", 422, op="add-folder", 경로=["돌쌓기", "찰쌓기"], 이름="셋째") - _tree(me, "personal", 409, op="add-folder", 경로=[], 이름="돌쌓기") - _tree(me, "personal", 404, op="move", 키=key, 새경로=["없음"]) - got = _tree(me, "personal", op="move", 키=key, 새경로=["돌쌓기", "찰쌓기"]) - row = next(r for r in got["줄"] if r["키"] == key) - assert (row["구분"], row["상세구분"]) == ("돌쌓기", "찰쌓기") - got = _tree(me, "personal", op="rename", 경로=["돌쌓기"], 이름="석축") - assert got["폴더"] == [["석축"], ["석축", "찰쌓기"]] - assert next(r for r in got["줄"] if r["키"] == key)["구분"] == "석축" - _tree(me, "personal", 422, op="move", 경로=["석축"], 새경로=["석축", "찰쌓기"]) - got = _tree(me, "personal", op="move", 경로=["석축", "찰쌓기"], 새경로=[]) - assert got["폴더"] == [["석축"], ["찰쌓기"]] # 빈 「석축」 은 남음 - row = next(r for r in got["줄"] if r["키"] == key) - assert (row["구분"], row["상세구분"]) == ("찰쌓기", None) - _tree(me, "personal", 409, op="add-folder", 경로=[], 이름="석축", 판="낡은판") - _new(me, "personal", _combo("다른 것")) - got = _tree(me, "personal", op="rename", 키=key, 이름="찰쌓기 한 벌") - assert next(r for r in got["줄"] if r["키"] == key)["이름"] == "찰쌓기 한 벌" - assert ( - "이미 있음" - in _tree(me, "personal", 422, op="rename", 키=key, 이름="다른 것")["detail"]["errors"][0] - ) - other = next(r["키"] for r in _list(me, "personal")["줄"] if r["이름"] == "다른 것") - _tree(me, "personal", op="delete", 키=other) - got = _tree(me, "personal", op="delete", 경로=["찰쌓기"]) - assert got["지운조합"] == [key] and got["줄"] == [] and got["폴더"] == [["석축"]] - - -def test_쓰는_조합이_든_폴더는_못_지움_이름을_알림(world): - me = _as(world) - key = _new(me, "personal", _combo("쓰는 것", 구분="석축"))["키"] - tree = me.post( - "/api/m02/layers/personal/tree", - json={"op": "add-item", "경로": ["옹벽"], "이름": "찰쌓기", "단위": "m", "판": _판(me)}, - ).json() - column = tree["알림"]["열"] - use = me.post( - f"/api/m02/layers/personal/structures/{column}/use-combo", - json={"종류": "일위대가", "키": key}, - ) - assert use.status_code == 200, use.text - got = _tree(me, "personal", 409, op="delete", 경로=["석축"]) - assert got["detail"]["users"] == ["옹벽 찰쌓기"] - res = me.delete(f"{URL.format('personal')}/{key}", params={"판": _list(me, "personal")["판"]}) - assert res.status_code == 409 - assert [r["키"] for r in _list(me, "personal")["줄"]] == [key] # 그대로 남음 - - +# ── 마스터 사본 ─────────────────────────────────────────────────────── def _판(client: TestClient) -> str: return client.get("/api/m02/layers/personal/tree").json()["판"] @@ -208,28 +147,6 @@ def master_copy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: return folder -def test_마스터_나무는_M01_저장_길_빈_폴더는_머리_틀_검사_통과(world, master_copy): - admin = _as(world, "SYSTEM_ADMIN") - before = _list(admin, "system") - first = before["줄"][0]["키"] - got = _tree(admin, "system", op="rename", 키=first, 이름="이름 바꾼 견본") - assert next(r for r in got["줄"] if r["키"] == first)["이름"] == "이름 바꾼 견본" - before = _list(admin, "system") - old = next(r["구분"] for r in before["줄"] if r["구분"]) # 정본 폴더 이름에 기대지 않음 - assert [old] in before["폴더"] - _tree(admin, "system", op="add-folder", 경로=[], 이름="빈 폴더") - got = _tree(admin, "system", op="rename", 경로=[old], 이름="시험 폴더") - assert ["시험 폴더"] in got["폴더"] and ["빈 폴더"] in got["폴더"] - assert [old] not in got["폴더"] - doc = json.loads((master_copy / combos.FILE).read_text(encoding="utf-8")) - assert ["빈 폴더"] in doc["폴더"] - assert sum(r["구분"] == "시험 폴더" for r in doc["줄"]) == len( - [r for r in before["줄"] if r["구분"] == old] - ) - assert m01.cm.check_form(combos.FILE, doc) == [] # 마스터 틀 그대로 - _tree(_as(world), "system", 403, op="add-folder", 경로=[], 이름="막힘") - - # ── 세 층 고르기 · 갈라 내기 · 읽는 곳 ───────────────────────────────── def test_세_층_목록_고르기_남의_층_403_갈라_낸_것은_그_층에(world): boss = _as(world, "ADMIN") @@ -316,81 +233,10 @@ def test_담을_로직_읽기는_로그인이면_됨(world): assert user.get("/api/m02/logic-lib/auto", params={"key": A}).status_code == 200 -# ── 층 사이 복사 · 여러 개 옮기기(PLAN 30-3) ───────────────────────────── -def _paste(client: TestClient, layer: str, body: dict, want: int = 200, **params) -> dict: - body.setdefault("판", _list(client, layer, **params)["판"] if want != 403 else "") - res = client.post(URL.format(layer) + "/paste", json=body, params=params) - assert res.status_code == want, res.text - return res.json() - - -def test_마스터_조합_셋을_개인에_붙임_새_키_본뜬키_같은_이름은_2(world): - user = _as(world) - master = _list(user, "system")["줄"][:3] - keys = [r["키"] for r in master] - got = _paste(user, "personal", {"from_layer": "system", "키": keys}) - assert [p["새키"] for p in got["새키"]] == ["UP42-000001", "UP42-000002", "UP42-000003"] - rows = {r["키"]: r for r in got["줄"]} - for pair, src in zip(got["새키"], master): - one = rows[pair["새키"]] - assert (one["본뜬키"], one["이름"], one["count"]) == (src["키"], src["이름"], src["count"]) - assert (one["구분"], one["상세구분"]) == (None, None) # 붙인 자리 = 맨 위 - again = _paste(user, "personal", {"from_layer": "system", "키": keys[:1]}) - assert next(r for r in again["줄"] if r["키"] == "UP42-000004")["이름"].endswith(" (2)") - _paste(user, "company", {"from_layer": "system", "키": keys}, 403) # 회사 쓰기 권한 없음 - _paste(user, "personal", {"from_layer": "personal", "from_owner": "8/9", "키": keys}, 403) - _paste(user, "personal", {"from_layer": "system", "키": ["UA999999"]}, 404) - stale = {"from_layer": "system", "키": keys, "판": "낡은판"} - assert user.post(URL.format("personal") + "/paste", json=stale).status_code == 409 - - -def test_회사관리자_개인_폴더를_회사에_붙임_깊이_2_넘으면_422(world): - admin = _as(world, "ADMIN") - for name, where in (("가", ["석축"]), ("나", ["석축", "찰쌓기"])): - _new(admin, "personal", _combo(name, 구분=where[0], 상세구분=(where + [None])[1])) - _tree(admin, "personal", op="add-folder", 경로=["석축"], 이름="빈 곳") - body = {"from_layer": "personal", "폴더": [["석축"]]} - got = _paste(admin, "company", body) - assert [p["새키"] for p in got["새키"]] == ["UC7-000001", "UC7-000002"] - assert sorted(got["폴더"]) == [["석축"], ["석축", "빈 곳"], ["석축", "찰쌓기"]] - by = {r["이름"]: (r["구분"], r["상세구분"], r["본뜬키"]) for r in got["줄"]} - assert by["가"] == ("석축", None, "UP42-000001") and by["나"][:2] == ("석축", "찰쌓기") - _tree(admin, "company", op="add-folder", 경로=[], 이름="다른 곳") - deep = {"from_layer": "personal", "폴더": [["석축"]], "경로": ["다른 곳"]} - assert "2 단까지" in _paste(admin, "company", deep, 422)["detail"] - one = {"from_layer": "personal", "폴더": [["석축", "찰쌓기"]], "경로": ["다른 곳"]} - got = _paste(admin, "company", one) # 한 단 폴더는 들어감 - assert ["다른 곳", "찰쌓기"] in got["폴더"] - - -def test_여러_개_옮기기_지우기_한_번에(world): - me = _as(world) - keys = [_new(me, "personal", _combo(n))["키"] for n in ("가", "나", "다")] - _tree(me, "personal", op="add-folder", 경로=[], 이름="석축") - 판 = _list(me, "personal")["판"] - ops = [{"op": "move", "키": k, "새경로": ["석축"]} for k in keys[:2]] - res = me.post(URL.format("personal") + "/tree-many", json={"판": 판, "ops": ops}) - assert res.status_code == 200, res.text - assert [r["구분"] for r in res.json()["줄"]] == ["석축", "석축", None] - bad = {"판": res.json()["판"], "ops": [*ops[:1], {"op": "move", "키": "UP42-999999"}]} - assert me.post(URL.format("personal") + "/tree-many", json=bad).status_code == 404 - assert [r["구분"] for r in _list(me, "personal")["줄"]] == ["석축", "석축", None] # 안 씀 - 판 = _list(me, "personal")["판"] - ops = [{"op": "delete", "키": k} for k in keys[1:]] - got = me.post(URL.format("personal") + "/tree-many", json={"판": 판, "ops": ops}).json() - assert got["지운조합"] == keys[1:] and [r["키"] for r in got["줄"]] == keys[:1] - stale = {"판": "낡은판", "ops": ops} - assert me.post(URL.format("personal") + "/tree-many", json=stale).status_code == 409 - - -def test_시스템_관리자_개인_조합을_마스터에_붙임_본뜬키는_UA_만(world, master_copy): +# ── 나무 길 없음(PLAN 39-2) ──────────────────────────────────────────── +def test_나무_고침_붙여넣기_길은_없음(world): + """PLAN 39-2 — 일위대가 조합 나무 길(`/tree` · `/tree-many` · `/paste`)을 지움.""" admin = _as(world, "SYSTEM_ADMIN") - up = _new(admin, "personal", _combo("개인 것"))["키"] - before = len(_list(admin, "system")["줄"]) - got = _paste(admin, "system", {"from_layer": "personal", "키": [up]}) - new = got["새키"][0]["새키"] - assert new.startswith("UA") and len(got["줄"]) == before + 1 - row = next(r for r in got["줄"] if r["키"] == new) - assert row["본뜬키"] is None and row["이름"] == "개인 것" - doc = json.loads((master_copy / combos.FILE).read_text(encoding="utf-8")) - assert m01.cm.check_form(combos.FILE, doc) == [] + for tail in ("/tree", "/tree-many", "/paste"): + res = admin.post(URL.format("personal") + tail, json={"판": ""}) + assert res.status_code in (404, 405), tail From 1cf32deacac1be913ce8f6782f84fba059f6b8ac Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:48:04 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(M02):=20=EC=A2=8C=EC=B8=A1=20=EC=9D=BC?= =?UTF-8?q?=EC=9C=84=EB=8C=80=EA=B0=80=20=ED=95=98=EC=9C=84=20=EC=97=B4=20?= =?UTF-8?q?=EC=A4=84=20=E2=80=94=20=E3=80=8CSU-=E2=80=A6=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=E3=80=8D=20=C2=B7=20=EB=88=84=EB=A6=84=20=3D=20onOpen?= =?UTF-8?q?({kind:=20"labor"})(39-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일위대가 하위 = 구조물 열마다 한 줄(번호 = /structures/numbers 일위대가번 · 없으면 「없음」) - Api_Fetch Kind 에 labor · StructureNumbers 에 일위대가번(39-1a 서버 칸) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../M02_MasterTemplete_Api_Fetch.ts | 7 +++++-- M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts | 16 +++++++--------- resources/tester/test_m02_labor_tree.py | 6 +++++- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 6ac97e28e..76960523f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -8,8 +8,9 @@ import { API_BASE_URL } from "@config/config_frontend"; /** 층 이름 — 서버와 같은 글 */ export type Layer = "system" | "company" | "personal" | "project"; /** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure · - * 「면적산출」 = area · 「재료산출」 = material(둘 다 구조물집계표 열마다 한 통합문서) */ -export type Kind = "table" | "drawing" | "structure" | "area" | "material"; + * 「면적산출」 = area · 「재료산출」 = material(둘 다 구조물집계표 열마다 한 통합문서) · + * 「일위대가」 = labor(열마다 문서 · 39장) */ +export type Kind = "table" | "drawing" | "structure" | "area" | "material" | "labor"; export interface TemplateInfo { 종류: Kind; @@ -153,6 +154,8 @@ export interface StructureNumbers { 도번: string; 면적번: string; 재료번: string; + /** 일위대가 `SU-######`(39장) */ + 일위대가번: string; } /** `{열 id: {도번, 면적번, 재료번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 7d10e1875..51c9400c1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -48,17 +48,17 @@ const KINDS: { kind: Kind; label: () => string }[] = [ { kind: "table", label: () => L("M02_KindTable") }, { kind: "drawing", label: () => L("M02_KindDrawing") }, ]; -/** 구조물 열마다 한 줄씩인 하위 목록 — 종류 · 번호 칸(null = 열 줄 아직 없음) · 제목 · - * `all` = 맨 위 「전체 항목」 줄(PLAN 39-2) · 차례 = 구조물 도면 → 일위대가 → 면적산출 */ +/** 구조물 열마다 한 줄씩인 하위 목록 — 종류(편집 칸이 여는 `Kind`) · 번호 칸 · 제목 · + * `all` = 맨 위 「전체 항목」 줄(PLAN 39-2) · 차례 = 구조물 도면 → 일위대가(SU) → 면적산출 */ type ColumnKind = "structure" | "labor" | "area" | "material"; const COLUMN_KINDS: { kind: ColumnKind; - key: keyof StructureNumbers | null; + key: keyof StructureNumbers; label: () => string; all: boolean; }[] = [ { kind: "structure", key: "도번", label: () => L("M02_KindStructure"), all: true }, - { kind: "labor", key: null, label: () => L("M02_KindLabor"), all: true }, + { kind: "labor", key: "일위대가번", label: () => L("M02_KindLabor"), all: true }, { kind: "area", key: "면적번", label: () => L("M02_KindArea"), all: true }, { kind: "material", key: "재료번", label: () => L("M02_KindMaterial"), all: false }, ]; @@ -262,10 +262,8 @@ export function buildSide(opt: SideOptions): SideHandle { /** 하위 줄 — 「전체 항목」 + 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */ function paintRows(body: HTMLElement, k: (typeof COLUMN_KINDS)[number]): void { - const { key } = k; - const head = k.all ? [allRow(k.kind as AllKind)] : []; - if (key === null) return body.replaceChildren(...head); - const kind = k.kind as Kind; // 번호 칸이 있는 하위만 — 일위대가 열 줄은 번호(SU)가 선 뒤 + const { kind, key } = k; + const head = k.all ? [allRow(kind as AllKind)] : []; body.replaceChildren( ...head, ...(columns.length @@ -318,7 +316,7 @@ export function buildSide(opt: SideOptions): SideHandle { opt.onOpen({ layer, projectId: null, kind: info.종류, name: info.이름 }); } - async function selectColumn(col: StructureColumn, kind: Kind): Promise { + async function selectColumn(col: StructureColumn, kind: ColumnKind): Promise { if (!isPicked(kind, col.id) && !(await opt.confirmLeave())) return; // 제목은 편집 화면이 문서 번호를 앞에 붙임(「SD010101 돌기슭막이 …」) opt.onOpen({ layer, projectId: null, kind, name: col.id, label: columnHead(col) }); diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index 0776b560f..e77d15a6d 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -34,7 +34,11 @@ def test_하위_차례_전체_항목_이름(): kinds = side.split("const COLUMN_KINDS")[1].split("];")[0] order = [kinds.index(f'kind: "{k}"') for k in ("structure", "labor", "area")] assert order == sorted(order) # 구조물 도면 → 일위대가 → 면적산출 - assert 'label: () => L("M02_KindLabor"), all: true' in kinds + assert 'kind: "labor", key: "일위대가번", label: () => L("M02_KindLabor"), all: true' in kinds + # 일위대가 줄 = 열마다 「SU-… 이름」 · 누름 = onOpen({kind: "labor", name: 열}) — 같은 selectColumn 길 + assert "opt.onOpen({ layer, projectId: null, kind, name: col.id" in side + api = _text("M02_MasterTemplete_Api_Fetch.ts") + assert '| "labor";' in api and "일위대가번: string;" in api assert "onAll?: (kind: AllKind) => void;" in side # 화면은 ItemList(39-5) · 페이지가 이음 assert "if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return;" in side assert 'return void selectAll("labor");' in side # 해시 open=일위대가