일위대가 금액 바뀐 제목 4(9-5-1 · 9-19-1 연암·보통암ㆍ경암 · 9-19-3) · 나머지 456 변화 0 · 기계 층 미완성 6→4 배정 프로젝트: 면고르기 칸 비면 합계 그대로 · 칸 고르면 성토면 인력 점토 3,269원/㎡ · 절토면 호박돌 4,353원/㎡ 금액 섬(되돌림) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
212 lines
9.7 KiB
Python
212 lines
9.7 KiB
Python
"""면고르기 줄 — 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(성토면 인력 점토 500㎡ × 3,269원) 금액이 섬.
|
||
ⓘ 9-19-1 은 첫 커밋(ee9368ec) 때 공기압축기 3.5 손료 미확보(공종 단위 「일부만」)에 막혀 있었고
|
||
운전경비 표 괄호 딸림값 파서 고침으로 풀림(같은 날 · `test_b09_operating_paren_spec`).
|
||
성토면·기계는 [주]·규격 미정 사유가 후보 목록 곁에 붙음(Ⓓ)."""
|
||
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 == 500 * 3269, 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
|