"""D 예정노선 확정 · 업체 지정 API (요구 345 · 391 · PLAN 531-1 · 531-2 · 563-2). - `GET /api/projects/{id}/plan/assignees` — 조각별 지정 업체(지정 회사 = 자기 행만) - `PUT /api/projects/{id}/plan/assignees` — 조각별 지정 업체 저장(등록 회사 또는 업체명 + 메일) - `POST /api/projects/{id}/plan/finalize` — 연도 노선 확정 → 업체별 조각 zip → 메일 첨부 → 발송 시각 - `GET /api/projects/{id}/plan/summary` — D04 화면 연도 · 조각 요약(지정 회사 = 자기 조각 · 행만) - `GET /api/projects/{id}/plan/export` — [내보내기] zip(산림청 = 전 조각 + 전체 · 공유 = 자기 조각) 단위 = 조각(`plan_route_pieces` · 구역 × 갈래 · 요구 391) · 조각이 없는 연도 행(옛 프로젝트)은 연도 하나가 단위. 확정은 연도 단위(`plan_route_years.confirmed_at`). 업체 행 `piece_id` 가 비면 옛 연도 지정 — 그 연도 첫 단위에 붙음. 지정 · 확정은 산림청(시스템 관리자 또는 메일 *.go.kr)만. 지정받은 회사는 공용 접근 판정이 GET 만 통과시킴. 계산 자리 ③ 서버 단독 — 내보내기 파일은 저장된 `route_json` 으로만 만듦. """ from __future__ import annotations import asyncio import io import logging import zipfile from typing import Any from urllib.parse import quote from uuid import UUID from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import Response from pydantic import BaseModel from common_util.common_util_auth import is_forest_service, verify_session from common_util.common_util_crs import tm_to_lonlat from D04_PlanConfirm import D04_PlanConfirm_Repository as repo from D04_PlanConfirm.D04_PlanConfirm_Email import build_completion_mail, send_completion_mail from D04_PlanConfirm.D04_PlanConfirm_Export import ( MIME_ZIP, YearRoute, build_export, company_zip, safe_name, station_label, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["D04 Plan Confirm"]) class AssigneeIn(BaseModel): year: int piece_id: str | None = None # 조각 — 비면 옛 연도 지정(조각 없는 프로젝트) company_id: int | None = None # 등록 회사 — 없으면 수기 업체 company_name: str | None = None email: str | None = None class AssigneesRequest(BaseModel): assignees: list[AssigneeIn] years: list[int] | None = None # 맞출 연도(업체를 다 뺀 연도도) — 비우면 보낸 업체의 연도 class FinalizeRequest(BaseModel): years: list[int] | None = None # 비우면 분석이 저장된 모든 연도 resend: bool = False # 이미 메일을 보낸 업체에도 다시 보냄 def _require_forest(session: dict[str, Any]) -> None: if not is_forest_service(session): raise HTTPException( status_code=403, detail="산림청만 예정노선을 확정 · 지정할 수 있습니다." ) def _key(row: dict[str, Any]) -> tuple: """같은 업체 판정 — 같은 조각(옛 = 연도)에서 등록 회사 = id · 수기 = 메일(대소문자 무시).""" unit = (row["year"], row.get("piece_id")) if row.get("company_id") is not None: return (*unit, "c", row["company_id"]) return (*unit, "e", (row.get("email") or "").strip().lower()) def plan_assignee_changes( existing: list[dict[str, Any]], wanted: list[dict[str, Any]], years: set[int] ) -> tuple[list[int], list[dict], list[dict]]: """보낸 연도(`years`)만 맞춰 바꿈 — (지울 id, 고칠 행, 넣을 행). 같은 업체는 행을 살려 두어 발송 시각 · 연결한 프로젝트가 남는다. """ old = {_key(row): row for row in existing if row["year"] in years} new = {_key(row): row for row in wanted} delete_ids = [row["id"] for key, row in old.items() if key not in new] updates = [{**row, "id": old[key]["id"]} for key, row in new.items() if key in old] inserts = [row for key, row in new.items() if key not in old] return delete_ids, updates, inserts async def _resolve_assignee(item: AssigneeIn) -> dict[str, Any]: """등록 회사면 이름 · 대표 메일을 DB 에서, 수기면 이름 + 메일을 검사.""" email = (item.email or "").strip() name = (item.company_name or "").strip() unit = {"year": item.year, "piece_id": item.piece_id} if item.company_id is not None: company = await repo.get_company_contact(item.company_id) if not company: raise HTTPException( status_code=400, detail=f"등록 회사를 찾을 수 없습니다: {item.company_id}" ) return { **unit, "company_id": item.company_id, "company_name": company["name"], "email": email or company.get("email") or None, } if not name or "@" not in email: raise HTTPException(status_code=400, detail="업체명과 메일 주소가 필요합니다.") return {**unit, "company_id": None, "company_name": name, "email": email} async def _plan_project(project_id: UUID) -> dict[str, Any]: project = await repo.get_plan_project(str(project_id)) if not project: raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") if project["project_kind"] != "PLAN": raise HTTPException(status_code=400, detail="예정노선 프로젝트가 아닙니다.") return project def route_units(rows: list[dict[str, Any]], pieces: list[dict[str, Any]]) -> list[dict[str, Any]]: """표 한 줄 단위(연도 차례) — 조각마다 · 조각이 없는 연도 행(옛 · 분석 전)은 연도 하나.""" confirmed = {int(r["year"]): r.get("confirmed_at") for r in rows} units = [ { "piece_id": p["piece_id"], "line": p["line_id"], "line_name": p.get("line_name"), "year": int(p["year"]), "epsg": int(p["epsg"]), "planned": (p.get("route_json") or {}).get("planned") or [], "start_chainage_m": float(p["from_m"]), "end_chainage_m": float(p["to_m"]), "length_m": float(p["length_m"]), "confirmed_at": confirmed.get(int(p["year"])), } for p in pieces ] taken = {u["year"] for u in units} for row in rows: if int(row["year"]) in taken: continue body = row.get("route_json") or {} units.append( { "piece_id": None, "line": None, "line_name": None, "year": int(row["year"]), "epsg": row.get("epsg"), "planned": body.get("planned") or [], "start_chainage_m": body.get("start_chainage_m"), "end_chainage_m": body.get("end_chainage_m"), "length_m": body.get("length_m"), "confirmed_at": row.get("confirmed_at"), } ) units.sort(key=lambda u: u["year"]) # 같은 연도 안 = 조각 차례 그대로 return units def unit_key(unit: dict[str, Any]) -> str | int: """내보내기 묶음 열쇠 — 조각 id · 옛 연도.""" return unit["piece_id"] or unit["year"] def unit_label(unit: dict[str, Any]) -> str: """zip 폴더 · 첨부 이름 — 「2026」(옛) · 「2026_갈래1_p3」(조각).""" if not unit["piece_id"]: return str(unit["year"]) line = (unit["line_name"] or unit["line"]).replace(" ", "") return f"{unit['year']}_{line}_{unit['piece_id']}" def unit_of(assignee: dict[str, Any], units: list[dict[str, Any]]) -> dict[str, Any] | None: """업체 행의 단위 — 조각 지정 = 그 조각 · 옛 연도 지정 = 그 연도 첫 단위.""" piece = assignee.get("piece_id") for unit in units: if (unit["piece_id"] == piece) if piece else (unit["year"] == assignee["year"]): return unit return None def _saved(unit: dict[str, Any]) -> bool: return len(unit["planned"]) >= 2 def visible_to( session: dict[str, Any], units: list[dict[str, Any]], assignees: list[dict[str, Any]] ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: """산림청 = 전부 · 지정 회사 = 자기 회사가 지정된 단위 · 자기 업체 행만(남의 이름 · 메일 뺌).""" if is_forest_service(session): return units, assignees company = session.get("company_id") mine = [a for a in assignees if a["company_id"] is not None and a["company_id"] == company] own = [unit_of(a, units) for a in mine] return [u for u in units if any(u is o for o in own)], mine async def _units(project_id: UUID) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: """(연도 행, 단위).""" rows = await repo.list_year_routes(str(project_id)) return rows, route_units(rows, await repo.list_pieces(str(project_id))) @router.get("/{project_id}/plan/assignees", response_model=None) async def get_plan_assignees( project_id: UUID, session: dict = Depends(verify_session) ) -> dict[str, Any]: await _plan_project(project_id) _, assignees = visible_to(session, [], await repo.list_assignees(str(project_id))) return {"status": "success", "assignees": assignees} @router.put("/{project_id}/plan/assignees", response_model=None) async def put_plan_assignees( project_id: UUID, request: AssigneesRequest, session: dict = Depends(verify_session) ) -> dict[str, Any]: """보낸 연도의 지정 업체를 그대로 맞춤 — 연도를 안 보낸 곳은 건드리지 않음. 조각 지정이면 연도는 조각 행에서 읽음(보낸 연도는 무시).""" _require_forest(session) await _plan_project(project_id) known = {row["year"] for row in await repo.list_year_routes(str(project_id))} piece_years = {p["piece_id"]: int(p["year"]) for p in await repo.list_pieces(str(project_id))} for item in request.assignees: if item.piece_id is None: continue if item.piece_id not in piece_years: raise HTTPException(status_code=400, detail=f"조각이 없습니다: {item.piece_id}") item.year = piece_years[item.piece_id] years = {a.year for a in request.assignees} | set(request.years or []) unknown = sorted(years - known) if unknown: raise HTTPException(status_code=400, detail=f"예정노선 연도가 아닙니다: {unknown}") wanted = [await _resolve_assignee(item) for item in request.assignees] existing = await repo.list_assignees(str(project_id)) delete_ids, updates, inserts = plan_assignee_changes(existing, wanted, years) await repo.apply_assignee_changes(str(project_id), delete_ids, updates, inserts) return {"status": "success", "assignees": await repo.list_assignees(str(project_id))} def unit_routes(units: list[dict[str, Any]]) -> list[YearRoute]: """저장된 단위 → 내보내기 입력(`planned` = 프로젝트 좌표 꺾은선).""" return [ YearRoute( year=unit["year"], points=[(p[0], p[1]) for p in unit["planned"]], crs_epsg=int(unit["epsg"]), start_station_m=unit["start_chainage_m"], end_station_m=unit["end_chainage_m"], piece=unit["piece_id"], line=(unit["line_name"] or unit["line"] or "").replace(" ", "") or None, ) for unit in units if _saved(unit) ] def _result(assignee: dict[str, Any], state: str) -> dict[str, Any]: return { "year": assignee["year"], "piece_id": assignee.get("piece_id"), "company_name": assignee["company_name"], "email": assignee["email"], "state": state, } @router.post("/{project_id}/plan/finalize", response_model=None) async def finalize_plan( project_id: UUID, request: FinalizeRequest, session: dict = Depends(verify_session) ) -> dict[str, Any]: """연도 노선 확정 → 지정 업체마다 그 조각 zip 을 첨부해 메일 → 보낸 곳에 발송 시각.""" _require_forest(session) project = await _plan_project(project_id) _, units = await _units(project_id) routes = unit_routes(units) saved = {r.year for r in routes} years = sorted(set(request.years) if request.years else saved) missing = [y for y in years if y not in saved] if not years or missing: raise HTTPException( status_code=409, detail=f"분석 [저장]이 안 된 연도입니다: {missing or '없음'}" ) await repo.mark_confirmed(str(project_id), years) # 옛 누가거리가 연도마다 이어지므로 저장된 전 단위로 한 번에 만들고, 보낼 때만 고름. first = float(project.get("route_start_m") or 0.0) bundle = await asyncio.to_thread( build_export, project["name"], project["region"] or "", routes, first ) results = [] for assignee in await repo.list_assignees(str(project_id)): year = assignee["year"] if year not in years: continue if assignee["notified_at"] and not request.resend: results.append(_result(assignee, "already_sent")) continue if not assignee["email"]: results.append(_result(assignee, "no_email")) continue unit = unit_of(assignee, units) if unit is None or unit_key(unit) not in bundle.by_year: results.append(_result(assignee, "no_route")) continue key = unit_key(unit) zip_name, zip_bytes = company_zip(project["name"], unit_label(unit), bundle.by_year[key]) subject, body = build_completion_mail( project_name=project["name"], region=project["region"], year=year, length_m=bundle.lengths_m[key], recipient=assignee["company_name"] or assignee["email"], attachment_names=[zip_name], ) sent = await send_completion_mail( to_email=assignee["email"], subject=subject, body=body, attachments=[(zip_name, zip_bytes, MIME_ZIP)], ) if sent: await repo.mark_notified(assignee["id"]) results.append(_result(assignee, "sent" if sent else "failed")) return {"status": "success", "confirmed_years": years, "notifications": results} def _end_point(point: list[float], epsg: int) -> dict[str, float]: lon, lat = tm_to_lonlat(float(point[0]), float(point[1]), epsg) return {"x": float(point[0]), "y": float(point[1]), "lon": lon, "lat": lat} def year_summary(row: dict[str, Any]) -> dict[str, Any]: """연도 한 줄 — 분석 [저장]이 없으면 saved False 만.""" body = row.get("route_json") or {} planned = body.get("planned") or [] out = {"year": int(row["year"]), "saved": len(planned) >= 2, "confirmed_at": None} if row.get("confirmed_at"): out["confirmed_at"] = str(row["confirmed_at"]) if out["saved"]: epsg = int(row["epsg"]) out.update( length_m=body.get("length_m"), start_chainage_m=body.get("start_chainage_m"), end_chainage_m=body.get("end_chainage_m"), mode=body.get("mode"), start=_end_point(planned[0], epsg), end=_end_point(planned[-1], epsg), ) return out def unit_summary(unit: dict[str, Any]) -> dict[str, Any]: """표 한 줄(조각 · 옛 연도) — 저장 전이면 saved False · label 은 조각만(「본선 0+0~12+10」).""" out = {key: unit[key] for key in ("piece_id", "line", "line_name", "year", "length_m")} out.update(saved=_saved(unit), confirmed_at=None, label=None) if unit["confirmed_at"]: out["confirmed_at"] = str(unit["confirmed_at"]) if out["saved"]: epsg = int(unit["epsg"]) out.update( start_chainage_m=unit["start_chainage_m"], end_chainage_m=unit["end_chainage_m"], start=_end_point(unit["planned"][0], epsg), end=_end_point(unit["planned"][-1], epsg), ) if unit["piece_id"]: out["label"] = piece_label( unit["line_name"] or unit["line"], unit["start_chainage_m"], unit["end_chainage_m"] ) return out def piece_label(line_name: str, from_m: float, to_m: float) -> str: """조각 이름 「본선 0+0~12+10」(측점 = 선마다 No.0).""" return f"{line_name} {station_label(from_m)}~{station_label(to_m)}" @router.get("/{project_id}/plan/summary", response_model=None) async def get_plan_summary( project_id: UUID, session: dict = Depends(verify_session) ) -> dict[str, Any]: """`years` = 연도 행(확정 고르기) · `pieces` = 표 한 줄 단위(조각 · 옛 연도).""" project = await _plan_project(project_id) rows, units = await _units(project_id) units, assignees = visible_to(session, units, await repo.list_assignees(str(project_id))) own_years = {u["year"] for u in units} years = [year_summary(row) for row in rows if int(row["year"]) in own_years] return { "status": "success", "name": project["name"], "years": years, "pieces": [unit_summary(u) for u in units], "assignees": assignees, } def export_zip(bundle: Any, units: list[dict[str, Any]], with_all: bool) -> bytes: """단위마다 `<연도>/`(옛) · `<연도>_<선>_<조각>/` · 전체 `전체/` — 좌표표 이름이 같아 폴더로.""" buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: for unit in units: for name, data in bundle.by_year[unit_key(unit)].items(): archive.writestr(f"{unit_label(unit)}/{name}", data) if with_all: for name, data in bundle.all.items(): archive.writestr(f"전체/{name}", data) return buffer.getvalue() @router.get("/{project_id}/plan/export", response_model=None) async def export_plan(project_id: UUID, session: dict = Depends(verify_session)) -> Response: """산림청 = 저장된 전 조각 + 전체 · 지정 회사 = 확정된 자기 조각만(전체 없음).""" project = await _plan_project(project_id) _, units = await _units(project_id) routes = unit_routes(units) if not routes: raise HTTPException(status_code=409, detail="분석 [저장]이 된 연도가 없습니다.") forest = is_forest_service(session) picked = [u for u in units if _saved(u)] if not forest: own, _ = visible_to(session, picked, await repo.list_assignees(str(project_id))) picked = [u for u in own if u["confirmed_at"]] if not picked: raise HTTPException(status_code=403, detail="받을 수 있는 조각이 없습니다.") first = float(project.get("route_start_m") or 0.0) bundle = await asyncio.to_thread( build_export, project["name"], project["region"] or "", routes, first ) data = await asyncio.to_thread(export_zip, bundle, picked, forest) name = f"{safe_name(project['name'])}_예정노선.zip" return Response( content=data, media_type=MIME_ZIP, headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(name)}"}, )