diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 26072a38..b5769d65 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -162,6 +162,15 @@ def _revetment(): #: 전개식을 일부러 안 두는 종류 — 왜 안 두는지 사람이 읽게 적는다. EXPANDER_WITHHELD: dict[str, str] = {} +#: 전개식이 **개소당·㎡당**으로 값을 내는 종류의 내역 단위와 수량(2026-09-14 구조물도 그림에서 잡음). +#: ⚠ 안 적으면 「m · 연장」으로 읽혀 — 골막이·바닥막이(점 배치라 연장 0)는 **수량 0 이라 내역에 안 서고** +#: 구조물도가 전 줄 「단위당 못 냄」, 떼흙막이는 개소당 값이 연장으로 나뉘어 **떼 0.139/m** 로 보였음. +EXPANDER_BILLING: dict[str, Any] = { + "erosion_check": lambda options: ("개소", 1.0), # 정본 「골막이(찰)(치수조서연결)」 개소당 + "soil_guard": lambda options: ("개소", 1.0), # 정본 「떼흙막이」 개소당(평균 붙박이) + "bed_sill": lambda options: ("㎡", _num(options.get("area_m2"), 0.0)), # 정본 돌붙임 ㎡당 +} + #: 한 구조물이 **여러 내역 줄**을 낳는 자리. 배수관은 관 자체와 유입부 집수정이 따로 선다 #: (품셈도 관부설과 집수정을 다른 공종으로 둔다). 한 줄로 합치면 어느 쪽 물량인지 못 가른다. @@ -305,6 +314,9 @@ def expand( face, face_reason = structure_face_role(section_mode, options.get("side")) result.components, notes = expander(height, length, options, face, face_reason) result.notes.extend(notes) + billing = EXPANDER_BILLING.get(type_id) + if billing is not None and result.components: + result.billing_unit, result.billing_quantity = billing(options) return result diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py index 47181b73..de1d3782 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py @@ -12,9 +12,9 @@ ⚠ **형태가 돌쌓기가 아닌 갈래는 식이 없다** — 콘크리트·돌망태·통나무/목재틀·바자. 지어내지 않고 **왜 없는지**를 줄로 남긴다. -⚠ **뒷길이·돌 종류 칸이 기슭막이 등록부에 아직 없다**(2026-09-09 실측). 돌쌓기 식은 그 둘로 - 계수가 갈리는데, 없으면 `_back_length` 가 **조용히 45㎝ 로 돈다**. 값이 나오므로 아무도 - 못 알아채는 자리라 **그 사실을 줄로 드러낸다** — 「값이 있기는 하니 안 보이는」 그것. +ⓘ 뒷길이·돌 종류 칸은 기슭막이 등록부에 섰음(`test_revetment_stone_fields`). 안 고른 장은 + 돌쌓기 식이 내는 한 줄(「뒷길이를 안 골라 기본 45㎝」·「돌 종류를 안 정해」)이 그대로 드러냄. + 2026-09-14 까지 붙이던 「칸이 없어」 줄은 **거짓 + 같은 말 두 번**이라 걷음. """ from __future__ import annotations @@ -85,12 +85,6 @@ BED_SILL_FORMS: dict[str, dict[str, Any]] = { }, } -#: 돌쌓기 식이 계수를 가르는 데 쓰는 칸 — 기슭막이 등록부에 아직 없는 것을 알린다. -STONE_SPEC_KEYS: tuple[tuple[str, str], ...] = ( - ("back_len_cm", "뒷길이"), - ("stone_kind", "돌 종류"), -) - def revetment( height_m: float, @@ -110,17 +104,7 @@ def revetment( "— 형태를 골라야 물량이 섭니다" ] - components, notes = stone_masonry( - height_m, length_m, options, STONE_FORMS[form], face, face_reason - ) - # ⚠ 계수를 가르는 칸이 없으면 **기본값으로 조용히 돈다** — 그 사실을 드러낸다. - missing = [label for key, label in STONE_SPEC_KEYS if not options.get(key)] - if missing and components: - notes.append( - f"⚠ 기슭막이 제원에 {' · '.join(missing)} 칸이 없어 **돌쌓기 기본값으로 섰음** " - "— 그 값이 고임돌·야면석·채움콘크리트 계수를 가름" - ) - return components, notes + return stone_masonry(height_m, length_m, options, STONE_FORMS[form], face, face_reason) #: 돌골막이 원단위 — 소광리 정본 「골막이(찰)(치수조서연결)」, **개소당**. @@ -414,7 +398,9 @@ OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = { ("면목", "m", 2.0 * 1.0, "2 × 1"), ), }, - "콘크리트 L형수로 H=0.2": { + # ⚠ 키는 **등록부 선택지 글자 그대로**(「L형수로 H=0.2」) — 2026-09-14 까지 앞에 + # 「콘크리트 」가 붙어 있어 골라도 **영영 「원단위가 없습니다」**로 섰음. + "L형수로 H=0.2": { "rows": ( ("터파기", "㎥", 0.31645, "[{(1.11+1.51)÷2×0.2} + {(0.33×0.33)÷2}] × 1.0"), ("되메우기", "㎥", 0.129975, "(0.2+0.30)÷2×0.25×1 + (0.2+0.19)÷2×0.33×1.0"), diff --git a/resources/tester/test_b08_structure_table_holes.py b/resources/tester/test_b08_structure_table_holes.py new file mode 100644 index 00000000..5b6363f2 --- /dev/null +++ b/resources/tester/test_b08_structure_table_holes.py @@ -0,0 +1,98 @@ +"""구조물도 그림을 그리려다 드러난 **표 구멍** (2026-09-14 · PLAN 12장 남은 종류 그림). + +⚠ 셋 — 값이 서기는 하거나(떼흙막이) 조용히 0 이라 아무 시험도 안 잡던 자리. + ① 골막이·바닥막이는 개소당·㎡당 값인데 내역 단위가 「m · 연장 0」 → **수량 0, 내역에 안 섬** + ② 떼흙막이는 개소당 값이 연장으로 나뉘어 **떼 0.139/m** 로 보였음 + ③ 개거 「L형수로 H=0.2」 — 등록부 선택지 글자와 엔진 키가 달라 골라도 영영 안 섬 + ④ 기슭막이 사유 「돌 종류·뒷길이 칸이 없어」 — 칸이 선 뒤로 거짓 · 같은 말 두 번 +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 + + +def _table(type_id: str, start: float, end: float, **options) -> dict: + structure = { + "structure_id": type_id, + "type_id": type_id, + "start_m": start, + "end_m": end, + "options": options, + } + return build_table([structure], None) + + +def _sheet(table: dict) -> dict: + return build_standard_sheets(table)["sheets"][0] + + +def _unit(sheet: dict, name: str) -> float: + return next(row["unit_amount"] for row in sheet["rows"] if row["name"] == name) + + +def test_골막이는_개소당으로_서고_내역_수량이_1_개소() -> None: + table = _table( + "erosion_check", 50, 50, form="돌", height_m=1.5, top_length_m=4, bottom_length_m=2 + ) + item = table["structures"][0] + assert (item["billing_unit"], item["billing_quantity"]) == ("개소", 1.0) + sheet = _sheet(table) + assert sheet["unit_label"] == "개소당" and sheet["unpriced_rows"] == [] + row = next(r for r in build_handoff(unit_quantity_table=table)["work_items"] if r["unit"]) + assert (row["unit"], row["quantity"]) == ("개소", 1.0) # 전: m · 0.0 → 내역에 안 섬 + + +def test_떼흙막이는_개소당_값이_그대로_보인다() -> None: + sheet = _sheet(_table("soil_guard", 60, 70, form="떼", height_m=0.5, length_m=10)) + assert sheet["unit_label"] == "개소당" + assert abs(_unit(sheet, "떼") - 1.39) < 1e-9 # 전: 0.139 (m당으로 나뉨) + + +def test_바닥막이는_제곱미터당() -> None: + table = _table("bed_sill", 80, 80, form="돌붙임(찰)", area_m2=12, height_m=0.3) + sheet = _sheet(table) + assert sheet["unit_label"] == "㎡당" and sheet["billing_total"] == 12 + assert abs(_unit(sheet, "돌붙임") - 1.0) < 1e-9 and abs(_unit(sheet, "입적") - 0.45) < 1e-9 + + +def test_개거_L형수로는_등록부_글자로_선다() -> None: + registry = json.loads( + (ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8") + ) + choices = next( + option["choices"] + for item in registry["types"] + if item["type_id"] == "open_ditch" + for option in item["options"] + if option["key"] == "ditch_spec" + ) + for choice in choices: + item = _table("open_ditch", 0, 0, ditch_spec=choice, length_m=5)["structures"][0] + assert item["components"], (choice, item["notes"]) + concrete = next( + c + for c in _table("open_ditch", 0, 0, ditch_spec="L형수로 H=0.2", length_m=5)["structures"][ + 0 + ]["components"] + if c["name"] == "콘크리트" + ) + assert abs(concrete["amount"] - 0.1508 * 5) < 1e-9 + + +def test_기슭막이_사유에_없는_칸_이야기가_없다() -> None: + notes = _table("revetment", 0, 10, form="돌쌓기(찰)", height_m=2, length_m=10)["structures"][0][ + "notes" + ] + assert not any("칸이 없어" in note for note in notes) + assert any("뒷길이를 안 골라" in note for note in notes) # 같은 사실은 돌쌓기 한 줄이 말함