- fix2(사사오입 0.01) — 굴착기·도자·다짐 작업량, 암 평균 Q, 표 직접 작업량 - TRUNCATED_KINDS 에 PRICE_BASIS(D) 더함 — B 는 다음 차례 - 골든셋 ③ 단가산출근거 실무 6건 208 호표 전수 일치(제외 소계·% 줄·산근 참조·계약단가 읽기) · 회귀 3,545.5 → 3,545 - 검증 프로젝트 내역 본체 122,924,846 → 122,870,975 · 전체 시험 1606 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
215 lines
10 KiB
Python
215 lines
10 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
|
||
variants = [(rock, q, f"작업능력 Q = {q} ㎥/hr ({rock})") for rock, q, _ in rows]
|
||
if code in AVERAGE_BASIS:
|
||
# 평균 Q 도 **소수 2자리로 확정**한 뒤 나눔(명세 7장) — (5.0+3.4+2.6)/3 = 3.67.
|
||
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]}"))
|
||
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:
|
||
# 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}"
|
||
)
|
||
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)
|