Files
Aislo/B09_Estimation/B09_Estimation_MachineProductivity.py
T
eomsangdonandClaude Opus 5 b97eb042b6 feat(B09): 불도저 표(8-2-1) 읽어 갈래별 단가 세움
- FP-10-11 불도저 운반이 굴착기 식(8-1-4)으로 읽혀 「K·Cm 없음」으로 잘못
  진단되던 것을 8-2-1 식으로 읽게 함. 갈래 셋(토사·파쇄암·발파암) 각각 섬
  (1,439.7 / 3,289.3 / 3,959.4 원/㎥).
- 표가 기종 이름을 안 적어 삽날 용량 q゚ 3.2㎥ + 속도 55/70(2단)으로
  불도저(무한궤도) 19톤을 되짚음. 하나로 안 좁혀지면 안 고름.
- 공식표에만 나오는 기종을 시간당 사용료 층에 넣음 — 안 넣으면 공식이 다 서고도
  붙을 사용료가 없어 빈 일위대가가 남았음.
- 아무것도 안 붙은 제목은 걷어 냄 (화면에서 「조립 불가」로 터지던 자리).
- 시험 6건 추가, 224건 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 11:47:51 +09:00

678 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.550.45」·「0.20.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
#: 표가 기종 이름을 안 적어 `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}) × {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)
#: 삽날 용량 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"),
}
#: 타이어형 2단 속도 — 8-2-1 2.나.
_DOZER_TIRE_SPEEDS_2ND_GEAR = {
Decimal("15"): (Decimal(200), Decimal(125)),
Decimal("28"): (Decimal(200), Decimal(200)),
Decimal("33"): (Decimal(210), Decimal(250)),
}
#: 습지·초습지 갈래는 카탈로그 이름이 따로다 — 「습지 불도저」.
_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 dozer_tire_speeds(tonnage: Decimal) -> tuple[Decimal, Decimal] | None:
"""타이어형 전진·후진 속도(2단). 표에 없으면 `None`."""
return _DOZER_TIRE_SPEEDS_2ND_GEAR.get(tonnage)
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 톤
⚠ **2단만 되짚는다.** 1·3·4단 속도표는 여기 안 들고 있어, 다른 단이면 `None` 이다.
"""
if gear != 2:
return None
candidates = []
for (track, tonnage), blade in _DOZER_BLADE_M3.items():
if blade != blade_m3:
continue
speeds = dozer_speeds(tonnage) if track == "무한궤도" else dozer_tire_speeds(tonnage)
if speeds == (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 label and 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` 갈래에 맞는 작업효율을 못 골랐습니다.",
)
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`)과 **자리를 나눠 쓴다** — 두 식이 같은 일위대가에
붙으면 장비를 두 번 세는 것이 된다. 그래서 부르는 쪽이 **먼저 이쪽을 보고, 안 붙었을
때만** 굴착기 쪽으로 간다.
"""
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail
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
book.add_detail(
PriceDetail(
title_code,
hourly_code,
dozer_machine_hours_per_unit(factors),
note=factors.formula_text,
)
)
return Decimal(100)
return _ZERO