fix(B05·B06 한 페이지): 노선 바뀐 저장 때 옛 저장 관 자리 조절 = 같은 관 id 의 새 자리로 (417-1 M2)

- 노선 저장 직전 저장 관(id · 옛 누가거리)을 떠 두고 저장 뒤 같은 id 새 자리로 — 초안으로 안 따라간 관 조절(첫 초안 측점 짝 · 관 id 규칙 갈림)
- carryChainageStates 시설 풀이 = 옛 자리 → 새 자리 · 관 열쇠 → 새 자리
- 시험 test_417 옛 자리 짝 · 346-3 저장 차례(관 받기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
This commit is contained in:
2026-10-18 13:04:33 +09:00
co-authored by Claude Opus 5.5
parent 42af5add77
commit 041a22b4b3
6 changed files with 48 additions and 11 deletions
+20 -6
View File
@@ -119,6 +119,7 @@ export async function saveRouteDraft(
projectId: string,
body: RouteSaveBody,
): Promise<RouteReplanResponse> {
pipesBeforeSave.set(projectId, await savedPipes(projectId));
const saved = await replanRoute(projectId, body);
useDesignStore(projectId, null).set("routeDraft", null);
// 노선이 바뀌면 조회 결과 캐시는 옛 노선 것 — 추천은 새 노선의 출발점이라 다시 적는다.
@@ -141,6 +142,15 @@ export function routeSaveSummary(saved: RouteReplanResponse, outside = 0): strin
return `노선 저장 — ${parts.join(" · ")}`;
}
type SavedPipe = { id?: string; chainage_m: number };
/** 노선 저장 직전 저장 관(id · 옛 누가거리) — 저장 뒤 같은 id 의 새 자리로 조절을 옮김(초안으로 안 따라간 고침 · 417). */
const pipesBeforeSave = new Map<string, SavedPipe[]>();
const savedPipes = (projectId: string): Promise<SavedPipe[]> =>
fetchDetailPipePoints(projectId)
.then((res) => res.pipe_points)
.catch(() => []);
/**
* 노선 저장 뒤 세션 횡단 초안(옛 노선 측점 열쇠 · 누가거리 열쇠 조절 · 417) → 새 노선으로 넘기고 그 고침을 저장 —
* 초안을 통째로 지우지 않는다. 사라진 측점 고침은 버림(수를 돌려줌 · 서버 `cross_dropped` 와 한 줄로).
@@ -154,18 +164,22 @@ export async function carryAfterRouteSave(
const detail = await loadSectionDetail(projectId, toRouteId);
useStationKeys(projectId, toRouteId, detail);
// 관 열쇠 → 새 자리 — 세션 id(`pipe-s3`) = 관 장부 · 저장 관 id(`p8017`) = 서버가 옮긴 관
const saved = await fetchDetailPipePoints(projectId)
.then((res) => res.pipe_points)
.catch(() => []);
const saved = await savedPipes(projectId);
const before = pipesBeforeSave.get(projectId) ?? [];
pipesBeforeSave.delete(projectId);
const newAt = (id: string | undefined): number | null =>
saved.find((pipe) => id !== undefined && pipe.id === id)?.chainage_m ?? null;
const pipeAt = (key: string): number | null => {
const id = key.startsWith("pipe:") ? key.slice(5) : null;
if (!id) return null;
if (id.startsWith("pipe-")) return pipeChainageOf(id, projectId);
return saved.find((pipe) => pipe.id === id)?.chainage_m ?? null;
return id.startsWith("pipe-") ? pipeChainageOf(id, projectId) : newAt(id);
};
// 옛 저장 관 자리에 남은 고침(초안으로 안 따라감) = 같은 관 id 의 새 자리
const movedAt = (chainageM: number): number | null =>
newAt(before.find((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.01)?.id);
const outside =
carryStationDrafts(projectId, fromRouteId, toRouteId, { quiet: true }) +
carryChainageStates(projectId, fromRouteId, toRouteId, pipeAt);
carryChainageStates(projectId, fromRouteId, toRouteId, { keyAt: pipeAt, movedAt });
// 옮긴 고침을 새 상세 설계 칸에 — 같은 [저장]의 횡단 패치가 실음(사면 쉬프트 · 조절 · 소단 깊이 · 417)
withDraftAdjusts(detail, projectId, toRouteId);
withCarriedEdits(detail, projectId, toRouteId);
+14 -2
View File
@@ -380,6 +380,14 @@ function carriedValue(name: KeyedState, value: unknown): unknown {
return Object.keys(picked).length ? picked : null;
}
/** 노선 저장 때 시설 측점 풀이(`carryChainageStates`). */
export interface CarryFacilities {
/** 옛 저장 시설 측점 누가거리 → 새 노선 그 시설 측점 누가거리(없으면 null). */
movedAt?: (chainageM: number) => number | null | undefined;
/** 관 · 구조물 열쇠 → 새 노선 누가거리(없으면 null). */
keyAt?: (key: string) => number | null | undefined;
}
/** 누가거리 열쇠 머리(`b` · 0.01 m 글 · `:역할`) — 조정창 맵 열쇠 꼴(`renameChainageKeys` 와 같음). */
const CHAINAGE_HEAD = /^(b?)(-?\d+(?:\.\d+)?)(:.*)?$/;
@@ -387,16 +395,20 @@ const CHAINAGE_HEAD = /^(b?)(-?\d+(?:\.\d+)?)(:.*)?$/;
* 노선 저장 뒤(새 노선 측점 열쇠를 세운 뒤) 누가거리 열쇠 칸(`ROUTE_CARRY_STATES` 중 측점 열쇠 칸 빼고)을 옛 노선
* → 새 노선 측점으로 옮김 — 옛 누가거리 → 옛 측점 열쇠 → 새 누가거리. 구조물 조절은 관 · 구조물 열쇠만 ·
* 못 옮긴 고침은 버림. 돌려주는 값 = 버린 측점 수(「노선 밖 고침」 · 측점 단위).
* `facilityAt` = 관 · 구조물 열쇠 → 새 노선 누가거리(관 id 규칙이 갈림 — 세션 `pipe-s3` · 계산 줄 `pipe:p8017`).
* 시설 풀이(`facilities`) = 옛 저장 시설 측점 → 새 노선 그 시설 측점(좌표 짝 · 초안으로 안 따라간 고침) · 관 ·
* 구조물 열쇠 → 새 누가거리(관 id 규칙이 갈림 — 세션 `pipe-s3` · 계산 줄 `pipe:p8017`).
*/
export function carryChainageStates(
projectId: string,
fromRouteId: number,
toRouteId: number,
facilityAt: (key: string) => number | null | undefined = () => null,
facilities: CarryFacilities = {},
): number {
const facilityAt = facilities.keyAt ?? (() => null);
const outside = new Set<string>();
const moveAt = (state: string, text: string): string | null => {
const moved = facilities.movedAt?.(Number(text));
if (moved !== null && moved !== undefined) return chainageKey(moved);
const stable = keyAt(projectId, fromRouteId, Number(text));
const structural = stable.startsWith("pipe:") || stable.startsWith("structure:");
// 새 목록에 그 열쇠가 없으면 그 시설의 새 자리 · 없으면 같은 자리 측점(초안 측점 = 저장 측점)
+4 -1
View File
@@ -38,7 +38,10 @@ const STUBS = {
},
"../B06_Section/B06_Section_Section_Store": {
invalidateSectionDetail: (_p, routeId) => calls.push(`invalidate:${routeId ?? "all"}`),
loadSectionDetail: async (_p, routeId) => (calls.push(`load:${routeId}`), { tag: routeId }),
loadSectionDetail: async (_p, routeId) => (
calls.push(`load:${routeId}`),
{ tag: routeId, cross_sections: [] }
),
saveCachedCrossPatches: async (_p, routeId) => (calls.push(`crossSave:${routeId}`), 1),
},
"../B06_Section/B06_Section_Station_Keys": {
+6 -1
View File
@@ -142,9 +142,14 @@ m.useStationKeys(
detailOf([section(0, "bp"), section(45, "irregular", pipe), section(60, "ep")]),
);
m.writeState("revetx", { "45.00:outlet": wall }, P, R);
m.carryChainageStates(P, R, R3, (key) => (key === "pipe:pipe-s1" ? 52 : null));
m.carryChainageStates(P, R, R3, { keyAt: (key) => (key === "pipe:pipe-s1" ? 52 : null) });
m.carryChainageStates(P, R, R4);
out.splitResolved = raw("revetx", R3);
out.splitDropped = raw("revetx", R4);
// 옛 저장 시설 측점(초안으로 안 따라간 고침) — 좌표 짝으로 새 시설 측점에
const R5 = 11;
m.useStationKeyList(P, R5, list);
m.carryChainageStates(P, R, R5, { movedAt: (c) => (c === 45 ? 52 : null) });
out.movedByPlace = raw("revetx", R5);
process.stdout.write(JSON.stringify(out));
@@ -94,7 +94,8 @@ def test_추천_적용은_as_initial_저장_뒤_다시_세움(out):
body = out["recommendBody"]
assert body["as_initial"] is True and body["initial"] == {"mode": "earthwork"}
assert body["draft"]["nodes"] == [[1, 1], [9, 9]]
assert out["recommendCalls"] == ["overlay:on", "replan", "overlay:off"] # 잠금 막 하나
# 잠금 막 하나 · 저장 직전 저장 관 떠 둠(417 — 저장 뒤 같은 관 id 새 자리로 조절 옮김)
assert out["recommendCalls"] == ["overlay:on", "pipes", "replan", "overlay:off"]
assert out["recommendDraftsLeft"] == {"route": None, "pipes": None} # 새 초기값에서 다시
assert out["rebuilt"] == 1
+2
View File
@@ -101,6 +101,8 @@ def test_조절은_관_따라_옮김_일반_측점_옹벽은_버림(out):
def test_관_id_규칙이_갈려도_시설_자리로_옮김(out):
assert out["splitResolved"] == {"52.00:outlet": {"x": 0.3, "d": None, "h": 1.5, "m": None}}
assert out["splitDropped"] == {} # 풀이 없음 = 같은 자리 측점 없음 → 버림
# 초안으로 안 따라간 옛 저장 시설 측점 고침 = 좌표 짝 시설 측점으로
assert out["movedByPlace"] == {"52.00:outlet": {"x": 0.3, "d": None, "h": 1.5, "m": None}}
def test_새_상세_설계_칸에_얹힘(out):