Files
Aislo/B09_Estimation/B09_Estimation_Consumables.py
T
eomsangdonandClaude Opus 5 852ad6c7d8 feat(b09): ㉡ 2장 소요재료·기계손료 표를 읽는 한 벌 — 체인톱부터(4-2-2 단목베기 · 6-5 어린나무가꾸기)
· 장비 한 벌(Equipment)에 「어느 표 줄을 읽을지」 만 적고 값은 2장 표에서 읽음 — 나머지 소형 장비도 같은 길로 더함
· 체인톱 1대 1일 호표 = 휘발유 5.6ℓ(2-1-1) + 잡품 주연료비 40% + 손료 0.0084(2-2-1) × 구입가(「자재 단가」 칸 · 없으면 사유)
· 쓰는 공종에 표가 이름으로 밝힌 인원 줄 수량만큼(4-2-2 「벌목부」 · 6-5 「특별인부 (체인톱 사용)」 · 넓히지 않음)
· 체인오일 일반/친환경은 「자재 단가」 에 넣은 쪽 × 인원 × 2.1ℓ · 둘 다면 사유(브레인 판정 ①②③)

오른 제목: 4-2-2 +31·+41·+53원(+3.6%) · 6-5 +43,821원(+4.29%) · 나머지 그대로 · 프로젝트 내역 지장목제거 잡관목제거 +548,652(본체 109,122,948 → 109,671,600) · 잴 시험 넷 빨강→초록 · 끄기 넷 빨강 · 전체 시험 1712 + 381 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-15 02:15:48 +09:00

