From 81af27f0e822924bdf8e4d3ed3c9f0e458aa0ad5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 12:48:29 +0900 Subject: [PATCH] =?UTF-8?q?fix(B08):=20=EB=8F=8C=EC=8C=93=EA=B8=B0=20?= =?UTF-8?q?=EC=9D=B8=EA=B3=84=EB=A5=BC=20=ED=92=88=EC=85=88=20=EB=B0=91?= =?UTF-8?q?=EC=88=98(=E3=8E=A1)=EB=A1=9C=20=EC=84=B8=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=A0=95=EC=A0=95=20+=20=EB=B0=91=EC=88=98=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=EB=8C=80=EC=A1=B0=20=EA=B0=80=EB=93=9C=20=EC=8B=A0?= =?UTF-8?q?=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠ 실제로 금액이 틀리던 자리 — 돌쌓기(찰)를 `m · 10.0` 으로 보내는데 품셈 13-4-5 밑수는 ㎡ 라, 받는 쪽이 52,938.9원/㎡ 를 그대로 곱해 529,389원이 섰음. 면적으로 세면 26.101㎡ × 52,938.9 = 1,381,753원 — 2.6배. 양쪽 다 오류 없이 조용했음. - `billing_component` 를 매핑에 두고 **전개 성분에서** 내역 수량을 가져옴 (면적 식을 두 벌로 짜지 않음). 돌쌓기·큰돌쌓기 = ㎡ · 비탈면적. - 성분이 안 서면 **연장으로 대신 세지 않음** — 0 + 품셈 밑수 단위로 두고 사유를 냄. - 신설 `B08_Quantity_Engine_BasisUnit.py` — 인계 줄 단위를 **공종 마스터 밑수**와 대조해 `basis_unit_warnings` 로 냄. 표기 차이(㎥/m3·개소/개)만 흡수하고 **환산 안 함**. - 돌 종류를 둘째 갈래 축(`secondary_axes`)으로 실어 보냄 — 품셈 13-4 [주]② 가 「깬돌·깬잡석 전용」이고 돌 종류 표는 13-5 에 따로 있음. 공종 판단은 사용자 확정 대기. - 옛 시험이 「10.0 m」를 계약으로 못 박고 있어 정정(오늘 다섯 번째). 시험 732 통과 (B05 코리도 1건 기존 깨짐, 무관). Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_BasisUnit.py | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 B08_Quantity/B08_Quantity_Engine_BasisUnit.py diff --git a/B08_Quantity/B08_Quantity_Engine_BasisUnit.py b/B08_Quantity/B08_Quantity_Engine_BasisUnit.py new file mode 100644 index 00000000..677f9fa4 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_BasisUnit.py @@ -0,0 +1,116 @@ +"""품셈 밑수 단위 대조 — **보내는 단위가 그 공종의 밑수와 같은가** (2026-09-08). + +⚠ **왜 있나 — 실제로 금액이 틀렸다.** + 돌쌓기(찰)를 `m · 10.0` 으로 보내는데 품셈 13-4-5 밑수는 **㎡** 였다. 받는 쪽이 그 공종의 + 단가(52,938.9 원/㎡)를 그대로 곱해 **529,389원**이 섰다. 면적으로 세면 26.101㎡ × + 52,938.9 = **1,381,753원** — **2.6배 차이**인데 양쪽 다 오류가 안 났다. + +⚠ **오늘만 다섯 번째 「보내는 쪽과 받는 쪽 사이」 사고다** + 개소가 미터로 나간 것 · 준비공 표가 통째로 안 간 것 · B군 코드가 빈 것 · 성분 줄이 안 + 가는 것 · 이번 단위 불일치. **전부 조용했다.** 한쪽만 보는 시험은 이 자리를 못 잡는다 — + 보내는 값과 **품셈 원문**을 맞대야 잡힌다. 그래서 이 파일은 매핑이 아니라 **마스터**를 본다. + +⚠ **환산하지 않는다.** m 을 ㎡ 로 바꾸는 길은 없다(두께·기울기를 지어내야 한다). + 표기 차이(㎥/m3 · 개소/개)만 같은 것으로 보고, **뜻이 다른 단위는 드러낸다.** +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Iterable + +MASTER_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_work_item_master" +MASTER_PREFIX = "work_item_master_" + +#: 같은 뜻인데 표기만 다른 단위 — **환산이 아니라 표기 흡수**다. +UNIT_ALIASES = { + "m3": "㎥", + "m³": "㎥", + "m2": "㎡", + "m²": "㎡", + "개": "개소", + "본": "개소", + "kg": "㎏", + "ton": "t", + "톤": "t", +} + + +def normalize_unit(unit: str) -> str: + """표기만 다른 단위를 한 글자로 모은다. **뜻이 다른 단위는 안 건드린다.**""" + text = str(unit or "").strip() + return UNIT_ALIASES.get(text, text) + + +def load_master(path: Path | None = None) -> dict[str, Any]: + """공종 마스터. 파일이 없으면 빈 표 — 대조를 못 할 뿐 값은 그대로 간다.""" + target = path + if target is None: + files = sorted(MASTER_DIR.glob(MASTER_PREFIX + "*.json")) if MASTER_DIR.is_dir() else [] + target = files[-1] if files else None + if target is None or not target.is_file(): + return {} + return json.loads(target.read_text(encoding="utf-8")) + + +def basis_units(master: dict[str, Any] | None = None) -> dict[str, set[str]]: + """공종코드 → 그 공종 표들이 쓰는 밑수 단위 모음. + + ⚠ 한 공종에 표가 여럿이면 단위도 여럿이다(흄관은 「m」와 「개소」 둘 다 있다). + **하나로 줄이지 않는다** — 줄이면 맞는 단위를 틀렸다고 말하게 된다. + """ + found = master if master is not None else load_master() + out: dict[str, set[str]] = {} + for item in found.get("work_items") or []: + code = str(item.get("work_item_code") or "") + if not code: + continue + units = { + normalize_unit(table.get("basis_unit")) + for table in item.get("tables") or [] + if table.get("basis_unit") + } + if units: + out[code] = units + return out + + +def verify_unit_matches_basis( + rows: Iterable[dict[str, Any]], table: dict[str, set[str]] | None = None +) -> list[str]: + """⚠ 인계 줄의 단위가 품셈 밑수와 다르면 알린다. + + **막지는 않는다** — 여기서 줄을 빼면 「빠진 줄」이 되어 더 안 보인다. 사유를 내고 + 사람이 보게 한다. 코드가 없는 줄·수량이 없는 줄·마스터에 없는 코드는 대상이 아니다. + """ + known = table if table is not None else basis_units() + if not known: + return [] + out: list[str] = [] + for row in rows: + code = str(row.get("work_item_code") or "") + unit = normalize_unit(row.get("unit")) + if not code or not unit or code not in known: + continue + if not row.get("in_bill"): + continue + if unit in known[code]: + continue + expected = " · ".join(sorted(known[code])) + out.append( + f"{row.get('name') or code}({code}) — 보내는 단위 「{unit}」가 품셈 밑수 " + f"「{expected}」와 다릅니다. 그대로 곱하면 금액이 틀립니다" + ) + return out + + +def unit_for_code(code: str, table: dict[str, set[str]] | None = None) -> str: + """그 공종이 **한 가지 밑수 단위만** 쓰면 그 단위, 아니면 빈 문자열. + + ⚠ 물량이 못 서는 줄에도 **단위는 맞는 것**을 실으려고 쓴다 — 「0 m」로 내면 받는 쪽이 + 길이로 읽고, 나중에 값이 채워질 때 축이 어긋난 채로 선다. 여럿이면 고르지 않는다. + """ + known = table if table is not None else basis_units() + units = known.get(str(code or ""), set()) + return next(iter(units)) if len(units) == 1 else ""