Files
Aislo/B09_Estimation/B09_Estimation_ParentSteps.py
eomsangdonandClaude Opus 5 a81bc844fb feat(axis): 부모 공종 모양 parent_mode — 단계 합산형 조립으로 흙깎기 암절취 금액이 섬
- 마스터 부모 94곳에 parent_mode — 기본 choose_one, 합산형은 사람이 적은 셋(9-4·9-5·12-38)만 단계·가중치
- 9-4 암절취 = 암파쇄(연암·보통암·경암, 브레이커+굴착기 조합 1/Q) + 집토(굴착기 0.7 공식) 합으로 조립
- 표에 없는 암질은 9-4-1 [주]① 평균으로만 섬 · 9-5 발파암은 발파 단계(착암기 층 없음) 사유로 막힘
- B08 인계가 흙깎기 암 갈래 이름을 갈래 값으로 넘김 · 갈래 고르기형 부모를 가리키는 줄은 오류로 막음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 19:09:39 +09:00

219 lines
10 KiB
Python
Raw Permalink 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 원가계산 — **단계 합산형 부모**를 잎들의 합으로 조립한다 (명세 2장 · 2026-09-13 축 C Ⓐ).
공종 마스터가 부모마다 `parent_mode` 를 단다(`B08_Quantity_Build_WorkItemMaster_Parents`).
choose_one 갈래 고르기형 — **여기서 안 세운다.** 내역 줄이 부모를 가리키면 오류로 드러냄
sum_steps 단계 합산형 — B-<부모>[#갈래] = Σ B-<잎>[#갈래] × 가중치
⭐ 흙깎기(리핑암) = 9-4 암절취 = 9-4-1 암파쇄 + 9-4-2 집토 가 첫 사례다 — 부모에 일위대가 제목이
아예 없어 금액 0 이던 자리(명세 2장 실측).
⚠ **암질 갈래** — 암파쇄(9-4-1)·깎기(9-5-2) 표는 연암·보통암·경암마다 작업능력 `Q`(㎥/hr)를 준다.
갈래마다 따로 세우고, 부모도 그 갈래로 선다. 내역 줄의 암질이 표에 없으면(풍화암·암 등)
**원문이 평균을 정한 잎만** 평균으로 선다(9-4-1 [주]①). 9-5-2 는 그 [주]가 없어 막는다.
⚠ 모르는 값을 지어내지 않는다 — 단계 하나라도 못 서면 부모를 안 세우고 **어느 단계가 왜**를 남긴다.
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle
ROCK_CLASSES = ("연암", "보통암", "경암")
#: 암질을 표에 없는 이름으로 준 줄이 쓸 갈래 — **원문이 평균을 정한 잎만**.
AVERAGE_VARIANT = "평균"
AVERAGE_BASIS: dict[str, str] = {
"FP-09-04-01": "품셈 9-4-1 [주]① 「Q(시간당 작업량 ㎥/hr) =(5.0+3.4+2.6)/3」 — 세 암질 평균",
}
#: 표에 기계 이름이 없는 잎 — [주]가 기종을 적은 자리(마스터가 [주]를 못 싣는다). 원문 그대로.
#: ⚠ 부모(9-5)에서 물려받을 기계는 없다 — 부모엔 표가 없고, 잎의 [주]가 스스로 적었다.
NOTE_MACHINES: dict[str, tuple[tuple[str, ...], str]] = {
"FP-09-05-02": (
("0201-0070", "0230-0007"),
"품셈 9-5-2 [주] 「장비는 대형브레이커와 무한궤도 굴착기(0.7㎥)를 적용한다」",
),
}
def _tight(text: Any) -> str:
return re.sub(r"\s", "", str(text or ""))
def rock_rows(node: dict[str, Any]) -> list[tuple[str, Decimal, Decimal | None]]:
"""(암질, 작업능력 Q, 치즐소모량) — 암질 이름 칸 **바로 뒤** 두 칸을 읽는다."""
from B09_Estimation.B09_Estimation_MachineProductivity import parse_measure
found: list[tuple[str, Decimal, Decimal | None]] = []
for table in node.get("tables", []):
for row in table.get("raw_row") or []:
cells = [str(cell) for cell in row]
index = next((i for i, c in enumerate(cells) if _tight(c) in ROCK_CLASSES), None)
if index is None:
continue
tail = [parse_measure(c) for c in cells[index + 1 : index + 3]] + [None, None]
if tail[0] is not None and tail[0] > 0:
found.append((_tight(cells[index]), tail[0], tail[1]))
return found
def leaf_machines(node: dict[str, Any]) -> tuple[list[tuple[str, str]], str]:
"""(기종 코드·이름 목록, 출처) — 표의 조합 칸(「대형브레이커+ 유압식백호우 …」)이 먼저."""
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine
from B09_Estimation.B09_Estimation_MachineProductivity_Reference import _machine_by_name
catalog = load_machine_catalog()
for table in node.get("tables", []):
for row in table.get("raw_row") or []:
for cell in row:
parts = [part.strip() for part in str(cell).split("+")]
if len(parts) < 2:
continue
bodies = [resolve_machine(part) for part in parts]
body = next((b for b in bodies if b is not None), None)
if body is None:
continue
spec = str(catalog.machines[body[0]].specification)
picked = [b or _machine_by_name(p, spec) for b, p in zip(bodies, parts)]
if all(picked):
return [
p for p in picked if p
], f"표의 조합 칸 「{' '.join(str(cell).split())}」"
code = str(node.get("work_item_code") or "")
if code in NOTE_MACHINES:
codes, source = NOTE_MACHINES[code]
return [(c, catalog.machines[c].name) for c in codes if c in catalog.machines], source
return [], ""
def _add_rock_leaf(build: Any, node: dict[str, Any]) -> None:
"""암질 갈래마다 잎 제목을 세운다 — 기계마다 1/Q 시간."""
code = str(node["work_item_code"])
rows = rock_rows(node)
if not rows or any(title.startswith(f"B-{code}#") for title in build.book.titles):
return
machines, source = leaf_machines(node)
missing = [f"X-{m}" for m, _ in machines if f"X-{m}" not in build.book.titles]
if not machines or missing:
build.component_gaps[code] = (
f"작업능력 표의 기종을 못 골랐습니다 — {source or '표·[주]에 기종 없음'}"
+ (f" (기계 단가 층 없음: {', '.join(missing)})" if missing else "")
)
return
variants = [(rock, q, f"작업능력 Q = {q} ㎥/hr ({rock})") for rock, q, _ in rows]
if code in AVERAGE_BASIS:
average = sum((q for _, q, _ in rows), Decimal(0)) / Decimal(len(rows))
variants.append(
(AVERAGE_VARIANT, average, f"Q = {average:.4f} ㎥/hr — {AVERAGE_BASIS[code]}")
)
for variant, capacity, note in variants:
title_code = f"B-{code}#{variant}"
build.book.add_title(
PriceTitle(
code=title_code,
kind=PriceKind.UNIT_PRICE,
name=f"{node.get('name', code)} ({variant})",
spec=variant,
unit="㎥",
)
)
for machine_code, _name in machines:
build.book.add_detail(
PriceDetail(
title_code,
f"X-{machine_code}",
Decimal(1) / capacity,
note=f"{note} · {source}",
)
)
build.variants.setdefault(code, []).append(variant)
# 표 줄은 다 읽었다 — 남는 것은 치즐뿐(자재 카탈로그가 없어 금액에 안 붙는 알려진 미결).
build.unattached[code] = ["치즐소모량(본/hr) — 자재 단가 층 없음"] if rows[0][2] else []
def _step_titles(build: Any, step: str) -> dict[str, str]:
"""갈래 → 그 단계의 제목 코드. 갈래 없는 제목은 빈 문자열 키."""
titles = {"": f"B-{step}"} if f"B-{step}" in build.book.titles else {}
for code in build.book.titles:
if code.startswith(f"B-{step}#"):
titles[code.split("#", 1)[1]] = code
return titles
def _assemble(build: Any, node: dict[str, Any], names: dict[str, str]) -> None:
parent = str(node["work_item_code"])
steps = [(str(s["code"]), Decimal(str(s["weight"]))) for s in node.get("steps") or []]
per_step: list[tuple[str, Decimal, dict[str, str]]] = []
for step, weight in steps:
label = f"단계 {step} {names.get(step, '')}".strip()
titles = _step_titles(build, step)
if not titles:
why = build.component_gaps.get(step) or "일위대가가 없습니다"
build.component_gaps[parent] = f"{label} 이 안 섰습니다 — {why}"
return
if step in build.partial_ratio or step in build.basis_missing:
why = build.component_gaps.get(step) or "일부 몫만 섰습니다"
build.component_gaps[parent] = f"{label} 이 일부만 섰습니다 — {why}"
return
per_step.append((step, weight, titles))
varying = [item for item in per_step if set(item[2]) - {""}]
if len(varying) > 1:
build.component_gaps[parent] = "갈래가 두 단계 이상에 걸쳐 조립하지 않았습니다"
return
units = {build.book.titles[t].unit for _, _, ts in per_step for t in ts.values()} - {""}
if len(units) > 1:
build.component_gaps[parent] = f"단계 단위가 서로 다릅니다 — {', '.join(sorted(units))}"
return
variants = [v for v in varying[0][2] if v] if varying else [""]
for variant in variants:
title_code = f"B-{parent}" + (f"#{variant}" if variant else "")
build.book.add_title(
PriceTitle(
code=title_code,
kind=PriceKind.UNIT_PRICE,
name=f"{names.get(parent, parent)} ({variant})"
if variant
else names.get(parent, parent),
spec=variant or parent,
unit=next(iter(units), ""),
)
)
for step, weight, titles in per_step:
ref = titles.get(variant) or titles[""]
build.book.add_detail(
PriceDetail(
title_code,
ref,
weight,
note=f"단계 합산 × {weight}{node.get('steps_basis', '')}",
)
)
if variant:
build.variants.setdefault(parent, []).append(variant)
if varying and AVERAGE_VARIANT in varying[0][2] and varying[0][0] in AVERAGE_BASIS:
build.default_variants[parent] = (AVERAGE_VARIANT, AVERAGE_BASIS[varying[0][0]])
build.component_gaps.pop(parent, None)
def attach_parent_steps(build: Any, master: dict[str, Any]) -> None:
"""부모 모양을 싣고, 합산형 부모를 조립한다 — `build_unit_prices` 끝(조합 16% 바꿔 달기 전)."""
nodes = master.get("work_items", [])
names = {str(n.get("work_item_code")): str(n.get("name", "")) for n in nodes}
by_code = {str(n.get("work_item_code")): n for n in nodes}
for node in nodes:
mode = node.get("parent_mode")
if mode:
build.parent_modes[str(node["work_item_code"])] = str(mode)
if mode != "sum_steps":
continue
for step in node.get("steps") or []:
leaf = by_code.get(str(step["code"]))
if leaf is not None:
_add_rock_leaf(build, leaf)
_assemble(build, node, names)