Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1

This commit is contained in:
2026-09-09 12:35:33 +09:00
25 changed files with 1203 additions and 128 deletions
+45
View File
@@ -199,6 +199,50 @@ def _merge_irregular_into_longitudinal(
atomic_write_json(path, data)
#: 지표 샘플링 조건을 적어 두는 파일 — **나중에 측점을 더 만들 때 같은 조건으로** 뜨기 위해.
#: ⚠ 조건이 다르면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋난다.
SAMPLING_SNAPSHOT_NAME = "sampling.json"
def sampling_snapshot_path(project_root: Path) -> Path:
return project_root / "B06_Section" / SAMPLING_SNAPSHOT_NAME
def save_sampling_snapshot(project_root: Path, request: RouteConfirmRequest) -> None:
"""확정 때 쓴 지표 샘플링 조건을 남긴다(2026-09-09).
왜 — 관을 나중에 놓으면 그 측점이 안 생기는데(계획서 3-14), 나중에 만들려면 **그때와 같은
조건**으로 떠야 한다. 조건을 안 남기면 되짚을 길이 없어 **지어내야 하는 자리**가 된다.
"""
if not request.filter_key or not request.method:
return
path = sampling_snapshot_path(project_root)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(
path,
{
"filter_key": request.filter_key,
"method": request.method,
"smooth": bool(request.smooth),
"surface_model_id": request.surface_model_id,
},
)
def load_sampling_snapshot(project_root: Path) -> dict[str, Any] | None:
"""남겨 둔 샘플링 조건. 없으면 `None` — **지어내지 않는다**(노선 확정을 한 번 더 받는다)."""
path = sampling_snapshot_path(project_root)
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not data.get("filter_key") or not data.get("method"):
return None
return data
async def _append_irregular_cross_sections(
connection: aiomysql.Connection,
project_id: UUID,
@@ -208,6 +252,7 @@ async def _append_irregular_cross_sections(
"""확정 시 비정규 측점의 횡단을 생성해 종단 파일에 병합한다(파일 기반, 비치명적 호출용)."""
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
save_sampling_snapshot(project_root, request)
stored_options = await get_latest_section_options(connection, project_id)
crs_epsg = await get_surface_crs_epsg(connection, project_id, request.surface_model_id)
irregular_stations = await asyncio.to_thread(
+254 -2
View File
@@ -459,7 +459,17 @@
"abbr": "개거"
},
"drawing_views": ["plan", "profile", "cross_section", "quantity"],
"options": []
"options": [
{
"key": "ditch_spec",
"label": "규격",
"input": "select",
"choices": ["콘크리트 개거 150×200", "L형수로 H=0.2"],
"default": null,
"required": false,
"phase": "detail"
}
]
},
{
"type_id": "ditch_side",
@@ -709,7 +719,7 @@
"key": "form",
"label": "형식",
"input": "select",
"choices": ["중력식", "반중력식", "캔틸레버식", "부벽식"],
"choices": ["중력식", "반중력식", "캔틸레버식", "부벽식", "식생옹벽블럭"],
"default": null,
"required": true,
"phase": "detail"
@@ -720,6 +730,24 @@
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
},
{
"key": "weep_hole_diameter_mm",
"label": "물빼기 구멍 지름",
"input": "number",
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail"
},
{
"key": "weep_hole_area_m2",
"label": "물빼기 구멍 1개당 벽면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail"
}
]
},
@@ -838,6 +866,24 @@
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
},
{
"key": "weep_hole_diameter_mm",
"label": "물빼기 구멍 지름",
"input": "number",
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail"
},
{
"key": "weep_hole_area_m2",
"label": "물빼기 구멍 1개당 벽면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail"
}
]
},
@@ -956,6 +1002,24 @@
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
},
{
"key": "weep_hole_diameter_mm",
"label": "물빼기 구멍 지름",
"input": "number",
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail"
},
{
"key": "weep_hole_area_m2",
"label": "물빼기 구멍 1개당 벽면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail"
}
]
},
@@ -1030,6 +1094,113 @@
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
},
{
"key": "tiers",
"label": "단 수(다단)",
"input": "number",
"unit": "단",
"default": 1,
"required": false,
"phase": "b05"
},
{
"key": "lift_m",
"label": "기준 올림(사면 위로)",
"input": "number",
"unit": "m",
"default": 0,
"required": false,
"phase": "b05"
},
{
"key": "shift_m",
"label": "기준 좌우 이동",
"input": "number",
"unit": "m",
"default": 0,
"required": false,
"phase": "b05"
},
{
"key": "back_len_cm",
"label": "뒷길이",
"input": "select",
"choices": ["25", "30", "35", "45", "55", "60", "75"],
"default": null,
"required": false,
"phase": "detail"
},
{
"key": "stone_kind",
"label": "돌 종류",
"input": "select",
"choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"],
"default": null,
"required": false,
"phase": "detail"
},
{
"key": "stone_supply",
"label": "조달",
"input": "select",
"choices": ["채집", "구입"],
"default": "채집",
"required": false,
"phase": "detail"
},
{
"key": "stone_coeff_basis",
"label": "야면석 계수",
"input": "select",
"choices": ["품셈", "실무 관행"],
"default": null,
"required": false,
"phase": "detail"
},
{
"key": "fill_concrete_mpa",
"label": "채움 강도",
"input": "select",
"choices": ["180", "210"],
"default": null,
"required": false,
"phase": "detail"
},
{
"key": "face_slope_ratio",
"label": "전면 기울기 (1:n 의 n)",
"input": "number",
"default": null,
"required": false,
"phase": "detail"
},
{
"key": "foundation",
"label": "기초",
"input": "select",
"choices": ["기초유", "기초버림"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "weep_hole_diameter_mm",
"label": "물빼기 구멍 지름",
"input": "number",
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail"
},
{
"key": "weep_hole_area_m2",
"label": "물빼기 구멍 1개당 벽면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail"
}
]
},
@@ -1148,6 +1319,24 @@
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
},
{
"key": "weep_hole_diameter_mm",
"label": "물빼기 구멍 지름",
"input": "number",
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail"
},
{
"key": "weep_hole_area_m2",
"label": "물빼기 구멍 1개당 벽면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail"
}
]
},
@@ -1238,6 +1427,33 @@
"required": true,
"phase": "detail"
},
{
"key": "spillway",
"label": "방수로",
"input": "select",
"choices": ["있음", "없음"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "stone_kind",
"label": "돌 종류",
"input": "select",
"choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "back_len_cm",
"label": "뒷길이 3",
"input": "select",
"choices": ["25", "30", "35", "45", "55", "60", "75"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "stone_supply",
"label": "조달",
@@ -1313,6 +1529,24 @@
"input": "number",
"unit": "m",
"default": 2.0
},
{
"key": "weep_hole_diameter_mm",
"label": "물빼기 구멍 지름",
"input": "number",
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail"
},
{
"key": "weep_hole_area_m2",
"label": "물빼기 구멍 1개당 벽면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail"
}
]
},
@@ -1576,6 +1810,24 @@
"default": 5,
"required": false,
"phase": "b05"
},
{
"key": "weep_hole_diameter_mm",
"label": "물빼기 구멍 지름",
"input": "number",
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail"
},
{
"key": "weep_hole_area_m2",
"label": "물빼기 구멍 1개당 벽면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail"
}
]
},
+19 -1
View File
@@ -431,6 +431,24 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]
if not sets:
return 0
attached = 0
# ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측).
# 측점을 만들 때 정수 미터가 같은 격자 측점이 있으면 그리로 스냅한다
# (`B05_Profile_Engine_Sections_Core` 의 파일명 가드). 관 440.241 은 **측점 440.0** 위에 선다.
# ⇒ 0.02m 로만 보면 그런 관은 **어느 측점에도 안 붙어** 횡단도에 안 서고 길이도 안 실려
# B08 이 「연장 없음」으로 막는다(실측: 배수관 넷이 그렇게 금액에서 빠져 있었다).
# ⇒ **가장 가까운 측점 하나**는 거리와 무관하게 그 관의 자리로 본다. 하나만 고르므로
# 두 번 세지 않고, 스냅 폭이 바뀌어도 따라간다.
owner: dict[float, float] = {}
for pipe_chainage in sets:
nearest = None
for section in cross_sections:
value = _number(section.get("chainage_m"), None)
if value is None:
continue
if nearest is None or abs(value - pipe_chainage) < abs(nearest - pipe_chainage):
nearest = value
if nearest is not None:
owner[pipe_chainage] = nearest
for section in cross_sections:
chainage = _number(section.get("chainage_m"), None)
if chainage is None:
@@ -440,7 +458,7 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]
reach = _CHAINAGE_TOLERANCE_M
if spec.get("type") in _SPAN_LINKED_TYPES:
reach += (_number(spec.get("span_m"), 0.0) or 0.0) / 2
if abs(chainage - pipe_chainage) <= reach:
if abs(chainage - pipe_chainage) <= reach or owner.get(pipe_chainage) == chainage:
# 세월교·BOX암거·물넘이는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다.
kind = spec.get("type")
# ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의
+247
View File
@@ -0,0 +1,247 @@
"""구조물 측점이 빠진 관·시설을 **알리고, 눌러서 만든다** (계획서 3-14 ㉯).
무엇이 문제였나
측점을 만드는 자리는 **B05 노선 [확정] 한 곳뿐**이다. 관을 저장하는
`PUT /drainage/pipe-points` 는 관 파일과 유역만 쓰고 측점을 다시 만들지 않는다.
⇒ **관을 나중에 놓거나 옮기면 그 측점이 안 생긴다.** 그 관은 횡단도에도 안 서고
수량·금액에서 통째로 빠지는데 **아무 말도 안 나온다**(실측: 배수관 넷).
왜 이 방식인가 (세 갈래 중 ㉯)
㉮ 관 저장 뒤 바로 만들기 — 그 엔드포인트에 노선·지표면 인자가 없어 끌어와야 함
㉯ **알리고 [측점 만들기] 단추** — 누를 때만 돌아 비용이 적고 **왜 값이 없는지가 보임**
㉰ 그대로 두기 — 조용히 빠지는 것이 문제라 적어도 알림은 있어야 함
⚠ **지어내지 않는 것** — 지표 샘플링 조건(어느 DTM·어느 방법)이 없으면 만들지 않는다.
조건이 다르면 그 측점만 다른 지표에서 뽑혀 **옆 측점과 지반고가 어긋난다.** 조건은
노선 확정 때 남긴 `B06_Section/sampling.json` 에서 읽고, 없으면 사유를 내고 막는다.
"""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Repository import get_latest_route, get_surface_crs_epsg
from B05_Profile.B05_Profile_Router_Confirm import load_sampling_snapshot
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import run_with_connection
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B06 Section"])
#: 「그 자리에 측점이 있다」고 볼 거리(m).
#: ⚠⚠ **0.5m 다 — 0.05m 가 아니다**(2026-09-09 실측으로 뒤집힌 자리).
#: 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로 스냅**한다
#: (`B05_Profile_Engine_Sections_Core` 의 파일명 가드). 그래서 관 440.241 은 측점 440.0
#: 위에 서고, 그 측점은 **구조물 이름표까지 달고 있다**(`structure`).
#: 0.05m 로 보면 그런 자리를 「측점 없음」으로 잘못 세어 **있는 측점을 또 만들라고 한다.**
#: 스냅 폭이 「정수 미터 반올림」이므로 최대 어긋남은 0.5m 다.
STATION_MATCH_TOLERANCE_M = 0.5
SNAPSHOT_MISSING = (
"지표 샘플링 조건을 찾을 수 없어 측점을 만들 수 없음 — 1단계(지표 확정)를 마친 뒤"
" 다시 눌러야 함. ⚠ 조건을 지어내면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋남"
)
async def _sampling_conditions(project_id: UUID, project_root: Path) -> dict[str, Any] | None:
"""이 프로젝트가 쓰는 지표 샘플링 조건. 둘 다 **기록된 값**이고 지어내지 않는다.
① 노선 [확정] 때 남긴 `B06_Section/sampling.json` — **그때 실제로 쓴 조건**이라 1순위.
② 없으면 1단계(지표 확정) 저장값 — B06 화면 `context` 가 쓰는 그 값이라 같은 조건이다.
옛 프로젝트는 ①이 없으므로 이 길이 없으면 단추가 영영 안 돈다.
"""
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
snapshot = load_sampling_snapshot(project_root)
if snapshot is not None:
return snapshot
params = await run_with_connection(get_surface_confirmation_params, str(project_id))
if not params or not params.get("source_filter") or not params.get("method"):
return None
return {
"filter_key": params["source_filter"],
"method": params["method"],
"smooth": bool(params.get("smooth")),
"surface_model_id": None,
"source": "stage1",
}
def _missing_marks(project_root: Path, route_data_path: str) -> list[dict[str, Any]]:
"""구조물 측점 가운데 **종단 정본에 행이 없는 것**만. 없으면 빈 목록."""
from B05_Profile.B05_Profile_Engine_Sections import (
_load_pipe_points,
_load_route_polyline,
resolve_extra_stations,
)
polyline = _load_route_polyline(project_root, route_data_path)
pipes = _load_pipe_points(project_root, polyline)
extras = resolve_extra_stations(project_root, pipes)
if not extras:
return []
existing = _station_chainages(project_root)
missing = []
for chainage, label in extras:
value = float(chainage)
if any(abs(value - other) <= STATION_MATCH_TOLERANCE_M for other in existing):
continue
missing.append({"chainage_m": round(value, 3), "label": label})
return sorted(missing, key=lambda item: item["chainage_m"])
def _station_chainages(project_root: Path) -> list[float]:
"""종단 정본에 실제로 서 있는 측점 누가거리. 파일이 없으면 빈 목록."""
import json
folder = project_root / "B06_Section" / "longitudinal"
values: list[float] = []
for path in sorted(folder.glob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
for station in data.get("stations") or []:
chainage = station.get("chainage_m")
if isinstance(chainage, (int, float)):
values.append(float(chainage))
return values
async def _project_paths(project_id: UUID) -> tuple[Path, dict[str, Any]] | None:
async def _load(connection):
stored = await get_project_storage_relative_path(connection, project_id)
route = await get_latest_route(connection, project_id)
return stored, route
stored, route = await run_with_connection(_load)
if not stored or not route:
return None
return Path(resolve_stored_project_path(stored)), route
@router.get("/{project_id}/section/missing-stations")
async def get_missing_stations(project_id: UUID) -> JSONResponse:
"""측점이 없는 구조물 목록 — 화면이 「측점 없는 관 N개」를 띄우는 데 쓴다."""
try:
paths = await _project_paths(project_id)
if paths is None:
return JSONResponse(content={"status": "success", "missing": [], "can_create": False})
project_root, route = paths
missing = await asyncio.to_thread(
_missing_marks, project_root, str(route["route_data_path"])
)
snapshot = await _sampling_conditions(project_id, project_root)
except Exception:
logger.exception("B06 측점 점검 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "측점을 점검하지 못했습니다."},
)
return JSONResponse(
content={
"status": "success",
"missing": missing,
"can_create": bool(missing) and snapshot is not None,
"reason": "" if snapshot is not None else SNAPSHOT_MISSING,
}
)
@router.post("/{project_id}/section/missing-stations")
async def create_missing_stations(project_id: UUID) -> JSONResponse:
"""빠진 구조물 측점을 **노선 확정 때와 같은 조건으로** 만들어 종단 정본에 병합한다.
⚠ 구조물 측점 전체를 다시 뜬다 — 종단 병합이 비정규 측점을 **통째로 교체**하므로
빠진 것만 넘기면 이미 있던 구조물 측점이 지워진다.
"""
from B05_Profile.B05_Profile_Engine_Sections import (
_load_pipe_points,
_load_route_polyline,
generate_irregular_sections,
resolve_extra_stations,
)
from B05_Profile.B05_Profile_Router_Confirm import (
_merge_irregular_into_longitudinal,
_section_options_from_stored,
)
from B06_Section.B06_Section_Repository import (
get_latest_section_options,
get_longitudinal_section,
)
try:
paths = await _project_paths(project_id)
if paths is None:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "확정된 노선이 없습니다."},
)
project_root, route = paths
snapshot = await _sampling_conditions(project_id, project_root)
if snapshot is None:
return JSONResponse(
status_code=409,
content={"status": "error", "message": SNAPSHOT_MISSING},
)
missing = await asyncio.to_thread(
_missing_marks, project_root, str(route["route_data_path"])
)
if not missing:
return JSONResponse(content={"status": "success", "created": 0, "missing": []})
async def _load(connection):
options = await get_latest_section_options(connection, project_id)
crs_epsg = await get_surface_crs_epsg(
connection, project_id, snapshot.get("surface_model_id")
)
longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
return options, crs_epsg, longitudinal
stored_options, crs_epsg, longitudinal = await run_with_connection(_load)
def _regenerate() -> int:
polyline = _load_route_polyline(project_root, str(route["route_data_path"]))
pipes = _load_pipe_points(project_root, polyline)
extras = resolve_extra_stations(project_root, pipes)
stations = generate_irregular_sections(
project_root,
str(route["route_data_path"]),
str(snapshot["filter_key"]),
str(snapshot["method"]),
bool(snapshot.get("smooth")),
extra_stations=extras,
options=_section_options_from_stored(stored_options),
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
)
if stations and longitudinal:
_merge_irregular_into_longitudinal(
project_root, str(longitudinal["longitudinal_file_path"]), stations
)
return len(stations)
made = await asyncio.to_thread(_regenerate)
except Exception:
logger.exception("B06 측점 만들기 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "측점을 만들지 못했습니다."},
)
return JSONResponse(
content={
"status": "success",
# 새로 선 것만 세어 낸다 — 다시 뜬 총수(`made`)와 다르다.
"created": len(missing),
"regenerated": made,
"missing": missing,
}
)
+33 -2
View File
@@ -61,6 +61,27 @@ const inletStructure: InletStructureControl = {
resetAdjust: () => undefined,
};
/**
* 그 관의 **주인 측점** 누가거리 — 관 자리에서 가장 가까운 측점 하나. 관 자리를 모르면 `null`.
*
* ⚠ 관 자리와 측점 자리는 스냅 때문에 어긋날 수 있다(위 설명 참조). 거리 한계를 두지 않고
* **가장 가까운 하나**만 고르는 것이 요점 — 두 측점이 같이 「주인」이 되면 같은 관을 두 번 센다.
*/
function pipeOwnerChainage(
section: CrossSection,
sections: readonly CrossSection[],
): number | null {
const target = section.culvert?.chainage_m;
if (typeof target !== "number") return null;
let best: number | null = null;
for (const item of sections) {
if (best === null || Math.abs(item.chainage_m - target) < Math.abs(best - target)) {
best = item.chainage_m;
}
}
return best;
}
/** 한 측점의 구조물 기하 한 벌 — 설계선 트림과 구조물 그리기가 같은 결과를 나눠 쓴다. */
export function computeStoredLayouts(section: CrossSection, sections: readonly CrossSection[]) {
const design = section.design;
@@ -136,10 +157,20 @@ function areaRowOf(
// ⚠ **관을 가진 측점(소유)에만 싣는다.** 옆 측점도 그 관 구간에 걸리면 레이아웃을 만들지만
// (`culvertLinkFor` — 3D·카드가 이어 그리려고), 그 자리에 길이를 실으면 **같은 관을 두 번**
// 세게 된다. 실측에서 관 9개에 값이 10곳 실렸던 자리다.
//
// ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측).
// 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로 스냅**한다
// (`B05_Profile_Engine_Sections_Core` — 횡단 파일명이 정수 미터라 두 측점이 한 파일을
// 덮어쓰는 것을 막는 가드). 그래서 관 440.241 은 **측점 440.0** 위에 선다.
// ⇒ 0.02m 로 주인을 가리면 **그런 관은 주인이 없어** 길이가 아무 데도 안 실리고,
// B08 이 「연장 없음」으로 막아 **금액이 통째로 빠진다**(실측: 배수관 넷).
// ⇒ **가장 가까운 측점 하나**를 주인으로 본다. 거리로 자르지 않으므로 스냅 폭이
// 바뀌어도 따라가고, 하나만 고르므로 두 번 세지도 않는다.
const ownerChainage = pipeOwnerChainage(section, sections);
const pipeOwner =
!!section.culvert &&
(typeof section.culvert.chainage_m !== "number" ||
Math.abs(section.culvert.chainage_m - section.chainage_m) <= CHAINAGE_TOLERANCE_M);
(ownerChainage === null ||
Math.abs(ownerChainage - section.chainage_m) <= CHAINAGE_TOLERANCE_M);
const pipeLengthM = pipeOwner ? layouts.culvert?.pipe?.lengthM : undefined;
const pipeRow: Record<string, number> | null =
typeof pipeLengthM === "number" && pipeLengthM > 0
@@ -0,0 +1,99 @@
/* =============================================================================
* B06_Section_UI_Missing_Stations.ts
* 「측점 없는 관 N개」 알림 + [측점 만들기] 단추 (계획서 3-14 ㉯).
*
* 무엇이 문제였나 — 측점을 만드는 자리는 **B05 노선 [확정] 한 곳뿐**이라, 관을 나중에
* 놓거나 옮기면 그 측점이 안 생긴다. 그 관은 횡단도에도 안 서고 **수량·금액에서 통째로
* 빠지는데 아무 말도 안 나온다**(실측: 배수관 넷이 B09 에서 막혀 있었다).
*
* ⚠ 자동으로 만들지 않는다 — 사용자가 누를 때만 돈다(비용이 큰 지표 샘플링이다).
* 대신 **왜 값이 없는지**가 화면에 남는다.
* ⚠ 지표 샘플링 조건이 저장에 없으면 **단추를 잠그고 사유를 보인다** — 조건을 지어내면
* 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋난다.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createButton, showToast } from "@ui/ui_template_elements";
interface MissingStation {
chainage_m: number;
label: string;
}
interface MissingResponse {
missing?: MissingStation[];
can_create?: boolean;
reason?: string;
created?: number;
message?: string;
}
async function call(projectId: string, method: "GET" | "POST"): Promise<MissingResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/section/missing-stations`, {
method,
credentials: "include",
headers: { "Content-Type": "application/json" },
});
const payload = (await response.json()) as MissingResponse;
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload;
}
/**
* 빠진 측점이 있으면 알림 줄을 `host` 맨 앞에 얹는다. 없으면 아무것도 하지 않는다.
* `onCreated` 는 측점이 실제로 생긴 뒤에만 불린다(화면을 다시 읽는 자리).
*/
export async function mountMissingStationNotice(
host: HTMLElement,
projectId: string,
onCreated: () => void | Promise<void>,
): Promise<void> {
let data: MissingResponse;
try {
data = await call(projectId, "GET");
} catch {
return; // 점검이 안 되는 것으로 화면을 막지 않는다 — 이 줄은 덤이다.
}
const missing = data.missing ?? [];
if (!missing.length) return;
const box = document.createElement("div");
box.className = "b06-missing-stations";
const text = document.createElement("p");
text.className = "b06-missing-stations__text";
const where = missing
.slice(0, 6)
.map((item) => `${item.chainage_m.toFixed(2)}m ${item.label}`)
.join(" · ");
text.textContent =
`측점이 없는 구조물 ${missing.length}개 — ${where}` +
(missing.length > 6 ? `${missing.length - 6}` : "") +
". 이 자리는 횡단도에도 안 서고 수량에서도 빠집니다.";
box.append(text);
if (data.can_create) {
const button = createButton({ label: "측점 만들기", variant: "filled" });
button.addEventListener("click", async () => {
button.disabled = true;
button.textContent = "만드는 중…";
try {
const result = await call(projectId, "POST");
showToast(`측점 ${result.created ?? 0}개를 만들었습니다.`, "success");
box.remove();
await onCreated();
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`측점을 만들지 못했습니다.${detail}`, "error");
button.disabled = false;
button.textContent = "측점 만들기";
}
});
box.append(button);
} else if (data.reason) {
const reason = document.createElement("p");
reason.className = "b06-missing-stations__reason";
reason.textContent = data.reason;
box.append(reason);
}
host.prepend(box);
}
+4
View File
@@ -4,6 +4,7 @@ import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state";
import { navigateTo } from "../A00_Common/router";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { mountMissingStationNotice } from "./B06_Section_UI_Missing_Stations";
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { attachCollapsible } from "@ui/ui_template_collapsible";
@@ -750,6 +751,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0)
stationInterval = storedOptions.station_interval_m;
renderSectionDetail();
// 측점이 없는 구조물 알림 — 관을 나중에 놓으면 그 측점이 안 생겨 수량에서 조용히 빠진다
// (계획서 3-14 ㉯). 만드는 것은 사용자가 누를 때만.
void mountMissingStationNotice(root, projectId, refreshDetailForStructures);
void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6)
updateActionState();
} catch (error) {
@@ -416,3 +416,29 @@
.b06-chart__spoil-fill.is-unclosed {
stroke: #c0392b;
}
/* 「측점 없는 관 N개」 알림 — 조용히 빠지던 것을 드러내는 줄(계획서 3-14 ㉯). */
.b06-missing-stations {
align-items: center;
background: #fff8e6;
border: 1px solid #e0b872;
border-radius: 6px;
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
margin: 0 0 12px;
padding: 10px 12px;
}
.b06-missing-stations__text {
color: #7a5a12;
flex: 1 1 320px;
margin: 0;
}
.b06-missing-stations__reason {
color: #8a6a22;
flex: 1 1 100%;
font-size: 12px;
margin: 0;
}
@@ -49,7 +49,11 @@ _CROSS_RIGHT_ROWS: tuple[tuple[str | None, str | None], ...] = (
("성토파종", "fill_seeding"),
("절토살포", "cut_spraying"),
("제근", "grubbing"),
(None, None),
# 사토장(유용토운반작업장) — **「쌓기」와 갈라 세운다**(2026-09-09 확정 ㉠).
# ⚠ 여기는 원래 **빈 줄**이었고, 실무 원본 횡단도에는 그 자리가 **아예 없다**
# (2026-09-09 400dpi 실측). 즉 **납품 양식을 늘리는 것이 아니라 안 쓰던 칸을 쓰는 것**이다.
# ⚠ 사토장이 없는 측점에는 값이 안 실려 종전처럼 빈칸으로 남는다.
("사토장", "spoil_fill"),
("노면다짐", "road_compaction"),
)
@@ -74,6 +78,8 @@ QUANTITY_VALUE_KEYS: tuple[str, ...] = (
"fill_seeding",
"cut_spraying",
"grubbing",
# 사토장 — 「쌓기(embankment)」와 **다른 칸**이다. 합치지 않는다(확정 ㉠).
"spoil_fill",
"road_compaction",
)
@@ -47,6 +47,9 @@ AREA_KEYS: tuple[tuple[str, str], ...] = (
("embankment", "fill_area_m2"),
("ditch_soil", "ditch_soil_area_m2"),
("ditch_rock", "ditch_rock_area_m2"),
# 사토장 — **`fill_area_m2` 와 갈라 든다**. B06 이 이미 노선 성토에서 뺀 값이라
# 여기서 더하거나 빼지 않는다(확정 ㉠ — 같은 흙을 두 번 세지 않기).
("spoil_fill", "spoil_fill_area_m2"),
)
#: `ditch_split_basis` 를 사람이 읽는 말로. 도면·화면이 「왜 이렇게 갈렸나」를 보일 때 쓴다.
@@ -170,24 +170,49 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
)
)
removal = slope.get("tree_removal_fill", 0.0) + slope.get("tree_removal_cut", 0.0)
rows.append(
SummaryRow(
group="지장목제거",
unit="",
amount=removal * _ratio(source, "obstacle_removal"),
amount_gross=removal,
application_ratio_pct=_ratio(source, "obstacle_removal") * 100.0,
application_ratio_breakdown={
"fill": _ratio(source, "obstacle_removal") * 100.0,
"cut": _ratio(source, "obstacle_removal") * 100.0,
},
quantity_breakdown={
"fill": slope.get("tree_removal_fill", 0.0) * _ratio(source, "obstacle_removal"),
"cut": slope.get("tree_removal_cut", 0.0) * _ratio(source, "obstacle_removal"),
},
note=_ratio_note(source, "obstacle_removal", "성토면+절토면"),
# ⭐ 2026-09-09 **사용자 확정 5차 2번** — 지장목제거를 **두 줄로 가른다**(실무 서식).
# 영월 설계내역서 1.9 지장목제거가 두 줄이고 **같은 면적을 나눠 쓴다**:
# 1.9.1 뿌리뽑기(장비+인력) 11,035㎡ @475
# 1.9.2 잡관목제거 벌목(5m미만) 11,035㎡ @882 ← 같은 11,035㎡
# ⚠⚠ **이중계상이 아니다** — 한 면적에 **다른 두 작업**이 얹히는 것이라 실무가 그렇게 적는다.
# (같은 작업을 두 축에서 두 번 세는 것과는 다른 자리다.)
# ⚠ 잡관목제거는 **품셈에 그 이름이 없다** — 실무는 별도 단가(영월 D00033)를 씀.
# 공종 없는 줄 보류(확정 5차 3번)에 걸리므로 **코드 없이 서고 사유가 붙는다.**
for item, why in (
(
"뿌리뽑기",
"확정 5차 2번 — 실무가 뿌리뽑기·잡관목제거 두 줄로 가름(같은 면적을 나눠 씀 · 이중계상 아님)",
),
(
"잡관목제거",
"확정 5차 2번 — 같은 면적에 얹히는 다른 작업(이중계상 아님)."
" ⚠ 품셈에 그 이름이 없어 실무는 별도 단가를 씀(영월 D00033) — 공종 보류 대상",
),
):
rows.append(
SummaryRow(
group="지장목제거",
item=item,
unit="",
amount=removal * _ratio(source, "obstacle_removal"),
amount_gross=removal,
application_ratio_pct=_ratio(source, "obstacle_removal") * 100.0,
application_ratio_breakdown={
"fill": _ratio(source, "obstacle_removal") * 100.0,
"cut": _ratio(source, "obstacle_removal") * 100.0,
},
quantity_breakdown={
"fill": slope.get("tree_removal_fill", 0.0)
* _ratio(source, "obstacle_removal"),
"cut": slope.get("tree_removal_cut", 0.0) * _ratio(source, "obstacle_removal"),
},
note=" · ".join(
part
for part in (_ratio_note(source, "obstacle_removal", "성토면+절토면"), why)
if part
),
)
)
)
rows.append(
SummaryRow(
group="층따기", spec="백호우", unit="", amount=slope.get("bench_cut_fill", 0.0)
@@ -134,6 +134,13 @@ BLOCKED_UNIT_DATA_MISSING = "unit_data_missing" # 원단위·표준 물량 자
BLOCKED_FORMULA_MISSING = "formula_missing" # 수량 산출식 자체가 없음
#: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다.
#: ⚠ `item` 칸이 **지반 갈래**인 공종 — 그 밖의 공종에서 `item` 은 **작업 갈래**다
#: (지장목제거의 「뿌리뽑기·잡관목제거」). 갈래로 읽으면 「시공법 미지정」이라는 **틀린 사유**가
#: 붙는다(2026-09-09 실측). 정의처는 `EarthworkSummary` 이고 여기서 그대로 가져다 쓴다.
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
GROUND_SPLIT_GROUPS as GROUND_SPLIT_GROUPS,
)
SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"})
#: 집계 합계 줄 — 내역 줄이 아니라 검산용이다.
@@ -14,6 +14,7 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
BLOCKED_FORMULA_MISSING,
BLOCKED_INPUT_MISSING,
BLOCKED_UNIT_DATA_MISSING,
GROUND_SPLIT_GROUPS,
HAUL_SUMMARY_GROUPS,
METHOD_TO_GROUND,
NOTE_HAUL_IN_SUMMARY,
@@ -114,7 +115,12 @@ def _earthwork_rows(
group = str(row.get("group") or "")
if not group:
continue
ground = row.get("item") or None
# ⚠ `item` 이 **지반 갈래인 공종**은 정해져 있다(흙깎기·측구터파기·구조물터파기).
# 그 밖(지장목제거의 「뿌리뽑기·잡관목제거」 같은 **작업 갈래**)을 갈래로 읽으면
# 「시공법 미지정으로 공종을 못 고름」이라는 **틀린 사유**가 붙는다(2026-09-09 실측).
is_ground_split = group in GROUND_SPLIT_GROUPS
ground = (row.get("item") or None) if is_ground_split else None
work_kind = None if is_ground_split else (row.get("item") or None)
origin = ORIGIN_SLOPE if group in SLOPE_GROUPS else ORIGIN_EARTHWORK
# ⚠ 운반은 **집계에도 오르고 운반표에도 오른다** — 내역 줄은 운반표 쪽 하나뿐이다.
is_subtotal = group in SUBTOTAL_GROUPS or group in HAUL_SUMMARY_GROUPS
@@ -142,7 +148,8 @@ def _earthwork_rows(
{
"work_item_code": code,
"name": group,
"spec": str(row.get("spec") or ""),
# 작업 갈래가 있으면 **규격 칸**에 적는다 — 갈래 축(`ground_class`)이 아니다.
"spec": str(work_kind or row.get("spec") or ""),
"unit": unit,
"quantity": amount,
# 반영률 — 곱하기는 여기가 끝이다. 받는 쪽은 적기만 한다.
@@ -582,7 +589,15 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]
"station_from": None,
"station_to": None,
"excavation_method": None,
"spec_detail": str(row.get("group") or ""),
# ⚠ **값이 서는 줄에도 근거를 싣는다**(2026-09-09) — 종전에는 대분류 이름만
# 실어, 값이 서는 순간 **왜 그 값인지가 사라졌다**(제근이 면적 축으로 확정돼
# 값이 서자 「교차 참조: 건설품셈 3-9-2」가 화면에서 사라진 자리).
# 못 서는 줄은 종전대로 `blocked_reason` 이 따로 든다.
"spec_detail": " · ".join(
part
for part in (str(row.get("group") or ""), str(row.get("reason") or ""))
if part
),
"composite_parts": None,
"structure_kind": None,
# ⚠ 못 서는 까닭을 그대로 넘긴다 — 받는 쪽이 「만들어야 할 것」 목록에 얹는다.
+21 -14
View File
@@ -46,17 +46,24 @@ NOTE_SECTION_MISSING = (
"[저장]으로는 안 풀립니다. 횡단설계에서 그 측점이 서야 합니다"
)
# ── 허용 오차 둘 — **묻는 것이 다르다.** 한 자리에 모아 둔다 ────────────────
# ⚠ **맞추지 말 것.** 하나로 맞추면 둘 다 나빠진다(2026-09-09 세 창 확인).
# · `LENGTH_MATCH_TOLERANCE_M` (0.5m) — 「이 관의 **길이를 어디서 가져오나**」.
# 넉넉해도 된다. 값을 못 찾는 것보다 옆 측점 길이를 쓰는 편이 낫다.
# · `SECTION_MATCH_TOLERANCE_M` (0.05m) — 「이 측점에 **횡단 설계가 있나**」.
# 좁아야 한다. 없는 설계를 「있다」고 말하면 사용자가 [저장]을 눌러 보고 헤맨다.
# ⚠ 두 값의 차이는 **증상**이지 병이 아니다. 병은 **관 자리에 측점이 없는 것**이고
# · `LENGTH_MATCH_TOLERANCE_M` — 「이 관의 **길이를 어디서 가져오나**」.
# 넉넉해도 된다. 값을 못 찾는 것보다 옆 측점 길이를 쓰는 편이 낫다.
# · `SECTION_MATCH_TOLERANCE_M` — 「이 측점에 **횡단 설계가 있나**」.
#
# ⚠⚠ **둘 다 0.5 다. 좁히지 말 것** — 앞서 「좁아야 한다」던 판단이 실측으로 뒤집혔다
# (2026-09-09 랩탑 메인). 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로
# 스냅**한다(횡단 파일명이 정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드).
# 그래서 관 440.241 의 횡단은 **측점 440.0** 이고 구조물 이름표까지 달고 있다 —
# **없는 것이 아니다.** 좁게 보면 「그 측점의 횡단 자체가 없습니다」라는 **거짓 사유**가
# 뜬다(반대 방향의 거짓). 스냅 폭이 「정수 미터 반올림」이라 최대 어긋남이 0.5m 이고,
# 그래서 길이 찾기와 **같은 값**이 된다 — 우연이 아니라 같은 까닭이다.
# ⚠ 값이 같아졌다고 **하나로 합치지 말 것.** 묻는 것이 둘이라 근거도 둘이고, 한쪽 근거가
# 바뀌면 한쪽만 움직여야 한다.
# ⚠ 두 값은 **증상**이지 병이 아니다. 병은 **관 자리에 측점이 없는 것**이고
# (측점을 만드는 자리는 B05 노선 [확정] 한 곳뿐 — 관을 나중에 놓거나 옮기면 안 생긴다,
# 계획서 3-14), 그것이 고쳐지면 의 차이가 **아무 관도 안 건드린다**
# (자기 자리에 측점이 있는 관 다섯이 이미 그 상태다).
# 계획서 3-14), 그것이 고쳐지면 들이 **아무 관도 안 건드린다.**
LENGTH_MATCH_TOLERANCE_M = 0.5
SECTION_MATCH_TOLERANCE_M = 0.05
SECTION_MATCH_TOLERANCE_M = 0.5
NOTE_KIND_DEFAULT = "관종을 안 정해 기본값({kind})으로 섰습니다 — 정하면 공종이 갈립니다"
NOTE_KIND_UNKNOWN = "{kind}」은(는) 아는 관종이 아니라 공종을 못 골랐습니다"
@@ -89,7 +96,7 @@ def _nearest(
"""관 측점과 단면 측점이 소수점에서 어긋날 수 있어 **가까운 것**을 본다.
좁게 본다(기본 0.5m) 넓히면 측점의 길이를 물어 조용히 틀린다.
횡단이 있나 재는 `SECTION_MATCH_TOLERANCE_M`(0.05m) **다른 물음**이다
횡단이 있나 재는 `SECTION_MATCH_TOLERANCE_M`(0.5m) **다른 물음**이다
주석을 . 맞추면 나빠진다.
"""
if not lengths:
@@ -141,10 +148,10 @@ def build_rows(
kind_note = NOTE_KIND_UNKNOWN.format(kind=stored_kind)
length = _nearest(lengths, chainage)
# ⚠ **관이 놓인 그 측점**이 있는지를 본다 — 옆 측점이 있는 것은 소용없다.
# 실측(2026-09-08 `5601e828`): 관 439.55 근처에 측점 440.0 만 있었고, 0.5m 로
# 느슨히 보면 「횡단이 있다」로 읽혀 **「[저장]하면 풀린다」는 거짓 안내**가 떴다.
# 길이는 B06 이 **관이 놓인 측점에만** 싣는다(2026-09-08 이웃 오염을 고친 뒤).
# ⚠ 관이 선 자리의 횡단이 있는지를 본다. **스냅을 셈에 넣는다**(2026-09-09) —
# 관 440.241 의 횡단은 측점 440.0 이고 그것이 정상이다. 옛 주석은 0.05m 로 좁게
# 보라고 했으나, 그때는 **스냅 때문에 관이 측점에 안 붙던 것**을 「측점이 없다」로
# 읽던 시절이라 판단이 뒤집혔다. 길이는 여전히 **주인 측점 하나에만** 실린다.
has_section = not sections or any(
abs(x - chainage) <= SECTION_MATCH_TOLERANCE_M for x in sections
)
+78 -19
View File
@@ -192,9 +192,13 @@ TOPSOIL_HAUL_LAW = (
#: 적혀 있는데 9-21 표에는 그 표기가 없다. 등급을 넣어도 **무엇당 값인지 모르면 못 센다.**
#: 지어내지 않고 그 사실을 사유로 낸다(원문 확인이 필요한 자리).
STAND_VOLUME_CLASSES = ("소림", "중림", "밀림")
ROOT_REMOVAL_BASIS_UNKNOWN = (
"품셈 9-21 표에 **밑수 단위가 없음** — 9-20 은 「10주당」이라 적혀 있으나 9-21 에는"
" 그 표기가 없어 「무엇당 값」인지 원문으로 확인해야 함. 등급을 넣어도 그 전에는 못 셈"
#: ⭐ 2026-09-09 **사용자 확정 5차 6번** — 제근 밑수를 **면적 축**으로 확정.
#: 산림품셈 9-21 표에 밑수 표기가 없어, **건설공사 표준품셈 3-9-2 「1,000㎡당」**을 빌려 쓴다.
#: ⚠ **교차 참조 표시 필수**(CLAUDE.md 3장 — 다른 품셈을 빌려 쓸 때의 규칙).
#: ⇒ 이 확정으로 **제근·뿌리 적재·뿌리 운반·지장목제거 넷이 같은 면적 밑수**로 선다.
ROOT_REMOVAL_BASIS = (
"밑수는 **면적 축**(사용자 확정 5차 6번) — 산림품셈 9-21 에 밑수 표기가 없어"
" ⚠ **교차 참조**: 건설공사 표준품셈 **3-9-2 뿌리뽑기 「1,000㎡당」**을 빌려 씀"
)
ROOT_REMOVAL_CLASS_MISSING = (
"임목축적 등급이 아직 입력되지 않았습니다 — 품셈 9-21 [주]① 이 소림(30~60㎥/㏊)·"
@@ -210,18 +214,20 @@ def _root_removal_row(slope: dict[str, float], stand_volume_class: str | None) -
"""
area = float(slope.get("tree_removal_fill", 0.0)) + float(slope.get("tree_removal_cut", 0.0))
picked = str(stand_volume_class or "").strip()
reasons = [ROOT_REMOVAL_BASIS_UNKNOWN]
reasons = [ROOT_REMOVAL_BASIS, "대상 면적은 지장목제거와 같은 자리(벌개제근 연동)"]
if picked in STAND_VOLUME_CLASSES:
reasons.append(f"등급{picked}로 정해져 있음")
reasons.append(f"임목축적 등급 「{picked} — 품셈 9-21 [주]① 이 품을 그 축으로 가름")
else:
reasons.append(ROOT_REMOVAL_CLASS_MISSING)
# ⚠ 등급은 **품(단가) 갈래**이지 물량 밑수가 아니다 — 면적이 서면 물량은 선다.
# 등급이 비면 값은 서되 **단가를 못 고른다**는 사실만 사유로 남는다.
return {
"group": "준비공",
"item": "제근·뿌리다듬기",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"reason": " · ".join(reasons) + f" (대상 면적 {area:,.1f}㎡ — 벌개제근 연동)",
"unit": "",
"amount": area if area > 0 else None,
"status": STATUS_READY if area > 0 else STATUS_PENDING,
"reason": " · ".join(reasons),
"reference_amount": area,
"work_item_code": "FP-09-21",
}
@@ -240,37 +246,87 @@ ROOT_STEPS_NOTE = (
)
#: ⭐ 2026-09-09 **사용자 확정 5차 5번** — 근주이식·임목파쇄는 **기본 안 셈**.
#: ⚠ 다만 「**현장에 따라 파쇄가 적용될 필요 있음**」이라 **임목파쇄만 켤 수 있는 칸**을 둔다.
#: **기본은 꺼짐**이고, **근주이식은 칸도 안 만든다**(켤 자리가 없으면 물을 일도 없다).
#: ⚠ 켜도 **부피는 지어내지 않는다** — 실무는 부피(영월 78㎥ @56,124)로 세고, 그 부피를
#: 우리가 든 곳이 없다. 켜면 줄이 서고 **수량 칸이 비어 사유로 드러난다.**
CHIPPING_ITEM = "임목파쇄"
CHIPPING_CODE = "FP-08-11" # 이동식 임목 파쇄
CHIPPING_OFF_NOTE = (
"기본 안 셈(확정 5차 5번) — 현장에 따라 필요하면 산출 조건에서 켤 것."
" 근주이식(FP-14-02)은 칸도 두지 않음"
)
CHIPPING_ON_NOTE = (
"켜져 있음(확정 5차 5번) — ⚠ **파쇄할 부피(㎥)를 든 곳이 없어** 물량이 안 섬."
" 실무는 부피로 셈(영월 78㎥). 산출 조건에 부피를 넣으면 값이 섬"
)
def chipping_rows(enabled: Any, volume_m3: Any) -> list[dict[str, Any]]:
"""임목파쇄 — **켰을 때만** 줄이 선다. 끄면 줄 자체를 안 낸다.
상태에서 줄을 세우면 ** 칸이 세야 으로 읽힌다**(부대시설과 다른 자리
그쪽은 법정 의무라 줄이 서고, 이쪽은 **셀지 말지가 설계 판단**이다).
"""
if not bool(enabled):
return []
try:
amount = float(volume_m3) if volume_m3 not in (None, "") else None
except (TypeError, ValueError):
amount = None
return [
{
"group": "준비공",
"item": CHIPPING_ITEM,
"unit": "",
"amount": amount,
"status": STATUS_READY if amount and amount > 0 else STATUS_PENDING,
"reason": CHIPPING_ON_NOTE if not amount else "산출 조건에서 넣은 부피 (확정 5차 5번)",
"work_item_code": CHIPPING_CODE,
}
]
def _root_steps_rows(
slope: dict[str, float], stand_volume_class: str | None
) -> list[dict[str, Any]]:
"""뿌리 적재·운반 — **제근과 한 벌로 가는 뒤 단계**. 밑수가 서면 함께 선다."""
"""뿌리 적재·운반 — **제근과 한 벌로 가는 뒤 단계**. 제근이 서면 함께 선다."""
area = float(slope.get("tree_removal_fill", 0.0)) + float(slope.get("tree_removal_cut", 0.0))
return [
{
"group": "준비공",
"item": "뿌리 적재",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"unit": "",
"amount": area if area > 0 else None,
"status": STATUS_READY if area > 0 else STATUS_PENDING,
"reason": (
f"{ROOT_STEPS_NOTE} · 품셈 9-20-2 「(단위: 10주당)」 — **본수가 서야 셈**"
f" (대상 면적 {area:,.1f}㎡ · 제근과 같은 밑수)"
f"{ROOT_STEPS_NOTE} · {ROOT_REMOVAL_BASIS}"
" · ⚠ **품셈 9-20-2 는 「10주당」이라 밑수 축이 다름** — 면적 축으로 내고"
" 그 사실을 적음(본수가 서면 그 축으로 옮길 것)"
),
"reference_amount": area,
"work_item_code": "FP-09-20-02",
},
{
# ⭐ 2026-09-09 **확정 5차 4번** — 뿌리 운반은 **덤프**로 잡는다.
# ⚠ 사용자 관찰 「실제 운반품이 적용 안 되는 듯함. 나중엔 적용될 가능성도 있음」
# ⇒ 코드는 붙이되 **품이 안 붙으면 그대로 두고 사유**를 낸다. 억지로 안 붙인다.
# ⚠ 물량 축이 다르다 — 운반은 **부피(㎥)** 인데 우리가 든 것은 **면적**뿐이라
# ㎥ 를 지어내지 않고 면적을 참고로만 싣는다.
"group": "준비공",
"item": "뿌리 운반",
"unit": "",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"reason": (
f"{ROOT_STEPS_NOTE} · ⚠ **9-20 장에 운반 공종이 없음** — 10장 계열 어디에"
" 붙는지 원문이 말하지 않아 공종코드를 못 붙임"
f"{ROOT_STEPS_NOTE} · 사용자 확정 5차 4번 「**덤프**로 잡음」 —"
" ⚠ 운반 밑수는 **부피(㎥)** 인데 뿌리 부피를 든 곳이 없어 물량이 안 섬"
" (대상 면적만 있음). ⚠ 실무에서 **운반품이 안 붙는 경우가 있음**(사용자 관찰) —"
" 안 붙으면 그대로 두고 이 사유가 남음"
),
"reference_amount": area,
"work_item_code": None,
"work_item_code": "FP-10-12",
},
]
@@ -574,6 +630,8 @@ def build_table(
ancillary_counts: dict[str, Any] | None = None,
topsoil_haul_distance_m: float | None = None,
stand_volume_class: str | None = None,
chipping_enabled: Any = False,
chipping_volume_m3: Any = None,
) -> dict[str, Any]:
"""화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**"""
rows = (
@@ -585,6 +643,7 @@ def build_table(
stand_volume_class,
)
+ erosion_rows(structures, names)
+ chipping_rows(chipping_enabled, chipping_volume_m3)
+ ancillary_rows(ancillary_counts)
)
return {
+164 -44
View File
@@ -86,6 +86,81 @@ STONE_WEIGHT_BASIS = (
" 품셈·교본에는 돌중량표가 없음"
)
#: ⭐ 사용자 확정 5차 큰 것 7 — **돌 무게는 계산식**: `뒷길이 × 0.77 × 2.65`.
#: 정본 여섯 탭이 모두 그 식을 그대로 적어 둔다(「2.09 × 0.45 × 0.77 × 2.65 ton/m3」).
#: ⚠ **야면석은 그 식이 안 맞는다** — 공극이 커 ㎥당 무게가 달라 원문이 값을 직접 준다
#: (35→0.575 · 45→0.880 · 55→1.100 ton/㎡). 그래서 **종류로 갈래를 둔다.**
#: ⚠ **잠정이다** — 확정 문구가 「나중에 실무자 협의 후 명확히」이므로 화면 근거에 적는다.
STONE_PACKING_RATIO = 0.77 # 채움률 — 돌 사이 공극을 뺀 몫
STONE_DENSITY_TON_M3 = 2.65 # 석재 비중
#: 계산식이 안 맞아 **관측표**를 쓰는 종류. 안 고른 경우도 이쪽(지금까지 쓰던 값이 그것).
STONE_WEIGHT_OBSERVED_KINDS = frozenset({"야면석·호박돌", "야면석"})
STONE_WEIGHT_FORMULA_BASIS = (
"⭐ 확정 5차 — 뒷길이 × 0.77(채움률) × 2.65(비중). ⚠ 잠정 — 실무자 협의 후 명확히 할 값"
)
#: 물구멍 칸 — 2026-09-09 에 등록부에 생겼다(랩탑 메인 `0b0763ed`). **빈 칸이 기본**이다.
#: ⚠ 기본 Ø50 은 **국가기준**이고 2㎡당 1개소는 실무 관측값이다(법은 「2~3㎡당 1개소 이상」).
WEEP_HOLE_DIAMETER_KEY = "weep_hole_diameter_mm"
WEEP_HOLE_AREA_KEY = "weep_hole_area_m2"
WEEP_HOLE_DIAMETER_DEFAULT_MM = 50
def weep_hole_spec(options: dict[str, Any]) -> tuple[float, int, str]:
"""(개소당 벽면적 ㎡, 관 지름 ㎜, 근거). 안 정하면 기본으로 서고 그 사실이 근거에 남는다."""
area = _num(options.get(WEEP_HOLE_AREA_KEY), 0.0)
diameter = _num(options.get(WEEP_HOLE_DIAMETER_KEY), 0.0)
area_given = area > 0
dia_given = diameter > 0
if not area_given:
area = STONE_MASONRY["weep_hole_area_m2"]
if not dia_given:
diameter = WEEP_HOLE_DIAMETER_DEFAULT_MM
basis = (
f"{area:g}㎡당 1개소 · Ø{int(diameter)}"
+ ("" if area_given else " · ⚠ 면적 안 정함 — 실무 관측 2㎡(법은 2~3㎡당 1개소 이상)")
+ ("" if dia_given else " · ⚠ 지름 안 정함 — 국가기준 Ø50")
)
return area, int(diameter), basis
def stone_weight_per_m2(
back_cm: int, kind: str, observed: float | None
) -> tuple[str, float | None, str, str]:
"""(줄 이름, ton/㎡, 근거, source) — ⭐ 확정 5차 큰 것 7.
**계산식이 기본**이고 야면석만 관측표다. 돌쌓기·골막이가 같은 규칙을 쓰도록 여기 벌만 둔다.
"""
if kind in STONE_WEIGHT_OBSERVED_KINDS:
basis = (
f"× {observed} ton/㎡ (뒷길이 {back_cm}㎝) · {STONE_WEIGHT_BASIS}"
if observed is not None
else ""
)
return kind, observed, basis, STONE_WEIGHT_SOURCE
ton = back_cm / 100.0 * STONE_PACKING_RATIO * STONE_DENSITY_TON_M3
basis = (
f"× {ton:.3f} ton/㎡ = 뒷길이 {back_cm / 100.0:g} × {STONE_PACKING_RATIO}"
f" × {STONE_DENSITY_TON_M3}"
+ (f" · {kind}" if kind else " · ⚠ 돌 종류를 안 골랐음")
+ f" · {STONE_WEIGHT_FORMULA_BASIS}"
)
return kind or "", ton, basis, ""
#: 뒷채움(막자갈) 폭 — ⭐ 사용자 확정 5차 작은 것 3 「막자갈 = (상 + 하) ÷ 2 × H」.
#: ⚠⚠ **여기 상·하는 벽 두께가 아니라 뒷채움 폭이다.** 벽 두께(0.75/0.9)를 끌어다 쓰면
#: H=2.0 에서 실무 0.75 가 1.66(입적)으로 튄다 — 옛 식이 곧 그 자리였다.
#: 정본 `04.구조도(기슭막이).xls` **여섯 탭 전부** 상 0.30 · 하 0.45 로 같다
#: (H=1.0~3.0 · 메/찰 · 기초유/버림 전수 확인, 2026-09-09).
#: ⓘ 소광리는 같은 식에 **0.30 / 0.60** 을 쓴다(그래서 H=2.0 에서 0.900). 문서마다 폭이
#: 다르므로 **값이 적힌 구조물은 그 값**을 쓰고, 없으면 이 정본 값으로 서되 그 사실을 알린다.
STONE_BACKFILL_WIDTH_M = {"top": 0.30, "bottom": 0.45}
#: 석적 — ⭐ 확정 5차 작은 것 4. **정본에는 없고 소광리에만 있는 줄**이라 사유에 적는다.
#: 소광리 식 `H × ℓ3 × 0.77`(면적 × 뒷길이 × 채움률) 그대로.
STONE_PILE_BASIS = "⚠ 정본에 없는 줄 — 소광리 시트에만 있음(정면적 × 뒷길이 × 0.77)"
#: 돌 종류별 계수표 — 품셈 13-4-3·13-4-4 [주]① · 교본 7-3.
#: ⚠ 지금까지 **건설품셈 참고자료 한 벌**(돌 종류로 안 갈리는 표)로만 돌고 있었다.
@@ -171,11 +246,13 @@ def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str,
# 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것.
#: ⚠⚠ **기슭막이 계열(돌쌓기 찰·메) 전용이다. 다른 구조물로 넓히지 말 것**(2026-09-09).
#: 골막이는 **두께식부터 다르다** — 소광리 숨김탭 「골막이(찰)(치수조서연결)」 C26:
#: 평균두께 = {(3 + 0.1×H) + (3 + 0.4×H)} ÷ 2
#: ⚠ 앞서 이 식을 「0.45 + 0.1H」로 적어 두었으나 **0.45 는 상수가 아니라 뒷길이 ℓ3**
#: 이다(C26 이 `P6/100` 을 읽고 P6 = 45㎝). ⇒ 두 식 다 뒷길이 기반이고 **더하는 몫만**
#: 다르다. 우리가 확정 ② 로 버린 옛 식이 뒷길이를 안 보던 것과는 그 점이 다르다.
#: 골막이는 **더하는 몫이 다르다** — 소광리 숨김탭 「골막이(찰)(치수조서연결)」 C26:
#: 골막이 평균두께 = {(3 + 0.1×H) + (3 + 0.4×H)} ÷ 2
#: 기슭막이 상부 = ℓ3 + 0.30 · 하부 = 상부 + 0.30(H 1)
#: ⚠⚠ **둘 다 뒷길이(ℓ3) 기반이다** — C26 의 「0.45」는 상수가 아니라 `P6/100`(뒷길이 45㎝)을
#: 읽은 값이었다(2026-09-09 랩탑 보조가 셀 참조로 확인). 앞서 여기 「0.45 + 0.1H」로 적어
#: **상수처럼 보이게** 두었는데, 그렇게 굳으면 **뒷길이를 바꿔도 골막이 값이 안 움직이는**
#: 결함이 조용히 남는다.
#: ⚠ 상수만 보고 골막이에 가져다 쓰면 **값이 나오므로 아무 시험도 안 잡는다.**
#: ⚠ 골막이는 그 밖에도 셋이 더 다르다 — 밑수가 **돌쌓기 + 돌붙임**(기슭막이는 돌쌓기만) ·
#: 단위가 **개소당**(기슭막이는 m당) · 정면적이 **사다리꼴**.
@@ -313,8 +390,16 @@ DESTINATION = {
# ⚠ **보여 주기만 하는 줄** — 자재도 토공도 일위대가도 아니다. 정본 계산표 좌측 열에
# 「입적」으로 실려 있어 그 이름 그대로 낸다(2026-09-08 확정 ⑦). 값은 이미 식 안에
# 있던 것을 줄로 꺼낸 것뿐이고, `material`·`earthwork` 어디에도 안 섞인다.
# ⚠ 「석적」은 정본에 없다(소광리 시트에만) — **안 낸다.**
# ⚠ 「석적」은 정본에 없소광리 시트에만 있으나 **확정 5차로 세운다** — 같은
# 보여 주기 줄이고, 「소광리에만 있는 줄」임을 사유에 적는다.
"입적": "reference",
"석적": "reference",
# 돌 종류를 고르면 그 이름으로 줄이 선다 — 자재총괄이 이름으로 찾으므로 넷 다 둔다.
"야면석·호박돌": "material",
"깬잡석": "material",
"견치돌": "material",
# 종류를 안 고른 경우의 이름 — 정본 계산표 줄 이름 그대로다(「돌 ℓ3=45cm」).
"": "material",
# 기초잡석(품셈 12-25) — 운반·부설·다짐 품이 붙는 **공종**이라 일위대가로 간다.
# 자재총괄로 보내면 같은 잡석이 재료로 한 번 더 선다.
RUBBLE_BASE_NAME: "unit_price",
@@ -755,12 +840,9 @@ def stone_masonry(
for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"):
if picked.get(key) is not None:
table[key] = picked[key]
#: ⚠ **표는 「뒤채움 몫」, 우리 식은 「빼는 몫」** — 뜻이 반대라 1 에서 뺀다.
#: 교본은 「뒤채움 = 뒷길이 × (깬돌·잡석 1/2, 야면석 1/3)」이고, 우리 식이 입적에서
#: 빼는 것은 **돌 몸통**이라 `1 − 뒤채움몫` 이다. 종전 2/3 이 곧 야면석(1 − 1/3)이었다.
#: ⚠ 그대로 넣었더니 미지정 값이 15.130 → 19.045 로 바뀌었다(만들다 잡음).
backfill_share = picked.get("backfill_ratio")
body_ratio = 1.0 - float(backfill_share) if backfill_share is not None else 2.0 / 3.0
# ⚠ **교본의 「뒤채움 몫」(깬돌·잡석 1/2 · 야면석 1/3)은 이제 막자갈 밑수가 아니다** —
# ⭐ 확정 5차 작은 것 3 이 막자갈을 **뒷채움 폭 사다리꼴**로 못 박았다. 표는 그대로
# 두되(다른 자리에서 쓸 수 있다) 여기서는 안 쓴다.
kind_label = str(picked.get("kind") or "")
# ⚠ `face_slope_ratio` 는 **2026-09-09 에 칸이 생겼다**(돌쌓기 계열 여섯 종류 · 표준도
# 제원 폼). 빈 값이 「자동」의 뜻이라 비어 있으면 아래 표준경사표가 돌고, 채우면 그 값이
@@ -779,7 +861,8 @@ def stone_masonry(
# 실무 시트의 「정면적 × 1.04」가 바로 이 값이다(1:0.3 에서 1.0440 ≈ 1.04).
masonry_area = face_area * math.hypot(1.0, slope_ratio)
# 벽 두께 — 실무 구조물도 식(확정 2차 ②). **뒷길이가 들어간다.**
top_thickness = back_cm / 100.0 + constants["thickness_top_add_m"]
back_m = back_cm / 100.0
top_thickness = back_m + constants["thickness_top_add_m"]
bottom_thickness = top_thickness + constants["thickness_slope_per_m"] * max(
height_m - constants["thickness_height_base_m"], 0.0
)
@@ -829,38 +912,65 @@ def stone_masonry(
)
)
stone_ton = table["stone_ton_per_m2"]
# 석적 — ⭐ 확정 5차 작은 것 4. 「체적」은 위 **입적** 줄이 이미 그것이고, 이쪽만 새 줄이다.
stone_pile = face_area * back_m * STONE_PACKING_RATIO
components.append(
Component(
"석적",
"",
stone_pile,
DESTINATION["석적"],
f"정면적 × 뒷길이 {back_m:g}m × {STONE_PACKING_RATIO}(채움률) · {STONE_PILE_BASIS}",
)
)
# 돌 무게 — ⭐ 확정 5차 큰 것 7. **계산식이 기본**이고 야면석만 관측표다(헬퍼 한 벌).
# ⚠ 안 고른 경우도 **계산식**으로 선다 — 정본 여섯 탭이 종류를 안 적고 그 식을 쓰고,
# 그래야 정본 H=2.0 의 「1.92 톤」과 맞는다(관측표 0.88 로 서면 1.84 로 4 % 낮다).
stone_name, stone_ton, weight_tail, weight_source = stone_weight_per_m2(
back_cm, kind_label, table["stone_ton_per_m2"]
)
weight_basis = f"돌쌓기 {weight_tail}" if weight_tail else ""
if not kind_label:
notes.append(
"돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 — "
"야면석이면 계산식이 안 맞아 관측표로 갈립니다"
)
if stone_ton is None:
# 원본 표가 비어 있는 칸이다 — 지어내지 않고 알린다(PLAN 8-8 ㉮).
notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 야면석을 내지 못함")
notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 {stone_name}을 내지 못함")
else:
components.append(
Component(
"야면석",
stone_name,
"ton",
masonry_area * stone_ton,
DESTINATION["야면석"],
f"돌쌓기 × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝) · {STONE_WEIGHT_BASIS}",
source=STONE_WEIGHT_SOURCE,
DESTINATION.get(stone_name, "material"),
weight_basis,
source=weight_source,
)
)
# 막자갈 = 입적 − (면적 × 뒷길이 × 뒤채움몫 + 고임돌).
# 뒤채움 몫은 **돌 종류로 갈린다** — 깬돌·잡석 1/2 · 야면석 1/3 (교본 7-3).
wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0 — 막자갈에서 안 뺌
stone_body = masonry_area * (back_cm / 100.0) * body_ratio # 면석 몸통 체적
rubble = volume - (stone_body + wedge)
if rubble > 0:
components.append(
Component(
"막자갈",
"",
rubble,
DESTINATION["막자갈"],
f"입적 (면적×뒷길이×{body_ratio:.4g} + 고임돌) · {thickness_basis}"
+ (f" · {kind_label}" if kind_label else ""),
)
# 막자갈(뒷채움) — ⭐ 확정 5차 작은 것 3 「(상 + 하) ÷ 2 × H」. **뒷채움 폭**이지 벽 두께가
# 아니다. 옛 식(입적 − 몸통 − 고임돌)에서 갈아탄 자리다.
backfill_top = STONE_BACKFILL_WIDTH_M["top"]
backfill_bottom = STONE_BACKFILL_WIDTH_M["bottom"]
rubble = (backfill_top + backfill_bottom) / 2.0 * height_m * length_m
components.append(
Component(
"막자갈",
"",
rubble,
DESTINATION["막자갈"],
f"(뒷채움 상 {backfill_top:g} + 하 {backfill_bottom:g}) ÷ 2 × H {height_m:g}m"
f" × 연장 {length_m:g}m · 정본 여섯 탭 공통값",
)
)
notes.append(
f"막자갈 뒷채움 폭이 정본(`04.구조도(기슭막이).xls`) 값 상 {backfill_top:g} · "
f"{backfill_bottom:g}m 붙박이입니다 — 구조물 제원에 뒷채움 폭 칸이 없습니다"
"(소광리는 같은 식에 0.30/0.60 을 씁니다)"
)
if wet:
mpa, mpa_basis = fill_concrete_mpa(options)
@@ -870,8 +980,12 @@ def stone_masonry(
"",
masonry_area * _num(table["fill_concrete_m3_per_m2"]),
DESTINATION["채움콘크리트"],
# ⚠ 돌 종류로 계수가 갈리는데(깬돌 0.2 · 야면석 0.15) 근거에 종류가 안
# 적히던 자리다 — 값은 바뀌는데 **왜 바뀌었는지가 안 보였다**(고임돌은
# 적고 있었다). 2026-09-09 맞춤.
f"돌쌓기 × {table['fill_concrete_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)"
f" · {mpa_basis}",
+ (f" · {kind_label}" if kind_label else "")
+ f" · {mpa_basis}",
spec=f"{mpa}",
)
)
@@ -896,22 +1010,29 @@ def stone_masonry(
# 지금 쓰는 13-4 계열에는 제잡비 행 자체가 없어 겹치지 않는다(전수 확인).
# ⚠ 관종·지름은 미확정 — 법은 「지름 3~6㎝ 파이프」, 실무 관측은 Ø50. 규격이 정해지면
# 이름에 붙인다(`물구멍 Ø50`). 지어내지 않고 규격 없는 이름으로 둔다.
# ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 —
# **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식).
# ⓘ 정본은 개소당 관 길이를 **평균두께**로 잡는다(H=2.0 에서 0.83m). 우리는 상수 0.5m 라
# 그만큼 짧게 선다 — 관은 벽을 가로지르므로 정본 쪽이 이치에 맞는다(미결).
hole_area, hole_dia, hole_basis = weep_hole_spec(options)
components.append(
Component(
"물구멍관",
"m",
masonry_area / constants["weep_hole_area_m2"] * constants["weep_hole_length_m"],
masonry_area / hole_area * constants["weep_hole_length_m"],
DESTINATION["물구멍관"],
# ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 —
# **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식).
"돌쌓기 ÷ 2㎡/개소 × 0.5 m/개소 · ⚠ 잠정: 관 Ø 미정(법 3~6㎝ / 실무 Ø50) ·"
" 간격 2.0㎡당 1개소(법 2~3㎡당 1개소 이상)",
f"돌쌓기 ÷ {hole_area:g}㎡/개소 × {constants['weep_hole_length_m']:g} m/개소"
f" · {hole_basis}",
spec=f"Ø{hole_dia}",
)
)
# 채집석 — **캐서 쓰는 구조물**의 돌 체적. 사토에서 뺄 밑수이고 **여기서 빼지 않는다.**
# ⚠ 밑수가 확정 5차로 바뀌었다 — 면석 몸통은 이제 **석적**(정면적×뒷길이×0.77)이고
# 막자갈은 **뒷채움 사다리꼴**이다. 옛 「입적 − 몸통 − 고임돌」 몫이 아니다.
if is_collected_stone(options):
collected = max(stone_body + wedge + max(rubble, 0.0), 0.0)
wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0
collected = max(stone_pile + wedge + rubble, 0.0)
if collected > 0:
components.append(
Component(
@@ -919,9 +1040,8 @@ def stone_masonry(
"",
collected,
DESTINATION["채집석"],
f"면석 몸통 {stone_body:.3f} + 고임돌 {wedge:.3f} + 막자갈"
f" {max(rubble, 0.0):.3f}· 현장 채집분 ·"
" ⚠ 여기서 빼지 않음 — 사토에서 한 번만 뺌",
f"석적 {stone_pile:.3f} + 고임돌 {wedge:.3f} + 막자갈 {rubble:.3f}"
" · 현장 채집분 · ⚠ 여기서 빼지 않음 — 사토에서 한 번만 뺌",
)
)
@@ -210,6 +210,8 @@ def erosion_check_dam(
STONE_MASONRY,
_back_length,
fill_concrete_mpa,
stone_weight_per_m2,
weep_hole_spec,
)
form = str(options.get("form") or "").strip()
@@ -239,8 +241,8 @@ def erosion_check_dam(
return [], [f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다"]
if not any(options.get(key) is not None for key in ("back_len_cm", "stone_back_length_cm")):
notes.append(
f"골막이 제원에 **뒷길이 칸이 없어** 기본 {back_cm}㎝ 로 섰습니다 "
"— 그 값이 두께식·고임돌·야면석·채움콘크리트 계수를 모두 가릅니다"
f"뒷길이를 안 골라 기본 {back_cm}㎝ 로 섰습니다 "
"— 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다"
)
back_m = back_cm / 100.0
@@ -252,13 +254,21 @@ def erosion_check_dam(
slope_note = f"1:{slope:g} — 정본 「반수면비탈」 붙박이(안 정함)"
# ① 정면적(사다리꼴) − ② 방수로 파형강관 단면
# ⚠ 「없음」이면 안 뺀다 — 정면적이 밑수라 **열한 줄이 통째로 움직인다.**
# ⓘ 정본은 방수로 치수가 0 인 개소에서도 뺐다. 그러므로 **정본과 같은 값은 「있음」**이고,
# 「없음」은 정본보다 크게 나온다. 안 고르면 정본 쪽(「있음」)으로 서고 그 사실을 알린다.
trapezoid = _floor2((top_m + bottom_m) / 2 * height_m)
spillway = str(options.get("spillway") or "").strip()
pipe = const["spillway_pipe_r_m"] ** 2 * const["spillway_pipe_pi"]
front_area = trapezoid - pipe
notes.append(
f"방수로 파형강관 {pipe:.4f}㎡ 를 정면적에서 뺐습니다 — 정본 붙박이(Ø0.8) "
"⚠ 「방수로 없음」을 고를 칸이 등록부에 없어 늘 뺍니다"
)
if spillway == "없음":
front_area = trapezoid
notes.append("방수로 「없음」이라 파형강관 단면을 안 뺐습니다 — 정본보다 정면적이 큽니다")
else:
front_area = trapezoid - pipe
notes.append(
f"방수로 파형강관 {pipe:.4f}㎡ 를 정면적에서 뺐습니다 — 정본 붙박이(Ø0.8)"
+ ("" if spillway else " · ⚠ 방수로를 안 골라 정본대로 뺐습니다")
)
masonry = _floor2(front_area * _round2(math.hypot(slope, 1.0)))
top_t = _floor2(back_m + const["thickness_top_per_m"] * height_m)
@@ -285,16 +295,20 @@ def erosion_check_dam(
),
]
stone_ton = coeff["stone_ton_per_m2"]
# 돌 무게 — ⭐ 확정 5차 큰 것 7. 돌쌓기와 **같은 헬퍼**를 쓴다(규칙이 하나여야 한다).
kind = str(options.get("stone_kind") or "").strip()
stone_name, stone_ton, weight_tail, _src = stone_weight_per_m2(
back_cm, kind, coeff["stone_ton_per_m2"]
)
if stone_ton is None:
notes.append(f"뒷길이 {back_cm}㎝ 에 야면석 중량 칸이 비어 있어 돌을 못 세웠습니다")
notes.append(f"뒷길이 {back_cm}㎝ 에 {stone_name} 중량 칸이 비어 있어 돌을 못 세웠습니다")
else:
rows.append(
(
"야면석",
stone_name,
"ton",
_ceil2(base_area * stone_ton),
f"(돌쌓기+돌붙임) {base_area:.4f} × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝)",
f"(돌쌓기+돌붙임) {base_area:.4f}{weight_tail}",
)
)
wedge_per = coeff["wedge_stone_m3_per_m2"]
@@ -312,15 +326,14 @@ def erosion_check_dam(
"입적 − ((돌쌓기+돌붙임) × 뒷길이 × 2/3 + 고임돌)",
)
)
hole_area, hole_dia, hole_basis = weep_hole_spec(options)
rows.append(
(
"물구멍관",
"m",
_round2(
masonry / STONE_MASONRY["weep_hole_area_m2"] * STONE_MASONRY["weep_hole_length_m"]
),
f"돌쌓기 ÷ {STONE_MASONRY['weep_hole_area_m2']:g}㎡/개소 × "
f"{STONE_MASONRY['weep_hole_length_m']:g}m/개소",
_round2(masonry / hole_area * STONE_MASONRY["weep_hole_length_m"]),
f"돌쌓기 ÷ {hole_area:g}㎡/개소 × {STONE_MASONRY['weep_hole_length_m']:g}m/개소"
f" · {hole_basis}{hole_dia})",
)
)
@@ -360,9 +373,10 @@ def erosion_check_dam(
spec=f"{mpa}",
)
)
if not options.get("stone_kind"):
if not kind:
notes.append(
"돌 종류 칸이 골막이 등록부에 없어 **야면석**으로 섰습니다 — 정본 탭이 그 돌입니다"
"돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 "
"— 정본 탭은 야면석이고, 야면석은 계산식이 안 맞아 관측표로 갈립니다"
)
return components, notes
@@ -389,8 +403,8 @@ def erosion_check_dam(
#: 본체 표를 못 찾았으므로 **「원문에 없음」으로 두고 그 사실을 화면에 적는다** —
#: 「콘크리트 개거인데 콘크리트가 0」이 우리 결함으로 읽히지 않게.
#:
#: **규격이 붙박이다** — 정본에 「150×200」 하나뿐이고 `open_ditch` 등록부에 **옵션이 하나도
#: 없다**(규격 칸 없음). 칸이 생기면 이 표를 열면 된다.
#: **규격 칸이 생겼다**(2026-09-09 랩탑 메인 `0b0763ed`) — 두 규격을 고를 수 있고,
#: 비우면 「콘크리트 개거 150×200」으로 선다.
OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = {
"콘크리트 개거 150×200": {
"rows": (
@@ -500,8 +514,8 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component
for name, unit, per_m, basis in table["rows"]
]
notes: list[str] = []
if "ditch_spec" not in options:
notes.append(f"규격{spec} 붙박이입니다 — 개거 제원에 규격 칸이 없습니다")
if not options.get("ditch_spec"):
notes.append(f"규격을 안 골라{spec}으로 섰습니다 — 제원에서 고를 수 있습니다")
if spec == "콘크리트 개거 150×200":
notes.append(
"⚠ 이 표에는 **콘크리트 본체 줄이 없습니다** — 정본 원문 그대로입니다"
@@ -142,6 +142,9 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
settings.get("topsoil_haul_distance_m"),
# 임목축적 등급 — 품셈 9-21 제근이 소·중·밀로 갈리는 축(본수가 아니다).
settings.get("stand_volume_class"),
# 임목파쇄 — 기본 꺼짐. 켠 프로젝트에서만 줄이 선다(확정 5차 5번).
settings.get("wood_chipping_enabled"),
settings.get("wood_chipping_volume_m3"),
)
method, method_is_default = concrete_placing_method(settings)
# ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다.
@@ -409,6 +412,9 @@ class QuantitySettingsBody(BaseModel):
stand_volume_class: str | None = None
# 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다.
frame_material: dict[str, Any] | None = None
# 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜면 줄이 서고, 부피를 넣으면 값이 선다.
wood_chipping_enabled: bool | None = None
wood_chipping_volume_m3: float | None = None
#: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**.
@@ -419,6 +425,7 @@ NULLABLE_SETTING_KEYS = (
"spoil_site_distance_m",
"rubble_base_thickness_m",
"topsoil_haul_distance_m",
"wood_chipping_volume_m3",
)
@@ -83,6 +83,10 @@ export interface QuantitySettings {
stand_volume_class?: string | null;
/** 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다. */
frame_material?: Record<string, number | null>;
/** 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜야 줄이 선다. */
wood_chipping_enabled?: boolean | null;
/** 파쇄 부피(㎥) — 켜도 이 값이 없으면 줄만 서고 사유가 남는다. */
wood_chipping_volume_m3?: number | null;
}
export interface EarthworkTable {
+37
View File
@@ -94,6 +94,8 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
topsoil_haul_distance_m: draft.topsoil_haul_distance_m,
stand_volume_class: draft.stand_volume_class,
frame_material: draft.frame_material,
wood_chipping_enabled: draft.wood_chipping_enabled,
wood_chipping_volume_m3: draft.wood_chipping_volume_m3,
// 개소는 **통째로** 보낸다 — 지운 항목까지 그대로 가야 되돌릴 길이 있다.
ancillary_counts: draft.ancillary_counts,
}),
@@ -284,6 +286,9 @@ interface DraftSettings {
stand_volume_class: string;
// 규준틀 개소당 재료 — 비우면 **제안값(실무 관측)**이 선다. 값이 아니라 「고칠 수 있음」이 요점.
frame_material: Record<string, number | null>;
// 임목파쇄 — **기본 꺼짐**(확정 5차 5번). 켜야 줄이 선다. 근주이식은 칸 자체가 없다.
wood_chipping_enabled: boolean;
wood_chipping_volume_m3: number | null;
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
material_supply: Record<string, SupplyChoice>;
dirty: boolean;
@@ -588,6 +593,36 @@ function buildQuantitySidePanel(
}
panel.append(hintRow(L("B08_Quantity_Side_Frame_Hint")));
// ── 임목파쇄 — ⚠ **기본 꺼짐**(확정 5차 5번). 「셀지 말지가 설계 판단」이라 켜야 줄이 선다.
// ⚠ 근주이식은 **칸 자체를 안 만든다** — 켤 자리가 없으면 물을 일도 없다.
panel.append(field(L("B08_Quantity_Side_Chipping"), ""));
panel.append(
selectField(
L("B08_Quantity_Chipping_Label"),
draft.wood_chipping_enabled ? "on" : "",
[
{ value: "", label: L("B08_Quantity_Chipping_Off") },
{ value: "on", label: L("B08_Quantity_Chipping_On") },
],
(value) => {
draft.wood_chipping_enabled = value === "on";
draft.dirty = true;
},
),
);
panel.append(
optionalNumberField(
L("B08_Quantity_Chipping_Volume"),
draft.wood_chipping_volume_m3,
"1",
(value) => {
draft.wood_chipping_volume_m3 = value;
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_Chipping_Hint")));
// ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ──
panel.append(field(L("B08_Quantity_Side_Placing"), ""));
panel.append(
@@ -846,6 +881,8 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
topsoil_haul_distance_m: (stored.topsoil_haul_distance_m as number | null) ?? null,
stand_volume_class: (stored.stand_volume_class as string) ?? "",
frame_material: { ...((stored.frame_material ?? {}) as Record<string, number | null>) },
wood_chipping_enabled: Boolean(stored.wood_chipping_enabled),
wood_chipping_volume_m3: (stored.wood_chipping_volume_m3 as number | null) ?? null,
ancillary_counts: {
...((stored.ancillary_counts ?? {}) as Record<string, number | null>),
},
+18 -1
View File
@@ -329,6 +329,23 @@ export function attachCulvertSets(
): number {
if (sets.size === 0) return 0;
let attached = 0;
// ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측).
// 측점을 만들 때 정수 미터가 같은 격자 측점이 있으면 그리로 스냅한다(횡단 파일명이
// 정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드). 관 440.241 은 측점 440.0 위에 선다.
// ⇒ 0.02m 로만 보면 그런 관은 어느 측점에도 안 붙어 **그림도 수량도 통째로 빠진다.**
// ⇒ **가장 가까운 측점 하나**는 거리와 무관하게 그 관의 자리로 본다(짝: 파이썬 `attach_culvert_sets`).
const owner = new Map<number, number>();
for (const [pipeChainage] of sets) {
let nearest: number | null = null;
for (const section of sections) {
const value = num(section.chainage_m, null);
if (value === null) continue;
if (nearest === null || Math.abs(value - pipeChainage) < Math.abs(nearest - pipeChainage)) {
nearest = value;
}
}
if (nearest !== null) owner.set(pipeChainage, nearest);
}
for (const section of sections) {
const chainage = num(section.chainage_m, null);
if (chainage === null) continue;
@@ -336,7 +353,7 @@ export function attachCulvertSets(
// 연동 대상 종류만 폭의 절반까지 옆 측점에 걸친다.
let reach = CHAINAGE_TOLERANCE_M;
if (SPAN_LINKED_TYPES.has(String(spec.type))) reach += (num(spec.span_m, 0) ?? 0) / 2;
if (Math.abs(chainage - pipeChainage) <= reach) {
if (Math.abs(chainage - pipeChainage) <= reach || owner.get(pipeChainage) === chainage) {
// ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08, 짝: 파이썬
// `attach_culvert_sets`). 세트는 폭의 절반까지 옆 측점에도 붙으므로, 이것이 없으면
// 소비처가 「소유 측점」을 못 가려 **같은 시설을 여러 측점에서 센다**.
+9 -1
View File
@@ -64,7 +64,15 @@ WALL_BLINDING_WIDTH_M = 0.7 # 그때의 폭 (여유폭 0.0 — 버림 탭에는
#: 근거의 급 — 화면 노티스가 이 값을 그대로 쓴다.
BASIS_PIPE = "법정 표(KCS 44 40 10 그림 3.2-1) — 설계도서에 따로 없으면 이 값"
BASIS_WALL = "법정 근거 없음 · 실무 정본 식(구조도 기슭막이 xls) 기본값 — 확인 후 쓸 것"
#: ⭐ 2026-09-09 **사용자 확정 5차 1번** — 「비탈 터파기를 셀 것인가」에 **「지금 이대로」**로 답이
#: 왔다(기초 + 비탈 두 줄). 그래서 문구에서 **「확인 후 쓸 것」을 걷는다** — 확인이 끝났다.
#: ⚠ 「법정 근거 없음」은 그대로 둔다(사실이 안 바뀜) — 근거의 급은 여전히 실무 정본이다.
#: ⚠ 소광리 07-구조도는 **비탈 몫을 안 적는다**(설계사무소 간 차이) — 그래도 우리는 정본
#: 04.구조도(기슭막이)의 두 줄을 따른다는 것이 이 확정의 뜻이다.
BASIS_WALL = (
"법정 근거 없음 · 실무 정본 식(구조도 기슭막이 xls) — 기초 + 비탈 두 줄."
" **2026-09-09 사용자 확정 5차 1번으로 「지금 이대로」 확정**"
)
def pipe_trench_width_m(diameter_mm: float | int | None) -> float | None:
@@ -139,6 +139,11 @@ def default_settings() -> dict[str, Any]:
# 품셈 11-2·11-3 [주]④ 가 「재료량은 설계수량에 따른다」라 값을 안 주는 자리이고,
# 세는 것 자체는 확정이라 **제안값 + 고칠 수 있게**로 둔다(확정 ⑨·⑩ 과 같은 틀).
"frame_material": {},
# 임목파쇄 — **기본 꺼짐**(확정 5차 5번). 현장에 따라 켤 수 있는 칸.
# ⚠ 근주이식은 칸도 두지 않는다 — 「셀지 말지가 설계 판단」이라 켤 자리가 없으면
# 물을 일도 없다. 켜도 **부피는 지어내지 않는다**(아래 칸이 비면 줄만 서고 사유).
"wood_chipping_enabled": False,
"wood_chipping_volume_m3": None,
"dataset_versions": {},
},
"estimation": {
+11 -1
View File
@@ -42,7 +42,17 @@ export interface WallSpec {
/** 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 파이썬 `_EDGE_TOLERANCE_M`. */
const EDGE_TOLERANCE_M = 0.02;
/** 구조물 종류 → 횡단 기하가 아는 형태 이름 — 파이썬 `_FORM_BY_TYPE` 와 같은 표. */
/**
* `_FORM_BY_TYPE` .
*
* ** **(2026-09-09 4 :
* · ** ** ·
* ** ** ).
* ** · ** , ··
* ** **. .
* , .
* ** .**
*/
export const FORM_BY_TYPE: Record<string, string> = {
masonry_wet: "돌쌓기(찰)",
masonry_dry: "돌쌓기(메)",
+9
View File
@@ -686,6 +686,15 @@ export const ui_locales_b2 = {
"제근(품셈 9-21)이 이 등급으로 갈립니다 — ⚠ 본수가 아니라 축적이고, 산림조사부·영림계획에서 옮겨 적는 값입니다",
"Root removal (9-21) splits by this class — stand volume, not tree count; copied from the forest survey",
],
B08_Quantity_Side_Chipping: ["임목파쇄", "Wood Chipping"],
B08_Quantity_Chipping_Label: ["임목파쇄를 셀 것인가", "Count wood chipping"],
B08_Quantity_Chipping_Off: ["안 셈(기본)", "No (default)"],
B08_Quantity_Chipping_On: ["셈", "Yes"],
B08_Quantity_Chipping_Volume: ["파쇄 부피(㎥)", "Chipping volume (㎥)"],
B08_Quantity_Side_Chipping_Hint: [
"기본은 안 셉니다(확정 5차 5번) — 현장에 따라 필요하면 켜세요. ⚠ 켜도 부피를 넣어야 값이 섭니다(실무는 부피로 셈)",
"Off by default — turn on per site. Volume must be entered for the row to carry a quantity",
],
B08_Quantity_Side_Frame: ["규준틀 개소당 재료", "Batter Board Materials (per unit)"],
B08_Quantity_Side_Frame_Hint: [
"⚠ 채워진 값은 실무 관측값(제안값)이고 법정 기준이 아닙니다 — 품셈 11-2·11-3 [주]④ 는 「재료량은 설계수량에 따른다」로만 둡니다. 손율은 원문값(비탈 50% · 수평 80%)",