feat(b08,b09): 면고르기 줄 — 밑수 = 초류종자살포 면적(덮어쓰기 칸 · 파종 0 이면 0 + 사유) · 절토면은 사면 조각 토사/암으로 갈라 암은 시공법(9-19-2·3) · 절토면 토질 · 성토면 시공·토질 칸(제안값 없음 · 비면 입력 사유) · 성토면 기계는 규격 미정 사유(브레인 판정 Ⓐ~Ⓓ)

실무 대조: 오솔길 BOM 6벌 면적 셈법 ±0.11% · 거창 줄 없음 · 영월 성토면 × 50% · 원문 9-19-1 성토면·9-19-2·3 식재 조건부
배정 프로젝트: 인계 줄 +3(성토면 12,350㎡ · 절토면 토사 3,085.51 · 암 2,262.62) 외 변화 0 · 본체 합계 105,512,060 그대로 · 규격 글로 시작하는 갈래는 규격에 한 번만(도자 운반 「토사 토사」 → 「토사」 · 금액 불변)

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-14 15:25:03 +09:00
co-authored by Claude Opus 5
parent df547a90f8
commit ee9368ec66
11 changed files with 402 additions and 13 deletions
@@ -94,11 +94,24 @@
"mismatch_reason": "층따기 길이(깊이)가 아직 입력되지 않았습니다 — 산출 조건에서 넣으면 면적 × 길이로 물량이 섭니다(면적은 이미 섰습니다).",
"mismatch_kind": "input_missing"
},
{
"group": "면고르기",
"work_item_code": "FP-09-19-01",
"master_name": "면고르기 > 토사면 고르기",
"variant_axis": "face_dressing_fill_class",
"variant_from": "face_dressing_fill_class",
"variant_missing_reason": "면고르기 성토면 시공·토질이 아직 입력되지 않았습니다 — 산출 조건에서 고르면 단가가 섭니다(품셈 9-19-1 성토면: 인력 점토·모래 2갈래 · 기계 1갈래)",
"note": "성토면 줄(지반 없음) — 2026-09-14 브레인 판정: 밑수 = 초류종자살포 면적(Ⓐ) · 시공·토질은 산출 조건 칸 · 제안값 없음(Ⓒ)."
},
{
"group": "면고르기",
"ground": "토사",
"work_item_code": "FP-09-19-01",
"master_name": "면고르기 > 토사면 고르기"
"master_name": "면고르기 > 토사면 고르기",
"variant_axis": "face_dressing_cut_class",
"variant_from": "face_dressing_cut_class",
"variant_missing_reason": "면고르기 절토면 토질이 아직 입력되지 않았습니다 — 산출 조건에서 고르면 단가가 섭니다(품셈 9-19-1 절토면 토질 6갈래)",
"note": "절토면 토사 몫 — 사면 조각의 토사/암으로 가름, 암 몫은 흙깎기와 같은 시공법으로 9-19-2·3(2026-09-14 판정 Ⓑ)."
},
{
"group": "면고르기",
+211
View File
@@ -0,0 +1,211 @@
"""면고르기 줄 — 2026-09-14 브레인 판정 Ⓐ~Ⓓ.
종전: 면적(face_dressing 성·절토)은 있는데 토공집계가 줄을 안 세워 인계가 안 보냄(내역 0줄).
실무 대조: 오솔길 BOM 6벌 면적 셈법 ±0.11% 일치 · 그러나 내역엔 사면적 통째가 아님
(거창 줄 없음 · 영월 성토면 × 50%) — 원문 9-19-1 성토면 [주] 「식재를 위한」 · 9-19-2·3 [주]①
「식재기반 조성에만」 → Ⓐ 밑수 = 초류종자살포(파종) 면적 · 덮어쓰기 칸 · 파종 0 이면 0 + 사유
Ⓑ 절토면 토사/암 갈라 암은 시공법(리핑 9-19-2 · 발파 9-19-3) Ⓒ 토질·시공 칸 둘 · 제안값 없음
Ⓓ 성토면 기계(굴착기 0.6㎥ 형식 둘)는 사유로.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from test_b08_slope_area import 절토_설계선 # noqa: E402
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
FACE_DRESSING_CUT_CLASSES,
FACE_DRESSING_FILL_CLASSES,
SummaryInput,
build_rows,
build_table,
)
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402
from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as slope_table # noqa: E402
from B08_Quantity.B08_Quantity_Engine_SlopeLength import ( # noqa: E402
StationSlope,
station_slope,
)
CUT_SAND = "절토면 · 모래ㆍ사질토ㆍ점토ㆍ점질토"
FILL_CLAY = "성토면 · 인력 · 점토 또는 점질토"
# ── 사면길이 · 면적 — 절토면을 토사/암으로 ─────────────────────────────
def test_절토_사면길이가_토사_암으로_갈림() -> None:
slope = station_slope(20.0, 절토_설계선())
assert slope.cut_soil_length_m + slope.cut_rock_length_m == pytest.approx(slope.cut_length_m)
soil = sum(s.length_m for s in slope.segments if s.role == "cut" and s.material == "soil")
assert slope.cut_soil_length_m == pytest.approx(soil) and soil > 0
assert slope.cut_rock_length_m > 0
def test_2단이_아닌_측점은_설계_지반_프리셋으로_가르고_모르면_안_가름() -> None:
"""토사 프리셋은 비탈 전체가 토사 · 암 프리셋 1단은 전체가 암 · 프리셋이 없으면 못 가름."""
one = {**절토_설계선(), "two_stage_slope": False}
soil = station_slope(20.0, {**one, "geometry_preset": "soil"})
assert (
soil.cut_soil_length_m == pytest.approx(soil.cut_length_m) and soil.cut_rock_length_m == 0
)
rock = station_slope(20.0, {**one, "geometry_preset": "rock"})
assert (
rock.cut_rock_length_m == pytest.approx(rock.cut_length_m) and rock.cut_soil_length_m == 0
)
unknown = station_slope(20.0, one)
assert unknown.cut_length_m > 0 and unknown.cut_soil_length_m == unknown.cut_rock_length_m == 0
def test_면적_합계에_절토면_토사_암이_평균단면적법으로_실림() -> None:
one = StationSlope(
chainage_m=0.0, cut_length_m=4.0, cut_soil_length_m=3.0, cut_rock_length_m=1.0
)
two = StationSlope(
chainage_m=20, cut_length_m=6.0, cut_soil_length_m=2.0, cut_rock_length_m=4.0
)
slopes = [one, two]
totals = slope_table(slopes)["totals"]
assert totals["face_dressing_cut"] == pytest.approx(100.0)
assert totals["face_dressing_cut_soil"] == pytest.approx(50.0)
assert totals["face_dressing_cut_rock"] == pytest.approx(50.0)
# ── 토공집계 — 밑수 = 파종 면적 ──────────────────────────────────────
def _source(**extra) -> SummaryInput:
base = dict(
slope_totals={
"face_dressing_fill": 1000.0,
"face_dressing_cut": 500.0,
"face_dressing_cut_soil": 300.0,
"face_dressing_cut_rock": 200.0,
},
rock_classes=["토사", "연암", "경암"],
rock_ratios_pct={"연암": 50.0, "경암": 50.0},
application_ratios={"seed_spray_fill": 0.5, "seed_spray_cut": 1.0},
)
base.update(extra)
return SummaryInput(**base)
def _faces(source: SummaryInput) -> list[tuple[str, str, float, bool]]:
return [
(row.spec, row.item, round(row.amount, 6), row.in_bill)
for row in build_rows(source)
if row.group == "면고르기"
]
def test_면고르기는_파종_면적을_따라가고_절토면은_토사_암_갈래로() -> None:
"""성토 1000 × 파종 50% = 500 · 절토 500 × 100% → 토사 300 · 암 200 을 구성비로 연암·경암."""
assert _faces(_source()) == [
("성토면", "", 500.0, True),
("절토면", "토사", 300.0, True),
("절토면 · 연암", "연암", 100.0, True),
("절토면 · 경암", "경암", 100.0, True),
]
fill = next(r for r in build_table(_source())["rows"] if r["spec"] == "성토면")
assert fill["amount_gross"] == 1000.0 and fill["application_ratio_pct"] == 50.0
assert "초류종자살포" in fill["note"]
def test_덮어쓴_면적이_이기고_비고에_파종_면적과_나란히() -> None:
source = _source(face_dressing_area_m2={"fill": 123.0, "cut": 100.0})
faces = _faces(source)
assert faces[0] == ("성토면", "", 123.0, True)
assert faces[1] == ("절토면", "토사", 60.0, True) # 100 × 300/500
fill = next(r for r in build_rows(source) if r.group == "면고르기")
assert "123" in fill.note and "500" in fill.note
def test_파종_면적이_0이면_면고르기도_0이고_내역에_안_섬() -> None:
source = _source(application_ratios={"seed_spray_fill": 0.0, "seed_spray_cut": 0.0})
faces = _faces(source)
assert faces[0] == ("성토면", "", 0.0, False)
rows = [r for r in build_rows(source) if r.group == "면고르기"]
assert all("파종 면적이 0" in r.note for r in rows)
# ── 인계 — 코드 · 갈래 · 칸 비면 사유 ─────────────────────────────────
def _handoff(**extra) -> dict:
summary = build_table(_source())
return build_handoff(
summary_table=summary,
ground_classes=["토사", "연암", "경암"],
ground_methods={"연암": "ripping", "경암": "blasting"},
**extra,
)
def _items(handoff: dict) -> list[dict]:
return [row for row in handoff["work_items"] if row["name"] == "면고르기"]
def test_면고르기_인계는_성토면_절토면_토사_암을_각_절로() -> None:
items = _items(_handoff(face_dressing_cut_class=CUT_SAND, face_dressing_fill_class=FILL_CLAY))
got = [(r["spec"], r["ground_class"], r["work_item_code"], r["variant_value"]) for r in items]
assert got == [
("성토면", None, "FP-09-19-01", FILL_CLAY),
("절토면", "토사", "FP-09-19-01", CUT_SAND),
("절토면 · 연암", "연암", "FP-09-19-02", None),
("절토면 · 경암", "경암", "FP-09-19-03", None),
]
assert not any(r["blocked_kind"] for r in items)
def test_토질_시공_칸이_비면_금액_없이_입력_사유() -> None:
items = _items(_handoff())
assert [r["spec"] for r in items[:2]] == ["성토면", "절토면"] # 줄이 없으면 헛통과 막음
for row in items[:2]:
assert row["blocked_kind"] == "input_missing", row
assert "산출 조건" in row["blocked_reason"]
def test_고르는_갈래는_원문_표_두_벌과_마스터_갈래_키가_같음() -> None:
"""B08 칸의 선택지와 B09 표 읽기가 세운 갈래 — 두 벌이 갈리면 빨강(성토면·기계는 Ⓓ 로 안 섬)."""
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
from B09_Estimation.B09_Estimation_UnitPrice import normalize_variant_key
node = next(
n for n in load_work_item_master()["work_items"] if n["work_item_code"] == "FP-09-19-01"
)
keys = {normalize_variant_key(k) for k in node["variant_keys"]}
choices = {
normalize_variant_key(k) for k in FACE_DRESSING_CUT_CLASSES + FACE_DRESSING_FILL_CLASSES
}
assert keys == choices - {normalize_variant_key("성토면 · 기계")}
# ── 내역 — 갈래가 서면 금액 · 기계는 사유 ─────────────────────────────
def test_내역은_암_리핑_줄에_금액이_서고_토사면_고르기는_아래층_사유를_그대로() -> None:
"""9-19-2(연암 · 리핑) 는 금액이 섬. 9-19-1 은 지금 공종 단위 「일부만」 표시(연암·보통암 갈래의
공기압축기 3.5 손료 미확보)에 걸려 흙 갈래까지 막힘 — 그 까닭이 덮이지 않고 올라와야 함.
⚠ 다음 차례(공기압축기 손료)가 서면 이 둘째 단언이 뒤집힘 — 그때 금액 단언으로 바꿀 것.
성토면·기계는 [주]·규격 미정 사유가 후보 목록 곁에 붙음(Ⓓ)."""
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
handoff = _handoff(face_dressing_cut_class=CUT_SAND, face_dressing_fill_class=FILL_CLAY)
rows = [r for r in build_bill(handoff).rows if str(r.code).startswith("FP-09-19-0")]
ripping = next(r for r in rows if r.code == "FP-09-19-02")
assert ripping.amount_krw and int(ripping.amount_krw) > 0, ripping.note
fill = next(r for r in rows if r.code == "FP-09-19-01" and r.spec == FILL_CLAY)
assert fill.amount_krw is None and "공기압축기" in fill.note, fill.note
machine = _handoff(face_dressing_cut_class=CUT_SAND, face_dressing_fill_class="성토면 · 기계")
row = next(
r for r in build_bill(machine).rows if r.code == "FP-09-19-01" and r.spec == "성토면"
)
assert row.amount_krw is None and "규격 미정" in row.note, row.note