Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1

This commit is contained in:
2026-09-14 17:01:15 +09:00
6 changed files with 218 additions and 23 deletions
@@ -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:
+8 -3
View File
@@ -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(
+16 -5
View File
@@ -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():
@@ -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
@@ -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:
+174
View File
@@ -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