From 11161659f54c0133ae2f8ab370c6f9c0e2df5bf2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 12 Sep 2026 15:50:02 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(b06):=20=EC=A2=85=EB=8B=A8=20=EA=B7=B8?= =?UTF-8?q?=EB=9E=98=ED=94=84=20=EC=9A=B0=ED=81=B4=EB=A6=AD=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=C2=B7=EA=B3=84=EA=B3=A1?= =?UTF-8?q?=20=ED=86=B5=EA=B3=BC=20=EC=8B=9C=EC=84=A4=EC=9D=84=20=EB=84=A3?= =?UTF-8?q?=EA=B3=A0=20=EB=B9=BC=EA=B2=8C=20=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B05 와 B06 은 한 페이지인데 구조물을 넣고 빼는 자리가 B05 뿐이라 화면을 오가야 했음. 종단 그래프 렌더러·알약 레인·좌측 배치 폼은 이미 공용이었으므로, 빠져 있던 조작층만 B05 와 같은 부품(mountStructureMenu)으로 B06 에도 붙임. 관 추가·삭제는 이동과 같은 예약 경로를 씀 — 세션에 쌓고 [저장]·[확정]에서 정본 (pipe_points.json)으로 나가며, 세부 배수유역 재분할은 그때 서버가 처리함. 넣었다 바로 빼거나 뺐다 다시 넣으면 예약만 걷어 내 정본이 흔들리지 않음. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR --- A00_Common/b_page_state.ts | 2 + .../B06_Section_Api_Culvert_Options.ts | 95 +++++++++++- B06_Section/B06_Section_UI_Page.ts | 13 ++ .../B06_Section_UI_Page_Station_Controls.ts | 8 ++ .../B06_Section_UI_Page_Structures_Panel.ts | 64 ++++++++- B06_Section/B06_Section_UI_Section_View.ts | 24 ++++ .../B06_Section_UI_Section_View_Menu.ts | 83 +++++++++++ .../tester/test_b06_culvert_edit_queue.py | 135 ++++++++++++++++++ 8 files changed, 417 insertions(+), 7 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Section_View_Menu.ts create mode 100644 resources/tester/test_b06_culvert_edit_queue.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 9c05c761..53711a03 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -107,6 +107,8 @@ 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/B06_Section/B06_Section_Api_Culvert_Options.ts b/B06_Section/B06_Section_Api_Culvert_Options.ts index 130274e0..eae5d769 100644 --- a/B06_Section/B06_Section_Api_Culvert_Options.ts +++ b/B06_Section/B06_Section_Api_Culvert_Options.ts @@ -31,6 +31,11 @@ export interface CulvertOptionWriter { /** 기준 측점 이동을 예약한다(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; } @@ -75,16 +80,59 @@ 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); - if (!projectId || (Object.keys(pending).length === 0 && Object.keys(moves).length === 0)) return; + const edits = readEdits(editKey); + if ( + !projectId || + (Object.keys(pending).length === 0 && + Object.keys(moves).length === 0 && + !edits.adds.length && + !edits.removes.length) + ) + return; const current = await fetchDetailPipePoints(projectId); - let touched = false; + let touched = edits.adds.length > 0 || edits.removes.length > 0; const points: DetailPipeInput[] = current.pipe_points.map((point) => { // 좌표는 서버가 다시 계산하므로 싣지 않는다(DetailPipeInput 규약). const { lonlat: _lonlat, ...input } = point; @@ -100,9 +148,23 @@ export async function flushCulvertOptions( if (movedTo !== undefined) next.chainage_m = movedTo; return next; }); - if (touched) await saveDetailPipePoints(projectId, points); + // 지울 것을 걷어 내고 넣을 것을 붙인다 — 서버는 이 목록을 그대로 정본으로 삼는다. + 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); writePending(sessionKey, {}); writePending(moveKey, {}); + writeEdits(editKey, { adds: [], removes: [] }); } /** @@ -115,6 +177,8 @@ export function createCulvertOptionWriter( onError?: (message: string) => void, /** 이동 예약 세션 키 — 없으면 이동은 메모리에도 남지 않는다. */ moveKey: () => string | null = () => null, + /** 추가·삭제 예약 세션 키 — 없으면 추가·삭제를 받지 않는다. */ + editKey: () => string | null = () => null, ): CulvertOptionWriter { return { queue(chainageM, patch) { @@ -134,9 +198,32 @@ export function createCulvertOptionWriter( 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()); + await flushCulvertOptions(projectId(), sessionKey(), moveKey(), editKey()); } 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 178b74b8..5b9d296e 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -203,6 +203,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { 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"); @@ -483,6 +485,17 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { ); // 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일). structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types); + // 종단 그래프 우클릭 — B05 와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자: B05·B06 은 한 + // 페이지라 같은 자리에서 되어야 한다). 어느 길로 들어와도 좌측 「구조물 배치」와 + // 같은 함수를 타므로 목록·폼·알약이 함께 선다. + sectionView.setStructureEdit({ + addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM), + removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM), + addStructureType: (chainageM, typeId) => structuresPanel.addStructureAt(chainageM, typeId), + removeStructure: (structureId) => { + structuresPanel.removeStructureById(structureId); + }, + }); // 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리). const pipeOptionsContext: PipeOptionsContext = { detail: () => sectionDetail, diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 095e6be5..e777ea9e 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -45,6 +45,7 @@ export interface StationControlDeps { | "extraspan" | "culvertopt" | "culvertmove" + | "culvertedit" | "revetlink" | "fordadjust" | "boxadjust", @@ -96,6 +97,10 @@ export interface StationControls { 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; @@ -432,6 +437,7 @@ 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; @@ -619,6 +625,8 @@ export function createStationControls(deps: StationControlDeps): StationControls 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 b3bb3fd4..1a824da0 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -39,8 +39,13 @@ import { import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { StationControls } from "./B06_Section_UI_Page_Station_Controls"; -const PIPE_ADD_GUIDE = "계곡 통과 시설의 추가·삭제는 B05(종단) 화면에서 합니다."; +const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를 먼저 여세요."; const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다."; +/** B05·B06 은 한 페이지라 넣고 빼는 자리도 같아야 한다(2026-09-12 사용자). 재분할은 저장 뒤. */ +const PIPE_ADD_NOTICE = + "계곡 통과 시설을 넣었습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다."; +const PIPE_REMOVE_NOTICE = + "계곡 통과 시설을 뺐습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다."; /** 고른 것 없이 폼만 만졌을 때 — 값이 어디로도 가지 않으므로 이유를 알린다. */ const PIPE_PICK_GUIDE = "먼저 횡단도나 목록에서 구조물을 고르세요 — 고른 것에만 값이 반영됩니다."; const PIPE_MOVE_NOTICE = @@ -75,6 +80,10 @@ export interface B06StructuresPanelDeps { /** 기준 측점 이동을 예약한다 — 세부 배수유역 재분할은 [저장]·[확정] 때 서버가 * 관 목록을 다시 받아 처리한다(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; @@ -97,6 +106,11 @@ export interface B06StructuresPanel { showAtChainage: (chainageM: number | null) => void; /** 지금 폼에 올라온 계곡 통과 시설의 누가거리(없으면 null). */ currentChainage: () => number | null; + /** 종단 그래프 우클릭이 쓰는 길 — 좌측 폼·목록과 **같은 함수**를 탄다(2026-09-12). */ + addStructureAt: (chainageM: number, typeId: string) => void; + removeStructureById: (structureId: string) => boolean; + addPipeAt: (chainageM: number) => void; + removePipeAt: (chainageM: number) => void; /** 유입구 "구조"에 합쳐진 B06 유입측 형식 — 조정창 값과 맞춘다(2026-08-29 지시 5). */ facility: { setInletStructure: (value: "auto" | "revet" | "I" | "L" | "U") => void; @@ -248,7 +262,27 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc }, getInterval: deps.stationInterval, onReveal: () => deps.reveal?.(), - onPipeAdd: () => showToast(PIPE_ADD_GUIDE, "error"), + // 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다. + onPipeAdd: (chainageM, attributes) => { + if (!deps.addPipe) { + showToast(PIPE_ADD_GUIDE, "error"); + return; + } + const at = Number(chainageM.toFixed(2)); + if (pipeFacilities.some((entry) => Math.abs(entry.chainage_m - at) < PIPE_MATCH_M)) { + showToast("그 자리에는 계곡 통과 시설이 이미 있습니다.", "error"); + return; + } + const facility = attributes.facility ?? "pipe"; + deps.addPipe(at, facility); + pipeFacilities.push({ chainage_m: at, facility, options: attributes.options ?? {} }); + pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m); + currentChainageM = at; + section.setPipeFacilities(pipeFacilities); + pushMarks(); + deps.onStructuresChanged?.(); + showToast(PIPE_ADD_NOTICE, "success"); + }, // 값 수정은 B06에서도 받는다 — 조정창 구간값과 같은 저장 경로(캐시 예약 → // [저장]·[확정])로 보낸다. 기준점 이동만 배수유역 재분할이 걸려 B05 몫이다. onPipeUpdate: (fromChainageM, toChainageM, attributes) => { @@ -289,7 +323,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc if (hit) hit.options = { ...(hit.options ?? {}), ...patch }; section.setPipeFacilities(pipeFacilities); }, - onPipeRemove: () => showToast(PIPE_ADD_GUIDE, "error"), + onPipeRemove: (chainageM) => removePipeAt(chainageM), onPipeSelect: (chainageM) => { // 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다. writeStructurePick(deps.projectId, chainageM); @@ -400,11 +434,35 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc section.setPipeFacilities(pipeFacilities); } + /** 관 빼기 — 목록·폼·종단 우클릭이 같은 길을 탄다. 정본은 [저장]·[확정]에서 나간다. */ + function removePipeAt(chainageM: number): void { + if (!deps.removePipe) { + 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; + section.setPipeFacilities(pipeFacilities); + pushMarks(); + deps.onStructuresChanged?.(); + showToast(PIPE_REMOVE_NOTICE, "success"); + } + return { root: section.root, listRoot: section.listRoot, load, currentChainage: () => currentChainageM, + // 종단 우클릭 → 좌측 폼·목록과 같은 함수. 폼이 열리고 목록·알약도 함께 선다. + addStructureAt: (chainageM, typeId) => section.addAt(chainageM, typeId), + removeStructureById: (structureId) => section.removeById(structureId), + addPipeAt: (chainageM) => section.addAt(chainageM, "pipe"), + removePipeAt: (chainageM) => removePipeAt(chainageM), facility: { setInletStructure: (value) => section.facility.setInletStructure(value), onInletStructureChange: (handler) => section.facility.onInletStructureChange(handler), diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 0d5111e9..d5b940e1 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -50,6 +50,10 @@ import { buildStructureLane, STRUCTURE_LANE_HEIGHT_PX, } from "../B05_Profile/B05_Profile_UI_Structures_Marks"; +import { + mountSectionStructureMenu, + type SectionStructureEdit, +} from "./B06_Section_UI_Section_View_Menu"; import { structureAnchorM, type StructureInstance, @@ -131,6 +135,9 @@ export interface SectionViewController { structures: ReadonlyArray, types: ReadonlyArray, ) => void; + /** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길 — 없으면 메뉴가 안 뜬다 + * (2026-09-12 B05·B06 일원화). */ + setStructureEdit: (edit: SectionStructureEdit | null) => void; dispose: () => void; } @@ -163,6 +170,7 @@ export function createSectionView( let currentNaturalSpoilSlope: number | undefined; let markStructures: ReadonlyArray = []; let markTypes: ReadonlyArray = []; + let structureEdit: SectionStructureEdit | null = null; let renderWidth = 0; let resizeTimer = 0; let panelResizeTimer = 0; @@ -554,6 +562,18 @@ export function createSectionView( } chartWrap.replaceChildren(...nodes); + // 종단 그래프 우클릭 — B05 와 같은 메뉴다(가까운 구조물이 있으면 삭제, 없으면 + // 구조물군 → 종류 2단 추가). 그래프를 다시 그릴 때마다 붙인다(상태를 안 남긴다). + if (structureEdit) { + mountSectionStructureMenu(chartWrap, { + structures: markStructures, + types: markTypes, + x: chart.toX, + chainageAt: chart.toChainage, + maxChainageM: chart.maxChainageM, + edit: structureEdit, + }); + } // 유토곡선 그래프는 B06 에서 **그리지 않는다**(2026-09-06 사용자 지시) — 자리를 많이 // 먹는데 정작 필요한 값은 마지막 지점 누가토량 하나다. 곡선은 B05 유토곡선 패널에서 // 펼쳐 본다. 여기서는 같은 계산으로 값만 내 좌측 상단 배지에 올린다(기준: 횡단). @@ -753,6 +773,10 @@ export function createSectionView( setStationSelectListener(listener) { stationSelectListener = listener; }, + setStructureEdit(edit) { + structureEdit = edit; + drawPanel(); + }, setStructureMarks(structures, types) { markStructures = structures; markTypes = types; diff --git a/B06_Section/B06_Section_UI_Section_View_Menu.ts b/B06_Section/B06_Section_UI_Section_View_Menu.ts new file mode 100644 index 00000000..6a0c1a24 --- /dev/null +++ b/B06_Section/B06_Section_UI_Section_View_Menu.ts @@ -0,0 +1,83 @@ +/* ============================================================================= + * B06_Section_UI_Section_View_Menu.ts + * B06 종단 그래프 위 **우클릭 메뉴** — B05 와 같은 부품(`mountStructureMenu`)을 그대로 쓴다. + * + * 왜 여기서도 여나(2026-09-12 사용자) — B05·B06 은 한 페이지다. 같은 그래프를 보면서 + * 한쪽에서만 넣고 뺄 수 있으면 사용자가 화면을 오가야 하고 직관도 깨진다. + * + * 정본에 바로 쓰지 않는다 — 추가·삭제·이동은 **세션에 예약**하고 [저장]·[확정]에서 + * 관 정본(`pipe_points.json`)으로 나간다(CLAUDE.md 5장). 세부 배수유역 재분할은 그때 + * 서버가 관 목록을 다시 받아 처리한다. + * ========================================================================== */ + +import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; +import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures"; +import type { IrregularStation } from "../B05_Profile/B05_Profile_UI_IrregularStations"; +import { mountStructureMenu } from "../B05_Profile/B05_Profile_UI_Profile_Structures"; + +/** 종단 그래프에서 구조물을 넣고 빼는 길 — 페이지가 물려 준다. 없으면 메뉴를 안 붙인다. */ +export interface SectionStructureEdit { + /** 관(계곡 통과 시설)을 그 자리에 넣는다. */ + addPipe: (chainageM: number) => void; + /** 관을 뺀다. */ + removePipe: (chainageM: number) => void; + /** 레지스트리 종류를 골라 구조물을 넣는다(좌측 「구조물 배치」와 같은 2단 메뉴). */ + addStructureType: (chainageM: number, typeId: string) => void; + /** 구조물(관 아님)을 뺀다. */ + removeStructure: (structureId: string) => void; +} + +/** 알약·우클릭이 쓰는 「관인가」 판정 — 관은 정본이 달라 지우는 길도 다르다. */ +export function isPipeMark(structure: StructureInstance): boolean { + return String(structure.structure_id ?? "").startsWith("pipe-"); +} + +/** + * 종단 그래프 칸에 우클릭 메뉴를 붙인다. 그래프는 그릴 때마다 새로 만들어지므로 이 + * 함수도 그때마다 다시 부른다 — 상태를 밖에 남기지 않는다(B05 와 같은 규칙). + */ +export function mountSectionStructureMenu( + host: HTMLElement, + input: { + structures: ReadonlyArray; + types: ReadonlyArray; + x: (chainageM: number) => number; + chainageAt: (px: number) => number; + maxChainageM: number; + edit: SectionStructureEdit; + }, +): void { + // 메뉴가 쓰는 최소 정보만 옮겨 담는다 — B06 에는 B05 의 비정규 측점 목록이 없다. + const stations: IrregularStation[] = input.structures.map((structure) => { + const chainage = structureAnchorM(structure); + const pipe = isPipeMark(structure); + return { + id: String(structure.structure_id), + station: 0, + remainder: 0, + chainage_m: chainage, + structure: + input.types.find((type) => type.type_id === structure.type_id)?.name ?? + String(structure.type_id), + origin: pipe ? "pipe" : "user", + } as IrregularStation; + }); + + mountStructureMenu(host, { + stations, + x: input.x, + chainageAt: input.chainageAt, + maxChainageM: input.maxChainageM, + onRemove: (station) => { + if (station.origin === "pipe") input.edit.removePipe(station.chainage_m); + else input.edit.removeStructure(station.id); + }, + onAddPipe: (chainageM) => input.edit.addPipe(chainageM), + structureTypes: input.types.map((type) => ({ + type_id: type.type_id, + group: type.group, + name: type.name, + })), + onAddStructureType: (chainageM, typeId) => input.edit.addStructureType(chainageM, typeId), + }); +} diff --git a/resources/tester/test_b06_culvert_edit_queue.py b/resources/tester/test_b06_culvert_edit_queue.py new file mode 100644 index 00000000..0bca3917 --- /dev/null +++ b/resources/tester/test_b06_culvert_edit_queue.py @@ -0,0 +1,135 @@ +"""B06 종단에서 넣고 뺀 **계곡 통과 시설**이 세션에 제대로 쌓이는지(2026-09-12 사용자 지시). + +B05·B06 은 한 페이지이므로 관 추가·삭제도 양쪽에서 되어야 한다. 다만 정본에 바로 쓰지 +않고 **세션에 예약**했다가 [저장]·[확정]에서 한 번에 내보낸다(CLAUDE.md 5장). 넣었다 바로 +빼면 예약만 걷어 내야 하고, 뺐다 다시 넣어도 마찬가지다 — 그러지 않으면 저장 때 있지도 +않은 관을 지우거나 같은 자리에 둘이 선다. + +실제 화면 코드를 그대로 컴파일해 Node 로 돌린다(다단 하한 시험과 같은 방식). +""" + +import json +import subprocess +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +MODULE = PROJECT_ROOT / "B06_Section" / "B06_Section_Api_Culvert_Options.ts" + +_RUNNER = """ +const { writeFileSync } = require("node:fs"); +const Module = require("node:module"); + +// 화면 코드가 번들러 별칭(`@config/…`)을 쓴다 — Node 에는 없으니 빈 껍데기로 돌려준다. +// 이 시험이 보는 것은 세션 예약 로직뿐이라 값은 안 쓴다. +const load = Module._load; +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); +}; + +// 세션 저장소 흉내 — 화면 코드는 window.sessionStorage 만 쓴다. +const store = new Map(); +global.window = { + sessionStorage: { + getItem: (key) => (store.has(key) ? store.get(key) : null), + setItem: (key, value) => store.set(key, String(value)), + removeItem: (key) => store.delete(key), + }, +}; + +const { createCulvertOptionWriter } = require(process.argv[3]); +const EDIT_KEY = "edit"; +const writer = 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(); + +// 방금 넣은 자리를 다시 빼면 **추가 예약만** 사라진다(정본에는 아직 없다). +writer.queueRemove(140); +steps.afterUndoAdd = read(); + +// 정본에 있던 관을 빼면 삭제 예약으로 남는다. +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)); +""" + + +def _run(tmp_path: Path) -> dict: + out = tmp_path / "out" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + "--ignoreConfig", + "--target", + "es2022", + "--module", + "commonjs", + "--skipLibCheck", + "--outDir", + str(out), + str(MODULE), + ], + cwd=str(PROJECT_ROOT), + check=False, + capture_output=True, + ) + compiled = next(out.rglob("B06_Section_Api_Culvert_Options.js"), None) + assert compiled is not None, "화면 코드가 JS 로 안 나옴 — tsc 실패" + (out / "runner.cjs").write_text(_RUNNER, encoding="utf-8") + result = tmp_path / "result.json" + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(out / "runner.cjs"), str(result), str(compiled)], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def test_추가와_삭제_예약이_서로를_걷어_낸다(tmp_path: Path) -> None: + steps = _run(tmp_path) + + # 넣은 둘이 그대로 쌓인다. + 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 [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] From fd3b38f846c42f422c783c4ce359aa848b7e8c55 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 12 Sep 2026 15:50:07 +0900 Subject: [PATCH 2/5] auto: 2026-09-12 15:50 (ESD_LAPTOP) --- .../2026-09-12_B09_데이터_원천_조사.md | 428 ++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 docs/raw/verification/2026-09-12_B09_데이터_원천_조사.md diff --git a/docs/raw/verification/2026-09-12_B09_데이터_원천_조사.md b/docs/raw/verification/2026-09-12_B09_데이터_원천_조사.md new file mode 100644 index 00000000..c532aac6 --- /dev/null +++ b/docs/raw/verification/2026-09-12_B09_데이터_원천_조사.md @@ -0,0 +1,428 @@ +# B09 원가계산 화면 — 데이터 원천 조사표 + +**날짜** 2026-09-12 · **창** 데스크탑 보조(`sub_desktop_1`) · **근거** PLAN.md 8-36 ⑥ 앞작업 +(데스크탑 메인 창 요청, 병렬 조사) + +**무엇을 적었나** — B09 화면에 **실제로 뜨는 열마다** 넷을 적음. +① 열 이름(화면에 보이는 그대로) ② 원천(`파일:줄`) ③ 식(사람이 읽는 한 줄) ④ 등급 후보. + +**등급 여섯** — `입력`(사용자가 넣음) · `측량`(앞 단계가 낳음) · `기준`(법·품셈·단가판, 고정) · +`계산`(중간값) · `최종`(내역서·원가로 나가는 값) · `막힘`(근거가 없어 못 세움). + +⚠ **여섯에 안 맞는 열은 억지로 끼우지 않고 그대로 적었음.** 6장에 모아 둠 — 이 조사의 값어치는 +거기에 있음. + +--- + +## 0. 탭 구성 — 열 개 중 아홉이 살아 있음 + +`B09_Estimation_UI_Page.ts:610` `TAB_KEYS` · 이름은 `ui_template/ui_template_locale_b2.ts:776` + +| 탭 키 | 화면 이름 | 그리는 자리 | 살아 있나 | +| --- | --- | --- | --- | +| `cost_sheet` | 공사원가계산서 | `B09_Estimation_UI_Page.ts:250` | ○ | +| `boq` | 설계내역서 | `B09_Estimation_UI_Page.ts:911` | ○ | +| `unit_price` | 일위대가 | `B09_Estimation_UI_Page.ts:307`·`366` | ○ | +| `price_basis` | 단가산출근거 | `B09_Estimation_UI_Page.ts:1053` | ○ | +| `machine` | 중기 | `B09_Estimation_UI_BaseData.ts:156`·`292` | ○ | +| `duration` | 공사기간 | — | **✕ 막힘(버튼이 눌리지 않음)** | +| `supply` | 관급·사급 | `B09_Estimation_UI_Page.ts:1113` | ○ | +| `base_data` | 기초자료 | `B09_Estimation_UI_BaseData.ts:132`·`712`·`945` | ○ | +| `design_doc` | 설계서 구성 | `B09_Estimation_UI_BaseData.ts:222` | ○ | +| `basis_sheet` | 산출기초 | `B09_Estimation_UI_BaseData.ts:389` | ○ | + +--- + +## 1. 공사원가계산서 (`cost_sheet`) + +**표 뼈대** `B09_Estimation_UI_Page.ts:250` · 열 정의 `:257` · 줄을 낳는 곳 +`B09_Estimation_Engine_Cost.py:252 calculate_cost` + +### 1-1. 열 다섯 + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 비목 | `B09_Estimation_Engine_Cost.py:115 CostLine.name` · 법정경비 이름은 `B09_Estimation_Statutory.py:58 STATUTORY_ITEMS` | 비목 이름표(고정) | `기준` | +| 금액 | `B09_Estimation_Engine_Cost.py:124 CostLine.amount_krw` (`:175 _emitter` 가 채움) | `밑수 × 요율% + 정액` 을 원 단위 버림(`:44 floor_won`) | `계산` / 마지막 줄은 `최종` | +| 요율 | `B09_Estimation_Engine_Cost.py:122 rate_percent` ← `B09_Estimation_Rates.py:92 load_rate_dataset` 의 `rates_2026.json` | 금액·공사기간 구간으로 고름(`B09_Estimation_Rates.py:180 select_bracket`) | `기준` | +| 산출근거 | `B09_Estimation_Engine_Cost.py:128 formula_text` | `"{밑수:,} × {요율}%"` (+ 정액이 있으면 `+ 정액`) | `계산` — **이미 있는 것** | +| 비고 | `B09_Estimation_Engine_Cost.py:125 note` | 채택·미채택 표시 등 자유문 | 여섯 밖 → 6장 ㉮ | + +### 1-2. 줄(비목)마다의 밑수·등급 + +`emit(key=...)` 순서대로. 밑수 이름은 `B09_Estimation_Statutory.py:52 base_label` 이 들고 있음. + +| 화면 줄 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 재료비 | `Engine_Cost.py:314` | 직접재료비(사용자 입력) | `입력` | +| 간접노무비 | `Engine_Cost.py:332` | 직접노무비 × `rate_indirect`% | `계산` | +| 노무비 | `Engine_Cost.py:340` | 직접노무비 + 간접노무비 | `계산` | +| 산재보험료 | `Statutory.py:60` | 노무비(직접+간접) × `rate_sanjae`% | `계산` | +| 고용보험료 | `Statutory.py:63` | 노무비(직접+간접) × `rate_goyong`% | `계산` | +| 국민건강보험료 | `Statutory.py:64` | 직접노무비 × `rate_health`% | `계산` | +| 노인장기요양보험료 | `Statutory.py:65` | **국민건강보험료** × `rate_care`% (앞 줄이 밑수) | `계산` | +| 국민연금보험료 | `Statutory.py:66` | 직접노무비 × `rate_pension`% | `계산` | +| 산업안전보건관리비 | `Statutory.py:67` · 셈은 `Statutory.py:154 safety_management_cost` | **A·B 중 작은 값** (A=요율식, B=대상액×1.2, `Statutory.py:42 _SAFETY_B_MULTIPLIER`) | `계산` — **식이 「×%」가 아님, 6장 ㉯** | +| 기타경비 | `Statutory.py:71` | (재료비+노무비) × `rate_other_expense`% | `계산` | +| 환경보전비 | `Statutory.py:72` | 직접공사비 × `rate_environment`% | `계산` | +| 퇴직공제부금비 | `Statutory.py:73` | 직접노무비 × `rate_retirement_mutual_aid`% | `계산` | +| 임금채권보장기금 부담금 | `Statutory.py:76` | 노무비(직접+간접) × 율 | `계산` | +| 석면피해구제 분담금 | `Statutory.py:82` | 노무비(직접+간접) × 율 | `계산` | +| 건설기계대여대금 지급보증수수료 | `Statutory.py:88` | 직접공사비 × 율 | `계산` | +| 하도급대금 지급보증수수료 | `Statutory.py:94` | 직접공사비 × 율 | `계산` | +| 공사이행보증수수료 | `Statutory.py:100` | **직접공사비 × 공사기간(년)** × 율 — 기간이 식에 듦 | `계산` (기간은 `입력`) | +| 경비 | `Engine_Cost.py:358` | 직접경비 + 법정경비 합 | `계산` | +| 순공사원가 | `Engine_Cost.py:367` | 재료비+노무비+경비 | `계산` | +| 일반관리비 | `Engine_Cost.py:381` | 순공사원가 × `rate_overhead`% | `계산` | +| 이윤(조정 전) | `Engine_Cost.py:462` | (노무비+경비+일반관리비) × `rate_profit`% | `계산` | +| 이윤 조정 | `Engine_Cost.py:470` | 사용자가 넣은 조정액 | `입력` | +| 이윤 | `Engine_Cost.py:479` | 조정 전 + 조정액 | `계산` | +| 총원가 | `Engine_Cost.py:392` | 순공사원가 + 일반관리비 + 이윤 | `최종` | +| 부가가치세 | `Engine_Cost.py:399` | 총원가 × 10% (`Statutory.py:41 _VAT_DIVISOR` 와 짝) | `계산` | +| 도급금액 | `Engine_Cost.py:407` | 총원가 + 부가세, **천원 단위 올림**(`Engine_Cost.py:49 ceil_thousand`) | `최종` | +| 관급자재 대금 | `Engine_Cost.py:496 _owner_supplied_line` | 사용자 입력(총원가 **밖**) | `입력` | +| 폐기물 처리비 | `Engine_Cost.py:514 _waste_line` | 사용자 입력 | `입력` | +| 총계 | `Engine_Cost.py:419` | 도급금액 + 관급 + 폐기물 | `최종` | + +### 1-3. 요율 판 (좌측 패널 「요율 판」 상자) + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 적용일 | `B09_Estimation_UI_Page.ts:592` ← `rate_version.effective_date` | `rates_2026.json` 의 기준일 | `기준` | +| 지문 | 같은 자리 `rate_version.sha256` 앞 8자 | 파일 해시 | 여섯 밖 → 6장 ㉰ | + +--- + +## 2. 설계내역서 (`boq`) + +**표 뼈대** `B09_Estimation_UI_Page.ts:944` (머리글이 `innerHTML` 문자열로 박혀 있음) · +줄을 낳는 곳 `B09_Estimation_BillOfQuantities_Rows.py` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| No. | `BillOfQuantities_Rows.py:139 item_no` | 마스터 목차 번호 | `기준` | +| 공종 | `:148 name` ← B08 `HandoffWorkItem.name` (없으면 마스터 이름) | 그대로 | `기준` | +| 규격 | `:149 spec` (갈래가 붙으면 `:211` 에서 `spec + variant_value`) | 그대로 / 갈래 이어붙임 | `기준` | +| 단위 | `:150 unit` | 그대로 | `기준` | +| 수량 | `:151 quantity` ← **B08 이 넘긴 값** · 표시는 `UI_Page.ts:793 formatQuantity` | 그대로(반영률은 **B08 이 이미 곱함**, `:157` 주석) | `측량` | +| 단가 | `:290~ unit_price_krw` ← `unit_prices.book` 의 일위대가 합계 | 일위대가 본표 합계 | `계산` | +| 금액 | 같은 곳 `amount_krw` | 수량 × 단가 | `최종` | +| 비고 | `:155`·`:172`·`:180`·`:236`·`:245`·`:267` 등 여러 자리에서 이어 붙임 | 반영률·갈래 근거·막힘 사유를 `/` 로 이음 | 여섯 밖 → 6장 ㉮ | + +**표 밖에 붙는 줄들** (모두 `UI_Page.ts:974~1050`) + +| 화면에 뜨는 것 | 원천 | 등급 후보 | +| --- | --- | --- | +| 내역서 합계 | `bill.summary.body_total_krw` | `최종` | +| 「금액을 못 세운 줄」 세 갈래 | `BillOfQuantities_Rows.py:190`·`:241`·`:259`·`:280` 의 `result.missing` | `막힘` | +| ├ 사용자가 넣으면 풀림 | `blocked_kind == "input_missing"` | `막힘`(원인 `입력`) | +| ├ 우리가 만들어야 함 | `blocked_kind` 그 밖 | `막힘` | +| └ 여기서 안 세는 줄 | `blocked_kind == "not_our_row"` | 여섯 밖 → 6장 ㉱ | +| 검산용 줄(제외) | `:110 _excluded_row` — 수량만 보이고 **단가를 안 붙임** | 여섯 밖 → 6장 ㉱ | +| 자재 줄 | `:385 _material_row` | `측량` | + +--- + +## 3. 일위대가 (`unit_price`) + +### 3-1. 목록표 — `UI_Page.ts:307` · 열 정의 `:321` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 명칭 | `B09_Estimation_UnitPrice_View.py:149 list_unit_prices` | 단가판 제목 | `기준` | +| 단위 | 같은 곳 | 단가판 단위 | `기준` | +| 재료비·노무비·경비 | 같은 곳 ← `PriceBook.resolve` | 성분별 합 | `계산` | +| 합계 | 같은 곳 | 재료비+노무비+경비 | `계산` | + +### 3-2. 본표 — `UI_Page.ts:366` · 열 정의 `:412` · 서버 `UnitPrice_View.py:171 detail_of` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 명칭 | `UnitPrice_View.py:171~` (제잡비는 `:196`, 공구손료는 `:236`) | 성분 이름 | `기준` | +| 규격 | 같은 곳 | 성분 규격 / `"노무비의 N%"` 꼴 | `기준` | +| 원천 | `UnitPrice_View.py:257 source_label` ← `B09_Estimation_UnitPrice.py:1026 SOURCE_LABEL` | 자재·노임·기계경비·일위대가·단가산출·일식견적 중 하나 + 순번 | `기준` — **이미 있는 것** | +| 단위 | `UnitPrice_View.py:259 부근` | 그대로 | `기준` | +| 수량 | `:259 quantity` | 품셈 소요량 | `기준` | +| 재료비·노무비·경비 | `:264~266` | 단위값 × 수량, 성분별로 자름 | `계산` | +| 합계 | `:267` 주석 | **자른 성분 셋의 합** (전정밀 합과 끝자리가 다름 — 정상) | `계산` | + +**표 밖에 붙는 줄** — 「일부만 선 단가」 알림(`UI_Page.ts:387 unattached_note`)은 `막힘`, +「반올림 차」(`UI_Page.ts:398 precise_total`)는 여섯 밖 → 6장 ㉲. + +--- + +## 4. 단가산출근거 (`price_basis`) + +**목록** `UI_Page.ts:1069` · **본문** `UI_Page.ts:1097` · 서버 `B09_Estimation_PriceBasis.py:74 build_price_basis` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 번호 | `PriceBasis.py:31 PriceBasisEntry.number` | 차례 매김 | `기준` | +| 공종 | 같은 곳 `name`+`spec` | 이어 붙임 | `기준` | +| 단위 | 같은 곳 `unit` | 그대로 | `기준` | +| 단가 | 같은 곳 `unit_price_krw` | 한 층 아래 일위대가의 합계 | `계산` | +| (본문) 참조 | `UI_Page.ts:1105 ref_code` | 한 층 아래 코드를 가리킴 | 여섯 밖 → 6장 ㉳ | + +⚠ **이 탭은 내역서(`bill`)를 먼저 불러야 뜸** (`UI_Page.ts:1054`). 안 불러오면 안내문만 뜸. + +--- + +## 5. 나머지 여섯 탭 + +### 5-1. 중기 (`machine`) + +**중기목록표** `B09_Estimation_UI_BaseData.ts:164` · 서버 `B09_Estimation_Lists.py:105 machine_list` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 코드번호·명칭·규격·단위 | `Lists.py:105` ← 기계 카탈로그 | 그대로 | `기준` | +| 합계 | `Lists.py:105 total_krw` | 노무비+재료비+경비 | `계산` | +| 노무비 | 같은 곳 | 조종원 노임 ÷ 8 × 16/12 × 25/20 (`B09_Estimation_MachineCost.py:74 OPERATOR_ALLOWANCE_FACTOR`) | `계산` | +| 재료비 | 같은 곳 | 주연료 + 잡재료(주연료의 %) | `계산` | +| 경비 | 같은 곳 | 시간당 손료 | `계산` | +| 비고 | 같은 곳 | 자유문 | 여섯 밖 → 6장 ㉮ | + +**각종 중기경비계산서** `UI_BaseData.ts:292 machineExpenseSheet` · 서버 +`B09_Estimation_MachineExpenseSheet.py:81 machine_expense_sheets` + +| 줄 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| ① 취득가격(천원) | `MachineExpenseSheet.py:81~` | 카탈로그 값 | `기준` | +| ① 내용시간 / 연간표준가동시간 | 같은 곳 | 품셈 값 | `기준` | +| ① 상각비·정비비·관리비 계수(10⁻⁷) | 같은 곳 | 셋을 더해 손료계수 | `기준` | +| ① 시간당 손료 | 같은 곳 | 취득가격 × 손료계수 | `계산` | +| ② 주연료 × 유가 | 같은 곳 · 유가는 `B09_Estimation_Lists_Sources.py:137 base_reference_data` | 주연료(L/hr) × 경유 단가 | `계산`(유가는 `기준`, 지역 선택은 `입력`) | +| ② 잡재료(주연료의 %) | 같은 곳 | 주연료비 × % | `계산` | +| ② 조종원 일당 → 시간당 | `MachineCost.py:49`·`:74` | 일당 ÷ 8 × 1.667 | `계산` | +| ③ 재료비/노무비/경비·합계 | 같은 곳 | ①+② 를 성분으로 가름 | `계산` | +| ⚠ 못 채운 자리(`gaps`) | `MachineExpenseSheet.py:81~` | — | `막힘` | + +### 5-2. 관급·사급 (`supply`) + +`UI_Page.ts:1146` · 서버 `B09_Estimation_MaterialSheet.py:117 build_material_sheet` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 자재·규격·단위 | `MaterialSheet.py:47 MaterialSheetRow` | 그대로 | `기준` | +| 수량 | 같은 곳 `total_amount` | B08 수량 × 할증 | `측량` | +| 단가 | 같은 곳 `unit_price_krw` | 단가판 값 | `기준` | +| 금액 | 같은 곳 `amount_krw` | 수량 × 단가 | `최종` | +| 비고 | 같은 곳 `note` | 자유문 | 여섯 밖 → 6장 ㉮ | + +세 무리(사급 / 관급 / 안 갈린 것)는 `UI_Page.ts:1136` 에서 가름. **관급은 총원가 밖**, +**안 갈린 것은 어느 합계에도 안 듦** → 여섯 밖 → 6장 ㉱. + +### 5-3. 기초자료 (`base_data`) + +**목록표 셋** `UI_BaseData.ts:132` · 열 `:111 catalogTable` · 서버 `B09_Estimation_Lists.py:50 catalog_list` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 코드번호·명칭·규격·단위 | `Lists.py:50` | 단가판 그대로 | `기준` | +| 단가 | 같은 곳 | 단가판 그대로 | `기준` | +| 비고 | 같은 곳 | 자유문 | 여섯 밖 → 6장 ㉮ | + +⚠ **재료비목록표가 거의 비어 있음** (`UI_BaseData.ts:143` 경고문) — 사급 자재 카탈로그가 안 섬 → `막힘`. + +**자재단가대비표** `UI_BaseData.ts:533 comparisonTable` · 서버 +`B09_Estimation_Lists_Sources.py:79 material_price_comparison` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 코드번호·명칭·규격·단위 | `Lists_Sources.py:79` | 그대로 | `기준` | +| (원천마다) 단가 | 같은 곳 `slots[].price_krw` | 물가지·견적 등 원천별 값 | `기준` | +| (원천마다) 페이지 | 같은 곳 `slots[].source_note` | 쪽수·출처 문구 | `기준` — **이미 있는 것** | +| 적용 | 같은 곳 `adopted_price_krw`·`adopted_slot` | 다섯 중 채택한 하나 | `계산` | +| 비고 | 같은 곳 `note` | 자유문 | 여섯 밖 → 6장 ㉮ | + +**환율및기초자료** `UI_BaseData.ts:607 baseReferenceSections` · 서버 `Lists_Sources.py:137` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| ① 환율 | `Lists_Sources.py:137` | 안내문뿐(값 없음) | `막힘` | +| ② 코드번호·직종 | 같은 곳 | 노임판 그대로 | `기준` | +| ② 일당 | 같은 곳 `day_wage_krw` | 공표 노임 | `기준` | +| ② 시간당 | 같은 곳 `hourly_krw` | 일당 ÷ 8 (**자르지 않고 소수 그대로**, `UI_BaseData.ts:634` 주석) | `계산` | +| ② 산식 | 같은 곳 `formula` | 사람이 읽는 한 줄 | `계산` — **이미 있는 것** | +| ③ 경유 단가·적용 범위·기준일·자료 | 같은 곳 `fuel` | 공시가(전국 또는 시도) | `기준`(범위 선택은 `입력`) | + +**산출 조건** `UI_BaseData.ts:945 drawFactorChoices` · 서버 `B09_Estimation_FactorChoices.py:111 scan_range_factors` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 계수 이름·품셈 범위 | `FactorChoices.py:62 RangeFactor` | 품셈이 준 범위 | `기준` | +| 고른 값(상·중·하) | `FactorChoices.py:57 CHOICE_KEYS`·`:58 DEFAULT_CHOICE` | 사용자 선택(기본 `mid`) | `입력` | +| 근거 문구 | `FactorChoices.py:162 BASIS_NOTES` | 고정 문구 | `기준` | + +### 5-4. 설계서 구성 (`design_doc`) + +`UI_BaseData.ts:222` · 서버 `B09_Estimation_DesignDocIndex.py:164 design_doc_index` + +| 열 이름 | 원천 | 식 | 등급 후보 | +| --- | --- | --- | --- | +| 차례·이름 | `DesignDocIndex.py:38 DESIGN_DOC_ITEMS` | 법이 정한 목차 | `기준` | +| 상태 | `DesignDocIndex.py:25~27` (있음/반쪽/없음) | 우리가 내는 것과 맞대 봄 | 여섯 밖 → 6장 ㉴ | +| 누가 만드나 | `DesignDocIndex.py:29~30` | 프로그램 / 설계자·발주청 | 여섯 밖 → 6장 ㉴ | +| 어디서 나오나 | 같은 곳 `where` | 화면·파일 이름 | 여섯 밖 → 6장 ㉴ | +| 비고 | 같은 곳 `note` | 자유문 | 여섯 밖 → 6장 ㉮ | + +⚠ **이 표는 프로젝트 값이 아님** — 「우리가 무엇을 내는가」의 표(`UI_Page.ts:1219` 주석). + +### 5-5. 산출기초 (`basis_sheet`) + +`UI_BaseData.ts:389` · 서버 `B09_Estimation_BasisSheet.py:148 basis_sheet` + +| 절 | 열 이름 | 원천 | 등급 후보 | +| --- | --- | --- | --- | +| ① 어느 판으로 계산했나 | 자료·파일·기준일·지문 | `BasisSheet.py:44 dataset_versions` | `기준` (지문은 6장 ㉰) | +| ② 무엇을 골랐나 | 항목·고른 값 | `BasisSheet.py:77 chosen_conditions` | `입력` | +| ③ 공종마다 무엇을 근거로 | 코드·공종·단위·근거 | `BasisSheet.py:106 work_item_basis` | `기준` — **이미 있는 것** | +| ④ 못 채운 자리 | 갈래·코드·사유 | `BasisSheet.py:134 open_gaps` | `막힘` | + +⚠ **여기서 값을 다시 계산하지 않음 — 모으기만 함** (`UI_BaseData.ts:359` 주석). 즉 **③ 이 이미 +사전의 절반**임. + +### 5-6. 공사기간 (`duration`) — 통째로 막힘 + +`TAB_KEYS` 에서 `enabled=false` (`UI_Page.ts:616`). 버튼이 눌리지 않고 「준비 중」만 뜸. +그런데 **공사이행보증수수료 식에 공사기간(년)이 이미 들어감**(`Statutory.py:100`) — +값은 좌측 패널 「공사 조건 · 공사기간」 입력칸에서 옴. → `막힘` 이되 원인은 `입력`. + +--- + +## 6. 여섯에 안 맞는 것 — **이 조사의 알맹이** + +억지로 안 끼우고 그대로 적음. ㉮~㉴ 일곱 갈래. + +### ㉮ 「비고」 — 등급을 못 붙이는 열 (거의 모든 표에 있음) + +내역서·중기·자재대·기초자료·설계서 구성이 모두 「비고」를 가짐. 안에 들어 있는 것이 섞여 있음 — +**갈래 판정 근거**(`BillOfQuantities_Rows.py:155`), **반영률 안내**(`:157`·`:165`), +**주의 문구**(`:180` `ⓘ`), **막힘 사유**(`:186`). 같은 칸에 **원천이 다른 글**이 들어감. + +→ **제안** — 비고는 등급을 붙일 열이 아니라 **다른 열의 근거를 담는 그릇**임. 등급 여섯에 넣지 말고 +호버 카드의 「곁말」 자리로 빼는 것이 맞아 보임. 지금처럼 `/` 로 이어 붙이면 **호버에서 갈라 보일 +수 없음** — 서버가 조각 배열로 보내야 함. + +### ㉯ 「A·B 중 작은 값」 — `계산` 이 못 담는 식 + +산업안전보건관리비(`Statutory.py:154`)는 두 길로 셈해 **작은 쪽**을 씀. `formula_text` 도 이 줄만 +`× %` 꼴을 안 씀(`Engine_Cost.py:133` 이 이 키를 따로 뺌). + +→ **제안** — `계산` 안에 「**고름**」 성격이 숨어 있음. 이런 줄은 호버에 **두 후보값과 왜 그것을 +골랐나**를 함께 보여야 함. 등급을 늘릴 것인지, `계산` 의 하위 성질로 둘 것인지 결정 필요. +같은 성격이 하나 더 있음 — 자재단가대비표의 「적용」(원천 다섯 중 하나 채택, `Lists_Sources.py:79`). + +### ㉰ 「지문(sha256)」 — 값이 아니라 **재현성 표식** + +요율 판 지문(`UI_Page.ts:594`)과 산출기초 ①(`BasisSheet.py:44`)에 나옴. 사용자가 넣은 것도, +계산한 것도, 법이 정한 것도 아님. + +→ **제안** — 등급을 붙이지 말고 **모든 칸의 호버 카드에 공통으로 따라붙는 꼬리표**로 두는 편이 +나아 보임(「이 값은 어느 판·어느 지문으로 섰나」). + +### ㉱ 「여기서 안 세는 줄」 — `막힘` 과 뜻이 정반대 + +셋이 같은 성격임 — 내역서의 `not_our_row`(`UI_Page.ts:1015`), 검산용 제외 줄 +(`BillOfQuantities_Rows.py:110`), 자재대의 「안 갈린 것」(`UI_Page.ts:1139`). +**못 세운 것이 아니라 세면 안 되는 것**임. `막힘` 에 넣으면 사용자가 채우려 들고 **그것이 곧 +이중계상**임(화면 주석이 이미 그렇게 경고함, `UI_Page.ts:1010`). + +→ **제안** — 일곱째 등급 「**제외**」가 필요해 보임. 아니면 `막힘` 을 「못 세움 / 안 셈」 둘로 가름. +⚠ **이것이 이번 조사에서 가장 확실한 어긋남**임 — B08 토적표에도 같은 성격의 줄이 있을 것임. + +### ㉲ 「반올림 차」 — 값이 아니라 **표시의 성질** + +일위대가 본표의 `precise_total ≠ total`(`UI_Page.ts:398`), 내역서의 자릿수 안내 +(`UI_Page.ts:981`), 노임 시간당을 안 자르는 규칙(`UI_BaseData.ts:634`). + +→ **제안** — 등급이 아니라 **칸마다의 「자른 자리」 표기**로 다루는 것이 맞아 보임 +(호버에 「표시 N자리 · 계산 전정밀」). + +### ㉳ 「참조(ref_code)」 — 값이 아니라 **한 층 아래로 가는 길** + +단가산출근거의 참조(`UI_Page.ts:1105`), 일위대가 본표의 파고들기(`UnitPrice.py:1037 DRILLABLE_KINDS`). + +→ **제안** — 이것이 PLAN 8-36 ⑤ 의 「원천은 글로만 적음」과 같은 자리임. **B09 안에서는 이미 +눌러서 내려갈 수 있음** — 화면 밖(B05·B06)으로 나갈 때만 글로만 적으면 됨. + +### ㉴ 설계서 구성표 — **프로젝트 값이 아닌 표** + +「상태 / 누가 만드나 / 어디서 나오나」 세 열은 프로젝트 숫자가 아니라 **우리 개발 상태**임. + +→ **제안** — 이 탭은 사전·호버 대상에서 **아예 빼는 것**이 맞아 보임. 등급을 억지로 붙이면 +사전이 「프로그램 진척표」까지 떠안게 됨. + +--- + +## 7. 있는 것 / 없는 것 + +**이미 있어 그대로 쓸 것 (넷)** + +| 무엇 | 어디 | 덮는 범위 | +| --- | --- | --- | +| `formula_text` | `Engine_Cost.py:128` | 원가계산서 줄 전부 — **식** | +| `source_label`·`source_index` | `UnitPrice_View.py:256` ← `UnitPrice.py:1026` | 일위대가 본표 줄 — **원천** | +| `base_label` | `Statutory.py:52` | 법정경비 14 줄 — **밑수 이름** | +| 산출기초 ③ `work_item_basis` | `BasisSheet.py:106` | 공종마다의 근거 문구 | +| (덤) 자재단가대비표 `source_note` | `Lists_Sources.py:79` | 자재 단가의 쪽수·출처 | + +**없어서 새로 만들어야 할 것** + +| 무엇 | 어느 표가 비었나 | +| --- | --- | +| **식** | 내역서(수량·단가·금액) · 중기목록표 · 자재대 · 기초자료 목록표 셋 | +| **원천(`파일:줄`)** | 전부 없음 — 지금은 어느 표도 「어느 코드가 이 값을 냈나」를 안 들고 있음 | +| **등급** | 전부 없음 | +| **밑수 이름** | 법정경비 밖 — 일반관리비·이윤·부가세는 `formula_text` 안에 숫자로만 들어감 | +| **칸 단위 곁말** | 비고를 `/` 로 이어 붙이는 자리 전부(㉮) — 조각 배열로 바꿔야 함 | + +--- + +## 8. 다음에 할 것 + +1. **㉱(제외 등급)를 먼저 결정** — 등급 여섯의 밑동이 바뀌는 자리라 나머지보다 앞섬. + B08 토적표 쪽에도 같은 성격 줄이 있는지 맞대 볼 것. +2. ㉮(비고 그릇)는 **서버가 조각 배열로 보내도록** 바꾸는 일이 딸림 — 사전 뼈대와 같이 설계할 것. +3. ㉴(설계서 구성표)는 사전 대상에서 빼는 것으로 정리하면 표 하나가 통째로 줄어듦. +4. 사전 파일은 `B09_*` 700줄 넘은 파일들에 넣지 말고 **새 파일**로 뺄 것 + (PLAN.md 8-36 끝 ⚠ 그대로). + +--- + +## 9. 줄 사유 → 닿는 열 지도 (㉮ 배선용, 2026-09-12 추가) + +데스크탑 메인 창 당부 ② — **줄 사유는 그 사유가 닿는 열에만 붙일 것**(B08 에서 「절토 보정량」 +카드에 「측구 가름값이…」 가 떠서 읽는 사람을 속인 사고). `resolveExtra` 에서 열 키로 거르려면 +**어느 조각이 어느 열 것인지**를 먼저 못 박아야 함. 내역서 `note` 가 지금 `/` 로 이어 붙이는 +조각 전부를 그 임자 열에 갈라 놓음. + +| 조각 | 만드는 자리 | 닿는 열 | 지금 꼴 | +| --- | --- | --- | --- | +| 갈래 판정 근거(`spec_class_basis`) | `BillOfQuantities_Rows.py:154` | **규격** | `/` 로 이음 | +| 주의 문구 `ⓘ`(막힘 아님 — 기본값으로 섰다는 알림) | `:180` | **규격** | `/` 로 이음 | +| 관경이 표 밖(`pipe_diameter_note`) | `:228` | **규격** | `/` 로 이음 | +| 반영률 적용 후 수량(단일 율) | `:157` | **수량** | 덮어씀 | +| 반영률이 갈래마다 다름 | `:165` | **수량** | 덮어씀 | +| 묶음 조각이 덜 참 · 묶음 N조각 합계 | `:86`·`:106` | **수량** | 덮어씀 | +| 수량이 미확정 산식 위에 섬(`pending_formula_note`) | `:335` | **수량** | `/` 로 이음 | +| 막힘 사유(`blocked_kind` 있음) | `:186` | **단가** | 덮어씀 | +| 일위대가가 한 층 아래에 있음(후보 N건) | `:224` | **단가** | 덮어씀 | +| 성분이 빠져 못 세움 | `:236` | **단가** | 덮어씀 | +| 일위대가가 아직 없음 | `:239` | **단가** | 덮어씀 | +| 밑수(기준 수량) 못 찾음 — 곱하지 않음 | `:257` | **단가** | 덮어씀 | +| 단가가 일부만 섬(붙은 몫 N%) | `:278` | **단가** | 덮어씀 | +| 기준 단위가 표에 없음 — 같다고 보고 곱함 | `:297` | **단가** | `/` 로 이음 | +| 원문엔 있는데 단가에 못 실린 몫(`known_gap_note`) | `:341` | **단가** | `/` 로 이음 | +| 단산 참조번호(`entry.label`, 「단산 46」) | `BillOfQuantities.py:517` | **단가** | `/` 로 **앞에** 붙임 | +| 단위 불일치 — 곱하면 틀리므로 비워 둠 | `:314` | **금액** | 덮어씀 | +| 검산용 줄 — 금액을 안 매김 | `:170` | **줄 전체**(`excluded`) | 덮어씀 | +| 관급·사급이 안 갈림 | `:398` | **줄 전체**(`excluded`) | 덮어씀 | +| 관급/사급 자재 단가 미확보 | `:411`·`:418` | **단가** | 덮어씀 | + +⚠ **덮어쓰는 자리가 더 많음** — 지금은 앞 조각을 지우고 새로 적는 곳이 대부분이라, 한 줄에 +사유가 둘이면 **하나가 조용히 사라짐**. 조각 배열로 바꿀 때 이 자리들도 함께 `append` 로 +고쳐야 함(단순히 `/` 를 배열로 바꾸는 것만으로는 안 됨). + +⚠ **화면 `비고` 칸은 그대로 둘 것** — 조각 배열은 호버 카드용으로 따로 실음. 지금 비고를 +없애면 토글을 끈 사용자가 사유를 못 봄. From d1a2508053c6b45978b60d00be3858bdeee01c88 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 12 Sep 2026 15:50:55 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(B09):=20=EC=B9=B8=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=EA=B7=BC=EA=B1=B0=20=ED=98=B8=EB=B2=84=C2=B7=EB=93=B1?= =?UTF-8?q?=EA=B8=89=EC=83=89=20=EB=B0=B0=EC=84=A0=20+=20=EC=A4=84=20?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=20=EB=8D=AE=EC=96=B4=EC=93=B0=EA=B8=B0=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 버그 — BillRow.note 한 칸에 덮어쓰던 13곳·이어 붙이던 7곳을 조각 배열 notes 로 옮김. 한 줄에 사유가 둘이면 하나가 조용히 사라지던 것이 멈춤. 조각마다 닿는 열 키를 함께 실어 호버가 그 칸에만 띄우게 함. 화면 비고 칸은 종전과 같은 꼴 유지. ② 사전 — B09_Estimation_Provenance.py 신설(기존 B09 파일이 700줄 초과). 8장 57열. 이미 있던 formula_text·source_label·base_label 은 그대로 쓰고 없는 것만 새로 적음. ③ 배선 — 라우터 5개 응답에 _with_provenance(), 표 여섯에 markProvenanceCell + attachProvenance. 개발환경이 아니면 응답에 칸 자체가 안 생김(로직 보안). ④ 등급색 토글을 탭 줄 끝에. 사전이 안 오면 단추도 안 세움. ⚠ 원가계산서 금액은 같은 열에서 중간줄과 마지막줄의 성격이 갈리는데 등급이 열에 하나뿐이라 calc 로 두고 rule 에 어느 줄이 final 인지 적음(PLAN 8-36 ① 에 남김). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GrXDD23Dvt2sR7q3X6oekp --- .../B09_Estimation_BillOfQuantities.py | 23 +- .../B09_Estimation_BillOfQuantities_Rows.py | 93 ++-- B09_Estimation/B09_Estimation_Provenance.py | 478 ++++++++++++++++++ B09_Estimation/B09_Estimation_Router.py | 55 +- B09_Estimation/B09_Estimation_UI_BaseData.ts | 44 +- B09_Estimation/B09_Estimation_UI_Page.ts | 146 +++++- 6 files changed, 766 insertions(+), 73 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Provenance.py diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index fd50b7fc..0c9e6578 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -152,7 +152,22 @@ class BillRow: expense_krw: Decimal = _ZERO is_group: bool = False in_bill: bool = True - note: str = "" + #: 줄 사유 **조각** — `(닿는 열 키, 글)`. 화면 「비고」는 이것을 이어 붙인 것이고, + #: 근거 호버는 열 키로 걸러 **그 사유가 닿는 칸에만** 띄운다(PLAN 8-36 ㉮). + #: ⚠ 종전엔 `note` 한 칸에 덮어썼다 — 한 줄에 사유가 둘이면 **하나가 조용히 사라졌다** + #: (막힘 사유가 먼저 적힌 반영률 문구를 지웠다, 2026-09-12 데스크탑 보조 조사 ㉮). + #: 그래서 **덮지 않고 쌓는다.** 열을 못 짚는 줄 전체 사유는 키를 빈 글로 둔다. + notes: list[tuple[str, str]] = field(default_factory=list) + + @property + def note(self) -> str: + """화면 「비고」 칸 — 조각을 종전과 **같은 꼴**로 이어 붙인다.""" + return " / ".join(text for _, text in self.notes if text) + + def add_note(self, column: str, text: str) -> None: + """사유 한 조각을 **쌓는다**. `column` 은 그 사유가 닿는 열 키(줄 전체면 빈 글).""" + if text: + self.notes.append((column, text)) def as_dict(self) -> dict[str, Any]: def money(value: Decimal | None) -> str | None: @@ -185,6 +200,9 @@ class BillRow: "is_group": self.is_group, "in_bill": self.in_bill, "note": self.note, + #: 근거 호버용 — 어느 사유가 **어느 열**에 닿는지까지 실어 보낸다. + #: 화면 「비고」 칸은 위 `note` 그대로라 토글을 끈 사용자도 사유를 그대로 본다. + "notes": [{"column": column, "text": text} for column, text in self.notes], } @@ -514,7 +532,8 @@ def build_bill( continue entry = sheet.by_unit_price(f"B-{row.code}") if entry is not None: - row.note = " / ".join(part for part in (entry.label, row.note) if part) + # 종전처럼 **맨 앞**에 놓는다 — 실무 참조번호(「단산 46」)가 먼저 읽혀야 한다. + row.notes.insert(0, ("unit_price_krw", entry.label)) result.price_basis = sheet if any(m.surcharge_pct is None for m in materials): diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index 33843c80..d1f97d03 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -83,7 +83,7 @@ def _composite_row( if missing_parts or money is None: detail_text = "; ".join(reasons[:3]) or ", ".join(missing_parts[:4]) - row.note = f"묶음 조각이 덜 찼습니다 — {detail_text}" + row.add_note("quantity", f"묶음 조각이 덜 찼습니다 — {detail_text}") result.missing.append( { "name": row.name, @@ -103,7 +103,7 @@ def _composite_row( row.material_krw = line.material row.labor_krw = line.labor row.expense_krw = line.expense - row.note = f"묶음 {len(item.composite_parts)}조각 합계" + row.add_note("quantity", f"묶음 {len(item.composite_parts)}조각 합계") return row @@ -116,7 +116,7 @@ def _excluded_row(item: HandoffWorkItem) -> BillRow: · ⚠ **여기서 세지 않는 줄**(`blocked_kind` 없음) — 「다른 표에서 이미 섬」· 「이 노선엔 없음」. **이것을 할 일 목록에 얹으면 결국 이중계상이 된다.** """ - return BillRow( + row = BillRow( item_no="", level=1, code=item.work_item_code, @@ -125,10 +125,13 @@ def _excluded_row(item: HandoffWorkItem) -> BillRow: unit=item.unit, quantity=item.quantity, in_bill=False, - note=item.blocked_reason - or item.in_bill_reason - or "합계 검산용 줄 — 금액을 매기지 않습니다.", ) + # 줄 하나가 통째로 빠지는 사유라 닿는 열이 없다 — 키를 비워 **모든 칸**에 따라붙게 둔다. + row.add_note( + "", + item.blocked_reason or item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다.", + ) + return row def _leaf_row( @@ -151,10 +154,10 @@ def _leaf_row( ) if item.spec_class_basis: # 갈래 판정 근거는 **B08 문구를 그대로** 쓴다(두 벌로 짜지 않는다). - row.note = " / ".join(part for part in (row.note, item.spec_class_basis) if part) + row.add_note("spec", item.spec_class_basis) if item.application_ratio_pct is not None: # ⚠ 곱하지 않는다 — B08 이 이미 곱한 값이다. 산출근거로만 적는다. - row.note = f"반영률 {item.application_ratio_pct}% 적용 후 수량" + row.add_note("quantity", f"반영률 {item.application_ratio_pct}% 적용 후 수량") elif item.application_ratio_breakdown: # ⚠ **「율 없음」이 아니라 「갈래마다 다름」이다.** 율이 갈리는 줄은 B08 이 `pct` 를 # 비우고 갈래로만 보낸다. 그 사실을 안 적으면 **값은 맞는데 왜 그 수량인지**를 @@ -162,12 +165,12 @@ def _leaf_row( parts = ", ".join( f"{name} {value}%" for name, value in item.application_ratio_breakdown.items() ) - row.note = f"반영률이 갈래마다 다릅니다 — {parts} (적용 후 수량)" + row.add_note("quantity", f"반영률이 갈래마다 다릅니다 — {parts} (적용 후 수량)") if not item.in_bill: # 검산용 줄 — 수량은 보이되 **단가를 안 붙인다**(PLAN 8-7 ㉡ 와 같은 성격). row.quantity = item.quantity - row.note = item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다." + row.add_note("", item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다.") result.excluded.append(row) return row @@ -177,13 +180,16 @@ def _leaf_row( # **주의 문구**였다 — 「관종을 안 정해 기본값(파형강관)으로 섰습니다」. # ⇒ 사유만 온 줄은 **금액을 세우고 그 문구를 곁말로** 단다. if item.blocked_reason and not item.blocked_kind: - row.note = " / ".join(part for part in (row.note, f"ⓘ {item.blocked_reason}") if part) + row.add_note("spec", f"ⓘ {item.blocked_reason}") if item.blocked_reason and item.blocked_kind: # B08 이 「왜 못 골랐는지」를 적어 보냈다 — **그 문구를 그대로** 보인다. # 사용자가 입력하면 풀리는 것(`input_missing`)과 우리가 만들어야 하는 것을 # 가르지 않으면, 사용자가 「후보를 고르면 되나」로 잘못 읽는다. - row.note = f"{_BLOCKED_LABELS.get(item.blocked_kind, '막힘')} — {item.blocked_reason}" + row.add_note( + "unit_price_krw", + f"{_BLOCKED_LABELS.get(item.blocked_kind, '막힘')} — {item.blocked_reason}", + ) result.missing.append( { "name": row.name, @@ -221,11 +227,14 @@ def _leaf_row( ) if children: names = ", ".join(f"{c[2:]} {unit_prices.book.title(c).name}" for c in children) - row.note = f"이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보: {names}" + row.add_note( + "unit_price_krw", + f"이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보: {names}", + ) # 관경이 표 밖이면 **무엇을 정해야 하는지**까지 가리킨다. diameter_note = pipe_diameter_note(node.code, item.variant_value) if diameter_note: - row.note = f"{row.note} / {diameter_note}" + row.add_note("spec", diameter_note) reason = f"일위대가가 하위 공종에 있음(후보 {len(children)}건)" else: # ⚠ 「아직 안 만든 것」과 「성분이 빠져 못 세운 것」은 **할 일이 다르다**. @@ -233,10 +242,12 @@ def _leaf_row( # 표에 수량 칸이 비어 있고 「철근가공조립(간단)의 30 %」처럼 참조로만 적힌 자리). gap = unit_prices.component_gaps.get(node.code) if gap: - row.note = f"성분이 빠져 단가를 못 세웠습니다 — {gap}" + row.add_note("unit_price_krw", f"성분이 빠져 단가를 못 세웠습니다 — {gap}") reason = f"성분 미확보 — {gap}" else: - row.note = "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다." + row.add_note( + "unit_price_krw", "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다." + ) reason = "일위대가 없음" result.missing.append( { @@ -254,7 +265,10 @@ def _leaf_row( if missing_basis: # ⚠ 밑수를 모르는 표다 — 「10㎡당」인지 「1㎡당」인지 모른 채 곱하면 10배·100배 # 틀린다(떼채취가 실제로 100배였다). **곱하지 않고 드러낸다.** - row.note = f"밑수(기준 수량)를 못 찾은 표입니다 — 곱하지 않았습니다. 원문 {missing_basis}" + row.add_note( + "unit_price_krw", + f"밑수(기준 수량)를 못 찾은 표입니다 — 곱하지 않았습니다. 원문 {missing_basis}", + ) result.missing.append( { "name": row.name, @@ -275,8 +289,9 @@ def _leaf_row( missing_rows = unit_prices.unattached.get(node.code) or [] if not why and missing_rows: why = f"{', '.join(missing_rows[:3])} 줄이 아직 안 붙었습니다" - row.note = ( - f"단가가 일부만 섰습니다 — 붙은 몫 {covered}%" + (f" · {why}" if why else "") + "." + row.add_note( + "unit_price_krw", + f"단가가 일부만 섰습니다 — 붙은 몫 {covered}%" + (f" · {why}" if why else "") + ".", ) result.missing.append( { @@ -294,14 +309,10 @@ def _leaf_row( # ⚠ 품셈 표가 기준 단위를 안 준 단가다 — 「10㎡당」 같은 묶음 기준일 수 있다. # 값을 막지는 않되(막으면 대부분이 멈춘다) **모르는 채 곱했다는 사실을 적는다**. # 비고를 **덮지 않고 잇는다** — 반영률 문구가 먼저 적혀 있을 수 있다. - row.note = " / ".join( - part - for part in ( - row.note, - f"단가의 기준 단위가 표에 없습니다 — B08 수량 단위({row.unit})와 같다고 " - "보고 곱했습니다. 확인 필요.", - ) - if part + row.add_note( + "unit_price_krw", + f"단가의 기준 단위가 표에 없습니다 — B08 수량 단위({row.unit})와 같다고 " + "보고 곱했습니다. 확인 필요.", ) if title.unit and row.unit and not _same_unit(title.unit, row.unit): @@ -311,9 +322,10 @@ def _leaf_row( # 52,938.9 = 1,381,753원이라 **2.6 배 적은 금액**이 내역서에 든 셈이다. # 어느 쪽이 맞는지는 우리가 정할 일이 아니다 — **B08 이 면적을 보내거나 묶음 # 조각으로 보내야** 풀린다. 그때까지 **금액을 만들지 않고 드러낸다.** - row.note = ( + row.add_note( + "amount_krw", f"단위가 안 맞습니다 — 수량은 {row.unit}, 단가는 {title.unit}당입니다. " - "곱하면 금액이 틀리므로 비워 둡니다." + "곱하면 금액이 틀리므로 비워 둡니다.", ) result.missing.append( { @@ -332,13 +344,13 @@ def _leaf_row( # 그 밑수가 사용자 확정을 기다리고 있다(계획서 4-12 3단계). pending = pending_formula_note(node.code) if pending: - row.note = " / ".join(part for part in (row.note, pending) if part) + row.add_note("quantity", pending) # ⚠ **원문에는 있는데 단가에 못 실린 몫**도 같은 자리에서 말한다. 금액이 서 있는 줄이라 # 표시가 없으면 완성된 값으로 읽힌다(규준틀 둘이 인력만으로 492만원이었다). gap = known_gap_note(node.code) if gap: - row.note = " / ".join(part for part in (row.note, gap) if part) + row.add_note("unit_price_krw", gap) # 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다. if price_code not in result.used_unit_prices: @@ -392,10 +404,13 @@ def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow: spec=material.spec, unit=material.unit, quantity=material.total_amount, - note=material.surcharge_note, ) + # 할증 사유는 **수량**에 닿는다 — 할증이 곱해진 뒤의 수량이기 때문이다. + row.add_note("quantity", material.surcharge_note) if material.supply_type == SUPPLY_UNKNOWN: - row.note = "관급·사급이 안 갈렸습니다 — 관급자재대에도, 도급 재료비에도 넣지 않습니다." + row.add_note( + "", "관급·사급이 안 갈렸습니다 — 관급자재대에도, 도급 재료비에도 넣지 않습니다." + ) result.missing.append( { "name": material.display_name, @@ -409,14 +424,16 @@ def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow: # `owner_supplied` 인데 「사급 자재 단가 미확보」로 뜨고 있었다). 갈래마다 **가는 자리도 # 원천도 다르다** — 관급은 총원가 밖 관급자재대(나라장터), 사급은 도급 재료비(물가지). if material.supply_type == SUPPLY_OWNER: - row.note = ( - row.note - or "관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. " - "관급자재대(총원가 밖 별도 표기)로 갑니다." + row.add_note( + "unit_price_krw", + "관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. " + "관급자재대(총원가 밖 별도 표기)로 갑니다.", ) reason = "관급 자재 단가 없음" else: - row.note = row.note or "사급 자재 단가 미확보 — 6번 슬롯(적용 단가) 수동 입력 대기." + row.add_note( + "unit_price_krw", "사급 자재 단가 미확보 — 6번 슬롯(적용 단가) 수동 입력 대기." + ) reason = "사급 자재 단가 없음(미결 No.18)" result.missing.append( diff --git a/B09_Estimation/B09_Estimation_Provenance.py b/B09_Estimation/B09_Estimation_Provenance.py new file mode 100644 index 00000000..64dec7a2 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Provenance.py @@ -0,0 +1,478 @@ +"""B09 원가 화면의 **근거 사전** — 어느 숫자가 어디서 와서 어떻게 나왔나 (PLAN 8-36 ④). + +⚠⚠ **개발 전용.** 사전은 `provenance_payload()` 를 거쳐 나가고, 개발환경이 아니면 `None` + 이라 응답에 칸 자체가 안 생긴다. 화면에서 숨기는 것이 아니라 **안 보내는 것**이다. + +왜 이 파일인가 + 「식」과 「원천」의 정답은 값을 낳는 엔진이 안다. 화면 TS 에 손으로 적어 두면 엔진을 + 고칠 때 설명만 옛것으로 남는다. 엔진 옆에 두어 같이 눈에 들어오게 한다. + ⚠ `B09_Estimation_UI_Page.ts`(1415줄)·`B09_Estimation_UnitPrice.py`(1173줄) 가 이미 + 700줄을 크게 넘어 **새 파일로 뺐다**(PLAN 8-36 끝 ⚠). + +⚠ **열 단위로 적는다.** 줄마다 갈리는 사유는 줄이 `notes` 로 들고 오고(내역서는 그 사유가 + **닿는 열 키**까지 함께 들고 온다 — `BillRow.notes`), 화면이 그 열의 칸에만 덧붙인다. + +토적표와 맞대 본 것 (데스크탑 메인 요청) + · B08 토적표에는 `final` 이 **한 열도 없었다** — 중간 장부이기 때문이다. + · B09 는 반대로 `final` 이 분명히 있다 — 내역서 금액·자재대 금액이 그 자리다. + ⇒ 등급 여섯은 **한 장이 아니라 두 장을 합쳐야** 다 쓰인다. + · 그래도 **원가계산서 「금액」은 열 단위로 `final` 을 못 붙였다.** 같은 열 안에서 + 중간줄(간접노무비 따위)과 마지막줄(총원가·도급금액·총계)의 성격이 갈리는데 등급은 + **열에 하나**뿐이라서다. `calc` 로 두고 `rule` 에 어느 줄이 `final` 인지 적었다. + ⇒ 이 어긋남은 PLAN 8-36 ① 에 남긴다(칸 단위 등급이 필요한 첫 자리). +""" + +from __future__ import annotations + +from typing import Any + +from common_util.common_util_provenance import ( + TIER_CALC, + TIER_EXCLUDED, + TIER_FINAL, + TIER_INPUT, + TIER_STANDARD, + TIER_SURVEY, + TIER_UNCLASSIFIED, + ColumnProvenance, + provenance_payload, + sheet_provenance, +) + +#: 요율이 어디서 오는지 — 원가계산서 여러 열이 같은 문장을 쓴다. +_RATE_SOURCE = ( + "요율 판 `resources/data_cost_input_value/rates_2026.json` — " + "공사금액·공사기간 구간으로 골라 씀(`B09_Estimation_Rates.py:180 select_bracket`). " + "어느 판으로 섰는지는 좌측 패널 「요율 판」과 산출기초 ① 에 지문까지 남음" +) + +#: 이름표 열(코드·명칭·규격·단위)이 공통으로 쓰는 문장. +_CATALOG_SOURCE = "단가판·품셈 표의 이름을 그대로 옮긴 자리 — 여기서 짓지 않음" + +#: 「비고」가 왜 미분류인지 — 표마다 같은 말을 쓴다. +_NOTE_RULE = ( + "이 칸은 등급을 붙일 열이 아니라 **다른 열의 사유를 담는 그릇**이다. " + "안에 든 조각마다 닿는 열이 다르므로(갈래 근거는 규격, 반영률은 수량, " + "막힘 사유는 단가) 호버는 조각을 그 열의 칸에만 띄운다" +) + + +def _label(key: str, label: str, extra: str = "") -> ColumnProvenance: + """이름표 열 — 값을 낳은 것이 아니라 옮겨 적은 자리.""" + return ColumnProvenance( + key=key, + label=label, + tier=TIER_STANDARD, + formula="옮겨 적은 값 (여기서 계산하지 않음)", + source=_CATALOG_SOURCE + (f" · {extra}" if extra else ""), + ) + + +def _note_column(code: str) -> ColumnProvenance: + """「비고」 열 — 여섯 어디에도 안 맞아 `unclassified` 로 둔다(PLAN 8-36 ㉮).""" + return ColumnProvenance( + key="note", + label="비고", + tier=TIER_UNCLASSIFIED, + formula="줄에 달린 사유 조각을 차례로 이어 붙인 글", + source="조각마다 원천이 다름 — 조각별 원천은 그 조각이 닿는 열의 카드에 뜸", + rule=_NOTE_RULE, + code=code, + ) + + +# ============================================================================= +# ① 공사원가계산서 +# ============================================================================= + + +def cost_sheet() -> dict[str, Any]: + """열 키는 화면 `buildCostSheetTable` 이 심는 낱말과 같아야 한다.""" + return sheet_provenance( + [ + ColumnProvenance( + key="name", + label="비목", + tier=TIER_STANDARD, + formula="법이 정한 비목 이름 (차례도 법이 정함)", + source="법정경비 14 비목은 `B09_Estimation_Statutory.py:58 STATUTORY_ITEMS` " + "— 그 차례가 곧 원가계산서 줄 차례", + code="B09_Estimation_Engine_Cost.py:115 CostLine.name", + ), + ColumnProvenance( + key="amount_krw", + label="금액", + tier=TIER_CALC, + formula="밑수 × 요율% (+ 정액) 을 원 단위로 버림", + source="밑수는 비목마다 다름 — 「산출근거」 칸에 그 줄의 실제 밑수가 적힘. " + "버림은 `B09_Estimation_Engine_Cost.py:44 floor_won`", + rule="⚠ **총원가·도급금액·총계 줄은 `final`** — 계약으로 나가는 값이다. " + "등급이 열에 하나뿐이라 그 셋을 따로 못 적었다(PLAN 8-36 ①). " + "도급금액만 천원 단위 **올림**(`:49 ceil_thousand`)이라 끝자리가 다르다", + code="B09_Estimation_Engine_Cost.py:175 _emitter", + ), + ColumnProvenance( + key="rate_percent", + label="요율", + tier=TIER_STANDARD, + formula="공사금액·공사기간이 든 구간의 요율을 그대로 씀", + source=_RATE_SOURCE, + code="B09_Estimation_Rates.py:180 select_bracket", + ), + ColumnProvenance( + key="formula_text", + label="산출근거", + tier=TIER_CALC, + formula="그 줄이 실제로 쓴 밑수와 요율을 사람이 읽게 적은 한 줄", + source="엔진이 셈하면서 같이 지음 — 화면이 따로 짓지 않는다", + rule="⚠ **산업안전보건관리비만 「× %」 꼴이 아니다** — A(요율식)·B(대상액×1.2) " + "중 **작은 쪽**을 쓰므로 값 안에 고름이 숨어 있다" + "(`B09_Estimation_Statutory.py:154 safety_management_cost`)", + code="B09_Estimation_Engine_Cost.py:128 CostLine.formula_text", + ), + _note_column("B09_Estimation_Engine_Cost.py:125 CostLine.note"), + ] + ) + + +# ============================================================================= +# ② 설계내역서 +# ============================================================================= + + +def boq_sheet() -> dict[str, Any]: + return sheet_provenance( + [ + _label("item_no", "No.", "마스터 목차가 매긴 번호"), + _label("name", "공종", "B08 이 보낸 이름, 없으면 마스터 이름"), + ColumnProvenance( + key="spec", + label="규격", + tier=TIER_STANDARD, + formula="마스터 규격 (갈래가 정해진 줄은 갈래 이름을 뒤에 이음)", + source="갈래를 어떻게 골랐는지는 그 줄의 사유에 적힘 — B08 문구를 그대로 옮김", + code="B09_Estimation_BillOfQuantities_Rows.py:149", + ), + _label("unit", "단위"), + ColumnProvenance( + key="quantity", + label="수량", + tier=TIER_SURVEY, + formula="B08 이 보낸 값을 그대로 씀 (여기서 다시 곱하지 않음)", + source="⚠ **반영률은 B08 이 이미 곱했다** — 여기서 또 곱하면 두 번 곱해진다. " + "찍는 자리수는 품셈 1-2-2 종목별(`B09_Estimation_QuantityDigits.py`)이고 " + "값 자체는 전정밀로 남는다", + code="B09_Estimation_BillOfQuantities_Rows.py:151", + ), + ColumnProvenance( + key="unit_price_krw", + label="단가", + tier=TIER_CALC, + formula="그 공종의 일위대가 본표 합계 (1단위 값)", + source="일위대가 탭에서 같은 표를 그대로 봄. 묶음 줄은 조각들의 " + "`단가 × 조각수량` 을 더한 값", + rule="⚠ **못 세우면 0 으로 때우지 않고 비운다.** 일위대가가 없음·성분이 빠짐·" + "밑수를 모름·일부만 섬·단위가 안 맞음 — 사유는 그 줄의 사유에 적히고 " + "「금액을 못 세운 줄」 목록에도 오른다", + code="B09_Estimation_BillOfQuantities_Rows.py:134 _leaf_row", + ), + ColumnProvenance( + key="amount_krw", + label="금액", + tier=TIER_FINAL, + formula="수량 × 단가", + source="이 값들의 합이 공사원가계산서의 직접비로 나간다 — 화면 밖으로 나가는 값", + rule="단가가 안 선 줄은 **금액도 안 세운다**. 단위가 안 맞는 줄도 비운다 — " + "곱하면 조용히 틀린 금액이 내역서에 든다", + code="B09_Estimation_BillOfQuantities_Rows.py:134 _leaf_row", + ), + _note_column("B09_Estimation_BillOfQuantities.py:BillRow.note"), + ] + ) + + +# ============================================================================= +# ③ 일위대가 +# ============================================================================= + + +def unit_price_list_sheet() -> dict[str, Any]: + """목록표 — 「무엇이 있나」.""" + common = "단가판(`PriceBook`)이 성분을 풀어 낸 값 — 본표를 열면 줄마다 보인다" + return sheet_provenance( + [ + _label("name", "명칭", "단가판 제목"), + _label("unit", "단위", "단가판 기준 단위"), + ColumnProvenance( + key="material", + label="재료비", + tier=TIER_CALC, + formula="본표 재료비 줄의 합", + source=common, + code="B09_Estimation_UnitPrice_View.py:149 list_unit_prices", + ), + ColumnProvenance( + key="labor", + label="노무비", + tier=TIER_CALC, + formula="본표 노무비 줄의 합", + source=common, + code="B09_Estimation_UnitPrice_View.py:149 list_unit_prices", + ), + ColumnProvenance( + key="expense", + label="경비", + tier=TIER_CALC, + formula="본표 경비 줄의 합", + source=common, + code="B09_Estimation_UnitPrice_View.py:149 list_unit_prices", + ), + ColumnProvenance( + key="total", + label="합계", + tier=TIER_CALC, + formula="재료비 + 노무비 + 경비", + source=common, + code="B09_Estimation_UnitPrice_View.py:149 list_unit_prices", + ), + ] + ) + + +def unit_price_detail_sheet() -> dict[str, Any]: + """본표 — 「무엇으로 이루어졌나」.""" + money = ( + "성분 단위값 × 수량을 성분별로 자른 값. ⚠ 행마다 자르므로 **전정밀 합과 끝자리가 " + "어긋난다 — 정상이다.** 숨기면 나중에 「합계가 안 맞는다」며 계산을 고치려 든다" + ) + return sheet_provenance( + [ + _label("name", "명칭", "성분 이름. 제잡비·공구손료는 품셈 [주]가 만든 줄"), + _label("spec", "규격", "성분 규격. 비율 줄은 「노무비의 N%」 꼴"), + ColumnProvenance( + key="source", + label="원천", + tier=TIER_STANDARD, + formula="그 성분이 어느 판에서 왔는지 + 그 판에서의 순번", + source="자재 · 노임 · 기계경비 · 일위대가 · 단가산출 · 일식견적 여섯 중 하나" + "(`B09_Estimation_UnitPrice.py:1026 SOURCE_LABEL`)", + rule="기계경비·일위대가·단가산출 줄은 **눌러서 한 층 아래로 내려갈 수 있다**" + "(`:1037 DRILLABLE_KINDS`)", + code="B09_Estimation_UnitPrice_View.py:257", + ), + _label("unit", "단위"), + ColumnProvenance( + key="quantity", + label="수량", + tier=TIER_STANDARD, + formula="품셈 표가 정한 1단위당 소요량", + source="비율 줄(제잡비·공구손료)은 수량 칸에 **퍼센트**가 들어간다", + code="B09_Estimation_UnitPrice_View.py:259", + ), + ColumnProvenance( + key="material", + label="재료비", + tier=TIER_CALC, + formula="성분 단위 재료비 × 수량", + source=money, + code="B09_Estimation_UnitPrice_View.py:264", + ), + ColumnProvenance( + key="labor", + label="노무비", + tier=TIER_CALC, + formula="성분 단위 노무비 × 수량", + source=money, + code="B09_Estimation_UnitPrice_View.py:265", + ), + ColumnProvenance( + key="expense", + label="경비", + tier=TIER_CALC, + formula="성분 단위 경비 × 수량", + source=money, + code="B09_Estimation_UnitPrice_View.py:266", + ), + ColumnProvenance( + key="total", + label="합계", + tier=TIER_CALC, + formula="자른 성분 셋을 더한 값 (표에서 합계 = 재료비+노무비+경비 가 서게)", + source=money, + code="B09_Estimation_UnitPrice_View.py:267", + ), + ] + ) + + +# ============================================================================= +# ④ 관급·사급 자재대 +# ============================================================================= + + +def _material_columns(*, excluded: bool) -> list[ColumnProvenance]: + """자재대 열 일곱. 「안 갈린 것」 표만 통째로 `excluded` 로 선다.""" + if excluded: + why = ( + "⚠ **관급·사급이 안 갈린 줄** — 어느 합계에도 넣지 않는다. 못 세운 것이 아니라 " + "**세면 안 되는** 자리다. 관급자재대에도 도급 재료비에도 넣으면 이중계상이 된다" + ) + return [ + ColumnProvenance( + key=key, + label=label, + tier=TIER_EXCLUDED, + source=why, + code="B09_Estimation_BillOfQuantities_Rows.py:398", + ) + for key, label in ( + ("name", "자재"), + ("spec", "규격"), + ("unit", "단위"), + ("total_amount", "수량"), + ("unit_price_krw", "단가"), + ("amount_krw", "금액"), + ("note", "비고"), + ) + ] + return [ + _label("name", "자재"), + _label("spec", "규격"), + _label("unit", "단위"), + ColumnProvenance( + key="total_amount", + label="수량", + tier=TIER_SURVEY, + formula="B08 이 낸 자재 수량 × 할증률", + source="할증 사유는 그 줄의 사유에 적힘. 할증률이 아직 없는 자재는 " + "**할증 전 값**으로 서고 그 사실이 표 밑에 뜬다", + code="B09_Estimation_MaterialSheet.py:47 MaterialSheetRow", + ), + ColumnProvenance( + key="unit_price_krw", + label="단가", + tier=TIER_STANDARD, + formula="단가판에서 찾은 값", + source="⚠ **관급과 사급은 원천이 다르다** — 관급은 나라장터, 사급은 물가지·견적. " + "관급을 「사급 단가 없음」으로 적으면 안 된다", + rule="못 찾으면 **0 으로 때우지 않고 비운다** — 사유가 그 줄에 적힌다", + code="B09_Estimation_MaterialSheet.py:117 build_material_sheet", + ), + ColumnProvenance( + key="amount_krw", + label="금액", + tier=TIER_FINAL, + formula="수량 × 단가", + source="⚠ **사급만 도급 재료비로 든다.** 관급은 총원가 **밖** 별도 표기라 " + "여기 합계가 원가계산서 재료비와 같지 않다", + code="B09_Estimation_MaterialSheet.py:117 build_material_sheet", + ), + _note_column("B09_Estimation_BillOfQuantities.py:BillRow.note"), + ] + + +# ============================================================================= +# ⑤ 중기목록표 · 기초자료 목록표 +# ============================================================================= + + +def machine_sheet() -> dict[str, Any]: + hourly = "시간당 사용료 — 「각종 중기경비계산서」에 셈 과정이 그대로 펼쳐진다" + return sheet_provenance( + [ + _label("code", "코드번호"), + _label("name", "명 칭"), + _label("spec", "규 격"), + _label("unit", "단위"), + ColumnProvenance( + key="total_krw", + label="합 계", + tier=TIER_CALC, + formula="노무비 + 재료비 + 경비", + source=hourly, + code="B09_Estimation_Lists.py:105 machine_list", + ), + ColumnProvenance( + key="labor_krw", + label="노 무 비", + tier=TIER_CALC, + formula="조종원 노임 ÷ 8시간 × 16/12 × 25/20 (약 1.667배)", + source="공표 노임은 기본급여액뿐이라 제수당·상여금·퇴직급여충당금을 따로 " + "계상함(건협 임금적용요령 4-나 · 기재부 집행기준 제76조의3). " + "⚠ 계수 자체의 예규 원문은 아직 못 봐 실무 관행을 따름", + code="B09_Estimation_MachineCost.py:74 OPERATOR_ALLOWANCE_FACTOR", + ), + ColumnProvenance( + key="material_krw", + label="재 료 비", + tier=TIER_CALC, + formula="주연료(L/hr) × 유가 + 잡재료(주연료의 %)", + source="유가는 전국 또는 고른 시도의 공시가 — 기초자료 탭에서 고른다. " + "잡재료는 연료 소요량에 포함되어 있어 따로 세지 않는다", + rule="같은 기종이라도 **조합 사용이면 잡재료가 16% 로 줄어** 재료비가 달라진다 " + "— 그래서 층이 따로 선다(건설품셈 제8장 [주]⑤)", + code="B09_Estimation_MachineExpenseSheet.py:81 machine_expense_sheets", + ), + ColumnProvenance( + key="expense_krw", + label="경 비", + tier=TIER_CALC, + formula="취득가격 × 손료계수(상각비 + 정비비 + 관리비, 10⁻⁷)", + source="취득가격·내용시간·연간표준가동시간·계수 셋은 모두 품셈 표 값", + code="B09_Estimation_MachineExpenseSheet.py:81 machine_expense_sheets", + ), + _note_column("B09_Estimation_Lists.py:105 machine_list"), + ] + ) + + +def catalog_sheet() -> dict[str, Any]: + """기초자료 탭의 목록표 셋(노무비·재료비·경비)이 같이 쓰는 사전.""" + return sheet_provenance( + [ + _label("code", "코드번호"), + _label("name", "명 칭"), + _label("spec", "규 격"), + _label("unit", "단위"), + ColumnProvenance( + key="unit_price_krw", + label="단 가", + tier=TIER_STANDARD, + formula="단가판 값을 그대로 옮김 (여기서 셈하지 않음)", + source="어느 판·어느 기준일인지는 산출기초 ① 에 지문까지 남음. " + "⚠ 경비목록표의 값은 **기계 취득가격(천원)** 이고 시간당 사용료가 아니다", + rule="자재단가대비표에서 **원천 다섯 중 하나를 골라** 적용 단가가 선다 — " + "값 안에 고름이 숨은 자리(PLAN 8-36 ㉯)", + code="B09_Estimation_Lists.py:50 catalog_list", + ), + _note_column("B09_Estimation_Lists.py:50 catalog_list"), + ] + ) + + +# ============================================================================= +# 응답에 싣기 +# ============================================================================= + + +def estimation_provenance() -> dict[str, Any] | None: + """B09 응답에 실을 사전 — **개발환경이 아니면 `None`.** + + 시트를 늘릴 때는 여기 한 줄만 더한다. 화면은 시트 이름으로 찾아 쓴다. + + ⚠ 아직 사전이 없는 장 — 단가산출근거·설계서 구성·산출기초·환율및기초자료· + 자재단가대비표·산출 조건. 앞 넷은 **값을 낳는 표가 아니라 모으는 표**라 + 열 사전보다 먼저 「사전 대상인가」를 정해야 한다(PLAN 8-36 ㉴). + """ + return provenance_payload( + { + "cost_sheet": cost_sheet(), + "boq": boq_sheet(), + "unit_price_list": unit_price_list_sheet(), + "unit_price_detail": unit_price_detail_sheet(), + "material": sheet_provenance(_material_columns(excluded=False)), + "material_unknown": sheet_provenance(_material_columns(excluded=True)), + "machine": machine_sheet(), + "catalog": catalog_sheet(), + } + ) diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index cfc9e431..4be24d13 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -28,6 +28,7 @@ from B09_Estimation.B09_Estimation_Engine_Cost import ( proposed_profit_adjustment, ) from B09_Estimation.B09_Estimation_PriceBook import PriceBookError +from B09_Estimation.B09_Estimation_Provenance import estimation_provenance from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill from B09_Estimation.B09_Estimation_Guards import DoubleCountError from B09_Estimation.B09_Estimation_Rates import RateLookupError @@ -47,6 +48,18 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) +def _with_provenance(body: dict[str, Any]) -> dict[str, Any]: + """근거 사전을 응답에 얹는다 — **개발환경이 아니면 칸 자체를 안 만든다.** + + ⚠ 빈 dict 를 실으면 화면이 「사전이 있는데 비었다」로 읽어 빈 카드를 띄운다. + 그래서 `None` 이면 **키를 넣지 않는다**(로직 보안의 문은 서버 쪽 하나뿐이다). + """ + provenance = estimation_provenance() + if provenance is not None: + body["provenance"] = provenance + return body + + class CostRequest(BaseModel): """원가계산 입력 — 금액은 원 단위.""" @@ -176,7 +189,7 @@ async def compute_cost(project_id: UUID, payload: CostRequest) -> JSONResponse: body["suggested_profit_adjustment_krw"] = str( proposed_profit_adjustment(result, payload.target_contract_amount_krw) ) - return JSONResponse(content={"status": "success", **body}) + return JSONResponse(content=_with_provenance({"status": "success", **body})) @router.get("/{project_id}/estimation/items") @@ -203,11 +216,13 @@ async def list_unit_price_titles(project_id: UUID) -> JSONResponse: try: build = await _build_for(project_id) return JSONResponse( - content={ - "status": "success", - "summary": build_summary(build), - "rows": list_unit_prices(build), - } + content=_with_provenance( + { + "status": "success", + "summary": build_summary(build), + "rows": list_unit_prices(build), + } + ) ) except Exception: logger.exception("B09 일위대가 목록 실패: project_id=%s", project_id) @@ -274,7 +289,9 @@ async def get_base_data_lists(project_id: UUID) -> JSONResponse: try: return JSONResponse( - content={"status": "success", **all_lists(await _build_for(project_id))} + content=_with_provenance( + {"status": "success", **all_lists(await _build_for(project_id))} + ) ) except Exception: logger.exception("B09 기초자료 목록 실패: project_id=%s", project_id) @@ -689,7 +706,9 @@ async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse: """일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다.""" try: return JSONResponse( - content={"status": "success", **detail_of(await _build_for(project_id), code)} + content=_with_provenance( + {"status": "success", **detail_of(await _build_for(project_id), code)} + ) ) except PriceBookError as error: return JSONResponse(status_code=404, content={"status": "error", "message": str(error)}) @@ -759,14 +778,18 @@ async def get_bill(project_id: UUID) -> JSONResponse: ) return JSONResponse( - content={ - "status": "success", - "rows": [row.as_dict() for row in result.rows], - "excluded": [row.as_dict() for row in result.excluded], - "materials": [row.as_dict() for row in result.material_rows], - "summary": bill_summary(result), - "price_basis": result.price_basis.as_dict() if result.price_basis else {"entries": []}, - } + content=_with_provenance( + { + "status": "success", + "rows": [row.as_dict() for row in result.rows], + "excluded": [row.as_dict() for row in result.excluded], + "materials": [row.as_dict() for row in result.material_rows], + "summary": bill_summary(result), + "price_basis": ( + result.price_basis.as_dict() if result.price_basis else {"entries": []} + ), + } + ) ) diff --git a/B09_Estimation/B09_Estimation_UI_BaseData.ts b/B09_Estimation/B09_Estimation_UI_BaseData.ts index 5f88db88..4f376412 100644 --- a/B09_Estimation/B09_Estimation_UI_BaseData.ts +++ b/B09_Estimation/B09_Estimation_UI_BaseData.ts @@ -13,6 +13,12 @@ * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { + attachProvenance, + markProvenanceCell, + type ProvenancePayload, + type ProvenanceSheet, +} from "@ui/ui_template_provenance"; import { API_BASE_URL } from "@config/config_frontend"; function L(key: keyof typeof ui_locales): string { @@ -48,6 +54,8 @@ export interface BaseDataDto { material: BaseDataRow[]; expense: BaseDataRow[]; machine: MachineRow[]; + /** 근거 사전 — **개발환경에서만** 온다. 없으면 호버·등급색이 통째로 안 붙는다. */ + provenance?: ProvenancePayload; } export async function fetchBaseData(projectId: string): Promise { @@ -80,7 +88,19 @@ function note(text: string): HTMLElement { return el; } -function table(headers: string[], rows: string[][], leftCols: number[]): HTMLElement { +/** + * 표 한 장. + * + * `keys`·`sheet` 를 함께 주면 칸마다 근거 호버가 붙는다 — **사전이 없으면 아무 일도 + * 안 한다**(빈 카드를 띄우면 「설명이 있다」는 거짓만 남는다). 안 주는 표는 종전 그대로다. + */ +function table( + headers: string[], + rows: string[][], + leftCols: number[], + keys?: string[], + sheet?: ProvenanceSheet, +): HTMLElement { const el = document.createElement("table"); el.className = "b09-sheet"; const thead = document.createElement("thead"); @@ -99,16 +119,20 @@ function table(headers: string[], rows: string[][], leftCols: number[]): HTMLEle const td = document.createElement("td"); td.textContent = text; if (leftCols.includes(index)) td.className = "b09-left"; + const key = keys?.[index]; + const column = key ? sheet?.columns[key] : undefined; + if (key && column) markProvenanceCell(td, key, column.tier); tr.append(td); }); tbody.append(tr); } el.append(thead, tbody); + attachProvenance(el, sheet); return el; } /** 목록표 한 장 — 코드·명칭·규격·단위·단가·비고 (실무 시트와 같은 칸). */ -function catalogTable(rows: BaseDataRow[]): HTMLElement { +function catalogTable(rows: BaseDataRow[], sheet?: ProvenanceSheet): HTMLElement { return table( ["코드번호", "명 칭", "규 격", "단위", "단 가", "비 고"], rows.map((row) => [ @@ -120,6 +144,8 @@ function catalogTable(rows: BaseDataRow[]): HTMLElement { row.note, ]), [0, 1, 2, 5], + ["code", "name", "spec", "unit", "unit_price_krw", "note"], + sheet, ); } @@ -148,7 +174,7 @@ export function drawBaseDataTab(body: HTMLElement, data: BaseDataDto): void { body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다.")); continue; } - body.append(catalogTable(rows)); + body.append(catalogTable(rows, data.provenance?.sheets?.catalog)); } } @@ -174,6 +200,18 @@ export function drawMachineTab(body: HTMLElement, data: BaseDataDto): void { row.note, ]), [0, 1, 2, 8], + [ + "code", + "name", + "spec", + "unit", + "total_krw", + "labor_krw", + "material_krw", + "expense_krw", + "note", + ], + data.provenance?.sheets?.machine, ), ); // ⚠ 계산 과정을 감추지 않는다(PLAN 8-13). 조종원 환산이 실무와 다른 것을 여기서 밝힌다. diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index fa5576f8..60b3a1fd 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -17,6 +17,13 @@ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { + attachProvenance, + createProvenanceToggle, + markProvenanceCell, + type ProvenancePayload, + type ProvenanceSheet, +} from "@ui/ui_template_provenance"; import { drawBaseDataTab, drawFactorChoices, @@ -72,6 +79,8 @@ interface CostSheetDto { rate_version: { dataset_id: string; effective_date: string; sha256: string }; notes: string[]; suggested_profit_adjustment_krw?: string; + /** 근거 사전 — **개발환경에서만** 온다. 없으면 호버·등급색이 통째로 안 붙는다. */ + provenance?: ProvenancePayload; } interface UnitPriceRow { @@ -96,6 +105,7 @@ interface UnitPriceListDto { labor_reliability: Array<{ code: string; name: string; flag: string; why: string }>; }; rows: UnitPriceRow[]; + provenance?: ProvenancePayload; } interface UnitPriceDetailRow extends UnitPriceRow { @@ -120,6 +130,7 @@ interface UnitPriceDetailDto { expense: string; total: string; sum_matches: boolean; + provenance?: ProvenancePayload; /** 품셈 표에 있는데 아직 안 붙은 줄 — 있으면 이 단가는 **붙은 줄만의 값**이다. */ unattached: string[]; unattached_note: string; @@ -247,7 +258,37 @@ function formatWon(value: string): string { return n.toLocaleString("ko-KR"); } +/** + * 줄 사유 조각을 줄에 실어 둔다 — 카드가 꺼내 쓴다. + * + * ⚠ 조각마다 **닿는 열**이 함께 온다. 줄에 달렸다고 모든 칸에 띄우면 + * 「금액」 카드에 「갈래 근거…」 가 떠서 읽는 사람을 속인다(2026-09-12 B08 실측). + */ +function stashRowNotes(tr: HTMLElement, notes?: Array<{ column: string; text: string }>): void { + if (notes?.length) tr.dataset.provNotes = JSON.stringify(notes); +} + +/** 그 칸에 **닿는** 줄 사유만 돌려준다. 열 키가 빈 조각은 줄 전체에 걸리는 사유다. */ +function rowNotesFor(cell: HTMLElement, columnKey: string): string[] { + const raw = cell.closest("tr")?.dataset.provNotes; + if (!raw) return []; + try { + return (JSON.parse(raw) as Array<{ column: string; text: string }>) + .filter((note) => note.column === "" || note.column === columnKey) + .map((note) => note.text); + } catch { + return []; + } +} + +/** 칸에 열 키·등급을 심는다 — **사전에 없는 열은 아무 일도 안 한다**(빈 카드 방지). */ +function mark(cell: HTMLElement, sheet: ProvenanceSheet | undefined, columnKey: string): void { + const column = sheet?.columns[columnKey]; + if (column) markProvenanceCell(cell, columnKey, column.tier); +} + function buildCostSheetTable(sheet: CostSheetDto): HTMLElement { + const prov = sheet.provenance?.sheets?.cost_sheet; const wrap = document.createElement("div"); wrap.className = "b09-sheet"; @@ -295,11 +336,17 @@ function buildCostSheetTable(sheet: CostSheetDto): HTMLElement { note.className = "b09-left"; note.textContent = line.note; + mark(name, prov, "name"); + mark(amount, prov, "amount_krw"); + mark(rate, prov, "rate_percent"); + mark(basis, prov, "formula_text"); + mark(note, prov, "note"); tr.append(name, amount, rate, basis, note); tbody.append(tr); } table.append(tbody); wrap.append(table); + attachProvenance(wrap, prov); return wrap; } @@ -311,6 +358,7 @@ function buildUnitPriceList( ): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b09-sheet b09-up-list"; + const prov = list.provenance?.sheets?.unit_price_list; const caption = document.createElement("div"); caption.className = "b09-hint"; @@ -349,16 +397,21 @@ function buildUnitPriceList( const unit = document.createElement("td"); unit.className = "b09-left"; unit.textContent = row.unit; + mark(name, prov, "name"); + mark(unit, prov, "unit"); tr.append(name, unit); - for (const value of [row.material, row.labor, row.expense, row.total]) { + const moneyKeys = ["material", "labor", "expense", "total"]; + [row.material, row.labor, row.expense, row.total].forEach((value, index) => { const cell = document.createElement("td"); cell.textContent = formatWon(value); + mark(cell, prov, moneyKeys[index]); tr.append(cell); - } + }); body.append(tr); } table.append(body); wrap.append(table); + attachProvenance(wrap, prov); return wrap; } @@ -369,6 +422,7 @@ function buildUnitPriceDetail( ): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b09-sheet b09-up-detail"; + const prov = detail.provenance?.sheets?.unit_price_detail; const caption = document.createElement("div"); caption.className = "b09-hint"; @@ -441,12 +495,18 @@ function buildUnitPriceDetail( const unit = document.createElement("td"); unit.className = "b09-left"; unit.textContent = row.unit; + mark(name, prov, "name"); + mark(spec, prov, "spec"); + mark(source, prov, "source"); + mark(unit, prov, "unit"); tr.append(name, spec, source, unit); - for (const value of [row.quantity, row.material, row.labor, row.expense, row.total]) { + const detailKeys = ["quantity", "material", "labor", "expense", "total"]; + [row.quantity, row.material, row.labor, row.expense, row.total].forEach((value, index) => { const cell = document.createElement("td"); cell.textContent = formatWon(value); + mark(cell, prov, detailKeys[index]); tr.append(cell); - } + }); body.append(tr); } @@ -466,6 +526,7 @@ function buildUnitPriceDetail( table.append(body); wrap.append(table); + attachProvenance(wrap, prov); return wrap; } @@ -725,6 +786,12 @@ interface BillRowDto { is_group: boolean; in_bill: boolean; note: string; + /** + * 줄 사유 **조각** — 어느 사유가 어느 열에 닿는지까지 서버가 갈라 보낸다. + * ⚠ 줄에 달렸다고 모든 칸에 띄우면 「금액」 카드에 「갈래 근거…」 가 떠서 읽는 사람을 + * 속인다(2026-09-12 B08 실측). `column` 이 빈 글인 것만 줄 전체에 붙는다. + */ + notes?: Array<{ column: string; text: string }>; } interface PriceBasisEntryDto { @@ -757,6 +824,7 @@ interface BillDto { material_sheet: MaterialSheetDto | null; }; price_basis: { entries: PriceBasisEntryDto[] }; + provenance?: ProvenancePayload; } interface MaterialSheetRowDto { @@ -767,6 +835,7 @@ interface MaterialSheetRowDto { unit_price_krw: string | null; amount_krw: string | null; note: string; + notes?: Array<{ column: string; text: string }>; } interface MaterialSheetDto { @@ -839,6 +908,9 @@ export async function renderB09Estimation(root: HTMLElement): Promise { let selectedUnitPrice: string | null = null; let bill: BillDto | null = null; let priceBasis: string | null = null; + // 근거 사전이 **한 번이라도** 왔는지 — 개발환경에서만 온다. 안 오면 토글도 안 세운다 + // (없는 기능의 단추가 떠 있으면 눌러 보고 「고장났다」고 읽는다). + let hasProvenance = false; const main = document.createElement("div"); main.className = "b09-main"; @@ -852,11 +924,19 @@ export async function renderB09Estimation(root: HTMLElement): Promise { body.style.display = "flex"; body.style.flexDirection = "column"; + /** 사전이 **처음 온 순간에만** 탭 줄을 다시 세운다 — 토글이 그때 생긴다. */ + const noteProvenance = (payload?: ProvenancePayload): void => { + if (!payload || hasProvenance) return; + hasProvenance = true; + drawTabs(); + }; + /** 일위대가 본표를 불러 다시 그린다 — 기계 줄을 누르면 그 층으로 파고든다. */ const openUnitPrice = async (code: string): Promise => { if (!projectId) return; try { unitPriceDetail = await fetchUnitPriceDetail(projectId, code); + noteProvenance(unitPriceDetail.provenance); selectedUnitPrice = code; drawBody(); } catch { @@ -925,6 +1005,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise { void (async () => { try { bill = await fetchBill(projectId); + noteProvenance(bill.provenance); } catch { bill = null; window.alert(L("B09_Estimation_Boq_Failed")); @@ -942,6 +1023,18 @@ export async function renderB09Estimation(root: HTMLElement): Promise { head.innerHTML = "No.공종규격단위" + "수량단가금액비고"; + // 열 키는 서버 `BillRow.as_dict()` 낱말과 같아야 사전이 붙는다. + const boqKeys = [ + "item_no", + "name", + "spec", + "unit", + "quantity", + "unit_price_krw", + "amount_krw", + "note", + ]; + const prov = bill.provenance?.sheets?.boq; const tbody = document.createElement("tbody"); for (const row of bill.rows) { const tr = document.createElement("tr"); @@ -959,16 +1052,20 @@ export async function renderB09Estimation(root: HTMLElement): Promise { row.amount_krw ?? "", row.note, ]; - for (const text of cells) { + cells.forEach((text, index) => { const td = document.createElement("td"); td.textContent = text; + // 머리(그룹)줄은 값이 없다 — 빈 칸에 카드를 띄우면 「설명이 있다」는 거짓이 남는다. + if (!row.is_group) mark(td, prov, boqKeys[index]); tr.append(td); - } + }); if (row.is_group) tr.style.fontWeight = "600"; + else stashRowNotes(tr, row.notes); tbody.append(tr); } table.append(head, tbody); body.append(table); + attachProvenance(table, prov, rowNotesFor); const total = document.createElement("div"); total.className = "b09-hint"; @@ -1130,11 +1227,13 @@ export async function renderB09Estimation(root: HTMLElement): Promise { return; } - for (const [labelKey, rows, total] of [ - ["B09_Estimation_Mat_Contractor", sheet.contractor, sheet.contractor_total_krw], - ["B09_Estimation_Mat_Owner", sheet.owner, sheet.owner_total_krw], - ["B09_Estimation_Mat_Unknown", sheet.unknown, null], - ] as Array<[keyof typeof ui_locales, MaterialSheetRowDto[], string | null]>) { + // ⚠ 「안 갈린 것」은 **못 세운 것이 아니라 세면 안 되는 것**이라 사전을 따로 쓴다 + // (`excluded` — 채우면 이중계상, PLAN 8-36 ㉱). + for (const [labelKey, rows, total, sheetName] of [ + ["B09_Estimation_Mat_Contractor", sheet.contractor, sheet.contractor_total_krw, "material"], + ["B09_Estimation_Mat_Owner", sheet.owner, sheet.owner_total_krw, "material"], + ["B09_Estimation_Mat_Unknown", sheet.unknown, null, "material_unknown"], + ] as Array<[keyof typeof ui_locales, MaterialSheetRowDto[], string | null, string]>) { const head = document.createElement("div"); head.className = "b09-hint"; head.textContent = `${L(labelKey)} (${rows.length})` + (total === null ? "" : ` — ${total}`); @@ -1146,10 +1245,20 @@ export async function renderB09Estimation(root: HTMLElement): Promise { table.innerHTML = "자재규격단위수량" + "단가금액비고"; + const matKeys = [ + "name", + "spec", + "unit", + "total_amount", + "unit_price_krw", + "amount_krw", + "note", + ]; + const prov = bill?.provenance?.sheets?.[sheetName]; const tbody = document.createElement("tbody"); for (const row of rows) { const tr = document.createElement("tr"); - for (const text of [ + [ row.name, row.spec, row.unit, @@ -1157,15 +1266,18 @@ export async function renderB09Estimation(root: HTMLElement): Promise { row.unit_price_krw ?? "", row.amount_krw ?? "", row.note, - ]) { + ].forEach((text, index) => { const td = document.createElement("td"); td.textContent = text; + mark(td, prov, matKeys[index]); tr.append(td); - } + }); + stashRowNotes(tr, row.notes); tbody.append(tr); } table.append(tbody); body.append(table); + attachProvenance(table, prov, rowNotesFor); } for (const note of sheet.notes) { @@ -1249,6 +1361,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise { void fetchBaseData(projectId) .then((data) => { baseData = data; + noteProvenance(data.provenance); drawBody(); }) .catch(() => { @@ -1370,11 +1483,15 @@ export async function renderB09Estimation(root: HTMLElement): Promise { void fetchUnitPriceList(projectId) .then((data) => { unitPriceList = data; + noteProvenance(data.provenance); drawBody(); }) .catch(() => showToast(L("B09_Estimation_UP_Load_Failed"), "error")); } }); + // ⚠ 등급색은 **평소엔 꺼 둔다** — 여덟 색이 늘 켜져 있으면 표가 알록달록해 + // 실무 시트와 눈으로 대조를 못 한다(PLAN 8-36 ②). 단추는 탭 줄 끝에 둔다. + if (hasProvenance) bar.append(createProvenanceToggle(root)); const old = main.querySelector(".b09-tabs"); if (old) old.replaceWith(bar); else main.prepend(bar); @@ -1386,6 +1503,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise { if (!projectId) return; try { sheet = await fetchCostSheet(projectId, form); + noteProvenance(sheet.provenance); renderRateVersion(panel.rateVersionBox, sheet); panel.hintBox.textContent = sheet.suggested_profit_adjustment_krw && sheet.suggested_profit_adjustment_krw !== "0" From ce9a83a655d0da8b0db67963eb537715b7624a42 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 12 Sep 2026 15:54:08 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(b06):=20=EC=A2=85=EB=8B=A8=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=EC=84=A0=C2=B7=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=95=8C?= =?UTF-8?q?=EC=95=BD=EC=9D=84=20=EB=81=8C=EC=96=B4=20=EC=98=AE=EA=B8=B8=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EA=B2=8C=20=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B05 에서만 되던 끌어 옮기기를 B06 종단에도 붙임 — 측점선과 알약 어느 쪽을 끌어도 같은 길을 타고, 관은 예약 이동(저장·확정 때 정본과 배수유역 재분할), 구조물은 정본 이동임. 끌기 판별과 대상 찾기는 새 모듈(B06_Section_UI_Section_View_Menu)로 모아, 종단 화면 본체가 더 커지지 않게 둠. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR --- B06_Section/B06_Section_UI_Page.ts | 5 ++ .../B06_Section_UI_Page_Structures_Panel.ts | 24 ++++++++++ B06_Section/B06_Section_UI_Section_View.ts | 17 ++++++- .../B06_Section_UI_Section_View_Chart.ts | 5 +- .../B06_Section_UI_Section_View_Menu.ts | 47 +++++++++++++++++++ 5 files changed, 95 insertions(+), 3 deletions(-) diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 5b9d296e..b9fe7344 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -495,6 +495,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { removeStructure: (structureId) => { structuresPanel.removeStructureById(structureId); }, + movePipe: (fromChainageM, toChainageM) => + structuresPanel.movePipeTo(fromChainageM, toChainageM), + moveStructure: (structureId, toChainageM) => { + structuresPanel.moveStructureTo(structureId, toChainageM); + }, }); // 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리). const pipeOptionsContext: PipeOptionsContext = { diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 1a824da0..3a790a29 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -111,6 +111,8 @@ export interface B06StructuresPanel { removeStructureById: (structureId: string) => boolean; addPipeAt: (chainageM: number) => void; removePipeAt: (chainageM: number) => void; + movePipeTo: (fromChainageM: number, toChainageM: number) => void; + moveStructureTo: (structureId: string, toChainageM: number) => boolean; /** 유입구 "구조"에 합쳐진 B06 유입측 형식 — 조정창 값과 맞춘다(2026-08-29 지시 5). */ facility: { setInletStructure: (value: "auto" | "revet" | "I" | "L" | "U") => void; @@ -434,6 +436,26 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc section.setPipeFacilities(pipeFacilities); } + /** 관 옮기기 — 폼의 측점 칸과 종단 끌기가 같은 길을 탄다. 재분할은 [저장]·[확정] 뒤. */ + function movePipeTo(fromChainageM: number, toChainageM: number): void { + if (!deps.movePipe) { + 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, + ); + if (hit) hit.chainage_m = to; + if (currentChainageM !== null && Math.abs(currentChainageM - fromChainageM) < PIPE_MATCH_M) + currentChainageM = to; + pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m); + section.setPipeFacilities(pipeFacilities); + pushMarks(); + showToast(PIPE_MOVE_NOTICE, "success"); + } + /** 관 빼기 — 목록·폼·종단 우클릭이 같은 길을 탄다. 정본은 [저장]·[확정]에서 나간다. */ function removePipeAt(chainageM: number): void { if (!deps.removePipe) { @@ -463,6 +485,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc removeStructureById: (structureId) => section.removeById(structureId), addPipeAt: (chainageM) => section.addAt(chainageM, "pipe"), removePipeAt: (chainageM) => removePipeAt(chainageM), + movePipeTo: (fromChainageM, toChainageM) => movePipeTo(fromChainageM, toChainageM), + moveStructureTo: (structureId, toChainageM) => section.moveById(structureId, toChainageM), facility: { setInletStructure: (value) => section.facility.setInletStructure(value), onInletStructureChange: (handler) => section.facility.onInletStructureChange(handler), diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index d5b940e1..51c76845 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -52,6 +52,7 @@ import { } from "../B05_Profile/B05_Profile_UI_Structures_Marks"; import { mountSectionStructureMenu, + moveMarkById, type SectionStructureEdit, } from "./B06_Section_UI_Section_View_Menu"; import { @@ -511,6 +512,14 @@ export function createSectionView( // 생겨 패널이 16,000px 까지 부푼다(2026-09-07 실측). 빼 두면 한 번에 수렴한다. const laneHeight = markStructures.length && markTypes.length ? STRUCTURE_LANE_HEIGHT_PX : 0; const heights = chartHeights(Math.max(lastChartAvailable - laneHeight, MIN_LONG_HEIGHT)); + const moveStationMark = (markId: string, toChainageM: number): void => { + if (!structureEdit) return; + moveMarkById(markId, toChainageM, { + structures: markStructures, + stations: detail.longitudinal.stations ?? [], + edit: structureEdit, + }); + }; const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval); const chartWidth = Math.max(renderWidth, minWidth); const chart = buildLongitudinalChart({ @@ -524,6 +533,10 @@ export function createSectionView( minWidth, scrollLeft: keepScrollLeft, viewportWidth: chartWrap.clientWidth || chartWidth, + // 측점선을 끌면 그 구조물이 옮겨 간다 — 관은 예약 이동, 구조물은 정본 이동. + onDragStation: structureEdit + ? (stationId, toChainageM) => moveStationMark(stationId, toChainageM) + : undefined, }); const longAxis = chart.axis; const nodes: Element[] = [chart.node]; @@ -555,8 +568,8 @@ export function createSectionView( } if (best) selectStation(best.id, true); }, - // 알약 끌기는 B05 몫이다(배수유역 재분할이 걸린다) — 여기서는 자리만 보여 준다. - onMove: () => undefined, + // 알약 끌기도 B05 와 같게 받는다(2026-09-12 일원화) — 정본은 [저장]·[확정]에서. + onMove: (structureId, toChainageM) => moveStationMark(structureId, toChainageM), }), ); } diff --git a/B06_Section/B06_Section_UI_Section_View_Chart.ts b/B06_Section/B06_Section_UI_Section_View_Chart.ts index 7ab41541..05701438 100644 --- a/B06_Section/B06_Section_UI_Section_View_Chart.ts +++ b/B06_Section/B06_Section_UI_Section_View_Chart.ts @@ -30,6 +30,8 @@ export interface LongitudinalChartInput { /** 지금 보이는 구간을 정하는 값 — 가로 스크롤 위치와 컨테이너 안쪽 폭. */ scrollLeft: number; viewportWidth: number; + /** 구조물(비정규) 측점선을 끌어 옮겼다. 안 넘기면 그 선은 못 잡는다. */ + onDragStation?: (stationId: string, toChainageM: number) => void; } export interface LongitudinalChartResult { @@ -110,7 +112,8 @@ export function buildLongitudinalChart(input: LongitudinalChartInput): Longitudi axis = next; }, undefined, - undefined, + // 구조물(비정규) 측점선 끌어 옮기기 — B05 와 같은 조작이다(2026-09-12 일원화). + input.onDragStation, 0, 0, visibleElevationRange(detail, fromM, toM) ?? undefined, diff --git a/B06_Section/B06_Section_UI_Section_View_Menu.ts b/B06_Section/B06_Section_UI_Section_View_Menu.ts index 6a0c1a24..f3f41f18 100644 --- a/B06_Section/B06_Section_UI_Section_View_Menu.ts +++ b/B06_Section/B06_Section_UI_Section_View_Menu.ts @@ -25,6 +25,53 @@ export interface SectionStructureEdit { addStructureType: (chainageM: number, typeId: string) => void; /** 구조물(관 아님)을 뺀다. */ removeStructure: (structureId: string) => void; + /** 관을 다른 측점으로 옮긴다(측점선·알약 끌기). */ + movePipe: (fromChainageM: number, toChainageM: number) => void; + /** 구조물(관 아님)을 다른 측점으로 옮긴다. */ + moveStructure: (structureId: string, toChainageM: number) => void; +} + +/** + * 측점선·알약을 끌어 놓았다 — 그래프 위 id 로 구조물을 찾아 같은 예약 경로로 보낸다. + * 종단 측점선의 id 는 **종단 정본 측점 id** 라 알약(구조물 id)과 다를 수 있다. 둘 다 받아 + * 같은 자리(누가거리)로 맞춘다. + */ +export function moveMarkById( + markId: string, + toChainageM: number, + input: { + structures: ReadonlyArray; + stations: ReadonlyArray<{ station_id: string; chainage_m: number }>; + edit: SectionStructureEdit; + }, +): void { + const direct = input.structures.find((entry) => String(entry.structure_id) === markId); + if (direct) { + moveStructureMark(input.structures, input.edit, markId, toChainageM); + return; + } + const station = input.stations.find((entry) => String(entry.station_id) === markId); + if (!station) return; + // 측점선 id 로 온 경우 — 그 측점 자리에 선 구조물을 찾는다(관 매칭과 같은 0.51m). + const near = input.structures.find( + (entry) => Math.abs(structureAnchorM(entry) - station.chainage_m) < 0.51, + ); + if (near) moveStructureMark(input.structures, input.edit, String(near.structure_id), toChainageM); +} + +/** 측점선·알약을 끌었을 때 — 관인지 구조물인지 갈라 같은 예약 경로로 보낸다. */ +function moveStructureMark( + structures: ReadonlyArray, + edit: SectionStructureEdit, + structureId: string, + toChainageM: number, +): void { + const hit = structures.find((entry) => String(entry.structure_id) === structureId); + if (!hit) return; + const from = structureAnchorM(hit); + if (Math.abs(from - toChainageM) < 0.005) return; + if (isPipeMark(hit)) edit.movePipe(from, toChainageM); + else edit.moveStructure(structureId, toChainageM); } /** 알약·우클릭이 쓰는 「관인가」 판정 — 관은 정본이 달라 지우는 길도 다르다. */ From c94e77734ecda6e7d6effb0125d6258b40720fce Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 12 Sep 2026 16:00:19 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix(=EA=B3=B5=EC=9A=A9):=20=EA=B7=BC?= =?UTF-8?q?=EA=B1=B0=20=ED=98=B8=EB=B2=84=20=EB=B0=B0=EC=A7=80=EB=A5=BC=20?= =?UTF-8?q?=EC=B9=B8=20=EB=93=B1=EA=B8=89=EC=9C=BC=EB=A1=9C=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EA=B3=A0=20=EB=B9=A0=EC=A7=84=20=EB=93=B1=EA=B8=89?= =?UTF-8?q?=EC=9D=80=20=EC=97=B4=EC=97=90=EC=84=9C=20=EC=B1=84=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 열이라도 줄마다 성격이 갈리는 자리가 있음 — 원가계산서 「금액」은 중간줄 (간접노무비 따위)이 계산인데 마지막줄(총원가·도급금액·총계)은 최종임(데스크탑 보조 B09 배선에서 나옴). `markProvenanceCell` 은 이미 칸마다 등급을 받고 있었으나 카드 배지가 열 등급으로만 그려져 **띠는 최종인데 배지는 「계산」**으로 어긋났음. - `fill()` 이 칸을 받아 배지를 `cell.dataset.provTier || column.tier` 로 그림 - 등급을 안 심은 칸은 `attachProvenance` 가 열 등급으로 채움 — 안 그러면 배지는 뜨는데 띠가 안 붙는 「색 없는 칸」이 생김. 부르는 쪽이 등급을 빼먹기 쉬운 자리라 여기서 맞춤. 줄마다 다른 칸에만 세 번째 인자를 주면 됨. 자체검증(ORCA 5173) — 같은 열 `amount_krw` 에서 등급 안 심은 칸은 배지 「계산」·띠 초록 rgb(31,138,76), `final` 심은 칸은 배지 「최종」·띠 금색 rgb(184,134,11) 3px inset. 배지와 띠가 같은 말을 함. `tsc --noEmit` 통과 · 병합 뒤 `pytest -q` 1313 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk --- ui_template/ui_template_provenance.ts | 33 +++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/ui_template/ui_template_provenance.ts b/ui_template/ui_template_provenance.ts index be53625e..396023ee 100644 --- a/ui_template/ui_template_provenance.ts +++ b/ui_template/ui_template_provenance.ts @@ -52,7 +52,14 @@ const TINT_KEY = "aislo.provenance.tint"; const STYLE_ID = "ui-provenance-style"; const CARD_ID = "ui-provenance-card"; -/** 칸에 심는 표시 — 열 키와 등급. 표를 그리는 쪽이 칸마다 한 번 부른다. */ +/** + * 칸에 심는 표시 — 열 키와 등급. 표를 그리는 쪽이 칸마다 한 번 부른다. + * + * `tier` 를 주면 **그 칸만 열 등급을 이긴다.** 같은 열이라도 줄마다 성격이 갈리는 + * 자리가 있기 때문이다 — 원가계산서 「금액」은 중간줄(간접노무비 따위)이 `calc` 인데 + * 마지막줄(총원가·도급금액·총계)은 `final` 이다(2026-09-12 데스크탑 보조 B09 배선). + * 칸 등급을 심지 않으면 열 등급이 그대로 선다. + */ export function markProvenanceCell(cell: HTMLElement, columnKey: string, tier?: string): void { cell.dataset.provCol = columnKey; if (tier) cell.dataset.provTier = tier; @@ -137,7 +144,15 @@ function place(element: HTMLElement, x: number, y: number): void { } /** 카드 한 장을 채운다. 값은 **화면에 적힌 글자 그대로** 보인다 — 자리수까지 같은 것이 요점. */ -function fill(target: HTMLElement, column: ProvenanceColumn, value: string, extra: string[]): void { +/** 카드 한 장을 채운다. 배지는 **칸 등급**을 먼저 본다 — 띄는 띄었는데 배지가 다른 말을 + * 하면 읽는 사람이 둘 중 어느 것을 믿을지 모른다. */ +function fill( + target: HTMLElement, + cell: HTMLElement, + column: ProvenanceColumn, + value: string, + extra: string[], +): void { target.replaceChildren(); const head = document.createElement("div"); head.className = "ui-prov-card__head"; @@ -145,8 +160,9 @@ function fill(target: HTMLElement, column: ProvenanceColumn, value: string, extr title.textContent = column.label; const badge = document.createElement("span"); badge.className = "ui-prov-card__badge"; - badge.dataset.provTier = column.tier; - badge.textContent = TIER_LABELS[column.tier] ?? column.tier; + const tier = cell.dataset.provTier || column.tier; + badge.dataset.provTier = tier; + badge.textContent = TIER_LABELS[tier] ?? tier; head.append(title, badge); target.append(head); @@ -171,6 +187,14 @@ export function attachProvenance( ): void { if (!sheet) return; injectProvenanceStyles(); + // 등급을 안 심은 칸은 **여기서 열 등급으로 채운다.** + // 색칠은 CSS 가 `data-prov-tier` 를 보고 하므로, 그 칸은 배지만 띄고 띄는 안 붙어 + // 「색이 안 붙는 칸」이 생긴다. 부르는 쪽이 등급을 빼먹는 것은 흔한 일이라 여기서 맞춘다. + for (const cell of root.querySelectorAll("[data-prov-col]")) { + if (cell.dataset.provTier) continue; + const tier = sheet.columns[cell.dataset.provCol ?? ""]?.tier; + if (tier) cell.dataset.provTier = tier; + } const hide = (): void => { const element = document.getElementById(CARD_ID); if (element) element.style.display = "none"; @@ -183,6 +207,7 @@ export function attachProvenance( const target = card(); fill( target, + cell, column, cell.textContent?.trim() ?? "", resolveExtra?.(cell, cell.dataset.provCol ?? "") ?? [],