품셈에는 소요량 대신 **「작업조 몇 인이 하루 몇 ㎡」**로 주는 표가 11건 있음. 1단위당 품 = 인원 ÷ 시공량. 행-자원으로 읽으면 **인원 4를 소요량 4로** 오해해 35배 부풀어, 형태 판정보다 **먼저** 가름 (유로폼 12-38-3 은 마스터에서 `reference` 로 찍혀 형태 필터에 버려지고 있었음) - 유로폼 설치·해체: 형틀목공 4 ÷ 35 = 0.1143인/㎡, 보통인부 1 ÷ 35 = 0.0286인/㎡ → 유형별 갈래로 세움 (복잡 51,009.1 · 보통 36,435.0 · 간단 31,880.7 원/㎡) - 유형은 우리가 안 고름 — 품셈 12-38-3 [유형] 이 「보통: 측구·수로·옹벽」으로 정해 두었으므로 갈래로 세우고 고르는 것은 B08 (철근 12-3 [주]① 과 같은 모양) - ⚠ 못 푼 작업조 줄이 있으면 **표째 버림** — 평떼 시비에서 「트럭 2.5ton 1대」가 조용히 빠지고 노무만으로 28.6원/㎡ 이 서 있었음(분포 최소값이 233 → 28.5 로 떨어진 것으로 발견) - 이름 안에 공백이 든 줄(「비 계 공」)은 한 이름으로 먼저 시도 — 공백을 구분자로만 보면 그런 표가 통째로 버려짐 결과: 자원 축 246 → 256, 일위대가 139 → 145, 최소값 233.4 유지 검증: pytest 181 통과(신규 6 — 손계산 대조 + 오탐 짝 시험 포함) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
241 lines
8.8 KiB
Python
241 lines
8.8 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+)?$")
|
|
#: 시공량 열을 알아보는 말. 「시 공 량 (㎡)」처럼 띄어쓰기가 섞여 온다.
|
|
_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] = []
|
|
|
|
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):
|
|
# 자원이 아니면 유형 라벨 줄로 본다 — 「복 잡 | 보 통 | 간 단」.
|
|
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)
|
|
|
|
if not members:
|
|
return "작업조 줄을 못 찾았습니다"
|
|
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", ""))
|
|
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
|