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>
This commit is contained in:
2026-10-27 20:37:18 +09:00
co-authored by Claude Opus 5.5
parent 80948e0199
commit 7c94294f85
8 changed files with 720 additions and 127 deletions
+28 -4
View File
@@ -4,6 +4,7 @@
= 설계속도 작업임도 20 · 측점 없음(DB 칸 그대로).
"""
import json
from typing import Any
import aiomysql
@@ -72,8 +73,11 @@ async def post_plan_project(
async def assigned_plan_routes(
session: dict[str, Any] = Depends(require_company),
) -> dict[str, Any]:
"""우리 회사에 지정된 예정노선 연도 — 확정된 것(또는 이미 연결한 것) · 연결 상태 · 업데이트."""
"""우리 회사에 지정된 예정노선 조각(옛 = 연도) — 확정된 것(또는 이미 연결한 것) · 연결 상태 ·
업데이트 · 조각 이름 · 길이(조각 행 · 옛 = 연도 행)."""
from D04_PlanConfirm import D04_PlanConfirm_Repository as plan_repo
from D04_PlanConfirm.D04_PlanConfirm_Link import update_available
from D04_PlanConfirm.D04_PlanConfirm_Router import piece_label
async with (
get_db_pool().acquire() as connection,
@@ -81,23 +85,43 @@ async def assigned_plan_routes(
):
await cursor.execute(
"""SELECT a.id AS assignee_id, a.project_id, p.name AS project_name, a.year,
a.piece_id, c.length_m AS piece_length_m,
l.id AS linked_project_id, l.name AS linked_project_name,
l.source_fingerprint, y.route_json, y.confirmed_at
l.source_fingerprint, COALESCE(c.route_json, y.route_json) AS route_json,
y.confirmed_at
FROM plan_route_assignees a
JOIN projects p ON p.id = a.project_id AND p.deleted_at IS NULL
LEFT JOIN plan_route_years y ON y.project_id = a.project_id AND y.year = a.year
LEFT JOIN plan_route_pieces c
ON c.project_id = a.project_id AND c.piece_id = a.piece_id
LEFT JOIN projects l ON l.id = a.linked_project_id AND l.deleted_at IS NULL
WHERE a.company_id = %s
AND (y.confirmed_at IS NOT NULL OR l.id IS NOT NULL)
ORDER BY p.name, a.year""",
ORDER BY p.name, a.year, a.piece_id""",
(session.get("company_id"),),
)
rows = await cursor.fetchall()
pieces: dict[tuple[str, str], dict[str, Any]] = {}
for project_id in {r["project_id"] for r in rows if r.get("piece_id")}:
for piece in await plan_repo.list_pieces(str(project_id)):
pieces[(str(project_id), piece["piece_id"])] = piece
routes = []
for row in rows:
route_json, fingerprint = row.pop("route_json"), row.pop("source_fingerprint")
piece_length = row.pop("piece_length_m", None)
confirmed = row["confirmed_at"] is not None
body = json.loads(route_json) if isinstance(route_json, str | bytes) else route_json
piece = pieces.get((str(row["project_id"]), row.get("piece_id")))
routes.append(
{**row, "update_available": update_available(route_json, fingerprint, confirmed)}
{
**row,
"piece_id": row.get("piece_id"),
"piece_label": piece
and piece_label(piece["line_name"], piece["from_m"], piece["to_m"]),
"length_m": piece_length
if piece_length is not None
else (body or {}).get("length_m"),
"update_available": update_available(route_json, fingerprint, confirmed),
}
)
return {"status": "success", "routes": routes}
+58 -39
View File
@@ -1,4 +1,5 @@
"""D04 예정노선 내보내기 — 연도별 노선(꺾은선)을 용화 예정노선 양식 shapefile 한 벌로 만든다.
"""D04 예정노선 내보내기 — 조각(구역 × 갈래)마다 · 옛 프로젝트는 연도마다 노선(꺾은선)을
용화 예정노선 양식 shapefile 한 벌로 만든다.
양식 = `resources/knowledge/original/실무문서/2026년 임도 용화/예정노선/대상지_일월.용화.2.2.*`
(분석 345-C): 2D 폴리라인 1레코드 · UTM-K(EPSG 5179) · cp949 · 칸 `대상지` C(20) ·
@@ -70,9 +71,10 @@ def station_label(chainage_m: float, interval_m: float = STATION_INTERVAL_M) ->
@dataclass
class YearRoute:
"""한 연도의 노선 — `points` 는 `crs_epsg` 좌표의 (x, y) 꺾은선(시점 → 종점).
"""한 조각(또는 옛 연도)의 노선 — `points` 는 `crs_epsg` 좌표의 (x, y) 꺾은선(시점 → 종점).
누가거리 두 값이 있으면 그대로 칸에 쓰고(D03 이은 값), 없으면 연도 순서로 이어 셈.
누가거리 두 값이 있으면 그대로 칸에 쓰고(조각 = 선마다 No.0), 없으면 연도 순서로 이어 셈.
`piece` = 조각 id(옛 연도 노선은 None) · `line` = 선 이름(파일 이름 · 전체 묶음 열쇠).
"""
year: int
@@ -80,11 +82,18 @@ class YearRoute:
crs_epsg: int
start_station_m: float | None = None
end_station_m: float | None = None
piece: str | None = None
line: str | None = None
@property
def key(self) -> str | int:
return self.piece or self.year
@dataclass
class ExportBundle:
"""`by_year[연도]` = 그 연도 shp 한 벌 + 좌표표 · `all` = 전 연도 이은 한 벌 + 좌표표."""
"""`by_year[열쇠]` = 그 조각(열쇠 = 조각 id · 옛은 연도) shp 한 벌 + 좌표표 ·
`all` = 선마다 이은 레코드 한 벌 + 좌표표."""
by_year: dict[int, dict[str, bytes]] = field(default_factory=dict)
all: dict[str, bytes] = field(default_factory=dict)
@@ -120,20 +129,22 @@ def _target_text(region: str, length_m: float) -> str:
return f"{region}.{length_m / 1000:.1f}"
def _write_shapefile(
points: list[tuple[float, float]], year: int | None, stations: tuple[float, float], region: str
) -> dict[str, bytes]:
"""5179 꺾은선 → `.shp/.shx/.dbf/.prj/.cpg` 바이트 (파일명은 호출부가 붙임: 키 = 확장자)."""
length_m = polyline_length_m(points)
Record = tuple[list[tuple[float, float]], int | None, tuple[float, float]]
def _write_shapefile(records: list[Record], region: str) -> dict[str, bytes]:
"""5179 꺾은선 레코드(점 · 연도 · 누가 두 값) → `.shp/.shx/.dbf/.prj/.cpg` 바이트
(파일명은 호출부가 붙임: 키 = 확장자)."""
lengths = [polyline_length_m(points) for points, _, _ in records]
frame = gpd.GeoDataFrame(
{
"대상지": [_clip_bytes(_target_text(region, length_m))],
"길이": [round(length_m / 1000, 3)],
"연도": [year],
"시점누가": [round(stations[0], 2)],
"종점누가": [round(stations[1], 2)],
"대상지": [_clip_bytes(_target_text(region, m)) for m in lengths],
"길이": [round(m / 1000, 3) for m in lengths],
"연도": [year for _, year, _ in records],
"시점누가": [round(stations[0], 2) for _, _, stations in records],
"종점누가": [round(stations[1], 2) for _, _, stations in records],
},
geometry=[LineString(points)],
geometry=[LineString(points) for points, _, _ in records],
crs=EXPORT_EPSG,
)
with tempfile.TemporaryDirectory() as tmp:
@@ -169,6 +180,8 @@ def _coordinate_xlsx(project_name: str, rows: list[dict]) -> bytes:
"시점누가(m)",
"종점누가(m)",
"연장(m)",
"선",
"조각",
]
)
for row in rows:
@@ -190,6 +203,8 @@ def _coordinate_xlsx(project_name: str, rows: list[dict]) -> bytes:
round(row["start_station"], 2),
round(row["end_station"], 2),
round(row["length_m"], 2),
row["line"],
row["piece"],
]
)
buffer = io.BytesIO()
@@ -208,10 +223,10 @@ def build_export(
first_station_m: float = 0.0,
with_xlsx: bool = True,
) -> ExportBundle:
"""연도 노선들 → 연도별 한 벌 + 전체 한 벌(+ 좌표표).
"""조각(옛 = 연도) 노선들 → 조각마다 한 벌 + 전체 한 벌(+ 좌표표).
누가거리 = `first_station_m` 에서 시작해 연도 순서로 이어 셈(직전 연도 종점 = 다음 연도 시점).
shp 안 측점은 파일마다 0 부터가 아니라 이 누가값을 칸으로만 보관(B03 은 칸을 무시).
누가거리 = 값이 없으면 `first_station_m` 에서 시작해 연도 순서로 이어 셈(직전 연도 종점 =
다음 연도 시점). shp 안 측점은 칸으로만 보관(B03 은 칸을 무시).
"""
ordered = sorted(years, key=lambda y: y.year)
rows: list[dict] = []
@@ -232,6 +247,9 @@ def build_export(
"length_m": length_m,
"start_station": start,
"end_station": end,
"key": item.key,
"piece": item.piece,
"line": item.line,
}
)
station = end
@@ -241,39 +259,40 @@ def build_export(
xlsx_name = f"좌표표_{safe_name(project_name)}.xlsx"
for row in rows:
base = f"대상지_{region_name}.{row['length_m'] / 1000:.1f}_{row['year']}"
files = _named(
base,
_write_shapefile(
row["points"], row["year"], (row["start_station"], row["end_station"]), region
),
)
if row["piece"]:
base += f"_{safe_name(row['line'] or '')}_{safe_name(row['piece'])}"
stations = (row["start_station"], row["end_station"])
files = _named(base, _write_shapefile([(row["points"], row["year"], stations)], region))
if with_xlsx:
files[xlsx_name] = _coordinate_xlsx(project_name, [row])
bundle.by_year[row["year"]] = files
bundle.lengths_m[row["year"]] = row["length_m"]
bundle.by_year[row["key"]] = files
bundle.lengths_m[row["key"]] = row["length_m"]
# 전체: 앞 연도 종점 = 다음 연도 시점이라 겹치는 점 하나는 뺌.
merged: list[tuple[float, float]] = []
# 전체: 선마다 한 레코드 · 앞 조각 종점 = 다음 조각 시점이라 겹치는 점 하나는 뺌.
by_line: dict[str | None, list[dict]] = {}
for row in rows:
merged.extend(
row["points"][1:] if merged and merged[-1] == row["points"][0] else row["points"]
)
total_m = polyline_length_m(merged)
by_line.setdefault(row["line"], []).append(row)
records: list[Record] = []
for parts in by_line.values():
parts.sort(key=lambda r: r["start_station"])
merged: list[tuple[float, float]] = []
for row in parts:
same = merged and math.dist(merged[-1], row["points"][0]) < 1e-6
merged.extend(row["points"][1:] if same else row["points"])
records.append((merged, None, (parts[0]["start_station"], parts[-1]["end_station"])))
total_m = sum(polyline_length_m(points) for points, _, _ in records)
bundle.lengths_m[0] = total_m
base = f"대상지_{region_name}.{total_m / 1000:.1f}_전체"
bundle.all = _named(
base,
_write_shapefile(merged, None, (rows[0]["start_station"], rows[-1]["end_station"]), region),
)
bundle.all = _named(base, _write_shapefile(records, region))
if with_xlsx:
bundle.all[xlsx_name] = _coordinate_xlsx(project_name, rows)
return bundle
def company_zip(project_name: str, year: int, files: dict[str, bytes]) -> tuple[str, bytes]:
"""업체에 보낼 `<프로젝트명>_예정노선_<연도>.zip` (이름, 바이트)."""
def company_zip(project_name: str, label: str | int, files: dict[str, bytes]) -> tuple[str, bytes]:
"""업체에 보낼 `<프로젝트명>_예정노선_<연도 | 연도_선_조각>.zip` (이름, 바이트)."""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
for name, data in files.items():
archive.writestr(name, data)
return f"{safe_name(project_name)}_예정노선_{year}.zip", buffer.getvalue()
return f"{safe_name(project_name)}_예정노선_{safe_name(str(label))}.zip", buffer.getvalue()
+66 -17
View File
@@ -4,9 +4,9 @@
new = 임도 프로젝트 새로(B02 저장소 함수) · existing = 고른 우리 회사 임도 프로젝트
- `GET /api/projects/{id}/plan-source` — 임도 프로젝트가 이어 받은 예정노선 · 업데이트 있음
연결 = 그 연도 shp 한 벌(D04 내보내기 함수)을 B03 입력 `input/shp/` 에 원래 이름으로 +
`input_files` 행 + metadata.json(B03 분석 함수) — 파일로 올린 것과 같은 길. 입력이 다 차면
B03 완료 판정(옛 결과 지우기 확인 · 단계 되돌림) 뒤 WF1. 지형은 업체가 올림.
연결 = 지정 행의 조각(옛 = 연도) shp 한 벌(D04 내보내기 함수)을 B03 입력 `input/shp/` 에
원래 이름으로 + `input_files` 행 + metadata.json(B03 분석 함수) — 파일로 올린 것과 같은 길.
입력이 다 차면 B03 완료 판정(옛 결과 지우기 확인 · 단계 되돌림) 뒤 WF1. 지형은 업체가 올림.
연결 시점 복사 · 원본이 바뀌면 알림만(내용 지문 비교 · 자동 반영 없음).
"""
@@ -50,8 +50,7 @@ from common_util.common_util_auth import require_company
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_workflow_state import is_analysis_running, merge_stage_params
from config.config_db import get_db_pool
from D04_PlanConfirm.D04_PlanConfirm_Export import build_export
from D04_PlanConfirm.D04_PlanConfirm_Router import year_routes_from_rows
from D04_PlanConfirm.D04_PlanConfirm_Export import YearRoute, build_export
router = APIRouter(prefix="/api/projects", tags=["D04 Plan Link"])
dashboard_router = APIRouter(prefix="/api/dashboard", tags=["D04 Plan Link"])
@@ -102,18 +101,53 @@ async def _fetch_one(sql: str, args: tuple) -> dict[str, Any] | None:
return await cursor.fetchone()
# 지정 행의 노선 = 조각 행(조각 지정) · 연도 행(옛 연도 지정) — 확정은 연도 행
PIECE_JOIN = (
" LEFT JOIN plan_route_years y ON y.project_id = a.project_id AND y.year = a.year"
" LEFT JOIN plan_route_pieces c ON c.project_id = a.project_id AND c.piece_id = a.piece_id"
)
PIECE_COLUMNS = (
" a.piece_id, c.epsg AS piece_epsg, c.route_json AS piece_json,"
" c.from_m, c.to_m, c.length_m AS piece_length_m,"
)
def unit_row(row: dict[str, Any]) -> dict[str, Any]:
"""조각 지정이면 `epsg` · `route_json` 을 조각 것으로 갈아 끼움(누가 = 선 위 거리 ·
길이 = 조각) — 아래 지문 · shp · 새 임도 길이가 그대로 돎. 조각 칸은 뺌."""
out = {k: v for k, v in row.items() if k not in ("piece_epsg", "piece_json", "from_m", "to_m")}
out.pop("piece_length_m", None)
if not row.get("piece_id"):
return out
body = _parsed(row.get("piece_json")) or {}
out["epsg"] = row.get("piece_epsg")
out["route_json"] = (
{
"planned": body["planned"],
"start_chainage_m": row["from_m"],
"end_chainage_m": row["to_m"],
"length_m": row["piece_length_m"],
}
if body.get("planned")
else None
)
return out
async def load_assignee(assignee_id: int) -> dict[str, Any] | None:
"""지정 행 + 예정노선 프로젝트 칸 + 그 연도 노선."""
return await _fetch_one(
"""지정 행 + 예정노선 프로젝트 칸 + 그 조각(옛 = 연도) 노선."""
row = await _fetch_one(
"SELECT a.id, a.project_id, a.year, a.company_id, a.linked_project_id,"
f"{PIECE_COLUMNS}"
" p.name, p.region, p.road_type, p.design_speed_kph, p.route_start_m,"
" y.epsg, y.route_json, y.confirmed_at"
" FROM plan_route_assignees a"
" JOIN projects p ON p.id = a.project_id AND p.deleted_at IS NULL"
" LEFT JOIN plan_route_years y ON y.project_id = a.project_id AND y.year = a.year"
f"{PIECE_JOIN}"
" WHERE a.id = %s",
(assignee_id,),
)
return unit_row(row) if row else None
async def load_road_project(project_id: str) -> dict[str, Any] | None:
@@ -152,11 +186,17 @@ async def record_link(
def year_files(assignee: dict[str, Any]) -> dict[str, bytes]:
"""그 연도 shp 한 벌(.shp .shx .dbf .prj .cpg) — 파일명 → 바이트."""
row = {**assignee, "route_json": _parsed(assignee["route_json"])}
routes = year_routes_from_rows([row])
bundle = build_export(assignee["name"], assignee["region"] or "", routes, with_xlsx=False)
return bundle.by_year[int(assignee["year"])]
"""그 조각(옛 = 연도) shp 한 벌(.shp .shx .dbf .prj .cpg) — 파일명 → 바이트."""
body = _parsed(assignee["route_json"]) or {}
route = YearRoute(
year=int(assignee["year"]),
points=[(p[0], p[1]) for p in body["planned"]],
crs_epsg=int(assignee["epsg"]),
start_station_m=body.get("start_chainage_m"),
end_station_m=body.get("end_chainage_m"),
)
bundle = build_export(assignee["name"], assignee["region"] or "", [route], with_xlsx=False)
return bundle.by_year[route.key]
async def _supersede_route_set(connection: aiomysql.Connection, project_id: str) -> None:
@@ -285,7 +325,7 @@ async def seed_design_speed(project_id: str, speed: Any) -> None:
async def link_plan_route(
assignee_id: int, payload: LinkRequest, session: dict[str, Any] = Depends(require_company)
) -> dict[str, Any] | JSONResponse:
"""지정받은 연도 노선을 임도 프로젝트 B03 입력으로 — 지정 회사만."""
"""지정받은 조각(옛 = 연도) 노선을 임도 프로젝트 B03 입력으로 — 지정 회사만."""
assignee = await load_assignee(assignee_id)
if not assignee:
raise HTTPException(status_code=404, detail="지정 업체 행을 찾을 수 없습니다.")
@@ -319,7 +359,13 @@ async def link_plan_route(
raise HTTPException(status_code=400, detail=str(exc)) from exc
fingerprint = route_fingerprint(assignee["route_json"])
await record_link(project_id, assignee, fingerprint, assignee["design_speed_kph"])
return {"status": "success", "project_id": project_id, "year": assignee["year"], **result}
return {
"status": "success",
"project_id": project_id,
"year": assignee["year"],
"piece_id": assignee.get("piece_id"),
**result,
}
@router.get("/{project_id}/plan-source", response_model=None)
@@ -327,22 +373,25 @@ async def plan_source(project_id: UUID) -> dict[str, Any]:
"""임도 프로젝트가 이어 받은 예정노선(B03 머리 띠) — 없으면 linked False."""
row = await _fetch_one(
"SELECT p.source_fingerprint, s.name AS plan_name, a.id AS assignee_id, a.year,"
" y.route_json, y.confirmed_at"
f"{PIECE_COLUMNS}"
" y.epsg, y.route_json, y.confirmed_at"
" FROM projects p"
" JOIN projects s ON s.id = p.source_project_id AND s.project_kind = 'PLAN'"
" JOIN plan_route_assignees a ON a.linked_project_id = p.id"
" LEFT JOIN plan_route_years y ON y.project_id = a.project_id AND y.year = a.year"
f"{PIECE_JOIN}"
" WHERE p.id = %s LIMIT 1",
(str(project_id),),
)
if not row:
return {"status": "success", "linked": False}
row = unit_row(row)
return {
"status": "success",
"linked": True,
"assignee_id": row["assignee_id"],
"plan_name": row["plan_name"],
"year": row["year"],
"piece_id": row.get("piece_id"),
"update_available": update_available(
row["route_json"], row["source_fingerprint"], row["confirmed_at"] is not None
),
+43 -3
View File
@@ -41,13 +41,45 @@ async def list_year_routes(project_id: str) -> list[dict[str, Any]]:
return rows
async def list_pieces(project_id: str) -> list[dict[str, Any]]:
"""조각 행(연도 · 선 차례 · 선 위 거리 차례) + `line_name`(본선 · 갈래 N = 정본 선 차례)."""
async with (
get_db_pool().acquire() as connection,
connection.cursor(aiomysql.DictCursor) as cursor,
):
await cursor.execute(
"SELECT piece_id, line_id, year, from_m, to_m, length_m, epsg, route_json"
" FROM plan_route_pieces WHERE project_id = %s",
(project_id,),
)
rows = list(await cursor.fetchall())
await cursor.execute(
"SELECT network_json FROM plan_networks WHERE project_id = %s", (project_id,)
)
network = await cursor.fetchone()
lines = []
if network:
raw = network["network_json"]
body = json.loads(raw) if isinstance(raw, str | bytes) else raw
lines = [line["id"] for line in (body or {}).get("lines", [])]
order = {line: i for i, line in enumerate(lines)}
for row in rows:
raw = row["route_json"]
row["route_json"] = json.loads(raw) if isinstance(raw, str | bytes) else raw
index = order.get(row["line_id"], len(order))
row["line_name"] = "본선" if index == 0 else f"갈래 {index}"
rows.sort(key=lambda r: (r["year"], order.get(r["line_id"], len(order)), r["from_m"]))
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"
"SELECT id, year, piece_id, company_id, company_name, email, notified_at,"
" linked_project_id"
" FROM plan_route_assignees WHERE project_id = %s ORDER BY year, id",
(project_id,),
)
@@ -89,8 +121,16 @@ async def apply_assignee_changes(
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"]),
" (project_id, year, piece_id, company_id, company_name, email)"
" VALUES (%s,%s,%s,%s,%s,%s)",
(
project_id,
row["year"],
row.get("piece_id"),
row["company_id"],
row["company_name"],
row["email"],
),
)
await connection.commit()
except Exception:
+186 -64
View File
@@ -1,10 +1,14 @@
"""D 예정노선 확정 · 업체 지정 API (요구 345 · PLAN 531-1 · 531-2).
"""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(산림청 = 전 연도 + 전체 · 공유 = 자기 연도)
- `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` 으로만 만듦.
@@ -34,6 +38,7 @@ from D04_PlanConfirm.D04_PlanConfirm_Export import (
build_export,
company_zip,
safe_name,
station_label,
)
logger = logging.getLogger(__name__)
@@ -42,6 +47,7 @@ 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
@@ -65,10 +71,11 @@ def _require_forest(session: dict[str, Any]) -> None:
def _key(row: dict[str, Any]) -> tuple:
"""같은 업체 판정 — 등록 회사는 id · 수기 업체는 메일(대소문자 무시)."""
"""같은 업체 판정 — 같은 조각(옛 = 연도)에서 등록 회사 = id · 수기 = 메일(대소문자 무시)."""
unit = (row["year"], row.get("piece_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())
return (*unit, "c", row["company_id"])
return (*unit, "e", (row.get("email") or "").strip().lower())
def plan_assignee_changes(
@@ -90,6 +97,7 @@ 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:
@@ -97,14 +105,14 @@ async def _resolve_assignee(item: AssigneeIn) -> dict[str, Any]:
status_code=400, detail=f"등록 회사를 찾을 수 없습니다: {item.company_id}"
)
return {
"year": item.year,
**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 {"year": item.year, "company_id": None, "company_name": name, "email": email}
return {**unit, "company_id": None, "company_name": name, "email": email}
async def _plan_project(project_id: UUID) -> dict[str, Any]:
@@ -116,16 +124,89 @@ async def _plan_project(project_id: UUID) -> dict[str, Any]:
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], years: list[dict[str, Any]], assignees: list[dict[str, Any]]
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 years, assignees
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_years = {a["year"] for a in mine}
return [y for y in years if y["year"] in own_years], mine
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)
@@ -141,10 +222,19 @@ async def get_plan_assignees(
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:
@@ -156,28 +246,27 @@ async def put_plan_assignees(
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"),
)
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,
)
return routes
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,
@@ -188,10 +277,11 @@ def _result(assignee: dict[str, Any], state: str) -> dict[str, Any]:
async def finalize_plan(
project_id: UUID, request: FinalizeRequest, session: dict = Depends(verify_session)
) -> dict[str, Any]:
"""연도 노선 확정 → 지정 업체마다 그 연도 zip 을 첨부해 메일 → 보낸 곳에 발송 시각."""
"""연도 노선 확정 → 지정 업체마다 그 조각 zip 을 첨부해 메일 → 보낸 곳에 발송 시각."""
_require_forest(session)
project = await _plan_project(project_id)
routes = year_routes_from_rows(await repo.list_year_routes(str(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]
@@ -201,7 +291,7 @@ async def finalize_plan(
)
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
@@ -217,12 +307,17 @@ async def finalize_plan(
if not assignee["email"]:
results.append(_result(assignee, "no_email"))
continue
zip_name, zip_bytes = company_zip(project["name"], year, bundle.by_year[year])
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[year],
length_m=bundle.lengths_m[key],
recipient=assignee["company_name"] or assignee["email"],
attachment_names=[zip_name],
)
@@ -263,25 +358,58 @@ def year_summary(row: dict[str, Any]) -> dict[str, Any]:
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 = await repo.list_year_routes(str(project_id))
years, assignees = visible_to(
session, [year_summary(row) for row in rows], await repo.list_assignees(str(project_id))
)
return {"status": "success", "name": project["name"], "years": years, "assignees": assignees}
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, years: list[int], with_all: bool) -> bytes:
"""연도마다 `<연도>/` · 전체는 `전체/` — 좌표표 이름이 연도마다 같아 폴더로 가름."""
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 year in years:
for name, data in bundle.by_year[year].items():
archive.writestr(f"{year}/{name}", data)
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)
@@ -290,30 +418,24 @@ def export_zip(bundle: Any, years: list[int], with_all: bool) -> bytes:
@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)
rows = await repo.list_year_routes(str(project_id))
routes = year_routes_from_rows(rows)
_, units = await _units(project_id)
routes = unit_routes(units)
if not routes:
raise HTTPException(status_code=409, detail="분석 [저장]이 된 연도가 없습니다.")
forest = is_forest_service(session)
if forest:
years = [r.year for r in routes]
else:
confirmed = {int(r["year"]) for r in rows if r.get("confirmed_at")}
mine = {
a["year"]
for a in await repo.list_assignees(str(project_id))
if a["company_id"] is not None and a["company_id"] == session.get("company_id")
}
years = sorted({r.year for r in routes} & confirmed & mine)
if not years:
raise HTTPException(status_code=403, detail="받을 수 있는 연도가 없습니다.")
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, years, forest)
data = await asyncio.to_thread(export_zip, bundle, picked, forest)
name = f"{safe_name(project['name'])}_예정노선.zip"
return Response(
content=data,
+4
View File
@@ -438,9 +438,13 @@ def plan_repo(monkeypatch):
{"id": 10, "year": 2028, "company_id": 6, "company_name": "다", "email": "c@da.kr"},
]
async def no_pieces(_pid): # 옛 프로젝트 — 조각 없음
return []
monkeypatch.setattr(repo, "get_plan_project", project)
monkeypatch.setattr(repo, "list_year_routes", years)
monkeypatch.setattr(repo, "list_assignees", assignees)
monkeypatch.setattr(repo, "list_pieces", no_pieces)
def _zip_names(response):
@@ -328,9 +328,13 @@ def fake_db(monkeypatch):
state["mails"].append((to_email, subject, attachments))
return True
async def no_pieces(_pid): # 옛 프로젝트 — 조각 없음
return []
monkeypatch.setattr(repo, "get_plan_project", project)
monkeypatch.setattr(repo, "list_year_routes", years)
monkeypatch.setattr(repo, "list_assignees", assignees)
monkeypatch.setattr(repo, "list_pieces", no_pieces)
monkeypatch.setattr(repo, "mark_confirmed", confirmed)
monkeypatch.setattr(repo, "mark_notified", notified)
monkeypatch.setattr(repo, "apply_assignee_changes", apply)
+331
View File
@@ -0,0 +1,331 @@
# -*- coding: utf-8 -*-
"""563-2 예정노선 아래 단계 = 조각 단위(요구 391).
견본 = 같은 연도 조각 둘(본선 p1 · 갈래 p3, 2026) + 본선 p2(2027) · 업체 둘(p1 = 회사 5 · p3 = 회사 6).
- D04 요약 = 조각 줄 · 이름 「본선 0+0~12+10」 · 지정 회사 = 자기 조각 · 자기 행만
- 내보내기 = 조각마다 폴더 · 전체 = 선마다 레코드 · 지정 회사 = 확정된 자기 조각만
- 확정 메일 = 업체마다 자기 조각 zip(남의 조각 안 샘)
- 업체 저장 = 조각 id 검사 · 연도는 조각에서 · 같은 업체 두 조각 = 두 행
- 연결 · 가져오기 = 조각 꺾은선 shp · 받은 목록 = 조각 이름 · 길이
"""
from __future__ import annotations
import asyncio
import io
import json
import sys
import tempfile
import zipfile
from pathlib import Path
from uuid import uuid4
import geopandas as gpd
import pytest
from fastapi import HTTPException
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata # noqa: E402
from D01_PlanRegister import D01_PlanRegister_Router as d01 # noqa: E402
from D04_PlanConfirm import D04_PlanConfirm_Link as link # noqa: E402
from D04_PlanConfirm import D04_PlanConfirm_Repository as repo # noqa: E402
from D04_PlanConfirm import D04_PlanConfirm_Router as d04 # noqa: E402
PLAN_ID = str(uuid4())
P1 = [[210000.0, 380000.0], [210200.0, 380150.0]] # 본선 0 ~ 250
P2 = [[210200.0, 380150.0], [210300.0, 380400.0]] # 본선 250 ~ 519.258
P3 = [[210200.0, 380150.0], [210330.0, 380150.0]] # 갈래 0 ~ 130
L2 = (100.0**2 + 250.0**2) ** 0.5
FOREST = {"role": "SYSTEM_ADMIN", "email": "root@x.kr", "company_id": 1, "user_id": 1}
FIRM5 = {"role": "USER", "email": "a@ga.kr", "company_id": 5, "user_id": 3}
FIRM6 = {"role": "USER", "email": "b@na.kr", "company_id": 6, "user_id": 4}
def _piece(pid, line, name, year, start, end, planned):
return {
"piece_id": pid,
"line_id": line,
"line_name": name,
"year": year,
"from_m": start,
"to_m": end,
"length_m": end - start,
"epsg": 5186,
"route_json": {"planned": planned, "stations": [start, end]},
}
PIECES = [
_piece("p1", "main", "본선", 2026, 0.0, 250.0, P1),
_piece("p3", "b1", "갈래 1", 2026, 0.0, 130.0, P3),
_piece("p2", "main", "본선", 2027, 250.0, 250.0 + L2, P2),
]
def _assignee(aid, piece, year, company, name, email, notified=None):
return {
"id": aid,
"year": year,
"piece_id": piece,
"company_id": company,
"company_name": name,
"email": email,
"notified_at": notified,
"linked_project_id": None,
}
@pytest.fixture
def plan(monkeypatch):
state = {
"years": [
{"year": 2026, "epsg": 5186, "route_json": {"planned": P1}, "confirmed_at": "x"},
{"year": 2027, "epsg": 5186, "route_json": {"planned": P2}, "confirmed_at": None},
],
"assignees": [
_assignee(7, "p1", 2026, 5, "가", "a@ga.kr"),
_assignee(8, "p3", 2026, 6, "나", "b@na.kr"),
],
"mails": [],
"applied": None,
}
async def project(_pid):
return {"id": PLAN_ID, "name": "시험", "region": "일월.용화", "project_kind": "PLAN"}
async def years(_pid):
return state["years"]
async def pieces(_pid):
return PIECES
async def assignees(_pid):
return state["assignees"]
async def noop(*a):
return None
async def apply(_pid, d, u, i):
state["applied"] = (d, u, i)
async def contact(cid):
return {"id": cid, "name": f"등록{cid}", "email": f"m{cid}@reg.kr"}
async def send(*, to_email, subject, body, attachments):
state["mails"].append((to_email, attachments))
return True
monkeypatch.setattr(repo, "get_plan_project", project)
monkeypatch.setattr(repo, "list_year_routes", years)
monkeypatch.setattr(repo, "list_pieces", pieces)
monkeypatch.setattr(repo, "list_assignees", assignees)
monkeypatch.setattr(repo, "mark_confirmed", noop)
monkeypatch.setattr(repo, "mark_notified", noop)
monkeypatch.setattr(repo, "apply_assignee_changes", apply)
monkeypatch.setattr(repo, "get_company_contact", contact)
monkeypatch.setattr(d04, "send_completion_mail", send)
return state
def _names(response) -> list[str]:
return sorted(zipfile.ZipFile(io.BytesIO(response.body)).namelist())
def test_summary_piece_rows_and_firm_view(plan):
out = asyncio.run(d04.get_plan_summary(PLAN_ID, FOREST))
rows = out["pieces"]
assert [(r["piece_id"], r["year"], r["label"]) for r in rows] == [
("p1", 2026, "본선 0+0~12+10"),
("p3", 2026, "갈래 1 0+0~6+10"),
("p2", 2027, "본선 12+10~25+19.3"),
]
assert rows[1]["length_m"] == 130.0 and rows[1]["start"]["x"] == P3[0][0]
assert rows[0]["confirmed_at"] == "x" and rows[2]["confirmed_at"] is None
assert [y["year"] for y in out["years"]] == [2026, 2027]
# 같은 연도 다른 업체 — 자기 조각 · 자기 행만
for session, piece, aid in ((FIRM5, "p1", 7), (FIRM6, "p3", 8)):
mine = asyncio.run(d04.get_plan_summary(PLAN_ID, session))
assert [r["piece_id"] for r in mine["pieces"]] == [piece]
assert [a["id"] for a in mine["assignees"]] == [aid]
assert [y["year"] for y in mine["years"]] == [2026]
assert "b@na.kr" not in str(asyncio.run(d04.get_plan_summary(PLAN_ID, FIRM5)))
def test_export_piece_folders_and_firm_only_own(plan):
names = _names(asyncio.run(d04.export_plan(PLAN_ID, FOREST)))
assert {n.split("/")[0] for n in names} == {
"2026_본선_p1",
"2026_갈래1_p3",
"2027_본선_p2",
"전체",
}
assert sum(n.endswith(".shp") for n in names) == 4
with zipfile.ZipFile(io.BytesIO(asyncio.run(d04.export_plan(PLAN_ID, FOREST)).body)) as z:
with tempfile.TemporaryDirectory() as tmp:
for n in z.namelist():
if n.startswith("전체/") and not n.endswith(".xlsx"):
(Path(tmp) / n.split("/")[1]).write_bytes(z.read(n))
whole = gpd.read_file(next(Path(tmp).glob("*.shp")))
# 전체 = 선마다 한 레코드 — 본선(p1 + p2 이음) · 갈래
assert len(whole) == 2
assert sorted(round(g.length, 1) for g in whole.to_crs(5186).geometry) == [
130.0,
round(250.0 + L2, 1),
]
firm = _names(asyncio.run(d04.export_plan(PLAN_ID, FIRM6)))
assert {n.split("/")[0] for n in firm} == {"2026_갈래1_p3"}
plan["years"][0]["confirmed_at"] = None # 확정 전 = 받을 것 없음
with pytest.raises(HTTPException) as err:
asyncio.run(d04.export_plan(PLAN_ID, FIRM6))
assert err.value.status_code == 403
def test_finalize_mails_own_piece_zip(plan):
out = asyncio.run(d04.finalize_plan(PLAN_ID, d04.FinalizeRequest(years=[2026]), FOREST))
assert [(n["piece_id"], n["state"]) for n in out["notifications"]] == [
("p1", "sent"),
("p3", "sent"),
]
for (to, attachments), piece in zip(plan["mails"], ("본선_p1", "갈래1_p3"), strict=True):
name, blob, _ = attachments[0]
assert name == f"시험_예정노선_2026_{piece}.zip"
shp = [n for n in zipfile.ZipFile(io.BytesIO(blob)).namelist() if n.endswith(".shp")]
assert len(shp) == 1 and shp[0].endswith(f"_2026_{piece}.shp")
def test_put_assignees_by_piece(plan):
def put(items, years=None):
request = d04.AssigneesRequest(assignees=items, years=years)
return asyncio.run(d04.put_plan_assignees(PLAN_ID, request, FOREST))
with pytest.raises(HTTPException) as err:
put([d04.AssigneeIn(year=2026, piece_id="없음", company_id=5)])
assert err.value.status_code == 400
# 같은 업체(회사 5)를 같은 연도 두 조각에 — 두 행 · 연도는 조각에서(보낸 2099 무시)
put(
[
d04.AssigneeIn(year=2026, piece_id="p1", company_id=5),
d04.AssigneeIn(year=2099, piece_id="p3", company_id=5),
],
years=[2026],
)
deleted, updates, inserts = plan["applied"]
assert deleted == [8] and [u["id"] for u in updates] == [7]
assert [(i["piece_id"], i["year"], i["company_id"]) for i in inserts] == [("p3", 2026, 5)]
def test_legacy_year_assignee_sits_on_first_piece(plan):
"""조각이 생기기 전 연도 지정(piece_id 없음) — 그 연도 첫 조각에 붙어 보임."""
plan["assignees"] = [_assignee(9, None, 2026, 5, "가", "a@ga.kr")]
mine = asyncio.run(d04.get_plan_summary(PLAN_ID, FIRM5))
assert [r["piece_id"] for r in mine["pieces"]] == ["p1"]
def test_link_uses_piece_route():
row = {
"id": 7,
"year": 2026,
"piece_id": "p3",
"piece_epsg": 5186,
"piece_json": json.dumps({"planned": P3, "stations": [0.0, 130.0]}),
"from_m": 0.0,
"to_m": 130.0,
"piece_length_m": 130.0,
"epsg": 5186,
"route_json": json.dumps({"planned": P1, "length_m": 380.0}),
"name": "2026 홍천 노선 계획",
"region": "일월.용화",
}
unit = link.unit_row(row)
assert unit["route_json"]["planned"] == P3 and unit["route_json"]["length_m"] == 130.0
assert "piece_json" not in unit and "from_m" not in unit
assert link.route_fingerprint(unit["route_json"]) != link.route_fingerprint(row["route_json"])
files = link.year_files(unit)
with tempfile.TemporaryDirectory() as tmp:
for name, data in files.items():
(Path(tmp) / name).write_bytes(data)
meta = analyze_input_metadata(next(Path(tmp).glob("*.shp")))
assert meta["point_count"] == 2 and float(meta["attributes"]["종점누가"]) == 130.0
# 옛 연도 지정 = 연도 노선 그대로
legacy = link.unit_row({**row, "piece_id": None})
assert legacy["route_json"] == row["route_json"] and legacy["epsg"] == 5186
class _Cursor:
def __init__(self, rows):
self.rows = rows
async def execute(self, sql, args=None):
self.sql = sql
async def fetchall(self):
return self.rows
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
class _Conn:
def __init__(self, rows):
self.rows = rows
def acquire(self):
return self
def cursor(self, *a):
return _Cursor(self.rows)
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
def test_assigned_list_piece_label_length(monkeypatch):
rows = [
{
"assignee_id": 8,
"project_id": PLAN_ID,
"project_name": "시험",
"year": 2026,
"piece_id": "p3",
"piece_length_m": 130.0,
"linked_project_id": None,
"linked_project_name": None,
"source_fingerprint": None,
"route_json": json.dumps({"planned": P3}),
"confirmed_at": "x",
},
{
"assignee_id": 9,
"project_id": PLAN_ID,
"project_name": "옛",
"year": 2025,
"piece_id": None,
"piece_length_m": None,
"linked_project_id": None,
"linked_project_name": None,
"source_fingerprint": None,
"route_json": json.dumps({"planned": P1, "length_m": 250.0}),
"confirmed_at": "x",
},
]
async def pieces(_pid):
return PIECES
monkeypatch.setattr(d01, "get_db_pool", lambda: _Conn(rows))
monkeypatch.setattr(repo, "list_pieces", pieces)
out = asyncio.run(d01.assigned_plan_routes(FIRM6))["routes"]
assert [(r["piece_id"], r["piece_label"], r["length_m"]) for r in out] == [
("p3", "갈래 1 0+0~6+10", 130.0),
(None, None, 250.0),
]