feat(M02): 일위대가 조합 층 사이 복사 · 여러 개 옮기기 · 지우기 서버(PLAN 30-3)

- POST /api/m02/layers/{layer}/combos/paste — 원본 층(읽기) 조합 · 폴더 → 이 층(쓰기) 경로 폴더에 새 키(UA · UC · UP) · 본뜬키 = 원본(마스터는 UA 만) · 같은 이름 「이름 (2)」 · 폴더는 안 조합 · 빈 폴더까지 · 깊이 2 넘으면 422
- POST /api/m02/layers/{layer}/combos/tree-many — 나무 고침 여럿 차례대로 · 저장 한 번 · 하나라도 막히면 안 씀
- 새 M02_MasterTemplete_Combo_Copy · Combo_Store edit_tree 를 edit_tree_many · commit 한 길로
- Labor_Api 에 pasteCombos · editComboTreeMany
- 시험 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 18:53:57 +09:00
co-authored by Claude Opus 5.5
parent 7ea8b75162
commit c0a76b844d
5 changed files with 272 additions and 7 deletions
@@ -0,0 +1,98 @@
"""일위대가 조합 층 사이 복사(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}
@@ -367,25 +367,44 @@ def _apply(doc: dict, body: dict) -> tuple[dict, list[str]]:
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})
new, gone = _apply(doc, body)
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:
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})
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 {**listing(), "지운조합": gone}
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)
return {**listing(), "지운조합": gone}
def _write_master_folders(folders: list[list[str]], version: str) -> None:
@@ -203,6 +203,30 @@ export const editComboTree = (
scope: LaborScope = SYSTEM_SCOPE,
): Promise<ComboList & { 지운조합: string[] }> => request(at(scope, "/tree"), "POST", body);
/** 여러 개 옮기기 · 지우기(PLAN 30-3) — 나무 고침 여럿을 차례대로 · 저장 한 번 */
export const editComboTreeMany = (
ops: Omit<ComboTreeEdit, "판">[],
판: string,
scope: LaborScope = SYSTEM_SCOPE,
): Promise<ComboList & { 지운조합: string[] }> =>
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<ComboList & { 새키: { 원본: string; 새키: string }[] }> =>
request(at(scope, "/paste"), "POST", {
from_layer: from.layer,
from_owner: from.owner,
...pick,
경로,
판,
});
/** 보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 */
export const previewCombo = (
combo: Combo | string,
@@ -19,6 +19,7 @@ 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
@@ -41,6 +42,25 @@ class ComboTreeBody(BaseModel):
판: 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` 는 로직마다."""
@@ -148,6 +168,30 @@ def post_tree(
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,
+80
View File
@@ -309,3 +309,83 @@ def test_담을_로직_읽기는_로그인이면_됨(world):
assert user.get("/api/m02/logic-lib/logic", params={"key": "GF999999"}).status_code == 404
assert user.get("/api/m02/logic-lib/subs").json()["subs"]
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):
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) == []