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