"""계약 단계 — 설계 → 계약내역 **구조**가 서는가 (PLAN 12장 · 2026-09-14 브레인 배정). ⚠ 값이 맞는지는 못 잰다 — 계약 표본 0건(STmate 27번 §9). 여기서 재는 것: ① 적용률 100% 면 설계와 같음 · 설계 줄(입력)은 안 바뀜 ② 노·재·경 따로 곱해짐 · 절사는 설계 내역 규칙(성분 단가 원 미만 · 줄 성분마다) ③ 적용 제외 줄은 설계 단가 · W 코드 안 붙음 ④ 0% = 0 원(브레인 판정) · 「공내역 생성」 켜면 그 줄에 공내역 이름 ⑤ 동일코드 개별생성 켜면 줄마다 다른 W 코드 · 끄면 같은 코드 한 벌 ⑥ 묶음 줄 합 · 틀린 적용률 거름 ⑦ 2벌 — 기초단가 적용은 단가표 **복사본**만(설계 단가표 불변) · 못 푸는 줄은 까닭 · 계약 호표(W-B·W-D)는 기초단가 적용과 함께만 섬 """ 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_Contract import clean_settings, contract_bill # noqa: E402 from B09_Estimation.B09_Estimation_PriceBook import ( # noqa: E402 PriceBook, PriceDetail, PriceKind, PriceTitle, ) from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild # noqa: E402 ROWS = [ {"item_no": "1", "is_group": True, "in_bill": True}, { "item_no": "1.1", "is_group": False, "in_bill": True, "quantity": "12.5", "price_code": "B-FP-09-03-02", "unit_material_krw": "1001", "unit_labor_krw": "2003", "unit_expense_krw": "3005", }, { "item_no": "1.2", "is_group": False, "in_bill": True, "quantity": "3", "price_code": "B-FP-09-03-02", "unit_material_krw": "1001", "unit_labor_krw": "2003", "unit_expense_krw": "3005", }, { "item_no": "1.3", "is_group": False, "in_bill": True, "quantity": "2", "price_code": "M-관급자재", "unit_material_krw": "50000", "unit_labor_krw": "0", "unit_expense_krw": "0", }, ] def _rows(settings: dict) -> dict[str, dict]: return {row["item_no"]: row for row in contract_bill(ROWS, settings)["rows"]} def test_100퍼센트면_설계와_같고_입력은_안_바뀐다() -> None: before = copy.deepcopy(ROWS) result = contract_bill(ROWS, clean_settings({})[0]) assert ROWS == before assert result["totals"]["design"] == result["totals"]["contract"] def test_노재경을_따로_곱하고_성분_단가를_원_미만_절사() -> None: row = _rows({"labor_pct": "80", "material_pct": "90", "expense_pct": "70"})["1.1"] assert ( row["contract_unit_material_krw"], row["contract_unit_labor_krw"], row["contract_unit_expense_krw"], ) == ("900", "1602", "2103") # 900.9→900 · 1602.4→1602 · 2103.5→2103 assert row["contract_material_krw"] == "11250" # 12.5 × 900 assert row["contract_code"] == "W-B-FP-09-03-02" def test_적용_제외_줄은_설계_단가_W코드_없음() -> None: row = _rows({"material_pct": "50", "excluded": ["1.3"]})["1.3"] assert row["contract_unit_material_krw"] == "50000" and row["contract_excluded"] is True assert not row["contract_code"].startswith("W-") def test_0퍼센트_공내역_옵션() -> None: zero = {"labor_pct": "0", "material_pct": "0", "expense_pct": "0"} empty = _rows({**zero, "zero_makes_empty": True})["1.1"] assert empty["contract_amount_krw"] == "0" and "공내역" in empty["contract_note"] plain = _rows(zero)["1.1"] assert plain["contract_amount_krw"] == "0" and plain["contract_note"] == "" # 그냥 0 원 줄 def test_동일코드_개별생성() -> None: shared = _rows({"material_pct": "90"}) assert shared["1.1"]["contract_code"] == shared["1.2"]["contract_code"] separate = _rows({"material_pct": "90", "separate_same_code": True}) assert separate["1.1"]["contract_code"] != separate["1.2"]["contract_code"] def test_묶음_줄_합과_합계_비율() -> None: result = contract_bill(ROWS, {"material_pct": "90", "labor_pct": "90", "expense_pct": "90"}) group = result["rows"][0] children = [r for r in result["rows"] if not r.get("is_group")] assert Decimal(group["contract_amount_krw"]) == sum( Decimal(r["contract_amount_krw"]) for r in children ) assert result["totals"]["ratio_pct"]["labor"] is not None def test_틀린_적용률은_거른다() -> None: cleaned, errors = clean_settings({"labor_pct": "-3", "material_pct": "abc", "expense_pct": ""}) assert len(errors) == 2 and cleaned["expense_pct"] == "100" # ── 2벌 — 「기초단가에 적용」 · 「적용율 적용된 일위대가/산출근거 생성」 ────────────── def _build(): """자재 1,001 · 노임 2,003 · 산출근거 D-T(노임 0.5) · 일위대가 B-T(자재 2 + D-T 1).""" book = PriceBook() for code, kind, price in ( ("M-A", PriceKind.MATERIAL, "1001"), ("L-B", PriceKind.LABOR, "2003"), ): slots = [None] * 6 slots[5] = Decimal(price) book.add_title(PriceTitle(code=code, kind=kind, name=code, slots=slots)) book.add_title(PriceTitle(code="D-T", kind=PriceKind.PRICE_BASIS, name="산출근거")) book.add_title(PriceTitle(code="B-T", kind=PriceKind.UNIT_PRICE, name="일위대가")) book.add_detail(PriceDetail("D-T", "L-B", Decimal("0.5"))) book.add_detail(PriceDetail("B-T", "M-A", Decimal(2))) book.add_detail(PriceDetail("B-T", "D-T", Decimal(1))) return UnitPriceBuild(book=book) def _line(item_no: str, code: str, material: str, labor: str) -> dict: return { "item_no": item_no, "is_group": False, "in_bill": True, "quantity": "3", "price_code": code, "unit_material_krw": material, "unit_labor_krw": labor, "unit_expense_krw": "0", } BASE_ROWS = [ _line("2.1", "B-T", "2002", "1001"), # 설계 단가 = 단가표 조립값 _line("2.2", "B-T", "2500", "1001"), # 수동·할증으로 조립값과 다름 _line("2.3", "Z-없음", "100", "0"), # 단가표 밖 ] BASE_ON = { "material_pct": "90", "labor_pct": "80", "apply_to_base_prices": True, "generate_unit_prices": True, } def test_기초단가_적용은_복사본만_고치고_다시_조립() -> None: build = _build() before = (build.book.resolve("B-T"), copy.deepcopy(build.book.titles["M-A"].slots)) result = contract_bill(BASE_ROWS, BASE_ON, build=build) assert (build.book.resolve("B-T"), build.book.titles["M-A"].slots) == before # 설계 단가표 불변 rows = {row["item_no"]: row for row in result["rows"]} # 자재 900.9 × 2 = 1801.8 → 1801 · 노임 1602.4 × 0.5 = 801.2 → D 801 → B 801 # (성분에 곱했으면 노무 1001 × 0.8 = 800.8 → 800 — 층을 다시 조립해야 801) assert (rows["2.1"]["contract_unit_material_krw"], rows["2.1"]["contract_unit_labor_krw"]) == ( "1801", "801", ) assert rows["2.1"]["contract_note"].startswith("기초단가 적용") def test_기초단가로_못_푸는_줄은_성분에_곱하고_까닭을_적는다() -> None: rows = {r["item_no"]: r for r in contract_bill(BASE_ROWS, BASE_ON, build=_build())["rows"]} assert ( rows["2.2"]["contract_unit_material_krw"] == "2250" and "조립값과 다름" in (rows["2.2"]["contract_note"]) ) assert "단가표 밖" in rows["2.3"]["contract_note"] def test_계약_호표는_W코드로_따로_서고_산출근거도_따라_선다() -> None: sheets = contract_bill(BASE_ROWS, BASE_ON, build=_build())["unit_price_sheets"] by_code = {sheet["code"]: sheet for sheet in sheets} assert set(by_code) == {"W-B-T", "W-D-T"} assert (by_code["W-B-T"]["design_code"], by_code["W-B-T"]["total"]) == ("B-T", "2602") assert by_code["W-D-T"]["labor"] == "801" def test_호표_생성은_기초단가_적용이_함께_켜져야_선다() -> None: only_sheets = {**BASE_ON, "apply_to_base_prices": False} result = contract_bill(BASE_ROWS, only_sheets, build=_build()) assert result["unit_price_sheets"] == [] assert "generate_unit_prices" in result["options_not_used"] off = contract_bill(BASE_ROWS, {**BASE_ON, "generate_unit_prices": False}, build=_build()) assert off["unit_price_sheets"] == []