Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -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",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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)의 **밑수** — 그 항목에 바로 붙은 자재 줄의 합.
|
||||||
|
|||||||
@@ -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 ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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() # 성분 소계 원 미만 절사
|
||||||
@@ -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 # [주]① 평균
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user