186 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""B09 원가계산 — **2장 소요재료·기계손료 표를 읽는 한 벌** (2026-09-15 브레인 ㉡ · 판정 ①②③).
산림품셈 2장은 소형 장비(체인톱·예취기·윈치·천공기 …)의 1대 1일 소모(연료·잡품·오일)와 손료계수를
표로 주고, 각 공종 표는 **인원**만 적음(「벌목부」·「특별인부 (체인톱 사용)」). 대수 규칙이 인원에 붙어
장비 몫을 세우는데 그 길이 없어 공종이 인력만으로 싸게 서 있었음(㉯ 셈 7 · 4-2-2 는 부모 표에만 장비 말).
장비 한 벌(`Equipment`) = 소요재료 표 줄 · 손료 표 · 고르는 줄(체인오일 일반/친환경) · 쓰는 공종과 인원 줄
값은 표에서 읽음(연료 ℓ/대/일 · 잡품 % · 오일 ℓ · 손료계수) — 여기엔 **어느 줄을 읽을지**만 적음
`X-<AR-X>` 1대 1일 호표 = 연료 × ℓ + 잡품(주연료비 %) + 손료(구입가 × 계수 · 구입가가 들면)
공종 제목에 장비 호표 × 인원 줄 수량(대수 = 인원 × 100%) · 고르는 오일은 넣은 쪽 하나 × 인원 × ℓ
⚠ 인원 줄은 **표가 이름으로 밝힌 줄만**(③) — 「비슷한 인원 줄」로 넓히지 않음.
⚠ 구입가·오일 단가는 카탈로그가 없어 「자재 단가」 칸 + 사유(②) · 휘발유는 유가 판으로 바로 섬.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any
@dataclass(frozen=True)
class Equipment:
key: str
machine: str # AR-X — 1대 1일 호표
price: str # AR-M — 구입가(손료 밑수) 칸
fuel: tuple[str, str, str] # (2-1 공종, 표, 주연료 줄 이름)
loss: tuple[str, str] # (2-2 공종, 표)
choices: tuple[tuple[str, str], ...] = () # 넣은 쪽 하나 — (AR-M, 2-1 표 줄 이름)
users: dict[str, str] = field(default_factory=dict) # 공종 → 표가 밝힌 인원 줄 이름
basis: str = ""
EQUIPMENTS: tuple[Equipment, ...] = (
Equipment(
key="체인톱",
machine="AR-X-61d1681d",
price="AR-M-5649cf3f",
fuel=("FP-02-01-01", "F0042", "보통휘발유 (주연료)"),
loss=("FP-02-02-01", "F0064"),
choices=(
("AR-M-fa7fbf6d", "체인오일 (일반오일)"),
("AR-M-de2fe662", "체인오일 (친환경오일)"),
),
# 2-1-1 [주]① 「숲가꾸기(작업로설치, 어린나무가꾸기, 단목베기) 및 수확베기, 병해충방제」 중
# 지금 제목이 서는 둘 · 표가 이름으로 밝힌 인원 줄(2026-09-15 브레인 ③).
users={"FP-04-02-02": "벌목부", "FP-06-05": "특별인부 (체인톱 사용)"},
basis="산림품셈 2-1-1 「체인톱 대수는 산출된 벌목부 또는 특별인부의 100% 적용」 · 2-2-1 손료",
),
)
_RE_NUMBER = re.compile(r"\d+(?:\.\d+)?")
def _tight(text: Any) -> str:
return re.sub(r"\s", "", str(text or ""))
def _table_rows(nodes: dict[str, dict[str, Any]], code: str, table_id: str) -> list[list[str]]:
node = nodes.get(code) or {}
table = next((t for t in node.get("tables") or [] if t.get("pum_table_id") == table_id), {})
return [[str(c) for c in row] for row in table.get("raw_row") or []]
def _row_numbers(rows: list[list[str]], name: str) -> list[Decimal]:
"""그 이름 줄의 수 칸들 — 이름 칸 뒤에서 차례로(빈 칸 건너뜀)."""
row = next((r for r in rows if r and _tight(r[0]) == _tight(name)), None)
if row is None:
return []
return [
Decimal(m.group()) for c in row[1:] if (m := _RE_NUMBER.fullmatch(_tight(c).rstrip("%")))
]
def _fuel_title(book: Any, kind: str, fuel_region: str | None) -> str:
from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price
from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle
from B09_Estimation.B09_Estimation_UnitPrice import FUEL_CODE_PREFIX, _slots
code = f"{FUEL_CODE_PREFIX}{kind}"
if code not in book.titles:
price, meta = load_fuel_price(region=fuel_region, kind=kind)
spec = f"{meta.get('region_name')} 공시가" if meta.get("region_name") else "전국 공시가"
book.add_title(PriceTitle(code, PriceKind.MATERIAL, kind, spec, "L", slots=_slots(price)))
return code
def _machine_title(build: Any, nodes: dict, eq: Equipment, fuel_region: str | None) -> list[str]:
"""`X-<AR-X>` 1대 1일 — 표에서 연료·잡품·손료계수를 읽음. 돌려주는 값 = 못 붙은 사유들."""
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle
from B09_Estimation.B09_Estimation_UnitPrice import _misc_row, _slots
book = build.book
code = f"X-{eq.machine}"
fuel_rows = _table_rows(nodes, eq.fuel[0], eq.fuel[1])
numbers = _row_numbers(fuel_rows, eq.fuel[2])
loss = _row_numbers(_table_rows(nodes, *eq.loss), _table_rows(nodes, *eq.loss)[0][0])
if len(numbers) < 2 or not loss:
return [f"{eq.key} — 2장 표 칸이 달라져 장비 몫을 못 읽음"]
if code in book.titles:
return []
kind = "휘발유" if "휘발유" in eq.fuel[2] else "경유"
liters, misc = numbers[0], numbers[1]
book.add_title(PriceTitle(code, PriceKind.MACHINE_HOURLY, eq.key, "1대 1일", "대·일"))
book.add_detail(
PriceDetail(code, _fuel_title(book, kind, fuel_region), liters, note=f"주연료 {kind}")
)
book.add_detail(_misc_row(code, misc))
reasons = []
if eq.price in book.titles:
base = f"S-{eq.machine}"
price = book.titles[eq.price].slots[-1]
book.add_title(
PriceTitle(
base, PriceKind.MACHINE_BASE, eq.key, "손료", "대·일", slots=_slots(price * loss[0])
)
)
book.add_detail(PriceDetail(code, base, Decimal(1), note=f"손료 = 구입가 × {loss[0]}"))
else:
reasons.append(f"{eq.key}가격 — 손료({loss[0]} × 구입가) 칸 · 「자재 단가」 에 넣으면 붙음")
return reasons
def attach_consumables(
build: Any, nodes: dict[str, dict[str, Any]], fuel_region: str | None = None
) -> None:
"""장비마다 1대 1일 호표를 세우고, 쓰는 공종 제목에 인원 줄 수량만큼 붙임."""
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail
book = build.book
for eq in EQUIPMENTS:
for material in (eq.price, *(c for c, _ in eq.choices)):
uses = build.material_uses.setdefault(material, [])
uses.extend(c for c in eq.users if c not in uses)
machine_reasons = _machine_title(build, nodes, eq, fuel_region)
fuel_rows = _table_rows(nodes, eq.fuel[0], eq.fuel[1])
picked = [(c, name) for c, name in eq.choices if c in book.titles]
for work_item, user_row in eq.users.items():
node_rows = [
r
for t in (nodes.get(work_item) or {}).get("tables") or []
for r in t.get("raw_row") or []
]
# 이름 칸이 뭉친 표(「벌목부 보통인부」)도 낱말로 봄 — 그 이름이 없으면 넓히지 않음(③).
if not any(
r and (_tight(r[0]) == _tight(user_row) or user_row in str(r[0]).split())
for r in node_rows
):
continue
labor_name = _tight(user_row).split("(")[0]
reasons = list(machine_reasons)
for title in [
t for t in book.titles if t == f"B-{work_item}" or t.startswith(f"B-{work_item}#")
]:
if f"X-{eq.machine}" not in book.titles:
break
users = [
r
for r in book.details.get(title, [])
if book.titles.get(r.ref_code)
and _tight(book.titles[r.ref_code].name) == labor_name
]
if not users:
continue
count = users[0].quantity
note = f"{eq.key} {count}대(인원 「{user_row}× 100%) — {eq.basis}"
book.add_detail(PriceDetail(title, f"X-{eq.machine}", count, note=note))
if len(picked) == 1:
code, name = picked[0]
liters = _row_numbers(fuel_rows, name)[0]
book.add_detail(
PriceDetail(title, code, count * liters, note=f"{name} {liters}/대/일")
)
if len(picked) > 1:
reasons.append(
f"{eq.choices[0][1]}·{eq.choices[1][1]} 가 둘 다 들어옴 — 하나만 넣을 것"
)
elif not picked and eq.choices:
reasons.append(
f"{' 또는 '.join(name for _, name in eq.choices)} — 시중가격 칸(설계자 선택) · 넣은 쪽이 붙음"
)
labels = build.unattached.setdefault(work_item, [])
labels.extend(r for r in reasons if r not in labels)