fix(b05·b06): 저장 전 구조물 자리 값 다시 셈 — 물넘이포장 · 맹암거 측구 없음 · 종류 바꿈 · 기준측점 옮김 (314 · 요구 210 · 211)

- 낡음 판정(B05 · B06 공용 isStaleSection)에 서버 _enforce_stored_designs 짝 셋: 포장 · 기울기(물넘이 · 맹암거) · 세월교 노면 하강
- 구조물 바뀐 뒤 횡단 다시 받으면 낡은 측점 다시 셈(refreshDetailForStructures)
- 다시 셈의 세월교 노면 하강 = 그 측점에 얹힌 세월교 제원(옛 저장값 안 이음)
- 물넘이포장: 옮기거나 지운 옛 자리 걷고 새 자리 · 걸친 기존 측점에 얹음(빈 관 목록도)
- 월류 높이 기다림: 빈 글자도 빈칸(채우는 쪽과 같은 규칙)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
This commit is contained in:
2026-10-13 19:54:27 +09:00
co-authored by Claude Opus 5.5
parent 1dc815eae3
commit fbaa5b4838
7 changed files with 179 additions and 6 deletions
+2 -1
View File
@@ -235,6 +235,7 @@ export async function mountCrossView(
);
}
renderSectionDetail();
void reconcileStaleDesigns(); // 구조물 제원 ↔ 저장분 값 다른 측점 다시 셈(측구 · 포장 · 노면 하강 · 314)
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`구조물을 횡단에 반영하지 못했습니다.${detail}`, "error");
@@ -562,7 +563,7 @@ export async function mountCrossView(
// 종단 보기에서 고름을 풀었으면 횡단 강조도 풂(54-38)
if (!readStructurePick(projectId)) releaseCross();
void structuresPanel.load().then(restoreStructurePick);
void refreshDetailForStructures().then(() => reconcileStaleDesigns());
void refreshDetailForStructures(); // 안에서 낡은 설계도 맞춤(314)
});
host.onDispose(() => {
window.clearTimeout(pipeReloadTimer);
@@ -32,7 +32,9 @@ export function waitsFordHeight(
facility: string | undefined,
options?: Record<string, string | number>,
): boolean {
return !!facility && facility in FORD_KINDS && options?.ford_height_m === undefined;
// 빈 글자도 빈칸 — 채우는 쪽(`fillFordHeights`)과 같은 규칙(314)
const height = options?.ford_height_m;
return !!facility && facility in FORD_KINDS && (height === undefined || height === "");
}
/** 기다리는 측점의 빈 높이를 채움 — 채운(또는 사용자 값이라 그만 기다릴) 측점은 `pending` 에서 뺌.
+3 -2
View File
@@ -377,8 +377,9 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
? design.ditch_choice
: null,
ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null,
// 세월교 월류 하강은 계획선 편집으로 바뀌지 않는다 — 저장분 값을 그대로 잇는다.
surfaceDropM: typeof design.surface_drop_m === "number" ? design.surface_drop_m : 0,
// 세월교 월류 하강 = 그 측점에 얹힌 세월교 제원(초안 · 저장분 같은 자리 · 서버 `ford_drop_at` 짝 · 314)
surfaceDropM:
Number(section.ford?.overflow_depth_m) > 0 ? Number(section.ford?.overflow_depth_m) : 0,
// 곡선부 확폭 입력 — 측점 기록에 실려 온다(서버 엔진과 같은 값, 2026-09-06).
planRadiusM: section.plan_radius_m ?? null,
curveOuterSide:
+11 -2
View File
@@ -132,10 +132,17 @@ const facilityKey = (facility: string): (typeof FACILITY_KEYS)[number] =>
*/
async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: string): Promise<void> {
const pipes = readPendingPipes(projectId);
if (!pipes?.length) return;
if (!pipes) return;
const sections = detail.cross_sections as unknown as Array<Record<string, unknown>>;
const near = (spec: Record<string, unknown>, section: Record<string, unknown>, at: number) =>
Math.abs(Number(spec.chainage_m ?? section.chainage_m) - at) < PIPE_MATCH_M;
// 물넘이포장(구간형 · 걸친 측점마다 얹힘) — 옮기거나 지운 옛 자리는 걷음 · 새 자리는 아래에서 얹음(314)
for (const section of sections) {
const spec = section.ford_pavement as Record<string, unknown> | undefined;
const kept = (pipe: (typeof pipes)[number]) =>
pipe.facility === "ford_pavement" && !!spec && near(spec, section, pipe.chainage_m);
if (spec && !pipes.some(kept)) delete section.ford_pavement;
}
/** 그 관 자리에 얹힌 시설 키가 이 종류와 맞는가 — 얹힌 것이 없으면(새 관 · 옮긴 관) 대상 밖. */
const fits = (pipe: (typeof pipes)[number], key: string, spec: Record<string, unknown>) =>
key === facilityKey(pipe.facility ?? "pipe") &&
@@ -147,7 +154,9 @@ async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: stri
return spec && near(spec, section, pipe.chainage_m) ? [[key, spec] as const] : [];
}),
);
return here.length > 0 && !here.some(([key, spec]) => fits(pipe, key, spec));
// 얹힌 것이 없는 관(새 관 · 옮긴 관)은 미리 뜬 횡단이 맡음 — 물넘이포장만 걸친 기존 측점까지 여기서(314)
if (!here.length) return pipe.facility === "ford_pavement";
return !here.some(([key, spec]) => fits(pipe, key, spec));
});
if (!stale.length) return;
const types = await fetchStructureTypes().catch(() => []);
@@ -101,6 +101,11 @@ export function hasStaleDesigns(
interface StaleCandidate {
chainage_m: number;
// 측점에 얹힌 구조물 제원 — 저장분 설계값(포장 · 기울기 · 노면 하강)과 맞대 본다(314)
pavement?: unknown[] | null;
ford_pavement?: unknown;
underdrain?: unknown;
ford?: { overflow_depth_m?: number } | null;
design?: {
design_elevation_m: number;
geometry_preset?: string;
@@ -108,6 +113,8 @@ interface StaleCandidate {
surface_drop_m?: number;
section_mode?: string;
ditch_enabled?: boolean;
paved?: boolean;
tilted?: boolean;
} | null;
}
@@ -124,6 +131,15 @@ function isStaleSection(
if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true;
// 옛 엔진이 양측 성토에 측구를 세워 둔 저장분(206-1) — 지금 엔진으로 다시 셈.
if (design.section_mode === "both_fill" && design.ditch_enabled === true) return true;
// 얹힌 구조물 제원과 저장분 설계값이 다르면 낡음 — 서버 `_enforce_stored_designs` 짝(314).
// 저장 전에 놓거나 · 옮기거나 · 종류를 바꾼 물넘이포장 · 맹암거 · 세월교 · 노면공이 [저장] 전에도
// 측구 없음 · 포장 · 노면 하강으로 다시 셈(포장 = `pavedBySpecs` · 기울기 = `Cross_Refresh` 와 같은 규칙).
const paved = Boolean(section.pavement?.length) || Boolean(section.ford_pavement);
if (Boolean(design.paved) !== paved) return true;
if (Boolean(design.tilted) !== Boolean(section.ford_pavement || section.underdrain)) return true;
const drop =
Number(section.ford?.overflow_depth_m) > 0 ? Number(section.ford?.overflow_depth_m) : 0;
if (Math.abs((design.surface_drop_m ?? 0) - drop) > 1e-6) return true;
if (!profiles?.length) return false;
const planned = designElevationAt(profiles, section.chainage_m);
if (planned === undefined) return false;
@@ -0,0 +1,63 @@
/* PLAN 314 단위검증 헬퍼 — 저장분 횡단 값(포장 · 기울기 · 노면 하강) 낡음 판정 · 월류 높이 기다림.
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌.
* 짝 = `test_314_stale_structure.py`.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
const load = (file) => {
if (cache.has(file)) return cache.get(file);
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(file, box.exports);
const near = (m) => {
if (m.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${m.slice(6)}.ts`));
if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`));
return new Proxy({}, { get: () => () => {} });
};
new Function("exports", "module", "require", js)(box.exports, box, near);
return box.exports;
};
const { hasStaleDesigns } = load(
path.join(ROOT, "B06_Section", "B06_Section_UI_Section_Common.ts"),
);
const { waitsFordHeight } = load(
path.join(ROOT, "B05_Profile", "B05_Profile_UI_Drainage_Ford_Fill.ts"),
);
const stale = (section) =>
hasStaleDesigns({ longitudinal: { design_profiles: [] }, cross_sections: [section] });
const design = (extra) => ({ design_elevation_m: 100, ...extra });
const out = {
ford_untilted: stale({ chainage_m: 10, ford_pavement: {}, design: design({ paved: true }) }),
ford_tilted: stale({
chainage_m: 10,
ford_pavement: {},
design: design({ paved: true, tilted: true }),
}),
left_tilted: stale({ chainage_m: 10, design: design({ tilted: true }) }),
underdrain_tilted: stale({ chainage_m: 10, underdrain: {}, design: design({ tilted: true }) }),
pavement_unpaved: stale({ chainage_m: 10, pavement: [{}], design: design({ paved: false }) }),
ford_drop_missing: stale({ chainage_m: 10, ford: { overflow_depth_m: 0.3 }, design: design({}) }),
ford_drop_kept: stale({
chainage_m: 10,
ford: { overflow_depth_m: 0.3 },
design: design({ surface_drop_m: 0.3 }),
}),
plain: stale({ chainage_m: 10, design: design({}) }),
waits: {
pave_empty: waitsFordHeight("ford_pavement", {}),
pave_blank: waitsFordHeight("ford_pavement", { ford_height_m: "" }),
pave_set: waitsFordHeight("ford_pavement", { ford_height_m: 0.5 }),
bridge_none: waitsFordHeight("ford_bridge", undefined),
pipe: waitsFordHeight("pipe", {}),
},
};
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,81 @@
"""PLAN 314 — 물넘이포장 · 맹암거 후속(요구 210 · 211).
- 314-1 · 314-2 · 314-4 저장 전에 놓거나 · 옮기거나 · 종류를 바꾼 구조물 자리 = 저장분 횡단 값(포장 · 기울기 ·
세월교 노면 하강)과 얹힌 제원이 다르면 낡음 → 다시 셈(서버 `_enforce_stored_designs` 짝) ·
물넘이포장 옛 자리 걷고 새 자리(걸친 기존 측점까지) 얹음
- 314-3 월류 높이 기다림 = 빈 글자도 빈칸(채우는 쪽과 같은 규칙)
실제 TS 를 `helper_314_stale_structure.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(__file__)
ROOT = os.path.join(HERE, "..", "..")
pytestmark = pytest.mark.skipif(
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
reason="프론트엔드 의존성 미설치",
)
def _read(*parts: str) -> str:
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
return handle.read()
@pytest.fixture(scope="module")
def result() -> dict:
out = subprocess.run(
["node", os.path.join(HERE, "helper_314_stale_structure.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
check=True,
)
return json.loads(out.stdout)
def test_tilt_mismatch_is_stale(result: dict) -> None:
# 물넘이포장 · 맹암거 자리인데 저장분이 안 기울었음(측구 있음) = 낡음 · 맞으면 그대로
assert result["ford_untilted"] is True
assert result["ford_tilted"] is False
assert result["underdrain_tilted"] is False
# 구조물이 떠났는데 저장분이 아직 기울었음(옮김 · 종류 바꿈) = 낡음
assert result["left_tilted"] is True
def test_paved_and_surface_drop_mismatch_is_stale(result: dict) -> None:
assert result["pavement_unpaved"] is True
assert result["ford_drop_missing"] is True
assert result["ford_drop_kept"] is False
assert result["plain"] is False
def test_ford_height_wait_treats_blank_as_empty(result: dict) -> None:
waits = result["waits"]
assert waits["pave_empty"] is True
assert waits["pave_blank"] is True
assert waits["pave_set"] is False
assert waits["bridge_none"] is True
assert waits["pipe"] is False
def test_structure_change_reconciles_and_ford_reattach() -> None:
view = _read("A00_Common", "design", "design_view_cross.ts")
body = view.split("async function refreshDetailForStructures", 1)[1].split("\n }\n", 1)[0]
assert "void reconcileStaleDesigns();" in body
refresh = _read("B06_Section", "B06_Section_Cross_Refresh.ts")
assert "Number(section.ford?.overflow_depth_m) > 0" in refresh
store = _read("B06_Section", "B06_Section_Section_Store.ts")
kinds = store.split("async function withDraftPipeKinds", 1)[1].split("\n}\n", 1)[0]
assert "if (!pipes) return;" in kinds
assert "delete section.ford_pavement" in kinds
assert 'if (!here.length) return pipe.facility === "ford_pavement";' in kinds