feat(M02): 층 인식 집계표 저장소 — 회사 · 개인 층 · 항목 목록 · 항목 복사 (PLAN 21-2)

- Store · Matset · Branch 가 지금 층 폴더(store.at · 기본 시스템 층)를 봄 — 시스템 층 동작 · 옛 길 그대로
- 회사 · 개인 층 첫 진입 때 빈 뼈대(집계표 열 0 · 조합 장부 다음 1 · 자재 조합 빈 표)
- /api/m02/layers/{layer}/… 집계표 길(번호 · 조합 장부 · 구조물 새로 · 복사 · 조합 갈라 내기 · 자재 조합) · 권한 = 시스템 SYSTEM_ADMIN · 회사 관리자 · 개인 본인
- 층 양식 저장(시스템 · 회사 · 개인)은 집계표 규칙(새 열 파일 · 번호 · SS) 거침 · 시스템 층 쓰기는 SYSTEM_ADMIN · 쓰는 양식 지우기 409
- GET /api/m02/items(한 층 · ?all=1 세 층) · POST /api/m02/layers/personal/items/copy · 회사 없음 409 「회사부터 등록」
- 시험 test_m02_layers_store

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
This commit is contained in:
2026-09-29 12:01:08 +09:00
co-authored by Claude Opus 5.5
parent 1758ba0fb2
commit b8195aff2e
8 changed files with 645 additions and 25 deletions
+34 -11
View File
@@ -1,4 +1,4 @@
"""M02 마스터 템플릿 — 시스템 층 저장소 (`resources/master_template/{종류}/<이름>.json`).
"""M02 마스터 템플릿 — 층 저장소 · 기본 시스템 층(`resources/master_template/{종류}/<이름>.json`).
M01 `Store` 방식 — 판(파일 sha256 앞 16자) · 판이 다르면 409 · 원자 쓰기.
권한은 등록하는 쪽(`main.py`)이 붙임.
@@ -10,6 +10,9 @@ 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
@@ -20,6 +23,26 @@ 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/`
# (둘 다 스프레드시트 통합문서)
@@ -75,7 +98,7 @@ def _path(kind: str, name: str) -> Path:
or _BAD_NAME.search(name)
):
raise StoreError(400, f"쓸 수 없는 글자가 있는 이름 「{name}」")
return FOLDER / kind / f"{name}.json"
return root() / kind / f"{name}.json"
def _info(kind: str, path: Path) -> dict[str, Any]:
@@ -91,7 +114,7 @@ def list_all() -> list[dict[str, Any]]:
rows = [
_info(kind, p)
for kind in KINDS
for p in sorted((FOLDER / kind).glob("*.json"))
for p in sorted((root() / kind).glob("*.json"))
if not p.name.startswith(".")
]
return rows
@@ -162,7 +185,7 @@ def delete(kind: str, name: str, version: str | None = None) -> None:
def _docs(kind: str) -> list[tuple[str, Any]]:
folder = FOLDER / kind
folder = root() / kind
return [(p.stem, json.loads(p.read_bytes())) for p in sorted(folder.glob("[!.]*.json"))]
@@ -260,7 +283,7 @@ def _new_code(column: str) -> str:
def _retired() -> list[str]:
"""지운 열의 8자리 — 조합 장부 `지운번호`(한 번 준 번호는 다시 안 줌)."""
path = FOLDER / COMBO_FILE
path = root() / COMBO_FILE
return json.loads(path.read_bytes()).get("지운번호", []) if path.is_file() else []
@@ -318,7 +341,7 @@ def _take_origins(doc: Any) -> dict[str, str]:
source = col.pop("본뜬열", None)
if not source or source not in by_id or not col.get("id"):
continue
if (FOLDER / "structure" / f"{col['id']}.json").is_file():
if (root() / "structure" / f"{col['id']}.json").is_file():
continue
out[col["id"]] = source
for field in ("일위대가", "자재조합"):
@@ -360,14 +383,14 @@ def prune_removed_structures() -> list[str]:
codes: set[str] = set()
with _LOCK:
for kind, key, _, _ in PARTS:
for path in sorted((FOLDER / kind).glob("[!.]*.json")):
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 = FOLDER / COMBO_FILE
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)
@@ -377,7 +400,7 @@ def prune_removed_structures() -> list[str]:
def _move_basis() -> list[str]:
"""12장 산출근거 `basis/` → 면적산출 `area/` (내용 · 알약 연결 그대로 · 표번 칸 → 면적번 ·
시트 이름 산출근거 → 면적산출) · 옮긴 뒤 빈 `basis/` 폴더를 지움."""
old = FOLDER / "basis"
old = root() / "basis"
if not old.is_dir():
return []
moved: list[str] = []
@@ -478,7 +501,7 @@ def _combo_row(key: str, col: dict[str, Any], numbers: dict[str, str]) -> dict[s
def sync_combos() -> dict[str, Any]:
"""조합 표를 집계표에 맞춤 — 새 열 = 새 SS 키 · 지운 열 = 줄 지움 · 고친 열 = 칸 갱신 ·
줄 차례 = 집계표 열 차례. 바뀐 것이 없으면 파일을 안 씀."""
path = FOLDER / COMBO_FILE
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("줄", [])}
@@ -502,5 +525,5 @@ def sync_combos() -> dict[str, Any]:
def read_combos() -> dict[str, Any]:
"""조합 표 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦."""
path = FOLDER / COMBO_FILE
path = root() / COMBO_FILE
return json.loads(path.read_bytes()) if path.is_file() else sync_combos()