feat(m02): 일위대가 사전조합 나무 = 자유 폴더 — 분류 고정 폴더 걷어 냄(158-1)

- 서버: 층 뿌리 precombo_폴더.json 에 폴더 · 문서 자리만 적음(문서 파일 제자리) · GET/POST /layers/{층}/precombo-tree · 새 폴더 · 이름 바꾸기 · 옮기기 · 지우기(빈 폴더만) · 판 409 · 층 권한 그대로
- 화면: 오른쪽 클릭 [새 폴더] · [여기에 새 사전조합] · [이름 바꾸기] · [지우기] · 빈 자리 = 뿌리 메뉴 · 끌어 옮기기(구조물 조합 부품) · 자리 없는 문서 = 뿌리
- 구분 · 상세구분은 문서 칸 그대로(고르기 창 거름 precomboClasses)
- 시험: test_m02_precombo_folders 새로 · test_m02_precombo_scope 나무 시험 갈음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
This commit is contained in:
2026-10-09 21:30:49 +09:00
co-authored by Claude Opus 5.5
parent c08d321af4
commit 064d9ebae7
8 changed files with 587 additions and 161 deletions
+10 -7
View File
@@ -1,7 +1,7 @@
/* M02 일위대가 사전조합 층(134-3) 헬퍼 — `M02_MasterTemplete_Api_Fetch.ts` 명시 owner 길 ·
* `M02_MasterTemplete_UI_Side_Precombo.ts` `groupRows`(구분 › 상세구분 2단) 를 트랜스파일해 Node 로 돌림 ·
* `M02_MasterTemplete_UI_Side_Precombo.ts` `buildPrecomboFolders`(158-1 자유 폴더) 를 트랜스파일해 Node 로 돌림 ·
* `precomboClasses`(134-5 · 구조물군 › 종류 + 공통) · fetch 대역이 부른 주소를 모음 ·
* `classes`(149-1 분류 뼈대) · stdin = {rows, def?, classes?} · stdout = {urls, groups, classes} JSON. */
* stdin = {rows, def?, view?} · stdout = {urls, tree, classes} JSON. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
@@ -45,10 +45,13 @@ global.fetch = async (url) => {
await api.readTemplate("personal", "precombo", "SC-000001", null, null); // 사전조합 = 내 개인
await api.listTemplates("company", null, "8"); // 사전조합 = 볼 회사
await api.saveTemplate("system", "precombo", "SC-000002", null, "v", {}, null);
const groups = side.groupRows(input.rows, "미분류", input.classes ?? []).map((g) => ({
구분: g.구분,
상세: [...g.상세].map(([k, v]) => [k, v.map((r) => r.이름)]),
}));
const shape = (f) => ({
path: f.path,
count: side.docCount(f),
docs: f.docs.map((r) => r.이름),
folders: f.folders.map(shape),
});
const tree = shape(side.buildPrecomboFolders(input.rows, input.view ?? { 폴더: [], 자리: {} }));
const classes = input.def ? side.precomboClasses(input.def) : null; // 134-5
process.stdout.write(JSON.stringify({ urls, groups, classes }));
process.stdout.write(JSON.stringify({ urls, tree, classes }));
})();
@@ -0,0 +1,155 @@
"""일위대가 사전조합 나무 = 자유 폴더(PLAN 158-1 · `M02_Precombo_Folders`) — 임시 storage.
새 폴더(깊이 자유) · 문서 옮기기(파일은 제자리 · 자리만) · 폴더 이름 바꾸기 ·
옮기기(아래 자리 따라감) · 지우기(빈 폴더만 · 문서 있으면 409 까닭) · 판 409 ·
층(마스터 · 회사 · 개인)마다 따로 · 문서 수(양식 목록) 그대로 · 쓰기 권한 = 층 규칙(마스터 403).
"""
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from config import config_system
from M02_MasterTemplete import M02_Labor_Router as labor_router
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Precombo_Folders as pre
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_m02_structure import write_sample_table
TREE = "/api/m02/layers/{}/precombo-tree"
@pytest.fixture()
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
system = tmp_path / "system"
system.mkdir()
write_sample_table(system)
monkeypatch.setattr(store, "FOLDER", system)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
(tmp_path / "storage").mkdir()
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
app = FastAPI()
app.include_router(labor_router.router)
app.include_router(layers_router.router)
app.dependency_overrides[verify_session] = lambda: {
"user_id": 42,
"company_id": 7,
"role": "USER",
"is_master": False,
}
got = TestClient(app)
for name in ("가", "나", "다"): # 개인 층 문서 셋 — SC-000001~3
body = {"문서": {"이름": name, "구분": "C 사면안정", "상세구분": "옹벽", "담은로직": []}}
assert got.post("/api/m02/layers/personal/library/precombo", json=body).status_code == 200
return got
class Tree:
def __init__(self, client: TestClient, layer: str = "personal"):
self.client, self.url = client, TREE.format(layer)
self.now = client.get(self.url).json()
def do(self, want: int = 200, **body) -> dict:
res = self.client.post(self.url, json={"판": self.now["판"], **body})
assert res.status_code == want, res.text
if want == 200:
self.now = res.json()
assert self.now == {**self.client.get(self.url).json(), "알림": self.now["알림"]}
return res.json()
def _docs(client: TestClient, layer: str = "personal") -> list[str]:
rows = client.get(f"/api/m02/layers/{layer}/templates").json()["양식"]
return [r["이름"] for r in rows if r["종류"] == "precombo"]
def test_처음은_모두_뿌리_새_폴더_깊이_자유(client: TestClient):
t = Tree(client)
assert t.now == {"판": "", "폴더": [], "자리": {}} # 문서 셋 = 뿌리
t.do(op="add-folder", 경로=[], 이름="옹벽")
t.do(op="add-folder", 경로=["옹벽"], 이름="찰쌓기")
t.do(op="add-folder", 경로=["옹벽", "찰쌓기"], 이름="높이 3m")
assert t.now["폴더"] == [["옹벽"], ["옹벽", "찰쌓기"], ["옹벽", "찰쌓기", "높이 3m"]]
assert t.do(409, op="add-folder", 경로=[], 이름="옹벽")["detail"].startswith("이미 있는 폴더")
assert t.do(404, op="add-folder", 경로=["없음"], 이름="x")["detail"].startswith("없는 폴더")
assert t.do(422, op="add-folder", 경로=[], 이름=" ") # 빈 이름
def test_문서_옮기기는_자리만_파일과_문서_수_그대로(client: TestClient, tmp_path: Path):
t = Tree(client)
t.do(op="add-folder", 경로=[], 이름="옹벽")
t.do(op="add-folder", 경로=["옹벽"], 이름="찰쌓기")
t.do(op="move", 키="SC-000001", 새경로=["옹벽", "찰쌓기"])
t.do(op="move", 키="SC-000002", 새경로=["옹벽"])
assert t.now["자리"] == {"SC-000001": ["옹벽", "찰쌓기"], "SC-000002": ["옹벽"]}
t.do(op="move", 키="SC-000002", 새경로=[]) # 뿌리로 = 자리 지움
assert t.now["자리"] == {"SC-000001": ["옹벽", "찰쌓기"]}
assert t.do(404, op="move", 키="SC-000009", 새경로=[])["detail"].startswith("없는 사전조합")
assert t.do(404, op="move", 키="SC-000001", 새경로=["없음"])
personal = layers.personal_dir(7, 42)
assert sorted(p.name for p in (personal / "precombo").iterdir()) == [
"SC-000001.json",
"SC-000002.json",
"SC-000003.json",
]
assert _docs(client) == ["SC-000001", "SC-000002", "SC-000003"] # 자리 파일은 목록에 안 섞임
assert (personal / pre.FILE).is_file()
def test_폴더_이름_바꾸기_옮기기는_아래_자리가_따라감(client: TestClient):
t = Tree(client)
for parent, name in (([], "가"), (["가"], "나"), ([], "다")):
t.do(op="add-folder", 경로=parent, 이름=name)
t.do(op="move", 키="SC-000001", 새경로=["가", "나"])
t.do(op="rename", 경로=["가"], 이름="라")
assert t.now["폴더"] == [["라"], ["라", "나"], ["다"]]
assert t.now["자리"] == {"SC-000001": ["라", "나"]}
assert t.do(409, op="rename", 경로=["라"], 이름="다")
t.do(op="move", 경로=["라", "나"], 새경로=["다"])
assert t.now["자리"] == {"SC-000001": ["다", "나"]} and ["라", "나"] not in t.now["폴더"]
assert t.do(422, op="move", 경로=["다"], 새경로=["다", "나"])["detail"].startswith("폴더를 제")
t.do(op="add-folder", 경로=[], 이름="나")
t.do(op="move", 경로=["다", "나"], 새경로=[]) # 같은 이름 = 「나(1)」
assert t.now["알림"]["바뀐이름"] == ["나(1)"] and t.now["자리"]["SC-000001"] == ["나(1)"]
def test_지우기는_빈_폴더만_문서가_있으면_까닭(client: TestClient):
t = Tree(client)
t.do(op="add-folder", 경로=[], 이름="가")
t.do(op="add-folder", 경로=["가"], 이름="나")
t.do(op="add-folder", 경로=["가", "나"], 이름="다")
t.do(op="move", 키="SC-000003", 새경로=["가", "나", "다"])
assert "사전조합 1개" in t.do(409, op="delete", 경로=["가"])["detail"]
t.do(op="move", 키="SC-000003", 새경로=[])
t.do(op="delete", 경로=["가"]) # 빈 아래 폴더까지
assert t.now["폴더"] == [] and t.now["자리"] == {}
assert _docs(client) == ["SC-000001", "SC-000002", "SC-000003"]
def test_판이_다르면_409_고침_안_함(client: TestClient):
t = Tree(client)
t.do(op="add-folder", 경로=[], 이름="가")
old = t.now["판"]
t.do(op="add-folder", 경로=[], 이름="나")
res = client.post(TREE.format("personal"), json={"판": old, "op": "delete", "경로": ["가"]})
assert res.status_code == 409 and ["가"] in client.get(TREE.format("personal")).json()["폴더"]
def test_층마다_따로_마스터_쓰기는_층_규칙(client: TestClient):
Tree(client).do(op="add-folder", 경로=[], 이름="내 폴더")
company = Tree(client, "company")
assert company.now["폴더"] == [] and Tree(client, "system").now["폴더"] == []
assert Tree(client, "system").do(403, op="add-folder", 경로=[], 이름="x")
assert client.get(TREE.format("personal")).json()["폴더"] == [["내 폴더"]]
def test_지운_문서_자리는_안_보임():
doc = {"폴더": [["가"]], "자리": {"SC-000001": ["가"], "SC-000002": ["가", "나"]}}
got = pre.view_of(doc, "v", {"SC-000001"})
assert got == {"판": "v", "폴더": [["가"]], "자리": {"SC-000001": ["가"]}}
out, _ = pre.apply(doc, {"op": "add-folder", "경로": [], "이름": "다"}, {"SC-000001"})
assert out["자리"] == {"SC-000001": ["가"]} # 고칠 때 걷힘
+44 -54
View File
@@ -1,8 +1,8 @@
"""일위대가 사전조합 층 따로(PLAN 134-3) — 페이지 층과 따로 · 명시 owner · 2단 폴더 나무.
"""일위대가 사전조합 층 따로(PLAN 134-3) — 페이지 층과 따로 · 명시 owner · 자유 폴더 나무(158-1).
- 서버 길: 명시 owner 를 넘기면 그 주인만(null = 자기 층) — 남의 개인을 보는 중에도 사전조합 길에 안 섞임 ·
안 넘기면 지금처럼 페이지 층 주인(Node · fetch 대역)
- 나무: 구분 › 상세구분 2단 · 빈 구분 = 미분류 맨 뒤 · 빈 상세구분 = 구분 바로 아래
- 나무: 자유 폴더(158-1) — 적어 둔 폴더 차례 · 자리 없는 문서 = 뿌리 · 문서 수 = 아래 폴더까지
- 사전조합 층 단추 = 해시를 바꿔 페이지를 다시 세우지 않음(replaceState · 그 컨테이너만 다시 그림) ·
구조물 조합 층 단추는 그대로 해시 이동 · 주소 `pre` 에 남음 · 고름에 owner · canWrite
"""
@@ -20,10 +20,10 @@ def _text(name: str) -> str:
return (M02 / name).read_text(encoding="utf-8")
def _run(rows, classes=None):
def _run(rows, view=None):
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m02_precombo_scope.cjs")],
input=json.dumps({"rows": rows, "classes": classes}, ensure_ascii=False),
input=json.dumps({"rows": rows, "view": view}, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
@@ -40,19 +40,30 @@ def test_명시_owner_길은_페이지_층_주인과_안_섞임():
assert urls[3] == "/api/m02/layers/system/templates/precombo/SC-000002"
def test_나무는_구분_상세구분_2단_미분류는_맨_뒤():
def test_나무는_자유_폴더_자리_없는_문서는_뿌리():
rows = [
{"이름": "SC-000003", "종류": "precombo", "구분": "C 사면안정", "상세구분": "옹벽"},
{"이름": "SC-000001", "종류": "precombo", "구분": "", "상세구분": "07장"},
{"이름": "SC-000002", "종류": "precombo", "구분": "A 횡단배수", "상세구분": ""},
{"이름": "SC-000004", "종류": "precombo", "구분": "C 사면안정", "상세구분": "돌쌓기"},
{"이름": "SC-000005", "종류": "precombo", "구분": "A 횡단배수", "상세구분": "공통"},
{"이름": "SC-000001", "종류": "precombo", "구분": "C 사면안정", "상세구분": "옹벽"},
{"이름": "SC-000002", "종류": "precombo"},
{"이름": "SC-000003", "종류": "precombo"},
]
groups = _run(rows)["groups"]
assert [g["구분"] for g in groups] == ["A 횡단배수", "C 사면안정", "미분류"]
assert groups[0]["상세"] == [["공통", ["SC-000005"]], ["", ["SC-000002"]]]
assert groups[1]["상세"] == [["돌쌓기", ["SC-000004"]], ["옹벽", ["SC-000003"]]]
assert groups[2]["상세"] == [["", ["SC-000001"]]] # 구분 없으면 상세구분도 안 봄
view = {
"폴더": [["옹벽"], ["옹벽", "찰쌓기"], ["빈 폴더"]],
"자리": {"SC-000001": ["옹벽", "찰쌓기"], "SC-000009": ["옹벽"]}, # 없는 문서 자리 = 무시
}
tree = _run(rows, view)["tree"]
assert tree["docs"] == ["SC-000002", "SC-000003"] and tree["count"] == 3 # 구분과 무관
wall, empty = tree["folders"]
assert (wall["path"], wall["count"], wall["docs"]) == (["옹벽"], 1, [])
assert wall["folders"][0] == {
"path": ["옹벽", "찰쌓기"],
"count": 1,
"docs": ["SC-000001"],
"folders": [],
}
assert (empty["path"], empty["count"]) == (["빈 폴더"], 0)
# 폴더를 못 받으면(빈 자리) 모두 뿌리 · 폴더 없음
flat = _run(rows)["tree"]
assert flat["docs"] == ["SC-000001", "SC-000002", "SC-000003"] and flat["folders"] == []
def test_사전조합_층_단추는_페이지를_다시_안_세움():
@@ -88,46 +99,25 @@ def test_고름에_owner_권한_서고_길에_명시_owner():
assert "opt.owner !== undefined" in detail
def test_분류_뼈대_폴더_고정_빈_폴더도_목록_밖_값_뒤_미분류_맨_뒤():
"""149-1 — 분류 차례대로 빈 폴더도 · 목록 밖 지금 값은 뒤 · 미분류는 문서 있을 때만 맨 뒤"""
classes = [
{"name": "A 횡단배수", "details": ["공통", "배관", "BOX암거"]},
{"name": "C 사면안정", "details": ["공통", "돌쌓기"]},
]
rows = [
{"이름": "SC-000001", "종류": "precombo", "구분": "", "상세구분": ""},
{"이름": "SC-000002", "종류": "precombo", "구분": "C 사면안정", "상세구분": "돌쌓기"},
{"이름": "SC-000003", "종류": "precombo", "구분": "건설품셈 공통", "상세구분": "07장"},
{"이름": "SC-000004", "종류": "precombo", "구분": "A 횡단배수", "상세구분": "옛 종류"},
]
groups = _run(rows, classes)["groups"]
assert [g["구분"] for g in groups] == ["A 횡단배수", "C 사면안정", "건설품셈 공통", "미분류"]
assert groups[0]["상세"] == [
["공통", []],
["배관", []],
["BOX암거", []],
["옛 종류", ["SC-000004"]],
]
assert groups[1]["상세"] == [["공통", []], ["돌쌓기", ["SC-000002"]]]
assert groups[3]["상세"] == [["", ["SC-000001"]]]
# 문서 없으면 미분류 폴더 없음 · 뼈대는 그대로
empty = _run([], classes)["groups"]
assert [g["구분"] for g in empty] == ["A 횡단배수", "C 사면안정"]
def test_나무_폴더_문서수_처음_펼침_폴더_메뉴_생성():
def test_분류_고정_폴더_지움_자유_폴더_메뉴_끌어_옮기기():
side = _text("M02_MasterTemplete_UI_Side_Precombo.ts")
assert "groupRows(rows, unsorted, shape)" in side
assert "fetchStructureDef().then(precomboClasses" in side
assert "const count = children.reduce((n, c) => n + docs(c), 0);" in side # 수 = 문서 수
assert "open: toggled.get(id) ?? count > 0," in side # 문서 든 폴더만 펼침
assert "if (!rows.length && !shape.length)" in side # 뼈대 있으면 「없음」 아님
assert 'label: tc("Lib_NewHere")' in side and "run: () => make(...at)" in side
assert 'disabledReason: opt.scope().canWrite ? undefined : tc("ReadOnly")' in side
head = "const make = (구분: string, 상세구분: string): void => {"
make = side.split(head)[1].split("\n };")[0]
assert "const 문서 = { 이름: name, 구분, 상세구분, 단위: null, 담은로직: [] };" in make
assert 'newLibrary(at.layer, "precombo", { 문서 }, at.owner)' in make
for gone in ("groupRows", "fetchStructureDef", "Lib_Unsorted", "shape"):
assert gone not in side, gone
assert "fetchPrecomboTree(at.layer, at.owner)" in side
assert "editPrecomboTree(at.layer, body, view.판, at.owner)" in side
menu = side.split("const menuAt = (")[1].split("\n };")[0]
for label in ('L("M02_TreeAddFolder")', 'tc("Lib_NewHere")', 'L("M02_TreeRename")'):
assert label in menu, label
assert 'L("M02_Delete"), () => void remove(path, count)' in menu
assert 'opt.scope().canWrite ? undefined : tc("ReadOnly")' in menu # 읽기만 층 = 흐림
remove = side.split("const remove = async (")[1].split("\n };")[0]
assert 'if (count) return void showToast(tc("Lib_FolderFull")' in remove # 빈 폴더만
make = side.split("const make = (path: string[]): void => {")[1].split("\n };")[0]
assert 'const 문서 = { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] };' in make
assert 'if (path.length) await edit({ op: "move", 키: got.이름, 새경로: path });' in make
assert "attachTreeDrag({" in side and "if (canWrite) drag.arm(row, node.id);" in side
assert "openTreeMenu(event, menuAt([], 0));" in side # 빈 자리 = 뿌리 메뉴
assert "open: toggled.get(id) ?? count > 0," in side
lib = _text("M02_MasterTemplete_Library_List.ts")
assert "export const nameDialog = (" in lib