Files
Aislo/B09_Estimation/B09_Estimation_Rounding.py
T
eomsangdonandClaude Opus 5 29cf61f4ad feat(B09): 일위대가 금액 0.1원 버림 — 자리 규칙 적용 + 끝자리 차이를 화면에 드러냄
- `OutputPlace.UNIT_PRICE_ROW` 추가 — **0.1원 미만 버림**(품셈 1-2-2 「일위대가 금액란
  0.1원 미만 버림」). 계산은 전정밀, **표 그리는 자리에서만** 자름(단수는 출력 위치에 붙음).
- **행 합계 = 자른 성분 셋의 합** — 그래야 표에서 `TC = NC+GC+JC` 가 섬. 전정밀 합을
  따로 자르면 성분과 합계가 어긋나 보임. 표 전체 합계도 **행별로 자른 값을 더함**
  (`단수처리_규칙.md` §2 「행별 처리(합계 후 아님)」).
- ⚠ **행별 절사로 생기는 끝자리 차이를 숨기지 않음** — 화면에 「행별로 0.1원 미만을 버려
  합계 끝자리가 다릅니다 (정상). 자르기 전 합계: …」를 띄움. 숨기면 나중에 「합계가 안
  맞는다」며 계산을 고치려 듦.

**화면 실측(5174)** — 일위대가 탭 → 목록 64건 → 「제근」 본표 →
`▸ 굴착기(무한궤도) 0.2 | 기계경비(105) | hr | 0.8 | 8,936.5 | 28,329.7 | 11,098.5 | 48,364.7`
→ 기계 줄 클릭 → **시간당 사용료 본표로 파고듦**(손료 13,873.1 · 경유 11,170.6 ·
건설기계운전사 35,412.1 = 60,455.9). 합계 98,292.9 / 자르기 전 98,293.2 표시.
산출 요약 2줄(자재 미확보로 구조물 계열 안 섬)도 탭 상단에 뜸.

⚠ 검증 함정 기록 — 해시 라우트에 쿼리를 붙일 때 `#/b09-estimation?v=2` 는 라우트가
안 잡힘. **`?v=2#/b09-estimation` 처럼 해시 앞에** 붙여야 함.

자체검증 — `pytest tmp/tests/ -q` 116 passed · ruff 통과 · tsc(B09) 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 23:23:09 +09:00

74 lines
3.4 KiB
Python

"""B09 원가계산 — 단수 처리는 **출력 위치**에 붙는다.
`resources/knowledge/technical_info/01_임도/05_원가정보/단수처리_규칙.md` §1 관측:
같은 「수량 × 단가」라도 **내역서 본체는 절사(ROUNDDOWN), 집계표는 반올림(ROUND)** 이다.
즉 단수 함수는 **항목이 아니라 표 종류에 바인딩**된다.
그래서 이 모듈이 있는 자리 —
- **계산 함수 안에서 자르지 않는다.** 계산은 전정밀 `Decimal` 로 내고,
**표를 그리는 자리에서** 이 모듈의 함수로 자른다.
- ⚠ **집계표(반올림)와 본체(절사)를 더하면 합이 1원 단위로 어긋나는 것이 정상**이다.
나중에 「합계가 안 맞는다」는 지적이 반드시 나오는데, **그때 계산을 고치면 안 된다.**
어긋남 자체가 규칙이다.
값은 원문에서 복사하지 않는다 — 자릿수·함수만 여기 두고 근거는 위 문서를 가리킨다.
"""
from __future__ import annotations
from decimal import ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_UP, Decimal
from enum import Enum
_ONE = Decimal(1)
_THOUSAND = Decimal(1000)
class OutputPlace(str, Enum):
"""숫자가 찍히는 자리. 자리마다 단수 함수가 다르다."""
#: 설계내역서 행 금액(수량×단가) — 절사
BOQ_ROW = "boq_row"
#: 제경비 각 항목(밑수×율) — 행별 절사
OVERHEAD_ROW = "overhead_row"
#: 조달수수료(관급×율) — 절사
PROCUREMENT_FEE = "procurement_fee"
#: 자원 집계표(재료·노무·경비·중기) — **반올림**
RESOURCE_SUMMARY = "resource_summary"
#: 관급자재대 총액 — **천원 올림**
OWNER_MATERIAL_TOTAL = "owner_material_total"
#: 일위대가표 금액란 — **0.1원 미만 버림** (품셈 1-2-2 「일위대가 금액란 0.1원 미만 버림」)
UNIT_PRICE_ROW = "unit_price_row"
def round_at(value: Decimal, place: OutputPlace) -> Decimal:
"""그 자리의 규칙대로 자른다.
자리를 안 대고 부르는 길을 두지 않는다 — 기본값을 두면 어느 자리인지 모른 채
아무 함수나 쓰게 된다.
"""
if place in (OutputPlace.BOQ_ROW, OutputPlace.OVERHEAD_ROW, OutputPlace.PROCUREMENT_FEE):
return value.quantize(_ONE, rounding=ROUND_FLOOR)
if place is OutputPlace.RESOURCE_SUMMARY:
return value.quantize(_ONE, rounding=ROUND_HALF_UP)
if place is OutputPlace.UNIT_PRICE_ROW:
return value.quantize(Decimal("0.1"), rounding=ROUND_FLOOR)
if place is OutputPlace.OWNER_MATERIAL_TOTAL:
return (value / _THOUSAND).quantize(_ONE, rounding=ROUND_CEILING) * _THOUSAND
raise ValueError(f"단수 처리 자리를 모릅니다: {place}")
#: 집계표와 본체를 나란히 보일 때 화면 비고에 다는 문구.
#: 「합이 1원 안 맞는다」는 지적에 계산을 고치지 않게 하려는 것이다.
SUMMARY_MISMATCH_NOTE = (
"집계표는 반올림, 내역서 본체는 절사 — 두 표의 합이 원 단위로 어긋나는 것은 정상입니다."
)
def summary_vs_body_gap(summary_total: Decimal, body_total: Decimal) -> Decimal:
"""집계표 합계와 내역서 본체 합계의 차이.
**0 이 아닌 것이 정상**이다. 화면에 그 차이를 숨기지 않고 보여, 설계자가
「어긋남이 규칙임」을 알고 넘어가게 한다.
"""
return summary_total - body_total