· 손료 = 자재가 × 표 수량 곧장(패널 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
245 lines
12 KiB
Python
245 lines
12 KiB
Python
"""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)
|