⚠⚠ **차단인지는 `blocked_kind` 가 정한다 — 문구가 아니다.** 배수관 다섯 줄이 `blocked_kind=None · in_bill=True` 인데도 **사유가 있다는 것만으로** 막혀 금액이 안 서고 있었음. 그 사유는 차단이 아니라 **주의 문구**였음 — 「관종을 안 정해 기본값(파형강관)으로 섰습니다」. ⇒ 사유만 온 줄은 **금액을 세우고 그 문구를 곁말(ⓘ)로** 닮. 차단 표시가 붙은 줄은 그대로 막음. ⓘ 오늘 두 번째 같은 병임 — 아침엔 겹침 설명을 `blocked_reason` 에 넣었더니 막힌 줄로 읽혔음. ⚠ 그리고 막힘이 풀리자 **다음 벽**이 드러남 — 관부설 12-11 표를 한 줄도 못 읽고 있었음. 그 표는 **규격이 열**(∅800·∅1000·∅1200)이고 값이 **나눗셈 식**(「0.62/2.5」 = 관 2.5m 한 개당 0.62시간 ⇒ m 당 환산값)임. - `match_spec_column_table` 신설 — 첫 줄이 규격 이름만 늘어선 표일 때만 내 표로 봄 - 「0.016/2.5/2」처럼 겹친 나눗셈도 읽음 · 값이 빈 칸(기초콘크리트·거푸집)은 건너뜀 - 관경마다 갈래가 섬. B08 이 「Ø800」으로 보내도 「∅800mm」 갈래에 붙음 결과 — 배수관 네 줄이 처음으로 금액이 섬 Ø1000 10m × 10,903.7 = 109,037 · Ø800 11m × 10,973.0 = 120,703 · Ø800 9m ×2 = 197,514 ⇒ 합 **427,254원** 본체 31,118,251 → **31,545,505원** ⚠ Ø1500 은 품셈 표에 그 관경이 없어(800·1000·1200) 그대로 막힘 — 후보만 보임 검증: pytest 307 통과(신규 4) · 자원 축 547 → 565 줄 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
311 lines
13 KiB
Python
311 lines
13 KiB
Python
"""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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 규격이 **열**로 선 표 (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
|
||
|
||
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
|
||
for index, row in enumerate(rows[1:], start=1):
|
||
cells = [_clean(cell) for cell in row]
|
||
if not cells:
|
||
continue
|
||
name, spec_text = split_name_and_spec(cells[0])
|
||
entry = catalog.resolve(name, spec_text) or catalog.resolve(name, "")
|
||
if entry is None:
|
||
result.unmatched.append(
|
||
UnmatchedRow(
|
||
work_item_code=work_item_code,
|
||
pum_table_id=table_id,
|
||
cell=cells[0],
|
||
reason="카탈로그에 없는 이름 (규격이 열로 선 표) — 0 으로 때우지 않습니다.",
|
||
)
|
||
)
|
||
continue
|
||
values = [_cell_amount(cell) for cell in cells[1:]]
|
||
values = [value for value in values if value is not None]
|
||
if len(values) < len(specs):
|
||
# 값이 빈 규격이 있다 — 「별산」 줄이거나 표가 덜 찼다. 짐작해 채우지 않는다.
|
||
continue
|
||
for spec_name, amount in zip(specs, values[-len(specs) :]):
|
||
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
|