feat(B09): 조합 사용 시 본체 잡재료 16% + 터파기 노티스 문구 정정

① 품셈 제8장 [주]⑤ 의 마지막 조각 — 「…조합하여 사용할 때는 …**잡재료비율을 16%로
   계상**하고, 리퍼·브레이커·집게의 손료 및 치즐 소모율을 추가」. 손료·치즐만 넣고
   이 줄을 빠뜨리면 본체 재료비가 22% 로 남아 조금씩 비싸짐.
   - 기계마다 「#조합」 층을 함께 세움(손료·조종원 같음, 잡재료만 16%)
   - 조립 **끝에 한 번** 조합 공종의 본체 줄을 그 층으로 바꿔 닮 — 도중에는 어느 공종이
     조합인지 아직 모름. 바꾼 줄 24
   - 부착 장비는 **이름이 아니라 분류번호**로 잡음(0103 리퍼 · 0230 브레이커 ·
     0240 진동콤팩터 · 7206 집게) — 이름은 원천이 뭉개 놓는 일이 있음(0230 이 그랬음)
   - 결과: 구조물터파기 72,378.3 → 72,333.2 · 돌쌓기(메) 78,864.5 → 78,423.8 ·
     돌쌓기(찰) 61,757.3 → 61,418.0 · 본체 30,892,279 → **30,849,376원**

② 터파기 노티스 문구 정정 — 「실무는 기초만 세고 우리는 벽 전체를 셈(6.6배)」이
   **틀렸음**. 우리 식은 정본(기초 0.45 + 비탈) 그대로이고, 6.6배는 소광리 시트가
   비탈 줄을 안 적어서 난 것임. ⇒ 「비탈 터파기를 셀지 확정 대기 — 안 세면 약 1/7」로
   고침. 값·금액은 그대로

