feat(b05·b08): 비탈면 녹화 ㎡ 넷(새심기·평떼·줄떼·비탈덮기) — 구간 사면 면적 × 대상 면 칸 · 떼 11매/3.33매(품셈 5-22-1·5-23-1) · 초류종자 살포는 공법에서 빼고 칸 밑 까닭 · 단 직고·선떼 급수 칸 기본값 없음(12장 D 녹화 · 브레인 ⓐ~ⓓ)

- 겹친 사면(초류종자살포·면고르기)은 자동으로 안 빼고 맨 앞 사유 · 평떼 떼꽂이 5-22-3 [주] 읽기 사유
- 혼합석 사유 정정: 토사 = B06 측점 토사 토글(구성비는 암 몫만) · 사전터파기 「산림품셈에 노면 사전 터파기 공종 없음 — 9-3-2 토사깍기(기계)로 이음」
- UnitQuantity 700줄 — 늦은 import 를 한 벌(_lazy)로

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-15 05:30:32 +09:00
co-authored by Claude Opus 5
parent 419d70d898
commit 0d4f46ecbe
10 changed files with 399 additions and 10 deletions
@@ -195,7 +195,7 @@
"work_item_code": "FP-09-03-02",
"basis_unit": "㎥",
"master_name": "토사깍기 > 기계",
"note": "2026-09-15 — 교본 10-1 「포설 전 10㎝ 터파기」 · 부록 7-2 「노면의 토사구간」이라 토사 측점만 · 브레인 ① 토공 축. ⚠ 노면 10㎝ 걷기를 토사깍기(기계)로 이음 — 산림품셈에 노면 사전 터파기 공종이 따로 없음(판정 확인 대기)."
"note": "2026-09-15 — 교본 10-1 「포설 전 10㎝ 터파기」 · 부록 7-2 「노면의 토사구간」이라 토사 측점만 · 브레인 ① 토공 축. 산림품셈에 노면 사전 터파기 공종 없음 — 9-3-2 토사깍기(기계)로 이음(같은 일에 맞는 공종을 고름 · 2026-09-15 브레인 ② 승인)."
}
],
"haul": [
@@ -534,6 +534,68 @@
],
"why": "실무 내역은 「콘크리트포장 T=20 ㎡」 한 줄이고 품셈은 포설(12-6 ㎥ · 비닐·철망깔기·양생 포함) · 거푸집(12-8 연장 m) · 줄눈(12-7) 을 따로 둠 — 거푸집·줄눈 제외라 묶음으로 셈(2026-09-15 브레인 12장 D 포장 둘). 콘크리트·비닐·철망 재료는 자재 축.",
"needs": "일위대가 조립은 B09 몫 — B08 은 물량과 묶음만 넘김"
},
{
"type_id": "revegetation",
"when": {"method": "새심기"},
"parts": [
{"code": "FP-05-14", "name": "새심기", "unit": "㎡", "from_components": ["새심기"]}
],
"why": "비탈면 녹화 한 줄(등록부 공법 고르기)이 공법마다 다른 품셈 공종으로 감 — 새심기 5-14 ㎡당(2026-09-15 브레인 녹화 ⓓ).",
"needs": "일위대가 조립은 B09 몫 — B08 은 물량과 묶음만 넘김"
},
{
"type_id": "revegetation",
"when": {"method": "평떼"},
"parts": [
{"code": "FP-05-22-02", "name": "평떼 붙임", "unit": "㎡", "from_components": ["평떼"]},
{
"code": "FP-05-22-03",
"name": "떼꽂이 제작 및 설치",
"unit": "㎡",
"from_components": ["평떼"],
"why": "5-22-3 [주] 「평떼(보통인부 0.06인)품에는 떼꽂이가 포함」은 5-12 품에 걸린 것으로 읽음 — 5-22-3 떼꽂이는 따로 셈(5-22-2 붙임을 씀 · 2026-09-15 브레인 ⓐ 승인 · 뒤집을 수 있음)"
}
],
"why": "평떼 5-22 — 떼(0.3×0.3 11매/㎡)는 자재 축 · 붙임·떼꽂이 ㎡ 묶음(2026-09-15 브레인 녹화 ⓓ).",
"needs": "일위대가 조립은 B09 몫 — B08 은 물량과 묶음만 넘김"
},
{
"type_id": "revegetation",
"when": {"method": "줄떼"},
"parts": [
{"code": "FP-05-23-02", "name": "떼붙임", "unit": "㎡", "from_components": ["줄떼"]},
{"code": "FP-05-23-03", "name": "떼꽂이 제작 및 설치", "unit": "㎡", "from_components": ["줄떼"]}
],
"why": "줄떼 5-23 — 떼(0.3×0.3 · 30㎝ 간격 3.33매/㎡)는 자재 축 · 붙임·떼꽂이 ㎡ 묶음(2026-09-15 브레인 녹화 ⓓ).",
"needs": "일위대가 조립은 B09 몫 — B08 은 물량과 묶음만 넘김"
},
{
"type_id": "revegetation",
"when": {"method": "비탈덮기", "cover_kind": "거적"},
"parts": [
{"code": "FP-05-25", "name": "거적덮기", "unit": "㎡", "from_components": ["비탈덮기"]}
],
"why": "비탈덮기(거적) — 품셈 5-25 거적덮기 ㎡당(거적 1.1㎡ 표 안 · 앙카핀 등 부속은 별도).",
"needs": "일위대가 조립은 B09 몫 — B08 은 물량과 묶음만 넘김"
},
{
"type_id": "revegetation",
"when": {"method": "비탈덮기", "cover_kind": "짚망"},
"parts": [
{"code": "FP-05-28-01", "name": "짚망", "unit": "㎡", "from_components": ["비탈덮기"]}
],
"why": "비탈덮기(짚망) — 품셈 5-28-1 · ⚠ 원문 표에 밑수(단위) 표기가 없음 — B09 가 밑수로 막으면 그 사유가 섬 · 재료량은 설계수량 별도([주]②).",
"needs": "일위대가 조립은 B09 몫 — B08 은 물량과 묶음만 넘김"
},
{
"type_id": "revegetation",
"when": {"method": "비탈덮기", "cover_kind": "방초·야자섬유매트"},
"parts": [
{"code": "FP-05-28-02", "name": "방초매트 및 야자섬유매트 포장", "unit": "㎡", "from_components": ["비탈덮기"]}
],
"why": "비탈덮기(매트) — 품셈 5-28-2 일당 시공량(폭 1.5m 이하 90㎡ · 2.0m 이하 130㎡) · 폭 갈래는 B09 몫.",
"needs": "일위대가 조립은 B09 몫 — B08 은 물량과 묶음만 넘김"
}
],
"unit_conversion": {
@@ -42,6 +42,11 @@ MARKED: dict[str, tuple[dict, list[str]]] = {
"weep_hole_area_m2",
],
),
# 2026-09-15 녹화 ㎡ 넷 — 단 직고·선떼 급수는 길이형(선떼·조공) 산출식이 설 때 읽힘.
"revegetation": (
{"method": "평떼", "face": "양쪽", "b06_cut_area_m2": 40.0, "b06_fill_area_m2": 60.0},
["step_height_m", "sod_grade"],
),
"soil_guard": (
{"height_m": 1.0, "length_m": 10.0, "form": "떼"},
[
@@ -47,6 +47,8 @@ def test_법령_조건만_자동_토사_8퍼센트_초과() -> None:
assert result["excavation_soil_m3"] == pytest.approx(9.0)
text = " ".join(result["notes"])
assert "별표2" in text and "토사 전부를 해당으로 봄" in text
# 토사/암은 B06 토사 토글이 정본 — 리핑암은 「암」의 자리표시(브레인 2026-09-15 ① 정정).
assert "토사 = B06 측점 토사 토글(구성비는 암 몫만 가름)" in text
assert "원문 표에 혼합석 줄 없음" in text and "방향이 반대" in text
assert "다짐" in text
@@ -66,6 +68,10 @@ def test_비포장_전_구간_고르개는_포장만_뺀다_암은_사전터파
assert result["excavation_soil_m3"] == pytest.approx(170.0 * 0.10)
assert any("교본 3-2" in note for note in result["notes"])
assert any("토사구간" in note for note in result["notes"])
assert any(
"산림품셈에 노면 사전 터파기 공종 없음 — 9-3-2 토사깍기(기계)로 이음" in note
for note in result["notes"]
)
def test_칸이_두께_C_L_을_덮어쓴다() -> None:
@@ -0,0 +1,115 @@
"""비탈면 녹화 ㎡ 넷 — 새심기·평떼·줄떼·비탈덮기 (2026-09-15 브레인 12장 D 녹화 ⓐ~ⓓ).
근거
산림품셈 5-14 새심기(㎡당) · 5-22 평떼(떼 0.3×0.3 11매/㎡ · 붙임 ㎡ · 떼꽂이 ㎡) ·
5-23 줄떼(떼 3.33매/㎡ · 붙임 ㎡ · 떼꽂이 ㎡) · 5-25 거적덮기(㎡당) · 5-28 비탈덮기(짚망 · 매트)
판정 — 면적 = 구간 안 사면 면적(칸: 절토면/성토면/양쪽 · 기본값 없음) · 초류종자 살포는 공법에서 뺌
(토공집계가 5-24 로 사면 전체를 셈) · 단 직고·선떼 급수 칸은 기본값 없음 · 길이형은 다음 차례.
"""
from __future__ import annotations
import pytest
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revegetation import (
revegetation_from_designs,
)
AREAS = {"b06_cut_area_m2": 40.0, "b06_fill_area_m2": 60.0}
def _row(**options) -> dict:
structure = {
"structure_id": "g1",
"type_id": "revegetation",
"start_m": 0.0,
"end_m": 20.0,
"options": {**AREAS, **options},
}
table = build_table([structure], {"revegetation": "비탈면 녹화"})
return table
def _parts(table: dict) -> dict:
row = next(
r for r in build_handoff(unit_quantity_table=table)["work_items"] if "녹화" in r["name"]
)
return {part["code"]: part for part in row["composite_parts"] or []}
def test_평떼_양쪽은_면적과_떼_11매() -> None:
table = _row(method="평떼", face="양쪽")
row = table["structures"][0]
got = {(c["name"], c["unit"]): c["amount"] for c in row["components"]}
assert (row["billing_unit"], row["billing_quantity"]) == ("㎡", pytest.approx(100.0))
assert got[("평떼", "㎡")] == pytest.approx(100.0)
assert got[("떼", "매")] == pytest.approx(1100.0)
text = " ".join(row["notes"])
assert "겹친 몫을 자동으로 빼지 않음" in text and row["notes"][0].startswith("⚠")
assert "5-12 품에 걸린 것으로 읽음 — 5-22-3 떼꽂이는 따로 셈" in text
parts = _parts(table)
assert parts["FP-05-22-02"]["quantity"] == pytest.approx(100.0)
assert parts["FP-05-22-03"]["quantity"] == pytest.approx(100.0)
def test_줄떼_절토면은_3_33매() -> None:
table = _row(method="줄떼", face="절토면")
got = {(c["name"], c["unit"]): c["amount"] for c in table["structures"][0]["components"]}
assert got[("줄떼", "㎡")] == pytest.approx(40.0)
assert got[("떼", "매")] == pytest.approx(40.0 * 3.33)
assert set(_parts(table)) == {"FP-05-23-02", "FP-05-23-03"}
def test_새심기_성토면() -> None:
table = _row(method="새심기", face="성토면")
assert _parts(table)["FP-05-14"]["quantity"] == pytest.approx(60.0)
def test_비탈덮기는_덮개를_골라야_선다() -> None:
table = _row(method="비탈덮기", face="양쪽", cover_kind="거적")
assert _parts(table)["FP-05-25"]["quantity"] == pytest.approx(100.0)
unset = _row(method="비탈덮기", face="양쪽")["structures"][0]
assert not unset["components"] and any("덮개" in n for n in unset["notes"])
def test_면을_안_고르면_사유() -> None:
row = _row(method="평떼")["structures"][0]
assert not row["components"] and any("절토면" in n and "성토면" in n for n in row["notes"])
def test_길이형은_떼_매수표를_기다린다() -> None:
row = _row(method="선떼붙이기", face="양쪽")["structures"][0]
assert not row["components"]
assert any("사방기술교본" in n for n in row["notes"])
def test_구간_안_사면_면적은_B06_측점에서_보간() -> None:
designs = [
{"chainage_m": 0.0, "design": {}},
{"chainage_m": 20.0, "design": {}},
]
from B08_Quantity.B08_Quantity_Engine_SlopeLength import StationSlope
slopes = [
StationSlope(chainage_m=0.0, cut_length_m=2.0, fill_length_m=4.0),
StationSlope(chainage_m=20.0, cut_length_m=4.0, fill_length_m=0.0),
]
structure = {"type_id": "revegetation", "start_m": 5.0, "end_m": 20.0, "options": {}}
filled = revegetation_from_designs(structure, designs, slopes)
# 절토 5→2.5, 20→4 : (2.5+4)/2×15 · 성토 5→3, 20→0 : (3+0)/2×15
assert filled["options"]["b06_cut_area_m2"] == pytest.approx(48.75)
assert filled["options"]["b06_fill_area_m2"] == pytest.approx(22.5)
def test_등록부_공법에서_초류종자_살포를_빼고_까닭을_보인다() -> None:
options = {o.key: o for o in structure_type_map()["revegetation"].options}
assert "초류종자 살포" not in options["method"].choices
assert "토공집계" in (options["method"].default_basis or "")
assert (
options["face"].choices == ["절토면", "성토면", "양쪽"] and options["face"].default is None
)
for key in ("step_height_m", "sod_grade"):
assert options[key].default is None and "아직 표에 안 쓰임" in options[key].not_in_table