From a36b1051d419778f6ba9d55ae1453191d11f8063 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 14:50:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(712-3):=20=ED=91=9C=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=20=E2=86=92=20=EA=B8=B0=EC=A4=80=EB=B0=B0=20=C2=B7=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EB=8B=A8=EC=9C=84=20=EC=9D=BD=EC=9D=84=20=EB=95=8C=20?= =?UTF-8?q?=ED=8C=8C=EC=83=9D=20=E2=80=94=20=ED=91=9C=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=20=EA=B8=80=20=C2=B7=20=EB=B9=88=20=EA=B8=B0=EC=A4=80=EC=9D=80?= =?UTF-8?q?=20=EC=9B=90=EB=AC=B8=20=EC=A0=88=20=EB=A8=B8=EB=A6=AC=20=C2=B7?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B8=20=ED=91=9C=20=EC=95=88=20=EC=94=80=20?= =?UTF-8?q?=C2=B7=20API=20=ED=91=9C=20=EB=B0=96=20=EC=B9=B8=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=ED=8C=8C=EC=83=9D=20=C2=B7=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=EA=B7=9C(=ED=8C=90=20=3D=20=EC=9D=BD=EC=9D=84=20=EB=95=8C=20?= =?UTF-8?q?=ED=8C=8C=EC=83=9D)=20=C2=B7=20check=5Fmaster=20=E3=80=8C?= =?UTF-8?q?=EB=B0=B0=E3=80=8D=20=EB=8C=80=EC=A1=B0=207=20=EA=B1=B4=20?= =?UTF-8?q?=C2=B7=20=EC=A7=80=ED=82=B4=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Router.py | 15 +- resources/master_data/scripts/check_master.py | 4 + resources/master_data/scripts/master_basis.py | 153 ++++++++++++++++++ resources/tester/test_712_3_basis.py | 79 +++++++++ 4 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 resources/master_data/scripts/master_basis.py create mode 100644 resources/tester/test_712_3_basis.py diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index cd241f9b7..273f709ad 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -17,6 +17,8 @@ from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M01_MasterData import M01_MasterData_Store_Flow as flow from M01_MasterData import M01_MasterData_Store_Make as make +import master_basis as mbs # noqa: E402 (`store` 가 scripts 를 길에 둠) + router = APIRouter(prefix="/api/m01", tags=["M01 MasterData"]) if "pytest" not in sys.modules: # 시험은 사본 폴더를 써서 정본을 미리 읽을 까닭이 없음 @@ -122,7 +124,9 @@ def get_groups() -> dict: @router.get("/groups/{group}/files") def get_files(group: str) -> dict: - return {"files": _call(store.files_of, group)} + """`판정규` = 머리 판의 해(「2026-01-01」 · 「2026」 → 「2026」) · 읽을 때 파생(파일 안 씀).""" + files = _call(store.files_of, group) + return {"files": [{**f, "판정규": mbs.edition(f["edition"])} for f in files]} @router.get("/subs") @@ -156,12 +160,17 @@ def get_tables( usage: str = "", section: str = "", ) -> dict: - return _call(store.tables, file, group, sub, detail, q, page, size, usage, section) + out = _call(store.tables, file, group, sub, detail, q, page, size, usage, section) + for t in out["tables"]: # 기준배 · 기준단위 — 읽을 때 파생(정본 표에 안 씀 · 712-3) + t["기준파생"] = _num(mbs.basis(t)) + return out @router.get("/table") def get_table(file: str, key: str) -> dict: - return _call(store.table, file, key) + """`기준파생` 은 표 밖 칸 — 표 줄째 되돌려 저장해도 정본에 안 섞임.""" + out = _call(store.table, file, key) + return {**out, "기준파생": _num(mbs.basis(out["table"]))} @router.get("/table/options") diff --git a/resources/master_data/scripts/check_master.py b/resources/master_data/scripts/check_master.py index 810bc54a3..daf4d4019 100644 --- a/resources/master_data/scripts/check_master.py +++ b/resources/master_data/scripts/check_master.py @@ -8,6 +8,7 @@ (2) 본문(`check_master_body.py`) — 표형 요소의 출처 절을 본문 md 에서 찾아 수를 맞댐 · 결손(본문 표에 있고 요소에 없음)·허구(요소에 있고 본문 절에 없음) (3) 로직 — 변수가 가리키는 요소·표·칸 · 찾기 조건 이름 · 로직 입력 · 재료 고르기 조건 · 돌고 도는 참조 · 끊긴 키 (4) 조합 — 일위대가 조합이 담은 로직 키 · 같은 로직 두 번 · 빈 조합 · 조합을 담음 (`master_combo.py`) +(배) 로직 결과단위 배 ↔ 부르는 표 기준배(10㎡당 표를 원/㎡ 로직이 「/ 10」 없이 씀) (`master_basis.py`) (5) 계산 — 로직 키와 입력값으로 호표 줄별 수량·단가·금액 · 비목 합 (`master_formula.py`) 수 뽑기는 `resources/knowledge/original/_pipeline/pum_md_tool.py` 의 것을 씀. """ @@ -27,6 +28,7 @@ COST = ROOT / "resources/knowledge/original/원가계산" sys.path.insert(0, str(HERE)) sys.path.insert(0, str(ROOT / "resources/knowledge/original/_pipeline")) +import master_basis as mbs # noqa: E402 import master_combo as mcb # noqa: E402 import master_formula as mf # noqa: E402 import master_keys as mk # noqa: E402 @@ -556,6 +558,8 @@ def main(argv: list[str]) -> int: report["조합"] = mcb.check_all(files, whole) if mode in ("단위", "전부"): report["단위"] = check_units(files, whole) + if mode in ("배", "전부"): # 결과단위 배 ↔ 부르는 표 기준배(읽을 때 파생 · `master_basis`) + report["배"] = mbs.check_times(files) for title, found in report.items(): print(f"({title}) {len(found)}건") for line in found: diff --git a/resources/master_data/scripts/master_basis.py b/resources/master_data/scripts/master_basis.py new file mode 100644 index 000000000..cf60c6928 --- /dev/null +++ b/resources/master_data/scripts/master_basis.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +"""표 기준 → 「기준배 · 기준단위」 — **읽을 때 파생**(정본 표 파일에 쓰지 않음 · PLAN 712-3). + +- 표 `기준` 글(「10㎡당」 · 「인/100본당」 · 「ton 당」)을 배 · 단위로 읽음. +- `기준` 이 비었으면 원문 절 머리(「(단위: ㎥당)」 · 「(일당)」)에서 + — 절과 아래 절 머리가 한 꼴일 때만. +- 단위가 아닌 기준(「인력품」 · 「할증」 · 「정의」)은 `갈래` 「비단위」 · 못 읽음은 「모름」. +- 배 대조(`check_times`) = 로직 결과단위 배(원/10㎡ → 10) ↔ 그 로직이 부르는 표 기준배 + (`check_master` 「배」). +""" + +from __future__ import annotations + +import re +from decimal import Decimal +from functools import lru_cache + +from check_master_body import section_of + +# ponytail: 단위 별칭은 여기 짧은 표 — 712-2 단위 모듈이 서면 그쪽 별칭으로 갈아 끼움 +_ALIAS = {"ea": "개", "톤": "ton", "t": "ton", "㎞": "km", "hr": "시간", "min": "분", "㎏": "kg"} +_UNITS = sorted( + ( + "㎡ ㎥ ㎢ ㎞ km ㎜ mm ㎝ cm m ha ton 톤 t kg ㎏ 개소 개 본 대 회 조 기 틀 " + "일 시간 hr 분 min 초 매 주 도엽 도곽 필지 점 발파 인 노선 공 경간 련 식 " + "개월 월 년 가닥 면 장 측점 세트 set 호 층 단계 ea" + ).split(), + key=len, + reverse=True, +) +_UNIT_AT_END = re.compile( + rf"(? str: + return _ALIAS.get(text, text) + + +def _times(text: str | None) -> Decimal: + return Decimal(text.replace(",", "")) if text else Decimal(1) + + +def parse(text) -> dict | None: + """기준 글 → `{배, 단위}` · 단위 아닌 글이면 None.""" + core = re.split(r"\s+[—-]\s+", str(text or "").strip())[0] + core = re.sub(r"\([^()]*\)", "", core).strip() + if not core or core.startswith("단위"): # 「단위: 인」 = 값 칸 단위(기준 아님) + return None + if re.search( + r",\s|[∼~]", core + ): # 「구하는점 5점, 주어진점 3점」 · 「400필지∼450필지」 = 조건 글 + return None + core = core.rsplit("/", 1)[-1].strip() # 「인/100본당」 → 「100본당」(자원/기준) + if "당" in core: # 「1필지당 1,500㎡」 · 「1대당 높이 2m 기준」 → 「당」 앞까지 + core = core[: core.rfind("당")].strip() + m = _UNIT_AT_END.search(core) + if not m: + return None + one, at = m.group(2), m.start(2) + if len(one) == 1 and _HANGUL.match(one) and _HANGUL.match(core[at - 1 : at] or " "): + return None # 「참조」 의 「조」 — 한 글자 한글 단위가 낱말 끝이면 단위 아님 + return {"배": _times(m.group(1)), "단위": unit(m.group(2))} + + +@lru_cache(maxsize=None) +def _heads(source: str) -> tuple[str, ...]: + """원문 절(과 아래 절) 머리 줄 글 — 차례대로 · 겹침 뺌.""" + _, _, text = section_of(source) + found = (_HEAD.match(line.strip()) for line in text.split("\n")) + return tuple(dict.fromkeys(m.group(1).strip() for m in found if m)) + + +def basis(table: dict) -> dict: + """표 하나의 파생 칸 — `{기준배, 기준단위, 기준근거, 갈래}`(갈래 = 단위 · 비단위 · 모름).""" + said = str(table.get("기준") or "").strip() + if said: + got = parse(said) + if got: + return {"기준배": got["배"], "기준단위": got["단위"], "기준근거": "표", "갈래": "단위"} + return {"기준배": None, "기준단위": None, "기준근거": "표", "갈래": "비단위"} + heads = [h for h in _heads(str(table.get("출처") or "")) if parse(h)] + picks = {(parse(h)["배"], parse(h)["단위"]) for h in heads} + if len(picks) == 1: # 절 머리가 한 꼴일 때만 — 둘 넘으면 손 확인 + times, one = picks.pop() + return { + "기준배": times, + "기준단위": one, + "기준근거": f"원문 「{heads[0]}」", + "갈래": "단위", + } + return {"기준배": None, "기준단위": None, "기준근거": None, "갈래": "모름"} + + +def result_per(result_unit) -> list[tuple[Decimal, str]]: + """로직 결과단위 분모 — 「원/10㎡」 → [(10, ㎡)] · 여럿이면 여럿.""" + return [(_times(n), unit(u)) for n, u in _RESULT.findall(str(result_unit or ""))] + + +def _formula_text(row: dict) -> str: + return str([row.get(k) for k in ("중간", "호표", "덧줄")]) + + +def _divides(text: str, times: Decimal) -> bool: + """식이 그 배로 나누거나 곱하는가 — 「/ 10」 · 「÷10」 · 「* 0.1」.""" + n = format(times.normalize(), "f") + inverse = format((1 / times).normalize(), "f") + pattern = rf"(?:[/÷]\s*{re.escape(n)}|\*\s*{re.escape(inverse)})(?![\d.])" + return re.search(pattern, text) is not None + + +def check_times(files: dict[str, dict]) -> list[str]: + """배 어긋남 — 로직이 부르는 표(`용도.로직키`)의 기준배 ≠ 1 인데 결과단위가 같은 배가 아니고 + 식에서도 그 배로 나누지 않는 짝(시스템 관리자 원문 확인 거리).""" + logic_rows = { + str(r.get("키")): r + for d in files.values() + if d.get("그룹") == "로직" + for r in d.get("줄") or [] + } + out = [] + for name, data in files.items(): + for table in data.get("표") or []: + got = basis(table) + if got["갈래"] != "단위" or got["기준배"] == 1: + continue + for key in (table.get("용도") or {}).get("로직키") or []: + row = logic_rows.get(str(key)) + if row is None: + continue + per = result_per(row.get("결과단위")) + shown = format(got["기준배"].normalize(), "f") + said = str(row.get("결과단위") or "") # 「원/보양길이 100m」 + if ( + (got["기준배"], got["기준단위"]) in per + or f"{shown}{got['기준단위']}" in said + or _divides(_formula_text(row), got["기준배"]) + ): + continue + out.append( + f"{key} · 결과단위 「{row.get('결과단위')}」 ↔ 표 {table.get('키')}" + f" 기준 「{shown}{got['기준단위']}」 — 배 어긋남" + ) + return out + + +def edition(text) -> str: + """파일 머리 판 → 해(「2026-01-01」 · 「2026」 → 「2026」) · 해 없으면 그대로(파일 안 씀).""" + m = re.match(r"\s*(\d{4})(?:\D|$)", str(text or "")) + return m.group(1) if m else str(text or "") diff --git a/resources/tester/test_712_3_basis.py b/resources/tester/test_712_3_basis.py new file mode 100644 index 000000000..58093a347 --- /dev/null +++ b/resources/tester/test_712_3_basis.py @@ -0,0 +1,79 @@ +"""712-3 지킴 — 표 기준 → 기준배 · 기준단위 읽을 때 파생 · 배 대조 · 정본 안 씀(`master_basis`).""" + +from __future__ import annotations + +import json +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "resources/master_data/scripts")) +sys.path.insert(0, str(ROOT)) + +import check_master as cm # noqa: E402 +import master_basis as mbs # noqa: E402 + + +def _p(text): + got = mbs.parse(text) + return None if got is None else (got["배"], got["단위"]) + + +def test_parse_forms(): + assert _p("10㎡당") == (Decimal(10), "㎡") + assert _p("인/100본당") == (Decimal(100), "본") + assert _p("ton 당") == _p("톤당") == _p("1t") == (Decimal(1), "ton") + assert _p("1,000본당") == (Decimal(1000), "본") + assert _p("1필지당 1,500㎡") == (Decimal(1), "필지") + assert _p("일당 — 편성 1조 기준") == (Decimal(1), "일") + assert _p("세면기 개당") == (Decimal(1), "개") + assert _p("바닥면적㎡당") == (Decimal(1), "㎡") + for text in ( + "인력품", + "정의", + "할증", + "참조", + "Joint당", + "단위: 인", + "구하는점 5점, 주어진점 3점", + ): + assert _p(text) is None, text + + +def test_fill_ratio_and_no_write(): + files = cm.load() + before = json.dumps(files, default=str, ensure_ascii=False) + called = [ + mbs.basis(t) + for d in files.values() + for t in d.get("표") or [] + if (t.get("용도") or {}).get("로직키") + ] + unit = sum(1 for b in called if b["갈래"] == "단위") + from_body = sum(1 for b in called if str(b["기준근거"] or "").startswith("원문")) + assert unit / len(called) >= 0.78 # 로직이 부르는 표 2,023 중 1,601(79 %) + assert from_body >= 280 # 빈 기준 579 중 원문 머리로 채움 285 + assert json.dumps(files, default=str, ensure_ascii=False) == before # 읽기만 + + +def test_times_mismatch_list(): + """배 어긋남 = 시스템 관리자 원문 확인 거리 — 늘거나 줄면 PLAN 712-3 목록도 고침.""" + found = {line.split(" · ")[0] for line in mbs.check_times(cm.load())} + known = {"GC000482", "GC000483", "GC000484", "GC000485", "GF000081"} + assert found == known | {"GC000522", "GC000524"} + + +def test_edition(): + assert mbs.edition("2026-01-01") == mbs.edition("2026") == "2026" + assert mbs.edition(None) == "" + + +def test_router_basis_outside_table(): + from M01_MasterData import M01_MasterData_Router as router + + got = router.get_table("소요량_산림품셈_13장_구조물.json", "QF000443") + assert got["기준파생"]["기준배"] == 10 and got["기준파생"]["기준단위"] == "㎡" + assert "기준배" not in got["table"] and "기준파생" not in got["table"] + listed = router.get_tables(file="소요량_산림품셈_13장_구조물.json", q="QF000443") + assert listed["tables"][0]["기준파생"]["기준배"] == 10