Files
Aislo/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py
eomsangdonandClaude Opus 5 4a6254ba98 fix(B09): 성토·성토면다짐에 단가가 서게 함 — 금액 없이 내역에 서 있던 두 줄
물량은 인계로 오는데 단가가 없어 성토 13,150.2㎥·성토면다짐 12,350.3㎡ 가 금액 없이
서 있었음. 원문에는 값이 다 있고 막힌 까닭만 서로 달랐음.

- 노체포설(9-16-1): 기종이 [주]에만 있어 못 골랐음 → 흙깎기와 같은 장비 칸을 둠
  (무한궤도 굴착기 0.7㎥, [주]① 그대로. 화면에서 바꿀 수 있음).
- 비탈면 다짐(9-17-1): 표가 한 장도 없고 값이 절 제목에 있음(A = 77.7 ㎡/시간)
  → 제목·[주]에만 있는 작업량을 싣는 자리를 두고 원문 문구를 줄에 남김.
- 부착 콤팩터(0240-0007)는 카탈로그에 이름이 비어 있어 손료가 안 붙고 있었음 —
  이름 대신 분류번호로도 가림(조합 사용 넷은 그대로, 목록을 넓히지 않음).

금액: 성토 59,477,858원 · 성토면다짐 17,827,251원 (합 77,305,109원)이 새로 섬.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 21:59:46 +09:00

