refactor(B05/B06): 유토곡선 일원화 — 보는 노선·횡단 재계산 창구·낡음 판정 통합

같은 데이터를 두 화면에 보여 주는 기능인데 세 곳이 갈려 값이 달랐다(실측: 절토(자연)
B06 3,704.6㎥ ↔ B05 4,526.8㎥).

① 보는 노선 — B05는 최신 경로, B06은 최신 **확정** 경로를 열어 노선을 다시 탐색한
   프로젝트에서 서로 다른 노선을 봤다(route 126 DRAFT ↔ 125 CONFIRMED, 같은 20m
   측점 성토 4.82㎡ ↔ 85.9㎡). `get_workflow_route_context()`(최신 경로) 신설해 B06
   화면 context가 그것을 쓴다. 납품 도면(B07)이 쓰는 확정 전용 창구는 그대로 둔다.

② 횡단 재계산 — 호출이 두 벌이라 인자가 갈렸다(B06만 표준 단면값·암 경계 오프셋 전달,
   보존하는 사용자 부속값도 2개 ↔ 7개). `B06_Section_Cross_Refresh.refreshCrossDesigns()`
   한 창구로 모으고 세션 편집값은 저장소에서 직접 읽어 패널 없는 B05도 같은 값을 보낸다.

③ 낡음 판정 — 「옛 암 2단계 필드 누락」 조건이 B06 페이지에만 있어 B05는 재계산을
   건너뛰었다. 공용 `staleDesignChainages()` 안으로 옮겨 두 화면이 같은 시점에 같은
   조치를 한다.

