diff --git a/B09_Estimation/B09_Estimation_Contract.py b/B09_Estimation/B09_Estimation_Contract.py index 01aa2248..01510a0d 100644 --- a/B09_Estimation/B09_Estimation_Contract.py +++ b/B09_Estimation/B09_Estimation_Contract.py @@ -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) ) diff --git a/B09_Estimation/B09_Estimation_Execution.py b/B09_Estimation/B09_Estimation_Execution.py new file mode 100644 index 00000000..2598a5d2 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Execution.py @@ -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)}, + } diff --git a/B09_Estimation/B09_Estimation_Router_Execution.py b/B09_Estimation/B09_Estimation_Router_Execution.py new file mode 100644 index 00000000..00df4504 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router_Execution.py @@ -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}) diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Execution.ts b/B09_Estimation/B09_Estimation_UI_Tab_Execution.ts new file mode 100644 index 00000000..57da862f --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_Execution.ts @@ -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; + quantities: Record; +} + +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( + 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(); + +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 { + 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 { + 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, +}; diff --git a/main.py b/main.py index b3cc9ca7..73b46797 100644 --- a/main.py +++ b/main.py @@ -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) # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). diff --git a/resources/tester/test_b09_execution.py b/resources/tester/test_b09_execution.py new file mode 100644 index 00000000..8f6ae88c --- /dev/null +++ b/resources/tester/test_b09_execution.py @@ -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"] == {} diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index 70a5edf9..ddece2fe 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -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;