12-6 은 두께 칸이 작업조 칸과 엇갈려 병합된 표라 두께 줄을 시공량 갈래로 읽음(20·30·40㎝, ㎥당). 후진 진입·경운기 칸은 「50%까지 감」 범위라 안 세움. 공종 단위가 빈 작업조 표는 시공량 열 머리 단위를 씀. 13-4-1·13-4-4 는 hwpx 병합대로 앞 세 돌 골·켜 두 잎 + 호박돌 및 야면석 한 잎으로 갈래를 세움(각 40 갈래). 13-4-4 는 같은 절의 표준경사 표 오판정으로 아직 막힘 — 형태 판정 일감으로 넘김. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
267 lines
11 KiB
Python
267 lines
11 KiB
Python
"""B09 원가계산 — **작업조 + 시공량** 표 읽기 (2026-09-08).
|
||
|
||
품셈에는 소요량을 직접 안 주고 **「작업조 몇 인이 하루 몇 ㎡」** 로 주는 표가 있다.
|
||
|
||
구 분 | 단 위 | 수 량 | 시 공 량 (㎡)
|
||
| | | 복잡 보통 간단
|
||
형틀목공 | 인 | 4 | 25 35 40
|
||
보통인부 | 인 | 1 |
|
||
|
||
1단위당 품 = 인원 ÷ 시공량 (유로폼 12-38-3: 형틀목공 4 ÷ 35 = 0.1143 인/㎡)
|
||
|
||
**유형은 우리가 안 고른다.** 품셈 12-38-3 [유형] 표가 「보통 : 측구, 수로, 옹벽 …」로
|
||
정해 두었으므로 **갈래(`#복잡`·`#보통`·`#간단`)로 세워 두고 고르는 것은 B08**에 맡긴다
|
||
(철근 12-3 [주]① 과 같은 모양).
|
||
|
||
⚠ **뭉쳐 온 칸을 짝지어 읽는다.** 이름·단위·인원이 한 칸에 붙어 온다
|
||
(`형틀목공 보통인부` | `인 인` | `4 1`). **개수가 안 맞으면 그 표를 통째로 버린다** —
|
||
자리를 밀어 읽으면 다른 직종의 품이 붙는다(오늘 여러 번 겪은 자리).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from dataclasses import dataclass
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
from B09_Estimation.B09_Estimation_ResourceAxis import (
|
||
AxisResult,
|
||
ResourceCatalog,
|
||
ResourceRow,
|
||
UnmatchedRow,
|
||
parse_amount,
|
||
split_name_and_spec,
|
||
)
|
||
|
||
_RE_NUMBER = re.compile(r"^\d+(?:,\d{3})*(?:\.\d+)?$")
|
||
#: 두께 갈래 칸 — 「20㎝」. 넓히면 규격 칸(「0.6㎥」)을 갈래로 오해한다.
|
||
_RE_THICKNESS = re.compile(r"^\d+(?:\.\d+)?\s*(?:㎝|cm)$")
|
||
#: 시공량 열을 알아보는 말. 「시 공 량 (㎡)」처럼 띄어쓰기가 섞여 온다.
|
||
_OUTPUT_WORDS = ("시공량", "기준시공량", "적용시공량")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class CrewMember:
|
||
"""작업조 한 사람(또는 한 대)."""
|
||
|
||
code: str
|
||
name: str
|
||
spec: str
|
||
kind: str
|
||
count: Decimal
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class CrewTable:
|
||
"""작업조 표 한 장. `outputs` 는 유형별 시공량 — 갈래가 하나면 이름이 빈 문자열."""
|
||
|
||
members: tuple[CrewMember, ...]
|
||
outputs: tuple[tuple[str, Decimal], ...]
|
||
|
||
def amount_of(self, member: CrewMember, output: Decimal) -> Decimal:
|
||
"""1단위당 품 = 인원 ÷ 시공량."""
|
||
return member.count / output
|
||
|
||
|
||
def _normalize(text: str) -> str:
|
||
return " ".join(str(text).split())
|
||
|
||
|
||
def _is_output_header(cell: str) -> bool:
|
||
tight = "".join(str(cell).split())
|
||
return any(word in tight for word in _OUTPUT_WORDS)
|
||
|
||
|
||
def _numbers_in(cell: str) -> list[Decimal]:
|
||
"""한 칸에 뭉쳐 온 수들 — 「4 1」 → [4, 1]."""
|
||
found: list[Decimal] = []
|
||
for token in _normalize(cell).split(" "):
|
||
if _RE_NUMBER.match(token):
|
||
found.append(Decimal(token.replace(",", "")))
|
||
return found
|
||
|
||
|
||
def output_columns(table: dict[str, Any]) -> list[int]:
|
||
"""시공량 열의 번호. 없으면 빈 목록 — 작업조 표가 아니다."""
|
||
headers = table.get("condition_note") or []
|
||
return [index for index, cell in enumerate(headers) if _is_output_header(cell)]
|
||
|
||
|
||
def parse_crew_table(
|
||
table: dict[str, Any],
|
||
catalog: ResourceCatalog,
|
||
) -> CrewTable | str:
|
||
"""작업조 표를 읽는다. 못 읽으면 **사유 문자열**을 돌려준다(빈 표를 만들지 않는다)."""
|
||
if not output_columns(table):
|
||
return "시공량 열이 없습니다"
|
||
|
||
rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])]
|
||
if not rows:
|
||
return "빈 표입니다"
|
||
|
||
members: list[CrewMember] = []
|
||
outputs: list[Decimal] = []
|
||
labels: list[str] = []
|
||
thickness: list[tuple[str, Decimal]] = []
|
||
|
||
for row in rows:
|
||
if not row or not row[0]:
|
||
continue
|
||
head = row[0]
|
||
# 첫 칸에 자원 이름이 뭉쳐 올 수 있다 — 「형틀목공 보통인부」.
|
||
# ⚠ 이름 안에 공백이 든 표가 있다 — 「조 경 공」·「비 계 공」. 공백을 구분자로만
|
||
# 보면 그런 줄이 통째로 안 풀린다. **한 이름으로 먼저 시도**하고, 안 되면 쪼갠다.
|
||
tight = "".join(_normalize(head).split(" "))
|
||
single = _resolve(catalog, tight)
|
||
if single is not None:
|
||
names, resolved = [tight], [single]
|
||
else:
|
||
names = [part for part in _normalize(head).split(" ") if part]
|
||
resolved = [_resolve(catalog, name) for name in names]
|
||
if not all(resolved):
|
||
# 두께 갈래 줄 — 「30㎝ | 150」. 콘크리트 포장 12-6 은 두께 칸이 작업조 칸과
|
||
# **줄을 엇갈려** 병합돼 있어(hwpx 확인) 마스터가 작업조 줄 사이에 두께 줄을 끼워 둔다.
|
||
if _RE_THICKNESS.match(head) and len(_numbers_in(" ".join(row[1:]))) == 1:
|
||
thickness.append((head, _numbers_in(" ".join(row[1:]))[0]))
|
||
continue
|
||
# 자원이 아니면 유형 라벨 줄로 본다 — 「복 잡 | 보 통 | 간 단」.
|
||
if not _numbers_in(" ".join(row)):
|
||
if not members:
|
||
labels = [_normalize(cell) for cell in row if _normalize(cell)]
|
||
continue
|
||
# ⚠ **수가 있는데 이름을 못 푼 줄은 작업조의 한 몫**이다 — 조용히 건너뛰면
|
||
# 그 몫이 빠진 채 단가가 선다(2026-09-08: 평떼 시비에서 「트럭 2.5ton 1대」가
|
||
# 빠지고 노무만으로 28.6원/㎡ 이 섰다). 표를 통째로 버린다.
|
||
return f"작업조 줄 「{_normalize(head)[:20]}」을 못 풀었습니다"
|
||
|
||
counts = _counts_of(row, len(resolved))
|
||
if counts is None:
|
||
return f"인원 수가 이름 {len(resolved)} 개와 안 맞습니다"
|
||
for entry, count in zip(resolved, counts):
|
||
members.append(
|
||
CrewMember(
|
||
code=entry.code,
|
||
name=entry.name,
|
||
spec=entry.spec,
|
||
kind=entry.kind,
|
||
count=count,
|
||
)
|
||
)
|
||
# 시공량은 보통 **첫 작업조 줄**에 붙어 온다.
|
||
if not outputs:
|
||
outputs = _outputs_of(row, counts)
|
||
# 작업조 줄에 두께 갈래가 붙어 온 자리 — 「포장공 | 3 | 20㎝ | 100 | …」.
|
||
for label, value in zip(row, row[1:]):
|
||
if _RE_THICKNESS.match(label) and _RE_NUMBER.match(value):
|
||
thickness.append((label, Decimal(value.replace(",", ""))))
|
||
|
||
if not members:
|
||
return "작업조 줄을 못 찾았습니다"
|
||
if thickness:
|
||
# 두께마다 시공량이 하나 — 그 옆 시공량 칸(「후진 진입·경운기 운반은 좌측의 50%까지 감」)은
|
||
# **범위라 값이 아니다.** 유형 라벨은 그 두 칸의 머리라 두께 갈래와 짝이 아니다.
|
||
return CrewTable(members=tuple(members), outputs=tuple(thickness))
|
||
if not outputs:
|
||
return "시공량 값을 못 찾았습니다"
|
||
if labels and len(labels) != len(outputs):
|
||
# 라벨과 값의 개수가 다르면 **어느 유형인지 단정할 수 없다** — 읽지 않는다.
|
||
return f"유형 {len(labels)} 개와 시공량 {len(outputs)} 개가 안 맞습니다"
|
||
|
||
named = tuple((labels[index] if labels else "", value) for index, value in enumerate(outputs))
|
||
return CrewTable(members=tuple(members), outputs=named)
|
||
|
||
|
||
def _resolve(catalog: ResourceCatalog, name_cell: str):
|
||
name, spec = split_name_and_spec(name_cell)
|
||
entry = catalog.resolve(name, spec)
|
||
if entry is not None:
|
||
return entry
|
||
if spec:
|
||
return None
|
||
found = catalog.by_name(name)
|
||
return found[0] if len(found) == 1 else None
|
||
|
||
|
||
def _counts_of(row: list[str], wanted: int) -> list[Decimal] | None:
|
||
"""이름 개수와 같은 만큼의 인원 수를 가진 칸을 찾는다. 없으면 `None`."""
|
||
for cell in row[1:]:
|
||
numbers = _numbers_in(cell)
|
||
if len(numbers) == wanted:
|
||
return numbers
|
||
return None
|
||
|
||
|
||
def _outputs_of(row: list[str], counts: list[Decimal]) -> list[Decimal]:
|
||
"""인원 칸 **뒤**에 오는 수들이 시공량이다."""
|
||
seen_counts = False
|
||
values: list[Decimal] = []
|
||
for cell in row[1:]:
|
||
numbers = _numbers_in(cell)
|
||
if not numbers:
|
||
continue
|
||
if not seen_counts and numbers == counts:
|
||
seen_counts = True
|
||
continue
|
||
if seen_counts:
|
||
values.extend(numbers)
|
||
return values
|
||
|
||
|
||
def match_crew_table(
|
||
node: dict[str, Any],
|
||
table: dict[str, Any],
|
||
catalog: ResourceCatalog,
|
||
result: AxisResult,
|
||
unit: str,
|
||
) -> bool:
|
||
"""작업조 표를 자원 축 줄로 바꾼다. 그런 표가 아니면 `False`."""
|
||
if not output_columns(table):
|
||
return False
|
||
|
||
work_item_code = node.get("work_item_code", "")
|
||
table_id = str(table.get("pum_table_id", ""))
|
||
form = str(table.get("pum_form", ""))
|
||
from B09_Estimation.B09_Estimation_WorkItemUnit import unit_of
|
||
|
||
if not unit and not unit_of(work_item_code):
|
||
# 밑수가 「일당」이라 비어 있는 표(12-6) — 1단위당 품의 단위는 **시공량 열 머리**의 단위다.
|
||
# ⚠ 공종 단위가 따로 적힌 자리는 그대로 둔다(12-2 에 딸린 비계 표가 m 를 들고 옴).
|
||
headers = table.get("condition_note") or []
|
||
found = [
|
||
re.search(r"[((]\s*([^))]+?)\s*[))]", str(headers[i])) for i in output_columns(table)
|
||
]
|
||
unit = found[0].group(1) if found and found[0] else ""
|
||
parsed = parse_crew_table(table, catalog)
|
||
if isinstance(parsed, str):
|
||
result.unmatched.append(
|
||
UnmatchedRow(
|
||
work_item_code=work_item_code,
|
||
pum_table_id=table_id,
|
||
cell=_normalize(" | ".join(str(c) for c in (table.get("condition_note") or []))),
|
||
reason=f"작업조 표를 못 읽었습니다 — {parsed}",
|
||
)
|
||
)
|
||
return True # 다른 길로 보내지 않는다 — 행-자원으로 읽으면 인원을 소요량으로 오해한다
|
||
|
||
for index, (label, output) in enumerate(parsed.outputs):
|
||
if output <= 0:
|
||
continue
|
||
for member in parsed.members:
|
||
result.rows.append(
|
||
ResourceRow(
|
||
work_item_code=work_item_code,
|
||
pum_table_id=table_id,
|
||
pum_form=form,
|
||
resource_kind=member.kind,
|
||
resource_code=member.code,
|
||
resource_name=member.name,
|
||
resource_spec=member.spec,
|
||
amount=parsed.amount_of(member, output),
|
||
amount_unit=unit,
|
||
raw_row_index=index,
|
||
variant=label,
|
||
)
|
||
)
|
||
return True
|