355 lines
17 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 원가계산 — **「다른 절과 동일」 참조**를 따라가 계수를 잇는다 (2026-09-09).
품셈은 같은 계수를 되풀이 적지 않고 **다른 절을 가리킨다.**
9-13-1 육상토사(01m) 장비(90%) 유압식백호우 | k 0.9 | f 0.77 | E 0.60 | ㎝ 20(135°)
9-13-2 육상토사(12m) 장비(90%) 유압식백호우 | **「육상토사(0~1m)와 동일」**
9-13-10 용수 암절취(01m) 들어내기 … | k 0.55 「**육상과동일**」
그 자리를 안 따라가면 **장비 몫 90%가 통째로 안 붙고 인력 10%만 선다** — 2026-09-09
실측으로 구조물터파기 여덟 갈래가 전부 그 모양이었다(「단가가 일부만 섰습니다 — 붙은 몫 10%」).
⚠ **값을 옮겨 적지 않는다.** 가리키는 절의 계수를 **그때그때 읽어** 쓴다. 옮겨 적으면
품셈이 개정될 때 한쪽만 고쳐진다.
⚠ **어디서 온 값인지 남긴다.** 화면이 「9-13-1 과 동일(품셈 원문)」을 그대로 보여야
나중에 누가 봐도 근거를 되짚을 수 있다(오늘 규칙).
⚠ **못 따라가는 참조는 따라간 척하지 않는다.**
· **자기 자신을 가리키는 것** — 9-13-14 가 「육상 발파암(1~2m)와 동일」이라 적었는데
그 절이 곧 육상 발파암(1~2m)이다(원문 오기로 보이나 **고쳐 읽지 않는다**).
· **가리키는 절을 못 찾는 것 · 그 절도 계수가 없는 것.**
이 셋은 사유를 남기고 **빈 채로 둔다.**
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
#: 「…와 동일」 — 앞의 이름이 가리키는 절이다.
_NAMED = re.compile(r"^(?P<name>.+?)\s*(?:와|과)\s*동일$")
#: 「육상과동일」 — 이름이 아니라 **한 낱말만 바꾸라**는 지시다(용수 → 육상).
_SWAP_WORDS = (("용수", "육상"),)
#: 이 이름들만 계수로 본다. 참조가 가리키는 것도 결국 이 넷이다.
_FACTOR_HEADS = {
"k": "K",
"f": "f",
"e": "E",
"cm": "Cm",
"㎝": "Cm",
"cm(sec)": "Cm",
"㎝(sec)": "Cm",
}
def _clean(cell: Any) -> str:
return " ".join(str(cell or "").split())
def _normalize_name(text: str) -> str:
"""절 이름 비교용 — 공백과 물결표기 차이를 지운다(「0~1m」·「0-1m」)."""
return re.sub(r"[\s~〜–—-]", "", str(text))
def _row_has_machine(cells: list[str]) -> bool:
"""그 줄이 **기계 줄**인가 — 계수가 와야 할 자리인지 가른다."""
from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine
return any(resolve_machine(cell) is not None for cell in cells)
def _find_reference(node: dict[str, Any]) -> tuple[str, str] | None:
"""이 절이 가리키는 이름과 그 원문 문구. 참조가 없으면 `None`.
⚠ **기계 줄에 붙은 참조만 본다.** 한 표 안에 참조가 둘 이상 있고 **가리키는 곳이
서로 다르다** — 9-13-11 은 「치즐소모량 … 육상과동일」과 「들어내기 유압식백호우 …
용수 암절취(0~1m)와 동일」을 함께 적는다. 아무 줄에서나 주우면 **용수 자리에 육상
계수**가 붙어 작업효율이 0.375 대신 0.50 으로 서고 금액이 조용히 틀린다
(2026-09-09 실측으로 잡았다).
"""
own_name = str(node.get("name", ""))
for table in node.get("tables", []):
for row in table.get("raw_row") or []:
cells = [_clean(cell) for cell in row]
if not _row_has_machine(cells):
continue
for cell in cells:
text = _clean(cell)
if not text or len(text) > 40:
continue
# ⚠ **낱말 바꾸기를 먼저 본다.** 「육상과동일」은 「육상」이라는 절을
# 가리키는 것이 아니라 **제 이름에서 용수를 육상으로 바꾸라**는 뜻이다.
# 이름 규칙(「…와 동일」)을 먼저 태우면 「육상」이라는 없는 절을 찾다가
# 놓친다(2026-09-09 실측: 네 갈래가 그렇게 빠졌다).
for source, target in _SWAP_WORDS:
# 문구에 적힌 낱말은 **가리키는 쪽**(육상)이고, 제 이름에 있는 낱말이
# **바꿀 쪽**(용수)이다. 둘을 뒤집어 보면 영영 못 찾는다.
if text in (f"{target}과동일", f"{target}과 동일") and source in own_name:
return own_name.replace(source, target), text
matched = _NAMED.match(text)
if matched:
return matched.group("name").strip(), text
return None
def _factor_values(node: dict[str, Any]) -> dict[str, Decimal]:
"""그 절이 **스스로 적어 둔** 계수들. 참조는 안 따라간다(한 걸음만 간다)."""
from B09_Estimation.B09_Estimation_MachineProductivity import parse_measure
values: dict[str, Decimal] = {}
for table in node.get("tables", []):
for row in table.get("raw_row") or []:
cells = [_clean(cell) for cell in row]
if not cells:
continue
for index, cell in enumerate(cells):
factor = _FACTOR_HEADS.get(cell.lower().replace(" ", ""))
if factor is None or factor in values:
continue
for candidate in cells[index + 1 :]:
parsed = parse_measure(candidate)
if parsed is not None:
values[factor] = parsed
break
return values
def reference_factor_values(
master: dict[str, Any],
) -> tuple[dict[tuple[str, str], Decimal], dict[str, str], dict[str, str], dict[str, str]]:
"""참조를 따라가 얻은 계수들.
돌려주는 것 넷 — (공종코드, 계수) → 값 · 공종코드 → 근거 한 줄 · 공종코드 → 못 따라간
사유 · 공종코드 → **원문 참조 문구 그대로**(그 줄을 「못 붙은 줄」 목록에서 빼는 데 쓴다).
"""
nodes = {str(n.get("work_item_code", "")): n for n in master.get("work_items", [])}
by_name: dict[str, list[str]] = {}
for code, node in nodes.items():
by_name.setdefault(_normalize_name(node.get("name", "")), []).append(code)
values: dict[tuple[str, str], Decimal] = {}
provenance: dict[str, str] = {}
failures: dict[str, str] = {}
raw_texts: dict[str, str] = {}
def resolve(code: str, seen: tuple[str, ...]) -> tuple[dict[str, Decimal], list[str], str]:
"""그 절의 계수를 푼다 — 스스로 적은 것 + 참조를 따라간 것.
⚠ **참조는 사슬로 이어진다** — 9-13-11(용수 암절취 1~2m)은 「육상과동일」로
9-13-8 을 가리키고, 그 절은 다시 「육상 암절취(0~1m)와 동일」로 9-13-7 을
가리킨다. 한 걸음만 가면 가운데서 멈춘다(2026-09-09 실측).
⚠ **돈 자리는 멈춘다** — 자기 자신이나 이미 지나온 절로 돌아가면 사슬이 도는
것이라 따라간 척하지 않는다.
"""
node = nodes.get(code)
if node is None:
return {}, [], f"공종 {code} 을 못 찾았습니다"
own = _factor_values(node)
if len(own) >= 4:
return own, [], ""
found = _find_reference(node)
if found is None:
return own, [], ""
target_name, raw_text = found
matches = [m for m in by_name.get(_normalize_name(target_name), []) if m != code]
if not matches:
return own, [], f"「{raw_text}」가 가리키는 절을 못 찾았습니다"
if len(matches) > 1:
return own, [], f"「{raw_text}」가 가리키는 절이 여럿입니다 — 하나로 못 좁혔습니다"
target = matches[0]
if target in seen:
return own, [], f"「{raw_text}」가 이미 지나온 절을 다시 가리킵니다 — 사슬이 돕니다"
borrowed, path, why = resolve(target, (*seen, code))
if why:
return own, [], f"「{raw_text}」를 따라갔으나 {why}"
merged = {**borrowed, **own}
missing = [key for key in ("K", "f", "E", "Cm") if key not in merged]
if missing:
return own, [], f"「{raw_text}」를 따라갔으나 계수가 없습니다 — {', '.join(missing)}"
step = f"「{raw_text}」 → {nodes[target].get('name', target)}"
return merged, [step, *path], ""
for code, node in nodes.items():
if _find_reference(node) is None:
continue
own = _factor_values(node)
if len(own) >= 4:
continue # 스스로 다 적어 둔 절 — 참조는 곁말이다
merged, path, why = resolve(code, ())
if why:
failures[code] = why
continue
for key, value in merged.items():
if key not in own:
values[(code, key)] = value
provenance[code] = "계수 출처: " + " · ".join(path) + " (품셈 원문 표기 그대로)"
own_ref = _find_reference(node)
if own_ref:
raw_texts[code] = own_ref[1]
return values, provenance, failures, raw_texts
# ---------------------------------------------------------------------------
# 작업량을 **직접 준** 기계 줄 (2026-09-09)
# ---------------------------------------------------------------------------
#
# 품셈은 기계 몫을 늘 공식으로만 주지 않는다. **시간당 작업량을 바로 적는** 줄이 있다.
#
# ['장비 (90%)', '깨기', '대형브레이커(㎥/hr)', '3.5', 'Q=(3.2+3.8)/2 (연암평균치 적용)']
#
# 이 줄을 못 읽으면 암·발파암 갈래의 **깨기 몫이 통째로 빠진다** — 들어내기(백호우)만
# 붙어 「일부만 선 단가」로 남는다.
#
# ⚠ **단위가 붙어 있을 때만 읽는다.** 「(㎥/hr)」·「(m/hr)」처럼 시간당 작업량임을
# 표가 스스로 밝힌 줄만 본다. 숫자만 있는 칸을 작업량으로 넘겨짚지 않는다.
_CAPACITY_UNIT = re.compile(r"[(]\s*(㎥|m3|㎡|m2|m|ton|t)\s*/\s*(?:hr|시간)\s*[)]")
def _paired_machine_spec(node: dict[str, Any]) -> str:
"""그 표에 함께 나오는 기종의 규격(「유압식백호우 (무한궤도,0.7㎥)」 → 0.7)."""
from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
catalog = load_machine_catalog()
for table in node.get("tables", []):
for row in table.get("raw_row") or []:
for cell in row:
found = resolve_machine(_clean(cell))
if found is not None:
machine = catalog.machines.get(found[0])
if machine is not None and machine.specification:
return str(machine.specification)
return ""
def _machine_by_name(text: str, preferred_spec: str) -> tuple[str, str] | None:
"""이름만으로 기종을 고른다 — 규격이 여럿이면 **짝의 규격**을 따른다.
⚠ 「대형브레이커(㎥/hr)」는 괄호가 **규격이 아니라 단위**라 보통 길로는 안 풀린다.
"""
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
wanted = re.sub(r"\s", "", text)
if not wanted:
return None
catalog = load_machine_catalog()
hits = [
(code, machine)
for code, machine in catalog.machines.items()
if wanted and wanted in re.sub(r"\s", "", machine.name)
]
if not hits:
return None
if preferred_spec:
narrowed = [item for item in hits if str(item[1].specification) == str(preferred_spec)]
if len(narrowed) == 1:
return narrowed[0][0], narrowed[0][1].name
return (hits[0][0], hits[0][1].name) if len(hits) == 1 else None
#: ⚠ **표가 아니라 절 제목·[주]에 작업량이 적힌 자리.**
#: 9-17-1 은 표가 **아예 없고** 제목 줄이 값을 든다 — 「9-17-1. 비탈면 다짐 : **A = 77.7 ㎡/시간**」.
#: 공종 마스터는 표만 절에 붙이므로 이런 자리는 **표가 0장인 공종**으로 남아 단가가 안 섰다
#: (2026-09-09 밤 실측: 성토면다짐 12,350.3㎡ 가 금액 없이 내역에 서 있었다).
#: ⚠ **값을 지어내지 않는다** — 아래 수·기종은 전부 원문 문구 그대로이고 그 문구를 함께 싣는다.
#: ⓘ 마스터가 제목의 작업량까지 싣게 되는 날 이 표는 지운다(그때는 표 쪽이 정본).
SOURCE_TEXT_CAPACITY: dict[str, tuple[dict[str, Any], ...]] = {
"FP-09-17-01": (
{
"machine_code": "0201-0070",
"machine_name": "굴착기(무한궤도)",
"capacity_per_hour": Decimal("77.7"),
"cell": "비탈면 다짐 A(㎡/hr)",
"source_text": (
"산림사업 표준품셈 9-17-1 「비탈면 다짐 : A = 77.7 ㎡/시간」 · "
"[주]② 「굴착기 부착용 유압식 진동 콤펙터와 굴착기(0.7㎥)를 적용한다」"
),
},
{
"machine_code": "0240-0007",
"machine_name": "유압식 진동콤팩터(굴착기 부착용, 0.7)",
"capacity_per_hour": Decimal("77.7"),
"cell": "비탈면 다짐 A(㎡/hr) — 부착 콤팩터",
"source_text": (
"산림사업 표준품셈 9-17-1 [주]② — 굴착기와 **함께** 도는 부착 장비라 "
"같은 작업량으로 붙는다(품셈 제8장 [주]⑤ 조합 사용)."
),
},
),
}
def direct_capacity_rows(node: dict[str, Any]) -> list[dict[str, Any]]:
"""그 공종에서 **시간당 작업량을 직접 준 기계 줄**들.
돌려주는 것 — 기계 이름 칸 · 기종 코드/이름 · 시간당 작업량 · 묶음 배분율(%) · 원문 문구.
⚠ 표에서 읽은 것에 더해 **절 제목·[주]에만 적힌 자리**(`SOURCE_TEXT_CAPACITY`)도 함께 낸다.
"""
from B09_Estimation.B09_Estimation_MachineProductivity import parse_measure
found: list[dict[str, Any]] = []
ratio: Decimal | None = None
# 같은 표에 짝이 되는 기종이 있으면 **그 규격**을 따른다 — 「대형브레이커」는 규격을
# 안 적고, 실무도 「대형브레이커 + B/H 0.7」처럼 붙는 굴착기 규격으로 잡는다.
paired_spec = _paired_machine_spec(node)
for table in node.get("tables", []):
for row in table.get("raw_row") or []:
cells = [_clean(cell) for cell in row]
if not cells:
continue
seen_ratio = re.search(r"[(]\s*(\d+(?:\.\d+)?)\s*%\s*[)]", cells[0])
if seen_ratio:
ratio = Decimal(seen_ratio.group(1))
for index, cell in enumerate(cells):
if not _CAPACITY_UNIT.search(cell):
continue
machine = _machine_by_name(_CAPACITY_UNIT.sub("", cell).strip(), paired_spec)
if machine is None:
continue
capacity = next(
(parse_measure(token) for token in cells[index + 1 :] if parse_measure(token)),
None,
)
if capacity is None or capacity <= 0:
continue
found.append(
{
"cell": cell,
"machine_code": machine[0],
"machine_name": machine[1],
"capacity_per_hour": capacity,
"ratio_pct": ratio,
"table_id": str(table.get("pum_table_id", "")),
# 그 줄의 칸들 — 「못 붙은 줄」 목록에서 이 줄을 걷어내는 데 쓴다.
"row_cells": [c for c in cells if c],
}
)
# 절 제목·[주]에만 있는 자리 — 표에서 이미 잡혔으면 **덧붙이지 않는다**(두 번 세지 않게).
already = {row["machine_code"] for row in found}
for entry in SOURCE_TEXT_CAPACITY.get(str(node.get("work_item_code") or ""), ()):
if entry["machine_code"] in already:
continue
found.append(
{
"cell": entry["cell"],
"machine_code": entry["machine_code"],
"machine_name": entry["machine_name"],
"capacity_per_hour": entry["capacity_per_hour"],
"ratio_pct": None,
"table_id": "",
"row_cells": [],
"source_text": entry["source_text"],
}
)
return found