diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 53711a03..9c05c761 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -107,8 +107,6 @@ export const STATE_REGISTRY = { culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` }, /** 배수관 이동(측점 옮김) 예약. */ culvertmove: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertmove:${p}:${r}` }, - /** 배수관 추가·삭제 예약(2026-09-12) — B06 종단·목록에서 넣고 뺀 관. */ - culvertedit: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertedit:${p}:${r}` }, /** 암 경계선 오프셋(측점별). */ rockb: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:rockb:${p}:${r}` }, /** 측점별 암 절토 경사비(1:n 의 n) — 카드에서 넣은 사용자 값(2026-09-07). diff --git a/B05_Profile/B05_Profile_UI_Page_Actions.ts b/B05_Profile/B05_Profile_UI_Page_Actions.ts index 591cbe61..88a86153 100644 --- a/B05_Profile/B05_Profile_UI_Page_Actions.ts +++ b/B05_Profile/B05_Profile_UI_Page_Actions.ts @@ -171,16 +171,15 @@ export async function tempSaveAction(ctx: PageActionContext): Promise { ); // B06 조정창에서 만진 배수관 구간값도 세션에만 있다 — 함께 내보낸다 // (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). 실패해도 저장은 진행한다. - // ⚠ 키 **셋을 다 넘긴다**(2026-09-12 사용자: 어느 페이지에서 저장해도 결과가 같아야 - // 한다). 종전에는 구간값 키만 넘겨, B06 에서 예약한 관 이동·추가·삭제가 B05 [임시저장] - // 에서는 조용히 빠졌다. + // ⚠ 이동 키까지 넘긴다(2026-09-12 사용자: 어느 페이지에서 저장해도 결과가 같아야 + // 한다). 종전에는 구간값 키만 넘겨, 옛 세션에 남은 관 이동 예약이 B05 [임시저장]에서는 + // 조용히 빠졌다. 관 목록 자체는 이제 두 페이지가 같은 스냅샷(`pipes`)을 쓴다. if (latest?.route?.id != null) { const routeId = latest.route.id; await flushCulvertOptions( projectId, stateKey("culvertopt", projectId, routeId), stateKey("culvertmove", projectId, routeId), - stateKey("culvertedit", projectId, routeId), ).catch(() => undefined); } // B06에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래 diff --git a/B06_Section/B06_Section_Api_Culvert_Options.ts b/B06_Section/B06_Section_Api_Culvert_Options.ts index eae5d769..011a583d 100644 --- a/B06_Section/B06_Section_Api_Culvert_Options.ts +++ b/B06_Section/B06_Section_Api_Culvert_Options.ts @@ -28,14 +28,6 @@ type PendingMap = Record; export interface CulvertOptionWriter { /** 측점 옵션을 세션에 예약한다. 같은 측점의 앞선 예약과는 합쳐진다. */ queue: (chainageM: number, patch: CulvertOptionPatch) => void; - /** 기준 측점 이동을 예약한다(2026-08-29 사용자: B06에서도 옮길 수 있어야 한다). - * 세부 배수유역 재분할은 저장 시 서버가 관 목록을 다시 받아 처리한다. */ - queueMove: (fromChainageM: number, toChainageM: number) => void; - /** 관(계곡 통과 시설) 추가를 예약한다 — B05·B06 은 한 페이지라 넣는 자리도 같아야 한다 - * (2026-09-12 사용자). 이동과 같은 규칙으로 세션에만 쌓고 [저장]·[확정]에서 나간다. */ - queueAdd: (chainageM: number, facility?: string) => void; - /** 관 삭제를 예약한다. 추가해 둔 것을 다시 지우면 예약만 걷어 낸다. */ - queueRemove: (chainageM: number) => void; /** 예약분을 즉시 내보낸다([저장]·[확정]에서만 부른다). */ flush: () => Promise; } @@ -67,7 +59,9 @@ function writePending(sessionKey: string | null, pending: PendingMap): void { * 하지 않는다. 성공하면 세션을 비우고, 실패하면 **그대로 두어** 다음 [저장]에서 * 다시 시도한다 — 조용히 잃으면 화면과 저장분이 갈린다. */ -/** 예약된 이동 — 옛 측점 키 → 새 누가거리(m). */ +/** 예약된 이동 — 옛 측점 키 → 새 누가거리(m). + * ⚠ **이제 쓰는 곳은 없다**(2026-09-12 일원화: 관 목록은 B05·B06 이 같은 스냅샷을 쓴다). + * 옛 세션에 남아 있던 예약을 저장 때 한 번 흘려보내려고 읽기만 남겨 둔다. */ type PendingMoves = Record; function readMoves(sessionKey: string | null): PendingMoves { @@ -80,59 +74,16 @@ function readMoves(sessionKey: string | null): PendingMoves { } } -/** 예약된 추가·삭제 — 이동과 같은 세션 칸에 함께 담는다(키 하나로 읽고 쓴다). */ -interface PendingEdits { - /** 넣을 관 — 누가거리(m)와 시설 종류(기본 배관). */ - adds: Array<{ chainage_m: number; facility?: string }>; - /** 지울 관 — 측점 키(누가거리 2자리). */ - removes: string[]; -} - -const EMPTY_EDITS: PendingEdits = { adds: [], removes: [] }; - -function readEdits(sessionKey: string | null): PendingEdits { - if (!sessionKey) return { ...EMPTY_EDITS, adds: [], removes: [] }; - try { - const raw = window.sessionStorage.getItem(sessionKey); - const parsed = raw ? (JSON.parse(raw) as Partial) : null; - return { - adds: Array.isArray(parsed?.adds) ? parsed.adds : [], - removes: Array.isArray(parsed?.removes) ? parsed.removes : [], - }; - } catch { - return { adds: [], removes: [] }; - } -} - -function writeEdits(sessionKey: string | null, edits: PendingEdits): void { - if (!sessionKey) return; - try { - if (!edits.adds.length && !edits.removes.length) window.sessionStorage.removeItem(sessionKey); - else window.sessionStorage.setItem(sessionKey, JSON.stringify(edits)); - } catch { - /* 세션 저장 실패는 무시 — 값은 화면 캐시에 남아 있다. */ - } -} - export async function flushCulvertOptions( projectId: string | null, sessionKey: string | null, moveKey: string | null = null, - editKey: string | null = null, ): Promise { const pending = readPending(sessionKey); const moves = readMoves(moveKey); - const edits = readEdits(editKey); - if ( - !projectId || - (Object.keys(pending).length === 0 && - Object.keys(moves).length === 0 && - !edits.adds.length && - !edits.removes.length) - ) - return; + if (!projectId || (Object.keys(pending).length === 0 && Object.keys(moves).length === 0)) return; const current = await fetchDetailPipePoints(projectId); - let touched = edits.adds.length > 0 || edits.removes.length > 0; + let touched = false; const points: DetailPipeInput[] = current.pipe_points.map((point) => { // 좌표는 서버가 다시 계산하므로 싣지 않는다(DetailPipeInput 규약). const { lonlat: _lonlat, ...input } = point; @@ -148,23 +99,9 @@ export async function flushCulvertOptions( if (movedTo !== undefined) next.chainage_m = movedTo; return next; }); - // 지울 것을 걷어 내고 넣을 것을 붙인다 — 서버는 이 목록을 그대로 정본으로 삼는다. - const removed = new Set(edits.removes); - const next = points.filter((point) => !removed.has(keyOf(point.chainage_m))); - for (const add of edits.adds) { - if (next.some((point) => Math.abs(point.chainage_m - add.chainage_m) < 0.005)) continue; - next.push({ - chainage_m: add.chainage_m, - // 사람이 넣은 자리 — 세류 교차·간격 규칙이 만든 것과 구분한다. - source: "user", - ...(add.facility ? { facility: add.facility as DetailPipeInput["facility"] } : {}), - }); - } - next.sort((left, right) => left.chainage_m - right.chainage_m); - if (touched) await saveDetailPipePoints(projectId, next); + if (touched) await saveDetailPipePoints(projectId, points); writePending(sessionKey, {}); writePending(moveKey, {}); - writeEdits(editKey, { adds: [], removes: [] }); } /** @@ -177,8 +114,6 @@ export function createCulvertOptionWriter( onError?: (message: string) => void, /** 이동 예약 세션 키 — 없으면 이동은 메모리에도 남지 않는다. */ moveKey: () => string | null = () => null, - /** 추가·삭제 예약 세션 키 — 없으면 추가·삭제를 받지 않는다. */ - editKey: () => string | null = () => null, ): CulvertOptionWriter { return { queue(chainageM, patch) { @@ -187,43 +122,9 @@ export function createCulvertOptionWriter( pending[keyOf(chainageM)] = { ...(pending[keyOf(chainageM)] ?? {}), ...patch }; writePending(key, pending); }, - queueMove(fromChainageM, toChainageM) { - const key = moveKey(); - if (!key) return; - const moves = readMoves(key); - // 이미 옮긴 관을 또 옮기면 **원래 자리** 기준으로 최종 위치만 남긴다. - const origin = - Object.keys(moves).find((entry) => Math.abs(moves[entry] - fromChainageM) < 0.005) ?? - keyOf(fromChainageM); - moves[origin] = toChainageM; - window.sessionStorage.setItem(key, JSON.stringify(moves)); - }, - queueAdd(chainageM, facility) { - const key = editKey(); - if (!key) return; - const edits = readEdits(key); - const at = Number(chainageM.toFixed(2)); - // 같은 자리를 지웠다가 다시 넣으면 삭제 예약만 걷어 낸다(정본은 그대로). - const removedAt = edits.removes.indexOf(keyOf(at)); - if (removedAt >= 0) edits.removes.splice(removedAt, 1); - else if (!edits.adds.some((entry) => Math.abs(entry.chainage_m - at) < 0.005)) - edits.adds.push({ chainage_m: at, ...(facility ? { facility } : {}) }); - writeEdits(key, edits); - }, - queueRemove(chainageM) { - const key = editKey(); - if (!key) return; - const edits = readEdits(key); - const at = Number(chainageM.toFixed(2)); - // 아직 정본에 없는(이번에 넣은) 관이면 추가 예약만 거둔다. - const addedAt = edits.adds.findIndex((entry) => Math.abs(entry.chainage_m - at) < 0.005); - if (addedAt >= 0) edits.adds.splice(addedAt, 1); - else if (!edits.removes.includes(keyOf(at))) edits.removes.push(keyOf(at)); - writeEdits(key, edits); - }, async flush() { try { - await flushCulvertOptions(projectId(), sessionKey(), moveKey(), editKey()); + await flushCulvertOptions(projectId(), sessionKey(), moveKey()); } catch (error) { onError?.(error instanceof Error ? error.message : "배수관 구간값 저장 실패"); throw error; diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index b9fe7344..5bc343a5 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -201,10 +201,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { queuePipeOptions: (chainageM, patch) => stationControls.queueCulvertOptions(chainageM, patch), applyPipeOptions: (chainageM, patch) => applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch), - movePipe: (fromChainageM, toChainageM) => - stationControls.queueCulvertMove(fromChainageM, toChainageM), - addPipe: (chainageM, facility) => stationControls.queueCulvertAdd(chainageM, facility), - removePipe: (chainageM) => stationControls.queueCulvertRemove(chainageM), }); const dockDivider = document.createElement("hr"); diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index e777ea9e..c1d1460e 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -45,7 +45,6 @@ export interface StationControlDeps { | "extraspan" | "culvertopt" | "culvertmove" - | "culvertedit" | "revetlink" | "fordadjust" | "boxadjust", @@ -95,12 +94,6 @@ export interface StationControls { flushCulvertOptions: () => Promise; /** 관 옵션 조각을 예약한다 — 좌측 폼의 [수정]도 이 경로로 정본에 간다(2026-08-29). */ queueCulvertOptions: (chainageM: number, patch: Record) => void; - /** 기준 측점 이동을 예약한다 — B06 구조물 배치 폼의 측점 칸이 쓴다(2026-08-29). */ - queueCulvertMove: (fromChainageM: number, toChainageM: number) => void; - /** 관 추가를 예약한다 — B06 종단 우클릭·목록이 쓴다(2026-09-12 B05·B06 일원화). */ - queueCulvertAdd: (chainageM: number, facility?: string) => void; - /** 관 삭제를 예약한다. */ - queueCulvertRemove: (chainageM: number) => void; load: () => void; /** 전체 반영 — 개별 반폭을 전역값으로 덮는다(없으면 비운다). */ applyGlobalWidth: (requested: number | undefined, chainages: number[]) => void; @@ -437,7 +430,6 @@ export function createStationControls(deps: StationControlDeps): StationControls () => deps.sessionKey("culvertopt"), deps.onSaveError, () => deps.sessionKey("culvertmove"), - () => deps.sessionKey("culvertedit"), ); const linkSession = createLinkFlagSession(() => deps.sessionKey("revetlink")); const linkDetached = linkSession.detached; @@ -623,10 +615,6 @@ export function createStationControls(deps: StationControlDeps): StationControls }, flushCulvertOptions: () => culvertOptions.flush(), queueCulvertOptions: (chainageM, patch) => culvertOptions.queue(chainageM, patch), - queueCulvertMove: (fromChainageM, toChainageM) => - culvertOptions.queueMove(fromChainageM, toChainageM), - queueCulvertAdd: (chainageM, facility) => culvertOptions.queueAdd(chainageM, facility), - queueCulvertRemove: (chainageM) => culvertOptions.queueRemove(chainageM), load: () => { loadStationWidths(); loadRevetShifts(); diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index e7c048dc..4f53a3de 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -24,7 +24,7 @@ import { type StructureInstance, type StructureType, } from "../B05_Profile/B05_Profile_Api_Structures"; -import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; +import { readPendingPipes, writePendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { readStructurePick, @@ -78,13 +78,6 @@ export interface B06StructuresPanelDeps { /** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 — * 조정창과 같은 흐름이라 폼 조작도 도면에 바로 보인다(2026-08-29 사용자 보고). */ applyPipeOptions?: (chainageM: number, patch: Record) => void; - /** 기준 측점 이동을 예약한다 — 세부 배수유역 재분할은 [저장]·[확정] 때 서버가 - * 관 목록을 다시 받아 처리한다(2026-08-29 사용자: B06에서도 옮길 수 있어야 한다). */ - movePipe?: (fromChainageM: number, toChainageM: number) => void; - /** 관(계곡 통과 시설)을 넣는다 — 이동과 같은 예약 경로(2026-09-12 B05·B06 일원화). */ - addPipe?: (chainageM: number, facility?: string) => void; - /** 관을 뺀다 — 같은 예약 경로. */ - removePipe?: (chainageM: number) => void; /** 구조물 목록이 바뀌었다 — 벽(C군)은 횡단 제원으로 얹혀 **면적까지 달라지므로** * 화면이 횡단을 다시 받아 그려야 한다(2026-09-06 사용자 확정). */ onStructuresChanged?: () => void; @@ -267,7 +260,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc onReveal: () => deps.reveal?.(), // 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다. onPipeAdd: (chainageM, attributes) => { - if (!deps.addPipe) { + if (!deps.projectId) { showToast(PIPE_ADD_GUIDE, "error"); return; } @@ -276,38 +269,28 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc showToast("그 자리에는 계곡 통과 시설이 이미 있습니다.", "error"); return; } - const facility = attributes.facility ?? "pipe"; - deps.addPipe(at, facility); - pipeFacilities.push({ chainage_m: at, facility, options: attributes.options ?? {} }); + pipeFacilities.push({ + chainage_m: at, + facility: attributes.facility ?? "pipe", + source: "user", + options: attributes.options ?? {}, + }); pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m); currentChainageM = at; + persistPipes(); section.setPipeFacilities(pipeFacilities); pushMarks(); deps.onStructuresChanged?.(); showToast(PIPE_ADD_NOTICE, "success"); }, - // 값 수정은 B06에서도 받는다 — 조정창 구간값과 같은 저장 경로(캐시 예약 → - // [저장]·[확정])로 보낸다. 기준점 이동만 배수유역 재분할이 걸려 B05 몫이다. + // 값 수정·기준점 이동 모두 B06 에서 받는다 — 이동은 종단 끌기와 **같은 함수**를 탄다 + // (2026-09-12 일원화). 재분할은 [저장]·[확정] 뒤 서버 몫이다. onPipeUpdate: (fromChainageM, toChainageM, attributes) => { // 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는 // 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다 // (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다). - const moved = Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M; - if (moved) { - if (deps.movePipe) { - deps.movePipe(fromChainageM, toChainageM); - const hit = pipeFacilities.find( - (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, - ); - if (hit) hit.chainage_m = toChainageM; - currentChainageM = toChainageM; - section.setPipeFacilities(pipeFacilities); - pushMarks(); - showToast(PIPE_MOVE_NOTICE, "success"); - } else { - showToast(PIPE_MOVE_GUIDE, "error"); - } - } + if (Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M) + movePipeTo(fromChainageM, toChainageM); const patch = attributes.options; if (!patch || !Object.keys(patch).length) return; if (!deps.queuePipeOptions) { @@ -324,6 +307,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, ); if (hit) hit.options = { ...(hit.options ?? {}), ...patch }; + persistPipes(); section.setPipeFacilities(pipeFacilities); }, onPipeRemove: (chainageM) => removePipeAt(chainageM), @@ -440,14 +424,33 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc section.setPipeFacilities(pipeFacilities); } + /** + * 관 목록을 **미저장 스냅샷**으로 남긴다 — B05 가 쓰는 바로 그 세션이다(2026-09-12 + * 사용자: 담는 꼴도 한 벌이어야 한다). 종전에는 B06 만 「바뀐 것만」 따로 담아 + * 두 페이지가 서로 다른 자리를 보고 저장 순서에 따라 결과가 갈렸다. + */ + function persistPipes(): void { + if (!deps.projectId) return; + writePendingPipes( + deps.projectId, + pipeFacilities.map((pipe) => ({ + chainage_m: pipe.chainage_m, + source: pipe.source ?? "user", + facility: pipe.facility, + ...(pipe.start_m !== undefined ? { start_m: pipe.start_m } : {}), + ...(pipe.end_m !== undefined ? { end_m: pipe.end_m } : {}), + ...(pipe.options ? { options: pipe.options } : {}), + })), + ); + } + /** 관 옮기기 — 폼의 측점 칸과 종단 끌기가 같은 길을 탄다. 재분할은 [저장]·[확정] 뒤. */ function movePipeTo(fromChainageM: number, toChainageM: number): void { - if (!deps.movePipe) { + if (!deps.projectId) { showToast(PIPE_MOVE_GUIDE, "error"); return; } const to = Number(toChainageM.toFixed(2)); - deps.movePipe(fromChainageM, to); const hit = pipeFacilities.find( (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, ); @@ -455,6 +458,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc if (currentChainageM !== null && Math.abs(currentChainageM - fromChainageM) < PIPE_MATCH_M) currentChainageM = to; pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m); + persistPipes(); section.setPipeFacilities(pipeFacilities); pushMarks(); showToast(PIPE_MOVE_NOTICE, "success"); @@ -462,17 +466,17 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc /** 관 빼기 — 목록·폼·종단 우클릭이 같은 길을 탄다. 정본은 [저장]·[확정]에서 나간다. */ function removePipeAt(chainageM: number): void { - if (!deps.removePipe) { + if (!deps.projectId) { showToast(PIPE_ADD_GUIDE, "error"); return; } const at = Number(chainageM.toFixed(2)); - deps.removePipe(at); pipeFacilities = pipeFacilities.filter( (entry) => Math.abs(entry.chainage_m - at) >= PIPE_MATCH_M, ); if (currentChainageM !== null && Math.abs(currentChainageM - at) < PIPE_MATCH_M) currentChainageM = null; + persistPipes(); section.setPipeFacilities(pipeFacilities); pushMarks(); deps.onStructuresChanged?.(); diff --git a/B08_Quantity/B08_Quantity_Provenance.py b/B08_Quantity/B08_Quantity_Provenance.py index a33e3379..401d3d84 100644 --- a/B08_Quantity/B08_Quantity_Provenance.py +++ b/B08_Quantity/B08_Quantity_Provenance.py @@ -23,6 +23,9 @@ from typing import Any from common_util.common_util_provenance import ( TIER_CALC, + TIER_FINAL, + TIER_INPUT, + TIER_STANDARD, TIER_SURVEY, ColumnProvenance, provenance_payload, @@ -155,9 +158,316 @@ def earthwork_sheet() -> dict[str, Any]: ) +def summary_sheet() -> dict[str, Any]: + """토공집계표 — 토적표·사면표를 공종별 총량으로 모은 장. + + ⚠ **B08 에서 `final`(최종)이 처음 서는 자리다.** 토적표는 중간 장부였고, 내역서로 + 나가는 값은 여기 「계」다. 다만 무대(소운반 20m)처럼 **집계에는 오르되 내역 줄이 + 되지 않는** 줄이 있어, 그 줄의 「계」는 칸 등급 `excluded` 로 덮어쓴다(화면 배선). + """ + return sheet_provenance( + [ + ColumnProvenance( + key="group", + label="구분", + tier=TIER_STANDARD, + formula="품셈 공종 갈래 이름을 그대로 씀 (흙깎기·성토·측구터파기…)", + source="거창 실무 토공집계표 시트의 열 문구를 그대로 옮김", + code="B08_Quantity_Engine_EarthworkSummary.py:SummaryRow", + ), + ColumnProvenance( + key="item", + label="공종", + tier=TIER_STANDARD, + formula="지반 갈래 이름 (토사·연암·발파암…)", + source="갈래 수는 프로젝트 설정의 암 갈래 세트가 정함 — 코드에 안 박음", + rule="암 총량을 설계자가 넣은 갈래 비율(%)로 나눠 줄을 만듦", + code="B08_Quantity_Engine_EarthworkSummary.py:_rock_split", + ), + ColumnProvenance( + key="spec", + label="규격", + tier=TIER_STANDARD, + formula="시공 방법 표기 (기계(굴삭기)·백호우…)", + source="품셈 공종이 요구하는 규격. 암은 시공법(긁어내기/터뜨리기)이 갈림", + code="B08_Quantity_Engine_EarthworkSummary.py:SummaryRow", + ), + ColumnProvenance( + key="unit", + label="단위", + tier=TIER_STANDARD, + formula="품셈 공종이 정한 단위 (㎥·㎡·주…)", + source="단위가 다르면 내역 단가와 안 맞음 — 여기서 정하지 않고 품셈을 따름", + code="B08_Quantity_Engine_EarthworkSummary.py:SummaryRow", + ), + ColumnProvenance( + key="amount", + label="계", + tier=TIER_FINAL, + formula="토적표·사면표 총량 × 반영률(%)", + source=( + "토공은 토적표 합계, 사면은 사면표 합계. ⚠ 반영률은 법정값이 아니라 " + "설계자가 넣는 값이고 기본 100 %" + ), + rule="무대(소운반 20m)는 집계에는 오르되 내역 줄이 아님 — 그 줄은 「제외」로 섬", + code="B08_Quantity_Engine_EarthworkSummary.py:build_table", + ), + ] + ) + + +def haul_sheet() -> dict[str, Any]: + """운반거리 — (운반수단 × 지반유형)별 가중평균 줄. + + ⚠⚠ **상태가 둘이다.** 거리는 다짐상태로 재고, 내역에 오르는 수량만 자연상태(÷C)로 낸다 + (설계실무 요령 5-4-3). 이 표의 「토량」은 **다짐상태**이므로 내역서 수량과 숫자가 다르다 — + 그 어긋남이 정상이라는 것을 카드가 말해 주어야 헛걸음을 안 한다. + """ + return sheet_provenance( + [ + ColumnProvenance( + key="equipment", + label="운반수단", + tier=TIER_CALC, + formula="유토곡선이 띠마다 고른 수단 (무대·도자운반·덤프운반)", + source="B06 운반계획(HaulPlan)의 띠. 여기서 다시 고르지 않음", + code="B08_Quantity_Engine_HaulSummary.py:_legs_of", + ), + ColumnProvenance( + key="ground", + label="지반유형", + tier=TIER_CALC, + formula="띠의 토량을 절토 구간 구성비로 안분한 세 갈래 (토사·리핑암·발파암)", + source="B06 운반계획이 이미 안분해 둔 값", + code="B08_Quantity_Engine_HaulSummary.py:_legs_of", + ), + ColumnProvenance( + key="volume_m3", + label="토량", + tier=TIER_CALC, + formula="그 갈래에 속한 근거 구간들의 토량 합", + source=( + "⚠ **다짐상태**임. 내역서에 오르는 수량은 자연상태(÷토량환산계수)라 " + "숫자가 다름 — 어긋난 것이 아님" + ), + code="B08_Quantity_Engine_HaulSummary.py:summarize", + ), + ColumnProvenance( + key="average_distance_m", + label="평균운반거리", + tier=TIER_CALC, + formula="Σ(토량 × 거리) ÷ Σ(토량) — 단순평균이 아님", + source="실무 산출서가 「토량 × 거리」를 쌓아 나누는 그 식", + code="B08_Quantity_Engine_HaulSummary.py:119 average_distance_m", + ), + ColumnProvenance( + key="legs", + label="근거 구간", + tier=TIER_CALC, + formula="이 평균을 만든 구간의 개수", + source="구간 줄은 버리지 않고 표 아래 근거로 함께 냄 — 되짚을 수 있어야 함", + code="B08_Quantity_Engine_HaulSummary.py:190", + ), + ] + ) + + +def preparation_sheet() -> dict[str, Any]: + """준비공·사방공 — **못 서는 줄도 서는 장.** + + 빈 표를 내면 「빠뜨린 것」과 「원래 없는 것」이 구별되지 않는다. 그래서 값이 없는 줄도 + 상태와 사유를 달아 그대로 세운다. 값이 비어 있는 줄의 「수량」은 칸 등급 `blocked` 로 + 덮어쓴다 — **근거가 오면 채워질 자리**이지 일부러 비운 자리가 아니다. + """ + return sheet_provenance( + [ + ColumnProvenance( + key="group", + label="구분", + tier=TIER_STANDARD, + formula="준비공·사방공의 갈래 이름", + source="품셈 9장(준비공)과 배치된 구조물 종류가 줄을 만듦", + code="B08_Quantity_Engine_Preparation.py:build_table", + ), + ColumnProvenance( + key="item", + label="공종", + tier=TIER_STANDARD, + formula="품셈 공종 이름 (표토제거·제근·임목파쇄…)", + source="공종이 없으면 줄도 없음 — 화면에서 이름을 짓지 않음", + code="B08_Quantity_Engine_Preparation.py:build_table", + ), + ColumnProvenance( + key="unit", + label="단위", + tier=TIER_STANDARD, + formula="품셈 공종이 정한 단위", + source="단위가 다르면 내역 단가와 안 맞음", + code="B08_Quantity_Engine_Preparation.py:build_table", + ), + ColumnProvenance( + key="amount", + label="수량", + tier=TIER_CALC, + formula="공종마다 다름 — 표토제거는 면적 × 표토 두께(T), 제근은 임목축적 등급", + source=( + "밑수는 사면표·구조물 목록이 내고, 두께·등급·개소는 산출 조건 패널에서 " + "설계자가 넣음" + ), + rule="넣어야 할 값이 비면 줄은 서되 수량이 「-」로 남고 사유가 붙음", + code="B08_Quantity_Engine_Preparation.py:build_table", + ), + ColumnProvenance( + key="status", + label="상태", + tier=TIER_CALC, + formula="값을 세웠나 못 세웠나", + source="못 세운 줄은 옆 칸에 사유가 붙음 — 사유가 곧 무엇을 넣어야 하는지임", + code="B08_Quantity_Engine_Preparation_Status.py", + ), + ] + ) + + +def material_sheet() -> dict[str, Any]: + """자재총괄 — 구조물 원단위에서 자재별로 모은 장. 관급/사급을 줄마다 고른다.""" + return sheet_provenance( + [ + ColumnProvenance( + key="name", + label="자재", + tier=TIER_STANDARD, + formula="품셈·카탈로그의 자재 이름", + source="구조물 원단위의 성분 이름을 그대로 모음 — 여기서 이름을 짓지 않음", + code="B08_Quantity_Engine_MaterialSummary.py", + ), + ColumnProvenance( + key="unit", + label="단위", + tier=TIER_STANDARD, + formula="자재가 팔리는 단위 (㎥·본·kg…)", + source="단가가 붙는 단위와 같아야 함", + code="B08_Quantity_Engine_MaterialSummary.py", + ), + ColumnProvenance( + key="net_amount", + label="순수량", + tier=TIER_CALC, + formula="구조물마다 낸 성분 수량의 합 (할증 전)", + source="구조물 원단위 표의 「수량」을 자재 이름으로 모은 값", + code="B08_Quantity_Engine_MaterialSummary.py", + ), + ColumnProvenance( + key="surcharge_pct", + label="할증률", + tier=TIER_STANDARD, + formula="자재마다 정해진 할증률(%)", + source="할증 판(dataset)이 정함. 판에 없는 자재는 「-」로 두고 지어내지 않음", + code="B08_Quantity_Engine_MaterialSummary.py", + ), + ColumnProvenance( + key="total_amount", + label="총수량", + tier=TIER_FINAL, + formula="순수량 × (1 + 할증률)", + source="내역서·자재대로 나가는 값. 할증률이 없으면 순수량 그대로", + code="B08_Quantity_Engine_MaterialSummary.py", + ), + ColumnProvenance( + key="supply", + label="관급/사급", + tier=TIER_INPUT, + formula="설계자가 줄마다 고름", + source="자재마다 갈리는 발주 결정이라 표 안에서 고름 (2026-09-07 확정)", + code="B08_Quantity_UI_MaterialGrid.ts", + ), + ColumnProvenance( + key="install_by", + label="설치 주체", + tier=TIER_INPUT, + formula="설계자가 줄마다 고름", + source="⚠ **관급 줄에만 뜻이 있음** — 사급으로 되돌리면 값이 비워짐", + code="B08_Quantity_UI_MaterialGrid.ts", + ), + ] + ) + + +def unit_quantity_sheet() -> dict[str, Any]: + """구조물 원단위 — 치수에서 성분까지. 성분마다 갈 곳을 적는다. + + ⚠ 이 장은 **근거·출처 열을 이미 화면에 들고 있다**(2026-09-09 부터). 사전은 그 열이 + 무엇을 뜻하는지 설명하는 자리이지, 있는 값을 다시 만드는 자리가 아니다. + """ + return sheet_provenance( + [ + ColumnProvenance( + key="structure", + label="구조물", + tier=TIER_SURVEY, + formula="B05 노선에 놓인 구조물의 이름과 놓인 측점", + source="측점 표기(NO.4 ~ NO.4+10)는 화면이 만듦 — 서버는 이정만 냄", + code="B08_Quantity_Engine_Handoff_Rows_Prep.py:182", + ), + ColumnProvenance( + key="spec", + label="규격", + tier=TIER_SURVEY, + formula="구조물 제원 (길이 × 높이)", + source="B05·B06 이 배치할 때 정한 치수. 여기서 다시 정하지 않음", + code="B08_Quantity_Engine_UnitQuantity.py", + ), + ColumnProvenance( + key="component", + label="성분", + tier=TIER_STANDARD, + formula="그 구조물이 쓰는 재료·공종 이름", + source="품셈 표 또는 실무 관측 원단위표가 정함", + code="B08_Quantity_Engine_UnitQuantity.py", + ), + ColumnProvenance( + key="unit", + label="단위", + tier=TIER_STANDARD, + formula="성분이 세어지는 단위", + source="단가가 붙는 단위와 같아야 함", + code="B08_Quantity_Engine_UnitQuantity.py", + ), + ColumnProvenance( + key="amount", + label="수량", + tier=TIER_CALC, + formula="치수 전개(길이·높이로 편 식) 또는 실무 관측 원단위 × 개소", + source="어느 쪽인지는 같은 줄의 「출처」 칸이 말해 줌 (치수 전개 / 실무 관측)", + rule="치수 전개는 식이 있고, 실무 관측은 관측값이라 식이 없음 — 둘을 섞지 않음", + code="B08_Quantity_Engine_UnitQuantity.py", + ), + ColumnProvenance( + key="destination", + label="갈 곳", + tier=TIER_STANDARD, + formula="이 성분이 어느 표로 가는가 (자재총괄·공종 내역·양쪽)", + source=( + "⚠ 갈 곳이 겹치면 이중계상임 — 그것을 막으려고 성분마다 갈 곳을 적음 (PLAN 8-7)" + ), + code="B08_Quantity_Engine_Handoff_Mapping.py", + ), + ] + ) + + def quantity_provenance() -> dict[str, Any] | None: """B08 응답에 실을 사전 — **개발환경이 아니면 `None`.** 시트를 늘릴 때는 여기 한 줄만 더한다. 화면은 시트 이름으로 찾아 쓴다. """ - return provenance_payload({"earthwork": earthwork_sheet()}) + return provenance_payload( + { + "earthwork": earthwork_sheet(), + "summary": summary_sheet(), + "haul": haul_sheet(), + "preparation": preparation_sheet(), + "material": material_sheet(), + "unit_quantity": unit_quantity_sheet(), + } + ) diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index f5c55b21..344d1500 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -31,6 +31,7 @@ from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping, summarize from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material_table +from B08_Quantity.B08_Quantity_Provenance import quantity_provenance from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( ground_types_from_designs, @@ -173,15 +174,19 @@ async def get_material_summary(project_id: UUID) -> JSONResponse: # 조각이 없어도(원단위 자체가 없어 못 세운 경우) 사유는 보여야 한다. if row.get("composite_parts") or row.get("composite_not_ready") ] - return JSONResponse( - content={ - "unit_quantity": unit_table, - "material": material_table, - "composite": composite, - "skipped_structures": skipped, - "structure_count": len(structures), - } - ) + body: dict[str, Any] = { + "unit_quantity": unit_table, + "material": material_table, + "composite": composite, + "skipped_structures": skipped, + "structure_count": len(structures), + } + # 근거 사전(PLAN 8-36 ④) — ⚠ **개발환경에서만** 실린다. 운영에서는 `None` 이라 + # 칸 자체가 안 생긴다 — 화면에서 숨기는 것이 아니라 안 보내는 것이 요점이다. + provenance = quantity_provenance() + if provenance is not None: + body["provenance"] = provenance + return JSONResponse(content=body) async def project_haul_inputs(project_id: UUID) -> dict[str, Any]: diff --git a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts index 65ceec08..6ce32804 100644 --- a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts @@ -12,6 +12,12 @@ * ========================================================================== */ import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid"; +import { + attachProvenance, + markProvenanceCell, + type ProvenancePayload, + type ProvenanceSheet, +} from "@ui/ui_template_provenance"; export interface MaterialRow { name: string; @@ -27,6 +33,38 @@ export interface MaterialRow { sources: string[]; } +/** 줄 하나의 칸에 열 키를 차례대로 심는다 (집계표 쪽과 같은 틀). */ +function markRow(tr: HTMLTableRowElement, keys: readonly (string | null)[]): void { + [...tr.children].forEach((cell, index) => { + const key = keys[index]; + if (key) markProvenanceCell(cell as HTMLElement, key); + }); +} + +/** 자재총괄 열 차례 — 비고는 사전을 안 붙인다. */ +const MATERIAL_KEYS = [ + "name", + "unit", + "net_amount", + "surcharge_pct", + "total_amount", + "supply", + "install_by", + null, +] as const; + +/** 구조물 원단위 열 차례 — 근거·출처는 **이미 설명 글**이라 카드를 거듭 안 띄운다. */ +const UNIT_QUANTITY_KEYS = [ + "structure", + "spec", + "component", + "unit", + "amount", + "destination", + null, + null, +] as const; + export interface MaterialTable { columns: string[]; rows: MaterialRow[]; @@ -95,6 +133,8 @@ export interface MaterialResponse { skipped_structures: string[]; structure_count: number; /** 인계에서 온 묶음 조각 — 화면이 「무엇으로 나뉘어 서는지」를 보인다. */ + /** 근거 사전 — ⚠ **개발환경에서만** 실려 온다. 운영에서는 칸 자체가 없다. */ + provenance?: ProvenancePayload; composite?: { name: string; parts: CompositePart[]; @@ -244,6 +284,7 @@ function spreadLine(spread: MaterialTable["amount_spread"], title: string): HTML export function renderMaterialGrid( table: MaterialTable, options?: MaterialGridOptions, + sheet?: ProvenanceSheet, ): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b08-grid"; @@ -330,10 +371,12 @@ export function renderMaterialGrid( tr.append(textCell(row.install_by_label)); } tr.append(textCell(row.note, "b08-grid__note")); + markRow(tr, MATERIAL_KEYS); body.append(tr); } element.append(body); + attachProvenance(element, sheet); scroller.append(element); wrap.append(scroller); return wrap; @@ -465,11 +508,15 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement // 나중에 「이 값이 왜 이런가」를 되짚을 수 있다. const kind = component.basis_kind === "observed" ? "실무 관측" : "치수 전개"; tr.append(textCell(component.reuse_count ? `${kind} · ${component.reuse_count}회` : kind)); + markRow(tr, UNIT_QUANTITY_KEYS); body.append(tr); } } element.append(body); + // 성분이 없는 줄은 칸을 붙여 쌀으므로(`colSpan`) 짚지 않았다 — 차례가 어긋나면 + // 엉뚱한 열의 설명이 뜼다. + attachProvenance(element, response.provenance?.sheets?.unit_quantity); scroller.append(element); wrap.append(scroller); return wrap; diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index d4c548c2..6813b290 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -781,7 +781,10 @@ function buildQuantityBody( }; // 등급색 토글 — ⚠ **사전이 왔을 때만** 만든다(개발환경). 배포 빌드에서는 단추 자체가 없다. - if ((table as unknown as { provenance?: unknown } | null)?.provenance) { + if ( + (table as unknown as { provenance?: unknown } | null)?.provenance || + (material as unknown as { provenance?: unknown } | null)?.provenance + ) { tabs.append(createProvenanceToggle(body)); } @@ -799,13 +802,19 @@ function buildQuantityBody( { label: L("B08_Quantity_Tab_Summary"), build: () => - table.summary ? renderSummaryGrid(table.summary) : message(L("B08_Quantity_Grid_Empty")), + table.summary + ? renderSummaryGrid(table.summary, table.provenance?.sheets?.summary) + : message(L("B08_Quantity_Grid_Empty")), }, { label: L("B08_Quantity_Tab_Haul"), build: () => table.haul - ? renderHaulGrid(table.haul, Boolean(table.haul_available)) + ? renderHaulGrid( + table.haul, + Boolean(table.haul_available), + table.provenance?.sheets?.haul, + ) : message(L("B08_Quantity_Haul_Missing")), }, { @@ -813,7 +822,7 @@ function buildQuantityBody( build: () => { const preparation = (table as unknown as { preparation?: PreparationTable }).preparation; return preparation - ? renderPreparationGrid(preparation) + ? renderPreparationGrid(preparation, table.provenance?.sheets?.preparation) : message(L("B08_Quantity_Grid_Empty")); }, }, @@ -826,12 +835,16 @@ function buildQuantityBody( label: L("B08_Quantity_Tab_Material"), build: () => material - ? renderMaterialGrid(material.material, { - choices: draft.material_supply, - onChange: () => { - draft.dirty = true; + ? renderMaterialGrid( + material.material, + { + choices: draft.material_supply, + onChange: () => { + draft.dirty = true; + }, }, - }) + material.provenance?.sheets?.material, + ) : message(L("B08_Quantity_Material_Failed")), }, ]; diff --git a/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts b/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts index b4c03e32..7845acce 100644 --- a/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts @@ -11,11 +11,38 @@ * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { + attachProvenance, + markProvenanceCell, + type ProvenanceSheet, +} from "@ui/ui_template_provenance"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } +/** 줄 하나의 칸에 열 키를 차례대로 심는다. + + * 이 세 표는 칸을 `textCell()` 로 줄지어 붙이므로 **다 지은 뒤에 차례로 짚는 것**이 + * 가장 적게 고치는 길이다. `override` 는 **그 칸만 열 등급을 이기는** 자리다 — + * 같은 열이라도 줄마다 성격이 갈리는 것(내역 제외 줄·값을 못 세운 줄)을 위한 것이다. + */ +function markRow( + tr: HTMLTableRowElement, + keys: readonly (string | null)[], + override?: Record, +): void { + [...tr.children].forEach((cell, index) => { + const key = keys[index]; + if (key) markProvenanceCell(cell as HTMLElement, key, override?.[index]); + }); +} + +/** 열 차례 — 비고는 사유 글이라 사전을 안 붙인다(`null`). */ +const SUMMARY_KEYS = ["group", "item", "spec", "unit", "amount", null] as const; +const HAUL_KEYS = ["equipment", "ground", "volume_m3", "average_distance_m", "legs", null] as const; +const PREPARATION_KEYS = ["group", "item", "unit", "amount", "status", null] as const; + export interface SummaryRow { group: string; item: string; @@ -77,7 +104,7 @@ function textCell(text: string, className?: string): HTMLTableCellElement { } /** 토공집계표 — 실무 시트와 같은 여섯 열. */ -export function renderSummaryGrid(table: SummaryTable): HTMLElement { +export function renderSummaryGrid(table: SummaryTable, sheet?: ProvenanceSheet): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b08-grid"; @@ -114,17 +141,25 @@ export function renderSummaryGrid(table: SummaryTable): HTMLElement { note.prepend(tag); } tr.append(note); + // ⚠ 무대(소운반 20m)처럼 **집계에는 오르되 내역 줄이 아닌** 줄은 「계」가 + // 최종이 아니라 **제외**임(품셀 1-2-7). 못 세운 것과 뜻이 정반대라 칸 등급을 갈라 준다. + markRow(tr, SUMMARY_KEYS, row.in_bill ? undefined : { 4: "excluded" }); body.append(tr); } element.append(head, body); + attachProvenance(element, sheet); scroller.append(element); wrap.append(scroller); return wrap; } /** 운반거리 — 내역 줄(가중평균)과 근거 줄을 나눠 보인다. */ -export function renderHaulGrid(table: HaulTable, available: boolean): HTMLElement { +export function renderHaulGrid( + table: HaulTable, + available: boolean, + sheet?: ProvenanceSheet, +): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b08-grid"; @@ -180,10 +215,13 @@ export function renderHaulGrid(table: HaulTable, available: boolean): HTMLElemen note.append(document.createTextNode(" 품셈 1-2-7 소운반 20m 이내는 품에 포함")); } tr.append(note); + // 내역 줄이 안 되는 줄은 토량·거리 둘 다 「제외」임 — 값은 검산에만 쓴다. + markRow(tr, HAUL_KEYS, row.in_bill ? undefined : { 2: "excluded", 3: "excluded" }); body.append(tr); } element.append(head, body); + attachProvenance(element, sheet); scroller.append(element); wrap.append(scroller); return wrap; @@ -212,7 +250,10 @@ export interface PreparationTable { * 빈 표를 내면 「빠뜨린 것」과 「원래 없는 것」이 구별되지 않는다. 그래서 값이 없는 줄도 * 상태와 사유를 달아 그대로 세운다. */ -export function renderPreparationGrid(table: PreparationTable): HTMLElement { +export function renderPreparationGrid( + table: PreparationTable, + sheet?: ProvenanceSheet, +): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b08-grid"; @@ -251,10 +292,13 @@ export function renderPreparationGrid(table: PreparationTable): HTMLElement { note.append(document.createTextNode(` (참고 면적 ${num(row.reference_amount, 1)})`)); } tr.append(note); + // 값을 못 세운 줄의 「수량」은 **막힘** — 근거가 오면 채워질 자리라 제외과 갈라 보인다. + markRow(tr, PREPARATION_KEYS, row.amount === null ? { 3: "blocked" } : undefined); body.append(tr); } element.append(head, body); + attachProvenance(element, sheet); scroller.append(element); wrap.append(scroller); return wrap; diff --git a/resources/tester/test_b06_culvert_edit_queue.py b/resources/tester/test_b06_culvert_edit_queue.py index 0bca3917..5a167fda 100644 --- a/resources/tester/test_b06_culvert_edit_queue.py +++ b/resources/tester/test_b06_culvert_edit_queue.py @@ -1,9 +1,14 @@ -"""B06 종단에서 넣고 뺀 **계곡 통과 시설**이 세션에 제대로 쌓이는지(2026-09-12 사용자 지시). +"""관 편집이 **한 자리에만** 쌓이는지(2026-09-12 사용자: 담는 꼴도 한 벌이어야 한다). -B05·B06 은 한 페이지이므로 관 추가·삭제도 양쪽에서 되어야 한다. 다만 정본에 바로 쓰지 -않고 **세션에 예약**했다가 [저장]·[확정]에서 한 번에 내보낸다(CLAUDE.md 5장). 넣었다 바로 -빼면 예약만 걷어 내야 하고, 뺐다 다시 넣어도 마찬가지다 — 그러지 않으면 저장 때 있지도 -않은 관을 지우거나 같은 자리에 둘이 선다. +B05 는 관 목록 전체를 세션 스냅샷(`pipes`)으로 담고, B06 은 바뀐 것만 따로 담고 있었다. +저장 순서에 따라 한쪽이 다른 쪽을 덮어 **넣은 관이 사라지는** 일이 생겼다. 그래서 관 +목록은 두 페이지가 같은 스냅샷을 쓰도록 합쳤다. + +여기서 지키는 것은 둘이다. + ① 저장기(`createCulvertOptionWriter`)에는 **관을 넣고 빼는 길이 없다** — 남아 있으면 + 관 목록을 담는 자리가 다시 둘이 된다. + ② 옛 세션에 남아 있던 **이동 예약은 저장 때 흘려보낸다** — 합치기 전에 만들어 둔 + 예약이 조용히 사라지면 안 된다. 실제 화면 코드를 그대로 컴파일해 Node 로 돌린다(다단 하한 시험과 같은 방식). """ @@ -25,15 +30,28 @@ const { writeFileSync } = require("node:fs"); const Module = require("node:module"); // 화면 코드가 번들러 별칭(`@config/…`)을 쓴다 — Node 에는 없으니 빈 껍데기로 돌려준다. -// 이 시험이 보는 것은 세션 예약 로직뿐이라 값은 안 쓴다. const load = Module._load; +const saved = []; Module._load = function (request, parent, isMain) { if (request.startsWith("@config/") || request.startsWith("@util/") || request.startsWith("@ui/")) return new Proxy({}, { get: () => () => undefined }); - return load.call(this, request, parent, isMain); + const mod = load.call(this, request, parent, isMain); + // 관 정본 읽기·쓰기는 서버로 나가므로 가로챈다. + if (request.includes("B04_PreProcess_Api_Fetch")) { + return { + ...mod, + fetchDetailPipePoints: async () => ({ + pipe_points: [ + { chainage_m: 120, lonlat: [0, 0], source: "stream" }, + { chainage_m: 200, lonlat: [0, 0], source: "stream" }, + ], + }), + saveDetailPipePoints: async (_projectId, points) => saved.push(points), + }; + } + return mod; }; -// 세션 저장소 흉내 — 화면 코드는 window.sessionStorage 만 쓴다. const store = new Map(); global.window = { sessionStorage: { @@ -43,39 +61,36 @@ global.window = { }, }; -const { createCulvertOptionWriter } = require(process.argv[3]); -const EDIT_KEY = "edit"; -const writer = createCulvertOptionWriter( +const api = require(process.argv[3]); +const writer = api.createCulvertOptionWriter( () => "p1", () => "opt", undefined, () => "move", - () => EDIT_KEY, ); -const read = () => JSON.parse(store.get(EDIT_KEY) ?? '{"adds":[],"removes":[]}'); -const steps = {}; -writer.queueAdd(120, "pipe"); -writer.queueAdd(140); -steps.afterTwoAdds = read(); +// ① 관을 넣고 빼는 길이 저장기에 남아 있으면 안 된다. +const methods = Object.keys(writer).sort(); -// 방금 넣은 자리를 다시 빼면 **추가 예약만** 사라진다(정본에는 아직 없다). -writer.queueRemove(140); -steps.afterUndoAdd = read(); +// ② 합치기 전 세션에 남아 있던 이동 예약은 저장 때 흘려보낸다. +store.set("move", JSON.stringify({ "120.00": 135 })); -// 정본에 있던 관을 빼면 삭제 예약으로 남는다. -writer.queueRemove(200); -steps.afterRemove = read(); - -// 뺀 자리를 다시 넣으면 삭제 예약만 걷어 낸다(새로 만들지 않는다). -writer.queueAdd(200); -steps.afterUndoRemove = read(); - -// 같은 자리를 두 번 넣어도 하나만 남는다. -writer.queueAdd(120); -steps.afterDuplicateAdd = read(); - -writeFileSync(process.argv[2], JSON.stringify(steps)); +api + .flushCulvertOptions("p1", "opt", "move") + .then(() => { + writeFileSync( + process.argv[2], + JSON.stringify({ + methods, + savedChainages: (saved[0] ?? []).map((point) => point.chainage_m), + moveLeft: store.get("move") ?? null, + }), + ); + }) + .catch((error) => { + writeFileSync(process.argv[2], JSON.stringify({ error: String(error) })); + process.exitCode = 1; + }); """ @@ -112,24 +127,13 @@ def _run(tmp_path: Path) -> dict: return json.loads(result.read_text(encoding="utf-8")) -def test_추가와_삭제_예약이_서로를_걷어_낸다(tmp_path: Path) -> None: - steps = _run(tmp_path) +def test_관_목록을_담는_자리는_하나뿐이다(tmp_path: Path) -> None: + result = _run(tmp_path) + assert "error" not in result, result.get("error") - # 넣은 둘이 그대로 쌓인다. - assert [entry["chainage_m"] for entry in steps["afterTwoAdds"]["adds"]] == [120.0, 140.0] - assert steps["afterTwoAdds"]["adds"][0]["facility"] == "pipe" - assert steps["afterTwoAdds"]["removes"] == [] + # ① 저장기에는 구간값 예약과 내보내기만 있다 — 관을 넣고 빼는 길은 스냅샷 한 곳뿐이다. + assert result["methods"] == ["flush", "queue"] - # 방금 넣은 자리를 빼면 추가 예약만 사라지고 삭제 예약은 안 생긴다. - assert [entry["chainage_m"] for entry in steps["afterUndoAdd"]["adds"]] == [120.0] - assert steps["afterUndoAdd"]["removes"] == [] - - # 정본에 있던 관은 삭제 예약으로 남는다. - assert steps["afterRemove"]["removes"] == ["200.00"] - - # 뺀 자리를 다시 넣으면 삭제 예약만 걷힌다 — 새 관을 만들지 않는다. - assert steps["afterUndoRemove"]["removes"] == [] - assert [entry["chainage_m"] for entry in steps["afterUndoRemove"]["adds"]] == [120.0] - - # 같은 자리를 두 번 넣어도 하나만 남는다. - assert [entry["chainage_m"] for entry in steps["afterDuplicateAdd"]["adds"]] == [120.0] + # ② 옛 세션의 이동 예약은 저장 때 반영되고 비워진다. + assert result["savedChainages"] == [135, 200] + assert result["moveLeft"] is None diff --git a/resources/tester/test_b08_provenance.py b/resources/tester/test_b08_provenance.py index 3f6bcd95..597f3fc3 100644 --- a/resources/tester/test_b08_provenance.py +++ b/resources/tester/test_b08_provenance.py @@ -11,8 +11,15 @@ import dataclasses import pytest +from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryRow from B08_Quantity.B08_Quantity_Engine_EarthworkTable import EarthworkRow -from B08_Quantity.B08_Quantity_Provenance import earthwork_sheet, quantity_provenance +from B08_Quantity.B08_Quantity_Engine_HaulSummary import HaulSummaryRow +from B08_Quantity.B08_Quantity_Provenance import ( + earthwork_sheet, + haul_sheet, + quantity_provenance, + summary_sheet, +) from common_util import common_util_provenance as provenance_module from common_util.common_util_provenance import ( TIERS, @@ -30,16 +37,38 @@ def test_토적표_사전이_엔진_열과_같은_이름을_쓴다(): assert not 낯선_키, f"사전에 있는데 토적표 줄에 없는 열: {낯선_키}" +def _모든_열(): + """개발환경에서 실리는 여섯 장을 한 줄로 펜다 — (장 이름, 열 키, 몸통).""" + payload = quantity_provenance() + assert payload is not None + for sheet_name, sheet in payload["sheets"].items(): + for key, body in sheet["columns"].items(): + yield sheet_name, key, body + + +def test_여섯_장이_다_실린다(): + payload = quantity_provenance() + assert payload is not None + assert set(payload["sheets"]) == { + "earthwork", + "summary", + "haul", + "preparation", + "material", + "unit_quantity", + } + + def test_사전_등급이_전부_아는_값이다(): - for key, body in earthwork_sheet()["columns"].items(): - assert body["tier"] in TIERS, f"{key} 의 등급이 모르는 값: {body['tier']}" + for sheet_name, key, body in _모든_열(): + assert body["tier"] in TIERS, f"{sheet_name}.{key} 등급이 모르는 값: {body['tier']}" def test_사전_열마다_이름과_식이_비어_있지_않다(): """빈 카드는 「설명이 있다」는 거짓만 남긴다 — 적을 것이 없으면 열을 아예 안 넣는다.""" - for key, body in earthwork_sheet()["columns"].items(): - assert body.get("label"), f"{key} 에 이름이 없음" - assert body.get("formula"), f"{key} 에 식이 없음" + for sheet_name, key, body in _모든_열(): + assert body.get("label"), f"{sheet_name}.{key} 에 이름이 없음" + assert body.get("formula"), f"{sheet_name}.{key} 에 식이 없음" def test_같은_열을_두_번_적으면_막는다(): @@ -79,3 +108,25 @@ def test_고르는_자리의_채택_규칙은_적었을_때만_실린다(): key="b", label="나", tier="calc", formula="x", rule="A·B 중 작은 쪽" ).as_dict() assert 있는_것["rule"] == "A·B 중 작은 쪽" + + +def test_집계표_사전이_엔진_열과_같은_이름을_쓴다(): + row_fields = {field.name for field in dataclasses.fields(SummaryRow)} + 낯선_키 = sorted(set(summary_sheet()["columns"]) - row_fields) + assert not 낯선_키, f"사전에 있는데 집계표 줄에 없는 열: {낯선_키}" + + +def test_운반표_사전이_엔진_열과_같은_이름을_쓴다(): + """⚠ `average_distance_m` 은 필드가 아니라 property 라 필드 목록만 보면 놓친다.""" + names = {field.name for field in dataclasses.fields(HaulSummaryRow)} + names |= {n for n in dir(HaulSummaryRow) if not n.startswith("_")} + 낯선_키 = sorted(set(haul_sheet()["columns"]) - names) + assert not 낯선_키, f"사전에 있는데 운반표 줄에 없는 열: {낯선_키}" + + +def test_집계표에_최종이_서고_토적표에는_없다(): + """등급 여섯은 한 장이 아니라 **두 장을 합쳐야** 다 쓰인다 — 그 갈림을 시험으로 박는다.""" + 토적표 = {body["tier"] for body in earthwork_sheet()["columns"].values()} + 집계표 = {body["tier"] for body in summary_sheet()["columns"].values()} + assert "final" not in 토적표, "토적표는 중간 장부라 최종 열이 없어야 함" + assert "final" in 집계표, "내역서로 나가는 값은 집계표 「계」에서 서야 함"