검증 — 공용 브라우저 실측: 두 화면 모두 route 126, 요약줄 문자열 완전 일치
(`절토(자연) 4,526.8㎥ · 성토 14,881.8㎥ · 토취 10,213.3㎥ · 최종 누가토량 −10,213.3㎥`).
pytest 370 passed·17 skipped(일원화 검사 4건 신설), typecheck·prettier·ruff 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-03 09:50:14 +09:00
co-authored by Claude Opus 5
parent 23b1e7e5b0
commit 9b4abecf9d
8 changed files with 195 additions and 72 deletions
+14 -23
View File
@@ -10,7 +10,7 @@
* ========================================================================== */
import type { AlignmentEdits } from "./B05_Profile_UI_Profile_Alignment";
import { previewCrossDesigns } from "../B06_Section/B06_Section_Api_Fetch";
import { refreshCrossDesigns } from "../B06_Section/B06_Section_Cross_Refresh";
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
export interface CrossPreviewContext {
@@ -43,29 +43,20 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview {
const routeId = ctx.routeId();
if (!detail || routeId === null) return;
const current = (seq += 1);
// full_designs — 설계선 좌표까지 통째로 받아야 3D 코리도가 편집 즉시 정확한
// 형상으로 재빌드된다(2026-08-23 사용자 지시). 암 경계는 백엔드가 세션값이 없으면
// DB 저장 echo를 폴백으로 쓰므로 그대로 유지된다.
void previewCrossDesigns(ctx.projectId, routeId, ctx.edits(), undefined, {
fullDesigns: true,
// 재계산은 B06과 **같은 창구**를 쓴다 — 표준 단면값·암 경계 오프셋이 빠지면 서버가
// 다른 설계를 그려 같은 데이터가 두 화면에서 다른 값이 된다(2026-09-03 일원화).
const isCurrent = (): boolean =>
current === seq && ctx.detail() === detail && ctx.routeId() === routeId;
void refreshCrossDesigns({
projectId: ctx.projectId,
routeId,
detail,
edits: ctx.edits(),
// 늦게 온 옛 응답이 새 설계를 덮지 않게 반영 직전에 한 번 더 확인한다.
shouldApply: isCurrent,
})
.then((next) => {
const live = ctx.detail();
if (current !== seq || !live || ctx.routeId() !== routeId) return;
const designByChainage = new Map(
next.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]),
);
for (const section of live.cross_sections) {
const full = designByChainage.get(section.chainage_m.toFixed(3));
if (!full || !section.design) continue;
// 전체 교체(설계선 포함) — B06 reconcile과 같은 패턴으로 사용자 부속값은 보존.
section.design = {
...(full as NonNullable<typeof section.design>),
inlet_structure: section.design.inlet_structure,
basin_adjust: section.design.basin_adjust,
};
}
ctx.onApplied();
.then((updated) => {
if (updated.length) ctx.onApplied();
})
.catch(() => {
/* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */
+93
View File
@@ -0,0 +1,93 @@
/* =============================================================================
* B06_Section_Cross_Refresh.ts
* 「현재 계획선에 맞춘 횡단 재계산」 **단일 창구** — B05·B06이 같은 입력으로 같은 결과를 본다.
*
* ── 왜 필요한가 (2026-09-03 사용자 지시) ─────────────────────────────
* 유토곡선은 두 화면이 같은 데이터를 보여 주는 기능인데, 재계산 호출이 두 벌이라 값이
* 갈렸다. 실측 — 같은 프로젝트·같은 시점에 B06 `절토(자연) 3,704.6㎥` ↔ B05 `4,526.8㎥`.
* 원인은 인자였다:
* · B06: `previewCrossDesigns(..., standardPanel.getValues(), { fullDesigns, rockBoundaryOffsets })`
* · B05: `previewCrossDesigns(..., undefined, { fullDesigns })`
* 표준 단면값과 암 경계 오프셋이 빠지면 서버가 다른 설계를 그려 단면적이 달라진다.
* 보존하는 사용자 부속값 목록도 서로 달라(B05는 2개, B06은 7개) B05를 거치면 기슭막이
* 조정 같은 값이 사라졌다.
*
* 그래서 **입력 수집·서버 호출·제자리 반영**을 여기 한 곳으로 모은다. 세션 편집값은
* 패널이 아니라 세션 저장소에서 직접 읽으므로, 패널이 없는 B05도 B06과 같은 값을 보낸다.
* ========================================================================== */
import { previewCrossDesigns } from "./B06_Section_Api_Fetch";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
import { readStandardCrossSession } from "./B06_Section_UI_Standard_Panel";
/** 계획선 편집 델타 — B05 `AlignmentEdits`와 저장분 `profile_alignment.edits`가 같은 모양이다. */
export interface CrossRefreshEdits {
station_offsets: Record<string, number>;
curve_radii: Record<string, number>;
}
export interface CrossRefreshInput {
projectId: string;
routeId: number;
/** 제자리 갱신 대상 — 공유 캐시가 들고 있는 그 객체여야 두 화면이 같이 따라온다. */
detail: SectionDetailResponse;
edits: CrossRefreshEdits;
/**
* 응답이 도착한 시점에 **아직 이 결과를 써도 되는지** 묻는다(false면 반영하지 않는다).
* 끌기 중에는 요청이 겹치므로, 늦게 온 옛 응답이 새 설계를 덮지 않게 하는 문지기다.
*/
shouldApply?: () => boolean;
}
/** 서버 설계로 갈아 끼워도 **살려 두는 사용자 부속값** — 화면 조작으로만 생기는 값이다. */
function preserveUserFields(
next: NonNullable<CrossSection["design"]>,
previous: CrossSection["design"],
): NonNullable<CrossSection["design"]> {
if (!previous) return next;
return {
...next,
inlet_structure: previous.inlet_structure,
basin_adjust: previous.basin_adjust,
revet_adjust: previous.revet_adjust,
extra_wall_counts: previous.extra_wall_counts,
extra_spans: previous.extra_spans,
revet_link_detached: previous.revet_link_detached,
revet_follow_grade: previous.revet_follow_grade,
};
}
/**
* 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다.
* 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다.
*/
export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<number[]> {
const { projectId, routeId, detail, edits, shouldApply } = input;
// full_designs — 설계선 좌표까지 받아야 3D 코리도·횡단도가 편집 즉시 같은 형상이 된다.
const response = await previewCrossDesigns(
projectId,
routeId,
edits,
readStandardCrossSession(projectId) ?? undefined,
{
fullDesigns: true,
rockBoundaryOffsets: readRockBoundarySession(projectId, routeId),
},
);
if (shouldApply && !shouldApply()) return [];
const designByChainage = new Map(
response.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]),
);
const updated: number[] = [];
for (const section of detail.cross_sections) {
const next = designByChainage.get(section.chainage_m.toFixed(3));
if (!next) continue;
section.design = preserveUserFields(
next as NonNullable<CrossSection["design"]>,
section.design,
);
updated.push(section.chainage_m);
}
return updated;
}
+26 -5
View File
@@ -25,17 +25,18 @@ def _validate_stage_path(relative_path: str) -> str:
return normalized.as_posix()
async def get_confirmed_route_context(
connection: aiomysql.Connection, project_id: UUID
async def _route_context(
connection: aiomysql.Connection, project_id: UUID, confirmed_only: bool
) -> dict[str, Any] | None:
"""프로젝트의 최신 확정 경로와 연결된 지표면 좌표계를 조회한다.
"""경로 하나와 연결된 지표면 좌표계를 조회한다(정렬은 최신 우선).
surface_models.crs_epsg가 NULL이면(분석에 사용한 입력 파일에 좌표계가
없던 경우) 같은 프로젝트 input_files의 감지된 좌표계로 폴백한다.
"""
status_filter = "AND r.status = 'CONFIRMED'" if confirmed_only else ""
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""
f"""
SELECT r.id AS route_id,
COALESCE(
sm.crs_epsg,
@@ -47,7 +48,7 @@ async def get_confirmed_route_context(
) AS crs_epsg
FROM routes r
LEFT JOIN surface_models sm ON sm.id = r.surface_model_id
WHERE r.project_id = %s AND r.status = 'CONFIRMED'
WHERE r.project_id = %s {status_filter}
ORDER BY r.computed_at DESC, r.id DESC
LIMIT 1
""",
@@ -62,6 +63,26 @@ async def get_confirmed_route_context(
}
async def get_confirmed_route_context(
connection: aiomysql.Connection, project_id: UUID
) -> dict[str, Any] | None:
"""최신 **확정** 경로 — 납품 도면(B07)처럼 확정본만 봐야 하는 곳이 쓴다."""
return await _route_context(connection, project_id, confirmed_only=True)
async def get_workflow_route_context(
connection: aiomysql.Connection, project_id: UUID
) -> dict[str, Any] | None:
"""워크플로 화면이 보는 경로 = **최신 경로**(확정 여부 무관).
B05는 `get_latest_route()`로 최신 경로를 열고, B06은 확정 경로만 열어서 노선을 다시
탐색한 프로젝트에서 두 화면이 **다른 노선**을 봤다(2026-09-03 실측: B05 route 126
DRAFT / B06 route 125 CONFIRMED — 같은 20m 측점의 성토가 4.82㎡ ↔ 85.9㎡). 같은
데이터를 두 창으로 보여 주는 구조이므로 경로 선택 규칙을 최신 경로 하나로 맞춘다.
"""
return await _route_context(connection, project_id, confirmed_only=False)
async def get_latest_section_options(
connection: aiomysql.Connection, project_id: UUID
) -> dict[str, Any] | None:
+4 -2
View File
@@ -26,7 +26,7 @@ from B06_Section.B06_Section_Repository import (
count_cross_sections,
create_longitudinal_section,
delete_sections_for_route,
get_confirmed_route_context,
get_workflow_route_context,
get_cross_section_designs,
get_latest_section_options,
get_longitudinal_section,
@@ -104,7 +104,9 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON
pool = get_db_pool()
try:
async with pool.acquire() as connection:
route_context = await get_confirmed_route_context(connection, project_id)
# 화면이 보는 경로 = 최신 경로(확정 여부 무관) — B05와 같은 규칙이어야
# 두 화면이 같은 노선의 같은 값을 본다(2026-09-03 일원화).
route_context = await get_workflow_route_context(connection, project_id)
surface_params = await get_surface_confirmation_params(connection, str(project_id))
# 임도 종류(projects.road_type) — B05가 계획선 법정 기준을 정하는 데 쓴다.
async with connection.cursor() as cursor:
+15 -37
View File
@@ -15,15 +15,16 @@ import {
computeCrossDesign,
fetchSectionContext,
getSections,
previewCrossDesigns,
type SectionContextResponse,
type SectionDetailResponse,
type StandardCrossSection,
} from "./B06_Section_Api_Fetch";
import { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh";
import {
confirmCurrentSections,
createRockBoundaryStore,
rockBoundarySessionKey,
saveCurrentSections,
type SectionPersistContext,
} from "./B06_Section_UI_Page_Persist";
@@ -284,16 +285,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
*/
async function reconcileStaleDesigns(): Promise<void> {
if (!sectionDetail || !projectId || currentRouteId === null) return;
// 계획고 어긋남은 B05와 **같은 규칙**으로 판정한다(공용 staleDesignChainages).
// 옛 암 2단계 필드 누락은 B06 전용 조건이라 여기서 더한다.
const staleByPlan = new Set(staleDesignChainages(sectionDetail));
const stale = sectionDetail.cross_sections.filter((section) => {
const design = section.design;
if (!design) return false;
if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true;
return staleByPlan.has(section.chainage_m);
});
if (!stale.length) return;
// 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 staleDesignChainages — 계획고 어긋남 +
// 옛 암 2단계 필드 누락). 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다.
if (!staleDesignChainages(sectionDetail).length) return;
// 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다
// (CLAUDE.md 5장).
try {
@@ -309,33 +303,15 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
station_offsets: alignment?.edits?.station_offsets ?? {},
curve_radii: alignment?.edits?.curve_radii ?? {},
};
const response = await previewCrossDesigns(
// 재계산은 B05와 **같은 창구**를 쓴다 — 인자가 갈리면 같은 데이터가 두 화면에서
// 다른 값이 된다(2026-09-03 사용자 지시로 일원화).
const updated = await refreshCrossDesigns({
projectId,
currentRouteId,
routeId: currentRouteId,
detail: sectionDetail,
edits,
standardPanel?.getValues(),
{ fullDesigns: true, rockBoundaryOffsets: Object.fromEntries(rockOffsets) },
);
const designByChainage = new Map(
response.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]),
);
for (const section of sectionDetail.cross_sections) {
const next = designByChainage.get(section.chainage_m.toFixed(3));
if (next) {
// full_designs 응답은 설계 전체(설계선 좌표 포함)라 통째로 교체한다.
section.design = {
...(next as NonNullable<typeof section.design>),
inlet_structure: section.design?.inlet_structure,
basin_adjust: section.design?.basin_adjust,
revet_adjust: section.design?.revet_adjust,
extra_wall_counts: section.design?.extra_wall_counts,
extra_spans: section.design?.extra_spans,
revet_link_detached: section.design?.revet_link_detached,
revet_follow_grade: section.design?.revet_follow_grade,
};
sectionView.refreshCard(section.chainage_m);
}
}
});
for (const chainageM of updated) sectionView.refreshCard(chainageM);
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${L("B06_Design_Failed")}${detail}`, "error");
@@ -399,7 +375,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
// 암 경계선 오프셋(측점별) 세션 저장소는 저장 흐름 모듈이 맡는다(2026-09-02 분리).
const rockStore = createRockBoundaryStore({
sessionKey: () =>
projectId && currentRouteId !== null ? `b06:rockb:${projectId}:${currentRouteId}` : null,
projectId && currentRouteId !== null
? rockBoundarySessionKey(projectId, currentRouteId)
: null,
detail: () => sectionDetail,
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
recompute: (chainageM) => recomputeIfRock(chainageM),
@@ -25,6 +25,28 @@ import { computeHaulPlan } from "@util/common_util_mass_haul_balance";
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
import { L } from "./B06_Section_UI_Page_Common";
/**
* 암 경계선 오프셋 세션 키 — 저장소와 재계산 창구가 **같은 자리**를 보게 정의처를 하나로 둔다.
* (`B06_Section_Cross_Refresh` 가 패널 없는 B05에서도 같은 값을 읽어 서버로 보낸다.)
*/
export function rockBoundarySessionKey(projectId: string, routeId: number): string {
return `b06:rockb:${projectId}:${routeId}`;
}
/** 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체. */
export function readRockBoundarySession(
projectId: string,
routeId: number,
): Record<string, number> {
try {
const raw = window.sessionStorage.getItem(rockBoundarySessionKey(projectId, routeId));
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
return parsed && typeof parsed === "object" ? (parsed as Record<string, number>) : {};
} catch {
return {};
}
}
/** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */
export interface RockBoundaryStore {
/** 측점키(누가거리 2자리) → 오프셋(m). `buildCrossPatches` 가 그대로 읽는다. */
+13 -2
View File
@@ -81,16 +81,27 @@ export function validElevation(
export function staleDesignChainages(
detail: {
longitudinal: { design_profiles?: DesignProfile[] };
cross_sections: Array<{ chainage_m: number; design?: { design_elevation_m: number } | null }>;
cross_sections: Array<{
chainage_m: number;
design?: {
design_elevation_m: number;
geometry_preset?: string;
two_stage_slope?: boolean;
} | null;
}>;
},
toleranceM = 1e-3,
): number[] {
const profiles = detail.longitudinal.design_profiles;
if (!profiles?.length) return [];
return detail.cross_sections
.filter((section) => {
const design = section.design;
if (!design) return false;
// 옛 암 측점: 2단계 절토 경사 필드가 없으면 절토 면적이 최신 엔진과 다르다.
// 이 조건이 B06 페이지에만 있어 B05는 재계산을 건너뛰었고, 같은 데이터인데 두 화면의
// 절토량이 갈렸다(2026-09-03 실측 228.52㎡ ↔ 270.12㎡). 판정을 여기 한 곳으로 모은다.
if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true;
if (!profiles?.length) return false;
const planned = designElevationAt(profiles, section.chainage_m);
return planned !== undefined && Math.abs(planned - design.design_elevation_m) > toleranceM;
})
+8 -3
View File
@@ -52,8 +52,13 @@ function cloneDefaults(defaults: StandardCrossSection): StandardCrossSection {
return JSON.parse(JSON.stringify(defaults)) as StandardCrossSection;
}
/** 세션에 저장된 편집값을 읽는다. 없거나 손상 시 null. */
function readSession(projectId: string): StandardCrossSection | null {
/**
* . null.
*
* (`B06_Section_Cross_Refresh`) B05처럼
* ** ** .
*/
export function readStandardCrossSession(projectId: string): StandardCrossSection | null {
try {
const raw = window.sessionStorage.getItem(sessionKey(projectId));
return raw ? (JSON.parse(raw) as StandardCrossSection) : null;
@@ -170,7 +175,7 @@ export function createStandardPanel(
onApplyAll?: () => void | Promise<void>,
): StandardPanelController {
// 세션값 우선, 없으면 config 기본값. defaults는 복원 기준으로 보존한다.
const sessionValue = readSession(projectId);
const sessionValue = readStandardCrossSession(projectId);
const hadSession = sessionValue !== null;
const state: StandardCrossSection = sessionValue ?? cloneDefaults(defaults);