diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index bc790a7f..2455f881 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -379,6 +379,14 @@ def match_table( basis_quantity = None if basis is None else Decimal(str(basis)) unit = table.get("basis_unit") or "" + # ⚠ **한 표에 밑수가 둘인 표가 있다** — 「㎡당 0.17 / ㎥당 0.64」(채집 13-2 계열). + # 행-자원으로 읽으면 **앞줄만 잡고 뒷줄을 버린다** — 막돌 채집이 ㎡당 값을 ㎥ 단위로 + # 달고 있었다(3.8 배 차이). 형태 판정보다 먼저 가른다. + from B09_Estimation.B09_Estimation_ResourceAxis_UnitBasis import match_unit_basis_table + + if match_unit_basis_table(node, table, catalog, result): + return + # ⚠ **자원이 열 머리에 오는 표가 따로 있다** (2026-09-08 발견, 39 표). # 「구 분 | 콘크리트공(인) | 보통인부(인)」처럼 **열이 자원**이고 행은 규격 갈래 # (무근·철근·소형구조물)다. 행을 자원으로 읽는 길로 보내면 통째로 못 맞춘다 — diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_UnitBasis.py b/B09_Estimation/B09_Estimation_ResourceAxis_UnitBasis.py new file mode 100644 index 00000000..f594434b --- /dev/null +++ b/B09_Estimation/B09_Estimation_ResourceAxis_UnitBasis.py @@ -0,0 +1,151 @@ +"""B09 자원 축 — **한 표에 밑수 단위가 둘인 표** (2026-09-08 발견). + +품셈에는 같은 품을 **㎡당과 ㎥당 두 벌**로 주는 표가 있다. + + 13-2-2 막돌 채집 (단위: ㎥당) + 보통인부 | ㎡당 | 0.17 + | ㎥당 | 0.64 + + 13-2-4 야면석 채집(인력) 뒷길이(㎝) 25 35 45 55 60 + 인부 | ㎡당 0.11 0.17 0.22 0.28 0.36 + | ㎥당 0.60 0.64 0.67 0.70 0.80 + +⚠ **행-자원으로 읽으면 앞줄만 잡고 뒷줄을 버린다.** 실제로 막돌 채집이 **㎡당 0.17 을 +쓰면서 단위는 ㎥** 로 서 있었다(㎥당은 0.64 — **3.8 배**). 야면석은 열이 다섯이라 아예 +안 섰다. 둘 다 조용히 틀린 자리다. + +**그래서 밑수마다 따로 세운다.** 갈래 이름에 밑수를 넣고(`#45㎝·㎥당`), 그 갈래의 +**단위를 그 밑수로** 준다 — 그러면 내역서의 단위 불일치 검사가 **엉뚱한 밑수를 걸러 준다**. + +⚠ **밑수 표기가 하나뿐인 표는 건드리지 않는다.** 그런 표는 여태 하던 길이 맞고, +넓게 잡으면 멀쩡한 표까지 갈래가 둘로 쪼개진다. +""" + +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, + parse_amount, + split_name_and_spec, +) + +#: 「㎡당」·「㎥당」처럼 **밑수를 말하는 칸**. 앞에 수가 붙으면(「10㎡당」) 여기가 아니라 +#: 밑수(basis_quantity) 자리라 건드리지 않는다. +_RE_BASIS = re.compile(r"^(㎡|㎥|㏊|m|㎝|개|본|주|ton|톤|kg|㎏)\s*당$") + +#: 밑수 표기 → 그 갈래가 갖는 단위. +_BASIS_UNIT = {"㏊": "ha", "톤": "ton", "㎏": "kg"} + + +def _tight(text: str) -> str: + return "".join(str(text or "").split()) + + +def _basis_of(cell: str) -> str | None: + """그 칸이 밑수 표기면 단위, 아니면 `None`.""" + found = _RE_BASIS.match(_tight(cell)) + if not found: + return None + unit = found.group(1) + return _BASIS_UNIT.get(unit, unit) + + +def _column_labels(table: dict[str, Any]) -> list[str]: + """열 머리가 갈래인 표의 갈래 이름들 — 「25 35 45 55 60」. 없으면 빈 목록.""" + headers = [_tight(c) for c in (table.get("condition_note") or [])] + labels = [cell for cell in headers[1:] if cell and parse_amount(cell) is not None] + return labels + + +def match_unit_basis_table( + node: dict[str, Any], + table: dict[str, Any], + catalog: ResourceCatalog, + result: AxisResult, +) -> bool: + """밑수가 둘 이상인 표를 밑수별 갈래로 편다. 그런 표가 아니면 `False`.""" + rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])] + if not rows: + return False + + # 밑수 표기가 **둘 이상** 있어야 이 길이다 — 하나뿐이면 여태 하던 길이 맞다. + seen_basis = {b for row in rows for cell in row if (b := _basis_of(cell))} + if len(seen_basis) < 2: + return False + + work_item_code = str(node.get("work_item_code", "")) + table_id = str(table.get("pum_table_id", "")) + form = str(table.get("pum_form", "")) + labels = _column_labels(table) + + entry = None + made = 0 + for index, cells in enumerate(rows): + basis_at = next((i for i, cell in enumerate(cells) if _basis_of(cell)), None) + if basis_at is None: + continue + if basis_at > 0: + # 이름이 앞에 붙은 줄 — 「인 부 | ㎡당 | 0.11 | …」. 이름을 여기서 갱신한다. + found = _resolve(catalog, cells[0]) + if found is None: + result.unmatched.append( + UnmatchedRow( + work_item_code=work_item_code, + pum_table_id=table_id, + cell=cells[0][:30], + reason="밑수 둘인 표인데 자원 이름을 못 풀었습니다", + ) + ) + return True # 다른 길로 보내지 않는다 — 앞줄만 잡고 뒷줄을 버리게 된다 + entry = found + if entry is None: + continue + + unit = _basis_of(cells[basis_at]) + values = [parse_amount(cell) for cell in cells[basis_at + 1 :]] + values = [v for v in values if v is not None] + if not values: + continue + + for position, amount in enumerate(values): + if labels and position >= len(labels): + break # 비고 칸까지 값으로 읽지 않는다 + label = labels[position] if labels else "" + # ⚠ 갈래 이름에 **밑수를 함께** 적는다 — 안 적으면 ㎡당과 ㎥당이 같은 갈래로 + # 뭉쳐 하나가 덮인다(막돌 채집이 그렇게 ㎡당 값을 ㎥ 단위로 달고 있었다). + variant = f"{label}㎝·{unit}당" if label else f"{unit}당" + result.rows.append( + ResourceRow( + work_item_code=work_item_code, + pum_table_id=table_id, + pum_form=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, + ) + ) + made += 1 + return made > 0 + + +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