Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user