diff --git a/B09_Estimation/B09_Estimation_Lists_Sources.py b/B09_Estimation/B09_Estimation_Lists_Sources.py index 53bb7613..30f1f11c 100644 --- a/B09_Estimation/B09_Estimation_Lists_Sources.py +++ b/B09_Estimation/B09_Estimation_Lists_Sources.py @@ -170,7 +170,9 @@ def base_reference_data( # 제수당·상여금·퇴직급여충당금 계수를 곱한 값 (2026-09-09 사용자 확정 ③). # 근거·한계는 `MachineCost.OPERATOR_ALLOWANCE_FACTOR` 주석 한 곳에 모아 뒀다. # 식 좌→우 순차 + 원 미만 절사(명세 7장 · STmate 18번 §2.1). - "hourly_krw": _money(hourly_operator_wage(Decimal(str(wage)))), + "hourly_krw": _money( + hourly_operator_wage(Decimal(str(wage)), digits=prices.operator_wage_digits) + ), "formula": "일당 ÷ 8시간 × 16/12 × 25/20", } ) diff --git a/B09_Estimation/B09_Estimation_MachineExpenseSheet.py b/B09_Estimation/B09_Estimation_MachineExpenseSheet.py index 1fbc6e68..46f756cf 100644 --- a/B09_Estimation/B09_Estimation_MachineExpenseSheet.py +++ b/B09_Estimation/B09_Estimation_MachineExpenseSheet.py @@ -151,7 +151,9 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]: "operator_daily_wage": _money(wage), # 식 좌→우 순차 + 원 미만 절사(명세 7장) — 계수로 접으면 1원 틀림. "operator_krw_per_hour": _money( - hourly_operator_wage(wage) if wage is not None else None + hourly_operator_wage(wage, digits=getattr(build, "operator_wage_digits", 0)) + if wage is not None + else None ), # ③ 시간당 사용료 — 조립된 값(이 장의 결론) "material_krw": _money(money.material), diff --git a/B09_Estimation/B09_Estimation_PriceBook.py b/B09_Estimation/B09_Estimation_PriceBook.py index b5328f75..7bd8cf77 100644 --- a/B09_Estimation/B09_Estimation_PriceBook.py +++ b/B09_Estimation/B09_Estimation_PriceBook.py @@ -20,7 +20,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from decimal import Decimal +from decimal import ROUND_FLOOR, Decimal from enum import Enum _ZERO = Decimal(0) @@ -57,6 +57,14 @@ class PriceKind(str, Enum): #: 카탈로그 층 — 상세를 갖지 않고 값이 바로 있는 종류. 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): """단가 조립이 성립하지 않는 경우. 0 으로 때우지 않고 멈춘다.""" @@ -84,6 +92,15 @@ class Money3: def scaled(self, factor: Decimal) -> Money3: 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 class PriceTitle: @@ -207,6 +224,8 @@ class PriceBook: raise PriceBookError(f"{code} ({title.name}): 상세 줄이 없어 단가를 조립할 수 없습니다") total = Money3() + # 호표 안에서 자르는 층이면 줄 금액을 0.1원 미만 절사해 쌓고 끝에 성분 소계를 원 미만 절사. + truncate = title.kind in TRUNCATED_KINDS # 제잡비 밑수로 쓸 **사람 품(직접노무비)** — 기계 줄 안의 조종원 노임은 안 센다. # 근거는 아래 `percent_of_labor` 자리 주석의 인용 셋. direct_labor = Decimal(0) @@ -264,9 +283,10 @@ class PriceBook: # ⚠ 밑수가 **명시된 잡재료를 뺀 주재료비**여야 하는데, 품셈이 명시한 잡재료는 # 이미 자원 줄로 서 있어 이 밑수에 함께 든다 — 그 공종에 잡재료가 명시돼 # 있으면 1-2-6 이 애초에 안 걸리는 자리이므로 **칸을 비워 두는 것이 맞다.** - total = total + Money3( - material=direct_material * row.percent_of_material / Decimal(100) - ) + # ⚠ 중기(X) 호표의 **잡품**도 이 줄 — 「주연료비 × 율」의 가산 행(STmate 17번 §3, + # 잡품 단가 칸 = 연료 금액). 수량 × 단가로 연료 수량에 접으면 줄이 사라짐. + share = Money3(material=direct_material * row.percent_of_material / Decimal(100)) + total = total + (share.floored(_TENTH) if truncate else share) continue child = self.resolve(row.ref_code, (*_seen, code)) @@ -275,12 +295,14 @@ class PriceBook: total = total + total.scaled(row.percent_of_parent / Decimal(100)) continue scaled = child.scaled(row.quantity) + if truncate: + scaled = scaled.floored(_TENTH) if self.titles[row.ref_code].kind is PriceKind.LABOR: direct_labor = direct_labor + scaled.labor if self.titles[row.ref_code].kind is PriceKind.MATERIAL: direct_material = direct_material + scaled.material total = total + scaled - return total + return total.floored(_WON) if truncate else total def material_base(self, code: str) -> Decimal: """공구손료·잡재료(산림품셈 1-2-6)의 **밑수** — 그 항목에 바로 붙은 자재 줄의 합. diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 9be99af8..8d84d9f4 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -275,6 +275,8 @@ async def _build_for(project_id: UUID): str(settings.get("transport_road") or ""), # 품 할인·할증(1-4) — **안 고르면 안 붙는다.** tuple(sorted(parse_labor_surcharge(settings.get("labor_surcharge")).items())), + # 조종원 시간당 노임 자르는 자리 — 실무마다 다름(명세 7장 정정). 안 정하면 원 미만. + str(settings.get("operator_wage_digits") or ""), ) diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 9d54e12e..16f68212 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -94,6 +94,8 @@ def _slots(value: Decimal) -> list[Decimal | None]: @dataclass class UnitPriceBuild: book: PriceBook = field(default_factory=PriceBook) + #: 조종원 시간당 노임을 자른 자리(프로젝트 설정 `operator_wage_digits`, 기본 0 = 원 미만). + operator_wage_digits: int = 0 #: 세우지 못한 공종 — 값이 안 서는 것을 빈 줄로 두지 않는다. skipped: list[str] = field(default_factory=list) #: 기계 성분이 비어 있는 채로 쓰인 기종 (연료·조종원 미확보). @@ -244,8 +246,26 @@ def _apply_combined_misc_rate(book: PriceBook, work_item_titles: list[str]) -> i 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( - 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]: """`S`(취득가) · `L`(운전사) · `M`(연료) 을 세우고 그 위에 `X` 를 올린다. @@ -353,10 +373,11 @@ def _add_machine_layers( liters = record.fuel_liters_per_hour if liters is not None: + book.add_detail(PriceDetail(hourly_code, fuel_code, liters, note="주연료")) if record.misc_material_percent is not None: - # 잡재료는 **주연료의 %** — 유가와 같이 움직인다. - liters = liters * (Decimal(1) + record.misc_material_percent / Decimal(100)) - book.add_detail(PriceDetail(hourly_code, fuel_code, liters, note="주연료 + 잡재료")) + # 잡품은 **주연료비 × 율의 가산 행**(명세 7장 · STmate 17번 §3 「잡품 단가 칸 = 연료 + # 금액」) — 연료 수량에 접지 않음. 유가와 같이 움직이는 것은 그대로. + book.add_detail(_misc_row(hourly_code, record.misc_material_percent)) # 조합 사용(리퍼·브레이커·집게)일 때 쓸 **잡재료 16%** 짜리 층을 함께 세운다. # 같은 기계라도 조합이면 본체 잡재료가 줄어든다(품셈 제8장 [주]⑤). @@ -378,11 +399,11 @@ def _add_machine_layers( PriceDetail( combined_code, fuel_code, - record.fuel_liters_per_hour - * (Decimal(1) + COMBINED_MISC_PERCENT / Decimal(100)), - note=f"주연료 + 잡재료 {COMBINED_MISC_PERCENT}% (조합 사용)", + record.fuel_liters_per_hour, + note="주연료 (조합 사용)", ) ) + book.add_detail(_misc_row(combined_code, Decimal(COMBINED_MISC_PERCENT))) else: incomplete.append(f"{code} (연료소모량 없음)") @@ -402,12 +423,18 @@ def _add_machine_layers( kind=PriceKind.LABOR, name="조종원(시간당)", unit="hr", - slots=_slots(hourly_operator_wage(wages[wage_code])), + slots=_slots( + hourly_operator_wage(wages[wage_code], digits=operator_wage_digits) + ), # ⚠ 조종원도 노임이다 — 같은 플래그가 붙어야 한다. 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}#조합" if combined_code in book.titles: @@ -416,7 +443,7 @@ def _add_machine_layers( combined_code, wage_code, per_hour_person, - note=f"조종원 (일당 × {OPERATOR_WAGE_FORMULA} 좌→우 · 원 미만 절사)", + note=operator_note, ) ) book.add_detail( @@ -424,7 +451,7 @@ def _add_machine_layers( hourly_code, wage_code, per_hour_person, - note=f"조종원 (일당 × {OPERATOR_WAGE_FORMULA} 좌→우 · 원 미만 절사)", + note=operator_note, ) ) else: @@ -580,6 +607,7 @@ def build_unit_prices( transport_distance_km: Decimal | None = None, transport_road: str | None = None, labor_surcharge_choices: dict[str, str] | None = None, + operator_wage_digits: int = 0, ) -> UnitPriceBuild: """자원 축을 일위대가(`B`)로 조립한다. @@ -599,6 +627,9 @@ def build_unit_prices( ⚠ `labor_surcharge_choices` — 품의 할인·할증 26계열(산림품셈 1-4). **안 고르면 안 붙는다.** 어느 공종에 붙일지는 원문 [주]가 작업을 지목하므로 **켜는 것은 사용자 몫**이다. + + ⚠ `operator_wage_digits` — 조종원 시간당 노임을 자르는 자리(0 = 원 미만 절사). 실무마다 다름 + (다섯 건 0 · 2025 울진 소광 1) — 규정이 없는 자리라 프로젝트 설정 칸(브레인 판정 2026-09-13). """ from B09_Estimation.B09_Estimation_FactorChoices import ( chosen_values, @@ -731,7 +762,10 @@ def build_unit_prices( from B09_Estimation.B09_Estimation_Transport import 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`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은 # 품이 달라 한 일위대가로 뭉치면 어느 것도 안 맞는다. @@ -1116,6 +1150,7 @@ def cached_build( transport_distance_km: str = "", transport_road: str = "", labor_surcharge: tuple[tuple[str, str], ...] = (), + operator_wage_digits: str = "", ) -> UnitPriceBuild: """조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다. @@ -1142,9 +1177,16 @@ def cached_build( transport_distance_km=parse_distance_km(transport_distance_km), transport_road=transport_road or None, 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 class DirectCostBreakdown: """⑤ 공사원가계산서가 받는 **직접비 3분할**. diff --git a/B09_Estimation/B09_Estimation_UnitPrice_View.py b/B09_Estimation/B09_Estimation_UnitPrice_View.py index 9e41ef49..3d88c18f 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice_View.py +++ b/B09_Estimation/B09_Estimation_UnitPrice_View.py @@ -224,18 +224,20 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict: _ZERO, ) amount = material_so_far * detail.percent_of_material / Decimal(100) + # 중기 호표의 잡품(주연료비 × 율)도 같은 비율 줄 — 이름표만 가름(품셈 제8장). + misc = detail.note.startswith("잡품") rows.append( { "code": detail.ref_code, - "name": "공구손료·잡재료", - "spec": f"주재료비의 {detail.percent_of_material}%", + "name": "잡품" if misc else "공구손료·잡재료", + "spec": f"{'주연료비' if misc else '주재료비'}의 {detail.percent_of_material}%", "unit": "%", "quantity": str(detail.percent_of_material), "material": str(amount), "labor": "0", "expense": "0", "total": _money_text(amount), - "source": "품셈 1-2-6", + "source": "품셈 제8장" if misc else "품셈 1-2-6", "drillable": False, "note": detail.note, } diff --git a/resources/tester/test_b09_golden_stmate.py b/resources/tester/test_b09_golden_stmate.py index 12e40a53..6061e428 100644 --- a/resources/tester/test_b09_golden_stmate.py +++ b/resources/tester/test_b09_golden_stmate.py @@ -1,10 +1,10 @@ -"""STmate 골든셋 재현 — 실무 내역 원본(평문 XLSX)을 **우리 엔진**으로 되풀어 대조(PLAN 6장·명세 8장). +"""STmate 골든셋 재현 — 실무 내역 원본(XLSX)을 **우리 엔진**으로 되풀어 대조(PLAN 6장·명세 8장). 기준점 — `resources/knowledge/original/실무문서/` 의 실무 6건(STmate 출력 XLSX). 브레인 완료 판정 자리 — 6장 사슬 단계를 하나 세울 때마다 여기 한 벌씩 더하고 **단계마다 돌림**. - ② 노임 시간당 환산 `환율및기초자료` 시트 — 일당 × 식 → 시간당 ✅ 이 판 - ④ 중기 시간당 사용료 `중기사용료` 시트 호표 — 손료·운전원·연료·잡품 (다음 단계) + ② 노임 시간당 환산 `환율및기초자료` 시트 — 일당 × 식 → 시간당 ✅ + ④ 중기 시간당 사용료 `중기사용료` 시트 호표 — 손료·운전원·연료·잡품 ✅ ③ 단가산출 Q 식 `단가산출서` 시트 — 시간당 단가 ÷ Q (층 차례 뒤) ① 일위대가·내역 절사 일위대가·내역 줄 — 성분별 절사 (마지막) @@ -22,6 +22,14 @@ 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" / "실무문서" @@ -31,7 +39,7 @@ PRACTICE = ROOT / "resources" / "knowledge" / "original" / "실무문서" def _workbooks() -> tuple[tuple[str, dict[str, list[tuple]]], ...]: """실무 XLSX 마다 쓰는 시트만 값으로 읽어 둠(한 번). `(상대 경로, {시트: 줄들})`.""" openpyxl = pytest.importorskip("openpyxl") - wanted = ("환율및기초자료",) + wanted = ("환율및기초자료", "중기사용료") found = [] for path in sorted(PRACTICE.rglob("*.xlsx")): if path.name.startswith("~$"): @@ -87,3 +95,73 @@ def test_노임_시간당_환산_실무_원본_전수_재현() -> None: != 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) diff --git a/resources/tester/test_b09_hourly_wage.py b/resources/tester/test_b09_hourly_wage.py index c6b06154..aa1445d8 100644 --- a/resources/tester/test_b09_hourly_wage.py +++ b/resources/tester/test_b09_hourly_wage.py @@ -43,6 +43,22 @@ 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)] @@ -67,3 +83,19 @@ def test_중기_호표의_운전원은_시간당_노임_제목을_1시간분_부 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() # 성분 소계 원 미만 절사