feat(b09): 초류종자살포 5-24 둘이 섬 — 트럭 → 덤프트럭 4.5 · 종자살포기 → 취부기 11.94㎾ 손료만(8-4 줄 없음 · 거창 실무) · 종자 후보 넷 칸 · 어긋남 사유 나란히(브레인 판정 ①②′④)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-15 04:19:23 +09:00
co-authored by Claude Opus 5
parent 078af37d33
commit 3d896dec50
8 changed files with 189 additions and 7 deletions
@@ -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)」 줄을 "
@@ -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,
}
)
@@ -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)
@@ -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
@@ -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
+9 -2
View File
@@ -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
@@ -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 브레인 판정 ②"
}
]
}
+76
View File
@@ -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