feat(b09): 단가산출 Q 식 — f·Q 소수 2자리 확정 뒤 나눔 · D 줄 0.1원·머리 원 미만 절사
- fix2(사사오입 0.01) — 굴착기·도자·다짐 작업량, 암 평균 Q, 표 직접 작업량 - TRUNCATED_KINDS 에 PRICE_BASIS(D) 더함 — B 는 다음 차례 - 골든셋 ③ 단가산출근거 실무 6건 208 호표 전수 일치(제외 소계·% 줄·산근 참조·계약단가 읽기) · 회귀 3,545.5 → 3,545 - 검증 프로젝트 내역 본체 122,924,846 → 122,870,975 · 전체 시험 1606 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
@@ -32,7 +32,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
|
||||
@@ -75,6 +75,18 @@ class ProductivityError(ValueError):
|
||||
"""시공능력을 못 세운 경우. 0 이나 가운데값으로 때우지 않는다."""
|
||||
|
||||
|
||||
_HUNDREDTH = Decimal("0.01")
|
||||
|
||||
|
||||
def fix2(value: Decimal) -> Decimal:
|
||||
"""**소수 2자리로 먼저 확정**(사사오입) — 작업량 Q 와 토량환산계수 f(명세 7장 · STmate 18번 §2.2).
|
||||
|
||||
⚠ 원값으로 나누면 어긋남 — `55,700 ÷ 15.708 = 3,545.96` ≠ `55,700 ÷ 15.71 = 3,545.5`.
|
||||
f 도 `1/1.175 = 0.85` 로 먼저 자른 뒤 곱함(봉화 2024 단가산출근거 제2호표).
|
||||
"""
|
||||
return Decimal(value).quantize(_HUNDREDTH, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def parse_measure(cell: str) -> Decimal | None:
|
||||
"""계수 셀 하나를 수로 읽는다. **확정값이 아니면 `None`.**
|
||||
|
||||
@@ -117,7 +129,12 @@ class CycleFactors:
|
||||
def formula_text(self) -> str:
|
||||
return (
|
||||
f"Q = 3600 ÷ {self.cycle_seconds} × {self.bucket_capacity_m3} × "
|
||||
f"{self.bucket_coefficient} × {self.volume_factor} × {self.efficiency}"
|
||||
f"{self.bucket_coefficient} × {fix2(self.volume_factor)} × {self.efficiency}"
|
||||
+ (
|
||||
f" = {hourly_output(self)} ㎥/hr (f·Q 소수 2자리 확정)"
|
||||
if self.cycle_seconds > 0
|
||||
else ""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -134,23 +151,23 @@ class FactorGap:
|
||||
def hourly_output(factors: CycleFactors) -> Decimal:
|
||||
"""시간당 작업량 `Q` (㎥/hr).
|
||||
|
||||
`Q = (3600 ÷ Cm) · q · K · f · E` — 품셈 8-1-4.
|
||||
`Q = (3600 ÷ Cm) · q · K · f · E` — 품셈 8-1-4. f 와 Q 는 **소수 2자리로 확정**(`fix2`).
|
||||
"""
|
||||
if factors.cycle_seconds <= 0:
|
||||
raise ProductivityError(
|
||||
f"{factors.work_item_code}: 1싸이클 시간(Cm)이 {factors.cycle_seconds} 입니다."
|
||||
)
|
||||
cycles_per_hour = _SECONDS_PER_HOUR / factors.cycle_seconds
|
||||
output = (
|
||||
cycles_per_hour
|
||||
_SECONDS_PER_HOUR
|
||||
* factors.bucket_capacity_m3
|
||||
* factors.bucket_coefficient
|
||||
* factors.volume_factor
|
||||
* fix2(factors.volume_factor)
|
||||
* factors.efficiency
|
||||
/ factors.cycle_seconds
|
||||
)
|
||||
if output <= 0:
|
||||
raise ProductivityError(f"{factors.work_item_code}: 시간당 작업량이 {output} 입니다.")
|
||||
return output
|
||||
return fix2(output)
|
||||
|
||||
|
||||
def machine_hours_per_unit(factors: CycleFactors) -> Decimal:
|
||||
|
||||
@@ -29,6 +29,8 @@ import re
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_MachineProductivity import fix2
|
||||
|
||||
_ZERO = Decimal(0)
|
||||
|
||||
#: 계수 이름 — 표 첫 칸이 「V(다짐속도,km/hr)」처럼 기호+설명이거나 「A」처럼 기호뿐이다.
|
||||
@@ -105,19 +107,13 @@ def capacity_per_hour(factors: dict[str, Decimal], formula: str = FORMULA_ROLLER
|
||||
"""시간당 작업량(㎥/시간). **식이 둘**이라 어느 식인지 함께 받는다.
|
||||
|
||||
롤러 `Q = 1000 × V × W × E × D × f / N` · 콤펙터 `Q = A × N × H × f × E / P`
|
||||
⚠ f 와 Q 는 **소수 2자리로 확정**(명세 7장 — 원값으로 나누면 성분 단가가 어긋남).
|
||||
"""
|
||||
f = fix2(factors["f"])
|
||||
if formula == FORMULA_PLATE:
|
||||
return (
|
||||
factors["A"] * factors["N"] * factors["H"] * factors["f"] * factors["E"] / factors["P"]
|
||||
)
|
||||
return (
|
||||
Decimal(1000)
|
||||
* factors["V"]
|
||||
* factors["W"]
|
||||
* factors["E"]
|
||||
* factors["D"]
|
||||
* factors["f"]
|
||||
/ factors["N"]
|
||||
return fix2(factors["A"] * factors["N"] * factors["H"] * f * factors["E"] / factors["P"])
|
||||
return fix2(
|
||||
Decimal(1000) * factors["V"] * factors["W"] * factors["E"] * factors["D"] * f / factors["N"]
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ from B09_Estimation.B09_Estimation_MachineProductivity import (
|
||||
ProductivityError,
|
||||
_first_measure,
|
||||
extract_cycle_factors,
|
||||
fix2,
|
||||
parse_measure,
|
||||
)
|
||||
|
||||
@@ -112,12 +113,13 @@ class DozerFactors:
|
||||
def formula_text(self) -> str:
|
||||
return (
|
||||
f"Q = 60 ÷ {self.cycle_minutes:.4f}분 × ({self.blade_capacity_m3} × "
|
||||
f"{self.distance_factor}) × {self.volume_factor} × {self.efficiency}"
|
||||
f"{self.distance_factor}) × {fix2(self.volume_factor)} × {self.efficiency}"
|
||||
+ (f" = {dozer_hourly_output(self)} ㎥/hr" if self.cycle_minutes > 0 else "")
|
||||
)
|
||||
|
||||
|
||||
def dozer_hourly_output(factors: DozerFactors) -> Decimal:
|
||||
"""불도저 시간당 작업량 `Q` (㎥/hr). **밑수는 60(분)** 이다."""
|
||||
"""불도저 시간당 작업량 `Q` (㎥/hr). **밑수는 60(분)** — f 와 Q 는 소수 2자리로 확정(명세 7장)."""
|
||||
if factors.cycle_minutes <= 0:
|
||||
raise ProductivityError(f"{factors.work_item_code}: 싸이클 시간이 0 이하입니다.")
|
||||
blade = factors.blade_capacity_m3 * factors.distance_factor
|
||||
@@ -125,12 +127,12 @@ def dozer_hourly_output(factors: DozerFactors) -> Decimal:
|
||||
_MINUTES_PER_HOUR
|
||||
/ factors.cycle_minutes
|
||||
* blade
|
||||
* factors.volume_factor
|
||||
* fix2(factors.volume_factor)
|
||||
* factors.efficiency
|
||||
)
|
||||
if output <= 0:
|
||||
raise ProductivityError(f"{factors.work_item_code}: 시간당 작업량이 {output} 입니다.")
|
||||
return output
|
||||
return fix2(output)
|
||||
|
||||
|
||||
def dozer_speeds(
|
||||
|
||||
@@ -20,6 +20,7 @@ import re
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_MachineProductivity import fix2
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle
|
||||
|
||||
ROCK_CLASSES = ("연암", "보통암", "경암")
|
||||
@@ -107,10 +108,9 @@ def _add_rock_leaf(build: Any, node: dict[str, Any]) -> None:
|
||||
return
|
||||
variants = [(rock, q, f"작업능력 Q = {q} ㎥/hr ({rock})") for rock, q, _ in rows]
|
||||
if code in AVERAGE_BASIS:
|
||||
average = sum((q for _, q, _ in rows), Decimal(0)) / Decimal(len(rows))
|
||||
variants.append(
|
||||
(AVERAGE_VARIANT, average, f"Q = {average:.4f} ㎥/hr — {AVERAGE_BASIS[code]}")
|
||||
)
|
||||
# 평균 Q 도 **소수 2자리로 확정**한 뒤 나눔(명세 7장) — (5.0+3.4+2.6)/3 = 3.67.
|
||||
average = fix2(sum((q for _, q, _ in rows), Decimal(0)) / Decimal(len(rows)))
|
||||
variants.append((AVERAGE_VARIANT, average, f"Q = {average} ㎥/hr — {AVERAGE_BASIS[code]}"))
|
||||
for variant, capacity, note in variants:
|
||||
title_code = f"B-{code}#{variant}"
|
||||
build.book.add_title(
|
||||
|
||||
@@ -60,8 +60,9 @@ CATALOG_KINDS = frozenset({PriceKind.MATERIAL, PriceKind.LABOR, PriceKind.MACHIN
|
||||
#: **호표 안에서 자르는** 층 — 줄 금액은 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})
|
||||
#: D(단가산출)도 같음 — 줄 `중기 성분 ÷ Q` 0.1원 · 머리 성분 원 미만(봉화 2024 제2호표 3,545.5 → 3,545).
|
||||
#: ⚠ 일위대가(B) 호표도 같은 규칙(345 중 94.2%)이나 **아래층(D)부터 맞춘 뒤** 붙임(PLAN 6장 판정).
|
||||
TRUNCATED_KINDS = frozenset({PriceKind.MACHINE_HOURLY, PriceKind.PRICE_BASIS})
|
||||
_TENTH = Decimal("0.1")
|
||||
_WON = Decimal(1)
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ from B09_Estimation.B09_Estimation_MachineProductivity import (
|
||||
FactorGap,
|
||||
attach_machine_share,
|
||||
extract_cycle_factors,
|
||||
fix2,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import (
|
||||
attach_dozer_share,
|
||||
@@ -965,7 +966,8 @@ def build_unit_prices(
|
||||
build.book.add_output_detail(
|
||||
title_code,
|
||||
hourly_code,
|
||||
(Decimal(1) / capacity["capacity_per_hour"]) * group_share,
|
||||
# 작업량도 소수 2자리로 확정한 뒤 나눔(명세 7장) — 표·원문 값은 대개 이미 그 자리.
|
||||
(Decimal(1) / fix2(capacity["capacity_per_hour"])) * group_share,
|
||||
note=(
|
||||
f"작업량을 표가 직접 줌 — {capacity['cell']} {capacity['capacity_per_hour']}"
|
||||
f" (품셈 원문 표기 그대로)"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
② 노임 시간당 환산 `환율및기초자료` 시트 — 일당 × 식 → 시간당 ✅
|
||||
④ 중기 시간당 사용료 `중기사용료` 시트 호표 — 손료·운전원·연료·잡품 ✅
|
||||
③ 단가산출 Q 식 `단가산출서` 시트 — 시간당 단가 ÷ Q (층 차례 뒤)
|
||||
③ 단가산출 Q 식 `단가산출근거` 시트 — 중기 성분 × 1/Q(0.1원) · 머리 원 미만 ✅
|
||||
① 일위대가·내역 절사 일위대가·내역 줄 — 성분별 절사 (마지막)
|
||||
|
||||
⚠ 실무 원본은 **git 안 지식DB**라 어느 창에서든 돎. 원본이 없으면 건너뜀(시험 코드 탓이 아님).
|
||||
@@ -39,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("~$"):
|
||||
@@ -165,3 +165,118 @@ def test_중기_시간당_사용료_실무_원본_호표_전수_재현() -> None
|
||||
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)
|
||||
|
||||
|
||||
def _basis_blocks() -> list[tuple[str, str, tuple, list[tuple]]]:
|
||||
"""`단가산출근거` 시트의 호표 — `(원본, 호표, 머리 줄, Q 줄들)`. 머리 = 합계·노무·재료·경비.
|
||||
|
||||
`소계(제외금액)`(틀 `(-=)`) 위 줄은 머리에 안 들어감 — Q 를 끌어내려고 보인 인력 품 따위.
|
||||
"""
|
||||
found = []
|
||||
for name, sheets in _workbooks():
|
||||
head: tuple | None = None
|
||||
rows: list[tuple] = []
|
||||
kept = 0 # 마지막 소계까지 든 줄 수
|
||||
for row in [*sheets.get("단가산출근거", []), ("총계", None)]:
|
||||
label = str(row[1] or "").replace(" ", "")
|
||||
first = str(row[0] or "").replace(" ", "")
|
||||
if (label.startswith("제") and label.endswith("호표")) or first.startswith("총"):
|
||||
if head is not None and rows:
|
||||
found.append((name, str(head[1]).replace(" ", ""), head, rows))
|
||||
head, rows, kept = (row if label.endswith("호표") else None), [], 0
|
||||
continue
|
||||
if label.startswith("소계") or label.startswith("계("):
|
||||
template = str(row[6])
|
||||
if "(-==)" in template: # 계(제외금액) — 그때까지 든 줄 전부
|
||||
rows, kept = [], 0
|
||||
elif "(-=)" in template: # 소계(제외금액) — 마지막 소계 뒤 줄
|
||||
rows = rows[:kept]
|
||||
else:
|
||||
kept = len(rows)
|
||||
continue
|
||||
if (
|
||||
head is not None
|
||||
and first.startswith("계") # 「계」 · 「계(경비로적용)」
|
||||
and _num(row[2]) is not None
|
||||
and _num(row[7]) is None # 「계약단가」·「계 x 낙찰율」 은 율 칸이 참
|
||||
):
|
||||
# 머리가 낙찰률을 곱한 계약단가인 원본 — 절사 규칙 대조는 그 앞 「계」 줄로.
|
||||
head = (head[0], head[1], *row[2:])
|
||||
continue
|
||||
if (
|
||||
head is not None
|
||||
and len(row) > 8
|
||||
and _num(row[7]) is not None
|
||||
and _num(row[8]) is not None
|
||||
):
|
||||
rows.append(row)
|
||||
return found
|
||||
|
||||
|
||||
def _assemble_basis(rows: list[tuple]) -> Money3:
|
||||
"""원본 Q 줄(QTY · 성분 단가 J·K·L)을 **우리 D 층**에 올려 풂 — 줄 0.1원 · 머리 원 미만(엔진 규칙).
|
||||
|
||||
성분 단가 칸이 둘 이상 찬 줄은 다른 호표 참조(「산근 N호표」) — 성분마다 잎 하나씩.
|
||||
"""
|
||||
kinds = (PriceKind.LABOR, PriceKind.MATERIAL, PriceKind.MACHINE_BASE)
|
||||
book = PriceBook()
|
||||
book.add_title(PriceTitle("D", PriceKind.PRICE_BASIS, "단가산출"))
|
||||
for index, row in enumerate(rows):
|
||||
quantity, price, shown = _num(row[7]), _num(row[8]), _num(row[2]) or Decimal(0)
|
||||
if abs(quantity * price / 100 - shown) < abs(quantity * price - shown):
|
||||
quantity /= 100 # 공구손료 「노무비 × 2 %」 — 칸 QTY 가 백분율 수인 줄
|
||||
parts = [(k, _num(p)) for k, p in zip(kinds, (*row[9:12], None, None, None))]
|
||||
parts = [(k, p) for k, p in parts if p]
|
||||
if not parts: # 성분 단가 칸이 빈 원본 — 줄 금액이 든 성분 칸으로 가름
|
||||
kind = kinds[next((i for i in range(3) if _num(row[3 + i])), 2)]
|
||||
parts = [(kind, price)]
|
||||
for kind, part in parts:
|
||||
code = f"R{index}{kind.name}"
|
||||
book.add_title(PriceTitle(code, kind, str(row[1]), slots=_slots(part)))
|
||||
book.add_detail(PriceDetail("D", code, quantity))
|
||||
return book.resolve("D")
|
||||
|
||||
|
||||
def test_단가산출_Q_식_실무_원본_호표_전수_재현() -> None:
|
||||
"""③ 줄 = 중기 성분 × 1/Q(Q 소수 2자리 확정) → 0.1원 미만 절사 · 머리 성분 = 원 미만 절사.
|
||||
|
||||
회귀 기준 — 봉화 2024 제2호표 굴삭기 0.2㎥ Q 15.71: 노무 55,700 ÷ Q = **3,545.5** → 머리 3,545(명세 7장).
|
||||
"""
|
||||
blocks = _basis_blocks()
|
||||
if not blocks:
|
||||
pytest.skip("실무 원본 XLSX 가 없음")
|
||||
misses = []
|
||||
for name, title, head, rows in blocks:
|
||||
got = _assemble_basis(rows)
|
||||
want = (_num(head[2]), _num(head[3]), _num(head[4]), _num(head[5]))
|
||||
if not want[1] and not want[2] and want[0] == want[3]:
|
||||
# 경비로 넘기는 호표(운반·소운반 따위) — 성분 절사 합을 경비 한 칸에 모음.
|
||||
got = Money3(expense=got.total)
|
||||
if (got.total, got.labor, got.material, got.expense) != want:
|
||||
misses.append((name, title, want, (got.total, got.labor, got.material, got.expense)))
|
||||
assert len(blocks) >= 200, len(blocks) # 6건 208 호표
|
||||
assert not misses, (len(misses), misses[:5])
|
||||
second = next(b for b in blocks if "현동" in b[0] and b[1] == "제2호표")
|
||||
assert Decimal("3545.5") in [_num(r[3]) for r in second[3]]
|
||||
assert _assemble_basis(second[3]).labor == Decimal(3545)
|
||||
|
||||
|
||||
def test_Q_는_소수_2자리로_먼저_확정한_뒤_나눈다() -> None:
|
||||
"""③ 엔진 — 18번 §2.2 예: q 0.2 · f 1/1.175 → 0.85 · K 0.7 · Cm 15 · E 0.55 → Q **15.71**."""
|
||||
from B09_Estimation.B09_Estimation_MachineProductivity import (
|
||||
CycleFactors,
|
||||
hourly_output,
|
||||
machine_hours_per_unit,
|
||||
)
|
||||
|
||||
factors = CycleFactors(
|
||||
"T", "T", "0201-0020", "굴삭기", Decimal("0.2"), Decimal("0.7"),
|
||||
Decimal(1) / Decimal("1.175"), Decimal("0.55"), Decimal(15),
|
||||
) # fmt: skip
|
||||
assert hourly_output(factors) == Decimal("15.71") # 원값 15.708…
|
||||
book = PriceBook()
|
||||
book.add_title(PriceTitle("X", PriceKind.MACHINE_BASE, "중기", slots=_slots(Decimal(55700))))
|
||||
book.add_title(PriceTitle("D", PriceKind.PRICE_BASIS, "단가산출"))
|
||||
book.add_detail(PriceDetail("D", "X", machine_hours_per_unit(factors)))
|
||||
# 줄 55,700 ÷ 15.71 = 3,545.5(0.1원 절사) → 머리 원 미만 3,545 · 원값 Q 로 나누면 3,545.9 로 갈림.
|
||||
assert book.resolve("D").expense == Decimal(3545)
|
||||
|
||||
Reference in New Issue
Block a user