· 판정표 공통 한 곳(_unread_rows) — 읽힌 줄 밖의 줄을 올림 · 넘치지 않게: 읽힌 줄 · 이미 못 맞춤 이름 · 다른 갈래가 쓴 기계 줄 · 자원 머리(첫 줄 머리 모양 · 횟수별) · 빈 줄은 뺌 · 분류 딱지 줄(자재·인력·기계)은 이름 칸으로 · 비고는 글 앞머리와 함께 · 손 사유와 안 겹치게: 공종 사유 한 줄에 이미 적힌 이름은 안 올림 · 오늘 손으로 단 날개벽·면벽·맨홀 줄 목록 사유는 걷고 [주] 30% 할증만 남김 · 새던 셋이 함께 닫힘 — 12-15 거푸집·철근·뚜껑·설치비 · 12-04 비고(수직고 7m 할증) · 12-34-1 레미콘 별도계상 판정표 9표 늘어난 줄: 9-19-1 0 · 12-04 +1 · 12-15 +4 · 12-12 +8 · 12-13 +6 · 12-16 +7 · 12-34-1 +1 · 제목 금액·막힘·일부만 그대로 · 잴 시험 넷 빨강→초록 · 끄기 넷 빨강 · 전체 시험 1702 + 362 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
373 lines
18 KiB
Python
373 lines
18 KiB
Python
"""B09 원가계산 — **사람이 모양을 읽어 둔 표** (자원 축 보조 · 2026-09-14 ⑤ 브레인 판정 Ⓐ~Ⓔ).
|
||
|
||
9-19-1 토사면 고르기(고시 2025-82 원문 L5407~5436)는 한 절에 표가 둘이고 둘 다 일반 길에 안 맞음.
|
||
|
||
F0288 1. 절토면 고르기 자원 이름이 **첫 자료 줄**(「보통인부 (인)」 …) · 규격은 [주]① 에만
|
||
· 「·」 = 그 토질엔 그 자원 없음
|
||
F0289 2. 성토면 고르기 병합 첫 칸(시공) 탓에 둘째 줄이 한 칸 앞당겨 옴
|
||
F0336 12-4 합판거푸집 기준수량(1회) × 사용횟수별 비율 — 재료 줄은 재료 %, 인력 줄은 노무비 %
|
||
(원문 L6187 · 2026-09-14 브레인 ㉮ — 비율 셈이 없어 막아 뒀던 표)
|
||
|
||
⚠ 일반 보정으로 넓히지 않음 — 밀린 줄 고쳐 읽기는 목재틀흙막이 503만원 전례(`_Transposed` 머리말).
|
||
**적어 둔 표만** 읽고, 칸이 판정과 다르면 한 줄도 안 세우고 막음.
|
||
⚠ 규격은 범위 별칭(`data_aliases` scope FP-09-19-01 · [주]①)이 코드로 이음 — 칸이 규격을 적었으면
|
||
별칭이 안 덮음(`scoped_alias_entry`). 성토면 굴착기 0.6㎥ 는 무한궤도·타이어 둘이라 규격 미정(Ⓑ).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
from B09_Estimation.B09_Estimation_ResourceAxis import (
|
||
AxisResult,
|
||
ResourceCatalog,
|
||
ResourceRow,
|
||
UnmatchedRow,
|
||
_resolve_cell,
|
||
parse_amount,
|
||
)
|
||
from B09_Estimation.B09_Estimation_ResourceAxis_Join import scoped_alias_entry, unmatched_reason
|
||
|
||
#: 표 번호 → (공종 · 모양 · 갈래 앞말 · 판정 근거). 여기 없는 표는 이 길로 안 읽음.
|
||
JUDGED_TABLES: dict[str, dict[str, Any]] = {
|
||
"F0288": {
|
||
"code": "FP-09-19-01",
|
||
"shape": "header_row",
|
||
"prefix": "절토면",
|
||
"why": "원문 L5414 「1. 절토면 고르기 (단위: 10㎡당)」 — 자원 머리가 첫 자료 줄",
|
||
},
|
||
"F0289": {
|
||
"code": "FP-09-19-01",
|
||
"shape": "merged_first",
|
||
"prefix": "성토면",
|
||
# 원문 L5430 표 — 「인력시공」 칸이 두 줄 병합이라 둘째 줄(모래 또는 사질토)이 앞당겨 옴.
|
||
"merged_rows": (1,),
|
||
# 기계시공 「굴착기 | 0.6㎥」 — 원문이 형식을 안 적음. 카탈로그 0.6 은 무한궤도·타이어
|
||
# 둘이라 형식마다 갈래 · 고르기는 B08 칸(제안 무한궤도 — 영월 「06M3 B/H」 · 브레인 ②).
|
||
"form_split": {"굴착기": ("무한궤도", "타이어")},
|
||
"why": "원문 L5430 「2. 성토면 고르기 (단위: 10㎡당)」 — 시공 칸 병합으로 둘째 줄 앞당김",
|
||
},
|
||
"F0336": {
|
||
"code": "FP-12-04",
|
||
"shape": "use_count",
|
||
"prefix": "합판거푸집",
|
||
"why": "원문 L6187 「기준수량(1회사용) · 사용횟수별기준수량에대한 비율(%) 재료별·노무비」",
|
||
},
|
||
"F0353": {
|
||
"code": "FP-12-15",
|
||
"shape": "remark_labor",
|
||
"prefix": "집수정",
|
||
# 구체콘크리트는 바로 아래 다짐기 줄과 한 갈래 — 다짐기가 안 풀리면 갈래를 안 세움(⑴).
|
||
"needs_machine": {"구체콘크리트": "다짐:봉상후렉시블(45mm)"},
|
||
"why": "원문 L6460 「구체·버림콘크리트 ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
|
||
},
|
||
# 12-15 와 같은 모양 셋 — 같은 봉상후렉시블 줄 하나가 셋을 막고 있었음(2026-09-14 브레인 · 672 다음).
|
||
"F0350": {
|
||
"code": "FP-12-12",
|
||
"shape": "remark_labor",
|
||
"prefix": "날개벽",
|
||
"needs_machine": {"콘크리트": "다짐:봉상후렉시블(45mm)"},
|
||
"why": "원문 L6419 12-12 「콘크리트(레미콘) ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
|
||
},
|
||
"F0351": {
|
||
"code": "FP-12-13",
|
||
"shape": "remark_labor",
|
||
"prefix": "면벽",
|
||
"needs_machine": {"콘크리트": "다짐:봉상후렉시블(45mm)"},
|
||
"why": "원문 L6436 12-13 「콘크리트(레미콘) ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
|
||
},
|
||
"F0354": {
|
||
"code": "FP-12-16",
|
||
"shape": "remark_labor",
|
||
"prefix": "맨홀",
|
||
# 칸이 하나 밀려 비고가 끝 칸이 아님(「구체콘크리트 | 철근 | ㎥ | | 비고 | 」).
|
||
"needs_machine": {"구체콘크리트": "봉상후렉시블(45mm)"},
|
||
"why": "원문 L6474 12-16 「구체·버림콘크리트 ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
|
||
},
|
||
# 12-34-1 — 머리 「(단위: 개소당)」 이나 인력 0.17·0.29 가 12-16 맨홀 ㎥당과 같고 기계가 Q ㎥/hr
|
||
# ⇒ ㎥당으로 읽음 · 「봉상후렉시블 대 2」 는 엔진식 진동기(엔진+플렉시블 한 대)의 봉(2026-09-14 브레인).
|
||
"F0385": {
|
||
"code": "FP-12-34-01",
|
||
"shape": "per_m3_rows",
|
||
"prefix": "콘크리트 타설",
|
||
# 엔진식 진동기(건설품셈 8-3 (4611) 엔진+플렉시블 한 대)의 봉 — 「진동기(3.5HP) 대 2」 로 셈.
|
||
"same_machine": ("봉상후렉시블(45mm)",),
|
||
"why": "원문 L6839 12-34-1 「인력 콘크리트공·보통인부 인 · 기계 대 (Q=5.4㎥/hr)」",
|
||
},
|
||
}
|
||
#: 줄 첫 칸이 분류 딱지인 표(12-34-1 「자재 | 콘크리트(레미콘)」) — 이름은 다음 칸.
|
||
_ROW_CATEGORIES = ("자재", "인력", "기계")
|
||
#: 자원이 아닌 머리 줄(12-04 「횟수별 | 재료별(%) | 노무비(%)」).
|
||
_HEADER_ROWS = ("횟수별", "구분")
|
||
UNREAD_REASON = "판정표가 안 읽은 줄 — 이 일위대가에 안 넣음(자동 · 2026-09-14)"
|
||
|
||
|
||
def _loose(text: str) -> str:
|
||
"""겹침 비교용 — 빈칸·괄호·가운뎃점·쉼표를 뺌(「적사(굴착기 0.7㎥)」 ↔ 「적사 굴착기 0.7㎥」)."""
|
||
return re.sub(r"[\s()·,:]", "", str(text))
|
||
|
||
|
||
def _unread_rows(code, table_id, judged, rows, staged) -> list:
|
||
"""읽힌 줄 밖의 줄을 「못 붙은 줄」 로 — 표를 넣을 때마다 손으로 사유를 안 달아도 안 샘.
|
||
|
||
㉠ 빼는 것: 읽힌 줄(`raw_row_index`) · 이미 못 맞춤으로 선 이름 · 다른 갈래가 쓴 기계 줄
|
||
(`needs_machine`·`same_machine`) · 자원 머리(`header_row` 첫 줄 · 「횟수별」) · 빈 줄
|
||
㉡ 손 사유(`known_gap_note`)가 이미 적은 이름은 안 올림 — 같은 말이 두 번 안 뜨게
|
||
"""
|
||
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
|
||
|
||
read = {item.raw_row_index for item in staged if isinstance(item, ResourceRow)}
|
||
taken = {_loose(item.cell) for item in staged if isinstance(item, UnmatchedRow)}
|
||
taken |= {_loose(name) for name in judged.get("needs_machine", {}).values()}
|
||
taken |= {_loose(name) for name in judged.get("same_machine", ())}
|
||
hand = _loose(known_gap_note(code))
|
||
unread: list = []
|
||
for index, cells in enumerate(rows):
|
||
cells = [c for c in cells]
|
||
if index in read or not any(cells) or (judged["shape"] == "header_row" and index == 0):
|
||
continue
|
||
name = cells[1] if cells[0] in _ROW_CATEGORIES and len(cells) > 1 else cells[0]
|
||
key = _loose(name)
|
||
if not key or key in _HEADER_ROWS or key in taken or key in hand:
|
||
continue
|
||
if key == "비고":
|
||
name = f"비고 — {' '.join(' '.join(cells[1:]).split())[:40]}…"
|
||
unread.append(UnmatchedRow(code, table_id, " ".join(name.split()), UNREAD_REASON))
|
||
taken.add(key)
|
||
return unread
|
||
|
||
|
||
#: 비고 칸 인력 — 「콘크리트공0.24인/㎥, 보통인부 0.42인/㎥」.
|
||
_RE_REMARK_LABOR = re.compile(r"([가-힣]+)\s*(\d+(?:\.\d+)?)\s*인/㎥")
|
||
_RE_Q = re.compile(r"Q\s*=\s*(\d+(?:\.\d+)?)")
|
||
#: 비율 줄 — 「1회사용시 2회사용시 …」 칸.
|
||
_RE_USE_COUNT = re.compile(r"(\d+)회사용시")
|
||
#: 값으로 안 읽는 줄 — 사용고재 평가기준(원문이 셈을 안 줌 · 사유는 `KNOWN_GAPS`) · 비고.
|
||
_NOT_RESOURCE_ROWS = ("사용고재평가기준", "비고", "횟수별")
|
||
|
||
#: 「보통인부 (인)」 의 단위 꼬리 — 규격이 아님.
|
||
_UNIT_TAIL = re.compile(r"\s*[((](?:인|시간)[))]\s*$")
|
||
_ABSENT = frozenset({"·", "ㆍ", "-", "-", "–"})
|
||
|
||
|
||
def _entry(catalog: ResourceCatalog, name_cell: str, code: str, side: tuple[str, ...] = ()):
|
||
return scoped_alias_entry(catalog, name_cell, code, side) or _resolve_cell(
|
||
catalog, name_cell, [name_cell, *side]
|
||
)
|
||
|
||
|
||
def _row(
|
||
code: str, table: dict[str, Any], entry, amount: Decimal, unit: str, index: int, variant: str
|
||
):
|
||
return ResourceRow(
|
||
work_item_code=code,
|
||
pum_table_id=str(table.get("pum_table_id", "")),
|
||
pum_form=str(table.get("pum_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=variant,
|
||
)
|
||
|
||
|
||
def match_judged_table(
|
||
node: dict[str, Any],
|
||
table: dict[str, Any],
|
||
catalog: ResourceCatalog,
|
||
result: AxisResult,
|
||
basis_quantity: Decimal | None,
|
||
unit: str,
|
||
) -> bool:
|
||
"""적어 둔 표를 판정대로 읽음. 그 표가 아니면 `False`."""
|
||
code = str(node.get("work_item_code") or "")
|
||
table_id = str(table.get("pum_table_id", ""))
|
||
judged = JUDGED_TABLES.get(table_id)
|
||
if judged is None or judged["code"] != code:
|
||
return False
|
||
rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])]
|
||
|
||
def block(why: str) -> bool:
|
||
result.unmatched.append(UnmatchedRow(code, table_id, judged["prefix"], why))
|
||
result.partial_items[code] = why
|
||
return True
|
||
|
||
# 비고·Q 가 ㎥당을 적는 모양은 표 머리 밑수를 안 씀(12-12 날개벽은 「개소당」 머리조차 없음).
|
||
if basis_quantity in (None, 0) and judged["shape"] not in ("remark_labor", "per_m3_rows"):
|
||
return block("판정 표에 밑수가 없습니다")
|
||
if judged["shape"] == "header_row":
|
||
staged = _header_row(code, table, judged, rows, catalog, basis_quantity, unit)
|
||
elif judged["shape"] == "use_count":
|
||
staged = _use_count(code, table, rows, catalog, basis_quantity, unit)
|
||
elif judged["shape"] == "remark_labor":
|
||
staged = _remark_labor(code, table, judged, rows, catalog)
|
||
elif judged["shape"] == "per_m3_rows":
|
||
staged = _per_m3_rows(code, table, judged, rows, catalog)
|
||
else:
|
||
staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit)
|
||
if isinstance(staged, str):
|
||
return block(f"{staged} — 판정({judged['why']})과 칸이 달라 안 읽음")
|
||
staged = [*staged, *_unread_rows(code, table_id, judged, rows, staged)]
|
||
for item in staged:
|
||
if isinstance(item, UnmatchedRow):
|
||
result.unmatched.append(item)
|
||
else:
|
||
result.rows.append(item)
|
||
return True
|
||
|
||
|
||
def _header_row(code, table, judged, rows, catalog, basis, unit) -> list | str:
|
||
"""첫 줄 = 자원 머리 · 이후 줄 = [토질, 값 …]. 머리 자원을 하나라도 못 풀면 표째 막음."""
|
||
names = [_UNIT_TAIL.sub("", cell) for cell in (rows[0] if rows else []) if cell]
|
||
entries = [_entry(catalog, name, code) for name in names]
|
||
if len(names) < 2 or not all(entries):
|
||
missing = [name for name, entry in zip(names, entries) if entry is None]
|
||
return f"자원 머리 {missing or names} 을 못 풂"
|
||
staged: list = []
|
||
for index, cells in enumerate(rows[1:], start=1):
|
||
values, tail = cells[1 : 1 + len(names)], cells[1 + len(names) :]
|
||
readable = all(v in _ABSENT or parse_amount(v) is not None for v in values)
|
||
if not cells[0] or len(values) != len(names) or any(tail) or not readable:
|
||
return f"{index}째 줄 「{cells[0] if cells else ''}」 값 칸"
|
||
variant = f"{judged['prefix']} · {cells[0]}"
|
||
for value, entry in zip(values, entries):
|
||
if value in _ABSENT:
|
||
continue # 그 토질엔 이 자원이 안 듦
|
||
staged.append(
|
||
_row(code, table, entry, parse_amount(value) / basis, unit, index, variant)
|
||
)
|
||
return staged
|
||
|
||
|
||
def _merged_first(code, table, judged, rows, catalog, basis, unit) -> list | str:
|
||
"""[시공, 토질, 구분, 규격, 단위, 수량] — 병합 줄은 앞 줄 시공을 이어받아 한 칸 되돌림."""
|
||
width = len(table.get("condition_note") or [])
|
||
if width != 6:
|
||
return f"머리 {width} 칸"
|
||
lines: list[list[str]] = []
|
||
for index, cells in enumerate(rows):
|
||
if index in judged["merged_rows"]:
|
||
if not lines or cells[-1]:
|
||
return f"{index}째 줄 병합 칸"
|
||
cells = [lines[-1][0], *cells[:-1]]
|
||
lines.append(cells)
|
||
methods = [cells[0] for cells in lines]
|
||
staged: list = []
|
||
for index, cells in enumerate(lines):
|
||
if len(cells) != width:
|
||
return f"{index}째 줄 칸 {len(cells)}"
|
||
method, soil, name, size, _unit_word, value = cells
|
||
amount = parse_amount(value)
|
||
if not method or not name or amount is None:
|
||
return f"{index}째 줄 「{method}」 값 칸"
|
||
short = method.removesuffix("시공")
|
||
variant = " · ".join(
|
||
[judged["prefix"], short, *([soil] if methods.count(method) > 1 else [])]
|
||
)
|
||
side = (size,) if size else ()
|
||
forms = judged.get("form_split", {}).get(name)
|
||
for form in forms or (None,):
|
||
entry = _entry(catalog, f"{name}({form})" if form else name, code, side)
|
||
if entry is None:
|
||
# 그 갈래만 안 섬(자원이 하나뿐) — 사유는 못 맞춘 줄로.
|
||
label = f"{name} {size}".strip()
|
||
reason = unmatched_reason(catalog, name)
|
||
staged.append(UnmatchedRow(code, str(table.get("pum_table_id", "")), label, reason))
|
||
continue
|
||
named = f"{variant} · {form}" if form else variant
|
||
staged.append(_row(code, table, entry, amount / basis, unit, index, named))
|
||
return staged
|
||
|
||
|
||
def _use_count(code, table, rows, catalog, basis, unit) -> list | str:
|
||
"""[이름, 단위, 기준수량, …] + 비율 줄 하나 → 사용횟수마다 갈래(재료 % · 노무비 %)."""
|
||
ratio_row = next((cells for cells in rows if _RE_USE_COUNT.search(" ".join(cells))), None)
|
||
if ratio_row is None:
|
||
return "사용횟수 비율 줄"
|
||
counts = _RE_USE_COUNT.findall(" ".join(ratio_row))
|
||
number = re.compile(r"\d+(?:\.\d+)?")
|
||
material = [Decimal(x) for x in number.findall(ratio_row[4] if len(ratio_row) > 4 else "")]
|
||
labor = [Decimal(x) for x in number.findall(ratio_row[5] if len(ratio_row) > 5 else "")]
|
||
if not counts or not (len(counts) == len(material) == len(labor)):
|
||
return f"비율 칸 수(횟수 {len(counts)} · 재료 {len(material)} · 노무비 {len(labor)})"
|
||
staged: list = []
|
||
for index, cells in enumerate(rows):
|
||
name = "".join(cells[0].split()) if cells else ""
|
||
base = parse_amount(cells[2]) if len(cells) > 2 else None
|
||
if not name or name in _NOT_RESOURCE_ROWS or base is None:
|
||
continue
|
||
entry = _entry(catalog, cells[0], code)
|
||
if entry is None:
|
||
reason = unmatched_reason(catalog, cells[0])
|
||
staged.append(UnmatchedRow(code, str(table.get("pum_table_id", "")), cells[0], reason))
|
||
continue
|
||
ratios = labor if entry.kind == "labor" else material
|
||
for count, ratio in zip(counts, ratios):
|
||
amount = base * ratio / Decimal(100) / basis
|
||
staged.append(_row(code, table, entry, amount, unit, index, f"{count}회"))
|
||
return staged
|
||
|
||
|
||
def _remark_labor(code, table, judged, rows, catalog) -> list | str:
|
||
"""㎥ 줄 비고 칸의 인력(인/㎥)으로 갈래 — 다짐기가 딸린 갈래는 그 기계가 풀려야 세움."""
|
||
table_id = str(table.get("pum_table_id", ""))
|
||
by_name = {"".join(cells[0].split()): cells for cells in rows if cells}
|
||
staged: list = []
|
||
for index, cells in enumerate(rows):
|
||
labors = _RE_REMARK_LABOR.findall(" ".join(cells[3:])) # 비고가 끝 칸이 아닌 표(12-16)
|
||
if len(cells) < 3 or cells[2] != "㎥" or not labors:
|
||
continue
|
||
variant = cells[0].split("(")[0].strip()
|
||
pieces = []
|
||
for name, amount in labors:
|
||
entry = _entry(catalog, name, code)
|
||
if entry is None:
|
||
return f"{variant} 인력 「{name}」"
|
||
pieces.append((entry, Decimal(amount)))
|
||
machine_name = judged.get("needs_machine", {}).get(variant)
|
||
if machine_name:
|
||
machine_cells = by_name.get("".join(machine_name.split())) or []
|
||
found_q = _RE_Q.search(" ".join(machine_cells))
|
||
entry = _entry(catalog, machine_name, code) if machine_cells else None
|
||
if entry is None or found_q is None:
|
||
reason = unmatched_reason(catalog, machine_name)
|
||
staged.append(UnmatchedRow(code, table_id, machine_name, reason))
|
||
why = (
|
||
f"다짐기 「{machine_name}」 가 안 풀려 갈래를 안 세움 — 인력만이면 조립 줄이"
|
||
" 조용히 싸짐(2026-09-14 ㉯ ⑴)"
|
||
)
|
||
staged.append(UnmatchedRow(code, table_id, variant, why))
|
||
continue
|
||
pieces.append((entry, Decimal(1) / Decimal(found_q.group(1))))
|
||
for entry, amount in pieces:
|
||
staged.append(_row(code, table, entry, amount, "㎥", index, variant))
|
||
return staged
|
||
|
||
|
||
def _per_m3_rows(code, table, judged, rows, catalog) -> list | str:
|
||
"""「인」 칸 앞 이름 · 뒤 수(인/㎥) · 「대」 칸 앞 이름 · 뒤 대수 ÷ Q — 칸이 밀린 줄도 단위 칸으로 찾음."""
|
||
staged: list = []
|
||
for index, cells in enumerate(rows):
|
||
unit = next((i for i, c in enumerate(cells) if c in ("인", "대") and i > 0), None)
|
||
if unit is None:
|
||
continue
|
||
name = cells[unit - 1]
|
||
if "".join(name.split()) in judged.get("same_machine", {}):
|
||
continue # 같은 기계 두 번 안 셈 — 까닭은 공종 사유 한 줄(`KnownGaps`)이 화면에 보임
|
||
amount = next((parse_amount(c) for c in cells[unit + 1 :] if parse_amount(c)), None)
|
||
entry = _entry(catalog, name, code)
|
||
if amount is None or entry is None:
|
||
return f"{name} 줄"
|
||
if cells[unit] == "대":
|
||
found_q = _RE_Q.search(" ".join(cells))
|
||
if found_q is None:
|
||
return f"{name} Q"
|
||
amount = amount / Decimal(found_q.group(1))
|
||
staged.append(_row(code, table, entry, amount, "㎥", index, ""))
|
||
return staged
|