From c12a2bf8262ea1eb8cec59e89811e624699de6c0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 11:07:57 +0900 Subject: [PATCH] =?UTF-8?q?fix(b08):=20=EA=B0=88=20=EA=B3=B3=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=EA=B0=92=20=EA=B8=88=EC=A7=80=20=E2=80=94=20=ED=91=9C?= =?UTF-8?q?=EC=97=90=20=EC=97=86=EB=8A=94=20=EC=84=B1=EB=B6=84=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EC=9D=80=20=EB=A7=89=EA=B3=A0=20=EC=82=AC=EC=9C=A0(?= =?UTF-8?q?=EC=A2=85=EC=A0=84=20quantity=20=EB=A1=9C=20=EB=96=A8=EC=96=B4?= =?UTF-8?q?=EC=A0=B8=20=EC=A1=B0=EC=9A=A9=ED=9E=88=20=EB=B9=A0=EC=A7=90)?= =?UTF-8?q?=20=C2=B7=20=EB=93=9C=EB=9F=AC=EB=82=9C=20=EB=84=B7(=EC=9D=B4?= =?UTF-8?q?=ED=98=95=EC=B2=A0=EA=B7=BC=20D13=C2=B7=EB=A9=B4=EB=AA=A9=C2=B7?= =?UTF-8?q?=EB=96=BC=20=EC=9E=90=EC=9E=AC=20=C2=B7=20=EC=9C=A0=EB=A1=9C?= =?UTF-8?q?=ED=8F=BC=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80)=20=ED=91=9C?= =?UTF-8?q?=EC=97=90=20=EC=98=AC=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../B08_Quantity_Engine_UnitQuantity_Base.py | 27 ++++++++++ ..._Quantity_Engine_UnitQuantity_Revetment.py | 27 ++++++---- .../tester/test_b08_destination_no_default.py | 54 +++++++++++++++++++ 3 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 resources/tester/test_b08_destination_no_default.py diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py index 5bd4177b..ae11a11b 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py @@ -51,8 +51,35 @@ DESTINATION = { # 기초잡석(품셈 12-25) — 운반·부설·다짐 품이 붙는 **공종**이라 일위대가로 간다. # 자재총괄로 보내면 같은 잡석이 재료로 한 번 더 선다. RUBBLE_BASE_NAME: "unit_price", + # ⭐ 2026-09-14 — 갈 곳 기본값을 걷자 드러난 넷(L형수로·개거·떼흙막이 정본 줄). + # 관측 원단위(`structure_unit_observed`)가 같은 이름에 둔 갈 곳과 맞춤(두 벌이 안 갈리게). + # ⚠ 「이형철근 D13」 은 이름에 규격이 섞인 글자 그대로 — 인계 대응표 `from_components` 가 + # 그 글자로 조각을 찾아 지금 가르면 옹벽 철근 조각이 끊김(이름 가르기는 대응표와 함께 따로). + "이형철근 D13": "material", + "유로폼": "unit_price", # 거푸집 계열(품셈 12-38) — 설치·해체 품 + "면목": "material", + "떼": "material", # 사면 떼(자재총괄 extra)와 같은 자리 · 할증 10%(1-3-1) } +#: ⛔ **갈 곳 기본값 금지**(명세 13장 「빠진 것이 조용해짐」) — 표에 없는 이름은 막고 사유를 냄. +#: 종전엔 `DESTINATION.get(name, "quantity")` 로 떨어져 **아무 축에도 안 가고** 자재총괄 +#: 건너뛴 수로만 셈(2026-09-14 L형수로 이형철근·떼흙막이 떼가 통째로 빠져 있던 자리). +DESTINATION_MISSING = ( + "「{name}」 갈 곳이 표(`DESTINATION`)에 없어 물량을 어느 축에도 안 보냄" + " — 표에 올릴 것(명세 13장 갈 곳 기본값 금지)" +) + + +def routed(name: str, notes: list[str]) -> str | None: + """성분 이름의 갈 곳 — 표에 없으면 `None` 이고 사유 한 줄을 `notes` 에 더함(이름마다 한 번).""" + destination = DESTINATION.get(name) + if destination is None: + text = DESTINATION_MISSING.format(name=name) + if text not in notes: + notes.append(text) + return destination + + # ⚠ 배합 성분 — 산출물에 나타나면 안 된다(㉢). B09 일위대가가 배합표로 분해한다. # ⚠ **정확히 같은 이름**으로만 본다. 부분문자열로 재면 `막자갈`(뒤채움 재료)이 배합 `자갈` 로 # 오탐된다 — 개발 중 실제로 걸렸던 자리다. diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py index f138f672..3b84fb2b 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py @@ -25,6 +25,7 @@ from typing import Any from B08_Quantity.B08_Quantity_Engine_StructureSheet import REVETMENT_STONE_FORMS from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, _num, stone_masonry +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import routed #: 형태 → 돌쌓기 갈래(찰이면 True). 여기 없는 형태는 전개식이 없다. #: ⚠ 구조물도 기울기·그림이 같은 표를 씀(`StructureSheet.REVETMENT_STONE_FORMS` 한 벌). @@ -369,8 +370,9 @@ def erosion_check_dam( ) components = [ - Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis, spec="".join(spec)) + Component(name, unit, amount, destination, basis, spec="".join(spec)) for name, unit, amount, basis, *spec in rows + if (destination := routed(name, notes)) ] mpa, mpa_basis = fill_concrete_mpa(options) fill_per = coeff["fill_concrete_m3_per_m2"] @@ -489,7 +491,6 @@ def soil_guard(options: dict[str, Any]) -> tuple[list[Component], list[str]]: ⚠ 치수가 **전부 평균 붙박이**다 — 정본 표에 「(평균)」이라 적혀 있고 개소별 치수조서가 따로 없다. 그래서 높이·길이를 받지 않는다. 개소별로 갈리게 하려면 치수 칸이 먼저다. """ - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION form = str(options.get("form") or "").strip() if not form: @@ -501,13 +502,16 @@ def soil_guard(options: dict[str, Any]) -> tuple[list[Component], list[str]]: "— 흙막이는 앞면 1:0.3·뒷면 수직·천단 30㎝(교본)라 축이 다르고 근거가 없습니다" ] + notes: list[str] = [] components = [ - Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis) + Component(name, unit, amount, destination, basis) for name, unit, amount, basis in SOIL_GUARD_SOD["rows"] + if (destination := routed(name, notes)) ] return components, [ + *notes, f"떼흙막이는 **{SOIL_GUARD_SOD['unit_label']}**이고 치수가 정본 평균 붙박이입니다" - "(상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54m) — 개소 제원을 안 봅니다" + "(상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54m) — 개소 제원을 안 봅니다", ] @@ -516,7 +520,6 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component ⚠ 규격을 고를 칸이 없어 지금은 **「콘크리트 개거 150×200」 붙박이**다. 그 사실을 줄로 낸다. """ - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION if length_m <= 0: from B08_Quantity.B08_Quantity_Wording import option_missing @@ -529,17 +532,18 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component f"개거 규격 「{spec}」 원단위가 없습니다 — 있는 것: {' · '.join(OPEN_DITCH_FORMS)}" ] + notes: list[str] = [] components = [ Component( name, unit, per_m * length_m, - DESTINATION.get(name, "quantity"), + destination, f"{basis} × 연장 {length_m:g}m (m당 {per_m:g})", ) for name, unit, per_m, basis in table["rows"] + if (destination := routed(name, notes)) ] - notes: list[str] = [] if not options.get("ditch_spec"): notes.append(f"규격을 안 골라 「{spec}」으로 섰습니다 — 제원에서 고를 수 있습니다") if spec == "콘크리트 개거 150×200": @@ -562,7 +566,6 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component], 등록부 밑수도 `area_m2` 라 축이 그대로 맞는다. ⚠ 기울기 몫이 **없다** — 평면에 붙이는 것이라 √(1+n²) 를 곱하지 않는다. """ - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION form = str(options.get("form") or "").strip() table = BED_SILL_FORMS.get(form) @@ -607,11 +610,15 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component], # 터파기 — 정본은 「두께 × 1 × 1」이라 **면적 × 두께**와 같다. rows.append(("터파기", "㎥", area_m2 * back, f"면적 × 두께 {back:g}m")) + notes: list[str] = [] components = [ - Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis, spec="".join(spec)) + Component(name, unit, amount, destination, basis, spec="".join(spec)) for name, unit, amount, basis, *spec in rows + if (destination := routed(name, notes)) ] - notes = [f"뒷길이 {back:g}m 는 정본 탭 붙박이 값입니다 — 바닥막이 제원에 뒷길이 칸이 없습니다"] + notes.append( + f"뒷길이 {back:g}m 는 정본 탭 붙박이 값입니다 — 바닥막이 제원에 뒷길이 칸이 없습니다" + ) if form == "돌붙임(찰)": notes.append( "버림 콘크리트 0.1 ㎥/㎡ 포함 — 정본 주기 「바닥 10㎝ 이상 콘크리트(버림) 포설 후 " diff --git a/resources/tester/test_b08_destination_no_default.py b/resources/tester/test_b08_destination_no_default.py new file mode 100644 index 00000000..8e540db1 --- /dev/null +++ b/resources/tester/test_b08_destination_no_default.py @@ -0,0 +1,54 @@ +"""갈 곳(`destination`) 기본값 금지 — 명세 13장 「⛔ 기본값 금지(빠진 것이 조용해짐)」 (2026-09-14). + +종전 `DESTINATION.get(name, "quantity")` 는 표에 없는 이름을 **아무 축에도 안 가는 칸**으로 떨어뜨려 +L형수로 이형철근·면목 · 개거 유로폼·면목 · 떼흙막이 떼가 자재총괄에서 통째로 빠져 있었음. + +지키는 것 + ① 표에 없는 이름은 성분이 안 서고 **사유가 뜸**(조용히 새지 않음) + ② 정본 표(개거·떼흙막이·바닥막이)의 모든 줄 이름이 갈 곳 표에 있음 — 줄이 늘면 여기서 걸림 + ③ 드러난 넷이 제자리로 감(관측 원단위와 같은 갈 곳) +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import DESTINATION, routed # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import ( # noqa: E402 + BED_SILL_FORMS, + OPEN_DITCH_FORMS, + SOIL_GUARD_SOD, + open_ditch, + soil_guard, +) + + +def test_표에_없는_이름은_막히고_사유가_뜸() -> None: + notes: list[str] = [] + assert routed("낯선성분", notes) is None + assert routed("낯선성분", notes) is None + assert len(notes) == 1 and "갈 곳이 표" in notes[0] and "기본값 금지" in notes[0] + assert routed("터파기", notes) == "earthwork" and len(notes) == 1 + + +def test_정본_표의_줄_이름은_전부_갈_곳_표에_있음() -> None: + names = {row[0] for table in OPEN_DITCH_FORMS.values() for row in table["rows"]} + names |= {row[0] for row in SOIL_GUARD_SOD["rows"]} + names |= {table["stone_name"] for table in BED_SILL_FORMS.values()} + assert sorted(name for name in names if name not in DESTINATION) == [] + + +def test_드러난_넷이_제자리로_감() -> None: + ditch, notes = open_ditch(10.0, {"ditch_spec": "L형수로 H=0.2"}) + got = {component.name: component.destination for component in ditch} + assert got["이형철근 D13"] == "material" and got["면목"] == "material" + assert not [note for note in notes if "갈 곳이 표" in note] + plain, _ = open_ditch(10.0, {}) + assert {c.name: c.destination for c in plain}["유로폼"] == "unit_price" + sod, sod_notes = soil_guard({"form": "떼"}) + assert {c.name: c.destination for c in sod}["떼"] == "material" + assert not [note for note in sod_notes if "갈 곳이 표" in note]