Files
Aislo/B09_Estimation/B09_Estimation_QuantityDigits.py
eomsangdonandClaude Opus 5 3a452028d3 feat(B09): 수량 자릿수를 품셈 1-2-2 종목별로, 단위 불일치는 금액을 안 만듦
수량 자릿수 (품셈 1-2-2의 1)
- 화면이 모든 수량을 2자리로 찍고 있었음. 품셈은 종목별로 자리를 따로 정함 —
  체적합계·시멘트·철근 정수 / 돌쌓기·옹벽·떼 1자리 / 철강재 3자리.
- 수량은 반올림([주]①), 금액은 버림 — 반대라 모듈을 갈라 둠.
- 항목이 표를 이김([주]②) → override 받음. 표에 없는 종목은 안 건드림.
- 서버가 줄마다 quantity_digits 를 실어 보내고 화면은 그 자리로 찍기만 함.

금액 자리 보강 (품셈 1-2-2의 2)
- 설계서 총액 1,000원 미만 버림 · 일위대가 계금 1원 미만 버림 추가.
  금액란(0.1원)·소계(1원)와 자리가 넷 다 다름.

⚠ 단위 불일치 가드
- 돌쌓기(찰)이 연장 10m 로 오는데 일위대가는 ㎡당이라 52,938.9×10 = 529,389원이
  조용히 서 있었음. 면적으로 세면 1,381,753원 — 2.6배 적은 금액이었음.
- 단위가 다르면 곱하지 않고 사유를 남김. 표기 차이(㎥/m3·개소/개)는 흡수하되
  환산은 안 함. 내역 합계 6,252,172 → 5,048,775 (틀린 두 줄이 빠짐).

제잡비 밑수
- 닫힌 물음임을 원문 인용으로 못 박음 — 산림품셈 [주]③ 여섯 자리가 「노무비의
  합계액」이고, 일위대가 안에는 간접노무비가 없으므로 그 표의 노무비 합 =
  직접노무비. 하루에 두 번 뒤집힌 값이라 세 번째가 없게 근거를 남김.

시험 10건 추가, 238건 통과.

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

105 lines
5.1 KiB
Python

