Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1

This commit is contained in:
2026-09-14 05:29:57 +09:00
7 changed files with 962 additions and 10 deletions
+23 -10
View File
@@ -277,15 +277,28 @@ def _contract_unit(
).floored(Decimal(1))
if scaled_book is None or book is None:
return scaled, ""
code = str(row.get("price_code") or "")
unit, why = reassembled_unit(str(row.get("price_code") or ""), unit_design, book, scaled_book)
if unit is None:
return scaled, f"기초단가로 못 풂({why}) — 성분에 적용"
return unit, "기초단가 적용 — 단가표 다시 조립"
def reassembled_unit(
code: str, unit_design: Money3, book: PriceBook, new_book: PriceBook
) -> tuple[Money3 | None, str]:
"""고친 단가표 복사본으로 그 줄 단가를 다시 조립 — 못 풀면 (None, 까닭).
⚠ 설계 단가가 설계 단가표 조립값과 같을 때만 — 다르면(할증·수동 단가·구조물도 호표)
다시 조립한 값이 그 줄 단가가 아님(계약·실행예산 공용).
"""
if code not in book.titles:
return scaled, "기초단가로 못 풂(단가표 밖 코드) — 성분에 적용"
return None, "단가표 밖 코드"
try:
if book.resolve(code).floored(Decimal(1)) != unit_design:
return scaled, "설계 단가가 단가표 조립값과 다름(할증·수동 단가) — 성분에 적용"
return scaled_book.resolve(code).floored(Decimal(1)), "기초단가 적용 — 단가표 다시 조립"
return None, "설계 단가가 단가표 조립값과 다름할증·수동 단가"
return new_book.resolve(code).floored(Decimal(1)), ""
except PriceBookError as error:
return scaled, f"기초단가로 못 풂({error}) — 성분에 적용"
return None, str(error)
def _totals(money: Money3) -> dict[str, str]:
@@ -297,8 +310,8 @@ def _totals(money: Money3) -> dict[str, str]:
}
def _group_sums(rows: list[dict[str, Any]]) -> None:
"""묶음 줄 계 금액 — 아래 줄의 합(설계 내역 `_group_sums` 와 같은 꼴)."""
def _group_sums(rows: list[dict[str, Any]], stage: str = "contract") -> None:
"""묶음 줄 계 금액(`{stage}_…_krw`) — 아래 줄의 합(설계 내역 `_group_sums` 와 같은 꼴)."""
for group in rows:
if not group.get("is_group"):
continue
@@ -308,9 +321,9 @@ def _group_sums(rows: list[dict[str, Any]]) -> None:
for r in rows
if not r.get("is_group")
and str(r.get("item_no", "")).startswith(prefix)
and r.get("contract_amount_krw") is not None
and r.get(f"{stage}_amount_krw") is not None
]
for part in ("material", "labor", "expense", "amount"):
group[f"contract_{part}_krw"] = str(
sum((Decimal(r[f"contract_{part}_krw"]) for r in children), _ZERO)
group[f"{stage}_{part}_krw"] = str(
sum((Decimal(r[f"{stage}_{part}_krw"]) for r in children), _ZERO)
)
+278
View File
@@ -0,0 +1,278 @@
"""B09 실행예산 단계 — 설계 내역 → 실행예산 (PLAN 12장 「설계 뒤 네 단계」 · 2026-09-14 배정).
근거: STmate 분석 `27_계약_실행_기성_단계.md` §4 (`wM_Boq_ExecX` 「시간/단위당 중기실행단가 계산 및
입력」) · `35_형식과_단계의_공통과_차이.md` §3 · 원자료 `ui_form_catalog.txt` 65~67행.
· 설계 중기사용료와 **별도로** 「1단위당 중기사용료(원/단위)」 · 「1단위 = 시간」
· 최초단가(노무비·재료비·경비) → 실행단가 · 절사 1원 ~ 100,000원 미만
· 차액 보정 : 기본보정 / 노무비 / 재료비 / 경비
· 최초수량 → 실행수량
⚠ **설계·계약을 안 건드린다** — 설계 단가표를 **복사해** 중기(X) 호표만 실행단가로 갈아 끼우고
그 위 일위대가를 다시 조립한다. 설계 내역·원가계산서·골든셋은 그대로다.
⚠ **값이 맞다가 아니라 구조가 선다까지** — 실행예산 표본 0건(27번 §9 · 35번 「중기 화면 구조
확인 · 전체 실행예산 및 실제 값 미확인」). 아래 둘은 **구조로 읽은 것**이라 확인 대기:
① 실행단가 성분 = 시간당 실행단가 × 설계 성분 비율(최초단가 → 실행단가 한 줄에서 읽음)
② 절사는 시간당 성분마다 · 차액 = 절사한 시간당 합계 − 성분 합
"""
from __future__ import annotations
import copy
from decimal import ROUND_FLOOR, Decimal, InvalidOperation
from typing import Any
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line
from B09_Estimation.B09_Estimation_Contract import _group_sums, _money, _totals, reassembled_unit
from B09_Estimation.B09_Estimation_PriceBook import (
DEFAULT_ADOPTED_SLOT,
PRICE_SLOT_COUNT,
Money3,
PriceBook,
PriceBookError,
PriceDetail,
PriceKind,
PriceTitle,
)
_ZERO = Decimal(0)
#: 저장 자리 — `estimation` 구획 안 한 칸.
SETTINGS_KEY = "execution"
#: 절사 기준 — `wCm_Jeol` 표기 차례(원 미만 절사 단위).
CUT_UNITS: tuple[str, ...] = ("1", "10", "100", "1000", "10000", "100000")
#: 차액 보정 — `wCm_OPT` 표기 차례.
CORRECTIONS: tuple[tuple[str, str], ...] = (
("basic", "기본보정"),
("labor", "노무비"),
("material", "재료비"),
("expense", "경비"),
)
#: 뜻이 확인 안 된 보정 — 조용히 한쪽에 몰지 않고 차액을 그대로 보임.
CORRECTION_NOT_KNOWN = {
"basic": "「기본보정」의 뜻이 분석 자료에서 확인 안 됨(27번 §4) — 차액을 안 몰고 그대로 보임",
}
#: 실행단가를 받치는 기초단가 줄 — 성분마다 한 줄(자재 = 재료 · 노임 = 노무 · 중기 취득가 = 경비).
_PART_KIND = (
("material", PriceKind.MATERIAL, "M"),
("labor", PriceKind.LABOR, "L"),
("expense", PriceKind.MACHINE_BASE, "S"),
)
_PART_LABEL = {"material": "재료비", "labor": "노무비", "expense": "경비"}
def _number(value: Any) -> Decimal | None:
try:
number = Decimal(str(value))
except (InvalidOperation, ValueError):
return None
return number if number.is_finite() else None
def clean_settings(values: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
"""저장값과 거른 까닭. 사용료가 빈 중기는 설계 사용료 그대로 · 수량이 빈 줄은 설계 수량."""
errors: list[str] = []
machines: dict[str, dict[str, str]] = {}
for code, entry in (values.get("machines") or {}).items():
entry = entry or {}
if entry.get("unit_price_krw") in (None, ""):
continue
price = _number(entry.get("unit_price_krw"))
hours = _number(entry.get("hours_per_unit"))
if price is None or price < 0:
errors.append(
f"{code} 1단위당 중기사용료가 0 이상 수가 아님 — {entry.get('unit_price_krw')}"
)
continue
if hours is None or hours <= 0:
errors.append(
f"{code} 「1단위 = 시간」이 0 보다 큰 수가 아님 — {entry.get('hours_per_unit')}"
)
continue
cut = str(entry.get("cut_unit_krw") or "1")
correction = str(entry.get("correction") or "basic")
machines[str(code)] = {
"unit_price_krw": str(price),
"hours_per_unit": str(hours),
"cut_unit_krw": cut if cut in CUT_UNITS else "1",
"correction": correction if correction in dict(CORRECTIONS) else "basic",
}
quantities: dict[str, str] = {}
for item_no, raw in (values.get("quantities") or {}).items():
if raw in (None, ""):
continue
qty = _number(raw)
if qty is None or qty < 0:
errors.append(f"{item_no} 실행수량이 0 이상 수가 아님 — {raw}")
continue
quantities[str(item_no)] = str(qty)
return {"machines": machines, "quantities": quantities}, errors
def execution_hourly(
design: Money3, unit_price: Decimal, hours: Decimal, cut: Decimal, correction: str
) -> tuple[Money3 | None, Decimal, str]:
"""시간당 실행단가(성분) · 차액 · 까닭. 못 가르면 (None, 0, 까닭).
시간당 실행단가 = 1단위당 중기사용료 ÷ 1단위 시간 → 설계 성분 비율로 가름 → 성분마다 절사 →
차액(절사한 합계 − 성분 합)을 고른 비목에 더함. 「기본보정」은 뜻 미확인이라 차액을 남김.
"""
if design.total <= 0:
return None, _ZERO, "설계 중기사용료 성분 합이 0 — 비율로 못 가름"
per_hour = unit_price / hours
parts = {
part: (per_hour * getattr(design, part) / design.total / cut).to_integral_value(
rounding=ROUND_FLOOR
)
* cut
for part, _, _ in _PART_KIND
}
target = (per_hour / cut).to_integral_value(rounding=ROUND_FLOOR) * cut
diff = target - sum(parts.values(), _ZERO)
note = ""
if diff and correction in parts:
parts[correction] += diff
note = f"차액 {diff}원 → {dict(CORRECTIONS)[correction]}"
elif diff:
note = f"차액 {diff}원 남음 — {CORRECTION_NOT_KNOWN['basic']}"
return Money3(**parts), diff, note
def execution_book(book: PriceBook, hourly: dict[str, Money3]) -> PriceBook:
"""중기(X) 호표를 실행단가로 갈아 끼운 **복사본** — 원본 단가표(캐시 공유)는 안 건드림.
X 상세 줄을 비우고 성분마다 기초단가 한 줄(수량 1)을 달아 그 위 일위대가가 설계와 같은
규칙(`PriceBook.resolve`)으로 다시 조립되게 함.
"""
copied = copy.deepcopy(book)
for code, money in hourly.items():
copied.details[code] = []
for part, kind, prefix in _PART_KIND:
slots: list[Decimal | None] = [None] * PRICE_SLOT_COUNT
slots[DEFAULT_ADOPTED_SLOT - 1] = getattr(money, part)
ref = f"{prefix}-실행-{code}"
copied.titles[ref] = PriceTitle(
code=ref, kind=kind, name=f"중기실행단가 {_PART_LABEL[part]}", slots=slots
)
copied.add_detail(PriceDetail(code, ref, Decimal(1), note="실행예산 중기실행단가"))
return copied
def machines_under(book: PriceBook, codes: list[str]) -> dict[str, set[str]]:
"""내역 단가코드 → 그 아래 중기(X) 코드들 — X 안으로는 안 내려감."""
found: dict[str, set[str]] = {}
def walk(code: str, seen: tuple[str, ...]) -> set[str]:
if code in found:
return found[code]
title = book.titles.get(code)
if title is None or code in seen:
return set()
if title.kind is PriceKind.MACHINE_HOURLY:
return {code}
result: set[str] = set()
for detail in book.details.get(code, []):
if detail.ref_code != code:
result |= walk(detail.ref_code, (*seen, code))
found[code] = result
return result
return {code: walk(code, ()) for code in codes}
def execution_bill(
bill_rows: list[dict[str, Any]],
settings: dict[str, Any],
build: Any = None,
) -> dict[str, Any]:
"""설계 내역 줄 → 실행예산 줄 · 중기 실행단가 표 · 합계.
`build` = 설계 일위대가 조립본(캐시 공유본이라 **안 고침**) — 없으면 중기 표 없이 수량만.
"""
entries = settings.get("machines") or {}
quantities = settings.get("quantities") or {}
book = build.book if build is not None else None
codes = [str(row.get("price_code") or "") for row in bill_rows if not row.get("is_group")]
under = machines_under(book, codes) if book is not None else {}
machines: list[dict[str, Any]] = []
hourly: dict[str, Money3] = {}
for code in sorted(set().union(*under.values())):
title = book.titles[code]
item: dict[str, Any] = {"code": code, "name": title.name, "spec": title.spec}
try:
design = book.resolve(code)
except PriceBookError as error:
machines.append({**item, "note": f"설계 중기사용료가 안 섬({error})"})
continue
item.update(design=_totals(design), **entries.get(code, {}))
entry = entries.get(code)
if entry:
money, diff, note = execution_hourly(
design,
Decimal(entry["unit_price_krw"]),
Decimal(entry["hours_per_unit"]),
Decimal(entry["cut_unit_krw"]),
entry["correction"],
)
item.update(note=note, diff_krw=str(diff))
if money is not None:
hourly[code] = money
item["execution"] = _totals(money)
machines.append(item)
exec_book = execution_book(book, hourly) if hourly else None
rows: list[dict[str, Any]] = []
design_sum = Money3()
exec_sum = Money3()
for source in bill_rows:
row = dict(source)
if row.get("is_group") or not row.get("in_bill", True):
rows.append(row)
continue
quantity = row.get("quantity")
if quantity in (None, "") or row.get("unit_material_krw") is None:
row.update(execution_note="설계 단가가 안 선 줄 — 실행단가도 못 섬")
rows.append(row)
continue
unit_design = Money3(
material=_money(row.get("unit_material_krw")),
labor=_money(row.get("unit_labor_krw")),
expense=_money(row.get("unit_expense_krw")),
)
design_sum += bill_line(unit_design, Decimal(str(quantity)))
code = str(row.get("price_code") or "")
unit, note = unit_design, ""
changed = under.get(code, set()) & set(hourly)
if exec_book is not None and changed:
rebuilt, why = reassembled_unit(code, unit_design, book, exec_book)
if rebuilt is None:
note = f"중기 실행단가를 못 얹음({why}) — 설계 단가 그대로"
else:
unit, note = rebuilt, f"중기 실행단가 적용 — {', '.join(sorted(changed))}"
item_no = str(row.get("item_no"))
qty = Decimal(quantities.get(item_no, str(quantity)))
line = bill_line(unit, qty)
exec_sum += line
row.update(
execution_quantity=str(qty),
execution_quantity_changed=item_no in quantities,
execution_unit_material_krw=str(unit.material),
execution_unit_labor_krw=str(unit.labor),
execution_unit_expense_krw=str(unit.expense),
execution_unit_price_krw=str(unit.total),
execution_material_krw=str(line.material),
execution_labor_krw=str(line.labor),
execution_expense_krw=str(line.expense),
execution_amount_krw=str(line.total),
execution_note=note,
)
rows.append(row)
_group_sums(rows, "execution")
return {
"rows": rows,
"machines": machines,
"totals": {"design": _totals(design_sum), "execution": _totals(exec_sum)},
}
@@ -0,0 +1,92 @@
"""B09 실행예산 단계 탭 API — 설계 내역 → 실행예산 (PLAN 12장 · 랩탑 메인).
⚠ 설계 내역·단가표를 **읽기만** 한다(복사본에 중기 실행단가·실행수량을 얹음). 저장은
`estimation.execution` 한 칸 — 설계 내역·계약·원가계산서 저장본은 안 건드린다.
"""
from __future__ import annotations
import json
import logging
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from B09_Estimation.B09_Estimation_Execution import (
CORRECTIONS,
CUT_UNITS,
SETTINGS_KEY,
clean_settings,
execution_bill,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation — Execution"])
_NOT_FOUND = {"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}
async def _root(project_id: UUID) -> str | None:
from B09_Estimation.B09_Estimation_Router import _project_root_of
return await _project_root_of(project_id)
@router.get("/{project_id}/estimation/execution")
async def get_execution(project_id: UUID) -> JSONResponse:
"""실행예산 한 장 — 설계 줄 옆에 실행수량·실행단가·실행금액 · 중기 실행단가 표 · 합계."""
from B09_Estimation.B09_Estimation_Router import _build_for, get_bill
from common_util.common_util_project_settings import estimation_settings
root = await _root(project_id)
if root is None:
return JSONResponse(status_code=404, content=_NOT_FOUND)
response = await get_bill(project_id)
bill = json.loads(bytes(response.body).decode("utf-8"))
if response.status_code != 200 or "rows" not in bill:
return JSONResponse(status_code=response.status_code or 502, content=bill)
stored, _ = clean_settings(dict(estimation_settings(root).get(SETTINGS_KEY) or {}))
# 조립본은 캐시 공유본 — `execution_bill` 이 단가표 복사본에만 중기 실행단가를 얹음(설계 불변).
result = execution_bill(bill["rows"], stored, build=await _build_for(project_id))
return JSONResponse(
content={
"status": "success",
**result,
"settings": stored,
"fields": {
"cut_units": list(CUT_UNITS),
"corrections": [{"key": k, "label": label} for k, label in CORRECTIONS],
},
"bill_missing_count": len((bill.get("summary") or {}).get("missing") or []),
"limit_note": (
"실행예산 표본 0건 — 구조가 서는지까지만 확인됨 · 성분 가르기·기본보정은 확인 대기"
),
}
)
@router.put("/{project_id}/estimation/execution")
async def put_execution(project_id: UUID, body: dict[str, Any]) -> JSONResponse:
"""중기 실행단가 입력·실행수량 저장 — 틀린 칸이 있으면 아무것도 안 저장."""
from common_util.common_util_project_settings import save_section
root = await _root(project_id)
if root is None:
return JSONResponse(status_code=404, content=_NOT_FOUND)
cleaned, errors = clean_settings(body)
if errors:
return JSONResponse(
status_code=400,
content={"status": "error", "message": " · ".join(errors), "errors": errors},
)
try:
save_section(root, "estimation", {SETTINGS_KEY: cleaned}, replace_keys=(SETTINGS_KEY,))
except Exception:
logger.exception("B09 실행예산 저장 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "실행예산을 저장하지 못했습니다."},
)
return JSONResponse(content={"status": "success", "settings": cleaned})
@@ -0,0 +1,398 @@
/* =============================================================================
* B09_Estimation_UI_Tab_Execution.ts
* 실행예산 탭 — STmate 「시간/단위당 중기실행단가 계산 및 입력」(wM_Boq_ExecX)을 본뜸 (PLAN 12장 · 랩탑 메인).
*
* - 본문 위 = 중기 실행단가 표 — 최초단가(노·재·경) · 1단위당 중기사용료 · 1단위 = 시간 ·
* 절사 · 차액 보정 → 실행단가.
* - 본문 아래 = 설계 내역 줄 옆에 **실행수량** 칸 · 실행 단가 · 실행 금액.
* - ⚠ 값은 서버(`/estimation/execution`)가 설계 단가표를 복사해 셈 — 설계·계약은 안 바뀜.
* - ⚠ 실행예산 표본이 없어 「구조가 선다」까지만 확인된 화면 — 머리에 그 한계를 적음.
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, showToast } from "@ui/ui_template_elements";
import { API_BASE_URL } from "@config/config_frontend";
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
interface Money {
material_krw: string;
labor_krw: string;
expense_krw: string;
total_krw: string;
}
interface MachineEntry {
unit_price_krw: string;
hours_per_unit: string;
cut_unit_krw: string;
correction: string;
}
interface MachineRow {
code: string;
name: string;
spec: string;
design?: Money;
execution?: Money;
note?: string;
}
interface ExecutionRow {
item_no: string;
name: string;
spec: string;
unit: string;
quantity: string | null;
is_group: boolean;
in_bill: boolean;
amount_krw: string | null;
execution_quantity?: string;
execution_unit_price_krw?: string;
execution_amount_krw?: string;
execution_note?: string;
}
interface Settings {
machines: Record<string, MachineEntry>;
quantities: Record<string, string>;
}
interface ExecutionDto {
status: string;
message?: string;
rows: ExecutionRow[];
machines: MachineRow[];
totals: { design: Money; execution: Money };
settings: Settings;
fields: { cut_units: string[]; corrections: { key: string; label: string }[] };
bill_missing_count: number;
limit_note: string;
}
const STYLE_ID = "b09-execution-styles";
function injectStyles(): void {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
.b09ex { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; }
.b09ex__meta { font-size: 12px; color: var(--color-text-secondary); }
.b09ex__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); }
.b09ex__scroll { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 12px; }
.b09ex__table { border-collapse: collapse; font-size: 12px; white-space: nowrap; }
.b09ex__table th, .b09ex__table td { border: 1px solid var(--color-border); padding: 2px 6px; }
.b09ex__table td.num { text-align: right; font-variant-numeric: tabular-nums; }
.b09ex__table tr.is-group td { font-weight: 600; }
.b09ex__table input { width: 7em; text-align: right; }
.b09ex__table input.is-changed { font-weight: 600; }
.b09ex__panel { display: flex; flex-direction: column; gap: 6px; font-size: 12px; font-variant-numeric: tabular-nums; }
`;
document.head.append(style);
}
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
className = "",
text = "",
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
if (className) node.className = className;
if (text) node.textContent = text;
return node;
}
function won(value: string | null | undefined): string {
if (value === null || value === undefined || value === "") return "";
const n = Number(value);
return Number.isFinite(n) ? n.toLocaleString("ko-KR") : value;
}
/** 프로젝트별 입력 캐시 — [저장] 전 값(지침 5장 · 자동저장 없음). */
const drafts = new Map<string, Settings>();
function draftOf(projectId: string, data: ExecutionDto): Settings {
let draft = drafts.get(projectId);
if (!draft) {
draft = {
machines: structuredClone(data.settings.machines),
quantities: { ...data.settings.quantities },
};
drafts.set(projectId, draft);
}
return draft;
}
function endpoint(projectId: string): string {
return `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/execution`;
}
async function fetchExecution(projectId: string): Promise<ExecutionDto> {
const response = await fetch(endpoint(projectId), { credentials: "include" });
const body = (await response.json()) as ExecutionDto;
if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`);
return body;
}
async function saveExecution(projectId: string, values: Settings): Promise<void> {
const response = await fetch(endpoint(projectId), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
const body = (await response.json()) as { message?: string };
if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`);
}
function drawPanel(ctx: B09TabContext, data: ExecutionDto, reload: () => void): void {
const projectId = ctx.projectId as string;
const box = el("div", "b09ex__panel");
box.append(
createButton({
label: "저장",
onClick: async () => {
try {
await saveExecution(projectId, draftOf(projectId, data));
drafts.delete(projectId);
showToast("실행예산 저장 — 설계·계약 내역은 그대로", "success");
reload();
} catch (error) {
showToast(error instanceof Error ? error.message : "저장 못 함", "error");
}
},
}),
);
const t = data.totals;
for (const [label, part] of [
["재료비", "material"],
["노무비", "labor"],
["경비", "expense"],
["직접공사비", "total"],
] as const) {
box.append(
el(
"div",
"",
`${label} ${won(t.design[`${part}_krw`])}${won(t.execution[`${part}_krw`])}`,
),
);
}
ctx.panel.append(box);
}
function input(value: string, onInput: (value: string) => void): HTMLInputElement {
const node = el("input");
node.type = "number";
node.min = "0";
node.value = value;
node.addEventListener("input", () => onInput(node.value));
return node;
}
function select(
options: { key: string; label: string }[],
value: string,
onChange: (value: string) => void,
): HTMLSelectElement {
const node = el("select");
for (const option of options) {
const item = el("option", "", option.label);
item.value = option.key;
node.append(item);
}
node.value = value;
node.addEventListener("change", () => onChange(node.value));
return node;
}
/** 중기 실행단가 표 — 사용료가 빈 중기는 설계 사용료 그대로. */
function drawMachines(data: ExecutionDto, draft: Settings): HTMLElement {
const box = el("div");
box.append(el("strong", "", `시간/단위당 중기실행단가 — 중기 ${data.machines.length}`));
const table = el("table", "b09ex__table");
const head = el("tr");
for (const label of [
"코드",
"명칭",
"규격",
"최초 노무비",
"최초 재료비",
"최초 경비",
"최초 계",
"1단위당 중기사용료(원)",
"1단위 = 시간",
"절사",
"차액 보정",
"실행 노무비",
"실행 재료비",
"실행 경비",
"실행 계",
"비고",
]) {
head.append(el("th", "", label));
}
table.append(head);
const cuts = data.fields.cut_units.map((unit) => ({
key: unit,
label: `${won(unit)}원 미만절사`,
}));
for (const machine of data.machines) {
const entry = (): MachineEntry =>
(draft.machines[machine.code] ??= {
unit_price_krw: "",
hours_per_unit: "",
cut_unit_krw: "1",
correction: "basic",
});
const saved = draft.machines[machine.code];
const tr = el("tr");
const d = machine.design;
const x = machine.execution;
tr.append(
el("td", "", machine.code),
el("td", "", machine.name),
el("td", "", machine.spec ?? ""),
el("td", "num", won(d?.labor_krw)),
el("td", "num", won(d?.material_krw)),
el("td", "num", won(d?.expense_krw)),
el("td", "num", won(d?.total_krw)),
);
const cells: HTMLElement[] = [
input(saved?.unit_price_krw ?? "", (v) => (entry().unit_price_krw = v)),
input(saved?.hours_per_unit ?? "", (v) => (entry().hours_per_unit = v)),
select(cuts, saved?.cut_unit_krw ?? "1", (v) => (entry().cut_unit_krw = v)),
select(
data.fields.corrections,
saved?.correction ?? "basic",
(v) => (entry().correction = v),
),
];
for (const cell of cells) {
const td = el("td");
td.append(cell);
tr.append(td);
}
tr.append(
el("td", "num", won(x?.labor_krw)),
el("td", "num", won(x?.material_krw)),
el("td", "num", won(x?.expense_krw)),
el("td", "num", won(x?.total_krw)),
el("td", "", machine.note ?? ""),
);
table.append(tr);
}
box.append(table);
return box;
}
function drawBill(data: ExecutionDto, draft: Settings): HTMLElement {
const box = el("div");
box.append(el("strong", "", "실행예산 내역 — 최초수량 → 실행수량"));
const table = el("table", "b09ex__table");
const head = el("tr");
for (const label of [
"공종번호",
"명칭",
"규격",
"단위",
"최초수량",
"금액(설계)",
"실행수량",
"실행 단가",
"실행 금액",
"비고",
]) {
head.append(el("th", "", label));
}
table.append(head);
for (const row of data.rows) {
const tr = el("tr", row.is_group ? "is-group" : "");
tr.append(
el("td", "", row.item_no),
el("td", "", row.name),
el("td", "", row.spec ?? ""),
el("td", "", row.unit ?? ""),
el("td", "num", row.quantity ?? ""),
el("td", "num", won(row.amount_krw)),
);
const cell = el("td");
if (!row.is_group && row.in_bill && row.execution_quantity !== undefined) {
const qty = input(draft.quantities[row.item_no] ?? row.execution_quantity, (v) => {
if (v === "" || v === row.quantity) delete draft.quantities[row.item_no];
else draft.quantities[row.item_no] = v;
});
qty.step = "any";
qty.classList.toggle("is-changed", row.item_no in draft.quantities);
cell.append(qty);
}
tr.append(
cell,
el("td", "num", won(row.execution_unit_price_krw)),
el("td", "num", won(row.execution_amount_krw)),
el("td", "", row.execution_note ?? ""),
);
table.append(tr);
}
box.append(table);
return box;
}
function drawBody(ctx: B09TabContext, data: ExecutionDto): void {
const draft = draftOf(ctx.projectId as string, data);
const wrap = el("div", "b09ex");
wrap.append(el("div", "b09ex__warn", `${data.limit_note}`));
if (data.bill_missing_count) {
wrap.append(
el(
"div",
"b09ex__warn",
`⚠ 설계 내역 ${L("B09_Sheet_Missing")} ${data.bill_missing_count}${L("B09_Sheet_Count")} — 실행단가도 못 섬`,
),
);
}
wrap.append(
el("div", "b09ex__meta", "1단위당 중기사용료를 비우면 설계 사용료 그대로 — [저장]하면 반영"),
);
const scroll = el("div", "b09ex__scroll");
scroll.append(drawMachines(data, draft), drawBill(data, draft));
wrap.append(scroll);
ctx.body.append(wrap);
}
function render(ctx: B09TabContext): void {
injectStyles();
if (!ctx.projectId) {
ctx.body.append(el("div", "b09ex__meta", "프로젝트를 고르세요"));
return;
}
const load = (): void => {
ctx.body.replaceChildren(el("div", "b09ex__meta", "실행예산 계산 중…"));
ctx.panel.replaceChildren();
fetchExecution(ctx.projectId as string)
.then((data) => {
ctx.body.replaceChildren();
drawPanel(ctx, data, load);
drawBody(ctx, data);
})
.catch((error: unknown) => {
ctx.body.replaceChildren(
el(
"div",
"b09ex__warn",
`실행예산을 세우지 못함 — ${error instanceof Error ? error.message : ""}`,
),
);
});
};
load();
}
export const executionTab: B09Tab = {
key: "execution",
label: () => L("B09_Estimation_Tab_Execution"),
render,
};
+2
View File
@@ -66,6 +66,7 @@ from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_rou
from B08_Quantity.B08_Quantity_Router_StructureSheet import router as b08_structure_sheet_router
from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router
from B09_Estimation.B09_Estimation_Router_Contract import router as b09_contract_router
from B09_Estimation.B09_Estimation_Router_Execution import router as b09_execution_router
from B09_Estimation.B09_Estimation_Router_CostSheet import router as b09_cost_sheet_router
from B09_Estimation.B09_Estimation_Router_Edits import router as b09_edits_router
from B09_Estimation.B09_Estimation_Router_Factors import router as b09_factors_router
@@ -642,6 +643,7 @@ app.include_router(b08_structure_sheet_router, dependencies=protected_with_compa
app.include_router(b09_estimation_router, dependencies=protected_with_company)
app.include_router(b09_cost_sheet_router, dependencies=protected_with_company)
app.include_router(b09_contract_router, dependencies=protected_with_company)
app.include_router(b09_execution_router, dependencies=protected_with_company)
app.include_router(b09_edits_router, dependencies=protected_with_company)
app.include_router(b09_factors_router, dependencies=protected_with_company)
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
+168
View File
@@ -0,0 +1,168 @@
"""실행예산 단계 — 설계 → 실행예산 **구조**가 서는가 (PLAN 12장 · 2026-09-14 브레인 배정).
⚠ 값이 맞는지는 못 잰다 — 실행예산 표본 0건(STmate 27번 §9 · 35번). 여기서 재는 것:
① 시간당 실행단가 = 1단위당 사용료 ÷ 시간 → 설계 성분 비율 → 성분마다 절사 → 차액 보정
② 「기본보정」은 뜻 미확인 — 차액을 안 몰고 남김
③ 중기(X)를 갈아 끼운 **복사본**으로 일위대가 다시 조립 · 설계 단가표·입력 줄 불변
④ 실행수량 · 조립값과 다른 줄은 설계 단가 + 까닭 · 묶음 줄 합 · 틀린 칸 거름
"""
from __future__ import annotations
import copy
import sys
from decimal import Decimal
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_Execution import ( # noqa: E402
clean_settings,
execution_bill,
execution_hourly,
)
from B09_Estimation.B09_Estimation_PriceBook import ( # noqa: E402
Money3,
PriceBook,
PriceDetail,
PriceKind,
PriceTitle,
)
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild # noqa: E402
DESIGN_X = Money3(material=Decimal(2000), labor=Decimal(3000), expense=Decimal(1500))
def _parts(money: Money3) -> tuple[str, str, str]:
return (str(money.material), str(money.labor), str(money.expense))
def test_시간당_실행단가를_설계_성분_비율로_가르고_차액을_고른_비목에() -> None:
# 80,000원/일 ÷ 8시간 = 10,000 · 비율 2000:3000:1500 → 3076.9 / 4615.3 / 2307.6 → 절사 합 9,998
money, diff, note = execution_hourly(DESIGN_X, Decimal(80000), Decimal(8), Decimal(1), "labor")
assert _parts(money) == ("3076", "4617", "2307") and diff == 2 and "노무비" in note
money, _, _ = execution_hourly(DESIGN_X, Decimal(80000), Decimal(8), Decimal(100), "expense")
assert _parts(money) == ("3000", "4600", "2400") # 100원 미만 절사 · 차액 100 → 경비
def test_기본보정은_뜻_미확인이라_차액을_남긴다() -> None:
money, diff, note = execution_hourly(DESIGN_X, Decimal(80000), Decimal(8), Decimal(1), "basic")
assert money.total == 9998 and diff == 2 and "기본보정" in note
def _build() -> UnitPriceBuild:
"""중기 X-G(연료 2 · 운전원 1 · 취득가 0.0003) · B-T(X-G 0.5 + 노임 1) · B-N(연료만)."""
book = PriceBook()
for code, kind, price in (
("M-F", PriceKind.MATERIAL, "1000"),
("L-O", PriceKind.LABOR, "3000"),
("L-B", PriceKind.LABOR, "2000"),
("S-G", PriceKind.MACHINE_BASE, "5000000"),
):
slots = [None] * 6
slots[5] = Decimal(price)
book.add_title(PriceTitle(code=code, kind=kind, name=code, slots=slots))
book.add_title(PriceTitle(code="X-G", kind=PriceKind.MACHINE_HOURLY, name="굴삭기"))
book.add_title(PriceTitle(code="B-T", kind=PriceKind.UNIT_PRICE, name="흙깎기"))
book.add_title(PriceTitle(code="B-N", kind=PriceKind.UNIT_PRICE, name="자재만"))
for parent, ref, qty in (
("X-G", "M-F", "2"),
("X-G", "L-O", "1"),
("X-G", "S-G", "0.0003"),
("B-T", "X-G", "0.5"),
("B-T", "L-B", "1"),
("B-N", "M-F", "1"),
):
book.add_detail(PriceDetail(parent, ref, Decimal(qty)))
return UnitPriceBuild(book=book)
def _line(item_no: str, code: str, unit: tuple[str, str, str], qty: str = "3") -> dict:
return {
"item_no": item_no,
"is_group": False,
"in_bill": True,
"quantity": qty,
"price_code": code,
"unit_material_krw": unit[0],
"unit_labor_krw": unit[1],
"unit_expense_krw": unit[2],
}
ROWS = [
{"item_no": "2", "is_group": True, "in_bill": True},
_line("2.1", "B-T", ("1000", "3500", "750")), # 설계 = 조립값
_line("2.2", "B-N", ("1000", "0", "0")), # 중기 없음
_line("2.3", "B-T", ("1200", "3500", "750")), # 수동 단가 — 조립값과 다름
]
SETTINGS = {
"machines": {
"X-G": {
"unit_price_krw": "80000",
"hours_per_unit": "8",
"cut_unit_krw": "1",
"correction": "labor",
}
},
"quantities": {"2.2": "5"},
}
def test_중기를_갈아_끼운_복사본으로_다시_조립하고_설계는_그대로() -> None:
build = _build()
before = (build.book.resolve("X-G"), build.book.resolve("B-T"), len(build.book.titles))
rows_before = copy.deepcopy(ROWS)
result = execution_bill(ROWS, clean_settings(SETTINGS)[0], build=build)
assert (build.book.resolve("X-G"), build.book.resolve("B-T"), len(build.book.titles)) == before
assert ROWS == rows_before
rows = {row["item_no"]: row for row in result["rows"]}
# X-G 실행 3076/4617/2307 × 0.5 = 1538 / 2308.5 / 1153.5 + 노임 2000 → 원 미만 절사
assert (
rows["2.1"]["execution_unit_material_krw"],
rows["2.1"]["execution_unit_labor_krw"],
rows["2.1"]["execution_unit_expense_krw"],
) == ("1538", "4308", "1153")
assert "X-G" in rows["2.1"]["execution_note"]
machine = result["machines"][0]
assert machine["code"] == "X-G" and machine["execution"]["total_krw"] == "10000"
assert machine["design"]["total_krw"] == "6500"
def test_실행수량과_중기_없는_줄() -> None:
rows = {r["item_no"]: r for r in execution_bill(ROWS, SETTINGS, build=_build())["rows"]}
assert (
rows["2.2"]["execution_quantity"] == "5" and rows["2.2"]["execution_amount_krw"] == "5000"
)
assert rows["2.2"]["execution_note"] == "" and rows["2.1"]["execution_quantity"] == "3"
def test_조립값과_다른_줄은_설계_단가와_까닭() -> None:
row = execution_bill(ROWS, SETTINGS, build=_build())["rows"][3]
assert row["execution_unit_material_krw"] == "1200" and "못 얹음" in row["execution_note"]
def test_설정이_없으면_설계와_같고_묶음_줄_합() -> None:
result = execution_bill(ROWS, {}, build=_build())
assert result["totals"]["design"] == result["totals"]["execution"]
group = result["rows"][0]
children = [r for r in result["rows"] if not r.get("is_group")]
assert Decimal(group["execution_amount_krw"]) == sum(
Decimal(r["execution_amount_krw"]) for r in children
)
def test_틀린_칸은_거른다() -> None:
cleaned, errors = clean_settings(
{
"machines": {
"X-A": {"unit_price_krw": ""},
"X-B": {"unit_price_krw": "100", "hours_per_unit": "0"},
"X-C": {"unit_price_krw": "100", "hours_per_unit": "8", "cut_unit_krw": "7"},
},
"quantities": {"1.1": "-1", "1.2": ""},
}
)
assert len(errors) == 2 and list(cleaned["machines"]) == ["X-C"]
assert cleaned["machines"]["X-C"]["cut_unit_krw"] == "1" and cleaned["quantities"] == {}
+1
View File
@@ -9,4 +9,5 @@
export const ui_locales_b4 = {
B09_Estimation_Tab_RateTable: ["제비율 요율표", "Overhead Rate Table"],
B09_Estimation_Tab_Contract: ["계약내역", "Contract Bill"],
B09_Estimation_Tab_Execution: ["실행예산", "Execution Budget"],
} as const;