Merge remote-tracking branch 'origin/dev' into main_laptop_1

This commit is contained in:
2026-09-15 05:14:50 +09:00
14 changed files with 800 additions and 163 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": "혼합석 부설",
}
]
+46 -143
View File
@@ -15,13 +15,14 @@
from __future__ import annotations
import asyncio
import json
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
@@ -41,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 (
@@ -50,6 +52,11 @@ from B08_Quantity.B08_Quantity_Engine_HaulSummary import (
)
from B08_Quantity.B08_Quantity_Engine_RockSplit import apply_rock_split
from B08_Quantity.B08_Quantity_Router_Earthwork_HaulPlan import HAUL_PLAN_KEYS
from B08_Quantity.B08_Quantity_Router_Earthwork_Settings import ( # noqa: F401 — 다시 내보냄
NULLABLE_SETTING_KEYS,
QuantitySettingsBody,
clean_setting_values,
)
from B08_Quantity.B08_Quantity_Router_Earthwork_HaulPlan import (
recompute_haul_plan as _recompute_haul_plan,
)
@@ -58,8 +65,6 @@ from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slop
from B08_Quantity.B08_Quantity_Engine_SlopeLength import road_surface_area, station_slopes
from B08_Quantity.B08_Quantity_Provenance import quantity_provenance
from common_util.common_util_project_settings import (
CONCRETE_PLACING_METHODS,
ROCK_METHODS,
application_ratio,
concrete_placing_method,
earthwork_conversion_choices,
@@ -74,9 +79,7 @@ from common_util.common_util_project_settings import (
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import run_with_connection
from config.config_system_design import (
EARTHWORK_CONVERSION_FACTORS,
EARTHWORK_CONVERSION_PUMSEM_C_RANGES,
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M,
)
logger = logging.getLogger(__name__)
@@ -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,87 +480,36 @@ async def _stored_haul_plan(project_id: UUID, route_id: int) -> dict[str, Any] |
return plan if isinstance(plan, dict) and plan else None
class QuantitySettingsBody(BaseModel):
"""[저장]이 보내는 산출 조건. 보내지 않은 칸은 저장분을 그대로 둔다."""
async def _station_grades(
project_id: UUID, route_id: int, project_root: str | None, designs: list[dict[str, Any]]
) -> dict[float, float]:
"""횡단 측점마다 종단 경사(%) — 종단 파일 계획선에서 B05 포장 제안과 **같은 식**으로.
rock_class_set: str | None = None
rock_classes: list[str] | None = None
rock_ratios_pct: dict[str, float] | None = None
# 갈래별 시공법 — 값은 "ripping"·"blasting". 안 정한 갈래는 보내지 않는다.
rock_methods: dict[str, str] | None = None
application_ratios_pct: dict[str, float] | None = None
# 자재별 관급/사급 — `{자재명: {"supply": …, "install_by": …}}`.
# 표 안에서 줄마다 고른 값이 여기로 온다(2026-09-07 확정).
material_supply: dict[str, Any] | None = None
# 자재별 할증률 — `{이름 규격: %}`. 품셈 1-3-1 「표의 값 이내」라 0~표값만 씀(엔진이 거름).
material_surcharge: dict[str, Any] | None = None
# 콘크리트 타설 방식. `""` 는 「안 정함」으로 되돌리는 뜻이라 서버가 None 으로 만든다.
concrete_placing_method: str | None = None
# 표토제거 두께(m). 품셈이 정하는 값이 아니라 설계 입력이다(9-15 [주]②).
topsoil_thickness_m: float | None = None
# 부대시설 개소 — `{항목키: 개소}`(2026-09-09 확정 ⑬).
# ⚠ 산식(연장÷500)으로 만들지 않는다 — 임도규정이 「필요시 거리를 조정」이라 하고
# 기점 포함·갈림길 중복을 원문이 정하지 않는다. **설계자가 넣는 값**이다.
ancillary_counts: dict[str, float] | None = None
# 층따기 길이(깊이, m). 면적 × 이 값 = ㎥ (확정 2차 ①).
bench_cut_depth_m: float | None = None
# 사토장까지 운반거리(m). 유토곡선이 낸 사토를 **실어 내는 줄**이 이 값으로 선다.
spoil_site_distance_m: float | None = None
# 기초잡석 두께(m) — 확정 3차 ② 0.2. 폭은 버림 폭과 같다(KCS 34 50 05).
rubble_base_thickness_m: float | None = None
# 구조물터파기 용수 유무 — "육상"·"용수". ⚠ 기본 육상은 **통상값**이지 사용자 확정이 아니다.
structure_trench_water: str | None = None
# 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다.
topsoil_haul_distance_m: float | None = None
# 표토제거 대상 — "road_only" 는 노면만. `""` 는 기본(노면 + 절토, 별표2 문언)으로 되돌림.
topsoil_target: str | None = None
# 임목축적 등급 — "소림"·"중림"·"밀림"(품셈 9-21 [주]①). `""` 는 「안 정함」이다.
stand_volume_class: str | None = None
# 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질(9-19-1 원문 표). `""` 는 「안 정함」.
face_dressing_cut_class: str | None = None
# 제근 굴착기 크기 — "0.2"·"0.7"(품셈 9-21 갈래). `""` 는 「안 정함」.
root_removal_excavator_m3: str | None = None
# 초류종자살포 비탈면 토질 — "일반"·"마사토"(품셈 5-24 잎). `""` 는 「안 정함」.
seed_spray_ground: str | None = None
face_dressing_fill_class: str | None = None
# 면고르기 면적 덮어쓰기(㎡) — `None` 은 파종 면적을 그대로(2026-09-14 판정 Ⓐ).
face_dressing_fill_area_m2: float | None = None
face_dressing_cut_area_m2: float | None = None
# 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다.
frame_material: dict[str, Any] | None = None
# 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜면 줄이 서고, 부피를 넣으면 값이 선다.
wood_chipping_enabled: bool | None = None
wood_chipping_volume_m3: float | None = None
# 노체다짐 — 기본 꺼짐(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄.
subgrade_compaction_enabled: bool | None = None
# 임목폐기물 — 현장 조사값 넷(통째로 갈아 끼움) · 수동 처리단가 · 분리발주.
tree_waste: dict[str, float | None] | None = None
tree_waste_unit_price_krw_per_ton: float | None = None
waste_separate_order: bool | None = None
# 뿌리 산정법 — "root_ball"(뿌리분 체적 × 1,300) · `""` 는 기본(분배비 15/85)으로 되돌림.
tree_waste_root_method: str | None = None
# 토량환산계수(다짐) — `{갈래: {"compacted": C, "reason": 사유}}`.
# ⚠ **기본값을 복사해 넣지 않는다** — 안 고른 갈래는 키가 없어야 정본이 선다.
# 빈 dict 는 「전부 기본값으로 되돌림」이라 통째로 갈아 끼운다.
conversion_factors_override: dict[str, Any] | None = None
# 도쟈 한계거리(m) — `None` 은 기본값(60 m). 종무대 20 m 보다 커야 한다(도쟈 몫이 사라짐).
dozer_haul_limit_m: float | None = None
⚠ 종단 파일 `stations` 의 `pavement_grade_pct` 는 정규 측점뿐(비정규 측점 85.052 등이 빠짐) —
계획선에서 바로 셈. 못 읽으면 빈 표(혼합석 판정이 「경사 못 읽음」 사유).
"""
from B05_Profile.B05_Profile_Engine_Sections import local_grade_pct
#: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**.
#: 빈 문자열로 되돌리는 칸(시공법·타설 방식)과 달리 숫자 칸은 되돌릴 값이 `None` 뿐이다.
NULLABLE_SETTING_KEYS = (
"topsoil_thickness_m",
"bench_cut_depth_m",
"spoil_site_distance_m",
"rubble_base_thickness_m",
"topsoil_haul_distance_m",
"wood_chipping_volume_m3",
"dozer_haul_limit_m",
"tree_waste_unit_price_krw_per_ton",
"face_dressing_fill_area_m2",
"face_dressing_cut_area_m2",
)
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")
@@ -577,66 +535,10 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
for key in NULLABLE_SETTING_KEYS:
if key in body.model_fields_set:
values[key] = getattr(body, key)
dozer_limit = values.get("dozer_haul_limit_m")
free_haul = dict(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)["free_haul"] or 0.0
if dozer_limit is not None and dozer_limit <= free_haul:
error = clean_setting_values(values)
if error:
# 조용히 기본값으로 돌리지 않는다 — 넣은 값이 안 쓰이는 줄 모른다.
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": f"도쟈 한계거리는 종무대 {free_haul:g} m 보다 커야 합니다.",
},
)
for key, choices in (
("face_dressing_cut_class", FACE_DRESSING_CUT_CLASSES),
("face_dressing_fill_class", FACE_DRESSING_FILL_CLASSES),
("root_removal_excavator_m3", ROOT_REMOVAL_EXCAVATOR_SIZES),
("seed_spray_ground", SEED_SPRAY_GROUNDS),
):
if key in values and values[key] not in choices:
values[key] = "" # 선택지 밖·빈 값은 「안 정함」 — 가까운 갈래로 안 고침
if "concrete_placing_method" in values:
method = values["concrete_placing_method"]
# 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리).
values["concrete_placing_method"] = method if method in CONCRETE_PLACING_METHODS else None
if "tree_waste" in values:
# 양수만 남긴다 — 빈 칸은 키가 없어야 「안 넣음」으로 읽힌다.
values["tree_waste"] = {
key: float(value)
for key, value in (values["tree_waste"] or {}).items()
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0
}
if "tree_waste_root_method" in values:
method = values["tree_waste_root_method"]
values["tree_waste_root_method"] = method if method == "root_ball" else None
if "topsoil_target" in values:
# 기본(노면 + 절토)은 저장하지 않는다 — 「안 정함」과 같게 둬 법 문언이 선다.
target = values["topsoil_target"]
values["topsoil_target"] = target if target == "road_only" else None
if "conversion_factors_override" in values:
# 아는 갈래·양수만 남긴다. 사유는 값이 있을 때만 따라간다(계산에는 안 쓴다).
cleaned: dict[str, Any] = {}
for kind, entry in (values["conversion_factors_override"] or {}).items():
if kind not in EARTHWORK_CONVERSION_FACTORS or not isinstance(entry, dict):
continue
value = entry.get("compacted")
if not isinstance(value, (int, float)) or isinstance(value, bool) or float(value) <= 0:
continue
kept: dict[str, Any] = {"compacted": float(value)}
reason = entry.get("reason")
if isinstance(reason, str) and reason.strip():
kept["reason"] = reason.strip()
cleaned[kind] = kept
values["conversion_factors_override"] = cleaned
if "rock_methods" in values:
# 「안 정함」(빈 값)은 저장하지 않는다 — 정한 것과 구별이 안 된다. 통째로 갈아 끼우므로
# 여기서 버리면 그 갈래는 미지정으로 돌아간다.
values["rock_methods"] = {
name: method
for name, method in values["rock_methods"].items()
if method in ROCK_METHODS
}
return JSONResponse(status_code=400, content={"status": "error", "message": error})
before = {key: quantity_settings(root).get(key) for key in HAUL_PLAN_KEYS}
try:
# ⚠ 고른 값을 **되돌릴 수 있어야** 하는 칸은 통째로 갈아 끼운다 — 병합이면
@@ -656,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,
)
@@ -0,0 +1,185 @@
"""B08 산출 조건 [저장] — 받는 모양과 값 거르기 (2026-09-15 `Router_Earthwork` 700줄에서 가름).
순수 분리 ·거르기 규칙은 그대로 옮김. 저장 자리(`save_quantity_settings`) 라우터에 남음.
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import (
FACE_DRESSING_CUT_CLASSES,
FACE_DRESSING_FILL_CLASSES,
ROOT_REMOVAL_EXCAVATOR_SIZES,
SEED_SPRAY_GROUNDS,
)
from common_util.common_util_project_settings import CONCRETE_PLACING_METHODS, ROCK_METHODS
from config.config_system_design import (
EARTHWORK_CONVERSION_FACTORS,
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M,
)
class QuantitySettingsBody(BaseModel):
"""[저장]이 보내는 산출 조건. 보내지 않은 칸은 저장분을 그대로 둔다."""
rock_class_set: str | None = None
rock_classes: list[str] | None = None
rock_ratios_pct: dict[str, float] | None = None
# 갈래별 시공법 — 값은 "ripping"·"blasting". 안 정한 갈래는 보내지 않는다.
rock_methods: dict[str, str] | None = None
application_ratios_pct: dict[str, float] | None = None
# 자재별 관급/사급 — `{자재명: {"supply": …, "install_by": …}}`.
# 표 안에서 줄마다 고른 값이 여기로 온다(2026-09-07 확정).
material_supply: dict[str, Any] | None = None
# 자재별 할증률 — `{이름 규격: %}`. 품셈 1-3-1 「표의 값 이내」라 0~표값만 씀(엔진이 거름).
material_surcharge: dict[str, Any] | None = None
# 콘크리트 타설 방식. `""` 는 「안 정함」으로 되돌리는 뜻이라 서버가 None 으로 만든다.
concrete_placing_method: str | None = None
# 표토제거 두께(m). 품셈이 정하는 값이 아니라 설계 입력이다(9-15 [주]②).
topsoil_thickness_m: float | None = None
# 부대시설 개소 — `{항목키: 개소}`(2026-09-09 확정 ⑬).
# ⚠ 산식(연장÷500)으로 만들지 않는다 — 임도규정이 「필요시 거리를 조정」이라 하고
# 기점 포함·갈림길 중복을 원문이 정하지 않는다. **설계자가 넣는 값**이다.
ancillary_counts: dict[str, float] | None = None
# 층따기 길이(깊이, m). 면적 × 이 값 = ㎥ (확정 2차 ①).
bench_cut_depth_m: float | None = None
# 사토장까지 운반거리(m). 유토곡선이 낸 사토를 **실어 내는 줄**이 이 값으로 선다.
spoil_site_distance_m: float | None = None
# 기초잡석 두께(m) — 확정 3차 ② 0.2. 폭은 버림 폭과 같다(KCS 34 50 05).
rubble_base_thickness_m: float | None = None
# 구조물터파기 용수 유무 — "육상"·"용수". ⚠ 기본 육상은 **통상값**이지 사용자 확정이 아니다.
structure_trench_water: str | None = None
# 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다.
topsoil_haul_distance_m: float | None = None
# 표토제거 대상 — "road_only" 는 노면만. `""` 는 기본(노면 + 절토, 별표2 문언)으로 되돌림.
topsoil_target: str | None = None
# 임목축적 등급 — "소림"·"중림"·"밀림"(품셈 9-21 [주]①). `""` 는 「안 정함」이다.
stand_volume_class: str | None = None
# 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질(9-19-1 원문 표). `""` 는 「안 정함」.
face_dressing_cut_class: str | None = None
# 제근 굴착기 크기 — "0.2"·"0.7"(품셈 9-21 갈래). `""` 는 「안 정함」.
root_removal_excavator_m3: str | None = None
# 초류종자살포 비탈면 토질 — "일반"·"마사토"(품셈 5-24 잎). `""` 는 「안 정함」.
seed_spray_ground: str | None = None
face_dressing_fill_class: str | None = None
# 면고르기 면적 덮어쓰기(㎡) — `None` 은 파종 면적을 그대로(2026-09-14 판정 Ⓐ).
face_dressing_fill_area_m2: float | None = None
face_dressing_cut_area_m2: float | None = None
# 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다.
frame_material: dict[str, Any] | None = None
# 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜면 줄이 서고, 부피를 넣으면 값이 선다.
wood_chipping_enabled: bool | None = None
wood_chipping_volume_m3: float | None = None
# 노체다짐 — 기본 꺼짐(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄.
subgrade_compaction_enabled: bool | None = None
# 임목폐기물 — 현장 조사값 넷(통째로 갈아 끼움) · 수동 처리단가 · 분리발주.
tree_waste: dict[str, float | None] | None = None
tree_waste_unit_price_krw_per_ton: float | None = None
waste_separate_order: bool | None = None
# 뿌리 산정법 — "root_ball"(뿌리분 체적 × 1,300) · `""` 는 기본(분배비 15/85)으로 되돌림.
tree_waste_root_method: str | None = None
# 토량환산계수(다짐) — `{갈래: {"compacted": C, "reason": 사유}}`.
# ⚠ **기본값을 복사해 넣지 않는다** — 안 고른 갈래는 키가 없어야 정본이 선다.
# 빈 dict 는 「전부 기본값으로 되돌림」이라 통째로 갈아 끼운다.
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` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**.
#: 빈 문자열로 되돌리는 칸(시공법·타설 방식)과 달리 숫자 칸은 되돌릴 값이 `None` 뿐이다.
NULLABLE_SETTING_KEYS = (
"topsoil_thickness_m",
"bench_cut_depth_m",
"spoil_site_distance_m",
"rubble_base_thickness_m",
"topsoil_haul_distance_m",
"wood_chipping_volume_m3",
"dozer_haul_limit_m",
"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",
)
def clean_setting_values(values: dict[str, Any]) -> str | None:
"""받은 값을 **그 자리에서** 거른다 — 거절할 값이면 사람이 읽는 까닭, 아니면 `None`."""
dozer_limit = values.get("dozer_haul_limit_m")
free_haul = dict(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)["free_haul"] or 0.0
if dozer_limit is not None and dozer_limit <= free_haul:
# 조용히 기본값으로 돌리지 않는다 — 넣은 값이 안 쓰이는 줄 모른다.
return f"도쟈 한계거리는 종무대 {free_haul:g} m 보다 커야 합니다."
for key, choices in (
("face_dressing_cut_class", FACE_DRESSING_CUT_CLASSES),
("face_dressing_fill_class", FACE_DRESSING_FILL_CLASSES),
("root_removal_excavator_m3", ROOT_REMOVAL_EXCAVATOR_SIZES),
("seed_spray_ground", SEED_SPRAY_GROUNDS),
):
if key in values and values[key] not in choices:
values[key] = "" # 선택지 밖·빈 값은 「안 정함」 — 가까운 갈래로 안 고침
if "concrete_placing_method" in values:
method = values["concrete_placing_method"]
# 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리).
values["concrete_placing_method"] = method if method in CONCRETE_PLACING_METHODS else None
if "tree_waste" in values:
# 양수만 남긴다 — 빈 칸은 키가 없어야 「안 넣음」으로 읽힌다.
values["tree_waste"] = {
key: float(value)
for key, value in (values["tree_waste"] or {}).items()
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0
}
if "tree_waste_root_method" in values:
method = values["tree_waste_root_method"]
values["tree_waste_root_method"] = method if method == "root_ball" else None
if "topsoil_target" in values:
# 기본(노면 + 절토)은 저장하지 않는다 — 「안 정함」과 같게 둬 법 문언이 선다.
target = values["topsoil_target"]
values["topsoil_target"] = target if target == "road_only" else None
if "conversion_factors_override" in values:
# 아는 갈래·양수만 남긴다. 사유는 값이 있을 때만 따라간다(계산에는 안 쓴다).
cleaned: dict[str, Any] = {}
for kind, entry in (values["conversion_factors_override"] or {}).items():
if kind not in EARTHWORK_CONVERSION_FACTORS or not isinstance(entry, dict):
continue
value = entry.get("compacted")
if not isinstance(value, (int, float)) or isinstance(value, bool) or float(value) <= 0:
continue
kept: dict[str, Any] = {"compacted": float(value)}
reason = entry.get("reason")
if isinstance(reason, str) and reason.strip():
kept["reason"] = reason.strip()
cleaned[kind] = kept
values["conversion_factors_override"] = cleaned
if "rock_methods" in values:
# 「안 정함」(빈 값)은 저장하지 않는다 — 정한 것과 구별이 안 된다. 통째로 갈아 끼우므로
# 여기서 버리면 그 갈래는 미지정으로 돌아간다.
values["rock_methods"] = {
name: method
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")));
}
+3
View File
@@ -46,6 +46,9 @@
③ 연약·습윤 = 산출 조건 측점 구간 칸 · 「비포장 전 구간」 고르개 · 종단 8% = B05 `pavement_grade_pct`
④ 제안값 소광 관측 C 0.85 · L 1.25 · 원문 1-2-3 역(礫) L 1.10~1.20 · C 1.05~1.10 병기 · ⚠ 소광 C(줄어듦)와 역 C(늘어남)는 방향이 반대 — 사유에 적음
⑤ 폭 = 차도 표준 폭 + 확폭(노견 제외 · 소광 「B=3.0」)
- [X] 토공집계 두 줄(혼합석부설 11-4 ㎥ = 다짐 후 ÷ C × L · 사전터파기 토사 9-3-2) · 자재총괄 혼합석 ㎥(사급) · 산출 조건 칸(범위 · 연약·습윤 구간 · 두께·C·L 비면 제안값) · 종단 경사는 계획선에서 B05 포장 제안과 같은 한 벌(`local_grade_pct` 로 뺌 — 종단 파일 측점 경사에 비정규 측점 8곳이 없었음) · 산출 조건 받는 모양은 `_Router_Earthwork_Settings` 로 가름 196bd73a. 검증완료(시험 · 936be972 화면 조작 후 되돌림)
- 936be972 — 법령 조건 0㎡(62측점 모두 리핑암 자리표시) · 전 구간 3,533.18㎡ · 519.58㎥ · 법령 + 연약·습윤 100~200 = 112.42㎡ · 되돌림(키는 false·[]·null 로 남음)
- ⚠ 물음 — B06 토사 토글 끔이면 측점 지반이 「리핑암」 자리표시라 법령 자동이 영영 0 · 사전터파기 공종 9-3-2 로 이은 것 확인 대기
- [ ] 녹화 — 교본 7-1 · 그림 7-8~7-12 「표준도」가 모식도인지 먼저
- [ ] 소단 — 임도 조문(사면길이 2~3m 마다 폭 50~100㎝) · 사방 기준 아님
- [ ] 개소형 — 대피소·작업장·차돌림곳·노출형 횡단수로
@@ -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"]]
+5 -1
View File
@@ -53,7 +53,11 @@ def test_선택지는_마스터_잎_이름과_같음() -> None:
def test_산출_조건이_받고_선택지_밖은_안_정함으로() -> None:
source = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Earthwork.py").read_text("utf-8")
assert '("seed_spray_ground", SEED_SPRAY_GROUNDS)' in source
# 값 거르기는 2026-09-15 `_Router_Earthwork_Settings` 로 가름(700줄) — 규칙은 그대로.
settings = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Earthwork_Settings.py").read_text(
"utf-8"
)
assert '("seed_spray_ground", SEED_SPRAY_GROUNDS)' in settings
assert 'table["seed_spray_choices"]' in source
material = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Material.py").read_text("utf-8")
assert 'seed_spray_ground=settings.get("seed_spray_ground") or None' in material
+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;