3. 제3조(적용범위) 「총공사금액 2천만 원 이상」 — 요율 데이터의
minimum_total_construction_amount_krw 를 규모 기준액과 견주고 미달이면 줄 자체를 안 만듦
(A·B 곁줄도 안 섬 · 0 원으로 안 채움). 잴 시험 먼저 빨강 확인한 뒤 고침.
⚠ 견주는 값은 규모 기준액(추정가격 · 없으면 수렴한 총원가) — 고시의 「총공사금액」과 딱 같은
말은 아니나 계산 차례상 안전관리비 앞에 설 수 있는 값이 그것뿐. 보건관리자 문턱과 같은 축.
1. 1.2배 차례 = STmate 쪽 확정(브레인 판정) — 안 고른 갈래의 사유를 코드에 남김.
거창 2025 원본은 버림(밑수 × 율 × 1.2) 로 1원 위지만 STmate 출력이 아니라 채택하지 않음.
4. 관급 ÷1.1 = 실무 따름 — 근거를 정확히 적음. 고시 제4조① 단서는 「해당 재료비를 대상액에
포함」까지만이고 부가세를 말하지 않음 · ÷1.1 은 부가세 제외 환산으로 실무 6건 공통.
전체 시험 1952 통과 · 28 건너뜀 · 1 xfail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
207 lines
9.7 KiB
Python
207 lines
9.7 KiB
Python
"""산업안전보건관리비 — **고시 원문과 직접 대조**(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")
|