Files
Aislo/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py
T
eomsangdonandClaude Opus 5 e0081cd953 fix(B09): 제근 이중계상 — 표가 「고르라」고 둔 장비 블록을 다 더하고 있었음
품셈 9-21 제근은 장비 규격 두 갈래(0.2㎥·0.7㎥)를 **고르라고** 둔 표인데 둘 다 붙고
보통인부도 두 번 붙어 단가가 **128,039원**으로 서 있었음. 밑수가 원문에 없어 아직
금액이 안 서 있었을 뿐, **밑수가 정해지는 날 조용히 두 배로 설 자리**였음.

- `B09_Estimation_ResourceAxis_ChooseOne` 신설 — 블록마다 갈래 하나, 열마다 갈래 하나.
  장비 2 × 임목밀도 3 = **여섯 갈래**로 세움 (55,625.9 ~ 111,161.4원)
- ⚠ **「고르는 표」를 좁게 가름** — 기계 줄이 둘이라고 다 그런 표가 아님. 9-13 암절취는
  깨기(브레이커)와 들어내기(백호우)가 **함께 듦**. 자국은 **같은 이름에 규격만 다른 것**.
  품셈도 그렇게 말함 — 9-20-1 [주]④ 「0.2㎥ 또는 0.4㎥ … 계수를 달리 적용」
- ⚠ 밑수는 만들지 않음 — 9-21 은 표 머리·제목·[주] 어디에도 밑수 표기가 없음
  (앞 절 9-20-1 「(10주당)」·9-20-2 「(단위: 10주당)」과 대비되는 자리)

⚠ 짓다 잡은 것 — 기종 해석이 **넘겨받은 카탈로그를 무시**하고 기계 카탈로그를 직접 봐서,
노무만 든 카탈로그로 돌릴 때도 기계 줄이 나왔음. 카탈로그에 기계가 없으면 이 표를
안 읽게 막음(시험이 그것을 잡았음).

⚠ 시험 하나가 **버그를 맞는 것으로 적어 두고 있었음** — 「제근은 보통인부 줄이 둘이다」.
그것이 곧 이중계상이었음. 뒤집어 다시 적었음.

검증: pytest 295 통과(신규 4) · 자원 축 539 → 547 줄, 늘어난 8 줄은 전부 제근

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 09:03:49 +09:00

