- 시공능력 Q 로 선 기계 줄(굴착기·도자·직접 작업량·다짐·암 잎)은 D(단가산출)에 달고 B 는 D 를 수량 1 로 부름 - 종전 PriceBasis 의 껍데기 D(D → B) 걷음 — 「단산 N」은 D 를 품은 줄에만(갈래 줄도 실제 단가 코드로 찾음) - 자원 집계가 B→B·B→D 참조를 끝까지 풀어 기계 사용료에 닿음(종전 조용히 버림) · 모르는 종류는 드러냄 - 조합 16% 바꿔 달기·공종 근거 문구가 D 줄도 봄 - 전후 대조: B·X 420 제목 금액 전부 같음 · 검증 프로젝트 내역 본체 122,924,846 그대로 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
360 lines
19 KiB
Python
360 lines
19 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 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 호표 전수).
|
|
#: ⚠ 일위대가(B) 호표도 같은 규칙(345 중 94.2%)이나 **층 차례를 바로잡은 뒤** 붙임(PLAN 6장 판정).
|
|
TRUNCATED_KINDS = frozenset({PriceKind.MACHINE_HOURLY})
|
|
_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
|
|
|
|
|
|
@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 = ""
|
|
) -> 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))
|
|
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 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:
|
|
# 비율 행 — 지금까지 쌓인 값의 %로 붙는다(공구손료 등).
|
|
total = total + total.scaled(row.percent_of_parent / Decimal(100))
|
|
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
|