feat(B09): 깨기(대형브레이커)가 붙어 구조물터파기가 섬 — 본체 3,089만원

품셈 제8장 [주]⑤ 「불도저 및 굴착기에 리퍼, 브레이커, 부착용집게를 조합하여 사용할
때는 …잡재료비율을 16%로 계상하고, 리퍼, 브레이커, 부착용 집게의 손료 및 치즐
소모율을 추가하는 것이다」 를 따라감.

세 자리를 이었음
1. **뭉개진 카탈로그 줄을 원문으로 되살림** — `mach_base_2026.json` 의 대형 브레이커
   여섯 줄이 이름 칸에 표 전체가 뭉쳐 규격·손료계수가 없었음. 건설품셈 제8장 (0230)
   표에서 읽어 채움. ⚠ **두 번 검증** — 계수 합 3,000+2,833+768=6,601 이 표의 「계」와
   같고, 같은 방식으로 읽은 굴착기 「계 2,085」가 카탈로그 0.0002085 와 정확히 일치
2. **부착용 장비는 손료만으로 층을 세움** — 제 엔진이 없어 운전경비표에 줄이 없음.
   없다고 버리면 깨기 몫이 영영 안 붙음. ⚠ 리퍼·브레이커·집게에만 여는 길
3. **작업량을 직접 준 기계 줄을 읽음** — 「대형브레이커(㎥/hr) 3.5」. 공식이 아니라
   시간당 작업량을 표가 바로 주는 모양. 단위가 붙은 칸만 읽음(짐작 금지)

결과
- 구조물터파기(암절취·육상·2~3m) 155.625㎥ × 72,378.4 = **11,263,899원** — 처음 섬
- 돌쌓기 단가에 **부착용 집게 손료**가 더해짐(메 76,607.9 → 78,864.5) — [주]⑤ 그대로
- 본체 19,444,626 → **30,892,279원**
- 남은 것은 **치즐(자재) 하나** — 기계가 빠지면 막고 자재 소모품이 빠지면 드러내기만
  하는 규칙대로, 금액을 세우고 본표가 「치즐소모량 줄이 아직 안 붙었습니다」를 말함

