refactor(B08,B09): 폴더째 old_code 로 옮기고 빈 화면 둘만 남김 (PLAN 7-3)

B08_Quantity 86 · B09_Estimation 111 파일을 old_code/ 로 옮김(지우지 않음).
화면은 메뉴·주소·단계 막대만 남은 빈 틀 둘 — main.py 라우터 13 개는 끊음.
B07 이 빌려 쓰던 비탈 길이·면적은 필요한 함수만 B07_DesignDetail_Engine_SlopeGeometry
로 옮겨 적음(면적 적분·노면 면적·측점 묶음은 안 옮김) · 시험 하나를 새로 둠.
B07 구조물도 조립(Cad_StandardSheet)은 2026-09-13 에 이미 도면 목록에서 빠져
부르는 곳이 없어 old_code 로 같이 보냄 — 구조물 그림은 되살리지 않음.
B06 구조물 몫 조회는 빈 값으로 두어 화면이 그대로 서게 함.
B08·B09 를 부르던 시험 25 개도 old_code/resources/tester 로 옮김.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
2026-09-22 12:27:45 +09:00
co-authored by Claude Opus 5
parent 185f800d23
commit 8472fc9f40
231 changed files with 1617 additions and 1216 deletions
@@ -0,0 +1,324 @@
"""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 — 구입가(손료 밑수) 칸
#: (2-1 공종, 표, 주연료 줄 이름) · 연료 없는 장비(배부식분무기)는 None
fuel: tuple[str, str, str] | None
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 = ""
#: 도구 줄 표(6-2·6-4 「사용도구 | … | 인력구분」)의 도구 칸 이름 — 그 줄의 인력이 쓰는 사람.
tool: 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 손료",
),
# 도구 줄 표 넷(2026-09-15 브레인 ②) — 2-1-1 2 [주]① 「재료비는 예취기 작업(줄베기, 모두베기, 지상부
# 덩굴걷기)에만」 · 「1대당 1인 작업」 · 2-2-2 손료.
Equipment(
key="예취기",
machine="AR-X-da716e54",
price="AR-M-148c5888",
fuel=("FP-02-01-01", "F0043", "예취기(휘발유)"),
loss=("FP-02-02-02", "F0065"),
users={"FP-06-02-02": "특별인부", "FP-06-02-03": "특별인부", "FP-06-04-01": "특별인부"},
basis="산림품셈 2-1-1 2 예취기 「1대당 1인 작업」 · 2-2-2 손료",
tool="예취기",
),
# 2-2-4 배부식분무기(덩굴 약제처리) 손료만 — 2-1 에 연료 표 없음(사람이 멤).
Equipment(
key="배부식분무기",
machine="AR-X-5d0a0416",
price="AR-M-d6394831",
fuel=None,
loss=("FP-02-02-04", "F0067"),
users={"FP-06-04-02": "특별인부"},
basis="산림품셈 2-2-4 배부식분무기 「1대당 1인 작업」 손료",
tool="배부식분무기",
),
)
@dataclass(frozen=True)
class AreaSet:
"""면적·일 꼴 — 표의 「160ha당」 이 1일 작업량인 공종(8-6-1 유인헬기 · 2026-09-15 브레인 ①③)."""
work_item: str
machine: Equipment # 1대 1일 호표(양수기) — 갈래 면적으로 나눔
per_area: tuple[tuple[str, str], ...] = () # (AR-M, 2-1 표 줄 이름) — 「20ha당 1개」 를 읽음
per_day: tuple[tuple[str, str], ...] = () # (AR-M, 사유 이름) — 1일 1대 값 ÷ 갈래 면적
AREA_SETS: tuple[AreaSet, ...] = (
AreaSet(
work_item="FP-08-06-01",
machine=Equipment(
key="양수기",
machine="AR-X-ea09a419",
price="AR-M-cfc70216",
fuel=("FP-02-01-08", "F0058", "휘발유 (양수기)"),
loss=("FP-02-02-06", "F0069"),
basis="산림품셈 2-1-8 유인 헬기(160ha당) · [주]① 대형헬기도 휘발유 10ℓ · 2-2-6 양수기 손료",
),
per_area=(("AR-M-a79b4d20", "깃 발"),),
per_day=(("AR-M-08db9ecc", "유인헬기 임차료(1일)"),),
),
)
#: 막힌 채 사유만 보태는 공종(밑수 없음 — 성분을 얹으면 밑수가 서는 날 두 번 셈 · 브레인 ②).
BLOCKED_NOTES: dict[str, tuple[str, ...]] = {
"FP-08-06-03": (
"경유(차량살포) 1.3ℓ/ha · 잡품 5% · 동력분무기 45HP 손료 0.0084 — 인력 표의 1일 작업량이 원문에 없음"
"(15ha 는 연료 설명) → 밑수가 없어 안 붙임",
"1톤 방제차량은 건설품셈 적산기준에 준하나 카탈로그에 1톤 트럭이 없음(덤프 2.5톤~·크레인 2톤~)",
),
}
_RE_AREA = re.compile(r"\(\s*(\d[\d,]*(?:\.\d+)?)\s*ha\s*당\s*\)")
_RE_PER_AREA = re.compile(r"(\d+(?:\.\d+)?)\s*ha\s*당\s*(\d+(?:\.\d+)?)\s*개")
_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_values(rows: list[list[str]], name: str) -> tuple[Decimal, Decimal] | None:
"""(주연료 ℓ, 잡품 %) — ℓ 은 이름 다음 칸의 첫 수 · % 는 「%」 가 붙은 칸의 수(「주재료비의 95%」)."""
row = next((r for r in rows if r and _tight(r[0]) == _tight(name)), None)
if row is None or len(row) < 2:
return None
liters = _RE_NUMBER.search(row[1])
misc = next((m for c in row[2:] if "%" in c and (m := _RE_NUMBER.search(c))), None)
if liters is None or misc is None:
return None
return Decimal(liters.group()), Decimal(misc.group())
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 = _fuel_values(_table_rows(nodes, eq.fuel[0], eq.fuel[1]), eq.fuel[2]) if eq.fuel else None
loss_rows = _table_rows(nodes, *eq.loss)
loss = _row_numbers(loss_rows, loss_rows[0][0]) if loss_rows else []
if (eq.fuel and fuel is None) or not loss:
return [f"{eq.key} — 2장 표 칸이 달라져 장비 몫을 못 읽음"]
if code in book.titles:
return [] if eq.price in book.titles else [f"{eq.key}가격 — 손료({loss[0]} × 구입가) 칸"]
if not eq.fuel and eq.price not in book.titles:
# 연료 없는 장비는 손료가 전부 — 구입가가 없으면 빈 호표가 되어 제목을 안 세움(칸 사유만).
return [f"{eq.key}가격 — 손료({loss[0]} × 구입가) 칸 · 「자재 단가」 에 넣으면 붙음"]
book.add_title(PriceTitle(code, PriceKind.MACHINE_HOURLY, eq.key, "1대 1일", "대·일"))
if eq.fuel and fuel is not None:
kind = "휘발유" if "휘발유" in eq.fuel[2] else "경유"
liters, misc = fuel
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]) if eq.fuel else []
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()
or (
eq.tool
and _tight(eq.tool) in {_tight(c) for c in r}
and _tight(user_row) in {_tight(c) for c in r}
)
)
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)
for area in AREA_SETS:
_attach_area(build, nodes, area, fuel_region)
for code, notes in BLOCKED_NOTES.items():
labels = build.unattached.setdefault(code, [])
labels.extend(n for n in notes if n not in labels)
def _attach_area(build: Any, nodes: dict, area: AreaSet, fuel_region: str | None) -> None:
"""갈래 이름의 「(Nha당)」 = 1일 작업량 — 1일 몫(양수기·임차료)은 ÷ N · 「20ha당 1개」 는 ha당."""
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail
book = build.book
eq = area.machine
for material in (eq.price, *(c for c, _ in area.per_area), *(c for c, _ in area.per_day)):
uses = build.material_uses.setdefault(material, [])
if area.work_item not in uses:
uses.append(area.work_item)
reasons = _machine_title(build, nodes, eq, fuel_region)
rows = _table_rows(nodes, eq.fuel[0], eq.fuel[1])
for title in [t for t in book.titles if t.startswith(f"B-{area.work_item}#")]:
found = _RE_AREA.search(title)
if found is None or f"X-{eq.machine}" not in book.titles:
continue
hectares = Decimal(found.group(1).replace(",", ""))
per_day = Decimal(1) / hectares
note = f"1일 1대 ÷ 1일 살포면적 {hectares}ha — {eq.basis}"
book.add_detail(PriceDetail(title, f"X-{eq.machine}", per_day, note=note))
for code, name in area.per_area:
row = next((r for r in rows if r and _tight(r[0]) == _tight(name)), [])
rule = next((m for c in row if (m := _RE_PER_AREA.search(c))), None)
if rule is None:
reasons.append(f"{_tight(name)} — 표의 「N ha당 N개」 를 못 읽음")
elif code in book.titles:
amount = Decimal(rule.group(2)) / Decimal(rule.group(1))
book.add_detail(
PriceDetail(title, code, amount, note=f"{_tight(name)} {rule.group(0)}")
)
else:
reasons.append(
f"{_tight(name)} — 단가 칸 · 넣으면 {rule.group(0)} 로 붙음(2-1-8 [주]③)"
)
for code, name in area.per_day:
if code in book.titles:
book.add_detail(PriceDetail(title, code, per_day, note=f"{name} ÷ {hectares}ha"))
else:
reasons.append(
f"{name} — 견적 칸 · 넣으면 ÷ 1일 살포면적으로 붙음(품셈에 사용료 표 없음)"
)
labels = build.unattached.setdefault(area.work_item, [])
labels.extend(r for r in reasons if r not in labels)