From cd8a2263234e1762e7572c5c70337007cc724052 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:06:22 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(M02):=20=EC=98=AE=EA=B8=B0=EA=B8=B0=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EC=9D=B4=EB=A6=84=20=3D=20=E3=80=8C?= =?UTF-8?q?=EC=9D=B4=EB=A6=84(1)=E3=80=8D=20=E2=80=94=20=ED=95=AD=EB=AA=A9?= =?UTF-8?q?=20=C2=B7=20=ED=8F=B4=EB=8D=94=20=C2=B7=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=EA=B0=9C=20=C2=B7=20=EB=B6=99=EC=97=AC=EB=84=A3=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=B3=B5=EC=82=AC=20=EC=83=9D=EC=84=B1=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=8C(86-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- M02_MasterTemplete/M02_Item_Paste.py | 25 ++++++------- M02_MasterTemplete/M02_Item_Personal.py | 7 +--- .../M02_MasterTemplete_Api_Fetch.ts | 2 + .../M02_MasterTemplete_Router_Items.py | 5 ++- M02_MasterTemplete/M02_MasterTemplete_Tree.py | 26 ++++++++++--- .../M02_MasterTemplete_TreePanel.ts | 8 +++- resources/tester/test_m02_tree.py | 37 ++++++++++++++++++- resources/tester/test_m02_tree_paste.py | 21 +++++++++-- ui_template/ui_template_locale_m2.ts | 4 ++ 9 files changed, 103 insertions(+), 32 deletions(-) diff --git a/M02_MasterTemplete/M02_Item_Paste.py b/M02_MasterTemplete/M02_Item_Paste.py index 453dce6fb..e906dffb6 100644 --- a/M02_MasterTemplete/M02_Item_Paste.py +++ b/M02_MasterTemplete/M02_Item_Paste.py @@ -1,7 +1,7 @@ """여러 항목 · 폴더를 층 사이 붙여넣기(PLAN 30-2) — 구조물 라이브러리 나무의 [복사] → [붙여넣기]. 붙일 폴더(`dest`) 아래 새 항목 — 대상 층 새 키(SC · SP) · 열 `본뜬` = 원본(층 · 주인 · 키) · -같은 이름은 「이름 (2)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더 (2)」). +같은 이름은 「이름(1)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더(1)」). 파일(도면 · 면적 · 입력 연결)은 `Items.copy_item` 이 옮김. 권한은 부르는 쪽 몫. """ @@ -40,13 +40,6 @@ def _items(doc: dict[str, Any]) -> list[dict[str, Any]]: return [c for c in doc.get("열") or [] if tree.is_item(c)] -def _unique(path: list[str], taken: list[list[str]]) -> list[str]: - n, out = 2, path - while out in taken: - out, n = [*path[:-1], f"{path[-1]} ({n})"], n + 1 - return out - - def paste( source: Path, target: Path, @@ -67,6 +60,7 @@ def paste( taken_folders = tree.folders_of(dst) plan: list[tuple[str, list[str]]] = [] # (원본 키, 새 경로) new_folders: list[list[str]] = [] + renamed: list[str] = [] # 겹쳐서 번호 붙은 새 이름 def busy(path: list[str]) -> bool: paths = [*taken, *(p for _, p in plan)] @@ -78,16 +72,18 @@ def paste( found = next((p for p, k in src_items if k == key), None) if found is None: raise store.StoreError(404, f"없는 항목 「{key}」") - plan.append((key, _unique([*dest, found[-1]], [*taken, *(p for _, p in plan)]))) + used = [*taken, *(p for _, p in plan)] + path = tree.unique_path([*dest, found[-1]], lambda p: p in used) + renamed += [path[-1]] if path[-1] != found[-1] else [] + plan.append((key, path)) continue folder = list(one.get("경로") or []) if not folder or ( folder not in src_folders and not any(_under(p, folder) for p, _ in src_items) ): raise store.StoreError(404, f"없는 폴더 「{' '.join(folder)}」") - top, n = [*dest, folder[-1]], 2 - while busy(top): - top, n = [*dest, f"{folder[-1]} ({n})"], n + 1 + top = tree.unique_path([*dest, folder[-1]], busy) + renamed += [top[-1]] if top[-1] != folder[-1] else [] new_folders.append(top) new_folders += [[*top, *f[len(folder) :]] for f in src_folders if _under(f, folder)] plan += [(k, [*top, *p[len(folder) :]]) for p, k in src_items if _under(p, folder) and k] @@ -102,4 +98,7 @@ def paste( doc = got["문서"] doc["폴더"] = [*(doc.get("폴더") or []), *new_folders] store.write("table", store.STRUCTURE_TABLE, got["판"], doc) - return {"op": "paste", "경로": dest, "열": made} + note: dict[str, Any] = {"op": "paste", "경로": dest, "열": made} + if renamed: + note["바뀐이름"] = renamed + return note diff --git a/M02_MasterTemplete/M02_Item_Personal.py b/M02_MasterTemplete/M02_Item_Personal.py index 2ffd79765..b98cc2f5c 100644 --- a/M02_MasterTemplete/M02_Item_Personal.py +++ b/M02_MasterTemplete/M02_Item_Personal.py @@ -34,13 +34,10 @@ def heads_of(path: list[str], name: str | None) -> list[str]: def _free_path(doc: dict[str, Any], full: list[str]) -> list[str]: - """같은 경로 항목이 이미 있으면 마지막 이름을 「이름 (2)」 로 + """같은 경로 항목이 이미 있으면 마지막 이름을 「이름(1)」 로 (나무는 같은 경로 두 항목을 안 둠).""" taken = [tree.path_of(c) for c in doc.get("열", []) if tree.is_item(c)] - out, n = list(full), 2 - while out in taken: - out, n = [*full[:-1], f"{full[-1]} ({n})"], n + 1 - return out + return tree.unique_path(list(full), lambda p: p in taken) def create_item( diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index a76619d33..30ec77179 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -354,6 +354,8 @@ export interface TreeNote { /** 새 · 고친 항목 열 — 붙여넣기는 새 열 목록(PLAN 30-2) */ 열?: string | string[]; 지운항목?: string[]; + /** 같은 이름이 있어 「이름(1)」 꼴로 바뀐 새 이름 — 옮기기 · 붙여넣기(PLAN 86-2) */ + 바뀐이름?: string[]; } export interface TreeView { 판: string; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index d92c53a72..da280d8c1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -235,11 +235,12 @@ def _edit_tree(folder: Path, body: dict[str, Any]) -> dict[str, Any]: else: # 여러 개 — 차례로 고쳐 저장은 한 번(하나라도 막히면 아무것도 안 씀) if body.get("op") not in ("move", "delete") or not targets: raise tree.TreeError(422, "여러 개는 옮기기 · 지우기만 — 「대상」 이 필요함") - doc, note = got["문서"], {"op": body["op"], "지운항목": []} + doc, note = got["문서"], {"op": body["op"], "지운항목": [], "바뀐이름": []} for one in paste_store.outer(targets): step = {**body, "경로": one.get("경로"), "키": one.get("키")} doc, done = tree.apply(doc, step, ids) note["지운항목"] += done.get("지운항목", []) + note["바뀐이름"] += done.get("바뀐이름", []) except tree.TreeError as e: raise store.StoreError(e.status, e.detail) from e store.write("table", store.STRUCTURE_TABLE, got["판"], doc) @@ -267,7 +268,7 @@ def post_paste( session: Session = Depends(verify_session), ): """여러 항목 · 폴더를 층 사이 붙여넣기(PLAN 30-2) — 복사한 층은 읽기 권한 · 이 층은 쓰기 권한 · - 새 키(SC · SP) · 본뜬 = 원본 · 같은 이름 「이름 (2)」 · 폴더는 안의 것까지 → 새 나무 + 알림.""" + 새 키(SC · SP) · 본뜬 = 원본 · 같은 이름 「이름(1)」 · 폴더는 안의 것까지 → 새 나무 + 알림.""" target = _root(session, layer, owner, write=True) source = _root(session, body.from_layer, body.from_owner) who = access.grant(session, body.from_layer, body.from_owner).owner diff --git a/M02_MasterTemplete/M02_MasterTemplete_Tree.py b/M02_MasterTemplete/M02_MasterTemplete_Tree.py index 7e3d0369e..22d6c85dd 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Tree.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Tree.py @@ -14,6 +14,7 @@ from __future__ import annotations import copy import re +from collections.abc import Callable from typing import Any MIN_LEVELS = 4 # 옛 약속 — 종류 · 분류1 · 분류2 · 분류3 @@ -173,6 +174,14 @@ def _taken_item(doc: dict[str, Any], path: list[str], skip=None) -> None: raise TreeError(409, f"이미 있는 항목 「{name_of(path)}」") +def unique_path(path: list[str], busy: Callable[[list[str]], bool]) -> list[str]: + """겹치는 경로면 마지막 이름을 「이름(1)」 「이름(2)」… 로 — 옮기기 · 붙여넣기 · 복사 생성이 한 벌을 씀.""" + out, n = path, 1 + while busy(out): + out, n = [*path[:-1], f"{path[-1]}({n})"], n + 1 + return out + + def _set_path(col: dict[str, Any], path: list[str]) -> None: col["경로"] = path col["머리"] = list(path) # 머리 줄 수는 저장 맞춤(`normalize_table`)이 채움 @@ -272,21 +281,28 @@ def apply( to = _path(body.get("새경로"), "새경로") if key: col = _find(out, key, ids) - moved = [*to, path_of(col)[-1]] - _taken_item(out, moved, [col]) + want = [*to, path_of(col)[-1]] + taken = _item_paths(out, [col]) + moved = unique_path(want, lambda p: p in taken) # 같은 폴더 같은 이름 = 「이름(1)」 out["열"] = [c for c in out["열"] if c is not col] _set_path(col, moved) _place(out, col) note["경로"] = moved + if moved != want: + note["바뀐이름"] = [moved[-1]] else: _folder(out, where, "옮길") if _under(to, where): raise TreeError(422, "폴더를 제 안으로 옮길 수 없음") - moved = [*to, where[-1]] - if moved in folders_of(out) or any(_under(p, moved) for p in _item_paths(out)): - raise TreeError(409, f"이미 있는 폴더 「{name_of(moved)}」") + folders_now, paths_now = folders_of(out), _item_paths(out) + moved = unique_path( + [*to, where[-1]], + lambda p: p != where and (p in folders_now or any(_under(x, p) for x in paths_now)), + ) _move_folder(out, where, moved, replace=True) note["경로"] = moved + if moved[-1] != where[-1]: + note["바뀐이름"] = [moved[-1]] else: # delete if key: gone = [_find(out, key, ids)] diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index f8c2ed41f..942642abd 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -425,7 +425,13 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { } picked = new Set(); paint(); - showToast(L("M02_TreeDone"), "success"); + const renamed = note?.바뀐이름 ?? []; + showToast( + renamed.length + ? L("M02_TreeRenamed").replace("{value}", renamed.join("」 · 「")) + : L("M02_TreeDone"), + "success", + ); await opt.onChanged(col); } catch (error) { // 409 = 판 다름(서버 detail 이 객체 → 「HTTP 409」) 또는 같은 이름(글 까닭 — 그대로 보임) diff --git a/resources/tester/test_m02_tree.py b/resources/tester/test_m02_tree.py index af43e062d..306d82db7 100644 --- a/resources/tester/test_m02_tree.py +++ b/resources/tester/test_m02_tree.py @@ -198,6 +198,39 @@ def test_빈_층에서_폴더_항목_깊이_1_2_5_이름_옮기기_지우기(wor assert json.loads((root / "table/구조물집계표.json").read_bytes())["열"] == [] +def test_옮기기_같은_이름은_이름_번호로_저장(world): + """옮기기(항목 · 폴더) 같은 폴더 같은 이름 = 말없이 「이름(1)」 「이름(2)」 · 키 · 번호 그대로. + 사용자가 직접 친 이름(더하기 · 이름 바꾸기) 겹침은 그대로 409.""" + root = world["storage"] / "7" / "42" / "templates" + t = Tree(_as(world), "personal") + t.do(op="add-folder", 경로=[], 이름="A") + t.do(op="add-folder", 경로=[], 이름="B") + for f in ("A", "B"): + t.do(op="add-item", 경로=[f], 이름="같음") + t.do(op="add-item", 경로=["B"], 이름="같음(1)") + t.do(op="add-folder", 경로=["A"], 이름="하위") + t.do(op="add-folder", 경로=["B"], 이름="하위") + t.do(op="add-item", 경로=["B", "하위"], 이름="안쪽") + mine = t.item(["A", "같음"]) + note = t.do(op="move", 키=mine["key"], 새경로=["B"])["알림"] + assert note["경로"] == ["B", "같음(2)"] and note["바뀐이름"] == ["같음(2)"] # (1) 도 있어서 + moved = t.item(["B", "같음(2)"]) + assert moved["key"] == mine["key"] and _numbers(root, moved["열"]) == _numbers(root, mine["열"]) + # 겹침 없으면 이름 그대로 · 바뀐이름 없음 · 제자리 옮기기도 그대로 + free = t.do(op="move", 키=moved["key"], 새경로=["A"])["알림"] + assert free["경로"] == ["A", "같음(2)"] and "바뀐이름" not in free + stay = t.do(op="move", 키=t.item(["B", "같음"])["key"], 새경로=["B"])["알림"] + assert stay["경로"] == ["B", "같음"] and "바뀐이름" not in stay + # 폴더 — 같은 이름 폴더가 이미 있으면 「하위(1)」 · 안의 항목 따라감 · 같은 부모로는 그대로 + folder = t.do(op="move", 경로=["A", "하위"], 새경로=["B"])["알림"] + assert folder["경로"] == ["B", "하위(1)"] and folder["바뀐이름"] == ["하위(1)"] + t.do(op="move", 경로=["B", "하위"], 새경로=["B"]) # 제자리 — 409 아님 + assert ["B", "하위", "안쪽"] in t.paths() + # 직접 친 이름은 지금처럼 막음 + t.do(409, op="add-item", 경로=["B"], 이름="같음") + t.do(409, op="rename", 키=t.item(["B", "같음(1)"])["key"], 이름="같음") + + def test_판_409_권한_403(world): admin = Tree(_as(world, "ADMIN"), "company") stale = admin.now["판"] @@ -254,7 +287,7 @@ def test_표_저장과_나무가_한_줄기(world): def test_프로젝트에서_새_항목도_경로_그대로(world): """개인에 저장 · 프로젝트에서 새 항목(`M02_Item_Personal`)도 경로를 넷에 접지 않고 그대로 · - 같은 경로면 「이름 (2)」.""" + 같은 경로면 「이름(1)」.""" from M02_MasterTemplete import M02_Item_Personal as personal folder = layers.personal_dir(7, 42) @@ -262,5 +295,5 @@ def test_프로젝트에서_새_항목도_경로_그대로(world): again = personal.create_item(folder, ["가", "나", "다", "라"], "마", "m") t = Tree(_as(world), "personal") assert t.item(["가", "나", "다", "라", "마"])["key"] == made["key"] - assert t.item(["가", "나", "다", "라", "마 (2)"])["key"] == again["key"] + assert t.item(["가", "나", "다", "라", "마(1)"])["key"] == again["key"] assert made["이름"] == "가 나 다 라 마" diff --git a/resources/tester/test_m02_tree_paste.py b/resources/tester/test_m02_tree_paste.py index 9b40d26dd..7cecd6ca3 100644 --- a/resources/tester/test_m02_tree_paste.py +++ b/resources/tester/test_m02_tree_paste.py @@ -1,7 +1,7 @@ """M02 구조물 라이브러리 여러 개 · 층 사이 붙여넣기(PLAN 30-2) — 임시 storage. ① 회사 항목 · 폴더를 일반 사용자 개인 층에 붙임 — 새 SP 키 · 본뜬 = 원본 · 세 파일 복사 · - 폴더는 안의 것 · 빈 폴더까지 · 다시 붙이면 「이름 (2)」 · 고른 폴더 안의 것은 한 번만. + 폴더는 안의 것 · 빈 폴더까지 · 다시 붙이면 「이름(1)」 · 고른 폴더 안의 것은 한 번만. ② 권한 — 쓰기 층만(일반 사용자 회사 층 403 · 남의 층 403) · 복사한 층은 읽기 권한(남의 회사 403). ③ 여러 개 옮기기 · 지우기 한 번에(하나라도 막히면 아무것도 안 바뀜). """ @@ -52,10 +52,10 @@ def test_회사_항목_폴더를_개인에_붙임_새_키_파일_이름2(world): assert made["본뜬"] == {"layer": "company", "owner": "7", "key": wall} for kind in ("structure", "area"): assert (root / kind / f"{made['id']}.json").is_file() - # 다시 붙이면 같은 이름 「(2)」 · 붙일 폴더 안으로 + # 다시 붙이면 같은 이름 「(1)」 · 붙일 폴더 안으로 again = client.post(PASTE.format("personal"), json={**body, "경로": []}).json() - assert ["배수 (2)", "측구"] in [r["경로"] for r in again["items"]] - assert ["옹벽 (2)"] in [r["경로"] for r in again["items"]] + assert ["배수(1)", "측구"] in [r["경로"] for r in again["items"]] + assert ["옹벽(1)"] in [r["경로"] for r in again["items"]] inner = client.post(PASTE.format("personal"), json={**body, "대상": [body["대상"][2]], "경로": ["배수"]}).json() # fmt: skip assert ["배수", "옹벽"] in [r["경로"] for r in inner["items"]] @@ -109,6 +109,19 @@ def test_여러_개_옮기기_지우기_한_번에(world): assert t.paths() == [["나", "둘"]] +def test_여러_개_옮기기_같은_이름은_이름_번호로(world): + t = Tree(_as(world), "personal") + t.do(op="add-folder", 경로=[], 이름="나") + t.do(op="add-item", 경로=["나"], 이름="같음") + t.do(op="add-item", 경로=[], 이름="같음") + t.do(op="add-folder", 경로=[], 이름="묶음") + t.do(op="add-item", 경로=["묶음"], 이름="같음") + moves = [{"경로": [], "키": t.item(["같음"])["key"]}, {"경로": ["묶음"]}] + note = t.do(op="move", 대상=moves, 새경로=["나"])["알림"] + assert sorted(t.paths()) == [["나", "같음"], ["나", "같음(1)"], ["나", "묶음", "같음"]] + assert note["바뀐이름"] == ["같음(1)"] + + def test_항목_추가_일위대가_단위_없이(world): """PLAN 31-2 · 39-1d — 항목 추가 = 종류(폴더) + 이름 · 단위 안 받아도 됨 · 일위대가 키는 안 받음 — 열 칸에 안 적고 늘 빈 labor 문서 diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 39a8a1f48..6a13b6696 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -157,6 +157,10 @@ export const ui_locales_m2 = { M02_TreeSaveGo: ["저장 후 넘어감", "Save and continue"], M02_TreeDiscardGo: ["버리고 넘어감", "Discard and continue"], M02_TreeDone: ["나무를 고침", "Tree updated"], + M02_TreeRenamed: [ + "나무를 고침 — 같은 이름이 있어 「{value}」 로 저장", + "Tree updated — name already taken, saved as “{value}”", + ], M02_PracticeTitle: ["실무문서", "Practice documents"], M02_PracticeRow: ["실무문서 목록", "Practice document list"], M02_PracticeCount: [ From 142194a1e5278c98798156a5f1d59ea35b61dc4c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:11:47 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=EC=83=81=EB=8B=A8=20=ED=91=9C=20=E3=80=8C?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=ED=95=A9=EA=B3=84?= =?UTF-8?q?=E3=80=8D=20=EC=9D=BD=EA=B8=B0=20=EC=A0=84=EC=9A=A9=20=EC=A4=84?= =?UTF-8?q?=20=E2=80=94=20=EB=B0=98=EC=98=81=ED=95=A9=20=C2=B7=20=EC=97=86?= =?UTF-8?q?=EC=9C=BC=EB=A9=B4=20=EA=B8=B0=EC=A4=80=ED=95=A9(=EA=B8=B0?= =?UTF-8?q?=EC=A4=80)=20=C2=B7=20=EC=B9=B4=EB=93=9C=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EB=92=A4=20=EB=8B=A4=EC=8B=9C=20=EC=9D=BD=EA=B8=B0(86-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../M02_MasterTemplete_Api_Fetch.ts | 3 ++ .../M02_MasterTemplete_ItemCards.ts | 14 ++++-- .../M02_MasterTemplete_Store.py | 32 +++++++++++-- .../M02_MasterTemplete_Structure.ts | 19 ++++++++ .../M02_MasterTemplete_UI_Main.ts | 3 ++ resources/tester/test_m02_combo_table.py | 46 +++++++++++++++++++ resources/tester/test_m02_structure_panel.py | 2 +- resources/tester/test_sheet_drawing_select.py | 2 +- ui_template/sheet/ui_template_sheet.ts | 3 ++ ui_template/sheet/ui_template_sheet_ops.ts | 4 +- ui_template/sheet/ui_template_sheet_render.ts | 23 +++++++--- ui_template/sheet/ui_template_sheet_text.ts | 1 + 12 files changed, 136 insertions(+), 16 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 30ec77179..6fc25f8ee 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -175,6 +175,9 @@ export interface StructureNumbers { 면적번: string; /** 일위대가 `SU-######`(39장) */ 일위대가번: string; + /** 일위대가 합계(86-3) — 반영합 · 없으면 기준합(`합계기준` 참) · 계산값 없으면 칸 없음 */ + 일위대가합계?: number; + 합계기준?: boolean; } /** `{열 id: {도번, 면적번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index ea605c524..0c35d5b1d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -24,7 +24,6 @@ import { readTemplate, saveTemplate, type StructureDoc, - type StructureNumbers, } from "./M02_MasterTemplete_Api_Fetch"; import { cardShell, @@ -48,7 +47,7 @@ export type EditKind = ItemPart; /** 카드 종류 — 열 문서 셋 + 일위대가(39장 · 보기 갈래 카드 · 고침은 일위대가 상세) */ export type CardKind = EditKind | "labor"; export type SheetKind = Exclude; -export const PART_FIELD: Record = { +export const PART_FIELD: Record = { structure: "도번", labor: "일위대가번", area: "면적번", @@ -100,6 +99,8 @@ export interface ItemCardsOptions { extraCards?: HTMLElement[]; /** 카드 가운데 하나라도 저장 안 한 고침이 생김 · 없어짐(PLAN 31-4 · 31-7) */ onDirty?: (dirty: boolean) => void; + /** 카드 [저장] 이 끝남 — 번호 · 일위대가 합계를 다시 읽는 자리(86-3) */ + onSaved?: () => void; /** 면적산출 카드를 미리보기에서 고침(31-3) — 없으면 보기만 */ sheetEdit?: SheetEditOptions; /** 「가져오기」(39-5 · 43-9b) — 다른 항목의 이 칸을 골라 덮어씀 · 빈 카드도 같음 · 없으면 단추 없음 */ @@ -195,7 +196,14 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { const no = el("span", { className: "m02-struct__no" }); const name = el("span", { className: "m02-struct__title" }); const shell = cardShell({ title, body, sub: [no, name] }); - const saver = saveControl(shell, save, () => opts.onDirty?.(anyDirty())); + const saver = saveControl( + shell, + async () => { + await save(); + opts.onSaved?.(); + }, + () => opts.onDirty?.(anyDirty()), + ); const help = helpButton(shell, title, CARD_HELP[kind]); // 제목 바로 옆(47-1) const edit = createButton({ label: TEXT.edit, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 214f2fe6b..1c4f38552 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -226,13 +226,39 @@ def _codes() -> dict[str, str]: return codes -def structure_numbers() -> dict[str, dict[str, str]]: - """`{열 id: {도번, 면적번}}` — 집계표 줄 · 좌측 줄이 한 번에 받음(없는 쪽은 빈 글).""" - out: dict[str, dict[str, str]] = {} +def _total(sums: Any) -> float | None: + """`계산값` 합 칸의 `계` — 수 아닌 값 · 없음 = None.""" + total = sums.get("계") if isinstance(sums, dict) else None + if total is None or isinstance(total, bool): + return None + try: + return float(total) + except (TypeError, ValueError): + return None + + +def _labor_sum(doc: dict[str, Any]) -> dict[str, Any]: + """일위대가 합계 칸(86-3) — 반영합 `계`(0 아님) · 없으면 기준합 `계` + `합계기준` 참 · + 담은 로직 0 · 계산값 없음 = 빈 칸 (목록 `labor` 합계와 같은 규칙).""" + if not doc.get("담은로직"): + return {} + calc = doc.get("계산값") or {} + if total := _total(calc.get("반영합")): + return {"일위대가합계": total, "합계기준": False} + base = _total(calc.get("기준합")) + return {} if base is None else {"일위대가합계": base, "합계기준": True} + + +def structure_numbers() -> dict[str, dict[str, Any]]: + """`{열 id: {도번, 일위대가번, 면적번, (일위대가합계, 합계기준)}}` — 집계표 줄 · 좌측 줄이 한 번에 + 받음(번호 없는 쪽은 빈 글 · 합계는 일위대가 계산값이 있을 때만).""" + out: dict[str, dict[str, Any]] = {} for kind, key, _, _ in PARTS: for name, doc in _docs(kind): row = out.setdefault(name, {k: "" for _, k, _, _ in PARTS}) row[key] = str(doc.get(key) or "") + if kind == "labor": + row.update(_labor_sum(doc)) return out diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index d11baa92a..892af0823 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -58,6 +58,7 @@ const TEXT = { noPills: "이 열 일위대가에 단가산출 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음", pillsFailed: "일위대가 알약을 못 받음 — {value}", discardCards: "카드(도면 · 면적산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?", + baseNote: "(기준)", }; /** 집계표 줄 열쇠 → 문서 */ @@ -73,6 +74,13 @@ const fill = (text: string, value: string): string => text.replace("{value}", va /** `{열 id: {도번, 면적번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */ export type ColumnNumbers = Record; +/** 집계표 「일위대가 합계」 줄 글(86-3) — 반영합 · 수량이 없으면 기준합 + 「(기준)」 · 계산값 없음 = 빈 글(「—」) */ +export function laborSumText(numbers: StructureNumbers | undefined): string { + if (typeof numbers?.일위대가합계 !== "number") return ""; + const text = numbers.일위대가합계.toLocaleString("ko-KR", { maximumFractionDigits: 0 }); + return numbers.합계기준 ? `${text} ${TEXT.baseNote}` : text; +} + /** 집계표 열 — 머리 글(제목행 종류 · 분류1 · 분류2 · 분류3) · 꼴만 씀 */ export interface StructureColumn { id: string; @@ -183,6 +191,8 @@ export interface StructurePanelOptions { /** [도면 수정] · [면적산출 수정] — 그 열을 CAD · 스프레드시트 전체 편집으로 엶 * (페이지의 `open`) · label 은 화면 제목에 씀 */ onEdit: (column: string, label: string, kind: CardKind) => void; + /** 번호 · 일위대가 합계를 다시 읽음(카드 [저장] 뒤 · 86-3) — 집계표 줄 글을 새로 그림 */ + onNumbers?: () => void; /** 집계표에 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */ confirmLeave: () => Promise; /** 카드 고침을 버리지 않고 머묾 — 표의 고른 열을 되돌림(31-3) */ @@ -221,6 +231,15 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane editable: () => opts.isAdmin, onEdit: (kind) => void openEdit(kind), numberOf: (kind, id) => numbers[id]?.[PART_FIELD[kind]], + // 카드 [저장] 뒤 — 서버가 일위대가 계산값을 다시 적었을 수 있어 번호 · 합계를 다시 읽음(86-3) + onSaved: () => + void fetchStructureNumbers().then( + (got) => { + numbers = got; + opts.onNumbers?.(); + }, + () => undefined, + ), // 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」 warnOnFail: (id) => !!numbers[id], // 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 집계표 줄 「조회 후 선택」 과 같은 길 diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index a319ab1d8..9931bb0ce 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -22,6 +22,7 @@ import { comboMerge, comboView, createStructurePanel, + laborSumText, pillsFor, STRUCTURE_TABLE, type StructureColumn, @@ -262,6 +263,7 @@ export function createMain( isAdmin, confirmLeave, keepColumn: (colId) => sheet?.selectColumn(colId), // 카드 고침을 안 버림 — 고른 열 되돌림 + onNumbers: () => sheet?.recalc(), // 카드 [저장] 뒤 번호 · 일위대가 합계 줄을 새로(86-3) // 같은 페이지에서 그 열을 CAD·스프레드시트 전체 편집으로 엶 onEdit: (column, label, kind) => { void open({ layer, projectId: null, kind, name: column, label }); @@ -357,6 +359,7 @@ export function createMain( laborLabel: (colId: string) => struct.numbers()[colId]?.일위대가번 ?? "", drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "", areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "", + laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId]), // 86-3 combo: { ...(readOnly || !extras.onHeadRename ? {} diff --git a/resources/tester/test_m02_combo_table.py b/resources/tester/test_m02_combo_table.py index f6d74b82b..193f23b41 100644 --- a/resources/tester/test_m02_combo_table.py +++ b/resources/tester/test_m02_combo_table.py @@ -90,3 +90,49 @@ def test_머리_고침은_쓸_수_있는_층만_나무로(): assert page.index("...(canEdit(layer, isAdmin)\n ? {\n onHeadRename") > 0 tree = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert "if (opt.canEdit && (await ready())) await edit(body, colId);" in tree + + +def test_일위대가_합계_줄_번호_칸(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """86-3 — `structure_numbers` 의 일위대가 합계: 반영합 `계` · 없으면 기준합 `계` + 합계기준 · + 담은 로직 0 · 계산값 없음 = 칸 없음(목록 `labor` 합계와 같은 규칙).""" + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + monkeypatch.setattr(store, "FOLDER", tmp_path) + (tmp_path / "labor").mkdir() + docs = { + "a": {"일위대가번": "SU-000001", "담은로직": ["x"], "계산값": {"반영합": {"계": 1500.4}}}, + "b": { + "일위대가번": "SU-000002", + "담은로직": ["x"], + "계산값": {"반영합": {"계": 0}, "기준합": {"계": 900}}, + }, + "c": {"일위대가번": "SU-000003", "담은로직": [], "계산값": {"반영합": {"계": 5}}}, + "d": {"일위대가번": "SU-000004", "담은로직": ["x"], "계산값": {}}, + } + for name, doc in docs.items(): + (tmp_path / "labor" / f"{name}.json").write_text(json.dumps(doc), encoding="utf-8") + got = store.structure_numbers() + assert (got["a"]["일위대가합계"], got["a"]["합계기준"]) == (1500.4, False) + assert (got["b"]["일위대가합계"], got["b"]["합계기준"]) == (900.0, True) + assert "일위대가합계" not in got["c"] and "일위대가합계" not in got["d"] + assert got["d"]["일위대가번"] == "SU-000004" and got["d"]["도번"] == "" + + +def test_일위대가_합계_줄_표_부품과_연결(): + sheet = _text(SHEET / "ui_template_sheet.ts") + assert "laborSumLabel: opts.laborSumLabel," in sheet + ops = _text(SHEET / "ui_template_sheet_ops.ts") + assert 'export const KEY_SUM = "s:laborsum";' in ops + assert "[KEY_PRICE, KEY_DRAWING, KEY_AREA, KEY_SUM]" in ops # 읽기만 — 고치기 · 쓰기 막힘 + assert "KEY_SUM" not in re.search(r"MASTER_ROWS = \[[^\]]*\]", ops).group( + 0 + ) # 방향키 · 고르기 밖 + render = _text(SHEET / "ui_template_sheet_render.ts") + assert "state.combo && state.laborSumLabel" in render and "masterRow(KEY_SUM)" in render + main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") + assert "laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId])" in main + assert "onNumbers: () => sheet?.recalc()" in main # 카드 [저장] 뒤 다시 읽기 + struct = _text(M02 / "M02_MasterTemplete_Structure.ts") + assert "fetchStructureNumbers().then(" in struct and "opts.onNumbers?.()" in struct + cards = _text(M02 / "M02_MasterTemplete_ItemCards.ts") + assert "opts.onSaved?.();" in cards diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index f2771090c..ff9e24e19 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -74,7 +74,7 @@ def test_sub2_가_부르는_이름과_모양(): ), name assert "fetchStructureNumbers = (): Promise>" in body assert re.search( - r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;\s*\}", + r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;(\s*/\*\*[^*]*\*/)?\s*일위대가합계\?: number;\s*합계기준\?: boolean;\s*\}", body, ) assert re.search(r'export type Kind = [^;]*"area" \| "labor"', body) # 재료산출 없앰 diff --git a/resources/tester/test_sheet_drawing_select.py b/resources/tester/test_sheet_drawing_select.py index 133f770b1..41b1e95c1 100644 --- a/resources/tester/test_sheet_drawing_select.py +++ b/resources/tester/test_sheet_drawing_select.py @@ -76,7 +76,7 @@ def test_text_column_pick_rows_blank(): def test_labor_row_shows_su_number(): """마스터 일위대가 줄 = 그 열 일위대가번(SU) — 열의 옛 `일위대가` 칸(UA 키)은 안 읽음.""" ops = OPS.read_text(encoding="utf-8") - assert "REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA]" in ops + assert "REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA, KEY_SUM]" in ops render = (OPS.parent / "ui_template_sheet_render.ts").read_text(encoding="utf-8") assert re.search(r"key === KEY_PRICE\s*\?\s*state\.laborLabel", render) assert "col.일위대가" not in render diff --git a/ui_template/sheet/ui_template_sheet.ts b/ui_template/sheet/ui_template_sheet.ts index 418dcb355..3d952c266 100644 --- a/ui_template/sheet/ui_template_sheet.ts +++ b/ui_template/sheet/ui_template_sheet.ts @@ -59,6 +59,8 @@ export interface SheetOptions { drawingLabel?: (colId: string) => string; /** 면적산출 줄 칸 글(번호) — `drawingLabel` 과 같음. */ areaLabel?: (colId: string) => string; + /** 구조물 조합 표(`combo`) 「일위대가 합계」 줄 칸 글 — 주면 읽기 전용 줄이 생김(86-3). */ + laborSumLabel?: (colId: string) => string; /** 칸을 고름 — 누르기 · 방향키 · Tab. */ onSelect?: (sel: SheetSelection) => void; /** 조회 후 선택 — master 일위대가 · 도면 · 면적산출 칸을 두 번 누르기 · Enter · F2 로 부름. @@ -90,6 +92,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio laborLabel: opts.laborLabel, drawingLabel: opts.drawingLabel, areaLabel: opts.areaLabel, + laborSumLabel: opts.laborSumLabel, pick: !!opts.onPick, headLocked: !!opts.lockHead, combo: !!opts.combo, diff --git a/ui_template/sheet/ui_template_sheet_ops.ts b/ui_template/sheet/ui_template_sheet_ops.ts index 0f0a51ea2..1df960141 100644 --- a/ui_template/sheet/ui_template_sheet_ops.ts +++ b/ui_template/sheet/ui_template_sheet_ops.ts @@ -22,11 +22,13 @@ export const KEY_UNIT = "s:unit"; export const KEY_PRICE = "s:price"; export const KEY_DRAWING = "s:drawing"; export const KEY_AREA = "s:area"; +/** 구조물 조합 표 읽기 전용 줄 「일위대가 합계」(86-3) — 마스터 줄 차례 · 조회 후 선택에는 안 들어감. */ +export const KEY_SUM = "s:laborsum"; /** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 일위대가 · 도면 · 면적산출 줄은 손으로 못 침(번호 글). */ export const MASTER_ROWS = [KEY_PRICE, KEY_DRAWING, KEY_AREA] as const; export type MasterRowKey = (typeof MASTER_ROWS)[number]; /** 번호 글 줄 — 부른 쪽이 준 글(`refLabel`)만 보임(일위대가 = SU · 열의 옛 `일위대가` 칸은 안 읽음). */ -export const REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA]; +export const REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA, KEY_SUM]; /** 조회 후 선택 줄 — `onPick` 을 주면 이 줄 칸은 치지 않고 고름. */ export const PICK_ROWS: readonly string[] = MASTER_ROWS; diff --git a/ui_template/sheet/ui_template_sheet_render.ts b/ui_template/sheet/ui_template_sheet_render.ts index 8ef33269c..36114d47e 100644 --- a/ui_template/sheet/ui_template_sheet_render.ts +++ b/ui_template/sheet/ui_template_sheet_render.ts @@ -17,6 +17,7 @@ import { isBlankPick, isCalcColumn, KEY_AREA, + KEY_SUM, KEY_PRICE, KEY_UNIT, MASTER_ROWS, @@ -43,6 +44,8 @@ export interface RenderState { laborLabel?: (colId: string) => string; drawingLabel?: (colId: string) => string; areaLabel?: (colId: string) => string; + /** 구조물 조합 표 「일위대가 합계」 줄 칸 글 — 주면 combo 표에 읽기 전용 줄이 생김(86-3). */ + laborSumLabel?: (colId: string) => string; /** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */ pick?: boolean; /** 머리 · 층 이름을 못 고침(`lockHead`) — 칸 겉모양만. */ @@ -55,11 +58,13 @@ export interface RenderState { /** 일위대가 · 도면 · 면적산출 줄 칸 글(번호). */ export const refText = (state: RenderState, key: string, colId: string): string => - (key === KEY_AREA - ? state.areaLabel - : key === KEY_PRICE - ? state.laborLabel - : state.drawingLabel)?.(colId)?.trim() || st("Drawing_None"); + key === KEY_SUM + ? state.laborSumLabel?.(colId)?.trim() || "—" + : (key === KEY_AREA + ? state.areaLabel + : key === KEY_PRICE + ? state.laborLabel + : state.drawingLabel)?.(colId)?.trim() || st("Drawing_None"); /** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */ const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map): number => @@ -173,17 +178,18 @@ export function renderSheet(state: RenderState): HTMLElement { return tr; }; - const masterRow = (key: MasterRowKey): HTMLElement => { + const masterRow = (key: MasterRowKey | typeof KEY_SUM): HTMLElement => { const label = ( { "s:price": "Row_Unit_Price", "s:drawing": "Row_Drawing", "s:area": "Row_Area", + "s:laborsum": "Row_LaborSum", } as const )[key]; const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] }); cols.forEach((col, j) => { - const blank = isBlankPick(key, col); + const blank = key === KEY_SUM ? col.꼴 === "글" : isBlankPick(key, col); const text = blank ? "" : refText(state, key, col.id); // 마스터 줄 = 모두 번호 글 const td = bodyCell(key, col, j, text); td.classList.remove("is-num"); @@ -264,6 +270,9 @@ ${formulaLabel(formula, names)}` children: [ // 구조물 조합 = 일위대가 · 도면 · 면적산출 키 줄 안 보임(보기만 · 번호는 장부 · 카드에) ...(mode === "master" && p === 0 && !state.combo ? MASTER_ROWS.map(masterRow) : []), + ...(mode === "master" && p === 0 && state.combo && state.laborSumLabel + ? [masterRow(KEY_SUM)] + : []), // 구조물 조합 = 읽기 전용 합계 줄 하나(86-3) ...page.rows.map((row, i) => dataRow(row, page.first + i)), ...(page.totals ? totalRows() : []), ], diff --git a/ui_template/sheet/ui_template_sheet_text.ts b/ui_template/sheet/ui_template_sheet_text.ts index 3ed27f142..b53aba30f 100644 --- a/ui_template/sheet/ui_template_sheet_text.ts +++ b/ui_template/sheet/ui_template_sheet_text.ts @@ -11,6 +11,7 @@ const TEXT = { Col_New: ["새 열", "New column"], Row_Unit: ["단위", "Unit"], Row_Unit_Price: ["일위대가", "Unit price"], + Row_LaborSum: ["일위대가 합계", "Unit price total"], Row_Drawing: ["도면", "Drawing"], Row_Area: ["면적산출", "Area"], Add_Suffix: [" 추가", " +"],