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,350 @@
"""B09 원가계산 — 기계경비 (PLAN 9-3 의 `S` → `X` 두 단계).
실무·교본이 같은 두 단계다 (신규 문서 5장 라-1 예제도 같은 모양):
S 취득가(천원) ──(손료계수)──► X 시간당 중기사용료
= 손료(경비) + 연료(재료) + 운전사(노무)
이 모듈이 내는 것은 **시간당 사용료 한 시간분**이고, 3분할(재료·노무·경비)로 낸다.
`B09_Estimation_PriceBook` 의 `MACHINE_BASE`(S) · `MACHINE_HOURLY`(X) 층에 그대로 앉는다.
데이터 (`resources/data_cost_input_value/mach_base_2026.json`)
- `mach_price` **613 기종** — `machine_code` + `specification` 으로 규격이 갈린다.
- `mach_loss_coef` **387건** — 시간당 손료계수·내용시간·연간표준시간.
- ⚠ `mach_fuel_rate` **0건** · `mach_operator_map` **0건** — **비어 있다.**
연료소모량과 기종별 운전사 직종은 품셈 본문에 있고, 아직 뽑히지 않았다.
⚠ **그래서 이 모듈은 손료(경비)까지만 채우고, 연료·운전사는 「공백」으로 표시한다.**
0 으로 때우면 **시간당 사용료가 절반 이하로 나오고 그대로 총액에 섞인다** — 실측
비중이 노무 53 % · 재료 20 % · 경비 27 % 라 손료만으로는 4분의 1 남짓이다.
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass, field
from decimal import ROUND_FLOOR, Decimal
from typing import Any
from B09_Estimation.B09_Estimation_Guards import (
check_operator_hours_basis,
reject_efficiency_in_hourly_rate,
)
from B09_Estimation.B09_Estimation_PriceBook import Money3
_CATALOG_SUBPATH = ("resources", "master_data", "old")
_THOUSAND = Decimal(1000)
#: 조종원 노임을 시간당으로 바꿀 때 나누는 시간.
#: 근거 — 품셈 8-1-6 기계손료 단서 「**관리비는 1일 8시간 초과해도 8시간으로 계산**」이
#: 1일을 8시간으로 잡는다. 노임표도 `hours_per_day: 8` 이다.
#: ⚠ **실무 관측은 이보다 짧은 시간으로 나눈 사례가 있다** — STC 2024 관측
#: 굴착기 0.7㎥ 노무 55,700 원/hr 은 건설기계운전사 일당 283,297 을 **약 5.09시간**으로
#: 나눈 값이다(작업효율 `E`(실작업시간율)를 시간당 사용료에 미리 반영한 것으로 보임).
#: 품셈에서 작업효율은 **작업량 산정**(`Q = n·q·f·E`, 8-1-4)에 들어가지 시간당 사용료에
#: 들어가지 않으므로 **8시간을 유지한다.** 관측에 맞추려 나눗수를 바꾸지 않는다
#: (★법대로 PLAN 8-10). TODO(미결 PLAN 9-6): 발주처가 실가동시간을 요구하는 사례 확인.
OPERATOR_HOURS_PER_DAY = 8
#: 조종원 노임에 붙는 **제수당·상여금·퇴직급여충당금** 계수 (= 16/12 × 25/20 ≒ 1.6667).
#:
#: 왜 붙나 — **공표 노임은 「기본급여액」일 뿐**이다. 대한건설협회 「임금적용요령」 4-나
#: (재경원 회계45101-45, 1995.1.13)가 「공표된 시중노임단가는 …기본급여액임. 따라서
#: 근로기준법에서 규정하고 있는 **제수당, 상여금 및 퇴직급여충당금**은 …회계예규인
#: 예정가격작성기준의 정한 바에 따라 계상하여야 함」이라고 못 박는다. 품셈 8-1-2 5.6호도
#: 운전사 노임을 「예정가격 작성기준(기획재정부 회계예규)에 의거 계상한다」로 넘긴다.
#: 그 예규가 정한 범위는 기재부 「(계약예규) 정부 입찰·계약 집행기준」 제76조의3
#: (노무비의 계상) — **노임 + 제수당 + 상여금(연 400% 한도) + 퇴직급여충당금**이다.
#:
#: ⚠ **예규 원문은 저장소에 없다.** 위 둘은 그 예규를 **인용한** 문서다. 그래서 「무엇을
#: 더하는가」는 원문으로 확인됐고 **「얼마를 더하는가」의 계수는 실무 관행을 따른다**
#: (2026-09-09 사용자 확정 ③ 「없으면 실무 기준으로 가되 노티스를 붙여 줄 것」).
#: · 16/12 — 상여금 연 400%(= 기본급 4개월분)를 12개월에 나눠 붙인 것과 **산술이 일치**한다.
#: · 25/20 — 월 지급일수 25 대 가동일수 20 으로 읽히나 **원문 확인 못 함**. 짐작을 적지 않는다.
#: 실물 셋이 같은 계수를 쓴다 — 실무 두 공사지(영월·봉화) 중기사용료 전수,
#: 임도교본 예제(`산림과임업기술(임도)/2. 임도/5. 설계.md:512`), 상용 적산 프로그램의
#: 기준계수표(`원가계산/다산소프트/ESTX_값사전_자재노무기계.md:427` `1/8*16/12*25/20`=0.20833).
#:
#: ⚠ **고용보험·산재보험·퇴직공제부금과 헷갈리지 말 것** — 그 셋은 노임 계수가 아니라
#: 원가계산 뒷단의 **요율 항목**으로 따로 선다(산재 3.56% · 고용 1.01~1.57% · 퇴직공제 2.3%,
#: `원가_입력변수_사전.md`). 여기에 겹쳐 넣으면 이중계상이 된다.
#: ⚠ **기계 감가상각도 여기가 아니다** — 상각비는 손료(경비) 쪽이다(품셈 8-1-5 1호).
OPERATOR_ALLOWANCE_FACTOR = (Decimal(16) / Decimal(12)) * (Decimal(25) / Decimal(20))
#: 조종원 **시간당 노임 식** — 위 8시간·계수를 **식 문자열 한 줄**로(STmate 설정 `RXNM_`).
#: ⚠⚠ **왼쪽부터 차례로** 평가하고 **원 미만 절사**(명세 7장 · STmate 18번 §2.1).
#: 계수로 미리 접으면 `267,360 × 0.20833…` = 55,699.99… → 절사 55,699 로 **1원 틀림**.
#: 차례로 하면 33,420 → 534,720 → 44,560 → 1,114,000 → **55,700**(6건×3직종 18/18 일치).
#: ⚠ 위 `OPERATOR_ALLOWANCE_FACTOR` 는 근거 문구·검사용으로만 남김 — 금액에 곱하지 말 것.
OPERATOR_WAGE_FORMULA = "1/8*16/12*25/20"
def hourly_operator_wage(
daily_wage: Decimal, formula: str = OPERATOR_WAGE_FORMULA, *, digits: int | None = 0
) -> Decimal:
"""일 노임 → 시간당 노임. 식을 **좌→우 순차**로 풀고 `digits` 자리 아래 절사(`None` 이면 안 자름).
⚠ 자르는 자리는 **실무 설정**임 — 실무 여섯 건 중 다섯이 원 미만 절사(55,700),
2025 울진 소광 한 건이 0.1원 미만 절사(57,077.2). 기본은 다수인 0 자리.
"""
value = Decimal(daily_wage)
for op, number in re.findall(r"([*/]?)\s*(\d+(?:\.\d+)?)", formula):
value = value / Decimal(number) if op == "/" else value * Decimal(number)
if digits is None:
return value
return value.quantize(Decimal(1).scaleb(-digits), rounding=ROUND_FLOOR)
#: 화면·표가 그대로 띄우는 노티스 한 줄. 계수를 쓴 자리마다 같은 문구가 서야 한다.
OPERATOR_ALLOWANCE_NOTICE = (
"조종원 노임에 제수당·상여금·퇴직급여충당금 계수 1.667배(16/12 × 25/20)를 넣었습니다 — "
"공표 노임은 기본급여액뿐이라 별도 계상해야 합니다(건협 임금적용요령 4-나, "
"기재부 정부 입찰·계약 집행기준 제76조의3). ⚠ 계수 자체의 예규 원문은 아직 못 봐 "
"실무 관행(실무 두 공사지·임도교본 예제·상용 적산 프로그램이 같은 계수)을 따랐습니다."
)
#: ⚠ **원천이 뭉개 놓은 줄을 원문으로 되살린다** (2026-09-09).
#:
#: `mach_base_2026.json` 의 대형 브레이커 여섯 줄은 **이름 칸에 표 전체가 뭉쳐** 들어가
#: 규격이 비고 손료계수가 없다. 그래서 「대형브레이커」로 찾아지지도, 시간당 사용료가
#: 서지도 않았다 — 구조물터파기(암절취)가 그 때문에 통째로 막혀 있었다.
#:
#: 값은 **건설공사 표준품셈 제8장 (0230) 대형 브레이커** 표에서 읽었고, **두 번 검증**했다.
#: ① 계수 합이 맞는다 — 상각 3,000 + 정비 2,833 + 관리 768 = **6,601** (표의 「계」와 같다)
#: ② 같은 방식으로 읽은 굴착기(0201) 표의 「계 2,085」가 카탈로그의 손료계수
#: **0.0002085 와 정확히 일치**한다 — 열 배치를 잘못 읽지 않았다는 증거다.
#:
#: ⚠ **원천이 이 줄을 제대로 싣게 되면 이 표는 지운다.** 두 곳에 같은 값을 두면
#: 나중에 한쪽만 고쳐진다. 취득가는 원천 값을 그대로 쓴다 — 여기서는 **이름·규격·손료계수**만 채운다.
MASHED_MACHINE_FIXES: dict[str, tuple[str, str]] = {
"0230-0002": ("대형 브레이커", "0.2"),
"0230-0004": ("대형 브레이커", "0.4"),
"0230-0006": ("대형 브레이커", "0.6"),
"0230-0007": ("대형 브레이커", "0.7"),
"0230-0008": ("대형 브레이커", "0.8"),
"0230-0010": ("대형 브레이커", "1.0"),
}
#: (0230) 표의 「시간당 계」 — 규격이 달라도 같은 값이다(원문 여섯 줄 모두 6,601).
MASHED_LOSS_COEFFICIENT = Decimal("0.0006601")
#: ⚠ **같은 파싱 병의 나머지** (2026-09-14 · 축 C 1장 ① 브레인 차례).
#: 원천이 쪽 끝 기종의 이름 칸에 **다음 쪽 머리글 + 다음 분류 이름**을 붙여 넣어 ① 그 기종은
#: 이름에 표 머리글이 붙고 규격이 비고 ② 다음 분류 첫 기종들은 **이름이 빔**. 이름으로 찾아지지
#: 않고 화면에도 빈 이름으로 선다. 이름·규격은 **건설공사 표준품셈 제8장 표**(`pum_const_2026.json`
#: 기종 목록 줄 · 붙은 꼬리 「(분류번호) 이름」)에서 읽음 — 취득가·손료계수는 원천 그대로.
#: ⚠ 원천이 바로 실으면 이 표는 지운다(위 표와 같은 약속).
GLUED_MACHINE_FIXES: dict[str, tuple[str, str]] = {
"5220-0015": ("소형브레이커(전기식)", "1.5㎾"),
"6532-0220": ("진동파일 해머(유압식)", "162㎾"),
"7101-0450": ("고성능 착정기", "335.70㎾"),
"7120-0746": ("버킷식준설기", "7.46kW"),
"7995-0050": ("배관파이프", "ø50-2.6m"),
}
#: ⚠ **한 칸에 두 줄이 뭉친 표** (2026-09-14 661 뒤 ① 브레인) — 원천이 (4611) 콘크리트 진동기 표의
#: 두 기종(「4611-0075 0350」 · 규격·계수가 한 칸에 둘씩)을 못 갈라 **규격이 비고 손료계수가 없음**.
#: 규격·시간당 계는 **건설공사 표준품셈 제8장 (4611)** 원문(L2504) 그대로 — 판정 없음.
#: 상각 3,000 + 정비 1,167 + 관리 768 = 4,935 · 3,000 + 1,333 + 768 = 5,101 (표의 「계」와 같음)
#: ⚠ 원천이 바로 실으면 이 표는 지운다(위 표와 같은 약속). 취득가는 원천 값 그대로.
MERGED_ROW_FIXES: dict[str, tuple[str, Decimal]] = {
"4611-0075": ("전기식 플렉시블형 ø45(0.75㎾)", Decimal("0.0004935")),
"4611-0350": ("엔진식 플렉시블형 ø45(2.6㎾)", Decimal("0.0005101")),
}
#: 이름이 빈 분류 — 분류번호(코드 앞 넷) → 원문 이름. 규격은 원천 값 그대로.
EMPTY_NAME_BY_GROUP: dict[str, str] = {
"0240": "유압식 진동콤팩터(굴착기 부착용)",
"3611": "콘크리트 피니셔(중앙분리대용)",
"5330": "드릴웨곤",
"6540": "워터젯트",
"6802": "파일천공전용장비",
"7202": "자동세륜기(롤 타입)",
"7930": "모터",
"8201": "3D GNSS 머신 가이던스(굴착기용)",
"9070": "이우선(비자항)",
}
class MachineCostError(LookupError):
"""기계경비를 세울 수 없는 경우. 0 으로 때우지 않고 멈춘다."""
def _project_root() -> str:
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _read_json(file_name: str) -> dict[str, Any]:
with open(os.path.join(_project_root(), *_CATALOG_SUBPATH, file_name), encoding="utf-8") as h:
return json.load(h)
@dataclass(frozen=True)
class MachineSpec:
"""기종 한 줄 — 규격까지 붙어야 한 대가 정해진다."""
machine_code: str
name: str
specification: str
price_thousand_krw: Decimal
loss_coefficient_per_hour: Decimal | None = None
economic_life_hours: int | None = None
annual_standard_hours: int | None = None
@property
def display_name(self) -> str:
return f"{self.name} {self.specification}".strip()
@dataclass
class MachineCatalog:
"""613 기종. **이름만으로는 못 고른다** — 규격이 있어야 한 대가 정해진다."""
machines: dict[str, MachineSpec] = field(default_factory=dict)
def by_name(self, name: str) -> list[MachineSpec]:
return [m for m in self.machines.values() if m.name == name]
def resolve(self, name: str, specification: str) -> MachineSpec | None:
"""이름 + 규격으로 한 대를 고른다. 규격이 없으면 **고르지 않는다**."""
found = self.by_name(name)
if not found:
return None
if len(found) == 1 and not specification:
return found[0]
narrowed = [m for m in found if m.specification == specification]
return narrowed[0] if len(narrowed) == 1 else None
def get(self, machine_code: str) -> MachineSpec:
try:
return self.machines[machine_code]
except KeyError as exc:
raise MachineCostError(f"기종 카탈로그에 없는 코드입니다: {machine_code}") from exc
def load_machine_catalog(
file_name: str = "3_품셈_건설_기계경비기준_8장_2026-01-01.json",
) -> MachineCatalog:
"""취득가 613건에 손료계수 387건을 붙여 카탈로그 한 벌을 만든다."""
variables = _read_json(file_name)["variables"]
coefficients = {
r["machine_code"]: r for r in variables.get("mach_loss_coef", {}).get("records", [])
}
catalog = MachineCatalog()
for row in variables.get("mach_price", {}).get("records", []):
code = row["machine_code"]
coefficient = coefficients.get(code, {})
fixed = MASHED_MACHINE_FIXES.get(code)
if fixed:
# 뭉개진 줄 — 원문으로 이름·규격을 되살리고 손료계수를 채운다.
row = {**row, "machine_name": fixed[0], "specification": fixed[1]}
coefficient = {**coefficient, "loss_coefficient_per_hour": MASHED_LOSS_COEFFICIENT}
elif code in GLUED_MACHINE_FIXES:
name, spec = GLUED_MACHINE_FIXES[code]
row = {**row, "machine_name": name, "specification": spec}
elif code in MERGED_ROW_FIXES and "loss_coefficient_per_hour" not in coefficient:
spec, merged_coefficient = MERGED_ROW_FIXES[code]
row = {**row, "specification": spec}
coefficient = {**coefficient, "loss_coefficient_per_hour": merged_coefficient}
elif not str(row.get("machine_name") or "").strip() and code[:4] in EMPTY_NAME_BY_GROUP:
row = {**row, "machine_name": EMPTY_NAME_BY_GROUP[code[:4]]}
catalog.machines[code] = MachineSpec(
machine_code=code,
name=row["machine_name"],
specification=str(row.get("specification", "")),
price_thousand_krw=Decimal(str(row["price_thousand_krw"])),
loss_coefficient_per_hour=(
Decimal(str(coefficient["loss_coefficient_per_hour"]))
if "loss_coefficient_per_hour" in coefficient
else None
),
economic_life_hours=coefficient.get("economic_life_hours"),
annual_standard_hours=coefficient.get("annual_standard_hours"),
)
return catalog
def hourly_loss_cost(machine: MachineSpec) -> Decimal:
"""시간당 손료 = 취득가 × 손료계수.
취득가가 **천원 단위**라 원으로 환산한다 — 이 단위를 놓치면 1,000배 틀린다.
"""
if machine.loss_coefficient_per_hour is None:
raise MachineCostError(
f"{machine.display_name}: 손료계수가 없습니다 (취득가만 있는 기종 226건 중 하나)"
)
return machine.price_thousand_krw * _THOUSAND * machine.loss_coefficient_per_hour
@dataclass
class HourlyMachineCost:
"""시간당 중기사용료 — 3분할과 **채우지 못한 성분 목록**을 함께 낸다."""
machine: MachineSpec
money: Money3
gaps: list[str] = field(default_factory=list)
@property
def is_complete(self) -> bool:
return not self.gaps
def hourly_machine_cost(
machine: MachineSpec,
*,
fuel_liters_per_hour: Decimal | None = None,
fuel_price_per_liter: Decimal | None = None,
operator_daily_wage: Decimal | None = None,
efficiency_factor: Decimal | None = None,
) -> HourlyMachineCost:
"""시간당 사용료 한 시간분.
손료(경비)는 카탈로그로 바로 나온다. **연료(재료)·운전사(노무)는 값을 주지 않으면
0 으로 때우지 않고 `gaps` 에 적어 돌려준다** — 빠진 채로 총액에 섞이는 것이
이 자리에서 제일 위험하다.
"""
# ㉣ 작업효율이 이 자리에 들어오면 멈춘다 — 효율은 작업량 산정에만 들어간다.
reject_efficiency_in_hourly_rate(
efficiency_factor, where=f"{machine.display_name} 시간당 사용료"
)
gaps: list[str] = []
expense = hourly_loss_cost(machine)
material = Decimal(0)
if fuel_liters_per_hour is None or fuel_price_per_liter is None:
# TODO(미결 PLAN 9-6): `mach_fuel_rate` 0건 — 연료소모량이 품셈 본문에만 있다.
gaps.append("연료소모량(L/hr) 미확보 — 재료비 성분 비어 있음")
else:
material = fuel_liters_per_hour * fuel_price_per_liter
labor = Decimal(0)
if operator_daily_wage is None:
# TODO(미결 PLAN 9-6): `mach_operator_map` 0건 — 기종별 운전사 직종이 품셈 본문에만 있다.
gaps.append("운전사 직종 매핑 미확보 — 노무비 성분 비어 있음")
else:
# 나눗수는 8시간 그대로 두고 **계수를 곱한다** — 식 문자열을 좌→우로 풂(명세 7장).
exact = hourly_operator_wage(operator_daily_wage, digits=None)
# ㉣ 보조 — 나눗수를 몰래 줄이면 효율을 사용료에 넣은 것이 된다(자르기 전 값으로 봄).
check_operator_hours_basis(
labor_per_hour=exact,
daily_wage=operator_daily_wage,
hours_per_day=OPERATOR_HOURS_PER_DAY,
allowance_factor=OPERATOR_ALLOWANCE_FACTOR,
)
labor = exact.to_integral_value(rounding=ROUND_FLOOR)
return HourlyMachineCost(
machine=machine,
money=Money3(material=material, labor=labor, expense=expense),
gaps=gaps,
)
def catalog_gaps(catalog: MachineCatalog) -> dict[str, int]:
"""카탈로그 자체의 공백 — 몇 기종이 손료계수를 못 가졌나."""
missing = [m for m in catalog.machines.values() if m.loss_coefficient_per_hour is None]
return {"machines": len(catalog.machines), "without_loss_coefficient": len(missing)}