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
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
"""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)
|
||||
@@ -1114,6 +1114,10 @@ def build_unit_prices(
|
||||
from B09_Estimation.B09_Estimation_WoodChipping import attach_wood_chipping
|
||||
|
||||
attach_wood_chipping(build, nodes_by_code)
|
||||
# 2장 소요재료·기계손료 표(체인톱 …) — 인원 줄에 장비 몫을 붙임(2026-09-15 ㉡).
|
||||
from B09_Estimation.B09_Estimation_Consumables import attach_consumables
|
||||
|
||||
attach_consumables(build, nodes_by_code, fuel_region)
|
||||
# ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다
|
||||
# (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다.
|
||||
build.combined_swapped = _apply_combined_misc_rate(
|
||||
|
||||
@@ -394,6 +394,59 @@
|
||||
"F0237"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "AR-X-61d1681d",
|
||||
"kind": "machine",
|
||||
"name": "체인톱",
|
||||
"spec": "45cc (배기량기준)",
|
||||
"unit": "대·일",
|
||||
"source": {
|
||||
"pum_edition": "2026-01-01",
|
||||
"pum_table_ids": [
|
||||
"F0042",
|
||||
"F0064"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "AR-M-5649cf3f",
|
||||
"kind": "material",
|
||||
"name": "체인톱 구입가",
|
||||
"spec": "45cc (배기량기준) · 손료 밑수",
|
||||
"unit": "대",
|
||||
"source": {
|
||||
"pum_edition": "2026-01-01",
|
||||
"pum_table_ids": [
|
||||
"F0064"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "AR-M-fa7fbf6d",
|
||||
"kind": "material",
|
||||
"name": "체인오일",
|
||||
"spec": "일반오일",
|
||||
"unit": "ℓ",
|
||||
"source": {
|
||||
"pum_edition": "2026-01-01",
|
||||
"pum_table_ids": [
|
||||
"F0042"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "AR-M-de2fe662",
|
||||
"kind": "material",
|
||||
"name": "체인오일",
|
||||
"spec": "친환경오일",
|
||||
"unit": "ℓ",
|
||||
"source": {
|
||||
"pum_edition": "2026-01-01",
|
||||
"pum_table_ids": [
|
||||
"F0042"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""2장 소요재료·기계손료 표를 읽는 한 벌 — 체인톱부터(2026-09-15 브레인 ㉡ · 판정 ①②③).
|
||||
|
||||
산림품셈 2-1-1 체인톱 표(F0042): 보통휘발유(주연료) 5.6ℓ/대/일 · 잡품 주연료비 40% · 체인오일(일반·친환경) 2.1ℓ/대/일
|
||||
「체인톱 대수는 산출된 벌목부 또는 특별인부의 100% 적용」 · 2-2-1 체인톱 손료(F0064): 45cc 0.0084(1일 1대) × 체인톱가격
|
||||
체인톱을 쓰는 공종(4-2-2 단목베기 · 6-5 어린나무가꾸기)이 인력만으로 서서 연료·잡품·손료가 통째로 빠져 있었음.
|
||||
③ 표가 이름으로 밝힌 인원 줄만 — 4-2-2 「벌목부」 · 6-5 「특별인부 (체인톱 사용)」
|
||||
① 체인오일 일반/친환경은 「자재 단가」 에 넣은 쪽 · 둘 다면 안 붙고 사유
|
||||
② 체인톱가격·체인오일 시중가격은 칸 + 사유 · 휘발유는 유가 판으로 바로 섬
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import ROUND_FLOOR, Decimal
|
||||
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import cached_build
|
||||
|
||||
SAW = "X-AR-X-61d1681d"
|
||||
SAW_PRICE = "AR-M-5649cf3f"
|
||||
OIL = "AR-M-fa7fbf6d"
|
||||
ECO_OIL = "AR-M-de2fe662"
|
||||
|
||||
|
||||
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_체인톱_1대_1일_호표는_표의_휘발유와_잡품_40퍼센트() -> None:
|
||||
book = cached_build().book
|
||||
rows = book.details[SAW]
|
||||
fuel = next(r for r in rows if r.ref_code == "M-FUEL-휘발유")
|
||||
assert fuel.quantity == Decimal("5.6"), fuel
|
||||
misc = next(r for r in rows if r.percent_of_material is not None)
|
||||
assert misc.percent_of_material == Decimal(40)
|
||||
price = book.titles["M-FUEL-휘발유"].slots[-1]
|
||||
expected = (Decimal("5.6") * price * Decimal("1.4")).quantize(Decimal(1), rounding=ROUND_FLOOR)
|
||||
assert abs(book.resolve(SAW).material - expected) <= 1, (book.resolve(SAW), expected)
|
||||
|
||||
|
||||
def test_단목베기는_벌목부_인원만큼_체인톱_어린나무가꾸기는_체인톱_사용_특별인부만큼() -> None:
|
||||
book = cached_build().book
|
||||
rows = _rows(book, "B-FP-04-02-02#5m미만")
|
||||
faller = next(r for r in rows if book.titles[r.ref_code].name == "벌목부")
|
||||
saw = next(r for r in rows if r.ref_code == SAW)
|
||||
assert saw.quantity == faller.quantity, (saw, faller)
|
||||
rows = _rows(book, "B-FP-06-05")
|
||||
special = next(
|
||||
r
|
||||
for r in rows
|
||||
if book.titles.get(r.ref_code) and book.titles[r.ref_code].name == "특별인부"
|
||||
)
|
||||
saw = next(r for r in rows if r.ref_code == SAW)
|
||||
assert saw.quantity == special.quantity, (saw, special)
|
||||
# 보통인부(작업 보조)는 체인톱을 안 씀 — 표가 이름으로 밝힌 줄만(③)
|
||||
assert sum(1 for r in rows if r.ref_code == SAW) == 1
|
||||
|
||||
|
||||
def test_체인오일은_넣은_쪽만_둘_다면_사유() -> None:
|
||||
none = cached_build()
|
||||
assert any("체인오일" in label for label in none.unattached.get("FP-04-02-02", []))
|
||||
one = cached_build(material_prices=((OIL, "2250", "시중"),))
|
||||
rows = _rows(one.book, "B-FP-04-02-02#5m미만")
|
||||
faller = next(r for r in rows if one.book.titles[r.ref_code].name == "벌목부")
|
||||
oil = next(r for r in rows if r.ref_code == OIL)
|
||||
assert oil.quantity == faller.quantity * Decimal("2.1"), oil
|
||||
both = cached_build(material_prices=((OIL, "2250", "시중"), (ECO_OIL, "6000", "시중")))
|
||||
assert not any(r.ref_code in (OIL, ECO_OIL) for r in _rows(both.book, "B-FP-04-02-02#5m미만"))
|
||||
assert any("둘 다" in label for label in both.unattached.get("FP-04-02-02", []))
|
||||
|
||||
|
||||
def test_체인톱가격이_들면_손료_0_0084_가_경비로() -> None:
|
||||
none = cached_build()
|
||||
assert any("체인톱가격" in label for label in none.unattached.get("FP-06-05", []))
|
||||
priced = cached_build(material_prices=((SAW_PRICE, "900000", "견적"),))
|
||||
assert priced.book.resolve(SAW).expense == Decimal(7560) # 900,000 × 0.0084
|
||||
for code in (SAW_PRICE, OIL, ECO_OIL):
|
||||
assert "FP-04-02-02" in none.material_uses.get(code, []), code
|
||||
Reference in New Issue
Block a user