- 환경친화적인 도로건설 지침 부록1 확인: 뿌리분 체적 V = 0.3927D³ × 1,300㎏/㎥ — 고르개로 넣음(기본은 분배비 15/85) - 뿌리분 직경 D 는 지침 표기 「뿌리분의 직경(DBH, m)」이 모호해 흉고직경과 따로 받음 - 지상부 식을 지침 원문대로 π 3.14 로 셈 — 실정보고 예 130.40㎥ 와 바로 맞음 - 분리발주 칸 밑·사유에 「임목폐기물은 분리발주 대상 아님(환경부 회신)」 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
706 lines
35 KiB
Python
706 lines
35 KiB
Python
"""준비공·사방공 — **자리를 만들되 없는 값을 지어내지 않는다** (B08 일감 ⑪ · PLAN 8-3).
|
||
|
||
8-3 대응표에서 ❌ 로 남아 있던 둘이다. 여기서 하는 일은 **줄을 세우고, 설 수 있는 줄은
|
||
값을 채우고, 못 서는 줄은 왜 못 서는지 적는 것**이다. 빈 표를 내면 「빠뜨린 것」과
|
||
「원래 없는 것」이 구별되지 않는다.
|
||
|
||
⚠⚠ 지장목제거와 겹치지 않는다 (이중계상)
|
||
벌목·지장목제거는 **이미 토공집계의 사면 계열로 서 있다**(`tree_removal_*` × 반영률).
|
||
여기서 또 세우면 같은 나무를 두 번 벤다. 그래서 준비공의 벌목 줄은 **값을 내지 않고
|
||
「토공집계 지장목제거로 이미 섬」이라고 가리키기만** 한다.
|
||
|
||
⚠ 값이 없는 줄의 사유를 적는다
|
||
· 표토제거(9-15-2) — **노면 + 절토 사면 ㎡** · 노면 폭을 못 세면 안 섬(2026-09-13 「실무대로」).
|
||
· 제근·뿌리다듬기(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
|
||
|
||
# ⚠ 상태 낱말은 `_Preparation_Status` 한 벌에서 온다 — 700줄 제한으로 부대시설을 갈라 낼 때
|
||
# 두 파일이 같은 문자열을 각자 적으면 조용히 갈릴 자리였다(2026-09-09).
|
||
from B08_Quantity.B08_Quantity_Engine_Preparation_Ancillary import ( # noqa: E402
|
||
ANCILLARY_ITEMS,
|
||
ancillary_rows,
|
||
)
|
||
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( # noqa: E402
|
||
STATUS_COUNTED_ELSEWHERE,
|
||
STATUS_NOT_APPLICABLE,
|
||
STATUS_PENDING,
|
||
STATUS_READY,
|
||
)
|
||
from B08_Quantity.B08_Quantity_Engine_Preparation_TreeWaste import tree_waste_rows # noqa: E402
|
||
|
||
__all__ = ["ANCILLARY_ITEMS", "ancillary_rows"] # 갈라 나간 뒤에도 여기서 읽을 수 있게
|
||
|
||
|
||
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,
|
||
road_surface_area_m2: float | None = None,
|
||
topsoil_road_only: bool = False,
|
||
) -> 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, road_surface_area_m2, topsoil_road_only)
|
||
return [
|
||
{
|
||
"group": "준비공",
|
||
"item": "벌목·지장목제거",
|
||
"unit": "㎡",
|
||
"amount": None,
|
||
"status": STATUS_COUNTED_ELSEWHERE,
|
||
# ⚠ 값을 여기서 또 내면 같은 나무를 두 번 벤다. 참고로 면적만 보인다.
|
||
"reference_amount": tree_area,
|
||
"reason": (
|
||
# ⚠ **소단면이 이 면적에 들어 있다** — 갈라 내지 않는다(2026-09-09 세 창 확인).
|
||
# 별표2 타.(1) 이 「노출되는 절·성토면은 **전체면적**을 녹화」라 소단도 녹화
|
||
# 대상이고, 갈라 내라는 원문이 없으며 실무도 한 덩이로 센다. 가르면 「그 몫으로
|
||
# 무엇을 세나」를 우리가 정하게 되어 **근거 없는 줄**이 선다. 대신 여기 적어 둔다.
|
||
"대상 면적에 **소단면이 포함**됨(별표2 타.(1) 「노출되는 면은 전체면적 녹화」 — "
|
||
"원문이 가르지 않고 실무도 한 덩이로 셈). "
|
||
"토공집계의 「지장목제거」로 이미 섬 — 여기서 또 세우면 이중계상. "
|
||
"공종은 토공집계 줄이 가짐 — 잡관목제거는 품셈 4-2-2 단목베기(FP-04-02-02)"
|
||
" 「5m 미만」, 뿌리뽑기는 9-21 제근(FP-09-21)(2026-09-13 판정)."
|
||
),
|
||
"work_item_code": None,
|
||
},
|
||
topsoil,
|
||
# ⚠ 법이 요구하는 **운반·적치** — 제거 물량이 곧 밑수다(별표2).
|
||
_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)),
|
||
_level_frame_row(list(slope_rows)),
|
||
]
|
||
|
||
|
||
def _topsoil_row(
|
||
slope: dict[str, float], road_area_m2: float | None, road_only: bool = False
|
||
) -> dict[str, Any]:
|
||
"""표토제거 — **밑수 ㎡ · 대상 · 20m 압토 셋이 한 묶음**(2026-09-13 판정 · PLAN 10장).
|
||
|
||
① 밑수 **㎡** — 품셈 9-15-2 가 T=0.2 m 를 **품 조건**으로 둔 면적 축(실무 영월 「표토제거
|
||
답외구간 M2」). 두께는 여기 안 곱한다 — 운반 부피에만 쓴다.
|
||
② 대상 **노면 + 절토대상지** — 별표2 Ⅰ.2.차.(6) 문언. 성토대상지는 (7) 「제거·정리」라 안 셈.
|
||
`road_only` 면 노면만(영월 한 건 관측 · 설계자 선택 · 2026-09-14 판정).
|
||
③ **20 m 압토는 품에 듦** — 운반 줄은 그 넘는 몫만(`_topsoil_haul_row`).
|
||
⚠ 하나만 고치면 합계가 그럴듯해 보이면서 틀린다 — 셋을 함께 바꿨다.
|
||
"""
|
||
cut = 0.0 if road_only else 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 {
|
||
**base,
|
||
"amount": None,
|
||
"status": STATUS_PENDING,
|
||
"reason": (
|
||
"대상 면적이 0 ㎡ 입니다 — 횡단·사면표가 아직 서지 않았습니다. "
|
||
"0 ㎡ 로 내면 「표토가 없는 노선」으로 읽히므로 값을 세우지 않습니다"
|
||
),
|
||
}
|
||
if road_only:
|
||
return {
|
||
**base,
|
||
"amount": area,
|
||
"status": STATUS_READY,
|
||
"reason": f"노면 {area:,.1f}㎡ (노면만 — 산출 조건에서 고름)"
|
||
f" — {TOPSOIL_ROAD_ONLY_BASIS}",
|
||
}
|
||
return {
|
||
**base,
|
||
"amount": area,
|
||
"status": STATUS_READY,
|
||
"reason": (
|
||
f"노면 {float(road_area_m2):,.1f}㎡ + 절토 사면 {cut:,.1f}㎡ — {TOPSOIL_TARGET_BASIS}"
|
||
),
|
||
}
|
||
|
||
|
||
#: ⚠⚠ **원문에 적용값이 있다**(2026-09-09 마스터 원문 대조에서 잡음).
|
||
#: 품셈 **9-15-2 답(畓)외구간** 표는 조건을 이렇게 박아 둔다 —
|
||
#: `T(표토두께) 0.2m` · `L(운반거리) 20m` · `E 0.4` · 도자(삽날 3.2㎥)
|
||
#: ⇒ 앞서 여기 「품셈이 정하는 값이 아니다」라고 적었는데 **그 말이 반쪽이었다.**
|
||
#: 두께를 바꾸면 품이 달라지는 것은 맞지만, **원문 적용값은 0.2m 로 적혀 있다.**
|
||
#: 지어내지 않되 **원문에 있는 값을 없다고 말하지도 않는다.**
|
||
#: ⚠⚠ **그리고 그 품에 20m 압토가 이미 들어 있다** — 실무 내역서(영월 2024)가 정확히
|
||
#: 그 모양이다: 「표토제거 답외구간 / M2 · 도자 19Ton · D=20」 **한 줄뿐이고 운반 줄이
|
||
#: 따로 없다.** 우리 「표토 운반·적치」 줄은 그래서 **20m 를 넘는 몫**일 때만 새 줄이다.
|
||
TOPSOIL_ORIGINAL_APPLIED = (
|
||
"⚠ 품셈 9-15-2(답외구간) 원문 적용값은 **T=0.2m · L(운반거리)=20m** 입니다"
|
||
" — 값을 자동으로 넣지 않되, 원문에 있는 값이므로 참고할 것"
|
||
)
|
||
|
||
#: 표토제거 공종 — **답외구간**(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 판정 「실무대로」)"
|
||
)
|
||
#: 노면만 — 설계자가 고른 경우의 근거. ⚠ 법 문언이 아니라는 사실을 사유에 함께 적는다.
|
||
TOPSOIL_ROAD_ONLY_BASIS = (
|
||
"⚠ 법 문언(별표2 Ⅰ.2.차.(6))은 「노면·절토대상지」라 기본은 노면 + 절토"
|
||
" · 노면만은 실무 영월 한 건(1m당 3.34㎡) 관측이라 설계자 선택"
|
||
" · 밑수 ㎡ · 20m 압토는 제거 품에 듦"
|
||
)
|
||
|
||
#: 표토 운반·적치 — ⚠ **법이 요구하는데 우리가 제거만 세고 있던 자리**(2026-09-09).
|
||
#: 시행규칙 별표2 Ⅰ.2.차.(6)·Ⅰ.3.카.(6):
|
||
#: 「노면·절토대상지에 있는 입목…과 그 뿌리, **표토는 전량 제거한 후** 강우 시 유실되거나
|
||
#: 경관에 저해되지 않도록 **최고 홍수위보다 높은 장소로 운반하고 쌓아두어야 한다**」
|
||
#: ⇒ 제거(9-15)만 세면 **운반이 빠진다.** 물량은 제거 물량 그대로이고 **거리가 설계 입력**이다.
|
||
#: ⚠ 거리를 지어내지 않는다 — 비면 막고 사유를 낸다.
|
||
TOPSOIL_HAUL_LAW = (
|
||
"⚠ 법정 의무 — 시행규칙 별표2 「표토는 전량 제거한 후 … 최고 홍수위보다 높은 장소로"
|
||
" 운반하고 쌓아두어야 한다」. 제거만 세면 운반이 빠짐"
|
||
" · 제거 품(9-15-2)에 L=20m 압토가 들어 있어 이 줄은 **20m 를 넘는 몫**만 셈"
|
||
" (실무 영월 「표토제거 답외구간 / M2」 한 줄 · 운반 줄 없음)"
|
||
)
|
||
#: 제거 품이 품은 압토 거리(m) — 품셈 9-15-2 원문 적용값 `L(운반거리) 20m`.
|
||
TOPSOIL_PUSH_INCLUDED_M = 20.0
|
||
|
||
|
||
#: 제근 — ⚠ **본수가 아니라 임목축적으로 갈린다**(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㎡당」**을 빌려 씀"
|
||
)
|
||
#: ⭐ 2026-09-13 판정 Ⓑ — 제근은 **토공 줄**이다(지식DB `토공_수량.md:30` 「뿌리다듬기·적재·제근 |
|
||
#: 9-20~21 | 벌개제근 연동」). 실무 서식(영월 1.9.1 뿌리뽑기)대로 토공집계가 세고 여기는 참조.
|
||
ROOT_COUNTED_IN_SUMMARY = (
|
||
"토공집계 「지장목제거 · 뿌리뽑기」가 품셈 9-21 제근(FP-09-21)으로 셈 — 같은 면적·같은 작업이라"
|
||
" 여기서 또 세우면 이중계상(2026-09-13 판정)"
|
||
)
|
||
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_COUNTED_IN_SUMMARY,
|
||
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,
|
||
# ⭐ 2026-09-13 판정 Ⓑ — 셈은 토공집계 「지장목제거 · 뿌리뽑기」(FP-09-21)가 한다.
|
||
# 여기는 **보이되 안 실린다**(같은 면적 · 같은 작업 — 또 세면 이중계상).
|
||
"status": STATUS_COUNTED_ELSEWHERE if area > 0 else STATUS_PENDING,
|
||
"reason": " · ".join(reasons),
|
||
"reference_amount": area,
|
||
"work_item_code": None,
|
||
}
|
||
|
||
|
||
#: ⚠⚠ **품셈이 네 단계로 두었는데 우리가 한 줄만 세고 있었다**(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], thickness_m: float | None, distance_m: float | None
|
||
) -> dict[str, Any]:
|
||
"""표토 운반 — 부피는 **제거 면적 × 두께**, 거리는 설계 입력에서 **20m 압토를 뺀 몫**.
|
||
|
||
⚠ 제거 줄이 안 서거나 두께가 비면 운반도 안 선다 — 0 으로 때우지 않는다.
|
||
"""
|
||
area = topsoil.get("amount")
|
||
distance = None
|
||
if distance_m is not None:
|
||
try:
|
||
distance = float(distance_m)
|
||
except (TypeError, ValueError):
|
||
distance = None
|
||
if area is None:
|
||
return {
|
||
"group": "준비공",
|
||
"item": "표토 운반·적치",
|
||
"unit": "㎥",
|
||
"amount": None,
|
||
"status": STATUS_PENDING,
|
||
"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:
|
||
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,
|
||
}
|
||
beyond = distance - TOPSOIL_PUSH_INCLUDED_M
|
||
return {
|
||
"group": "준비공",
|
||
"item": "표토 운반·적치",
|
||
"unit": "㎥",
|
||
"amount": amount,
|
||
"status": STATUS_READY,
|
||
"reason": (
|
||
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": beyond,
|
||
# ⚠ `FP-10-12` 는 **부모**다 — 품이 붙은 것은 잎(10-12-1 토사 · -2 암절취 · -3 발파암)
|
||
# 뿐이라 갈래를 안 보내면 B09 가 「후보 3건」만 보이고 금액이 안 선다. 표토가 토사인
|
||
# 것은 다툼이 없으므로 여기서 갈래를 실어 보낸다(2026-09-09 마스터 대조에서 잡음).
|
||
"variant_axis": "ground_class",
|
||
"variant_value": "토사",
|
||
}
|
||
|
||
|
||
#: 규준틀 재료 — **세는 것은 확정이고 수량만 몰랐던 자리**(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
|
||
]
|
||
|
||
|
||
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,
|
||
road_surface_area_m2: float | None = None,
|
||
topsoil_road_only: bool = False,
|
||
tree_waste: dict[str, Any] | None = None,
|
||
tree_waste_unit_price_krw_per_ton: Any = None,
|
||
waste_separate_order: Any = False,
|
||
tree_waste_root_method: Any = None,
|
||
) -> dict[str, Any]:
|
||
"""화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**"""
|
||
rows = (
|
||
preparation_rows(
|
||
slope_totals,
|
||
slope_rows,
|
||
topsoil_thickness_m,
|
||
topsoil_haul_distance_m,
|
||
stand_volume_class,
|
||
road_surface_area_m2,
|
||
topsoil_road_only,
|
||
)
|
||
+ erosion_rows(structures, names)
|
||
+ chipping_rows(chipping_enabled, chipping_volume_m3)
|
||
# 임목폐기물 — 톤 · 비목은 경비(내역 줄 아님 · 2026-09-14 판정).
|
||
+ tree_waste_rows(
|
||
slope_totals,
|
||
tree_waste,
|
||
tree_waste_unit_price_krw_per_ton,
|
||
waste_separate_order,
|
||
tree_waste_root_method,
|
||
)
|
||
+ 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),
|
||
}
|