"""B09 원가계산 — **불도저** 시공능력 (건설품셈 8-2-1). **굴착기(8-1-4)와 식이 다르다.** 한 파일에 두면 700 줄을 넘고, 무엇보다 두 식이 섞여 읽힌다 — 실제로 임도 불도저 표(FP-10-11)가 굴착기 식으로 읽혀 「K·Cm 없음」으로 잘못 진단되고 있었다(2026-09-08). Q = 60 ÷ cm · (q₀ × e) · f · E cm = L/V1 + L/V2 + t t = 기어 변속시간 0.25 분 q₀ 거리를 고려하지 않은 삽날 용량(㎥) · e 운반거리계수 · L 운반거리(m) V1 전진속도(m/분) · V2 후진속도(m/분) · f 체적환산계수 · E 작업효율 ⚠ **밑수가 60(분)이다.** 굴착기는 3600(초)이라 섞으면 60 배 어긋난다. ⚠ **표가 기종 이름을 안 적는다.** 임도 표는 `q₀`·`V1`·`V2` 만 주므로 8-2-1 표에서 규격을 **되짚는다** — 하나로 안 좁혀지면 안 고른다(지어내지 않는다). """ 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_MachineProductivity import ( CycleFactors, FactorGap, ProductivityError, _first_measure, extract_cycle_factors, fix2, parse_measure, ) _ZERO = Decimal(0) #: 전진·후진 속도 (m/분) — 8-2-1 2.가·나. **단(gear)마다 다르다.** #: {규격(ton): {단: (전진, 후진)}} #: ⚠ 무한궤도 4·13 톤과 타이어 전 규격은 **후진 3단이 표에 없다**(「-」) — 그 단은 #: 아예 안 담는다. 담으면 없는 속도로 싸이클이 서서 값이 조용히 틀린다. #: ⚠ **단은 작업이 정한다** (8-2-1 2.가 [주]) — 굴착·굴착운반 1단, 흐트러진 토사운반 #: 2단, 평탄 정지·전압 3단. 그래서 임도 표가 「55m/분(2단)」처럼 단을 적어 온다. _DOZER_SPEEDS = { "무한궤도": { Decimal("4"): {1: (Decimal(40), Decimal(63)), 2: (Decimal(57), Decimal(85))}, Decimal("7"): { 1: (Decimal(43), Decimal(53)), 2: (Decimal(67), Decimal(78)), 3: (Decimal(92), Decimal(107)), }, Decimal("10"): { 1: (Decimal(42), Decimal(50)), 2: (Decimal(64), Decimal(75)), 3: (Decimal(88), Decimal(105)), }, Decimal("12"): { 1: (Decimal(40), Decimal(48)), 2: (Decimal(55), Decimal(70)), 3: (Decimal(75), Decimal(100)), }, Decimal("13"): {1: (Decimal(40), Decimal(48)), 2: (Decimal(55), Decimal(70))}, Decimal("19"): { 1: (Decimal(40), Decimal(46)), 2: (Decimal(55), Decimal(70)), 3: (Decimal(75), Decimal(98)), }, Decimal("32"): { 1: (Decimal(40), Decimal(43)), 2: (Decimal(52), Decimal(58)), 3: (Decimal(70), Decimal(78)), }, }, "타이어": { Decimal("15"): {1: (Decimal(83), Decimal(92)), 2: (Decimal(200), Decimal(125))}, Decimal("28"): {1: (Decimal(92), Decimal(92)), 2: (Decimal(200), Decimal(200))}, Decimal("33"): {1: (Decimal(92), Decimal(110)), 2: (Decimal(210), Decimal(250))}, }, } #: 기어 변속시간 (분) — 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 #: 표가 기종 이름을 안 적어 `q゚`·`V1`·`V2` 로 되짚은 결과(`resolve_dozer`). machine_code: str = "" machine_name: str = "" @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}) × {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(분)** — 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 output = ( _MINUTES_PER_HOUR / factors.cycle_minutes * blade * fix2(factors.volume_factor) * factors.efficiency ) if output <= 0: raise ProductivityError(f"{factors.work_item_code}: 시간당 작업량이 {output} 입니다.") return fix2(output) def dozer_speeds( tonnage: Decimal, gear: int = 2, track: str = "무한궤도" ) -> tuple[Decimal, Decimal] | None: """그 규격·그 단의 전진·후진 속도. 표에 없으면 `None` — 지어내지 않는다.""" return _DOZER_SPEEDS.get(track, {}).get(tonnage, {}).get(gear) #: 삽날 용량 q゚(㎥) — 8-2-1 1.가. **규격을 되짚는 열쇠**로도 쓴다. #: ⚠ 무한궤도 10 톤과 13 톤이 둘 다 1.5 ㎥ 라 **용량만으로는 못 가른다** — 속도로 마저 가른다. _DOZER_BLADE_M3 = { ("무한궤도", Decimal("4")): Decimal("0.5"), # 초습지 ("무한궤도", Decimal("7")): Decimal("1.1"), ("무한궤도", Decimal("10")): Decimal("1.5"), ("무한궤도", Decimal("12")): Decimal("2.0"), ("무한궤도", Decimal("13")): Decimal("1.5"), # 습지 ("무한궤도", Decimal("19")): Decimal("3.2"), ("무한궤도", Decimal("32")): Decimal("5.5"), ("타이어", Decimal("15")): Decimal("3.1"), ("타이어", Decimal("28")): Decimal("4.0"), ("타이어", Decimal("33")): Decimal("5.7"), } #: 습지·초습지 갈래는 카탈로그 이름이 따로다 — 「습지 불도저」. _DOZER_WET_TONS = (Decimal("4"), Decimal("13")) #: 표의 머리말. ⚠ **`e` 와 `E` 는 대소문자만 다르고 뜻이 전혀 다르다** — #: `e` 는 운반거리계수, `E` 는 작업효율이다. 그래서 이 표는 **소문자로 내려 읽으면 안 된다** #: (굴착기 쪽 `extract_cycle_factors` 는 내려 읽는다 — 그쪽엔 `e` 가 없어 안전하다). _DOZER_SINGLE_KEYS = { "L": "L", "q0": "q0", "q゚": "q0", "q₀": "q0", "e": "e", "V1": "V1", "V2": "V2", "t": "t", } #: 갈래를 거느리는 머리말 — 「E | 토사 | 0.55」 아래에 「암석 | 0.25」가 딸려 온다. _DOZER_GROUP_KEYS = ("E", "f") _RE_GEAR = re.compile(r"(\d+)\s*단") def resolve_dozer( blade_m3: Decimal, forward: Decimal, reverse: Decimal, gear: int = 2, ) -> tuple[str, str] | None: """삽날 용량과 속도로 **불도저 기종을 되짚는다**. 임도 품셈 표는 기종 이름을 안 적고 `q゚`·`V1`·`V2` 만 준다. 그 셋이 8-2-1 표에서 한 규격만 가리킬 때 그 기종으로 본다 — **둘 이상이면 안 고른다**(지어내지 않는다). q゚ 3.2㎥ + 55/70 m/분(2단) → 불도저(무한궤도) 19 톤 ⚠ **단(gear)까지 맞아야 한다** — 같은 삽날이라도 단이 다르면 다른 규격을 가리킨다. """ candidates = [] for (track, tonnage), blade in _DOZER_BLADE_M3.items(): if blade != blade_m3: continue if dozer_speeds(tonnage, gear, track) == (forward, reverse): candidates.append((track, tonnage)) if len(candidates) != 1: return None track, tonnage = candidates[0] if track == "무한궤도" and tonnage in _DOZER_WET_TONS: wanted = "습지 불도저" else: wanted = f"불도저({track})" for code, machine in load_machine_catalog().machines.items(): if machine.name == wanted and parse_measure(machine.specification) == tonnage: return code, f"{machine.name} {machine.specification}" return None def dozer_machine_hours_per_unit(factors: DozerFactors) -> Decimal: """수량 1단위당 불도저 소요시간(hr).""" return Decimal(1) / dozer_hourly_output(factors) def extract_dozer_factors( work_item_code: str, table: dict[str, Any], ) -> dict[str, DozerFactors] | FactorGap | None: """불도저 표 하나에서 **갈래별** 계수를 뽑는다 (품셈 8-2-1). 돌려주는 것 — 불도저 표가 아니면 `None`, 계수가 모자라면 `FactorGap`, 다 서면 `{갈래: DozerFactors}` (토사·파쇄암·발파암처럼 `f` 갈래마다 한 벌). ⚠ **딸린 줄은 한 칸 왼쪽으로 밀려 온다** — 머리 줄은 「f | 토사 | 1/1.30」이고 다음 줄은 「파쇄암 | 1/1.35」다. 자리를 그대로 읽으면 갈래가 통째로 빠진다. """ rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])] if not rows: return None single: dict[str, Decimal] = {} groups: dict[str, dict[str, Decimal]] = {"E": {}, "f": {}} gears: dict[str, int] = {} current: str | None = None for cells in rows: if not cells or not cells[0]: continue head = cells[0] if head in _DOZER_SINGLE_KEYS: current = None key = _DOZER_SINGLE_KEYS[head] value = _first_measure(cells[1:]) if value is not None: single[key] = value found = _RE_GEAR.search(" ".join(cells[1:])) if found: gears[key] = int(found.group(1)) elif head in _DOZER_GROUP_KEYS: current = head label = cells[1] if len(cells) > 1 else "" value = _first_measure(cells[2:]) if value is None: # 갈래가 없는 표 — 「f | 1/1.3」처럼 값이 바로 붙는다. 갈래 이름은 빈 문자열. value = _measure_with_adjustment(label) label = "" if value is not None: groups[head][_normalize_label(label)] = value elif current is not None: # 딸린 줄 — 「파쇄암 | 1/1.35」. 값이 없으면 갈래 줄이 아니다. value = _first_measure(cells[1:]) if value is not None: groups[current][_normalize_label(head)] = value if not groups["f"] or "q0" not in single or "V1" not in single: return None # 불도저 표가 아니다 missing = [key for key in ("L", "q0", "e", "V1", "V2", "t") if key not in single] if not groups["E"]: missing.append("E(작업효율)") if missing: return FactorGap( work_item_code=work_item_code, pum_table_id=str(table.get("pum_table_id", "")), missing=tuple(missing), note="불도저 표(8-2-1)인데 계수가 모자랍니다.", ) gear = gears.get("V1", gears.get("V2", 2)) machine = resolve_dozer(single["q0"], single["V1"], single["V2"], gear) if machine is None: return FactorGap( work_item_code=work_item_code, pum_table_id=str(table.get("pum_table_id", "")), missing=("기계",), note=( f"삽날 {single['q0']}㎥ · {single['V1']}/{single['V2']}m/분({gear}단) " "으로는 규격이 하나로 안 좁혀집니다." ), ) built: dict[str, DozerFactors] = {} for label, volume_factor in groups["f"].items(): efficiency = _dozer_efficiency(groups["E"], label) if efficiency is None: continue # 그 갈래의 작업효율이 없다 — 가운데값을 지어내지 않는다 built[label] = DozerFactors( work_item_code=work_item_code, blade_capacity_m3=single["q0"], distance_factor=single["e"], volume_factor=volume_factor, efficiency=efficiency, haul_distance_m=single["L"], forward_speed_m_min=single["V1"], reverse_speed_m_min=single["V2"], machine_code=machine[0], machine_name=machine[1], ) return built or FactorGap( work_item_code=work_item_code, pum_table_id=str(table.get("pum_table_id", "")), missing=("E(갈래별 작업효율)",), note="`f` 갈래에 맞는 작업효율을 못 골랐습니다.", ) _RE_ADJUSTED = re.compile(r"^\(?\s*(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*\)?$") def _measure_with_adjustment(cell: str) -> Decimal | None: """계수 한 칸. **「(0.55-0.1)」 같은 보정식도 읽는다.** 8-2-1 [주]④⑤ 가 「정지작업을 겸하면 0.1 을, 터파기는 0.05 를 뺀 값」이라 적어, 임도 표가 뺄셈을 그대로 적어 온다(비다듬기·정지 9-17-2). ⚠ **범위(「0.55∼0.45」)와 헷갈리면 안 된다** — 범위는 확정값이 아니라 `parse_measure` 가 `None` 을 낸다. 여기서 읽는 것은 **하이픈 뺄셈 한 가지**뿐이다. """ found = _RE_ADJUSTED.match(" ".join(str(cell).split())) if found: return Decimal(found.group(1)) - Decimal(found.group(2)) return parse_measure(cell) def _normalize_label(text: str) -> str: return "".join(str(text).split()) def _dozer_efficiency(efficiencies: dict[str, Decimal], label: str) -> Decimal | None: """그 갈래의 작업효율 `E`. ⚠ 표가 `f` 는 「토사·파쇄암·발파암」으로 잘게 주고 `E` 는 「토사·암석」으로 굵게 준다. 그래서 **암 갈래는 「암석」 줄을 쓴다** — 그 표가 암을 한 값으로 묶어 준 것이다. """ if label in efficiencies: return efficiencies[label] if "암" in label: for key, value in efficiencies.items(): if "암" in key: return value return efficiencies.get("토사") if len(efficiencies) == 1 else None def dozer_variants(node: dict[str, Any]) -> list[str]: """그 공종이 불도저 공식으로 세울 수 있는 갈래 이름들. 아니면 빈 목록.""" labels: list[str] = [] for table in node.get("tables", []): found = extract_dozer_factors(str(node.get("work_item_code", "")), table) if isinstance(found, dict): labels.extend(label for label in found if label not in labels) return labels def formula_machine_codes(master: dict[str, Any]) -> set[str]: """**공식표에서만 드러나는 기종 코드.** ⚠ 자원 축에는 안 나온다 — 표가 기계를 「줄」로 안 적고 계수로만 적기 때문이다. 이 코드를 시간당 사용료 층에 안 넣으면, 공식은 다 서 놓고 **붙일 사용료가 없어** 빈 일위대가가 남는다(2026-09-08 불도저 운반에서 실제로 그랬다). """ codes: set[str] = set() for node in master.get("work_items", []): code = str(node.get("work_item_code", "")) for table in node.get("tables", []): factors = extract_cycle_factors(code, table) if isinstance(factors, CycleFactors): codes.add(factors.machine_code) found = extract_dozer_factors(code, table) if isinstance(found, dict): codes.update(f.machine_code for f in found.values() if f.machine_code) return codes def attach_dozer_share( book: Any, factor_gaps: dict[str, FactorGap], master: dict[str, Any], work_item_code: str, title_code: str, variant: str, ) -> Decimal: """불도저 공식으로 **장비 몫**을 붙인다. 붙였으면 100(%), 아니면 0. ⚠ 굴착기 쪽(`_attach_machine_share`)과 **자리를 나눠 쓴다** — 두 식이 같은 일위대가에 붙으면 장비를 두 번 세는 것이 된다. 그래서 부르는 쪽이 **먼저 이쪽을 보고, 안 붙었을 때만** 굴착기 쪽으로 간다. """ node = next( (w for w in master.get("work_items", []) if w.get("work_item_code") == work_item_code), None, ) if node is None: return _ZERO wanted = _normalize_label(variant) for table in node.get("tables", []): found = extract_dozer_factors(work_item_code, table) if isinstance(found, FactorGap): factor_gaps[work_item_code] = found continue if not isinstance(found, dict): continue factors = found.get(wanted) if factors is None: continue hourly_code = f"X-{factors.machine_code}" if hourly_code not in book.titles: factor_gaps[work_item_code] = FactorGap( work_item_code=work_item_code, pum_table_id=factors.work_item_code, missing=("시간당 사용료",), note=f"{factors.machine_name} 의 시간당 사용료가 아직 안 섰습니다.", ) continue # Q 로 선 장비 줄 — D(단가산출)에 달고 B 는 D 를 1 로 부름(층 차례 X → D → B). book.add_output_detail( title_code, hourly_code, dozer_machine_hours_per_unit(factors), factors.formula_text, output=dozer_hourly_output(factors), ) return Decimal(100) return _ZERO