# -*- 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 계산 GF000123 뒷길이=35 돌=견치돌 … (1) 틀 — 파일 이름 · 머리 칸 · 줄 칸 · 출처 모양 · 키 모양·겹침 · 대장 (2) 본문(`check_master_body.py`) — 표형 요소의 출처 절을 본문 md 에서 찾아 수를 맞댐 · 결손(본문 표에 있고 요소에 없음)·허구(요소에 있고 본문 절에 없음) (3) 로직 — 변수가 가리키는 요소·표·칸 · 찾기 조건 이름 · 로직 입력 · 재료 고르기 조건 · 돌고 도는 참조 · 끊긴 키 (4) 조합 — 일위대가 조합이 담은 로직 키 · 같은 로직 두 번 · 빈 조합 · 조합을 담음 (`master_combo.py`) (5) 계산 — 로직 키와 입력값으로 호표 줄별 수량·단가·금액 · 비목 합 (`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_combo as mcb # noqa: E402 import master_formula as mf # noqa: E402 import master_keys as mk # noqa: E402 import master_material_combo as mmc # noqa: E402 # (2) 본문은 따로 모듈 — 부르는 쪽은 `cm.check_body` · `cm.DIVISIONS` 그대로 from check_master_body import DIVISIONS, check_body # noqa: E402, F401 BOOKS = ( "산림품셈", "건설품셈", "건설노임", "제조노임", "엔지니어링노임", "측량노임", "건설사업관리노임", "SW노임", "산림노임", "유가전력", "자재품목", "한국은행환율", "조달청제비율", "품셈재료", "질의회신", "엔산법", "공간정보법", "자체", ) _FILE = re.compile( rf"^(?:(인력|기계)|({'|'.join(mf.GROUPS)})_({'|'.join(BOOKS)})(?:_(\d\d)장_(.+))?)\.json$" ) #: 8장 틀에 안 맞는 특수 최상위 파일(표·요소가 아닌 다른 모양) — 틀 검사에서 뺌(PLAN 7-5 ⑤). SPECIAL_FILES = {"기준수량없음_산림품셈_2026-01-01.json"} _SOURCE = re.compile( rf"^(?:자체|(?:{'|'.join(b for b in BOOKS if b not in ('건설품셈', '자체'))})(?:\s\S.*)?|건설품셈 (?:{'|'.join(DIVISIONS)}) \S.*)$" ) ROW_KEYS = { "요소": ("키", "원문번호", "이름", "값", "출처"), "표": ( "키", "원문번호", "구분", "상세구분", "이름", "기준", "출처", "조건", "값칸", "줄", "주", ), "로직": ( "키", "원문번호", "구분", "상세구분", "이름", "결과단위", "출처", "소유", "입력", "중간", "끝수", ), "품셈재료": ("키", "원문번호", "이름", "규격", "단위", "요구절", "연결", "고르기"), } # 한 테이블 파일 — 줄의 갈래 칸 · 머리의 갈래 묶음 (칸 이름은 `master_keys.MERGED` 한 곳에서) MERGED = {group: slot for group, (_, slot, _) in mk.MERGED.items()} # 인력 줄 — 모든 줄이 같은 열 한 벌 · 상태 값 LABOR_COLS = ( "키", "원문번호", "구분", "상세구분", "이름", "규격", "단위", "값", "출처", "일시간", "상태", "옛이름", "비고", "준용", ) LABOR_STATUS = ("공표", "산정", "미공표", "미확보", "추정") # 원문별 열 한 벌 — 모든 줄이 이 칸을 이 차례로 가짐(없으면 null) COLS = { # 자재품목 — 원문번호(남의 품번)는 줄에 두지 않음 · 대조는 `_키대장.json` 에만 "자재품목": ( "키", "구분", "상세구분", "이름", "규격", "단위", "물가자료", "유통물가", "물가정보", "거래가격", "관급", "출처", "비고", "면수", ), "유가전력": ( "키", "원문번호", "구분", "상세구분", "이름", "규격", "단위", "값", "출처", "비고", ), } COLS["한국은행환율"] = COLS["유가전력"] def _detail(name: str) -> str: """장 파일 이름 → 상세구분 「03장 토공사」.""" return name.removesuffix(".json").split("_", 2)[2].replace("_", " ") 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 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 _head(name: str, data: dict, m) -> list[str]: """머리 — 그룹·원문·판 · 한 테이블은 갈래 묶음 · 장 파일은 차례(건설은 부문).""" out = [] if m.group(1): slot = MERGED[m.group(1)] if data.get("그룹") != m.group(1): out.append(f"{name} · 머리 그룹이 파일 이름과 다름") if not isinstance(data.get(slot), dict) or not data[slot]: out.append(f"{name} · 머리 「{slot}」 묶음 없음") return out if (data.get("그룹"), data.get("원문")) != (m.group(2), m.group(3)): out.append(f"{name} · 머리 그룹·원문이 파일 이름과 다름") for head in ("그룹", "원문", "판"): if head not in data: out.append(f"{name} · 머리 「{head}」 없음") chapter = bool(m.group(4)) if (data.get("그룹") in (*mf.TABLE_GROUPS, "로직") and data.get("원문") != "자체") != chapter: out.append(f"{name} · 파일 이름이 「그룹_원문_NN장_장 제목.json」 아님") if chapter and not isinstance(data.get("차례"), Decimal): out.append(f"{name} · 머리 「차례」 없음") if chapter and (data.get("원문") == "건설품셈") != (data.get("부문") in DIVISIONS): out.append(f"{name} · 머리 「부문」 「{data.get('부문')}」") return out def check_combo_form(name: str, data: dict) -> list[str]: """조합 파일 틀 — 원문·장이 없는 한 파일(관리자가 만드는 것이라 원문 근거가 없음).""" out = [] if data.get("그룹") != mcb.GROUP: out.append(f"{name} · 머리 그룹이 「{mcb.GROUP}」 아님") if "판" not in data: out.append(f"{name} · 머리 「판」 없음") if "원문" in data: out.append(f"{name} · 조합은 원문이 없음 — 머리 「원문」 을 두지 않음") rows_ = data.get("줄") if not isinstance(rows_, list): return out + [f"{name} · 머리 「줄」 목록 없음"] seen_names: dict[str, str] = {} for row in rows_: key = str(row.get("키")) where = f"{name} · {key} {row.get('이름') or ''}".strip() if not mk.KEY.fullmatch(key) or key[:2] != mcb.TID: out.append(f"{where} · 키 모양이 「{mcb.TID}」 + 6자리 아님") if "원문번호" in row: out.append(f"{where} · 원문번호는 줄에 두지 않음 — 대장에만") if tuple(row) != mcb.COLS: out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(mcb.COLS)}」") name_ = row.get("이름") if not isinstance(name_, str) or not name_.strip(): out.append(f"{where} · 「이름」 이 글자 아니거나 없음") else: plain = name_.strip() if len(plain) > mcb.MAX_이름: out.append(f"{where} · 「이름」 이 너무 김({len(plain)}자 — {mcb.MAX_이름}자까지)") if plain in seen_names: out.append(f"{where} · 「이름」 이 같은 조합과 겹침 — {seen_names[plain]}") seen_names.setdefault(plain, key) if row.get("소유") not in mcb.OWNERS: out.append(f"{where} · 소유 「{row.get('소유')}」 — {' · '.join(mcb.OWNERS)} 아님") if not _source_ok(row.get("출처")): out.append(f"{where} · 출처 모양 「{row.get('출처')}」") origin = row.get("본뜬키") if origin is not None and not (mk.KEY.fullmatch(str(origin)) and origin[:2] == mcb.TID): out.append(f"{where} · 본뜬키 모양 「{origin}」") return out def check_form(name: str, data: dict) -> list[str]: if name == mcb.FILE: return check_combo_form(name, data) if name in SPECIAL_FILES: return [] m = _FILE.match(name) if not m: return [ f"{name} · 파일 이름이 「그룹_원문[_NN장_장 제목].json」 · 「인력.json」 · 「기계.json」 아님" ] out = _head(name, data, m) # 한 테이블 파일(기계)은 머리 「원문」 이 갈래 묶음 — 원문 이름은 묶음 안에 book = data.get("원문") if isinstance(data.get("원문"), str) else None try: tid = mk.table_id(data.get("그룹"), book) except KeyError: return out + [f"{name} · 테이블ID 없는 그룹·원문"] merged = MERGED.get(data.get("그룹")) table = data.get("그룹") in mf.TABLE_GROUPS kind = "표" if table else "로직" if data.get("그룹") == "로직" else "요소" if book == "품셈재료": kind = "품셈재료" rows = data.get("표" if table else "줄") if not isinstance(rows, list): return out + [f"{name} · 머리 「{'표' if table else '줄'}」 목록 없음"] division = data.get("부문") if kind != "요소" else None # 장 파일 줄의 갈래 — 구분 = 원문 + 부문 · 상세구분 = 「NN장 장 제목」(파일 이름 그대로) want = (" ".join(x for x in (book, division) if x), _detail(name)) if m.group(4) else None for row in rows: key, number = str(row.get("키")), str(row.get("원문번호") or "") where = f"{name} · {key} {row.get('이름') or number}" if not mk.KEY.fullmatch(key) or key[:2] != tid: out.append(f"{where} · 키 모양이 「{tid}」 + 6자리 아님") if number and tid in mk.NO_NUMBER: out.append(f"{where} · 원문번호는 줄에 두지 않음 — 대장에만") if merged and row.get(merged) not in data.get(merged, {}): out.append(f"{where} · {merged} 「{row.get(merged)}」 이 머리에 없음") if number and mk.section_no(number) != number: out.append(f"{where} · 원문번호는 절 번호만 — 「{mk.section_no(number)}」") if want and (row.get("구분"), row.get("상세구분")) != want: out.append( f"{where} · 구분·상세구분 「{row.get('구분')} · {row.get('상세구분')}」" f" 이 파일 머리·이름 「{want[0]} · {want[1]}」 과 다름" ) cols = COLS.get(book) for need in cols or 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 data.get("그룹") == "인력": out += _check_labor(where, row) if cols and tuple(row) != cols: out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(cols)}」") if kind == "품셈재료": out += _check_linked(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_labor(where: str, row: dict) -> list[str]: """인력 줄 — 열 한 벌(차례까지 같음) · 상태 값 · 옛이름 목록.""" out = [] if tuple(row) != LABOR_COLS: out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(LABOR_COLS)}」") if row.get("상태") not in LABOR_STATUS: out.append(f"{where} · 상태 「{row.get('상태')}」 — {' · '.join(LABOR_STATUS)} 아님") if not isinstance(row.get("옛이름"), list): out.append(f"{where} · 옛이름 목록 아님 「{row.get('옛이름')}」") return out def _check_linked(where: str, row: dict) -> list[str]: """품셈재료 — 값 칸 없음 · 요구절은 출처 모양 · 연결 = 자재품목 키 · 후보 조건 · null.""" out = [f"{where} · 값 칸을 두지 않음"] if "값" in row else [] need = row.get("요구절") if not (isinstance(need, list) and need and all(_source_ok(x) for x in need)): out.append(f"{where} · 요구절 모양 「{need}」") link = row.get("연결") if isinstance(link, dict) and ( not link.get("이름") or set(link) - {"이름", "규격", "지역", "계약종별"} or link.get("지역") not in (None, "입력") or link.get("계약종별") not in (None, "입력") ): out.append(f"{where} · 후보 조건 모양 「{link}」") elif link is not None and not isinstance(link, (str, dict)): out.append(f"{where} · 연결 모양 「{link}」") return out def check_links(whole: mf.Master) -> list[str]: """끊긴 키 — 품셈재료 연결 · 고르기 조건 · 인력 준용이 가리키는 줄 · 추정 줄 값 = 준용 직종 값.""" out = [] for key, row in whole.index.get("MP", {}).items(): link = row.get("연결") if isinstance(link, str) and link not in whole.index.get(link[:2], {}): out.append(f"{whole.label(key)} · 연결 「{link}」 이 자재품목·유가전력에 없음") pick = row.get("고르기") if isinstance(pick, dict): out += mf.check_pick(whole, whole.label(key) + " 고르기", pick) elif pick is not None: out.append(f"{whole.label(key)} · 고르기 모양 「{pick}」 — 조건 묶음이나 null") for key, row in whole.index.get("LB", {}).items(): for col in ("준용",): ref = row.get(col) if ref in (None, ""): continue target = whole.index.get("LB", {}).get(str(ref)) if target is None: out.append(f"{whole.label(key)} · {col} 「{ref}」 없는 인력 키") elif row.get("상태") == "추정" and row.get("값") != target.get("값"): out.append( f"{whole.label(key)} · 추정 값 「{row.get('값')}」 이" f" 준용 「{target.get('이름')}」 값 「{target.get('값')}」 과 다름" ) return out def check_units(files: dict[str, dict], whole: mf.Master) -> list[str]: """단위 경고 — 재료 고르기 조건의 후보 단위가 그 줄 단위와 다름(호표 줄 · 품셈재료 줄). 데이터가 틀린 것이 아니라 조건을 더 좁혀야 하는 자리라 로직 검사와 따로 봄.""" out = [] for key, row in whole.index.get("MP", {}).items(): cond = row.get("고르기") if isinstance(cond, dict): out += mf.check_unit(whole, whole.label(key) + " 고르기", cond, row.get("단위")) for name, data in files.items(): if data.get("그룹") != "로직": continue for row in data.get("줄", []): for item in row.get("호표", []): if isinstance(item.get("요소"), dict): where = f"{name} · {row.get('키')} 호표 {item.get('이름', '')} 요소" out += mf.check_unit(whole, where, item["요소"], item.get("단위")) 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 # ── (3) 로직 ────────────────────────────────────────────────────────── def check_logics( files: dict[str, dict], whole: mf.Master, book: dict | None = None, narrow=None ) -> list[str]: """`narrow` = (검사할 로직 키, 안 보는 줄이 부르는 키) — 주면 그 키만 검사하고 품셈재료 연결 · 인력 준용(`check_links`)도 건너뜀(로직 줄 고침이 못 건드리는 자리).""" only, graph = narrow or (None, {}) out, graph = [], dict(graph) for name, data in files.items(): if data.get("그룹") != "로직": continue for row in data.get("줄", []): key = str(row.get("키")) if only is not None and key not in only: continue found, calls = mf.check_logic(whole, row) out += [f"{name} · {x}" for x in found] graph[key] = calls out += [f"돌고 도는 참조 · {' → '.join(loop)}" for loop in mf.find_loops(graph)] return out + duplicate_keys(files, book) + ([] if only is not None else check_links(whole)) def duplicate_keys(files: dict[str, dict], book: dict | None = None) -> list[str]: """키 겹침(전체) · 원문번호 겹침(인력·재료 — 번호가 곧 그 줄의 이름) · 대장 대조. 표·로직·기계의 원문번호는 절 번호·분류번호라 한 절에 여럿이 겹쳐도 됨 (겹친 번호를 「ID:원문번호」 로 부르면 `master_formula` 가 잡음).""" book = mk.load_book() if book is None else book first, numbers, out = {}, {}, [] for name, data in files.items(): rows = data.get("표") if data.get("그룹") in mf.TABLE_GROUPS else data.get("줄") one_each = data.get("그룹") in ("인력", "재료") for row in rows or []: key, number = str(row.get("키")), str(row.get("원문번호") or "") if key in first: out.append(f"{name} · 키 겹침 「{key}」 — {first[key]} 에도 있음") first.setdefault(key, name) if one_each and number: if (key[:2], number) in numbers: out.append( f"{name} · {key} · 원문번호 겹침 「{number}」 — {numbers[key[:2], number]}" ) numbers.setdefault((key[:2], number), key) had = book["키"].get(key) if had is None: out.append(f"{name} · {key} · 대장에 없는 키") continue # 줄에 번호를 두지 않는 테이블(`mk.NO_NUMBER`)은 대조를 건너뜀 if key[:2] not in mk.NO_NUMBER and number not in ( had["원문번호"], mk.section_no(had["원문번호"]), ): out.append(f"{name} · {key} · 대장 원문번호 「{had['원문번호']}」 와 다름") elif int(key[2:]) >= book["다음"].get(key[:2], 0): out.append(f"{name} · {key} · 대장 다음 번호보다 큼") 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 시험입력(row: dict) -> dict: """일괄 시험 계산용 입력 한 벌 — 고르기 첫 값 · 범위 위끝 · 줄의 「시험입력」 이 이김. 시험 계산 전용 — 정본 계산은 화면이 준 값만 씀.""" given = {} for spec in row.get("입력", []): if "고르기" in spec: given[spec["이름"]] = spec["고르기"][0] elif "범위" in spec: given[spec["이름"]] = spec["범위"][1] else: given[spec["이름"]] = Decimal(1) given.update(row.get("시험입력") or {}) return given def calc_all(files: dict[str, dict], whole: mf.Master) -> tuple[int, list[str]]: """로직 전부를 시험 입력으로 돌림 — (통과 수, 멈춘 줄 목록).""" passed, stuck = 0, [] for data in files.values(): if data.get("그룹") != "로직": continue for row in data.get("줄", []): try: mf.run(whole, str(row["키"]), 시험입력(row)) passed += 1 except mf.FormulaError as err: stuck.append(f"{row['키']} {row.get('이름')} · {err}") return passed, stuck 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 if mode == "계산전부": files, whole = load(), master() passed, stuck = calc_all(files, whole) print(f"(계산) 통과 {passed} · 멈춤 {len(stuck)}") for line in stuck: print(" " + line) 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)] report["틀"] += mmc.check_file() # 자재 조합은 마스터 템플릿 표 if mode in ("본문", "전부"): report["본문"] = check_body(files) if mode in ("로직", "전부"): report["로직"] = check_logics(files, whole) if mode in ("조합", "전부"): report["조합"] = mcb.check_all(files, whole) if mode in ("단위", "전부"): report["단위"] = check_units(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:]))