From 3d896dec5088623c20e1c98d45402417564095be Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 04:19:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09):=20=EC=B4=88=EB=A5=98=EC=A2=85?= =?UTF-8?q?=EC=9E=90=EC=82=B4=ED=8F=AC=205-24=20=EB=91=98=EC=9D=B4=20?= =?UTF-8?q?=EC=84=AC=20=E2=80=94=20=ED=8A=B8=EB=9F=AD=20=E2=86=92=20?= =?UTF-8?q?=EB=8D=A4=ED=94=84=ED=8A=B8=EB=9F=AD=204.5=20=C2=B7=20=EC=A2=85?= =?UTF-8?q?=EC=9E=90=EC=82=B4=ED=8F=AC=EA=B8=B0=20=E2=86=92=20=EC=B7=A8?= =?UTF-8?q?=EB=B6=80=EA=B8=B0=2011.94=E3=8E=BE=20=EC=86=90=EB=A3=8C?= =?UTF-8?q?=EB=A7=8C(8-4=20=EC=A4=84=20=EC=97=86=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EA=B1=B0=EC=B0=BD=20=EC=8B=A4=EB=AC=B4)=20=C2=B7=20=EC=A2=85?= =?UTF-8?q?=EC=9E=90=20=ED=9B=84=EB=B3=B4=20=EB=84=B7=20=EC=B9=B8=20=C2=B7?= =?UTF-8?q?=20=EC=96=B4=EA=B8=8B=EB=82=A8=20=EC=82=AC=EC=9C=A0=20=EB=82=98?= =?UTF-8?q?=EB=9E=80=ED=9E=88(=EB=B8=8C=EB=A0=88=EC=9D=B8=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=20=E2=91=A0=E2=91=A1=E2=80=B2=E2=91=A3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- B09_Estimation/B09_Estimation_KnownGaps.py | 9 +++ .../B09_Estimation_MachineExpenseSheet.py | 6 +- .../B09_Estimation_MachineOperating.py | 9 +++ .../B09_Estimation_ResourceAxis_Join.py | 12 ++- B09_Estimation/B09_Estimation_SeedSpray.py | 57 ++++++++++++++ B09_Estimation/B09_Estimation_UnitPrice.py | 11 ++- .../data_aliases/aliases_2026-01-01.json | 16 ++++ resources/tester/test_b09_seed_spray.py | 76 +++++++++++++++++++ 8 files changed, 189 insertions(+), 7 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_SeedSpray.py create mode 100644 resources/tester/test_b09_seed_spray.py diff --git a/B09_Estimation/B09_Estimation_KnownGaps.py b/B09_Estimation/B09_Estimation_KnownGaps.py index 0c67c8d0..550c4258 100644 --- a/B09_Estimation/B09_Estimation_KnownGaps.py +++ b/B09_Estimation/B09_Estimation_KnownGaps.py @@ -114,6 +114,15 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = { "ⓘ 재료(합판·못 카탈로그 없음 · 각재·철선·박리제 규격 미정)는 못 붙은 줄 — 지금 값은" " **인력 품만** · [주]③ 동바리 별도 · [주]⑥ 소형구조물 인력품 30% 할증(선택)은 안 걺.", ), + "FP-05-24": ( + "실무 역산", + "ⓘ 원문이 애매해 실무(거창 「초류종자살포(씨드스프레이)」)를 따른 자리 둘 — 뒤집을 수 있게 어긋남을" + " 나란히: ① 「트럭 4.5ton」 → 덤프트럭 4.5(원문은 트럭 · 카탈로그에 트럭 없음 · 거창이 덤프트럭 4.5Ton)" + " ② 「종자살포기 2,500-3,000ℓ」 → 취부기 11.94㎾(원문 규격은 ℓ 탱크 용량 · 카탈로그는 ㎾ · 건설 4-1-3" + "·거창이 취부기) · 취부기는 손료만(8-4 운전경비표에 줄 없음 · 거창도 손료만) · ⚠ 거창은 디젤엔진 11.19㎾" + " 를 따로 세움 — 산림 5-24 · 건설 4-1-3 표 둘 다에 없어 안 넣음 · 물탱크 조종원은 화물차운전사(8-1-2 5호" + " 살수차) · [주]⑦ 물주기(인력) 보통인부 0.0005인은 필요시라 안 걺(2026-09-15 브레인).", + ), "FP-12-25": ( "운반거리 미정", "⚠ 이 값에는 **운반 몫이 빠져 있습니다** — 품셈 12-25 는 「운반 | 덤프트럭(15ton)」 줄을 " diff --git a/B09_Estimation/B09_Estimation_MachineExpenseSheet.py b/B09_Estimation/B09_Estimation_MachineExpenseSheet.py index 7a80b66f..4f399ade 100644 --- a/B09_Estimation/B09_Estimation_MachineExpenseSheet.py +++ b/B09_Estimation/B09_Estimation_MachineExpenseSheet.py @@ -81,6 +81,7 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]: """**내역에 실제로 선 기종만** 한 장씩. 안 쓰는 613 기종을 다 뿌리지 않는다.""" from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price from B09_Estimation.B09_Estimation_MachineOperating import ( + LOSS_ONLY_MACHINES, load_operating_records, load_operator_wages, ) @@ -120,7 +121,8 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]: money = build.book.resolve(code) gaps: list[str] = [] - attachment = machine_code.startswith(_ATTACHMENT_PREFIXES) + loss_only = LOSS_ONLY_MACHINES.get(machine_code, "") # 8-4 에 줄 없음 · 손료만(②′) + attachment = machine_code.startswith(_ATTACHMENT_PREFIXES) or bool(loss_only) fuel_liters = getattr(record, "fuel_liters_per_hour", None) # 연료 종류대로 그 유가(휘발유 기계가 경유값으로 보이던 자리 · 661 뒤 ②). fuel_price, fuel_meta = ( @@ -181,7 +183,7 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]: "expense_krw": _money(money.expense), "total_krw": _money(money.total), "attachment": attachment, - "attachment_note": ATTACHMENT_NOTE if attachment else "", + "attachment_note": loss_only or (ATTACHMENT_NOTE if attachment else ""), "gaps": gaps, } ) diff --git a/B09_Estimation/B09_Estimation_MachineOperating.py b/B09_Estimation/B09_Estimation_MachineOperating.py index 7e4e802a..4f5f3e63 100644 --- a/B09_Estimation/B09_Estimation_MachineOperating.py +++ b/B09_Estimation/B09_Estimation_MachineOperating.py @@ -62,6 +62,15 @@ MERGED_CELL_OPERATING_FIXES: dict[str, str] = { "7204-0065": "9.4", "7204-0160": "12.9", } +#: **운전경비 줄이 원문에 없는 기계** — 손료만으로 층을 세움(부착 장비 길 · 2026-09-15 브레인 판정 ②′). +#: ⚠ 이 목록 밖으로 넓히지 말 것 — 연료가 빠진 채 조용히 싼 값이 섬. 줄마다 원문 없음 · 실무 실증을 적음. +LOSS_ONLY_MACHINES: dict[str, str] = { + "7750-0016": ( + "손료만 — 건설품셈 8-4 운전경비표에 취부기(7750) 줄이 없음(원문 없음) · 거창 실무 「취부기(녹생토)" + " 11.94kW」 도 손료 20,295 만 · ⚠ 거창은 디젤엔진 11.19kW 를 따로 세움 — 산림 5-24 · 건설 4-1-3" + " 표 둘 다에 없어 안 넣음" + ), +} _WATER_TANK_MISC_PERCENT = Decimal(30) _WATER_TANK_OPERATORS = Decimal(1) diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_Join.py b/B09_Estimation/B09_Estimation_ResourceAxis_Join.py index 00d1cc53..3cd10d63 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis_Join.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis_Join.py @@ -128,14 +128,20 @@ def scoped_alias_entry( 흐려짐 — 별칭이 코드를 적었으면 그 코드가 곧 답(2026-09-14 · 9-19-3 소형브레이커). ⚠ 별칭은 **빈 곳을 채움** — 옆 칸이 규격을 적었으면 안 덮음(같은 9-19-1 성토면 「굴착기 | 0.6㎥」 가 절토면 [주]① 0.7 로 덮이던 자리 · 2026-09-14 판정 Ⓐ). + 단, 대상 규격이 옆 칸 규격과 **같으면** 덮는 것이 아님 — 이름만 이음(5-24 「트럭 | 4.5ton」 → + 덤프트럭 4.5 · 2026-09-15 판정 ①). """ - if _writes_spec(side_cells): - return None wanted = _normalize(name_cell) for row in catalog.scoped_aliases: if _normalize(row["from"]) != wanted or not in_scope(work_item_code, row["scope"]): continue - return next((entry for entry in catalog.entries if entry.code == row["to"]), None) + entry = next((entry for entry in catalog.entries if entry.code == row["to"]), None) + if entry is not None and _writes_spec(side_cells): + same = any( + _RE_SIDE_SPEC.match(_normalize(c)) and same_spec(entry.spec, c) for c in side_cells + ) + return entry if same else None + return entry return None diff --git a/B09_Estimation/B09_Estimation_SeedSpray.py b/B09_Estimation/B09_Estimation_SeedSpray.py new file mode 100644 index 00000000..a3e2d49b --- /dev/null +++ b/B09_Estimation/B09_Estimation_SeedSpray.py @@ -0,0 +1,57 @@ +"""B09 원가계산 — **초류종자살포 5-24 종자**(2026-09-15 브레인 판정 ④ · 화약류 길). + +표 「종자 kg 0.025」 는 규격을 안 적음 — 자원 목록에 종자 후보가 넷(생태복원형 초본류·목본류 · 초본류 · 목본류)이라 +조인 규칙(규격이 같아야)에 걸려 못 붙었음. 배합은 [주]①③ 이 현장 판단(도로비탈면 녹화 지침 · 방향·고도·계절)으로 넘김. +⇒ 넷 모두 「자재 단가」 탭에 칸이 서고 **설계자가 넣은 하나**가 표 수량으로 붙음 · 안 넣으면 「규격 미정」 · + 둘 이상 넣으면 고르지 않고 사유(임의로 규격을 고르지 않음). +""" + +from __future__ import annotations + +from typing import Any + +CODES = ("FP-05-24-01", "FP-05-24-02") +NAME = "종자" +#: (자원 목록 코드, 규격) — 자원 목록 `resource_catalog_ext` 의 종자 넷. +SEEDS: tuple[tuple[str, str], ...] = ( + ("AR-M-f89c57d4", "생태복원형,초본류"), + ("AR-M-d52b8fc1", "생태복원형,목본류"), + ("AR-M-8f852514", "초본류"), + ("AR-M-e39ee36e", "목본류"), +) +SEED_MISSING = ( + "종자 — 규격 미정(원문 표가 규격을 안 적음 · 후보 넷 " + + " · ".join(spec for _, spec in SEEDS) + + ") · 「자재 단가」 탭에서 하나를 넣으면 붙음" +) +SEED_TWO = "종자 — 후보가 둘 이상 들어옴({specs}) · 하나만 넣을 것" + + +def attach_seed_spray(build: Any, nodes_by_code: dict[str, dict[str, Any]]) -> None: + """칸 넷을 세우고(`material_uses`) · 넣은 하나만 붙이고 · 없거나 둘이면 사유로 갈아 끼움.""" + from B09_Estimation.B09_Estimation_Explosives import _table_amount + from B09_Estimation.B09_Estimation_PriceBook import PriceDetail + + book = build.book + for code in CODES: + for material, _ in SEEDS: + uses = build.material_uses.setdefault(material, []) + if code not in uses: + uses.append(code) + labels = [ + label for label in build.unattached.get(code) or [] if "".join(label.split()) != NAME + ] + amount = _table_amount(nodes_by_code.get(code) or {}, NAME) + picked = [(material, spec) for material, spec in SEEDS if material in book.titles] + titles = [t for t in book.titles if t == f"B-{code}" or t.startswith(f"B-{code}#")] + if len(picked) == 1 and amount is not None and titles: + material, spec = picked[0] + for title in titles: + book.add_detail( + PriceDetail(title, material, amount, note=f"종자 {spec} — 설계자 선택") + ) + elif len(picked) > 1: + labels.append(SEED_TWO.format(specs=" · ".join(spec for _, spec in picked))) + else: + labels.append(SEED_MISSING) + build.unattached[code] = labels diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index da583d2f..1f8442c6 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -45,6 +45,7 @@ from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import ( formula_machine_codes, ) from B09_Estimation.B09_Estimation_MachineOperating import ( + LOSS_ONLY_MACHINES, load_fuel_price, load_operating_records, LABOR_RELIABILITY_LABEL, @@ -354,7 +355,8 @@ def _add_machine_layers( if machine is None or machine.loss_coefficient_per_hour is None: incomplete.append(code) continue - if record is None and not attachment: + loss_only = LOSS_ONLY_MACHINES.get(code) # 8-4 에 줄 없는 기계 — 원문 없음 · 실무 실증(②′) + if record is None and not attachment and not loss_only: incomplete.append(code) continue @@ -394,7 +396,8 @@ def _add_machine_layers( hourly_code, base_code, Decimal(0), - note=( + note=loss_only + or ( "부착용 장비 — 연료·조종원은 본체(굴착기·불도저)에 듭니다" " (품셈 제8장 [주]⑤)." ), @@ -1106,6 +1109,10 @@ def build_unit_prices( from B09_Estimation.B09_Estimation_Explosives import attach_explosives attach_explosives(build, nodes_by_code) + # 초류종자살포 5-24 종자 — 후보 넷 칸 · 넣은 하나만 붙음(2026-09-15 ④ 화약류 길). + from B09_Estimation.B09_Estimation_SeedSpray import attach_seed_spray + + attach_seed_spray(build, nodes_by_code) # 표토제거 답외구간(9-15-2) — 계수표를 불도저 식 + T 로(2026-09-14 ㉰). from B09_Estimation.B09_Estimation_TopsoilRemoval import attach_topsoil_removal diff --git a/resources/data_aliases/aliases_2026-01-01.json b/resources/data_aliases/aliases_2026-01-01.json index 90611b55..a35ee88a 100644 --- a/resources/data_aliases/aliases_2026-01-01.json +++ b/resources/data_aliases/aliases_2026-01-01.json @@ -146,6 +146,22 @@ "scope": "FP-10-12", "pum_edition": "2026-01-01", "basis": "품셈 10-12 덤프 운반 잎이 토사·암절취·발파암으로 갈리고 [주]③ 환산계수가 「암절취 1.35」(10-11 f 파쇄암 1/1.35 와 같은 값) — 우리 리핑암(리퍼 절취) = 품셈 암절취. 옛 매핑표 ground_aliases 에서 범위를 붙여 옮김(2026-09-13). ⚠ 쓰는 곳: 덤프 운반 잎 고르기(아직 없음 — 갈래 고르기형 부모 판정 대기)" + }, + { + "axis": "resource", + "from": "트럭", + "to": "0602-0045", + "scope": "FP-05-24", + "pum_edition": "2026-01-01", + "basis": "산림 5-24 표 「트럭 4.5ton」 — 카탈로그에 「트럭」 이 없음(덤프트럭·크레인만) = 원문 애매 → 실무. 거창 실무 호표 「51.초류종자살포(씨드스프레이) 나. 덤프트럭 (4.5Ton)」(건설 4-1-3 표 「트럭 4.5ton」 을 덤프트럭 4.5 로 셈). ⚠ 어긋남: 원문은 트럭(건설 4-1-3 [주]① 트럭에 종자살포기 장착) · 카탈로그는 덤프트럭. 2026-09-15 브레인 판정 ①" + }, + { + "axis": "resource", + "from": "종자살포기", + "to": "7750-0016", + "scope": "FP-05-24", + "pum_edition": "2026-01-01", + "basis": "산림 5-24 표 「종자살포기 2,500-3,000ℓ」 — 원문 규격은 ℓ(탱크 용량) · 카탈로그는 취부기(녹생토 암절개면 보호식재용) 11.94㎾ 로 같은 기계라는 원문 근거 없음 = 애매 → 실무. 5-24-1 [주]⑤ 가 건설 4-1-2 초류종자 살포를 참조 · 건설 4-1-3 표 「취부기 11.94kW」 · 거창 실무 「종자살포기 : 취부기(녹생토) (11.94kW)」. 2026-09-15 브레인 판정 ②" } ] } diff --git a/resources/tester/test_b09_seed_spray.py b/resources/tester/test_b09_seed_spray.py new file mode 100644 index 00000000..52a9f8ab --- /dev/null +++ b/resources/tester/test_b09_seed_spray.py @@ -0,0 +1,76 @@ +"""초류종자살포 5-24-1·2 뿜어붙이기 — 2026-09-15 브레인 판정 ①②′④ (③ 물탱크는 `test_b09_water_tank_operating`). + +산림 5-24 표(㎡당): 종자 0.025 · 복합비료 0.1 · 화이버 0.18 · 합성접착제 0.1 · 색소 0.002 kg · + 종자살포기 2,500-3,000ℓ 0.0024h · 트럭 4.5ton 0.0024h · 물탱크 5,500ℓ 0.0036h · 조경공 0.0007 · 보통인부 0.0004 + [주]⑤ 「건설공사 표준품셈 공통부문 4-1-2 초류종자 살포」 참조 +① 트럭 4.5ton → 덤프트럭 4.5(0602-0045) — 카탈로그에 「트럭」 없음(원문 애매) · 거창 실무 「덤프트럭 (4.5Ton)」 +②′ 종자살포기 → 취부기 11.94㎾(7750-0016) · **손료만** — 원문 규격은 ℓ(탱크 용량) · 건설 4-1-3 「취부기 11.94kW」 · + 거창 실무 「종자살포기 : 취부기(녹생토)」 · 8-4 운전경비표에 7750 줄 없음(원문 없음) · 거창도 손료 20,295 만 + 디젤엔진 11.19㎾ 는 거창만 따로 세움 — 산림 5-24 · 건설 4-1-3 표 둘 다에 없어 안 넣고 어긋남만 기록 +④ 종자 규격 후보 넷(생태복원형 초본·목본 · 초본류 · 목본류) — 화약류 길: 「자재 단가」 칸 · 넣은 쪽 하나가 붙음 +""" + +from __future__ import annotations + +from decimal import ROUND_FLOOR, Decimal + +from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note +from B09_Estimation.B09_Estimation_MachineExpenseSheet import machine_expense_sheets +from B09_Estimation.B09_Estimation_UnitPrice import cached_build + +CODES = ("FP-05-24-01", "FP-05-24-02") +SEEDS = ("AR-M-f89c57d4", "AR-M-d52b8fc1", "AR-M-8f852514", "AR-M-e39ee36e") + + +def _rows(book, code: str) -> dict[str, Decimal]: + return {r.ref_code: r.quantity for r in book.details[f"B-{code}"]} + + +def test_트럭은_덤프트럭_종자살포기는_취부기_물탱크까지_표_시간대로() -> None: + build = cached_build() + for code in CODES: + rows = _rows(build.book, code) + assert rows["X-0602-0045"] == Decimal("0.0024"), rows + assert rows["X-7750-0016"] == Decimal("0.0024"), rows + assert rows["X-7204-0055"] == Decimal("0.0036"), rows + left = " ".join(build.unattached.get(code, [])) + assert "트 럭" not in left and "종자살포기" not in left, left + assert code not in build.component_gaps, build.component_gaps.get(code) + + +def test_취부기는_손료만으로_서고_사유가_붙음() -> None: + build = cached_build() + assert not any(m.startswith("7750-0016") for m in build.incomplete_machines) + money = build.book.resolve("X-7750-0016") + assert money.material == 0 and money.labor == 0 + assert ( + money.expense.quantize(Decimal(1), rounding=ROUND_FLOOR) == 20295 + ) # 거창 46,603,000 × 4,355 + notes = " ".join(r.note for r in build.book.details["X-7750-0016"]) + assert "8-4" in notes and "거창" in notes, notes + sheet = next(s for s in machine_expense_sheets(build) if s["code"] == "X-7750-0016") + assert sheet["gaps"] == [] and "8-4" in sheet["attachment_note"], sheet + + +def test_종자는_후보_넷_칸_넣은_하나만_붙고_둘이면_사유() -> None: + none = cached_build() + for code in CODES: + assert all(code in none.material_uses.get(seed, []) for seed in SEEDS) + left = " ".join(none.unattached.get(code, [])) + assert "종자 — 규격 미정" in left and "종 자" not in left, left + one = cached_build(material_prices=(("AR-M-8f852514", "20000", "견적"),)) + assert _rows(one.book, "FP-05-24-01")["AR-M-8f852514"] == Decimal("0.025") + assert "종자" not in " ".join(one.unattached.get("FP-05-24-01", [])) + two = cached_build( + material_prices=(("AR-M-8f852514", "20000", "견적"), ("AR-M-e39ee36e", "30000", "견적")) + ) + rows = _rows(two.book, "FP-05-24-01") + assert "AR-M-8f852514" not in rows and "AR-M-e39ee36e" not in rows + assert "하나만" in " ".join(two.unattached.get("FP-05-24-01", [])) + + +def test_사유는_어긋남을_나란히() -> None: + note = known_gap_note("FP-05-24-01") + for word in ("트럭", "덤프트럭", "ℓ", "취부기", "손료만", "디젤엔진"): + assert word in note, (word, note) + assert known_gap_note("FP-05-24-02") == note