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}년 노선 파일을 보내 드립니다.

    +{table}
    +

    첨부 파일

    +
      {files}
    +

    압축을 풀면 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(";