From ad1e10b3e9dd66f7b2c26236ad5110ebd3f5c651 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:00:48 +0900 Subject: [PATCH 01/11] =?UTF-8?q?docs(B08):=20=EA=B3=A8=EB=A7=89=EC=9D=B4?= =?UTF-8?q?=20=EB=91=90=EA=BB=98=EC=8B=9D=20=EC=A3=BC=EC=84=9D=20=EC=A0=95?= =?UTF-8?q?=EC=A0=95=20=E2=80=94=20=E3=80=8C0.45=E3=80=8D=EB=8A=94=20?= =?UTF-8?q?=EC=83=81=EC=88=98=EA=B0=80=20=EC=95=84=EB=8B=88=EB=9D=BC=20?= =?UTF-8?q?=EB=92=B7=EA=B8=B8=EC=9D=B4=EC=98=80=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 소광리 C26 이 `P6/100`(뒷길이 45㎝)을 읽는 것이라 골막이도 **뒷길이 기반**임. 둘은 「더하는 몫」만 다름 — 골막이 ℓ3 + 0.1H/0.4H · 기슭막이 ℓ3 + 0.30 / +0.30(H−1) - 앞서 상수처럼 적어 두었는데 그렇게 굳으면 **뒷길이를 바꿔도 골막이 값이 안 움직이는** 결함이 조용히 남음(랩탑 보조가 셀 참조로 확인해 알려 옴) Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_UnitQuantity.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 97260c1a..a267b922 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -171,10 +171,13 @@ def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, # 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것. #: ⚠⚠ **기슭막이 계열(돌쌓기 찰·메) 전용이다. 다른 구조물로 넓히지 말 것**(2026-09-09). -#: 골막이는 **두께식부터 다르다** — 소광리 숨김탭 「골막이(찰)(치수조서연결)」 C26: -#: 평균두께 = {(0.45 + 0.1×H) + (0.45 + 0.4×H)} ÷ 2 -#: 우리가 확정 ② 로 버린 옛 식과 **글자까지 같다.** 그 식은 틀린 식이 아니라 **골막이 식**이고, -#: 그것을 기슭막이에까지 쓰고 있었던 것이 어긋남의 정체였다. +#: 골막이는 **더하는 몫이 다르다** — 소광리 숨김탭 「골막이(찰)(치수조서연결)」 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당) · 정면적이 **사다리꼴**. From e1fed5f68b86c75256210a2be2b7092c01b80549 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:02:27 +0900 Subject: [PATCH 02/11] =?UTF-8?q?fix(B06/B08):=20=EC=8A=A4=EB=83=85?= =?UTF-8?q?=EB=90=9C=20=EA=B4=80=EC=9D=B4=20=EC=B8=A1=EC=A0=90=EC=97=90=20?= =?UTF-8?q?=EC=95=88=20=EB=B6=99=EC=96=B4=20=EA=B8=88=EC=95=A1=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B9=A0=EC=A7=80=EB=8D=98=20=EA=B2=83=20=E2=80=94?= =?UTF-8?q?=20=EB=B0=B0=EC=88=98=EA=B4=80=20=EB=84=B7=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠⚠ **진단이 뒤집힌 자리임.** 계획서 3-14 는 「관을 나중에 놓으면 측점이 안 생긴다」로 적혀 있었으나 실측하니 **측점은 이미 있었음** — 구조물 이름표까지 달고. 진짜 원인 — **관 자리와 측점 자리는 최대 0.5m 어긋나고 그것이 설계임.** 측점을 만들 때 정수 미터가 같은 격자 측점이 있으면 그리로 스냅함 (`B05_Profile_Engine_Sections_Core` — 횡단 파일명이 정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드). 관 440.241 은 **측점 440.0** 위에 섬. 그런데 붙이는 쪽이 **0.02m** 로만 봐서 그런 관은 어느 측점에도 안 붙었음 ⇒ 횡단도에 안 서고 길이도 안 실려 B08 이 「연장 없음」으로 막음. - `attach_culvert_sets` / `attachCulvertSets`(짝) — **가장 가까운 측점 하나**를 그 관의 자리로 봄. 거리로 자르지 않아 스냅 폭이 바뀌어도 따라가고, 하나만 고르므로 두 번 안 셈 - `pipeOwnerChainage` 신설 — 길이를 싣는 주인도 같은 규칙 - `SECTION_MATCH_TOLERANCE_M` 0.05 → 0.5 — 좁게 보면 「횡단 자체가 없습니다」라는 **거짓 사유**가 뜸(반대 방향의 거짓). 옛 판단 근거를 주석에 남기고 뒤집은 까닭도 적음 실측 — 관 9개 중 **5개만** 길이가 있던 것이 **9개 전부**로 (440·620·720·900 복구). 곁들여 - 「측점 없는 구조물 N개」 알림 + [측점 만들기] 단추(3-14 ㉯) — **진짜로 측점이 없는** 경우를 위해 남김. 판정은 스냅을 셈에 넣어 0.5m. 샘플링 조건은 확정 때 남긴 `sampling.json` → 없으면 1단계 저장값. 둘 다 없으면 막고 사유 - 등록부 `retaining_wall.form` 에 「식생옹벽블럭」 추가(다른 창 요청) Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Router_Confirm.py | 45 ++++ B05_Profile/B05_Profile_Structure_Types.json | 2 +- B06_Section/B06_Section_Engine_Culvert.py | 20 +- B06_Section/B06_Section_Router_Stations.py | 247 ++++++++++++++++++ B06_Section/B06_Section_Structure_Layouts.ts | 35 ++- .../B06_Section_UI_Missing_Stations.ts | 99 +++++++ B06_Section/B06_Section_UI_Page.ts | 4 + .../B06_Section_UI_Style_Cross_Areas.css | 26 ++ B08_Quantity/B08_Quantity_Engine_Pipe.py | 19 +- common_util/common_util_culvert_sets.ts | 19 +- main.py | 2 + 11 files changed, 506 insertions(+), 12 deletions(-) create mode 100644 B06_Section/B06_Section_Router_Stations.py create mode 100644 B06_Section/B06_Section_UI_Missing_Stations.ts diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index ac8d637e..a9ce488e 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -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( diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index a555707d..96cbbc35 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -709,7 +709,7 @@ "key": "form", "label": "형식", "input": "select", - "choices": ["중력식", "반중력식", "캔틸레버식", "부벽식"], + "choices": ["중력식", "반중력식", "캔틸레버식", "부벽식", "식생옹벽블럭"], "default": null, "required": true, "phase": "detail" diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 5f18bbd2..17f00ca6 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -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). 세트는 폭의 diff --git a/B06_Section/B06_Section_Router_Stations.py b/B06_Section/B06_Section_Router_Stations.py new file mode 100644 index 00000000..53e73e5e --- /dev/null +++ b/B06_Section/B06_Section_Router_Stations.py @@ -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, + } + ) diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts index 8a61f7b8..066718ec 100644 --- a/B06_Section/B06_Section_Structure_Layouts.ts +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -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 | null = typeof pipeLengthM === "number" && pipeLengthM > 0 diff --git a/B06_Section/B06_Section_UI_Missing_Stations.ts b/B06_Section/B06_Section_UI_Missing_Stations.ts new file mode 100644 index 00000000..8031db76 --- /dev/null +++ b/B06_Section/B06_Section_UI_Missing_Stations.ts @@ -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 { + 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, +): Promise { + 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); +} diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index a09744a8..500c100a 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -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 { 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) { diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 94de826c..4ee815e5 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -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; +} diff --git a/B08_Quantity/B08_Quantity_Engine_Pipe.py b/B08_Quantity/B08_Quantity_Engine_Pipe.py index 9261086c..64598269 100644 --- a/B08_Quantity/B08_Quantity_Engine_Pipe.py +++ b/B08_Quantity/B08_Quantity_Engine_Pipe.py @@ -45,9 +45,14 @@ NOTE_SECTION_MISSING = ( "그 측점의 횡단 자체가 없습니다 — 관은 놓였는데 횡단이 안 만들어진 자리라 " "[저장]으로는 안 풀립니다. 횡단설계에서 그 측점이 서야 합니다" ) -#: 관 자리에 횡단이 있는지 볼 때의 허용 오차. **아주 좁게** — 옆 측점을 「있다」로 세면 -#: 거짓 안내가 된다. 길이 찾기(0.5m)보다 좁은 것은 뜻이 다르기 때문이다. -SECTION_MATCH_TOLERANCE_M = 0.05 +#: 관 자리에 횡단이 있는지 볼 때의 허용 오차(m). +#: ⚠⚠ **0.05 → 0.5 로 넓혔다**(2026-09-09 실측으로 앞의 판단이 뒤집힌 자리). +#: 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로 스냅**한다(횡단 파일명이 +#: 정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드). 그래서 관 440.241 의 횡단은 +#: **측점 440.0** 이고, 그 측점은 구조물 이름표까지 달고 있다 — **없는 것이 아니다.** +#: 좁게 보면 「그 측점의 횡단 자체가 없습니다」라는 **거짓 사유**가 뜬다(반대 방향의 거짓). +#: 스냅 폭이 「정수 미터 반올림」이라 최대 어긋남이 0.5m 이고, 길이 찾기와 같은 값이 된다. +SECTION_MATCH_TOLERANCE_M = 0.5 NOTE_KIND_DEFAULT = "관종을 안 정해 기본값({kind})으로 섰습니다 — 정하면 공종이 갈립니다" NOTE_KIND_UNKNOWN = "「{kind}」은(는) 아는 관종이 아니라 공종을 못 골랐습니다" @@ -126,10 +131,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 ) diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index 165ed43c..83a726e9 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -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(); + 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`). 세트는 폭의 절반까지 옆 측점에도 붙으므로, 이것이 없으면 // 소비처가 「소유 측점」을 못 가려 **같은 시설을 여러 측점에서 센다**. diff --git a/main.py b/main.py index b3f96e6f..a3967f44 100644 --- a/main.py +++ b/main.py @@ -50,6 +50,7 @@ from B05_Profile.B05_Profile_Router_Lifecycle import router as b05_route_lifecyc from B05_Profile.B05_Profile_Router_Replan import router as b05_route_replan_router from B05_Profile.B05_Profile_Structures_Router import router as b05_structures_router from B06_Section.B06_Section_Router import router as b06_section_router +from B06_Section.B06_Section_Router_Stations import router as b06_section_stations_router from B06_Section.B06_Section_Router_Confirm import ( router as b06_section_confirm_router, ) @@ -538,6 +539,7 @@ app.include_router(b05_corridor_router, dependencies=protected_with_company) app.include_router(b05_route_replan_router, dependencies=protected_with_company) app.include_router(b05_structures_router, dependencies=protected_with_company) app.include_router(b06_section_router, dependencies=protected_with_company) +app.include_router(b06_section_stations_router, dependencies=protected_with_company) app.include_router(b06_section_confirm_router, dependencies=protected_with_company) app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company) app.include_router(b07_design_router, dependencies=protected_with_company) From 0b0763ed76081d7bbf1778b66e87010c054b01f7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:06:49 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat(B05):=20=EB=93=B1=EB=A1=9D=EB=B6=80?= =?UTF-8?q?=20=EC=B9=B8=20=EB=8B=A4=EC=84=AF=20=EB=AC=B6=EC=9D=8C=20?= =?UTF-8?q?=E2=80=94=20=EB=B6=99=EB=B0=95=EC=9D=B4=EB=A1=9C=20=EB=8F=8C?= =?UTF-8?q?=EB=8D=98=20=EC=9E=90=EB=A6=AC=EB=A5=BC=20=EC=97=B4=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 랩탑 보조가 산출식을 손에 들고 기다리던 칸들. **기본값은 안 넣음**(현장 값이라 지어내면 안 되고, 비어 있으면 사유가 뜨는 것이 맞음). - 골막이 `back_len_cm`(뒷길이 ℓ3) · `stone_kind`(돌 종류) · `spillway`(방수로) ⚠ 뒷길이 하나에 두께식·고임돌·야면석·채움콘 네 성분이 걸림 ⚠ 방수로는 지금 **늘 빼고** 있었음 — 「없음」을 고를 칸이 없었음 - 개거 `ditch_spec` — 콘크리트 개거 150×200 / L형수로 H=0.2. 비우면 실무 붙박이(개거) - 물빼기 구멍 덮어쓰기 `weep_hole_diameter_mm` · `weep_hole_area_m2` (여섯 종류) 비우면 **국가기준**(Ø50 · 2㎡당) — 사용자 확정 「국가기준 + 숫자 변경 가능하게」 ⚠ 바닥막이는 뺌 — 바닥에 눕는 시설이라 벽면이 없음 칸 이름·글자는 기슭막이·돌쌓기와 같은 벌을 씀(엔진이 읽는 이름 그대로). Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Structure_Types.json | 147 ++++++++++++++++++- 1 file changed, 146 insertions(+), 1 deletion(-) diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 96cbbc35..f60218a2 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -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", @@ -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" } ] }, @@ -1148,6 +1212,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 +1320,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 +1422,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 +1703,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" } ] }, From 691b356e2a38e6547ae902021cbec637075e024f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:08:23 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat(B08):=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=ED=99=95=EC=A0=95=205=EC=B0=A8=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=E2=80=94=20=EC=A0=9C=EA=B7=BC=20=EA=B3=84=EC=97=B4=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=81=20=EC=B6=95=20=C2=B7=20=ED=84=B0=ED=8C=8C=EA=B8=B0=20?= =?UTF-8?q?=ED=99=95=EC=A0=95=20=C2=B7=20=EB=BF=8C=EB=A6=AC=20=EC=9A=B4?= =?UTF-8?q?=EB=B0=98=20=EB=8D=A4=ED=94=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1번 비탈 터파기 「지금 이대로」 — `BASIS_WALL` 에서 「확인 후 쓸 것」을 걷고 확정 표시로 바꿈 (「법정 근거 없음」은 사실이라 그대로 둠) 6번 제근 밑수 **면적 축** — 산림품셈 9-21 에 밑수 표기가 없어 **건설품셈 3-9-2 「1,000㎡당」** 을 ⚠ **교차 참조**로 빌려 씀. 제근·뿌리 적재가 같은 면적으로 섬(㎡) ⚠ 임목축적 등급은 **단가 갈래**이지 물량 밑수가 아님 — 비어도 값은 서고 사유만 남음 4번 뿌리 운반 **덤프**(FP-10-12) — ⚠ 운반 밑수는 부피(㎥)인데 뿌리 부피를 든 곳이 없어 물량은 안 섬. 사용자 관찰(실무에서 운반품이 안 붙기도 함)도 사유에 적음 곁들여 — 준비공 인계 줄이 **값이 서는 순간 근거를 잃던 자리**를 고침(대분류 이름만 싣고 있었음). 제근이 확정으로 값이 서자 「교차 참조」 표시가 사라진 것에서 드러남 Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_Handoff_Rows.py | 10 +++- .../B08_Quantity_Engine_Preparation.py | 52 ++++++++++++------- common_util/common_util_excavation.py | 10 +++- 3 files changed, 51 insertions(+), 21 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index 52c7ec19..6967234a 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -582,7 +582,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, # ⚠ 못 서는 까닭을 그대로 넘긴다 — 받는 쪽이 「만들어야 할 것」 목록에 얹는다. diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index 52e665c7..fc1c3537 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -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", } @@ -243,34 +249,42 @@ ROOT_STEPS_NOTE = ( 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", }, ] diff --git a/common_util/common_util_excavation.py b/common_util/common_util_excavation.py index d5535eee..78f4cbf3 100644 --- a/common_util/common_util_excavation.py +++ b/common_util/common_util_excavation.py @@ -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: From 252e6b5447360991188ce3731f11be815ff969d0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:08:47 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat(B08):=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=ED=99=95=EC=A0=95=205=EC=B0=A8=20=EB=84=B7=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=E2=80=94=20=EB=A7=89=EC=9E=90=EA=B0=88=20=EC=8B=A4?= =?UTF-8?q?=EB=AC=B4=EC=8B=9D=20=C2=B7=20=EC=84=9D=EC=A0=81=20=C2=B7=20?= =?UTF-8?q?=EB=8F=8C=20=EB=AC=B4=EA=B2=8C=20=EA=B3=84=EC=82=B0=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 막자갈 (작은 것 3) - 옛 식(입적 − 몸통 − 고임돌)에서 (뒷채움 상 + 하) ÷ 2 × H 로 갈아탐. - 상·하는 벽 두께가 아니라 뒷채움 폭임. 정본 04.구조도(기슭막이).xls 여섯 탭(H=1.0~3.0 · 메/찰 · 기초유/버림) 전부 상 0.30 · 하 0.45 로 같음. 소광리는 같은 식에 0.30/0.60 을 써서 0.900 이 나옴 — 그 사실을 사유로 냄. - H=2.0 에서 정본 0.75 와 일치(옛 식은 1.66 쪽으로 튀던 자리). 석적 (작은 것 4) - 정면적 × 뒷길이 × 0.77 로 새 줄. 정본에 없고 소광리에만 있는 줄임을 사유에 적음. - 「체적」은 이미 「입적」 이름으로 내고 있어 이름만 맞음 — 새로 만들지 않음. 돌 무게 (큰 것 7) - 뒷길이 × 0.77(채움률) × 2.65(비중) 계산식이 기본. H=2.0 에서 정본 1.92 톤과 일치. - 야면석만 관측표(35→0.575 · 45→0.880 · 55→1.100) — 공극이 커 계산식이 안 맞음. - 종류를 안 고르면 정본 줄 이름 그대로 「돌」로 서고 그 사실이 사유로 뜸. - 잠정임(실무자 협의 후 명확히)을 화면 근거에 적음. 터파기 (큰 것 1) — 기초 + 비탈 그대로. 코드 변경 없음. 채집석 밑수도 새 줄을 따라감 — 석적 + 고임돌 + 막자갈. 거울 시험 신설: 정본 H=2.0 여섯 줄 일치, 일부러 다른 두 줄(입적·물구멍관)의 차이까지 못박음. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_UnitQuantity.py | 144 ++++++++++++++---- 1 file changed, 111 insertions(+), 33 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index d69923b7..86bc6673 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -86,6 +86,32 @@ 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(비중). ⚠ 잠정 — 실무자 협의 후 명확히 할 값" +) + +#: 뒷채움(막자갈) 폭 — ⭐ 사용자 확정 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. #: ⚠ 지금까지 **건설품셈 참고자료 한 벌**(돌 종류로 안 갈리는 표)로만 돌고 있었다. @@ -313,8 +339,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 +789,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 +810,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 +861,82 @@ 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 % 낮다). + # 이름은 정본 줄 이름 그대로 **「돌」**이고, 종류를 고르면 그 이름으로 바뀐다. + if kind_label in STONE_WEIGHT_OBSERVED_KINDS: + # 야면석은 공극이 커 계산식이 안 맞는다 — 원문 관측표를 그대로 쓴다. + stone_name = kind_label + stone_ton = table["stone_ton_per_m2"] + weight_basis = ( + f"돌쌓기 × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝) · {STONE_WEIGHT_BASIS}" + if stone_ton is not None + else "" + ) + weight_source = STONE_WEIGHT_SOURCE + else: + stone_name = kind_label or "돌" + stone_ton = back_m * STONE_PACKING_RATIO * STONE_DENSITY_TON_M3 + weight_basis = ( + f"돌쌓기 × {stone_ton:.3f} ton/㎡ = 뒷길이 {back_m:g} × {STONE_PACKING_RATIO}" + f" × {STONE_DENSITY_TON_M3}" + + (f" · {kind_label}" if kind_label else " · ⚠ 돌 종류를 안 골랐음") + + f" · {STONE_WEIGHT_FORMULA_BASIS}" + ) + weight_source = "" + 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) @@ -910,8 +986,11 @@ def stone_masonry( ) # 채집석 — **캐서 쓰는 구조물**의 돌 체적. 사토에서 뺄 밑수이고 **여기서 빼지 않는다.** + # ⚠ 밑수가 확정 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 +998,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} ㎥" + " · 현장 채집분 · ⚠ 여기서 빼지 않음 — 사토에서 한 번만 뺌", ) ) From 857a93f08c4f9acf7e75eee1d3c81a233bf69ed4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:12:30 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat(B08):=20=EC=A7=80=EC=9E=A5=EB=AA=A9?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=EB=A5=BC=20=EB=91=90=20=EC=A4=84=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=80=EB=A6=84=20(=ED=99=95=EC=A0=95=205=EC=B0=A8=202?= =?UTF-8?q?=EB=B2=88)=20+=20=EC=9E=91=EC=97=85=20=EA=B0=88=EB=9E=98?= =?UTF-8?q?=EB=A5=BC=20=EC=A7=80=EB=B0=98=20=EA=B0=88=EB=9E=98=EB=A1=9C=20?= =?UTF-8?q?=EC=95=88=20=EC=9D=BD=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실무(영월 1.9)가 한 면적에 두 작업을 얹음 — 뿌리뽑기 @475 · 잡관목제거 @882 가 같은 11,035㎡. ⚠ 이중계상이 아니라 서식이 그러함(사유에 적음) - 잡관목제거는 품셈에 이름이 없음(실무 D00033 별도 단가) — 공종 보류(확정 5차 3번)라 코드 없이 서고 사유가 붙음 - ⚠ 곁들여 잡은 것: `item` 칸을 **늘 지반 갈래로 읽고 있었음** — 지장목제거의 작업 갈래에 「시공법 미지정으로 공종을 못 고름」이라는 틀린 사유가 붙었음. 갈래로 읽는 공종을 GROUND_SPLIT_GROUPS 로 한정하고 나머지는 규격 칸으로 보냄 - tmp/tests/test_b08_tree_removal_split.py 신설(5건) Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_EarthworkSummary.py | 59 +++++++++++++------ .../B08_Quantity_Engine_Handoff_Mapping.py | 7 +++ .../B08_Quantity_Engine_Handoff_Rows.py | 11 +++- 3 files changed, 58 insertions(+), 19 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py index 27a46e93..143ea9d8 100644 --- a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py @@ -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) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py index d20bf41b..a99b51a3 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py @@ -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({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"}) #: 집계 합계 줄 — 내역 줄이 아니라 검산용이다. diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index 6967234a..61303b3d 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -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, # 반영률 — 곱하기는 여기가 끝이다. 받는 쪽은 적기만 한다. From 962bbfe9718662c95ecc46b18ec60af01744abe1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:16:09 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat(B08):=20=EC=9E=84=EB=AA=A9=ED=8C=8C?= =?UTF-8?q?=EC=87=84=20=EC=BC=9C=EB=8A=94=20=EC=B9=B8=20(=ED=99=95?= =?UTF-8?q?=EC=A0=95=205=EC=B0=A8=205=EB=B2=88)=20=E2=80=94=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EA=BA=BC=EC=A7=90=20=C2=B7=20=EA=B7=BC=EC=A3=BC?= =?UTF-8?q?=EC=9D=B4=EC=8B=9D=EC=9D=80=20=EC=B9=B8=20=EC=97=86=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「기본 안 셈」이되 「현장에 따라 파쇄가 적용될 필요 있음」이라 **임목파쇄만** 켤 수 있게 둠 - ⚠ 안 켠 프로젝트에는 **줄 자체가 없음** — 빈 칸이 「세야 함」으로 읽히지 않게 (부대시설은 법정 의무라 늘 서는 것과 다른 자리) - 켜도 부피는 지어내지 않음 — 넣어야 값이 서고 FP-08-11(이동식 임목 파쇄)이 붙음 - 근주이식(FP-14-02)은 **칸도 안 만듦** — 켤 자리가 없으면 물을 일도 없음 - 설정 `wood_chipping_enabled`·`wood_chipping_volume_m3` + 화면 칸 둘 - tmp/tests/test_b08_wood_chipping.py 신설(5건) Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_Preparation.py | 45 +++++++++++++++++++ B08_Quantity/B08_Quantity_Router_Earthwork.py | 7 +++ B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts | 4 ++ B08_Quantity/B08_Quantity_UI_Page.ts | 37 +++++++++++++++ common_util/common_util_project_settings.py | 5 +++ ui_template/ui_template_locale_b2.ts | 9 ++++ 6 files changed, 107 insertions(+) diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index fc1c3537..9c1a82c0 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -246,6 +246,48 @@ 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]]: @@ -588,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 = ( @@ -599,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 { diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 8fd45e00..cc53eb50 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -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", ) diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 7eb74241..95c9548b 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -83,6 +83,10 @@ export interface QuantitySettings { stand_volume_class?: string | null; /** 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다. */ frame_material?: Record; + /** 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜야 줄이 선다. */ + wood_chipping_enabled?: boolean | null; + /** 파쇄 부피(㎥) — 켜도 이 값이 없으면 줄만 서고 사유가 남는다. */ + wood_chipping_volume_m3?: number | null; } export interface EarthworkTable { diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index d710a543..3c5bc69c 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -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; + // 임목파쇄 — **기본 꺼짐**(확정 5차 5번). 켜야 줄이 선다. 근주이식은 칸 자체가 없다. + wood_chipping_enabled: boolean; + wood_chipping_volume_m3: number | null; // 자재별 관급/사급 — 표 안에서 줄마다 고른 값. material_supply: Record; 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 { 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) }, + 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), }, diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 805a72fe..28a97660 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -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": { diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 7e43adb6..553bda58 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -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%)", From 92fd9092128013a299ff09f81d56b5e77a533c5c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:16:39 +0900 Subject: [PATCH 08/11] =?UTF-8?q?feat(B08):=20=EC=83=88=20=EC=B9=B8=20?= =?UTF-8?q?=EB=8B=A4=EC=84=AF=EC=9D=84=20=EA=B3=A8=EB=A7=89=EC=9D=B4=C2=B7?= =?UTF-8?q?=EA=B0=9C=EA=B1=B0=C2=B7=EB=AC=BC=EA=B5=AC=EB=A9=8D=EC=97=90=20?= =?UTF-8?q?=EB=AC=BC=EB=A6=BC=20=E2=80=94=20=EB=B0=A9=EC=88=98=EB=A1=9C?= =?UTF-8?q?=EA=B0=80=20=EC=A0=95=EB=A9=B4=EC=A0=81=EC=9D=84=20=EA=B0=80?= =?UTF-8?q?=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 골막이 (erosion_check) - spillway 「있음/없음」 — 「없음」이면 파형강관 0.5024㎡ 를 안 뺌. 정면적이 밑수라 일곱 줄이 +2.1~3.1 % 움직임. 「있음」이 정본과 같은 값(정본은 방수로 치수 0 인 개소에서도 뺐음). 안 고르면 정본 쪽으로 서고 사유가 뜸. - stone_kind · back_len_cm 를 읽음. 「칸이 없다」던 사유가 「안 골랐다」로 바뀜. 돌 무게 헬퍼 한 벌 (stone_weight_per_m2) - 확정 5차 큰 것 7 규칙을 돌쌓기·골막이가 같이 씀. 계산식이 기본, 야면석만 관측표. 두 벌로 두면 같은 확정이 구조물마다 갈림. 물구멍 헬퍼 한 벌 (weep_hole_spec) - weep_hole_diameter_mm · weep_hole_area_m2 를 읽고 비우면 Ø50 · 2㎡당. 지름은 규격(spec)으로 실어 단가가 갈리게 함. - 정본이 개소당 관 길이를 평균두께(0.83m)로 잡는 것을 주석에 남김 — 우리 상수 0.5m 와 갈리는 자리이고 미결임. 개거 — ditch_spec 칸이 와서 두 규격을 고를 수 있음. 시험 — 등록부 정책 예외에 새 칸 넷(ditch_spec · weep_hole 둘 · fill_slope_ratio)과 사토장 둘을 넣음. 613 통과. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_UnitQuantity.py | 98 +++++++++++++------ ..._Quantity_Engine_UnitQuantity_Revetment.py | 58 ++++++----- 2 files changed, 103 insertions(+), 53 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 87e08afa..b89f9d6e 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -99,6 +99,55 @@ 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(입적)으로 튄다 — 옛 식이 곧 그 자리였다. @@ -874,35 +923,18 @@ def stone_masonry( ) ) - # 돌 무게 — ⭐ 확정 5차 큰 것 7. **계산식이 기본**이고 야면석만 관측표다. + # 돌 무게 — ⭐ 확정 5차 큰 것 7. **계산식이 기본**이고 야면석만 관측표다(헬퍼 한 벌). # ⚠ 안 고른 경우도 **계산식**으로 선다 — 정본 여섯 탭이 종류를 안 적고 그 식을 쓰고, # 그래야 정본 H=2.0 의 「1.92 톤」과 맞는다(관측표 0.88 로 서면 1.84 로 4 % 낮다). - # 이름은 정본 줄 이름 그대로 **「돌」**이고, 종류를 고르면 그 이름으로 바뀐다. - if kind_label in STONE_WEIGHT_OBSERVED_KINDS: - # 야면석은 공극이 커 계산식이 안 맞는다 — 원문 관측표를 그대로 쓴다. - stone_name = kind_label - stone_ton = table["stone_ton_per_m2"] - weight_basis = ( - f"돌쌓기 × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝) · {STONE_WEIGHT_BASIS}" - if stone_ton is not None - else "" + 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)으로 섰습니다 — " + "야면석이면 계산식이 안 맞아 관측표로 갈립니다" ) - weight_source = STONE_WEIGHT_SOURCE - else: - stone_name = kind_label or "돌" - stone_ton = back_m * STONE_PACKING_RATIO * STONE_DENSITY_TON_M3 - weight_basis = ( - f"돌쌓기 × {stone_ton:.3f} ton/㎡ = 뒷길이 {back_m:g} × {STONE_PACKING_RATIO}" - f" × {STONE_DENSITY_TON_M3}" - + (f" · {kind_label}" if kind_label else " · ⚠ 돌 종류를 안 골랐음") - + f" · {STONE_WEIGHT_FORMULA_BASIS}" - ) - weight_source = "" - if not kind_label: - notes.append( - "돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 — " - "야면석이면 계산식이 안 맞아 관측표로 갈립니다" - ) if stone_ton is None: # 원본 표가 비어 있는 칸이다 — 지어내지 않고 알린다(PLAN 8-8 ㉮). notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 {stone_name}을 내지 못함") @@ -973,16 +1005,20 @@ 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}", ) ) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py index 8a0fdf5c..c8e16357 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py @@ -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( "⚠ 이 표에는 **콘크리트 본체 줄이 없습니다** — 정본 원문 그대로입니다" From 8c883564599734715550748d63d30f9224563205 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:17:03 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat(B05/B06):=20=ED=9D=99=EB=A7=89?= =?UTF-8?q?=EC=9D=B4=EB=A5=BC=20=EA=B8=B0=EC=8A=AD=EB=A7=89=EC=9D=B4?= =?UTF-8?q?=EC=99=80=20=EA=B0=99=EC=9D=80=20=EB=B2=8C=EB=A1=9C=20=E2=80=94?= =?UTF-8?q?=20=ED=98=95=EC=83=81=C2=B7=EC=98=B5=EC=85=98=20=EB=8F=99?= =?UTF-8?q?=EC=9D=BC,=20=EC=88=98=EB=9F=89=EC=9D=80=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정 4차 원문 — 「흙막이는 횡단도에서 **표현방식들과 옵션들은 동일하게** 반영하면 될거야. **형상은 동일해.** 물론 **데이터는 분리하여 계산**되어야함.」 - 그리기 경로는 **이미 같음** — C군 벽은 `attachWallSpecs` 로 `section.revetment` 에 붙어 기슭막이와 같은 기하를 탐(따로 그리지 않음). 형태 이름도 「찰/콘크리트」 글자로 갈려 흙막이 선택지(돌(찰)·돌(메)·떼…)가 그대로 먹힘 - 빠져 있던 것은 **옵션 칸**이라 기슭막이 열두 칸을 **같은 글자 그대로** 옮김 — 단 수 · 올림 · 이동 · 뒷길이 · 돌 종류 · 조달 · 야면석 계수 · 채움 강도 · 전면 기울기 · 기초 · 물빼기 구멍 둘 ⚠ 흙막이 수량이 안 쓰는 칸도 **숨기지 않음**(사용자가 「동일하게」라 함) — 수량 쪽에서 조용히 무시되면 됨 - ⚠ 코드에 **「형상 동일 ≠ 수량 동일」**을 못 박음. 원단위·공종코드·성분 줄은 흙막이 자기 것. 반대 사례가 골막이임(그림도 수량도 다르고 두께식마저 다른 식) — **계열이 다르면 상수를 나눠 쓸 것** Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Structure_Types.json | 107 +++++++++++++++++++ common_util/common_util_structure_walls.ts | 12 ++- 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index f60218a2..1788819a 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -1094,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" } ] }, diff --git a/common_util/common_util_structure_walls.ts b/common_util/common_util_structure_walls.ts index 5cd7c2e8..d6bb4f40 100644 --- a/common_util/common_util_structure_walls.ts +++ b/common_util/common_util_structure_walls.ts @@ -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 = { masonry_wet: "돌쌓기(찰)", masonry_dry: "돌쌓기(메)", From 60e7e137bb58edae5df3afa869ec51c97d2b2765 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:19:04 +0900 Subject: [PATCH 10/11] =?UTF-8?q?feat(B07):=20=ED=9A=A1=EB=8B=A8=EB=8F=84?= =?UTF-8?q?=20=ED=95=98=EB=8B=A8=ED=91=9C=EC=97=90=20=EC=82=AC=ED=86=A0?= =?UTF-8?q?=EC=9E=A5=20=EC=A4=84=20=E2=80=94=20=E3=80=8C=EC=8C=93=EA=B8=B0?= =?UTF-8?q?=E3=80=8D=EC=99=80=20=EA=B0=88=EB=9D=BC=20=EB=B3=B4=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실무 원본 횡단도에는 그 자리가 **아예 없음**(400dpi 실측, 다른 창 확인) — 납품 양식을 늘리는 것이 아니라 **안 쓰던 빈 줄을 쓰는 것**임. - 우측 그룹의 빈 줄 자리에 「사토장」 신설 (`spoil_fill`) - 값은 B06 이 낸 `spoil_fill_area_m2` 를 그대로 옮김 — 여기서 더하거나 빼지 않음 (`fill_area_m2` 에서 이미 빠져 있음 · 확정 ㉠ 같은 흙을 두 번 세지 않기) - 사토장이 없는 측점은 값이 안 실려 종전처럼 빈칸 Co-Authored-By: Claude Opus 5 (1M context) --- B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py | 8 +++++++- .../B07_DesignDetail_Engine_Cross_Quantity.py | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py index d0f1fce7..c8e605c5 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py @@ -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", ) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Quantity.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Quantity.py index c51b7a3c..0604ec3f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Quantity.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Quantity.py @@ -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` 를 사람이 읽는 말로. 도면·화면이 「왜 이렇게 갈렸나」를 보일 때 쓴다. From db4acbf1acc338cfc3cc3bace15380a0212df1dd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 12:32:43 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix(B08):=20=EC=B1=84=EC=9B=80=EC=BD=98?= =?UTF-8?q?=ED=81=AC=EB=A6=AC=ED=8A=B8=20=EA=B7=BC=EA=B1=B0=EC=97=90=20?= =?UTF-8?q?=EB=8F=8C=20=EC=A2=85=EB=A5=98=20=ED=91=9C=EC=8B=9C=20+=20?= =?UTF-8?q?=EA=B3=A8=EB=A7=89=EC=9D=B4=20=EC=A3=BC=EC=84=9D=20=EC=A4=84?= =?UTF-8?q?=EB=B0=94=EA=BF=88=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 채움콘크리트 계수가 돌 종류로 갈리는데(깬돌 0.2 · 야면석 0.15) 근거 문구에 종류가 안 적히던 자리 — 값은 바뀌는데 왜 바뀌었는지가 화면에 안 보였음. 고임돌은 이미 적고 있었으므로 같은 규칙으로 맞춤. - 병합 해결 과정에서 골막이 두께식 주석 두 줄이 한 줄로 붙은 것 복구. Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_UnitQuantity.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index b89f9d6e..edf519cb 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -252,7 +252,8 @@ def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, #: ⚠⚠ **둘 다 뒷길이(ℓ3) 기반이다** — C26 의 「0.45」는 상수가 아니라 `P6/100`(뒷길이 45㎝)을 #: 읽은 값이었다(2026-09-09 랩탑 보조가 셀 참조로 확인). 앞서 여기 「0.45 + 0.1H」로 적어 #: **상수처럼 보이게** 두었는데, 그렇게 굳으면 **뒷길이를 바꿔도 골막이 값이 안 움직이는** -#: 결함이 조용히 남는다.#: ⚠ 상수만 보고 골막이에 가져다 쓰면 **값이 나오므로 아무 시험도 안 잡는다.** +#: 결함이 조용히 남는다. +#: ⚠ 상수만 보고 골막이에 가져다 쓰면 **값이 나오므로 아무 시험도 안 잡는다.** #: ⚠ 골막이는 그 밖에도 셋이 더 다르다 — 밑수가 **돌쌓기 + 돌붙임**(기슭막이는 돌쌓기만) · #: 단위가 **개소당**(기슭막이는 m당) · 정면적이 **사다리꼴**. STONE_MASONRY = { @@ -979,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}", ) )