diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index fc69cdb1..104a7fbe 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -71,6 +71,8 @@ import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합 import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리 import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색 import orderRoutes from "./routes/orderRoutes"; // 수주 관리 +import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 +import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -236,6 +238,8 @@ app.use("/api/code-merge", codeMergeRoutes); // 코드 병합 app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리 app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색 app.use("/api/orders", orderRoutes); // 수주 관리 +app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 +app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); @@ -280,7 +284,7 @@ app.listen(PORT, HOST, async () => { // 배치 스케줄러 초기화 try { - await BatchSchedulerService.initialize(); + await BatchSchedulerService.initializeScheduler(); logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`); } catch (error) { logger.error(`❌ 배치 스케줄러 초기화 실패:`, error); diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index 521f5250..e324c332 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -1,4 +1,7 @@ import { Response } from "express"; +import https from "https"; +import axios, { AxiosRequestConfig } from "axios"; +import { logger } from "../utils/logger"; import { AuthenticatedRequest } from "../middleware/authMiddleware"; import { DashboardService } from "../services/DashboardService"; import { @@ -7,6 +10,7 @@ import { DashboardListQuery, } from "../types/dashboard"; import { PostgreSQLService } from "../database/PostgreSQLService"; +import { ExternalRestApiConnectionService } from "../services/externalRestApiConnectionService"; /** * 대시보드 컨트롤러 @@ -415,7 +419,7 @@ export class DashboardController { limit: Math.min(parseInt(req.query.limit as string) || 20, 100), search: req.query.search as string, category: req.query.category as string, - createdBy: userId, // 본인이 만든 대시보드만 + // createdBy 제거 - 회사 대시보드 전체 표시 }; const result = await DashboardService.getDashboards( @@ -590,7 +594,14 @@ export class DashboardController { res: Response ): Promise { try { - const { url, method = "GET", headers = {}, queryParams = {} } = req.body; + const { + url, + method = "GET", + headers = {}, + queryParams = {}, + body, + externalConnectionId, // 프론트엔드에서 선택된 커넥션 ID를 전달받아야 함 + } = req.body; if (!url || typeof url !== "string") { res.status(400).json({ @@ -608,85 +619,153 @@ export class DashboardController { } }); - // 외부 API 호출 (타임아웃 30초) - // @ts-ignore - node-fetch dynamic import - const fetch = (await import("node-fetch")).default; - - // 타임아웃 설정 (Node.js 글로벌 AbortController 사용) - const controller = new (global as any).AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 60000); // 60초 (기상청 API는 느림) - - let response; - try { - response = await fetch(urlObj.toString(), { - method: method.toUpperCase(), - headers: { - "Content-Type": "application/json", - ...headers, - }, - signal: controller.signal, - }); - clearTimeout(timeoutId); - } catch (err: any) { - clearTimeout(timeoutId); - if (err.name === 'AbortError') { - throw new Error('외부 API 요청 타임아웃 (30초 초과)'); + // Axios 요청 설정 + const requestConfig: AxiosRequestConfig = { + url: urlObj.toString(), + method: method.toUpperCase(), + headers: { + "Content-Type": "application/json", + Accept: "application/json", + ...headers, + }, + timeout: 60000, // 60초 타임아웃 + validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리) + }; + + // 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용 + if (externalConnectionId) { + try { + // 사용자 회사 코드가 있으면 사용하고, 없으면 '*' (최고 관리자)로 시도 + let companyCode = req.user?.companyCode; + + if (!companyCode) { + companyCode = "*"; + } + + // 커넥션 로드 + const connectionResult = + await ExternalRestApiConnectionService.getConnectionById( + Number(externalConnectionId), + companyCode + ); + + if (connectionResult.success && connectionResult.data) { + const connection = connectionResult.data; + + // 인증 헤더 생성 (DB 토큰 등) + const authHeaders = + await ExternalRestApiConnectionService.getAuthHeaders( + connection.auth_type, + connection.auth_config, + connection.company_code + ); + + // 기존 헤더에 인증 헤더 병합 + requestConfig.headers = { + ...requestConfig.headers, + ...authHeaders, + }; + + // API Key가 Query Param인 경우 처리 + if ( + connection.auth_type === "api-key" && + connection.auth_config?.keyLocation === "query" && + connection.auth_config?.keyName && + connection.auth_config?.keyValue + ) { + const currentUrl = new URL(requestConfig.url!); + currentUrl.searchParams.append( + connection.auth_config.keyName, + connection.auth_config.keyValue + ); + requestConfig.url = currentUrl.toString(); + } + } + } catch (connError) { + logger.error( + `외부 커넥션(${externalConnectionId}) 정보 로드 및 인증 적용 실패:`, + connError + ); } - throw err; } - if (!response.ok) { + // Body 처리 + if (body) { + requestConfig.data = body; + } + + // TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응) + // ExternalRestApiConnectionService와 동일한 로직 적용 + const bypassDomains = ["thiratis.com"]; + const hostname = urlObj.hostname; + const shouldBypassTls = bypassDomains.some((domain) => + hostname.includes(domain) + ); + + if (shouldBypassTls) { + requestConfig.httpsAgent = new https.Agent({ + rejectUnauthorized: false, + }); + } + + // 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩 + const isKmaApi = urlObj.hostname.includes('kma.go.kr'); + if (isKmaApi) { + requestConfig.responseType = 'arraybuffer'; + } + + const response = await axios(requestConfig); + + if (response.status >= 400) { throw new Error( `외부 API 오류: ${response.status} ${response.statusText}` ); } - // Content-Type에 따라 응답 파싱 - const contentType = response.headers.get("content-type"); - let data: any; + let data = response.data; + const contentType = response.headers["content-type"]; - // 한글 인코딩 처리 (EUC-KR → UTF-8) - const isKoreanApi = urlObj.hostname.includes('kma.go.kr') || - urlObj.hostname.includes('data.go.kr'); - - if (isKoreanApi) { - // 한국 정부 API는 EUC-KR 인코딩 사용 - const buffer = await response.arrayBuffer(); - const decoder = new TextDecoder('euc-kr'); - const text = decoder.decode(buffer); + // 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR) + if (isKmaApi && Buffer.isBuffer(data)) { + const iconv = require('iconv-lite'); + const buffer = Buffer.from(data); + const utf8Text = buffer.toString('utf-8'); - try { - data = JSON.parse(text); - } catch { - data = { text, contentType }; - } - } else if (contentType && contentType.includes("application/json")) { - data = await response.json(); - } else if (contentType && contentType.includes("text/")) { - // 텍스트 응답 (CSV, 일반 텍스트 등) - const text = await response.text(); - data = { text, contentType }; - } else { - // 기타 응답 (JSON으로 시도) - try { - data = await response.json(); - } catch { - const text = await response.text(); - data = { text, contentType }; + // UTF-8로 정상 디코딩되었는지 확인 + if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') || + (utf8Text.includes('#START7777') && !utf8Text.includes('�'))) { + data = { text: utf8Text, contentType, encoding: 'utf-8' }; + } else { + // EUC-KR로 디코딩 + const eucKrText = iconv.decode(buffer, 'EUC-KR'); + data = { text: eucKrText, contentType, encoding: 'euc-kr' }; } } + // 텍스트 응답인 경우 포맷팅 + else if (typeof data === "string") { + data = { text: data, contentType }; + } res.status(200).json({ success: true, data, }); - } catch (error) { + } catch (error: any) { + const status = error.response?.status || 500; + const message = error.response?.statusText || error.message; + + logger.error("외부 API 호출 오류:", { + message, + status, + data: error.response?.data, + }); + res.status(500).json({ success: false, message: "외부 API 호출 중 오류가 발생했습니다.", error: process.env.NODE_ENV === "development" - ? (error as Error).message + ? message : "외부 API 호출 오류", }); } diff --git a/backend-node/src/controllers/batchController.ts b/backend-node/src/controllers/batchController.ts index 638edcd2..009e30a8 100644 --- a/backend-node/src/controllers/batchController.ts +++ b/backend-node/src/controllers/batchController.ts @@ -4,6 +4,7 @@ import { Request, Response } from "express"; import { BatchService } from "../services/batchService"; import { BatchSchedulerService } from "../services/batchSchedulerService"; +import { BatchExternalDbService } from "../services/batchExternalDbService"; import { BatchConfigFilter, CreateBatchConfigRequest, @@ -63,7 +64,7 @@ export class BatchController { res: Response ) { try { - const result = await BatchService.getAvailableConnections(); + const result = await BatchExternalDbService.getAvailableConnections(); if (result.success) { res.json(result); @@ -99,8 +100,8 @@ export class BatchController { } const connectionId = type === "external" ? Number(id) : undefined; - const result = await BatchService.getTablesFromConnection( - type, + const result = await BatchService.getTables( + type as "internal" | "external", connectionId ); @@ -142,10 +143,10 @@ export class BatchController { } const connectionId = type === "external" ? Number(id) : undefined; - const result = await BatchService.getTableColumns( - type, - connectionId, - tableName + const result = await BatchService.getColumns( + tableName, + type as "internal" | "external", + connectionId ); if (result.success) { diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index 61194485..05aece84 100644 --- a/backend-node/src/controllers/batchManagementController.ts +++ b/backend-node/src/controllers/batchManagementController.ts @@ -331,8 +331,11 @@ export class BatchManagementController { const duration = endTime.getTime() - startTime.getTime(); // executionLog가 정의되어 있는지 확인 - if (typeof executionLog !== "undefined") { - await BatchService.updateExecutionLog(executionLog.id, { + if (typeof executionLog !== "undefined" && executionLog) { + const { BatchExecutionLogService } = await import( + "../services/batchExecutionLogService" + ); + await BatchExecutionLogService.updateExecutionLog(executionLog.id, { execution_status: "FAILED", end_time: endTime, duration_ms: duration, @@ -594,7 +597,7 @@ export class BatchManagementController { if (result.success && result.data) { // 스케줄러에 자동 등록 ✅ try { - await BatchSchedulerService.scheduleBatchConfig(result.data); + await BatchSchedulerService.scheduleBatch(result.data); console.log( `✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})` ); diff --git a/backend-node/src/controllers/digitalTwinLayoutController.ts b/backend-node/src/controllers/digitalTwinLayoutController.ts index d7ecbae1..f95ed0e2 100644 --- a/backend-node/src/controllers/digitalTwinLayoutController.ts +++ b/backend-node/src/controllers/digitalTwinLayoutController.ts @@ -22,11 +22,19 @@ export const getLayouts = async ( LEFT JOIN user_info u1 ON l.created_by = u1.user_id LEFT JOIN user_info u2 ON l.updated_by = u2.user_id LEFT JOIN digital_twin_objects o ON l.id = o.layout_id - WHERE l.company_code = $1 `; - const params: any[] = [companyCode]; - let paramIndex = 2; + const params: any[] = []; + let paramIndex = 1; + + // 최고 관리자는 모든 레이아웃 조회 가능 + if (companyCode && companyCode !== '*') { + query += ` WHERE l.company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } else { + query += ` WHERE 1=1`; + } if (externalDbConnectionId) { query += ` AND l.external_db_connection_id = $${paramIndex}`; @@ -75,14 +83,27 @@ export const getLayoutById = async ( const companyCode = req.user?.companyCode; const { id } = req.params; - // 레이아웃 기본 정보 - const layoutQuery = ` - SELECT l.* - FROM digital_twin_layout l - WHERE l.id = $1 AND l.company_code = $2 - `; + // 레이아웃 기본 정보 - 최고 관리자는 모든 레이아웃 조회 가능 + let layoutQuery: string; + let layoutParams: any[]; - const layoutResult = await pool.query(layoutQuery, [id, companyCode]); + if (companyCode && companyCode !== '*') { + layoutQuery = ` + SELECT l.* + FROM digital_twin_layout l + WHERE l.id = $1 AND l.company_code = $2 + `; + layoutParams = [id, companyCode]; + } else { + layoutQuery = ` + SELECT l.* + FROM digital_twin_layout l + WHERE l.id = $1 + `; + layoutParams = [id]; + } + + const layoutResult = await pool.query(layoutQuery, layoutParams); if (layoutResult.rowCount === 0) { return res.status(404).json({ diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 9b8ef6fc..30364189 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -203,7 +203,7 @@ export const updateFormDataPartial = async ( }; const result = await dynamicFormService.updateFormDataPartial( - parseInt(id), + id, // 🔧 parseInt 제거 - UUID 문자열도 지원 tableName, originalData, newDataWithMeta @@ -419,3 +419,188 @@ export const getTableColumns = async ( }); } }; + +// 특정 필드만 업데이트 (다른 테이블 지원) +export const updateFieldValue = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + const { tableName, keyField, keyValue, updateField, updateValue } = req.body; + + console.log("🔄 [updateFieldValue] 요청:", { + tableName, + keyField, + keyValue, + updateField, + updateValue, + userId, + companyCode, + }); + + // 필수 필드 검증 + if (!tableName || !keyField || keyValue === undefined || !updateField || updateValue === undefined) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)", + }); + } + + // SQL 인젝션 방지를 위한 테이블명/컬럼명 검증 + const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + if (!validNamePattern.test(tableName) || !validNamePattern.test(keyField) || !validNamePattern.test(updateField)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명 또는 컬럼명입니다.", + }); + } + + // 업데이트 쿼리 실행 + const result = await dynamicFormService.updateFieldValue( + tableName, + keyField, + keyValue, + updateField, + updateValue, + companyCode, + userId + ); + + console.log("✅ [updateFieldValue] 성공:", result); + + res.json({ + success: true, + data: result, + message: "필드 값이 업데이트되었습니다.", + }); + } catch (error: any) { + console.error("❌ [updateFieldValue] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "필드 업데이트에 실패했습니다.", + }); + } +}; + +/** + * 위치 이력 저장 (연속 위치 추적용) + * POST /api/dynamic-form/location-history + */ +export const saveLocationHistory = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + const { + latitude, + longitude, + accuracy, + altitude, + speed, + heading, + tripId, + tripStatus, + departure, + arrival, + departureName, + destinationName, + recordedAt, + vehicleId, + } = req.body; + + console.log("📍 [saveLocationHistory] 요청:", { + userId, + companyCode, + latitude, + longitude, + tripId, + }); + + // 필수 필드 검증 + if (latitude === undefined || longitude === undefined) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (latitude, longitude)", + }); + } + + const result = await dynamicFormService.saveLocationHistory({ + userId, + companyCode, + latitude, + longitude, + accuracy, + altitude, + speed, + heading, + tripId, + tripStatus: tripStatus || "active", + departure, + arrival, + departureName, + destinationName, + recordedAt: recordedAt || new Date().toISOString(), + vehicleId, + }); + + console.log("✅ [saveLocationHistory] 성공:", result); + + res.json({ + success: true, + data: result, + message: "위치 이력이 저장되었습니다.", + }); + } catch (error: any) { + console.error("❌ [saveLocationHistory] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "위치 이력 저장에 실패했습니다.", + }); + } +}; + +/** + * 위치 이력 조회 (경로 조회용) + * GET /api/dynamic-form/location-history/:tripId + */ +export const getLocationHistory = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { tripId } = req.params; + const { userId, startDate, endDate, limit } = req.query; + + console.log("📍 [getLocationHistory] 요청:", { + tripId, + userId, + startDate, + endDate, + limit, + }); + + const result = await dynamicFormService.getLocationHistory({ + companyCode, + tripId, + userId: userId as string, + startDate: startDate as string, + endDate: endDate as string, + limit: limit ? parseInt(limit as string) : 1000, + }); + + res.json({ + success: true, + data: result, + count: result.length, + }); + } catch (error: any) { + console.error("❌ [getLocationHistory] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "위치 이력 조회에 실패했습니다.", + }); + } +}; diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index 85ad2259..e03bfe25 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -32,8 +32,17 @@ export class FlowController { */ createFlowDefinition = async (req: Request, res: Response): Promise => { try { - const { name, description, tableName, dbSourceType, dbConnectionId } = - req.body; + const { + name, + description, + tableName, + dbSourceType, + dbConnectionId, + // REST API 관련 필드 + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, + } = req.body; const userId = (req as any).user?.userId || "system"; const userCompanyCode = (req as any).user?.companyCode; @@ -43,6 +52,9 @@ export class FlowController { tableName, dbSourceType, dbConnectionId, + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, userCompanyCode, }); @@ -54,8 +66,11 @@ export class FlowController { return; } - // 테이블 이름이 제공된 경우에만 존재 확인 - if (tableName) { + // REST API인 경우 테이블 존재 확인 스킵 + const isRestApi = dbSourceType === "restapi"; + + // 테이블 이름이 제공된 경우에만 존재 확인 (REST API 제외) + if (tableName && !isRestApi && !tableName.startsWith("_restapi_")) { const tableExists = await this.flowDefinitionService.checkTableExists(tableName); if (!tableExists) { @@ -68,7 +83,16 @@ export class FlowController { } const flowDef = await this.flowDefinitionService.create( - { name, description, tableName, dbSourceType, dbConnectionId }, + { + name, + description, + tableName, + dbSourceType, + dbConnectionId, + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, + }, userId, userCompanyCode ); diff --git a/backend-node/src/controllers/screenEmbeddingController.ts b/backend-node/src/controllers/screenEmbeddingController.ts new file mode 100644 index 00000000..497d99db --- /dev/null +++ b/backend-node/src/controllers/screenEmbeddingController.ts @@ -0,0 +1,925 @@ +/** + * 화면 임베딩 및 데이터 전달 시스템 컨트롤러 + */ + +import { Request, Response } from "express"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; +import { AuthenticatedRequest } from "../types/auth"; + +const pool = getPool(); + +// ============================================ +// 1. 화면 임베딩 API +// ============================================ + +/** + * 화면 임베딩 목록 조회 + * GET /api/screen-embedding?parentScreenId=1 + */ +export async function getScreenEmbeddings(req: AuthenticatedRequest, res: Response) { + try { + const { parentScreenId } = req.query; + const companyCode = req.user!.companyCode; + + if (!parentScreenId) { + return res.status(400).json({ + success: false, + message: "부모 화면 ID가 필요합니다.", + }); + } + + const query = ` + SELECT + se.*, + ps.screen_name as parent_screen_name, + cs.screen_name as child_screen_name + FROM screen_embedding se + LEFT JOIN screen_definitions ps ON se.parent_screen_id = ps.screen_id + LEFT JOIN screen_definitions cs ON se.child_screen_id = cs.screen_id + WHERE se.parent_screen_id = $1 + AND se.company_code = $2 + ORDER BY se.position, se.created_at + `; + + const result = await pool.query(query, [parentScreenId, companyCode]); + + logger.info("화면 임베딩 목록 조회", { + companyCode, + parentScreenId, + count: result.rowCount, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("화면 임베딩 목록 조회 실패", error); + return res.status(500).json({ + success: false, + message: "화면 임베딩 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +} + +/** + * 화면 임베딩 상세 조회 + * GET /api/screen-embedding/:id + */ +export async function getScreenEmbeddingById(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const companyCode = req.user!.companyCode; + + const query = ` + SELECT + se.*, + ps.screen_name as parent_screen_name, + cs.screen_name as child_screen_name + FROM screen_embedding se + LEFT JOIN screen_definitions ps ON se.parent_screen_id = ps.screen_id + LEFT JOIN screen_definitions cs ON se.child_screen_id = cs.screen_id + WHERE se.id = $1 + AND se.company_code = $2 + `; + + const result = await pool.query(query, [id, companyCode]); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "화면 임베딩 설정을 찾을 수 없습니다.", + }); + } + + logger.info("화면 임베딩 상세 조회", { companyCode, id }); + + return res.json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("화면 임베딩 상세 조회 실패", error); + return res.status(500).json({ + success: false, + message: "화면 임베딩 상세 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +} + +/** + * 화면 임베딩 생성 + * POST /api/screen-embedding + */ +export async function createScreenEmbedding(req: AuthenticatedRequest, res: Response) { + try { + const { + parentScreenId, + childScreenId, + position, + mode, + config = {}, + } = req.body; + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + // 필수 필드 검증 + if (!parentScreenId || !childScreenId || !position || !mode) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다.", + }); + } + + const query = ` + INSERT INTO screen_embedding ( + parent_screen_id, child_screen_id, position, mode, + config, company_code, created_by, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) + RETURNING * + `; + + const result = await pool.query(query, [ + parentScreenId, + childScreenId, + position, + mode, + JSON.stringify(config), + companyCode, + userId, + ]); + + logger.info("화면 임베딩 생성", { + companyCode, + userId, + id: result.rows[0].id, + }); + + return res.status(201).json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("화면 임베딩 생성 실패", error); + + // 유니크 제약조건 위반 + if (error.code === "23505") { + return res.status(409).json({ + success: false, + message: "이미 동일한 임베딩 설정이 존재합니다.", + }); + } + + return res.status(500).json({ + success: false, + message: "화면 임베딩 생성 중 오류가 발생했습니다.", + error: error.message, + }); + } +} + +/** + * 화면 임베딩 수정 + * PUT /api/screen-embedding/:id + */ +export async function updateScreenEmbedding(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const { position, mode, config } = req.body; + const companyCode = req.user!.companyCode; + + const updates: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (position) { + updates.push(`position = $${paramIndex++}`); + values.push(position); + } + + if (mode) { + updates.push(`mode = $${paramIndex++}`); + values.push(mode); + } + + if (config) { + updates.push(`config = $${paramIndex++}`); + values.push(JSON.stringify(config)); + } + + if (updates.length === 0) { + return res.status(400).json({ + success: false, + message: "수정할 내용이 없습니다.", + }); + } + + updates.push(`updated_at = NOW()`); + + values.push(id, companyCode); + + const query = ` + UPDATE screen_embedding + SET ${updates.join(", ")} + WHERE id = $${paramIndex++} + AND company_code = $${paramIndex++} + RETURNING * + `; + + const result = await pool.query(query, values); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "화면 임베딩 설정을 찾을 수 없습니다.", + }); + } + + logger.info("화면 임베딩 수정", { companyCode, id }); + + return res.json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("화면 임베딩 수정 실패", error); + return res.status(500).json({ + success: false, + message: "화면 임베딩 수정 중 오류가 발생했습니다.", + error: error.message, + }); + } +} + +/** + * 화면 임베딩 삭제 + * DELETE /api/screen-embedding/:id + */ +export async function deleteScreenEmbedding(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const companyCode = req.user!.companyCode; + + const query = ` + DELETE FROM screen_embedding + WHERE id = $1 AND company_code = $2 + RETURNING id + `; + + const result = await pool.query(query, [id, companyCode]); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "화면 임베딩 설정을 찾을 수 없습니다.", + }); + } + + logger.info("화면 임베딩 삭제", { companyCode, id }); + + return res.json({ + success: true, + message: "화면 임베딩이 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("화면 임베딩 삭제 실패", error); + return res.status(500).json({ + success: false, + message: "화면 임베딩 삭제 중 오류가 발생했습니다.", + error: error.message, + }); + } +} + +// ============================================ +// 2. 데이터 전달 API +// ============================================ + +/** + * 데이터 전달 설정 조회 + * GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2 + */ +export async function getScreenDataTransfer(req: AuthenticatedRequest, res: Response) { + try { + const { sourceScreenId, targetScreenId } = req.query; + const companyCode = req.user!.companyCode; + + if (!sourceScreenId || !targetScreenId) { + return res.status(400).json({ + success: false, + message: "소스 화면 ID와 타겟 화면 ID가 필요합니다.", + }); + } + + const query = ` + SELECT + sdt.*, + ss.screen_name as source_screen_name, + ts.screen_name as target_screen_name + FROM screen_data_transfer sdt + LEFT JOIN screen_definitions ss ON sdt.source_screen_id = ss.screen_id + LEFT JOIN screen_definitions ts ON sdt.target_screen_id = ts.screen_id + WHERE sdt.source_screen_id = $1 + AND sdt.target_screen_id = $2 + AND sdt.company_code = $3 + `; + + const result = await pool.query(query, [ + sourceScreenId, + targetScreenId, + companyCode, + ]); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "데이터 전달 설정을 찾을 수 없습니다.", + }); + } + + logger.info("데이터 전달 설정 조회", { + companyCode, + sourceScreenId, + targetScreenId, + }); + + return res.json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("데이터 전달 설정 조회 실패", error); + return res.status(500).json({ + success: false, + message: "데이터 전달 설정 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +} + +/** + * 데이터 전달 설정 생성 + * POST /api/screen-data-transfer + */ +export async function createScreenDataTransfer(req: AuthenticatedRequest, res: Response) { + try { + const { + sourceScreenId, + targetScreenId, + sourceComponentId, + sourceComponentType, + dataReceivers, + buttonConfig, + } = req.body; + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + // 필수 필드 검증 + if (!sourceScreenId || !targetScreenId || !dataReceivers) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다.", + }); + } + + const query = ` + INSERT INTO screen_data_transfer ( + source_screen_id, target_screen_id, source_component_id, source_component_type, + data_receivers, button_config, company_code, created_by, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW()) + RETURNING * + `; + + const result = await pool.query(query, [ + sourceScreenId, + targetScreenId, + sourceComponentId, + sourceComponentType, + JSON.stringify(dataReceivers), + JSON.stringify(buttonConfig || {}), + companyCode, + userId, + ]); + + logger.info("데이터 전달 설정 생성", { + companyCode, + userId, + id: result.rows[0].id, + }); + + return res.status(201).json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("데이터 전달 설정 생성 실패", error); + + // 유니크 제약조건 위반 + if (error.code === "23505") { + return res.status(409).json({ + success: false, + message: "이미 동일한 데이터 전달 설정이 존재합니다.", + }); + } + + return res.status(500).json({ + success: false, + message: "데이터 전달 설정 생성 중 오류가 발생했습니다.", + error: error.message, + }); + } +} + +/** + * 데이터 전달 설정 수정 + * PUT /api/screen-data-transfer/:id + */ +export async function updateScreenDataTransfer(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const { dataReceivers, buttonConfig } = req.body; + const companyCode = req.user!.companyCode; + + const updates: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dataReceivers) { + updates.push(`data_receivers = $${paramIndex++}`); + values.push(JSON.stringify(dataReceivers)); + } + + if (buttonConfig) { + updates.push(`button_config = $${paramIndex++}`); + values.push(JSON.stringify(buttonConfig)); + } + + if (updates.length === 0) { + return res.status(400).json({ + success: false, + message: "수정할 내용이 없습니다.", + }); + } + + updates.push(`updated_at = NOW()`); + + values.push(id, companyCode); + + const query = ` + UPDATE screen_data_transfer + SET ${updates.join(", ")} + WHERE id = $${paramIndex++} + AND company_code = $${paramIndex++} + RETURNING * + `; + + const result = await pool.query(query, values); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "데이터 전달 설정을 찾을 수 없습니다.", + }); + } + + logger.info("데이터 전달 설정 수정", { companyCode, id }); + + return res.json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("데이터 전달 설정 수정 실패", error); + return res.status(500).json({ + success: false, + message: "데이터 전달 설정 수정 중 오류가 발생했습니다.", + error: error.message, + }); + } +} + +/** + * 데이터 전달 설정 삭제 + * DELETE /api/screen-data-transfer/:id + */ +export async function deleteScreenDataTransfer(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const companyCode = req.user!.companyCode; + + const query = ` + DELETE FROM screen_data_transfer + WHERE id = $1 AND company_code = $2 + RETURNING id + `; + + const result = await pool.query(query, [id, companyCode]); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "데이터 전달 설정을 찾을 수 없습니다.", + }); + } + + logger.info("데이터 전달 설정 삭제", { companyCode, id }); + + return res.json({ + success: true, + message: "데이터 전달 설정이 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("데이터 전달 설정 삭제 실패", error); + return res.status(500).json({ + success: false, + message: "데이터 전달 설정 삭제 중 오류가 발생했습니다.", + error: error.message, + }); + } +} + +// ============================================ +// 3. 분할 패널 API +// ============================================ + +/** + * 분할 패널 설정 조회 + * GET /api/screen-split-panel/:screenId + */ +export async function getScreenSplitPanel(req: AuthenticatedRequest, res: Response) { + try { + const { screenId } = req.params; + const companyCode = req.user!.companyCode; + + const query = ` + SELECT + ssp.*, + le.parent_screen_id as le_parent_screen_id, + le.child_screen_id as le_child_screen_id, + le.position as le_position, + le.mode as le_mode, + le.config as le_config, + re.parent_screen_id as re_parent_screen_id, + re.child_screen_id as re_child_screen_id, + re.position as re_position, + re.mode as re_mode, + re.config as re_config, + sdt.source_screen_id, + sdt.target_screen_id, + sdt.source_component_id, + sdt.source_component_type, + sdt.data_receivers, + sdt.button_config + FROM screen_split_panel ssp + LEFT JOIN screen_embedding le ON ssp.left_embedding_id = le.id + LEFT JOIN screen_embedding re ON ssp.right_embedding_id = re.id + LEFT JOIN screen_data_transfer sdt ON ssp.data_transfer_id = sdt.id + WHERE ssp.screen_id = $1 + AND ssp.company_code = $2 + `; + + const result = await pool.query(query, [screenId, companyCode]); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "분할 패널 설정을 찾을 수 없습니다.", + }); + } + + const row = result.rows[0]; + + // 데이터 구조화 + const data = { + id: row.id, + screenId: row.screen_id, + leftEmbeddingId: row.left_embedding_id, + rightEmbeddingId: row.right_embedding_id, + dataTransferId: row.data_transfer_id, + layoutConfig: row.layout_config, + companyCode: row.company_code, + createdAt: row.created_at, + updatedAt: row.updated_at, + leftEmbedding: row.le_child_screen_id + ? { + id: row.left_embedding_id, + parentScreenId: row.le_parent_screen_id, + childScreenId: row.le_child_screen_id, + position: row.le_position, + mode: row.le_mode, + config: row.le_config, + } + : null, + rightEmbedding: row.re_child_screen_id + ? { + id: row.right_embedding_id, + parentScreenId: row.re_parent_screen_id, + childScreenId: row.re_child_screen_id, + position: row.re_position, + mode: row.re_mode, + config: row.re_config, + } + : null, + dataTransfer: row.source_screen_id + ? { + id: row.data_transfer_id, + sourceScreenId: row.source_screen_id, + targetScreenId: row.target_screen_id, + sourceComponentId: row.source_component_id, + sourceComponentType: row.source_component_type, + dataReceivers: row.data_receivers, + buttonConfig: row.button_config, + } + : null, + }; + + logger.info("분할 패널 설정 조회", { companyCode, screenId }); + + return res.json({ + success: true, + data, + }); + } catch (error: any) { + logger.error("분할 패널 설정 조회 실패", error); + return res.status(500).json({ + success: false, + message: "분할 패널 설정 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +} + +/** + * 분할 패널 설정 생성 + * POST /api/screen-split-panel + */ +export async function createScreenSplitPanel(req: AuthenticatedRequest, res: Response) { + const client = await pool.connect(); + + try { + const { + screenId, + leftEmbedding, + rightEmbedding, + dataTransfer, + layoutConfig, + } = req.body; + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + // 필수 필드 검증 + if (!screenId || !leftEmbedding || !rightEmbedding || !dataTransfer) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다.", + }); + } + + await client.query("BEGIN"); + + // 1. 좌측 임베딩 생성 + const leftEmbeddingQuery = ` + INSERT INTO screen_embedding ( + parent_screen_id, child_screen_id, position, mode, + config, company_code, created_by, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) + RETURNING id + `; + + const leftResult = await client.query(leftEmbeddingQuery, [ + screenId, + leftEmbedding.childScreenId, + leftEmbedding.position, + leftEmbedding.mode, + JSON.stringify(leftEmbedding.config || {}), + companyCode, + userId, + ]); + + const leftEmbeddingId = leftResult.rows[0].id; + + // 2. 우측 임베딩 생성 + const rightEmbeddingQuery = ` + INSERT INTO screen_embedding ( + parent_screen_id, child_screen_id, position, mode, + config, company_code, created_by, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) + RETURNING id + `; + + const rightResult = await client.query(rightEmbeddingQuery, [ + screenId, + rightEmbedding.childScreenId, + rightEmbedding.position, + rightEmbedding.mode, + JSON.stringify(rightEmbedding.config || {}), + companyCode, + userId, + ]); + + const rightEmbeddingId = rightResult.rows[0].id; + + // 3. 데이터 전달 설정 생성 + const dataTransferQuery = ` + INSERT INTO screen_data_transfer ( + source_screen_id, target_screen_id, source_component_id, source_component_type, + data_receivers, button_config, company_code, created_by, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW()) + RETURNING id + `; + + const dataTransferResult = await client.query(dataTransferQuery, [ + dataTransfer.sourceScreenId, + dataTransfer.targetScreenId, + dataTransfer.sourceComponentId, + dataTransfer.sourceComponentType, + JSON.stringify(dataTransfer.dataReceivers), + JSON.stringify(dataTransfer.buttonConfig || {}), + companyCode, + userId, + ]); + + const dataTransferId = dataTransferResult.rows[0].id; + + // 4. 분할 패널 생성 + const splitPanelQuery = ` + INSERT INTO screen_split_panel ( + screen_id, left_embedding_id, right_embedding_id, data_transfer_id, + layout_config, company_code, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + RETURNING * + `; + + const splitPanelResult = await client.query(splitPanelQuery, [ + screenId, + leftEmbeddingId, + rightEmbeddingId, + dataTransferId, + JSON.stringify(layoutConfig || {}), + companyCode, + ]); + + await client.query("COMMIT"); + + logger.info("분할 패널 설정 생성", { + companyCode, + userId, + screenId, + id: splitPanelResult.rows[0].id, + }); + + return res.status(201).json({ + success: true, + data: splitPanelResult.rows[0], + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("분할 패널 설정 생성 실패", error); + + return res.status(500).json({ + success: false, + message: "분할 패널 설정 생성 중 오류가 발생했습니다.", + error: error.message, + }); + } finally { + client.release(); + } +} + +/** + * 분할 패널 설정 수정 + * PUT /api/screen-split-panel/:id + */ +export async function updateScreenSplitPanel(req: AuthenticatedRequest, res: Response) { + try { + const { id } = req.params; + const { layoutConfig } = req.body; + const companyCode = req.user!.companyCode; + + if (!layoutConfig) { + return res.status(400).json({ + success: false, + message: "수정할 내용이 없습니다.", + }); + } + + const query = ` + UPDATE screen_split_panel + SET layout_config = $1, updated_at = NOW() + WHERE id = $2 AND company_code = $3 + RETURNING * + `; + + const result = await pool.query(query, [ + JSON.stringify(layoutConfig), + id, + companyCode, + ]); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "분할 패널 설정을 찾을 수 없습니다.", + }); + } + + logger.info("분할 패널 설정 수정", { companyCode, id }); + + return res.json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("분할 패널 설정 수정 실패", error); + return res.status(500).json({ + success: false, + message: "분할 패널 설정 수정 중 오류가 발생했습니다.", + error: error.message, + }); + } +} + +/** + * 분할 패널 설정 삭제 + * DELETE /api/screen-split-panel/:id + */ +export async function deleteScreenSplitPanel(req: AuthenticatedRequest, res: Response) { + const client = await pool.connect(); + + try { + const { id } = req.params; + const companyCode = req.user!.companyCode; + + await client.query("BEGIN"); + + // 1. 분할 패널 조회 + const selectQuery = ` + SELECT left_embedding_id, right_embedding_id, data_transfer_id + FROM screen_split_panel + WHERE id = $1 AND company_code = $2 + `; + + const selectResult = await client.query(selectQuery, [id, companyCode]); + + if (selectResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "분할 패널 설정을 찾을 수 없습니다.", + }); + } + + const { left_embedding_id, right_embedding_id, data_transfer_id } = + selectResult.rows[0]; + + // 2. 분할 패널 삭제 + await client.query( + "DELETE FROM screen_split_panel WHERE id = $1 AND company_code = $2", + [id, companyCode] + ); + + // 3. 관련 임베딩 및 데이터 전달 설정 삭제 (CASCADE로 자동 삭제되지만 명시적으로) + if (left_embedding_id) { + await client.query( + "DELETE FROM screen_embedding WHERE id = $1 AND company_code = $2", + [left_embedding_id, companyCode] + ); + } + + if (right_embedding_id) { + await client.query( + "DELETE FROM screen_embedding WHERE id = $1 AND company_code = $2", + [right_embedding_id, companyCode] + ); + } + + if (data_transfer_id) { + await client.query( + "DELETE FROM screen_data_transfer WHERE id = $1 AND company_code = $2", + [data_transfer_id, companyCode] + ); + } + + await client.query("COMMIT"); + + logger.info("분할 패널 설정 삭제", { companyCode, id }); + + return res.json({ + success: true, + message: "분할 패널 설정이 삭제되었습니다.", + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("분할 패널 설정 삭제 실패", error); + return res.status(500).json({ + success: false, + message: "분할 패널 설정 삭제 중 오류가 발생했습니다.", + error: error.message, + }); + } finally { + client.release(); + } +} diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index 0ff80988..c7ecf75e 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -148,11 +148,42 @@ export const updateScreenInfo = async ( try { const { id } = req.params; const { companyCode } = req.user as any; - const { screenName, tableName, description, isActive } = req.body; + const { + screenName, + tableName, + description, + isActive, + // REST API 관련 필드 추가 + dataSourceType, + dbSourceType, + dbConnectionId, + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, + } = req.body; + + console.log("화면 정보 수정 요청:", { + screenId: id, + dataSourceType, + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, + }); await screenManagementService.updateScreenInfo( parseInt(id), - { screenName, tableName, description, isActive }, + { + screenName, + tableName, + description, + isActive, + dataSourceType, + dbSourceType, + dbConnectionId, + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, + }, companyCode ); res.json({ success: true, message: "화면 정보가 수정되었습니다." }); diff --git a/backend-node/src/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts index c25b4127..248bb867 100644 --- a/backend-node/src/controllers/tableCategoryValueController.ts +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -481,6 +481,52 @@ export const deleteColumnMapping = async (req: AuthenticatedRequest, res: Respon } }; +/** + * 테이블+컬럼 기준으로 모든 매핑 삭제 + * + * DELETE /api/categories/column-mapping/:tableName/:columnName + * + * 메뉴 선택 변경 시 기존 매핑을 모두 삭제하고 새로운 매핑만 추가하기 위해 사용 + */ +export const deleteColumnMappingsByColumn = async (req: AuthenticatedRequest, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const { tableName, columnName } = req.params; + + if (!tableName || !columnName) { + return res.status(400).json({ + success: false, + message: "tableName과 columnName은 필수입니다", + }); + } + + logger.info("테이블+컬럼 기준 매핑 삭제", { + tableName, + columnName, + companyCode, + }); + + const deletedCount = await tableCategoryValueService.deleteColumnMappingsByColumn( + tableName, + columnName, + companyCode + ); + + return res.json({ + success: true, + message: `${deletedCount}개의 컬럼 매핑이 삭제되었습니다`, + deletedCount, + }); + } catch (error: any) { + logger.error(`테이블+컬럼 기준 매핑 삭제 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: error.message || "컬럼 매핑 삭제 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + /** * 2레벨 메뉴 목록 조회 * diff --git a/backend-node/src/controllers/vehicleReportController.ts b/backend-node/src/controllers/vehicleReportController.ts new file mode 100644 index 00000000..db17dd24 --- /dev/null +++ b/backend-node/src/controllers/vehicleReportController.ts @@ -0,0 +1,206 @@ +/** + * 차량 운행 리포트 컨트롤러 + */ +import { Response } from "express"; +import { AuthenticatedRequest } from "../middleware/authMiddleware"; +import { vehicleReportService } from "../services/vehicleReportService"; + +/** + * 일별 통계 조회 + * GET /api/vehicle/reports/daily + */ +export const getDailyReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { startDate, endDate, userId, vehicleId } = req.query; + + console.log("📊 [getDailyReport] 요청:", { companyCode, startDate, endDate }); + + const result = await vehicleReportService.getDailyReport(companyCode, { + startDate: startDate as string, + endDate: endDate as string, + userId: userId as string, + vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getDailyReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "일별 통계 조회에 실패했습니다.", + }); + } +}; + +/** + * 주별 통계 조회 + * GET /api/vehicle/reports/weekly + */ +export const getWeeklyReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { year, month, userId, vehicleId } = req.query; + + console.log("📊 [getWeeklyReport] 요청:", { companyCode, year, month }); + + const result = await vehicleReportService.getWeeklyReport(companyCode, { + year: year ? parseInt(year as string) : new Date().getFullYear(), + month: month ? parseInt(month as string) : new Date().getMonth() + 1, + userId: userId as string, + vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getWeeklyReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "주별 통계 조회에 실패했습니다.", + }); + } +}; + +/** + * 월별 통계 조회 + * GET /api/vehicle/reports/monthly + */ +export const getMonthlyReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { year, userId, vehicleId } = req.query; + + console.log("📊 [getMonthlyReport] 요청:", { companyCode, year }); + + const result = await vehicleReportService.getMonthlyReport(companyCode, { + year: year ? parseInt(year as string) : new Date().getFullYear(), + userId: userId as string, + vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getMonthlyReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "월별 통계 조회에 실패했습니다.", + }); + } +}; + +/** + * 요약 통계 조회 (대시보드용) + * GET /api/vehicle/reports/summary + */ +export const getSummaryReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { period } = req.query; // today, week, month, year + + console.log("📊 [getSummaryReport] 요청:", { companyCode, period }); + + const result = await vehicleReportService.getSummaryReport( + companyCode, + (period as string) || "today" + ); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getSummaryReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "요약 통계 조회에 실패했습니다.", + }); + } +}; + +/** + * 운전자별 통계 조회 + * GET /api/vehicle/reports/by-driver + */ +export const getDriverReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { startDate, endDate, limit } = req.query; + + console.log("📊 [getDriverReport] 요청:", { companyCode, startDate, endDate }); + + const result = await vehicleReportService.getDriverReport(companyCode, { + startDate: startDate as string, + endDate: endDate as string, + limit: limit ? parseInt(limit as string) : 10, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getDriverReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운전자별 통계 조회에 실패했습니다.", + }); + } +}; + +/** + * 구간별 통계 조회 + * GET /api/vehicle/reports/by-route + */ +export const getRouteReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { startDate, endDate, limit } = req.query; + + console.log("📊 [getRouteReport] 요청:", { companyCode, startDate, endDate }); + + const result = await vehicleReportService.getRouteReport(companyCode, { + startDate: startDate as string, + endDate: endDate as string, + limit: limit ? parseInt(limit as string) : 10, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getRouteReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "구간별 통계 조회에 실패했습니다.", + }); + } +}; + diff --git a/backend-node/src/controllers/vehicleTripController.ts b/backend-node/src/controllers/vehicleTripController.ts new file mode 100644 index 00000000..d1604ede --- /dev/null +++ b/backend-node/src/controllers/vehicleTripController.ts @@ -0,0 +1,301 @@ +/** + * 차량 운행 이력 컨트롤러 + */ +import { Response } from "express"; +import { AuthenticatedRequest } from "../middleware/authMiddleware"; +import { vehicleTripService } from "../services/vehicleTripService"; + +/** + * 운행 시작 + * POST /api/vehicle/trip/start + */ +export const startTrip = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + const { vehicleId, departure, arrival, departureName, destinationName, latitude, longitude } = req.body; + + console.log("🚗 [startTrip] 요청:", { userId, companyCode, departure, arrival }); + + if (latitude === undefined || longitude === undefined) { + return res.status(400).json({ + success: false, + message: "위치 정보(latitude, longitude)가 필요합니다.", + }); + } + + const result = await vehicleTripService.startTrip({ + userId, + companyCode, + vehicleId, + departure, + arrival, + departureName, + destinationName, + latitude, + longitude, + }); + + console.log("✅ [startTrip] 성공:", result); + + res.json({ + success: true, + data: result, + message: "운행이 시작되었습니다.", + }); + } catch (error: any) { + console.error("❌ [startTrip] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운행 시작에 실패했습니다.", + }); + } +}; + +/** + * 운행 종료 + * POST /api/vehicle/trip/end + */ +export const endTrip = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + const { tripId, latitude, longitude } = req.body; + + console.log("🚗 [endTrip] 요청:", { userId, companyCode, tripId }); + + if (!tripId) { + return res.status(400).json({ + success: false, + message: "tripId가 필요합니다.", + }); + } + + if (latitude === undefined || longitude === undefined) { + return res.status(400).json({ + success: false, + message: "위치 정보(latitude, longitude)가 필요합니다.", + }); + } + + const result = await vehicleTripService.endTrip({ + tripId, + userId, + companyCode, + latitude, + longitude, + }); + + console.log("✅ [endTrip] 성공:", result); + + res.json({ + success: true, + data: result, + message: "운행이 종료되었습니다.", + }); + } catch (error: any) { + console.error("❌ [endTrip] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운행 종료에 실패했습니다.", + }); + } +}; + +/** + * 위치 기록 추가 (연속 추적) + * POST /api/vehicle/trip/location + */ +export const addTripLocation = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + const { tripId, latitude, longitude, accuracy, speed } = req.body; + + if (!tripId) { + return res.status(400).json({ + success: false, + message: "tripId가 필요합니다.", + }); + } + + if (latitude === undefined || longitude === undefined) { + return res.status(400).json({ + success: false, + message: "위치 정보(latitude, longitude)가 필요합니다.", + }); + } + + const result = await vehicleTripService.addLocation({ + tripId, + userId, + companyCode, + latitude, + longitude, + accuracy, + speed, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [addTripLocation] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "위치 기록에 실패했습니다.", + }); + } +}; + +/** + * 운행 이력 목록 조회 + * GET /api/vehicle/trips + */ +export const getTripList = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { userId, vehicleId, status, startDate, endDate, departure, arrival, limit, offset } = req.query; + + console.log("🚗 [getTripList] 요청:", { companyCode, userId, status, startDate, endDate }); + + const result = await vehicleTripService.getTripList(companyCode, { + userId: userId as string, + vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined, + status: status as string, + startDate: startDate as string, + endDate: endDate as string, + departure: departure as string, + arrival: arrival as string, + limit: limit ? parseInt(limit as string) : 50, + offset: offset ? parseInt(offset as string) : 0, + }); + + res.json({ + success: true, + data: result.data, + total: result.total, + }); + } catch (error: any) { + console.error("❌ [getTripList] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운행 이력 조회에 실패했습니다.", + }); + } +}; + +/** + * 운행 상세 조회 (경로 포함) + * GET /api/vehicle/trips/:tripId + */ +export const getTripDetail = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { tripId } = req.params; + + console.log("🚗 [getTripDetail] 요청:", { companyCode, tripId }); + + const result = await vehicleTripService.getTripDetail(tripId, companyCode); + + if (!result) { + return res.status(404).json({ + success: false, + message: "운행 정보를 찾을 수 없습니다.", + }); + } + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getTripDetail] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운행 상세 조회에 실패했습니다.", + }); + } +}; + +/** + * 활성 운행 조회 (현재 진행 중) + * GET /api/vehicle/trip/active + */ +export const getActiveTrip = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + + const result = await vehicleTripService.getActiveTrip(userId, companyCode); + + res.json({ + success: true, + data: result, + hasActiveTrip: !!result, + }); + } catch (error: any) { + console.error("❌ [getActiveTrip] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "활성 운행 조회에 실패했습니다.", + }); + } +}; + +/** + * 운행 취소 + * POST /api/vehicle/trip/cancel + */ +export const cancelTrip = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { tripId } = req.body; + + if (!tripId) { + return res.status(400).json({ + success: false, + message: "tripId가 필요합니다.", + }); + } + + const result = await vehicleTripService.cancelTrip(tripId, companyCode); + + if (!result) { + return res.status(404).json({ + success: false, + message: "취소할 운행을 찾을 수 없습니다.", + }); + } + + res.json({ + success: true, + message: "운행이 취소되었습니다.", + }); + } catch (error: any) { + console.error("❌ [cancelTrip] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운행 취소에 실패했습니다.", + }); + } +}; + diff --git a/backend-node/src/routes/dynamicFormRoutes.ts b/backend-node/src/routes/dynamicFormRoutes.ts index 5514fb54..cec78990 100644 --- a/backend-node/src/routes/dynamicFormRoutes.ts +++ b/backend-node/src/routes/dynamicFormRoutes.ts @@ -5,12 +5,15 @@ import { saveFormDataEnhanced, updateFormData, updateFormDataPartial, + updateFieldValue, deleteFormData, getFormData, getFormDataList, validateFormData, getTableColumns, getTablePrimaryKeys, + saveLocationHistory, + getLocationHistory, } from "../controllers/dynamicFormController"; const router = express.Router(); @@ -21,6 +24,7 @@ router.use(authenticateToken); // 폼 데이터 CRUD router.post("/save", saveFormData); // 기존 버전 (레거시 지원) router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전 +router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원) - /:id 보다 먼저 선언! router.put("/:id", updateFormData); router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트 router.delete("/:id", deleteFormData); @@ -38,4 +42,8 @@ router.get("/table/:tableName/columns", getTableColumns); // 테이블 기본키 조회 router.get("/table/:tableName/primary-keys", getTablePrimaryKeys); +// 위치 이력 (연속 위치 추적) +router.post("/location-history", saveLocationHistory); +router.get("/location-history/:tripId", getLocationHistory); + export default router; diff --git a/backend-node/src/routes/screenEmbeddingRoutes.ts b/backend-node/src/routes/screenEmbeddingRoutes.ts new file mode 100644 index 00000000..6b604c15 --- /dev/null +++ b/backend-node/src/routes/screenEmbeddingRoutes.ts @@ -0,0 +1,80 @@ +/** + * 화면 임베딩 및 데이터 전달 시스템 라우트 + */ + +import express from "express"; +import { + // 화면 임베딩 + getScreenEmbeddings, + getScreenEmbeddingById, + createScreenEmbedding, + updateScreenEmbedding, + deleteScreenEmbedding, + // 데이터 전달 + getScreenDataTransfer, + createScreenDataTransfer, + updateScreenDataTransfer, + deleteScreenDataTransfer, + // 분할 패널 + getScreenSplitPanel, + createScreenSplitPanel, + updateScreenSplitPanel, + deleteScreenSplitPanel, +} from "../controllers/screenEmbeddingController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); + +// ============================================ +// 화면 임베딩 라우트 +// ============================================ + +// 화면 임베딩 목록 조회 +router.get("/screen-embedding", authenticateToken, getScreenEmbeddings); + +// 화면 임베딩 상세 조회 +router.get("/screen-embedding/:id", authenticateToken, getScreenEmbeddingById); + +// 화면 임베딩 생성 +router.post("/screen-embedding", authenticateToken, createScreenEmbedding); + +// 화면 임베딩 수정 +router.put("/screen-embedding/:id", authenticateToken, updateScreenEmbedding); + +// 화면 임베딩 삭제 +router.delete("/screen-embedding/:id", authenticateToken, deleteScreenEmbedding); + +// ============================================ +// 데이터 전달 라우트 +// ============================================ + +// 데이터 전달 설정 조회 +router.get("/screen-data-transfer", authenticateToken, getScreenDataTransfer); + +// 데이터 전달 설정 생성 +router.post("/screen-data-transfer", authenticateToken, createScreenDataTransfer); + +// 데이터 전달 설정 수정 +router.put("/screen-data-transfer/:id", authenticateToken, updateScreenDataTransfer); + +// 데이터 전달 설정 삭제 +router.delete("/screen-data-transfer/:id", authenticateToken, deleteScreenDataTransfer); + +// ============================================ +// 분할 패널 라우트 +// ============================================ + +// 분할 패널 설정 조회 +router.get("/screen-split-panel/:screenId", authenticateToken, getScreenSplitPanel); + +// 분할 패널 설정 생성 +router.post("/screen-split-panel", authenticateToken, createScreenSplitPanel); + +// 분할 패널 설정 수정 +router.put("/screen-split-panel/:id", authenticateToken, updateScreenSplitPanel); + +// 분할 패널 설정 삭제 +router.delete("/screen-split-panel/:id", authenticateToken, deleteScreenSplitPanel); + +export default router; + diff --git a/backend-node/src/routes/tableCategoryValueRoutes.ts b/backend-node/src/routes/tableCategoryValueRoutes.ts index c4afe66e..b79aab75 100644 --- a/backend-node/src/routes/tableCategoryValueRoutes.ts +++ b/backend-node/src/routes/tableCategoryValueRoutes.ts @@ -11,6 +11,7 @@ import { createColumnMapping, getLogicalColumns, deleteColumnMapping, + deleteColumnMappingsByColumn, getSecondLevelMenus, } from "../controllers/tableCategoryValueController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -57,7 +58,11 @@ router.get("/logical-columns/:tableName/:menuObjid", getLogicalColumns); // 컬럼 매핑 생성/수정 router.post("/column-mapping", createColumnMapping); -// 컬럼 매핑 삭제 +// 테이블+컬럼 기준 매핑 삭제 (메뉴 선택 변경 시 기존 매핑 모두 삭제용) +// 주의: 더 구체적인 라우트가 먼저 와야 함 (3개 세그먼트 > 1개 세그먼트) +router.delete("/column-mapping/:tableName/:columnName/all", deleteColumnMappingsByColumn); + +// 컬럼 매핑 삭제 (단일) router.delete("/column-mapping/:mappingId", deleteColumnMapping); export default router; diff --git a/backend-node/src/routes/vehicleTripRoutes.ts b/backend-node/src/routes/vehicleTripRoutes.ts new file mode 100644 index 00000000..c70a7394 --- /dev/null +++ b/backend-node/src/routes/vehicleTripRoutes.ts @@ -0,0 +1,71 @@ +/** + * 차량 운행 이력 및 리포트 라우트 + */ +import { Router } from "express"; +import { + startTrip, + endTrip, + addTripLocation, + getTripList, + getTripDetail, + getActiveTrip, + cancelTrip, +} from "../controllers/vehicleTripController"; +import { + getDailyReport, + getWeeklyReport, + getMonthlyReport, + getSummaryReport, + getDriverReport, + getRouteReport, +} from "../controllers/vehicleReportController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 적용 +router.use(authenticateToken); + +// === 운행 관리 === +// 운행 시작 +router.post("/trip/start", startTrip); + +// 운행 종료 +router.post("/trip/end", endTrip); + +// 위치 기록 추가 (연속 추적) +router.post("/trip/location", addTripLocation); + +// 활성 운행 조회 (현재 진행 중) +router.get("/trip/active", getActiveTrip); + +// 운행 취소 +router.post("/trip/cancel", cancelTrip); + +// 운행 이력 목록 조회 +router.get("/trips", getTripList); + +// 운행 상세 조회 (경로 포함) +router.get("/trips/:tripId", getTripDetail); + +// === 리포트 === +// 요약 통계 (대시보드용) +router.get("/reports/summary", getSummaryReport); + +// 일별 통계 +router.get("/reports/daily", getDailyReport); + +// 주별 통계 +router.get("/reports/weekly", getWeeklyReport); + +// 월별 통계 +router.get("/reports/monthly", getMonthlyReport); + +// 운전자별 통계 +router.get("/reports/by-driver", getDriverReport); + +// 구간별 통계 +router.get("/reports/by-route", getRouteReport); + +export default router; + diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts index b75034c2..0d96b285 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -178,21 +178,24 @@ export class DashboardService { let params: any[] = []; let paramIndex = 1; - // 회사 코드 필터링 (최우선) + // 회사 코드 필터링 - company_code가 일치하면 해당 회사 사용자는 모두 조회 가능 if (companyCode) { - whereConditions.push(`d.company_code = $${paramIndex}`); - params.push(companyCode); - paramIndex++; - } - - // 권한 필터링 - if (userId) { + if (companyCode === '*') { + // 최고 관리자는 모든 대시보드 조회 가능 + } else { + whereConditions.push(`d.company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + } else if (userId) { + // 회사 코드 없이 userId만 있는 경우 (본인 생성 또는 공개) whereConditions.push( `(d.created_by = $${paramIndex} OR d.is_public = true)` ); params.push(userId); paramIndex++; } else { + // 비로그인 사용자는 공개 대시보드만 whereConditions.push("d.is_public = true"); } @@ -228,7 +231,7 @@ export class DashboardService { const whereClause = whereConditions.join(" AND "); - // 대시보드 목록 조회 (users 테이블 조인 제거) + // 대시보드 목록 조회 (user_info 조인하여 생성자 이름 포함) const dashboardQuery = ` SELECT d.id, @@ -242,13 +245,16 @@ export class DashboardService { d.tags, d.category, d.view_count, + d.company_code, + u.user_name as created_by_name, COUNT(de.id) as elements_count FROM dashboards d LEFT JOIN dashboard_elements de ON d.id = de.dashboard_id + LEFT JOIN user_info u ON d.created_by = u.user_id WHERE ${whereClause} GROUP BY d.id, d.title, d.description, d.thumbnail_url, d.is_public, d.created_by, d.created_at, d.updated_at, d.tags, d.category, - d.view_count + d.view_count, d.company_code, u.user_name ORDER BY d.updated_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; @@ -277,12 +283,14 @@ export class DashboardService { thumbnailUrl: row.thumbnail_url, isPublic: row.is_public, createdBy: row.created_by, + createdByName: row.created_by_name || row.created_by, createdAt: row.created_at, updatedAt: row.updated_at, tags: JSON.parse(row.tags || "[]"), category: row.category, viewCount: parseInt(row.view_count || "0"), elementsCount: parseInt(row.elements_count || "0"), + companyCode: row.company_code, })), pagination: { page, @@ -299,6 +307,8 @@ export class DashboardService { /** * 대시보드 상세 조회 + * - company_code가 일치하면 해당 회사 사용자는 모두 조회 가능 + * - company_code가 '*'인 경우 최고 관리자만 조회 가능 */ static async getDashboardById( dashboardId: string, @@ -310,44 +320,43 @@ export class DashboardService { let dashboardQuery: string; let dashboardParams: any[]; - if (userId) { - if (companyCode) { + if (companyCode) { + // 회사 코드가 있으면 해당 회사 대시보드 또는 공개 대시보드 조회 가능 + // 최고 관리자(companyCode = '*')는 모든 대시보드 조회 가능 + if (companyCode === '*') { dashboardQuery = ` SELECT d.* FROM dashboards d WHERE d.id = $1 AND d.deleted_at IS NULL - AND d.company_code = $2 - AND (d.created_by = $3 OR d.is_public = true) - `; - dashboardParams = [dashboardId, companyCode, userId]; - } else { - dashboardQuery = ` - SELECT d.* - FROM dashboards d - WHERE d.id = $1 AND d.deleted_at IS NULL - AND (d.created_by = $2 OR d.is_public = true) - `; - dashboardParams = [dashboardId, userId]; - } - } else { - if (companyCode) { - dashboardQuery = ` - SELECT d.* - FROM dashboards d - WHERE d.id = $1 AND d.deleted_at IS NULL - AND d.company_code = $2 - AND d.is_public = true - `; - dashboardParams = [dashboardId, companyCode]; - } else { - dashboardQuery = ` - SELECT d.* - FROM dashboards d - WHERE d.id = $1 AND d.deleted_at IS NULL - AND d.is_public = true `; dashboardParams = [dashboardId]; + } else { + dashboardQuery = ` + SELECT d.* + FROM dashboards d + WHERE d.id = $1 AND d.deleted_at IS NULL + AND d.company_code = $2 + `; + dashboardParams = [dashboardId, companyCode]; } + } else if (userId) { + // 회사 코드 없이 userId만 있는 경우 (본인 생성 또는 공개) + dashboardQuery = ` + SELECT d.* + FROM dashboards d + WHERE d.id = $1 AND d.deleted_at IS NULL + AND (d.created_by = $2 OR d.is_public = true) + `; + dashboardParams = [dashboardId, userId]; + } else { + // 비로그인 사용자는 공개 대시보드만 + dashboardQuery = ` + SELECT d.* + FROM dashboards d + WHERE d.id = $1 AND d.deleted_at IS NULL + AND d.is_public = true + `; + dashboardParams = [dashboardId]; } const dashboardResult = await PostgreSQLService.query( diff --git a/backend-node/src/services/batchExternalDbService.ts b/backend-node/src/services/batchExternalDbService.ts index 18524085..303c2d7a 100644 --- a/backend-node/src/services/batchExternalDbService.ts +++ b/backend-node/src/services/batchExternalDbService.ts @@ -203,8 +203,7 @@ export class BatchExternalDbService { // 비밀번호 복호화 if (connection.password) { try { - const passwordEncryption = new PasswordEncryption(); - connection.password = passwordEncryption.decrypt(connection.password); + connection.password = PasswordEncryption.decrypt(connection.password); } catch (error) { console.error("비밀번호 복호화 실패:", error); // 복호화 실패 시 원본 사용 (또는 에러 처리) diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index a8f755c3..ee849ae2 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -1,10 +1,10 @@ -import cron from "node-cron"; +import cron, { ScheduledTask } from "node-cron"; import { BatchService } from "./batchService"; import { BatchExecutionLogService } from "./batchExecutionLogService"; import { logger } from "../utils/logger"; export class BatchSchedulerService { - private static scheduledTasks: Map = new Map(); + private static scheduledTasks: Map = new Map(); /** * 모든 활성 배치의 스케줄링 초기화 @@ -124,6 +124,14 @@ export class BatchSchedulerService { try { logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`); + // 매핑 정보가 없으면 상세 조회로 다시 가져오기 + if (!config.batch_mappings || config.batch_mappings.length === 0) { + const fullConfig = await BatchService.getBatchConfigById(config.id); + if (fullConfig.success && fullConfig.data) { + config = fullConfig.data; + } + } + // 실행 로그 생성 const executionLogResponse = await BatchExecutionLogService.createExecutionLog({ @@ -175,7 +183,7 @@ export class BatchSchedulerService { // 실행 로그 업데이트 (실패) if (executionLog) { await BatchExecutionLogService.updateExecutionLog(executionLog.id, { - execution_status: "FAILURE", + execution_status: "FAILED", end_time: new Date(), duration_ms: Date.now() - startTime.getTime(), error_message: @@ -396,4 +404,11 @@ export class BatchSchedulerService { return { totalRecords, successRecords, failedRecords }; } + + /** + * 개별 배치 작업 스케줄링 (scheduleBatch의 별칭) + */ + static async scheduleBatchConfig(config: any) { + return this.scheduleBatch(config); + } } diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts index 41f20964..2aefc98b 100644 --- a/backend-node/src/services/batchService.ts +++ b/backend-node/src/services/batchService.ts @@ -16,7 +16,6 @@ import { UpdateBatchConfigRequest, } from "../types/batchTypes"; import { BatchExternalDbService } from "./batchExternalDbService"; -import { DbConnectionManager } from "./dbConnectionManager"; export class BatchService { /** @@ -475,7 +474,13 @@ export class BatchService { try { if (connectionType === "internal") { // 내부 DB 테이블 조회 - const tables = await DbConnectionManager.getInternalTables(); + const tables = await query( + `SELECT table_name, table_type, table_schema + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + ORDER BY table_name` + ); return { success: true, data: tables, @@ -509,7 +514,13 @@ export class BatchService { try { if (connectionType === "internal") { // 내부 DB 컬럼 조회 - const columns = await DbConnectionManager.getInternalColumns(tableName); + const columns = await query( + `SELECT column_name, data_type, is_nullable, column_default + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 + ORDER BY ordinal_position`, + [tableName] + ); return { success: true, data: columns, @@ -543,7 +554,9 @@ export class BatchService { try { if (connectionType === "internal") { // 내부 DB 데이터 조회 - const data = await DbConnectionManager.getInternalData(tableName, 10); + const data = await query( + `SELECT * FROM ${tableName} LIMIT 10` + ); return { success: true, data, diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index c40037bb..04586d65 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1,4 +1,4 @@ -import { query, queryOne, transaction } from "../database/db"; +import { query, queryOne, transaction, getPool } from "../database/db"; import { EventTriggerService } from "./eventTriggerService"; import { DataflowControlService } from "./dataflowControlService"; @@ -746,7 +746,7 @@ export class DynamicFormService { * 폼 데이터 부분 업데이트 (변경된 필드만 업데이트) */ async updateFormDataPartial( - id: number, + id: string | number, // 🔧 UUID 문자열도 지원 tableName: string, originalData: Record, newData: Record @@ -1635,6 +1635,287 @@ export class DynamicFormService { // 에러를 다시 던지지 않음 - 메인 저장 프로세스에 영향 주지 않기 위해 } } + + /** + * 특정 테이블의 특정 필드 값만 업데이트 + * (다른 테이블의 레코드 업데이트 지원) + */ + async updateFieldValue( + tableName: string, + keyField: string, + keyValue: any, + updateField: string, + updateValue: any, + companyCode: string, + userId: string + ): Promise<{ affectedRows: number }> { + const pool = getPool(); + const client = await pool.connect(); + + try { + console.log("🔄 [updateFieldValue] 업데이트 실행:", { + tableName, + keyField, + keyValue, + updateField, + updateValue, + companyCode, + }); + + // 테이블 컬럼 정보 조회 (updated_by, updated_at 존재 여부 확인) + const columnQuery = ` + SELECT column_name + FROM information_schema.columns + WHERE table_name = $1 AND column_name IN ('updated_by', 'updated_at', 'company_code') + `; + const columnResult = await client.query(columnQuery, [tableName]); + const existingColumns = columnResult.rows.map((row: any) => row.column_name); + + const hasUpdatedBy = existingColumns.includes('updated_by'); + const hasUpdatedAt = existingColumns.includes('updated_at'); + const hasCompanyCode = existingColumns.includes('company_code'); + + console.log("🔍 [updateFieldValue] 테이블 컬럼 확인:", { + hasUpdatedBy, + hasUpdatedAt, + hasCompanyCode, + }); + + // 동적 SET 절 구성 + let setClause = `"${updateField}" = $1`; + const params: any[] = [updateValue]; + let paramIndex = 2; + + if (hasUpdatedBy) { + setClause += `, updated_by = $${paramIndex}`; + params.push(userId); + paramIndex++; + } + + if (hasUpdatedAt) { + setClause += `, updated_at = NOW()`; + } + + // WHERE 절 구성 + let whereClause = `"${keyField}" = $${paramIndex}`; + params.push(keyValue); + paramIndex++; + + // 멀티테넌시: company_code 조건 추가 (최고관리자는 제외, 컬럼이 있는 경우만) + if (hasCompanyCode && companyCode && companyCode !== "*") { + whereClause += ` AND company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + + const sqlQuery = ` + UPDATE "${tableName}" + SET ${setClause} + WHERE ${whereClause} + `; + + console.log("🔍 [updateFieldValue] 쿼리:", sqlQuery); + console.log("🔍 [updateFieldValue] 파라미터:", params); + + const result = await client.query(sqlQuery, params); + + console.log("✅ [updateFieldValue] 결과:", { + affectedRows: result.rowCount, + }); + + return { affectedRows: result.rowCount || 0 }; + } catch (error) { + console.error("❌ [updateFieldValue] 오류:", error); + throw error; + } finally { + client.release(); + } + } + + /** + * 위치 이력 저장 (연속 위치 추적용) + */ + async saveLocationHistory(data: { + userId: string; + companyCode: string; + latitude: number; + longitude: number; + accuracy?: number; + altitude?: number; + speed?: number; + heading?: number; + tripId?: string; + tripStatus?: string; + departure?: string; + arrival?: string; + departureName?: string; + destinationName?: string; + recordedAt?: string; + vehicleId?: number; + }): Promise<{ id: number }> { + const pool = getPool(); + const client = await pool.connect(); + + try { + console.log("📍 [saveLocationHistory] 저장 시작:", data); + + const sqlQuery = ` + INSERT INTO vehicle_location_history ( + user_id, + company_code, + latitude, + longitude, + accuracy, + altitude, + speed, + heading, + trip_id, + trip_status, + departure, + arrival, + departure_name, + destination_name, + recorded_at, + vehicle_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + RETURNING id + `; + + const params = [ + data.userId, + data.companyCode, + data.latitude, + data.longitude, + data.accuracy || null, + data.altitude || null, + data.speed || null, + data.heading || null, + data.tripId || null, + data.tripStatus || "active", + data.departure || null, + data.arrival || null, + data.departureName || null, + data.destinationName || null, + data.recordedAt ? new Date(data.recordedAt) : new Date(), + data.vehicleId || null, + ]; + + const result = await client.query(sqlQuery, params); + + console.log("✅ [saveLocationHistory] 저장 완료:", { + id: result.rows[0]?.id, + }); + + return { id: result.rows[0]?.id }; + } catch (error) { + console.error("❌ [saveLocationHistory] 오류:", error); + throw error; + } finally { + client.release(); + } + } + + /** + * 위치 이력 조회 (경로 조회용) + */ + async getLocationHistory(params: { + companyCode: string; + tripId?: string; + userId?: string; + startDate?: string; + endDate?: string; + limit?: number; + }): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + console.log("📍 [getLocationHistory] 조회 시작:", params); + + const conditions: string[] = []; + const queryParams: any[] = []; + let paramIndex = 1; + + // 멀티테넌시: company_code 필터 + if (params.companyCode && params.companyCode !== "*") { + conditions.push(`company_code = $${paramIndex}`); + queryParams.push(params.companyCode); + paramIndex++; + } + + // trip_id 필터 + if (params.tripId) { + conditions.push(`trip_id = $${paramIndex}`); + queryParams.push(params.tripId); + paramIndex++; + } + + // user_id 필터 + if (params.userId) { + conditions.push(`user_id = $${paramIndex}`); + queryParams.push(params.userId); + paramIndex++; + } + + // 날짜 범위 필터 + if (params.startDate) { + conditions.push(`recorded_at >= $${paramIndex}`); + queryParams.push(new Date(params.startDate)); + paramIndex++; + } + + if (params.endDate) { + conditions.push(`recorded_at <= $${paramIndex}`); + queryParams.push(new Date(params.endDate)); + paramIndex++; + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const limitClause = params.limit ? `LIMIT ${params.limit}` : "LIMIT 1000"; + + const sqlQuery = ` + SELECT + id, + user_id, + vehicle_id, + latitude, + longitude, + accuracy, + altitude, + speed, + heading, + trip_id, + trip_status, + departure, + arrival, + departure_name, + destination_name, + recorded_at, + created_at, + company_code + FROM vehicle_location_history + ${whereClause} + ORDER BY recorded_at ASC + ${limitClause} + `; + + console.log("🔍 [getLocationHistory] 쿼리:", sqlQuery); + console.log("🔍 [getLocationHistory] 파라미터:", queryParams); + + const result = await client.query(sqlQuery, queryParams); + + console.log("✅ [getLocationHistory] 조회 완료:", { + count: result.rowCount, + }); + + return result.rows; + } catch (error) { + console.error("❌ [getLocationHistory] 오류:", error); + throw error; + } finally { + client.release(); + } + } } // 싱글톤 인스턴스 생성 및 export diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts index 36f3a7e2..af37eff1 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -474,6 +474,105 @@ export class ExternalRestApiConnectionService { } } + /** + * 인증 헤더 생성 + */ + static async getAuthHeaders( + authType: AuthType, + authConfig: any, + companyCode?: string + ): Promise> { + const headers: Record = {}; + + if (authType === "db-token") { + const cfg = authConfig || {}; + const { + dbTableName, + dbValueColumn, + dbWhereColumn, + dbWhereValue, + dbHeaderName, + dbHeaderTemplate, + } = cfg; + + if (!dbTableName || !dbValueColumn) { + throw new Error("DB 토큰 설정이 올바르지 않습니다."); + } + + if (!companyCode) { + throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다."); + } + + const hasWhereColumn = !!dbWhereColumn; + const hasWhereValue = + dbWhereValue !== undefined && + dbWhereValue !== null && + dbWhereValue !== ""; + + // where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함 + if (hasWhereColumn !== hasWhereValue) { + throw new Error( + "DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다." + ); + } + + // 식별자 검증 (간단한 화이트리스트) + const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + if ( + !identifierRegex.test(dbTableName) || + !identifierRegex.test(dbValueColumn) || + (hasWhereColumn && !identifierRegex.test(dbWhereColumn as string)) + ) { + throw new Error( + "DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다." + ); + } + + let sql = ` + SELECT ${dbValueColumn} AS token_value + FROM ${dbTableName} + WHERE company_code = $1 + `; + + const params: any[] = [companyCode]; + + if (hasWhereColumn && hasWhereValue) { + sql += ` AND ${dbWhereColumn} = $2`; + params.push(dbWhereValue); + } + + sql += ` + ORDER BY updated_date DESC + LIMIT 1 + `; + + const tokenResult: QueryResult = await pool.query(sql, params); + + if (tokenResult.rowCount === 0) { + throw new Error("DB에서 토큰을 찾을 수 없습니다."); + } + + const tokenValue = tokenResult.rows[0]["token_value"]; + const headerName = dbHeaderName || "Authorization"; + const template = dbHeaderTemplate || "Bearer {{value}}"; + + headers[headerName] = template.replace("{{value}}", tokenValue); + } else if (authType === "bearer" && authConfig?.token) { + headers["Authorization"] = `Bearer ${authConfig.token}`; + } else if (authType === "basic" && authConfig) { + const credentials = Buffer.from( + `${authConfig.username}:${authConfig.password}` + ).toString("base64"); + headers["Authorization"] = `Basic ${credentials}`; + } else if (authType === "api-key" && authConfig) { + if (authConfig.keyLocation === "header") { + headers[authConfig.keyName] = authConfig.keyValue; + } + } + + return headers; + } + /** * REST API 연결 테스트 (테스트 요청 데이터 기반) */ @@ -485,99 +584,15 @@ export class ExternalRestApiConnectionService { try { // 헤더 구성 - const headers = { ...testRequest.headers }; + let headers = { ...testRequest.headers }; - // 인증 헤더 추가 - if (testRequest.auth_type === "db-token") { - const cfg = testRequest.auth_config || {}; - const { - dbTableName, - dbValueColumn, - dbWhereColumn, - dbWhereValue, - dbHeaderName, - dbHeaderTemplate, - } = cfg; - - if (!dbTableName || !dbValueColumn) { - throw new Error("DB 토큰 설정이 올바르지 않습니다."); - } - - if (!userCompanyCode) { - throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다."); - } - - const hasWhereColumn = !!dbWhereColumn; - const hasWhereValue = - dbWhereValue !== undefined && dbWhereValue !== null && dbWhereValue !== ""; - - // where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함 - if (hasWhereColumn !== hasWhereValue) { - throw new Error( - "DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다." - ); - } - - // 식별자 검증 (간단한 화이트리스트) - const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/; - if ( - !identifierRegex.test(dbTableName) || - !identifierRegex.test(dbValueColumn) || - (hasWhereColumn && !identifierRegex.test(dbWhereColumn as string)) - ) { - throw new Error( - "DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다." - ); - } - - let sql = ` - SELECT ${dbValueColumn} AS token_value - FROM ${dbTableName} - WHERE company_code = $1 - `; - - const params: any[] = [userCompanyCode]; - - if (hasWhereColumn && hasWhereValue) { - sql += ` AND ${dbWhereColumn} = $2`; - params.push(dbWhereValue); - } - - sql += ` - ORDER BY updated_date DESC - LIMIT 1 - `; - - const tokenResult: QueryResult = await pool.query(sql, params); - - if (tokenResult.rowCount === 0) { - throw new Error("DB에서 토큰을 찾을 수 없습니다."); - } - - const tokenValue = tokenResult.rows[0]["token_value"]; - const headerName = dbHeaderName || "Authorization"; - const template = dbHeaderTemplate || "Bearer {{value}}"; - - headers[headerName] = template.replace("{{value}}", tokenValue); - } else if ( - testRequest.auth_type === "bearer" && - testRequest.auth_config?.token - ) { - headers["Authorization"] = `Bearer ${testRequest.auth_config.token}`; - } else if (testRequest.auth_type === "basic" && testRequest.auth_config) { - const credentials = Buffer.from( - `${testRequest.auth_config.username}:${testRequest.auth_config.password}` - ).toString("base64"); - headers["Authorization"] = `Basic ${credentials}`; - } else if ( - testRequest.auth_type === "api-key" && - testRequest.auth_config - ) { - if (testRequest.auth_config.keyLocation === "header") { - headers[testRequest.auth_config.keyName] = - testRequest.auth_config.keyValue; - } - } + // 인증 헤더 생성 및 병합 + const authHeaders = await this.getAuthHeaders( + testRequest.auth_type, + testRequest.auth_config, + userCompanyCode + ); + headers = { ...headers, ...authHeaders }; // URL 구성 let url = testRequest.base_url; diff --git a/backend-node/src/services/flowDefinitionService.ts b/backend-node/src/services/flowDefinitionService.ts index 759178c1..4416faa0 100644 --- a/backend-node/src/services/flowDefinitionService.ts +++ b/backend-node/src/services/flowDefinitionService.ts @@ -27,13 +27,20 @@ export class FlowDefinitionService { tableName: request.tableName, dbSourceType: request.dbSourceType, dbConnectionId: request.dbConnectionId, + restApiConnectionId: request.restApiConnectionId, + restApiEndpoint: request.restApiEndpoint, + restApiJsonPath: request.restApiJsonPath, companyCode, userId, }); const query = ` - INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, company_code, created_by) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO flow_definition ( + name, description, table_name, db_source_type, db_connection_id, + rest_api_connection_id, rest_api_endpoint, rest_api_json_path, + company_code, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING * `; @@ -43,6 +50,9 @@ export class FlowDefinitionService { request.tableName || null, request.dbSourceType || "internal", request.dbConnectionId || null, + request.restApiConnectionId || null, + request.restApiEndpoint || null, + request.restApiJsonPath || "data", companyCode, userId, ]; @@ -206,6 +216,10 @@ export class FlowDefinitionService { tableName: row.table_name, dbSourceType: row.db_source_type || "internal", dbConnectionId: row.db_connection_id, + // REST API 관련 필드 + restApiConnectionId: row.rest_api_connection_id, + restApiEndpoint: row.rest_api_endpoint, + restApiJsonPath: row.rest_api_json_path, companyCode: row.company_code || "*", isActive: row.is_active, createdBy: row.created_by, diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 7d969b06..70b45af4 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -10,10 +10,6 @@ export interface MenuCopyResult { copiedMenus: number; copiedScreens: number; copiedFlows: number; - copiedCategories: number; - copiedCodes: number; - copiedCategorySettings: number; - copiedNumberingRules: number; menuIdMap: Record; screenIdMap: Record; flowIdMap: Record; @@ -129,35 +125,6 @@ interface FlowStepConnection { label: string | null; } -/** - * 코드 카테고리 - */ -interface CodeCategory { - category_code: string; - category_name: string; - category_name_eng: string | null; - description: string | null; - sort_order: number | null; - is_active: string; - company_code: string; - menu_objid: number; -} - -/** - * 코드 정보 - */ -interface CodeInfo { - code_category: string; - code_value: string; - code_name: string; - code_name_eng: string | null; - description: string | null; - sort_order: number | null; - is_active: string; - company_code: string; - menu_objid: number; -} - /** * 메뉴 복사 서비스 */ @@ -249,6 +216,24 @@ export class MenuCopyService { } } } + + // 3) 탭 컴포넌트 (tabs 배열 내부의 screenId) + if ( + props?.componentConfig?.tabs && + Array.isArray(props.componentConfig.tabs) + ) { + for (const tab of props.componentConfig.tabs) { + if (tab.screenId) { + const screenId = tab.screenId; + const numId = + typeof screenId === "number" ? screenId : parseInt(screenId); + if (!isNaN(numId)) { + referenced.push(numId); + logger.debug(` 📑 탭 컴포넌트에서 화면 참조 발견: ${numId} (탭: ${tab.label || tab.id})`); + } + } + } + } } return referenced; @@ -355,127 +340,6 @@ export class MenuCopyService { return flowIds; } - /** - * 코드 수집 - */ - private async collectCodes( - menuObjids: number[], - sourceCompanyCode: string, - client: PoolClient - ): Promise<{ categories: CodeCategory[]; codes: CodeInfo[] }> { - logger.info(`📋 코드 수집 시작: ${menuObjids.length}개 메뉴`); - - const categories: CodeCategory[] = []; - const codes: CodeInfo[] = []; - - for (const menuObjid of menuObjids) { - // 코드 카테고리 - const catsResult = await client.query( - `SELECT * FROM code_category - WHERE menu_objid = $1 AND company_code = $2`, - [menuObjid, sourceCompanyCode] - ); - categories.push(...catsResult.rows); - - // 각 카테고리의 코드 정보 - for (const cat of catsResult.rows) { - const codesResult = await client.query( - `SELECT * FROM code_info - WHERE code_category = $1 AND menu_objid = $2 AND company_code = $3`, - [cat.category_code, menuObjid, sourceCompanyCode] - ); - codes.push(...codesResult.rows); - } - } - - logger.info( - `✅ 코드 수집 완료: 카테고리 ${categories.length}개, 코드 ${codes.length}개` - ); - return { categories, codes }; - } - - /** - * 카테고리 설정 수집 - */ - private async collectCategorySettings( - menuObjids: number[], - sourceCompanyCode: string, - client: PoolClient - ): Promise<{ - columnMappings: any[]; - categoryValues: any[]; - }> { - logger.info(`📂 카테고리 설정 수집 시작: ${menuObjids.length}개 메뉴`); - - const columnMappings: any[] = []; - const categoryValues: any[] = []; - - // 카테고리 컬럼 매핑 (메뉴별 + 공통) - const mappingsResult = await client.query( - `SELECT * FROM category_column_mapping - WHERE (menu_objid = ANY($1) OR menu_objid = 0) - AND company_code = $2`, - [menuObjids, sourceCompanyCode] - ); - columnMappings.push(...mappingsResult.rows); - - // 테이블 컬럼 카테고리 값 (메뉴별 + 공통) - const valuesResult = await client.query( - `SELECT * FROM table_column_category_values - WHERE (menu_objid = ANY($1) OR menu_objid = 0) - AND company_code = $2`, - [menuObjids, sourceCompanyCode] - ); - categoryValues.push(...valuesResult.rows); - - logger.info( - `✅ 카테고리 설정 수집 완료: 컬럼 매핑 ${columnMappings.length}개 (공통 포함), 카테고리 값 ${categoryValues.length}개 (공통 포함)` - ); - return { columnMappings, categoryValues }; - } - - /** - * 채번 규칙 수집 - */ - private async collectNumberingRules( - menuObjids: number[], - sourceCompanyCode: string, - client: PoolClient - ): Promise<{ - rules: any[]; - parts: any[]; - }> { - logger.info(`📋 채번 규칙 수집 시작: ${menuObjids.length}개 메뉴`); - - const rules: any[] = []; - const parts: any[] = []; - - for (const menuObjid of menuObjids) { - // 채번 규칙 - const rulesResult = await client.query( - `SELECT * FROM numbering_rules - WHERE menu_objid = $1 AND company_code = $2`, - [menuObjid, sourceCompanyCode] - ); - rules.push(...rulesResult.rows); - - // 각 규칙의 파트 - for (const rule of rulesResult.rows) { - const partsResult = await client.query( - `SELECT * FROM numbering_rule_parts - WHERE rule_id = $1 AND company_code = $2`, - [rule.rule_id, sourceCompanyCode] - ); - parts.push(...partsResult.rows); - } - } - - logger.info( - `✅ 채번 규칙 수집 완료: 규칙 ${rules.length}개, 파트 ${parts.length}개` - ); - return { rules, parts }; - } - /** * 다음 메뉴 objid 생성 */ @@ -709,42 +573,8 @@ export class MenuCopyService { ]); logger.info(` ✅ 메뉴 권한 삭제 완료`); - // 5-5. 채번 규칙 파트 삭제 - await client.query( - `DELETE FROM numbering_rule_parts - WHERE rule_id IN ( - SELECT rule_id FROM numbering_rules - WHERE menu_objid = ANY($1) AND company_code = $2 - )`, - [existingMenuIds, targetCompanyCode] - ); - logger.info(` ✅ 채번 규칙 파트 삭제 완료`); - - // 5-6. 채번 규칙 삭제 - await client.query( - `DELETE FROM numbering_rules - WHERE menu_objid = ANY($1) AND company_code = $2`, - [existingMenuIds, targetCompanyCode] - ); - logger.info(` ✅ 채번 규칙 삭제 완료`); - - // 5-7. 테이블 컬럼 카테고리 값 삭제 - await client.query( - `DELETE FROM table_column_category_values - WHERE menu_objid = ANY($1) AND company_code = $2`, - [existingMenuIds, targetCompanyCode] - ); - logger.info(` ✅ 카테고리 값 삭제 완료`); - - // 5-8. 카테고리 컬럼 매핑 삭제 - await client.query( - `DELETE FROM category_column_mapping - WHERE menu_objid = ANY($1) AND company_code = $2`, - [existingMenuIds, targetCompanyCode] - ); - logger.info(` ✅ 카테고리 매핑 삭제 완료`); - - // 5-9. 메뉴 삭제 (역순: 하위 메뉴부터) + // 5-5. 메뉴 삭제 (역순: 하위 메뉴부터) + // 주의: 채번 규칙과 카테고리 설정은 회사마다 고유하므로 삭제하지 않음 for (let i = existingMenus.length - 1; i >= 0; i--) { await client.query(`DELETE FROM menu_info WHERE objid = $1`, [ existingMenus[i].objid, @@ -801,33 +631,11 @@ export class MenuCopyService { const flowIds = await this.collectFlows(screenIds, client); - const codes = await this.collectCodes( - menus.map((m) => m.objid), - sourceCompanyCode, - client - ); - - const categorySettings = await this.collectCategorySettings( - menus.map((m) => m.objid), - sourceCompanyCode, - client - ); - - const numberingRules = await this.collectNumberingRules( - menus.map((m) => m.objid), - sourceCompanyCode, - client - ); - logger.info(` 📊 수집 완료: - 메뉴: ${menus.length}개 - 화면: ${screenIds.size}개 - 플로우: ${flowIds.size}개 - - 코드 카테고리: ${codes.categories.length}개 - - 코드: ${codes.codes.length}개 - - 카테고리 설정: 컬럼 매핑 ${categorySettings.columnMappings.length}개, 카테고리 값 ${categorySettings.categoryValues.length}개 - - 채번 규칙: 규칙 ${numberingRules.rules.length}개, 파트 ${numberingRules.parts.length}개 `); // === 2단계: 플로우 복사 === @@ -871,30 +679,6 @@ export class MenuCopyService { client ); - // === 6단계: 코드 복사 === - logger.info("\n📋 [6단계] 코드 복사"); - await this.copyCodes(codes, menuIdMap, targetCompanyCode, userId, client); - - // === 7단계: 카테고리 설정 복사 === - logger.info("\n📂 [7단계] 카테고리 설정 복사"); - await this.copyCategorySettings( - categorySettings, - menuIdMap, - targetCompanyCode, - userId, - client - ); - - // === 8단계: 채번 규칙 복사 === - logger.info("\n📋 [8단계] 채번 규칙 복사"); - await this.copyNumberingRules( - numberingRules, - menuIdMap, - targetCompanyCode, - userId, - client - ); - // 커밋 await client.query("COMMIT"); logger.info("✅ 트랜잭션 커밋 완료"); @@ -904,13 +688,6 @@ export class MenuCopyService { copiedMenus: menuIdMap.size, copiedScreens: screenIdMap.size, copiedFlows: flowIdMap.size, - copiedCategories: codes.categories.length, - copiedCodes: codes.codes.length, - copiedCategorySettings: - categorySettings.columnMappings.length + - categorySettings.categoryValues.length, - copiedNumberingRules: - numberingRules.rules.length + numberingRules.parts.length, menuIdMap: Object.fromEntries(menuIdMap), screenIdMap: Object.fromEntries(screenIdMap), flowIdMap: Object.fromEntries(flowIdMap), @@ -923,10 +700,8 @@ export class MenuCopyService { - 메뉴: ${result.copiedMenus}개 - 화면: ${result.copiedScreens}개 - 플로우: ${result.copiedFlows}개 - - 코드 카테고리: ${result.copiedCategories}개 - - 코드: ${result.copiedCodes}개 - - 카테고리 설정: ${result.copiedCategorySettings}개 - - 채번 규칙: ${result.copiedNumberingRules}개 + + ⚠️ 주의: 코드, 카테고리 설정, 채번 규칙은 복사되지 않습니다. ============================================ `); @@ -1125,13 +900,31 @@ export class MenuCopyService { const screenDef = screenDefResult.rows[0]; - // 2) 새 screen_code 생성 + // 2) 중복 체크: 같은 screen_code가 대상 회사에 이미 있는지 확인 + const existingScreenResult = await client.query<{ screen_id: number }>( + `SELECT screen_id FROM screen_definitions + WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL + LIMIT 1`, + [screenDef.screen_code, targetCompanyCode] + ); + + if (existingScreenResult.rows.length > 0) { + // 이미 존재하는 화면 - 복사하지 않고 기존 ID 매핑 + const existingScreenId = existingScreenResult.rows[0].screen_id; + screenIdMap.set(originalScreenId, existingScreenId); + logger.info( + ` ⏭️ 화면 이미 존재 (스킵): ${originalScreenId} → ${existingScreenId} (${screenDef.screen_code})` + ); + continue; // 레이아웃 복사도 스킵 + } + + // 3) 새 screen_code 생성 const newScreenCode = await this.generateUniqueScreenCode( targetCompanyCode, client ); - // 2-1) 화면명 변환 적용 + // 4) 화면명 변환 적용 let transformedScreenName = screenDef.screen_name; if (screenNameConfig) { // 1. 제거할 텍스트 제거 @@ -1150,7 +943,7 @@ export class MenuCopyService { } } - // 3) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화) + // 5) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화) const newScreenResult = await client.query<{ screen_id: number }>( `INSERT INTO screen_definitions ( screen_name, screen_code, table_name, company_code, @@ -1479,383 +1272,4 @@ export class MenuCopyService { logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}개`); } - /** - * 코드 카테고리 중복 체크 - */ - private async checkCodeCategoryExists( - categoryCode: string, - companyCode: string, - menuObjid: number, - client: PoolClient - ): Promise { - const result = await client.query<{ exists: boolean }>( - `SELECT EXISTS( - SELECT 1 FROM code_category - WHERE category_code = $1 AND company_code = $2 AND menu_objid = $3 - ) as exists`, - [categoryCode, companyCode, menuObjid] - ); - return result.rows[0].exists; - } - - /** - * 코드 정보 중복 체크 - */ - private async checkCodeInfoExists( - categoryCode: string, - codeValue: string, - companyCode: string, - menuObjid: number, - client: PoolClient - ): Promise { - const result = await client.query<{ exists: boolean }>( - `SELECT EXISTS( - SELECT 1 FROM code_info - WHERE code_category = $1 AND code_value = $2 - AND company_code = $3 AND menu_objid = $4 - ) as exists`, - [categoryCode, codeValue, companyCode, menuObjid] - ); - return result.rows[0].exists; - } - - /** - * 코드 복사 - */ - private async copyCodes( - codes: { categories: CodeCategory[]; codes: CodeInfo[] }, - menuIdMap: Map, - targetCompanyCode: string, - userId: string, - client: PoolClient - ): Promise { - logger.info(`📋 코드 복사 중...`); - - let categoryCount = 0; - let codeCount = 0; - let skippedCategories = 0; - let skippedCodes = 0; - - // 1) 코드 카테고리 복사 (중복 체크) - for (const category of codes.categories) { - const newMenuObjid = menuIdMap.get(category.menu_objid); - if (!newMenuObjid) continue; - - // 중복 체크 - const exists = await this.checkCodeCategoryExists( - category.category_code, - targetCompanyCode, - newMenuObjid, - client - ); - - if (exists) { - skippedCategories++; - logger.debug( - ` ⏭️ 카테고리 이미 존재: ${category.category_code} (menu_objid=${newMenuObjid})` - ); - continue; - } - - // 카테고리 복사 - await client.query( - `INSERT INTO code_category ( - category_code, category_name, category_name_eng, description, - sort_order, is_active, company_code, menu_objid, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, - [ - category.category_code, - category.category_name, - category.category_name_eng, - category.description, - category.sort_order, - category.is_active, - targetCompanyCode, // 새 회사 코드 - newMenuObjid, // 재매핑 - userId, - ] - ); - - categoryCount++; - } - - // 2) 코드 정보 복사 (중복 체크) - for (const code of codes.codes) { - const newMenuObjid = menuIdMap.get(code.menu_objid); - if (!newMenuObjid) continue; - - // 중복 체크 - const exists = await this.checkCodeInfoExists( - code.code_category, - code.code_value, - targetCompanyCode, - newMenuObjid, - client - ); - - if (exists) { - skippedCodes++; - logger.debug( - ` ⏭️ 코드 이미 존재: ${code.code_category}.${code.code_value} (menu_objid=${newMenuObjid})` - ); - continue; - } - - // 코드 복사 - await client.query( - `INSERT INTO code_info ( - code_category, code_value, code_name, code_name_eng, description, - sort_order, is_active, company_code, menu_objid, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, - [ - code.code_category, - code.code_value, - code.code_name, - code.code_name_eng, - code.description, - code.sort_order, - code.is_active, - targetCompanyCode, // 새 회사 코드 - newMenuObjid, // 재매핑 - userId, - ] - ); - - codeCount++; - } - - logger.info( - `✅ 코드 복사 완료: 카테고리 ${categoryCount}개 (${skippedCategories}개 스킵), 코드 ${codeCount}개 (${skippedCodes}개 스킵)` - ); - } - - /** - * 카테고리 설정 복사 - */ - private async copyCategorySettings( - settings: { columnMappings: any[]; categoryValues: any[] }, - menuIdMap: Map, - targetCompanyCode: string, - userId: string, - client: PoolClient - ): Promise { - logger.info(`📂 카테고리 설정 복사 중...`); - - const valueIdMap = new Map(); // 원본 value_id → 새 value_id - let mappingCount = 0; - let valueCount = 0; - - // 1) 카테고리 컬럼 매핑 복사 (덮어쓰기 모드) - for (const mapping of settings.columnMappings) { - // menu_objid = 0인 공통 설정은 그대로 0으로 유지 - let newMenuObjid: number | undefined; - - if ( - mapping.menu_objid === 0 || - mapping.menu_objid === "0" || - mapping.menu_objid == 0 - ) { - newMenuObjid = 0; // 공통 설정 - } else { - newMenuObjid = menuIdMap.get(mapping.menu_objid); - if (newMenuObjid === undefined) { - logger.debug( - ` ⏭️ 매핑할 메뉴가 없음: menu_objid=${mapping.menu_objid}` - ); - continue; - } - } - - // 기존 매핑 삭제 (덮어쓰기) - await client.query( - `DELETE FROM category_column_mapping - WHERE table_name = $1 AND physical_column_name = $2 AND company_code = $3`, - [mapping.table_name, mapping.physical_column_name, targetCompanyCode] - ); - - // 새 매핑 추가 - await client.query( - `INSERT INTO category_column_mapping ( - table_name, logical_column_name, physical_column_name, - menu_objid, company_code, description, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7)`, - [ - mapping.table_name, - mapping.logical_column_name, - mapping.physical_column_name, - newMenuObjid, - targetCompanyCode, - mapping.description, - userId, - ] - ); - - mappingCount++; - } - - // 2) 테이블 컬럼 카테고리 값 복사 (덮어쓰기 모드, 부모-자식 관계 유지) - const sortedValues = settings.categoryValues.sort( - (a, b) => a.depth - b.depth - ); - - // 먼저 기존 값들을 모두 삭제 (테이블+컬럼 단위) - const uniqueTableColumns = new Set(); - for (const value of sortedValues) { - uniqueTableColumns.add(`${value.table_name}:${value.column_name}`); - } - - for (const tableColumn of uniqueTableColumns) { - const [tableName, columnName] = tableColumn.split(":"); - await client.query( - `DELETE FROM table_column_category_values - WHERE table_name = $1 AND column_name = $2 AND company_code = $3`, - [tableName, columnName, targetCompanyCode] - ); - logger.debug(` 🗑️ 기존 카테고리 값 삭제: ${tableName}.${columnName}`); - } - - // 새 값 추가 - for (const value of sortedValues) { - // menu_objid = 0인 공통 설정은 그대로 0으로 유지 - let newMenuObjid: number | undefined; - - if ( - value.menu_objid === 0 || - value.menu_objid === "0" || - value.menu_objid == 0 - ) { - newMenuObjid = 0; // 공통 설정 - } else { - newMenuObjid = menuIdMap.get(value.menu_objid); - if (newMenuObjid === undefined) { - logger.debug( - ` ⏭️ 매핑할 메뉴가 없음: menu_objid=${value.menu_objid}` - ); - continue; - } - } - - // 부모 ID 재매핑 - let newParentValueId = null; - if (value.parent_value_id) { - newParentValueId = valueIdMap.get(value.parent_value_id) || null; - } - - const result = await client.query( - `INSERT INTO table_column_category_values ( - table_name, column_name, value_code, value_label, - value_order, parent_value_id, depth, description, - color, icon, is_active, is_default, - company_code, menu_objid, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) - RETURNING value_id`, - [ - value.table_name, - value.column_name, - value.value_code, - value.value_label, - value.value_order, - newParentValueId, - value.depth, - value.description, - value.color, - value.icon, - value.is_active, - value.is_default, - targetCompanyCode, - newMenuObjid, - userId, - ] - ); - - // ID 매핑 저장 - const newValueId = result.rows[0].value_id; - valueIdMap.set(value.value_id, newValueId); - - valueCount++; - } - - logger.info( - `✅ 카테고리 설정 복사 완료: 컬럼 매핑 ${mappingCount}개, 카테고리 값 ${valueCount}개 (덮어쓰기)` - ); - } - - /** - * 채번 규칙 복사 - */ - private async copyNumberingRules( - rules: { rules: any[]; parts: any[] }, - menuIdMap: Map, - targetCompanyCode: string, - userId: string, - client: PoolClient - ): Promise { - logger.info(`📋 채번 규칙 복사 중...`); - - const ruleIdMap = new Map(); // 원본 rule_id → 새 rule_id - let ruleCount = 0; - let partCount = 0; - - // 1) 채번 규칙 복사 - for (const rule of rules.rules) { - const newMenuObjid = menuIdMap.get(rule.menu_objid); - if (!newMenuObjid) continue; - - // 새 rule_id 생성 (타임스탬프 기반) - const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - ruleIdMap.set(rule.rule_id, newRuleId); - - await client.query( - `INSERT INTO numbering_rules ( - rule_id, rule_name, description, separator, - reset_period, current_sequence, table_name, column_name, - company_code, menu_objid, created_by, scope_type - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, - [ - newRuleId, - rule.rule_name, - rule.description, - rule.separator, - rule.reset_period, - 1, // 시퀀스 초기화 - rule.table_name, - rule.column_name, - targetCompanyCode, - newMenuObjid, - userId, - rule.scope_type, - ] - ); - - ruleCount++; - } - - // 2) 채번 규칙 파트 복사 - for (const part of rules.parts) { - const newRuleId = ruleIdMap.get(part.rule_id); - if (!newRuleId) continue; - - await client.query( - `INSERT INTO numbering_rule_parts ( - rule_id, part_order, part_type, generation_method, - auto_config, manual_config, company_code - ) VALUES ($1, $2, $3, $4, $5, $6, $7)`, - [ - newRuleId, - part.part_order, - part.part_type, - part.generation_method, - part.auto_config, - part.manual_config, - targetCompanyCode, - ] - ); - - partCount++; - } - - logger.info( - `✅ 채번 규칙 복사 완료: 규칙 ${ruleCount}개, 파트 ${partCount}개` - ); - } } diff --git a/backend-node/src/services/menuService.ts b/backend-node/src/services/menuService.ts index 86df579c..57bddabd 100644 --- a/backend-node/src/services/menuService.ts +++ b/backend-node/src/services/menuService.ts @@ -102,6 +102,72 @@ export async function getSiblingMenuObjids(menuObjid: number): Promise } } +/** + * 선택한 메뉴와 그 하위 메뉴들의 OBJID 조회 + * + * 형제 메뉴는 포함하지 않고, 선택한 메뉴와 그 자식 메뉴들만 반환합니다. + * 채번 규칙 필터링 등 특정 메뉴 계층만 필요할 때 사용합니다. + * + * @param menuObjid 메뉴 OBJID + * @returns 선택한 메뉴 + 모든 하위 메뉴 OBJID 배열 (재귀적) + * + * @example + * // 메뉴 구조: + * // └── 구매관리 (100) + * // ├── 공급업체관리 (101) + * // ├── 발주관리 (102) + * // └── 입고관리 (103) + * // └── 입고상세 (104) + * + * await getMenuAndChildObjids(100); + * // 결과: [100, 101, 102, 103, 104] + */ +export async function getMenuAndChildObjids(menuObjid: number): Promise { + const pool = getPool(); + + try { + logger.debug("메뉴 및 하위 메뉴 조회 시작", { menuObjid }); + + // 재귀 CTE를 사용하여 선택한 메뉴와 모든 하위 메뉴 조회 + const query = ` + WITH RECURSIVE menu_tree AS ( + -- 시작점: 선택한 메뉴 + SELECT objid, parent_obj_id, 1 AS depth + FROM menu_info + WHERE objid = $1 + + UNION ALL + + -- 재귀: 하위 메뉴들 + SELECT m.objid, m.parent_obj_id, mt.depth + 1 + FROM menu_info m + INNER JOIN menu_tree mt ON m.parent_obj_id = mt.objid + WHERE mt.depth < 10 -- 무한 루프 방지 + ) + SELECT objid FROM menu_tree ORDER BY depth, objid + `; + + const result = await pool.query(query, [menuObjid]); + const objids = result.rows.map((row) => Number(row.objid)); + + logger.debug("메뉴 및 하위 메뉴 조회 완료", { + menuObjid, + totalCount: objids.length, + objids + }); + + return objids; + } catch (error: any) { + logger.error("메뉴 및 하위 메뉴 조회 실패", { + menuObjid, + error: error.message, + stack: error.stack + }); + // 에러 발생 시 안전하게 자기 자신만 반환 + return [menuObjid]; + } +} + /** * 여러 메뉴의 형제 메뉴 OBJID 합집합 조회 * diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index cb405b33..83b4f63b 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -4,7 +4,7 @@ import { getPool } from "../database/db"; import { logger } from "../utils/logger"; -import { getSiblingMenuObjids } from "./menuService"; +import { getMenuAndChildObjids } from "./menuService"; interface NumberingRulePart { id?: number; @@ -161,7 +161,7 @@ class NumberingRuleService { companyCode: string, menuObjid?: number ): Promise { - let siblingObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언 + let menuAndChildObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언 try { logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", { @@ -171,14 +171,14 @@ class NumberingRuleService { const pool = getPool(); - // 1. 형제 메뉴 OBJID 조회 + // 1. 선택한 메뉴와 하위 메뉴 OBJID 조회 (형제 메뉴 제외) if (menuObjid) { - siblingObjids = await getSiblingMenuObjids(menuObjid); - logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids }); + menuAndChildObjids = await getMenuAndChildObjids(menuObjid); + logger.info("선택한 메뉴 및 하위 메뉴 OBJID 목록", { menuObjid, menuAndChildObjids }); } // menuObjid가 없으면 global 규칙만 반환 - if (!menuObjid || siblingObjids.length === 0) { + if (!menuObjid || menuAndChildObjids.length === 0) { let query: string; let params: any[]; @@ -280,7 +280,7 @@ class NumberingRuleService { let params: any[]; if (companyCode === "*") { - // 최고 관리자: 모든 규칙 조회 (형제 메뉴 포함) + // 최고 관리자: 모든 규칙 조회 (선택한 메뉴 + 하위 메뉴) query = ` SELECT rule_id AS "ruleId", @@ -301,8 +301,7 @@ class NumberingRuleService { WHERE scope_type = 'global' OR (scope_type = 'menu' AND menu_objid = ANY($1)) - OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ✅ 메뉴별로 필터링 - OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성) + OR (scope_type = 'table' AND menu_objid = ANY($1)) ORDER BY CASE WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1 @@ -311,10 +310,10 @@ class NumberingRuleService { END, created_at DESC `; - params = [siblingObjids]; - logger.info("최고 관리자: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { siblingObjids }); + params = [menuAndChildObjids]; + logger.info("최고 관리자: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { menuAndChildObjids }); } else { - // 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함, 메뉴별 필터링) + // 일반 회사: 자신의 규칙만 조회 (선택한 메뉴 + 하위 메뉴) query = ` SELECT rule_id AS "ruleId", @@ -336,8 +335,7 @@ class NumberingRuleService { AND ( scope_type = 'global' OR (scope_type = 'menu' AND menu_objid = ANY($2)) - OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ✅ 메뉴별로 필터링 - OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성) + OR (scope_type = 'table' AND menu_objid = ANY($2)) ) ORDER BY CASE @@ -347,8 +345,8 @@ class NumberingRuleService { END, created_at DESC `; - params = [companyCode, siblingObjids]; - logger.info("회사별: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { companyCode, siblingObjids }); + params = [companyCode, menuAndChildObjids]; + logger.info("회사별: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { companyCode, menuAndChildObjids }); } logger.info("🔍 채번 규칙 쿼리 실행", { @@ -420,7 +418,7 @@ class NumberingRuleService { logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", { companyCode, menuObjid, - siblingCount: siblingObjids.length, + menuAndChildCount: menuAndChildObjids.length, count: result.rowCount, }); @@ -432,7 +430,7 @@ class NumberingRuleService { errorStack: error.stack, companyCode, menuObjid, - siblingObjids: siblingObjids || [], + menuAndChildObjids: menuAndChildObjids || [], }); throw error; } diff --git a/backend-node/src/services/riskAlertService.ts b/backend-node/src/services/riskAlertService.ts index f3561bbe..03a3fdf1 100644 --- a/backend-node/src/services/riskAlertService.ts +++ b/backend-node/src/services/riskAlertService.ts @@ -47,9 +47,24 @@ export class RiskAlertService { console.log('✅ 기상청 특보 현황 API 응답 수신 완료'); - // 텍스트 응답 파싱 (EUC-KR 인코딩) + // 텍스트 응답 파싱 (인코딩 자동 감지) const iconv = require('iconv-lite'); - const responseText = iconv.decode(Buffer.from(warningResponse.data), 'EUC-KR'); + const buffer = Buffer.from(warningResponse.data); + + // UTF-8 먼저 시도, 실패하면 EUC-KR 시도 + let responseText: string; + const utf8Text = buffer.toString('utf-8'); + + // UTF-8로 정상 디코딩되었는지 확인 (한글이 깨지지 않았는지) + if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') || + (utf8Text.includes('#START7777') && !utf8Text.includes('�'))) { + responseText = utf8Text; + console.log('📝 UTF-8 인코딩으로 디코딩'); + } else { + // EUC-KR로 디코딩 + responseText = iconv.decode(buffer, 'EUC-KR'); + console.log('📝 EUC-KR 인코딩으로 디코딩'); + } if (typeof responseText === 'string' && responseText.includes('#START7777')) { const lines = responseText.split('\n'); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 71550fd6..007a39e7 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -326,7 +326,19 @@ export class ScreenManagementService { */ async updateScreenInfo( screenId: number, - updateData: { screenName: string; tableName?: string; description?: string; isActive: string }, + updateData: { + screenName: string; + tableName?: string; + description?: string; + isActive: string; + // REST API 관련 필드 추가 + dataSourceType?: string; + dbSourceType?: string; + dbConnectionId?: number; + restApiConnectionId?: number; + restApiEndpoint?: string; + restApiJsonPath?: string; + }, userCompanyCode: string ): Promise { // 권한 확인 @@ -348,24 +360,43 @@ export class ScreenManagementService { throw new Error("이 화면을 수정할 권한이 없습니다."); } - // 화면 정보 업데이트 (tableName 포함) + // 화면 정보 업데이트 (REST API 필드 포함) await query( `UPDATE screen_definitions SET screen_name = $1, table_name = $2, description = $3, is_active = $4, - updated_date = $5 - WHERE screen_id = $6`, + updated_date = $5, + data_source_type = $6, + db_source_type = $7, + db_connection_id = $8, + rest_api_connection_id = $9, + rest_api_endpoint = $10, + rest_api_json_path = $11 + WHERE screen_id = $12`, [ updateData.screenName, updateData.tableName || null, updateData.description || null, updateData.isActive, new Date(), + updateData.dataSourceType || "database", + updateData.dbSourceType || "internal", + updateData.dbConnectionId || null, + updateData.restApiConnectionId || null, + updateData.restApiEndpoint || null, + updateData.restApiJsonPath || null, screenId, ] ); + + console.log(`화면 정보 업데이트 완료: screenId=${screenId}`, { + dataSourceType: updateData.dataSourceType, + restApiConnectionId: updateData.restApiConnectionId, + restApiEndpoint: updateData.restApiEndpoint, + restApiJsonPath: updateData.restApiJsonPath, + }); } /** @@ -2016,37 +2047,40 @@ export class ScreenManagementService { // Advisory lock 획득 (다른 트랜잭션이 같은 회사 코드를 생성하는 동안 대기) await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]); - // 해당 회사의 기존 화면 코드들 조회 + // 해당 회사의 기존 화면 코드들 조회 (모든 화면 - 삭제된 코드도 재사용 방지) + // LIMIT 제거하고 숫자 추출하여 최대값 찾기 const existingScreens = await client.query<{ screen_code: string }>( `SELECT screen_code FROM screen_definitions - WHERE company_code = $1 AND screen_code LIKE $2 - ORDER BY screen_code DESC - LIMIT 10`, - [companyCode, `${companyCode}%`] + WHERE screen_code LIKE $1 + ORDER BY screen_code DESC`, + [`${companyCode}_%`] ); // 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기 let maxNumber = 0; const pattern = new RegExp( - `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$` + `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}_(\\d+)$` ); + console.log(`🔍 화면 코드 생성 - 조회된 화면 수: ${existingScreens.rows.length}`); + console.log(`🔍 패턴: ${pattern}`); + for (const screen of existingScreens.rows) { const match = screen.screen_code.match(pattern); if (match) { const number = parseInt(match[1], 10); + console.log(`🔍 매칭: ${screen.screen_code} → 숫자: ${number}`); if (number > maxNumber) { maxNumber = number; } } } - // 다음 순번으로 화면 코드 생성 (3자리 패딩) + // 다음 순번으로 화면 코드 생성 const nextNumber = maxNumber + 1; - const paddedNumber = nextNumber.toString().padStart(3, "0"); - - const newCode = `${companyCode}_${paddedNumber}`; - console.log(`🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber})`); + // 숫자가 3자리 이상이면 패딩 없이, 아니면 3자리 패딩 + const newCode = `${companyCode}_${nextNumber}`; + console.log(`🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber}, nextNumber: ${nextNumber})`); return newCode; // Advisory lock은 트랜잭션 종료 시 자동으로 해제됨 diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 2a379ae0..b68d5f05 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -1066,6 +1066,66 @@ class TableCategoryValueService { } } + /** + * 테이블+컬럼 기준으로 모든 매핑 삭제 + * + * 메뉴 선택 변경 시 기존 매핑을 모두 삭제하고 새로운 매핑만 추가하기 위해 사용 + * + * @param tableName - 테이블명 + * @param columnName - 컬럼명 + * @param companyCode - 회사 코드 + * @returns 삭제된 매핑 수 + */ + async deleteColumnMappingsByColumn( + tableName: string, + columnName: string, + companyCode: string + ): Promise { + const pool = getPool(); + + try { + logger.info("테이블+컬럼 기준 매핑 삭제", { tableName, columnName, companyCode }); + + // 멀티테넌시 적용 + let deleteQuery: string; + let deleteParams: any[]; + + if (companyCode === "*") { + // 최고 관리자: 해당 테이블+컬럼의 모든 매핑 삭제 + deleteQuery = ` + DELETE FROM category_column_mapping + WHERE table_name = $1 + AND logical_column_name = $2 + `; + deleteParams = [tableName, columnName]; + } else { + // 일반 회사: 자신의 매핑만 삭제 + deleteQuery = ` + DELETE FROM category_column_mapping + WHERE table_name = $1 + AND logical_column_name = $2 + AND company_code = $3 + `; + deleteParams = [tableName, columnName, companyCode]; + } + + const result = await pool.query(deleteQuery, deleteParams); + const deletedCount = result.rowCount || 0; + + logger.info("테이블+컬럼 기준 매핑 삭제 완료", { + tableName, + columnName, + companyCode, + deletedCount + }); + + return deletedCount; + } catch (error: any) { + logger.error(`테이블+컬럼 기준 매핑 삭제 실패: ${error.message}`); + throw error; + } + } + /** * 논리적 컬럼명을 물리적 컬럼명으로 변환 * diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index dabe41da..64eb44c8 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1165,12 +1165,26 @@ export class TableManagementService { paramCount: number; } | null> { try { - // 🔧 날짜 범위 문자열 "YYYY-MM-DD|YYYY-MM-DD" 체크 (최우선!) + // 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위) if (typeof value === "string" && value.includes("|")) { const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); + + // 날짜 타입이면 날짜 범위로 처리 if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) { return this.buildDateRangeCondition(columnName, value, paramIndex); } + + // 그 외 타입이면 다중선택(IN 조건)으로 처리 + const multiValues = value.split("|").filter((v: string) => v.trim() !== ""); + if (multiValues.length > 0) { + const placeholders = multiValues.map((_: string, idx: number) => `$${paramIndex + idx}`).join(", "); + logger.info(`🔍 다중선택 필터 적용: ${columnName} IN (${multiValues.join(", ")})`); + return { + whereClause: `${columnName}::text IN (${placeholders})`, + values: multiValues, + paramCount: multiValues.length, + }; + } } // 🔧 날짜 범위 객체 {from, to} 체크 diff --git a/backend-node/src/services/vehicleReportService.ts b/backend-node/src/services/vehicleReportService.ts new file mode 100644 index 00000000..842dff19 --- /dev/null +++ b/backend-node/src/services/vehicleReportService.ts @@ -0,0 +1,403 @@ +/** + * 차량 운행 리포트 서비스 + */ +import { getPool } from "../database/db"; + +interface DailyReportFilters { + startDate?: string; + endDate?: string; + userId?: string; + vehicleId?: number; +} + +interface WeeklyReportFilters { + year: number; + month: number; + userId?: string; + vehicleId?: number; +} + +interface MonthlyReportFilters { + year: number; + userId?: string; + vehicleId?: number; +} + +interface DriverReportFilters { + startDate?: string; + endDate?: string; + limit?: number; +} + +interface RouteReportFilters { + startDate?: string; + endDate?: string; + limit?: number; +} + +class VehicleReportService { + private get pool() { + return getPool(); + } + + /** + * 일별 통계 조회 + */ + async getDailyReport(companyCode: string, filters: DailyReportFilters) { + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + // 기본값: 최근 30일 + const endDate = filters.endDate || new Date().toISOString().split("T")[0]; + const startDate = + filters.startDate || + new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; + + conditions.push(`DATE(start_time) >= $${paramIndex++}`); + params.push(startDate); + conditions.push(`DATE(start_time) <= $${paramIndex++}`); + params.push(endDate); + + if (filters.userId) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(filters.userId); + } + + if (filters.vehicleId) { + conditions.push(`vehicle_id = $${paramIndex++}`); + params.push(filters.vehicleId); + } + + const whereClause = conditions.join(" AND "); + + const query = ` + SELECT + DATE(start_time) as date, + COUNT(*) as trip_count, + COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count, + COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_count, + COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance, + COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration, + COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance, + COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration + FROM vehicle_trip_summary + WHERE ${whereClause} + GROUP BY DATE(start_time) + ORDER BY DATE(start_time) DESC + `; + + const result = await this.pool.query(query, params); + + return { + startDate, + endDate, + data: result.rows.map((row) => ({ + date: row.date, + tripCount: parseInt(row.trip_count), + completedCount: parseInt(row.completed_count), + cancelledCount: parseInt(row.cancelled_count), + totalDistance: parseFloat(row.total_distance), + totalDuration: parseInt(row.total_duration), + avgDistance: parseFloat(row.avg_distance), + avgDuration: parseFloat(row.avg_duration), + })), + }; + } + + /** + * 주별 통계 조회 + */ + async getWeeklyReport(companyCode: string, filters: WeeklyReportFilters) { + const { year, month, userId, vehicleId } = filters; + + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + conditions.push(`EXTRACT(YEAR FROM start_time) = $${paramIndex++}`); + params.push(year); + conditions.push(`EXTRACT(MONTH FROM start_time) = $${paramIndex++}`); + params.push(month); + + if (userId) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(userId); + } + + if (vehicleId) { + conditions.push(`vehicle_id = $${paramIndex++}`); + params.push(vehicleId); + } + + const whereClause = conditions.join(" AND "); + + const query = ` + SELECT + EXTRACT(WEEK FROM start_time) as week_number, + MIN(DATE(start_time)) as week_start, + MAX(DATE(start_time)) as week_end, + COUNT(*) as trip_count, + COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count, + COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance, + COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration, + COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance + FROM vehicle_trip_summary + WHERE ${whereClause} + GROUP BY EXTRACT(WEEK FROM start_time) + ORDER BY week_number + `; + + const result = await this.pool.query(query, params); + + return { + year, + month, + data: result.rows.map((row) => ({ + weekNumber: parseInt(row.week_number), + weekStart: row.week_start, + weekEnd: row.week_end, + tripCount: parseInt(row.trip_count), + completedCount: parseInt(row.completed_count), + totalDistance: parseFloat(row.total_distance), + totalDuration: parseInt(row.total_duration), + avgDistance: parseFloat(row.avg_distance), + })), + }; + } + + /** + * 월별 통계 조회 + */ + async getMonthlyReport(companyCode: string, filters: MonthlyReportFilters) { + const { year, userId, vehicleId } = filters; + + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + conditions.push(`EXTRACT(YEAR FROM start_time) = $${paramIndex++}`); + params.push(year); + + if (userId) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(userId); + } + + if (vehicleId) { + conditions.push(`vehicle_id = $${paramIndex++}`); + params.push(vehicleId); + } + + const whereClause = conditions.join(" AND "); + + const query = ` + SELECT + EXTRACT(MONTH FROM start_time) as month, + COUNT(*) as trip_count, + COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count, + COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_count, + COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance, + COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration, + COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance, + COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration, + COUNT(DISTINCT user_id) as driver_count + FROM vehicle_trip_summary + WHERE ${whereClause} + GROUP BY EXTRACT(MONTH FROM start_time) + ORDER BY month + `; + + const result = await this.pool.query(query, params); + + return { + year, + data: result.rows.map((row) => ({ + month: parseInt(row.month), + tripCount: parseInt(row.trip_count), + completedCount: parseInt(row.completed_count), + cancelledCount: parseInt(row.cancelled_count), + totalDistance: parseFloat(row.total_distance), + totalDuration: parseInt(row.total_duration), + avgDistance: parseFloat(row.avg_distance), + avgDuration: parseFloat(row.avg_duration), + driverCount: parseInt(row.driver_count), + })), + }; + } + + /** + * 요약 통계 조회 (대시보드용) + */ + async getSummaryReport(companyCode: string, period: string) { + let dateCondition = ""; + + switch (period) { + case "today": + dateCondition = "DATE(start_time) = CURRENT_DATE"; + break; + case "week": + dateCondition = "start_time >= CURRENT_DATE - INTERVAL '7 days'"; + break; + case "month": + dateCondition = "start_time >= CURRENT_DATE - INTERVAL '30 days'"; + break; + case "year": + dateCondition = "EXTRACT(YEAR FROM start_time) = EXTRACT(YEAR FROM CURRENT_DATE)"; + break; + default: + dateCondition = "DATE(start_time) = CURRENT_DATE"; + } + + const query = ` + SELECT + COUNT(*) as total_trips, + COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_trips, + COUNT(CASE WHEN status = 'active' THEN 1 END) as active_trips, + COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_trips, + COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance, + COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration, + COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance, + COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration, + COUNT(DISTINCT user_id) as active_drivers + FROM vehicle_trip_summary + WHERE company_code = $1 AND ${dateCondition} + `; + + const result = await this.pool.query(query, [companyCode]); + const row = result.rows[0]; + + // 완료율 계산 + const totalTrips = parseInt(row.total_trips) || 0; + const completedTrips = parseInt(row.completed_trips) || 0; + const completionRate = totalTrips > 0 ? (completedTrips / totalTrips) * 100 : 0; + + return { + period, + totalTrips, + completedTrips, + activeTrips: parseInt(row.active_trips) || 0, + cancelledTrips: parseInt(row.cancelled_trips) || 0, + completionRate: parseFloat(completionRate.toFixed(1)), + totalDistance: parseFloat(row.total_distance) || 0, + totalDuration: parseInt(row.total_duration) || 0, + avgDistance: parseFloat(row.avg_distance) || 0, + avgDuration: parseFloat(row.avg_duration) || 0, + activeDrivers: parseInt(row.active_drivers) || 0, + }; + } + + /** + * 운전자별 통계 조회 + */ + async getDriverReport(companyCode: string, filters: DriverReportFilters) { + const conditions: string[] = ["vts.company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + if (filters.startDate) { + conditions.push(`DATE(vts.start_time) >= $${paramIndex++}`); + params.push(filters.startDate); + } + + if (filters.endDate) { + conditions.push(`DATE(vts.start_time) <= $${paramIndex++}`); + params.push(filters.endDate); + } + + const whereClause = conditions.join(" AND "); + const limit = filters.limit || 10; + + const query = ` + SELECT + vts.user_id, + ui.user_name, + COUNT(*) as trip_count, + COUNT(CASE WHEN vts.status = 'completed' THEN 1 END) as completed_count, + COALESCE(SUM(CASE WHEN vts.status = 'completed' THEN vts.total_distance ELSE 0 END), 0) as total_distance, + COALESCE(SUM(CASE WHEN vts.status = 'completed' THEN vts.duration_minutes ELSE 0 END), 0) as total_duration, + COALESCE(AVG(CASE WHEN vts.status = 'completed' THEN vts.total_distance END), 0) as avg_distance + FROM vehicle_trip_summary vts + LEFT JOIN user_info ui ON vts.user_id = ui.user_id + WHERE ${whereClause} + GROUP BY vts.user_id, ui.user_name + ORDER BY total_distance DESC + LIMIT $${paramIndex} + `; + + params.push(limit); + const result = await this.pool.query(query, params); + + return result.rows.map((row) => ({ + userId: row.user_id, + userName: row.user_name || row.user_id, + tripCount: parseInt(row.trip_count), + completedCount: parseInt(row.completed_count), + totalDistance: parseFloat(row.total_distance), + totalDuration: parseInt(row.total_duration), + avgDistance: parseFloat(row.avg_distance), + })); + } + + /** + * 구간별 통계 조회 + */ + async getRouteReport(companyCode: string, filters: RouteReportFilters) { + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + if (filters.startDate) { + conditions.push(`DATE(start_time) >= $${paramIndex++}`); + params.push(filters.startDate); + } + + if (filters.endDate) { + conditions.push(`DATE(start_time) <= $${paramIndex++}`); + params.push(filters.endDate); + } + + // 출발지/도착지가 있는 것만 + conditions.push("departure IS NOT NULL"); + conditions.push("arrival IS NOT NULL"); + + const whereClause = conditions.join(" AND "); + const limit = filters.limit || 10; + + const query = ` + SELECT + departure, + arrival, + departure_name, + destination_name, + COUNT(*) as trip_count, + COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count, + COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance, + COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance, + COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration + FROM vehicle_trip_summary + WHERE ${whereClause} + GROUP BY departure, arrival, departure_name, destination_name + ORDER BY trip_count DESC + LIMIT $${paramIndex} + `; + + params.push(limit); + const result = await this.pool.query(query, params); + + return result.rows.map((row) => ({ + departure: row.departure, + arrival: row.arrival, + departureName: row.departure_name || row.departure, + destinationName: row.destination_name || row.arrival, + tripCount: parseInt(row.trip_count), + completedCount: parseInt(row.completed_count), + totalDistance: parseFloat(row.total_distance), + avgDistance: parseFloat(row.avg_distance), + avgDuration: parseFloat(row.avg_duration), + })); + } +} + +export const vehicleReportService = new VehicleReportService(); + diff --git a/backend-node/src/services/vehicleTripService.ts b/backend-node/src/services/vehicleTripService.ts new file mode 100644 index 00000000..ee640e24 --- /dev/null +++ b/backend-node/src/services/vehicleTripService.ts @@ -0,0 +1,456 @@ +/** + * 차량 운행 이력 서비스 + */ +import { getPool } from "../database/db"; +import { v4 as uuidv4 } from "uuid"; +import { calculateDistance } from "../utils/geoUtils"; + +interface StartTripParams { + userId: string; + companyCode: string; + vehicleId?: number; + departure?: string; + arrival?: string; + departureName?: string; + destinationName?: string; + latitude: number; + longitude: number; +} + +interface EndTripParams { + tripId: string; + userId: string; + companyCode: string; + latitude: number; + longitude: number; +} + +interface AddLocationParams { + tripId: string; + userId: string; + companyCode: string; + latitude: number; + longitude: number; + accuracy?: number; + speed?: number; +} + +interface TripListFilters { + userId?: string; + vehicleId?: number; + status?: string; + startDate?: string; + endDate?: string; + departure?: string; + arrival?: string; + limit?: number; + offset?: number; +} + +class VehicleTripService { + private get pool() { + return getPool(); + } + + /** + * 운행 시작 + */ + async startTrip(params: StartTripParams) { + const { + userId, + companyCode, + vehicleId, + departure, + arrival, + departureName, + destinationName, + latitude, + longitude, + } = params; + + const tripId = `TRIP-${Date.now()}-${uuidv4().substring(0, 8)}`; + + // 1. vehicle_trip_summary에 운행 기록 생성 + const summaryQuery = ` + INSERT INTO vehicle_trip_summary ( + trip_id, user_id, vehicle_id, departure, arrival, + departure_name, destination_name, start_time, status, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), 'active', $8) + RETURNING * + `; + + const summaryResult = await this.pool.query(summaryQuery, [ + tripId, + userId, + vehicleId || null, + departure || null, + arrival || null, + departureName || null, + destinationName || null, + companyCode, + ]); + + // 2. 시작 위치 기록 + const locationQuery = ` + INSERT INTO vehicle_location_history ( + trip_id, user_id, vehicle_id, latitude, longitude, + trip_status, departure, arrival, departure_name, destination_name, + recorded_at, company_code + ) VALUES ($1, $2, $3, $4, $5, 'start', $6, $7, $8, $9, NOW(), $10) + RETURNING id + `; + + await this.pool.query(locationQuery, [ + tripId, + userId, + vehicleId || null, + latitude, + longitude, + departure || null, + arrival || null, + departureName || null, + destinationName || null, + companyCode, + ]); + + return { + tripId, + summary: summaryResult.rows[0], + startLocation: { latitude, longitude }, + }; + } + + /** + * 운행 종료 + */ + async endTrip(params: EndTripParams) { + const { tripId, userId, companyCode, latitude, longitude } = params; + + // 1. 운행 정보 조회 + const tripQuery = ` + SELECT * FROM vehicle_trip_summary + WHERE trip_id = $1 AND company_code = $2 AND status = 'active' + `; + const tripResult = await this.pool.query(tripQuery, [tripId, companyCode]); + + if (tripResult.rows.length === 0) { + throw new Error("활성 운행을 찾을 수 없습니다."); + } + + const trip = tripResult.rows[0]; + + // 2. 마지막 위치 기록 + const locationQuery = ` + INSERT INTO vehicle_location_history ( + trip_id, user_id, vehicle_id, latitude, longitude, + trip_status, departure, arrival, departure_name, destination_name, + recorded_at, company_code + ) VALUES ($1, $2, $3, $4, $5, 'end', $6, $7, $8, $9, NOW(), $10) + RETURNING id + `; + + await this.pool.query(locationQuery, [ + tripId, + userId, + trip.vehicle_id, + latitude, + longitude, + trip.departure, + trip.arrival, + trip.departure_name, + trip.destination_name, + companyCode, + ]); + + // 3. 총 거리 및 위치 수 계산 + const statsQuery = ` + SELECT + COUNT(*) as location_count, + MIN(recorded_at) as start_time, + MAX(recorded_at) as end_time + FROM vehicle_location_history + WHERE trip_id = $1 AND company_code = $2 + `; + const statsResult = await this.pool.query(statsQuery, [tripId, companyCode]); + const stats = statsResult.rows[0]; + + // 4. 모든 위치 데이터로 총 거리 계산 + const locationsQuery = ` + SELECT latitude, longitude + FROM vehicle_location_history + WHERE trip_id = $1 AND company_code = $2 + ORDER BY recorded_at ASC + `; + const locationsResult = await this.pool.query(locationsQuery, [tripId, companyCode]); + + let totalDistance = 0; + const locations = locationsResult.rows; + for (let i = 1; i < locations.length; i++) { + const prev = locations[i - 1]; + const curr = locations[i]; + totalDistance += calculateDistance( + prev.latitude, + prev.longitude, + curr.latitude, + curr.longitude + ); + } + + // 5. 운행 시간 계산 (분) + const startTime = new Date(stats.start_time); + const endTime = new Date(stats.end_time); + const durationMinutes = Math.round((endTime.getTime() - startTime.getTime()) / 60000); + + // 6. 운행 요약 업데이트 + const updateQuery = ` + UPDATE vehicle_trip_summary + SET + end_time = NOW(), + total_distance = $1, + duration_minutes = $2, + location_count = $3, + status = 'completed' + WHERE trip_id = $4 AND company_code = $5 + RETURNING * + `; + + const updateResult = await this.pool.query(updateQuery, [ + totalDistance.toFixed(3), + durationMinutes, + stats.location_count, + tripId, + companyCode, + ]); + + return { + tripId, + summary: updateResult.rows[0], + totalDistance: parseFloat(totalDistance.toFixed(3)), + durationMinutes, + locationCount: parseInt(stats.location_count), + }; + } + + /** + * 위치 기록 추가 (연속 추적) + */ + async addLocation(params: AddLocationParams) { + const { tripId, userId, companyCode, latitude, longitude, accuracy, speed } = params; + + // 1. 운행 정보 조회 + const tripQuery = ` + SELECT * FROM vehicle_trip_summary + WHERE trip_id = $1 AND company_code = $2 AND status = 'active' + `; + const tripResult = await this.pool.query(tripQuery, [tripId, companyCode]); + + if (tripResult.rows.length === 0) { + throw new Error("활성 운행을 찾을 수 없습니다."); + } + + const trip = tripResult.rows[0]; + + // 2. 이전 위치 조회 (거리 계산용) + const prevLocationQuery = ` + SELECT latitude, longitude + FROM vehicle_location_history + WHERE trip_id = $1 AND company_code = $2 + ORDER BY recorded_at DESC + LIMIT 1 + `; + const prevResult = await this.pool.query(prevLocationQuery, [tripId, companyCode]); + + let distanceFromPrev = 0; + if (prevResult.rows.length > 0) { + const prev = prevResult.rows[0]; + distanceFromPrev = calculateDistance( + prev.latitude, + prev.longitude, + latitude, + longitude + ); + } + + // 3. 위치 기록 추가 + const locationQuery = ` + INSERT INTO vehicle_location_history ( + trip_id, user_id, vehicle_id, latitude, longitude, + accuracy, speed, distance_from_prev, + trip_status, departure, arrival, departure_name, destination_name, + recorded_at, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'tracking', $9, $10, $11, $12, NOW(), $13) + RETURNING id + `; + + const result = await this.pool.query(locationQuery, [ + tripId, + userId, + trip.vehicle_id, + latitude, + longitude, + accuracy || null, + speed || null, + distanceFromPrev > 0 ? distanceFromPrev.toFixed(3) : null, + trip.departure, + trip.arrival, + trip.departure_name, + trip.destination_name, + companyCode, + ]); + + // 4. 운행 요약의 위치 수 업데이트 + await this.pool.query( + `UPDATE vehicle_trip_summary SET location_count = location_count + 1 WHERE trip_id = $1`, + [tripId] + ); + + return { + locationId: result.rows[0].id, + distanceFromPrev: parseFloat(distanceFromPrev.toFixed(3)), + }; + } + + /** + * 운행 이력 목록 조회 + */ + async getTripList(companyCode: string, filters: TripListFilters) { + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + if (filters.userId) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(filters.userId); + } + + if (filters.vehicleId) { + conditions.push(`vehicle_id = $${paramIndex++}`); + params.push(filters.vehicleId); + } + + if (filters.status) { + conditions.push(`status = $${paramIndex++}`); + params.push(filters.status); + } + + if (filters.startDate) { + conditions.push(`start_time >= $${paramIndex++}`); + params.push(filters.startDate); + } + + if (filters.endDate) { + conditions.push(`start_time <= $${paramIndex++}`); + params.push(filters.endDate + " 23:59:59"); + } + + if (filters.departure) { + conditions.push(`departure = $${paramIndex++}`); + params.push(filters.departure); + } + + if (filters.arrival) { + conditions.push(`arrival = $${paramIndex++}`); + params.push(filters.arrival); + } + + const whereClause = conditions.join(" AND "); + + // 총 개수 조회 + const countQuery = `SELECT COUNT(*) as total FROM vehicle_trip_summary WHERE ${whereClause}`; + const countResult = await this.pool.query(countQuery, params); + const total = parseInt(countResult.rows[0].total); + + // 목록 조회 + const limit = filters.limit || 50; + const offset = filters.offset || 0; + + const listQuery = ` + SELECT + vts.*, + ui.user_name, + v.vehicle_number + FROM vehicle_trip_summary vts + LEFT JOIN user_info ui ON vts.user_id = ui.user_id + LEFT JOIN vehicles v ON vts.vehicle_id = v.id + WHERE ${whereClause} + ORDER BY vts.start_time DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex++} + `; + + params.push(limit, offset); + const listResult = await this.pool.query(listQuery, params); + + return { + data: listResult.rows, + total, + }; + } + + /** + * 운행 상세 조회 (경로 포함) + */ + async getTripDetail(tripId: string, companyCode: string) { + // 1. 운행 요약 조회 + const summaryQuery = ` + SELECT + vts.*, + ui.user_name, + v.vehicle_number + FROM vehicle_trip_summary vts + LEFT JOIN user_info ui ON vts.user_id = ui.user_id + LEFT JOIN vehicles v ON vts.vehicle_id = v.id + WHERE vts.trip_id = $1 AND vts.company_code = $2 + `; + const summaryResult = await this.pool.query(summaryQuery, [tripId, companyCode]); + + if (summaryResult.rows.length === 0) { + return null; + } + + // 2. 경로 데이터 조회 + const routeQuery = ` + SELECT + id, latitude, longitude, accuracy, speed, + distance_from_prev, trip_status, recorded_at + FROM vehicle_location_history + WHERE trip_id = $1 AND company_code = $2 + ORDER BY recorded_at ASC + `; + const routeResult = await this.pool.query(routeQuery, [tripId, companyCode]); + + return { + summary: summaryResult.rows[0], + route: routeResult.rows, + }; + } + + /** + * 활성 운행 조회 + */ + async getActiveTrip(userId: string, companyCode: string) { + const query = ` + SELECT * FROM vehicle_trip_summary + WHERE user_id = $1 AND company_code = $2 AND status = 'active' + ORDER BY start_time DESC + LIMIT 1 + `; + const result = await this.pool.query(query, [userId, companyCode]); + return result.rows[0] || null; + } + + /** + * 운행 취소 + */ + async cancelTrip(tripId: string, companyCode: string) { + const query = ` + UPDATE vehicle_trip_summary + SET status = 'cancelled', end_time = NOW() + WHERE trip_id = $1 AND company_code = $2 AND status = 'active' + RETURNING * + `; + const result = await this.pool.query(query, [tripId, companyCode]); + return result.rows[0] || null; + } +} + +export const vehicleTripService = new VehicleTripService(); diff --git a/backend-node/src/types/batchTypes.ts b/backend-node/src/types/batchTypes.ts index 1cbec196..15efd003 100644 --- a/backend-node/src/types/batchTypes.ts +++ b/backend-node/src/types/batchTypes.ts @@ -1,4 +1,98 @@ -import { ApiResponse, ColumnInfo } from './batchTypes'; +// 배치관리 타입 정의 +// 작성일: 2024-12-24 + +// 공통 API 응답 타입 +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; + error?: string; + pagination?: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +} + +// 컬럼 정보 타입 +export interface ColumnInfo { + column_name: string; + data_type: string; + is_nullable?: string; + column_default?: string | null; +} + +// 테이블 정보 타입 +export interface TableInfo { + table_name: string; + table_type?: string; + table_schema?: string; +} + +// 연결 정보 타입 +export interface ConnectionInfo { + type: 'internal' | 'external'; + id?: number; + name: string; + db_type?: string; +} + +// 배치 설정 필터 타입 +export interface BatchConfigFilter { + page?: number; + limit?: number; + search?: string; + is_active?: string; + company_code?: string; +} + +// 배치 매핑 타입 +export interface BatchMapping { + id?: number; + batch_config_id?: number; + company_code?: string; + from_connection_type: 'internal' | 'external' | 'restapi'; + from_connection_id?: number; + from_table_name: string; + from_column_name: string; + from_column_type?: string; + from_api_url?: string; + from_api_key?: string; + from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + from_api_param_type?: 'url' | 'query'; + from_api_param_name?: string; + from_api_param_value?: string; + from_api_param_source?: 'static' | 'dynamic'; + from_api_body?: string; + to_connection_type: 'internal' | 'external' | 'restapi'; + to_connection_id?: number; + to_table_name: string; + to_column_name: string; + to_column_type?: string; + to_api_url?: string; + to_api_key?: string; + to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + to_api_body?: string; + mapping_order?: number; + created_by?: string; + created_date?: Date; +} + +// 배치 설정 타입 +export interface BatchConfig { + id?: number; + batch_name: string; + description?: string; + cron_schedule: string; + is_active: 'Y' | 'N'; + company_code?: string; + created_by?: string; + created_date?: Date; + updated_by?: string; + updated_date?: Date; + batch_mappings?: BatchMapping[]; +} export interface BatchConnectionInfo { type: 'internal' | 'external'; @@ -27,7 +121,7 @@ export interface BatchMappingRequest { from_api_param_name?: string; // API 파라미터명 from_api_param_value?: string; // API 파라미터 값 또는 템플릿 from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입 - // 👇 REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요) + // REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요) from_api_body?: string; to_connection_type: 'internal' | 'external' | 'restapi'; to_connection_id?: number; diff --git a/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts index c127eccc..c877a2b3 100644 --- a/backend-node/src/types/flow.ts +++ b/backend-node/src/types/flow.ts @@ -8,8 +8,12 @@ export interface FlowDefinition { name: string; description?: string; tableName: string; - dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입 + dbSourceType?: "internal" | "external" | "restapi"; // 데이터 소스 타입 dbConnectionId?: number; // 외부 DB 연결 ID (external인 경우) + // REST API 관련 필드 + restApiConnectionId?: number; // REST API 연결 ID (restapi인 경우) + restApiEndpoint?: string; // REST API 엔드포인트 + restApiJsonPath?: string; // JSON 응답에서 데이터 경로 (기본: data) companyCode: string; // 회사 코드 (* = 공통) isActive: boolean; createdBy?: string; @@ -22,8 +26,12 @@ export interface CreateFlowDefinitionRequest { name: string; description?: string; tableName: string; - dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입 + dbSourceType?: "internal" | "external" | "restapi"; // 데이터 소스 타입 dbConnectionId?: number; // 외부 DB 연결 ID + // REST API 관련 필드 + restApiConnectionId?: number; // REST API 연결 ID + restApiEndpoint?: string; // REST API 엔드포인트 + restApiJsonPath?: string; // JSON 응답에서 데이터 경로 companyCode?: string; // 회사 코드 (미제공 시 사용자의 company_code 사용) } diff --git a/backend-node/src/utils/geoUtils.ts b/backend-node/src/utils/geoUtils.ts new file mode 100644 index 00000000..50f370ad --- /dev/null +++ b/backend-node/src/utils/geoUtils.ts @@ -0,0 +1,176 @@ +/** + * 지리 좌표 관련 유틸리티 함수 + */ + +/** + * Haversine 공식을 사용하여 두 좌표 간의 거리 계산 (km) + * + * @param lat1 - 첫 번째 지점의 위도 + * @param lon1 - 첫 번째 지점의 경도 + * @param lat2 - 두 번째 지점의 위도 + * @param lon2 - 두 번째 지점의 경도 + * @returns 두 지점 간의 거리 (km) + */ +export function calculateDistance( + lat1: number, + lon1: number, + lat2: number, + lon2: number +): number { + const R = 6371; // 지구 반경 (km) + + const dLat = toRadians(lat2 - lat1); + const dLon = toRadians(lon2 - lon1); + + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRadians(lat1)) * + Math.cos(toRadians(lat2)) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return R * c; +} + +/** + * 각도를 라디안으로 변환 + */ +function toRadians(degrees: number): number { + return degrees * (Math.PI / 180); +} + +/** + * 라디안을 각도로 변환 + */ +export function toDegrees(radians: number): number { + return radians * (180 / Math.PI); +} + +/** + * 좌표 배열에서 총 거리 계산 + * + * @param coordinates - { latitude, longitude }[] 형태의 좌표 배열 + * @returns 총 거리 (km) + */ +export function calculateTotalDistance( + coordinates: Array<{ latitude: number; longitude: number }> +): number { + let totalDistance = 0; + + for (let i = 1; i < coordinates.length; i++) { + const prev = coordinates[i - 1]; + const curr = coordinates[i]; + totalDistance += calculateDistance( + prev.latitude, + prev.longitude, + curr.latitude, + curr.longitude + ); + } + + return totalDistance; +} + +/** + * 좌표가 특정 반경 내에 있는지 확인 + * + * @param centerLat - 중심점 위도 + * @param centerLon - 중심점 경도 + * @param pointLat - 확인할 지점의 위도 + * @param pointLon - 확인할 지점의 경도 + * @param radiusKm - 반경 (km) + * @returns 반경 내에 있으면 true + */ +export function isWithinRadius( + centerLat: number, + centerLon: number, + pointLat: number, + pointLon: number, + radiusKm: number +): boolean { + const distance = calculateDistance(centerLat, centerLon, pointLat, pointLon); + return distance <= radiusKm; +} + +/** + * 두 좌표 사이의 방위각(bearing) 계산 + * + * @param lat1 - 시작점 위도 + * @param lon1 - 시작점 경도 + * @param lat2 - 도착점 위도 + * @param lon2 - 도착점 경도 + * @returns 방위각 (0-360도) + */ +export function calculateBearing( + lat1: number, + lon1: number, + lat2: number, + lon2: number +): number { + const dLon = toRadians(lon2 - lon1); + const lat1Rad = toRadians(lat1); + const lat2Rad = toRadians(lat2); + + const x = Math.sin(dLon) * Math.cos(lat2Rad); + const y = + Math.cos(lat1Rad) * Math.sin(lat2Rad) - + Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLon); + + let bearing = toDegrees(Math.atan2(x, y)); + bearing = (bearing + 360) % 360; // 0-360 범위로 정규화 + + return bearing; +} + +/** + * 좌표 배열의 경계 상자(bounding box) 계산 + * + * @param coordinates - 좌표 배열 + * @returns { minLat, maxLat, minLon, maxLon } + */ +export function getBoundingBox( + coordinates: Array<{ latitude: number; longitude: number }> +): { minLat: number; maxLat: number; minLon: number; maxLon: number } | null { + if (coordinates.length === 0) return null; + + let minLat = coordinates[0].latitude; + let maxLat = coordinates[0].latitude; + let minLon = coordinates[0].longitude; + let maxLon = coordinates[0].longitude; + + for (const coord of coordinates) { + minLat = Math.min(minLat, coord.latitude); + maxLat = Math.max(maxLat, coord.latitude); + minLon = Math.min(minLon, coord.longitude); + maxLon = Math.max(maxLon, coord.longitude); + } + + return { minLat, maxLat, minLon, maxLon }; +} + +/** + * 좌표 배열의 중심점 계산 + * + * @param coordinates - 좌표 배열 + * @returns { latitude, longitude } 중심점 + */ +export function getCenterPoint( + coordinates: Array<{ latitude: number; longitude: number }> +): { latitude: number; longitude: number } | null { + if (coordinates.length === 0) return null; + + let sumLat = 0; + let sumLon = 0; + + for (const coord of coordinates) { + sumLat += coord.latitude; + sumLon += coord.longitude; + } + + return { + latitude: sumLat / coordinates.length, + longitude: sumLon / coordinates.length, + }; +} diff --git a/frontend/app/(admin)/admin/vehicle-reports/page.tsx b/frontend/app/(admin)/admin/vehicle-reports/page.tsx new file mode 100644 index 00000000..ce84f584 --- /dev/null +++ b/frontend/app/(admin)/admin/vehicle-reports/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const VehicleReport = dynamic( + () => import("@/components/vehicle/VehicleReport"), + { + ssr: false, + loading: () => ( +
+
로딩 중...
+
+ ), + } +); + +export default function VehicleReportsPage() { + return ( +
+
+

운행 리포트

+

+ 차량 운행 통계 및 분석 리포트를 확인합니다. +

+
+ +
+ ); +} + diff --git a/frontend/app/(admin)/admin/vehicle-trips/page.tsx b/frontend/app/(admin)/admin/vehicle-trips/page.tsx new file mode 100644 index 00000000..fea63166 --- /dev/null +++ b/frontend/app/(admin)/admin/vehicle-trips/page.tsx @@ -0,0 +1,29 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const VehicleTripHistory = dynamic( + () => import("@/components/vehicle/VehicleTripHistory"), + { + ssr: false, + loading: () => ( +
+
로딩 중...
+
+ ), + } +); + +export default function VehicleTripsPage() { + return ( +
+
+

운행 이력 관리

+

+ 차량 운행 이력을 조회하고 관리합니다. +

+
+ +
+ ); +} diff --git a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx index e7680584..613ab16b 100644 --- a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx +++ b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx @@ -195,6 +195,7 @@ export default function DashboardListClient() { 제목 설명 + 생성자 생성일 수정일 작업 @@ -209,6 +210,9 @@ export default function DashboardListClient() {
+ +
+
@@ -277,6 +281,7 @@ export default function DashboardListClient() { 제목 설명 + 생성자 생성일 수정일 작업 @@ -296,6 +301,9 @@ export default function DashboardListClient() { {dashboard.description || "-"} + + {dashboard.createdByName || dashboard.createdBy || "-"} + {formatDate(dashboard.createdAt)} @@ -363,6 +371,10 @@ export default function DashboardListClient() { 설명 {dashboard.description || "-"} +
+ 생성자 + {dashboard.createdByName || dashboard.createdBy || "-"} +
생성일 {formatDate(dashboard.createdAt)} diff --git a/frontend/app/(main)/admin/flow-management/page.tsx b/frontend/app/(main)/admin/flow-management/page.tsx index bb2bf04a..5a335daf 100644 --- a/frontend/app/(main)/admin/flow-management/page.tsx +++ b/frontend/app/(main)/admin/flow-management/page.tsx @@ -34,6 +34,7 @@ import { formatErrorMessage } from "@/lib/utils/errorUtils"; import { tableManagementApi } from "@/lib/api/tableManagement"; import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; +import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection"; export default function FlowManagementPage() { const router = useRouter(); @@ -52,13 +53,19 @@ export default function FlowManagementPage() { ); const [loadingTables, setLoadingTables] = useState(false); const [openTableCombobox, setOpenTableCombobox] = useState(false); - const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal"); // "internal" 또는 외부 DB connection ID + // 데이터 소스 타입: "internal" (내부 DB), "external_db_숫자" (외부 DB), "restapi_숫자" (REST API) + const [selectedDbSource, setSelectedDbSource] = useState("internal"); const [externalConnections, setExternalConnections] = useState< Array<{ id: number; connection_name: string; db_type: string }> >([]); const [externalTableList, setExternalTableList] = useState([]); const [loadingExternalTables, setLoadingExternalTables] = useState(false); + // REST API 연결 관련 상태 + const [restApiConnections, setRestApiConnections] = useState([]); + const [restApiEndpoint, setRestApiEndpoint] = useState(""); + const [restApiJsonPath, setRestApiJsonPath] = useState("data"); + // 생성 폼 상태 const [formData, setFormData] = useState({ name: "", @@ -135,75 +142,132 @@ export default function FlowManagementPage() { loadConnections(); }, []); + // REST API 연결 목록 로드 + useEffect(() => { + const loadRestApiConnections = async () => { + try { + const connections = await ExternalRestApiConnectionAPI.getConnections({ is_active: "Y" }); + setRestApiConnections(connections); + } catch (error) { + console.error("Failed to load REST API connections:", error); + setRestApiConnections([]); + } + }; + loadRestApiConnections(); + }, []); + // 외부 DB 테이블 목록 로드 useEffect(() => { - if (selectedDbSource === "internal" || !selectedDbSource) { + // REST API인 경우 테이블 목록 로드 불필요 + if (selectedDbSource === "internal" || !selectedDbSource || selectedDbSource.startsWith("restapi_")) { setExternalTableList([]); return; } - const loadExternalTables = async () => { - try { - setLoadingExternalTables(true); - const token = localStorage.getItem("authToken"); + // 외부 DB인 경우 + if (selectedDbSource.startsWith("external_db_")) { + const connectionId = selectedDbSource.replace("external_db_", ""); + + const loadExternalTables = async () => { + try { + setLoadingExternalTables(true); + const token = localStorage.getItem("authToken"); - const response = await fetch(`/api/multi-connection/connections/${selectedDbSource}/tables`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); + const response = await fetch(`/api/multi-connection/connections/${connectionId}/tables`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); - if (response && response.ok) { - const data = await response.json(); - if (data.success && data.data) { - const tables = Array.isArray(data.data) ? data.data : []; - const tableNames = tables - .map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) => - typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name, - ) - .filter(Boolean); - setExternalTableList(tableNames); + if (response && response.ok) { + const data = await response.json(); + if (data.success && data.data) { + const tables = Array.isArray(data.data) ? data.data : []; + const tableNames = tables + .map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) => + typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name, + ) + .filter(Boolean); + setExternalTableList(tableNames); + } else { + setExternalTableList([]); + } } else { setExternalTableList([]); } - } else { + } catch (error) { + console.error("외부 DB 테이블 목록 조회 오류:", error); setExternalTableList([]); + } finally { + setLoadingExternalTables(false); } - } catch (error) { - console.error("외부 DB 테이블 목록 조회 오류:", error); - setExternalTableList([]); - } finally { - setLoadingExternalTables(false); - } - }; + }; - loadExternalTables(); + loadExternalTables(); + } }, [selectedDbSource]); // 플로우 생성 const handleCreate = async () => { console.log("🚀 handleCreate called with formData:", formData); - if (!formData.name || !formData.tableName) { - console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName }); + // REST API인 경우 테이블 이름 검증 스킵 + const isRestApi = selectedDbSource.startsWith("restapi_"); + + if (!formData.name || (!isRestApi && !formData.tableName)) { + console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName, isRestApi }); toast({ title: "입력 오류", - description: "플로우 이름과 테이블 이름은 필수입니다.", + description: isRestApi ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.", + variant: "destructive", + }); + return; + } + + // REST API인 경우 엔드포인트 검증 + if (isRestApi && !restApiEndpoint) { + toast({ + title: "입력 오류", + description: "REST API 엔드포인트는 필수입니다.", variant: "destructive", }); return; } try { - // DB 소스 정보 추가 - const requestData = { + // 데이터 소스 타입 및 ID 파싱 + let dbSourceType: "internal" | "external" | "restapi" = "internal"; + let dbConnectionId: number | undefined = undefined; + let restApiConnectionId: number | undefined = undefined; + + if (selectedDbSource === "internal") { + dbSourceType = "internal"; + } else if (selectedDbSource.startsWith("external_db_")) { + dbSourceType = "external"; + dbConnectionId = parseInt(selectedDbSource.replace("external_db_", "")); + } else if (selectedDbSource.startsWith("restapi_")) { + dbSourceType = "restapi"; + restApiConnectionId = parseInt(selectedDbSource.replace("restapi_", "")); + } + + // 요청 데이터 구성 + const requestData: Record = { ...formData, - dbSourceType: selectedDbSource === "internal" ? "internal" : "external", - dbConnectionId: selectedDbSource === "internal" ? undefined : Number(selectedDbSource), + dbSourceType, + dbConnectionId, }; + // REST API인 경우 추가 정보 + if (dbSourceType === "restapi") { + requestData.restApiConnectionId = restApiConnectionId; + requestData.restApiEndpoint = restApiEndpoint; + requestData.restApiJsonPath = restApiJsonPath || "data"; + // REST API는 가상 테이블명 사용 + requestData.tableName = `_restapi_${restApiConnectionId}`; + } + console.log("✅ Calling createFlowDefinition with:", requestData); - const response = await createFlowDefinition(requestData); + const response = await createFlowDefinition(requestData as Parameters[0]); if (response.success && response.data) { toast({ title: "생성 완료", @@ -212,6 +276,8 @@ export default function FlowManagementPage() { setIsCreateDialogOpen(false); setFormData({ name: "", description: "", tableName: "" }); setSelectedDbSource("internal"); + setRestApiEndpoint(""); + setRestApiJsonPath("data"); loadFlows(); } else { toast({ @@ -415,125 +481,186 @@ export default function FlowManagementPage() { />
- {/* DB 소스 선택 */} + {/* 데이터 소스 선택 */}
- +

- 플로우에서 사용할 데이터베이스를 선택합니다 + 플로우에서 사용할 데이터 소스를 선택합니다

- {/* 테이블 선택 */} -
- - - - - - - - - - 테이블을 찾을 수 없습니다. - - {selectedDbSource === "internal" - ? // 내부 DB 테이블 목록 - tableList.map((table) => ( - { - console.log("📝 Internal table selected:", { - tableName: table.tableName, - currentValue, - }); - setFormData({ ...formData, tableName: currentValue }); - setOpenTableCombobox(false); - }} - className="text-xs sm:text-sm" - > - -
- {table.displayName || table.tableName} - {table.description && ( - {table.description} - )} -
-
- )) - : // 외부 DB 테이블 목록 - externalTableList.map((tableName, index) => ( - { - setFormData({ ...formData, tableName: currentValue }); - setOpenTableCombobox(false); - }} - className="text-xs sm:text-sm" - > - -
{tableName}
-
- ))} -
-
-
-
-
-

