"""M01 「만들기」 저장소 — 원문 절 목록 · 표 조건 값 · 자체 로직 만들기·본뜨기·고치기·지우기. 계약: `resources/master_data/_화면_계약.md` 2장·5장 · 파일 모양: `_틀.md` 7장. 자체 로직은 `로직_자체.json` 한 파일 · 키는 `_키대장.json` 의 `GX` 다음 번호(서버가 줌). """ from __future__ import annotations import re from datetime import date from pathlib import Path from M01_MasterData import M01_MasterData_Store as store cm, mk = store.cm, store.mk OWN = "로직_자체.json" # 자체 로직 한 자리 — 이 파일 줄만 고치고 지울 수 있음 OWNERS = ("현장", "공용") BODY = mk._BODY # resources/knowledge/original/원가계산 BOOKS = {"산림품셈": "산림_표준품셈", "건설품셈": "건설공사_표준품셈"} _HEAD = re.compile(r"^#{2,6}\s*(\d+(?:-\d+)*)\.?\s+(\S.*?)\s*$") _CHAPTER_DIR = re.compile(r"^제(\d\d)장_(.+)$") # ── 원문 절 목록 ─────────────────────────────────────────────────────── def _chapters(book: str): """(부문, 장 번호, 장 제목, 폴더) — 건설품셈은 부문 폴더 아래.""" root = BODY / BOOKS[book] / "본문" tops = sorted(root.glob("*부문")) if book == "건설품셈" else [root] for top in tops: division = top.name.split("_", 1)[1].removesuffix("부문") if book == "건설품셈" else "" for folder in sorted(top.glob("제*장_*")): m = _CHAPTER_DIR.match(folder.name) if m: yield division, m.group(1), m.group(2).replace("_", " "), folder _CACHE: dict[str, list[dict]] = {} def _all_sections(book: str) -> list[dict]: """원문 본문 제목 줄에서 모은 절 — 한 번 세워 두고 다시 씀(원문은 안 바뀜).""" if book in _CACHE: return _CACHE[book] out, seen = [], set() for division, number, title, folder in _chapters(book): for md in sorted(folder.glob("*.md")): for line in md.read_text(encoding="utf-8").split("\n"): m = _HEAD.match(line) if not m or m.group(1).split("-")[0] != str(int(number)): continue if (division, m.group(1)) in seen: continue seen.add((division, m.group(1))) out.append( { "book": book, "division": division, "chapter": f"{division} {number}장 {title}".strip(), "chapterNo": number, "section": m.group(1), "title": m.group(2), } ) _CACHE[book] = out return out def _counts() -> dict[tuple[str, str], list[int]]: """(구분, 절 번호) → [표 수, 로직 수] — 아래 절까지 세도록 절 번호의 모든 앞마디에 얹음.""" out: dict[tuple[str, str], list[int]] = {} for data in cm.load(folder=store.FOLDER).values(): group = data.get("그룹") at = 0 if group in cm.mf.TABLE_GROUPS else 1 if group == "로직" else None if at is None: continue for row in data.get(store.items_key(data)) or []: number, sub = str(row.get("원문번호") or ""), str(row.get("구분") or "") parts = number.split("-") for upto in range(1, len(parts) + 1): out.setdefault((sub, "-".join(parts[:upto])), [0, 0])[at] += 1 return out def sections(book: str, division: str = "", chapter: str = "", q: str = "", limit: int = 200): """원문 절 목록 — 만들기 첫 걸음(절을 고르면 그 절의 표만 보임).""" if book not in BOOKS: raise store.StoreError(404, f"없는 원문 「{book}」") if division and division not in cm.DIVISIONS: raise store.StoreError(404, f"없는 부문 「{division}」") counts, hits = _counts(), [] for one in _all_sections(book): if division and one["division"] != division: continue if chapter and one["chapterNo"] != str(chapter).zfill(2): continue if q and q.lower() not in f"{one['section']} {one['title']}".lower(): continue sub = " ".join(x for x in (book, one["division"]) if x) tables, logics = counts.get((sub, one["section"]), [0, 0]) hits.append({**one, "tables": tables, "logics": logics}) return {"total": len(hits), "sections": hits[: min(max(limit, 1), 1000)]} # ── 표 조건 값 ───────────────────────────────────────────────────────── def table_options(file: str, key: str) -> dict: """표 조건 칸의 값만 — 표 몸을 통째로 받지 않으려는 자리(만들기 입력 제안).""" table = store.table(file, key)["table"] conds = table.get("조건") or {} values: dict[str, list] = {name: [] for name in conds} for row in table.get("줄") or []: for name in conds: value = row.get(name) if value is None or value in values[name]: continue values[name].append(value) return { "file": file, "key": key, "이름": table.get("이름"), "조건": conds, "범위규칙": table.get("범위규칙"), "값칸": table.get("값칸") or {}, "count": len(table.get("줄") or []), "값": values, } # ── 자체 로직 ────────────────────────────────────────────────────────── def _own_path() -> Path: return store.FOLDER / OWN def _make_file() -> None: """자체 로직 파일이 없으면 머리만 두고 만듦.""" path = _own_path() if path.is_file(): return head = {"그룹": "로직", "원문": "자체", "판": str(date.today().year), "줄": []} path.write_text(store.dump(head), encoding="utf-8", newline="\n") def _find(key: str) -> tuple[str, dict]: got = store.logic(key) return got["file"], got["logic"] def _own_only(file: str, key: str) -> None: if file != OWN: raise store.StoreError(403, f"정본 로직 「{key}」 은 못 고침 — 본뜬 뒤 고칠 것") def _clean(logic: dict, owner: str, key: str | None = None) -> dict: """서버가 잡는 칸 — 키·소유·구분·상세구분. 화면이 보낸 것은 안 씀.""" if not isinstance(logic, dict) or not str(logic.get("이름") or "").strip(): raise store.StoreError(400, "로직 줄에 「이름」 이 없음") if owner not in OWNERS: raise store.StoreError(400, f"모르는 소유 「{owner}」 — {' · '.join(OWNERS)}") number = str(logic.get("원문번호") or "") rest = { k: v for k, v in logic.items() if k not in ("키", "원문번호", "구분", "상세구분", "소유") } head = {"키": key, "원문번호": number} if key else {"원문번호": number} one = {**head, "구분": "자체", "상세구분": _chapter_of(logic), "소유": owner, **rest} order = mk.ROW_ORDER["로직"] # 열 차례는 `_틀.md` 2장 return { **{k: one[k] for k in order if k in one}, **{k: v for k, v in one.items() if k not in order}, } def _chapter_of(logic: dict) -> str: """상세구분 — 원문 절이 속한 장(「13장 구조물」) · 원문 절이 없으면 빈 글.""" number = str(logic.get("원문번호") or "") source = str(logic.get("출처") or "") book = next((b for b in BOOKS if source.startswith(b)), "") if not number or not book: return "" at = int(number.split("-")[0]) division = next((d for d in cm.DIVISIONS if source.startswith(f"{book} {d} ")), "") for one_division, chapter, title, _ in _chapters(book): if one_division == division and int(chapter) == at: return f"{chapter}장 {title}" return "" def _write(changes: list[dict]) -> dict: """자체 파일 한 벌 저장 — 검사는 `/save` 와 같음(422 면 아무것도 안 씀).""" fresh = not _own_path().is_file() _make_file() try: out = store.save([{"file": OWN, "version": store.read(OWN)[1], "changes": changes}]) except store.StoreError: if fresh: _own_path().unlink(missing_ok=True) raise return out[0] def _keys() -> list[str]: if not _own_path().is_file(): return [] return [str(r.get("키")) for r in store.read(OWN)[0].get("줄") or []] def logic_new(logic: dict, owner: str = OWNERS[0]) -> dict: """새 자체 로직 — 키는 서버가 `GX` 다음 번호로 줌.""" before = set(_keys()) done = _write([{"op": "add", "row": _clean(logic, owner)}]) key = next((k for k in reversed(_keys()) if k not in before), "") return {**done, "key": key, "logic": store.logic(key)["logic"] if key else {}} def logic_copy(key: str, 이름: str = "", owner: str = OWNERS[0]) -> dict: """본떠 만들기 — 정본도 본뜰 수 있음 · 비고 끝에 본뜬 키를 적음.""" _, row = _find(key) note = " · ".join(x for x in (str(row.get("비고") or ""), f"{key} 를 본뜸") if x) copy = {**row, "비고": note} if 이름: copy["이름"] = 이름 return logic_new(copy, owner) def logic_edit(key: str, version: str, logic: dict, owner: str = "") -> dict: file, row = _find(key) _own_only(file, key) if store.read(OWN)[1] != version: raise store.StoreError(409, {"stale": [OWN]}) fixed = _clean(logic, owner or str(row.get("소유") or OWNERS[0]), key) done = _write([{"op": "edit", "key": key, "row": fixed}]) return {**done, "logic": store.logic(key)["logic"]} def logic_delete(key: str, version: str) -> dict: file, _ = _find(key) _own_only(file, key) if store.read(OWN)[1] != version: raise store.StoreError(409, {"stale": [OWN]}) return _write([{"op": "delete", "key": key}])