From 4a6254ba9875be4fddb13ab672c2219a391e8a62 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 21:59:46 +0900 Subject: [PATCH] =?UTF-8?q?fix(B09):=20=EC=84=B1=ED=86=A0=C2=B7=EC=84=B1?= =?UTF-8?q?=ED=86=A0=EB=A9=B4=EB=8B=A4=EC=A7=90=EC=97=90=20=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=EA=B0=80=20=EC=84=9C=EA=B2=8C=20=ED=95=A8=20=E2=80=94?= =?UTF-8?q?=20=EA=B8=88=EC=95=A1=20=EC=97=86=EC=9D=B4=20=EB=82=B4=EC=97=AD?= =?UTF-8?q?=EC=97=90=20=EC=84=9C=20=EC=9E=88=EB=8D=98=20=EB=91=90=20?= =?UTF-8?q?=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 물량은 인계로 오는데 단가가 없어 성토 13,150.2㎥·성토면다짐 12,350.3㎡ 가 금액 없이 서 있었음. 원문에는 값이 다 있고 막힌 까닭만 서로 달랐음. - 노체포설(9-16-1): 기종이 [주]에만 있어 못 골랐음 → 흙깎기와 같은 장비 칸을 둠 (무한궤도 굴착기 0.7㎥, [주]① 그대로. 화면에서 바꿀 수 있음). - 비탈면 다짐(9-17-1): 표가 한 장도 없고 값이 절 제목에 있음(A = 77.7 ㎡/시간) → 제목·[주]에만 있는 작업량을 싣는 자리를 두고 원문 문구를 줄에 남김. - 부착 콤팩터(0240-0007)는 카탈로그에 이름이 비어 있어 손료가 안 붙고 있었음 — 이름 대신 분류번호로도 가림(조합 사용 넷은 그대로, 목록을 넓히지 않음). 금액: 성토 59,477,858원 · 성토면다짐 17,827,251원 (합 77,305,109원)이 새로 섬. Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_FactorChoices.py | 12 +++ ...stimation_MachineProductivity_Reference.py | 52 +++++++++++ B09_Estimation/B09_Estimation_UnitPrice.py | 30 +++++- .../tester/test_b09_fill_and_slope_price.py | 93 +++++++++++++++++++ 4 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 resources/tester/test_b09_fill_and_slope_price.py diff --git a/B09_Estimation/B09_Estimation_FactorChoices.py b/B09_Estimation/B09_Estimation_FactorChoices.py index 8625214d..ae1f464a 100644 --- a/B09_Estimation/B09_Estimation_FactorChoices.py +++ b/B09_Estimation/B09_Estimation_FactorChoices.py @@ -207,6 +207,18 @@ MACHINE_CHOICES: dict[str, dict[str, Any]] = { "암절취 깎기를 고려하여 0.7㎥ 적용한다」.", ], }, + "FP-09-16-01": { + "work_item_name": "노체포설", + "default_code": "0201-0070", + "source": "note", + "basis": [ + "산림사업 표준품셈 9-16-1 [주]① 「장비는 무한궤도 굴착기(0.7㎥)를 적용한다」 — " + "흙깎기(9-3-2)와 같이 표가 아니라 [주] 에 있어 공종 마스터가 못 싣는 값입니다.", + "⚠ 이 칸이 비어 있던 동안 **노체포설 단가가 아예 안 서고 있었습니다** — " + "계수표(K·f·E·㎝)는 있는데 기종을 못 골라 시공능력 공식이 안 돌았습니다 " + "(2026-09-09 밤에 드러남).", + ], + }, "FP-09-18": { "work_item_name": "층따기", "default_code": "0201-0070", diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py b/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py index 6bfce469..1a412706 100644 --- a/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Reference.py @@ -255,10 +255,44 @@ def _machine_by_name(text: str, preferred_spec: str) -> tuple[str, str] | None: return (hits[0][0], hits[0][1].name) if len(hits) == 1 else None +#: ⚠ **표가 아니라 절 제목·[주]에 작업량이 적힌 자리.** +#: 9-17-1 은 표가 **아예 없고** 제목 줄이 값을 든다 — 「9-17-1. 비탈면 다짐 : **A = 77.7 ㎡/시간**」. +#: 공종 마스터는 표만 절에 붙이므로 이런 자리는 **표가 0장인 공종**으로 남아 단가가 안 섰다 +#: (2026-09-09 밤 실측: 성토면다짐 12,350.3㎡ 가 금액 없이 내역에 서 있었다). +#: ⚠ **값을 지어내지 않는다** — 아래 수·기종은 전부 원문 문구 그대로이고 그 문구를 함께 싣는다. +#: ⓘ 마스터가 제목의 작업량까지 싣게 되는 날 이 표는 지운다(그때는 표 쪽이 정본). +SOURCE_TEXT_CAPACITY: dict[str, tuple[dict[str, Any], ...]] = { + "FP-09-17-01": ( + { + "machine_code": "0201-0070", + "machine_name": "굴착기(무한궤도)", + "capacity_per_hour": Decimal("77.7"), + "cell": "비탈면 다짐 A(㎡/hr)", + "source_text": ( + "산림사업 표준품셈 9-17-1 「비탈면 다짐 : A = 77.7 ㎡/시간」 · " + "[주]② 「굴착기 부착용 유압식 진동 콤펙터와 굴착기(0.7㎥)를 적용한다」" + ), + }, + { + "machine_code": "0240-0007", + "machine_name": "유압식 진동콤팩터(굴착기 부착용, 0.7)", + "capacity_per_hour": Decimal("77.7"), + "cell": "비탈면 다짐 A(㎡/hr) — 부착 콤팩터", + "source_text": ( + "산림사업 표준품셈 9-17-1 [주]② — 굴착기와 **함께** 도는 부착 장비라 " + "같은 작업량으로 붙는다(품셈 제8장 [주]⑤ 조합 사용)." + ), + }, + ), +} + + def direct_capacity_rows(node: dict[str, Any]) -> list[dict[str, Any]]: """그 공종에서 **시간당 작업량을 직접 준 기계 줄**들. 돌려주는 것 — 기계 이름 칸 · 기종 코드/이름 · 시간당 작업량 · 묶음 배분율(%) · 원문 문구. + + ⚠ 표에서 읽은 것에 더해 **절 제목·[주]에만 적힌 자리**(`SOURCE_TEXT_CAPACITY`)도 함께 낸다. """ from B09_Estimation.B09_Estimation_MachineProductivity import parse_measure @@ -299,4 +333,22 @@ def direct_capacity_rows(node: dict[str, Any]) -> list[dict[str, Any]]: "row_cells": [c for c in cells if c], } ) + + # 절 제목·[주]에만 있는 자리 — 표에서 이미 잡혔으면 **덧붙이지 않는다**(두 번 세지 않게). + already = {row["machine_code"] for row in found} + for entry in SOURCE_TEXT_CAPACITY.get(str(node.get("work_item_code") or ""), ()): + if entry["machine_code"] in already: + continue + found.append( + { + "cell": entry["cell"], + "machine_code": entry["machine_code"], + "machine_name": entry["machine_name"], + "capacity_per_hour": entry["capacity_per_hour"], + "ratio_pct": None, + "table_id": "", + "row_cells": [], + "source_text": entry["source_text"], + } + ) return found diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 9b498c78..32c93994 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -276,8 +276,14 @@ def _add_machine_layers( # ⇒ 그 셋은 **손료만으로** 층을 세운다. 운전경비 줄이 없다고 통째로 버리면 # 깨기 몫이 영영 안 붙는다(구조물터파기 암 갈래가 그 자리였다). # ⚠ **다른 기계에는 이 길을 열지 않는다** — 연료가 빠진 채 조용히 싼 값이 선다. - attachment = machine is not None and any( - word in re.sub(r"\s", "", machine.name) for word in _ATTACHMENT_WORDS + # ⚠ 이름으로만 가리면 **이름이 빈 줄을 놓친다** — 카탈로그의 `0240-0007` + # (유압식 진동콤팩터, 굴착기 부착용)이 원천에서 **이름을 잃은 채** 들어와 있어 + # 비탈면 다짐의 콤팩터 손료가 통째로 안 붙고 있었다(2026-09-09 밤 실측). + # 그래서 **분류번호**로도 가린다 — 아래 번호는 이미 조합 사용으로 못 박아 둔 + # 그 넷(`_ATTACHMENT_PREFIXES`)이고, **목록을 넓히는 것이 아니다.** + attachment = machine is not None and ( + any(word in re.sub(r"\s", "", machine.name) for word in _ATTACHMENT_WORDS) + or f"X-{code}".startswith(_ATTACHMENT_PREFIXES) ) if machine is None or machine.loss_coefficient_per_hour is None: incomplete.append(code) @@ -675,6 +681,12 @@ def build_unit_prices( for label in labels: by_item.setdefault((code, label), []) continue + # ⚠ **표가 한 장도 없어도 작업량을 원문이 준 공종은 세운다** — 9-17-1 비탈면 다짐이 + # 그 자리다(제목 줄이 「A = 77.7 ㎡/시간」을 든다). 표만 보면 영영 안 서고, + # 실제로 성토면다짐 12,350.3㎡ 가 금액 없이 내역에 서 있었다(2026-09-09 밤). + if capacity_rows.get(code): + by_item[(code, "")] = [] + continue for table in node.get("tables", []): if isinstance( extract_cycle_factors(code, table, factor_choices, machine_picks), CycleFactors @@ -698,10 +710,12 @@ def build_unit_prices( for row in rows ] attachable = [(row, ref) for row, ref in attachable if ref in build.book.titles] - if not attachable and not _has_full_formula( - master, work_item_code, factor_choices, machine_picks + if ( + not attachable + and not capacity_rows.get(work_item_code) + and not _has_full_formula(master, work_item_code, factor_choices, machine_picks) ): - # 붙을 상세도 없고 공식도 없으면 **제목도 안 세운다**(0 원 일위대가 금지). + # 붙을 상세도 없고 공식도 작업량도 없으면 **제목도 안 세운다**(0 원 일위대가 금지). build.skipped.append(work_item_code) continue @@ -805,6 +819,12 @@ def build_unit_prices( note=( f"작업량을 표가 직접 줌 — {capacity['cell']} {capacity['capacity_per_hour']}" f" (품셈 원문 표기 그대로)" + if not capacity.get("source_text") + # 표가 아니라 절 제목·[주]에서 온 값은 **어디서 왔는지 그대로** 싣는다. + else ( + f"작업량을 원문이 직접 줌 — {capacity['cell']}" + f" {capacity['capacity_per_hour']} · {capacity['source_text']}" + ) ), ) ) diff --git a/resources/tester/test_b09_fill_and_slope_price.py b/resources/tester/test_b09_fill_and_slope_price.py new file mode 100644 index 00000000..0f1f05ba --- /dev/null +++ b/resources/tester/test_b09_fill_and_slope_price.py @@ -0,0 +1,93 @@ +"""성토·성토면다짐에 단가가 서는가 (2026-09-09 밤). + +**왜 안 서고 있었나** — 둘 다 원문에는 값이 다 있는데 서로 다른 까닭으로 막혀 있었다. + + 9-16-1 노체포설 계수표(K·f·E·㎝)는 붙었는데 **기종이 [주]에만 있어** 못 골랐음 + → 시공능력 공식이 안 돌아 단가가 통째로 안 섬 + 9-17-1 비탈면 다짐 **표가 한 장도 없음** — 값이 절 제목에 있음(「A = 77.7 ㎡/시간」) + → 표만 보는 조립이 그 공종을 아예 안 봄 + +⚠ 그동안 인계에는 물량이 그대로 실려 **성토 13,150.2㎥ · 성토면다짐 12,350.3㎡ 가 금액 없이** +내역에 서 있었다. 그래서 아래 셋을 못으로 박는다. + + ① 두 공종에 단가가 선다 · ② 단위가 원문과 같다(㎥·㎡) · ③ 근거가 줄에 남는다 + ④ 부착 콤팩터 손료가 함께 붙는다 — 이름이 빈 카탈로그 줄이라 놓치던 자리 +""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_FactorChoices import MACHINE_CHOICES # noqa: E402 +from B09_Estimation.B09_Estimation_MachineProductivity_Reference import ( # noqa: E402 + SOURCE_TEXT_CAPACITY, + direct_capacity_rows, +) +from B09_Estimation.B09_Estimation_UnitPrice import ( # noqa: E402 + cached_build, + load_work_item_master, +) + + +def _build(): + return cached_build() + + +def test_노체포설에_단가가_선다() -> None: + build = _build() + title = build.book.titles.get("B-FP-09-16-01") + assert title is not None, "노체포설 일위대가가 안 섰습니다" + assert title.unit == "㎥" + assert build.book.resolve("B-FP-09-16-01").total > 0 + + +def test_노체포설_기종은_주에서_왔음을_적는다() -> None: + """⚠ 기본값을 어디서 가져왔는지 안 적으면 임의 값과 구별이 안 된다.""" + entry = MACHINE_CHOICES["FP-09-16-01"] + assert entry["default_code"] == "0201-0070" # 무한궤도 굴착기 0.7㎥ + assert entry["source"] == "note" + assert any("9-16-1" in line for line in entry["basis"]) + + +def test_비탈면다짐에_단가가_선다() -> None: + build = _build() + title = build.book.titles.get("B-FP-09-17-01") + assert title is not None, "비탈면 다짐 일위대가가 안 섰습니다" + assert title.unit == "㎡" + assert build.book.resolve("B-FP-09-17-01").total > 0 + + +def test_비탈면다짐은_원문_작업량을_그대로_쓴다() -> None: + """② 77.7 은 원문 값이다 — 지어낸 수가 아님을 줄에 남긴다.""" + build = _build() + rows = build.book.details["B-FP-09-17-01"] + assert rows, "상세 줄이 없습니다" + for row in rows: + # 시간 = 1 ÷ 77.7 ㎡/시간 + assert row.quantity == Decimal(1) / Decimal("77.7") + assert "9-17-1" in row.note and "77.7" in row.note + + +def test_부착_콤팩터_손료가_함께_붙는다() -> None: + """④ 카탈로그에 **이름이 빈 줄**(0240-0007)이라 이름으로만 가리면 놓친다.""" + build = _build() + refs = {row.ref_code for row in build.book.details["B-FP-09-17-01"]} + assert "X-0240-0007" in refs, refs + # 본체 굴착기는 조합 사용 층(잡재료 16%)으로 바뀌어야 한다 — 품셈 제8장 [주]⑤. + assert any(ref.startswith("X-0201-0070#") for ref in refs), refs + assert "0240-0007" not in build.incomplete_machines + + +def test_제목에만_있는_작업량은_표에서_온_것과_안_겹친다() -> None: + """⚠ 같은 기종이 표에도 있으면 두 번 세면 안 된다.""" + master = load_work_item_master() + node = next(w for w in master["work_items"] if w["work_item_code"] == "FP-09-17-01") + rows = direct_capacity_rows(node) + codes = [row["machine_code"] for row in rows] + assert len(codes) == len(set(codes)) + assert set(codes) == {entry["machine_code"] for entry in SOURCE_TEXT_CAPACITY["FP-09-17-01"]}