Author SHA1 Message Date
eomsangdonandClaude Opus 5 2f9d42ac35 fix(b09): ㉠ 자원 축 줄 읽기가 말없이 버리던 기계·연료 줄을 「못 맞춤 + 일부만」 으로 — 인력만으로 싸게 서던 공종 막기
· 버리던 세 자리: 규격이 이름 앞인 장비 칸(「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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-15 01:48:34 +09:00
3 changed files with 95 additions and 1 deletions
@@ -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」 · 「같은 이름 여럿」 · 「카탈로그에 없는 이름」을 가른다.
@@ -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(
@@ -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", [])
)