Files
Aislo/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py
T
eomsangdonandClaude Opus 5 8a31f87412 fix(axis): 표 읽기 ② — 원본이 [주] 문장을 절 제목으로 읽은 표 14장은 앞 절을 이어받음(12-2 표면 마무리 가짜 표 7장·갈래 #설치·#철거 → 12-17-1) · 9-21 제근 안내는 못 붙은 줄이 아니라 비고로(빌린 밑수 교차참조)
마스터 전후 대조: 표가 옮겨 붙은 공종 8(12-02·12-17-01·01-01-02·05-34·08-02-03·09-07-01·09-11-01·14-02) · 일위대가 453 제목 금액 변화 0 · 12-02 가짜 갈래 둘이 12-17-01 로만 옮김

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 13:08:01 +09:00

362 lines
15 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 ""
# ⚠ **빌려 온 밑수의 배수를 여기서 나눈다** — 「1,000㎡당」 표를 ㎡당으로 싣는다.
# 안 나누면 금액이 **천 배**로 선다(2026-09-09 확정 5차 6번, 제근).
from B09_Estimation.B09_Estimation_WorkItemUnit import borrowed_basis_per
per = Decimal(borrowed_basis_per(str(node.get("work_item_code", ""))))
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 / per,
amount_unit=unit,
raw_row_index=0,
variant=variant,
)
)
made += 1
del cell
if made == 0:
return False
if per != 1:
# 밑수를 사용자가 정해 빌려 온 표(확정 5차 6번 제근) — 안내 글은 「못 붙은 줄」이 아니라
# 내역·본표 비고(`KnownGaps.known_gap_note` ← `borrowed_unit_note`)로 뜸(2026-09-14).
return True
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
# ---------------------------------------------------------------------------
# 규격이 **열**로 선 표 (2026-09-09) — 관부설 12-11-1·2·3
# ---------------------------------------------------------------------------
#
# | 구 분 | 규격 | 단위 | 관 경 별 적 용 |
# | ∅800mm | ∅1000mm | ∅1200mm | ← 첫 줄이 **규격 이름만** 늘어선 줄
# | 크레인 | 10ton | hr | 0.62/2.5 | 0.76/2.5 | 0.90/2.5 |
# | 배관공 | | 인 | 0.26/2.5 | 0.35/2.5 | 0.46/2.5 |
#
# ⚠ 값이 **나눗셈 식**이다 — 「0.62/2.5」는 「관 2.5m 한 개당 0.62시간」이라는 뜻이라
# **m 당으로 환산된 값**이다. 그대로 두면 자원 줄이 하나도 안 서서 배수관이 통째로
# 금액을 못 냈다(2026-09-09 실측: 아홉 줄 중 다섯이 길이까지 있는데 단가가 없었다).
#
# ⚠ **값이 빈 칸은 건너뛴다** — 기초콘크리트·거푸집·모래부설은 「별산」 자리다(계획서 9-9).
_SPEC_HEAD = re.compile(r"^[∅Ø⌀]\s*\d")
_FRACTION = re.compile(r"^\s*(\d+(?:\.\d+)?)((?:\s*/\s*\d+(?:\.\d+)?)+)\s*$")
def _fraction_value(cell: str) -> Decimal | None:
"""「0.62/2.5」·「0.016/2.5/2」를 수로. 나눗셈이 아니면 `None`."""
matched = _FRACTION.match(cell)
if not matched:
return None
value = Decimal(matched.group(1))
for part in matched.group(2).split("/"):
part = part.strip()
if not part:
continue
divisor = Decimal(part)
if divisor == 0:
return None
value = value / divisor
return value
def _cell_amount(cell: str) -> Decimal | None:
"""값 칸 하나 — 숫자 그대로이거나 나눗셈 식."""
text = _clean(cell)
if not text:
return None
if _NUMBER.match(text):
return parse_amount(text)
return _fraction_value(text)
def match_spec_column_table(
node: dict[str, Any],
table: dict[str, Any],
catalog: ResourceCatalog,
result: AxisResult,
) -> bool:
"""규격이 **열**로 선 표를 읽는다. 그런 표가 아니면 `False`.
⚠ 첫 줄이 **규격 이름만** 늘어선 줄일 때만 내 표로 본다 — 「∅800mm ∅1000mm …」.
"""
rows = [row for row in (table.get("raw_row") or []) if isinstance(row, list)]
if len(rows) < 2:
return False
specs = [_clean(cell) for cell in rows[0] if _clean(cell)]
if len(specs) < 2 or not all(_SPEC_HEAD.match(spec) for spec in specs):
return False
from B09_Estimation.B09_Estimation_ResourceAxis_Join import resolve_family, unmatched_reason
unit = table.get("basis_unit") or ""
work_item_code = str(node.get("work_item_code", ""))
table_id = str(table.get("pum_table_id", ""))
form = str(table.get("pum_form", ""))
made = 0
previous_note = ""
def unmatched(cell: str, reason: str) -> None:
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code, pum_table_id=table_id, cell=cell, reason=reason
)
)
for index, row in enumerate(rows[1:], start=1):
cells = [_clean(cell) for cell in row]
if not cells or not cells[0]:
continue
# 줄 모양 — | 구분 | 규격 | 단위 | 관경별 값 … | 비고 | (2026-09-14 · 원문 12-11 표 머리)
values = [
value for value in (_cell_amount(cell) for cell in cells[1:]) if value is not None
]
note = cells[-1] if len(cells) > 1 and _cell_amount(cells[-1]) is None else ""
note = previous_note if note == "〃" else note
spec_cell = cells[1] if len(cells) > 1 and _cell_amount(cells[1]) is None else ""
if not values and not note and previous_note.replace(" ", "") == "설계수량":
note = previous_note # 원문 병합 칸 — 바로 위 줄 비고를 따름(흄관 거푸집)
previous_note = note
tight_note = note.replace(" ", "")
# ⚠ 원문 비고가 「이 일위대가 밖」인 줄 — 못 찾은 게 아니라 안 넣는 것 · 까닭을 남김.
if tight_note in ("별산", "별도계산"):
unmatched(
cells[0], f"「{note}」 — 원문 비고대로 이 일위대가 밖에서 따로 셈 · 여기 안 넣음"
)
continue
if tight_note == "설계수량":
unmatched(cells[0], "「설계수량」 — 원문 비고대로 설계 물량으로 따로 셈 · 여기 안 넣음")
continue
if len(values) < len(specs):
why = f"「{note}」 — " if note else ""
unmatched(cells[0], f"{why}관경별 값이 비어 짐작해 채우지 않음")
continue
name, inline_spec = split_name_and_spec(cells[0])
row_specs = [text for text in (spec_cell, inline_spec) if text]
entries: list[CatalogEntry] = []
for spec_name in specs:
entry = (
next(
(
found
for text in (*row_specs, spec_name)
if (found := catalog.resolve(name, text)) is not None
),
None,
)
or catalog.resolve(name, "")
or resolve_family(catalog, cells[0], cells)
)
if entry is None:
break
entries.append(entry)
if len(entries) < len(specs):
origin = f" · 원문 규격 {spec_cell}" if spec_cell else ""
unmatched(cells[0], f"{unmatched_reason(catalog, name)}{origin} (규격이 열로 선 표)")
continue
for spec_name, amount, entry in zip(specs, values[-len(specs) :], 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=index,
variant=spec_name,
)
)
made += 1
return made > 0