Merge remote-tracking branch 'origin/dev' into main_laptop_1

This commit is contained in:
2026-09-13 23:34:39 +09:00
11 changed files with 421 additions and 45 deletions
+7 -2
View File
@@ -31,7 +31,11 @@ from B09_Estimation.B09_Estimation_Rounding import (
OutputPlace, OutputPlace,
round_at, round_at,
) )
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build from B09_Estimation.B09_Estimation_UnitPrice import (
HOURLY_WAGE_SUFFIX,
UnitPriceBuild,
cached_build,
)
_ZERO = Decimal(0) _ZERO = Decimal(0)
@@ -55,7 +59,8 @@ def catalog_list(build: UnitPriceBuild, kind: PriceKind) -> list[dict[str, Any]]
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
for code in sorted(build.book.titles): for code in sorted(build.book.titles):
title = build.book.titles[code] title = build.book.titles[code]
if title.kind is not kind: # 조종원 시간당(`…#시간`)은 기초단가가 아니라 일당에서 **셈한** 값 — 노무비목록표엔 안 실음.
if title.kind is not kind or code.endswith(HOURLY_WAGE_SUFFIX):
continue continue
try: try:
price: Decimal | None = title.adopted_price() price: Decimal | None = title.adopted_price()
@@ -31,8 +31,8 @@ from decimal import Decimal
from typing import Any from typing import Any
from B09_Estimation.B09_Estimation_MachineCost import ( from B09_Estimation.B09_Estimation_MachineCost import (
OPERATOR_ALLOWANCE_FACTOR,
OPERATOR_ALLOWANCE_NOTICE, OPERATOR_ALLOWANCE_NOTICE,
hourly_operator_wage,
) )
from B09_Estimation.B09_Estimation_PriceBook import PRICE_SLOT_COUNT, PriceKind from B09_Estimation.B09_Estimation_PriceBook import PRICE_SLOT_COUNT, PriceKind
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
@@ -169,7 +169,10 @@ def base_reference_data(
"day_wage_krw": _money(Decimal(str(wage))), "day_wage_krw": _money(Decimal(str(wage))),
# 제수당·상여금·퇴직급여충당금 계수를 곱한 값 (2026-09-09 사용자 확정 ③). # 제수당·상여금·퇴직급여충당금 계수를 곱한 값 (2026-09-09 사용자 확정 ③).
# 근거·한계는 `MachineCost.OPERATOR_ALLOWANCE_FACTOR` 주석 한 곳에 모아 뒀다. # 근거·한계는 `MachineCost.OPERATOR_ALLOWANCE_FACTOR` 주석 한 곳에 모아 뒀다.
"hourly_krw": _money(Decimal(str(wage)) / Decimal(8) * OPERATOR_ALLOWANCE_FACTOR), # 식 좌→우 순차 + 원 미만 절사(명세 7장 · STmate 18번 §2.1).
"hourly_krw": _money(
hourly_operator_wage(Decimal(str(wage)), digits=prices.operator_wage_digits)
),
"formula": "일당 ÷ 8시간 × 16/12 × 25/20", "formula": "일당 ÷ 8시간 × 16/12 × 25/20",
} }
) )
+33 -9
View File
@@ -23,8 +23,9 @@ from __future__ import annotations
import json import json
import os import os
import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from decimal import Decimal from decimal import ROUND_FLOOR, Decimal
from typing import Any from typing import Any
from B09_Estimation.B09_Estimation_Guards import ( from B09_Estimation.B09_Estimation_Guards import (
@@ -73,6 +74,30 @@ OPERATOR_HOURS_PER_DAY = 8
#: ⚠ **기계 감가상각도 여기가 아니다** — 상각비는 손료(경비) 쪽이다(품셈 8-1-5 1호). #: ⚠ **기계 감가상각도 여기가 아니다** — 상각비는 손료(경비) 쪽이다(품셈 8-1-5 1호).
OPERATOR_ALLOWANCE_FACTOR = (Decimal(16) / Decimal(12)) * (Decimal(25) / Decimal(20)) 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 = ( OPERATOR_ALLOWANCE_NOTICE = (
"조종원 노임에 제수당·상여금·퇴직급여충당금 계수 1.667배(16/12 × 25/20)를 넣었습니다 — " "조종원 노임에 제수당·상여금·퇴직급여충당금 계수 1.667배(16/12 × 25/20)를 넣었습니다 — "
@@ -227,8 +252,6 @@ def hourly_machine_cost(
fuel_liters_per_hour: Decimal | None = None, fuel_liters_per_hour: Decimal | None = None,
fuel_price_per_liter: Decimal | None = None, fuel_price_per_liter: Decimal | None = None,
operator_daily_wage: Decimal | None = None, operator_daily_wage: Decimal | None = None,
operator_hours_per_day: int = OPERATOR_HOURS_PER_DAY,
operator_allowance_factor: Decimal = OPERATOR_ALLOWANCE_FACTOR,
efficiency_factor: Decimal | None = None, efficiency_factor: Decimal | None = None,
) -> HourlyMachineCost: ) -> HourlyMachineCost:
"""시간당 사용료 한 시간분. """시간당 사용료 한 시간분.
@@ -257,15 +280,16 @@ def hourly_machine_cost(
# TODO(미결 PLAN 9-6): `mach_operator_map` 0건 — 기종별 운전사 직종이 품셈 본문에만 있다. # TODO(미결 PLAN 9-6): `mach_operator_map` 0건 — 기종별 운전사 직종이 품셈 본문에만 있다.
gaps.append("운전사 직종 매핑 미확보 — 노무비 성분 비어 있음") gaps.append("운전사 직종 매핑 미확보 — 노무비 성분 비어 있음")
else: else:
# 나눗수는 8시간 그대로 두고 **계수를 곱한다** — 나눗수를 줄이는 것과 다르다. # 나눗수는 8시간 그대로 두고 **계수를 곱한다** — 식 문자열을 좌→우로 풂(명세 7장).
labor = (operator_daily_wage / Decimal(operator_hours_per_day)) * operator_allowance_factor exact = hourly_operator_wage(operator_daily_wage, digits=None)
# ㉣ 보조 — 나눗수를 몰래 줄이면 효율을 사용료에 넣은 것이 된다. # ㉣ 보조 — 나눗수를 몰래 줄이면 효율을 사용료에 넣은 것이 된다(자르기 전 값으로 봄).
check_operator_hours_basis( check_operator_hours_basis(
labor_per_hour=labor, labor_per_hour=exact,
daily_wage=operator_daily_wage, daily_wage=operator_daily_wage,
hours_per_day=operator_hours_per_day, hours_per_day=OPERATOR_HOURS_PER_DAY,
allowance_factor=operator_allowance_factor, allowance_factor=OPERATOR_ALLOWANCE_FACTOR,
) )
labor = exact.to_integral_value(rounding=ROUND_FLOOR)
return HourlyMachineCost( return HourlyMachineCost(
machine=machine, machine=machine,
@@ -25,8 +25,7 @@ from functools import lru_cache
from typing import Any from typing import Any
from B09_Estimation.B09_Estimation_MachineCost import ( from B09_Estimation.B09_Estimation_MachineCost import (
OPERATOR_ALLOWANCE_FACTOR, hourly_operator_wage,
OPERATOR_HOURS_PER_DAY,
load_machine_catalog, load_machine_catalog,
) )
from B09_Estimation.B09_Estimation_PriceBook import PriceKind from B09_Estimation.B09_Estimation_PriceBook import PriceKind
@@ -150,8 +149,9 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
), ),
"operator_code": occupation, "operator_code": occupation,
"operator_daily_wage": _money(wage), "operator_daily_wage": _money(wage),
# 식 좌→우 순차 + 원 미만 절사(명세 7장) — 계수로 접으면 1원 틀림.
"operator_krw_per_hour": _money( "operator_krw_per_hour": _money(
(wage / Decimal(OPERATOR_HOURS_PER_DAY)) * OPERATOR_ALLOWANCE_FACTOR hourly_operator_wage(wage, digits=getattr(build, "operator_wage_digits", 0))
if wage is not None if wage is not None
else None else None
), ),
+27 -5
View File
@@ -20,7 +20,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from decimal import Decimal from decimal import ROUND_FLOOR, Decimal
from enum import Enum from enum import Enum
_ZERO = Decimal(0) _ZERO = Decimal(0)
@@ -57,6 +57,14 @@ class PriceKind(str, Enum):
#: 카탈로그 층 — 상세를 갖지 않고 값이 바로 있는 종류. #: 카탈로그 층 — 상세를 갖지 않고 값이 바로 있는 종류.
CATALOG_KINDS = frozenset({PriceKind.MATERIAL, PriceKind.LABOR, PriceKind.MACHINE_BASE}) CATALOG_KINDS = frozenset({PriceKind.MATERIAL, PriceKind.LABOR, PriceKind.MACHINE_BASE})
#: **호표 안에서 자르는** 층 — 줄 금액은 0.1원 미만, 성분 소계는 원 미만 절사(명세 7장).
#: 근거 STmate 17번 — 중기사용료 호표 성분 소계 398/398 절사
#: (굴삭기 0.7㎥ 23,128 + 55,700 + 18,015 = 96,843 · 골든셋 실무 143 호표 전수).
#: ⚠ 일위대가(B) 호표도 같은 규칙(345 중 94.2%)이나 **층 차례를 바로잡은 뒤** 붙임(PLAN 6장 판정).
TRUNCATED_KINDS = frozenset({PriceKind.MACHINE_HOURLY})
_TENTH = Decimal("0.1")
_WON = Decimal(1)
class PriceBookError(LookupError): class PriceBookError(LookupError):
"""단가 조립이 성립하지 않는 경우. 0 으로 때우지 않고 멈춘다.""" """단가 조립이 성립하지 않는 경우. 0 으로 때우지 않고 멈춘다."""
@@ -84,6 +92,15 @@ class Money3:
def scaled(self, factor: Decimal) -> Money3: def scaled(self, factor: Decimal) -> Money3:
return Money3(self.material * factor, self.labor * factor, self.expense * factor) return Money3(self.material * factor, self.labor * factor, self.expense * factor)
def floored(self, unit: Decimal) -> Money3:
"""성분마다 `unit` 자리 아래 절사 — 호표 줄(0.1원)·성분 소계(원) 자르기."""
return Money3(
*(
v.quantize(unit, rounding=ROUND_FLOOR)
for v in (self.material, self.labor, self.expense)
)
)
@dataclass @dataclass
class PriceTitle: class PriceTitle:
@@ -207,6 +224,8 @@ class PriceBook:
raise PriceBookError(f"{code} ({title.name}): 상세 줄이 없어 단가를 조립할 수 없습니다") raise PriceBookError(f"{code} ({title.name}): 상세 줄이 없어 단가를 조립할 수 없습니다")
total = Money3() total = Money3()
# 호표 안에서 자르는 층이면 줄 금액을 0.1원 미만 절사해 쌓고 끝에 성분 소계를 원 미만 절사.
truncate = title.kind in TRUNCATED_KINDS
# 제잡비 밑수로 쓸 **사람 품(직접노무비)** — 기계 줄 안의 조종원 노임은 안 센다. # 제잡비 밑수로 쓸 **사람 품(직접노무비)** — 기계 줄 안의 조종원 노임은 안 센다.
# 근거는 아래 `percent_of_labor` 자리 주석의 인용 셋. # 근거는 아래 `percent_of_labor` 자리 주석의 인용 셋.
direct_labor = Decimal(0) direct_labor = Decimal(0)
@@ -264,9 +283,10 @@ class PriceBook:
# ⚠ 밑수가 **명시된 잡재료를 뺀 주재료비**여야 하는데, 품셈이 명시한 잡재료는 # ⚠ 밑수가 **명시된 잡재료를 뺀 주재료비**여야 하는데, 품셈이 명시한 잡재료는
# 이미 자원 줄로 서 있어 이 밑수에 함께 든다 — 그 공종에 잡재료가 명시돼 # 이미 자원 줄로 서 있어 이 밑수에 함께 든다 — 그 공종에 잡재료가 명시돼
# 있으면 1-2-6 이 애초에 안 걸리는 자리이므로 **칸을 비워 두는 것이 맞다.** # 있으면 1-2-6 이 애초에 안 걸리는 자리이므로 **칸을 비워 두는 것이 맞다.**
total = total + Money3( # ⚠ 중기(X) 호표의 **잡품**도 이 줄 — 「주연료비 × 율」의 가산 행(STmate 17번 §3,
material=direct_material * row.percent_of_material / Decimal(100) # 잡품 단가 칸 = 연료 금액). 수량 × 단가로 연료 수량에 접으면 줄이 사라짐.
) share = Money3(material=direct_material * row.percent_of_material / Decimal(100))
total = total + (share.floored(_TENTH) if truncate else share)
continue continue
child = self.resolve(row.ref_code, (*_seen, code)) child = self.resolve(row.ref_code, (*_seen, code))
@@ -275,12 +295,14 @@ class PriceBook:
total = total + total.scaled(row.percent_of_parent / Decimal(100)) total = total + total.scaled(row.percent_of_parent / Decimal(100))
continue continue
scaled = child.scaled(row.quantity) scaled = child.scaled(row.quantity)
if truncate:
scaled = scaled.floored(_TENTH)
if self.titles[row.ref_code].kind is PriceKind.LABOR: if self.titles[row.ref_code].kind is PriceKind.LABOR:
direct_labor = direct_labor + scaled.labor direct_labor = direct_labor + scaled.labor
if self.titles[row.ref_code].kind is PriceKind.MATERIAL: if self.titles[row.ref_code].kind is PriceKind.MATERIAL:
direct_material = direct_material + scaled.material direct_material = direct_material + scaled.material
total = total + scaled total = total + scaled
return total return total.floored(_WON) if truncate else total
def material_base(self, code: str) -> Decimal: def material_base(self, code: str) -> Decimal:
"""공구손료·잡재료(산림품셈 1-2-6)의 **밑수** — 그 항목에 바로 붙은 자재 줄의 합. """공구손료·잡재료(산림품셈 1-2-6)의 **밑수** — 그 항목에 바로 붙은 자재 줄의 합.
+2
View File
@@ -275,6 +275,8 @@ async def _build_for(project_id: UUID):
str(settings.get("transport_road") or ""), str(settings.get("transport_road") or ""),
# 품 할인·할증(1-4) — **안 고르면 안 붙는다.** # 품 할인·할증(1-4) — **안 고르면 안 붙는다.**
tuple(sorted(parse_labor_surcharge(settings.get("labor_surcharge")).items())), tuple(sorted(parse_labor_surcharge(settings.get("labor_surcharge")).items())),
# 조종원 시간당 노임 자르는 자리 — 실무마다 다름(명세 7장 정정). 안 정하면 원 미만.
str(settings.get("operator_wage_digits") or ""),
) )
+69 -20
View File
@@ -27,7 +27,8 @@ from typing import Any
from B09_Estimation.B09_Estimation_Guards import check_column_sums, check_surcharge_once from B09_Estimation.B09_Estimation_Guards import check_column_sums, check_surcharge_once
from B09_Estimation.B09_Estimation_MachineCost import ( from B09_Estimation.B09_Estimation_MachineCost import (
OPERATOR_ALLOWANCE_FACTOR, OPERATOR_WAGE_FORMULA,
hourly_operator_wage,
load_machine_catalog, load_machine_catalog,
) )
from B09_Estimation.B09_Estimation_MachineProductivity import ( from B09_Estimation.B09_Estimation_MachineProductivity import (
@@ -68,6 +69,8 @@ from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
from B09_Estimation.B09_Estimation_Transport import parse_distance_km from B09_Estimation.B09_Estimation_Transport import parse_distance_km
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
#: 조종원 **시간당 노임** 제목 꼬리 — 일 노임 제목(`L…`)과 갈라 둠. 노무비목록표엔 안 실림(일당만).
HOURLY_WAGE_SUFFIX = "#시간"
_RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*") _RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*")
_ZERO = Decimal(0) _ZERO = Decimal(0)
#: 연료는 자재 카탈로그에 없어 합성 코드로 세운다 — 코드가 있어야 조인이 성립한다. #: 연료는 자재 카탈로그에 없어 합성 코드로 세운다 — 코드가 있어야 조인이 성립한다.
@@ -91,6 +94,8 @@ def _slots(value: Decimal) -> list[Decimal | None]:
@dataclass @dataclass
class UnitPriceBuild: class UnitPriceBuild:
book: PriceBook = field(default_factory=PriceBook) book: PriceBook = field(default_factory=PriceBook)
#: 조종원 시간당 노임을 자른 자리(프로젝트 설정 `operator_wage_digits`, 기본 0 = 원 미만).
operator_wage_digits: int = 0
#: 세우지 못한 공종 — 값이 안 서는 것을 빈 줄로 두지 않는다. #: 세우지 못한 공종 — 값이 안 서는 것을 빈 줄로 두지 않는다.
skipped: list[str] = field(default_factory=list) skipped: list[str] = field(default_factory=list)
#: 기계 성분이 비어 있는 채로 쓰인 기종 (연료·조종원 미확보). #: 기계 성분이 비어 있는 채로 쓰인 기종 (연료·조종원 미확보).
@@ -241,8 +246,26 @@ def _apply_combined_misc_rate(book: PriceBook, work_item_titles: list[str]) -> i
return swapped return swapped
#: 중기 호표 잡품 줄 이름표 — 화면이 「공구손료·잡재료(1-2-6)」와 가르는 데 씀.
MISC_NOTE_PREFIX = "잡품"
def _misc_row(hourly_code: str, percent: Decimal) -> PriceDetail:
"""중기 호표 **잡품** — 주연료비의 % 가 재료비로 붙는 가산 행(자기 호표를 가리키는 비율 줄)."""
return PriceDetail(
hourly_code,
hourly_code,
Decimal(0),
note=f"{MISC_NOTE_PREFIX} — 주연료비 × {percent}%",
percent_of_material=percent,
)
def _add_machine_layers( def _add_machine_layers(
book: PriceBook, machine_codes: set[str], fuel_region: str | None = None book: PriceBook,
machine_codes: set[str],
fuel_region: str | None = None,
operator_wage_digits: int = 0,
) -> list[str]: ) -> list[str]:
"""`S`(취득가) · `L`(운전사) · `M`(연료) 을 세우고 그 위에 `X` 를 올린다. """`S`(취득가) · `L`(운전사) · `M`(연료) 을 세우고 그 위에 `X` 를 올린다.
@@ -350,10 +373,11 @@ def _add_machine_layers(
liters = record.fuel_liters_per_hour liters = record.fuel_liters_per_hour
if liters is not None: if liters is not None:
book.add_detail(PriceDetail(hourly_code, fuel_code, liters, note="주연료"))
if record.misc_material_percent is not None: if record.misc_material_percent is not None:
# 잡재료는 **주연료의 %** — 유가와 같이 움직인다. # 잡품은 **주연료비 × 율의 가산 행**(명세 7장 · STmate 17번 §3 「잡품 단가 칸 = 연료
liters = liters * (Decimal(1) + record.misc_material_percent / Decimal(100)) # 금액」) — 연료 수량에 접지 않음. 유가와 같이 움직이는 것은 그대로.
book.add_detail(PriceDetail(hourly_code, fuel_code, liters, note="주연료 + 잡재료")) book.add_detail(_misc_row(hourly_code, record.misc_material_percent))
# 조합 사용(리퍼·브레이커·집게)일 때 쓸 **잡재료 16%** 짜리 층을 함께 세운다. # 조합 사용(리퍼·브레이커·집게)일 때 쓸 **잡재료 16%** 짜리 층을 함께 세운다.
# 같은 기계라도 조합이면 본체 잡재료가 줄어든다(품셈 제8장 [주]⑤). # 같은 기계라도 조합이면 본체 잡재료가 줄어든다(품셈 제8장 [주]⑤).
@@ -375,32 +399,42 @@ def _add_machine_layers(
PriceDetail( PriceDetail(
combined_code, combined_code,
fuel_code, fuel_code,
record.fuel_liters_per_hour record.fuel_liters_per_hour,
* (Decimal(1) + COMBINED_MISC_PERCENT / Decimal(100)), note="주연료 (조합 사용)",
note=f"주연료 + 잡재료 {COMBINED_MISC_PERCENT}% (조합 사용)",
) )
) )
book.add_detail(_misc_row(combined_code, Decimal(COMBINED_MISC_PERCENT)))
else: else:
incomplete.append(f"{code} (연료소모량 없음)") incomplete.append(f"{code} (연료소모량 없음)")
wage_code = record.operator_occupation_code wage_code = record.operator_occupation_code
if wage_code and wage_code in wages and record.operator_person_days is not None: if wage_code and wage_code in wages and record.operator_person_days is not None:
# ㉣ 나눗수는 8시간 — `PriceDetail` 수량이 「1시간분 인」이 된다. # ㉣ 나눗수는 8시간 + **제수당·상여금·퇴직급여충당금 계수**(16/12×25/20) — 공표 노임이
# 여기에 **제수당·상여금·퇴직급여충당금 계수**(1.667배)를 곱한다. 공표 노임이 # 기본급여액뿐이라 별도 계상하는 몫(`MachineCost` 상수 주석에 근거).
# 기본급여액뿐이라 별도 계상해야 하는 몫이다(`MachineCost` 상수 주석에 근거). # ⚠⚠ 계수를 **수량에 접지 않음** — `일당 × 0.20833…` 은 55,699.99… 로 1원 틀림.
per_hour_person = (record.operator_person_days / Decimal(8)) * OPERATOR_ALLOWANCE_FACTOR # 시간당 노임을 식 좌→우 + 원 미만 절사로 **따로 세운 제목**(`#시간`)을 1시간분 인수로 부름
if wage_code not in book.titles: # (STmate 중기사용료 호표 「운전원 1 × 55,700」 모양, 명세 7장).
per_hour_person = record.operator_person_days
hourly_wage_code = f"{wage_code}{HOURLY_WAGE_SUFFIX}"
if hourly_wage_code not in book.titles:
book.add_title( book.add_title(
PriceTitle( PriceTitle(
code=wage_code, code=hourly_wage_code,
kind=PriceKind.LABOR, kind=PriceKind.LABOR,
name="조종원", name="조종원(시간당)",
unit="", unit="hr",
slots=_slots(wages[wage_code]), slots=_slots(
hourly_operator_wage(wages[wage_code], digits=operator_wage_digits)
),
# ⚠ 조종원도 노임이다 — 같은 플래그가 붙어야 한다. # ⚠ 조종원도 노임이다 — 같은 플래그가 붙어야 한다.
reliability=load_labor_reliability().get(wage_code, ""), reliability=load_labor_reliability().get(wage_code, ""),
) )
) )
wage_code = hourly_wage_code
cut = (
"원 미만" if operator_wage_digits == 0 else f"소수 {operator_wage_digits}자리 아래"
)
operator_note = f"조종원 (일당 × {OPERATOR_WAGE_FORMULA} 좌→우 · {cut} 절사)"
# 조합 층에도 조종원을 같이 단다 — 본체를 모는 사람은 하나뿐이다. # 조합 층에도 조종원을 같이 단다 — 본체를 모는 사람은 하나뿐이다.
combined_code = f"{hourly_code}#조합" combined_code = f"{hourly_code}#조합"
if combined_code in book.titles: if combined_code in book.titles:
@@ -409,7 +443,7 @@ def _add_machine_layers(
combined_code, combined_code,
wage_code, wage_code,
per_hour_person, per_hour_person,
note="조종원 (1일 8시간 × 제수당·상여·퇴직충당 16/12 × 25/20)", note=operator_note,
) )
) )
book.add_detail( book.add_detail(
@@ -417,7 +451,7 @@ def _add_machine_layers(
hourly_code, hourly_code,
wage_code, wage_code,
per_hour_person, per_hour_person,
note="조종원 (1일 8시간 × 제수당·상여·퇴직충당 16/12 × 25/20)", note=operator_note,
) )
) )
else: else:
@@ -573,6 +607,7 @@ def build_unit_prices(
transport_distance_km: Decimal | None = None, transport_distance_km: Decimal | None = None,
transport_road: str | None = None, transport_road: str | None = None,
labor_surcharge_choices: dict[str, str] | None = None, labor_surcharge_choices: dict[str, str] | None = None,
operator_wage_digits: int = 0,
) -> UnitPriceBuild: ) -> UnitPriceBuild:
"""자원 축을 일위대가(`B`)로 조립한다. """자원 축을 일위대가(`B`)로 조립한다.
@@ -592,6 +627,9 @@ def build_unit_prices(
`labor_surcharge_choices` 품의 할인·할증 26계열(산림품셈 1-4). ** 고르면 붙는다.** `labor_surcharge_choices` 품의 할인·할증 26계열(산림품셈 1-4). ** 고르면 붙는다.**
어느 공종에 붙일지는 원문 [] 작업을 지목하므로 **켜는 것은 사용자 **이다. 어느 공종에 붙일지는 원문 [] 작업을 지목하므로 **켜는 것은 사용자 **이다.
`operator_wage_digits` 조종원 시간당 노임을 자르는 자리(0 = 미만 절사). 실무마다 다름
(다섯 0 · 2025 울진 소광 1) 규정이 없는 자리라 프로젝트 설정 (브레인 판정 2026-09-13).
""" """
from B09_Estimation.B09_Estimation_FactorChoices import ( from B09_Estimation.B09_Estimation_FactorChoices import (
chosen_values, chosen_values,
@@ -724,7 +762,10 @@ def build_unit_prices(
from B09_Estimation.B09_Estimation_Transport import TRANSPORT_VARIANTS from B09_Estimation.B09_Estimation_Transport import TRANSPORT_VARIANTS
machine_codes |= {variant["machine_code"] for variant in TRANSPORT_VARIANTS} machine_codes |= {variant["machine_code"] for variant in TRANSPORT_VARIANTS}
build.incomplete_machines = _add_machine_layers(build.book, machine_codes, fuel_region) build.operator_wage_digits = operator_wage_digits
build.incomplete_machines = _add_machine_layers(
build.book, machine_codes, fuel_region, operator_wage_digits
)
# 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은 # 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은
# 품이 달라 한 일위대가로 뭉치면 어느 것도 안 맞는다. # 품이 달라 한 일위대가로 뭉치면 어느 것도 안 맞는다.
@@ -1109,6 +1150,7 @@ def cached_build(
transport_distance_km: str = "", transport_distance_km: str = "",
transport_road: str = "", transport_road: str = "",
labor_surcharge: tuple[tuple[str, str], ...] = (), labor_surcharge: tuple[tuple[str, str], ...] = (),
operator_wage_digits: str = "",
) -> UnitPriceBuild: ) -> UnitPriceBuild:
"""조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다. """조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다.
@@ -1135,9 +1177,16 @@ def cached_build(
transport_distance_km=parse_distance_km(transport_distance_km), transport_distance_km=parse_distance_km(transport_distance_km),
transport_road=transport_road or None, transport_road=transport_road or None,
labor_surcharge_choices=dict(labor_surcharge), labor_surcharge_choices=dict(labor_surcharge),
operator_wage_digits=parse_operator_wage_digits(operator_wage_digits),
) )
def parse_operator_wage_digits(value: Any) -> int:
"""조종원 시간당 노임 자르는 자리 설정 — 0·1·2 만 받고 그 밖(빈 값 포함)은 기본 0."""
text = str(value if value is not None else "").strip()
return int(text) if text in ("0", "1", "2") else 0
@dataclass @dataclass
class DirectCostBreakdown: class DirectCostBreakdown:
"""⑤ 공사원가계산서가 받는 **직접비 3분할**. """⑤ 공사원가계산서가 받는 **직접비 3분할**.
@@ -224,18 +224,20 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
_ZERO, _ZERO,
) )
amount = material_so_far * detail.percent_of_material / Decimal(100) amount = material_so_far * detail.percent_of_material / Decimal(100)
# 중기 호표의 잡품(주연료비 × 율)도 같은 비율 줄 — 이름표만 가름(품셈 제8장).
misc = detail.note.startswith("잡품")
rows.append( rows.append(
{ {
"code": detail.ref_code, "code": detail.ref_code,
"name": "공구손료·잡재료", "name": "잡품" if misc else "공구손료·잡재료",
"spec": f"주재료비{detail.percent_of_material}%", "spec": f"{'주연료비' if misc else '주재료비'}{detail.percent_of_material}%",
"unit": "%", "unit": "%",
"quantity": str(detail.percent_of_material), "quantity": str(detail.percent_of_material),
"material": str(amount), "material": str(amount),
"labor": "0", "labor": "0",
"expense": "0", "expense": "0",
"total": _money_text(amount), "total": _money_text(amount),
"source": "품셈 1-2-6", "source": "품셈 제8장" if misc else "품셈 1-2-6",
"drillable": False, "drillable": False,
"note": detail.note, "note": detail.note,
} }
+167
View File
@@ -0,0 +1,167 @@
"""STmate 골든셋 재현 — 실무 내역 원본(XLSX)을 **우리 엔진**으로 되풀어 대조(PLAN 6장·명세 8장).
기준점 `resources/knowledge/original/실무문서/` 실무 6(STmate 출력 XLSX).
브레인 완료 판정 자리 6 사슬 단계를 하나 세울 때마다 여기 벌씩 더하고 **단계마다 돌림**.
노임 시간당 환산 `환율및기초자료` 시트 일당 × 시간당
중기 시간당 사용료 `중기사용료` 시트 호표 손료·운전원·연료·잡품
단가산출 Q `단가산출서` 시트 시간당 단가 ÷ Q ( 차례 )
일위대가·내역 절사 일위대가·내역 성분별 절사 (마지막)
실무 원본은 **git 지식DB** 어느 창에서든 . 원본이 없으면 건너뜀(시험 코드 탓이 아님).
값을 여기서 짓지 않음 원본 칸을 읽어 **엔진 함수의 입력**으로만 .
"""
from __future__ import annotations
import pathlib
import warnings
from decimal import Decimal
from functools import lru_cache
import pytest
from B09_Estimation.B09_Estimation_MachineCost import hourly_operator_wage
from B09_Estimation.B09_Estimation_PriceBook import (
Money3,
PriceBook,
PriceDetail,
PriceKind,
PriceTitle,
)
from B09_Estimation.B09_Estimation_UnitPrice import _misc_row, _slots
ROOT = pathlib.Path(__file__).resolve().parents[2]
PRACTICE = ROOT / "resources" / "knowledge" / "original" / "실무문서"
@lru_cache(maxsize=1)
def _workbooks() -> tuple[tuple[str, dict[str, list[tuple]]], ...]:
"""실무 XLSX 마다 쓰는 시트만 값으로 읽어 둠(한 번). `(상대 경로, {시트: 줄들})`."""
openpyxl = pytest.importorskip("openpyxl")
wanted = ("환율및기초자료", "중기사용료")
found = []
for path in sorted(PRACTICE.rglob("*.xlsx")):
if path.name.startswith("~$"):
continue
with warnings.catch_warnings():
warnings.simplefilter("ignore")
try:
book = openpyxl.load_workbook(path, data_only=True, read_only=True)
except Exception: # 깨진 사본 — 기준점이 아님
continue
sheets = {
name: list(book[name].iter_rows(max_col=12, values_only=True))
for name in wanted
if name in book.sheetnames
}
book.close()
if sheets:
found.append((str(path.relative_to(PRACTICE)), sheets))
return tuple(found)
def _decimal_places(value: Decimal) -> int:
return max(0, -value.normalize().as_tuple().exponent)
def _wage_rows() -> list[tuple[str, str, Decimal, str, Decimal]]:
rows = []
for name, sheets in _workbooks():
for row in sheets.get("환율및기초자료", []):
if len(row) < 7 or not isinstance(row[3], (int, float)) or not isinstance(row[4], str):
continue
if row[2] and isinstance(row[6], (int, float)):
formula = row[4].replace("*", "", 1).replace("=", "").strip()
rows.append(
(name, str(row[2]), Decimal(str(row[3])), formula, Decimal(str(row[6])))
)
return rows
def test_노임_시간당_환산_실무_원본_전수_재현() -> None:
"""② 일당 × `1/8*16/12*25/20` 좌→우 순차 + 절사 — 실무 원본의 시간당 칸과 **전부** 같음.
절사 자리는 원본이 보인 대로(다섯 · 2025 울진 소광 0.1) 설정값이라 칸에서 읽음.
"""
rows = _wage_rows()
if not rows:
pytest.skip("실무 원본 XLSX 가 없음")
assert len(rows) >= 18 # 6건 × 운전사 3직종
misses = [
(name, job, daily, expected, got)
for name, job, daily, formula, expected in rows
if (got := hourly_operator_wage(daily, formula, digits=_decimal_places(expected)))
!= expected
]
assert not misses, misses
def _num(value: object) -> Decimal | None:
return Decimal(str(value)) if isinstance(value, (int, float)) else None
def _machine_sheets() -> list[tuple[str, str, list[tuple], tuple]]:
"""`중기사용료` 시트의 호표 — `(원본, 호표 이름, 구성 줄들, 합계 줄)`."""
found = []
for name, sheets in _workbooks():
block: list[tuple] | None = None
title = ""
for row in sheets.get("중기사용료", []):
head = str(row[0] or "").replace(" ", "")
if head.startswith("") and head.endswith("호표"):
block, title = [], ""
continue
if block is None:
continue
if head == "합계":
found.append((name, title, block, row))
block = None
elif row[2] is None and row[3] and not title:
title = f"{row[0]} {row[1] or ''}".strip()
elif _num(row[2]) is not None:
block.append(row)
return found
def _assemble(rows: list[tuple]) -> Money3 | None:
"""원본 호표 줄을 **우리 단가표 층**(S·L·M·잡품 비율 줄 → X)에 올려 풂. 모르는 줄이면 `None`."""
book = PriceBook()
book.add_title(PriceTitle("X", PriceKind.MACHINE_HOURLY, "호표"))
for index, row in enumerate(rows):
quantity, price, unit = _num(row[2]), _num(row[4]), str(row[3] or "").strip()
code = f"R{index}"
if unit == "%":
# 이름은 원본마다 다름(잡품·잡재료·잡유·잡재료비) — 「주연료의 %」 가산 행 모양.
book.add_detail(_misc_row("X", quantity))
continue
kind = {"천원": PriceKind.MACHINE_BASE, "": PriceKind.LABOR}.get(unit, PriceKind.MATERIAL)
if price is None:
return None
book.add_title(PriceTitle(code, kind, str(row[0]), unit=unit, slots=_slots(price)))
book.add_detail(PriceDetail("X", code, quantity))
return book.resolve("X")
def test_중기_시간당_사용료_실무_원본_호표_전수_재현() -> None:
"""④ 손료·운전원·주연료·**잡품(주연료비 × 율 가산 행)** — 줄 0.1원 · 성분 소계 원 미만 절사.
회귀 기준 봉화 2024 제2호표 굴삭기 0.7: 23,128 + 55,700 + 18,015 = **96,843**(명세 7).
"""
sheets = _machine_sheets()
if not sheets:
pytest.skip("실무 원본 XLSX 가 없음")
checked = 0
misses = []
for name, title, rows, total in sheets:
got = _assemble(rows)
if got is None:
continue
checked += 1
want = (_num(total[5]), _num(total[7]), _num(total[9]), _num(total[11]))
if (got.total, got.labor, got.material, got.expense) != want:
misses.append((name, title, want, (got.total, got.labor, got.material, got.expense)))
assert checked >= 50, checked
assert not misses, misses[:5]
bonghwa = next(t for t in sheets if "현동" in t[0] and "0.7" in t[1])
assert _assemble(bonghwa[2]).total == Decimal(96843)
+101
View File
@@ -0,0 +1,101 @@
"""조종원 시간당 노임 — 식 좌→우 순차 + 원 미만 절사 (2026-09-13, PLAN 6장 · 명세 7장).
기준점 STmate 분석 18 §2.1: 6 × 3직종 18/18 우일 때만 일치(계수 선계산 14/18).
건설기계운전사 267,360 55,700 · 화물차운전사 226,709 47,231 ·
일반기계운전사 161,142 33,571
"""
from __future__ import annotations
from decimal import Decimal
from functools import lru_cache
import pytest
from B09_Estimation.B09_Estimation_MachineCost import (
OPERATOR_ALLOWANCE_FACTOR,
OPERATOR_WAGE_FORMULA,
hourly_operator_wage,
)
from B09_Estimation.B09_Estimation_UnitPrice import HOURLY_WAGE_SUFFIX, build_unit_prices
@pytest.mark.parametrize(
("daily", "hourly"),
[(267360, 55700), (226709, 47231), (161142, 33571)],
)
def test_좌에서_우로_차례로_풀고_원_미만_절사(daily: int, hourly: int) -> None:
assert OPERATOR_WAGE_FORMULA == "1/8*16/12*25/20"
assert hourly_operator_wage(Decimal(daily)) == Decimal(hourly)
def test_계수를_미리_접으면_1원_틀린다() -> None:
"""회귀 막이 — 접은 계수 곱은 55,699.99… 라 절사하면 55,699(STmate 55,700)."""
folded = (Decimal(267360) / 8 * OPERATOR_ALLOWANCE_FACTOR).to_integral_value(
rounding="ROUND_FLOOR"
)
assert folded == Decimal(55699) # 종전 코드 — 계수 1.666…6 이 끝자리에서 잘려 55,699.99…
assert hourly_operator_wage(Decimal(267360), digits=None) == Decimal(55700)
@lru_cache(maxsize=1)
def _build():
return build_unit_prices()
def test_자르는_자리는_프로젝트_설정이고_기본은_원_미만() -> None:
"""실무마다 다름(다섯 건 0 · 2025 울진 소광 1) — 규정이 없어 설정 칸(브레인 판정)."""
from B09_Estimation.B09_Estimation_UnitPrice import parse_operator_wage_digits
assert [parse_operator_wage_digits(v) for v in ("", None, "0", "1", "2", "3", "x")] == [
0, 0, 0, 1, 2, 0, 0,
] # fmt: skip
assert hourly_operator_wage(Decimal(273971), digits=1) == Decimal("57077.2")
one = build_unit_prices(operator_wage_digits=1)
assert one.operator_wage_digits == 1
code = next(c for c in one.book.titles if c.endswith(HOURLY_WAGE_SUFFIX))
daily = _build().book.titles[code].adopted_price() # 기본 벌 — 원 미만
assert one.book.titles[code].adopted_price() >= daily
assert daily == daily.to_integral_value()
def test_중기_호표의_운전원은_시간당_노임_제목을_1시간분_부른다() -> None:
book = _build().book
hourly = [code for code in book.titles if code.endswith(HOURLY_WAGE_SUFFIX)]
assert hourly, "조종원 시간당 제목이 한 벌도 안 섬"
for code in hourly:
daily = (
book.titles[code.removesuffix(HOURLY_WAGE_SUFFIX)].adopted_price()
if code.removesuffix(HOURLY_WAGE_SUFFIX) in book.titles
else None
)
price = book.titles[code].adopted_price()
assert price == price.to_integral_value() # 원 미만 절사
if daily is not None:
assert price == hourly_operator_wage(daily)
# X 층 운전원 줄은 계수 접은 수량이 아니라 시간당 제목 × 인수.
operator_rows = [
detail
for details in book.details.values()
for detail in details
if detail.ref_code.endswith(HOURLY_WAGE_SUFFIX)
]
assert operator_rows and all(
d.quantity == d.quantity.to_integral_value() for d in operator_rows
)
def test_중기_호표는_연료를_따로_세우고_잡품은_비율_가산_행이며_소계를_자른다() -> None:
"""PLAN 6장 ④ — 연료 수량에 잡품을 접지 않음 · 줄 0.1원 · 성분 소계 원 미만(명세 7장)."""
from B09_Estimation.B09_Estimation_MachineOperating import load_operating_records
book = _build().book
record = next(r for r in load_operating_records().records if r.machine_code == "0201-0070")
details = book.details["X-0201-0070"]
fuel = next(d for d in details if d.ref_code.startswith("M-FUEL-"))
misc = next(d for d in details if d.percent_of_material is not None)
assert fuel.quantity == record.fuel_liters_per_hour # 종전엔 × (1 + 잡품%)
assert misc.percent_of_material == record.misc_material_percent and misc.note.startswith("잡품")
money = book.resolve("X-0201-0070")
for part in (money.material, money.labor, money.expense):
assert part == part.to_integral_value() # 성분 소계 원 미만 절사
+2 -1
View File
@@ -46,7 +46,8 @@ def test_암절취는_암파쇄와_집토의_합으로_선다():
assert all(d.quantity == Decimal(1) / Decimal("5.0") for d in leaf) assert all(d.quantity == Decimal(1) / Decimal("5.0") for d in leaf)
whole = book.resolve("B-FP-09-04#연암").total whole = book.resolve("B-FP-09-04#연암").total
parts = book.resolve("B-FP-09-04-01#연암").total + book.resolve("B-FP-09-04-02").total parts = book.resolve("B-FP-09-04-01#연암").total + book.resolve("B-FP-09-04-02").total
assert whole == parts > 0 # 28자리 끝의 반올림 차례만 다를 수 있음 — 금액 자리(원)에서 같으면 같은 합.
assert parts > 0 and abs(whole - parts) < Decimal("1e-15")
assert "B-FP-09-04#평균" in book.titles # [주]① 평균 assert "B-FP-09-04#평균" in book.titles # [주]① 평균