Files
Aislo/B09_Estimation/B09_Estimation_Execution.py
T
eomsangdonandClaude Opus 5 f1f8482e7d feat(b09): 실행예산 단계 1벌 — 중기 실행단가(1단위당 사용료 ÷ 시간 · 성분 비율 · 절사 · 차액 보정) · 실행수량
- 단가표 복사본의 중기(X) 호표만 실행단가로 갈아 끼워 일위대가 다시 조립 — 설계·계약 불변
- 조립값과 다른 줄은 설계 단가 그대로 + 비고에 까닭
- 「기본보정」은 뜻 미확인 — 차액을 안 몰고 남김(확인 대기)
- 계약 모듈의 다시 조립·묶음 합 조각을 두 단계가 같이 씀
- 실행예산 탭 파일 · 사전 키(등록은 서브) · 시험 7건

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 05:29:05 +09:00

279 lines
12 KiB
Python

"""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)},
}