- 플로우의 모든 단계에서 사용할 기본 테이블입니다 (단계마다 상태 컬럼만 지정합니다) -

-
+ {/* REST API인 경우 엔드포인트 설정 */} + {selectedDbSource.startsWith("restapi_") ? ( + <> +
+ + setRestApiEndpoint(e.target.value)} + placeholder="예: /api/data/list" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 데이터를 조회할 API 엔드포인트 경로입니다 +

+
+
+ + setRestApiJsonPath(e.target.value)} + placeholder="예: data 또는 result.items" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 응답 JSON에서 데이터 배열의 경로입니다 (기본: data) +

+
+ + ) : ( + /* 테이블 선택 (내부 DB 또는 외부 DB) */ +
+ + + + + + + + + + 테이블을 찾을 수 없습니다. + + {selectedDbSource === "internal" + ? // 내부 DB 테이블 목록 + tableList.map((table) => ( + { + console.log("📝 Internal table selected:", { + tableName: table.tableName, + currentValue, + }); + setFormData({ ...formData, tableName: currentValue }); + setOpenTableCombobox(false); + }} + className="text-xs sm:text-sm" + > + +
+ {table.displayName || table.tableName} + {table.description && ( + {table.description} + )} +
+
+ )) + : // 외부 DB 테이블 목록 + externalTableList.map((tableName, index) => ( + { + setFormData({ ...formData, tableName: currentValue }); + setOpenTableCombobox(false); + }} + className="text-xs sm:text-sm" + > + +
{tableName}
+
+ ))} +
+
+
+
+
+

