From a6c9ca01852c798c4d15cc5f50c6dcc2f63372dc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 19:57:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=EC=9E=90=EC=9E=AC=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=EB=8C=80=EB=B9=84=ED=91=9C=C2=B7=ED=99=98=EC=9C=A8?= =?UTF-8?q?=EB=B0=8F=EA=B8=B0=EC=B4=88=EC=9E=90=EB=A3=8C=EB=A5=BC=20?= =?UTF-8?q?=EA=B8=B0=EC=B4=88=EC=9E=90=EB=A3=8C=20=ED=83=AD=EC=97=90=20?= =?UTF-8?q?=EB=B6=99=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 표를 냈는데 화면에 없으면 낸 것이 아니라 붙임. 목록표 넷 아래에 A9·A10 두 장 추가. - 자재단가대비표: 원천마다 「단가·페이지」 두 칸으로 머리글 두 줄, 채택 원천 굵게 - 슬롯 6 이 곧 「적용 단가」라 「적 용」 칸을 따로 세우지 않음 (같은 값 두 번 서는 것 방지) - 환율및기초자료: 환율·인건비(운전사 3)·단가 및 재료비 세 구획 - 유가 적용 범위 고르는 칸 — 지역 공시가는 자료가 없어 고를 수 없게 두고 까닭 표시 (확정 ⑮) - 두 표는 목록표와 따로 받아 옴 — 못 받아도 목록표 넷은 그대로 남음 - 시간당 노임이 소수로 남는 까닭을 화면에 밝힘 (여기서 자르면 기계 사용료가 어긋남) 검증: tsc 통과 · pytest 267 통과 · 공용 브라우저 5174 에서 표 6장·줄 737 확인, body.scrollWidth 1696 < innerWidth 1704 (가로 넘침 없음) Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_UI_BaseData.ts | 253 +++++++++++++++++++ B09_Estimation/B09_Estimation_UI_Page.ts | 29 ++- 2 files changed, 280 insertions(+), 2 deletions(-) diff --git a/B09_Estimation/B09_Estimation_UI_BaseData.ts b/B09_Estimation/B09_Estimation_UI_BaseData.ts index e43bdb7f..03b44474 100644 --- a/B09_Estimation/B09_Estimation_UI_BaseData.ts +++ b/B09_Estimation/B09_Estimation_UI_BaseData.ts @@ -192,3 +192,256 @@ export function drawMachineTab(body: HTMLElement, data: BaseDataDto): void { export function drawBaseDataError(body: HTMLElement): void { body.append(note(L("B09_Estimation_Tab_Pending"))); } + +/* ============================================================================= + * 자재단가대비표(A9) · 환율및기초자료(A10) — 사용자 확정 ③·⑮ 와 한 벌. + * + * 표를 냈는데 화면에 없으면 **낸 것이 아니다.** 그래서 기초자료 탭 아래에 붙인다. + * 서식은 실무 「자재단가대비표」·「환율및기초자료」 시트를 그대로 옮겼다 — + * 원천마다 **단가·페이지** 두 칸이 서고, 채택한 원천에 표시가 붙는다. + * ========================================================================== */ + +/** 원천 한 칸 — 값이 없으면 **빈칸**이다. 0 을 넣으면 「0원짜리 견적」으로 읽힌다. */ +export interface PriceSlot { + name: string; + price_krw: string | null; + /** 「페이지」 자리 — 물가지는 쪽수, 견적은 업체명·날짜(확정 ③). */ + source_note: string; + adopted: boolean; +} + +export interface MaterialComparisonRow { + code: string; + name: string; + spec: string; + unit: string; + slots: PriceSlot[]; + adopted_slot: number; + adopted_price_krw: string | null; + note: string; +} + +export interface FuelScope { + key: string; + label: string; + available: boolean; + why?: string; +} + +export interface PriceSourcesDto { + status: string; + material_comparison: { + slot_names: string[]; + rows: MaterialComparisonRow[]; + notes: string[]; + }; + base_reference: { + exchange: { rows: unknown[]; note: string }; + labor: { + rows: Array<{ + code: string; + name: string; + day_wage_krw: string | null; + hourly_krw: string | null; + formula: string; + }>; + note: string; + }; + fuel: { + diesel_krw_per_l: string | null; + scope: string; + effective_date: string; + dataset_id: string; + scopes: FuelScope[]; + note: string; + }; + }; +} + +export async function fetchPriceSources(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/price-sources`, + { credentials: "include" }, + ); + if (!response.ok) throw new Error(`price-sources ${response.status}`); + return (await response.json()) as PriceSourcesDto; +} + +/** + * 자재단가대비표 — 원천마다 **단가·페이지** 두 칸이라 머리글이 두 줄이다. + * 채택한 원천 칸에 표시를 넣어 「어느 것을 썼나」가 한눈에 보이게 한다. + */ +function comparisonTable(slotNames: string[], rows: MaterialComparisonRow[]): HTMLElement { + const el = document.createElement("table"); + el.className = "b09-sheet"; + + const thead = document.createElement("thead"); + const top = document.createElement("tr"); + const bottom = document.createElement("tr"); + // ⚠ 슬롯 6 이 곧 「적용 단가」다(`JUKNM=6`). 그 자리에 「적 용」 칸을 또 세우면 + // 같은 값이 두 번 선다 — 실무 시트도 원천 다섯 + 적용 하나로 끝난다. + const appliedIsLastSlot = + rows.length > 0 && rows.every((row) => row.adopted_slot === slotNames.length); + const trailing = appliedIsLastSlot ? [] : ["적 용"]; + const fixed = ["코드번호", "명 칭", "규 격", "단위"]; + fixed.forEach((text, index) => { + const th = document.createElement("th"); + th.textContent = text; + th.rowSpan = 2; + if (index <= 2) th.className = "b09-left"; + top.append(th); + }); + for (const name of [...slotNames, ...trailing]) { + const th = document.createElement("th"); + th.textContent = name; + th.colSpan = 2; + top.append(th); + for (const sub of ["단 가", "페이지"]) { + const cell = document.createElement("th"); + cell.textContent = sub; + bottom.append(cell); + } + } + const noteHead = document.createElement("th"); + noteHead.textContent = "비 고"; + noteHead.rowSpan = 2; + noteHead.className = "b09-left"; + top.append(noteHead); + thead.append(top, bottom); + + const tbody = document.createElement("tbody"); + for (const row of rows) { + const tr = document.createElement("tr"); + const put = (text: string, left = false): void => { + const td = document.createElement("td"); + td.textContent = text; + if (left) td.className = "b09-left"; + tr.append(td); + }; + put(row.code, true); + put(row.name, true); + put(row.spec, true); + put(row.unit); + for (const slot of row.slots) { + const td = document.createElement("td"); + td.textContent = money(slot.price_krw); + // 채택한 원천을 굵게 — 「어느 값을 썼나」를 표가 스스로 밝힌다. + if (slot.adopted) td.style.fontWeight = "700"; + tr.append(td); + const page = document.createElement("td"); + page.textContent = slot.source_note; + page.className = "b09-left"; + tr.append(page); + } + if (!appliedIsLastSlot) { + put(money(row.adopted_price_krw)); + put(row.adopted_slot ? (row.slots[row.adopted_slot - 1]?.name ?? "") : "", true); + } + put(row.note, true); + tbody.append(tr); + } + el.append(thead, tbody); + return el; +} + +/** 환율및기초자료 — 실무 시트 세 구획(환율·인건비·단가 및 재료비)을 차례대로. */ +function baseReferenceSections(body: HTMLElement, data: PriceSourcesDto["base_reference"]): void { + body.append(head("환율및기초자료 — ① 환율")); + body.append(note(data.exchange.note)); + + body.append(head(`환율및기초자료 — ② 인건비 (${data.labor.rows.length})`)); + if (data.labor.rows.length === 0) { + body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다.")); + } else { + body.append( + table( + ["코드번호", "직 종", "일 당", "시간당", "산 식"], + data.labor.rows.map((row) => [ + row.code, + row.name, + money(row.day_wage_krw), + money(row.hourly_krw), + row.formula, + ]), + [0, 1, 4], + ), + ); + } + // 시간당이 소수로 남는 까닭을 밝힌다 — 안 밝히면 「덜 다듬은 값」으로 읽힌다. + body.append( + note( + "시간당은 나눈 값을 그대로 둡니다 — 여기서 원 단위로 자르면 기계 시간당 사용료가 " + + "조금씩 어긋납니다. 자르는 자리는 일위대가·내역서 쪽입니다.", + ), + ); + body.append(note(data.labor.note)); + + body.append(head("환율및기초자료 — ③ 단가 및 재료비")); + const fuel = data.fuel; + body.append( + table( + ["항 목", "단 가", "적용 범위", "기준일", "자료"], + [ + [ + "경유", + money(fuel.diesel_krw_per_l), + fuel.scopes.find((scope) => scope.key === fuel.scope)?.label || fuel.scope, + fuel.effective_date, + fuel.dataset_id, + ], + ], + [0, 2, 3, 4], + ), + ); + + // ⚠ 확정 ⑮ — 전국/지역을 고르는 칸. 자료가 없는 것은 **고를 수 없게** 두고 + // 까닭을 곧바로 밝힌다. 고르게만 해 두고 값이 없으면 조용히 틀린 값이 선다. + const picker = document.createElement("div"); + picker.className = "b09-hint"; + picker.style.display = "flex"; + picker.style.alignItems = "center"; + picker.style.gap = "8px"; + const label = document.createElement("span"); + label.textContent = "유가 적용 범위"; + const select = document.createElement("select"); + for (const scope of fuel.scopes) { + const option = document.createElement("option"); + option.value = scope.key; + option.textContent = scope.available ? scope.label : `${scope.label} (자료 없음)`; + option.disabled = !scope.available; + option.selected = scope.key === fuel.scope; + select.append(option); + } + picker.append(label, select); + body.append(picker); + for (const scope of fuel.scopes) { + if (!scope.available && scope.why) body.append(note(`⚠ ${scope.label}: ${scope.why}`)); + } + body.append(note(fuel.note)); +} + +/** 기초자료 탭 아래쪽 — A9·A10 두 장. */ +export function drawPriceSourcesSections(body: HTMLElement, data: PriceSourcesDto): void { + const comparison = data.material_comparison; + body.append(head(`자재단가대비표 (${comparison.rows.length})`)); + if (comparison.rows.length <= 1) { + body.append( + note( + "⚠ 사급 자재 카탈로그가 아직 서지 않아 줄이 거의 없습니다 — 재료비목록표와 같은 원인입니다.", + ), + ); + } + if (comparison.rows.length === 0) { + body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다.")); + } else { + body.append(comparisonTable(comparison.slot_names, comparison.rows)); + } + for (const text of comparison.notes) body.append(note(text)); + + baseReferenceSections(body, data.base_reference); +} + +/** 두 표를 아직 못 받아왔을 때 — 화면을 비우지 않는다. */ +export function drawPriceSourcesPending(body: HTMLElement): void { + body.append(note("자재단가대비표·환율및기초자료를 불러오는 중입니다…")); +} diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index fce92be4..7052d984 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -19,8 +19,12 @@ import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { drawBaseDataTab, drawMachineTab, + drawPriceSourcesPending, + drawPriceSourcesSections, fetchBaseData, + fetchPriceSources, type BaseDataDto, + type PriceSourcesDto, } from "./B09_Estimation_UI_BaseData"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; @@ -780,6 +784,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise { const form: CostFormState = { ...INITIAL_FORM }; let activeTab = "cost_sheet"; let baseData: BaseDataDto | null = null; + let priceSources: PriceSourcesDto | null = null; let sheet: CostSheetDto | null = null; let unitPriceList: UnitPriceListDto | null = null; let unitPriceDetail: UnitPriceDetailDto | null = null; @@ -1151,8 +1156,28 @@ export async function renderB09Estimation(root: HTMLElement): Promise { } return; } - if (activeTab === "machine") drawMachineTab(body, baseData); - else drawBaseDataTab(body, baseData); + if (activeTab === "machine") { + drawMachineTab(body, baseData); + return; + } + drawBaseDataTab(body, baseData); + // 자재단가대비표·환율및기초자료는 **따로 받아 온다** — 목록표 넷이 먼저 서고 + // 두 표가 뒤따라 붙는다. 안 붙으면 위 넷도 못 보게 되는 것을 막는다. + if (priceSources) { + drawPriceSourcesSections(body, priceSources); + return; + } + drawPriceSourcesPending(body); + if (projectId) { + void fetchPriceSources(projectId) + .then((data) => { + priceSources = data; + drawBody(); + }) + .catch(() => { + /* 못 받아도 목록표 넷은 그대로 남는다. */ + }); + } return; } if (activeTab !== "cost_sheet") {