Files
Aislo/B09_Estimation/B09_Estimation_PriceBook.py
T
eomsangdonandClaude Opus 5 c3618fe872 feat(b09): 2차 편집 ② 일위대가·단산 구성행 수정 · ③ Q 식 편집 — 프로젝트 단위 · 서버가 다시 계산 · ↺
- 구성행(sheet_rows): 수량 고치기 · 줄 빼기(수량 0, 흐리게) · 줄 더하기(단가표 고르개) — 비율 줄·돌림 참조는 받지 않음
- Q 식(price_basis_q): 식은 명세 13장 식 언어 — B08 구조물도 풀이기(evaluate_sheets, Node 한 벌)를 그대로 부름 ·
  소수 2자리 사사오입 확정 → 새 수량 = 수량 × 옛 Q ÷ 새 Q · 비고에 「Q(사용자) = 식 = 값」
  · PriceDetail.output(시공능력 Q)을 Q 로 선 장비 줄 넷 자리(굴착기·도자·직접 작업량·암 잎)에서 실음 · 저장 모양에도 실음
- 따로 짰던 파이썬 식 셈(B09_Estimation_Expression)은 걷어냄 — 식 풀이 두 벌 금지(브레인 판정)
- 새 문: GET /estimation/edits/sheet/{code}(본표 + 줄마다 고친 값 표시) · GET /estimation/edits/search(줄 더하기 고르개)
- 화면: 본표 [편집] — 수량 칸 · ✕ · Q 식 칸 · 줄 더하기 · 고친 줄 「사용자」 + ↺
- 검증: 시험 1664 통과 · 골든셋 초록 · ORCA — 제 3 호표 보통인부 0.023→0.046 이면 5,652→9,610 · 내역 본체 122,848,989→122,857,198,
  ↺ 뒤 5,652 · 산근 2호표 Q 58.21→116.42 이면 1,695→847, ↺ 뒤 1,695 · 틀린 식 422 「모르는 이름: abc」 · 고친 값 {} 로 복구

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 03:37:18 +09:00

