"""M01 「만들기」 저장소 — 원문 절 목록 · 표 조건 값 · 자체 로직 만들기·본뜨기·고치기·지우기
· 복사해서 만들기(정본 로직을 별칭 꼴 식으로 펴 보이고 되돌려 저장).
계약: `resources/master_data/_화면_계약.md` 2장·5장·7장 · 파일 모양: `_틀.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, mcp = store.cm, store.mk, store.mcp
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 store.loaded()[0].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)]}
# ── 로직 원문(출처 글 → 품셈 본문 절) ──────────────────────────────────
_NUMBER = re.compile(r"(?~`])") # 마크다운 이스케이프 `\.` `\-` …
_COMMENT = re.compile(r"") # 글 안 주석(글자층 표시) — 글자만 남김
_CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳"
def _plain(text: str) -> str:
"""원문 md 기호 벗김 — 주석 · 이스케이프 · 인용 머리 `> `."""
text = _ESCAPE.sub(r"\1", _COMMENT.sub("", text)).strip()
return text[1:].strip() if text.startswith(">") else text
def _blocks(lines: list[str]) -> list[dict]:
"""md 줄 → 화면 조각 — 제목 {h} · 표 {table} · 글 {p} · 주석 줄은 뺌 ·
빈 줄 · 주석이 표를 끊음(붙은 표 둘을 한 표로 안 합침) · `
` = 줄바꿈."""
out: list[dict] = []
joinable = False
for line in lines:
text = re.sub(r"
", "\n", line.strip())
if not text or text.startswith("