diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py new file mode 100644 index 00000000..25a82e5f --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router.py @@ -0,0 +1,175 @@ +"""B09 원가계산 라우터 — ⑤ 공사원가계산서 계산 결과를 화면에 낸다. + +지금은 **무상태 계산 엔드포인트**다. 순공사비를 받아 원가계산서 한 장을 돌려주고, +저장은 하지 않는다. 프로젝트 저장(채택 단가 스냅샷 `B09_Estimation/v1/`)은 PLAN 9-2 +항목으로 뒤에 붙인다. + +화면이 「비목 · 금액 · 요율 · 산출근거」 네 칸을 다 보이므로 (PLAN 8-13) 줄마다 그 넷을 +그대로 실어 보낸다. 안전관리비는 A·B 두 줄이 나란히 오고 `note` 에 채택 표시가 붙는다. +""" + +from __future__ import annotations + +import logging +from decimal import Decimal +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from B09_Estimation.B09_Estimation_Engine_Cost import ( + CostInput, + CostResult, + calculate_cost, + proposed_profit_adjustment, +) +from B09_Estimation.B09_Estimation_Rates import RateLookupError +from B09_Estimation.B09_Estimation_Statutory import STATUTORY_ITEMS +from common_util.common_util_workflow_state import complete_stage +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) + + +class CostRequest(BaseModel): + """원가계산 입력 — 금액은 원 단위.""" + + direct_material_krw: Decimal = Field(default=Decimal(0), ge=0) + direct_labor_krw: Decimal = Field(default=Decimal(0), ge=0) + direct_expense_krw: Decimal = Field(default=Decimal(0), ge=0) + indirect_material_krw: Decimal = Field(default=Decimal(0), ge=0) + + work_type_indirect_labor: str = "civil" + work_type_safety: str = "civil" + duration_days: int = Field(default=183, ge=1) + pension_year: int = 2026 + + owner_supplied_material_krw: Decimal = Field(default=Decimal(0), ge=0) + procurement_fee_krw: Decimal = Field(default=Decimal(0), ge=0) + include_fee_in_owner_material_total: bool = True + owner_supplied_for_safety_krw: Decimal | None = None + owner_supplied_includes_vat: bool = True + deduct_procurement_fee_for_safety: bool = False + + estimated_price_krw: Decimal | None = None + profit_adjustment_krw: Decimal = Field(default=Decimal(0), ge=0) + waste_disposal_krw: Decimal = Field(default=Decimal(0), ge=0) + + environment_work_type: str = "civil_road" + equipment_guarantee_work_type: str = "civil_general" + subcontract_guarantee_variant: str = "integrated_civil_or_industrial" + + rate_file_name: str = "rates_2026.json" + + #: 목표 도급공사비 — 주면 「필요한 이윤 조정액」을 **보여만 준다**. + #: ★ 법대로(PLAN 8-10) — 프로그램이 스스로 이윤을 깎지 않는다. + target_contract_amount_krw: Decimal | None = None + + def to_engine_input(self) -> CostInput: + return CostInput( + direct_material_krw=self.direct_material_krw, + direct_labor_krw=self.direct_labor_krw, + direct_expense_krw=self.direct_expense_krw, + indirect_material_krw=self.indirect_material_krw, + work_type_indirect_labor=self.work_type_indirect_labor, + work_type_safety=self.work_type_safety, + duration_days=self.duration_days, + pension_year=self.pension_year, + owner_supplied_material_krw=self.owner_supplied_material_krw, + procurement_fee_krw=self.procurement_fee_krw, + include_fee_in_owner_material_total=self.include_fee_in_owner_material_total, + owner_supplied_for_safety_krw=self.owner_supplied_for_safety_krw, + owner_supplied_includes_vat=self.owner_supplied_includes_vat, + deduct_procurement_fee_for_safety=self.deduct_procurement_fee_for_safety, + estimated_price_krw=self.estimated_price_krw, + profit_adjustment_krw=self.profit_adjustment_krw, + waste_disposal_krw=self.waste_disposal_krw, + environment_work_type=self.environment_work_type, + equipment_guarantee_work_type=self.equipment_guarantee_work_type, + subcontract_guarantee_variant=self.subcontract_guarantee_variant, + rate_file_name=self.rate_file_name, + ) + + +def _serialize(result: CostResult) -> dict[str, Any]: + """계산 결과를 화면이 그대로 그릴 수 있는 모양으로 편다.""" + return { + "lines": [ + { + "key": line.key, + "name": line.name, + "base_label": line.base_label, + "base_amount_krw": str(line.base_amount_krw), + "rate_percent": (None if line.rate_percent is None else str(line.rate_percent)), + "flat_amount_krw": str(line.flat_amount_krw), + "amount_krw": str(line.amount_krw), + "formula_text": line.formula_text, + "note": line.note, + } + for line in result.lines + ], + "totals": {key: str(value) for key, value in result.totals.items()}, + "rate_version": result.rate_version, + "notes": result.notes, + } + + +@router.post("/{project_id}/estimation/cost") +async def compute_cost(project_id: UUID, payload: CostRequest) -> JSONResponse: + """공사원가계산서 한 장을 계산해 돌려준다 (저장 없음).""" + try: + result = calculate_cost(payload.to_engine_input()) + except RateLookupError as error: + # 요율 구간을 못 고른 경우 — 기본값으로 때우지 않고 그대로 알린다. + logger.warning("B09 원가계산 요율 조회 실패: project_id=%s, %s", project_id, error) + return JSONResponse(status_code=422, content={"status": "error", "message": str(error)}) + except Exception: + logger.exception("B09 원가계산 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "원가계산에 실패했습니다."}, + ) + + body = _serialize(result) + if payload.target_contract_amount_krw is not None: + # 필요액을 **보여만 준다**. 적용은 설계자가 `profit_adjustment_krw` 로 명시해야 한다. + body["suggested_profit_adjustment_krw"] = str( + proposed_profit_adjustment(result, payload.target_contract_amount_krw) + ) + return JSONResponse(content={"status": "success", **body}) + + +@router.get("/{project_id}/estimation/items") +async def list_items(project_id: UUID) -> JSONResponse: + """비목 정의 목록 — 화면이 무엇을 켜고 끌 수 있는지 알기 위한 것.""" + return JSONResponse( + content={ + "status": "success", + "items": [ + {"key": item.key, "name": item.name, "base_label": item.base_label} + for item in STATUTORY_ITEMS + ], + } + ) + + +@router.post("/{project_id}/estimation/confirm") +async def confirm_estimation(project_id: UUID) -> JSONResponse: + """원가계산 단계 확정 — 워크플로 stage 6(ESTIMATION)을 COMPLETE 로 전이한다.""" + pool = get_db_pool() + async with pool.acquire() as connection: + try: + async with connection.cursor() as cursor: + await complete_stage(cursor, str(project_id), 6) + await connection.commit() + except Exception: + await connection.rollback() + logger.exception("B09 원가계산 확정 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "원가계산 단계 확정에 실패했습니다."}, + ) + return JSONResponse(content={"status": "success", "project_id": str(project_id)}) diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index 879aef9b..7e0a684c 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -1,26 +1,481 @@ /* ============================================================================= * B09_Estimation_UI_Page.ts - * 로그인 후 09: 6차 워크플로우 (견적·문서) + * 로그인 후 09: 6차 워크플로우 (원가계산) * - * ⚠️ 본문 준비 중 — 워크플로우 셸(헤더+스텝바)만 구성. 추후 구체화. - * 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowLayout 재사용. + * 화면 규칙 (PLAN 8-13 · 화면 기획) + * - 3단 레이아웃: 상단 타이틀·스텝바 / 좌측 고정폭 입력 / 우측 탭 + 표. + * - 원가계산서 줄은 **「비목 · 금액 · 요율 · 산출근거」 네 칸**을 다 보인다. + * 결과 숫자만 보이면 설계자가 검산을 못 한다. + * - **안전관리비는 A·B 두 줄을 나란히 두고 채택한 쪽을 표시**한다 + * (실무 `안전관리비검토` 시트와 같은 서식, PLAN 8-12). + * - **어느 판 요율로 계산했는지**를 좌측에 남긴다 — 재현성(PLAN 9-2). + * - 이윤 조정액은 **설계자가 직접 넣을 때만** 반영. 목표 도급액을 넣으면 필요액을 + * 보여만 준다 (★법대로 PLAN 8-10). * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { renderPendingWorkflow, workflowSteps } from "../A00_Common/b_page_scaffold"; +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"; -/** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } +/* ----------------------------------------------------------------------------- + * 타입 — 라우터 응답과 1:1 + * -------------------------------------------------------------------------- */ + +interface CostLineDto { + key: string; + name: string; + base_label: string; + base_amount_krw: string; + rate_percent: string | null; + flat_amount_krw: string; + amount_krw: string; + formula_text: string; + note: string; +} + +interface CostSheetDto { + status: string; + lines: CostLineDto[]; + totals: Record; + rate_version: { dataset_id: string; effective_date: string; sha256: string }; + notes: string[]; + suggested_profit_adjustment_krw?: string; +} + +/** 좌측 입력 상태 — 화면이 들고 있는 값. 저장은 [확정] 때만. */ +interface CostFormState { + direct_material_krw: string; + direct_labor_krw: string; + direct_expense_krw: string; + duration_days: string; + owner_supplied_material_krw: string; + procurement_fee_krw: string; + profit_adjustment_krw: string; + target_contract_amount_krw: string; +} + +const INITIAL_FORM: CostFormState = { + direct_material_krw: "0", + direct_labor_krw: "0", + direct_expense_krw: "0", + duration_days: "183", + owner_supplied_material_krw: "0", + procurement_fee_krw: "0", + profit_adjustment_krw: "0", + target_contract_amount_krw: "", +}; + +/** 총계 성격의 줄 — 표에서 굵게 띄운다. */ +const TOTAL_KEYS = new Set([ + "material_cost", + "labor_cost", + "expense", + "net_construction_cost", + "total_cost", + "contract_amount", + "grand_total", +]); + +/* ----------------------------------------------------------------------------- + * 스타일 — 공통 토큰만 사용 (frontend.md §1 하드코딩 금지) + * -------------------------------------------------------------------------- */ + +const STYLE_ID = "b09-estimation-styles"; + +function injectStyles(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` +.b09-panel { display: flex; flex-direction: column; gap: var(--space-md, 12px); } +.b09-panel__group { display: flex; flex-direction: column; gap: var(--space-xs, 4px); } +.b09-panel__legend { + font-size: var(--font-size-xs, 12px); letter-spacing: .06em; + color: var(--color-text-secondary); text-transform: uppercase; +} +.b09-panel__readonly { + font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); + display: flex; justify-content: space-between; gap: var(--space-sm, 8px); + border-bottom: 1px solid var(--color-border); padding: 2px 0; +} +.b09-panel__actions { display: flex; gap: var(--space-xs, 4px); margin-top: var(--space-sm, 8px); } +.b09-hint { font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); } + +.b09-main { display: flex; flex-direction: column; gap: var(--space-sm, 8px); height: 100%; min-height: 0; } +.b09-tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--color-border); padding-bottom: 6px; } +.b09-tab { + font-size: var(--font-size-xs, 12px); padding: 2px 8px; cursor: pointer; + border: 1px solid var(--color-border); background: transparent; color: var(--color-text-secondary); +} +.b09-tab.is-active { border-color: var(--color-primary); color: var(--color-primary); background: var(--color-surface); } +.b09-tab:disabled { cursor: not-allowed; opacity: .55; } + +.b09-sheet { overflow: auto; min-height: 0; flex: 1; } +.b09-sheet table { width: 100%; border-collapse: collapse; font-size: var(--font-size-sm, 13px); } +.b09-sheet th, .b09-sheet td { + border-bottom: 1px solid var(--color-border); padding: 4px 8px; text-align: right; + white-space: nowrap; font-variant-numeric: tabular-nums; +} +.b09-sheet th { text-align: center; color: var(--color-text-secondary); font-weight: 600; } +.b09-sheet td.b09-left, .b09-sheet th.b09-left { text-align: left; white-space: normal; } +.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-empty { padding: var(--space-lg, 16px); color: var(--color-text-secondary); font-size: var(--font-size-sm, 13px); } +`; + document.head.append(style); +} + +/* ----------------------------------------------------------------------------- + * 표 그리기 + * -------------------------------------------------------------------------- */ + +function formatWon(value: string): string { + const n = Number(value); + if (!Number.isFinite(n)) return value; + return n.toLocaleString("ko-KR"); +} + +function buildCostSheetTable(sheet: CostSheetDto): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b09-sheet"; + + const table = document.createElement("table"); + const thead = document.createElement("thead"); + const headRow = document.createElement("tr"); + const headers: Array<[string, boolean]> = [ + [L("B09_Estimation_Col_Item"), true], + [L("B09_Estimation_Col_Amount"), false], + [L("B09_Estimation_Col_Rate"), false], + [L("B09_Estimation_Col_Basis"), true], + [L("B09_Estimation_Col_Note"), true], + ]; + for (const [text, left] of headers) { + const th = document.createElement("th"); + th.textContent = text; + if (left) th.className = "b09-left"; + headRow.append(th); + } + thead.append(headRow); + table.append(thead); + + const tbody = document.createElement("tbody"); + 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"); + + const name = document.createElement("td"); + name.className = "b09-left"; + name.textContent = line.name; + + const amount = document.createElement("td"); + amount.textContent = formatWon(line.amount_krw); + + const rate = document.createElement("td"); + rate.textContent = line.rate_percent === null ? "" : `${line.rate_percent}%`; + + const basis = document.createElement("td"); + basis.className = "b09-left"; + basis.textContent = line.formula_text; + + const note = document.createElement("td"); + note.className = "b09-left"; + note.textContent = line.note; + + tr.append(name, amount, rate, basis, note); + tbody.append(tr); + } + table.append(tbody); + wrap.append(table); + return wrap; +} + +/* ----------------------------------------------------------------------------- + * 좌측 패널 + * -------------------------------------------------------------------------- */ + +interface PanelHandles { + root: HTMLElement; + rateVersionBox: HTMLElement; + hintBox: HTMLElement; +} + +function buildSidePanel( + form: CostFormState, + onRecalc: () => void, + onConfirm: () => void, +): PanelHandles { + const root = document.createElement("div"); + root.className = "b09-panel"; + + const addGroup = ( + legendKey: keyof typeof ui_locales, + fields: Array<[keyof CostFormState, keyof typeof ui_locales]>, + ): void => { + const group = document.createElement("div"); + group.className = "b09-panel__group"; + const legend = document.createElement("span"); + legend.className = "b09-panel__legend"; + legend.textContent = L(legendKey); + group.append(legend); + for (const [field, labelKey] of fields) { + const handle = createInputField({ + label: L(labelKey), + type: "number", + min: 0, + value: form[field], + onInput: (value) => { + form[field] = value; + }, + }); + group.append(handle.root); + } + root.append(group); + }; + + addGroup("B09_Estimation_Group_Condition", [ + ["direct_material_krw", "B09_Estimation_Field_DirectMaterial"], + ["direct_labor_krw", "B09_Estimation_Field_DirectLabor"], + ["direct_expense_krw", "B09_Estimation_Field_DirectExpense"], + ["duration_days", "B09_Estimation_Field_Duration"], + ]); + + // 요율 판 — 읽기 전용. 「어느 판으로 계산했나」가 화면에 남아야 재현성이 선다. + const rateGroup = document.createElement("div"); + rateGroup.className = "b09-panel__group"; + const rateLegend = document.createElement("span"); + rateLegend.className = "b09-panel__legend"; + rateLegend.textContent = L("B09_Estimation_Group_RateVersion"); + const rateVersionBox = document.createElement("div"); + rateGroup.append(rateLegend, rateVersionBox); + root.append(rateGroup); + + addGroup("B09_Estimation_Group_Supplied", [ + ["owner_supplied_material_krw", "B09_Estimation_Field_OwnerMaterial"], + ["procurement_fee_krw", "B09_Estimation_Field_ProcurementFee"], + ]); + + addGroup("B09_Estimation_Group_Profit", [ + ["profit_adjustment_krw", "B09_Estimation_Field_ProfitAdjust"], + ["target_contract_amount_krw", "B09_Estimation_Field_TargetContract"], + ]); + + const hintBox = document.createElement("div"); + hintBox.className = "b09-hint"; + root.append(hintBox); + + 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 }), + ); + root.append(actions); + + return { root, rateVersionBox, hintBox }; +} + +function renderRateVersion(box: HTMLElement, sheet: CostSheetDto | null): void { + box.replaceChildren(); + if (!sheet) return; + const rows: Array<[string, string]> = [ + ["적용일", sheet.rate_version.effective_date || "—"], + ["지문", sheet.rate_version.sha256 ? `${sheet.rate_version.sha256.slice(0, 8)}…` : "—"], + ]; + for (const [label, value] of rows) { + const row = document.createElement("div"); + row.className = "b09-panel__readonly"; + const left = document.createElement("span"); + left.textContent = label; + const right = document.createElement("span"); + right.textContent = value; + row.append(left, right); + box.append(row); + } +} + +/* ----------------------------------------------------------------------------- + * 탭 + * -------------------------------------------------------------------------- */ + +const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [ + ["cost_sheet", "B09_Estimation_Tab_CostSheet", true], + ["boq", "B09_Estimation_Tab_Boq", false], + ["unit_price", "B09_Estimation_Tab_UnitPrice", false], + ["price_basis", "B09_Estimation_Tab_PriceBasis", false], + ["machine", "B09_Estimation_Tab_Machine", false], + ["duration", "B09_Estimation_Tab_Duration", false], + ["supply", "B09_Estimation_Tab_Supply", false], + ["base_data", "B09_Estimation_Tab_BaseData", false], +]; + +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) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b09-tab"; + button.dataset.tab = key; + button.textContent = L(labelKey); + button.disabled = !enabled; + if (!enabled) button.title = L("B09_Estimation_Tab_Pending"); + if (key === active) button.classList.add("is-active"); + button.addEventListener("click", () => onSelect(key)); + bar.append(button); + } + return bar; +} + +/* ----------------------------------------------------------------------------- + * API + * -------------------------------------------------------------------------- */ + +function toRequestBody(form: CostFormState): Record { + 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), + direct_expense_krw: num(form.direct_expense_krw), + duration_days: Number(num(form.duration_days)), + owner_supplied_material_krw: num(form.owner_supplied_material_krw), + procurement_fee_krw: num(form.procurement_fee_krw), + profit_adjustment_krw: num(form.profit_adjustment_krw), + }; + if (form.target_contract_amount_krw.trim() !== "") { + body.target_contract_amount_krw = form.target_contract_amount_krw.trim(); + } + return body; +} + +async function fetchCostSheet(projectId: string, form: CostFormState): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost`, + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(toRequestBody(form)), + }, + ); + if (!response.ok) throw new Error(`estimation cost failed: ${response.status}`); + return (await response.json()) as CostSheetDto; +} + +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}`); +} + /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ + export async function renderB09Estimation(root: HTMLElement): Promise { - await renderPendingWorkflow(root, { + injectStyles(); + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const form: CostFormState = { ...INITIAL_FORM }; + let activeTab = "cost_sheet"; + let sheet: CostSheetDto | null = null; + + const main = document.createElement("div"); + main.className = "b09-main"; + const body = document.createElement("div"); + body.style.flex = "1"; + body.style.minHeight = "0"; + body.style.display = "flex"; + body.style.flexDirection = "column"; + + const drawBody = (): void => { + body.replaceChildren(); + if (activeTab !== "cost_sheet") { + const empty = document.createElement("div"); + empty.className = "b09-empty"; + empty.textContent = L("B09_Estimation_Tab_Pending"); + body.append(empty); + return; + } + if (!sheet) { + const empty = document.createElement("div"); + empty.className = "b09-empty"; + empty.textContent = L("B09_Estimation_Btn_Recalc"); + body.append(empty); + return; + } + body.append(buildCostSheetTable(sheet)); + for (const note of sheet.notes) { + const line = document.createElement("div"); + line.className = "b09-hint"; + line.textContent = note; + body.append(line); + } + }; + + const drawTabs = (): void => { + const bar = buildTabs(activeTab, (key) => { + activeTab = key; + drawTabs(); + drawBody(); + }); + const old = main.querySelector(".b09-tabs"); + if (old) old.replaceWith(bar); + else main.prepend(bar); + }; + + const panel = buildSidePanel( + form, + async () => { + if (!projectId) return; + try { + sheet = await fetchCostSheet(projectId, form); + renderRateVersion(panel.rateVersionBox, sheet); + panel.hintBox.textContent = + sheet.suggested_profit_adjustment_krw && sheet.suggested_profit_adjustment_krw !== "0" + ? `${L("B09_Estimation_Suggest_Adjust")} ${formatWon(sheet.suggested_profit_adjustment_krw)}` + : ""; + drawBody(); + } catch { + showToast(L("B09_Estimation_Calc_Failed"), "error"); + } + }, + async () => { + if (!projectId) return; + try { + await confirmEstimationStage(projectId); + showToast(L("B09_Estimation_Confirm_Success"), "success"); + goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[6]); + } catch { + showToast(L("B09_Estimation_Confirm_Failed"), "error"); + } + }, + ); + + main.append(body); + drawTabs(); + drawBody(); + + const layout = createWorkflowLayout({ title: L("B09_Estimation_Title"), steps: workflowSteps(), activeStep: 6, + leftPanel: panel.root, + mainContent: main, + routes: WORKFLOW_STEP_ROUTES, + onStepClick: (stepIndex) => { + if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); + }, }); + root.append(layout.root); } diff --git a/main.py b/main.py index 6088b1f7..410c771d 100644 --- a/main.py +++ b/main.py @@ -59,6 +59,7 @@ from B06_Section.B06_Section_Router_HaulPlan import ( from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router from B08_Quantity.B08_Quantity_Router import router as b08_quantity_router +from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router from common_util.common_util_audit import note_api_call, record_call_burst from common_util.common_util_auth import ( require_company, @@ -535,6 +536,7 @@ app.include_router(b06_section_haul_plan_router, dependencies=protected_with_com app.include_router(b07_design_router, dependencies=protected_with_company) app.include_router(b07_frame_router, dependencies=protected_with_company) app.include_router(b08_quantity_router, dependencies=protected_with_company) +app.include_router(b09_estimation_router, dependencies=protected_with_company) # ───────────────────────────────────────────────────────────────────────── diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 3e21a402..113316ac 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -610,8 +610,52 @@ export const ui_locales_b2 = { "Failed to confirm the quantity stage.", ], - /* --- B09_Estimation 견적·문서 --- */ - B09_Estimation_Title: ["설계도서", "Design Docs"], + /* --- B09_Estimation 원가계산 --- */ + B09_Estimation_Title: ["원가계산", "Cost Estimate"], + B09_Estimation_Tab_CostSheet: ["공사원가계산서", "Cost Statement"], + B09_Estimation_Tab_Boq: ["설계내역서", "Bill of Quantities"], + B09_Estimation_Tab_UnitPrice: ["일위대가", "Unit Price"], + B09_Estimation_Tab_PriceBasis: ["단가산출근거", "Price Basis"], + B09_Estimation_Tab_Machine: ["중기", "Equipment"], + B09_Estimation_Tab_Duration: ["공사기간", "Duration"], + B09_Estimation_Tab_Supply: ["관급·사급", "Supplied Materials"], + B09_Estimation_Tab_BaseData: ["기초자료", "Base Data"], + B09_Estimation_Group_Condition: ["공사 조건", "Project Conditions"], + B09_Estimation_Group_RateVersion: ["요율 판", "Rate Edition"], + B09_Estimation_Group_Supplied: ["관급자재", "Owner-Supplied"], + B09_Estimation_Group_Profit: ["이윤 조정", "Profit Adjustment"], + B09_Estimation_Field_DirectMaterial: ["직접재료비", "Direct Material"], + B09_Estimation_Field_DirectLabor: ["직접노무비", "Direct Labor"], + B09_Estimation_Field_DirectExpense: ["직접경비", "Direct Expense"], + B09_Estimation_Field_Duration: ["공사기간(일)", "Duration (days)"], + B09_Estimation_Field_WorkType: ["공종", "Work Type"], + B09_Estimation_Field_OwnerMaterial: ["순자재대", "Net Material"], + B09_Estimation_Field_ProcurementFee: ["조달수수료", "Procurement Fee"], + B09_Estimation_Field_ProfitAdjust: ["조정액", "Adjustment"], + B09_Estimation_Field_TargetContract: ["목표 도급공사비", "Target Contract"], + B09_Estimation_Btn_Recalc: ["재계산", "Recalculate"], + B09_Estimation_Btn_Confirm: ["확정", "Confirm"], + B09_Estimation_Col_Item: ["비목", "Item"], + B09_Estimation_Col_Amount: ["금액", "Amount"], + B09_Estimation_Col_Rate: ["요율", "Rate"], + B09_Estimation_Col_Basis: ["산출근거", "Basis"], + B09_Estimation_Col_Note: ["비고", "Note"], + B09_Estimation_Adopted: ["채택", "Adopted"], + B09_Estimation_NotAdopted: ["미채택", "Not adopted"], + B09_Estimation_Suggest_Adjust: [ + "목표 도급공사비를 맞추려면 이윤을 이만큼 깎아야 합니다 — 적용하려면 조정액에 직접 넣으세요.", + "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_Confirm_Success: [ + "원가계산 단계를 확정했습니다.", + "Cost estimate stage confirmed.", + ], + B09_Estimation_Confirm_Failed: [ + "원가계산 단계 확정에 실패했습니다.", + "Failed to confirm the cost estimate stage.", + ], + B09_Estimation_Tab_Pending: ["준비 중", "Coming soon"], /* --- B10_Payment 결재 --- */ B10_Payment_Title: ["결재", "Payment"],