"""B09 사용자가 고친 값 — 프로젝트 단위 저장·얹기·되돌리기 (PLAN 12장 2차 · 2026-09-14 브레인 판정). 겨누는 것 ① 고친 값은 **복사본에** 얹힘 — 기본 벌(캐시)은 그대로(골든셋·다른 프로젝트가 안 흔들림) ② 자재단가대비표 채택 슬롯 — 값 있는 슬롯만 받음 · 채택하면 금액이 그 슬롯 값으로 섬 ③ 지우면(↺) 계산값으로 돌아감 · 고친 값이 없으면 캐시 키가 빈 글 """ from __future__ import annotations from decimal import Decimal from typing import Any import pytest from B09_Estimation.B09_Estimation_Edits import ( EditError, apply_edits, edits_key, merge_changes, ) from B09_Estimation.B09_Estimation_PriceBook import PriceBook, PriceDetail, PriceKind, PriceTitle from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild def _build() -> UnitPriceBuild: book = PriceBook() slots = [Decimal(100), None, Decimal(90), None, None, Decimal(120)] book.add_title(PriceTitle("M-1", PriceKind.MATERIAL, "시멘트", slots=slots)) book.add_title(PriceTitle("B-1", PriceKind.UNIT_PRICE, "타설")) book.add_detail(PriceDetail("B-1", "M-1", Decimal(2))) return UnitPriceBuild(book=book) def test_채택_슬롯을_바꾸면_복사본에서만_금액이_바뀐다() -> None: base = _build() edits = merge_changes({}, base, [{"section": "adopted_slots", "key": "M-1", "value": "3"}]) assert edits == {"adopted_slots": {"M-1": 3}} edited = apply_edits(base, edits) assert edited.book.resolve("B-1").material == Decimal(180) # 90 × 2 assert base.book.resolve("B-1").material == Decimal(240) # 기본 벌은 6번 120 그대로 assert edited.edit_skipped == [] def test_값_없는_슬롯은_받지_않는다() -> None: with pytest.raises(EditError): merge_changes({}, _build(), [{"section": "adopted_slots", "key": "M-1", "value": 2}]) with pytest.raises(EditError): merge_changes({}, _build(), [{"section": "adopted_slots", "key": "B-1", "value": 1}]) def test_지우면_계산값으로_돌아가고_캐시_키가_빈다() -> None: base = _build() edits = merge_changes( {"adopted_slots": {"M-1": 1}}, base, [{"section": "adopted_slots", "key": "M-1", "value": None}], ) assert edits == {} and edits_key(edits) == "" assert edits_key({"adopted_slots": {"M-1": 1}}) != "" def _book_with_basis() -> UnitPriceBuild: build = _build() book = build.book book.add_title( PriceTitle("L-1", PriceKind.LABOR, "보통인부", slots=[None] * 5 + [Decimal(100000)]) ) book.add_title( PriceTitle("X-1", PriceKind.MACHINE_BASE, "굴착기", slots=[None] * 5 + [Decimal(55700)]) ) book.add_detail(PriceDetail("B-1", "L-1", Decimal("0.1"))) # D — Q 15.71 로 선 장비 줄(몫 1): 수량 1/15.71 book.add_output_detail("B-1", "X-1", Decimal(1) / Decimal("15.71"), "Q 식", Decimal("15.71")) return build def test_구성행_수량을_고치고_빼고_더하면_금액이_다시_서고_지우면_돌아온다() -> None: base = _book_with_basis() before = base.book.resolve("B-1") changes = [ { "section": "sheet_rows", "key": "B-1|1", "value": {"quantity": "0.2"}, }, # 보통인부 0.1 → 0.2 {"section": "sheet_rows", "key": "B-1|0", "value": {"removed": True}}, # 시멘트 뺌 {"section": "sheet_rows", "key": "B-1|+1", "value": {"ref": "L-1", "quantity": "0.05"}}, ] edits = merge_changes({}, base, changes) edited = apply_edits(base, edits) after = edited.book.resolve("B-1") assert after.material == 0 and after.labor == Decimal(25000) # 0.2×10만 + 0.05×10만 marks = edited.edit_rows["B-1"] assert marks[0]["removed"] and marks[1]["was_quantity"] == "0.1" and marks[3]["added"] assert base.book.resolve("B-1") == before # 기본 벌 그대로 cleared = merge_changes(edits, base, [{**c, "value": None} for c in changes]) assert cleared == {} and apply_edits(base, cleared).book.resolve("B-1") == before def test_Q_식을_고치면_수량이_옛_Q_를_새_Q_로_바꾼_만큼_변한다() -> None: base = _book_with_basis() key = "D-1|0" edits = merge_changes( {}, base, [ { "section": "price_basis_q", "key": key, "value": {"q_formula": "3600*0.2*0.7*0.85*0.55/15*2"}, } ], ) edited = apply_edits(base, edits) detail = edited.book.details["D-1"][0] assert detail.output == Decimal("31.42") assert detail.quantity == Decimal(1) / Decimal("15.71") * Decimal("15.71") / Decimal("31.42") assert edited.edit_rows["D-1"][0]["q_formula"].startswith("3600") # 줄 0.1원 · 머리 원 미만 절사: 55,700 ÷ 31.42 = 1,772.7 → 1,772 assert edited.book.resolve("D-1").expense == Decimal(1772) def test_Q_로_안_선_줄·비율_줄·돌림_참조는_받지_않는다() -> None: base = _book_with_basis() with pytest.raises(EditError): merge_changes( {}, base, [{"section": "price_basis_q", "key": "B-1|1", "value": {"q_formula": "10"}}] ) with pytest.raises(EditError): merge_changes( {}, base, [{"section": "sheet_rows", "key": "B-1|+1", "value": {"ref": "B-1", "quantity": "1"}}], ) with pytest.raises(EditError): merge_changes( {}, base, [{"section": "sheet_rows", "key": "D-1|+1", "value": {"ref": "B-1", "quantity": "1"}}], ) def test_단가표에서_사라진_코드는_버리지_않고_남긴다() -> None: edited = apply_edits(_build(), {"adopted_slots": {"M-없음": 1, "M-1": 2}}) assert len(edited.edit_skipped) == 2 assert edited.book.titles["M-1"].adopted_slot == 6 def _bill_row(code: str, spec: str = "", quantity: str = "10") -> Any: from B09_Estimation.B09_Estimation_BillOfQuantities import BillRow row = BillRow(item_no="1", level=1, code=code, name=code, spec=spec, quantity=Decimal(quantity)) row.price_code = f"B-{code}" row.unit_material_krw, row.unit_labor_krw, row.unit_expense_krw = ( Decimal(1000), Decimal(2000), Decimal(333), ) row.unit_price_krw = Decimal(3333) row.amount_krw = Decimal(33330) return row def test_할증율은_줄_칸이_이기고_없으면_내역서_전체가_얹힌다() -> None: from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line from B09_Estimation.B09_Estimation_BillRates import apply_bill_rates first, second = _bill_row("A"), _bill_row("B") rates = { "B-A|": {"all": "10"}, "*": { "material": "0", "labor": "5", "expense": "3", "rounding": {"expense": {"mode": "round", "digits": 1}}, }, } apply_bill_rates([first, second], rates, bill_line) # 줄 칸 10% — 성분마다 원 미만 절사: 1,100 · 2,200 · 366.3 → 366 assert (first.unit_material_krw, first.unit_labor_krw, first.unit_expense_krw) == ( Decimal(1100), Decimal(2200), Decimal(366), ) assert first.amount_krw == Decimal(36660) and first.rate["source"] == "B-A|" # 전체 칸 — 경비는 반올림 소수 1자리: 333 × 1.03 = 342.99 → 343.0 · 노무 2,100 assert second.unit_expense_krw == Decimal("343.0") and second.unit_labor_krw == Decimal(2100) assert second.amount_krw == Decimal(10000 + 21000 + 3430) assert "내역서 전체" in second.note def test_할증율_모양이_틀리면_받지_않고_지우면_돌아온다() -> None: base = _build() with pytest.raises(EditError): merge_changes({}, base, [{"section": "bill_rates", "key": "*", "value": {"all": "-100"}}]) with pytest.raises(EditError): merge_changes( {}, base, [ { "section": "bill_rates", "key": "*", "value": {"all": "5", "rounding": {"mode": "ceil", "digits": 0}}, } ], ) edits = merge_changes({}, base, [{"section": "bill_rates", "key": "*", "value": {"all": "5"}}]) assert edits["bill_rates"]["*"]["all"] == "5" and edits_key(edits) == "" # 조립 캐시엔 안 듦 assert merge_changes(edits, base, [{"section": "bill_rates", "key": "*", "value": None}]) == {}