"""준비공·사방공 — **자리를 만들되 없는 값을 지어내지 않는다** (B08 일감 ⑪ · PLAN 8-3). 8-3 대응표에서 ❌ 로 남아 있던 둘이다. 여기서 하는 일은 **줄을 세우고, 설 수 있는 줄은 값을 채우고, 못 서는 줄은 왜 못 서는지 적는 것**이다. 빈 표를 내면 「빠뜨린 것」과 「원래 없는 것」이 구별되지 않는다. ⚠⚠ 지장목제거와 겹치지 않는다 (이중계상) 벌목·지장목제거는 **이미 토공집계의 사면 계열로 서 있다**(`tree_removal_*` × 반영률). 여기서 또 세우면 같은 나무를 두 번 벤다. 그래서 준비공의 벌목 줄은 **값을 내지 않고 「토공집계 지장목제거로 이미 섬」이라고 가리키기만** 한다. ⚠ 값이 없는 줄의 사유를 적는다 · 표토제거(9-15) — 면적은 사면적에서 나오나 **두께·대상 구간이 설계로 안 정해져 있다**. · 제근·뿌리다듬기(9-20~21) — 단위가 **「개」(그루 수)**인데 입목 본수를 우리가 안 든다. · 규준틀(11-2) — **개소** 산정 기준(구조물·절성토 구간별 몇 개소)이 안 정해져 있다. ⚠ 사방공은 이 노선에 실물이 없으면 「해당 없음」이다 있는 것처럼 0 을 적지 않는다. 구조물 목록에 사방 시설이 서면 그때 값이 선다. """ from __future__ import annotations from typing import Any, Iterable from B08_Quantity.B08_Quantity_Wording import type_label #: 사방 시설로 보는 구조물 종류 — **레지스트리의 실제 `type_id` 를 쓴다**(D 그룹 + 흙막이). #: 목록에 없으면 그 노선에 사방공이 **없는** 것이다. 이름을 지어내면 영영 안 걸린다. EROSION_CONTROL_TYPES = frozenset( { "erosion_check", # 골막이 "bed_sill", # 바닥막이 "check_dam_small", # 소형사방댐(복합형) "revetment", # 기슭막이 "soil_guard", # 흙막이 } ) #: 규준틀 — **품셈 원문이 개소 기준을 정해 둔다**(2026-09-07 ㉒ 에서 찾음). #: 11-2 [주]① 「비탈길이 **10m 이상** **20m마다** 설치한다」 #: 11-3 [주]① 「중심점에서 **성토 높이 5m 이상**에 설치한다」 #: ⚠ 재료량은 [주]④ 「설계수량에 따른다」 — **개소만 내고 재료는 미확보**로 둔다. BATTER_MIN_SLOPE_LENGTH_M = 10.0 BATTER_INTERVAL_M = 20.0 LEVEL_MIN_FILL_HEIGHT_M = 5.0 STATUS_COUNTED_ELSEWHERE = "다른 표에서 이미 섬" STATUS_PENDING = "값을 낼 근거가 없음" STATUS_NOT_APPLICABLE = "해당 없음" STATUS_READY = "값 있음" def batter_frame_count(slope_rows: Iterable[dict[str, Any]]) -> tuple[int, list[str]]: """비탈 규준틀 개소 — **비탈길이 10m 이상인 구간에서 20m마다**(품셈 11-2 [주]①). ⚠ 「10m 이상」은 **비탈길이**(사면길이) 조건이고 「20m마다」는 **노선 거리** 간격이다. 둘을 섞지 않는다 — 사면이 긴 구간의 **연장**을 20m 로 나눈다. """ length_m = 0.0 notes: list[str] = [] for row in slope_rows: lengths = row.get("lengths") or {} # 그 측점의 사면길이는 계열마다 있으나 **가장 긴 것**으로 본다(같은 사면이다). longest = max((float(v) for v in lengths.values()), default=0.0) if longest >= BATTER_MIN_SLOPE_LENGTH_M: length_m += float(row.get("distance_m") or 0.0) if length_m <= 0: return 0, ["비탈길이 10m 이상인 구간이 없어 비탈 규준틀이 서지 않음"] count = int(length_m // BATTER_INTERVAL_M) + 1 notes.append( f"비탈길이 {BATTER_MIN_SLOPE_LENGTH_M:g}m 이상 구간 {length_m:g}m ÷ " f"{BATTER_INTERVAL_M:g}m + 1 (품셈 11-2 [주]①)" ) return count, notes def level_frame_count(slope_rows: Iterable[dict[str, Any]]) -> tuple[int | None, list[str]]: """수평 규준틀 — **성토고 5m 이상 측점마다** 한 개소(품셈 11-3 [주]①). ⚠ 「비탈길이 10m 이상 20m마다」인 비탈규준틀과 **기준이 다르다** — 이쪽은 **지점 조건**이라 간격이 없다. 두 기준을 같은 식으로 쓰면 조용히 틀린다. ⚠ 성토고 칸이 아예 없으면 **0 으로 때우지 않고** 미확보로 둔다 — 「없음」과 다르다. """ rows = list(slope_rows) if not rows: return None, ["사면표가 없어 성토고를 못 봄"] if all("fill_height_m" not in row for row in rows): return None, ["성토고가 사면표에 없어 개소를 못 셈 (품셈 11-3 [주]① 「성토고 5m 이상」)"] tall = [ row for row in rows if float(row.get("fill_height_m") or 0.0) >= LEVEL_MIN_FILL_HEIGHT_M ] return len(tall), [ f"성토고 {LEVEL_MIN_FILL_HEIGHT_M:g}m 이상 측점 {len(tall)}곳 (품셈 11-3 [주]①)" ] def preparation_rows( slope_totals: dict[str, float] | None = None, slope_rows: Iterable[dict[str, Any]] = (), topsoil_thickness_m: float | None = None, topsoil_haul_distance_m: float | None = None, stand_volume_class: str | 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) return [ { "group": "준비공", "item": "벌목·지장목제거", "unit": "㎡", "amount": None, "status": STATUS_COUNTED_ELSEWHERE, # ⚠ 값을 여기서 또 내면 같은 나무를 두 번 벤다. 참고로 면적만 보인다. "reference_amount": tree_area, "reason": ( # ⚠ **소단면이 이 면적에 들어 있다** — 갈라 내지 않는다(2026-09-09 세 창 확인). # 별표2 타.(1) 이 「노출되는 절·성토면은 **전체면적**을 녹화」라 소단도 녹화 # 대상이고, 갈라 내라는 원문이 없으며 실무도 한 덩이로 센다. 가르면 「그 몫으로 # 무엇을 세나」를 우리가 정하게 되어 **근거 없는 줄**이 선다. 대신 여기 적어 둔다. "대상 면적에 **소단면이 포함**됨(별표2 타.(1) 「노출되는 면은 전체면적 녹화」 — " "원문이 가르지 않고 실무도 한 덩이로 셈). " "토공집계의 「지장목제거」로 이미 섬 — 여기서 또 세우면 이중계상. " "⚠ 다만 **공종 미확정** — 품셈 4장이 벌목을 목적별로 갈라(수확베기·단목베기·" "위험목 베기) 임도 지장목이 어디에 붙는지 원본이 말하지 않음. 지금은 공종코드 없이 감." ), "work_item_code": None, }, topsoil, # ⚠ 법이 요구하는 **운반·적치** — 제거 물량이 곧 밑수다(별표2). _topsoil_haul_row(topsoil, topsoil_haul_distance_m), _root_removal_row(slope, stand_volume_class), *_root_steps_rows(slope, stand_volume_class), _batter_frame_row(list(slope_rows)), _level_frame_row(list(slope_rows)), ] def _topsoil_row(slope: dict[str, float], thickness_m: float | None) -> dict[str, Any]: """표토제거 — **두께는 품셈이 아니라 설계가 정한다**(9-15 [주]② 「T : 표토두께(m)」). ⚠ 두께를 안 넣으면 **0 으로 때우지 않고** 물량을 안 낸다. 대상 면적은 절·성토 사면적을 쓴다(사면 계열의 면고르기 면적과 같은 자리). """ area = float(slope.get("face_dressing_fill", 0.0)) + float(slope.get("face_dressing_cut", 0.0)) # ⚠ 면적이 0 이면 **0 ㎥ 를 「값 있음」으로 내지 않는다** — 0 은 「없음」과 구별이 안 되고, # 받는 쪽이 「표토가 없는 노선」으로 읽는다. 절·성토 사면적이 0 인 임도는 성립하지 # 않으므로 이 자리는 사실상 **사면표가 아직 안 선 것**이다(2026-09-09 감사에서 잡음). if area <= 0: return { "group": "준비공", "item": "표토제거", "unit": "㎥", "amount": None, "status": STATUS_PENDING, "reason": ( "대상 면적이 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": ( "표토 두께가 아직 입력되지 않았습니다 — 품셈 9-15 [주]② 가 두께를 " "「공식의 입력 변수(T)」로 두어 **품셈이 정하는 값이 아닙니다**. " f"산출 조건에서 두께를 넣으면 값이 섭니다 (대상 면적 {area:,.1f}㎡)" ), "reference_amount": area, "work_item_code": "FP-09-15", } thickness = float(thickness_m) return { "group": "준비공", "item": "표토제거", "unit": "㎥", "amount": area * thickness, "status": STATUS_READY, "reason": (f"사면적 {area:,.1f}㎡ × 두께 {thickness:g}m (품셈 9-15) · {TOPSOIL_AREA_GAP}"), "work_item_code": "FP-09-15", } #: ⚠⚠ **대상 면적이 법 문언과 어긋난다**(2026-09-09 준비공 축 감사에서 잡음). #: 별표2 Ⅰ.2.차.(6) 은 대상을 **「노면·절토대상지」**로 못박고, 성토대상지는 (7) 에서 #: 「표토 등은 **제거·정리**한다」로 따로 두어 **운반·적치 의무를 안 건다.** #: 우리는 `face_dressing_cut + face_dressing_fill` 로 **절토·성토 사면적을 다 더하고** #: **노면(연장 × 노폭)은 안 센다** — 두 방향으로 어긋난 셈이다. #: ㉡ 계열(「또는」을 다 더함) — 성토 사면까지 운반·적치 물량에 실림 #: 빠짐 — 노면 표토가 아예 안 섬(법이 첫째로 든 대상) #: ⚠ **값을 임의로 고치지 않는다** — 실무가 사면적 한 덩이로 잡는지, 노면을 따로 잡는지가 #: 사용자·실무자 확인 사항이다. 지금은 **사유로 드러내기만** 한다. TOPSOIL_AREA_GAP = ( "⚠ 대상 면적 확인 필요 — 별표2 는 「노면·절토대상지」로 두는데 우리는 절토·성토" " **사면적을 다 더하고 노면은 안 셈**. 성토대상지는 별표2 (7) 이 「제거·정리」로만 두어" " 운반·적치 의무를 안 검. 실무 확인 뒤 대상을 확정할 것" ) #: 표토 운반·적치 — ⚠ **법이 요구하는데 우리가 제거만 세고 있던 자리**(2026-09-09). #: 시행규칙 별표2 Ⅰ.2.차.(6)·Ⅰ.3.카.(6): #: 「노면·절토대상지에 있는 입목…과 그 뿌리, **표토는 전량 제거한 후** 강우 시 유실되거나 #: 경관에 저해되지 않도록 **최고 홍수위보다 높은 장소로 운반하고 쌓아두어야 한다**」 #: ⇒ 제거(9-15)만 세면 **운반이 빠진다.** 물량은 제거 물량 그대로이고 **거리가 설계 입력**이다. #: ⚠ 거리를 지어내지 않는다 — 비면 막고 사유를 낸다. TOPSOIL_HAUL_LAW = ( "⚠ 법정 의무 — 시행규칙 별표2 「표토는 전량 제거한 후 … 최고 홍수위보다 높은 장소로" " 운반하고 쌓아두어야 한다」. 제거만 세면 운반이 빠짐" ) #: 제근 — ⚠ **본수가 아니라 임목축적으로 갈린다**(2026-09-09 원문 확인). #: 품셈 9-21 [주]① 「소림 : 임목축적이 30㎥/㏊ 이상 60 미만 · 중림 : 60 이상 90 미만 · #: 밀림 : 90 이상」. ⇒ 물어야 할 값은 **본/ha 밀도가 아니라 축적 등급**이다. #: ⚠⚠ **그런데 원문 표에 밑수 단위 열이 없다** — 9-20(뿌리다듬기·적재)은 「10주당」이라 #: 적혀 있는데 9-21 표에는 그 표기가 없다. 등급을 넣어도 **무엇당 값인지 모르면 못 센다.** #: 지어내지 않고 그 사실을 사유로 낸다(원문 확인이 필요한 자리). STAND_VOLUME_CLASSES = ("소림", "중림", "밀림") #: ⭐ 2026-09-09 **사용자 확정 5차 6번** — 제근 밑수를 **면적 축**으로 확정. #: 산림품셈 9-21 표에 밑수 표기가 없어, **건설공사 표준품셈 3-9-2 「1,000㎡당」**을 빌려 쓴다. #: ⚠ **교차 참조 표시 필수**(CLAUDE.md 3장 — 다른 품셈을 빌려 쓸 때의 규칙). #: ⇒ 이 확정으로 **제근·뿌리 적재·뿌리 운반·지장목제거 넷이 같은 면적 밑수**로 선다. ROOT_REMOVAL_BASIS = ( "밑수는 **면적 축**(사용자 확정 5차 6번) — 산림품셈 9-21 에 밑수 표기가 없어" " ⚠ **교차 참조**: 건설공사 표준품셈 **3-9-2 뿌리뽑기 「1,000㎡당」**을 빌려 씀" ) ROOT_REMOVAL_CLASS_MISSING = ( "임목축적 등급이 아직 입력되지 않았습니다 — 품셈 9-21 [주]① 이 소림(30~60㎥/㏊)·" "중림(60~90)·밀림(90 이상)으로 가름. ⚠ 본수가 아니라 **축적**이고, 산림조사부·영림계획에서" " 옮겨 적는 값이라 프로그램이 만들 수 없음" ) def _root_removal_row(slope: dict[str, float], stand_volume_class: str | None) -> dict[str, Any]: """제근·뿌리다듬기 — 등급과 밑수 단위가 다 서야 값이 난다. ⚠ 대상 면적은 지장목제거와 같은 자리(벌개제근 연동)라 이미 서 있다 — 참고로 보인다. """ area = float(slope.get("tree_removal_fill", 0.0)) + float(slope.get("tree_removal_cut", 0.0)) picked = str(stand_volume_class or "").strip() reasons = [ROOT_REMOVAL_BASIS, "대상 면적은 지장목제거와 같은 자리(벌개제근 연동)"] if picked in STAND_VOLUME_CLASSES: reasons.append(f"임목축적 등급 「{picked}」 — 품셈 9-21 [주]① 이 품을 그 축으로 가름") else: reasons.append(ROOT_REMOVAL_CLASS_MISSING) # ⚠ 등급은 **품(단가) 갈래**이지 물량 밑수가 아니다 — 면적이 서면 물량은 선다. # 등급이 비면 값은 서되 **단가를 못 고른다**는 사실만 사유로 남는다. return { "group": "준비공", "item": "제근·뿌리다듬기", "unit": "㎡", "amount": area if area > 0 else None, "status": STATUS_READY if area > 0 else STATUS_PENDING, "reason": " · ".join(reasons), "reference_amount": area, "work_item_code": "FP-09-21", } #: ⚠⚠ **품셈이 네 단계로 두었는데 우리가 한 줄만 세고 있었다**(2026-09-09). #: 9-20 가. 「벌개·제근 → **뿌리다듬기** → **적재** → **운반**」이 시공 과정 표준이고 #: [주] 「본 품에서는 제근 후 **뿌리다듬기와 적재항목**에 적용한다」로 두 항목을 준다. #: 법도 같은 말을 한다 — 별표2 「입목…과 그 뿌리, 표토는 전량 제거한 후 … **운반하고 #: 쌓아두어야** 한다」. ⇒ 제거만 세면 **적재·운반이 빠진다**(표토에서 이미 겪은 자리). #: ⚠ 적재는 9-20-2 로 코드가 있고, **운반은 그 장에 공종이 없다** — 10장 계열 어디에 붙는지 #: 원문이 말하지 않는다. 지어내지 않고 사유로 낸다. ROOT_STEPS_NOTE = ( "품셈 9-20 가. 「벌개·제근 → 뿌리다듬기 → 적재 → 운반」 · 별표2 「그 뿌리…를 전량 제거한 후" " 운반하고 쌓아두어야 한다」 — 제거만 세면 뒤 단계가 빠짐" ) #: ⭐ 2026-09-09 **사용자 확정 5차 5번** — 근주이식·임목파쇄는 **기본 안 셈**. #: ⚠ 다만 「**현장에 따라 파쇄가 적용될 필요 있음**」이라 **임목파쇄만 켤 수 있는 칸**을 둔다. #: **기본은 꺼짐**이고, **근주이식은 칸도 안 만든다**(켤 자리가 없으면 물을 일도 없다). #: ⚠ 켜도 **부피는 지어내지 않는다** — 실무는 부피(영월 78㎥ @56,124)로 세고, 그 부피를 #: 우리가 든 곳이 없다. 켜면 줄이 서고 **수량 칸이 비어 사유로 드러난다.** CHIPPING_ITEM = "임목파쇄" CHIPPING_CODE = "FP-08-11" # 이동식 임목 파쇄 CHIPPING_OFF_NOTE = ( "기본 안 셈(확정 5차 5번) — 현장에 따라 필요하면 산출 조건에서 켤 것." " 근주이식(FP-14-02)은 칸도 두지 않음" ) CHIPPING_ON_NOTE = ( "켜져 있음(확정 5차 5번) — ⚠ **파쇄할 부피(㎥)를 든 곳이 없어** 물량이 안 섬." " 실무는 부피로 셈(영월 78㎥). 산출 조건에 부피를 넣으면 값이 섬" ) def chipping_rows(enabled: Any, volume_m3: Any) -> list[dict[str, Any]]: """임목파쇄 — **켰을 때만** 줄이 선다. 끄면 줄 자체를 안 낸다. ⚠ 끈 상태에서 줄을 세우면 **빈 칸이 「세야 함」으로 읽힌다**(부대시설과 다른 자리 — 그쪽은 법정 의무라 줄이 늘 서고, 이쪽은 **셀지 말지가 설계 판단**이다). """ if not bool(enabled): return [] try: amount = float(volume_m3) if volume_m3 not in (None, "") else None except (TypeError, ValueError): amount = None return [ { "group": "준비공", "item": CHIPPING_ITEM, "unit": "㎥", "amount": amount, "status": STATUS_READY if amount and amount > 0 else STATUS_PENDING, "reason": CHIPPING_ON_NOTE if not amount else "산출 조건에서 넣은 부피 (확정 5차 5번)", "work_item_code": CHIPPING_CODE, } ] def _root_steps_rows( slope: dict[str, float], stand_volume_class: str | None ) -> list[dict[str, Any]]: """뿌리 적재·운반 — **제근과 한 벌로 가는 뒤 단계**. 제근이 서면 함께 선다.""" area = float(slope.get("tree_removal_fill", 0.0)) + float(slope.get("tree_removal_cut", 0.0)) return [ { "group": "준비공", "item": "뿌리 적재", "unit": "㎡", "amount": area if area > 0 else None, "status": STATUS_READY if area > 0 else STATUS_PENDING, "reason": ( f"{ROOT_STEPS_NOTE} · {ROOT_REMOVAL_BASIS}" " · ⚠ **품셈 9-20-2 는 「10주당」이라 밑수 축이 다름** — 면적 축으로 내고" " 그 사실을 적음(본수가 서면 그 축으로 옮길 것)" ), "reference_amount": area, "work_item_code": "FP-09-20-02", }, { # ⭐ 2026-09-09 **확정 5차 4번** — 뿌리 운반은 **덤프**로 잡는다. # ⚠ 사용자 관찰 「실제 운반품이 적용 안 되는 듯함. 나중엔 적용될 가능성도 있음」 # ⇒ 코드는 붙이되 **품이 안 붙으면 그대로 두고 사유**를 낸다. 억지로 안 붙인다. # ⚠ 물량 축이 다르다 — 운반은 **부피(㎥)** 인데 우리가 든 것은 **면적**뿐이라 # ㎥ 를 지어내지 않고 면적을 참고로만 싣는다. "group": "준비공", "item": "뿌리 운반", "unit": "㎥", "amount": None, "status": STATUS_PENDING, "reason": ( f"{ROOT_STEPS_NOTE} · 사용자 확정 5차 4번 「**덤프**로 잡음」 —" " ⚠ 운반 밑수는 **부피(㎥)** 인데 뿌리 부피를 든 곳이 없어 물량이 안 섬" " (대상 면적만 있음). ⚠ 실무에서 **운반품이 안 붙는 경우가 있음**(사용자 관찰) —" " 안 붙으면 그대로 두고 이 사유가 남음" ), "reference_amount": area, "work_item_code": "FP-10-12", }, ] def _topsoil_haul_row(topsoil: dict[str, Any], distance_m: float | None) -> dict[str, Any]: """표토 운반 — 물량은 제거 물량 그대로, 거리는 설계 입력. ⚠ 제거 줄이 안 서면(두께 미입력) 운반도 안 선다 — 밑수가 그 줄이기 때문이다. """ amount = 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: return { "group": "준비공", "item": "표토 운반·적치", "unit": "㎥", "amount": None, "status": STATUS_PENDING, "reason": f"{TOPSOIL_HAUL_LAW} · 제거 물량이 아직 안 서서 운반도 못 셈(두께 먼저)", "work_item_code": None, } if distance is None or distance <= 0: return { "group": "준비공", "item": "표토 운반·적치", "unit": "㎥", "amount": None, "status": STATUS_PENDING, "reason": ( f"{TOPSOIL_HAUL_LAW} · 운반거리가 아직 입력되지 않았습니다 — 「최고 홍수위보다" f" 높은 장소」는 현장에서 정하는 자리라 품셈이 거리를 주지 않습니다" f" (운반할 물량 {float(amount):,.2f}㎥)" ), "reference_amount": float(amount), "work_item_code": None, } return { "group": "준비공", "item": "표토 운반·적치", "unit": "㎥", "amount": float(amount), "status": STATUS_READY, "reason": f"{TOPSOIL_HAUL_LAW} · 제거 물량 그대로 · 운반거리 {distance:g}m(설계 입력)", "work_item_code": "FP-10-12", "haul_distance_m": distance, } #: 규준틀 재료 — **세는 것은 확정이고 수량만 몰랐던 자리**(2026-09-09). #: 품셈 11-2·11-3 [주]④ 「재료량은 **설계수량에 따른다**」 ⇒ 품셈이 값을 안 주는 것이지 #: 「안 센다」가 아니다. 그래서 **제안값을 보이고 사용자가 고치는** 모양으로 둔다 #: (확정 ⑨·⑩ 과 같은 틀 — 「가는 기본값이고 나 선택처럼 동작할 수 있어야 함」). #: ⚠ **제안값은 실무 관측값이지 법정 기준이 아니다** — 울진 소광 원단위 라이브러리 §8 #: 「규준틀 수평 | 개소 | 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏」. #: ⚠ **비탈 규준틀 값은 그 시트에 없다** — 수평 값을 준용하고 그 사실을 사유에 적는다. #: ⚠ **손율은 원문에 있다** — 품셈 11-2 [주]③ 비탈 **50%** · 11-3 [주]③ 수평 **80%**. FRAME_MATERIAL_SUGGESTED = { "각재 50×50": (0.0044, "㎥"), "판재 T12": (0.0029, "㎥"), "못": (0.03, "㎏"), } FRAME_MATERIAL_SOURCE = ( "⚠ 실무 관측값(울진 소광 원단위 라이브러리 §8 규준틀 수평) — **법정 기준 아님**." " 품셈 11-2·11-3 [주]④ 는 「재료량은 설계수량에 따른다」로만 둠. 산출 조건에서 고칠 수 있음" ) FRAME_LOSS_RATE = {"비탈 규준틀": 50, "수평 규준틀": 80} def frame_material_rows( frame_rows: list[dict[str, Any]], overrides: dict[str, Any] | None = None ) -> list[dict[str, Any]]: """규준틀 재료 — 개소 × 개소당 수량. **자재 축으로 보낸다.** ⚠ 개소가 안 서면 재료도 안 선다(밑수가 그 줄이다). ⚠ 값은 **제안값**이고 산출 조건에서 덮어쓸 수 있다 — 그 사실이 사유에 적힌다. """ given = overrides or {} rows: list[dict[str, Any]] = [] for frame in frame_rows: count = frame.get("amount") if not count: continue loss = FRAME_LOSS_RATE.get(str(frame.get("item")), None) for name, (default, unit) in FRAME_MATERIAL_SUGGESTED.items(): raw = given.get(name) try: per_ea = ( float(raw) if raw is not None and str(raw).strip() != "" else float(default) ) except (TypeError, ValueError): per_ea = float(default) picked = "산출 조건에서 고른 값" if raw not in (None, "") else "제안값(기본)" rows.append( { "name": name, "unit": unit, "amount": float(count) * per_ea, "destination": "material", "source": str(frame.get("item") or "규준틀"), "basis": ( f"{frame.get('item')} {float(count):g}개소 × {per_ea:g}{unit}/개소" f" ({picked}) · {FRAME_MATERIAL_SOURCE}" # ⚠ 준용이라는 사실이 상수 주석에만 있고 **화면 근거에는 없던** # 자리다 — 값이 서면 어디서 온 값인지 안 보인다(2026-09-09 감사). + ( " · ⚠ 비탈 규준틀 재료량은 그 시트에 없어 **수평 값을 준용**함" if str(frame.get("item")) == "비탈 규준틀" else "" ) + (f" · 손율 {loss}%(품셈 [주]③)" if loss else "") ), } ) return rows def _batter_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]: """비탈 규준틀 한 줄. **개소는 원문 기준으로 서고 재료는 미확보**다.""" count, notes = batter_frame_count(slope_rows) return { "group": "준비공", "item": "비탈 규준틀", "unit": "개소", "amount": float(count) if count else None, "status": STATUS_READY if count else STATUS_PENDING, "reason": ( "; ".join(notes) + " · 재료량은 품셈 11-2 [주]④ 「설계수량에 따른다」 —" + " **제안값(실무 관측)으로 서고 산출 조건에서 고칠 수 있음**" ), "work_item_code": "FP-11-02", } def _level_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]: """수평 규준틀 한 줄. 성토고를 못 보면 미확보, 보면 개소가 선다.""" count, notes = level_frame_count(slope_rows) return { "group": "준비공", "item": "수평 규준틀", "unit": "개소", "amount": float(count) if count is not None else None, "status": STATUS_READY if count is not None else STATUS_PENDING, "reason": "; ".join(notes) + ( " · 재료량은 품셈 11-3 [주]④ 「설계수량에 따른다」 —" " **제안값(실무 관측)으로 서고 산출 조건에서 고칠 수 있음**" if count is not None else "" ), "work_item_code": "FP-11-03", } def erosion_rows( structures: Iterable[dict[str, Any]] = (), names: dict[str, str] | None = None, ) -> list[dict[str, Any]]: """사방공 줄 — 이 노선에 사방 시설이 **있을 때만** 값이 선다. ⚠ 줄 이름은 **레지스트리 이름**을 쓴다 — `type_id` 를 그대로 적으면 화면에 `soil_guard` 같은 개발자 키가 뜬다(B08 ㉑ 과 같은 병). `names` 가 없으면 문구표가 받아 주고, 그것도 없으면 키를 보이되 **지어내지는 않는다**. """ found = sorted( { str(item.get("type_id")) for item in structures if str(item.get("type_id")) in EROSION_CONTROL_TYPES } ) if not found: return [ { "group": "사방공", "item": "사방 시설", "unit": "", "amount": None, "status": STATUS_NOT_APPLICABLE, "reason": "이 노선에 사방 시설이 배치돼 있지 않음 — 있는 것처럼 0 을 적지 않음", "work_item_code": None, } ] return [ { "group": "사방공", "item": type_label(type_id, names), "type_id": type_id, "unit": "개소", "amount": None, "status": STATUS_PENDING, "reason": "구조물 원단위가 아직 없음 — 전개식·관측값 모두 미확보", "work_item_code": None, } for type_id in found ] #: 부대시설·가설공사 — **법이 요구하는데 우리가 안 내던 다섯 줄**(2026-09-09 사용자 확정 ⑬). #: `key` 는 설정의 `ancillary_counts` 칸 이름, `code` 는 품셈 공종(없으면 `None`). #: ⚠ 다섯 중 **품셈에 공종이 있는 것은 가설창고 하나뿐**이다(마스터 전수 확인). #: 나머지 넷은 **금액이 못 선다** — 줄은 세우되 「공종 자체가 품셈에 없음」이라고 적는다. #: 「아직 안 만든 것」과 갈라 적어야 다음에 할 일이 달라진다. ANCILLARY_ITEMS: tuple[dict[str, Any], ...] = ( { "key": "national_point_sign", "item": "국가지점번호판", "unit": "개소", "code": None, "legal": True, "why": ( "⚠ 법정 의무 — 임도규정 제26조제5항 「국가지점번호판을 제작하여 500미터 마다 " "설치·관리하되, 필요시 거리를 조정할 수 있으며」. ⚠ 기점 포함·종점 잔여·갈림길 " "중복을 원문이 정하지 않아 **연장÷500 을 산식으로 쓰지 않는다** — 개소를 넣으면 섬" ), }, { "key": "guide_sign", "item": "임도 안내판", "unit": "개소", "code": None, "legal": True, "why": ( "⚠ 법정 의무 — 임도규정 제26조제6항 「임도의 시점 및 종점에 안내판…을 설치하여야 " "한다」. 노선을 이어 가면 최초 시점·최종 종점에 둘 수 있어 **개소는 설계 판단**임" ), }, { "key": "gate", "item": "차단기", "unit": "개소", "code": None, "legal": False, "why": "임도기술교본 11장(실무 참고) — 법령·행정규칙에는 없음. 설치 개소는 설계 판단", }, { "key": "site_container", "item": "가설창고(컨테이너)", "unit": "개소", "code": "FP-11-01", "legal": False, "why": "품셈 11-1 콘테이너형 가설건축물 — ⚠ 개소는 현장 조건이라 설계가 정함", }, { "key": "flood_supplies", "item": "수방대책 자재", "unit": "식", "code": None, "legal": False, "why": ( "임도기술교본 11장 — 비닐·말뚝·마대·삽 등을 현장 입구에 비치. " "품목·수량이 원문에 없어 **한 벌(식)로 받는다**" ), }, ) #: 품셈에 그 이름의 공종이 아예 없는 줄에 적는 사유. 「아직 안 만든 것」과 갈라 쓴다. REASON_NO_WORK_ITEM = "품셈에 그 이름의 공종이 없음 — 금액은 별도 단가로만 설 수 있음" def ancillary_rows(counts: dict[str, Any] | None = None) -> list[dict[str, Any]]: """부대시설·가설공사 줄. **개소를 안 넣어도 줄은 선다** — 빠진 것이 보이게. ⚠ 개소를 **지어내지 않는다**(확정 ⑬). 설정 `ancillary_counts` 에 넣은 값만 쓴다. """ given = {str(key): value for key, value in (counts or {}).items()} rows: list[dict[str, Any]] = [] for spec in ANCILLARY_ITEMS: raw = given.get(spec["key"]) try: amount = float(raw) if raw is not None and str(raw).strip() != "" else None except (TypeError, ValueError): amount = None reasons = [spec["why"]] if spec["code"] is None: reasons.append(REASON_NO_WORK_ITEM) if amount is None: reasons.append("개소가 아직 입력되지 않았습니다 — 넣으면 물량이 섭니다") status = STATUS_PENDING else: status = STATUS_READY if spec["code"] else STATUS_PENDING rows.append( { "group": "부대시설", "item": spec["item"], "unit": spec["unit"], "amount": amount, "status": status, "work_item_code": spec["code"], "legal_required": bool(spec["legal"]), "reason": " · ".join(reasons), } ) return rows def build_table( slope_totals: dict[str, float] | None = None, structures: Iterable[dict[str, Any]] = (), slope_rows: Iterable[dict[str, Any]] = (), topsoil_thickness_m: float | None = None, names: dict[str, str] | None = None, ancillary_counts: dict[str, Any] | None = None, topsoil_haul_distance_m: float | None = None, stand_volume_class: str | None = None, chipping_enabled: Any = False, chipping_volume_m3: Any = None, ) -> dict[str, Any]: """화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**""" rows = ( preparation_rows( slope_totals, slope_rows, topsoil_thickness_m, topsoil_haul_distance_m, stand_volume_class, ) + erosion_rows(structures, names) + chipping_rows(chipping_enabled, chipping_volume_m3) + ancillary_rows(ancillary_counts) ) return { "columns": ["구분", "공종", "단위", "수량", "상태", "사유"], "rows": rows, "ready_count": sum(1 for row in rows if row["status"] == STATUS_READY), "pending_count": sum(1 for row in rows if row["status"] == STATUS_PENDING), "row_count": len(rows), }