Files
Aislo/B09_Estimation/B09_Estimation_CrewOutput.py
T
eomsangdonandClaude Opus 5 1c79fc288c fix(axis): 표 읽기 — 콘크리트 포장 12-6 두께별 작업조 · 인력 돌쌓기 13-4-1/4 두 단 머리
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
2026-09-13 21:42:23 +09:00

267 lines
11 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-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