From 573d7bbae2327eb32999747d969f4a518553945b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 01:36:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=EC=98=88=EC=82=B0=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C=20=ED=99=94=EB=A9=B4=20=ED=83=AD=20=EC=8B=A0?= =?UTF-8?q?=EC=84=A4=20+=20ITEM=20NO.=20=EA=B2=B9=EC=B9=A8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 화면 - 「설계내역서」 탭을 켜고 [B08 수량 불러오기] 로 표를 세움. 계층은 들여쓰기, 머리글 줄은 굵게 - 못 세운 줄은 0 으로 채우지 않고 **사유째** 표와 목록에 보임 (「일위대가 없음」·「하위 공종에 있음(후보 2건)」·「밑수를 못 찾은 표」) - 검산용 줄(보정량계·무대)은 수량만 보이고 금액칸이 빔 - ⚠ 「사급 자재 단가가 없어 자재비가 빠져 있음 — 지금 합계는 모자란 값」을 합계 옆에 상시 표시 ITEM NO. 겹침 수정 - 같은 공종코드가 지반만 달리해 두 번 오면 번호가 겹치고 있었음 (실물: 도자운반 토사/리핑암이 둘 다 `3-1`). 잎 줄은 번호를 재사용하지 않음 실측(공용 브라우저 5174, 운반이 실린 실물 자료): 16줄 · 겹친 번호 0 · 합계 488,926원 운반 4줄이 3-1~3-4 로 서고 무대 2줄은 검산줄로 빠짐 (㉡ 가 실물에서 돎) 검증: pytest 175 통과(신규 1), tsc 0건 Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities.py | 7 +- B09_Estimation/B09_Estimation_UI_Page.ts | 234 +++++++++++++----- ui_template/ui_template_locale_b2.ts | 229 +++++------------ 3 files changed, 236 insertions(+), 234 deletions(-) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index 5d18cacf..add44313 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -314,8 +314,11 @@ def build_bill( ) ) leaf = chain[-1] - item_no = emitted.get(leaf.code) or next_number(parent_no) - emitted[leaf.code] = item_no + # ⚠ **잎 줄은 번호를 재사용하지 않는다.** 같은 공종코드가 지반·규격만 달리해 + # 두 번 올 수 있고(2026-09-08 실물: 도자운반 토사/리핑암 두 줄), 그때 번호를 + # 물려주면 ITEM NO. 가 겹쳐 어느 줄인지 못 가린다. 머리글만 물려준다. + item_no = next_number(parent_no) + emitted.setdefault(leaf.code, item_no) result.rows.append(_leaf_row(item_no, leaf, item, unit_prices, result)) # ── 2) 공종을 못 고른 줄 — 이름째 남긴다 ──────────────────────────────────── diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index 01c4ca1e..603cbf96 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -14,18 +14,11 @@ * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { - createButton, - createInputField, - showToast, -} from "@ui/ui_template_elements"; +import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; -import { - goToWorkflowStage, - WORKFLOW_STEP_ROUTES, -} from "../A00_Common/b_workflow_nav"; +import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -234,10 +227,8 @@ function buildCostSheetTable(sheet: CostSheetDto): HTMLElement { for (const line of sheet.lines) { const tr = document.createElement("tr"); if (TOTAL_KEYS.has(line.key)) tr.classList.add("is-total"); - if (line.note === L("B09_Estimation_Adopted")) - tr.classList.add("is-adopted"); - if (line.note === L("B09_Estimation_NotAdopted")) - tr.classList.add("is-dropped"); + if (line.note === L("B09_Estimation_Adopted")) tr.classList.add("is-adopted"); + if (line.note === L("B09_Estimation_NotAdopted")) tr.classList.add("is-dropped"); const name = document.createElement("td"); name.className = "b09-left"; @@ -247,8 +238,7 @@ function buildCostSheetTable(sheet: CostSheetDto): HTMLElement { amount.textContent = formatWon(line.amount_krw); const rate = document.createElement("td"); - rate.textContent = - line.rate_percent === null ? "" : `${line.rate_percent}%`; + rate.textContent = line.rate_percent === null ? "" : `${line.rate_percent}%`; const basis = document.createElement("td"); basis.className = "b09-left"; @@ -394,13 +384,7 @@ function buildUnitPriceDetail( unit.className = "b09-left"; unit.textContent = row.unit; tr.append(name, spec, source, unit); - for (const value of [ - row.quantity, - row.material, - row.labor, - row.expense, - row.total, - ]) { + for (const value of [row.quantity, row.material, row.labor, row.expense, row.total]) { const cell = document.createElement("td"); cell.textContent = formatWon(value); tr.append(cell); @@ -415,12 +399,7 @@ function buildUnitPriceDetail( label.colSpan = 5; label.textContent = L("B09_Estimation_Col_Total"); sum.append(label); - for (const value of [ - detail.material, - detail.labor, - detail.expense, - detail.total, - ]) { + for (const value of [detail.material, detail.labor, detail.expense, detail.total]) { const cell = document.createElement("td"); cell.textContent = formatWon(value); sum.append(cell); @@ -548,12 +527,7 @@ function renderRateVersion(box: HTMLElement, sheet: CostSheetDto | null): void { if (!sheet) return; const rows: Array<[string, string]> = [ ["적용일", sheet.rate_version.effective_date || "—"], - [ - "지문", - sheet.rate_version.sha256 - ? `${sheet.rate_version.sha256.slice(0, 8)}…` - : "—", - ], + ["지문", sheet.rate_version.sha256 ? `${sheet.rate_version.sha256.slice(0, 8)}…` : "—"], ]; for (const [label, value] of rows) { const row = document.createElement("div"); @@ -573,7 +547,7 @@ function renderRateVersion(box: HTMLElement, sheet: CostSheetDto | null): void { const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [ ["cost_sheet", "B09_Estimation_Tab_CostSheet", true], - ["boq", "B09_Estimation_Tab_Boq", false], + ["boq", "B09_Estimation_Tab_Boq", true], ["unit_price", "B09_Estimation_Tab_UnitPrice", true], ["price_basis", "B09_Estimation_Tab_PriceBasis", false], ["machine", "B09_Estimation_Tab_Machine", false], @@ -582,10 +556,7 @@ const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [ ["base_data", "B09_Estimation_Tab_BaseData", false], ]; -function buildTabs( - active: string, - onSelect: (key: string) => void, -): HTMLElement { +function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement { const bar = document.createElement("div"); bar.className = "b09-tabs"; for (const [key, labelKey, enabled] of TAB_KEYS) { @@ -623,8 +594,7 @@ function parseQuantities(text: string): Record { } function toRequestBody(form: CostFormState): Record { - const num = (value: string): string => - value.trim() === "" ? "0" : value.trim(); + const num = (value: string): string => (value.trim() === "" ? "0" : value.trim()); const body: Record = { direct_material_krw: num(form.direct_material_krw), direct_labor_krw: num(form.direct_labor_krw), @@ -642,10 +612,7 @@ function toRequestBody(form: CostFormState): Record { return body; } -async function fetchCostSheet( - projectId: string, - form: CostFormState, -): Promise { +async function fetchCostSheet(projectId: string, form: CostFormState): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost`, { @@ -655,43 +622,72 @@ async function fetchCostSheet( body: JSON.stringify(toRequestBody(form)), }, ); - if (!response.ok) - throw new Error(`estimation cost failed: ${response.status}`); + if (!response.ok) throw new Error(`estimation cost failed: ${response.status}`); return (await response.json()) as CostSheetDto; } -async function fetchUnitPriceList( - projectId: string, -): Promise { +async function fetchUnitPriceList(projectId: string): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices`, { credentials: "include" }, ); - if (!response.ok) - throw new Error(`unit price list failed: ${response.status}`); + if (!response.ok) throw new Error(`unit price list failed: ${response.status}`); return (await response.json()) as UnitPriceListDto; } -async function fetchUnitPriceDetail( - projectId: string, - code: string, -): Promise { +async function fetchUnitPriceDetail(projectId: string, code: string): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices/${encodeURIComponent(code)}`, { credentials: "include" }, ); - if (!response.ok) - throw new Error(`unit price detail failed: ${response.status}`); + if (!response.ok) throw new Error(`unit price detail failed: ${response.status}`); return (await response.json()) as UnitPriceDetailDto; } +/** ④ 예산내역서 한 줄. 금액이 `null` 이면 **못 세운 것**이지 0 이 아니다. */ +interface BillRowDto { + item_no: string; + level: number; + code: string | null; + name: string; + spec: string; + unit: string; + quantity: string | null; + unit_price_krw: string | null; + amount_krw: string | null; + is_group: boolean; + in_bill: boolean; + note: string; +} + +interface BillDto { + rows: BillRowDto[]; + excluded: BillRowDto[]; + materials: BillRowDto[]; + summary: { + rows: number; + detail_rows: number; + body_total_krw: string; + missing: Array<{ name: string; reason: string; unit?: string; quantity?: string }>; + notes: string[]; + }; +} + +async function fetchBill(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/bill`, + { credentials: "include" }, + ); + if (!response.ok) throw new Error(`estimation bill failed: ${response.status}`); + return (await response.json()) as BillDto; +} + async function confirmEstimationStage(projectId: string): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/confirm`, { method: "POST", credentials: "include" }, ); - if (!response.ok) - throw new Error(`estimation confirm failed: ${response.status}`); + if (!response.ok) throw new Error(`estimation confirm failed: ${response.status}`); } /* ----------------------------------------------------------------------------- @@ -707,6 +703,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise { let unitPriceList: UnitPriceListDto | null = null; let unitPriceDetail: UnitPriceDetailDto | null = null; let selectedUnitPrice: string | null = null; + let bill: BillDto | null = null; const main = document.createElement("div"); main.className = "b09-main"; @@ -762,12 +759,123 @@ export async function renderB09Estimation(root: HTMLElement): Promise { } }; + /** ④ 예산내역서 — B08 수량에 단가를 붙인 표. 못 세운 줄은 **그대로 보인다**. */ + const drawBoqTab = (): void => { + if (!bill) { + if (!projectId) { + const empty = document.createElement("div"); + empty.className = "b09-empty"; + empty.textContent = L("B09_Estimation_Boq_Failed"); + body.append(empty); + return; + } + const load = document.createElement("button"); + load.type = "button"; + load.className = "b09-btn"; + load.textContent = L("B09_Estimation_Boq_Load"); + load.addEventListener("click", () => { + void (async () => { + try { + bill = await fetchBill(projectId); + } catch { + bill = null; + window.alert(L("B09_Estimation_Boq_Failed")); + } + drawBody(); + })(); + }); + body.append(load); + return; + } + + const table = document.createElement("table"); + table.className = "b09-sheet"; + const head = document.createElement("thead"); + head.innerHTML = + "No.공종규격단위" + + "수량단가금액비고"; + const tbody = document.createElement("tbody"); + for (const row of bill.rows) { + const tr = document.createElement("tr"); + // 계층은 들여쓰기로 보인다 — 번호만으로는 깊이가 안 읽힌다. + const indent = " ".repeat(Math.max(0, (row.level - 1) * 2)); + const cells = row.is_group + ? [row.item_no, indent + row.name, "", "", "", "", "", ""] + : [ + row.item_no, + indent + row.name, + row.spec, + row.unit, + row.quantity ?? "", + row.unit_price_krw ?? "", + row.amount_krw ?? "", + row.note, + ]; + for (const text of cells) { + const td = document.createElement("td"); + td.textContent = text; + tr.append(td); + } + if (row.is_group) tr.style.fontWeight = "600"; + tbody.append(tr); + } + table.append(head, tbody); + body.append(table); + + const total = document.createElement("div"); + total.className = "b09-hint"; + total.textContent = `${L("B09_Estimation_Boq_Total")}: ${bill.summary.body_total_krw}`; + body.append(total); + + // ⚠ 자재비가 빠진 채 선 합계임을 숨기지 않는다. + const shortfall = document.createElement("div"); + shortfall.className = "b09-hint"; + shortfall.textContent = L("B09_Estimation_Boq_NoMaterialPrice"); + body.append(shortfall); + + if (bill.excluded.length > 0) { + const note = document.createElement("div"); + note.className = "b09-hint"; + note.textContent = + `${L("B09_Estimation_Boq_Excluded")}: ` + + bill.excluded.map((row) => `${row.name} ${row.quantity ?? ""}${row.unit}`).join(", "); + body.append(note); + } + + if (bill.summary.missing.length > 0) { + const note = document.createElement("div"); + note.className = "b09-hint"; + note.textContent = `${L("B09_Estimation_Boq_Missing")} (${bill.summary.missing.length})`; + body.append(note); + const list = document.createElement("ul"); + for (const item of bill.summary.missing) { + const li = document.createElement("li"); + li.textContent = `${item.name} — ${item.reason}`; + list.append(li); + } + body.append(list); + } + + if (bill.materials.length > 0) { + const note = document.createElement("div"); + note.className = "b09-hint"; + note.textContent = + `${L("B09_Estimation_Boq_Materials")}: ` + + bill.materials.map((row) => `${row.name} ${row.quantity ?? ""}${row.unit}`).join(", "); + body.append(note); + } + }; + const drawBody = (): void => { body.replaceChildren(); if (activeTab === "unit_price") { drawUnitPriceTab(); return; } + if (activeTab === "boq") { + drawBoqTab(); + return; + } if (activeTab !== "cost_sheet") { const empty = document.createElement("div"); empty.className = "b09-empty"; @@ -835,8 +943,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise { sheet = await fetchCostSheet(projectId, form); renderRateVersion(panel.rateVersionBox, sheet); panel.hintBox.textContent = - sheet.suggested_profit_adjustment_krw && - sheet.suggested_profit_adjustment_krw !== "0" + sheet.suggested_profit_adjustment_krw && sheet.suggested_profit_adjustment_krw !== "0" ? `${L("B09_Estimation_Suggest_Adjust")} ${formatWon(sheet.suggested_profit_adjustment_krw)}` : ""; drawBody(); @@ -868,8 +975,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise { mainContent: main, routes: WORKFLOW_STEP_ROUTES, onStepClick: (stepIndex) => { - if (projectId) - goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); + if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); }, }); root.append(layout.root); diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 1c9317e4..df183a17 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -37,10 +37,7 @@ export const ui_locales_b2 = { "B04에서 분석해 둔 배수유역을 불러와, 지금 배치된 배관을 기준으로 세부유역(관이 담당하는 구역)을 다시 나눕니다. 관이 부족한 구간은 자동으로 보충합니다. B04 분석 결과가 없으면 B04에서 먼저 실행해야 합니다.", "Reloads the B04 drainage analysis and re-splits sub-basins around the current culverts, adding culverts where spacing requires. Run the analysis in B04 first if none exists.", ], - B05_Drainage_Btn_DeleteSelected: [ - "선택한 관 삭제", - "Delete selected culvert", - ], + B05_Drainage_Btn_DeleteSelected: ["선택한 관 삭제", "Delete selected culvert"], B05_Drainage_Btn_DeleteSelected_Tip: [ "지도에서 고른 배관 한 개를 지웁니다. 관을 먼저 눌러 고른 뒤에 쓸 수 있습니다.", "Removes the culvert selected on the map. Select a culvert marker first.", @@ -72,34 +69,19 @@ export const ui_locales_b2 = { "노선을 확정하면 배수유역도가 표시됩니다.", "The drainage map appears once the route is confirmed.", ], - B05_Drainage_Status_Analyzing: [ - "세부유역을 산정하는 중…", - "Computing sub-basins…", - ], - B05_Drainage_Status_NoBasin: [ - "산정된 배수유역이 없습니다.", - "No drainage basin was computed.", - ], + B05_Drainage_Status_Analyzing: ["세부유역을 산정하는 중…", "Computing sub-basins…"], + B05_Drainage_Status_NoBasin: ["산정된 배수유역이 없습니다.", "No drainage basin was computed."], B05_Drainage_Status_AnalyzeFailed: [ "세부유역 산정에 실패했습니다.", "Failed to compute sub-basins.", ], - B05_Drainage_Status_LoadingBase: [ - "배경도를 불러오는 중…", - "Loading the basemap…", - ], - B05_Drainage_Status_LoadingSheets: [ - "도엽 레이어를 불러오는 중…", - "Loading map sheet layers…", - ], + B05_Drainage_Status_LoadingBase: ["배경도를 불러오는 중…", "Loading the basemap…"], + B05_Drainage_Status_LoadingSheets: ["도엽 레이어를 불러오는 중…", "Loading map sheet layers…"], B05_Drainage_Status_NoSheets: [ "도엽 레이어가 없습니다. B04에서 임포트하세요.", "No map sheet layer found. Import them in B04.", ], - B05_Drainage_Status_LoadFailed: [ - "배경도를 불러오지 못했습니다.", - "Failed to load the basemap.", - ], + B05_Drainage_Status_LoadFailed: ["배경도를 불러오지 못했습니다.", "Failed to load the basemap."], B05_Drainage_Basin_Undecided: ["미정", "TBD"], /* 관 최대 규격 초과 계류 유역 — 관이 아니라 세월교 대상. 유효직경은 앞머리가 적는다 */ B05_Drainage_Basin_Bridge: ["세월교 제안", "Ford bridge proposal"], @@ -114,10 +96,7 @@ export const ui_locales_b2 = { "Tc {tc}min · I {i}mm/hr · Qd {q}m³/s (100yr, ×2.0)", ], /* {chainage}=측점 누가거리(m) */ - B05_Drainage_Basin_Chainage: [ - "측점 누가거리 {chainage}m", - "Station chainage {chainage}m", - ], + B05_Drainage_Basin_Chainage: ["측점 누가거리 {chainage}m", "Station chainage {chainage}m"], /* {d}=규격 스냅 관경(mm). 유효직경 이상인 가장 작은 레지스트리 선택지 */ B05_Drainage_Basin_RecPipe: ["Ø{d} 배관 제안", "Ø{d} pipe proposal"], /* 유효직경 Ø1,500 초과 — 교본 BOX암거 전환 유량 조건 */ @@ -142,10 +121,7 @@ export const ui_locales_b2 = { B05_Route_Field_Filter: ["지면 필터", "Ground filter"], B05_Route_Field_Method: ["지표면 표현", "Surface method"], B05_Route_Field_SurfaceId: ["지표면 모델 ID", "Surface model ID"], - B05_Route_Surface_Confirmed: [ - "확정 모델 #{id} · {method}", - "Confirmed model #{id} · {method}", - ], + B05_Route_Surface_Confirmed: ["확정 모델 #{id} · {method}", "Confirmed model #{id} · {method}"], B05_Route_Surface_NotConfirmed: [ "WF1에서 지표면 모델을 확정하세요.", "Confirm a surface model in WF1.", @@ -180,45 +156,27 @@ export const ui_locales_b2 = { ], B05_Route_Reset_Failed: ["초기화에 실패했습니다.", "Failed to reset."], B05_Route_Result_Title: ["경로 탐색 결과", "Route Result"], - B05_Route_Result_Empty: [ - "아직 계산된 경로가 없습니다.", - "No route computed yet.", - ], + B05_Route_Result_Empty: ["아직 계산된 경로가 없습니다.", "No route computed yet."], B05_Route_Result_Length: ["총 연장(m)", "Total length (m)"], B05_Route_Result_MinSlope: ["최소 경사", "Min slope"], B05_Route_Result_MaxSlope: ["최대 경사", "Max slope"], B05_Route_Result_MeanSlope: ["평균 경사", "Mean slope"], B05_Route_Result_Cost: ["비용 점수", "Cost score"], B05_Route_Result_Path: ["경로 파일", "Route file"], - B05_Route_Error_Project: [ - "먼저 프로젝트를 선택하세요.", - "Select a project first.", - ], + B05_Route_Error_Project: ["먼저 프로젝트를 선택하세요.", "Select a project first."], B05_Route_Error_Points: [ "시점과 종점 좌표를 모두 입력하세요.", "Enter both begin and end coordinates.", ], - B05_Route_Error_Filter: [ - "지면 필터 키를 입력하세요.", - "Enter a ground filter key.", - ], + B05_Route_Error_Filter: ["지면 필터 키를 입력하세요.", "Enter a ground filter key."], B05_Route_Solve_Success: ["경로 탐색을 완료했습니다.", "Route solved."], B05_Route_Solve_Failed: ["경로 탐색에 실패했습니다.", "Route solve failed."], B05_Route_Confirm_Success: ["경로를 확정했습니다.", "Route confirmed."], - B05_Route_Confirm_Failed: [ - "경로 확정에 실패했습니다.", - "Route confirm failed.", - ], - B05_Route_Group_SectionOptions: [ - "시작 측점 및 샘플링 설정", - "Start Station & Sampling Settings", - ], + B05_Route_Confirm_Failed: ["경로 확정에 실패했습니다.", "Route confirm failed."], + B05_Route_Group_SectionOptions: ["시작 측점 및 샘플링 설정", "Start Station & Sampling Settings"], B05_Route_Field_StationInterval: ["측점 간격(m)", "Station interval (m)"], B05_Route_Field_CrossHalfWidth: ["횡단 반폭(m)", "Cross half-width (m)"], - B05_Route_Field_CrossSample: [ - "횡단 샘플 간격(m)", - "Cross sample interval (m)", - ], + B05_Route_Field_CrossSample: ["횡단 샘플 간격(m)", "Cross sample interval (m)"], B05_Route_Field_LongSample: ["종단 샘플 간격(m)", "Long sample interval (m)"], B05_Route_Field_StationLines: ["측점 가로선", "Station cross lines"], B05_Route_Field_StationLabels: ["측점 라벨", "Station labels"], @@ -231,10 +189,7 @@ export const ui_locales_b2 = { B06_Profile_Field_Method: ["지표면 표현", "Surface method"], B06_Profile_Field_Crs: ["좌표계", "CRS"], B06_Profile_Group_Display: ["표시 옵션", "Display Options"], - B06_Profile_Field_VerticalExaggeration: [ - "높이 배율", - "Vertical exaggeration", - ], + B06_Profile_Field_VerticalExaggeration: ["높이 배율", "Vertical exaggeration"], B06_Profile_Field_Smooth: ["지표면 스무딩", "Smooth surface"], B06_Profile_Smooth_On: ["사용", "On"], B06_Profile_Smooth_Off: ["미사용", "Off"], @@ -261,18 +216,9 @@ export const ui_locales_b2 = { B06_Profile_Result_Length: ["종단 연장(m)", "Longitudinal length (m)"], B06_Profile_Result_CrossCount: ["횡단 개수", "Cross-section count"], B06_Profile_Result_Path: ["종단 파일", "Longitudinal file"], - B06_Profile_Error_Project: [ - "먼저 프로젝트를 선택하세요.", - "Select a project first.", - ], - B06_Profile_Confirm_Success: [ - "종·횡단을 확정했습니다.", - "Sections confirmed.", - ], - B06_Profile_Confirm_Failed: [ - "종·횡단 확정에 실패했습니다.", - "Section confirm failed.", - ], + B06_Profile_Error_Project: ["먼저 프로젝트를 선택하세요.", "Select a project first."], + B06_Profile_Confirm_Success: ["종·횡단을 확정했습니다.", "Sections confirmed."], + B06_Profile_Confirm_Failed: ["종·횡단 확정에 실패했습니다.", "Section confirm failed."], B06_Profile_Detail_Failed: [ "종·횡단 도면 데이터를 불러오지 못했습니다.", "Failed to load section drawing data.", @@ -298,18 +244,9 @@ export const ui_locales_b2 = { B06_Cross_Revet_Pipe: ["관 길이", "Pipe length"], B06_Cross_Revet_Outward: ["바깥", "outward"], B06_Cross_Revet_Inward: ["안쪽", "inward"], - B06_Cross_Revet_Left: [ - "왼쪽으로 — 관 길이 1m 단위", - "Move left — 1m of pipe length", - ], - B06_Cross_Revet_Right: [ - "오른쪽으로 — 관 길이 1m 단위", - "Move right — 1m of pipe length", - ], - B06_Cross_Revet_Reset: [ - "기슭막이 자동 자리로 초기화", - "Reset revetment to solved position", - ], + B06_Cross_Revet_Left: ["왼쪽으로 — 관 길이 1m 단위", "Move left — 1m of pipe length"], + B06_Cross_Revet_Right: ["오른쪽으로 — 관 길이 1m 단위", "Move right — 1m of pipe length"], + B06_Cross_Revet_Reset: ["기슭막이 자동 자리로 초기화", "Reset revetment to solved position"], B06_Cross_Revet_Inlet: ["기슭막이(유입)", "Revetment (inlet)"], B06_Cross_Revet_Outlet: ["기슭막이(유출)", "Revetment (outlet)"], /* 배관과 무관한 독립 기슭막이(구조물 정본 D군) — 2026-08-28. */ @@ -330,10 +267,7 @@ export const ui_locales_b2 = { "Cannot move further down the slope", ], B06_Cross_Height_Label: ["높이", "Height"], - B06_Cross_Move_Label: [ - "이동(좌우·사면 상하)", - "Move (lateral / along slope)", - ], + B06_Cross_Move_Label: ["이동(좌우·사면 상하)", "Move (lateral / along slope)"], B06_Cross_Lateral_Label: ["좌우", "Lateral"], B06_Cross_Slope_Label: ["상하(사면)", "Along slope"], B06_Cross_Height_Minus: ["높이 −0.1m", "Height −0.1m"], @@ -342,10 +276,7 @@ export const ui_locales_b2 = { "{mat} 높이 한계 {limit}m — 더 올리려면 재질을 변경하세요", "{mat} height limit {limit}m — change material to go higher", ], - B06_Cross_Height_Floor: [ - "최소 높이라 더 낮출 수 없습니다", - "Already at the minimum height", - ], + B06_Cross_Height_Floor: ["최소 높이라 더 낮출 수 없습니다", "Already at the minimum height"], B06_Cross_Basin_Limit_Pipe: [ "여기까지입니다 — 더 옮기면 배관 길이가 달라집니다(I형은 관을 감싸는 구조)", "Limit reached — moving further changes the pipe length (type I wraps the pipe)", @@ -513,10 +444,7 @@ export const ui_locales_b2 = { B06_Design_Area_Total: ["계", "Total"], /* 단위는 값 칸마다 붙이지 않고 표 좌상단(행제목 × 열제목 교차) 칸에 한 번만 적는다. */ B06_Design_Area_Unit: ["㎡", "㎡"], - B06_Design_Area_Highlight: [ - "누르면 해당 면적을 강조합니다", - "Click to highlight this area", - ], + B06_Design_Area_Highlight: ["누르면 해당 면적을 강조합니다", "Click to highlight this area"], B06_Design_Fill_Area: ["성토", "Fill"], B06_Design_Unset: ["미지정", "Not set"], B06_Design_DitchType_Legend: ["측구형식", "Ditch type"], @@ -557,14 +485,8 @@ export const ui_locales_b2 = { B06_Design_RockBoundary_Legend: ["암 경계", "Rock boundary"], B06_Design_RockBoundary_Up: ["암 경계선 올림", "Raise rock boundary"], B06_Design_RockBoundary_Down: ["암 경계선 내림", "Lower rock boundary"], - B06_Design_RockBoundary_Reset: [ - "암 경계선 기본값 복원", - "Reset rock boundary", - ], - B06_Design_Failed: [ - "횡단 설계 계산에 실패했습니다.", - "Failed to compute cross-section design.", - ], + B06_Design_RockBoundary_Reset: ["암 경계선 기본값 복원", "Reset rock boundary"], + B06_Design_Failed: ["횡단 설계 계산에 실패했습니다.", "Failed to compute cross-section design."], B06_Profile_Confirm_NeedDesign: [ "지반유형이 지정되지 않은 측점이 있습니다.", "Some stations have no ground type assigned.", @@ -577,29 +499,17 @@ export const ui_locales_b2 = { "표시 반폭만 바로 반영합니다(측점 설계 재계산 없음). 계산 반폭(20m)을 넘는 값만 재생성이 필요해 시간이 걸립니다.", "Applies the display half-width only (no per-station redesign). Only values beyond the sampled 20 m need regeneration.", ], - B06_View_Apply_Success: [ - "표시 반폭을 반영했습니다.", - "Display half-width applied.", - ], + B06_View_Apply_Success: ["표시 반폭을 반영했습니다.", "Display half-width applied."], /* --- B06 표준 횡단면 설정 패널 --- */ B06_Std_Title: ["표준 횡단면 설정", "Standard cross-section"], B06_Std_Group_Soil: ["토사 구간", "Soil section"], - B06_Std_Group_Rock: [ - "암 구간 (리핑/발파)", - "Rock section (ripping/blasting)", - ], + B06_Std_Group_Rock: ["암 구간 (리핑/발파)", "Rock section (ripping/blasting)"], B06_Std_Group_Paved: ["포장 구간", "Paved section"], B06_Std_Detail_Title: ["표준횡단면 상세값", "Standard cross-section details"], B06_Std_Section_Common: ["공통", "Common"], - B06_Std_Section_RockOnly: [ - "암 구간 — 다른 값만", - "Rock section - differing values", - ], - B06_Std_Section_PavedOnly: [ - "포장 구간 — 다른 값만", - "Paved section - differing values", - ], + B06_Std_Section_RockOnly: ["암 구간 — 다른 값만", "Rock section - differing values"], + B06_Std_Section_PavedOnly: ["포장 구간 — 다른 값만", "Paved section - differing values"], B06_Std_Field_RoadWidth: ["노폭(m)", "Road width (m)"], B06_Std_Field_ShoulderLeft: ["노견 좌(m)", "Shoulder L (m)"], B06_Std_Field_ShoulderRight: ["노견 우(m)", "Shoulder R (m)"], @@ -622,10 +532,7 @@ export const ui_locales_b2 = { "패널 설정을 전체 측점에 반영했습니다.", "Applied panel settings to all stations.", ], - B06_Std_Load_Title: [ - "다른 프로젝트에서 불러오기", - "Load from another project", - ], + B06_Std_Load_Title: ["다른 프로젝트에서 불러오기", "Load from another project"], B06_Std_Load_Select: ["프로젝트 선택", "Select project"], B06_Std_Load_Placeholder: ["— 프로젝트 선택 —", "— Select a project —"], B06_Std_Load_Empty: [ @@ -635,10 +542,7 @@ export const ui_locales_b2 = { B06_Std_Load_Loading: ["불러오는 중…", "Loading…"], B06_Std_Load_Apply: ["현재 설정에 적용", "Apply to current settings"], B06_Std_Load_Applied: ["적용되었습니다.", "Applied."], - B06_Std_Load_Failed: [ - "설계값을 불러오지 못했습니다.", - "Failed to load design values.", - ], + B06_Std_Load_Failed: ["설계값을 불러오지 못했습니다.", "Failed to load design values."], B06_Std_Load_None: [ "선택한 프로젝트에 저장된 설계값이 없습니다.", "The selected project has no saved design values.", @@ -665,10 +569,7 @@ export const ui_locales_b2 = { "Side panel will be configured after the upstream data spec is finalized.", ], B07_Cad_Loading: ["도면을 불러오는 중...", "Loading drawing..."], - B07_Cad_Load_Failed: [ - "도면을 불러오지 못했습니다.", - "Failed to load drawing.", - ], + B07_Cad_Load_Failed: ["도면을 불러오지 못했습니다.", "Failed to load drawing."], B07_Info_Ground_Title: ["지반정보", "Ground info"], B07_Info_Plan_Title: ["계획정보", "Plan info"], B07_Info_GroundType: ["지반유형", "Ground type"], @@ -683,10 +584,7 @@ export const ui_locales_b2 = { B07_Info_FillArea: ["성토 단면적", "Fill area"], B07_Info_Provisional: ["잠정", "Provisional"], B07_Info_Confirmed: ["확정", "Confirmed"], - B07_Info_NoDesign: [ - "지반·계획 지정 데이터가 없습니다.", - "No ground/plan designation data.", - ], + B07_Info_NoDesign: ["지반·계획 지정 데이터가 없습니다.", "No ground/plan designation data."], B07_Info_Station: ["측점", "Station"], /* 장(여러 측점을 담은 횡단 도면)은 측점 단위 지반·계획 정보를 갖지 않는다 — 제목을 「측점」으로 달면 어느 측점 값인지 오해된다(2026-09-03 정리). */ @@ -712,10 +610,7 @@ export const ui_locales_b2 = { "Failed to confirm the quantity stage.", ], B08_Quantity_Tab_Earthwork: ["토적표", "Earthwork Table"], - B08_Quantity_Grid_Loading: [ - "토적표를 만드는 중입니다…", - "Building the earthwork table…", - ], + B08_Quantity_Grid_Loading: ["토적표를 만드는 중입니다…", "Building the earthwork table…"], B08_Quantity_Grid_Empty: [ "측점 단면적이 아직 없습니다. 횡단 설계를 먼저 마치세요.", "No cross-section areas yet. Finish the cross-section design first.", @@ -756,15 +651,31 @@ export const ui_locales_b2 = { B08_Quantity_Unsaved: ["저장하지 않은 변경이 있습니다.", "You have unsaved changes."], B08_Quantity_Side_Method: ["산출법", "Method"], B08_Quantity_Side_Method_Value: ["평균단면적법", "Average end area"], - B08_Quantity_Side_Factors: [ - "토량환산계수(다짐)", - "Conversion factors (compacted)", - ], + B08_Quantity_Side_Factors: ["토량환산계수(다짐)", "Conversion factors (compacted)"], /* --- B09_Estimation 원가계산 --- */ B09_Estimation_Title: ["원가계산", "Cost Estimate"], B09_Estimation_Tab_CostSheet: ["공사원가계산서", "Cost Statement"], B09_Estimation_Tab_Boq: ["설계내역서", "Bill of Quantities"], + B09_Estimation_Boq_Total: ["내역서 합계", "Bill total"], + B09_Estimation_Boq_Excluded: [ + "검산용 줄 — 수량만 보이고 금액을 매기지 않습니다", + "Check rows — quantity only, never priced", + ], + B09_Estimation_Boq_Missing: [ + "금액을 못 세운 줄 — 0 으로 채우지 않고 그대로 보입니다", + "Rows without an amount — shown as-is, not zero-filled", + ], + B09_Estimation_Boq_Materials: ["자재 (별도 벌)", "Materials (separate set)"], + B09_Estimation_Boq_NoMaterialPrice: [ + "사급 자재 단가가 아직 없어 자재비가 빠져 있습니다 — 지금 합계는 모자란 값입니다.", + "Contractor-supplied material prices are missing, so material cost is absent — this total is short.", + ], + B09_Estimation_Boq_Load: ["B08 수량 불러오기", "Load B08 quantities"], + B09_Estimation_Boq_Failed: [ + "B08 인계 자료를 받지 못했습니다.", + "Could not load the B08 handoff.", + ], B09_Estimation_Tab_UnitPrice: ["일위대가", "Unit Price"], B09_Estimation_Tab_PriceBasis: ["단가산출근거", "Price Basis"], B09_Estimation_Tab_Machine: ["중기", "Equipment"], @@ -797,10 +708,7 @@ export const ui_locales_b2 = { "목표 도급공사비를 맞추려면 이윤을 이만큼 깎아야 합니다 — 적용하려면 조정액에 직접 넣으세요.", "To hit the target contract amount, profit must be reduced by this much — enter it in Adjustment to apply.", ], - B09_Estimation_Calc_Failed: [ - "원가계산에 실패했습니다.", - "Cost calculation failed.", - ], + B09_Estimation_Calc_Failed: ["원가계산에 실패했습니다.", "Cost calculation failed."], B09_Estimation_Confirm_Success: [ "원가계산 단계를 확정했습니다.", "Cost estimate stage confirmed.", @@ -812,10 +720,7 @@ export const ui_locales_b2 = { B09_Estimation_Tab_Pending: ["준비 중", "Coming soon"], B09_Estimation_UP_List: ["일위대가 목록표", "Unit Price Index"], B09_Estimation_UP_Detail: ["일위대가표", "Unit Price Sheet"], - B09_Estimation_UP_Pick: [ - "목록에서 항목을 고르세요.", - "Pick an item from the index.", - ], + B09_Estimation_UP_Pick: ["목록에서 항목을 고르세요.", "Pick an item from the index."], B09_Estimation_UP_Drill: ["펼쳐 보기", "Open"], B09_Estimation_Col_Name: ["명칭", "Name"], B09_Estimation_Col_Spec: ["규격", "Spec"], @@ -827,10 +732,7 @@ export const ui_locales_b2 = { B09_Estimation_Col_Expense: ["경비", "Expense"], B09_Estimation_Col_Total: ["합계", "Total"], B09_Estimation_UP_SumOk: ["합계 = 재료+노무+경비 일치", "Total = M+L+E ✓"], - B09_Estimation_UP_SumBad: [ - "⚠ 합계가 재료+노무+경비와 다릅니다", - "⚠ Total ≠ M+L+E", - ], + B09_Estimation_UP_SumBad: ["⚠ 합계가 재료+노무+경비와 다릅니다", "⚠ Total ≠ M+L+E"], B09_Estimation_UP_RoundGap: [ "행별로 0.1원 미만을 버려 합계 끝자리가 다릅니다 (정상). 자르기 전 합계:", "Rows are floored to 0.1 KRW, so the total's last digit differs (expected). Unrounded total:", @@ -840,10 +742,7 @@ export const ui_locales_b2 = { "공종별 수량 (한 줄에 「공종코드=수량」)", 'Quantities (one "code=qty" per line)', ], - B09_Estimation_Src_Manual: [ - "수량 원천: 손입력(직접비 직접 입력)", - "Source: manual direct costs", - ], + B09_Estimation_Src_Manual: ["수량 원천: 손입력(직접비 직접 입력)", "Source: manual direct costs"], B09_Estimation_Src_Quantities: [ "수량 원천: 손입력 공종 수량 × 일위대가", "Source: manual quantities × unit prices", @@ -852,10 +751,7 @@ export const ui_locales_b2 = { "수량은 있는데 단가가 없는 공종 — 총액에서 빠졌습니다:", "Quantities without a unit price — excluded from the total:", ], - B09_Estimation_UP_Load_Failed: [ - "일위대가를 못 불러왔습니다.", - "Failed to load unit prices.", - ], + B09_Estimation_UP_Load_Failed: ["일위대가를 못 불러왔습니다.", "Failed to load unit prices."], /* --- B10_Payment 결재 --- */ B10_Payment_Title: ["결재", "Payment"], @@ -873,10 +769,7 @@ export const ui_locales_b2 = { B10_Payment_Deposit_Title: ["계좌 입금 안내", "Bank Transfer Guide"], B10_Payment_Deposit_Account: ["입금 계좌", "Deposit Account"], B10_Payment_Deposit_Amount: ["입금 금액", "Deposit Amount"], - B10_Payment_Deposit_Pending: [ - "견적 확정 후 표시", - "Shown after estimate confirmation", - ], + B10_Payment_Deposit_Pending: ["견적 확정 후 표시", "Shown after estimate confirmation"], B10_Payment_Deposit_Note: [ "입금 확인 후 설계문서와 DWG 다운로드가 허용됩니다.", "Design documents and DWG downloads are enabled after the deposit is confirmed.",