From 59e6624d79e3e407b869ea1a7e1f11a19e95ed47 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 06:57:11 +0900 Subject: [PATCH] =?UTF-8?q?fix(B09):=20=ED=92=88=EC=85=88=20=E3=80=8C?= =?UTF-8?q?=E2=80=A6=EC=99=80=20=EB=8F=99=EC=9D=BC=E3=80=8D=20=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=EB=A5=BC=20=EB=94=B0=EB=9D=BC=EA=B0=90=20+=20?= =?UTF-8?q?=EB=AC=B6=EC=9D=8C=20=EB=B0=B0=EB=B6=84=EC=9C=A8=EC=9D=B4=20?= =?UTF-8?q?=EC=B2=AB=20=EC=A4=84=EC=97=90=EB=A7=8C=20=EB=B6=99=EB=8D=98=20?= =?UTF-8?q?=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 구조물터파기 여덟 갈래가 「단가가 일부만 섰습니다 — 붙은 몫 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) --- .../B09_Estimation_MachineProductivity.py | 9 +- ...stimation_MachineProductivity_Reference.py | 195 ++++++++++++++++++ B09_Estimation/B09_Estimation_ResourceAxis.py | 9 + B09_Estimation/B09_Estimation_UnitPrice.py | 43 +++- 4 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_MachineProductivity_Reference.py diff --git a/B09_Estimation/B09_Estimation_MachineProductivity.py b/B09_Estimation/B09_Estimation_MachineProductivity.py index 7aa3bb02..a120d4d7 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity.py @@ -338,6 +338,7 @@ def attach_machine_share( title_code: str, choices: dict[tuple[str, str], Decimal] | None = None, machines: dict[str, str] | None = None, + sources: dict[str, str] | None = None, ) -> Decimal: """시공능력 공식(8-1-4)으로 **장비 몫**을 붙인다. 붙인 비율(%)을 돌려준다. @@ -382,7 +383,13 @@ def attach_machine_share( title_code, hourly_code, machine_hours_per_unit(factors) * share, - note=factors.formula_text, + # 계수를 남의 절에서 빌려 왔으면 **그 사실을 줄 비고에 적는다.** + note=factors.formula_text + + ( + f" · {(sources or {}).get(work_item_code, '')}" + if (sources or {}).get(work_item_code) + else "" + ), ) ) cycle_factors[work_item_code] = factors diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py b/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py new file mode 100644 index 00000000..95250c35 --- /dev/null +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py @@ -0,0 +1,195 @@ +"""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 diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index 5e0cf8f9..5a336691 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -411,6 +411,11 @@ def match_table( if match_packed_rows(node, table, catalog, result, basis_quantity, unit): return + # ⚠ **묶음 배분율은 다음 줄로 이어진다.** 품셈 표는 묶음 머리를 **병합해** 적는다 — + # 「인력(10%) | 할석공 2.0」 다음 줄이 「보통인부 1.0」이라 그 줄엔 딱지가 없다. + # 이어 주지 않으면 그 줄만 **100%로 서서** 조용히 열 배가 된다(2026-09-09 실측: + # 구조물터파기 보통인부가 0.1 대신 1.0 으로 서 단가가 224,305원/㎥ 이었다). + carried_ratio: Decimal | None = None for index, row in enumerate(table.get("raw_row", [])): cells = [str(c) for c in row] if not cells: @@ -435,6 +440,10 @@ def match_table( group_ratio = _group_ratio_of(name_cell) name_cell = cells[1] value_cells = cells[2:] + # 새 묶음 머리를 만났다 — 여기서부터 이 배분율이 이어진다(없으면 끊는다). + carried_ratio = group_ratio + else: + group_ratio = carried_ratio # 제잡비 비율 줄 — 자원이 아니라 **노무비에 붙는 경비율**이다(품셈 [주]③). if "제잡비" in _normalize(name_cell): diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 09e86a52..f8c64e04 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -98,6 +98,10 @@ class UnitPriceBuild: #: 반드시 말해야 한다.** 안 말하면 조용히 싼 단가가 내역서에 그대로 든다 #: (2026-09-09 실측: 일위대가가 선 141 공종 중 **70 공종**이 이 자리였다). unattached: dict[str, list[str]] = field(default_factory=dict) + #: 공종코드 → **계수를 어디서 가져왔는지** 한 줄. 「…와 동일」 참조를 따라간 자리다. + #: ⚠ 값이 남의 절에서 온 것이면 **화면이 그렇게 말해야** 한다 — 안 그러면 나중에 + #: 「이 숫자 어디서 왔지」로 되짚을 길이 없다. + factor_sources: dict[str, str] = field(default_factory=dict) #: 배분율 표인데 일부 몫만 붙은 공종 — 「단가가 일부만 섬」. 값은 붙은 몫(%). partial_ratio: dict[str, Decimal] = field(default_factory=dict) #: 시공능력 공식으로 장비 몫을 세운 공종 — 산출근거를 화면에 그대로 보인다. @@ -348,9 +352,16 @@ def build_unit_prices( scan_range_factors, ) + from B09_Estimation.B09_Estimation_MachineProductivity_Reference import ( + reference_factor_values, + ) + master = load_work_item_master() if factor_choices is None: factor_choices = chosen_values(scan_range_factors(master)) + # 「…와 동일」 참조로 이어 온 계수 — **사용자가 고른 값이 있으면 그것이 이긴다.** + borrowed, borrow_note, borrow_fail, borrow_text = reference_factor_values(master) + factor_choices = {**borrowed, **factor_choices} if machine_picks is None: machine_picks = machine_choices() if axis is None: @@ -359,14 +370,43 @@ def build_unit_prices( names = {w["work_item_code"]: w.get("name", "") for w in master.get("work_items", [])} build = UnitPriceBuild() + build.factor_sources = dict(borrow_note) + for failed_code, why in borrow_fail.items(): + build.factor_sources.setdefault(failed_code, f"⚠ {why}") build.component_gaps = dict(axis.partial_items) + # ⚠ **참조로 이미 푼 줄은 「못 붙은 줄」이 아니다.** 안 걷어 내면 다 풀린 공종이 + # 계속 반쪽으로 보이고, 그 표시를 믿고 막아 둔 금액이 영영 안 선다. + resolved_rows = {code: text for code, text in borrow_text.items() if code in borrow_note} for unmatched_row in axis.unmatched: # ⚠ 지역 이름을 조심할 것 — 바로 위 `names` 는 **공종 이름표**다. 같은 이름을 쓰면 # 그 표가 리스트로 덮여 조립이 통째로 터진다(2026-09-09 실측). labels = build.unattached.setdefault(unmatched_row.work_item_code, []) label = " ".join(str(unmatched_row.cell).split()) + reference_text = resolved_rows.get(unmatched_row.work_item_code) + if reference_text and reference_text in label: + continue # 그 줄은 참조를 따라가 값을 얻었다 if label and label not in labels: labels.append(label) + + # ⚠ **거두는 자리는 「못 붙은 줄」을 다 모은 뒤다.** 앞에서 거두면 목록이 비어 있어 + # **전부 거둬지고**, 깨기(대형브레이커)가 빠진 암 계열까지 「다 찼다」로 선다 + # (2026-09-09 실측). 남은 줄이 하나도 없을 때만 거둔다. + solved_codes = { + code + for code in borrow_note + if code in axis.partial_items and not build.unattached.get(code) + } + for code in solved_codes: + build.component_gaps.pop(code, None) + + # 참조는 풀렸는데 **다른 줄이 남은** 공종은 막힌 채로 두되 **사유를 고쳐 적는다** — + # 「백호우 줄을 못 읽었다」는 이미 푼 이야기라 그대로 두면 사람을 엉뚱한 데로 보낸다. + for code in borrow_note: + remaining = build.unattached.get(code) + if remaining and code in build.component_gaps: + build.component_gaps[code] = ( + f"{', '.join(remaining[:3])} 줄이 아직 안 붙었습니다 (계수 참조는 풀렸습니다)" + ) missing_basis = load_basis_missing() wages = load_operator_wages() _add_labor_titles(build.book, wages) @@ -490,6 +530,7 @@ def build_unit_prices( title_code, factor_choices, machine_picks, + build.factor_sources, ) # ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.** @@ -497,7 +538,7 @@ def build_unit_prices( # 조용히 서면 내역서가 틀린 줄 모른다(2026-09-08 실측: 측구터파기 39,575.6원/㎥ # 이 인력 10 % 몫만이었다). 0 으로 때우는 것과 같은 종류의 사고다. # 값을 못 읽은 자원 줄이 있으면 **일부만 선 단가**다 — 금액을 만들지 않는다. - if work_item_code in axis.partial_items: + if work_item_code in axis.partial_items and work_item_code not in solved_codes: build.partial_ratio.setdefault(work_item_code, _ZERO) # ⚠ 공식은 있는데 **아무것도 안 붙은** 제목은 남기지 않는다 — 「상세 줄이 없어