근거 — 산림청고시 제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
234 lines
10 KiB
Python
234 lines
10 KiB
Python
"""원가계산서 기준 입력 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
|
||
)
|