From 8b1b286e0400efe05b3ac045e6fddcddef6495de Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 18:01:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(B01):=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=B9=B4=EB=93=9C=20=EC=9A=94=EC=95=BD=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=C2=B7=20API=20=E2=80=94=20=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=C2=B7=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EC=84=A0=20=C2=B7=20?= =?UTF-8?q?=EB=8B=B4=EB=8B=B9=20=C2=B7=20=EB=A9=88=EC=B6=98=20=EB=82=A0=20?= =?UTF-8?q?=C2=B7=20=EC=97=B0=EC=9E=A5=20=C2=B7=20=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?=C2=B7=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20(PLAN=20547-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- B01_Dashboard/B01_Dashboard_Router_Home.py | 47 +++++ B01_Dashboard/B01_Dashboard_Summary.py | 172 ++++++++++++++++++ main.py | 2 + .../tester/test_547_1_project_summary.py | 143 +++++++++++++++ 4 files changed, 364 insertions(+) create mode 100644 B01_Dashboard/B01_Dashboard_Router_Home.py create mode 100644 B01_Dashboard/B01_Dashboard_Summary.py create mode 100644 resources/tester/test_547_1_project_summary.py diff --git a/B01_Dashboard/B01_Dashboard_Router_Home.py b/B01_Dashboard/B01_Dashboard_Router_Home.py new file mode 100644 index 000000000..ebeed7c2b --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Router_Home.py @@ -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])} diff --git a/B01_Dashboard/B01_Dashboard_Summary.py b/B01_Dashboard/B01_Dashboard_Summary.py new file mode 100644 index 000000000..eeec1b3a3 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Summary.py @@ -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 diff --git a/main.py b/main.py index cd8ab38f4..f39967346 100644 --- a/main.py +++ b/main.py @@ -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) — 권한은 길 안에서(산림청 · 회사). diff --git a/resources/tester/test_547_1_project_summary.py b/resources/tester/test_547_1_project_summary.py new file mode 100644 index 000000000..71d3a0907 --- /dev/null +++ b/resources/tester/test_547_1_project_summary.py @@ -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"]