Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
394 lines
19 KiB
Python
394 lines
19 KiB
Python
"""B09 원가계산 — **「다른 절과 동일」 참조**를 따라가 계수를 잇는다 (2026-09-09).
|
||
|
||
품셈은 같은 계수를 되풀이 적지 않고 **다른 절을 가리킨다.**
|
||
|
||
9-13-1 육상토사(0~1m) 장비(90%) 유압식백호우 | k 0.9 | f 0.77 | E 0.60 | ㎝ 20(135°)
|
||
9-13-2 육상토사(1~2m) 장비(90%) 유압식백호우 | **「육상토사(0~1m)와 동일」**
|
||
9-13-10 용수 암절취(0~1m) 들어내기 … | k 0.55 「**육상과동일**」
|
||
|
||
그 자리를 안 따라가면 **장비 몫 90%가 통째로 안 붙고 인력 10%만 선다** — 2026-09-09
|
||
실측으로 구조물터파기 여덟 갈래가 전부 그 모양이었다(「단가가 일부만 섰습니다 — 붙은 몫 10%」).
|
||
|
||
⚠ **값을 옮겨 적지 않는다.** 가리키는 절의 계수를 **그때그때 읽어** 쓴다. 옮겨 적으면
|
||
품셈이 개정될 때 한쪽만 고쳐진다.
|
||
|
||
⚠ **어디서 온 값인지 남긴다.** 화면이 「9-13-1 과 동일(품셈 원문)」을 그대로 보여야
|
||
나중에 누가 봐도 근거를 되짚을 수 있다(오늘 규칙).
|
||
|
||
⚠ **못 따라가는 참조는 따라간 척하지 않는다.**
|
||
· **자기 자신을 가리키는 것** — 9-13-14 가 「육상 발파암(1~2m)와 동일」이라 적었는데
|
||
그 절이 곧 육상 발파암(1~2m)이다. ⭐ 2026-09-14 브레인 승인으로 **그 두 절만** 고쳐 읽음
|
||
(`READ_AS` — 고른 값·버린 값을 근거 줄에 나란히). 나머지 자기 참조는 여전히 빈 채로.
|
||
· **가리키는 절을 못 찾는 것 · 그 절도 계수가 없는 것.**
|
||
이 셋은 사유를 남기고 **빈 채로 둔다.**
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
#: 「…와 동일」 — 앞의 이름이 가리키는 절이다.
|
||
_NAMED = re.compile(r"^(?P<name>.+?)\s*(?:와|과)\s*동일$")
|
||
|
||
#: 「육상과동일」 — 이름이 아니라 **한 낱말만 바꾸라**는 지시다(용수 → 육상).
|
||
_SWAP_WORDS = (("용수", "육상"),)
|
||
|
||
#: 원문 참조가 풀 수 없게 적힌 자리를 **사람이 판정해** 고쳐 읽음 — 공종 → (읽을 이름, 사유).
|
||
#: ⚠ 넓히지 말 것. 판정마다 근거(같은 절 형제들의 표기)를 사유에 적고 브레인·사용자 승인을 받음.
|
||
READ_AS: dict[str, tuple[str, str]] = {
|
||
"FP-09-13-14": (
|
||
"육상 발파암(0~1m)",
|
||
"원문 표기 「육상 발파암(1~2m)와 동일」(고시 2025-82 5189줄)은 자기 참조라 풀 수 없어"
|
||
" 0~1m(9-13-13)로 읽음 — 같은 절 암절취(1~2m·2~3m)·용수 발파암이 모두 「(0~1m)와 동일」",
|
||
),
|
||
"FP-09-13-15": (
|
||
"육상 발파암(0~1m)",
|
||
"원문 표기 「육상 발파암(1~2m)와 동일」(고시 2025-82 5201줄)이 가리키는 9-13-14 가 자기"
|
||
" 참조라 끝이 없어 0~1m(9-13-13)로 읽음 — 같은 절 형제들이 모두 「(0~1m)와 동일」",
|
||
),
|
||
}
|
||
|
||
#: 이 이름들만 계수로 본다. 참조가 가리키는 것도 결국 이 넷이다.
|
||
_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
|
||
read_as = READ_AS.get(code)
|
||
if read_as:
|
||
target_name = read_as[0]
|
||
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)}"
|
||
if read_as:
|
||
step += f" ⚠ {read_as[1]}"
|
||
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(node: dict[str, Any]) -> tuple[str, str, str] | None:
|
||
"""그 표에 함께 나오는 기종 — (코드, 이름, 규격). 「유압식백호우 (…0.7㎥)」 → 0201-0070."""
|
||
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 found[0], machine.name, str(machine.specification)
|
||
return None
|
||
|
||
|
||
#: 제 엔진이 없는 **부착 장비** 분류번호 — 리퍼·대형 브레이커·진동콤팩터·부착용 집게.
|
||
#: `B09_Estimation_UnitPrice._ATTACHMENT_PREFIXES` 와 같은 번호(그쪽은 `X-` 층 코드로 잡음).
|
||
_ATTACHMENT_CODE_PREFIXES = ("0103-", "0230-", "0240-", "7206-")
|
||
|
||
|
||
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 = _paired_machine(node)
|
||
paired_spec = paired[2] if paired else ""
|
||
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],
|
||
}
|
||
)
|
||
# ⚠ 부착 장비(깨기 대형브레이커)는 **굴착기에 달려 돈다** — 몸체 시간도
|
||
# 같은 작업량으로 붙인다(2026-09-13 브레인 판정 · 영월 「브레이커조합」 ÷ Q).
|
||
# 안 붙이면 장비비가 몸체만큼 모자란다. 본체는 뒤에서 `#조합`(잡재료 16%)으로.
|
||
if machine[0].startswith(_ATTACHMENT_CODE_PREFIXES) and paired:
|
||
found.append(
|
||
{
|
||
**found[-1],
|
||
"cell": f"{cell} — 몸체 {paired[1]} {paired[2]}",
|
||
"machine_code": paired[0],
|
||
"machine_name": paired[1],
|
||
}
|
||
)
|
||
|
||
# 절 제목·[주]에만 있는 자리 — 표에서 이미 잡혔으면 **덧붙이지 않는다**(두 번 세지 않게).
|
||
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
|