From ceec2a99dad3c67e47f93c4af0eb6a32dd2bc489 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:35:23 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(716-4):=20=EC=84=9C=EA=B3=A0=20?= =?UTF-8?q?=EB=82=98=EB=AC=B4=20=ED=8F=B4=EB=8D=94=20=E2=80=94=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=20=EC=9D=B8=EC=9E=90=20=C2=B7=20=EC=95=88=EC=97=90=20?= =?UTF-8?q?=EB=93=A0=20=EA=B2=83=EC=A7=B8=20=EC=A7=80=EC=9B=80=20=C2=B7=20?= =?UTF-8?q?=EC=93=B0=EB=8A=94=20=EA=B3=B3=20409=20=C2=B7=20=EC=97=AC?= =?UTF-8?q?=EB=9F=BF=20=C2=B7=20=EB=B3=B5=EC=82=AC=20=EB=B6=99=EC=97=AC?= =?UTF-8?q?=EB=84=A3=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 `M02_Precombo_Folders` — 종류 인자(precombo · sangeun · 자리 파일 `{종류}_폴더.json`) · 폴더 지우기 = 안의 문서 파일째(모두 검사 뒤 한 번에 · 바깥에서 쓰면 409 + 그 이름 · 지우는 것끼리 참조는 됨) · 문서 지우기 · `대상` 여럿 옮기기 · 지우기 · 붙여넣기(새 키 · 본뜬 = 원본 · 같은 폴더 이름 「(1)」) - 길 `M02_Labor_Router` — `/layers/{층}/{종류}-tree` GET · POST · `/paste`(옛 precombo-tree 주소 그대로) - 화면 새 `UI_Side_Library.ts` — 일위대가 · 단가산출 두 칸이 같이 쓰는 서고 칸(Ctrl · Shift 고름 · 오른쪽 클릭 · Ctrl+C · Ctrl+V · Delete · 지우기 확인 창 · 끌어 옮기기 여럿) · `UI_Side_Precombo.ts` = 일위대가 몫만 넘김 - `Api_Fetch` — 나무 부르기 종류 인자 · `pasteLibraryTree` · `newLibrary` 종류 sangeun - 시험 — 새 `test_716_4_library_folders.py`(임시 storage) · 낡은 것 고침: `test_m02_precombo_folders`(빈 폴더만 지움 → 안에 든 것째 · 「없는 사전조합」 → 「없는 문서」 · FILE → file_of) · `test_m02_precombo_scope` + helper(나무 짜기 · 서고 길 글이 Library.ts 로 옮김) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Labor_Router.py | 51 +- .../M02_MasterTemplete_Api_Fetch.ts | 36 +- .../M02_MasterTemplete_UI_Side_Library.ts | 451 ++++++++++++++++++ .../M02_MasterTemplete_UI_Side_Precombo.ts | 307 +----------- M02_MasterTemplete/M02_Precombo_Folders.py | 194 ++++++-- .../tester/helper_m02_precombo_scope.cjs | 7 +- .../tester/test_716_4_library_folders.py | 119 +++++ resources/tester/test_m02_precombo_folders.py | 15 +- resources/tester/test_m02_precombo_scope.py | 24 +- 9 files changed, 842 insertions(+), 362 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts create mode 100644 resources/tester/test_716_4_library_folders.py diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 5931b2b67..b7126e051 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -3,7 +3,8 @@ 문서 읽기 · 쓰기는 양식 길(`GET/PUT /api/m02/layers/{층}/templates/labor/{열}`) 그대로 · 여기는 저장하지 않는 셈만 + 서고(단가승격 · 사전조합 · 50-3) 새로 · 복사(키는 서버가 줌 · 고침 · 지우기는 양식 길 그대로) + 개인 자재 목록 · 추가(148-2 · 개인 층만 · 품번은 서버) + -사전조합 나무 자유 폴더(158-1 · `M02_Precombo_Folders` · 읽기 = 층 읽기 · 고침 = 층 쓰기) + +서고 나무 자유 폴더(158-1 · 716-4 `{종류}-tree` · `M02_Precombo_Folders` · 읽기 = 층 읽기 · +고침 = 층 쓰기) + 일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기) · 338 = 확인한 줄만 만들기(몸 없음 400 · 옛 즉시 확정 길 없앰) · 별칭 장부 보기 · 고치기 · 올린 뒤 맞춤 뒤 셈(`M02_Hopyo_Fit.ensure`) · 시동 때 우리 지문 데움(`M02_Hopyo_Ours.warm`) · @@ -46,6 +47,9 @@ from M02_MasterTemplete.M02_MasterTemplete_Router_Items import ( _call, _root, ) +from M02_MasterTemplete.M02_MasterTemplete_Router_Items import ( + PasteBody as TreePasteBody, +) router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Labor"]) @@ -130,25 +134,44 @@ def new_library( return _call(folder, _copy, kind, doc, (got.layer, got.owner), ref) -@router.get("/layers/{layer}/precombo-tree") -def precombo_tree( - layer: Layer, owner: str | None = None, session: Session = Depends(verify_session) -) -> dict: - """사전조합 나무 폴더 — `{판, 폴더: [경로 …], 자리: {키: 경로}}`(자리 없는 문서 = 뿌리).""" - return _call(_root(session, layer, owner), pre_folders.view) - - -@router.post("/layers/{layer}/precombo-tree") -def edit_precombo_tree( +@router.get("/layers/{layer}/{kind}-tree") +def library_tree( layer: Layer, + kind: Literal["precombo", "sangeun"], + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """서고 나무 폴더(일위대가 · 단가산출) — `{판, 폴더: [경로 …], 자리: {키: 경로}}` · + 자리 없는 문서 = 뿌리.""" + return _call(_root(session, layer, owner), pre_folders.view, kind) + + +@router.post("/layers/{layer}/{kind}-tree") +def edit_library_tree( + layer: Layer, + kind: Literal["precombo", "sangeun"], body: TreeBody, owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: - """새 폴더 · 이름 바꾸기 · 옮기기(폴더 · 문서 `키`) · 지우기(빈 폴더만) → 새 나무 · - 판 다르면 409.""" + """새 폴더 · 이름 바꾸기 · 옮기기 · 지우기(폴더 = 안에 든 문서째 · 쓰는 곳 있으면 409) · + 여럿 = `대상` → 새 나무 · 판 다르면 409.""" folder = _root(session, layer, owner, write=True) - return _call(folder, pre_folders.edit, body.model_dump()) + return _call(folder, pre_folders.edit, body.model_dump(), kind) + + +@router.post("/layers/{layer}/{kind}-tree/paste") +def paste_library_tree( + layer: Layer, + kind: Literal["precombo", "sangeun"], + body: TreePasteBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """복사한 층(읽기 권한) 문서 · 폴더 → 이 층(쓰기 권한) `경로` 아래 새 키 → 새 나무 + 알림.""" + target = _root(session, layer, owner, write=True) + source = _root(session, body.from_layer, body.from_owner) + return _call(target, pre_folders.paste, source, body.대상, body.경로, kind) @router.get("/layers/{layer}/materials") diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 6da5621f7..205097490 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -150,7 +150,7 @@ export type LibraryBody = { 문서: Record } | { 본뜬: { 층: Layer; 주인: string | null; 키: string } }; export const newLibrary = ( layer: Layer, - kind: "precombo", + kind: LibraryKind, body: LibraryBody, owner?: string | null, ): Promise => @@ -159,25 +159,49 @@ export const newLibrary = ( body: JSON.stringify(body), }); -/** 사전조합 나무 자유 폴더(158-1) — 자리 없는 문서 = 뿌리 · 판이 다르면 409(`StaleError`) */ +/** 서고 나무 자유 폴더(158-1 · 716-4) — 일위대가(precombo) · 단가산출(sangeun) 따로 · + * 자리 없는 문서 = 뿌리 · 판이 다르면 409(`StaleError`) · 지우기 = 안에 든 문서째(쓰는 곳 있으면 409) */ +export type LibraryKind = "precombo" | "sangeun"; export interface PrecomboTree { 판: string; 폴더: string[][]; 자리: Record; + 알림?: { op: string; 지운문서?: string[]; 만든문서?: string[]; 바뀐이름?: string[] }; } -export const fetchPrecomboTree = (layer: Layer, owner: string | null): Promise => - call(owned(layer, `/layers/${layer}/precombo-tree`, owner)); +const libTree = (kind: LibraryKind, layer: Layer, owner: string | null, tail = ""): string => + owned(layer, `/layers/${layer}/${kind}-tree${tail}`, owner); +export const fetchPrecomboTree = ( + layer: Layer, + owner: string | null, + kind: LibraryKind = "precombo", +): Promise => call(libTree(kind, layer, owner)); export const editPrecomboTree = ( layer: Layer, edit: Pick & { op: "add-folder" | "rename" | "move" | "delete"; + 대상?: TreeRef[]; }, 판: string, owner: string | null, + kind: LibraryKind = "precombo", ): Promise => - call(owned(layer, `/layers/${layer}/precombo-tree`, owner), { + call(libTree(kind, layer, owner), { method: "POST", body: JSON.stringify({ ...edit, 판 }) }); +/** 복사해 둔 문서 · 폴더 → 이 층 `dest` 아래 새 키(폴더는 안의 것까지 · 같은 폴더 이름 「(1)」) */ +export const pasteLibraryTree = ( + kind: LibraryKind, + layer: Layer, + owner: string | null, + clip: TreeClip, + dest: string[], +): Promise => + call(libTree(kind, layer, owner, "/paste"), { method: "POST", - body: JSON.stringify({ ...edit, 판 }), + body: JSON.stringify({ + from_layer: clip.from.layer, + from_owner: clip.from.owner, + 대상: clip.refs, + 경로: dest, + }), }); /* --- 구조물 도면(종류 structure · 이름 = 구조물집계표 열 id) --- diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts new file mode 100644 index 000000000..a2180e8f8 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts @@ -0,0 +1,451 @@ +/* ============================================================================= + * M02_MasterTemplete_UI_Side_Library.ts + * 좌측 서고 칸(716-4) — 일위대가(`precombo`) · 단가산출(`sangeun`) 두 칸이 같이 쓰는 층 단추 + 그 층 나무. + * 칸마다 다른 것(종류 · 목록 고름 · 새 문서 몸 · 글)은 `LibraryOptions` 로 받음(일위대가 = `UI_Side_Precombo`). + * 나무 = 자유 폴더(158-1 · 깊이 자유 · 층마다 따로 · 자리 없는 문서 = 뿌리) — 폴더 자리만 서버 + * (`{종류}-tree`)에 적음 · 문서 파일은 안 옮김. 폴더 오른쪽 클릭 [새 폴더] · [여기에 새 …] · [이름 바꾸기] · + * [지우기](안에 든 문서째 + 확인 창 · 쓰는 곳 있으면 서버 409 까닭 그대로) · 빈 자리 = 뿌리 메뉴 · 끌어 옮기기. + * 여럿(구조물 조합 나무 꼴 · `TreePanel_Pick`) — Ctrl · Shift 클릭 고름 · 오른쪽 클릭 · Ctrl+C · Ctrl+V · + * Delete 가 고른 것 전부에 · 복사는 읽을 수 있는 층 어디서나 → 쓸 수 있는 층에 붙여넣기(새 키). + * 문서 누름 = 편집 칸 · 켜진 층 단추 다시 누름 = 목록표(페이지 몫). + * ========================================================================== */ + +import { isCtrlKey } from "../A00_Common/key_shortcut"; +import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { createEmpty } from "@ui/ui_template_empty"; +import { t as L } from "@ui/ui_template_locale"; +import { + createTreeRow, + openTreeMenu, + renderTree, + type TreeMenuItem, + type TreeNode, +} from "@ui/ui_template_tree"; +import { + StaleError, + editPrecomboTree, + fetchPrecomboTree, + listTemplates, + newLibrary, + pasteLibraryTree, + type Layer, + type LibraryKind, + type PrecomboTree, +} from "./M02_MasterTemplete_Api_Fetch"; +import { nameDialog } from "./M02_MasterTemplete_Library_List"; +import type { Root } from "./M02_MasterTemplete_Layer"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; +import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag"; +import { folderIdOf, samePath } from "./M02_MasterTemplete_TreePanel_Model"; +import { + clipOf, + keepClip, + pickClick, + refOf, + type Target, +} from "./M02_MasterTemplete_TreePanel_Pick"; +import type { Selection } from "./M02_MasterTemplete_UI_Main"; + +/** 서고 칸 층(134-3) — 페이지 층과 따로 · `owner` null = 자기 층 */ +export interface LibraryScope { + root: Root; + layer: Layer; + owner: string | null; + canWrite: boolean; +} + +export interface LibraryOptions { + kind: LibraryKind; + scope: () => LibraryScope; + /** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) */ + bar: () => HTMLElement; + confirmLeave: () => Promise; + onOpen: (sel: Selection | null) => void; + getOpen: () => Selection | null; + /** 그 층 목록표 고름 — 문서 열기의 바탕(종류 · 층 · 주인 · 권한) */ + list: (scope: LibraryScope) => Selection; + /** [여기에 새 …] 문서 몸 */ + blank: (name: string) => Record; + /** 빈 나무 글 · 새 문서 메뉴 글 */ + text: { none: string; newHere: string }; + /** 문서 줄 덧메뉴(706-1 회사에 올리기 요청) — 없거나 빈 목록이면 메뉴 없음 */ + more?: (key: string, name: string) => TreeMenuItem[]; +} + +export interface LibraryHandle { + body: HTMLElement; + /** 고름 표시만 */ + paint: () => void; + /** 층 단추 · 나무를 그 층 목록으로 다시 그림 */ + refresh: () => Promise; +} + +export interface LibraryRow { + 이름: string; + 종류: string; + 제목?: string | null; + 구분?: string | null; + 상세구분?: string | null; +} + +export interface LibraryFolder { + path: string[]; + folders: LibraryFolder[]; + docs: LibraryRow[]; +} + +/** 폴더 나무(158-1) — 적어 둔 폴더(그 차례) + 문서 자리(없으면 뿌리 · 모르는 폴더면 그 폴더를 만듦) */ +export function buildLibraryFolders( + rows: LibraryRow[], + view: Pick, +): LibraryFolder { + const root: LibraryFolder = { path: [], folders: [], docs: [] }; + const at = (path: string[]): LibraryFolder => + path.reduce((node, name, i) => { + let next = node.folders.find((f) => f.path[i] === name); + if (!next) { + next = { path: path.slice(0, i + 1), folders: [], docs: [] }; + node.folders.push(next); + } + return next; + }, root); + view.폴더.forEach(at); + for (const row of rows) at(view.자리[row.이름] ?? []).docs.push(row); + return root; +} + +/** 폴더 안 문서 수(아래 폴더까지) */ +export const docCount = (node: LibraryFolder): number => + node.docs.length + node.folders.reduce((n, f) => n + docCount(f), 0); + +type TreeBody = Parameters[1]; +const why = (error: unknown): string => (error instanceof Error ? error.message : ""); + +export function buildLibrarySection(opt: LibraryOptions): LibraryHandle { + const kind = opt.kind; + const clipKind = `library:${kind}`; + const barHost = el("div"); + const treeHost = el("div", { + className: "ui-tree m02-tree__body", + attrs: { "data-library-tree": kind, tabindex: "0" }, + }); + const leaves = new Map(); + const toggled = new Map(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로 + const targets = new Map(); + const counts = new Map(); + let picked = new Set(); + let anchor: string | null = null; + let rows: LibraryRow[] = []; + let view: PrecomboTree | null = null; + let seq = 0; + let busy = false; + + const open = async (row: LibraryRow): Promise => { + const at = opt.scope(); + if (!(await opt.confirmLeave())) return; + opt.onOpen({ + ...opt.list(at), + name: row.이름, + label: row.제목 ? `${row.이름} ${row.제목}` : row.이름, + }); + }; + + /** 나무 고침 한 번 — 판 다름 = 다시 읽음 · 막힘 까닭(같은 이름 · 쓰는 곳) 그대로 */ + const apply = async (run: (판: string) => Promise): Promise => { + if (!view || busy) return; + busy = true; + try { + view = await run(view.판); + const gone = view.알림?.지운문서 ?? []; + if (gone.length) { + rows = rows.filter((r) => !gone.includes(r.이름)); + const cur = opt.getOpen(); + if (cur && cur.kind === kind && gone.includes(cur.name)) opt.onOpen(null); + } + if (view.알림?.만든문서?.length) await refresh(true); + else draw(); + showToast(L("M02_TreeDone"), "success"); + } catch (error) { + if (error instanceof StaleError && error.message === "HTTP 409") { + await refresh(); + showToast(L("M02_TreeStale"), "error"); + } else { + showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); + } + } finally { + busy = false; + } + }; + const edit = async (body: TreeBody): Promise => { + const at = opt.scope(); + for (const path of [body.경로, body.새경로]) + path?.forEach((_, i) => toggled.set(folderIdOf(path.slice(0, i + 1)), true)); + await apply((판) => editPrecomboTree(at.layer, body, 판, at.owner, kind)); + }; + + /** [여기에 새 …] — 뿌리에 생성 → 폴더면 그 자리로 옮김 → 나무 다시 · 상세 엶 */ + const make = (path: string[]): void => { + const at = opt.scope(); + nameDialog(opt.text.newHere, "", async (name) => { + const got = await newLibrary(at.layer, kind, { 문서: opt.blank(name) }, at.owner); + showToast(L("M02_Created"), "success"); + await refresh(); + if (path.length) await edit({ op: "move", 키: got.이름, 새경로: path }); + await open({ 이름: got.이름, 종류: kind, 제목: name }); + }); + }; + const addFolder = async (parent: string[]): Promise => { + const got = await askText(L("M02_TreeAddFolder"), [ + { key: "name", label: L("M02_TreeFolderName") }, + ]); + if (got) await edit({ op: "add-folder", 경로: parent, 이름: got.name }); + }; + const rename = async (path: string[]): Promise => { + const was = path[path.length - 1]; + const got = await askText(L("M02_TreeRename"), [ + { key: "name", label: L("M02_TreeFolderName"), value: was }, + ]); + if (got && got.name !== was) await edit({ op: "rename", 경로: path, 이름: got.name }); + }; + + /* --- 지우기 · 여럿 · 복사 붙여넣기(구조물 조합 나무 꼴) --- */ + const pickedTargets = (): Target[] => + [...picked].map((id) => targets.get(id)).filter((t): t is Target => !!t); + /** 지우기 — 확인 창(폴더 = 안의 문서 수) 뒤 한 번에 · 쓰는 곳이 있으면 서버가 막음(그 이름) */ + const remove = async (list: Target[]): Promise => { + if (!list.length || !opt.scope().canWrite) return; + const [one] = list; + const ask = + list.length > 1 + ? L("M02_TreeDeleteManyAsk").replace("{n}", String(list.length)) + : one.kind === "item" + ? L("M02_TreeDeleteItem").replace("{value}", one.item.이름) + : L("M02_TreeDeleteFolder") + .replace("{value}", one.path[one.path.length - 1]) + .replace("{n}", String(counts.get(folderIdOf(one.path)) ?? 0)); + if (!(await showConfirmDialog(ask, L("M02_Delete")))) return; + await edit({ op: "delete", 대상: list.map(refOf) }); + }; + const copy = (list: Target[]): void => { + if (!list.length) return; + const at = opt.scope(); + keepClip({ kind: clipKind, from: { layer: at.layer, owner: at.owner }, refs: list.map(refOf) }); + showToast(L("M02_TreeCopied").replace("{n}", String(list.length)), "success"); + }; + const paste = async (dest: string[]): Promise => { + const got = clipOf(clipKind); + const at = opt.scope(); + if (!got || !at.canWrite) return; + toggled.set(folderIdOf(dest), true); + await apply(() => pasteLibraryTree(kind, at.layer, at.owner, got, dest)); + }; + + const menuAt = (path: string[]): TreeMenuItem[] => { + const off = opt.scope().canWrite ? undefined : tc("ReadOnly"); + const item = (label: string, run: () => void): TreeMenuItem => ({ + label, + run, + disabledReason: off, + }); + const got = clipOf(clipKind); + const target: Target = { kind: "folder", path }; + return [ + item(L("M02_TreeAddFolder"), () => void addFolder(path)), + item(opt.text.newHere, () => make(path)), + ...(path.length + ? [ + item(L("M02_TreeRename"), () => void rename(path)), + item(L("M02_Delete"), () => void remove([target])), + { label: L("M02_TreeCopy").replace("{n}", "1"), run: () => copy([target]) }, + ] + : []), + ...(got + ? [item(L("M02_TreePaste").replace("{n}", String(got.refs.length)), () => void paste(path))] + : []), + ]; + }; + /** 오른쪽 클릭 — 고른 것 안의 줄이면 고른 것 전부(복사 · 지우기) · 아니면 그 줄 하나 */ + const menuFor = (id: string, single: () => TreeMenuItem[]): TreeMenuItem[] => { + if (!picked.has(id)) setPicked(new Set([id]), id); + const list = pickedTargets(); + if (list.length < 2) return single(); + const n = String(list.length); + const off = opt.scope().canWrite ? undefined : tc("ReadOnly"); + return [ + { label: L("M02_TreeCopy").replace("{n}", n), run: () => copy(list) }, + { + label: L("M02_TreeDeleteMany").replace("{n}", n), + run: () => void remove(list), + disabledReason: off, + }, + ]; + }; + + const leaf = (row: LibraryRow, folder: string[]): TreeNode => { + const id = `doc:${row.이름}`; + const label = row.제목 || row.이름; + const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label }; + const target: Target = { kind: "item", item }; + targets.set(id, target); + const single = (): TreeMenuItem[] => [ + { label: L("M02_TreeCopy").replace("{n}", "1"), run: () => copy([target]) }, + { + label: L("M02_Delete"), + run: () => void remove([target]), + disabledReason: opt.scope().canWrite ? undefined : tc("ReadOnly"), + }, + ...(opt.more?.(row.이름, label) ?? []), + ]; + const menu = () => menuFor(id, single); + return { id, label, tip: row.이름, count: null, run: () => void open(row), menu }; + }; + const folderNode = (node: LibraryFolder): TreeNode => { + const id = folderIdOf(node.path); + const count = docCount(node); + targets.set(id, { kind: "folder", path: node.path }); + counts.set(id, count); + return { + id, + label: node.path[node.path.length - 1], + count, + folder: true, + open: toggled.get(id) ?? count > 0, + children: kids(node), + menu: () => menuFor(id, () => menuAt(node.path)), + }; + }; + const kids = (node: LibraryFolder): TreeNode[] => [ + ...node.folders.map(folderNode), + ...node.docs.map((row) => leaf(row, node.path)), + ]; + + /** 끌어 옮기기(구조물 조합 나무와 같은 부품) — 고른 것 전부 · 폴더 줄 위 = 그 안 · 빈 자리 = 뿌리 */ + const drag = attachTreeDrag({ + body: treeHost, + targetOf: (id) => targets.get(id), + listFor: (id) => + picked.size > 1 && picked.has(id) + ? pickedTargets() + : targets.has(id) + ? [targets.get(id)!] + : null, + move: (list, dest) => { + const from = (t: Target): string[] => + t.kind === "item" ? t.item.경로.slice(0, -1) : t.path.slice(0, -1); + const moving = list.filter((t) => !samePath(from(t), dest)); + if (moving.length) void edit({ op: "move", 대상: moving.map(refOf), 새경로: dest }); + }, + }); + + const visibleIds = (): string[] => + [...treeHost.querySelectorAll("[data-node]")] + .filter((b) => !b.closest("[hidden]")) + .map((b) => b.dataset.node ?? ""); + const setPicked = (next: Set, at: string | null): void => { + picked = next; + anchor = at; + for (const row of treeHost.querySelectorAll("[data-node]")) + row.classList.toggle("is-picked", picked.size > 1 && picked.has(row.dataset.node ?? "")); + }; + + const draw = (): void => { + leaves.clear(); + targets.clear(); + counts.clear(); + const tree = buildLibraryFolders(rows, view ?? { 폴더: [], 자리: {} }); + if (!tree.docs.length && !tree.folders.length) + return void treeHost.replaceChildren(createEmpty({ text: opt.text.none, small: true })); + const canWrite = opt.scope().canWrite; + treeHost.replaceChildren( + ...renderTree(kids(tree), (node, caret, depth, onClick) => { + const row = createTreeRow(node, caret, depth, () => { + onClick(); + if (!node.children?.length) return; + toggled.set(node.id, row.querySelector(".ui-tree__caret")?.textContent !== "▸"); + }); + row.dataset.node = node.id; + // Ctrl · Shift 클릭 = 고르기만(열기 · 접기 안 함) — 줄 본디 click 보다 먼저(capture) + row.addEventListener( + "click", + (event) => { + const mod = { ctrl: event.ctrlKey || event.metaKey, shift: event.shiftKey }; + const got = pickClick(picked, anchor, node.id, mod, visibleIds()); + if (got.handled) event.stopImmediatePropagation(); + setPicked(got.picked, got.anchor); + }, + { capture: true }, + ); + if (node.run) { + row.dataset.libraryKey = node.id.slice(4); + leaves.set(node.id.slice(4), row); + } + if (canWrite) drag.arm(row, node.id); + return row; + }), + ); + setPicked(new Set([...picked].filter((id) => targets.has(id))), anchor); + paint(); + }; + + // 빈 자리 오른쪽 클릭 = 뿌리 메뉴(새 폴더 · 새 문서 · 붙여넣기) + treeHost.addEventListener("contextmenu", (event) => { + if (!view || (event.target as HTMLElement).closest("[data-node]")) return; + event.preventDefault(); + openTreeMenu(event, menuAt([])); + }); + // 키 — Delete = 고른 것(없으면 초점 줄) 지움 · Ctrl+C 복사 · Ctrl+V = 초점 폴더 안(아니면 뿌리) + treeHost.addEventListener("keydown", (event) => { + const row = (event.target as HTMLElement).closest("[data-node]"); + const id = row?.dataset.node ?? ""; + const target = targets.get(id); + const list = picked.has(id) ? pickedTargets() : target ? [target] : []; + if (event.key === "Delete" && list.length) { + event.preventDefault(); + void remove(list); + } else if (isCtrlKey(event, "c") && list.length) { + event.preventDefault(); + copy(list); + } else if (isCtrlKey(event, "v")) { + event.preventDefault(); + void paste(target?.kind === "folder" ? target.path : []); + } + }); + + const paint = (): void => { + const cur = opt.getOpen(); + const at = opt.scope(); + const mine = + cur && cur.kind === kind && cur.layer === at.layer && (cur.owner ?? null) === at.owner; + for (const [key, row] of leaves) row.classList.toggle("is-active", !!mine && cur.name === key); + }; + + /** `keep` = 나무 자리 그대로 두고 목록만 다시(붙여넣기 뒤 새 문서) */ + const refresh = async (keep = false): Promise => { + const mine = ++seq; + const at = opt.scope(); + barHost.replaceChildren(opt.bar()); + if (!keep) treeHost.replaceChildren(createEmpty({ text: tc("Loading"), small: true })); + const [got, tree] = await Promise.all([ + // 못 받으면 빈 나무(목록표에서 까닭) · 폴더를 못 받으면 문서는 모두 뿌리 + listTemplates(at.layer, null, at.owner).then( + (list): LibraryRow[] => list.filter((r) => r.종류 === kind), + (): LibraryRow[] => [], + ), + keep && view + ? Promise.resolve(view) + : fetchPrecomboTree(at.layer, at.owner, kind).catch(() => null), + ]); + if (mine !== seq) return; + rows = got; + view = tree; + draw(); + }; + + const body = el("div", { + className: "m02-side__items ui-sidebar-list", + attrs: { "data-library": kind }, + children: [barHost, treeHost], + }); + return { body, paint, refresh: () => refresh() }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts index 38125c298..b8a79ae16 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts @@ -1,44 +1,22 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side_Precombo.ts - * 좌측 「일위대가」 컨테이너(50-3 ③ · 51-4 · 134-3) — 구조물 조합 아래 · 층 단추(페이지가 만듦 · - * 페이지 층과 따로 · 마스터 · 회사 · 내 개인) + 그 층 일위대가 나무. - * 나무 = 자유 폴더(158-1 · 구조물 조합 탐색기와 같은 꼴 · 깊이 자유 · 층마다 따로 · 자리 없는 문서 = 뿌리) — - * 폴더 오른쪽 클릭 [새 폴더] · [여기에 새 일위대가] · [이름 바꾸기] · [지우기](빈 폴더만) · 빈 자리 오른쪽 - * 클릭 = 뿌리 메뉴 · 문서 · 폴더 끌어 옮기기. 폴더 자리만 서버(`precombo-tree`)에 적음 — 문서 파일은 안 옮김. + * 좌측 「일위대가」 칸(50-3 ③ · 51-4 · 134-3) — 서고 칸(`UI_Side_Library` · 716-4)에 일위대가 몫만 줌: + * 종류 precombo · 그 층 목록표 고름 · 새 문서 몸 · 글. 층 단추는 페이지가 만듦(페이지 층과 따로). * 구분 · 상세구분은 문서 칸(상세 머리 · 고르기 창 거름 · `precomboClasses`) — 나무 폴더와 무관. * 문서 누름 = 편집 칸(UI_Main 종류 precombo = 일위대가 상세 모드) · 켜진 층 단추 다시 누름 = 목록표 * (`Library_List` · 생성 · 복사 · 삭제는 거기) · 그 층 주인(`owner`) · 고칠 권한(`canWrite`)은 고름에 실어 보냄. * ========================================================================== */ -import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; -import { t as L } from "@ui/ui_template_locale"; -import { - createTreeRow, - openTreeMenu, - renderTree, - type TreeMenuItem, - type TreeNode, -} from "@ui/ui_template_tree"; -import { - StaleError, - editPrecomboTree, - fetchPrecomboTree, - listTemplates, - newLibrary, - type Kind, - type Layer, - type PrecomboTree, -} from "./M02_MasterTemplete_Api_Fetch"; -import { nameDialog } from "./M02_MasterTemplete_Library_List"; -import type { Root } from "./M02_MasterTemplete_Layer"; +import type { TreeMenuItem } from "@ui/ui_template_tree"; +import type { Kind } from "./M02_MasterTemplete_Api_Fetch"; import type { StructDef } from "./StructOptions/M02_SO_Def_Model"; import { tc } from "./M02_MasterTemplete_Labor_Text"; -import { askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; -import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag"; -import { folderIdOf, samePath } from "./M02_MasterTemplete_TreePanel_Model"; -import type { Target } from "./M02_MasterTemplete_TreePanel_Pick"; import type { Selection } from "./M02_MasterTemplete_UI_Main"; -import { createEmpty } from "@ui/ui_template_empty"; +import { + buildLibrarySection, + type LibraryHandle, + type LibraryScope, +} from "./M02_MasterTemplete_UI_Side_Library"; export type PrecomboKind = Extract; @@ -60,12 +38,7 @@ export const precomboClasses = ( })); /** 사전조합 층(134-3) — 페이지 층과 따로 · `owner` null = 자기 층 */ -export interface PrecomboScope { - root: Root; - layer: Layer; - owner: string | null; - canWrite: boolean; -} +export type PrecomboScope = LibraryScope; /** 그 층 목록표 고름(켜진 층 단추 다시 누름) */ export const precomboList = (scope: PrecomboScope): Selection => ({ @@ -89,257 +62,13 @@ export interface PrecomboOptions { more?: (key: string, name: string) => TreeMenuItem[]; } -export interface PrecomboHandle { - body: HTMLElement; - /** 고름 표시만 */ - paint: () => void; - /** 층 단추 · 나무를 그 층 목록으로 다시 그림 */ - refresh: () => Promise; -} +export type PrecomboHandle = LibraryHandle; -interface Row { - 이름: string; - 종류: string; - 제목?: string | null; - 구분?: string | null; - 상세구분?: string | null; -} - -export interface PrecomboFolder { - path: string[]; - folders: PrecomboFolder[]; - docs: Row[]; -} - -/** 폴더 나무(158-1) — 적어 둔 폴더(그 차례) + 문서 자리(없으면 뿌리 · 모르는 폴더면 그 폴더를 만듦) */ -export function buildPrecomboFolders( - rows: Row[], - view: Pick, -): PrecomboFolder { - const root: PrecomboFolder = { path: [], folders: [], docs: [] }; - const at = (path: string[]): PrecomboFolder => - path.reduce((node, name, i) => { - let next = node.folders.find((f) => f.path[i] === name); - if (!next) { - next = { path: path.slice(0, i + 1), folders: [], docs: [] }; - node.folders.push(next); - } - return next; - }, root); - view.폴더.forEach(at); - for (const row of rows) at(view.자리[row.이름] ?? []).docs.push(row); - return root; -} - -/** 폴더 안 문서 수(아래 폴더까지) */ -export const docCount = (node: PrecomboFolder): number => - node.docs.length + node.folders.reduce((n, f) => n + docCount(f), 0); - -type TreeBody = Parameters[1]; -const why = (error: unknown): string => (error instanceof Error ? error.message : ""); - -export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle { - const barHost = el("div"); - const treeHost = el("div", { className: "ui-tree", attrs: { "data-precombo-tree": "" } }); - const leaves = new Map(); - const toggled = new Map(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로 - const targets = new Map(); - let rows: Row[] = []; - let view: PrecomboTree | null = null; - let seq = 0; - let busy = false; - - const open = async (row: Row): Promise => { - const at = opt.scope(); - if (!(await opt.confirmLeave())) return; - opt.onOpen({ - ...precomboList(at), - name: row.이름, - label: row.제목 ? `${row.이름} ${row.제목}` : row.이름, - }); - }; - - /** 나무 고침 한 번 — 판 다름 = 다시 읽음 · 막힘 까닭(빈 폴더 아님 · 같은 이름) 그대로 */ - const edit = async (body: TreeBody): Promise => { - const at = opt.scope(); - if (!view || busy) return; - busy = true; - try { - view = await editPrecomboTree(at.layer, body, view.판, at.owner); - for (const path of [body.경로, body.새경로]) - path?.forEach((_, i) => toggled.set(folderIdOf(path.slice(0, i + 1)), true)); - draw(); - showToast(L("M02_TreeDone"), "success"); - } catch (error) { - if (error instanceof StaleError && error.message === "HTTP 409") { - await refresh(); - showToast(L("M02_TreeStale"), "error"); - } else { - showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); - } - } finally { - busy = false; - } - }; - - /** [여기에 새 일위대가] — 뿌리에 생성 → 폴더면 그 자리로 옮김 → 나무 다시 · 상세 엶 */ - const make = (path: string[]): void => { - const at = opt.scope(); - nameDialog(tc("Lib_NewHere"), "", async (name) => { - const 문서 = { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] }; - const got = await newLibrary(at.layer, "precombo", { 문서 }, at.owner); - showToast(L("M02_Created"), "success"); - await refresh(); - if (path.length) await edit({ op: "move", 키: got.이름, 새경로: path }); - await open({ 이름: got.이름, 종류: "precombo", 제목: name }); - }); - }; - const addFolder = async (parent: string[]): Promise => { - const got = await askText(L("M02_TreeAddFolder"), [ - { key: "name", label: L("M02_TreeFolderName") }, - ]); - if (got) await edit({ op: "add-folder", 경로: parent, 이름: got.name }); - }; - const rename = async (path: string[]): Promise => { - const was = path[path.length - 1]; - const got = await askText(L("M02_TreeRename"), [ - { key: "name", label: L("M02_TreeFolderName"), value: was }, - ]); - if (got && got.name !== was) await edit({ op: "rename", 경로: path, 이름: got.name }); - }; - /** 지우기 — 빈 폴더만(안에 문서가 있으면 알림만) */ - const remove = async (path: string[], count: number): Promise => { - if (count) return void showToast(tc("Lib_FolderFull").replace("{n}", String(count)), "error"); - const ask = tc("Lib_FolderDelete").replace("{value}", path[path.length - 1]); - if (await showConfirmDialog(ask, L("M02_Delete"))) await edit({ op: "delete", 경로: path }); - }; - - const menuAt = (path: string[], count: number): TreeMenuItem[] => { - const off = opt.scope().canWrite ? undefined : tc("ReadOnly"); - const item = (label: string, run: () => void): TreeMenuItem => ({ - label, - run, - disabledReason: off, - }); - return [ - item(L("M02_TreeAddFolder"), () => void addFolder(path)), - item(tc("Lib_NewHere"), () => make(path)), - ...(path.length - ? [ - item(L("M02_TreeRename"), () => void rename(path)), - item(L("M02_Delete"), () => void remove(path, count)), - ] - : []), - ]; - }; - - const leaf = (row: Row, folder: string[]): TreeNode => { - const id = `doc:${row.이름}`; - const label = row.제목 || row.이름; - const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label }; - targets.set(id, { kind: "item", item }); - const menu = () => opt.more?.(row.이름, label) ?? []; - return { id, label, tip: row.이름, count: null, run: () => void open(row), menu }; - }; - const folderNode = (node: PrecomboFolder): TreeNode => { - const id = folderIdOf(node.path); - const count = docCount(node); - targets.set(id, { kind: "folder", path: node.path }); - return { - id, - label: node.path[node.path.length - 1], - count, - folder: true, - open: toggled.get(id) ?? count > 0, - children: kids(node), - menu: () => menuAt(node.path, count), - }; - }; - const kids = (node: PrecomboFolder): TreeNode[] => [ - ...node.folders.map(folderNode), - ...node.docs.map((row) => leaf(row, node.path)), - ]; - - /** 끌어 옮기기(구조물 조합 나무와 같은 부품) — 폴더 줄 위 = 그 안 · 빈 자리 = 뿌리 · 제자리면 안 함 */ - const drag = attachTreeDrag({ - body: treeHost, - targetOf: (id) => targets.get(id), - listFor: (id) => (targets.has(id) ? [targets.get(id)!] : null), - move: ([t], dest) => { - const from = t.kind === "item" ? t.item.경로.slice(0, -1) : t.path.slice(0, -1); - if (samePath(from, dest)) return; - void edit( - t.kind === "item" - ? { op: "move", 키: t.item.key, 새경로: dest } - : { op: "move", 경로: t.path, 새경로: dest }, - ); - }, +export const buildPrecomboSection = (opt: PrecomboOptions): PrecomboHandle => + buildLibrarySection({ + ...opt, + kind: "precombo", + list: precomboList, + blank: (name) => ({ 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] }), + text: { none: tc("Lib_None"), newHere: tc("Lib_NewHere") }, }); - - const draw = (): void => { - leaves.clear(); - targets.clear(); - const tree = buildPrecomboFolders(rows, view ?? { 폴더: [], 자리: {} }); - if (!tree.docs.length && !tree.folders.length) - return void treeHost.replaceChildren(createEmpty({ text: tc("Lib_None"), small: true })); - const canWrite = opt.scope().canWrite; - treeHost.replaceChildren( - ...renderTree(kids(tree), (node, caret, depth, onClick) => { - const row = createTreeRow(node, caret, depth, () => { - onClick(); - if (!node.children?.length) return; - toggled.set(node.id, row.querySelector(".ui-tree__caret")?.textContent !== "▸"); - }); - row.dataset.node = node.id; - if (node.run) { - row.dataset.precomboKey = node.id.slice(4); - leaves.set(node.id.slice(4), row); - } - if (canWrite) drag.arm(row, node.id); - return row; - }), - ); - paint(); - }; - - // 빈 자리 오른쪽 클릭 = 뿌리 메뉴(새 폴더 · 새 일위대가) - treeHost.addEventListener("contextmenu", (event) => { - if (!view || (event.target as HTMLElement).closest("[data-node]")) return; - event.preventDefault(); - openTreeMenu(event, menuAt([], 0)); - }); - - const paint = (): void => { - const cur = opt.getOpen(); - const at = opt.scope(); - const mine = - cur && isPrecombo(cur.kind) && cur.layer === at.layer && (cur.owner ?? null) === at.owner; - for (const [key, row] of leaves) row.classList.toggle("is-active", !!mine && cur.name === key); - }; - - const refresh = async (): Promise => { - const mine = ++seq; - const at = opt.scope(); - barHost.replaceChildren(opt.bar()); - treeHost.replaceChildren(createEmpty({ text: tc("Loading"), small: true })); - const [got, tree] = await Promise.all([ - // 못 받으면 빈 나무(목록표에서 까닭) · 폴더를 못 받으면 문서는 모두 뿌리 - listTemplates(at.layer, null, at.owner).then( - (list): Row[] => list.filter((r) => r.종류 === "precombo"), - (): Row[] => [], - ), - fetchPrecomboTree(at.layer, at.owner).catch(() => null), - ]); - if (mine !== seq) return; - rows = got; - view = tree; - draw(); - }; - - const body = el("div", { - className: "m02-side__items ui-sidebar-list", - attrs: { "data-precombo": "" }, - children: [barHost, treeHost], - }); - return { body, paint, refresh }; -} diff --git a/M02_MasterTemplete/M02_Precombo_Folders.py b/M02_MasterTemplete/M02_Precombo_Folders.py index 110768d11..9e418fe33 100644 --- a/M02_MasterTemplete/M02_Precombo_Folders.py +++ b/M02_MasterTemplete/M02_Precombo_Folders.py @@ -1,32 +1,42 @@ -"""M02 일위대가 사전조합 나무 = 자유 폴더(PLAN 158-1) — 구조물 조합 나무와 같은 꼴(깊이 자유). +"""M02 서고 나무 = 자유 폴더(PLAN 158-1 · 716-4) — 일위대가(`precombo`) · 단가산출(`sangeun`) 한 벌. -자리 = 층 뿌리 `precombo_폴더.json` `{폴더: [경로 …], 자리: {키: 경로}}` — -층(마스터 · 회사 · 개인)마다 따로. 문서 파일(`{층}/precombo/SC-######.json`)은 안 옮김 · -폴더 자리만 적음 · 자리 없는 문서 = 뿌리. 고침(폴더 더하기 · 이름 바꾸기 · 옮기기 · 지우기 · -문서 옮기기)은 판이 다르면 409 · 지우기는 빈 폴더만(안에 문서가 있으면 409). +자리 = 층 뿌리 `{종류}_폴더.json` `{폴더: [경로 …], 자리: {키: 경로}}` — 종류 · 층(마스터 · 회사 · +개인)마다 따로. 문서 파일(`{층}/{종류}/SC-######.json`)은 안 옮김 · 폴더 자리만 적음 · 자리 없는 +문서 = 뿌리. 고침(폴더 더하기 · 이름 바꾸기 · 옮기기 · 지우기 · 문서 옮기기 · 지우기)은 판이 다르면 +409 · 여럿(`대상`) = 옮기기 · 지우기를 차례로 · 저장 한 번. 지우기 = 안에 든 문서째(716-4) — +모두 검사 뒤 한 번에 · 바깥에 쓰는 곳이 있으면 409 + 그 이름(지우는 것끼리 참조는 막지 않음). +붙여넣기(`paste`) = 복사한 층 문서 · 폴더(안의 것까지) → 지금 층 새 키 · 같은 폴더 이름 「(1)」 · +못 읽는 참조는 같이 복사(`M02_Labor_Ref.carry`). 이름 · 경로 검사 · 같은 이름 「(1)」 은 `M02_MasterTemplete_Tree` 한 벌. """ from __future__ import annotations import json +from pathlib import Path from typing import Any from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_MasterTemplete_Tree as tree +from M02_MasterTemplete import M02_Template_Layers as layers -FILE = "precombo_폴더.json" OPS = ("add-folder", "rename", "move", "delete") +USERS = (("labor", "일위대가"), ("precombo", "사전조합"), ("sangeun", "단가산출")) # refs.users 꼴 -def _keys() -> set[str]: - """지금 층에 있는 사전조합 키(파일 이름).""" - return {p.stem for p in (store.root() / "precombo").glob("[!._]*.json")} +def file_of(kind: str) -> str: + return f"{kind}_폴더.json" -def _read() -> tuple[dict[str, Any], str]: - path = store.root() / FILE +def _keys(kind: str, root: Path | None = None) -> set[str]: + """그 층에 있는 서고 키(파일 이름).""" + return {p.stem for p in ((root or store.root()) / kind).glob("[!._]*.json")} + + +def _read(kind: str, root: Path | None = None) -> tuple[dict[str, Any], str]: + path = (root or store.root()) / file_of(kind) if not path.is_file(): return {"폴더": [], "자리": {}}, "" raw = path.read_bytes() @@ -45,29 +55,38 @@ def _folders(doc: dict[str, Any], keys: set[str]) -> list[list[str]]: return out +def _places(doc: dict[str, Any], keys: set[str]) -> dict[str, list[str]]: + return {k: tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in keys} + + def view_of(doc: dict[str, Any], 판: str, keys: set[str]) -> dict[str, Any]: - places = {k: tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in keys} + places = _places(doc, keys) return {"판": 판, "폴더": _folders(doc, keys), "자리": {k: p for k, p in places.items() if p}} -def view() -> dict[str, Any]: - doc, 판 = _read() - return view_of(doc, 판, _keys()) +def view(kind: str = "precombo") -> dict[str, Any]: + doc, 판 = _read(kind) + return view_of(doc, 판, _keys(kind)) def _shift(path: list[str], old: list[str], new: list[str]) -> list[str]: return [*new, *path[len(old) :]] if tree._under(path, old) else path +def _out(folders: list[list[str]], places: dict[str, list[str]]) -> dict[str, Any]: + return {"폴더": folders, "자리": {k: p for k, p in sorted(places.items()) if p}} + + def apply( doc: dict[str, Any], body: dict[str, Any], keys: set[str] ) -> tuple[dict[str, Any], dict[str, Any]]: - """고침 하나 → (새 자리 문서, 알림) — 순수 함수(저장은 `edit`).""" + """고침 하나 → (새 자리 문서, 알림) — 순수 함수(저장 · 문서 파일 지우기는 `edit`). + 지우기 알림 `지운문서` = 지울 문서 키(폴더 안 · 아래 폴더까지).""" op = body.get("op") if op not in OPS: raise tree.TreeError(422, f"모르는 고침 「{op}」 — {' · '.join(OPS)}") folders = _folders(doc, keys) - places = {k: tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in keys} + places = _places(doc, keys) where = tree._path(body.get("경로")) key = str(body.get("키") or "") note: dict[str, Any] = {"op": op} @@ -78,6 +97,8 @@ def apply( if not path and what: raise tree.TreeError(422, f"{what} 폴더 「경로」 가 없음") + if key and op in ("move", "delete") and key not in keys: + raise tree.TreeError(404, f"없는 문서 「{key}」") if op == "add-folder": need(where, "") made = [*where, tree._name(body.get("이름"))] @@ -86,12 +107,13 @@ def apply( folders.append(made) note["경로"] = made elif op == "move" and key: - if key not in keys: - raise tree.TreeError(404, f"없는 사전조합 「{key}」") to = tree._path(body.get("새경로"), "새경로") need(to, "") places[key] = to note["경로"] = to + elif op == "delete" and key: + places.pop(key, None) + note["지운문서"] = [key] elif op in ("rename", "move"): need(where, "바꿀" if op == "rename" else "옮길") if op == "rename": @@ -109,28 +131,136 @@ def apply( folders = [_shift(f, where, new) for f in folders] places = {k: _shift(p, where, new) for k, p in places.items()} note["경로"] = new - else: # delete — 빈 폴더만 + else: # delete — 안에 든 문서째(716-4) need(where, "지울") inside = sorted(k for k, p in places.items() if tree._under(p, where)) - if inside: - raise tree.TreeError(409, f"폴더 안에 사전조합 {len(inside)}개 — 먼저 옮기거나 지움") folders = [f for f in folders if not tree._under(f, where)] + places = {k: p for k, p in places.items() if k not in inside} note["경로"] = where - out = {"폴더": folders, "자리": {k: p for k, p in sorted(places.items()) if p}} - return out, note + note["지운문서"] = inside + return _out(folders, places), note + + +def apply_many( + doc: dict[str, Any], body: dict[str, Any], keys: set[str] +) -> tuple[dict[str, Any], dict[str, Any]]: + """`대상` 이 있으면 옮기기 · 지우기를 하나씩 차례로(하나라도 막히면 아무것도 안 함) · + 없으면 `apply`.""" + targets = body.get("대상") + if targets is None: + return apply(doc, body, keys) + op = body.get("op") + if op not in ("move", "delete") or not targets: + raise tree.TreeError(422, "여러 개는 옮기기 · 지우기만 — 「대상」 이 필요함") + note: dict[str, Any] = {"op": op, "지운문서": [], "바뀐이름": []} + for one in targets: + if not isinstance(one, dict): + raise tree.TreeError(422, "「대상」 은 {경로, 키?} 목록") + part = { + "op": op, + "경로": one.get("경로"), + "키": one.get("키"), + "새경로": body.get("새경로"), + } + if op == "delete" and not part["키"] and not tree._path(part["경로"]): + continue # 맨 위는 못 지움 + try: + doc, got = apply(doc, part, keys) + except tree.TreeError as e: + if e.status == 404 and op == "delete": + continue # 고른 폴더 안의 것 — 앞에서 이미 지움 + raise + note["지운문서"] += [k for k in got.get("지운문서", []) if k not in note["지운문서"]] + note["바뀐이름"] += got.get("바뀐이름", []) + return doc, note + + +def _refuse_used(kind: str, gone: list[str]) -> None: + """지울 문서를 바깥(지우는 것 밖)에서 쓰면 409 + 「어디 이름 ← 키」 — + 모든 층 훑음(프로젝트 뺌).""" + layer, owner = refs.here() + rids = {refs.ref_id({"층": layer, "주인": owner, "키": k}) for k in gone} + mine = {(store.root() / kind / f"{k}.json").resolve() for k in gone} + found = [] + for what_kind, what in USERS: + for at, path in layers.layer_files(what_kind): + if path.resolve() in mine: + continue + try: + doc = json.loads(path.read_bytes()) + except (OSError, ValueError): + continue + hit = sorted({r["키"] for r in refs.refs_in(doc) if refs.ref_id(r) in rids}) + if hit: + name = doc.get("일위대가번") or doc.get("이름") or path.stem + found.append(f"{what} {at} 「{name}」 ← {' · '.join(hit)}") + if found: + raise store.StoreError(409, "쓰는 곳이 있어 못 지움 — " + " · ".join(found)) # ponytail: 지운 문서 자리는 다음 고침 때 걷힘 — 그 사이 같은 키로 새로 만들면 옛 자리에 보임 # (키 재사용을 막을 때 함께 고침) -def edit(body: dict[str, Any]) -> dict[str, Any]: - """지금 층 자리 파일을 고쳐 씀 → 새 나무 + 알림 · 판 다르면 409.""" - doc, 판 = _read() +def edit(body: dict[str, Any], kind: str = "precombo") -> dict[str, Any]: + """지금 층 자리 파일을 고쳐 씀(지우기면 문서 파일째) → 새 나무 + 알림 · 판 다르면 409.""" + doc, 판 = _read(kind) if 판 != str(body.get("판") or ""): - raise store.StoreError(409, {"stale": [FILE], "판": 판}) - keys = _keys() + raise store.StoreError(409, {"stale": [file_of(kind)], "판": 판}) + keys = _keys(kind) try: - out, note = apply(doc, body, keys) + out, note = apply_many(doc, body, keys) except tree.TreeError as e: raise store.StoreError(e.status, e.detail) from e - atomic_write_json(store.root() / FILE, out) - return {**view(), "알림": note} + gone = note.get("지운문서") or [] + if gone: + _refuse_used(kind, gone) # 모두 검사 뒤 한 번에 + for key in gone: + (store.root() / kind / f"{key}.json").unlink(missing_ok=True) + atomic_write_json(store.root() / file_of(kind), out) + return {**view(kind), "알림": note} + + +# ponytail: 붙여넣기 도중 막히면 앞서 만든 사본은 뿌리에 남음 — 한 번에 되돌림이 필요하면 그때 +def paste(source: Path, picks: list[dict[str, Any]], dest: Any, kind: str) -> dict[str, Any]: + """복사한 층(`source`) 고른 것 `[{경로, 키?}]` → 지금 층 `dest` 아래 새 키(본뜬 = 원본) · + 폴더는 안의 것 · 아래 폴더까지 · 같은 폴더 이름 「(1)」 → 새 나무 + 알림 `만든문서`.""" + src = refs.where(source) + src_keys = _keys(kind, source) + src_doc, _ = _read(kind, source) + src_folders, src_places = _folders(src_doc, src_keys), _places(src_doc, src_keys) + doc, _ = _read(kind) + keys = _keys(kind) + folders, places = _folders(doc, keys), _places(doc, keys) + try: + to = tree._path(dest) + if to and to not in folders: + raise tree.TreeError(404, f"없는 폴더 「{' / '.join(to)}」") + except tree.TreeError as e: + raise store.StoreError(e.status, e.detail) from e + + def copy(key: str) -> str: + ref = {"층": src[0], "주인": src[1], "키": key} + got = refs.read_ref(ref) + if got is None: + raise store.StoreError(404, f"없는 문서 「{key}」") + return refs.create(kind, refs.carry(refs.origin_mark(got, ref), src))["이름"] + + made: list[str] = [] + for pick in picks: + key = str(pick.get("키") or "") if isinstance(pick, dict) else "" + path = tree._clean(pick.get("경로")) if isinstance(pick, dict) else [] + if key: + if key not in src_keys: + raise store.StoreError(404, f"없는 문서 「{key}」") + made.append(copy(key)) + places[made[-1]] = to + continue + if not path or path not in src_folders: + raise store.StoreError(404, f"없는 폴더 「{' / '.join(path)}」") + top = tree.unique_path([*to, path[-1]], lambda p: p in folders) + folders += [[*top, *f[len(path) :]] for f in src_folders if tree._under(f, path)] + for k, p in sorted(src_places.items()): + if tree._under(p, path): + made.append(copy(k)) + places[made[-1]] = [*top, *p[len(path) :]] + atomic_write_json(store.root() / file_of(kind), _out(folders, places)) + return {**view(kind), "알림": {"op": "paste", "경로": to, "만든문서": made}} diff --git a/resources/tester/helper_m02_precombo_scope.cjs b/resources/tester/helper_m02_precombo_scope.cjs index c6a04c4c4..97287d1fa 100644 --- a/resources/tester/helper_m02_precombo_scope.cjs +++ b/resources/tester/helper_m02_precombo_scope.cjs @@ -1,5 +1,5 @@ /* M02 일위대가 사전조합 층(134-3) 헬퍼 — `M02_MasterTemplete_Api_Fetch.ts` 명시 owner 길 · - * `M02_MasterTemplete_UI_Side_Precombo.ts` `buildPrecomboFolders`(158-1 자유 폴더) 를 트랜스파일해 Node 로 돌림 · + * `M02_MasterTemplete_UI_Side_Library.ts` `buildLibraryFolders`(158-1 자유 폴더 · 716-4) 를 트랜스파일해 Node 로 돌림 · * `precomboClasses`(134-5 · 구조물군 › 종류 + 공통) · fetch 대역이 부른 주소를 모음 · * stdin = {rows, def?, view?} · stdout = {urls, tree, classes} JSON. */ const fs = require("fs"); @@ -31,6 +31,7 @@ global.fetch = async (url) => { const side = loadTs( path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_UI_Side_Precombo.ts"), ); + const lib = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_UI_Side_Library.ts")); api.setActiveLayer("personal", "7/99"); // 페이지 = 남의 개인을 보는 중 await api.readTemplate("personal", "labor", "x1", null); // 페이지 층 = 그 사람 await api.readTemplate("personal", "precombo", "SC-000001", null, null); // 사전조합 = 내 개인 @@ -38,11 +39,11 @@ global.fetch = async (url) => { await api.saveTemplate("system", "precombo", "SC-000002", null, "v", {}, null); const shape = (f) => ({ path: f.path, - count: side.docCount(f), + count: lib.docCount(f), docs: f.docs.map((r) => r.이름), folders: f.folders.map(shape), }); - const tree = shape(side.buildPrecomboFolders(input.rows, input.view ?? { 폴더: [], 자리: {} })); + const tree = shape(lib.buildLibraryFolders(input.rows, input.view ?? { 폴더: [], 자리: {} })); const classes = input.def ? side.precomboClasses(input.def) : null; // 134-5 process.stdout.write(JSON.stringify({ urls, tree, classes })); })(); diff --git a/resources/tester/test_716_4_library_folders.py b/resources/tester/test_716_4_library_folders.py new file mode 100644 index 000000000..08e79a5e3 --- /dev/null +++ b/resources/tester/test_716_4_library_folders.py @@ -0,0 +1,119 @@ +"""서고 나무 폴더(PLAN 716-4) — 종류 인자 · 안에 든 것째 지움 · 쓰는 곳 409 · 여럿 · 복사 붙여넣기. + +임시 storage. + +`/api/m02/layers/{층}/{종류}-tree` = 일위대가(precombo) · 단가산출(sangeun) 따로 · +폴더 지우기 = 안의 문서 파일째(모두 검사 뒤 한 번에 · 바깥에서 쓰면 409 + 그 이름 · +지우는 것끼리 참조는 됨) · +`대상` 여럿 옮기기 · 지우기 · `…-tree/paste` = 새 키 · 같은 폴더 이름 「(1)」 · 본뜬 = 원본. +""" + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from M02_MasterTemplete import M02_Precombo_Folders as pre +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_precombo_folders import Tree, client # noqa: F401 — 같은 임시 층 + +MINE = {"층": "personal", "주인": "7/42"} +SK = "/api/m02/layers/personal/library/sangeun" + + +def _sk(c: TestClient, name: str, **doc) -> str: + body = {"문서": {"이름": name, "단위": "㎡", "더한줄": [], **doc}} + res = c.post(SK, json=body) + assert res.status_code == 200, res.text + return res.json()["이름"] + + +def _files(kind: str) -> list[str]: + return sorted(p.stem for p in (layers.personal_dir(7, 42) / kind).glob("*.json")) + + +def _sk_tree(c: TestClient) -> Tree: + t = Tree(c) + t.url = "/api/m02/layers/personal/sangeun-tree" + t.now = c.get(t.url).json() + return t + + +def test_종류마다_따로_자리_파일(client: TestClient): # noqa: F811 + _sk(client, "갑") + sk = _sk_tree(client) + sk.do(op="add-folder", 경로=[], 이름="단가") + sk.do(op="move", 키="SK-000001", 새경로=["단가"]) + assert Tree(client).now["폴더"] == [] # 일위대가 나무는 그대로 + root = layers.personal_dir(7, 42) + assert (root / pre.file_of("sangeun")).is_file() + assert not (root / pre.file_of("precombo")).exists() + assert client.get("/api/m02/layers/personal/labor-tree").status_code == 422 # 모르는 종류 + + +def test_폴더_지우기는_안에_든_것째_지우는_것끼리_참조는_됨(client: TestClient): # noqa: F811 + a = _sk(client, "바탕") + b = _sk(client, "위", 바탕={"참조": {**MINE, "키": a}}) + sk = _sk_tree(client) + sk.do(op="add-folder", 경로=[], 이름="묶음") + sk.do(op="add-folder", 경로=["묶음"], 이름="안") + sk.do(op="move", 키=a, 새경로=["묶음", "안"]) + sk.do(op="move", 키=b, 새경로=["묶음"]) + got = sk.do(op="delete", 경로=["묶음"]) + assert sorted(got["알림"]["지운문서"]) == [a, b] + assert _files("sangeun") == [] and sk.now["폴더"] == [] + + +def test_바깥에서_쓰면_409_그_이름_아무것도_안_지움(client: TestClient): # noqa: F811 + a = _sk(client, "바탕") + body = {"문서": {"이름": "쓰는 일위대가", "담은로직": [{"참조": {**MINE, "키": a}}]}} + assert client.post("/api/m02/layers/personal/library/precombo", json=body).status_code == 200 + sk = _sk_tree(client) + sk.do(op="add-folder", 경로=[], 이름="묶음") + sk.do(op="move", 키=a, 새경로=["묶음"]) + detail = sk.do(409, op="delete", 경로=["묶음"])["detail"] + assert "쓰는 곳" in detail and "「쓰는 일위대가」" in detail and a in detail + assert _files("sangeun") == [a] and client.get(sk.url).json()["폴더"] == [["묶음"]] + + +def test_여럿_옮기기_지우기(client: TestClient): # noqa: F811 + t = Tree(client) # 개인 층 일위대가 SC-000001~3 + t.do(op="add-folder", 경로=[], 이름="가") + t.do(op="add-folder", 경로=[], 이름="나") + t.do( + op="move", 대상=[{"키": "SC-000001"}, {"키": "SC-000002"}, {"경로": ["나"]}], 새경로=["가"] + ) + assert t.now["폴더"] == [["가"], ["가", "나"]] + assert t.now["자리"] == {"SC-000001": ["가"], "SC-000002": ["가"]} + got = t.do(op="delete", 대상=[{"경로": ["가"]}, {"키": "SC-000001"}, {"키": "SC-000003"}]) + assert sorted(got["알림"]["지운문서"]) == ["SC-000001", "SC-000002", "SC-000003"] + assert _files("precombo") == [] and t.now["폴더"] == [] + + +def test_복사_붙여넣기_새_키_같은_이름_폴더는_1(client: TestClient): # noqa: F811 + t = Tree(client) + t.do(op="add-folder", 경로=[], 이름="가") + t.do(op="add-folder", 경로=["가"], 이름="속") + t.do(op="move", 키="SC-000001", 새경로=["가", "속"]) + url = "/api/m02/layers/personal/precombo-tree/paste" + picks = [{"경로": ["가"]}, {"경로": [], "키": "SC-000002"}] + res = client.post(url, json={"from_layer": "personal", "대상": picks, "경로": ["가"]}) + assert res.status_code == 200, res.text + got = res.json() + assert got["알림"]["만든문서"] == ["SC-000004", "SC-000005"] + assert ["가", "가"] in got["폴더"] and ["가", "가", "속"] in got["폴더"] + assert got["자리"]["SC-000004"] == ["가", "가", "속"] and got["자리"]["SC-000005"] == ["가"] + again = client.post(url, json={"from_layer": "personal", "대상": [{"경로": ["가"]}]}).json() + assert ["가(1)"] in again["폴더"] # 같은 이름 = 「(1)」 + doc = client.get("/api/m02/layers/personal/templates/precombo/SC-000005").json()["문서"] + assert doc["이름"] == "나" and doc["본뜬"]["키"] == "SC-000002" + bad = client.post(url, json={"from_layer": "personal", "대상": [{"키": "SC-000099"}]}) + assert bad.status_code == 404 + + +@pytest.mark.parametrize("kind", ["precombo", "sangeun"]) +def test_순수_지우기_알림(kind: str, tmp_path: Path): + doc = {"폴더": [["가"], ["가", "나"]], "자리": {"X-1": ["가", "나"], "X-2": []}} + out, note = pre.apply(doc, {"op": "delete", "경로": ["가"]}, {"X-1", "X-2"}) + assert out == {"폴더": [], "자리": {}} and note["지운문서"] == ["X-1"] + assert pre.file_of(kind) == f"{kind}_폴더.json" diff --git a/resources/tester/test_m02_precombo_folders.py b/resources/tester/test_m02_precombo_folders.py index 3c19a088f..b3b4e98c2 100644 --- a/resources/tester/test_m02_precombo_folders.py +++ b/resources/tester/test_m02_precombo_folders.py @@ -1,7 +1,7 @@ """일위대가 사전조합 나무 = 자유 폴더(PLAN 158-1 · `M02_Precombo_Folders`) — 임시 storage. 새 폴더(깊이 자유) · 문서 옮기기(파일은 제자리 · 자리만) · 폴더 이름 바꾸기 · -옮기기(아래 자리 따라감) · 지우기(빈 폴더만 · 문서 있으면 409 까닭) · 판 409 · +옮기기(아래 자리 따라감) · 지우기(안에 든 문서째 · 716-4) · 판 409 · 층(마스터 · 회사 · 개인)마다 따로 · 문서 수(양식 목록) 그대로 · 쓰기 권한 = 층 규칙(마스터 403). """ @@ -88,7 +88,7 @@ def test_문서_옮기기는_자리만_파일과_문서_수_그대로(client: Te 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-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()) == [ @@ -97,7 +97,7 @@ def test_문서_옮기기는_자리만_파일과_문서_수_그대로(client: Te "SC-000003.json", ] assert _docs(client) == ["SC-000001", "SC-000002", "SC-000003"] # 자리 파일은 목록에 안 섞임 - assert (personal / pre.FILE).is_file() + assert (personal / pre.file_of("precombo")).is_file() def test_폴더_이름_바꾸기_옮기기는_아래_자리가_따라감(client: TestClient): @@ -117,17 +117,16 @@ def test_폴더_이름_바꾸기_옮기기는_아래_자리가_따라감(client: assert t.now["알림"]["바뀐이름"] == ["나(1)"] and t.now["자리"]["SC-000001"] == ["나(1)"] -def test_지우기는_빈_폴더만_문서가_있으면_까닭(client: TestClient): +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", 경로=["가"]) # 빈 아래 폴더까지 + got = t.do(op="delete", 경로=["가"]) # 아래 폴더 · 문서까지(716-4) + assert got["알림"]["지운문서"] == ["SC-000003"] assert t.now["폴더"] == [] and t.now["자리"] == {} - assert _docs(client) == ["SC-000001", "SC-000002", "SC-000003"] + assert _docs(client) == ["SC-000001", "SC-000002"] def test_판이_다르면_409_고침_안_함(client: TestClient): diff --git a/resources/tester/test_m02_precombo_scope.py b/resources/tester/test_m02_precombo_scope.py index 081da91c9..1271ecae6 100644 --- a/resources/tester/test_m02_precombo_scope.py +++ b/resources/tester/test_m02_precombo_scope.py @@ -84,7 +84,8 @@ def test_고름에_owner_권한_서고_길에_명시_owner(): side = _text("M02_MasterTemplete_UI_Side_Precombo.ts") assert "PRECOMBO_TITLE" not in side # 716-3 — 제목은 locale 키 「일위대가」 assert "owner: scope.owner," in side and "canWrite: scope.canWrite," in side - assert "listTemplates(at.layer, null, at.owner)" in side + lib_side = _text("M02_MasterTemplete_UI_Side_Library.ts") # 716-4 서고 칸 한 벌 + assert "listTemplates(at.layer, null, at.owner)" in lib_side main = _text("M02_MasterTemplete_UI_Main.ts") assert ( "const writable = (at: Selection): boolean => at.canWrite ?? canEdit(at.layer, isAdmin);" @@ -100,23 +101,26 @@ def test_고름에_owner_권한_서고_길에_명시_owner(): def test_분류_고정_폴더_지움_자유_폴더_메뉴_끌어_옮기기(): - side = _text("M02_MasterTemplete_UI_Side_Precombo.ts") + pre_side = _text("M02_MasterTemplete_UI_Side_Precombo.ts") + side = _text("M02_MasterTemplete_UI_Side_Library.ts") # 716-4 서고 칸 한 벌 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 + assert gone not in side and gone not in pre_side, gone + assert "fetchPrecomboTree(at.layer, at.owner, kind)" in side + assert "editPrecomboTree(at.layer, body, 판, at.owner, kind)" in side menu = side.split("const menuAt = (")[1].split("\n };")[0] - for label in ('L("M02_TreeAddFolder")', 'tc("Lib_NewHere")', 'L("M02_TreeRename")'): + for label in ('L("M02_TreeAddFolder")', "opt.text.newHere", 'L("M02_TreeRename")'): assert label in menu, label - assert 'L("M02_Delete"), () => void remove(path, count)' in menu + assert 'item(L("M02_Delete"), () => void remove([target]))' 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 # 빈 폴더만 + assert "showConfirmDialog(ask" in remove and 'op: "delete", 대상:' in remove # 안에 든 것째 + assert 'newHere: tc("Lib_NewHere")' in pre_side + assert '({ 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] })' in pre_side make = side.split("const make = (path: string[]): void => {")[1].split("\n };")[0] - assert 'const 문서 = { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] };' in make + assert "{ 문서: opt.blank(name) }" 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 "openTreeMenu(event, menuAt([]));" in side # 빈 자리 = 뿌리 메뉴 assert "open: toggled.get(id) ?? count > 0," in side lib = _text("M02_MasterTemplete_Library_List.ts") assert "export const nameDialog = (" in lib From 69d989e5c804b1914652a592e47f540d33c58b68 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:39:36 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(717-1):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=83=81=EC=84=B8=20=EB=B6=80=ED=92=88=20=EA=B8=B8?= =?UTF-8?q?=20=EC=A3=BC=EC=9E=85=20=C2=B7=20M02=20logic-lib=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=85=88=20=EA=B8=B8=20=EB=84=93=ED=9E=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Logic_Api.ts · Api_Fetch.ts · Logic_Note.ts — `/m01` 박힘 대신 setLogicApiPaths({ read, calc }) 길 묶음 · 안 넘기면 /m01 그대로 · 저장 · 만들기 · 관리자 길은 늘 /m01 - 새 M02_Logic_Lib_Read.py — /api/m02/logic-lib/ 로그인 길(logic/auto · logic/flow · elements · pick · materials · rows(인력 · 재료 · 기계만) · table(소요량 · 계수만) · calc · text) · M01 길 함수 그대로 부름 · row = 저장 안 한 줄 셈만 · 쓰기 없음 - main.py 등록(로그인만) · 지킴 시험 test_717_1_logic_lib.py + helper Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Api_Fetch.ts | 26 ++++-- M01_MasterData/M01_MasterData_UI_Logic_Api.ts | 39 +++++--- .../M01_MasterData_UI_Logic_Note.ts | 4 +- M02_MasterTemplete/M02_Logic_Lib_Read.py | 73 +++++++++++++++ main.py | 5 + .../tester/helper_717_1_logic_api_paths.cjs | 47 ++++++++++ resources/tester/test_717_1_logic_lib.py | 93 +++++++++++++++++++ 7 files changed, 268 insertions(+), 19 deletions(-) create mode 100644 M02_MasterTemplete/M02_Logic_Lib_Read.py create mode 100644 resources/tester/helper_717_1_logic_api_paths.cjs create mode 100644 resources/tester/test_717_1_logic_lib.py diff --git a/M01_MasterData/M01_MasterData_Api_Fetch.ts b/M01_MasterData/M01_MasterData_Api_Fetch.ts index c2eaeb337..05f9f82d2 100644 --- a/M01_MasterData/M01_MasterData_Api_Fetch.ts +++ b/M01_MasterData/M01_MasterData_Api_Fetch.ts @@ -4,6 +4,7 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; +import { logicApiPaths } from "./M01_MasterData_UI_Logic_Api"; /** 줄·표 한 개 — 파일 그대로(한글 칸). */ export type Row = Record; @@ -89,8 +90,12 @@ export type SaveResult = | { status: 422; errors: string[] } | { status: number; detail: string }; -async function call(path: string, init: RequestInit = {}): Promise<{ status: number; body: T }> { - const response = await fetch(`${API_BASE_URL}/m01${path}`, { +async function call( + path: string, + init: RequestInit = {}, + base = "/m01", +): Promise<{ status: number; body: T }> { + const response = await fetch(`${API_BASE_URL}${base}${path}`, { credentials: "include", headers: { "Content-Type": "application/json" }, ...init, @@ -98,9 +103,13 @@ async function call(path: string, init: RequestInit = {}): Promise<{ status: return { status: response.status, body: (await response.json()) as T }; } -async function get(path: string, params: Record): Promise { +async function get( + path: string, + params: Record, + base = "/m01", +): Promise { const query = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)])); - const { status, body } = await call(`${path}?${query}`); + const { status, body } = await call(`${path}?${query}`, {}, base); if (status !== 200) throw new Error(String(body.detail ?? `HTTP ${status}`)); return body; } @@ -121,7 +130,11 @@ export const fetchRows = ( detail = "", state = "", ): Promise => - get("/rows", { file, page, size, q, unlinked: unlinked ? 1 : 0, sub, detail, state }); + get( + "/rows", + { file, page, size, q, unlinked: unlinked ? 1 : 0, sub, detail, state }, + logicApiPaths().read, // 고르기 표 · 품목 찾기 — 상세 부품 길(717-1) + ); /** 한 테이블 파일은 `file` · 장 그룹(소요량 · 계수 · 로직)은 `group` 으로 부름. */ export const fetchSubsAll = (where: { @@ -174,7 +187,8 @@ export interface PickItem { export const fetchPick = ( kind: PickKind, q: string, -): Promise<{ total: number; items: PickItem[] }> => get("/pick", { kind, q, limit: 50 }); +): Promise<{ total: number; items: PickItem[] }> => + get("/pick", { kind, q, limit: 50 }, logicApiPaths().read); /** 자재 찾기 — `/pick?kind=price` 감쌈 · 이름·규격 낱말이 모두 든 줄 */ export const searchMaterials = async ( diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index 947a8a044..2ebcfbef4 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -190,8 +190,22 @@ export class ApiError extends Error { } } -async function request(path: string, body?: unknown): Promise { - const response = await fetch(`${API_BASE_URL}/m01${path}`, { +/** 상세 부품이 부르는 길 앞머리(717-1) — 읽기(로직 · 요소 · 재료 · 표 · 줄) · 셈(`/calc` · `/text`) + * · 기본 = M01(`/m01` · 시스템 관리자) · M02 = `{ read: "/m02/logic-lib", calc: "/m02/logic-lib" }`(로그인) */ +export interface LogicApiPaths { + read: string; + calc: string; +} +const M01_PATHS: LogicApiPaths = { read: "/m01", calc: "/m01" }; +let paths = M01_PATHS; +/** 상세를 그리기 전에 한 번 — 안 넘기면 M01 길로 되돌림 · 저장 · 만들기 길은 늘 `/m01` */ +export const setLogicApiPaths = (next?: LogicApiPaths): void => { + paths = next ?? M01_PATHS; +}; +export const logicApiPaths = (): LogicApiPaths => paths; + +async function request(path: string, body?: unknown, base = "/m01"): Promise { + const response = await fetch(`${API_BASE_URL}${base}${path}`, { method: body === undefined ? "GET" : "POST", credentials: "include", headers: { "Content-Type": "application/json" }, @@ -205,13 +219,16 @@ async function request(path: string, body?: unknown): Promise { const query = (params: Record): string => new URLSearchParams(params).toString(); export const fetchLogics = (): Promise => - request<{ logics: LogicSummary[] }>("/logics").then((d) => d.logics); + request<{ logics: LogicSummary[] }>("/logics", undefined, paths.read).then((d) => d.logics); /** 왼쪽 트리 목록 — 서버가 준 구분·상세구분(장 차례대로) */ export const fetchLogicSubs = (): Promise => - request<{ subs: SubBrief[] }>(`/subs?${query({ group: "로직" })}`).then((d) => d.subs); + request<{ subs: SubBrief[] }>(`/subs?${query({ group: "로직" })}`, undefined, paths.read).then( + (d) => d.subs, + ); -export const fetchLogic = (key: string): Promise => request(`/logic?${query({ key })}`); +export const fetchLogic = (key: string): Promise => + request(`/logic?${query({ key })}`, undefined, paths.read); export const fetchLogicFiles = (): Promise => request<{ files: LogicFile[] }>(`/groups/${encodeURIComponent("로직")}/files`).then( @@ -222,10 +239,10 @@ export const searchElements = ( group: string, q: string, ): Promise<{ total: number; items: ElementBrief[] }> => - request(`/elements?${query({ group, q, limit: "100" })}`); + request(`/elements?${query({ group, q, limit: "100" })}`, undefined, paths.read); export const searchPrice = (q: string): Promise<{ total: number; items: ElementBrief[] }> => - request(`/pick?${query({ kind: "price", q, limit: "100" })}`); + request(`/pick?${query({ kind: "price", q, limit: "100" })}`, undefined, paths.read); export const fetchMaterials = (cond: { sub: string; @@ -235,11 +252,11 @@ export const fetchMaterials = (cond: { /** 호표 줄 단위 — 주면 그 단위 줄만 */ unit?: string; }): Promise<{ total: number; 기본: string | null; items: ElementBrief[] }> => - request(`/materials?${query({ ...cond, limit: "100" })}`); + request(`/materials?${query({ ...cond, limit: "100" })}`, undefined, paths.read); /** 자동값 길(`GET /logic/auto`) — 입력 이름 → 견본값 · 서버에 아직 없거나 값이 없으면 빈 채로(화면은 그냥 안 채움) */ export const fetchAuto = (key: string): Promise> => - request<{ 값?: Record }>(`/logic/auto?${query({ key })}`) + request<{ 값?: Record }>(`/logic/auto?${query({ key })}`, undefined, paths.read) .then((d) => d.값 ?? {}) .catch(() => ({})); @@ -250,7 +267,7 @@ export const runCalc = (body: { file?: string; /** 시험 계산에서 고른 재료 — 호표 줄 차례 → 자재품목 키 · 저장 안 함 */ 고름?: Record; -}): Promise => request("/calc", body); +}): Promise => request("/calc", body, paths.calc); export const runText = (body: { key: string; @@ -259,7 +276,7 @@ export const runText = (body: { file?: string; /** 시험 계산에서 고른 재료 — 호표 줄 차례 → 자재품목 키 · 저장 안 함 */ 고름?: Record; -}): Promise => request("/text", body); +}): Promise => request("/text", body, paths.calc); export const saveFiles = ( files: SaveFile[], diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Note.ts b/M01_MasterData/M01_MasterData_UI_Logic_Note.ts index 40e41ed6f..f9936df40 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Note.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Note.ts @@ -9,7 +9,7 @@ import { API_BASE_URL } from "@config/config_frontend"; import { el } from "@ui/ui_template_elements"; -import type { ElementBrief, NamedFormula } from "./M01_MasterData_UI_Logic_Api"; +import { logicApiPaths, type ElementBrief, type NamedFormula } from "./M01_MasterData_UI_Logic_Api"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; import { tx } from "./M01_MasterData_UI_Logic_Text"; @@ -31,7 +31,7 @@ const tableCache = new Map>(); async function getJson(path: string): Promise { try { - const res = await fetch(`${API_BASE_URL}/m01${path}`); + const res = await fetch(`${API_BASE_URL}${logicApiPaths().read}${path}`); if (!res.ok) return null; return (await res.json()) as T; } catch { diff --git a/M02_MasterTemplete/M02_Logic_Lib_Read.py b/M02_MasterTemplete/M02_Logic_Lib_Read.py new file mode 100644 index 000000000..ae6231d52 --- /dev/null +++ b/M02_MasterTemplete/M02_Logic_Lib_Read.py @@ -0,0 +1,73 @@ +"""단가산출 로직 상세 읽기 · 셈 길(PLAN 717-1) — `/api/m02/logic-lib/…` · 로그인만(`main.py`). + +M01 상세 부품(`M01_MasterData_UI_Logic_Api.ts` · `Api_Fetch.ts` · `Logic_Note.ts`)이 길 앞머리만 +`/m02/logic-lib` 로 바꿔 부름 — 길 · 몸 · 답 = M01 `/api/m01/…` 와 같음(M01 길 함수를 부름). +쓰기 없음 — `/calc` · `/text` 의 `row`(저장 안 한 고친 줄)는 메모리 사본에서만 셈(`Store._swap`). +`/logics` · `/subs` · `/logic` · 원문은 `M02_MasterTemplete_Router_Combos.py` 에 있음. +""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from M01_MasterData import M01_MasterData_Router as m01 + +router = APIRouter(prefix="/api/m02/logic-lib", tags=["M02 Logic Lib Read"]) + +#: `/rows` 로 줄을 내줄 요소 파일 앞머리 — 인력 · 재료 · 기계(고르기 표 · 품목 찾기) +ROW_FILES = ("인력", "재료", "기계") +#: `/table` 로 내줄 표 파일 앞머리 — 소요량 · 계수(설명 카드 원문 표) +TABLE_FILES = ("소요량_", "계수_") + + +@router.get("/logic/auto") +def get_auto(key: str) -> dict: + return m01.get_logic_auto(key) + + +@router.get("/logic/flow") +def get_flow(key: str, file: str = "") -> dict: + """흐름 보기(읽기 · 변환해도 파일 안 바뀜).""" + return m01.get_logic_flow(key, file) + + +@router.get("/elements") +def get_elements(group: str, q: str = "", limit: int = 50) -> dict: + return m01.get_elements(group, q, min(limit, 200)) + + +@router.get("/pick") +def get_pick(kind: str, q: str = "", limit: int = 50) -> dict: + return m01.get_pick(kind, q, min(limit, 200)) + + +@router.get("/materials") +def get_materials( + sub: str, detail: str = "", spec: str = "", region: str = "", unit: str = "", limit: int = 50 +) -> dict: + return m01.get_materials(sub, detail, spec, region, unit, min(limit, 200)) + + +@router.get("/rows") +def get_rows(file: str, page: int = 1, size: int = 50, q: str = "", sub: str = "") -> dict: + if not file.startswith(ROW_FILES): + raise HTTPException(status_code=403, detail="요소 파일(인력 · 재료 · 기계)만 읽음") + return m01.get_rows(file, page, size, q, False, sub, "", "") + + +@router.get("/table") +def get_table(file: str, key: str) -> dict: + if not file.startswith(TABLE_FILES): + raise HTTPException(status_code=403, detail="소요량 · 계수 표만 읽음") + return m01.get_table(file, key) + + +@router.post("/calc") +def post_calc(body: m01.CalcBody) -> dict: + """M01 `/calc` 와 같음 — `row` = 저장 안 한 고친 줄(셈만 · 쓰기 없음).""" + return m01.post_calc(body) + + +@router.post("/text") +def post_text(body: m01.CalcBody) -> dict: + return m01.post_text(body) diff --git a/main.py b/main.py index d375c0544..982dfdf54 100644 --- a/main.py +++ b/main.py @@ -563,6 +563,11 @@ app.include_router(m02_items_router, dependencies=protected) app.include_router(m02_combos_router, dependencies=protected) app.include_router(m02_drawing_router, dependencies=protected) app.include_router(m02_labor_router, dependencies=protected) +# 단가산출 로직 상세 읽기 · 셈(PLAN 717-1) — 로그인만 · 쓰기 없음 +app.include_router( + __import__("M02_MasterTemplete.M02_Logic_Lib_Read", fromlist=["router"]).router, + dependencies=protected, +) # 단가산출(SK) 셈 길(PLAN 715-1b) — 로그인 · 층 읽기 권한은 길 안에서 app.include_router( __import__("M02_MasterTemplete.M02_Sangeun_Router", fromlist=["router"]).router, diff --git a/resources/tester/helper_717_1_logic_api_paths.cjs b/resources/tester/helper_717_1_logic_api_paths.cjs new file mode 100644 index 000000000..b67d706d8 --- /dev/null +++ b/resources/tester/helper_717_1_logic_api_paths.cjs @@ -0,0 +1,47 @@ +/* 717-1 헬퍼 — 상세 부품 길 주입 · 가짜 fetch 로 부른 주소만 모음. 짝 = `test_717_1_logic_lib.py`. */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const { load } = makeLoader({ + root: ROOT, + stubs: { M01_MasterData_UI_Logic_Edit: {}, M01_MasterData_UI_Logic_Text: {} }, +}); +const urls = []; +global.fetch = async (url) => { + urls.push(String(url).replace("http://fake", "")); + return { + ok: true, + status: 200, + statusText: "", + json: async () => ({ logics: [], subs: [], items: [], rows: [] }), + }; +}; + +const api = load("M01_MasterData", "M01_MasterData_UI_Logic_Api.ts"); +const rows = load("M01_MasterData", "M01_MasterData_Api_Fetch.ts"); +const note = load("M01_MasterData", "M01_MasterData_UI_Logic_Note.ts"); + +async function round(tag) { + await api.fetchLogic("K"); + await api.fetchAuto("K"); + await api.searchElements("재료", "q"); + await api.fetchMaterials({ sub: "s" }); + await api.runCalc({ key: "K", inputs: {} }); + await api.runText({ key: "K", inputs: {} }); + await rows.fetchRows("기계.json", 1, 5, "q"); + await rows.fetchPick("price", "q"); + await note.loadTable(`T-${tag}`); + await rows.fetchGroups(); // 관리자 길 — 늘 /m01 + await api.saveFiles([]); // 저장 — 늘 /m01 + return urls.splice(0).map((u) => u.split("?")[0]); +} + +(async () => { + const out = { before: await round("a") }; + api.setLogicApiPaths({ read: "/m02/logic-lib", calc: "/m02/calc-x" }); + out.injected = await round("b"); + api.setLogicApiPaths(); + out.after = await round("c"); + process.stdout.write(JSON.stringify(out)); +})(); diff --git a/resources/tester/test_717_1_logic_lib.py b/resources/tester/test_717_1_logic_lib.py new file mode 100644 index 000000000..aa97c99c2 --- /dev/null +++ b/resources/tester/test_717_1_logic_lib.py @@ -0,0 +1,93 @@ +"""717-1 상세 부품 공용 — 길 주입(TS) · M02 `logic-lib` 읽기 · 셈 길(로그인 · 쓰기 없음). + +TS = `helper_717_1_logic_api_paths.cjs`(가짜 fetch 주소) · 서버 = 정본 읽기만(파일 지문 같음). +""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +from pathlib import Path + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from M01_MasterData import M01_MasterData_Store as store +from M02_MasterTemplete import M02_Logic_Lib_Read as lib + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).parent / "helper_717_1_logic_api_paths.cjs" +KEY = "GF000001" # 경계표시 — 보통인부 한 줄 +BASE = "/api/m02/logic-lib" + + +@pytest.mark.skipif(not (ROOT / "config" / "node_modules").is_dir(), reason="의존성 미설치") +def test_길_주입_없으면_m01_있으면_넘긴_길() -> None: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + out = json.loads(done.stdout) + assert out["before"] == out["after"] # 안 넘기면 지금 그대로 + assert all(u.startswith("/m01/") for u in out["before"]) + shared, admin = out["injected"][:-2], out["injected"][-2:] + assert all(u.startswith(("/m02/logic-lib/", "/m02/calc-x/")) for u in shared) + assert [u for u in shared if u.startswith("/m02/calc-x/")] == [ + "/m02/calc-x/calc", + "/m02/calc-x/text", + ] + assert admin == ["/m01/groups", "/m01/save"] # 관리자 · 저장 길은 늘 M01 + + +@pytest.fixture(scope="module") +def user() -> TestClient: + """main.py 와 같은 등록(로그인만) · 일반 사용자.""" + app = FastAPI() + app.include_router(lib.router, dependencies=[Depends(verify_session)]) + app.dependency_overrides[verify_session] = lambda: { + "user_id": 42, "company_id": 7, "role": "USER", "is_master": False, + } # fmt: skip + return TestClient(app) + + +def _prints() -> dict[str, str]: + return {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in store.FOLDER.glob("*.json")} + + +def test_일반_계정_읽기(user: TestClient) -> None: + assert user.get(f"{BASE}/logic/auto", params={"key": KEY}).status_code == 200 + assert user.get(f"{BASE}/elements", params={"group": "인력", "q": "LB000002"}).json()["items"] + assert user.get(f"{BASE}/pick", params={"kind": "price", "q": "시멘트"}).status_code == 200 + assert user.get(f"{BASE}/rows", params={"file": "기계.json", "size": 3}).json()["rows"] + assert user.get(f"{BASE}/materials", params={"sub": "시멘트"}).status_code == 200 + flow = user.get(f"{BASE}/logic/flow", params={"key": KEY}) + assert flow.status_code == 200 and "flow" in flow.json() + found = user.get(f"{BASE}/elements", params={"group": "소요량", "q": "QF000025"}).json() + file = next(i["file"] for i in found["items"] if i["ref"] == "QF000025") + assert user.get(f"{BASE}/table", params={"file": file, "key": "QF000025"}).json()["table"] + + +def test_요소_표_밖_파일은_막음(user: TestClient) -> None: + logic_file = store.logics("", "", "", None, "")[0]["file"] + assert user.get(f"{BASE}/rows", params={"file": logic_file}).status_code == 403 + assert user.get(f"{BASE}/table", params={"file": "기계.json", "key": "x"}).status_code == 403 + assert user.get(f"{BASE}/rows", params={"file": "재료_../x.json"}).status_code == 404 + + +def test_저장_안_한_줄_셈_쓰기_없음(user: TestClient) -> None: + before = _prints() + saved = user.post(f"{BASE}/calc", json={"key": KEY, "inputs": {}}).json() + row = store.logic(KEY)["logic"] + same = user.post(f"{BASE}/calc", json={"key": KEY, "inputs": {}, "row": row}).json() + line = row["호표"][0] + row2 = {**row, "호표": [{**line, "수량": f"({line['수량']}) * 2"}, *row["호표"][1:]]} + double = user.post(f"{BASE}/calc", json={"key": KEY, "inputs": {}, "row": row2}).json() + text = user.post(f"{BASE}/text", json={"key": KEY, "inputs": {}, "row": row2}).json() + assert saved["ok"] and same["sums"] == saved["sums"] + assert double["sums"]["노무비"] == pytest.approx(saved["sums"]["노무비"] * 2) + assert text["ok"] and text["계"] == pytest.approx(double["sums"]["계"], rel=1e-6) + assert store.logic(KEY)["logic"]["호표"][0]["수량"] == line["수량"] # 정본 그대로 + assert _prints() == before