Files
Aislo/resources/tester/test_b09_edits.py
T
eomsangdonandClaude Opus 5 b9d0ac0866 feat(b09): 2차 편집 ④ 내역 할증율·일괄보정 — 범위(선택 줄·내역서 전체) · 비목별 절사/반올림 · 소수 0~4 · 줄 ↺ · 할증 전부 되돌리기
- 서버가 내역 줄 성분 단가에 율을 얹어 다시 셈(줄 칸이 전체 칸보다 우선) · 수량은 그대로
- 공용 작은 버튼 글자색 상속 — 어두운 화면에서 검정 글씨로 묻히던 것

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 04:06:40 +09:00

212 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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}]) == {}