Files
Aislo/B09_Estimation/B09_Estimation_ParentSteps.py
T
eomsangdonandClaude Opus 5 efaf2403a0 feat(b09): 301 유로폼 사용수량 12-38-2 — 임대료 또는 손료를 「자재 단가」 탭에서 설계자가 넣은 쪽으로 세움
· 손료 = 자재가 × 표 수량 곧장(패널 0.089 · 내부 패널 0.003매/㎡) + 부자재 24/52/79% · 소모자재 5%(주자재비 = 패널 + 내부 패널) — 봉화 2024 호표 재료비 보통 4,500 · 간단 3,697 과 일치
· 임대료 = 「유로폼 임대료 ㎡당」 한 줄 · 부자재 % 는 원문이 안 적어 안 걺
· 둘 다 · 한쪽만 반쯤 · 아무것도 — 안 세우고 사유(부모 12-38 까지 그대로)
· 12-38-1 잔존율은 안 곱함 — 표 수량이 이미 12회·25% 몫으로 보임(우리 역산 · 차 2.5% 는 추정이라 비고에 밝힘)
· 이중계상 ③ 가드에서 12-38-02 만 뺌(패널이 자재총괄에 줄 없음) · 유로폼이 자재총괄로 가면 빨강 시험
· 합산형 부모 — 갈래 벌이 같으면 여러 단계 갈래끼리 합산(12-38 = 사용수량 + 설치·해체)
· 작업조 표 7 은 밑수 없음 목록에서 뺌(1단위당 = 인원 ÷ 시공량) — 12-38-3 설치·해체가 막혀 있던 자리
· 마스터 재생성 — 12-38 · 12-38-02 갈래 키만 바뀜

기본 조립 제목 금액 변화 0 · 프로젝트 내역 변화 0 · 잴 시험 일곱 빨강→초록 · 끄기 넷 빨강 · 전체 시험 1677 + 318 통과

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

245 lines
12 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 원가계산 — **단계 합산형 부모**를 잎들의 합으로 조립한다 (명세 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_MachineProductivity import fix2
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
from B09_Estimation.B09_Estimation_Chisel import (
CHISEL_AVERAGE_NOT_GIVEN,
CHISEL_MISSING,
attach_chisel,
)
variants = [(rock, q, f"작업능력 Q = {q} ㎥/hr ({rock})", chisel) for rock, q, chisel in rows]
if code in AVERAGE_BASIS:
# 평균 Q 도 **소수 2자리로 확정**한 뒤 나눔(명세 7장) — (5.0+3.4+2.6)/3 = 3.67.
# ⚠ 치즐 평균은 원문에 없어 안 붙임(None) — 지어내지 않고 사유로 남김.
average = fix2(sum((q for _, q, _ in rows), Decimal(0)) / Decimal(len(rows)))
variants.append(
(AVERAGE_VARIANT, average, f"Q = {average} ㎥/hr — {AVERAGE_BASIS[code]}", None)
)
attached = False
for variant, capacity, note, chisel 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:
# Q 로 선 장비 줄 — D(단가산출)에 달고 B 는 D 를 1 로 부름(층 차례 X → D → B).
build.book.add_output_detail(
title_code,
f"X-{machine_code}",
Decimal(1) / capacity,
f"{note} · {source}",
output=capacity,
)
# 치즐 — 암질마다 표의 본/hr ÷ Q(수동 단가가 들어와 치즐 제목이 섰을 때만).
attached = attach_chisel(build.book, title_code, chisel, capacity) or attached
build.variants.setdefault(code, []).append(variant)
# 표 줄은 다 읽었다 — 남는 것은 치즐뿐. 단가가 없으면 못 붙은 줄, 붙었으면 평균 갈래 사유만.
if rows[0][2]:
# 암질 표는 치즐이 열이라 「치즐소모」 줄로 안 잡힘 — 「자재 단가」 탭 쓰이는 곳에 올림.
from B09_Estimation.B09_Estimation_Chisel import CHISEL_CODE
uses = build.material_uses.setdefault(CHISEL_CODE, [])
if code not in uses:
uses.append(code)
if not rows[0][2]:
build.unattached[code] = []
elif not attached:
build.unattached[code] = [CHISEL_MISSING]
else:
build.unattached[code] = [CHISEL_AVERAGE_NOT_GIVEN] if code in AVERAGE_BASIS 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]) - {""}]
# 갈래가 같은 이름으로 여러 단계에 걸치면 갈래끼리 합산(유로폼 12-38 = 사용수량 + 설치·해체
# 둘 다 간단·보통·복잡 · 2026-09-14 301). 갈래 벌이 다르면 짝을 못 지어 종전대로 막음.
if len({frozenset(set(ts) - {""}) for _, _, ts in 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)