Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1

This commit is contained in:
2026-09-14 05:29:57 +09:00
7 changed files with 962 additions and 10 deletions
+168
View File
@@ -0,0 +1,168 @@
"""실행예산 단계 — 설계 → 실행예산 **구조**가 서는가 (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"] == {}