# -*- coding: utf-8 -*- """마스터 데이터 검사 도구 (`resources/master_data/_틀.md`). 돌리기: ./venv/Scripts/python.exe resources/master_data/scripts/check_master.py [틀|본문|로직] [파일 …] ./venv/Scripts/python.exe resources/master_data/scripts/check_master.py 계산 "산림품셈:13-4-1 메쌓기" 뒷길이=35 돌=견치돌 … (1) 틀 — 파일 이름 · 머리 칸 · 줄 칸 · 출처 모양 (2) 본문 — 표형 요소의 출처 절을 본문 md 에서 찾아 수를 맞댐 · 결손(본문 표에 있고 요소에 없음)·허구(요소에 있고 본문 절에 없음) (3) 로직 — 변수가 가리키는 요소·표·칸 · 찾기 조건 이름 · 로직 입력 · 돌고 도는 참조 (4) 계산 — 로직 열쇠와 입력값으로 호표 줄별 수량·단가·금액 · 비목 합 (`master_formula.py`) 수 뽑기는 `resources/knowledge/original/_pipeline/pum_md_tool.py` 의 것을 씀. """ from __future__ import annotations import json import re import sys from decimal import Decimal from pathlib import Path HERE = Path(__file__).resolve().parent MASTER = HERE.parent ROOT = MASTER.parents[1] COST = ROOT / "resources/knowledge/original/원가계산" sys.path.insert(0, str(HERE)) sys.path.insert(0, str(ROOT / "resources/knowledge/original/_pipeline")) import master_formula as mf # noqa: E402 from pum_md_tool import _NUM, md_lines # noqa: E402 BOOKS = ( "산림품셈", "건설품셈", "건설노임", "제조노임", "나라장터자재", "오피넷유가", "한국은행환율", "조달청제비율", "자체", ) DIVISIONS = { "공통": "01_공통부문", "토목": "02_토목부문", "건축": "03_건축부문", "기계설비": "04_기계설비부문", "유지관리": "05_유지관리부문", } _FILE = re.compile(rf"^({'|'.join(mf.GROUPS)})_({'|'.join(BOOKS)})(?:_(\d+장|[^_]+))?\.json$") _SOURCE = re.compile( rf"^(?:자체|(?:{'|'.join(b for b in BOOKS if b not in ('건설품셈', '자체'))})(?:\s\S.*)?|건설품셈 (?:{'|'.join(DIVISIONS)}) \S.*)$" ) _IDENT = re.compile(r"\d+-\d+(?:-\d+)*") ROW_KEYS = { "요소": ("열쇠", "이름", "값", "출처"), "표": ("열쇠", "이름", "기준", "출처", "조건", "값칸", "줄", "주"), "로직": ("열쇠", "이름", "결과단위", "출처", "소유", "입력", "중간", "끝수"), } def load(names: list[str] | None = None, folder: Path = MASTER) -> dict[str, dict]: """첫 층 JSON 모두(밑줄 파일 빼고) — 수는 Decimal. `folder` = 마스터 폴더(M01 시험은 사본).""" out = {} for path in sorted(Path(folder).glob("*.json")): if path.name.startswith("_") or ( names and path.name not in names and path.stem not in names ): continue out[path.name] = json.loads( path.read_text(encoding="utf-8"), parse_float=Decimal, parse_int=Decimal ) return out def master(folder: Path = MASTER) -> mf.Master: return mf.Master(load(folder=folder)) def check_saved(files: dict[str, dict], changed: list[str]) -> list[str]: """저장 전 검사(M01) — 고친 파일의 틀 + 모든 로직의 변수. `files` = 폴더 전부(고친 뒤).""" out = [x for name in changed for x in check_form(name, files[name])] return out + check_logics(files, mf.Master(files)) def calc(files: dict[str, dict], ref: str, given: dict) -> dict: """시험 계산(M01) — `ref` = 「원문:로직 열쇠」 · 멈추면 FormulaError.""" return mf.run(mf.Master(files), ref, given) # ── (1) 틀 ──────────────────────────────────────────────────────────── def _source_ok(source) -> bool: if isinstance(source, dict): return all(_source_ok(v) for v in source.values()) return isinstance(source, str) and bool(_SOURCE.match(source)) def check_form(name: str, data: dict) -> list[str]: out = [] m = _FILE.match(name) if not m: out.append(f"{name} · 파일 이름이 「그룹_원문[_장].json」 아님") elif (data.get("그룹"), data.get("원문")) != (m.group(1), m.group(2)): out.append(f"{name} · 머리 그룹·원문이 파일 이름과 다름") for head in ("그룹", "원문", "판"): if head not in data: out.append(f"{name} · 머리 「{head}」 없음") table = data.get("그룹") in mf.TABLE_GROUPS kind = "표" if table else "로직" if data.get("그룹") == "로직" else "요소" rows = data.get("표" if table else "줄") if not isinstance(rows, list): return out + [f"{name} · 머리 「{'표' if table else '줄'}」 목록 없음"] seen = set() for row in rows: key = str(row.get("열쇠")) where = f"{name} · {key}" if key in seen: out.append(f"{where} · 열쇠 겹침") seen.add(key) for need in ROW_KEYS[kind]: if need not in row: out.append(f"{where} · 칸 「{need}」 없음") if "출처" in row and not _source_ok(row["출처"]): out.append(f"{where} · 출처 모양 「{row['출처']}」") if kind == "요소" and isinstance(row.get("값"), dict): for bundle in ("단위", "출처"): if isinstance(row.get(bundle), dict) and set(row[bundle]) != set(row["값"]): out.append(f"{where} · 「{bundle}」 묶음 칸이 값 묶음과 다름") if kind == "표": out += _check_table(where, row) if kind == "로직" and "결과" not in row and "호표" not in row: out.append(f"{where} · 「호표」 도 「결과」 도 없음") return out def _check_table(where: str, table: dict) -> list[str]: out, conds, cols = [], table.get("조건", {}), table.get("값칸", {}) allowed = set(conds) | set(cols) | {"단위", "짝"} | {c + "원문" for c in cols} for kind in conds.values(): if kind not in ("수", "고르기", "범위"): out.append(f"{where} · 조건 종류 「{kind}」") if "범위" in conds.values() and "범위규칙" not in table: out.append(f"{where} · 범위 조건에 「범위규칙」 없음") for number, row in enumerate(table.get("줄", []), start=1): extra = set(row) - allowed if extra: out.append(f"{where} 줄{number} · 모르는 칸 {sorted(extra)}") if not set(row) & set(cols): out.append(f"{where} 줄{number} · 값 칸 없음") for cond, kind in conds.items(): if cond not in row: continue value = row[cond] if kind == "범위" and not (isinstance(value, list) and len(value) == 2): out.append(f"{where} 줄{number} · 범위 조건 「{cond}」 이 [아래, 위] 아님") if kind == "수" and not isinstance(value, Decimal): out.append(f"{where} 줄{number} · 수 조건 「{cond}」 이 수 아님") for col in cols: value = row.get(col) if isinstance(value, list) and len(value) != 2: out.append(f"{where} 줄{number} · 값 칸 「{col}」 범위가 [아래, 위] 아님") return out # ── (2) 본문 ────────────────────────────────────────────────────────── def _key(parts: str) -> tuple[int, ...]: return tuple(int(p) for p in parts.split("-")) def section_of(source: str) -> tuple[Path | None, str, str]: """(본문 md, 절 번호, 절 글) — 못 찾으면 md 나 글이 빔.""" ident = _IDENT.search(source) if not ident: return None, "", "" ident = ident.group() parts = _key(ident) if source.startswith("산림품셈"): chapters = COST / "산림_표준품셈/본문" elif source.startswith("건설품셈"): chapters = COST / "건설공사_표준품셈/본문" / DIVISIONS.get(source.split()[1], "") else: return None, ident, "" best = None for md in chapters.glob(f"제{parts[0]:02d}장_*/*.md"): head = md.name.split("_", 1)[0] if not _IDENT.fullmatch(head): continue own = _key(head) if ( own[0] and own == parts[: len(own)] and (best is None or len(own) > len(_key(best.name.split("_", 1)[0]))) ): best = md if best is None: return None, ident, "" lines = best.read_text(encoding="utf-8").split("\n") heading = re.compile(rf"^(#+)\s*{re.escape(ident)}\.?(\s|$)") for at, line in enumerate(lines): m = heading.match(line) if m: level, end = len(m.group(1)), len(lines) for later in range(at + 1, len(lines)): h = re.match(r"^(#+)\s", lines[later]) if h and len(h.group(1)) <= level: end = later break return best, ident, "\n".join(lines[at:end]) return best, ident, "" def _canon(token: str) -> str: return format(Decimal(token.replace(",", "")).normalize(), "f") def numbers_in(text: str) -> set[str]: return {_canon(t) for _, line in md_lines(text) for t in _NUM.findall(line)} def _flat(value) -> set[str]: if isinstance(value, Decimal): return {_canon(str(abs(value)))} if isinstance(value, str): return {_canon(t) for t in _NUM.findall(value)} if isinstance(value, list): return set().union(*map(_flat, value)) if value else set() if isinstance(value, dict): return set().union(*map(_flat, value.values())) if value else set() return set() def check_body(files: dict[str, dict]) -> list[str]: """표마다 허구 · 절마다 결손.""" out, by_section = [], {} for name, data in files.items(): for table in (data.get("표") or []) if data.get("그룹") in mf.TABLE_GROUPS else []: where = f"{name} · {table.get('열쇠')}" md, ident, text = section_of(str(table.get("출처", ""))) if not text: out.append(f"{where} · 본문 절 못 찾음 「{table.get('출처')}」") continue mine = ( _flat(table.get("줄")) | _flat(table.get("기준")) | _flat(table.get("주")) | _flat(table.get("값칸")) ) fake = mine - numbers_in(text) if fake: out.append(f"{where} · 허구 {len(fake)} {sorted(fake, key=Decimal)}") slot = by_section.setdefault((md, ident), [text, set(), []]) slot[1] |= mine slot[2].append(where) for (md, ident), (text, mine, tables) in by_section.items(): for (md2, ident2), (_, mine2, tables2) in by_section.items(): if md2 == md and ident2.startswith(ident + "-"): mine = mine | mine2 tables = tables + tables2 table_text = "\n".join(line for line in text.split("\n") if line.lstrip().startswith("|")) lack = numbers_in(table_text) - mine if lack: out.append( f"{md.name} {ident} · 결손 {len(lack)} {sorted(lack, key=Decimal)} ← {' · '.join(tables)}" ) return out # ── (3) 로직 ────────────────────────────────────────────────────────── def check_logics(files: dict[str, dict], whole: mf.Master) -> list[str]: out, graph = [], {} for name, data in files.items(): if data.get("그룹") != "로직": continue for row in data.get("줄", []): found, calls = mf.check_logic(whole, data.get("원문"), row) out += [f"{name} · {x}" for x in found] graph[f"{data.get('원문')}:{row.get('열쇠')}"] = calls out += [f"돌고 도는 참조 · {' → '.join(loop)}" for loop in mf.find_loops(graph)] return out # ── (4) 계산 ────────────────────────────────────────────────────────── def show(result: dict) -> str: if "결과" in result: return f"결과 {result['결과']}" rows = [ f" {r['이름']} | {r['단위']} | 수량 {r['수량']:.6f} | 단가 {r['단가']} | 금액 {r['금액']:.2f}" for r in result["줄"] ] rows += [f" {k} {result[k]:.2f}" for k in (*mf.COST_ITEMS, "계")] return "\n".join(rows) def _value(text: str): try: return Decimal(text) except ArithmeticError: return text def main(argv: list[str]) -> int: mode = argv[0] if argv else "전부" if mode == "계산": given = dict(arg.split("=", 1) for arg in argv[2:]) print(show(mf.run(master(), argv[1], {k: _value(v) for k, v in given.items()}))) return 0 names = argv[1:] or None files, whole = load(names), master() report = {} if mode in ("틀", "전부"): report["틀"] = [x for name, data in files.items() for x in check_form(name, data)] if mode in ("본문", "전부"): report["본문"] = check_body(files) if mode in ("로직", "전부"): report["로직"] = check_logics(files, whole) for title, found in report.items(): print(f"({title}) {len(found)}건") for line in found: print(" " + line) return 1 if any(report.values()) else 0 if __name__ == "__main__": sys.stdout.reconfigure(encoding="utf-8") sys.exit(main(sys.argv[1:]))