feat(b06,b08): 다단 추가 기슭막이 수량 — 서버 재계산이 선 다단 벽 목록(design.extra_walls)을 남기고 B08 이 줄을 세움 · 높이 안 적은 단은 미확정(④)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-14 13:37:15 +09:00
co-authored by Claude Opus 5
parent 6b7c093e7e
commit 8a17d8308b
6 changed files with 282 additions and 10 deletions
+12 -3
View File
@@ -14,8 +14,10 @@
* 실행: node <번들> <입력.json> <출력.json> * 실행: node <번들> <입력.json> <출력.json>
* 입력 { detail: 종횡단 상세(API와 같은 꼴), context: { earthwork_conversion, * 입력 { detail: 종횡단 상세(API와 같은 꼴), context: { earthwork_conversion,
* natural_spoil_min_ground_slope, haul_equipment_limits } } * 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 는 **구조물 트림이 있는 측점만**. 나머지는 표준 계산값이 이미 맞다. * — areas 는 **구조물 트림이 있는 측점만**. 나머지는 표준 계산값이 이미 맞다.
* — extra_walls 는 관 주인 측점마다(빈 목록 포함) · B08 이 다단 기슭막이 줄을 세움(④).
* 끝 코드: 0 성공 / 2 인자 오류 * 끝 코드: 0 성공 / 2 인자 오류
* ========================================================================== */ * ========================================================================== */
@@ -23,7 +25,11 @@ import { readFileSync, writeFileSync } from "node:fs";
import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul"; import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul";
import { computeHaulPlan, haulPlanPayload } from "@util/common_util_mass_haul_balance"; import { computeHaulPlan, haulPlanPayload } from "@util/common_util_mass_haul_balance";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; 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 { interface ServerCalcInput {
detail?: SectionDetailResponse; detail?: SectionDetailResponse;
@@ -112,4 +118,7 @@ const massHaul = result
? massHaulPayload(result, plan ? { haul_plan: haulPlanPayload(plan) } : null) ? massHaulPayload(result, plan ? { haul_plan: haulPlanPayload(plan) } : null)
: 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 }));
@@ -245,7 +245,13 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
output = {} output = {}
rows = output.get("areas") rows = output.get("areas")
mass_haul = output.get("mass_haul") 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 return 0
updated = 0 updated = 0
@@ -281,6 +287,10 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
updated = await merge_cross_section_designs( updated = await merge_cross_section_designs(
connection, route_id=route_id, entries=area_entries, replace=False 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): if isinstance(mass_haul, dict):
await merge_longitudinal_section_data( await merge_longitudinal_section_data(
connection, route_id=route_id, data_patch={"mass_haul": mass_haul} connection, route_id=route_id, data_patch={"mass_haul": mass_haul}
+63 -2
View File
@@ -13,9 +13,14 @@
import { computeStructureAreas } from "@util/common_util_cross_structure_areas"; import { computeStructureAreas } from "@util/common_util_cross_structure_areas";
import type { CrossSection } from "./B06_Section_Api_Fetch"; import type { CrossSection } from "./B06_Section_Api_Fetch";
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom"; 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 { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import type { WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
import { computeCardCulvert, culvertLinkFor } from "./B06_Section_UI_Cross_Culvert_Wire"; import {
computeCardCulvert,
culvertLinkFor,
tierSpanOf,
} from "./B06_Section_UI_Cross_Culvert_Wire";
import type { import type {
ExtraWallControl, ExtraWallControl,
InletStructureControl, InletStructureControl,
@@ -219,6 +224,62 @@ export function structureAreaRows(
.filter((row): row is Record<string, number> => !!row); .filter((row): row is Record<string, number> => !!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( export function applyStructureAreaRows(
sections: readonly CrossSection[], sections: readonly CrossSection[],
+75 -1
View File
@@ -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 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). """계곡 통과 시설(`pipe_points.json`) → 원단위 전개가 읽는 구조물 줄 (A1, 2026-09-14).
⚠ 관 자체는 안 냄 — `build_rows` 가 관 연장으로 셈. ⚠ 관 자체는 안 냄 — `build_rows` 가 관 연장으로 셈.
⚠ 다단 추가 기슭막이는 `extra_walls`(측점 → 선 벽 목록, `extra_walls_from_designs`)로 줄을 세움.
⚠ 안 적힌 벽 칸은 **등록부 기본값** — 그 사실을 줄 사유로 붙이고 `unconfirmed` 로 금액 합에서 뺌 ⚠ 안 적힌 벽 칸은 **등록부 기본값** — 그 사실을 줄 사유로 붙이고 `unconfirmed` 로 금액 합에서 뺌
(2026-09-14 브레인 판정 「줄은 서되 금액은 실제 값이 있을 때만」). (2026-09-14 브레인 판정 「줄은 서되 금액은 실제 값이 있을 때만」).
⚠ 집수정·기슭막이 터파기·되메우기는 전개 성분(`destination: earthwork`)이라 토공집계로만 감. ⚠ 집수정·기슭막이 터파기·되메우기는 전개 성분(`destination: earthwork`)이라 토공집계로만 감.
@@ -409,4 +445,42 @@ def facility_structures(points: list[dict[str, Any]]) -> list[dict[str, Any]]:
unconfirmed=" · ".join(reasons), unconfirmed=" · ".join(reasons),
) )
rows.append(row) 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 return rows
@@ -44,6 +44,7 @@ def project_structure_sheets(
project_root: str, project_root: str,
section_modes: dict[float, str] | None, section_modes: dict[float, str] | None,
ground_types: dict[float, str] | None = None, ground_types: dict[float, str] | None = None,
designs: list[dict[str, Any]] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""구조물도 장 목록 — **원단위 탭(`material-summary`)과 같은 입력**으로 전개해 접는다. """구조물도 장 목록 — **원단위 탭(`material-summary`)과 같은 입력**으로 전개해 접는다.
@@ -60,7 +61,8 @@ def project_structure_sheets(
from B08_Quantity.B08_Quantity_Router_Material import _collect_structures from B08_Quantity.B08_Quantity_Router_Material import _collect_structures
from common_util.common_util_project_settings import quantity_settings 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) settings = quantity_settings(project_root)
templates = project_templates(project_root) templates = project_templates(project_root)
unit_table = build_unit_table( 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`)를 그대로 씀. 단면유형·지반 갈래는 원단위 탭 창구(`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) modes = await _section_modes(project_id)
ground = await _ground_types(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: def _not_found() -> JSONResponse:
+115
View File
@@ -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