Files
Aislo/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py
T
eomsangdonandClaude Opus 5 59e6624d79 fix(B09): 품셈 「…와 동일」 참조를 따라감 + 묶음 배분율이 첫 줄에만 붙던 것
구조물터파기 여덟 갈래가 「단가가 일부만 섰습니다 — 붙은 몫 10%」로 서 있던 자리.
값이 없어서가 아니라 품셈이 「육상토사(0~1m)와 동일」처럼 **다른 절을 가리키는데**
그 참조를 안 따라가서였음.

- `B09_Estimation_MachineProductivity_Reference` 신설 — 참조를 따라가 계수를 이음.
  값을 옮겨 적지 않고 가리키는 절을 그때그때 읽음. 사슬·순환도 처리(순환은 멈춤)
- 열 갈래가 풀림: 09-13-02·03·05·06·08·09·11·12·17·18
- 빌려 온 계수는 **출처를 줄 비고에 적음** — 「계수 출처: 「…와 동일」 → 육상토사(0~1m)」

⚠ 짓다 잡은 것 셋
- 「육상과동일」은 「육상」이라는 절이 아니라 **제 이름에서 용수를 육상으로 바꾸라**는 뜻.
  이름 규칙을 먼저 태우면 없는 절을 찾다가 네 갈래를 놓쳤음
- 한 표에 참조가 둘이고 **가리키는 곳이 다름**. 9-13-11 은 치즐 줄이 「육상과동일」,
  백호우 줄이 「용수 암절취(0~1m)와 동일」임. 아무 줄에서나 주우면 용수 자리에 육상
  계수가 붙어 작업효율이 0.375 대신 0.50 으로 서고 **금액이 조용히 틀림**.
  ⇒ **기계 줄에 붙은 참조만** 보게 함
-  **묶음 배분율이 첫 줄에만 붙고 있었음** — 품셈이 「인력(10%)」 머리를 병합해 적어
  다음 줄엔 딱지가 없음. 그래서 「보통인부 1.0」이 100%로 서 **조용히 열 배**가 됐음
  (구조물터파기 단가가 224,305원/㎥ 이었음). 묶음 머리를 만날 때까지 이어 주게 고침

막힌 채로 두는 것 — 암·발파암 갈래는 **깨기(대형브레이커)·치즐**이 그대로 빠져 있어
금액을 세우지 않음. 다만 **사유를 남은 줄로 고쳐 적음**(백호우 이야기는 이미 풀렸음).
9-13-14 는 자기 자신을 가리키는 원문 오기라 **고쳐 읽지 않고** 사유로 남김.

검증: pytest 288 통과(신규 6) · 토사 갈래 단가가 처음 섬(1~2m 7,218.5원/㎥)

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

196 lines
9.2 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 원가계산 — **「다른 절과 동일」 참조**를 따라가 계수를 잇는다 (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