feat(b09): ㉰ 이동식 임목 파쇄 8-11 일위대가 — 운전경비표에 값이 없던 파쇄기 기계 층을 8-11 [주]⑤·비고로 세움

· 건설 8-4 운전경비표 7205-0125 93.25㎾ 줄은 연료·잡품 칸이 「-」 → 레코드가 없어 기계 층도 일위대가도 조용히 없었음
· 8-11 [주]⑤ 연료 13.5ℓ(경유) · 비고 잡재료 주연료비 16% · [주]② 기계운전자 1인이 유일한 값이라 그대로(브레인 판정)
· B-FP-08-11 ㎥당 = 파쇄기 1/Q(3.5) + 보통인부 2인 ÷ 8시간 ÷ Q · 기계운전자는 기계 층 조종원으로 한 번만
· 파쇄기날(자원 목록 새 칸) — 「자재 단가」 에 넣으면 0.00125개/hr ÷ Q, 아니면 사유 · [주]③ 운반비·④ 우드그랩·⑥ 규격 외 견적은 사유 한 줄
· 영월 호표는 옛 판(8-2-35 Q=6)이라 대조 대상 아님

새 제목 B 51,029원/㎥ · 바뀐 제목 0 · 프로젝트 내역 그대로(파쇄 기본 끔) · 잴 시험 넷 빨강→초록 · 끄기 넷 빨강 · 전체 시험 1711 + 371 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-15 01:08:56 +09:00
co-authored by Claude Opus 5
parent 08ff9458ea
commit 0fc64fa704
5 changed files with 209 additions and 0 deletions
@@ -57,6 +57,11 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = {
# 2026-09-14 ㉮ — 사용횟수 갈래로 푼 뒤 남는 원문 몫. 값을 짓지 않고 말만.
# 봉상후렉시블 셋의 표 나머지 줄은 판정표 자동 목록(`_unread_rows`)이 맡음 — 줄이 아닌 [주] 만 여기.
"FP-12-12": ("원문 [주]", "ⓘ [주] 「성토부 날개벽 설치시 인건비 30% 할증」 은 안 걺(선택)."),
"FP-08-11": (
"원문 [주]",
"ⓘ [주]③ 장비 운반비는 별도 계상(기계 수송비 칸) · [주]④ 우드그랩은 원목 규격에 따라 별도 · [주]② 추가"
" 인력(파쇄 후 마대담기 등)은 조사해 반영 · [주]⑥ 이 규격 외 파쇄기는 견적 — 이 일위대가엔 안 넣음.",
),
# 원문 대 실무 어긋남 기록(2026-09-15 브레인 규칙 — 원문이 또렷하면 원문 · 어긋남은 늘 기록).
"FP-09-15-02": (
"실무 어긋남",
@@ -295,6 +295,14 @@ def _add_machine_layers(
"""
catalog = load_machine_catalog()
operating = {r.machine_code: r for r in load_operating_records().records}
# 8-4 칸이 「-」 라 레코드가 없는 이동식 임목파쇄기 93.25 — 8-11 [주]⑤·비고가 유일한 값(2026-09-15).
from B09_Estimation.B09_Estimation_WoodChipping import MACHINE as CHIPPER
from B09_Estimation.B09_Estimation_WoodChipping import operating_record as chipper_record
if CHIPPER in machine_codes and CHIPPER not in operating:
chipper = chipper_record()
if chipper is not None:
operating[CHIPPER] = chipper
wages = load_operator_wages()
incomplete: list[str] = []
@@ -796,6 +804,9 @@ def build_unit_prices(
if dump_haul_m:
machine_codes.add(DUMP_TRUCK_CODE)
from B09_Estimation.B09_Estimation_WoodChipping import MACHINE as CHIPPER
machine_codes.add(CHIPPER) # 8-11 표가 자원 줄로 안 읽혀(Q 칸) 공종 모듈이 부름
build.operator_wage_digits = operator_wage_digits
build.incomplete_machines = _add_machine_layers(
build.book, machine_codes, fuel_region, operator_wage_digits
@@ -1099,6 +1110,10 @@ def build_unit_prices(
from B09_Estimation.B09_Estimation_TopsoilRemoval import attach_topsoil_removal
attach_topsoil_removal(build, nodes_by_code)
# 이동식 임목 파쇄(8-11) — 파쇄기 1/Q · 보통인부 2인/8h/Q · 파쇄기날(2026-09-15 ㉰).
from B09_Estimation.B09_Estimation_WoodChipping import attach_wood_chipping
attach_wood_chipping(build, nodes_by_code)
# ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다
# (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다.
build.combined_swapped = _apply_combined_misc_rate(
@@ -0,0 +1,113 @@
"""B09 원가계산 — **이동식 임목 파쇄** 8-11 (2026-09-15 브레인 판정 · ㉰ 둘째).
원문 L4651 : 이동식 임목 파쇄기 93.25KW · Q = 3.5 /hr · 비고 잡재료 : 주연료비의 16% · 소모품비(파쇄기날)
0.00125/hr · [] 1 8시간 1 기계운전자 1·보통인부 2 장비 운반비 별도 우드그랩 별도
연료 10.8 + 16.3 / 2 = 13.5 (디젤).
건설품셈 운전경비표(8-4 L3458) 7205-0125 93.25 줄은 연료·잡품 칸이 - 운전경비 레코드가 없어
기계 층이 서고 일위대가도 조용히 없었음. 8-11 []·비고가 **유일한 **(부딪히는 원문 없음)이라 그대로 .
기계운전자는 기계 조종원(건설기계운전사 · 8-4 잠정 규칙과 같은 직종)으로 번만 .
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
CODE = "FP-08-11"
MACHINE = "7205-0125"
BLADE = "AR-M-249d0a01"
TABLE = "F0237"
#: [주]⑤ — 표 칸이 아니라 [주] 에만 있어 마스터 원문 줄에 안 실림. 원문 그대로 한 곳.
FUEL_LITERS_PER_HOUR = Decimal("13.5")
#: [주]① 1일 8시간 · [주]② 1일 보통인부 2인(기계운전자 1인은 기계 층 조종원).
HOURS_PER_DAY = Decimal(8)
LABORERS_PER_DAY = Decimal(2)
LABORER_CODE = "1002" # 보통인부
BLADE_MISSING = "파쇄기날 — 규격·단가 없음 · 「자재 단가」 탭에서 넣으면 0.00125개/hr ÷ Q 로 붙음"
_RE_Q = re.compile(r"Q\s*=\s*(\d+(?:\.\d+)?)")
_RE_MISC = re.compile(r"주연료비의\s*(\d+(?:\.\d+)?)\s*%")
_RE_BLADE = re.compile(r"(\d+(?:\.\d+)?)\s*개\s*/\s*hr")
def _table_values(node: dict[str, Any]) -> tuple[Decimal, Decimal, Decimal] | None:
"""(Q ㎥/hr, 잡재료 %, 파쇄기날 개/hr) — 표 한 줄에서. 칸이 달라지면 `None`."""
table = next((t for t in node.get("tables") or [] if t.get("pum_table_id") == TABLE), {})
text = " ".join(str(c) for row in table.get("raw_row") or [] for c in row)
found = [pattern.search(text) for pattern in (_RE_Q, _RE_MISC, _RE_BLADE)]
if not all(found):
return None
return tuple(Decimal(m.group(1)) for m in found) # type: ignore[return-value]
def operating_record(node: dict[str, Any] | None = None):
"""7205-0125 운전경비 — 8-11 [주]⑤ 연료 · 비고 잡재료 · [주]② 기계운전자 1인."""
from B09_Estimation.B09_Estimation_MachineOperating import (
_CATALOG_SUBPATH,
OperatingRecord,
_operator_code,
_read_json,
)
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
if node is None:
node = next(n for n in load_work_item_master()["work_items"] if n["work_item_code"] == CODE)
values = _table_values(node)
if values is None:
return None
aliases = _read_json(*_CATALOG_SUBPATH, "labor_const_2026-01-01.json")["variables"]["aliases"]
return OperatingRecord(
machine_code=MACHINE,
machine_name="이동식 임목파쇄기",
specification="93.25",
fuel_liters_per_hour=FUEL_LITERS_PER_HOUR,
fuel_kind="경유",
misc_material_percent=values[1],
operator_person_days=Decimal(1),
operator_occupation_code=_operator_code("이동식 임목파쇄기", aliases),
)
def attach_wood_chipping(build: Any, nodes_by_code: dict[str, dict[str, Any]]) -> None:
"""`B-FP-08-11` ㎥당 — 파쇄기 1/Q(D) · 보통인부 2인 ÷ 8시간 ÷ Q · 파쇄기날(단가가 들면)."""
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle
node = nodes_by_code.get(CODE)
values = _table_values(node or {})
book = build.book
uses = build.material_uses.setdefault(BLADE, [])
if CODE not in uses:
uses.append(CODE)
if node is None or values is None or f"X-{MACHINE}" not in book.titles:
build.component_gaps[CODE] = "8-11 표 칸이 달라졌거나 파쇄기 기계 층이 안 섬"
return
q, _misc, blade_per_hour = values
title = f"B-{CODE}"
book.add_title(
PriceTitle(code=title, kind=PriceKind.UNIT_PRICE, name=str(node.get("name")), unit="")
)
book.add_output_detail(
title, f"X-{MACHINE}", Decimal(1) / q, f"Q = {q} ㎥/hr (산림품셈 8-11)", output=q
)
book.add_detail(
PriceDetail(
title,
LABORER_CODE,
LABORERS_PER_DAY / HOURS_PER_DAY / q,
note=f"[주]② 1일 보통인부 {LABORERS_PER_DAY}인 ÷ [주]① {HOURS_PER_DAY}시간 ÷ Q {q}",
)
)
labels = [label for label in build.unattached.get(CODE, []) if "파쇄기날" not in label]
if BLADE in book.titles:
book.add_detail(
PriceDetail(
title, BLADE, blade_per_hour / q, note=f"파쇄기날 {blade_per_hour}개/hr ÷ Q"
)
)
else:
labels.append(BLADE_MISSING)
build.unattached[CODE] = labels
if CODE in build.skipped:
build.skipped.remove(CODE)
@@ -381,6 +381,19 @@
"F0393"
]
}
},
{
"code": "AR-M-249d0a01",
"kind": "material",
"name": "파쇄기날",
"spec": "이동식 임목파쇄기용",
"unit": "개",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0237"
]
}
}
]
}
@@ -0,0 +1,63 @@
"""이동식 임목 파쇄 8-11 — 2026-09-15 브레인 판정(㉰ 둘째).
원문 L4651: 이동식 임목 파쇄기 93.25KW · Q = 3.5 /hr · 비고 잡재료 : 주연료비의 16% · 소모품비(파쇄기날) 0.00125/hr
[] 1 8시간 1 기계운전자 1 · 보통인부 2 장비 운반비 별도 우드그랩 별도 연료 13.5(디젤)
건설품셈 운전경비표(8-4 L3458) 7205-0125 93.25 줄은 연료·잡품 칸이 - 우리 운전경비 레코드가 없어
기계 층이 서고 일위대가도 조용히 없었음. 8-11 []·비고가 **유일한 **이라 그대로 (판정할 없음).
파쇄기날은 규격·단가가 없어 자재 단가 + 사유(화약류·치즐과 같은 통로).
"""
from __future__ import annotations
from decimal import Decimal
from B09_Estimation.B09_Estimation_UnitPrice import cached_build
BLADE = "AR-M-249d0a01"
Q = Decimal("3.5")
def _rows(book, code: str) -> list:
own = book.details.get(code) or []
return [
*own,
*(r for d in own if d.ref_code.startswith("D-") for r in book.details[d.ref_code]),
]
def test_파쇄기_기계_층이_8_11_주5_연료와_잡재료_16퍼센트로_섬() -> None:
book = cached_build().book
rows = {r.ref_code: r for r in book.details["X-7205-0125"]}
assert rows["M-FUEL-경유"].quantity == Decimal("13.5"), rows
misc = next(r for r in book.details["X-7205-0125"] if r.percent_of_material is not None)
assert misc.percent_of_material == Decimal(16)
assert any(code.endswith("#시간") for code in rows), rows # 기계운전자 1인(조종원 시간당)
def test_파쇄_일위대가는_기계_1_Q_와_보통인부_2인_8시간() -> None:
build = cached_build()
book = build.book
assert book.titles["B-FP-08-11"].unit == ""
rows = _rows(book, "B-FP-08-11")
machine = next(r for r in rows if r.ref_code == "X-7205-0125")
assert machine.quantity == Decimal(1) / Q and machine.output == Q, machine
labor = next(r for r in rows if r.ref_code == "1002")
assert labor.quantity == Decimal(2) / Decimal(8) / Q, labor
assert not any(r.ref_code.startswith("1048") for r in rows) # 운전자는 기계 층에 — 두 번 안 셈
def test_파쇄기날은_단가가_없으면_사유_있으면_시간당_수량_나누기_Q() -> None:
build = cached_build()
assert "FP-08-11" in build.material_uses.get(BLADE, [])
assert any("파쇄기날" in label for label in build.unattached.get("FP-08-11", []))
priced = cached_build(material_prices=((BLADE, "150000", "견적"),))
blade = next(r for r in _rows(priced.book, "B-FP-08-11") if r.ref_code == BLADE)
assert blade.quantity == Decimal("0.00125") / Q, blade
assert not any("파쇄기날" in label for label in priced.unattached.get("FP-08-11", []))
def test_원문_주의_별도_계상은_사유로() -> None:
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
note = known_gap_note("FP-08-11")
assert "운반비" in note and "우드그랩" in note, note