⚠ 아직 안 한 것 — 같은 [주]⑤ 의 **「잡재료비율 16%」**. 조합 사용 시 굴착기 잡재료가
22% 가 아니라 16% 임. 그 자리는 운전경비 층이라 따로 손봐야 함.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-09 08:06:02 +09:00
co-authored by Claude Opus 5
parent de8f0f15f2
commit 1dceeb5bef
3 changed files with 234 additions and 3 deletions
+96 -3
View File
@@ -132,6 +132,11 @@ def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None:
)
#: 부착용 장비 — 제 엔진이 없어 **손료만** 붙는다(품셈 제8장 [주]⑤).
#: ⚠ 이 목록을 넓히지 말 것 — 넓히면 연료가 빠진 기계가 조용히 싸게 선다.
_ATTACHMENT_WORDS = ("브레이커", "리퍼", "부착용집게", "집게")
def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]:
"""`S`(취득가) · `L`(운전사) · `M`(연료) 을 세우고 그 위에 `X` 를 올린다.
@@ -159,7 +164,21 @@ def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]:
for code in sorted(machine_codes):
machine = catalog.machines.get(code)
record = operating.get(code)
if machine is None or machine.loss_coefficient_per_hour is None or record is None:
# ⚠ **부착용 장비는 운전경비표에 줄이 없다** — 제 엔진이 없어 연료·조종원이
# 본체(굴착기·불도저)에 든다. 품셈 제8장 [주]⑤ 가 그 자리를 밝힌다:
# 「불도저 및 굴착기에 **리퍼, 브레이커, 부착용집게를 조합하여 사용**할 때는
# …잡재료비율을 16%로 계상하고, **리퍼, 브레이커, 부착용 집게의 손료 및
# 치즐 소모율을 추가**하는 것이다.」
# ⇒ 그 셋은 **손료만으로** 층을 세운다. 운전경비 줄이 없다고 통째로 버리면
# 깨기 몫이 영영 안 붙는다(구조물터파기 암 갈래가 그 자리였다).
# ⚠ **다른 기계에는 이 길을 열지 않는다** — 연료가 빠진 채 조용히 싼 값이 선다.
attachment = machine is not None and any(
word in re.sub(r"\s", "", machine.name) for word in _ATTACHMENT_WORDS
)
if machine is None or machine.loss_coefficient_per_hour is None:
incomplete.append(code)
continue
if record is None and not attachment:
incomplete.append(code)
continue
@@ -192,6 +211,21 @@ def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]:
)
book.add_detail(PriceDetail(hourly_code, base_code, Decimal(1), note="시간당 손료"))
if record is None:
# 부착용 장비 — 여기서 끝난다. 연료·조종원은 본체 줄에 이미 들어 있다.
book.add_detail(
PriceDetail(
hourly_code,
base_code,
Decimal(0),
note=(
"부착용 장비 — 연료·조종원은 본체(굴착기·불도저)에 듭니다"
" (품셈 제8장 [주]⑤)."
),
)
)
continue
liters = record.fuel_liters_per_hour
if liters is not None:
if record.misc_material_percent is not None:
@@ -353,6 +387,7 @@ def build_unit_prices(
)
from B09_Estimation.B09_Estimation_MachineProductivity_Reference import (
direct_capacity_rows,
reference_factor_values,
)
@@ -374,9 +409,22 @@ def build_unit_prices(
for failed_code, why in borrow_fail.items():
build.factor_sources.setdefault(failed_code, f"⚠ {why}")
build.component_gaps = dict(axis.partial_items)
# 「작업량을 직접 준」 기계(깨기 대형브레이커)도 사용료 층을 세운다 — 안 세우면
# 그 줄이 붙을 데가 없어 암·발파암 갈래의 깨기 몫이 통째로 빠진다.
capacity_rows = {
str(node.get("work_item_code", "")): direct_capacity_rows(node)
for node in master.get("work_items", [])
}
capacity_rows = {code: rows for code, rows in capacity_rows.items() if rows}
# ⚠ **참조로 이미 푼 줄은 「못 붙은 줄」이 아니다.** 안 걷어 내면 다 풀린 공종이
# 계속 반쪽으로 보이고, 그 표시를 믿고 막아 둔 금액이 영영 안 선다.
resolved_rows = {code: text for code, text in borrow_text.items() if code in borrow_note}
# 작업량을 직접 준 줄도 이제 붙었다 — 그 줄의 칸들은 「못 붙은 줄」이 아니다.
capacity_cells: dict[str, set[str]] = {}
for capacity_code, capacity_list in capacity_rows.items():
for entry in capacity_list:
capacity_cells.setdefault(capacity_code, set()).update(entry.get("row_cells") or [])
for unmatched_row in axis.unmatched:
# ⚠ 지역 이름을 조심할 것 — 바로 위 `names` 는 **공종 이름표**다. 같은 이름을 쓰면
# 그 표가 리스트로 덮여 조립이 통째로 터진다(2026-09-09 실측).
@@ -385,16 +433,35 @@ def build_unit_prices(
reference_text = resolved_rows.get(unmatched_row.work_item_code)
if reference_text and reference_text in label:
continue # 그 줄은 참조를 따라가 값을 얻었다
if label in capacity_cells.get(unmatched_row.work_item_code, set()):
continue # 그 줄은 표가 작업량을 직접 줘 붙었다
if label and label not in labels:
labels.append(label)
# ⚠ **거두는 자리는 「못 붙은 줄」을 다 모은 뒤다.** 앞에서 거두면 목록이 비어 있어
# **전부 거둬지고**, 깨기(대형브레이커)가 빠진 암 계열까지 「다 찼다」로 선다
# (2026-09-09 실측). 남은 줄이 하나도 없을 때만 거둔다.
def _names_a_machine(label: str) -> bool:
"""그 줄이 **기계를 가리키나** — 기계가 빠지면 막고, 자재가 빠지면 드러내기만 한다.
⚠ 두 가지는 무게가 다르다. 기계 몫은 단가의 대부분이라 빠지면 금액이 통째로
틀리고, 자재 소모품(치즐 0.006본/hr)은 카탈로그가 서면 채워지는 알려진 미결이다
(자원 축이 이미 그 규칙으로 가른다).
"""
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
flat = re.sub(r"\s", "", label)
return any(
re.sub(r"\s", "", machine.name) in flat
for machine in load_machine_catalog().machines.values()
if len(re.sub(r"\s", "", machine.name)) >= 3
)
solved_codes = {
code
for code in borrow_note
if code in axis.partial_items and not build.unattached.get(code)
if code in axis.partial_items
and not any(_names_a_machine(label) for label in build.unattached.get(code, []))
}
for code in solved_codes:
build.component_gaps.pop(code, None)
@@ -414,6 +481,7 @@ def build_unit_prices(
machine_codes = {r.resource_code for r in axis.rows if r.resource_kind == "machine"}
# 공식표에만 나오는 기종도 사용료 층을 세운다 — 안 세우면 공식이 붙을 데가 없다.
machine_codes |= formula_machine_codes(master)
machine_codes |= {row["machine_code"] for rows in capacity_rows.values() for row in rows}
build.incomplete_machines = _add_machine_layers(build.book, machine_codes)
# 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은
@@ -533,6 +601,31 @@ def build_unit_prices(
build.factor_sources,
)
# 작업량을 직접 준 기계 줄(깨기) — 공식 몫과 **자리를 나눠 쓴다**. 같은 묶음(장비
# 90%) 안에서 깨기와 들어내기가 차례로 붙는다.
attached_capacity = False
for capacity in capacity_rows.get(work_item_code, []):
hourly_code = f"X-{capacity['machine_code']}"
if hourly_code not in build.book.titles:
continue
group_share = (
Decimal(1)
if capacity["ratio_pct"] is None
else Decimal(str(capacity["ratio_pct"])) / Decimal(100)
)
build.book.add_detail(
PriceDetail(
title_code,
hourly_code,
(Decimal(1) / capacity["capacity_per_hour"]) * group_share,
note=(
f"작업량을 표가 직접 줌 — {capacity['cell']} {capacity['capacity_per_hour']}"
f" (품셈 원문 표기 그대로)"
),
)
)
attached_capacity = True
# ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.**
# 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이
# 조용히 서면 내역서가 틀린 줄 모른다(2026-09-08 실측: 측구터파기 39,575.6원/㎥
@@ -543,7 +636,7 @@ def build_unit_prices(
# ⚠ 공식은 있는데 **아무것도 안 붙은** 제목은 남기지 않는다 — 「상세 줄이 없어
# 조립 불가」로 화면에서 터진다. 기계 층이 못 선 경우가 그 자리다.
if not attachable and not build.book.details.get(title_code):
if not attachable and not attached_capacity and not build.book.details.get(title_code):
build.book.titles.pop(title_code, None)
if variant in build.variants.get(work_item_code, []):
build.variants[work_item_code].remove(variant)