"""기성 단계 — 계약 → 기성내역 · 기성 제잡비 계산서 **구조**가 서는가 (PLAN 12장 · 2026-09-14 배정). ⚠ 값이 맞는지는 못 잰다 — 기성 표본 0건(STmate 27번 §9 · 35번). 여기서 재는 것: ① ⭐ 제잡비 줄 = 금회직접공사비 × 계약잡비율(도급액 ÷ 계약 직접공사비) — 줄마다 밑수×요율 다시 안 셈 ② 계약 · 전회(앞 회차 금회의 합) · 금회 · 누계 네 칸과 각 비율 · 기성(%) · 잔량 ③ 부가세 넷 — 직접입력 · 공급가액 · 재료비 · 재료비+산출경비 ④ 계약 줄(입력) 불변 · 분리발주 폐기물은 제잡비 밖 · 틀린 칸 거름 """ from __future__ import annotations import copy import sys from decimal import Decimal from pathlib import Path from types import SimpleNamespace ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from B09_Estimation.B09_Estimation_Engine_Cost import CostLine, CostResult # noqa: E402 from B09_Estimation.B09_Estimation_Progress import clean_settings, progress_sheet # noqa: E402 def _contract_row(item_no: str, qty: str, unit: tuple[int, int, int]) -> dict: total = sum(unit) * int(qty) return { "item_no": item_no, "is_group": False, "in_bill": True, "quantity": qty, "contract_unit_material_krw": str(unit[0]), "contract_unit_labor_krw": str(unit[1]), "contract_unit_expense_krw": str(unit[2]), "contract_amount_krw": str(total), } CONTRACT_ROWS = [ {"item_no": "1", "is_group": True, "in_bill": True}, _contract_row("1.1", "100", (1000, 2000, 500)), # 350,000 _contract_row("1.2", "10", (5000, 0, 0)), # 50,000 → 계약 직접공사비 400,000 ] def _cost(separate_waste: bool = False) -> tuple[SimpleNamespace, CostResult]: result = CostResult() for key, amount in ( ("indirect_labor_cost", 20000), ("industrial_accident_insurance", 7000), ("waste_disposal", 5000), ("general_overhead", 24000), ("profit", 30000), ("vat", 48100), ): result.lines.append( CostLine(key, key, "", Decimal(amount), None, Decimal(0), Decimal(amount)) ) data = SimpleNamespace(waste_separate_order=separate_waste, indirect_material_krw=Decimal(0)) return data, result SETTINGS = { "rounds": [ {"quantities": {"1.1": "40", "1.2": "2"}, "vat_mode": "supply"}, {"quantities": {"1.1": "30"}, "vat_mode": "material"}, ] } def _sheet(settings=SETTINGS, round_no=None, separate_waste=True) -> dict: data, result = _cost(separate_waste) return progress_sheet(CONTRACT_ROWS, data, result, clean_settings(settings)[0], round_no) def test_제잡비는_금회직접공사비_곱하기_계약잡비율() -> None: before = copy.deepcopy(CONTRACT_ROWS) sheet = _sheet() assert CONTRACT_ROWS == before # 계약 줄 불변 items = {item["key"]: item for item in sheet["items"]} labor = items["indirect_labor_cost"] # 도급액 20,000 ÷ 계약 직접 400,000 = 5% · 1회 150,000 → 7,500 · 2회 105,000 → 5,250 assert (labor["contract_ratio_pct"], labor["previous_krw"], labor["current_krw"]) == ( "5.000", "7500", "5250", ) assert (labor["cumulative_krw"], labor["cumulative_pct"]) == ("12750", "63.750") # 7,000 × 105,000 ÷ 400,000 = 1,837.5 → 회차마다 원 미만 절사 assert items["industrial_accident_insurance"]["current_krw"] == "1837" assert "waste_disposal" not in items # 분리발주면 도급 밖 assert sheet["contract_overhead_ratio_pct"] == "20.250" # 81,000 ÷ 400,000 def test_네_칸과_기성율_잔량() -> None: sheet = _sheet() row = {r["item_no"]: r for r in sheet["rows"]}["1.1"] assert ( row["progress_previous_quantity"], row["progress_current_quantity"], row["progress_cumulative_quantity"], ) == ("40", "30", "70") assert (row["progress_cumulative_amount_krw"], row["progress_pct"]) == ("245000", "70.000") assert row["progress_remaining_quantity"] == "30" group = sheet["rows"][0] assert group["progress_cumulative_amount_krw"] == "255000" # 245,000 + 1.2 의 10,000 summary = {s["key"]: s for s in sheet["summary"]} assert (summary["direct"]["previous_krw"], summary["direct"]["current_krw"]) == ( "150000", "105000", ) assert summary["direct"]["cumulative_pct"] == "63.750" def test_부가세_넷() -> None: summary = {s["key"]: s for s in _sheet()["summary"]} # 1회 공급가액 180,375 × 10% = 18,037 · 2회 재료비 30,000 × 10% = 3,000 assert (summary["supply"]["previous_krw"], summary["vat"]["previous_krw"]) == ( "180375", "18037", ) assert summary["vat"]["current_krw"] == "3000" coop = {"rounds": [{"quantities": {"1.1": "40"}, "vat_mode": "forest_coop"}]} vat = {s["key"]: s for s in _sheet(coop)["summary"]}["vat"]["current_krw"] assert vat == "6000" # (재료비 40,000 + 산출경비 20,000) × 10% manual = { "rounds": [{"quantities": {"1.1": "40"}, "vat_mode": "manual", "vat_manual_krw": "12345"}] } assert {s["key"]: s for s in _sheet(manual)["summary"]}["vat"]["current_krw"] == "12345" def test_회차를_고르면_그_앞이_전회() -> None: first = _sheet(round_no=1) row = {r["item_no"]: r for r in first["rows"]}["1.1"] assert (row["progress_previous_quantity"], row["progress_current_quantity"]) == ("0", "40") assert first["round"] == 1 and first["round_count"] == 2 def test_계약_수량_넘으면_알림과_분리발주_아니면_폐기물도_제잡비() -> None: over = {"rounds": [{"quantities": {"1.2": "12"}, "vat_mode": "supply"}]} row = {r["item_no"]: r for r in _sheet(over)["rows"]}["1.2"] assert row["progress_remaining_quantity"] == "-2" and "넘음" in row["progress_note"] items = {item["key"] for item in _sheet(separate_waste=False)["items"]} assert "waste_disposal" in items def test_틀린_칸은_거른다() -> None: _, errors = clean_settings( { "rounds": [ {"quantities": {"1.1": "-1"}, "vat_mode": "supply"}, {"quantities": {}, "vat_mode": "manual", "vat_manual_krw": ""}, {"quantities": {}, "vat_mode": "없는방식"}, ] } ) assert len(errors) == 3