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"]}