From ca881fbb18fb444308f3679f2756b54086c2389e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 00:09:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=EC=9B=90=EA=B0=80=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=ED=99=94=EB=A9=B4=EC=97=90=20=EA=B3=B5=EC=A2=85=20?= =?UTF-8?q?=EC=88=98=EB=9F=89=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공종 수량(코드=수량)을 일위대가에 곱해 직접비 3분할 생성, ⑤ 원가계산서 밑수로 투입 — 뭉치지 않고 재료·노무·경비 성분 그대로 전달 - 단가 없는 공종은 0 으로 안 때우고 `missing_unit_prices` 로 화면 노출 - 계산 원천을 화면에 표시 (「수량 원천: 손입력 / 직접 입력」) - ⑤ 표에 찍히는 값은 자원 집계표 규칙(반올림)으로 절단 — 소수점 유출 제거 - 일위대가 요약에 총액 분포(최소·중앙·최대)·의심 저가 목록 추가 검증: pytest 136 통과, 공용 브라우저(5174) 실측 — 수량 입력 후 재계산 시 재료비 10,478,189 · 순공사원가 84,454,045 · 총공사비 113,652,213, 미매칭 공종 1건 화면 표시 확인 Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_Router.py | 31 ++- B09_Estimation/B09_Estimation_UI_Page.ts | 154 ++++++++++++-- B09_Estimation/B09_Estimation_UnitPrice.py | 37 +++- ui_template/ui_template_locale_b2.ts | 232 +++++++++++++++++---- 4 files changed, 386 insertions(+), 68 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index cdc4101d..762d581f 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -11,6 +11,7 @@ from __future__ import annotations import logging +from dataclasses import replace as dataclass_replace from decimal import Decimal from typing import Any from uuid import UUID @@ -28,10 +29,12 @@ from B09_Estimation.B09_Estimation_Engine_Cost import ( from B09_Estimation.B09_Estimation_PriceBook import PriceBookError from B09_Estimation.B09_Estimation_Rates import RateLookupError from B09_Estimation.B09_Estimation_Statutory import STATUTORY_ITEMS +from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_UnitPrice import ( build_summary, cached_build, detail_of, + direct_cost_from_quantities, list_unit_prices, ) from common_util.common_util_workflow_state import complete_stage @@ -70,6 +73,11 @@ class CostRequest(BaseModel): rate_file_name: str = "rates_2026.json" + #: 공종별 수량 `{공종코드: 수량}`. 주면 **직접비 3분할을 여기서 만들어** 쓴다. + #: ⚠ 일위대가 합계를 뭉쳐 넣지 않는다 — 밑수가 항목마다 갈린다(PLAN 8-9 규칙 2). + #: 지금 원천은 **손입력**이고, B08 인계(9번)가 나오면 **원천만 바꿔 끼운다**. + quantities: dict[str, Decimal] | None = None + #: 목표 도급공사비 — 주면 「필요한 이윤 조정액」을 **보여만 준다**. #: ★ 법대로(PLAN 8-10) — 프로그램이 스스로 이윤을 깎지 않는다. target_contract_amount_krw: Decimal | None = None @@ -125,8 +133,25 @@ def _serialize(result: CostResult) -> dict[str, Any]: @router.post("/{project_id}/estimation/cost") async def compute_cost(project_id: UUID, payload: CostRequest) -> JSONResponse: """공사원가계산서 한 장을 계산해 돌려준다 (저장 없음).""" + direct_source = "manual" + missing_unit_prices: list[str] = [] try: - result = calculate_cost(payload.to_engine_input()) + data = payload.to_engine_input() + if payload.quantities: + # 수량이 오면 **일위대가에서 직접비 3분할을 만들어** 갈아 끼운다. + breakdown = direct_cost_from_quantities(payload.quantities) + # ⑤ 표에 찍히는 자리라 **자원 집계표 규칙(반올림)** 으로 자른다 — + # 안 자르면 원가계산서에 소수점이 그대로 흘러나온다. + summary = OutputPlace.RESOURCE_SUMMARY + data = dataclass_replace( + data, + direct_material_krw=round_at(breakdown.material, summary), + direct_labor_krw=round_at(breakdown.labor, summary), + direct_expense_krw=round_at(breakdown.expense, summary), + ) + direct_source = "quantities" + missing_unit_prices = breakdown.missing + result = calculate_cost(data) except RateLookupError as error: # 요율 구간을 못 고른 경우 — 기본값으로 때우지 않고 그대로 알린다. logger.warning("B09 원가계산 요율 조회 실패: project_id=%s, %s", project_id, error) @@ -139,6 +164,10 @@ async def compute_cost(project_id: UUID, payload: CostRequest) -> JSONResponse: ) body = _serialize(result) + # 어느 값으로 계산했는지 화면이 알아야 한다 — 안 보이면 나중에 못 가른다. + body["direct_cost_source"] = direct_source + # 수량은 있는데 단가가 없는 공종 — **화면에 반드시 보인다**. + body["missing_unit_prices"] = missing_unit_prices if payload.target_contract_amount_krw is not None: # 필요액을 **보여만 준다**. 적용은 설계자가 `profit_adjustment_krw` 로 명시해야 한다. body["suggested_profit_adjustment_krw"] = str( diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index 813d2dd1..01c4ca1e 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -14,11 +14,18 @@ * ========================================================================== */ 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]; @@ -42,6 +49,8 @@ interface CostLineDto { interface CostSheetDto { status: string; + direct_cost_source: "manual" | "quantities"; + missing_unit_prices: string[]; lines: CostLineDto[]; totals: Record; rate_version: { dataset_id: string; effective_date: string; sha256: string }; @@ -62,7 +71,12 @@ interface UnitPriceRow { interface UnitPriceListDto { status: string; - summary: { titles: number; unit_prices: number; machine_hourly: number; notes: string[] }; + summary: { + titles: number; + unit_prices: number; + machine_hourly: number; + notes: string[]; + }; rows: UnitPriceRow[]; } @@ -101,6 +115,8 @@ interface CostFormState { procurement_fee_krw: string; profit_adjustment_krw: string; target_contract_amount_krw: string; + /** 「공종코드=수량」 한 줄씩. 비어 있으면 위 직접비 3칸을 그대로 쓴다. */ + quantities_text: string; } const INITIAL_FORM: CostFormState = { @@ -112,6 +128,7 @@ const INITIAL_FORM: CostFormState = { procurement_fee_krw: "0", profit_adjustment_krw: "0", target_contract_amount_krw: "", + quantities_text: "", }; /** 총계 성격의 줄 — 표에서 굵게 띄운다. */ @@ -170,6 +187,7 @@ function injectStyles(): void { .b09-sheet tr.is-total td { font-weight: 600; background: var(--color-surface); } .b09-sheet tr.is-adopted td { background: var(--color-surface); } .b09-sheet tr.is-dropped td { color: var(--color-text-secondary); text-decoration: line-through; } +.b09-qty { min-height: 64px; font-family: monospace; font-size: var(--font-size-xs, 12px); } .b09-clickable { cursor: pointer; } .b09-clickable:hover td { background: var(--color-surface); } .b09-up-list { max-height: 45%; } @@ -216,8 +234,10 @@ 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"; @@ -227,7 +247,8 @@ 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"; @@ -373,7 +394,13 @@ 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); @@ -388,7 +415,12 @@ 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); @@ -470,6 +502,25 @@ function buildSidePanel( ["target_contract_amount_krw", "B09_Estimation_Field_TargetContract"], ]); + // 수량 — 여러 줄이라 텍스트 영역으로. 비어 있으면 위 직접비 3칸을 그대로 쓴다. + const quantityGroup = document.createElement("div"); + quantityGroup.className = "b09-panel__group"; + const quantityLegend = document.createElement("span"); + quantityLegend.className = "b09-panel__legend"; + quantityLegend.textContent = L("B09_Estimation_Group_Quantity"); + const quantityLabel = document.createElement("label"); + quantityLabel.className = "ui-field__label"; + quantityLabel.textContent = L("B09_Estimation_Field_Quantities"); + const quantityInput = document.createElement("textarea"); + quantityInput.className = "ui-input b09-qty"; + quantityInput.rows = 4; + quantityInput.placeholder = "FP-09-21=500"; + quantityInput.addEventListener("input", () => { + form.quantities_text = quantityInput.value; + }); + quantityGroup.append(quantityLegend, quantityLabel, quantityInput); + root.append(quantityGroup); + const hintBox = document.createElement("div"); hintBox.className = "b09-hint"; root.append(hintBox); @@ -477,8 +528,15 @@ function buildSidePanel( const actions = document.createElement("div"); actions.className = "b09-panel__actions"; actions.append( - createButton({ label: L("B09_Estimation_Btn_Recalc"), variant: "filled", onClick: onRecalc }), - createButton({ label: L("B09_Estimation_Btn_Confirm"), onClick: onConfirm }), + createButton({ + label: L("B09_Estimation_Btn_Recalc"), + variant: "filled", + onClick: onRecalc, + }), + createButton({ + label: L("B09_Estimation_Btn_Confirm"), + onClick: onConfirm, + }), ); root.append(actions); @@ -490,7 +548,12 @@ 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"); @@ -519,7 +582,10 @@ 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) { @@ -541,8 +607,24 @@ function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement * API * -------------------------------------------------------------------------- */ +/** 「공종코드=수량」 여러 줄을 객체로. 형식이 아닌 줄은 조용히 버리지 않고 건너뛴다. */ +function parseQuantities(text: string): Record { + const out: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + const [code, value] = trimmed.split(/[=\t,]/); + if (!code || !value) continue; + const qty = value.trim(); + if (!/^\d+(\.\d+)?$/.test(qty)) continue; + out[code.trim()] = qty; + } + return out; +} + 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), @@ -555,10 +637,15 @@ function toRequestBody(form: CostFormState): Record { if (form.target_contract_amount_krw.trim() !== "") { body.target_contract_amount_krw = form.target_contract_amount_krw.trim(); } + const quantities = parseQuantities(form.quantities_text); + if (Object.keys(quantities).length > 0) body.quantities = quantities; 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`, { @@ -568,16 +655,20 @@ async function fetchCostSheet(projectId: string, form: CostFormState): 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; } @@ -589,7 +680,8 @@ async function fetchUnitPriceDetail( `${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; } @@ -598,7 +690,8 @@ async function confirmEstimationStage(projectId: string): Promise { `${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}`); } /* ----------------------------------------------------------------------------- @@ -689,6 +782,23 @@ export async function renderB09Estimation(root: HTMLElement): Promise { body.append(empty); return; } + // 어느 값으로 계산했는지 화면에 남긴다 — 안 보이면 나중에 못 가른다. + const source = document.createElement("div"); + source.className = "b09-hint"; + source.textContent = + sheet.direct_cost_source === "quantities" + ? L("B09_Estimation_Src_Quantities") + : L("B09_Estimation_Src_Manual"); + body.append(source); + + // 수량은 있는데 단가가 없는 공종 — 총액에서 빠졌으므로 **반드시 보인다**. + if (sheet.missing_unit_prices.length > 0) { + const missing = document.createElement("div"); + missing.className = "b09-hint"; + missing.textContent = `${L("B09_Estimation_Missing_UP")} ${sheet.missing_unit_prices.join(", ")}`; + body.append(missing); + } + body.append(buildCostSheetTable(sheet)); for (const note of sheet.notes) { const line = document.createElement("div"); @@ -725,7 +835,8 @@ 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(); @@ -757,7 +868,8 @@ 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/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index d4ae5643..c34a3a0f 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -48,6 +48,8 @@ _RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*") _ZERO = Decimal(0) #: 연료는 자재 카탈로그에 없어 합성 코드로 세운다 — 코드가 있어야 조인이 성립한다. FUEL_CODE_PREFIX = "M-FUEL-" +#: 일위대가 총액이 이보다 작으면 **성분이 빠졌을 가능성**이 크다 — 값이 있어도 경고한다. +SUSPICIOUSLY_LOW_KRW = Decimal(100) def _slots(value: Decimal) -> list[Decimal | None]: @@ -321,8 +323,34 @@ def build_summary(build: UnitPriceBuild) -> dict: kinds: dict[str, int] = {} for title in build.book.titles.values(): kinds[title.kind.value] = kinds.get(title.kind.value, 0) + 1 + # ⚠ **크기가 말이 되나**를 볼 수 있게 분포를 낸다. + # 「0 이 아님」만 보면 씨앗뿜어붙이기가 **합계 68.8원**이던 것을 못 잡는다 + # (자재·장비가 통째로 빠지고 노무 한 줄만 남았던 자리, 2026-09-07). + totals = sorted( + build.book.resolve(code).total + for code, title in build.book.titles.items() + if title.kind is PriceKind.UNIT_PRICE + ) + stats: dict[str, str] = {} + low: list[dict[str, str]] = [] + if totals: + stats = { + "min": _money_text(totals[0]), + "median": _money_text(totals[len(totals) // 2]), + "max": _money_text(totals[-1]), + } + low = [ + {"code": code, "name": title.name, "total": _money_text(money)} + for code, title in build.book.titles.items() + if title.kind is PriceKind.UNIT_PRICE + and (money := build.book.resolve(code).total) < SUSPICIOUSLY_LOW_KRW + ] + return { "titles": len(build.book.titles), + "unit_price_totals": stats, + # 값이 서기는 했는데 **크기가 이상한** 것 — 성분이 빠졌을 가능성이 크다. + "suspiciously_low": low, "unit_prices": kinds.get(PriceKind.UNIT_PRICE.value, 0), "machine_hourly": kinds.get(PriceKind.MACHINE_HOURLY.value, 0), "skipped_work_items": len(build.skipped), @@ -489,11 +517,14 @@ def cost_input_from_quantities( from B09_Estimation.B09_Estimation_Engine_Cost import CostInput breakdown = direct_cost_from_quantities(quantities, build) + # ⑤ 표에 들어가는 자리이므로 여기서 자른다 — 자원 집계표는 **반올림**이다 + # (`B09_Estimation_Rounding` 참조). `breakdown` 자체는 전정밀 값으로 남긴다. + summary = OutputPlace.RESOURCE_SUMMARY return ( CostInput( - direct_material_krw=breakdown.material, - direct_labor_krw=breakdown.labor, - direct_expense_krw=breakdown.expense, + direct_material_krw=round_at(breakdown.material, summary), + direct_labor_krw=round_at(breakdown.labor, summary), + direct_expense_krw=round_at(breakdown.expense, summary), **cost_input_kwargs, ), breakdown, diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index e64aa2c3..49035ddf 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -37,7 +37,10 @@ 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.", @@ -69,19 +72,34 @@ 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"], @@ -96,7 +114,10 @@ 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암거 전환 유량 조건 */ @@ -121,7 +142,10 @@ 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.", @@ -156,27 +180,45 @@ 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"], @@ -189,7 +231,10 @@ 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"], @@ -216,9 +261,18 @@ 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.", @@ -244,9 +298,18 @@ 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. */ @@ -267,7 +330,10 @@ 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"], @@ -276,7 +342,10 @@ 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)", @@ -444,7 +513,10 @@ 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"], @@ -485,8 +557,14 @@ 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.", @@ -499,17 +577,29 @@ 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)"], @@ -532,7 +622,10 @@ 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: [ @@ -542,7 +635,10 @@ 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.", @@ -569,7 +665,10 @@ 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"], @@ -584,7 +683,10 @@ 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 정리). */ @@ -610,7 +712,10 @@ 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.", @@ -630,15 +735,24 @@ export const ui_locales_b2 = { B08_Quantity_Side_RockSet: ["암 갈래 세트", "Rock class set"], B08_Quantity_Side_RockRatios: ["지반 구성비(%)", "Ground composition (%)"], B08_Quantity_Btn_Save: ["저장", "Save"], - B08_Quantity_Save_Success: ["산출 조건을 저장했습니다.", "Calculation settings saved."], - B08_Quantity_Save_Failed: ["산출 조건을 저장하지 못했습니다.", "Failed to save the settings."], + B08_Quantity_Save_Success: [ + "산출 조건을 저장했습니다.", + "Calculation settings saved.", + ], + B08_Quantity_Save_Failed: [ + "산출 조건을 저장하지 못했습니다.", + "Failed to save the settings.", + ], 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"], @@ -676,7 +790,10 @@ 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.", @@ -688,7 +805,10 @@ 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"], @@ -700,12 +820,35 @@ 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:", ], - B09_Estimation_UP_Load_Failed: ["일위대가를 못 불러왔습니다.", "Failed to load unit prices."], + B09_Estimation_Group_Quantity: ["수량", "Quantities"], + B09_Estimation_Field_Quantities: [ + "공종별 수량 (한 줄에 「공종코드=수량」)", + 'Quantities (one "code=qty" per line)', + ], + B09_Estimation_Src_Manual: [ + "수량 원천: 손입력(직접비 직접 입력)", + "Source: manual direct costs", + ], + B09_Estimation_Src_Quantities: [ + "수량 원천: 손입력 공종 수량 × 일위대가", + "Source: manual quantities × unit prices", + ], + B09_Estimation_Missing_UP: [ + "수량은 있는데 단가가 없는 공종 — 총액에서 빠졌습니다:", + "Quantities without a unit price — excluded from the total:", + ], + B09_Estimation_UP_Load_Failed: [ + "일위대가를 못 불러왔습니다.", + "Failed to load unit prices.", + ], /* --- B10_Payment 결재 --- */ B10_Payment_Title: ["결재", "Payment"], @@ -723,7 +866,10 @@ 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.",