Files
Aislo/D04_PlanConfirm/D04_PlanConfirm_Router.py
eomsangdonandClaude Opus 5.5 7c94294f85 feat(563-2): D04 확정 · 업체 지정 · 내보내기 · 연결 = 조각 단위 — 옛 연도 행 프로젝트 그대로
- 단위 = plan_route_pieces 한 줄 · 조각 없는 연도 행 = 연도 하나 · 확정은 연도 단위 그대로
- 요약 pieces(이름 「본선 0+0~12+10」) · 업체 행 piece_id · 지정 회사 = 자기 조각만
- 내보내기 폴더 = 연도_선_조각 · 전체 = 선마다 레코드 · 좌표표 선 · 조각 칸
- 확정 메일 = 업체마다 자기 조각 zip · 연결 · 가져오기 = 조각 꺾은선
- 받은 예정노선 목록 = 조각 이름 · 길이
- 시험 531 둘 = 조각 없음 가짜 더함 · 새 563_2 지킴 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:37:18 +09:00

445 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)}"},
)