"""B09 원가계산 — 기계 시공능력 `Q` (품셈 8-1-4, PLAN 9-5 ③). **왜 있는가** — 토공 주요 공종(흙깎기·측구터파기·성토)의 품셈 표는 **소요량표가 아니다.** 「인력 10 % + 장비 90 %」로 갈리고, 장비 몫은 자원 수량이 아니라 **공식의 계수** (`K`·`f`·`E`·`Cm`)로 적혀 있다. 그래서 표를 베끼면 **인력 몫만 서고 장비 몫이 통째로 빠진다** — 2026-09-08 실측으로 측구터파기가 인력 10 % 몫(39,575.6원/㎥)만으로 서 있었다. 공식 (지식DB `05_원가정보/기계경비_산정.md` §4 — 품셈 8-1-4) Q = n · q · K · f · E n = 3600 ÷ Cm (시간당 싸이클 수) q 1싸이클 표준작업량 (버킷 용량 ㎥ — **기종 규격에서 온다**) K 버킷계수 (표의 `K`·`k`) f 체적환산계수 (표의 `f`) E 작업효율 = 현장능력계수 × 실작업시간율 (표의 `E`) Cm 1싸이클 소요시간(초) (표의 `㎝(sec)` — 원문 표기가 「㎝」이지 센티미터가 아니다) 수량 1단위당 기계 소요시간(hr) = 1 ÷ Q → × 시간당 사용료 = 그 공종의 기계경비 ⚠ **㉣ 와 어긋나지 않는다** (PLAN 9-6). ㉣ 는 「작업효율 `E` 를 **시간당 사용료** 쪽에 넣지 말라」이고, 품셈이 `E` 를 넣으라는 자리가 **바로 여기(작업량 `Q`)** 다. 그러므로 `reject_efficiency_in_hourly_rate()` 는 이 모듈에서 **부르지 않는다** — 부르면 정상 계산이 멈추는 오탐이 된다. 진짜 위반은 **같은 `E` 를 `Q` 와 사용료에 둘 다 넣는 것**이라, 그쪽은 사용료 계산 자리(`B09_Estimation_MachineCost`)의 가드가 그대로 지킨다. ⚠ **모르는 값을 지어내지 않는다.** 표가 범위(「0.55∼0.45」)만 주고 확정값을 안 주면 계수를 못 세운 것으로 보고 `FactorGap` 으로 드러낸다 — 가운데값을 임의로 취하지 않는다 (CLAUDE.md 3장). """ from __future__ import annotations import re from dataclasses import dataclass from decimal import Decimal from typing import Any from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog from B09_Estimation.B09_Estimation_ResourceAxis import RANGE_DASH_CLASS _ZERO = Decimal(0) _SECONDS_PER_HOUR = Decimal(3600) #: 표의 행 머리 — 대문자·소문자가 섞여 온다(`K` 와 `k` 가 같은 표 안에 있다). _KEY_BUCKET = ("k",) _KEY_VOLUME = ("f",) _KEY_EFFICIENCY = ("e",) _KEY_CYCLE = ("㎝(sec)", "cm(sec)", "cm", "㎝") #: 품셈 표의 기계 이름 → 기종 카탈로그 이름. **표기만 다르고 같은 기종**이다. #: 「유압식백호우」는 카탈로그에 없어 그대로 두면 장비 몫이 통째로 빠진다. #: ⚠ 넓게 잡지 않는다 — 이름 전체가 이 표의 열쇠와 같을 때만 바꾼다. MACHINE_NAME_ALIASES = { "유압식백호우": "굴착기", "백호우": "굴착기", "백호": "굴착기", "유압식굴삭기": "굴착기", "굴삭기": "굴착기", } #: 무한궤도·타이어 갈래. 카탈로그 이름이 「굴착기(무한궤도)」처럼 갈래를 품고 있다. _TRACK_WORDS = ("무한궤도", "타이어", "습지") _RE_PARENS = re.compile(r"[((]([^))]*)[))]") _RE_NUMBER = re.compile(r"-?\d+(?:\.\d+)?") _RE_FRACTION = re.compile(r"^(\d+(?:\.\d+)?)\s*/\s*(\d+(?:\.\d+)?)$") #: 범위 표기 — 「0.55∼0.45」·「0.2~0.8」. **확정값이 아니다.** _RE_RANGE = re.compile(rf"\d+(?:\.\d+)?\s*[{RANGE_DASH_CLASS}]\s*\d+(?:\.\d+)?") class ProductivityError(ValueError): """시공능력을 못 세운 경우. 0 이나 가운데값으로 때우지 않는다.""" def parse_measure(cell: str) -> Decimal | None: """계수 셀 하나를 수로 읽는다. **확정값이 아니면 `None`.** 읽는 모양 — 「0.77」 · 「1/1.30」(분수) · 「20(135°)」(괄호는 조건 설명이라 버린다). 안 읽는 모양 — 「0.55∼0.45」(범위) · 「육상과동일」(참조) · 빈 칸. """ text = str(cell).strip() if not text: return None if _RE_RANGE.search(text): return None # 범위는 확정값이 아니다 — 가운데를 임의로 취하지 않는다 fraction = _RE_FRACTION.match(text) if fraction: divisor = Decimal(fraction.group(2)) return None if divisor == 0 else Decimal(fraction.group(1)) / divisor # 괄호 안은 조건 설명(각도 등)이므로 떼고 본다 — 「20(135°)」 → 20 outside = _RE_PARENS.sub("", text).strip() found = _RE_NUMBER.search(outside) return Decimal(found.group(0)) if found else None @dataclass(frozen=True) class CycleFactors: """한 표에서 뽑아낸 시공능력 계수 한 벌.""" work_item_code: str pum_table_id: str machine_code: str machine_name: str bucket_capacity_m3: Decimal # q bucket_coefficient: Decimal # K volume_factor: Decimal # f efficiency: Decimal # E — **작업량 쪽에만 들어간다** (㉣) cycle_seconds: Decimal # Cm #: 인력 몫 배분율(%) — 「인력(10%)」이면 `10`. 없으면 `None`. labor_ratio_pct: Decimal | None = None machine_ratio_pct: Decimal | None = None @property 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}" ) @dataclass(frozen=True) class FactorGap: """계수를 못 세운 표. **빈칸으로 두지 않고 무엇이 없는지 적는다.**""" work_item_code: str pum_table_id: str missing: tuple[str, ...] note: str = "" def hourly_output(factors: CycleFactors) -> Decimal: """시간당 작업량 `Q` (㎥/hr). `Q = (3600 ÷ Cm) · q · K · f · E` — 품셈 8-1-4. """ 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 * factors.bucket_capacity_m3 * factors.bucket_coefficient * factors.volume_factor * factors.efficiency ) if output <= 0: raise ProductivityError(f"{factors.work_item_code}: 시간당 작업량이 {output} 입니다.") return output def machine_hours_per_unit(factors: CycleFactors) -> Decimal: """수량 1단위당 기계 소요시간(hr). 여기에 시간당 사용료를 곱하면 기계경비가 된다.""" return Decimal(1) / hourly_output(factors) def resolve_machine(cell: str) -> tuple[str, str] | None: """표의 기계 이름 셀을 기종 카탈로그 한 줄로 푼다. 「유압식백호우 (무한궤도,0.7㎥)」 → `0201-0070` 굴착기(무한궤도) 0.7. **이름과 규격이 둘 다 맞을 때만** 고른다 — 규격이 안 맞으면 안 고른다. """ text = str(cell).strip() if not text: return None inside = " ".join(_RE_PARENS.findall(text)) head = _RE_PARENS.sub("", text).strip() name = MACHINE_NAME_ALIASES.get(head.replace(" ", ""), head) capacity = parse_measure(_capacity_token(inside)) track = next((word for word in _TRACK_WORDS if word in inside), "") if capacity is None: return None catalog = load_machine_catalog() for code, machine in catalog.machines.items(): if name not in machine.name: continue if track and track not in machine.name: continue spec = parse_measure(machine.specification) if spec is not None and spec == capacity: return code, f"{machine.name} {machine.specification}" return None def _capacity_token(inside: str) -> str: """괄호 안에서 용량 토막만 뽑는다 — 「무한궤도,0.7㎥」 → 「0.7㎥」.""" for token in re.split(r"[,,]", inside): if any(unit in token for unit in ("㎥", "m3", "M3", "루베")): return token return "" def extract_cycle_factors( work_item_code: str, table: dict[str, Any], ) -> CycleFactors | FactorGap | None: """표 하나에서 계수를 뽑는다. 공식 계수가 하나도 없으면 `None`(이 표는 공식형이 아니다), 일부만 있으면 `FactorGap`, 다 있으면 `CycleFactors`. """ rows = table.get("raw_row") or [] values: dict[str, Decimal] = {} machine: tuple[str, str] | None = None bucket_from_machine_row: Decimal | None = None ratios: dict[str, Decimal] = {} saw_key = False for row in rows: cells = [str(c).strip() for c in row] if not cells: continue head = cells[0].lower().replace(" ", "") rest = cells[1:] # 「장비(90%) | 유압식백호우 (무한궤도,0.7㎥) | k | 0.9」 모양 for index, cell in enumerate(cells): found = resolve_machine(cell) if found is not None and machine is None: machine = found bucket_from_machine_row = parse_measure( _capacity_token(" ".join(_RE_PARENS.findall(cell))) ) # 같은 줄 뒤쪽에 「k | 0.9」가 붙어 오는 표가 있다 tail = cells[index + 1 :] for position, token in enumerate(tail): if token.lower() in _KEY_BUCKET and position + 1 < len(tail): parsed = parse_measure(tail[position + 1]) if parsed is not None: values["K"] = parsed break ratio = _ratio_of(cells[0]) if ratio is not None: label = "labor" if "인력" in cells[0] else "machine" if "장비" in cells[0] else "" if label: ratios[label] = ratio if head in _KEY_BUCKET: saw_key = True values.setdefault("K", _first_measure(rest)) elif head in _KEY_VOLUME: saw_key = True values.setdefault("f", _first_measure(rest)) elif head in _KEY_EFFICIENCY: saw_key = True values.setdefault("E", _first_measure(rest)) elif head in _KEY_CYCLE: saw_key = True values.setdefault("Cm", _first_measure(rest)) if not saw_key and machine is None: return None missing = [key for key in ("K", "f", "E", "Cm") if values.get(key) is None] capacity = bucket_from_machine_row if machine is None: missing.append("기계") if capacity is None: missing.append("q(버킷 용량)") if missing: return FactorGap( work_item_code=work_item_code, pum_table_id=str(table.get("pum_table_id", "")), missing=tuple(missing), note="표가 확정값 대신 범위·참조만 주었거나 기종을 못 골랐습니다.", ) return CycleFactors( work_item_code=work_item_code, pum_table_id=str(table.get("pum_table_id", "")), machine_code=machine[0], machine_name=machine[1], bucket_capacity_m3=capacity, bucket_coefficient=values["K"], volume_factor=values["f"], efficiency=values["E"], cycle_seconds=values["Cm"], labor_ratio_pct=ratios.get("labor"), machine_ratio_pct=ratios.get("machine"), ) def _first_measure(cells: list[str]) -> Decimal | None: """그 행에서 **처음 읽히는 확정값**. 뒤 칸은 유도식·참조라 앞 칸이 우선이다.""" for cell in cells: parsed = parse_measure(cell) if parsed is not None: return parsed return None _RE_RATIO = re.compile(r"[((]\s*(\d+(?:\.\d+)?)\s*%\s*[))]") def _ratio_of(cell: str) -> Decimal | None: found = _RE_RATIO.search(str(cell)) return Decimal(found.group(1)) if found else None # ── 불도저 — 굴착기와 **공식이 다르다** (건설품셈 8-2-1) ────────────────────── # # Q = 60 · q · f · E ÷ cm q = q₀ × e # cm = L/V1 + L/V2 + t t = 기어 변속시간 0.25 분 # # q₀ 거리를 고려하지 않은 삽날 용량(㎥) · e 운반거리계수 · L 운반거리(m) # V1 전진속도(m/분) · V2 후진속도(m/분) # # ⚠ **굴착기 식(3600 ÷ Cm 초)과 섞지 말 것** — 불도저 cm 은 **분**이고 왕복 주행으로 # 만든다. 그래서 밑수가 60 이다. 섞으면 60배 어긋난다. # # ⚠ **속도는 「단」에 따라 다르다** (8-2-1 [주]) — 굴착·운반은 전진 1단·후진 1단, # 흐트러진 토사운반은 2단, 평탄 정지는 3단. **임도 운반은 [주]② 「흐트러진 상태의 # 토사운반」이 가장 가까워 2단을 잠정 채택**한다. ⚠ TODO(미결) 사용자 확정 대기. _DOZER_SPEEDS_2ND_GEAR = { Decimal("4"): (Decimal(57), Decimal(85)), Decimal("7"): (Decimal(67), Decimal(78)), Decimal("10"): (Decimal(64), Decimal(75)), Decimal("12"): (Decimal(55), Decimal(70)), Decimal("13"): (Decimal(55), Decimal(70)), Decimal("19"): (Decimal(55), Decimal(70)), Decimal("32"): (Decimal(52), Decimal(58)), } #: 기어 변속시간 (분) — 8-2-1 「t: 기어 변속시간(0.25분)」 _DOZER_GEAR_SHIFT_MIN = Decimal("0.25") _MINUTES_PER_HOUR = Decimal(60) @dataclass(frozen=True) class DozerFactors: """불도저 시공능력 계수 한 벌 (건설품셈 8-2-1).""" work_item_code: str blade_capacity_m3: Decimal # q₀ distance_factor: Decimal # e volume_factor: Decimal # f efficiency: Decimal # E haul_distance_m: Decimal # L forward_speed_m_min: Decimal # V1 reverse_speed_m_min: Decimal # V2 @property def cycle_minutes(self) -> Decimal: """cm = L/V1 + L/V2 + t — **분**이다.""" return ( self.haul_distance_m / self.forward_speed_m_min + self.haul_distance_m / self.reverse_speed_m_min + _DOZER_GEAR_SHIFT_MIN ) @property 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}" ) def dozer_hourly_output(factors: DozerFactors) -> Decimal: """불도저 시간당 작업량 `Q` (㎥/hr). **밑수는 60(분)** 이다.""" if factors.cycle_minutes <= 0: raise ProductivityError(f"{factors.work_item_code}: 싸이클 시간이 0 이하입니다.") blade = factors.blade_capacity_m3 * factors.distance_factor output = ( _MINUTES_PER_HOUR / factors.cycle_minutes * blade * factors.volume_factor * factors.efficiency ) if output <= 0: raise ProductivityError(f"{factors.work_item_code}: 시간당 작업량이 {output} 입니다.") return output def dozer_speeds(tonnage: Decimal) -> tuple[Decimal, Decimal] | None: """그 규격의 전진·후진 속도(2단). 표에 없는 규격이면 `None` — 지어내지 않는다.""" return _DOZER_SPEEDS_2ND_GEAR.get(tonnage)