Files
Aislo/resources/tester/test_b09_cost_options.py
T
eomsangdonandClaude Opus 5 aeb18266bd fix(b09): 총액 1,000원 미만 버림을 기본으로 켬 · 절사 자동보정 진동 고침
근거 — 산림청고시 제2025-82호 「금액의 단위표준」 「설계서의 총액 · 원 · 1,000 · 미만버림」.
실무 6건 원가계산서가 여섯 다 000 으로 끝나는 것이 증거.

· 기본 켬 — cut_basis="grand_total" · cut_unit_krw=1000
· 조용히 안 깎음(2026-09-08 합의의 정신) — 이윤 조정액 줄과 결과 메모에 고시 번호를 달고
  기준 입력판 11 에서 끌 수 있음을 함께 적음 · 칸 밑 안내도 고쳐 씀
· 끄는 값을 none 으로 바꿈 — 빈 값은 저장이 안 돼 기본으로 되돌아가 끌 수가 없었음
· 진동 고침 — 걸음마다 ÷1.1(종전엔 첫 걸음만). 이윤 1원이 총공사비 1.1원이라
  잔차를 그대로 빼면 경계를 지나침(울진 신설 654→999→900→910→909…)
· 안 앉으면 「끝자리 N원이 남음」을 메모로 보임
· 실무 6건 전수 000 판정을 새 벌로 지킴

곁 — tmp/tests/test_b09_cost_fixtures.py 를 resources/tester/ 로 옮김(픽스처 포함).
     tmp 쪽 사본은 안 지움 · 겹침은 파일 머리에 적음.
     기본이 바뀌어 깨진 남의 시험 6건은 「안 자른 값」 자리에 cut_basis="none" 을 넣어 고침.

전체 시험 1782 통과 · 28 건너뜀.

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

