Files
Aislo/B09_Estimation/B09_Estimation_CrewOutput.py
eomsangdonandClaude Opus 5 a8f0248bf7 feat(B09): 작업조+시공량 표 읽기 — 유로폼 노무 성립
품셈에는 소요량 대신 **「작업조 몇 인이 하루 몇 ㎡」**로 주는 표가 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>
2026-09-08 01:42:20 +09:00

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