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>
This commit is contained in:
2026-10-26 18:01:25 +09:00
co-authored by Claude Opus 5.5
parent 392cb92987
commit 8b1b286e04
4 changed files with 364 additions and 0 deletions
@@ -0,0 +1,47 @@
"""B01 대시보드 홈 API (PLAN 547) — 프로젝트 카드 요약 · 역할별 홈."""
from typing import Any
import aiomysql
from fastapi import APIRouter, Depends, Query
from common_util.common_util_auth import verify_session
from config.config_db import get_db_pool
from .B01_Dashboard_Summary import project_summaries
router = APIRouter(prefix="/api/dashboard", tags=["B01_Dashboard"])
SUMMARY_LIMIT = 200
async def visible_project_ids(session: dict[str, Any]) -> list[str]:
"""보이는 프로젝트(최근 수정 순) — 시스템 관리자 = 전부 · 회사 = 회사 전체 · 없으면 내 것."""
where, args = "p.deleted_at IS NULL", ()
if session["role"] != "SYSTEM_ADMIN":
if session.get("company_id"):
where, args = where + " AND p.company_id = %s", (int(session["company_id"]),)
else:
where, args = where + " AND p.user_id = %s", (int(session["user_id"]),)
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
f"SELECT p.id FROM projects p WHERE {where} "
"ORDER BY p.updated_at DESC, p.created_at DESC",
args,
)
return [str(r["id"]) for r in await cursor.fetchall()]
@router.get("/projects/summary")
async def projects_summary(
ids: str | None = Query(None, max_length=8000, description="쉼표로 이은 프로젝트 id"),
session: dict[str, Any] = Depends(verify_session),
):
"""카드 요약 — ids 없으면 보이는 프로젝트 전부(최근 수정 순) · 안 보이는 id 는 빠짐."""
visible = await visible_project_ids(session)
if ids:
allowed = set(visible)
wanted = dict.fromkeys(pid.strip() for pid in ids.split(","))
visible = [pid for pid in wanted if pid in allowed]
return {"status": "success", "projects": await project_summaries(visible[:SUMMARY_LIMIT])}
+172
View File
@@ -0,0 +1,172 @@
"""프로젝트 카드 요약 (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
+2
View File
@@ -28,6 +28,7 @@ from A07_Register.A07_Register_Router import router as a07_register_router
from A08_Support.A08_Support_Router import router as a08_support_router
from A09_Security.A09_Security_Router import router as a09_security_router
from B01_Dashboard.B01_Dashboard_Router import router as b01_dashboard_router
from B01_Dashboard.B01_Dashboard_Router_Home import router as b01_home_router
from B02_ProjRegister.B02_ProjRegister_Router import router as b02_proj_register_router
from B03_FileInput.B03_FileInput_Repository import ProjectNotFoundError
from B03_FileInput.B03_FileInput_Router import router as b03_file_input_router
@@ -464,6 +465,7 @@ app.include_router(a07_register_router)
app.include_router(a08_support_router)
app.include_router(a09_security_router)
app.include_router(b01_dashboard_router)
app.include_router(b01_home_router)
app.include_router(b02_proj_register_router)
protected = [Depends(verify_session)]
# 신규 그룹 등록(요구 345) — 권한은 길 안에서(산림청 · 회사).
@@ -0,0 +1,143 @@
"""547-1 프로젝트 카드 요약 — 썸네일 줄임 · 단계 · 없는 값 빼기 · 보이는 프로젝트만."""
import asyncio
import json
from datetime import datetime, timedelta
import B01_Dashboard.B01_Dashboard_Router_Home as home
import B01_Dashboard.B01_Dashboard_Summary as summary
ROUTE = "B05_Profile/route/r.geojson"
def test_thumb_line_fits_unit_box_and_caps_points():
line = [[1000.0 + i, 2000.0 + i * 0.5] for i in range(500)]
thumb = summary.thumb_line(line)
assert len(thumb) == 100
assert thumb[0] == [0.0, 0.75] and thumb[-1] == [1.0, 0.25] # y 아래로 · 세로 가운데
assert all(0 <= x <= 1 and 0 <= y <= 1 for x, y in thumb)
assert summary.thumb_line([[0.0, 0.0]]) is None
def test_stage_names_follow_step_bar():
assert len(summary.stage_names("ROAD")) == 7
assert summary.stage_names("PLAN")[0] == "기준 지정"
assert summary.stage_names("EROSION") == summary.stage_names("ROAD")
class _Cursor:
def __init__(self, db):
self.db, self.rows = db, []
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
async def execute(self, sql, args=()):
self.db["queries"] += 1
key = next(k for k in self.db["answers"] if k in sql)
self.rows = self.db["answers"][key]
async def fetchall(self):
return self.rows
class _Pool:
def __init__(self, db):
self.db = db
def acquire(self):
pool = self
class _Conn:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
def cursor(self, *_):
return _Cursor(pool.db)
return _Conn()
def test_summaries_in_batch(monkeypatch, tmp_path):
now = datetime.now()
route_dir = tmp_path / "B05_Profile" / "route"
route_dir.mkdir(parents=True)
coords = [[0, 0, 1], [10, 0, 1], [10, 5, 1]]
(route_dir / "r.geojson").write_text(json.dumps({"geometry": {"coordinates": coords}}))
(route_dir / "structures.json").write_text(json.dumps({"structures": [{}, {}]}))
db = {
"queries": 0,
"answers": {
"FROM projects p LEFT JOIN users": [
{
"id": "a",
"name": "가",
"project_kind": "ROAD",
"region": "강원",
"updated_at": now - timedelta(days=20),
"storage_path": "storage/a",
"company_id": 1,
"user_id": 7,
"owner_name": "엄",
},
{
"id": "b",
"name": "나",
"project_kind": "PLAN",
"region": None,
"updated_at": now,
"storage_path": None,
"company_id": 1,
"user_id": 7,
"owner_name": "엄",
},
],
"project_workflow_stages": [
{"project_id": "a", "stage_no": n, "stage_key": "", "state": s}
| {"progress_percent": 0, "params": None, "message": None}
| {"started_at": None, "completed_at": None}
for n, s in enumerate(["COMPLETE", "COMPLETE", "IN_PROGRESS"] + ["NOT"] * 4)
],
"project_members": [{"project_id": "a", "name": "박"}],
"FROM routes": [
{"id": 9, "project_id": "a", "total_length_m": 15.04, "route_data_path": ROUTE},
{"id": 5, "project_id": "a", "total_length_m": 1.0, "route_data_path": "old"},
],
"cross_sections": [{"route_id": 9, "n": 3}],
},
}
monkeypatch.setattr(summary, "get_db_pool", lambda: _Pool(db))
monkeypatch.setattr(summary, "project_root", lambda p: tmp_path if p else None)
out = asyncio.run(summary.project_summaries(["b", "a", "zz"]))
assert [s["id"] for s in out] == ["b", "a"] # 넣은 차례 · 없는 id 빠짐
b, a = out
assert (a["stage_index"], a["stage_total"], a["stage_name"]) == (2, 7, "노선설계")
assert a["stale_days"] == 20 and a["owner_names"] == ["엄", "박"]
assert a["metrics"] == {"length_m": 15.0, "stations": 3, "structures": 2}
assert len(a["thumb"]) == 3 and a["done"] is False
assert b["thumb"] is None and b["metrics"] == {} and b["stage_name"] == "기준 지정"
assert db["queries"] == 5
def test_summary_api_drops_invisible_ids(monkeypatch):
seen = {}
async def fake_visible(session):
return ["a", "b"]
async def fake_summaries(ids):
seen["ids"] = ids
return []
monkeypatch.setattr(home, "visible_project_ids", fake_visible)
monkeypatch.setattr(home, "project_summaries", fake_summaries)
asyncio.run(home.projects_summary(ids="b, x,a,b", session={}))
assert seen["ids"] == ["b", "a"]
asyncio.run(home.projects_summary(ids=None, session={}))
assert seen["ids"] == ["a", "b"]