Files
Aislo/resources/tester/test_b09_cost_options.py
T
eomsangdonandClaude Opus 5 8046205039 fix(b09): 산업안전 50억 이상 구간 겹침 해소 · 하도급대금 지급보증수수료 칸(기본 꺼짐)
- 산업안전보건관리비: 고시 별표1 「보건관리자 선임 대상 건설공사의 적용비율」 열을 대상액이 아니라
  공사가 선임 대상인가(요율 데이터 manager_thresholds 800억 · 토목 1,000억)로 가름 — 두 줄이 겹쳐 멈추던 자리
- 하도급대금 지급보증수수료: 기본 꺼짐 — 실무 원가계산서 6건에 줄 없음 · 건설산업기본법 제34조 대상이면 켬
  (칸 밑에 「대상 아님인지 안 적음인지 못 가름」 사유)
- 시험: 50억 이상 2.60 / 선임 대상 2.73 · 하도급 꺼짐·켬 · 골든셋 초록

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

222 lines
9.7 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.
"""
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:
explicit = replace(
BASE, overhead_class="civil_landscape_industrial", vat_mode="supply", cut_basis=""
)
assert calculate_cost(explicit).totals == calculate_cost(BASE).totals
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 = 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(BASE)
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:
assert not calculate_cost(replace(BASE, cut_unit_krw=1000)).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, 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
)