fix(b09): 총액 1,000원 미만 버림을 기본으로 켬 · 절사 자동보정 진동 고침

근거 — 산림청고시 제2025-82호 「금액의 단위표준」 「설계서의 총액 · 원 · 1,000 · 미만버림」.
실무 6건 원가계산서가 여섯 다 000 으로 끝나는 것이 증거.

· 기본 켬 — cut_basis="grand_total" · cut_unit_krw=1000
· 조용히 안 깎음(2026-09-08 합의의 정신) — 이윤 조정액 줄과 결과 메모에 고시 번호를 달고
  기준 입력판 11 에서 끌 수 있음을 함께 적음 · 칸 밑 안내도 고쳐 씀
· 끄는 값을 none 으로 바꿈 — 빈 값은 저장이 안 돼 기본으로 되돌아가 끌 수가 없었음
· 진동 고침 — 걸음마다 ÷1.1(종전엔 첫 걸음만). 이윤 1원이 총공사비 1.1원이라
  잔차를 그대로 빼면 경계를 지나침(울진 신설 654→999→900→910→909…)
· 안 앉으면 「끝자리 N원이 남음」을 메모로 보임
· 실무 6건 전수 000 판정을 새 벌로 지킴

곁 — tmp/tests/test_b09_cost_fixtures.py 를 resources/tester/ 로 옮김(픽스처 포함).
     tmp 쪽 사본은 안 지움 · 겹침은 파일 머리에 적음.
     기본이 바뀌어 깨진 남의 시험 6건은 「안 자른 값」 자리에 cut_basis="none" 을 넣어 고침.

전체 시험 1782 통과 · 28 건너뜀.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
This commit is contained in:
2026-09-14 09:40:24 +09:00
co-authored by Claude Opus 5
parent f07ff0033f
commit aeb18266bd
7 changed files with 510 additions and 27 deletions
+35 -12
View File
@@ -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