표토 운반(법이 요구하는데 제거만 세고 있던 자리) - 시행규칙 별표2 「표토는 전량 제거한 후 … 최고 홍수위보다 높은 장소로 운반하고 쌓아두어야 한다」 — 제거(9-15)만 세면 운반이 빠짐 - 물량은 제거 물량 그대로(다시 안 셈), 거리는 설계 입력 `topsoil_haul_distance_m` - 거리가 없으면 막고 사유 · 제거가 안 서면 운반도 안 섬(밑수가 그 줄) · 법 문구를 사유에 화면 칸(사용자 지시 「대신 페이지에 남길 것」) - 부대시설 개소 다섯(국가지점번호판·안내판·차단기·가설창고·수방자재) — 서버가 받고 있었는데 넣을 칸이 없던 자리. 산식으로 만들지 않는다는 사유를 칸 밑에 적음 - 표토 운반거리 칸 + 별표2 근거 한 줄 - tmp/tests/test_b08_topsoil_haul.py 신설(5건) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
434 lines
19 KiB
Python
434 lines
19 KiB
Python
"""준비공·사방공 — **자리를 만들되 없는 값을 지어내지 않는다** (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,
|
||
) -> 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": (
|
||
"토공집계의 「지장목제거」로 이미 섬 — 여기서 또 세우면 이중계상. "
|
||
"⚠ 다만 **공종 미확정** — 품셈 4장이 벌목을 목적별로 갈라(수확베기·단목베기·"
|
||
"위험목 베기) 임도 지장목이 어디에 붙는지 원본이 말하지 않음. 지금은 공종코드 없이 감."
|
||
),
|
||
"work_item_code": None,
|
||
},
|
||
topsoil,
|
||
# ⚠ 법이 요구하는 **운반·적치** — 제거 물량이 곧 밑수다(별표2).
|
||
_topsoil_haul_row(topsoil, topsoil_haul_distance_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",
|
||
}
|
||
|
||
|
||
#: 표토 운반·적치 — ⚠ **법이 요구하는데 우리가 제거만 세고 있던 자리**(2026-09-09).
|
||
#: 시행규칙 별표2 Ⅰ.2.차.(6)·Ⅰ.3.카.(6):
|
||
#: 「노면·절토대상지에 있는 입목…과 그 뿌리, **표토는 전량 제거한 후** 강우 시 유실되거나
|
||
#: 경관에 저해되지 않도록 **최고 홍수위보다 높은 장소로 운반하고 쌓아두어야 한다**」
|
||
#: ⇒ 제거(9-15)만 세면 **운반이 빠진다.** 물량은 제거 물량 그대로이고 **거리가 설계 입력**이다.
|
||
#: ⚠ 거리를 지어내지 않는다 — 비면 막고 사유를 낸다.
|
||
TOPSOIL_HAUL_LAW = (
|
||
"⚠ 법정 의무 — 시행규칙 별표2 「표토는 전량 제거한 후 … 최고 홍수위보다 높은 장소로"
|
||
" 운반하고 쌓아두어야 한다」. 제거만 세면 운반이 빠짐"
|
||
)
|
||
|
||
|
||
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,
|
||
}
|
||
|
||
|
||
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,
|
||
) -> dict[str, Any]:
|
||
"""화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**"""
|
||
rows = (
|
||
preparation_rows(slope_totals, slope_rows, topsoil_thickness_m, topsoil_haul_distance_m)
|
||
+ erosion_rows(structures, names)
|
||
+ 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),
|
||
}
|