"""M02 마스터 템플릿 — 시스템 층 저장소 (`resources/master_template/{종류}/<이름>.json`). M01 `Store` 방식 — 판(파일 sha256 앞 16자) · 판이 다르면 409 · 원자 쓰기. 권한은 등록하는 쪽(`main.py`)이 붙임. """ from __future__ import annotations import hashlib import json import re import threading from datetime import datetime from pathlib import Path 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_Template_Layers as layers FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_template" KINDS = layers.KINDS # 시험은 FOLDER 를 사본으로 바꿈 # 구조물 도면 = 구조물집계표 구조물 열(꼴=수) 마다 하나(파일 이름 = 열 id) · 도각 없는 전용 화면 # 산출근거 = 같은 열 id 로 따로 한 파일(`basis/<열 id>.json` · 스프레드시트 통합문서) # 번호 = 도번 SD · 표번 SC + 6자리 [종류][분류1][분류2](제목행 = 열 `머리` 세 층 · 없는 층 00) — # 한 열의 두 번호는 같은 6자리 · 한 번 준 번호는 파일에 남아 안 바뀜(PLAN 12장) STRUCTURE_TABLE = "구조물집계표" _BASIS_HEAD = ("공종", "규격", "산출 내역", "단위", "수량(m당)") _BASIS_HEAD_STYLE = { "굵게": True, "가로": "center", "세로": "center", "테두리": {side: {"선": "thin"} for side in ("위", "아래", "왼", "오른")}, } _CODE = re.compile(r"^S[DC](\d{6})$") _BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.') # 도각 없는 빈 CAD 문서의 기본 도면층 — 없으면 B07 CAD 가 못 엶("Cannot read properties # of undefined (reading 'length')") · CAD 쪽도 없으면 새로 채우지만(2026-09-27 sub1 고침) # 뿌리도 처음부터 채움 _BLANK_LAYER = {"id": "기본", "name": "기본", "isVisible": True, "isLocked": False} # ponytail: 저장은 한 번에 하나(프로세스 안 잠금) · 서버를 여럿 띄우면 파일 잠금으로 _LOCK = threading.Lock() class StoreError(Exception): def __init__(self, status: int, detail): super().__init__(detail) self.status, self.detail = status, detail def version_of(raw: bytes) -> str: return hashlib.sha256(raw).hexdigest()[:16] def _path(kind: str, name: str) -> Path: if kind not in KINDS: raise StoreError(404, f"없는 종류 「{kind}」") if not name or name != name.strip() or name.startswith(".") or _BAD_NAME.search(name): raise StoreError(400, f"쓸 수 없는 글자가 있는 이름 「{name}」") return FOLDER / kind / f"{name}.json" def _info(kind: str, path: Path) -> dict[str, Any]: raw = path.read_bytes() stamp = datetime.fromtimestamp(path.stat().st_mtime).isoformat(timespec="seconds") return {"종류": kind, "이름": path.stem, "판": version_of(raw), "수정일": stamp} def list_all() -> list[dict[str, Any]]: rows = [ _info(kind, p) for kind in KINDS for p in sorted((FOLDER / kind).glob("*.json")) if not p.name.startswith(".") ] return rows def read(kind: str, name: str) -> dict[str, Any]: path = _path(kind, name) if not path.is_file(): raise StoreError(404, f"없는 양식 「{name}」") raw = path.read_bytes() return {"종류": kind, "이름": name, "판": version_of(raw), "문서": json.loads(raw)} def _check_skeleton(kind: str, doc: Any) -> None: """빈 문서·뼈대 없는 문서는 거절 — 층 저장과 같은 규칙(`layers.check_skeleton`).""" try: layers.check_skeleton(kind, doc) except ValueError as e: raise StoreError(400, str(e)) from e def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]: """`version` 이 빈 글이면 새로 만듦(이미 있으면 409) · 아니면 그 판일 때만 덮어씀. 구조물집계표를 저장하면 — 새 구조물 열의 빈 구조물 도면 · 산출근거를 번호와 같이 만들고 지운 열(또는 구조물이 아니게 된 열)의 두 파일을 지움(잠금 밖에서 · 안에서 부르면 죽음). 산출근거(basis)는 서버가 같은 TS 엔진을 Node 로 돌려 `계산값` 을 새로 적음 (브라우저 값 버림 · 5장 ②).""" path = _path(kind, name) _check_skeleton(kind, doc) if kind == "basis": doc = _server_values(doc) with _LOCK: have = version_of(path.read_bytes()) if path.is_file() else "" if have != (version or ""): raise StoreError(409, {"stale": [name], "판": have}) atomic_write_json(path, doc) info = _info(kind, path) if kind == "table" and name == STRUCTURE_TABLE: backfill_missing_structures() # 지우기 앞 — 같은 저장의 새 열이 지운 번호를 안 받게 prune_removed_structures() return info def delete(kind: str, name: str, version: str | None = None) -> None: path = _path(kind, name) with _LOCK: if not path.is_file(): raise StoreError(404, f"없는 양식 「{name}」") if version is not None and version_of(path.read_bytes()) != version: raise StoreError(409, {"stale": [name], "판": version_of(path.read_bytes())}) path.unlink() # ── 구조물 도면 ─────────────────────────────────────── def _structures() -> list[tuple[str, Any]]: folder = FOLDER / "structure" return [(p.stem, json.loads(p.read_bytes())) for p in sorted(folder.glob("[!.]*.json"))] def _code(doc: Any, key: str) -> str | None: """문서의 새 번호 6자리 — 옛 `구-01` · `표-01` · 없음은 None.""" found = _CODE.match(str(doc.get(key) or "")) if isinstance(doc, dict) else None return found.group(1) if found else None def _codes() -> dict[str, str]: """`{열 id: 6자리}` — 구조물 도면 도번 먼저 · 없으면 산출근거 표번.""" codes = {name: c for name, doc in _basis_docs() if (c := _code(doc, "표번"))} codes.update({name: c for name, doc in _structures() if (c := _code(doc, "도번"))}) return codes def structure_numbers() -> dict[str, dict[str, str]]: """`{열 id: {도번, 표번}}` — 집계표 도면 · 산출근거 줄 · 좌측 줄이 한 번에 받음 (없는 쪽은 빈 글).""" out: dict[str, dict[str, str]] = {} for name, doc in _structures(): out.setdefault(name, {"도번": "", "표번": ""})["도번"] = str(doc.get("도번") or "") for name, doc in _basis_docs(): out.setdefault(name, {"도번": "", "표번": ""})["표번"] = str(doc.get("표번") or "") return out def _heads(col: dict[str, Any]) -> tuple[str | None, str | None, str | None]: """제목행 세 층(종류 · 분류1 · 분류2) — 빈 층은 None.""" parts = [*(col.get("머리") or []), None, None, None][:3] clean = [str(p).strip() if p is not None else "" for p in parts] return (clean[0] or None, clean[1] or None, clean[2] or None) def _structure_columns() -> list[dict[str, Any]]: """번호 · 파일을 받는 열 — 꼴=수(NO · 측점 · 비고 같은 글 열은 구조물 아님).""" table = read("table", STRUCTURE_TABLE)["문서"] return [ col for col in table.get("열", []) if isinstance(col, dict) and col.get("id") and col.get("꼴") == "수" ] 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]) -> str: """새 열 번호 — 같은 종류(· 분류1)가 이미 번호를 가졌으면 그 두 자리를 이어 받고 · 없으면 그 층 가장 큰 번호 + 1 · 빈 층은 00. 같은 6자리가 이미 있으면(같은 제목행 · 빈 분류2 겹침) 분류2 를 그 무리 가장 큰 번호 + 1 로. `taken` = 이미 번호 있는 열 `{열 id: 6자리}` · `heads` = 열 id → 제목행.""" kind, first, second = _heads(col) codes = list(taken.values()) mates = [(heads[cid], code) for cid, code in taken.items() if cid in heads] def next_of(prefix: str, at: slice) -> str: found = [int(c[at]) for c in codes if c.startswith(prefix)] return f"{max(found, default=0) + 1:02d}" k = next((c[:2] for h, c in mates if h[0] == kind), None) or next_of("", slice(0, 2)) if first is None: a = "00" else: same = (c[2:4] for h, c in mates if h[:2] == (kind, first) and c[:2] == k) a = next(same, None) or next_of(k, slice(2, 4)) b = "00" if second is None else next_of(k + a, slice(4, 6)) if k + a + b in codes: b = next_of(k + a, slice(4, 6)) return k + a + b def _new_code(column: str) -> str: """이 열이 쓸 6자리 — 짝 파일에 이미 있으면 그것 · 없으면 새로(잠금 안에서 부름).""" 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}) def create_structure(column: str) -> dict[str, Any]: """구조물 열 하나의 구조물 도면 새로 — 도번 SD + 6자리(산출근거와 같은 6자리) · 이미 있으면 409. 산출근거 파일이 없으면 빈 통합문서도 같이 만듦.""" path = _path("structure", column) if column not in _column_ids(): raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 구조물 열 「{column}」") with _LOCK: if path.is_file(): raise StoreError(409, f"이미 있는 구조물 도면 「{column}」") atomic_write_json(path, {**_blank_structure(column), "도번": f"SD{_new_code(column)}"}) ensure_basis(column) return read("structure", column) def _blank_structure(column: str) -> dict[str, Any]: """빈 구조물 도면(도번은 부르는 쪽이 붙임) — 도각 없음 · 기본 도면층 하나.""" return { "양식": "구조물도면", "종류": "structure", "판": 1, "열": column, "도면": {"format": 6, "entities": [], "layers": [dict(_BLANK_LAYER)]}, } def copy_structure(column: str, source: str) -> dict[str, Any]: """다른 열 구조물 도면 내용을 가져옴 — 도번 · 열은 받는 쪽 그대로(두 열은 따로 움직임).""" return _copy("structure", column, source, ("열", "도번")) def copy_basis(column: str, source: str) -> dict[str, Any]: """다른 열 산출근거 내용을 가져옴 — 표번 · 열은 받는 쪽 그대로 · 계산값은 서버가 다시 풂.""" return _copy("basis", column, source, ("열", "표번")) def _copy(kind: str, column: str, source: str, keep: tuple[str, ...]) -> dict[str, Any]: target = read(kind, column)["문서"] doc = {**read(kind, source)["문서"], **{key: target.get(key) for key in keep}} if kind == "basis": doc = _server_values({k: v for k, v in doc.items() if k != "계산값"}) with _LOCK: atomic_write_json(_path(kind, column), doc) return _info(kind, _path(kind, column)) # ── 산출근거 ────────────────────────────────────────── def _col_letters(index: int) -> str: letters = "" index += 1 while index: index, rest = divmod(index - 1, 26) letters = chr(65 + rest) + letters return letters def basis_doc(column: str, old: Any = None) -> dict[str, Any]: """산출근거 통합문서 — 새로는 진짜 빈 시트(머리 줄 없음). `old` = 옛 구조물 문서 `산출근거` 표(열 · 줄) — 머리 글 · 칸 값 · 열 폭을 옮김 (옛 열 식은 없어 안 옮김).""" columns = old.get("열") if isinstance(old, dict) and isinstance(old.get("열"), list) else None if columns: heads = [ " ".join(str(h) for h in (c.get("머리") or []) if h) or c.get("id", "") for c in columns ] else: heads, columns = [], [] cells: dict[str, Any] = { f"{_col_letters(i)}1": {"값": head, "서식": 1} for i, head in enumerate(heads) if head } for r, row in enumerate(old.get("줄", []) if columns else [], start=2): values = row.get("값", {}) if isinstance(row, dict) else {} for i, col in enumerate(columns): value = values.get(col.get("id")) if value not in (None, ""): cells[f"{_col_letters(i)}{r}"] = {"값": value} widths = ((old or {}).get("보기") or {}).get("열너비") or {} if columns else {} # 옛 폭은 px · 새 폭은 엑셀 글자 단위(숫자 폭 7px 어림) info = { _col_letters(i): {"폭": round(widths[c["id"]] / 7, 1)} for i, c in enumerate(columns) if isinstance(widths.get(c.get("id")), (int, float)) } sheet: dict[str, Any] = {"id": "s1", "이름": "산출근거", "칸": cells} if info: sheet["열"] = info return { "종류": "통합문서", "판": 1, "열": column, "서식": [{}, _BASIS_HEAD_STYLE] if heads else [{}], "시트": [sheet], "활성": "s1", } def _basis_docs() -> list[tuple[str, Any]]: folder = FOLDER / "basis" return [(p.stem, json.loads(p.read_bytes())) for p in sorted(folder.glob("[!.]*.json"))] def ensure_basis(column: str, old: Any = None) -> bool: """산출근거 파일이 없으면 만듦(식 없는 새 문서라 `계산값` 없음 · 첫 [저장] 때 서버가 적음). 표번 = SC + 구조물 도면과 같은 6자리 · 만든 뒤로는 안 바뀜.""" path = _path("basis", column) with _LOCK: if path.is_file(): return False doc = {**basis_doc(column, old), "표번": f"SC{_new_code(column)}"} atomic_write_json(path, doc) return True def _strip_default_head(doc: Any) -> bool: """옛 빈 산출근거의 1 행 기본 머리(공종 · 규격 · 산출 내역 · 단위 · 수량(m당))를 지움 — 다섯 칸이 모두 그 글일 때만(사용자가 고친 머리는 둠) · 머리 서식만 쓰였으면 서식 표도 비움.""" sheets = doc.get("시트") if isinstance(doc, dict) else None cells = sheets[0].get("칸") if isinstance(sheets, list) and sheets else None keys = [f"{_col_letters(i)}1" for i in range(len(_BASIS_HEAD))] if not isinstance(cells, dict) or [(cells.get(k) or {}).get("값") for k in keys] != list( _BASIS_HEAD ): return False for k in keys: del cells[k] if not any(isinstance(c, dict) and c.get("서식") for s in sheets for c in s["칸"].values()): doc["서식"] = [{}] return True def prune_removed_structures() -> list[str]: """집계표에서 지운 열(또는 구조물이 아니게 된 열)의 구조물 도면 · 산출근거를 같이 지움 — 번호는 비워 둠(다른 열 번호를 안 당김).""" keep = set(_column_ids()) gone: list[str] = [] with _LOCK: for kind in ("structure", "basis"): for path in sorted((FOLDER / kind).glob("[!.]*.json")): if path.stem not in keep: path.unlink() gone.append(f"{kind}/{path.stem}") return gone def renumber() -> dict[str, list[str]]: """한 번 — 옛 번호(구-NN · 표-NN · 없음)를 새 번호로(열 차례 = 종류 · 분류1 · 분류2 차례) · 옛 빈 산출근거 기본 머리 줄 지움 · 구조물 아닌 열 파일 지움 · 빠진 파일 만듦. 내용은 그대로 · 새 번호가 이미 있으면 그대로(두 번 돌려도 같음).""" gone = prune_removed_structures() changed: list[str] = [] made: list[str] = [] with _LOCK: cols = _structure_columns() heads = {c["id"]: _heads(c) for c in cols} taken = _codes() for col in cols: if col["id"] not in taken: taken[col["id"]] = _pick_code(col, taken, heads) docs = {"structure": dict(_structures()), "basis": dict(_basis_docs())} for col in cols: cid = col["id"] for kind, key, head in (("structure", "도번", "SD"), ("basis", "표번", "SC")): number = f"{head}{taken[cid]}" doc = docs[kind].get(cid) if doc is None: blank = _blank_structure(cid) if kind == "structure" else basis_doc(cid) atomic_write_json(_path(kind, cid), {**blank, key: number}) made.append(f"{kind}/{cid}") continue stripped = kind == "basis" and _strip_default_head(doc) if doc.get(key) != number or stripped: doc[key] = number atomic_write_json(_path(kind, cid), doc) changed.append(f"{kind}/{cid}") return {"지움": gone, "바꿈": changed, "새로": made} def _server_values(doc: Any) -> dict[str, Any]: try: return spreadsheet.with_server_values(doc) except spreadsheet.RecalcError as e: raise StoreError(503, str(e)) from e def migrate_basis() -> dict[str, list[str]]: """한 번 — 옛 구조물 문서 안 `산출근거` 표를 `basis/<열 id>.json` 으로 옮기고 구조물 문서에서 뺌 · 그 뒤 산출근거가 없는 집계표 열은 빈 통합문서. 두 번 돌려도 같음(있는 파일은 안 덮음).""" moved: list[str] = [] for name, doc in _structures(): if not isinstance(doc, dict) or "산출근거" not in doc: continue ensure_basis(name, doc["산출근거"]) rest = {key: value for key, value in doc.items() if key != "산출근거"} with _LOCK: atomic_write_json(_path("structure", name), rest) moved.append(name) made = [column for column in _column_ids() if ensure_basis(column)] return {"옮김": moved, "새로": made} def backfill_missing_structures() -> list[str]: """구조물 열마다 빈 구조물 도면 · 산출근거가 있게 — 없는 열만 열 차례로 새로 만듦(그 무리 다음 번호). 그 사이 남이 만들었거나(409) 열 규칙에 안 맞으면(404) 그 열만 건너뜀.""" have = {name for name, _ in _structures()} made: list[str] = [] for cid in _column_ids(): if cid in have: ensure_basis(cid) continue try: create_structure(cid) made.append(cid) except StoreError: pass return made