234 lines
10 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.
"""원가계산서 기준 입력 10·11·12 — 일반관리비 주/전문 · 절사 · 부가세 방식 (PLAN 6장 새 절).
⚠ 금액을 박지 않는다 — 구조(어느 밑수·어느 표·끝자리)만 잰다. 실무 값은 식 한 조각만 씀:
봉화 차액 373 → 이윤 339 · 영월 695 → 632 (÷1.1 반올림) · 영덕(산림조합-면세품) 996 → 996.
⚠ 2026-09-14 — **11 절사 기본이 켜짐**(총공사비 1,000원 미만, 산림청고시 제2025-82호).
「안 자른 값」을 보는 자리는 `cut_basis="none"` 으로 **손수 꺼서** 본다.
"""
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_Engine_Cost_Options import ( # noqa: E402
cut_gap,
profit_cut,
)
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(1_000_000_000), # 10억 — 주공사 8.0 / 전문 6.5 가 갈리는 자리
)
def test_기본값은_주공사_공급가액_총공사비절사() -> None:
"""10 주공사 · 12 공급가액 10% · **11 총공사비 1,000원 미만 절사**(2026-09-14 기본으로 켬)."""
explicit = replace(
BASE,
overhead_class="civil_landscape_industrial",
vat_mode="supply",
cut_basis="grand_total",
cut_unit_krw=1000,
)
assert calculate_cost(explicit).totals == calculate_cost(BASE).totals
assert calculate_cost(BASE).totals["grand_total"] % 1000 == 0
def test_전문공사는_요율표만_바뀐다() -> None:
main, special = (
calculate_cost(BASE),
calculate_cost(replace(BASE, overhead_class="specialty_electric_communication_fire_other")),
)
assert (
main.line("general_overhead").base_amount_krw
== special.line("general_overhead").base_amount_krw
)
assert (
main.line("general_overhead").rate_percent != special.line("general_overhead").rate_percent
)
def test_관리품목자재대는_일반관리비_밑수에_든다() -> None:
plain = calculate_cost(BASE)
managed = calculate_cost(replace(BASE, overhead_managed_material_krw=Decimal(10_000_000)))
gain = (
managed.line("general_overhead").base_amount_krw
- plain.line("general_overhead").base_amount_krw
)
assert gain == Decimal(10_000_000)
assert "관리품목자재대" in managed.line("general_overhead").base_label
@pytest.mark.parametrize(
("mode", "expected"),
[
("material", lambda r, d: r.amount("material_cost")),
("none", lambda r, d: Decimal(0)),
("forest_coop", lambda r, d: r.amount("material_cost") + d.direct_expense_krw),
(
"forest_coop_exempt",
lambda r, d: (
r.amount("material_cost") - d.tax_exempt_material_krw + d.direct_expense_krw
),
),
],
)
def test_부가세_방식은_밑수만_바꾼다(mode, expected) -> None:
data = replace(BASE, vat_mode=mode, tax_exempt_material_krw=Decimal(3_327_500))
result = calculate_cost(data)
assert result.line("vat").base_amount_krw == expected(result, data)
if mode == "none":
assert result.amount("vat") == 0
def test_실무_절사_식() -> None:
assert profit_cut(Decimal(373), "grand_total", "supply") == 339 # 봉화
assert profit_cut(Decimal(695), "grand_total", "supply") == 632 # 영월
assert profit_cut(Decimal(996), "grand_total", "forest_coop_exempt") == 996 # 영덕
assert cut_gap(Decimal(1_270_728_373), 1000) == 373
@pytest.mark.parametrize("vat_mode", ["supply", "forest_coop"])
def test_총공사비_천원_절사는_이윤에서_보정(vat_mode) -> None:
plain = calculate_cost(replace(BASE, vat_mode=vat_mode, cut_basis="none"))
cut = calculate_cost(
replace(BASE, vat_mode=vat_mode, cut_basis="grand_total", cut_unit_krw=1000)
)
assert cut.amount("grand_total") % 1000 == 0
assert 0 <= plain.amount("grand_total") - cut.amount("grand_total") < 1000
assert cut.amount("profit") < plain.amount("profit")
assert cut.has("profit_adjustment") and any("자동보정" in note for note in cut.notes)
# 이윤 말고는 안 움직임 — 순공사원가·일반관리비 그대로.
for key in ("net_construction_cost", "general_overhead"):
assert cut.amount(key) == plain.amount(key)
def test_공급가액_절사는_차액_그대로() -> None:
plain = calculate_cost(replace(BASE, cut_basis="none"))
cut = calculate_cost(replace(BASE, cut_basis="supply", cut_unit_krw=10_000))
assert cut.amount("total_cost") % 10_000 == 0
assert plain.amount("profit") - cut.amount("profit") == plain.amount("total_cost") % 10_000
def test_절사를_끄면_이윤을_안_건드린다() -> None:
"""고르개 「절사 안 함」 = `none`. ⚠ 빈 값도 같이 받는다(저장 안 된 옛 프로젝트)."""
for off in ("none", ""):
assert not calculate_cost(replace(BASE, cut_basis=off)).has("profit_adjustment"), off
# 기준만 있고 단위가 0 이면 안 자른다.
assert not calculate_cost(replace(BASE, cut_unit_krw=0)).has("profit_adjustment")
def test_자동보정_줄은_제_이름을_적는다() -> None:
cut = calculate_cost(replace(BASE, cut_basis="grand_total", cut_unit_krw=1000))
assert "절사 자동보정" in cut.line("profit_adjustment").base_label
manual = calculate_cost(replace(BASE, cut_basis="none", profit_adjustment_krw=Decimal(100)))
assert manual.line("profit_adjustment").base_label == "설계자 명시 입력"
def test_고용보험_등급_고르기와_없음() -> None:
auto = calculate_cost(BASE)
grade1 = calculate_cost(replace(BASE, employment_insurance_grade="1"))
none = calculate_cost(replace(BASE, employment_insurance_grade="none"))
assert grade1.line("employment_insurance").rate_percent == Decimal("1.57")
assert grade1.amount("employment_insurance") > auto.amount("employment_insurance")
assert not none.has("employment_insurance") or none.amount("employment_insurance") == 0
with pytest.raises(Exception):
calculate_cost(replace(BASE, employment_insurance_grade="9"))
def test_퇴직공제_적용_여부() -> None:
small = replace(BASE, estimated_price_krw=Decimal(50_000_000)) # 1억 미만 — 자동이면 안 섬
assert (
not calculate_cost(small).has("retirement_mutual_aid")
or calculate_cost(small).amount("retirement_mutual_aid") == 0
)
applied = calculate_cost(replace(small, retirement_mutual_aid_mode="apply"))
assert applied.amount("retirement_mutual_aid") > 0
off = calculate_cost(replace(BASE, retirement_mutual_aid_mode="none"))
assert not off.has("retirement_mutual_aid") or off.amount("retirement_mutual_aid") == 0
def test_폐기물_승률_밖_자리() -> None:
"""실무 관행(울진소광) — 이윤 뒤 · 총원가 안 · 부가세 안 · 일반관리비·이윤 밑수 밖."""
waste = Decimal(1_000_000)
law = calculate_cost(replace(BASE, waste_disposal_krw=waste))
practice = calculate_cost(
replace(BASE, waste_disposal_krw=waste, waste_placement="after_profit")
)
none = calculate_cost(BASE)
assert practice.amount("general_overhead") == none.amount("general_overhead")
assert practice.amount("profit") == none.amount("profit")
assert practice.amount("total_cost") - none.amount("total_cost") == waste
assert law.amount("total_cost") - practice.amount("total_cost") > 0 # 법 문언 쪽이 승률만큼 큼
keys = [line.key for line in practice.lines]
assert keys.index("profit") < keys.index("waste_disposal") < keys.index("total_cost")
def test_이행보증은_일반계약이면_300억_미만에서_안_선다() -> None:
assert not calculate_cost(BASE).has("performance_guarantee_fee")
tech = calculate_cost(replace(BASE, performance_guarantee_mode="lowest_price_tech"))
assert tech.amount("performance_guarantee_fee") > 0
def test_이행보증_70억_이상은_기초액과_기준액을_쓴다() -> None:
"""제비율 기준 §10 — [79만원 + (직공비−75억원) × 0.0070%] × 공기(년)."""
big = CostInput(
direct_material_krw=Decimal(500_000_000),
direct_labor_krw=Decimal(300_000_000),
direct_expense_krw=Decimal(9_200_000_000), # 직공비 100억 · 안전 대상액 8억
estimated_price_krw=Decimal(12_000_000_000),
duration_days=365,
performance_guarantee_mode="lowest_price_tech",
)
assert calculate_cost(big).amount("performance_guarantee_fee") == Decimal(790_000 + 175_000)
def test_낙찰방식이_하도급대금_요율_줄을_고른다() -> None:
on = replace(BASE, subcontract_guarantee="on")
assert calculate_cost(on).line("subcontract_payment_guarantee").rate_percent == Decimal("0.081")
turnkey = calculate_cost(replace(on, bid_method="turnkey"))
assert turnkey.line("subcontract_payment_guarantee").rate_percent == Decimal("0.084")
comp = calculate_cost(replace(on, bid_method="comprehensive"))
assert comp.line("subcontract_payment_guarantee").rate_percent == Decimal("0.071")
def test_사급비_위치_적용기준은_계산에_안_쓴다() -> None:
same = calculate_cost(
replace(BASE, private_material_position="material", contract_law_basis="local")
)
assert same.totals == calculate_cost(BASE).totals
def test_산업안전_50억_이상은_선임_대상으로_가른다() -> None:
"""고시 별표1 — 「50억 이상」 열과 「보건관리자 선임 대상」 열은 공사가 선임 대상인가로 갈림."""
big = replace(
BASE,
direct_material_krw=Decimal(4_000_000_000),
direct_labor_krw=Decimal(3_000_000_000),
estimated_price_krw=Decimal(20_000_000_000), # 토목 선임 기준 1,000억 미만
)
assert calculate_cost(big).line("safety_management_cost_b").rate_percent == Decimal("2.6")
target = calculate_cost(replace(big, estimated_price_krw=Decimal(120_000_000_000)))
assert target.line("safety_management_cost_b").rate_percent == Decimal("2.73")
def test_하도급대금_지급보증은_기본_꺼짐() -> None:
assert not calculate_cost(BASE).has("subcontract_payment_guarantee")
assert (
calculate_cost(replace(BASE, subcontract_guarantee="on")).amount(
"subcontract_payment_guarantee"
)
> 0
)