+ 플로우의 모든 단계에서 사용할 기본 테이블입니다 (단계마다 상태 컬럼만 지정합니다) +

+
+ )}
)} diff --git a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx index 5c516491..86da8fe7 100644 --- a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react"; import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection"; import { getApiUrl } from "@/lib/utils/apiUrl"; @@ -20,7 +21,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); const [apiConnections, setApiConnections] = useState([]); - const [selectedConnectionId, setSelectedConnectionId] = useState(""); + const [selectedConnectionId, setSelectedConnectionId] = useState(dataSource.externalConnectionId || ""); const [availableColumns, setAvailableColumns] = useState([]); // API 테스트 후 발견된 컬럼 목록 const [columnTypes, setColumnTypes] = useState>({}); // 컬럼 타입 정보 const [sampleData, setSampleData] = useState([]); // 샘플 데이터 (최대 3개) @@ -35,6 +36,13 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M loadApiConnections(); }, []); + // dataSource.externalConnectionId가 변경되면 selectedConnectionId 업데이트 + useEffect(() => { + if (dataSource.externalConnectionId) { + setSelectedConnectionId(dataSource.externalConnectionId); + } + }, [dataSource.externalConnectionId]); + // 외부 커넥션 선택 핸들러 const handleConnectionSelect = async (connectionId: string) => { setSelectedConnectionId(connectionId); @@ -58,11 +66,20 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M const updates: Partial = { endpoint: fullEndpoint, + externalConnectionId: connectionId, // 외부 연결 ID 저장 }; const headers: KeyValuePair[] = []; const queryParams: KeyValuePair[] = []; + // 기본 메서드/바디가 있으면 적용 + if (connection.default_method) { + updates.method = connection.default_method as ChartDataSource["method"]; + } + if (connection.default_body) { + updates.body = connection.default_body; + } + // 기본 헤더가 있으면 적용 if (connection.default_headers && Object.keys(connection.default_headers).length > 0) { Object.entries(connection.default_headers).forEach(([key, value]) => { @@ -210,6 +227,11 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M } }); + const bodyPayload = + dataSource.body && dataSource.body.trim().length > 0 + ? dataSource.body + : undefined; + const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { method: "POST", headers: { "Content-Type": "application/json" }, @@ -219,6 +241,8 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M method: dataSource.method || "GET", headers, queryParams, + body: bodyPayload, + externalConnectionId: dataSource.externalConnectionId, // 외부 연결 ID 전달 }), }); @@ -415,6 +439,58 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M

+ {/* HTTP 메서드 */} +
+ + +
+ + {/* Request Body (POST/PUT/PATCH 일 때만) */} + {(dataSource.method === "POST" || + dataSource.method === "PUT" || + dataSource.method === "PATCH") && ( +
+ +