- 구성행(sheet_rows): 수량 고치기 · 줄 빼기(수량 0, 흐리게) · 줄 더하기(단가표 고르개) — 비율 줄·돌림 참조는 받지 않음
- Q 식(price_basis_q): 식은 명세 13장 식 언어 — B08 구조물도 풀이기(evaluate_sheets, Node 한 벌)를 그대로 부름 ·
소수 2자리 사사오입 확정 → 새 수량 = 수량 × 옛 Q ÷ 새 Q · 비고에 「Q(사용자) = 식 = 값」
· PriceDetail.output(시공능력 Q)을 Q 로 선 장비 줄 넷 자리(굴착기·도자·직접 작업량·암 잎)에서 실음 · 저장 모양에도 실음
- 따로 짰던 파이썬 식 셈(B09_Estimation_Expression)은 걷어냄 — 식 풀이 두 벌 금지(브레인 판정)
- 새 문: GET /estimation/edits/sheet/{code}(본표 + 줄마다 고친 값 표시) · GET /estimation/edits/search(줄 더하기 고르개)
- 화면: 본표 [편집] — 수량 칸 · ✕ · Q 식 칸 · 줄 더하기 · 고친 줄 「사용자」 + ↺
- 검증: 시험 1664 통과 · 골든셋 초록 · ORCA — 제 3 호표 보통인부 0.023→0.046 이면 5,652→9,610 · 내역 본체 122,848,989→122,857,198,
↺ 뒤 5,652 · 산근 2호표 Q 58.21→116.42 이면 1,695→847, ↺ 뒤 1,695 · 틀린 식 422 「모르는 이름: abc」 · 고친 값 {} 로 복구
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
147 lines
5.8 KiB
Python
147 lines
5.8 KiB
Python
"""B09 사용자가 고친 값 — 프로젝트 단위 저장·얹기·되돌리기 (PLAN 12장 2차 · 2026-09-14 브레인 판정).
|
||
|
||
겨누는 것
|
||
① 고친 값은 **복사본에** 얹힘 — 기본 벌(캐시)은 그대로(골든셋·다른 프로젝트가 안 흔들림)
|
||
② 자재단가대비표 채택 슬롯 — 값 있는 슬롯만 받음 · 채택하면 금액이 그 슬롯 값으로 섬
|
||
③ 지우면(↺) 계산값으로 돌아감 · 고친 값이 없으면 캐시 키가 빈 글
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from decimal import Decimal
|
||
|
||
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
|