"""산업안전보건관리비 — **고시 원문과 직접 대조**(2026-09-14 편입). 원문 `resources/knowledge/original/행정규칙/건설업 산업안전보건관리비 계상 및 사용기준/` `현행_20250212.md`(제3조·제4조) · `별표/별표1_…계상기준표.md` ⚠ 값을 여기서 짓지 않음 — **별표1 표를 원문에서 읽어** 우리 요율 데이터와 견줌. 요율표가 개정되면 원문과 데이터가 함께 바뀌어야 이 벌이 초록으로 남음. 아직 안 한 것(2026-09-14 실측 · 배정 대기) — 여기서 **안 재는 자리**임을 밝혀 둠 · 제3조 「총공사금액 2천만원 이상」 적용 하한을 엔진이 안 읽음 (요율 데이터엔 `minimum_total_construction_amount_krw` 가 있음 · 임도에선 안 걸림) · 제4조①3호 「대상액이 명확하지 않은 경우 = 총공사금액의 10분의 7」 미구현 · 관급 ÷1.1 은 고시에 없는 **실무 해석**(실무 원본 6건이 「관급재/1.1」로 적음) · 보건관리자 선임 문턱(800억·토목 1,000억)은 고시 밖(산업안전보건법 시행령 별표5, 원문 미보유) """ from __future__ import annotations import io import json import pathlib from decimal import ROUND_FLOOR, Decimal import pytest from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, calculate_cost ROOT = pathlib.Path(__file__).resolve().parents[2] NOTICE = ( ROOT / "resources" / "knowledge" / "original" / "행정규칙" / "건설업 산업안전보건관리비 계상 및 사용기준" ) RATES = ROOT / "resources" / "data_cost_input_value" / "rates_2026.json" #: 별표1 행 이름 → 요율 데이터 공종 키. _WORK_TYPES = { "건 축 공 사": "building", "토 목 공 사": "civil", "중 건 설 공 사": "heavy_construction", "특 수 건 설 공 사": "special_construction", } #: 별표1 칸 차례 → 요율 데이터 구간 이름. 기초액은 **가운데 구간에만** 붙음(제4조①1·2호). _BRACKETS = ( "lt_500_million", "500_million_to_5_billion", "gte_5_billion_below_manager_threshold", "gte_5_billion_at_or_above_manager_threshold", ) def _table() -> dict[str, tuple[list[Decimal], Decimal]]: """별표1 표 → `{공종: ([네 구간 율], 기초액)}`. 원문이 없으면 빈 표.""" path = NOTICE / "별표" / "별표1_공사종류 및 규모별 산업안전보건관리비 계상기준표.md" if not path.exists(): return {} rows: dict[str, tuple[list[Decimal], Decimal]] = {} for line in io.open(path, encoding="utf-8").read().splitlines(): cells = [c.strip() for c in line.strip().strip("|").split("|")] if cells and cells[0] in _WORK_TYPES: percents = [Decimal(cells[i].replace("%", "")) for i in (1, 2, 4, 5)] rows[_WORK_TYPES[cells[0]]] = ( percents, Decimal(cells[3].replace("원", "").replace(",", "")), ) return rows def _floor(value: Decimal) -> Decimal: return value.quantize(Decimal(1), rounding=ROUND_FLOOR) def _run(material: Decimal, labor: Decimal, **over) -> object: """안전관리비 한 줄만 세워 돌림 — 규모 구간은 못 박아 이 벌 밖으로 뺌.""" fields = dict( direct_material_krw=material, direct_labor_krw=labor, direct_expense_krw=Decimal(0), owner_supplied_includes_vat=False, enabled_items=("safety_management_cost",), estimated_price_krw=Decimal(1_000_000_000), cut_basis="none", ) fields.update(over) return calculate_cost(CostInput(**fields)) def test_별표1_요율표가_고시_원문과_전수_같다() -> None: """별표1 **16칸**(4 공종 × 4 열) — 율과 기초액이 원문과 한 자도 안 갈림.""" table = _table() if not table: pytest.skip("고시 원문이 없음") ours = { (row["work_type"], row["target_amount_bracket"]): ( Decimal(str(row["rate_percent"])), Decimal(str(row.get("base_amount_krw", 0))), ) for row in json.load(io.open(RATES, encoding="utf-8"))["variables"]["rate_safety_pct"][ "brackets" ] } misses = [] for work_type, (percents, flat) in table.items(): for index, bracket in enumerate(_BRACKETS): # 제4조①1호 — 5억 미만·50억 이상은 **기초액 없이** 율만 곱함. want = (percents[index], flat if bracket == "500_million_to_5_billion" else Decimal(0)) if ours.get((work_type, bracket)) != want: misses.append((work_type, bracket, want, ours.get((work_type, bracket)))) assert len(table) == 4 and not misses, misses @pytest.mark.parametrize( ("label", "target", "percent", "flat"), [ ("5억 바로 아래", Decimal(499_999_999), Decimal("3.15"), Decimal(0)), ("정확히 5억", Decimal(500_000_000), Decimal("2.53"), Decimal(3_300_000)), ("50억 바로 아래", Decimal(4_999_999_999), Decimal("2.53"), Decimal(3_300_000)), ("정확히 50억", Decimal(5_000_000_000), Decimal("2.60"), Decimal(0)), ], ) def test_제4조_구간_경계가_고시대로다(label, target, percent, flat) -> None: """제4조①1·2호 — 「5억 미만 **또는** 50억 이상」은 기초액이 없고, 그 사이만 기초액을 더함.""" result = _run(target / 2, target / 2) line = result.line("safety_management_cost_a") assert (line.rate_percent, line.flat_amount_krw) == (percent, flat), label assert result.amount("safety_management_cost") == _floor(target * percent / 100 + flat), label def test_제4조_단서_A와_B의_1점2배_중_작은_쪽() -> None: """단서 — 관급 포함(A)과 관급 제외 × 1.2(B) 를 견줘 **작은 쪽**. A·B 가 **서로 다른 구간**에 떨어지는 자리로 잼 — A 대상액 5억(2호) · B 대상액 4억(1호). """ result = _run( Decimal(200_000_000), Decimal(200_000_000), owner_supplied_material_krw=Decimal(100_000_000) ) want_a = _floor(Decimal(500_000_000) * Decimal("2.53") / 100 + 3_300_000) want_b = _floor(_floor(Decimal(400_000_000) * Decimal("3.15") / 100) * Decimal("1.2")) got = (result.amount("safety_management_cost_a"), result.amount("safety_management_cost_b")) assert got == (want_a, want_b) assert result.amount("safety_management_cost") == min(got) def test_1점2배는_호별_산정액을_먼저_원_단위로_맺은_뒤_곱한다() -> None: """⭐ 단서 문언 — 1.2배의 대상은 **1·2호로 산정이 끝난 금액**임(2026-09-14 고침). 골든셋 실증 — 영월 2024 B 줄이 종전 20,330,639(먼저 곱하고 한 번 버림) · 원본·고침 뒤 20,330,638. 끝자리가 살아 있는 밑수로 두 셈법이 갈리는 것을 여기서 잼. """ base = Decimal(123_456_789) # × 3.15 % = 3,888,888.85… — 버림 자리가 살아 있음 result = _run(base / 2, base / 2) raw = base * Decimal("3.15") / 100 assert _floor(raw) * Decimal("1.2") != raw * Decimal("1.2") # 두 셈법이 실제로 갈리는 밑수 assert result.amount("safety_management_cost_b") == _floor(_floor(raw) * Decimal("1.2")) def test_대상액은_직재_간재_직노_그리고_발주자_제공_재료다() -> None: """제2조2호 — 대상액 = 직접재료비 + 간접재료비 + 직접노무비(발주자 제공 재료 포함).""" plain = _run(Decimal(100_000_000), Decimal(100_000_000)) indirect = _run( Decimal(100_000_000), Decimal(100_000_000), indirect_material_krw=Decimal(50_000_000) ) owner = _run( Decimal(100_000_000), Decimal(100_000_000), owner_supplied_material_krw=Decimal(30_000_000) ) base = plain.line("safety_management_cost_a").base_amount_krw assert base == Decimal(200_000_000) assert indirect.line("safety_management_cost_a").base_amount_krw == base + 50_000_000 assert owner.line("safety_management_cost_a").base_amount_krw == base + 30_000_000 # 관급 제외 밑수(B)는 관급이 들어도 안 움직임. assert owner.line("safety_management_cost_b").base_amount_krw == base def _scaled(estimated_price: Decimal) -> object: """총공사금액(규모 기준액)만 갈아 끼워 돌림 — 대상액은 구간이 안 갈리게 작게 둠.""" return calculate_cost( CostInput( direct_material_krw=Decimal(5_000_000), direct_labor_krw=Decimal(5_000_000), direct_expense_krw=Decimal(0), enabled_items=("safety_management_cost",), estimated_price_krw=estimated_price, cut_basis="none", ) ) def test_제3조_총공사금액_2천만원_미만이면_줄이_안_선다() -> None: """제3조 — 「총공사금액 2천만 원 이상인 공사에 적용」. 하한은 요율 데이터가 들고 있음. ⚠ 우리가 견주는 값은 **규모 기준액**(설계자가 준 추정가격 · 없으면 수렴한 총원가)임. 고시의 「총공사금액」과 딱 같은 말은 아니나(관급·부가세 자리가 다름) 계산 차례상 안전관리비 앞에 설 수 있는 값이 그것뿐이라 같은 축으로 씀 — 보건관리자 문턱도 같은 축. """ minimum = json.load(io.open(RATES, encoding="utf-8"))["variables"]["rate_safety_pct"].get( "minimum_total_construction_amount_krw" ) assert minimum == 20_000_000, minimum assert not _scaled(Decimal(minimum) - 1).has("safety_management_cost") assert _scaled(Decimal(minimum)).has("safety_management_cost") # 안 서면 A·B 곁줄도 안 선다 — 0 원으로 채우지 않음. below = _scaled(Decimal(minimum) - 1) assert not below.has("safety_management_cost_a") and not below.has("safety_management_cost_b")