Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1
This commit is contained in:
@@ -66,9 +66,10 @@ FIELD_HINTS: dict[str, str] = {
|
||||
),
|
||||
"overhead_class": "임도가 (주)공사·전문공사 어느 쪽인지 정한 규정 없음 — 기본 (주)공사",
|
||||
"cut_basis": (
|
||||
"기본 「안 함」 — 이윤을 자동으로 깎지 않는다는 합의(2026-09-08) · 참고: 실무 임도 3건"
|
||||
"(봉화·영월·거창) 모두 「총공사비 1,000원 미만 + 이윤 자동보정」을 씀 — 고르면 이윤 줄에"
|
||||
" 「절사 자동보정」으로 적힘"
|
||||
"기본 「총공사비 1,000원 미만 버림」 — 근거: 산림청고시 제2025-82호 「금액의 단위표준」"
|
||||
"(설계서의 총액 · 원 · 1,000 · 미만버림) · 실무 6건이 여섯 다 000 으로 끝남 ·"
|
||||
" 깎은 몫은 이윤 줄에 「절사 자동보정(산림청고시 2025-82호)」으로 적혀 조용히 안 깎임 ·"
|
||||
" 발주처가 달리 정하면 「절사 안 함」으로 끄고 이윤보정액을 직접 넣을 것"
|
||||
),
|
||||
"vat_mode": "실무 임도는 (0) 공급가액의 10% · 산림조합 형식은 계류보전(영덕) 1건",
|
||||
"private_material_position": (
|
||||
@@ -118,7 +119,12 @@ VAT_MODE_LABELS = {
|
||||
|
||||
|
||||
def choice_options() -> dict[str, list[dict[str, str]]]:
|
||||
"""10·11·12 선택지. 절사 「안 함」이 첫째 = 기본(이윤을 자동으로 안 깎음)."""
|
||||
"""10·11·12 선택지.
|
||||
|
||||
⚠ 절사 「안 함」 값은 **`none`** 임 — 빈 값이면 저장되지 않아(`clean_settings`)
|
||||
기본으로 되돌아가 **끌 수가 없다**.
|
||||
기본은 「총공사비에서 조정 · 1,000원 미만」(산림청고시 2025-82호).
|
||||
"""
|
||||
return {
|
||||
"employment_insurance_grade": _GRADE_OPTIONS,
|
||||
"retirement_mutual_aid_mode": [
|
||||
@@ -154,8 +160,15 @@ def choice_options() -> dict[str, list[dict[str, str]]]:
|
||||
],
|
||||
"overhead_class": [{"value": k, "label": v} for k, v in OVERHEAD_CLASSES.items()],
|
||||
"cut_basis": [
|
||||
{"value": "", "label": "절사 안 함(이윤보정액 입력)"},
|
||||
*({"value": k, "label": f"{v}에서 조정"} for k, v in CUT_BASES.items()),
|
||||
*(
|
||||
{
|
||||
"value": k,
|
||||
"label": f"{v}에서 조정"
|
||||
+ (" (기본 · 고시 2025-82호)" if k == "grand_total" else ""),
|
||||
}
|
||||
for k, v in CUT_BASES.items()
|
||||
),
|
||||
{"value": "none", "label": "절사 안 함(이윤보정액 직접 입력)"},
|
||||
],
|
||||
"cut_unit_krw": [{"value": str(u), "label": f"{u:,}원 미만"} for u in CUT_UNITS_KRW],
|
||||
"vat_mode": [{"value": k, "label": VAT_MODE_LABELS[k]} for k in VAT_MODES],
|
||||
|
||||
@@ -123,10 +123,15 @@ class CostInput:
|
||||
#: 12. 부가세 방식(`VAT_MODES`) · 산림조합-면세품이면 면세품 금액.
|
||||
vat_mode: str = "supply"
|
||||
tax_exempt_material_krw: Decimal = _ZERO
|
||||
#: 11. 절사 — `grand_total`(총공사비에서 조정) · `supply`(공급가액) · 빈 값은 안 자름.
|
||||
#: ⚠ 고른 때만 이윤을 자동보정한다 — 기본은 수동 이윤보정만(2026-09-08 합의).
|
||||
cut_basis: str = ""
|
||||
cut_unit_krw: int = 0
|
||||
#: 11. 절사 — `grand_total`(총공사비에서 조정) · `supply`(공급가액) · `none`·빈 값은 안 자름.
|
||||
#: ⭐ 2026-09-14 **기본이 켜짐** — 근거 산림청고시 제2025-82호 「금액의 단위표준」
|
||||
#: 「설계서의 총액 · 원 · 1,000 · 미만버림」.
|
||||
#: 실무 6건이 여섯 다 `000` 으로 끝나는 것이 증거.
|
||||
#: ⚠ 2026-09-08 「조용히 깎지 않음」 합의의 정신은 살린다 — 깎은 몫과
|
||||
#: **까닭(고시 번호)** 을 이윤 조정액 줄과 결과 메모에 적고,
|
||||
#: 설계자가 `none` 으로 **끌 수 있게** 둔다.
|
||||
cut_basis: str = "grand_total"
|
||||
cut_unit_krw: int = 1000
|
||||
#: 4. 고용보험 등급 — `auto`(추정금액 구간) · `none`(없음) · `1`~`7`(등급 직접).
|
||||
employment_insurance_grade: str = "auto"
|
||||
#: 5. 퇴직공제부금비 — `auto`(추정금액 1억 이상) · `apply`(적용) · `none`(미적용).
|
||||
@@ -360,8 +365,11 @@ def calculate_cost(data: CostInput) -> CostResult:
|
||||
)
|
||||
|
||||
|
||||
#: 절사 뒤 잔차 맞춤 반복 상한 — 실무 수식도 잔차 칸 한 번뿐(표본 넷 모두 0).
|
||||
#: 절사 뒤 잔차 맞춤 반복 상한. 실무 6건은 **두 걸음 안에** 앉음
|
||||
#: (다섯 건 한 걸음 · 울진 신설 두 걸음).
|
||||
_CUT_MAX_PASSES = 3
|
||||
#: 11. 절사 끔 — 고르개가 보내는 값. 빈 값도 같이 받는다(저장 안 된 옛 프로젝트).
|
||||
CUT_OFF = ("", "none")
|
||||
|
||||
|
||||
def _calculate_with_scale(
|
||||
@@ -370,32 +378,47 @@ def _calculate_with_scale(
|
||||
scale: Decimal,
|
||||
notes: list[str],
|
||||
) -> CostResult:
|
||||
"""규모 기준액을 못 박고 계산 — 11. 절사를 **고른 때만** 이윤을 보정해 다시 셈."""
|
||||
"""규모 기준액을 못 박고 계산 — 11. 절사가 켜져 있으면 이윤을 보정해 다시 셈.
|
||||
|
||||
⚠ **걸음마다 ÷1.1 을 해야 한다**(2026-09-14 고침). 이윤을 1원 깎으면 부가세가 따라 줄어
|
||||
총공사비는 **1.1원** 줄므로, 잔차를 그대로 빼면 경계를 **지나쳐** 진동한다 —
|
||||
울진 신설 실측 `654 → 999 → 900 → 910 → 909 …` 로 안 앉았다. 늘 ÷1.1 하면 `654 → 999 → 0`.
|
||||
"""
|
||||
result = _calculate_once(data, dataset, scale, notes)
|
||||
if not data.cut_basis or data.cut_unit_krw <= 0:
|
||||
if data.cut_basis in CUT_OFF or data.cut_unit_krw <= 0:
|
||||
return result
|
||||
key = "grand_total" if data.cut_basis == "grand_total" else "total_cost"
|
||||
automatic = _ZERO
|
||||
gap = _ZERO
|
||||
for _ in range(_CUT_MAX_PASSES):
|
||||
gap = cut_gap(result.totals[key], data.cut_unit_krw)
|
||||
if gap == 0:
|
||||
break
|
||||
# 첫 차례는 실무 식(총공사비·공급가액 비례 부가세면 ÷1.1), 그 뒤는 잔차 그대로.
|
||||
automatic += profit_cut(gap, data.cut_basis, data.vat_mode) if automatic == 0 else gap
|
||||
# 실무 식 — 총공사비 절사 + 부가세가 공급가액 비례면 ÷1.1(잔차 걸음도 같음).
|
||||
automatic += profit_cut(gap, data.cut_basis, data.vat_mode)
|
||||
adjusted = replace(
|
||||
data,
|
||||
profit_adjustment_krw=data.profit_adjustment_krw + automatic,
|
||||
cut_basis="",
|
||||
cut_basis="none",
|
||||
profit_adjustment_label=(
|
||||
f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만"
|
||||
" 절사 자동보정" + (" + 설계자 입력" if data.profit_adjustment_krw else "")
|
||||
" 절사 자동보정(산림청고시 2025-82호)"
|
||||
+ (" + 설계자 입력" if data.profit_adjustment_krw else "")
|
||||
),
|
||||
)
|
||||
result = _calculate_once(adjusted, dataset, scale, notes)
|
||||
result.notes.append(
|
||||
f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만 절사 —"
|
||||
f" 이윤에서 {automatic:,}원 자동보정(설계자가 고른 절사 기준)"
|
||||
f" 이윤에서 {automatic:,}원 자동보정."
|
||||
" 근거: 산림청고시 제2025-82호 「금액의 단위표준」(설계서의 총액 1,000원 미만버림) ·"
|
||||
" 기준 입력판 11 에서 끌 수 있음"
|
||||
)
|
||||
if gap:
|
||||
# 조용히 남기지 않는다 — 안 앉았으면 끝자리가 남았다는 것을 화면에 보인다.
|
||||
result.notes.append(
|
||||
f"⚠ 절사가 {_CUT_MAX_PASSES}걸음 안에 안 앉음 —"
|
||||
f" 끝자리 {gap:,}원이 남음(설계자 확인 필요)"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"dataset_id": "rates_2024_fixture",
|
||||
"effective_date": "2024-01-01",
|
||||
"_note": "검산 전용 픽스처 — 정본 아님. 울진 공통(2024) 공사원가계산서를 재현해 「연도별 요율 교체 구조가 실제로 도는가」를 시험한다. 값 출처는 그 서류의 관측 요율(PLAN 8-9)이며, 프로그램 기본값으로 쓰지 않는다.",
|
||||
"variables": {
|
||||
"rate_indirect_labor": {
|
||||
"base": "direct_labor_cost",
|
||||
"brackets": [
|
||||
{
|
||||
"direct_cost_bracket": "lt_1_billion",
|
||||
"duration_bracket": "lte_183_days",
|
||||
"work_type": "civil",
|
||||
"rate_percent": 14.5
|
||||
}
|
||||
]
|
||||
},
|
||||
"rate_sanjae": { "rate_percent": 3.56, "base": "total_labor_cost" },
|
||||
"rate_goyong": {
|
||||
"base": "total_labor_cost",
|
||||
"brackets": [
|
||||
{ "grade": "below_7", "estimated_amount_bracket": "below_official_threshold", "rate_percent": 1.01 }
|
||||
]
|
||||
},
|
||||
"rate_health": { "rate_percent": 3.545, "base": "direct_labor_cost" },
|
||||
"rate_care": { "rate_percent": 12.95, "base": "health_insurance_amount" },
|
||||
"rate_pension": {
|
||||
"base": "direct_labor_cost",
|
||||
"annual_rates": [{ "year": 2024, "rate_percent": 4.5 }]
|
||||
},
|
||||
"rate_safety_pct": {
|
||||
"base_with_owner_supplied_material": "minimum_of_statutory_two_formulas",
|
||||
"brackets": [
|
||||
{
|
||||
"target_amount_bracket": "500_million_to_5_billion",
|
||||
"work_type": "civil",
|
||||
"rate_percent": 1.86,
|
||||
"base_amount_krw": 5349000
|
||||
},
|
||||
{ "target_amount_bracket": "lt_500_million", "work_type": "civil", "rate_percent": 2.35 }
|
||||
]
|
||||
},
|
||||
"rate_other_expense": {
|
||||
"base": "material_cost_plus_total_labor_cost",
|
||||
"brackets": [
|
||||
{
|
||||
"direct_cost_bracket": "lt_1_billion",
|
||||
"duration_bracket": "lte_183_days",
|
||||
"work_type": "civil",
|
||||
"rate_percent": 6.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"rate_environment": {
|
||||
"base": "direct_construction_cost",
|
||||
"minimum_estimated_amount_krw": 100000000,
|
||||
"all_work_types": [{ "work_type": "civil_road", "rate_percent": 0.8 }]
|
||||
},
|
||||
"rate_equipment_payment_guarantee": {
|
||||
"general_construction": [{ "work_type": "civil_general", "rate_percent": 0.4 }],
|
||||
"specialty_construction": []
|
||||
},
|
||||
"rate_overhead": {
|
||||
"base": "material_plus_labor_plus_expense",
|
||||
"civil_landscape_industrial": [{ "estimated_price_bracket": "lt_5_billion", "rate_percent": 6.0 }]
|
||||
},
|
||||
"rate_profit": {
|
||||
"base": "labor_plus_expense_plus_overhead",
|
||||
"brackets": [{ "estimated_price_bracket": "lt_5_billion", "rate_percent": 15.0 }]
|
||||
},
|
||||
"rate_vat": { "rate_percent": 10.0, "base": "construction_cost_before_vat" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""B09 ⑤ 엔진 — 실무 서류 재현 검산 고정값 두 벌 + 거울 테스트 3종.
|
||||
|
||||
목적은 **구조 검증**이지 실무 금액 추종이 아니다 (PLAN 8-10 ★ 사용자 확정: 법대로).
|
||||
① **2024 요율** 로 돌리면 그 해 서류가 재현되는가 → 「연도 교체 구조가 실제로 도는가」
|
||||
② **현행 요율** 로 돌리면 규정대로 다른 값이 나오는가
|
||||
③ 안전관리비 A/B min 이 **양방향**으로 도는가 (울진 A 채택 · 거창 B 채택)
|
||||
|
||||
고정값 출처 = PLAN 8-9 · 8-10 · 8-12.
|
||||
|
||||
⚠ 2026-09-14 `tmp/tests/` 에서 **여기로 옮겨 옴**(브레인 지시) —
|
||||
시험은 git 안에 둬야 다른 창이 돌림. `tmp/tests/` 쪽 사본은 지우지 않음
|
||||
(tmp 는 손대지 않는 자리) — **같은 파일이 두 벌** 있음.
|
||||
⚠ 짝 벌과 **겹치는 자리** — `test_b09_golden_cost_sheet.py` 가 울진 2024 를
|
||||
**XLSX 시트에서** 되풀므로 ① 의 금액 사슬과 겹침. 여기는 **고정값**,
|
||||
저기는 **원본 칸**이라 되짚는 길이 달라 둘 다 둠(정리는 사용자 몫).
|
||||
⚠ 절사는 2026-09-14 부터 **기본이 켜짐**(산림청고시 2025-82호) — 「안 켠 값」을 보는 자리는
|
||||
`cut_basis="none"` 으로 손수 끈다.
|
||||
"""
|
||||
|
||||
import os
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from B09_Estimation.B09_Estimation_Engine_Cost import (
|
||||
CostInput,
|
||||
calculate_cost,
|
||||
proposed_profit_adjustment,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_Guards import (
|
||||
DoubleCountError,
|
||||
check_free_haul_not_priced,
|
||||
check_haul_volume_within_cut,
|
||||
check_mix_decomposed_once,
|
||||
check_surcharge_once,
|
||||
)
|
||||
|
||||
FIXTURE_2024 = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "fixtures", "rates_2024_uljin.json"
|
||||
)
|
||||
|
||||
|
||||
def _won(v) -> Decimal:
|
||||
return Decimal(str(v))
|
||||
|
||||
|
||||
# 울진 공통(2024) 원가계산서 입력 3종 — PLAN 8-9
|
||||
ULJIN_DIRECT_MATERIAL = _won(194_234_417)
|
||||
ULJIN_DIRECT_LABOR = _won(346_800_554)
|
||||
ULJIN_DIRECT_EXPENSE = _won(169_324_165)
|
||||
ULJIN_OWNER_MATERIAL = _won(69_474_220) # 순자재대 (수수료 375,160 별도)
|
||||
ULJIN_PROCUREMENT_FEE = _won(375_160)
|
||||
|
||||
|
||||
def _uljin(**overrides) -> CostInput:
|
||||
base = dict(
|
||||
direct_material_krw=ULJIN_DIRECT_MATERIAL,
|
||||
direct_labor_krw=ULJIN_DIRECT_LABOR,
|
||||
direct_expense_krw=ULJIN_DIRECT_EXPENSE,
|
||||
owner_supplied_material_krw=ULJIN_OWNER_MATERIAL,
|
||||
procurement_fee_krw=ULJIN_PROCUREMENT_FEE,
|
||||
duration_days=183,
|
||||
)
|
||||
base.update(overrides)
|
||||
return CostInput(**base)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# ① 2024 요율 재현 — 16/16 금액 사슬
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _uljin_2024() -> CostInput:
|
||||
"""그 해 서류를 그대로 재현하려면 그 해 관행 처리 둘을 켜야 한다.
|
||||
|
||||
2024 요율 파일과 그 해 연금 요율만 갈아끼우면 된다 — **관행 옵션은 필요 없다**.
|
||||
관급은 **순자재대 69,474,220 + 수수료 375,160** 으로 나눠 넣으면 관급자재대
|
||||
`ROUNDUP(69,849,380, −3) = 69,850,000` 과 안전관리비 관급항
|
||||
`69,474,220 ÷ 1.1` 이 둘 다 저절로 맞는다.
|
||||
"""
|
||||
return _uljin(
|
||||
rate_file_path=FIXTURE_2024,
|
||||
pension_year=2024,
|
||||
profit_adjustment_krw=_won(905_980),
|
||||
)
|
||||
|
||||
|
||||
def test_uljin_2024_chain_reproduced():
|
||||
"""2024 요율로 돌리면 그 해 원가계산서가 재현된다 — 금액 사슬 전건."""
|
||||
r = calculate_cost(_uljin_2024())
|
||||
|
||||
assert r.amount("indirect_labor_cost") == _won(50_286_080)
|
||||
assert r.amount("industrial_accident_insurance") == _won(14_136_284)
|
||||
assert r.amount("employment_insurance") == _won(4_010_575)
|
||||
assert r.amount("health_insurance") == _won(12_294_079)
|
||||
assert r.amount("long_term_care_insurance") == _won(1_592_083)
|
||||
assert r.amount("national_pension") == _won(15_606_024)
|
||||
assert r.amount("safety_management_cost") == _won(16_586_996)
|
||||
assert r.amount("other_expense") == _won(35_479_263)
|
||||
assert r.amount("environment_preservation") == _won(5_682_873)
|
||||
assert r.amount("equipment_payment_guarantee") == _won(2_841_436)
|
||||
|
||||
assert r.totals["expense"] == _won(277_553_778)
|
||||
assert r.totals["net_construction_cost"] == _won(868_874_829)
|
||||
assert r.amount("general_overhead") == _won(52_132_489)
|
||||
assert r.amount("profit_before_adjustment") == _won(109_015_935)
|
||||
assert r.amount("profit") == _won(108_109_955)
|
||||
assert r.totals["total_cost"] == _won(1_029_117_273)
|
||||
assert r.amount("vat") == _won(102_911_727)
|
||||
assert r.totals["contract_amount"] == _won(1_132_029_000)
|
||||
assert r.amount("owner_supplied_material_total") == _won(69_850_000)
|
||||
assert r.totals["grand_total"] == _won(1_201_879_000)
|
||||
|
||||
|
||||
def test_uljin_2024_safety_adopts_a():
|
||||
"""2024 울진은 A 채택 — A 16,586,996 < B 18,494,700."""
|
||||
r = calculate_cost(_uljin_2024())
|
||||
assert r.amount("safety_management_cost_a") == _won(16_586_996)
|
||||
assert r.amount("safety_management_cost_b") == _won(18_494_700)
|
||||
assert r.amount("safety_management_cost") == r.amount("safety_management_cost_a")
|
||||
|
||||
|
||||
def test_uljin_2024_item_list_comes_from_data():
|
||||
"""비목 목록은 코드가 아니라 **요율 데이터**가 정한다 (PLAN 8-13).
|
||||
|
||||
2024 픽스처에는 퇴직공제부금비가 없으므로 그 줄이 서지 않는다.
|
||||
"""
|
||||
r = calculate_cost(_uljin_2024())
|
||||
assert not r.has("retirement_mutual_aid")
|
||||
assert not r.has("wage_claim_contribution")
|
||||
assert r.has("environment_preservation")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# ② 현행 요율 — 같은 입력, 규정대로 다른 값
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_owner_supplied_input_is_net_material_not_total():
|
||||
"""⚠ 관급 입력은 **순자재대**다 — 실무 서류의 「관급자재대」를 그대로 넣으면 틀린다.
|
||||
|
||||
울진: 순자재대 69,474,220 + 수수료 375,160 = 69,849,380 → 천원 올림 **69,850,000**.
|
||||
안전관리비 관급항은 **순자재대만** 부가세 제외로 환산(69,474,220 ÷ 1.1).
|
||||
합계를 순자재대 자리에 넣으면 안전관리비가 8,629 원 커진다.
|
||||
"""
|
||||
right = calculate_cost(_uljin())
|
||||
wrong = calculate_cost(_uljin(owner_supplied_material_krw=_won(69_849_380)))
|
||||
|
||||
assert right.amount("owner_supplied_material_total") == _won(69_850_000)
|
||||
assert right.amount("safety_management_cost") == _won(18_586_091)
|
||||
assert wrong.amount("safety_management_cost") - right.amount("safety_management_cost") == _won(
|
||||
8_629
|
||||
)
|
||||
|
||||
|
||||
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 채택
|
||||
"""
|
||||
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") == _won(18_586_091)
|
||||
|
||||
|
||||
def test_year_swap_changes_result_but_not_structure():
|
||||
"""같은 입력·같은 코드, 요율만 갈아끼우면 값이 달라진다 — 교체 구조가 도는 증거."""
|
||||
old = calculate_cost(_uljin_2024())
|
||||
new = calculate_cost(_uljin())
|
||||
|
||||
assert old.amount("safety_management_cost") != new.amount("safety_management_cost")
|
||||
assert old.amount("indirect_labor_cost") != new.amount("indirect_labor_cost")
|
||||
assert old.rate_version["effective_date"] == "2024-01-01"
|
||||
assert new.rate_version["effective_date"] == "2026-04-13"
|
||||
# 픽스처는 지문이 없어 정본이 아님이 드러난다.
|
||||
assert old.rate_version["sha256"] == ""
|
||||
assert len(new.rate_version["sha256"]) == 64
|
||||
|
||||
|
||||
def test_current_rates_add_items_absent_in_2024():
|
||||
"""현행 요율 판에는 2024 에 없던 비목이 붙는다 (PLAN 8-14 사용자 「전부 필요」)."""
|
||||
r = calculate_cost(_uljin())
|
||||
for key in ("retirement_mutual_aid", "wage_claim_contribution", "asbestos_contribution"):
|
||||
assert r.has(key), key
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# ③ 안전관리비 A/B — 양방향
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_geochang_adopts_b():
|
||||
"""거창(2025) 실측 — B 채택. `안전관리비검토` 시트 원문 「적은금액 적용」."""
|
||||
r = calculate_cost(
|
||||
CostInput(
|
||||
direct_material_krw=_won(80_165_010),
|
||||
direct_labor_krw=_won(243_648_150),
|
||||
direct_expense_krw=_won(0),
|
||||
owner_supplied_material_krw=_won(74_634_214),
|
||||
enabled_items=("safety_management_cost",),
|
||||
)
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
def test_safety_formula_text_is_per_row():
|
||||
"""각 줄에 **제 산식**이 붙는다 — 실무 원문은 A 식을 B 줄에 복사한 오류가 있었다."""
|
||||
r = calculate_cost(_uljin_2024())
|
||||
a = r.line("safety_management_cost_a").formula_text
|
||||
b = r.line("safety_management_cost_b").formula_text
|
||||
assert a != b
|
||||
assert "× 1.2" in b and "× 1.2" not in a
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 이윤 조정 — 보여만 주고 적용은 명시로
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_proposed_adjustment_is_advice_only():
|
||||
"""목표 도급액을 주면 필요한 조정액을 계산해 보여 준다. 스스로 적용하지 않는다."""
|
||||
plain = calculate_cost(_uljin(rate_file_path=FIXTURE_2024, pension_year=2024, cut_basis="none"))
|
||||
suggested = proposed_profit_adjustment(plain, _won(1_132_029_000))
|
||||
assert suggested == _won(905_980)
|
||||
# 제안값을 받았다고 해서 결과가 바뀌지는 않는다.
|
||||
assert plain.amount("profit") == plain.amount("profit_before_adjustment")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 거울 테스트 3종 — 이중계상 감시 (PLAN 8-7 ㉠㉡㉢)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_guard_surcharge_double_detected():
|
||||
"""㉠ 자재총괄이 할증 전 값에 두 번 붙으면 잡힌다."""
|
||||
check_surcharge_once(
|
||||
material_summary_total=_won(110),
|
||||
unit_price_material_total=_won(100),
|
||||
surcharge_rate_percent=_won(10),
|
||||
)
|
||||
with pytest.raises(DoubleCountError):
|
||||
check_surcharge_once(
|
||||
material_summary_total=_won(121),
|
||||
unit_price_material_total=_won(100),
|
||||
surcharge_rate_percent=_won(10),
|
||||
)
|
||||
|
||||
|
||||
def test_guard_free_haul_must_not_be_priced():
|
||||
"""㉡ 무대 줄은 남기되 단가가 붙으면 잡힌다."""
|
||||
rows = [
|
||||
{"equipment": "free_haul", "unit_price_krw": 0},
|
||||
{"equipment": "dozer", "unit_price_krw": 827},
|
||||
]
|
||||
check_free_haul_not_priced(haul_rows=rows)
|
||||
rows[0]["unit_price_krw"] = 500
|
||||
with pytest.raises(DoubleCountError):
|
||||
check_free_haul_not_priced(haul_rows=rows)
|
||||
|
||||
|
||||
def test_guard_haul_volume_within_cut():
|
||||
"""㉡ 보조 — 운반토량 합이 절취량을 넘으면 잡힌다."""
|
||||
check_haul_volume_within_cut(haul_volume_total_m3=_won(9_000), total_cut_volume_m3=_won(10_044))
|
||||
with pytest.raises(DoubleCountError):
|
||||
check_haul_volume_within_cut(
|
||||
haul_volume_total_m3=_won(10_915), total_cut_volume_m3=_won(10_044)
|
||||
)
|
||||
|
||||
|
||||
def test_guard_mix_decomposed_once():
|
||||
"""㉢ 시멘트가 콘크리트 체적 × 배합비를 넘으면 잡힌다."""
|
||||
check_mix_decomposed_once(
|
||||
cement_total_kg=_won(3_200), concrete_volume_m3=_won(10), cement_per_m3_kg=_won(320)
|
||||
)
|
||||
with pytest.raises(DoubleCountError):
|
||||
check_mix_decomposed_once(
|
||||
cement_total_kg=_won(6_400), concrete_volume_m3=_won(10), cement_per_m3_kg=_won(320)
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 규모 구간 — 추정가격 반복 수렴 (국가계약법 시행령 제7조 1호)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_scale_converges_to_estimated_price():
|
||||
"""구간은 씨앗(직접공사비)이 아니라 **수렴한 추정가격**으로 판정된다.
|
||||
|
||||
울진은 직접공사비 7.1억 · 총원가 10.3억 — 둘 다 50억 미만이라 구간은 같지만,
|
||||
엔진이 씨앗에서 멈추지 않고 총원가로 다시 판정했는지는 아래로 확인한다.
|
||||
"""
|
||||
r = calculate_cost(_uljin())
|
||||
# 수렴이 끝났으면 「안 굳음」·「진동」 메모가 없다(절사 메모는 늘 붙는다).
|
||||
assert not [n for n in r.notes if "굳음" in n or "진동" in n], r.notes
|
||||
|
||||
|
||||
def test_explicit_estimated_price_skips_convergence():
|
||||
"""설계자가 추정가격을 명시하면 반복 없이 그 값으로 판정한다."""
|
||||
small = calculate_cost(_uljin(estimated_price_krw=_won(1_000_000_000)))
|
||||
big = calculate_cost(_uljin(estimated_price_krw=_won(6_000_000_000)))
|
||||
# 50억을 넘기면 일반관리비 8 % → 6.5 %, 이윤 15 % → 12 % 로 갈린다.
|
||||
assert small.line("general_overhead").rate_percent == Decimal("8.0")
|
||||
assert big.line("general_overhead").rate_percent == Decimal("6.5")
|
||||
assert small.line("profit_before_adjustment").rate_percent == Decimal("15.0")
|
||||
assert big.line("profit_before_adjustment").rate_percent == Decimal("12.0")
|
||||
|
||||
|
||||
def test_scale_crossing_bracket_is_resolved_by_convergence():
|
||||
"""씨앗은 50억 미만인데 총원가가 50억을 넘는 공사 — 구간이 재판정되어야 한다."""
|
||||
r = calculate_cost(
|
||||
CostInput(
|
||||
direct_material_krw=_won(900_000_000),
|
||||
direct_labor_krw=_won(2_400_000_000),
|
||||
direct_expense_krw=_won(1_400_000_000),
|
||||
duration_days=400,
|
||||
)
|
||||
)
|
||||
# 씨앗(직접공사비) 47억 → 8 % 구간. 총원가가 50억을 넘으면 6.5 % 로 내려간다.
|
||||
assert r.totals["direct_construction_cost"] < _won(5_000_000_000)
|
||||
assert r.totals["total_cost"] > _won(5_000_000_000)
|
||||
assert r.line("general_overhead").rate_percent == Decimal("6.5")
|
||||
assert not [n for n in r.notes if "굳음" in n or "진동" in n], r.notes
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
⚠ 금액을 박지 않는다 — 구조(어느 밑수·어느 표·끝자리)만 잰다. 실무 값은 식 한 조각만 씀:
|
||||
봉화 차액 373 → 이윤 339 · 영월 695 → 632 (÷1.1 반올림) · 영덕(산림조합-면세품) 996 → 996.
|
||||
⚠ 2026-09-14 — **11 절사 기본이 켜짐**(총공사비 1,000원 미만, 산림청고시 제2025-82호).
|
||||
「안 자른 값」을 보는 자리는 `cut_basis="none"` 으로 **손수 꺼서** 본다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -30,11 +32,17 @@ BASE = CostInput(
|
||||
)
|
||||
|
||||
|
||||
def test_기본값은_종전과_같다() -> None:
|
||||
def test_기본값은_주공사_공급가액_총공사비절사() -> None:
|
||||
"""10 주공사 · 12 공급가액 10% · **11 총공사비 1,000원 미만 절사**(2026-09-14 기본으로 켬)."""
|
||||
explicit = replace(
|
||||
BASE, overhead_class="civil_landscape_industrial", vat_mode="supply", cut_basis=""
|
||||
BASE,
|
||||
overhead_class="civil_landscape_industrial",
|
||||
vat_mode="supply",
|
||||
cut_basis="grand_total",
|
||||
cut_unit_krw=1000,
|
||||
)
|
||||
assert calculate_cost(explicit).totals == calculate_cost(BASE).totals
|
||||
assert calculate_cost(BASE).totals["grand_total"] % 1000 == 0
|
||||
|
||||
|
||||
def test_전문공사는_요율표만_바뀐다() -> None:
|
||||
@@ -93,7 +101,7 @@ def test_실무_절사_식() -> None:
|
||||
|
||||
@pytest.mark.parametrize("vat_mode", ["supply", "forest_coop"])
|
||||
def test_총공사비_천원_절사는_이윤에서_보정(vat_mode) -> None:
|
||||
plain = calculate_cost(replace(BASE, vat_mode=vat_mode))
|
||||
plain = calculate_cost(replace(BASE, vat_mode=vat_mode, cut_basis="none"))
|
||||
cut = calculate_cost(
|
||||
replace(BASE, vat_mode=vat_mode, cut_basis="grand_total", cut_unit_krw=1000)
|
||||
)
|
||||
@@ -107,20 +115,24 @@ def test_총공사비_천원_절사는_이윤에서_보정(vat_mode) -> None:
|
||||
|
||||
|
||||
def test_공급가액_절사는_차액_그대로() -> None:
|
||||
plain = calculate_cost(BASE)
|
||||
plain = calculate_cost(replace(BASE, cut_basis="none"))
|
||||
cut = calculate_cost(replace(BASE, cut_basis="supply", cut_unit_krw=10_000))
|
||||
assert cut.amount("total_cost") % 10_000 == 0
|
||||
assert plain.amount("profit") - cut.amount("profit") == plain.amount("total_cost") % 10_000
|
||||
|
||||
|
||||
def test_절사를_안_고르면_이윤을_안_건드린다() -> None:
|
||||
assert not calculate_cost(replace(BASE, cut_unit_krw=1000)).has("profit_adjustment")
|
||||
def test_절사를_끄면_이윤을_안_건드린다() -> None:
|
||||
"""고르개 「절사 안 함」 = `none`. ⚠ 빈 값도 같이 받는다(저장 안 된 옛 프로젝트)."""
|
||||
for off in ("none", ""):
|
||||
assert not calculate_cost(replace(BASE, cut_basis=off)).has("profit_adjustment"), off
|
||||
# 기준만 있고 단위가 0 이면 안 자른다.
|
||||
assert not calculate_cost(replace(BASE, cut_unit_krw=0)).has("profit_adjustment")
|
||||
|
||||
|
||||
def test_자동보정_줄은_제_이름을_적는다() -> None:
|
||||
cut = calculate_cost(replace(BASE, cut_basis="grand_total", cut_unit_krw=1000))
|
||||
assert "절사 자동보정" in cut.line("profit_adjustment").base_label
|
||||
manual = calculate_cost(replace(BASE, profit_adjustment_krw=Decimal(100)))
|
||||
manual = calculate_cost(replace(BASE, cut_basis="none", profit_adjustment_krw=Decimal(100)))
|
||||
assert manual.line("profit_adjustment").base_label == "설계자 명시 입력"
|
||||
|
||||
|
||||
|
||||
@@ -321,6 +321,9 @@ def test_원가계산서_총공사비까지_사슬_재현(tmp_path) -> None:
|
||||
울진 공통 905,980 — 예산 맞춤이라 절사 규칙으로는 안 나옴(자동절사는 525).
|
||||
조정액은 설계자 입력 칸이라 원본에서 읽어 넣음 — 이 벌이 재는 것은 **그 뒤 사슬**임.
|
||||
도급공사비 차는 **원본에만 있는 줄**로만 설명됨 — 영월 국가지점번호판검증수수료 762,000.
|
||||
|
||||
⚠ 절사는 2026-09-14 부터 **기본이 켜짐**(산림청고시 2025-82호) — 그래서 「안 켠 값」을 볼 때는
|
||||
`cut_basis="none"` 으로 **손수 꺼서** 봄. 자동절사 쪽은 기본값 그대로 돌림.
|
||||
"""
|
||||
sheets = _cost_sheets()
|
||||
if not sheets:
|
||||
@@ -329,7 +332,7 @@ def test_원가계산서_총공사비까지_사슬_재현(tmp_path) -> None:
|
||||
assert len(full) >= 3, [s[0] for s in full]
|
||||
chained, automatic, manual, misses = 0, [], [], []
|
||||
for index, (name, got, unknown) in enumerate(full):
|
||||
data = _cost_input(got, tmp_path / f"chain{index}.json")
|
||||
data = replace(_cost_input(got, tmp_path / f"chain{index}.json"), cut_basis="none")
|
||||
plain = calculate_cost(data)
|
||||
gap = plain.amount("profit_before_adjustment") - got["profit"][0]
|
||||
cut = calculate_cost(replace(data, cut_basis="grand_total", cut_unit_krw=1000))
|
||||
@@ -367,3 +370,28 @@ def test_원가계산서_총공사비까지_사슬_재현(tmp_path) -> None:
|
||||
assert len(automatic) == 2, automatic
|
||||
assert [g for _n, g in automatic] == [Decimal(339), Decimal(632)], automatic
|
||||
assert [g for _n, g in manual] == [Decimal(905_980)], manual
|
||||
|
||||
|
||||
def test_총공사비는_천원_미만_버림이_기본(tmp_path) -> None:
|
||||
"""⑤ 총액 절사 — 실무 6건이 **여섯 다** `000` 으로 앉음(2026-09-14 기본으로 켬).
|
||||
|
||||
근거 — 산림청고시 제2025-82호 「금액의 단위표준」 「설계서의 총액 · 원 · 1,000 · 미만버림」.
|
||||
끄면(`cut_basis="none"`) 여섯 다 끝자리가 남는 것으로 **절사가 실제로 도는지**를 가름.
|
||||
|
||||
⚠ 잔차 걸음도 ÷1.1 을 해야 앉음(2026-09-14 고침) — 그대로 빼면 울진 신설이
|
||||
`654 → 999 → 900 → 910 → 909 …` 로 진동해 끝자리가 남았음.
|
||||
"""
|
||||
sheets = _cost_sheets()
|
||||
if not sheets:
|
||||
pytest.skip("실무 원본 XLSX 가 없음")
|
||||
landed, left_over = [], []
|
||||
for index, (name, got, _unknown) in enumerate(sheets):
|
||||
data = _cost_input(got, tmp_path / f"cut{index}.json") # 기본값 그대로
|
||||
result = calculate_cost(data)
|
||||
landed.append((name, result.totals["grand_total"] % 1000))
|
||||
off = calculate_cost(replace(data, cut_basis="none"))
|
||||
left_over.append((name, off.totals["grand_total"] % 1000))
|
||||
assert any("2025-82호" in note for note in result.notes), result.notes
|
||||
assert not [n for n in result.notes if "안 앉음" in n], result.notes
|
||||
assert [tail for _n, tail in landed] == [Decimal(0)] * len(sheets), landed
|
||||
assert all(tail for _n, tail in left_over), left_over
|
||||
|
||||
@@ -42,8 +42,15 @@ MOVES = {
|
||||
|
||||
|
||||
def _run(waste: Decimal, separate: bool = False):
|
||||
# ⚠ 절사는 꺼서 본다 — 총공사비가 바뀌면 자동보정액도 따라 바뀌어 **구조**를 못 잰다
|
||||
# (2026-09-14 절사 기본 켜짐, 산림청고시 제2025-82호).
|
||||
return calculate_cost(
|
||||
CostInput(**BASE, waste_disposal_krw=waste, waste_separate_order=separate)
|
||||
CostInput(
|
||||
**BASE,
|
||||
waste_disposal_krw=waste,
|
||||
waste_separate_order=separate,
|
||||
cut_basis="none",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user