From 852ad6c7d857956ac13d7a009b0c3d95d454cc88 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 02:15:48 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(b09):=20=E3=89=A1=202=EC=9E=A5=20?= =?UTF-8?q?=EC=86=8C=EC=9A=94=EC=9E=AC=EB=A3=8C=C2=B7=EA=B8=B0=EA=B3=84?= =?UTF-8?q?=EC=86=90=EB=A3=8C=20=ED=91=9C=EB=A5=BC=20=EC=9D=BD=EB=8A=94=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=8C=20=E2=80=94=20=EC=B2=B4=EC=9D=B8=ED=86=B1?= =?UTF-8?q?=EB=B6=80=ED=84=B0(4-2-2=20=EB=8B=A8=EB=AA=A9=EB=B2=A0=EA=B8=B0?= =?UTF-8?q?=20=C2=B7=206-5=20=EC=96=B4=EB=A6=B0=EB=82=98=EB=AC=B4=EA=B0=80?= =?UTF-8?q?=EA=BE=B8=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit · 장비 한 벌(Equipment)에 「어느 표 줄을 읽을지」 만 적고 값은 2장 표에서 읽음 — 나머지 소형 장비도 같은 길로 더함 · 체인톱 1대 1일 호표 = 휘발유 5.6ℓ(2-1-1) + 잡품 주연료비 40% + 손료 0.0084(2-2-1) × 구입가(「자재 단가」 칸 · 없으면 사유) · 쓰는 공종에 표가 이름으로 밝힌 인원 줄 수량만큼(4-2-2 「벌목부」 · 6-5 「특별인부 (체인톱 사용)」 · 넓히지 않음) · 체인오일 일반/친환경은 「자재 단가」 에 넣은 쪽 × 인원 × 2.1ℓ · 둘 다면 사유(브레인 판정 ①②③) 오른 제목: 4-2-2 +31·+41·+53원(+3.6%) · 6-5 +43,821원(+4.29%) · 나머지 그대로 · 프로젝트 내역 지장목제거 잡관목제거 +548,652(본체 109,122,948 → 109,671,600) · 잴 시험 넷 빨강→초록 · 끄기 넷 빨강 · 전체 시험 1712 + 381 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- B09_Estimation/B09_Estimation_Consumables.py | 185 ++++++++++++++++++ B09_Estimation/B09_Estimation_UnitPrice.py | 4 + .../resource_catalog_ext_2026-01-01.json | 53 +++++ .../tester/test_b09_consumables_chainsaw.py | 80 ++++++++ 4 files changed, 322 insertions(+) create mode 100644 B09_Estimation/B09_Estimation_Consumables.py create mode 100644 resources/tester/test_b09_consumables_chainsaw.py diff --git a/B09_Estimation/B09_Estimation_Consumables.py b/B09_Estimation/B09_Estimation_Consumables.py new file mode 100644 index 00000000..5e9cbc18 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Consumables.py @@ -0,0 +1,185 @@ +"""B09 원가계산 — **2장 소요재료·기계손료 표를 읽는 한 벌** (2026-09-15 브레인 ㉡ · 판정 ①②③). + +산림품셈 2장은 소형 장비(체인톱·예취기·윈치·천공기 …)의 1대 1일 소모(연료·잡품·오일)와 손료계수를 +표로 주고, 각 공종 표는 **인원**만 적음(「벌목부」·「특별인부 (체인톱 사용)」). 대수 규칙이 인원에 붙어 +장비 몫을 세우는데 그 길이 없어 공종이 인력만으로 싸게 서 있었음(㉯ 셈 7 · 4-2-2 는 부모 표에만 장비 말). + + 장비 한 벌(`Equipment`) = 소요재료 표 줄 · 손료 표 · 고르는 줄(체인오일 일반/친환경) · 쓰는 공종과 인원 줄 + 값은 표에서 읽음(연료 ℓ/대/일 · 잡품 % · 오일 ℓ · 손료계수) — 여기엔 **어느 줄을 읽을지**만 적음 + `X-` 1대 1일 호표 = 연료 × ℓ + 잡품(주연료비 %) + 손료(구입가 × 계수 · 구입가가 들면) + 공종 제목에 장비 호표 × 인원 줄 수량(대수 = 인원 × 100%) · 고르는 오일은 넣은 쪽 하나 × 인원 × ℓ + +⚠ 인원 줄은 **표가 이름으로 밝힌 줄만**(③) — 「비슷한 인원 줄」로 넓히지 않음. +⚠ 구입가·오일 단가는 카탈로그가 없어 「자재 단가」 칸 + 사유(②) · 휘발유는 유가 판으로 바로 섬. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any + + +@dataclass(frozen=True) +class Equipment: + key: str + machine: str # AR-X — 1대 1일 호표 + price: str # AR-M — 구입가(손료 밑수) 칸 + fuel: tuple[str, str, str] # (2-1 공종, 표, 주연료 줄 이름) + loss: tuple[str, str] # (2-2 공종, 표) + choices: tuple[tuple[str, str], ...] = () # 넣은 쪽 하나 — (AR-M, 2-1 표 줄 이름) + users: dict[str, str] = field(default_factory=dict) # 공종 → 표가 밝힌 인원 줄 이름 + basis: str = "" + + +EQUIPMENTS: tuple[Equipment, ...] = ( + Equipment( + key="체인톱", + machine="AR-X-61d1681d", + price="AR-M-5649cf3f", + fuel=("FP-02-01-01", "F0042", "보통휘발유 (주연료)"), + loss=("FP-02-02-01", "F0064"), + choices=( + ("AR-M-fa7fbf6d", "체인오일 (일반오일)"), + ("AR-M-de2fe662", "체인오일 (친환경오일)"), + ), + # 2-1-1 [주]① 「숲가꾸기(작업로설치, 어린나무가꾸기, 단목베기) 및 수확베기, 병해충방제」 중 + # 지금 제목이 서는 둘 · 표가 이름으로 밝힌 인원 줄(2026-09-15 브레인 ③). + users={"FP-04-02-02": "벌목부", "FP-06-05": "특별인부 (체인톱 사용)"}, + basis="산림품셈 2-1-1 「체인톱 대수는 산출된 벌목부 또는 특별인부의 100% 적용」 · 2-2-1 손료", + ), +) + +_RE_NUMBER = re.compile(r"\d+(?:\.\d+)?") + + +def _tight(text: Any) -> str: + return re.sub(r"\s", "", str(text or "")) + + +def _table_rows(nodes: dict[str, dict[str, Any]], code: str, table_id: str) -> list[list[str]]: + node = nodes.get(code) or {} + table = next((t for t in node.get("tables") or [] if t.get("pum_table_id") == table_id), {}) + return [[str(c) for c in row] for row in table.get("raw_row") or []] + + +def _row_numbers(rows: list[list[str]], name: str) -> list[Decimal]: + """그 이름 줄의 수 칸들 — 이름 칸 뒤에서 차례로(빈 칸 건너뜀).""" + row = next((r for r in rows if r and _tight(r[0]) == _tight(name)), None) + if row is None: + return [] + return [ + Decimal(m.group()) for c in row[1:] if (m := _RE_NUMBER.fullmatch(_tight(c).rstrip("%"))) + ] + + +def _fuel_title(book: Any, kind: str, fuel_region: str | None) -> str: + from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price + from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle + from B09_Estimation.B09_Estimation_UnitPrice import FUEL_CODE_PREFIX, _slots + + code = f"{FUEL_CODE_PREFIX}{kind}" + if code not in book.titles: + price, meta = load_fuel_price(region=fuel_region, kind=kind) + spec = f"{meta.get('region_name')} 공시가" if meta.get("region_name") else "전국 공시가" + book.add_title(PriceTitle(code, PriceKind.MATERIAL, kind, spec, "L", slots=_slots(price))) + return code + + +def _machine_title(build: Any, nodes: dict, eq: Equipment, fuel_region: str | None) -> list[str]: + """`X-` 1대 1일 — 표에서 연료·잡품·손료계수를 읽음. 돌려주는 값 = 못 붙은 사유들.""" + from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle + from B09_Estimation.B09_Estimation_UnitPrice import _misc_row, _slots + + book = build.book + code = f"X-{eq.machine}" + fuel_rows = _table_rows(nodes, eq.fuel[0], eq.fuel[1]) + numbers = _row_numbers(fuel_rows, eq.fuel[2]) + loss = _row_numbers(_table_rows(nodes, *eq.loss), _table_rows(nodes, *eq.loss)[0][0]) + if len(numbers) < 2 or not loss: + return [f"{eq.key} — 2장 표 칸이 달라져 장비 몫을 못 읽음"] + if code in book.titles: + return [] + kind = "휘발유" if "휘발유" in eq.fuel[2] else "경유" + liters, misc = numbers[0], numbers[1] + book.add_title(PriceTitle(code, PriceKind.MACHINE_HOURLY, eq.key, "1대 1일", "대·일")) + book.add_detail( + PriceDetail(code, _fuel_title(book, kind, fuel_region), liters, note=f"주연료 {kind}") + ) + book.add_detail(_misc_row(code, misc)) + reasons = [] + if eq.price in book.titles: + base = f"S-{eq.machine}" + price = book.titles[eq.price].slots[-1] + book.add_title( + PriceTitle( + base, PriceKind.MACHINE_BASE, eq.key, "손료", "대·일", slots=_slots(price * loss[0]) + ) + ) + book.add_detail(PriceDetail(code, base, Decimal(1), note=f"손료 = 구입가 × {loss[0]}")) + else: + reasons.append(f"{eq.key}가격 — 손료({loss[0]} × 구입가) 칸 · 「자재 단가」 에 넣으면 붙음") + return reasons + + +def attach_consumables( + build: Any, nodes: dict[str, dict[str, Any]], fuel_region: str | None = None +) -> None: + """장비마다 1대 1일 호표를 세우고, 쓰는 공종 제목에 인원 줄 수량만큼 붙임.""" + from B09_Estimation.B09_Estimation_PriceBook import PriceDetail + + book = build.book + for eq in EQUIPMENTS: + for material in (eq.price, *(c for c, _ in eq.choices)): + uses = build.material_uses.setdefault(material, []) + uses.extend(c for c in eq.users if c not in uses) + machine_reasons = _machine_title(build, nodes, eq, fuel_region) + fuel_rows = _table_rows(nodes, eq.fuel[0], eq.fuel[1]) + picked = [(c, name) for c, name in eq.choices if c in book.titles] + for work_item, user_row in eq.users.items(): + node_rows = [ + r + for t in (nodes.get(work_item) or {}).get("tables") or [] + for r in t.get("raw_row") or [] + ] + # 이름 칸이 뭉친 표(「벌목부 보통인부」)도 낱말로 봄 — 그 이름이 없으면 넓히지 않음(③). + if not any( + r and (_tight(r[0]) == _tight(user_row) or user_row in str(r[0]).split()) + for r in node_rows + ): + continue + labor_name = _tight(user_row).split("(")[0] + reasons = list(machine_reasons) + for title in [ + t for t in book.titles if t == f"B-{work_item}" or t.startswith(f"B-{work_item}#") + ]: + if f"X-{eq.machine}" not in book.titles: + break + users = [ + r + for r in book.details.get(title, []) + if book.titles.get(r.ref_code) + and _tight(book.titles[r.ref_code].name) == labor_name + ] + if not users: + continue + count = users[0].quantity + note = f"{eq.key} {count}대(인원 「{user_row}」 × 100%) — {eq.basis}" + book.add_detail(PriceDetail(title, f"X-{eq.machine}", count, note=note)) + if len(picked) == 1: + code, name = picked[0] + liters = _row_numbers(fuel_rows, name)[0] + book.add_detail( + PriceDetail(title, code, count * liters, note=f"{name} {liters}ℓ/대/일") + ) + if len(picked) > 1: + reasons.append( + f"{eq.choices[0][1]}·{eq.choices[1][1]} 가 둘 다 들어옴 — 하나만 넣을 것" + ) + elif not picked and eq.choices: + reasons.append( + f"{' 또는 '.join(name for _, name in eq.choices)} — 시중가격 칸(설계자 선택) · 넣은 쪽이 붙음" + ) + labels = build.unattached.setdefault(work_item, []) + labels.extend(r for r in reasons if r not in labels) diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 9cb8d648..da583d2f 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -1114,6 +1114,10 @@ def build_unit_prices( from B09_Estimation.B09_Estimation_WoodChipping import attach_wood_chipping attach_wood_chipping(build, nodes_by_code) + # 2장 소요재료·기계손료 표(체인톱 …) — 인원 줄에 장비 몫을 붙임(2026-09-15 ㉡). + from B09_Estimation.B09_Estimation_Consumables import attach_consumables + + attach_consumables(build, nodes_by_code, fuel_region) # ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다 # (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다. build.combined_swapped = _apply_combined_misc_rate( diff --git a/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json b/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json index d44bffed..c99926b9 100644 --- a/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json +++ b/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json @@ -394,6 +394,59 @@ "F0237" ] } + }, + { + "code": "AR-X-61d1681d", + "kind": "machine", + "name": "체인톱", + "spec": "45cc (배기량기준)", + "unit": "대·일", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0042", + "F0064" + ] + } + }, + { + "code": "AR-M-5649cf3f", + "kind": "material", + "name": "체인톱 구입가", + "spec": "45cc (배기량기준) · 손료 밑수", + "unit": "대", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0064" + ] + } + }, + { + "code": "AR-M-fa7fbf6d", + "kind": "material", + "name": "체인오일", + "spec": "일반오일", + "unit": "ℓ", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0042" + ] + } + }, + { + "code": "AR-M-de2fe662", + "kind": "material", + "name": "체인오일", + "spec": "친환경오일", + "unit": "ℓ", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0042" + ] + } } ] } diff --git a/resources/tester/test_b09_consumables_chainsaw.py b/resources/tester/test_b09_consumables_chainsaw.py new file mode 100644 index 00000000..1a7acb52 --- /dev/null +++ b/resources/tester/test_b09_consumables_chainsaw.py @@ -0,0 +1,80 @@ +"""2장 소요재료·기계손료 표를 읽는 한 벌 — 체인톱부터(2026-09-15 브레인 ㉡ · 판정 ①②③). + +산림품셈 2-1-1 체인톱 표(F0042): 보통휘발유(주연료) 5.6ℓ/대/일 · 잡품 주연료비 40% · 체인오일(일반·친환경) 2.1ℓ/대/일 + 「체인톱 대수는 산출된 벌목부 또는 특별인부의 100% 적용」 · 2-2-1 체인톱 손료(F0064): 45cc 0.0084(1일 1대) × 체인톱가격 +체인톱을 쓰는 공종(4-2-2 단목베기 · 6-5 어린나무가꾸기)이 인력만으로 서서 연료·잡품·손료가 통째로 빠져 있었음. + ③ 표가 이름으로 밝힌 인원 줄만 — 4-2-2 「벌목부」 · 6-5 「특별인부 (체인톱 사용)」 + ① 체인오일 일반/친환경은 「자재 단가」 에 넣은 쪽 · 둘 다면 안 붙고 사유 + ② 체인톱가격·체인오일 시중가격은 칸 + 사유 · 휘발유는 유가 판으로 바로 섬 +""" + +from __future__ import annotations + +from decimal import ROUND_FLOOR, Decimal + +from B09_Estimation.B09_Estimation_UnitPrice import cached_build + +SAW = "X-AR-X-61d1681d" +SAW_PRICE = "AR-M-5649cf3f" +OIL = "AR-M-fa7fbf6d" +ECO_OIL = "AR-M-de2fe662" + + +def _rows(book, code: str) -> list: + own = book.details.get(code) or [] + return [ + *own, + *(r for d in own if d.ref_code.startswith("D-") for r in book.details[d.ref_code]), + ] + + +def test_체인톱_1대_1일_호표는_표의_휘발유와_잡품_40퍼센트() -> None: + book = cached_build().book + rows = book.details[SAW] + fuel = next(r for r in rows if r.ref_code == "M-FUEL-휘발유") + assert fuel.quantity == Decimal("5.6"), fuel + misc = next(r for r in rows if r.percent_of_material is not None) + assert misc.percent_of_material == Decimal(40) + price = book.titles["M-FUEL-휘발유"].slots[-1] + expected = (Decimal("5.6") * price * Decimal("1.4")).quantize(Decimal(1), rounding=ROUND_FLOOR) + assert abs(book.resolve(SAW).material - expected) <= 1, (book.resolve(SAW), expected) + + +def test_단목베기는_벌목부_인원만큼_체인톱_어린나무가꾸기는_체인톱_사용_특별인부만큼() -> None: + book = cached_build().book + rows = _rows(book, "B-FP-04-02-02#5m미만") + faller = next(r for r in rows if book.titles[r.ref_code].name == "벌목부") + saw = next(r for r in rows if r.ref_code == SAW) + assert saw.quantity == faller.quantity, (saw, faller) + rows = _rows(book, "B-FP-06-05") + special = next( + r + for r in rows + if book.titles.get(r.ref_code) and book.titles[r.ref_code].name == "특별인부" + ) + saw = next(r for r in rows if r.ref_code == SAW) + assert saw.quantity == special.quantity, (saw, special) + # 보통인부(작업 보조)는 체인톱을 안 씀 — 표가 이름으로 밝힌 줄만(③) + assert sum(1 for r in rows if r.ref_code == SAW) == 1 + + +def test_체인오일은_넣은_쪽만_둘_다면_사유() -> None: + none = cached_build() + assert any("체인오일" in label for label in none.unattached.get("FP-04-02-02", [])) + one = cached_build(material_prices=((OIL, "2250", "시중"),)) + rows = _rows(one.book, "B-FP-04-02-02#5m미만") + faller = next(r for r in rows if one.book.titles[r.ref_code].name == "벌목부") + oil = next(r for r in rows if r.ref_code == OIL) + assert oil.quantity == faller.quantity * Decimal("2.1"), oil + both = cached_build(material_prices=((OIL, "2250", "시중"), (ECO_OIL, "6000", "시중"))) + assert not any(r.ref_code in (OIL, ECO_OIL) for r in _rows(both.book, "B-FP-04-02-02#5m미만")) + assert any("둘 다" in label for label in both.unattached.get("FP-04-02-02", [])) + + +def test_체인톱가격이_들면_손료_0_0084_가_경비로() -> None: + none = cached_build() + assert any("체인톱가격" in label for label in none.unattached.get("FP-06-05", [])) + priced = cached_build(material_prices=((SAW_PRICE, "900000", "견적"),)) + assert priced.book.resolve(SAW).expense == Decimal(7560) # 900,000 × 0.0084 + for code in (SAW_PRICE, OIL, ECO_OIL): + assert "FP-04-02-02" in none.material_uses.get(code, []), code From e15e9fc2235d695f1e30f79c49d77d9caba85891 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 02:47:59 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(b09):=202=EB=8B=A8=20=EB=A8=B8=EB=A6=AC?= =?UTF-8?q?=20=ED=91=9C=EA=B0=80=20=EB=B0=91=EC=88=98=EB=A1=9C=20=EC=95=88?= =?UTF-8?q?=20=EB=82=98=EB=88=84=EB=8D=98=20=EA=B2=83=20=E2=80=94=20?= =?UTF-8?q?=EA=B0=88=EB=9E=98=20=EC=9D=B4=EB=A6=84=EC=9D=98=20=E3=80=8C(Nh?= =?UTF-8?q?a=EB=8B=B9)=E3=80=8D=20=EB=B0=91=EC=88=98=EB=A1=9C=20=EB=82=98?= =?UTF-8?q?=EB=88=94(8-6-1=20=EC=9C=A0=EC=9D=B8=ED=97=AC=EA=B8=B0=20160?= =?UTF-8?q?=EB=B0=B0=C2=B7400=EB=B0=B0=20=EB=B6=80=ED=92=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit · _match_two_row_table 이 표 밑수로 아예 안 나눠 제목 단위 ha 에 160ha·400ha 몫이 그대로 앉아 있었음 · 표 밑수가 한 벌뿐이라 갈래마다 다른 밑수(소형 160ha · 대형 400ha)도 못 가르던 병 → 갈래 이름이 제 밑수를 적으면 그것으로, 없으면 표 밑수로 · 그 길을 지나는 표 다섯 중 밑수가 1 이 아닌 것 8-6-1 하나 · 전체에서 갈래마다 밑수가 다른 표도 8-6-1 하나 8-6-1 소형 1,591,855 → 9,949원/ha · 대형 2,780,567 → 6,951 · 나머지 제목 그대로 · 잴 시험 빨강→초록 · 끄기 빨강 · 전체 시험 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../B09_Estimation_ResourceAxis_Transposed.py | 13 +++++- resources/tester/test_b09_variant_basis.py | 40 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 resources/tester/test_b09_variant_basis.py diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py b/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py index 79b87bf4..d8595726 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py @@ -46,6 +46,8 @@ def _normalize_label(text: str) -> str: #: 「계」 열 — **가공 + 조립을 이미 더한 값**이다. 같이 읽으면 두 번 센다(㉤ 열 방향). _SUM_GROUP_LABELS = ("계", "합계", "소계", "총계") +#: 갈래 이름에 적힌 제 밑수 — 「대형헬기 (400ha당)」. +_RE_VARIANT_BASIS = re.compile(r"\(\s*(\d[\d,]*(?:\.\d+)?)\s*(?:ha|㏊|㎡|㎥|m|본|개소)\s*당\s*\)") def _sum_group_positions(headers: list, resource_count: int) -> set: @@ -143,6 +145,7 @@ def _match_two_row_table( unit: str, ordinal: list, skip_rows: int, + basis_quantity: Decimal | None = None, ) -> bool: """2단 표 — **숫자 칸을 순서대로** 자원에 맞춘다. @@ -180,9 +183,15 @@ def _match_two_row_table( ) ) continue + # ⚠ 밑수로 나눔 — 갈래 이름이 「(400ha당)」 처럼 제 밑수를 적으면 그것으로(8-6-1 유인헬기가 + # 160배·400배 부풀어 있던 자리 · 2026-09-15). 표 밑수가 한 벌뿐이라 갈래 밑수를 못 가르던 병. + found = _RE_VARIANT_BASIS.search(variant) + divisor = Decimal(found.group(1).replace(",", "")) if found else basis_quantity for (order, entry, blocked), amount in zip(ordinal, numbers): if blocked: continue # 「계」 묶음 — 이미 더한 값이다 + if divisor not in (None, 0, Decimal(1)): + amount = amount / divisor result.rows.append( ResourceRow( work_item_code=work_item_code, @@ -497,7 +506,9 @@ def match_transposed_table( # 자원 이름이 **둘째 줄**에 오는 2단 표일 수 있다. ordinal, skip_rows = second_row_columns(table, catalog) if ordinal: - return _match_two_row_table(node, table, catalog, result, unit, ordinal, skip_rows) + return _match_two_row_table( + node, table, catalog, result, unit, ordinal, skip_rows, basis_quantity + ) if not columns: return False diff --git a/resources/tester/test_b09_variant_basis.py b/resources/tester/test_b09_variant_basis.py new file mode 100644 index 00000000..836db1b4 --- /dev/null +++ b/resources/tester/test_b09_variant_basis.py @@ -0,0 +1,40 @@ +"""갈래마다 밑수가 다른 표 — 2026-09-15 브레인(헬기 착수 전 바로잡기). + +8-6-1 유인헬기방제 표(F0225)는 「소형헬기 (160ha당)」·「대형헬기 (400ha당)」 갈래마다 밑수가 다른데, +2단 머리 표 읽기(`_match_two_row_table`)가 **밑수로 아예 안 나눠** 제목 단위 「ha」 에 160ha·400ha 몫이 +그대로 앉아 있었음(160배·400배 부풂). 표 밑수가 한 벌(160ha)로만 잡혀 대형 400ha 도 못 가름. +⇒ 갈래 이름의 「(N단위당)」 이 있으면 그 밑수로, 없으면 표 밑수로 나눔. +그 길을 지나는 표 다섯 중 밑수가 1 이 아닌 표는 8-6-1 하나 · 갈래마다 밑수가 다른 표도 전체에서 8-6-1 하나. +""" + +from __future__ import annotations + +from decimal import Decimal + +from B09_Estimation.B09_Estimation_UnitPrice import cached_build + + +def _labor(book, title: str) -> dict[str, Decimal]: + return { + book.titles[r.ref_code].name: r.quantity + for r in book.details[title] + if r.ref_code in ("1002", "1003") + } + + +def test_유인헬기_인력은_갈래_밑수로_나눈_ha당() -> None: + book = cached_build().book + small = _labor(book, "B-FP-08-06-01#소형헬기(160ha당)") + assert ( + small["특별인부"] == Decimal("0.8") / 160 and small["보통인부"] == Decimal("8.2") / 160 + ), small + large = _labor(book, "B-FP-08-06-01#대형헬기(400ha당)") + assert ( + large["특별인부"] == Decimal("2.1") / 400 and large["보통인부"] == Decimal("13.4") / 400 + ), large + + +def test_밑수_1_인_2단_표는_그대로() -> None: + book = cached_build().book + rows = [r for t in book.titles if t.startswith("B-FP-12-03#") for r in book.details[t]] + assert rows and all(r.quantity < 20 for r in rows) # 철근 가공·조립 ton당 — 나누기 전과 같음 From 9563d0604a9a257156e6a6abe3b4f2a6d166790f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 02:47:59 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(b09):=20=E3=89=A1=20=EC=9C=A0=EC=9D=B8?= =?UTF-8?q?=ED=97=AC=EA=B8=B0=EB=B0=A9=EC=A0=9C=208-6-1=20=EC=86=8C?= =?UTF-8?q?=EC=9A=94=EC=9E=AC=EB=A3=8C=C2=B7=EC=96=91=EC=88=98=EA=B8=B0=20?= =?UTF-8?q?=EC=86=90=EB=A3=8C=20=E2=80=94=202=EC=9E=A5=20=ED=91=9C=20?= =?UTF-8?q?=EC=9D=BD=EB=8A=94=20=ED=95=9C=20=EB=B2=8C=EC=97=90=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=81=C2=B7=EC=9D=BC=20=EA=BC=B4(AreaSet)=20=EB=8D=94?= =?UTF-8?q?=ED=95=A8=20=C2=B7=20=EC=A7=80=EC=83=81=EB=B0=A9=EC=A0=9C?= =?UTF-8?q?=EB=8A=94=20=EB=A7=89=ED=9E=8C=20=EC=B1=84=20=EC=82=AC=EC=9C=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit · 양수기 1대 1일 호표 = 휘발유 10ℓ(2-1-8) + 잡품 주재료비 95% + 손료 0.0084(2-2-6) × 구입가(칸) · 갈래 「(160ha당)」·「(400ha당)」 = 1일 작업량으로 나눔([주]① 대형도 휘발유 10ℓ) · 깃발은 표의 「20ha당 1개」 를 읽어 ha당 · 헬기 임차료는 견적 칸 · [주]② 소방관서 급수 지원이면 휘발유 미반영 — 사유 한 줄 · 연료값 읽기를 「ℓ 은 첫 수 · % 는 % 가 붙은 칸」 으로 넓힘(체인톱 값 그대로) · 자원 목록 이름을 「유인헬기방제 양수기·깃발」 로 — 「양수기」 로 두면 12-26 물푸기 양수기 줄 풀이가 바뀌는 것을 잰 뒤 피함 · 8-6-3 지상방제는 밑수 없음으로 막힌 채 사유 둘(1일 작업량이 원문에 없음 · 15ha 는 연료 설명 / 1톤 방제차량은 카탈로그에 1톤 트럭이 없음) 오른 제목: 8-6-1 소형 +227원/ha · 대형 +90 · 나머지 그대로 · 잴 시험 다섯 빨강→초록 · 끄기 넷 빨강 · 전체 시험 1712 + 388 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- B09_Estimation/B09_Estimation_Consumables.py | 113 ++++++++++++++++-- B09_Estimation/B09_Estimation_KnownGaps.py | 5 + .../resource_catalog_ext_2026-01-01.json | 53 ++++++++ .../tester/test_b09_consumables_helicopter.py | 77 ++++++++++++ 4 files changed, 241 insertions(+), 7 deletions(-) create mode 100644 resources/tester/test_b09_consumables_helicopter.py diff --git a/B09_Estimation/B09_Estimation_Consumables.py b/B09_Estimation/B09_Estimation_Consumables.py index 5e9cbc18..01a90a12 100644 --- a/B09_Estimation/B09_Estimation_Consumables.py +++ b/B09_Estimation/B09_Estimation_Consumables.py @@ -51,6 +51,43 @@ EQUIPMENTS: tuple[Equipment, ...] = ( ), ) + +@dataclass(frozen=True) +class AreaSet: + """면적·일 꼴 — 표의 「160ha당」 이 1일 작업량인 공종(8-6-1 유인헬기 · 2026-09-15 브레인 ①③).""" + + work_item: str + machine: Equipment # 1대 1일 호표(양수기) — 갈래 면적으로 나눔 + per_area: tuple[tuple[str, str], ...] = () # (AR-M, 2-1 표 줄 이름) — 「20ha당 1개」 를 읽음 + per_day: tuple[tuple[str, str], ...] = () # (AR-M, 사유 이름) — 1일 1대 값 ÷ 갈래 면적 + + +AREA_SETS: tuple[AreaSet, ...] = ( + AreaSet( + work_item="FP-08-06-01", + machine=Equipment( + key="양수기", + machine="AR-X-ea09a419", + price="AR-M-cfc70216", + fuel=("FP-02-01-08", "F0058", "휘발유 (양수기)"), + loss=("FP-02-02-06", "F0069"), + basis="산림품셈 2-1-8 유인 헬기(160ha당) · [주]① 대형헬기도 휘발유 10ℓ · 2-2-6 양수기 손료", + ), + per_area=(("AR-M-a79b4d20", "깃 발"),), + per_day=(("AR-M-08db9ecc", "유인헬기 임차료(1일)"),), + ), +) +#: 막힌 채 사유만 보태는 공종(밑수 없음 — 성분을 얹으면 밑수가 서는 날 두 번 셈 · 브레인 ②). +BLOCKED_NOTES: dict[str, tuple[str, ...]] = { + "FP-08-06-03": ( + "경유(차량살포) 1.3ℓ/ha · 잡품 5% · 동력분무기 45HP 손료 0.0084 — 인력 표의 1일 작업량이 원문에 없음" + "(15ha 는 연료 설명) → 밑수가 없어 안 붙임", + "1톤 방제차량은 건설품셈 적산기준에 준하나 카탈로그에 1톤 트럭이 없음(덤프 2.5톤~·크레인 2톤~)", + ), +} +_RE_AREA = re.compile(r"\(\s*(\d[\d,]*(?:\.\d+)?)\s*ha\s*당\s*\)") +_RE_PER_AREA = re.compile(r"(\d+(?:\.\d+)?)\s*ha\s*당\s*(\d+(?:\.\d+)?)\s*개") + _RE_NUMBER = re.compile(r"\d+(?:\.\d+)?") @@ -74,6 +111,18 @@ def _row_numbers(rows: list[list[str]], name: str) -> list[Decimal]: ] +def _fuel_values(rows: list[list[str]], name: str) -> tuple[Decimal, Decimal] | None: + """(주연료 ℓ, 잡품 %) — ℓ 은 이름 다음 칸의 첫 수 · % 는 「%」 가 붙은 칸의 수(「주재료비의 95%」).""" + row = next((r for r in rows if r and _tight(r[0]) == _tight(name)), None) + if row is None or len(row) < 2: + return None + liters = _RE_NUMBER.search(row[1]) + misc = next((m for c in row[2:] if "%" in c and (m := _RE_NUMBER.search(c))), None) + if liters is None or misc is None: + return None + return Decimal(liters.group()), Decimal(misc.group()) + + def _fuel_title(book: Any, kind: str, fuel_region: str | None) -> str: from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle @@ -94,15 +143,15 @@ def _machine_title(build: Any, nodes: dict, eq: Equipment, fuel_region: str | No book = build.book code = f"X-{eq.machine}" - fuel_rows = _table_rows(nodes, eq.fuel[0], eq.fuel[1]) - numbers = _row_numbers(fuel_rows, eq.fuel[2]) - loss = _row_numbers(_table_rows(nodes, *eq.loss), _table_rows(nodes, *eq.loss)[0][0]) - if len(numbers) < 2 or not loss: + fuel = _fuel_values(_table_rows(nodes, eq.fuel[0], eq.fuel[1]), eq.fuel[2]) + loss_rows = _table_rows(nodes, *eq.loss) + loss = _row_numbers(loss_rows, loss_rows[0][0]) if loss_rows else [] + if fuel is None or not loss: return [f"{eq.key} — 2장 표 칸이 달라져 장비 몫을 못 읽음"] - if code in book.titles: - return [] kind = "휘발유" if "휘발유" in eq.fuel[2] else "경유" - liters, misc = numbers[0], numbers[1] + liters, misc = fuel + if code in book.titles: + return [] if eq.price in book.titles else [f"{eq.key}가격 — 손료({loss[0]} × 구입가) 칸"] book.add_title(PriceTitle(code, PriceKind.MACHINE_HOURLY, eq.key, "1대 1일", "대·일")) book.add_detail( PriceDetail(code, _fuel_title(book, kind, fuel_region), liters, note=f"주연료 {kind}") @@ -183,3 +232,53 @@ def attach_consumables( ) labels = build.unattached.setdefault(work_item, []) labels.extend(r for r in reasons if r not in labels) + for area in AREA_SETS: + _attach_area(build, nodes, area, fuel_region) + for code, notes in BLOCKED_NOTES.items(): + labels = build.unattached.setdefault(code, []) + labels.extend(n for n in notes if n not in labels) + + +def _attach_area(build: Any, nodes: dict, area: AreaSet, fuel_region: str | None) -> None: + """갈래 이름의 「(Nha당)」 = 1일 작업량 — 1일 몫(양수기·임차료)은 ÷ N · 「20ha당 1개」 는 ha당.""" + from B09_Estimation.B09_Estimation_PriceBook import PriceDetail + + book = build.book + eq = area.machine + for material in (eq.price, *(c for c, _ in area.per_area), *(c for c, _ in area.per_day)): + uses = build.material_uses.setdefault(material, []) + if area.work_item not in uses: + uses.append(area.work_item) + reasons = _machine_title(build, nodes, eq, fuel_region) + rows = _table_rows(nodes, eq.fuel[0], eq.fuel[1]) + for title in [t for t in book.titles if t.startswith(f"B-{area.work_item}#")]: + found = _RE_AREA.search(title) + if found is None or f"X-{eq.machine}" not in book.titles: + continue + hectares = Decimal(found.group(1).replace(",", "")) + per_day = Decimal(1) / hectares + note = f"1일 1대 ÷ 1일 살포면적 {hectares}ha — {eq.basis}" + book.add_detail(PriceDetail(title, f"X-{eq.machine}", per_day, note=note)) + for code, name in area.per_area: + row = next((r for r in rows if r and _tight(r[0]) == _tight(name)), []) + rule = next((m for c in row if (m := _RE_PER_AREA.search(c))), None) + if rule is None: + reasons.append(f"{_tight(name)} — 표의 「N ha당 N개」 를 못 읽음") + elif code in book.titles: + amount = Decimal(rule.group(2)) / Decimal(rule.group(1)) + book.add_detail( + PriceDetail(title, code, amount, note=f"{_tight(name)} {rule.group(0)}") + ) + else: + reasons.append( + f"{_tight(name)} — 단가 칸 · 넣으면 {rule.group(0)} 로 붙음(2-1-8 [주]③)" + ) + for code, name in area.per_day: + if code in book.titles: + book.add_detail(PriceDetail(title, code, per_day, note=f"{name} ÷ {hectares}ha")) + else: + reasons.append( + f"{name} — 견적 칸 · 넣으면 ÷ 1일 살포면적으로 붙음(품셈에 사용료 표 없음)" + ) + labels = build.unattached.setdefault(area.work_item, []) + labels.extend(r for r in reasons if r not in labels) diff --git a/B09_Estimation/B09_Estimation_KnownGaps.py b/B09_Estimation/B09_Estimation_KnownGaps.py index fec4c50c..cbf78a3d 100644 --- a/B09_Estimation/B09_Estimation_KnownGaps.py +++ b/B09_Estimation/B09_Estimation_KnownGaps.py @@ -57,6 +57,11 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = { # 2026-09-14 ㉮ — 사용횟수 갈래로 푼 뒤 남는 원문 몫. 값을 짓지 않고 말만. # 봉상후렉시블 셋의 표 나머지 줄은 판정표 자동 목록(`_unread_rows`)이 맡음 — 줄이 아닌 [주] 만 여기. "FP-12-12": ("원문 [주]", "ⓘ [주] 「성토부 날개벽 설치시 인건비 30% 할증」 은 안 걺(선택)."), + "FP-08-06-01": ( + "원문 [주]", + "ⓘ 2-1-8 [주]② 「소방관서의 급수 지원을 받은 경우에는 휘발유(양수기)는 미반영」 — 해당하면 양수기 몫을" + " 뺄 것 · 8-6-1 [주]③ 실제 사용하지 않는 품은 제외.", + ), "FP-08-11": ( "원문 [주]", "ⓘ [주]③ 장비 운반비는 별도 계상(기계 수송비 칸) · [주]④ 우드그랩은 원목 규격에 따라 별도 · [주]② 추가" diff --git a/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json b/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json index c99926b9..2df1d6be 100644 --- a/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json +++ b/resources/data_resource_catalog/resource_catalog_ext_2026-01-01.json @@ -447,6 +447,59 @@ "F0042" ] } + }, + { + "code": "AR-X-ea09a419", + "kind": "machine", + "name": "유인헬기방제 양수기", + "spec": "5HP (유인헬기 방제용)", + "unit": "대·일", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0058", + "F0069" + ] + } + }, + { + "code": "AR-M-cfc70216", + "kind": "material", + "name": "유인헬기방제 양수기 구입가", + "spec": "5HP · 손료 밑수", + "unit": "대", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0069" + ] + } + }, + { + "code": "AR-M-a79b4d20", + "kind": "material", + "name": "유인헬기방제 깃발", + "spec": "천재질·코팅·박음마감", + "unit": "개", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0058" + ] + } + }, + { + "code": "AR-M-08db9ecc", + "kind": "material", + "name": "유인헬기 임차료", + "spec": "1일 1대 · 견적", + "unit": "일", + "source": { + "pum_edition": "2026-01-01", + "pum_table_ids": [ + "F0225" + ] + } } ] } diff --git a/resources/tester/test_b09_consumables_helicopter.py b/resources/tester/test_b09_consumables_helicopter.py new file mode 100644 index 00000000..5864d690 --- /dev/null +++ b/resources/tester/test_b09_consumables_helicopter.py @@ -0,0 +1,77 @@ +"""유인헬기방제 8-6-1 소요재료·양수기 손료 — 2026-09-15 브레인 판정 ①③(2장 표 읽는 한 벌에 면적 꼴 더함). + +2-1-8 유인 헬기(160ha당): 휘발유(양수기) 10ℓ · 잡품 주재료비의 95% · 깃발 8개(20ha당 1개) + [주]① 대형헬기도 휘발유 10ℓ ③ 깃발은 20ha당 1개 · 2-2-6 양수기 5HP 손료 0.0084(1일 1대) +⇒ 표의 「160ha당」 이 1일 작업량 — 양수기 1대 1일 호표(휘발유 10ℓ + 잡품 95% + 손료)를 소형 ÷160ha · 대형 ÷400ha + 깃발은 ha당 1/20 개 · 양수기 구입가·깃발·헬기 임차료는 「자재 단가」 칸 + 사유(③) +(앞서 인력이 갈래 밑수로 안 나뉘어 160·400배 부풀던 것은 `test_b09_variant_basis` 가 바로잡음) +""" + +from __future__ import annotations + +from decimal import Decimal + +from B09_Estimation.B09_Estimation_UnitPrice import cached_build + +PUMP = "X-AR-X-ea09a419" +PUMP_PRICE = "AR-M-cfc70216" +FLAG = "AR-M-a79b4d20" +HELI = "AR-M-08db9ecc" +SMALL = "B-FP-08-06-01#소형헬기(160ha당)" +LARGE = "B-FP-08-06-01#대형헬기(400ha당)" + + +def _row(book, title: str, code: str): + return next((r for r in book.details[title] if r.ref_code == code), None) + + +def test_양수기_1대_1일_호표는_휘발유_10리터와_잡품_95퍼센트() -> None: + book = cached_build().book + fuel = _row(book, PUMP, "M-FUEL-휘발유") + assert fuel.quantity == Decimal(10), fuel + misc = next(r for r in book.details[PUMP] if r.percent_of_material is not None) + assert misc.percent_of_material == Decimal(95) + + +def test_소형은_160ha_대형은_400ha_로_나눈_하루_몫() -> None: + book = cached_build().book + assert _row(book, SMALL, PUMP).quantity == Decimal(1) / 160 + assert _row(book, LARGE, PUMP).quantity == Decimal(1) / 400 + + +def test_깃발_헬기임차료_양수기가격은_칸_넣으면_ha당으로() -> None: + none = cached_build() + left = " ".join(none.unattached.get("FP-08-06-01", [])) + assert "깃발" in left and "헬기 임차료" in left and "양수기가격" in left, left + priced = cached_build( + material_prices=( + (FLAG, "15000", "견적"), + (HELI, "3000000", "견적"), + (PUMP_PRICE, "800000", "견적"), + ) + ) + book = priced.book + assert _row(book, SMALL, FLAG).quantity == Decimal(1) / 20 + assert _row(book, LARGE, FLAG).quantity == Decimal(1) / 20 + assert _row(book, SMALL, HELI).quantity == Decimal(1) / 160 + assert _row(book, LARGE, HELI).quantity == Decimal(1) / 400 + assert book.resolve(PUMP).expense == Decimal(6720) # 800,000 × 0.0084 + + +def test_소방관서_급수_지원이면_휘발유_미반영_단서를_보임() -> None: + from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note + + assert "소방관서" in known_gap_note("FP-08-06-01") + + +def test_지상방제는_막힌_채_사유만_보탬() -> None: + build = cached_build() + assert "FP-08-06-03" in build.basis_missing + left = " ".join(build.unattached.get("FP-08-06-03", [])) + assert "15ha 는 연료 설명" in left and "1톤 트럭이 없음" in left, left + assert not any( + r.ref_code.startswith("M-FUEL") + for t in build.book.titles + if t.startswith("B-FP-08-06-03") + for r in build.book.details[t] + )