From 1c79fc288cf54bfe8b51f68eda776344aeac6ae8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 21:42:23 +0900 Subject: [PATCH] =?UTF-8?q?fix(axis):=20=ED=91=9C=20=EC=9D=BD=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20=EC=BD=98=ED=81=AC=EB=A6=AC=ED=8A=B8=20=ED=8F=AC?= =?UTF-8?q?=EC=9E=A5=2012-6=20=EB=91=90=EA=BB=98=EB=B3=84=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=EC=A1=B0=20=C2=B7=20=EC=9D=B8=EB=A0=A5=20=EB=8F=8C?= =?UTF-8?q?=EC=8C=93=EA=B8=B0=2013-4-1/4=20=EB=91=90=20=EB=8B=A8=20?= =?UTF-8?q?=EB=A8=B8=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- B09_Estimation/B09_Estimation_CrewOutput.py | 26 ++++ B09_Estimation/B09_Estimation_ResourceAxis.py | 7 + ..._Estimation_ResourceAxis_TwoLevelHeader.py | 126 ++++++++++++++++++ resources/tester/test_b09_table_reading.py | 57 ++++++++ 4 files changed, 216 insertions(+) create mode 100644 B09_Estimation/B09_Estimation_ResourceAxis_TwoLevelHeader.py diff --git a/B09_Estimation/B09_Estimation_CrewOutput.py b/B09_Estimation/B09_Estimation_CrewOutput.py index 81bc628b..370d5225 100644 --- a/B09_Estimation/B09_Estimation_CrewOutput.py +++ b/B09_Estimation/B09_Estimation_CrewOutput.py @@ -35,6 +35,8 @@ from B09_Estimation.B09_Estimation_ResourceAxis import ( ) _RE_NUMBER = re.compile(r"^\d+(?:,\d{3})*(?:\.\d+)?$") +#: 두께 갈래 칸 — 「20㎝」. 넓히면 규격 칸(「0.6㎥」)을 갈래로 오해한다. +_RE_THICKNESS = re.compile(r"^\d+(?:\.\d+)?\s*(?:㎝|cm)$") #: 시공량 열을 알아보는 말. 「시 공 량 (㎡)」처럼 띄어쓰기가 섞여 온다. _OUTPUT_WORDS = ("시공량", "기준시공량", "적용시공량") @@ -101,6 +103,7 @@ def parse_crew_table( 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]: @@ -117,6 +120,11 @@ def parse_crew_table( 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: @@ -143,9 +151,17 @@ def parse_crew_table( # 시공량은 보통 **첫 작업조 줄**에 붙어 온다. 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): @@ -206,6 +222,16 @@ def match_crew_table( 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( diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index e65014c9..5164bb8f 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -446,6 +446,13 @@ def match_table( if match_process_sum_table(node, table, catalog, result, basis_quantity, unit): return + # 머리가 두 단으로 병합된 표(인력 돌쌓기 13-4-1·13-4-4) — hwpx 병합으로 확인한 공종만. + from B09_Estimation.B09_Estimation_ResourceAxis_TwoLevelHeader import ( + match_two_level_header_table, + ) + + if match_two_level_header_table(node, table, catalog, result, basis_quantity, unit): + return from B09_Estimation.B09_Estimation_ResourceAxis_Transposed import match_transposed_table diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_TwoLevelHeader.py b/B09_Estimation/B09_Estimation_ResourceAxis_TwoLevelHeader.py new file mode 100644 index 00000000..0eb28bcc --- /dev/null +++ b/B09_Estimation/B09_Estimation_ResourceAxis_TwoLevelHeader.py @@ -0,0 +1,126 @@ +"""B09 원가계산 — **머리가 두 단으로 병합된 표** 읽기 (자원 축 보조, 2026-09-13). + +인력 돌쌓기 13-4-1 메쌓기 · 13-4-4 찰쌓기 — hwpx 원본의 병합 칸으로 머리 모양을 확인함. + + 뒷길이(㎝) | 견치돌(4칸) | 깬돌(4칸) | 깬잡석(4칸) | 호박돌 및 야면석(2칸·두 줄 병합) + | 골쌓기 켜쌓기 | 골쌓기 켜쌓기 | 골쌓기 켜쌓기 | + | 석공 | 보통인부 × 7 벌 + 35 | 0.50 | 0.40 | 0.55 | 0.44 | … | 0.16 | 0.14 + +마스터는 병합을 풀어 칸만 늘어놓아 **어느 값이 어느 돌·쌓기인지**가 사라졌다 — 행 읽기 길은 +「값 묶음 0 개가 갈래 6 개와 안 맞습니다」로 표째 버렸다. + +읽는 법 — 자원 줄의 되풀이 폭(석공·보통인부 = 2)으로 **잎 칸 수**(14 ÷ 2 = 7)를 얻고, +소분류 줄의 되풀이(골·켜 × 3)가 **앞의 대분류 셋**을 두 잎씩 먹고, 남은 대분류가 한 잎씩 먹는다. +⚠ 「앞에서부터」는 hwpx 병합으로 확인한 두 표에만 참이다 — **적어 둔 공종만** 읽고, + 칸 수가 딱 맞지 않으면 한 줄도 안 세우고 막는다. +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_ResourceAxis import ( + AxisResult, + ResourceCatalog, + ResourceRow, + UnmatchedRow, + parse_amount, +) + +#: 공종 → 확인 근거. 여기 없는 공종은 이 길로 안 읽는다. +TWO_LEVEL_HEADER: dict[str, str] = { + "FP-13-04-01": "hwpx 병합 — 견치돌·깬돌·깬잡석 4칸(골·켜) · 호박돌 및 야면석 2칸(행 병합)", + "FP-13-04-04": "hwpx 병합 — 견치돌·깬돌·깬잡석 4칸(골·켜) · 호박돌 및 야면석 2칸(행 병합)", +} +_ABSENT = ("-", "-", "–", "—") + + +def _label(text: Any) -> str: + """「견 치 돌」처럼 벌려 쓴 한 낱말은 붙이고, 「호박돌 및 야면석」은 그대로.""" + parts = str(text or "").split() + return "".join(parts) if parts and all(len(p) == 1 for p in parts) else " ".join(parts) + + +def _period(items: list[str]) -> int: + """되풀이 폭 — 딱 나뉘는 가장 짧은 주기. 없으면 전체 길이.""" + for width in range(1, len(items) + 1): + if len(items) % width == 0 and items == items[:width] * (len(items) // width): + return width + return len(items) + + +def match_two_level_header_table( + node: dict[str, Any], + table: dict[str, Any], + catalog: ResourceCatalog, + result: AxisResult, + basis_quantity: Decimal | None, + unit: str, +) -> bool: + """적어 둔 공종의 두 단 머리 표를 읽는다. 그 공종이 아니면 `False`.""" + from B09_Estimation.B09_Estimation_CrewOutput import _resolve + from B09_Estimation.B09_Estimation_ResourceAxis_ThreeAxis import _axis_label + + code = str(node.get("work_item_code") or "") + if code not in TWO_LEVEL_HEADER: + return False + table_id = str(table.get("pum_table_id", "")) + head = list(table.get("condition_note") or []) + 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, " | ".join(head), why)) + result.partial_items[code] = why + return True + + names = [c for c in rows[1] if c] if len(rows) >= 3 else [] + entries = [_resolve(catalog, "".join(name.split())) for name in names] + if len(head) < 3 or len(names) < 2 or not all(entries): + # 같은 절에 딸린 다른 표(뒷길이 표준·표준경사)다 — 원래 길로 보낸다. + return False + if basis_quantity in (None, 0): + return block("두 단 머리 표에 밑수가 없습니다") + groups = [_label(g) for g in head[1:]] + subs = [_label(c) for c in rows[0] if c] + width = _period([e.code for e in entries]) + leaves = len(names) // width + cycle = _period(subs) + split = len(subs) // cycle + if split > len(groups) or split * cycle + (len(groups) - split) != leaves: + return block(f"대분류 {len(groups)} · 소분류 {len(subs)} · 잎 {leaves} 칸이 딱 안 나뉩니다") + leaf_labels = [[g, s] for g in groups[:split] for s in subs[:cycle]] + leaf_labels += [[g] for g in groups[split:]] + + staged: list[ResourceRow] = [] + for index, cells in enumerate(rows[2:], start=2): + values = [c for c in cells[1:] if c] + if not cells or not cells[0]: + continue + readable = all(v in _ABSENT or parse_amount(v) is not None for v in values) + if len(values) != len(names) or not readable: + return block( + f"뒷길이 {cells[0]} 줄의 값 {len(values)} 칸이 자원 {len(names)} 칸과 안 맞습니다" + ) + for position, (value, entry) in enumerate(zip(values, entries)): + if value in _ABSENT: + continue # 그 돌·뒷길이엔 품이 없다 + labels = leaf_labels[position // width] + staged.append( + ResourceRow( + work_item_code=code, + pum_table_id=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=parse_amount(value) / basis_quantity, + amount_unit=unit, + raw_row_index=index, + variant=" · ".join([*labels, _axis_label(_label(head[0]), cells[0])]), + ) + ) + result.rows.extend(staged) + return True diff --git a/resources/tester/test_b09_table_reading.py b/resources/tester/test_b09_table_reading.py index d49d576b..6ef9bde1 100644 --- a/resources/tester/test_b09_table_reading.py +++ b/resources/tester/test_b09_table_reading.py @@ -144,3 +144,60 @@ def test_단끊기는_공정_줄을_더하고_작업반장을_20인당_1인_더 other = AxisResult() match_table({"work_item_code": "FP-13-12-01"}, _단끊기, catalog, other) assert not other.rows + + +_STONE = [*MACHINES, CatalogEntry("1033", "석공", "labor"), CatalogEntry("1019", "포장공", "labor")] + + +def test_인력_찰쌓기_두_단_머리는_hwpx_병합대로_돌과_쌓기를_가른다(): + """앞 세 돌은 골·켜 두 잎씩, 호박돌 및 야면석은 한 잎(행 병합) — 7 잎 × 석공·보통인부.""" + table = { + "pum_table_id": "F-찰쌓기", + "pum_form": "requirement", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "condition_note": ["뒷길이 (㎝)", "견 치 돌", "깬 돌", "깬 잡 석", "호박돌 및 야면석"], + "raw_row": [ + ["골쌓기", "켜쌓기", "골쌓기", "켜쌓기", "골쌓기", "켜쌓기", "", ""], + ["석 공 (인)", "보통인부 (인)"] * 7 + [""], + ["25", *["-"] * 8, "0.12", "0.12", "0.10", "0.10", "0.08", "0.10"], + ["35", "0.40", "0.40", "0.44", "0.44", "0.24", "0.24", "0.22", "0.22"] + + ["0.20", "0.20", "0.18", "0.18", "0.11", "0.14"], + ], + } + result = AxisResult() + match_table({"work_item_code": "FP-13-04-04"}, table, ResourceCatalog(entries=_STONE), result) + assert _codes(result, "견치돌 · 켜쌓기 · 뒷길이 35㎝") == { + "1033": Decimal("0.44"), + "1002": Decimal("0.44"), + } + assert _codes(result, "호박돌 및 야면석 · 뒷길이 35㎝") == { + "1033": Decimal("0.11"), + "1002": Decimal("0.14"), + } + assert not _codes(result, "견치돌 · 골쌓기 · 뒷길이 25㎝") # 「-」 — 그 뒷길이엔 품이 없음 + assert "FP-13-04-04" not in result.partial_items + + +def test_콘크리트_포장_작업조는_두께마다_시공량으로_나눈다(): + """12-6 — 두께 칸이 작업조 칸과 엇갈려 병합(hwpx) · 후진 진입 칸은 「50%까지 감」 범위.""" + table = { + "pum_table_id": "F-포장", + "pum_form": "reference", + "condition_note": ["배치인원(인)", "포장두께", "시공량(㎥)"], + "raw_row": [ + [ + "콘크리트믹서트럭 직접타설인경우", + "콘크리트믹서트럭 후진 진입 또는 경운기 등으로 운반인 경우", + ], + ["포장공", "3", "20㎝", "100", "좌측 시공량의 50%까지 감하여 적용한다."], + ["30㎝", "150", "", "", ""], + ["보통인부", "3", "", "", ""], + ["40㎝", "200", "", "", ""], + ], + } + result = AxisResult() + match_table({"work_item_code": "FP-12-06"}, table, ResourceCatalog(entries=_STONE), result) + assert _codes(result, "20㎝") == {"1019": Decimal("0.03"), "1002": Decimal("0.03")} + assert _codes(result, "40㎝")["1019"] == Decimal("0.015") + assert {r.amount_unit for r in result.rows} == {"㎥"} # 시공량 열 머리의 단위