Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -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,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
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
@@ -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"]]
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user