From 2f9d42ac350ef82e67b3767d14008a5a8f5444b9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 01:48:34 +0900 Subject: [PATCH] =?UTF-8?q?fix(b09):=20=E3=89=A0=20=EC=9E=90=EC=9B=90=20?= =?UTF-8?q?=EC=B6=95=20=EC=A4=84=20=EC=9D=BD=EA=B8=B0=EA=B0=80=20=EB=A7=90?= =?UTF-8?q?=EC=97=86=EC=9D=B4=20=EB=B2=84=EB=A6=AC=EB=8D=98=20=EA=B8=B0?= =?UTF-8?q?=EA=B3=84=C2=B7=EC=97=B0=EB=A3=8C=20=EC=A4=84=EC=9D=84=20?= =?UTF-8?q?=E3=80=8C=EB=AA=BB=20=EB=A7=9E=EC=B6=A4=20+=20=EC=9D=BC?= =?UTF-8?q?=EB=B6=80=EB=A7=8C=E3=80=8D=20=EC=9C=BC=EB=A1=9C=20=E2=80=94=20?= =?UTF-8?q?=EC=9D=B8=EB=A0=A5=EB=A7=8C=EC=9C=BC=EB=A1=9C=20=EC=8B=B8?= =?UTF-8?q?=EA=B2=8C=20=EC=84=9C=EB=8D=98=20=EA=B3=B5=EC=A2=85=20=EB=A7=89?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit · 버리던 세 자리: 규격이 이름 앞인 장비 칸(「0.8㎥ 굴착기 | h」 가 머리글로 걸러짐) · 값·이름 둘 다 못 읽은 줄(「디젤엔진(15HP) | 11.19kW」 · 「연료비 | ps × 0.253ℓ × 6h」) · 수량 빈 연료 줄(「연료비 | 경유 | L」) · 연료·휘발유·경유·엔진·기관·양수기·발전기 이름이거나 시간·대·kW·HP 단위인 줄만 — 머리 줄·설명 줄은 그대로 · 사유 「기계·연료 줄이 안 붙음」 · 파쇄 모듈은 제가 쓰는 파쇄기 줄의 사유를 걷음 · ⚠ 처음 승인받은 「_names_a_machine 을 모든 공종으로」 는 재어 보니 0 을 막아 정정 보고 뒤 이 자리로 새로 사유가 붙은 줄 8 · 사라진 줄 0 · 새로 막힌 공종 5(12-26 물푸기 · 14-1 · 14-2 근주이식 · 10-8-3 케이블크레인 운전 · 10-7-4 모노레일) · 제목 금액 변화 0 · 프로젝트 내역 그대로 · 끄기 둘 빨강 · 전체 시험 1712 + 374 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- B09_Estimation/B09_Estimation_ResourceAxis.py | 44 +++++++++++++++++++ B09_Estimation/B09_Estimation_WoodChipping.py | 10 ++++- .../tester/test_b09_silent_machine_rows.py | 42 ++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 resources/tester/test_b09_silent_machine_rows.py diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index d1412520..65fc60da 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -226,6 +226,28 @@ def _tidy_resource_name(cell: str) -> str: _MACHINE_UNITS = ("시간", "hr", "h", "대", "시 간") +#: 조용히 빠지던 기계·연료 줄의 사유(2026-09-15 ㉠ — 인력만으로 싸게 서던 7공종). +SILENT_MACHINE = "기계·연료 줄이 안 붙음 — 이 공종 단가는 일부만 섬(인력만으로 싸게 서지 않게 막음)" +#: 규격이 이름 앞에 온 장비 칸 — 「0.8㎥ 굴착기」. +_RE_SPEC_FIRST = re.compile(r"^\d+(?:\.\d+)?(?:㎥|m3|ton|톤|㎾|kW)\s*[가-힣]{2,}") +#: 연료·기관 줄 이름 — 값이 비었거나 식이라도 기계 몫(연료비 · 휘발유 · 경유 · 엔진 · 기관). +_RE_FUEL_OR_ENGINE = re.compile(r"연료|휘발유|경유|엔진|기관|양수기|발전기|원동기") +_POWER_UNITS = ("kW", "㎾", "HP", "PS", "마력") + + +def _silent_machine_row(name_cell: str, value_cells: list[str], has_digit: bool) -> bool: + """값을 못 읽고 이름도 안 풀린 줄이 **기계·연료 몫**인가 — 머리 줄·설명 줄은 아님.""" + name = _normalize(name_cell) + if not name or is_non_resource_label(name_cell) and not _RE_FUEL_OR_ENGINE.search(name): + return False + if _RE_FUEL_OR_ENGINE.search(name): + return True + joined = " ".join(value_cells) + return has_digit and ( + _is_machine_like_row(value_cells) or any(unit in joined for unit in _POWER_UNITS) + ) + + def _is_machine_like_row(cells: list[str]) -> bool: """그 줄이 **장비 몫**인가 — 단위 칸이 시간·대수인지로 본다.""" for cell in cells: @@ -480,6 +502,17 @@ def match_table( reason="자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", ) ) + elif _silent_machine_row(name_cell, value_cells, has_digit): + # 이름도 값도 못 읽은 기계·연료 줄 — 조용히 넘기면 인력만으로 싸게 섬(2026-09-15 ㉠). + code = node.get("work_item_code", "") + result.partial_items[code] = ( + f"{_normalize(name_cell)[:20]} (기계·연료 줄)이 안 붙음" + ) + result.unmatched.append( + UnmatchedRow( + code, str(table.get("pum_table_id", "")), name_cell, SILENT_MACHINE + ) + ) continue # ⚠ **카탈로그 조회를 먼저 한다.** 이름 필터를 앞에 두면 필터가 넓을 때 @@ -491,6 +524,17 @@ def match_table( name_cell = apply_scoped_alias(catalog, name_cell, node["work_item_code"], value_cells) entry = entry or _resolve_cell(catalog, name_cell, [name_cell, *value_cells]) if entry is None: + if _RE_SPEC_FIRST.match(_normalize(name_cell)) and _is_machine_like_row(value_cells): + # 「0.8㎥ 굴착기 | h | 0.25」 — 규격이 이름 앞이라 머리글로 걸러지던 장비 줄(14-2 · ㉠). + result.partial_items[node["work_item_code"]] = ( + f"{_normalize(name_cell)[:20]} (기계·연료 줄)이 안 붙음" + ) + result.unmatched.append( + UnmatchedRow( + node["work_item_code"], table["pum_table_id"], name_cell, SILENT_MACHINE + ) + ) + continue if is_non_resource_label(name_cell): continue # 머리글·소계 — 못 맞춘 목록에도 안 올린다 # 「규격 미정 — 후보 N」 · 「같은 이름 여럿」 · 「카탈로그에 없는 이름」을 가른다. diff --git a/B09_Estimation/B09_Estimation_WoodChipping.py b/B09_Estimation/B09_Estimation_WoodChipping.py index bcccacd0..39b0ccdc 100644 --- a/B09_Estimation/B09_Estimation_WoodChipping.py +++ b/B09_Estimation/B09_Estimation_WoodChipping.py @@ -99,7 +99,15 @@ def attach_wood_chipping(build: Any, nodes_by_code: dict[str, dict[str, Any]]) - note=f"[주]② 1일 보통인부 {LABORERS_PER_DAY}인 ÷ [주]① {HOURS_PER_DAY}시간 ÷ Q {q}", ) ) - labels = [label for label in build.unattached.get(CODE, []) if "파쇄기날" not in label] + # 표의 파쇄기 줄은 이 모듈이 씀 — 자원 축이 「기계·연료 줄이 안 붙음」 으로 올린 것을 걷음. + labels = [ + label + for label in build.unattached.get(CODE, []) + if "파쇄기날" not in label and "".join(label.split()) != "이동식임목파쇄기" + ] + if "파쇄기" in (build.component_gaps.get(CODE) or ""): + build.component_gaps.pop(CODE) + build.partial_ratio.pop(CODE, None) if BLADE in book.titles: book.add_detail( PriceDetail( diff --git a/resources/tester/test_b09_silent_machine_rows.py b/resources/tester/test_b09_silent_machine_rows.py new file mode 100644 index 00000000..9e29a13b --- /dev/null +++ b/resources/tester/test_b09_silent_machine_rows.py @@ -0,0 +1,42 @@ +"""말없이 버려지던 기계·연료 줄 — 2026-09-15 브레인 ㉠(인력만으로 싸게 서던 공종 막기). + +자원 축 줄 읽기가 세 자리에서 기계·연료 줄을 「못 붙은 줄」 목록에도 안 올리고 버렸음 → +그 공종 단가가 인력만으로 **막히지도 사유도 없이** 싸게 섬(사용자는 값이 맞는 줄 앎). + ① 규격이 이름 앞 「0.8㎥ 굴착기 | h | 0.25」(14-2 · 14-1) — 숫자로 시작해 머리글로 걸러짐 + ② 값·이름 둘 다 못 읽음 「디젤엔진(15HP) | 11.19kW」(12-26) · 「연료비 | 소요량 적용 | ps × 0.253ℓ × 6h」(10-7-4) + ③ 수량 빈 연료 줄 「연료비 | 경유 | L | 」(10-8-3) — 숫자 없는 줄은 머리 줄로 봄 +⇒ 기계·연료로 보이는 줄은 「못 맞춤 + 일부만」 — 틀린 금액보다 안 선 금액(사유 또렷이). +""" + +from __future__ import annotations + +from B09_Estimation.B09_Estimation_UnitPrice import cached_build + + +def test_기계_연료_줄이_안_붙은_다섯_공종은_일부만으로_막히고_사유() -> None: + build = cached_build() + for code in ("FP-12-26", "FP-14-02", "FP-14-01", "FP-10-08-03", "FP-10-07-04"): + assert code in build.partial_ratio, code + assert "기계·연료 줄" in (build.component_gaps.get(code) or ""), ( + code, + build.component_gaps.get(code), + ) + left = " ".join(build.unattached.get("FP-12-26", [])) + assert "디젤엔진" in left and "양수기" in left, left + assert "0.8㎥ 굴착기" in " ".join(build.unattached.get("FP-14-02", [])) + + +def test_머리_줄과_인력만인_정상_공종은_안_막힘() -> None: + build = cached_build() + for code in ("FP-09-15-02", "FP-12-12", "FP-04-02-02", "FP-09-03-02"): + assert code not in build.partial_ratio, code + + +def test_파쇄_모듈이_쓰는_파쇄기_줄은_막힘으로_안_남음() -> None: + build = cached_build() + assert "FP-08-11" not in build.partial_ratio + assert "기계·연료 줄" not in (build.component_gaps.get("FP-08-11") or "") + assert not any( + "파쇄기 " in label or label == "이동식 임목 파쇄기" + for label in build.unattached.get("FP-08-11", []) + )