From 8a17d8308bfa8cc8038c79bd75ef13eebe5193fa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 13:37:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(b06,b08):=20=EB=8B=A4=EB=8B=A8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EA=B8=B0=EC=8A=AD=EB=A7=89=EC=9D=B4=20=EC=88=98?= =?UTF-8?q?=EB=9F=89=20=E2=80=94=20=EC=84=9C=EB=B2=84=20=EC=9E=AC=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EC=9D=B4=20=EC=84=A0=20=EB=8B=A4=EB=8B=A8=20=EB=B2=BD?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D(design.extra=5Fwalls)=EC=9D=84=20?= =?UTF-8?q?=EB=82=A8=EA=B8=B0=EA=B3=A0=20B08=20=EC=9D=B4=20=EC=A4=84?= =?UTF-8?q?=EC=9D=84=20=EC=84=B8=EC=9B=80=20=C2=B7=20=EB=86=92=EC=9D=B4=20?= =?UTF-8?q?=EC=95=88=20=EC=A0=81=EC=9D=80=20=EB=8B=A8=EC=9D=80=20=EB=AF=B8?= =?UTF-8?q?=ED=99=95=EC=A0=95(=E2=91=A3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B06_Section/B06_Section_Server_Calc_Node.ts | 15 ++- .../B06_Section_Server_Calc_Prebuild.py | 12 +- B06_Section/B06_Section_Structure_Layouts.ts | 65 +++++++++- B08_Quantity/B08_Quantity_Engine_Pipe.py | 76 +++++++++++- .../B08_Quantity_Router_StructureSheet.py | 9 +- resources/tester/test_b08_extra_walls.py | 115 ++++++++++++++++++ 6 files changed, 282 insertions(+), 10 deletions(-) create mode 100644 resources/tester/test_b08_extra_walls.py diff --git a/B06_Section/B06_Section_Server_Calc_Node.ts b/B06_Section/B06_Section_Server_Calc_Node.ts index 99099d7c..4fa13fdb 100644 --- a/B06_Section/B06_Section_Server_Calc_Node.ts +++ b/B06_Section/B06_Section_Server_Calc_Node.ts @@ -14,8 +14,10 @@ * 실행: node <번들> <입력.json> <출력.json> * 입력 { detail: 종횡단 상세(API와 같은 꼴), context: { earthwork_conversion, * natural_spoil_min_ground_slope, haul_equipment_limits } } - * 출력 { areas: [{ chainage_m, cut_area_m2, … }], mass_haul: {…} | null } + * 출력 { areas: [{ chainage_m, cut_area_m2, … }], mass_haul: {…} | null, + * extra_walls: [{ chainage_m, extra_walls: [선 다단 벽…] }] } * — areas 는 **구조물 트림이 있는 측점만**. 나머지는 표준 계산값이 이미 맞다. + * — extra_walls 는 관 주인 측점마다(빈 목록 포함) · B08 이 다단 기슭막이 줄을 세움(④). * 끝 코드: 0 성공 / 2 인자 오류 * ========================================================================== */ @@ -23,7 +25,11 @@ import { readFileSync, writeFileSync } from "node:fs"; import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul"; import { computeHaulPlan, haulPlanPayload } from "@util/common_util_mass_haul_balance"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; -import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; +import { + applyStructureAreaRows, + extraWallRows, + structureAreaRows, +} from "./B06_Section_Structure_Layouts"; interface ServerCalcInput { detail?: SectionDetailResponse; @@ -112,4 +118,7 @@ const massHaul = result ? massHaulPayload(result, plan ? { haul_plan: haulPlanPayload(plan) } : null) : null; -writeFileSync(outputPath, JSON.stringify({ areas, mass_haul: massHaul })); +// 선 다단 벽 목록(④) — 관 연장처럼 기하가 세운 결과를 정본에 남겨 B08 이 줄을 세움. +const extraWalls = extraWallRows(sections); + +writeFileSync(outputPath, JSON.stringify({ areas, mass_haul: massHaul, extra_walls: extraWalls })); diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index f87df21b..02c08f8e 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -245,7 +245,13 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int: output = {} rows = output.get("areas") mass_haul = output.get("mass_haul") - if not fixed and not rows and not mass_haul: + # 선 다단 벽 목록(④) — 목록째 얹음(수가 아니라 `_AREA_KEYS` 로는 못 거름). 주인 측점엔 빈 목록도. + extra_walls = [ + (float(row["chainage_m"]), {"extra_walls": row["extra_walls"]}) + for row in output.get("extra_walls") or [] + if isinstance(row, dict) and isinstance(row.get("extra_walls"), list) + ] + if not fixed and not rows and not mass_haul and not extra_walls: return 0 updated = 0 @@ -281,6 +287,10 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int: updated = await merge_cross_section_designs( connection, route_id=route_id, entries=area_entries, replace=False ) + if extra_walls: + await merge_cross_section_designs( + connection, route_id=route_id, entries=extra_walls, replace=False + ) if isinstance(mass_haul, dict): await merge_longitudinal_section_data( connection, route_id=route_id, data_patch={"mass_haul": mass_haul} diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts index 066718ec..faa6c802 100644 --- a/B06_Section/B06_Section_Structure_Layouts.ts +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -13,9 +13,14 @@ import { computeStructureAreas } from "@util/common_util_cross_structure_areas"; import type { CrossSection } from "./B06_Section_Api_Fetch"; import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom"; +import { REVET_EMBED_DEPTH_M } from "./B06_Section_UI_Cross_Culvert_Const"; import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; -import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; -import { computeCardCulvert, culvertLinkFor } from "./B06_Section_UI_Cross_Culvert_Wire"; +import type { WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types"; +import { + computeCardCulvert, + culvertLinkFor, + tierSpanOf, +} from "./B06_Section_UI_Cross_Culvert_Wire"; import type { ExtraWallControl, InletStructureControl, @@ -219,6 +224,62 @@ export function structureAreaRows( .filter((row): row is Record => !!row); } +/** 선 다단 벽 한 매 — B08 이 줄을 세우는 값(파이썬 `B08_Quantity_Engine_Pipe.facility_structures`). */ +export interface BuiltExtraWall { + key: string; + side: "outlet" | "basin"; + form: string; + /** 순수 높이(m) — 바닥~상단(근입 0.5 포함). 지형에 맞춰 선 값. */ + height_m: number; + /** 사용자가 높이·형태를 적었나 — 안 적었으면 B08 이 미확정으로 셈. */ + height_set: boolean; + form_set: boolean; + before_m: number; + after_m: number; +} + +/** + * 관(·독립 기슭막이) 주인 측점마다 **실제로 선** 다단 벽 목록(④, 2026-09-14). + * + * 단 수·높이는 지형이 정해 요청보다 적게 설 수 있음 — 그래서 요청 칸(`extra_wall_counts`)이 아니라 + * 기하가 세운 결과를 남김(관 연장 `pipe_length_m` 과 같은 길). 주인 측점에는 빈 목록도 실어 옛 값을 지움. + */ +export function extraWallRows( + sections: readonly CrossSection[], +): Array<{ chainage_m: number; extra_walls: BuiltExtraWall[] }> { + const rows: Array<{ chainage_m: number; extra_walls: BuiltExtraWall[] }> = []; + for (const section of sections) { + if (!section.culvert) continue; + const owner = pipeOwnerChainage(section, sections); + if (owner !== null && Math.abs(owner - section.chainage_m) > CHAINAGE_TOLERANCE_M) continue; + const culvert = computeStoredLayouts(section, sections)?.culvert; + if (!culvert) continue; + const walls: BuiltExtraWall[] = []; + const built: Array<["outlet" | "basin", WallLayout[], WallAdjust[]]> = [ + ["outlet", culvert.extraWalls, culvert.revetShift.extras], + ["basin", culvert.basinExtras, culvert.revetShift.basinExtras], + ]; + for (const [side, list, applied] of built) { + list.forEach((wall, index) => { + const key = `${side === "basin" ? "bextra" : "extra"}${index}`; + const span = tierSpanOf(section, key); + walls.push({ + key, + side, + form: wall.form ?? "", + height_m: Number((wall.height + REVET_EMBED_DEPTH_M).toFixed(3)), + height_set: applied[index]?.h != null, + form_set: section.design?.revet_adjust?.[key]?.m != null, + before_m: span.beforeM, + after_m: span.afterM, + }); + }); + } + rows.push({ chainage_m: section.chainage_m, extra_walls: walls }); + } + return rows; +} + /** 낸 면적을 측점 자료에 도로 얹는다 — 유토곡선이 고쳐진 면적 위에서 쌓이도록. */ export function applyStructureAreaRows( sections: readonly CrossSection[], diff --git a/B08_Quantity/B08_Quantity_Engine_Pipe.py b/B08_Quantity/B08_Quantity_Engine_Pipe.py index f9f8e95a..c83f9c7c 100644 --- a/B08_Quantity/B08_Quantity_Engine_Pipe.py +++ b/B08_Quantity/B08_Quantity_Engine_Pipe.py @@ -315,10 +315,46 @@ def _pipe_diameter_m(options: dict[str, Any], defaults: dict[str, Any]) -> float return _num_or_zero(raw) / 1000.0 or 1.0 -def facility_structures(points: list[dict[str, Any]]) -> list[dict[str, Any]]: +#: 다단 추가 기슭막이(④, 2026-09-14) — 횡단 설계가 정본이고 **실제로 선** 단 수·높이는 지형이 정함. +#: 서버 Node 재계산(`B06_Section_Structure_Layouts.extraWallRows`)이 소유 측점 `design.extra_walls` 에 +#: 「선 다단 벽 목록」을 남기고 여기서 읽음(관 연장 `pipe_length_m` 과 같은 길). +EXTRA_WALLS_KEY = "extra_walls" +EXTRA_SIDE_LABELS = {"outlet": "유출측", "basin": "유입측"} +#: 다단이 딸린 기준벽 — 유출측 다단은 유출 벽, 유입측(집수정 계류측) 다단은 유입 벽. +EXTRA_SIDE_ROLES = {"outlet": "outlet", "basin": "inlet"} +UNCONFIRMED_EXTRA_HEIGHT = ( + "다단 높이를 안 적음 — 기본 1.5m 를 지반에 맞춰 그린 {height}m 로 섰음 · 적으면 금액이 섬" +) +UNCONFIRMED_EXTRA_FORM = "다단 형태를 안 정해 기준벽의 기본 형태 「{form}」로 섰음" + + +def extra_walls_from_designs(designs: list[dict[str, Any]] | None) -> dict[float, list[dict]]: + """측점별 「선 다단 벽 목록」 — 목록이 있는 측점만(빈 목록도 담음 — 「다단 없음」).""" + found: dict[float, list[dict]] = {} + for row in designs or []: + design = row.get("design") if isinstance(row, dict) else None + walls = design.get(EXTRA_WALLS_KEY) if isinstance(design, dict) else None + if isinstance(walls, list): + found[round(float(row.get("chainage_m") or 0.0), 3)] = walls + return found + + +def _extra_walls_at(extra_walls: dict[float, list[dict]] | None, chainage: float) -> list[dict]: + """관 자리의 주인 측점 목록 — 스냅 때문에 최대 0.5m 어긋남(`SECTION_MATCH_TOLERANCE_M`).""" + if not extra_walls: + return [] + best = min(extra_walls, key=lambda value: abs(value - chainage)) + return extra_walls[best] if abs(best - chainage) <= SECTION_MATCH_TOLERANCE_M else [] + + +def facility_structures( + points: list[dict[str, Any]], + extra_walls: dict[float, list[dict]] | None = None, +) -> list[dict[str, Any]]: """계곡 통과 시설(`pipe_points.json`) → 원단위 전개가 읽는 구조물 줄 (A1, 2026-09-14). ⚠ 관 자체는 안 냄 — `build_rows` 가 관 연장으로 셈. + ⚠ 다단 추가 기슭막이는 `extra_walls`(측점 → 선 벽 목록, `extra_walls_from_designs`)로 줄을 세움. ⚠ 안 적힌 벽 칸은 **등록부 기본값** — 그 사실을 줄 사유로 붙이고 `unconfirmed` 로 금액 합에서 뺌 (2026-09-14 브레인 판정 「줄은 서되 금액은 실제 값이 있을 때만」). ⚠ 집수정·기슭막이 터파기·되메우기는 전개 성분(`destination: earthwork`)이라 토공집계로만 감. @@ -409,4 +445,42 @@ def facility_structures(points: list[dict[str, Any]]) -> list[dict[str, Any]]: unconfirmed=" · ".join(reasons), ) rows.append(row) + # 다단 — 기준벽의 기초·(독립이면) 제원 칸을 물려받고, 형태·높이·구간은 선 벽 목록 값. + parents = {role: (values, filled) for role, _label, values, _notes, _w, filled in walls} + counts = {"outlet": 0, "basin": 0} + for tier in _extra_walls_at(extra_walls, chainage): + side = "basin" if str(tier.get("side")) == "basin" else "outlet" + counts[side] += 1 + parent_values, parent_filled = parents.get( + EXTRA_SIDE_ROLES[side], next(iter(parents.values()), ({}, [])) + ) + height = _num_or_zero(tier.get("height_m")) + form = str(tier.get("form") or parent_values.get("form") or "") + before, after = _num_or_zero(tier.get("before_m")), _num_or_zero(tier.get("after_m")) + reasons = [] + if not tier.get("height_set"): + reasons.append(UNCONFIRMED_EXTRA_HEIGHT.format(height=f"{height:g}")) + if not tier.get("form_set") and any(str(f).startswith("형태") for f in parent_filled): + reasons.append(UNCONFIRMED_EXTRA_FORM.format(form=form)) + label = f"{EXTRA_SIDE_LABELS[side]} 다단 기슭막이 {counts[side]}단" + row = child_row(base, "revetment", label, str(tier.get("key") or label)) + row.update( + start_m=chainage - before, + end_m=chainage + after, + options={ + **(kept if legacy else {}), + "form": form, + "height_m": height, + "length_m": before + after, + "before_m": before, + "after_m": after, + "foundation": foundation, + }, + notes=[ + f"ⓘ 횡단도에 선 다단 벽 — 높이 {height:g}m · 형태 {form} · 전 {before:g}/후 {after:g}m" + ], + withheld=False, + unconfirmed=" · ".join(reasons), + ) + rows.append(row) return rows diff --git a/B08_Quantity/B08_Quantity_Router_StructureSheet.py b/B08_Quantity/B08_Quantity_Router_StructureSheet.py index d47daa53..a6b3cbed 100644 --- a/B08_Quantity/B08_Quantity_Router_StructureSheet.py +++ b/B08_Quantity/B08_Quantity_Router_StructureSheet.py @@ -44,6 +44,7 @@ def project_structure_sheets( project_root: str, section_modes: dict[float, str] | None, ground_types: dict[float, str] | None = None, + designs: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: """구조물도 장 목록 — **원단위 탭(`material-summary`)과 같은 입력**으로 전개해 접는다. @@ -60,7 +61,8 @@ def project_structure_sheets( from B08_Quantity.B08_Quantity_Router_Material import _collect_structures from common_util.common_util_project_settings import quantity_settings - structures, names, skipped = _collect_structures(project_root) + # 설계를 넘겨야 다단 추가 기슭막이 장이 원단위 탭과 같이 섬(④). + structures, names, skipped = _collect_structures(project_root, designs) settings = quantity_settings(project_root) templates = project_templates(project_root) unit_table = build_unit_table( @@ -87,11 +89,12 @@ async def _sheets_of(project_id: UUID, project_root: str) -> dict[str, Any]: 단면유형·지반 갈래는 원단위 탭 창구(`B08_Quantity_Router_Material`)를 그대로 씀. """ - from B08_Quantity.B08_Quantity_Router_Material import _ground_types, _section_modes + from B08_Quantity.B08_Quantity_Router_Material import _designs, _ground_types, _section_modes modes = await _section_modes(project_id) ground = await _ground_types(project_id) - return await asyncio.to_thread(project_structure_sheets, project_root, modes, ground) + designs = await _designs(project_id) + return await asyncio.to_thread(project_structure_sheets, project_root, modes, ground, designs) def _not_found() -> JSONResponse: diff --git a/resources/tester/test_b08_extra_walls.py b/resources/tester/test_b08_extra_walls.py new file mode 100644 index 00000000..fa4b5c76 --- /dev/null +++ b/resources/tester/test_b08_extra_walls.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +"""다단 추가 기슭막이도 수량이 섬 — ④ (2026-09-14 브레인 판정 「수량이 통째로 빠지는 것」). + +앞서 B06 횡단도는 다단 벽(`design.extra_wall_counts` · `revet_adjust.extra*` · `extra_spans`)을 +그리고 면적에도 넣었는데, B08 전개는 관 유입·유출 기준벽만 셈 → 다단 벽 돌쌓기·터파기가 한 줄도 안 섬. + +길 — 실제로 **선** 단 수·높이는 지형이 정하므로(요청보다 적게 설 수 있음) 서버 Node 재계산이 +관 연장처럼 「선 다단 벽 목록」(`design.extra_walls`)을 소유 측점에 남기고, B08 이 그 목록으로 줄을 세움. +값이 없는 다단(높이를 안 적어 기본 1.5m 로 지반에 맞춰 그린 단)은 **미확정·금액 밖** 규칙 그대로. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Pipe import ( # noqa: E402 + extra_walls_from_designs, + facility_structures, +) +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 + +LAYOUTS = (ROOT / "B06_Section" / "B06_Section_Structure_Layouts.ts").read_text(encoding="utf-8") +NODE = (ROOT / "B06_Section" / "B06_Section_Server_Calc_Node.ts").read_text(encoding="utf-8") +PREBUILD = (ROOT / "B06_Section" / "B06_Section_Server_Calc_Prebuild.py").read_text( + encoding="utf-8" +) + +WALLS = [ + { + "key": "extra0", + "side": "outlet", + "form": "돌쌓기(메)", + "height_m": 1.8, + "height_set": True, + "form_set": True, + "before_m": 4.0, + "after_m": 4.0, + }, + { + "key": "extra1", + "side": "outlet", + "form": "돌쌓기(메)", + "height_m": 1.3, + "height_set": False, + "form_set": False, + "before_m": 5.0, + "after_m": 5.0, + }, +] +POINT = { + "chainage_m": 85.05, + "options": { + "pipe_diameter_mm": 1000, + "outlet_revet_form": "돌쌓기(메)", + "outlet_revet_height_m": 2.0, + "outlet_revet_length_m": 10, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_height_m": 2.0, + "inlet_revet_length_m": 10, + "revet_foundation": "기초유", + }, +} + + +def _tiers(rows: list[dict]) -> list[dict]: + return [row for row in rows if "다단" in str(row.get("attachment_label") or "")] + + +def test_설계에서_다단_목록을_측점별로_읽는다() -> None: + designs = [ + {"chainage_m": 85.0, "design": {"extra_walls": WALLS}}, + {"chainage_m": 140.0, "design": {"cut_area_m2": 1.0}}, + ] + assert extra_walls_from_designs(designs) == {85.0: WALLS} + + +def test_선_다단_벽마다_기슭막이_줄이_선다() -> None: + rows = facility_structures([POINT], {85.0: WALLS}) + tiers = _tiers(rows) + assert [row["attachment_label"] for row in tiers] == [ + "유출측 다단 기슭막이 1단", + "유출측 다단 기슭막이 2단", + ] + first, second = tiers + assert first["type_id"] == "revetment" + assert first["options"]["height_m"] == 1.8 and first["options"]["form"] == "돌쌓기(메)" + assert (first["start_m"], first["end_m"]) == (85.05 - 4.0, 85.05 + 4.0) + assert first["options"]["foundation"] == "기초유" # 기준벽과 같은 기초 + assert first["unconfirmed"] == "" + # 높이를 안 적은 단 — 줄은 서되 미확정 + assert "다단 높이를 안 적음" in second["unconfirmed"] + + +def test_다단_줄이_원단위와_금액_합에_든다() -> None: + table = build_table(facility_structures([POINT], {85.0: WALLS}), {"revetment": "기슭막이"}, {}) + tiers = [s for s in table["structures"] if "다단" in s["name"]] + assert len(tiers) == 2 + assert tiers[0]["components"] and not tiers[0]["unconfirmed"] + assert tiers[1]["unconfirmed"] + + +def test_목록이_없으면_종전과_같다() -> None: + assert not _tiers(facility_structures([POINT])) + assert not _tiers(facility_structures([POINT], {})) + + +def test_서버_재계산이_선_다단_목록을_남긴다() -> None: + assert "export function extraWallRows" in LAYOUTS + assert "extraWallRows(sections)" in NODE and "extra_walls" in NODE + assert '"extra_walls"' in PREBUILD