- 단가표 복사본의 중기(X) 호표만 실행단가로 갈아 끼워 일위대가 다시 조립 — 설계·계약 불변 - 조립값과 다른 줄은 설계 단가 그대로 + 비고에 까닭 - 「기본보정」은 뜻 미확인 — 차액을 안 몰고 남김(확인 대기) - 계약 모듈의 다시 조립·묶음 합 조각을 두 단계가 같이 씀 - 실행예산 탭 파일 · 사전 키(등록은 서브) · 시험 7건 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
279 lines
12 KiB
Python
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)},
|
|
}
|