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", []) + )