feat(B09): 원가계산 화면에 공종 수량 연결
- 공종 수량(코드=수량)을 일위대가에 곱해 직접비 3분할 생성, ⑤ 원가계산서 밑수로 투입 — 뭉치지 않고 재료·노무·경비 성분 그대로 전달 - 단가 없는 공종은 0 으로 안 때우고 `missing_unit_prices` 로 화면 노출 - 계산 원천을 화면에 표시 (「수량 원천: 손입력 / 직접 입력」) - ⑤ 표에 찍히는 값은 자원 집계표 규칙(반올림)으로 절단 — 소수점 유출 제거 - 일위대가 요약에 총액 분포(최소·중앙·최대)·의심 저가 목록 추가 검증: pytest 136 통과, 공용 브라우저(5174) 실측 — 수량 입력 후 재계산 시 재료비 10,478,189 · 순공사원가 84,454,045 · 총공사비 113,652,213, 미매칭 공종 1건 화면 표시 확인 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import replace as dataclass_replace
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
@@ -28,10 +29,12 @@ from B09_Estimation.B09_Estimation_Engine_Cost import (
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
|
||||
from B09_Estimation.B09_Estimation_Rates import RateLookupError
|
||||
from B09_Estimation.B09_Estimation_Statutory import STATUTORY_ITEMS
|
||||
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import (
|
||||
build_summary,
|
||||
cached_build,
|
||||
detail_of,
|
||||
direct_cost_from_quantities,
|
||||
list_unit_prices,
|
||||
)
|
||||
from common_util.common_util_workflow_state import complete_stage
|
||||
@@ -70,6 +73,11 @@ class CostRequest(BaseModel):
|
||||
|
||||
rate_file_name: str = "rates_2026.json"
|
||||
|
||||
#: 공종별 수량 `{공종코드: 수량}`. 주면 **직접비 3분할을 여기서 만들어** 쓴다.
|
||||
#: ⚠ 일위대가 합계를 뭉쳐 넣지 않는다 — 밑수가 항목마다 갈린다(PLAN 8-9 규칙 2).
|
||||
#: 지금 원천은 **손입력**이고, B08 인계(9번)가 나오면 **원천만 바꿔 끼운다**.
|
||||
quantities: dict[str, Decimal] | None = None
|
||||
|
||||
#: 목표 도급공사비 — 주면 「필요한 이윤 조정액」을 **보여만 준다**.
|
||||
#: ★ 법대로(PLAN 8-10) — 프로그램이 스스로 이윤을 깎지 않는다.
|
||||
target_contract_amount_krw: Decimal | None = None
|
||||
@@ -125,8 +133,25 @@ def _serialize(result: CostResult) -> dict[str, Any]:
|
||||
@router.post("/{project_id}/estimation/cost")
|
||||
async def compute_cost(project_id: UUID, payload: CostRequest) -> JSONResponse:
|
||||
"""공사원가계산서 한 장을 계산해 돌려준다 (저장 없음)."""
|
||||
direct_source = "manual"
|
||||
missing_unit_prices: list[str] = []
|
||||
try:
|
||||
result = calculate_cost(payload.to_engine_input())
|
||||
data = payload.to_engine_input()
|
||||
if payload.quantities:
|
||||
# 수량이 오면 **일위대가에서 직접비 3분할을 만들어** 갈아 끼운다.
|
||||
breakdown = direct_cost_from_quantities(payload.quantities)
|
||||
# ⑤ 표에 찍히는 자리라 **자원 집계표 규칙(반올림)** 으로 자른다 —
|
||||
# 안 자르면 원가계산서에 소수점이 그대로 흘러나온다.
|
||||
summary = OutputPlace.RESOURCE_SUMMARY
|
||||
data = dataclass_replace(
|
||||
data,
|
||||
direct_material_krw=round_at(breakdown.material, summary),
|
||||
direct_labor_krw=round_at(breakdown.labor, summary),
|
||||
direct_expense_krw=round_at(breakdown.expense, summary),
|
||||
)
|
||||
direct_source = "quantities"
|
||||
missing_unit_prices = breakdown.missing
|
||||
result = calculate_cost(data)
|
||||
except RateLookupError as error:
|
||||
# 요율 구간을 못 고른 경우 — 기본값으로 때우지 않고 그대로 알린다.
|
||||
logger.warning("B09 원가계산 요율 조회 실패: project_id=%s, %s", project_id, error)
|
||||
@@ -139,6 +164,10 @@ async def compute_cost(project_id: UUID, payload: CostRequest) -> JSONResponse:
|
||||
)
|
||||
|
||||
body = _serialize(result)
|
||||
# 어느 값으로 계산했는지 화면이 알아야 한다 — 안 보이면 나중에 못 가른다.
|
||||
body["direct_cost_source"] = direct_source
|
||||
# 수량은 있는데 단가가 없는 공종 — **화면에 반드시 보인다**.
|
||||
body["missing_unit_prices"] = missing_unit_prices
|
||||
if payload.target_contract_amount_krw is not None:
|
||||
# 필요액을 **보여만 준다**. 적용은 설계자가 `profit_adjustment_krw` 로 명시해야 한다.
|
||||
body["suggested_profit_adjustment_krw"] = str(
|
||||
|
||||
@@ -14,11 +14,18 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
import {
|
||||
goToWorkflowStage,
|
||||
WORKFLOW_STEP_ROUTES,
|
||||
} from "../A00_Common/b_workflow_nav";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -42,6 +49,8 @@ interface CostLineDto {
|
||||
|
||||
interface CostSheetDto {
|
||||
status: string;
|
||||
direct_cost_source: "manual" | "quantities";
|
||||
missing_unit_prices: string[];
|
||||
lines: CostLineDto[];
|
||||
totals: Record<string, string>;
|
||||
rate_version: { dataset_id: string; effective_date: string; sha256: string };
|
||||
@@ -62,7 +71,12 @@ interface UnitPriceRow {
|
||||
|
||||
interface UnitPriceListDto {
|
||||
status: string;
|
||||
summary: { titles: number; unit_prices: number; machine_hourly: number; notes: string[] };
|
||||
summary: {
|
||||
titles: number;
|
||||
unit_prices: number;
|
||||
machine_hourly: number;
|
||||
notes: string[];
|
||||
};
|
||||
rows: UnitPriceRow[];
|
||||
}
|
||||
|
||||
@@ -101,6 +115,8 @@ interface CostFormState {
|
||||
procurement_fee_krw: string;
|
||||
profit_adjustment_krw: string;
|
||||
target_contract_amount_krw: string;
|
||||
/** 「공종코드=수량」 한 줄씩. 비어 있으면 위 직접비 3칸을 그대로 쓴다. */
|
||||
quantities_text: string;
|
||||
}
|
||||
|
||||
const INITIAL_FORM: CostFormState = {
|
||||
@@ -112,6 +128,7 @@ const INITIAL_FORM: CostFormState = {
|
||||
procurement_fee_krw: "0",
|
||||
profit_adjustment_krw: "0",
|
||||
target_contract_amount_krw: "",
|
||||
quantities_text: "",
|
||||
};
|
||||
|
||||
/** 총계 성격의 줄 — 표에서 굵게 띄운다. */
|
||||
@@ -170,6 +187,7 @@ function injectStyles(): void {
|
||||
.b09-sheet tr.is-total td { font-weight: 600; background: var(--color-surface); }
|
||||
.b09-sheet tr.is-adopted td { background: var(--color-surface); }
|
||||
.b09-sheet tr.is-dropped td { color: var(--color-text-secondary); text-decoration: line-through; }
|
||||
.b09-qty { min-height: 64px; font-family: monospace; font-size: var(--font-size-xs, 12px); }
|
||||
.b09-clickable { cursor: pointer; }
|
||||
.b09-clickable:hover td { background: var(--color-surface); }
|
||||
.b09-up-list { max-height: 45%; }
|
||||
@@ -216,8 +234,10 @@ function buildCostSheetTable(sheet: CostSheetDto): HTMLElement {
|
||||
for (const line of sheet.lines) {
|
||||
const tr = document.createElement("tr");
|
||||
if (TOTAL_KEYS.has(line.key)) tr.classList.add("is-total");
|
||||
if (line.note === L("B09_Estimation_Adopted")) tr.classList.add("is-adopted");
|
||||
if (line.note === L("B09_Estimation_NotAdopted")) tr.classList.add("is-dropped");
|
||||
if (line.note === L("B09_Estimation_Adopted"))
|
||||
tr.classList.add("is-adopted");
|
||||
if (line.note === L("B09_Estimation_NotAdopted"))
|
||||
tr.classList.add("is-dropped");
|
||||
|
||||
const name = document.createElement("td");
|
||||
name.className = "b09-left";
|
||||
@@ -227,7 +247,8 @@ function buildCostSheetTable(sheet: CostSheetDto): HTMLElement {
|
||||
amount.textContent = formatWon(line.amount_krw);
|
||||
|
||||
const rate = document.createElement("td");
|
||||
rate.textContent = line.rate_percent === null ? "" : `${line.rate_percent}%`;
|
||||
rate.textContent =
|
||||
line.rate_percent === null ? "" : `${line.rate_percent}%`;
|
||||
|
||||
const basis = document.createElement("td");
|
||||
basis.className = "b09-left";
|
||||
@@ -373,7 +394,13 @@ function buildUnitPriceDetail(
|
||||
unit.className = "b09-left";
|
||||
unit.textContent = row.unit;
|
||||
tr.append(name, spec, source, unit);
|
||||
for (const value of [row.quantity, row.material, row.labor, row.expense, row.total]) {
|
||||
for (const value of [
|
||||
row.quantity,
|
||||
row.material,
|
||||
row.labor,
|
||||
row.expense,
|
||||
row.total,
|
||||
]) {
|
||||
const cell = document.createElement("td");
|
||||
cell.textContent = formatWon(value);
|
||||
tr.append(cell);
|
||||
@@ -388,7 +415,12 @@ function buildUnitPriceDetail(
|
||||
label.colSpan = 5;
|
||||
label.textContent = L("B09_Estimation_Col_Total");
|
||||
sum.append(label);
|
||||
for (const value of [detail.material, detail.labor, detail.expense, detail.total]) {
|
||||
for (const value of [
|
||||
detail.material,
|
||||
detail.labor,
|
||||
detail.expense,
|
||||
detail.total,
|
||||
]) {
|
||||
const cell = document.createElement("td");
|
||||
cell.textContent = formatWon(value);
|
||||
sum.append(cell);
|
||||
@@ -470,6 +502,25 @@ function buildSidePanel(
|
||||
["target_contract_amount_krw", "B09_Estimation_Field_TargetContract"],
|
||||
]);
|
||||
|
||||
// 수량 — 여러 줄이라 텍스트 영역으로. 비어 있으면 위 직접비 3칸을 그대로 쓴다.
|
||||
const quantityGroup = document.createElement("div");
|
||||
quantityGroup.className = "b09-panel__group";
|
||||
const quantityLegend = document.createElement("span");
|
||||
quantityLegend.className = "b09-panel__legend";
|
||||
quantityLegend.textContent = L("B09_Estimation_Group_Quantity");
|
||||
const quantityLabel = document.createElement("label");
|
||||
quantityLabel.className = "ui-field__label";
|
||||
quantityLabel.textContent = L("B09_Estimation_Field_Quantities");
|
||||
const quantityInput = document.createElement("textarea");
|
||||
quantityInput.className = "ui-input b09-qty";
|
||||
quantityInput.rows = 4;
|
||||
quantityInput.placeholder = "FP-09-21=500";
|
||||
quantityInput.addEventListener("input", () => {
|
||||
form.quantities_text = quantityInput.value;
|
||||
});
|
||||
quantityGroup.append(quantityLegend, quantityLabel, quantityInput);
|
||||
root.append(quantityGroup);
|
||||
|
||||
const hintBox = document.createElement("div");
|
||||
hintBox.className = "b09-hint";
|
||||
root.append(hintBox);
|
||||
@@ -477,8 +528,15 @@ function buildSidePanel(
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b09-panel__actions";
|
||||
actions.append(
|
||||
createButton({ label: L("B09_Estimation_Btn_Recalc"), variant: "filled", onClick: onRecalc }),
|
||||
createButton({ label: L("B09_Estimation_Btn_Confirm"), onClick: onConfirm }),
|
||||
createButton({
|
||||
label: L("B09_Estimation_Btn_Recalc"),
|
||||
variant: "filled",
|
||||
onClick: onRecalc,
|
||||
}),
|
||||
createButton({
|
||||
label: L("B09_Estimation_Btn_Confirm"),
|
||||
onClick: onConfirm,
|
||||
}),
|
||||
);
|
||||
root.append(actions);
|
||||
|
||||
@@ -490,7 +548,12 @@ function renderRateVersion(box: HTMLElement, sheet: CostSheetDto | null): void {
|
||||
if (!sheet) return;
|
||||
const rows: Array<[string, string]> = [
|
||||
["적용일", sheet.rate_version.effective_date || "—"],
|
||||
["지문", sheet.rate_version.sha256 ? `${sheet.rate_version.sha256.slice(0, 8)}…` : "—"],
|
||||
[
|
||||
"지문",
|
||||
sheet.rate_version.sha256
|
||||
? `${sheet.rate_version.sha256.slice(0, 8)}…`
|
||||
: "—",
|
||||
],
|
||||
];
|
||||
for (const [label, value] of rows) {
|
||||
const row = document.createElement("div");
|
||||
@@ -519,7 +582,10 @@ const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [
|
||||
["base_data", "B09_Estimation_Tab_BaseData", false],
|
||||
];
|
||||
|
||||
function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement {
|
||||
function buildTabs(
|
||||
active: string,
|
||||
onSelect: (key: string) => void,
|
||||
): HTMLElement {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "b09-tabs";
|
||||
for (const [key, labelKey, enabled] of TAB_KEYS) {
|
||||
@@ -541,8 +607,24 @@ function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement
|
||||
* API
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
/** 「공종코드=수량」 여러 줄을 객체로. 형식이 아닌 줄은 조용히 버리지 않고 건너뛴다. */
|
||||
function parseQuantities(text: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const [code, value] = trimmed.split(/[=\t,]/);
|
||||
if (!code || !value) continue;
|
||||
const qty = value.trim();
|
||||
if (!/^\d+(\.\d+)?$/.test(qty)) continue;
|
||||
out[code.trim()] = qty;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toRequestBody(form: CostFormState): Record<string, unknown> {
|
||||
const num = (value: string): string => (value.trim() === "" ? "0" : value.trim());
|
||||
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),
|
||||
@@ -555,10 +637,15 @@ function toRequestBody(form: CostFormState): Record<string, unknown> {
|
||||
if (form.target_contract_amount_krw.trim() !== "") {
|
||||
body.target_contract_amount_krw = form.target_contract_amount_krw.trim();
|
||||
}
|
||||
const quantities = parseQuantities(form.quantities_text);
|
||||
if (Object.keys(quantities).length > 0) body.quantities = quantities;
|
||||
return body;
|
||||
}
|
||||
|
||||
async function fetchCostSheet(projectId: string, form: CostFormState): Promise<CostSheetDto> {
|
||||
async function fetchCostSheet(
|
||||
projectId: string,
|
||||
form: CostFormState,
|
||||
): Promise<CostSheetDto> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost`,
|
||||
{
|
||||
@@ -568,16 +655,20 @@ async function fetchCostSheet(projectId: string, form: CostFormState): Promise<C
|
||||
body: JSON.stringify(toRequestBody(form)),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`estimation cost failed: ${response.status}`);
|
||||
if (!response.ok)
|
||||
throw new Error(`estimation cost failed: ${response.status}`);
|
||||
return (await response.json()) as CostSheetDto;
|
||||
}
|
||||
|
||||
async function fetchUnitPriceList(projectId: string): Promise<UnitPriceListDto> {
|
||||
async function fetchUnitPriceList(
|
||||
projectId: string,
|
||||
): Promise<UnitPriceListDto> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices`,
|
||||
{ credentials: "include" },
|
||||
);
|
||||
if (!response.ok) throw new Error(`unit price list failed: ${response.status}`);
|
||||
if (!response.ok)
|
||||
throw new Error(`unit price list failed: ${response.status}`);
|
||||
return (await response.json()) as UnitPriceListDto;
|
||||
}
|
||||
|
||||
@@ -589,7 +680,8 @@ async function fetchUnitPriceDetail(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices/${encodeURIComponent(code)}`,
|
||||
{ credentials: "include" },
|
||||
);
|
||||
if (!response.ok) throw new Error(`unit price detail failed: ${response.status}`);
|
||||
if (!response.ok)
|
||||
throw new Error(`unit price detail failed: ${response.status}`);
|
||||
return (await response.json()) as UnitPriceDetailDto;
|
||||
}
|
||||
|
||||
@@ -598,7 +690,8 @@ async function confirmEstimationStage(projectId: string): Promise<void> {
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/confirm`,
|
||||
{ method: "POST", credentials: "include" },
|
||||
);
|
||||
if (!response.ok) throw new Error(`estimation confirm failed: ${response.status}`);
|
||||
if (!response.ok)
|
||||
throw new Error(`estimation confirm failed: ${response.status}`);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
@@ -689,6 +782,23 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
body.append(empty);
|
||||
return;
|
||||
}
|
||||
// 어느 값으로 계산했는지 화면에 남긴다 — 안 보이면 나중에 못 가른다.
|
||||
const source = document.createElement("div");
|
||||
source.className = "b09-hint";
|
||||
source.textContent =
|
||||
sheet.direct_cost_source === "quantities"
|
||||
? L("B09_Estimation_Src_Quantities")
|
||||
: L("B09_Estimation_Src_Manual");
|
||||
body.append(source);
|
||||
|
||||
// 수량은 있는데 단가가 없는 공종 — 총액에서 빠졌으므로 **반드시 보인다**.
|
||||
if (sheet.missing_unit_prices.length > 0) {
|
||||
const missing = document.createElement("div");
|
||||
missing.className = "b09-hint";
|
||||
missing.textContent = `${L("B09_Estimation_Missing_UP")} ${sheet.missing_unit_prices.join(", ")}`;
|
||||
body.append(missing);
|
||||
}
|
||||
|
||||
body.append(buildCostSheetTable(sheet));
|
||||
for (const note of sheet.notes) {
|
||||
const line = document.createElement("div");
|
||||
@@ -725,7 +835,8 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
sheet = await fetchCostSheet(projectId, form);
|
||||
renderRateVersion(panel.rateVersionBox, sheet);
|
||||
panel.hintBox.textContent =
|
||||
sheet.suggested_profit_adjustment_krw && sheet.suggested_profit_adjustment_krw !== "0"
|
||||
sheet.suggested_profit_adjustment_krw &&
|
||||
sheet.suggested_profit_adjustment_krw !== "0"
|
||||
? `${L("B09_Estimation_Suggest_Adjust")} ${formatWon(sheet.suggested_profit_adjustment_krw)}`
|
||||
: "";
|
||||
drawBody();
|
||||
@@ -757,7 +868,8 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
mainContent: main,
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
onStepClick: (stepIndex) => {
|
||||
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
if (projectId)
|
||||
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
},
|
||||
});
|
||||
root.append(layout.root);
|
||||
|
||||
@@ -48,6 +48,8 @@ _RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*")
|
||||
_ZERO = Decimal(0)
|
||||
#: 연료는 자재 카탈로그에 없어 합성 코드로 세운다 — 코드가 있어야 조인이 성립한다.
|
||||
FUEL_CODE_PREFIX = "M-FUEL-"
|
||||
#: 일위대가 총액이 이보다 작으면 **성분이 빠졌을 가능성**이 크다 — 값이 있어도 경고한다.
|
||||
SUSPICIOUSLY_LOW_KRW = Decimal(100)
|
||||
|
||||
|
||||
def _slots(value: Decimal) -> list[Decimal | None]:
|
||||
@@ -321,8 +323,34 @@ def build_summary(build: UnitPriceBuild) -> dict:
|
||||
kinds: dict[str, int] = {}
|
||||
for title in build.book.titles.values():
|
||||
kinds[title.kind.value] = kinds.get(title.kind.value, 0) + 1
|
||||
# ⚠ **크기가 말이 되나**를 볼 수 있게 분포를 낸다.
|
||||
# 「0 이 아님」만 보면 씨앗뿜어붙이기가 **합계 68.8원**이던 것을 못 잡는다
|
||||
# (자재·장비가 통째로 빠지고 노무 한 줄만 남았던 자리, 2026-09-07).
|
||||
totals = sorted(
|
||||
build.book.resolve(code).total
|
||||
for code, title in build.book.titles.items()
|
||||
if title.kind is PriceKind.UNIT_PRICE
|
||||
)
|
||||
stats: dict[str, str] = {}
|
||||
low: list[dict[str, str]] = []
|
||||
if totals:
|
||||
stats = {
|
||||
"min": _money_text(totals[0]),
|
||||
"median": _money_text(totals[len(totals) // 2]),
|
||||
"max": _money_text(totals[-1]),
|
||||
}
|
||||
low = [
|
||||
{"code": code, "name": title.name, "total": _money_text(money)}
|
||||
for code, title in build.book.titles.items()
|
||||
if title.kind is PriceKind.UNIT_PRICE
|
||||
and (money := build.book.resolve(code).total) < SUSPICIOUSLY_LOW_KRW
|
||||
]
|
||||
|
||||
return {
|
||||
"titles": len(build.book.titles),
|
||||
"unit_price_totals": stats,
|
||||
# 값이 서기는 했는데 **크기가 이상한** 것 — 성분이 빠졌을 가능성이 크다.
|
||||
"suspiciously_low": low,
|
||||
"unit_prices": kinds.get(PriceKind.UNIT_PRICE.value, 0),
|
||||
"machine_hourly": kinds.get(PriceKind.MACHINE_HOURLY.value, 0),
|
||||
"skipped_work_items": len(build.skipped),
|
||||
@@ -489,11 +517,14 @@ def cost_input_from_quantities(
|
||||
from B09_Estimation.B09_Estimation_Engine_Cost import CostInput
|
||||
|
||||
breakdown = direct_cost_from_quantities(quantities, build)
|
||||
# ⑤ 표에 들어가는 자리이므로 여기서 자른다 — 자원 집계표는 **반올림**이다
|
||||
# (`B09_Estimation_Rounding` 참조). `breakdown` 자체는 전정밀 값으로 남긴다.
|
||||
summary = OutputPlace.RESOURCE_SUMMARY
|
||||
return (
|
||||
CostInput(
|
||||
direct_material_krw=breakdown.material,
|
||||
direct_labor_krw=breakdown.labor,
|
||||
direct_expense_krw=breakdown.expense,
|
||||
direct_material_krw=round_at(breakdown.material, summary),
|
||||
direct_labor_krw=round_at(breakdown.labor, summary),
|
||||
direct_expense_krw=round_at(breakdown.expense, summary),
|
||||
**cost_input_kwargs,
|
||||
),
|
||||
breakdown,
|
||||
|
||||
@@ -37,7 +37,10 @@ export const ui_locales_b2 = {
|
||||
"B04에서 분석해 둔 배수유역을 불러와, 지금 배치된 배관을 기준으로 세부유역(관이 담당하는 구역)을 다시 나눕니다. 관이 부족한 구간은 자동으로 보충합니다. B04 분석 결과가 없으면 B04에서 먼저 실행해야 합니다.",
|
||||
"Reloads the B04 drainage analysis and re-splits sub-basins around the current culverts, adding culverts where spacing requires. Run the analysis in B04 first if none exists.",
|
||||
],
|
||||
B05_Drainage_Btn_DeleteSelected: ["선택한 관 삭제", "Delete selected culvert"],
|
||||
B05_Drainage_Btn_DeleteSelected: [
|
||||
"선택한 관 삭제",
|
||||
"Delete selected culvert",
|
||||
],
|
||||
B05_Drainage_Btn_DeleteSelected_Tip: [
|
||||
"지도에서 고른 배관 한 개를 지웁니다. 관을 먼저 눌러 고른 뒤에 쓸 수 있습니다.",
|
||||
"Removes the culvert selected on the map. Select a culvert marker first.",
|
||||
@@ -69,19 +72,34 @@ export const ui_locales_b2 = {
|
||||
"노선을 확정하면 배수유역도가 표시됩니다.",
|
||||
"The drainage map appears once the route is confirmed.",
|
||||
],
|
||||
B05_Drainage_Status_Analyzing: ["세부유역을 산정하는 중…", "Computing sub-basins…"],
|
||||
B05_Drainage_Status_NoBasin: ["산정된 배수유역이 없습니다.", "No drainage basin was computed."],
|
||||
B05_Drainage_Status_Analyzing: [
|
||||
"세부유역을 산정하는 중…",
|
||||
"Computing sub-basins…",
|
||||
],
|
||||
B05_Drainage_Status_NoBasin: [
|
||||
"산정된 배수유역이 없습니다.",
|
||||
"No drainage basin was computed.",
|
||||
],
|
||||
B05_Drainage_Status_AnalyzeFailed: [
|
||||
"세부유역 산정에 실패했습니다.",
|
||||
"Failed to compute sub-basins.",
|
||||
],
|
||||
B05_Drainage_Status_LoadingBase: ["배경도를 불러오는 중…", "Loading the basemap…"],
|
||||
B05_Drainage_Status_LoadingSheets: ["도엽 레이어를 불러오는 중…", "Loading map sheet layers…"],
|
||||
B05_Drainage_Status_LoadingBase: [
|
||||
"배경도를 불러오는 중…",
|
||||
"Loading the basemap…",
|
||||
],
|
||||
B05_Drainage_Status_LoadingSheets: [
|
||||
"도엽 레이어를 불러오는 중…",
|
||||
"Loading map sheet layers…",
|
||||
],
|
||||
B05_Drainage_Status_NoSheets: [
|
||||
"도엽 레이어가 없습니다. B04에서 임포트하세요.",
|
||||
"No map sheet layer found. Import them in B04.",
|
||||
],
|
||||
B05_Drainage_Status_LoadFailed: ["배경도를 불러오지 못했습니다.", "Failed to load the basemap."],
|
||||
B05_Drainage_Status_LoadFailed: [
|
||||
"배경도를 불러오지 못했습니다.",
|
||||
"Failed to load the basemap.",
|
||||
],
|
||||
B05_Drainage_Basin_Undecided: ["미정", "TBD"],
|
||||
/* 관 최대 규격 초과 계류 유역 — 관이 아니라 세월교 대상. 유효직경은 앞머리가 적는다 */
|
||||
B05_Drainage_Basin_Bridge: ["세월교 제안", "Ford bridge proposal"],
|
||||
@@ -96,7 +114,10 @@ export const ui_locales_b2 = {
|
||||
"Tc {tc}min · I {i}mm/hr · Qd {q}m³/s (100yr, ×2.0)",
|
||||
],
|
||||
/* {chainage}=측점 누가거리(m) */
|
||||
B05_Drainage_Basin_Chainage: ["측점 누가거리 {chainage}m", "Station chainage {chainage}m"],
|
||||
B05_Drainage_Basin_Chainage: [
|
||||
"측점 누가거리 {chainage}m",
|
||||
"Station chainage {chainage}m",
|
||||
],
|
||||
/* {d}=규격 스냅 관경(mm). 유효직경 이상인 가장 작은 레지스트리 선택지 */
|
||||
B05_Drainage_Basin_RecPipe: ["Ø{d} 배관 제안", "Ø{d} pipe proposal"],
|
||||
/* 유효직경 Ø1,500 초과 — 교본 BOX암거 전환 유량 조건 */
|
||||
@@ -121,7 +142,10 @@ export const ui_locales_b2 = {
|
||||
B05_Route_Field_Filter: ["지면 필터", "Ground filter"],
|
||||
B05_Route_Field_Method: ["지표면 표현", "Surface method"],
|
||||
B05_Route_Field_SurfaceId: ["지표면 모델 ID", "Surface model ID"],
|
||||
B05_Route_Surface_Confirmed: ["확정 모델 #{id} · {method}", "Confirmed model #{id} · {method}"],
|
||||
B05_Route_Surface_Confirmed: [
|
||||
"확정 모델 #{id} · {method}",
|
||||
"Confirmed model #{id} · {method}",
|
||||
],
|
||||
B05_Route_Surface_NotConfirmed: [
|
||||
"WF1에서 지표면 모델을 확정하세요.",
|
||||
"Confirm a surface model in WF1.",
|
||||
@@ -156,27 +180,45 @@ export const ui_locales_b2 = {
|
||||
],
|
||||
B05_Route_Reset_Failed: ["초기화에 실패했습니다.", "Failed to reset."],
|
||||
B05_Route_Result_Title: ["경로 탐색 결과", "Route Result"],
|
||||
B05_Route_Result_Empty: ["아직 계산된 경로가 없습니다.", "No route computed yet."],
|
||||
B05_Route_Result_Empty: [
|
||||
"아직 계산된 경로가 없습니다.",
|
||||
"No route computed yet.",
|
||||
],
|
||||
B05_Route_Result_Length: ["총 연장(m)", "Total length (m)"],
|
||||
B05_Route_Result_MinSlope: ["최소 경사", "Min slope"],
|
||||
B05_Route_Result_MaxSlope: ["최대 경사", "Max slope"],
|
||||
B05_Route_Result_MeanSlope: ["평균 경사", "Mean slope"],
|
||||
B05_Route_Result_Cost: ["비용 점수", "Cost score"],
|
||||
B05_Route_Result_Path: ["경로 파일", "Route file"],
|
||||
B05_Route_Error_Project: ["먼저 프로젝트를 선택하세요.", "Select a project first."],
|
||||
B05_Route_Error_Project: [
|
||||
"먼저 프로젝트를 선택하세요.",
|
||||
"Select a project first.",
|
||||
],
|
||||
B05_Route_Error_Points: [
|
||||
"시점과 종점 좌표를 모두 입력하세요.",
|
||||
"Enter both begin and end coordinates.",
|
||||
],
|
||||
B05_Route_Error_Filter: ["지면 필터 키를 입력하세요.", "Enter a ground filter key."],
|
||||
B05_Route_Error_Filter: [
|
||||
"지면 필터 키를 입력하세요.",
|
||||
"Enter a ground filter key.",
|
||||
],
|
||||
B05_Route_Solve_Success: ["경로 탐색을 완료했습니다.", "Route solved."],
|
||||
B05_Route_Solve_Failed: ["경로 탐색에 실패했습니다.", "Route solve failed."],
|
||||
B05_Route_Confirm_Success: ["경로를 확정했습니다.", "Route confirmed."],
|
||||
B05_Route_Confirm_Failed: ["경로 확정에 실패했습니다.", "Route confirm failed."],
|
||||
B05_Route_Group_SectionOptions: ["시작 측점 및 샘플링 설정", "Start Station & Sampling Settings"],
|
||||
B05_Route_Confirm_Failed: [
|
||||
"경로 확정에 실패했습니다.",
|
||||
"Route confirm failed.",
|
||||
],
|
||||
B05_Route_Group_SectionOptions: [
|
||||
"시작 측점 및 샘플링 설정",
|
||||
"Start Station & Sampling Settings",
|
||||
],
|
||||
B05_Route_Field_StationInterval: ["측점 간격(m)", "Station interval (m)"],
|
||||
B05_Route_Field_CrossHalfWidth: ["횡단 반폭(m)", "Cross half-width (m)"],
|
||||
B05_Route_Field_CrossSample: ["횡단 샘플 간격(m)", "Cross sample interval (m)"],
|
||||
B05_Route_Field_CrossSample: [
|
||||
"횡단 샘플 간격(m)",
|
||||
"Cross sample interval (m)",
|
||||
],
|
||||
B05_Route_Field_LongSample: ["종단 샘플 간격(m)", "Long sample interval (m)"],
|
||||
B05_Route_Field_StationLines: ["측점 가로선", "Station cross lines"],
|
||||
B05_Route_Field_StationLabels: ["측점 라벨", "Station labels"],
|
||||
@@ -189,7 +231,10 @@ export const ui_locales_b2 = {
|
||||
B06_Profile_Field_Method: ["지표면 표현", "Surface method"],
|
||||
B06_Profile_Field_Crs: ["좌표계", "CRS"],
|
||||
B06_Profile_Group_Display: ["표시 옵션", "Display Options"],
|
||||
B06_Profile_Field_VerticalExaggeration: ["높이 배율", "Vertical exaggeration"],
|
||||
B06_Profile_Field_VerticalExaggeration: [
|
||||
"높이 배율",
|
||||
"Vertical exaggeration",
|
||||
],
|
||||
B06_Profile_Field_Smooth: ["지표면 스무딩", "Smooth surface"],
|
||||
B06_Profile_Smooth_On: ["사용", "On"],
|
||||
B06_Profile_Smooth_Off: ["미사용", "Off"],
|
||||
@@ -216,9 +261,18 @@ export const ui_locales_b2 = {
|
||||
B06_Profile_Result_Length: ["종단 연장(m)", "Longitudinal length (m)"],
|
||||
B06_Profile_Result_CrossCount: ["횡단 개수", "Cross-section count"],
|
||||
B06_Profile_Result_Path: ["종단 파일", "Longitudinal file"],
|
||||
B06_Profile_Error_Project: ["먼저 프로젝트를 선택하세요.", "Select a project first."],
|
||||
B06_Profile_Confirm_Success: ["종·횡단을 확정했습니다.", "Sections confirmed."],
|
||||
B06_Profile_Confirm_Failed: ["종·횡단 확정에 실패했습니다.", "Section confirm failed."],
|
||||
B06_Profile_Error_Project: [
|
||||
"먼저 프로젝트를 선택하세요.",
|
||||
"Select a project first.",
|
||||
],
|
||||
B06_Profile_Confirm_Success: [
|
||||
"종·횡단을 확정했습니다.",
|
||||
"Sections confirmed.",
|
||||
],
|
||||
B06_Profile_Confirm_Failed: [
|
||||
"종·횡단 확정에 실패했습니다.",
|
||||
"Section confirm failed.",
|
||||
],
|
||||
B06_Profile_Detail_Failed: [
|
||||
"종·횡단 도면 데이터를 불러오지 못했습니다.",
|
||||
"Failed to load section drawing data.",
|
||||
@@ -244,9 +298,18 @@ export const ui_locales_b2 = {
|
||||
B06_Cross_Revet_Pipe: ["관 길이", "Pipe length"],
|
||||
B06_Cross_Revet_Outward: ["바깥", "outward"],
|
||||
B06_Cross_Revet_Inward: ["안쪽", "inward"],
|
||||
B06_Cross_Revet_Left: ["왼쪽으로 — 관 길이 1m 단위", "Move left — 1m of pipe length"],
|
||||
B06_Cross_Revet_Right: ["오른쪽으로 — 관 길이 1m 단위", "Move right — 1m of pipe length"],
|
||||
B06_Cross_Revet_Reset: ["기슭막이 자동 자리로 초기화", "Reset revetment to solved position"],
|
||||
B06_Cross_Revet_Left: [
|
||||
"왼쪽으로 — 관 길이 1m 단위",
|
||||
"Move left — 1m of pipe length",
|
||||
],
|
||||
B06_Cross_Revet_Right: [
|
||||
"오른쪽으로 — 관 길이 1m 단위",
|
||||
"Move right — 1m of pipe length",
|
||||
],
|
||||
B06_Cross_Revet_Reset: [
|
||||
"기슭막이 자동 자리로 초기화",
|
||||
"Reset revetment to solved position",
|
||||
],
|
||||
B06_Cross_Revet_Inlet: ["기슭막이(유입)", "Revetment (inlet)"],
|
||||
B06_Cross_Revet_Outlet: ["기슭막이(유출)", "Revetment (outlet)"],
|
||||
/* 배관과 무관한 독립 기슭막이(구조물 정본 D군) — 2026-08-28. */
|
||||
@@ -267,7 +330,10 @@ export const ui_locales_b2 = {
|
||||
"Cannot move further down the slope",
|
||||
],
|
||||
B06_Cross_Height_Label: ["높이", "Height"],
|
||||
B06_Cross_Move_Label: ["이동(좌우·사면 상하)", "Move (lateral / along slope)"],
|
||||
B06_Cross_Move_Label: [
|
||||
"이동(좌우·사면 상하)",
|
||||
"Move (lateral / along slope)",
|
||||
],
|
||||
B06_Cross_Lateral_Label: ["좌우", "Lateral"],
|
||||
B06_Cross_Slope_Label: ["상하(사면)", "Along slope"],
|
||||
B06_Cross_Height_Minus: ["높이 −0.1m", "Height −0.1m"],
|
||||
@@ -276,7 +342,10 @@ export const ui_locales_b2 = {
|
||||
"{mat} 높이 한계 {limit}m — 더 올리려면 재질을 변경하세요",
|
||||
"{mat} height limit {limit}m — change material to go higher",
|
||||
],
|
||||
B06_Cross_Height_Floor: ["최소 높이라 더 낮출 수 없습니다", "Already at the minimum height"],
|
||||
B06_Cross_Height_Floor: [
|
||||
"최소 높이라 더 낮출 수 없습니다",
|
||||
"Already at the minimum height",
|
||||
],
|
||||
B06_Cross_Basin_Limit_Pipe: [
|
||||
"여기까지입니다 — 더 옮기면 배관 길이가 달라집니다(I형은 관을 감싸는 구조)",
|
||||
"Limit reached — moving further changes the pipe length (type I wraps the pipe)",
|
||||
@@ -444,7 +513,10 @@ export const ui_locales_b2 = {
|
||||
B06_Design_Area_Total: ["계", "Total"],
|
||||
/* 단위는 값 칸마다 붙이지 않고 표 좌상단(행제목 × 열제목 교차) 칸에 한 번만 적는다. */
|
||||
B06_Design_Area_Unit: ["㎡", "㎡"],
|
||||
B06_Design_Area_Highlight: ["누르면 해당 면적을 강조합니다", "Click to highlight this area"],
|
||||
B06_Design_Area_Highlight: [
|
||||
"누르면 해당 면적을 강조합니다",
|
||||
"Click to highlight this area",
|
||||
],
|
||||
B06_Design_Fill_Area: ["성토", "Fill"],
|
||||
B06_Design_Unset: ["미지정", "Not set"],
|
||||
B06_Design_DitchType_Legend: ["측구형식", "Ditch type"],
|
||||
@@ -485,8 +557,14 @@ export const ui_locales_b2 = {
|
||||
B06_Design_RockBoundary_Legend: ["암 경계", "Rock boundary"],
|
||||
B06_Design_RockBoundary_Up: ["암 경계선 올림", "Raise rock boundary"],
|
||||
B06_Design_RockBoundary_Down: ["암 경계선 내림", "Lower rock boundary"],
|
||||
B06_Design_RockBoundary_Reset: ["암 경계선 기본값 복원", "Reset rock boundary"],
|
||||
B06_Design_Failed: ["횡단 설계 계산에 실패했습니다.", "Failed to compute cross-section design."],
|
||||
B06_Design_RockBoundary_Reset: [
|
||||
"암 경계선 기본값 복원",
|
||||
"Reset rock boundary",
|
||||
],
|
||||
B06_Design_Failed: [
|
||||
"횡단 설계 계산에 실패했습니다.",
|
||||
"Failed to compute cross-section design.",
|
||||
],
|
||||
B06_Profile_Confirm_NeedDesign: [
|
||||
"지반유형이 지정되지 않은 측점이 있습니다.",
|
||||
"Some stations have no ground type assigned.",
|
||||
@@ -499,17 +577,29 @@ export const ui_locales_b2 = {
|
||||
"표시 반폭만 바로 반영합니다(측점 설계 재계산 없음). 계산 반폭(20m)을 넘는 값만 재생성이 필요해 시간이 걸립니다.",
|
||||
"Applies the display half-width only (no per-station redesign). Only values beyond the sampled 20 m need regeneration.",
|
||||
],
|
||||
B06_View_Apply_Success: ["표시 반폭을 반영했습니다.", "Display half-width applied."],
|
||||
B06_View_Apply_Success: [
|
||||
"표시 반폭을 반영했습니다.",
|
||||
"Display half-width applied.",
|
||||
],
|
||||
|
||||
/* --- B06 표준 횡단면 설정 패널 --- */
|
||||
B06_Std_Title: ["표준 횡단면 설정", "Standard cross-section"],
|
||||
B06_Std_Group_Soil: ["토사 구간", "Soil section"],
|
||||
B06_Std_Group_Rock: ["암 구간 (리핑/발파)", "Rock section (ripping/blasting)"],
|
||||
B06_Std_Group_Rock: [
|
||||
"암 구간 (리핑/발파)",
|
||||
"Rock section (ripping/blasting)",
|
||||
],
|
||||
B06_Std_Group_Paved: ["포장 구간", "Paved section"],
|
||||
B06_Std_Detail_Title: ["표준횡단면 상세값", "Standard cross-section details"],
|
||||
B06_Std_Section_Common: ["공통", "Common"],
|
||||
B06_Std_Section_RockOnly: ["암 구간 — 다른 값만", "Rock section - differing values"],
|
||||
B06_Std_Section_PavedOnly: ["포장 구간 — 다른 값만", "Paved section - differing values"],
|
||||
B06_Std_Section_RockOnly: [
|
||||
"암 구간 — 다른 값만",
|
||||
"Rock section - differing values",
|
||||
],
|
||||
B06_Std_Section_PavedOnly: [
|
||||
"포장 구간 — 다른 값만",
|
||||
"Paved section - differing values",
|
||||
],
|
||||
B06_Std_Field_RoadWidth: ["노폭(m)", "Road width (m)"],
|
||||
B06_Std_Field_ShoulderLeft: ["노견 좌(m)", "Shoulder L (m)"],
|
||||
B06_Std_Field_ShoulderRight: ["노견 우(m)", "Shoulder R (m)"],
|
||||
@@ -532,7 +622,10 @@ export const ui_locales_b2 = {
|
||||
"패널 설정을 전체 측점에 반영했습니다.",
|
||||
"Applied panel settings to all stations.",
|
||||
],
|
||||
B06_Std_Load_Title: ["다른 프로젝트에서 불러오기", "Load from another project"],
|
||||
B06_Std_Load_Title: [
|
||||
"다른 프로젝트에서 불러오기",
|
||||
"Load from another project",
|
||||
],
|
||||
B06_Std_Load_Select: ["프로젝트 선택", "Select project"],
|
||||
B06_Std_Load_Placeholder: ["— 프로젝트 선택 —", "— Select a project —"],
|
||||
B06_Std_Load_Empty: [
|
||||
@@ -542,7 +635,10 @@ export const ui_locales_b2 = {
|
||||
B06_Std_Load_Loading: ["불러오는 중…", "Loading…"],
|
||||
B06_Std_Load_Apply: ["현재 설정에 적용", "Apply to current settings"],
|
||||
B06_Std_Load_Applied: ["적용되었습니다.", "Applied."],
|
||||
B06_Std_Load_Failed: ["설계값을 불러오지 못했습니다.", "Failed to load design values."],
|
||||
B06_Std_Load_Failed: [
|
||||
"설계값을 불러오지 못했습니다.",
|
||||
"Failed to load design values.",
|
||||
],
|
||||
B06_Std_Load_None: [
|
||||
"선택한 프로젝트에 저장된 설계값이 없습니다.",
|
||||
"The selected project has no saved design values.",
|
||||
@@ -569,7 +665,10 @@ export const ui_locales_b2 = {
|
||||
"Side panel will be configured after the upstream data spec is finalized.",
|
||||
],
|
||||
B07_Cad_Loading: ["도면을 불러오는 중...", "Loading drawing..."],
|
||||
B07_Cad_Load_Failed: ["도면을 불러오지 못했습니다.", "Failed to load drawing."],
|
||||
B07_Cad_Load_Failed: [
|
||||
"도면을 불러오지 못했습니다.",
|
||||
"Failed to load drawing.",
|
||||
],
|
||||
B07_Info_Ground_Title: ["지반정보", "Ground info"],
|
||||
B07_Info_Plan_Title: ["계획정보", "Plan info"],
|
||||
B07_Info_GroundType: ["지반유형", "Ground type"],
|
||||
@@ -584,7 +683,10 @@ export const ui_locales_b2 = {
|
||||
B07_Info_FillArea: ["성토 단면적", "Fill area"],
|
||||
B07_Info_Provisional: ["잠정", "Provisional"],
|
||||
B07_Info_Confirmed: ["확정", "Confirmed"],
|
||||
B07_Info_NoDesign: ["지반·계획 지정 데이터가 없습니다.", "No ground/plan designation data."],
|
||||
B07_Info_NoDesign: [
|
||||
"지반·계획 지정 데이터가 없습니다.",
|
||||
"No ground/plan designation data.",
|
||||
],
|
||||
B07_Info_Station: ["측점", "Station"],
|
||||
/* 장(여러 측점을 담은 횡단 도면)은 측점 단위 지반·계획 정보를 갖지 않는다 —
|
||||
제목을 「측점」으로 달면 어느 측점 값인지 오해된다(2026-09-03 정리). */
|
||||
@@ -610,7 +712,10 @@ export const ui_locales_b2 = {
|
||||
"Failed to confirm the quantity stage.",
|
||||
],
|
||||
B08_Quantity_Tab_Earthwork: ["토적표", "Earthwork Table"],
|
||||
B08_Quantity_Grid_Loading: ["토적표를 만드는 중입니다…", "Building the earthwork table…"],
|
||||
B08_Quantity_Grid_Loading: [
|
||||
"토적표를 만드는 중입니다…",
|
||||
"Building the earthwork table…",
|
||||
],
|
||||
B08_Quantity_Grid_Empty: [
|
||||
"측점 단면적이 아직 없습니다. 횡단 설계를 먼저 마치세요.",
|
||||
"No cross-section areas yet. Finish the cross-section design first.",
|
||||
@@ -630,15 +735,24 @@ export const ui_locales_b2 = {
|
||||
B08_Quantity_Side_RockSet: ["암 갈래 세트", "Rock class set"],
|
||||
B08_Quantity_Side_RockRatios: ["지반 구성비(%)", "Ground composition (%)"],
|
||||
B08_Quantity_Btn_Save: ["저장", "Save"],
|
||||
B08_Quantity_Save_Success: ["산출 조건을 저장했습니다.", "Calculation settings saved."],
|
||||
B08_Quantity_Save_Failed: ["산출 조건을 저장하지 못했습니다.", "Failed to save the settings."],
|
||||
B08_Quantity_Save_Success: [
|
||||
"산출 조건을 저장했습니다.",
|
||||
"Calculation settings saved.",
|
||||
],
|
||||
B08_Quantity_Save_Failed: [
|
||||
"산출 조건을 저장하지 못했습니다.",
|
||||
"Failed to save the settings.",
|
||||
],
|
||||
B08_Quantity_Unsaved: [
|
||||
"저장하지 않은 변경이 있습니다.",
|
||||
"You have unsaved changes.",
|
||||
],
|
||||
B08_Quantity_Side_Method: ["산출법", "Method"],
|
||||
B08_Quantity_Side_Method_Value: ["평균단면적법", "Average end area"],
|
||||
B08_Quantity_Side_Factors: ["토량환산계수(다짐)", "Conversion factors (compacted)"],
|
||||
B08_Quantity_Side_Factors: [
|
||||
"토량환산계수(다짐)",
|
||||
"Conversion factors (compacted)",
|
||||
],
|
||||
|
||||
/* --- B09_Estimation 원가계산 --- */
|
||||
B09_Estimation_Title: ["원가계산", "Cost Estimate"],
|
||||
@@ -676,7 +790,10 @@ export const ui_locales_b2 = {
|
||||
"목표 도급공사비를 맞추려면 이윤을 이만큼 깎아야 합니다 — 적용하려면 조정액에 직접 넣으세요.",
|
||||
"To hit the target contract amount, profit must be reduced by this much — enter it in Adjustment to apply.",
|
||||
],
|
||||
B09_Estimation_Calc_Failed: ["원가계산에 실패했습니다.", "Cost calculation failed."],
|
||||
B09_Estimation_Calc_Failed: [
|
||||
"원가계산에 실패했습니다.",
|
||||
"Cost calculation failed.",
|
||||
],
|
||||
B09_Estimation_Confirm_Success: [
|
||||
"원가계산 단계를 확정했습니다.",
|
||||
"Cost estimate stage confirmed.",
|
||||
@@ -688,7 +805,10 @@ export const ui_locales_b2 = {
|
||||
B09_Estimation_Tab_Pending: ["준비 중", "Coming soon"],
|
||||
B09_Estimation_UP_List: ["일위대가 목록표", "Unit Price Index"],
|
||||
B09_Estimation_UP_Detail: ["일위대가표", "Unit Price Sheet"],
|
||||
B09_Estimation_UP_Pick: ["목록에서 항목을 고르세요.", "Pick an item from the index."],
|
||||
B09_Estimation_UP_Pick: [
|
||||
"목록에서 항목을 고르세요.",
|
||||
"Pick an item from the index.",
|
||||
],
|
||||
B09_Estimation_UP_Drill: ["펼쳐 보기", "Open"],
|
||||
B09_Estimation_Col_Name: ["명칭", "Name"],
|
||||
B09_Estimation_Col_Spec: ["규격", "Spec"],
|
||||
@@ -700,12 +820,35 @@ export const ui_locales_b2 = {
|
||||
B09_Estimation_Col_Expense: ["경비", "Expense"],
|
||||
B09_Estimation_Col_Total: ["합계", "Total"],
|
||||
B09_Estimation_UP_SumOk: ["합계 = 재료+노무+경비 일치", "Total = M+L+E ✓"],
|
||||
B09_Estimation_UP_SumBad: ["⚠ 합계가 재료+노무+경비와 다릅니다", "⚠ Total ≠ M+L+E"],
|
||||
B09_Estimation_UP_SumBad: [
|
||||
"⚠ 합계가 재료+노무+경비와 다릅니다",
|
||||
"⚠ Total ≠ M+L+E",
|
||||
],
|
||||
B09_Estimation_UP_RoundGap: [
|
||||
"행별로 0.1원 미만을 버려 합계 끝자리가 다릅니다 (정상). 자르기 전 합계:",
|
||||
"Rows are floored to 0.1 KRW, so the total's last digit differs (expected). Unrounded total:",
|
||||
],
|
||||
B09_Estimation_UP_Load_Failed: ["일위대가를 못 불러왔습니다.", "Failed to load unit prices."],
|
||||
B09_Estimation_Group_Quantity: ["수량", "Quantities"],
|
||||
B09_Estimation_Field_Quantities: [
|
||||
"공종별 수량 (한 줄에 「공종코드=수량」)",
|
||||
'Quantities (one "code=qty" per line)',
|
||||
],
|
||||
B09_Estimation_Src_Manual: [
|
||||
"수량 원천: 손입력(직접비 직접 입력)",
|
||||
"Source: manual direct costs",
|
||||
],
|
||||
B09_Estimation_Src_Quantities: [
|
||||
"수량 원천: 손입력 공종 수량 × 일위대가",
|
||||
"Source: manual quantities × unit prices",
|
||||
],
|
||||
B09_Estimation_Missing_UP: [
|
||||
"수량은 있는데 단가가 없는 공종 — 총액에서 빠졌습니다:",
|
||||
"Quantities without a unit price — excluded from the total:",
|
||||
],
|
||||
B09_Estimation_UP_Load_Failed: [
|
||||
"일위대가를 못 불러왔습니다.",
|
||||
"Failed to load unit prices.",
|
||||
],
|
||||
|
||||
/* --- B10_Payment 결재 --- */
|
||||
B10_Payment_Title: ["결재", "Payment"],
|
||||
@@ -723,7 +866,10 @@ export const ui_locales_b2 = {
|
||||
B10_Payment_Deposit_Title: ["계좌 입금 안내", "Bank Transfer Guide"],
|
||||
B10_Payment_Deposit_Account: ["입금 계좌", "Deposit Account"],
|
||||
B10_Payment_Deposit_Amount: ["입금 금액", "Deposit Amount"],
|
||||
B10_Payment_Deposit_Pending: ["견적 확정 후 표시", "Shown after estimate confirmation"],
|
||||
B10_Payment_Deposit_Pending: [
|
||||
"견적 확정 후 표시",
|
||||
"Shown after estimate confirmation",
|
||||
],
|
||||
B10_Payment_Deposit_Note: [
|
||||
"입금 확인 후 설계문서와 DWG 다운로드가 허용됩니다.",
|
||||
"Design documents and DWG downloads are enabled after the deposit is confirmed.",
|
||||
|
||||
Reference in New Issue
Block a user