From 6905cd1eb5a7165eb7aa85913e0f1f48635d9154 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 18:05:02 +0900 Subject: [PATCH] =?UTF-8?q?fix(axis):=20=ED=91=9C=20=EC=9D=BD=EA=B8=B0=202?= =?UTF-8?q?=EC=B0=A8=20=E2=80=94=20=EC=A0=88=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EB=B0=91=EC=88=98=20=C2=B7=20=EC=A1=B0=ED=95=A9=20=EA=B8=B0?= =?UTF-8?q?=EC=A2=85=20=EC=95=9E=EB=A8=B8=EB=A6=AC=20=C2=B7=20=ED=95=A9?= =?UTF-8?q?=ED=8C=90=EA=B1=B0=ED=91=B8=EC=A7=91=20=EB=A7=89=ED=9E=98=20?= =?UTF-8?q?=EC=82=AC=EC=9C=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 마스터 생성기: 밑수가 절 이름에만 있는 표를 이름 전체가 「수+단위+당」일 때만 읽음 — 4-2-1 「100본당」·4-2-2 「1,000㎡당」 두 표만 바뀜(재생성 diff 2표 · basis_missing 140→138) - 뭉친 줄 조합 기종: 앞머리를 공백 지우고 견줌 — 「굴착기+부착용집게」가 카탈로그 「부착용 집게」에 붙어 단목베기 4-2-2 가 섬(X-0201-0020#조합 + 부착용 집게) - 실무 대조: 단목베기 5m 미만 866원/㎡ ↔ 영월 설계내역 「잡관목제거 벌목(5m미만)」 882원/㎡(노임 연도 차 2%) - 합판거푸집 12-4: 사용횟수별 비율 미구현이라 일부러 안 풂 — 막힌 사유를 그 까닭으로 갈아 끼움(판정) - 일위대가 HEAD↔수정 대조: 바뀐 공종은 FP-04-02-02 하나 · 전체 시험 1522 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../B08_Quantity_Build_WorkItemMaster.py | 25 ++++ B09_Estimation/B09_Estimation_KnownGaps.py | 14 +++ .../B09_Estimation_ResourceAxis_Transposed.py | 5 +- B09_Estimation/B09_Estimation_UnitPrice.py | 6 + .../data_work_item_master/_manifest.json | 10 +- .../basis_missing_2026-01-01.json | 12 -- .../work_item_master_2026-01-01.json | 20 ++-- resources/tester/test_b09_table_reading.py | 107 ++++++++++++++++++ 8 files changed, 171 insertions(+), 28 deletions(-) create mode 100644 resources/tester/test_b09_table_reading.py diff --git a/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py b/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py index 525b1d64..d152127e 100644 --- a/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py +++ b/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py @@ -366,6 +366,26 @@ def basis_from_source(lines: list[str], line_no: int) -> tuple[float | None, str return None, None +#: ⚠ **밑수가 절 이름에만 있는 표** — 「4-2-2. 1,000㎡당」·「4-2-1. 100본당」(2026-09-13 판정). +#: 본문·표 안 어디에도 「(단위: …)」가 없어 밑수가 비어 있었다(B09 가 막아 금액은 안 섰음). +#: ⚠ **이름 전체가 「수 + 단위 + 당」일 때만** 뽑는다 — 「나무주사(100본당)」처럼 섞인 것은 +#: 본문이 이미 주고, 「자재의 1회당 표준 운반량」 같은 설명문은 밑수가 아니다. 짐작하지 않는다. +NAME_BASIS_RE = re.compile( + r"^([\d,]+(?:\.\d+)?)\s*(㎥|㎡|㏊|ha|㎞|km|m|매|본|개소|개|주|kg|㎏|톤|ton)\s*당$" +) + + +def basis_from_name(name: str) -> tuple[float | None, str | None]: + """절 이름이 곧 밑수인 경우만 `(수, 단위)`. 아니면 `(None, None)`.""" + found = NAME_BASIS_RE.match(norm(name)) + if found is None: + return None, None + try: + return float(found.group(1).replace(",", "")), found.group(2) + except ValueError: + return None, None + + def detect_basis(table: dict[str, Any]) -> tuple[float | None, str | None]: """「100㎥당」 같은 밑수. 없으면 `(None, None)` — 단위당 1 로 단정하지 않는다. @@ -599,6 +619,11 @@ def build() -> dict[str, Any]: basis_source = "표 안" if basis_unit else None else: basis_source = "본문" + # 본문·표 안에 없을 때만 **절 이름**을 본다(4-2-2 「1,000㎡당」) — 이름 전체가 밑수일 때만. + if basis_unit is None and number in by_number: + name_qty, name_unit = basis_from_name(by_number[number]["name"]) + if name_unit: + basis_qty, basis_unit, basis_source = name_qty, name_unit, "절 이름" if basis_unit: basis_found += 1 if basis_quantity_is_grouped(basis_qty): diff --git a/B09_Estimation/B09_Estimation_KnownGaps.py b/B09_Estimation/B09_Estimation_KnownGaps.py index b0ee64ef..05efc371 100644 --- a/B09_Estimation/B09_Estimation_KnownGaps.py +++ b/B09_Estimation/B09_Estimation_KnownGaps.py @@ -62,6 +62,20 @@ CONDITIONAL_INCLUDED: dict[str, str] = { } +#: **일부러 풀지 않은 표** — 풀면 금액이 조용히 틀리는 자리. 막힌 사유를 이 문구로 **갈아 끼운다** +#: (표 읽기 사유 「박리제 줄을 못 풀었습니다」는 사람을 엉뚱한 데로 보낸다). +#: ⚠ 이 구현이 서면 그 줄을 지운다. +BLOCKED_BY_DESIGN: dict[str, str] = { + # 2026-09-13 브레인 판정 — B09 일위대가 일감(B08 Formwork 머리말 「횟수별 재료 환산은 여기서 + # 하지 않는다」). 표를 그대로 풀면 1회 사용 값(재료·노무 100 %)으로 조용히 비싸진다. + "FP-12-04": ( + "거푸집 **사용횟수별 비율**(12-4 「1회 100 % · 4회 40 %…」)을 일위대가에 거는 셈이" + " 아직 없어" + " 풀지 않았습니다 — 풀면 1회 사용 값으로 비싸게 섭니다" + ), +} + + def known_gap_note(code: str | None) -> str: """그 공종에 **원문에는 있는데 못 실린 몫**이 있으면 사유 한 줄.""" if not code: diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py b/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py index dde9430b..79b87bf4 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py @@ -304,9 +304,12 @@ def _resolve_with_side_spec(catalog: ResourceCatalog, name: str, specs: list[str if not name or not specs: return None wanted = {"".join(str(s).split()) for s in specs if str(s).strip()} + # ⚠ 앞머리는 **공백을 지우고** 견준다 — 품셈은 「부착용집게」, 카탈로그는 「부착용 집게」라 + # 원문 그대로 보면 단목베기 4-2-2 조합 줄이 못 풀려 공종이 막혔다(2026-09-13). + head = _normalize_label(name) hits = [] for entry in catalog.entries: - if not entry.name.startswith(name): + if not _normalize_label(entry.name).startswith(head): continue spec = "".join(str(entry.spec).split()) if spec and any(_spec_matches(spec, w) for w in wanted): diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 3e2780ce..40487fa6 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -606,6 +606,12 @@ def build_unit_prices( for failed_code, why in borrow_fail.items(): build.factor_sources.setdefault(failed_code, f"⚠ {why}") build.component_gaps = dict(axis.partial_items) + # 일부러 풀지 않은 표는 **그 까닭**으로 사유를 갈아 끼운다(합판거푸집 사용횟수 비율 — 2026-09-13). + from B09_Estimation.B09_Estimation_KnownGaps import BLOCKED_BY_DESIGN + + for blocked_code, why in BLOCKED_BY_DESIGN.items(): + if blocked_code in build.component_gaps: + build.component_gaps[blocked_code] = why # 「작업량을 직접 준」 기계(깨기 대형브레이커)도 사용료 층을 세운다 — 안 세우면 # 그 줄이 붙을 데가 없어 암·발파암 갈래의 깨기 몫이 통째로 빠진다. from B09_Estimation.B09_Estimation_MachineProductivity_Compaction import compaction_rows diff --git a/resources/data_work_item_master/_manifest.json b/resources/data_work_item_master/_manifest.json index 9be25944..78b517cd 100644 --- a/resources/data_work_item_master/_manifest.json +++ b/resources/data_work_item_master/_manifest.json @@ -1,7 +1,7 @@ { "schema_version": "1.0", "dataset_id": "data_work_item_master_manifest", - "generated_at": "2026-09-09T11:44:28+09:00", + "generated_at": "2026-09-13T17:55:11+09:00", "built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py", "source": { "dataset_id": "pum_forest", @@ -12,8 +12,8 @@ "files": [ { "file": "work_item_master_2026-01-01.json", - "sha256": "3462899767156784158f724513c793052711806260e60db59d3e2a5fbf1fab21", - "size_bytes": 856810 + "sha256": "43b6d48175847e1d4011aad4a0597313008f791ec7eba0429d39d279e3e1fcb3", + "size_bytes": 856831 }, { "file": "form_undetermined_2026-01-01.json", @@ -22,8 +22,8 @@ }, { "file": "basis_missing_2026-01-01.json", - "sha256": "644b179f7d4b57641fc4ea6df27ef3e3904a03a0bf49c6a4a8bcf44f1195bdb1", - "size_bytes": 19018 + "sha256": "cb97edff0f47183173e23549c77f98f0509441edecc291bfcabba23254e60e9d", + "size_bytes": 18770 } ] } \ No newline at end of file diff --git a/resources/data_work_item_master/basis_missing_2026-01-01.json b/resources/data_work_item_master/basis_missing_2026-01-01.json index 2627bc66..9f31bba8 100644 --- a/resources/data_work_item_master/basis_missing_2026-01-01.json +++ b/resources/data_work_item_master/basis_missing_2026-01-01.json @@ -106,18 +106,6 @@ "pum_form": "requirement", "line": 1957 }, - { - "pum_table_id": "F0086", - "section": "4-2-1. 100본당", - "pum_form": "requirement", - "line": 1967 - }, - { - "pum_table_id": "F0087", - "section": "4-2-2. 1,000㎡당", - "pum_form": "requirement", - "line": 1997 - }, { "pum_table_id": "F0088", "section": "4-3. 위험목 베기", diff --git a/resources/data_work_item_master/work_item_master_2026-01-01.json b/resources/data_work_item_master/work_item_master_2026-01-01.json index bb11ffc2..c1300d30 100644 --- a/resources/data_work_item_master/work_item_master_2026-01-01.json +++ b/resources/data_work_item_master/work_item_master_2026-01-01.json @@ -2,7 +2,7 @@ "schema_version": "1.0", "dataset_id": "work_item_master_forest", "effective_date": "2026-01-01", - "generated_at": "2026-09-09T11:44:28+09:00", + "generated_at": "2026-09-13T17:55:11+09:00", "dataset_version": { "dataset_id": "pum_forest", "effective_date": "2026-01-01", @@ -21,9 +21,9 @@ "tables_attached": 456, "tables_orphan": 19, "form_undetermined": 77, - "basis_found": 180, - "basis_missing": 140, - "basis_grouped": 35 + "basis_found": 182, + "basis_missing": 138, + "basis_grouped": 37 }, "orphan_tables": [ { @@ -12700,9 +12700,9 @@ "source_line": 1967, "pum_form": "requirement", "form_basis": "직종 표기((인)·인부·공)", - "basis_quantity": null, - "basis_unit": null, - "basis_source": null, + "basis_quantity": 100.0, + "basis_unit": "본", + "basis_source": "절 이름", "resource_shares": {}, "partial_ratio": false, "expression_cells": [], @@ -12857,9 +12857,9 @@ "source_line": 1997, "pum_form": "requirement", "form_basis": "직종 표기((인)·인부·공)", - "basis_quantity": null, - "basis_unit": null, - "basis_source": null, + "basis_quantity": 1000.0, + "basis_unit": "㎡", + "basis_source": "절 이름", "resource_shares": {}, "partial_ratio": false, "expression_cells": [], diff --git a/resources/tester/test_b09_table_reading.py b/resources/tester/test_b09_table_reading.py new file mode 100644 index 00000000..0d06686c --- /dev/null +++ b/resources/tester/test_b09_table_reading.py @@ -0,0 +1,107 @@ +"""표 읽기 (2026-09-13 축 C 1장 · 브레인 차례 ②). + +지키는 것 + ① 밑수가 **절 이름에만** 있는 표는 이름 전체가 「수+단위+당」일 때만 뽑는다(4-2-2 「1,000㎡당」) + ② 뭉친 줄: 「굴 삭 기 (무한궤도)」 표기 맞추기 · 비고 줄 거름 · 「-」 칸은 그 갈래에 품 없음 + ③ 조합 기종 「굴착기+부착용집게」는 공백이 달라도 카탈로그 「부착용 집게」에 붙는다 + ④ 형식을 안 적은 「굴 삭 기」는 잠정 우선순위로 고르지 않는다(판정 Ⓒ) + ⑤ 실무값 대조 — 단목베기 5m 미만이 영월 설계내역 「잡관목제거 벌목(5m미만)」 882원/㎡ 근처로 선다 +""" + +from __future__ import annotations + +from decimal import Decimal + +from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_name +from B09_Estimation.B09_Estimation_ResourceAxis import ( + AxisResult, + CatalogEntry, + ResourceCatalog, + match_table, +) + + +def test_절_이름_밑수는_이름_전체가_밑수일_때만(): + assert basis_from_name("1,000㎡당") == (1000.0, "㎡") + assert basis_from_name("100본당") == (100.0, "본") + assert basis_from_name("나무주사(100본당)") == (None, None) + assert basis_from_name("자재의 1회당 표준 운반량") == (None, None) + + +def _packed(*rows: list[str]) -> dict: + return { + "pum_table_id": "T-시험", + "pum_form": "requirement", + "basis_quantity": 10.0, + "basis_unit": "㎡", + "raw_row": [list(row) for row in rows], + } + + +MACHINES = [ + CatalogEntry("0201-0080", "굴착기(무한궤도)", "machine", "0.8"), + CatalogEntry("0211-0080", "굴착기(타이어)", "machine", "0.8"), + CatalogEntry("0201-0020", "굴착기(무한궤도)", "machine", "0.2"), + CatalogEntry("7206-0020", "부착용 집게", "machine", "0.2"), + CatalogEntry("7206-0100", "부착용 집게", "machine", "1.0"), + CatalogEntry("1002", "보통인부", "labor"), + CatalogEntry("1037", "벌목부", "labor"), + CatalogEntry("1003", "특별인부", "labor"), +] + + +def _codes(result: AxisResult, variant: str) -> dict[str, Decimal]: + return {r.resource_code: r.amount for r in result.rows if r.variant == variant} + + +def test_뭉친_줄_굴삭기_표기를_맞추고_비고는_거른다(): + table = _packed( + ["직경 40㎝이상 ∼60㎝미만", "직경 60㎝이상 ∼80㎝미만"], + ["보통인부", "", "인", "1.04(1.17)", "1.08(1.22)"], + ["굴 삭 기 (무한궤도)", "0.8㎥", "h", "3.84", "3.52"], + ["비고", "- 본 품의 집재거리는 100m까지를 기준하며 매 100m 증가마다 30%씩 가산한다."], + ) + result = AxisResult() + match_table({"work_item_code": "FP-13-06-01"}, table, ResourceCatalog(entries=MACHINES), result) + first = _codes(result, "직경 40㎝이상 ∼60㎝미만") + assert first["0201-0080"] == Decimal("0.384") # 무한궤도 — 이름에 형식이 적혀 있음 + assert "0211-0080" not in first + assert "FP-13-06-01" not in result.partial_items # 비고 줄로 막히지 않음 + + +def test_조합_기종은_공백이_달라도_붙는다(): + table = _packed( + ["5m 미만", "5m이상~8m미만"], + ["벌목부 보통인부", "", "인 인", "2.14 0.51", "2.80 0.66"], + ["굴착기+부착용집게", "0.2㎥", "hr", "2.71", "3.54"], + ) + result = AxisResult() + match_table({"work_item_code": "FP-04-02-02"}, table, ResourceCatalog(entries=MACHINES), result) + assert set(_codes(result, "5m 미만")) >= {"0201-0020", "7206-0020"} + assert "FP-04-02-02" not in result.partial_items + + +def test_형식_없는_굴삭기와_빈칸_표시(): + table = _packed( + ["식생매트설치", "복 토"], + ["특 별 인 부", "", "인", "0.014", "-"], + ["굴 삭 기", "0.6 ㎥", "시간", "-", "0.031"], + ) + result = AxisResult() + match_table({"work_item_code": "FP-13-13-02"}, table, ResourceCatalog(entries=MACHINES), result) + # 「-」 — 복토 갈래에는 특별인부가 안 든다(자리만 지킴) + assert _codes(result, "식생매트설치") == {"1003": Decimal("0.0014")} + # 형식이 없는 굴삭기는 무한궤도·타이어 중 고르지 않는다 → 막고 사유를 남긴다 + assert "굴 삭 기" in result.partial_items.get("FP-13-13-02", "") + + +def test_단목베기_5m_미만이_실무값_근처로_선다(): + from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices + + build = build_unit_prices() + assert "FP-04-02-02" not in build.partial_ratio + # 합판거푸집 — 일부러 안 푼 표는 **그 까닭**이 사유로 뜬다(사용횟수 비율 미구현) + assert "사용횟수" in (build.component_gaps.get("FP-12-04") or "") + total = build.book.resolve("B-FP-04-02-02#5m미만").total + # 영월 설계내역 1.9.2 「잡관목제거 벌목(5m미만)」 @882 — 노임 연도 차이로 ±5 % 안이면 같은 읽기 + assert Decimal("838") <= total <= Decimal("926"), total