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>
This commit is contained in:
2026-09-08 11:47:51 +09:00
co-authored by Claude Opus 5
parent 3c5b561491
commit b97eb042b6
2 changed files with 325 additions and 2 deletions
+29 -2
View File
@@ -27,7 +27,11 @@ from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
from B09_Estimation.B09_Estimation_MachineProductivity import (
CycleFactors,
FactorGap,
attach_dozer_share,
dozer_variants,
formula_machine_codes,
extract_cycle_factors,
extract_dozer_factors,
machine_hours_per_unit,
)
from B09_Estimation.B09_Estimation_MachineOperating import (
@@ -325,6 +329,8 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
_add_labor_titles(build.book, wages)
machine_codes = {r.resource_code for r in axis.rows if r.resource_kind == "machine"}
# 공식표에만 나오는 기종도 사용료 층을 세운다 — 안 세우면 공식이 붙을 데가 없다.
machine_codes |= formula_machine_codes(master)
build.incomplete_machines = _add_machine_layers(build.book, machine_codes)
# 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은
@@ -341,6 +347,13 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
code = node.get("work_item_code")
if not code or (code, "") in by_item:
continue
# 불도저 운반(8-2-1)은 **갈래(토사·파쇄암·발파암)마다 값이 다르다** — 한 벌로
# 뭉치면 어느 것도 안 맞는다. 갈래가 있으면 갈래를 세우고 여기서 끝낸다.
labels = dozer_variants(node)
if labels:
for label in labels:
by_item.setdefault((code, label), [])
continue
for table in node.get("tables", []):
if isinstance(extract_cycle_factors(code, table), CycleFactors):
by_item[(code, "")] = []
@@ -410,9 +423,13 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
)
# 장비 몫은 자원 수량이 아니라 **시공능력 공식**으로 온다 (품셈 8-1-4).
machine_share = (
_ZERO if variant else _attach_machine_share(build, master, work_item_code, title_code)
# ⚠ 불도저와 굴착기는 **식이 다르다**(8-2-1 vs 8-1-4). 둘 다 붙이면 장비를 두 번
# 세므로, 불도저가 붙은 자리는 굴착기 쪽을 아예 안 본다.
machine_share = attach_dozer_share(
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)
# ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.**
# 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이
@@ -422,6 +439,15 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
if work_item_code in axis.partial_items:
build.partial_ratio.setdefault(work_item_code, _ZERO)
# ⚠ 공식은 있는데 **아무것도 안 붙은** 제목은 남기지 않는다 — 「상세 줄이 없어
# 조립 불가」로 화면에서 터진다. 기계 층이 못 선 경우가 그 자리다.
if not attachable and not build.book.details.get(title_code):
build.book.titles.pop(title_code, None)
if variant in build.variants.get(work_item_code, []):
build.variants[work_item_code].remove(variant)
build.skipped.append(work_item_code)
continue
covered = _covered_ratio_pct(rows, {ref for _, ref in attachable}, build)
if covered is not None:
covered += machine_share
@@ -440,6 +466,7 @@ def _has_full_formula(master: dict, work_item_code: str) -> bool:
return False
return any(
isinstance(extract_cycle_factors(work_item_code, table), CycleFactors)
or isinstance(extract_dozer_factors(work_item_code, table), dict)
for table in node.get("tables", [])
)