섞인 본표 B-FP-09-19-01(보통인부 한 줄 2,925원) 걷힘 · 일위대가 나머지 452 제목 금액 변화 0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
172 lines
7.3 KiB
Python
172 lines
7.3 KiB
Python
"""B09 원가계산 — **사람이 모양을 읽어 둔 표** (자원 축 보조 · 2026-09-14 ⑤ 브레인 판정 Ⓐ~Ⓔ).
|
||
|
||
9-19-1 토사면 고르기(고시 2025-82 원문 L5407~5436)는 한 절에 표가 둘이고 둘 다 일반 길에 안 맞음.
|
||
|
||
F0288 1. 절토면 고르기 자원 이름이 **첫 자료 줄**(「보통인부 (인)」 …) · 규격은 [주]① 에만
|
||
· 「·」 = 그 토질엔 그 자원 없음
|
||
F0289 2. 성토면 고르기 병합 첫 칸(시공) 탓에 둘째 줄이 한 칸 앞당겨 옴
|
||
|
||
⚠ 일반 보정으로 넓히지 않음 — 밀린 줄 고쳐 읽기는 목재틀흙막이 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,),
|
||
"why": "원문 L5430 「2. 성토면 고르기 (단위: 10㎡당)」 — 시공 칸 병합으로 둘째 줄 앞당김",
|
||
},
|
||
}
|
||
|
||
#: 「보통인부 (인)」 의 단위 꼬리 — 규격이 아님.
|
||
_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)
|
||
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 [])]
|
||
)
|
||
entry = _entry(catalog, name, code, (size,) if size else ())
|
||
if entry is None:
|
||
# 그 갈래만 안 섬(자원이 하나뿐) — 사유는 못 맞춘 줄로(Ⓑ 굴착기 0.6㎥ 형식 둘).
|
||
label = f"{name} {size}".strip()
|
||
reason = unmatched_reason(catalog, name)
|
||
staged.append(UnmatchedRow(code, str(table.get("pum_table_id", "")), label, reason))
|
||
continue
|
||
staged.append(_row(code, table, entry, amount / basis, unit, index, variant))
|
||
return staged
|