- 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
120 lines
4.7 KiB
Python
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)
|