feat(B09): 공구손료·잡재료 칸 — 기본은 빔, 넣으면 주재료비의 %로 붙음

사용자 확정 5차 작은 것 1 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」.
근거는 산림품셈 1-2-6 — 주재료비(할증수량 제외)의 2~5%까지, 산정 근거 명시.

- 기초자료 탭 「산출 조건」에 칸 하나 + [적용]. 비면 줄 자체가 안 섬(지금 상태 그대로).
- 밑수는 **자재 줄만** — 노무·경비, 하위 일위대가 재료비는 안 듦(층마다 거듭 세지 않음).
- 상한 5% 초과는 거절(400) — 조용히 깎아 넣지 않음.
- ⚠ 지금은 일위대가에 주재료비가 선 공종이 0개라 붙을 밑수가 없음 — 그 사실을 칸 밑에 띄움.
- 곁다리: 비율 줄(제잡비·공구손료)이 저장했다 읽으면 사라지던 것을 고침.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-09 20:30:10 +09:00
co-authored by Claude Opus 5
parent 404ec16e65
commit 83c842e37a
8 changed files with 445 additions and 5 deletions
@@ -22,6 +22,7 @@ from dataclasses import dataclass, field
from dataclasses import replace as dataclass_replace
from decimal import Decimal
from functools import lru_cache
from typing import Any
from B09_Estimation.B09_Estimation_Guards import check_column_sums, check_surcharge_once
from B09_Estimation.B09_Estimation_MachineCost import (
@@ -157,6 +158,40 @@ _ATTACHMENT_WORDS = ("브레이커", "리퍼", "부착용집게", "집게")
#: 조금씩 비싸진다(굴착기 0.7 기준 시간당 1,285원).
COMBINED_MISC_PERCENT = Decimal(16)
#: ⭐ 사용자 확정 5차 작은 것 1 — **공구손료·잡재료 칸**.
#: 「지금은 안 넣음. 다만 숫자를 넣으면 되게 열어 둘 것 — 칸을 만들되 기본은 빔, 비면 안 붙음」
#: 근거는 산림품셈 1-2-6 — 「각 항목에 명시되어 있는 잡재료 및 소모재료에 대해서는 이를
#: 계상하고, 명시되어 있지 않는 … 주재료비(재료비의 할증수량 제외)의 **2~5%까지** 별도
#: 계상하되 **산정 근거를 명시**하여야 한다」.
#: ⚠ **몇 %인지는 사용자 몫이다** — 기본값을 만들어 두지 않는다(범위값을 임의로 굳히면
#: 금액이 조용히 그 값으로 선다). 비어 있으면 줄 자체가 안 서고 지금 상태 그대로다.
MISC_MATERIAL_MAX_PERCENT = Decimal(5) # 「2~5%**까지**」 — 상한
MISC_MATERIAL_MIN_PERCENT = Decimal(2) # 원문이 적은 아랫값(아래로 내려가면 사유로 알린다)
MISC_MATERIAL_BASIS = "공구손료·잡재료 — 주재료비의 {percent}% (산림품셈 1-2-6, 산정 근거 명시)"
def parse_misc_material_percent(raw: Any) -> Decimal | None:
"""설정 칸의 값을 비율로 읽는다. **비면 `None`**(= 안 붙음).
⚠ 상한(5%)을 넘는 값은 **받지 않는다** — 품셈이 「2~5%까지」로 못 박은 자리다.
"""
text = str(raw or "").strip().rstrip("%").strip()
if not text:
return None
try:
percent = Decimal(text)
except (ArithmeticError, ValueError):
raise ValueError(f"공구손료·잡재료 비율을 숫자로 못 읽었습니다: {raw!r}") from None
if percent <= 0:
return None
if percent > MISC_MATERIAL_MAX_PERCENT:
raise ValueError(
f"공구손료·잡재료는 주재료비의 {MISC_MATERIAL_MAX_PERCENT}% 까지입니다"
f" (산림품셈 1-2-6) — 받은 값 {percent}%"
)
return percent
#: 조합으로 쓰는 부착 장비 — 이 층이 붙은 공종의 본체는 위 비율을 쓴다.
#: 카탈로그 분류번호로 잡는다 — 0103 유압식 리퍼 · 0230 대형 브레이커 ·
#: 0240 유압식 진동콤팩터(굴착기 부착용) · 7206 부착용 집게.
@@ -487,6 +522,7 @@ def build_unit_prices(
axis: AxisResult | None = None,
factor_choices: dict[tuple[str, str], Decimal] | None = None,
machine_picks: dict[str, str] | None = None,
misc_material_percent: Decimal | None = None,
) -> UnitPriceBuild:
"""자원 축을 일위대가(`B`)로 조립한다.
@@ -495,6 +531,9 @@ def build_unit_prices(
⚠ `factor_choices` — 품셈이 **범위로 준 계수**에 사용자가 고른 값(확정 ①). 안 주면
**평균**이 기본이다(`B09_Estimation_FactorChoices`). 범위가 아닌 칸은 안 덮는다.
⚠ `misc_material_percent` — 공구손료·잡재료(산림품셈 1-2-6). **안 주면 줄이 안 선다** —
사용자 확정 5차 작은 것 1 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」 그대로다.
"""
from B09_Estimation.B09_Estimation_FactorChoices import (
chosen_values,
@@ -698,6 +737,20 @@ def build_unit_prices(
)
)
# 공구손료·잡재료 — **주재료비의 %가 재료비로** 붙는다(산림품셈 1-2-6).
# ⚠ **비어 있으면 이 줄이 아예 안 선다** = 지금까지와 같은 금액이다(확정 5차 작은 것 1).
# ⚠ 갈래 제목(`__` 로 시작하는 내부 갈래)에는 안 붙인다 — 제잡비와 같은 자리를 쓴다.
if misc_material_percent is not None and not variant_key.startswith("__"):
build.book.add_detail(
PriceDetail(
title_code,
title_code,
_ZERO,
note=MISC_MATERIAL_BASIS.format(percent=misc_material_percent),
percent_of_material=misc_material_percent,
)
)
# 장비 몫은 자원 수량이 아니라 **시공능력 공식**으로 온다 (품셈 8-1-4).
# ⚠ 불도저와 굴착기는 **식이 다르다**(8-2-1 vs 8-1-4). 둘 다 붙이면 장비를 두 번
# 세므로, 불도저가 붙은 자리는 굴착기 쪽을 아예 안 본다.
@@ -892,6 +945,7 @@ DRILLABLE_KINDS = frozenset({PriceKind.MACHINE_HOURLY, PriceKind.UNIT_PRICE, Pri
def cached_build(
range_choices: tuple[tuple[str, str], ...] = (),
machine_picks: tuple[tuple[str, str], ...] = (),
misc_material_percent: str = "",
) -> UnitPriceBuild:
"""조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다.
@@ -913,6 +967,7 @@ def cached_build(
return build_unit_prices(
factor_choices=chosen_values(scan_range_factors(master), settings),
machine_picks=machine_choices(settings),
misc_material_percent=parse_misc_material_percent(misc_material_percent),
)