검증: pytest 290 통과(신규 1)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-09 08:30:30 +09:00
co-authored by Claude Opus 5
parent 1d5145eaa5
commit c892bdc935
2 changed files with 99 additions and 15 deletions
@@ -332,26 +332,21 @@ def _leaf_row(
#: 수량 산식이 **사용자 확정을 기다리는** 공종 — 금액은 세우되 그 사실을 함께 싣는다.
#:
#: ⚠ **값을 우리가 바꾸지 않는다.** 실무 시트와 어긋나는 것이 확인됐을 뿐이고, 어느 쪽으로
#: 갈지는 설계 판단이다(계획서 4-12 3단계, 확정 대기).
#: 돌쌓기(찰) H=2.0·ℓ3=45 · m당 기준으로 실무 대비
#: 터파기 실무 0.35 우리 2.30 (약 6.6배) — 실무는 기초만(기초폭 × 깊이 0.3),
#: 우리는 벽 전체를 센다
#: 되메우기 실무 0.185 우리 0.40 · 잔토정리 실무 0.165 우리 1.90 (위 둘의 차)
#: ⚠ **값을 우리가 바꾸지 않는다.** 어느 쪽으로 갈지는 설계 판단이다(계획서 4-12 3단계).
#:
#: ⚠ **2026-09-09 문구를 고쳤다.** 처음에는 「실무는 기초만 세고 우리는 벽 전체를 센다」로
#: 적었는데 **그것이 틀렸다** — 우리 식은 정본(기초 0.45 + 비탈) 그대로이고, 6.6배 차이는
#: **소광리 시트가 비탈 터파기 줄을 안 적어서** 난 것이다. 다투는 자리는 「우리 식이
#: 맞는가」가 아니라 **「비탈 터파기를 셀 것인가」**다.
_PENDING_FORMULA: dict[str, str] = {
"FP-09-13": (
"터파기 수량 산식이 확정 대기입니다 — 실무 시트는 기초만(기초폭 × 깊이 0.3) 세고 "
"우리는 벽 전체를 셉니다(m당 실무 0.35 vs 우리 2.30, 약 6.6배). 실무 식으로 확정되면 "
"이 줄 금액이 약 1/6.6 로 줄어듭니다."
"비탈 터파기를 셀지 확정 대기입니다 — 정본은 세고(기초 0.45 + 비탈) 다른 실무 "
"시트는 안 셉니다. 안 세는 쪽으로 확정되면 이 줄 금액이 약 1/7 로 줄어듭니다."
),
"FP-09-14": (
"⚠ 되메우기 수량 산식이 확정 대기입니다 — 터파기와 같은 뿌리입니다"
"(m당 실무 0.185 vs 우리 0.40)."
),
"FP-09-15": (
"⚠ 잔토처리 수량 산식이 확정 대기입니다 — 터파기·되메우기의 차라 같은 뿌리입니다"
"(m당 실무 0.165 vs 우리 1.90)."
"⚠ 되메우기도 같은 확정에 걸립니다 — 비탈 터파기를 안 세면 되메울 부피도 함께 줍니다."
),
"FP-09-15": ("⚠ 잔토처리도 같은 확정에 걸립니다 — 터파기와 되메우기의 차라 같은 뿌리입니다."),
}
@@ -19,6 +19,7 @@ from __future__ import annotations
import re
from dataclasses import dataclass, field
from dataclasses import replace as dataclass_replace
from decimal import Decimal
from functools import lru_cache
@@ -102,6 +103,8 @@ class UnitPriceBuild:
#: ⚠ 값이 남의 절에서 온 것이면 **화면이 그렇게 말해야** 한다 — 안 그러면 나중에
#: 「이 숫자 어디서 왔지」로 되짚을 길이 없다.
factor_sources: dict[str, str] = field(default_factory=dict)
#: 조합 사용(품셈 [주]⑤)으로 **잡재료 16% 층으로 바꿔 단 줄** 수.
combined_swapped: int = 0
#: 배분율 표인데 일부 몫만 붙은 공종 — 「단가가 일부만 섬」. 값은 붙은 몫(%).
partial_ratio: dict[str, Decimal] = field(default_factory=dict)
#: 시공능력 공식으로 장비 몫을 세운 공종 — 산출근거를 화면에 그대로 보인다.
@@ -137,6 +140,50 @@ def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None:
_ATTACHMENT_WORDS = ("브레이커", "리퍼", "부착용집게", "집게")
#: 조합 사용 시 본체(굴착기·불도저)의 잡재료비율 — 품셈 제8장 [주]⑤.
#: 「…리퍼, 브레이커, 부착용집게를 **조합하여 사용**할 때는 …**잡재료비율을 16%로 계상**하고,
#: 리퍼, 브레이커, 부착용 집게의 손료 및 치즐 소모율을 추가하는 것이다.」
#: ⚠ 손료·치즐만 더하고 이 줄을 빠뜨리기 쉽다 — 그러면 본체 재료비가 계속 22% 로 서서
#: 조금씩 비싸진다(굴착기 0.7 기준 시간당 1,285원).
COMBINED_MISC_PERCENT = Decimal(16)
#: 조합으로 쓰는 부착 장비 — 이 층이 붙은 공종의 본체는 위 비율을 쓴다.
#: 카탈로그 분류번호로 잡는다 — 0103 유압식 리퍼 · 0230 대형 브레이커 ·
#: 0240 유압식 진동콤팩터(굴착기 부착용) · 7206 부착용 집게.
#: ⚠ 이름이 아니라 **번호**로 잡는다 — 이름은 원천이 뭉개 놓는 일이 있다(0230 이 그랬다).
_ATTACHMENT_PREFIXES = ("X-0103-", "X-0230-", "X-0240-", "X-7206-")
def _apply_combined_misc_rate(book: PriceBook, work_item_titles: list[str]) -> int:
"""조합 사용 공종의 본체 기계를 **잡재료 16% 짜리 층**으로 바꿔 단다.
바꾼 줄 수를 돌려준다. ⚠ 원래 층은 그대로 둔다 — 조합이 아닌 공종은 22% 그대로다.
"""
swapped = 0
for title_code in work_item_titles:
details = book.details.get(title_code) or []
if not any(detail.ref_code.startswith(_ATTACHMENT_PREFIXES) for detail in details):
continue
for index, detail in enumerate(details):
if not detail.ref_code.startswith("X-") or detail.ref_code.startswith(
_ATTACHMENT_PREFIXES
):
continue
combined = f"{detail.ref_code}#조합"
if combined not in book.titles:
continue
details[index] = dataclass_replace(
detail,
ref_code=combined,
note=(
(detail.note + " · " if detail.note else "")
+ "조합 사용 — 잡재료 16% (품셈 제8장 [주]⑤)"
),
)
swapped += 1
return swapped
def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]:
"""`S`(취득가) · `L`(운전사) · `M`(연료) 을 세우고 그 위에 `X` 를 올린다.
@@ -232,6 +279,32 @@ def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]:
# 잡재료는 **주연료의 %** — 유가와 같이 움직인다.
liters = liters * (Decimal(1) + record.misc_material_percent / Decimal(100))
book.add_detail(PriceDetail(hourly_code, fuel_code, liters, note="주연료 + 잡재료"))
# 조합 사용(리퍼·브레이커·집게)일 때 쓸 **잡재료 16%** 짜리 층을 함께 세운다.
# 같은 기계라도 조합이면 본체 잡재료가 줄어든다(품셈 제8장 [주]⑤).
combined_code = f"{hourly_code}#조합"
if combined_code not in book.titles:
book.add_title(
PriceTitle(
code=combined_code,
kind=PriceKind.MACHINE_HOURLY,
name=machine.name,
spec=(f"{machine.specification} · 조합").strip(" ·"),
unit="hr",
)
)
book.add_detail(
PriceDetail(combined_code, base_code, Decimal(1), note="시간당 손료")
)
book.add_detail(
PriceDetail(
combined_code,
fuel_code,
record.fuel_liters_per_hour
* (Decimal(1) + COMBINED_MISC_PERCENT / Decimal(100)),
note=f"주연료 + 잡재료 {COMBINED_MISC_PERCENT}% (조합 사용)",
)
)
else:
incomplete.append(f"{code} (연료소모량 없음)")
@@ -251,6 +324,17 @@ def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]:
slots=_slots(wages[wage_code]),
)
)
# 조합 층에도 조종원을 같이 단다 — 본체를 모는 사람은 하나뿐이다.
combined_code = f"{hourly_code}#조합"
if combined_code in book.titles:
book.add_detail(
PriceDetail(
combined_code,
wage_code,
per_hour_person,
note="조종원 (1일 8시간 × 제수당·상여·퇴직충당 16/12 × 25/20)",
)
)
book.add_detail(
PriceDetail(
hourly_code,
@@ -668,6 +752,11 @@ def build_unit_prices(
covered += machine_share
if covered < Decimal(100):
build.partial_ratio[work_item_code] = covered
# ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다
# (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다.
build.combined_swapped = _apply_combined_misc_rate(
build.book, [code for code in build.book.titles if code.startswith("B-")]
)
return build