feat(b09): ㉡ 유인헬기방제 8-6-1 소요재료·양수기 손료 — 2장 표 읽는 한 벌에 면적·일 꼴(AreaSet) 더함 · 지상방제는 막힌 채 사유
· 양수기 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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": (
|
||||
"원문 [주]",
|
||||
"ⓘ [주]③ 장비 운반비는 별도 계상(기계 수송비 칸) · [주]④ 우드그랩은 원목 규격에 따라 별도 · [주]② 추가"
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
Reference in New Issue
Block a user