"""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 collections.abc import Iterator from contextlib import contextmanager from contextvars import ContextVar 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_MasterTemplete_Tree as tree from M02_MasterTemplete import M02_Template_Layers as layers FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_template" KINDS = layers.KINDS # 시험은 FOLDER 를 사본으로 바꿈 # 지금 다루는 층 폴더 — 층 라우터가 `at(층 폴더)` 로 바꿈 · 안 바꾸면 시스템 층(`FOLDER`). # 회사 · 개인 층도 시스템과 같은 모양(`table/구조물집계표.json` · 조합 장부 · # structure/area/material · 자재조합.json — PLAN 21-1)이라 아래 함수는 그대로 씀 _ROOT: ContextVar[Path | None] = ContextVar("m02_layer_root", default=None) def root() -> Path: return _ROOT.get() or FOLDER @contextmanager def at(folder: str | Path) -> Iterator[Path]: """이 안에서 부르는 저장소 함수는 `folder` 층을 봄(요청 하나 · 스레드 하나 안에서만).""" token = _ROOT.set(Path(folder)) try: yield Path(folder) finally: _ROOT.reset(token) # 구조물집계표 구조물 열(꼴=수) 마다 세 파일(파일 이름 = 열 id) — # 구조물 도면 `structure/` (도각 없는 전용 화면) · 면적산출 `area/` · 재료산출 `material/` # (둘 다 스프레드시트 통합문서) # 번호 = 도번 SD · 면적번 SA · 재료번 SM + 뒷자리 — 한 열의 세 번호는 같은 뒷자리 · 한 번 준 번호는 # 파일에 남아 안 바뀜(PLAN 13장). 뒷자리 두 모양: # 옛 8자리 `SD01020300` = [종류][분류1][분류2][분류3] 두 자리씩(그대로 유효 · 더는 새로 안 줌) # 새 일련 `SD-000001` = 붙임표 + 6자리 · 자리 뜻 없음(PLAN 26-1 · 26-4a) — 옛 8자리 · # 12장 옛 6자리(`SD010101`)와 글자 모양이 달라 겹치지 않음 · 층마다 따로 셈 # (파일은 열 id 로 가름 · 번호는 보이는 이름) # 항목 경로(폴더 이름 배열) = 열 `머리` 의 빈 칸 건너뛴 이름(`M02_MasterTemplete_Tree`) STRUCTURE_TABLE = "구조물집계표" # 프로그램이 이름으로 부르는 양식 — 지우면 그 기능이 죽음(B07 이름은 `B07_DesignDetail_Engine_*`) IN_USE = { ("table", STRUCTURE_TABLE): "구조물 도면 · 면적산출 · 재료산출 · 조합 장부가 씀", ("drawing", "00_template_A1"): "B07 도면 도각", ("drawing", "00_template_compass"): "B07 방위표", ("drawing", "00_template_cover"): "B07 표지", } # (종류, 문서 번호 칸, 머리 글자, 시트 이름) — 시트 이름이 None 이면 구조물 도면 PARTS = ( ("structure", "도번", "SD", None), ("area", "면적번", "SA", "면적산출"), ("material", "재료번", "SM", "재료산출"), ) _KEY = {kind: key for kind, key, _, _ in PARTS} SHEETS = ("area", "material") # 통합문서 종류 — [저장] 때 서버가 계산값을 새로 적음 _CODE = re.compile(r"^S[DAM](\d{8}|-\d{6})$") _OLD_CODE = re.compile(r"^S[DCA](\d{6})$") # PLAN 12장 6자리 — 끝에 00 을 붙여 읽음 _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((".", "_")) # `_` 도 막음 — 새 프로젝트 복사(layers.check_name)가 거절함 or len(name) > 80 # 파일 이름이 길면 OS 가 죽음(500) — 화면 규칙과 같은 80자 or _BAD_NAME.search(name) ): raise StoreError(400, f"쓸 수 없는 글자가 있는 이름 「{name}」") return root() / kind / f"{name}.json" # 항목 연결(PLAN 25-1b) — 열 id 마다 `inputs/<열id>.json` · 종류 폴더(KINDS) 밖 · 읽기 · 쓰기는 # `M02_MasterTemplete_Inputs` 몫 · 열 지우기 · 분류 더하기 복사는 여기서 같이 INPUTS = "inputs" def inputs_path(column: str) -> Path: """그 열의 항목 연결 파일 자리 — 이름 규칙은 종류 파일과 같음.""" return _path("structure", column).parent.parent / INPUTS / f"{column}.json" def _info(kind: str, path: Path) -> dict[str, Any]: raw = path.read_bytes() stamp = datetime.fromtimestamp(path.stat().st_mtime).isoformat(timespec="seconds") row = {"종류": kind, "이름": path.stem, "판": version_of(raw), "수정일": stamp} if (kind, path.stem) in IN_USE: row["쓰는곳"] = IN_USE[(kind, path.stem)] # 화면이 지우기 확인 창 전에 막음 return row def list_all() -> list[dict[str, Any]]: rows = [ _info(kind, p) for kind in KINDS for p in sorted((root() / 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) · 아니면 그 판일 때만 덮어씀. 구조물집계표를 저장하면 — 새 구조물 열의 빈 구조물 도면 · 면적산출 · 재료산출을 번호와 같이 만들고 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 지움(잠금 밖에서 · 안에서 부르면 죽음). 통합문서(area · material)는 서버가 같은 TS 엔진을 Node 로 돌려 `계산값` 을 새로 적음 (브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘. 번호 칸(도번 · 면적번 · 재료번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음). 표 열의 `본뜬열`(분류 더하기로 복사한 새 열)은 적지 않음 — 집계표면 아직 파일이 없는 새 열이 원본 열의 일위대가 · 자재조합 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로).""" 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: have = version_of(path.read_bytes()) if path.is_file() else "" if have != (version or ""): raise StoreError(409, {"stale": [name], "판": have}) if have and kind in _KEY: number = json.loads(path.read_bytes()).get(_KEY[kind]) doc = {**doc, _KEY[kind]: number} if number else doc atomic_write_json(path, doc) info = _info(kind, path) if kind == "table" and name == STRUCTURE_TABLE: for column, source in origins.items(): # 빈 파일 채우기 앞 — 원본 내용으로 만듦 for part in _KEY: _ensure(part, column, source) _copy_inputs(source, column) backfill_missing_structures() # 지우기 앞 — 같은 저장의 새 열이 지운 번호를 안 받게 prune_removed_structures() sync_combos() # 번호가 다 선 뒤 — 조합 표 도번 · 면적번 · 재료번 칸 return info def delete(kind: str, name: str, version: str | None = None) -> None: path = _path(kind, name) if (kind, name) in IN_USE: raise StoreError(409, f"쓰는 양식이라 못 지움 「{name}」 — {IN_USE[(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 _docs(kind: str) -> list[tuple[str, Any]]: folder = root() / kind return [(p.stem, json.loads(p.read_bytes())) for p in sorted(folder.glob("[!.]*.json"))] def _code(doc: Any, key: str) -> str | None: """문서 번호의 뒷자리 — 옛 8자리 · 새 `-` + 6자리 · 12장 6자리는 끝에 00 · 옛 `구-01` · `표-01` · 없음은 None.""" text = str(doc.get(key) or "") if isinstance(doc, dict) else "" if found := _CODE.match(text): return found.group(1) found = _OLD_CODE.match(text) return found.group(1) + "00" if found else None def _codes() -> dict[str, str]: """`{열 id: 뒷자리}` — 구조물 도면 도번 먼저 · 없으면 면적번 · 재료번.""" codes: dict[str, str] = {} for kind, key, _, _ in reversed(PARTS): codes.update({name: c for name, doc in _docs(kind) if (c := _code(doc, key))}) return codes def structure_numbers() -> dict[str, dict[str, str]]: """`{열 id: {도번, 면적번, 재료번}}` — 집계표 줄 · 좌측 줄이 한 번에 받음(없는 쪽은 빈 글).""" out: dict[str, dict[str, str]] = {} 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 "") return out def _heads(col: dict[str, Any]) -> tuple[str | None, ...]: """옛 네 칸(종류 · 분류1 · 분류2 · 분류3) = 경로 앞 넷 — 없는 칸은 None(다른 코드가 읽음).""" return tuple(tree.old_fields(tree.path_of(col)).values()) 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()] _SERIAL = re.compile(r"^-(\d{6})$") def _serial(taken: dict[str, str], retired: list[str] | None = None) -> str: """새 항목 번호 뒷자리 — 붙임표 + 일련 6자리(새 모양 번호 · 지운 번호 가운데 가장 큰 수 + 1) · 옛 8자리는 셈에 안 넣음(모양이 달라 겹칠 일 없음).""" found = (_SERIAL.match(str(c)) for c in [*taken.values(), *(retired or [])]) return f"-{max((int(m.group(1)) for m in found if m), default=0) + 1:06d}" def _new_code(column: str) -> str: """이 열이 쓸 번호 뒷자리 — 짝 파일에 이미 있으면 그것 · 없으면 새로(잠금 안에서 부름).""" taken = _codes() return taken[column] if column in taken else _serial(taken, _retired()) def _retired() -> list[str]: """지운 열의 번호 뒷자리 — 조합 장부 `지운번호`(한 번 준 번호는 다시 안 줌).""" path = root() / COMBO_FILE return json.loads(path.read_bytes()).get("지운번호", []) if path.is_file() else [] def _blank(kind: str, column: str) -> dict[str, Any]: """빈 문서(번호는 부르는 쪽이 붙임) — 구조물 도면 = 도각 없음 · 기본 도면층 하나 / 면적산출 · 재료산출 = 진짜 빈 시트 하나(식 없어 `계산값` 없음 · 첫 [저장] 때 서버가 적음).""" if kind == "structure": return { "양식": "구조물도면", "종류": "structure", "판": 1, "열": column, "도면": {"format": 6, "entities": [], "layers": [dict(_BLANK_LAYER)]}, } sheet = next(name for k, _, _, name in PARTS if k == kind) return { "종류": "통합문서", "판": 1, "열": column, "서식": [{}], "시트": [{"id": "s1", "이름": sheet, "칸": {}}], "활성": "s1", } def _ensure(kind: str, column: str, source: str | None = None) -> bool: """그 열의 파일이 없으면 빈 문서를 번호(세 파일이 같은 뒷자리)와 같이 만듦 · 있으면 그대로. `source` 열 파일이 있으면 빈 문서 대신 그 내용(알약 `연결` 포함 · 계산값은 서버가 다시 풂).""" path = _path(kind, column) if path.is_file(): return False head = next(h for k, _, h, _ in PARTS if k == kind) base = _blank(kind, column) if source and _path(kind, source).is_file(): got = json.loads(_path(kind, source).read_bytes()) base = {**{k: v for k, v in got.items() if k != "계산값"}, "열": column} if kind in SHEETS: base = _server_values(base) with _LOCK: if path.is_file(): return False atomic_write_json(path, {**base, _KEY[kind]: head + _new_code(column)}) return True def _take_origins(doc: Any) -> dict[str, str]: """표 열의 `본뜬열` 을 떼어 냄 → `{새 열: 원본 열}` (구조물 파일이 아직 없는 열만) · 그 열에 일위대가 · 자재조합 키가 비었으면 원본 열 것을 받음.""" cols = ( [c for c in (doc.get("열") or []) if isinstance(c, dict)] if isinstance(doc, dict) else [] ) by_id = {c.get("id"): c for c in cols} out: dict[str, str] = {} for col in cols: source = col.pop("본뜬열", None) if not source or source not in by_id or not col.get("id"): continue if (root() / "structure" / f"{col['id']}.json").is_file(): continue out[col["id"]] = source for field in ("일위대가", "자재조합"): col[field] = col.get(field) or by_id[source].get(field) return out def create_structure(column: str) -> dict[str, Any]: """구조물 열 하나의 구조물 도면 새로 — 도번 SD + 새 일련(`-` + 6자리) · 이미 있으면 409. 면적산출 · 재료산출 파일이 없으면 빈 통합문서도 같이 만듦(같은 뒷자리).""" if column not in _column_ids(): raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 구조물 열 「{column}」") if not _ensure("structure", column): raise StoreError(409, f"이미 있는 구조물 도면 「{column}」") for kind in SHEETS: _ensure(kind, column) return read("structure", column) def copy_from(kind: str, column: str, source: str) -> dict[str, Any]: """다른 열(`source`) 내용을 받는 열(`column`)로 가져옴 — 번호 · 열은 받는 쪽 그대로(두 열은 따로 움직임) · 통합문서는 알약 `연결` 도 같이 오고 계산값은 서버가 다시 풂.""" if kind not in _KEY: raise StoreError(404, f"없는 종류 「{kind}」") target = read(kind, column)["문서"] doc = {**read(kind, source)["문서"], **{k: target.get(k) for k in ("열", _KEY[kind])}} if kind in SHEETS: 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 _copy_inputs(source: str, column: str) -> None: """분류 더하기로 복사한 새 열 — 원본 열의 항목 연결을 판 1 로(이미 있으면 그대로).""" have, got = inputs_path(column), inputs_path(source) if have.is_file() or not got.is_file(): return have.parent.mkdir(parents=True, exist_ok=True) atomic_write_json(have, {"판": 1, "줄": json.loads(got.read_bytes()).get("줄") or []}) def prune_removed_structures() -> list[str]: """집계표에서 지운 열(또는 구조물이 아니게 된 열)의 세 파일 · 항목 연결을 같이 지움 — 번호는 비워 둠(다른 열 번호를 안 당김).""" keep = set(_column_ids()) gone: list[str] = [] codes: set[str] = set() with _LOCK: for path in sorted((root() / INPUTS).glob("[!.]*.json")): if path.stem not in keep: path.unlink() gone.append(f"{INPUTS}/{path.stem}") for kind, key, _, _ in PARTS: for path in sorted((root() / kind).glob("[!.]*.json")): if path.stem not in keep: code = _code(json.loads(path.read_bytes()), key) codes.update([code] if code else []) path.unlink() gone.append(f"{kind}/{path.stem}") if codes: # 지운 번호는 장부에 남겨 새 열이 다시 안 받게 book = root() / COMBO_FILE doc = json.loads(book.read_bytes()) if book.is_file() else {} doc["지운번호"] = sorted(codes | set(doc.get("지운번호", []))) atomic_write_json(book, doc) return gone def _move_basis() -> list[str]: """12장 산출근거 `basis/` → 면적산출 `area/` (내용 · 알약 연결 그대로 · 표번 칸 → 면적번 · 시트 이름 산출근거 → 면적산출) · 옮긴 뒤 빈 `basis/` 폴더를 지움.""" old = root() / "basis" if not old.is_dir(): return [] moved: list[str] = [] with _LOCK: for path in sorted(old.glob("[!.]*.json")): doc = json.loads(path.read_bytes()) doc["면적번"] = doc.pop("표번", None) # 번호는 renumber 가 SA 8자리로 for sheet in doc.get("시트") or []: if isinstance(sheet, dict) and sheet.get("이름") == "산출근거": sheet["이름"] = "면적산출" target = _path("area", path.stem) if not target.is_file(): atomic_write_json(target, doc) path.unlink() moved.append(path.stem) if not any(old.iterdir()): old.rmdir() return moved def renumber() -> dict[str, list[str]]: """한 번 — 옛 산출근거를 면적산출로 옮김 · 옛 번호(6자리 · 구-NN · 표-NN · 없음)를 8자리로 (6자리는 끝에 00 · 번호 없는 열은 열 차례로 일련번호) · 구조물 아닌 열 파일 지움 · 빠진 파일(재료산출 등) 만듦. 내용은 그대로 · 두 번 돌려도 같음.""" moved = _move_basis() gone = prune_removed_structures() changed: list[str] = [] made: list[str] = [] with _LOCK: cols = _structure_columns() taken = _codes() retired = _retired() for col in cols: if col["id"] not in taken: taken[col["id"]] = _serial(taken, retired) docs = {kind: dict(_docs(kind)) for kind in _KEY} for col in cols: cid = col["id"] for kind, key, head, _ in PARTS: number = f"{head}{taken[cid]}" doc = docs[kind].get(cid) if doc is None: atomic_write_json(_path(kind, cid), {**_blank(kind, cid), key: number}) made.append(f"{kind}/{cid}") elif doc.get(key) != number: doc[key] = number atomic_write_json(_path(kind, cid), doc) changed.append(f"{kind}/{cid}") return {"옮김": moved, "지움": 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 backfill_missing_structures() -> list[str]: """구조물 열마다 세 파일이 있게 — 없는 것만 열 차례로 새로 만듦(다음 일련번호). 그 사이 남이 만들었으면 그대로 둠.""" made: list[str] = [] for cid in _column_ids(): made += [f"{kind}/{cid}" for kind in _KEY if _ensure(kind, cid)] return made # ── 구조물집계표조합(SS 장부) — `M02_MasterTemplete_Book` 몫 ───────────────────── COMBO_FILE = layers.COMBO_FILE def sync_combos() -> dict[str, Any]: """조합 장부를 집계표에 맞춤(`Book.sync`) — 부를 때 읽음(장부 파일이 이 파일을 부름).""" from M02_MasterTemplete import M02_MasterTemplete_Book as book return book.sync() def find_combo(key: str) -> dict[str, Any] | None: """키(또는 옮기기 전 옛 키)로 장부 줄 — 없으면 None(`Book.find`).""" from M02_MasterTemplete import M02_MasterTemplete_Book as book return book.find(book.read().get("줄", []), key) def read_combos() -> dict[str, Any]: """조합 장부 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦(`Book.read`).""" from M02_MasterTemplete import M02_MasterTemplete_Book as book return book.read()