refactor(B09): 불도저를 딴 파일로 분리, 장비 붙이는 자리를 한 켤레로 맞춤

- B09_Estimation_MachineProductivity_Dozer.py 신설 (8-2-1 전용).
  굴착기(8-1-4)와 한 파일에 두면 700줄을 넘고, 두 식이 섞여 읽힘.
- 굴착기 쪽 장비 붙이기를 attach_machine_share 로 올려 불도저 쪽
  attach_dozer_share 와 같은 모양으로 맞춤 — 자리를 나눠 쓰는 것이 눈에 보이게.
- 줄수: UnitPrice 652 · MachineProductivity 360 · Dozer 398.
- 224건 통과 (동작 변화 없음).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 11:54:12 +09:00
co-authored by Claude Opus 5
parent b97eb042b6
commit b3c6f29684
3 changed files with 436 additions and 398 deletions
@@ -301,377 +301,60 @@ def _ratio_of(cell: str) -> Decimal | None:
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(
def attach_machine_share(
book: Any,
factor_gaps: dict[str, FactorGap],
cycle_factors: dict[str, CycleFactors],
master: dict[str, Any],
work_item_code: str,
title_code: str,
variant: str,
) -> Decimal:
"""불도저 공식으로 **장비 몫**을 붙인다. 붙였으면 100(%), 아니면 0.
"""시공능력 공식(8-1-4)으로 **장비 몫**을 붙인다. 붙인 비율(%)을 돌려준다.
굴착기 쪽(`_attach_machine_share`)과 **자리를 나눠 쓴다** — 두 식이 같은 일위대가에
붙으면 장비를 두 번 세는 것이 된다. 그래서 부르는 쪽이 **먼저 이쪽을 보고, 안 붙었을
때만** 굴착기 쪽으로 간다.
불도저 쪽(`attach_dozer_share`)과 **자리를 나눠 쓴다** — 두 식이 일위대가에
붙으면 장비를 두 번 세는 것이 된다.
계수가 다 안 서면 **아무것도 안 붙이고 0 을 돌려준다** — 그러면 그 공종은
`partial_ratio` 에 남아 내역서에서 금액이 안 붙는다(지어낸 값이 서는 것보다 낫다).
"""
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)
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail
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:
factors = extract_cycle_factors(work_item_code, table)
if not isinstance(factors, CycleFactors):
if isinstance(factors, FactorGap):
factor_gaps[work_item_code] = factors
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,
pum_table_id=factors.pum_table_id,
missing=("시간당 사용료",),
note=f"{factors.machine_name} 의 시간당 사용료가 아직 안 섰습니다.",
)
continue
share = (
Decimal(1)
if factors.machine_ratio_pct is None
else Decimal(str(factors.machine_ratio_pct)) / Decimal(100)
)
book.add_detail(
PriceDetail(
title_code,
hourly_code,
dozer_machine_hours_per_unit(factors),
machine_hours_per_unit(factors) * share,
note=factors.formula_text,
)
)
return Decimal(100)
cycle_factors[work_item_code] = factors
return share * Decimal(100)
return _ZERO
@@ -0,0 +1,398 @@
"""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,
parse_measure,
)
_ZERO = Decimal(0)
_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
+13 -56
View File
@@ -27,12 +27,14 @@ from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
from B09_Estimation.B09_Estimation_MachineProductivity import (
CycleFactors,
FactorGap,
attach_machine_share,
extract_cycle_factors,
)
from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import (
attach_dozer_share,
dozer_variants,
formula_machine_codes,
extract_cycle_factors,
extract_dozer_factors,
machine_hours_per_unit,
formula_machine_codes,
)
from B09_Estimation.B09_Estimation_MachineOperating import (
load_fuel_price,
@@ -429,7 +431,14 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
build.book, build.factor_gaps, master, work_item_code, title_code, variant
)
if not machine_share and not variant:
machine_share = _attach_machine_share(build, master, work_item_code, title_code)
machine_share = attach_machine_share(
build.book,
build.factor_gaps,
build.cycle_factors,
master,
work_item_code,
title_code,
)
# ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.**
# 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이
@@ -477,58 +486,6 @@ def _share_of(row) -> Decimal:
return Decimal(1) if ratio is None else Decimal(str(ratio)) / Decimal(100)
def _attach_machine_share(
build: UnitPriceBuild,
master: dict,
work_item_code: str,
title_code: str,
) -> Decimal:
"""시공능력 공식으로 **장비 몫**을 붙인다. 붙인 비율(%)을 돌려준다.
계수가 다 안 서면 **아무것도 안 붙이고 0 을 돌려준다** — 그러면 그 공종은
`partial_ratio` 에 남아 내역서에서 금액이 안 붙는다(지어낸 값이 서는 것보다 낫다).
"""
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
for table in node.get("tables", []):
factors = extract_cycle_factors(work_item_code, table)
if not isinstance(factors, CycleFactors):
if isinstance(factors, FactorGap):
build.factor_gaps[work_item_code] = factors
continue
hourly_code = f"X-{factors.machine_code}"
if hourly_code not in build.book.titles:
# 기계 층이 안 섰다 — 지어내지 않고 못 붙인 채로 둔다.
build.factor_gaps[work_item_code] = FactorGap(
work_item_code=work_item_code,
pum_table_id=factors.pum_table_id,
missing=("시간당 사용료",),
note=f"{factors.machine_name} 의 시간당 사용료가 아직 안 섰습니다.",
)
continue
share = (
Decimal(1)
if factors.machine_ratio_pct is None
else Decimal(str(factors.machine_ratio_pct)) / Decimal(100)
)
build.book.add_detail(
PriceDetail(
title_code,
hourly_code,
machine_hours_per_unit(factors) * share,
note=factors.formula_text,
)
)
build.cycle_factors[work_item_code] = factors
return share * Decimal(100)
return _ZERO
def _covered_ratio_pct(
rows: list, attached_refs: set[str], build: UnitPriceBuild
) -> Decimal | None: