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
@@ -26,7 +26,32 @@ from dataclasses import dataclass, field
from typing import Any, Iterable
# 지반 구분이 붙는 공종 — 실무 시트가 이 셋을 각각 암 갈래만큼 늘려 적는다.
GROUND_SPLIT_GROUPS = ("흙깎기", "측구터파기", "구조물터파기")
# 면고르기는 절토면만 토사/암 갈래(성토면 줄은 `item` 이 비어 지반 없음 · 2026-09-14 Ⓑ).
GROUND_SPLIT_GROUPS = ("흙깎기", "측구터파기", "구조물터파기", "면고르기")
#: 면고르기 갈래 — 품셈 9-19-1 원문 표 두 벌 그대로(L5414 절토면 토질 6 · L5430 성토면 시공·토질).
#: B09 표 읽기(`_ResourceAxis_JudgedTable`)가 같은 이름으로 갈래를 세움 — 짝 시험이 마스터와 대조.
#: ⚠ 제안값 없음(판정 Ⓒ) · 「성토면 · 기계」 는 굴착기 0.6㎥ 형식 둘이라 B09 가 안 세움(Ⓓ 사유).
FACE_DRESSING_CUT_CLASSES = (
"절토면 · 모래ㆍ사질토ㆍ점토ㆍ점질토",
"절토면 · 연질토ㆍ불순자갈",
"절토면 · 호박돌 섞인 고결토ㆍ경질토",
"절토면 · 풍화암",
"절토면 · 연암",
"절토면 · 보통암ㆍ경암",
)
FACE_DRESSING_FILL_CLASSES = (
"성토면 · 인력 · 점토 또는 점질토",
"성토면 · 인력 · 모래 또는 사질토",
"성토면 · 기계",
)
#: ⭐ 2026-09-14 브레인 판정 Ⓐ — 밑수 = 초류종자살포(파종) 면적. 반영률 칸을 따로 두면 같은 값을
#: 두 곳에서 관리하게 됨. 실무도 그렇게 움직임(거창 파종 성토 50%·절토 100% · 영월 성토 50%).
FACE_DRESSING_NOTE = (
"초류종자살포와 같은 면적(식재 대상) — 품셈 9-19-1 성토면 [주] 「식재를 위한 성토사면」 ·"
" 9-19-2·3 [주]① 「비탈면 식재기반 조성에만」"
)
FACE_DRESSING_ZERO = "파종 면적이 0 — 식재 대상 아님(면고르기 안 셈)"
# 반영률 키 ↔ 집계 공종. 값은 프로젝트 설정에서 온다(기본 100 %).
RATIO_OF_ROW = {
@@ -74,6 +99,8 @@ class SummaryInput:
application_ratios: dict[str, float] = field(default_factory=dict)
# 노체다짐 — 기본 꺼짐(2026-09-13 판정). 켜면 성토 밑에 별도 줄이 선다.
subgrade_compaction_enabled: bool = False
# 면고르기 면적 덮어쓰기 `{fill, cut}`(㎡) — 비우면 파종 면적(판정 Ⓐ).
face_dressing_area_m2: dict[str, float | None] = field(default_factory=dict)
#: 노체다짐 줄 — ⭐ 2026-09-13 판정 「별도 줄 · 칸으로 켜고 끔 · 기본 꺼짐」.
@@ -193,6 +220,7 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
note=_seed_note(source),
)
)
rows.extend(_face_dressing_rows(source, fill_face, cut_face))
removal = slope.get("tree_removal_fill", 0.0) + slope.get("tree_removal_cut", 0.0)
# ⭐ 2026-09-09 **사용자 확정 5차 2번** — 지장목제거를 **두 줄로 가른다**(실무 서식).
# 영월 설계내역서 1.9 지장목제거가 두 줄이고 **같은 면적을 나눠 쓴다**:
@@ -249,6 +277,61 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
return rows
def _face_dressing_rows(
source: SummaryInput, fill_face: float, cut_face: float
) -> list[SummaryRow]:
"""면고르기 — 성토면 한 줄 · 절토면 토사 한 줄 + 암 갈래(판정 Ⓐ·Ⓑ). 밑수 = 파종 면적."""
rows: list[SummaryRow] = []
for face, key, gross, ratio_key in (
("성토면", "fill", fill_face, "seed_spray_fill"),
("절토면", "cut", cut_face, "seed_spray_cut"),
):
ratio = _ratio(source, ratio_key)
seeded = gross * ratio
given = source.face_dressing_area_m2.get(key)
amount = float(given) if isinstance(given, (int, float)) else seeded
if amount <= 0:
note = FACE_DRESSING_ZERO
elif isinstance(given, (int, float)):
note = f"덮어쓴 면적 {amount:,.2f}㎡ (파종 면적 {seeded:,.2f}㎡)"
else:
note = FACE_DRESSING_NOTE
common = {
"group": "면고르기",
"spec": face,
"unit": "",
"amount_gross": gross,
"application_ratio_pct": None if isinstance(given, (int, float)) else ratio * 100.0,
}
if face == "성토면":
rows.append(SummaryRow(**common, amount=amount, note=note, in_bill=amount > 0))
continue
# 절토면 — 사면 조각의 토사/암 몫(적용 전 면적)으로 나누고 같은 배율을 곱함.
# 못 가른 몫은 안 세고 드러냄. 몫마다 적용 전 값을 따로 실어 율 거울 검사가 맞게.
slope = source.slope_totals
soil = slope.get("face_dressing_cut_soil", 0.0)
rock = slope.get("face_dressing_cut_rock", 0.0)
scale = amount / cut_face if cut_face > 0 else 0.0
unknown = max(cut_face - soil - rock, 0.0) * scale
if unknown > 1e-6:
note += f" · ⚠ 토사/암을 못 가른 사면 {unknown:,.2f}㎡ 는 안 셈(측점 지반 프리셋 없음)"
parts = [("토사", soil, "")] + (_split_by_rock(rock, source) if rock > 0 else [])
for name, part_gross, split_note in parts:
part = part_gross * scale
# 토사 몫은 갈래 값(「절토면 · 모래…」)이 규격을 말함 · 암 몫은 규격에 암 갈래
spec = face if name == "토사" else f"절토면 · {name}"
rows.append(
SummaryRow(
**{**common, "amount_gross": part_gross, "spec": spec},
item=name,
amount=part,
note=" · ".join(text for text in (note, split_note) if text),
in_bill=part > 0,
)
)
return rows
def _ratio_note(source: SummaryInput, key: str, base: str) -> str:
ratio = _ratio(source, key)
return "" if abs(ratio - 1.0) < 1e-9 else f"{base} {ratio * 100:g} % 반영"
+8 -1
View File
@@ -113,6 +113,8 @@ def build_handoff(
bench_cut_depth_m: float | None = None,
structure_trench_water: str | None = None,
stand_volume_class: str | None = None,
face_dressing_cut_class: str | None = None,
face_dressing_fill_class: str | None = None,
priced_sheets: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**.
@@ -127,7 +129,12 @@ def build_handoff(
methods = {key: value for key, value in (ground_methods or {}).items() if value}
if summary_table:
# 임목축적 등급 — 지장목제거 뿌리뽑기(9-21 제근)의 품 갈래(2026-09-13 판정 Ⓑ).
variant_inputs = {"stand_volume_class": stand_volume_class}
# 면고르기 절토면 토질 · 성토면 시공·토질 — 9-19-1 갈래(2026-09-14 판정 Ⓒ).
variant_inputs = {
"stand_volume_class": stand_volume_class,
"face_dressing_cut_class": face_dressing_cut_class,
"face_dressing_fill_class": face_dressing_fill_class,
}
rows, misses = _earthwork_rows(
summary_table, table, methods, bench_cut_depth_m, variant_inputs
)
@@ -151,6 +151,18 @@ def _earthwork_rows(
# 보는 쪽을 고쳤으면 **다는 쪽도** 빠짐없이 달아야 한다.
blocked_kind = mismatch[1] if mismatch else None
blocked_reason = mismatch[2] if mismatch else ""
variant_value = (
(entry or {}).get("variant_value")
or {
**(variant_inputs or {}),
"ground_class": ground,
}.get(str((entry or {}).get("variant_from") or ""))
or None
)
# 매핑이 「이 칸이 비면 못 고름」이라 적은 갈래 — 금액 없이 입력 사유(면고르기 · 09-14 Ⓒ).
if code and not variant_value and (entry or {}).get("variant_missing_reason"):
blocked_kind = blocked_kind or BLOCKED_INPUT_MISSING
blocked_reason = blocked_reason or str(entry["variant_missing_reason"])
if code is None and not is_subtotal:
label = f"{group}({ground})" if ground else group
unmatched.append(f"{label}{method_note}" if method_note else label)
@@ -185,11 +197,7 @@ def _earthwork_rows(
# 매핑이 갈래를 적은 작업 갈래(잡관목제거 → 단목베기 「5m 미만」)만 값을 싣는다.
"variant_axis": (entry or {}).get("variant_axis"),
# 암 갈래(연암·보통암·경암)는 줄 자신의 갈래를 넘김 — 9-4·9-5 단계 합산형(축 C Ⓐ).
"variant_value": (entry or {}).get("variant_value")
or {**(variant_inputs or {}), "ground_class": ground}.get(
str((entry or {}).get("variant_from") or "")
)
or None,
"variant_value": variant_value,
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": "",
+20 -1
View File
@@ -134,6 +134,24 @@ def totals(rows: list[SlopeAreaRow]) -> dict[str, float]:
return {key: sum(row.areas.get(key, 0.0) for row in rows) for key in keys}
def cut_material_totals(
slopes: Iterable[StationSlope], ratios: SlopeRatios | None = None
) -> dict[str, float]:
"""면고르기 절토면의 토사·암 몫 면적 — 같은 평균단면적법 · 같은 반영률(합 ≤ 절토면 전체)."""
rate = (ratios or SlopeRatios()).of("face_dressing")
ordered = sorted(slopes, key=lambda s: s.chainage_m)
result = {"face_dressing_cut_soil": 0.0, "face_dressing_cut_rock": 0.0}
for before, after in zip(ordered, ordered[1:]):
distance = after.chainage_m - before.chainage_m
for key, field_name in (
("face_dressing_cut_soil", "cut_soil_length_m"),
("face_dressing_cut_rock", "cut_rock_length_m"),
):
pair = getattr(before, field_name) + getattr(after, field_name)
result[key] += pair / 2.0 * distance * rate
return result
def unclosed_stations(rows: list[SlopeAreaRow]) -> list[float]:
"""사면이 원지반을 못 만나 **면적이 잘린** 측점 목록.
@@ -148,6 +166,7 @@ def build_table(
) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양."""
rates = ratios or SlopeRatios()
slopes = list(slopes)
rows = build_rows(slopes, rates)
return {
"method": "average_end_area",
@@ -166,7 +185,7 @@ def build_table(
}
for row in rows
],
"totals": totals(rows),
"totals": {**totals(rows), **cut_material_totals(slopes, rates)},
"unclosed_stations": unclosed_stations(rows),
"station_count": len(rows),
}
@@ -58,6 +58,10 @@ class StationSlope:
chainage_m: float
cut_length_m: float = 0.0
fill_length_m: float = 0.0
#: 절토 사면길이의 토사·암 몫 — 면고르기가 9-19-1(토사)·9-19-2·3(암)으로 갈림(2026-09-14).
#: 둘의 합이 `cut_length_m` 보다 작으면 그 차이는 **못 가른 몫**(설계 지반 프리셋 없음).
cut_soil_length_m: float = 0.0
cut_rock_length_m: float = 0.0
#: 층따기 밑수 — **원지반 표면**의 경사길이(m). B06 설계가 측점마다 낸다
#: (`design.bench_cut_length_m`, 2026-09-09 랩탑 메인).
#: ⚠ **성토 비탈면 길이와 다른 면이다** — 층따기는 성토부 **아래 원지반**을 계단으로
@@ -240,9 +244,18 @@ def station_slope(chainage_m: float, design: dict[str, Any]) -> StationSlope:
side: sum(abs(s.rise_m) for s in segments if s.role == "fill" and s.side == side)
for side in ("left", "right")
}
# 2단 비탈은 경사비가 토사·암을 가름 · 1단은 그 측점 설계의 지반 프리셋(토사/암)이 비탈 전체
preset = {"soil": "soil", "rock": "rock"}.get(str(design.get("geometry_preset") or ""))
cut_by = {"soil": 0.0, "rock": 0.0}
for segment in segments:
material = segment.material or preset
if segment.role == "cut" and material in cut_by:
cut_by[material] += segment.length_m
return StationSlope(
chainage_m=float(chainage_m),
cut_length_m=sum(s.length_m for s in segments if s.role == "cut"),
cut_soil_length_m=cut_by["soil"],
cut_rock_length_m=cut_by["rock"],
fill_length_m=sum(s.length_m for s in segments if s.role == "fill"),
# ⚠ 없는 측점은 0 이다 — 성토 사면길이로 **대신 채우지 않는다**(면이 다름).
bench_cut_length_m=_num(design.get("bench_cut_length_m")) or 0.0,
+29 -1
View File
@@ -30,7 +30,11 @@ from B06_Section.B06_Section_Repository import (
get_longitudinal_section,
get_workflow_route_context,
)
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import (
FACE_DRESSING_CUT_CLASSES,
FACE_DRESSING_FILL_CLASSES,
SummaryInput,
)
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table
from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table
from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping
@@ -151,8 +155,18 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
},
# 노체다짐 — 기본 꺼짐. 켠 프로젝트에서만 줄이 선다(2026-09-13 판정).
subgrade_compaction_enabled=bool(settings.get("subgrade_compaction_enabled")),
# 면고르기 면적 덮어쓰기 — 비우면 파종 면적(2026-09-14 판정 Ⓐ).
face_dressing_area_m2={
"fill": settings.get("face_dressing_fill_area_m2"),
"cut": settings.get("face_dressing_cut_area_m2"),
},
)
)
# 면고르기 갈래 고르기 — 선택지는 서버 한 곳(원문 표 두 벌) · 제안값 없음(판정 Ⓒ).
table["face_dressing_choices"] = {
"cut": list(FACE_DRESSING_CUT_CLASSES),
"fill": list(FACE_DRESSING_FILL_CLASSES),
}
# 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨).
structures = await _route_structures(project_id)
table["preparation"] = build_preparation_table(
@@ -456,6 +470,12 @@ class QuantitySettingsBody(BaseModel):
topsoil_target: str | None = None
# 임목축적 등급 — "소림"·"중림"·"밀림"(품셈 9-21 [주]①). `""` 는 「안 정함」이다.
stand_volume_class: str | None = None
# 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질(9-19-1 원문 표). `""` 는 「안 정함」.
face_dressing_cut_class: str | None = None
face_dressing_fill_class: str | None = None
# 면고르기 면적 덮어쓰기(㎡) — `None` 은 파종 면적을 그대로(2026-09-14 판정 Ⓐ).
face_dressing_fill_area_m2: float | None = None
face_dressing_cut_area_m2: float | None = None
# 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다.
frame_material: dict[str, Any] | None = None
# 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜면 줄이 서고, 부피를 넣으면 값이 선다.
@@ -488,6 +508,8 @@ NULLABLE_SETTING_KEYS = (
"wood_chipping_volume_m3",
"dozer_haul_limit_m",
"tree_waste_unit_price_krw_per_ton",
"face_dressing_fill_area_m2",
"face_dressing_cut_area_m2",
)
@@ -525,6 +547,12 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
"message": f"도쟈 한계거리는 종무대 {free_haul:g} m 보다 커야 합니다.",
},
)
for key, choices in (
("face_dressing_cut_class", FACE_DRESSING_CUT_CLASSES),
("face_dressing_fill_class", FACE_DRESSING_FILL_CLASSES),
):
if key in values and values[key] not in choices:
values[key] = "" # 선택지 밖·빈 값은 「안 정함」 — 가까운 갈래로 안 고침
if "concrete_placing_method" in values:
method = values["concrete_placing_method"]
# 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리).
@@ -494,6 +494,9 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
structure_trench_water=settings.get("structure_trench_water"),
# 임목축적 등급 — 지장목제거 뿌리뽑기(제근 9-21)의 품 갈래. 안 넣으면 B09 가 후보를 보임.
stand_volume_class=settings.get("stand_volume_class"),
# 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질. 비면 그 줄이 입력 사유로 막힘.
face_dressing_cut_class=settings.get("face_dressing_cut_class") or None,
face_dressing_fill_class=settings.get("face_dressing_fill_class") or None,
# 구조물도 양식 일위대가로 셀 장 — 그 구조물은 호표 `AX-ST` 줄 하나로(PLAN 6장 ②).
priced_sheets=_priced_sheets(project_root, unit_table, modes, settings),
)
@@ -374,7 +374,8 @@ def _leaf_row(
default = unit_prices.default_variants.get(node.code)
if picked is not None:
price_code = picked
row.spec = f"{row.spec} {item.variant_value}".strip()
variant = str(item.variant_value) # 규격 글로 시작하는 갈래는 한 번만(면고르기)
row.spec = variant if variant.startswith(row.spec) else f"{row.spec} {variant}"
elif default is not None:
# 표에 없거나 안 준 암질(풍화암·암) — **원문이 정한 갈래**로만 선다(9-4-1 [주]① 평균).
price_code = f"{price_code}#{default[0]}"
@@ -427,7 +428,8 @@ def _leaf_row(
names = ", ".join(f"{c[2:]} {unit_prices.book.title(c).name}" for c in children)
row.add_note(
"unit_price_krw",
f"이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보: {names}",
f"이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보: {names}"
+ (f" / {known_gap_note(node.code)}" if item.variant_value else ""),
)
reason = f"일위대가가 하위 공종에 있음(후보 {len(children)}건)"
else:
+3 -1
View File
@@ -50,7 +50,9 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = {
"ⓘ 절토면 암 갈래(연암·보통암ㆍ경암): [주]③ 「소형브레이커 조작 인력품은 착암공으로 한다」"
" — 표에 착암공 수량이 없어 줄을 안 세움(보통인부는 표 그대로). "
"ⓘ 절토면 풍화암: [주]② 「소형브레이커를 사용할 시는 연암 고르기 품을 **준용할 수 있다**」"
" — 선택지라 따로 갈래를 안 만듦(브레이커를 쓰면 연암 갈래를 고를 것).",
" — 선택지라 따로 갈래를 안 만듦(브레이커를 쓰면 연암 갈래를 고를 것). "
"ⓘ 성토면 · 기계: 굴착기 0.6㎥ 가 무한궤도·타이어 둘이라 **규격 미정** — 이 갈래는 안 섬"
"(굴착기 규격 칸과 함께 풀 자리 · 2026-09-14 판정 Ⓓ).",
),
"FP-12-25": (
"운반거리 미정",
@@ -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