Files
Aislo/resources/tester/test_b09_safety_notice.py
eomsangdonandClaude Opus 5 5a016979c4 fix(b09): 안전관리비 제3조 적용 하한을 엔진이 읽게 함 · 1.2배 차례와 관급 ÷1.1 사유 명기
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
2026-09-14 19:22:08 +09:00

207 lines
9.7 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.
"""산업안전보건관리비 — **고시 원문과 직접 대조**(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")