diff --git a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py index 63b9639e..11f19890 100644 --- a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py @@ -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} % 반영" diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index 553881af..cd0054e2 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -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 ) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index c4ddcfb6..6316ab95 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -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": "", diff --git a/B08_Quantity/B08_Quantity_Engine_SlopeArea.py b/B08_Quantity/B08_Quantity_Engine_SlopeArea.py index 94384282..7261950d 100644 --- a/B08_Quantity/B08_Quantity_Engine_SlopeArea.py +++ b/B08_Quantity/B08_Quantity_Engine_SlopeArea.py @@ -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), } diff --git a/B08_Quantity/B08_Quantity_Engine_SlopeLength.py b/B08_Quantity/B08_Quantity_Engine_SlopeLength.py index 5256c526..9f404359 100644 --- a/B08_Quantity/B08_Quantity_Engine_SlopeLength.py +++ b/B08_Quantity/B08_Quantity_Engine_SlopeLength.py @@ -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, diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 64202a35..fb719465 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -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 ② 와 같은 자리). diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index 09fbb35e..5aec260c 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -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), ) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index 6a0f97ff..6cf1df63 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -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: diff --git a/B09_Estimation/B09_Estimation_KnownGaps.py b/B09_Estimation/B09_Estimation_KnownGaps.py index 30a62286..88ce8313 100644 --- a/B09_Estimation/B09_Estimation_KnownGaps.py +++ b/B09_Estimation/B09_Estimation_KnownGaps.py @@ -50,7 +50,9 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = { "ⓘ 절토면 암 갈래(연암·보통암ㆍ경암): [주]③ 「소형브레이커 조작 인력품은 착암공으로 한다」" " — 표에 착암공 수량이 없어 줄을 안 세움(보통인부는 표 그대로). " "ⓘ 절토면 풍화암: [주]② 「소형브레이커를 사용할 시는 연암 고르기 품을 **준용할 수 있다**」" - " — 선택지라 따로 갈래를 안 만듦(브레이커를 쓰면 연암 갈래를 고를 것).", + " — 선택지라 따로 갈래를 안 만듦(브레이커를 쓰면 연암 갈래를 고를 것). " + "ⓘ 성토면 · 기계: 굴착기 0.6㎥ 가 무한궤도·타이어 둘이라 **규격 미정** — 이 갈래는 안 섬" + "(굴착기 규격 칸과 함께 풀 자리 · 2026-09-14 판정 Ⓓ).", ), "FP-12-25": ( "운반거리 미정", diff --git a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json index 7c163680..63b4e1ef 100644 --- a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json +++ b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json @@ -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": "면고르기", diff --git a/resources/tester/test_b08_face_dressing.py b/resources/tester/test_b08_face_dressing.py new file mode 100644 index 00000000..94dfd362 --- /dev/null +++ b/resources/tester/test_b08_face_dressing.py @@ -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