"""준비공·사방공 — **자리를 만들되 없는 값을 지어내지 않는다** (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, ) -> 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) ) return [ { "group": "준비공", "item": "벌목·지장목제거", "unit": "㎡", "amount": None, "status": STATUS_COUNTED_ELSEWHERE, # ⚠ 값을 여기서 또 내면 같은 나무를 두 번 벤다. 참고로 면적만 보인다. "reference_amount": tree_area, "reason": ( "토공집계의 「지장목제거」로 이미 섬 — 여기서 또 세우면 이중계상. " "⚠ 다만 **공종 미확정** — 품셈 4장이 벌목을 목적별로 갈라(수확베기·단목베기·" "위험목 베기) 임도 지장목이 어디에 붙는지 원본이 말하지 않음. 지금은 공종코드 없이 감." ), "work_item_code": None, }, _topsoil_row(slope, topsoil_thickness_m), { "group": "준비공", "item": "제근·뿌리다듬기", "unit": "개", "amount": None, "status": STATUS_PENDING, "reason": "단위가 「개」(그루 수)인데 입목 본수를 들고 있지 않음 (품셈 9-20~21)", "work_item_code": "FP-09-21", }, _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)) 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)", "work_item_code": "FP-09-15", } 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 ] 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, ) -> dict[str, Any]: """화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**""" rows = preparation_rows(slope_totals, slope_rows, topsoil_thickness_m) + erosion_rows( structures, names ) 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), }