"""기성 단계 — 계약 → 기성내역 · 기성 제잡비 계산서 **구조**가 서는가 (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 ( # noqa: E402 clean_settings, completion_sheet, progress_sheet, ) 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 _items(sheet: dict) -> dict: return {item["key"]: item for item in sheet["items"]} def _summary(sheet: dict) -> dict: return {s["key"]: s for s in sheet["summary"]} def test_계약잡비율_직접입력이_이기고_사유가_없으면_거른다() -> None: settings = { **SETTINGS, "rate_overrides": {"indirect_labor_cost": {"rate_pct": "10", "reason": "계약서 제잡비율"}}, } labor = _items(_sheet(settings))["indirect_labor_cost"] assert (labor["contract_ratio_pct"], labor["default_ratio_pct"]) == ("10.000", "5.000") assert labor["current_krw"] == "10500" and labor["override"]["reason"] == "계약서 제잡비율" _, errors = clean_settings({"rate_overrides": {"profit": {"rate_pct": "7", "reason": " "}}}) assert len(errors) == 1 def test_단일율_곱과_벌어진_차이를_알린다() -> None: # 1.2 × 0.1998 = 999원 → 줄별 49 + 17 + 59 + 74 = 199 · 단일율 20.25% 곱 202 → 3원 sheet = _sheet({"rounds": [{"quantities": {"1.2": "0.1998"}}]}) assert sheet["single_rate_gap_krw"]["current"] == "3" assert any("단일율 곱과 금회 3원" in note for note in sheet["notes"]) def test_공급가액_절사와_총공사비_절사는_이윤에서() -> None: first = {"quantities": {"1.1": "40", "1.2": "2"}, "vat_mode": "supply"} cut = _sheet({"rounds": [{**first, "supply_cut_krw": "100"}]}) # 공급가액 180,375 → 180,300 · 이윤 11,250 − 75 · 부가세 18,030 assert _items(cut)["profit"]["current_krw"] == "11175" assert (_summary(cut)["supply"]["current_krw"], _summary(cut)["vat"]["current_krw"]) == ( "180300", "18030", ) total = _sheet({"rounds": [{**first, "total_cut_krw": "1000"}]}) # 총공사비 198,412 → 412 ÷ 1.1 = 375 을 이윤에서 → 공급가액 180,000 · 부가세 18,000 assert _summary(total)["total"]["current_krw"] == "198000" assert _items(total)["profit"]["current_krw"] == "10875" def test_이윤금액_직접입력과_사정_칸() -> None: entry = { "quantities": {"1.1": "40", "1.2": "2"}, "profit_manual_krw": "10000", "assessed": {"1.1": "130000"}, } sheet = _sheet({"rounds": [entry]}) assert _items(sheet)["profit"]["current_krw"] == "10000" assert _summary(sheet)["supply"]["current_krw"] == "179125" row = {r["item_no"]: r for r in sheet["rows"]}["1.1"] assert row["progress_assessed_krw"] == "130000" # 칸만 — 금액에 안 섞임 assert row["progress_current_amount_krw"] == "140000" def test_준공은_마지막_회차_누계를_옮기기만() -> None: progress = _sheet() before = copy.deepcopy(progress) done = completion_sheet(progress) assert progress == before # 기성 한 장 불변 row = {r["item_no"]: r for r in done["rows"]}["1.1"] assert (row["contract_amount_krw"], row["completion_amount_krw"]) == ("350000", "245000") lines = {line["key"]: line for line in done["lines"]} summary = {s["key"]: s for s in progress["summary"]} assert lines["total"]["completion_krw"] == summary["total"]["cumulative_krw"] assert lines["indirect_labor_cost"]["completion_krw"] == "12750" and done["from_round"] == 2 def test_기성_회차가_없으면_준공금액을_비운다() -> None: done = completion_sheet(_sheet({"rounds": []})) assert all(line["completion_krw"] is None for line in done["lines"]) assert done["notes"] and done["from_round"] == 0 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