diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 104a7fbe..d214c19a 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -73,6 +73,7 @@ import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검 import orderRoutes from "./routes/orderRoutes"; // 수주 관리 import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 +import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -238,6 +239,7 @@ 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/driver", driverRoutes); // 공차중계 운전자 관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index da0ea772..3ac5d26b 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1428,10 +1428,51 @@ export async function deleteMenu( } } + // 외래키 제약 조건이 있는 관련 테이블 데이터 먼저 정리 + const menuObjid = Number(menuId); + + // 1. category_column_mapping에서 menu_objid를 NULL로 설정 + await query( + `UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); + + // 2. code_category에서 menu_objid를 NULL로 설정 + await query( + `UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); + + // 3. code_info에서 menu_objid를 NULL로 설정 + await query( + `UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); + + // 4. numbering_rules에서 menu_objid를 NULL로 설정 + await query( + `UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); + + // 5. rel_menu_auth에서 관련 권한 삭제 + await query( + `DELETE FROM rel_menu_auth WHERE menu_objid = $1`, + [menuObjid] + ); + + // 6. screen_menu_assignments에서 관련 할당 삭제 + await query( + `DELETE FROM screen_menu_assignments WHERE menu_objid = $1`, + [menuObjid] + ); + + logger.info("메뉴 관련 데이터 정리 완료", { menuObjid }); + // Raw Query를 사용한 메뉴 삭제 const [deletedMenu] = await query( `DELETE FROM menu_info WHERE objid = $1 RETURNING *`, - [Number(menuId)] + [menuObjid] ); logger.info("메뉴 삭제 성공", { deletedMenu }); diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index 374015ee..6f72eb10 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -384,4 +384,69 @@ export class AuthController { }); } } + + /** + * POST /api/auth/signup + * 공차중계 회원가입 API + */ + static async signup(req: Request, res: Response): Promise { + try { + const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType } = req.body; + + logger.info(`=== 공차중계 회원가입 API 호출 ===`); + logger.info(`userId: ${userId}, vehicleNumber: ${vehicleNumber}`); + + // 입력값 검증 + if (!userId || !password || !userName || !phoneNumber || !licenseNumber || !vehicleNumber) { + res.status(400).json({ + success: false, + message: "필수 입력값이 누락되었습니다.", + error: { + code: "INVALID_INPUT", + details: "아이디, 비밀번호, 이름, 연락처, 면허번호, 차량번호는 필수입니다.", + }, + }); + return; + } + + // 회원가입 처리 + const signupResult = await AuthService.signupDriver({ + userId, + password, + userName, + phoneNumber, + licenseNumber, + vehicleNumber, + vehicleType, + }); + + if (signupResult.success) { + logger.info(`공차중계 회원가입 성공: ${userId}`); + res.status(201).json({ + success: true, + message: "회원가입이 완료되었습니다.", + }); + } else { + logger.warn(`공차중계 회원가입 실패: ${userId} - ${signupResult.message}`); + res.status(400).json({ + success: false, + message: signupResult.message || "회원가입에 실패했습니다.", + error: { + code: "SIGNUP_FAILED", + details: signupResult.message, + }, + }); + } + } catch (error) { + logger.error("공차중계 회원가입 API 오류:", error); + res.status(500).json({ + success: false, + message: "회원가입 처리 중 오류가 발생했습니다.", + error: { + code: "SIGNUP_ERROR", + details: error instanceof Error ? error.message : "알 수 없는 오류", + }, + }); + } + } } diff --git a/backend-node/src/controllers/driverController.ts b/backend-node/src/controllers/driverController.ts new file mode 100644 index 00000000..a448d9c0 --- /dev/null +++ b/backend-node/src/controllers/driverController.ts @@ -0,0 +1,458 @@ +// 공차중계 운전자 컨트롤러 +import { Request, Response } from "express"; +import { query } from "../database/db"; +import { logger } from "../utils/logger"; + +export class DriverController { + /** + * GET /api/driver/profile + * 운전자 프로필 조회 + */ + static async getProfile(req: Request, res: Response): Promise { + try { + const userId = req.user?.userId; + + if (!userId) { + res.status(401).json({ + success: false, + message: "인증이 필요합니다.", + }); + return; + } + + // 사용자 정보 조회 + const userResult = await query( + `SELECT + user_id, user_name, cell_phone, license_number, vehicle_number, signup_type, branch_name + FROM user_info + WHERE user_id = $1`, + [userId] + ); + + if (userResult.length === 0) { + res.status(404).json({ + success: false, + message: "사용자를 찾을 수 없습니다.", + }); + return; + } + + const user = userResult[0]; + + // 공차중계 사용자가 아닌 경우 + if (user.signup_type !== "DRIVER") { + res.status(403).json({ + success: false, + message: "공차중계 사용자만 접근할 수 있습니다.", + }); + return; + } + + // 차량 정보 조회 + const vehicleResult = await query( + `SELECT + vehicle_number, vehicle_type, driver_name, driver_phone, status + FROM vehicles + WHERE user_id = $1`, + [userId] + ); + + const vehicle = vehicleResult.length > 0 ? vehicleResult[0] : null; + + res.status(200).json({ + success: true, + data: { + userId: user.user_id, + userName: user.user_name, + phoneNumber: user.cell_phone, + licenseNumber: user.license_number, + vehicleNumber: user.vehicle_number, + vehicleType: vehicle?.vehicle_type || null, + vehicleStatus: vehicle?.status || null, + branchName: user.branch_name || null, + }, + }); + } catch (error) { + logger.error("운전자 프로필 조회 오류:", error); + res.status(500).json({ + success: false, + message: "프로필 조회 중 오류가 발생했습니다.", + }); + } + } + + /** + * PUT /api/driver/profile + * 운전자 프로필 수정 (이름, 연락처, 면허정보, 차량번호, 차종) + */ + static async updateProfile(req: Request, res: Response): Promise { + try { + const userId = req.user?.userId; + + if (!userId) { + res.status(401).json({ + success: false, + message: "인증이 필요합니다.", + }); + return; + } + + const { userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType, branchName } = req.body; + + // 공차중계 사용자 확인 + const userCheck = await query( + `SELECT signup_type, vehicle_number FROM user_info WHERE user_id = $1`, + [userId] + ); + + if (userCheck.length === 0) { + res.status(404).json({ + success: false, + message: "사용자를 찾을 수 없습니다.", + }); + return; + } + + if (userCheck[0].signup_type !== "DRIVER") { + res.status(403).json({ + success: false, + message: "공차중계 사용자만 접근할 수 있습니다.", + }); + return; + } + + const oldVehicleNumber = userCheck[0].vehicle_number; + + // 차량번호 변경 시 중복 확인 + if (vehicleNumber && vehicleNumber !== oldVehicleNumber) { + const duplicateCheck = await query( + `SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1 AND user_id != $2`, + [vehicleNumber, userId] + ); + + if (duplicateCheck.length > 0) { + res.status(400).json({ + success: false, + message: "이미 등록된 차량번호입니다.", + }); + return; + } + } + + // user_info 업데이트 + await query( + `UPDATE user_info SET + user_name = COALESCE($1, user_name), + cell_phone = COALESCE($2, cell_phone), + license_number = COALESCE($3, license_number), + vehicle_number = COALESCE($4, vehicle_number), + branch_name = COALESCE($5, branch_name) + WHERE user_id = $6`, + [userName || null, phoneNumber || null, licenseNumber || null, vehicleNumber || null, branchName || null, userId] + ); + + // vehicles 테이블 업데이트 + await query( + `UPDATE vehicles SET + vehicle_number = COALESCE($1, vehicle_number), + vehicle_type = COALESCE($2, vehicle_type), + driver_name = COALESCE($3, driver_name), + driver_phone = COALESCE($4, driver_phone), + branch_name = COALESCE($5, branch_name), + updated_at = NOW() + WHERE user_id = $6`, + [vehicleNumber || null, vehicleType || null, userName || null, phoneNumber || null, branchName || null, userId] + ); + + logger.info(`운전자 프로필 수정 완료: ${userId}`); + + res.status(200).json({ + success: true, + message: "프로필이 수정되었습니다.", + }); + } catch (error) { + logger.error("운전자 프로필 수정 오류:", error); + res.status(500).json({ + success: false, + message: "프로필 수정 중 오류가 발생했습니다.", + }); + } + } + + /** + * PUT /api/driver/status + * 차량 상태 변경 (대기/정비만 가능) + */ + static async updateStatus(req: Request, res: Response): Promise { + try { + const userId = req.user?.userId; + + if (!userId) { + res.status(401).json({ + success: false, + message: "인증이 필요합니다.", + }); + return; + } + + const { status } = req.body; + + // 허용된 상태값만 (대기: off, 정비: maintenance) + const allowedStatuses = ["off", "maintenance"]; + if (!status || !allowedStatuses.includes(status)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 상태값입니다. (off: 대기, maintenance: 정비)", + }); + return; + } + + // 공차중계 사용자 확인 + const userCheck = await query( + `SELECT signup_type FROM user_info WHERE user_id = $1`, + [userId] + ); + + if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") { + res.status(403).json({ + success: false, + message: "공차중계 사용자만 접근할 수 있습니다.", + }); + return; + } + + // vehicles 테이블 상태 업데이트 + const updateResult = await query( + `UPDATE vehicles SET status = $1, updated_at = NOW() WHERE user_id = $2`, + [status, userId] + ); + + logger.info(`차량 상태 변경: ${userId} -> ${status}`); + + res.status(200).json({ + success: true, + message: `차량 상태가 ${status === "off" ? "대기" : "정비"}로 변경되었습니다.`, + }); + } catch (error) { + logger.error("차량 상태 변경 오류:", error); + res.status(500).json({ + success: false, + message: "상태 변경 중 오류가 발생했습니다.", + }); + } + } + + /** + * DELETE /api/driver/vehicle + * 차량 삭제 (user_id = NULL 처리, 기록 보존) + */ + static async deleteVehicle(req: Request, res: Response): Promise { + try { + const userId = req.user?.userId; + + if (!userId) { + res.status(401).json({ + success: false, + message: "인증이 필요합니다.", + }); + return; + } + + // 공차중계 사용자 확인 + const userCheck = await query( + `SELECT signup_type, vehicle_number FROM user_info WHERE user_id = $1`, + [userId] + ); + + if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") { + res.status(403).json({ + success: false, + message: "공차중계 사용자만 접근할 수 있습니다.", + }); + return; + } + + // vehicles 테이블에서 user_id를 NULL로 변경하고 status를 disabled로 (기록 보존) + await query( + `UPDATE vehicles SET user_id = NULL, status = 'disabled', updated_at = NOW() WHERE user_id = $1`, + [userId] + ); + + // user_info에서 vehicle_number를 NULL로 변경 + await query( + `UPDATE user_info SET vehicle_number = NULL WHERE user_id = $1`, + [userId] + ); + + logger.info(`차량 삭제 완료 (기록 보존): ${userId}`); + + res.status(200).json({ + success: true, + message: "차량이 삭제되었습니다.", + }); + } catch (error) { + logger.error("차량 삭제 오류:", error); + res.status(500).json({ + success: false, + message: "차량 삭제 중 오류가 발생했습니다.", + }); + } + } + + /** + * POST /api/driver/vehicle + * 새 차량 등록 + */ + static async registerVehicle(req: Request, res: Response): Promise { + try { + const userId = req.user?.userId; + const companyCode = req.user?.companyCode; + + if (!userId) { + res.status(401).json({ + success: false, + message: "인증이 필요합니다.", + }); + return; + } + + const { vehicleNumber, vehicleType, branchName } = req.body; + + if (!vehicleNumber) { + res.status(400).json({ + success: false, + message: "차량번호는 필수입니다.", + }); + return; + } + + // 공차중계 사용자 확인 + const userCheck = await query( + `SELECT signup_type, user_name, cell_phone, vehicle_number, company_code FROM user_info WHERE user_id = $1`, + [userId] + ); + + if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") { + res.status(403).json({ + success: false, + message: "공차중계 사용자만 접근할 수 있습니다.", + }); + return; + } + + // 이미 차량이 있는지 확인 + if (userCheck[0].vehicle_number) { + res.status(400).json({ + success: false, + message: "이미 등록된 차량이 있습니다. 먼저 기존 차량을 삭제해주세요.", + }); + return; + } + + // 차량번호 중복 확인 + const duplicateCheck = await query( + `SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1 AND user_id IS NOT NULL`, + [vehicleNumber] + ); + + if (duplicateCheck.length > 0) { + res.status(400).json({ + success: false, + message: "이미 등록된 차량번호입니다.", + }); + return; + } + + const userName = userCheck[0].user_name; + const userPhone = userCheck[0].cell_phone; + // 사용자의 company_code 사용 (req.user에서 가져오거나 DB에서 조회한 값 사용) + const userCompanyCode = companyCode || userCheck[0].company_code; + + // vehicles 테이블에 새 차량 등록 (company_code 포함, status는 'off') + await query( + `INSERT INTO vehicles (vehicle_number, vehicle_type, user_id, driver_name, driver_phone, branch_name, status, company_code, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, 'off', $7, NOW(), NOW())`, + [vehicleNumber, vehicleType || null, userId, userName, userPhone, branchName || null, userCompanyCode] + ); + + // user_info에 vehicle_number 업데이트 + await query( + `UPDATE user_info SET vehicle_number = $1 WHERE user_id = $2`, + [vehicleNumber, userId] + ); + + logger.info(`새 차량 등록 완료: ${userId} -> ${vehicleNumber} (company_code: ${userCompanyCode})`); + + res.status(200).json({ + success: true, + message: "차량이 등록되었습니다.", + }); + } catch (error) { + logger.error("차량 등록 오류:", error); + res.status(500).json({ + success: false, + message: "차량 등록 중 오류가 발생했습니다.", + }); + } + } + + /** + * DELETE /api/driver/account + * 회원 탈퇴 (차량 정보 포함 삭제) + */ + static async deleteAccount(req: Request, res: Response): Promise { + try { + const userId = req.user?.userId; + + if (!userId) { + res.status(401).json({ + success: false, + message: "인증이 필요합니다.", + }); + return; + } + + // 공차중계 사용자 확인 + const userCheck = await query( + `SELECT signup_type FROM user_info WHERE user_id = $1`, + [userId] + ); + + if (userCheck.length === 0) { + res.status(404).json({ + success: false, + message: "사용자를 찾을 수 없습니다.", + }); + return; + } + + if (userCheck[0].signup_type !== "DRIVER") { + res.status(403).json({ + success: false, + message: "공차중계 사용자만 탈퇴할 수 있습니다.", + }); + return; + } + + // vehicles 테이블에서 삭제 + await query(`DELETE FROM vehicles WHERE user_id = $1`, [userId]); + + // user_info 테이블에서 삭제 + await query(`DELETE FROM user_info WHERE user_id = $1`, [userId]); + + logger.info(`회원 탈퇴 완료: ${userId}`); + + res.status(200).json({ + success: true, + message: "회원 탈퇴가 완료되었습니다.", + }); + } catch (error) { + logger.error("회원 탈퇴 오류:", error); + res.status(500).json({ + success: false, + message: "회원 탈퇴 처리 중 오류가 발생했습니다.", + }); + } + } +} + diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index 880c54fc..4d911c57 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -32,10 +32,32 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) { const companyCode = req.user!.companyCode; // 검색 필드 파싱 - const fields = searchFields + const requestedFields = searchFields ? (searchFields as string).split(",").map((f) => f.trim()) : []; + // 🆕 테이블의 실제 컬럼 목록 조회 + const pool = getPool(); + const columnsResult = await pool.query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1`, + [tableName] + ); + const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name)); + + // 🆕 존재하는 컬럼만 필터링 + const fields = requestedFields.filter((field) => { + if (existingColumns.has(field)) { + return true; + } else { + logger.warn(`엔티티 검색: 테이블 "${tableName}"에 컬럼 "${field}"이(가) 존재하지 않아 제외`); + return false; + } + }); + + const existingColumnsArray = Array.from(existingColumns); + logger.info(`엔티티 검색 필드 확인 - 테이블: ${tableName}, 요청필드: [${requestedFields.join(", ")}], 유효필드: [${fields.join(", ")}], 테이블컬럼(샘플): [${existingColumnsArray.slice(0, 10).join(", ")}]`); + // WHERE 조건 생성 const whereConditions: string[] = []; const params: any[] = []; @@ -43,32 +65,57 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) { // 멀티테넌시 필터링 if (companyCode !== "*") { - whereConditions.push(`company_code = $${paramIndex}`); - params.push(companyCode); - paramIndex++; + // 🆕 company_code 컬럼이 있는 경우에만 필터링 + if (existingColumns.has("company_code")) { + whereConditions.push(`company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } } // 검색 조건 - if (searchText && fields.length > 0) { - const searchConditions = fields.map((field) => { - const condition = `${field}::text ILIKE $${paramIndex}`; - paramIndex++; - return condition; - }); - whereConditions.push(`(${searchConditions.join(" OR ")})`); + if (searchText) { + // 유효한 검색 필드가 없으면 기본 텍스트 컬럼에서 검색 + let searchableFields = fields; + if (searchableFields.length === 0) { + // 기본 검색 컬럼: name, code, description 등 일반적인 컬럼명 + const defaultSearchColumns = [ + 'name', 'code', 'description', 'title', 'label', + 'item_name', 'item_code', 'item_number', + 'equipment_name', 'equipment_code', + 'inspection_item', 'consumable_name', // 소모품명 추가 + 'supplier_name', 'customer_name', 'product_name', + ]; + searchableFields = defaultSearchColumns.filter(col => existingColumns.has(col)); + + logger.info(`엔티티 검색: 기본 검색 필드 사용 - 테이블: ${tableName}, 검색필드: [${searchableFields.join(", ")}]`); + } + + if (searchableFields.length > 0) { + const searchConditions = searchableFields.map((field) => { + const condition = `${field}::text ILIKE $${paramIndex}`; + paramIndex++; + return condition; + }); + whereConditions.push(`(${searchConditions.join(" OR ")})`); - // 검색어 파라미터 추가 - fields.forEach(() => { - params.push(`%${searchText}%`); - }); + // 검색어 파라미터 추가 + searchableFields.forEach(() => { + params.push(`%${searchText}%`); + }); + } } - // 추가 필터 조건 + // 추가 필터 조건 (존재하는 컬럼만) const additionalFilter = JSON.parse(filterCondition as string); for (const [key, value] of Object.entries(additionalFilter)) { - whereConditions.push(`${key} = $${paramIndex}`); - params.push(value); - paramIndex++; + if (existingColumns.has(key)) { + whereConditions.push(`${key} = $${paramIndex}`); + params.push(value); + paramIndex++; + } else { + logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key }); + } } // 페이징 @@ -78,8 +125,7 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) { ? `WHERE ${whereConditions.join(" AND ")}` : ""; - // 쿼리 실행 - const pool = getPool(); + // 쿼리 실행 (pool은 위에서 이미 선언됨) const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`; const dataQuery = ` SELECT * FROM ${tableName} ${whereClause} diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index e03bfe25..9459e1f6 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -66,11 +66,12 @@ export class FlowController { return; } - // REST API인 경우 테이블 존재 확인 스킵 - const isRestApi = dbSourceType === "restapi"; + // REST API 또는 다중 연결인 경우 테이블 존재 확인 스킵 + const isRestApi = dbSourceType === "restapi" || dbSourceType === "multi_restapi"; + const isMultiConnection = dbSourceType === "multi_restapi" || dbSourceType === "multi_external_db"; - // 테이블 이름이 제공된 경우에만 존재 확인 (REST API 제외) - if (tableName && !isRestApi && !tableName.startsWith("_restapi_")) { + // 테이블 이름이 제공된 경우에만 존재 확인 (REST API 및 다중 연결 제외) + if (tableName && !isRestApi && !isMultiConnection && !tableName.startsWith("_restapi_") && !tableName.startsWith("_multi_restapi_") && !tableName.startsWith("_multi_external_db_")) { const tableExists = await this.flowDefinitionService.checkTableExists(tableName); if (!tableExists) { @@ -92,6 +93,7 @@ export class FlowController { restApiConnectionId, restApiEndpoint, restApiJsonPath, + restApiConnections: req.body.restApiConnections, // 다중 REST API 설정 }, userId, userCompanyCode diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index c7ecf75e..5605031e 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -325,6 +325,53 @@ export const getDeletedScreens = async ( } }; +// 활성 화면 일괄 삭제 (휴지통으로 이동) +export const bulkDeleteScreens = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { companyCode, userId } = req.user as any; + const { screenIds, deleteReason, force } = req.body; + + if (!Array.isArray(screenIds) || screenIds.length === 0) { + return res.status(400).json({ + success: false, + message: "삭제할 화면 ID 목록이 필요합니다.", + }); + } + + const result = await screenManagementService.bulkDeleteScreens( + screenIds, + companyCode, + userId, + deleteReason, + force || false + ); + + let message = `${result.deletedCount}개 화면이 휴지통으로 이동되었습니다.`; + if (result.skippedCount > 0) { + message += ` (${result.skippedCount}개 화면은 삭제되지 않았습니다.)`; + } + + return res.json({ + success: true, + message, + result: { + deletedCount: result.deletedCount, + skippedCount: result.skippedCount, + errors: result.errors, + }, + }); + } catch (error) { + console.error("활성 화면 일괄 삭제 실패:", error); + return res.status(500).json({ + success: false, + message: "일괄 삭제에 실패했습니다.", + }); + } +}; + // 휴지통 화면 일괄 영구 삭제 export const bulkPermanentDeleteScreens = async ( req: AuthenticatedRequest, diff --git a/backend-node/src/routes/authRoutes.ts b/backend-node/src/routes/authRoutes.ts index 29bc7944..adba86e6 100644 --- a/backend-node/src/routes/authRoutes.ts +++ b/backend-node/src/routes/authRoutes.ts @@ -41,4 +41,10 @@ router.post("/logout", AuthController.logout); */ router.post("/refresh", AuthController.refreshToken); +/** + * POST /api/auth/signup + * 공차중계 회원가입 API + */ +router.post("/signup", AuthController.signup); + export default router; diff --git a/backend-node/src/routes/driverRoutes.ts b/backend-node/src/routes/driverRoutes.ts new file mode 100644 index 00000000..b46cca1b --- /dev/null +++ b/backend-node/src/routes/driverRoutes.ts @@ -0,0 +1,48 @@ +// 공차중계 운전자 API 라우터 +import { Router } from "express"; +import { DriverController } from "../controllers/driverController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 필요 +router.use(authenticateToken); + +/** + * GET /api/driver/profile + * 운전자 프로필 조회 + */ +router.get("/profile", DriverController.getProfile); + +/** + * PUT /api/driver/profile + * 운전자 프로필 수정 (이름, 연락처, 면허정보, 차량번호, 차종) + */ +router.put("/profile", DriverController.updateProfile); + +/** + * PUT /api/driver/status + * 차량 상태 변경 (대기/정비만) + */ +router.put("/status", DriverController.updateStatus); + +/** + * DELETE /api/driver/vehicle + * 차량 삭제 (기록 보존) + */ +router.delete("/vehicle", DriverController.deleteVehicle); + +/** + * POST /api/driver/vehicle + * 새 차량 등록 + */ +router.post("/vehicle", DriverController.registerVehicle); + +/** + * DELETE /api/driver/account + * 회원 탈퇴 + */ +router.delete("/account", DriverController.deleteAccount); + +export default router; + diff --git a/backend-node/src/routes/externalRestApiConnectionRoutes.ts b/backend-node/src/routes/externalRestApiConnectionRoutes.ts index 48813575..14fd17d0 100644 --- a/backend-node/src/routes/externalRestApiConnectionRoutes.ts +++ b/backend-node/src/routes/externalRestApiConnectionRoutes.ts @@ -97,6 +97,8 @@ router.post( const data: ExternalRestApiConnection = { ...req.body, created_by: req.user?.userId || "system", + // 로그인 사용자의 company_code 사용 (프론트에서 안 보내도 자동 설정) + company_code: req.body.company_code || req.user?.companyCode || "*", }; const result = diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index 4207c719..67263277 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -8,6 +8,7 @@ import { updateScreen, updateScreenInfo, deleteScreen, + bulkDeleteScreens, checkScreenDependencies, restoreScreen, permanentDeleteScreen, @@ -44,6 +45,7 @@ router.put("/screens/:id", updateScreen); router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정 router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크 router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동 +router.delete("/screens/bulk/delete", bulkDeleteScreens); // 활성 화면 일괄 삭제 (휴지통으로 이동) router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지 router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크 router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용) diff --git a/backend-node/src/services/authService.ts b/backend-node/src/services/authService.ts index 11e34576..e5d6aa97 100644 --- a/backend-node/src/services/authService.ts +++ b/backend-node/src/services/authService.ts @@ -342,4 +342,130 @@ export class AuthService { ); } } + + /** + * 공차중계 회원가입 처리 + * - user_info 테이블에 사용자 정보 저장 + * - vehicles 테이블에 차량 정보 저장 + */ + static async signupDriver(data: { + userId: string; + password: string; + userName: string; + phoneNumber: string; + licenseNumber: string; + vehicleNumber: string; + vehicleType?: string; + }): Promise<{ success: boolean; message?: string }> { + try { + const { + userId, + password, + userName, + phoneNumber, + licenseNumber, + vehicleNumber, + vehicleType, + } = data; + + // 1. 중복 사용자 확인 + const existingUser = await query( + `SELECT user_id FROM user_info WHERE user_id = $1`, + [userId] + ); + + if (existingUser.length > 0) { + return { + success: false, + message: "이미 존재하는 아이디입니다.", + }; + } + + // 2. 중복 차량번호 확인 + const existingVehicle = await query( + `SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1`, + [vehicleNumber] + ); + + if (existingVehicle.length > 0) { + return { + success: false, + message: "이미 등록된 차량번호입니다.", + }; + } + + // 3. 비밀번호 암호화 (MD5 - 기존 시스템 호환) + const crypto = require("crypto"); + const hashedPassword = crypto + .createHash("md5") + .update(password) + .digest("hex"); + + // 4. 사용자 정보 저장 (user_info) + await query( + `INSERT INTO user_info ( + user_id, + user_password, + user_name, + cell_phone, + license_number, + vehicle_number, + company_code, + user_type, + signup_type, + status, + regdate + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())`, + [ + userId, + hashedPassword, + userName, + phoneNumber, + licenseNumber, + vehicleNumber, + "COMPANY_13", // 기본 회사 코드 + null, // user_type: null + "DRIVER", // signup_type: 공차중계 회원가입 사용자 + "active", // status: active + ] + ); + + // 5. 차량 정보 저장 (vehicles) + await query( + `INSERT INTO vehicles ( + vehicle_number, + vehicle_type, + driver_name, + driver_phone, + status, + company_code, + user_id, + created_at, + updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())`, + [ + vehicleNumber, + vehicleType || null, + userName, + phoneNumber, + "off", // 초기 상태: off (대기) + "COMPANY_13", // 기본 회사 코드 + userId, // 사용자 ID 연결 + ] + ); + + logger.info(`공차중계 회원가입 성공: ${userId}, 차량번호: ${vehicleNumber}`); + + return { + success: true, + message: "회원가입이 완료되었습니다.", + }; + } catch (error: any) { + logger.error("공차중계 회원가입 오류:", error); + return { + success: false, + message: error.message || "회원가입 중 오류가 발생했습니다.", + }; + } + } } diff --git a/backend-node/src/services/externalDbConnectionService.ts b/backend-node/src/services/externalDbConnectionService.ts index 99164ae1..410e8daf 100644 --- a/backend-node/src/services/externalDbConnectionService.ts +++ b/backend-node/src/services/externalDbConnectionService.ts @@ -28,39 +28,39 @@ export class ExternalDbConnectionService { // 회사별 필터링 (최고 관리자가 아닌 경우 필수) if (userCompanyCode && userCompanyCode !== "*") { - whereConditions.push(`company_code = $${paramIndex++}`); + whereConditions.push(`e.company_code = $${paramIndex++}`); params.push(userCompanyCode); logger.info(`회사별 외부 DB 연결 필터링: ${userCompanyCode}`); } else if (userCompanyCode === "*") { logger.info(`최고 관리자: 모든 외부 DB 연결 조회`); // 필터가 있으면 적용 if (filter.company_code) { - whereConditions.push(`company_code = $${paramIndex++}`); + whereConditions.push(`e.company_code = $${paramIndex++}`); params.push(filter.company_code); } } else { // userCompanyCode가 없는 경우 (하위 호환성) if (filter.company_code) { - whereConditions.push(`company_code = $${paramIndex++}`); + whereConditions.push(`e.company_code = $${paramIndex++}`); params.push(filter.company_code); } } // 필터 조건 적용 if (filter.db_type) { - whereConditions.push(`db_type = $${paramIndex++}`); + whereConditions.push(`e.db_type = $${paramIndex++}`); params.push(filter.db_type); } if (filter.is_active) { - whereConditions.push(`is_active = $${paramIndex++}`); + whereConditions.push(`e.is_active = $${paramIndex++}`); params.push(filter.is_active); } // 검색 조건 적용 (연결명 또는 설명에서 검색) if (filter.search && filter.search.trim()) { whereConditions.push( - `(connection_name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})` + `(e.connection_name ILIKE $${paramIndex} OR e.description ILIKE $${paramIndex})` ); params.push(`%${filter.search.trim()}%`); paramIndex++; @@ -72,9 +72,12 @@ export class ExternalDbConnectionService { : ""; const connections = await query( - `SELECT * FROM external_db_connections + `SELECT e.*, + COALESCE(c.company_name, CASE WHEN e.company_code = '*' THEN '전체' ELSE e.company_code END) AS company_name + FROM external_db_connections e + LEFT JOIN company_mng c ON e.company_code = c.company_code ${whereClause} - ORDER BY is_active DESC, connection_name ASC`, + ORDER BY e.is_active DESC, e.connection_name ASC`, params ); diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts index af37eff1..2632a6e6 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -31,15 +31,17 @@ export class ExternalRestApiConnectionService { try { let query = ` SELECT - id, connection_name, description, base_url, endpoint_path, default_headers, - default_method, + e.id, e.connection_name, e.description, e.base_url, e.endpoint_path, e.default_headers, + e.default_method, -- DB 스키마의 컬럼명은 default_request_body 기준이고 -- 코드에서는 default_body 필드로 사용하기 위해 alias 처리 - default_request_body AS default_body, - auth_type, auth_config, timeout, retry_count, retry_delay, - company_code, is_active, created_date, created_by, - updated_date, updated_by, last_test_date, last_test_result, last_test_message - FROM external_rest_api_connections + e.default_request_body AS default_body, + e.auth_type, e.auth_config, e.timeout, e.retry_count, e.retry_delay, + e.company_code, e.is_active, e.created_date, e.created_by, + e.updated_date, e.updated_by, e.last_test_date, e.last_test_result, e.last_test_message, + COALESCE(c.company_name, CASE WHEN e.company_code = '*' THEN '전체' ELSE e.company_code END) AS company_name + FROM external_rest_api_connections e + LEFT JOIN company_mng c ON e.company_code = c.company_code WHERE 1=1 `; @@ -48,7 +50,7 @@ export class ExternalRestApiConnectionService { // 회사별 필터링 (최고 관리자가 아닌 경우 필수) if (userCompanyCode && userCompanyCode !== "*") { - query += ` AND company_code = $${paramIndex}`; + query += ` AND e.company_code = $${paramIndex}`; params.push(userCompanyCode); paramIndex++; logger.info(`회사별 REST API 연결 필터링: ${userCompanyCode}`); @@ -56,14 +58,14 @@ export class ExternalRestApiConnectionService { logger.info(`최고 관리자: 모든 REST API 연결 조회`); // 필터가 있으면 적용 if (filter.company_code) { - query += ` AND company_code = $${paramIndex}`; + query += ` AND e.company_code = $${paramIndex}`; params.push(filter.company_code); paramIndex++; } } else { // userCompanyCode가 없는 경우 (하위 호환성) if (filter.company_code) { - query += ` AND company_code = $${paramIndex}`; + query += ` AND e.company_code = $${paramIndex}`; params.push(filter.company_code); paramIndex++; } @@ -71,14 +73,14 @@ export class ExternalRestApiConnectionService { // 활성 상태 필터 if (filter.is_active) { - query += ` AND is_active = $${paramIndex}`; + query += ` AND e.is_active = $${paramIndex}`; params.push(filter.is_active); paramIndex++; } // 인증 타입 필터 if (filter.auth_type) { - query += ` AND auth_type = $${paramIndex}`; + query += ` AND e.auth_type = $${paramIndex}`; params.push(filter.auth_type); paramIndex++; } @@ -86,9 +88,9 @@ export class ExternalRestApiConnectionService { // 검색어 필터 (연결명, 설명, URL) if (filter.search) { query += ` AND ( - connection_name ILIKE $${paramIndex} OR - description ILIKE $${paramIndex} OR - base_url ILIKE $${paramIndex} + e.connection_name ILIKE $${paramIndex} OR + e.description ILIKE $${paramIndex} OR + e.base_url ILIKE $${paramIndex} )`; params.push(`%${filter.search}%`); paramIndex++; @@ -233,6 +235,7 @@ export class ExternalRestApiConnectionService { // 디버깅: 저장하려는 데이터 로깅 logger.info(`REST API 연결 생성 요청 데이터:`, { connection_name: data.connection_name, + company_code: data.company_code, default_method: data.default_method, endpoint_path: data.endpoint_path, base_url: data.base_url, @@ -1091,4 +1094,150 @@ export class ExternalRestApiConnectionService { throw new Error("올바르지 않은 인증 타입입니다."); } } + + /** + * 다중 REST API 데이터 조회 및 병합 + * 여러 REST API의 응답을 병합하여 하나의 데이터셋으로 반환 + */ + static async fetchMultipleData( + configs: Array<{ + connectionId: number; + endpoint: string; + jsonPath: string; + alias: string; + }>, + userCompanyCode?: string + ): Promise; + total: number; + sources: Array<{ connectionId: number; connectionName: string; rowCount: number }>; + }>> { + try { + logger.info(`다중 REST API 데이터 조회 시작: ${configs.length}개 API`); + + // 각 API에서 데이터 조회 + const results = await Promise.all( + configs.map(async (config) => { + try { + const result = await this.fetchData( + config.connectionId, + config.endpoint, + config.jsonPath, + userCompanyCode + ); + + if (result.success && result.data) { + return { + success: true, + connectionId: config.connectionId, + connectionName: result.data.connectionInfo.connectionName, + alias: config.alias, + rows: result.data.rows, + columns: result.data.columns, + }; + } else { + logger.warn(`API ${config.connectionId} 조회 실패:`, result.message); + return { + success: false, + connectionId: config.connectionId, + connectionName: "", + alias: config.alias, + rows: [], + columns: [], + error: result.message, + }; + } + } catch (error) { + logger.error(`API ${config.connectionId} 조회 오류:`, error); + return { + success: false, + connectionId: config.connectionId, + connectionName: "", + alias: config.alias, + rows: [], + columns: [], + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + }) + ); + + // 성공한 결과만 필터링 + const successfulResults = results.filter(r => r.success); + + if (successfulResults.length === 0) { + return { + success: false, + message: "모든 REST API 조회에 실패했습니다.", + error: { + code: "ALL_APIS_FAILED", + details: results.map(r => ({ connectionId: r.connectionId, error: r.error })), + }, + }; + } + + // 컬럼 병합 (별칭 적용) + const mergedColumns: Array<{ columnName: string; columnLabel: string; dataType: string; sourceApi: string }> = []; + + for (const result of successfulResults) { + for (const col of result.columns) { + const prefixedColumnName = result.alias ? `${result.alias}${col.columnName}` : col.columnName; + mergedColumns.push({ + columnName: prefixedColumnName, + columnLabel: `${col.columnLabel} (${result.connectionName})`, + dataType: col.dataType, + sourceApi: result.connectionName, + }); + } + } + + // 데이터 병합 (가로 병합: 각 API의 첫 번째 행끼리 병합) + // 참고: 실제 사용 시에는 조인 키가 필요할 수 있음 + const maxRows = Math.max(...successfulResults.map(r => r.rows.length)); + const mergedRows: any[] = []; + + for (let i = 0; i < maxRows; i++) { + const mergedRow: any = {}; + + for (const result of successfulResults) { + const row = result.rows[i] || {}; + + for (const [key, value] of Object.entries(row)) { + const prefixedKey = result.alias ? `${result.alias}${key}` : key; + mergedRow[prefixedKey] = value; + } + } + + mergedRows.push(mergedRow); + } + + logger.info(`다중 REST API 데이터 병합 완료: ${mergedRows.length}개 행, ${mergedColumns.length}개 컬럼`); + + return { + success: true, + data: { + rows: mergedRows, + columns: mergedColumns, + total: mergedRows.length, + sources: successfulResults.map(r => ({ + connectionId: r.connectionId, + connectionName: r.connectionName, + rowCount: r.rows.length, + })), + }, + message: `${successfulResults.length}개 API에서 총 ${mergedRows.length}개 데이터를 조회했습니다.`, + }; + } catch (error) { + logger.error("다중 REST API 데이터 조회 오류:", error); + return { + success: false, + message: "다중 REST API 데이터 조회에 실패했습니다.", + error: { + code: "MULTI_FETCH_ERROR", + details: error instanceof Error ? error.message : "알 수 없는 오류", + }, + }; + } + } } diff --git a/backend-node/src/services/flowDefinitionService.ts b/backend-node/src/services/flowDefinitionService.ts index 4416faa0..80c920ad 100644 --- a/backend-node/src/services/flowDefinitionService.ts +++ b/backend-node/src/services/flowDefinitionService.ts @@ -30,6 +30,7 @@ export class FlowDefinitionService { restApiConnectionId: request.restApiConnectionId, restApiEndpoint: request.restApiEndpoint, restApiJsonPath: request.restApiJsonPath, + restApiConnections: request.restApiConnections, companyCode, userId, }); @@ -38,9 +39,9 @@ export class FlowDefinitionService { 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 + rest_api_connections, company_code, created_by ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING * `; @@ -52,7 +53,8 @@ export class FlowDefinitionService { request.dbConnectionId || null, request.restApiConnectionId || null, request.restApiEndpoint || null, - request.restApiJsonPath || "data", + request.restApiJsonPath || "response", + request.restApiConnections ? JSON.stringify(request.restApiConnections) : null, companyCode, userId, ]; @@ -209,6 +211,19 @@ export class FlowDefinitionService { * DB 행을 FlowDefinition 객체로 변환 */ private mapToFlowDefinition(row: any): FlowDefinition { + // rest_api_connections 파싱 (JSONB → 배열) + let restApiConnections = undefined; + if (row.rest_api_connections) { + try { + restApiConnections = typeof row.rest_api_connections === 'string' + ? JSON.parse(row.rest_api_connections) + : row.rest_api_connections; + } catch (e) { + console.warn("Failed to parse rest_api_connections:", e); + restApiConnections = []; + } + } + return { id: row.id, name: row.name, @@ -216,10 +231,12 @@ export class FlowDefinitionService { tableName: row.table_name, dbSourceType: row.db_source_type || "internal", dbConnectionId: row.db_connection_id, - // REST API 관련 필드 + // REST API 관련 필드 (단일) restApiConnectionId: row.rest_api_connection_id, restApiEndpoint: row.rest_api_endpoint, restApiJsonPath: row.rest_api_json_path, + // 다중 REST API 관련 필드 + restApiConnections: restApiConnections, 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 70b45af4..a0e707c1 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -53,6 +53,7 @@ interface ScreenDefinition { layout_metadata: any; db_source_type: string | null; db_connection_id: number | null; + source_screen_id: number | null; // 원본 화면 ID (복사 추적용) } /** @@ -234,6 +235,27 @@ export class MenuCopyService { } } } + + // 4) 화면 분할 패널 (screen-split-panel: leftScreenId, rightScreenId) + if (props?.componentConfig?.leftScreenId) { + const leftScreenId = props.componentConfig.leftScreenId; + const numId = + typeof leftScreenId === "number" ? leftScreenId : parseInt(leftScreenId); + if (!isNaN(numId) && numId > 0) { + referenced.push(numId); + logger.debug(` 📐 분할 패널 좌측 화면 참조 발견: ${numId}`); + } + } + + if (props?.componentConfig?.rightScreenId) { + const rightScreenId = props.componentConfig.rightScreenId; + const numId = + typeof rightScreenId === "number" ? rightScreenId : parseInt(rightScreenId); + if (!isNaN(numId) && numId > 0) { + referenced.push(numId); + logger.debug(` 📐 분할 패널 우측 화면 참조 발견: ${numId}`); + } + } } return referenced; @@ -431,14 +453,16 @@ export class MenuCopyService { const value = obj[key]; const currentPath = path ? `${path}.${key}` : key; - // screen_id, screenId, targetScreenId 매핑 (숫자 또는 숫자 문자열) + // screen_id, screenId, targetScreenId, leftScreenId, rightScreenId 매핑 (숫자 또는 숫자 문자열) if ( key === "screen_id" || key === "screenId" || - key === "targetScreenId" + key === "targetScreenId" || + key === "leftScreenId" || + key === "rightScreenId" ) { const numValue = typeof value === "number" ? value : parseInt(value); - if (!isNaN(numValue)) { + if (!isNaN(numValue) && numValue > 0) { const newId = screenIdMap.get(numValue); if (newId) { obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지 @@ -856,7 +880,10 @@ export class MenuCopyService { } /** - * 화면 복사 + * 화면 복사 (업데이트 또는 신규 생성) + * - source_screen_id로 기존 복사본 찾기 + * - 변경된 내용이 있으면 업데이트 + * - 없으면 새로 복사 */ private async copyScreens( screenIds: Set, @@ -876,18 +903,19 @@ export class MenuCopyService { return screenIdMap; } - logger.info(`📄 화면 복사 중: ${screenIds.size}개`); + logger.info(`📄 화면 복사/업데이트 중: ${screenIds.size}개`); - // === 1단계: 모든 screen_definitions 먼저 복사 (screenIdMap 생성) === + // === 1단계: 모든 screen_definitions 처리 (screenIdMap 생성) === const screenDefsToProcess: Array<{ originalScreenId: number; - newScreenId: number; + targetScreenId: number; screenDef: ScreenDefinition; + isUpdate: boolean; // 업데이트인지 신규 생성인지 }> = []; for (const originalScreenId of screenIds) { try { - // 1) screen_definitions 조회 + // 1) 원본 screen_definitions 조회 const screenDefResult = await client.query( `SELECT * FROM screen_definitions WHERE screen_id = $1`, [originalScreenId] @@ -900,122 +928,198 @@ export class MenuCopyService { const screenDef = screenDefResult.rows[0]; - // 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 + // 2) 기존 복사본 찾기: source_screen_id로 검색 + const existingCopyResult = await client.query<{ + screen_id: number; + screen_name: string; + updated_date: Date; + }>( + `SELECT screen_id, screen_name, updated_date + FROM screen_definitions + WHERE source_screen_id = $1 AND company_code = $2 AND deleted_date IS NULL LIMIT 1`, - [screenDef.screen_code, targetCompanyCode] + [originalScreenId, 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 - ); - - // 4) 화면명 변환 적용 + // 3) 화면명 변환 적용 let transformedScreenName = screenDef.screen_name; if (screenNameConfig) { - // 1. 제거할 텍스트 제거 if (screenNameConfig.removeText?.trim()) { transformedScreenName = transformedScreenName.replace( new RegExp(screenNameConfig.removeText.trim(), "g"), "" ); - transformedScreenName = transformedScreenName.trim(); // 앞뒤 공백 제거 + transformedScreenName = transformedScreenName.trim(); } - - // 2. 접두사 추가 if (screenNameConfig.addPrefix?.trim()) { transformedScreenName = screenNameConfig.addPrefix.trim() + " " + transformedScreenName; } } - // 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, - description, is_active, layout_metadata, - db_source_type, db_connection_id, created_by, - deleted_date, deleted_by, delete_reason - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) - RETURNING screen_id`, - [ - transformedScreenName, // 변환된 화면명 - newScreenCode, // 새 화면 코드 - screenDef.table_name, - targetCompanyCode, // 새 회사 코드 - screenDef.description, - screenDef.is_active === "D" ? "Y" : screenDef.is_active, // 삭제된 화면은 활성화 - screenDef.layout_metadata, - screenDef.db_source_type, - screenDef.db_connection_id, - userId, - null, // deleted_date: NULL (새 화면은 삭제되지 않음) - null, // deleted_by: NULL - null, // delete_reason: NULL - ] - ); + if (existingCopyResult.rows.length > 0) { + // === 기존 복사본이 있는 경우: 업데이트 === + const existingScreen = existingCopyResult.rows[0]; + const existingScreenId = existingScreen.screen_id; - const newScreenId = newScreenResult.rows[0].screen_id; - screenIdMap.set(originalScreenId, newScreenId); + // 원본 레이아웃 조회 + const sourceLayoutsResult = await client.query( + `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, + [originalScreenId] + ); - logger.info( - ` ✅ 화면 정의 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})` - ); + // 대상 레이아웃 조회 + const targetLayoutsResult = await client.query( + `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, + [existingScreenId] + ); - // 저장해서 2단계에서 처리 - screenDefsToProcess.push({ originalScreenId, newScreenId, screenDef }); + // 변경 여부 확인 (레이아웃 개수 또는 내용 비교) + const hasChanges = this.hasLayoutChanges( + sourceLayoutsResult.rows, + targetLayoutsResult.rows + ); + + if (hasChanges) { + // 변경 사항이 있으면 업데이트 + logger.info( + ` 🔄 화면 업데이트 필요: ${originalScreenId} → ${existingScreenId} (${screenDef.screen_name})` + ); + + // screen_definitions 업데이트 + await client.query( + `UPDATE screen_definitions SET + screen_name = $1, + table_name = $2, + description = $3, + is_active = $4, + layout_metadata = $5, + db_source_type = $6, + db_connection_id = $7, + updated_by = $8, + updated_date = NOW() + WHERE screen_id = $9`, + [ + transformedScreenName, + screenDef.table_name, + screenDef.description, + screenDef.is_active === "D" ? "Y" : screenDef.is_active, + screenDef.layout_metadata, + screenDef.db_source_type, + screenDef.db_connection_id, + userId, + existingScreenId, + ] + ); + + screenIdMap.set(originalScreenId, existingScreenId); + screenDefsToProcess.push({ + originalScreenId, + targetScreenId: existingScreenId, + screenDef, + isUpdate: true, + }); + } else { + // 변경 사항이 없으면 스킵 + screenIdMap.set(originalScreenId, existingScreenId); + logger.info( + ` ⏭️ 화면 변경 없음 (스킵): ${originalScreenId} → ${existingScreenId} (${screenDef.screen_name})` + ); + } + } else { + // === 기존 복사본이 없는 경우: 신규 생성 === + const newScreenCode = await this.generateUniqueScreenCode( + targetCompanyCode, + client + ); + + const newScreenResult = await client.query<{ screen_id: number }>( + `INSERT INTO screen_definitions ( + screen_name, screen_code, table_name, company_code, + description, is_active, layout_metadata, + db_source_type, db_connection_id, created_by, + deleted_date, deleted_by, delete_reason, source_screen_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING screen_id`, + [ + transformedScreenName, + newScreenCode, + screenDef.table_name, + targetCompanyCode, + screenDef.description, + screenDef.is_active === "D" ? "Y" : screenDef.is_active, + screenDef.layout_metadata, + screenDef.db_source_type, + screenDef.db_connection_id, + userId, + null, + null, + null, + originalScreenId, // source_screen_id 저장 + ] + ); + + const newScreenId = newScreenResult.rows[0].screen_id; + screenIdMap.set(originalScreenId, newScreenId); + + logger.info( + ` ✅ 화면 신규 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})` + ); + + screenDefsToProcess.push({ + originalScreenId, + targetScreenId: newScreenId, + screenDef, + isUpdate: false, + }); + } } catch (error: any) { logger.error( - `❌ 화면 정의 복사 실패: screen_id=${originalScreenId}`, + `❌ 화면 처리 실패: screen_id=${originalScreenId}`, error ); throw error; } } - // === 2단계: screen_layouts 복사 (이제 screenIdMap이 완성됨) === + // === 2단계: screen_layouts 처리 (이제 screenIdMap이 완성됨) === logger.info( - `\n📐 레이아웃 복사 시작 (screenIdMap 완성: ${screenIdMap.size}개)` + `\n📐 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)` ); for (const { originalScreenId, - newScreenId, + targetScreenId, screenDef, + isUpdate, } of screenDefsToProcess) { try { - // screen_layouts 복사 + // 원본 레이아웃 조회 const layoutsResult = await client.query( `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, [originalScreenId] ); - // 1단계: component_id 매핑 생성 (원본 → 새 ID) + if (isUpdate) { + // 업데이트: 기존 레이아웃 삭제 후 새로 삽입 + await client.query( + `DELETE FROM screen_layouts WHERE screen_id = $1`, + [targetScreenId] + ); + logger.info(` ↳ 기존 레이아웃 삭제 (업데이트 준비)`); + } + + // component_id 매핑 생성 (원본 → 새 ID) const componentIdMap = new Map(); for (const layout of layoutsResult.rows) { const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; componentIdMap.set(layout.component_id, newComponentId); } - // 2단계: screen_layouts 복사 (parent_id, zone_id도 매핑) + // 레이아웃 삽입 for (const layout of layoutsResult.rows) { const newComponentId = componentIdMap.get(layout.component_id)!; - // parent_id와 zone_id 매핑 (다른 컴포넌트를 참조하는 경우) const newParentId = layout.parent_id ? componentIdMap.get(layout.parent_id) || layout.parent_id : null; @@ -1023,7 +1127,6 @@ export class MenuCopyService { ? componentIdMap.get(layout.zone_id) || layout.zone_id : null; - // properties 내부 참조 업데이트 const updatedProperties = this.updateReferencesInProperties( layout.properties, screenIdMap, @@ -1037,38 +1140,94 @@ export class MenuCopyService { display_order, layout_type, layout_config, zones_config, zone_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, [ - newScreenId, // 새 화면 ID + targetScreenId, layout.component_type, - newComponentId, // 새 컴포넌트 ID - newParentId, // 매핑된 parent_id + newComponentId, + newParentId, layout.position_x, layout.position_y, layout.width, layout.height, - updatedProperties, // 업데이트된 속성 + updatedProperties, layout.display_order, layout.layout_type, layout.layout_config, layout.zones_config, - newZoneId, // 매핑된 zone_id + newZoneId, ] ); } - logger.info(` ↳ 레이아웃 복사: ${layoutsResult.rows.length}개`); + const action = isUpdate ? "업데이트" : "복사"; + logger.info(` ↳ 레이아웃 ${action}: ${layoutsResult.rows.length}개`); } catch (error: any) { logger.error( - `❌ 레이아웃 복사 실패: screen_id=${originalScreenId}`, + `❌ 레이아웃 처리 실패: screen_id=${originalScreenId}`, error ); throw error; } } - logger.info(`\n✅ 화면 복사 완료: ${screenIdMap.size}개`); + // 통계 출력 + const newCount = screenDefsToProcess.filter((s) => !s.isUpdate).length; + const updateCount = screenDefsToProcess.filter((s) => s.isUpdate).length; + const skipCount = screenIds.size - screenDefsToProcess.length; + + logger.info(` +✅ 화면 처리 완료: + - 신규 복사: ${newCount}개 + - 업데이트: ${updateCount}개 + - 스킵 (변경 없음): ${skipCount}개 + - 총 매핑: ${screenIdMap.size}개 + `); + return screenIdMap; } + /** + * 레이아웃 변경 여부 확인 + */ + private hasLayoutChanges( + sourceLayouts: ScreenLayout[], + targetLayouts: ScreenLayout[] + ): boolean { + // 1. 레이아웃 개수가 다르면 변경됨 + if (sourceLayouts.length !== targetLayouts.length) { + return true; + } + + // 2. 각 레이아웃의 주요 속성 비교 + for (let i = 0; i < sourceLayouts.length; i++) { + const source = sourceLayouts[i]; + const target = targetLayouts[i]; + + // component_type이 다르면 변경됨 + if (source.component_type !== target.component_type) { + return true; + } + + // 위치/크기가 다르면 변경됨 + if ( + source.position_x !== target.position_x || + source.position_y !== target.position_y || + source.width !== target.width || + source.height !== target.height + ) { + return true; + } + + // properties의 JSON 문자열 비교 (깊은 비교) + const sourceProps = JSON.stringify(source.properties || {}); + const targetProps = JSON.stringify(target.properties || {}); + if (sourceProps !== targetProps) { + return true; + } + } + + return false; + } + /** * 메뉴 위상 정렬 (부모 먼저) */ diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 007a39e7..6628cf4c 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -892,6 +892,134 @@ export class ScreenManagementService { }; } + /** + * 활성 화면 일괄 삭제 (휴지통으로 이동) + */ + async bulkDeleteScreens( + screenIds: number[], + userCompanyCode: string, + deletedBy: string, + deleteReason?: string, + force: boolean = false + ): Promise<{ + deletedCount: number; + skippedCount: number; + errors: Array<{ screenId: number; error: string }>; + }> { + if (screenIds.length === 0) { + throw new Error("삭제할 화면을 선택해주세요."); + } + + let deletedCount = 0; + let skippedCount = 0; + const errors: Array<{ screenId: number; error: string }> = []; + + // 각 화면을 개별적으로 삭제 처리 + for (const screenId of screenIds) { + try { + // 권한 확인 (Raw Query) + const existingResult = await query<{ + company_code: string | null; + is_active: string; + screen_name: string; + }>( + `SELECT company_code, is_active, screen_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screenId] + ); + + if (existingResult.length === 0) { + skippedCount++; + errors.push({ + screenId, + error: "화면을 찾을 수 없습니다.", + }); + continue; + } + + const existingScreen = existingResult[0]; + + // 권한 확인 + if ( + userCompanyCode !== "*" && + existingScreen.company_code !== userCompanyCode + ) { + skippedCount++; + errors.push({ + screenId, + error: "이 화면을 삭제할 권한이 없습니다.", + }); + continue; + } + + // 이미 삭제된 화면인지 확인 + if (existingScreen.is_active === "D") { + skippedCount++; + errors.push({ + screenId, + error: "이미 삭제된 화면입니다.", + }); + continue; + } + + // 강제 삭제가 아닌 경우 의존성 체크 + if (!force) { + const dependencyCheck = await this.checkScreenDependencies( + screenId, + userCompanyCode + ); + if (dependencyCheck.hasDependencies) { + skippedCount++; + errors.push({ + screenId, + error: `다른 화면에서 사용 중 (${dependencyCheck.dependencies.length}개 참조)`, + }); + continue; + } + } + + // 트랜잭션으로 화면 삭제와 메뉴 할당 정리를 함께 처리 + await transaction(async (client) => { + const now = new Date(); + + // 소프트 삭제 (휴지통으로 이동) + await client.query( + `UPDATE screen_definitions + SET is_active = 'D', + deleted_date = $1, + deleted_by = $2, + delete_reason = $3, + updated_date = $4, + updated_by = $5 + WHERE screen_id = $6`, + [now, deletedBy, deleteReason || null, now, deletedBy, screenId] + ); + + // 메뉴 할당 정리 (삭제된 화면의 메뉴 할당 제거) + await client.query( + `DELETE FROM screen_menu_assignments WHERE screen_id = $1`, + [screenId] + ); + }); + + deletedCount++; + logger.info(`화면 삭제 완료: ${screenId} (${existingScreen.screen_name})`); + } catch (error) { + skippedCount++; + errors.push({ + screenId, + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + logger.error(`화면 삭제 실패: ${screenId}`, error); + } + } + + logger.info( + `일괄 삭제 완료: 성공 ${deletedCount}개, 실패 ${skippedCount}개` + ); + + return { deletedCount, skippedCount, errors }; + } + /** * 휴지통 화면 일괄 영구 삭제 */ @@ -1517,11 +1645,23 @@ export class ScreenManagementService { }; } + // 🔥 최신 inputType 정보 조회 (table_type_columns에서) + const inputTypeMap = await this.getLatestInputTypes(componentLayouts, companyCode); + const components: ComponentData[] = componentLayouts.map((layout) => { const properties = layout.properties as any; + + // 🔥 최신 inputType으로 widgetType 및 componentType 업데이트 + const tableName = properties?.tableName; + const columnName = properties?.columnName; + const latestTypeInfo = tableName && columnName + ? inputTypeMap.get(`${tableName}.${columnName}`) + : null; + const component = { id: layout.component_id, - type: layout.component_type as any, + // 🔥 최신 componentType이 있으면 type 덮어쓰기 + type: latestTypeInfo?.componentType || layout.component_type as any, position: { x: layout.position_x, y: layout.position_y, @@ -1530,6 +1670,17 @@ export class ScreenManagementService { size: { width: layout.width, height: layout.height }, parentId: layout.parent_id, ...properties, + // 🔥 최신 inputType이 있으면 widgetType, componentType 덮어쓰기 + ...(latestTypeInfo && { + widgetType: latestTypeInfo.inputType, + inputType: latestTypeInfo.inputType, + componentType: latestTypeInfo.componentType, + componentConfig: { + ...properties?.componentConfig, + type: latestTypeInfo.componentType, + inputType: latestTypeInfo.inputType, + }, + }), }; console.log(`로드된 컴포넌트:`, { @@ -1539,6 +1690,9 @@ export class ScreenManagementService { size: component.size, parentId: component.parentId, title: (component as any).title, + widgetType: (component as any).widgetType, + componentType: (component as any).componentType, + latestTypeInfo, }); return component; @@ -1558,6 +1712,112 @@ export class ScreenManagementService { }; } + /** + * 입력 타입에 해당하는 컴포넌트 ID 반환 + * (프론트엔드 webTypeMapping.ts와 동일한 매핑) + */ + private getComponentIdFromInputType(inputType: string): string { + const mapping: Record = { + // 텍스트 입력 + text: "text-input", + email: "text-input", + password: "text-input", + tel: "text-input", + // 숫자 입력 + number: "number-input", + decimal: "number-input", + // 날짜/시간 + date: "date-input", + datetime: "date-input", + time: "date-input", + // 텍스트 영역 + textarea: "textarea-basic", + // 선택 + select: "select-basic", + dropdown: "select-basic", + // 체크박스/라디오 + checkbox: "checkbox-basic", + radio: "radio-basic", + boolean: "toggle-switch", + // 파일 + file: "file-upload", + // 이미지 + image: "image-widget", + img: "image-widget", + picture: "image-widget", + photo: "image-widget", + // 버튼 + button: "button-primary", + // 기타 + label: "text-display", + code: "select-basic", + entity: "select-basic", + category: "select-basic", + }; + + return mapping[inputType] || "text-input"; + } + + /** + * 컴포넌트들의 최신 inputType 정보 조회 + * @param layouts - 레이아웃 목록 + * @param companyCode - 회사 코드 + * @returns Map<"tableName.columnName", { inputType, componentType }> + */ + private async getLatestInputTypes( + layouts: any[], + companyCode: string + ): Promise> { + const inputTypeMap = new Map(); + + // tableName과 columnName이 있는 컴포넌트들의 고유 조합 추출 + const tableColumnPairs = new Set(); + for (const layout of layouts) { + const properties = layout.properties as any; + if (properties?.tableName && properties?.columnName) { + tableColumnPairs.add(`${properties.tableName}|${properties.columnName}`); + } + } + + if (tableColumnPairs.size === 0) { + return inputTypeMap; + } + + // 각 테이블-컬럼 조합에 대해 최신 inputType 조회 + const pairs = Array.from(tableColumnPairs).map(pair => { + const [tableName, columnName] = pair.split('|'); + return { tableName, columnName }; + }); + + // 배치 쿼리로 한 번에 조회 + const placeholders = pairs.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(', '); + const params = pairs.flatMap(p => [p.tableName, p.columnName]); + + try { + const results = await query<{ table_name: string; column_name: string; input_type: string }>( + `SELECT table_name, column_name, input_type + FROM table_type_columns + WHERE (table_name, column_name) IN (${placeholders}) + AND company_code = $${params.length + 1}`, + [...params, companyCode] + ); + + for (const row of results) { + const componentType = this.getComponentIdFromInputType(row.input_type); + inputTypeMap.set(`${row.table_name}.${row.column_name}`, { + inputType: row.input_type, + componentType: componentType, + }); + } + + console.log(`최신 inputType 조회 완료: ${results.length}개`); + } catch (error) { + console.warn(`최신 inputType 조회 실패 (무시됨):`, error); + } + + return inputTypeMap; + } + // ======================================== // 템플릿 관리 // ======================================== diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 64eb44c8..8e01903b 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -797,6 +797,9 @@ export class TableManagementService { ] ); + // 🔥 해당 컬럼을 사용하는 화면 레이아웃의 widgetType도 업데이트 + await this.syncScreenLayoutsInputType(tableName, columnName, inputType, companyCode); + // 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제 const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`; cache.delete(cacheKeyPattern); @@ -816,6 +819,135 @@ export class TableManagementService { } } + /** + * 입력 타입에 해당하는 컴포넌트 ID 반환 + * (프론트엔드 webTypeMapping.ts와 동일한 매핑) + */ + private getComponentIdFromInputType(inputType: string): string { + const mapping: Record = { + // 텍스트 입력 + text: "text-input", + email: "text-input", + password: "text-input", + tel: "text-input", + // 숫자 입력 + number: "number-input", + decimal: "number-input", + // 날짜/시간 + date: "date-input", + datetime: "date-input", + time: "date-input", + // 텍스트 영역 + textarea: "textarea-basic", + // 선택 + select: "select-basic", + dropdown: "select-basic", + // 체크박스/라디오 + checkbox: "checkbox-basic", + radio: "radio-basic", + boolean: "toggle-switch", + // 파일 + file: "file-upload", + // 이미지 + image: "image-widget", + img: "image-widget", + picture: "image-widget", + photo: "image-widget", + // 버튼 + button: "button-primary", + // 기타 + label: "text-display", + code: "select-basic", + entity: "select-basic", + category: "select-basic", + }; + + return mapping[inputType] || "text-input"; + } + + /** + * 컬럼 입력 타입 변경 시 해당 컬럼을 사용하는 화면 레이아웃의 widgetType 및 componentType 동기화 + * @param tableName - 테이블명 + * @param columnName - 컬럼명 + * @param inputType - 새로운 입력 타입 + * @param companyCode - 회사 코드 + */ + private async syncScreenLayoutsInputType( + tableName: string, + columnName: string, + inputType: string, + companyCode: string + ): Promise { + try { + // 해당 컬럼을 사용하는 화면 레이아웃 조회 + const affectedLayouts = await query<{ + layout_id: number; + screen_id: number; + component_id: string; + component_type: string; + properties: any; + }>( + `SELECT sl.layout_id, sl.screen_id, sl.component_id, sl.component_type, sl.properties + FROM screen_layouts sl + JOIN screen_definitions sd ON sl.screen_id = sd.screen_id + WHERE sl.properties->>'tableName' = $1 + AND sl.properties->>'columnName' = $2 + AND (sd.company_code = $3 OR $3 = '*')`, + [tableName, columnName, companyCode] + ); + + if (affectedLayouts.length === 0) { + logger.info( + `화면 레이아웃 동기화: ${tableName}.${columnName}을 사용하는 화면 없음` + ); + return; + } + + logger.info( + `화면 레이아웃 동기화 시작: ${affectedLayouts.length}개 컴포넌트 발견` + ); + + // 새로운 componentType 계산 + const newComponentType = this.getComponentIdFromInputType(inputType); + + // 각 레이아웃의 widgetType, componentType 업데이트 + for (const layout of affectedLayouts) { + const updatedProperties = { + ...layout.properties, + widgetType: inputType, + inputType: inputType, + // componentConfig 내부의 type도 업데이트 + componentConfig: { + ...layout.properties?.componentConfig, + type: newComponentType, + inputType: inputType, + }, + }; + + await query( + `UPDATE screen_layouts + SET properties = $1, component_type = $2 + WHERE layout_id = $3`, + [JSON.stringify(updatedProperties), newComponentType, layout.layout_id] + ); + + logger.info( + `화면 레이아웃 업데이트: screen_id=${layout.screen_id}, component_id=${layout.component_id}, widgetType=${inputType}, componentType=${newComponentType}` + ); + } + + logger.info( + `화면 레이아웃 동기화 완료: ${affectedLayouts.length}개 컴포넌트 업데이트됨` + ); + } catch (error) { + // 화면 레이아웃 동기화 실패는 치명적이지 않으므로 로그만 남기고 계속 진행 + logger.warn( + `화면 레이아웃 동기화 실패 (무시됨): ${tableName}.${columnName}`, + error + ); + } + } + /** * 입력 타입별 기본 상세 설정 생성 */ diff --git a/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts index c877a2b3..9f105a49 100644 --- a/backend-node/src/types/flow.ts +++ b/backend-node/src/types/flow.ts @@ -2,18 +2,38 @@ * 플로우 관리 시스템 타입 정의 */ +// 다중 REST API 연결 설정 +export interface RestApiConnectionConfig { + connectionId: number; + connectionName: string; + endpoint: string; + jsonPath: string; + alias: string; // 컬럼 접두어 (예: "api1_") +} + +// 다중 외부 DB 연결 설정 +export interface ExternalDbConnectionConfig { + connectionId: number; + connectionName: string; + dbType: string; + tableName: string; + alias: string; // 컬럼 접두어 (예: "db1_") +} + // 플로우 정의 export interface FlowDefinition { id: number; name: string; description?: string; tableName: string; - dbSourceType?: "internal" | "external" | "restapi"; // 데이터 소스 타입 + dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입 dbConnectionId?: number; // 외부 DB 연결 ID (external인 경우) - // REST API 관련 필드 + // REST API 관련 필드 (단일) restApiConnectionId?: number; // REST API 연결 ID (restapi인 경우) restApiEndpoint?: string; // REST API 엔드포인트 restApiJsonPath?: string; // JSON 응답에서 데이터 경로 (기본: data) + // 다중 REST API 관련 필드 + restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정 배열 companyCode: string; // 회사 코드 (* = 공통) isActive: boolean; createdBy?: string; @@ -26,12 +46,14 @@ export interface CreateFlowDefinitionRequest { name: string; description?: string; tableName: string; - dbSourceType?: "internal" | "external" | "restapi"; // 데이터 소스 타입 + dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입 dbConnectionId?: number; // 외부 DB 연결 ID - // REST API 관련 필드 + // REST API 관련 필드 (단일) restApiConnectionId?: number; // REST API 연결 ID restApiEndpoint?: string; // REST API 엔드포인트 restApiJsonPath?: string; // JSON 응답에서 데이터 경로 + // 다중 REST API 관련 필드 + restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정 배열 companyCode?: string; // 회사 코드 (미제공 시 사용자의 company_code 사용) } diff --git a/frontend/app/(main)/admin/external-connections/page.tsx b/frontend/app/(main)/admin/external-connections/page.tsx index 88754ac4..0ab2fbeb 100644 --- a/frontend/app/(main)/admin/external-connections/page.tsx +++ b/frontend/app/(main)/admin/external-connections/page.tsx @@ -317,6 +317,7 @@ export default function ExternalConnectionsPage() { 연결명 + 회사 DB 타입 호스트:포트 데이터베이스 @@ -333,6 +334,9 @@ export default function ExternalConnectionsPage() {
{connection.connection_name}
+ + {(connection as any).company_name || connection.company_code} + {DB_TYPE_LABELS[connection.db_type] || connection.db_type} diff --git a/frontend/app/(main)/admin/flow-management/[id]/page.tsx b/frontend/app/(main)/admin/flow-management/[id]/page.tsx index a311bc63..b8d14e19 100644 --- a/frontend/app/(main)/admin/flow-management/[id]/page.tsx +++ b/frontend/app/(main)/admin/flow-management/[id]/page.tsx @@ -319,6 +319,10 @@ export default function FlowEditorPage() { flowTableName={flowDefinition?.tableName} // 플로우 정의의 테이블명 전달 flowDbSourceType={flowDefinition?.dbSourceType} // DB 소스 타입 전달 flowDbConnectionId={flowDefinition?.dbConnectionId} // 외부 DB 연결 ID 전달 + flowRestApiConnectionId={flowDefinition?.restApiConnectionId} // REST API 연결 ID 전달 + flowRestApiEndpoint={flowDefinition?.restApiEndpoint} // REST API 엔드포인트 전달 + flowRestApiJsonPath={flowDefinition?.restApiJsonPath} // REST API JSON 경로 전달 + flowRestApiConnections={flowDefinition?.restApiConnections} // 다중 REST API 설정 전달 onClose={() => setSelectedStep(null)} onUpdate={loadFlowData} /> diff --git a/frontend/app/(main)/admin/flow-management/page.tsx b/frontend/app/(main)/admin/flow-management/page.tsx index 5a335daf..d283f72d 100644 --- a/frontend/app/(main)/admin/flow-management/page.tsx +++ b/frontend/app/(main)/admin/flow-management/page.tsx @@ -64,7 +64,30 @@ export default function FlowManagementPage() { // REST API 연결 관련 상태 const [restApiConnections, setRestApiConnections] = useState([]); const [restApiEndpoint, setRestApiEndpoint] = useState(""); - const [restApiJsonPath, setRestApiJsonPath] = useState("data"); + const [restApiJsonPath, setRestApiJsonPath] = useState("response"); + + // 다중 REST API 선택 상태 + interface RestApiConfig { + connectionId: number; + connectionName: string; + endpoint: string; + jsonPath: string; + alias: string; // 컬럼 접두어 (예: "api1_") + } + const [selectedRestApis, setSelectedRestApis] = useState([]); + const [isMultiRestApi, setIsMultiRestApi] = useState(false); // 다중 REST API 모드 + + // 다중 외부 DB 선택 상태 + interface ExternalDbConfig { + connectionId: number; + connectionName: string; + dbType: string; + tableName: string; + alias: string; // 컬럼 접두어 (예: "db1_") + } + const [selectedExternalDbs, setSelectedExternalDbs] = useState([]); + const [isMultiExternalDb, setIsMultiExternalDb] = useState(false); // 다중 외부 DB 모드 + const [multiDbTableLists, setMultiDbTableLists] = useState>({}); // 각 DB별 테이블 목록 // 생성 폼 상태 const [formData, setFormData] = useState({ @@ -207,25 +230,161 @@ export default function FlowManagementPage() { } }, [selectedDbSource]); + // 다중 외부 DB 추가 + const addExternalDbConfig = async (connectionId: number) => { + const connection = externalConnections.find(c => c.id === connectionId); + if (!connection) return; + + // 이미 추가된 경우 스킵 + if (selectedExternalDbs.some(db => db.connectionId === connectionId)) { + toast({ + title: "이미 추가됨", + description: "해당 외부 DB가 이미 추가되어 있습니다.", + variant: "destructive", + }); + return; + } + + // 해당 DB의 테이블 목록 로드 + try { + const data = await ExternalDbConnectionAPI.getTables(connectionId); + 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); + setMultiDbTableLists(prev => ({ ...prev, [connectionId]: tableNames })); + } + } catch (error) { + console.error("외부 DB 테이블 목록 조회 오류:", error); + } + + const newConfig: ExternalDbConfig = { + connectionId, + connectionName: connection.connection_name, + dbType: connection.db_type, + tableName: "", + alias: `db${selectedExternalDbs.length + 1}_`, // 자동 별칭 생성 + }; + + setSelectedExternalDbs([...selectedExternalDbs, newConfig]); + }; + + // 다중 외부 DB 삭제 + const removeExternalDbConfig = (connectionId: number) => { + setSelectedExternalDbs(selectedExternalDbs.filter(db => db.connectionId !== connectionId)); + }; + + // 다중 외부 DB 설정 업데이트 + const updateExternalDbConfig = (connectionId: number, field: keyof ExternalDbConfig, value: string) => { + setSelectedExternalDbs(selectedExternalDbs.map(db => + db.connectionId === connectionId ? { ...db, [field]: value } : db + )); + }; + + // 다중 REST API 추가 + const addRestApiConfig = (connectionId: number) => { + const connection = restApiConnections.find(c => c.id === connectionId); + if (!connection) return; + + // 이미 추가된 경우 스킵 + if (selectedRestApis.some(api => api.connectionId === connectionId)) { + toast({ + title: "이미 추가됨", + description: "해당 REST API가 이미 추가되어 있습니다.", + variant: "destructive", + }); + return; + } + + // 연결 테이블의 기본값 사용 + const newConfig: RestApiConfig = { + connectionId, + connectionName: connection.connection_name, + endpoint: connection.endpoint_path || "", // 연결 테이블의 기본 엔드포인트 + jsonPath: "response", // 기본값 + alias: `api${selectedRestApis.length + 1}_`, // 자동 별칭 생성 + }; + + setSelectedRestApis([...selectedRestApis, newConfig]); + }; + + // 다중 REST API 삭제 + const removeRestApiConfig = (connectionId: number) => { + setSelectedRestApis(selectedRestApis.filter(api => api.connectionId !== connectionId)); + }; + + // 다중 REST API 설정 업데이트 + const updateRestApiConfig = (connectionId: number, field: keyof RestApiConfig, value: string) => { + setSelectedRestApis(selectedRestApis.map(api => + api.connectionId === connectionId ? { ...api, [field]: value } : api + )); + }; + // 플로우 생성 const handleCreate = async () => { console.log("🚀 handleCreate called with formData:", formData); - // REST API인 경우 테이블 이름 검증 스킵 - const isRestApi = selectedDbSource.startsWith("restapi_"); + // REST API 또는 다중 선택인 경우 테이블 이름 검증 스킵 + const isRestApi = selectedDbSource.startsWith("restapi_") || isMultiRestApi; + const isMultiMode = isMultiRestApi || isMultiExternalDb; - if (!formData.name || (!isRestApi && !formData.tableName)) { - console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName, isRestApi }); + if (!formData.name || (!isRestApi && !isMultiMode && !formData.tableName)) { + console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName, isRestApi, isMultiMode }); toast({ title: "입력 오류", - description: isRestApi ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.", + description: (isRestApi || isMultiMode) ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.", variant: "destructive", }); return; } - // REST API인 경우 엔드포인트 검증 - if (isRestApi && !restApiEndpoint) { + // 다중 REST API 모드인 경우 검증 + if (isMultiRestApi) { + if (selectedRestApis.length === 0) { + toast({ + title: "입력 오류", + description: "최소 하나의 REST API를 추가해주세요.", + variant: "destructive", + }); + return; + } + + // 각 API의 엔드포인트 검증 + const missingEndpoint = selectedRestApis.find(api => !api.endpoint); + if (missingEndpoint) { + toast({ + title: "입력 오류", + description: `${missingEndpoint.connectionName}의 엔드포인트를 입력해주세요.`, + variant: "destructive", + }); + return; + } + } else if (isMultiExternalDb) { + // 다중 외부 DB 모드인 경우 검증 + if (selectedExternalDbs.length === 0) { + toast({ + title: "입력 오류", + description: "최소 하나의 외부 DB를 추가해주세요.", + variant: "destructive", + }); + return; + } + + // 각 DB의 테이블 선택 검증 + const missingTable = selectedExternalDbs.find(db => !db.tableName); + if (missingTable) { + toast({ + title: "입력 오류", + description: `${missingTable.connectionName}의 테이블을 선택해주세요.`, + variant: "destructive", + }); + return; + } + } else if (isRestApi && !restApiEndpoint) { + // 단일 REST API인 경우 엔드포인트 검증 toast({ title: "입력 오류", description: "REST API 엔드포인트는 필수입니다.", @@ -236,11 +395,15 @@ export default function FlowManagementPage() { try { // 데이터 소스 타입 및 ID 파싱 - let dbSourceType: "internal" | "external" | "restapi" = "internal"; + let dbSourceType: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db" = "internal"; let dbConnectionId: number | undefined = undefined; let restApiConnectionId: number | undefined = undefined; - if (selectedDbSource === "internal") { + if (isMultiRestApi) { + dbSourceType = "multi_restapi"; + } else if (isMultiExternalDb) { + dbSourceType = "multi_external_db"; + } else if (selectedDbSource === "internal") { dbSourceType = "internal"; } else if (selectedDbSource.startsWith("external_db_")) { dbSourceType = "external"; @@ -257,11 +420,27 @@ export default function FlowManagementPage() { dbConnectionId, }; - // REST API인 경우 추가 정보 - if (dbSourceType === "restapi") { + // 다중 REST API인 경우 + if (dbSourceType === "multi_restapi") { + requestData.restApiConnections = selectedRestApis; + // 다중 REST API는 첫 번째 API의 ID를 기본으로 사용 + requestData.restApiConnectionId = selectedRestApis[0]?.connectionId; + requestData.restApiEndpoint = selectedRestApis[0]?.endpoint; + requestData.restApiJsonPath = selectedRestApis[0]?.jsonPath || "response"; + // 가상 테이블명: 모든 연결 ID를 조합 + requestData.tableName = `_multi_restapi_${selectedRestApis.map(a => a.connectionId).join("_")}`; + } else if (dbSourceType === "multi_external_db") { + // 다중 외부 DB인 경우 + requestData.externalDbConnections = selectedExternalDbs; + // 첫 번째 DB의 ID를 기본으로 사용 + requestData.dbConnectionId = selectedExternalDbs[0]?.connectionId; + // 가상 테이블명: 모든 연결 ID와 테이블명 조합 + requestData.tableName = `_multi_external_db_${selectedExternalDbs.map(db => `${db.connectionId}_${db.tableName}`).join("_")}`; + } else if (dbSourceType === "restapi") { + // 단일 REST API인 경우 requestData.restApiConnectionId = restApiConnectionId; requestData.restApiEndpoint = restApiEndpoint; - requestData.restApiJsonPath = restApiJsonPath || "data"; + requestData.restApiJsonPath = restApiJsonPath || "response"; // REST API는 가상 테이블명 사용 requestData.tableName = `_restapi_${restApiConnectionId}`; } @@ -277,7 +456,11 @@ export default function FlowManagementPage() { setFormData({ name: "", description: "", tableName: "" }); setSelectedDbSource("internal"); setRestApiEndpoint(""); - setRestApiJsonPath("data"); + setRestApiJsonPath("response"); + setSelectedRestApis([]); + setSelectedExternalDbs([]); + setIsMultiRestApi(false); + setIsMultiExternalDb(false); loadFlows(); } else { toast({ @@ -485,13 +668,27 @@ export default function FlowManagementPage() {

@@ -535,8 +751,160 @@ export default function FlowManagementPage() {

- {/* REST API인 경우 엔드포인트 설정 */} - {selectedDbSource.startsWith("restapi_") ? ( + {/* 다중 REST API 선택 UI */} + {isMultiRestApi && ( +
+
+ + +
+ + {selectedRestApis.length === 0 ? ( +
+

+ 위에서 REST API를 추가해주세요 +

+
+ ) : ( +
+ {selectedRestApis.map((api) => ( +
+
+ {api.connectionName} + + ({api.endpoint || "기본 엔드포인트"}) + +
+ +
+ ))} +
+ )} +

+ 선택한 REST API들의 데이터가 자동으로 병합됩니다. +

+
+ )} + + {/* 다중 외부 DB 선택 UI */} + {isMultiExternalDb && ( +
+
+ + +
+ + {selectedExternalDbs.length === 0 ? ( +
+

+ 위에서 외부 DB를 추가해주세요 +

+
+ ) : ( +
+ {selectedExternalDbs.map((db) => ( +
+
+ + {db.connectionName} ({db.dbType?.toUpperCase()}) + + +
+
+
+ + +
+
+ + updateExternalDbConfig(db.connectionId, "alias", e.target.value)} + placeholder="db1_" + className="h-7 text-xs" + /> +
+
+
+ ))} +
+ )} +

+ 선택한 외부 DB들의 데이터가 자동으로 병합됩니다. 각 DB별 테이블을 선택해주세요. +

+
+ )} + + {/* 단일 REST API인 경우 엔드포인트 설정 */} + {!isMultiRestApi && selectedDbSource.startsWith("restapi_") && ( <>
- ) : ( - /* 테이블 선택 (내부 DB 또는 외부 DB) */ + )} + + {/* 테이블 선택 (내부 DB 또는 단일 외부 DB - 다중 선택 모드가 아닌 경우만) */} + {!isMultiRestApi && !isMultiExternalDb && !selectedDbSource.startsWith("restapi_") && (