Files
Aislo/B01_Dashboard/B01_Dashboard_Summary.py
T
eomsangdonandClaude Opus 5.5 8b1b286e04 feat(B01): 프로젝트 카드 요약 함수 · API — 단계 · 썸네일 선 · 담당 · 멈춘 날 · 연장 · 측점 · 구조물 (PLAN 547-1)
- 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>
2026-10-26 18:01:25 +09:00

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