"""실행예산 단계 — 설계 → 실행예산 **구조**가 서는가 (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"] == {}