"""B09 원가계산 — **수량의 소수자리**는 종목마다 다르다 (산림품셈 1-2-2의 1).
**왜 있는가** — 지금까지 화면이 **모든 수량을 2자리로** 찍고 있었는데, 품셈은 종목별로
자리를 따로 정해 두었다. 체적합계·시멘트·철근은 **정수**, 돌쌓기·옹벽·떼는 **1자리**,
철강재는 **3자리**다. 2자리 고정은 그 어느 것도 아니다 (2026-09-08 지식DB 대조에서 드러남).
원문 = resources/knowledge/original/행정규칙/임도 품셈 적용기준
(현 산림사업 표준품셈)/첨부/(산림청고시 제2025-82호) 산림사업 표준품셈.md L575~660
요약 = resources/knowledge/technical_info/01_임도/04_수량분석정보/수량산출_일반.md §2
⚠ **수량은 반올림이다** ([주]① 「본 표에 따르며, **반올림**하여 적용한다」).
금액이 전부 **버림**인 것과 반대다 — 한 모듈에 섞어 두면 반드시 헷갈리므로
금액은 `B09_Estimation_Rounding` 이 따로 맡는다.
⚠ **항목이 표를 이긴다** ([주]② 「품셈 각 항목에서 제시한 소숫자리가 본 표와 상이할 경우
**항목에서 제시하는 소숫자리를 우선**」). 그래서 `override` 를 받는다. 다만 지금 공종
마스터에 「이 항목의 소수자리」를 담은 칸이 **아직 없다** — 그 칸이 생기면 여기로 흘리면 된다.
⚠ **모르는 종목은 손대지 않는다.** 규칙을 넓게 잡아 엉뚱한 줄까지 자르는 사고를 오늘만
여러 번 겪었다. 표에 없으면 `None` 을 돌려주고 **화면이 종전대로** 찍게 둔다 —
「모른다」가 보이는 편이 조용히 틀리는 것보다 낫다.
"""
from __future__ import annotations
from decimal import ROUND_HALF_UP, Decimal
#: (이름에 들어 있는 말, 그 단위) → 소수자리. **원문 표 순서 그대로** 옮겼다.
#: ⚠ 값을 고칠 일이 생기면 원문을 먼저 볼 것 — 여기서 고치면 근거가 끊긴다.
_DIGITS: tuple[tuple[tuple[str, ...], tuple[str, ...], int], ...] = (
# 종목 이름 후보 · 단위 후보 · 소수자리
(("공사연장", "노선연장"), ("m",), 0),
(("공사폭원", "노폭"), ("m",), 1),
(("직공인부",), ("인",), 2),
(("체적합계", "토적합계"), ("㎥", "m3"), 0),
(("떼", "평떼", "줄떼"), ("㎡", "m2"), 1),
(("모래", "자갈", "조약돌"), ("㎥", "m3"), 2),
(("견치돌", "깬돌"), ("㎡", "m2"), 1),
(("야면석",), ("㎥", "m3"), 1),
(("돌쌓기", "돌붙임"), ("㎥", "m3", "㎡", "m2"), 1),
(("사석",), ("㎥", "m3"), 1),
(("다듬돌", "절석", "판석"), ("개",), 2),
(("벽돌", "블록"), ("개",), 0),
(("시멘트",), ("kg", "㎏"), 0),
(("모르타르", "모르터"), ("kg", "㎏"), 2),
(("콘크리트",), ("㎥", "m3"), 2),
(("합판",), ("장",), 1),
(("말뚝",), ("개",), 0),
(("철강재", "강재"), ("kg", "㎏"), 3),
(("용접봉",), ("kg", "㎏"), 1),
(("철근",), ("kg", "㎏"), 0),
(("볼트", "너트", "꺽쇠"), ("개",), 0),
(("철선", "철사"), ("kg", "㎏"), 2),
(("못",), ("kg", "㎏"), 2),
(("화약",), ("kg", "㎏"), 3),
(("뇌관",), ("개",), 0),
(("도화선",), ("m",), 1),
(("수로연장",), ("m",), 1),
(("옹벽",), ("㎡", "m2"), 1),
(("도장", "칠하기"), ("㎡", "m2"), 1),
(("방수",), ("㎡", "m2"), 1),
(("보오링", "보링"), ("m",), 1),
)
#: 위 표에 이름이 안 걸릴 때 **단위만으로** 줄 수 있는 자리 — 품셈 「토적」 줄이 근거다.
#: 토적: 높이·너비 m 2 · 단면적 ㎡ 1 · 체적 ㎥ 2.
#: ⚠ **면적을 1자리로 내리지 않는다** — 「토적(단면적)」은 횡단면적을 말하는 것이라
#: 사면적·거푸집 면적까지 1자리로 자르면 틀린다. 확실한 ㎥ 만 잡는다.
_UNIT_ONLY = {"㎥": 2, "m3": 2}
def _tight(text: str) -> str:
return "".join(str(text or "").split())
def digits_for(name: str, unit: str, spec: str = "") -> int | None:
"""그 줄의 수량 소수자리. **표에 없으면 `None`** — 지어내지 않는다."""
haystack = _tight(name) + _tight(spec)
unit_tight = _tight(unit)
for words, units, digits in _DIGITS:
if unit_tight not in units:
continue
if any(word in haystack for word in words):
return digits
return _UNIT_ONLY.get(unit_tight)
def round_quantity(
value: Decimal,
name: str,
unit: str,
spec: str = "",
override: int | None = None,
) -> tuple[Decimal, int | None]:
"""수량을 그 종목의 자리로 **반올림**한다.
돌려주는 것 — (자른 값, 쓴 자리). 자리를 못 찾으면 **값을 안 건드리고** `(값, None)`.
`override` 는 품셈 **항목이 따로 제시한 자리**([주]②) — 표보다 우선한다.
"""
digits = override if override is not None else digits_for(name, unit, spec)
if digits is None:
return value, None
quantum = Decimal(1).scaleb(-digits)
return value.quantize(quantum, rounding=ROUND_HALF_UP), digits