Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -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} % 반영"
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": "면고르기",
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user