Merge remote-tracking branch 'origin/main_desktop_1' into main_laptop_1
This commit is contained in:
@@ -123,8 +123,7 @@ const GROUPS: { label: string; sub: { label: string; cols: Column[] }[] }[] = [
|
||||
const TRIPLE_LABELS = ["단면적", "입 적", "보정량"];
|
||||
const PAIR_LABELS = ["단면적", "입 적"];
|
||||
|
||||
const flatColumns = (): Column[] =>
|
||||
GROUPS.flatMap((g) => g.sub.flatMap((s) => s.cols));
|
||||
const flatColumns = (): Column[] => GROUPS.flatMap((g) => g.sub.flatMap((s) => s.cols));
|
||||
|
||||
/** 측점 표기 — `20` → `NO.1`, `25` → `NO.1+5`. 실무 토적표가 이 모양이다. */
|
||||
function stationLabel(chainage: number, interval = 20): string {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_EarthworkGrid_Style.ts
|
||||
* 토적표 그리드 스타일. 한 번만 주입한다.
|
||||
*
|
||||
* 실무 산출서와 눈으로 대조되는 것이 이 표의 목적이라, 장식보다 **줄·칸이 또렷한 것**을
|
||||
* 우선한다. 숫자는 등폭으로 두어 자릿수가 세로로 맞는다.
|
||||
* ========================================================================== */
|
||||
|
||||
const STYLE_ID = "b08-earthwork-grid-style";
|
||||
|
||||
/* 색은 전부 프로젝트 테마 변수를 쓴다 — 어두운 테마에서 머리글이 묻히지 않아야 한다.
|
||||
글자색을 배경과 함께 지정하는 까닭이 그것이다(배경만 주면 상속색이 배경에 잠긴다). */
|
||||
const CSS = `
|
||||
.b08-grid { display: flex; flex-direction: column; gap: 8px; min-width: 0; height: 100%; }
|
||||
|
||||
.b08-grid__caption {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 열이 많아 화면을 넘친다 — 표만 가로로 구르고 페이지 몸통은 안 구른다. */
|
||||
.b08-grid__scroll { overflow: auto; flex: 1 1 auto; min-height: 0; }
|
||||
|
||||
.b08-grid__table {
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.b08-grid__table th,
|
||||
.b08-grid__table td {
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 2px 8px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.b08-grid__table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
font-weight: var(--font-weight-medium, 600);
|
||||
text-align: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 측점 열은 왼쪽에 붙어 있어야 가로로 굴러도 어느 줄인지 보인다. */
|
||||
.b08-grid__station {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
text-align: left;
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
}
|
||||
|
||||
.b08-grid__table tfoot td {
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
font-weight: var(--font-weight-medium, 600);
|
||||
}
|
||||
|
||||
.b08-quantity__tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--color-border); }
|
||||
|
||||
.b08-quantity__tab {
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-bottom: none;
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b08-quantity__tab.is-active {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-weight: var(--font-weight-medium, 600);
|
||||
}
|
||||
|
||||
.b08-quantity__body { display: flex; flex-direction: column; gap: 8px; padding: 8px; min-height: 0; flex: 1 1 auto; }
|
||||
.b08-quantity__message { margin: 0; padding: 16px; font-size: 13px; color: var(--color-text-secondary); }
|
||||
.b08-quantity__field { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; padding: 2px 0; }
|
||||
.b08-quantity__field-value { color: var(--color-text-secondary); font-variant-numeric: tabular-nums; }
|
||||
`;
|
||||
|
||||
/** 스타일을 한 번만 넣는다 — 페이지를 다시 그려도 중복되지 않는다. */
|
||||
export function injectEarthworkGridStyles(): void {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = CSS;
|
||||
document.head.append(style);
|
||||
}
|
||||
@@ -2,16 +2,23 @@
|
||||
* B08_Quantity_UI_Page.ts
|
||||
* 로그인 후 08: 5차 워크플로우 (수량 산출)
|
||||
*
|
||||
* ⚠️ 본문 준비 중 — 워크플로우 셸 + 좌측 [확정] 버튼만 구성.
|
||||
* 우측 = 실무 수량산출서의 시트를 탭으로 옮긴 것. 지금은 **토적표** 한 장이 서 있고
|
||||
* 나머지(토적집계·구조물위치·수량집계표·총괄집계·수리계산·운반거리)는 차례로 붙인다.
|
||||
* 확정 = 워크플로 stage 5(QUANTITY) 완료 처리 후 B09 설계도서로 이동.
|
||||
* 수량 본문(B06 종횡단 기반 산출)은 후속 계획에서 구현한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { renderPendingWorkflow, workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import {
|
||||
fetchWorkflowState,
|
||||
goToWorkflowStage,
|
||||
WORKFLOW_STEP_ROUTES,
|
||||
} from "../A00_Common/b_workflow_nav";
|
||||
import { renderEarthworkGrid, type EarthworkTable } from "./B08_Quantity_UI_EarthworkGrid";
|
||||
import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style";
|
||||
|
||||
/** locale 헬퍼 */
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
@@ -29,15 +36,46 @@ async function confirmQuantityStage(projectId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 좌측 패널: 준비 중 안내 + 하단 [확정] 액션 행 (다른 워크플로우 페이지와 동일 배치). */
|
||||
function buildQuantitySidePanel(projectId: string | null): HTMLElement {
|
||||
/** 토적표를 받아 온다. 노선을 안 주면 워크플로가 보고 있는 최신 노선으로 나온다. */
|
||||
async function fetchEarthworkTable(projectId: string): Promise<EarthworkTable> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/earthwork-table`,
|
||||
{ credentials: "include" },
|
||||
);
|
||||
if (!response.ok) throw new Error(`earthwork table failed: ${response.status}`);
|
||||
return (await response.json()) as EarthworkTable;
|
||||
}
|
||||
|
||||
/** 좌측 패널의 한 줄 — 이름과 값. 산출 조건을 읽기 전용으로 보인다. */
|
||||
function field(label: string, value: string): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "b08-quantity__field";
|
||||
const name = document.createElement("span");
|
||||
name.textContent = label;
|
||||
const amount = document.createElement("span");
|
||||
amount.className = "b08-quantity__field-value";
|
||||
amount.textContent = value;
|
||||
row.append(name, amount);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 좌측 패널: 산출 조건(읽기 전용) + 하단 [확정] 액션 행. */
|
||||
function buildQuantitySidePanel(
|
||||
projectId: string | null,
|
||||
table: EarthworkTable | null,
|
||||
): HTMLElement {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b08-quantity__panel";
|
||||
|
||||
const note = document.createElement("p");
|
||||
note.className = "b08-quantity__pending-note";
|
||||
note.textContent = L("B08_Quantity_Side_Pending");
|
||||
panel.append(note);
|
||||
// 계수는 서버 상수가 유일한 정의처다 — 화면은 보여 주기만 하고 값을 다시 적지 않는다.
|
||||
panel.append(field(L("B08_Quantity_Side_Method"), L("B08_Quantity_Side_Method_Value")));
|
||||
const entries = Object.entries(table?.conversion_factors ?? {});
|
||||
if (entries.length) {
|
||||
panel.append(field(L("B08_Quantity_Side_Factors"), ""));
|
||||
for (const [kind, value] of entries) {
|
||||
panel.append(field(kind, String((value as { compacted: number }).compacted)));
|
||||
}
|
||||
}
|
||||
|
||||
const confirmButton = createButton({
|
||||
label: L("B08_Quantity_Btn_Confirm"),
|
||||
@@ -67,15 +105,77 @@ function buildQuantitySidePanel(projectId: string | null): HTMLElement {
|
||||
return panel;
|
||||
}
|
||||
|
||||
/** 우측 본문 — 시트 탭 + 그 장의 표. 지금 서 있는 장은 토적표 하나다. */
|
||||
function buildQuantityBody(table: EarthworkTable | null, failed: boolean): HTMLElement {
|
||||
const body = document.createElement("div");
|
||||
body.className = "b08-quantity__body";
|
||||
|
||||
const tabs = document.createElement("div");
|
||||
tabs.className = "b08-quantity__tabs";
|
||||
const tab = document.createElement("button");
|
||||
tab.type = "button";
|
||||
tab.className = "b08-quantity__tab is-active";
|
||||
tab.textContent = L("B08_Quantity_Tab_Earthwork");
|
||||
tabs.append(tab);
|
||||
body.append(tabs);
|
||||
|
||||
if (failed) {
|
||||
const message = document.createElement("p");
|
||||
message.className = "b08-quantity__message";
|
||||
message.textContent = L("B08_Quantity_Grid_Failed");
|
||||
body.append(message);
|
||||
return body;
|
||||
}
|
||||
if (!table || !table.rows?.length) {
|
||||
const message = document.createElement("p");
|
||||
message.className = "b08-quantity__message";
|
||||
message.textContent = L("B08_Quantity_Grid_Empty");
|
||||
body.append(message);
|
||||
return body;
|
||||
}
|
||||
body.append(renderEarthworkGrid(table));
|
||||
return body;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 페이지 진입점
|
||||
* -------------------------------------------------------------------------- */
|
||||
export async function renderB08Quantity(root: HTMLElement): Promise<void> {
|
||||
injectEarthworkGridStyles();
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
await renderPendingWorkflow(root, {
|
||||
|
||||
// 표는 한 번만 받아 좌측 패널(계수 표시)과 우측 그리드가 함께 쓴다.
|
||||
let table: EarthworkTable | null = null;
|
||||
let failed = false;
|
||||
if (projectId) {
|
||||
try {
|
||||
table = await fetchEarthworkTable(projectId);
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
let workflowState: Awaited<ReturnType<typeof fetchWorkflowState>> | undefined;
|
||||
if (projectId) {
|
||||
try {
|
||||
workflowState = await fetchWorkflowState(projectId);
|
||||
} catch {
|
||||
/* 조회 실패 시 stages 미전달 → 전체 이동 허용 (다른 워크플로 페이지와 같음) */
|
||||
}
|
||||
}
|
||||
|
||||
const layout = createWorkflowLayout({
|
||||
title: L("B08_Quantity_Title"),
|
||||
steps: workflowSteps(),
|
||||
activeStep: 5,
|
||||
leftPanel: buildQuantitySidePanel(projectId),
|
||||
leftPanel: buildQuantitySidePanel(projectId, table),
|
||||
mainContent: buildQuantityBody(table, failed),
|
||||
stages: workflowState?.stages,
|
||||
currentStage: workflowState?.current_stage,
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
onStepClick: (stepIndex: number) => {
|
||||
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
},
|
||||
});
|
||||
root.append(layout.root);
|
||||
}
|
||||
|
||||
@@ -2,86 +2,65 @@
|
||||
|
||||
순공사비(직접재료비·직접노무비·직접경비)를 받아 법정경비·일반관리비·이윤·부가세를 얹어
|
||||
**공사원가계산서 한 장**을 만든다. 수량·단가와 무관하게 홀로 도는 계산이다 (PLAN 9-5).
|
||||
법정경비 계산은 `B09_Estimation_Statutory` 로 나눠 두었다 (700줄 제한).
|
||||
|
||||
지켜야 할 것 (PLAN 8-9·8-10 — 실무 원가계산서 재현으로 확인된 것만)
|
||||
지켜야 할 것 (PLAN 8-9 「엔진이 지켜야 할 것 7가지」 — 실무 원가계산서 재현으로 확인)
|
||||
1. **모든 줄은 원 단위 버림**(ROUNDDOWN). 반올림이 아니다.
|
||||
2. **밑수가 항목마다 갈린다** — 직노 / 직노+간노 / 건강보험료 / 재료비+직노+관급항 / …
|
||||
하나로 뭉치면 틀린다.
|
||||
3. **안전관리비 = A·B 두 값을 다 내고 작은 쪽**(고용노동부 고시 제2025-11호).
|
||||
⚠ **A 가 항상 작지 않다** — 관급을 넣어 대상액이 구간 경계를 넘으면 뒤집힌다
|
||||
(실증: 울진 A 채택 / 거창 B 채택).
|
||||
4. **이윤 수동 조정액** — 실무는 도급공사비 끝수를 맞추려 이윤을 깎는다. 법에 없는
|
||||
관행이므로 **설계자가 명시로 넣을 때만** 적용하고 프로그램이 스스로 깎지 않는다.
|
||||
5. **비목 목록을 코드에 박지 않는다** — 공사마다 있는 줄이 다르다(퇴직공제·폐기물처리 등).
|
||||
6. 요율은 전부 `B09_Estimation_Rates` 를 거쳐 데이터에서 읽는다. 코드에 숫자가 없다.
|
||||
3. **안전관리비 = A·B 두 값을 다 내고 작은 쪽.** A 가 항상 작지 않다.
|
||||
4. **이윤 밑수 = (순공사원가 + 일반관리비) − 재료비.**
|
||||
5. **이윤 수동 조정액** — 설계자가 명시로 넣을 때만. 자동 역산 금지 (★법대로 8-10).
|
||||
6. **관급자재대 = ROUNDUP(원자재대(+조달수수료), 천원)** — 총원가 밖 별도 표기.
|
||||
7. 요율은 전부 데이터에서 읽는다. **코드에 요율 숫자가 없다.**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
|
||||
|
||||
from B09_Estimation.B09_Estimation_Rates import (
|
||||
RateDataset,
|
||||
RateLookupError,
|
||||
base_amount,
|
||||
flat_rate,
|
||||
load_rate_dataset,
|
||||
pension_rate_percent,
|
||||
load_rate_dataset_from_path,
|
||||
rate_percent,
|
||||
select_bracket,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_Statutory import (
|
||||
ExpenseContext,
|
||||
available_items,
|
||||
statutory_expenses,
|
||||
)
|
||||
|
||||
_ZERO = Decimal(0)
|
||||
_HUNDRED = Decimal(100)
|
||||
_VAT_DIVISOR = Decimal("1.1")
|
||||
|
||||
#: 기본으로 켜는 법정경비 비목. 공사마다 다르므로 `CostInput.enabled_items` 로 갈아끼운다.
|
||||
DEFAULT_STATUTORY_ITEMS: tuple[str, ...] = (
|
||||
"industrial_accident_insurance",
|
||||
"employment_insurance",
|
||||
"health_insurance",
|
||||
"long_term_care_insurance",
|
||||
"national_pension",
|
||||
"safety_management_cost",
|
||||
"other_expense",
|
||||
"environment_preservation",
|
||||
"retirement_mutual_aid",
|
||||
)
|
||||
|
||||
#: 켤 수 있으나 기본은 끄는 비목 (공사·발주처에 따라 등장).
|
||||
OPTIONAL_STATUTORY_ITEMS: tuple[str, ...] = (
|
||||
"wage_claim_contribution",
|
||||
"asbestos_contribution",
|
||||
"equipment_payment_guarantee",
|
||||
"subcontract_payment_guarantee",
|
||||
"performance_guarantee_fee",
|
||||
)
|
||||
#: 기본으로 켜는 비목 = **그 해 요율 데이터에 있는 것 전부**.
|
||||
#: 사용자 확정(2026-09-07, PLAN 8-14): 실무 서류에 없다고 빼지 않는다.
|
||||
DEFAULT_ITEMS = "ALL_AVAILABLE"
|
||||
|
||||
|
||||
def floor_won(value: Decimal) -> Decimal:
|
||||
"""원 단위 버림 — 원가계산서 모든 줄의 기본 처리."""
|
||||
"""원 단위 버림 — 원가계산서 모든 줄의 기본 처리 (PLAN 8-9 규칙 1)."""
|
||||
return value.quantize(Decimal(1), rounding=ROUND_FLOOR)
|
||||
|
||||
|
||||
def ceil_thousand(value: Decimal) -> Decimal:
|
||||
"""천원 올림 — 관급자재대 표기."""
|
||||
"""천원 올림 — 관급자재대 표기 (PLAN 8-9 규칙 7)."""
|
||||
return (value / 1000).quantize(Decimal(1), rounding=ROUND_CEILING) * 1000
|
||||
|
||||
|
||||
@dataclass
|
||||
class CostInput:
|
||||
"""원가계산 입력.
|
||||
|
||||
금액은 전부 원 단위 `Decimal`. 요율·구간 판정에 쓰는 조건이 함께 들어온다.
|
||||
"""
|
||||
"""원가계산 입력. 금액은 전부 원 단위 `Decimal`."""
|
||||
|
||||
direct_material_krw: Decimal
|
||||
direct_labor_krw: Decimal
|
||||
direct_expense_krw: Decimal
|
||||
indirect_material_krw: Decimal = _ZERO
|
||||
|
||||
#: 구간 판정용 공종·기간. `work_type` 은 요율 데이터의 값을 그대로 쓴다.
|
||||
#: 구간 판정용 공종·기간. `work_type` 값은 요율 데이터의 표기를 그대로 쓴다.
|
||||
work_type_indirect_labor: str = "civil"
|
||||
work_type_safety: str = "civil"
|
||||
duration_days: int = 183
|
||||
@@ -92,31 +71,49 @@ class CostInput:
|
||||
procurement_fee_krw: Decimal = _ZERO
|
||||
include_fee_in_owner_material_total: bool = True
|
||||
|
||||
#: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액을 쓴다.
|
||||
#: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액.
|
||||
owner_supplied_for_safety_krw: Decimal | None = None
|
||||
#: 위 금액이 부가세 포함인가 — 포함이면 1.1 로 나눠 부가세를 뺀다(규정: 부가세 제외 기준).
|
||||
#: 그 금액이 부가세 포함인가 — 포함이면 1.1 로 나눈다(규정: 부가세 제외 기준).
|
||||
owner_supplied_includes_vat: bool = True
|
||||
#: ★ 법대로(8-10) — 조달수수료 차감은 **규정 문구가 아니다.** 기본 꺼짐.
|
||||
#: 옛 서류(울진 2024) 재현 검산에만 켠다.
|
||||
deduct_procurement_fee_for_safety: bool = False
|
||||
|
||||
#: 규모 구간 판정에 쓸 금액. None 이면 순공사원가를 쓴다(추정가격 순환 회피).
|
||||
#: 규모 구간 판정에 쓸 **추정가격**. 주면 그 값으로 한 번만 판정한다.
|
||||
#: 없으면 직접공사비를 씨앗으로 **반복 수렴**한다 (`calculate_cost` 참조).
|
||||
#: 근거 — 국가계약법 시행령 제7조 1호 「공사계약의 경우에는 관급자재로 공급될
|
||||
#: 부분의 가격을 제외한 금액」. 우리 계산의 그 값은 **총원가**(부가세 전, 관급 밖).
|
||||
estimated_price_krw: Decimal | None = None
|
||||
|
||||
#: 이윤 수동 조정액 — 설계자가 명시로 넣을 때만. 프로그램이 스스로 채우지 않는다.
|
||||
#: 이윤 수동 조정액 — 설계자 명시 입력일 때만. 프로그램이 스스로 채우지 않는다.
|
||||
profit_adjustment_krw: Decimal = _ZERO
|
||||
|
||||
#: 환경보전비 공종(요율 데이터 `rate_environment.all_work_types` 의 값).
|
||||
#: 폐기물처리비 — 요율이 아니라 **실비**. 총원가 밖, 관급자재대와 나란히.
|
||||
#: TODO(미결 PLAN 8-14·9-6): 자리 확정 대기 (사용자 「실무자 확인 후 재공유」).
|
||||
#: 실무 근거는 거창 원가계산서의 `총공사비 = 도급액 + 관급자재대 + 폐기물처리비` 한 줄뿐.
|
||||
waste_disposal_krw: Decimal = _ZERO
|
||||
|
||||
#: 환경보전비 공종 (`rate_environment.all_work_types` 의 값).
|
||||
#: TODO(미결 PLAN 9-6): 임도가 「도로 0.9 %」인지 「기타 토목 0.8 %」인지 미확정.
|
||||
#: 잠정 = 도로(0.9 %). 요율 데이터가 `pending` 을 달고 있어 결과 줄에 경고가 붙는다.
|
||||
environment_work_type: str = "civil_road"
|
||||
#: 건설기계대여대금 지급보증 공종.
|
||||
equipment_guarantee_work_type: str = "civil_general"
|
||||
#: 하도급대금 지급보증 — 30억 이상 구간이 공종으로 갈린다(토목·산업설비 / 건축).
|
||||
subcontract_guarantee_variant: str = "integrated_civil_or_industrial"
|
||||
|
||||
enabled_items: tuple[str, ...] = DEFAULT_STATUTORY_ITEMS
|
||||
#: 켤 비목. 기본은 「그 해 요율 데이터에 있는 것 전부」.
|
||||
enabled_items: tuple[str, ...] | str = DEFAULT_ITEMS
|
||||
|
||||
#: 요율 데이터 파일명. 연도를 갈아끼우는 자리.
|
||||
#: 요율 데이터 파일명. **연도를 갈아끼우는 자리.**
|
||||
rate_file_name: str = "rates_2026.json"
|
||||
#: 매니페스트 밖 요율 파일(옛 연도 재현 검산 전용). 주면 이쪽이 우선.
|
||||
rate_file_path: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CostLine:
|
||||
"""원가계산서 한 줄 — 화면이 「밑수 · 율 · 금액」 셋을 다 보이므로 셋을 다 든다."""
|
||||
"""원가계산서 한 줄 — 화면이 「비목·금액·요율·산출근거」를 다 보이므로 넷을 다 든다."""
|
||||
|
||||
key: str
|
||||
name: str
|
||||
@@ -127,6 +124,27 @@ class CostLine:
|
||||
amount_krw: Decimal
|
||||
note: str = ""
|
||||
|
||||
@property
|
||||
def formula_text(self) -> str:
|
||||
"""화면 `산출근거` 칸 문구 — 줄마다 **제 산식**을 적는다.
|
||||
|
||||
실무 원문은 안전관리비 A 식을 B 줄에 복사해 둔 오류가 있었다(PLAN 8-13).
|
||||
"""
|
||||
if self.rate_percent is None or self.key == "safety_management_cost":
|
||||
# 채택 요약 줄은 요율을 다시 붙이지 않는다 — 산식은 A·B 줄에 이미 있다.
|
||||
return self.base_label
|
||||
# 밑수가 합·차로 이루어졌으면 괄호를 씌운다.
|
||||
# 안 씌우면 「(순공사원가+일반관리비) − 재료비 × 15%」처럼 곱하는 대상이 뒤바뀌어 읽힌다.
|
||||
base = self.base_label
|
||||
if any(mark in base for mark in ("+", "−", "×")):
|
||||
base = f"({base})"
|
||||
text = f"{base} × {self.rate_percent}%"
|
||||
if self.flat_amount_krw:
|
||||
text += f" + {self.flat_amount_krw:,.0f}"
|
||||
if self.key == "safety_management_cost_b":
|
||||
text = f"[{text}] × 1.2"
|
||||
return text
|
||||
|
||||
|
||||
@dataclass
|
||||
class CostResult:
|
||||
@@ -144,350 +162,156 @@ class CostResult:
|
||||
def amount(self, key: str) -> Decimal:
|
||||
return self.line(key).amount_krw
|
||||
|
||||
def has(self, key: str) -> bool:
|
||||
return any(item.key == key for item in self.lines)
|
||||
|
||||
def _line(
|
||||
result: CostResult,
|
||||
*,
|
||||
key: str,
|
||||
name: str,
|
||||
base_label: str,
|
||||
base: Decimal,
|
||||
percent: Decimal | None = None,
|
||||
flat: Decimal = _ZERO,
|
||||
amount: Decimal | None = None,
|
||||
note: str = "",
|
||||
) -> Decimal:
|
||||
"""줄 하나를 계산해 결과에 담고 금액을 돌려준다. 금액은 항상 원 단위 버림."""
|
||||
if amount is None:
|
||||
computed = base * (percent or _ZERO) / _HUNDRED + flat
|
||||
amount = floor_won(computed)
|
||||
result.lines.append(
|
||||
CostLine(
|
||||
key=key,
|
||||
name=name,
|
||||
base_label=base_label,
|
||||
base_amount_krw=base,
|
||||
rate_percent=percent,
|
||||
flat_amount_krw=flat,
|
||||
amount_krw=amount,
|
||||
note=note,
|
||||
|
||||
def _load_dataset(data: CostInput) -> RateDataset:
|
||||
if data.rate_file_path:
|
||||
return load_rate_dataset_from_path(data.rate_file_path)
|
||||
return load_rate_dataset(data.rate_file_name)
|
||||
|
||||
|
||||
def _emitter(result: CostResult):
|
||||
"""줄 하나를 계산해 결과에 담고 금액을 돌려주는 함수를 만든다."""
|
||||
|
||||
def emit(
|
||||
*,
|
||||
key: str,
|
||||
name: str,
|
||||
base_label: str,
|
||||
base: Decimal,
|
||||
percent: Decimal | None = None,
|
||||
flat: Decimal = _ZERO,
|
||||
raw: Decimal | None = None,
|
||||
amount: Decimal | None = None,
|
||||
note: str = "",
|
||||
) -> Decimal:
|
||||
if amount is None:
|
||||
computed = raw if raw is not None else base * (percent or _ZERO) / _HUNDRED + flat
|
||||
amount = floor_won(computed)
|
||||
result.lines.append(
|
||||
CostLine(
|
||||
key=key,
|
||||
name=name,
|
||||
base_label=base_label,
|
||||
base_amount_krw=base,
|
||||
rate_percent=percent,
|
||||
flat_amount_krw=flat,
|
||||
amount_krw=amount,
|
||||
note=note,
|
||||
)
|
||||
)
|
||||
)
|
||||
return amount
|
||||
return amount
|
||||
|
||||
return emit
|
||||
|
||||
|
||||
def _safety_management_cost(
|
||||
result: CostResult,
|
||||
dataset: RateDataset,
|
||||
data: CostInput,
|
||||
*,
|
||||
material_cost: Decimal,
|
||||
) -> Decimal:
|
||||
"""산업안전보건관리비 — A·B 두 값을 다 내고 **작은 쪽**을 채택한다.
|
||||
#: 규모 구간 수렴 반복 상한. 2~3회면 고정된다.
|
||||
_SCALE_MAX_PASSES = 5
|
||||
|
||||
A) (재료비 + 직접노무비 + 도급자설치 관급금액) × 요율 + 기초액
|
||||
B) ((재료비 + 직접노무비) × 요율 + 기초액) × 1.2
|
||||
두 대상액이 **다른 구간에 떨어질 수 있어** A 가 항상 작지는 않다.
|
||||
|
||||
def _scale_signature(dataset: RateDataset, amount: Decimal) -> tuple:
|
||||
"""이 금액이 어느 구간들에 떨어지는가 — 구간이 바뀌었는지 판정하는 지문.
|
||||
|
||||
규모(추정가격)로 갈리는 요율만 모은다. 지문이 같으면 더 돌 필요가 없다.
|
||||
"""
|
||||
variable = dataset.variable("rate_safety_pct")
|
||||
brackets = variable["brackets"]
|
||||
|
||||
owner_supplied = data.owner_supplied_for_safety_krw
|
||||
if owner_supplied is None:
|
||||
owner_supplied = data.owner_supplied_material_krw
|
||||
if data.owner_supplied_includes_vat:
|
||||
owner_supplied = owner_supplied / _VAT_DIVISOR
|
||||
|
||||
base_with = material_cost + data.direct_labor_krw + owner_supplied
|
||||
base_without = material_cost + data.direct_labor_krw
|
||||
|
||||
def evaluate(
|
||||
base: Decimal, *, multiplier: Decimal, label: str
|
||||
) -> tuple[Decimal, Decimal, Decimal]:
|
||||
row = select_bracket(
|
||||
brackets,
|
||||
amount_field="target_amount_bracket",
|
||||
amount=base,
|
||||
equals={"work_type": data.work_type_safety},
|
||||
label=label,
|
||||
)
|
||||
percent = rate_percent(row, label=label)
|
||||
flat = base_amount(row)
|
||||
amount = floor_won((base * percent / _HUNDRED + flat) * multiplier)
|
||||
return amount, percent, flat
|
||||
|
||||
amount_a, percent_a, flat_a = evaluate(
|
||||
base_with, multiplier=Decimal(1), label="안전관리비 A(관급 포함)"
|
||||
)
|
||||
amount_b, percent_b, flat_b = evaluate(
|
||||
base_without, multiplier=Decimal("1.2"), label="안전관리비 B(관급 제외 × 1.2)"
|
||||
)
|
||||
|
||||
adopted = "A" if amount_a <= amount_b else "B"
|
||||
|
||||
_line(
|
||||
result,
|
||||
key="safety_management_cost_a",
|
||||
name="산업안전보건관리비 A(관급 포함)",
|
||||
base_label="재료비+직접노무비+도급자설치 관급금액(부가세 제외)",
|
||||
base=base_with,
|
||||
percent=percent_a,
|
||||
flat=flat_a,
|
||||
amount=amount_a,
|
||||
note="채택" if adopted == "A" else "미채택",
|
||||
)
|
||||
_line(
|
||||
result,
|
||||
key="safety_management_cost_b",
|
||||
name="산업안전보건관리비 B(관급 제외 × 1.2)",
|
||||
base_label="(재료비+직접노무비) × 요율 + 기초액, 그 값의 1.2배",
|
||||
base=base_without,
|
||||
percent=percent_b,
|
||||
flat=flat_b,
|
||||
amount=amount_b,
|
||||
note="채택" if adopted == "B" else "미채택",
|
||||
)
|
||||
|
||||
adopted_amount = min(amount_a, amount_b)
|
||||
return _line(
|
||||
result,
|
||||
key="safety_management_cost",
|
||||
name="산업안전보건관리비",
|
||||
base_label=f"A·B 중 작은 금액 (채택 = {adopted})",
|
||||
base=base_with if adopted == "A" else base_without,
|
||||
percent=percent_a if adopted == "A" else percent_b,
|
||||
amount=adopted_amount,
|
||||
note="고용노동부 고시 제2025-11호 — 둘 중 작은 금액",
|
||||
)
|
||||
|
||||
|
||||
def _statutory_expenses(
|
||||
result: CostResult,
|
||||
dataset: RateDataset,
|
||||
data: CostInput,
|
||||
*,
|
||||
material_cost: Decimal,
|
||||
total_labor_cost: Decimal,
|
||||
direct_construction_cost: Decimal,
|
||||
) -> Decimal:
|
||||
"""법정경비 묶음. `enabled_items` 에 든 줄만 계산한다."""
|
||||
enabled = set(data.enabled_items)
|
||||
total = _ZERO
|
||||
|
||||
if "industrial_accident_insurance" in enabled:
|
||||
total += _line(
|
||||
result,
|
||||
key="industrial_accident_insurance",
|
||||
name="산재보험료",
|
||||
base_label="노무비(직접+간접)",
|
||||
base=total_labor_cost,
|
||||
percent=flat_rate(dataset, "rate_sanjae"),
|
||||
)
|
||||
|
||||
if "employment_insurance" in enabled:
|
||||
variable = dataset.variable("rate_goyong")
|
||||
# 고용보험료는 등급(1~7)이 추정가격으로 갈린다. 임도는 대개 고시 기준금액 미만이라
|
||||
# 숫자 구간에 안 걸리므로 잔여 구간을 이름으로 지정한다(등급 7·그 이하 모두 1.01 %).
|
||||
row = select_bracket(
|
||||
variable["brackets"],
|
||||
amount_field="estimated_amount_bracket",
|
||||
amount=_scale_reference(data, direct_construction_cost),
|
||||
residual_label="below_official_threshold",
|
||||
label="고용보험료",
|
||||
)
|
||||
total += _line(
|
||||
result,
|
||||
key="employment_insurance",
|
||||
name="고용보험료",
|
||||
base_label="노무비(직접+간접)",
|
||||
base=total_labor_cost,
|
||||
percent=rate_percent(row, label="고용보험료"),
|
||||
)
|
||||
|
||||
health_amount = _ZERO
|
||||
if "health_insurance" in enabled:
|
||||
health_amount = _line(
|
||||
result,
|
||||
key="health_insurance",
|
||||
name="국민건강보험료",
|
||||
base_label="직접노무비",
|
||||
base=data.direct_labor_krw,
|
||||
percent=flat_rate(dataset, "rate_health"),
|
||||
)
|
||||
total += health_amount
|
||||
|
||||
if "long_term_care_insurance" in enabled:
|
||||
if "health_insurance" not in enabled:
|
||||
raise RateLookupError(
|
||||
"노인장기요양보험료는 건강보험료를 밑수로 씁니다 — 건강보험료를 켜야 합니다"
|
||||
parts: list[str] = []
|
||||
for variable, bracket_field, key in (
|
||||
("rate_overhead", "estimated_price_bracket", "civil_landscape_industrial"),
|
||||
("rate_profit", "estimated_price_bracket", "brackets"),
|
||||
("rate_goyong", "estimated_amount_bracket", "brackets"),
|
||||
("rate_subcontract_payment_guarantee", "estimated_price_bracket", "brackets"),
|
||||
):
|
||||
if variable not in dataset.variables:
|
||||
continue
|
||||
rows = dataset.variable(variable)[key]
|
||||
try:
|
||||
row = select_bracket(
|
||||
rows,
|
||||
amount_field=bracket_field,
|
||||
amount=amount,
|
||||
residual_label="below_official_threshold",
|
||||
label=variable,
|
||||
)
|
||||
total += _line(
|
||||
result,
|
||||
key="long_term_care_insurance",
|
||||
name="노인장기요양보험료",
|
||||
base_label="국민건강보험료",
|
||||
base=health_amount,
|
||||
percent=flat_rate(dataset, "rate_care"),
|
||||
)
|
||||
except Exception: # noqa: BLE001 - 구간 밖이면 지문에서 뺀다
|
||||
parts.append(f"{variable}:none")
|
||||
continue
|
||||
parts.append(f"{variable}:{row.get(bracket_field)}")
|
||||
|
||||
if "national_pension" in enabled:
|
||||
total += _line(
|
||||
result,
|
||||
key="national_pension",
|
||||
name="국민연금보험료",
|
||||
base_label="직접노무비",
|
||||
base=data.direct_labor_krw,
|
||||
percent=pension_rate_percent(dataset, data.pension_year),
|
||||
)
|
||||
|
||||
if "safety_management_cost" in enabled:
|
||||
total += _safety_management_cost(result, dataset, data, material_cost=material_cost)
|
||||
|
||||
if "other_expense" in enabled:
|
||||
variable = dataset.variable("rate_other_expense")
|
||||
row = select_bracket(
|
||||
variable["brackets"],
|
||||
amount_field="direct_cost_bracket",
|
||||
amount=direct_construction_cost,
|
||||
duration_days=data.duration_days,
|
||||
equals={"work_type": data.work_type_indirect_labor},
|
||||
label="기타경비",
|
||||
)
|
||||
total += _line(
|
||||
result,
|
||||
key="other_expense",
|
||||
name="기타경비",
|
||||
base_label="재료비+노무비(직접+간접)",
|
||||
base=material_cost + total_labor_cost,
|
||||
percent=rate_percent(row, label="기타경비"),
|
||||
)
|
||||
|
||||
if "environment_preservation" in enabled:
|
||||
variable = dataset.variable("rate_environment")
|
||||
threshold = Decimal(str(variable.get("minimum_estimated_amount_krw", 0)))
|
||||
if _scale_reference(data, direct_construction_cost) >= threshold:
|
||||
row = next(
|
||||
(
|
||||
r
|
||||
for r in variable["all_work_types"]
|
||||
if r.get("work_type") == data.environment_work_type
|
||||
),
|
||||
None,
|
||||
)
|
||||
if row is None:
|
||||
raise RateLookupError(
|
||||
f"환경보전비: 공종을 못 찾았습니다 — {data.environment_work_type}"
|
||||
)
|
||||
total += _line(
|
||||
result,
|
||||
key="environment_preservation",
|
||||
name="환경보전비",
|
||||
base_label="직접공사비",
|
||||
base=direct_construction_cost,
|
||||
percent=rate_percent(row, label="환경보전비"),
|
||||
note=(
|
||||
"⚠ 임도 공종 채택값 미확정 — 지식DB "
|
||||
"`rate_environment.forest_road_selection_status: pending`"
|
||||
),
|
||||
)
|
||||
|
||||
if "retirement_mutual_aid" in enabled:
|
||||
variable = dataset.variable("rate_retirement_mutual_aid")
|
||||
threshold = Decimal(str(variable.get("minimum_estimated_amount_krw", 0)))
|
||||
if _scale_reference(data, direct_construction_cost) >= threshold:
|
||||
total += _line(
|
||||
result,
|
||||
key="retirement_mutual_aid",
|
||||
name="퇴직공제부금비",
|
||||
base_label="직접노무비",
|
||||
base=data.direct_labor_krw,
|
||||
percent=Decimal(str(variable["rate_percent"])),
|
||||
)
|
||||
|
||||
if "wage_claim_contribution" in enabled:
|
||||
total += _line(
|
||||
result,
|
||||
key="wage_claim_contribution",
|
||||
name="임금채권보장기금 부담금",
|
||||
base_label="노무비(직접+간접)",
|
||||
base=total_labor_cost,
|
||||
percent=flat_rate(dataset, "rate_wage_claim_contribution"),
|
||||
)
|
||||
|
||||
if "asbestos_contribution" in enabled:
|
||||
total += _line(
|
||||
result,
|
||||
key="asbestos_contribution",
|
||||
name="석면피해구제 분담금",
|
||||
base_label="노무비(직접+간접)",
|
||||
base=total_labor_cost,
|
||||
percent=flat_rate(dataset, "rate_asbestos_contribution"),
|
||||
)
|
||||
|
||||
if "equipment_payment_guarantee" in enabled:
|
||||
variable = dataset.variable("rate_equipment_payment_guarantee")
|
||||
row = next(
|
||||
(
|
||||
r
|
||||
for r in variable["general_construction"] + variable["specialty_construction"]
|
||||
if r.get("work_type") == data.equipment_guarantee_work_type
|
||||
),
|
||||
None,
|
||||
)
|
||||
if row is None:
|
||||
raise RateLookupError(
|
||||
"건설기계대여대금 지급보증: 공종을 못 찾았습니다 — "
|
||||
f"{data.equipment_guarantee_work_type}"
|
||||
)
|
||||
total += _line(
|
||||
result,
|
||||
key="equipment_payment_guarantee",
|
||||
name="건설기계대여대금 지급보증수수료",
|
||||
base_label="직접공사비",
|
||||
base=direct_construction_cost,
|
||||
percent=rate_percent(row, label="건설기계대여대금 지급보증수수료"),
|
||||
)
|
||||
|
||||
if "subcontract_payment_guarantee" in enabled:
|
||||
variable = dataset.variable("rate_subcontract_payment_guarantee")
|
||||
row = select_bracket(
|
||||
variable["brackets"],
|
||||
amount_field="estimated_price_bracket",
|
||||
amount=_scale_reference(data, direct_construction_cost),
|
||||
label="하도급대금 지급보증수수료",
|
||||
)
|
||||
total += _line(
|
||||
result,
|
||||
key="subcontract_payment_guarantee",
|
||||
name="하도급대금 지급보증수수료",
|
||||
base_label="직접공사비",
|
||||
base=direct_construction_cost,
|
||||
percent=rate_percent(row, label="하도급대금 지급보증수수료"),
|
||||
)
|
||||
|
||||
return total
|
||||
|
||||
|
||||
def _scale_reference(data: CostInput, direct_construction_cost: Decimal) -> Decimal:
|
||||
"""규모 구간 판정 기준액.
|
||||
|
||||
조달청 제비율표는 「추정가격」으로 구간을 나누지만, 추정가격은 원가 계산 결과에
|
||||
딸려 나오므로 그대로 쓰면 순환이 된다. 설계자가 추정가격을 명시하면 그 값을,
|
||||
없으면 **직접공사비**를 기준으로 쓴다.
|
||||
"""
|
||||
if data.estimated_price_krw is not None:
|
||||
return data.estimated_price_krw
|
||||
return direct_construction_cost
|
||||
# 적용 하한(추정금액 1억 이상 등)도 구간과 같은 축이다.
|
||||
for variable in ("rate_environment", "rate_retirement_mutual_aid"):
|
||||
if variable not in dataset.variables:
|
||||
continue
|
||||
minimum = dataset.variable(variable).get("minimum_estimated_amount_krw")
|
||||
if minimum is not None:
|
||||
parts.append(f"{variable}:met={amount >= Decimal(str(minimum))}")
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def calculate_cost(data: CostInput) -> CostResult:
|
||||
"""공사원가계산서 한 장을 계산한다."""
|
||||
dataset = load_rate_dataset(data.rate_file_name)
|
||||
result = CostResult(rate_version=dataset.version_stamp)
|
||||
"""공사원가계산서 한 장을 계산한다.
|
||||
|
||||
**규모 구간은 「추정가격」으로 판정한다** — 국가계약법 시행령 제7조 1호:
|
||||
「공사계약의 경우에는 **관급자재로 공급될 부분의 가격을 제외한 금액**」.
|
||||
우리 계산에서 그 값은 **총원가**다(부가세 전, 관급자재대는 애초에 총원가 밖).
|
||||
|
||||
그런데 총원가는 계산 **결과**라 구간 판정에 그대로 쓰면 순환이 된다. 그래서
|
||||
직접공사비를 씨앗으로 한 번 돌린 뒤 **나온 총원가로 구간을 다시 판정**해
|
||||
구간이 고정될 때까지 되풀이한다(최대 `_SCALE_MAX_PASSES` 회). 설계자가
|
||||
`estimated_price_krw` 를 명시하면 반복 없이 그 값으로 한 번만 판정한다.
|
||||
"""
|
||||
dataset = _load_dataset(data)
|
||||
|
||||
if data.estimated_price_krw is not None:
|
||||
return _calculate_with_scale(data, dataset, data.estimated_price_krw, [])
|
||||
|
||||
scale = (
|
||||
data.direct_material_krw
|
||||
+ data.indirect_material_krw
|
||||
+ data.direct_labor_krw
|
||||
+ data.direct_expense_krw
|
||||
)
|
||||
seen_signatures: list[tuple] = []
|
||||
tried_amounts: list[Decimal] = []
|
||||
|
||||
for _ in range(_SCALE_MAX_PASSES):
|
||||
signature = _scale_signature(dataset, scale)
|
||||
if signature in seen_signatures:
|
||||
# 구간이 진동한다 — 보수적으로 **높은 쪽**을 잡고 그 사실을 남긴다.
|
||||
highest = max([*tried_amounts, scale])
|
||||
return _calculate_with_scale(
|
||||
data, dataset, highest, ["규모 구간 진동 — 높은 쪽 구간 채택"]
|
||||
)
|
||||
seen_signatures.append(signature)
|
||||
tried_amounts.append(scale)
|
||||
|
||||
trial = _calculate_with_scale(data, dataset, scale, [])
|
||||
estimated_price = trial.totals["total_cost"]
|
||||
if _scale_signature(dataset, estimated_price) == signature:
|
||||
return trial
|
||||
scale = estimated_price
|
||||
|
||||
return _calculate_with_scale(
|
||||
data, dataset, scale, [f"규모 구간이 {_SCALE_MAX_PASSES}회 안에 안 굳음 — 마지막 값 채택"]
|
||||
)
|
||||
|
||||
|
||||
def _calculate_with_scale(
|
||||
data: CostInput,
|
||||
dataset: RateDataset,
|
||||
scale: Decimal,
|
||||
notes: list[str],
|
||||
) -> CostResult:
|
||||
"""규모 기준액을 못 박고 한 번 계산한다."""
|
||||
result = CostResult(rate_version=dataset.version_stamp, notes=list(notes))
|
||||
emit = _emitter(result)
|
||||
|
||||
if data.enabled_items == DEFAULT_ITEMS:
|
||||
data = replace(data, enabled_items=available_items(dataset))
|
||||
|
||||
material_cost = data.direct_material_krw + data.indirect_material_krw
|
||||
_line(
|
||||
result,
|
||||
emit(
|
||||
key="material_cost",
|
||||
name="재료비",
|
||||
base_label="직접재료비+간접재료비",
|
||||
@@ -497,7 +321,7 @@ def calculate_cost(data: CostInput) -> CostResult:
|
||||
|
||||
direct_construction_cost = material_cost + data.direct_labor_krw + data.direct_expense_krw
|
||||
|
||||
indirect_labor_row = select_bracket(
|
||||
indirect_row = select_bracket(
|
||||
dataset.variable("rate_indirect_labor")["brackets"],
|
||||
amount_field="direct_cost_bracket",
|
||||
amount=direct_construction_cost,
|
||||
@@ -505,17 +329,15 @@ def calculate_cost(data: CostInput) -> CostResult:
|
||||
equals={"work_type": data.work_type_indirect_labor},
|
||||
label="간접노무비",
|
||||
)
|
||||
indirect_labor = _line(
|
||||
result,
|
||||
indirect_labor = emit(
|
||||
key="indirect_labor_cost",
|
||||
name="간접노무비",
|
||||
base_label="직접노무비",
|
||||
base=data.direct_labor_krw,
|
||||
percent=rate_percent(indirect_labor_row, label="간접노무비"),
|
||||
percent=rate_percent(indirect_row, label="간접노무비"),
|
||||
)
|
||||
total_labor_cost = data.direct_labor_krw + indirect_labor
|
||||
_line(
|
||||
result,
|
||||
emit(
|
||||
key="labor_cost",
|
||||
name="노무비",
|
||||
base_label="직접노무비+간접노무비",
|
||||
@@ -523,17 +345,17 @@ def calculate_cost(data: CostInput) -> CostResult:
|
||||
amount=total_labor_cost,
|
||||
)
|
||||
|
||||
statutory = _statutory_expenses(
|
||||
result,
|
||||
dataset,
|
||||
data,
|
||||
ctx = ExpenseContext(
|
||||
material_cost=material_cost,
|
||||
direct_labor_cost=data.direct_labor_krw,
|
||||
total_labor_cost=total_labor_cost,
|
||||
direct_construction_cost=direct_construction_cost,
|
||||
scale_reference=scale,
|
||||
)
|
||||
statutory = statutory_expenses(dataset, data, ctx, result, emit)
|
||||
|
||||
expense_total = data.direct_expense_krw + statutory
|
||||
_line(
|
||||
result,
|
||||
emit(
|
||||
key="expense",
|
||||
name="경비",
|
||||
base_label="직접경비(산출경비)+법정경비",
|
||||
@@ -542,8 +364,7 @@ def calculate_cost(data: CostInput) -> CostResult:
|
||||
)
|
||||
|
||||
net_construction_cost = material_cost + total_labor_cost + expense_total
|
||||
_line(
|
||||
result,
|
||||
emit(
|
||||
key="net_construction_cost",
|
||||
name="순공사원가",
|
||||
base_label="재료비+노무비+경비",
|
||||
@@ -551,15 +372,13 @@ def calculate_cost(data: CostInput) -> CostResult:
|
||||
amount=net_construction_cost,
|
||||
)
|
||||
|
||||
scale = _scale_reference(data, direct_construction_cost)
|
||||
overhead_row = select_bracket(
|
||||
dataset.variable("rate_overhead")["civil_landscape_industrial"],
|
||||
amount_field="estimated_price_bracket",
|
||||
amount=scale,
|
||||
amount=ctx.scale_reference,
|
||||
label="일반관리비",
|
||||
)
|
||||
overhead = _line(
|
||||
result,
|
||||
overhead = emit(
|
||||
key="general_overhead",
|
||||
name="일반관리비",
|
||||
base_label="순공사원가",
|
||||
@@ -567,55 +386,17 @@ def calculate_cost(data: CostInput) -> CostResult:
|
||||
percent=rate_percent(overhead_row, label="일반관리비"),
|
||||
)
|
||||
|
||||
profit_row = select_bracket(
|
||||
dataset.variable("rate_profit")["brackets"],
|
||||
amount_field="estimated_price_bracket",
|
||||
amount=scale,
|
||||
label="이윤",
|
||||
)
|
||||
profit_base = total_labor_cost + expense_total + overhead
|
||||
profit_before = floor_won(profit_base * rate_percent(profit_row, label="이윤") / _HUNDRED)
|
||||
_line(
|
||||
result,
|
||||
key="profit_before_adjustment",
|
||||
name="이윤(조정 전)",
|
||||
base_label="노무비+경비+일반관리비 (재료비 제외)",
|
||||
base=profit_base,
|
||||
percent=rate_percent(profit_row, label="이윤"),
|
||||
amount=profit_before,
|
||||
)
|
||||
if data.profit_adjustment_krw:
|
||||
_line(
|
||||
result,
|
||||
key="profit_adjustment",
|
||||
name="이윤 조정액",
|
||||
base_label="설계자 명시 입력",
|
||||
base=_ZERO,
|
||||
amount=-data.profit_adjustment_krw,
|
||||
note="도급공사비 끝수 맞춤 — 법정 항목 아님",
|
||||
)
|
||||
profit = profit_before - data.profit_adjustment_krw
|
||||
_line(
|
||||
result,
|
||||
key="profit",
|
||||
name="이윤",
|
||||
base_label="조정 전 이윤 − 조정액",
|
||||
base=profit_base,
|
||||
amount=profit,
|
||||
)
|
||||
profit = _profit_lines(dataset, data, emit, ctx, net_construction_cost, overhead)
|
||||
|
||||
total_cost = net_construction_cost + overhead + profit
|
||||
_line(
|
||||
result,
|
||||
emit(
|
||||
key="total_cost",
|
||||
name="총원가",
|
||||
base_label="순공사원가+일반관리비+이윤",
|
||||
base=total_cost,
|
||||
amount=total_cost,
|
||||
)
|
||||
|
||||
vat = _line(
|
||||
result,
|
||||
vat = emit(
|
||||
key="vat",
|
||||
name="부가가치세",
|
||||
base_label="총원가",
|
||||
@@ -623,8 +404,7 @@ def calculate_cost(data: CostInput) -> CostResult:
|
||||
percent=flat_rate(dataset, "rate_vat"),
|
||||
)
|
||||
contract_amount = total_cost + vat
|
||||
_line(
|
||||
result,
|
||||
emit(
|
||||
key="contract_amount",
|
||||
name="도급공사비",
|
||||
base_label="총원가+부가가치세",
|
||||
@@ -632,32 +412,14 @@ def calculate_cost(data: CostInput) -> CostResult:
|
||||
amount=contract_amount,
|
||||
)
|
||||
|
||||
owner_total = _ZERO
|
||||
if data.owner_supplied_material_krw:
|
||||
raw = data.owner_supplied_material_krw
|
||||
if data.include_fee_in_owner_material_total:
|
||||
raw = raw + data.procurement_fee_krw
|
||||
owner_total = ceil_thousand(raw)
|
||||
_line(
|
||||
result,
|
||||
key="owner_supplied_material_total",
|
||||
name="관급자재대",
|
||||
base_label=(
|
||||
"순자재대+조달수수료 (천원 올림)"
|
||||
if data.include_fee_in_owner_material_total
|
||||
else "순자재대 (천원 올림)"
|
||||
),
|
||||
base=raw,
|
||||
amount=owner_total,
|
||||
note="총원가 밖 별도 표기",
|
||||
)
|
||||
owner_total = _owner_supplied_line(data, emit)
|
||||
waste = _waste_line(data, emit)
|
||||
|
||||
grand_total = contract_amount + owner_total
|
||||
_line(
|
||||
result,
|
||||
grand_total = contract_amount + owner_total + waste
|
||||
emit(
|
||||
key="grand_total",
|
||||
name="총공사비",
|
||||
base_label="도급공사비+관급자재대",
|
||||
base_label="도급공사비+관급자재대" + ("+폐기물처리비" if waste else ""),
|
||||
base=grand_total,
|
||||
amount=grand_total,
|
||||
)
|
||||
@@ -674,6 +436,99 @@ def calculate_cost(data: CostInput) -> CostResult:
|
||||
"vat": vat,
|
||||
"contract_amount": contract_amount,
|
||||
"owner_supplied_material_total": owner_total,
|
||||
"waste_disposal": waste,
|
||||
"grand_total": grand_total,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _profit_lines(
|
||||
dataset: RateDataset,
|
||||
data: CostInput,
|
||||
emit,
|
||||
ctx: ExpenseContext,
|
||||
net_construction_cost: Decimal,
|
||||
overhead: Decimal,
|
||||
) -> Decimal:
|
||||
"""이윤 — 조정 전 / 조정액 / 조정 후 세 줄. 조정은 **명시 입력일 때만**."""
|
||||
profit_row = select_bracket(
|
||||
dataset.variable("rate_profit")["brackets"],
|
||||
amount_field="estimated_price_bracket",
|
||||
amount=ctx.scale_reference,
|
||||
label="이윤",
|
||||
)
|
||||
percent = rate_percent(profit_row, label="이윤")
|
||||
profit_base = net_construction_cost + overhead - ctx.material_cost
|
||||
before = emit(
|
||||
key="profit_before_adjustment",
|
||||
name="이윤(조정 전)",
|
||||
base_label="(순공사원가+일반관리비) − 재료비",
|
||||
base=profit_base,
|
||||
percent=percent,
|
||||
)
|
||||
if data.profit_adjustment_krw:
|
||||
emit(
|
||||
key="profit_adjustment",
|
||||
name="이윤 조정액",
|
||||
base_label="설계자 명시 입력",
|
||||
base=_ZERO,
|
||||
amount=-data.profit_adjustment_krw,
|
||||
note="도급공사비 끝수 맞춤 — 법정 항목 아님 (★법대로 8-10)",
|
||||
)
|
||||
profit = before - data.profit_adjustment_krw
|
||||
emit(
|
||||
key="profit",
|
||||
name="이윤",
|
||||
base_label="조정 전 이윤 − 조정액",
|
||||
base=profit_base,
|
||||
amount=profit,
|
||||
)
|
||||
return profit
|
||||
|
||||
|
||||
def _owner_supplied_line(data: CostInput, emit) -> Decimal:
|
||||
"""관급자재대 — 총원가 밖 별도 표기, 천원 올림."""
|
||||
if not data.owner_supplied_material_krw:
|
||||
return _ZERO
|
||||
raw = data.owner_supplied_material_krw
|
||||
if data.include_fee_in_owner_material_total:
|
||||
raw = raw + data.procurement_fee_krw
|
||||
return emit(
|
||||
key="owner_supplied_material_total",
|
||||
name="관급자재대",
|
||||
base_label=(
|
||||
"순자재대+조달수수료 (천원 올림)"
|
||||
if data.include_fee_in_owner_material_total
|
||||
else "순자재대 (천원 올림)"
|
||||
),
|
||||
base=raw,
|
||||
amount=ceil_thousand(raw),
|
||||
note="총원가 밖 별도 표기",
|
||||
)
|
||||
|
||||
|
||||
def _waste_line(data: CostInput, emit) -> Decimal:
|
||||
"""폐기물처리비 — 요율이 아니라 실비. 설계자 입력이 있을 때만 줄이 선다."""
|
||||
if not data.waste_disposal_krw:
|
||||
return _ZERO
|
||||
return emit(
|
||||
key="waste_disposal",
|
||||
name="폐기물처리비",
|
||||
base_label="설계자 입력(실비)",
|
||||
base=data.waste_disposal_krw,
|
||||
amount=floor_won(data.waste_disposal_krw),
|
||||
note="⚠ 자리 미확정 — 실무 관측 한 줄이 유일한 근거 (PLAN 8-14)",
|
||||
)
|
||||
|
||||
|
||||
def proposed_profit_adjustment(result: CostResult, target_contract_amount: Decimal) -> Decimal:
|
||||
"""목표 도급공사비를 맞추려면 이윤을 얼마 깎아야 하는지 **보여만 준다**.
|
||||
|
||||
★ 법대로(8-10) — 프로그램이 스스로 적용하지 않는다. 설계자가 이 값을 보고
|
||||
`CostInput.profit_adjustment_krw` 에 명시로 넣어야 반영된다.
|
||||
"""
|
||||
gap = result.totals["contract_amount"] - target_contract_amount
|
||||
if gap <= 0:
|
||||
return _ZERO
|
||||
# 이윤 1원을 깎으면 총원가 1원 + 부가세 0.1원이 줄어든다.
|
||||
return floor_won(gap / Decimal("1.1"))
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""B09 원가계산 — 이중계상 감시 (거울 테스트 3종).
|
||||
|
||||
**왜 있는가** — 수량(B08)과 원가(B09)의 담당이 갈렸다가 합쳐졌다가 다시 갈리는 동안,
|
||||
「할증을 두 번 붙인다·20 m 운반을 또 센다·콘크리트를 두 번 쪼갠다」 세 자리가 반복해서
|
||||
위험 항목으로 올라왔다(PLAN 8-7 금지 규칙). 주석은 읽히지 않으므로 **수치로 깨지는
|
||||
검사**를 두어, 규칙을 어기면 계산이 멈추게 한다.
|
||||
|
||||
세 규칙 (원문 = PLAN 8-7 ㉠㉡㉢)
|
||||
㉠ **할증은 자재총괄에서 딱 한 번.** 일위대가 재료비 구성은 **할증 전** 값을 쓴다
|
||||
(품셈 1-3-1 「할증 중복 적용 금지」).
|
||||
㉡ **소운반 20 m 이내(`free_haul`)는 내역 줄에 단가를 붙이지 않는다.** 품에 이미
|
||||
포함돼 있고, 품셈에 20 m 이내 운반 품목 자체가 없다(1-2-7 · 인력운반 10-6).
|
||||
㉢ **콘크리트·모르터는 한 번만 쪼갠다.** 원단위표는 「㎥」까지 내고, 시멘트·모래
|
||||
분해는 일위대가에서 한 번만 한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
_TOLERANCE = Decimal("0.5")
|
||||
|
||||
|
||||
class DoubleCountError(AssertionError):
|
||||
"""이중계상이 감지된 경우. 값을 고치지 않고 여기서 멈춘다."""
|
||||
|
||||
|
||||
def check_surcharge_once(
|
||||
*,
|
||||
material_summary_total: Decimal,
|
||||
unit_price_material_total: Decimal,
|
||||
surcharge_rate_percent: Decimal,
|
||||
label: str = "자재",
|
||||
) -> None:
|
||||
"""㉠ 할증이 두 번 붙지 않았는가.
|
||||
|
||||
`material_summary_total` = 자재총괄의 **할증 포함** 합계.
|
||||
`unit_price_material_total` = 일위대가 재료비 구성의 **할증 전** 합계.
|
||||
둘의 비가 (1 + 할증률) 을 **넘으면** 어딘가에서 할증을 또 붙인 것이다.
|
||||
"""
|
||||
if unit_price_material_total <= 0:
|
||||
return
|
||||
expected = unit_price_material_total * (Decimal(1) + surcharge_rate_percent / Decimal(100))
|
||||
if material_summary_total > expected + _TOLERANCE:
|
||||
raise DoubleCountError(
|
||||
f"{label}: 할증이 두 번 붙었습니다 — 자재총괄 {material_summary_total:,.2f} > "
|
||||
f"할증 전 {unit_price_material_total:,.2f} × (1+{surcharge_rate_percent}%) "
|
||||
f"= {expected:,.2f}. 할증은 자재총괄에서 한 번만 (PLAN 8-7 ㉠)."
|
||||
)
|
||||
|
||||
|
||||
def check_free_haul_not_priced(
|
||||
*,
|
||||
haul_rows: list[dict],
|
||||
equipment_field: str = "equipment",
|
||||
unit_price_field: str = "unit_price_krw",
|
||||
free_haul_equipment: str = "free_haul",
|
||||
) -> None:
|
||||
"""㉡ 무대(20 m 이내) 줄에 단가가 붙지 않았는가.
|
||||
|
||||
줄 자체는 실무 서식대로 남긴다(STmate `W00005 무대처리` 는 금액 0 으로 실재).
|
||||
금지되는 것은 **단가를 붙이는 것**이다.
|
||||
"""
|
||||
for row in haul_rows:
|
||||
if row.get(equipment_field) != free_haul_equipment:
|
||||
continue
|
||||
price = Decimal(str(row.get(unit_price_field) or 0))
|
||||
if price != 0:
|
||||
raise DoubleCountError(
|
||||
f"무대(20 m 이내) 줄에 단가 {price:,.0f} 원이 붙었습니다 — "
|
||||
"소운반 20 m 이내는 품에 포함이라 별도 계상하지 않습니다 (PLAN 8-7 ㉡)."
|
||||
)
|
||||
|
||||
|
||||
def check_haul_volume_within_cut(
|
||||
*,
|
||||
haul_volume_total_m3: Decimal,
|
||||
total_cut_volume_m3: Decimal,
|
||||
) -> None:
|
||||
"""㉡ 보조 — 운반토량 합이 총 절취량을 넘지 않는가.
|
||||
|
||||
무대 줄을 잘못 이중으로 세면 합이 절취량을 넘는다.
|
||||
"""
|
||||
if haul_volume_total_m3 > total_cut_volume_m3 + _TOLERANCE:
|
||||
raise DoubleCountError(
|
||||
f"운반토량 합 {haul_volume_total_m3:,.2f} ㎥ 가 총 절취량 "
|
||||
f"{total_cut_volume_m3:,.2f} ㎥ 를 넘습니다 — 같은 토량을 두 번 셌습니다 "
|
||||
"(PLAN 8-7 ㉡)."
|
||||
)
|
||||
|
||||
|
||||
def check_mix_decomposed_once(
|
||||
*,
|
||||
cement_total_kg: Decimal,
|
||||
concrete_volume_m3: Decimal,
|
||||
cement_per_m3_kg: Decimal,
|
||||
) -> None:
|
||||
"""㉢ 콘크리트를 두 번 쪼개지 않았는가.
|
||||
|
||||
시멘트 총량이 `콘크리트 체적 × 배합비` 를 넘으면, 원단위표가 이미 분해한 값을
|
||||
일위대가가 또 분해한 것이다.
|
||||
"""
|
||||
if concrete_volume_m3 <= 0:
|
||||
return
|
||||
expected = concrete_volume_m3 * cement_per_m3_kg
|
||||
if cement_total_kg > expected + _TOLERANCE:
|
||||
raise DoubleCountError(
|
||||
f"시멘트 {cement_total_kg:,.2f} kg 가 콘크리트 {concrete_volume_m3:,.2f} ㎥ × "
|
||||
f"{cement_per_m3_kg} kg/㎥ = {expected:,.2f} kg 를 넘습니다 — 배합을 두 번 "
|
||||
"쪼갰습니다. 원단위표는 「콘크리트 ㎥」까지만 냅니다 (PLAN 8-7 ㉢)."
|
||||
)
|
||||
@@ -102,6 +102,24 @@ def load_rate_dataset(file_name: str = "rates_2026.json") -> RateDataset:
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def load_rate_dataset_from_path(path: str) -> RateDataset:
|
||||
"""매니페스트 밖의 요율 파일을 읽는다 — **옛 연도 재현 검산 전용**.
|
||||
|
||||
정본 요율은 `load_rate_dataset` 으로만 읽는다. 이 함수는 「2024년 값으로 돌리면
|
||||
그때 서류가 재현되는가」를 시험하려고 두는 것이고, 지문이 없으므로 결과에
|
||||
`sha256=""` 로 남아 **정본이 아님이 드러난다**.
|
||||
"""
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
return RateDataset(
|
||||
dataset_id=payload.get("dataset_id", ""),
|
||||
effective_date=payload.get("effective_date", ""),
|
||||
sha256="",
|
||||
variables=payload.get("variables", {}),
|
||||
)
|
||||
|
||||
|
||||
def _bracket_bounds(label: str) -> tuple[Decimal, Decimal] | None:
|
||||
"""금액 구간 라벨 → [하한, 상한). 숫자 구간이 아니면 None."""
|
||||
match = _RE_LT.match(label)
|
||||
@@ -168,6 +186,7 @@ def select_bracket(
|
||||
duration_field: str = "duration_bracket",
|
||||
equals: dict[str, Any] | None = None,
|
||||
residual_label: str | None = None,
|
||||
prefer_suffix: str | None = None,
|
||||
label: str,
|
||||
) -> dict[str, Any]:
|
||||
"""구간 목록에서 한 행을 고른다. 못 고르면 `RateLookupError` — 기본값으로 안 때운다.
|
||||
@@ -201,6 +220,13 @@ def select_bracket(
|
||||
for key, expected in equals.items():
|
||||
candidates = [row for row in candidates if row.get(key) == expected]
|
||||
|
||||
if len(candidates) > 1 and prefer_suffix is not None and amount_field is not None:
|
||||
# 같은 금액 구간이 공종으로 갈리는 표가 있다(하도급보증의 `…_integrated_civil…`).
|
||||
# 부르는 쪽이 공종을 대야 하며, 조용한 기본값이 아니다.
|
||||
narrowed = [r for r in candidates if str(r.get(amount_field, "")).endswith(prefer_suffix)]
|
||||
if narrowed:
|
||||
candidates = narrowed
|
||||
|
||||
if not candidates:
|
||||
raise RateLookupError(
|
||||
f"{label}: 조건에 맞는 요율 구간이 없습니다 "
|
||||
|
||||
@@ -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)})
|
||||
@@ -0,0 +1,408 @@
|
||||
"""B09 원가계산 — 법정경비 묶음 (⑤ 공사원가계산서의 경비 부분).
|
||||
|
||||
`B09_Estimation_Engine_Cost` 가 부르는 하위 모듈. 700줄 제한(CLAUDE.md 4장)에 맞춰
|
||||
경비 계산만 떼어 두었다.
|
||||
|
||||
지켜야 할 것 (PLAN 8-9·8-10·8-13·8-14)
|
||||
- **비목 목록을 코드에 박지 않는다.** 아래 `STATUTORY_ITEMS` 는 「무엇을 어떤 밑수로
|
||||
계산하는가」의 정의일 뿐이고, **그 해에 그 비목이 있는지는 요율 데이터가 정한다**
|
||||
(해당 요율 변수가 데이터셋에 없으면 그 해에는 없는 비목).
|
||||
- **밑수가 항목마다 갈린다** — 산재·고용 = 직노+간노 / 건강·연금 = 직노 /
|
||||
요양 = 건강보험료 / 안전 = 재료비+직노+관급항 / 기타경비 = 재료비+직노+간노 /
|
||||
환경·보증 = 직접공사비. 뭉뚱그리면 틀린다.
|
||||
- **안전관리비는 A·B 두 값을 다 내고 작은 쪽** (고용노동부 고시 제2025-11호).
|
||||
⚠ A 가 항상 작지 않다 — 관급을 넣어 대상액이 5억·50억 경계를 넘으면 뒤집힌다
|
||||
(실증: 울진 A 채택 / 거창 B 채택).
|
||||
- ★ **법대로**(8-10) — 조달수수료 차감은 규정 문구가 아니다. 기본은 **차감하지 않음**.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from B09_Estimation.B09_Estimation_Rates import (
|
||||
RateDataset,
|
||||
RateLookupError,
|
||||
base_amount,
|
||||
pension_rate_percent,
|
||||
rate_percent,
|
||||
select_bracket,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - 순환 import 회피용
|
||||
from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, CostResult
|
||||
|
||||
_ZERO = Decimal(0)
|
||||
_HUNDRED = Decimal(100)
|
||||
_VAT_DIVISOR = Decimal("1.1")
|
||||
_SAFETY_B_MULTIPLIER = Decimal("1.2")
|
||||
|
||||
#: 공사이행보증수수료 요율 데이터가 값 대신 들고 있는 식 문자열 형태.
|
||||
_RE_GUARANTEE_FORMULA = re.compile(r"([0-9.]+)\s*%")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StatutoryItem:
|
||||
"""법정경비 한 비목의 정의 — 이름·요율 변수·밑수 뽑는 법."""
|
||||
|
||||
key: str
|
||||
name: str
|
||||
rate_variable: str
|
||||
base_label: str
|
||||
|
||||
|
||||
#: 비목 정의. **순서가 곧 원가계산서 줄 순서**다.
|
||||
STATUTORY_ITEMS: tuple[StatutoryItem, ...] = (
|
||||
StatutoryItem(
|
||||
"industrial_accident_insurance", "산재보험료", "rate_sanjae", "노무비(직접+간접)"
|
||||
),
|
||||
StatutoryItem("employment_insurance", "고용보험료", "rate_goyong", "노무비(직접+간접)"),
|
||||
StatutoryItem("health_insurance", "국민건강보험료", "rate_health", "직접노무비"),
|
||||
StatutoryItem("long_term_care_insurance", "노인장기요양보험료", "rate_care", "국민건강보험료"),
|
||||
StatutoryItem("national_pension", "국민연금보험료", "rate_pension", "직접노무비"),
|
||||
StatutoryItem(
|
||||
"safety_management_cost", "산업안전보건관리비", "rate_safety_pct", "A·B 중 작은 금액"
|
||||
),
|
||||
StatutoryItem("other_expense", "기타경비", "rate_other_expense", "재료비+노무비(직접+간접)"),
|
||||
StatutoryItem("environment_preservation", "환경보전비", "rate_environment", "직접공사비"),
|
||||
StatutoryItem(
|
||||
"retirement_mutual_aid", "퇴직공제부금비", "rate_retirement_mutual_aid", "직접노무비"
|
||||
),
|
||||
StatutoryItem(
|
||||
"wage_claim_contribution",
|
||||
"임금채권보장기금 부담금",
|
||||
"rate_wage_claim_contribution",
|
||||
"노무비(직접+간접)",
|
||||
),
|
||||
StatutoryItem(
|
||||
"asbestos_contribution",
|
||||
"석면피해구제 분담금",
|
||||
"rate_asbestos_contribution",
|
||||
"노무비(직접+간접)",
|
||||
),
|
||||
StatutoryItem(
|
||||
"equipment_payment_guarantee",
|
||||
"건설기계대여대금 지급보증수수료",
|
||||
"rate_equipment_payment_guarantee",
|
||||
"직접공사비",
|
||||
),
|
||||
StatutoryItem(
|
||||
"subcontract_payment_guarantee",
|
||||
"하도급대금 지급보증수수료",
|
||||
"rate_subcontract_payment_guarantee",
|
||||
"직접공사비",
|
||||
),
|
||||
StatutoryItem(
|
||||
"performance_guarantee_fee",
|
||||
"공사이행보증수수료",
|
||||
"rate_performance_guarantee_fee",
|
||||
"직접공사비 × 공사기간(년)",
|
||||
),
|
||||
)
|
||||
|
||||
_ITEM_BY_KEY = {item.key: item for item in STATUTORY_ITEMS}
|
||||
|
||||
|
||||
def available_items(dataset: RateDataset) -> tuple[str, ...]:
|
||||
"""**그 해에 유효한 비목 목록** — 요율 데이터에 그 변수가 있는 것만 (PLAN 8-13).
|
||||
|
||||
비목 목록을 코드에 박지 않기 위한 자리. 연도별 요율 파일이 갈리면 목록도 따라 갈린다.
|
||||
"""
|
||||
return tuple(item.key for item in STATUTORY_ITEMS if item.rate_variable in dataset.variables)
|
||||
|
||||
|
||||
def item_name(key: str) -> str:
|
||||
return _ITEM_BY_KEY[key].name
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExpenseContext:
|
||||
"""법정경비 계산에 필요한 밑수 묶음 — 엔진이 채워 넘긴다."""
|
||||
|
||||
material_cost: Decimal
|
||||
direct_labor_cost: Decimal
|
||||
total_labor_cost: Decimal
|
||||
direct_construction_cost: Decimal
|
||||
scale_reference: Decimal
|
||||
|
||||
|
||||
def _threshold_met(dataset: RateDataset, variable: str, field: str, amount: Decimal) -> bool:
|
||||
"""적용 하한(추정금액 1억 이상 등)을 넘겼는가."""
|
||||
minimum = dataset.variable(variable).get(field)
|
||||
if minimum is None:
|
||||
return True
|
||||
return amount >= Decimal(str(minimum))
|
||||
|
||||
|
||||
def _guarantee_percent_from_formula(row: dict[str, Any], *, label: str) -> Decimal:
|
||||
"""공사이행보증수수료는 요율 데이터가 값이 아니라 **식 문자열**을 들고 있다.
|
||||
|
||||
예: `"(direct_cost * 0.0108%) * duration_years"` → 0.0108 을 뽑는다.
|
||||
식 모양이 바뀌면 조용히 넘기지 않고 멈춘다.
|
||||
"""
|
||||
formula = str(row.get("formula", ""))
|
||||
match = _RE_GUARANTEE_FORMULA.search(formula)
|
||||
if not match:
|
||||
raise RateLookupError(f"{label}: 요율 식에서 백분율을 못 읽었습니다 — {formula!r}")
|
||||
return Decimal(match.group(1))
|
||||
|
||||
|
||||
def safety_management_cost(
|
||||
dataset: RateDataset,
|
||||
data: CostInput,
|
||||
ctx: ExpenseContext,
|
||||
emit: Callable[..., Decimal],
|
||||
) -> Decimal:
|
||||
"""산업안전보건관리비 — A·B 두 값을 다 내고 **작은 쪽**을 채택한다.
|
||||
|
||||
A) (재료비 + 직접노무비 + 도급자설치 관급금액) × 요율 + 기초액
|
||||
B) ((재료비 + 직접노무비) × 요율 + 기초액) × 1.2
|
||||
|
||||
두 대상액이 **다른 구간에 떨어질 수 있어** A 가 항상 작지는 않다. 두 줄을 다 남겨
|
||||
화면이 나란히 보이게 한다(실무 `안전관리비검토` 시트와 같은 서식).
|
||||
"""
|
||||
variable = dataset.variable("rate_safety_pct")
|
||||
brackets = variable["brackets"]
|
||||
|
||||
owner_supplied = data.owner_supplied_for_safety_krw
|
||||
if owner_supplied is None:
|
||||
owner_supplied = data.owner_supplied_material_krw
|
||||
if data.deduct_procurement_fee_for_safety:
|
||||
# ★ 법대로(8-10) — 규정 문구가 아니다. 옛 서류 재현용으로만 켠다.
|
||||
owner_supplied = owner_supplied - data.procurement_fee_krw
|
||||
if data.owner_supplied_includes_vat:
|
||||
owner_supplied = owner_supplied / _VAT_DIVISOR
|
||||
|
||||
base_with = ctx.material_cost + ctx.direct_labor_cost + owner_supplied
|
||||
base_without = ctx.material_cost + ctx.direct_labor_cost
|
||||
|
||||
def evaluate(
|
||||
base: Decimal, multiplier: Decimal, label: str
|
||||
) -> tuple[Decimal, Decimal, Decimal]:
|
||||
row = select_bracket(
|
||||
brackets,
|
||||
amount_field="target_amount_bracket",
|
||||
amount=base,
|
||||
equals={"work_type": data.work_type_safety},
|
||||
label=label,
|
||||
)
|
||||
percent = rate_percent(row, label=label)
|
||||
flat = base_amount(row)
|
||||
return (base * percent / _HUNDRED + flat) * multiplier, percent, flat
|
||||
|
||||
raw_a, percent_a, flat_a = evaluate(base_with, Decimal(1), "안전관리비 A(관급 포함)")
|
||||
raw_b, percent_b, flat_b = evaluate(
|
||||
base_without, _SAFETY_B_MULTIPLIER, "안전관리비 B(관급 제외 × 1.2)"
|
||||
)
|
||||
|
||||
# 어느 쪽이 채택인지 먼저 정해 두 줄에 표시를 단다 — 화면이 나란히 보이고
|
||||
# 채택 줄이 눈에 띄어야 한다(PLAN 8-12 실무 `안전관리비검토` 시트 서식).
|
||||
from B09_Estimation.B09_Estimation_Engine_Cost import floor_won
|
||||
|
||||
adopted = "A" if floor_won(raw_a) <= floor_won(raw_b) else "B"
|
||||
|
||||
amount_a = emit(
|
||||
key="safety_management_cost_a",
|
||||
name="산업안전보건관리비 A(관급 포함)",
|
||||
base_label="재료비+직접노무비+도급자설치 관급금액(부가세 제외)",
|
||||
base=base_with,
|
||||
percent=percent_a,
|
||||
flat=flat_a,
|
||||
raw=raw_a,
|
||||
note="채택" if adopted == "A" else "미채택",
|
||||
)
|
||||
amount_b = emit(
|
||||
key="safety_management_cost_b",
|
||||
name="산업안전보건관리비 B(관급 제외 × 1.2)",
|
||||
base_label="재료비+직접노무비 (관급 제외)",
|
||||
base=base_without,
|
||||
percent=percent_b,
|
||||
flat=flat_b,
|
||||
raw=raw_b,
|
||||
note="채택" if adopted == "B" else "미채택",
|
||||
)
|
||||
return emit(
|
||||
key="safety_management_cost",
|
||||
name="산업안전보건관리비",
|
||||
base_label=f"A·B 중 작은 금액 (채택 = {adopted})",
|
||||
base=base_with if adopted == "A" else base_without,
|
||||
percent=percent_a if adopted == "A" else percent_b,
|
||||
flat=_ZERO,
|
||||
raw=min(amount_a, amount_b),
|
||||
note="고용노동부 고시 제2025-11호 — 둘 중 작은 금액",
|
||||
)
|
||||
|
||||
|
||||
def statutory_expenses(
|
||||
dataset: RateDataset,
|
||||
data: CostInput,
|
||||
ctx: ExpenseContext,
|
||||
result: CostResult,
|
||||
emit: Callable[..., Decimal],
|
||||
) -> Decimal:
|
||||
"""켜진 비목만 순서대로 계산해 합계를 돌려준다."""
|
||||
enabled = [key for key in data.enabled_items if key in _ITEM_BY_KEY]
|
||||
unknown = [key for key in data.enabled_items if key not in _ITEM_BY_KEY]
|
||||
if unknown:
|
||||
raise RateLookupError(f"모르는 비목입니다: {unknown}")
|
||||
|
||||
missing = [k for k in enabled if _ITEM_BY_KEY[k].rate_variable not in dataset.variables]
|
||||
if missing:
|
||||
raise RateLookupError(
|
||||
f"이 요율 판({dataset.effective_date})에 없는 비목입니다: "
|
||||
f"{[item_name(k) for k in missing]}"
|
||||
)
|
||||
|
||||
total = _ZERO
|
||||
health_amount = _ZERO
|
||||
|
||||
for key in enabled:
|
||||
item = _ITEM_BY_KEY[key]
|
||||
|
||||
if key == "safety_management_cost":
|
||||
total += safety_management_cost(dataset, data, ctx, emit)
|
||||
continue
|
||||
|
||||
if key == "long_term_care_insurance":
|
||||
if "health_insurance" not in enabled:
|
||||
raise RateLookupError(
|
||||
"노인장기요양보험료는 건강보험료를 밑수로 씁니다 — 건강보험료를 켜야 합니다"
|
||||
)
|
||||
total += emit(
|
||||
key=key,
|
||||
name=item.name,
|
||||
base_label=item.base_label,
|
||||
base=health_amount,
|
||||
percent=Decimal(str(dataset.variable(item.rate_variable)["rate_percent"])),
|
||||
)
|
||||
continue
|
||||
|
||||
base, percent, note = _base_and_rate(dataset, data, ctx, item)
|
||||
if base is None:
|
||||
continue # 적용 하한 미달 — 줄 자체를 만들지 않는다
|
||||
|
||||
amount = emit(
|
||||
key=key,
|
||||
name=item.name,
|
||||
base_label=item.base_label,
|
||||
base=base,
|
||||
percent=percent,
|
||||
note=note,
|
||||
)
|
||||
if key == "health_insurance":
|
||||
health_amount = amount
|
||||
total += amount
|
||||
|
||||
return total
|
||||
|
||||
|
||||
def _base_and_rate(
|
||||
dataset: RateDataset,
|
||||
data: CostInput,
|
||||
ctx: ExpenseContext,
|
||||
item: StatutoryItem,
|
||||
) -> tuple[Decimal | None, Decimal, str]:
|
||||
"""비목별 밑수·요율. 밑수가 `None` 이면 적용 대상이 아니다."""
|
||||
variable = dataset.variable(item.rate_variable)
|
||||
key = item.key
|
||||
|
||||
if key in ("industrial_accident_insurance", "wage_claim_contribution", "asbestos_contribution"):
|
||||
return ctx.total_labor_cost, Decimal(str(variable["rate_percent"])), ""
|
||||
|
||||
if key == "employment_insurance":
|
||||
# 등급이 추정가격으로 갈린다. 임도는 대개 고시 기준금액 미만이라 숫자 구간에 안 걸리므로
|
||||
# 잔여 구간을 이름으로 지정한다(등급 7·그 이하 모두 같은 요율).
|
||||
row = select_bracket(
|
||||
variable["brackets"],
|
||||
amount_field="estimated_amount_bracket",
|
||||
amount=ctx.scale_reference,
|
||||
residual_label="below_official_threshold",
|
||||
label=item.name,
|
||||
)
|
||||
return ctx.total_labor_cost, rate_percent(row, label=item.name), ""
|
||||
|
||||
if key == "health_insurance":
|
||||
return ctx.direct_labor_cost, Decimal(str(variable["rate_percent"])), ""
|
||||
|
||||
if key == "national_pension":
|
||||
return ctx.direct_labor_cost, pension_rate_percent(dataset, data.pension_year), ""
|
||||
|
||||
if key == "other_expense":
|
||||
row = select_bracket(
|
||||
variable["brackets"],
|
||||
amount_field="direct_cost_bracket",
|
||||
amount=ctx.direct_construction_cost,
|
||||
duration_days=data.duration_days,
|
||||
equals={"work_type": data.work_type_indirect_labor},
|
||||
label=item.name,
|
||||
)
|
||||
return ctx.material_cost + ctx.total_labor_cost, rate_percent(row, label=item.name), ""
|
||||
|
||||
if key == "environment_preservation":
|
||||
if not _threshold_met(
|
||||
dataset, item.rate_variable, "minimum_estimated_amount_krw", ctx.scale_reference
|
||||
):
|
||||
return None, _ZERO, ""
|
||||
row = next(
|
||||
(
|
||||
r
|
||||
for r in variable["all_work_types"]
|
||||
if r.get("work_type") == data.environment_work_type
|
||||
),
|
||||
None,
|
||||
)
|
||||
if row is None:
|
||||
raise RateLookupError(
|
||||
f"환경보전비: 공종을 못 찾았습니다 — {data.environment_work_type}"
|
||||
)
|
||||
note = ""
|
||||
if variable.get("forest_road_selection_status") == "pending":
|
||||
note = "⚠ 임도 공종 채택값 미확정 (지식DB `forest_road_selection_status: pending`)"
|
||||
return ctx.direct_construction_cost, rate_percent(row, label=item.name), note
|
||||
|
||||
if key == "retirement_mutual_aid":
|
||||
if not _threshold_met(
|
||||
dataset, item.rate_variable, "minimum_estimated_amount_krw", ctx.scale_reference
|
||||
):
|
||||
return None, _ZERO, ""
|
||||
return ctx.direct_labor_cost, Decimal(str(variable["rate_percent"])), ""
|
||||
|
||||
if key == "equipment_payment_guarantee":
|
||||
rows = variable["general_construction"] + variable["specialty_construction"]
|
||||
row = next(
|
||||
(r for r in rows if r.get("work_type") == data.equipment_guarantee_work_type), None
|
||||
)
|
||||
if row is None:
|
||||
raise RateLookupError(
|
||||
"건설기계대여대금 지급보증: 공종을 못 찾았습니다 — "
|
||||
f"{data.equipment_guarantee_work_type}"
|
||||
)
|
||||
return ctx.direct_construction_cost, rate_percent(row, label=item.name), ""
|
||||
|
||||
if key == "subcontract_payment_guarantee":
|
||||
# 30억 이상 구간은 공종(토목·산업설비 / 건축)으로 한 번 더 갈린다.
|
||||
row = select_bracket(
|
||||
variable["brackets"],
|
||||
amount_field="estimated_price_bracket",
|
||||
amount=ctx.scale_reference,
|
||||
prefer_suffix=data.subcontract_guarantee_variant,
|
||||
label=item.name,
|
||||
)
|
||||
return ctx.direct_construction_cost, rate_percent(row, label=item.name), ""
|
||||
|
||||
if key == "performance_guarantee_fee":
|
||||
row = select_bracket(
|
||||
variable["brackets"],
|
||||
amount_field="direct_cost_bracket",
|
||||
amount=ctx.direct_construction_cost,
|
||||
label=item.name,
|
||||
)
|
||||
percent = _guarantee_percent_from_formula(row, label=item.name)
|
||||
years = Decimal(str(data.duration_days)) / Decimal(365)
|
||||
note = variable.get("typical_forest_road_applicability", "")
|
||||
return ctx.direct_construction_cost * years, percent, f"임도 적용성: {note}" if note else ""
|
||||
|
||||
raise RateLookupError(f"밑수 정의가 없는 비목입니다: {key}")
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -609,9 +609,66 @@ 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_Empty: [
|
||||
"측점 단면적이 아직 없습니다. 횡단 설계를 먼저 마치세요.",
|
||||
"No cross-section areas yet. Finish the cross-section design first.",
|
||||
],
|
||||
B08_Quantity_Grid_Failed: [
|
||||
"토적표를 불러오지 못했습니다.",
|
||||
"Failed to load the earthwork table.",
|
||||
],
|
||||
B08_Quantity_Side_Method: ["산출법", "Method"],
|
||||
B08_Quantity_Side_Method_Value: ["평균단면적법", "Average end area"],
|
||||
B08_Quantity_Side_Factors: ["토량환산계수(다짐)", "Conversion factors (compacted)"],
|
||||
|
||||
/* --- 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"],
|
||||
|
||||
@@ -82,7 +82,7 @@ export const ui_locales_common = {
|
||||
WF_Step_ProfileCross: ["횡단설계", "Cross Design"],
|
||||
WF_Step_DesignDetail: ["상세설계", "Detail Design"],
|
||||
WF_Step_Quantity: ["수량산출", "Quantity"],
|
||||
WF_Step_Estimation: ["설계도서", "Design Docs"],
|
||||
WF_Step_Estimation: ["원가계산", "Cost Estimate"],
|
||||
WF_State_Stale: ["무효화됨 (하위 단계 변경)", "Stale (invalidated by downstream changes)"],
|
||||
WF_State_Failed: ["실패", "Failed"],
|
||||
WF_State_Complete: ["완료", "Complete"],
|
||||
|
||||
Reference in New Issue
Block a user