From ec94a1fca73002d0c50351662b834acc23f91ef8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 07:53:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(B08):=20=ED=91=9C=ED=86=A0=20=EC=9A=B4?= =?UTF-8?q?=EB=B0=98=C2=B7=EC=A0=81=EC=B9=98=20=EC=A4=84=20=EC=8B=A0?= =?UTF-8?q?=EC=84=A4=20+=20=EB=B6=80=EB=8C=80=EC=8B=9C=EC=84=A4=C2=B7?= =?UTF-8?q?=ED=91=9C=ED=86=A0=EA=B1=B0=EB=A6=AC=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=B9=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 표토 운반(법이 요구하는데 제거만 세고 있던 자리) - 시행규칙 별표2 「표토는 전량 제거한 후 … 최고 홍수위보다 높은 장소로 운반하고 쌓아두어야 한다」 — 제거(9-15)만 세면 운반이 빠짐 - 물량은 제거 물량 그대로(다시 안 셈), 거리는 설계 입력 `topsoil_haul_distance_m` - 거리가 없으면 막고 사유 · 제거가 안 서면 운반도 안 섬(밑수가 그 줄) · 법 문구를 사유에 화면 칸(사용자 지시 「대신 페이지에 남길 것」) - 부대시설 개소 다섯(국가지점번호판·안내판·차단기·가설창고·수방자재) — 서버가 받고 있었는데 넣을 칸이 없던 자리. 산식으로 만들지 않는다는 사유를 칸 밑에 적음 - 표토 운반거리 칸 + 별표2 근거 한 줄 - tmp/tests/test_b08_topsoil_haul.py 신설(5건) Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_Preparation.py | 70 ++++++++++++++++++- B08_Quantity/B08_Quantity_Router_Earthwork.py | 5 ++ B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts | 4 ++ B08_Quantity/B08_Quantity_UI_Page.ts | 49 +++++++++++++ common_util/common_util_project_settings.py | 4 ++ ui_template/ui_template_locale_b2.ts | 15 ++++ 6 files changed, 145 insertions(+), 2 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index 0973ab9f..83b42fde 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -98,12 +98,14 @@ def preparation_rows( slope_totals: dict[str, float] | None = None, slope_rows: Iterable[dict[str, Any]] = (), topsoil_thickness_m: float | None = None, + topsoil_haul_distance_m: float | None = None, ) -> list[dict[str, Any]]: """준비공 줄 — 값이 서는 것과 안 서는 것을 **한 목록에** 낸다.""" slope = slope_totals or {} tree_area = float(slope.get("tree_removal_fill", 0.0)) + float( slope.get("tree_removal_cut", 0.0) ) + topsoil = _topsoil_row(slope, topsoil_thickness_m) return [ { "group": "준비공", @@ -120,7 +122,9 @@ def preparation_rows( ), "work_item_code": None, }, - _topsoil_row(slope, topsoil_thickness_m), + topsoil, + # ⚠ 법이 요구하는 **운반·적치** — 제거 물량이 곧 밑수다(별표2). + _topsoil_haul_row(topsoil, topsoil_haul_distance_m), { "group": "준비공", "item": "제근·뿌리다듬기", @@ -169,6 +173,67 @@ def _topsoil_row(slope: dict[str, float], thickness_m: float | None) -> dict[str } +#: 표토 운반·적치 — ⚠ **법이 요구하는데 우리가 제거만 세고 있던 자리**(2026-09-09). +#: 시행규칙 별표2 Ⅰ.2.차.(6)·Ⅰ.3.카.(6): +#: 「노면·절토대상지에 있는 입목…과 그 뿌리, **표토는 전량 제거한 후** 강우 시 유실되거나 +#: 경관에 저해되지 않도록 **최고 홍수위보다 높은 장소로 운반하고 쌓아두어야 한다**」 +#: ⇒ 제거(9-15)만 세면 **운반이 빠진다.** 물량은 제거 물량 그대로이고 **거리가 설계 입력**이다. +#: ⚠ 거리를 지어내지 않는다 — 비면 막고 사유를 낸다. +TOPSOIL_HAUL_LAW = ( + "⚠ 법정 의무 — 시행규칙 별표2 「표토는 전량 제거한 후 … 최고 홍수위보다 높은 장소로" + " 운반하고 쌓아두어야 한다」. 제거만 세면 운반이 빠짐" +) + + +def _topsoil_haul_row(topsoil: dict[str, Any], distance_m: float | None) -> dict[str, Any]: + """표토 운반 — 물량은 제거 물량 그대로, 거리는 설계 입력. + + ⚠ 제거 줄이 안 서면(두께 미입력) 운반도 안 선다 — 밑수가 그 줄이기 때문이다. + """ + amount = topsoil.get("amount") + distance = None + if distance_m is not None: + try: + distance = float(distance_m) + except (TypeError, ValueError): + distance = None + if amount is None: + return { + "group": "준비공", + "item": "표토 운반·적치", + "unit": "㎥", + "amount": None, + "status": STATUS_PENDING, + "reason": f"{TOPSOIL_HAUL_LAW} · 제거 물량이 아직 안 서서 운반도 못 셈(두께 먼저)", + "work_item_code": None, + } + if distance is None or distance <= 0: + return { + "group": "준비공", + "item": "표토 운반·적치", + "unit": "㎥", + "amount": None, + "status": STATUS_PENDING, + "reason": ( + f"{TOPSOIL_HAUL_LAW} · 운반거리가 아직 입력되지 않았습니다 — 「최고 홍수위보다" + f" 높은 장소」는 현장에서 정하는 자리라 품셈이 거리를 주지 않습니다" + f" (운반할 물량 {float(amount):,.2f}㎥)" + ), + "reference_amount": float(amount), + "work_item_code": None, + } + return { + "group": "준비공", + "item": "표토 운반·적치", + "unit": "㎥", + "amount": float(amount), + "status": STATUS_READY, + "reason": f"{TOPSOIL_HAUL_LAW} · 제거 물량 그대로 · 운반거리 {distance:g}m(설계 입력)", + "work_item_code": "FP-10-12", + "haul_distance_m": distance, + } + + def _batter_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]: """비탈 규준틀 한 줄. **개소는 원문 기준으로 서고 재료는 미확보**다.""" count, notes = batter_frame_count(slope_rows) @@ -351,10 +416,11 @@ def build_table( topsoil_thickness_m: float | None = None, names: dict[str, str] | None = None, ancillary_counts: dict[str, Any] | None = None, + topsoil_haul_distance_m: float | None = None, ) -> dict[str, Any]: """화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**""" rows = ( - preparation_rows(slope_totals, slope_rows, topsoil_thickness_m) + preparation_rows(slope_totals, slope_rows, topsoil_thickness_m, topsoil_haul_distance_m) + erosion_rows(structures, names) + ancillary_rows(ancillary_counts) ) diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index f928aa37..9879da54 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -138,6 +138,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: {type_id: definition.name for type_id, definition in structure_type_map().items()}, # 부대시설 개소 — 산식으로 만들지 않고 **설계자가 넣은 값**만 쓴다(확정 ⑬). settings.get("ancillary_counts") or {}, + # 표토 운반거리 — 별표2 가 요구하는 운반·적치의 밑수(거리는 현장값). + settings.get("topsoil_haul_distance_m"), ) method, method_is_default = concrete_placing_method(settings) # ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다. @@ -316,6 +318,8 @@ class QuantitySettingsBody(BaseModel): rubble_base_thickness_m: float | None = None # 구조물터파기 용수 유무 — "육상"·"용수". ⚠ 기본 육상은 **통상값**이지 사용자 확정이 아니다. structure_trench_water: str | None = None + # 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다. + topsoil_haul_distance_m: float | None = None #: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**. @@ -325,6 +329,7 @@ NULLABLE_SETTING_KEYS = ( "bench_cut_depth_m", "spoil_site_distance_m", "rubble_base_thickness_m", + "topsoil_haul_distance_m", ) diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 49a8b023..09f24d24 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -75,6 +75,10 @@ export interface QuantitySettings { spoil_site_distance_m?: number | null; /** 구조물터파기 용수 — `"육상"`·`"용수"`. ⚠ 「육상」은 통상값이지 사용자 확정이 아니다. */ structure_trench_water?: string | null; + /** 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다. */ + topsoil_haul_distance_m?: number | null; + /** 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13). */ + ancillary_counts?: Record; } export interface EarthworkTable { diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index a86d81f4..39193713 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -91,6 +91,9 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr rubble_base_thickness_m: draft.rubble_base_thickness_m, spoil_site_distance_m: draft.spoil_site_distance_m, structure_trench_water: draft.structure_trench_water, + topsoil_haul_distance_m: draft.topsoil_haul_distance_m, + // 개소는 **통째로** 보낸다 — 지운 항목까지 그대로 가야 되돌릴 길이 있다. + ancillary_counts: draft.ancillary_counts, }), }, ); @@ -110,6 +113,15 @@ function field(label: string, value: string): HTMLElement { return row; } +/** 부대시설 항목 키 — 서버 `ANCILLARY_ITEMS` 와 **같은 차례·같은 낱말**이라야 한다. */ +const ANCILLARY_KEYS = [ + "national_point_sign", + "guide_sign", + "gate", + "site_container", + "flood_supplies", +] as const; + /** 칸 밑에 붙는 **근거 한 줄** — 왜 그 값인지 화면에서 보이게 한다(2026-09-09 사용자 지시). */ function hintRow(text: string): HTMLElement { const row = document.createElement("p"); @@ -249,6 +261,10 @@ interface DraftSettings { spoil_site_distance_m: number | null; // 구조물터파기 용수 — "육상"·"용수". ⚠ 「육상」은 **통상값**이지 사용자 확정이 아니다. structure_trench_water: string; + // 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. `null` 은 「안 정함」. + topsoil_haul_distance_m: number | null; + // 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13). + ancillary_counts: Record; // 자재별 관급/사급 — 표 안에서 줄마다 고른 값. material_supply: Record; dirty: boolean; @@ -486,6 +502,35 @@ function buildQuantitySidePanel( ), ); panel.append(hintRow(L("B08_Quantity_Side_Water_Hint"))); + panel.append( + optionalNumberField( + L("B08_Quantity_Side_TopsoilHaul_Label"), + draft.topsoil_haul_distance_m, + "10", + (value) => { + draft.topsoil_haul_distance_m = value; + draft.dirty = true; + }, + ), + ); + panel.append(hintRow(L("B08_Quantity_Side_TopsoilHaul_Hint"))); + + // ── 부대시설 개소 — ⚠ **산식으로 만들지 않는다**(확정 13). 넣어야 줄이 선다 ── + panel.append(field(L("B08_Quantity_Side_Ancillary"), "")); + for (const key of ANCILLARY_KEYS) { + panel.append( + optionalNumberField( + L(`B08_Quantity_Ancillary_${key}` as keyof typeof ui_locales), + draft.ancillary_counts[key] ?? null, + "1", + (value) => { + draft.ancillary_counts[key] = value; + draft.dirty = true; + }, + ), + ); + } + panel.append(hintRow(L("B08_Quantity_Side_Ancillary_Hint"))); // ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ── panel.append(field(L("B08_Quantity_Side_Placing"), "")); @@ -742,6 +787,10 @@ export async function renderB08Quantity(root: HTMLElement): Promise { rubble_base_thickness_m: (stored.rubble_base_thickness_m as number | null) ?? null, spoil_site_distance_m: (stored.spoil_site_distance_m as number | null) ?? null, structure_trench_water: (stored.structure_trench_water as string) ?? "", + topsoil_haul_distance_m: (stored.topsoil_haul_distance_m as number | null) ?? null, + ancillary_counts: { + ...((stored.ancillary_counts ?? {}) as Record), + }, material_supply: { ...((stored.material_supply ?? {}) as Record) }, dirty: false, }; diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 880b2537..2a0f6499 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -127,6 +127,10 @@ def default_settings() -> dict[str, Any]: # ⚠ **사용자 확정이 아니라 통상값**이다(확정 3차 ④). 화면·사유에 그 사실을 # 적어 두고 사용자가 뒤집을 수 있게 둔다. "structure_trench_water": "육상", + # 표토 운반거리(m) — ⚠ 별표2 Ⅰ.2.차.(6) 「표토는 전량 제거한 후 … 최고 홍수위보다 + # 높은 장소로 **운반하고 쌓아두어야** 한다」. 제거만 세면 법이 요구하는 운반이 빠진다. + # 거리는 현장값이라 품셈이 정하지 않는다 — 비면 운반 줄이 막힌 채로 선다. + "topsoil_haul_distance_m": None, "dataset_versions": {}, }, "estimation": { diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 0f8e0e8e..fa400c21 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -674,6 +674,21 @@ export const ui_locales_b2 = { "⚠ 「육상」은 통상값이고 사용자 확정이 아닙니다(확정 3차 ④) — 품셈 9-13 의 18구분이 이 값으로 갈립니다", "⚠ “Dry” is a customary default, not a user decision — it selects one of the 18 sub-items", ], + B08_Quantity_Side_Ancillary: ["부대시설 개소", "Ancillary Facilities"], + B08_Quantity_Side_Ancillary_Hint: [ + "개소를 넣어야 줄이 섭니다 — 원문이 배치 간격을 정하지 않아 연장÷500 같은 산식을 쓰지 않습니다(확정 13)", + "Counts are design input — the source sets no spacing rule, so no formula is used", + ], + B08_Quantity_Ancillary_national_point_sign: ["국가지점번호판(개소)", "National point signs"], + B08_Quantity_Ancillary_guide_sign: ["임도 안내판(개소)", "Guide signs"], + B08_Quantity_Ancillary_gate: ["차단기(개소)", "Gates"], + B08_Quantity_Ancillary_site_container: ["가설창고(개소)", "Site containers"], + B08_Quantity_Ancillary_flood_supplies: ["수방대책 자재(식)", "Flood supplies (set)"], + B08_Quantity_Side_TopsoilHaul_Label: ["표토 운반거리(m)", "Topsoil haul distance (m)"], + B08_Quantity_Side_TopsoilHaul_Hint: [ + "⚠ 별표2 는 표토를 「전량 제거한 후 최고 홍수위보다 높은 장소로 운반하고 쌓아두어야」로 정합니다 — 거리를 넣으면 운반 줄이 섭니다", + "The law requires hauling and stockpiling removed topsoil — enter the distance to raise that row", + ], B08_Quantity_Side_Placing: ["콘크리트 타설", "Concrete Placing"], B08_Quantity_Side_Placing_Label: ["타설 방식", "Method"], B08_Quantity_Placing_Unset: ["안 정함(기본값 사용)", "Not set (default)"],