From b5114ec17f69242cdda31dbf5c4b93db851403fa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 07:50:20 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(M02):=20=EC=A7=91=EA=B3=84=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=ED=8F=B4=EB=8D=94=20=EA=B5=AC=EC=A1=B0=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=E2=80=94=20=EA=B2=BD=EB=A1=9C=20=C2=B7=20=EC=9D=BC?= =?UTF-8?q?=EB=A0=A8=EB=B2=88=ED=98=B8=20=C2=B7=20=EB=82=98=EB=AC=B4=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=A8=20=EA=B8=B8(PLAN=2026-2a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 항목 경로 = 폴더 이름 배열(깊이 자유) · 집계표 열 머리(빈 칸 건너뜀)가 곧 경로 · 열에 경로를 같이 적음 · 머리 줄 수(층)는 가장 깊은 경로에 맞춤(넷 밑으로는 안 줄임) · 빈 폴더는 집계표 폴더 칸(M02_MasterTemplete_Tree) - 표 저장과 나무 고침 한 줄기 — 나무 고침은 집계표 문서를 고쳐 같은 집계표 저장(store.write)을 탐(번호 · SS 장부 · 세 파일 · 입력 연결 파일 지우기가 함께) - 나무 고침 길 POST /api/m02/layers/{layer}/tree(add-folder · add-item · rename · move · delete · 판 409 · 422 · 404 · 층 권한) · 읽기 GET 같은 길 {판, 폴더, items} - 번호 = 새 항목은 자리 뜻 없는 일련번호(있던 번호 · 지운 번호 가장 큰 수 + 1) · 있던 번호 그대로 - SS 장부 = M02_MasterTemplete_Book 로 떼어 냄(Store 543 → 478) · 줄에 경로 · 이름 · 옛 종류 · 분류1~3 = 경로 앞 넷 · 경로 없는 옛 줄은 읽을 때 채움 · 옮기는 함수 migrate_paths - 목록 길 /api/m02/items 줄에 경로 · 이름(경로 이음) · 자재 조합 · 갈라 내기 이름도 경로 이음 - 마스터 집계표 · 장부를 경로 모양으로 옮김(51 항목 · 키 · 번호 그대로 · 빈 글 머리는 null) - 시험 test_m02_tree 5(옛 51 항목 · 옮기기 두 번 같음 · 깊이 1 · 2 · 5 · 이름 · 옮기기 · 지우기 · 마지막 항목 · 409 · 403 · 표 저장 한 줄기) · 번호 시험을 일련번호 규칙으로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- M02_MasterTemplete/M02_MasterTemplete_Book.py | 90 ++++ .../M02_MasterTemplete_Branch.py | 3 +- .../M02_MasterTemplete_Items.py | 11 +- .../M02_MasterTemplete_Matset.py | 3 +- .../M02_MasterTemplete_Router_Items.py | 57 ++ .../M02_MasterTemplete_Store.py | 125 +---- M02_MasterTemplete/M02_MasterTemplete_Tree.py | 303 +++++++++++ .../master_template/table/구조물집계표.json | 508 +++++++++++++----- .../master_template/구조물집계표조합.json | 262 ++++++++- .../spreadsheet/test_spreadsheet_store.py | 9 +- resources/tester/test_m02_structure.py | 67 +-- resources/tester/test_m02_structure_combo.py | 3 +- resources/tester/test_m02_tree.py | 247 +++++++++ 13 files changed, 1395 insertions(+), 293 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Book.py create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Tree.py create mode 100644 resources/tester/test_m02_tree.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Book.py b/M02_MasterTemplete/M02_MasterTemplete_Book.py new file mode 100644 index 00000000..11188397 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Book.py @@ -0,0 +1,90 @@ +"""M02 구조물집계표조합(SS 장부) — 구조물 열마다 줄 하나 · 경로 옮겨 받기(PLAN 26-2a). + +제어용 장부 · 관리자는 집계표 화면(또는 나무)으로 고침 · 서버가 집계표 [저장] 때 맞춤 · +키 = SS + 6자리 · 한 번 준 키는 그 열에 남음 · 지운 열의 키는 다시 안 씀(`다음` 이 기억). +줄 = `{키, 열, 경로, 이름, 종류, 분류1~3(경로 앞 넷 — 옛 코드가 읽음), 단위, 일위대가, 자재조합, +도번, 면적번, 재료번, 바인딩, 펼침}`. `M02_MasterTemplete_Store.py` 가 커서 떼어 냄 — 저장소는 +`store.sync_combos` · `store.read_combos` 이름으로 이 파일을 부름. +""" + +from __future__ import annotations + +import json +from typing import Any + +from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MasterTemplete_Tree as tree + +HEAD = "SS" +NOTE = ( + "구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · " + "키 SS 는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀 · 경로 = 폴더 이름 배열" +) + + +def _row(key: str, col: dict[str, Any], numbers: dict[str, str]) -> dict[str, Any]: + path = tree.path_of(col) + return { + "키": key, + "열": col["id"], + "경로": path, + "이름": tree.name_of(path), + **tree.old_fields(path), + "단위": col.get("단위"), + "일위대가": col.get("일위대가"), + "자재조합": col.get("자재조합"), + **{field: numbers.get(field, "") for _, field, _, _ in store.PARTS}, + "바인딩": col.get("바인딩"), + "펼침": col.get("펼침"), + } + + +def sync() -> dict[str, Any]: + """장부를 집계표에 맞춤 — 새 열 = 새 SS 키 · 지운 열 = 줄 지움 · 고친 열 = 칸 갱신 · + 줄 차례 = 집계표 열 차례. 바뀐 것이 없으면 파일을 안 씀.""" + path = store.root() / store.COMBO_FILE + with store._LOCK: + old = json.loads(path.read_bytes()) if path.is_file() else {} + keys = {row["열"]: row["키"] for row in old.get("줄", [])} + taken = [int(k[len(HEAD) :]) for k in keys.values()] + after = max(old.get("다음", 1), max(taken, default=0) + 1) + numbers = store.structure_numbers() + rows = [] + for col in store._structure_columns(): + if col["id"] not in keys: + keys[col["id"]] = f"{HEAD}{after:06d}" + after += 1 + rows.append(_row(keys[col["id"]], col, numbers.get(col["id"], {}))) + doc = {"그룹": "구조물집계표조합", "판": 1, "비고": NOTE, "다음": after} + if old.get("지운번호"): + doc["지운번호"] = old["지운번호"] # 지운 열의 도번 8자리 — 다시 안 줌 + doc["줄"] = rows + if doc != old: + atomic_write_json(path, doc) + return doc + + +def read() -> dict[str, Any]: + """장부 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦 · `경로` 가 없던 옛 줄은 읽을 때 + 종류 · 분류1~3 에서 채움(빈 분류 건너뜀 · 파일은 다음 [저장] 때 새 모양).""" + path = store.root() / store.COMBO_FILE + doc = json.loads(path.read_bytes()) if path.is_file() else sync() + for row in doc.get("줄") or []: + if "경로" not in row: + row["경로"] = tree.row_path(row) + row["이름"] = tree.name_of(row["경로"]) + return doc + + +def migrate_paths() -> bool: + """옛 집계표 · 장부 → 경로 모양(열 `경로` · 머리 줄 수 · 장부 `경로` · `이름`) — 한 번 · 두 번 + 돌려도 같음. 바뀐 것이 없으면 파일을 안 씀 · 바꿨으면 참. 지금 층(`store.at`) 기준.""" + got = store.read("table", store.STRUCTURE_TABLE) + if tree.normalize_table(got["문서"]) != got["문서"]: + store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"]) + return True + path = store.root() / store.COMBO_FILE + before = path.read_bytes() if path.is_file() else b"" + sync() + return (path.read_bytes() if path.is_file() else b"") != before diff --git a/M02_MasterTemplete/M02_MasterTemplete_Branch.py b/M02_MasterTemplete/M02_MasterTemplete_Branch.py index 47fe3430..802c013e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Branch.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Branch.py @@ -15,6 +15,7 @@ from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_MasterTemplete_Matset as matset from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MasterTemplete_Tree as tree _OWNER = "현장" @@ -150,7 +151,7 @@ def branch_material_combo( return matset.create(row)["키"] heads = next((c for c in store._structure_columns() if c["id"] == column), {}) - default = " ".join(h for h in store._heads(heads) if h) if heads else column + default = tree.name_of(tree.path_of(heads)) if heads else column # 경로 이음(26-1) name = name.strip() or str(was.get("이름") or default or column) rows = matset.listing()["줄"] return _branch( diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py index f535a722..e08613ff 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -22,6 +22,8 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store # 21-1 목록 칸 — SS 줄에서 그대로(키는 `key`) ITEM_FIELDS = ( "열", + "경로", + "이름", "종류", "분류1", "분류2", @@ -74,8 +76,9 @@ def _combo_names() -> dict[str, str]: def items(folder: Path) -> list[dict[str, Any]]: - """그 층 항목 — SS 줄마다 `{key, 열, 종류, 분류1~3, 단위, 일위대가, 자재조합, - 도번, 면적번, 재료번, names}` · `names` = 화면 이름(키 대신 · 없으면 null) — + """그 층 항목 — SS 줄마다 `{key, 열, 경로, 이름, 종류, 분류1~3, 단위, 일위대가, 자재조합, + 도번, 면적번, 재료번, names}` · 경로 = 폴더 이름 배열 · 이름 = 경로 이음(26-1 · 종류 · + 분류1~3 은 경로 앞 넷) · `names` = 화면 이름(키 대신 · 없으면 null) — 일위대가 = 조합 이름 · 자재조합 = 그 층 자재 조합 이름 · 도번 · 면적번 · 재료번 = 항목(열) 이름.""" ua = _combo_names() @@ -84,7 +87,7 @@ def items(folder: Path) -> list[dict[str, Any]]: um = {str(r.get("키")): str(r.get("이름") or "") for r in matset.listing()["줄"]} out = [] for r in rows: - head = " ".join(str(r[k]) for k in ("종류", "분류1", "분류2", "분류3") if r.get(k)) + head = r.get("이름") names = { "일위대가": ua.get(str(r.get("일위대가"))) if r.get("일위대가") else None, "자재조합": um.get(str(r.get("자재조합"))) if r.get("자재조합") else None, @@ -155,4 +158,4 @@ def copy_item(source: Path, target: Path, key: str, origin: dict[str, Any]) -> d atomic_write_json(store._path(kind, new_id), body) ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id) inputs_store.copy_between(source, target, col_id, new_id) # 항목 연결도 같이(판 1) - return {"key": ss["키"], "열": new_id, "이름": " ".join(h for h in store._heads(made) if h)} + return {"key": ss["키"], "열": new_id, "이름": ss["이름"]} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Matset.py b/M02_MasterTemplete/M02_MasterTemplete_Matset.py index 09788153..47784a50 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Matset.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Matset.py @@ -13,6 +13,7 @@ from typing import Any from common_util.common_util_json import atomic_write_json from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MasterTemplete_Tree as tree mmc = m01.cm.mmc # 자재 조합 틀(`resources/master_data/scripts/master_material_combo.py`) FILE = mmc.FILE @@ -47,7 +48,7 @@ def listing() -> dict[str, Any]: def _name(col: dict) -> str: - return " ".join(h for h in store._heads(col) if h) or col["id"] + return tree.name_of(tree.path_of(col)) or col["id"] # 경로 이음(26-1) def one(key: str) -> dict[str, Any]: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 08f8ad31..86bfd072 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -24,6 +24,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store from M02_MasterTemplete import M02_MasterTemplete_Items as items_store from M02_MasterTemplete import M02_MasterTemplete_Matset as matset 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 from M02_MasterTemplete.M02_MasterTemplete_Router import ( BranchBody, @@ -44,6 +45,18 @@ class InputLinksBody(BaseModel): 줄: list[Any] = [] +class TreeBody(BaseModel): + """26-1 나무 고침 — `{op, 경로, 이름?, 새경로?, 키?, 판}` (+ 항목 더하기 때 `단위?`).""" + + op: str + 경로: list[str] | None = None + 이름: str | None = None + 새경로: list[str] | None = None + 키: str | None = None + 단위: str | None = None + 판: str + + class CopyItemBody(BaseModel): from_layer: Literal["system", "company"] from_owner: str | None = None @@ -195,6 +208,50 @@ def get_item_pills( return _call(_root(session, layer, owner), inputs_store.pills, key, place) +# ── 폴더 구조(PLAN 26-1 · 26-2a) — 읽기 = 층 읽기 권한 · 고침 = 층 쓰기 권한 ── + + +def _tree_view(folder: Path) -> dict[str, Any]: + """나무 — `{판(집계표), 폴더: [경로 …], items: [항목 …]}` · 폴더 = 빈 폴더 + 항목 앞마디.""" + with store.at(folder): + got = store.read("table", store.STRUCTURE_TABLE) + doc = tree.normalize_table(got["문서"]) # 옛 파일도 경로로 읽음 + return {"판": got["판"], "폴더": tree.folders_of(doc), "items": items_store.items(folder)} + + +@router.get("/layers/{layer}/tree") +def get_tree(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)): + folder = _root(session, layer, owner) + return _call(folder, _tree_view, folder) + + +def _edit_tree(folder: Path, body: dict[str, Any]) -> dict[str, Any]: + """집계표를 고쳐 집계표 저장 한 줄기로 — 번호 · SS 장부 · 지운 항목의 세 파일 · 입력 연결.""" + got = store.read("table", store.STRUCTURE_TABLE) + if got["판"] != body.get("판"): + raise store.StoreError(409, {"stale": [store.STRUCTURE_TABLE], "판": got["판"]}) + ids = {str(r.get("키")): str(r.get("열")) for r in store.read_combos().get("줄", [])} + try: + doc, note = tree.apply(got["문서"], body, ids) + except tree.TreeError as e: + raise store.StoreError(e.status, e.detail) from e + store.write("table", store.STRUCTURE_TABLE, got["판"], doc) + return {**_tree_view(folder), "알림": note} + + +@router.post("/layers/{layer}/tree") +def post_tree( + layer: Layer, + body: TreeBody, + owner: str | None = None, + session: Session = Depends(verify_session), +): + """폴더 더하기 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기 → 새 나무 · 판 다르면 409. + 쓰이는 항목을 지워도 프로젝트엔 영향 없음(프로젝트는 복사본을 씀 · 알림의 `지운항목`).""" + folder = _root(session, layer, owner, write=True) + return _call(folder, _edit_tree, folder, body.model_dump()) + + # ── 층 길 — 옛 시스템 길과 같은 모양 ───────────────────── diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 4aa8d3f1..8f1e4a0b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -19,6 +19,7 @@ from typing import Any from common_util import common_util_spreadsheet as spreadsheet from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_MasterTemplete_Tree as tree from M02_MasterTemplete import M02_Template_Layers as layers FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_template" @@ -46,9 +47,9 @@ def at(folder: str | Path) -> Iterator[Path]: # 구조물집계표 구조물 열(꼴=수) 마다 세 파일(파일 이름 = 열 id) — # 구조물 도면 `structure/` (도각 없는 전용 화면) · 면적산출 `area/` · 재료산출 `material/` # (둘 다 스프레드시트 통합문서) -# 번호 = 도번 SD · 면적번 SA · 재료번 SM + 8자리 [종류][분류1][분류2][분류3] -# (제목행 = 열 `머리` 네 층 · 없는 층 00) — 한 열의 세 번호는 같은 8자리 · -# 한 번 준 번호는 파일에 남아 안 바뀜(PLAN 13장) +# 번호 = 도번 SD · 면적번 SA · 재료번 SM + 8자리 — 한 열의 세 번호는 같은 8자리 · 한 번 준 번호는 +# 파일에 남아 안 바뀜(PLAN 13장) · 새 항목은 자리 뜻 없는 일련번호(PLAN 26-1 — 있던 번호는 그대로) +# 항목 경로(폴더 이름 배열) = 열 `머리` 의 빈 칸 건너뛴 이름(`M02_MasterTemplete_Tree`) STRUCTURE_TABLE = "구조물집계표" # 프로그램이 이름으로 부르는 양식 — 지우면 그 기능이 죽음(B07 이름은 `B07_DesignDetail_Engine_*`) IN_USE = { @@ -65,7 +66,6 @@ PARTS = ( ) _KEY = {kind: key for kind, key, _, _ in PARTS} SHEETS = ("area", "material") # 통합문서 종류 — [저장] 때 서버가 계산값을 새로 적음 -_LEVELS = 4 _CODE = re.compile(r"^S[DAM](\d{8})$") _OLD_CODE = re.compile(r"^S[DCA](\d{6})$") # PLAN 12장 6자리 — 끝에 00 을 붙여 읽음 _BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.') @@ -158,6 +158,8 @@ def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]: path = _path(kind, name) _check_skeleton(kind, doc) origins = _take_origins(doc) if kind == "table" else {} + if kind == "table" and name == STRUCTURE_TABLE: + doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기) if kind in SHEETS: doc = _server_values(doc) with _LOCK: @@ -228,9 +230,8 @@ def structure_numbers() -> dict[str, dict[str, str]]: def _heads(col: dict[str, Any]) -> tuple[str | None, ...]: - """제목행 네 층(종류 · 분류1 · 분류2 · 분류3) — 빈 층은 None.""" - parts = [*(col.get("머리") or []), *[None] * _LEVELS][:_LEVELS] - return tuple((str(p).strip() if p is not None else "") or None for p in parts) + """옛 네 칸(종류 · 분류1 · 분류2 · 분류3) = 경로 앞 넷 — 없는 칸은 None(다른 코드가 읽음).""" + return tuple(tree.old_fields(tree.path_of(col)).values()) def _structure_columns() -> list[dict[str, Any]]: @@ -247,49 +248,16 @@ def _column_ids() -> list[str]: return [col["id"] for col in _structure_columns()] -def _pick_code( - col: dict[str, Any], - taken: dict[str, str], - heads: dict[str, tuple], - retired: list[str] | None = None, -) -> str: - """새 열 번호 — 층마다 같은 무리(그 층까지 제목행이 같은 열)가 이미 번호를 가졌으면 그 두 자리를 - 이어 받고 · 없으면 그 층 가장 큰 번호 + 1 · 빈 층(종류 밖)은 00 · 맨 아래 층(분류3)은 늘 다음 - 번호. 같은 8자리가 이미 있으면(같은 제목행 · 빈 분류3 겹침) 맨 아래 층을 그 무리 다음 번호로. - `taken` = 이미 번호 있는 열 `{열 id: 8자리}` · `heads` = 열 id → 제목행 · - `retired` = 지운 열의 번호(다시 안 줌 — 「가장 큰 번호」 · 겹침 셈에만 듦).""" - parts = _heads(col) - codes = [*taken.values(), *(retired or [])] - mates = [(heads[cid], code) for cid, code in taken.items() if cid in heads] - - def next_of(prefix: str) -> str: - at = slice(len(prefix), len(prefix) + 2) - found = [int(c[at]) for c in codes if c.startswith(prefix)] - return f"{max(found, default=0) + 1:02d}" - - code = "" - for depth, head in enumerate(parts): - if depth and head is None: - code += "00" - continue - at = slice(2 * depth, 2 * depth + 2) - group = parts[: depth + 1] - same = (c[at] for h, c in mates if h[: depth + 1] == group and c.startswith(code)) - leaf = depth == _LEVELS - 1 - code += (None if leaf else next(same, None)) or next_of(code) - if code in codes: - code = code[:-2] + next_of(code[:-2]) - return code +def _serial(taken: dict[str, str], retired: list[str] | None = None) -> str: + """새 항목 번호 — 자리 뜻 없는 일련번호(있던 번호 · 지운 번호 가운데 가장 큰 수 + 1 · 8자리).""" + codes = [int(c) for c in [*taken.values(), *(retired or [])] if str(c).isdigit()] + return f"{max(codes, default=0) + 1:08d}" def _new_code(column: str) -> str: """이 열이 쓸 8자리 — 짝 파일에 이미 있으면 그것 · 없으면 새로(잠금 안에서 부름).""" taken = _codes() - if column in taken: - return taken[column] - cols = _structure_columns() - col = next((c for c in cols if c["id"] == column), {"id": column}) - return _pick_code(col, taken, {c["id"]: _heads(c) for c in cols}, _retired()) + return taken[column] if column in taken else _serial(taken, _retired()) def _retired() -> list[str]: @@ -447,7 +415,7 @@ def _move_basis() -> list[str]: def renumber() -> dict[str, list[str]]: """한 번 — 옛 산출근거를 면적산출로 옮김 · 옛 번호(6자리 · 구-NN · 표-NN · 없음)를 8자리로 - (6자리는 끝에 00 · 없는 열 차례 = 종류 · 분류1 · 분류2 · 분류3 차례) · + (6자리는 끝에 00 · 번호 없는 열은 열 차례로 일련번호) · 구조물 아닌 열 파일 지움 · 빠진 파일(재료산출 등) 만듦. 내용은 그대로 · 두 번 돌려도 같음.""" moved = _move_basis() gone = prune_removed_structures() @@ -455,12 +423,11 @@ def renumber() -> dict[str, list[str]]: made: list[str] = [] with _LOCK: cols = _structure_columns() - heads = {c["id"]: _heads(c) for c in cols} taken = _codes() retired = _retired() for col in cols: if col["id"] not in taken: - taken[col["id"]] = _pick_code(col, taken, heads, retired) + taken[col["id"]] = _serial(taken, retired) docs = {kind: dict(_docs(kind)) for kind in _KEY} for col in cols: cid = col["id"] @@ -485,7 +452,7 @@ def _server_values(doc: Any) -> dict[str, Any]: def backfill_missing_structures() -> list[str]: - """구조물 열마다 세 파일이 있게 — 없는 것만 열 차례로 새로 만듦(그 무리 다음 번호). + """구조물 열마다 세 파일이 있게 — 없는 것만 열 차례로 새로 만듦(다음 일련번호). 그 사이 남이 만들었으면 그대로 둠.""" made: list[str] = [] for cid in _column_ids(): @@ -493,61 +460,19 @@ def backfill_missing_structures() -> list[str]: return made -# ── 구조물집계표조합 ───────────────────── -# 구조물 열마다 줄 하나(제어용 장부 · 관리자는 집계표 화면으로 고침 · 서버가 집계표로 맞춤) · -# 키 = SS + 6자리 · 한 번 준 키는 그 열에 남음 · 지운 열의 키는 다시 안 씀(`다음` 이 기억) +# ── 구조물집계표조합(SS 장부) — `M02_MasterTemplete_Book` 몫 ───────────────────── COMBO_FILE = layers.COMBO_FILE -_COMBO_HEAD = "SS" -_COMBO_NOTE = ( - "구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · " - "키 SS 는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀" -) - - -def _combo_row(key: str, col: dict[str, Any], numbers: dict[str, str]) -> dict[str, Any]: - kind, level1, level2, level3 = _heads(col) - return { - "키": key, - "열": col["id"], - "종류": kind, - "분류1": level1, - "분류2": level2, - "분류3": level3, - "단위": col.get("단위"), - "일위대가": col.get("일위대가"), - "자재조합": col.get("자재조합"), - **{field: numbers.get(field, "") for _, field, _, _ in PARTS}, - "바인딩": col.get("바인딩"), - "펼침": col.get("펼침"), - } def sync_combos() -> dict[str, Any]: - """조합 표를 집계표에 맞춤 — 새 열 = 새 SS 키 · 지운 열 = 줄 지움 · 고친 열 = 칸 갱신 · - 줄 차례 = 집계표 열 차례. 바뀐 것이 없으면 파일을 안 씀.""" - path = root() / COMBO_FILE - with _LOCK: - old = json.loads(path.read_bytes()) if path.is_file() else {} - keys = {row["열"]: row["키"] for row in old.get("줄", [])} - taken = [int(k[len(_COMBO_HEAD) :]) for k in keys.values()] - after = max(old.get("다음", 1), max(taken, default=0) + 1) - numbers = structure_numbers() - rows = [] - for col in _structure_columns(): - if col["id"] not in keys: - keys[col["id"]] = f"{_COMBO_HEAD}{after:06d}" - after += 1 - rows.append(_combo_row(keys[col["id"]], col, numbers.get(col["id"], {}))) - doc = {"그룹": "구조물집계표조합", "판": 1, "비고": _COMBO_NOTE, "다음": after} - if old.get("지운번호"): - doc["지운번호"] = old["지운번호"] # 지운 열의 도번 8자리 — 다시 안 줌 - doc["줄"] = rows - if doc != old: - atomic_write_json(path, doc) - return doc + """조합 장부를 집계표에 맞춤(`Book.sync`) — 부를 때 읽음(장부 파일이 이 파일을 부름).""" + from M02_MasterTemplete import M02_MasterTemplete_Book as book + + return book.sync() def read_combos() -> dict[str, Any]: - """조합 표 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦.""" - path = root() / COMBO_FILE - return json.loads(path.read_bytes()) if path.is_file() else sync_combos() + """조합 장부 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦(`Book.read`).""" + from M02_MasterTemplete import M02_MasterTemplete_Book as book + + return book.read() diff --git a/M02_MasterTemplete/M02_MasterTemplete_Tree.py b/M02_MasterTemplete/M02_MasterTemplete_Tree.py new file mode 100644 index 00000000..b70e3e55 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Tree.py @@ -0,0 +1,303 @@ +"""M02 집계 항목 **폴더 구조**(PLAN 26-1 · 26-2a) — 항목 경로 · 집계표 맞춤 · 나무 고침. + +항목 = 구조물집계표의 구조물 열(꼴=수) 하나 · 경로 = 폴더 이름 배열(깊이 자유 · 마지막이 항목 이름) +· 이름 = 경로를 이은 글. 자료의 자리는 집계표 한 파일 — 열 `머리`(제목행)가 곧 경로(빈 칸은 건너뜀) +이고, 열에 `경로` 를 같이 적어 둠. 머리 줄 수(`층`)는 가장 깊은 경로에 맞춤(넷보다 적게는 안 줄임 — +옛 코드가 종류 · 분류1~3 넷을 읽음). 빈 폴더는 집계표 `폴더`(경로 목록)에 적음. + +나무 고침(폴더 더하기 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기)은 집계표 문서를 고쳐 +돌려줄 뿐 — 저장은 부르는 쪽이 집계표 저장(`store.write`)으로 해서 표 저장과 한 줄기 +(번호 · SS 장부 · 세 파일 · 입력 연결 파일 지우기)를 탐. 이 파일은 저장소를 부르지 않음(순수 함수). +""" + +from __future__ import annotations + +import copy +import re +from typing import Any + +MIN_LEVELS = 4 # 옛 약속 — 종류 · 분류1 · 분류2 · 분류3 +_OLD_NAMES = ("종류", "분류1", "분류2", "분류3") +OPS = ("add-folder", "add-item", "rename", "move", "delete") +MAX_NAME = 60 +_BAD = re.compile(r"[\x00-\x1f]") + + +class TreeError(Exception): + def __init__(self, status: int, detail: Any): + super().__init__(detail) + self.status, self.detail = status, detail + + +# ── 경로 읽기 ───────────────────────────────────────── + + +def _clean(parts: Any) -> list[str]: + if not isinstance(parts, list): + return [] + return [str(p).strip() for p in parts if p is not None and str(p).strip()] + + +def path_of(col: dict[str, Any]) -> list[str]: + """열의 경로 — 머리(제목행)의 빈 칸을 건너뛴 이름들 · 머리가 없으면 `경로`.""" + heads = col.get("머리") + return _clean(heads) if isinstance(heads, list) else _clean(col.get("경로")) + + +def row_path(row: dict[str, Any]) -> list[str]: + """SS 장부 줄의 경로 — `경로` 가 없던 옛 줄은 종류 · 분류1~3 에서(빈 분류 건너뜀).""" + got = _clean(row.get("경로")) + return got or _clean([row.get(k) for k in _OLD_NAMES]) + + +def name_of(path: list[str]) -> str: + return " ".join(path) + + +def old_fields(path: list[str]) -> dict[str, str | None]: + """옛 칸(종류 · 분류1~3) — 경로 앞 넷(다른 코드가 읽음).""" + padded = [*path, *[None] * MIN_LEVELS][:MIN_LEVELS] + return dict(zip(_OLD_NAMES, padded)) + + +def is_item(col: Any) -> bool: + return isinstance(col, dict) and bool(col.get("id")) and col.get("꼴") == "수" + + +def _items(doc: dict[str, Any]) -> list[dict[str, Any]]: + return [c for c in doc.get("열") or [] if is_item(c)] + + +# ── 집계표 맞춤 ─────────────────────────────────────── + + +def _trimmed(heads: Any) -> list[Any]: + out = list(heads) if isinstance(heads, list) else [] + while out and (out[-1] is None or not str(out[-1]).strip()): + out.pop() + return out + + +def normalize_table(doc: dict[str, Any]) -> dict[str, Any]: + """저장 앞 맞춤 — 항목 열: `경로` = 머리의 빈 칸 건너뛴 이름 · 머리 = 경로를 머리 줄 수만큼 + 채움 · 머리 줄 수(`층`) = 가장 깊은 경로(넷보다 적게는 안 줄임) · + `폴더` = 경로 목록(겹침 · 빈 것 뺌). + 글 열(NO · 측점 · 비고)은 머리 줄 수만 맞춤.""" + out = copy.deepcopy(doc) + cols = [c for c in out.get("열") or [] if isinstance(c, dict)] + depth = max( + [MIN_LEVELS, *(len(path_of(c)) for c in cols if is_item(c))] + + [len(_trimmed(c.get("머리"))) for c in cols if not is_item(c)] + ) + names = [str(n) for n in (out.get("층") or [])][:depth] + names += [_OLD_NAMES[i] if i < MIN_LEVELS else f"분류{i}" for i in range(len(names), depth)] + out["층"] = names + for col in cols: + if is_item(col): + path = path_of(col) + col["경로"] = path + col["머리"] = [*path, *[None] * depth][:depth] + else: + col["머리"] = [*_trimmed(col.get("머리")), *[None] * depth][:depth] + folders: list[list[str]] = [] + for raw in out.get("폴더") or []: + path = _clean(raw) + if path and path not in folders: + folders.append(path) + out["폴더"] = folders + return out + + +def folders_of(doc: dict[str, Any]) -> list[list[str]]: + """나무의 폴더 전부 — 적어 둔 빈 폴더 + 항목 경로의 앞마디(차례 = 표 차례).""" + out: list[list[str]] = [] + for col in _items(doc): + path = path_of(col) + for n in range(1, len(path)): + if path[:n] not in out: + out.append(path[:n]) + for path in doc.get("폴더") or []: + path = _clean(path) + if path and path not in out: + out.append(path) + return out + + +# ── 나무 고침 ───────────────────────────────────────── + + +def _name(value: Any, what: str = "이름") -> str: + text = str(value).strip() if isinstance(value, str) else "" + if not text or len(text) > MAX_NAME or _BAD.search(text): + raise TreeError(422, f"「{what}」 — 빈 글 · {MAX_NAME}자 넘음 · 조절 문자는 못 씀") + return text + + +def _path(value: Any, what: str = "경로") -> list[str]: + if value is None: + return [] + if not isinstance(value, list): + raise TreeError(422, f"「{what}」 이 폴더 이름 목록이 아님") + return [_name(v, what) for v in value] + + +def _find(doc: dict[str, Any], key: str | None, ids: dict[str, str]) -> dict[str, Any]: + """SS 키(또는 열 id)로 항목 열 — 없으면 404.""" + cid = ids.get(str(key), str(key)) if key else None + col = next((c for c in _items(doc) if c["id"] == cid), None) + if col is None: + raise TreeError(404, f"없는 항목 「{key}」") + return col + + +def _item_paths(doc: dict[str, Any], skip: list[dict[str, Any]] | None = None) -> list[list[str]]: + return [path_of(c) for c in _items(doc) if not skip or all(c is not s for s in skip)] + + +def _taken_item(doc: dict[str, Any], path: list[str], skip=None) -> None: + if path in _item_paths(doc, skip): + raise TreeError(409, f"이미 있는 항목 「{name_of(path)}」") + + +def _set_path(col: dict[str, Any], path: list[str]) -> None: + col["경로"] = path + col["머리"] = list(path) # 머리 줄 수는 저장 맞춤(`normalize_table`)이 채움 + + +def _common(a: list[str], b: list[str]) -> int: + n = 0 + while n < min(len(a), len(b)) and a[n] == b[n]: + n += 1 + return n + + +def _place(doc: dict[str, Any], col: dict[str, Any]) -> None: + """열을 가장 가까운 형제 뒤에 끼움(같은 앞마디가 긴 항목 중 마지막) — 제목행 병합이 이어지게. + 항목이 없으면 글 열 가운데 마지막 앞(비고 앞) · 그것도 없으면 끝.""" + cols = doc.setdefault("열", []) + path = path_of(col) + best, at = -1, None + for i, other in enumerate(cols): + if is_item(other): + share = _common(path, path_of(other)) + if share >= best: + best, at = share, i + if at is None: + at = len(cols) - 2 if len(cols) >= 2 else len(cols) - 1 + cols.insert(at + 1, col) + + +def _fresh_id(doc: dict[str, Any]) -> str: + used = {c.get("id") for c in doc.get("열") or [] if isinstance(c, dict)} + n = 1 + while f"item{n}" in used: + n += 1 + return f"item{n}" + + +def _under(path: list[str], folder: list[str]) -> bool: + return path[: len(folder)] == folder + + +def _folder(doc: dict[str, Any], where: list[str], what: str) -> None: + """고칠 폴더가 있는지 — 경로가 비었으면 422 · 없으면 404.""" + if not where: + raise TreeError(422, f"{what} 폴더 「경로」 가 없음") + if where not in folders_of(doc) and not any(_under(p, where) for p in _item_paths(doc)): + raise TreeError(404, f"없는 폴더 「{name_of(where)}」") + + +def apply( + doc: dict[str, Any], body: dict[str, Any], ids: dict[str, str] | None = None +) -> tuple[dict[str, Any], dict[str, Any]]: + """나무 고침 하나 → (새 집계표 문서, 알림). `ids` = SS 키 → 열 id(항목을 키로 부를 때). + 본문 `{op, 경로, 이름?, 새경로?, 키?, 단위?}` — 26-1 약속 모양 · 판은 부르는 쪽이 봄.""" + out = copy.deepcopy(doc) + ids = ids or {} + op = body.get("op") + if op not in OPS: + raise TreeError(422, f"모르는 고침 「{op}」 — {' · '.join(OPS)}") + where = _path(body.get("경로")) + key = body.get("키") + note: dict[str, Any] = {"op": op} + folders = out.setdefault("폴더", []) + + if op == "add-folder": + made = [*where, _name(body.get("이름"))] + if made in folders_of(out) or made in _item_paths(out): + raise TreeError(409, f"이미 있는 폴더 · 항목 「{name_of(made)}」") + folders.append(made) + note["경로"] = made + elif op == "add-item": + made = [*where, _name(body.get("이름"))] + _taken_item(out, made) + col: dict[str, Any] = {"id": _fresh_id(out), "단위": body.get("단위"), "꼴": "수"} + col.update({"일위대가": None, "자재조합": None}) + _set_path(col, made) + _place(out, col) + note.update({"경로": made, "열": col["id"]}) + elif op == "rename": + new = _name(body.get("이름")) + if key: + col = _find(out, key, ids) + path = path_of(col) + moved = [*path[:-1], new] + _taken_item(out, moved, [col]) + _set_path(col, moved) + note["경로"] = moved + else: + _folder(out, where, "바꿀") + moved = [*where[:-1], new] + if moved != where and ( + moved in folders_of(out) or any(_under(p, moved) for p in _item_paths(out)) + ): + raise TreeError(409, f"이미 있는 폴더 「{name_of(moved)}」") + _move_folder(out, where, moved) + note["경로"] = moved + elif op == "move": + to = _path(body.get("새경로"), "새경로") + if key: + col = _find(out, key, ids) + moved = [*to, path_of(col)[-1]] + _taken_item(out, moved, [col]) + out["열"] = [c for c in out["열"] if c is not col] + _set_path(col, moved) + _place(out, col) + note["경로"] = moved + 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)}」") + _move_folder(out, where, moved, replace=True) + note["경로"] = moved + else: # delete + if key: + gone = [_find(out, key, ids)] + else: + _folder(out, where, "지울") + gone = [c for c in _items(out) if _under(path_of(c), where)] + out["폴더"] = [f for f in folders if not _under(_clean(f), where)] + out["열"] = [c for c in out["열"] if all(c is not g for g in gone)] + note["지운항목"] = [g["id"] for g in gone] + return out, note + + +def _move_folder( + doc: dict[str, Any], old: list[str], new: list[str], replace: bool = False +) -> None: + """폴더 `old` 아래 항목 · 빈 폴더의 앞마디를 `new` 로 — 이름 바꾸기는 제자리 · + 옮기기(`replace`)는 새 형제 곁으로 열 차례도 옮김.""" + moving = [c for c in _items(doc) if _under(path_of(c), old)] + if replace: + doc["열"] = [c for c in doc["열"] if all(c is not m for m in moving)] + for col in moving: + _set_path(col, [*new, *path_of(col)[len(old) :]]) + if replace: + _place(doc, col) + doc["폴더"] = [ + [*new, *_clean(f)[len(old) :]] if _under(_clean(f), old) else f + for f in doc.get("폴더") or [] + ] diff --git a/resources/master_template/table/구조물집계표.json b/resources/master_template/table/구조물집계표.json index b229bef3..860fb71b 100644 --- a/resources/master_template/table/구조물집계표.json +++ b/resources/master_template/table/구조물집계표.json @@ -105,7 +105,13 @@ } } ] - } + }, + "경로": [ + "돌기슭막이", + "메쌓기", + "1.2m", + "기초유" + ] }, { "id": "c4", @@ -117,7 +123,13 @@ ], "단위": "m", "꼴": "수", - "일위대가": "UA000004" + "일위대가": "UA000004", + "경로": [ + "돌기슭막이", + "메쌓기", + "1.2m", + "기초무, 소단" + ] }, { "id": "c1", @@ -129,19 +141,29 @@ ], "단위": "m", "꼴": "수", - "일위대가": "UA000004" + "일위대가": "UA000004", + "경로": [ + "돌기슭막이", + "메쌓기", + "1.5m", + "기초유" + ] }, { "id": "c5", "머리": [ "돌기슭막이", "찰쌓기", - "", - "" + null, + null ], "단위": "m", "꼴": "수", - "일위대가": "UA000004" + "일위대가": "UA000004", + "경로": [ + "돌기슭막이", + "찰쌓기" + ] }, { "id": "pv_b", @@ -149,7 +171,7 @@ "콘크리트포장", "T=(두께마다)", "B", - "" + null ], "단위": "m", "꼴": "수", @@ -173,7 +195,12 @@ "빈값": [ "두께 미입력" ] - } + }, + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "B" + ] }, { "id": "pv_l", @@ -181,7 +208,7 @@ "콘크리트포장", "T=(두께마다)", "L", - "" + null ], "단위": "m", "꼴": "수", @@ -206,7 +233,12 @@ "빈값": [ "두께 미입력" ] - } + }, + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "L" + ] }, { "id": "pv_w", @@ -214,7 +246,7 @@ "콘크리트포장", "T=(두께마다)", "확폭", - "" + null ], "단위": "㎡", "꼴": "수", @@ -238,7 +270,12 @@ "빈값": [ "두께 미입력" ] - } + }, + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "확폭" + ] }, { "id": "pv_a", @@ -246,7 +283,7 @@ "콘크리트포장", "T=(두께마다)", "A", - "" + null ], "단위": "㎡", "꼴": "수", @@ -270,7 +307,12 @@ "빈값": [ "두께 미입력" ] - } + }, + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "A" + ] }, { "id": "pv_jt", @@ -278,7 +320,7 @@ "콘크리트포장", "T=(두께마다)", "수축줄눈", - "" + null ], "단위": "m", "꼴": "수", @@ -301,7 +343,12 @@ "빈값": [ "두께 미입력" ] - } + }, + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "수축줄눈" + ] }, { "id": "c3", @@ -309,10 +356,15 @@ "콘크리트포장", "T=(두께마다)", "새 분류2", - "" + null ], "단위": "m", - "꼴": "수" + "꼴": "수", + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "새 분류2" + ] }, { "id": "pp_len", @@ -320,7 +372,7 @@ "관공", "(관종·관경마다)", "관매설", - "" + null ], "단위": "m", "꼴": "수", @@ -352,7 +404,12 @@ "관종 미입력", "관경 미입력" ] - } + }, + "경로": [ + "관공", + "(관종·관경마다)", + "관매설" + ] }, { "id": "pp_cp", @@ -360,7 +417,7 @@ "관공", "(관종·관경마다)", "커플링밴드", - "" + null ], "단위": "개소", "꼴": "수", @@ -381,7 +438,12 @@ "관종 미입력", "관경 미입력" ] - } + }, + "경로": [ + "관공", + "(관종·관경마다)", + "커플링밴드" + ] }, { "id": "pg_in", @@ -389,7 +451,7 @@ "관보호공", "(형식마다)", "유입", - "" + null ], "단위": "개소", "꼴": "수", @@ -433,7 +495,12 @@ } } ] - } + }, + "경로": [ + "관보호공", + "(형식마다)", + "유입" + ] }, { "id": "bx_len", @@ -441,7 +508,7 @@ "BOX암거", "(폭×높이마다)", "연장", - "" + null ], "단위": "m", "꼴": "수", @@ -465,7 +532,12 @@ "폭 미입력", "높이 미입력" ] - } + }, + "경로": [ + "BOX암거", + "(폭×높이마다)", + "연장" + ] }, { "id": "bx_wing", @@ -473,7 +545,7 @@ "BOX암거", "(폭×높이마다)", "날개벽", - "" + null ], "단위": "개소", "꼴": "수", @@ -513,15 +585,20 @@ } } ] - } + }, + "경로": [ + "BOX암거", + "(폭×높이마다)", + "날개벽" + ] }, { "id": "fb", "머리": [ "세월교", "(관경 × 련수마다)", - "", - "" + null, + null ], "단위": "개소", "꼴": "수", @@ -548,7 +625,11 @@ "관경 미입력", "련수 미입력" ] - } + }, + "경로": [ + "세월교", + "(관경 × 련수마다)" + ] }, { "id": "fp_w", @@ -556,7 +637,7 @@ "물넘이포장", "T=(두께마다)", "폭", - "" + null ], "단위": "m", "꼴": "수", @@ -580,7 +661,12 @@ "빈값": [ "두께 미입력" ] - } + }, + "경로": [ + "물넘이포장", + "T=(두께마다)", + "폭" + ] }, { "id": "fp_l", @@ -588,7 +674,7 @@ "물넘이포장", "T=(두께마다)", "길이", - "" + null ], "단위": "m", "꼴": "수", @@ -612,7 +698,12 @@ "빈값": [ "두께 미입력" ] - } + }, + "경로": [ + "물넘이포장", + "T=(두께마다)", + "길이" + ] }, { "id": "od_len", @@ -620,7 +711,7 @@ "횡단개거", "(규격마다)", "길이", - "" + null ], "단위": "m", "꼴": "수", @@ -642,7 +733,12 @@ "빈값": [ "규격 미입력" ] - } + }, + "경로": [ + "횡단개거", + "(규격마다)", + "길이" + ] }, { "id": "od_cnt", @@ -650,7 +746,7 @@ "횡단개거", "(규격마다)", "개소", - "" + null ], "단위": "개소", "꼴": "수", @@ -681,7 +777,12 @@ "값": "개소" } ] - } + }, + "경로": [ + "횡단개거", + "(규격마다)", + "개소" + ] }, { "id": "rw", @@ -689,7 +790,7 @@ "옹벽", "(형식마다)", "H=(높이마다)", - "" + null ], "단위": "m", "꼴": "수", @@ -716,7 +817,12 @@ "형식 미입력", "높이 미입력" ] - } + }, + "경로": [ + "옹벽", + "(형식마다)", + "H=(높이마다)" + ] }, { "id": "ms", @@ -724,7 +830,7 @@ "돌쌓기", "(찰·메마다)", "H=(높이마다)", - "" + null ], "단위": "m", "꼴": "수", @@ -761,7 +867,12 @@ "값": "길이" } ] - } + }, + "경로": [ + "돌쌓기", + "(찰·메마다)", + "H=(높이마다)" + ] }, { "id": "sg", @@ -769,7 +880,7 @@ "흙막이", "(형태마다)", "H=(높이마다)", - "" + null ], "단위": "m", "꼴": "수", @@ -797,7 +908,12 @@ "형식 미입력", "높이 미입력" ] - } + }, + "경로": [ + "흙막이", + "(형태마다)", + "H=(높이마다)" + ] }, { "id": "bm_len", @@ -805,7 +921,7 @@ "큰돌쌓기", "(찰·메 · 돌 크기 · 높이마다)", "길이", - "" + null ], "단위": "m", "꼴": "수", @@ -835,7 +951,12 @@ "돌규격 미입력", "높이 미입력" ] - } + }, + "경로": [ + "큰돌쌓기", + "(찰·메 · 돌 크기 · 높이마다)", + "길이" + ] }, { "id": "bm_area", @@ -843,7 +964,7 @@ "큰돌쌓기", "(찰·메 · 돌 크기 · 높이마다)", "면적", - "" + null ], "단위": "㎡", "꼴": "수", @@ -868,15 +989,20 @@ "높이 미입력" ], "식틀": "IF([bm_len]>0,[bm_len]*{2},\"\")" - } + }, + "경로": [ + "큰돌쌓기", + "(찰·메 · 돌 크기 · 높이마다)", + "면적" + ] }, { "id": "ec", "머리": [ "골막이", "(형태마다)", - "", - "" + null, + null ], "단위": "개소", "꼴": "수", @@ -897,15 +1023,19 @@ "빈값": [ "형식 미입력" ] - } + }, + "경로": [ + "골막이", + "(형태마다)" + ] }, { "id": "be", "머리": [ "소단", - "", - "", - "" + null, + null, + null ], "단위": "m", "꼴": "수", @@ -913,15 +1043,18 @@ "바인딩": { "종류": "berm", "값": "길이" - } + }, + "경로": [ + "소단" + ] }, { "id": "rf", "머리": [ "대피소", - "", - "", - "" + null, + null, + null ], "단위": "개소", "꼴": "수", @@ -929,26 +1062,33 @@ "바인딩": { "종류": "refuge", "값": "개소" - } + }, + "경로": [ + "대피소" + ] }, { "id": "c2", "머리": [ "대피소", "새 분류1", - "", - "" + null, + null ], "단위": "개소", - "꼴": "수" + "꼴": "수", + "경로": [ + "대피소", + "새 분류1" + ] }, { "id": "wy", "머리": [ "정차·작업장", - "", - "", - "" + null, + null, + null ], "단위": "개소", "꼴": "수", @@ -956,15 +1096,18 @@ "바인딩": { "종류": "work_yard", "값": "개소" - } + }, + "경로": [ + "정차·작업장" + ] }, { "id": "ta", "머리": [ "차돌림곳", - "", - "", - "" + null, + null, + null ], "단위": "개소", "꼴": "수", @@ -972,15 +1115,18 @@ "바인딩": { "종류": "turnaround", "값": "개소" - } + }, + "경로": [ + "차돌림곳" + ] }, { "id": "gr_rail", "머리": [ "가드레일", - "", - "", - "" + null, + null, + null ], "단위": "m", "꼴": "수", @@ -991,15 +1137,18 @@ "조건": { "kind": "가드레일" } - } + }, + "경로": [ + "가드레일" + ] }, { "id": "gr_curb", "머리": [ "경계석", - "", - "", - "" + null, + null, + null ], "단위": "m", "꼴": "수", @@ -1010,15 +1159,18 @@ "조건": { "kind": "경계석" } - } + }, + "경로": [ + "경계석" + ] }, { "id": "gr_sign", "머리": [ "위험표지", - "", - "", - "" + null, + null, + null ], "단위": "개소", "꼴": "수", @@ -1030,15 +1182,18 @@ "조건": { "kind": "위험표지" } - } + }, + "경로": [ + "위험표지" + ] }, { "id": "mr", "머리": [ "반사경", - "", - "", - "" + null, + null, + null ], "단위": "개소", "꼴": "수", @@ -1047,15 +1202,18 @@ "바인딩": { "종류": "mirror", "값": "개소" - } + }, + "경로": [ + "반사경" + ] }, { "id": "bg", "머리": [ "차단기", - "", - "", - "" + null, + null, + null ], "단위": "개소", "꼴": "수", @@ -1064,15 +1222,18 @@ "바인딩": { "종류": "barrier_gate", "값": "개소" - } + }, + "경로": [ + "차단기" + ] }, { "id": "ps", "머리": [ "국가지점번호판", - "", - "", - "" + null, + null, + null ], "단위": "개소", "꼴": "수", @@ -1081,15 +1242,18 @@ "바인딩": { "종류": "position_sign", "값": "개소" - } + }, + "경로": [ + "국가지점번호판" + ] }, { "id": "cs", "머리": [ "준공표지석", - "", - "", - "" + null, + null, + null ], "단위": "개소", "꼴": "수", @@ -1098,15 +1262,18 @@ "바인딩": { "종류": "completion_sign", "값": "개소" - } + }, + "경로": [ + "준공표지석" + ] }, { "id": "etc", "머리": [ "기타", "(이름마다)", - "", - "" + null, + null ], "단위": "개소", "꼴": "수", @@ -1127,175 +1294,217 @@ "빈값": [ "이름 미입력" ] - } + }, + "경로": [ + "기타", + "(이름마다)" + ] }, { "id": "h_intake", "머리": [ "취수정", - "", - "", - "" + null, + null, + null ], "단위": "개소", "꼴": "수", "손": true, "설명": "손 입력", - "일위대가": "UA000005" + "일위대가": "UA000005", + "경로": [ + "취수정" + ] }, { "id": "h_rip_c", "머리": [ "돌붙임", "찰붙임", - "", - "" + null, + null ], "단위": "㎡", "꼴": "수", "손": true, "설명": "손 입력", - "일위대가": "UA000009" + "일위대가": "UA000009", + "경로": [ + "돌붙임", + "찰붙임" + ] }, { "id": "h_rip_m", "머리": [ "돌붙임", "메붙임", - "", - "" + null, + null ], "단위": "㎡", "꼴": "수", "손": true, "설명": "손 입력", - "일위대가": "UA000009" + "일위대가": "UA000009", + "경로": [ + "돌붙임", + "메붙임" + ] }, { "id": "h_stone_ch", "머리": [ "돌수로", - "", - "", - "" + null, + null, + null ], "단위": "m", "꼴": "수", "손": true, "설명": "손 입력", - "일위대가": "UA000011" + "일위대가": "UA000011", + "경로": [ + "돌수로" + ] }, { "id": "h_stone_bed", "머리": [ "돌조공", - "", - "", - "" + null, + null, + null ], "단위": "m", "꼴": "수", "손": true, "설명": "손 입력", - "일위대가": "UA000023" + "일위대가": "UA000023", + "경로": [ + "돌조공" + ] }, { "id": "h_pv_rail", "머리": [ "포장 난간", - "", - "", - "" + null, + null, + null ], "단위": "m", "꼴": "수", "손": true, "설명": "손 입력", - "일위대가": "UA000012" + "일위대가": "UA000012", + "경로": [ + "포장 난간" + ] }, { "id": "h_pg_rail", "머리": [ "관보호공 안전난간", - "", - "", - "" + null, + null, + null ], "단위": "개소", "꼴": "수", "손": true, "설명": "손 입력", - "일위대가": "UA000013" + "일위대가": "UA000013", + "경로": [ + "관보호공 안전난간" + ] }, { "id": "h_soil_ditch", "머리": [ "수로형 토사개거", - "", - "", - "" + null, + null, + null ], "단위": "개소", "꼴": "수", "손": true, "설명": "손 입력", - "일위대가": "UA000033" + "일위대가": "UA000033", + "경로": [ + "수로형 토사개거" + ] }, { "id": "h_ford_basin", "머리": [ "물넘이 집수부", - "", - "", - "" + null, + null, + null ], "단위": "개소", "꼴": "수", "손": true, "설명": "손 입력", - "일위대가": "UA000034" + "일위대가": "UA000034", + "경로": [ + "물넘이 집수부" + ] }, { "id": "h_rockfall", "머리": [ "낙석방지책", - "", - "", - "" + null, + null, + null ], "단위": "경간", "꼴": "수", "손": true, "설명": "손 입력", - "일위대가": null + "일위대가": null, + "경로": [ + "낙석방지책" + ] }, { "id": "h_break", "머리": [ "콘크리트·포장 깨기", - "", - "", - "" + null, + null, + null ], "단위": "㎥", "꼴": "수", "손": true, "설명": "손 입력", - "일위대가": null + "일위대가": null, + "경로": [ + "콘크리트·포장 깨기" + ] }, { "id": "h_waste", "머리": [ "폐기물처리", - "", - "", - "" + null, + null, + null ], "단위": "ton", "꼴": "수", "손": true, "설명": "손 입력", - "일위대가": null + "일위대가": null, + "경로": [ + "폐기물처리" + ] }, { "id": "memo", @@ -1337,5 +1546,6 @@ "memo": 120, "h_soil_ditch": 32 } - } + }, + "폴더": [] } diff --git a/resources/master_template/구조물집계표조합.json b/resources/master_template/구조물집계표조합.json index 31cb46e1..e66d5b18 100644 --- a/resources/master_template/구조물집계표조합.json +++ b/resources/master_template/구조물집계표조합.json @@ -1,12 +1,19 @@ { "그룹": "구조물집계표조합", "판": 1, - "비고": "구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · 키 SS 는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀", + "비고": "구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · 키 SS 는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀 · 경로 = 폴더 이름 배열", "다음": 52, "줄": [ { "키": "SS000001", "열": "rv", + "경로": [ + "돌기슭막이", + "메쌓기", + "1.2m", + "기초유" + ], + "이름": "돌기슭막이 메쌓기 1.2m 기초유", "종류": "돌기슭막이", "분류1": "메쌓기", "분류2": "1.2m", @@ -72,6 +79,13 @@ { "키": "SS000002", "열": "c4", + "경로": [ + "돌기슭막이", + "메쌓기", + "1.2m", + "기초무, 소단" + ], + "이름": "돌기슭막이 메쌓기 1.2m 기초무, 소단", "종류": "돌기슭막이", "분류1": "메쌓기", "분류2": "1.2m", @@ -88,6 +102,13 @@ { "키": "SS000003", "열": "c1", + "경로": [ + "돌기슭막이", + "메쌓기", + "1.5m", + "기초유" + ], + "이름": "돌기슭막이 메쌓기 1.5m 기초유", "종류": "돌기슭막이", "분류1": "메쌓기", "분류2": "1.5m", @@ -104,6 +125,11 @@ { "키": "SS000004", "열": "c5", + "경로": [ + "돌기슭막이", + "찰쌓기" + ], + "이름": "돌기슭막이 찰쌓기", "종류": "돌기슭막이", "분류1": "찰쌓기", "분류2": null, @@ -120,6 +146,12 @@ { "키": "SS000005", "열": "pv_b", + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "B" + ], + "이름": "콘크리트포장 T=(두께마다) B", "종류": "콘크리트포장", "분류1": "T=(두께마다)", "분류2": "B", @@ -154,6 +186,12 @@ { "키": "SS000006", "열": "pv_l", + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "L" + ], + "이름": "콘크리트포장 T=(두께마다) L", "종류": "콘크리트포장", "분류1": "T=(두께마다)", "분류2": "L", @@ -188,6 +226,12 @@ { "키": "SS000007", "열": "pv_w", + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "확폭" + ], + "이름": "콘크리트포장 T=(두께마다) 확폭", "종류": "콘크리트포장", "분류1": "T=(두께마다)", "분류2": "확폭", @@ -222,6 +266,12 @@ { "키": "SS000008", "열": "pv_a", + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "A" + ], + "이름": "콘크리트포장 T=(두께마다) A", "종류": "콘크리트포장", "분류1": "T=(두께마다)", "분류2": "A", @@ -238,6 +288,12 @@ { "키": "SS000009", "열": "pv_jt", + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "수축줄눈" + ], + "이름": "콘크리트포장 T=(두께마다) 수축줄눈", "종류": "콘크리트포장", "분류1": "T=(두께마다)", "분류2": "수축줄눈", @@ -254,6 +310,12 @@ { "키": "SS000010", "열": "c3", + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "새 분류2" + ], + "이름": "콘크리트포장 T=(두께마다) 새 분류2", "종류": "콘크리트포장", "분류1": "T=(두께마다)", "분류2": "새 분류2", @@ -270,6 +332,12 @@ { "키": "SS000011", "열": "pp_len", + "경로": [ + "관공", + "(관종·관경마다)", + "관매설" + ], + "이름": "관공 (관종·관경마다) 관매설", "종류": "관공", "분류1": "(관종·관경마다)", "분류2": "관매설", @@ -307,6 +375,12 @@ { "키": "SS000012", "열": "pp_cp", + "경로": [ + "관공", + "(관종·관경마다)", + "커플링밴드" + ], + "이름": "관공 (관종·관경마다) 커플링밴드", "종류": "관공", "분류1": "(관종·관경마다)", "분류2": "커플링밴드", @@ -323,6 +397,12 @@ { "키": "SS000013", "열": "pg_in", + "경로": [ + "관보호공", + "(형식마다)", + "유입" + ], + "이름": "관보호공 (형식마다) 유입", "종류": "관보호공", "분류1": "(형식마다)", "분류2": "유입", @@ -371,6 +451,12 @@ { "키": "SS000014", "열": "bx_len", + "경로": [ + "BOX암거", + "(폭×높이마다)", + "연장" + ], + "이름": "BOX암거 (폭×높이마다) 연장", "종류": "BOX암거", "분류1": "(폭×높이마다)", "분류2": "연장", @@ -404,6 +490,12 @@ { "키": "SS000015", "열": "bx_wing", + "경로": [ + "BOX암거", + "(폭×높이마다)", + "날개벽" + ], + "이름": "BOX암거 (폭×높이마다) 날개벽", "종류": "BOX암거", "분류1": "(폭×높이마다)", "분류2": "날개벽", @@ -453,6 +545,11 @@ { "키": "SS000016", "열": "fb", + "경로": [ + "세월교", + "(관경 × 련수마다)" + ], + "이름": "세월교 (관경 × 련수마다)", "종류": "세월교", "분류1": "(관경 × 련수마다)", "분류2": null, @@ -490,6 +587,12 @@ { "키": "SS000017", "열": "fp_w", + "경로": [ + "물넘이포장", + "T=(두께마다)", + "폭" + ], + "이름": "물넘이포장 T=(두께마다) 폭", "종류": "물넘이포장", "분류1": "T=(두께마다)", "분류2": "폭", @@ -524,6 +627,12 @@ { "키": "SS000018", "열": "fp_l", + "경로": [ + "물넘이포장", + "T=(두께마다)", + "길이" + ], + "이름": "물넘이포장 T=(두께마다) 길이", "종류": "물넘이포장", "분류1": "T=(두께마다)", "분류2": "길이", @@ -558,6 +667,12 @@ { "키": "SS000019", "열": "od_len", + "경로": [ + "횡단개거", + "(규격마다)", + "길이" + ], + "이름": "횡단개거 (규격마다) 길이", "종류": "횡단개거", "분류1": "(규격마다)", "분류2": "길이", @@ -589,6 +704,12 @@ { "키": "SS000020", "열": "od_cnt", + "경로": [ + "횡단개거", + "(규격마다)", + "개소" + ], + "이름": "횡단개거 (규격마다) 개소", "종류": "횡단개거", "분류1": "(규격마다)", "분류2": "개소", @@ -629,6 +750,12 @@ { "키": "SS000021", "열": "rw", + "경로": [ + "옹벽", + "(형식마다)", + "H=(높이마다)" + ], + "이름": "옹벽 (형식마다) H=(높이마다)", "종류": "옹벽", "분류1": "(형식마다)", "분류2": "H=(높이마다)", @@ -666,6 +793,12 @@ { "키": "SS000022", "열": "ms", + "경로": [ + "돌쌓기", + "(찰·메마다)", + "H=(높이마다)" + ], + "이름": "돌쌓기 (찰·메마다) H=(높이마다)", "종류": "돌쌓기", "분류1": "(찰·메마다)", "분류2": "H=(높이마다)", @@ -713,6 +846,12 @@ { "키": "SS000023", "열": "sg", + "경로": [ + "흙막이", + "(형태마다)", + "H=(높이마다)" + ], + "이름": "흙막이 (형태마다) H=(높이마다)", "종류": "흙막이", "분류1": "(형태마다)", "분류2": "H=(높이마다)", @@ -750,6 +889,12 @@ { "키": "SS000024", "열": "bm_len", + "경로": [ + "큰돌쌓기", + "(찰·메 · 돌 크기 · 높이마다)", + "길이" + ], + "이름": "큰돌쌓기 (찰·메 · 돌 크기 · 높이마다) 길이", "종류": "큰돌쌓기", "분류1": "(찰·메 · 돌 크기 · 높이마다)", "분류2": "길이", @@ -790,6 +935,12 @@ { "키": "SS000025", "열": "bm_area", + "경로": [ + "큰돌쌓기", + "(찰·메 · 돌 크기 · 높이마다)", + "면적" + ], + "이름": "큰돌쌓기 (찰·메 · 돌 크기 · 높이마다) 면적", "종류": "큰돌쌓기", "분류1": "(찰·메 · 돌 크기 · 높이마다)", "분류2": "면적", @@ -806,6 +957,11 @@ { "키": "SS000026", "열": "ec", + "경로": [ + "골막이", + "(형태마다)" + ], + "이름": "골막이 (형태마다)", "종류": "골막이", "분류1": "(형태마다)", "분류2": null, @@ -837,6 +993,10 @@ { "키": "SS000027", "열": "be", + "경로": [ + "소단" + ], + "이름": "소단", "종류": "소단", "분류1": null, "분류2": null, @@ -856,6 +1016,10 @@ { "키": "SS000028", "열": "rf", + "경로": [ + "대피소" + ], + "이름": "대피소", "종류": "대피소", "분류1": null, "분류2": null, @@ -875,6 +1039,11 @@ { "키": "SS000029", "열": "c2", + "경로": [ + "대피소", + "새 분류1" + ], + "이름": "대피소 새 분류1", "종류": "대피소", "분류1": "새 분류1", "분류2": null, @@ -891,6 +1060,10 @@ { "키": "SS000030", "열": "wy", + "경로": [ + "정차·작업장" + ], + "이름": "정차·작업장", "종류": "정차·작업장", "분류1": null, "분류2": null, @@ -910,6 +1083,10 @@ { "키": "SS000031", "열": "ta", + "경로": [ + "차돌림곳" + ], + "이름": "차돌림곳", "종류": "차돌림곳", "분류1": null, "분류2": null, @@ -929,6 +1106,10 @@ { "키": "SS000032", "열": "gr_rail", + "경로": [ + "가드레일" + ], + "이름": "가드레일", "종류": "가드레일", "분류1": null, "분류2": null, @@ -951,6 +1132,10 @@ { "키": "SS000033", "열": "gr_curb", + "경로": [ + "경계석" + ], + "이름": "경계석", "종류": "경계석", "분류1": null, "분류2": null, @@ -973,6 +1158,10 @@ { "키": "SS000034", "열": "gr_sign", + "경로": [ + "위험표지" + ], + "이름": "위험표지", "종류": "위험표지", "분류1": null, "분류2": null, @@ -995,6 +1184,10 @@ { "키": "SS000035", "열": "mr", + "경로": [ + "반사경" + ], + "이름": "반사경", "종류": "반사경", "분류1": null, "분류2": null, @@ -1014,6 +1207,10 @@ { "키": "SS000036", "열": "bg", + "경로": [ + "차단기" + ], + "이름": "차단기", "종류": "차단기", "분류1": null, "분류2": null, @@ -1033,6 +1230,10 @@ { "키": "SS000037", "열": "ps", + "경로": [ + "국가지점번호판" + ], + "이름": "국가지점번호판", "종류": "국가지점번호판", "분류1": null, "분류2": null, @@ -1052,6 +1253,10 @@ { "키": "SS000038", "열": "cs", + "경로": [ + "준공표지석" + ], + "이름": "준공표지석", "종류": "준공표지석", "분류1": null, "분류2": null, @@ -1071,6 +1276,11 @@ { "키": "SS000039", "열": "etc", + "경로": [ + "기타", + "(이름마다)" + ], + "이름": "기타 (이름마다)", "종류": "기타", "분류1": "(이름마다)", "분류2": null, @@ -1102,6 +1312,10 @@ { "키": "SS000040", "열": "h_intake", + "경로": [ + "취수정" + ], + "이름": "취수정", "종류": "취수정", "분류1": null, "분류2": null, @@ -1118,6 +1332,11 @@ { "키": "SS000041", "열": "h_rip_c", + "경로": [ + "돌붙임", + "찰붙임" + ], + "이름": "돌붙임 찰붙임", "종류": "돌붙임", "분류1": "찰붙임", "분류2": null, @@ -1134,6 +1353,11 @@ { "키": "SS000042", "열": "h_rip_m", + "경로": [ + "돌붙임", + "메붙임" + ], + "이름": "돌붙임 메붙임", "종류": "돌붙임", "분류1": "메붙임", "분류2": null, @@ -1150,6 +1374,10 @@ { "키": "SS000043", "열": "h_stone_ch", + "경로": [ + "돌수로" + ], + "이름": "돌수로", "종류": "돌수로", "분류1": null, "분류2": null, @@ -1166,6 +1394,10 @@ { "키": "SS000044", "열": "h_stone_bed", + "경로": [ + "돌조공" + ], + "이름": "돌조공", "종류": "돌조공", "분류1": null, "분류2": null, @@ -1182,6 +1414,10 @@ { "키": "SS000045", "열": "h_pv_rail", + "경로": [ + "포장 난간" + ], + "이름": "포장 난간", "종류": "포장 난간", "분류1": null, "분류2": null, @@ -1198,6 +1434,10 @@ { "키": "SS000046", "열": "h_pg_rail", + "경로": [ + "관보호공 안전난간" + ], + "이름": "관보호공 안전난간", "종류": "관보호공 안전난간", "분류1": null, "분류2": null, @@ -1214,6 +1454,10 @@ { "키": "SS000047", "열": "h_soil_ditch", + "경로": [ + "수로형 토사개거" + ], + "이름": "수로형 토사개거", "종류": "수로형 토사개거", "분류1": null, "분류2": null, @@ -1230,6 +1474,10 @@ { "키": "SS000048", "열": "h_ford_basin", + "경로": [ + "물넘이 집수부" + ], + "이름": "물넘이 집수부", "종류": "물넘이 집수부", "분류1": null, "분류2": null, @@ -1246,6 +1494,10 @@ { "키": "SS000049", "열": "h_rockfall", + "경로": [ + "낙석방지책" + ], + "이름": "낙석방지책", "종류": "낙석방지책", "분류1": null, "분류2": null, @@ -1262,6 +1514,10 @@ { "키": "SS000050", "열": "h_break", + "경로": [ + "콘크리트·포장 깨기" + ], + "이름": "콘크리트·포장 깨기", "종류": "콘크리트·포장 깨기", "분류1": null, "분류2": null, @@ -1278,6 +1534,10 @@ { "키": "SS000051", "열": "h_waste", + "경로": [ + "폐기물처리" + ], + "이름": "폐기물처리", "종류": "폐기물처리", "분류1": null, "분류2": null, diff --git a/resources/tester/spreadsheet/test_spreadsheet_store.py b/resources/tester/spreadsheet/test_spreadsheet_store.py index 44e83528..5be901bb 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_store.py +++ b/resources/tester/spreadsheet/test_spreadsheet_store.py @@ -67,13 +67,14 @@ def test_옛_산출근거를_면적산출로_옮기고_번호_8자리(client, tm def test_새_구조물_도면은_면적_재료_진짜_빈_시트_같은_8자리(client): made1 = client.post("/api/m02/structures/rv").json()["문서"] made2 = client.post("/api/m02/structures/pv_b").json()["문서"] - assert made1["도번"] == "SD01010100" and made2["도번"] == "SD02010100" + # 새 항목 번호 = 자리 뜻 없는 일련번호(PLAN 26-1) + assert made1["도번"] == "SD00000001" and made2["도번"] == "SD00000002" for kind, key, head in (("area", "면적번", "SA"), ("material", "재료번", "SM")): doc = client.get(f"/api/m02/templates/{kind}/pv_b").json()["문서"] - assert doc[key] == f"{head}02010100" + assert doc[key] == f"{head}00000002" assert doc["시트"][0]["칸"] == {} and doc["서식"] == [{}] # 머리 줄 없음 numbers = client.get("/api/m02/structures/numbers").json() - assert numbers["pv_b"] == {"도번": "SD02010100", "면적번": "SA02010100", "재료번": "SM02010100"} + assert numbers["pv_b"] == {"도번": "SD00000002", "면적번": "SA00000002", "재료번": "SM00000002"} @pytest.mark.parametrize( @@ -89,7 +90,7 @@ def test_다른_열_내용을_복사해_가져옴_연결도_같이(client, kind, assert r.status_code == 200 ms = client.get(f"/api/m02/templates/{kind}/ms").json()["문서"] assert (ms["시트"][0]["칸"], ms["연결"]) == ({"A1": {"식": "1+1"}}, LINKS) - assert (ms[key], ms["열"]) == (f"{head}05010100", "ms") # 번호 · 열은 받는 쪽 그대로 + assert (ms[key], ms["열"]) == (f"{head}00000010", "ms") # 번호 · 열은 받는 쪽 그대로 assert ms["계산값"] == SERVER # 서버가 다시 풂 assert client.post(f"/api/m02/{kind}/ms/copy", params={"from": "없는열"}).status_code == 404 diff --git a/resources/tester/test_m02_structure.py b/resources/tester/test_m02_structure.py index 7989dcbd..6b28e248 100644 --- a/resources/tester/test_m02_structure.py +++ b/resources/tester/test_m02_structure.py @@ -1,5 +1,6 @@ """M02 구조물 도면(structure) — 새로 · 도번 SD+8자리 · 409 · 저장 · 지운 뒤 번호 · -제목행 번호 매김(분류3 까지) · 집계표 저장 때 만들기/지우기 · 복사 가져오기 · 프로젝트 복사. +번호 매김(새 항목 = 자리 뜻 없는 일련번호 · PLAN 26-1) · 집계표 저장 때 만들기/지우기 · +복사 가져오기 · 프로젝트 복사. 면적산출(area) · 재료산출(material)은 따로 파일 — `resources/tester/spreadsheet/test_spreadsheet_store.py`.""" @@ -75,7 +76,7 @@ def test_새로_도번_자동_409_없는_열(client): "구조물도면", "structure", "pp_len", - "SD01010100", + "SD00000001", ) # 도각 없는 전용 화면 — 그린 만큼이 설계 영역 · 기본 도면층 하나(없으면 CAD 가 못 엶) assert doc["도면"]["format"] == 6 @@ -86,13 +87,13 @@ def test_새로_도번_자동_409_없는_열(client): for kind in ("area", "material"): assert client.get(f"/api/m02/templates/{kind}/pp_len").json()["문서"]["종류"] == "통합문서" - assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "SD02010100" + assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "SD00000002" assert client.post("/api/m02/structures/pp_len").status_code == 409 assert client.post("/api/m02/structures/없는열").status_code == 404 assert client.post("/api/m02/structures/no").status_code == 404 # 글 열은 구조물 아님 assert client.get("/api/m02/structures/numbers").json() == { - "pp_len": _num("01010100"), - "rv": _num("02010100"), + "pp_len": _num("00000001"), + "rv": _num("00000002"), } rows = client.get("/api/m02/templates").json() assert {(r["종류"], r["이름"]) for r in rows if r["종류"] == "structure"} == { @@ -118,7 +119,7 @@ def test_저장_뼈대_거름(client): "/api/m02/templates/structure/pp_len", json={"판": now, "문서": {**doc, "도번": "SD9"}} ) got = client.get("/api/m02/templates/structure/pp_len").json()["문서"] - assert got["도번"] == "SD01010100" # 번호는 서버 것 — 화면 값을 안 받음 + assert got["도번"] == "SD00000001" # 번호는 서버 것 — 화면 값을 안 받음 def test_지워도_번호를_당기지_않음_짝은_같은_번호(client): @@ -126,15 +127,15 @@ def test_지워도_번호를_당기지_않음_짝은_같은_번호(client): client.post(f"/api/m02/structures/{column}") assert client.delete("/api/m02/templates/structure/rv").status_code == 200 numbers = client.get("/api/m02/structures/numbers").json() - assert numbers["rv"] == {**_num("02010100"), "도번": ""} # 면적 · 재료는 남아 번호를 쥠 - assert numbers["ms"] == _num("03010100") # 다른 열 번호는 그대로 - # 같은 종류(관공) 새 열 = 그 무리 다음 번호 · 다시 만든 도면은 짝(면적산출) 번호를 이어 받음 - assert client.post("/api/m02/structures/pp_cp").json()["문서"]["도번"] == "SD01010200" - assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "SD02010100" + assert numbers["rv"] == {**_num("00000002"), "도번": ""} # 면적 · 재료는 남아 번호를 쥠 + assert numbers["ms"] == _num("00000003") # 다른 열 번호는 그대로 + # 새 열 = 다음 일련번호(자리 뜻 없음) · 다시 만든 도면은 짝(면적산출) 번호를 이어 받음 + assert client.post("/api/m02/structures/pp_cp").json()["문서"]["도번"] == "SD00000004" + assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "SD00000002" -def test_제목행으로_번호_매김_한_번_옮김(client, tmp_path): - """옛 구-NN 파일 · 글 열 파일 → 새 번호(열 차례 = 종류 · 분류1 · 분류2 · 분류3 차례) · +def test_번호_없는_열은_열_차례로_일련번호_한_번_옮김(client, tmp_path): + """옛 구-NN 파일 · 글 열 파일 → 새 번호(번호 없는 열 = 열 차례 일련번호 · 자리 뜻 없음) · 내용 그대로.""" for column in ("no", "rv", "pv_l"): doc = {"양식": "구조물도면", "종류": "structure", "판": 1, "열": column, "도번": "구-07"} @@ -146,16 +147,16 @@ def test_제목행으로_번호_매김_한_번_옮김(client, tmp_path): assert done["지움"] == ["structure/no"] # 글 열(NO · 측점 · 비고)은 구조물 아님 numbers = client.get("/api/m02/structures/numbers").json() assert set(numbers) == set(store._column_ids()) and "sta" not in numbers - expect = { - "rv": "01010100", # 돌기슭막이 · (형태마다) · H=(높이마다) · 분류3 없음 = 00 - "c1": "01010200", # 같은 종류 · 분류1 · 다음 분류2 - "pv_b": "02010100", # 콘크리트포장 · T=(두께마다) · B - "pv_l": "02010200", - "pp_len": "03010100", - "fb": "04010000", # 세월교 · (관경 × 련수마다) · 분류2 없음 = 00 - "be": "06000000", # 소단 — 분류 모두 없음 - "h_rip_c": "07010000", # 돌붙임 · 찰붙임 - "h_rip_m": "07020000", # 돌붙임 · 메붙임 — 분류1 다음 번호 + expect = { # 구조물 열 차례 그대로 1 부터(제목행 자리 뜻 없음) + "rv": "00000001", + "c1": "00000002", + "pv_b": "00000003", + "pv_l": "00000004", + "pp_len": "00000007", + "fb": "00000009", + "be": "00000011", + "h_rip_c": "00000012", + "h_rip_m": "00000013", } for column, code in expect.items(): assert numbers[column] == _num(code), column @@ -182,24 +183,26 @@ def test_집계표_저장_때_새_열은_만들고_지운_열은_같이_지움(c assert "pv_a" not in numbers and "note2" not in numbers for kind_ in ("structure", "area", "material"): assert client.get(f"/api/m02/templates/{kind_}/pv_a").status_code == 404 - assert numbers["pv_x"] == _num("02010500") # 지운 pv_a(03)는 비워 둠 · 무리 다음 - assert numbers["zz"] == _num("08000000") # 새 종류 = 종류 다음 번호 - assert numbers["pv_y"] == _num("02010101") # pv_b 무리 안 분류3 다음 번호 + # 새 열 = 열 차례로 다음 일련번호 · 지운 pv_a(5) 는 비워 둠(다른 번호를 안 당김) + assert numbers["pv_x"] == _num("00000014") + assert numbers["zz"] == _num("00000015") + assert numbers["pv_y"] == _num("00000016") + assert numbers["pv_b"] == _num("00000003") # 있던 번호는 그대로 def test_지운_열_번호는_다음_저장에도_다시_안_줌(client): - """무리 맨 끝 열(pv_jt 02010400)을 지워 저장한 뒤 새 열을 더해도 04 를 다시 안 받음.""" + """가장 큰 번호 열(h_rip_m 13)을 지워 저장한 뒤 새 열을 더해도 13 을 다시 안 받음.""" store.renumber() url = "/api/m02/templates/table/구조물집계표" got = client.get(url).json() - got["문서"]["열"] = [c for c in got["문서"]["열"] if c["id"] != "pv_jt"] + got["문서"]["열"] = [c for c in got["문서"]["열"] if c["id"] != "h_rip_m"] assert client.put(url, json={"판": got["판"], "문서": got["문서"]}).status_code == 200 - assert client.get("/api/m02/structure-combos").json()["지운번호"] == ["02010400"] + assert client.get("/api/m02/structure-combos").json()["지운번호"] == ["00000013"] got = client.get(url).json() new = {"id": "pv_z", "머리": ["콘크리트포장", "T=(두께마다)", "Z", None], "꼴": "수"} got["문서"]["열"].append(new) assert client.put(url, json={"판": got["판"], "문서": got["문서"]}).status_code == 200 - assert client.get("/api/m02/structures/numbers").json()["pv_z"] == _num("02010500") + assert client.get("/api/m02/structures/numbers").json()["pv_z"] == _num("00000014") def test_다른_열_도면을_복사해_가져옴(client): @@ -211,8 +214,8 @@ def test_다른_열_도면을_복사해_가져옴(client): assert r.status_code == 200 ms = client.get("/api/m02/templates/structure/ms").json()["문서"] assert ms["도면"]["entities"] == [{"type": "circle"}] - assert (ms["도번"], ms["열"]) == ("SD05010100", "ms") # 번호 · 열은 받는 쪽 그대로 - assert client.get("/api/m02/templates/structure/rv").json()["문서"]["도번"] == "SD01010100" + assert (ms["도번"], ms["열"]) == ("SD00000010", "ms") # 번호 · 열은 받는 쪽 그대로 + assert client.get("/api/m02/templates/structure/rv").json()["문서"]["도번"] == "SD00000001" bad = client.post("/api/m02/structures/ms/copy", params={"from": "없는열"}) assert bad.status_code == 404 diff --git a/resources/tester/test_m02_structure_combo.py b/resources/tester/test_m02_structure_combo.py index 9e68aacc..c10e1d7e 100644 --- a/resources/tester/test_m02_structure_combo.py +++ b/resources/tester/test_m02_structure_combo.py @@ -10,6 +10,7 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient +from M02_MasterTemplete import M02_MasterTemplete_Book as book from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Template_Layers as layers from M02_MasterTemplete.M02_MasterTemplete_Router import router @@ -97,7 +98,7 @@ def test_집계표_저장_때_새_열_지운_열_고친_열을_맞춤_키_유지 def test_SS_머리글은_키대장과_안_겹침(): ledger = json.loads((ROOT / "resources/master_data/_키대장.json").read_text("utf-8")) assert "SS" not in ledger["다음"] - assert store._COMBO_HEAD == "SS" + assert book.HEAD == "SS" # 장부는 `M02_MasterTemplete_Book`(26-2a 나눔) def test_새_프로젝트_복사에_실림(client, tmp_path, monkeypatch): diff --git a/resources/tester/test_m02_tree.py b/resources/tester/test_m02_tree.py new file mode 100644 index 00000000..6c767208 --- /dev/null +++ b/resources/tester/test_m02_tree.py @@ -0,0 +1,247 @@ +"""M02 집계 항목 폴더 구조(PLAN 26-1 · 26-2a) — 경로 · 옛 자료 읽기 · 일련번호 · 나무 고침 · 권한. + +① 옛 마스터(실제 `resources/master_template` · 읽기만) 51 항목이 경로로 읽힘 — 개수 · 이름(옛 종류 · + 분류1~3 이음과 같음) · 키 차례. +② 나무 고침은 임시 storage(회사 · 개인 층)와 견본 시스템 집계표로만 — 빈 층 첫 폴더 · 항목 · 깊이 + 1 · 2 · 5 · 이름 바꾸기 · 옮기기 · 지우기(세 파일 · 입력 연결 같이) · 마지막 항목 · 판 409 · + 422 · 404 · 권한 403 · 표 저장과 한 줄기(머리를 고치면 경로가 따라옴) · 번호 일련 · 안 되돌림. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from config import config_system +from M02_MasterTemplete import M02_MasterTemplete_Book as book +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router +from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MasterTemplete_Tree as tree +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +TABLE = "table/구조물집계표" +MASTER = Path(__file__).resolve().parents[2] / "resources" / "master_template" + + +def _app(session: dict[str, Any]) -> TestClient: + app = FastAPI() + app.include_router(layers_router.router) + app.include_router(items_router.router) + app.dependency_overrides[verify_session] = lambda: session + return TestClient(app) + + +# ── ① 옛 마스터 — 읽기만 ───────────────────────────── + + +def test_옛_마스터_51_항목이_경로로_읽힘(): + raw = json.loads((MASTER / store.COMBO_FILE).read_bytes())["줄"] + old = [ + " ".join(str(r[k]) for k in ("종류", "분류1", "분류2", "분류3") if r.get(k)) for r in raw + ] + client = _app({"user_id": 1, "company_id": 1, "role": "USER", "is_master": False}) + rows = client.get("/api/m02/items").json() + assert len(rows) == len(raw) == 51 + assert [r["key"] for r in rows] == [r["키"] for r in raw] + assert [r["이름"] for r in rows] == old + assert all(r["경로"] and r["이름"] == " ".join(r["경로"]) for r in rows) + got = client.get("/api/m02/layers/system/tree").json() + assert len(got["items"]) == 51 and got["판"] + assert ["돌기슭막이"] in got["폴더"] and ["돌기슭막이", "메쌓기"] in got["폴더"] + # 옛 줄(경로 없음)도 종류 · 분류1~3 에서 경로로(빈 분류 건너뜀) + assert tree.row_path({"종류": "대피소", "분류1": None, "분류2": "", "분류3": "x"}) == [ + "대피소", + "x", + ] + + +def test_옮기기_함수는_두_번_돌려도_같음(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + for kind in store.KINDS: + (tmp_path / kind).mkdir() + for name in ("table/구조물집계표.json", store.COMBO_FILE): + (tmp_path / name).write_bytes((MASTER / name).read_bytes()) + monkeypatch.setattr(store, "FOLDER", tmp_path) + before = json.loads((tmp_path / store.COMBO_FILE).read_bytes())["줄"] + store.backfill_missing_structures() # 사본에 세 파일(번호) — 옮기기가 번호를 안 바꾸는지 봄 + assert book.migrate_paths() is True + assert book.migrate_paths() is False + after = json.loads((tmp_path / store.COMBO_FILE).read_bytes())["줄"] + assert [r["키"] for r in after] == [r["키"] for r in before] + assert all(r["경로"] == tree.row_path({**r, "경로": None}) for r in after) + table = json.loads((tmp_path / "table/구조물집계표.json").read_bytes()) + assert table["층"] == ["종류", "분류1", "분류2", "분류3"] and table["폴더"] == [] + assert all(c["경로"] == tree.path_of(c) for c in table["열"] if tree.is_item(c)) + + +# ── ② 나무 고침 — 임시 storage ────────────────────────── + + +@pytest.fixture() +def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + store.renumber() + storage = tmp_path / "storage" + storage.mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} + return {"client": _app(session), "session": session, "storage": storage, "system": system} + + +def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient: + world["session"].update(role=role, user_id=user) + return world["client"] + + +class Tree: + """한 층 나무를 판을 따라가며 고침.""" + + def __init__(self, client: TestClient, layer: str): + self.client, self.layer = client, layer + self.url = f"/api/m02/layers/{layer}/tree" + self.now = client.get(self.url).json() + + def do(self, want: int = 200, **body: Any) -> dict[str, Any]: + res = self.client.post(self.url, json={"판": self.now["판"], **body}) + assert res.status_code == want, res.text + if want == 200: + self.now = res.json() + assert self.now == {**self.client.get(self.url).json(), "알림": self.now["알림"]} + return res.json() + + def item(self, path: list[str]) -> dict[str, Any]: + return next(r for r in self.now["items"] if r["경로"] == path) + + def paths(self) -> list[list[str]]: + return [r["경로"] for r in self.now["items"]] + + +def _numbers(root: Path, cid: str) -> set[str]: + out = set() + for kind, key, _, _ in store.PARTS: + doc = json.loads((root / kind / f"{cid}.json").read_bytes()) + out.add(doc[key][2:]) + return out + + +def test_빈_층에서_폴더_항목_깊이_1_2_5_이름_옮기기_지우기(world): + t = Tree(_as(world), "personal") + root = world["storage"] / "7" / "42" / "templates" + assert (t.now["폴더"], t.now["items"]) == ([], []) # 빈 층 + t.do(op="add-folder", 경로=[], 이름="돌쌓기") + assert t.now["폴더"] == [["돌쌓기"]] and t.now["items"] == [] + t.do(op="add-item", 경로=[], 이름="소단", 단위="m") # 깊이 1 + t.do(op="add-item", 경로=["돌쌓기"], 이름="찰쌓기") # 깊이 2 + t.do(op="add-item", 경로=["a", "b", "c", "d"], 이름="e") # 깊이 5(폴더는 저절로) + assert t.paths() == [["소단"], ["돌쌓기", "찰쌓기"], ["a", "b", "c", "d", "e"]] + deep = t.item(["a", "b", "c", "d", "e"]) + assert deep["이름"] == "a b c d e" + assert (deep["종류"], deep["분류1"], deep["분류2"], deep["분류3"]) == ("a", "b", "c", "d") + assert t.item(["소단"])["단위"] == "m" + table = json.loads((root / "table/구조물집계표.json").read_bytes()) + assert table["층"] == ["종류", "분류1", "분류2", "분류3", "분류4"] # 가장 깊은 경로에 맞춤 + assert all(len(c["머리"]) == 5 for c in table["열"]) + # 번호 = 자리 뜻 없는 일련번호 · 세 파일 같은 번호 + codes = [_numbers(root, t.item(p)["열"]) for p in t.paths()] + assert codes == [{"00000001"}, {"00000002"}, {"00000003"}] + assert ["a", "b", "c", "d"] in t.now["폴더"] and ["돌쌓기"] in t.now["폴더"] + # 겹침 409 · 모양 422 · 없는 것 404 + t.do(409, op="add-item", 경로=["돌쌓기"], 이름="찰쌓기") + t.do(409, op="add-folder", 경로=[], 이름="a") + for bad in ( + {"op": "grow"}, + {"op": "add-item", "이름": " "}, + {"op": "add-item", "이름": "x" * 61}, + ): + t.do(422, **bad) + t.do(404, op="rename", 키="SS999999", 이름="x") + t.do(404, op="delete", 경로=["없는폴더"]) + # 이름 바꾸기 — 항목(키 · 열 · 번호 그대로) · 폴더(아래 항목 모두) + key = t.item(["돌쌓기", "찰쌓기"])["key"] + t.do(op="rename", 키=key, 이름="메쌓기") + moved = t.item(["돌쌓기", "메쌓기"]) + assert moved["key"] == key and _numbers(root, moved["열"]) == {"00000002"} + t.do(op="rename", 경로=["a"], 이름="A") + assert ["A", "b", "c", "d", "e"] in t.paths() and ["a"] not in t.now["폴더"] + t.do(409, op="rename", 경로=["A"], 이름="돌쌓기") # 있는 폴더로 합치기 막음 + # 옮기기 — 항목 · 폴더 · 제 안으로는 422 + t.do(op="move", 키=t.item(["소단"])["key"], 새경로=["돌쌓기"]) + t.do(op="move", 경로=["A", "b"], 새경로=["돌쌓기"]) + assert t.paths() == [["돌쌓기", "메쌓기"], ["돌쌓기", "소단"], ["돌쌓기", "b", "c", "d", "e"]] + t.do(422, op="move", 경로=["돌쌓기"], 새경로=["돌쌓기", "b"]) + # 지우기 — 항목의 세 파일 · 입력 연결 파일도 같이 · 마지막 항목까지 + gone = t.item(["돌쌓기", "소단"]) + (root / "inputs").mkdir(exist_ok=True) + (root / "inputs" / f"{gone['열']}.json").write_text('{"판": 1, "줄": []}', encoding="utf-8") + note = t.do(op="delete", 키=gone["key"])["알림"] + assert note["지운항목"] == [gone["열"]] + for part in ("structure", "area", "material", "inputs"): + assert not (root / part / f"{gone['열']}.json").is_file() + t.do(op="add-item", 경로=["돌쌓기"], 이름="새것") # 지운 번호는 다시 안 줌 + assert _numbers(root, t.item(["돌쌓기", "새것"])["열"]) == {"00000004"} + t.do(op="delete", 경로=["돌쌓기", "b"]) # 깊은 항목이 빠지면 머리 줄 넷으로 + table = json.loads((root / "table/구조물집계표.json").read_bytes()) + assert table["층"] == ["종류", "분류1", "분류2", "분류3"] + t.do(op="delete", 경로=["돌쌓기"]) # 마지막 항목들까지 + assert t.now["items"] == [] and t.now["폴더"] == [] + assert json.loads((root / "table/구조물집계표.json").read_bytes())["열"] == [] + + +def test_판_409_권한_403(world): + admin = Tree(_as(world, "ADMIN"), "company") + stale = admin.now["판"] + admin.do(op="add-folder", 경로=[], 이름="회사폴더") + res = admin.client.post(admin.url, json={"판": stale, "op": "add-folder", "이름": "x"}) + assert res.status_code == 409 and res.json()["detail"]["판"] == admin.now["판"] + user = _as(world) # 같은 회사 일반 사용자 — 읽기 200 · 고침 403 + assert user.get("/api/m02/layers/company/tree").json()["폴더"] == [["회사폴더"]] + body = {"판": admin.now["판"], "op": "add-folder", "이름": "y"} + assert user.post("/api/m02/layers/company/tree", json=body).status_code == 403 + assert user.get("/api/m02/layers/personal/tree?owner=7/9").status_code == 403 # 남의 개인 + system = user.get("/api/m02/layers/system/tree").json() + body = {"판": system["판"], "op": "add-folder", "이름": "y"} + assert user.post("/api/m02/layers/system/tree", json=body).status_code == 403 + sysadmin = Tree(_as(world, "SYSTEM_ADMIN"), "system") + sysadmin.do(op="add-item", 경로=["돌붙임"], 이름="줄붙임") + assert ["돌붙임", "줄붙임"] in sysadmin.paths() + + +def test_표_저장과_나무가_한_줄기(world): + """집계표 표에서 머리를 고치면 경로가 따라오고, 나무 고침 뒤 표에도 같은 자료가 보임.""" + admin = _as(world, "SYSTEM_ADMIN") + t = Tree(admin, "system") + rv = next(r for r in t.now["items"] if r["열"] == "rv") + url = f"/api/m02/layers/system/templates/{TABLE}" + got = admin.get(url).json() + col = next(c for c in got["문서"]["열"] if c["id"] == "rv") + col["머리"] = ["돌기슭막이", None, "찰쌓기", None] # 빈 칸은 건너뜀 + assert admin.put(url, json=got).status_code == 200 + t = Tree(admin, "system") + assert t.item(["돌기슭막이", "찰쌓기"])["key"] == rv["key"] + t.do(op="rename", 키=rv["key"], 이름="메쌓기") + table = admin.get(url).json()["문서"] + col = next(c for c in table["열"] if c["id"] == "rv") + assert col["경로"] == ["돌기슭막이", "메쌓기"] and col["머리"] == [ + "돌기슭막이", + "메쌓기", + None, + None, + ] + # 표에서 열을 지우면 나무에서도 빠지고 파일도 같이 + table["열"] = [c for c in table["열"] if c["id"] != "rv"] + assert ( + admin.put(url, json={"판": admin.get(url).json()["판"], "문서": table}).status_code == 200 + ) + assert "rv" not in [r["열"] for r in Tree(admin, "system").now["items"]] + assert not (world["system"] / "structure" / "rv.json").is_file() From b3b4363120bf990ac2b1388d07dea3d7a12450e0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 07:57:41 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(M02):=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=EC=97=90=EC=84=9C=20=EB=A7=8C=EB=93=A0=20=EA=B0=9C?= =?UTF-8?q?=EC=9D=B8=20=EC=B8=B5=20=ED=95=AD=EB=AA=A9=EB=8F=84=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EA=B7=B8=EB=8C=80=EB=A1=9C(PLAN=2026-2a=20=C2=B7?= =?UTF-8?q?=2026-2b=20=EC=9D=B4=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_Item_Personal.create_item — 경로를 제목행 넷에 접지 않고 그대로(머리 줄 수는 집계표 저장이 맞춤) · 같은 경로면 「이름 (2)」 · 같은 앞마디 형제 곁에 끼움 - 시험 test_m02_tree 6(깊이 5 새 항목 · 같은 경로 이름 (2)) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- M02_MasterTemplete/M02_Item_Personal.py | 30 ++++++++++++++++--------- resources/tester/test_m02_tree.py | 14 ++++++++++++ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/M02_MasterTemplete/M02_Item_Personal.py b/M02_MasterTemplete/M02_Item_Personal.py index 8b5ca230..806cf7d0 100644 --- a/M02_MasterTemplete/M02_Item_Personal.py +++ b/M02_MasterTemplete/M02_Item_Personal.py @@ -3,8 +3,8 @@ 개인 층 새 항목 = 구조물집계표 새 열 하나 + 빈 도면 · 면적산출 · 재료산출(번호 · SS 키는 저장소가 줌). [개인에 저장]은 구조물 복사본의 열 정의 · 도면 · 면적 · 재료 · 자재조합 · 입력연결을 담음 — 단가 묶음 · 입력값 · 고른자재는 프로젝트 몫이라 안 가져감. 쓰기는 지금 층 저장소 함수(`store.at` · -`store.write` · `matset.create` · 항목 연결 `write_file`) — sub4 나무 길 -(`POST /layers/{layer}/tree`)이 오면 그리로. +`store.write` · `matset.create` · 항목 연결 `write_file`) — 나무 고침(`M02_MasterTemplete_Tree`)과 +같은 집계표 저장 한 줄기(경로 · 머리 줄 수 맞춤 · 번호 · SS 장부)를 탐(PLAN 26-2a). 권한(본인 개인 층 · 회사 없음 409)은 부르는 쪽 몫. """ @@ -19,24 +19,30 @@ from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store from M02_MasterTemplete import M02_MasterTemplete_Items as items_store from M02_MasterTemplete import M02_MasterTemplete_Matset as matset 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 from M02_MasterTemplete.M02_Item_Source import LINK_FILE, UM_FILE, read_json #: 복사본 파일 → 층 양식 종류 PART_KINDS = (("도면.json", "structure"), ("면적.json", "area"), ("재료.json", "material")) -LEVELS = 4 # 지금 집계표 제목행 층 수 — 26-2a 경로 모델이 오면 깊이 자유 def heads_of(path: list[str], name: str | None) -> list[str]: - """경로 + 이름 → 제목행(`머리`) — 빈 칸은 뺌 · 넷을 넘으면 넷째 칸에 이어 붙임. - - ponytail: 제목행 4층 한계 — 26-2a(sub4) 경로 모델이 오면 경로를 그대로 적음.""" + """경로 + 이름 → 항목 경로(깊이 자유 · 빈 칸은 뺌) — 머리 줄 수는 집계표 저장이 맞춤.""" full = [str(p).strip() for p in [*path, name or ""] if str(p or "").strip()] if not full: raise ValueError("새 항목의 경로 · 이름이 비어 있습니다.") - if len(full) > LEVELS: - full = [*full[: LEVELS - 1], " ".join(full[LEVELS - 1 :])] - return [*full, *[""] * (LEVELS - len(full))] + return full + + +def _free_path(doc: dict[str, Any], full: list[str]) -> list[str]: + """같은 경로 항목이 이미 있으면 마지막 이름을 「이름 (2)」 로 + (나무는 같은 경로 두 항목을 안 둠).""" + 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 def _new_matset(row: dict[str, Any], fallback: str) -> str: @@ -73,10 +79,12 @@ def create_item( new_id = items_store._fresh_id(str(base_col.get("id") or "item").split("@")[0], used) um_row = read_json(copy / UM_FILE) if copy else None um_key = _new_matset(um_row, heads[0]) if isinstance(um_row, dict) else None + heads = _free_path(doc, heads) column = { **json.loads(json.dumps(base_col)), "id": new_id, "머리": heads, + "경로": heads, "꼴": "수", "단위": unit or base_col.get("단위") or base_row.get("단위"), "일위대가": base_col.get("일위대가") or base_row.get("일위대가"), @@ -85,7 +93,7 @@ def create_item( column.pop("본뜬열", None) if origin: column["본뜬"] = origin - doc["열"] = [*doc.get("열", []), column] + tree._place(doc, column) # 같은 앞마디 형제 곁(제목행 병합이 이어지게) store.write("table", store.STRUCTURE_TABLE, table["판"], doc) # 빈 세 파일 · 번호 · SS for file, kind in PART_KINDS if copy else (): got = read_json(copy / file) @@ -103,7 +111,7 @@ def create_item( if isinstance(links, dict) and links.get("줄"): inputs_store.write_file(new_id, {"판": 1, "줄": links["줄"]}) ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id) - return {"key": ss["키"], "열": new_id, "이름": " ".join(h for h in heads if h)} + return {"key": ss["키"], "열": new_id, "이름": tree.name_of(heads)} def personal_item(company: int, user: int, made: dict[str, Any]) -> dict[str, Any]: diff --git a/resources/tester/test_m02_tree.py b/resources/tester/test_m02_tree.py index 6c767208..02ed8bb9 100644 --- a/resources/tester/test_m02_tree.py +++ b/resources/tester/test_m02_tree.py @@ -245,3 +245,17 @@ def test_표_저장과_나무가_한_줄기(world): ) assert "rv" not in [r["열"] for r in Tree(admin, "system").now["items"]] assert not (world["system"] / "structure" / "rv.json").is_file() + + +def test_프로젝트에서_새_항목도_경로_그대로(world): + """개인에 저장 · 프로젝트에서 새 항목(`M02_Item_Personal`)도 경로를 넷에 접지 않고 그대로 · + 같은 경로면 「이름 (2)」.""" + from M02_MasterTemplete import M02_Item_Personal as personal + + folder = layers.personal_dir(7, 42) + made = personal.create_item(folder, ["가", "나", "다", "라"], "마", "m") + 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 made["이름"] == "가 나 다 라 마"