Files
Aislo/resources/tester/test_b09_cost_options.py
eomsangdonandClaude Opus 5 47196780b2 fix(b09): 절사 자동보정을 이분으로 앉힘 — 못 밟는 끝자리에서 999 가 남던 자리
까닭 — 이윤 1원을 깎으면 총공사비는 1원이나 2원 준다(부가세가 버림). ÷1.1 어림이 한 칸
아래로 지나치고 3걸음 안에 못 돌아와 거창 꼴 40 자리 중 16 이 끝자리 999 로 남았음.

고친 법 — 노릴 배수를 못 박고 목표 이하로 내려가는 **가장 작은 보정액을 이분으로** 찾는다
(총공사비는 보정액에 대해 비증가). 못 밟는 배수면 한 칸 아래로 내려 다시 찾는다(뒷받침 —
실측에서는 한 칸도 안 내려감). _CUT_MAX_PASSES → _CUT_DESCENT_MAX 로 뜻이 바뀜.

판정 — 거창 꼴 40/40 앉음 · 깎인 몫 356~423원으로 늘 한 칸 안쪽 ·
실무 6건 자동보정액 그대로(봉화 339 · 영월 632, 골든셋이 지킴).

잴 시험 먼저 빨강 10 자리 확인한 뒤 고침. 전체 시험 2043 통과 · 28 건너뜀 · 1 xfail.

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

273 lines
12 KiB
Python
Raw Permalink 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
)
#: 실무 거창 2025 꼴 — 안전관리비 한 줄만 서고 관급이 큰 모양. 총공사비 끝자리가
#: **건너뛰는** 자리가 생긴다(이윤 1원이 총공사비 1~2원이라 어떤 배수는 못 밟는다).
_SKIPPING = CostInput(
direct_material_krw=Decimal(80_165_010),
direct_labor_krw=Decimal(243_648_150),
direct_expense_krw=Decimal(0),
owner_supplied_material_krw=Decimal(74_634_214),
enabled_items=("safety_management_cost",),
cut_basis="grand_total",
cut_unit_krw=1000,
)
@pytest.mark.parametrize("delta", [0, 1, 6, 13, 18, 24, 26, 31, 38, 39])
def test_절사는_끝자리를_건너뛰는_자리에서도_앉는다(delta) -> None:
"""⭐ 이윤 1원을 깎으면 총공사비는 **1원이나 2원** 줄어 어떤 1,000 배수는 못 밟는다.
종전엔 ÷1.1 어림이 한 칸 아래로 지나친 뒤 3걸음 안에 못 돌아와 끝자리 999 가 남았다
(거창 꼴 40 자리 중 16). 못 밟는 배수면 **한 칸 아래 배수로 내려가** 앉아야 한다.
"""
result = calculate_cost(replace(_SKIPPING, direct_labor_krw=Decimal(243_648_150 + delta)))
assert result.totals["grand_total"] % 1000 == 0, delta
assert not [n for n in result.notes if "안 앉음" in n], result.notes
@pytest.mark.parametrize("delta", [0, 1, 6, 13, 18, 24, 26, 31, 38, 39])
def test_절사는_깎을_수_있는_가장_적은_몫만_깎는다(delta) -> None:
"""버림이므로 깎인 몫은 **한 칸(1,000원) 안쪽**이어야 한다.
⚠ 이 줄이 깨지면 「못 밟는 배수를 만나 한 칸 내려갔다」는 뜻이다 — 실측(거창 꼴 40 자리)에서는
한 칸도 안 내려갔다. 깨지는 자리가 생기면 그 자체가 봐야 할 소식이다.
"""
data = replace(_SKIPPING, direct_labor_krw=Decimal(243_648_150 + delta))
plain = calculate_cost(replace(data, cut_basis="none"))
cut = calculate_cost(data)
drop = plain.totals["grand_total"] - cut.totals["grand_total"]
assert 0 <= drop < 1000, (delta, drop)