feat(b05·b08): 혼합석 부설 — 법령 별표2 조건 자동(종단 8% 초과 토사 · 연약·습윤 칸) · 「비포장 전 구간」 고르개(교본 3-2) · 11-4 ㎥ + 사전터파기 토공 축 · 자재총괄 혼합석(12장 D 포장 둘 · 브레인 ⓑ①~⑤)

- 자재 = 다짐 후 부피 ÷ C × L · 두께 제안 0.10(교본) · C 0.85 · L 1.25 제안(소광 관측 · 원문 표에 혼합석 줄 없음 · 역 C 와 방향 반대 사유)
- 종단 경사는 계획선에서 B05 포장 제안과 같은 한 벌(local_grade_pct 로 뺌 — 종단 파일 측점 경사엔 비정규 측점이 없었음)
- 해당 측점이 없으면 0 원 줄 안 세움 · 다짐 줄은 공종 없어 사유

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-15 04:55:31 +09:00
co-authored by Claude Opus 5
parent 196bd73a5b
commit 4186a851a4
12 changed files with 624 additions and 19 deletions
+19 -15
View File
@@ -295,6 +295,24 @@ def _append_design_profiles(
return {"id": profile["id"], **profile["summary"]}
def local_grade_pct(points: list[tuple[float, float]], chainage: float) -> float:
"""측점을 감싸는 인접 계획선 구간들의 경사 중 최댓값(절댓값 %)을 돌려준다.
`points` = 계획선 `(누가거리, 표고)`. 포장 제안과 B08 혼합석 법령 조건(종단 8%)이 같은 한 벌을 씀.
"""
worst = 0.0
for index in range(1, len(points)):
c0, z0 = points[index - 1]
c1, z1 = points[index]
if c1 < chainage - 1e-6 or c0 > chainage + 1e-6:
continue
span = c1 - c0
if span <= 1e-9:
continue
worst = max(worst, abs((z1 - z0) / span) * 100.0)
return worst
def _annotate_pavement_suggestions(
longitudinal: dict[str, Any], grade_options: GradeDesignOptions | None
) -> None:
@@ -326,25 +344,11 @@ def _annotate_pavement_suggestions(
criteria["max_grade_pct"].get(terrain, criteria["max_grade_pct"]["normal"])
)
def local_grade_pct(chainage: float) -> float:
"""측점을 감싸는 인접 계획선 구간들의 경사 중 최댓값(절댓값 %)을 돌려준다."""
worst = 0.0
for index in range(1, len(points)):
c0, z0 = points[index - 1]
c1, z1 = points[index]
if c1 < chainage - 1e-6 or c0 > chainage + 1e-6:
continue
span = c1 - c0
if span <= 1e-9:
continue
worst = max(worst, abs((z1 - z0) / span) * 100.0)
return worst
for station in stations:
chainage = station.get("chainage_m")
if not isinstance(chainage, (int, float)):
continue
grade_pct = local_grade_pct(float(chainage))
grade_pct = local_grade_pct(points, float(chainage))
station["pavement_suggested"] = grade_pct > unpaved_limit + 1e-6
station["pavement_grade_pct"] = round(grade_pct, 2)
station["pavement_grade_limit_pct"] = round(unpaved_limit, 2)
@@ -122,6 +122,8 @@ class SummaryInput:
# 토취(반입토) — 유토곡선이 낸 성토 부족분(다짐상태 ㎥)과 구간(2026-09-14 브레인 ①).
borrow_m3: float = 0.0
borrow_sites: list[dict[str, Any]] = field(default_factory=list)
# 혼합석 부설 — `Engine_GravelSurfacing.gravel_surfacing` 한 벌(2026-09-15 브레인 포장 둘 ⓑ).
gravel: dict[str, Any] | None = None
#: 토취(반입토) 줄 — 수량만 서고 금액은 사유(「줄은 서고 금액은 안 섬」).
@@ -313,6 +315,32 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
group="층따기", spec="백호우", unit="㎡", amount=slope.get("bench_cut_fill", 0.0)
)
)
if source.gravel is not None:
# 노면 — 혼합석 부설(11-4 ㎥ = 흐트러진 부피) · 사전 터파기는 토공 축(브레인 ①).
note = " · ".join(source.gravel.get("notes") or [])
loose = float(source.gravel.get("loose_m3") or 0.0)
dig = float(source.gravel.get("excavation_soil_m3") or 0.0)
zero = "물량 0 이라 내역에 안 세움 · " # 0 원 줄은 「없음」과 구별이 안 됨(구조물 줄과 같은 규칙)
rows.append(
SummaryRow(
group="혼합석부설",
spec="유압식백호우(0.7㎥)",
amount=loose,
note=f"{'' if loose > 0 else zero}"
f"면적 {float(source.gravel.get('area_m2') or 0.0):,.2f}㎡ · {note}",
in_bill=loose > 0,
)
)
rows.append(
SummaryRow(
group="혼합석 사전터파기",
spec="토사 · 기계(굴삭기)",
amount=dig,
note=f"{'' if dig > 0 else zero}"
f"토사 면적 {float(source.gravel.get('soil_area_m2') or 0.0):,.2f}㎡ × 0.10m",
in_bill=dig > 0,
)
)
return rows
@@ -0,0 +1,163 @@
"""혼합석(쇄석) 부설 — **법령 조건 자동 · 연약·습윤 칸 · 「비포장 전 구간」 고르개** (2026-09-15).
근거
① 법령 별표2 Ⅰ.2.바.(2) — 종단 8% 초과 사질·점토 구간 · 8% 이하 연약·습윤 구간에 쇄석·자갈 부설
(최소 요구 · 조건이 또렷함 ⇒ 기본)
② 임도기술교본 3-2 — 「콘크리트 포장 구간 이외에는 혼합석 부설 다짐 후 0.10m 내외」
(더 넓은 권장 ⇒ 고르개)
③ 임도기술교본 10-1 · 부록 7-2 — 포설 전 10㎝ 터파기 · 적용범위 「노면의 토사구간」
④ 품셈 11-4 쇄석·혼합석 부설(㎥ · 백호 부설만) · 1-2-3 체적환산표(혼합석 줄 없음)
⚠ 면적은 측점 사이 평균(끝 둘 중 하나만 해당이면 반만) — 토적표 평균단면적법과 같은 결.
"""
from __future__ import annotations
from typing import Any
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import _num
LEGAL_GRADE_PCT = 8.0
THICKNESS_SUGGESTED_M = 0.10 # 교본 3-2 「다짐 후 0.10m 내외」
PRE_EXCAVATION_M = 0.10 # 교본 10-1 「포설 전 10㎝ 터파기」
C_SUGGESTED, L_SUGGESTED = 0.85, 1.25 # 실무 소광 관측
NOTE_LEGAL = (
"법령 별표2 Ⅰ.2.바.(2) 조건으로 자동 — 종단 8% 초과 토사 측점 + 8% 이하 중 연약·습윤 칸 구간"
" · 교본 3-2 는 「콘크리트 포장 구간 이외」 전부(산출 조건 「비포장 전 구간」으로 넓힘)"
)
NOTE_ALL = (
"비포장 전 구간 — 임도기술교본 3-2 「콘크리트 포장 구간 이외에는 혼합석 부설」 · 법령 별표2"
" Ⅰ.2.바.(2) 는 종단 8% 초과 사질·점토 / 8% 이하 연약·습윤 구간만 요구"
)
NOTE_SOIL = (
"법령은 사질·점토를 가르는데 우리 프리셋은 토사 하나 — 토사 전부를 해당으로 봄(암은 비해당)"
)
NOTE_GRADE = "종단 경사 = 종단 계획선에서 측점을 감싼 구간 경사 중 큰 값(B05 포장 제안과 같은 식)"
NOTE_WIDTH = "폭 = 차도 표준 폭 + 확폭(노견 제외 · 실무 소광 「혼합석부설 B=3.0」)"
NOTE_COMPACTION = (
"다짐 줄 없음 — 품셈 11-4 는 부설만이고 산림품셈에 쇄석 다짐 공종이 없음"
" · 9-16-2 노체다짐은 「대규모 성토지 층다짐」 조건이라 안 이음"
)
NOTE_EXCAVATION = (
"사전 터파기 — 교본 10-1 「포설 전 10㎝ 터파기」 · 부록 7-2 적용범위 「노면의 토사구간」이라"
" 토사 측점만 · 파낸 흙의 운반·사토는 유토곡선 밖이라 안 셈"
)
def _factor_note(c: float, l_factor: float, suggested: bool) -> str:
head = (
f"C {c:g} · L {l_factor:g} — 제안값 실무 소광 관측"
if suggested
else f"C {c:g} · L {l_factor:g} — 산출 조건 칸"
)
return (
f"{head} · 자재 = 다짐 후 부피 ÷ C × L · 원문 표에 혼합석 줄 없음(품셈 1-2-3 가까운 줄"
" 역(礫) L 1.10~1.20 · C 1.05~1.10) · ⚠ 소광 C 0.85(다지면 줄어듦)와 원문 역 C(늘어남)는"
" 방향이 반대 — 같은 C 라도 뜻이 다를 수 있음"
)
def _qualifies(
chainage: float,
design: dict[str, Any],
grades: dict[float, float],
soft_wet: list[tuple[float, float]],
all_unpaved: bool,
) -> bool | None:
"""이 측점에 까는가 — 경사를 모르면 `None`(판정 못 함)."""
if design.get("paved"):
return False
if all_unpaved:
return True
grade = grades.get(round(chainage, 3))
if grade is None:
return None
if grade > LEGAL_GRADE_PCT:
return design.get("ground_type") == "soil"
return any(start <= chainage <= end for start, end in soft_wet)
def gravel_surfacing(
designs: list[dict[str, Any]] | None,
grades: dict[float, float] | None,
settings: dict[str, Any],
) -> dict[str, Any]:
"""노선 혼합석 부설 한 벌 — 면적·부피·사전 터파기·사유."""
grades = {round(float(k), 3): float(v) for k, v in (grades or {}).items()}
all_unpaved = bool(settings.get("gravel_all_unpaved"))
soft_wet = [
tuple(sorted((_num(r.get("from_m")), _num(r.get("to_m")))))
for r in settings.get("gravel_soft_wet_ranges") or []
if isinstance(r, dict)
]
stations = sorted(
(
(_num(item.get("chainage_m")), item["design"])
for item in designs or ()
if isinstance(item, dict) and isinstance(item.get("design"), dict)
),
key=lambda row: row[0],
)
weights: list[tuple[float, float, float]] = [] # (측점, 폭×해당, 폭×해당×토사)
unknown = 0
for chainage, design in stations:
hit = _qualifies(chainage, design, grades, soft_wet, all_unpaved)
unknown += hit is None
width = (
_num(design.get("carriageway_standard_width_m"))
+ _num(design.get("widening_left_m"))
+ _num(design.get("widening_right_m"))
)
on = width if hit else 0.0
weights.append((chainage, on, on if design.get("ground_type") == "soil" else 0.0))
area = soil_area = 0.0
for (s0, w0, t0), (s1, w1, t1) in zip(weights, weights[1:]):
area += (w0 + w1) / 2 * (s1 - s0)
soil_area += (t0 + t1) / 2 * (s1 - s0)
thickness = _num(settings.get("gravel_thickness_m")) or THICKNESS_SUGGESTED_M
c = _num(settings.get("gravel_conversion_c")) or C_SUGGESTED
l_factor = _num(settings.get("gravel_conversion_l")) or L_SUGGESTED
suggested = not (settings.get("gravel_conversion_c") or settings.get("gravel_conversion_l"))
notes = [NOTE_ALL if all_unpaved else NOTE_LEGAL, NOTE_SOIL, NOTE_GRADE, NOTE_WIDTH]
notes.append(
f"두께(다짐 후) {thickness:g}m — "
+ (
"산출 조건 칸"
if settings.get("gravel_thickness_m")
else "제안값 교본 3-2 「0.10m 내외」"
)
)
notes += [_factor_note(c, l_factor, suggested), NOTE_COMPACTION, NOTE_EXCAVATION]
if unknown:
notes.append(f"종단 경사를 못 읽은 측점 {unknown}곳은 안 깜(판정 못 함)")
compacted = area * thickness
return {
"all_unpaved": all_unpaved,
"area_m2": area,
"soil_area_m2": soil_area,
"thickness_m": thickness,
"compacted_m3": compacted,
"loose_m3": compacted / c * l_factor,
"excavation_soil_m3": soil_area * PRE_EXCAVATION_M,
"notes": notes,
# 칸이 비면 쓰는 제안값 — 화면이 회색으로 보임(값의 정의처는 여기 한 곳).
"suggested": {"thickness_m": THICKNESS_SUGGESTED_M, "c": C_SUGGESTED, "l": L_SUGGESTED},
}
def gravel_material_rows(result: dict[str, Any] | None) -> list[dict[str, Any]]:
"""혼합석 자재(흐트러진 부피) → 자재총괄 성분 한 줄 — 부피가 없으면 빈 목록."""
amount = _num((result or {}).get("loose_m3"))
if amount <= 0:
return []
return [
{
"name": "혼합석",
"spec": "",
"unit": "㎥",
"amount": amount,
"destination": "material",
"source": "혼합석 부설",
}
]
@@ -15,7 +15,9 @@
from __future__ import annotations
import asyncio
import json
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
@@ -40,6 +42,7 @@ from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import (
)
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_GravelSurfacing import gravel_surfacing
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 (
@@ -162,8 +165,14 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
"by_equipment": check.details,
}
# 혼합석 부설 — 법령 조건 자동(종단 8% = B05 측점 경사 · 토질 = 지반 프리셋) · 칸(2026-09-15).
gravel = gravel_surfacing(
designs, await _station_grades(project_id, route_id, project_root, designs), settings
)
table["gravel"] = gravel
table["summary"] = build_summary_table(
SummaryInput(
gravel=gravel,
earthwork_totals=table.get("totals") or {},
slope_totals=slope.get("totals") or {},
haul_rows=summary_input_rows(haul),
@@ -471,6 +480,38 @@ async def _stored_haul_plan(project_id: UUID, route_id: int) -> dict[str, Any] |
return plan if isinstance(plan, dict) and plan else None
async def _station_grades(
project_id: UUID, route_id: int, project_root: str | None, designs: list[dict[str, Any]]
) -> dict[float, float]:
"""횡단 측점마다 종단 경사(%) — 종단 파일 계획선에서 B05 포장 제안과 **같은 식**으로.
⚠ 종단 파일 `stations` 의 `pavement_grade_pct` 는 정규 측점뿐(비정규 측점 85.052 등이 빠짐) —
계획선에서 바로 셈. 못 읽으면 빈 표(혼합석 판정이 「경사 못 읽음」 사유).
"""
from B05_Profile.B05_Profile_Engine_Sections import local_grade_pct
try:
row = await run_with_connection(get_longitudinal_section, project_id, route_id)
path = Path(str(project_root)) / str((row or {})["longitudinal_file_path"])
profiles = json.loads(path.read_text(encoding="utf-8")).get("design_profiles") or []
points = [
(float(s["chainage_m"]), float(s["elevation_m"]))
for s in (profiles[0].get("samples") or [] if profiles else [])
if isinstance(s.get("chainage_m"), (int, float))
and isinstance(s.get("elevation_m"), (int, float))
]
except Exception:
logger.warning("B08 혼합석 — 종단 계획선을 못 읽음: route_id=%s", route_id)
return {}
if len(points) < 2:
return {}
return {
round(float(item["chainage_m"]), 3): local_grade_pct(points, float(item["chainage_m"]))
for item in designs
if isinstance(item, dict) and isinstance(item.get("chainage_m"), (int, float))
}
@router.put("/{project_id}/quantity/settings")
async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -> JSONResponse:
"""산출 조건을 정본에 남긴다 — [저장]이 부르는 자리.
@@ -517,6 +558,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
"ancillary_counts",
# 고른 계수를 **기본값으로 되돌릴 길**이 있어야 한다 — 병합이면 못 지운다.
"conversion_factors_override",
"gravel_soft_wet_ranges",
)
+ NULLABLE_SETTING_KEYS,
)
@@ -87,6 +87,14 @@ class QuantitySettingsBody(BaseModel):
conversion_factors_override: dict[str, Any] | None = None
# 도쟈 한계거리(m) — `None` 은 기본값(60 m). 종무대 20 m 보다 커야 한다(도쟈 몫이 사라짐).
dozer_haul_limit_m: float | None = None
# 혼합석 부설(2026-09-15 브레인 포장 둘 ⓑ) — 「비포장 전 구간」 고르개(교본 3-2) · 연약·습윤 구간
# `[{from_m, to_m}]`(법령 별표2 Ⅰ.2.바.(2) 8% 이하 조건 — 판정 근거가 없어 설계자 칸) ·
# 두께(다짐 후)·C·L 은 비면 제안값(교본 0.10 · 소광 0.85/1.25).
gravel_all_unpaved: bool | None = None
gravel_soft_wet_ranges: list[dict[str, Any]] | None = None
gravel_thickness_m: float | None = None
gravel_conversion_c: float | None = None
gravel_conversion_l: float | None = None
#: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**.
@@ -102,6 +110,9 @@ NULLABLE_SETTING_KEYS = (
"tree_waste_unit_price_krw_per_ton",
"face_dressing_fill_area_m2",
"face_dressing_cut_area_m2",
"gravel_thickness_m",
"gravel_conversion_c",
"gravel_conversion_l",
)
@@ -161,4 +172,14 @@ def clean_setting_values(values: dict[str, Any]) -> str | None:
for name, method in values["rock_methods"].items()
if method in ROCK_METHODS
}
if "gravel_soft_wet_ranges" in values:
# 숫자 둘이 다 선 구간만 · 앞뒤를 바로잡음 — 빈 목록은 「구간 없음」으로 통째로 갈아 끼움.
values["gravel_soft_wet_ranges"] = [
{"from_m": min(a, b), "to_m": max(a, b)}
for entry in values["gravel_soft_wet_ranges"] or []
if isinstance(entry, dict)
for a, b in [(entry.get("from_m"), entry.get("to_m"))]
if all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in (a, b))
and a != b
]
return None
+11 -3
View File
@@ -45,6 +45,7 @@ from common_util.common_util_project_settings import (
rock_classes,
rock_method,
)
from B08_Quantity.B08_Quantity_Engine_GravelSurfacing import gravel_material_rows
from B08_Quantity.B08_Quantity_Engine_HaulInputs import haul_inputs
from B08_Quantity.B08_Quantity_Engine_Preparation import frame_material_rows
from B08_Quantity.B08_Quantity_Engine_Preparation_Ancillary import ancillary_material_rows
@@ -159,6 +160,7 @@ def material_table_for(
unit_table: dict[str, Any],
settings: dict[str, Any],
preparation_table: dict[str, Any] | None,
gravel: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""자재총괄 **한 벌** — 화면(`material-summary`)·인계(`handoff`)가 같이 부름(브레인 판정).
@@ -180,7 +182,9 @@ def material_table_for(
concrete_placing_method=settings.get("concrete_placing_method"),
extra_materials=frame_material_rows(frame_rows, settings.get("frame_material") or {})
# 공종 없는 부대시설 개소 — 「자재 단가」 탭에 단가를 넣는 통로(2026-09-14 브레인 판정).
+ ancillary_material_rows((preparation_table or {}).get("rows") or []),
+ ancillary_material_rows((preparation_table or {}).get("rows") or [])
# 혼합석 자재(흐트러진 부피) — 11-4 는 부설 품뿐이라 재료는 여기서(2026-09-15).
+ gravel_material_rows(gravel),
)
@@ -223,7 +227,9 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
)
# 인계와 같은 한 벌 — 규준틀 재료가 준비공 표를 따라 서므로 토공 표를 받음.
earthwork = await _earthwork_tables(project_id)
material_table = material_table_for(unit_table, settings, earthwork.get("preparation"))
material_table = material_table_for(
unit_table, settings, earthwork.get("preparation"), earthwork.get("gravel")
)
# 묶음으로 서는 구조물의 조각을 화면에도 보인다 — 코드만으로는 사람이 검증 못 한다.
handoff = build_handoff(unit_quantity_table=unit_table)
composite = [
@@ -474,7 +480,9 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
# 토공·운반 표는 토적표 라우터의 것을 그대로 쓴다 — 여기서 다시 만들지 않는다.
earthwork = await _earthwork_tables(project_id)
# 자재총괄 — 화면과 같은 한 벌(규준틀 재료 포함).
material_table = material_table_for(unit_table, settings, earthwork.get("preparation"))
material_table = material_table_for(
unit_table, settings, earthwork.get("preparation"), earthwork.get("gravel")
)
handoff = build_handoff(
summary_table=earthwork.get("summary"),
@@ -168,6 +168,8 @@ export interface EarthworkTable {
face_dressing_choices?: { cut: string[]; fill: string[] };
/** 초류종자살포 비탈면 토질 선택지(5-24 잎 둘) — 서버 한 곳 · 제안값 없음. */
seed_spray_choices?: { choices: string[] };
/** 혼합석 부설 — 서버가 셈한 면적·부피·제안값(2026-09-15). 화면은 보이기만. */
gravel?: import("./B08_Quantity_UI_Side_Gravel").GravelSummary;
/** 제근 굴착기 크기 선택지·제안(회색 · [제안값 넣기]) — 서버 한 곳. */
root_removal_excavator_choices?: {
choices: string[];
+12 -1
View File
@@ -44,6 +44,8 @@ import { appendTreeWasteFields } from "./B08_Quantity_UI_Side_TreeWaste";
import { appendFaceDressingFields } from "./B08_Quantity_UI_Side_FaceDressing";
import { appendRootRemovalFields } from "./B08_Quantity_UI_Side_RootRemoval";
import { appendSeedSprayField } from "./B08_Quantity_UI_Side_SeedSpray";
import type { GravelDraft } from "./B08_Quantity_UI_Side_Gravel";
import { appendGravelFields, gravelDraftFrom, gravelPayload } from "./B08_Quantity_UI_Side_Gravel";
import { appendBenchCutFields } from "./B08_Quantity_UI_Side_BenchCut";
/** locale 헬퍼 */
@@ -133,6 +135,7 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
conversion_factors_override: conversionOverridePayload(draft.conversion_factors),
// 도쟈 한계거리 — `null` 도 보낸다(기본값으로 되돌리는 길).
dozer_haul_limit_m: draft.dozer_haul_limit_m,
...gravelPayload(draft), // 혼합석 부설 — 칸은 `_Side_Gravel`
}),
},
);
@@ -285,7 +288,7 @@ export interface SupplyChoice {
}
/** 저장 전 변경분 — 조작은 여기 쌓이고 [저장]에서만 정본으로 간다. */
interface DraftSettings {
interface DraftSettings extends GravelDraft {
rock_class_set?: string;
rock_ratios_pct: Record<string, number>;
application_ratios_pct: Record<string, number>;
@@ -733,6 +736,13 @@ function buildQuantitySidePanel(
),
);
panel.append(hintRow(L("B08_Quantity_Side_SubgradeCompaction_Hint")));
// ── 혼합석 부설 — 법령 조건 자동 · 연약·습윤 칸 · 전 구간 고르개(2026-09-15 브레인 ⓑ).
appendGravelFields(panel, draft, table?.gravel, {
field,
optionalNumberField,
selectField,
hintRow,
});
// ── 임목폐기물 — 조사값 넷 · 수동 처리단가 · 분리발주(2026-09-14). 칸은 따로 뺀 파일에서.
appendTreeWasteFields(panel, draft, { field, optionalNumberField, selectField, hintRow });
@@ -1065,6 +1075,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
]),
),
dozer_haul_limit_m: (stored.dozer_haul_limit_m as number | null) ?? null,
...gravelDraftFrom(stored as Record<string, unknown>),
dirty: false,
};
const reload = (): void => {
+159
View File
@@ -0,0 +1,159 @@
/* =============================================================================
* B08_Quantity_UI_Side_Gravel.ts
* 산출 조건 「혼합석 부설」 구획 — 범위 고르개 · 연약·습윤 구간 · 두께·C·L (2026-09-15 브레인 포장 둘 ⓑ).
*
* 페이지 본문(`B08_Quantity_UI_Page.ts`)이 700줄을 넘어 새 칸을 이 파일로 뺌(CLAUDE.md 4장).
* ⚠ 화면은 셈하지 않음 — 면적·부피는 서버 `gravel` 을 보이기만 · 제안값도 서버 `gravel.suggested`.
* ⚠ 빈 칸(`null`)은 「제안값으로」 — 0 과 다름. 구간 글자를 못 읽으면 저장값을 안 바꾸고 알림.
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import type { SideFieldHelpers } from "./B08_Quantity_UI_Side_TreeWaste";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export interface GravelRange {
from_m: number;
to_m: number;
}
export interface GravelDraft {
gravel_all_unpaved: boolean;
gravel_soft_wet_ranges: GravelRange[];
gravel_thickness_m: number | null;
gravel_conversion_c: number | null;
gravel_conversion_l: number | null;
}
/** 서버 `gravel_surfacing` 결과 중 화면이 보이는 몫. */
export interface GravelSummary {
area_m2: number;
loose_m3: number;
excavation_soil_m3: number;
suggested?: { thickness_m: number; c: number; l: number };
}
export function gravelDraftFrom(stored: Record<string, unknown>): GravelDraft {
const ranges = Array.isArray(stored.gravel_soft_wet_ranges)
? (stored.gravel_soft_wet_ranges as GravelRange[])
: [];
const num = (value: unknown) => (typeof value === "number" ? value : null);
return {
gravel_all_unpaved: Boolean(stored.gravel_all_unpaved),
gravel_soft_wet_ranges: ranges.map((r) => ({ from_m: r.from_m, to_m: r.to_m })),
gravel_thickness_m: num(stored.gravel_thickness_m),
gravel_conversion_c: num(stored.gravel_conversion_c),
gravel_conversion_l: num(stored.gravel_conversion_l),
};
}
/** [저장] 몸통 — 구간은 **통째로**(지운 구간까지 가야 되돌릴 길이 있음) · 숫자 `null` 도 보냄. */
export function gravelPayload(draft: GravelDraft): GravelDraft {
return gravelDraftFrom(draft as unknown as Record<string, unknown>);
}
/** 「120~160, 480~520」 → 구간 목록. 한 조각이라도 못 읽으면 `null`(빈 글은 빈 목록). */
export function parseGravelRanges(text: string): GravelRange[] | null {
const parts = text
.split(",")
.map((part) => part.trim())
.filter(Boolean);
const ranges: GravelRange[] = [];
for (const part of parts) {
const match = /^(\d+(?:\.\d+)?)\s*[~\-]\s*(\d+(?:\.\d+)?)$/.exec(part);
if (!match) return null;
const a = Number(match[1]);
const b = Number(match[2]);
if (a === b) return null;
ranges.push({ from_m: Math.min(a, b), to_m: Math.max(a, b) });
}
return ranges;
}
export function appendGravelFields(
panel: HTMLElement,
draft: GravelDraft & { dirty: boolean },
summary: GravelSummary | undefined,
h: SideFieldHelpers,
): void {
panel.append(h.field(L("B08_Quantity_Side_Gravel"), ""));
panel.append(
h.selectField(
L("B08_Quantity_Gravel_Scope"),
draft.gravel_all_unpaved ? "all" : "",
[
{ value: "", label: L("B08_Quantity_Gravel_Scope_Legal") },
{ value: "all", label: L("B08_Quantity_Gravel_Scope_All") },
],
(value) => {
draft.gravel_all_unpaved = value === "all";
draft.dirty = true;
},
),
);
const row = document.createElement("label");
row.className = "b08-quantity__field";
const name = document.createElement("span");
name.textContent = L("B08_Quantity_Gravel_SoftWet");
const input = document.createElement("input");
input.type = "text";
input.className = "b08-quantity__input";
input.placeholder = L("B08_Quantity_Gravel_SoftWet_Placeholder");
input.value = draft.gravel_soft_wet_ranges.map((r) => `${r.from_m}~${r.to_m}`).join(", ");
const warn = h.hintRow("");
input.addEventListener("input", () => {
const parsed = parseGravelRanges(input.value);
warn.textContent = parsed ? "" : L("B08_Quantity_Gravel_SoftWet_Invalid");
if (!parsed) return;
draft.gravel_soft_wet_ranges = parsed;
draft.dirty = true;
});
row.append(name, input);
panel.append(row, warn);
const suggested = summary?.suggested;
const number = (
label: keyof typeof ui_locales,
value: number | null,
hint: number | undefined,
set: (v: number | null) => void,
) => {
const field = h.optionalNumberField(L(label), value, "0.01", (typed) => {
set(typed);
draft.dirty = true;
});
const box = field.querySelector("input");
if (box && hint !== undefined)
box.placeholder = `${L("B08_Quantity_Gravel_Suggested")} ${hint}`;
return field;
};
panel.append(
number(
"B08_Quantity_Gravel_Thickness",
draft.gravel_thickness_m,
suggested?.thickness_m,
(v) => {
draft.gravel_thickness_m = v;
},
),
number("B08_Quantity_Gravel_C", draft.gravel_conversion_c, suggested?.c, (v) => {
draft.gravel_conversion_c = v;
}),
number("B08_Quantity_Gravel_L", draft.gravel_conversion_l, suggested?.l, (v) => {
draft.gravel_conversion_l = v;
}),
);
if (summary) {
const fmt = (value: number) => value.toLocaleString("ko-KR", { maximumFractionDigits: 2 });
panel.append(
h.hintRow(
`${L("B08_Quantity_Gravel_Now")} — ${fmt(summary.area_m2)}㎡ · ${fmt(summary.loose_m3)}㎥` +
` · ${L("B08_Quantity_Gravel_Excavation")} ${fmt(summary.excavation_soil_m3)}㎥`,
),
);
}
panel.append(h.hintRow(L("B08_Quantity_Side_Gravel_Hint")));
}
@@ -181,6 +181,21 @@
"variant_missing_reason": "제근 굴착기 크기·임목축적 등급이 아직 다 입력되지 않았습니다 — 산출 조건에서 고르면 단가가 섭니다(품셈 9-21: 굴착기 0.2·0.7 × 소림·중림·밀림 · 크기 제안 0.7㎥ 산림품셈 10-12-1 [주]①)",
"variant_note": "2026-09-14 브레인 판정 — 크기·등급 두 칸을 템플릿으로 한 값(「0.7·소림」)으로 엮고 B09 갈래 표기는 범위 별칭(FP-09-21)이 앎. 둘 중 하나라도 비면 등급만 싣고 입력 사유.",
"note": "2026-09-13 브레인 판정 Ⓑ — 제근은 토공 줄(토공_수량.md:30 「뿌리다듬기·적재·제근 | 9-20~21 | 벌개제근 연동」)이라 여기서 셈. 준비공 「제근·뿌리다듬기」는 같은 면적이라 참조로만 보임(이중계상 막이). 품은 임목축적 등급(소림·중림·밀림, 9-21 [주]①)으로 갈려 등급을 갈래로 넘김."
},
{
"group": "혼합석부설",
"work_item_code": "FP-11-04",
"basis_unit": "㎥",
"basis_source": "산림사업 표준품셈(고시 2025-82) 11-4 쇄석·혼합석 부설 「(단위: ㎥당)」 · [주]① Q=3600×q×k×f×E/㎝ (f=1 — 버킷이 다루는 흐트러진 부피).",
"master_name": "쇄석·혼합석 부설",
"note": "2026-09-15 브레인 포장 둘 ⓑ — 법령 별표2 Ⅰ.2.바.(2) 조건으로 자동 · 「비포장 전 구간」 고르개(교본 3-2). 수량 = 다짐 후 부피 ÷ C × L(흐트러진 부피). 다짐 공종은 산림품셈에 없어 줄 없음."
},
{
"group": "혼합석 사전터파기",
"work_item_code": "FP-09-03-02",
"basis_unit": "㎥",
"master_name": "토사깍기 > 기계",
"note": "2026-09-15 — 교본 10-1 「포설 전 10㎝ 터파기」 · 부록 7-2 「노면의 토사구간」이라 토사 측점만 · 브레인 ① 토공 축. ⚠ 노면 10㎝ 걷기를 토사깍기(기계)로 이음 — 산림품셈에 노면 사전 터파기 공종이 따로 없음(판정 확인 대기)."
}
],
"haul": [
@@ -0,0 +1,127 @@
"""혼합석 부설 — 법령 조건 자동 · 연약·습윤 칸 · 「비포장 전 구간」 고르개 (2026-09-15 브레인 ⓑ).
근거
법령 별표2 Ⅰ.2.바.(2) — 「종단기울기가 8퍼센트를 초과하는 사질토양 또는 점토질 토양인 구간과
종단기울기가 8퍼센트 이하인 구간으로서 지반이 약하고 습한 구간에는 쇄석·자갈을 부설」
임도기술교본 3-2 — 「콘크리트 포장 구간 이외에는 혼합석 부설 다짐 후 0.10m 내외」(고르개)
임도기술교본 10-1 · 부록 7-2 — 포설 전 10㎝ 터파기 · 적용범위 「노면의 토사구간」
실무 소광 — 혼합석 C 0.85 · L 1.25 (원문 체적환산표에 혼합석 줄 없음 ·
역(礫) L 1.10~1.20 · C 1.05~1.10)
판정 — 토사 = 사질·점토 해당 · 암 = 비해당 · 폭 = 차도 표준 폭 + 확폭(노견 제외).
"""
from __future__ import annotations
import pytest
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput, build_rows
from B08_Quantity.B08_Quantity_Engine_GravelSurfacing import gravel_material_rows, gravel_surfacing
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff
def _design(ground: str = "soil", widening: float = 0.0, paved: bool = False) -> dict:
return {
"ground_type": ground,
"carriageway_standard_width_m": 3.0,
"widening_left_m": widening,
"paved": paved,
}
DESIGNS = [
{"chainage_m": 0.0, "design": _design()},
{"chainage_m": 20.0, "design": _design()},
{"chainage_m": 40.0, "design": _design(widening=1.0)},
{"chainage_m": 60.0, "design": _design(paved=True)},
{"chainage_m": 80.0, "design": _design(ground="ripping_rock")},
]
GRADES = {0.0: 10.0, 20.0: 10.0, 40.0: 5.0, 60.0: 12.0, 80.0: 12.0}
def test_법령_조건만_자동_토사_8퍼센트_초과() -> None:
result = gravel_surfacing(DESIGNS, GRADES, {})
# 0·20 만 해당 — [0,20] (3+3)/2×20 + [20,40] (3+0)/2×20
assert result["area_m2"] == pytest.approx(90.0)
assert result["thickness_m"] == pytest.approx(0.10)
assert result["loose_m3"] == pytest.approx(90.0 * 0.10 * 1.25 / 0.85)
assert result["excavation_soil_m3"] == pytest.approx(9.0)
text = " ".join(result["notes"])
assert "별표2" in text and "토사 전부를 해당으로 봄" in text
assert "원문 표에 혼합석 줄 없음" in text and "방향이 반대" in text
assert "다짐" in text
def test_연약_습윤_구간_칸이_8퍼센트_이하_측점을_더한다() -> None:
result = gravel_surfacing(
DESIGNS, GRADES, {"gravel_soft_wet_ranges": [{"from_m": 30, "to_m": 50}]}
)
# 40 추가(경사 5 · 확폭 1) — [20,40] (3+4)/2×20 + [40,60] (4+0)/2×20
assert result["area_m2"] == pytest.approx(60.0 + 70.0 + 40.0)
def test_비포장_전_구간_고르개는_포장만_뺀다_암은_사전터파기_안_셈() -> None:
result = gravel_surfacing(DESIGNS, GRADES, {"gravel_all_unpaved": True})
assert result["area_m2"] == pytest.approx(60.0 + 70.0 + 40.0 + 30.0)
# 암 측점(80)은 교본 부록 7-2 「토사구간」 밖 — 사전 터파기 밑수에서 빠짐
assert result["excavation_soil_m3"] == pytest.approx(170.0 * 0.10)
assert any("교본 3-2" in note for note in result["notes"])
assert any("토사구간" in note for note in result["notes"])
def test_칸이_두께_C_L_을_덮어쓴다() -> None:
settings = {"gravel_thickness_m": 0.2, "gravel_conversion_c": 1.0, "gravel_conversion_l": 1.2}
result = gravel_surfacing(DESIGNS, GRADES, settings)
assert result["loose_m3"] == pytest.approx(90.0 * 0.2 * 1.2 / 1.0)
def test_토공집계_두_줄과_인계_자재() -> None:
result = gravel_surfacing(DESIGNS, GRADES, {})
rows = {row.group: row for row in build_rows(SummaryInput(gravel=result))}
assert rows["혼합석부설"].amount == pytest.approx(result["loose_m3"])
assert "토사" in rows["혼합석 사전터파기"].spec
assert rows["혼합석 사전터파기"].amount == pytest.approx(9.0)
materials = gravel_material_rows(result)
assert materials == [
{
"name": "혼합석",
"spec": "",
"unit": "㎥",
"amount": pytest.approx(result["loose_m3"]),
"destination": "material",
"source": "혼합석 부설",
}
]
def test_산출_조건_저장이_구간을_거르고_자재총괄에_혼합석이_선다() -> None:
from B08_Quantity.B08_Quantity_Router_Earthwork_Settings import (
NULLABLE_SETTING_KEYS,
clean_setting_values,
)
from B08_Quantity.B08_Quantity_Router_Material import material_table_for
values = {"gravel_soft_wet_ranges": [{"from_m": 50, "to_m": 30}, {"from_m": 1}, "x"]}
assert clean_setting_values(values) is None
assert values["gravel_soft_wet_ranges"] == [{"from_m": 30, "to_m": 50}]
assert {"gravel_thickness_m", "gravel_conversion_c", "gravel_conversion_l"} <= set(
NULLABLE_SETTING_KEYS
)
gravel = gravel_surfacing(DESIGNS, GRADES, {})
rows = material_table_for({"rows": []}, {}, None, gravel)["rows"]
assert [row for row in rows if row["name"] == "혼합석"]
def test_인계_코드() -> None:
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table
result = gravel_surfacing(DESIGNS, GRADES, {})
items = build_handoff(summary_table=build_table(SummaryInput(gravel=result)))["work_items"]
codes = {row["work_item_code"]: row for row in items if "혼합석" in str(row["name"])}
assert codes["FP-11-04"]["quantity"] == pytest.approx(result["loose_m3"])
assert codes["FP-09-03-02"]["quantity"] == pytest.approx(9.0)
assert codes["FP-11-04"]["in_bill"] and codes["FP-09-03-02"]["in_bill"]
# 해당 측점이 없으면(모두 암) 0 원 줄을 안 세움 — 936be972 실측에서 잡음.
rock = [{**d, "design": {**d["design"], "ground_type": "ripping_rock"}} for d in DESIGNS]
empty = gravel_surfacing(rock, GRADES, {})
zero = build_handoff(summary_table=build_table(SummaryInput(gravel=empty)))["work_items"]
assert not [r for r in zero if "혼합석" in str(r["name"]) and r["in_bill"]]
+25
View File
@@ -111,4 +111,29 @@ export const ui_locales_b3 = {
B09_Sheet_Rate_ClearAll: ["할증 전부 되돌리기", "Clear all surcharges"],
B09_Sheet_Rate_SelectedCount: ["고른 줄", "Selected"],
B09_Sheet_Rate_AllBadge: ["전체 할증", "Bill-wide"],
/* --- B08 산출 조건 「혼합석 부설」(2026-09-15 브레인 포장 둘 ⓑ) --- */
B08_Quantity_Side_Gravel: ["혼합석 부설", "Gravel Surfacing"],
B08_Quantity_Gravel_Scope: ["까는 범위", "Scope"],
B08_Quantity_Gravel_Scope_Legal: [
"법령 조건(종단 8% 초과 토사 · 연약·습윤 구간)",
"Legal conditions (grade > 8% soil · soft/wet ranges)",
],
B08_Quantity_Gravel_Scope_All: ["비포장 전 구간(교본 3-2)", "All unpaved stations (manual 3-2)"],
B08_Quantity_Gravel_SoftWet: ["연약·습윤 구간(m)", "Soft/wet ranges (m)"],
B08_Quantity_Gravel_SoftWet_Placeholder: ["예: 120~160, 480~520", "e.g. 120~160, 480~520"],
B08_Quantity_Gravel_SoftWet_Invalid: [
"구간 글자를 못 읽음 — 「시작~끝」을 쉼표로 이어 적을 것",
"Cannot read ranges — write start~end separated by commas",
],
B08_Quantity_Gravel_Thickness: ["두께(다짐 후, m)", "Thickness after compaction (m)"],
B08_Quantity_Gravel_C: ["C(다짐)", "C (compacted)"],
B08_Quantity_Gravel_L: ["L(흐트러짐)", "L (loose)"],
B08_Quantity_Gravel_Suggested: ["비우면 제안값", "Blank uses suggested"],
B08_Quantity_Gravel_Now: ["지금 셈", "Now"],
B08_Quantity_Gravel_Excavation: ["사전 터파기(토사)", "Pre-excavation (soil)"],
B08_Quantity_Side_Gravel_Hint: [
"법령 별표2 Ⅰ.2.바.(2) 가 요구하는 구간만 자동 — 종단 8% 초과 사질·점토(우리 프리셋은 토사 하나라 토사 전부) · 8% 이하 연약·습윤은 칸(판정 근거 없음). 교본 3-2 는 「콘크리트 포장 구간 이외」 전부라 고르면 넓힘. 두께 제안 0.10(교본) · C·L 제안 소광 관측(원문 표에 혼합석 줄 없음 · 역 C 와 방향이 반대)",
"Only the stations required by the forest act table 2 are automatic — grade > 8% sandy/clay soil (our preset has one soil class) and ≤ 8% soft/wet ranges you enter. Manual 3-2 covers all non-concrete stations. Suggested thickness 0.10 (manual) · C·L from practice (no gravel row in the standard table)",
],
} as const;