201 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""B09 원가계산 — **표가 「둘 중 하나를 고르라」고 둔 장비 블록** 읽기 (2026-09-09).
품셈에는 같은 일을 **장비 규격에 따라 달리 세는** 표가 있다. 블록이 둘인데 **둘 다 더하면
장비 두 대와 인부 두 몫이 서서 대략 두 배**가 된다.
9-21 제근
| 종 류 | 명 칭 | 단위 | 소 | 중 | 밀 |
| 굴착기(무한궤도) | 굴착기(무한궤도,0.2㎥) | hr | 0.80 | 1.01 | 1.22 | ┐ 0.2㎥ 블록
| 보통인부 | 인 | 0.03 | 0.04 | 0.05 | ┘
| 굴착기(무한궤도,0.7㎥) | hr | 0.46 | 0.58 | 0.70 | ┐ 0.7㎥ 블록
| 보통인부 | 인 | 0.03 | 0.04 | 0.05 | ┘
2026-09-09 실측: 제근 단가가 **128,039원**으로 서 있었다 — 굴착기 0.2·0.7 이 둘 다 붙고
보통인부도 두 번 붙은 값이다. 밑수가 원문에 없어 아직 금액이 안 서 있었을 뿐,
**밑수가 정해지는 날 조용히 두 배로 설 자리**였다.
읽는 법 — **블록마다 갈래 하나**, 열마다 갈래 하나. 둘을 곱해 갈래를 낸다.
갈래 = 「굴착기(무한궤도) 0.2㎥ · 소」 … 「굴착기(무한궤도) 0.7㎥ · 밀」 (2 × 3 = 6)
⚠ **「고르는 표」인지 아닌지를 좁게 가른다.** 기계 줄이 둘이라고 다 고르는 표가 아니다 —
9-13 암절취는 「깨기(대형브레이커)」와 「들어내기(백호우)」가 **함께 드는** 표다.
가르는 자국은 **같은 기계 이름에 규격만 다른 것**이다(굴착기 0.2 vs 0.7). 품셈도 그렇게
말한다 — 9-20-1 [주]④ 「0.2㎥ 또는 0.4㎥ 용량의 굴착기를 사용하는 경우에는 …적용계수를
달리 적용하도록 한다」.
⚠ **밑수는 여기서 만들지 않는다.** 9-21 은 표 머리·제목·[주] 어디에도 밑수가 없다
(2026-09-09 원문 전수 확인). 갈래만 바로 세우고 밑수는 빈 채로 둔다.
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_ResourceAxis import (
AxisResult,
CatalogEntry,
ResourceCatalog,
ResourceRow,
UnmatchedRow,
parse_amount,
split_name_and_spec,
)
_NUMBER = re.compile(r"^\d+(?:\.\d+)?$")
#: 열 머리로 인정하지 않는 말 — 값이 아니라 설명이다.
_NOT_A_COLUMN = ("비고", "적요", "참고", "단위", "명칭", "명 칭", "종류", "종 류", "규격", "규 격")
def _clean(cell: Any) -> str:
return " ".join(str(cell or "").split())
def _column_labels(header: list[Any]) -> list[str]:
"""표 머리에서 **갈래 열 이름**만 골라 낸다 — 「소·중·밀」."""
labels = [_clean(cell) for cell in header]
return [
label
for label in labels
if label and "".join(label.split()) not in {"".join(w.split()) for w in _NOT_A_COLUMN}
]
def _machine_of(cells: list[str], catalog: ResourceCatalog):
"""그 줄이 기계 줄이면 (칸 번호, 기종, 원문 칸). 아니면 `None`.
⚠ 자원 카탈로그의 이름·규격 짝으로는 안 풀린다 — 품셈이 「굴착기(무한궤도,0.2㎥)」처럼
**규격을 괄호 안에 몰아** 적기 때문이다. 기종 해석은 그 모양을 아는 쪽에 맡긴다.
"""
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine
machines = load_machine_catalog().machines
for index, cell in enumerate(cells):
found = resolve_machine(cell)
if found is None:
continue
machine = machines.get(found[0])
if machine is None:
continue
entry = CatalogEntry(
code=found[0], name=machine.name, kind="machine", spec=str(machine.specification)
)
return index, entry, cell
return None
def _values_of(cells: list[str], count: int) -> list[Decimal] | None:
"""그 줄 끝에서 값 `count` 개. 개수가 안 맞으면 `None` — 짐작해 채우지 않는다."""
numbers = [cell for cell in cells if _NUMBER.match(cell)]
if len(numbers) < count:
return None
picked = [parse_amount(cell) for cell in numbers[-count:]]
return None if any(value is None for value in picked) else picked # type: ignore[return-value]
def match_choose_one_machine_table(
node: dict[str, Any],
table: dict[str, Any],
catalog: ResourceCatalog,
result: AxisResult,
) -> bool:
"""「둘 중 하나를 고르는」 장비 블록 표를 읽는다. 그런 표가 아니면 `False`.
⚠ 같은 기계 이름에 **규격만 다른** 블록이 둘 이상일 때만 내 표로 본다.
"""
# ⚠ **기계 카탈로그가 없는 조립에서는 이 표를 읽지 않는다.** 기종 해석은 기계 쪽
# 카탈로그를 직접 보므로, 노무만 든 카탈로그로 돌릴 때도 기계 줄이 나와 버린다
# (2026-09-09 시험이 그것을 잡았다). **넘겨받은 카탈로그의 결을 따른다.**
if not any(entry.kind == "machine" for entry in catalog.entries):
return False
rows = [row for row in (table.get("raw_row") or []) if isinstance(row, list)]
header = list(table.get("condition_note") or [])
labels = _column_labels(header)
if len(rows) < 2 or len(labels) < 2:
return False
blocks: list[dict[str, Any]] = []
for row in rows:
cells = [_clean(cell) for cell in row]
if not cells:
continue
found = _machine_of(cells, catalog)
if found is not None:
_index, entry, raw_cell = found
values = _values_of(cells, len(labels))
if values is None:
return False
blocks.append({"entry": entry, "cell": raw_cell, "rows": [], "values": values})
continue
if not blocks:
continue
name, spec = split_name_and_spec(cells[0])
entry = catalog.resolve(name, spec)
values = _values_of(cells, len(labels))
if entry is None or values is None:
continue
blocks[-1]["rows"].append({"entry": entry, "values": values, "cell": cells[0]})
if len(blocks) < 2:
return False
# ⚠ **같은 이름 · 다른 규격**일 때만 「고르는 표」다. 이름이 다르면 함께 드는 장비다.
names = {block["entry"].name for block in blocks}
specs = {block["entry"].spec for block in blocks}
if len(names) != 1 or len(specs) != len(blocks):
return False
work_item_code = str(node.get("work_item_code", ""))
table_id = str(table.get("pum_table_id", ""))
form = str(table.get("pum_form", ""))
unit = table.get("basis_unit") or ""
made = 0
for block in blocks:
machine = block["entry"]
for column, label in enumerate(labels):
variant = f"{machine.name} {machine.spec} · {label}".strip()
entries = [(machine, block["values"][column], block["cell"])]
entries += [
(item["entry"], item["values"][column], item["cell"]) for item in block["rows"]
]
for entry, amount, cell in entries:
result.rows.append(
ResourceRow(
work_item_code=work_item_code,
pum_table_id=table_id,
pum_form=form,
resource_kind=entry.kind,
resource_code=entry.code,
resource_name=entry.name,
resource_spec=entry.spec,
amount=amount,
amount_unit=unit,
raw_row_index=0,
variant=variant,
)
)
made += 1
del cell
if made == 0:
return False
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=" | ".join(_clean(cell) for cell in header),
reason=(
f"장비 규격 {len(blocks)} 가지 × 갈래 {len(labels)} 가지로 세웠습니다 — "
"표가 「둘 중 하나」로 둔 자리라 **더하지 않고 고르게** 합니다. "
"⚠ 밑수(무엇당)는 원문에 없습니다."
),
)
)
return True