Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
)
|
||||
@@ -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": []}
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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). 조종원 환산이 실무와 다른 것을 여기서 밝힌다.
|
||||
|
||||
@@ -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]
|
||||
@@ -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 ?? "") ?? [],
|
||||
|
||||
Reference in New Issue
Block a user