refactor(B08,B09): 폴더째 old_code 로 옮기고 빈 화면 둘만 남김 (PLAN 7-3)

B08_Quantity 86 · B09_Estimation 111 파일을 old_code/ 로 옮김(지우지 않음).
화면은 메뉴·주소·단계 막대만 남은 빈 틀 둘 — main.py 라우터 13 개는 끊음.
B07 이 빌려 쓰던 비탈 길이·면적은 필요한 함수만 B07_DesignDetail_Engine_SlopeGeometry
로 옮겨 적음(면적 적분·노면 면적·측점 묶음은 안 옮김) · 시험 하나를 새로 둠.
B07 구조물도 조립(Cad_StandardSheet)은 2026-09-13 에 이미 도면 목록에서 빠져
부르는 곳이 없어 old_code 로 같이 보냄 — 구조물 그림은 되살리지 않음.
B06 구조물 몫 조회는 빈 값으로 두어 화면이 그대로 서게 함.
B08·B09 를 부르던 시험 25 개도 old_code/resources/tester 로 옮김.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
2026-09-22 12:27:45 +09:00
co-authored by Claude Opus 5
parent 185f800d23
commit 8472fc9f40
231 changed files with 1617 additions and 1216 deletions
@@ -0,0 +1,130 @@
"""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` 를 받는다. 다만 지금 공종
마스터에 「이 항목의 소수자리」를 담은 칸이 **아직 없다** — 그 칸이 생기면 여기로 흘리면 된다.
⚠ **표에 없는 종목은 우리 기본 소수 2자리**(2026-09-14 브레인 판정 — 종전 「손대지 않음」 폐기).
수량을 이 자리로 **먼저 확정한 뒤** 금액을 셈 — [주]① 이 시킨 차례이고, 명세의 「Q 를 소수 2자리로
먼저 확정한 뒤 나눔」과 같은 원칙(내역 줄 `BillOfQuantities_Rows.settle_quantity`).
못 맞춘 이름은 종전대로 로그로 드러냄.
"""
from __future__ import annotations
import logging
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),
# 같은 줄의 단위 환산 — 원문 「철근 kg 정수」 = ton 소수 3자리. STmate 실무 원본 영월·울진
# 설계내역서 철근·철근운반 Ton 0.264·0.008 이 그 자리(2026-09-14 골든셋 대조 · 원문 값 아님).
(("철근",), ("ton", ""), 3),
(("볼트", "너트", "꺽쇠"), ("",), 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}
#: 표에도 단위에도 안 걸리는 줄의 자리 — 우리 기본(브레인 판정 2026-09-14).
DEFAULT_DIGITS = 2
logger = logging.getLogger(__name__)
#: 못 맞춘 줄을 이미 알린 (이름, 단위) — 내역을 그릴 때마다 같은 로그가 쌓이지 않게.
_UNMATCHED_LOGGED: set[tuple[str, str]] = set()
def _tight(text: str) -> str:
return "".join(str(text or "").split())
def digits_for(name: str, unit: str, spec: str = "") -> int:
"""그 줄의 수량 소수자리. 표에 없으면 단위(㎥ 2) → 그것도 없으면 우리 기본 2.
⚠ 이름으로 종목을 찾는다 — 품셈 1-2-2 표 자체가 종목 이름 표라 코드로 옮길 대응표가 없음
(금액이 아닌 표시 자리). **이름을 못 맞춘 줄은 로그**로 남긴다 — 마스터 갈래 키(1장)가
서면 함께 정리함(2026-09-13 브레인 판정: 이름은 그대로 두고 못 맞춘 것만 드러냄).
"""
haystack = _tight(name) + _tight(spec)
# 단위 대소문자는 뜻이 같음(실무 원본 「M3」·「TON」·「Ton」) — 표기만 흡수.
unit_tight = _tight(unit).lower()
for words, units, digits in _DIGITS:
if unit_tight not in units:
continue
if any(word in haystack for word in words):
return digits
key = (_tight(name), unit_tight)
if key not in _UNMATCHED_LOGGED:
_UNMATCHED_LOGGED.add(key)
fallback = _UNIT_ONLY.get(unit_tight)
logger.info(
"B09 수량 자리 — 종목 이름을 못 맞춤: 「%s%s%s",
name,
unit,
"단위만으로 %s자리" % fallback
if fallback is not None
else "기본 %s자리" % DEFAULT_DIGITS,
)
return _UNIT_ONLY.get(unit_tight, DEFAULT_DIGITS)
def round_quantity(
value: Decimal,
name: str,
unit: str,
spec: str = "",
override: int | None = None,
) -> tuple[Decimal, int | None]:
"""수량을 그 종목의 자리로 **반올림**한다.
돌려주는 것 — (자른 값, 쓴 자리).
`override` 는 품셈 **항목이 따로 제시한 자리**([주]②) — 표보다 우선한다(마스터 칸 대기).
"""
digits = override if override is not None else digits_for(name, unit, spec)
quantum = Decimal(1).scaleb(-digits)
return value.quantize(quantum, rounding=ROUND_HALF_UP), digits