Merge remote-tracking branch 'origin/dev' into sub_laptop_1

This commit is contained in:
2026-09-15 03:02:39 +09:00
8 changed files with 608 additions and 1 deletions
@@ -394,6 +394,112 @@
"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"
]
}
},
{
"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,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
@@ -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]
)
@@ -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당 — 나누기 전과 같음