diff --git a/B09_Estimation/B09_Estimation_RockLoss.py b/B09_Estimation/B09_Estimation_RockLoss.py new file mode 100644 index 00000000..b740a9cc --- /dev/null +++ b/B09_Estimation/B09_Estimation_RockLoss.py @@ -0,0 +1,162 @@ +"""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_coefficient(code: str) -> Decimal | None: + """암석 손료계수 = (상각 + 정비) × (1 + 가산) + 관리 — 1e-7 정수 아래 버림(봉화 3533.75 → 0.3533).""" + rate, _ = rock_rate(code) + record = _sources()[0].get(code) + if rate is None or record is None: + return None + parts = [ + Decimal(str(record[f"{key}_coefficient_1e_minus_7"])) + for key in ("depreciation", "maintenance", "management") + ] + raised = (parts[0] + parts[1]) * (1 + Decimal(rate) / 100) + parts[2] + return raised.quantize(Decimal(1), rounding=ROUND_FLOOR) * _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 diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 835e85f4..bac57652 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -1090,6 +1090,10 @@ def build_unit_prices( build.combined_swapped = _apply_combined_misc_rate( build.book, [code for code in build.book.titles if code.startswith("B-")] ) + # 암석 작업 기계손료 보정(건설품셈 8-1-7 1 · 2026-09-14 661) — 조합 바꿔 달기 뒤(`#조합` 은 안 걺). + from B09_Estimation.B09_Estimation_RockLoss import attach_rock_loss + + attach_rock_loss(build, master) build.labor_reliability = _labor_reliability_in_use(build.book) # ③ 할증 포함 재료량을 준 공종에 자재가 재료비로 붙지 않았는가(명세 6장 · ㉠). from B09_Estimation.B09_Estimation_Guards import check_materials_before_surcharge diff --git a/resources/tester/test_b09_rock_loss.py b/resources/tester/test_b09_rock_loss.py new file mode 100644 index 00000000..38e11143 --- /dev/null +++ b/resources/tester/test_b09_rock_loss.py @@ -0,0 +1,79 @@ +"""암석 작업 기계손료 보정 — 건설품셈 8-1-7 1 (2026-09-14 브레인 661 ①②). + +원문: 「암석굴착, 암석적재, 암석운반 등의 가혹한 작업에 사용되는 경우에는 손료(관리비 제외)를 보정 가산」 + 불도저(19톤 이상 제외) 25 · 굴착기(무한궤도) 및 로더(무한궤도) 20 · 덤프트럭 25 (%) + [주]① 전용덤프트럭(18톤 이상)과 불도저(19톤 이상)는 보정하지 않음(타이어·습지 불도저는 보정) +실무 봉화 2024 중기목록 「(암석)」 줄 셋이 (상각 + 정비) × (1 + 가산) + 관리 로 역산됨 — +굴착기 1.0 0.2405 · 덤프 2.5 0.3533 · 덤프 15 0.2679. B09 는 이 보정을 한 번도 안 걸고 있었음. +⚠ 브레이커 조합 본체(`#조합`)는 안 걺 — 봉화 「굴삭기 0.7 브레이커조합」 손료 = 비암석 23,128 + 브레이커. +⚠ 전석섞인토사 10% 는 혼입율 입력이 없어 안 걺(② · 칸도 안 만듦). +""" + +from __future__ import annotations + +from decimal import ROUND_FLOOR, Decimal + +from B09_Estimation.B09_Estimation_UnitPrice import cached_build + +HAUL = ("164.23",) + + +def _rows(book, code: str) -> list: + """그 제목과 제 단가산출(D) 줄.""" + 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 _machines(book, code: str) -> set[str]: + """그 제목과 제 단가산출(D) 줄이 부르는 기계 호표.""" + return {r.ref_code for r in _rows(book, code) if r.ref_code.startswith("X-")} + + +def test_실무_암석_손료계수_셋이_원천표로_역산됨() -> None: + from B09_Estimation.B09_Estimation_RockLoss import rock_coefficient + + # 실무 표기는 취득가 천원당 — 우리 계수는 원당(× 1000 이 실무 값) + assert rock_coefficient("0201-0100") * 1000 == Decimal("0.2405") # 굴착기 1.0 (900+700)×1.2+485 + assert rock_coefficient("0602-0025") * 1000 == Decimal("0.3533") # 덤프 2.5 3533.75 버림 + assert rock_coefficient("0602-0150") * 1000 == Decimal("0.2679") # 덤프 15 + assert rock_coefficient("0101-0019") is None # 불도저 19톤 — [주]① 보정 안 함 + assert rock_coefficient("0602-0240") is None # 덤프 24톤 — [주]① 18톤 이상 + assert rock_coefficient("0211-0060") is None # 굴착기(타이어) — 표에 없음 + + +def test_암_공종의_대상_기계는_암석_호표를_부름() -> None: + book = cached_build(dump_haul_m=HAUL).book + assert "X-0201-0070#암석" in _machines(book, "B-FP-10-12-02#적재") + assert "X-0602-0150#암석" in _machines(book, "B-FP-10-12-02#L164.23m") + assert "X-0602-0150#암석" in _machines(book, "B-FP-10-12-03#L164.23m") + assert "X-0201-0070#암석" in _machines(book, "B-FP-09-04-02") # 암절취 집토 + assert "X-0201-0070#암석" in _machines(book, "B-FP-09-19-02") # 비탈면 면고르기(암절취) + + +def test_토사_공종과_브레이커_조합_본체는_그대로() -> None: + book = cached_build(dump_haul_m=HAUL).book + assert _machines(book, "B-FP-10-12-01#적재") == {"X-0201-0070"} + assert "X-0602-0150" in _machines(book, "B-FP-10-12-01#L164.23m") + assert "X-0201-0070#조합" in _machines(book, "B-FP-09-04-01#연암") + assert not any(m.endswith("#암석") for m in _machines(book, "B-FP-09-19-01#절토면·풍화암")) + + +def test_불도저_19톤_발파암_운반은_보정_안_하고_까닭을_남김() -> None: + book = cached_build(dump_haul_m=HAUL).book + rows = [r for r in _rows(book, "B-FP-10-11#발파암") if r.ref_code == "X-0101-0019"] + assert rows and "8-1-7" in rows[0].note and "19톤" in rows[0].note, rows + + +def test_암석_호표_손료는_취득가_곱하기_보정_계수() -> None: + from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + + book = cached_build(dump_haul_m=HAUL).book + price = load_machine_catalog().machines["0201-0070"].price_thousand_krw * 1000 + rock = book.resolve("X-0201-0070#암석").expense + plain = book.resolve("X-0201-0070").expense + assert rock == (price * Decimal("0.0002405")).quantize(Decimal(1), rounding=ROUND_FLOOR) + assert plain == (price * Decimal("0.0002085")).quantize(Decimal(1), rounding=ROUND_FLOOR) + assert book.resolve("X-0201-0070#암석").labor == book.resolve("X-0201-0070").labor