374 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 ROUND_FLOOR, 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})
#: **호표 안에서 자르는** 층 — 줄 금액은 0.1원 미만, 성분 소계는 원 미만 절사(명세 7장).
#: 근거 STmate 17번 — 중기사용료 호표 성분 소계 398/398 절사
#: (굴삭기 0.7㎥ 23,128 + 55,700 + 18,015 = 96,843 · 골든셋 실무 143 호표 전수).
#: D(단가산출)도 같음 — 줄 `중기 성분 ÷ Q` 0.1원 · 머리 성분 원 미만(봉화 2024 제2호표 3,545.5 → 3,545).
#: B(일위대가)도 같음 — 골든셋 실무 성분 소계 477/510(93.5%, 17번 원문 94.2%). 안 맞는 것은 반올림으로
#: 뽑은 원본 한 건·적용률 줄 서식 — **새 반올림 규칙을 넣지 말 것**(PLAN 6장 판정).
#: ⚠ 비율 줄(공구손료·제잡비·품 할증)도 줄 금액이라 0.1원 미만 절사.
TRUNCATED_KINDS = frozenset({PriceKind.MACHINE_HOURLY, PriceKind.PRICE_BASIS, PriceKind.UNIT_PRICE})
_TENTH = Decimal("0.1")
_WON = Decimal(1)
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)
def floored(self, unit: Decimal) -> Money3:
"""성분마다 `unit` 자리 아래 절사 — 호표 줄(0.1원)·성분 소계(원) 자르기."""
return Money3(
*(
v.quantize(unit, rounding=ROUND_FLOOR)
for v in (self.material, self.labor, self.expense)
)
)
@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
#: 노임 신뢰도 기호(`*`·`**`) — 노임 줄에만 붙는다. 빈 문자열이면 정상 공표 직종.
#: ⚠ **금액을 바꾸지 않는다.** 값은 그대로 쓰고 「표본이 얇다」는 사실만 나른다.
reliability: str = ""
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
#: **노무비 합계**의 %로 붙는 경비 줄 — 제잡비(품셈 13-6-1 [주]③).
#: ⚠ `percent_of_parent` 와 다르다: 밑수가 3분할 전체가 아니라 **노무비만**이고,
#: 결과는 **경비(J)** 로만 들어간다. 「상한」이라 설계자가 낮출 수 있는 값이다.
percent_of_labor: Decimal | None = None
#: 그 %가 **어느 성분으로** 들어가나 — 제잡비는 경비(기본), 품 할증은 **노무비**다.
#: ⚠ 품 할증(산림품셈 1-4)은 품을 늘리는 것이라 노무비로 들어가고, 그래서 **제잡비 밑수도
#: 함께 커진다** — 그 순서를 지키려고 조립하는 쪽이 할증 줄을 제잡비보다 먼저 넣는다.
percent_of_labor_target: str = "expense"
#: **주재료비**의 %로 붙는 재료비 줄 — 공구손료·잡재료(산림품셈 1-2-6).
#: ⚠ 밑수는 **자재 카탈로그(M)에서 온 재료비만**이다. 하위 일위대가가 품고 온 재료비는
#: 그 일위대가에서 이미 한 번 셌으므로 여기서 또 세지 않는다.
#: ⚠ 원문이 「재료비의 **할증수량 제외**」라 밑수가 **할증 전** 값이어야 하는데, 일위대가
#: 층의 재료비가 곧 할증 전 값이다(할증은 자재총괄에서 한 번만 — PLAN 8-7 ㉠).
percent_of_material: Decimal | None = None
#: 시공능력 **Q**(소수 2자리 확정 값) — Q 로 선 장비 줄(D)에만. 수량 = 몫 ÷ Q 라 Q 식을 고치면
#: 새 수량 = 수량 × 옛 Q ÷ 새 Q(PLAN 12장 2차 ③ · `B09_Estimation_Edits`).
output: 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 add_output_detail(
self,
parent_code: str,
ref_code: str,
quantity: Decimal,
note: str = "",
output: Decimal | None = None,
) -> str:
"""**시공능력 Q** 로 선 장비 줄 — B 에 바로 안 달고 **D(단가산출)** 에 달고 B 는 D 를 1 로 부름.
층 차례 `X → D → B`(명세 3장 정정 · PLAN 6장 판정): D 는 Q 쓰는 자리에만 서고, 내역 코드 축은
B 하나라 B → D 수량 1(금액 같음). 한 B 에 Q 줄이 여럿이면 **D 한 장**에 모임. D 코드를 돌려줌.
"""
parent = self.title(parent_code)
basis_code = f"D-{parent_code[2:]}" if parent_code.startswith("B-") else f"D-{parent_code}"
if basis_code not in self.titles:
self.add_title(
PriceTitle(
code=basis_code,
kind=PriceKind.PRICE_BASIS,
name=parent.name,
spec=parent.spec,
unit=parent.unit,
)
)
self.add_detail(PriceDetail(parent_code, basis_code, Decimal(1), note="단가산출(Q)"))
self.add_detail(PriceDetail(basis_code, ref_code, quantity, note=note, output=output))
return basis_code
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()
# 호표 안에서 자르는 층이면 줄 금액을 0.1원 미만 절사해 쌓고 끝에 성분 소계를 원 미만 절사.
truncate = title.kind in TRUNCATED_KINDS
# 제잡비 밑수로 쓸 **사람 품(직접노무비)** — 기계 줄 안의 조종원 노임은 안 센다.
# 근거는 아래 `percent_of_labor` 자리 주석의 인용 셋.
direct_labor = Decimal(0)
# 공구손료·잡재료 밑수로 쓸 **주재료비** — 자재 카탈로그에서 바로 온 재료비만 센다.
direct_material = Decimal(0)
for row in rows:
# ⚠ 비율 줄은 **참조를 풀기 전에** 처리한다 — 자기 자신을 가리키므로
# 먼저 풀면 순환으로 잡힌다(제잡비 줄이 그렇다).
if row.percent_of_labor is not None:
# 제잡비 — **노무비 합계**의 %가 **경비**로 붙는다(품셈 13-6-1 [주]③).
#
# ⚠ **밑수는 사람 품(직접노무비)이다** — 기계 줄 안의 조종원 노임은
# 안 센다. 근거 셋(2026-09-08 원문 대조, 두 창 합의):
# ① 산림품셈 13-6-2 [주]③ — 「제잡비는 콘크리트 버켓 손료, 다짐기계
# 손료 비용이며 **노무비의 합계액**에 위 표의 비율을 곱한 금액을
# 상한으로 하여 계상한다」
# ② 건설품셈 제8장 — 「잡재료 등 손료 : **직접노무비**에 다음 표의
# 비율을 곱한 것을 상한으로 한다」 (같은 이름의 규정)
# ③ 같은 장 — 기계를 넣을 때는 「잡재료비 = **노무비, 기계손료 및
# 운전경비의 합** × 잡재료비율」이라 **따로 적음** ⇒ 넓은 쪽이면
# 명시하는 서식인데 13-6-2 는 그냥 「노무비」다.
# 뜻으로도 그렇다 — 제잡비는 **본 자원에 안 선 잔 기계 손료**를 사람
# 품에 비례해 얹는 자리인데, 그 표엔 굴착기가 이미 본 자원으로 서 있다.
#
# ⚠⚠ **닫힌 물음이다 — 다시 뒤집지 말 것** (2026-09-08 전수 대조로 확정).
# 이 값은 하루에 두 번 뒤집혔고 금액이 약 ±22 % 움직였다. 세 번째가
# 없도록 근거를 여기 못 박는다.
# ㉠ **산림품셈이 1차 적용**이고(품셈 1-1·별표2, 건설품셈은 보완),
# 그 [주]③ 은 여섯 자리에서 한결같이 **「노무비의 합계액」**이라 적는다
# (원문 L5498·L7013·L7299·L7323·L7350·L7401).
# ㉡ **일위대가 안에는 간접노무비가 없다.** 간접노무비는 원가계산서 층에서
# 「직접노무비 × 율」로 나중에 생기는 값이다
# (resources/knowledge/.../05_원가정보/원가계산_체계.md §2 밑수 정의표).
# ⇒ 그러므로 **그 표의 노무비 줄 합 = 직접노무비**이고, 「직접노무비냐
# 노무비 계정이냐」라는 물음 자체가 이 층에서는 성립하지 않는다.
# ⚠ 건설품셈 제8장(말뚝)은 **「직접노무비」**라고 다르게 적지만, 위 ㉠ 으로
# 임도는 산림품셈 문구를 따른다 — 결과값은 어차피 같다.
# ⚠ 「**상한**」이다 — 곱한 값 **이하**로 계상하는 값이라 설계자가 낮출 수 있다.
share = direct_labor * row.percent_of_labor / Decimal(100)
if truncate:
share = share.quantize(_TENTH, rounding=ROUND_FLOOR)
if row.percent_of_labor_target == "labor":
# 품 할인·할증(1-4) — **품이 늘어난 것**이라 노무비로 들어가고, 뒤에 오는
# 제잡비의 밑수에도 든다.
direct_labor = direct_labor + share
total = total + Money3(labor=share)
else:
total = total + Money3(expense=share)
continue
if row.percent_of_material is not None:
# 공구손료·잡재료 — **주재료비**의 %가 **재료비**로 붙는다(산림품셈 1-2-6
# 「각 항목에 명시되어 있지 않는 잡재료 및 소모재료 … 주재료비(재료비의
# 할증수량 제외)의 2~5%까지 별도 계상하되 산정 근거를 명시하여야 한다」).
# ⚠ **기본은 빈 칸이라 이 줄 자체가 안 선다**(사용자 확정 5차 작은 것 1
# 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」). 몇 %인지는 사용자 몫이다.
# ⚠ 밑수가 **명시된 잡재료를 뺀 주재료비**여야 하는데, 품셈이 명시한 잡재료는
# 이미 자원 줄로 서 있어 이 밑수에 함께 든다 — 그 공종에 잡재료가 명시돼
# 있으면 1-2-6 이 애초에 안 걸리는 자리이므로 **칸을 비워 두는 것이 맞다.**
# ⚠ 중기(X) 호표의 **잡품**도 이 줄 — 「주연료비 × 율」의 가산 행(STmate 17번 §3,
# 잡품 단가 칸 = 연료 금액). 수량 × 단가로 연료 수량에 접으면 줄이 사라짐.
share = Money3(material=direct_material * row.percent_of_material / Decimal(100))
total = total + (share.floored(_TENTH) if truncate else share)
continue
child = self.resolve(row.ref_code, (*_seen, code))
if row.percent_of_parent is not None:
# 비율 행 — 지금까지 쌓인 값의 %로 붙는다(공구손료 등).
share = total.scaled(row.percent_of_parent / Decimal(100))
total = total + (share.floored(_TENTH) if truncate else share)
continue
scaled = child.scaled(row.quantity)
if truncate:
scaled = scaled.floored(_TENTH)
if self.titles[row.ref_code].kind is PriceKind.LABOR:
direct_labor = direct_labor + scaled.labor
if self.titles[row.ref_code].kind is PriceKind.MATERIAL:
direct_material = direct_material + scaled.material
total = total + scaled
return total.floored(_WON) if truncate else total
def material_base(self, code: str) -> Decimal:
"""공구손료·잡재료(산림품셈 1-2-6)의 **밑수** — 그 항목에 바로 붙은 자재 줄의 합.
`resolve` 안의 `direct_material` 과 같은 규칙이다. 화면이 「넣을 데가 있는가」를
미리 물어볼 수 있게 따로 낸다 — 지금은 사급 단가가 미결이라 대부분 0 이고,
그 사실을 숨기면 사용자가 값을 넣고도 왜 안 붙는지 모른다.
"""
base = _ZERO
for row in self.details.get(code, []):
if row.percent_of_material is not None or row.percent_of_labor is not None:
continue
if row.percent_of_parent is not None or row.ref_code == code:
continue
title = self.titles.get(row.ref_code)
if title is not None and title.kind is PriceKind.MATERIAL:
base = base + title.catalog_money().material * row.quantity
return base
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