- CC000338~340 · 원문 수 그대로 · 쓰는 로직 없음(로직키 빈 목록) · 본문 대조 0건 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
698 lines
30 KiB
Python
698 lines
30 KiB
Python
# -*- 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) 본문 — 표형 요소의 출처 절을 본문 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
|
|
from pum_md_tool import _NUM, md_lines # noqa: E402
|
|
|
|
BOOKS = (
|
|
"산림품셈",
|
|
"건설품셈",
|
|
"건설노임",
|
|
"제조노임",
|
|
"엔지니어링노임",
|
|
"측량노임",
|
|
"건설사업관리노임",
|
|
"SW노임",
|
|
"산림노임",
|
|
"유가전력",
|
|
"자재품목",
|
|
"한국은행환율",
|
|
"조달청제비율",
|
|
"품셈재료",
|
|
"질의회신",
|
|
"엔산법",
|
|
"공간정보법",
|
|
"자체",
|
|
)
|
|
DIVISIONS = {
|
|
"공통": "01_공통부문",
|
|
"토목": "02_토목부문",
|
|
"건축": "03_건축부문",
|
|
"기계설비": "04_기계설비부문",
|
|
"유지관리": "05_유지관리부문",
|
|
}
|
|
_FILE = re.compile(
|
|
rf"^(?:(인력|기계)|({'|'.join(mf.GROUPS)})_({'|'.join(BOOKS)})(?:_(\d\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 = {
|
|
"요소": ("키", "원문번호", "이름", "값", "출처"),
|
|
"표": (
|
|
"키",
|
|
"원문번호",
|
|
"구분",
|
|
"상세구분",
|
|
"이름",
|
|
"기준",
|
|
"출처",
|
|
"조건",
|
|
"값칸",
|
|
"줄",
|
|
"주",
|
|
),
|
|
"로직": (
|
|
"키",
|
|
"원문번호",
|
|
"구분",
|
|
"상세구분",
|
|
"이름",
|
|
"결과단위",
|
|
"출처",
|
|
"소유",
|
|
"입력",
|
|
"중간",
|
|
"끝수",
|
|
),
|
|
"품셈재료": ("키", "원문번호", "이름", "규격", "단위", "요구절", "연결", "고르기"),
|
|
}
|
|
# 한 테이블 파일 — 줄의 갈래 칸 · 머리의 갈래 묶음 (칸 이름은 `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 check_saved(files: dict[str, dict], changed: list[str], book: dict | None = None) -> list[str]:
|
|
"""저장 전 검사(M01) — 고친 파일의 틀 + 모든 로직의 변수 · 키 + 조합.
|
|
|
|
`files` = 폴더 전부(고친 뒤) · `book` = 키 대장.
|
|
"""
|
|
whole = mf.Master(files)
|
|
out = [x for name in changed for x in check_form(name, files[name])]
|
|
return out + check_logics(files, whole, book) + mcb.check_all(files, whole)
|
|
|
|
|
|
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('출처')}」")
|
|
return out
|
|
|
|
|
|
def check_form(name: str, data: dict) -> list[str]:
|
|
if name == mcb.FILE:
|
|
return check_combo_form(name, data)
|
|
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
|
|
|
|
|
|
# ── (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]:
|
|
# 칸 안에서 줄바뀐 천 단위 수(「5,1<br>50」)는 이어 붙임
|
|
text = re.sub(r"(?<=\d,\d)<br>(?=\d{2})", "", text)
|
|
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('키')} {table.get('원문번호')}"
|
|
source = str(table.get("출처", ""))
|
|
if not source.startswith(("산림품셈", "건설품셈")):
|
|
continue # 품셈 본문이 아닌 출처(질의회신 등) — 절 대조 대상 아님
|
|
md, ident, text = section_of(source)
|
|
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)
|
|
_elements_into(files, by_section)
|
|
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
|
|
|
|
|
|
def _elements_into(files: dict[str, dict], by_section: dict) -> None:
|
|
"""요소 줄(기계 운전경비처럼 표 대신 요소로 올린 절)의 수도 그 절 결손 대조에 넣음."""
|
|
seen: dict[str, tuple] = {}
|
|
for data in files.values():
|
|
if data.get("그룹") in (*mf.TABLE_GROUPS, "로직"):
|
|
continue
|
|
for row in data.get("줄") or []:
|
|
source = row.get("출처")
|
|
for text in source.values() if isinstance(source, dict) else [source]:
|
|
if not isinstance(text, str) or not text.startswith(("건설품셈", "산림품셈")):
|
|
continue
|
|
if text not in seen:
|
|
seen[text] = section_of(text)[:2]
|
|
slot = by_section.get(seen[text])
|
|
if slot:
|
|
slot[1] |= _flat(row.get("값")) | _flat(
|
|
[row.get(k) for k in ("이름", "규격", "원문번호")]
|
|
)
|
|
|
|
|
|
# ── (3) 로직 ──────────────────────────────────────────────────────────
|
|
def check_logics(files: dict[str, dict], whole: mf.Master, book: dict | None = None) -> 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, row)
|
|
out += [f"{name} · {x}" for x in found]
|
|
graph[str(row.get("키"))] = calls
|
|
out += [f"돌고 도는 참조 · {' → '.join(loop)}" for loop in mf.find_loops(graph)]
|
|
return out + duplicate_keys(files, book) + 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)]
|
|
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:]))
|