From dd617547055dd8e7d7cb8e59b3019408d777c82e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 14:59:31 +0900 Subject: [PATCH] =?UTF-8?q?refactor(B06):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EB=A9=B4=EC=A0=81=C2=B7=EC=9C=A0=ED=86=A0=EA=B3=A1=EC=84=A0?= =?UTF-8?q?=EC=9D=84=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EC=9C=BC=EB=A1=9C=20=EB=90=98=EB=8F=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정(2026-09-06): 조작 중과 [저장]·[확정]의 계산은 브라우저 몫이고, 서버는 초기값을 만들 때만 같은 코드를 Node 로 돌린다. - 면적 산출을 B06_Section_Structure_Layouts 로 빼 Node 진입점과 브라우저가 같은 한 벌을 쓰게 함(structureAreaRows / applyStructureAreaRows). - [저장]·[확정]이 카드를 그리지 않은 측점까지 면적을 계산해 cross_patch 로 보냄. 유토곡선도 그 위에서 쌓음. - 서버는 저장 때 Node 를 돌리지 않음 — 포장 구간·세월교 노면 하강 보정만 남기고, 그 보정은 편집분을 얹기 전에 돌게 순서를 바꿈. 검증: tsc --noEmit 통과, pytest 387 passed, Node 진입점 스모크 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Api_Types.ts | 6 ++ B06_Section/B06_Section_Router_Confirm.py | 25 ++++--- B06_Section/B06_Section_Schema.py | 7 ++ B06_Section/B06_Section_Server_Calc_Node.ts | 53 ++------------ .../B06_Section_Server_Calc_Prebuild.py | 39 +++++++--- B06_Section/B06_Section_Structure_Layouts.ts | 73 +++++++++++++++++++ B06_Section/B06_Section_UI_Page_Persist.ts | 27 ++++++- 7 files changed, 164 insertions(+), 66 deletions(-) diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 8529efac..5f2e5150 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -504,4 +504,10 @@ export interface CrossSectionPatch { /** 연동 해제(측점별)·종단경사 반영(전체 공통) — 2026-08-24 사용자. */ revet_link_detached?: boolean; revet_follow_grade?: boolean; + /** 구조물이 선 측점의 폐회로 절·성토 면적(㎡) — 브라우저가 계산해 보낸다 + * (2026-09-06 사용자 확정). 구조물이 없는 측점에는 싣지 않는다. */ + cut_area_m2?: number; + fill_area_m2?: number; + cut_soil_area_m2?: number; + cut_rock_area_m2?: number; } diff --git a/B06_Section/B06_Section_Router_Confirm.py b/B06_Section/B06_Section_Router_Confirm.py index 0fd21357..62153fcb 100644 --- a/B06_Section/B06_Section_Router_Confirm.py +++ b/B06_Section/B06_Section_Router_Confirm.py @@ -140,6 +140,11 @@ async def _apply_section_edits( patch["revet_link_detached"] = patch_item.revet_link_detached if patch_item.revet_follow_grade is not None: patch["revet_follow_grade"] = patch_item.revet_follow_grade + # 구조물 폐회로 면적 — 브라우저가 계산해 보낸 값을 그대로 정본에 얹는다. + for area_key in ("cut_area_m2", "fill_area_m2", "cut_soil_area_m2", "cut_rock_area_m2"): + value = getattr(patch_item, area_key) + if value is not None: + patch[area_key] = value if patch: await merge_cross_section_design_patch( connection, route_id=route_id, chainage_m=patch_item.chainage_m, patch=patch @@ -147,20 +152,19 @@ async def _apply_section_edits( @router.post("/{project_id}/sections/{route_id}/save", response_model=SectionConfirmResponse) -async def _recompute_server_side(project_id: UUID, route_id: int) -> None: - """구조물 면적·유토곡선을 서버가 다시 계산해 정본에 얹는다(2026-09-06). +async def _enforce_stored_designs(project_id: UUID, route_id: int) -> None: + """저장분의 포장 구간·세월교 노면 하강을 바로잡는다 — 편집분을 얹기 **전에** 돈다. - 브라우저가 보낸 값을 그대로 받아 적지 않는다 — 정본은 서버가 만든다 - (CLAUDE.md 5장 「계산 자리」). 사용자 조작(patch)이 먼저 들어간 **뒤**에 돌아야 - 바뀐 벽 위치가 면적·유토곡선에 실린다. 실패는 비치명적이다. + 구조물 면적·유토곡선은 브라우저가 만들어 보낸다(2026-09-06 사용자 확정). 여기서는 + 카드를 한 번도 안 그린 측점의 보정만 서버가 챙긴다. 실패는 비치명적이다. """ try: - from B06_Section.B06_Section_Server_Calc_Prebuild import recompute_server_side + from B06_Section.B06_Section_Server_Calc_Prebuild import enforce_stored_designs - await recompute_server_side(project_id, route_id) + await enforce_stored_designs(project_id, route_id) except Exception: logger.exception( - "횡단 서버 재계산 실패 (저장은 유지): project_id=%s route_id=%s", + "저장분 설계 보정 실패 (저장은 유지): project_id=%s route_id=%s", project_id, route_id, ) @@ -209,6 +213,7 @@ async def save_sections( request.standard_cross_section if request else None, ) + await _enforce_stored_designs(project_id, route_id) async with pool.acquire() as connection: await connection.begin() try: @@ -219,7 +224,6 @@ async def save_sections( except Exception: await connection.rollback() raise - await _recompute_server_side(project_id, route_id) return SectionConfirmResponse( project_id=str(project_id), route_id=route_id, confirmed=False ) @@ -283,6 +287,7 @@ async def confirm_sections( request.standard_cross_section if request else None, ) + await _enforce_stored_designs(project_id, route_id) async with pool.acquire() as connection: await connection.begin() try: @@ -304,8 +309,6 @@ async def confirm_sections( await connection.rollback() raise - await _recompute_server_side(project_id, route_id) - # 측구 방향(design.ditch_side) 변경을 B05 종단 정본 stations.uphill_side에 역반영한다(E-7). # 파일 기반·비치명적: 실패해도 확정은 유지한다. try: diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index f4d80979..4ee56495 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -153,6 +153,13 @@ class CrossSectionPatch(BaseModel): # 기슭막이 한 벌 전체 공통이라 소유 측점에만 실린다. revet_link_detached: bool | None = None revet_follow_grade: bool | None = None + # 구조물이 선 측점의 폐회로 절·성토 면적(㎡) — 브라우저가 계산해 보낸다 + # (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」). 서버는 초기값을 만들 때만 + # 같은 코드를 Node 로 돌린다(`B06_Section_Server_Calc_Node.ts`). + cut_area_m2: float | None = Field(default=None, ge=0) + fill_area_m2: float | None = Field(default=None, ge=0) + cut_soil_area_m2: float | None = Field(default=None, ge=0) + cut_rock_area_m2: float | None = Field(default=None, ge=0) class SectionConfirmRequest(BaseModel): diff --git a/B06_Section/B06_Section_Server_Calc_Node.ts b/B06_Section/B06_Section_Server_Calc_Node.ts index 6503b045..177c053e 100644 --- a/B06_Section/B06_Section_Server_Calc_Node.ts +++ b/B06_Section/B06_Section_Server_Calc_Node.ts @@ -20,11 +20,10 @@ * ========================================================================== */ import { readFileSync, writeFileSync } from "node:fs"; -import { computeStructureAreas } from "@util/common_util_cross_structure_areas"; import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul"; import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; -import { computeStoredLayouts, trimOfLayouts } from "./B06_Section_Structure_Layouts"; +import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; interface ServerCalcInput { detail: SectionDetailResponse; @@ -44,54 +43,18 @@ if (!inputPath || !outputPath) { const input = JSON.parse(readFileSync(inputPath, "utf8")) as ServerCalcInput; const sections: CrossSection[] = input.detail?.cross_sections ?? []; -/** 화면(`B06_Section_UI_Cross_View.applyStructureAreas`)과 같은 입력을 만든다. */ -function areasOf(section: CrossSection): Record | null { - const layouts = computeStoredLayouts(section, sections); - if (!layouts) return null; - const trim = trimOfLayouts(layouts); - const design = layouts.design; - if (!trim || !Array.isArray(design.design_line) || design.design_line.length < 2) return null; - const ground = section.samples - .filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number") - .map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number })) - .sort((a, b) => a.offset - b.offset); - const areas = computeStructureAreas({ - designLine: design.design_line as Array<{ offset_m: number; elevation_m: number }>, - ground, - trim, - rockBoundaryOffsetM: - typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null, - }); - if (!areas) return null; - const round = (value: number): number => Number(value.toFixed(4)); - const out: Record = { - chainage_m: section.chainage_m, - cut_area_m2: round(areas.cutAreaM2), - fill_area_m2: round(areas.fillAreaM2), - }; - // 토사·암 분리는 원래 값이 있을 때만 덮는다 — 화면 규칙과 같다. - if (typeof design.cut_soil_area_m2 === "number") { - out.cut_soil_area_m2 = round(areas.cutSoilAreaM2); - out.cut_rock_area_m2 = round(areas.cutRockAreaM2); - } - return out; -} - -const areas = sections.map(areasOf).filter((entry): entry is Record => !!entry); +const areas = structureAreaRows(sections); // 보정값을 **자리에서** 얹는다 — 유토곡선이 고쳐진 면적 위에서 쌓이게 한다. -areas.forEach((row) => { - const section = sections.find((item) => item.chainage_m === row.chainage_m); - const design = section?.design; - if (!design) return; - for (const key of ["cut_area_m2", "fill_area_m2", "cut_soil_area_m2", "cut_rock_area_m2"]) { - if (typeof row[key] === "number") (design as Record)[key] = row[key]; - } -}); +applyStructureAreaRows(sections, areas); // 유토곡선 — balloon 위치는 사용자 화면값이라 서버가 만들지 않는다(파이썬이 보존). const conversion = input.context?.earthwork_conversion; const result = conversion - ? computeMassHaul(sections, conversion, input.context?.natural_spoil_min_ground_slope ?? undefined) + ? computeMassHaul( + sections, + conversion, + input.context?.natural_spoil_min_ground_slope ?? undefined, + ) : null; const massHaul = result ? massHaulPayload(result, computeHaulPlan(result, input.context?.haul_equipment_limits)) diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index b73556e4..98af86fd 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -5,8 +5,9 @@ 끊겨 지반선과 설계선이 이루는 폐회로가 달라진다. ② 그 면적을 쌓아 만드는 유토곡선. -왜 — 둘 다 화면에서만 돌아, 사용자가 B06 을 한 번도 안 열면 값이 없고 저장·확정 뒤에는 -구조물을 모르는 표준값으로 되돌아갔다(CLAUDE.md 5장 「계산 자리」 — 금지 항목이던 자리). +왜 — 사용자가 B06 을 한 번도 안 열어도 **초기값**에는 이 값이 있어야 한다. +여기(Node 실행)는 **초기값 산출 전용**이다 — 사용자가 화면을 만지는 동안과 [저장]·[확정] +때의 계산은 브라우저 몫이다(2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」). **계산을 다시 짜지 않는다.** 화면이 쓰는 TS 를 Node 진입점(`B06_Section_Server_Calc_Node.ts`) 으로 감싸 그대로 돌린다. 파이썬으로 포팅하면 같은 기하가 두 벌이 되어 「그림은 이런데 @@ -81,8 +82,22 @@ def _enforce_stored_designs( enforce_ford_surface_drops(longitudinal, sections, project_root, standard) +async def enforce_stored_designs(project_id: UUID | str, route_id: int) -> int: + """저장분 설계의 포장 구간·세월교 노면 하강만 바로잡는다(Node 실행 없음). + + [저장]·[확정]이 부른다. 구조물 면적·유토곡선은 브라우저가 만들어 보내므로 여기서 + 다시 만들지 않는다(2026-09-06 사용자 확정). 카드를 한 번도 안 그린 측점의 포장· + 세월교 보정만 서버가 챙긴다. + """ + return await _recompute(project_id, route_id, run_node=False) + + async def recompute_server_side(project_id: UUID | str, route_id: int) -> int: - """구조물 면적·유토곡선을 다시 계산해 정본에 얹는다. 고친 측점 수를 돌려준다.""" + """초기값 산출 — 위 보정에 더해 구조물 면적·유토곡선까지 Node 로 만들어 저장한다.""" + return await _recompute(project_id, route_id, run_node=True) + + +async def _recompute(project_id: UUID | str, route_id: int, *, run_node: bool) -> int: from B06_Section.B06_Section_Router import get_section_detail project_uuid = UUID(str(project_id)) @@ -113,16 +128,22 @@ async def recompute_server_side(project_id: UUID | str, route_id: int) -> int: if json.dumps(item.get("design"), sort_keys=True, default=str) != before[index] ] - output = await asyncio.to_thread( - run_bundle_json, - BUNDLE, - _NPM_SCRIPT, - {"detail": detail, "context": _mass_haul_context()}, + output = ( + await asyncio.to_thread( + run_bundle_json, + BUNDLE, + _NPM_SCRIPT, + {"detail": detail, "context": _mass_haul_context()}, + ) + if run_node + else {} ) if not isinstance(output, dict): - return 0 + output = {} rows = output.get("areas") mass_haul = output.get("mass_haul") + if not fixed and not rows and not mass_haul: + return 0 updated = 0 async with pool.acquire() as connection: diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts index 8c45d119..ec4b2613 100644 --- a/B06_Section/B06_Section_Structure_Layouts.ts +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -10,6 +10,7 @@ * DOM·SVG 를 부르지 않는다. 브라우저에서도 Node 에서도 같은 결과가 나와야 한다. * ========================================================================== */ +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 { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; @@ -105,3 +106,75 @@ export function trimOfLayouts(layouts: StoredLayouts) { layouts.own?.designTrim ); } + +/** 정본에 얹는 면적 키 — 이 넷만 오간다. */ +export const STRUCTURE_AREA_KEYS = [ + "cut_area_m2", + "fill_area_m2", + "cut_soil_area_m2", + "cut_rock_area_m2", +] as const; + +/** 측점 하나의 폐회로 면적 — 구조물이 없으면 null(표준 계산값이 이미 맞다). */ +function areaRowOf( + section: CrossSection, + sections: readonly CrossSection[], +): Record | null { + const layouts = computeStoredLayouts(section, sections); + if (!layouts) return null; + const trim = trimOfLayouts(layouts); + const design = layouts.design; + if (!trim || !Array.isArray(design.design_line) || design.design_line.length < 2) return null; + const ground = section.samples + .filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number") + .map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number })) + .sort((a, b) => a.offset - b.offset); + const areas = computeStructureAreas({ + designLine: design.design_line as Array<{ offset_m: number; elevation_m: number }>, + ground, + trim, + rockBoundaryOffsetM: + typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null, + }); + if (!areas) return null; + const round = (value: number): number => Number(value.toFixed(4)); + const row: Record = { + chainage_m: section.chainage_m, + cut_area_m2: round(areas.cutAreaM2), + fill_area_m2: round(areas.fillAreaM2), + }; + // 토사·암 분리는 원래 값이 있을 때만 덮는다 — 화면 규칙과 같다. + if (typeof design.cut_soil_area_m2 === "number") { + row.cut_soil_area_m2 = round(areas.cutSoilAreaM2); + row.cut_rock_area_m2 = round(areas.cutRockAreaM2); + } + return row; +} + +/** + * 구조물이 선 측점의 절·성토 면적 — **카드를 그리지 않은 측점까지** 전부 낸다. + * 화면 그리기(`applyStructureAreas`)와 같은 계산이며, [저장]·[확정]과 초기값 산출 + * (Node 진입점)이 이 한 벌을 함께 쓴다. + */ +export function structureAreaRows( + sections: readonly CrossSection[], +): Array> { + return sections + .map((section) => areaRowOf(section, sections)) + .filter((row): row is Record => !!row); +} + +/** 낸 면적을 측점 자료에 도로 얹는다 — 유토곡선이 고쳐진 면적 위에서 쌓이도록. */ +export function applyStructureAreaRows( + sections: readonly CrossSection[], + rows: ReadonlyArray>, +): void { + for (const row of rows) { + const design = sections.find((item) => item.chainage_m === row.chainage_m)?.design as + Record | undefined; + if (!design) continue; + for (const key of STRUCTURE_AREA_KEYS) { + if (typeof row[key] === "number") design[key] = row[key]; + } + } +} diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 785c2a84..70f6f178 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -19,6 +19,11 @@ import { import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch"; import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; +import { + applyStructureAreaRows, + STRUCTURE_AREA_KEYS, + structureAreaRows, +} from "./B06_Section_Structure_Layouts"; import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul"; @@ -167,8 +172,28 @@ export function collectSectionEdits(ctx: SectionPersistContext): { massHaul: Record | undefined; } { const crossPatches = buildCrossPatches(ctx.patchSources()); - // 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 저장 시점에만 영구 저장된다. const detail = ctx.detail(); + // 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산해 싣는다 + // (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」). 카드 그리기가 고쳐 둔 값에 + // 기대면 화면에 안 뜬 측점이 표준값으로 남는다. + if (detail) { + const areaRows = structureAreaRows(detail.cross_sections); + // 유토곡선이 **고쳐진 면적 위에서** 쌓이도록 캐시에도 얹는다(Node 진입점과 같은 순서). + applyStructureAreaRows(detail.cross_sections, areaRows); + const byChainage = new Map(crossPatches.map((patch) => [patch.chainage_m, patch])); + for (const row of areaRows) { + let patch = byChainage.get(row.chainage_m); + if (!patch) { + patch = { chainage_m: row.chainage_m }; + byChainage.set(row.chainage_m, patch); + crossPatches.push(patch); + } + for (const key of STRUCTURE_AREA_KEYS) { + if (typeof row[key] === "number") patch[key] = row[key]; + } + } + } + // 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 저장 시점에만 영구 저장된다. const context = ctx.context(); const result = detail && context?.earthwork_conversion