merge(dev): dev 받기 — E01 생성 = sub1 동결 복사(532-1) 뒤 E02 초기값 · main.py 라우터 D04 · E02 둘 다 · test_528 복사 · 초기값 가짜 둘 다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 20:07:33 +09:00
co-authored by Claude Opus 5.5
30 changed files with 5849 additions and 305 deletions
+8
View File
@@ -36,6 +36,14 @@ const FLOWS: readonly GroupFlow[] = [
},
];
/** 종류별 단계 묶음(대시보드 표 단계 막대 · 528-2) — ROAD · 모르는 종류 = 없음(B 막대 그대로) */
export function groupFlowOf(
kind: string | null | undefined,
): { routes: readonly RoutePath[]; steps: string[]; icons: readonly string[] } | null {
const flow = { PLAN: FLOWS[0], STABILITY: FLOWS[1] }[kind ?? ""];
return flow ? { ...flow, steps: flow.labels.map((key) => t(key)) } : null;
}
/** 단계 틀을 세움 — mainContent · leftPanel 이 없으면 빈 단계 안내 · [?] 줄만 */
export function mountGroupStep(
root: HTMLElement,
+2
View File
@@ -39,6 +39,8 @@ export interface ProjectItem {
id: string;
company_id?: number | null;
name: string;
/** 종류(요구 345) — ROAD 임도 · PLAN 예정노선 · STABILITY 안정성평가 · EROSION 사방 */
project_kind?: string | null;
region?: string | null;
road_type?: string | null;
project_year?: number | null;
@@ -0,0 +1,70 @@
/* =============================================================================
* B01_Dashboard_UI_AssignedPlans.ts
* 대시보드 「받은 예정노선」 구역(요구서 345 2-2 · PLAN 528-2) — 우리 회사에 지정된 예정노선 연도.
* 열 = 예정노선 · 연도 · 연결 상태(연결됨 · 미연결 · 업데이트 있음 배지) · [보기] [연결]
* [보기] = 그 예정노선 첫 단계(D02 · 공유 회사 = GET 만) · [연결] = 자리만(동작은 531-3)
* ========================================================================== */
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
import { createButton, createTag } from "@ui/ui_template_elements";
import { table, text } from "@ui/ui_template_general_blocks";
import { goToWorkflowStage } from "../A00_Common/b_workflow_nav";
import { DASHBOARD_VISIBLE_ROWS, L } from "./B01_Dashboard_UI_Common";
export interface AssignedPlan {
project_id: string;
project_name: string;
year: number;
linked_project_id: string | null;
update_available: boolean;
}
/** 받은 예정노선 — 실패 · 없음 = 빈 목록(구역을 안 그림) */
export async function fetchAssignedPlans(): Promise<AssignedPlan[]> {
const response = await fetch(`${API_BASE_URL}/dashboard/plan-routes/assigned`, {
credentials: "include",
});
if (!response.ok) return [];
return ((await response.json()) as { routes?: AssignedPlan[] }).routes ?? [];
}
function linkState(plan: AssignedPlan): HTMLElement {
const cell = document.createElement("div");
cell.className = "b01-dashboard__actions";
cell.append(
createTag(
L(plan.linked_project_id ? "DE_Assigned_Linked" : "DE_Assigned_Unlinked"),
plan.linked_project_id ? "success" : "neutral",
),
);
if (plan.update_available) cell.append(createTag(L("DE_Assigned_Update"), "warning"));
return cell;
}
export function assignedPlanTable(plans: AssignedPlan[]): HTMLElement {
return table(
[
L("DE_New_Plan"),
L("DE_Assigned_Year"),
L("DE_Assigned_State"),
L("B01_Dashboard_Table_Action"),
],
plans.map((plan) => {
const actions = document.createElement("div");
actions.className = "b01-dashboard__actions";
const link = createButton({ label: L("DE_Assigned_Link"), variant: "ghost" });
link.disabled = true;
link.title = L("DE_New_Soon");
actions.append(
createButton({
label: L("DE_Assigned_View"),
variant: "ghost",
onClick: () => void goToWorkflowStage(plan.project_id, ROUTES.D02_PLAN_BASE),
}),
link,
);
return [text(plan.project_name), text(plan.year), linkState(plan), actions];
}),
DASHBOARD_VISIBLE_ROWS,
);
}
+20 -1
View File
@@ -45,6 +45,11 @@ import { openAddMemberModal, openCreateCompanyModal } from "./B01_Dashboard_UI_M
import { buildProfileForm, buildSecurityForm } from "./B01_Dashboard_UI_Profile";
import { openCompanyPicker } from "./B01_Dashboard_UI_CompanyPick";
import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject";
import {
assignedPlanTable,
fetchAssignedPlans,
type AssignedPlan,
} from "./B01_Dashboard_UI_AssignedPlans";
import { projectTable } from "./B01_Dashboard_UI_Projects";
import { buildResourcePanel } from "./B01_Dashboard_UI_Resources";
import { buildTempUploadSection } from "./B01_Dashboard_UI_TempUpload";
@@ -65,6 +70,8 @@ export interface DashboardState {
allJoinRequests: JoinRequest[];
auditLogs: AuditLog[];
resources: ResourceData | null;
/** 우리 회사에 지정된 예정노선(528-2) — 회사관리자 · 사용자만 */
assignedPlans: AssignedPlan[];
/** 시스템 관리자가 고른 회사(29-1) — 프로젝트 · 사용자 · 가입 요청 · 회사 관리가 이 회사 것 */
adminCompanyId: number | null;
}
@@ -96,6 +103,7 @@ export async function renderB01Dashboard(root: HTMLElement): Promise<void> {
allJoinRequests: [],
auditLogs: [],
resources: null,
assignedPlans: [],
adminCompanyId: null,
};
await loadRoleData(state);
@@ -134,12 +142,14 @@ async function loadRoleData(state: DashboardState): Promise<void> {
});
return;
}
const [projects, company] = await Promise.all([
const [projects, company, assigned] = await Promise.all([
fetchUserProjects().catch(() => []),
fetchUserCompany().catch(() => null),
fetchAssignedPlans().catch(() => []),
]);
state.userProjects = projects;
state.company = company;
state.assignedPlans = assigned;
if (state.user.role === "ADMIN") {
const [members, requests, companyProjects] = await Promise.all([
fetchCompanyMembers().catch(() => []),
@@ -219,6 +229,7 @@ const SECTION_ICONS: [Parameters<typeof L>[0], string][] = [
["B01_Dashboard_Resources", "📊"],
["B01_Dashboard_SystemSettings", "⚙️"],
["B01_Dashboard_Projects", "🗂️"],
["DE_Assigned_Title", "📬"],
["B01_Temp_Section", "📦"],
["B01_Dashboard_Users", "👥"],
["B01_Dashboard_Members", "👥"],
@@ -246,6 +257,12 @@ function mountSectionShortcuts(grid: HTMLElement): void {
setDrawerSteps(bar);
}
/** 받은 예정노선 구역 — 받은 것이 있을 때만 */
function assignedPlanSection(state: DashboardState): HTMLElement[] {
if (!state.assignedPlans.length) return [];
return [section(L("DE_Assigned_Title"), assignedPlanTable(state.assignedPlans), true)];
}
function buildPage(state: DashboardState): HTMLElement {
// 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양.
const page = document.createElement("div");
@@ -297,6 +314,7 @@ function buildPage(state: DashboardState): HTMLElement {
section(L("B01_Dashboard_Projects"), projectTable(state.companyProjects, state.user), true, [
createButton({ label: "+", onClick: () => void openNewProjectPicker() }),
]),
...assignedPlanSection(state),
buildTempUploadSection(),
section(L("B01_Dashboard_Members"), memberTable(state.members, state.user), true, [
createButton({ label: "+", onClick: () => openAddMemberModal() }),
@@ -310,6 +328,7 @@ function buildPage(state: DashboardState): HTMLElement {
section(L("B01_Dashboard_Projects"), projectTable(state.userProjects, state.user), true, [
createButton({ label: "+", onClick: () => void openNewProjectPicker() }),
]),
...assignedPlanSection(state),
// 임시 보관함 — 프로젝트를 만들기 전에 자료를 올려 두는 곳이라 프로젝트 목록
// 바로 아래에 둔다(2026-08-08 사용자 지시).
buildTempUploadSection(),
+19 -3
View File
@@ -3,15 +3,27 @@ import { table, text } from "@ui/ui_template_general_blocks";
import { createStepBar } from "@ui/ui_template_workflow_layout";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
import { groupFlowOf } from "../A00_Common/group_workflow";
import type { DashboardUser, ProjectItem } from "./B01_Dashboard_Api_Fetch";
import { canDeleteProject, canEditProject } from "./B01_Dashboard_UI_Helper";
import { openDeleteProjectModal, openEditProjectModal } from "./B01_Dashboard_UI_Modals";
import { DASHBOARD_VISIBLE_ROWS, formatDate, L } from "./B01_Dashboard_UI_Common";
/** 종류 열 글(528-2) — 칸이 없는 옛 응답 = 임도 */
const KIND_LABEL = {
ROAD: "DE_New_Road",
PLAN: "DE_New_Plan",
STABILITY: "DE_New_Stability",
EROSION: "DE_New_Erosion",
} as const;
const kindText = (project: ProjectItem): string =>
L(KIND_LABEL[(project.project_kind ?? "ROAD") as keyof typeof KIND_LABEL] ?? "DE_New_Road");
export function projectTable(projects: ProjectItem[], currentUser: DashboardUser): HTMLElement {
return table(
[
L("B01_Dashboard_Table_Project"),
L("DE_Kind_Column"),
L("B01_Dashboard_Table_Region"),
// 진행도(%) 열은 없앴다 (2026-09-06 사용자 지시) — 워크플로 배지가 같은 것을 보여 준다.
L("B01_Dashboard_Table_Workflow"),
@@ -43,6 +55,7 @@ export function projectTable(projects: ProjectItem[], currentUser: DashboardUser
return [
text(project.name),
text(kindText(project)),
text(project.region),
workflow(project),
text(formatDate(project.updated_at)),
@@ -53,16 +66,19 @@ export function projectTable(projects: ProjectItem[], currentUser: DashboardUser
);
}
/** 단계 막대 — 종류별 단계(ROAD = B03~B09 · PLAN = D02~D04 · STABILITY = E02~E05) · 누름 = 그 단계 화면 */
export function workflow(project: ProjectItem): HTMLElement {
const group = groupFlowOf(project.project_kind);
const routes = group?.routes ?? WORKFLOW_STEP_ROUTES;
return createStepBar(
workflowSteps(),
group?.steps ?? workflowSteps(),
project.workflow_state?.current_stage ?? project.workflow_stage,
{
stages: project.workflow_state?.stages,
currentStage: project.workflow_state?.current_stage,
routes: WORKFLOW_STEP_ROUTES,
routes,
compact: true,
onStepClick: (stepIndex) => goToWorkflowStage(project.id, WORKFLOW_STEP_ROUTES[stepIndex]),
onStepClick: (stepIndex) => goToWorkflowStage(project.id, routes[stepIndex]),
},
);
}
+13 -4
View File
@@ -68,20 +68,29 @@ async def post_plan_project(
async def assigned_plan_routes(
session: dict[str, Any] = Depends(require_company),
) -> dict[str, Any]:
"""우리 회사에 지정된 예정노선 연도 — 보기 · 연결 상태."""
"""우리 회사에 지정된 예정노선 연도 — 확정된 것(또는 이미 연결한 것) · 연결 상태 · 업데이트."""
from D04_PlanConfirm.D04_PlanConfirm_Link import update_available
async with (
get_db_pool().acquire() as connection,
connection.cursor(aiomysql.DictCursor) as cursor,
):
await cursor.execute(
"""SELECT a.project_id, p.name AS project_name, a.year, a.linked_project_id
"""SELECT a.id AS assignee_id, a.project_id, p.name AS project_name, a.year,
l.id AS linked_project_id, l.name AS linked_project_name,
l.source_fingerprint, y.route_json
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 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""",
(session.get("company_id"),),
)
rows = await cursor.fetchall()
# ponytail: update_available 자리만 — 지문 비교는 531-3.
routes = [{**r, "update_available": False} for r in rows]
routes = []
for row in rows:
route_json, fingerprint = row.pop("route_json"), row.pop("source_fingerprint")
routes.append({**row, "update_available": update_available(route_json, fingerprint)})
return {"status": "success", "routes": routes}
+7 -2
View File
@@ -60,8 +60,13 @@ def plan_criteria(road_type: str | None, design_speed_kph: int | None) -> dict[s
}
def _point(value: Any) -> tuple[float, float]:
"""엔진 출력 자리수(0.1 mm)로 — 노선 끝점 = 지점 그대로 · 연도 경계가 정확히 맞물림."""
return (round(float(value[0]), 4), round(float(value[1]), 4))
def _same_point(a: Any, b: Any) -> bool:
return abs(float(a[0]) - float(b[0])) < 1e-6 and abs(float(a[1]) - float(b[1])) < 1e-6
return abs(float(a[0]) - float(b[0])) < 1e-3 and abs(float(a[1]) - float(b[1])) < 1e-3
def read_segment(project_root: Path, seg: dict[str, Any]) -> dict[str, Any] | None:
@@ -91,7 +96,7 @@ def analyze_segment(
corridor_m: float = PLAN_CORRIDOR_M,
) -> dict[str, Any]:
"""연도 구간 하나 · 갈래 넷 — 입력 지문이 저장본과 같으면 그대로(`stored` True)."""
expected = [tuple(map(float, seg["start"])), tuple(map(float, seg["end"]))]
expected = [_point(seg["start"]), _point(seg["end"])]
inputs = {
"weights": None,
"max_grade": criteria["max_grade"],
+1 -1
View File
@@ -41,7 +41,7 @@ logger = logging.getLogger(__name__)
# 구간 표본 간격 — 도엽(≈2.8 × 2.3 km) 3×3 묶음이 끊기지 않게
SAMPLE_STEP_M = 1000.0
# 탐색 복도 반폭(D 만 · B 기본 100 m) — 실측(530-2): 노선이 복도 끝에 닿지 않는 폭
# 탐색 복도 반폭(D 만 · B 기본 100 m) — 실측(530-2): 100 · 200 m 는 못 지킨 곳 남음 · 300 m 0~1
PLAN_CORRIDOR_M = 300.0
# DEM 이 복도 밖으로 더 덮는 폭(복도 끝 칸 기울기 · 횡단 표본 ±20 m)
DEM_EXTRA_M = 50.0
+50
View File
@@ -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"<tr><td style='padding:4px 12px 4px 0;color:#6b7280'>{esc(k)}</td>"
f"<td style='padding:4px 0'><strong>{esc(v)}</strong></td></tr>"
for k, v in rows
)
files = "".join(f"<li>{esc(name)}</li>" for name in attachment_names)
body = f"""<!doctype html>
<html lang="ko"><head><meta charset="utf-8"></head>
<body style="font-family:'Segoe UI',Arial,sans-serif;color:#111827;line-height:1.6">
<p>안녕하세요, <strong>{esc(recipient)}</strong> 담당자님.</p>
<p>예정노선이 확정되어 {year}년 노선 파일을 보내 드립니다.</p>
<table>{table}</table>
<p>첨부 파일</p>
<ul>{files}</ul>
<p>압축을 풀면 shapefile(.shp · .shx · .dbf · .prj · .cpg · 좌표계 UTM-K)이 들어 있으며,
임도 설계 프로그램의 노선 입력에 그대로 올릴 수 있습니다.</p>
</body></html>"""
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)
+258
View File
@@ -0,0 +1,258 @@
"""D04 예정노선 내보내기 — 연도별 노선(꺾은선)을 용화 예정노선 양식 shapefile 한 벌로 만든다.
양식 = `resources/knowledge/original/실무문서/2026년 임도 용화/예정노선/대상지_일월.용화.2.2.*`
(분석 345-C): 2D 폴리라인 1레코드 · UTM-K(EPSG 5179) · cp949 · 칸 `대상지` C(20) ·
`길이` N(11,3) km + 덧칸 `연도` N(4) · `시점누가` · `종점누가` N(11,2) m.
`.prj` 는 용화 원문 그대로 · `.cpg` 는 `949`.
계산 자리 ③ 서버 단독 — 화면이 만지지 않는다. 순수 함수(값 → 바이트)만 두고
파일은 임시 폴더에서만 쓴다.
"""
from __future__ import annotations
import io
import math
import re
import tempfile
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
import geopandas as gpd
from openpyxl import Workbook
from pyproj import Transformer
from shapely.geometry import LineString
EXPORT_EPSG = 5179
SHP_SUFFIXES = (".shp", ".shx", ".dbf", ".prj", ".cpg")
MIME_ZIP = "application/zip"
MIME_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
# 용화 `.prj` 원문(ESRI WKT) 그대로 — 양식 일치(시험이 용화 파일과 바이트 비교).
_YONGHWA_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",
"properties": {
"대상지": "str:20",
"길이": "float:11.3",
"연도": "int:4",
"시점누가": "float:11.2",
"종점누가": "float:11.2",
},
}
_BAD_FILENAME = re.compile(r'[\\/:*?"<>|\r\n\t]')
@dataclass
class YearRoute:
"""한 연도의 노선 — `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
class ExportBundle:
"""`by_year[연도]` = 그 연도 shp 한 벌 + 좌표표 · `all` = 전 연도 이은 한 벌 + 좌표표."""
by_year: dict[int, dict[str, bytes]] = field(default_factory=dict)
all: dict[str, bytes] = field(default_factory=dict)
lengths_m: dict[int, float] = field(default_factory=dict)
def safe_name(text: str) -> str:
"""파일명에 못 쓰는 글자를 `_` 로."""
return _BAD_FILENAME.sub("_", text).strip() or "_"
def polyline_length_m(points: list[tuple[float, float]]) -> float:
return sum(math.dist(a, b) for a, b in zip(points, points[1:], strict=False))
def to_utmk(points: list[tuple[float, float]], crs_epsg: int) -> list[tuple[float, float]]:
"""프로젝트 좌표 → UTM-K(5179). 같은 좌표계면 그대로."""
pts = [(float(p[0]), float(p[1])) for p in points]
if crs_epsg == EXPORT_EPSG:
return pts
tf = Transformer.from_crs(crs_epsg, EXPORT_EPSG, always_xy=True)
return [tf.transform(x, y) for x, y in pts]
def _clip_bytes(text: str, limit: int = 20) -> str:
"""dbf 문자 칸(cp949 바이트) 한도에 글자 단위로 맞춤."""
while len(text.encode("cp949", errors="replace")) > limit:
text = text[:-1]
return text
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)
frame = gpd.GeoDataFrame(
{
"대상지": [_clip_bytes(_target_text(region, length_m))],
"길이": [round(length_m / 1000, 3)],
"연도": [year],
"시점누가": [round(stations[0], 2)],
"종점누가": [round(stations[1], 2)],
},
geometry=[LineString(points)],
crs=EXPORT_EPSG,
)
with tempfile.TemporaryDirectory() as tmp:
out = Path(tmp) / "r.shp"
frame.to_file(
out, driver="ESRI Shapefile", encoding="cp949", engine="fiona", schema=_SCHEMA
)
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}
def _coordinate_xlsx(project_name: str, rows: list[dict]) -> bytes:
"""사람이 읽는 좌표표 — 연도별 시점 · 종점(UTM-K · 위경도) · 누가 · 연장."""
to_ll = Transformer.from_crs(EXPORT_EPSG, 4326, always_xy=True)
book = Workbook()
sheet = book.active
sheet.title = "좌표표"
sheet.append([f"{project_name} 예정노선 좌표표 (UTM-K EPSG:5179)"])
sheet.append(
[
"연도",
"시점 X",
"시점 Y",
"종점 X",
"종점 Y",
"시점 경도",
"시점 위도",
"종점 경도",
"종점 위도",
"시점누가(m)",
"종점누가(m)",
"연장(m)",
]
)
for row in rows:
(sx, sy), (ex, ey) = row["points"][0], row["points"][-1]
(slon, slat), (elon, elat) = to_ll.transform(sx, sy), to_ll.transform(ex, ey)
sheet.append(
[
row["year"],
round(sx, 3),
round(sy, 3),
round(ex, 3),
round(ey, 3),
round(slon, 7),
round(slat, 7),
round(elon, 7),
round(elat, 7),
round(row["start_station"], 2),
round(row["end_station"], 2),
round(row["length_m"], 2),
]
)
buffer = io.BytesIO()
book.save(buffer)
return buffer.getvalue()
def _named(base: str, parts: dict[str, bytes]) -> dict[str, bytes]:
return {f"{base}{suffix}": data for suffix, data in parts.items()}
def build_export(
project_name: str,
region: str,
years: list[YearRoute],
first_station_m: float = 0.0,
with_xlsx: bool = True,
) -> ExportBundle:
"""연도 노선들 → 연도별 한 벌 + 전체 한 벌(+ 좌표표).
누가거리 = `first_station_m` 에서 시작해 연도 순서로 이어 셈(직전 연도 종점 = 다음 연도 시점).
shp 안 측점은 파일마다 0 부터가 아니라 이 누가값을 칸으로만 보관(B03 은 칸을 무시).
"""
ordered = sorted(years, key=lambda y: y.year)
rows: list[dict] = []
station = float(first_station_m)
for item in ordered:
points = to_utmk(item.points, item.crs_epsg)
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": start,
"end_station": end,
}
)
station = end
bundle = ExportBundle()
region_name = safe_name(region)
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 with_xlsx:
files[xlsx_name] = _coordinate_xlsx(project_name, [row])
bundle.by_year[row["year"]] = files
bundle.lengths_m[row["year"]] = row["length_m"]
# 전체: 앞 연도 종점 = 다음 연도 시점이라 겹치는 점 하나는 뺌.
merged: list[tuple[float, float]] = []
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)
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),
)
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` (이름, 바이트)."""
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()
+323
View File
@@ -0,0 +1,323 @@
"""D 예정노선 → 임도 연결 (요구 345 · 요구서 2-3 · 3-5 · PLAN 531-3).
- `POST /api/dashboard/plan-routes/{assignee_id}/link` — {mode: new | existing, project_id?}
new = 임도 프로젝트 새로(B02 저장소 함수) · existing = 고른 우리 회사 임도 프로젝트
- `GET /api/projects/{id}/plan-source` — 임도 프로젝트가 이어 받은 예정노선 · 업데이트 있음
연결 = 그 연도 shp 한 벌(D04 내보내기 함수)을 B03 입력 `input/shp/` 에 원래 이름으로 +
`input_files` 행 + metadata.json(B03 분석 함수) — 파일로 올린 것과 같은 길. 입력이 다 차면
B03 완료 판정(옛 결과 지우기 확인 · 단계 되돌림) 뒤 WF1. 지형은 업체가 올림.
연결 시점 복사 · 원본이 바뀌면 알림만(내용 지문 비교 · 자동 반영 없음).
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import shutil
import tempfile
from pathlib import Path
from typing import Any, Literal
from uuid import UUID
import aiomysql
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from B02_ProjRegister.B02_ProjRegister_Repository import create_project
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata
from B03_FileInput.B03_FileInput_Repository import (
create_input_file,
get_project_input_readiness,
get_project_storage_relative_path,
supersede_previous_input_files,
)
from B03_FileInput.B03_FileInput_Router_Helpers import (
_ANALYSIS_RUNNING_MESSAGE,
OutputsWouldBeDiscarded,
_complete_file_input_if_ready,
_confirm_replace_response,
_missing_required_file_types,
_schedule_background_task,
_write_stage_metadata,
)
from B03_FileInput.B03_FileInput_Schema import UploadedFileResult
from B03_FileInput.B03_FileInput_Service_WF1 import trigger_wf1_analysis_and_email
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
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
router = APIRouter(prefix="/api/projects", tags=["D04 Plan Link"])
dashboard_router = APIRouter(prefix="/api/dashboard", tags=["D04 Plan Link"])
ROUTE_DIR = "B03_FileInput/input/shp"
class LinkRequest(BaseModel):
mode: Literal["new", "existing"]
project_id: UUID | None = None
confirm_replace: bool = False
las_free: bool = False # B03 「LAS 없이 설계」(화면 기억값) — 입력 완료 판정에만
def _parsed(route_json: Any) -> dict[str, Any] | None:
value = json.loads(route_json) if isinstance(route_json, str | bytes) else route_json
return value if isinstance(value, dict) else None
def route_fingerprint(route_json: Any) -> str | None:
"""연도 노선 내용 지문(sha1) — 고른 중심선 점(mm 반올림)만 봄 · 같은 값이면 같은 지문."""
body = _parsed(route_json)
planned = (body or {}).get("planned")
if not planned:
return None
points = [[round(float(p[0]), 3), round(float(p[1]), 3)] for p in planned]
return hashlib.sha1(json.dumps(points).encode()).hexdigest()
def update_available(route_json: Any, source_fingerprint: str | None) -> bool:
"""연결 때 지문 ≠ 지금 지문 — 지금 노선이 비면(지점 바뀜 · 분석 전) 알리지 않음."""
now = route_fingerprint(route_json)
return bool(source_fingerprint) and now is not None and now != source_fingerprint
async def _fetch_one(sql: str, args: tuple) -> dict[str, Any] | None:
async with (
get_db_pool().acquire() as connection,
connection.cursor(aiomysql.DictCursor) as cursor,
):
await cursor.execute(sql, args)
return await cursor.fetchone()
async def load_assignee(assignee_id: int) -> dict[str, Any] | None:
"""지정 행 + 예정노선 프로젝트 칸 + 그 연도 노선."""
return await _fetch_one(
"SELECT a.id, a.project_id, a.year, a.company_id, a.linked_project_id,"
" 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"
" WHERE a.id = %s",
(assignee_id,),
)
async def load_road_project(project_id: str) -> dict[str, Any] | None:
return await _fetch_one(
"SELECT id, company_id, project_kind FROM projects WHERE id = %s AND deleted_at IS NULL",
(project_id,),
)
async def record_link(
project_id: str, assignee: dict[str, Any], fingerprint: str | None, speed: Any
) -> None:
"""원본 칸 · 연결 표시 — 한 임도 프로젝트는 한 연도에만 이어짐(옛 연결은 끊음)."""
async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
await connection.begin()
try:
await cursor.execute(
"UPDATE projects SET source_project_id = %s, source_copied_at = NOW(),"
" source_fingerprint = %s, design_speed_kph = COALESCE(design_speed_kph, %s)"
" WHERE id = %s",
(assignee["project_id"], fingerprint, speed, project_id),
)
await cursor.execute(
"UPDATE plan_route_assignees SET linked_project_id = NULL"
" WHERE linked_project_id = %s AND id <> %s",
(project_id, assignee["id"]),
)
await cursor.execute(
"UPDATE plan_route_assignees SET linked_project_id = %s WHERE id = %s",
(project_id, assignee["id"]),
)
await connection.commit()
except Exception:
await connection.rollback()
raise
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"])]
async def _supersede_route_set(connection: aiomysql.Connection, project_id: str) -> None:
"""옛 노선 세트(다른 이름 포함) 행을 내림 — 노선 칸은 새 한 벌만 봄."""
async with connection.cursor() as cursor:
await cursor.execute(
"UPDATE input_files SET status = 'SUPERSEDED' WHERE project_id = %s"
" AND raw_file_path LIKE %s AND status IN ('UPLOADED', 'PROCESSED')",
(project_id, f"%{ROUTE_DIR}/%"),
)
async def import_route_files(
project_id: str,
files: dict[str, bytes],
session: dict[str, Any],
confirm_replace: bool = False,
las_free: bool = False,
) -> dict[str, Any]:
"""shp 한 벌 → B03 입력. 지울 결과가 있고 확인이 없으면 `OutputsWouldBeDiscarded`."""
pid = UUID(project_id)
async with get_db_pool().acquire() as connection:
async with connection.cursor(aiomysql.DictCursor) as cursor:
if await is_analysis_running(cursor, project_id):
raise HTTPException(status_code=409, detail=_ANALYSIS_RUNNING_MESSAGE)
stored = await get_project_storage_relative_path(connection, pid)
project_root = Path(resolve_stored_project_path(stored))
with tempfile.TemporaryDirectory() as tmp:
for name, data in files.items():
(Path(tmp) / name).write_bytes(data)
metas = {
name: await asyncio.to_thread(analyze_input_metadata, Path(tmp) / name)
for name in files
}
await connection.begin()
try:
await _supersede_route_set(connection, project_id)
results: list[UploadedFileResult] = []
for name, data in files.items():
meta, relative = metas[name], f"{ROUTE_DIR}/{name}"
epsg = meta.get("epsg")
input_id = await create_input_file(
connection,
project_id=pid,
file_type=Path(name).suffix.lower().lstrip("."),
original_filename=name,
relative_path=relative,
file_size_bytes=len(data),
upload_by=int(session["user_id"]),
crs_epsg=int(epsg) if epsg is not None else None,
metadata=meta,
)
await supersede_previous_input_files(connection, pid, name, input_id)
results.append(
UploadedFileResult(
input_file_id=input_id,
original_filename=name,
file_type=Path(name).suffix.lower().lstrip("."),
relative_path=relative,
size_bytes=len(data),
metadata=meta,
)
)
types, _, _ = await get_project_input_readiness(connection, pid)
wf1_input = None
if not _missing_required_file_types(types, las_free):
wf1_input = await _complete_file_input_if_ready(
connection, pid, las_free, confirm_replace
)
target = project_root / ROUTE_DIR
target.mkdir(parents=True, exist_ok=True)
for name in files:
shutil.move(str(Path(tmp) / name), str(target / name))
await connection.commit()
except Exception:
await connection.rollback()
raise
_write_stage_metadata(project_root / "B03_FileInput", pid, results)
if wf1_input is not None:
_schedule_background_task(
trigger_wf1_analysis_and_email(
project_id=pid,
input_file_id=wf1_input,
user_role=str(session["role"]),
started_by=session.get("user_id"),
),
task_name=f"b04-preprocess-auto-{project_id}",
)
return {"files": [r.original_filename for r in results], "analysis_started": bool(wf1_input)}
async def _new_road_project(assignee: dict[str, Any], session: dict[str, Any]) -> str:
"""예정노선 이름 · 지역 · 임도 종류 · 그 연도로 임도 프로젝트(B02 등록과 같은 함수)."""
body = _parsed(assignee["route_json"]) or {}
created = await create_project(
user_id=int(session["user_id"]),
company_id=int(session["company_id"]),
name=f"{assignee['name']} {assignee['year']}",
region=assignee["region"] or "",
road_type=assignee["road_type"] or "work",
project_year=int(assignee["year"]),
estimated_length_m=body.get("length_m"),
memo=None,
)
return str(created["project_id"])
@dashboard_router.post("/plan-routes/{assignee_id}/link", response_model=None)
async def link_plan_route(
assignee_id: int, payload: LinkRequest, session: dict[str, Any] = Depends(require_company)
) -> dict[str, Any] | JSONResponse:
"""지정받은 연도 노선을 임도 프로젝트 B03 입력으로 — 지정 회사만."""
assignee = await load_assignee(assignee_id)
if not assignee:
raise HTTPException(status_code=404, detail="지정 업체 행을 찾을 수 없습니다.")
if assignee["company_id"] is None or assignee["company_id"] != session.get("company_id"):
raise HTTPException(status_code=403, detail="이 예정노선을 지정받은 회사가 아닙니다.")
if not assignee["confirmed_at"] or route_fingerprint(assignee["route_json"]) is None:
raise HTTPException(status_code=409, detail="확정된 노선이 없습니다.")
if payload.mode == "existing":
if payload.project_id is None:
raise HTTPException(status_code=400, detail="연결할 임도 프로젝트를 고르세요.")
project = await load_road_project(str(payload.project_id))
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
if project["company_id"] != session.get("company_id"):
raise HTTPException(status_code=403, detail="다른 회사의 프로젝트입니다.")
if project["project_kind"] != "ROAD":
raise HTTPException(status_code=400, detail="임도설계 프로젝트만 연결할 수 있습니다.")
files = await asyncio.to_thread(year_files, assignee)
project_id = (
str(payload.project_id)
if payload.mode == "existing"
else await _new_road_project(assignee, session)
)
try:
result = await import_route_files(
project_id, files, session, payload.confirm_replace, payload.las_free
)
except OutputsWouldBeDiscarded as exc:
return _confirm_replace_response(exc)
except ValueError as exc:
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}
@router.get("/{project_id}/plan-source", response_model=None)
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"
" 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"
" WHERE p.id = %s LIMIT 1",
(str(project_id),),
)
if not row:
return {"status": "success", "linked": False}
return {
"status": "success",
"linked": True,
"assignee_id": row["assignee_id"],
"plan_name": row["plan_name"],
"year": row["year"],
"update_available": update_available(row["route_json"], row["source_fingerprint"]),
}
@@ -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()
+306
View File
@@ -0,0 +1,306 @@
"""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 → 메일 첨부 → 발송 시각
- `GET /api/projects/{id}/plan/summary` — D04 화면 연도 요약(연장 · 시점 · 종점 · 누가 · 확정)
- `GET /api/projects/{id}/plan/export` — [내보내기] zip(산림청 = 전 연도 + 전체 · 공유 = 자기 연도)
지정 · 확정은 산림청(시스템 관리자 또는 메일 *.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,
)
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}
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
@router.get("/{project_id}/plan/summary", response_model=None)
async def get_plan_summary(project_id: UUID) -> dict[str, Any]:
project = await _plan_project(project_id)
rows = await repo.list_year_routes(str(project_id))
return {
"status": "success",
"name": project["name"],
"years": [year_summary(row) for row in rows],
"assignees": await repo.list_assignees(str(project_id)),
}
def export_zip(bundle: Any, years: list[int], 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)
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)
rows = await repo.list_year_routes(str(project_id))
routes = year_routes_from_rows(rows)
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="받을 수 있는 연도가 없습니다.")
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)
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)}"},
)
+40 -3
View File
@@ -1,8 +1,13 @@
"""E01 안정성평가 생성 API (요구 345 · 347 · 요구서 4-1).
대상 = 같은 회사 임도(ROAD) 중 B06 횡단 단계(stage 3) 완료. 자료 복사는 528-4 — 지금은 행만.
대상 = 같은 회사 임도(ROAD) 중 B06 횡단 단계(stage 3) 완료. 생성 때 해석에 쓰는 자료만
`E01_StabRegister/source/` 로 동결 복사(532-1 · 원본이 바뀌어도 불변)
— 실패하면 만든 행 · 폴더를 지움.
"""
import logging
from datetime import datetime
from pathlib import Path
from typing import Any
import aiomysql
@@ -10,10 +15,15 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from common_util.common_util_auth import require_company
from common_util.common_util_project_copy import copy_project
from common_util.common_util_project_delete import hard_delete_project
from common_util.common_util_project_kind import create_kind_project
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from E02_StabGround.E02_StabGround_Router import seed_initial
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/e01", tags=["E01_StabRegister"])
_SOURCE_SQL = """
@@ -85,5 +95,32 @@ async def post_stab_project(
project_year=source["project_year"],
source_project_id=source["project_id"],
)
await seed_initial(project_id) # E02 초기값(기본값 + B06 측점)
return {"project_id": project_id}
try:
manifest = await _copy_source(project_id, source["project_id"])
await seed_initial(project_id) # E02 초기값(기본값 + 복사본 B06 측점)
except Exception:
logger.exception("E01 복사 실패 — 만든 프로젝트를 지움: %s", project_id)
await hard_delete_project(project_id, int(session["user_id"]), request)
raise HTTPException(status_code=500, detail="원본 자료 복사에 실패했습니다.") from None
return {"project_id": project_id, "source_fingerprint": manifest["source_fingerprint"]}
async def _copy_source(project_id: str, source_id: str) -> dict[str, Any]:
"""원본 → E 복사본 + 지문 · 복사 시각 기록."""
async with get_db_pool().acquire() as connection:
async with connection.cursor() as cursor:
await cursor.execute("SELECT storage_path FROM projects WHERE id = %s", (project_id,))
(storage_path,) = await cursor.fetchone()
manifest = await copy_project(
connection,
source_id,
scope="stability",
new_id=project_id,
project_root=Path(resolve_stored_project_path(storage_path)),
)
async with connection.cursor() as cursor:
await cursor.execute(
"UPDATE projects SET source_fingerprint = %s, source_copied_at = %s WHERE id = %s",
(manifest["source_fingerprint"], datetime.utcnow(), project_id),
)
return manifest
+134
View File
@@ -0,0 +1,134 @@
"""E 안정성평가 복사본 읽기 — storage 를 아는 유일한 자리 (요구 345 · 532-1).
복사본 = E 프로젝트 저장소 `E01_StabRegister/source/`(`common_util_project_copy.copy_stability`
이 생성 때 한 번 만듦 · 그 뒤 불변). E 엔진 · 화면은 이 함수들로만 받음 — 원본 임도 프로젝트를
읽지 않음.
"""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
from typing import Any
import aiomysql
from common_util.common_util_project_copy import (
MANIFEST_NAME,
STABILITY_DB_NAME,
STABILITY_SOURCE_DIR,
)
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
def source_dir(project_root: Path | str) -> Path:
"""E 프로젝트 루트 → 복사본 폴더."""
return Path(project_root) / STABILITY_SOURCE_DIR
async def project_source_dir(project_id: str) -> Path:
"""E project_id → 복사본 폴더(없으면 LookupError)."""
async with (
get_db_pool().acquire() as connection,
connection.cursor(aiomysql.DictCursor) as cursor,
):
await cursor.execute(
"SELECT storage_path FROM projects"
" WHERE id = %s AND project_kind = 'STABILITY' AND deleted_at IS NULL",
(project_id,),
)
row = await cursor.fetchone()
if not row:
raise LookupError(f"안정성평가 프로젝트가 아님: {project_id}")
root = source_dir(resolve_stored_project_path(row["storage_path"]))
if not (root / MANIFEST_NAME).is_file():
raise LookupError(f"복사본이 없음: {project_id}")
return root
@lru_cache(maxsize=8)
def _read_json(path: str, mtime_ns: int) -> Any:
return json.loads(Path(path).read_text(encoding="utf-8"))
def _json(path: Path) -> Any:
return _read_json(str(path), path.stat().st_mtime_ns)
def manifest(root: Path) -> dict[str, Any]:
"""복사 목록 — 원본 id · 복사 시각 · 지문 · 파일."""
return _json(root / MANIFEST_NAME)
def db_snapshot(root: Path) -> dict[str, Any]:
"""동결 DB 행 — project · project_workflow_stages · surface_params · route ·
route_points · route_statistics · longitudinal_sections · cross_sections."""
return _json(root / STABILITY_DB_NAME)
def _cross_rows(root: Path) -> list[dict[str, Any]]:
rows = db_snapshot(root)["cross_sections"]
return sorted(rows, key=lambda row: (float(row["chainage_m"] or 0.0), row["sequence_num"] or 0))
def stations(root: Path) -> list[dict[str, Any]]:
"""측점 목록(누가 차례) — chainage_m · label · 중심 좌표 · 방위각 · 오르막 쪽 · 지반 유형."""
out = []
for row in _cross_rows(root):
data = json.loads(row["data"]) if isinstance(row["data"], str) else row["data"]
cross = _json(root / row["cross_section_file_path"])
out.append(
{
"chainage_m": float(row["chainage_m"]),
"label": cross.get("label"),
"kind": cross.get("kind"),
"center_x": cross.get("center_x"),
"center_y": cross.get("center_y"),
"center_z": cross.get("center_z"),
"azimuth_deg": cross.get("azimuth_deg"),
"uphill_side": cross.get("uphill_side"),
"ground_type": (data.get("design") or {}).get("ground_type"),
}
)
return out
def cross_section(root: Path, chainage_m: float) -> dict[str, Any]:
"""한 측점 횡단 — 지반선(samples: offset_m · x · y · z · valid) · 좌표틀 · B06 설계값
(`design`: design_line · road_edges · section_mode · 절성토 경사 · 소단 · ground_type ·
rock_boundary_offset_m(암 경계 = 지반선을 그만큼 내린 선 · 암 측점만) …)."""
for row in _cross_rows(root):
if abs(float(row["chainage_m"]) - float(chainage_m)) < 1e-6:
data = json.loads(row["data"]) if isinstance(row["data"], str) else row["data"]
cross = _json(root / row["cross_section_file_path"])
return {
"chainage_m": float(row["chainage_m"]),
"label": cross.get("label"),
"frame": cross.get("frame"),
"uphill_side": cross.get("uphill_side"),
"samples": cross.get("samples") or [],
"design": data.get("design") or {},
}
raise KeyError(f"측점 없음: {chainage_m}")
def terrain_grid_path(root: Path) -> Path:
"""복사한 지형 격자(DTM npz — 원본 설계가 쓴 그 하나)."""
found = sorted((root / "B04_PreProcess" / "models").glob("dtm_*.npz"))
if len(found) != 1:
raise FileNotFoundError(f"복사한 지형 격자가 하나가 아님: {[p.name for p in found]}")
return found[0]
def corridor_path(root: Path) -> Path | None:
"""최신 노선 3D 노선면(없으면 None)."""
found = sorted((root / "B05_Profile" / "corridor").glob("corridor_*.json"))
return found[0] if found else None
def structures_path(root: Path) -> Path | None:
"""노선 구조물 정본(벽 등 · 없으면 None)."""
path = root / "B05_Profile" / "route" / "structures.json"
return path if path.is_file() else None
+1 -2
View File
@@ -46,8 +46,7 @@ export function renderE01StabRegister(root: HTMLElement): void {
label: t("DE_Reg_Submit"),
variant: "filled",
onClick: () => {
source.setError();
if (!source.select.value) return void source.setError(t("B02_Proj_Error_Required"));
// 목록이 비면 단추가 꺼져 있음 — 고른 값은 늘 있음
void createGroupProject(
"/e01/project",
{ project_name: name.input.value.trim() || null, source_project_id: source.select.value },
+15 -4
View File
@@ -22,13 +22,22 @@ from config.config_system import (
logger = logging.getLogger(__name__)
def _send_sync(to_email: str, subject: str, html: str) -> None:
# 첨부 = (파일명, 바이트, MIME 예 "application/zip")
Attachment = tuple[str, bytes, str]
def _send_sync(
to_email: str, subject: str, html: str, attachments: list[Attachment] | None = None
) -> None:
message = EmailMessage()
message["From"] = formataddr((EMAIL_FROM_NAME, EMAIL_FROM_ADDRESS))
message["To"] = to_email
message["Subject"] = subject
message.set_content("HTML 이메일을 지원하는 메일 앱에서 확인해 주세요.")
message.add_alternative(html, subtype="html")
for name, data, mime in attachments or []:
maintype, _, subtype = mime.partition("/")
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=name)
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as smtp:
if SMTP_USE_TLS:
@@ -37,8 +46,10 @@ def _send_sync(to_email: str, subject: str, html: str) -> None:
smtp.send_message(message)
async def send_email(to_email: str, subject: str, html: str) -> bool:
"""SMTP 발송을 워커 스레드에서 실행하고 설정된 횟수만큼 재시도한다."""
async def send_email(
to_email: str, subject: str, html: str, attachments: list[Attachment] | None = None
) -> bool:
"""SMTP 발송을 워커 스레드에서 실행하고 설정된 횟수만큼 재시도한다. 첨부는 선택."""
if not EMAIL_NOTIFICATION_ENABLED:
logger.info("이메일 발송 비활성화: %s", to_email)
return True
@@ -48,7 +59,7 @@ async def send_email(to_email: str, subject: str, html: str) -> bool:
for attempt in range(1, EMAIL_RETRY_COUNT + 1):
try:
await asyncio.to_thread(_send_sync, to_email, subject, html)
await asyncio.to_thread(_send_sync, to_email, subject, html, attachments)
logger.info("이메일 발송 성공: %s", to_email)
return True
except (OSError, smtplib.SMTPException):
+470
View File
@@ -0,0 +1,470 @@
"""프로젝트 복사 — 통째 복제(full) · 안정성평가용 동결 복사(stability) (요구 345 · 528-4 · 532-1).
원본은 읽기만 — SELECT 와 파일 읽기뿐. 복사 목록은 `copy_manifest.json` 에 남김.
full DB 행(projects · project_members · input_files · processed_point_cloud ·
surface_models · terrain_layers · routes · route_points · route_statistics ·
longitudinal_sections · cross_sections · project_workflow_stages)을 새 id 로 다시 이어
한 트랜잭션에 넣고 저장소 폴더 통째(robocopy) — 복제 도구(`db_management/
tools_clone_project.py`)의 본체.
stability DB 행은 넣지 않고 E 프로젝트 저장소 `E01_StabRegister/source/` 에 동결 스냅샷
(`db.json`) + 해석에 쓰는 파일만(B05 노선 · 구조물 · B06 종횡단 · 쓰인 DTM 하나 ·
최신 노선 corridor · 관 편집분) — 원본 LAS · 전처리 점군 · B07~B09 · 초기값 안 가져옴.
둘 다 복사한 JSON 안의 옛 프로젝트 UUID 를 새 UUID 로 바꿈.
"""
from __future__ import annotations
import hashlib
import json
import subprocess
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
from uuid import uuid4
import aiomysql
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
Scope = Literal["full", "stability"]
MANIFEST_NAME = "copy_manifest.json"
#: E 프로젝트 안 복사본 자리 — E 엔진은 `E01_StabRegister_Source` 로만 읽음
STABILITY_SOURCE_DIR = "E01_StabRegister/source"
STABILITY_DB_NAME = "db.json"
#: stability 가 통째로 뜨는 폴더(프로젝트 루트 기준)
_STABILITY_TREES = (
"B05_Profile/route",
"B06_Section/longitudinal",
"B06_Section/cross_sections",
"B04_PreProcess/drainage/edits",
)
async def _rows(cursor, sql, args):
await cursor.execute(sql, args)
return await cursor.fetchall()
async def _insert(cursor, table, row: dict) -> int:
names = ", ".join(f"`{key}`" for key in row)
holders = ", ".join(["%s"] * len(row))
await cursor.execute(f"INSERT INTO `{table}` ({names}) VALUES ({holders})", tuple(row.values()))
return int(cursor.lastrowid)
def _copy_folder(source_root: Path, new_root: Path) -> None:
print(f"파일 복사 시작: {source_root.name} -> {new_root.name}")
started = time.perf_counter()
result = subprocess.run(
[
"robocopy",
str(source_root),
str(new_root),
"/E",
"/NFL",
"/NDL",
"/NJH",
"/NJS",
"/MT:8",
],
capture_output=True,
text=True,
)
if result.returncode >= 8:
raise RuntimeError(f"robocopy 실패 (코드 {result.returncode})")
print(f"파일 복사 완료: {time.perf_counter() - started:.0f}초")
def replace_project_id(root: Path, source_id: str, new_id: str) -> list[str]:
"""root 아래 JSON 안의 옛 프로젝트 UUID → 새 UUID · 바꾼 파일(상대 경로)."""
replaced = []
for path in root.rglob("*.json"):
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
if source_id not in text:
continue
path.write_text(text.replace(source_id, new_id), encoding="utf-8")
replaced.append(path.relative_to(root).as_posix())
return replaced
def _fix_files(new_root: Path, source_id: str, new_id: str, model_map: dict) -> None:
print("프로젝트 id 치환:", replace_project_id(new_root, source_id, new_id))
snapshot = new_root / "initial_snapshot" / "db.json"
if snapshot.is_file():
dump = json.loads(snapshot.read_text(encoding="utf-8"))
changed = False
for route in dump.get("routes", []):
old_model = route.get("surface_model_id")
if old_model in model_map:
route["surface_model_id"] = model_map[old_model]
changed = True
if changed:
snapshot.write_text(json.dumps(dump, ensure_ascii=False), encoding="utf-8")
print("초기값 스냅샷 지표면 id 교정:", changed)
def _write_manifest(root: Path, manifest: dict[str, Any]) -> None:
(root / MANIFEST_NAME).write_text(
json.dumps(manifest, ensure_ascii=False, indent=1, default=str), encoding="utf-8"
)
def _tree_files(root: Path) -> dict[str, int]:
return {
path.relative_to(root).as_posix(): path.stat().st_size
for path in sorted(root.rglob("*"))
if path.is_file()
}
async def _source_rows(cursor, source_id: str) -> dict[str, list]:
"""복제 대상 DB 행 — 표 이름 → 행 목록(부모 → 자식 차례)."""
one = "WHERE project_id = %s ORDER BY id"
rows = {
"projects": await _rows(cursor, "SELECT * FROM projects WHERE id = %s", (source_id,)),
"input_files": await _rows(cursor, f"SELECT * FROM input_files {one}", (source_id,)),
"processed_point_cloud": await _rows(
cursor, f"SELECT * FROM processed_point_cloud {one}", (source_id,)
),
"surface_models": await _rows(cursor, f"SELECT * FROM surface_models {one}", (source_id,)),
"project_members": await _rows(
cursor, "SELECT * FROM project_members WHERE project_id = %s", (source_id,)
),
"terrain_layers": await _rows(
cursor,
"SELECT t.* FROM terrain_layers t JOIN surface_models s"
" ON s.id = t.surface_model_id WHERE s.project_id = %s ORDER BY t.id",
(source_id,),
),
"routes": await _rows(cursor, f"SELECT * FROM routes {one}", (source_id,)),
"project_workflow_stages": await _rows(
cursor,
"SELECT * FROM project_workflow_stages WHERE project_id = %s ORDER BY stage_no",
(source_id,),
),
}
if not rows["projects"]:
raise LookupError(f"원본 프로젝트가 없음: {source_id}")
rows.update(await _route_children(cursor, [route["id"] for route in rows["routes"]]))
return rows
async def _route_children(cursor, route_ids: list[int]) -> dict[str, list]:
children: dict[str, list] = {
name: [] for name in ("route_points", "route_statistics", "longitudinal_sections")
}
children["cross_sections"] = []
if route_ids:
holders = ", ".join(["%s"] * len(route_ids))
for table in children:
children[table] = await _rows(
cursor,
f"SELECT * FROM {table} WHERE route_id IN ({holders}) ORDER BY id",
route_ids,
)
return children
async def _insert_full(
connection, rows: dict[str, list], new_id: str, new_name: str, new_storage: str, owner, members
) -> dict[str, int]:
"""DB 행을 새 id 로 다시 이어 한 트랜잭션에 넣음 → 지표면 id 짝(옛 → 새)."""
await connection.begin()
async with connection.cursor(aiomysql.DictCursor) as cursor:
project = dict(rows["projects"][0])
project["id"] = new_id
project["name"] = new_name
project["storage_path"] = new_storage
if owner is not None:
project["user_id"] = owner
await _insert(cursor, "projects", project)
member_ids = (
list(members)
if members is not None
else [row["user_id"] for row in rows["project_members"]]
)
for user in member_ids:
await _insert(cursor, "project_members", {"project_id": new_id, "user_id": user})
file_map: dict[int, int] = {}
for row in rows["input_files"]:
row = dict(row)
old = row.pop("id")
row["project_id"] = new_id
file_map[old] = await _insert(cursor, "input_files", row)
cloud_map: dict[int, int] = {}
for row in rows["processed_point_cloud"]:
row = dict(row)
old = row.pop("id")
row["project_id"] = new_id
row["input_file_id"] = file_map.get(row["input_file_id"], row["input_file_id"])
cloud_map[old] = await _insert(cursor, "processed_point_cloud", row)
model_map: dict[int, int] = {}
for row in rows["surface_models"]:
row = dict(row)
old = row.pop("id")
row["project_id"] = new_id
row["source_file_id"] = file_map.get(row["source_file_id"], row["source_file_id"])
row["processed_cloud_id"] = cloud_map.get(
row["processed_cloud_id"], row["processed_cloud_id"]
)
model_map[old] = await _insert(cursor, "surface_models", row)
for row in rows["terrain_layers"]:
row = dict(row)
row.pop("id")
row["surface_model_id"] = model_map[row["surface_model_id"]]
await _insert(cursor, "terrain_layers", row)
route_map: dict[int, int] = {}
for row in rows["routes"]:
row = dict(row)
old = row.pop("id")
row["project_id"] = new_id
row["surface_model_id"] = model_map.get(
row["surface_model_id"], row["surface_model_id"]
)
route_map[old] = await _insert(cursor, "routes", row)
for table in (
"route_points",
"route_statistics",
"longitudinal_sections",
"cross_sections",
):
for row in rows[table]:
row = dict(row)
row.pop("id")
row["route_id"] = route_map.get(row["route_id"], row["route_id"])
if "project_id" in row:
row["project_id"] = new_id
await _insert(cursor, table, row)
for row in rows["project_workflow_stages"]:
row = dict(row)
row.pop("id")
row["project_id"] = new_id
params = row.get("params")
if params:
# 단계 설정에 박힌 옛 id 를 새 id 로 바꾼다(입력 파일·지표면 모델).
data = json.loads(params)
if data.get("input_file_id") is not None:
old_file = int(data["input_file_id"])
data["input_file_id"] = str(file_map.get(old_file, old_file))
if data.get("surface_model_id") is not None:
old_model = int(data["surface_model_id"])
data["surface_model_id"] = model_map.get(old_model, old_model)
row["params"] = json.dumps(data, ensure_ascii=False)
await _insert(cursor, "project_workflow_stages", row)
await connection.commit()
print(
f"DB 복제 완료: 참여자 {len(member_ids)} · 입력파일 {len(file_map)}"
f" · 지표면 {len(model_map)} · 지형층 {len(rows['terrain_layers'])}"
f" · 노선 {len(route_map)}"
f" · 정점 {len(rows['route_points'])} · 횡단 {len(rows['cross_sections'])}"
f" · 단계 {len(rows['project_workflow_stages'])}"
)
return model_map
async def clone_full(
connection,
source_id: str,
new_name: str,
*,
owner: int | None = None,
members: list[int] | None = None,
) -> str:
"""통째 복제 → 새 project_id. 새 폴더 = 원본과 같은 회사/사용자 폴더."""
async with connection.cursor(aiomysql.DictCursor) as cursor:
rows = await _source_rows(cursor, source_id)
source = rows["projects"][0]
new_id = str(uuid4())
source_root = Path(resolve_stored_project_path(source["storage_path"]))
new_storage = "/".join(source["storage_path"].split("/")[:-1] + [new_id])
new_root = source_root.parent / new_id
# 1) 파일 먼저 - 실패하면 DB 를 건드리지 않은 채로 끝난다.
_copy_folder(source_root, new_root)
# 2) DB - 한 트랜잭션으로.
model_map = await _insert_full(connection, rows, new_id, new_name, new_storage, owner, members)
# 3) 파일 안에 박힌 옛 프로젝트 id 치환 + 초기값 스냅샷의 지표면 모델 id 교정.
_fix_files(new_root, source_id, new_id, model_map)
_write_manifest(
new_root,
{
"scope": "full",
"source_project_id": source_id,
"project_id": new_id,
"copied_at": datetime.now().isoformat(timespec="seconds"),
"tables": {name: len(items) for name, items in rows.items()},
"files": _tree_files(new_root),
},
)
print(f"\n새 프로젝트: {new_name}\n id = {new_id}\n 저장소 = {new_storage}")
return new_id
async def clone_storage_only(connection, source_id: str, new_id: str) -> None:
"""DB 는 SELECT 만 — 원본 · 새 프로젝트 행(이미 복제됨)으로 폴더 자리 · 지표면 id 짝."""
paths = {}
models = {}
async with connection.cursor(aiomysql.DictCursor) as cursor:
for project_id in (source_id, new_id):
row = await _rows(
cursor, "SELECT storage_path FROM projects WHERE id = %s", (project_id,)
)
paths[project_id] = row[0]["storage_path"]
rows = await _rows(
cursor,
"SELECT id FROM surface_models WHERE project_id = %s ORDER BY id",
(project_id,),
)
models[project_id] = [item["id"] for item in rows]
# 복제는 지표면 행을 원본 id 차례대로 넣는다 — 차례로 짝 지음.
model_map = dict(zip(models[source_id], models[new_id], strict=True))
new_root = Path(resolve_stored_project_path(paths[new_id]))
if any(path.is_file() for path in new_root.rglob("*")):
raise RuntimeError(f"새 폴더에 파일이 이미 있음: {new_root}")
_copy_folder(Path(resolve_stored_project_path(paths[source_id])), new_root)
_fix_files(new_root, source_id, new_id, model_map)
print(f"storage 만 복제 완료: {new_root}")
async def _stability_rows(connection, source_id: str) -> dict[str, Any]:
"""E 가 읽는 DB 행 — 원본 행 · 단계 · 지표면 확정값 · 최신 노선과 그 자식 행."""
async with connection.cursor(aiomysql.DictCursor) as cursor:
project = await _rows(cursor, "SELECT * FROM projects WHERE id = %s", (source_id,))
if not project:
raise LookupError(f"원본 프로젝트가 없음: {source_id}")
stages = await _rows(
cursor,
"SELECT * FROM project_workflow_stages WHERE project_id = %s ORDER BY stage_no",
(source_id,),
)
# 최신 노선 = B05 `get_latest_route` 와 같은 차례
route = await _rows(
cursor,
"SELECT * FROM routes WHERE project_id = %s ORDER BY computed_at DESC, id DESC LIMIT 1",
(source_id,),
)
models = await _rows(
cursor, "SELECT * FROM surface_models WHERE project_id = %s ORDER BY id", (source_id,)
)
children = await _route_children(cursor, [route[0]["id"]] if route else [])
return {
"project": project[0],
"project_workflow_stages": stages,
"surface_params": await get_surface_confirmation_params(connection, source_id),
"route": route[0] if route else None,
"surface_models": models,
**children,
}
def _stability_files(source_root: Path, dump: dict[str, Any]) -> list[str]:
"""복사할 파일(프로젝트 루트 기준 상대 경로)."""
files = [
path.relative_to(source_root).as_posix()
for tree in _STABILITY_TREES
for path in sorted((source_root / tree).rglob("*"))
if path.is_file()
]
params = dump["surface_params"]
models = source_root / "B04_PreProcess" / "models"
dtm = models / f"dtm_{params['source_filter']}.npz"
smooth = dtm.with_name(f"dtm_{params['source_filter']}_smooth.npz")
# 쓰인 지형 하나 = `build_surface_sampler` 가 여는 그 npz
used = smooth if params.get("smooth") and smooth.is_file() else dtm
for path in (used, models / "manifest.json"):
if path.is_file():
files.append(path.relative_to(source_root).as_posix())
if dump["route"]:
corridor = (
source_root / "B05_Profile" / "corridor" / f"corridor_{dump['route']['id']:04d}.json"
)
if corridor.is_file():
files.append(corridor.relative_to(source_root).as_posix())
return files
async def copy_stability(connection, source_id: str, new_id: str, project_root: Path) -> dict:
"""E 프로젝트 루트 아래 `E01_StabRegister/source/` 로 동결 복사 → manifest."""
started = time.perf_counter()
dump = await _stability_rows(connection, source_id)
source_root = Path(resolve_stored_project_path(dump["project"]["storage_path"]))
target = Path(project_root) / STABILITY_SOURCE_DIR
if target.exists():
raise RuntimeError(f"복사본 자리가 이미 있음: {target}")
files = _stability_files(source_root, dump)
digest = hashlib.sha1()
sizes: dict[str, int] = {}
for relative in files:
data = (source_root / relative).read_bytes()
digest.update(relative.encode("utf-8") + b"\0" + hashlib.sha1(data).digest())
out = target / relative
out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(data)
sizes[relative] = len(data)
replaced = replace_project_id(target, source_id, new_id)
db_text = json.dumps(dump, ensure_ascii=False, default=str)
digest.update(STABILITY_DB_NAME.encode("utf-8") + b"\0" + db_text.encode("utf-8"))
(target / STABILITY_DB_NAME).write_text(db_text, encoding="utf-8")
sizes[STABILITY_DB_NAME] = len(db_text.encode("utf-8"))
manifest = {
"scope": "stability",
"source_project_id": source_id,
"project_id": new_id,
"copied_at": datetime.now().isoformat(timespec="seconds"),
"source_fingerprint": digest.hexdigest(),
"route_id": dump["route"]["id"] if dump["route"] else None,
"tables": {
"cross_sections": len(dump["cross_sections"]),
"route_points": len(dump["route_points"]),
"longitudinal_sections": len(dump["longitudinal_sections"]),
},
"uuid_replaced": replaced,
"total_bytes": sum(sizes.values()),
"elapsed_s": round(time.perf_counter() - started, 2),
"files": sizes,
}
_write_manifest(target, manifest)
return manifest
async def copy_project(
connection,
source_id: str,
*,
scope: Scope,
new_name: str | None = None,
new_id: str | None = None,
project_root: Path | None = None,
owner: int | None = None,
members: list[int] | None = None,
) -> dict[str, Any]:
"""범위별 복사 — full = 새 프로젝트 행까지(new_name) · stability = 만든 E 프로젝트(new_id ·
project_root) 안 복사본만."""
if scope == "full":
if not new_name:
raise ValueError("full 복사는 new_name 필요")
new = await clone_full(connection, source_id, new_name, owner=owner, members=members)
return {"scope": scope, "project_id": new}
if scope == "stability":
if not new_id or project_root is None:
raise ValueError("stability 복사는 new_id · project_root 필요")
return await copy_stability(connection, source_id, new_id, project_root)
raise ValueError(f"모르는 복사 범위: {scope}")
+19 -277
View File
@@ -1,5 +1,7 @@
"""프로젝트 통째 복제 — **검증용 프로젝트를 창마다 하나씩 뜨는 도구** (2026-09-08).
본체 = `common_util/common_util_project_copy.py`(full 범위) · 이 파일은 CLI 껍데기.
⚠ **왜 저장소에 두나** — 네 창이 **같은 원본에서 같은 방법으로** 떠야 수치를 견줄 수 있다.
`tmp/` 는 창마다 따로라 서로 못 봄(2026-09-08 랩탑 두 창이 막힌 자리).
@@ -27,22 +29,18 @@
import argparse
import asyncio
import json
import subprocess
import sys
import time
from pathlib import Path
from uuid import uuid4
sys.path.append(str(Path(__file__).resolve().parent.parent))
import aiomysql
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_project_copy import clone_full, clone_storage_only
from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER
def _args() -> argparse.Namespace:
def _args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="프로젝트를 통째로 복제한다(원본은 읽기만).")
parser.add_argument("source_id", help="원본 프로젝트 UUID")
parser.add_argument("new_name", help="새 프로젝트 이름(--storage-only 면 새 프로젝트 UUID)")
@@ -53,104 +51,11 @@ def _args() -> argparse.Namespace:
)
parser.add_argument("--owner", type=int, help="새 주인 사용자 id(없으면 원본 주인)")
parser.add_argument("--members", help="참여자 사용자 id 쉼표 목록(없으면 원본 참여자)")
return parser.parse_args()
return parser.parse_args(argv)
ARGS = _args()
SOURCE_ID = ARGS.source_id
NEW_NAME = ARGS.new_name
async def _rows(cursor, sql, args):
await cursor.execute(sql, args)
return await cursor.fetchall()
async def _insert(cursor, table, row: dict) -> int:
names = ", ".join(f"`{key}`" for key in row)
holders = ", ".join(["%s"] * len(row))
await cursor.execute(f"INSERT INTO `{table}` ({names}) VALUES ({holders})", tuple(row.values()))
return int(cursor.lastrowid)
def _copy_folder(source_root: Path, new_root: Path) -> None:
print(f"파일 복사 시작: {source_root.name} -> {new_root.name}")
started = time.perf_counter()
result = subprocess.run(
[
"robocopy",
str(source_root),
str(new_root),
"/E",
"/NFL",
"/NDL",
"/NJH",
"/NJS",
"/MT:8",
],
capture_output=True,
text=True,
)
if result.returncode >= 8:
raise RuntimeError(f"robocopy 실패 (코드 {result.returncode})")
print(f"파일 복사 완료: {time.perf_counter() - started:.0f}초")
def _fix_files(new_root: Path, new_id: str, model_map: dict) -> None:
replaced = []
for path in new_root.rglob("*.json"):
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
if SOURCE_ID not in text:
continue
path.write_text(text.replace(SOURCE_ID, new_id), encoding="utf-8")
replaced.append(str(path.relative_to(new_root)))
print("프로젝트 id 치환:", replaced)
snapshot = new_root / "initial_snapshot" / "db.json"
if snapshot.is_file():
dump = json.loads(snapshot.read_text(encoding="utf-8"))
changed = False
for route in dump.get("routes", []):
old_model = route.get("surface_model_id")
if old_model in model_map:
route["surface_model_id"] = model_map[old_model]
changed = True
if changed:
snapshot.write_text(json.dumps(dump, ensure_ascii=False), encoding="utf-8")
print("초기값 스냅샷 지표면 id 교정:", changed)
async def storage_only(connection) -> None:
"""DB 는 SELECT 만 — 원본 · 새 프로젝트 행(이미 복제됨)으로 폴더 자리 · 지표면 id 짝."""
new_id = NEW_NAME
paths = {}
models = {}
async with connection.cursor(aiomysql.DictCursor) as cursor:
for project_id in (SOURCE_ID, new_id):
row = await _rows(
cursor, "SELECT storage_path FROM projects WHERE id = %s", (project_id,)
)
paths[project_id] = row[0]["storage_path"]
rows = await _rows(
cursor,
"SELECT id FROM surface_models WHERE project_id = %s ORDER BY id",
(project_id,),
)
models[project_id] = [item["id"] for item in rows]
# 복제는 지표면 행을 원본 id 차례대로 넣는다 — 차례로 짝 지음.
model_map = dict(zip(models[SOURCE_ID], models[new_id], strict=True))
new_root = Path(resolve_stored_project_path(paths[new_id]))
if any(path.is_file() for path in new_root.rglob("*")):
raise RuntimeError(f"새 폴더에 파일이 이미 있음: {new_root}")
_copy_folder(Path(resolve_stored_project_path(paths[SOURCE_ID])), new_root)
_fix_files(new_root, new_id, model_map)
print(f"storage 만 복제 완료: {new_root}")
async def main() -> None:
async def main(argv: list[str] | None = None) -> str | None:
args = _args(argv)
connection = await aiomysql.connect(
host=DB_HOST,
port=DB_PORT,
@@ -160,183 +65,20 @@ async def main() -> None:
charset="utf8mb4",
)
try:
if ARGS.storage_only:
await storage_only(connection)
return
async with connection.cursor(aiomysql.DictCursor) as cursor:
source = (await _rows(cursor, "SELECT * FROM projects WHERE id = %s", (SOURCE_ID,)))[0]
input_files = await _rows(
cursor, "SELECT * FROM input_files WHERE project_id = %s ORDER BY id", (SOURCE_ID,)
)
clouds = await _rows(
cursor,
"SELECT * FROM processed_point_cloud WHERE project_id = %s ORDER BY id",
(SOURCE_ID,),
)
models = await _rows(
cursor,
"SELECT * FROM surface_models WHERE project_id = %s ORDER BY id",
(SOURCE_ID,),
)
members = await _rows(
cursor, "SELECT * FROM project_members WHERE project_id = %s", (SOURCE_ID,)
)
layers = await _rows(
cursor,
"SELECT t.* FROM terrain_layers t JOIN surface_models s"
" ON s.id = t.surface_model_id WHERE s.project_id = %s ORDER BY t.id",
(SOURCE_ID,),
)
routes = await _rows(
cursor, "SELECT * FROM routes WHERE project_id = %s ORDER BY id", (SOURCE_ID,)
)
stages = await _rows(
cursor,
"SELECT * FROM project_workflow_stages WHERE project_id = %s ORDER BY stage_no",
(SOURCE_ID,),
)
route_ids = [route["id"] for route in routes]
points: list = []
statistics: list = []
longitudinal: list = []
crosses: list = []
if route_ids:
holders = ", ".join(["%s"] * len(route_ids))
points = await _rows(
cursor,
f"SELECT * FROM route_points WHERE route_id IN ({holders}) ORDER BY id",
route_ids,
)
statistics = await _rows(
cursor,
f"SELECT * FROM route_statistics WHERE route_id IN ({holders}) ORDER BY id",
route_ids,
)
longitudinal = await _rows(
cursor,
"SELECT * FROM longitudinal_sections"
f" WHERE route_id IN ({holders}) ORDER BY id",
route_ids,
)
crosses = await _rows(
cursor,
f"SELECT * FROM cross_sections WHERE route_id IN ({holders}) ORDER BY id",
route_ids,
)
new_id = str(uuid4())
source_root = Path(resolve_stored_project_path(source["storage_path"]))
new_storage = "/".join(source["storage_path"].split("/")[:-1] + [new_id])
new_root = source_root.parent / new_id
# 1) 파일 먼저 - 실패하면 DB 를 건드리지 않은 채로 끝난다.
_copy_folder(source_root, new_root)
# 2) DB - 한 트랜잭션으로.
await connection.begin()
async with connection.cursor(aiomysql.DictCursor) as cursor:
project = dict(source)
project["id"] = new_id
project["name"] = NEW_NAME
project["storage_path"] = new_storage
if ARGS.owner is not None:
project["user_id"] = ARGS.owner
await _insert(cursor, "projects", project)
member_ids = (
[int(user) for user in ARGS.members.split(",") if user.strip()]
if ARGS.members is not None
else [row["user_id"] for row in members]
)
for user in member_ids:
await _insert(cursor, "project_members", {"project_id": new_id, "user_id": user})
file_map: dict[int, int] = {}
for row in input_files:
row = dict(row)
old = row.pop("id")
row["project_id"] = new_id
file_map[old] = await _insert(cursor, "input_files", row)
cloud_map: dict[int, int] = {}
for row in clouds:
row = dict(row)
old = row.pop("id")
row["project_id"] = new_id
row["input_file_id"] = file_map.get(row["input_file_id"], row["input_file_id"])
cloud_map[old] = await _insert(cursor, "processed_point_cloud", row)
model_map: dict[int, int] = {}
for row in models:
row = dict(row)
old = row.pop("id")
row["project_id"] = new_id
row["source_file_id"] = file_map.get(row["source_file_id"], row["source_file_id"])
row["processed_cloud_id"] = cloud_map.get(
row["processed_cloud_id"], row["processed_cloud_id"]
)
model_map[old] = await _insert(cursor, "surface_models", row)
for row in layers:
row = dict(row)
row.pop("id")
row["surface_model_id"] = model_map[row["surface_model_id"]]
await _insert(cursor, "terrain_layers", row)
route_map: dict[int, int] = {}
for row in routes:
row = dict(row)
old = row.pop("id")
row["project_id"] = new_id
row["surface_model_id"] = model_map.get(
row["surface_model_id"], row["surface_model_id"]
)
route_map[old] = await _insert(cursor, "routes", row)
for table, rows in (
("route_points", points),
("route_statistics", statistics),
("longitudinal_sections", longitudinal),
("cross_sections", crosses),
):
for row in rows:
row = dict(row)
row.pop("id")
row["route_id"] = route_map.get(row["route_id"], row["route_id"])
if "project_id" in row:
row["project_id"] = new_id
await _insert(cursor, table, row)
for row in stages:
row = dict(row)
row.pop("id")
row["project_id"] = new_id
params = row.get("params")
if params:
# 단계 설정에 박힌 옛 id 를 새 id 로 바꾼다(입력 파일·지표면 모델).
data = json.loads(params)
if data.get("input_file_id") is not None:
old_file = int(data["input_file_id"])
data["input_file_id"] = str(file_map.get(old_file, old_file))
if data.get("surface_model_id") is not None:
old_model = int(data["surface_model_id"])
data["surface_model_id"] = model_map.get(old_model, old_model)
row["params"] = json.dumps(data, ensure_ascii=False)
await _insert(cursor, "project_workflow_stages", row)
await connection.commit()
print(
f"DB 복제 완료: 참여자 {len(member_ids)} · 입력파일 {len(file_map)}"
f" · 지표면 {len(model_map)} · 지형층 {len(layers)}"
f" · 노선 {len(route_map)}"
f" · 정점 {len(points)} · 횡단 {len(crosses)} · 단계 {len(stages)}"
if args.storage_only:
await clone_storage_only(connection, args.source_id, args.new_name)
return None
members = (
[int(user) for user in args.members.split(",") if user.strip()]
if args.members is not None
else None
)
return await clone_full(
connection, args.source_id, args.new_name, owner=args.owner, members=members
)
# 3) 파일 안에 박힌 옛 프로젝트 id 치환 + 초기값 스냅샷의 지표면 모델 id 교정.
_fix_files(new_root, new_id, model_map)
print(f"\n새 프로젝트: {NEW_NAME}\n id = {new_id}\n 저장소 = {new_storage}")
finally:
connection.close()
asyncio.run(main())
if __name__ == "__main__":
asyncio.run(main())
+6
View File
@@ -76,6 +76,9 @@ from D01_PlanRegister.D01_PlanRegister_Router import dashboard_router as d01_das
from D01_PlanRegister.D01_PlanRegister_Router import router as d01_plan_register_router
from D02_PlanBase.D02_PlanBase_Router import router as d02_plan_base_router
from D03_PlanAnalysis.D03_PlanAnalysis_Router import router as d03_plan_analysis_router
from D04_PlanConfirm.D04_PlanConfirm_Link import dashboard_router as d04_link_dashboard_router
from D04_PlanConfirm.D04_PlanConfirm_Link import router as d04_plan_link_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 E02_StabGround.E02_StabGround_Router import router as e02_stab_ground_router
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
@@ -459,6 +462,7 @@ protected = [Depends(verify_session)]
# 신규 그룹 등록(요구 345) — 권한은 길 안에서(산림청 · 회사).
app.include_router(d01_plan_register_router, dependencies=protected)
app.include_router(d01_dashboard_router, dependencies=protected)
app.include_router(d04_link_dashboard_router, dependencies=protected)
app.include_router(e01_stab_register_router, dependencies=protected)
# 프로젝트 경로가 붙는 라우터는 그 프로젝트가 내 회사 것인지도 함께 본다
# (2026-09-01 — 지금까지 id만 알면 남의 회사 자료에 닿았다).
@@ -481,6 +485,8 @@ app.include_router(b05_route_router, dependencies=protected_with_company)
app.include_router(b05_route_lifecycle_router, dependencies=protected_with_company)
app.include_router(d02_plan_base_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(d04_plan_link_router, dependencies=protected_with_company)
app.include_router(e02_stab_ground_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)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
# E05 보고서 양식 자리표 — `stab_report.json`
빌더(534-3 뒤쪽)가 읽는 표. 시트 id · 칸 주소 · 서식 번호는 양식 그대로 쓰고 바꾸지 않음.
## 공통
- 몫: 사용자 몫(표지 · 개요) = 처음 만들 때만 채움 · 다시 만들 때(보고서 갱신) 안 건드림. 프로그램 몫(입력조건 · 측점표 · 대표단면 · 3D요약) = 갱신 때 통째 다시 씀.
- 서식 번호(`서식` 표): 1 title · 2 sub · 3 section · 4 label · 5 text · 6 head · 7 body(가운데 글) · 8 fs(0.00) · 9 int(0) · 10 ng(N.G 빨강 채움) · 11 ok(O.K 초록) · 12 notice · 13 num1(#,##0.0) · 14 num0(#,##0) · 15 para(위 맞춤 긴 글) · 16 prop(0.0) · 17 chain(#,##0.00).
- 판정 글(`O.K` · `N.G`) 칸 = 엔진 판정을 값으로 씀 · N.G 칸은 서식 10 · O.K 칸은 서식 11 을 직접 붙임 — 인쇄 HTML · xlsx 는 `조건부서식` 을 안 먹이므로(화면만). 양식의 `조건부서식`(글포함 `N.G` → 빨강)은 사용자가 화면에서 고친 판정 글용.
- 수식이 보는 끝 행 = 20000(측점표 · 3D요약). 측점이 이보다 많으면 개요 수식의 `20000` 을 같이 늘림.
- 빈 값은 칸을 안 적거나 `값` 을 안 둠(빈 글 `""` 금지).
- 그림 열쇠 = `fig/<측점 키>.png`(측점 키 = `common_util_station_key`) · 3D 띠 = `fig/3d_band.png`. 양식의 `fig/{측점키}.png` 는 자리 표시일 뿐 — 빌더가 목록을 새로 씀.
## s1 표지 (사용자 몫)
| 칸 | 내용 |
| --- | --- |
| B4:E4 | 제목(고정 글 · 서식 1) |
| C8:E8 · C9:E9 · C10:E10 · C11:E11 · C12:E12 | 사업명 · 노선명 · 발주처 · 작성일 · 작성자 값(병합 · 서식 5) — 왼위 칸 `C8` … `C12` 에 값 |
| B14:E14 | 「문헌 추정치 · 현장 시험 없음」(고정 글 · 건드리지 않음) |
## s2 개요 (사용자 몫 · 수식은 양식에 들어 있음)
- 빌더가 쓸 것: 없음(수식이 `측점표` · `입력조건` 을 가리킴). 의견 칸 `A22:F30`(병합 · 서식 15) = 사용자 글.
- 수식 칸: `B5` 대상 측점 수 · `B6` 연장 · `B10:B12` 조건별 최소 Fs · `C10:C12` = `입력조건!B12:B14` · `D10:D12` 판정 · `E10`·`E11`·`E12` 최소 측점 · `B15` N.G 수 · `B16` 종합 판정 · `B17`(병합 B17:F19) 판정 글.
- 지진을 안 쓰면 `측점표!F` 열을 비움 → 개요 지진 행은 `-`.
## s3 입력조건 (프로그램 몫)
| 구역 | 칸 | 내용 |
| --- | --- | --- |
| 해석 방법 | `B5` `B6` `B7` `B8` (각 B:G 병합) | 해석 기법 · 활동면 탐색 · 3D 해석 · 적용 기준 글 |
| 기준 안전율 | `B12` `B13` `B14` | 건기 · 우기 · 지진 기준 값(수 · 서식 8) · 지진 끔 = 글 `-` |
| | `C12` `C13` `C14` (C:G 병합) | 비고 글 |
| 지하수 규칙 | `B17` `B18` `B19` (B:G 병합) | 건기 · 우기 · 지진 시 규칙 글 |
| 노면 하중 | `B22` | 수(kPa · 서식 16) |
| 지반 물성치 | **26 행부터 한 줄씩** · 머리 25 행(머리 줄 반복) | A 지층 · B 적용 구간(전체 또는 구간 글) · C γt · D γsat · E c · F φ(서식 16) · G 출처(서식 5) · A·B = 서식 7 |
- 지반 물성치가 표의 맨 끝이라 줄 수가 늘어도 다른 칸이 안 밀림. 구간별 덮어쓰기 줄 = 같은 표에 줄 추가(B 열에 구간).
## s4 측점표 (프로그램 몫 · 머리 줄 4~5 행 쪽마다 반복 · 가로 · 폭 1쪽)
- 자료는 **6 행부터 측점 하나 = 한 줄** · 서식은 6 행 서식을 줄마다 그대로.
| 열 | 내용 | 서식 |
| --- | --- | --- |
| A | 측점 이름 | 7 |
| B | 누가거리(m · 수) | 17 |
| C | 구분(`깎기` · `쌓기`) | 7 |
| D · E · F | 건기 · 우기 · 지진 안전율 Fs(지진 끔 = 칸 없음) | 8 |
| G · H · I | 건기 · 우기 · 지진 기준 안전율 | 8 |
| J | 판정 `O.K` · `N.G`(엔진 판정 값) | N.G = 10 · O.K = 11 |
- 개요 · 3D 수식이 `A6:A20000` · `B` · `D` · `E` · `F` · `J` 열을 읽음 → 이 열 배치 바꾸지 않음.
- 줄 수 = 측점 수(시험: 측점 수 = 줄 수). 쪽 나눔은 자동(`인쇄.쪽나눔` 안 씀).
## s5 대표단면 (프로그램 몫 · 단면 하나 = 28 행 덩이 = 한 쪽)
덩이 `k`(0 부터)의 시작 오프셋 `dr = 28 × k` — 양식의 1~28 행 덩이를 `dr` 만큼 아래로 복사해 단면 수만큼 만듦(칸 · 병합 · 행 높이 · `조건부서식` 범위 포함).
| 덩이 안 행 | 칸 | 내용 |
| --- | --- | --- |
| 1 | `A1:H1` 병합 | 단면 제목(`대표 단면 ① — 측점 · 구분 · 높이`) |
| 2 | `B2:H2` 병합 | 선정 사유 글 |
| 3~22 | 그림 `A3:H22` | 그림 한 장 — 그림 칸 크기 약 712 × 533 px(가로 : 세로 = 4 : 3) · 열쇠 `fig/<측점 키>.png` · 닻은 `dr` 만큼 내림 |
| 23 | 머리(고정 글) | 조건 · 해석 방법 · 안전율 · 기준 · 판정 · 활동면 반경 R · 중심 x · 중심 y |
| 24 · 25 · 26 | A = 건기 · 우기 · 지진(고정 글) | B 해석 방법 글(서식 7) · C Fs(8) · D 기준(8) · E 판정(7→ N.G 10 · O.K 11) · F 반경 · G 중심 x · H 중심 y(13) |
| 27 | `B27:H27` 병합 | 비고 글 |
| 28 | 빈 줄 | 쪽 끝 |
- `인쇄.쪽나눔` = `[28, 56, …, 28 × 단면 수]`(마지막 덩이 끝 쪽 나눔은 있어도 빈 쪽이 안 생김).
- 지진을 안 쓰면 26 행(덩이 안) 칸을 비움.
## s6 3D요약 (프로그램 몫 · 머리 줄 19 행 반복)
| 칸 | 내용 |
| --- | --- |
| 그림 `A5:H16` | 평면 위험 띠 — 그림 칸 약 684 × 320 px(2.1 : 1) · 열쇠 `fig/3d_band.png` |
| 20 행부터 구간 하나 = 한 줄 | A 구간 · B 시작 측점 · C 끝 측점(서식 7) · D 3D 최소 Fs · E 2D 최소 Fs(서식 8) · **F 3D/2D 비 = 수식 `IF(E{r}>0,D{r}/E{r},"")`**(서식 8) · G 임계 체적 m³(서식 14) · H 판정(서식 7 → N.G 10 · O.K 11) |
- 20 행 서식을 줄마다 복사.
+9 -2
View File
@@ -139,10 +139,16 @@ def _e01(monkeypatch, rows):
monkeypatch.setattr(e01, "_sources", fake_sources)
async def fake_copy(project_id, source_id):
seen["copied"] = (project_id, source_id)
return {"source_fingerprint": "f" * 40}
monkeypatch.setattr(e01, "create_kind_project", fake_create)
monkeypatch.setattr(e01, "_copy_source", fake_copy)
async def fake_seed(project_id):
seen["seeded"] = project_id
monkeypatch.setattr(e01, "create_kind_project", fake_create)
monkeypatch.setattr(e01, "seed_initial", fake_seed)
payload = e01.CreateStabRequest(source_project_id="s" * 36)
return seen, lambda: asyncio.run(e01.post_stab_project(None, payload, USER))
@@ -159,7 +165,8 @@ def test_e01_requires_b06(monkeypatch):
call()
assert e.value.status_code == 404
seen, call = _e01(monkeypatch, [{**src, "project_year": 2026, "section_complete": 1}])
assert call() == {"project_id": "stab-id"}
assert call() == {"project_id": "stab-id", "source_fingerprint": "f" * 40}
assert seen["copied"] == ("stab-id", "s" * 36)
assert seen["kind"] == "STABILITY" and seen["source_project_id"] == "s" * 36
assert seen["name"] == "임도 안정성평가"
assert seen["seeded"] == "stab-id" # E02 초기값(532-3)
+5 -5
View File
@@ -12,8 +12,8 @@ from D03_PlanAnalysis import D03_PlanAnalysis_Analyze as analyze
CRITERIA = {"max_grade": 0.14, "min_radius_m": 12.0, "design_speed": 20}
SEGMENTS = [
{"year": 2026, "start": (60.0, 200.0), "end": (560.0, 230.0)},
{"year": 2027, "start": (560.0, 230.0), "end": (1040.0, 160.0)},
{"year": 2026, "start": (60.0, 200.0), "end": (560.123456789, 230.987654321)},
{"year": 2027, "start": (560.123456789, 230.987654321), "end": (1040.0, 160.0)},
]
@@ -54,8 +54,8 @@ def test_boundary_chainage_and_line(run) -> None:
for route in result["routes"].values():
assert "error" not in route
# 시종점 그대로 — 연도 경계가 정확히 맞물림
assert route["planned"][0] == list(seg["start"])
assert route["planned"][-1] == list(seg["end"])
assert route["planned"][0] == result["start"] == pytest.approx(seg["start"], abs=1e-4)
assert route["planned"][-1] == result["end"] == pytest.approx(seg["end"], abs=1e-4)
assert results[0]["routes"]["gradeline"]["planned"][-1] == results[1]["start"]
rows = analyze.chain(results, {2026: "earthwork", 2027: None}, 1234.5)
@@ -70,7 +70,7 @@ def test_boundary_chainage_and_line(run) -> None:
first = results[0]["routes"]["earthwork"]["planned"]
second = results[1]["routes"]["gradeline"]["planned"]
assert len(line) == len(first) + len(second) - 1 # 경계점 한 번만(꺾인 채)
assert line[len(first) - 1] == list(SEGMENTS[0]["end"])
assert line[len(first) - 1] == results[0]["end"] == results[1]["start"]
def test_same_input_reads_store(run, monkeypatch) -> None:
+389
View File
@@ -0,0 +1,389 @@
# -*- coding: utf-8 -*-
"""531-3 예정노선 → 임도 연결 · 업데이트 있음 · [내보내기] 받기.
- 연결 → B03 입력 `input/shp/` 파일 5 개 · input_files 행 5 · B03 분석 통과 · 원본 칸 · 연결 표시
- 입력이 다 찼고 지울 결과가 있으면 확인 409 · 파일 · 행 안 남김
- 지정 안 받은 회사 403 · 확정 안 된 연도 409 · 남의 회사 임도 403
- 지문 — 노선 바뀌면 update_available True · 같은 값으로 되돌리면 False
- 받은 예정노선 목록 · plan-source 가 지문으로 판정
- [내보내기] — 산림청 = 전 연도 + 전체 · 지정 회사 = 확정된 자기 연도 · 없으면 403
"""
from __future__ import annotations
import asyncio
import io
import json
import sys
import zipfile
from pathlib import Path
from uuid import uuid4
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 B03_FileInput.B03_FileInput_Router_Helpers import OutputsWouldBeDiscarded # noqa: E402
from common_util.common_util_route_geometry import find_planned_route_file # 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
from D04_PlanConfirm.D04_PlanConfirm_Export import SHP_SUFFIXES # noqa: E402
PLAN_ID = str(uuid4())
ROAD_ID = str(uuid4())
# 5186 좌표 두 해 — 2026 끝 = 2027 시작
P2026 = [[210000.0, 380000.0], [210100.0, 380050.0], [210250.0, 380040.0]]
P2027 = [[210250.0, 380040.0], [210400.0, 380120.0]]
def _route(points, start=0.0):
length = sum(
((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2) ** 0.5
for a, b in zip(points, points[1:], strict=False)
)
return {
"mode": "balanced",
"planned": points,
"length_m": length,
"start_chainage_m": start,
"end_chainage_m": start + length,
}
def _assignee(**kw):
row = {
"id": 7,
"project_id": PLAN_ID,
"year": 2026,
"company_id": 5,
"linked_project_id": None,
"name": "시험 예정노선",
"region": "일월.용화",
"road_type": "work",
"design_speed_kph": 20,
"route_start_m": 0.0,
"epsg": 5186,
"route_json": json.dumps(_route(P2026)),
"confirmed_at": "2026-10-25 12:00:00",
}
row.update(kw)
return row
SESSION = {"user_id": 3, "company_id": 5, "role": "USER", "email": "a@corp.com"}
class FakeCursor:
def __init__(self, log, rows=None):
self.log, self.rows = log, rows or []
async def execute(self, sql, args=None):
self.log.append((" ".join(sql.split()), args))
async def fetchall(self):
return self.rows
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
class FakeConn:
def __init__(self, log, rows=None):
self.log, self.rows = log, rows
def acquire(self):
return self
def cursor(self, *a):
return FakeCursor(self.log, self.rows)
async def begin(self):
self.log.append(("begin", None))
async def commit(self):
self.log.append(("commit", None))
async def rollback(self):
self.log.append(("rollback", None))
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
@pytest.fixture
def env(monkeypatch, tmp_path):
"""DB 가짜 — input_files 행 · 입력 유형 · 완료 판정 · 연결 기록을 쌓음."""
state = {"rows": [], "types": set(), "complete": None, "links": [], "log": []}
conn = FakeConn(state["log"])
monkeypatch.setattr(link, "get_db_pool", lambda: conn)
async def false(*a, **k):
return False
async def storage(*a, **k):
return "1/3/x"
async def create_input_file(connection, **kw):
state["rows"].append(kw)
return len(state["rows"])
async def noop(*a, **k):
return 0
async def readiness(*a, **k):
types = set(state["types"])
for row in state["rows"]:
ft = row["file_type"]
types.add("route_prj" if ft == "prj" and "/input/shp/" in row["relative_path"] else ft)
return types, None, None
async def complete(connection, pid, las_free, confirm):
state["complete"] = confirm
if not confirm:
raise OutputsWouldBeDiscarded(["노선 · 종단 설계"])
return 99
async def record(project_id, assignee, fingerprint, speed):
state["links"].append((project_id, assignee["id"], fingerprint, speed))
monkeypatch.setattr(link, "is_analysis_running", false)
monkeypatch.setattr(link, "get_project_storage_relative_path", storage)
monkeypatch.setattr(link, "resolve_stored_project_path", lambda s: str(tmp_path))
monkeypatch.setattr(link, "create_input_file", create_input_file)
monkeypatch.setattr(link, "supersede_previous_input_files", noop)
monkeypatch.setattr(link, "get_project_input_readiness", readiness)
monkeypatch.setattr(link, "_complete_file_input_if_ready", complete)
monkeypatch.setattr(link, "record_link", record)
monkeypatch.setattr(link, "_schedule_background_task", lambda coro, **k: coro.close())
state["root"] = tmp_path
return state
def _use(monkeypatch, assignee, road=None):
async def load(_id):
return assignee
async def road_project(_pid):
return road
monkeypatch.setattr(link, "load_assignee", load)
monkeypatch.setattr(link, "load_road_project", road_project)
def _link(mode="existing", **kw):
payload = link.LinkRequest(mode=mode, project_id=kw.pop("project_id", ROAD_ID), **kw)
return asyncio.run(link.link_plan_route(7, payload, SESSION))
ROAD = {"id": ROAD_ID, "company_id": 5, "project_kind": "ROAD"}
def test_link_writes_route_set_like_upload(env, monkeypatch):
_use(monkeypatch, _assignee(), ROAD)
result = _link()
shp_dir = env["root"] / "B03_FileInput/input/shp"
names = sorted(p.name for p in shp_dir.iterdir())
assert len(names) == 5 and {Path(n).suffix for n in names} == set(SHP_SUFFIXES)
assert all(n.startswith("대상지_일월.용화.") and "_2026." in n for n in names)
assert len(env["rows"]) == 5
assert {r["relative_path"] for r in env["rows"]} == {f"{link.ROUTE_DIR}/{n}" for n in names}
assert env["complete"] is None # 지형 없음 → 완료 판정 안 함 · WF1 없음
assert result["analysis_started"] is False and result["project_id"] == ROAD_ID
shp = find_planned_route_file(env["root"] / "B03_FileInput/input")
meta = analyze_input_metadata(shp)
assert meta["purpose"] == "planned_route" and meta["epsg"] == 5179
assert meta["point_count"] == len(P2026)
stage_meta = json.loads((env["root"] / "B03_FileInput/metadata.json").read_text("utf-8"))
assert len(stage_meta["files"]) == 5
fp = link.route_fingerprint(_assignee()["route_json"])
assert env["links"] == [(ROAD_ID, 7, fp, 20)] and len(fp) == 40
assert any("SUPERSEDED" in sql for sql, _ in env["log"] if isinstance(sql, str))
def test_complete_set_asks_before_discarding(env, monkeypatch):
_use(monkeypatch, _assignee(), ROAD)
env["types"] = {"las", "prj", "tfw"}
response = _link()
assert response.status_code == 409 and json.loads(response.body)["confirm"] == "replace_outputs"
assert not (env["root"] / "B03_FileInput/input/shp").exists()
assert ("rollback", None) in env["log"] and env["links"] == []
env["rows"].clear()
result = _link(confirm_replace=True)
assert env["complete"] is True and result["analysis_started"] is True
assert len(list((env["root"] / "B03_FileInput/input/shp").iterdir())) == 5
def test_other_company_and_unconfirmed_and_foreign_project(env, monkeypatch):
_use(monkeypatch, _assignee(company_id=9), ROAD)
with pytest.raises(HTTPException) as err:
_link()
assert err.value.status_code == 403
_use(monkeypatch, _assignee(confirmed_at=None), ROAD)
with pytest.raises(HTTPException) as err:
_link()
assert err.value.status_code == 409
_use(monkeypatch, _assignee(), {**ROAD, "company_id": 9})
with pytest.raises(HTTPException) as err:
_link()
assert err.value.status_code == 403
_use(monkeypatch, _assignee(), {**ROAD, "project_kind": "PLAN"})
with pytest.raises(HTTPException) as err:
_link()
assert err.value.status_code == 400
assert env["rows"] == []
def test_new_mode_creates_road_project(env, monkeypatch):
_use(monkeypatch, _assignee())
made = {}
async def create_project(**kw):
made.update(kw)
return {"project_id": ROAD_ID}
monkeypatch.setattr(link, "create_project", create_project)
result = _link(mode="new", project_id=None)
assert result["project_id"] == ROAD_ID
assert made["name"] == "시험 예정노선 2026" and made["region"] == "일월.용화"
assert made["road_type"] == "work" and made["project_year"] == 2026
assert made["company_id"] == 5 and len(env["rows"]) == 5
def test_fingerprint_update_and_revert():
linked = link.route_fingerprint(_route(P2026))
moved = [p[:] for p in P2026]
moved[1][0] += 0.5
assert link.update_available(json.dumps(_route(P2026)), linked) is False
assert link.update_available(json.dumps(_route(moved)), linked) is True
moved[1][0] -= 0.5 # 같은 값으로 되돌림
assert link.update_available(json.dumps(_route(moved)), linked) is False
assert link.update_available(None, linked) is False # 지점 바뀌어 노선 빔 → 알림 없음
assert link.update_available(_route(P2026), None) is False # 연결 안 함
def test_assigned_list_flags_update(monkeypatch):
linked = link.route_fingerprint(_route(P2026))
moved = [[x + 1.0, y] for x, y in P2026]
rows = [
{
"assignee_id": 7,
"project_id": PLAN_ID,
"project_name": "시험",
"year": 2026,
"linked_project_id": ROAD_ID,
"linked_project_name": "임도",
"source_fingerprint": linked,
"route_json": json.dumps(_route(moved)),
},
{
"assignee_id": 8,
"project_id": PLAN_ID,
"project_name": "시험",
"year": 2027,
"linked_project_id": None,
"linked_project_name": None,
"source_fingerprint": None,
"route_json": json.dumps(_route(P2027)),
},
]
log = []
monkeypatch.setattr(d01, "get_db_pool", lambda: FakeConn(log, rows))
out = asyncio.run(d01.assigned_plan_routes(SESSION))["routes"]
assert [r["update_available"] for r in out] == [True, False]
assert "route_json" not in out[0] and out[0]["assignee_id"] == 7
assert "confirmed_at IS NOT NULL" in log[0][0]
def test_plan_source(monkeypatch):
linked = link.route_fingerprint(_route(P2026))
row = {
"source_fingerprint": linked,
"plan_name": "시험",
"assignee_id": 7,
"year": 2026,
"route_json": json.dumps(_route(P2026)),
}
async def one(sql, args):
return row
monkeypatch.setattr(link, "_fetch_one", one)
out = asyncio.run(link.plan_source(ROAD_ID))
assert out["linked"] and out["update_available"] is False and out["year"] == 2026
row["route_json"] = json.dumps(_route([[x, y + 2.0] for x, y in P2026]))
assert asyncio.run(link.plan_source(ROAD_ID))["update_available"] is True
async def none(sql, args):
return None
monkeypatch.setattr(link, "_fetch_one", none)
assert asyncio.run(link.plan_source(ROAD_ID)) == {"status": "success", "linked": False}
@pytest.fixture
def plan_repo(monkeypatch):
async def project(_pid):
return {
"id": PLAN_ID,
"name": "시험 예정노선",
"region": "일월.용화",
"project_kind": "PLAN",
"route_start_m": 0.0,
}
async def years(_pid):
return [
{"year": 2026, "epsg": 5186, "route_json": _route(P2026), "confirmed_at": "x"},
{"year": 2027, "epsg": 5186, "route_json": _route(P2027), "confirmed_at": None},
]
async def assignees(_pid):
return [
{"id": 7, "year": 2026, "company_id": 5},
{"id": 8, "year": 2027, "company_id": 5},
]
monkeypatch.setattr(repo, "get_plan_project", project)
monkeypatch.setattr(repo, "list_year_routes", years)
monkeypatch.setattr(repo, "list_assignees", assignees)
def _zip_names(response):
return sorted(zipfile.ZipFile(io.BytesIO(response.body)).namelist())
def test_export_forest_gets_all(plan_repo):
forest = {"role": "USER", "email": "kim@forest.go.kr", "company_id": 1}
names = _zip_names(asyncio.run(d04.export_plan(PLAN_ID, forest)))
assert {n.split("/")[0] for n in names} == {"2026", "2027", "전체"}
assert sum(n.endswith(".shp") for n in names) == 3
def test_export_shared_gets_own_confirmed_year(plan_repo):
names = _zip_names(asyncio.run(d04.export_plan(PLAN_ID, SESSION)))
assert {n.split("/")[0] for n in names} == {"2026"} # 2027 = 확정 전 · 전체 없음
with pytest.raises(HTTPException) as err:
asyncio.run(d04.export_plan(PLAN_ID, {**SESSION, "company_id": 9}))
assert err.value.status_code == 403
def test_summary_year_rows(plan_repo):
out = asyncio.run(d04.get_plan_summary(PLAN_ID))
first = out["years"][0]
assert first["saved"] and first["start"]["x"] == P2026[0][0] and first["confirmed_at"] == "x"
assert 120 < first["start"]["lon"] < 135 and first["end_chainage_m"] == pytest.approx(
first["length_m"]
)
assert out["years"][1]["confirmed_at"] is None and len(out["assignees"]) == 2
@@ -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("<H", blob[8:10])[0] - 33) // 32
out = []
for i in range(count):
d = blob[32 + i * 32 : 64 + i * 32]
out.append((d[:11].rstrip(b"\0").decode("cp949"), chr(d[11]), d[16], d[17]))
return out
def _write(tmp_path: Path, files: dict[str, bytes]) -> 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("<i", files[stem + ".shp"][32:36])[0] == 3
def test_b03_analysis_accepts_exported_shapefile(tmp_path):
bundle = _bundle()
shp = _write(tmp_path, bundle.by_year[2026])
meta = analyze_input_metadata(shp)
ref = analyze_input_metadata(_yh(".shp"))
assert meta["shape_type"] == ref["shape_type"]
assert meta["part_count"] == 1 and meta["point_count"] == len(Y2026)
assert meta["encoding"] == ref["encoding"] == "cp949"
assert meta["crs_input"] == ref["crs_input"] # .prj 원문 같음 → 같은 좌표계
assert meta["missing_members"] == []
assert meta["route_name"] == meta["attributes"]["대상지"]
assert meta["route_name"].startswith("일월.용화.")
# 5186 → 5179 로 바뀐 시점 좌표
sx, sy = Transformer.from_crs(5186, 5179, always_xy=True).transform(*Y2026[0])
assert meta["start_point"][:2] == pytest.approx([sx, sy], abs=1e-6)
assert float(meta["attributes"]["연도"]) == 2026
assert float(meta["attributes"]["시점누가"]) == pytest.approx(100.0)
def test_stations_chain_and_total(tmp_path):
bundle = _bundle()
l26, l27 = bundle.lengths_m[2026], bundle.lengths_m[2027]
shp27 = _write(tmp_path, bundle.by_year[2027])
attrs = analyze_input_metadata(shp27)["attributes"]
assert float(attrs["시점누가"]) == pytest.approx(100.0 + l26, abs=0.01) # 직전 종점 = 시점
assert float(attrs["종점누가"]) == pytest.approx(100.0 + l26 + l27, abs=0.01)
total_tmp = tmp_path / "all"
total_tmp.mkdir()
meta = analyze_input_metadata(_write(total_tmp, bundle.all))
assert meta["point_count"] == len(Y2026) + len(Y2027) - 1 # 겹친 경계점 하나 뺌
assert bundle.lengths_m[0] == pytest.approx(l26 + l27, abs=0.01)
assert meta["attributes"].get("연도") in (
None,
"",
"****",
) # 전체는 연도 칸 비움(dBase 널 표기)
def test_explicit_stations_win_and_short_route_rejected():
routes = [YearRoute(2026, Y2026, 5186, start_station_m=500.0, end_station_m=900.0)]
rows = build_export("p", "r", routes, with_xlsx=False)
meta = {}
import tempfile
with tempfile.TemporaryDirectory() as tmp:
meta = analyze_input_metadata(_write(Path(tmp), rows.by_year[2026]))
assert float(meta["attributes"]["시점누가"]) == 500.0
assert float(meta["attributes"]["종점누가"]) == 900.0
with pytest.raises(ValueError):
build_export("p", "r", [YearRoute(2026, [(0.0, 0.0)], 5179)])
def test_zip_and_coordinate_table():
bundle = _bundle()
name, blob = company_zip("시험 프로젝트", 2026, bundle.by_year[2026])
assert name == "시험 프로젝트_예정노선_2026.zip"
with zipfile.ZipFile(io.BytesIO(blob)) as archive:
names = archive.namelist()
assert sum(n.endswith(s) for n in names for s in (".shp", ".shx", ".dbf", ".prj", ".cpg")) == 5
assert "좌표표_시험 프로젝트.xlsx" in names
sheet = load_workbook(io.BytesIO(bundle.all["좌표표_시험 프로젝트.xlsx"])).active
rows = list(sheet.iter_rows(min_row=3, values_only=True))
assert [r[0] for r in rows] == [2026, 2027]
# 업체용 좌표표는 그 연도만
one = load_workbook(io.BytesIO(bundle.by_year[2026]["좌표표_시험 프로젝트.xlsx"])).active
assert [r[0] for r in one.iter_rows(min_row=3, values_only=True)] == [2026]
# ── 메일 ─────────────────────────────────────────────────
class _FakeSmtp:
sent: list = []
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def starttls(self):
pass
def login(self, *args):
pass
def send_message(self, message):
_FakeSmtp.sent.append(message)
def test_mail_attachment_assembly_and_old_calls(monkeypatch):
monkeypatch.setattr(email_mod.smtplib, "SMTP", _FakeSmtp)
_FakeSmtp.sent = []
payload = b"PK\x03\x04" + b"x" * 5000
email_mod._send_sync(
"a@b.kr", "제목", "<p>본문</p>", [("시험_예정노선_2026.zip", payload, "application/zip")]
)
email_mod._send_sync("a@b.kr", "제목", "<p>본문</p>") # 기존 호출 그대로
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", "<p>h</p>")) is True
assert asyncio.run(
email_mod.send_email("a@b.kr", "s", "<p>h</p>", [("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 "&lt;예정&gt;" 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] # 빠진 업체 지움
+203
View File
@@ -0,0 +1,203 @@
"""528-4 · 532-1 공용 프로젝트 복사 — 임시 storage 사본에서(요구 345 · 351).
- stability — 필요한 것만(LAS · 전처리 점군 · B07~B09 · 초기값 없음 · 지형 하나) · 지문 40자 ·
복사본만으로 측점 수 · 설계선 = DB · 원본을 고쳐도 복사본 불변 · 고친 원본 = 다른 지문
- full(CLI 껍데기) — 새 id · 표마다 행 수 같음 · 지표면 id 다시 이음 · 파일 안 UUID 치환 ·
원본 그대로(만든 행은 끝에 지움 · 연쇄 삭제)
"""
import asyncio
import hashlib
import importlib.util
import json
import os
import shutil
import sys
import tempfile
from pathlib import Path
import aiomysql
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent))
from helper_temp_storage import temp_project_storage # noqa: E402
from common_util import common_util_project_copy as cp # noqa: E402
from common_util.common_util_storage import resolve_stored_project_path # noqa: E402
from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER # noqa: E402
from E01_StabRegister import E01_StabRegister_Source as src # noqa: E402
#: 검증_sub1 — B06 까지 설계된 임도(읽기만)
SOURCE_ID = "916ea58c-0e35-4982-bf55-d8558eeb78b5"
ROOT = Path(__file__).resolve().parents[2]
async def _connect():
return await aiomysql.connect(
host=DB_HOST,
port=DB_PORT,
user=DB_USER,
password=DB_PASSWORD,
db=DB_NAME,
charset="utf8mb4",
autocommit=True,
)
async def _query(sql, args=()):
connection = await _connect()
try:
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(sql, args)
return await cursor.fetchall()
finally:
connection.close()
@pytest.fixture(scope="module")
def source_root():
rows = asyncio.run(_query("SELECT storage_path FROM projects WHERE id = %s", (SOURCE_ID,)))
if not rows:
pytest.skip("검증 원본 프로젝트가 DB 에 없음")
shared = Path(resolve_stored_project_path(rows[0]["storage_path"]))
with temp_project_storage(shared) as root:
yield root
async def _stability(target: Path) -> dict:
connection = await _connect()
try:
return await cp.copy_project(
connection, SOURCE_ID, scope="stability", new_id="e" * 36, project_root=target
)
finally:
connection.close()
def _hashes(root: Path) -> dict[str, str]:
return {
path.relative_to(root).as_posix(): hashlib.sha1(path.read_bytes()).hexdigest()
for path in root.rglob("*.json")
if path.is_file()
}
@pytest.fixture
def work():
"""pytest tmp_path 정리가 이 PC 임시 폴더 권한에 걸려 tempfile 로."""
path = Path(tempfile.mkdtemp(prefix="aislo_532_"))
yield path
shutil.rmtree(path, ignore_errors=True)
def test_stability_copy(source_root, work):
before = _hashes(source_root)
manifest = asyncio.run(_stability(work / "e1"))
root = src.source_dir(work / "e1")
assert manifest["scope"] == "stability" and manifest["source_project_id"] == SOURCE_ID
assert len(manifest["source_fingerprint"]) == 40
assert manifest["total_bytes"] < 40 * 1024 * 1024
banned = ("B03_FileInput", "B04_PreProcess/processed", "B07_", "B08_", "B09_", "initial_")
assert not [name for name in manifest["files"] if name.startswith(banned)]
assert src.terrain_grid_path(root).is_file() # 지형 하나뿐
assert src.corridor_path(root) is not None
# 파일 안 옛 UUID = 없음(동결 db.json · manifest 의 원본 기록 빼고)
for path in root.rglob("*.json"):
if path.name not in (cp.STABILITY_DB_NAME, cp.MANIFEST_NAME):
assert SOURCE_ID not in path.read_text(encoding="utf-8"), path
# 복사본만으로 측점 · 설계선 = DB 최신 노선
rows = asyncio.run(
_query(
"SELECT chainage_m, data FROM cross_sections WHERE route_id = %s ORDER BY chainage_m",
(manifest["route_id"],),
)
)
stations = src.stations(root)
assert len(stations) == len(rows) > 0
for station, row in zip(stations, rows, strict=True):
assert station["chainage_m"] == pytest.approx(row["chainage_m"])
cross = src.cross_section(root, station["chainage_m"])
assert cross["design"]["design_line"] == json.loads(row["data"])["design"]["design_line"]
assert len(cross["samples"]) > 10
# 원본 그대로 · 원본을 고쳐도 복사본 불변 · 같은 원본 = 같은 지문
assert _hashes(source_root) == before
first = stations[0]["chainage_m"]
kept = src.cross_section(root, first)
cross_file = next((source_root / "B06_Section" / "cross_sections").glob("*.json"))
data = json.loads(cross_file.read_text(encoding="utf-8"))
for sample in data["samples"]:
sample["z"] = -999.0
cross_file.write_text(json.dumps(data), encoding="utf-8") # 임시 사본만 고침
assert src.cross_section(root, first) == kept
again = asyncio.run(_stability(work / "e2"))
assert again["source_fingerprint"] != manifest["source_fingerprint"]
def _cli():
spec = importlib.util.spec_from_file_location(
"tools_clone_project", ROOT / "db_management" / "tools_clone_project.py"
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _copytree(source: Path, target: Path) -> None:
"""robocopy 대신 — 시험 사본의 정션(B03 · 전처리 점군)은 따라가지 않음."""
shutil.copytree(
source,
target,
ignore=lambda folder, names: [n for n in names if os.path.isjunction(Path(folder, n))],
)
COUNT_TABLES = (
"routes",
"surface_models",
"input_files",
"project_workflow_stages",
"project_members",
"cross_sections",
"longitudinal_sections",
)
def _counts(project_id):
return {
table: asyncio.run(
_query(f"SELECT COUNT(*) AS n FROM {table} WHERE project_id = %s", (project_id,))
)[0]["n"]
for table in COUNT_TABLES
}
def test_full_clone_cli(source_root, monkeypatch):
monkeypatch.setattr(cp, "_copy_folder", _copytree)
before = _hashes(source_root)
new_id = asyncio.run(_cli().main([SOURCE_ID, "시험_528_4_복제"]))
try:
assert new_id and new_id != SOURCE_ID
assert _counts(new_id) == _counts(SOURCE_ID)
models = {
r["id"]
for r in asyncio.run(
_query("SELECT id FROM surface_models WHERE project_id = %s", (new_id,))
)
}
routes = asyncio.run(
_query("SELECT surface_model_id FROM routes WHERE project_id = %s", (new_id,))
)
assert {r["surface_model_id"] for r in routes} <= models
new_root = source_root.parent / new_id
assert (new_root / cp.MANIFEST_NAME).is_file()
for path in new_root.rglob("*.json"):
if path.name != cp.MANIFEST_NAME:
assert SOURCE_ID not in path.read_text(encoding="utf-8"), path
assert _hashes(source_root) == before
finally:
if new_id:
asyncio.run(_query("DELETE FROM projects WHERE id = %s", (new_id,)))
@@ -0,0 +1,252 @@
"""534-3(양식) — E05 안정성평가 보고서 양식 `resources/master_template/report/stab_report.json`.
계약 검사(키 · 서식 번호 · A1 · 병합 · 쪽 나눔) + 가짜 값 사본으로 수식 · 인쇄 HTML · PDF 쪽 수 · xlsx 되읽기.
"""
import copy
import io
import json
import re
import shutil
from pathlib import Path
import pytest
from openpyxl import load_workbook
from common_util import common_util_spreadsheet as spreadsheet
from common_util.common_util_print_pdf import PrintError
from common_util.common_util_svg_png import svgs_to_pngs
from common_util.common_util_workbook_xlsx import workbook_to_xlsx
ROOT = Path(__file__).resolve().parents[2]
FORM = ROOT / "resources/master_template/report/stab_report.json"
BLOCK = 28 # 대표단면 한 쪽 = 28 행
SVG = (
'<svg xmlns="http://www.w3.org/2000/svg" width="712" height="533">'
'<rect width="712" height="533" fill="#cde"/></svg>'
)
BOOK_KEYS = {"종류", "판", "열", "기본", "서식", "시트", "활성", "계산값"}
SHEET_KEYS = {
"id", "이름", "칸", "병합", "열", "행", "틀고정", "보기", "기본", "comments", "필터",
"조건부서식", "유효성", "이름정의", "숨김", "탭색", "연결", "인쇄", "그림",
} # fmt: skip
STYLE_KEYS = {
"글꼴", "크기", "굵게", "기울임", "밑줄", "취소선", "글자색", "채움", "가로", "세로", "줄바꿈",
"테두리", "형식",
} # fmt: skip
PRINT_KEYS = {"용지", "방향", "배율", "맞춤", "여백", "머리행", "쪽나눔"}
A1 = re.compile(r"^([A-Z]{1,3})(\d+)$")
NAMES = ["표지", "개요", "입력조건", "측점표", "대표단면", "3D요약"]
needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def _form() -> dict:
return json.loads(FORM.read_text(encoding="utf-8"))
def _rc(a1: str) -> tuple[int, int]:
m = A1.match(a1)
assert m, a1
col = 0
for ch in m.group(1):
col = col * 26 + ord(ch) - 64
return int(m.group(2)), col
def _span(text: str) -> tuple[int, int, int, int]:
a, _, b = text.partition(":")
(r0, c0), (r1, c1) = _rc(a), _rc(b or a)
assert r0 <= r1 and c0 <= c1, text
return r0, c0, r1, c1
def _contract(book: dict) -> None:
assert set(book) <= BOOK_KEYS and book["종류"] == "통합문서" and book["판"] == 1
assert book["서식"][0] == {}
for st in book["서식"]:
assert set(st) <= STYLE_KEYS
ids = [s["id"] for s in book["시트"]]
assert len(set(ids)) == len(ids) and book["활성"] in ids
for sheet in book["시트"]:
assert set(sheet) <= SHEET_KEYS
for a1, cell in sheet["칸"].items():
_rc(a1)
assert set(cell) <= {"값", "식", "서식"} and not ({"값", "식"} <= set(cell))
assert 0 <= cell.get("서식", 0) < len(book["서식"])
used: set[tuple[int, int]] = set()
for m in sheet.get("병합", []):
r0, c0, r1, c1 = _span(m)
box = {(r, c) for r in range(r0, r1 + 1) for c in range(c0, c1 + 1)}
assert len(box) > 1 and not (used & box), m
used |= box
pr = sheet.get("인쇄", {})
assert set(pr) <= PRINT_KEYS
assert pr.get("쪽나눔", []) == sorted(set(pr.get("쪽나눔", [])))
if "머리행" in pr:
assert 1 <= pr["머리행"][0] <= pr["머리행"][1]
for fig in sheet.get("그림", []):
_span(fig["닻"])
assert fig["열쇠"]
for rule in sheet.get("조건부서식", []):
assert rule["조건"]["종류"] == "글포함" and rule["범위"]
# ── 가짜 값 사본 ─────────────────────────────────────────────────────────────
def _shift(a1: str, dr: int) -> str:
m = A1.match(a1)
return f"{m.group(1)}{int(m.group(2)) + dr}"
def _fill(book: dict, stations: int, sections: int) -> tuple[dict, int]:
"""측점표 `stations` 줄 · 대표단면 `sections` 쪽 · 3D 두 줄을 채운 사본 · N.G 수."""
book = copy.deepcopy(book)
by = {s["이름"]: s for s in book["시트"]}
s3, s4, s5, s6 = by["입력조건"], by["측점표"], by["대표단면"], by["3D요약"]
for r, v in ((12, 1.5), (13, 1.2), (14, 1.1)):
s3["칸"][f"B{r}"]["값"] = v
ng = 0
for i in range(stations):
r = 6 + i
bad = i % 7 == 3
ng += bad
fs = [1.32 if bad else 1.8 + i / 100, 1.5 + i / 100, 1.3 + i / 100]
for letter, v, st in (
("A", f"No.{i}", 7), ("B", 20.0 * i, 17), ("C", "깎기" if i % 2 else "쌓기", 7),
("D", fs[0], 8), ("E", fs[1], 8), ("F", fs[2], 8),
("G", 1.5, 8), ("H", 1.2, 8), ("I", 1.1, 8), ("J", "N.G" if bad else "O.K", 7),
): # fmt: skip
s4["칸"][f"{letter}{r}"] = {"값": v, "서식": st}
for k in range(1, sections): # 첫 덩이를 아래로 복사
dr = BLOCK * k
for a1, cell in list(s5["칸"].items()):
if int(A1.match(a1).group(2)) <= BLOCK:
s5["칸"][_shift(a1, dr)] = copy.deepcopy(cell)
for m in list(s5["병합"]):
if int(re.search(r"\d+", m).group()) <= BLOCK:
s5["병합"].append(":".join(_shift(p, dr) for p in m.split(":")))
for r, info in list(s5["행"].items()):
if int(r) <= BLOCK:
s5["행"][str(int(r) + dr)] = dict(info)
a, _, b = s5["그림"][0]["닻"].partition(":")
s5["그림"].append({"열쇠": f"fig/s{k}.png", "닻": f"{_shift(a, dr)}:{_shift(b, dr)}"})
s5["인쇄"]["쪽나눔"].append(BLOCK * (k + 1))
s5["그림"][0]["열쇠"] = "fig/s0.png"
for r, row in (
(20, ("1구간", "No.0", "No.9", 1.9, 1.7, 1200)),
(21, ("2구간", "No.9", "No.19", 1.4, 1.5, 800)),
):
for letter, v in zip("ABCDEG", row):
s6["칸"][f"{letter}{r}"] = {"값": v, "서식": s6["칸"][f"{letter}20"]["서식"]}
s6["칸"][f"F{r}"] = {"식": f'IF(E{r}>0,D{r}/E{r},"")', "서식": s6["칸"]["F20"]["서식"]}
s6["칸"][f"H{r}"] = {"값": "O.K" if r == 20 else "N.G", "서식": s6["칸"]["H20"]["서식"]}
return book, ng
# ── 시험 ─────────────────────────────────────────────────────────────────────
def test_양식_계약():
book = _form()
_contract(book)
assert [s["이름"] for s in book["시트"]] == NAMES
assert [s["id"] for s in book["시트"]] == ["s1", "s2", "s3", "s4", "s5", "s6"]
def test_양식_인쇄_쪽나눔_머리행_그림():
by = {s["이름"]: s for s in _form()["시트"]}
for sheet in by.values():
pr = sheet["인쇄"]
assert pr["용지"] == "A4" and pr["맞춤"]["폭"] == 1 and pr["여백"]
assert by["측점표"]["인쇄"]["방향"] == "landscape" and by["측점표"]["인쇄"]["머리행"] == [4, 5]
assert by["대표단면"]["인쇄"]["쪽나눔"] == [BLOCK]
figs = {n: len(s.get("그림", [])) for n, s in by.items()}
assert figs == {"표지": 0, "개요": 0, "입력조건": 0, "측점표": 0, "대표단면": 1, "3D요약": 1}
# N.G 빨강 조건부 서식 — 측점표 판정 열 · 서식 표에 N.G 칸 서식(빌더가 인쇄 · xlsx 용으로 직접 씀)
assert by["측점표"]["조건부서식"][0]["범위"][0]["c0"] == 9
assert any(st.get("글자색") == "#C00000" and st.get("채움") for st in _form()["서식"])
# 안전율 칸 숫자 형식 0.00
assert _form()["서식"][by["측점표"]["칸"]["D6"]["서식"]]["형식"] == "0.00"
@needs_node
def test_빈_양식도_풀림():
book = _form()
vals = spreadsheet.recalc_workbooks([book])[0]
assert vals["s2"]["B5"] == {"수": "0"}
assert vals["s2"]["B10"] == {"글": "-"}
assert vals["s2"]["B16"] == {"글": "-"}
assert vals["s2"]["B17"] == {"글": "해석 결과 없음"}
assert not [v for s in vals.values() for v in s.values() if "오류" in v]
@needs_node
def test_채운_양식_수식():
book, ng = _fill(_form(), 40, 2)
s2 = spreadsheet.recalc_workbooks([book])[0]["s2"]
assert s2["B5"] == {"수": "40"} and s2["B6"] == {"수": "780"}
assert s2["B15"] == {"수": str(ng)} and ng == 6
assert (
s2["B10"] == {"수": "1.32"} and s2["D10"] == {"글": "N.G"} and s2["E10"] == {"글": "No.3"}
)
assert s2["D11"] == {"글": "O.K"} and s2["B11"] == {"수": "1.5"}
assert s2["B16"] == {"글": "N.G"}
assert s2["B17"]["글"].startswith("대상 측점 40개소 중 6개소가")
s6 = spreadsheet.recalc_workbooks([book])[0]["s6"]
assert s6["F21"] == {"수": "0.933333333333333"} or s6["F21"]["수"].startswith("0.93333")
@pytest.fixture(scope="module")
def png() -> bytes:
try:
return svgs_to_pngs([SVG])[0]
except PrintError as error:
pytest.skip(f"브라우저 없음 — {error}")
@needs_node
def test_견본_pdf_쪽수_머리행(png):
book, _ = _fill(_form(), 40, 2)
figs = {"fig/s0.png": png, "fig/s1.png": png, "fig/3d_band.png": png}
try:
pdf = spreadsheet.workbook_pdf(book, figs)
except PrintError as error:
pytest.skip(f"브라우저 없음 — {error}")
from pypdf import PdfReader
pages = PdfReader(io.BytesIO(pdf)).pages
texts = [p.extract_text() for p in pages]
stab = [i for i, t in enumerate(texts) if "측점별 안전율" in t or "산정 안전율" in t]
assert len(stab) >= 2 and all("산정 안전율" in texts[i] for i in stab) # 머리 줄 쪽마다
assert (
texts[0].count("비탈면 안정성 검토 보고서") == 1
and "문헌 추정치 · 현장 시험 없음" in texts[0]
)
sections = [i for i, t in enumerate(texts) if "대표 단면" in t and "안전율 Fs" in t]
assert len(sections) == 2 # 단면마다 한 쪽
# 표지 1 + 개요 1 + 입력조건 1 + 측점표 n + 대표단면 2 + 3D 1
assert len(pages) == 1 + 1 + 1 + len(stab) + 2 + 1
assert all(float(pages[i].mediabox.width) > float(pages[i].mediabox.height) for i in stab)
html = spreadsheet.print_htmls([{"book": book, "figures": {k: png for k in figs}}])[0]
assert html.count("<img ") == 3 # 단면 2 + 3D 띠 1
@needs_node
def test_견본_xlsx_되읽기(png):
book, _ = _fill(_form(), 40, 2)
figs = {"fig/s0.png": png, "fig/s1.png": png, "fig/3d_band.png": png}
wb = load_workbook(io.BytesIO(workbook_to_xlsx(book, figs)))
assert wb.sheetnames == NAMES
ws = wb["측점표"]
assert ws.print_title_rows == "$4:$5" and ws.page_setup.orientation == "landscape"
assert ws["D6"].number_format == "0.00" and ws["J6"].value in ("O.K", "N.G")
assert "A4:A5" in {str(m) for m in ws.merged_cells.ranges}
sec = wb["대표단면"]
assert [b.id for b in sec.row_breaks.brk] == [BLOCK, BLOCK * 2] and len(sec._images) == 2
assert len(wb["3D요약"]._images) == 1 and wb["3D요약"].print_title_rows == "$19:$19"
assert wb["개요"]["B5"].value.startswith("=COUNTA(")
assert wb["표지"]["B14"].value == "문헌 추정치 · 현장 시험 없음"
def test_자리표_시트_모두_적음():
slots = (FORM.parent / "stab_report_slots.md").read_text(encoding="utf-8")
for sheet in _form()["시트"]:
assert f"## {sheet['id']} {sheet['이름']}" in slots
+1 -1
View File
@@ -34,6 +34,6 @@
"@kninnug/constrainautor": ["config/node_modules/@kninnug/constrainautor"],
}
},
"include": ["A00_Common", "A0*", "B0*", "B1*", "M0*", "D0*", "E0*", "Z0*", "ui_template", "config/**/*", "common_util"],
"include": ["A00_Common", "A0*", "B0*", "B1*", "M0*", "D0*/**/*", "E0*/**/*", "Z0*", "ui_template", "config/**/*", "common_util"],
"exclude": ["0_old", "node_modules", "venv", "dist"]
}
+11
View File
@@ -62,4 +62,15 @@ export const ui_locales_de = {
],
DE_Reg_Submit: ["등록", "Register"],
DE_Reg_Success: ["프로젝트를 만들었습니다.", "Project created."],
/* --- 대시보드 표 · 받은 예정노선(528-2) --- */
DE_Kind_Column: ["종류", "Kind"],
DE_Assigned_Title: ["받은 예정노선", "Assigned Planned Routes"],
DE_Assigned_Year: ["연도", "Year"],
DE_Assigned_State: ["연결 상태", "Link"],
DE_Assigned_Linked: ["연결됨", "Linked"],
DE_Assigned_Unlinked: ["미연결", "Not linked"],
DE_Assigned_Update: ["업데이트 있음", "Update available"],
DE_Assigned_View: ["보기", "View"],
DE_Assigned_Link: ["연결", "Link"],
} as const satisfies Record<string, LocaleEntry>;