Files
Aislo/resources/tester/test_b09_rate_override.py
eomsangdonandClaude Opus 5 f704bc3bf8 feat(b09): 요율 덮어쓰기 — 프로젝트 단위 · 칸마다 사유 · 기본값↔고친 값 · ↺ 되돌리기
- estimation.rate_overrides 에 칸 주소(variable·table·match)·요율·사유·입력 시각을 한 줄씩 — 마스터 요율 파일은 안 건드림
- 엔진이 계산할 때 복사본에만 얹음 · 덮어쓰기 없으면 원본 그대로(골든셋 초록) · 원가계산서 비고에 「요율 덮어쓰기 N건 — 사유」
- 요율표: 칸마다 기본값·지금 값·주소 · [수 정]에서 입력 · 고친 칸 테두리 + (기본 X) · ↺ · 고친 요율 목록에 사유 칸(비면 저장 안 됨)
- PUT /estimation/rate-table: 사유 빈 줄·틀린 주소·같은 칸 두 번·음수면 아무것도 안 저장 · 값·사유 같으면 입력 시각 유지
- 판이 바뀌어 주소가 안 맞으면 조용히 버리지 않고 알림
- 시험: 마스터 불변 · 계산에 걸림 · 한 값짜리 · 거름 · 주소 틀리면 멈춤 · 화면 주소 왕복

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 03:24:33 +09:00

120 lines
4.7 KiB
Python

"""프로젝트 요율 덮어쓰기 — 프로젝트 단위 · 사유 저장 · 기본값↔고친 값 (PLAN 6장 새 절).
⚠ 겨누는 것
① 마스터 요율판은 **안 바뀜**(덮어쓰기는 복사본에만)
② 사유 없는 줄·틀린 주소·같은 칸 두 번은 거름
③ 덮어쓴 요율이 계산에 실제로 걸림 · 안 쓴 프로젝트는 값이 그대로
④ 요율표 칸이 기본값·지금 값·주소를 함께 싣고, 그 주소를 그대로 돌려주면 저장됨
"""
from __future__ import annotations
import sys
from dataclasses import replace
from decimal import Decimal
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, calculate_cost # noqa: E402
from B09_Estimation.B09_Estimation_RateOverride import ( # noqa: E402
apply_overrides,
clean_overrides,
)
from B09_Estimation.B09_Estimation_Rates import RateLookupError, load_rate_dataset # noqa: E402
from B09_Estimation.B09_Estimation_RateTable import rate_sections # noqa: E402
BASE = CostInput(
direct_material_krw=Decimal(123_456_789),
direct_labor_krw=Decimal(234_567_891),
direct_expense_krw=Decimal(45_678_912),
estimated_price_krw=Decimal(300_000_000), # 50억 미만 구간
)
OVERHEAD = {
"variable": "rate_overhead",
"table": "civil_landscape_industrial",
"match": {"estimated_price_bracket": "lt_5_billion"},
"rate_percent": "7.5",
"reason": "발주처 지침(시험)",
}
def test_마스터는_안_바뀐다() -> None:
master = load_rate_dataset()
before = master.variables["rate_overhead"]["civil_landscape_industrial"][0]["rate_percent"]
changed = apply_overrides(master, [OVERHEAD])
assert (
changed.variables["rate_overhead"]["civil_landscape_industrial"][0]["rate_percent"] == 7.5
)
assert (
master.variables["rate_overhead"]["civil_landscape_industrial"][0]["rate_percent"] == before
)
assert (
load_rate_dataset().variables["rate_overhead"]["civil_landscape_industrial"][0][
"rate_percent"
]
== before
)
def test_덮어쓴_요율이_계산에_걸리고_안_쓰면_그대로() -> None:
plain = calculate_cost(BASE)
again = calculate_cost(replace(BASE, rate_overrides=()))
assert plain.totals == again.totals
changed = calculate_cost(replace(BASE, rate_overrides=(OVERHEAD,)))
assert changed.line("general_overhead").rate_percent == Decimal("7.5")
assert changed.amount("general_overhead") < plain.amount("general_overhead")
assert any("요율 덮어쓰기 1건" in note and "발주처 지침" in note for note in changed.notes)
def test_한_값짜리_요율도_덮어쓴다() -> None:
sanjae = {
"variable": "rate_sanjae",
"table": None,
"match": {},
"rate_percent": "3.7",
"reason": "고시 개정",
}
result = calculate_cost(replace(BASE, rate_overrides=(sanjae,)))
assert result.line("industrial_accident_insurance").rate_percent == Decimal("3.7")
def test_사유_주소_중복을_거른다() -> None:
master = load_rate_dataset()
kept, errors = clean_overrides(
[
{**OVERHEAD, "reason": " "},
{**OVERHEAD, "match": {"estimated_price_bracket": "no_such"}},
{**OVERHEAD, "rate_percent": "-1"},
OVERHEAD,
OVERHEAD,
],
master,
)
assert len(kept) == 1 and kept[0]["rate_percent"] == "7.5"
assert len(errors) == 4
assert any("사유" in e for e in errors) and any("두 번" in e for e in errors)
def test_주소가_안_맞으면_계산이_멈춘다() -> None:
stale = {**OVERHEAD, "match": {"estimated_price_bracket": "gone"}}
with pytest.raises(RateLookupError):
calculate_cost(replace(BASE, rate_overrides=(stale,)))
def test_요율표_칸이_기본값과_주소를_싣고_그대로_돌려주면_저장된다() -> None:
master = load_rate_dataset()
current = apply_overrides(master, [OVERHEAD])
sections = rate_sections(current, master)
cells = [cell for s in sections for row in s["rows"] for cell in row if isinstance(cell, dict)]
assert cells and all({"value", "default", "overridden", "address"} <= set(c) for c in cells)
changed = [c for c in cells if c["overridden"]]
assert len(changed) == 1 and (changed[0]["value"], changed[0]["default"]) == (7.5, 8.0)
# 화면이 받은 주소 그대로 새 값·사유를 붙여 돌려주면 모두 저장 가능해야 함.
raw = [{**c["address"], "rate_percent": "1", "reason": "왕복"} for c in cells]
kept, errors = clean_overrides(raw, master)
assert not errors and len(kept) == len(cells)