- 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>
678 lines
26 KiB
Python
678 lines
26 KiB
Python
"""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
|
||
#: 표가 기종 이름을 안 적어 `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
|