From 3cfe3ef58a978f98954e283834e3302cf5eaed5c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 03:55:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(b05=C2=B7b08):=20=EC=BD=98=ED=81=AC?= =?UTF-8?q?=EB=A6=AC=ED=8A=B8=20=ED=8F=AC=EC=9E=A5=20=EC=88=98=EB=9F=89?= =?UTF-8?q?=EC=8B=9D=20=E2=80=94=20=ED=8F=AD=C2=B7=ED=99=95=ED=8F=AD=C2=B7?= =?UTF-8?q?=EB=91=90=EA=BB=98=20B06=20=EC=9E=87=EA=B8=B0=20+=20=EC=B9=B8?= =?UTF-8?q?=20=EB=8D=AE=EC=96=B4=EC=93=B0=EA=B8=B0=20=C2=B7=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=81=C3=97=EB=91=90=EA=BB=98=20=C2=B7=20=EA=B1=B0=ED=91=B8?= =?UTF-8?q?=EC=A7=91=202L=20=C2=B7=20=EC=88=98=EC=B6=95=EC=A4=84=EB=88=88?= =?UTF-8?q?=206m=20=C2=B7=20=EB=B9=84=EB=8B=90=C2=B7=EC=B2=A0=EB=A7=9D=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0(12=EC=9E=A5=20D=20=ED=8F=AC?= =?UTF-8?q?=EC=9E=A5=20=EB=91=98=20=C2=B7=20=EB=B8=8C=EB=A0=88=EC=9D=B8=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=20=E2=91=A0~=E2=91=A4=C2=B7=E2=93=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 등록부: 두께 칸 필수 걷고 「비면 B06 포장층 두께」 · 폭·확폭 면적 칸(비면 B06) · 줄눈 간격 제안 6m(교본 부록 4-7 4-6m · 소광) · 비닐·철망 고르기(기본값 없음) - 연장은 구간(끝 − 시작) — 물넘이로 나뉜 조각에 길이 칸이 복사되는 자리 - 묶음 12-06#두께 · 12-08 · 12-07-01(12-7-2 줄눈설치는 안 씀 — 소광 실무가 컷팅) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B05_Profile/B05_Profile_Structure_Types.json | 52 +++++- .../B08_Quantity_Engine_Handoff_Mapping.py | 12 ++ .../B08_Quantity_Engine_UnitQuantity.py | 29 ++- .../B08_Quantity_Engine_UnitQuantity_Base.py | 5 + ...8_Quantity_Engine_UnitQuantity_Pavement.py | 171 ++++++++++++++++++ B08_Quantity/B08_Quantity_Router_Material.py | 5 + .../work_item_mapping_2026-01-01.json | 27 +++ .../tester/test_b08_pavement_concrete_unit.py | 158 ++++++++++++++++ 8 files changed, 453 insertions(+), 6 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Engine_UnitQuantity_Pavement.py create mode 100644 resources/tester/test_b08_pavement_concrete_unit.py diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index a57a4069..776801fe 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -2532,8 +2532,58 @@ "input": "number", "unit": "cm", "default": null, - "required": true, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 포장층 두께(표준 횡단면 설정 · 기본 0.2m = 임도기술교본 3-2 「두께 0.2m」)를 이음 — 적으면 적은 값이 이김" + }, + { + "key": "width_m", + "label": "포장 폭", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 노폭(구간 측점의 차도 표준 폭 · 교본 3-2 「포장 폭 3.0m」 · 실무 관측 3.5~4.0)을 이음 — 적으면 적은 값이 이김" + }, + { + "key": "widening_area_m2", + "label": "확폭 면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 곡선부 확폭을 측점 사이 평균 × 거리로 더함(교본 3-2 「곡선부에는 확폭」) — 확폭이 없으면 0 을 적을 것" + }, + { + "key": "joint_spacing_m", + "label": "수축줄눈 간격", + "input": "number", + "unit": "m", + "default": 6, + "default_basis": "제안값 · 임도기술교본 부록 4-7 7-1 3.5.1 「수축줄눈의 간격은 4-6m를 기준」 · 실무 소광 6m 관측 · 바꿀 수 있음", "phase": "detail" + }, + { + "key": "separation_sheet", + "label": "비닐(깔기·양생)", + "input": "select", + "choices": ["있음", "없음"], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 비닐 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「양생 재료비(비닐 등)는 별도 계상」이라 설계자가 고름(기본값 없음)" + }, + { + "key": "wire_mesh", + "label": "철망", + "input": "select", + "choices": ["있음", "없음"], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 철망 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「철망재료비는 별도 계상」 · 교본 부록 7-1 3.4 「설계도서에 따라」 · 실무 소광 「철망포함」 관측" } ] }, diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py index 8d85db66..79c2693d 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py @@ -419,6 +419,18 @@ def composite_quantities( entry["not_ready"] = True entry["why"] = why missing.append({"code": spec.get("code"), "reason": why}) + if suffix == "pavement_thickness": + # 12-6 갈래는 포장두께 20·30·40㎝ — 원단위가 쓴 두께(칸 또는 B06) 그대로(2026-09-15). + thickness = (structure.get("options") or {}).get("thickness_cm") + label = f"{thickness:g}㎝" if isinstance(thickness, (int, float)) else "" + if label in ("20㎝", "30㎝", "40㎝"): + entry["kind"] = label + entry["code"] = f"{spec.get('code')}#{label}" + elif found: + why = f"12-6 표는 포장두께 20·30·40㎝ 갈래뿐 — 두께 {label or '없음'} 은 못 고름" + entry["not_ready"] = True + entry["why"] = why + missing.append({"code": spec.get("code"), "reason": why}) if suffix == "rebar_complexity": # 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다. complexity, why = rebar_complexity( diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 88f1457a..01e7a0e4 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -118,6 +118,8 @@ EXPANDERS = { "bed_sill": lambda h, l, o, f=None, r="": _bed_sill()(_num(o.get("area_m2"), 0.0), o), # BOX암거 — m당 × 연장(산림과임업기술 예제 · 울진 2×2 · 2026-09-15 브레인 12장 D). "box_culvert": lambda h, l, o, f=None, r="": _box_culvert()(o), + # 콘크리트 포장 — 면적 × 두께 · 거푸집 2L · 수축줄눈(교본 3-2 · 부록 4-7 · 소광 · 2026-09-15). + "pavement_concrete": lambda h, l, o, f=None, r="": _pavement().concrete_pavement(l, o), # ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견). # 큰돌쌓기는 품셈 **13-6** 이고 돌쌓기는 **13-4** 다 — **규격 축이 다르다.** # 돌쌓기는 **뒷길이**(35·45·55·60㎝), 큰돌쌓기는 **직경**(40~60·60~80·80~100㎝). @@ -135,6 +137,13 @@ def _box_culvert(): return box_culvert +def _pavement(): + """콘크리트 포장 모듈을 늦게 가져온다.""" + from B08_Quantity import B08_Quantity_Engine_UnitQuantity_Pavement + + return B08_Quantity_Engine_UnitQuantity_Pavement + + def _soil_guard(): """흙막이 사유 함수를 늦게 가져온다.""" from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import soil_guard @@ -183,12 +192,17 @@ EXPANDER_WITHHELD: dict[str, str] = { #: ⚠ 안 적으면 「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)), # 정본 돌붙임 ㎡당 - "box_culvert": lambda options: ("m", _num(options.get("length_m"), 0.0)), # 점 배치라 연장 칸 + "erosion_check": lambda options, length: ("개소", 1.0), # 정본 「골막이(찰)」 개소당 + "soil_guard": lambda options, length: ("개소", 1.0), # 정본 「떼흙막이」 개소당(평균 붙박이) + "bed_sill": lambda options, length: ("㎡", _num(options.get("area_m2"), 0.0)), # 돌붙임 ㎡당 + "box_culvert": lambda options, length: ("m", _num(options.get("length_m"), 0.0)), # 점 배치 + "pavement_concrete": lambda options, length: _pavement().billing(length, options), # 면적 ㎡ } +#: 연장을 **구간(끝 − 시작)** 에서 읽는 종류 — 포장은 물넘이로 나뉘면 길이 칸(10)이 조각마다 +#: 그대로 복사돼 칸 값이 구간과 안 맞음(`B05_Profile_UI_Structures_Panel_Commit` 나누기). +SPAN_LENGTH_TYPES = frozenset({"pavement_concrete"}) + #: 한 구조물이 **여러 내역 줄**을 낳는 자리. 배수관은 관 자체와 유입부 집수정이 따로 선다 #: (품셈도 관부설과 집수정을 다른 공종으로 둔다). 한 줄로 합치면 어느 쪽 물량인지 못 가른다. @@ -288,6 +302,11 @@ def expand( start = _num(structure.get("start_m")) end = _num(structure.get("end_m")) length = _num(options.get("length_m")) or abs(end - start) + if type_id in SPAN_LENGTH_TYPES and None not in ( + structure.get("start_m"), + structure.get("end_m"), + ): + length = abs(end - start) height = _num(options.get("height_m")) # ⚠ 레지스트리 이름이 없으면 **코드값(`retaining_wall`)이 그대로 내역에 뜬다** — # `_Wording.type_label` 이 이미 대비표를 들고 있으므로 그것을 쓴다(2026-09-09 감사). @@ -340,7 +359,7 @@ def expand( 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) + result.billing_unit, result.billing_quantity = billing(options, length) return result diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py index a6692956..90f72755 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py @@ -62,6 +62,11 @@ DESTINATION = { "합판거푸집": "unit_price", "동바리": "unit_price", "비계": "unit_price", + # ⭐ 2026-09-15 콘크리트 포장 — 거푸집(12-8)·수축줄눈(12-7-1 절단)은 품 · 비닐·철망은 12-6 [주]② 재료 별도. + "포장거푸집": "unit_price", + "수축줄눈": "unit_price", + "비닐": "material", + "철망": "material", } #: ⛔ **갈 곳 기본값 금지**(명세 13장 「빠진 것이 조용해짐」) — 표에 없는 이름은 막고 사유를 냄. diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Pavement.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Pavement.py new file mode 100644 index 00000000..94be785b --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Pavement.py @@ -0,0 +1,171 @@ +"""콘크리트 포장 전개식 — **면적 × 두께 · 거푸집 양면 · 수축줄눈** (2026-09-15 브레인 포장 둘). + +근거 + ① 임도기술교본 3-2 기본설계 — 「포장 폭의 3.0m, 두께 0.2m를 기준으로 곡선부에는 확폭」 + ② 임도기술교본 부록 4-7 7-1 3.5.1 — 「수축줄눈의 간격은 4-6m를 기준」 + ③ 산림품셈 12-6 콘크리트 포장(인력) — 비닐·철망깔기·포설·양생 포함 · 거푸집·줄눈 제외 · + [주]② 비닐·양생재·철망 재료 별도 · 12-7-1 포장절단 · 12-8 콘크리트 포장 거푸집(연장 m) + ④ 실무 울진소광 `02-1-공종수량산출` 「포장및난간개거」 — 면적 = B × L · 거푸집(1면기준) = 2L · + 수축줄눈 = 내림(L÷6) × B · 「포장확폭」 면적 = 거리 × 확폭 · 신축줄눈 = 면적 ÷ 6 +⭐ 폭·확폭·두께는 **B06 이 이미 앎** — 빈 칸만 B06 에서 잇고 칸이 이김(판정 ①④ · 한 값 원칙). +""" + +from __future__ import annotations + +import math +from typing import Any + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import Component, _num, routed + +SOURCES = ( + "산식 = 임도기술교본 3-2(폭·두께·곡선부 확폭) · 부록 4-7 7-1(수축줄눈 4-6m) · 실무 울진소광" + " 「포장및난간개거」·「포장확폭」(면적 B×L · 거푸집 2L · 줄눈 내림(L÷간격)×B" + " · 확폭 줄눈 면적÷간격)" +) +NOTE_JOINT = ( + "수축줄눈 = 12-7-1 포장절단(m)으로 이음 — 12-7-2 줄눈설치는 안 씀 — 소광 실무가 컷팅" + "(「수축줄눈(컷팅)」) · 줄눈재를 넣는 공법이면 12-7-2 로 물을 것" +) +NOTE_FORM = ( + "포장거푸집 = 양쪽 가장자리 2 × 연장(소광 「1면기준」) · 12-8 이 강재 3m·핀폴·20회를 품에 둠" +) +SHEET_UNCHOSEN = ( + "비닐(깔기·양생)을 안 고름 — 품셈 12-6 [주]② 「양생에 필요한 재료비(비닐, 양생재 등)는 별도" + " 계상」이라 설계자가 고를 것(기본값 없음)" +) +MESH_UNCHOSEN = ( + "철망을 안 고름 — 품셈 12-6 [주]② 「철망재료비는 별도 계상」 · 교본 부록 7-1 3.4 「설계도서에" + " 따라」 · 실무 소광은 「철망포함」 관측 — 설계자가 고를 것(기본값 없음)" +) +MISSING = "콘크리트 포장 {what} — 칸이 비고 B06 에서도 못 읽어 수량이 안 섬 · 칸에 적을 것" +JOINT = "joint_spacing_m" + + +def _stations(designs: list[dict[str, Any]] | None) -> list[tuple[float, dict[str, Any]]]: + rows = [ + (_num(item.get("chainage_m")), item.get("design")) + for item in designs or () + if isinstance(item, dict) and isinstance(item.get("design"), dict) + ] + return sorted(rows, key=lambda row: row[0]) + + +def _widening(design: dict[str, Any]) -> float: + return _num(design.get("widening_left_m")) + _num(design.get("widening_right_m")) + + +def _widening_at(stations: list[tuple[float, dict[str, Any]]], at: float) -> float: + """그 자리의 확폭 — 앞뒤 측점 사이 직선 보간 · 끝 밖이면 가장 가까운 측점.""" + if at <= stations[0][0]: + return _widening(stations[0][1]) + for (s0, d0), (s1, d1) in zip(stations, stations[1:]): + if s0 <= at <= s1: + t = 0.0 if s1 == s0 else (at - s0) / (s1 - s0) + return _widening(d0) + (_widening(d1) - _widening(d0)) * t + return _widening(stations[-1][1]) + + +def pavement_from_designs( + structure: dict[str, Any], designs: list[dict[str, Any]] | None +) -> dict[str, Any]: + """빈 칸(폭·확폭·두께)을 B06 구간 측점에서 채운 **새** 구조물 — 칸이 차 있으면 그대로.""" + from B05_Profile.B05_Profile_Structures_Schema import structure_type_map + + options = dict(structure.get("options") or {}) + notes = list(structure.get("notes") or []) + # 상세 칸은 배치 폼에 없어 제안값이 저장에 안 들어감 — 등록부 한 벌에서 채움(값 두 벌 금지). + spacing = next(o for o in structure_type_map()["pavement_concrete"].options if o.key == JOINT) + if options.get(JOINT) in (None, "") and spacing.default is not None: + options[JOINT] = spacing.default + notes.append(f"수축줄눈 간격 {spacing.default:g}m — {spacing.default_basis}") + stations = _stations(designs) + start, end = sorted((_num(structure.get("start_m")), _num(structure.get("end_m")))) + inside = [design for chainage, design in stations if start <= chainage <= end] + if stations and not inside: + inside = [min(stations, key=lambda row: abs(row[0] - (start + end) / 2))[1]] + if options.get("width_m") in (None, "") and inside: + widths = {round(_num(d.get("carriageway_standard_width_m")), 3) for d in inside} - {0.0} + if len(widths) == 1: + options["width_m"] = widths.pop() + notes.append(f"포장 폭 {options['width_m']:g}m — B06 노폭(구간 측점 {len(inside)}곳)") + elif widths: + notes.append(f"포장 폭 — 구간 측점의 B06 노폭이 갈림({sorted(widths)}) · 칸에 적을 것") + if options.get("thickness_cm") in (None, "") and inside: + thick = {round(_num(d.get("pavement_thickness_m")), 4) for d in inside} - {0.0} + if len(thick) == 1: + options["thickness_cm"] = round(thick.pop() * 100, 2) + notes.append(f"포장 두께 {options['thickness_cm']:g}㎝ — B06 포장층 두께") + if options.get("widening_area_m2") in (None, "") and stations and end > start: + cuts = [start, *[s for s, _d in stations if start < s < end], end] + area = sum( + (_widening_at(stations, a) + _widening_at(stations, b)) / 2 * (b - a) + for a, b in zip(cuts, cuts[1:]) + ) + options["widening_area_m2"] = round(area, 4) + notes.append(f"확폭 면적 {area:.2f}㎡ — B06 곡선부 확폭 × 측점 사이 거리(평균)") + return {**structure, "options": options, "notes": notes} + + +def concrete_pavement(length: float, options: dict[str, Any]) -> tuple[list[Component], list[str]]: + """콘크리트 포장 한 구간 — (성분, 사유). 면적 밑수가 없으면 성분 없이 사유만.""" + width = _num(options.get("width_m")) + thickness = _num(options.get("thickness_cm")) / 100 + widening = options.get("widening_area_m2") + missing = [ + what + for what, value in ( + ("연장", length), + ("폭", width), + ("두께", thickness), + ("확폭 면적", 1.0 if isinstance(widening, (int, float)) else 0.0), + ) + if value <= 0 + ] + if missing: + return [], [MISSING.format(what="·".join(missing))] + spacing = _num(options.get("joint_spacing_m")) + widening = float(widening) + area = width * length + widening + rows: list[tuple[str, str, float, str]] = [ + ( + "콘크리트", + "㎥", + area * thickness, + f"(폭 {width:g} × 연장 {length:g} + 확폭 {widening:g}) × 두께 {thickness:g}", + ), + ("포장거푸집", "m", 2 * length, f"양쪽 2 × 연장 {length:g}"), + ] + notes = [SOURCES, NOTE_FORM] + if spacing > 0: + joints = math.floor(length / spacing) * width + widening / spacing + rows.append( + ( + "수축줄눈", + "m", + joints, + f"내림({length:g} ÷ {spacing:g}) × 폭 {width:g} + 확폭 {widening:g} ÷ {spacing:g}", + ) + ) + notes.append(NOTE_JOINT) + for key, name, unchosen in ( + ("separation_sheet", "비닐", SHEET_UNCHOSEN), + ("wire_mesh", "철망", MESH_UNCHOSEN), + ): + choice = options.get(key) + if choice == "있음": + rows.append((name, "㎡", area, f"포장 면적 {area:.4g}")) + elif choice != "없음": + notes.append(unchosen) + components = [ + Component(name, unit, amount, destination, basis) + for name, unit, amount, basis in rows + if (destination := routed(name, notes)) + ] + return components, notes + + +def billing(length: float, options: dict[str, Any]) -> tuple[str, float]: + """내역 줄 — 포장 면적 ㎡(소광 「콘크리트포장 T=20 ㎡」).""" + widening = options.get("widening_area_m2") + extra = float(widening) if isinstance(widening, (int, float)) else 0.0 + return "㎡", _num(options.get("width_m")) * length + extra diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index bf67ce4b..255b0ba6 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -97,6 +97,11 @@ def _collect_structures( if definition.reference_only: skipped.append(f"{definition.name}: 전문 상세설계 대상 — 배치까지만") continue + if type_id == "pavement_concrete": + # 폭·확폭·두께는 B06 이 앎 — 빈 칸만 이음(2026-09-15 브레인 포장 둘 ①④). + from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Pavement import pavement_from_designs + + payload = pavement_from_designs(payload, designs) if definition.group == "B": # ⚠ B군(종단배수)은 **연장표**로 간다 — `common_util_structure_lengths` 가 # 겹친 구간을 합쳐 주기 때문이다. 구조물별로 세면 겹친 구간을 두 번 센다. 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 e57bf03e..efe83198 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 @@ -492,6 +492,33 @@ ], "why": "품셈 12장에 「BOX암거」 공종이 없음 — 산림과임업기술(임도) 5장 수량산출 예제가 구체콘크리트·기초·거푸집·동바리·철근으로 가르고, 실무 울진 기번3 「암거수량집계표(2×2)」가 레미콘·버림·유로폼·합판·철근·동바리·비계로 셈(2026-09-15 브레인 12장 D).", "needs": "일위대가 조립은 B09 몫 — B08 은 물량과 묶음만 넘김" + }, + { + "type_id": "pavement_concrete", + "parts": [ + { + "code": "FP-12-06", + "name": "콘크리트 포장(인력시공)", + "unit": "㎥", + "from_components": ["콘크리트"], + "kind_suffix": "pavement_thickness" + }, + { + "code": "FP-12-08", + "name": "콘크리트 포장 거푸집", + "unit": "m", + "from_components": ["포장거푸집"] + }, + { + "code": "FP-12-07-01", + "name": "포장절단", + "unit": "m", + "from_components": ["수축줄눈"], + "why": "수축줄눈은 12-7-1 포장절단으로 이음 — 12-7-2 줄눈설치는 안 씀 — 소광 실무가 컷팅(「수축줄눈(컷팅)」 · 2026-09-15 브레인 ⓐ)" + } + ], + "why": "실무 내역은 「콘크리트포장 T=20 ㎡」 한 줄이고 품셈은 포설(12-6 ㎥ · 비닐·철망깔기·양생 포함) · 거푸집(12-8 연장 m) · 줄눈(12-7) 을 따로 둠 — 거푸집·줄눈 제외라 묶음으로 셈(2026-09-15 브레인 12장 D 포장 둘). 콘크리트·비닐·철망 재료는 자재 축.", + "needs": "일위대가 조립은 B09 몫 — B08 은 물량과 묶음만 넘김" } ], "unit_conversion": { diff --git a/resources/tester/test_b08_pavement_concrete_unit.py b/resources/tester/test_b08_pavement_concrete_unit.py new file mode 100644 index 00000000..837bc7f7 --- /dev/null +++ b/resources/tester/test_b08_pavement_concrete_unit.py @@ -0,0 +1,158 @@ +"""콘크리트 포장 전개식 — 면적 × 두께 · 거푸집 양면 · 수축줄눈 (2026-09-15 브레인 포장 둘 ①~⑤·ⓐ). + +근거 + 임도기술교본 3-2 기본설계 — 포장 폭 3.0m · 두께 0.2m · 곡선부 확폭 + 임도기술교본 부록 4-7 7-1 3.5.1 — 「수축줄눈의 간격은 4-6m를 기준」 + 산림품셈 12-6 콘크리트 포장(인력) · 12-7-1 포장절단 · 12-8 콘크리트 포장 거푸집 + 실무 울진소광 「포장및난간개거」 — 면적 = B × L · 거푸집(1면기준) = 2L · + 수축줄눈 = 내림(L÷6) × B (103m·B 3.5 → 59.5 · 418m → 241.5) · 「포장확폭」 줄눈 = 면적 ÷ 6 +판정 — 폭·확폭·두께는 **B06 에서 잇고 칸이 이김**(한 값 원칙) · 비닐·철망은 고르기(기본값 없음). +""" + +from __future__ import annotations + +import pytest + +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Pavement import pavement_from_designs + +FILLED = { + "width_m": 3.0, + "widening_area_m2": 6.0, + "thickness_cm": 20, + "joint_spacing_m": 6, + "separation_sheet": "있음", + "wire_mesh": "없음", +} + + +def _row(options: dict, start: float = 100.0, end: float = 130.0) -> dict: + structure = { + "structure_id": "p1", + "type_id": "pavement_concrete", + "start_m": start, + "end_m": end, + # 구간을 물넘이로 나누면 길이 칸(10)이 조각마다 그대로 복사됨 — 연장은 구간에서 읽음. + "options": {"length_m": 10, **options}, + } + return build_table([structure], {"pavement_concrete": "콘크리트 포장"})["structures"][0] + + +def test_칸이_찬_구간은_면적_두께_거푸집_줄눈이_선다() -> None: + row = _row(FILLED) + got = {c["name"]: c for c in row["components"]} + area = 3.0 * 30 + 6.0 + assert (row["billing_unit"], row["billing_quantity"]) == ("㎡", pytest.approx(area)) + assert got["콘크리트"]["amount"] == pytest.approx(area * 0.2) + assert got["콘크리트"]["destination"] == "material" + assert got["포장거푸집"]["amount"] == pytest.approx(2 * 30) + # 내림(30 ÷ 6) × 3.0 + 확폭 6 ÷ 6 + assert got["수축줄눈"]["amount"] == pytest.approx(5 * 3.0 + 1.0) + assert got["비닐"]["amount"] == pytest.approx(area) and "철망" not in got + text = " ".join(row["notes"]) + assert "12-7-2 줄눈설치는 안 씀 — 소광 실무가 컷팅" in text + + +@pytest.mark.parametrize(("length", "joints"), [(103.0, 59.5), (418.0, 241.5)]) +def test_수축줄눈은_소광_관측과_같다(length: float, joints: float) -> None: + row = _row({**FILLED, "width_m": 3.5, "widening_area_m2": 0}, 0.0, length) + got = {c["name"]: c["amount"] for c in row["components"]} + assert got["수축줄눈"] == pytest.approx(joints) + + +def test_비닐_철망을_안_고르면_사유() -> None: + row = _row({k: v for k, v in FILLED.items() if k not in ("separation_sheet", "wire_mesh")}) + names = {c["name"] for c in row["components"]} + assert "비닐" not in names and "철망" not in names and "콘크리트" in names + text = " ".join(row["notes"]) + assert "비닐" in text and "철망" in text and "12-6" in text + + +DESIGNS = [ + {"chainage_m": 90.0, "design": {"carriageway_standard_width_m": 3.0, "paved": False}}, + { + "chainage_m": 100.0, + "design": { + "carriageway_standard_width_m": 3.0, + "widening_right_m": 1.0, + "paved": True, + "pavement_thickness_m": 0.2, + }, + }, + { + "chainage_m": 120.0, + "design": { + "carriageway_standard_width_m": 3.0, + "widening_left_m": 0.5, + "paved": True, + "pavement_thickness_m": 0.2, + }, + }, + { + "chainage_m": 140.0, + "design": {"carriageway_standard_width_m": 3.0, "paved": True, "pavement_thickness_m": 0.2}, + }, +] + + +def _structure(**options) -> dict: + return { + "type_id": "pavement_concrete", + "start_m": 100.0, + "end_m": 130.0, + "options": {"length_m": 10, **options}, + } + + +def test_빈_칸은_B06_폭_확폭_두께를_잇는다() -> None: + filled = pavement_from_designs(_structure(), DESIGNS) + options = filled["options"] + assert options["width_m"] == pytest.approx(3.0) + assert options["thickness_cm"] == pytest.approx(20.0) + # 100 → 1.0 · 120 → 0.5 · 130 → 0.25(120~140 사이 보간): (1.0+0.5)/2×20 + (0.5+0.25)/2×10 + assert options["widening_area_m2"] == pytest.approx(15.0 + 3.75) + assert any("B06" in note for note in filled["notes"]) + # 상세 칸은 배치 폼에 없어 저장에 없음 — 등록부 제안값 6m 을 사유와 함께 채움. + assert options["joint_spacing_m"] == 6 and any("4-6m" in note for note in filled["notes"]) + + +def test_칸을_적으면_칸이_이긴다() -> None: + filled = pavement_from_designs(_structure(width_m=3.5, widening_area_m2=0), DESIGNS) + assert filled["options"]["width_m"] == 3.5 and filled["options"]["widening_area_m2"] == 0 + assert filled["options"]["thickness_cm"] == pytest.approx(20.0) + + +def test_B06_설계도_칸도_없으면_안_선다() -> None: + filled = pavement_from_designs(_structure(), []) + row = build_table([filled])["structures"][0] + assert row["components"] == [] + assert any("폭" in note for note in row["notes"]) + + +def test_인계_묶음은_12_6_두께_갈래_12_8_12_7_1() -> None: + table = build_table( + [{**_structure(**FILLED), "structure_id": "p1"}], {"pavement_concrete": "콘크리트 포장"} + ) + row = next( + r for r in build_handoff(unit_quantity_table=table)["work_items"] if "포장" in r["name"] + ) + parts = {part["code"]: part for part in row["composite_parts"] or []} + area = 3.0 * 30 + 6.0 + assert parts["FP-12-06#20㎝"]["quantity"] == pytest.approx(area * 0.2) + assert parts["FP-12-08"]["quantity"] == pytest.approx(60.0) + assert parts["FP-12-07-01"]["quantity"] == pytest.approx(16.0) + + +def test_등록부_칸() -> None: + options = {o.key: o for o in structure_type_map()["pavement_concrete"].options} + assert not options["thickness_cm"].required and "B06" in options["thickness_cm"].empty_means + assert "B06" in options["width_m"].empty_means and "3.5" in options["width_m"].empty_means + assert "B06" in options["widening_area_m2"].empty_means + assert ( + options["joint_spacing_m"].default == 6 + and "4-6m" in options["joint_spacing_m"].default_basis + ) + for key in ("separation_sheet", "wire_mesh"): + assert options[key].default is None and options[key].choices == ["있음", "없음"]