From 82755260e2c324290eb62d5cf9728d41937df844 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 23:58:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=20=ED=91=9C=ED=86=A0=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EC=8B=A4=EB=AC=B4=EB=8C=80=EB=A1=9C=20=E2=80=94=20?= =?UTF-8?q?=EB=85=B8=EB=A9=B4=20+=20=EC=A0=88=ED=86=A0=20=EC=82=AC?= =?UTF-8?q?=EB=A9=B4=20=E3=8E=A1=20=C2=B7=20=EC=9A=B4=EB=B0=98=EC=9D=80=20?= =?UTF-8?q?20=20m=20=EB=84=98=EB=8A=94=20=EB=AA=AB=EB=A7=8C=20=C2=B7=20?= =?UTF-8?q?=EC=B8=B5=EB=94=B0=EA=B8=B0=20=EC=A0=9C=EC=95=88=EA=B0=92=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 표토제거: 답외구간 FP-09-15-02 · 밑수 ㎡ · 대상 노면·절토대상지(성토 사면 안 셈) · 노면 폭이 빈 측점이 있으면 안 섬 - 표토 운반·적치: 제거 ㎡ × 두께 · 거리 − 압토 20 m · 20 m 이내면 제거 품에 듦 - 층따기: 「제안값(확정 아님)」 경고 줄 · 칸에 자동으로 안 넣음 - 시험: 옛 ㎥ 기대값 넷 갱신 · 노면 면적 평균단면적 시험 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../B08_Quantity_Engine_Preparation.py | 157 ++++++++++------- .../B08_Quantity_Engine_SlopeLength.py | 23 +++ B08_Quantity/B08_Quantity_Router_Earthwork.py | 12 +- B08_Quantity/B08_Quantity_UI_Page.ts | 6 +- resources/tester/test_b08_handoff.py | 4 +- resources/tester/test_b08_preparation.py | 39 +++-- resources/tester/test_b08_topsoil_haul.py | 159 +++++++----------- .../tester/test_b09_prep_handoff_bill.py | 6 +- ui_template/ui_template_locale_b2.ts | 8 +- 9 files changed, 216 insertions(+), 198 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index ed1d57ec..df803dcd 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -10,7 +10,7 @@ 「토공집계 지장목제거로 이미 섬」이라고 가리키기만** 한다. ⚠ 값이 없는 줄의 사유를 적는다 - · 표토제거(9-15) — 면적은 사면적에서 나오나 **두께·대상 구간이 설계로 안 정해져 있다**. + · 표토제거(9-15-2) — **노면 + 절토 사면 ㎡** · 노면 폭을 못 세면 안 섬(2026-09-13 「실무대로」). · 제근·뿌리다듬기(9-20~21) — 단위가 **「개」(그루 수)**인데 입목 본수를 우리가 안 든다. · 규준틀(11-2) — **개소** 산정 기준(구조물·절성토 구간별 몇 개소)이 안 정해져 있다. @@ -110,13 +110,14 @@ def preparation_rows( topsoil_thickness_m: float | None = None, topsoil_haul_distance_m: float | None = None, stand_volume_class: str | None = None, + road_surface_area_m2: float | None = None, ) -> list[dict[str, Any]]: """준비공 줄 — 값이 서는 것과 안 서는 것을 **한 목록에** 낸다.""" slope = slope_totals or {} tree_area = float(slope.get("tree_removal_fill", 0.0)) + float( slope.get("tree_removal_cut", 0.0) ) - topsoil = _topsoil_row(slope, topsoil_thickness_m) + topsoil = _topsoil_row(slope, road_surface_area_m2) return [ { "group": "준비공", @@ -141,7 +142,7 @@ def preparation_rows( }, topsoil, # ⚠ 법이 요구하는 **운반·적치** — 제거 물량이 곧 밑수다(별표2). - _topsoil_haul_row(topsoil, topsoil_haul_distance_m), + _topsoil_haul_row(topsoil, topsoil_thickness_m, topsoil_haul_distance_m), _root_removal_row(slope, stand_volume_class), *_root_steps_rows(slope, stand_volume_class), _batter_frame_row(list(slope_rows)), @@ -149,53 +150,48 @@ def preparation_rows( ] -def _topsoil_row(slope: dict[str, float], thickness_m: float | None) -> dict[str, Any]: - """표토제거 — **두께는 품셈이 아니라 설계가 정한다**(9-15 [주]② 「T : 표토두께(m)」). +def _topsoil_row(slope: dict[str, float], road_area_m2: float | None) -> dict[str, Any]: + """표토제거 — **「실무대로」 셋이 한 묶음**(2026-09-13 판정 · PLAN 10장). - ⚠ 두께를 안 넣으면 **0 으로 때우지 않고** 물량을 안 낸다. 대상 면적은 절·성토 사면적을 - 쓴다(사면 계열의 면고르기 면적과 같은 자리). + ① 밑수 **㎡** — 품셈 9-15-2 가 T=0.2 m 를 **품 조건**으로 둔 면적 축(실무 영월 「표토제거 + 답외구간 M2」). 두께는 여기 안 곱한다 — 운반 부피에만 쓴다. + ② 대상 **노면 + 절토대상지** — 별표2 Ⅰ.2.차.(6) 문언. 성토대상지는 (7) 「제거·정리」라 안 셈. + ③ **20 m 압토는 품에 듦** — 운반 줄은 그 넘는 몫만(`_topsoil_haul_row`). + ⚠ 하나만 고치면 합계가 그럴듯해 보이면서 틀린다 — 셋을 함께 바꿨다. """ - area = float(slope.get("face_dressing_fill", 0.0)) + float(slope.get("face_dressing_cut", 0.0)) - # ⚠ 면적이 0 이면 **0 ㎥ 를 「값 있음」으로 내지 않는다** — 0 은 「없음」과 구별이 안 되고, - # 받는 쪽이 「표토가 없는 노선」으로 읽는다. 절·성토 사면적이 0 인 임도는 성립하지 - # 않으므로 이 자리는 사실상 **사면표가 아직 안 선 것**이다(2026-09-09 감사에서 잡음). + cut = float(slope.get("face_dressing_cut", 0.0)) + base = {"group": "준비공", "item": "표토제거", "unit": "㎡", "work_item_code": TOPSOIL_CODE} + if road_area_m2 is None: + return { + **base, + "amount": None, + "status": STATUS_PENDING, + "reason": ( + "노면 면적을 못 셉니다 — 횡단 설계에 노체 끝(노면 폭)이 없는 측점이 있음. " + f"대상은 노면 + 절토대상지(별표2) · 절토 사면 {cut:,.1f}㎡ 만으로는 세우지 않음" + ), + "reference_amount": cut, + } + area = float(road_area_m2) + cut + # ⚠ 면적이 0 이면 **0 ㎡ 를 「값 있음」으로 내지 않는다** — 받는 쪽이 「표토가 없는 노선」으로 + # 읽는다(2026-09-09 감사에서 잡음). 노면이 0 인 임도는 성립하지 않으므로 표가 안 선 것이다. if area <= 0: return { - "group": "준비공", - "item": "표토제거", - "unit": "㎥", + **base, "amount": None, "status": STATUS_PENDING, "reason": ( - "대상 면적이 0 ㎡ 입니다 — 사면표(면고르기 면적)가 아직 서지 않았습니다. " - "0 ㎥ 로 내면 「표토가 없는 노선」으로 읽히므로 값을 세우지 않습니다" + "대상 면적이 0 ㎡ 입니다 — 횡단·사면표가 아직 서지 않았습니다. " + "0 ㎡ 로 내면 「표토가 없는 노선」으로 읽히므로 값을 세우지 않습니다" ), - "work_item_code": "FP-09-15", } - if thickness_m is None or float(thickness_m) <= 0: - return { - "group": "준비공", - "item": "표토제거", - "unit": "㎥", - "amount": None, - "status": STATUS_PENDING, - "reason": ( - "표토 두께가 아직 입력되지 않았습니다 — 두께는 설계가 정하는 값입니다. " - f"{TOPSOIL_ORIGINAL_APPLIED} " - f"산출 조건에서 두께를 넣으면 값이 섭니다 (대상 면적 {area:,.1f}㎡)" - ), - "reference_amount": area, - "work_item_code": "FP-09-15", - } - thickness = float(thickness_m) return { - "group": "준비공", - "item": "표토제거", - "unit": "㎥", - "amount": area * thickness, + **base, + "amount": area, "status": STATUS_READY, - "reason": (f"사면적 {area:,.1f}㎡ × 두께 {thickness:g}m (품셈 9-15) · {TOPSOIL_AREA_GAP}"), - "work_item_code": "FP-09-15", + "reason": ( + f"노면 {float(road_area_m2):,.1f}㎡ + 절토 사면 {cut:,.1f}㎡ — {TOPSOIL_TARGET_BASIS}" + ), } @@ -213,19 +209,16 @@ TOPSOIL_ORIGINAL_APPLIED = ( " — 값을 자동으로 넣지 않되, 원문에 있는 값이므로 참고할 것" ) -#: ⚠⚠ **대상 면적이 법 문언과 어긋난다**(2026-09-09 준비공 축 감사에서 잡음). -#: 별표2 Ⅰ.2.차.(6) 은 대상을 **「노면·절토대상지」**로 못박고, 성토대상지는 (7) 에서 -#: 「표토 등은 **제거·정리**한다」로 따로 두어 **운반·적치 의무를 안 건다.** -#: 우리는 `face_dressing_cut + face_dressing_fill` 로 **절토·성토 사면적을 다 더하고** -#: **노면(연장 × 노폭)은 안 센다** — 두 방향으로 어긋난 셈이다. -#: ㉡ 계열(「또는」을 다 더함) — 성토 사면까지 운반·적치 물량에 실림 -#: 빠짐 — 노면 표토가 아예 안 섬(법이 첫째로 든 대상) -#: ⚠ **값을 임의로 고치지 않는다** — 실무가 사면적 한 덩이로 잡는지, 노면을 따로 잡는지가 -#: 사용자·실무자 확인 사항이다. 지금은 **사유로 드러내기만** 한다. -TOPSOIL_AREA_GAP = ( - "⚠ 대상 면적 확인 필요 — 별표2 는 「노면·절토대상지」로 두는데 우리는 절토·성토" - " **사면적을 다 더하고 노면은 안 셈**. 성토대상지는 별표2 (7) 이 「제거·정리」로만 두어" - " 운반·적치 의무를 안 검. 실무 확인 뒤 대상을 확정할 것" +#: 표토제거 공종 — **답외구간**(9-15-2). 임도는 논(畓)이 아니고 실무 영월도 「답외구간」으로 셈. +TOPSOIL_CODE = "FP-09-15-02" +#: ⭐ 2026-09-13 판정 「실무대로」 — 대상·밑수·압토 셋의 근거(화면 사유에 그대로 보임). +#: 종전(2026-09-09 감사)엔 절토·성토 **사면적을 다 더하고 노면은 안 세** 법 문언과 두 방향으로 +#: 어긋나 있었고 사유로만 드러내고 있었다. +TOPSOIL_TARGET_BASIS = ( + "대상은 별표2 Ⅰ.2.차.(6) 「노면·절토대상지」 · 성토대상지는 (7) 「제거·정리」라 안 셈" + " · 밑수 ㎡(품셈 9-15-2 가 T=0.2m 를 품 조건으로 둔 면적 축" + " · 실무 영월 「표토제거 답외구간 M2」)" + " · 20m 압토는 제거 품에 듦(2026-09-13 판정 「실무대로」)" ) #: 표토 운반·적치 — ⚠ **법이 요구하는데 우리가 제거만 세고 있던 자리**(2026-09-09). @@ -237,10 +230,11 @@ TOPSOIL_AREA_GAP = ( TOPSOIL_HAUL_LAW = ( "⚠ 법정 의무 — 시행규칙 별표2 「표토는 전량 제거한 후 … 최고 홍수위보다 높은 장소로" " 운반하고 쌓아두어야 한다」. 제거만 세면 운반이 빠짐" - " · ⚠⚠ **다만 제거 품(9-15-2)에 이미 L=20m 압토가 들어 있음** — 실무 내역서도" - " 「표토제거 답외구간 / M2」 한 줄뿐이고 운반 줄이 따로 없음. 이 줄은 **20m 를 넘는" - " 몫**일 때만 새 줄이며, 그 가름을 아직 안 함(확인 필요)" + " · 제거 품(9-15-2)에 L=20m 압토가 들어 있어 이 줄은 **20m 를 넘는 몫**만 셈" + " (실무 영월 「표토제거 답외구간 / M2」 한 줄 · 운반 줄 없음)" ) +#: 제거 품이 품은 압토 거리(m) — 품셈 9-15-2 원문 적용값 `L(운반거리) 20m`. +TOPSOIL_PUSH_INCLUDED_M = 20.0 #: 제근 — ⚠ **본수가 아니라 임목축적으로 갈린다**(2026-09-09 원문 확인). @@ -401,26 +395,56 @@ def _root_steps_rows( ] -def _topsoil_haul_row(topsoil: dict[str, Any], distance_m: float | None) -> dict[str, Any]: - """표토 운반 — 물량은 제거 물량 그대로, 거리는 설계 입력. +def _topsoil_haul_row( + topsoil: dict[str, Any], thickness_m: float | None, distance_m: float | None +) -> dict[str, Any]: + """표토 운반 — 부피는 **제거 면적 × 두께**, 거리는 설계 입력에서 **20m 압토를 뺀 몫**. - ⚠ 제거 줄이 안 서면(두께 미입력) 운반도 안 선다 — 밑수가 그 줄이기 때문이다. + ⚠ 제거 줄이 안 서거나 두께가 비면 운반도 안 선다 — 0 으로 때우지 않는다. """ - amount = topsoil.get("amount") + area = topsoil.get("amount") distance = None if distance_m is not None: try: distance = float(distance_m) except (TypeError, ValueError): distance = None - if amount is None: + if area is None: return { "group": "준비공", "item": "표토 운반·적치", "unit": "㎥", "amount": None, "status": STATUS_PENDING, - "reason": f"{TOPSOIL_HAUL_LAW} · 제거 물량이 아직 안 서서 운반도 못 셈(두께 먼저)", + "reason": f"{TOPSOIL_HAUL_LAW} · 제거 면적이 아직 안 서서 운반도 못 셈", + "work_item_code": None, + } + if thickness_m is None or float(thickness_m) <= 0: + return { + "group": "준비공", + "item": "표토 운반·적치", + "unit": "㎥", + "amount": None, + "status": STATUS_PENDING, + "reason": ( + f"{TOPSOIL_HAUL_LAW} · 운반 부피 = 제거 면적 × 표토 두께 — 두께가 아직 입력되지" + f" 않았습니다. {TOPSOIL_ORIGINAL_APPLIED} (제거 면적 {float(area):,.1f}㎡)" + ), + "reference_amount": float(area), + "work_item_code": None, + } + amount = float(area) * float(thickness_m) + if distance is not None and 0 < distance <= TOPSOIL_PUSH_INCLUDED_M: + return { + "group": "준비공", + "item": "표토 운반·적치", + "unit": "㎥", + "amount": None, + "status": STATUS_COUNTED_ELSEWHERE, + "reason": ( + f"{TOPSOIL_HAUL_LAW} · 운반거리 {distance:g}m 는 제거 품의 20m 압토 안 — 따로 안 셈" + ), + "reference_amount": amount, "work_item_code": None, } if distance is None or distance <= 0: @@ -438,19 +462,22 @@ def _topsoil_haul_row(topsoil: dict[str, Any], distance_m: float | None) -> dict "reference_amount": float(amount), "work_item_code": None, } + beyond = distance - TOPSOIL_PUSH_INCLUDED_M return { "group": "준비공", "item": "표토 운반·적치", "unit": "㎥", - "amount": float(amount), + "amount": amount, "status": STATUS_READY, "reason": ( - f"{TOPSOIL_HAUL_LAW} · 제거 물량 그대로 · 운반거리 {distance:g}m(설계 입력)" + f"{TOPSOIL_HAUL_LAW} · 제거 면적 {float(area):,.1f}㎡ × 두께 {float(thickness_m):g}m" + f" · 운반거리 {distance:g}m(설계 입력) − 압토 {TOPSOIL_PUSH_INCLUDED_M:g}m" + f" = {beyond:g}m" " · 갈래 「토사」로 보냄(표토는 토사임 — 덤프 운반 10-12 는 토사/암절취/발파암으로" " 갈리고 부모 코드에는 품이 없음)" ), "work_item_code": "FP-10-12", - "haul_distance_m": distance, + "haul_distance_m": beyond, # ⚠ `FP-10-12` 는 **부모**다 — 품이 붙은 것은 잎(10-12-1 토사 · -2 암절취 · -3 발파암) # 뿐이라 갈래를 안 보내면 B09 가 「후보 3건」만 보이고 금액이 안 선다. 표토가 토사인 # 것은 다툼이 없으므로 여기서 갈래를 실어 보낸다(2026-09-09 마스터 대조에서 잡음). @@ -620,6 +647,7 @@ def build_table( stand_volume_class: str | None = None, chipping_enabled: Any = False, chipping_volume_m3: Any = None, + road_surface_area_m2: float | None = None, ) -> dict[str, Any]: """화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**""" rows = ( @@ -629,6 +657,7 @@ def build_table( topsoil_thickness_m, topsoil_haul_distance_m, stand_volume_class, + road_surface_area_m2, ) + erosion_rows(structures, names) + chipping_rows(chipping_enabled, chipping_volume_m3) diff --git a/B08_Quantity/B08_Quantity_Engine_SlopeLength.py b/B08_Quantity/B08_Quantity_Engine_SlopeLength.py index 5594d578..5256c526 100644 --- a/B08_Quantity/B08_Quantity_Engine_SlopeLength.py +++ b/B08_Quantity/B08_Quantity_Engine_SlopeLength.py @@ -69,6 +69,9 @@ class StationSlope: # ⚠ 좌우가 다르면 **큰 쪽**을 쓴다. 「중심점 성토고 5m 이상」(품셈 11-3 [주]①) 판정은 # 가장 높은 쪽이 기준이고, 양쪽을 더하면 실제보다 두 배가 된다. fill_height_m: float = 0.0 + #: 노면 폭(m) — 노체 끝(`road_edges`) 좌우 사이. 표토제거 대상 「노면」 면적의 밑수(별표2). + #: 노체 끝이 저장에 없으면 `None` — 0 으로 때우지 않는다. + roadbed_width_m: float | None = None segments: tuple[SlopeSegment, ...] = () # 사면이 샘플 범위 끝까지 원지반을 못 만나 **면적이 잘린** 측점. # 설계 엔진이 `slope_unclosed` 로 이미 경고하는 값을 그대로 물고 온다. 잘린 측점은 @@ -207,6 +210,25 @@ def _material(design: dict[str, Any], ratio: float) -> str | None: return "rock" if abs(ratio - rock) < abs(ratio - soil) else "soil" +def _roadbed_width(design: dict[str, Any]) -> float | None: + """노면 폭 — 노체 끝 좌(+)·우(−) 오프셋 사이. 한쪽이라도 없으면 `None`.""" + edges = design.get("road_edges") or {} + left = _num((edges.get("left") or {}).get("offset_m")) + right = _num((edges.get("right") or {}).get("offset_m")) + return None if left is None or right is None else abs(left - right) + + +def road_surface_area(slopes: Iterable[StationSlope]) -> float | None: + """노면 면적(㎡) — 사면과 같은 평균단면적법. 노면 폭이 빈 측점이 하나라도 있으면 `None`.""" + ordered = sorted(slopes, key=lambda s: s.chainage_m) + if not ordered or any(s.roadbed_width_m is None for s in ordered): + return None + return sum( + (float(a.roadbed_width_m) + float(b.roadbed_width_m)) / 2.0 * (b.chainage_m - a.chainage_m) + for a, b in zip(ordered, ordered[1:]) + ) + + def station_slope(chainage_m: float, design: dict[str, Any]) -> StationSlope: """측점 하나의 사면길이. 좌우를 합쳐 절토·성토 각각의 총 사면길이를 낸다.""" ratios = _ratios(design) @@ -225,6 +247,7 @@ def station_slope(chainage_m: float, design: dict[str, Any]) -> StationSlope: # ⚠ 없는 측점은 0 이다 — 성토 사면길이로 **대신 채우지 않는다**(면이 다름). bench_cut_length_m=_num(design.get("bench_cut_length_m")) or 0.0, fill_height_m=max(fill_by_side.values(), default=0.0), + roadbed_width_m=_roadbed_width(design), berm_width_m=_num(berm.get("width_m")) or 0.0, segments=tuple(segments), unclosed=bool(design.get("slope_unclosed")), diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index d64f9023..49171e5e 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -33,13 +33,12 @@ from B06_Section.B06_Section_Repository import ( from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table -from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table -from B08_Quantity.B08_Quantity_Engine_HaulSummary import check_against_plan from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping +from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table +from B08_Quantity.B08_Quantity_Engine_HaulSummary import check_against_plan, summary_input_rows from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_preparation_table -from B08_Quantity.B08_Quantity_Engine_HaulSummary import summary_input_rows from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table -from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slopes +from B08_Quantity.B08_Quantity_Engine_SlopeLength import road_surface_area, station_slopes from B08_Quantity.B08_Quantity_Provenance import quantity_provenance from common_util.common_util_project_settings import ( CONCRETE_PLACING_METHODS, @@ -101,7 +100,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: for name, low, high in EARTHWORK_CONVERSION_PUMSEM_C_RANGES ] # 사면 계열은 저장된 설계선에서 유도한다. - slope = build_slope_table(station_slopes(designs)) + slopes = station_slopes(designs) + slope = build_slope_table(slopes) table["slope"] = slope plan = await _stored_haul_plan(project_id, route_id) @@ -167,6 +167,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: # 임목파쇄 — 기본 꺼짐. 켠 프로젝트에서만 줄이 선다(확정 5차 5번). settings.get("wood_chipping_enabled"), settings.get("wood_chipping_volume_m3"), + # 표토제거 대상 「노면」 면적 — 사면과 같은 측점 설계에서(2026-09-13 판정 「실무대로」). + road_surface_area(slopes), ) method, method_is_default = concrete_placing_method(settings) # ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다. diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 463e2e3c..3ece5f3e 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -488,7 +488,7 @@ function buildQuantitySidePanel( } } - // ── 표토 두께 — 정해야 준비공 표토제거 줄이 선다(품셈 9-15 [주]② 의 「T : 표토두께(m)」) ── + // ── 표토 두께 — 표토 운반 부피(제거 ㎡ × T)에 씀 · 제거 줄은 ㎡ 라 안 곱함(2026-09-13 「실무대로」) ── // ⚠ 2026-09-08 ㉘ 자기 감사: 서버·엔진은 이 값을 받고 있었는데 **화면에 넣을 칸이 없었다.** // 「죽은 칸」(넣어도 안 쓰임)의 반대 짝이다 — 쓰이는데 넣을 데가 없던 자리. panel.append(field(L("B08_Quantity_Side_Topsoil"), "")); @@ -521,6 +521,10 @@ function buildQuantitySidePanel( ), ); panel.append(hintRow(L("B08_Quantity_Side_BenchCut_Hint"))); + // 층따기 관행값 — **제안값**이지 확정이 아니다(원본 그림 확인 전 · 10장 판정). 칸에 넣지 않고 보이기만. + const benchSuggest = hintRow(L("B08_Quantity_Side_BenchCut_Suggest")); + benchSuggest.classList.add("b08-quantity__hint--warn"); + panel.append(benchSuggest); panel.append( optionalNumberField( L("B08_Quantity_Side_Rubble_Label"), diff --git a/resources/tester/test_b08_handoff.py b/resources/tester/test_b08_handoff.py index 8441bb1d..173643d5 100644 --- a/resources/tester/test_b08_handoff.py +++ b/resources/tester/test_b08_handoff.py @@ -1137,13 +1137,13 @@ def _준비공표() -> dict: "tree_removal_cut": 6726.9, "bench_cut_fill": 9000.0, } - return build_prep(slope, [], [], 0.15) + return build_prep(slope, [], [], 0.15, road_surface_area_m2=8000.0) def test_준비공_값이_서는_줄은_내역에_선다() -> None: handoff = build_handoff(preparation_table=_준비공표()) 표토 = next(r for r in handoff["work_items"] if r["name"] == "표토제거") - assert 표토["work_item_code"] == "FP-09-15" + assert 표토["work_item_code"] == "FP-09-15-02" assert 표토["in_bill"] is True assert 표토["quantity"] > 0 assert 표토["blocked_kind"] is None diff --git a/resources/tester/test_b08_preparation.py b/resources/tester/test_b08_preparation.py index bc7b1b04..9d6ddb71 100644 --- a/resources/tester/test_b08_preparation.py +++ b/resources/tester/test_b08_preparation.py @@ -63,7 +63,7 @@ def test_사유가_비어_있지_않을것() -> None: def test_값이_없어도_공종코드는_알려줄것() -> None: """무엇으로 서게 될 줄인지 알아야 나중에 잇는다.""" - assert 줄(build_table(), "표토제거")["work_item_code"] == "FP-09-15" + assert 줄(build_table(), "표토제거")["work_item_code"] == "FP-09-15-02" # ── 사방공 ────────────────────────────────────────────────────────── @@ -192,30 +192,29 @@ def test_성토고가_다_낮으면_0개소() -> None: # ⚠ 안 넣으면 0 으로 때우지 않고 물량을 안 낸다. -def test_두께가_없으면_물량을_안_낼것() -> None: - row = 줄(build_table({"face_dressing_fill": 1000.0, "face_dressing_cut": 500.0}), "표토제거") - assert row["amount"] is None - # ⚠ 사유가 정정됐다(2026-09-09) — 종전에 「품셈이 정하는 값이 아닙니다」라고만 적었는데 - # 품셈 9-15-2(답외구간) 원문에 **T=0.2m · L=20m 가 적용값으로 박혀 있다.** 두께를 - # 자동으로 넣지는 않되 **원문에 있는 값을 없다고 말하지도 않는다.** - assert "두께는 설계가 정하는 값" in row["reason"] - assert "T=0.2m" in row["reason"] and "9-15-2" in row["reason"] - # 대상 면적은 참고로 보인다 — 무엇에 곱할지 사용자가 알아야 한다. - assert row["reference_amount"] == pytest.approx(1500.0) - - -def test_두께를_넣으면_물량이_설것() -> None: +def test_표토제거는_노면과_절토대상지_면적이고_두께를_안_곱한다() -> None: + """⭐ 2026-09-13 판정 「실무대로」 — 밑수 ㎡ · 대상 노면+절토대상지 · 성토 사면은 안 셈.""" table = build_table( - {"face_dressing_fill": 1000.0, "face_dressing_cut": 500.0}, topsoil_thickness_m=0.15 + {"face_dressing_fill": 1000.0, "face_dressing_cut": 500.0}, + topsoil_thickness_m=0.15, + road_surface_area_m2=4000.0, ) row = 줄(table, "표토제거") - assert row["amount"] == pytest.approx(225.0) # 1,500㎡ × 0.15m - assert row["status"] == "값 있음" + assert (row["unit"], row["amount"], row["status"]) == ("㎡", pytest.approx(4500.0), "값 있음") + assert row["work_item_code"] == "FP-09-15-02" # 답외구간 + assert "노면·절토대상지" in row["reason"] and "20m 압토" in row["reason"] -def test_두께가_0이면_안_낼것() -> None: - """0 은 「안 넣음」과 같게 본다 — 0㎥ 를 내면 「없다」로 오해된다.""" - row = 줄(build_table({"face_dressing_fill": 1000.0}, topsoil_thickness_m=0), "표토제거") +def test_노면_면적을_못_세면_물량을_안_낼것() -> None: + """노체 끝이 없는 측점이 있으면 절토 사면만으로 세우지 않는다 — 합계가 그럴듯하게 틀림.""" + row = 줄(build_table({"face_dressing_cut": 500.0}), "표토제거") + assert row["amount"] is None and "노면 면적" in row["reason"] + assert row["reference_amount"] == pytest.approx(500.0) + + +def test_면적이_0이면_안_낼것() -> None: + """0 은 「없음」과 구별이 안 된다 — 0㎡ 를 내면 「표토가 없는 노선」으로 읽힌다.""" + row = 줄(build_table({}, road_surface_area_m2=0.0), "표토제거") assert row["amount"] is None diff --git a/resources/tester/test_b08_topsoil_haul.py b/resources/tester/test_b08_topsoil_haul.py index e6c6e49d..dbb0d04c 100644 --- a/resources/tester/test_b08_topsoil_haul.py +++ b/resources/tester/test_b08_topsoil_haul.py @@ -1,15 +1,15 @@ -"""표토 운반·적치 — 법이 요구하는데 제거만 세고 있던 줄 (2026-09-09). +"""표토 운반·적치 — 법이 요구하는데 제거만 세고 있던 줄 (2026-09-09 · 2026-09-13 판정 「실무대로」). ⚠⚠ 시행규칙 별표2 Ⅰ.2.차.(6)·Ⅰ.3.카.(6): 「노면·절토대상지에 있는 입목…과 그 뿌리, **표토는 전량 제거한 후** 강우 시 유실되거나 경관에 저해되지 않도록 **최고 홍수위보다 높은 장소로 운반하고 쌓아두어야 한다**」 ⇒ 제거(9-15)만 세면 **운반이 빠진다.** -⚠ 겨누는 것 다섯 +⚠ 겨누는 것 ① 줄이 **선다** — 값이 없어도 목록에 남는다 - ② 물량은 **제거 물량 그대로** — 다시 세지 않는다 - ③ 거리는 **설계 입력** — 「최고 홍수위보다 높은 장소」는 현장이 정한다. 지어내지 않는다 - ④ ⚠ 제거가 안 서면(두께 미입력) 운반도 안 선다 — 밑수가 그 줄이다 + ② 부피는 **제거 면적(㎡) × 두께** — 제거는 ㎡ 로 서고 두께는 운반 부피에만 쓴다 + ③ 거리는 **설계 입력** · 제거 품(9-15-2)이 **20m 압토**를 품어 **넘는 몫만** 운반으로 셈 + ④ 제거가 안 서거나 두께가 비면 운반도 안 선다 — 0 으로 때우지 않는다 ⑤ 법 문구가 **사유에 적힌다** — 왜 세는지 화면에서 보여야 한다 """ @@ -22,16 +22,19 @@ ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402 + STATUS_COUNTED_ELSEWHERE, STATUS_PENDING, STATUS_READY, + build_table, preparation_rows, ) 사면 = {"face_dressing_fill": 600.0, "face_dressing_cut": 400.0} +노면 = 600.0 # 노면 600 + 절토 사면 400 = 제거 1,000㎡ -def 줄(thickness: float | None, distance: float | None) -> dict: - rows = preparation_rows(사면, [], thickness, distance) +def 줄(thickness: float | None, distance: float | None, road: float | None = 노면) -> dict: + rows = preparation_rows(사면, [], thickness, distance, None, road) return {row["item"]: row for row in rows} @@ -39,13 +42,20 @@ def test_줄이_선다() -> None: assert "표토 운반·적치" in 줄(None, None) -def test_물량은_제거_물량_그대로() -> None: - """⚠ 다시 세면 두 값이 갈린다 — 제거 줄이 밑수다.""" +def test_부피는_제거_면적_곱하기_두께_거리는_20m_넘는_몫() -> None: rows = 줄(0.2, 300.0) - assert rows["표토제거"]["amount"] == 1000.0 * 0.2 - assert rows["표토 운반·적치"]["amount"] == rows["표토제거"]["amount"] - assert rows["표토 운반·적치"]["status"] == STATUS_READY - assert rows["표토 운반·적치"]["work_item_code"] == "FP-10-12" + assert rows["표토제거"]["amount"] == 1000.0 + 운반 = rows["표토 운반·적치"] + assert 운반["amount"] == 1000.0 * 0.2 and 운반["status"] == STATUS_READY + assert 운반["work_item_code"] == "FP-10-12" + assert 운반["haul_distance_m"] == 280.0 # 300 − 압토 20 + assert "20m 를 넘는 몫" in 운반["reason"] + + +def test_20m_이내면_제거_품에_들어_따로_안_셈() -> None: + 운반 = 줄(0.2, 15.0)["표토 운반·적치"] + assert 운반["amount"] is None and 운반["status"] == STATUS_COUNTED_ELSEWHERE + assert 운반["reference_amount"] == 200.0 def test_거리를_안_넣으면_막고_사유() -> None: @@ -53,18 +63,18 @@ def test_거리를_안_넣으면_막고_사유() -> None: row = 줄(0.2, None)["표토 운반·적치"] assert row["amount"] is None and row["status"] == STATUS_PENDING assert "운반거리가 아직 입력되지 않았습니다" in row["reason"] - # 값을 버리지 않는다 — 운반할 물량은 참고로 보인다. - assert row["reference_amount"] == 200.0 + assert row["reference_amount"] == 200.0 # 값을 버리지 않는다 -def test_제거가_안_서면_운반도_안_선다() -> None: - row = 줄(None, 300.0)["표토 운반·적치"] - assert row["amount"] is None - assert "두께 먼저" in row["reason"] +def test_두께가_비거나_제거가_안_서면_운반도_안_선다() -> None: + no_thickness = 줄(None, 300.0)["표토 운반·적치"] + assert no_thickness["amount"] is None and "T=0.2m" in no_thickness["reason"] + no_removal = 줄(0.2, 300.0, road=None)["표토 운반·적치"] + assert no_removal["amount"] is None and "제거 면적이 아직 안 서서" in no_removal["reason"] def test_법_문구가_사유에_적힌다() -> None: - for thickness, distance in ((None, None), (0.2, None), (0.2, 300.0)): + for thickness, distance in ((None, None), (0.2, None), (0.2, 300.0), (0.2, 10.0)): reason = 줄(thickness, distance)["표토 운반·적치"]["reason"] assert "별표2" in reason and "운반하고 쌓아두어야" in reason @@ -73,21 +83,12 @@ def test_법_문구가_사유에_적힌다() -> None: def test_뿌리_뒤_단계_두_줄이_선다() -> None: - """⚠⚠ 「벌개·제근 → 뿌리다듬기 → **적재** → **운반**」인데 우리는 한 줄만 세고 있었다. - - 표토와 같은 병이다 — 법·품셈이 두 동작을 묶어 두는데 앞 동작만 세던 자리. - """ rows = 줄(0.2, 300.0) assert "뿌리 적재" in rows and "뿌리 운반" in rows def test_적재는_9_20_2_운반은_덤프() -> None: - """⭐ 2026-09-09 확정 5차 4번 — 「뿌리 운반은 **덤프**」로 답이 왔다. - - ⚠ 그 전에는 「9-20 장에 운반 공종이 없어 코드를 못 붙임」이었다. 지어낸 것이 아니라 - **사용자가 고른 것**이라 코드가 붙는다. - ⚠ 다만 물량은 여전히 안 선다 — 운반 밑수는 **부피(㎥)** 인데 뿌리 부피를 든 곳이 없다. - """ + """⭐ 2026-09-09 확정 5차 4번 — 「뿌리 운반은 **덤프**」. 물량은 부피가 없어 안 선다.""" rows = 줄(0.2, 300.0) assert rows["뿌리 적재"]["work_item_code"] == "FP-09-20-02" assert rows["뿌리 운반"]["work_item_code"] == "FP-10-12" @@ -96,97 +97,51 @@ def test_적재는_9_20_2_운반은_덤프() -> None: def test_적재는_면적으로_서고_운반은_못_선다() -> None: - """⭐ 확정 5차 6번으로 **면적 축**이 정해져 적재는 선다(제근과 같은 밑수). - - ⚠ 운반만 못 선다 — 축이 **부피**라서다. 「같은 확정인데 한 줄만 서는」 자리라 갈라 적는다. - ⚠ 대상 면적은 **벌개제근 연동**이라 `tree_removal_*` 에서 온다(표토 계열 `face_dressing_*` - 과 **다른 면적**이다 — 2026-09-09 실측: 17,873.8 ↔ 15,726.9). - """ - 사면 = {**{"tree_removal_fill": 600.0, "tree_removal_cut": 400.0}, **{}} - rows = {row["item"]: row for row in preparation_rows(사면, [], 0.2, 300.0)} + 사면_ = {"tree_removal_fill": 600.0, "tree_removal_cut": 400.0} + rows = {row["item"]: row for row in preparation_rows(사면_, [], 0.2, 300.0)} assert rows["뿌리 적재"]["amount"] == 1000.0 assert rows["제근·뿌리다듬기"]["amount"] == 1000.0 # 같은 밑수로 함께 섬 assert rows["뿌리 운반"]["amount"] is None assert rows["뿌리 운반"]["reference_amount"] == 1000.0 -# ── 2026-09-09 준비공 축 감사에서 잡은 둘 ────────────────────────── - - -def test_면적이_0이면_물량을_안_낸다() -> None: - """⚠ 0 은 **「없음」과 구별이 안 된다** — 받는 쪽이 「표토가 없는 노선」으로 읽는다. - - 절·성토 사면적이 0 인 임도는 성립하지 않으므로, 이 자리는 사실상 **사면표가 아직 - 안 선 것**이다. 두께 미입력은 이미 막고 있었는데 면적 0 은 그냥 통과하고 있었다. - """ - from B08_Quantity.B08_Quantity_Engine_Preparation import build_table - - rows = {r["item"]: r for r in build_table(slope_totals={}, topsoil_thickness_m=0.2)["rows"]} - 제거 = rows["표토제거"] - assert 제거["amount"] is None - assert "사면표" in 제거["reason"] - # 운반은 제거를 밑수로 삼으므로 함께 막힌다 — 0 ㎥ 를 실어 나르는 줄이 안 생긴다. - assert rows["표토 운반·적치"]["amount"] is None - - -def test_대상_면적이_법_문언과_다르다는_사실이_적힌다() -> None: - """⚠ 값을 고치는 것이 아니라 **드러내는** 시험이다(임의 확정 금지). - - 별표2 Ⅰ.2.차.(6) 은 대상을 「**노면·절토대상지**」로 못박고, 성토대상지는 (7) 에서 - 「표토 등은 **제거·정리**한다」로 따로 두어 운반·적치 의무를 안 건다. 우리는 - 절토·성토 **사면적을 다 더하고 노면은 안 센다** — 두 방향으로 어긋난다. - 실무가 어느 쪽인지는 사용자·실무자 확인 사항이라 **사유만** 낸다. - """ - from B08_Quantity.B08_Quantity_Engine_Preparation import build_table - - rows = { - r["item"]: r - for r in build_table( - slope_totals={"face_dressing_cut": 9000.0, "face_dressing_fill": 6726.9}, - topsoil_thickness_m=0.2, - )["rows"] - } - reason = rows["표토제거"]["reason"] - assert rows["표토제거"]["amount"] is not None # 값은 그대로 선다 - assert "노면·절토대상지" in reason and "노면은 안 셈" in reason - - def test_운반이_갈래를_실어_보낸다() -> None: - """⚠ `FP-10-12` 는 **부모**다 — 품이 붙은 것은 잎(10-12-1 토사 · -2 암절취 · -3 발파암)뿐. - - 갈래를 안 보내면 B09 가 「이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보 3건」 - 에서 멈추고 **금액이 안 선다.** 표토가 토사인 것은 다툼이 없으므로 갈래를 실어 보낸다. - """ + """⚠ `FP-10-12` 는 **부모**다 — 갈래(토사)를 안 보내면 B09 가 「후보 3건」에서 멈춘다.""" from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff - from B08_Quantity.B08_Quantity_Engine_Preparation import build_table table = build_table( slope_totals={"face_dressing_cut": 9000.0, "face_dressing_fill": 6726.9}, topsoil_thickness_m=0.2, topsoil_haul_distance_m=500.0, + road_surface_area_m2=12000.0, ) handed = {r["name"]: r for r in build_handoff(preparation_table=table)["work_items"]} 운반 = handed["표토 운반·적치"] assert 운반["in_bill"] is True assert (운반["variant_axis"], 운반["variant_value"]) == ("ground_class", "토사") + 제거 = handed["표토제거"] + assert (제거["unit"], 제거["quantity"], 제거["in_bill"]) == ("㎡", 21000.0, True) -def test_제거_품에_20m_압토가_들어_있다는_사실이_적힌다() -> None: - """⚠ 품셈 9-15-2 원문이 `L(운반거리) 20m` 를 적용값으로 박아 뒀다. +def test_노면_면적은_노체_폭의_평균단면적() -> None: + from types import SimpleNamespace as S - 실무 내역서(영월 2024)도 「표토제거 답외구간 / M2 · 도자 19Ton · D=20」 **한 줄뿐이고 - 운반 줄이 따로 없다.** 우리 운반 줄은 그래서 **20m 를 넘는 몫**일 때만 새 줄인데 그 - 가름을 아직 안 했다 — 값을 지어내지 않고 **그 사실을 사유에 남긴다**(이중계상 후보). - """ - from B08_Quantity.B08_Quantity_Engine_Preparation import build_table + from B08_Quantity.B08_Quantity_Engine_SlopeLength import _roadbed_width, road_surface_area - rows = { - r["item"]: r - for r in build_table( - slope_totals={"face_dressing_cut": 9000.0, "face_dressing_fill": 6726.9}, - topsoil_thickness_m=0.2, - topsoil_haul_distance_m=500.0, - )["rows"] - } - reason = rows["표토 운반·적치"]["reason"] - assert "L=20m 압토가 들어 있음" in reason and "20m 를 넘는" in reason + assert ( + _roadbed_width({"road_edges": {"left": {"offset_m": 2.5}, "right": {"offset_m": -2.5}}}) + == 5.0 + ) + assert ( + road_surface_area( + [S(chainage_m=20.0, roadbed_width_m=6.0), S(chainage_m=0.0, roadbed_width_m=4.0)] + ) + == 100.0 + ) + assert ( + road_surface_area( + [S(chainage_m=0.0, roadbed_width_m=4.0), S(chainage_m=20.0, roadbed_width_m=None)] + ) + is None + ) + assert road_surface_area([]) is None diff --git a/resources/tester/test_b09_prep_handoff_bill.py b/resources/tester/test_b09_prep_handoff_bill.py index 462a9018..c9c40f0f 100644 --- a/resources/tester/test_b09_prep_handoff_bill.py +++ b/resources/tester/test_b09_prep_handoff_bill.py @@ -32,7 +32,8 @@ SLOPE = { def _handoff_and_bill(): - handoff = build_handoff(preparation_table=build_prep(SLOPE, [], [], 0.15)) + table = build_prep(SLOPE, [], [], 0.15, road_surface_area_m2=8000.0) + handoff = build_handoff(preparation_table=table) return handoff, build_bill(handoff) @@ -50,7 +51,8 @@ def test_준비공_줄은_내역_아니면_제외로_반드시_선다() -> None: def test_금액이_서는_줄은_수량째_본체에_선다() -> None: handoff, bill = _handoff_and_bill() topsoil = next(r for r in bill.rows if not r.is_group and r.name == "표토제거") - assert Decimal(str(topsoil.quantity)).quantize(Decimal("0.01")) == Decimal("2359.04") + # ⭐ 2026-09-13 「실무대로」 — 노면 8,000 + 절토 사면 6,726.9 ㎡ (두께를 안 곱함) + assert Decimal(str(topsoil.quantity)).quantize(Decimal("0.01")) == Decimal("14726.90") in_bill = [ r["name"] for r in handoff["work_items"] if r["origin"] == "preparation" and r["in_bill"] ] diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index d3f60bfe..aebb8161 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -661,6 +661,10 @@ export const ui_locales_b2 = { "면적 × 이 값 = ㎥ — 비우면 층따기 줄이 막힙니다(확정 2차 ①)", "Area × this = ㎥ — blank keeps the bench-cut row blocked", ], + B08_Quantity_Side_BenchCut_Suggest: [ + "제안값(확정 아님) — 토사 깊이 0.5 m 이상 · 암 깊이 0.4 m 이상 · 폭 1.0 m 이상. 출처: 산림과임업기술 「임도 시공」 편절토·편성토 접속부 그림의 층따기 치수표(기술서적 · 교본은 「설계도서에 명시」). ⚠ 원본 그림을 확인하기 전이라 칸에 자동으로 넣지 않습니다", + "Suggestion, not confirmed — soil depth ≥ 0.5 m · rock ≥ 0.4 m · width ≥ 1.0 m (technical book table; the manual defers to design documents). Not auto-filled until the original figure is checked", + ], B08_Quantity_Side_Rubble_Label: ["기초잡석 두께(m)", "Rubble base thickness (m)"], B08_Quantity_Side_Rubble_Hint: [ "폭은 버림 폭과 같음(KCS 34 50 05) · 두께 0.2m 는 사용자 확정 3차 ② — 품셈 12-25 는 두께를 정하지 않음", @@ -717,8 +721,8 @@ export const ui_locales_b2 = { B08_Quantity_Ancillary_flood_supplies: ["수방대책 자재(식)", "Flood supplies (set)"], B08_Quantity_Side_TopsoilHaul_Label: ["표토 운반거리(m)", "Topsoil haul distance (m)"], B08_Quantity_Side_TopsoilHaul_Hint: [ - "⚠ 별표2 는 표토를 「전량 제거한 후 최고 홍수위보다 높은 장소로 운반하고 쌓아두어야」로 정합니다 — 거리를 넣으면 운반 줄이 섭니다", - "The law requires hauling and stockpiling removed topsoil — enter the distance to raise that row", + "⚠ 별표2 는 표토를 「전량 제거한 후 최고 홍수위보다 높은 장소로 운반하고 쌓아두어야」로 정합니다 — 거리를 넣으면 운반 줄이 섭니다. 표토제거는 노면 + 절토 사면 ㎡ 로 서고, 두께는 운반 부피(㎡ × 두께)에만 씁니다. 제거 품(9-15-2)에 20m 압토가 들어 있어 운반은 20m 를 넘는 몫만 셉니다", + "The law requires hauling and stockpiling removed topsoil — enter the distance to raise that row. Removal counts road surface + cut slope in ㎡; thickness only sizes the haul volume. The removal rate already includes a 20 m push, so only the excess is hauled", ], B08_Quantity_Side_Placing: ["콘크리트 타설", "Concrete Placing"], B08_Quantity_Side_Placing_Label: ["타설 방식", "Method"],