- 집계표 — 관경 · 련 빈칸 = 「미입력」 열(or 1000 · or 1 지움) · 관경 빈칸 흙막이 빈 높이 미입력 · 1본 = 프로젝트 층 변수만(시스템 층 폴백 지움) - 토공집계표 — 성토면다짐 · 초류종자살포 · 지장목제거 = 빈칸 + 「비율 미정」 · 구조물 쪽 늘 빈 줄 8 뺌(나머지 7 유지) - 수리 박스 — 수심비 처음 값 빈칸 · 빈칸이면 수심 · 유출량 멈춤 · 칸 옆 알림 - 좌측 패널 1본 읽기 전 빈칸 · 시험 test_69 · 98 · 122 · 73_11 고침 + 새 test_143_2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
653 lines
27 KiB
Python
653 lines
27 KiB
Python
"""B08 구조물 집계표 길(PLAN 98-1) — 읽기만 · 계산 서버 단독(CLAUDE.md 5장 ③).
|
||
|
||
GET /api/projects/{id}/quantity/summary
|
||
횡단배수 한 곳마다 컨테이너 값 → 실무 구조물집계표 꼴 열 · 측점 줄 · 계
|
||
|
||
열 = 설계에 쓰인 값만 펼침 · 머리 = `group`[구조물, 형식, 규격] + `label`(단위 줄):
|
||
흙막이 › 형태 › H=높이 m 유입(흙막이일 때) · 유출 · 흙막이 n(실제 선 단까지)
|
||
· 길이 = 전 + 후 · 빈 높이 = 횡단도가 그리는 관경 최소 높이
|
||
돌붙임 › 찰붙임 · 메붙임 ㎡ 도수로/돌붙임 붙임 형식 · 면적(빈칸이면 안 셈 · 98-3)
|
||
관 공 › Φ{관경}m/m › 관매설 m B06 관 길이(1 m 올림 그대로)
|
||
› 커플링 ea 올림(L ÷ 1본) − 1 · 1본 = 프로젝트 층 M02 구조물집계표 변수만
|
||
관경 · 련 빈칸 = 「미입력」 열(임의 값으로 안 채움 · 143-2)
|
||
› 관보호공 모양 실제로 선 집수정 모양마다 1 개소(98-2 `inlet_basin`)
|
||
BOX암거 › {가로×세로} › 본체 m B06 새 키 `box_length_m`(두 끝 offset 차 · 120)
|
||
› 날개벽 › 유입 · 유출 개소
|
||
세월교 › Φ{관경}×{n}련 › 월류 폭 m · 본체 개소 · 날개벽 › 유입 · 유출 개소
|
||
관 몫 = 관 공 › 관매설 · 커플링에 더함(B06 새 키 `ford_pipe_length_m` × 련 · 131)
|
||
물넘이포장 › 월류 폭 › H={월류 높이} m
|
||
노출형 횡단수로 · 개거 · 맹암거(B05 구조물) 개소
|
||
흙막이 · 돌붙임 열은 관 · 박스 · 세월교 공용(실무 꼴) — 빈 높이: 관 = 관경 최소 ·
|
||
박스 = 박스 높이 · 세월교 = 「미입력」(본체 끝 높이는 기하에만)
|
||
열 id 접두 `pipe:` = 이 집계표(횡단배수) 이름공간 · 같은 측점 박스 + 관은 종류로 갈라 셈.
|
||
머리 글 = M02 정의 이름(`구조물정의.json` types.name · 종류.{종류}.이름 — 사용자가 M02 에서 고침) ·
|
||
열마다 `combos` = 그 값을 낸 컨테이너에 이은 조합 id(`{층}|{주인}|{키}` · 95-3 모달 · 맨 윗 머리).
|
||
설계 = B08 토공과 같은 길(확정 노선 먼저) · 관 · 컨테이너 줄 = M02 채움 `collect_items` 재사용.
|
||
`pipes` = 관마다 길이(커플링 열 · 측점) — 좌측 패널 1본 길이를 바꾸면 화면이 같은 식으로 바로 다시 셈
|
||
(짝 = `couplings` ↔ `B08_Quantity_Options_Calc.ts` `couplings` · `recountCouplings` · PLAN 122).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import math
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from uuid import UUID
|
||
|
||
from fastapi import APIRouter
|
||
|
||
from B06_Section.B06_Section_Engine_Culvert import pipe_wall_min_height_m
|
||
from B06_Section.B06_Section_Repository import (
|
||
get_confirmed_route_context,
|
||
get_cross_section_designs,
|
||
get_workflow_route_context,
|
||
)
|
||
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
|
||
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
|
||
from common_util.common_util_summary_item import GROUP_JOIN
|
||
from config.config_db import get_db_pool
|
||
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
|
||
from M02_MasterTemplete import M02_Table_Fill as fill
|
||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||
from M02_MasterTemplete.M02_Table_Fill_Spec import soil_tiers_from_designs
|
||
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
|
||
|
||
router = APIRouter(prefix="/api/projects", tags=["B08 Summary"])
|
||
|
||
TABLE_NAME = "구조물집계표"
|
||
ONE_LENGTH_VAR = "파형강관_1본_m"
|
||
#: 필수 컨테이너 잇기 키(`design_structure_pipe.ts` link) — 관 · 유입 · 유출
|
||
LINKS = {"pipe": "grv6lny", "inlet": "gnbf9m9", "outlet": "g9lq9uk"}
|
||
SOIL = "기슭막이" # 저장 값(화면 이름 흙막이)
|
||
GUARD_SOIL = "흙막이" # 박스 · 세월교 유입 · 유출 구조 값
|
||
WING = "날개벽"
|
||
#: 박스 · 세월교 필수 컨테이너 잇기 키(`design_structure_box` · `_ford`) · 물넘이 경사각도
|
||
BOX_LINKS = {"body": "gboxbody", "inlet": "gboxin", "outlet": "gboxout"}
|
||
FORD_LINKS = {"body": "gford", "inlet": "gfordin", "outlet": "gfordout"}
|
||
PAVE_LINKS = {"body": "gfordpaveslope"}
|
||
#: 옛 날개벽 설치 칸(「있음」 · 「없음」)
|
||
WING_KEYS = {"inlet": "wing_in", "outlet": "wing_out"}
|
||
SIDE_NAMES = {"inlet": "유입", "outlet": "유출"}
|
||
#: 개소 1 로 세는 B05 구조물(structures.json · 옵션 거의 없음)
|
||
SIMPLE = ("cross_drain_exposed", "open_ditch", "cross_underdrain")
|
||
#: 이 집계표가 펼치는 종류 — 화면은 이 종류를 조합 열에서 뺌(응답 `types`)
|
||
DRAIN_TYPES = ("pipe", "box_culvert", "ford_bridge", "ford_pavement", *SIMPLE)
|
||
FORMS = ("돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자")
|
||
SHAPES = ("I", "L", "U")
|
||
BONDS = ("찰붙임", "메붙임")
|
||
|
||
|
||
def _num(value: Any) -> float | None:
|
||
if isinstance(value, bool) or value in (None, ""):
|
||
return None
|
||
try:
|
||
return float(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _count(options: dict[str, Any], key: str) -> int | None:
|
||
"""관경 · 련 수 — 빈칸이면 None(「미입력」 열 · 임의 값으로 안 채움 · 143-2)."""
|
||
value = _num(options.get(key))
|
||
return int(value) if value else None
|
||
|
||
|
||
def _pipe_head(names: dict[str, str], diameter: int | None) -> list[str]:
|
||
"""「관 공」 머리 — 관경 빈칸이면 「미입력」."""
|
||
if diameter is None:
|
||
return ["관 공", fill.EMPTY_TEXT]
|
||
return ["관 공", f"Φ{names.get(f'pipe.diameter={diameter}', diameter)}m/m"]
|
||
|
||
|
||
def _key(value: int | None) -> str:
|
||
return fill.EMPTY_LABEL if value is None else str(value)
|
||
|
||
|
||
def _combo(item: Any) -> str | None:
|
||
"""집계 항목 → 조합 id(화면 `comboId` 와 같은 꼴) · 없으면 None."""
|
||
if not isinstance(item, dict) or not item.get("key"):
|
||
return None
|
||
return f"{item.get('layer')}|{item.get('owner') or ''}|{item['key']}"
|
||
|
||
|
||
def one_pipe_length(project_root: str | Path) -> float | None:
|
||
"""1본 길이(m) — 프로젝트 층 구조물집계표 변수만(시스템 층으로 안 채움 · 143-2) · 빈칸 None."""
|
||
found = layers.read_template(layers.project_dir(project_root), "table", TABLE_NAME)
|
||
value = _num((found["문서"].get("변수") or {}).get(ONE_LENGTH_VAR)) if found else None
|
||
return value if value and value > 0 else None
|
||
|
||
|
||
def couplings(length_m: float | None, one_m: float | None) -> int | None:
|
||
"""커플링 = 올림(L ÷ 1본) − 1(0 아래 없음) · 길이나 1본이 빈칸이면 None."""
|
||
if not length_m or not one_m:
|
||
return None
|
||
return max(math.ceil(length_m / one_m - 1e-9) - 1, 0)
|
||
|
||
|
||
def _basins(designs: Any) -> dict[float, str]:
|
||
"""설계 → `{측점: 실제로 선 집수정 모양}` — 안 섰으면 "" · 기록 없는 측점은 안 담음(98-2)."""
|
||
found: dict[float, str] = {}
|
||
for row in designs or []:
|
||
design = row.get("design") if isinstance(row, dict) else None
|
||
if isinstance(design, dict) and "inlet_basin" in design:
|
||
found[round(float(row.get("chainage_m") or 0.0), 3)] = str(design["inlet_basin"] or "")
|
||
return found
|
||
|
||
|
||
def _station_values(designs: Any, key: str) -> dict[float, float]:
|
||
"""설계 → `{측점: 양수 값}` — 값 없는 측점은 안 담음."""
|
||
found: dict[float, float] = {}
|
||
for row in designs or []:
|
||
design = row.get("design") if isinstance(row, dict) else None
|
||
value = _num(design.get(key)) if isinstance(design, dict) else None
|
||
if value and value > 0:
|
||
found[round(float(row.get("chainage_m") or 0.0), 3)] = value
|
||
return found
|
||
|
||
|
||
def _guard(type_id: str, options: dict[str, Any], side: str) -> str:
|
||
"""박스 · 세월교 유입 · 유출 구조(없음 · 날개벽 · 흙막이) — 옛 설치 칸 「있음」 = 날개벽.
|
||
짝(TS): 박스 `boxOptionsIn` · 세월교 `withLegacyGuards`(`A00_Common/design/`)."""
|
||
wing = options.get(WING_KEYS[side])
|
||
if type_id == "box_culvert":
|
||
return WING if wing == "있음" else str(wing or "")
|
||
kind = str(options.get(f"{side}_type") or "")
|
||
if wing == "있음":
|
||
return WING
|
||
if wing == "없음" and (kind or WING) == WING:
|
||
return "없음"
|
||
return kind
|
||
|
||
|
||
def _revet_length(options: dict[str, Any], head: str, unit: str) -> float | None:
|
||
"""흙막이 길이 = 전 + 후 · 둘 다 비면 길이 칸."""
|
||
before = _num(options.get(f"{head}before{unit}"))
|
||
after = _num(options.get(f"{head}after{unit}"))
|
||
if before is None and after is None:
|
||
return _num(options.get(f"{head}length{unit}"))
|
||
return (before or 0.0) + (after or 0.0)
|
||
|
||
|
||
class _Sheet:
|
||
"""열 · 측점 줄 · 계를 모음 — 같은 측점(1 cm)의 같은 열은 더함."""
|
||
|
||
def __init__(self) -> None:
|
||
self.columns: dict[str, dict[str, Any]] = {}
|
||
self.rows: dict[int, dict[str, Any]] = {}
|
||
self.owner = "" # 지금 셈하는 시설 — 사본 없어 안 센 칸의 시설을 모음(143-1)
|
||
self.unlinked: set[str] = set()
|
||
|
||
def add(
|
||
self,
|
||
chainage: float,
|
||
key: str,
|
||
sort: tuple[Any, ...],
|
||
group: list[str],
|
||
label: str,
|
||
value: float | None,
|
||
combos: list[str | None],
|
||
) -> None:
|
||
if not any(combos): # 그 칸을 낸 조합 사본이 없음 — 안 셈(143-1)
|
||
self.unlinked.add(self.owner)
|
||
return
|
||
cid = f"pipe:{key}"
|
||
column = self.columns.setdefault(
|
||
cid, {"id": cid, "group": group, "label": label, "combos": [], "_sort": sort}
|
||
)
|
||
for combo in combos:
|
||
if combo and combo not in column["combos"]:
|
||
column["combos"].append(combo)
|
||
row = self.rows.setdefault(round(chainage * 100), {"chainage_m": chainage, "values": {}})
|
||
if value is not None: # 열은 세움 — 놓였는데 값이 빈칸인 것이 보여야 함
|
||
row["values"][cid] = round((row["values"].get(cid) or 0.0) + value, 3)
|
||
|
||
def result(self) -> dict[str, Any]:
|
||
columns = sorted(self.columns.values(), key=lambda column: column["_sort"])
|
||
rows = [self.rows[key] for key in sorted(self.rows)]
|
||
total: dict[str, float] = {}
|
||
for row in rows:
|
||
for cid, value in row["values"].items():
|
||
total[cid] = round(total.get(cid, 0.0) + value, 3)
|
||
return {
|
||
"columns": [{k: v for k, v in c.items() if k != "_sort"} for c in columns],
|
||
"rows": rows,
|
||
"total": total,
|
||
}
|
||
|
||
|
||
def _order(values: tuple[str, ...], value: str) -> int:
|
||
return values.index(value) if value in values else len(values)
|
||
|
||
|
||
def _revets(
|
||
sheet: _Sheet,
|
||
at: float,
|
||
options: dict[str, Any],
|
||
links: dict[str, str | None],
|
||
sides: list[tuple[str, str | None]],
|
||
empty_height: float | None,
|
||
names: dict[str, str],
|
||
) -> None:
|
||
"""흙막이 — 유입 · 유출(흙막이일 때 · `sides`) + 흙막이 n(container_rows 가 선 단까지만 냄).
|
||
|
||
저장 키 = 필수는 `{쪽}_revet_{칸}_m` · 선택 흙막이는 `soil_add_{n}_{칸}`(명세 인스턴스 키) ·
|
||
빈 높이 = `empty_height`(None 이면 「미입력」 열) · 머리 글은 관 정의 이름(공용 열).
|
||
"""
|
||
sources = [(f"{side}_revet_", "_m", side, combo) for side, combo in sides]
|
||
sources += [
|
||
(f"soil_add_{link[len('gsoiladd') :]}_", "", "soil_add", combo)
|
||
for link, combo in links.items()
|
||
if link.startswith("gsoiladd")
|
||
]
|
||
for head, unit, container, combo in sources:
|
||
form = str(options.get(f"{head}form") or "")
|
||
height = _num(options.get(f"{head}height{unit}"))
|
||
if height is None:
|
||
height = empty_height
|
||
height_text = fill._label("height_m", height)
|
||
sheet.add(
|
||
at,
|
||
f"revet|{form or fill.EMPTY_LABEL}|{height_text}",
|
||
(0, _order(FORMS, form), form, math.inf if height is None else height),
|
||
[
|
||
names.get(f"outlet.structure={SOIL}", SOIL),
|
||
names.get(f"{container}.form={form}", form) or fill.EMPTY_TEXT,
|
||
fill.EMPTY_TEXT if height is None else f"H={height_text}",
|
||
],
|
||
"m",
|
||
_revet_length(options, head, unit),
|
||
[combo],
|
||
)
|
||
|
||
|
||
def _chutes(
|
||
sheet: _Sheet,
|
||
at: float,
|
||
options: dict[str, Any],
|
||
links: dict[str, str | None],
|
||
names: dict[str, str],
|
||
) -> None:
|
||
"""돌붙임 — 붙임 형식 · 면적이 다 있을 때만(설계자가 넣음)."""
|
||
for link, combo in links.items():
|
||
if not link.startswith("gchuteadd"):
|
||
continue
|
||
head = f"chute_add_{link[len('gchuteadd') :]}_"
|
||
bond = str(options.get(f"{head}bond") or "")
|
||
area = _num(options.get(f"{head}area"))
|
||
if options.get(f"{head}structure") != "돌붙임" or not bond or not area:
|
||
continue
|
||
sheet.add(
|
||
at,
|
||
f"chute|{bond}",
|
||
(1, _order(BONDS, bond), bond),
|
||
[
|
||
names.get("chute_add.structure=돌붙임", "돌붙임"),
|
||
names.get(f"chute_add.bond={bond}", bond),
|
||
],
|
||
"㎡",
|
||
area,
|
||
[combo],
|
||
)
|
||
|
||
|
||
def _wings(
|
||
sheet: _Sheet,
|
||
at: float,
|
||
type_id: str,
|
||
sort: int,
|
||
title: str,
|
||
options: dict[str, Any],
|
||
combos: dict[str, str | None],
|
||
names: dict[str, str],
|
||
) -> list[tuple[str, str | None]]:
|
||
"""박스 · 세월교 날개벽(유입 · 유출 개소) — 흙막이 쪽은 돌려줌(공용 흙막이 열로)."""
|
||
soils: list[tuple[str, str | None]] = []
|
||
for index, side in enumerate(("inlet", "outlet")):
|
||
kind = _guard(type_id, options, side)
|
||
if kind == GUARD_SOIL:
|
||
soils.append((side, combos.get(side)))
|
||
elif kind == WING:
|
||
sheet.add(
|
||
at,
|
||
f"{type_id}|wing|{side}",
|
||
(sort, 9, index),
|
||
[title, names.get(f"{side}.structure={WING}", WING), SIDE_NAMES[side]],
|
||
"개소",
|
||
1.0,
|
||
[combos.get(side)],
|
||
)
|
||
return soils
|
||
|
||
|
||
def _box(
|
||
sheet: _Sheet,
|
||
at: float,
|
||
base: dict[str, Any],
|
||
combos: dict[str, str | None],
|
||
names: dict[str, str],
|
||
length: float | None,
|
||
) -> float | None:
|
||
"""BOX암거 본체(가로 × 세로마다 m) — 흙막이 빈 높이로 쓸 박스 높이를 돌려줌."""
|
||
width = _num(base["options"].get("body_width_m"))
|
||
height = _num(base["options"].get("body_height_m"))
|
||
size = (
|
||
f"{fill._label('width_m', width)}×{fill._label('height_m', height)}"
|
||
if width or height
|
||
else fill.EMPTY_LABEL
|
||
)
|
||
sheet.add(
|
||
at,
|
||
f"box|body|{size}",
|
||
(3, 0, width or 0.0, height or 0.0),
|
||
[
|
||
names["종류"],
|
||
names.get(f"body.size={size}", fill.EMPTY_TEXT if size == fill.EMPTY_LABEL else size),
|
||
"본체",
|
||
],
|
||
"m",
|
||
length,
|
||
[_combo(base.get("summary_item")), combos.get("body")],
|
||
)
|
||
return height
|
||
|
||
|
||
def _ford(
|
||
sheet: _Sheet,
|
||
at: float,
|
||
base: dict[str, Any],
|
||
combos: dict[str, str | None],
|
||
names: dict[str, str],
|
||
) -> None:
|
||
"""세월교 — Φ관경×n련마다 월류 폭 m · 본체 개소(관경 · 련 빈칸 = 「미입력」)."""
|
||
diameter = _count(base["options"], "pipe_diameter_mm")
|
||
count = _count(base["options"], "pipe_count")
|
||
size = fill.EMPTY_TEXT if diameter is None else names.get(f"ford.diameter={diameter}", diameter)
|
||
spec = f"Φ{size}×{fill.EMPTY_TEXT if count is None else count}련"
|
||
width_name = names.get("ford.width", "월류 폭").split(" (")[0]
|
||
cells = (
|
||
("width", width_name, "m", _num(base["options"].get("ford_width_m"))),
|
||
("count", "본체", "개소", 1.0),
|
||
)
|
||
for index, (key, label, unit, value) in enumerate(cells):
|
||
sheet.add(
|
||
at,
|
||
f"ford|{_key(diameter)}|{_key(count)}|{key}",
|
||
(4, 0, diameter or math.inf, count or math.inf, index),
|
||
[names["종류"], spec, label],
|
||
unit,
|
||
value,
|
||
[_combo(base.get("summary_item")), combos.get("body")],
|
||
)
|
||
|
||
|
||
def _ford_pipes(
|
||
sheet: _Sheet,
|
||
at: float,
|
||
base: dict[str, Any],
|
||
length: float | None,
|
||
one_m: float | None,
|
||
names: dict[str, str],
|
||
pipes: list[dict[str, Any]],
|
||
body: str | None,
|
||
) -> None:
|
||
"""세월교 관 몫 — 한 련 길이 × 련 수를 관매설 · 커플링 열에 더함(관 공과 같은 열 · 131).
|
||
|
||
`pipes` 에는 련마다 한 줄(좌측 패널 1본 길이로 커플링을 다시 셀 때 련 수만큼 셈) ·
|
||
조합 = 시설 본체 + 본체 잇기(`body` · 143-1).
|
||
"""
|
||
diameter = _count(base["options"], "pipe_diameter_mm")
|
||
count = _count(base["options"], "pipe_count")
|
||
head = _pipe_head(names, diameter)
|
||
combos = [_combo(base.get("summary_item")), body]
|
||
each = couplings(length, one_m)
|
||
sheet.add(
|
||
at,
|
||
f"len|{_key(diameter)}",
|
||
(2, diameter or math.inf, 0),
|
||
[*head, "관매설"],
|
||
"m",
|
||
length * count if length and count else None,
|
||
combos,
|
||
)
|
||
sheet.add(
|
||
at,
|
||
f"cp|{_key(diameter)}",
|
||
(2, diameter or math.inf, 1),
|
||
[*head, "커플링"],
|
||
"ea",
|
||
None if each is None or count is None else each * count,
|
||
combos,
|
||
)
|
||
if any(combos):
|
||
column = f"pipe:cp|{_key(diameter)}"
|
||
pipes += [{"chainage_m": at, "column": column, "length_m": length}] * (count or 0)
|
||
|
||
|
||
def _pavement(
|
||
sheet: _Sheet,
|
||
at: float,
|
||
base: dict[str, Any],
|
||
combos: dict[str, str | None],
|
||
names: dict[str, str],
|
||
) -> None:
|
||
"""물넘이 포장 — 월류 높이(H)마다 월류 폭 m(실무 「물넘이 포장 H=1.0」)."""
|
||
height = _num(base["options"].get("ford_height_m"))
|
||
height_text = fill._label("height_m", height)
|
||
sheet.add(
|
||
at,
|
||
f"pave|{height_text}",
|
||
(5, math.inf if height is None else height),
|
||
[
|
||
names["종류"],
|
||
names.get("slope.width", "월류 폭").split(" (")[0],
|
||
fill.EMPTY_TEXT if height is None else f"H={height_text}",
|
||
],
|
||
"m",
|
||
_num(base["options"].get("ford_width_m")),
|
||
[_combo(base.get("summary_item")), combos.get("body")],
|
||
)
|
||
|
||
|
||
def _drain(
|
||
sheet: _Sheet,
|
||
at: float,
|
||
type_id: str,
|
||
base: dict[str, Any],
|
||
options: dict[str, Any],
|
||
links: dict[str, str | None],
|
||
group: dict[str, Any],
|
||
kinds: dict[str, Any],
|
||
pipe_names: dict[str, str],
|
||
box_length: float | None,
|
||
) -> None:
|
||
"""관 밖 횡단배수 한 곳 — 종류마다 본체 열 + 박스 · 세월교는 날개벽 · 흙막이 · 돌붙임(공용)."""
|
||
title = next(
|
||
(t["name"] for t in kinds.get("types") or [] if t.get("type_id") == type_id), type_id
|
||
)
|
||
names = {**(((kinds.get("종류") or {}).get(type_id) or {}).get("이름") or {}), "종류": title}
|
||
if type_id in SIMPLE:
|
||
combos = [_combo(base.get("summary_item")), *links.values()]
|
||
sheet.add(at, type_id, (6, SIMPLE.index(type_id)), [title], "개소", 1.0, combos)
|
||
return
|
||
# 필수 컨테이너 잇기 — 정의 그룹에 없는 것(세월교)은 저장된 잇기에서
|
||
required = {"box_culvert": BOX_LINKS, "ford_bridge": FORD_LINKS}.get(type_id, PAVE_LINKS)
|
||
combos = {part: links.get(link) or _combo(group.get(link)) for part, link in required.items()}
|
||
if type_id == "ford_pavement":
|
||
_pavement(sheet, at, base, combos, names)
|
||
return
|
||
if type_id == "box_culvert":
|
||
empty_height = _box(sheet, at, base, combos, names, box_length)
|
||
else:
|
||
_ford(sheet, at, base, combos, names)
|
||
empty_height = None # 본체 끝 높이는 기하에만 — 「미입력」 열
|
||
sort = 3 if type_id == "box_culvert" else 4
|
||
soils = _wings(sheet, at, type_id, sort, title, base["options"], combos, names)
|
||
_revets(sheet, at, options, links, soils, empty_height, pipe_names)
|
||
_chutes(sheet, at, options, links, pipe_names)
|
||
|
||
|
||
def pipe_summary(
|
||
project_root: str | Path,
|
||
designs: list[dict[str, Any]],
|
||
names: dict[str, str],
|
||
one_m: float | None,
|
||
kinds: dict[str, Any] | None = None,
|
||
linked: dict[str, dict[str, Any]] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""횡단배수 한 곳마다 컨테이너 값 → 집계표 열 · 줄 · 계(저장 안 함).
|
||
|
||
`names` = 관 정의 이름(공용 흙막이 · 돌붙임 머리) · `kinds` = M02 정의(비면 읽음) ·
|
||
`linked` = 연결된 사본(비면 읽음) — 칸마다 그 칸을 낸 컨테이너 사본이 있을 때만 셈(143-1).
|
||
"""
|
||
kinds = kinds or definition.read()
|
||
linked = snapshots.linked_snapshots(project_root) if linked is None else linked
|
||
items = fill.collect_items(
|
||
project_root,
|
||
fill.pipe_lengths_from_designs(designs),
|
||
soil_tiers=soil_tiers_from_designs(designs),
|
||
)
|
||
# 높이 등 빈 칸은 정의 기본값이 아니라 그림 규칙을 따라야 해 저장 그대로를 따로 읽음
|
||
# 같은 측점 박스 + 관은 복사본 id(`pipe@측점`)가 같음 — 종류로 가름
|
||
raw = {
|
||
(point.facility or "pipe", f"pipe@{round(float(point.chainage_m), 3)}"): point
|
||
for point in read_pipe_points_file(pipe_points_path_in(Path(project_root)))
|
||
}
|
||
basins = _basins(designs)
|
||
box_lengths = _station_values(designs, "box_length_m")
|
||
ford_lengths = _station_values(designs, "ford_pipe_length_m")
|
||
by_owner: dict[tuple[str, str], dict[str, Any]] = {}
|
||
others: dict[str, bool] = {} # 집계표 밖 구조물 — 사본 하나라도 있나(조합 열은 화면이 그림)
|
||
for item in items:
|
||
owner, _, link = item["복사본"].partition(GROUP_JOIN)
|
||
if item["type_id"] not in DRAIN_TYPES:
|
||
others[owner] = others.get(owner, False) or item["복사본"] in linked
|
||
continue
|
||
entry = by_owner.setdefault((item["type_id"], owner), {"links": {}})
|
||
# 거름 한 곳 — 사본 없는 잇기는 조합 없음으로(그 칸만 안 셈 · 143-1)
|
||
combo = _combo(item.get("summary_item")) if item["복사본"] in linked else None
|
||
if link:
|
||
entry["links"][link] = combo
|
||
else:
|
||
entry["base"] = {**item, "summary_item": item.get("summary_item") if combo else None}
|
||
|
||
sheet = _Sheet()
|
||
pipes: list[dict[str, Any]] = []
|
||
no_length = no_basin = no_box = no_ford = 0
|
||
for (type_id, owner), entry in by_owner.items():
|
||
base = entry.get("base")
|
||
if base is None:
|
||
continue
|
||
links = entry["links"]
|
||
sheet.owner = owner
|
||
point = raw.get((type_id, owner))
|
||
options = dict(point.options or {}) if point else {}
|
||
at = float(base["chainage_m"])
|
||
if type_id != "pipe":
|
||
box_length = fill._nearest(box_lengths, at) if type_id == "box_culvert" else None
|
||
group = { # 세월교 날 잇기(저장된 잇기)도 사본 있는 것만
|
||
gid: chosen
|
||
for gid, chosen in ((point.group_items if point else None) or {}).items()
|
||
if f"{owner}{GROUP_JOIN}{gid}" in linked
|
||
}
|
||
_drain(sheet, at, type_id, base, options, links, group, kinds, names, box_length)
|
||
body_link = BOX_LINKS["body"] if type_id == "box_culvert" else FORD_LINKS["body"]
|
||
body = links.get(body_link) or _combo(group.get(body_link))
|
||
counted = bool(base["summary_item"] or body) # 본체 칸을 셈(사본 있음)
|
||
no_box += type_id == "box_culvert" and counted and not box_length
|
||
if type_id == "ford_bridge":
|
||
ford_length = fill._nearest(ford_lengths, at)
|
||
no_ford += counted and not ford_length
|
||
_ford_pipes(sheet, at, base, ford_length, one_m, names, pipes, body)
|
||
continue
|
||
diameter = _count(base["options"], "pipe_diameter_mm")
|
||
pipe_head = _pipe_head(names, diameter)
|
||
pipe_combos = [_combo(base.get("summary_item")), links.get(LINKS["pipe"])]
|
||
length = base.get("관연장")
|
||
no_length += any(pipe_combos) and not length
|
||
sheet.add(
|
||
at,
|
||
f"len|{_key(diameter)}",
|
||
(2, diameter or math.inf, 0),
|
||
[*pipe_head, "관매설"],
|
||
"m",
|
||
length,
|
||
pipe_combos,
|
||
)
|
||
sheet.add(
|
||
at,
|
||
f"cp|{_key(diameter)}",
|
||
(2, diameter or math.inf, 1),
|
||
[*pipe_head, "커플링"],
|
||
"ea",
|
||
couplings(length, one_m),
|
||
pipe_combos,
|
||
)
|
||
if any(pipe_combos):
|
||
column = f"pipe:cp|{_key(diameter)}"
|
||
pipes.append({"chainage_m": at, "column": column, "length_m": length})
|
||
shape = fill._nearest(basins, at)
|
||
no_basin += bool(links.get(LINKS["inlet"])) and shape is None
|
||
if shape:
|
||
sheet.add(
|
||
at,
|
||
f"basin|{_key(diameter)}|{shape}",
|
||
(2, diameter or math.inf, 2 + _order(SHAPES, shape), shape),
|
||
[*pipe_head, "관보호공"],
|
||
names.get(f"inlet.form={shape}", shape),
|
||
1.0,
|
||
[links.get(LINKS["inlet"])],
|
||
)
|
||
|
||
sides = [side for side in ("inlet", "outlet") if options.get(f"{side}_type") == SOIL]
|
||
sources = [(side, links.get(LINKS[side])) for side in sides]
|
||
# 관경 빈칸 = 빈 높이도 「미입력」(관경 최소 높이를 모름)
|
||
wall = pipe_wall_min_height_m(diameter / 1000) if diameter else None
|
||
_revets(sheet, at, options, links, sources, wall, names)
|
||
_chutes(sheet, at, options, links, names)
|
||
|
||
out = sheet.result()
|
||
notes = []
|
||
unlinked = len(sheet.unlinked) + sum(not found for found in others.values())
|
||
if unlinked:
|
||
notes.append(
|
||
f"조합 사본 없는 시설 {unlinked}곳 — B05 · B06 좌측 패널에서 조합 연결 · [저장]"
|
||
)
|
||
if no_length:
|
||
notes.append(f"관 연장 없음 {no_length}곳 — B06 횡단 [저장] 뒤 채워짐")
|
||
if no_basin:
|
||
notes.append(f"집수정 모양 기록 없음 {no_basin}곳 — B06 횡단 [저장] 뒤 채워짐")
|
||
if no_box:
|
||
notes.append(f"BOX 본체 길이 없음 {no_box}곳 — B06 횡단 [저장] 뒤 채워짐")
|
||
if no_ford:
|
||
notes.append(f"세월교 관 길이 없음 {no_ford}곳 — B06 횡단 [저장] 뒤 채워짐")
|
||
if one_m is None:
|
||
notes.append(f"1본 길이 변수({ONE_LENGTH_VAR}) 빈칸 — 커플링 빈칸")
|
||
return {
|
||
**out,
|
||
"notes": notes,
|
||
"one_length_m": one_m,
|
||
"types": list(DRAIN_TYPES),
|
||
"pipes": pipes,
|
||
"linked": linked,
|
||
}
|
||
|
||
|
||
def _summary(root: Path, designs: list[dict[str, Any]]) -> dict[str, Any]:
|
||
kinds = definition.read()
|
||
names = ((kinds.get("종류") or {}).get("pipe") or {}).get("이름") or {}
|
||
return pipe_summary(root, designs, names, one_pipe_length(root), kinds)
|
||
|
||
|
||
@router.get("/{project_id}/quantity/summary")
|
||
async def structure_summary(project_id: UUID) -> dict[str, Any]:
|
||
root = await _project_root(project_id)
|
||
async with get_db_pool().acquire() as connection:
|
||
context = await get_confirmed_route_context(connection, project_id)
|
||
context = context or await get_workflow_route_context(connection, project_id)
|
||
# 노선 전이면 설계 없이 — 관은 서 있고 연장 · 집수정 모양만 빈칸(알림)
|
||
designs = (
|
||
await get_cross_section_designs(connection, int(context["route_id"])) if context else []
|
||
)
|
||
return await asyncio.to_thread(_summary, root, designs)
|