Files
Aislo/resources/tester/test_b09_execution.py
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

169 lines
6.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""실행예산 단계 — 설계 → 실행예산 **구조**가 서는가 (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"] == {}