From 20081ee3629bdd45db9d63bb6fded0f7ee4e2c3f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 19:49:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(D04):=20=EC=97=85=EC=B2=B4=20=EC=A7=80?= =?UTF-8?q?=EC=A0=95=20=C2=B7=20=ED=99=95=EC=A0=95=20API=20=C2=B7=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EB=A9=94=EC=9D=BC=20=C2=B7=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=20(531-1=20=C2=B7=20531-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- D04_PlanConfirm/D04_PlanConfirm_Email.py | 50 +++ D04_PlanConfirm/D04_PlanConfirm_Export.py | 29 +- D04_PlanConfirm/D04_PlanConfirm_Repository.py | 119 +++++ D04_PlanConfirm/D04_PlanConfirm_Router.py | 214 +++++++++ main.py | 2 + .../tester/test_531_d04_export_finalize.py | 411 ++++++++++++++++++ 6 files changed, 817 insertions(+), 8 deletions(-) create mode 100644 D04_PlanConfirm/D04_PlanConfirm_Email.py create mode 100644 D04_PlanConfirm/D04_PlanConfirm_Repository.py create mode 100644 D04_PlanConfirm/D04_PlanConfirm_Router.py create mode 100644 resources/tester/test_531_d04_export_finalize.py diff --git a/D04_PlanConfirm/D04_PlanConfirm_Email.py b/D04_PlanConfirm/D04_PlanConfirm_Email.py new file mode 100644 index 000000000..09d68aeb9 --- /dev/null +++ b/D04_PlanConfirm/D04_PlanConfirm_Email.py @@ -0,0 +1,50 @@ +"""D04 예정노선 확정 메일 — 업체에 그 연도 노선 묶음(zip)을 첨부해 보낸다 (요구 349-9).""" + +from __future__ import annotations + +import html + +from common_util.common_util_email import Attachment, send_email + + +def build_completion_mail( + *, + project_name: str, + region: str | None, + year: int, + length_m: float, + recipient: str, + attachment_names: list[str], +) -> tuple[str, str]: + """(제목, HTML 본문) — 프로젝트명 · 연도 · 연장 · 첨부 목록.""" + esc = html.escape + rows = [ + ("프로젝트", project_name), + ("사업 지역", region or "-"), + ("연도", f"{year}년"), + ("연장", f"{length_m / 1000:,.3f} km ({length_m:,.1f} m)"), + ] + table = "".join( + f"{esc(k)}" + f"{esc(v)}" + for k, v in rows + ) + files = "".join(f"
  • {esc(name)}
  • " for name in attachment_names) + body = f""" + + +

    안녕하세요, {esc(recipient)} 담당자님.

    +

    예정노선이 확정되어 {year}년 노선 파일을 보내 드립니다.

    +{table}
    +

    첨부 파일

    + +

    압축을 풀면 shapefile(.shp · .shx · .dbf · .prj · .cpg · 좌표계 UTM-K)이 들어 있으며, +임도 설계 프로그램의 노선 입력에 그대로 올릴 수 있습니다.

    +""" + return f"[예정노선 확정] {project_name} {year}년", body + + +async def send_completion_mail( + *, to_email: str, subject: str, body: str, attachments: list[Attachment] +) -> bool: + return await send_email(to_email=to_email, subject=subject, html=body, attachments=attachments) diff --git a/D04_PlanConfirm/D04_PlanConfirm_Export.py b/D04_PlanConfirm/D04_PlanConfirm_Export.py index be6b6deba..8bbb9cf78 100644 --- a/D04_PlanConfirm/D04_PlanConfirm_Export.py +++ b/D04_PlanConfirm/D04_PlanConfirm_Export.py @@ -29,10 +29,14 @@ SHP_SUFFIXES = (".shp", ".shx", ".dbf", ".prj", ".cpg") MIME_ZIP = "application/zip" MIME_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" -# 용화 `.prj` 원문(ESRI WKT) — 양식 일치용으로 그대로 씀. +# 용화 `.prj` 원문(ESRI WKT) 그대로 — 양식 일치(시험이 용화 파일과 바이트 비교). _YONGHWA_PRJ = ( - Path(__file__).resolve().parents[1] - / "resources/knowledge/original/실무문서/2026년 임도 용화/예정노선/대상지_일월.용화.2.2.prj" + 'PROJCS["PCS_ITRF2000_UTM_K",GEOGCS["GCS_ITRF_2000",DATUM["D_ITRF_2000",' + 'SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],' + 'UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],' + 'PARAMETER["False_Easting",1000000.0],PARAMETER["False_Northing",2000000.0],' + 'PARAMETER["Central_Meridian",127.5],PARAMETER["Scale_Factor",0.9996],' + 'PARAMETER["Latitude_Of_Origin",38.0],UNIT["METER",1.0]]' ) _SCHEMA = { "geometry": "LineString", @@ -49,11 +53,16 @@ _BAD_FILENAME = re.compile(r'[\\/:*?"<>|\r\n\t]') @dataclass class YearRoute: - """한 연도의 노선 — `points` 는 `crs_epsg` 좌표의 (x, y) 꺾은선(시점 → 종점).""" + """한 연도의 노선 — `points` 는 `crs_epsg` 좌표의 (x, y) 꺾은선(시점 → 종점). + + 누가거리 두 값이 있으면 그대로 칸에 쓰고(D03 이은 값), 없으면 연도 순서로 이어 셈. + """ year: int points: list[tuple[float, float]] crs_epsg: int + start_station_m: float | None = None + end_station_m: float | None = None @dataclass @@ -115,7 +124,7 @@ def _write_shapefile( frame.to_file( out, driver="ESRI Shapefile", encoding="cp949", engine="fiona", schema=_SCHEMA ) - out.with_suffix(".prj").write_bytes(_YONGHWA_PRJ.read_bytes()) + out.with_suffix(".prj").write_text(_YONGHWA_PRJ, encoding="ascii") out.with_suffix(".cpg").write_text("949", encoding="ascii") return {suffix: out.with_suffix(suffix).read_bytes() for suffix in SHP_SUFFIXES} @@ -191,16 +200,20 @@ def build_export( if len(points) < 2: raise ValueError(f"{item.year}년 노선 점이 2개 미만입니다.") length_m = polyline_length_m(points) + if item.start_station_m is not None and item.end_station_m is not None: + start, end = float(item.start_station_m), float(item.end_station_m) + else: + start, end = station, station + length_m rows.append( { "year": item.year, "points": points, "length_m": length_m, - "start_station": station, - "end_station": station + length_m, + "start_station": start, + "end_station": end, } ) - station += length_m + station = end bundle = ExportBundle() region_name = safe_name(region) diff --git a/D04_PlanConfirm/D04_PlanConfirm_Repository.py b/D04_PlanConfirm/D04_PlanConfirm_Repository.py new file mode 100644 index 000000000..83912fb8b --- /dev/null +++ b/D04_PlanConfirm/D04_PlanConfirm_Repository.py @@ -0,0 +1,119 @@ +"""D04 확정 · 업체 지정 DB (Raw SQL · 표는 db_management/020_project_groups.sql).""" + +from __future__ import annotations + +import json +from typing import Any + +import aiomysql + +from config.config_db import get_db_pool + + +async def get_plan_project(project_id: str) -> dict[str, Any] | None: + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute( + "SELECT id, name, region, project_kind, route_start_m FROM projects" + " WHERE id = %s AND deleted_at IS NULL", + (project_id,), + ) + return await cursor.fetchone() + + +async def list_year_routes(project_id: str) -> list[dict[str, Any]]: + """연도 구간(오름차순) — `route_json` 은 파싱해서 돌려줌(없으면 None).""" + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute( + "SELECT year, epsg, route_json, fingerprint, confirmed_at FROM plan_route_years" + " WHERE project_id = %s ORDER BY year", + (project_id,), + ) + rows = await cursor.fetchall() + for row in rows: + raw = row["route_json"] + row["route_json"] = json.loads(raw) if isinstance(raw, str | bytes) else raw + return rows + + +async def list_assignees(project_id: str) -> list[dict[str, Any]]: + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute( + "SELECT id, year, company_id, company_name, email, notified_at, linked_project_id" + " FROM plan_route_assignees WHERE project_id = %s ORDER BY year, id", + (project_id,), + ) + return await cursor.fetchall() + + +async def get_company_contact(company_id: int) -> dict[str, Any] | None: + """등록 회사 이름 + 대표 메일(회사 마스터 사용자).""" + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute( + "SELECT c.id, c.name, u.email FROM companies c" + " LEFT JOIN users u ON u.id = c.master_user_id AND u.deleted_at IS NULL" + " WHERE c.id = %s AND c.deleted_at IS NULL", + (company_id,), + ) + return await cursor.fetchone() + + +async def apply_assignee_changes( + project_id: str, delete_ids: list[int], updates: list[dict], inserts: list[dict] +) -> None: + async with get_db_pool().acquire() as connection, connection.cursor() as cursor: + await connection.begin() + try: + for assignee_id in delete_ids: + await cursor.execute( + "DELETE FROM plan_route_assignees WHERE id = %s AND project_id = %s", + (assignee_id, project_id), + ) + for row in updates: + await cursor.execute( + "UPDATE plan_route_assignees SET company_name = %s, email = %s" + " WHERE id = %s AND project_id = %s", + (row["company_name"], row["email"], row["id"], project_id), + ) + for row in inserts: + await cursor.execute( + "INSERT INTO plan_route_assignees" + " (project_id, year, company_id, company_name, email) VALUES (%s,%s,%s,%s,%s)", + (project_id, row["year"], row["company_id"], row["company_name"], row["email"]), + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + + +async def mark_confirmed(project_id: str, years: list[int]) -> None: + if not years: + return + marks = ",".join(["%s"] * len(years)) + async with get_db_pool().acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + f"UPDATE plan_route_years SET confirmed_at = NOW() WHERE project_id = %s" + f" AND year IN ({marks})", + (project_id, *years), + ) + await connection.commit() + + +async def mark_notified(assignee_id: int) -> None: + async with get_db_pool().acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + "UPDATE plan_route_assignees SET notified_at = NOW() WHERE id = %s", (assignee_id,) + ) + await connection.commit() diff --git a/D04_PlanConfirm/D04_PlanConfirm_Router.py b/D04_PlanConfirm/D04_PlanConfirm_Router.py new file mode 100644 index 000000000..037101d64 --- /dev/null +++ b/D04_PlanConfirm/D04_PlanConfirm_Router.py @@ -0,0 +1,214 @@ +"""D 예정노선 확정 · 업체 지정 API (요구 345 · PLAN 531-1 · 531-2). + +- `GET /api/projects/{id}/plan/assignees` — 연도별 지정 업체 +- `PUT /api/projects/{id}/plan/assignees` — 연도별 지정 업체 저장(등록 회사 또는 업체명 + 메일) +- `POST /api/projects/{id}/plan/finalize` — 연도 노선 확정 → 업체별 zip → 메일 첨부 → 발송 시각 + +지정 · 확정은 산림청(시스템 관리자 또는 메일 *.go.kr)만. 지정받은 회사는 공용 접근 판정이 +GET 만 통과시킴. 계산 자리 ③ 서버 단독 — 내보내기 파일은 저장된 `route_json` 으로만 만듦. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from common_util.common_util_auth import is_forest_service, verify_session +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, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["D04 Plan Confirm"]) + + +class AssigneeIn(BaseModel): + year: int + company_id: int | None = None # 등록 회사 — 없으면 수기 업체 + company_name: str | None = None + email: str | None = None + + +class AssigneesRequest(BaseModel): + assignees: list[AssigneeIn] + + +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 · 수기 업체는 메일(대소문자 무시).""" + if row.get("company_id") is not None: + return (row["year"], "c", row["company_id"]) + return (row["year"], "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() + 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 { + "year": item.year, + "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 {"year": item.year, "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 + + +@router.get("/{project_id}/plan/assignees", response_model=None) +async def get_plan_assignees(project_id: UUID) -> dict[str, Any]: + await _plan_project(project_id) + return {"status": "success", "assignees": await repo.list_assignees(str(project_id))} + + +@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))} + unknown = sorted({a.year for a in request.assignees} - known) + if unknown: + raise HTTPException(status_code=400, detail=f"예정노선 연도가 아닙니다: {unknown}") + wanted = [await _resolve_assignee(item) for item in request.assignees] + years = {a.year for a 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 year_routes_from_rows(rows: list[dict[str, Any]]) -> list[YearRoute]: + """분석이 저장된 연도 → 내보내기 입력 (D03 `route_json["planned"]` = 프로젝트 좌표 꺾은선).""" + routes = [] + for row in rows: + body = row.get("route_json") + if not body or not body.get("planned"): + continue + routes.append( + YearRoute( + year=int(row["year"]), + points=[(p[0], p[1]) for p in body["planned"]], + crs_epsg=int(row["epsg"]), + start_station_m=body.get("start_chainage_m"), + end_station_m=body.get("end_chainage_m"), + ) + ) + return routes + + +def _result(assignee: dict[str, Any], state: str) -> dict[str, Any]: + return { + "year": assignee["year"], + "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) + routes = year_routes_from_rows(await repo.list_year_routes(str(project_id))) + 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 + zip_name, zip_bytes = company_zip(project["name"], year, bundle.by_year[year]) + subject, body = build_completion_mail( + project_name=project["name"], + region=project["region"], + year=year, + length_m=bundle.lengths_m[year], + 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} diff --git a/main.py b/main.py index b7b8c5dce..b9ea3974a 100644 --- a/main.py +++ b/main.py @@ -75,6 +75,7 @@ from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router from D01_PlanRegister.D01_PlanRegister_Router import dashboard_router as d01_dashboard_router from D01_PlanRegister.D01_PlanRegister_Router import router as d01_plan_register_router from D03_PlanAnalysis.D03_PlanAnalysis_Router import router as d03_plan_analysis_router +from D04_PlanConfirm.D04_PlanConfirm_Router import router as d04_plan_confirm_router from E01_StabRegister.E01_StabRegister_Router import router as e01_stab_register_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router @@ -478,6 +479,7 @@ app.include_router(tiles_router, dependencies=protected_with_company) app.include_router(b05_route_router, dependencies=protected_with_company) app.include_router(b05_route_lifecycle_router, dependencies=protected_with_company) app.include_router(d03_plan_analysis_router, dependencies=protected_with_company) +app.include_router(d04_plan_confirm_router, dependencies=protected_with_company) app.include_router(b05_corridor_router, dependencies=protected_with_company) app.include_router(b05_route_replan_router, dependencies=protected_with_company) app.include_router(b05_route_terrain_router, dependencies=protected_with_company) diff --git a/resources/tester/test_531_d04_export_finalize.py b/resources/tester/test_531_d04_export_finalize.py new file mode 100644 index 000000000..ed191ff82 --- /dev/null +++ b/resources/tester/test_531_d04_export_finalize.py @@ -0,0 +1,411 @@ +# -*- coding: utf-8 -*- +"""531-1 · 531-2 D04 예정노선 내보내기 · 메일 첨부 · 업체 지정 · 확정. + +- 만든 shp 를 B03 입력 분석(`analyze_input_metadata`)이 읽음 · 용화 shp 와 칸 · .prj · 인코딩 같음 +- 메일 조립(SMTP 가짜) — 첨부 이름 · 크기 · 첨부 없는 기존 호출 그대로 +- finalize / assignees 권한(산림청 아님 403) · 보낸 업체 건너뜀 · 메일 없음 · 분석 안 저장 409 +""" + +from __future__ import annotations + +import asyncio +import io +import struct +import sys +import zipfile +from email import message_from_bytes +from pathlib import Path +from uuid import uuid4 + +import pytest +from fastapi import HTTPException +from openpyxl import load_workbook +from pyproj import Transformer + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import common_util.common_util_email as email_mod # noqa: E402 +from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata # noqa: E402 +from D04_PlanConfirm import D04_PlanConfirm_Repository as repo # noqa: E402 +from D04_PlanConfirm import D04_PlanConfirm_Router as router # noqa: E402 +from D04_PlanConfirm.D04_PlanConfirm_Email import build_completion_mail # noqa: E402 +from D04_PlanConfirm.D04_PlanConfirm_Export import ( # noqa: E402 + YearRoute, + build_export, + company_zip, +) + +YONGHWA = ROOT / "resources/knowledge/original/실무문서/2026년 임도 용화/예정노선" +YONGHWA_BASE = YONGHWA / "대상지_일월.용화.2.2" # 이름에 점이 있어 with_suffix 못 씀 + +# 5186(중부원점) 좌표의 두 해 — 2026 끝 = 2027 시작 +Y2026 = [(210000.0, 380000.0), (210100.0, 380050.0), (210250.0, 380040.0)] +Y2027 = [(210250.0, 380040.0), (210400.0, 380120.0)] + + +def _yh(ext: str) -> Path: + return YONGHWA / f"대상지_일월.용화.2.2{ext}" + + +def _bundle(with_xlsx: bool = True): + return build_export( + "시험 프로젝트", + "일월.용화", + [YearRoute(2027, Y2027, 5186), YearRoute(2026, Y2026, 5186)], + first_station_m=100.0, + with_xlsx=with_xlsx, + ) + + +def _dbf_fields(blob: bytes) -> list[tuple]: + count = (struct.unpack(" Path: + for name, data in files.items(): + (tmp_path / name).write_bytes(data) + return next(tmp_path / n for n in files if n.endswith(".shp")) + + +# ── 내보내기 ───────────────────────────────────────────── + + +def test_year_shapefile_matches_yonghwa_format(tmp_path): + bundle = _bundle() + files = bundle.by_year[2026] + shp_name = next(n for n in files if n.endswith(".shp")) + stem = shp_name[: -len(".shp")] + assert stem.startswith("대상지_일월.용화.") and stem.endswith("_2026") + + # 칸: 용화 두 칸(이름 · 형 · 폭 · 소수) 그대로 + 덧칸 + fields = _dbf_fields(files[stem + ".dbf"]) + assert fields[:2] == _dbf_fields((_yh(".dbf")).read_bytes()) + assert fields[2:] == [("연도", "N", 4, 0), ("시점누가", "N", 11, 2), ("종점누가", "N", 11, 2)] + # .prj 원문 · .cpg · 도형 종류(폴리라인 3) + assert files[stem + ".prj"] == _yh(".prj").read_bytes() + assert files[stem + ".cpg"] == _yh(".cpg").read_bytes() == b"949" + assert struct.unpack("본문

    ", [("시험_예정노선_2026.zip", payload, "application/zip")] + ) + email_mod._send_sync("a@b.kr", "제목", "

    본문

    ") # 기존 호출 그대로 + with_file, plain = (message_from_bytes(m.as_bytes()) for m in _FakeSmtp.sent) + + parts = [p for p in with_file.walk() if p.get_filename()] + assert [p.get_filename() for p in parts] == ["시험_예정노선_2026.zip"] + assert parts[0].get_content_type() == "application/zip" + assert parts[0].get_payload(decode=True) == payload + assert not [p for p in plain.walk() if p.get_filename()] + assert plain.get_content_type() == "multipart/alternative" + + +def test_send_email_disabled_ok_with_attachments(monkeypatch): + monkeypatch.setattr(email_mod, "EMAIL_NOTIFICATION_ENABLED", False) + assert asyncio.run(email_mod.send_email("a@b.kr", "s", "

    h

    ")) is True + assert asyncio.run( + email_mod.send_email("a@b.kr", "s", "

    h

    ", [("a.zip", b"1", "application/zip")]) + ) + + +def test_completion_mail_body(): + subject, body = build_completion_mail( + project_name="용화 <예정>", + region="일월.용화", + year=2026, + length_m=2135.9, + recipient="가나다건설", + attachment_names=["용화_예정노선_2026.zip"], + ) + assert "용화" in subject and "2026" in subject + assert "2026년" in body and "2.136 km" in body and "용화_예정노선_2026.zip" in body + assert "<예정>" in body # 이스케이프 + + +# ── API ────────────────────────────────────────────────── + +FOREST = {"user_id": 1, "email": "a@korea.go.kr", "role": "MEMBER", "company_id": 7} +OUTSIDER = {"user_id": 2, "email": "b@company.co.kr", "role": "MASTER", "company_id": 8} + + +def _route_row(year: int, points, start: float, end: float): + return { + "year": year, + "epsg": 5186, + "route_json": { + "planned": [list(p) + [0.0] for p in points], + "start_chainage_m": start, + "end_chainage_m": end, + }, + "fingerprint": "f" * 40, + "confirmed_at": None, + } + + +@pytest.fixture +def fake_db(monkeypatch): + state = { + "years": [_route_row(2026, Y2026, 0.0, 330.0), _route_row(2027, Y2027, 330.0, 520.0)], + "assignees": [ + { + "id": 1, + "year": 2026, + "company_id": None, + "company_name": "가나다건설", + "email": "ga@x.kr", + "notified_at": None, + "linked_project_id": None, + }, + { + "id": 2, + "year": 2026, + "company_id": 9, + "company_name": "라마", + "email": "ra@x.kr", + "notified_at": "2026-10-01", + "linked_project_id": None, + }, + { + "id": 3, + "year": 2027, + "company_id": None, + "company_name": "메일없음", + "email": None, + "notified_at": None, + "linked_project_id": None, + }, + ], + "confirmed": [], + "notified": [], + "mails": [], + "applied": None, + } + + async def project(_pid): + return { + "id": "p", + "name": "시험", + "region": "일월.용화", + "project_kind": "PLAN", + "route_start_m": 0.0, + } + + async def years(_pid): + return state["years"] + + async def assignees(_pid): + return state["assignees"] + + async def confirmed(_pid, ys): + state["confirmed"] = ys + + async def notified(aid): + state["notified"].append(aid) + + async def apply(_pid, d, u, i): + state["applied"] = (d, u, i) + + async def contact(cid): + return {"id": cid, "name": "등록사", "email": "master@reg.kr"} if cid == 9 else None + + async def send(*, to_email, subject, body, attachments): + state["mails"].append((to_email, subject, attachments)) + return True + + monkeypatch.setattr(repo, "get_plan_project", project) + monkeypatch.setattr(repo, "list_year_routes", years) + monkeypatch.setattr(repo, "list_assignees", assignees) + monkeypatch.setattr(repo, "mark_confirmed", confirmed) + monkeypatch.setattr(repo, "mark_notified", notified) + monkeypatch.setattr(repo, "apply_assignee_changes", apply) + monkeypatch.setattr(repo, "get_company_contact", contact) + monkeypatch.setattr(router, "send_completion_mail", send) + return state + + +def test_finalize_requires_forest_service(fake_db): + with pytest.raises(HTTPException) as err: + asyncio.run(router.finalize_plan(uuid4(), router.FinalizeRequest(), OUTSIDER)) + assert err.value.status_code == 403 + with pytest.raises(HTTPException) as err: + asyncio.run( + router.put_plan_assignees(uuid4(), router.AssigneesRequest(assignees=[]), OUTSIDER) + ) + assert err.value.status_code == 403 + assert fake_db["confirmed"] == [] and fake_db["mails"] == [] + + +def test_finalize_sends_zip_per_assignee(fake_db): + out = asyncio.run(router.finalize_plan(uuid4(), router.FinalizeRequest(), FOREST)) + assert out["confirmed_years"] == [2026, 2027] + states = {(n["year"], n["company_name"]): n["state"] for n in out["notifications"]} + assert states == { + (2026, "가나다건설"): "sent", + (2026, "라마"): "already_sent", # 이미 보냄 → 건너뜀 + (2027, "메일없음"): "no_email", + } + assert fake_db["notified"] == [1] + ((to, subject, attachments),) = fake_db["mails"] + assert to == "ga@x.kr" and "2026" in subject + name, blob, mime = attachments[0] + assert name == "시험_예정노선_2026.zip" and mime == "application/zip" and len(blob) > 1000 + with zipfile.ZipFile(io.BytesIO(blob)) as archive: + assert any(n.endswith("_2026.shp") for n in archive.namelist()) + assert not any("_2027" in n for n in archive.namelist()) # 다른 연도는 안 샘 + + # 다시 보내기 + fake_db["mails"].clear() + again = asyncio.run(router.finalize_plan(uuid4(), router.FinalizeRequest(resend=True), FOREST)) + assert sum(n["state"] == "sent" for n in again["notifications"]) == 2 + + +def test_finalize_unsaved_year_409(fake_db): + fake_db["years"][1]["route_json"] = None + with pytest.raises(HTTPException) as err: + asyncio.run(router.finalize_plan(uuid4(), router.FinalizeRequest(years=[2027]), FOREST)) + assert err.value.status_code == 409 + assert fake_db["confirmed"] == [] + + +def test_put_assignees_validation_and_diff(fake_db): + def put(items): + return asyncio.run( + router.put_plan_assignees(uuid4(), router.AssigneesRequest(assignees=items), FOREST) + ) + + for bad in ( + router.AssigneeIn(year=2026, company_name="수기", email="메일아님"), + router.AssigneeIn(year=2026, email="x@y.kr"), + router.AssigneeIn(year=2030, company_name="없는해", email="x@y.kr"), + router.AssigneeIn(year=2026, company_id=99), + ): + with pytest.raises(HTTPException) as err: + put([bad]) + assert err.value.status_code == 400 + assert fake_db["applied"] is None + + # 2026: 가나다 유지(메일 바꿈) · 라마(등록사 9) 유지 · 새 수기 추가 / 2027 은 안 보냈으니 그대로 + put( + [ + router.AssigneeIn(year=2026, company_name="가나다건설", email="GA@x.kr"), + router.AssigneeIn(year=2026, company_id=9), + router.AssigneeIn(year=2026, company_name="새업체", email="new@x.kr"), + ] + ) + deleted, updated, inserted = fake_db["applied"] + assert deleted == [] # 2027 의 id 3 은 건드리지 않음 + assert {u["id"] for u in updated} == {1, 2} + assert [i["company_name"] for i in inserted] == ["새업체"] + assert next(u for u in updated if u["id"] == 2)["email"] == "master@reg.kr" + + put([router.AssigneeIn(year=2026, company_name="새업체", email="new@x.kr")]) + assert sorted(fake_db["applied"][0]) == [1, 2] # 빠진 업체 지움