- 서버가 내역 줄 성분 단가에 율을 얹어 다시 셈(줄 칸이 전체 칸보다 우선) · 수량은 그대로 - 공용 작은 버튼 글자색 상속 — 어두운 화면에서 검정 글씨로 묻히던 것 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
212 lines
8.4 KiB
Python
212 lines
8.4 KiB
Python
"""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}]) == {}
|