"""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)이다(원문 오기로 보이나 **고쳐 읽지 않는다**). · **가리키는 절을 못 찾는 것 · 그 절도 계수가 없는 것.** 이 셋은 사유를 남기고 **빈 채로 둔다.** """ from __future__ import annotations import re from decimal import Decimal from typing import Any #: 「…와 동일」 — 앞의 이름이 가리키는 절이다. _NAMED = re.compile(r"^(?P.+?)\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 def direct_capacity_rows(node: dict[str, Any]) -> list[dict[str, Any]]: """그 공종에서 **시간당 작업량을 직접 준 기계 줄**들. 돌려주는 것 — 기계 이름 칸 · 기종 코드/이름 · 시간당 작업량 · 묶음 배분율(%) · 원문 문구. """ 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], } ) return found