**단수 처리를 「출력 위치」에 바인딩** (`B09_Estimation_Rounding.py`) - `단수처리_규칙.md` §1 — 같은 수량×단가라도 **내역서 본체는 절사, 집계표는 반올림**. 함수가 항목이 아니라 **표 종류**에 붙음. 자리를 안 대고 부르는 길을 안 둠(기본값 없음). - ⚠ **집계표와 본체의 합이 어긋나는 것이 정상** — 「합계가 1원 틀린다」에 계산을 고치지 않도록 `summary_vs_body_gap()` 으로 차이를 값으로 드러내고 화면 문구를 상수로 둠. - 계산은 전정밀, 자르는 것은 표 그리는 자리에서. 값은 원문에서 복사 안 하고 문서를 가리킴. **단가 계층 — 표를 세 벌 만들지 않음** (`B09_Estimation_PriceBook.py`) - 「제목 + 상세」 한 쌍 + 종류 구분. 층은 `S 중기취득가 → X 시간당사용료 → B 일위대가 → D 단가산출`, 바닥은 `M 자재`·`L 노임`, `W 일식·견적`은 **단가 0**(무대처리, 8-7 ㉡). - 금액은 어느 층이든 **재료·노무·경비 3분할**, 합계 = 셋의 합. - **단가 슬롯 6개** — 번호 고정, **이름은 프로젝트 설정**. 기본 채택 6번(`JUKNM=6`). 슬롯별 **근거 쪽수**(STC `PG_` 열) 자리도 둠. - 안전장치: 채택 슬롯이 비면 **0 으로 안 때우고 멈춤** · 없는 코드는 `unmatched_codes()` 로 **목록으로 냄** · 참조 순환 감지. **프로젝트 스냅샷** (`B09_Estimation_Storage.py`) - 채택 단가를 `<project_root>/B09_Estimation/v1/` 에 사본으로. 기준자료가 갱신돼도 **옛 프로젝트 결과가 안 바뀜**. `dataset_version` 은 `dataset_id`+`effective_date`+ `sha256` **세 쪽**. 사본이 바뀌면 지문 불일치로 멈춤. 차수(당초·1~3차) 자리 비워 둠. 자체검증 — 신규 13건 포함 `pytest tmp/tests/ -q` **85 passed** · ruff 통과 · 파일 최대 534줄. 중기 실측값(`X00005` 96,829 = 노 55,700 + 재 18,001 + 경 23,128)으로 3분할 유지·층 쌓임 확인. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
213 lines
8.3 KiB
Python
213 lines
8.3 KiB
Python
"""B09 원가계산 — 단가 계층 (PLAN 9-3 · 9-4).
|
|
|
|
**표를 세 벌 만들지 않는다.** 「제목 한 줄 + 상세 여러 줄」 한 쌍을 두고 **종류로만** 가른다.
|
|
상용 프로그램 둘(STmate `COSTN`/`BOQ11`, EST Plus `*Title`/`*Main`)이 같은 모양이었고,
|
|
실무 시트 이름도 `일위대가목록표 / 일위대가표` 처럼 짝을 이룬다.
|
|
|
|
실제 층은 넷이다 (2026-09-07 STC `COSTN` 186행 실측, PLAN 9-3):
|
|
|
|
S 중기 취득가(천원) → X 시간당 중기사용료 → B 일위대가 → D 단가산출
|
|
↑ L 노임 · M 자재를 참조
|
|
|
|
금액은 **어느 층이든 재료·노무·경비 3분할**이고 `합계 = 재료 + 노무 + 경비` 다
|
|
(ESTX 9.1만 건 전건 통과).
|
|
|
|
단가 원천은 **슬롯 6개**다 (PLAN 9-4). 번호는 고정, **이름은 프로젝트 설정**이다 —
|
|
설계사무소마다 다르다(영월만 「유통 물가·거래 가격등·기타 단가」). 기본 채택은 **6번**
|
|
(STmate `JUKNM=6`, Ini 보유 6파일 전건 일치).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from decimal import Decimal
|
|
from enum import Enum
|
|
|
|
_ZERO = Decimal(0)
|
|
|
|
#: 단가 원천 슬롯 수. 번호 고정.
|
|
PRICE_SLOT_COUNT = 6
|
|
#: 기본 채택 슬롯(1-based). STmate `JUKNM=6` = 「적용 단가」.
|
|
DEFAULT_ADOPTED_SLOT = 6
|
|
|
|
#: 슬롯 이름 기본값 — **프로젝트 설정으로 덮어쓴다**.
|
|
#: TODO(미결 PLAN 9-4): 사무소마다 1~5 이름이 달라 확정 기본값이 아님. 프로젝트가 고름.
|
|
DEFAULT_SLOT_NAMES: tuple[str, ...] = (
|
|
"조달가격",
|
|
"물가정보",
|
|
"물가자료",
|
|
"적산정보",
|
|
"견적단가",
|
|
"적용 단가",
|
|
)
|
|
|
|
|
|
class PriceKind(str, Enum):
|
|
"""단가 항목의 종류. STC `COSTN.CODE` 앞글자와 1:1 (PLAN 9-3)."""
|
|
|
|
MATERIAL = "material" # M — 자재 카탈로그 (재료비만)
|
|
LABOR = "labor" # L — 노임 카탈로그 (노무비만)
|
|
MACHINE_BASE = "machine_base" # S — 중기 취득가 (천원 단위, 경비만)
|
|
MACHINE_HOURLY = "machine_hourly" # X — 시간당 중기사용료 (3분할)
|
|
UNIT_PRICE = "unit_price" # B — 일위대가 (3분할)
|
|
PRICE_BASIS = "price_basis" # D — 단가산출 (3분할)
|
|
LUMPSUM = "lumpsum" # W — 일식·견적 (무대처리 등, 단가 0)
|
|
|
|
|
|
#: 카탈로그 층 — 상세를 갖지 않고 값이 바로 있는 종류.
|
|
CATALOG_KINDS = frozenset({PriceKind.MATERIAL, PriceKind.LABOR, PriceKind.MACHINE_BASE})
|
|
|
|
|
|
class PriceBookError(LookupError):
|
|
"""단가 조립이 성립하지 않는 경우. 0 으로 때우지 않고 멈춘다."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Money3:
|
|
"""금액 3분할 — 재료·노무·경비. 합계는 셋의 합이다."""
|
|
|
|
material: Decimal = _ZERO
|
|
labor: Decimal = _ZERO
|
|
expense: Decimal = _ZERO
|
|
|
|
@property
|
|
def total(self) -> Decimal:
|
|
return self.material + self.labor + self.expense
|
|
|
|
def __add__(self, other: Money3) -> Money3:
|
|
return Money3(
|
|
self.material + other.material,
|
|
self.labor + other.labor,
|
|
self.expense + other.expense,
|
|
)
|
|
|
|
def scaled(self, factor: Decimal) -> Money3:
|
|
return Money3(self.material * factor, self.labor * factor, self.expense * factor)
|
|
|
|
|
|
@dataclass
|
|
class PriceTitle:
|
|
"""제목 줄 — 「무엇이 있나」 한 줄. 실무 시트의 `…목록표` 에 해당."""
|
|
|
|
code: str
|
|
kind: PriceKind
|
|
name: str
|
|
spec: str = ""
|
|
unit: str = ""
|
|
|
|
#: 원천 슬롯 6개. 값이 없는 슬롯은 None (그 출처에 안 실린 자재).
|
|
slots: list[Decimal | None] = field(default_factory=lambda: [None] * PRICE_SLOT_COUNT)
|
|
#: 슬롯별 근거 쪽수 — 실무 내역서가 「물가정보 몇 쪽」을 남긴다(STC `PG_` 열).
|
|
slot_pages: list[str | None] = field(default_factory=lambda: [None] * PRICE_SLOT_COUNT)
|
|
#: 채택 슬롯(1-based).
|
|
adopted_slot: int = DEFAULT_ADOPTED_SLOT
|
|
|
|
def adopted_price(self) -> Decimal:
|
|
"""채택 슬롯의 단가. 비어 있으면 0 으로 때우지 않고 멈춘다."""
|
|
if not 1 <= self.adopted_slot <= PRICE_SLOT_COUNT:
|
|
raise PriceBookError(
|
|
f"{self.code}: 채택 슬롯 번호가 범위 밖입니다 ({self.adopted_slot})"
|
|
)
|
|
value = self.slots[self.adopted_slot - 1]
|
|
if value is None:
|
|
raise PriceBookError(
|
|
f"{self.code} ({self.name}): 채택 슬롯 {self.adopted_slot} 에 단가가 없습니다. "
|
|
"유료 물가지를 안 봤다면 6번(적용 단가)에 직접 넣으십시오."
|
|
)
|
|
return value
|
|
|
|
def catalog_money(self) -> Money3:
|
|
"""카탈로그 층의 3분할 — 종류가 성분을 정한다.
|
|
|
|
자재는 재료비만, 노임은 노무비만, 중기 취득가는 경비만 갖는다(STC 실측).
|
|
"""
|
|
price = self.adopted_price()
|
|
if self.kind is PriceKind.MATERIAL:
|
|
return Money3(material=price)
|
|
if self.kind is PriceKind.LABOR:
|
|
return Money3(labor=price)
|
|
if self.kind is PriceKind.MACHINE_BASE:
|
|
return Money3(expense=price)
|
|
raise PriceBookError(f"{self.code}: 카탈로그 종류가 아닙니다 ({self.kind})")
|
|
|
|
|
|
@dataclass
|
|
class PriceDetail:
|
|
"""상세 줄 — 「그것이 무엇으로 이루어졌나」 한 줄.
|
|
|
|
`ref_code` 가 **원천 참조**다. 어느 층을 가리키는지가 그 코드의 종류로 드러난다
|
|
(ESTX `LinkIndex` 와 같은 축).
|
|
"""
|
|
|
|
parent_code: str
|
|
ref_code: str
|
|
quantity: Decimal
|
|
note: str = ""
|
|
#: 비율 행(공구손료 등) — 참조 단가의 %로 계산하는 줄.
|
|
percent_of_parent: Decimal | None = None
|
|
|
|
|
|
@dataclass
|
|
class PriceBook:
|
|
"""제목 + 상세 한 벌. 종류로만 갈린다."""
|
|
|
|
titles: dict[str, PriceTitle] = field(default_factory=dict)
|
|
details: dict[str, list[PriceDetail]] = field(default_factory=dict)
|
|
#: 슬롯 이름 — 프로젝트 설정.
|
|
slot_names: tuple[str, ...] = DEFAULT_SLOT_NAMES
|
|
|
|
def add_title(self, title: PriceTitle) -> None:
|
|
if title.code in self.titles:
|
|
raise PriceBookError(f"코드가 겹칩니다: {title.code}")
|
|
self.titles[title.code] = title
|
|
|
|
def add_detail(self, detail: PriceDetail) -> None:
|
|
self.details.setdefault(detail.parent_code, []).append(detail)
|
|
|
|
def title(self, code: str) -> PriceTitle:
|
|
try:
|
|
return self.titles[code]
|
|
except KeyError as exc:
|
|
raise PriceBookError(f"단가표에 없는 코드입니다: {code}") from exc
|
|
|
|
def resolve(self, code: str, _seen: tuple[str, ...] = ()) -> Money3:
|
|
"""그 항목의 단가를 3분할로 조립한다.
|
|
|
|
카탈로그 층(M·L·S)은 값이 바로 있고, 그 위 층(X·B·D)은 상세 줄을 재귀로 더한다.
|
|
`W`(일식·견적)는 **단가가 0** 이다 — 무대처리처럼 품에 이미 포함된 줄 (PLAN 8-7 ㉡).
|
|
"""
|
|
if code in _seen:
|
|
raise PriceBookError(f"단가 참조가 돌고 있습니다: {' → '.join((*_seen, code))}")
|
|
|
|
title = self.title(code)
|
|
if title.kind is PriceKind.LUMPSUM:
|
|
return Money3()
|
|
if title.kind in CATALOG_KINDS:
|
|
return title.catalog_money()
|
|
|
|
rows = self.details.get(code)
|
|
if not rows:
|
|
raise PriceBookError(f"{code} ({title.name}): 상세 줄이 없어 단가를 조립할 수 없습니다")
|
|
|
|
total = Money3()
|
|
for row in rows:
|
|
child = self.resolve(row.ref_code, (*_seen, code))
|
|
if row.percent_of_parent is not None:
|
|
# 비율 행 — 지금까지 쌓인 값의 %로 붙는다(공구손료 등).
|
|
total = total + total.scaled(row.percent_of_parent / Decimal(100))
|
|
continue
|
|
total = total + child.scaled(row.quantity)
|
|
return total
|
|
|
|
def unmatched_codes(self) -> list[str]:
|
|
"""상세가 가리키는데 제목이 없는 코드 — **빈칸으로 두지 않고 목록으로 낸다**.
|
|
|
|
문자열 매칭 실패를 조용히 0 원으로 넘기지 않기 위한 자리 (PLAN 8-6).
|
|
"""
|
|
missing: list[str] = []
|
|
for rows in self.details.values():
|
|
for row in rows:
|
|
if row.ref_code not in self.titles and row.ref_code not in missing:
|
|
missing.append(row.ref_code)
|
|
return missing
|