"""B09 원가계산 — **암석 작업 기계손료 보정** (건설품셈 8-1-7 1 · 2026-09-14 브레인 661 ①②). 원문: 「다음 건설기계가 암석굴착, 암석적재, 암석운반 등의 가혹한 작업에 사용되는 경우에는 손료(관리비 제외)를 다음과 같이 보정 가산한다」 — 불도저(19톤 이상 제외) 25 · 굴착기(무한궤도) 및 로더(무한궤도) 20 · 덤프트럭 25 (%) · [주]① 전용덤프트럭(18톤 이상)과 불도저(19톤 이상)는 보정하지 않는다(타이어·습지 불도저는 보정). 율은 `mach_base` 의 `mach_rock_adj`(원문 파싱) 한 벌. 암석 손료계수 = (상각 + 정비) × (1 + 가산) + 관리 — 1e-7 정수 아래 버림 실무 봉화 2024 「(암석)」 줄 셋이 그대로 역산됨(굴착기 1.0 0.2405 · 덤프 2.5 0.3533 · 덤프 15 0.2679) 거는 자리 암 공종(자기·부모 이름이나 갈래가 연암·보통암·경암·발파암·파쇄암·암절취·암석)의 대상 기계 줄만 — 기계 호표를 「암석」 한 벌 더 세워 부름(봉화와 같은 모양) 안 거는 것 브레이커 조합 본체(`#조합`) — 봉화 「굴삭기 0.7 브레이커조합」 손료가 비암석 23,128 + 브레이커 풍화암·호박돌 섞인 토사 — 원문 표 「암석작업(연암·보통암·경암)」 밖 전석섞인토사 10% — 혼입율(0.5㎥ 이상 전석 30% 이상) 입력이 없어 판정 못 함(② · 칸 안 만듦) """ from __future__ import annotations import re from dataclasses import replace from decimal import ROUND_FLOOR, Decimal from functools import lru_cache from typing import Any ROCK_SUFFIX = "#암석" _ROCK_WORDS = re.compile(r"연암|보통암|경암|발파암|파쇄암|암절취|암석") _E7 = Decimal("1e-7") NOT_CORRECTED = "8-1-7 [주]① {what} 은 암석 손료보정 안 함" def _tight(text: Any) -> str: return re.sub(r"\s", "", str(text or "")) @lru_cache(maxsize=1) def _sources() -> tuple[dict[str, dict[str, Any]], dict[str, int]]: """(기계 코드 → 손료 성분 레코드, 규칙 이름 → 암석 가산 %).""" from B09_Estimation.B09_Estimation_MachineCost import _read_json variables = _read_json("mach_base_2026.json")["variables"] records = {r["machine_code"]: r for r in variables["mach_loss_coef"]["records"]} rules = {r["machine_group"]: int(r["rock_work"]) for r in variables["mach_rock_adj"]["rules"]} return records, rules def rock_rate(code: str) -> tuple[int | None, str]: """(가산 %, 안 거는 까닭) — 표에 없는 기종은 `(None, "")`.""" from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog machine = load_machine_catalog().machines.get(code) if machine is None: return None, "" _, rules = _sources() name = _tight(machine.name) size = re.match(r"\d+(?:\.\d+)?", machine.specification.replace(",", "")) tons = Decimal(size.group()) if size else Decimal(0) if name in ("불도저(타이어)", "습지불도저"): return rules["bulldozer_under_19_ton"], "" if name == "불도저(무한궤도)": if tons >= 19: return None, NOT_CORRECTED.format(what="불도저 19톤 이상") return rules["bulldozer_under_19_ton"], "" if name in ("굴착기(무한궤도)", "로더(무한궤도)"): return rules["crawler_excavator_or_loader"], "" if name == "덤프트럭": if tons >= 18: return None, NOT_CORRECTED.format(what="덤프트럭 18톤 이상") return rules["dump_truck"], "" return None, "" def rock_parts(code: str) -> tuple[Decimal, Decimal, Decimal, int] | None: """(보정 상각, 보정 정비, 관리, 계) — 1e-7 단위 · 계는 정수 아래 버림(봉화 3533.75 → 3533).""" rate, _ = rock_rate(code) record = _sources()[0].get(code) if rate is None or record is None: return None depreciation, maintenance, management = ( Decimal(str(record[f"{key}_coefficient_1e_minus_7"])) for key in ("depreciation", "maintenance", "management") ) factor = 1 + Decimal(rate) / 100 raised = (depreciation * factor, maintenance * factor, management) return (*raised, int(sum(raised).quantize(Decimal(1), rounding=ROUND_FLOOR))) def rock_coefficient(code: str) -> Decimal | None: """암석 손료계수(원당) = (상각 + 정비) × (1 + 가산) + 관리 — `rock_parts` 의 계 × 1e-7.""" parts = rock_parts(code) return None if parts is None else parts[3] * _E7 def _rock_hourly(book: Any, code: str) -> str | None: """`X-<코드>#암석` — 손료만 보정 계수로 바꾼 호표(연료·조종원·잡품은 본 호표 그대로).""" from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle from B09_Estimation.B09_Estimation_UnitPrice import _slots hourly, rock = f"X-{code}", f"X-{code}{ROCK_SUFFIX}" if rock in book.titles: return rock coefficient = rock_coefficient(code) if coefficient is None or hourly not in book.titles: return None machine = load_machine_catalog().machines[code] rate, _ = rock_rate(code) base, rock_base = f"S-{code}", f"S-{code}{ROCK_SUFFIX}" plain = book.titles[base] book.add_title( replace( plain, code=rock_base, slots=_slots(machine.price_thousand_krw * 1000 * coefficient) ) ) title = book.titles[hourly] book.add_title( PriceTitle( code=rock, kind=PriceKind.MACHINE_HOURLY, name=title.name, spec=f"{title.spec} · 암석".strip(" ·"), unit=title.unit, ) ) note = f"암석 작업 손료보정 — (상각 + 정비) × {100 + rate}% + 관리 = {coefficient} (건설품셈 8-1-7 1)" for detail in book.details.get(hourly, []): ref = {base: rock_base, hourly: rock}.get(detail.ref_code, detail.ref_code) book.add_detail( replace( detail, parent_code=rock, ref_code=ref, note=note if detail.ref_code == base else detail.note, ) ) return rock def attach_rock_loss(build: Any, master: dict[str, Any]) -> int: """암 공종의 대상 기계 줄을 암석 호표로 바꿔 닮 — 바꾼 줄 수. 조합 16% 바꿔 달기 **뒤**에 부름.""" nodes = {str(n.get("work_item_code")): n for n in master.get("work_items", [])} book = build.book changed = 0 for title_code in [code for code in book.titles if code.startswith("B-")]: work_item, _, variant = title_code[2:].partition("#") node = nodes.get(work_item) or {} parent = nodes.get(str(node.get("parent_code"))) or {} title = book.titles[title_code] text = " ".join(map(str, (node.get("name"), parent.get("name"), title.name, variant))) if not _ROCK_WORDS.search(text): continue own = book.details.get(title_code) or [] owners = [title_code, *(d.ref_code for d in own if d.ref_code.startswith("D-"))] for owner in owners: details = book.details.get(owner) or [] for index, detail in enumerate(details): ref = detail.ref_code if not ref.startswith("X-") or "#" in ref: continue rate, why = rock_rate(ref[2:]) if rate is None: if why and why not in detail.note: details[index] = replace(detail, note=f"{detail.note} · {why}".strip(" ·")) continue rock = _rock_hourly(book, ref[2:]) if rock: details[index] = replace(detail, ref_code=rock) changed += 1 return changed