feat(B09): 원가계산 라우터·화면 신설, 페이지명 「원가계산」으로 변경
- `B09_Estimation_Router.py` 신설 — 무상태 계산 엔드포인트 3종. · `POST …/estimation/cost` 원가계산서 한 장(줄마다 비목·금액·요율·산출근거) · `GET …/estimation/items` 비목 정의 목록 · `POST …/estimation/confirm` stage 6 완료 전이 요율 구간을 못 고르면 500 이 아니라 **422 + 사유**로 알림 (기본값으로 안 때움). `target_contract_amount_krw` 를 주면 필요한 이윤 조정액을 **보여만 줌**(★법대로 8-10). - `B09_Estimation_UI_Page.ts` — 셸에서 실제 화면으로. 3단 레이아웃, 좌측 입력 4군 (공사조건·요율 판 읽기전용·관급자재·이윤 조정) + 우측 탭 8장(원가계산서 활성). 원가계산서 표는 **네 칸 + 비고**이고 **안전관리비 A·B 두 줄이 나란히**, 채택 줄은 강조·미채택 줄은 취소선. - `main.py` — B09 라우터 import·등록 **자기 줄만** 추가. - `ui_template_locale_b2.ts` — B09 키 추가, `B09_Estimation_Title` 을 「설계도서」→**「원가계산」**으로 변경 (PLAN 9-1 사용자 승인). - 자체검증: `tsc --noEmit` 통과 · 백엔드 재시작 후 `openapi.json` 에 3개 경로 등록 확인 · `/api/health` 200. 화면 조작 검증은 다음 단계(브라우저 창이 닫혀 재기동 필요). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)})
|
||||
@@ -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<string, string>;
|
||||
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<string, unknown> {
|
||||
const num = (value: string): string => (value.trim() === "" ? "0" : value.trim());
|
||||
const body: Record<string, unknown> = {
|
||||
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<CostSheetDto> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user