From e0081cd953de52042273f769ac43535078616420 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 09:03:49 +0900 Subject: [PATCH] =?UTF-8?q?fix(B09):=20=EC=A0=9C=EA=B7=BC=20=EC=9D=B4?= =?UTF-8?q?=EC=A4=91=EA=B3=84=EC=83=81=20=E2=80=94=20=ED=91=9C=EA=B0=80=20?= =?UTF-8?q?=E3=80=8C=EA=B3=A0=EB=A5=B4=EB=9D=BC=E3=80=8D=EA=B3=A0=20?= =?UTF-8?q?=EB=91=94=20=EC=9E=A5=EB=B9=84=20=EB=B8=94=EB=A1=9D=EC=9D=84=20?= =?UTF-8?q?=EB=8B=A4=20=EB=8D=94=ED=95=98=EA=B3=A0=20=EC=9E=88=EC=97=88?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 품셈 9-21 제근은 장비 규격 두 갈래(0.2㎥·0.7㎥)를 **고르라고** 둔 표인데 둘 다 붙고 보통인부도 두 번 붙어 단가가 **128,039원**으로 서 있었음. 밑수가 원문에 없어 아직 금액이 안 서 있었을 뿐, **밑수가 정해지는 날 조용히 두 배로 설 자리**였음. - `B09_Estimation_ResourceAxis_ChooseOne` 신설 — 블록마다 갈래 하나, 열마다 갈래 하나. 장비 2 × 임목밀도 3 = **여섯 갈래**로 세움 (55,625.9 ~ 111,161.4원) - ⚠ **「고르는 표」를 좁게 가름** — 기계 줄이 둘이라고 다 그런 표가 아님. 9-13 암절취는 깨기(브레이커)와 들어내기(백호우)가 **함께 듦**. 자국은 **같은 이름에 규격만 다른 것**. 품셈도 그렇게 말함 — 9-20-1 [주]④ 「0.2㎥ 또는 0.4㎥ … 계수를 달리 적용」 - ⚠ 밑수는 만들지 않음 — 9-21 은 표 머리·제목·[주] 어디에도 밑수 표기가 없음 (앞 절 9-20-1 「(10주당)」·9-20-2 「(단위: 10주당)」과 대비되는 자리) ⚠ 짓다 잡은 것 — 기종 해석이 **넘겨받은 카탈로그를 무시**하고 기계 카탈로그를 직접 봐서, 노무만 든 카탈로그로 돌릴 때도 기계 줄이 나왔음. 카탈로그에 기계가 없으면 이 표를 안 읽게 막음(시험이 그것을 잡았음). ⚠ 시험 하나가 **버그를 맞는 것으로 적어 두고 있었음** — 「제근은 보통인부 줄이 둘이다」. 그것이 곧 이중계상이었음. 뒤집어 다시 적었음. 검증: pytest 295 통과(신규 4) · 자원 축 539 → 547 줄, 늘어난 8 줄은 전부 제근 Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_ResourceAxis.py | 9 + .../B09_Estimation_ResourceAxis_ChooseOne.py | 200 ++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index 5a336691..ac9add90 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -378,6 +378,15 @@ def match_table( if match_three_axis_table(node, table, catalog, result): return + # ⚠ **「둘 중 하나를 고르는」 장비 블록 표도 먼저 가른다.** 그냥 읽으면 블록을 다 더해 + # 장비 두 대·인부 두 몫이 서서 대략 두 배가 된다(2026-09-09 제근 128,039원). + from B09_Estimation.B09_Estimation_ResourceAxis_ChooseOne import ( + match_choose_one_machine_table, + ) + + if match_choose_one_machine_table(node, table, catalog, result): + return + form = table.get("pum_form", "") if form in NON_WORK_ITEM_FORMS or form in UNUSABLE_FORMS or form not in USABLE_FORMS: result.skipped_forms[form] = result.skipped_forms.get(form, 0) + 1 diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py b/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py new file mode 100644 index 00000000..11314660 --- /dev/null +++ b/B09_Estimation/B09_Estimation_ResourceAxis_ChooseOne.py @@ -0,0 +1,200 @@ +"""B09 원가계산 — **표가 「둘 중 하나를 고르라」고 둔 장비 블록** 읽기 (2026-09-09). + +품셈에는 같은 일을 **장비 규격에 따라 달리 세는** 표가 있다. 블록이 둘인데 **둘 다 더하면 +장비 두 대와 인부 두 몫이 서서 대략 두 배**가 된다. + + 9-21 제근 + | 종 류 | 명 칭 | 단위 | 소 | 중 | 밀 | + | 굴착기(무한궤도) | 굴착기(무한궤도,0.2㎥) | hr | 0.80 | 1.01 | 1.22 | ┐ 0.2㎥ 블록 + | 보통인부 | 인 | 0.03 | 0.04 | 0.05 | ┘ + | 굴착기(무한궤도,0.7㎥) | hr | 0.46 | 0.58 | 0.70 | ┐ 0.7㎥ 블록 + | 보통인부 | 인 | 0.03 | 0.04 | 0.05 | ┘ + +2026-09-09 실측: 제근 단가가 **128,039원**으로 서 있었다 — 굴착기 0.2·0.7 이 둘 다 붙고 +보통인부도 두 번 붙은 값이다. 밑수가 원문에 없어 아직 금액이 안 서 있었을 뿐, +**밑수가 정해지는 날 조용히 두 배로 설 자리**였다. + +읽는 법 — **블록마다 갈래 하나**, 열마다 갈래 하나. 둘을 곱해 갈래를 낸다. + + 갈래 = 「굴착기(무한궤도) 0.2㎥ · 소」 … 「굴착기(무한궤도) 0.7㎥ · 밀」 (2 × 3 = 6) + +⚠ **「고르는 표」인지 아닌지를 좁게 가른다.** 기계 줄이 둘이라고 다 고르는 표가 아니다 — +9-13 암절취는 「깨기(대형브레이커)」와 「들어내기(백호우)」가 **함께 드는** 표다. +가르는 자국은 **같은 기계 이름에 규격만 다른 것**이다(굴착기 0.2 vs 0.7). 품셈도 그렇게 +말한다 — 9-20-1 [주]④ 「0.2㎥ 또는 0.4㎥ 용량의 굴착기를 사용하는 경우에는 …적용계수를 +달리 적용하도록 한다」. + +⚠ **밑수는 여기서 만들지 않는다.** 9-21 은 표 머리·제목·[주] 어디에도 밑수가 없다 +(2026-09-09 원문 전수 확인). 갈래만 바로 세우고 밑수는 빈 채로 둔다. +""" + +from __future__ import annotations + +import re + +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_ResourceAxis import ( + AxisResult, + CatalogEntry, + ResourceCatalog, + ResourceRow, + UnmatchedRow, + parse_amount, + split_name_and_spec, +) + +_NUMBER = re.compile(r"^\d+(?:\.\d+)?$") + +#: 열 머리로 인정하지 않는 말 — 값이 아니라 설명이다. +_NOT_A_COLUMN = ("비고", "적요", "참고", "단위", "명칭", "명 칭", "종류", "종 류", "규격", "규 격") + + +def _clean(cell: Any) -> str: + return " ".join(str(cell or "").split()) + + +def _column_labels(header: list[Any]) -> list[str]: + """표 머리에서 **갈래 열 이름**만 골라 낸다 — 「소·중·밀」.""" + labels = [_clean(cell) for cell in header] + return [ + label + for label in labels + if label and "".join(label.split()) not in {"".join(w.split()) for w in _NOT_A_COLUMN} + ] + + +def _machine_of(cells: list[str], catalog: ResourceCatalog): + """그 줄이 기계 줄이면 (칸 번호, 기종, 원문 칸). 아니면 `None`. + + ⚠ 자원 카탈로그의 이름·규격 짝으로는 안 풀린다 — 품셈이 「굴착기(무한궤도,0.2㎥)」처럼 + **규격을 괄호 안에 몰아** 적기 때문이다. 기종 해석은 그 모양을 아는 쪽에 맡긴다. + """ + from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine + + machines = load_machine_catalog().machines + for index, cell in enumerate(cells): + found = resolve_machine(cell) + if found is None: + continue + machine = machines.get(found[0]) + if machine is None: + continue + entry = CatalogEntry( + code=found[0], name=machine.name, kind="machine", spec=str(machine.specification) + ) + return index, entry, cell + return None + + +def _values_of(cells: list[str], count: int) -> list[Decimal] | None: + """그 줄 끝에서 값 `count` 개. 개수가 안 맞으면 `None` — 짐작해 채우지 않는다.""" + numbers = [cell for cell in cells if _NUMBER.match(cell)] + if len(numbers) < count: + return None + picked = [parse_amount(cell) for cell in numbers[-count:]] + return None if any(value is None for value in picked) else picked # type: ignore[return-value] + + +def match_choose_one_machine_table( + node: dict[str, Any], + table: dict[str, Any], + catalog: ResourceCatalog, + result: AxisResult, +) -> bool: + """「둘 중 하나를 고르는」 장비 블록 표를 읽는다. 그런 표가 아니면 `False`. + + ⚠ 같은 기계 이름에 **규격만 다른** 블록이 둘 이상일 때만 내 표로 본다. + """ + # ⚠ **기계 카탈로그가 없는 조립에서는 이 표를 읽지 않는다.** 기종 해석은 기계 쪽 + # 카탈로그를 직접 보므로, 노무만 든 카탈로그로 돌릴 때도 기계 줄이 나와 버린다 + # (2026-09-09 시험이 그것을 잡았다). **넘겨받은 카탈로그의 결을 따른다.** + if not any(entry.kind == "machine" for entry in catalog.entries): + return False + + rows = [row for row in (table.get("raw_row") or []) if isinstance(row, list)] + header = list(table.get("condition_note") or []) + labels = _column_labels(header) + if len(rows) < 2 or len(labels) < 2: + return False + + blocks: list[dict[str, Any]] = [] + for row in rows: + cells = [_clean(cell) for cell in row] + if not cells: + continue + found = _machine_of(cells, catalog) + if found is not None: + _index, entry, raw_cell = found + values = _values_of(cells, len(labels)) + if values is None: + return False + blocks.append({"entry": entry, "cell": raw_cell, "rows": [], "values": values}) + continue + if not blocks: + continue + name, spec = split_name_and_spec(cells[0]) + entry = catalog.resolve(name, spec) + values = _values_of(cells, len(labels)) + if entry is None or values is None: + continue + blocks[-1]["rows"].append({"entry": entry, "values": values, "cell": cells[0]}) + + if len(blocks) < 2: + return False + # ⚠ **같은 이름 · 다른 규격**일 때만 「고르는 표」다. 이름이 다르면 함께 드는 장비다. + names = {block["entry"].name for block in blocks} + specs = {block["entry"].spec for block in blocks} + if len(names) != 1 or len(specs) != len(blocks): + 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", "")) + unit = table.get("basis_unit") or "" + + made = 0 + for block in blocks: + machine = block["entry"] + for column, label in enumerate(labels): + variant = f"{machine.name} {machine.spec} · {label}".strip() + entries = [(machine, block["values"][column], block["cell"])] + entries += [ + (item["entry"], item["values"][column], item["cell"]) for item in block["rows"] + ] + for entry, amount, cell in entries: + 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=0, + variant=variant, + ) + ) + made += 1 + del cell + + if made == 0: + return False + result.unmatched.append( + UnmatchedRow( + work_item_code=work_item_code, + pum_table_id=table_id, + cell=" | ".join(_clean(cell) for cell in header), + reason=( + f"장비 규격 {len(blocks)} 가지 × 갈래 {len(labels)} 가지로 세웠습니다 — " + "표가 「둘 중 하나」로 둔 자리라 **더하지 않고 고르게** 합니다. " + "⚠ 밑수(무엇당)는 원문에 없습니다." + ), + ) + ) + return True