Files
Aislo/B09_Estimation/B09_Estimation_ResourceAxis_JudgedTable.py
T

222 lines
10 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-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회사용) · 사용횟수별기준수량에대한 비율(%) 재료별·노무비」",
},
}
#: 비율 줄 — 「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
if basis_quantity in (None, 0):
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)
else:
staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit)
if isinstance(staged, str):
return block(f"{staged} — 판정({judged['why']})과 칸이 달라 안 읽음")
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