- 화면 이름 「구조물집계표」 · 「구조물 집계 양식」 → 「구조물 조합」(좌측 줄 · 상세 제목 · 안내글) · 파일 이름 · 양식 키 · API 길 그대로 - 좌측 패널 「항목 나무」 컨테이너 없앰 — 페이지가 나무를 만들어 편집 칸 왼쪽(aside)에 붙임 · 「구조물 조합」일 때만 보임 · 오른쪽 클릭 · 찾기 · 표와 서로 고름 · 고치기 전 저장 물음 그대로 - 표 부품 lockHead — 열 더하기 · 분류N 더하기 · 열 지우기 단추 없음 · 머리 · 층 이름 고치기 없음(단위 · 일위대가 · 조회 후 선택은 그대로) - 비고 열 · 전구간 줄 · 합계 줄은 보일 문서에서만 뺌(comboView) · [저장] 때 저장본 것을 다시 끼움(comboMerge) · 양식 파일 · M02_Table_Fill 그대로 - 서버 — 화면 표 [저장](store.write screen=True)은 있던 항목의 경로 · 층 이름 · 빈 폴더를 저장본 그대로(tree.keep_paths · 브라우저 머리 안 받음) · 머리는 경로에서 다시 만듦 · 나무 고침 줄기 그대로 - 나무 같은 이름 409 는 「이미 있는 항목」 까닭 그대로 보임(판 다름 안내와 가름) - 시험 test_m02_combo_screen 3(숨김 · 되끼움 Node · 잠금 · 나무 자리) · test_m02_tree 표 저장 약속 새로 · 이름 · 잠금 기대값 고침 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
499 lines
22 KiB
Python
499 lines
22 KiB
Python
"""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, screen: bool = False) -> dict[str, Any]:
|
|
"""`version` 이 빈 글이면 새로 만듦(이미 있으면 409) · 아니면 그 판일 때만 덮어씀.
|
|
구조물집계표를 저장하면 — 새 구조물 열의 빈 구조물 도면 · 면적산출 · 재료산출을 번호와 같이
|
|
만들고 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 지움(잠금 밖에서 · 안에서 부르면 죽음).
|
|
통합문서(area · material)는 서버가 같은 TS 엔진을 Node 로 돌려 `계산값` 을 새로 적음
|
|
(브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘.
|
|
번호 칸(도번 · 면적번 · 재료번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음).
|
|
표 열의 `본뜬열`(분류 더하기로 복사한 새 열)은 적지 않음 — 집계표면 아직 파일이 없는 새 열이
|
|
원본 열의 일위대가 · 자재조합 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로).
|
|
`screen` = 화면 표 [저장](PLAN 26-7e) — 있던 항목의 경로 · 층 이름 · 빈 폴더는 저장본 그대로
|
|
(정본 = 나무 · 머리는 경로에서 다시 만듦 · `tree.keep_paths`)."""
|
|
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 screen and have and kind == "table" and name == STRUCTURE_TABLE:
|
|
doc = tree.normalize_table(tree.keep_paths(doc, json.loads(path.read_bytes())))
|
|
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()
|