diff --git a/B09_Estimation/B09_Estimation_CostSheet_Forms.py b/B09_Estimation/B09_Estimation_CostSheet_Forms.py index fbb770a3..e03d5ea5 100644 --- a/B09_Estimation/B09_Estimation_CostSheet_Forms.py +++ b/B09_Estimation/B09_Estimation_CostSheet_Forms.py @@ -6,7 +6,8 @@ ⚠ **금액을 안 낸다** — 요율 데이터가 없다(2026-09-14 확인: 수공 세부 경비 율표 `RateB` · 표준시장단가 제비율 `RATE_RA`·`RATE_RA1`·실적 안전표 `An_A`). 줄마다 무엇이 막혔는지만 적는다. **0 원으로 채우지 않는다** — 0 원은 「없다」가 아니라 「공짜」로 읽혀 총액이 조용히 작아진다. -⚠ 35번 §5 미확인(실적형 이윤 밑수 · 실적수공 안전관리비 존재)은 「확인 대기」로 세운다. +⚠ 35번 §5 미확인은 「확인 대기」로 세운다 — 지금 남은 것은 **실적형 이윤 밑수** 하나다. + 실적수공 안전관리비는 2026-09-14 코덱스가 STmate 원문에서 **있음**을 확인해 별도내역으로 내렸다. """ from __future__ import annotations @@ -21,7 +22,6 @@ RATE_RA = "요율표 미확보 — 표준시장단가(실적공사비) 제비율 RATE_AN_A = "요율표 미확보 — 실적 산업안전보건관리비 표(STmate An_A·An_A1)" TOTAL = "합계 — 위 줄이 다 서야 섬" PENDING_PROFIT = "확인 대기 — 실적형 이윤 밑수가 복원식과 화면 문구가 충돌(35번 §5-1)" -PENDING_SAFETY = "확인 대기 — 실적수공에 안전관리비가 실제로 있는지 미확인(35번 §5-2)" DIRECT = "설계내역서 합계에서 옴" _SUGONG_EXPENSES = ( @@ -115,7 +115,8 @@ def _actual(sugong: bool) -> list[dict[str, Any]]: _r("6)", "연금보험료", "직접노무비 × 율", COMMON, 1), _r("7)", "퇴직공제부금비", "직접노무비 × 율", COMMON, 1), _r("8)", "산업안전보건관리비", "(직접공사비+도급자관급자재) × 율", RATE_AN_A, 1), - _r("9)", "안전관리비", "", PENDING_SAFETY if sugong else SEPARATE, 1), + # TA·SA 둘 다 별도내역 — SA 도 있음이 확인됨(2026-09-14 코덱스, 35번 §5-2 풀림). + _r("9)", "안전관리비", "", SEPARATE, 1), _r("10)", "품질관리비", "", SEPARATE, 1), ] if sugong: diff --git a/B09_Estimation/B09_Estimation_Statutory.py b/B09_Estimation/B09_Estimation_Statutory.py index 2351b0b3..f8712276 100644 --- a/B09_Estimation/B09_Estimation_Statutory.py +++ b/B09_Estimation/B09_Estimation_Statutory.py @@ -194,6 +194,8 @@ def safety_management_cost( 두 대상액이 **다른 구간에 떨어질 수 있어** A 가 항상 작지는 않다. 두 줄을 다 남겨 화면이 나란히 보이게 한다(실무 `안전관리비검토` 시트와 같은 서식). """ + from B09_Estimation.B09_Estimation_Engine_Cost import floor_won + variable = dataset.variable("rate_safety_pct") brackets = variable["brackets"] @@ -248,7 +250,12 @@ def safety_management_cost( ) percent = rate_percent(row, label=label) flat = base_amount(row) - return (base * percent / _HUNDRED + flat) * multiplier, percent, flat + # ⭐ 2026-09-14 고침 — **호별 산정액을 먼저 원 단위로 맺고** 1.2 를 곱한다. + # 고시 제4조① 단서 「… 대상액에서 제외하고 **산출한 산업안전보건관리비**의 1.2배」 — + # 1.2배의 대상은 1·2호로 **산정이 끝난 금액**이다. 종전엔 1.2 를 곱한 뒤 한 번만 버려 + # 영월 2024 B 줄이 20,330,639 로 원본(20,330,638)보다 1원 컸다(골든셋 실증). + # A(배수 1)는 어느 차례로 해도 같은 값이다. + return floor_won(base * percent / _HUNDRED + flat) * multiplier, percent, flat raw_a, percent_a, flat_a = evaluate(base_with, Decimal(1), "안전관리비 A(관급 포함)") raw_b, percent_b, flat_b = evaluate( @@ -257,8 +264,6 @@ def safety_management_cost( # 어느 쪽이 채택인지 먼저 정해 두 줄에 표시를 단다 — 화면이 나란히 보이고 # 채택 줄이 눈에 띄어야 한다(PLAN 8-12 실무 `안전관리비검토` 시트 서식). - from B09_Estimation.B09_Estimation_Engine_Cost import floor_won - adopted = "A" if floor_won(raw_a) <= floor_won(raw_b) else "B" amount_a = emit( diff --git a/resources/tester/test_b09_cost_fixtures.py b/resources/tester/test_b09_cost_fixtures.py index 42014b49..b8954238 100644 --- a/resources/tester/test_b09_cost_fixtures.py +++ b/resources/tester/test_b09_cost_fixtures.py @@ -158,11 +158,14 @@ def test_uljin_current_rates_safety_18_586_091(): """현행 요율로 돌리면 안전관리비가 18,586,091 이 된다 (PLAN 8-10). A 대상액 604,193,353 × 2.53 % + 3,300,000 = 18,586,091 - B 대상액 541,034,971 × 3.15 % × 1.2 = 20,385,821 → A 채택 + B 버림(대상액 541,034,971 × 3.15 %) × 1.2 = 20,385,820 → A 채택 + + ⚠ 2026-09-14 B 가 1원 내려감 — 1.2배의 대상을 **호별 산정이 끝난 금액**으로 바꿨음 + (고시 제4조① 단서 문언 · STmate 영월 실증). 여기 값은 현행 요율이라 실무 대조본이 없음. """ r = calculate_cost(_uljin()) assert r.amount("safety_management_cost_a") == _won(18_586_091) - assert r.amount("safety_management_cost_b") == _won(20_385_821) + assert r.amount("safety_management_cost_b") == _won(20_385_820) assert r.amount("safety_management_cost") == _won(18_586_091) @@ -193,7 +196,15 @@ def test_current_rates_add_items_absent_in_2024(): def test_geochang_adopts_b(): - """거창(2025) 실측 — B 채택. `안전관리비검토` 시트 원문 「적은금액 적용」.""" + """거창(2025) 실측 — B 채택. `안전관리비검토` 시트 원문 「적은금액 적용」. + + ⚠⚠ **두 실증본이 갈림**(2026-09-14) — 1.2배를 언제 곱하나. + STmate(영월 2024) `버림(밑수 × 율) × 1.2` 뒤 버림 → 영월 B 20,330,638 = 원본 + 거창 원본(다른 프로그램) `버림(밑수 × 율 × 1.2)` → 거창 B 12,240,137 + 거창 원가계산서는 시트 이름이 「원가계산서」로 **STmate 출력이 아님**(줄 차례도 다름). + 우리 목표가 STmate 골든셋 재현이라 **STmate 쪽으로 맞춤** — 그래서 거창 B 가 1원 내려감. + ⇒ 발주처가 거창 쪽 셈을 요구하면 갈래를 둬야 함. **판정 대기(브레인)**. + """ r = calculate_cost( CostInput( direct_material_krw=_won(80_165_010), @@ -204,8 +215,8 @@ def test_geochang_adopts_b(): ) ) assert r.amount("safety_management_cost_a") == _won(12_337_367) - assert r.amount("safety_management_cost_b") == _won(12_240_137) - assert r.amount("safety_management_cost") == _won(12_240_137) + assert r.amount("safety_management_cost_b") == _won(12_240_136) # 거창 원본은 12,240,137 + assert r.amount("safety_management_cost") == _won(12_240_136) def test_safety_formula_text_is_per_row(): diff --git a/resources/tester/test_b09_cost_sheet_forms.py b/resources/tester/test_b09_cost_sheet_forms.py index 2919a7f4..adc2035f 100644 --- a/resources/tester/test_b09_cost_sheet_forms.py +++ b/resources/tester/test_b09_cost_sheet_forms.py @@ -15,9 +15,9 @@ sys.path.insert(0, str(ROOT)) from B09_Estimation.B09_Estimation_CostSheet_Forms import ( # noqa: E402 PENDING_PROFIT, - PENDING_SAFETY, RATE_B, RATE_RA, + SEPARATE, form_rows, ) @@ -37,11 +37,16 @@ def test_수공은_세부_경비_여덟이_율표_미확보() -> None: assert "기타경비" not in [row["label"] for row in rows] -def test_실적형은_이윤이_확인_대기_실적수공은_안전관리비도() -> None: +def test_실적형은_이윤만_확인_대기_안전관리비는_둘_다_별도내역() -> None: + """SA 안전관리비 「확인 대기」를 **별도내역**으로 내림 — + 코덱스가 STmate 원문에서 확인(2026-09-14). + + TA 는 처음부터 별도내역이었음. 요율이 아니라 따로 산출한 금액이라 **금액엔 안 닿음**. + """ general = {row["label"]: row["note"] for row in form_rows("actual_general")} sugong = {row["label"]: row["note"] for row in form_rows("actual_sugong")} assert general["이 윤"] == PENDING_PROFIT and sugong["이 윤"] == PENDING_PROFIT - assert sugong["안전관리비"] == PENDING_SAFETY + assert general["안전관리비"] == SEPARATE and sugong["안전관리비"] == SEPARATE assert general["간접노무비"] == RATE_RA and general["기타경비"] == RATE_RA diff --git a/resources/tester/test_b09_golden_cost_sheet.py b/resources/tester/test_b09_golden_cost_sheet.py index 853f3076..95150ace 100644 --- a/resources/tester/test_b09_golden_cost_sheet.py +++ b/resources/tester/test_b09_golden_cost_sheet.py @@ -264,14 +264,13 @@ def test_원가계산서_법정경비_줄마다_실무_원본_재현(tmp_path) - · 요율 데이터가 다름 → 요율을 원본에서 뽑아 쓰므로 **안 생김** · 원본이 식을 안 보임 → 비고 빈 줄(건강·노인장기·연금·산업안전). 앞 차수 값을 그대로 물려 적은 자리라 되풀 수 없음 — 울진 신설·영덕·소광 각 4줄, 세어만 두고 대조 밖. - · 절사 자리가 다름 → 안전관리비 **B 줄** 1원(영월). 아래 `rounding` 에 따로 담음. + · 절사 자리가 다름 → 아래 `rounding` 에 따로 담음. **지금은 비어 있어야 함**. - ⭐ **고칠 자리 하나 — 안전관리비 B 의 절사 자리**(2026-09-14 실측, 브레인 보고 대상). - 우리 엔진 `버림((밑수 × 율 + 기초액) × 1.2)` 영월 20,330,639 - STmate `버림(밑수 × 율 + 기초액) × 1.2` 뒤 버림 영월 20,330,638 - 여섯 원본 중 영월에서만 갈림(봉화·울진은 두 셈법이 같은 값). - **미채택 줄이라 총액엔 안 닿음** — - 영월도 A 를 채택함. 이 벌은 재기만 하므로 엔진은 그대로 두고 차를 세어만 둠. + ⭐ **안전관리비 B 의 절사 자리 — 2026-09-14 실증본 쪽으로 맞춤**(브레인 배정). + 고침 전 `버림((밑수 × 율 + 기초액) × 1.2)` 영월 20,330,639 + 고침 뒤 `버림(밑수 × 율 + 기초액) × 1.2` 뒤 버림 영월 20,330,638 = 원본 + 고시 제4조① 단서 문언도 이쪽임 — 1.2배의 대상은 **1·2호로 산정이 끝난 금액**임. + 여섯 원본 중 영월에서만 갈렸음(봉화·울진은 두 셈법이 같은 값). """ sheets = _cost_sheets() if not sheets: @@ -308,8 +307,8 @@ def test_원가계산서_법정경비_줄마다_실무_원본_재현(tmp_path) - assert checked >= 54, checked assert blank == 12, blank # 원본이 식을 안 보인 줄 — 3건 × 4줄 assert not misses, misses - # 절사 자리 차 — 지금 아는 것은 영월 안전관리비 B 한 줄뿐. 늘면 여기서 드러남. - assert len(rounding) == 1 and "영월" in rounding[0][0], rounding + # 절사 자리 차 — 실증본 쪽으로 맞춘 뒤로는 **하나도 없어야** 함(영월 B 1원이 마지막이었음). + assert not rounding, rounding def test_원가계산서_총공사비까지_사슬_재현(tmp_path) -> None: diff --git a/resources/tester/test_b09_safety_notice.py b/resources/tester/test_b09_safety_notice.py new file mode 100644 index 00000000..1efb0942 --- /dev/null +++ b/resources/tester/test_b09_safety_notice.py @@ -0,0 +1,174 @@ +"""산업안전보건관리비 — **고시 원문과 직접 대조**(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