diff --git a/A00_Common/group_workflow.ts b/A00_Common/group_workflow.ts
index a056c69cf..0fa1d7e21 100644
--- a/A00_Common/group_workflow.ts
+++ b/A00_Common/group_workflow.ts
@@ -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,
diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts
index a4d41a743..bc3fbb138 100644
--- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts
+++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts
@@ -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;
diff --git a/B01_Dashboard/B01_Dashboard_UI_AssignedPlans.ts b/B01_Dashboard/B01_Dashboard_UI_AssignedPlans.ts
new file mode 100644
index 000000000..f5ab0a7ad
--- /dev/null
+++ b/B01_Dashboard/B01_Dashboard_UI_AssignedPlans.ts
@@ -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 {
+ 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,
+ );
+}
diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts
index ce3d5bdee..770e60116 100644
--- a/B01_Dashboard/B01_Dashboard_UI_Page.ts
+++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts
@@ -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 {
allJoinRequests: [],
auditLogs: [],
resources: null,
+ assignedPlans: [],
adminCompanyId: null,
};
await loadRoleData(state);
@@ -134,12 +142,14 @@ async function loadRoleData(state: DashboardState): Promise {
});
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[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(),
diff --git a/B01_Dashboard/B01_Dashboard_UI_Projects.ts b/B01_Dashboard/B01_Dashboard_UI_Projects.ts
index a07fe8071..313aeac3f 100644
--- a/B01_Dashboard/B01_Dashboard_UI_Projects.ts
+++ b/B01_Dashboard/B01_Dashboard_UI_Projects.ts
@@ -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]),
},
);
}
diff --git a/D01_PlanRegister/D01_PlanRegister_Router.py b/D01_PlanRegister/D01_PlanRegister_Router.py
index ce81164b8..62df091cc 100644
--- a/D01_PlanRegister/D01_PlanRegister_Router.py
+++ b/D01_PlanRegister/D01_PlanRegister_Router.py
@@ -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}
diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py b/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py
index 5e6296638..9cb67fa73 100644
--- a/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py
+++ b/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py
@@ -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"],
diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py
index 63027aac1..7d3be6c57 100644
--- a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py
+++ b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py
@@ -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
diff --git a/D04_PlanConfirm/D04_PlanConfirm_Email.py b/D04_PlanConfirm/D04_PlanConfirm_Email.py
new file mode 100644
index 000000000..09d68aeb9
--- /dev/null
+++ b/D04_PlanConfirm/D04_PlanConfirm_Email.py
@@ -0,0 +1,50 @@
+"""D04 예정노선 확정 메일 — 업체에 그 연도 노선 묶음(zip)을 첨부해 보낸다 (요구 349-9)."""
+
+from __future__ import annotations
+
+import html
+
+from common_util.common_util_email import Attachment, send_email
+
+
+def build_completion_mail(
+ *,
+ project_name: str,
+ region: str | None,
+ year: int,
+ length_m: float,
+ recipient: str,
+ attachment_names: list[str],
+) -> tuple[str, str]:
+ """(제목, HTML 본문) — 프로젝트명 · 연도 · 연장 · 첨부 목록."""
+ esc = html.escape
+ rows = [
+ ("프로젝트", project_name),
+ ("사업 지역", region or "-"),
+ ("연도", f"{year}년"),
+ ("연장", f"{length_m / 1000:,.3f} km ({length_m:,.1f} m)"),
+ ]
+ table = "".join(
+ f"{esc(k)} "
+ f"{esc(v)} "
+ for k, v in rows
+ )
+ files = "".join(f"{esc(name)} " for name in attachment_names)
+ body = f"""
+
+
+안녕하세요, {esc(recipient)} 담당자님.
+예정노선이 확정되어 {year}년 노선 파일을 보내 드립니다.
+
+첨부 파일
+
+압축을 풀면 shapefile(.shp · .shx · .dbf · .prj · .cpg · 좌표계 UTM-K)이 들어 있으며,
+임도 설계 프로그램의 노선 입력에 그대로 올릴 수 있습니다.
+"""
+ return f"[예정노선 확정] {project_name} {year}년", body
+
+
+async def send_completion_mail(
+ *, to_email: str, subject: str, body: str, attachments: list[Attachment]
+) -> bool:
+ return await send_email(to_email=to_email, subject=subject, html=body, attachments=attachments)
diff --git a/D04_PlanConfirm/D04_PlanConfirm_Export.py b/D04_PlanConfirm/D04_PlanConfirm_Export.py
new file mode 100644
index 000000000..8bbb9cf78
--- /dev/null
+++ b/D04_PlanConfirm/D04_PlanConfirm_Export.py
@@ -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()
diff --git a/D04_PlanConfirm/D04_PlanConfirm_Link.py b/D04_PlanConfirm/D04_PlanConfirm_Link.py
new file mode 100644
index 000000000..966a53fab
--- /dev/null
+++ b/D04_PlanConfirm/D04_PlanConfirm_Link.py
@@ -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"]),
+ }
diff --git a/D04_PlanConfirm/D04_PlanConfirm_Repository.py b/D04_PlanConfirm/D04_PlanConfirm_Repository.py
new file mode 100644
index 000000000..83912fb8b
--- /dev/null
+++ b/D04_PlanConfirm/D04_PlanConfirm_Repository.py
@@ -0,0 +1,119 @@
+"""D04 확정 · 업체 지정 DB (Raw SQL · 표는 db_management/020_project_groups.sql)."""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+import aiomysql
+
+from config.config_db import get_db_pool
+
+
+async def get_plan_project(project_id: str) -> dict[str, Any] | None:
+ async with (
+ get_db_pool().acquire() as connection,
+ connection.cursor(aiomysql.DictCursor) as cursor,
+ ):
+ await cursor.execute(
+ "SELECT id, name, region, project_kind, route_start_m FROM projects"
+ " WHERE id = %s AND deleted_at IS NULL",
+ (project_id,),
+ )
+ return await cursor.fetchone()
+
+
+async def list_year_routes(project_id: str) -> list[dict[str, Any]]:
+ """연도 구간(오름차순) — `route_json` 은 파싱해서 돌려줌(없으면 None)."""
+ async with (
+ get_db_pool().acquire() as connection,
+ connection.cursor(aiomysql.DictCursor) as cursor,
+ ):
+ await cursor.execute(
+ "SELECT year, epsg, route_json, fingerprint, confirmed_at FROM plan_route_years"
+ " WHERE project_id = %s ORDER BY year",
+ (project_id,),
+ )
+ rows = await cursor.fetchall()
+ for row in rows:
+ raw = row["route_json"]
+ row["route_json"] = json.loads(raw) if isinstance(raw, str | bytes) else raw
+ return rows
+
+
+async def list_assignees(project_id: str) -> list[dict[str, Any]]:
+ async with (
+ get_db_pool().acquire() as connection,
+ connection.cursor(aiomysql.DictCursor) as cursor,
+ ):
+ await cursor.execute(
+ "SELECT id, year, company_id, company_name, email, notified_at, linked_project_id"
+ " FROM plan_route_assignees WHERE project_id = %s ORDER BY year, id",
+ (project_id,),
+ )
+ return await cursor.fetchall()
+
+
+async def get_company_contact(company_id: int) -> dict[str, Any] | None:
+ """등록 회사 이름 + 대표 메일(회사 마스터 사용자)."""
+ async with (
+ get_db_pool().acquire() as connection,
+ connection.cursor(aiomysql.DictCursor) as cursor,
+ ):
+ await cursor.execute(
+ "SELECT c.id, c.name, u.email FROM companies c"
+ " LEFT JOIN users u ON u.id = c.master_user_id AND u.deleted_at IS NULL"
+ " WHERE c.id = %s AND c.deleted_at IS NULL",
+ (company_id,),
+ )
+ return await cursor.fetchone()
+
+
+async def apply_assignee_changes(
+ project_id: str, delete_ids: list[int], updates: list[dict], inserts: list[dict]
+) -> None:
+ async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
+ await connection.begin()
+ try:
+ for assignee_id in delete_ids:
+ await cursor.execute(
+ "DELETE FROM plan_route_assignees WHERE id = %s AND project_id = %s",
+ (assignee_id, project_id),
+ )
+ for row in updates:
+ await cursor.execute(
+ "UPDATE plan_route_assignees SET company_name = %s, email = %s"
+ " WHERE id = %s AND project_id = %s",
+ (row["company_name"], row["email"], row["id"], project_id),
+ )
+ for row in inserts:
+ await cursor.execute(
+ "INSERT INTO plan_route_assignees"
+ " (project_id, year, company_id, company_name, email) VALUES (%s,%s,%s,%s,%s)",
+ (project_id, row["year"], row["company_id"], row["company_name"], row["email"]),
+ )
+ await connection.commit()
+ except Exception:
+ await connection.rollback()
+ raise
+
+
+async def mark_confirmed(project_id: str, years: list[int]) -> None:
+ if not years:
+ return
+ marks = ",".join(["%s"] * len(years))
+ async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
+ await cursor.execute(
+ f"UPDATE plan_route_years SET confirmed_at = NOW() WHERE project_id = %s"
+ f" AND year IN ({marks})",
+ (project_id, *years),
+ )
+ await connection.commit()
+
+
+async def mark_notified(assignee_id: int) -> None:
+ async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
+ await cursor.execute(
+ "UPDATE plan_route_assignees SET notified_at = NOW() WHERE id = %s", (assignee_id,)
+ )
+ await connection.commit()
diff --git a/D04_PlanConfirm/D04_PlanConfirm_Router.py b/D04_PlanConfirm/D04_PlanConfirm_Router.py
new file mode 100644
index 000000000..45ba05381
--- /dev/null
+++ b/D04_PlanConfirm/D04_PlanConfirm_Router.py
@@ -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)}"},
+ )
diff --git a/E01_StabRegister/E01_StabRegister_Router.py b/E01_StabRegister/E01_StabRegister_Router.py
index 2c7caa5f3..a8f553d30 100644
--- a/E01_StabRegister/E01_StabRegister_Router.py
+++ b/E01_StabRegister/E01_StabRegister_Router.py
@@ -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
diff --git a/E01_StabRegister/E01_StabRegister_Source.py b/E01_StabRegister/E01_StabRegister_Source.py
new file mode 100644
index 000000000..ba4895d65
--- /dev/null
+++ b/E01_StabRegister/E01_StabRegister_Source.py
@@ -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
diff --git a/E01_StabRegister/E01_StabRegister_UI_Page.ts b/E01_StabRegister/E01_StabRegister_UI_Page.ts
index 6425818f2..461f78469 100644
--- a/E01_StabRegister/E01_StabRegister_UI_Page.ts
+++ b/E01_StabRegister/E01_StabRegister_UI_Page.ts
@@ -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 },
diff --git a/common_util/common_util_email.py b/common_util/common_util_email.py
index 1123a3685..865596187 100644
--- a/common_util/common_util_email.py
+++ b/common_util/common_util_email.py
@@ -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):
diff --git a/common_util/common_util_project_copy.py b/common_util/common_util_project_copy.py
new file mode 100644
index 000000000..12783a998
--- /dev/null
+++ b/common_util/common_util_project_copy.py
@@ -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}")
diff --git a/db_management/tools_clone_project.py b/db_management/tools_clone_project.py
index f38317b82..9e816cdb4 100644
--- a/db_management/tools_clone_project.py
+++ b/db_management/tools_clone_project.py
@@ -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())
diff --git a/main.py b/main.py
index 55851beb2..067aa0391 100644
--- a/main.py
+++ b/main.py
@@ -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)
diff --git a/resources/master_template/report/stab_report.json b/resources/master_template/report/stab_report.json
new file mode 100644
index 000000000..9ded03dad
--- /dev/null
+++ b/resources/master_template/report/stab_report.json
@@ -0,0 +1,2606 @@
+{
+ "종류": "통합문서",
+ "판": 1,
+ "열": "stab_report",
+ "기본": {
+ "글꼴": "맑은 고딕",
+ "크기": 10,
+ "행높이": 18
+ },
+ "서식": [
+ {},
+ {
+ "크기": 18,
+ "굵게": true,
+ "가로": "center",
+ "세로": "center"
+ },
+ {
+ "크기": 10,
+ "글자색": "#555555",
+ "가로": "center",
+ "세로": "center",
+ "줄바꿈": true
+ },
+ {
+ "크기": 11,
+ "굵게": true,
+ "세로": "center",
+ "테두리": {
+ "아래": {
+ "선": "medium"
+ }
+ }
+ },
+ {
+ "굵게": true,
+ "채움": "#F2F2F2",
+ "가로": "center",
+ "세로": "center",
+ "줄바꿈": true,
+ "테두리": {
+ "위": {
+ "선": "thin"
+ },
+ "아래": {
+ "선": "thin"
+ },
+ "왼": {
+ "선": "thin"
+ },
+ "오른": {
+ "선": "thin"
+ }
+ }
+ },
+ {
+ "가로": "left",
+ "세로": "center",
+ "줄바꿈": true,
+ "테두리": {
+ "위": {
+ "선": "thin"
+ },
+ "아래": {
+ "선": "thin"
+ },
+ "왼": {
+ "선": "thin"
+ },
+ "오른": {
+ "선": "thin"
+ }
+ }
+ },
+ {
+ "굵게": true,
+ "채움": "#DDEBF7",
+ "가로": "center",
+ "세로": "center",
+ "줄바꿈": true,
+ "테두리": {
+ "위": {
+ "선": "thin"
+ },
+ "아래": {
+ "선": "thin"
+ },
+ "왼": {
+ "선": "thin"
+ },
+ "오른": {
+ "선": "thin"
+ }
+ }
+ },
+ {
+ "가로": "center",
+ "세로": "center",
+ "테두리": {
+ "위": {
+ "선": "thin"
+ },
+ "아래": {
+ "선": "thin"
+ },
+ "왼": {
+ "선": "thin"
+ },
+ "오른": {
+ "선": "thin"
+ }
+ }
+ },
+ {
+ "가로": "center",
+ "세로": "center",
+ "형식": "0.00",
+ "테두리": {
+ "위": {
+ "선": "thin"
+ },
+ "아래": {
+ "선": "thin"
+ },
+ "왼": {
+ "선": "thin"
+ },
+ "오른": {
+ "선": "thin"
+ }
+ }
+ },
+ {
+ "가로": "center",
+ "세로": "center",
+ "형식": "0",
+ "테두리": {
+ "위": {
+ "선": "thin"
+ },
+ "아래": {
+ "선": "thin"
+ },
+ "왼": {
+ "선": "thin"
+ },
+ "오른": {
+ "선": "thin"
+ }
+ }
+ },
+ {
+ "굵게": true,
+ "글자색": "#C00000",
+ "채움": "#FFC7CE",
+ "가로": "center",
+ "세로": "center",
+ "테두리": {
+ "위": {
+ "선": "thin"
+ },
+ "아래": {
+ "선": "thin"
+ },
+ "왼": {
+ "선": "thin"
+ },
+ "오른": {
+ "선": "thin"
+ }
+ }
+ },
+ {
+ "굵게": true,
+ "글자색": "#006100",
+ "채움": "#E2EFDA",
+ "가로": "center",
+ "세로": "center",
+ "테두리": {
+ "위": {
+ "선": "thin"
+ },
+ "아래": {
+ "선": "thin"
+ },
+ "왼": {
+ "선": "thin"
+ },
+ "오른": {
+ "선": "thin"
+ }
+ }
+ },
+ {
+ "크기": 12,
+ "굵게": true,
+ "글자색": "#C00000",
+ "가로": "center",
+ "세로": "center",
+ "줄바꿈": true
+ },
+ {
+ "가로": "center",
+ "세로": "center",
+ "형식": "#,##0.0",
+ "테두리": {
+ "위": {
+ "선": "thin"
+ },
+ "아래": {
+ "선": "thin"
+ },
+ "왼": {
+ "선": "thin"
+ },
+ "오른": {
+ "선": "thin"
+ }
+ }
+ },
+ {
+ "가로": "center",
+ "세로": "center",
+ "형식": "#,##0",
+ "테두리": {
+ "위": {
+ "선": "thin"
+ },
+ "아래": {
+ "선": "thin"
+ },
+ "왼": {
+ "선": "thin"
+ },
+ "오른": {
+ "선": "thin"
+ }
+ }
+ },
+ {
+ "가로": "left",
+ "세로": "top",
+ "줄바꿈": true,
+ "테두리": {
+ "위": {
+ "선": "thin"
+ },
+ "아래": {
+ "선": "thin"
+ },
+ "왼": {
+ "선": "thin"
+ },
+ "오른": {
+ "선": "thin"
+ }
+ }
+ },
+ {
+ "가로": "center",
+ "세로": "center",
+ "형식": "0.0",
+ "테두리": {
+ "위": {
+ "선": "thin"
+ },
+ "아래": {
+ "선": "thin"
+ },
+ "왼": {
+ "선": "thin"
+ },
+ "오른": {
+ "선": "thin"
+ }
+ }
+ },
+ {
+ "가로": "center",
+ "세로": "center",
+ "형식": "#,##0.00",
+ "테두리": {
+ "위": {
+ "선": "thin"
+ },
+ "아래": {
+ "선": "thin"
+ },
+ "왼": {
+ "선": "thin"
+ },
+ "오른": {
+ "선": "thin"
+ }
+ }
+ }
+ ],
+ "시트": [
+ {
+ "id": "s1",
+ "이름": "표지",
+ "칸": {
+ "B4": {
+ "값": "비탈면 안정성 검토 보고서",
+ "서식": 1
+ },
+ "C4": {
+ "서식": 1
+ },
+ "D4": {
+ "서식": 1
+ },
+ "E4": {
+ "서식": 1
+ },
+ "B5": {
+ "값": "임도 노선 비탈면 안정해석 결과",
+ "서식": 2
+ },
+ "C5": {
+ "서식": 2
+ },
+ "D5": {
+ "서식": 2
+ },
+ "E5": {
+ "서식": 2
+ },
+ "B8": {
+ "값": "사업명",
+ "서식": 4
+ },
+ "C8": {
+ "서식": 5
+ },
+ "D8": {
+ "서식": 5
+ },
+ "E8": {
+ "서식": 5
+ },
+ "B9": {
+ "값": "노선명",
+ "서식": 4
+ },
+ "C9": {
+ "서식": 5
+ },
+ "D9": {
+ "서식": 5
+ },
+ "E9": {
+ "서식": 5
+ },
+ "B10": {
+ "값": "발주처",
+ "서식": 4
+ },
+ "C10": {
+ "서식": 5
+ },
+ "D10": {
+ "서식": 5
+ },
+ "E10": {
+ "서식": 5
+ },
+ "B11": {
+ "값": "작성일",
+ "서식": 4
+ },
+ "C11": {
+ "서식": 5
+ },
+ "D11": {
+ "서식": 5
+ },
+ "E11": {
+ "서식": 5
+ },
+ "B12": {
+ "값": "작성자",
+ "서식": 4
+ },
+ "C12": {
+ "서식": 5
+ },
+ "D12": {
+ "서식": 5
+ },
+ "E12": {
+ "서식": 5
+ },
+ "B14": {
+ "값": "문헌 추정치 · 현장 시험 없음",
+ "서식": 12
+ },
+ "C14": {
+ "서식": 12
+ },
+ "D14": {
+ "서식": 12
+ },
+ "E14": {
+ "서식": 12
+ },
+ "B15": {
+ "값": "※ 지반 물성치는 문헌 · 기준값을 적용한 추정치이며 현장 조사 · 시험 성과를 반영하지 않았음.",
+ "서식": 2
+ },
+ "C15": {
+ "서식": 2
+ },
+ "D15": {
+ "서식": 2
+ },
+ "E15": {
+ "서식": 2
+ }
+ },
+ "병합": [
+ "B4:E4",
+ "B5:E5",
+ "C8:E8",
+ "C9:E9",
+ "C10:E10",
+ "C11:E11",
+ "C12:E12",
+ "B14:E14",
+ "B15:E15"
+ ],
+ "열": {
+ "A": {
+ "폭": 4
+ },
+ "B": {
+ "폭": 18
+ },
+ "C": {
+ "폭": 22
+ },
+ "D": {
+ "폭": 22
+ },
+ "E": {
+ "폭": 22
+ },
+ "F": {
+ "폭": 4
+ }
+ },
+ "행": {
+ "4": {
+ "높이": 60
+ },
+ "5": {
+ "높이": 24
+ },
+ "14": {
+ "높이": 30
+ },
+ "15": {
+ "높이": 30
+ },
+ "8": {
+ "높이": 30
+ },
+ "9": {
+ "높이": 30
+ },
+ "10": {
+ "높이": 30
+ },
+ "11": {
+ "높이": 30
+ },
+ "12": {
+ "높이": 30
+ }
+ },
+ "보기": {
+ "눈금선": false
+ },
+ "인쇄": {
+ "용지": "A4",
+ "방향": "portrait",
+ "맞춤": {
+ "폭": 1,
+ "높이": 0
+ },
+ "여백": {
+ "위": 25,
+ "아래": 20,
+ "왼": 20,
+ "오른": 20
+ }
+ }
+ },
+ {
+ "id": "s2",
+ "이름": "개요",
+ "칸": {
+ "A1": {
+ "값": "개요 및 판정 요약",
+ "서식": 1
+ },
+ "B1": {
+ "서식": 1
+ },
+ "C1": {
+ "서식": 1
+ },
+ "D1": {
+ "서식": 1
+ },
+ "E1": {
+ "서식": 1
+ },
+ "F1": {
+ "서식": 1
+ },
+ "A2": {
+ "값": "수치는 「측점표」 시트를 따라감 — 해석이 바뀌면 [보고서 갱신] 뒤 새 값으로 바뀜",
+ "서식": 2
+ },
+ "B2": {
+ "서식": 2
+ },
+ "C2": {
+ "서식": 2
+ },
+ "D2": {
+ "서식": 2
+ },
+ "E2": {
+ "서식": 2
+ },
+ "F2": {
+ "서식": 2
+ },
+ "A4": {
+ "값": "1. 해석 대상",
+ "서식": 3
+ },
+ "B4": {
+ "서식": 3
+ },
+ "C4": {
+ "서식": 3
+ },
+ "D4": {
+ "서식": 3
+ },
+ "E4": {
+ "서식": 3
+ },
+ "F4": {
+ "서식": 3
+ },
+ "A5": {
+ "값": "대상 측점 수",
+ "서식": 4
+ },
+ "B5": {
+ "식": "COUNTA(측점표!A6:A20000)",
+ "서식": 9
+ },
+ "C5": {
+ "값": "개소",
+ "서식": 7
+ },
+ "A6": {
+ "값": "해석 구간 연장",
+ "서식": 4
+ },
+ "B6": {
+ "식": "IF(B5=0,0,MAX(측점표!B6:B20000)-MIN(측점표!B6:B20000))",
+ "서식": 13
+ },
+ "C6": {
+ "값": "m",
+ "서식": 7
+ },
+ "A8": {
+ "값": "2. 조건별 최소 안전율",
+ "서식": 3
+ },
+ "B8": {
+ "서식": 3
+ },
+ "C8": {
+ "서식": 3
+ },
+ "D8": {
+ "서식": 3
+ },
+ "E8": {
+ "서식": 3
+ },
+ "F8": {
+ "서식": 3
+ },
+ "A9": {
+ "값": "조건",
+ "서식": 6
+ },
+ "B9": {
+ "값": "최소 안전율",
+ "서식": 6
+ },
+ "C9": {
+ "값": "기준 안전율",
+ "서식": 6
+ },
+ "D9": {
+ "값": "판정",
+ "서식": 6
+ },
+ "E9": {
+ "값": "최소 안전율 측점",
+ "서식": 6
+ },
+ "F9": {
+ "서식": 6
+ },
+ "A10": {
+ "값": "건기",
+ "서식": 4
+ },
+ "B10": {
+ "식": "IF(COUNT(측점표!D6:D20000)=0,\"-\",MIN(측점표!D6:D20000))",
+ "서식": 8
+ },
+ "C10": {
+ "식": "입력조건!B12",
+ "서식": 8
+ },
+ "D10": {
+ "식": "IF(B10=\"-\",\"-\",IF(B10>=C10,\"O.K\",\"N.G\"))",
+ "서식": 7
+ },
+ "E10": {
+ "식": "IF(B10=\"-\",\"-\",INDEX(측점표!A6:A20000,MATCH(B10,측점표!D6:D20000,0)))",
+ "서식": 7
+ },
+ "F10": {
+ "서식": 7
+ },
+ "A11": {
+ "값": "우기",
+ "서식": 4
+ },
+ "B11": {
+ "식": "IF(COUNT(측점표!E6:E20000)=0,\"-\",MIN(측점표!E6:E20000))",
+ "서식": 8
+ },
+ "C11": {
+ "식": "입력조건!B13",
+ "서식": 8
+ },
+ "D11": {
+ "식": "IF(B11=\"-\",\"-\",IF(B11>=C11,\"O.K\",\"N.G\"))",
+ "서식": 7
+ },
+ "E11": {
+ "식": "IF(B11=\"-\",\"-\",INDEX(측점표!A6:A20000,MATCH(B11,측점표!E6:E20000,0)))",
+ "서식": 7
+ },
+ "F11": {
+ "서식": 7
+ },
+ "A12": {
+ "값": "지진",
+ "서식": 4
+ },
+ "B12": {
+ "식": "IF(COUNT(측점표!F6:F20000)=0,\"-\",MIN(측점표!F6:F20000))",
+ "서식": 8
+ },
+ "C12": {
+ "식": "입력조건!B14",
+ "서식": 8
+ },
+ "D12": {
+ "식": "IF(B12=\"-\",\"-\",IF(B12>=C12,\"O.K\",\"N.G\"))",
+ "서식": 7
+ },
+ "E12": {
+ "식": "IF(B12=\"-\",\"-\",INDEX(측점표!A6:A20000,MATCH(B12,측점표!F6:F20000,0)))",
+ "서식": 7
+ },
+ "F12": {
+ "서식": 7
+ },
+ "A14": {
+ "값": "3. 종합 판정",
+ "서식": 3
+ },
+ "B14": {
+ "서식": 3
+ },
+ "C14": {
+ "서식": 3
+ },
+ "D14": {
+ "서식": 3
+ },
+ "E14": {
+ "서식": 3
+ },
+ "F14": {
+ "서식": 3
+ },
+ "A15": {
+ "값": "기준 미달(N.G) 측점 수",
+ "서식": 4
+ },
+ "B15": {
+ "식": "COUNTIF(측점표!J6:J20000,\"N.G\")",
+ "서식": 9
+ },
+ "C15": {
+ "값": "개소",
+ "서식": 7
+ },
+ "A16": {
+ "값": "종합 판정",
+ "서식": 4
+ },
+ "B16": {
+ "식": "IF(B5=0,\"-\",IF(B15=0,\"O.K\",\"N.G\"))",
+ "서식": 7
+ },
+ "A17": {
+ "값": "판정 글",
+ "서식": 4
+ },
+ "B17": {
+ "식": "IF(B5=0,\"해석 결과 없음\",\"대상 측점 \"&B5&\"개소 중 \"&B15&\"개소가 기준 안전율에 미달함. \"&IF(B15=0,\"전 구간이 기준을 만족함.\",\"미달 구간은 보강 대책 검토가 필요함.\"))",
+ "서식": 15
+ },
+ "C17": {
+ "서식": 15
+ },
+ "D17": {
+ "서식": 15
+ },
+ "E17": {
+ "서식": 15
+ },
+ "F17": {
+ "서식": 15
+ },
+ "B18": {
+ "서식": 15
+ },
+ "C18": {
+ "서식": 15
+ },
+ "D18": {
+ "서식": 15
+ },
+ "E18": {
+ "서식": 15
+ },
+ "F18": {
+ "서식": 15
+ },
+ "B19": {
+ "서식": 15
+ },
+ "C19": {
+ "서식": 15
+ },
+ "D19": {
+ "서식": 15
+ },
+ "E19": {
+ "서식": 15
+ },
+ "F19": {
+ "서식": 15
+ },
+ "A18": {
+ "서식": 4
+ },
+ "A19": {
+ "서식": 4
+ },
+ "A21": {
+ "값": "4. 의견",
+ "서식": 3
+ },
+ "B21": {
+ "서식": 3
+ },
+ "C21": {
+ "서식": 3
+ },
+ "D21": {
+ "서식": 3
+ },
+ "E21": {
+ "서식": 3
+ },
+ "F21": {
+ "서식": 3
+ },
+ "A22": {
+ "서식": 15
+ },
+ "B22": {
+ "서식": 15
+ },
+ "C22": {
+ "서식": 15
+ },
+ "D22": {
+ "서식": 15
+ },
+ "E22": {
+ "서식": 15
+ },
+ "F22": {
+ "서식": 15
+ },
+ "A23": {
+ "서식": 15
+ },
+ "B23": {
+ "서식": 15
+ },
+ "C23": {
+ "서식": 15
+ },
+ "D23": {
+ "서식": 15
+ },
+ "E23": {
+ "서식": 15
+ },
+ "F23": {
+ "서식": 15
+ },
+ "A24": {
+ "서식": 15
+ },
+ "B24": {
+ "서식": 15
+ },
+ "C24": {
+ "서식": 15
+ },
+ "D24": {
+ "서식": 15
+ },
+ "E24": {
+ "서식": 15
+ },
+ "F24": {
+ "서식": 15
+ },
+ "A25": {
+ "서식": 15
+ },
+ "B25": {
+ "서식": 15
+ },
+ "C25": {
+ "서식": 15
+ },
+ "D25": {
+ "서식": 15
+ },
+ "E25": {
+ "서식": 15
+ },
+ "F25": {
+ "서식": 15
+ },
+ "A26": {
+ "서식": 15
+ },
+ "B26": {
+ "서식": 15
+ },
+ "C26": {
+ "서식": 15
+ },
+ "D26": {
+ "서식": 15
+ },
+ "E26": {
+ "서식": 15
+ },
+ "F26": {
+ "서식": 15
+ },
+ "A27": {
+ "서식": 15
+ },
+ "B27": {
+ "서식": 15
+ },
+ "C27": {
+ "서식": 15
+ },
+ "D27": {
+ "서식": 15
+ },
+ "E27": {
+ "서식": 15
+ },
+ "F27": {
+ "서식": 15
+ },
+ "A28": {
+ "서식": 15
+ },
+ "B28": {
+ "서식": 15
+ },
+ "C28": {
+ "서식": 15
+ },
+ "D28": {
+ "서식": 15
+ },
+ "E28": {
+ "서식": 15
+ },
+ "F28": {
+ "서식": 15
+ },
+ "A29": {
+ "서식": 15
+ },
+ "B29": {
+ "서식": 15
+ },
+ "C29": {
+ "서식": 15
+ },
+ "D29": {
+ "서식": 15
+ },
+ "E29": {
+ "서식": 15
+ },
+ "F29": {
+ "서식": 15
+ },
+ "A30": {
+ "서식": 15
+ },
+ "B30": {
+ "서식": 15
+ },
+ "C30": {
+ "서식": 15
+ },
+ "D30": {
+ "서식": 15
+ },
+ "E30": {
+ "서식": 15
+ },
+ "F30": {
+ "서식": 15
+ }
+ },
+ "병합": [
+ "A1:F1",
+ "A2:F2",
+ "A4:F4",
+ "A8:F8",
+ "E9:F9",
+ "E10:F10",
+ "E11:F11",
+ "E12:F12",
+ "A14:F14",
+ "B17:F19",
+ "A17:A19",
+ "A21:F21",
+ "A22:F30"
+ ],
+ "열": {
+ "A": {
+ "폭": 20
+ },
+ "B": {
+ "폭": 16
+ },
+ "C": {
+ "폭": 16
+ },
+ "D": {
+ "폭": 14
+ },
+ "E": {
+ "폭": 16
+ },
+ "F": {
+ "폭": 16
+ }
+ },
+ "행": {
+ "1": {
+ "높이": 36
+ },
+ "2": {
+ "높이": 20
+ },
+ "9": {
+ "높이": 22
+ },
+ "5": {
+ "높이": 22
+ },
+ "6": {
+ "높이": 22
+ },
+ "10": {
+ "높이": 22
+ },
+ "11": {
+ "높이": 22
+ },
+ "12": {
+ "높이": 22
+ },
+ "15": {
+ "높이": 22
+ },
+ "16": {
+ "높이": 22
+ },
+ "17": {
+ "높이": 22
+ },
+ "18": {
+ "높이": 22
+ },
+ "19": {
+ "높이": 22
+ }
+ },
+ "보기": {
+ "눈금선": false
+ },
+ "인쇄": {
+ "용지": "A4",
+ "방향": "portrait",
+ "맞춤": {
+ "폭": 1,
+ "높이": 0
+ },
+ "여백": {
+ "위": 20,
+ "아래": 15,
+ "왼": 15,
+ "오른": 15
+ }
+ },
+ "조건부서식": [
+ {
+ "id": "ng_d",
+ "범위": [
+ {
+ "r0": 9,
+ "c0": 3,
+ "r1": 11,
+ "c1": 3
+ }
+ ],
+ "조건": {
+ "종류": "글포함",
+ "글": "N.G"
+ },
+ "스타일": {
+ "채움": "#FFC7CE",
+ "글자색": "#C00000",
+ "굵게": true
+ }
+ },
+ {
+ "id": "ng_b16",
+ "범위": [
+ {
+ "r0": 15,
+ "c0": 1,
+ "r1": 15,
+ "c1": 1
+ }
+ ],
+ "조건": {
+ "종류": "글포함",
+ "글": "N.G"
+ },
+ "스타일": {
+ "채움": "#FFC7CE",
+ "글자색": "#C00000",
+ "굵게": true
+ }
+ }
+ ]
+ },
+ {
+ "id": "s3",
+ "이름": "입력조건",
+ "칸": {
+ "A1": {
+ "값": "입력 조건",
+ "서식": 1
+ },
+ "B1": {
+ "서식": 1
+ },
+ "C1": {
+ "서식": 1
+ },
+ "D1": {
+ "서식": 1
+ },
+ "E1": {
+ "서식": 1
+ },
+ "F1": {
+ "서식": 1
+ },
+ "G1": {
+ "서식": 1
+ },
+ "A2": {
+ "값": "문헌 추정치 · 현장 시험 없음 — 지반 물성치는 문헌 · 기준값 적용",
+ "서식": 2
+ },
+ "B2": {
+ "서식": 2
+ },
+ "C2": {
+ "서식": 2
+ },
+ "D2": {
+ "서식": 2
+ },
+ "E2": {
+ "서식": 2
+ },
+ "F2": {
+ "서식": 2
+ },
+ "G2": {
+ "서식": 2
+ },
+ "A4": {
+ "값": "1. 해석 방법",
+ "서식": 3
+ },
+ "B4": {
+ "서식": 3
+ },
+ "C4": {
+ "서식": 3
+ },
+ "D4": {
+ "서식": 3
+ },
+ "E4": {
+ "서식": 3
+ },
+ "F4": {
+ "서식": 3
+ },
+ "G4": {
+ "서식": 3
+ },
+ "A5": {
+ "값": "해석 기법",
+ "서식": 4
+ },
+ "B5": {
+ "서식": 5
+ },
+ "C5": {
+ "서식": 5
+ },
+ "D5": {
+ "서식": 5
+ },
+ "E5": {
+ "서식": 5
+ },
+ "F5": {
+ "서식": 5
+ },
+ "G5": {
+ "서식": 5
+ },
+ "A6": {
+ "값": "활동면 탐색",
+ "서식": 4
+ },
+ "B6": {
+ "서식": 5
+ },
+ "C6": {
+ "서식": 5
+ },
+ "D6": {
+ "서식": 5
+ },
+ "E6": {
+ "서식": 5
+ },
+ "F6": {
+ "서식": 5
+ },
+ "G6": {
+ "서식": 5
+ },
+ "A7": {
+ "값": "3D 해석",
+ "서식": 4
+ },
+ "B7": {
+ "서식": 5
+ },
+ "C7": {
+ "서식": 5
+ },
+ "D7": {
+ "서식": 5
+ },
+ "E7": {
+ "서식": 5
+ },
+ "F7": {
+ "서식": 5
+ },
+ "G7": {
+ "서식": 5
+ },
+ "A8": {
+ "값": "적용 기준",
+ "서식": 4
+ },
+ "B8": {
+ "서식": 5
+ },
+ "C8": {
+ "서식": 5
+ },
+ "D8": {
+ "서식": 5
+ },
+ "E8": {
+ "서식": 5
+ },
+ "F8": {
+ "서식": 5
+ },
+ "G8": {
+ "서식": 5
+ },
+ "A10": {
+ "값": "2. 기준 안전율",
+ "서식": 3
+ },
+ "B10": {
+ "서식": 3
+ },
+ "C10": {
+ "서식": 3
+ },
+ "D10": {
+ "서식": 3
+ },
+ "E10": {
+ "서식": 3
+ },
+ "F10": {
+ "서식": 3
+ },
+ "G10": {
+ "서식": 3
+ },
+ "A11": {
+ "값": "조건",
+ "서식": 6
+ },
+ "B11": {
+ "값": "기준 안전율",
+ "서식": 6
+ },
+ "C11": {
+ "값": "비고",
+ "서식": 6
+ },
+ "D11": {
+ "서식": 6
+ },
+ "E11": {
+ "서식": 6
+ },
+ "F11": {
+ "서식": 6
+ },
+ "G11": {
+ "서식": 6
+ },
+ "A12": {
+ "값": "건기",
+ "서식": 4
+ },
+ "B12": {
+ "서식": 8
+ },
+ "C12": {
+ "서식": 5
+ },
+ "D12": {
+ "서식": 5
+ },
+ "E12": {
+ "서식": 5
+ },
+ "F12": {
+ "서식": 5
+ },
+ "G12": {
+ "서식": 5
+ },
+ "A13": {
+ "값": "우기",
+ "서식": 4
+ },
+ "B13": {
+ "서식": 8
+ },
+ "C13": {
+ "서식": 5
+ },
+ "D13": {
+ "서식": 5
+ },
+ "E13": {
+ "서식": 5
+ },
+ "F13": {
+ "서식": 5
+ },
+ "G13": {
+ "서식": 5
+ },
+ "A14": {
+ "값": "지진",
+ "서식": 4
+ },
+ "B14": {
+ "서식": 8
+ },
+ "C14": {
+ "서식": 5
+ },
+ "D14": {
+ "서식": 5
+ },
+ "E14": {
+ "서식": 5
+ },
+ "F14": {
+ "서식": 5
+ },
+ "G14": {
+ "서식": 5
+ },
+ "A16": {
+ "값": "3. 지하수 규칙",
+ "서식": 3
+ },
+ "B16": {
+ "서식": 3
+ },
+ "C16": {
+ "서식": 3
+ },
+ "D16": {
+ "서식": 3
+ },
+ "E16": {
+ "서식": 3
+ },
+ "F16": {
+ "서식": 3
+ },
+ "G16": {
+ "서식": 3
+ },
+ "A17": {
+ "값": "건기",
+ "서식": 4
+ },
+ "B17": {
+ "서식": 5
+ },
+ "C17": {
+ "서식": 5
+ },
+ "D17": {
+ "서식": 5
+ },
+ "E17": {
+ "서식": 5
+ },
+ "F17": {
+ "서식": 5
+ },
+ "G17": {
+ "서식": 5
+ },
+ "A18": {
+ "값": "우기",
+ "서식": 4
+ },
+ "B18": {
+ "서식": 5
+ },
+ "C18": {
+ "서식": 5
+ },
+ "D18": {
+ "서식": 5
+ },
+ "E18": {
+ "서식": 5
+ },
+ "F18": {
+ "서식": 5
+ },
+ "G18": {
+ "서식": 5
+ },
+ "A19": {
+ "값": "지진 시",
+ "서식": 4
+ },
+ "B19": {
+ "서식": 5
+ },
+ "C19": {
+ "서식": 5
+ },
+ "D19": {
+ "서식": 5
+ },
+ "E19": {
+ "서식": 5
+ },
+ "F19": {
+ "서식": 5
+ },
+ "G19": {
+ "서식": 5
+ },
+ "A21": {
+ "값": "4. 노면 하중",
+ "서식": 3
+ },
+ "B21": {
+ "서식": 3
+ },
+ "C21": {
+ "서식": 3
+ },
+ "D21": {
+ "서식": 3
+ },
+ "E21": {
+ "서식": 3
+ },
+ "F21": {
+ "서식": 3
+ },
+ "G21": {
+ "서식": 3
+ },
+ "A22": {
+ "값": "노면 하중",
+ "서식": 4
+ },
+ "B22": {
+ "서식": 16
+ },
+ "C22": {
+ "값": "kPa",
+ "서식": 7
+ },
+ "A24": {
+ "값": "5. 지반 물성치",
+ "서식": 3
+ },
+ "B24": {
+ "서식": 3
+ },
+ "C24": {
+ "서식": 3
+ },
+ "D24": {
+ "서식": 3
+ },
+ "E24": {
+ "서식": 3
+ },
+ "F24": {
+ "서식": 3
+ },
+ "G24": {
+ "서식": 3
+ },
+ "A25": {
+ "값": "지층",
+ "서식": 6
+ },
+ "B25": {
+ "값": "적용 구간",
+ "서식": 6
+ },
+ "C25": {
+ "값": "γt (kN/m³)",
+ "서식": 6
+ },
+ "D25": {
+ "값": "γsat (kN/m³)",
+ "서식": 6
+ },
+ "E25": {
+ "값": "c (kPa)",
+ "서식": 6
+ },
+ "F25": {
+ "값": "φ (°)",
+ "서식": 6
+ },
+ "G25": {
+ "값": "출처",
+ "서식": 6
+ },
+ "A26": {
+ "서식": 7
+ },
+ "B26": {
+ "서식": 7
+ },
+ "C26": {
+ "서식": 16
+ },
+ "D26": {
+ "서식": 16
+ },
+ "E26": {
+ "서식": 16
+ },
+ "F26": {
+ "서식": 16
+ },
+ "G26": {
+ "서식": 5
+ }
+ },
+ "병합": [
+ "A1:G1",
+ "A2:G2",
+ "A4:G4",
+ "B5:G5",
+ "B6:G6",
+ "B7:G7",
+ "B8:G8",
+ "A10:G10",
+ "C11:G11",
+ "C12:G12",
+ "C13:G13",
+ "C14:G14",
+ "A16:G16",
+ "B17:G17",
+ "B18:G18",
+ "B19:G19",
+ "A21:G21",
+ "A24:G24"
+ ],
+ "열": {
+ "A": {
+ "폭": 18
+ },
+ "B": {
+ "폭": 16
+ },
+ "C": {
+ "폭": 12
+ },
+ "D": {
+ "폭": 12
+ },
+ "E": {
+ "폭": 12
+ },
+ "F": {
+ "폭": 12
+ },
+ "G": {
+ "폭": 28
+ }
+ },
+ "행": {
+ "1": {
+ "높이": 36
+ },
+ "2": {
+ "높이": 20
+ },
+ "25": {
+ "높이": 32
+ },
+ "5": {
+ "높이": 22
+ },
+ "6": {
+ "높이": 22
+ },
+ "7": {
+ "높이": 22
+ },
+ "8": {
+ "높이": 22
+ },
+ "12": {
+ "높이": 22
+ },
+ "13": {
+ "높이": 22
+ },
+ "14": {
+ "높이": 22
+ },
+ "17": {
+ "높이": 22
+ },
+ "18": {
+ "높이": 22
+ },
+ "19": {
+ "높이": 22
+ }
+ },
+ "보기": {
+ "눈금선": false
+ },
+ "인쇄": {
+ "용지": "A4",
+ "방향": "portrait",
+ "맞춤": {
+ "폭": 1,
+ "높이": 0
+ },
+ "여백": {
+ "위": 15,
+ "아래": 15,
+ "왼": 10,
+ "오른": 10
+ },
+ "머리행": [
+ 25,
+ 25
+ ]
+ }
+ },
+ {
+ "id": "s4",
+ "이름": "측점표",
+ "칸": {
+ "A1": {
+ "값": "측점별 안전율",
+ "서식": 1
+ },
+ "B1": {
+ "서식": 1
+ },
+ "C1": {
+ "서식": 1
+ },
+ "D1": {
+ "서식": 1
+ },
+ "E1": {
+ "서식": 1
+ },
+ "F1": {
+ "서식": 1
+ },
+ "G1": {
+ "서식": 1
+ },
+ "H1": {
+ "서식": 1
+ },
+ "I1": {
+ "서식": 1
+ },
+ "J1": {
+ "서식": 1
+ },
+ "A2": {
+ "값": "판정 — 모든 조건의 안전율이 기준 이상이면 O.K · 하나라도 미달이면 N.G",
+ "서식": 2
+ },
+ "B2": {
+ "서식": 2
+ },
+ "C2": {
+ "서식": 2
+ },
+ "D2": {
+ "서식": 2
+ },
+ "E2": {
+ "서식": 2
+ },
+ "F2": {
+ "서식": 2
+ },
+ "G2": {
+ "서식": 2
+ },
+ "H2": {
+ "서식": 2
+ },
+ "I2": {
+ "서식": 2
+ },
+ "J2": {
+ "서식": 2
+ },
+ "A4": {
+ "값": "측점",
+ "서식": 6
+ },
+ "A5": {
+ "서식": 6
+ },
+ "B4": {
+ "값": "누가거리 (m)",
+ "서식": 6
+ },
+ "B5": {
+ "서식": 6
+ },
+ "C4": {
+ "값": "구분",
+ "서식": 6
+ },
+ "C5": {
+ "서식": 6
+ },
+ "D4": {
+ "값": "산정 안전율 Fs",
+ "서식": 6
+ },
+ "E4": {
+ "서식": 6
+ },
+ "F4": {
+ "서식": 6
+ },
+ "G4": {
+ "값": "기준 안전율",
+ "서식": 6
+ },
+ "H4": {
+ "서식": 6
+ },
+ "I4": {
+ "서식": 6
+ },
+ "J4": {
+ "값": "판정",
+ "서식": 6
+ },
+ "J5": {
+ "서식": 6
+ },
+ "D5": {
+ "값": "건기",
+ "서식": 6
+ },
+ "E5": {
+ "값": "우기",
+ "서식": 6
+ },
+ "F5": {
+ "값": "지진",
+ "서식": 6
+ },
+ "G5": {
+ "값": "건기",
+ "서식": 6
+ },
+ "H5": {
+ "값": "우기",
+ "서식": 6
+ },
+ "I5": {
+ "값": "지진",
+ "서식": 6
+ },
+ "A6": {
+ "서식": 7
+ },
+ "B6": {
+ "서식": 17
+ },
+ "C6": {
+ "서식": 7
+ },
+ "D6": {
+ "서식": 8
+ },
+ "E6": {
+ "서식": 8
+ },
+ "F6": {
+ "서식": 8
+ },
+ "G6": {
+ "서식": 8
+ },
+ "H6": {
+ "서식": 8
+ },
+ "I6": {
+ "서식": 8
+ },
+ "J6": {
+ "서식": 7
+ }
+ },
+ "병합": [
+ "A1:J1",
+ "A2:J2",
+ "A4:A5",
+ "B4:B5",
+ "C4:C5",
+ "D4:F4",
+ "G4:I4",
+ "J4:J5"
+ ],
+ "열": {
+ "A": {
+ "폭": 16
+ },
+ "B": {
+ "폭": 13
+ },
+ "C": {
+ "폭": 9
+ },
+ "D": {
+ "폭": 9
+ },
+ "E": {
+ "폭": 9
+ },
+ "F": {
+ "폭": 9
+ },
+ "G": {
+ "폭": 9
+ },
+ "H": {
+ "폭": 9
+ },
+ "I": {
+ "폭": 9
+ },
+ "J": {
+ "폭": 9
+ }
+ },
+ "행": {
+ "1": {
+ "높이": 36
+ },
+ "2": {
+ "높이": 20
+ },
+ "3": {
+ "높이": 8
+ },
+ "4": {
+ "높이": 22
+ },
+ "5": {
+ "높이": 22
+ }
+ },
+ "틀고정": {
+ "행": 5
+ },
+ "보기": {
+ "눈금선": false
+ },
+ "인쇄": {
+ "용지": "A4",
+ "방향": "landscape",
+ "맞춤": {
+ "폭": 1,
+ "높이": 0
+ },
+ "여백": {
+ "위": 15,
+ "아래": 15,
+ "왼": 10,
+ "오른": 10
+ },
+ "머리행": [
+ 4,
+ 5
+ ]
+ },
+ "조건부서식": [
+ {
+ "id": "ng_j",
+ "범위": [
+ {
+ "r0": 5,
+ "c0": 9,
+ "r1": 19999,
+ "c1": 9
+ }
+ ],
+ "조건": {
+ "종류": "글포함",
+ "글": "N.G"
+ },
+ "스타일": {
+ "채움": "#FFC7CE",
+ "글자색": "#C00000",
+ "굵게": true
+ }
+ }
+ ]
+ },
+ {
+ "id": "s5",
+ "이름": "대표단면",
+ "칸": {
+ "A1": {
+ "값": "대표 단면 ①",
+ "서식": 3
+ },
+ "B1": {
+ "서식": 3
+ },
+ "C1": {
+ "서식": 3
+ },
+ "D1": {
+ "서식": 3
+ },
+ "E1": {
+ "서식": 3
+ },
+ "F1": {
+ "서식": 3
+ },
+ "G1": {
+ "서식": 3
+ },
+ "H1": {
+ "서식": 3
+ },
+ "A2": {
+ "값": "선정 사유",
+ "서식": 4
+ },
+ "B2": {
+ "서식": 5
+ },
+ "C2": {
+ "서식": 5
+ },
+ "D2": {
+ "서식": 5
+ },
+ "E2": {
+ "서식": 5
+ },
+ "F2": {
+ "서식": 5
+ },
+ "G2": {
+ "서식": 5
+ },
+ "H2": {
+ "서식": 5
+ },
+ "A23": {
+ "값": "조건",
+ "서식": 6
+ },
+ "B23": {
+ "값": "해석 방법",
+ "서식": 6
+ },
+ "C23": {
+ "값": "안전율 Fs",
+ "서식": 6
+ },
+ "D23": {
+ "값": "기준",
+ "서식": 6
+ },
+ "E23": {
+ "값": "판정",
+ "서식": 6
+ },
+ "F23": {
+ "값": "활동면 반경 R (m)",
+ "서식": 6
+ },
+ "G23": {
+ "값": "중심 x (m)",
+ "서식": 6
+ },
+ "H23": {
+ "값": "중심 y (m)",
+ "서식": 6
+ },
+ "A24": {
+ "값": "건기",
+ "서식": 4
+ },
+ "B24": {
+ "서식": 7
+ },
+ "C24": {
+ "서식": 8
+ },
+ "D24": {
+ "서식": 8
+ },
+ "E24": {
+ "서식": 7
+ },
+ "F24": {
+ "서식": 13
+ },
+ "G24": {
+ "서식": 13
+ },
+ "H24": {
+ "서식": 13
+ },
+ "A25": {
+ "값": "우기",
+ "서식": 4
+ },
+ "B25": {
+ "서식": 7
+ },
+ "C25": {
+ "서식": 8
+ },
+ "D25": {
+ "서식": 8
+ },
+ "E25": {
+ "서식": 7
+ },
+ "F25": {
+ "서식": 13
+ },
+ "G25": {
+ "서식": 13
+ },
+ "H25": {
+ "서식": 13
+ },
+ "A26": {
+ "값": "지진",
+ "서식": 4
+ },
+ "B26": {
+ "서식": 7
+ },
+ "C26": {
+ "서식": 8
+ },
+ "D26": {
+ "서식": 8
+ },
+ "E26": {
+ "서식": 7
+ },
+ "F26": {
+ "서식": 13
+ },
+ "G26": {
+ "서식": 13
+ },
+ "H26": {
+ "서식": 13
+ },
+ "A27": {
+ "값": "비고",
+ "서식": 4
+ },
+ "B27": {
+ "서식": 5
+ },
+ "C27": {
+ "서식": 5
+ },
+ "D27": {
+ "서식": 5
+ },
+ "E27": {
+ "서식": 5
+ },
+ "F27": {
+ "서식": 5
+ },
+ "G27": {
+ "서식": 5
+ },
+ "H27": {
+ "서식": 5
+ }
+ },
+ "병합": [
+ "A1:H1",
+ "B2:H2",
+ "B27:H27"
+ ],
+ "열": {
+ "A": {
+ "폭": 12
+ },
+ "B": {
+ "폭": 12
+ },
+ "C": {
+ "폭": 12
+ },
+ "D": {
+ "폭": 12
+ },
+ "E": {
+ "폭": 12
+ },
+ "F": {
+ "폭": 12
+ },
+ "G": {
+ "폭": 12
+ },
+ "H": {
+ "폭": 12
+ }
+ },
+ "행": {
+ "1": {
+ "높이": 26
+ },
+ "2": {
+ "높이": 22
+ },
+ "3": {
+ "높이": 20
+ },
+ "4": {
+ "높이": 20
+ },
+ "5": {
+ "높이": 20
+ },
+ "6": {
+ "높이": 20
+ },
+ "7": {
+ "높이": 20
+ },
+ "8": {
+ "높이": 20
+ },
+ "9": {
+ "높이": 20
+ },
+ "10": {
+ "높이": 20
+ },
+ "11": {
+ "높이": 20
+ },
+ "12": {
+ "높이": 20
+ },
+ "13": {
+ "높이": 20
+ },
+ "14": {
+ "높이": 20
+ },
+ "15": {
+ "높이": 20
+ },
+ "16": {
+ "높이": 20
+ },
+ "17": {
+ "높이": 20
+ },
+ "18": {
+ "높이": 20
+ },
+ "19": {
+ "높이": 20
+ },
+ "20": {
+ "높이": 20
+ },
+ "21": {
+ "높이": 20
+ },
+ "22": {
+ "높이": 20
+ },
+ "23": {
+ "높이": 32
+ },
+ "24": {
+ "높이": 22
+ },
+ "25": {
+ "높이": 22
+ },
+ "26": {
+ "높이": 22
+ },
+ "27": {
+ "높이": 22
+ },
+ "28": {
+ "높이": 6
+ }
+ },
+ "그림": [
+ {
+ "열쇠": "fig/{측점키}.png",
+ "닻": "A3:H22",
+ "이름": "대표 단면 그림"
+ }
+ ],
+ "보기": {
+ "눈금선": false
+ },
+ "인쇄": {
+ "용지": "A4",
+ "방향": "portrait",
+ "맞춤": {
+ "폭": 1,
+ "높이": 0
+ },
+ "여백": {
+ "위": 15,
+ "아래": 15,
+ "왼": 10,
+ "오른": 10
+ },
+ "쪽나눔": [
+ 28
+ ]
+ },
+ "조건부서식": [
+ {
+ "id": "ng_e",
+ "범위": [
+ {
+ "r0": 23,
+ "c0": 4,
+ "r1": 25,
+ "c1": 4
+ }
+ ],
+ "조건": {
+ "종류": "글포함",
+ "글": "N.G"
+ },
+ "스타일": {
+ "채움": "#FFC7CE",
+ "글자색": "#C00000",
+ "굵게": true
+ }
+ }
+ ]
+ },
+ {
+ "id": "s6",
+ "이름": "3D요약",
+ "칸": {
+ "A1": {
+ "값": "3D 결과 요약",
+ "서식": 1
+ },
+ "B1": {
+ "서식": 1
+ },
+ "C1": {
+ "서식": 1
+ },
+ "D1": {
+ "서식": 1
+ },
+ "E1": {
+ "서식": 1
+ },
+ "F1": {
+ "서식": 1
+ },
+ "G1": {
+ "서식": 1
+ },
+ "H1": {
+ "서식": 1
+ },
+ "A2": {
+ "값": "3D 최소 안전율은 2D 결과와 대조 — 3D/2D 비가 1 보다 크면 2D 가 안전 쪽",
+ "서식": 2
+ },
+ "B2": {
+ "서식": 2
+ },
+ "C2": {
+ "서식": 2
+ },
+ "D2": {
+ "서식": 2
+ },
+ "E2": {
+ "서식": 2
+ },
+ "F2": {
+ "서식": 2
+ },
+ "G2": {
+ "서식": 2
+ },
+ "H2": {
+ "서식": 2
+ },
+ "A4": {
+ "값": "1. 평면 위험 띠",
+ "서식": 3
+ },
+ "B4": {
+ "서식": 3
+ },
+ "C4": {
+ "서식": 3
+ },
+ "D4": {
+ "서식": 3
+ },
+ "E4": {
+ "서식": 3
+ },
+ "F4": {
+ "서식": 3
+ },
+ "G4": {
+ "서식": 3
+ },
+ "H4": {
+ "서식": 3
+ },
+ "A18": {
+ "값": "2. 구간별 3D 안전율",
+ "서식": 3
+ },
+ "B18": {
+ "서식": 3
+ },
+ "C18": {
+ "서식": 3
+ },
+ "D18": {
+ "서식": 3
+ },
+ "E18": {
+ "서식": 3
+ },
+ "F18": {
+ "서식": 3
+ },
+ "G18": {
+ "서식": 3
+ },
+ "H18": {
+ "서식": 3
+ },
+ "A19": {
+ "값": "구간",
+ "서식": 6
+ },
+ "B19": {
+ "값": "시작 측점",
+ "서식": 6
+ },
+ "C19": {
+ "값": "끝 측점",
+ "서식": 6
+ },
+ "D19": {
+ "값": "3D 최소 Fs",
+ "서식": 6
+ },
+ "E19": {
+ "값": "2D 최소 Fs",
+ "서식": 6
+ },
+ "F19": {
+ "값": "3D/2D 비",
+ "서식": 6
+ },
+ "G19": {
+ "값": "임계 체적 (m³)",
+ "서식": 6
+ },
+ "H19": {
+ "값": "판정",
+ "서식": 6
+ },
+ "A20": {
+ "서식": 7
+ },
+ "B20": {
+ "서식": 7
+ },
+ "C20": {
+ "서식": 7
+ },
+ "D20": {
+ "서식": 8
+ },
+ "E20": {
+ "서식": 8
+ },
+ "F20": {
+ "식": "IF(E20>0,D20/E20,\"\")",
+ "서식": 8
+ },
+ "G20": {
+ "서식": 14
+ },
+ "H20": {
+ "서식": 7
+ }
+ },
+ "병합": [
+ "A1:H1",
+ "A2:H2",
+ "A4:H4",
+ "A18:H18"
+ ],
+ "열": {
+ "A": {
+ "폭": 12
+ },
+ "B": {
+ "폭": 13
+ },
+ "C": {
+ "폭": 13
+ },
+ "D": {
+ "폭": 11
+ },
+ "E": {
+ "폭": 11
+ },
+ "F": {
+ "폭": 10
+ },
+ "G": {
+ "폭": 13
+ },
+ "H": {
+ "폭": 9
+ }
+ },
+ "행": {
+ "1": {
+ "높이": 36
+ },
+ "2": {
+ "높이": 20
+ },
+ "4": {
+ "높이": 22
+ },
+ "18": {
+ "높이": 22
+ },
+ "19": {
+ "높이": 32
+ },
+ "5": {
+ "높이": 20
+ },
+ "6": {
+ "높이": 20
+ },
+ "7": {
+ "높이": 20
+ },
+ "8": {
+ "높이": 20
+ },
+ "9": {
+ "높이": 20
+ },
+ "10": {
+ "높이": 20
+ },
+ "11": {
+ "높이": 20
+ },
+ "12": {
+ "높이": 20
+ },
+ "13": {
+ "높이": 20
+ },
+ "14": {
+ "높이": 20
+ },
+ "15": {
+ "높이": 20
+ },
+ "16": {
+ "높이": 20
+ }
+ },
+ "그림": [
+ {
+ "열쇠": "fig/3d_band.png",
+ "닻": "A5:H16",
+ "이름": "평면 위험 띠"
+ }
+ ],
+ "보기": {
+ "눈금선": false
+ },
+ "인쇄": {
+ "용지": "A4",
+ "방향": "portrait",
+ "맞춤": {
+ "폭": 1,
+ "높이": 0
+ },
+ "여백": {
+ "위": 15,
+ "아래": 15,
+ "왼": 10,
+ "오른": 10
+ },
+ "머리행": [
+ 19,
+ 19
+ ]
+ },
+ "조건부서식": [
+ {
+ "id": "ng_h",
+ "범위": [
+ {
+ "r0": 19,
+ "c0": 7,
+ "r1": 19999,
+ "c1": 7
+ }
+ ],
+ "조건": {
+ "종류": "글포함",
+ "글": "N.G"
+ },
+ "스타일": {
+ "채움": "#FFC7CE",
+ "글자색": "#C00000",
+ "굵게": true
+ }
+ }
+ ]
+ }
+ ],
+ "활성": "s1"
+}
diff --git a/resources/master_template/report/stab_report_slots.md b/resources/master_template/report/stab_report_slots.md
new file mode 100644
index 000000000..3fb11f4c7
--- /dev/null
+++ b/resources/master_template/report/stab_report_slots.md
@@ -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 행 서식을 줄마다 복사.
diff --git a/resources/tester/test_528_project_groups.py b/resources/tester/test_528_project_groups.py
index 9f226bcf2..e63553e50 100644
--- a/resources/tester/test_528_project_groups.py
+++ b/resources/tester/test_528_project_groups.py
@@ -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)
diff --git a/resources/tester/test_530_2_plan_analyze.py b/resources/tester/test_530_2_plan_analyze.py
index 5d364e0af..1e50b9275 100644
--- a/resources/tester/test_530_2_plan_analyze.py
+++ b/resources/tester/test_530_2_plan_analyze.py
@@ -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:
diff --git a/resources/tester/test_531_3_plan_link.py b/resources/tester/test_531_3_plan_link.py
new file mode 100644
index 000000000..247f38edb
--- /dev/null
+++ b/resources/tester/test_531_3_plan_link.py
@@ -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
diff --git a/resources/tester/test_531_d04_export_finalize.py b/resources/tester/test_531_d04_export_finalize.py
new file mode 100644
index 000000000..ed191ff82
--- /dev/null
+++ b/resources/tester/test_531_d04_export_finalize.py
@@ -0,0 +1,411 @@
+# -*- coding: utf-8 -*-
+"""531-1 · 531-2 D04 예정노선 내보내기 · 메일 첨부 · 업체 지정 · 확정.
+
+- 만든 shp 를 B03 입력 분석(`analyze_input_metadata`)이 읽음 · 용화 shp 와 칸 · .prj · 인코딩 같음
+- 메일 조립(SMTP 가짜) — 첨부 이름 · 크기 · 첨부 없는 기존 호출 그대로
+- finalize / assignees 권한(산림청 아님 403) · 보낸 업체 건너뜀 · 메일 없음 · 분석 안 저장 409
+"""
+
+from __future__ import annotations
+
+import asyncio
+import io
+import struct
+import sys
+import zipfile
+from email import message_from_bytes
+from pathlib import Path
+from uuid import uuid4
+
+import pytest
+from fastapi import HTTPException
+from openpyxl import load_workbook
+from pyproj import Transformer
+
+ROOT = Path(__file__).resolve().parents[2]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+import common_util.common_util_email as email_mod # noqa: E402
+from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata # noqa: E402
+from D04_PlanConfirm import D04_PlanConfirm_Repository as repo # noqa: E402
+from D04_PlanConfirm import D04_PlanConfirm_Router as router # noqa: E402
+from D04_PlanConfirm.D04_PlanConfirm_Email import build_completion_mail # noqa: E402
+from D04_PlanConfirm.D04_PlanConfirm_Export import ( # noqa: E402
+ YearRoute,
+ build_export,
+ company_zip,
+)
+
+YONGHWA = ROOT / "resources/knowledge/original/실무문서/2026년 임도 용화/예정노선"
+YONGHWA_BASE = YONGHWA / "대상지_일월.용화.2.2" # 이름에 점이 있어 with_suffix 못 씀
+
+# 5186(중부원점) 좌표의 두 해 — 2026 끝 = 2027 시작
+Y2026 = [(210000.0, 380000.0), (210100.0, 380050.0), (210250.0, 380040.0)]
+Y2027 = [(210250.0, 380040.0), (210400.0, 380120.0)]
+
+
+def _yh(ext: str) -> Path:
+ return YONGHWA / f"대상지_일월.용화.2.2{ext}"
+
+
+def _bundle(with_xlsx: bool = True):
+ return build_export(
+ "시험 프로젝트",
+ "일월.용화",
+ [YearRoute(2027, Y2027, 5186), YearRoute(2026, Y2026, 5186)],
+ first_station_m=100.0,
+ with_xlsx=with_xlsx,
+ )
+
+
+def _dbf_fields(blob: bytes) -> list[tuple]:
+ count = (struct.unpack(" Path:
+ for name, data in files.items():
+ (tmp_path / name).write_bytes(data)
+ return next(tmp_path / n for n in files if n.endswith(".shp"))
+
+
+# ── 내보내기 ─────────────────────────────────────────────
+
+
+def test_year_shapefile_matches_yonghwa_format(tmp_path):
+ bundle = _bundle()
+ files = bundle.by_year[2026]
+ shp_name = next(n for n in files if n.endswith(".shp"))
+ stem = shp_name[: -len(".shp")]
+ assert stem.startswith("대상지_일월.용화.") and stem.endswith("_2026")
+
+ # 칸: 용화 두 칸(이름 · 형 · 폭 · 소수) 그대로 + 덧칸
+ fields = _dbf_fields(files[stem + ".dbf"])
+ assert fields[:2] == _dbf_fields((_yh(".dbf")).read_bytes())
+ assert fields[2:] == [("연도", "N", 4, 0), ("시점누가", "N", 11, 2), ("종점누가", "N", 11, 2)]
+ # .prj 원문 · .cpg · 도형 종류(폴리라인 3)
+ assert files[stem + ".prj"] == _yh(".prj").read_bytes()
+ assert files[stem + ".cpg"] == _yh(".cpg").read_bytes() == b"949"
+ assert struct.unpack("본문
", [("시험_예정노선_2026.zip", payload, "application/zip")]
+ )
+ email_mod._send_sync("a@b.kr", "제목", "본문
") # 기존 호출 그대로
+ with_file, plain = (message_from_bytes(m.as_bytes()) for m in _FakeSmtp.sent)
+
+ parts = [p for p in with_file.walk() if p.get_filename()]
+ assert [p.get_filename() for p in parts] == ["시험_예정노선_2026.zip"]
+ assert parts[0].get_content_type() == "application/zip"
+ assert parts[0].get_payload(decode=True) == payload
+ assert not [p for p in plain.walk() if p.get_filename()]
+ assert plain.get_content_type() == "multipart/alternative"
+
+
+def test_send_email_disabled_ok_with_attachments(monkeypatch):
+ monkeypatch.setattr(email_mod, "EMAIL_NOTIFICATION_ENABLED", False)
+ assert asyncio.run(email_mod.send_email("a@b.kr", "s", "h
")) is True
+ assert asyncio.run(
+ email_mod.send_email("a@b.kr", "s", "h
", [("a.zip", b"1", "application/zip")])
+ )
+
+
+def test_completion_mail_body():
+ subject, body = build_completion_mail(
+ project_name="용화 <예정>",
+ region="일월.용화",
+ year=2026,
+ length_m=2135.9,
+ recipient="가나다건설",
+ attachment_names=["용화_예정노선_2026.zip"],
+ )
+ assert "용화" in subject and "2026" in subject
+ assert "2026년" in body and "2.136 km" in body and "용화_예정노선_2026.zip" in body
+ assert "<예정>" in body # 이스케이프
+
+
+# ── API ──────────────────────────────────────────────────
+
+FOREST = {"user_id": 1, "email": "a@korea.go.kr", "role": "MEMBER", "company_id": 7}
+OUTSIDER = {"user_id": 2, "email": "b@company.co.kr", "role": "MASTER", "company_id": 8}
+
+
+def _route_row(year: int, points, start: float, end: float):
+ return {
+ "year": year,
+ "epsg": 5186,
+ "route_json": {
+ "planned": [list(p) + [0.0] for p in points],
+ "start_chainage_m": start,
+ "end_chainage_m": end,
+ },
+ "fingerprint": "f" * 40,
+ "confirmed_at": None,
+ }
+
+
+@pytest.fixture
+def fake_db(monkeypatch):
+ state = {
+ "years": [_route_row(2026, Y2026, 0.0, 330.0), _route_row(2027, Y2027, 330.0, 520.0)],
+ "assignees": [
+ {
+ "id": 1,
+ "year": 2026,
+ "company_id": None,
+ "company_name": "가나다건설",
+ "email": "ga@x.kr",
+ "notified_at": None,
+ "linked_project_id": None,
+ },
+ {
+ "id": 2,
+ "year": 2026,
+ "company_id": 9,
+ "company_name": "라마",
+ "email": "ra@x.kr",
+ "notified_at": "2026-10-01",
+ "linked_project_id": None,
+ },
+ {
+ "id": 3,
+ "year": 2027,
+ "company_id": None,
+ "company_name": "메일없음",
+ "email": None,
+ "notified_at": None,
+ "linked_project_id": None,
+ },
+ ],
+ "confirmed": [],
+ "notified": [],
+ "mails": [],
+ "applied": None,
+ }
+
+ async def project(_pid):
+ return {
+ "id": "p",
+ "name": "시험",
+ "region": "일월.용화",
+ "project_kind": "PLAN",
+ "route_start_m": 0.0,
+ }
+
+ async def years(_pid):
+ return state["years"]
+
+ async def assignees(_pid):
+ return state["assignees"]
+
+ async def confirmed(_pid, ys):
+ state["confirmed"] = ys
+
+ async def notified(aid):
+ state["notified"].append(aid)
+
+ async def apply(_pid, d, u, i):
+ state["applied"] = (d, u, i)
+
+ async def contact(cid):
+ return {"id": cid, "name": "등록사", "email": "master@reg.kr"} if cid == 9 else None
+
+ async def send(*, to_email, subject, body, attachments):
+ state["mails"].append((to_email, subject, attachments))
+ return True
+
+ monkeypatch.setattr(repo, "get_plan_project", project)
+ monkeypatch.setattr(repo, "list_year_routes", years)
+ monkeypatch.setattr(repo, "list_assignees", assignees)
+ monkeypatch.setattr(repo, "mark_confirmed", confirmed)
+ monkeypatch.setattr(repo, "mark_notified", notified)
+ monkeypatch.setattr(repo, "apply_assignee_changes", apply)
+ monkeypatch.setattr(repo, "get_company_contact", contact)
+ monkeypatch.setattr(router, "send_completion_mail", send)
+ return state
+
+
+def test_finalize_requires_forest_service(fake_db):
+ with pytest.raises(HTTPException) as err:
+ asyncio.run(router.finalize_plan(uuid4(), router.FinalizeRequest(), OUTSIDER))
+ assert err.value.status_code == 403
+ with pytest.raises(HTTPException) as err:
+ asyncio.run(
+ router.put_plan_assignees(uuid4(), router.AssigneesRequest(assignees=[]), OUTSIDER)
+ )
+ assert err.value.status_code == 403
+ assert fake_db["confirmed"] == [] and fake_db["mails"] == []
+
+
+def test_finalize_sends_zip_per_assignee(fake_db):
+ out = asyncio.run(router.finalize_plan(uuid4(), router.FinalizeRequest(), FOREST))
+ assert out["confirmed_years"] == [2026, 2027]
+ states = {(n["year"], n["company_name"]): n["state"] for n in out["notifications"]}
+ assert states == {
+ (2026, "가나다건설"): "sent",
+ (2026, "라마"): "already_sent", # 이미 보냄 → 건너뜀
+ (2027, "메일없음"): "no_email",
+ }
+ assert fake_db["notified"] == [1]
+ ((to, subject, attachments),) = fake_db["mails"]
+ assert to == "ga@x.kr" and "2026" in subject
+ name, blob, mime = attachments[0]
+ assert name == "시험_예정노선_2026.zip" and mime == "application/zip" and len(blob) > 1000
+ with zipfile.ZipFile(io.BytesIO(blob)) as archive:
+ assert any(n.endswith("_2026.shp") for n in archive.namelist())
+ assert not any("_2027" in n for n in archive.namelist()) # 다른 연도는 안 샘
+
+ # 다시 보내기
+ fake_db["mails"].clear()
+ again = asyncio.run(router.finalize_plan(uuid4(), router.FinalizeRequest(resend=True), FOREST))
+ assert sum(n["state"] == "sent" for n in again["notifications"]) == 2
+
+
+def test_finalize_unsaved_year_409(fake_db):
+ fake_db["years"][1]["route_json"] = None
+ with pytest.raises(HTTPException) as err:
+ asyncio.run(router.finalize_plan(uuid4(), router.FinalizeRequest(years=[2027]), FOREST))
+ assert err.value.status_code == 409
+ assert fake_db["confirmed"] == []
+
+
+def test_put_assignees_validation_and_diff(fake_db):
+ def put(items):
+ return asyncio.run(
+ router.put_plan_assignees(uuid4(), router.AssigneesRequest(assignees=items), FOREST)
+ )
+
+ for bad in (
+ router.AssigneeIn(year=2026, company_name="수기", email="메일아님"),
+ router.AssigneeIn(year=2026, email="x@y.kr"),
+ router.AssigneeIn(year=2030, company_name="없는해", email="x@y.kr"),
+ router.AssigneeIn(year=2026, company_id=99),
+ ):
+ with pytest.raises(HTTPException) as err:
+ put([bad])
+ assert err.value.status_code == 400
+ assert fake_db["applied"] is None
+
+ # 2026: 가나다 유지(메일 바꿈) · 라마(등록사 9) 유지 · 새 수기 추가 / 2027 은 안 보냈으니 그대로
+ put(
+ [
+ router.AssigneeIn(year=2026, company_name="가나다건설", email="GA@x.kr"),
+ router.AssigneeIn(year=2026, company_id=9),
+ router.AssigneeIn(year=2026, company_name="새업체", email="new@x.kr"),
+ ]
+ )
+ deleted, updated, inserted = fake_db["applied"]
+ assert deleted == [] # 2027 의 id 3 은 건드리지 않음
+ assert {u["id"] for u in updated} == {1, 2}
+ assert [i["company_name"] for i in inserted] == ["새업체"]
+ assert next(u for u in updated if u["id"] == 2)["email"] == "master@reg.kr"
+
+ put([router.AssigneeIn(year=2026, company_name="새업체", email="new@x.kr")])
+ assert sorted(fake_db["applied"][0]) == [1, 2] # 빠진 업체 지움
diff --git a/resources/tester/test_532_1_project_copy.py b/resources/tester/test_532_1_project_copy.py
new file mode 100644
index 000000000..0d7ac1031
--- /dev/null
+++ b/resources/tester/test_532_1_project_copy.py
@@ -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,)))
diff --git a/resources/tester/test_534_3_stab_report_form.py b/resources/tester/test_534_3_stab_report_form.py
new file mode 100644
index 000000000..89ac63e32
--- /dev/null
+++ b/resources/tester/test_534_3_stab_report_form.py
@@ -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 = (
+ ''
+ ' '
+)
+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(" ;