"""프로젝트 카드 요약 (PLAN 547-1) — 홈 · 목록 · 상세 카드가 같이 씀. 단계 = 대시보드 표 단계 막대와 같은 값(`workflow_state.current_stage` · 종류별 단계 목록). 썸네일 = 최신 노선 경로 GeoJSON(B05 정본) 중심선을 100 점 안쪽으로 줄여 0~1 칸에 맞춤 (가로세로 비 유지 · 가운데 · y 는 화면 방향 = 아래로 커짐). 값이 없으면 칸을 뺌. 쿼리 = 프로젝트 · 단계 · 참여자 · 노선 · 측점 다섯 번(개수와 무관) + 노선 · 구조물 파일 읽기. """ from __future__ import annotations import json from pathlib import Path from typing import Any import aiomysql from common_util.common_util_time import utc_now_naive, utc_text 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 = utc_now_naive() 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": utc_text(updated), "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