"""배수관 물량 — **정본 셋을 잇기만 한다** (2026-09-08 두 창 합의). 값이 어디서 오나 관 자체(있나·어디·관경·관종) → `pipe_points.json` (레지스트리 `pipe` 타입이 `managed_by: pipe_points`) 관 연장(m) → 측점 `design.pipe_length_m` (B06 횡단이 **서버 Node 로** 계산해 m 단위 올림까지 끝낸 값을 정본에 남긴다 — 계산이 두 벌이 아니다) 관종 → 공종코드 → `work_item_mapping` 의 `pipe.kind_codes` ⚠ **여기서 길이를 짓지 않는다.** 앞서 「도로폭 = 관 길이」처럼 잡을 뻔했는데 그것이 곧 임의 수치다. 연장이 없는 관은 **줄을 세우되 막힌 사유와 함께** 보낸다. ⚠ **`facility` 가 `pipe` 인 점만 배관이다.** `pipe_points.json` 은 **계곡 통과 시설 전부의 정본**이라 BOX암거·물넘이·세월교·독립 기슭막이가 같은 파일에 있다. `pipe_diameter_mm` 유무로 가르면 **관경 미지정 관을 놓친다** (실측: `5601e828` 11점 중 2점이 `facility: ford_bridge`). ⚠ **유출·유입부 기슭막이는 관 줄에 넣지 않는다 — `facility_structures` 가 원단위 전개로 한 번 셈.** 관 옵션(`outlet_revet_*`)이 정본이다(2026-08-28 이관). 관 부설(품셈 12-11 m당: 관·기초콘크리트· 거푸집)에 기슭막이 몫이 없어 겹치지 않는다. 2026-09-14 까지는 **어느 쪽도 안 세고 있었다**(A1). ⚠ **터파기·되메우기를 관 줄에 붙이지 않는다.** 관 부설과 굴착이 각각 오면 **같은 굴착을 두 번** 센다(B09 ㉡ 가드와 같은 자리). """ from __future__ import annotations import json from functools import lru_cache from pathlib import Path from typing import Any #: 배관으로 보는 `facility` 값. 그 밖(BOX암거·물넘이·세월교·독립 기슭막이)은 관이 아니다. FACILITY_PIPE = "pipe" #: 연장을 못 찾은 줄의 막힌 갈래 — 「입력하면 풀림」이 아니라 **앞 단계가 내야 하는 값**이다. BLOCKED_LENGTH_MISSING = "input_missing" #: ⚠ **둘을 갈라 말한다** — 「[저장]을 누르면 풀리는 것」과 「눌러도 안 풀리는 것」은 #: 사용자가 할 일이 다르다. 뒤엣것에 앞 문구를 쓰면 눌러 보고 안 되어 헤맨다 #: (2026-09-08 실측: 관 9개 중 3개가 **횡단 행 자체가 없는** 자리였다). NOTE_LENGTH_MISSING = ( "관 연장이 아직 정본에 없습니다 — 횡단설계에서 [저장]을 한 번 누르면 " "그 측점의 관 길이가 남고 값이 섭니다" ) NOTE_SECTION_MISSING = ( "그 측점의 횡단 자체가 없습니다 — 관은 놓였는데 횡단이 안 만들어진 자리라 " "[저장]으로는 안 풀립니다. 횡단설계에서 그 측점이 서야 합니다" ) # ── 허용 오차 둘 — **묻는 것이 다르다.** 한 자리에 모아 둔다 ──────────────── # · `LENGTH_MATCH_TOLERANCE_M` — 「이 관의 **길이를 어디서 가져오나**」. # 넉넉해도 된다. 값을 못 찾는 것보다 옆 측점 길이를 쓰는 편이 낫다. # · `SECTION_MATCH_TOLERANCE_M` — 「이 측점에 **횡단 설계가 있나**」. # # ⚠⚠ **둘 다 0.5 다. 좁히지 말 것** — 앞서 「좁아야 한다」던 판단이 실측으로 뒤집혔다 # (2026-09-09 랩탑 메인). 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로 # 스냅**한다(횡단 파일명이 정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드). # 그래서 관 440.241 의 횡단은 **측점 440.0** 이고 구조물 이름표까지 달고 있다 — # **없는 것이 아니다.** 좁게 보면 「그 측점의 횡단 자체가 없습니다」라는 **거짓 사유**가 # 뜬다(반대 방향의 거짓). 스냅 폭이 「정수 미터 반올림」이라 최대 어긋남이 0.5m 이고, # 그래서 길이 찾기와 **같은 값**이 된다 — 우연이 아니라 같은 까닭이다. # ⚠ 값이 같아졌다고 **하나로 합치지 말 것.** 묻는 것이 둘이라 근거도 둘이고, 한쪽 근거가 # 바뀌면 한쪽만 움직여야 한다. # ⚠ 두 값은 **증상**이지 병이 아니다. 병은 **관 자리에 측점이 없는 것**이고 # (측점을 만드는 자리는 B05 노선 [확정] 한 곳뿐 — 관을 나중에 놓거나 옮기면 안 생긴다, # 계획서 3-14), 그것이 고쳐지면 이 값들이 **아무 관도 안 건드린다.** LENGTH_MATCH_TOLERANCE_M = 0.5 SECTION_MATCH_TOLERANCE_M = 0.5 NOTE_KIND_DEFAULT = "관종을 안 정해 기본값({kind})으로 섰습니다 — 정하면 공종이 갈립니다" NOTE_KIND_UNKNOWN = "「{kind}」은(는) 아는 관종이 아니라 공종을 못 골랐습니다" def _length_by_chainage(designs: list[dict[str, Any]], key: str) -> dict[float, float]: """측점별 관 길이. **없는 측점은 담지 않는다** — 0 으로 채우면 「없음」과 구별이 안 된다.""" found: dict[float, float] = {} for row in designs or []: design = row.get("design") if isinstance(row, dict) else None if not isinstance(design, dict): continue value = design.get(key) if value is None: continue try: length = float(value) except (TypeError, ValueError): continue if length > 0: found[round(float(row.get("chainage_m") or 0.0), 3)] = length return found def _nearest( lengths: dict[float, float], chainage: float, tolerance: float = LENGTH_MATCH_TOLERANCE_M, ) -> float | None: """관 측점과 단면 측점이 소수점에서 어긋날 수 있어 **가까운 것**을 본다. ⚠ 좁게 본다(기본 0.5m) — 넓히면 옆 측점의 길이를 물어 와 조용히 틀린다. ⚠ 「횡단이 있나」를 재는 `SECTION_MATCH_TOLERANCE_M`(0.5m)와 **다른 물음**이다 — 위 주석을 볼 것. 맞추면 둘 다 나빠진다. """ if not lengths: return None key = round(chainage, 3) if key in lengths: return lengths[key] best = min(lengths, key=lambda x: abs(x - chainage)) return lengths[best] if abs(best - chainage) <= tolerance else None def build_rows( pipe_points: list[dict[str, Any]], designs: list[dict[str, Any]], mapping: dict[str, Any] | None = None, section_chainages: list[float] | None = None, ) -> dict[str, Any]: """관 줄 목록. **값이 서는 줄도, 못 서는 줄도** 함께 낸다. `pipe_points` 는 `PipePoint.model_dump()` 또는 같은 모양의 딕셔너리 목록이다. """ table = mapping or {} kind_codes: dict[str, str] = table.get("kind_codes") or {} kind_key = str(table.get("kind_option_key") or "pipe_kind") default_kind = str(table.get("default_kind") or "") length_key = str(table.get("length_key") or "pipe_length_m") diameter_key = str(table.get("diameter_option_key") or "pipe_diameter_mm") lengths = _length_by_chainage(designs, length_key) # 횡단이 **있는데 길이가 없는 것**과 **관 자리에 횡단이 없는 것**을 가르기 위한 목록. # 안 주면 종전대로 「[저장]하면 풀림」 하나로만 말한다. sections = [float(x) for x in (section_chainages or [])] rows: list[dict[str, Any]] = [] notes: list[str] = [] for point in pipe_points or []: if str(point.get("facility") or FACILITY_PIPE) != FACILITY_PIPE: continue # 배관이 아닌 시설 — 그쪽 줄은 그쪽이 센다 options = point.get("options") or {} chainage = float(point.get("chainage_m") or 0.0) stored_kind = str(options.get(kind_key) or "").strip() kind = stored_kind or default_kind code = kind_codes.get(kind) kind_note = "" if not stored_kind and default_kind: kind_note = NOTE_KIND_DEFAULT.format(kind=default_kind) elif stored_kind and code is None: kind_note = NOTE_KIND_UNKNOWN.format(kind=stored_kind) length = _nearest(lengths, chainage) # ⚠ 관이 선 자리의 횡단이 있는지를 본다. **스냅을 셈에 넣는다**(2026-09-09) — # 관 440.241 의 횡단은 측점 440.0 이고 그것이 정상이다. 옛 주석은 0.05m 로 좁게 # 보라고 했으나, 그때는 **스냅 때문에 관이 측점에 안 붙던 것**을 「측점이 없다」로 # 읽던 시절이라 판단이 뒤집혔다. 길이는 여전히 **주인 측점 하나에만** 실린다. has_section = not sections or any( abs(x - chainage) <= SECTION_MATCH_TOLERANCE_M for x in sections ) blocked_kind = None if length else BLOCKED_LENGTH_MISSING blocked_reason = "" if not length: blocked_reason = NOTE_LENGTH_MISSING if has_section else NOTE_SECTION_MISSING if code is None: blocked_kind = blocked_kind or BLOCKED_LENGTH_MISSING blocked_reason = blocked_reason or kind_note rows.append( { "chainage_m": chainage, "work_item_code": code, "kind": kind, "kind_from_default": not stored_kind, # 갈래는 **저장 원본값**만 보낸다 — 「…㎜ 이하」 구간 나누기는 원문을 읽는 쪽 몫. "variant_axis": diameter_key, "variant_value": options.get(diameter_key), "unit": str(table.get("unit") or "m"), "quantity": float(length or 0.0), "blocked_kind": blocked_kind, "blocked_reason": blocked_reason or kind_note, "in_bill": bool(length and code), } ) if kind_note and kind_note not in notes: notes.append(kind_note) missing = sum(1 for row in rows if not row["in_bill"]) if missing: notes.append(f"관 {len(rows)}개 중 {missing}개가 아직 값이 안 섭니다") return { "rows": rows, "notes": notes, "pipe_count": len(rows), "ready_count": sum(1 for row in rows if row["in_bill"]), "length_total_m": round(sum(row["quantity"] for row in rows if row["in_bill"]), 3), } INLET_BASIN = "집수정" #: 기슭막이 벽 칸 — 관·독립 기슭막이 모두 `{쪽}_revet_{칸}` · 독립의 옛 저장분은 `{칸}`(B06 `_revet_side`). REVET_FIELDS = ( ("form", "형태"), ("height_m", "높이"), ("length_m", "길이"), ("before_m", ""), ("after_m", ""), ) #: (저장 채널, 관에 딸린 줄 이름 꼬리, 독립 기슭막이 줄 이름 꼬리) REVET_ROLES = ( ("inlet", "유입부 기슭막이", "유입 칸 벽"), ("outlet", "유출부 기슭막이", "유출 칸 벽"), ) #: 관 벽 높이를 안 적었으면 **횡단도가 그리는 관경 기준 최소 높이**(B06 `pipe_wall_min_height_m`, #: TS 짝 `pipeWallMinPureHeight`)로 섬 — 등록부·구조물도·횡단도가 한 값(2026-09-14 브레인 판정). #: ⚠ 「최소」라 실제 설계값이 더 클 수 있어 **미확정·금액 밖은 그대로**. NOTE_DEFAULT_WALL = ( "⚠ 시설 지점에 {filled} 을 안 적어 기본값으로 섰음 — 미확정이라 금액에 안 들어감" ) #: 꼬리 — 관 벽은 횡단도와 같은 높이. NOTE_WALL_HEIGHT_PIPE = " · 높이는 횡단도와 같은 관경 기준 최소 높이(실제 설계값은 더 클 수 있음)" #: 독립 기슭막이 치수 **한 벌**(`resources/data_masonry/revetment_sabang_*.json`) — B05 칸 밑 근거와 #: 이 사유가 같은 파일을 읽음(2026-09-14 브레인 판정). 높이는 **설계자 입력** — 등록부 기본 2.5 걷음. REVET_SABANG_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry" REVET_SABANG_PREFIX = "revetment_sabang_" UNCONFIRMED_OWN_HEIGHT = "높이를 안 적음 — {rule}({source}) 설계자 입력 · 적으면 금액이 섬" @lru_cache(maxsize=1) def load_revetment_sabang() -> dict[str, Any]: """독립 기슭막이 치수 한 벌. 없으면 빈 표 — 사유가 근거 없이 짧아질 뿐 값은 안 바뀜.""" files = sorted(REVET_SABANG_DIR.glob(REVET_SABANG_PREFIX + "*.json")) return json.loads(files[-1].read_text(encoding="utf-8")) if files else {} def _span(values: list[float], unit: str = "") -> str: return f"{values[0]:g}~{values[1]:g}{unit}" def own_revetment_basis() -> str: """독립 기슭막이 근거 한 줄 — 높이·계획비탈·둑마루·뒷채움, 쪽·줄 번호 그대로.""" data = load_revetment_sabang() items = data.get("items") or {} if not items: return "" height, slope = items["height"], items["face_slope"] crown, pebble = items["crown_thickness"], items["backfill_pebble"] return ( f"ⓘ 독립 기슭막이 치수 근거 — 높이 {height['rule']}({height['source']})" f" · 계획비탈 1:{_span(slope['standard_ratio'])}({slope['source']})" f" · 둑마루 두께 {_span(crown['standard_m'], 'm')}({crown['source']} · 콘크리트 기준)" f" · 뒷채움 조약돌 두께 {pebble['source']} · {data.get('scope', '')}" ) def own_height_unconfirmed() -> str: """높이를 안 적은 독립 기슭막이의 미확정 까닭.""" height = (load_revetment_sabang().get("items") or {}).get("height") or {} return UNCONFIRMED_OWN_HEIGHT.format( rule=height.get("rule", "계획홍수위 + 0.5~0.7m"), source=height.get("source", "사방기술교본"), ) #: 금액에서 뺄 까닭(2026-09-14 브레인 판정) — 줄·표·그림은 서되 내역 합에는 안 듦. UNCONFIRMED_WALL = "벽 치수({filled})가 시설 지점에 없어 기본값으로 섰음 — 적으면 금액이 섬" NOTE_SIDE_UNKNOWN = ( "⚠ 설치 측 「{side}」인데 두 칸(유입·유출) 값이 달라 어느 칸이 그쪽인지 서버가 못 가림" "(횡단 지형이 정함) — 값을 안 세움 · 두 칸을 같게 적으면 섬" ) def _blank(value: Any) -> bool: return value is None or value == "" def _num_or_zero(value: Any) -> float: try: return float(value) except (TypeError, ValueError): return 0.0 def _revet_values( options: dict[str, Any], role: str, defaults: dict[str, Any], legacy: bool, min_height_m: float | None = None, ) -> tuple[dict[str, Any], list[str]]: """벽 한쪽 제원과 기본값으로 채운 칸 — 등록부 기본값 · 관 벽 높이는 관경 기준 최소 높이.""" values: dict[str, Any] = {} filled: list[str] = [] for name, label in REVET_FIELDS: value = options.get(f"{role}_revet_{name}") if _blank(value) and legacy: value = options.get(name) if _blank(value): value = defaults.get(name if legacy else f"{role}_revet_{name}") if value is None and name == "height_m" and min_height_m is not None: value = min_height_m label = f"{label}(관경 기준)" if label and value is not None: filled.append(f"{label} {value}") values[name] = value return values, filled def _pipe_diameter_m(options: dict[str, Any], defaults: dict[str, Any]) -> float: """관경(m) — 저장값, 없으면 등록부 기본(B06 세트 스펙과 같은 채움).""" raw = options.get("pipe_diameter_mm") if _blank(raw): raw = defaults.get("pipe_diameter_mm") return _num_or_zero(raw) / 1000.0 or 1.0 #: 다단 추가 기슭막이(④, 2026-09-14) — 횡단 설계가 정본이고 **실제로 선** 단 수·높이는 지형이 정함. #: 서버 Node 재계산(`B06_Section_Structure_Layouts.extraWallRows`)이 소유 측점 `design.extra_walls` 에 #: 「선 다단 벽 목록」을 남기고 여기서 읽음(관 연장 `pipe_length_m` 과 같은 길). EXTRA_WALLS_KEY = "extra_walls" EXTRA_SIDE_LABELS = {"outlet": "유출측", "basin": "유입측"} #: 다단이 딸린 기준벽 — 유출측 다단은 유출 벽, 유입측(집수정 계류측) 다단은 유입 벽. EXTRA_SIDE_ROLES = {"outlet": "outlet", "basin": "inlet"} UNCONFIRMED_EXTRA_HEIGHT = ( "다단 높이를 안 적음 — 기본 1.5m 를 지반에 맞춰 그린 {height}m 로 섰음 · 적으면 금액이 섬" ) UNCONFIRMED_EXTRA_FORM = "다단 형태를 안 정해 기준벽의 기본 형태 「{form}」로 섰음" #: 첫 미확정 단 아래는 횡단도 면적도 없는 것으로 봄(B06 `confirmedOnly`) — 같은 선에서 끊음. UNCONFIRMED_EXTRA_ABOVE = "윗단({above}단)이 미확정이라 이 단도 미확정 — 윗단 높이를 적으면 섬" #: 벽 몸이 성토 폐회로에도 듦(2026-09-06 사용자 확정 「구조물 면적 안 뺌」) — 고치지 않고 사유로(2026-09-14 브레인). #: 넓이는 서버 Node 가 횡단도 벽 몸 ∩ 성토 폐회로로 잼(`wallBodyInFillM2` · `fill_overlap_m2`). NOTE_FILL_OVERLAP = ( "⚠ 벽 몸 자리가 성토 면적에도 들어 있음(2026-09-06 확정 「구조물 면적 안 뺌」) — 이 측점 횡단도 " "벽 몸 {area:.2f}㎡ × 연장 {length:g}m ≈ {volume:.1f}㎥ 를 벽과 성토에 두 번 셈 · " "벽 뒤 막자갈 자리도 성토선 아래라 더 겹침" ) #: 관 기준벽(유입·유출) 벽 몸 겹침 ㎡ — `design.wall_fill_overlap` {inlet, outlet}(서버 Node 가 잼, ㉡ 2026-09-14). #: 호출부(`Router_Material`)를 안 늘리려고 다단 목록 통에 `base` 표시 칸으로 실어 옴 — 다단 줄은 거름. WALL_OVERLAP_KEY = "wall_fill_overlap" def extra_walls_from_designs(designs: list[dict[str, Any]] | None) -> dict[float, list[dict]]: """측점별 「선 다단 벽 목록」 — 목록이 있는 측점만(빈 목록도 담음 — 「다단 없음」). 기준벽 겹침 값이 있으면 `{"base": 역할, "fill_overlap_m2": ㎡}` 칸을 뒤에 붙임.""" found: dict[float, list[dict]] = {} for row in designs or []: design = row.get("design") if isinstance(row, dict) else None if not isinstance(design, dict): continue walls = design.get(EXTRA_WALLS_KEY) overlaps = design.get(WALL_OVERLAP_KEY) if not isinstance(walls, list) and not isinstance(overlaps, dict): continue entries = list(walls) if isinstance(walls, list) else [] if isinstance(overlaps, dict): entries += [{"base": role, "fill_overlap_m2": area} for role, area in overlaps.items()] found[round(float(row.get("chainage_m") or 0.0), 3)] = entries return found def _extra_walls_at(extra_walls: dict[float, list[dict]] | None, chainage: float) -> list[dict]: """관 자리의 주인 측점 목록 — 스냅 때문에 최대 0.5m 어긋남(`SECTION_MATCH_TOLERANCE_M`).""" if not extra_walls: return [] best = min(extra_walls, key=lambda value: abs(value - chainage)) return extra_walls[best] if abs(best - chainage) <= SECTION_MATCH_TOLERANCE_M else [] #: BOX암거 치수 칸 — 안 적으면 등록부 기본값(제안값)으로 서되 **미확정**(2026-09-14 브레인 판정 · #: 2026-09-15 12장 D). 연장은 기본값이 없어 여기 없음 — 비면 전개식이 사유를 냄. BOX_DIMENSIONS = ( ("body_width_m", "본체 폭"), ("body_height_m", "본체 높이"), ("wall_thickness_m", "측벽 두께"), ("slab_thickness_m", "상·저판 두께"), ("haunch_m", "헌치"), ("blinding_thickness_m", "기초(버림) 두께"), ) UNCONFIRMED_BOX = "BOX암거 치수({filled})가 시설 지점에 없어 기본값으로 섰음 — 적으면 금액이 섬" def _box_row(base: dict[str, Any], registry: dict[str, Any]) -> dict[str, Any]: """BOX암거 줄 — 빈 치수 칸에 등록부 제안값을 채우고 그 사실을 미확정 사유로 붙임.""" options = dict(base["options"]) filled = [] for key, label in BOX_DIMENSIONS: if _blank(options.get(key)) and registry.get(key) is not None: options[key] = registry[key] filled.append(label) reason = UNCONFIRMED_BOX.format(filled=" · ".join(filled)) if filled else "" return { **base, "options": options, "unconfirmed": reason, "notes": [f"⚠ {reason}"] if reason else [], } def facility_structures( points: list[dict[str, Any]], extra_walls: dict[float, list[dict]] | None = None, ) -> list[dict[str, Any]]: """계곡 통과 시설(`pipe_points.json`) → 원단위 전개가 읽는 구조물 줄 (A1, 2026-09-14). ⚠ 관 자체는 안 냄 — `build_rows` 가 관 연장으로 셈. ⚠ 다단 추가 기슭막이는 `extra_walls`(측점 → 선 벽 목록, `extra_walls_from_designs`)로 줄을 세움. ⚠ 안 적힌 벽 칸은 **등록부 기본값** — 그 사실을 줄 사유로 붙이고 `unconfirmed` 로 금액 합에서 뺌 (2026-09-14 브레인 판정 「줄은 서되 금액은 실제 값이 있을 때만」). ⚠ 집수정·기슭막이 터파기·되메우기는 전개 성분(`destination: earthwork`)이라 토공집계로만 감. """ from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from B06_Section.B06_Section_Engine_Culvert import pipe_wall_min_height_m from B08_Quantity.B08_Quantity_Engine_StructureSummary import pipe_row_id from B08_Quantity.B08_Quantity_Engine_UnitQuantity import attachments_of, child_row types = structure_type_map() def defaults(type_id: str) -> dict[str, Any]: found = types.get(type_id) return {option.key: option.default for option in found.options} if found else {} rows: list[dict[str, Any]] = [] for point in points or []: facility = str(point.get("facility") or FACILITY_PIPE) chainage = float(point.get("chainage_m") or 0.0) options = dict(point.get("options") or {}) base = { "structure_id": pipe_row_id(point), "type_id": facility, "chainage_m": chainage, "start_m": point.get("start_m") if point.get("start_m") is not None else chainage, "end_m": point.get("end_m") if point.get("end_m") is not None else chainage, "options": options, } if facility == "box_culvert": rows.append(_box_row(base, defaults(facility))) continue if facility not in (FACILITY_PIPE, "revetment"): rows.append(base) # 물넘이·세월교 — 전개식·관측값이 없으면 사유로 섬 continue legacy = facility == "revetment" # 독립 기슭막이 — 벽 칸 밖의 제원(뒷길이 등)도 벽이 씀 wall_defaults = defaults(facility) roles = list(REVET_ROLES) inlet_notes: list[str] = [] if not legacy: parent = {**base, "type_id": "pipe"} children = [r for r in attachments_of(parent) if r["type_id"] != "pipe_inlet_basin"] inlet = str(options.get("inlet_type") or wall_defaults.get("inlet_type") or "") if inlet == INLET_BASIN: # 형식을 안 골랐어도 줄은 세움 — 관측표가 「형식을 골라야 섬」 사유를 냄. children.append(child_row(parent, "pipe_inlet_basin", "유입부 집수정")) roles = roles[1:] elif options.get("inlet_basin_form"): inlet_notes.append( f"⚠ 집수정 형식({options['inlet_basin_form']})이 적혀 있으나 유입구 구조가 " f"「{inlet}」라 집수정은 안 셈 — 유입구를 「집수정」으로 바꾸면 섬" ) rows.extend(children) walls = [] min_height = ( None if legacy else pipe_wall_min_height_m(_pipe_diameter_m(options, wall_defaults)) ) for role, pipe_label, own_label in roles: values, filled = _revet_values(options, role, wall_defaults, legacy, min_height) notes = list(inlet_notes) if role == "inlet" else [] if filled: tail = "" if legacy else NOTE_WALL_HEIGHT_PIPE notes.append(NOTE_DEFAULT_WALL.format(filled=" · ".join(filled)) + tail) if legacy: # 독립 기슭막이 — 치수 한 벌 근거(사방 교본 기준 · 쪽·줄 번호)를 늘 붙임. basis = own_revetment_basis() if basis: notes.append(basis) walls.append([role, own_label if legacy else pipe_label, values, notes, False, filled]) side = str(options.get("side") or "") if legacy else "" if side in ("좌", "우"): # 좌·우가 유입/유출 어느 칸인지는 **횡단 지형**이 정함 — 두 칸이 같을 때만 한 벽으로 셈. same = walls[0][2] == walls[1][2] walls = [[walls[0][0], f"{side} 벽", walls[0][2], walls[0][3], not same, walls[0][5]]] if not same: walls[0][3].append(NOTE_SIDE_UNKNOWN.format(side=side)) foundation = options.get("foundation" if legacy else "revet_foundation") kept = {k: v for k, v in options.items() if not k.startswith(("inlet_", "outlet_"))} station_walls = _extra_walls_at(extra_walls, chainage) base_overlap = { str(entry["base"]): _num_or_zero(entry.get("fill_overlap_m2")) for entry in station_walls if entry.get("base") } for role, label, values, notes, withheld, filled in walls: before, after = _num_or_zero(values["before_m"]), _num_or_zero(values["after_m"]) row = child_row(base, "revetment", label, f"{role}_revet") reasons = [UNCONFIRMED_WALL.format(filled=" · ".join(filled))] if filled else [] if legacy and _blank(values["height_m"]): # 높이는 설계자 입력(계획홍수위 + 여유고) — 없으면 줄만 서고 금액 밖. reasons.append(own_height_unconfirmed()) # 좌·우 한 벽으로 합친 독립 기슭막이는 그 측점에 선 벽 몸 전부. overlap = ( sum(base_overlap.values()) if legacy and len(walls) == 1 else base_overlap.get(role, 0.0) ) if overlap > 0 and not reasons: # 미확정 벽은 금액에 안 듦 — 두 번 셀 금액이 없음 notes.append( NOTE_FILL_OVERLAP.format( area=overlap, length=before + after, volume=overlap * (before + after) ) ) row.update( start_m=chainage - before, end_m=chainage + after, options={**(kept if legacy else {}), **values, "foundation": foundation}, notes=notes, withheld=withheld, unconfirmed=" · ".join(reasons), ) rows.append(row) # 다단 — 기준벽의 기초·(독립이면) 제원 칸을 물려받고, 형태·높이·구간은 선 벽 목록 값. parents = {role: (values, filled) for role, _label, values, _notes, _w, filled in walls} counts = {"outlet": 0, "basin": 0} first_unset: dict[str, int] = {} for tier in station_walls: if tier.get("base"): continue side = "basin" if str(tier.get("side")) == "basin" else "outlet" counts[side] += 1 if not tier.get("height_set"): first_unset.setdefault(side, counts[side]) parent_values, parent_filled = parents.get( EXTRA_SIDE_ROLES[side], next(iter(parents.values()), ({}, [])) ) height = _num_or_zero(tier.get("height_m")) form = str(tier.get("form") or parent_values.get("form") or "") before, after = _num_or_zero(tier.get("before_m")), _num_or_zero(tier.get("after_m")) reasons = [] if not tier.get("height_set"): reasons.append(UNCONFIRMED_EXTRA_HEIGHT.format(height=f"{height:g}")) elif first_unset.get(side, counts[side]) < counts[side]: reasons.append(UNCONFIRMED_EXTRA_ABOVE.format(above=first_unset[side])) if not tier.get("form_set") and any(str(f).startswith("형태") for f in parent_filled): reasons.append(UNCONFIRMED_EXTRA_FORM.format(form=form)) label = f"{EXTRA_SIDE_LABELS[side]} 다단 기슭막이 {counts[side]}단" row = child_row(base, "revetment", label, str(tier.get("key") or label)) tier_notes = [ f"ⓘ 횡단도에 선 다단 벽 — 높이 {height:g}m · 형태 {form} · 전 {before:g}/후 {after:g}m" ] overlap = _num_or_zero(tier.get("fill_overlap_m2")) if overlap > 0 and not reasons: # 미확정 단은 면적에도 금액에도 안 듦 tier_notes.append( NOTE_FILL_OVERLAP.format( area=overlap, length=before + after, volume=overlap * (before + after) ) ) row.update( start_m=chainage - before, end_m=chainage + after, options={ **(kept if legacy else {}), "form": form, "height_m": height, "length_m": before + after, "before_m": before, "after_m": after, "foundation": foundation, }, notes=tier_notes, withheld=False, unconfirmed=" · ".join(reasons), ) rows.append(row) return rows