- B01_Dashboard_Summary.project_summaries(ids) — 쿼리 다섯 번(개수 무관) · 넣은 차례 · 없는 값은 칸 뺌 - 썸네일 = 최신 노선 GeoJSON 중심선 100 점 안쪽 · 0~1 칸(가로세로 비 유지 · y 아래로) - GET /api/dashboard/projects/summary?ids= — 보이는 프로젝트만(시스템 관리자 전부 · 회사 · 내 것) - 실측 21 개 0.14 s Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
7.3 KiB
Python
173 lines
7.3 KiB
Python
"""프로젝트 카드 요약 (PLAN 547-1) — 홈 · 목록 · 상세 카드가 같이 씀.
|
|
|
|
단계 = 대시보드 표 단계 막대와 같은 값(`workflow_state.current_stage` · 종류별 단계 목록).
|
|
썸네일 = 최신 노선 경로 GeoJSON(B05 정본) 중심선을 100 점 안쪽으로 줄여 0~1 칸에 맞춤
|
|
(가로세로 비 유지 · 가운데 · y 는 화면 방향 = 아래로 커짐). 값이 없으면 칸을 뺌.
|
|
쿼리 = 프로젝트 · 단계 · 참여자 · 노선 · 측점 다섯 번(개수와 무관) + 노선 · 구조물 파일 읽기.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import aiomysql
|
|
|
|
from config.config_db import get_db_pool
|
|
from M02_MasterTemplete.M02_Template_Migrate import project_root
|
|
|
|
from .B01_Dashboard_Repository import get_workflow_states_for_projects
|
|
|
|
THUMB_POINTS = 100
|
|
|
|
# 단계 이름 = 화면 단계 막대 글(ui_template_locale_common · _de) 한국어 · 모르는 종류 = 임도
|
|
STAGE_NAMES: dict[str, tuple[str, ...]] = {
|
|
"ROAD": ("파일입력", "전처리", "노선설계", "횡단설계", "상세설계", "수량산출", "원가계산"),
|
|
"PLAN": ("기준 지정", "노선 분석", "확정 · 내보내기"),
|
|
"STABILITY": ("지반 조건", "2D 해석", "3D 해석", "보고서", "암반 해석", "광역 위험"),
|
|
}
|
|
|
|
|
|
def stage_names(kind: str | None) -> tuple[str, ...]:
|
|
return STAGE_NAMES.get(kind or "ROAD", STAGE_NAMES["ROAD"])
|
|
|
|
|
|
def thumb_line(points: list[list[float]], limit: int = THUMB_POINTS) -> list[list[float]] | None:
|
|
"""중심선 → limit 점 안쪽 · 0~1 칸(가로세로 비 유지 · y 아래로). 두 점 못 되면 None."""
|
|
if len(points) < 2:
|
|
return None
|
|
if len(points) > limit:
|
|
last = len(points) - 1
|
|
points = [points[round(i * last / (limit - 1))] for i in range(limit)]
|
|
xs = [p[0] for p in points]
|
|
ys = [p[1] for p in points]
|
|
dx, dy = max(xs) - min(xs), max(ys) - min(ys)
|
|
span = max(dx, dy) or 1.0
|
|
ox, oy = (1 - dx / span) / 2, (1 - dy / span) / 2
|
|
return [
|
|
[round(ox + (x - min(xs)) / span, 4), round(1 - oy - (y - min(ys)) / span, 4)]
|
|
for x, y in zip(xs, ys, strict=True)
|
|
]
|
|
|
|
|
|
def _route_points(root: Path | None, route_data_path: str | None) -> list[list[float]]:
|
|
from B05_Profile.B05_Profile_Engine_Sections import _load_route_polyline
|
|
|
|
if root is None or not route_data_path:
|
|
return []
|
|
try:
|
|
return [[x, y] for x, y, _ in _load_route_polyline(root, route_data_path)]
|
|
except (OSError, ValueError):
|
|
return []
|
|
|
|
|
|
def _structure_count(root: Path | None) -> int | None:
|
|
from B05_Profile.B05_Profile_Structures_Repository import structures_file_path
|
|
|
|
if root is None:
|
|
return None
|
|
try:
|
|
with open(structures_file_path(str(root)), encoding="utf-8") as handle:
|
|
items = json.load(handle).get("structures")
|
|
except (OSError, ValueError, AttributeError):
|
|
return None
|
|
return len(items) if isinstance(items, list) else None
|
|
|
|
|
|
def _marks(values: list[Any]) -> str:
|
|
return ", ".join(["%s"] * len(values))
|
|
|
|
|
|
async def project_summaries(project_ids: list[str]) -> list[dict[str, Any]]:
|
|
"""프로젝트 여럿 → 카드 요약(넣은 차례 · 지운 · 없는 프로젝트는 빠짐)."""
|
|
ids = list(dict.fromkeys(str(pid) for pid in project_ids))
|
|
if not ids:
|
|
return []
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
|
await cursor.execute(
|
|
f"""SELECT p.id, p.name, p.project_kind, p.region, p.updated_at, p.storage_path,
|
|
p.company_id, p.user_id, u.name AS owner_name
|
|
FROM projects p LEFT JOIN users u ON u.id = p.user_id
|
|
WHERE p.id IN ({_marks(ids)}) AND p.deleted_at IS NULL""",
|
|
tuple(ids),
|
|
)
|
|
rows = {str(r["id"]): r for r in await cursor.fetchall()}
|
|
states = await get_workflow_states_for_projects(cursor, list(rows))
|
|
await cursor.execute(
|
|
f"""SELECT m.project_id, u.name FROM project_members m
|
|
JOIN users u ON u.id = m.user_id AND u.deleted_at IS NULL
|
|
WHERE m.project_id IN ({_marks(ids)}) ORDER BY u.name""",
|
|
tuple(ids),
|
|
)
|
|
members: dict[str, list[str]] = {}
|
|
for r in await cursor.fetchall():
|
|
members.setdefault(str(r["project_id"]), []).append(r["name"])
|
|
# 최신 노선 = B05 get_latest_route 와 같은 차례(computed_at · id 내림)
|
|
await cursor.execute(
|
|
f"""SELECT id, project_id, total_length_m, route_data_path FROM routes
|
|
WHERE project_id IN ({_marks(ids)})
|
|
ORDER BY computed_at DESC, id DESC""",
|
|
tuple(ids),
|
|
)
|
|
routes: dict[str, dict[str, Any]] = {}
|
|
for r in await cursor.fetchall():
|
|
routes.setdefault(str(r["project_id"]), r)
|
|
stations: dict[int, int] = {}
|
|
route_ids = [int(r["id"]) for r in routes.values()]
|
|
if route_ids:
|
|
await cursor.execute(
|
|
f"""SELECT route_id, COUNT(*) AS n FROM cross_sections
|
|
WHERE route_id IN ({_marks(route_ids)}) GROUP BY route_id""",
|
|
tuple(route_ids),
|
|
)
|
|
stations = {int(r["route_id"]): int(r["n"]) for r in await cursor.fetchall()}
|
|
|
|
now = datetime.now()
|
|
result = []
|
|
for pid in ids:
|
|
row = rows.get(pid)
|
|
if row is None:
|
|
continue
|
|
state = states.get(pid, {"current_stage": 0, "stages": []})
|
|
names = stage_names(row.get("project_kind"))
|
|
index = min(int(state["current_stage"]), len(names) - 1)
|
|
stages = state["stages"]
|
|
root = project_root(row.get("storage_path"))
|
|
route = routes.get(pid)
|
|
metrics: dict[str, Any] = {}
|
|
if route and route.get("total_length_m") is not None:
|
|
metrics["length_m"] = round(float(route["total_length_m"]), 1)
|
|
if route and stations.get(int(route["id"])):
|
|
metrics["stations"] = stations[int(route["id"])]
|
|
structures = _structure_count(root)
|
|
if structures is not None:
|
|
metrics["structures"] = structures
|
|
owners = [row["owner_name"]] if row.get("owner_name") else []
|
|
owners += [name for name in members.get(pid, []) if name and name not in owners]
|
|
updated = row.get("updated_at")
|
|
result.append(
|
|
{
|
|
"id": pid,
|
|
"name": row["name"],
|
|
"kind": row.get("project_kind") or "ROAD",
|
|
"region": row.get("region"),
|
|
"company_id": row.get("company_id"),
|
|
"stage_index": index,
|
|
"stage_total": len(names),
|
|
"stage_name": names[index],
|
|
"done": bool(stages) and all(s["state"] == "COMPLETE" for s in stages),
|
|
"updated_at": updated.isoformat() if updated else None,
|
|
"stale_days": (now - updated).days if updated else None,
|
|
"owner_user_id": row.get("user_id"),
|
|
"owner_names": owners,
|
|
"thumb": thumb_line(
|
|
_route_points(root, route.get("route_data_path")) if route else []
|
|
),
|
|
"metrics": metrics,
|
|
}
|
|
)
|
|
return result
|