Merge remote-tracking branch 'origin/dev' into main_laptop_1

This commit is contained in:
2026-09-12 16:05:37 +09:00
16 changed files with 1307 additions and 87 deletions
+2
View File
@@ -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).
+91 -4
View File
@@ -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<void>;
}
@@ -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<PendingEdits>) : 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<void> {
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;
+18
View File
@@ -203,6 +203,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
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,22 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
);
// 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일).
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);
},
movePipe: (fromChainageM, toChainageM) =>
structuresPanel.movePipeTo(fromChainageM, toChainageM),
moveStructure: (structureId, toChainageM) => {
structuresPanel.moveStructureTo(structureId, toChainageM);
},
});
// 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리).
const pipeOptionsContext: PipeOptionsContext = {
detail: () => sectionDetail,
@@ -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<string, number | string>) => 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();
@@ -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,13 @@ 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;
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;
@@ -248,7 +264,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 +325,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 +436,57 @@ 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) {
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),
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),
+39 -2
View File
@@ -50,6 +50,11 @@ import {
buildStructureLane,
STRUCTURE_LANE_HEIGHT_PX,
} from "../B05_Profile/B05_Profile_UI_Structures_Marks";
import {
mountSectionStructureMenu,
moveMarkById,
type SectionStructureEdit,
} from "./B06_Section_UI_Section_View_Menu";
import {
structureAnchorM,
type StructureInstance,
@@ -131,6 +136,9 @@ export interface SectionViewController {
structures: ReadonlyArray<StructureInstance>,
types: ReadonlyArray<StructureType>,
) => void;
/** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길 — 없으면 메뉴가 안 뜬다
* (2026-09-12 B05·B06 일원화). */
setStructureEdit: (edit: SectionStructureEdit | null) => void;
dispose: () => void;
}
@@ -163,6 +171,7 @@ export function createSectionView(
let currentNaturalSpoilSlope: number | undefined;
let markStructures: ReadonlyArray<StructureInstance> = [];
let markTypes: ReadonlyArray<StructureType> = [];
let structureEdit: SectionStructureEdit | null = null;
let renderWidth = 0;
let resizeTimer = 0;
let panelResizeTimer = 0;
@@ -503,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({
@@ -516,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];
@@ -547,13 +568,25 @@ export function createSectionView(
}
if (best) selectStation(best.id, true);
},
// 알약 끌기 B05 몫이다(배수유역 재분할이 걸린다) — 여기서는 자리만 보여 준다.
onMove: () => undefined,
// 알약 끌기 B05 와 같게 받는다(2026-09-12 일원화) — 정본은 [저장]·[확정]에서.
onMove: (structureId, toChainageM) => moveStationMark(structureId, toChainageM),
}),
);
}
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 +786,10 @@ export function createSectionView(
setStationSelectListener(listener) {
stationSelectListener = listener;
},
setStructureEdit(edit) {
structureEdit = edit;
drawPanel();
},
setStructureMarks(structures, types) {
markStructures = structures;
markTypes = types;
@@ -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,
@@ -0,0 +1,130 @@
/* =============================================================================
* 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;
/** 관을 다른 측점으로 옮긴다(측점선·알약 끌기). */
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<StructureInstance>;
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<StructureInstance>,
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);
}
/** 알약·우클릭이 쓰는 「관인가」 판정 — 관은 정본이 달라 지우는 길도 다르다. */
export function isPipeMark(structure: StructureInstance): boolean {
return String(structure.structure_id ?? "").startsWith("pipe-");
}
/**
* .
* (B05 ).
*/
export function mountSectionStructureMenu(
host: HTMLElement,
input: {
structures: ReadonlyArray<StructureInstance>;
types: ReadonlyArray<StructureType>;
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),
});
}
@@ -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):
@@ -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(
+478
View File
@@ -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(),
}
)
+39 -16
View File
@@ -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": []}
),
}
)
)
+41 -3
View File
@@ -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<BaseDataDto> {
@@ -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). 조종원 환산이 실무와 다른 것을 여기서 밝힌다.
+132 -14
View File
@@ -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<void> {
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<void> {
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<void> => {
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> {
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<void> {
head.innerHTML =
"<tr><th>No.</th><th>공종</th><th>규격</th><th>단위</th>" +
"<th>수량</th><th>단가</th><th>금액</th><th>비고</th></tr>";
// 열 키는 서버 `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<void> {
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<void> {
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<void> {
table.innerHTML =
"<thead><tr><th>자재</th><th>규격</th><th>단위</th><th>수량</th>" +
"<th>단가</th><th>금액</th><th>비고</th></tr></thead>";
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<void> {
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> {
void fetchBaseData(projectId)
.then((data) => {
baseData = data;
noteProvenance(data.provenance);
drawBody();
})
.catch(() => {
@@ -1370,11 +1483,15 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
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<void> {
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"
@@ -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]
+29 -4
View File
@@ -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<HTMLElement>("[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 ?? "") ?? [],