From 81af27f0e822924bdf8e4d3ed3c9f0e458aa0ad5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 12:48:29 +0900 Subject: [PATCH 1/2] =?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 "" From 47943f2990e0da9760b3346fe8b9108d00a3ac1e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 12:48:34 +0900 Subject: [PATCH 2/2] auto: 2026-09-08 12:48 (EOMSANGDON-HOME) --- B08_Quantity/B08_Quantity_Engine_Handoff.py | 72 +++++++++++++++++-- .../work_item_mapping_2026-01-01.json | 10 +++ 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index f26afd6d..72d65abb 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -45,6 +45,10 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Iterable +from B08_Quantity.B08_Quantity_Engine_BasisUnit import ( + unit_for_code, + verify_unit_matches_basis, +) from B08_Quantity.B08_Quantity_Engine_Preparation import ( STATUS_COUNTED_ELSEWHERE as PREP_COUNTED_ELSEWHERE, ) @@ -515,6 +519,7 @@ def _earthwork_rows( # 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양). "variant_axis": None, "variant_value": None, + "secondary_axes": None, "spec_class": None, "spec_class_basis": "", # 토공 줄은 막힐 자리가 없다 — 그래도 **칸은 둔다**(계약이 한 모양이어야 한다). @@ -570,6 +575,7 @@ def _haul_rows( # 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양). "variant_axis": None, "variant_value": None, + "secondary_axes": None, "spec_class": None, "spec_class_basis": "", "blocked_kind": None, @@ -617,6 +623,29 @@ def blocked_of( return None, "" +def _component_billing( + structure: dict[str, Any], entry: dict[str, Any] +) -> tuple[str, float] | None: + """(내역 단위, 그 단위로 센 수량) — **전개 성분 하나**에서 가져온다. 없으면 `None`. + + ⚠ **왜 성분에서 가져오나** — 돌쌓기 면적은 이미 전개가 냈다(비탈면적 = 정면적 × + √(1+n²)). 여기서 다시 재면 **같은 식이 두 벌**이 되고 한쪽만 고쳐지는 자리가 된다. + ⚠ **어느 성분인지는 매핑이 말한다** — 단위만 보고 고르면 거푸집 같은 다른 ㎡ 성분을 + 집는다. + """ + name = str(entry.get("billing_component") or "") + if not name: + return None + for component in structure.get("components") or []: + if str(component.get("name") or "") != name: + continue + unit = str(component.get("unit") or "") + amount = float(component.get("amount") or 0.0) + if unit and amount > 0: + return unit, amount + return None + + def _structure_rows( unit_quantity_table: dict[str, Any], mapping: WorkItemMapping ) -> tuple[list[dict[str, Any]], list[str]]: @@ -651,16 +680,31 @@ def _structure_rows( if code and entry.get("class_from") == "back_length": class_key, class_basis = masonry_class(structure.get("options") or {}) + # ⚠ 품셈 밑수가 「㎡당」인 공종은 **연장으로 세면 안 된다** — 받는 쪽이 ㎡ 단가를 + # m 수량에 곱해 **2.6배** 금액이 섰다(2026-09-08 실증). 어느 성분으로 세는지는 + # 매핑이 말한다(`billing_component`) — 코드가 짐작하지 않는다. + billing = _component_billing(structure, entry) composite = mapping.composite_for(type_id) if code is None else None kind = structure_kind(structure) if composite else None parts: list[dict[str, Any]] | None = None parts_missing: list[dict[str, Any]] = [] if composite: parts, parts_missing = composite_quantities(structure, composite, mapping) - blocked_kind, blocked_reason = blocked_of(structure, class_basis, has_code=bool(code)) + # ⚠ 매핑이 「이 성분으로 센다」고 했는데 그 성분이 없으면 **연장으로 세지 않는다** — + # 세면 다시 틀린 축으로 금액이 선다. 코드가 없는 것과 같이 보아 사유를 찾는다. + counts_by_component = bool(entry.get("billing_component")) + blocked_kind, blocked_reason = blocked_of( + structure, + class_basis, + has_code=bool(code) and (billing is not None or not counts_by_component), + ) # 갈래 축과 **저장 제원 원본값**. 가공하지 않는다. variant_axis = str(entry.get("variant_axis") or "") or None variant_value = (structure.get("options") or {}).get(variant_axis) if variant_axis else None + secondary_axes = [ + {"axis": axis, "value": (structure.get("options") or {}).get(axis)} + for axis in entry.get("secondary_axes") or [] + ] if code is None and composite is None: unmatched.append(f"{wording_type_label(type_id)} — 품셈 공종을 아직 못 이었습니다") elif entry.get("class_from") in ("back_length", "bond") and class_key is None: @@ -669,12 +713,17 @@ def _structure_rows( # ⚠ 관측 원단위가 「개소당」·「㎡당」인 종류는 **연장으로 세면 축이 어긋난다** — # 집수정 한 개소가 연장 2m 면 값이 두 배로 실린다(2026-09-08 ㉕ 실증). # 성분은 개소 기준으로 맞게 서는데 **줄의 축만** 틀렸던 자리다. - bill_unit = str(structure.get("billing_unit") or "") or "m" - bill_quantity = ( - float(structure.get("billing_quantity") or 0.0) - if structure.get("billing_unit") - else length - ) + if billing is not None: + bill_unit, bill_quantity = billing + elif counts_by_component: + # ⚠ 물량이 못 섰다 — **연장으로 대신 세지 않는다.** 단위는 품셈 밑수를 그대로 + # 실어 둔다(「0 m」로 내면 받는 쪽이 길이로 읽고 축이 어긋난 채 채워진다). + bill_unit, bill_quantity = unit_for_code(str(code or "")), 0.0 + elif structure.get("billing_unit"): + bill_unit = str(structure["billing_unit"]) + bill_quantity = float(structure.get("billing_quantity") or 0.0) + else: + bill_unit, bill_quantity = "m", length rows.append( { "work_item_code": code, @@ -707,6 +756,9 @@ def _structure_rows( # 저장 원본값**만 보내고, 원문을 읽는 쪽이 그 표기를 흡수한다. "variant_axis": variant_axis, "variant_value": variant_value, + # ⚠ 갈래 축이 **둘 이상**인 자리 — 돌쌓기는 뒷길이와 **돌 종류**로 갈린다. + # 여기서도 **저장 원본값만** 싣는다(키 조립은 원문 읽는 쪽 몫). + "secondary_axes": secondary_axes or None, "spec_class": class_key, "spec_class_basis": class_basis, # ⚠ 물량을 못 채운 조각 — 0 으로 적지 않고 사유와 함께 드러낸다. @@ -788,6 +840,7 @@ def _placing_rows( "blocked_reason": "", "variant_axis": "structure_kind", "variant_value": kind, + "secondary_axes": None, "spec_class": kind, "spec_class_basis": ( "철근이 있으면 철근구조물, 없으면 무근구조물 — 원단위로 자동 판정" @@ -849,6 +902,7 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]] "blocked_reason": "" if ready else str(row.get("reason") or status), "variant_axis": None, "variant_value": None, + "secondary_axes": None, "spec_class": None, "spec_class_basis": str(row.get("reason") or ""), "composite_not_ready": None, @@ -907,6 +961,7 @@ def _pipe_rows(pipe_table: dict[str, Any]) -> list[dict[str, Any]]: # 갈래는 **저장 원본값**만 — 「…㎜ 이하」 구간 나누기는 원문을 읽는 쪽 몫. "variant_axis": row.get("variant_axis"), "variant_value": row.get("variant_value"), + "secondary_axes": None, "spec_class": None, "spec_class_basis": str(row.get("blocked_reason") or ""), "composite_not_ready": None, @@ -986,6 +1041,7 @@ def _length_rows( "blocked_reason": reason, "variant_axis": None, "variant_value": None, + "secondary_axes": None, "spec_class": None, "spec_class_basis": note, "composite_not_ready": None, @@ -1107,6 +1163,8 @@ def build_handoff( result["ratio_math_warnings"] = verify_ratio_math(result) result["material_code_warnings"] = verify_no_code_on_materials(result) result["bill_flag_warnings"] = verify_bill_flags(result) + # ⚠ 보내는 단위가 **품셈 밑수**와 같은가 — 받는 쪽이 그대로 곱하는 자리다(2026-09-08). + result["basis_unit_warnings"] = verify_unit_matches_basis(work_items) result["placing_notes"] = placing_notes return result diff --git a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json index c041c79a..f1a2da69 100644 --- a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json +++ b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json @@ -116,6 +116,10 @@ "structure": [ { "type_id": "masonry_wet", + "secondary_axes": ["stone_kind"], + "secondary_axes_note": "⚠ 돌 종류는 **갈래 축이 하나 더**인 자리다. 품셈 13-4-2·13-4-5 [주]② 가 「본 품은 **깬돌 및 깬 잡석**의 돌쌓기 기준」이라 못 박고, 돌 종류로 갈리는 표는 **13-5 돌붙임**에 따로 있다(뒷길이 7 × 돌종류 6). 어느 쪽 공종으로 볼지는 **사용자 확정 대기** — 여기서 공종을 바꾸지 않고 **저장 원본값만 실어 보낸다**(2026-09-08 계약과 같은 방식).", + "billing_component": "돌쌓기", + "billing_note": "⚠ 품셈 밑수가 「㎡당」이라 **연장(m)으로 세면 안 된다** — 받는 쪽이 ㎡ 단가를 m 수량에 곱해 금액이 2.6배로 섰다(2026-09-08 실증: 10m × 52,938.9 = 529,389원 / 26.101㎡ × 52,938.9 = 1,381,753원). 내역 줄 수량은 이 성분(비탈면적)으로 센다. 고임돌·야면석·막자갈은 **자재 축**으로 따로 가므로 여기서 빠지지 않는다.", "work_item_code": "FP-13-04-05", "master_name": "돌쌓기 > 찰쌓기(장비)", "note": "인력 시공이면 FP-13-04-04", @@ -124,6 +128,10 @@ }, { "type_id": "masonry_dry", + "secondary_axes": ["stone_kind"], + "secondary_axes_note": "⚠ 돌 종류는 **갈래 축이 하나 더**인 자리다. 품셈 13-4-2·13-4-5 [주]② 가 「본 품은 **깬돌 및 깬 잡석**의 돌쌓기 기준」이라 못 박고, 돌 종류로 갈리는 표는 **13-5 돌붙임**에 따로 있다(뒷길이 7 × 돌종류 6). 어느 쪽 공종으로 볼지는 **사용자 확정 대기** — 여기서 공종을 바꾸지 않고 **저장 원본값만 실어 보낸다**(2026-09-08 계약과 같은 방식).", + "billing_component": "돌쌓기", + "billing_note": "⚠ 품셈 밑수가 「㎡당」이라 **연장(m)으로 세면 안 된다** — 받는 쪽이 ㎡ 단가를 m 수량에 곱해 금액이 2.6배로 섰다(2026-09-08 실증: 10m × 52,938.9 = 529,389원 / 26.101㎡ × 52,938.9 = 1,381,753원). 내역 줄 수량은 이 성분(비탈면적)으로 센다. 고임돌·야면석·막자갈은 **자재 축**으로 따로 가므로 여기서 빠지지 않는다.", "work_item_code": "FP-13-04-02", "master_name": "돌쌓기 > 메쌓기(장비)", "note": "인력 시공이면 FP-13-04-01", @@ -142,6 +150,8 @@ }, { "type_id": "boulder_masonry", + "billing_component": "큰돌쌓기", + "billing_note": "⚠ 품셈 밑수가 「㎡당」이라 **연장(m)으로 세면 안 된다** — 받는 쪽이 ㎡ 단가를 m 수량에 곱해 금액이 2.6배로 섰다(2026-09-08 실증: 10m × 52,938.9 = 529,389원 / 26.101㎡ × 52,938.9 = 1,381,753원). 내역 줄 수량은 이 성분(비탈면적)으로 센다. 고임돌·야면석·막자갈은 **자재 축**으로 따로 가므로 여기서 빠지지 않는다. ⚠ 13-6 은 밑수가 **10㎡** 다 — 받는 쪽이 마스터의 `basis_quantity` 를 함께 봐야 한다.", "work_item_code": null, "class_from": "bond", "bond_codes": {