diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 43b698d2..ae55e3c4 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -18,6 +18,7 @@ "docx": "^9.5.1", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-async-errors": "^3.1.1", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", "html-to-docx": "^1.8.0", @@ -1044,7 +1045,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2372,7 +2372,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -3476,7 +3475,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3713,7 +3711,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3931,7 +3928,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4458,7 +4454,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -5669,7 +5664,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5989,6 +5983,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-async-errors": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz", + "integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==", + "license": "ISC", + "peerDependencies": { + "express": "^4.16.2" + } + }, "node_modules/express-rate-limit": { "version": "7.5.1", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", @@ -7432,7 +7435,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8402,6 +8404,7 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -9290,7 +9293,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -10141,6 +10143,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -10949,7 +10952,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11055,7 +11057,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/backend-node/package.json b/backend-node/package.json index b1bfa319..310ab401 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -32,6 +32,7 @@ "docx": "^9.5.1", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-async-errors": "^3.1.1", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", "html-to-docx": "^1.8.0", diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 1fbefea5..30e684d5 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -1,4 +1,5 @@ import "dotenv/config"; +import "express-async-errors"; // async 라우트 핸들러의 에러를 Express 에러 핸들러로 자동 전달 import express from "express"; import cors from "cors"; import helmet from "helmet"; diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index a89e50d1..7b3b1033 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -19,8 +19,6 @@ export async function getAdminMenus( res: Response ): Promise { try { - logger.info("=== 관리자 메뉴 목록 조회 시작 ==="); - // 현재 로그인한 사용자의 정보 가져오기 const userId = req.user?.userId; const userCompanyCode = req.user?.companyCode || "ILSHIN"; @@ -29,13 +27,6 @@ export async function getAdminMenus( const menuType = req.query.menuType as string | undefined; // menuType 파라미터 추가 const includeInactive = req.query.includeInactive === "true"; // includeInactive 파라미터 추가 - logger.info(`사용자 ID: ${userId}`); - logger.info(`사용자 회사 코드: ${userCompanyCode}`); - logger.info(`사용자 유형: ${userType}`); - logger.info(`사용자 로케일: ${userLang}`); - logger.info(`메뉴 타입: ${menuType || "전체"}`); - logger.info(`비활성 메뉴 포함: ${includeInactive}`); - const paramMap = { userId, userCompanyCode, @@ -47,13 +38,6 @@ export async function getAdminMenus( const menuList = await AdminService.getAdminMenuList(paramMap); - logger.info( - `관리자 메뉴 조회 결과: ${menuList.length}개 (타입: ${menuType || "전체"}, 회사: ${userCompanyCode})` - ); - if (menuList.length > 0) { - logger.info("첫 번째 메뉴:", menuList[0]); - } - const response: ApiResponse = { success: true, message: "관리자 메뉴 목록 조회 성공", @@ -85,19 +69,12 @@ export async function getUserMenus( res: Response ): Promise { try { - logger.info("=== 사용자 메뉴 목록 조회 시작 ==="); - // 현재 로그인한 사용자의 정보 가져오기 const userId = req.user?.userId; const userCompanyCode = req.user?.companyCode || "ILSHIN"; const userType = req.user?.userType; const userLang = (req.query.userLang as string) || "ko"; - logger.info(`사용자 ID: ${userId}`); - logger.info(`사용자 회사 코드: ${userCompanyCode}`); - logger.info(`사용자 유형: ${userType}`); - logger.info(`사용자 로케일: ${userLang}`); - const paramMap = { userId, userCompanyCode, @@ -107,13 +84,6 @@ export async function getUserMenus( const menuList = await AdminService.getUserMenuList(paramMap); - logger.info( - `사용자 메뉴 조회 결과: ${menuList.length}개 (회사: ${userCompanyCode})` - ); - if (menuList.length > 0) { - logger.info("첫 번째 메뉴:", menuList[0]); - } - const response: ApiResponse = { success: true, message: "사용자 메뉴 목록 조회 성공", @@ -473,7 +443,7 @@ export const getUserLocale = async ( res: Response ): Promise => { try { - logger.info("사용자 로케일 조회 요청", { + logger.debug("사용자 로케일 조회 요청", { query: req.query, user: req.user, }); @@ -496,7 +466,7 @@ export const getUserLocale = async ( if (userInfo?.locale) { userLocale = userInfo.locale; - logger.info("데이터베이스에서 사용자 로케일 조회 성공", { + logger.debug("데이터베이스에서 사용자 로케일 조회 성공", { userId: req.user.userId, locale: userLocale, }); @@ -513,7 +483,7 @@ export const getUserLocale = async ( message: "사용자 로케일 조회 성공", }; - logger.info("사용자 로케일 조회 성공", { + logger.debug("사용자 로케일 조회 성공", { userLocale, userId: req.user.userId, fromDatabase: !!userInfo?.locale, @@ -618,7 +588,7 @@ export const getCompanyList = async ( res: Response ) => { try { - logger.info("회사 목록 조회 요청", { + logger.debug("회사 목록 조회 요청", { query: req.query, user: req.user, }); @@ -658,12 +628,8 @@ export const getCompanyList = async ( message: "회사 목록 조회 성공", }; - logger.info("회사 목록 조회 성공", { + logger.debug("회사 목록 조회 성공", { totalCount: companies.length, - companies: companies.map((c) => ({ - code: c.company_code, - name: c.company_name, - })), }); res.status(200).json(response); @@ -1443,13 +1409,7 @@ async function collectAllChildMenuIds(parentObjid: number): Promise { * 메뉴 및 관련 데이터 정리 헬퍼 함수 */ async function cleanupMenuRelatedData(menuObjid: number): Promise { - // 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로 설정 + // 1. code_category에서 menu_objid를 NULL로 설정 await query( `UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`, [menuObjid] @@ -1870,7 +1830,7 @@ export async function getCompanyListFromDB( res: Response ): Promise { try { - logger.info("회사 목록 조회 요청 (Raw Query)", { user: req.user }); + logger.debug("회사 목록 조회 요청 (Raw Query)", { user: req.user }); // Raw Query로 회사 목록 조회 const companies = await query( @@ -1890,7 +1850,7 @@ export async function getCompanyListFromDB( ORDER BY regdate DESC` ); - logger.info("회사 목록 조회 성공 (Raw Query)", { count: companies.length }); + logger.debug("회사 목록 조회 성공 (Raw Query)", { count: companies.length }); const response: ApiResponse = { success: true, diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index 1903d397..ebf3e8f5 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -17,9 +17,7 @@ export class AuthController { const { userId, password }: LoginRequest = req.body; const remoteAddr = req.ip || req.connection.remoteAddress || "unknown"; - logger.info(`=== API 로그인 호출됨 ===`); - logger.info(`userId: ${userId}`); - logger.info(`password: ${password ? "***" : "null"}`); + logger.debug(`로그인 요청: ${userId}`); // 입력값 검증 if (!userId || !password) { @@ -50,14 +48,7 @@ export class AuthController { companyCode: loginResult.userInfo.companyCode || "ILSHIN", }; - logger.info(`=== API 로그인 사용자 정보 디버그 ===`); - logger.info( - `PersonBean companyCode: ${loginResult.userInfo.companyCode}` - ); - logger.info(`반환할 사용자 정보:`); - logger.info(`- userId: ${userInfo.userId}`); - logger.info(`- userName: ${userInfo.userName}`); - logger.info(`- companyCode: ${userInfo.companyCode}`); + logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`); // 사용자의 첫 번째 접근 가능한 메뉴 조회 let firstMenuPath: string | null = null; @@ -71,7 +62,7 @@ export class AuthController { }; const menuList = await AdminService.getUserMenuList(paramMap); - logger.info(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`); + logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`); // 접근 가능한 첫 번째 메뉴 찾기 // 조건: @@ -87,16 +78,9 @@ export class AuthController { if (firstMenu) { firstMenuPath = firstMenu.menu_url || firstMenu.url; - logger.info(`✅ 첫 번째 접근 가능한 메뉴 발견:`, { - name: firstMenu.menu_name_kor || firstMenu.translated_name, - url: firstMenuPath, - level: firstMenu.lev || firstMenu.level, - seq: firstMenu.seq, - }); + logger.debug(`첫 번째 메뉴: ${firstMenuPath}`); } else { - logger.info( - "⚠️ 접근 가능한 메뉴가 없습니다. 메인 페이지로 이동합니다." - ); + logger.debug("접근 가능한 메뉴 없음, 메인 페이지로 이동"); } } catch (menuError) { logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError); diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index f4f89d25..15e05473 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -193,10 +193,11 @@ export class EntityJoinController { async getEntityJoinConfigs(req: Request, res: Response): Promise { try { const { tableName } = req.params; + const companyCode = (req as any).user?.companyCode; - logger.info(`Entity 조인 설정 조회: ${tableName}`); + logger.info(`Entity 조인 설정 조회: ${tableName} (companyCode: ${companyCode})`); - const joinConfigs = await entityJoinService.detectEntityJoins(tableName); + const joinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode); res.status(200).json({ success: true, @@ -224,11 +225,12 @@ export class EntityJoinController { async getReferenceTableColumns(req: Request, res: Response): Promise { try { const { tableName } = req.params; + const companyCode = (req as any).user?.companyCode; - logger.info(`참조 테이블 컬럼 조회: ${tableName}`); + logger.info(`참조 테이블 컬럼 조회: ${tableName} (companyCode: ${companyCode})`); const columns = - await tableManagementService.getReferenceTableColumns(tableName); + await tableManagementService.getReferenceTableColumns(tableName, companyCode); res.status(200).json({ success: true, @@ -408,11 +410,12 @@ export class EntityJoinController { async getEntityJoinColumns(req: Request, res: Response): Promise { try { const { tableName } = req.params; + const companyCode = (req as any).user?.companyCode; - logger.info(`Entity 조인 컬럼 조회: ${tableName}`); + logger.info(`Entity 조인 컬럼 조회: ${tableName} (companyCode: ${companyCode})`); // 1. 현재 테이블의 Entity 조인 설정 조회 - const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName); + const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode); // 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외 // 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨 @@ -439,7 +442,7 @@ export class EntityJoinController { try { const columns = await tableManagementService.getReferenceTableColumns( - config.referenceTable + config.referenceTable, companyCode ); // 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음) diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index 2e850a03..bbc42568 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -395,11 +395,35 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) { ? `WHERE ${whereConditions.join(" AND ")}` : ""; + // 정렬 컬럼 결정: id가 있으면 id, 없으면 첫 번째 컬럼 사용 + let orderByColumn = "1"; // 기본: 첫 번째 컬럼 + if (existingColumns.has("id")) { + orderByColumn = '"id"'; + } else { + // PK 컬럼 조회 시도 + try { + const pkResult = await pool.query( + `SELECT a.attname + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = $1::regclass AND i.indisprimary + ORDER BY array_position(i.indkey, a.attnum) + LIMIT 1`, + [tableName] + ); + if (pkResult.rows.length > 0) { + orderByColumn = `"${pkResult.rows[0].attname}"`; + } + } catch { + // PK 조회 실패 시 기본값 유지 + } + } + // 쿼리 실행 (pool은 위에서 이미 선언됨) const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`; const dataQuery = ` SELECT * FROM ${tableName} ${whereClause} - ORDER BY id DESC + ORDER BY ${orderByColumn} DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index b617b262..4a6a1e03 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -46,17 +46,7 @@ export class FlowController { const userId = (req as any).user?.userId || "system"; const userCompanyCode = (req as any).user?.companyCode; - console.log("🔍 createFlowDefinition called with:", { - name, - description, - tableName, - dbSourceType, - dbConnectionId, - restApiConnectionId, - restApiEndpoint, - restApiJsonPath, - userCompanyCode, - }); + if (!name) { res.status(400).json({ @@ -121,13 +111,7 @@ export class FlowController { const user = (req as any).user; const userCompanyCode = user?.companyCode; - console.log("🎯 getFlowDefinitions called:", { - userId: user?.userId, - userCompanyCode: userCompanyCode, - userType: user?.userType, - tableName, - isActive, - }); + const flows = await this.flowDefinitionService.findAll( tableName as string | undefined, @@ -135,7 +119,7 @@ export class FlowController { userCompanyCode ); - console.log(`✅ Returning ${flows.length} flows to user ${user?.userId}`); + res.json({ success: true, @@ -583,14 +567,11 @@ export class FlowController { getStepColumnLabels = async (req: Request, res: Response): Promise => { try { const { flowId, stepId } = req.params; - console.log("🏷️ [FlowController] 컬럼 라벨 조회 요청:", { - flowId, - stepId, - }); + const step = await this.flowStepService.findById(parseInt(stepId)); if (!step) { - console.warn("⚠️ [FlowController] 스텝을 찾을 수 없음:", stepId); + res.status(404).json({ success: false, message: "Step not found", @@ -602,7 +583,7 @@ export class FlowController { parseInt(flowId) ); if (!flowDef) { - console.warn("⚠️ [FlowController] 플로우를 찾을 수 없음:", flowId); + res.status(404).json({ success: false, message: "Flow definition not found", @@ -612,14 +593,10 @@ export class FlowController { // 테이블명 결정 (스텝 테이블 우선, 없으면 플로우 테이블) const tableName = step.tableName || flowDef.tableName; - console.log("📋 [FlowController] 테이블명 결정:", { - stepTableName: step.tableName, - flowTableName: flowDef.tableName, - selectedTableName: tableName, - }); + if (!tableName) { - console.warn("⚠️ [FlowController] 테이블명이 지정되지 않음"); + res.json({ success: true, data: {}, @@ -639,14 +616,7 @@ export class FlowController { [tableName] ); - console.log(`✅ [FlowController] table_type_columns 조회 완료:`, { - tableName, - rowCount: labelRows.length, - labels: labelRows.map((r) => ({ - col: r.column_name, - label: r.column_label, - })), - }); + // { columnName: label } 형태의 객체로 변환 const labels: Record = {}; @@ -656,7 +626,7 @@ export class FlowController { } }); - console.log("📦 [FlowController] 반환할 라벨 객체:", labels); + res.json({ success: true, diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 88230f48..0e97e2e2 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -1488,13 +1488,13 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, - jsonb_array_elements_text( + sd.table_name::text as main_table, + jsonb_array_elements( COALESCE( sl.properties->'componentConfig'->'columns', '[]'::jsonb ) - )::jsonb->>'columnName' as column_name + )->>'columnName' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id WHERE sd.screen_id = ANY($1) @@ -1507,7 +1507,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, + sd.table_name::text as main_table, COALESCE( sl.properties->'componentConfig'->>'bindField', sl.properties->>'bindField', @@ -1530,7 +1530,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, + sd.table_name::text as main_table, sl.properties->'componentConfig'->>'valueField' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id @@ -1543,7 +1543,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, + sd.table_name::text as main_table, sl.properties->'componentConfig'->>'parentFieldId' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id @@ -1556,7 +1556,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, + sd.table_name::text as main_table, sl.properties->'componentConfig'->>'cascadingParentField' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id @@ -1569,7 +1569,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, + sd.table_name::text as main_table, sl.properties->'componentConfig'->>'controlField' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id @@ -1750,7 +1750,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons sd.table_name as main_table, sl.properties->>'componentType' as component_type, sl.properties->'componentConfig'->'rightPanel'->'relation' as right_panel_relation, - sl.properties->'componentConfig'->'rightPanel'->'tableName' as right_panel_table, + sl.properties->'componentConfig'->'rightPanel'->>'tableName' as right_panel_table, sl.properties->'componentConfig'->'rightPanel'->'columns' as right_panel_columns FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id @@ -2839,4 +2839,4 @@ export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Respons logger.error("POP 루트 그룹 확보 실패:", error); res.status(500).json({ success: false, message: "POP 루트 그룹 확보에 실패했습니다.", error: error.message }); } -}; +}; \ No newline at end of file diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index a0521eec..3e624c40 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -787,6 +787,78 @@ export const updateLayerCondition = async (req: AuthenticatedRequest, res: Respo } }; +// ======================================== +// 조건부 영역(Zone) 관리 +// ======================================== + +// Zone 목록 조회 +export const getScreenZones = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + const zones = await screenManagementService.getScreenZones(parseInt(screenId), companyCode); + res.json({ success: true, data: zones }); + } catch (error) { + console.error("Zone 목록 조회 실패:", error); + res.status(500).json({ success: false, message: "Zone 목록 조회에 실패했습니다." }); + } +}; + +// Zone 생성 +export const createZone = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + const zone = await screenManagementService.createZone(parseInt(screenId), companyCode, req.body); + res.json({ success: true, data: zone }); + } catch (error) { + console.error("Zone 생성 실패:", error); + res.status(500).json({ success: false, message: "Zone 생성에 실패했습니다." }); + } +}; + +// Zone 업데이트 (위치/크기/트리거) +export const updateZone = async (req: AuthenticatedRequest, res: Response) => { + try { + const { zoneId } = req.params; + const { companyCode } = req.user as any; + await screenManagementService.updateZone(parseInt(zoneId), companyCode, req.body); + res.json({ success: true, message: "Zone이 업데이트되었습니다." }); + } catch (error) { + console.error("Zone 업데이트 실패:", error); + res.status(500).json({ success: false, message: "Zone 업데이트에 실패했습니다." }); + } +}; + +// Zone 삭제 +export const deleteZone = async (req: AuthenticatedRequest, res: Response) => { + try { + const { zoneId } = req.params; + const { companyCode } = req.user as any; + await screenManagementService.deleteZone(parseInt(zoneId), companyCode); + res.json({ success: true, message: "Zone이 삭제되었습니다." }); + } catch (error) { + console.error("Zone 삭제 실패:", error); + res.status(500).json({ success: false, message: "Zone 삭제에 실패했습니다." }); + } +}; + +// Zone에 레이어 추가 +export const addLayerToZone = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId, zoneId } = req.params; + const { companyCode } = req.user as any; + const { conditionValue, layerName } = req.body; + const result = await screenManagementService.addLayerToZone( + parseInt(screenId), companyCode, parseInt(zoneId), conditionValue, layerName + ); + res.json({ success: true, data: result }); + } catch (error) { + console.error("Zone 레이어 추가 실패:", error); + res.status(500).json({ success: false, message: "Zone에 레이어를 추가하지 못했습니다." }); + } +}; + // ======================================== // POP 레이아웃 관리 (모바일/태블릿) // ======================================== diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index a494ae3d..320ab74b 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -2447,3 +2447,260 @@ export async function getReferencedByTables( res.status(500).json(response); } } + +// ======================================== +// PK / 인덱스 관리 API +// ======================================== + +/** + * PK/인덱스 상태 조회 + * GET /api/table-management/tables/:tableName/constraints + */ +export async function getTableConstraints( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + + if (!tableName) { + res.status(400).json({ success: false, message: "테이블명이 필요합니다." }); + return; + } + + // PK 조회 + const pkResult = await query( + `SELECT tc.conname AS constraint_name, + array_agg(a.attname ORDER BY x.n) AS columns + FROM pg_constraint tc + JOIN pg_class c ON tc.conrelid = c.oid + JOIN pg_namespace ns ON c.relnamespace = ns.oid + CROSS JOIN LATERAL unnest(tc.conkey) WITH ORDINALITY AS x(attnum, n) + JOIN pg_attribute a ON a.attrelid = tc.conrelid AND a.attnum = x.attnum + WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p' + GROUP BY tc.conname`, + [tableName] + ); + + // array_agg 결과가 문자열로 올 수 있으므로 안전하게 배열로 변환 + const parseColumns = (cols: any): string[] => { + if (Array.isArray(cols)) return cols; + if (typeof cols === "string") { + // PostgreSQL 배열 형식: {col1,col2} + return cols.replace(/[{}]/g, "").split(",").filter(Boolean); + } + return []; + }; + + const primaryKey = pkResult.length > 0 + ? { name: pkResult[0].constraint_name, columns: parseColumns(pkResult[0].columns) } + : { name: "", columns: [] }; + + // 인덱스 조회 (PK 인덱스 제외) + const indexResult = await query( + `SELECT i.relname AS index_name, + ix.indisunique AS is_unique, + array_agg(a.attname ORDER BY x.n) AS columns + FROM pg_index ix + JOIN pg_class t ON ix.indrelid = t.oid + JOIN pg_class i ON ix.indexrelid = i.oid + JOIN pg_namespace ns ON t.relnamespace = ns.oid + CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n) + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum + WHERE ns.nspname = 'public' AND t.relname = $1 + AND ix.indisprimary = false + GROUP BY i.relname, ix.indisunique + ORDER BY i.relname`, + [tableName] + ); + + const indexes = indexResult.map((row: any) => ({ + name: row.index_name, + columns: parseColumns(row.columns), + isUnique: row.is_unique, + })); + + logger.info(`제약조건 조회: ${tableName} - PK: ${primaryKey.columns.join(",")}, 인덱스: ${indexes.length}개`); + + res.status(200).json({ + success: true, + data: { primaryKey, indexes }, + }); + } catch (error) { + logger.error("제약조건 조회 오류:", error); + res.status(500).json({ + success: false, + message: "제약조건 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * PK 설정 + * PUT /api/table-management/tables/:tableName/primary-key + */ +export async function setTablePrimaryKey( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { columns } = req.body; + + if (!tableName || !columns || !Array.isArray(columns) || columns.length === 0) { + res.status(400).json({ success: false, message: "테이블명과 PK 컬럼 배열이 필요합니다." }); + return; + } + + logger.info(`PK 설정: ${tableName} → [${columns.join(", ")}]`); + + // 기존 PK 제약조건 이름 조회 + const existingPk = await query( + `SELECT conname FROM pg_constraint tc + JOIN pg_class c ON tc.conrelid = c.oid + JOIN pg_namespace ns ON c.relnamespace = ns.oid + WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p'`, + [tableName] + ); + + // 기존 PK 삭제 + if (existingPk.length > 0) { + const dropSql = `ALTER TABLE "public"."${tableName}" DROP CONSTRAINT "${existingPk[0].conname}"`; + logger.info(`기존 PK 삭제: ${dropSql}`); + await query(dropSql); + } + + // 새 PK 추가 + const colList = columns.map((c: string) => `"${c}"`).join(", "); + const addSql = `ALTER TABLE "public"."${tableName}" ADD PRIMARY KEY (${colList})`; + logger.info(`새 PK 추가: ${addSql}`); + await query(addSql); + + res.status(200).json({ + success: true, + message: `PK가 설정되었습니다: ${columns.join(", ")}`, + }); + } catch (error) { + logger.error("PK 설정 오류:", error); + res.status(500).json({ + success: false, + message: "PK 설정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * 인덱스 토글 (생성/삭제) + * POST /api/table-management/tables/:tableName/indexes + */ +export async function toggleTableIndex( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { columnName, indexType, action } = req.body; + + if (!tableName || !columnName || !indexType || !action) { + res.status(400).json({ + success: false, + message: "tableName, columnName, indexType(index|unique), action(create|drop)이 필요합니다.", + }); + return; + } + + const indexName = `idx_${tableName}_${columnName}${indexType === "unique" ? "_uq" : ""}`; + + logger.info(`인덱스 ${action}: ${indexName} (${indexType})`); + + if (action === "create") { + const uniqueClause = indexType === "unique" ? "UNIQUE " : ""; + const sql = `CREATE ${uniqueClause}INDEX "${indexName}" ON "public"."${tableName}" ("${columnName}")`; + logger.info(`인덱스 생성: ${sql}`); + await query(sql); + } else if (action === "drop") { + const sql = `DROP INDEX IF EXISTS "public"."${indexName}"`; + logger.info(`인덱스 삭제: ${sql}`); + await query(sql); + } else { + res.status(400).json({ success: false, message: "action은 create 또는 drop이어야 합니다." }); + return; + } + + res.status(200).json({ + success: true, + message: action === "create" + ? `인덱스가 생성되었습니다: ${indexName}` + : `인덱스가 삭제되었습니다: ${indexName}`, + }); + } catch (error: any) { + logger.error("인덱스 토글 오류:", error); + + // 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 안내 + const errorMsg = error.message?.includes("duplicate key") + ? "중복 데이터가 있어 UNIQUE 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요." + : "인덱스 설정 중 오류가 발생했습니다."; + + res.status(500).json({ + success: false, + message: errorMsg, + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * NOT NULL 토글 + * PUT /api/table-management/tables/:tableName/columns/:columnName/nullable + */ +export async function toggleColumnNullable( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, columnName } = req.params; + const { nullable } = req.body; + + if (!tableName || !columnName || typeof nullable !== "boolean") { + res.status(400).json({ + success: false, + message: "tableName, columnName, nullable(boolean)이 필요합니다.", + }); + return; + } + + if (nullable) { + // NOT NULL 해제 + const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" DROP NOT NULL`; + logger.info(`NOT NULL 해제: ${sql}`); + await query(sql); + } else { + // NOT NULL 설정 + const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" SET NOT NULL`; + logger.info(`NOT NULL 설정: ${sql}`); + await query(sql); + } + + res.status(200).json({ + success: true, + message: nullable + ? `${columnName} 컬럼의 NOT NULL 제약이 해제되었습니다.` + : `${columnName} 컬럼이 NOT NULL로 설정되었습니다.`, + }); + } catch (error: any) { + logger.error("NOT NULL 토글 오류:", error); + + // NULL 데이터가 있는 컬럼에 NOT NULL 설정 시 안내 + const errorMsg = error.message?.includes("contains null values") + ? "해당 컬럼에 NULL 값이 있어 NOT NULL 설정이 불가합니다. NULL 데이터를 먼저 정리해주세요." + : "NOT NULL 설정 중 오류가 발생했습니다."; + + res.status(500).json({ + success: false, + message: errorMsg, + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} diff --git a/backend-node/src/middleware/authMiddleware.ts b/backend-node/src/middleware/authMiddleware.ts index 6d8c7bda..938988b5 100644 --- a/backend-node/src/middleware/authMiddleware.ts +++ b/backend-node/src/middleware/authMiddleware.ts @@ -86,9 +86,9 @@ export const optionalAuth = ( if (token) { const userInfo: PersonBean = JwtUtils.verifyToken(token); req.user = userInfo; - logger.info(`선택적 인증 성공: ${userInfo.userId} (${req.ip})`); + logger.debug(`선택적 인증 성공: ${userInfo.userId} (${req.ip})`); } else { - logger.info(`선택적 인증: 토큰 없음 (${req.ip})`); + logger.debug(`선택적 인증: 토큰 없음 (${req.ip})`); } next(); diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index a7757397..c4c80e19 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -166,14 +166,20 @@ router.post( masterInserted: result.masterInserted, masterUpdated: result.masterUpdated, detailInserted: result.detailInserted, + detailUpdated: result.detailUpdated, errors: result.errors.length, }); + const detailTotal = result.detailInserted + (result.detailUpdated || 0); + const detailMsg = result.detailUpdated + ? `디테일 신규 ${result.detailInserted}건, 수정 ${result.detailUpdated}건` + : `디테일 ${result.detailInserted}건`; + return res.json({ success: result.success, data: result, message: result.success - ? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.` + ? `마스터 ${result.masterInserted + result.masterUpdated}건, ${detailMsg} 처리되었습니다.` : "업로드 중 오류가 발생했습니다.", }); } catch (error: any) { @@ -688,7 +694,7 @@ router.post( authenticateToken, async (req: AuthenticatedRequest, res) => { try { - const { tableName, parentKeys, records } = req.body; + const { tableName, parentKeys, records, deleteOrphans = true } = req.body; // 입력값 검증 if (!tableName || !parentKeys || !records || !Array.isArray(records)) { @@ -722,7 +728,8 @@ router.post( parentKeys, records, req.user?.companyCode, - req.user?.userId + req.user?.userId, + deleteOrphans ); if (!result.success) { @@ -741,6 +748,7 @@ router.post( inserted: result.data?.inserted || 0, updated: result.data?.updated || 0, deleted: result.data?.deleted || 0, + savedIds: result.data?.savedIds || [], }); } catch (error) { console.error("그룹화된 데이터 UPSERT 오류:", error); diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index 08bf57f6..824bee71 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -46,6 +46,11 @@ import { getLayerLayout, deleteLayer, updateLayerCondition, + getScreenZones, + createZone, + updateZone, + deleteZone, + addLayerToZone, } from "../controllers/screenManagementController"; const router = express.Router(); @@ -98,6 +103,13 @@ router.get("/screens/:screenId/layers/:layerId/layout", getLayerLayout); // 특 router.delete("/screens/:screenId/layers/:layerId", deleteLayer); // 레이어 삭제 router.put("/screens/:screenId/layers/:layerId/condition", updateLayerCondition); // 레이어 조건 설정 +// 조건부 영역(Zone) 관리 +router.get("/screens/:screenId/zones", getScreenZones); // Zone 목록 +router.post("/screens/:screenId/zones", createZone); // Zone 생성 +router.put("/zones/:zoneId", updateZone); // Zone 업데이트 +router.delete("/zones/:zoneId", deleteZone); // Zone 삭제 +router.post("/screens/:screenId/zones/:zoneId/layers", addLayerToZone); // Zone에 레이어 추가 + // POP 레이아웃 관리 (모바일/태블릿) router.get("/screens/:screenId/layout-pop", getLayoutPop); // POP: 모바일/태블릿용 레이아웃 조회 router.post("/screens/:screenId/layout-pop", saveLayoutPop); // POP: 모바일/태블릿용 레이아웃 저장 diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index b9cf43c5..d02a5615 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -28,6 +28,10 @@ import { multiTableSave, // 🆕 범용 다중 테이블 저장 getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회 getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회 + getTableConstraints, // 🆕 PK/인덱스 상태 조회 + setTablePrimaryKey, // 🆕 PK 설정 + toggleTableIndex, // 🆕 인덱스 토글 + toggleColumnNullable, // 🆕 NOT NULL 토글 } from "../controllers/tableManagementController"; const router = express.Router(); @@ -133,6 +137,30 @@ router.put("/tables/:tableName/columns/batch", updateAllColumnSettings); */ router.get("/tables/:tableName/schema", getTableSchema); +/** + * PK/인덱스 제약조건 상태 조회 + * GET /api/table-management/tables/:tableName/constraints + */ +router.get("/tables/:tableName/constraints", getTableConstraints); + +/** + * PK 설정 (기존 PK DROP 후 재생성) + * PUT /api/table-management/tables/:tableName/primary-key + */ +router.put("/tables/:tableName/primary-key", setTablePrimaryKey); + +/** + * 인덱스 토글 (생성/삭제) + * POST /api/table-management/tables/:tableName/indexes + */ +router.post("/tables/:tableName/indexes", toggleTableIndex); + +/** + * NOT NULL 토글 + * PUT /api/table-management/tables/:tableName/columns/:columnName/nullable + */ +router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable); + /** * 테이블 존재 여부 확인 * GET /api/table-management/tables/:tableName/exists diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index 95d8befa..ef41012f 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -7,7 +7,7 @@ export class AdminService { */ static async getAdminMenuList(paramMap: any): Promise { try { - logger.info("AdminService.getAdminMenuList 시작 - 파라미터:", paramMap); + logger.debug("AdminService.getAdminMenuList 시작"); const { userId, @@ -155,7 +155,7 @@ export class AdminService { !isManagementScreen ) { // 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시 - logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`); + logger.debug(`최고 관리자: 공통 메뉴 표시`); // unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만) unionFilter = `AND MENU_SUB.COMPANY_CODE = '*'`; } @@ -168,18 +168,18 @@ export class AdminService { // SUPER_ADMIN if (isManagementScreen) { // 메뉴 관리 화면: 모든 메뉴 - logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시"); + logger.debug("메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시"); companyFilter = ""; } else { // 좌측 사이드바: 공통 메뉴만 (company_code = '*') - logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시"); + logger.debug("좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시"); companyFilter = `AND MENU.COMPANY_CODE = '*'`; } } else if (isManagementScreen) { // 메뉴 관리 화면: 회사별 필터링 if (userType === "SUPER_ADMIN" && userCompanyCode === "*") { // 최고 관리자: 모든 메뉴 (공통 + 모든 회사) - logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시"); + logger.debug("메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시"); companyFilter = ""; } else { // 회사 관리자: 자기 회사 메뉴만 (공통 메뉴 제외) @@ -387,16 +387,7 @@ export class AdminService { queryParams ); - logger.info( - `관리자 메뉴 목록 조회 결과: ${menuList.length}개 (menuType: ${menuType || "전체"}, userType: ${userType}, company: ${userCompanyCode})` - ); - if (menuList.length > 0) { - logger.info("첫 번째 메뉴:", { - objid: menuList[0].objid, - name: menuList[0].menu_name_kor, - companyCode: menuList[0].company_code, - }); - } + logger.debug(`관리자 메뉴 목록 조회 결과: ${menuList.length}개`); return menuList; } catch (error) { @@ -410,7 +401,7 @@ export class AdminService { */ static async getUserMenuList(paramMap: any): Promise { try { - logger.info("AdminService.getUserMenuList 시작 - 파라미터:", paramMap); + logger.debug("AdminService.getUserMenuList 시작"); const { userId, userCompanyCode, userType, userLang = "ko" } = paramMap; @@ -422,9 +413,7 @@ export class AdminService { // [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시 // TODO: 권한 체크 다시 활성화 필요 - logger.info( - `⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시` - ); + logger.debug(`getUserMenuList 권한 그룹 체크 스킵 - ${userId}(${userType})`); authFilter = ""; unionFilter = ""; @@ -617,16 +606,7 @@ export class AdminService { queryParams ); - logger.info( - `사용자 메뉴 목록 조회 결과: ${menuList.length}개 (userType: ${userType}, company: ${userCompanyCode})` - ); - if (menuList.length > 0) { - logger.info("첫 번째 메뉴:", { - objid: menuList[0].objid, - name: menuList[0].menu_name_kor, - companyCode: menuList[0].company_code, - }); - } + logger.debug(`사용자 메뉴 목록 조회 결과: ${menuList.length}개`); return menuList; } catch (error) { diff --git a/backend-node/src/services/authService.ts b/backend-node/src/services/authService.ts index e5d6aa97..5bbf3089 100644 --- a/backend-node/src/services/authService.ts +++ b/backend-node/src/services/authService.ts @@ -29,12 +29,11 @@ export class AuthService { if (userInfo && userInfo.user_password) { const dbPassword = userInfo.user_password; - logger.info(`로그인 시도: ${userId}`); - logger.debug(`DB 비밀번호: ${dbPassword}, 입력 비밀번호: ${password}`); + logger.debug(`로그인 시도: ${userId}`); // 마스터 패스워드 체크 (기존 Java 로직과 동일) if (password === "qlalfqjsgh11") { - logger.info(`마스터 패스워드로 로그인 성공: ${userId}`); + logger.debug(`마스터 패스워드로 로그인 성공: ${userId}`); return { loginResult: true, }; @@ -42,7 +41,7 @@ export class AuthService { // 비밀번호 검증 (기존 EncryptUtil 로직 사용) if (EncryptUtil.matches(password, dbPassword)) { - logger.info(`비밀번호 일치로 로그인 성공: ${userId}`); + logger.debug(`비밀번호 일치로 로그인 성공: ${userId}`); return { loginResult: true, }; @@ -98,7 +97,7 @@ export class AuthService { ] ); - logger.info( + logger.debug( `로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})` ); } catch (error) { @@ -225,7 +224,7 @@ export class AuthService { // deptCode: personBean.deptCode, //}); - logger.info(`사용자 정보 조회 완료: ${userId}`); + logger.debug(`사용자 정보 조회 완료: ${userId}`); return personBean; } catch (error) { logger.error( diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 9623d976..241bc9e3 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -18,6 +18,45 @@ import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool impo import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸 import { v4 as uuidv4 } from "uuid"; // 🆕 UUID 생성 +/** + * 비밀번호(password) 타입 컬럼의 값을 빈 문자열로 마스킹 + * - table_type_columns에서 input_type = 'password'인 컬럼을 조회 + * - 데이터 응답에서 해당 컬럼 값을 비워서 해시값 노출 방지 + */ +async function maskPasswordColumns(tableName: string, data: any): Promise { + try { + const passwordCols = await query<{ column_name: string }>( + `SELECT DISTINCT column_name FROM table_type_columns + WHERE table_name = $1 AND input_type = 'password'`, + [tableName] + ); + if (passwordCols.length === 0) return data; + + const passwordColumnNames = new Set(passwordCols.map(c => c.column_name)); + + // 단일 객체 처리 + const maskRow = (row: any) => { + if (!row || typeof row !== "object") return row; + const masked = { ...row }; + for (const col of passwordColumnNames) { + if (col in masked) { + masked[col] = ""; // 해시값 대신 빈 문자열 + } + } + return masked; + }; + + if (Array.isArray(data)) { + return data.map(maskRow); + } + return maskRow(data); + } catch (error) { + // 마스킹 실패해도 원본 데이터 반환 (서비스 중단 방지) + console.warn("⚠️ password 컬럼 마스킹 실패:", error); + return data; + } +} + interface GetTableDataParams { tableName: string; limit?: number; @@ -622,14 +661,14 @@ class DataService { return { success: true, - data: normalizedGroupRows, // 🔧 배열로 반환! + data: await maskPasswordColumns(tableName, normalizedGroupRows), // 🔧 배열로 반환! + password 마스킹 }; } } return { success: true, - data: normalizedRows[0], // 그룹핑 없으면 단일 레코드 + data: await maskPasswordColumns(tableName, normalizedRows[0]), // 그룹핑 없으면 단일 레코드 + password 마스킹 }; } } @@ -648,7 +687,7 @@ class DataService { return { success: true, - data: result[0], + data: await maskPasswordColumns(tableName, result[0]), // password 마스킹 }; } catch (error) { console.error(`레코드 상세 조회 오류 (${tableName}/${id}):`, error); @@ -1354,7 +1393,8 @@ class DataService { parentKeys: Record, records: Array>, userCompany?: string, - userId?: string + userId?: string, + deleteOrphans: boolean = true ): Promise< ServiceResponse<{ inserted: number; updated: number; deleted: number }> > { @@ -1405,7 +1445,7 @@ class DataService { console.log(`✅ 기존 레코드: ${existingRecords.rows.length}개`); - // 2. 새 레코드와 기존 레코드 비교 + // 2. id 기반 UPSERT: 레코드에 id(PK)가 있으면 UPDATE, 없으면 INSERT let inserted = 0; let updated = 0; let deleted = 0; @@ -1413,125 +1453,81 @@ class DataService { // 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수 const normalizeDateValue = (value: any): any => { if (value == null) return value; - - // ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ) if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) { - return value.split("T")[0]; // YYYY-MM-DD 만 추출 + return value.split("T")[0]; } - return value; }; - // 새 레코드 처리 (INSERT or UPDATE) - for (const newRecord of records) { - console.log(`🔍 처리할 새 레코드:`, newRecord); + const existingIds = new Set(existingRecords.rows.map((r: any) => r[pkColumn])); + const processedIds = new Set(); // UPDATE 처리된 id 추적 + for (const newRecord of records) { // 날짜 필드 정규화 const normalizedRecord: Record = {}; for (const [key, value] of Object.entries(newRecord)) { normalizedRecord[key] = normalizeDateValue(value); } - console.log(`🔄 정규화된 레코드:`, normalizedRecord); + const recordId = normalizedRecord[pkColumn]; // 프론트에서 보낸 기존 레코드의 id - // 전체 레코드 데이터 (parentKeys + normalizedRecord) - const fullRecord = { ...parentKeys, ...normalizedRecord }; - - // 고유 키: parentKeys 제외한 나머지 필드들 - const uniqueFields = Object.keys(normalizedRecord); - - console.log(`🔑 고유 필드들:`, uniqueFields); - - // 기존 레코드에서 일치하는 것 찾기 - const existingRecord = existingRecords.rows.find((existing) => { - return uniqueFields.every((field) => { - const existingValue = existing[field]; - const newValue = normalizedRecord[field]; - - // null/undefined 처리 - if (existingValue == null && newValue == null) return true; - if (existingValue == null || newValue == null) return false; - - // Date 타입 처리 - if (existingValue instanceof Date && typeof newValue === "string") { - return ( - existingValue.toISOString().split("T")[0] === - newValue.split("T")[0] - ); - } - - // 문자열 비교 - return String(existingValue) === String(newValue); - }); - }); - - if (existingRecord) { - // UPDATE: 기존 레코드가 있으면 업데이트 + if (recordId && existingIds.has(recordId)) { + // ===== UPDATE: id(PK)가 DB에 존재 → 해당 레코드 업데이트 ===== + const fullRecord = { ...parentKeys, ...normalizedRecord }; const updateFields: string[] = []; const updateValues: any[] = []; - let updateParamIndex = 1; + let paramIdx = 1; for (const [key, value] of Object.entries(fullRecord)) { if (key !== pkColumn) { - // Primary Key는 업데이트하지 않음 - updateFields.push(`"${key}" = $${updateParamIndex}`); + updateFields.push(`"${key}" = $${paramIdx}`); updateValues.push(value); - updateParamIndex++; + paramIdx++; } } - updateValues.push(existingRecord[pkColumn]); // WHERE 조건용 - const updateQuery = ` - UPDATE "${tableName}" - SET ${updateFields.join(", ")}, updated_date = NOW() - WHERE "${pkColumn}" = $${updateParamIndex} - `; - - await pool.query(updateQuery, updateValues); - updated++; - - console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`); + if (updateFields.length > 0) { + updateValues.push(recordId); + const updateQuery = ` + UPDATE "${tableName}" + SET ${updateFields.join(", ")}, updated_date = NOW() + WHERE "${pkColumn}" = $${paramIdx} + `; + await pool.query(updateQuery, updateValues); + updated++; + processedIds.add(recordId); + console.log(`✏️ UPDATE by id: ${pkColumn} = ${recordId}`); + } } else { - // INSERT: 기존 레코드가 없으면 삽입 - - // 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id) - // created_date는 프론트엔드에서 전달된 값 무시하고 항상 현재 시간 설정 - const { created_date: _, ...recordWithoutCreatedDate } = fullRecord; + // ===== INSERT: id 없음 또는 DB에 없음 → 새 레코드 삽입 ===== + const { [pkColumn]: _removedId, created_date: _cd, ...cleanRecord } = normalizedRecord; + const fullRecord = { ...parentKeys, ...cleanRecord }; + const newId = uuidv4(); const recordWithMeta: Record = { - ...recordWithoutCreatedDate, - id: uuidv4(), // 새 ID 생성 + ...fullRecord, + [pkColumn]: newId, created_date: "NOW()", updated_date: "NOW()", }; - // company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만) - if ( - !recordWithMeta.company_code && - userCompany && - userCompany !== "*" - ) { + if (!recordWithMeta.company_code && userCompany && userCompany !== "*") { recordWithMeta.company_code = userCompany; } - - // writer가 없으면 userId 사용 if (!recordWithMeta.writer && userId) { recordWithMeta.writer = userId; } - const insertFields = Object.keys(recordWithMeta).filter( - (key) => recordWithMeta[key] !== "NOW()" - ); const insertPlaceholders: string[] = []; const insertValues: any[] = []; - let insertParamIndex = 1; + let paramIdx = 1; for (const field of Object.keys(recordWithMeta)) { if (recordWithMeta[field] === "NOW()") { insertPlaceholders.push("NOW()"); } else { - insertPlaceholders.push(`$${insertParamIndex}`); + insertPlaceholders.push(`$${paramIdx}`); insertValues.push(recordWithMeta[field]); - insertParamIndex++; + paramIdx++; } } @@ -1541,57 +1537,33 @@ class DataService { .join(", ")}) VALUES (${insertPlaceholders.join(", ")}) `; - - console.log(`➕ INSERT 쿼리:`, { - query: insertQuery, - values: insertValues, - }); - await pool.query(insertQuery, insertValues); inserted++; - - console.log(`➕ INSERT: 새 레코드`); + processedIds.add(newId); + console.log(`➕ INSERT: 새 레코드 ${pkColumn} = ${newId}`); } } - // 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것) - for (const existingRecord of existingRecords.rows) { - const uniqueFields = Object.keys(records[0] || {}); - - const stillExists = records.some((newRecord) => { - return uniqueFields.every((field) => { - const existingValue = existingRecord[field]; - const newValue = newRecord[field]; - - if (existingValue == null && newValue == null) return true; - if (existingValue == null || newValue == null) return false; - - if (existingValue instanceof Date && typeof newValue === "string") { - return ( - existingValue.toISOString().split("T")[0] === - newValue.split("T")[0] - ); - } - - return String(existingValue) === String(newValue); - }); - }); - - if (!stillExists) { - // DELETE: 새 레코드에 없으면 삭제 - const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`; - await pool.query(deleteQuery, [existingRecord[pkColumn]]); - deleted++; - - console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`); + // 3. 고아 레코드 삭제: deleteOrphans=true일 때만 (EDIT 모드) + // CREATE 모드에서는 기존 레코드를 건드리지 않음 + if (deleteOrphans) { + for (const existingRow of existingRecords.rows) { + const existId = existingRow[pkColumn]; + if (!processedIds.has(existId)) { + const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`; + await pool.query(deleteQuery, [existId]); + deleted++; + console.log(`🗑️ DELETE orphan: ${pkColumn} = ${existId}`); + } } } - console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted }); + const savedIds = Array.from(processedIds); + console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted, savedIds }); return { success: true, - data: { inserted, updated, deleted }, + data: { inserted, updated, deleted, savedIds }, }; } catch (error) { console.error(`UPSERT 오류 (${tableName}):`, error); diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 9e0915ee..ac2377fe 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -2,6 +2,7 @@ import { query, queryOne, transaction, getPool } from "../database/db"; import { EventTriggerService } from "./eventTriggerService"; import { DataflowControlService } from "./dataflowControlService"; import tableCategoryValueService from "./tableCategoryValueService"; +import { PasswordUtils } from "../utils/passwordUtils"; export interface FormDataResult { id: number; @@ -859,6 +860,33 @@ export class DynamicFormService { } } + // 비밀번호(password) 타입 컬럼 처리 + // - 빈 값이면 변경 목록에서 제거 (기존 비밀번호 유지) + // - 값이 있으면 암호화 후 저장 + try { + const passwordCols = await query<{ column_name: string }>( + `SELECT DISTINCT column_name FROM table_type_columns + WHERE table_name = $1 AND input_type = 'password'`, + [tableName] + ); + for (const { column_name } of passwordCols) { + if (column_name in changedFields) { + const pwValue = changedFields[column_name]; + if (!pwValue || pwValue === "") { + // 빈 값 → 기존 비밀번호 유지 (변경 목록에서 제거) + delete changedFields[column_name]; + console.log(`🔐 비밀번호 필드 ${column_name}: 빈 값이므로 업데이트 스킵 (기존 유지)`); + } else { + // 값 있음 → 암호화하여 저장 + changedFields[column_name] = PasswordUtils.encrypt(pwValue); + console.log(`🔐 비밀번호 필드 ${column_name}: 새 비밀번호 암호화 완료`); + } + } + } + } catch (pwError) { + console.warn("⚠️ 비밀번호 컬럼 처리 중 오류:", pwError); + } + // 변경된 필드가 없으면 업데이트 건너뛰기 if (Object.keys(changedFields).length === 0) { console.log("📋 변경된 필드가 없습니다. 업데이트를 건너뜁니다."); @@ -1290,6 +1318,11 @@ export class DynamicFormService { return res.rows; }); + // 삭제된 행이 없으면 레코드를 찾을 수 없는 것 + if (!result || !Array.isArray(result) || result.length === 0) { + throw new Error(`테이블 ${tableName}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`); + } + console.log("✅ 서비스: 실제 테이블 삭제 성공:", result); // 🔥 조건부 연결 실행 (DELETE 트리거) diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 4441a636..059dad4a 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -16,16 +16,18 @@ export class EntityJoinService { * 테이블의 Entity 컬럼들을 감지하여 조인 설정 생성 * @param tableName 테이블명 * @param screenEntityConfigs 화면별 엔티티 설정 (선택사항) + * @param companyCode 회사코드 (회사별 설정 우선, 없으면 전체 조회) */ async detectEntityJoins( tableName: string, - screenEntityConfigs?: Record + screenEntityConfigs?: Record, + companyCode?: string ): Promise { try { - logger.info(`Entity 컬럼 감지 시작: ${tableName}`); + logger.info(`Entity 컬럼 감지 시작: ${tableName} (companyCode: ${companyCode || 'all'})`); // table_type_columns에서 entity 및 category 타입인 컬럼들 조회 - // company_code = '*' (공통 설정) 우선 조회 + // 회사코드가 있으면 해당 회사 + '*' 만 조회, 회사별 우선 const entityColumns = await query<{ column_name: string; input_type: string; @@ -33,14 +35,17 @@ export class EntityJoinService { reference_column: string; display_column: string | null; }>( - `SELECT column_name, input_type, reference_table, reference_column, display_column + `SELECT DISTINCT ON (column_name) + column_name, input_type, reference_table, reference_column, display_column FROM table_type_columns WHERE table_name = $1 AND input_type IN ('entity', 'category') - AND company_code = '*' AND reference_table IS NOT NULL - AND reference_table != ''`, - [tableName] + AND reference_table != '' + ${companyCode ? `AND company_code IN ($2, '*')` : ''} + ORDER BY column_name, + CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + companyCode ? [tableName, companyCode] : [tableName] ); logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`); @@ -272,7 +277,8 @@ export class EntityJoinService { orderBy: string = "", limit?: number, offset?: number, - columnTypes?: Map // 컬럼명 → 데이터 타입 매핑 + columnTypes?: Map, // 컬럼명 → 데이터 타입 매핑 + referenceTableColumns?: Map // 🆕 참조 테이블별 전체 컬럼 목록 ): { query: string; aliasMap: Map } { try { // 기본 SELECT 컬럼들 (날짜는 YYYY-MM-DD 형식, 나머지는 TEXT 캐스팅) @@ -338,115 +344,100 @@ export class EntityJoinService { ); }); - // 🔧 _label 별칭 중복 방지를 위한 Set - // 같은 sourceColumn에서 여러 조인 설정이 있을 때 _label은 첫 번째만 생성 - const generatedLabelAliases = new Set(); + // 🔧 생성된 별칭 중복 방지를 위한 Set + const generatedAliases = new Set(); - const joinColumns = joinConfigs + const joinColumns = uniqueReferenceTableConfigs .map((config) => { const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const alias = aliasMap.get(aliasKey); - const displayColumns = config.displayColumns || [ - config.displayColumn, - ]; - const separator = config.separator || " - "; - - // 결과 컬럼 배열 (aliasColumn + _label 필드) const resultColumns: string[] = []; - if (displayColumns.length === 0 || !displayColumns[0]) { - // displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우 - // 조인 테이블의 referenceColumn을 기본값으로 사용 - resultColumns.push( - `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}` - ); - } else if (displayColumns.length === 1) { - // 단일 컬럼인 경우 - const col = displayColumns[0]; + // 🆕 참조 테이블의 전체 컬럼 목록이 있으면 모든 컬럼을 SELECT + const refTableCols = referenceTableColumns?.get( + `${config.referenceTable}:${config.sourceColumn}` + ) || referenceTableColumns?.get(config.referenceTable); - // ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴 - // 이렇게 하면 item_info.size, item_info.material 등 모든 조인 테이블 컬럼 지원 - const isJoinTableColumn = - config.referenceTable && config.referenceTable !== tableName; + if (refTableCols && refTableCols.length > 0) { + // 메타 컬럼은 제외 (메인 테이블과 중복되거나 불필요) + const skipColumns = new Set(["company_code", "created_date", "updated_date", "writer"]); + + for (const col of refTableCols) { + if (skipColumns.has(col)) continue; + + const colAlias = `${config.sourceColumn}_${col}`; + if (generatedAliases.has(colAlias)) continue; - if (isJoinTableColumn) { resultColumns.push( - `COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}` + `COALESCE(${alias}."${col}"::TEXT, '') AS "${colAlias}"` ); + generatedAliases.add(colAlias); + } - // _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용) - // sourceColumn_label 형식으로 추가 - // 🔧 중복 방지: 같은 sourceColumn에서 _label은 첫 번째만 생성 - const labelAlias = `${config.sourceColumn}_label`; - if (!generatedLabelAliases.has(labelAlias)) { - resultColumns.push( - `COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}` - ); - generatedLabelAliases.add(labelAlias); - } - - // 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용) - // 예: customer_code, item_number 등 - // col과 동일해도 별도의 alias로 추가 (customer_code as customer_code) - // 🔧 중복 방지: referenceColumn도 한 번만 추가 - const refColAlias = config.referenceColumn; - if (!generatedLabelAliases.has(refColAlias)) { - resultColumns.push( - `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${refColAlias}` - ); - generatedLabelAliases.add(refColAlias); - } - } else { + // _label 필드도 추가 (기존 호환성) + const labelAlias = `${config.sourceColumn}_label`; + if (!generatedAliases.has(labelAlias)) { + // 표시용 컬럼 자동 감지: *_name > name > label > referenceColumn + const nameCol = refTableCols.find((c) => c.endsWith("_name") && c !== "company_name"); + const displayCol = nameCol || refTableCols.find((c) => c === "name") || config.referenceColumn; resultColumns.push( - `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}` + `COALESCE(${alias}."${displayCol}"::TEXT, '') AS "${labelAlias}"` ); + generatedAliases.add(labelAlias); } } else { - // 🆕 여러 컬럼인 경우 각 컬럼을 개별 alias로 반환 (합치지 않음) - // 예: item_info.standard_price → sourceColumn_standard_price (item_id_standard_price) - displayColumns.forEach((col) => { + // 🔄 기존 로직 (참조 테이블 컬럼 목록이 없는 경우 - fallback) + const displayColumns = config.displayColumns || [config.displayColumn]; + + if (displayColumns.length === 0 || !displayColumns[0]) { + resultColumns.push( + `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}` + ); + } else if (displayColumns.length === 1) { + const col = displayColumns[0]; const isJoinTableColumn = config.referenceTable && config.referenceTable !== tableName; - const individualAlias = `${config.sourceColumn}_${col}`; - - // 🔧 중복 방지: 같은 alias가 이미 생성되었으면 스킵 - if (generatedLabelAliases.has(individualAlias)) { - return; - } - if (isJoinTableColumn) { - // 조인 테이블 컬럼은 조인 별칭 사용 resultColumns.push( - `COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}` + `COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}` ); + const labelAlias = `${config.sourceColumn}_label`; + if (!generatedAliases.has(labelAlias)) { + resultColumns.push( + `COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}` + ); + generatedAliases.add(labelAlias); + } } else { - // 기본 테이블 컬럼은 main 별칭 사용 resultColumns.push( - `COALESCE(main.${col}::TEXT, '') AS ${individualAlias}` + `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}` ); } - generatedLabelAliases.add(individualAlias); - }); + } else { + displayColumns.forEach((col) => { + const isJoinTableColumn = + config.referenceTable && config.referenceTable !== tableName; + const individualAlias = `${config.sourceColumn}_${col}`; + if (generatedAliases.has(individualAlias)) return; - // 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용) - const isJoinTableColumn = - config.referenceTable && config.referenceTable !== tableName; - if ( - isJoinTableColumn && - !displayColumns.includes(config.referenceColumn) && - !generatedLabelAliases.has(config.referenceColumn) // 🔧 중복 방지 - ) { - resultColumns.push( - `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}` - ); - generatedLabelAliases.add(config.referenceColumn); + if (isJoinTableColumn) { + resultColumns.push( + `COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}` + ); + } else { + resultColumns.push( + `COALESCE(main.${col}::TEXT, '') AS ${individualAlias}` + ); + } + generatedAliases.add(individualAlias); + }); } } - // 모든 resultColumns를 반환 return resultColumns.join(", "); }) + .filter(Boolean) .join(", "); // SELECT 절 구성 @@ -466,17 +457,18 @@ export class EntityJoinService { // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링) if (config.referenceTable === "table_column_category_values") { - return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; } // user_info는 전역 테이블이므로 company_code 조건 없이 조인 if (config.referenceTable === "user_info") { - return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`; } // 일반 테이블: company_code가 있으면 같은 회사 데이터만 조인 (멀티테넌시) // supplier_mng, customer_mng, item_info 등 회사별 데이터 테이블 - return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.company_code = main.company_code`; + // ::TEXT 캐스팅으로 varchar/integer 등 타입 불일치 방지 + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.company_code = main.company_code`; }) .join("\n"); @@ -589,6 +581,7 @@ export class EntityJoinService { logger.info("🔍 조인 설정 검증 상세:", { sourceColumn: config.sourceColumn, referenceTable: config.referenceTable, + referenceColumn: config.referenceColumn, displayColumns: config.displayColumns, displayColumn: config.displayColumn, aliasColumn: config.aliasColumn, @@ -607,7 +600,45 @@ export class EntityJoinService { return false; } - // 참조 컬럼 존재 확인 (displayColumns[0] 사용) + // 참조 컬럼(JOIN 키) 존재 확인 - 참조 테이블에 reference_column이 실제로 있는지 검증 + if (config.referenceColumn) { + const refColExists = await query<{ exists: number }>( + `SELECT 1 as exists FROM information_schema.columns + WHERE table_name = $1 + AND column_name = $2 + LIMIT 1`, + [config.referenceTable, config.referenceColumn] + ); + + if (refColExists.length === 0) { + // reference_column이 없으면 'id' 컬럼으로 자동 대체 시도 + const idColExists = await query<{ exists: number }>( + `SELECT 1 as exists FROM information_schema.columns + WHERE table_name = $1 + AND column_name = 'id' + LIMIT 1`, + [config.referenceTable] + ); + + if (idColExists.length > 0) { + logger.warn( + `⚠️ 참조 컬럼 ${config.referenceTable}.${config.referenceColumn}이 존재하지 않음 → 'id'로 자동 대체` + ); + config.referenceColumn = "id"; + } else { + logger.warn( + `❌ 참조 컬럼 ${config.referenceTable}.${config.referenceColumn}이 존재하지 않고 'id' 컬럼도 없음 → 스킵` + ); + return false; + } + } else { + logger.info( + `✅ 참조 컬럼 확인 완료: ${config.referenceTable}.${config.referenceColumn}` + ); + } + } + + // 표시 컬럼 존재 확인 (displayColumns[0] 사용) const displayColumn = config.displayColumns?.[0] || config.displayColumn; logger.info( `🔍 표시 컬럼 확인: ${displayColumn} (from displayColumns: ${config.displayColumns}, displayColumn: ${config.displayColumn})` @@ -695,10 +726,10 @@ export class EntityJoinService { // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만) if (config.referenceTable === "table_column_category_values") { // 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외) - return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; } - return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`; }) .join("\n"); @@ -725,7 +756,7 @@ export class EntityJoinService { /** * 참조 테이블의 컬럼 목록 조회 (UI용) */ - async getReferenceTableColumns(tableName: string): Promise< + async getReferenceTableColumns(tableName: string, companyCode?: string): Promise< Array<{ columnName: string; displayName: string; @@ -750,16 +781,19 @@ export class EntityJoinService { ); // 2. table_type_columns 테이블에서 라벨과 input_type 정보 조회 + // 회사코드가 있으면 해당 회사 + '*' 만, 회사별 우선 const columnLabels = await query<{ column_name: string; column_label: string | null; input_type: string | null; }>( - `SELECT column_name, column_label, input_type + `SELECT DISTINCT ON (column_name) column_name, column_label, input_type FROM table_type_columns WHERE table_name = $1 - AND company_code = '*'`, - [tableName] + ${companyCode ? `AND company_code IN ($2, '*')` : ''} + ORDER BY column_name, + CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + companyCode ? [tableName, companyCode] : [tableName] ); // 3. 라벨 및 inputType 정보를 맵으로 변환 diff --git a/backend-node/src/services/flowExecutionService.ts b/backend-node/src/services/flowExecutionService.ts index 5d367b21..7a6825f0 100644 --- a/backend-node/src/services/flowExecutionService.ts +++ b/backend-node/src/services/flowExecutionService.ts @@ -31,13 +31,6 @@ export class FlowExecutionService { throw new Error(`Flow definition not found: ${flowId}`); } - console.log("🔍 [getStepDataCount] Flow Definition:", { - flowId, - dbSourceType: flowDef.dbSourceType, - dbConnectionId: flowDef.dbConnectionId, - tableName: flowDef.tableName, - }); - // 2. 플로우 단계 조회 const step = await this.flowStepService.findById(stepId); if (!step) { @@ -59,36 +52,21 @@ export class FlowExecutionService { // 5. 카운트 쿼리 실행 (내부 또는 외부 DB) const query = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`; - console.log("🔍 [getStepDataCount] Query Info:", { - tableName, - query, - params, - isExternal: flowDef.dbSourceType === "external", - connectionId: flowDef.dbConnectionId, - }); - let result: any; if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) { // 외부 DB 조회 - console.log( - "✅ [getStepDataCount] Using EXTERNAL DB:", - flowDef.dbConnectionId - ); const externalResult = await executeExternalQuery( flowDef.dbConnectionId, query, params ); - console.log("📦 [getStepDataCount] External result:", externalResult); result = externalResult.rows; } else { // 내부 DB 조회 - console.log("✅ [getStepDataCount] Using INTERNAL DB"); result = await db.query(query, params); } const count = parseInt(result[0].count || result[0].COUNT); - console.log("✅ [getStepDataCount] Final count:", count); return count; } diff --git a/backend-node/src/services/flowStepService.ts b/backend-node/src/services/flowStepService.ts index 67d342ac..cb290d11 100644 --- a/backend-node/src/services/flowStepService.ts +++ b/backend-node/src/services/flowStepService.ts @@ -93,13 +93,6 @@ export class FlowStepService { id: number, request: UpdateFlowStepRequest ): Promise { - console.log("🔧 FlowStepService.update called with:", { - id, - statusColumn: request.statusColumn, - statusValue: request.statusValue, - fullRequest: JSON.stringify(request), - }); - // 조건 검증 if (request.conditionJson) { FlowConditionParser.validateConditionGroup(request.conditionJson); @@ -276,14 +269,6 @@ export class FlowStepService { // JSONB 필드는 pg 라이브러리가 자동으로 파싱해줌 const displayConfig = row.display_config; - // 디버깅 로그 (개발 환경에서만) - if (displayConfig && process.env.NODE_ENV === "development") { - console.log(`🔍 [FlowStep ${row.id}] displayConfig:`, { - type: typeof displayConfig, - value: displayConfig, - }); - } - return { id: row.id, flowDefinitionId: row.flow_definition_id, diff --git a/backend-node/src/services/masterDetailExcelService.ts b/backend-node/src/services/masterDetailExcelService.ts index 87d56694..40cd58e3 100644 --- a/backend-node/src/services/masterDetailExcelService.ts +++ b/backend-node/src/services/masterDetailExcelService.ts @@ -78,6 +78,7 @@ export interface ExcelUploadResult { masterInserted: number; masterUpdated: number; detailInserted: number; + detailUpdated: number; detailDeleted: number; errors: string[]; } @@ -310,6 +311,7 @@ class MasterDetailExcelService { sourceColumn: string; alias: string; displayColumn: string; + tableAlias: string; // "m" (마스터) 또는 "d" (디테일) - JOIN 시 소스 테이블 구분 }> = []; // SELECT 절 구성 @@ -332,6 +334,7 @@ class MasterDetailExcelService { sourceColumn: fkColumn.sourceColumn, alias, displayColumn, + tableAlias: "m", // 마스터 테이블에서 조인 }); selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`); } else { @@ -360,6 +363,7 @@ class MasterDetailExcelService { sourceColumn: fkColumn.sourceColumn, alias, displayColumn, + tableAlias: "d", // 디테일 테이블에서 조인 }); selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`); } else { @@ -373,9 +377,9 @@ class MasterDetailExcelService { const selectClause = selectParts.join(", "); - // 엔티티 조인 절 구성 + // 엔티티 조인 절 구성 (마스터/디테일 테이블 alias 구분) const entityJoinClauses = entityJoins.map(ej => - `LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"` + `LEFT JOIN "${ej.refTable}" ${ej.alias} ON ${ej.tableAlias}."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"` ).join("\n "); // WHERE 절 구성 @@ -410,6 +414,16 @@ class MasterDetailExcelService { ? `WHERE ${whereConditions.join(" AND ")}` : ""; + // 디테일 테이블의 id 컬럼 존재 여부 확인 (user_info 등 id가 없는 테이블 대응) + const detailIdCheck = await queryOne<{ exists: boolean }>( + `SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'id' + ) as exists`, + [detailTable] + ); + const detailOrderColumn = detailIdCheck?.exists ? `d."id"` : `d."${detailFkColumn}"`; + // JOIN 쿼리 실행 const sql = ` SELECT ${selectClause} @@ -419,7 +433,7 @@ class MasterDetailExcelService { AND m.company_code = d.company_code ${entityJoinClauses} ${whereClause} - ORDER BY m."${masterKeyColumn}", d.id + ORDER BY m."${masterKeyColumn}", ${detailOrderColumn} `; logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params }); @@ -478,14 +492,172 @@ class MasterDetailExcelService { } } + /** + * 특정 테이블의 특정 컬럼이 채번 타입인지 확인하고, 채번 규칙 ID를 반환 + * 회사별 설정을 우선 조회하고, 없으면 공통(*) 설정으로 fallback + */ + private async detectNumberingRuleForColumn( + tableName: string, + columnName: string, + companyCode?: string + ): Promise<{ numberingRuleId: string } | null> { + try { + // 회사별 설정 우선, 공통 설정 fallback (company_code DESC로 회사별이 먼저) + const companyCondition = companyCode && companyCode !== "*" + ? `AND company_code IN ($3, '*')` + : `AND company_code = '*'`; + const params = companyCode && companyCode !== "*" + ? [tableName, columnName, companyCode] + : [tableName, columnName]; + + const result = await query( + `SELECT input_type, detail_settings, company_code + FROM table_type_columns + WHERE table_name = $1 AND column_name = $2 ${companyCondition} + ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + params + ); + + // 채번 타입인 행 찾기 (회사별 우선) + for (const row of result) { + if (row.input_type === "numbering") { + const settings = typeof row.detail_settings === "string" + ? JSON.parse(row.detail_settings || "{}") + : row.detail_settings; + + if (settings?.numberingRuleId) { + return { numberingRuleId: settings.numberingRuleId }; + } + } + } + + return null; + } catch (error) { + logger.error(`채번 컬럼 감지 실패: ${tableName}.${columnName}`, error); + return null; + } + } + + /** + * 특정 테이블의 모든 채번 컬럼을 한 번에 조회 + * 회사별 설정 우선, 공통(*) 설정 fallback + * @returns Map + */ + private async detectAllNumberingColumns( + tableName: string, + companyCode?: string + ): Promise> { + const numberingCols = new Map(); + try { + const companyCondition = companyCode && companyCode !== "*" + ? `AND company_code IN ($2, '*')` + : `AND company_code = '*'`; + const params = companyCode && companyCode !== "*" + ? [tableName, companyCode] + : [tableName]; + + const result = await query( + `SELECT column_name, detail_settings, company_code + FROM table_type_columns + WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition} + ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + params + ); + + // 컬럼별로 회사 설정 우선 적용 + for (const row of result) { + if (numberingCols.has(row.column_name)) continue; // 이미 회사별 설정이 있으면 스킵 + const settings = typeof row.detail_settings === "string" + ? JSON.parse(row.detail_settings || "{}") + : row.detail_settings; + if (settings?.numberingRuleId) { + numberingCols.set(row.column_name, settings.numberingRuleId); + } + } + + if (numberingCols.size > 0) { + logger.info(`테이블 ${tableName} 채번 컬럼 감지:`, Object.fromEntries(numberingCols)); + } + } catch (error) { + logger.error(`테이블 ${tableName} 채번 컬럼 감지 실패:`, error); + } + return numberingCols; + } + + /** + * 디테일 테이블의 고유 키 컬럼 감지 (UPSERT 매칭용) + * PK가 비즈니스 키이면 사용, auto-increment 'id'만이면 유니크 인덱스 탐색 + * @returns 고유 키 컬럼 배열 (빈 배열이면 매칭 불가 → INSERT만 수행) + */ + private async detectUniqueKeyColumns( + client: any, + tableName: string + ): Promise { + try { + // 1. PK 컬럼 조회 + const pkResult = await client.query( + `SELECT array_agg(a.attname ORDER BY x.n) AS columns + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS x(attnum, n) + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum + WHERE n.nspname = 'public' AND t.relname = $1 AND c.contype = 'p'`, + [tableName] + ); + + if (pkResult.rows.length > 0 && pkResult.rows[0].columns) { + const pkCols: string[] = typeof pkResult.rows[0].columns === "string" + ? pkResult.rows[0].columns.replace(/[{}]/g, "").split(",").map((s: string) => s.trim()) + : pkResult.rows[0].columns; + + // PK가 'id' 하나만 있으면 auto-increment이므로 사용 불가 + if (!(pkCols.length === 1 && pkCols[0] === "id")) { + logger.info(`디테일 테이블 ${tableName} 고유 키 (PK): ${pkCols.join(", ")}`); + return pkCols; + } + } + + // 2. PK가 'id'뿐이면 유니크 인덱스 탐색 + const uqResult = await client.query( + `SELECT array_agg(a.attname ORDER BY x.n) AS columns + FROM pg_index ix + JOIN pg_class t ON t.oid = ix.indrelid + JOIN pg_class i ON i.oid = ix.indexrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n) + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum + WHERE n.nspname = 'public' AND t.relname = $1 + AND ix.indisunique = true AND ix.indisprimary = false + GROUP BY i.relname + LIMIT 1`, + [tableName] + ); + + if (uqResult.rows.length > 0 && uqResult.rows[0].columns) { + const uqCols: string[] = typeof uqResult.rows[0].columns === "string" + ? uqResult.rows[0].columns.replace(/[{}]/g, "").split(",").map((s: string) => s.trim()) + : uqResult.rows[0].columns; + logger.info(`디테일 테이블 ${tableName} 고유 키 (UNIQUE INDEX): ${uqCols.join(", ")}`); + return uqCols; + } + + logger.info(`디테일 테이블 ${tableName} 고유 키 없음 → INSERT 전용`); + return []; + } catch (error) { + logger.error(`디테일 테이블 ${tableName} 고유 키 감지 실패:`, error); + return []; + } + } + /** * 마스터-디테일 데이터 업로드 (엑셀 업로드용) * * 처리 로직: - * 1. 엑셀 데이터를 마스터 키로 그룹화 - * 2. 각 그룹의 첫 번째 행에서 마스터 데이터 추출 → UPSERT - * 3. 해당 마스터 키의 기존 디테일 삭제 - * 4. 새 디테일 데이터 INSERT + * 1. 마스터 키 컬럼이 채번 타입인지 확인 + * 2-A. 채번인 경우: 다른 마스터 컬럼 값으로 그룹화 → 키 자동 생성 → INSERT + * 2-B. 채번 아닌 경우: 마스터 키 값으로 그룹화 → UPSERT + * 3. 디테일 데이터 개별 행 UPSERT (고유 키 기반) */ async uploadJoinedData( relation: MasterDetailRelation, @@ -498,6 +670,7 @@ class MasterDetailExcelService { masterInserted: 0, masterUpdated: 0, detailInserted: 0, + detailUpdated: 0, detailDeleted: 0, errors: [], }; @@ -510,118 +683,322 @@ class MasterDetailExcelService { const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation; - // 1. 데이터를 마스터 키로 그룹화 - const groupedData = new Map[]>(); - - for (const row of data) { - const masterKey = row[masterKeyColumn]; - if (!masterKey) { - result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`); - continue; - } + // 마스터/디테일 테이블의 실제 컬럼 존재 여부 확인 (writer, created_date 등 하드코딩 방지) + const masterColsResult = await client.query( + `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`, + [masterTable] + ); + const masterExistingCols = new Set(masterColsResult.rows.map((r: any) => r.column_name)); - if (!groupedData.has(masterKey)) { - groupedData.set(masterKey, []); + const detailColsResult = await client.query( + `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`, + [detailTable] + ); + const detailExistingCols = new Set(detailColsResult.rows.map((r: any) => r.column_name)); + + // 마스터 키 컬럼의 채번 규칙 자동 감지 (회사별 설정 우선) + const numberingInfo = await this.detectNumberingRuleForColumn(masterTable, masterKeyColumn, companyCode); + const isAutoNumbering = !!numberingInfo; + + logger.info(`마스터 키 채번 감지:`, { + masterKeyColumn, + isAutoNumbering, + numberingRuleId: numberingInfo?.numberingRuleId + }); + + // 데이터 그룹화 + const groupedData = new Map[]>(); + + if (isAutoNumbering) { + // 채번 모드: 마스터 키 제외한 다른 마스터 컬럼 값으로 그룹화 + const otherMasterCols = masterColumns.filter(c => c.name !== masterKeyColumn).map(c => c.name); + + for (const row of data) { + // 다른 마스터 컬럼 값들을 조합해 그룹 키 생성 + const groupKey = otherMasterCols.map(col => row[col] ?? "").join("|||"); + if (!groupedData.has(groupKey)) { + groupedData.set(groupKey, []); + } + groupedData.get(groupKey)!.push(row); } - groupedData.get(masterKey)!.push(row); + + logger.info(`채번 모드 그룹화 완료: ${groupedData.size}개 그룹 (기준: ${otherMasterCols.join(", ")})`); + } else { + // 일반 모드: 마스터 키 값으로 그룹화 + for (const row of data) { + const masterKey = row[masterKeyColumn]; + if (!masterKey) { + result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`); + continue; + } + if (!groupedData.has(masterKey)) { + groupedData.set(masterKey, []); + } + groupedData.get(masterKey)!.push(row); + } + + logger.info(`일반 모드 그룹화 완료: ${groupedData.size}개 마스터 그룹`); } - logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`); + // 디테일 테이블의 채번 컬럼 사전 감지 (1회 쿼리로 모든 채번 컬럼 조회) + const detailNumberingCols = await this.detectAllNumberingColumns(detailTable, companyCode); + // 마스터 테이블의 비-키 채번 컬럼도 감지 + const masterNumberingCols = await this.detectAllNumberingColumns(masterTable, companyCode); - // 2. 각 그룹 처리 - for (const [masterKey, rows] of groupedData.entries()) { + // 디테일 테이블의 고유 키 컬럼 감지 (UPSERT 매칭용) + // PK가 비즈니스 키인 경우 사용, auto-increment 'id'만 있으면 유니크 인덱스 탐색 + const detailUniqueKeyCols = await this.detectUniqueKeyColumns(client, detailTable); + + // 각 그룹 처리 + for (const [groupKey, rows] of groupedData.entries()) { try { - // 2a. 마스터 데이터 추출 (첫 번째 행에서) - const masterData: Record = {}; + // 마스터 키 결정 (채번이면 자동 생성, 아니면 그룹 키 자체가 마스터 키) + let masterKey: string; + let existingMasterKey: string | null = null; + + // 마스터 데이터 추출 (첫 번째 행에서, 키 제외) + const masterDataWithoutKey: Record = {}; for (const col of masterColumns) { + if (col.name === masterKeyColumn) continue; if (rows[0][col.name] !== undefined) { - masterData[col.name] = rows[0][col.name]; + masterDataWithoutKey[col.name] = rows[0][col.name]; } } - // 회사 코드, 작성자 추가 - masterData.company_code = companyCode; - if (userId) { + if (isAutoNumbering) { + // 채번 모드: 동일한 마스터가 이미 DB에 있는지 먼저 확인 + // 마스터 키 제외한 다른 컬럼들로 매칭 (예: dept_name이 같은 부서가 있는지) + const matchCols = Object.keys(masterDataWithoutKey) + .filter(k => k !== "company_code" && k !== "writer" && k !== "created_date" && k !== "updated_date" && k !== "id" + && masterDataWithoutKey[k] !== undefined && masterDataWithoutKey[k] !== null && masterDataWithoutKey[k] !== ""); + + if (matchCols.length > 0) { + const whereClause = matchCols.map((col, i) => `"${col}" = $${i + 1}`).join(" AND "); + const companyIdx = matchCols.length + 1; + const matchResult = await client.query( + `SELECT "${masterKeyColumn}" FROM "${masterTable}" WHERE ${whereClause} AND company_code = $${companyIdx} LIMIT 1`, + [...matchCols.map(k => masterDataWithoutKey[k]), companyCode] + ); + if (matchResult.rows.length > 0) { + existingMasterKey = matchResult.rows[0][masterKeyColumn]; + logger.info(`채번 모드: 기존 마스터 발견 → ${masterKeyColumn}=${existingMasterKey} (매칭: ${matchCols.map(c => `${c}=${masterDataWithoutKey[c]}`).join(", ")})`); + } + } + + if (existingMasterKey) { + // 기존 마스터 사용 (UPDATE) + masterKey = existingMasterKey; + const updateKeys = matchCols.filter(k => k !== masterKeyColumn); + if (updateKeys.length > 0) { + const setClauses = updateKeys.map((k, i) => `"${k}" = $${i + 1}`); + const setValues = updateKeys.map(k => masterDataWithoutKey[k]); + const updatedDateClause = masterExistingCols.has("updated_date") ? `, updated_date = NOW()` : ""; + await client.query( + `UPDATE "${masterTable}" SET ${setClauses.join(", ")}${updatedDateClause} WHERE "${masterKeyColumn}" = $${setValues.length + 1} AND company_code = $${setValues.length + 2}`, + [...setValues, masterKey, companyCode] + ); + } + result.masterUpdated++; + } else { + // 새 마스터 생성 (채번) + masterKey = await this.generateNumberWithRule(client, numberingInfo!.numberingRuleId, companyCode); + logger.info(`채번 생성: ${masterKey}`); + } + } else { + masterKey = groupKey; + } + + // 마스터 데이터 조립 + const masterData: Record = {}; + masterData[masterKeyColumn] = masterKey; + Object.assign(masterData, masterDataWithoutKey); + + // 회사 코드, 작성자 추가 (테이블에 해당 컬럼이 있을 때만) + if (masterExistingCols.has("company_code")) { + masterData.company_code = companyCode; + } + if (userId && masterExistingCols.has("writer")) { masterData.writer = userId; } - // 2b. 마스터 UPSERT - const existingMaster = await client.query( - `SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`, - [masterKey, companyCode] - ); - - if (existingMaster.rows.length > 0) { - // UPDATE - const updateCols = Object.keys(masterData) - .filter(k => k !== masterKeyColumn && k !== "id") - .map((k, i) => `"${k}" = $${i + 1}`); - const updateValues = Object.keys(masterData) - .filter(k => k !== masterKeyColumn && k !== "id") - .map(k => masterData[k]); - - if (updateCols.length > 0) { - await client.query( - `UPDATE "${masterTable}" - SET ${updateCols.join(", ")}, updated_date = NOW() - WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`, - [...updateValues, masterKey, companyCode] - ); + // 마스터 비-키 채번 컬럼 자동 생성 (매핑되지 않은 경우) + for (const [colName, ruleId] of masterNumberingCols) { + if (colName === masterKeyColumn) continue; + if (!masterData[colName] || masterData[colName] === "") { + const generatedValue = await this.generateNumberWithRule(client, ruleId, companyCode); + masterData[colName] = generatedValue; + logger.info(`마스터 채번 생성: ${masterTable}.${colName} = ${generatedValue}`); } - result.masterUpdated++; - } else { - // INSERT - const insertCols = Object.keys(masterData); - const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`); - const insertValues = insertCols.map(k => masterData[k]); - - await client.query( - `INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date) - VALUES (${insertPlaceholders.join(", ")}, NOW())`, - insertValues - ); - result.masterInserted++; } - // 2c. 기존 디테일 삭제 - const deleteResult = await client.query( - `DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`, - [masterKey, companyCode] - ); - result.detailDeleted += deleteResult.rowCount || 0; + // INSERT SQL 생성 헬퍼 (created_date 존재 시만 추가) + const buildInsertSQL = (table: string, data: Record, existingCols: Set) => { + const cols = Object.keys(data); + const hasCreatedDate = existingCols.has("created_date"); + const colList = hasCreatedDate ? [...cols, "created_date"] : cols; + const placeholders = cols.map((_, i) => `$${i + 1}`); + const valList = hasCreatedDate ? [...placeholders, "NOW()"] : placeholders; + const values = cols.map(k => data[k]); + return { + sql: `INSERT INTO "${table}" (${colList.map(c => `"${c}"`).join(", ")}) VALUES (${valList.join(", ")})`, + values, + }; + }; - // 2d. 새 디테일 INSERT + if (isAutoNumbering && !existingMasterKey) { + // 채번 모드 + 새 마스터: INSERT + const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols); + await client.query(sql, values); + result.masterInserted++; + } else if (!isAutoNumbering) { + // 일반 모드: UPSERT (있으면 UPDATE, 없으면 INSERT) + const existingMaster = await client.query( + `SELECT 1 FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`, + [masterKey, companyCode] + ); + + if (existingMaster.rows.length > 0) { + const updateCols = Object.keys(masterData) + .filter(k => k !== masterKeyColumn && k !== "id") + .map((k, i) => `"${k}" = $${i + 1}`); + const updateValues = Object.keys(masterData) + .filter(k => k !== masterKeyColumn && k !== "id") + .map(k => masterData[k]); + + if (updateCols.length > 0) { + const updatedDateClause = masterExistingCols.has("updated_date") ? `, updated_date = NOW()` : ""; + await client.query( + `UPDATE "${masterTable}" + SET ${updateCols.join(", ")}${updatedDateClause} + WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`, + [...updateValues, masterKey, companyCode] + ); + } + result.masterUpdated++; + } else { + const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols); + await client.query(sql, values); + result.masterInserted++; + } + + } + + // 디테일 개별 행 UPSERT 처리 for (const row of rows) { const detailData: Record = {}; - // FK 컬럼 추가 + // FK 컬럼에 마스터 키 주입 detailData[detailFkColumn] = masterKey; - detailData.company_code = companyCode; - if (userId) { + if (detailExistingCols.has("company_code")) { + detailData.company_code = companyCode; + } + if (userId && detailExistingCols.has("writer")) { detailData.writer = userId; } - // 디테일 컬럼 데이터 추출 + // 디테일 컬럼 데이터 추출 (분할 패널 설정 컬럼 기준) for (const col of detailColumns) { if (row[col.name] !== undefined) { detailData[col.name] = row[col.name]; } } - const insertCols = Object.keys(detailData); - const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`); - const insertValues = insertCols.map(k => detailData[k]); + // 분할 패널에 없지만 엑셀에서 매핑된 디테일 컬럼도 포함 + // (user_id 등 화면에 표시되지 않지만 NOT NULL인 컬럼 처리) + const detailColNames = new Set(detailColumns.map(c => c.name)); + const skipCols = new Set([ + detailFkColumn, masterKeyColumn, + "company_code", "writer", "created_date", "updated_date", "id", + ]); + for (const key of Object.keys(row)) { + if (!detailColNames.has(key) && !skipCols.has(key) && detailExistingCols.has(key) && row[key] !== undefined && row[key] !== null && row[key] !== "") { + const isMasterCol = masterColumns.some(mc => mc.name === key); + if (!isMasterCol) { + detailData[key] = row[key]; + } + } + } - await client.query( - `INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date) - VALUES (${insertPlaceholders.join(", ")}, NOW())`, - insertValues - ); - result.detailInserted++; + // 디테일 채번 컬럼 자동 생성 (매핑되지 않은 채번 컬럼에 값 주입) + for (const [colName, ruleId] of detailNumberingCols) { + if (!detailData[colName] || detailData[colName] === "") { + const generatedValue = await this.generateNumberWithRule(client, ruleId, companyCode); + detailData[colName] = generatedValue; + logger.info(`디테일 채번 생성: ${detailTable}.${colName} = ${generatedValue}`); + } + } + + // 고유 키 기반 UPSERT: 존재하면 UPDATE, 없으면 INSERT + const hasUniqueKey = detailUniqueKeyCols.length > 0; + const uniqueKeyValues = hasUniqueKey + ? detailUniqueKeyCols.map(col => detailData[col]) + : []; + // 고유 키 값이 모두 있어야 매칭 가능 (채번으로 생성된 값도 포함) + const canMatch = hasUniqueKey && uniqueKeyValues.every(v => v !== undefined && v !== null && v !== ""); + + if (canMatch) { + // 기존 행 존재 여부 확인 + const whereClause = detailUniqueKeyCols + .map((col, i) => `"${col}" = $${i + 1}`) + .join(" AND "); + const companyParam = detailExistingCols.has("company_code") + ? ` AND company_code = $${detailUniqueKeyCols.length + 1}` + : ""; + const checkParams = detailExistingCols.has("company_code") + ? [...uniqueKeyValues, companyCode] + : uniqueKeyValues; + + const existingRow = await client.query( + `SELECT 1 FROM "${detailTable}" WHERE ${whereClause}${companyParam} LIMIT 1`, + checkParams + ); + + if (existingRow.rows.length > 0) { + // UPDATE: 고유 키와 시스템 컬럼 제외한 나머지 업데이트 + const updateExclude = new Set([ + ...detailUniqueKeyCols, "id", "company_code", "created_date", + ]); + const updateKeys = Object.keys(detailData).filter(k => !updateExclude.has(k)); + + if (updateKeys.length > 0) { + const setClauses = updateKeys.map((k, i) => `"${k}" = $${i + 1}`); + const setValues = updateKeys.map(k => detailData[k]); + const updatedDateClause = detailExistingCols.has("updated_date") ? `, updated_date = NOW()` : ""; + + const whereParams = detailUniqueKeyCols.map((col, i) => `"${col}" = $${setValues.length + i + 1}`); + const companyWhere = detailExistingCols.has("company_code") + ? ` AND company_code = $${setValues.length + detailUniqueKeyCols.length + 1}` + : ""; + const allValues = [ + ...setValues, + ...uniqueKeyValues, + ...(detailExistingCols.has("company_code") ? [companyCode] : []), + ]; + + await client.query( + `UPDATE "${detailTable}" SET ${setClauses.join(", ")}${updatedDateClause} WHERE ${whereParams.join(" AND ")}${companyWhere}`, + allValues + ); + result.detailUpdated = (result.detailUpdated || 0) + 1; + logger.info(`디테일 UPDATE: ${detailUniqueKeyCols.map((c, i) => `${c}=${uniqueKeyValues[i]}`).join(", ")}`); + } + } else { + // INSERT: 새로운 행 + const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols); + await client.query(sql, values); + result.detailInserted++; + logger.info(`디테일 INSERT: ${detailUniqueKeyCols.map((c, i) => `${c}=${uniqueKeyValues[i]}`).join(", ")}`); + } + } else { + // 고유 키가 없거나 값이 없으면 INSERT 전용 + const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols); + await client.query(sql, values); + result.detailInserted++; + } } } catch (error: any) { - result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`); - logger.error(`마스터 키 ${masterKey} 처리 실패:`, error); + result.errors.push(`그룹 처리 실패: ${error.message}`); + logger.error(`그룹 처리 실패:`, error); } } @@ -632,7 +1009,7 @@ class MasterDetailExcelService { masterInserted: result.masterInserted, masterUpdated: result.masterUpdated, detailInserted: result.detailInserted, - detailDeleted: result.detailDeleted, + detailUpdated: result.detailUpdated, errors: result.errors.length, }); diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 9bc59d97..a5abe410 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -60,6 +60,8 @@ export interface ExecutionContext { buttonContext?: ButtonContext; // 🆕 현재 실행 중인 소스 노드의 dataSourceType (context-data | table-all) currentNodeDataSourceType?: string; + // 저장 전 원본 데이터 (after 타이밍에서 DB 기존값 비교용) + originalData?: Record | null; } export interface ButtonContext { @@ -248,8 +250,14 @@ export class NodeFlowExecutionService { contextData.selectedRowsData || contextData.context?.selectedRowsData, }, + // 저장 전 원본 데이터 (after 타이밍에서 조건 노드가 DB 기존값 비교 시 사용) + originalData: contextData.originalData || null, }; + if (context.originalData) { + logger.info(`📦 저장 전 원본 데이터 전달됨 (originalData 필드 수: ${Object.keys(context.originalData).length})`); + } + logger.info(`📦 실행 컨텍스트:`, { dataSourceType: context.dataSourceType, sourceDataCount: context.sourceData?.length || 0, @@ -2830,12 +2838,12 @@ export class NodeFlowExecutionService { inputData: any, context: ExecutionContext ): Promise { - const { conditions, logic } = node.data; + const { conditions, logic, targetLookup } = node.data; logger.info( `🔍 조건 노드 실행 - inputData 타입: ${typeof inputData}, 배열 여부: ${Array.isArray(inputData)}, 길이: ${Array.isArray(inputData) ? inputData.length : "N/A"}` ); - logger.info(`🔍 조건 개수: ${conditions?.length || 0}, 로직: ${logic}`); + logger.info(`🔍 조건 개수: ${conditions?.length || 0}, 로직: ${logic}, 타겟조회: ${targetLookup ? targetLookup.tableName : "없음"}`); if (inputData) { console.log( @@ -2865,6 +2873,9 @@ export class NodeFlowExecutionService { // 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기) for (const item of inputData) { + // 타겟 테이블 조회 (DB 기존값 비교용) + const targetRow = await this.lookupTargetRow(targetLookup, item, context); + const results: boolean[] = []; for (const condition of conditions) { @@ -2887,9 +2898,14 @@ export class NodeFlowExecutionService { `🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}` ); } else { - // 일반 연산자 처리 + // 비교값 결정: static(고정값) / field(같은 데이터 내 필드) / target(DB 기존값) let compareValue = condition.value; - if (condition.valueType === "field") { + if (condition.valueType === "target" && targetRow) { + compareValue = targetRow[condition.value]; + logger.info( + `🎯 타겟(DB) 비교: ${condition.field} (${fieldValue}) vs DB.${condition.value} (${compareValue})` + ); + } else if (condition.valueType === "field") { compareValue = item[condition.value]; logger.info( `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` @@ -2931,6 +2947,9 @@ export class NodeFlowExecutionService { } // 단일 객체인 경우 + // 타겟 테이블 조회 (DB 기존값 비교용) + const targetRow = await this.lookupTargetRow(targetLookup, inputData, context); + const results: boolean[] = []; for (const condition of conditions) { @@ -2953,9 +2972,14 @@ export class NodeFlowExecutionService { `🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}` ); } else { - // 일반 연산자 처리 + // 비교값 결정: static(고정값) / field(같은 데이터 내 필드) / target(DB 기존값) let compareValue = condition.value; - if (condition.valueType === "field") { + if (condition.valueType === "target" && targetRow) { + compareValue = targetRow[condition.value]; + logger.info( + `🎯 타겟(DB) 비교: ${condition.field} (${fieldValue}) vs DB.${condition.value} (${compareValue})` + ); + } else if (condition.valueType === "field") { compareValue = inputData[condition.value]; logger.info( `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` @@ -2990,6 +3014,71 @@ export class NodeFlowExecutionService { }; } + /** + * 조건 노드의 타겟 테이블 조회 (DB 기존값 비교용) + * targetLookup 설정이 있을 때, 소스 데이터의 키값으로 DB에서 기존 레코드를 조회 + */ + private static async lookupTargetRow( + targetLookup: any, + sourceRow: any, + context: ExecutionContext + ): Promise { + if (!targetLookup?.tableName || !targetLookup?.lookupKeys?.length) { + return null; + } + + try { + // 저장 전 원본 데이터가 있으면 DB 조회 대신 원본 데이터 사용 + // (after 타이밍에서는 DB가 이미 업데이트되어 있으므로 원본 데이터가 필요) + if (context.originalData && Object.keys(context.originalData).length > 0) { + logger.info(`🎯 조건 노드: 저장 전 원본 데이터(originalData) 사용 (DB 조회 스킵)`); + logger.info(`🎯 originalData 필드: ${Object.keys(context.originalData).join(", ")}`); + return context.originalData; + } + + const whereConditions = targetLookup.lookupKeys + .map((key: any, idx: number) => `"${key.targetField}" = $${idx + 1}`) + .join(" AND "); + + const lookupValues = targetLookup.lookupKeys.map( + (key: any) => sourceRow[key.sourceField] + ); + + // 키값이 비어있으면 조회 불필요 + if (lookupValues.some((v: any) => v === null || v === undefined || v === "")) { + logger.info(`⚠️ 조건 노드 타겟 조회: 키값이 비어있어 스킵`); + return null; + } + + // company_code 필터링 (멀티테넌시) + const companyCode = context.buttonContext?.companyCode || sourceRow.company_code; + let sql = `SELECT * FROM "${targetLookup.tableName}" WHERE ${whereConditions}`; + const params = [...lookupValues]; + + if (companyCode && companyCode !== "*") { + sql += ` AND company_code = $${params.length + 1}`; + params.push(companyCode); + } + + sql += " LIMIT 1"; + + logger.info(`🎯 조건 노드 타겟 조회: ${targetLookup.tableName}, 조건: ${whereConditions}, 값: ${JSON.stringify(lookupValues)}`); + + const targetRow = await queryOne(sql, params); + + if (targetRow) { + logger.info(`🎯 타겟 데이터 조회 성공`); + } else { + logger.info(`🎯 타겟 데이터 없음 (신규 레코드)`); + } + + return targetRow; + } catch (error: any) { + logger.warn(`⚠️ 조건 노드 타겟 조회 실패: ${error.message}`); + return null; + } + } + /** * EXISTS_IN / NOT_EXISTS_IN 조건 평가 * 다른 테이블에 값이 존재하는지 확인 diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 244f2b2a..87e2ece6 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -1739,7 +1739,7 @@ export class ScreenManagementService { // V2 레이아웃이 있으면 V2 형식으로 반환 if (v2Layout && v2Layout.layout_data) { - console.log(`V2 레이아웃 발견, V2 형식으로 반환`); + const layoutData = v2Layout.layout_data; // URL에서 컴포넌트 타입 추출하는 헬퍼 함수 @@ -1799,7 +1799,7 @@ export class ScreenManagementService { }; } - console.log(`V2 레이아웃 없음, V1 테이블 조회`); + const layouts = await query( `SELECT * FROM screen_layouts @@ -4254,7 +4254,7 @@ export class ScreenManagementService { [newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)], ); - console.log(` ✅ V2 레이아웃 복사 완료: ${components.length}개 컴포넌트`); + } catch (error) { console.error("V2 레이아웃 복사 중 오류:", error); // 레이아웃 복사 실패해도 화면 생성은 유지 @@ -5045,8 +5045,7 @@ export class ScreenManagementService { companyCode: string, userType?: string, ): Promise { - console.log(`=== V2 레이아웃 로드 시작 ===`); - console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`); + // SUPER_ADMIN 여부 확인 const isSuperAdmin = userType === "SUPER_ADMIN"; @@ -5136,13 +5135,11 @@ export class ScreenManagementService { } if (!layout) { - console.log(`V2 레이아웃 없음: screen_id=${screenId}`); + return null; } - console.log( - `V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`, - ); + return layout.layout_data; } @@ -5158,11 +5155,11 @@ export class ScreenManagementService { ): Promise { const layerId = layoutData.layerId || 1; const layerName = layoutData.layerName || (layerId === 1 ? '기본 레이어' : `레이어 ${layerId}`); + // conditionConfig가 명시적으로 전달되었는지 확인 (undefined = 미전달, null/object = 명시적 전달) + const hasConditionConfig = 'conditionConfig' in layoutData; const conditionConfig = layoutData.conditionConfig || null; - console.log(`=== V2 레이아웃 저장 시작 ===`); - console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 레이어: ${layerId} (${layerName})`); - console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`); + // 권한 확인 const screens = await query<{ company_code: string | null }>( @@ -5187,16 +5184,27 @@ export class ScreenManagementService { ...pureLayoutData, }; - // UPSERT (레이어별 저장) - await query( - `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) - ON CONFLICT (screen_id, company_code, layer_id) - DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`, - [screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)], - ); + if (hasConditionConfig) { + // conditionConfig가 명시적으로 전달된 경우: condition_config도 함께 저장 + await query( + `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + ON CONFLICT (screen_id, company_code, layer_id) + DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`, + [screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)], + ); + } else { + // conditionConfig가 전달되지 않은 경우: 기존 condition_config 유지, layout_data만 업데이트 + await query( + `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) + ON CONFLICT (screen_id, company_code, layer_id) + DO UPDATE SET layout_data = $5, layer_name = $4, updated_at = NOW()`, + [screenId, companyCode, layerId, layerName, JSON.stringify(dataToSave)], + ); + } - console.log(`V2 레이아웃 저장 완료 (레이어 ${layerId})`); + } /** @@ -5349,6 +5357,172 @@ export class ScreenManagementService { ); } + // ======================================== + // 조건부 영역(Zone) 관리 + // ======================================== + + /** + * 화면의 조건부 영역(Zone) 목록 조회 + */ + async getScreenZones(screenId: number, companyCode: string): Promise { + let zones; + if (companyCode === "*") { + // 최고 관리자: 모든 회사 Zone 조회 가능 + zones = await query( + `SELECT * FROM screen_conditional_zones WHERE screen_id = $1 ORDER BY zone_id`, + [screenId], + ); + } else { + // 일반 회사: 자사 Zone + 공통(*) Zone 조회 + zones = await query( + `SELECT * FROM screen_conditional_zones + WHERE screen_id = $1 AND (company_code = $2 OR company_code = '*') + ORDER BY zone_id`, + [screenId, companyCode], + ); + } + return zones; + } + + /** + * 조건부 영역(Zone) 생성 + */ + async createZone( + screenId: number, + companyCode: string, + zoneData: { + zone_name?: string; + x: number; + y: number; + width: number; + height: number; + trigger_component_id?: string; + trigger_operator?: string; + }, + ): Promise { + const result = await queryOne( + `INSERT INTO screen_conditional_zones + (screen_id, company_code, zone_name, x, y, width, height, trigger_component_id, trigger_operator) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + [ + screenId, + companyCode, + zoneData.zone_name || '조건부 영역', + zoneData.x, + zoneData.y, + zoneData.width, + zoneData.height, + zoneData.trigger_component_id || null, + zoneData.trigger_operator || 'eq', + ], + ); + return result; + } + + /** + * 조건부 영역(Zone) 업데이트 (위치/크기/트리거) + */ + async updateZone( + zoneId: number, + companyCode: string, + updates: { + zone_name?: string; + x?: number; + y?: number; + width?: number; + height?: number; + trigger_component_id?: string; + trigger_operator?: string; + }, + ): Promise { + const setClauses: string[] = ['updated_at = NOW()']; + const params: any[] = [zoneId, companyCode]; + let paramIdx = 3; + + for (const [key, value] of Object.entries(updates)) { + if (value !== undefined) { + setClauses.push(`${key} = $${paramIdx}`); + params.push(value); + paramIdx++; + } + } + + await query( + `UPDATE screen_conditional_zones SET ${setClauses.join(', ')} + WHERE zone_id = $1 AND company_code = $2`, + params, + ); + } + + /** + * 조건부 영역(Zone) 삭제 + 소속 레이어들의 condition_config 정리 + */ + async deleteZone(zoneId: number, companyCode: string): Promise { + // Zone에 소속된 레이어들의 condition_config에서 zone_id 제거 + await query( + `UPDATE screen_layouts_v2 SET condition_config = NULL, updated_at = NOW() + WHERE company_code = $1 AND condition_config->>'zone_id' = $2::text`, + [companyCode, String(zoneId)], + ); + + await query( + `DELETE FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`, + [zoneId, companyCode], + ); + } + + /** + * Zone에 레이어 추가 (빈 레이아웃으로 새 레이어 생성 + zone_id 할당) + */ + async addLayerToZone( + screenId: number, + companyCode: string, + zoneId: number, + conditionValue: string, + layerName?: string, + ): Promise<{ layerId: number }> { + // 다음 layer_id 계산 + const maxResult = await queryOne<{ max_id: number }>( + `SELECT COALESCE(MAX(layer_id), 1) as max_id FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2`, + [screenId, companyCode], + ); + const newLayerId = (maxResult?.max_id || 1) + 1; + + // Zone 정보로 캔버스 크기 결정 (company_code 필터링 필수) + const zone = await queryOne( + `SELECT * FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`, + [zoneId, companyCode], + ); + + const layoutData = { + version: "2.1", + components: [], + screenResolution: zone + ? { width: zone.width, height: zone.height } + : { width: 800, height: 200 }, + }; + + const conditionConfig = { + zone_id: zoneId, + condition_value: conditionValue, + }; + + await query( + `INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, layer_id, layer_name, condition_config) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (screen_id, company_code, layer_id) DO UPDATE + SET layout_data = EXCLUDED.layout_data, + layer_name = EXCLUDED.layer_name, + condition_config = EXCLUDED.condition_config, + updated_at = NOW()`, + [screenId, companyCode, JSON.stringify(layoutData), newLayerId, layerName || `레이어 ${newLayerId}`, JSON.stringify(conditionConfig)], + ); + + return { layerId: newLayerId }; + } + // ======================================== // POP 레이아웃 관리 (모바일/태블릿) // v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로) diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 2eb35f64..dd2f73a9 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -1371,39 +1371,66 @@ class TableCategoryValueService { const pool = getPool(); - // 동적으로 파라미터 플레이스홀더 생성 - const placeholders = valueCodes.map((_, i) => `$${i + 1}`).join(", "); + const n = valueCodes.length; + + // 첫 번째 쿼리용 플레이스홀더: $1 ~ $n + const placeholders1 = valueCodes.map((_, i) => `$${i + 1}`).join(", "); let query: string; let params: any[]; if (companyCode === "*") { - // 최고 관리자: 모든 카테고리 값 조회 + // 최고 관리자: 두 테이블 모두에서 조회 (UNION으로 병합) + // 두 번째 쿼리용 플레이스홀더: $n+1 ~ $2n + const placeholders2 = valueCodes.map((_, i) => `$${n + i + 1}`).join(", "); query = ` - SELECT value_code, value_label - FROM table_column_category_values - WHERE value_code IN (${placeholders}) - AND is_active = true + SELECT value_code, value_label FROM ( + SELECT value_code, value_label + FROM table_column_category_values + WHERE value_code IN (${placeholders1}) + AND is_active = true + UNION ALL + SELECT value_code, value_label + FROM category_values + WHERE value_code IN (${placeholders2}) + AND is_active = true + ) combined `; - params = valueCodes; + params = [...valueCodes, ...valueCodes]; } else { - // 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회 + // 일반 회사: 두 테이블에서 자신의 카테고리 값 + 공통 카테고리 값 조회 + // 첫 번째: $1~$n (valueCodes), $n+1 (companyCode) + // 두 번째: $n+2~$2n+1 (valueCodes), $2n+2 (companyCode) + const companyIdx1 = n + 1; + const placeholders2 = valueCodes.map((_, i) => `$${n + 1 + i + 1}`).join(", "); + const companyIdx2 = 2 * n + 2; + query = ` - SELECT value_code, value_label - FROM table_column_category_values - WHERE value_code IN (${placeholders}) - AND is_active = true - AND (company_code = $${valueCodes.length + 1} OR company_code = '*') + SELECT value_code, value_label FROM ( + SELECT value_code, value_label + FROM table_column_category_values + WHERE value_code IN (${placeholders1}) + AND is_active = true + AND (company_code = $${companyIdx1} OR company_code = '*') + UNION ALL + SELECT value_code, value_label + FROM category_values + WHERE value_code IN (${placeholders2}) + AND is_active = true + AND (company_code = $${companyIdx2} OR company_code = '*') + ) combined `; - params = [...valueCodes, companyCode]; + params = [...valueCodes, companyCode, ...valueCodes, companyCode]; } const result = await pool.query(query, params); - // { [code]: label } 형태로 변환 + // { [code]: label } 형태로 변환 (중복 시 첫 번째 결과 우선) const labels: Record = {}; for (const row of result.rows) { - labels[row.value_code] = row.value_label; + if (!labels[row.value_code]) { + labels[row.value_code] = row.value_label; + } } logger.info(`카테고리 라벨 ${Object.keys(labels).length}개 조회 완료`, { companyCode }); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index db5f32ed..6e0f3944 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2875,10 +2875,11 @@ export class TableManagementService { }; } - // Entity 조인 설정 감지 (화면별 엔티티 설정 전달) + // Entity 조인 설정 감지 (화면별 엔티티 설정 + 회사코드 전달) let joinConfigs = await entityJoinService.detectEntityJoins( tableName, - options.screenEntityConfigs + options.screenEntityConfigs, + options.companyCode ); logger.info( @@ -2978,31 +2979,49 @@ export class TableManagementService { continue; // 기본 Entity 조인과 중복되면 추가하지 않음 } - // 추가 조인 컬럼 설정 생성 - const additionalJoinConfig: EntityJoinConfig = { - sourceTable: tableName, - sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id) - referenceTable: - (additionalColumn as any).referenceTable || - baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng) - referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code) - displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name) - displayColumn: actualColumnName, // 하위 호환성 - aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name) - separator: " - ", // 기본 구분자 - }; - - joinConfigs.push(additionalJoinConfig); - logger.info( - `✅ 추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn} -> ${actualColumnName}` + // 🆕 같은 sourceColumn + referenceTable 조합의 기존 config가 있으면 displayColumns에 병합 + const existingConfig = joinConfigs.find( + (config) => + config.sourceColumn === sourceColumn && + config.referenceTable === ((additionalColumn as any).referenceTable || baseJoinConfig.referenceTable) ); - logger.info(`🔍 추가된 조인 설정 상세:`, { - sourceTable: additionalJoinConfig.sourceTable, - sourceColumn: additionalJoinConfig.sourceColumn, - referenceTable: additionalJoinConfig.referenceTable, - displayColumns: additionalJoinConfig.displayColumns, - aliasColumn: additionalJoinConfig.aliasColumn, - }); + + if (existingConfig) { + // 기존 config에 display column 추가 (중복 방지) + if (!existingConfig.displayColumns?.includes(actualColumnName)) { + existingConfig.displayColumns = existingConfig.displayColumns || []; + existingConfig.displayColumns.push(actualColumnName); + logger.info( + `🔄 기존 조인 설정에 컬럼 병합: ${existingConfig.aliasColumn} ← ${actualColumnName} (총 ${existingConfig.displayColumns.length}개)` + ); + } + } else { + // 새 조인 설정 생성 + const additionalJoinConfig: EntityJoinConfig = { + sourceTable: tableName, + sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id) + referenceTable: + (additionalColumn as any).referenceTable || + baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng) + referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code) + displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name) + displayColumn: actualColumnName, // 하위 호환성 + aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name) + separator: " - ", // 기본 구분자 + }; + + joinConfigs.push(additionalJoinConfig); + logger.info( + `✅ 추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn} -> ${actualColumnName}` + ); + logger.info(`🔍 추가된 조인 설정 상세:`, { + sourceTable: additionalJoinConfig.sourceTable, + sourceColumn: additionalJoinConfig.sourceColumn, + referenceTable: additionalJoinConfig.referenceTable, + displayColumns: additionalJoinConfig.displayColumns, + aliasColumn: additionalJoinConfig.aliasColumn, + }); + } } } } @@ -3258,6 +3277,28 @@ export class TableManagementService { startTime: number ): Promise { try { + // 🆕 참조 테이블별 전체 컬럼 목록 미리 조회 + const referenceTableColumns = new Map(); + const uniqueRefTables = new Set( + joinConfigs + .filter((c) => c.referenceTable !== "table_column_category_values") // 카테고리는 제외 + .map((c) => `${c.referenceTable}:${c.sourceColumn}`) + ); + + for (const key of uniqueRefTables) { + const refTable = key.split(":")[0]; + if (!referenceTableColumns.has(key)) { + const cols = await query<{ column_name: string }>( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND table_schema = 'public' + ORDER BY ordinal_position`, + [refTable] + ); + referenceTableColumns.set(key, cols.map((c) => c.column_name)); + logger.info(`🔍 참조 테이블 컬럼 조회: ${refTable} → ${cols.length}개`); + } + } + // 데이터 조회 쿼리 const dataQuery = entityJoinService.buildJoinQuery( tableName, @@ -3266,7 +3307,9 @@ export class TableManagementService { whereClause, orderBy, limit, - offset + offset, + undefined, + referenceTableColumns // 🆕 참조 테이블 전체 컬럼 전달 ).query; // 카운트 쿼리 @@ -3767,12 +3810,12 @@ export class TableManagementService { reference_table: string; reference_column: string; }>( - `SELECT column_name, reference_table, reference_column + `SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column FROM table_type_columns WHERE table_name = $1 AND input_type = 'entity' AND reference_table = $2 - AND company_code = '*' + ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END LIMIT 1`, [tableName, refTable] ); @@ -3883,7 +3926,7 @@ export class TableManagementService { /** * 참조 테이블의 표시 컬럼 목록 조회 */ - async getReferenceTableColumns(tableName: string): Promise< + async getReferenceTableColumns(tableName: string, companyCode?: string): Promise< Array<{ columnName: string; displayName: string; @@ -3891,7 +3934,7 @@ export class TableManagementService { inputType?: string; }> > { - return await entityJoinService.getReferenceTableColumns(tableName); + return await entityJoinService.getReferenceTableColumns(tableName, companyCode); } /** @@ -5005,14 +5048,14 @@ export class TableManagementService { input_type: string; display_column: string | null; }>( - `SELECT column_name, reference_column, input_type, display_column + `SELECT DISTINCT ON (column_name) column_name, reference_column, input_type, display_column FROM table_type_columns WHERE table_name = $1 AND input_type IN ('entity', 'category') AND reference_table = $2 AND reference_column IS NOT NULL AND reference_column != '' - AND company_code = '*'`, + ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, [rightTable, leftTable] ); @@ -5034,14 +5077,14 @@ export class TableManagementService { input_type: string; display_column: string | null; }>( - `SELECT column_name, reference_column, input_type, display_column + `SELECT DISTINCT ON (column_name) column_name, reference_column, input_type, display_column FROM table_type_columns WHERE table_name = $1 AND input_type IN ('entity', 'category') AND reference_table = $2 AND reference_column IS NOT NULL AND reference_column != '' - AND company_code = '*'`, + ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, [leftTable, rightTable] ); diff --git a/docs/DB_ARCHITECTURE_ANALYSIS.md b/docs/DB_ARCHITECTURE_ANALYSIS.md new file mode 100644 index 00000000..084c6940 --- /dev/null +++ b/docs/DB_ARCHITECTURE_ANALYSIS.md @@ -0,0 +1,2188 @@ +# WACE ERP 데이터베이스 아키텍처 분석 보고서 + +> 📅 작성일: 2026-01-20 +> 🎯 목적: WACE ERP 시스템 전체 워크플로우 문서화를 위한 DB 구조 분석 +> 📊 DB 엔진: PostgreSQL 16.8 + +--- + +## 📋 목차 + +1. [개요](#1-개요) +2. [전체 테이블 목록](#2-전체-테이블-목록) +3. [멀티테넌시 아키텍처](#3-멀티테넌시-아키텍처) +4. [핵심 시스템 테이블](#4-핵심-시스템-테이블) +5. [메타데이터 관리 시스템](#5-메타데이터-관리-시스템) +6. [화면 관리 시스템](#6-화면-관리-시스템) +7. [비즈니스 도메인별 테이블](#7-비즈니스-도메인별-테이블) +8. [플로우 및 데이터 통합](#8-플로우-및-데이터-통합) +9. [인덱스 전략](#9-인덱스-전략) +10. [동적 테이블 생성 패턴](#10-동적-테이블-생성-패턴) +11. [마이그레이션 히스토리](#11-마이그레이션-히스토리) + +--- + +## 1. 개요 + +### 1.1 데이터베이스 통계 + +``` +- 총 테이블 수: 약 280개 +- 총 함수 수: 약 50개 +- 총 트리거 수: 약 30개 +- 총 시퀀스 수: 약 100개 +- 뷰 수: 약 20개 +``` + +### 1.2 아키텍처 특징 + +- **멀티테넌시**: 모든 테이블에 `company_code` 컬럼으로 회사별 데이터 격리 +- **동적 스키마**: 런타임에 테이블 생성/수정 가능 +- **메타데이터 드리븐**: UI 컴포넌트가 메타데이터 테이블을 기반으로 동적 렌더링 +- **이력 관리**: 주요 테이블에 `_log` 테이블로 변경 이력 추적 +- **외부 연동**: 외부 DB 및 REST API 연결 지원 +- **플로우 기반**: 화면 간 데이터 흐름을 정의하고 실행 + +--- + +## 2. 전체 테이블 목록 + +### 2.1 테이블 분류 체계 + +``` +시스템 관리 (약 30개) +├── 사용자/권한 (10개) +├── 메뉴 관리 (5개) +├── 회사 관리 (3개) +└── 공통 코드 (5개) + +메타데이터 시스템 (약 20개) +├── 테이블/컬럼 정의 (8개) +├── 화면 정의 (10개) +└── 레이아웃/컴포넌트 (5개) + +비즈니스 도메인 (약 200개) +├── 영업/수주 (30개) +├── 구매/발주 (25개) +├── 재고/창고 (20개) +├── 생산/작업 (25개) +├── 품질/검사 (15개) +├── 물류/운송 (20개) +├── PLM/설계 (30개) +├── 회계/원가 (20개) +└── 기타 (15개) + +통합/플로우 (약 30개) +├── 데이터플로우 (10개) +├── 배치 작업 (8개) +└── 외부 연동 (12개) +``` + +### 2.2 주요 테이블 목록 (알파벳순) + +
+전체 테이블 목록 보기 (280개) + +``` +approval +attach_file_info +auth_tokens +authority_master +authority_master_history +authority_sub_user +batch_configs +batch_execution_logs +batch_job_executions +batch_job_parameters +batch_jobs +batch_mappings +batch_schedules +button_action_standards +carrier_contract_mng +carrier_contract_mng_log +carrier_mng +carrier_mng_log +carrier_vehicle_mng +carrier_vehicle_mng_log +cascading_auto_fill_group +cascading_auto_fill_mapping +cascading_condition +cascading_hierarchy_group +cascading_hierarchy_level +cascading_multi_parent +cascading_multi_parent_source +cascading_mutual_exclusion +cascading_relation +cascading_reverse_lookup +category_column_mapping +category_value_cascading_group +category_value_cascading_mapping +chartmgmt +check_report_mng +code_category +code_info +collection_batch_executions +collection_batch_management +column_labels +comm_code +comm_code_history +comm_exchange_rate +comments +company_code_sequence +company_mng +component_standards +contract_mgmt +contract_mgmt_option +counselingmgmt +customer_item +customer_item_alias +customer_item_mapping +customer_item_price +customer_mng +customer_service_mgmt +customer_service_part +customer_service_workingtime +dashboard_elements +dashboard_shares +dashboard_slider_items +dashboard_sliders +dashboards +data_collection_configs +data_collection_history +data_collection_jobs +data_relationship_bridge +dataflow_diagrams +dataflow_external_calls +ddl_execution_log +defect_standard_mng +defect_standard_mng_log +delivery_destination +delivery_history +delivery_history_defect +delivery_part_price +delivery_route_mng +delivery_route_mng_log +delivery_status +dept_info +dept_info_history +digital_twin_layout +digital_twin_layout_template +digital_twin_location_layout +digital_twin_objects +digital_twin_zone_layout +drivers +dtg_contracts +dtg_maintenance_history +dtg_management +dtg_management_log +dtg_monthly_settlements +dynamic_form_data +equipment_consumable +equipment_consumable_log +equipment_inspection_item +equipment_inspection_item_log +equipment_mng +equipment_mng_log +estimate_mgmt +excel_mapping_template +expense_detail +expense_master +external_call_configs +external_call_logs +external_connection_permission +external_db_connection +external_db_connections +external_rest_api_connections +external_work_review_info +facility_assembly_plan +file_down_log +flow_audit_log +flow_data_mapping +flow_data_status +flow_definition +flow_external_connection_permission +flow_external_db_connection +flow_integration_log +flow_step +flow_step_connection +fund_mgmt +grid_standards +inbound_mng +inboxtask +injection_cost +input_cost_goal +input_resource +inspection_equipment_mng +inspection_equipment_mng_log +inspection_standard +inventory_history +inventory_stock +item_info +item_inspection_info +item_routing_detail +item_routing_version +klbom_tbl +language_master +layout_instances +layout_standards +login_access_log +logistics_cost_mng +logistics_cost_mng_log +mail_log +maintenance_schedules +material_cost +material_detail_mgmt +material_master_mgmt +material_mng +material_release +menu_info +menu_screen_group_items +menu_screen_groups +mold_dev_request_info +multi_lang_category +multi_lang_key_master +multi_lang_text +node_flows +numbering_rule_parts +numbering_rules +oem_factory_mng +oem_milestone_mng +oem_mng +option_mng +option_price_history +order_mgmt +order_mng_master +order_mng_sub +order_plan_mgmt +order_plan_result_error +order_spec_mng +order_spec_mng_history +outbound_mng +part_bom_qty +part_bom_report +part_distribution_list +part_mgmt +part_mng +part_mng_history +planning_issue +pms_invest_cost_mng +pms_pjt_concept_info +pms_pjt_info +pms_pjt_year_goal +pms_rel_pjt_concept_milestone +pms_rel_pjt_concept_prod +pms_rel_pjt_prod +pms_rel_prod_ref_dept +pms_wbs_task +pms_wbs_task_confirm +pms_wbs_task_info +pms_wbs_task_standard +pms_wbs_template +problem_mng +process_equipment +process_mng +procurement_standard +product_group_mng +product_kind_spec +product_kind_spec_main +product_mgmt +product_mgmt_model +product_mgmt_price_history +product_mgmt_upg_detail +product_mgmt_upg_master +product_mng +product_spec +production_issue +production_record +production_task +profit_loss +profit_loss_coefficient +profit_loss_coolingtime +profit_loss_depth +profit_loss_lossrate +profit_loss_machine +profit_loss_pretime +profit_loss_srrate +profit_loss_total +profit_loss_total_addlist +profit_loss_weight +project +project_mgmt +purchase_detail +purchase_order +purchase_order_master +purchase_order_mng +purchase_order_multi +purchase_order_part +ratecal_mgmt +receive_history +receiving +rel_menu_auth +report_layout +report_master +report_menu_mapping +report_query +report_template +safety_budget_execution +safety_incidents +safety_inspections +safety_inspections_log +sales_bom_part_qty +sales_bom_report +sales_bom_report_part +sales_long_delivery +sales_long_delivery_input +sales_long_delivery_predict +sales_order_detail +sales_order_detail_log +sales_order_mng +sales_part_chg +sales_request_master +sales_request_part +sample_supply +screen_data_flows +screen_data_transfer +screen_definitions +screen_embedding +screen_field_joins +screen_group_members +screen_group_screens +screen_groups +screen_layouts +screen_menu_assignments +screen_split_panel +screen_table_relations +screen_templates +screen_widgets +shipment_detail +shipment_header +shipment_instruction +shipment_instruction_item +shipment_pallet +shipment_plan +standard_doc_info +structural_review_proposal +style_templates +supplier_item +supplier_item_alias +supplier_item_mapping +supplier_item_price +supplier_mng +supplier_mng_log +supply_charger_mng +supply_mng +supply_mng_history +table_column_category_values +table_labels +table_log_config +table_relationships +table_type_columns +tax_invoice +tax_invoice_item +time_sheet +transport_logs +transport_statistics +transport_vehicle_locations +used_mng +user_dept +user_dept_sub +user_info +user_info_history +vehicle_location_history +vehicle_locations +vehicle_trip_summary +vehicles +warehouse_info +warehouse_location +web_type_standards +work_instruction +work_instruction_detail +work_instruction_detail_log +work_instruction_log +work_order +work_orders +work_orders_detail +work_request +yard_layout +yard_material_placement +``` + +
+ +--- + +## 3. 멀티테넌시 아키텍처 + +### 3.1 company_code 패턴 + +**모든 테이블에 필수적으로 포함되는 컬럼:** + +```sql +company_code VARCHAR(20) NOT NULL +``` + +**의미:** +- 하나의 데이터베이스에서 여러 회사의 데이터를 격리 +- 모든 쿼리는 반드시 `company_code` 필터 포함 필요 + +### 3.2 특별한 company_code 값 + +#### `company_code = "*"` 의미 + +```sql +-- ❌ 잘못된 이해: 모든 회사가 공유하는 공통 데이터 +-- ✅ 올바른 이해: 슈퍼 관리자 전용 데이터 + +-- 일반 회사는 "*" 데이터를 볼 수 없음 +SELECT * FROM table_name +WHERE company_code = 'COMPANY_A' + AND company_code != '*'; -- 필수! +``` + +**용도:** +- 시스템 관리자용 메타데이터 +- 전역 설정 값 +- 기본 템플릿 + +### 3.3 멀티테넌시 쿼리 패턴 + +```sql +-- ✅ 표준 SELECT 패턴 +SELECT * FROM table_name +WHERE company_code = $1 + AND company_code != '*' +ORDER BY created_date DESC; + +-- ✅ JOIN 패턴 (company_code 매칭 필수!) +SELECT a.*, b.name +FROM table_a a +LEFT JOIN table_b b + ON a.ref_id = b.id + AND a.company_code = b.company_code -- 필수! +WHERE a.company_code = $1; + +-- ✅ 서브쿼리 패턴 +SELECT * +FROM orders o +WHERE company_code = $1 + AND product_id IN ( + SELECT id FROM products + WHERE company_code = $1 -- 서브쿼리에도 필수! + ); + +-- ✅ 집계 패턴 +SELECT + product_type, + COUNT(*) as total, + SUM(amount) as total_amount +FROM sales +WHERE company_code = $1 +GROUP BY product_type; +``` + +### 3.4 company_code 인덱스 전략 + +**모든 테이블에 필수 인덱스:** + +```sql +CREATE INDEX idx_{table_name}_company_code +ON {table_name}(company_code); + +-- 복합 인덱스 예시 +CREATE INDEX idx_sales_company_date +ON sales(company_code, sale_date DESC); +``` + +--- + +## 4. 핵심 시스템 테이블 + +### 4.1 사용자 관리 + +#### user_info (사용자 정보) + +```sql +CREATE TABLE user_info ( + sabun VARCHAR(1024), -- 사번 + user_id VARCHAR(1024) PRIMARY KEY,-- 사용자 ID + user_password VARCHAR(1024), -- 암호화된 비밀번호 + user_name VARCHAR(1024), -- 한글명 + user_name_eng VARCHAR(1024), -- 영문명 + user_name_cn VARCHAR(1024), -- 중문명 + dept_code VARCHAR(1024), -- 부서 코드 + dept_name VARCHAR(1024), -- 부서명 + position_code VARCHAR(1024), -- 직위 코드 + position_name VARCHAR(1024), -- 직위명 + email VARCHAR(1024), -- 이메일 + tel VARCHAR(1024), -- 전화번호 + cell_phone VARCHAR(1024), -- 휴대폰 + user_type VARCHAR(1024), -- 사용자 유형 코드 + user_type_name VARCHAR(1024), -- 사용자 유형명 + company_code VARCHAR(50), -- 회사 코드 (멀티테넌시) + status VARCHAR(32), -- active/inactive + license_number VARCHAR(50), -- 면허번호 + vehicle_number VARCHAR(50), -- 차량번호 + signup_type VARCHAR(20), -- 가입 유형 + branch_name VARCHAR(100), -- 지점명 + regdate TIMESTAMP, -- 등록일 + end_date TIMESTAMP -- 종료일 +); +``` + +**관련 테이블:** +- `user_info_history`: 사용자 정보 변경 이력 +- `user_dept`: 사용자-부서 관계 +- `user_dept_sub`: 사용자 하위 부서 + +#### auth_tokens (인증 토큰) + +```sql +CREATE TABLE auth_tokens ( + id SERIAL PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + token VARCHAR(500) NOT NULL, + refresh_token VARCHAR(500), + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + ip_address VARCHAR(50), + user_agent TEXT +); +``` + +### 4.2 권한 관리 + +#### authority_master (권한 그룹) + +```sql +CREATE TABLE authority_master ( + objid NUMERIC PRIMARY KEY, + auth_code VARCHAR(64), -- 권한 코드 + auth_name VARCHAR(64), -- 권한명 + company_code VARCHAR(50), -- 회사 코드 + status VARCHAR(32), -- 상태 + writer VARCHAR(32), -- 작성자 + regdate TIMESTAMP -- 등록일 +); +``` + +**관련 테이블:** +- `authority_master_history`: 권한 변경 이력 +- `authority_sub_user`: 권한-사용자 매핑 +- `rel_menu_auth`: 권한-메뉴 매핑 + +### 4.3 메뉴 관리 + +#### menu_info (메뉴 정보) + +```sql +CREATE TABLE menu_info ( + objid NUMERIC PRIMARY KEY, + menu_type NUMERIC, -- 0=일반, 1=시스템관리, 2=동적생성 + parent_obj_id NUMERIC, -- 부모 메뉴 ID + menu_name_kor VARCHAR(64), -- 한글 메뉴명 + menu_name_eng VARCHAR(64), -- 영문 메뉴명 + menu_code VARCHAR(50), -- 메뉴 코드 + menu_url VARCHAR(256), -- 메뉴 URL + seq NUMERIC, -- 순서 + screen_code VARCHAR(50), -- 화면 코드 (동적 생성 시) + screen_group_id INTEGER, -- 화면 그룹 ID + company_code VARCHAR(50), -- 회사 코드 + status VARCHAR(32), -- active/inactive + lang_key VARCHAR(100), -- 다국어 키 + source_menu_objid BIGINT, -- 원본 메뉴 ID (복사 시) + writer VARCHAR(32), + regdate TIMESTAMP +); +``` + +**특징:** +- `menu_type = 2`: 화면 생성 시 자동으로 생성되는 메뉴 +- 트리거: `auto_create_menu_for_screen()` - 화면 생성 시 자동 메뉴 추가 + +**관련 테이블:** +- `menu_screen_groups`: 메뉴 화면 그룹 +- `menu_screen_group_items`: 그룹-화면 연결 + +### 4.4 회사 관리 + +#### company_mng (회사 정보) + +```sql +CREATE TABLE company_mng ( + company_code VARCHAR(32) PRIMARY KEY, + company_name VARCHAR(64), + business_registration_number VARCHAR(20), -- 사업자등록번호 + representative_name VARCHAR(100), -- 대표자명 + representative_phone VARCHAR(20), -- 대표 연락처 + email VARCHAR(255), -- 회사 이메일 + website VARCHAR(500), -- 웹사이트 + address VARCHAR(500), -- 주소 + status VARCHAR(32), + writer VARCHAR(32), + regdate TIMESTAMP +); +``` + +**관련 테이블:** +- `company_code_sequence`: 회사별 시퀀스 관리 + +### 4.5 부서 관리 + +#### dept_info (부서 정보) + +```sql +CREATE TABLE dept_info ( + dept_code VARCHAR(1024) PRIMARY KEY, + dept_name VARCHAR(1024), + parent_dept_code VARCHAR(1024), -- 상위 부서 + company_code VARCHAR(50), + status VARCHAR(32), + writer VARCHAR(32), + regdate TIMESTAMP +); +``` + +**관련 테이블:** +- `dept_info_history`: 부서 정보 변경 이력 + +--- + +## 5. 메타데이터 관리 시스템 + +WACE ERP의 핵심 특징은 **메타데이터 드리븐 아키텍처**입니다. 화면, 테이블, 컬럼 정보를 메타데이터 테이블에서 관리하고, 프론트엔드가 이를 기반으로 동적 렌더링합니다. + +### 5.1 테이블 메타데이터 + +#### table_labels (테이블 정의) + +```sql +CREATE TABLE table_labels ( + table_name VARCHAR(100) PRIMARY KEY, -- 테이블명 (물리명) + table_label VARCHAR(200), -- 테이블 한글명 + description TEXT, -- 설명 + use_log_table VARCHAR(1) DEFAULT 'N', -- 이력 테이블 사용 여부 + created_date TIMESTAMP, + updated_date TIMESTAMP +); +``` + +**역할:** +- 동적으로 생성된 모든 테이블의 메타정보 저장 +- 화면 생성 시 테이블 선택 목록 제공 +- 데이터 딕셔너리로 활용 + +#### table_type_columns (컬럼 타입 정의) + +```sql +CREATE TABLE table_type_columns ( + id SERIAL PRIMARY KEY, + table_name VARCHAR(255) NOT NULL, + column_name VARCHAR(255) NOT NULL, + company_code VARCHAR(20) NOT NULL, -- 회사별 컬럼 설정 + input_type VARCHAR(50) DEFAULT 'text',-- 입력 타입 + detail_settings TEXT DEFAULT '{}', -- JSON 상세 설정 + is_nullable VARCHAR(10) DEFAULT 'Y', + display_order INTEGER DEFAULT 0, -- 표시 순서 + created_date TIMESTAMP, + updated_date TIMESTAMP, + + UNIQUE(table_name, column_name, company_code) +); +``` + +**input_type 종류:** +- `text`: 일반 텍스트 +- `number`: 숫자 +- `date`: 날짜 +- `select`: 드롭다운 (options 필요) +- `textarea`: 여러 줄 텍스트 +- `entity`: 참조 테이블 (referenceTable, referenceColumn 필요) +- `checkbox`: 체크박스 +- `radio`: 라디오 버튼 + +**detail_settings 예시:** + +```json +// select 타입 +{ + "options": [ + {"label": "일반", "value": "normal"}, + {"label": "긴급", "value": "urgent"} + ] +} + +// entity 타입 +{ + "referenceTable": "customer_mng", + "referenceColumn": "customer_code", + "displayColumn": "customer_name" +} +``` + +#### column_labels (컬럼 라벨 - 레거시) + +```sql +CREATE TABLE column_labels ( + table_name VARCHAR(100) NOT NULL, + column_name VARCHAR(100) NOT NULL, + column_label VARCHAR(200), -- 한글 라벨 + input_type VARCHAR(50), + detail_settings TEXT, + description TEXT, + display_order INTEGER, + is_visible BOOLEAN DEFAULT true, + created_date TIMESTAMP, + updated_date TIMESTAMP, + + PRIMARY KEY (table_name, column_name) +); +``` + +**참고:** +- 레거시 호환을 위해 유지 +- 새로운 컬럼은 `table_type_columns` 사용 권장 +- `table_type_columns`는 회사별 설정, `column_labels`는 전역 설정 + +### 5.2 카테고리 값 관리 + +#### table_column_category_values (컬럼 카테고리 값) + +```sql +CREATE TABLE table_column_category_values ( + id SERIAL PRIMARY KEY, + table_name VARCHAR(255) NOT NULL, + column_name VARCHAR(255) NOT NULL, + company_code VARCHAR(20) NOT NULL, + category_value VARCHAR(500) NOT NULL, -- 카테고리 값 + display_label VARCHAR(500), -- 표시 라벨 + display_order INTEGER DEFAULT 0, + is_active VARCHAR(1) DEFAULT 'Y', + parent_value VARCHAR(500), -- 부모 카테고리 (계층 구조) + created_date TIMESTAMP, + updated_date TIMESTAMP, + + UNIQUE(table_name, column_name, company_code, category_value) +); +``` + +**용도:** +- 동적 드롭다운 값 관리 +- 계층형 카테고리 지원 (parent_value) +- 회사별 카테고리 값 커스터마이징 + +**관련 테이블:** +- `category_column_mapping`: 카테고리-컬럼 매핑 +- `category_value_cascading_group`: 카테고리 캐스케이딩 그룹 +- `category_value_cascading_mapping`: 캐스케이딩 매핑 + +### 5.3 테이블 관계 관리 + +#### table_relationships (테이블 관계) + +```sql +CREATE TABLE table_relationships ( + id SERIAL PRIMARY KEY, + parent_table VARCHAR(100), -- 부모 테이블 + parent_column VARCHAR(100), -- 부모 컬럼 + child_table VARCHAR(100), -- 자식 테이블 + child_column VARCHAR(100), -- 자식 컬럼 + relationship_type VARCHAR(20), -- one-to-many, many-to-one 등 + created_date TIMESTAMP +); +``` + +--- + +## 6. 화면 관리 시스템 + +WACE ERP는 코드 작성 없이 화면을 동적으로 생성/수정할 수 있는 **Low-Code 플랫폼** 기능을 제공합니다. + +### 6.1 화면 정의 + +#### screen_definitions (화면 정의) + +```sql +CREATE TABLE screen_definitions ( + screen_id SERIAL PRIMARY KEY, + screen_name VARCHAR(100) NOT NULL, -- 화면명 + screen_code VARCHAR(50) NOT NULL, -- 화면 코드 (URL용) + table_name VARCHAR(100) NOT NULL, -- 메인 테이블 + company_code VARCHAR(50) NOT NULL, + description TEXT, + is_active CHAR(1) DEFAULT 'Y', -- Y=활성, N=비활성, D=삭제 + layout_metadata JSONB, -- 레이아웃 JSON + + -- 외부 데이터 소스 지원 + db_source_type VARCHAR(10) DEFAULT 'internal', -- internal/external + db_connection_id INTEGER, -- 외부 DB 연결 ID + data_source_type VARCHAR(20) DEFAULT 'database', -- database/rest_api + rest_api_connection_id INTEGER, -- REST API 연결 ID + rest_api_endpoint VARCHAR(500), -- API 엔드포인트 + rest_api_json_path VARCHAR(200) DEFAULT 'data', -- JSON 응답 경로 + + source_screen_id INTEGER, -- 원본 화면 ID (복사 시) + + created_date TIMESTAMP NOT NULL DEFAULT NOW(), + created_by VARCHAR(50), + updated_date TIMESTAMP NOT NULL DEFAULT NOW(), + updated_by VARCHAR(50), + deleted_date TIMESTAMP, -- 휴지통 이동 시점 + deleted_by VARCHAR(50), + delete_reason TEXT, + + UNIQUE(screen_code, company_code) +); +``` + +**화면 생성 플로우:** +1. 관리자가 화면 설정 페이지에서 테이블 선택 +2. `screen_definitions` 레코드 생성 +3. 트리거 `auto_create_menu_for_screen()` 실행 → `menu_info` 자동 생성 +4. 프론트엔드가 `/screen/{screen_code}` 경로로 접근 시 동적 렌더링 + +#### screen_layouts (화면 레이아웃 - 레거시) + +```sql +CREATE TABLE screen_layouts ( + layout_id SERIAL PRIMARY KEY, + screen_id INTEGER REFERENCES screen_definitions(screen_id), + layout_name VARCHAR(100), + layout_type VARCHAR(50), -- grid, form, split, tab 등 + layout_config JSONB, -- 레이아웃 설정 + display_order INTEGER, + is_active CHAR(1) DEFAULT 'Y', + company_code VARCHAR(50), + created_date TIMESTAMP, + updated_date TIMESTAMP +); +``` + +### 6.2 화면 그룹 관리 + +#### screen_groups (화면 그룹) + +```sql +CREATE TABLE screen_groups ( + id SERIAL PRIMARY KEY, + group_name VARCHAR(100) NOT NULL, -- 그룹명 + group_code VARCHAR(50) NOT NULL, -- 그룹 코드 + main_table_name VARCHAR(100), -- 메인 테이블 + description TEXT, + icon VARCHAR(100), -- 아이콘 + display_order INT DEFAULT 0, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20) NOT NULL, + + -- 계층 구조 지원 (037 마이그레이션에서 추가) + parent_group_id INTEGER REFERENCES screen_groups(id) ON DELETE CASCADE, + group_level INTEGER DEFAULT 0, -- 0=대, 1=중, 2=소 + hierarchy_path VARCHAR(500), -- 예: /1/3/5/ + + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + writer VARCHAR(50), + + UNIQUE(company_code, group_code) +); + +CREATE INDEX idx_screen_groups_company_code ON screen_groups(company_code); +CREATE INDEX idx_screen_groups_parent_id ON screen_groups(parent_group_id); +CREATE INDEX idx_screen_groups_hierarchy_path ON screen_groups(hierarchy_path); +``` + +#### screen_group_screens (화면-그룹 연결) + +```sql +CREATE TABLE screen_group_screens ( + id SERIAL PRIMARY KEY, + group_id INT NOT NULL REFERENCES screen_groups(id) ON DELETE CASCADE, + screen_id INT NOT NULL REFERENCES screen_definitions(screen_id) ON DELETE CASCADE, + screen_role VARCHAR(50) DEFAULT 'main', -- main, register, list, detail 등 + display_order INT DEFAULT 0, + is_default VARCHAR(1) DEFAULT 'N', -- 기본 화면 여부 + company_code VARCHAR(20) NOT NULL, + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + writer VARCHAR(50), + + UNIQUE(group_id, screen_id) +); +``` + +**용도:** +- 관련 화면들을 그룹으로 묶어 관리 +- 예: "영업 관리" 그룹 → 견적 화면, 수주 화면, 출하 화면 + +### 6.3 화면 필드 조인 + +#### screen_field_joins (화면 필드 조인 설정) + +```sql +CREATE TABLE screen_field_joins ( + id SERIAL PRIMARY KEY, + screen_id INT NOT NULL REFERENCES screen_definitions(screen_id) ON DELETE CASCADE, + layout_id INT, + component_id VARCHAR(500), + field_name VARCHAR(100), + + -- 저장 테이블 설정 + save_table VARCHAR(100) NOT NULL, + save_column VARCHAR(100) NOT NULL, + + -- 조인 테이블 설정 + join_table VARCHAR(100) NOT NULL, + join_column VARCHAR(100) NOT NULL, + display_column VARCHAR(100) NOT NULL, + + -- 조인 옵션 + join_type VARCHAR(20) DEFAULT 'LEFT', + filter_condition TEXT, + sort_column VARCHAR(100), + sort_direction VARCHAR(10) DEFAULT 'ASC', + + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20) NOT NULL, + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + writer VARCHAR(50) +); +``` + +**예시:** +```json +{ + "save_table": "sales_order", + "save_column": "customer_code", + "join_table": "customer_mng", + "join_column": "customer_code", + "display_column": "customer_name" +} +``` + +### 6.4 화면 간 데이터 흐름 + +#### screen_data_flows (화면 간 데이터 흐름) + +```sql +CREATE TABLE screen_data_flows ( + id SERIAL PRIMARY KEY, + group_id INT REFERENCES screen_groups(id) ON DELETE SET NULL, + + -- 소스 화면 + source_screen_id INT NOT NULL REFERENCES screen_definitions(screen_id) ON DELETE CASCADE, + source_action VARCHAR(50), -- click, submit, select 등 + + -- 타겟 화면 + target_screen_id INT NOT NULL REFERENCES screen_definitions(screen_id) ON DELETE CASCADE, + target_action VARCHAR(50), -- open, load, refresh 등 + + -- 데이터 매핑 설정 + data_mapping JSONB, -- 필드 매핑 정보 + + -- 흐름 설정 + flow_type VARCHAR(20) DEFAULT 'unidirectional', -- unidirectional/bidirectional + flow_label VARCHAR(100), -- 시각화 라벨 + condition_expression TEXT, -- 실행 조건식 + + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20) NOT NULL, + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + writer VARCHAR(50) +); +``` + +**data_mapping 예시:** + +```json +{ + "customer_code": "customer_code", + "customer_name": "customer_name", + "selected_date": "order_date" +} +``` + +#### screen_table_relations (화면-테이블 관계) + +```sql +CREATE TABLE screen_table_relations ( + id SERIAL PRIMARY KEY, + group_id INT REFERENCES screen_groups(id) ON DELETE SET NULL, + screen_id INT NOT NULL REFERENCES screen_definitions(screen_id) ON DELETE CASCADE, + table_name VARCHAR(100) NOT NULL, + + relation_type VARCHAR(20) DEFAULT 'main', -- main, join, lookup + crud_operations VARCHAR(20) DEFAULT 'CRUD',-- CRUD 조합 + description TEXT, + + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20) NOT NULL, + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + writer VARCHAR(50) +); +``` + +### 6.5 컴포넌트 표준 + +#### component_standards (컴포넌트 표준) + +```sql +CREATE TABLE component_standards ( + component_code VARCHAR(50) PRIMARY KEY, + component_name VARCHAR(100) NOT NULL, + component_name_eng VARCHAR(100), + description TEXT, + category VARCHAR(50) NOT NULL, -- input, layout, display 등 + icon_name VARCHAR(50), + default_size JSON, -- {width, height} + component_config JSON NOT NULL, -- 컴포넌트 설정 + preview_image VARCHAR(255), + sort_order INTEGER DEFAULT 0, + is_active CHAR(1) DEFAULT 'Y', + is_public CHAR(1) DEFAULT 'Y', + company_code VARCHAR(50) NOT NULL, + created_date TIMESTAMP, + updated_date TIMESTAMP +); +``` + +#### layout_standards (레이아웃 표준) + +```sql +CREATE TABLE layout_standards ( + layout_code VARCHAR(50) PRIMARY KEY, + layout_name VARCHAR(100) NOT NULL, + layout_type VARCHAR(50), -- grid, form, split, tab + default_config JSON, + is_active CHAR(1) DEFAULT 'Y', + company_code VARCHAR(50), + created_date TIMESTAMP, + updated_date TIMESTAMP +); +``` + +--- + +## 7. 비즈니스 도메인별 테이블 + +### 7.1 영업/수주 관리 + +#### 수주 관리 (Order Management) + +``` +sales_order_mng -- 수주 마스터 +├── sales_order_detail -- 수주 상세 +├── sales_order_detail_log -- 수주 상세 이력 +├── sales_request_master -- 영업 요청 마스터 +├── sales_request_part -- 영업 요청 부품 +└── sales_part_chg -- 영업 부품 변경 +``` + +**sales_order_mng:** +- 고객별 수주 정보 +- 납기, 금액, 상태 관리 + +**sales_order_detail:** +- 수주 라인 아이템 +- 품목, 수량, 단가 정보 + +#### 견적 관리 + +``` +estimate_mgmt -- 견적 관리 +contract_mgmt -- 계약 관리 +├── contract_mgmt_option -- 계약 옵션 +``` + +#### BOM 관리 + +``` +sales_bom_report -- 영업 BOM 리포트 +├── sales_bom_report_part -- 영업 BOM 부품 +└── sales_bom_part_qty -- 영업 BOM 부품 수량 +``` + +### 7.2 구매/발주 관리 + +``` +purchase_order_master -- 발주 마스터 +├── purchase_order -- 발주 상세 +├── purchase_order_part -- 발주 부품 +├── purchase_order_multi -- 다중 발주 +└── purchase_detail -- 구매 상세 + +supplier_mng -- 공급업체 관리 +├── supplier_mng_log -- 공급업체 이력 +├── supplier_item -- 공급업체 품목 +├── supplier_item_alias -- 공급업체 품목 별칭 +├── supplier_item_mapping -- 공급업체 품목 매핑 +└── supplier_item_price -- 공급업체 품목 가격 +``` + +### 7.3 재고/창고 관리 + +``` +inventory_stock -- 재고 현황 +inventory_history -- 재고 이력 +warehouse_info -- 창고 정보 +warehouse_location -- 창고 위치 +inbound_mng -- 입고 관리 +outbound_mng -- 출고 관리 +receiving -- 입하 +receive_history -- 입하 이력 +``` + +### 7.4 생산/작업 관리 + +``` +work_orders -- 작업지시 (신규) +├── work_orders_detail -- 작업지시 상세 +work_order -- 작업지시 (레거시) +work_instruction -- 작업 지시서 +├── work_instruction_detail -- 작업 지시서 상세 +├── work_instruction_detail_log +└── work_instruction_log + +production_record -- 생산 실적 +production_task -- 생산 작업 +production_issue -- 생산 이슈 +work_request -- 작업 요청 +``` + +#### work_orders (작업지시 - 050 마이그레이션) + +```sql +CREATE TABLE work_orders ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500), + company_code VARCHAR(500), + + -- 작업지시 정보 + wo_number VARCHAR(500), -- WO-20250130-001 + product_code VARCHAR(500), + product_name VARCHAR(500), + spec VARCHAR(500), + order_qty VARCHAR(500), + completed_qty VARCHAR(500), + start_date VARCHAR(500), + due_date VARCHAR(500), + status VARCHAR(500), -- normal/urgent + progress_status VARCHAR(500), -- pending/in-progress/completed + equipment VARCHAR(500), + routing VARCHAR(500), + work_team VARCHAR(500), -- DAY/NIGHT + worker VARCHAR(500), + shift VARCHAR(500), -- DAY/NIGHT + remark VARCHAR(500) +); + +CREATE INDEX idx_work_orders_company_code ON work_orders(company_code); +CREATE INDEX idx_work_orders_wo_number ON work_orders(wo_number); +``` + +### 7.5 품질/검사 관리 + +``` +inspection_standard -- 검사 기준 +item_inspection_info -- 품목 검사 정보 +inspection_equipment_mng -- 검사 설비 관리 +├── inspection_equipment_mng_log +defect_standard_mng -- 불량 기준 관리 +├── defect_standard_mng_log +check_report_mng -- 검사 성적서 관리 +safety_inspections -- 안전 점검 +└── safety_inspections_log +``` + +### 7.6 물류/운송 관리 + +``` +vehicles -- 차량 정보 +├── vehicle_locations -- 차량 위치 +├── vehicle_location_history -- 차량 위치 이력 +├── vehicle_trip_summary -- 차량 운행 요약 +drivers -- 운전자 정보 +transport_logs -- 운송 로그 +transport_statistics -- 운송 통계 +transport_vehicle_locations -- 차량 위치 + +carrier_mng -- 운송사 관리 +├── carrier_mng_log +├── carrier_contract_mng -- 운송사 계약 +├── carrier_contract_mng_log +├── carrier_vehicle_mng -- 운송사 차량 +└── carrier_vehicle_mng_log + +delivery_route_mng -- 배송 경로 관리 +├── delivery_route_mng_log +delivery_destination -- 배송지 +delivery_status -- 배송 상태 +delivery_history -- 배송 이력 +├── delivery_history_defect -- 배송 불량 +delivery_part_price -- 배송 부품 가격 +``` + +#### DTG 관리 (디지털 타코그래프) + +``` +dtg_management -- DTG 관리 +├── dtg_management_log +dtg_contracts -- DTG 계약 +dtg_maintenance_history -- DTG 정비 이력 +dtg_monthly_settlements -- DTG 월별 정산 +``` + +### 7.7 PLM/설계 관리 + +``` +part_mng -- 부품 관리 (메인) +├── part_mng_history +part_mgmt -- 부품 관리 (서브) +part_bom_qty -- 부품 BOM 수량 +part_bom_report -- 부품 BOM 리포트 +part_distribution_list -- 부품 배포 목록 + +item_info -- 품목 정보 +item_routing_version -- 품목 라우팅 버전 +item_routing_detail -- 품목 라우팅 상세 + +product_mng -- 제품 관리 +product_mgmt -- 제품 관리 (메인) +├── product_mgmt_model -- 제품 모델 +├── product_mgmt_price_history -- 제품 가격 이력 +├── product_mgmt_upg_master -- 제품 업그레이드 마스터 +└── product_mgmt_upg_detail -- 제품 업그레이드 상세 + +product_kind_spec -- 제품 종류 사양 +product_kind_spec_main -- 제품 종류 사양 메인 +product_spec -- 제품 사양 +product_group_mng -- 제품 그룹 관리 + +mold_dev_request_info -- 금형 개발 요청 +structural_review_proposal -- 구조 검토 제안 +``` + +### 7.8 프로젝트 관리 (PMS) + +``` +pms_pjt_info -- 프로젝트 정보 +├── pms_pjt_concept_info -- 프로젝트 개념 정보 +├── pms_pjt_year_goal -- 프로젝트 연도 목표 +pms_wbs_task -- WBS 작업 +├── pms_wbs_task_info -- WBS 작업 정보 +├── pms_wbs_task_confirm -- WBS 작업 확인 +├── pms_wbs_task_standard -- WBS 작업 표준 +└── pms_wbs_template -- WBS 템플릿 + +pms_rel_pjt_concept_milestone -- 프로젝트 개념-마일스톤 관계 +pms_rel_pjt_concept_prod -- 프로젝트 개념-제품 관계 +pms_rel_pjt_prod -- 프로젝트-제품 관계 +pms_rel_prod_ref_dept -- 제품-참조부서 관계 + +pms_invest_cost_mng -- 투자 비용 관리 +project_mgmt -- 프로젝트 관리 +problem_mng -- 문제 관리 +planning_issue -- 계획 이슈 +``` + +### 7.9 회계/원가 관리 + +``` +tax_invoice -- 세금계산서 +├── tax_invoice_item -- 세금계산서 항목 +fund_mgmt -- 자금 관리 +expense_master -- 비용 마스터 +├── expense_detail -- 비용 상세 + +profit_loss -- 손익 계산 +├── profit_loss_total -- 손익 합계 +├── profit_loss_coefficient -- 손익 계수 +├── profit_loss_machine -- 손익 기계 +├── profit_loss_weight -- 손익 무게 +├── profit_loss_depth -- 손익 깊이 +├── profit_loss_pretime -- 손익 사전 시간 +├── profit_loss_coolingtime -- 손익 냉각 시간 +├── profit_loss_lossrate -- 손익 손실률 +└── profit_loss_srrate -- 손익 SR률 + +material_cost -- 자재 비용 +injection_cost -- 사출 비용 +logistics_cost_mng -- 물류 비용 관리 +└── logistics_cost_mng_log + +input_cost_goal -- 투입 비용 목표 +input_resource -- 투입 자원 +``` + +### 7.10 고객/협력사 관리 + +``` +customer_mng -- 고객 관리 +customer_item -- 고객 품목 +├── customer_item_alias -- 고객 품목 별칭 +├── customer_item_mapping -- 고객 품목 매핑 +└── customer_item_price -- 고객 품목 가격 + +customer_service_mgmt -- 고객 서비스 관리 +├── customer_service_part -- 고객 서비스 부품 +└── customer_service_workingtime -- 고객 서비스 작업시간 + +oem_mng -- OEM 관리 +├── oem_factory_mng -- OEM 공장 관리 +└── oem_milestone_mng -- OEM 마일스톤 관리 +``` + +### 7.11 설비/장비 관리 + +``` +equipment_mng -- 설비 관리 +├── equipment_mng_log +equipment_consumable -- 설비 소모품 +├── equipment_consumable_log +equipment_inspection_item -- 설비 검사 항목 +└── equipment_inspection_item_log + +process_equipment -- 공정 설비 +process_mng -- 공정 관리 +maintenance_schedules -- 정비 일정 +``` + +### 7.12 기타 + +``` +approval -- 결재 +comments -- 댓글 +inboxtask -- 수신함 작업 +time_sheet -- 작업 시간 +attach_file_info -- 첨부 파일 +file_down_log -- 파일 다운로드 로그 +login_access_log -- 로그인 접근 로그 +``` + +--- + +## 8. 플로우 및 데이터 통합 + +### 8.1 플로우 정의 + +#### flow_definition (플로우 정의) + +```sql +CREATE TABLE flow_definition ( + flow_id SERIAL PRIMARY KEY, + flow_name VARCHAR(200) NOT NULL, + flow_code VARCHAR(100) NOT NULL, + flow_type VARCHAR(50), -- data_transfer, approval, batch 등 + description TEXT, + trigger_type VARCHAR(50), -- manual, schedule, event + trigger_config JSONB, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + created_by VARCHAR(50), + updated_by VARCHAR(50), + + UNIQUE(flow_code, company_code) +); +``` + +#### flow_step (플로우 단계) + +```sql +CREATE TABLE flow_step ( + step_id SERIAL PRIMARY KEY, + flow_id INTEGER REFERENCES flow_definition(flow_id) ON DELETE CASCADE, + step_name VARCHAR(200) NOT NULL, + step_type VARCHAR(50) NOT NULL, -- query, transform, api_call, condition 등 + step_order INTEGER NOT NULL, + step_config JSONB NOT NULL, + error_handling_config JSONB, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW() +); +``` + +#### flow_step_connection (플로우 단계 연결) + +```sql +CREATE TABLE flow_step_connection ( + connection_id SERIAL PRIMARY KEY, + flow_id INTEGER REFERENCES flow_definition(flow_id) ON DELETE CASCADE, + source_step_id INTEGER REFERENCES flow_step(step_id) ON DELETE CASCADE, + target_step_id INTEGER REFERENCES flow_step(step_id) ON DELETE CASCADE, + condition_expression TEXT, -- 조건부 실행 + connection_type VARCHAR(20) DEFAULT 'sequential', -- sequential, parallel, conditional + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW() +); +``` + +### 8.2 데이터플로우 + +#### dataflow_diagrams (데이터플로우 다이어그램) + +```sql +CREATE TABLE dataflow_diagrams ( + diagram_id SERIAL PRIMARY KEY, + diagram_name VARCHAR(200) NOT NULL, + diagram_type VARCHAR(50), + diagram_json JSONB NOT NULL, -- 다이어그램 시각화 정보 + description TEXT, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + created_by VARCHAR(50), + updated_by VARCHAR(50) +); +``` + +#### flow_data_mapping (플로우 데이터 매핑) + +```sql +CREATE TABLE flow_data_mapping ( + mapping_id SERIAL PRIMARY KEY, + flow_id INTEGER REFERENCES flow_definition(flow_id) ON DELETE CASCADE, + source_type VARCHAR(20), -- table, api, flow + source_identifier VARCHAR(200), -- 테이블명 또는 API 엔드포인트 + source_field VARCHAR(100), + target_type VARCHAR(20), + target_identifier VARCHAR(200), + target_field VARCHAR(100), + transformation_rule TEXT, -- 변환 규칙 (JavaScript 표현식) + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW() +); +``` + +#### flow_data_status (플로우 데이터 상태) + +```sql +CREATE TABLE flow_data_status ( + status_id SERIAL PRIMARY KEY, + flow_id INTEGER REFERENCES flow_definition(flow_id), + execution_id VARCHAR(100), + source_table VARCHAR(100), + source_record_id VARCHAR(500), + target_table VARCHAR(100), + target_record_id VARCHAR(500), + status VARCHAR(20), -- pending, processing, completed, failed + error_message TEXT, + processed_at TIMESTAMPTZ, + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW() +); +``` + +### 8.3 외부 연동 + +#### external_db_connections (외부 DB 연결) + +```sql +CREATE TABLE external_db_connections ( + id SERIAL PRIMARY KEY, + connection_name VARCHAR(200) NOT NULL, + connection_code VARCHAR(100) NOT NULL, + db_type VARCHAR(50) NOT NULL, -- postgresql, mysql, mssql, oracle + host VARCHAR(255) NOT NULL, + port INTEGER NOT NULL, + database_name VARCHAR(100) NOT NULL, + username VARCHAR(100), + password_encrypted TEXT, + ssl_enabled BOOLEAN DEFAULT false, + connection_options JSONB, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + created_by VARCHAR(50), + + UNIQUE(connection_code, company_code) +); +``` + +#### external_rest_api_connections (외부 REST API 연결) + +```sql +CREATE TABLE external_rest_api_connections ( + id SERIAL PRIMARY KEY, + connection_name VARCHAR(200) NOT NULL, + connection_code VARCHAR(100) NOT NULL, + base_url VARCHAR(500) NOT NULL, + auth_type VARCHAR(50), -- none, basic, bearer, api_key + auth_config JSONB, + default_headers JSONB, + timeout_seconds INTEGER DEFAULT 30, + retry_count INTEGER DEFAULT 0, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + + UNIQUE(connection_code, company_code) +); +``` + +#### external_call_configs (외부 호출 설정) + +```sql +CREATE TABLE external_call_configs ( + id SERIAL PRIMARY KEY, + config_name VARCHAR(200) NOT NULL, + config_code VARCHAR(100) NOT NULL, + connection_id INTEGER, -- external_rest_api_connections 참조 + http_method VARCHAR(10), -- GET, POST, PUT, DELETE + endpoint_path VARCHAR(500), + request_mapping JSONB, -- 요청 데이터 매핑 + response_mapping JSONB, -- 응답 데이터 매핑 + error_handling JSONB, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW() +); +``` + +### 8.4 배치 작업 + +#### batch_jobs (배치 작업 정의) + +```sql +CREATE TABLE batch_jobs ( + job_id SERIAL PRIMARY KEY, + job_name VARCHAR(200) NOT NULL, + job_code VARCHAR(100) NOT NULL, + job_type VARCHAR(50), -- data_collection, aggregation, sync 등 + source_type VARCHAR(50), -- database, api, file + source_config JSONB, + target_config JSONB, + schedule_config JSONB, + is_active VARCHAR(1) DEFAULT 'Y', + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW(), + updated_date TIMESTAMPTZ DEFAULT NOW(), + + UNIQUE(job_code, company_code) +); +``` + +#### batch_execution_logs (배치 실행 로그) + +```sql +CREATE TABLE batch_execution_logs ( + execution_id SERIAL PRIMARY KEY, + job_id INTEGER REFERENCES batch_jobs(job_id), + execution_status VARCHAR(20), -- running, completed, failed + start_time TIMESTAMPTZ, + end_time TIMESTAMPTZ, + records_processed INTEGER, + records_failed INTEGER, + error_message TEXT, + execution_details JSONB, + company_code VARCHAR(20), + created_date TIMESTAMPTZ DEFAULT NOW() +); +``` + +--- + +## 9. 인덱스 전략 + +### 9.1 필수 인덱스 + +**모든 테이블에 적용:** + +```sql +-- company_code 인덱스 (멀티테넌시 성능) +CREATE INDEX idx_{table_name}_company_code ON {table_name}(company_code); + +-- 복합 인덱스 (company_code + 주요 검색 컬럼) +CREATE INDEX idx_{table_name}_company_{column} +ON {table_name}(company_code, {column}); +``` + +### 9.2 화면 관련 인덱스 + +```sql +-- screen_definitions +CREATE INDEX idx_screen_definitions_company_code ON screen_definitions(company_code); +CREATE INDEX idx_screen_definitions_table_name ON screen_definitions(table_name); +CREATE INDEX idx_screen_definitions_is_active ON screen_definitions(is_active); +CREATE UNIQUE INDEX idx_screen_definitions_code_company +ON screen_definitions(screen_code, company_code); + +-- screen_groups +CREATE INDEX idx_screen_groups_company_code ON screen_groups(company_code); +CREATE INDEX idx_screen_groups_parent_id ON screen_groups(parent_group_id); +CREATE INDEX idx_screen_groups_hierarchy_path ON screen_groups(hierarchy_path); + +-- screen_field_joins +CREATE INDEX idx_screen_field_joins_screen_id ON screen_field_joins(screen_id); +CREATE INDEX idx_screen_field_joins_save_table ON screen_field_joins(save_table); +CREATE INDEX idx_screen_field_joins_join_table ON screen_field_joins(join_table); +``` + +### 9.3 메타데이터 인덱스 + +```sql +-- table_type_columns +CREATE UNIQUE INDEX idx_table_type_columns_unique +ON table_type_columns(table_name, column_name, company_code); + +-- column_labels +CREATE INDEX idx_column_labels_table ON column_labels(table_name); +``` + +### 9.4 비즈니스 테이블 인덱스 예시 + +```sql +-- sales_order_mng +CREATE INDEX idx_sales_order_company_code ON sales_order_mng(company_code); +CREATE INDEX idx_sales_order_customer ON sales_order_mng(customer_code); +CREATE INDEX idx_sales_order_date ON sales_order_mng(order_date DESC); +CREATE INDEX idx_sales_order_status ON sales_order_mng(order_status); + +-- work_orders +CREATE INDEX idx_work_orders_company_code ON work_orders(company_code); +CREATE INDEX idx_work_orders_wo_number ON work_orders(wo_number); +CREATE INDEX idx_work_orders_start_date ON work_orders(start_date); +CREATE INDEX idx_work_orders_product_code ON work_orders(product_code); +``` + +### 9.5 플로우 관련 인덱스 + +```sql +-- flow_definition +CREATE UNIQUE INDEX idx_flow_definition_code_company +ON flow_definition(flow_code, company_code); + +-- flow_step +CREATE INDEX idx_flow_step_flow_id ON flow_step(flow_id); +CREATE INDEX idx_flow_step_order ON flow_step(flow_id, step_order); + +-- flow_data_status +CREATE INDEX idx_flow_data_status_flow_execution +ON flow_data_status(flow_id, execution_id); +CREATE INDEX idx_flow_data_status_source +ON flow_data_status(source_table, source_record_id); +``` + +--- + +## 10. 동적 테이블 생성 패턴 + +WACE ERP의 핵심 기능 중 하나는 **런타임에 테이블을 동적으로 생성**할 수 있다는 것입니다. + +### 10.1 표준 컬럼 구조 + +**모든 동적 생성 테이블의 기본 컬럼:** + +```sql +CREATE TABLE {dynamic_table_name} ( + -- 시스템 기본 컬럼 (자동 포함) + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500), + company_code VARCHAR(500), + + -- 사용자 정의 컬럼 (모두 VARCHAR(500)) + {user_column_1} VARCHAR(500), + {user_column_2} VARCHAR(500), + ... +); + +-- 필수 인덱스 +CREATE INDEX idx_{table_name}_company_code ON {table_name}(company_code); +``` + +### 10.2 메타데이터 등록 프로세스 + +동적 테이블 생성 시 반드시 수행해야 하는 작업: + +#### 1단계: 테이블 생성 + +```sql +CREATE TABLE {table_name} ( + -- 위의 표준 구조 참조 +); +``` + +#### 2단계: table_labels 등록 + +```sql +INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) +VALUES ('{table_name}', '{한글명}', '{설명}', NOW(), NOW()) +ON CONFLICT (table_name) +DO UPDATE SET + table_label = EXCLUDED.table_label, + description = EXCLUDED.description, + updated_date = NOW(); +``` + +#### 3단계: table_type_columns 등록 + +```sql +-- 기본 컬럼 등록 (display_order: -5 ~ -1) +INSERT INTO table_type_columns ( + table_name, column_name, company_code, input_type, detail_settings, + is_nullable, display_order, created_date, updated_date +) VALUES + ('{table_name}', 'id', '{company_code}', 'text', '{}', 'Y', -5, NOW(), NOW()), + ('{table_name}', 'created_date', '{company_code}', 'date', '{}', 'Y', -4, NOW(), NOW()), + ('{table_name}', 'updated_date', '{company_code}', 'date', '{}', 'Y', -3, NOW(), NOW()), + ('{table_name}', 'writer', '{company_code}', 'text', '{}', 'Y', -2, NOW(), NOW()), + ('{table_name}', 'company_code', '{company_code}', 'text', '{}', 'Y', -1, NOW(), NOW()) +ON CONFLICT (table_name, column_name, company_code) +DO UPDATE SET + input_type = EXCLUDED.input_type, + display_order = EXCLUDED.display_order, + updated_date = NOW(); + +-- 사용자 정의 컬럼 등록 (display_order: 0부터 시작) +INSERT INTO table_type_columns ( + table_name, column_name, company_code, input_type, detail_settings, + is_nullable, display_order, created_date, updated_date +) VALUES + ('{table_name}', '{column_1}', '{company_code}', 'text', '{}', 'Y', 0, NOW(), NOW()), + ('{table_name}', '{column_2}', '{company_code}', 'number', '{}', 'Y', 1, NOW(), NOW()), + ... +``` + +#### 4단계: column_labels 등록 (레거시 호환용) + +```sql +INSERT INTO column_labels ( + table_name, column_name, column_label, input_type, detail_settings, + description, display_order, is_visible, created_date, updated_date +) VALUES + ('{table_name}', 'id', 'ID', 'text', '{}', '기본키', -5, true, NOW(), NOW()), + ... +ON CONFLICT (table_name, column_name) +DO UPDATE SET + column_label = EXCLUDED.column_label, + input_type = EXCLUDED.input_type, + updated_date = NOW(); +``` + +### 10.3 마이그레이션 예시 + +**050_create_work_orders_table.sql:** + +```sql +-- ============================================ +-- 1. 테이블 생성 +-- ============================================ +CREATE TABLE work_orders ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500), + company_code VARCHAR(500), + + wo_number VARCHAR(500), + product_code VARCHAR(500), + product_name VARCHAR(500), + ... +); + +CREATE INDEX idx_work_orders_company_code ON work_orders(company_code); +CREATE INDEX idx_work_orders_wo_number ON work_orders(wo_number); + +-- ============================================ +-- 2. table_labels 등록 +-- ============================================ +INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) +VALUES ('work_orders', '작업지시', '작업지시 관리 테이블', NOW(), NOW()) +ON CONFLICT (table_name) DO UPDATE SET ...; + +-- ============================================ +-- 3. table_type_columns 등록 +-- ============================================ +INSERT INTO table_type_columns (...) VALUES (...); + +-- ============================================ +-- 4. column_labels 등록 +-- ============================================ +INSERT INTO column_labels (...) VALUES (...); + +-- ============================================ +-- 5. 코멘트 추가 +-- ============================================ +COMMENT ON TABLE work_orders IS '작업지시 관리 테이블'; +COMMENT ON COLUMN work_orders.wo_number IS '작업지시번호 (WO-YYYYMMDD-XXX)'; +``` + +--- + +## 11. 마이그레이션 히스토리 + +### 11.1 마이그레이션 파일 목록 + +``` +db/migrations/ +├── 037_add_parent_group_to_screen_groups.sql -- 화면 그룹 계층 구조 +├── 050_create_work_orders_table.sql -- 작업지시 테이블 +├── 051_insert_work_order_screen_definition.sql -- 작업지시 화면 정의 +├── 052_insert_work_order_screen_layout.sql -- 작업지시 화면 레이아웃 +├── 054_create_screen_management_enhancement.sql -- 화면 관리 기능 확장 +└── plm_schema_20260120.sql -- 전체 스키마 덤프 +``` + +### 11.2 주요 마이그레이션 내용 + +#### 037: 화면 그룹 계층 구조 + +- `screen_groups`에 계층 구조 지원 추가 +- `parent_group_id`, `group_level`, `hierarchy_path` 컬럼 추가 +- 대/중/소 분류 지원 + +#### 050~052: 작업지시 시스템 + +- `work_orders` 테이블 생성 +- 메타데이터 등록 (table_labels, table_type_columns, column_labels) +- 화면 정의 및 레이아웃 생성 + +#### 054: 화면 관리 기능 확장 + +- `screen_groups`: 화면 그룹 +- `screen_group_screens`: 화면-그룹 연결 +- `screen_field_joins`: 화면 필드 조인 설정 +- `screen_data_flows`: 화면 간 데이터 흐름 +- `screen_table_relations`: 화면-테이블 관계 + +### 11.3 마이그레이션 실행 가이드 + +**마이그레이션 문서:** +- `RUN_027_MIGRATION.md` +- `RUN_043_MIGRATION.md` +- `RUN_044_MIGRATION.md` +- `RUN_046_MIGRATION.md` +- `RUN_063_064_MIGRATION.md` +- `RUN_065_MIGRATION.md` +- `RUN_078_MIGRATION.md` + +--- + +## 12. 데이터베이스 함수 및 트리거 + +### 12.1 주요 함수 + +#### 화면 관련 + +```sql +-- 화면 생성 시 메뉴 자동 생성 +CREATE FUNCTION auto_create_menu_for_screen() RETURNS TRIGGER; + +-- 화면 삭제 시 메뉴 비활성화 +CREATE FUNCTION auto_deactivate_menu_for_screen() RETURNS TRIGGER; +``` + +#### 통계 집계 + +```sql +-- 일일 운송 통계 집계 +CREATE FUNCTION aggregate_daily_transport_statistics(target_date DATE) RETURNS INTEGER; +``` + +#### 거리 계산 + +```sql +-- Haversine 거리 계산 +CREATE FUNCTION calculate_distance(lat1 NUMERIC, lng1 NUMERIC, lat2 NUMERIC, lng2 NUMERIC) +RETURNS NUMERIC; + +-- 이전 위치로부터 거리 계산 +CREATE FUNCTION calculate_distance_from_prev() RETURNS TRIGGER; +``` + +#### 비즈니스 로직 + +```sql +-- 수주 잔량 계산 +CREATE FUNCTION calculate_order_balance() RETURNS TRIGGER; + +-- 세금계산서 합계 계산 +CREATE FUNCTION calculate_tax_invoice_total() RETURNS TRIGGER; + +-- 영업에서 프로젝트 자동 생성 +CREATE FUNCTION auto_create_project_from_sales(p_sales_no VARCHAR) RETURNS VARCHAR; +``` + +#### 로그 관리 + +```sql +-- 테이블 변경 로그 트리거 함수 +CREATE FUNCTION carrier_contract_mng_log_trigger_func() RETURNS TRIGGER; +CREATE FUNCTION carrier_mng_log_trigger_func() RETURNS TRIGGER; +-- ... 각 테이블별 로그 트리거 함수 +``` + +### 12.2 주요 트리거 + +```sql +-- 화면 생성 시 메뉴 자동 생성 +CREATE TRIGGER trg_auto_create_menu_for_screen +AFTER INSERT ON screen_definitions +FOR EACH ROW +EXECUTE FUNCTION auto_create_menu_for_screen(); + +-- 화면 삭제 시 메뉴 비활성화 +CREATE TRIGGER trg_auto_deactivate_menu_for_screen +AFTER UPDATE ON screen_definitions +FOR EACH ROW +EXECUTE FUNCTION auto_deactivate_menu_for_screen(); + +-- 차량 위치 이력 거리 계산 +CREATE TRIGGER trg_calculate_distance_from_prev +BEFORE INSERT ON vehicle_location_history +FOR EACH ROW +EXECUTE FUNCTION calculate_distance_from_prev(); +``` + +--- + +## 13. 뷰 (Views) + +### 13.1 시스템 뷰 + +```sql +-- 권한 그룹 요약 +CREATE VIEW v_authority_group_summary AS +SELECT + am.objid, + am.auth_name, + am.auth_code, + am.company_code, + (SELECT COUNT(*) FROM authority_sub_user WHERE master_objid = am.objid) AS member_count, + (SELECT COUNT(*) FROM rel_menu_auth WHERE auth_objid = am.objid) AS menu_count +FROM authority_master am; +``` + +--- + +## 14. 데이터베이스 보안 및 암호화 + +### 14.1 암호화 컬럼 + +```sql +-- external_db_connections +password_encrypted TEXT -- AES 암호화된 비밀번호 + +-- external_rest_api_connections +auth_config JSONB -- 암호화된 인증 정보 +``` + +### 14.2 접근 제어 + +- PostgreSQL 롤 기반 접근 제어 +- 회사별 데이터 격리 (company_code) +- 사용자별 권한 관리 (authority_master, rel_menu_auth) + +--- + +## 15. 성능 최적화 전략 + +### 15.1 인덱스 최적화 + +- **company_code 인덱스**: 모든 테이블에 필수 +- **복합 인덱스**: 자주 함께 조회되는 컬럼 조합 +- **부분 인덱스**: 특정 조건의 데이터만 인덱싱 + +### 15.2 쿼리 최적화 + +- **서브쿼리 최소화**: JOIN으로 대체 +- **EXPLAIN ANALYZE** 활용 +- **인덱스 힌트** 사용 + +### 15.3 캐싱 전략 + +- **참조 데이터 캐싱**: referenceCacheService.ts +- **Redis 캐싱**: 자주 조회되는 메타데이터 + +### 15.4 파티셔닝 + +- 대용량 이력 테이블 파티셔닝 고려 +- 날짜 기반 파티셔닝 (vehicle_location_history 등) + +--- + +## 16. 백업 및 복구 + +### 16.1 백업 전략 + +```bash +# 전체 스키마 백업 +pg_dump -h host -U user -d database > plm_schema_YYYYMMDD.sql + +# 데이터 포함 백업 +pg_dump -h host -U user -d database --data-only > data_YYYYMMDD.sql + +# 특정 테이블 백업 +pg_dump -h host -U user -d database -t table_name > table_backup.sql +``` + +### 16.2 마이그레이션 롤백 + +- DDL 작업 전 백업 필수 +- 트랜잭션 기반 마이그레이션 +- 롤백 스크립트 준비 + +--- + +## 17. 모니터링 및 로깅 + +### 17.1 시스템 로그 테이블 + +``` +login_access_log -- 로그인 접근 로그 +ddl_execution_log -- DDL 실행 로그 +batch_execution_logs -- 배치 실행 로그 +flow_audit_log -- 플로우 감사 로그 +flow_integration_log -- 플로우 통합 로그 +external_call_logs -- 외부 호출 로그 +mail_log -- 메일 발송 로그 +file_down_log -- 파일 다운로드 로그 +``` + +### 17.2 변경 이력 테이블 + +**패턴:** `{원본테이블}_log` 또는 `{원본테이블}_history` + +``` +user_info_history +dept_info_history +authority_master_history +comm_code_history +carrier_mng_log +supplier_mng_log +equipment_mng_log +... +``` + +--- + +## 18. 결론 + +### 18.1 핵심 아키텍처 특징 + +1. **멀티테넌시**: company_code로 완벽한 데이터 격리 +2. **메타데이터 드리븐**: 동적 화면/테이블 생성 +3. **Low-Code 플랫폼**: 코드 없이 화면 구축 +4. **플로우 기반**: 시각적 데이터 흐름 설계 +5. **외부 연동**: DB/API 통합 지원 +6. **이력 관리**: 완벽한 변경 이력 추적 + +### 18.2 확장성 + +- **수평 확장**: 멀티테넌시로 무한한 회사 추가 가능 +- **수직 확장**: 동적 테이블/컬럼 추가 +- **기능 확장**: 플로우/배치 작업으로 비즈니스 로직 추가 + +### 18.3 유지보수성 + +- **표준화된 구조**: 모든 테이블이 동일한 패턴 +- **자동화**: 트리거/함수로 반복 작업 자동화 +- **문서화**: 메타데이터 테이블 자체가 문서 + +--- + +## 부록 A: 백엔드 서비스 매핑 + +### 주요 서비스와 테이블 매핑 + +```typescript +// backend-node/src/services/ + +screenManagementService.ts → screen_definitions, screen_layouts +tableManagementService.ts → table_labels, table_type_columns, column_labels +menuService.ts → menu_info, menu_screen_groups +categoryTreeService.ts → table_column_category_values +flowDefinitionService.ts → flow_definition, flow_step +flowExecutionService.ts → flow_data_status, flow_audit_log +dataflowService.ts → dataflow_diagrams, screen_data_flows +externalDbConnectionService.ts → external_db_connections +externalRestApiConnectionService.ts → external_rest_api_connections +batchService.ts → batch_jobs, batch_execution_logs +authService.ts → user_info, auth_tokens +roleService.ts → authority_master, rel_menu_auth +``` + +--- + +## 부록 B: SQL 쿼리 예시 + +### 멀티테넌시 표준 쿼리 + +```sql +-- ✅ 단일 테이블 조회 +SELECT * FROM sales_order_mng +WHERE company_code = $1 + AND company_code != '*' + AND order_date >= $2 +ORDER BY order_date DESC +LIMIT 100; + +-- ✅ JOIN 쿼리 +SELECT + so.order_no, + so.order_date, + c.customer_name, + p.product_name, + so.quantity, + so.unit_price +FROM sales_order_mng so +LEFT JOIN customer_mng c + ON so.customer_code = c.customer_code + AND so.company_code = c.company_code +LEFT JOIN product_mng p + ON so.product_code = p.product_code + AND so.company_code = p.company_code +WHERE so.company_code = $1 + AND so.company_code != '*' + AND so.order_date BETWEEN $2 AND $3; + +-- ✅ 집계 쿼리 +SELECT + customer_code, + COUNT(*) as order_count, + SUM(total_amount) as total_sales +FROM sales_order_mng +WHERE company_code = $1 + AND company_code != '*' + AND order_date >= DATE_TRUNC('month', CURRENT_DATE) +GROUP BY customer_code +HAVING COUNT(*) >= 5 +ORDER BY total_sales DESC; +``` + +--- + +## 부록 C: 참고 문서 + +``` +docs/ +├── DDD1542/ +│ ├── DB_STRUCTURE_DIAGRAM.md -- DB 구조 다이어그램 +│ ├── DB_INEFFICIENCY_ANALYSIS.md -- DB 비효율성 분석 +│ ├── COMPONENT_URL_SYSTEM_IMPLEMENTATION.md +│ ├── V2_마이그레이션_학습노트_DDD1542.md +│ └── 본서버_개발서버_마이그레이션_가이드.md +├── backend-architecture-analysis.md -- 백엔드 아키텍처 분석 +└── screen-implementation-guide/ -- 화면 구현 가이드 +``` + +--- + +**문서 작성자**: Cursor AI (DB Specialist Agent) +**문서 버전**: 1.0 +**마지막 업데이트**: 2026-01-20 +**스키마 버전**: plm_schema_20260120.sql + +--- diff --git a/docs/DB_WORKFLOW_ANALYSIS.md b/docs/DB_WORKFLOW_ANALYSIS.md new file mode 100644 index 00000000..d5cd069b --- /dev/null +++ b/docs/DB_WORKFLOW_ANALYSIS.md @@ -0,0 +1,1728 @@ +# WACE ERP 데이터베이스 워크플로우 분석 + +> 📅 작성일: 2026-02-06 +> 🎯 목적: 비즈니스 워크플로우 중심의 DB 구조 분석 +> 📊 DB 엔진: PostgreSQL 16.8 +> 📝 기반 스키마: plm_schema_20260120.sql + +--- + +## 📋 Executive Summary + +WACE ERP 시스템은 **멀티테넌트 SaaS 아키텍처**를 기반으로 한 제조업 특화 ERP입니다. +- **총 테이블 수**: 337개 +- **핵심 아키텍처**: Multi-tenancy (company_code 기반 데이터 격리) +- **특징**: 메타데이터 드리븐, 동적 스키마, 플로우 기반 통합 + +--- + +## 🏗️ 1. 데이터베이스 아키텍처 개요 + +### 1.1 계층 구조 + +``` +┌─────────────────────────────────────────────────────────┐ +│ Application Layer (React) │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ Backend Layer (Node.js + TypeScript) │ +│ - API Routes │ +│ - Business Logic Services │ +│ - Raw SQL Query Execution │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ Database Layer (PostgreSQL) │ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ System Core │ │ Metadata │ │ +│ │ - user_info │ │ - table_labels │ │ +│ │ - company_mng │ │ - screen_def │ │ +│ │ - menu_info │ │ - flow_def │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ Business Domain Tables │ │ +│ │ - Sales (30+) - Purchase (25+) │ │ +│ │ - Stock (20+) - Production (25+) │ │ +│ │ - Quality (15+) - Logistics (20+) │ │ +│ │ - PLM (30+) - Accounting (20+) │ │ +│ └─────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 1.2 멀티테넌시 핵심 원칙 + +**ABSOLUTE MUST: 모든 테이블에 company_code** + +```sql +-- ✅ 표준 테이블 구조 +CREATE TABLE {table_name} ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + company_code VARCHAR(20) NOT NULL, -- 필수! + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500), + -- ... 비즈니스 컬럼들 +); + +-- 필수 인덱스 +CREATE INDEX idx_{table_name}_company_code +ON {table_name}(company_code); +``` + +**company_code = "*" 의미** +- ❌ 잘못된 이해: 모든 회사 공통 데이터 +- ✅ 올바른 이해: 슈퍼 관리자 전용 데이터 +- 일반 회사 쿼리: `WHERE company_code = $1 AND company_code != '*'` + +--- + +## 🎯 2. 핵심 시스템 테이블 (System Core) + +### 2.1 사용자 및 인증 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `user_info` | 사용자 정보 | user_id, user_name, email, password_hash, company_code | +| `user_info_history` | 사용자 변경 이력 | history_id, user_id, change_type, changed_at | +| `auth_tokens` | 인증 토큰 | token_id, user_id, token, expires_at | +| `login_access_log` | 로그인 이력 | log_id, user_id, ip_address, login_at | +| `user_dept` | 사용자-부서 매핑 (겸직 지원) | user_id, dept_code, is_primary | +| `user_dept_sub` | 겸직 부서 정보 | user_id, sub_dept_code | + +**워크플로우:** +``` +로그인 → auth_tokens 생성 → login_access_log 기록 +회사별 데이터 격리 → company_code 필터링 +``` + +### 2.2 권한 관리 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `authority_master` | 권한 그룹 마스터 | objid, auth_name, auth_code, company_code | +| `authority_master_history` | 권한 그룹 이력 | objid, parent_objid, history_type | +| `authority_sub_user` | 권한 그룹 멤버 | objid, master_objid, user_id | +| `rel_menu_auth` | 메뉴별 권한 CRUD | objid, menu_objid, auth_objid, create_auth, read_auth, update_auth, delete_auth | + +**권한 체계:** +``` +사용자 → authority_sub_user → authority_master → rel_menu_auth → 메뉴별 CRUD 권한 +``` + +### 2.3 회사 및 부서 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `company_mng` | 회사 정보 | company_code (PK), company_name, business_registration_number | +| `company_code_sequence` | 회사 코드 시퀀스 | company_code, next_sequence | +| `dept_info` | 부서 정보 | dept_code, dept_name, parent_dept_code, company_code | +| `dept_info_history` | 부서 변경 이력 | dept_code, change_type, changed_at | + +**계층 구조:** +``` +company_mng (회사) + └─ dept_info (부서 - 계층 구조) + └─ user_dept (사용자 배정) +``` + +### 2.4 메뉴 시스템 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `menu_info` | 메뉴 정보 | objid, menu_type, parent_obj_id, menu_name_kor, menu_url, screen_code, company_code, source_menu_objid | +| `menu_screen_groups` | 통합 메뉴/화면 그룹 | group_id, group_name, parent_group_id, company_code | +| `menu_screen_group_items` | 그룹-화면 연결 | group_id, screen_code | +| `screen_menu_assignments` | 화면-메뉴 할당 | screen_code, menu_id | + +**메뉴 타입:** +- `menu_type = 0`: 일반 메뉴 +- `menu_type = 1`: 시스템 관리 메뉴 +- `menu_type = 2`: 동적 생성 메뉴 (screen_definitions에서 자동 생성) + +**메뉴 복사 메커니즘:** +``` +원본 메뉴 (회사A) → 복사 → 새 메뉴 (회사B) +source_menu_objid: 원본 메뉴의 objid 추적 +재복사 시: source_menu_objid로 기존 복사본 찾아서 덮어쓰기 +``` + +--- + +## 🗂️ 3. 메타데이터 시스템 (Metadata Layer) + +### 3.1 테이블 메타데이터 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `table_labels` | 테이블 논리명 | table_name (PK), table_label, description | +| `table_type_columns` | 컬럼 타입 정의 (회사별) | table_name, column_name, company_code, input_type, detail_settings, display_order | +| `column_labels` | 컬럼 논리명 (레거시) | table_name, column_name, column_label, input_type | +| `table_relationships` | 테이블 관계 정의 | parent_table, child_table, join_condition | +| `table_log_config` | 테이블 로그 설정 | table_name, log_enabled, log_table_name | + +**메타데이터 구조:** +``` +table_labels (테이블 논리명) + └─ table_type_columns (컬럼 타입 정의 - 회사별) + └─ category_column_mapping (카테고리 컬럼 매핑) + └─ table_column_category_values (카테고리 값) +``` + +**동적 테이블 생성 프로세스:** +1. `CREATE TABLE` 실행 +2. `table_labels` 등록 +3. `table_type_columns` 등록 (회사별) +4. `column_labels` 등록 (레거시 호환) +5. `ddl_execution_log`에 DDL 실행 이력 기록 + +### 3.2 화면 메타데이터 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `screen_definitions` | 화면 정의 | screen_code (PK), screen_name, table_name, screen_type, company_code | +| `screen_layouts` | 화면 레이아웃 | screen_code, layout_config (JSONB) | +| `screen_templates` | 화면 템플릿 | template_id, template_name, template_config | +| `screen_widgets` | 화면 위젯 | widget_id, screen_code, widget_type, widget_config | +| `screen_groups` | 화면 그룹 | group_id, group_name, parent_group_id | +| `screen_group_screens` | 화면-그룹 연결 | group_id, screen_code | + +**화면 생성 워크플로우:** +``` +screen_definitions 생성 + → 트리거 발동 → menu_info 자동 생성 (menu_type=2) + → screen_layouts 레이아웃 정의 + → screen_widgets 위젯 배치 + → screen_table_relations 테이블 관계 설정 +``` + +### 3.3 화면 고급 기능 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `screen_embedding` | 화면 임베딩 | parent_screen, child_screen, embed_config | +| `screen_split_panel` | 분할 패널 | screen_code, left_screen, right_screen, split_ratio | +| `screen_data_transfer` | 화면 간 데이터 전달 | source_screen, target_screen, mapping_config | +| `screen_data_flows` | 화면 간 데이터 흐름 | flow_id, source_screen, target_screen, flow_type | +| `screen_field_joins` | 화면 필드 조인 | source_table, target_table, join_condition | +| `screen_table_relations` | 화면-테이블 관계 | screen_code, table_name, relation_type | + +**화면 임베딩 패턴:** +``` +마스터 화면 (sales_order) + ├─ 좌측 패널: 수주 목록 + └─ 우측 패널: 상세 정보 (임베딩) + ├─ 수주 기본 정보 + ├─ 수주 품목 (임베딩) + └─ 배송 정보 (임베딩) +``` + +### 3.4 UI 컴포넌트 표준 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `component_standards` | UI 컴포넌트 표준 | component_type, standard_props (JSONB) | +| `button_action_standards` | 버튼 액션 기준 | action_type, action_config (JSONB) | +| `web_type_standards` | 웹 타입 기준 | web_type, type_config (JSONB) | +| `grid_standards` | 격자 시스템 기준 | grid_type, grid_config (JSONB) | +| `layout_standards` | 레이아웃 표준 | layout_type, layout_config (JSONB) | +| `layout_instances` | 레이아웃 인스턴스 | instance_id, layout_type, instance_config | +| `style_templates` | 스타일 템플릿 | template_id, template_name, style_config (JSONB) | + +--- + +## 🔄 4. 플로우 및 데이터 통합 시스템 + +### 4.1 플로우 정의 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `flow_definition` | 플로우 정의 | id, name, table_name, db_source_type, company_code | +| `flow_step` | 플로우 단계 | step_id, flow_id, step_name, step_type, step_order, action_config (JSONB) | +| `flow_step_connection` | 플로우 단계 연결 | connection_id, source_step_id, target_step_id, condition | +| `flow_data_mapping` | 플로우 데이터 매핑 | mapping_id, flow_id, source_table, target_table, mapping_config | + +**플로우 타입:** +- 승인 플로우: 결재 라인 정의 +- 상태 플로우: 데이터 상태 전환 +- 데이터 플로우: 테이블 간 데이터 이동 +- 통합 플로우: 외부 시스템 연동 + +### 4.2 플로우 실행 및 모니터링 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `flow_data_status` | 데이터 현재 상태 | data_id, flow_id, current_step_id, status | +| `flow_audit_log` | 플로우 상태 변경 이력 | log_id, flow_id, data_id, from_step, to_step, changed_at, changed_by | +| `flow_integration_log` | 플로우 외부 연동 로그 | log_id, flow_id, integration_type, request_data, response_data | + +**플로우 실행 예시: 수주 승인** +``` +수주 등록 (sales_order_mng) + → flow_data_status 생성 (status: 'PENDING') + → flow_step 1: 영업팀장 승인 + → flow_step 2: 재고 확인 + → flow_step 3: 생산계획 생성 + → flow_step 4: 최종 승인 + → flow_audit_log 각 단계 기록 +``` + +### 4.3 노드 기반 플로우 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `node_flows` | 노드 기반 플로우 | flow_id, flow_name, nodes (JSONB), edges (JSONB) | +| `dataflow_diagrams` | 데이터플로우 다이어그램 | diagram_id, diagram_name, diagram_data (JSONB) | +| `dataflow_external_calls` | 데이터플로우 외부 호출 | call_id, diagram_id, external_connection_id | + +**노드 타입:** +- Start/End Node +- Data Node (테이블 조회/저장) +- Logic Node (조건 분기, 계산) +- External Node (외부 API 호출) +- Transform Node (데이터 변환) + +--- + +## 🌐 5. 외부 연동 시스템 + +### 5.1 외부 데이터베이스 연결 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `external_db_connections` | 외부 DB 연결 정보 | connection_id, connection_name, db_type, host, port, database, username, password_encrypted | +| `external_db_connection` | 외부 DB 연결 (레거시) | connection_id, connection_config (JSONB) | +| `external_connection_permission` | 외부 연결 권한 | permission_id, connection_id, user_id, allowed_operations | +| `flow_external_db_connection` | 플로우 전용 외부 DB 연결 | connection_id, flow_id, db_config (JSONB) | +| `flow_external_connection_permission` | 플로우 외부 연결 권한 | permission_id, flow_id, connection_id | + +**지원 DB 타입:** +- PostgreSQL +- MySQL +- MS SQL Server +- Oracle +- MongoDB (NoSQL) + +### 5.2 외부 REST API 연결 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `external_rest_api_connections` | 외부 REST API 연결 | connection_id, api_name, base_url, auth_type, auth_config (JSONB) | +| `external_call_configs` | 외부 호출 설정 | config_id, connection_id, endpoint, method, headers (JSONB) | +| `external_call_logs` | 외부 호출 로그 | log_id, config_id, request_data, response_data, status_code, executed_at | + +**인증 방식:** +- API Key +- Bearer Token +- OAuth 2.0 +- Basic Auth + +### 5.3 데이터 수집 배치 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `data_collection_configs` | 데이터 수집 설정 | config_id, source_type, source_config (JSONB), schedule | +| `data_collection_jobs` | 데이터 수집 작업 | job_id, config_id, job_status, started_at, completed_at | +| `data_collection_history` | 데이터 수집 이력 | history_id, job_id, collected_count, error_count | +| `collection_batch_management` | 수집 배치 관리 | batch_id, batch_name, batch_config (JSONB) | +| `collection_batch_executions` | 배치 실행 이력 | execution_id, batch_id, execution_status, executed_at | + +--- + +## 📊 6. 비즈니스 도메인 테이블 (Business Domain) + +### 6.1 영업/수주 (Sales & Orders) - 30+ 테이블 + +#### 핵심 테이블 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `sales_order_mng` | 수주 관리 | customer_mng, product_mng | +| `sales_order_detail` | 수주 상세 | sales_order_mng | +| `sales_order_detail_log` | 수주 상세 이력 | sales_order_detail | +| `sales_request_master` | 구매요청서 마스터 | customer_mng | +| `sales_request_part` | 구매요청서 품목 | sales_request_master, part_mng | +| `estimate_mgmt` | 견적 관리 | customer_mng, product_mng | +| `contract_mgmt` | 계약 관리 | customer_mng | +| `contract_mgmt_option` | 계약 옵션 | contract_mgmt | + +#### 영업 지원 테이블 + +| 테이블 | 역할 | +|--------|------| +| `sales_bom_report` | 영업 BOM 보고서 | +| `sales_bom_part_qty` | 영업 BOM 수량 | +| `sales_bom_report_part` | 영업 BOM 품목 | +| `sales_long_delivery` | 장납기 부품 리스트 | +| `sales_long_delivery_input` | 장납기 자재 투입 이력 | +| `sales_long_delivery_predict` | 장납기 예측 | +| `sales_part_chg` | 설계 변경 리스트 | +| `sample_supply` | 샘플 공급 | + +#### 고객 관리 + +| 테이블 | 역할 | +|--------|------| +| `customer_mng` | 거래처 마스터 | +| `customer_item` | 거래처별 품번 관리 | +| `customer_item_alias` | 거래처별 품목 품번/품명 | +| `customer_item_mapping` | 거래처별 품목 매핑 | +| `customer_item_price` | 거래처별 품목 단가 이력 | +| `customer_service_mgmt` | 조치내역서 마스터 | +| `customer_service_part` | 조치내역서 사용 부품 | +| `customer_service_workingtime` | 조치내역서 작업 시간 | +| `counselingmgmt` | 상담 관리 | + +**워크플로우: 수주 프로세스** +``` +1. 견적 요청 (estimate_mgmt) +2. 견적 작성 및 발송 +3. 수주 등록 (sales_order_mng) +4. 수주 상세 입력 (sales_order_detail) +5. 계약 체결 (contract_mgmt) +6. 생산 계획 생성 (order_plan_mgmt) +7. 구매 요청 (sales_request_master) +``` + +### 6.2 구매/발주 (Purchase & Procurement) - 25+ 테이블 + +#### 핵심 테이블 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `purchase_order_master` | 발주 관리 마스터 | supplier_mng | +| `purchase_order_part` | 발주서 상세 목록 | purchase_order_master, part_mng | +| `purchase_order_multi` | 동시적용 프로젝트 발주 | purchase_order_master | +| `purchase_order_mng` | 발주 관리 | supplier_mng | +| `purchase_detail` | 구매 상세 | purchase_order | +| `purchase_order` | 구매 주문 | supplier_mng | + +#### 공급처 관리 + +| 테이블 | 역할 | +|--------|------| +| `supplier_mng` | 공급처 마스터 | +| `supplier_mng_log` | 공급처 변경 이력 | +| `supplier_item` | 공급처별 품목 정보 | +| `supplier_item_alias` | 공급처별 품목 품번/품명 | +| `supplier_item_mapping` | 공급처별 품목 매핑 | +| `supplier_item_price` | 공급처별 품목 단가 | +| `procurement_standard` | 구매 기준 정보 | + +#### 입고 관리 + +| 테이블 | 역할 | +|--------|------| +| `delivery_history` | 입고 관리 | +| `delivery_history_defect` | 입고 불량 품목 | +| `delivery_part_price` | 입고 품목 단가 | +| `receiving` | 입고 처리 | +| `receive_history` | 입고 이력 | +| `inbound_mng` | 입고 관리 | +| `check_report_mng` | 검수 관리 보고서 | + +**워크플로우: 구매 프로세스** +``` +1. 구매 요청 (sales_request_master) +2. 공급처 선정 (supplier_mng) +3. 발주서 작성 (purchase_order_master) +4. 발주서 상세 (purchase_order_part) +5. 입고 예정 (delivery_history) +6. 검수 (check_report_mng) +7. 입고 확정 (receiving) +8. 재고 반영 (inventory_stock) +``` + +### 6.3 재고/창고 (Inventory & Warehouse) - 20+ 테이블 + +#### 핵심 테이블 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `inventory_stock` | 재고 현황 | item_info, warehouse_location | +| `inventory_history` | 재고 이력 | inventory_stock | +| `warehouse_info` | 창고 정보 | - | +| `warehouse_location` | 창고 위치 | warehouse_info | +| `inbound_mng` | 입고 관리 | warehouse_location | +| `outbound_mng` | 출고 관리 | warehouse_location | +| `material_release` | 자재 출고 | - | + +#### 물류 관리 + +| 테이블 | 역할 | +|--------|------| +| `shipment_header` | 출하 헤더 | +| `shipment_detail` | 출하 상세 | +| `shipment_plan` | 출하 계획 | +| `shipment_instruction` | 출하 지시 | +| `shipment_instruction_item` | 출하 지시 품목 | +| `shipment_pallet` | 출하 파레트 | +| `delivery_destination` | 납품처 정보 | +| `delivery_route_mng` | 배송 경로 관리 | +| `delivery_route_mng_log` | 배송 경로 이력 | +| `delivery_status` | 배송 상태 | + +#### 디지털 트윈 (창고 레이아웃) + +| 테이블 | 역할 | +|--------|------| +| `digital_twin_layout` | 디지털 트윈 레이아웃 | +| `digital_twin_layout_template` | 레이아웃 템플릿 | +| `digital_twin_location_layout` | Location 배치 정보 | +| `digital_twin_zone_layout` | Zone 배치 정보 | +| `digital_twin_objects` | 디지털 트윈 객체 | +| `yard_layout` | 야드 레이아웃 | +| `yard_material_placement` | 야드 자재 배치 | + +**워크플로우: 재고 관리** +``` +1. 입고 (inbound_mng) + → inventory_stock 증가 + → inventory_history 기록 + +2. 출고 (outbound_mng) + → inventory_stock 감소 + → inventory_history 기록 + +3. 창고 위치 관리 + → warehouse_location + → digital_twin_location_layout (시각화) + +4. 재고 조사 + → inventory_stock 조회 + → 실사 vs 전산 비교 + → 차이 조정 +``` + +### 6.4 생산/작업 (Production & Work) - 25+ 테이블 + +#### 핵심 테이블 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `work_orders` | 작업지시 관리 | product_mng, sales_order_mng | +| `work_orders_detail` | 작업지시 상세 | work_orders | +| `work_instruction` | 작업지시 | - | +| `work_instruction_detail` | 작업지시 상세 | work_instruction | +| `work_instruction_log` | 작업지시 이력 | work_instruction | +| `work_instruction_detail_log` | 작업지시 상세 이력 | work_instruction_detail | +| `work_order` | 작업지시 (레거시) | - | +| `work_request` | 작업 요청 (워크플로우) | - | + +#### 생산 계획 + +| 테이블 | 역할 | +|--------|------| +| `order_plan_mgmt` | 생산 계획 관리 | +| `order_plan_result_error` | 생산 계획 오류 결과 | +| `production_task` | 생산 작업 | +| `production_record` | 생산 실적 | +| `production_issue` | 생산 이슈 | +| `facility_assembly_plan` | 설비 조립 계획 | + +#### 공정 관리 + +| 테이블 | 역할 | +|--------|------| +| `process_mng` | 공정 관리 | +| `process_equipment` | 공정 설비 | +| `item_routing_version` | 품목 라우팅 버전 | +| `item_routing_detail` | 품목 라우팅 상세 | +| `input_resource` | 투입 자원 | + +#### 설비 관리 + +| 테이블 | 역할 | +|--------|------| +| `equipment_mng` | 설비 관리 | +| `equipment_mng_log` | 설비 변경 이력 | +| `equipment_consumable` | 설비 소모품 | +| `equipment_consumable_log` | 소모품 이력 | +| `equipment_inspection_item` | 설비 점검 항목 | +| `equipment_inspection_item_log` | 점검 항목 이력 | +| `inspection_equipment_mng` | 검사 설비 관리 | +| `inspection_equipment_mng_log` | 검사 설비 이력 | + +**워크플로우: 생산 프로세스** +``` +1. 수주 확정 (sales_order_mng) +2. 생산 계획 생성 (order_plan_mgmt) +3. 자재 소요 계획 (MRP) +4. 작업지시 발행 (work_orders) +5. 자재 출고 (material_release) +6. 생산 실행 (production_record) +7. 품질 검사 (inspection_standard) +8. 완제품 입고 (inventory_stock) +``` + +### 6.5 품질/검사 (Quality & Inspection) - 15+ 테이블 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `inspection_standard` | 검사 기준 | - | +| `item_inspection_info` | 품목 검사 정보 | item_info | +| `defect_standard_mng` | 불량 기준 관리 | - | +| `defect_standard_mng_log` | 불량 기준 이력 | defect_standard_mng | +| `check_report_mng` | 검수 관리 보고서 | - | + +**품질 관리 워크플로우:** +``` +1. 입고 검사 (check_report_mng) +2. 공정 검사 (inspection_standard) +3. 최종 검사 (item_inspection_info) +4. 불량 처리 (defect_standard_mng) +5. 품질 데이터 분석 +``` + +### 6.6 물류/운송 (Logistics & Transport) - 20+ 테이블 + +#### 차량 관리 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `vehicles` | 차량 정보 | - | +| `drivers` | 운전자 정보 | - | +| `vehicle_locations` | 차량 현재 위치 | vehicles | +| `vehicle_location_history` | 차량 위치 이력 | vehicles | +| `transport_vehicle_locations` | 운행 관리 실시간 위치 | vehicles | + +#### 운송 관리 + +| 테이블 | 역할 | +|--------|------| +| `transport_logs` | 운행 이력 | +| `transport_statistics` | 일별 운행 통계 | +| `vehicle_trip_summary` | 차량 운행 요약 | +| `maintenance_schedules` | 차량 정비 일정 | + +#### 운송사 관리 + +| 테이블 | 역할 | +|--------|------| +| `carrier_mng` | 운송사 관리 | +| `carrier_mng_log` | 운송사 이력 | +| `carrier_contract_mng` | 운송사 계약 관리 | +| `carrier_contract_mng_log` | 계약 이력 | +| `carrier_vehicle_mng` | 운송사 차량 관리 | +| `carrier_vehicle_mng_log` | 차량 이력 | + +#### DTG (디지털운행기록계) + +| 테이블 | 역할 | +|--------|------| +| `dtg_management` | DTG 통합 관리 (구매/설치/점검/폐기/정산) | +| `dtg_management_log` | DTG 관리 이력 | +| `dtg_contracts` | DTG 차종별/운송사별 계약 | +| `dtg_monthly_settlements` | DTG 월별 정산 | +| `dtg_maintenance_history` | DTG 점검 이력 | + +#### 물류 비용 + +| 테이블 | 역할 | +|--------|------| +| `logistics_cost_mng` | 물류 비용 관리 | +| `logistics_cost_mng_log` | 물류 비용 이력 | + +**워크플로우: 운송 관리** +``` +1. 출하 계획 (shipment_plan) +2. 차량 배차 (vehicles) +3. 운행 시작 + → vehicle_location_history (GPS 추적) + → transport_logs 기록 +4. 배송 완료 +5. 운행 통계 집계 (transport_statistics) +6. 정산 (dtg_monthly_settlements) +``` + +### 6.7 PLM/설계 (Product Lifecycle Management) - 30+ 테이블 + +#### 제품 관리 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `product_mng` | 제품 마스터 | - | +| `product_mgmt` | 제품 관리 | - | +| `product_mgmt_model` | 제품 모델 | product_mgmt | +| `product_mgmt_price_history` | 제품 가격 이력 | product_mgmt | +| `product_mgmt_upg_master` | 제품 업그레이드 마스터 | product_mgmt | +| `product_mgmt_upg_detail` | 제품 업그레이드 상세 | product_mgmt_upg_master | +| `product_kind_spec` | 제품별 사양 관리 | - | +| `product_kind_spec_main` | 제품별 사양 메인 | - | +| `product_spec` | 제품 사양 | - | +| `product_group_mng` | 제품 그룹 관리 | - | + +#### 품목 관리 + +| 테이블 | 역할 | +|--------|------| +| `item_info` | 품목 정보 | +| `part_mng` | 부품 관리 (설계 정보 포함) | +| `part_mng_history` | 부품 변경 이력 | +| `part_mgmt` | 부품 관리 | +| `part_distribution_list` | 부품 배포 리스트 | + +#### BOM 관리 + +| 테이블 | 역할 | +|--------|------| +| `klbom_tbl` | KL BOM 테이블 | +| `part_bom_qty` | BOM 수량 관리 | +| `part_bom_report` | BOM 보고서 | +| `sales_bom_report` | 영업 BOM 보고서 | +| `sales_bom_report_part` | 영업 BOM 품목 | +| `sales_bom_part_qty` | 영업 BOM 수량 | + +#### 프로젝트 관리 + +| 테이블 | 역할 | +|--------|------| +| `pms_pjt_info` | 프로젝트 정보 | +| `pms_pjt_concept_info` | 프로젝트 개념 정보 | +| `pms_pjt_year_goal` | 프로젝트 연간 목표 | +| `pms_rel_pjt_prod` | 프로젝트-제품 관계 | +| `pms_rel_pjt_concept_prod` | 프로젝트 개념-제품 관계 | +| `pms_rel_pjt_concept_milestone` | 프로젝트 개념-마일스톤 | +| `pms_rel_prod_ref_dept` | 제품-참조부서 관계 | +| `project` | 프로젝트 (레거시) | +| `project_mgmt` | 프로젝트 관리 | +| `project_concept` | 프로젝트 개념 (레거시?) | + +#### WBS (Work Breakdown Structure) + +| 테이블 | 역할 | +|--------|------| +| `pms_wbs_task` | WBS 작업 | +| `pms_wbs_task_info` | WBS 작업 정보 | +| `pms_wbs_task_confirm` | WBS 작업 확인 | +| `pms_wbs_task_standard` | WBS 작업 표준 | +| `pms_wbs_task_standard2` | WBS 작업 표준2 | +| `pms_wbs_template` | WBS 템플릿 | + +#### 설계 관리 + +| 테이블 | 역할 | +|--------|------| +| `mold_dev_request_info` | 금형 개발 요청 | +| `structural_review_proposal` | 구조 검토 제안서 | +| `external_work_review_info` | 외주 작업 검토 정보 | +| `standard_doc_info` | 표준 문서 정보 | + +#### OEM 관리 + +| 테이블 | 역할 | +|--------|------| +| `oem_mng` | OEM 관리 | +| `oem_factory_mng` | OEM 공장 관리 | +| `oem_milestone_mng` | OEM 마일스톤 관리 | + +**워크플로우: PLM 프로세스** +``` +1. 제품 기획 (pms_pjt_concept_info) +2. 프로젝트 생성 (pms_pjt_info) +3. WBS 작업 분해 (pms_wbs_task) +4. 제품 설계 (product_mng) +5. BOM 작성 (part_bom_report) +6. 부품 관리 (part_mng) +7. 설계 검토 (structural_review_proposal) +8. 양산 전환 (production_task) +``` + +### 6.8 회계/원가 (Accounting & Costing) - 20+ 테이블 + +#### 원가 관리 + +| 테이블 | 역할 | 주요 참조 | +|--------|------|-----------| +| `material_cost` | 재료비 | - | +| `injection_cost` | 사출 원가 | - | +| `profit_loss` | 손익 관리 | - | +| `profit_loss_total` | 손익 합계 | - | +| `profit_loss_coefficient` | 손익 계수 | - | +| `profit_loss_coolingtime` | 손익 냉각 시간 | - | +| `profit_loss_depth` | 손익 깊이 | - | +| `profit_loss_lossrate` | 손익 손실률 | - | +| `profit_loss_machine` | 손익 기계 | - | +| `profit_loss_pretime` | 손익 사전 시간 | - | +| `profit_loss_srrate` | 손익 SR률 | - | +| `profit_loss_weight` | 손익 중량 | - | +| `profit_loss_total_addlist` | 손익 합계 추가 리스트 | - | +| `profit_loss_total_addlist2` | 손익 합계 추가 리스트2 | - | + +#### 자재/비용 관리 + +| 테이블 | 역할 | +|--------|------| +| `material_mng` | 자재 관리 | +| `material_master_mgmt` | 자재 마스터 관리 | +| `material_detail_mgmt` | 자재 상세 관리 | +| `input_cost_goal` | 투입 원가 목표 | + +#### 비용/투자 + +| 테이블 | 역할 | +|--------|------| +| `expense_master` | 비용 마스터 | +| `expense_detail` | 비용 상세 | +| `pms_invest_cost_mng` | 투자 비용 관리 | +| `fund_mgmt` | 자금 관리 | + +#### 세금계산서 + +| 테이블 | 역할 | +|--------|------| +| `tax_invoice` | 세금계산서 | +| `tax_invoice_item` | 세금계산서 항목 | + +#### 안전 예산 + +| 테이블 | 역할 | +|--------|------| +| `safety_budget_execution` | 안전 예산 집행 | +| `safety_incidents` | 안전 사고 | +| `safety_inspections` | 안전 점검 | +| `safety_inspections_log` | 안전 점검 이력 | + +**워크플로우: 원가 계산** +``` +1. BOM 기반 재료비 계산 (material_cost) +2. 공정별 가공비 계산 (injection_cost) +3. 간접비 배부 +4. 총 원가 집계 (profit_loss_total) +5. 판매가 대비 이익률 계산 (profit_loss) +``` + +### 6.9 기타 비즈니스 테이블 - 15+ 테이블 + +#### 공통 코드 + +| 테이블 | 역할 | +|--------|------| +| `comm_code` | 공통 코드 | +| `comm_code_history` | 공통 코드 이력 | +| `code_category` | 코드 카테고리 | +| `code_info` | 코드 정보 | + +#### 환율 + +| 테이블 | 역할 | +|--------|------| +| `comm_exchange_rate` | 환율 정보 | + +#### 게시판/댓글 + +| 테이블 | 역할 | +|--------|------| +| `chartmgmt` | 차트 관리 | +| `comments` | 댓글 | +| `inboxtask` | 받은편지함 작업 | + +#### 첨부파일 + +| 테이블 | 역할 | +|--------|------| +| `attach_file_info` | 첨부파일 정보 | +| `file_down_log` | 파일 다운로드 로그 | + +#### 승인 + +| 테이블 | 역할 | +|--------|------| +| `approval` | 승인 | + +#### 옵션 관리 + +| 테이블 | 역할 | +|--------|------| +| `option_mng` | 옵션 관리 | +| `option_price_history` | 옵션 가격 이력 | + +#### 수주 사양 + +| 테이블 | 역할 | +|--------|------| +| `order_spec_mng` | 수주 사양 관리 | +| `order_spec_mng_history` | 수주 사양 이력 | + +#### 기타 + +| 테이블 | 역할 | +|--------|------| +| `ratecal_mgmt` | 요율 계산 관리 | +| `time_sheet` | 타임시트 | +| `problem_mng` | 문제 관리 | +| `planning_issue` | 계획 이슈 | +| `used_mng` | 중고 관리 | + +--- + +## 📊 7. 대시보드 및 리포트 시스템 + +### 7.1 대시보드 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `dashboards` | 대시보드 정의 | dashboard_id, dashboard_name, company_code | +| `dashboard_elements` | 대시보드 요소 | element_id, dashboard_id, element_type, element_config (JSONB) | +| `dashboard_shares` | 대시보드 공유 | share_id, dashboard_id, shared_with_user_id | +| `dashboard_sliders` | 대시보드 슬라이더 | slider_id, slider_name, company_code | +| `dashboard_slider_items` | 슬라이더 내 대시보드 목록 | item_id, slider_id, dashboard_id, display_order | + +**대시보드 구조:** +``` +dashboard_sliders (슬라이더 그룹) + └─ dashboard_slider_items (슬라이더에 포함된 대시보드들) + └─ dashboards (개별 대시보드) + └─ dashboard_elements (차트, 위젯 등) +``` + +### 7.2 리포트 시스템 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `report_master` | 리포트 마스터 | report_id, report_name, report_type | +| `report_query` | 리포트 쿼리 | query_id, report_id, query_sql, query_params | +| `report_layout` | 리포트 레이아웃 | layout_id, report_id, layout_config (JSONB) | +| `report_template` | 리포트 템플릿 | template_id, template_name, template_config (JSONB) | +| `report_menu_mapping` | 리포트-메뉴 매핑 | mapping_id, report_id, menu_id | + +**리포트 생성 프로세스:** +``` +1. report_master 정의 +2. report_query SQL 작성 +3. report_layout 레이아웃 설정 +4. report_menu_mapping 메뉴 연결 +5. 사용자가 메뉴 클릭 +6. 동적 SQL 실행 +7. 결과를 레이아웃에 맞춰 렌더링 +``` + +--- + +## 🔧 8. 고급 기능 시스템 + +### 8.1 카테고리 시스템 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `category_column_mapping` | 카테고리 컬럼 매핑 | table_name, column_name, category_key | +| `table_column_category_values` | 카테고리 값 | table_name, column_name, company_code, category_values (JSONB) | +| `category_value_cascading_group` | 카테고리 연쇄 그룹 | group_id, group_name | +| `category_value_cascading_mapping` | 카테고리 연쇄 매핑 | mapping_id, parent_category, child_category | + +**카테고리 기능:** +- 컬럼별 드롭다운 옵션 관리 +- 회사별 독립적인 카테고리 값 +- 연쇄 드롭다운 (parent → child) + +### 8.2 연쇄 드롭다운 (Cascading Dropdown) + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `cascading_relation` | 연쇄 드롭다운 관계 | relation_id, parent_field, child_field, relation_config | +| `cascading_hierarchy_group` | 다단계 연쇄 그룹 | group_id, group_name, hierarchy_levels | +| `cascading_hierarchy_level` | 다단계 연쇄 레벨 | level_id, group_id, level_order, level_config | +| `cascading_condition` | 조건부 연쇄 | condition_id, parent_field, child_field, condition_config | +| `cascading_multi_parent` | 다중 부모 연쇄 | relation_id, child_field, parent_fields (ARRAY) | +| `cascading_multi_parent_source` | 다중 부모 소스 | source_id, relation_id, parent_field, source_config | +| `cascading_mutual_exclusion` | 상호 배제 | exclusion_id, field_1, field_2 | +| `cascading_reverse_lookup` | 역방향 연쇄 | lookup_id, child_field, parent_field | +| `cascading_auto_fill_group` | 자동 입력 그룹 | group_id, master_field, auto_fill_fields | +| `cascading_auto_fill_mapping` | 자동 입력 매핑 | mapping_id, group_id, source_field, target_field | + +**연쇄 드롭다운 패턴:** +``` +1. 단순 연쇄: 대분류 → 중분류 → 소분류 +2. 다단계 연쇄: 회사 → 부서 → 팀 → 직원 +3. 조건부 연쇄: 제품군에 따라 다른 옵션 표시 +4. 다중 부모: 부품은 여러 제품에 속할 수 있음 +5. 자동 입력: 거래처 선택 시 주소/연락처 자동 입력 +``` + +### 8.3 채번 규칙 (Numbering Rules) + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `numbering_rules` | 채번 규칙 마스터 | rule_id, rule_name, table_name, column_name, company_code | +| `numbering_rule_parts` | 채번 규칙 파트 | part_id, rule_id, part_order, part_type, part_config | + +**채번 규칙 예시:** +``` +수주번호: SO-20260206-001 + - SO: 고정 접두사 + - 20260206: 날짜 (YYYYMMDD) + - 001: 일련번호 (3자리) + +발주번호: PO-{회사코드}-{YYYYMM}-{시퀀스} + - PO: 고정 접두사 + - COMPANY_A: 회사 코드 + - 202602: 년월 + - 0001: 시퀀스 (4자리) +``` + +### 8.4 엑셀 업로드 매핑 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `excel_mapping_template` | 엑셀 매핑 템플릿 | template_id, template_name, table_name, column_mapping (JSONB) | + +**엑셀 업로드 프로세스:** +``` +1. 사용자가 엑셀 업로드 +2. 헤더 행 읽기 +3. excel_mapping_template에서 매칭 템플릿 조회 +4. 컬럼 자동 매핑 +5. 데이터 검증 +6. DB 삽입 +``` + +### 8.5 데이터 관계 브리지 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `data_relationship_bridge` | 테이블 간 데이터 관계 중계 | bridge_id, source_table, source_id, target_table, target_id, relation_type | + +**용도:** +- 여러 테이블에 걸친 데이터 연결 추적 +- M:N 관계 관리 +- 데이터 통합 조회 + +--- + +## ⚙️ 9. 배치 및 자동화 시스템 + +### 9.1 배치 작업 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `batch_jobs` | 배치 작업 정의 | job_id, job_name, job_type, job_config (JSONB) | +| `batch_schedules` | 배치 스케줄 | schedule_id, job_id, cron_expression | +| `batch_execution_logs` | 배치 실행 로그 | log_id, job_id, execution_status, started_at, completed_at | +| `batch_job_executions` | 배치 작업 실행 | execution_id, job_id, execution_params | +| `batch_job_parameters` | 배치 작업 파라미터 | param_id, job_id, param_name, param_value | +| `batch_configs` | 배치 설정 | config_id, job_id, config_key, config_value | +| `batch_mappings` | 배치 매핑 | mapping_id, source_config, target_config | + +**배치 작업 예시:** +- 일일 재고 실사 +- 월말 마감 처리 +- 통계 데이터 집계 +- 외부 시스템 동기화 + +### 9.2 동적 폼 데이터 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `dynamic_form_data` | 동적 폼 데이터 | form_id, table_name, form_data (JSONB) | + +**용도:** +- 런타임에 정의된 폼의 데이터 저장 +- 유연한 데이터 구조 + +--- + +## 🌍 10. 다국어 시스템 + +### 10.1 다국어 관리 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `multi_lang_category` | 다국어 키 카테고리 (2단계 계층) | category_id, category_name, parent_category_id | +| `multi_lang_key_master` | 다국어 키 마스터 | key_id, key_name, category_id, default_text | +| `multi_lang_text` | 다국어 텍스트 | text_id, key_id, language_code, translated_text | +| `language_master` | 언어 마스터 | language_code, language_name, is_active | + +**다국어 구조:** +``` +multi_lang_category (카테고리) + └─ multi_lang_key_master (키) + └─ multi_lang_text (언어별 번역) + ├─ ko: 한국어 + ├─ en: 영어 + ├─ ja: 일본어 + └─ zh: 중국어 +``` + +**사용 예시:** +```typescript +// menu_info.lang_key = "menu.sales.order" +// multi_lang_key_master: key_name = "menu.sales.order" +// multi_lang_text: +// - ko: "수주관리" +// - en: "Sales Order" +// - ja: "受注管理" +``` + +--- + +## 📧 11. 메일 및 로그 시스템 + +### 11.1 메일 시스템 + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|-----------| +| `mail_log` | 메일 발송 로그 | log_id, to_email, subject, body, sent_at, sent_status | + +### 11.2 로그 테이블 + +| 테이블 | 역할 | +|--------|------| +| `ddl_execution_log` | DDL 실행 로그 | +| `file_down_log` | 파일 다운로드 로그 | +| `login_access_log` | 로그인 접근 로그 | + +### 11.3 변경 이력 테이블 (Audit Log) + +모든 주요 테이블에는 `{table_name}_log` 테이블이 있습니다: + +| 원본 테이블 | 이력 테이블 | 트리거 함수 | +|-------------|-------------|-------------| +| `carrier_contract_mng` | `carrier_contract_mng_log` | `carrier_contract_mng_log_trigger_func()` | +| `carrier_mng` | `carrier_mng_log` | `carrier_mng_log_trigger_func()` | +| `carrier_vehicle_mng` | `carrier_vehicle_mng_log` | `carrier_vehicle_mng_log_trigger_func()` | +| `defect_standard_mng` | `defect_standard_mng_log` | - | +| `delivery_route_mng` | `delivery_route_mng_log` | - | +| `dtg_management` | `dtg_management_log` | - | +| `equipment_consumable` | `equipment_consumable_log` | - | +| `equipment_inspection_item` | `equipment_inspection_item_log` | - | +| `equipment_mng` | `equipment_mng_log` | - | +| `inspection_equipment_mng` | `inspection_equipment_mng_log` | - | +| `item_info_20251202` | `item_info_20251202_log` | - | +| `logistics_cost_mng` | `logistics_cost_mng_log` | - | +| `order_table` | `order_table_log` | - | +| `safety_inspections` | `safety_inspections_log` | - | +| `sales_order_detail` | `sales_order_detail_log` | - | +| `supplier_mng` | `supplier_mng_log` | - | +| `work_instruction` | `work_instruction_log` | - | +| `work_instruction_detail` | `work_instruction_detail_log` | - | + +**이력 테이블 구조:** +```sql +CREATE TABLE {table_name}_log ( + id SERIAL PRIMARY KEY, + operation_type VARCHAR(10), -- INSERT, UPDATE, DELETE + original_id VARCHAR(500), -- 원본 레코드 ID + changed_column VARCHAR(100), + old_value TEXT, + new_value TEXT, + changed_by VARCHAR(100), + ip_address VARCHAR(50), + changed_at TIMESTAMP DEFAULT NOW(), + full_row_before JSONB, -- 변경 전 전체 행 + full_row_after JSONB -- 변경 후 전체 행 +); +``` + +**트리거 자동 기록:** +```sql +CREATE TRIGGER trg_{table_name}_log +AFTER INSERT OR UPDATE OR DELETE ON {table_name} +FOR EACH ROW +EXECUTE FUNCTION {table_name}_log_trigger_func(); +``` + +--- + +## 🔍 12. 데이터베이스 함수 및 트리거 + +### 12.1 주요 함수 + +#### 화면 관련 + +```sql +-- 화면 생성 시 메뉴 자동 생성 +CREATE FUNCTION auto_create_menu_for_screen() RETURNS TRIGGER; + +-- 화면 삭제 시 메뉴 비활성화 +CREATE FUNCTION auto_deactivate_menu_for_screen() RETURNS TRIGGER; +``` + +#### 통계 집계 + +```sql +-- 일일 운송 통계 집계 함수 +CREATE FUNCTION aggregate_daily_transport_statistics(target_date DATE DEFAULT CURRENT_DATE - 1) +RETURNS INTEGER; +``` + +#### 거리 계산 + +```sql +-- Haversine 거리 계산 (GPS 좌표) +CREATE FUNCTION calculate_distance(lat1 NUMERIC, lng1 NUMERIC, lat2 NUMERIC, lng2 NUMERIC) +RETURNS NUMERIC; + +-- 차량 위치 이력의 이전 위치로부터 거리 계산 +CREATE FUNCTION calculate_distance_from_prev() RETURNS TRIGGER; +``` + +#### 비즈니스 로직 + +```sql +-- 수주 잔량 자동 계산 +CREATE FUNCTION calculate_order_balance() RETURNS TRIGGER; + +-- 세금계산서 합계 자동 계산 +CREATE FUNCTION calculate_tax_invoice_total() RETURNS TRIGGER; + +-- 영업에서 프로젝트 자동 생성 +CREATE FUNCTION auto_create_project_from_sales(p_sales_no VARCHAR) RETURNS VARCHAR; + +-- 차량 상태 변경 시 운행 통계 계산 +CREATE FUNCTION calculate_trip_on_status_change() RETURNS TRIGGER; +``` + +### 12.2 주요 트리거 + +```sql +-- 화면 생성 시 메뉴 자동 생성 +CREATE TRIGGER trg_auto_create_menu_for_screen +AFTER INSERT ON screen_definitions +FOR EACH ROW +EXECUTE FUNCTION auto_create_menu_for_screen(); + +-- 화면 삭제 시 메뉴 비활성화 +CREATE TRIGGER trg_auto_deactivate_menu_for_screen +AFTER UPDATE ON screen_definitions +FOR EACH ROW +EXECUTE FUNCTION auto_deactivate_menu_for_screen(); + +-- 차량 위치 이력 거리 자동 계산 +CREATE TRIGGER trg_calculate_distance_from_prev +BEFORE INSERT ON vehicle_location_history +FOR EACH ROW +EXECUTE FUNCTION calculate_distance_from_prev(); + +-- 수주 잔량 자동 계산 +CREATE TRIGGER trg_calculate_order_balance +BEFORE INSERT OR UPDATE ON orders +FOR EACH ROW +EXECUTE FUNCTION calculate_order_balance(); + +-- 세금계산서 합계 자동 계산 +CREATE TRIGGER trg_calculate_tax_invoice_total +BEFORE INSERT OR UPDATE ON tax_invoice +FOR EACH ROW +EXECUTE FUNCTION calculate_tax_invoice_total(); +``` + +--- + +## 📈 13. 인덱스 전략 + +### 13.1 필수 인덱스 + +**모든 테이블:** +```sql +-- company_code 인덱스 (멀티테넌시 필수) +CREATE INDEX idx_{table_name}_company_code ON {table_name}(company_code); +``` + +### 13.2 복합 인덱스 + +**자주 함께 조회되는 컬럼:** +```sql +-- 회사별, 날짜별 조회 +CREATE INDEX idx_sales_company_date ON sales_order_mng(company_code, order_date DESC); + +-- 회사별, 상태별 조회 +CREATE INDEX idx_work_orders_company_status ON work_orders(company_code, status); + +-- 회사별, 거래처별 조회 +CREATE INDEX idx_purchase_company_supplier ON purchase_order_master(company_code, partner_objid); +``` + +### 13.3 부분 인덱스 + +**특정 조건의 데이터만:** +```sql +-- 활성 상태의 메뉴만 인덱싱 +CREATE INDEX idx_menu_active ON menu_info(company_code, menu_type) +WHERE status = 'active'; + +-- 미완료 작업지시만 인덱싱 +CREATE INDEX idx_work_orders_pending ON work_orders(company_code, wo_number) +WHERE status IN ('PENDING', 'IN_PROGRESS'); +``` + +### 13.4 JSONB 인덱스 + +**JSONB 컬럼 검색:** +```sql +-- GIN 인덱스 (JSONB 전체 검색) +CREATE INDEX idx_screen_layouts_config ON screen_layouts USING GIN (layout_config); + +-- JSONB 특정 키 인덱스 +CREATE INDEX idx_flow_step_action_type ON flow_step ((action_config->>'action_type')); +``` + +--- + +## 🔒 14. 데이터베이스 보안 + +### 14.1 암호화 컬럼 + +```sql +-- 외부 DB 비밀번호 암호화 +external_db_connections.password_encrypted TEXT + +-- 외부 REST API 인증 정보 암호화 +external_rest_api_connections.auth_config JSONB +``` + +**암호화 방식:** +- AES-256-GCM +- 애플리케이션 레벨에서 암호화/복호화 +- DB에는 암호화된 값만 저장 + +### 14.2 접근 제어 + +**회사별 데이터 격리:** +```sql +-- 모든 쿼리에 company_code 필터 필수 +WHERE company_code = $1 AND company_code != '*' +``` + +**사용자별 권한 관리:** +``` +user_info + → authority_sub_user + → authority_master + → rel_menu_auth + → 메뉴별 CRUD 권한 +``` + +### 14.3 감사 로그 + +- 모든 변경 사항은 `{table_name}_log` 테이블에 기록 +- IP 주소, 사용자 ID, 변경 시각 추적 +- 변경 전후 전체 행 데이터 JSONB로 저장 + +--- + +## 🚀 15. 성능 최적화 전략 + +### 15.1 쿼리 최적화 + +**1. company_code 필터링 항상 포함** +```sql +-- ✅ Good +SELECT * FROM sales_order_mng +WHERE company_code = 'COMPANY_A' + AND order_date >= '2026-01-01'; + +-- ❌ Bad (전체 스캔) +SELECT * FROM sales_order_mng +WHERE order_date >= '2026-01-01'; +``` + +**2. JOIN 시 company_code 매칭** +```sql +-- ✅ Good +SELECT so.*, c.customer_name +FROM sales_order_mng so +LEFT JOIN customer_mng c + ON so.customer_code = c.customer_code + AND so.company_code = c.company_code -- 필수! +WHERE so.company_code = 'COMPANY_A'; + +-- ❌ Bad (크로스 조인 발생) +SELECT so.*, c.customer_name +FROM sales_order_mng so +LEFT JOIN customer_mng c + ON so.customer_code = c.customer_code +WHERE so.company_code = 'COMPANY_A'; +``` + +**3. 인덱스 활용** +```sql +-- 복합 인덱스 순서 중요 +CREATE INDEX idx_sales_company_date_status +ON sales_order_mng(company_code, order_date, status); + +-- ✅ Good (인덱스 활용) +WHERE company_code = 'COMPANY_A' + AND order_date >= '2026-01-01' + AND status = 'CONFIRMED'; + +-- ❌ Bad (인덱스 미활용) +WHERE status = 'CONFIRMED' + AND order_date >= '2026-01-01' + AND company_code = 'COMPANY_A'; +``` + +### 15.2 대용량 데이터 처리 + +**파티셔닝:** +```sql +-- 날짜 기반 파티셔닝 (예시) +CREATE TABLE vehicle_location_history ( + ... +) PARTITION BY RANGE (recorded_at); + +CREATE TABLE vehicle_location_history_2026_01 +PARTITION OF vehicle_location_history +FOR VALUES FROM ('2026-01-01') TO ('2026-02-01'); +``` + +**배치 처리:** +```sql +-- 대량 삽입 시 COPY 사용 +COPY table_name FROM '/path/to/file.csv' WITH (FORMAT csv, HEADER true); + +-- 대량 업데이트 시 배치 단위로 +UPDATE table_name +SET status = 'PROCESSED' +WHERE id IN ( + SELECT id FROM table_name + WHERE status = 'PENDING' + LIMIT 1000 +); +``` + +### 15.3 캐싱 전략 + +**애플리케이션 레벨 캐싱:** +- 메타데이터 (table_labels, column_labels) → Redis +- 공통 코드 (comm_code) → Redis +- 메뉴 정보 (menu_info) → Redis +- 사용자 권한 (rel_menu_auth) → Redis + +**쿼리 결과 캐싱:** +- 통계 데이터 +- 집계 데이터 +- 읽기 전용 마스터 데이터 + +--- + +## 📝 16. 마이그레이션 가이드 + +### 16.1 마이그레이션 파일 목록 + +``` +db/migrations/ +├── 037_add_parent_group_to_screen_groups.sql +├── 050_create_work_orders_table.sql +├── 051_insert_work_order_screen_definition.sql +├── 052_insert_work_order_screen_layout.sql +├── 054_create_screen_management_enhancement.sql +├── 055_create_customer_item_prices_table.sql +└── plm_schema_20260120.sql (전체 스키마 덤프) +``` + +### 16.2 마이그레이션 실행 순서 + +**1. 테이블 생성** +```sql +CREATE TABLE {table_name} ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + company_code VARCHAR(20) NOT NULL, + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500), + -- 비즈니스 컬럼들... +); +``` + +**2. 인덱스 생성** +```sql +CREATE INDEX idx_{table_name}_company_code ON {table_name}(company_code); +CREATE INDEX idx_{table_name}_created_date ON {table_name}(created_date DESC); +-- 기타 필요한 인덱스들... +``` + +**3. 메타데이터 등록** +```sql +-- table_labels +INSERT INTO table_labels (table_name, table_label, description) +VALUES ('{table_name}', '{한글명}', '{설명}') +ON CONFLICT (table_name) DO UPDATE SET ...; + +-- table_type_columns +INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, ...) +VALUES ...; + +-- column_labels (레거시 호환) +INSERT INTO column_labels (table_name, column_name, column_label, ...) +VALUES ...; +``` + +**4. 코멘트 추가** +```sql +COMMENT ON TABLE {table_name} IS '{테이블 설명}'; +COMMENT ON COLUMN {table_name}.{column_name} IS '{컬럼 설명}'; +``` + +**5. 화면 정의 (선택)** +```sql +-- screen_definitions +INSERT INTO screen_definitions (screen_code, screen_name, table_name, ...) +VALUES ...; + +-- 트리거 자동 발동 → menu_info 자동 생성 +``` + +### 16.3 마이그레이션 롤백 + +```sql +-- 화면 정의 삭제 +DELETE FROM screen_definitions WHERE screen_code = '{screen_code}'; + +-- 메뉴 삭제 (자동 비활성화되었을 것) +DELETE FROM menu_info WHERE screen_code = '{screen_code}'; + +-- 메타데이터 삭제 +DELETE FROM column_labels WHERE table_name = '{table_name}'; +DELETE FROM table_type_columns WHERE table_name = '{table_name}'; +DELETE FROM table_labels WHERE table_name = '{table_name}'; + +-- 인덱스 삭제 +DROP INDEX IF EXISTS idx_{table_name}_company_code; + +-- 테이블 삭제 +DROP TABLE IF EXISTS {table_name}; +``` + +--- + +## 🎯 17. 데이터베이스 설계 원칙 요약 + +### 17.1 ABSOLUTE MUST (절대 필수) + +1. **모든 테이블에 company_code VARCHAR(20) NOT NULL** +2. **모든 쿼리에 company_code 필터 포함** +3. **JOIN 시 company_code 매칭 조건 포함** +4. **모든 테이블에 company_code 인덱스 생성** +5. **일반 회사는 company_code != '*' 필터 필수** + +### 17.2 표준 테이블 구조 + +```sql +CREATE TABLE {table_name} ( + -- 기본 컬럼 (표준 5종 세트) + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500), + company_code VARCHAR(20) NOT NULL, + + -- 비즈니스 컬럼들 + ... +); + +-- 필수 인덱스 +CREATE INDEX idx_{table_name}_company_code ON {table_name}(company_code); +``` + +### 17.3 메타데이터 등록 필수 + +동적 테이블 생성 시: +1. `table_labels` 등록 +2. `table_type_columns` 등록 (회사별) +3. `column_labels` 등록 (레거시 호환) +4. 코멘트 추가 + +### 17.4 쿼리 패턴 + +```sql +-- ✅ 표준 SELECT +SELECT * FROM table_name +WHERE company_code = $1 + AND company_code != '*' +ORDER BY created_date DESC; + +-- ✅ 표준 JOIN +SELECT a.*, b.name +FROM table_a a +LEFT JOIN table_b b + ON a.ref_id = b.id + AND a.company_code = b.company_code -- 필수! +WHERE a.company_code = $1; + +-- ✅ 표준 집계 +SELECT + category, + COUNT(*) as total, + SUM(amount) as total_amount +FROM sales +WHERE company_code = $1 +GROUP BY category; +``` + +--- + +## 📚 18. 참고 자료 + +### 18.1 관련 문서 + +``` +docs/ +├── DB_ARCHITECTURE_ANALYSIS.md -- 기존 상세 DB 분석 문서 +├── backend-architecture-analysis.md -- 백엔드 아키텍처 분석 +├── frontend-architecture-analysis.md -- 프론트엔드 아키텍처 분석 +└── kjs/ + ├── 멀티테넌시_구현_현황_분석_보고서.md + ├── 테이블_타입관리_성능최적화_결과.md + └── 카테고리_시스템_최종_완료_보고서.md +``` + +### 18.2 스키마 파일 + +``` +db/ +├── plm_schema_20260120.sql -- 전체 스키마 덤프 +└── migrations/ + ├── 037_add_parent_group_to_screen_groups.sql + ├── 050_create_work_orders_table.sql + ├── 051_insert_work_order_screen_definition.sql + ├── 052_insert_work_order_screen_layout.sql + ├── 054_create_screen_management_enhancement.sql + └── 055_create_customer_item_prices_table.sql +``` + +### 18.3 백엔드 서비스 매핑 + +```typescript +// backend-node/src/services/ + +// 화면 관리 +screenManagementService.ts → screen_definitions, screen_layouts + +// 테이블 관리 +tableManagementService.ts → table_labels, table_type_columns, column_labels + +// 메뉴 관리 +menuService.ts → menu_info, menu_screen_groups + +// 카테고리 관리 +categoryTreeService.ts → table_column_category_values + +// 플로우 관리 +flowDefinitionService.ts → flow_definition, flow_step +flowExecutionService.ts → flow_data_status, flow_audit_log + +// 데이터플로우 +dataflowService.ts → dataflow_diagrams, screen_data_flows + +// 외부 연동 +externalDbConnectionService.ts → external_db_connections +externalRestApiConnectionService.ts → external_rest_api_connections + +// 배치 +batchService.ts → batch_jobs, batch_execution_logs + +// 인증/권한 +authService.ts → user_info, auth_tokens +roleService.ts → authority_master, rel_menu_auth +``` + +--- + +## 🎬 19. 비즈니스 워크플로우 통합 예시 + +### 19.1 수주 → 생산 → 출하 전체 플로우 + +``` +[영업팀] +1. 견적 요청 접수 (estimate_mgmt) +2. 견적서 작성 및 발송 +3. 고객 승인 + +[영업팀] +4. 수주 등록 (sales_order_mng) + → sales_order_detail (품목별 상세) + → contract_mgmt (계약 체결) + +[생산관리팀] +5. 생산 계획 수립 (order_plan_mgmt) + → 자재 소요 계획 (MRP) + → 공정별 계획 + +[구매팀] +6. 구매 요청 (sales_request_master) + → 공급처 선정 (supplier_mng) + → 발주서 작성 (purchase_order_master) + → 발주서 상세 (purchase_order_part) + +[자재팀] +7. 입고 예정 (delivery_history) + → 검수 (check_report_mng) + → 입고 확정 (receiving) + → 재고 반영 (inventory_stock) + +[생산팀] +8. 작업지시 발행 (work_orders) + → 자재 출고 (material_release) + → 생산 실행 (production_record) + +[품질팀] +9. 품질 검사 (inspection_standard) + → 합격/불합격 판정 + → 완제품 입고 (inventory_stock) + +[물류팀] +10. 출하 계획 (shipment_plan) + → 출하 지시 (shipment_instruction) + → 차량 배차 (vehicles) + → 출고 처리 (outbound_mng) + +[운송팀] +11. 운행 시작 + → GPS 추적 (vehicle_location_history) + → 운행 로그 (transport_logs) + +[고객] +12. 납품 완료 + → 배송 상태 업데이트 (delivery_status) + → 세금계산서 발행 (tax_invoice) + +[재무팀] +13. 정산 + → 매출 인식 + → 원가 계산 (profit_loss) +``` + +### 19.2 데이터 흐름 추적 + +``` +플로우 정의 (flow_definition): "수주-생산-출하 플로우" + │ + ├─ Step 1: 수주 접수 (flow_step) + │ └─ 데이터: sales_order_mng + │ └─ flow_data_status: STEP_1_COMPLETED + │ + ├─ Step 2: 생산 계획 (flow_step) + │ └─ 데이터: order_plan_mgmt + │ └─ flow_data_status: STEP_2_COMPLETED + │ + ├─ Step 3: 발주 처리 (flow_step) + │ └─ 데이터: purchase_order_master + │ └─ flow_data_status: STEP_3_COMPLETED + │ + ├─ Step 4: 입고 처리 (flow_step) + │ └─ 데이터: receiving, inventory_stock + │ └─ flow_data_status: STEP_4_COMPLETED + │ + ├─ Step 5: 생산 실행 (flow_step) + │ └─ 데이터: work_orders, production_record + │ └─ flow_data_status: STEP_5_COMPLETED + │ + └─ Step 6: 출하 완료 (flow_step) + └─ 데이터: shipment_plan, outbound_mng + └─ flow_data_status: COMPLETED + +각 단계 변경 이력: flow_audit_log +외부 시스템 연동: flow_integration_log +``` + +--- + +**문서 작성자**: Cursor AI (DB Specialist Agent) +**문서 버전**: 2.0 +**작성일**: 2026-02-06 +**기반 스키마**: plm_schema_20260120.sql (337 테이블) +**목적**: WACE ERP 전체 워크플로우 문서화를 위한 DB 구조 분석 + +--- diff --git a/docs/WACE_SYSTEM_WORKFLOW.md b/docs/WACE_SYSTEM_WORKFLOW.md new file mode 100644 index 00000000..b9cd9f23 --- /dev/null +++ b/docs/WACE_SYSTEM_WORKFLOW.md @@ -0,0 +1,955 @@ +# WACE ERP 시스템 전체 워크플로우 문서 + +> 작성일: 2026-02-06 +> 분석 방법: Multi-Agent System (Backend + Frontend + DB 전문가 병렬 분석) + +--- + +## 목차 + +1. [시스템 개요](#1-시스템-개요) +2. [기술 스택](#2-기술-스택) +3. [전체 아키텍처](#3-전체-아키텍처) +4. [백엔드 아키텍처](#4-백엔드-아키텍처) +5. [프론트엔드 아키텍처](#5-프론트엔드-아키텍처) +6. [데이터베이스 구조](#6-데이터베이스-구조) +7. [인증/인가 워크플로우](#7-인증인가-워크플로우) +8. [화면 디자이너 워크플로우](#8-화면-디자이너-워크플로우) +9. [사용자 업무 워크플로우](#9-사용자-업무-워크플로우) +10. [플로우 엔진 워크플로우](#10-플로우-엔진-워크플로우) +11. [데이터플로우 시스템](#11-데이터플로우-시스템) +12. [대시보드 시스템](#12-대시보드-시스템) +13. [배치/스케줄 시스템](#13-배치스케줄-시스템) +14. [멀티테넌시 아키텍처](#14-멀티테넌시-아키텍처) +15. [외부 연동](#15-외부-연동) +16. [배포 환경](#16-배포-환경) + +--- + +## 1. 시스템 개요 + +WACE는 **로우코드(Low-Code) ERP 플랫폼**이다. 관리자가 코드 없이 드래그앤드롭으로 업무 화면을 설계하면, 사용자는 해당 화면으로 바로 업무를 처리할 수 있는 구조다. + +### 핵심 컨셉 + +``` +관리자 → 화면 디자이너로 화면 설계 → 메뉴에 연결 + ↓ +사용자 → 메뉴 클릭 → 화면 자동 렌더링 → 업무 수행 +``` + +### 주요 특징 + +- **드래그앤드롭 화면 디자이너**: 코드 없이 UI 구성 +- **동적 컴포넌트 시스템**: V2 통합 컴포넌트 10종으로 모든 UI 표현 +- **플로우 엔진**: 워크플로우(승인, 이동 등) 자동화 +- **데이터플로우**: 비즈니스 로직을 비주얼 다이어그램으로 설계 +- **멀티테넌시**: 회사별 완벽한 데이터 격리 +- **다국어 지원**: KR/EN/CN 다국어 라벨 관리 + +--- + +## 2. 기술 스택 + +| 영역 | 기술 | 비고 | +|------|------|------| +| **Frontend** | Next.js 15 (App Router) | React 19, TypeScript | +| **UI 라이브러리** | shadcn/ui + Radix UI | Tailwind CSS 4 | +| **상태 관리** | React Context + Zustand | React Query (서버 상태) | +| **Backend** | Node.js + Express | TypeScript | +| **Database** | PostgreSQL | Raw Query (ORM 미사용) | +| **인증** | JWT | 자동 갱신, 세션 관리 | +| **빌드/배포** | Docker | dev/prod 분리 | +| **포트** | FE: 9771(dev)/5555(prod) | BE: 8080 | + +--- + +## 3. 전체 아키텍처 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 사용자 브라우저 │ +│ Next.js App (React 19 + shadcn/ui + Tailwind CSS) │ +│ ├── 인증: JWT + Cookie + localStorage │ +│ ├── 상태: Context + Zustand + React Query │ +│ └── API: Axios Client (lib/api/) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ HTTP/JSON (JWT Bearer Token) + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ Express Backend (Node.js) │ +│ ├── Middleware: Helmet → CORS → RateLimit → Auth → Permission │ +│ ├── Routes: 60+ 모듈 │ +│ ├── Controllers: 69개 │ +│ ├── Services: 87개 │ +│ └── Database: pg Pool (Raw Query) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ TCP/SQL + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ PostgreSQL Database │ +│ ├── 시스템 테이블: 사용자, 회사, 메뉴, 권한, 화면 │ +│ ├── 메타데이터: 테이블/컬럼 정의, 코드, 카테고리 │ +│ ├── 비즈니스: 동적 생성 테이블 (화면별) │ +│ └── 멀티테넌시: 모든 테이블에 company_code │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. 백엔드 아키텍처 + +### 4.1 디렉토리 구조 + +``` +backend-node/src/ +├── app.ts # Express 앱 진입점 +├── config/ # 환경설정, Multer +├── controllers/ # 69개 컨트롤러 +├── services/ # 87개 서비스 +├── routes/ # 60+ 라우트 모듈 +├── middleware/ # 인증, 권한, 에러 처리 +│ ├── authMiddleware.ts # JWT 인증 +│ ├── permissionMiddleware.ts # 3단계 권한 체크 +│ ├── superAdminMiddleware.ts # 슈퍼관리자 전용 +│ └── errorHandler.ts # 전역 에러 처리 +├── database/ # DB 연결, 커넥터 팩토리 +│ ├── db.ts # PostgreSQL Pool +│ ├── DatabaseConnectorFactory.ts +│ ├── PostgreSQLConnector.ts +│ ├── MySQLConnector.ts +│ └── MariaDBConnector.ts +├── types/ # TypeScript 타입 (26개) +└── utils/ # 유틸리티 (16개) +``` + +### 4.2 미들웨어 스택 (실행 순서) + +``` +요청 → Helmet (보안 헤더) + → Compression (응답 압축) + → Body Parser (JSON/URLEncoded, 10MB) + → CORS (교차 출처 허용) + → Rate Limiter (10,000 req/min) + → Token Refresh (자동 갱신) + → Route Handlers (비즈니스 로직) + → Error Handler (전역 에러 처리) +``` + +### 4.3 API 라우트 도메인별 분류 + +#### 인증/사용자 관리 +| 라우트 | 역할 | +|--------|------| +| `/api/auth` | 로그인, 로그아웃, 토큰 갱신, 회사 전환 | +| `/api/admin/users` | 사용자 CRUD, 비밀번호 초기화, 상태 변경 | +| `/api/company-management` | 회사 CRUD | +| `/api/departments` | 부서 관리 | +| `/api/roles` | 권한 그룹 관리 | + +#### 화면/메뉴 관리 +| 라우트 | 역할 | +|--------|------| +| `/api/screen-management` | 화면 정의 CRUD, 그룹, 파일, 임베딩 | +| `/api/admin/menus` | 메뉴 트리 CRUD, 화면 할당 | +| `/api/table-management` | 테이블 CRUD, 엔티티 조인, 카테고리 | +| `/api/common-codes` | 공통 코드/카테고리 관리 | +| `/api/multilang` | 다국어 키/번역 관리 | + +#### 데이터 관리 +| 라우트 | 역할 | +|--------|------| +| `/api/data` | 동적 테이블 CRUD, 조인 쿼리 | +| `/api/data/:tableName` | 특정 테이블 데이터 조회 | +| `/api/data/join` | 조인 쿼리 실행 | +| `/api/dynamic-form` | 동적 폼 데이터 저장 | +| `/api/entity-search` | 엔티티 검색 | +| `/api/entity-reference` | 엔티티 참조 | +| `/api/numbering-rules` | 채번 규칙 관리 | +| `/api/cascading-*` | 연쇄 드롭다운 관계 | + +#### 자동화 +| 라우트 | 역할 | +|--------|------| +| `/api/flow` | 플로우 정의/단계/연결/실행 | +| `/api/dataflow` | 데이터플로우 다이어그램/실행 | +| `/api/batch-configs` | 배치 작업 설정 | +| `/api/batch-management` | 배치 작업 관리 | +| `/api/batch-execution-logs` | 배치 실행 로그 | + +#### 대시보드/리포트 +| 라우트 | 역할 | +|--------|------| +| `/api/dashboards` | 대시보드 CRUD, 쿼리 실행 | +| `/api/reports` | 리포트 생성 | + +#### 외부 연동 +| 라우트 | 역할 | +|--------|------| +| `/api/external-db-connections` | 외부 DB 연결 (PostgreSQL, MySQL, MariaDB, MSSQL, Oracle) | +| `/api/external-rest-api-connections` | 외부 REST API 연결 | +| `/api/mail` | 메일 발송/수신/템플릿 | +| `/api/tax-invoice` | 세금계산서 | + +#### 특수 도메인 +| 라우트 | 역할 | +|--------|------| +| `/api/delivery` | 배송/화물 관리 | +| `/api/risk-alerts` | 위험 알림 | +| `/api/todos` | 할일 관리 | +| `/api/bookings` | 예약 관리 | +| `/api/digital-twin` | 디지털 트윈 (야드 모니터링) | +| `/api/schedule` | 스케줄 자동 생성 | +| `/api/vehicle` | 차량 운행 | +| `/api/driver` | 운전자 관리 | +| `/api/files` | 파일 업로드/다운로드 | +| `/api/ddl` | DDL 실행 (슈퍼관리자 전용) | + +### 4.4 서비스 레이어 패턴 + +```typescript +// 표준 서비스 패턴 +class ExampleService { + // 목록 조회 (멀티테넌시 적용) + async findAll(companyCode: string, filters?: any) { + if (companyCode === "*") { + // 슈퍼관리자: 전체 데이터 + return await db.query("SELECT * FROM table ORDER BY company_code"); + } else { + // 일반 사용자: 자기 회사 데이터만 + return await db.query( + "SELECT * FROM table WHERE company_code = $1", + [companyCode] + ); + } + } +} +``` + +### 4.5 에러 처리 전략 + +```typescript +// 전역 에러 핸들러 (errorHandler.ts) +- PostgreSQL 에러: 중복키(23505), 외래키(23503), 널 제약(23502) 등 +- JWT 에러: 만료, 유효하지 않은 토큰 +- 일반 에러: 500 Internal Server Error +- 개발 환경: 상세 에러 스택 포함 +- 운영 환경: 일반적인 에러 메시지만 반환 +``` + +--- + +## 5. 프론트엔드 아키텍처 + +### 5.1 디렉토리 구조 + +``` +frontend/ +├── app/ # Next.js App Router +│ ├── (auth)/ # 인증 (로그인) +│ ├── (main)/ # 메인 앱 (인증 필요) +│ ├── (pop)/ # 모바일/팝업 +│ └── (admin)/ # 특수 관리자 +├── components/ # React 컴포넌트 +│ ├── screen/ # 화면 디자이너 & 뷰어 +│ ├── admin/ # 관리 기능 +│ ├── dashboard/ # 대시보드 위젯 +│ ├── dataflow/ # 데이터플로우 디자이너 +│ ├── v2/ # V2 통합 컴포넌트 +│ ├── ui/ # shadcn/ui 기본 컴포넌트 +│ └── report/ # 리포트 디자이너 +├── lib/ +│ ├── api/ # API 클라이언트 (57개 모듈) +│ ├── registry/ # 컴포넌트 레지스트리 (482개) +│ ├── utils/ # 유틸리티 +│ └── v2-core/ # V2 코어 로직 +├── contexts/ # React Context (인증, 메뉴, 화면 등) +├── hooks/ # Custom Hooks +├── stores/ # Zustand 상태관리 +└── middleware.ts # Next.js 인증 미들웨어 +``` + +### 5.2 페이지 라우팅 구조 + +``` +/login → 로그인 +/main → 메인 대시보드 +/screens/[screenId] → 동적 화면 뷰어 (사용자) + +/admin/screenMng/screenMngList → 화면 관리 +/admin/screenMng/dashboardList → 대시보드 관리 +/admin/screenMng/reportList → 리포트 관리 +/admin/systemMng/tableMngList → 테이블 관리 +/admin/systemMng/commonCodeList → 공통코드 관리 +/admin/systemMng/dataflow → 데이터플로우 관리 +/admin/systemMng/i18nList → 다국어 관리 +/admin/userMng/userMngList → 사용자 관리 +/admin/userMng/companyList → 회사 관리 +/admin/userMng/rolesList → 권한 관리 +/admin/automaticMng/flowMgmtList → 플로우 관리 +/admin/automaticMng/batchmngList → 배치 관리 +/admin/automaticMng/mail/* → 메일 시스템 +/admin/menu → 메뉴 관리 + +/dashboard/[dashboardId] → 대시보드 뷰어 +/pop/work → 모바일 작업 화면 +``` + +### 5.3 V2 통합 컴포넌트 시스템 + +**"하나의 컴포넌트, 여러 모드"** 철학으로 설계된 10개 통합 컴포넌트: + +| 컴포넌트 | 모드 | 역할 | +|----------|------|------| +| **V2Input** | text, number, password, slider, color | 텍스트/숫자 입력 | +| **V2Select** | dropdown, radio, checkbox, tag, toggle | 선택 입력 | +| **V2Date** | date, datetime, time, range | 날짜/시간 입력 | +| **V2List** | table, card, kanban, list | 데이터 목록 표시 | +| **V2Layout** | grid, split-panel, flex | 레이아웃 구성 | +| **V2Group** | tab, accordion, section, modal | 그룹 컨테이너 | +| **V2Media** | image, video, audio, file | 미디어 표시 | +| **V2Biz** | flow, rack, numbering-rule | 비즈니스 로직 | +| **V2Hierarchy** | tree, org-chart, BOM, cascading | 계층 구조 | +| **V2Repeater** | inline-table, modal, button | 반복 데이터 | + +### 5.4 API 클라이언트 규칙 + +```typescript +// 절대 금지: fetch 직접 사용 +const res = await fetch('/api/flow/definitions'); // ❌ + +// 반드시 사용: lib/api/ 클라이언트 +import { getFlowDefinitions } from '@/lib/api/flow'; +const res = await getFlowDefinitions(); // ✅ +``` + +환경별 URL 자동 처리: +| 환경 | 프론트엔드 | 백엔드 API | +|------|-----------|-----------| +| 로컬 개발 | localhost:9771 | localhost:8080/api | +| 운영 | v1.vexplor.com | api.vexplor.com/api | + +### 5.5 상태 관리 체계 + +``` +전역 상태 +├── AuthContext → 인증/세션/토큰 +├── MenuContext → 메뉴 트리/권한 +├── ScreenPreviewContext → 프리뷰 모드 +├── ScreenMultiLangContext → 다국어 라벨 +├── TableOptionsContext → 테이블 옵션 +└── ActiveTabContext → 활성 탭 + +로컬 상태 +├── Zustand Stores → 화면 디자이너 상태, 사용자 상태 +└── React Query → 서버 데이터 캐시 (5분 stale, 30분 GC) +``` + +### 5.6 레지스트리 시스템 + +```typescript +// 컴포넌트 등록 (482개 등록됨) +ComponentRegistry.registerComponent({ + id: "v2-input", + name: "통합 입력", + category: ComponentCategory.V2, + component: V2Input, + configPanel: V2InputConfigPanel, + defaultConfig: { inputType: "text" } +}); + +// 동적 렌더링 + +``` + +--- + +## 6. 데이터베이스 구조 + +### 6.1 테이블 도메인별 분류 + +#### 사용자/인증/회사 +| 테이블 | 역할 | +|--------|------| +| `company_mng` | 회사 마스터 | +| `user_info` | 사용자 정보 | +| `user_info_history` | 사용자 변경 이력 | +| `user_dept` | 사용자-부서 매핑 | +| `dept_info` | 부서 정보 | +| `authority_master` | 권한 그룹 마스터 | +| `authority_sub_user` | 사용자-권한 매핑 | +| `login_access_log` | 로그인 로그 | + +#### 메뉴/화면 +| 테이블 | 역할 | +|--------|------| +| `menu_info` | 메뉴 트리 구조 | +| `screen_definitions` | 화면 정의 (screenId, 테이블명 등) | +| `screen_layouts_v2` | V2 레이아웃 (JSON) | +| `screen_layouts` | V1 레이아웃 (레거시) | +| `screen_groups` | 화면 그룹 (계층구조) | +| `screen_group_screens` | 화면-그룹 매핑 | +| `screen_menu_assignments` | 화면-메뉴 할당 | +| `screen_field_joins` | 화면 필드 조인 설정 | +| `screen_data_flows` | 화면 데이터 플로우 | +| `screen_table_relations` | 화면-테이블 관계 | + +#### 메타데이터 +| 테이블 | 역할 | +|--------|------| +| `table_type_columns` | 테이블 타입별 컬럼 정의 (회사별) | +| `table_column_category_values` | 컬럼 카테고리 값 | +| `code_category` | 공통 코드 카테고리 | +| `code_info` | 공통 코드 값 | +| `category_column_mapping` | 카테고리-컬럼 매핑 | +| `cascading_relation` | 연쇄 드롭다운 관계 | +| `numbering_rules` | 채번 규칙 | +| `numbering_rule_parts` | 채번 규칙 파트 | + +#### 플로우/자동화 +| 테이블 | 역할 | +|--------|------| +| `flow_definition` | 플로우 정의 | +| `flow_step` | 플로우 단계 | +| `flow_step_connection` | 플로우 단계 연결 | +| `node_flows` | 노드 플로우 (버튼 액션) | +| `dataflow_diagrams` | 데이터플로우 다이어그램 | +| `batch_definitions` | 배치 작업 정의 | +| `batch_schedules` | 배치 스케줄 | +| `batch_execution_logs` | 배치 실행 로그 | + +#### 외부 연동 +| 테이블 | 역할 | +|--------|------| +| `external_db_connections` | 외부 DB 연결 정보 | +| `external_rest_api_connections` | 외부 REST API 연결 | + +#### 다국어 +| 테이블 | 역할 | +|--------|------| +| `multi_lang_key_master` | 다국어 키 마스터 | + +#### 기타 +| 테이블 | 역할 | +|--------|------| +| `work_history` | 작업 이력 | +| `todo_items` | 할일 목록 | +| `file_uploads` | 파일 업로드 | +| `ddl_audit_log` | DDL 감사 로그 | + +### 6.2 동적 테이블 생성 패턴 + +관리자가 화면 생성 시 비즈니스 테이블이 동적으로 생성된다: + +```sql +CREATE TABLE "dynamic_table_name" ( + "id" VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + "created_date" TIMESTAMP DEFAULT now(), + "updated_date" TIMESTAMP DEFAULT now(), + "writer" VARCHAR(500), + "company_code" VARCHAR(500), -- 멀티테넌시 필수! + -- 사용자 정의 컬럼들 (모두 VARCHAR(500)) + "product_name" VARCHAR(500), + "price" VARCHAR(500), + ... +); +CREATE INDEX idx_dynamic_company ON "dynamic_table_name"(company_code); +``` + +### 6.3 테이블 관계도 + +``` +company_mng (company_code PK) + │ + ├── user_info (company_code FK) + │ ├── authority_sub_user (user_id FK) + │ └── user_dept (user_id FK) + │ + ├── menu_info (company_code) + │ └── screen_menu_assignments (menu_objid FK) + │ + ├── screen_definitions (company_code) + │ ├── screen_layouts_v2 (screen_id FK) + │ ├── screen_groups → screen_group_screens (screen_id FK) + │ └── screen_field_joins (screen_id FK) + │ + ├── authority_master (company_code) + │ └── authority_sub_user (master_objid FK) + │ + ├── flow_definition (company_code) + │ ├── flow_step (flow_id FK) + │ └── flow_step_connection (flow_id FK) + │ + └── [동적 비즈니스 테이블들] (company_code) +``` + +--- + +## 7. 인증/인가 워크플로우 + +### 7.1 로그인 프로세스 + +``` +┌─── 사용자 ───┐ ┌─── 프론트엔드 ───┐ ┌─── 백엔드 ───┐ ┌─── DB ───┐ +│ │ │ │ │ │ │ │ +│ ID/PW 입력 │────→│ POST /auth/login │────→│ 비밀번호 검증 │────→│ user_info│ +│ │ │ │ │ │ │ 조회 │ +│ │ │ │ │ JWT 토큰 생성 │ │ │ +│ │ │ │←────│ 토큰 반환 │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ localStorage 저장│ │ │ │ │ +│ │ │ Cookie 저장 │ │ │ │ │ +│ │ │ /main 리다이렉트 │ │ │ │ │ +└──────────────┘ └──────────────────┘ └──────────────┘ └──────────┘ +``` + +### 7.2 JWT 토큰 관리 + +``` +토큰 저장: localStorage (주 저장소) + Cookie (SSR 미들웨어용) + +자동 갱신: +├── 10분마다 만료 시간 체크 +├── 만료 30분 전: 백그라운드 자동 갱신 +├── 401 응답 시: 즉시 갱신 시도 +└── 갱신 실패 시: /login 리다이렉트 + +세션 관리: +├── 데스크톱: 30분 비활성 → 세션 만료 (5분 전 경고) +└── 모바일: 24시간 비활성 → 세션 만료 (1시간 전 경고) +``` + +### 7.3 권한 체계 (3단계) + +``` +SUPER_ADMIN (company_code = "*") +├── 모든 회사 데이터 접근 가능 +├── DDL 실행 가능 +├── 시스템 설정 변경 +└── 다른 회사로 전환 (switch-company) + +COMPANY_ADMIN (userType = "COMPANY_ADMIN") +├── 자기 회사 데이터만 접근 +├── 사용자 관리 가능 +└── 메뉴/화면 관리 가능 + +USER (일반 사용자) +├── 자기 회사 데이터만 접근 +├── 권한 그룹에 따른 메뉴 접근 +└── 할당된 화면만 사용 가능 +``` + +--- + +## 8. 화면 디자이너 워크플로우 + +### 8.1 관리자: 화면 설계 + +``` +Step 1: 화면 생성 + └→ /admin/screenMng/screenMngList + └→ "새 화면" 클릭 → 화면명, 설명, 메인 테이블 입력 + +Step 2: 화면 디자이너 진입 (ScreenDesigner.tsx) + ├── 좌측 패널: 컴포넌트 팔레트 (V2 컴포넌트 10종) + ├── 중앙 캔버스: 드래그앤드롭 영역 + └── 우측 패널: 선택된 컴포넌트 속성 설정 + +Step 3: 컴포넌트 배치 + └→ V2Input 드래그 → 캔버스 배치 → 속성 설정: + ├── 위치: x, y 좌표 + ├── 크기: width, height + ├── 데이터 바인딩: columnName = "product_name" + ├── 라벨: "제품명" + ├── 조건부 표시: 특정 조건에서만 보이기 + └── 플로우 연결: 버튼 클릭 시 실행할 플로우 + +Step 4: 레이아웃 저장 + └→ screen_layouts_v2 테이블에 JSON 형태로 저장 + └→ Zod 스키마 검증 → V2 형식 우선, V1 호환 저장 + +Step 5: 메뉴에 화면 할당 + └→ /admin/menu → 메뉴 트리에서 "제품 관리" 선택 + └→ 화면 연결 (screen_menu_assignments) +``` + +### 8.2 화면 레이아웃 저장 구조 (V2) + +```json +{ + "version": "v2", + "components": [ + { + "id": "comp-1", + "componentType": "v2-input", + "position": { "x": 100, "y": 50 }, + "size": { "width": 200, "height": 40 }, + "config": { + "inputType": "text", + "columnName": "product_name", + "label": "제품명", + "required": true + } + }, + { + "id": "comp-2", + "componentType": "v2-list", + "position": { "x": 100, "y": 150 }, + "size": { "width": 600, "height": 400 }, + "config": { + "listType": "table", + "tableName": "products", + "columns": ["product_name", "price", "quantity"] + } + } + ] +} +``` + +--- + +## 9. 사용자 업무 워크플로우 + +### 9.1 전체 흐름 + +``` +사용자 로그인 + ↓ +메인 대시보드 (/main) + ↓ +좌측 메뉴에서 "제품 관리" 클릭 + ↓ +/screens/[screenId] 라우팅 + ↓ +InteractiveScreenViewer 렌더링 + ├── screen_definitions에서 화면 정보 로드 + ├── screen_layouts_v2에서 레이아웃 JSON 로드 + ├── V2 → Legacy 변환 (호환성) + └── 메인 테이블 데이터 자동 로드 + ↓ +컴포넌트별 렌더링 + ├── V2Input → formData 바인딩 + ├── V2List → 테이블 데이터 표시 + ├── V2Select → 드롭다운/라디오 선택 + └── Button → 플로우/액션 연결 + ↓ +사용자 인터랙션 + ├── 폼 입력 → formData 업데이트 + ├── 테이블 행 선택 → selectedRowsData 업데이트 + └── 버튼 클릭 → 플로우 실행 + ↓ +플로우 실행 (nodeFlowButtonExecutor) + ├── Step 1: 데이터 검증 + ├── Step 2: API 호출 (INSERT/UPDATE/DELETE) + ├── Step 3: 성공/실패 처리 + └── Step 4: 테이블 자동 새로고침 +``` + +### 9.2 조건부 표시 워크플로우 + +``` +관리자 설정: + "특별 할인 입력" 컴포넌트 + └→ 조건: product_type === "PREMIUM" 일 때만 표시 + +사용자 사용: + 1. 화면 진입 → evaluateConditional() 실행 + 2. product_type ≠ "PREMIUM" → "특별 할인 입력" 숨김 + 3. 사용자가 product_type을 "PREMIUM"으로 변경 + 4. formData 업데이트 → evaluateConditional() 재평가 + 5. product_type === "PREMIUM" → "특별 할인 입력" 표시! +``` + +--- + +## 10. 플로우 엔진 워크플로우 + +### 10.1 플로우 정의 (관리자) + +``` +/admin/automaticMng/flowMgmtList + ↓ +플로우 생성: + ├── 이름: "제품 승인 플로우" + ├── 테이블: "products" + └── 단계 정의: + Step 1: "신청" (requester) + Step 2: "부서장 승인" (manager) + Step 3: "최종 승인" (director) + 연결: Step 1 → Step 2 → Step 3 +``` + +### 10.2 플로우 실행 (사용자) + +``` +1. 사용자: 제품 신청 + └→ "저장" 버튼 클릭 + └→ flowApi.startFlow() → 상태: "부서장 승인 대기" + +2. 부서장: 승인 화면 + └→ V2Biz (flow) 컴포넌트 → 현재 단계 표시 + └→ [승인] 클릭 → flowApi.approveStep() + └→ 상태: "최종 승인 대기" + +3. 이사: 최종 승인 + └→ [승인] 클릭 → flowApi.approveStep() + └→ 상태: "완료" + └→ products.approval_status = "APPROVED" +``` + +### 10.3 데이터 이동 (moveData) + +``` +플로우의 핵심 동작: 데이터를 한 스텝에서 다음 스텝으로 이동 + +Step 1 (접수) → Step 2 (검토) → Step 3 (완료) + ├── 단건 이동: moveData(flowId, dataId, fromStep, toStep) + └── 배치 이동: moveBatchData(flowId, dataIds[], fromStep, toStep) +``` + +--- + +## 11. 데이터플로우 시스템 + +### 11.1 개요 + +데이터플로우는 비즈니스 로직을 **비주얼 다이어그램**으로 설계하는 시스템이다. + +``` +/admin/systemMng/dataflow + ↓ +React Flow 기반 캔버스 + ├── InputNode: 데이터 입력 (폼 데이터, 테이블 데이터) + ├── TransformNode: 데이터 변환 (매핑, 필터링, 계산) + ├── DatabaseNode: DB 조회/저장 + ├── RestApiNode: 외부 API 호출 + ├── ConditionNode: 조건 분기 + ├── LoopNode: 반복 처리 + ├── MergeNode: 데이터 합치기 + └── OutputNode: 결과 출력 +``` + +### 11.2 데이터플로우 실행 + +``` +버튼 클릭 → 데이터플로우 트리거 + ↓ +InputNode: formData 수집 + ↓ +TransformNode: 데이터 가공 + ↓ +ConditionNode: 조건 분기 (가격 > 10000?) + ├── Yes → DatabaseNode: INSERT INTO premium_products + └── No → DatabaseNode: INSERT INTO standard_products + ↓ +OutputNode: 결과 반환 → toast.success("저장 완료") +``` + +--- + +## 12. 대시보드 시스템 + +### 12.1 구조 + +``` +관리자: /admin/screenMng/dashboardList + └→ 대시보드 생성 → 위젯 추가 → 레이아웃 저장 + +사용자: /dashboard/[dashboardId] + └→ 위젯 그리드 렌더링 → 실시간 데이터 표시 +``` + +### 12.2 위젯 종류 + +| 카테고리 | 위젯 | 역할 | +|----------|------|------| +| 시각화 | CustomMetricWidget | 커스텀 메트릭 표시 | +| | StatusSummaryWidget | 상태 요약 | +| 리스트 | CargoListWidget | 화물 목록 | +| | VehicleListWidget | 차량 목록 | +| 지도 | MapTestWidget | 지도 표시 | +| | WeatherMapWidget | 날씨 지도 | +| 작업 | TodoWidget | 할일 목록 | +| | WorkHistoryWidget | 작업 이력 | +| 알림 | BookingAlertWidget | 예약 알림 | +| | RiskAlertWidget | 위험 알림 | +| 기타 | ClockWidget | 시계 | +| | CalendarWidget | 캘린더 | + +--- + +## 13. 배치/스케줄 시스템 + +### 13.1 구조 + +``` +관리자: /admin/automaticMng/batchmngList + ↓ +배치 작업 생성: + ├── 이름: "일일 재고 집계" + ├── 실행 쿼리: SQL 또는 데이터플로우 ID + ├── 스케줄: Cron 표현식 ("0 0 * * *" = 매일 자정) + └── 활성화/비활성화 + ↓ +배치 스케줄러 (batch_schedules) + ↓ +자동 실행 → 실행 로그 (batch_execution_logs) +``` + +### 13.2 배치 실행 흐름 + +``` +Cron 트리거 → 배치 정의 조회 → SQL/데이터플로우 실행 + ↓ +성공: execution_log에 "SUCCESS" 기록 +실패: execution_log에 "FAILED" + 에러 메시지 기록 +``` + +--- + +## 14. 멀티테넌시 아키텍처 + +### 14.1 핵심 원칙 + +``` +모든 비즈니스 테이블: company_code 컬럼 필수 +모든 쿼리: WHERE company_code = $1 필수 +모든 JOIN: ON a.company_code = b.company_code 필수 +모든 집계: GROUP BY company_code 필수 +``` + +### 14.2 데이터 격리 + +``` +회사 A (company_code = "COMPANY_A"): + └→ 자기 데이터만 조회/수정/삭제 가능 + +회사 B (company_code = "COMPANY_B"): + └→ 자기 데이터만 조회/수정/삭제 가능 + +슈퍼관리자 (company_code = "*"): + └→ 모든 회사 데이터 조회 가능 + └→ 일반 회사는 "*" 데이터를 볼 수 없음 + +중요: company_code = "*"는 공통 데이터가 아니라 슈퍼관리자 전용 데이터! +``` + +### 14.3 코드 패턴 + +```typescript +// 백엔드 표준 패턴 +const companyCode = req.user!.companyCode; + +if (companyCode === "*") { + // 슈퍼관리자: 전체 데이터 + query = "SELECT * FROM table ORDER BY company_code"; +} else { + // 일반 사용자: 자기 회사만, "*" 제외 + query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'"; + params = [companyCode]; +} +``` + +--- + +## 15. 외부 연동 + +### 15.1 외부 DB 연결 + +``` +지원 DB: PostgreSQL, MySQL, MariaDB, MSSQL, Oracle + +관리: /api/external-db-connections + ├── 연결 정보 등록 (host, port, database, credentials) + ├── 연결 테스트 + ├── 쿼리 실행 + └── 데이터플로우에서 DatabaseNode로 사용 +``` + +### 15.2 외부 REST API 연결 + +``` +관리: /api/external-rest-api-connections + ├── API 엔드포인트 등록 (URL, method, headers) + ├── 인증 설정 (Bearer, Basic, API Key) + ├── 테스트 호출 + └── 데이터플로우에서 RestApiNode로 사용 +``` + +### 15.3 메일 시스템 + +``` +관리: /admin/automaticMng/mail/* + ├── 메일 템플릿 관리 + ├── 메일 발송 (개별/대량) + ├── 수신 메일 확인 + └── 발송 이력 조회 +``` + +--- + +## 16. 배포 환경 + +### 16.1 Docker 구성 + +``` +개발 환경 (Mac): +├── docker/dev/docker-compose.backend.mac.yml (BE: 8080) +└── docker/dev/docker-compose.frontend.mac.yml (FE: 9771) + +운영 환경: +├── docker/prod/docker-compose.backend.prod.yml (BE: 8080) +└── docker/prod/docker-compose.frontend.prod.yml (FE: 5555) +``` + +### 16.2 서버 정보 + +| 환경 | 서버 | 포트 | DB | +|------|------|------|-----| +| 개발 | 39.117.244.52 | FE:9771, BE:8080 | 39.117.244.52:11132 | +| 운영 | 211.115.91.141 | FE:5555, BE:8080 | 211.115.91.141:11134 | + +### 16.3 백엔드 시작 시 자동 작업 + +``` +서버 시작 (app.ts) + ├── 마이그레이션 실행 (DB 스키마 업데이트) + ├── 배치 스케줄러 초기화 + ├── 위험 알림 캐시 로드 + └── 메일 정리 Cron 시작 +``` + +--- + +## 부록: 업무 진행 요약 + +### 새로운 업무 화면을 만드는 전체 프로세스 + +``` +1. [DB] 테이블 관리에서 비즈니스 테이블 생성 + └→ 컬럼 정의, 타입 설정 + +2. [화면] 화면 관리에서 새 화면 생성 + └→ 메인 테이블 지정 + +3. [디자인] 화면 디자이너에서 UI 구성 + └→ V2 컴포넌트 배치, 데이터 바인딩 + +4. [로직] 데이터플로우 설계 (필요시) + └→ 저장/수정/삭제 로직 다이어그램 + +5. [플로우] 플로우 정의 (승인 프로세스 필요시) + └→ 단계 정의, 연결 + +6. [메뉴] 메뉴에 화면 할당 + └→ 사용자가 접근할 수 있게 메뉴 트리 배치 + +7. [권한] 권한 그룹에 메뉴 할당 + └→ 특정 사용자 그룹만 접근 가능하게 + +8. [사용] 사용자가 메뉴 클릭 → 업무 시작! +``` diff --git a/docs/backend-analysis-README.md b/docs/backend-analysis-README.md new file mode 100644 index 00000000..27694d2e --- /dev/null +++ b/docs/backend-analysis-README.md @@ -0,0 +1,246 @@ +# WACE ERP Backend - 분석 문서 인덱스 + +> **분석 완료일**: 2026-02-06 +> **분석자**: Backend Specialist + +--- + +## 📚 문서 목록 + +### 1. 📖 상세 분석 문서 +**파일**: `backend-architecture-detailed-analysis.md` +**내용**: 백엔드 전체 아키텍처 상세 분석 (16개 섹션) + +- 전체 개요 및 기술 스택 +- 디렉토리 구조 +- 미들웨어 스택 구성 +- 인증/인가 시스템 (JWT, 3단계 권한) +- 멀티테넌시 구현 방식 +- API 라우트 전체 목록 +- 비즈니스 도메인별 모듈 (8개 도메인) +- 데이터베이스 접근 방식 (Raw Query) +- 외부 시스템 연동 (DB/REST API) +- 배치/스케줄 처리 (node-cron) +- 파일 처리 (multer) +- 에러 핸들링 +- 로깅 시스템 (Winston) +- 보안 및 권한 관리 +- 성능 최적화 + +**특징**: 워크플로우 문서에 통합하기 위한 완전한 아키텍처 분석 + +--- + +### 2. 📄 요약 문서 +**파일**: `backend-architecture-summary.md` +**내용**: 백엔드 아키텍처 핵심 요약 (16개 섹션 압축) + +- 기술 스택 요약 +- 계층 구조 다이어그램 +- 디렉토리 구조 +- 미들웨어 스택 순서 +- 인증/인가 흐름도 +- 멀티테넌시 핵심 원칙 +- API 라우트 카테고리별 정리 +- 비즈니스 도메인 8개 요약 +- 데이터베이스 접근 패턴 +- 외부 연동 아키텍처 +- 배치 스케줄러 시스템 +- 파일 처리 흐름 +- 보안 정책 +- 에러 핸들링 전략 +- 로깅 구조 +- 성능 최적화 전략 +- **핵심 체크리스트** (개발 시 필수 규칙 8개) + +**특징**: 빠른 참조를 위한 간결한 요약 + +--- + +### 3. 🔗 API 라우트 완전 매핑 +**파일**: `backend-api-route-mapping.md` +**내용**: 프론트엔드 개발자용 API 엔드포인트 전체 목록 (200+개) + +#### 포함된 API 카테고리 +1. 인증 API (7개) +2. 관리자 API (15개) +3. 테이블 관리 API (30개) +4. 화면 관리 API (10개) +5. 플로우 API (15개) +6. 데이터플로우 API (10개) +7. 외부 연동 API (15개) +8. 배치 API (10개) +9. 메일 API (5개) +10. 파일 API (5개) +11. 대시보드 API (5개) +12. 공통코드 API (3개) +13. 다국어 API (3개) +14. 회사 관리 API (4개) +15. 부서 API (2개) +16. 권한 그룹 API (2개) +17. DDL 실행 API (1개) +18. 외부 API 프록시 (2개) +19. 디지털 트윈 API (3개) +20. 3D 필드 API (2개) +21. 스케줄 API (1개) +22. 채번 규칙 API (3개) +23. 엔티티 검색 API (2개) +24. To-Do API (3개) +25. 예약 요청 API (2개) +26. 리스크/알림 API (2개) +27. 헬스 체크 (1개) + +#### 각 API 정보 포함 +- HTTP 메서드 +- 엔드포인트 경로 +- 필요 권한 (공개/인증/관리자/슈퍼관리자) +- 기능 설명 +- Request Body/Query Params +- Response 형식 + +#### 추가 정보 +- Base URL (개발/운영) +- 공통 헤더 (Authorization) +- 응답 형식 (성공/에러) +- 에러 코드 목록 + +**특징**: 프론트엔드에서 API 호출 시 즉시 참조 가능 + +--- + +### 4. 📊 JSON 응답 요약 +**파일**: `backend-analysis-response.json` +**내용**: 구조화된 JSON 형식의 분석 결과 + +```json +{ + "status": "success", + "confidence": "high", + "result": { + "summary": "...", + "details": "...", + "files_affected": [...], + "key_findings": { + "architecture_pattern": "...", + "tech_stack": {...}, + "middleware_stack": [...], + "authentication_flow": {...}, + "permission_levels": {...}, + "multi_tenancy": {...}, + "business_domains": {...}, + "database_access": {...}, + "security": {...}, + "performance_optimization": {...} + }, + "critical_rules": [...] + } +} +``` + +**특징**: 프로그래밍 방식으로 분석 결과 활용 가능 + +--- + +## 🎯 핵심 요약 + +### 아키텍처 +- **패턴**: Layered Architecture (Controller → Service → Database) +- **언어**: TypeScript (Strict Mode) +- **프레임워크**: Express.js +- **데이터베이스**: PostgreSQL (Raw Query, Connection Pool) +- **인증**: JWT (24시간 만료, 자동 갱신) + +### 멀티테넌시 +```typescript +// ✅ 핵심 원칙 +const companyCode = req.user!.companyCode; // JWT에서 추출 + +if (companyCode === "*") { + // 슈퍼관리자: 모든 데이터 + query = "SELECT * FROM table ORDER BY company_code"; +} else { + // 일반 사용자: 자기 회사만 + 슈퍼관리자 숨김 + query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'"; + params = [companyCode]; +} +``` + +### 권한 체계 (3단계) +1. **SUPER_ADMIN** (`company_code = "*"`) + - 전체 회사 데이터 접근 + - DDL 실행, 회사 생성/삭제 + +2. **COMPANY_ADMIN** (`company_code = "ILSHIN"`) + - 자기 회사 데이터만 접근 + - 사용자/설정 관리 + +3. **USER** (`company_code = "ILSHIN"`) + - 자기 회사 데이터만 접근 + - 읽기/쓰기만 + +### 주요 도메인 (8개) +1. **관리자** - 사용자/메뉴/권한 +2. **테이블/화면** - 메타데이터, 동적 화면 +3. **플로우** - 워크플로우 엔진 +4. **데이터플로우** - ERD, 관계도 +5. **외부 연동** - 외부 DB/REST API +6. **배치** - Cron 스케줄러 +7. **메일** - 발송/수신 +8. **파일** - 업로드/다운로드 + +### API 통계 +- **총 라우트**: 70+개 +- **총 API**: 200+개 +- **컨트롤러**: 70+개 +- **서비스**: 80+개 +- **미들웨어**: 4개 + +--- + +## 🚨 개발 시 필수 규칙 + +✅ **모든 쿼리에 `company_code` 필터 추가** +✅ **JWT 토큰에서 `company_code` 추출 (클라이언트 신뢰 금지)** +✅ **Parameterized Query 사용 (SQL Injection 방지)** +✅ **슈퍼관리자 데이터 숨김 (`company_code != '*'`)** +✅ **비밀번호는 bcrypt, 민감정보는 AES-256** +✅ **에러 핸들링 try/catch 필수** +✅ **트랜잭션이 필요한 경우 `transaction()` 사용** +✅ **파일 업로드는 회사별 디렉토리 분리** + +--- + +## 📁 문서 위치 + +``` +ERP-node/docs/ +├── backend-architecture-detailed-analysis.md (상세 분석, 16개 섹션) +├── backend-architecture-summary.md (요약, 간결한 참조) +├── backend-api-route-mapping.md (API 200+개 전체 매핑) +└── backend-analysis-response.json (JSON 구조화 데이터) +``` + +--- + +## 🔍 문서 사용 가이드 + +### 처음 백엔드를 이해하려면 +→ `backend-architecture-summary.md` 읽기 (20분) + +### 특정 기능을 구현하려면 +→ `backend-architecture-detailed-analysis.md`에서 해당 도메인 섹션 참조 + +### API를 호출하려면 +→ `backend-api-route-mapping.md`에서 엔드포인트 검색 + +### 워크플로우 문서에 통합하려면 +→ `backend-architecture-detailed-analysis.md` 전체 복사 + +### 프로그래밍 방식으로 활용하려면 +→ `backend-analysis-response.json` 파싱 + +--- + +**문서 버전**: 1.0 +**마지막 업데이트**: 2026-02-06 +**다음 업데이트 예정**: 신규 API 추가 시 diff --git a/docs/backend-analysis-response.json b/docs/backend-analysis-response.json new file mode 100644 index 00000000..b6b11bb1 --- /dev/null +++ b/docs/backend-analysis-response.json @@ -0,0 +1,239 @@ +{ + "status": "success", + "confidence": "high", + "result": { + "summary": "WACE ERP 백엔드 전체 아키텍처 분석 완료", + "details": "Node.js + Express + TypeScript + PostgreSQL Raw Query 기반 멀티테넌시 시스템. 70+ 라우트, 70+ 컨트롤러, 80+ 서비스로 구성된 계층형 아키텍처. JWT 인증, 3단계 권한 체계(SUPER_ADMIN/COMPANY_ADMIN/USER), company_code 기반 완전한 데이터 격리 구현.", + "files_affected": [ + "docs/backend-architecture-detailed-analysis.md (상세 분석 문서)", + "docs/backend-architecture-summary.md (요약 문서)", + "docs/backend-api-route-mapping.md (API 라우트 전체 매핑)" + ], + "key_findings": { + "architecture_pattern": "Layered Architecture (Controller → Service → Database)", + "tech_stack": { + "language": "TypeScript", + "runtime": "Node.js 20.10.0+", + "framework": "Express.js", + "database": "PostgreSQL (pg 라이브러리, Raw Query)", + "authentication": "JWT (jsonwebtoken)", + "scheduler": "node-cron", + "external_db_support": ["PostgreSQL", "MySQL", "MSSQL", "Oracle"] + }, + "directory_structure": { + "controllers": "70+ 파일 (API 요청 수신, 응답 생성)", + "services": "80+ 파일 (비즈니스 로직, 트랜잭션 관리)", + "routes": "70+ 파일 (API 라우팅)", + "middleware": "4개 (인증, 권한, 슈퍼관리자, 에러핸들러)", + "types": "26개 (TypeScript 타입 정의)", + "utils": "유틸리티 함수 (JWT, 암호화, 로거)" + }, + "middleware_stack": [ + "1. Process Level Exception Handlers", + "2. Helmet (보안 헤더)", + "3. Compression (Gzip)", + "4. Body Parser (10MB limit)", + "5. Static Files (/uploads)", + "6. CORS (credentials: true)", + "7. Rate Limiting (1분 10000회)", + "8. Token Auto Refresh (1시간 이내 만료 시 갱신)", + "9. API Routes (70+개)", + "10. 404 Handler", + "11. Error Handler" + ], + "authentication_flow": { + "step1": "로그인 요청 → AuthController.login()", + "step2": "AuthService.processLogin() → loginPwdCheck() (bcrypt 검증)", + "step3": "getPersonBeanFromSession() → 사용자 정보 조회", + "step4": "insertLoginAccessLog() → 로그인 이력 저장", + "step5": "JwtUtils.generateToken() → JWT 토큰 생성", + "step6": "응답: { token, userInfo, firstMenuPath }" + }, + "jwt_payload": { + "userId": "사용자 ID", + "userName": "사용자명", + "companyCode": "회사 코드 (멀티테넌시 키)", + "userType": "권한 레벨 (SUPER_ADMIN/COMPANY_ADMIN/USER)", + "exp": "만료 시간 (24시간)" + }, + "permission_levels": { + "SUPER_ADMIN": { + "company_code": "*", + "userType": "SUPER_ADMIN", + "capabilities": [ + "전체 회사 데이터 접근", + "DDL 실행", + "회사 생성/삭제", + "시스템 설정 변경" + ] + }, + "COMPANY_ADMIN": { + "company_code": "특정 회사 (예: ILSHIN)", + "userType": "COMPANY_ADMIN", + "capabilities": [ + "자기 회사 데이터만 접근", + "자기 회사 사용자 관리", + "회사 설정 변경" + ] + }, + "USER": { + "company_code": "특정 회사", + "userType": "USER", + "capabilities": [ + "자기 회사 데이터만 접근", + "읽기/쓰기 권한만" + ] + } + }, + "multi_tenancy": { + "principle": "모든 쿼리에 company_code 필터 필수", + "pattern": "JWT 토큰에서 company_code 추출 (클라이언트 신뢰 금지)", + "super_admin_visibility": "일반 회사 사용자에게 슈퍼관리자(company_code='*') 숨김", + "correct_pattern": "WHERE company_code = $1 AND company_code != '*'", + "wrong_pattern": "req.body.companyCode 사용 (보안 위험!)" + }, + "api_routes": { + "total_count": "200+개", + "categories": { + "인증/관리자": "15개", + "테이블/화면": "40개", + "플로우": "15개", + "데이터플로우": "5개", + "외부 연동": "15개", + "배치": "10개", + "메일": "5개", + "파일": "5개", + "기타": "90개" + } + }, + "business_domains": { + "관리자": { + "controller": "adminController.ts", + "service": "adminService.ts", + "features": ["사용자 관리", "메뉴 관리", "권한 그룹 관리", "시스템 설정"] + }, + "테이블/화면": { + "controller": "tableManagementController.ts, screenManagementController.ts", + "service": "tableManagementService.ts, screenManagementService.ts", + "features": ["테이블 메타데이터", "화면 정의", "화면 그룹", "테이블 로그", "엔티티 관계"] + }, + "플로우": { + "controller": "flowController.ts", + "service": "flowExecutionService.ts, flowDefinitionService.ts", + "features": ["워크플로우 설계", "단계 관리", "데이터 이동", "조건부 이동", "오딧 로그"] + }, + "데이터플로우": { + "controller": "dataflowController.ts, dataflowDiagramController.ts", + "service": "dataflowService.ts, dataflowDiagramService.ts", + "features": ["테이블 관계 정의", "ERD", "다이어그램 시각화", "관계 실행"] + }, + "외부 연동": { + "controller": "externalDbConnectionController.ts, externalRestApiConnectionController.ts", + "service": "externalDbConnectionService.ts, dbConnectionManager.ts", + "features": ["외부 DB 연결", "Connection Pool 관리", "REST API 프록시"] + }, + "배치": { + "controller": "batchController.ts, batchManagementController.ts", + "service": "batchService.ts, batchSchedulerService.ts", + "features": ["Cron 스케줄러", "외부 DB → 내부 DB 동기화", "컬럼 매핑", "실행 이력"] + }, + "메일": { + "controller": "mailSendSimpleController.ts, mailReceiveBasicController.ts", + "service": "mailSendSimpleService.ts, mailReceiveBasicService.ts", + "features": ["메일 발송 (nodemailer)", "메일 수신 (IMAP)", "템플릿 관리", "첨부파일"] + }, + "파일": { + "controller": "fileController.ts, screenFileController.ts", + "service": "fileSystemManager.ts", + "features": ["파일 업로드 (multer)", "파일 다운로드", "화면별 파일 관리"] + } + }, + "database_access": { + "connection_pool": { + "min": "2~5 (환경별)", + "max": "10~20 (환경별)", + "connectionTimeout": "30000ms", + "idleTimeout": "600000ms", + "statementTimeout": "60000ms" + }, + "query_patterns": { + "multi_row": "query('SELECT ...', [params])", + "single_row": "queryOne('SELECT ...', [params])", + "transaction": "transaction(async (client) => { ... })" + }, + "sql_injection_prevention": "Parameterized Query 사용 (pg 라이브러리)" + }, + "external_integration": { + "supported_databases": ["PostgreSQL", "MySQL", "MSSQL", "Oracle"], + "connector_pattern": "Factory Pattern (DatabaseConnectorFactory)", + "rest_api": "axios 기반 프록시" + }, + "batch_scheduler": { + "library": "node-cron", + "timezone": "Asia/Seoul", + "cron_examples": { + "매일 새벽 2시": "0 2 * * *", + "5분마다": "*/5 * * * *", + "평일 오전 8시": "0 8 * * 1-5" + }, + "execution_flow": [ + "1. 소스 DB에서 데이터 조회", + "2. 컬럼 매핑 적용", + "3. 타겟 DB에 INSERT/UPDATE", + "4. 실행 로그 기록" + ] + }, + "file_handling": { + "upload_path": "uploads/{company_code}/{timestamp}-{uuid}-{filename}", + "max_file_size": "10MB", + "allowed_types": ["이미지", "PDF", "Office 문서"], + "library": "multer" + }, + "security": { + "password_encryption": "bcrypt (12 rounds)", + "sensitive_data_encryption": "AES-256-CBC (외부 DB 비밀번호)", + "jwt_secret": "환경변수 관리", + "security_headers": ["Helmet (CSP, X-Frame-Options)", "CORS (credentials: true)", "Rate Limiting (1분 10000회)"], + "sql_injection_prevention": "Parameterized Query" + }, + "error_handling": { + "postgres_error_codes": { + "23505": "중복된 데이터", + "23503": "참조 무결성 위반", + "23502": "필수 입력값 누락" + }, + "process_level": { + "unhandledRejection": "로깅 (서버 유지)", + "uncaughtException": "로깅 (서버 유지, 주의)", + "SIGTERM/SIGINT": "Graceful Shutdown" + } + }, + "logging": { + "library": "Winston", + "log_files": { + "error.log": "에러만 (10MB × 5파일)", + "combined.log": "전체 로그 (10MB × 10파일)" + }, + "log_levels": "error (0) → warn (1) → info (2) → debug (5)" + }, + "performance_optimization": { + "pool_monitoring": "5분마다 상태 체크, 대기 연결 5개 이상 시 경고", + "slow_query_detection": "1초 이상 걸린 쿼리 자동 경고", + "caching": "Redis (메뉴: 10분 TTL, 공통코드: 30분 TTL)", + "compression": "Gzip (1KB 이상 응답, 레벨 6)" + } + }, + "critical_rules": [ + "✅ 모든 쿼리에 company_code 필터 추가", + "✅ JWT 토큰에서 company_code 추출 (클라이언트 신뢰 금지)", + "✅ Parameterized Query 사용 (SQL Injection 방지)", + "✅ 슈퍼관리자 데이터 숨김 (company_code != '*')", + "✅ 비밀번호는 bcrypt, 민감정보는 AES-256", + "✅ 에러 핸들링 try/catch 필수", + "✅ 트랜잭션이 필요한 경우 transaction() 사용", + "✅ 파일 업로드는 회사별 디렉토리 분리" + ] + }, + "needs_from_others": [], + "questions": [] +} diff --git a/docs/backend-api-route-mapping.md b/docs/backend-api-route-mapping.md new file mode 100644 index 00000000..972f64b1 --- /dev/null +++ b/docs/backend-api-route-mapping.md @@ -0,0 +1,542 @@ +# WACE ERP Backend - API 라우트 완전 매핑 + +> **작성일**: 2026-02-06 +> **목적**: 프론트엔드 개발자용 API 엔드포인트 전체 목록 + +--- + +## 📌 공통 규칙 + +### Base URL +``` +개발: http://localhost:8080 +운영: http://39.117.244.52:8080 +``` + +### 헤더 +```http +Content-Type: application/json +Authorization: Bearer {JWT_TOKEN} +``` + +### 응답 형식 +```json +{ + "success": true, + "message": "성공 메시지", + "data": { ... } +} + +// 에러 시 +{ + "success": false, + "error": { + "code": "ERROR_CODE", + "details": "에러 상세" + } +} +``` + +--- + +## 1. 인증 API (`/api/auth`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/auth/login` | 공개 | 로그인 | `{ userId, password }` | `{ token, userInfo, firstMenuPath }` | +| POST | `/auth/logout` | 인증 | 로그아웃 | - | `{ success: true }` | +| GET | `/auth/me` | 인증 | 현재 사용자 정보 | - | `{ userInfo }` | +| GET | `/auth/status` | 공개 | 인증 상태 확인 | - | `{ isLoggedIn, isAdmin }` | +| POST | `/auth/refresh` | 인증 | 토큰 갱신 | - | `{ token }` | +| POST | `/auth/signup` | 공개 | 회원가입 (공차중계) | `{ userId, password, userName, phoneNumber, licenseNumber, vehicleNumber }` | `{ success: true }` | +| POST | `/auth/switch-company` | 슈퍼관리자 | 회사 전환 | `{ companyCode }` | `{ token, companyCode }` | + +--- + +## 2. 관리자 API (`/api/admin`) + +### 2.1 사용자 관리 + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/admin/users` | 관리자 | 사용자 목록 | `page, limit, search` | `{ users[], total }` | +| POST | `/admin/users` | 관리자 | 사용자 생성 | - | `{ user }` | +| PUT | `/admin/users/:userId` | 관리자 | 사용자 수정 | - | `{ user }` | +| DELETE | `/admin/users/:userId` | 관리자 | 사용자 삭제 | - | `{ success: true }` | +| GET | `/admin/users/:userId/history` | 관리자 | 사용자 이력 | - | `{ history[] }` | + +### 2.2 메뉴 관리 + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/admin/menus` | 인증 | 메뉴 목록 (트리) | `userId, userLang` | `{ menus[] }` | +| POST | `/admin/menus` | 관리자 | 메뉴 생성 | - | `{ menu }` | +| PUT | `/admin/menus/:menuId` | 관리자 | 메뉴 수정 | - | `{ menu }` | +| DELETE | `/admin/menus/:menuId` | 관리자 | 메뉴 삭제 | - | `{ success: true }` | + +### 2.3 표준 관리 + +| 메서드 | 경로 | 권한 | 기능 | Response | +|--------|------|------|------|----------| +| GET | `/admin/web-types` | 인증 | 웹타입 표준 목록 | `{ webTypes[] }` | +| GET | `/admin/button-actions` | 인증 | 버튼 액션 표준 | `{ buttonActions[] }` | +| GET | `/admin/component-standards` | 인증 | 컴포넌트 표준 | `{ components[] }` | +| GET | `/admin/template-standards` | 인증 | 템플릿 표준 | `{ templates[] }` | +| GET | `/admin/reports` | 인증 | 리포트 목록 | `{ reports[] }` | + +--- + +## 3. 테이블 관리 API (`/api/table-management`) + +### 3.1 테이블 메타데이터 + +| 메서드 | 경로 | 권한 | 기능 | Response | +|--------|------|------|------|----------| +| GET | `/table-management/tables` | 인증 | 테이블 목록 | `{ tables[] }` | +| GET | `/table-management/tables/:table/columns` | 인증 | 컬럼 목록 | `{ columns[] }` | +| GET | `/table-management/tables/:table/schema` | 인증 | 테이블 스키마 | `{ schema }` | +| GET | `/table-management/tables/:table/exists` | 인증 | 테이블 존재 여부 | `{ exists: boolean }` | +| GET | `/table-management/tables/:table/web-types` | 인증 | 웹타입 정보 | `{ webTypes }` | + +### 3.2 컬럼 설정 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | +|--------|------|------|------|--------------| +| POST | `/table-management/tables/:table/columns/:column/settings` | 인증 | 컬럼 설정 업데이트 | `{ web_type, input_type, ... }` | +| POST | `/table-management/tables/:table/columns/settings` | 인증 | 전체 컬럼 일괄 업데이트 | `{ columns[] }` | +| PUT | `/table-management/tables/:table/label` | 인증 | 테이블 라벨 설정 | `{ label }` | + +### 3.3 데이터 CRUD + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/table-management/tables/:table/data` | 인증 | 데이터 조회 (페이징) | `{ page, limit, filters, sort }` | `{ data[], total }` | +| POST | `/table-management/tables/:table/record` | 인증 | 단일 레코드 조회 | `{ conditions }` | `{ record }` | +| POST | `/table-management/tables/:table/add` | 인증 | 데이터 추가 | `{ data }` | `{ success: true, id }` | +| PUT | `/table-management/tables/:table/edit` | 인증 | 데이터 수정 | `{ conditions, data }` | `{ success: true }` | +| DELETE | `/table-management/tables/:table/delete` | 인증 | 데이터 삭제 | `{ conditions }` | `{ success: true }` | + +### 3.4 다중 테이블 저장 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | +|--------|------|------|------|--------------| +| POST | `/table-management/multi-table-save` | 인증 | 메인+서브 테이블 저장 | `{ mainTable, mainData, subTables: [{ table, data[] }] }` | + +### 3.5 로그 시스템 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | +|--------|------|------|------|--------------| +| POST | `/table-management/tables/:table/log` | 관리자 | 로그 테이블 생성 | - | +| GET | `/table-management/tables/:table/log/config` | 인증 | 로그 설정 조회 | - | +| GET | `/table-management/tables/:table/log` | 인증 | 로그 데이터 조회 | - | +| POST | `/table-management/tables/:table/log/toggle` | 관리자 | 로그 활성화/비활성화 | `{ is_active }` | + +### 3.6 엔티티 관계 + +| 메서드 | 경로 | 권한 | 기능 | Query Params | +|--------|------|------|------|--------------| +| GET | `/table-management/tables/entity-relations` | 인증 | 두 테이블 간 관계 조회 | `leftTable, rightTable` | +| GET | `/table-management/columns/:table/referenced-by` | 인증 | 현재 테이블 참조 목록 | - | + +### 3.7 카테고리 관리 + +| 메서드 | 경로 | 권한 | 기능 | Response | +|--------|------|------|------|----------| +| GET | `/table-management/category-columns` | 인증 | 회사별 카테고리 컬럼 | `{ categoryColumns[] }` | +| GET | `/table-management/menu/:menuObjid/category-columns` | 인증 | 메뉴별 카테고리 컬럼 | `{ categoryColumns[] }` | + +--- + +## 4. 화면 관리 API (`/api/screen-management`) + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/screen-management/screens` | 인증 | 화면 목록 | `page, limit` | `{ screens[], total }` | +| GET | `/screen-management/screens/:id` | 인증 | 화면 상세 | - | `{ screen }` | +| POST | `/screen-management/screens` | 관리자 | 화면 생성 | - | `{ screen }` | +| PUT | `/screen-management/screens/:id` | 관리자 | 화면 수정 | - | `{ screen }` | +| DELETE | `/screen-management/screens/:id` | 관리자 | 화면 삭제 | - | `{ success: true }` | + +### 화면 그룹 + +| 메서드 | 경로 | 권한 | 기능 | Response | +|--------|------|------|------|----------| +| GET | `/screen-groups` | 인증 | 화면 그룹 목록 | `{ screenGroups[] }` | +| POST | `/screen-groups` | 관리자 | 그룹 생성 | `{ group }` | + +### 화면 파일 + +| 메서드 | 경로 | 권한 | 기능 | Response | +|--------|------|------|------|----------| +| GET | `/screen-files` | 인증 | 화면 파일 목록 | `{ files[] }` | +| POST | `/screen-files` | 관리자 | 파일 업로드 | `{ file }` | + +--- + +## 5. 플로우 API (`/api/flow`) + +### 5.1 플로우 정의 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/flow/definitions` | 인증 | 플로우 목록 | - | `{ flows[] }` | +| GET | `/flow/definitions/:id` | 인증 | 플로우 상세 | - | `{ flow }` | +| POST | `/flow/definitions` | 인증 | 플로우 생성 | `{ name, description, targetTable }` | `{ flow }` | +| PUT | `/flow/definitions/:id` | 인증 | 플로우 수정 | `{ name, description }` | `{ flow }` | +| DELETE | `/flow/definitions/:id` | 인증 | 플로우 삭제 | - | `{ success: true }` | + +### 5.2 단계 관리 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/flow/definitions/:flowId/steps` | 인증 | 단계 목록 | - | `{ steps[] }` | +| POST | `/flow/definitions/:flowId/steps` | 인증 | 단계 생성 | `{ name, type, settings }` | `{ step }` | +| PUT | `/flow/steps/:stepId` | 인증 | 단계 수정 | `{ name, settings }` | `{ step }` | +| DELETE | `/flow/steps/:stepId` | 인증 | 단계 삭제 | - | `{ success: true }` | + +### 5.3 연결 관리 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/flow/connections/:flowId` | 인증 | 연결 목록 | - | `{ connections[] }` | +| POST | `/flow/connections` | 인증 | 연결 생성 | `{ fromStepId, toStepId, condition }` | `{ connection }` | +| DELETE | `/flow/connections/:connectionId` | 인증 | 연결 삭제 | - | `{ success: true }` | + +### 5.4 데이터 이동 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/flow/move` | 인증 | 데이터 이동 (단건) | `{ flowId, fromStepId, toStepId, recordId }` | `{ success: true }` | +| POST | `/flow/move-batch` | 인증 | 데이터 이동 (다건) | `{ flowId, fromStepId, toStepId, recordIds[] }` | `{ success: true, movedCount }` | + +### 5.5 단계 데이터 조회 + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/flow/:flowId/step/:stepId/count` | 인증 | 단계 데이터 개수 | - | `{ count }` | +| GET | `/flow/:flowId/step/:stepId/list` | 인증 | 단계 데이터 목록 | `page, limit` | `{ data[], total }` | +| GET | `/flow/:flowId/step/:stepId/column-labels` | 인증 | 컬럼 라벨 조회 | - | `{ labels }` | +| GET | `/flow/:flowId/steps/counts` | 인증 | 모든 단계 카운트 | - | `{ counts[] }` | + +### 5.6 단계 데이터 수정 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| PUT | `/flow/:flowId/step/:stepId/data/:recordId` | 인증 | 인라인 편집 | `{ data }` | `{ success: true }` | + +### 5.7 오딧 로그 + +| 메서드 | 경로 | 권한 | 기능 | Response | +|--------|------|------|------|----------| +| GET | `/flow/audit/:flowId/:recordId` | 인증 | 레코드별 오딧 로그 | `{ auditLogs[] }` | +| GET | `/flow/audit/:flowId` | 인증 | 플로우 전체 오딧 로그 | `{ auditLogs[] }` | + +--- + +## 6. 데이터플로우 API (`/api/dataflow`) + +### 6.1 관계 관리 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/dataflow/relationships` | 인증 | 관계 목록 | - | `{ relationships[] }` | +| POST | `/dataflow/relationships` | 인증 | 관계 생성 | `{ fromTable, toTable, fromColumn, toColumn, type }` | `{ relationship }` | +| PUT | `/dataflow/relationships/:id` | 인증 | 관계 수정 | `{ name, type }` | `{ relationship }` | +| DELETE | `/dataflow/relationships/:id` | 인증 | 관계 삭제 | - | `{ success: true }` | + +### 6.2 다이어그램 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/dataflow-diagrams` | 인증 | 다이어그램 목록 | - | `{ diagrams[] }` | +| GET | `/dataflow-diagrams/:id` | 인증 | 다이어그램 상세 | - | `{ diagram }` | +| POST | `/dataflow-diagrams` | 인증 | 다이어그램 생성 | `{ name, description }` | `{ diagram }` | + +### 6.3 실행 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/dataflow` | 인증 | 데이터플로우 실행 | `{ relationshipId, params }` | `{ result[] }` | + +--- + +## 7. 외부 연동 API + +### 7.1 외부 DB 연결 (`/api/external-db-connections`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/external-db-connections` | 인증 | 연결 목록 | - | `{ connections[] }` | +| GET | `/external-db-connections/:id` | 인증 | 연결 상세 | - | `{ connection }` | +| POST | `/external-db-connections` | 관리자 | 연결 생성 | `{ connectionName, dbType, host, port, database, username, password }` | `{ connection }` | +| PUT | `/external-db-connections/:id` | 관리자 | 연결 수정 | `{ connectionName, ... }` | `{ connection }` | +| DELETE | `/external-db-connections/:id` | 관리자 | 연결 삭제 | - | `{ success: true }` | +| POST | `/external-db-connections/:id/test` | 인증 | 연결 테스트 | - | `{ success: boolean, message }` | +| GET | `/external-db-connections/:id/tables` | 인증 | 테이블 목록 조회 | - | `{ tables[] }` | +| GET | `/external-db-connections/:id/tables/:table/columns` | 인증 | 컬럼 목록 조회 | - | `{ columns[] }` | + +### 7.2 외부 REST API (`/api/external-rest-api-connections`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/external-rest-api-connections` | 인증 | API 연결 목록 | - | `{ connections[] }` | +| POST | `/external-rest-api-connections` | 관리자 | API 연결 생성 | `{ name, baseUrl, authType, ... }` | `{ connection }` | +| POST | `/external-rest-api-connections/:id/test` | 인증 | API 테스트 | `{ endpoint, method }` | `{ response }` | + +### 7.3 멀티 커넥션 (`/api/multi-connection`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/multi-connection/query` | 인증 | 멀티 DB 쿼리 | `{ connections: [{ connectionId, sql }] }` | `{ results[] }` | + +--- + +## 8. 배치 API + +### 8.1 배치 설정 (`/api/batch-configs`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/batch-configs` | 인증 | 배치 설정 목록 | - | `{ batchConfigs[] }` | +| GET | `/batch-configs/:id` | 인증 | 배치 설정 상세 | - | `{ batchConfig }` | +| POST | `/batch-configs` | 관리자 | 배치 설정 생성 | `{ batchName, cronSchedule, sourceConnection, targetTable, mappings }` | `{ batchConfig }` | +| PUT | `/batch-configs/:id` | 관리자 | 배치 설정 수정 | `{ batchName, ... }` | `{ batchConfig }` | +| DELETE | `/batch-configs/:id` | 관리자 | 배치 설정 삭제 | - | `{ success: true }` | +| GET | `/batch-configs/connections` | 관리자 | 사용 가능한 커넥션 목록 | - | `{ connections[] }` | +| GET | `/batch-configs/connections/:type/tables` | 관리자 | 테이블 목록 조회 | - | `{ tables[] }` | + +### 8.2 배치 실행 (`/api/batch-management`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/batch-management/:id/execute` | 관리자 | 배치 즉시 실행 | - | `{ success: true, executionLogId }` | + +### 8.3 실행 이력 (`/api/batch-execution-logs`) + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/batch-execution-logs` | 인증 | 실행 이력 목록 | `batchConfigId, page, limit` | `{ logs[], total }` | +| GET | `/batch-execution-logs/:id` | 인증 | 실행 이력 상세 | - | `{ log }` | + +--- + +## 9. 메일 API (`/api/mail`) + +### 9.1 계정 관리 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/mail/accounts` | 인증 | 계정 목록 | - | `{ accounts[] }` | +| POST | `/mail/accounts` | 관리자 | 계정 추가 | `{ email, smtpHost, smtpPort, password }` | `{ account }` | + +### 9.2 템플릿 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/mail/templates-file` | 인증 | 템플릿 목록 | - | `{ templates[] }` | +| POST | `/mail/templates-file` | 관리자 | 템플릿 생성 | `{ name, subject, body }` | `{ template }` | + +### 9.3 발송/수신 + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/mail/send` | 인증 | 메일 발송 | `{ accountId, to, subject, body, attachments[] }` | `{ success: true, messageId }` | +| GET | `/mail/sent` | 인증 | 발송 이력 | `page, limit` | `{ mails[], total }` | +| POST | `/mail/receive` | 인증 | 메일 수신 | `{ accountId }` | `{ mails[] }` | + +--- + +## 10. 파일 API (`/api/files`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/files/upload` | 인증 | 파일 업로드 (multipart) | `FormData { file }` | `{ fileId, fileName, filePath, fileSize }` | +| GET | `/files` | 인증 | 파일 목록 | `page, limit` | `{ files[], total }` | +| GET | `/files/:id` | 인증 | 파일 정보 조회 | - | `{ file }` | +| GET | `/files/download/:id` | 인증 | 파일 다운로드 | - | `(파일 스트림)` | +| DELETE | `/files/:id` | 인증 | 파일 삭제 | - | `{ success: true }` | +| GET | `/uploads/:filename` | 공개 | 정적 파일 서빙 | - | `(파일 스트림)` | + +--- + +## 11. 대시보드 API (`/api/dashboards`) + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/dashboards` | 인증 | 대시보드 목록 | - | `{ dashboards[] }` | +| GET | `/dashboards/:id` | 인증 | 대시보드 상세 | - | `{ dashboard }` | +| POST | `/dashboards` | 관리자 | 대시보드 생성 | - | `{ dashboard }` | +| GET | `/dashboards/:id/widgets` | 인증 | 위젯 데이터 조회 | - | `{ widgets[] }` | + +--- + +## 12. 공통코드 API (`/api/common-codes`) + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/common-codes` | 인증 | 공통코드 목록 | `codeGroup` | `{ codes[] }` | +| GET | `/common-codes/:codeGroup/:code` | 인증 | 공통코드 상세 | - | `{ code }` | +| POST | `/common-codes` | 관리자 | 공통코드 생성 | `{ codeGroup, code, name }` | `{ code }` | + +--- + +## 13. 다국어 API (`/api/multilang`) + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/multilang` | 인증 | 다국어 키 목록 | `lang` | `{ translations{} }` | +| GET | `/multilang/:key` | 인증 | 특정 키 조회 | `lang` | `{ key, value }` | +| POST | `/multilang` | 관리자 | 다국어 추가 | `{ key, ko, en, cn }` | `{ translation }` | + +--- + +## 14. 회사 관리 API (`/api/company-management`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/company-management` | 슈퍼관리자 | 회사 목록 | - | `{ companies[] }` | +| POST | `/company-management` | 슈퍼관리자 | 회사 생성 | `{ companyCode, companyName }` | `{ company }` | +| PUT | `/company-management/:code` | 슈퍼관리자 | 회사 수정 | `{ companyName }` | `{ company }` | +| DELETE | `/company-management/:code` | 슈퍼관리자 | 회사 삭제 | - | `{ success: true }` | + +--- + +## 15. 부서 API (`/api/departments`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/departments` | 인증 | 부서 목록 (트리) | - | `{ departments[] }` | +| POST | `/departments` | 관리자 | 부서 생성 | `{ deptCode, deptName, parentDeptCode }` | `{ department }` | + +--- + +## 16. 권한 그룹 API (`/api/roles`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/roles` | 인증 | 권한 그룹 목록 | - | `{ roles[] }` | +| POST | `/roles` | 관리자 | 권한 그룹 생성 | `{ roleName, permissions[] }` | `{ role }` | + +--- + +## 17. DDL 실행 API (`/api/ddl`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/ddl` | 슈퍼관리자 | DDL 실행 | `{ sql }` | `{ success: true, result }` | + +--- + +## 18. 외부 API 프록시 (`/api/open-api`) + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/open-api/weather` | 인증 | 날씨 정보 조회 | `location` | `{ weather }` | +| GET | `/open-api/exchange` | 인증 | 환율 정보 조회 | `fromCurrency, toCurrency` | `{ rate }` | + +--- + +## 19. 디지털 트윈 API (`/api/digital-twin`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/digital-twin/layouts` | 인증 | 레이아웃 목록 | - | `{ layouts[] }` | +| GET | `/digital-twin/templates` | 인증 | 템플릿 목록 | - | `{ templates[] }` | +| GET | `/digital-twin/data` | 인증 | 실시간 데이터 | - | `{ data[] }` | + +--- + +## 20. 3D 필드 API (`/api/yard-layouts`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/yard-layouts` | 인증 | 필드 레이아웃 목록 | - | `{ yardLayouts[] }` | +| POST | `/yard-layouts` | 인증 | 레이아웃 저장 | `{ layout }` | `{ success: true }` | + +--- + +## 21. 스케줄 API (`/api/schedule`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/schedule` | 인증 | 스케줄 자동 생성 | `{ params }` | `{ schedule }` | + +--- + +## 22. 채번 규칙 API (`/api/numbering-rules`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/numbering-rules` | 인증 | 채번 규칙 목록 | - | `{ rules[] }` | +| POST | `/numbering-rules` | 관리자 | 규칙 생성 | `{ ruleName, prefix, format }` | `{ rule }` | +| POST | `/numbering-rules/:id/generate` | 인증 | 번호 생성 | - | `{ number }` | + +--- + +## 23. 엔티티 검색 API (`/api/entity-search`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| POST | `/entity-search` | 인증 | 엔티티 검색 | `{ table, filters, page, limit }` | `{ results[], total }` | +| GET | `/entity/:table/options` | 인증 | V2Select용 옵션 | `search, limit` | `{ options[] }` | + +--- + +## 24. To-Do API (`/api/todos`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/todos` | 인증 | To-Do 목록 | `status, assignee` | `{ todos[] }` | +| POST | `/todos` | 인증 | To-Do 생성 | `{ title, description, dueDate }` | `{ todo }` | +| PUT | `/todos/:id` | 인증 | To-Do 수정 | `{ status }` | `{ todo }` | + +--- + +## 25. 예약 요청 API (`/api/bookings`) + +| 메서드 | 경로 | 권한 | 기능 | Request Body | Response | +|--------|------|------|------|--------------|----------| +| GET | `/bookings` | 인증 | 예약 목록 | - | `{ bookings[] }` | +| POST | `/bookings` | 인증 | 예약 생성 | `{ resourceId, startTime, endTime }` | `{ booking }` | + +--- + +## 26. 리스크/알림 API (`/api/risk-alerts`) + +| 메서드 | 경로 | 권한 | 기능 | Query Params | Response | +|--------|------|------|------|--------------|----------| +| GET | `/risk-alerts` | 인증 | 리스크/알림 목록 | `priority, status` | `{ alerts[] }` | +| POST | `/risk-alerts` | 인증 | 알림 생성 | `{ title, content, priority }` | `{ alert }` | + +--- + +## 27. 헬스 체크 + +| 메서드 | 경로 | 권한 | 기능 | Response | +|--------|------|------|------|----------| +| GET | `/health` | 공개 | 서버 상태 확인 | `{ status: "OK", timestamp, uptime, environment }` | + +--- + +## 🔐 에러 코드 목록 + +| 코드 | HTTP Status | 설명 | +|------|-------------|------| +| `TOKEN_MISSING` | 401 | 인증 토큰 누락 | +| `TOKEN_EXPIRED` | 401 | 토큰 만료 | +| `INVALID_TOKEN` | 401 | 유효하지 않은 토큰 | +| `AUTHENTICATION_REQUIRED` | 401 | 인증 필요 | +| `INSUFFICIENT_PERMISSION` | 403 | 권한 부족 | +| `SUPER_ADMIN_REQUIRED` | 403 | 슈퍼관리자 권한 필요 | +| `COMPANY_ACCESS_DENIED` | 403 | 회사 데이터 접근 거부 | +| `INVALID_INPUT` | 400 | 잘못된 입력 | +| `RESOURCE_NOT_FOUND` | 404 | 리소스 없음 | +| `DUPLICATE_ENTRY` | 400 | 중복 데이터 | +| `FOREIGN_KEY_VIOLATION` | 400 | 참조 무결성 위반 | +| `SERVER_ERROR` | 500 | 서버 오류 | + +--- + +**문서 버전**: 1.0 +**마지막 업데이트**: 2026-02-06 +**총 API 개수**: 200+개 diff --git a/docs/backend-architecture-analysis.md b/docs/backend-architecture-analysis.md new file mode 100644 index 00000000..c5c2b549 --- /dev/null +++ b/docs/backend-architecture-analysis.md @@ -0,0 +1,1424 @@ +# WACE ERP 백엔드 아키텍처 상세 분석 + +> **작성일**: 2026-02-06 +> **분석 대상**: ERP-node/backend-node +> **Stack**: Node.js + Express + TypeScript + PostgreSQL Raw Query + +--- + +## 📋 목차 + +1. [전체 디렉토리 구조](#1-전체-디렉토리-구조) +2. [API 라우트 목록 및 역할](#2-api-라우트-목록-및-역할) +3. [인증/인가 워크플로우](#3-인증인가-워크플로우) +4. [비즈니스 도메인별 모듈 분류](#4-비즈니스-도메인별-모듈-분류) +5. [미들웨어 스택 구성](#5-미들웨어-스택-구성) +6. [서비스 레이어 패턴](#6-서비스-레이어-패턴) +7. [멀티테넌시 구현 방식](#7-멀티테넌시-구현-방식) +8. [에러 핸들링 전략](#8-에러-핸들링-전략) +9. [파일 업로드/다운로드 처리](#9-파일-업로드다운로드-처리) +10. [외부 연동](#10-외부-연동) +11. [배치/스케줄 처리](#11-배치스케줄-처리) +12. [컨트롤러/서비스 상세 역할](#12-컨트롤러서비스-상세-역할) + +--- + +## 1. 전체 디렉토리 구조 + +``` +backend-node/ +├── src/ +│ ├── app.ts # Express 앱 진입점, 라우트 등록, 미들웨어 설정 +│ ├── config/ +│ │ └── environment.ts # 환경변수 관리 (PORT, DB, JWT, CORS 등) +│ ├── controllers/ # 69개 컨트롤러 (요청 처리 및 응답) +│ ├── services/ # 87개 서비스 (비즈니스 로직) +│ ├── routes/ # 77개 라우터 (엔드포인트 정의) +│ ├── middleware/ # 4개 미들웨어 (인증, 권한, 에러 핸들링) +│ ├── database/ # DB 연결 풀, 커넥터, 마이그레이션 +│ ├── utils/ # 16개 유틸리티 (JWT, 암호화, 로거 등) +│ ├── types/ # 26개 TypeScript 타입 정의 +│ ├── interfaces/ # 인터페이스 정의 +│ └── tests/ # 테스트 파일 +├── scripts/ # 배치 및 유틸리티 스크립트 +├── data/ # JSON 기반 설정 데이터 +├── uploads/ # 파일 업로드 디렉토리 +└── package.json # 의존성 관리 +``` + +### 주요 특징 +- **Layered Architecture**: Controller → Service → Database 3계층 구조 +- **TypeScript Strict Mode**: 타입 안전성 보장 +- **Raw Query 기반**: Prisma → PostgreSQL Raw Query 전환 완료 +- **Connection Pool**: pg 라이브러리 기반 연결 풀 관리 +- **마이크로서비스 지향**: 도메인별 명확한 분리 + +--- + +## 2. API 라우트 목록 및 역할 + +### 2.1 인증 및 관리자 (Auth & Admin) + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/auth/login` | POST | 로그인 (JWT 토큰 발급) | ❌ | +| `/api/auth/signup` | POST | 회원가입 (공차중계) | ❌ | +| `/api/auth/me` | GET | 현재 사용자 정보 조회 | ✅ | +| `/api/auth/logout` | POST | 로그아웃 | ✅ | +| `/api/auth/refresh` | POST | JWT 토큰 갱신 | ✅ | +| `/api/auth/switch-company` | POST | 관리자 전용: 회사 전환 | ✅ | +| `/api/admin/menus` | GET | 메뉴 목록 조회 | ✅ | +| `/api/admin/users` | GET/POST/PUT | 사용자 관리 (CRUD) | ✅ | +| `/api/admin/companies` | GET/POST/PUT/DELETE | 회사 관리 (CRUD) | ✅ | +| `/api/admin/departments` | GET | 부서 목록 조회 | ✅ | + +### 2.2 테이블 및 데이터 관리 + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/table-management/tables` | GET | 테이블 목록 조회 | ✅ | +| `/api/table-management/columns` | GET | 컬럼 정보 조회 | ✅ | +| `/api/table-management/entity-joins` | GET/POST | 테이블 조인 설정 | ✅ | +| `/api/data/*` | GET/POST/PUT/DELETE | 동적 테이블 데이터 CRUD | ✅ | +| `/api/ddl/*` | POST | DDL 실행 (테이블 생성/수정/삭제) | ✅ (Super Admin) | + +### 2.3 화면 및 폼 관리 + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/screen-management/*` | GET/POST/PUT/DELETE | 화면 메타데이터 관리 | ✅ | +| `/api/screen-groups/*` | GET/POST/PUT/DELETE | 화면 그룹 관리 | ✅ | +| `/api/dynamic-form/*` | GET/POST | 동적 폼 생성 및 렌더링 | ✅ | +| `/api/admin/web-types` | GET/POST | 웹 컴포넌트 타입 표준 관리 | ✅ | +| `/api/admin/button-actions` | GET/POST | 버튼 액션 표준 관리 | ✅ | +| `/api/admin/template-standards` | GET/POST | 템플릿 표준 관리 | ✅ | +| `/api/admin/component-standards` | GET/POST | 컴포넌트 표준 관리 | ✅ | + +### 2.4 플로우 및 데이터플로우 + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/flow/definitions` | GET/POST/PUT/DELETE | 플로우 정의 관리 | ✅ | +| `/api/flow/definitions/:id/steps` | GET/POST | 플로우 단계 관리 | ✅ | +| `/api/flow/connections` | GET/POST/DELETE | 플로우 연결 관리 | ✅ | +| `/api/flow/move` | POST | 데이터 이동 실행 | ✅ | +| `/api/flow/audit/:flowId` | GET | 플로우 오딧 로그 조회 | ✅ | +| `/api/dataflow/*` | GET/POST/PUT/DELETE | 데이터플로우 관계 관리 | ✅ | +| `/api/dataflow-diagrams/*` | GET/POST/PUT/DELETE | 데이터플로우 다이어그램 | ✅ | +| `/api/dataflow/execute` | POST | 데이터플로우 실행 | ✅ | + +### 2.5 배치 관리 + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/batch-configs` | GET/POST/PUT/DELETE | 배치 설정 관리 | ✅ | +| `/api/batch-configs/connections` | GET | 사용 가능한 커넥션 목록 | ✅ | +| `/api/batch-configs/:id/execute` | POST | 배치 수동 실행 | ✅ | +| `/api/batch-management/*` | GET/POST | 배치 실행 관리 | ✅ | +| `/api/batch-execution-logs` | GET | 배치 실행 이력 조회 | ✅ | + +### 2.6 외부 연동 + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/external-db-connections` | GET/POST/PUT/DELETE | 외부 DB 연결 관리 | ✅ | +| `/api/external-db-connections/:id/test` | POST | 외부 DB 연결 테스트 | ✅ | +| `/api/external-rest-api-connections` | GET/POST/PUT/DELETE | 외부 REST API 연결 | ✅ | +| `/api/external-calls/*` | GET/POST | 외부 API 호출 설정 | ✅ | +| `/api/multi-connection/query` | POST | 멀티 DB 통합 쿼리 | ✅ | + +### 2.7 메일 관리 + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/mail/accounts` | GET/POST/PUT/DELETE | 메일 계정 관리 | ✅ | +| `/api/mail/templates-file` | GET/POST/PUT/DELETE | 메일 템플릿 관리 | ✅ | +| `/api/mail/send` | POST | 메일 발송 (단일/대량) | ✅ | +| `/api/mail/sent` | GET | 발송 이력 조회 | ✅ | +| `/api/mail/receive` | GET | 메일 수신함 조회 | ✅ | + +### 2.8 기타 도메인 + +| 엔드포인트 | 메서드 | 역할 | 인증 | +|-----------|--------|------|------| +| `/api/dashboards/*` | GET/POST | 대시보드 관리 | ✅ | +| `/api/admin/reports/*` | GET/POST | 리포트 생성 및 조회 | ✅ | +| `/api/files/*` | POST | 파일 업로드/다운로드 | ✅ | +| `/api/delivery/*` | GET/POST | 배송/화물 관리 | ✅ | +| `/api/risk-alerts/*` | GET/POST | 리스크/알림 관리 | ✅ | +| `/api/todos/*` | GET/POST/PUT/DELETE | To-Do 관리 | ✅ | +| `/api/bookings/*` | GET/POST | 예약 관리 | ✅ | +| `/api/digital-twin/*` | GET/POST | 디지털 트윈 (야드 관제) | ✅ | +| `/api/schedule/*` | GET/POST | 스케줄 자동 생성 | ✅ | +| `/api/work-history/*` | GET | 작업 이력 조회 | ✅ | +| `/api/table-history/*` | GET | 테이블 변경 이력 조회 | ✅ | +| `/api/roles/*` | GET/POST | 권한 그룹 관리 | ✅ | +| `/api/numbering-rules/*` | GET/POST | 채번 규칙 관리 | ✅ | +| `/api/entity-search/*` | GET | 엔티티 검색 | ✅ | +| `/api/cascading-*` | GET/POST | 연쇄 드롭다운 관계 | ✅ | +| `/api/category-tree/*` | GET/POST | 카테고리 트리 | ✅ | +| `/api/vehicle/*` | GET/POST | 차량 운행 이력 | ✅ | +| `/api/tax-invoice/*` | GET/POST | 세금계산서 관리 | ✅ | + +**총 77개 라우터 파일, 200개 이상의 엔드포인트 제공** + +--- + +## 3. 인증/인가 워크플로우 + +### 3.1 인증 메커니즘 + +``` +로그인 요청 (userId, password) + ↓ +1. AuthController.login() + ↓ +2. AuthService.processLogin() + ├─ 비밀번호 검증 (BCrypt + 마스터 패스워드) + ├─ 사용자 정보 조회 (user_info 테이블) + ├─ 로그인 로그 기록 (LOGIN_ACCESS_LOG) + └─ JWT 토큰 생성 (JwtUtils.generateToken) + ↓ +3. JWT 토큰 응답 + ├─ accessToken (24시간 유효) + ├─ refreshToken (7일 유효) + └─ userInfo (userId, userName, companyCode, userType) +``` + +### 3.2 JWT 토큰 구조 + +```typescript +// JWT Payload +{ + userId: string; // 사용자 ID + userName: string; // 사용자 이름 + companyCode: string; // 회사 코드 (멀티테넌시 핵심) + userType: string; // 사용자 유형 (SUPER_ADMIN, COMPANY_ADMIN, USER) + userLang?: string; // 사용자 언어 + iat: number; // 발급 시간 + exp: number; // 만료 시간 +} +``` + +### 3.3 미들웨어 체인 + +``` +1. refreshTokenIfNeeded (자동 토큰 갱신) + ↓ +2. authenticateToken (JWT 검증 및 사용자 정보 설정) + ↓ +3. 권한 미들웨어 (선택적) + ├─ requireSuperAdmin (회사코드 '*' 필수) + ├─ requireAdmin (회사관리자 이상) + ├─ requireCompanyAccess (회사 데이터 접근 권한) + ├─ requireDDLPermission (DDL 실행 권한) + └─ requireUserManagement (사용자 관리 권한) + ↓ +4. Controller 실행 + ↓ +5. errorHandler (에러 발생 시) +``` + +### 3.4 권한 레벨 (3단계) + +| 레벨 | companyCode | userType | 권한 범위 | +|------|-------------|----------|----------| +| **Super Admin** | `*` | `SUPER_ADMIN` | 전체 시스템 접근, DDL 실행, 회사 생성/삭제 | +| **Company Admin** | 회사코드 | `COMPANY_ADMIN` | 자사 데이터 관리, 사용자 관리, 설정 변경 | +| **일반 사용자** | 회사코드 | `USER` | 자사 데이터 조회/수정 (권한 범위 내) | + +### 3.5 토큰 갱신 전략 + +- **자동 갱신**: 토큰이 1시간 이내 만료 시 응답 헤더(`X-New-Token`)에 새 토큰 포함 +- **명시적 갱신**: `/api/auth/refresh` 엔드포인트 호출 +- **만료 처리**: 만료된 토큰은 401 Unauthorized 응답 (`TOKEN_EXPIRED`) + +--- + +## 4. 비즈니스 도메인별 모듈 분류 + +### 4.1 관리자 영역 (Admin) + +**파일**: +- `adminController.ts`, `adminService.ts`, `adminRoutes.ts` + +**주요 기능**: +- 메뉴 관리 (CRUD, 복사, 상태 토글, 일괄 삭제) +- 사용자 관리 (등록, 수정, 상태 변경, 비밀번호 초기화) +- 회사 관리 (등록, 수정, 삭제, 조회) +- 부서 관리 (조회, 사용자-부서 통합 저장) +- 로케일 설정 (다국어 지원) +- 테이블 스키마 조회 (엑셀 매핑용) + +**특징**: +- 멀티테넌시 기반 회사별 데이터 격리 +- Super Admin만 회사 생성/삭제 가능 +- 사용자 변경 이력 추적 + +### 4.2 테이블 및 데이터 관리 (Table Management & Data) + +**파일**: +- `tableManagementController.ts`, `tableManagementService.ts` +- `dataController.ts`, `dataService.ts` +- `entityJoinController.ts`, `entityJoinService.ts` + +**주요 기능**: +- 테이블 목록 조회 (PostgreSQL information_schema 활용) +- 컬럼 정보 조회 (타입, 라벨, 제약조건, 참조 관계) +- 동적 테이블 데이터 CRUD (Raw Query 기반) +- 테이블 조인 설정 및 실행 (1:N, N:M 관계) +- 컬럼 라벨 및 설정 관리 (table_type_columns) + +**특징**: +- 캐시 기반 성능 최적화 (테이블/컬럼 정보) +- 멀티테넌시 자동 필터링 (`company_code` 조건) +- 코드 타입 컬럼 자동 처리 (공통 코드 연동) + +### 4.3 화면 관리 (Screen Management) + +**파일**: +- `screenManagementController.ts`, `screenManagementService.ts` +- `screenGroupController.ts`, `screenEmbeddingController.ts` + +**주요 기능**: +- 화면 메타데이터 관리 (테이블 연결, 컬럼 설정, 레이아웃) +- 화면 그룹 관리 (폴더 구조) +- 화면 임베딩 (부모-자식 화면 데이터 전달) +- 동적 폼 생성 (JSON 기반 폼 설정 → React 컴포넌트) + +**특징**: +- Low-Code 화면 구성 +- 웹 컴포넌트 타입 표준 기반 렌더링 +- 버튼 액션 표준 지원 (저장, 삭제, 조회, 커스텀) + +### 4.4 플로우 관리 (Flow Management) + +**파일**: +- `flowController.ts`, `flowService.ts` +- `flowExecutionService.ts`, `flowStepService.ts` +- `flowConnectionService.ts`, `flowDataMoveService.ts` + +**주요 기능**: +- 플로우 정의 관리 (작업 흐름 설계) +- 플로우 단계 관리 (스텝 생성, 수정, 삭제) +- 플로우 연결 관리 (스텝 간 조건부 연결) +- 데이터 이동 실행 (스텝 간 데이터 이동) +- 오딧 로그 조회 (변경 이력 추적) + +**특징**: +- 비주얼 워크플로우 엔진 +- 조건부 분기 지원 +- 배치 데이터 이동 지원 + +### 4.5 데이터플로우 (Dataflow) + +**파일**: +- `dataflowController.ts`, `dataflowService.ts` +- `dataflowDiagramController.ts`, `dataflowDiagramService.ts` +- `dataflowExecutionController.ts` + +**주요 기능**: +- 테이블 관계 정의 (1:1, 1:N, N:M) +- 데이터플로우 다이어그램 생성 (ERD 같은 시각화) +- 데이터플로우 실행 (자동 데이터 동기화) +- 관계 기반 데이터 조회 (조인 쿼리 자동 생성) + +**특징**: +- 그래프 기반 데이터 관계 모델링 +- 다이어그램별 관계 그룹화 + +### 4.6 배치 관리 (Batch Management) + +**파일**: +- `batchController.ts`, `batchService.ts` +- `batchSchedulerService.ts`, `batchExecutionLogService.ts` +- `batchExternalDbService.ts` + +**주요 기능**: +- 배치 설정 관리 (CRUD) +- Cron 기반 스케줄링 (node-cron) +- 배치 수동/자동 실행 +- 실행 이력 조회 (성공/실패 로그) +- 외부 DB 연동 배치 지원 + +**특징**: +- 실시간 스케줄러 업데이트 +- 다중 DB 간 데이터 동기화 +- 실행 시간 제한 및 오류 알림 + +### 4.7 외부 연동 (External Integration) + +**파일**: +- `externalDbConnectionController.ts`, `externalDbConnectionService.ts` +- `externalRestApiConnectionController.ts`, `externalRestApiConnectionService.ts` +- `externalCallController.ts`, `externalCallService.ts` +- `multiConnectionQueryService.ts` + +**주요 기능**: +- 외부 DB 연결 관리 (PostgreSQL, MySQL, MSSQL, Oracle, MariaDB) +- 외부 REST API 연결 관리 +- 멀티 DB 통합 쿼리 실행 +- 연결 테스트 및 상태 확인 +- 크레덴셜 암호화 저장 + +**특징**: +- 5종 DB 지원 (PostgreSQL, MySQL, MSSQL, Oracle, MariaDB) +- Connection Pool 기반 연결 관리 +- 비밀번호 암호화 (AES-256-CBC) + +### 4.8 메일 관리 (Mail Management) + +**파일**: +- `mailAccountFileController.ts`, `mailAccountFileService.ts` +- `mailTemplateFileController.ts`, `mailTemplateFileService.ts` +- `mailSendSimpleController.ts`, `mailSendSimpleService.ts` +- `mailSentHistoryController.ts`, `mailSentHistoryService.ts` +- `mailReceiveBasicController.ts`, `mailReceiveBasicService.ts` + +**주요 기능**: +- 메일 계정 관리 (SMTP/IMAP 설정) +- 메일 템플릿 관리 (JSON 기반 컴포넌트 조합) +- 메일 발송 (단일/대량, 첨부파일 지원) +- 발송 이력 조회 (30일 자동 삭제) +- 메일 수신함 조회 (IMAP) + +**특징**: +- Nodemailer 기반 발송 +- 템플릿 변수 치환 +- 대량 발송 지원 (100건/배치) +- 메일 예약 발송 + +### 4.9 대시보드 (Dashboard) + +**파일**: +- `DashboardController.ts`, `DashboardService.ts` + +**주요 기능**: +- 대시보드 위젯 관리 (차트, 테이블, 카드) +- 실시간 데이터 조회 (집계 쿼리) +- 사용자별 대시보드 설정 + +**특징**: +- JSON 기반 위젯 설정 +- 캐시 기반 성능 최적화 + +### 4.10 기타 도메인 + +**파일 및 주요 기능**: +- **리포트**: 리포트 생성 및 조회 (`reportController.ts`, `reportService.ts`) +- **파일**: 파일 업로드/다운로드 (`fileController.ts`, Multer) +- **배송/화물**: 배송 관리, 화물 추적 (`deliveryController.ts`) +- **리스크/알림**: 리스크 알림, 캐시 기반 자동 갱신 (`riskAlertController.ts`) +- **To-Do**: 할 일 관리 (`todoController.ts`) +- **예약**: 예약 요청 관리 (`bookingController.ts`) +- **디지털 트윈**: 야드 관제, 3D 레이아웃 (`digitalTwinController.ts`) +- **스케줄**: 스케줄 자동 생성 (`scheduleController.ts`) +- **작업 이력**: 작업 로그 조회 (`workHistoryController.ts`) +- **권한 그룹**: 권한 그룹 관리 (`roleController.ts`) +- **채번 규칙**: 자동 채번 규칙 (`numberingRuleController.ts`) +- **엔티티 검색**: 동적 엔티티 검색 (`entitySearchController.ts`) +- **연쇄 드롭다운**: 조건부 드롭다운 (`cascadingController.ts` 시리즈) + +--- + +## 5. 미들웨어 스택 구성 + +### 5.1 미들웨어 실행 순서 (app.ts 기준) + +``` +1. 프로세스 레벨 예외 처리 (unhandledRejection, uncaughtException) +2. 보안 헤더 (helmet) +3. 압축 (compression) +4. 바디 파싱 (express.json, express.urlencoded) +5. 정적 파일 서빙 (/uploads) +6. CORS (cors) +7. Rate Limiting (express-rate-limit) +8. 토큰 자동 갱신 (refreshTokenIfNeeded) +9. [라우트별 미들웨어] + ├─ authenticateToken (모든 /api/* 라우트) + ├─ 권한 미들웨어 (선택적) + └─ 컨트롤러 실행 +10. 404 핸들러 +11. 에러 핸들러 (errorHandler) +``` + +### 5.2 미들웨어 파일 + +#### `authMiddleware.ts` +- **authenticateToken**: JWT 토큰 검증 및 사용자 정보 설정 +- **optionalAuth**: 선택적 인증 (토큰 없어도 통과) +- **requireAdmin**: 관리자 권한 필수 (userId === 'plm_admin') +- **refreshTokenIfNeeded**: 토큰 자동 갱신 (1시간 이내 만료 시) +- **checkAuthStatus**: 인증 상태 확인 (유효성 검사만) + +#### `permissionMiddleware.ts` +- **requireSuperAdmin**: 슈퍼관리자 권한 필수 (companyCode === '*') +- **requireAdmin**: 관리자 이상 권한 필수 (Super Admin + Company Admin) +- **requireCompanyAccess**: 회사 데이터 접근 권한 체크 +- **requireUserManagement**: 사용자 관리 권한 체크 +- **requireCompanySettingsManagement**: 회사 설정 변경 권한 체크 +- **requireCompanyManagement**: 회사 생성/삭제 권한 체크 +- **requireDDLPermission**: DDL 실행 권한 체크 + +#### `superAdminMiddleware.ts` +- **requireSuperAdmin**: 슈퍼관리자 권한 확인 (DDL 전용) +- **validateDDLPermission**: DDL 실행 전 추가 보안 검증 (5초 간격 제한) +- **isSuperAdmin**: 슈퍼관리자 여부 확인 유틸 함수 +- **checkDDLPermission**: DDL 권한 체크 (미들웨어 없이 사용) + +#### `errorHandler.ts` +- **AppError**: 커스텀 에러 클래스 (statusCode, isOperational) +- **errorHandler**: 전역 에러 핸들러 (PostgreSQL, JWT 에러 처리) +- **notFoundHandler**: 404 에러 핸들러 + +### 5.3 보안 설정 + +```typescript +// helmet: 보안 헤더 설정 +helmet({ + contentSecurityPolicy: { + directives: { + 'frame-ancestors': ['self', 'http://localhost:9771', 'http://localhost:3000'] + } + } +}) + +// Rate Limiting: 1분당 10,000 요청 (개발), 100 요청 (운영) +rateLimit({ + windowMs: 1 * 60 * 1000, + max: process.env.NODE_ENV === 'development' ? 10000 : 100, + skip: (req) => { + // 헬스 체크, 자주 호출되는 API는 제외 + return req.path === '/health' + || req.path.includes('/table-management/') + || req.path.includes('/external-db-connections/') + } +}) + +// CORS: 환경별 origin 설정 +cors({ + origin: process.env.NODE_ENV === 'development' + ? true + : ['http://localhost:9771', 'http://39.117.244.52:5555'], + credentials: true +}) +``` + +--- + +## 6. 서비스 레이어 패턴 + +### 6.1 서비스 레이어 구조 + +``` +Controller (요청 처리) + ↓ +Service (비즈니스 로직) + ↓ +Database (Raw Query 실행) + ↓ +PostgreSQL (데이터 저장소) +``` + +### 6.2 데이터베이스 접근 방식 + +#### `db.ts` - Raw Query 매니저 + +```typescript +// 기본 쿼리 실행 +async function query(text: string, params?: any[]): Promise + +// 단일 행 조회 +async function queryOne(text: string, params?: any[]): Promise + +// 트랜잭션 +async function transaction(callback: (client: PoolClient) => Promise): Promise + +// 연결 풀 상태 +function getPoolStatus(): { totalCount, idleCount, waitingCount } +``` + +#### Connection Pool 설정 + +```typescript +new Pool({ + host: dbConfig.host, + port: dbConfig.port, + database: dbConfig.database, + user: dbConfig.user, + password: dbConfig.password, + + // 연결 풀 설정 + min: process.env.NODE_ENV === 'production' ? 5 : 2, + max: process.env.NODE_ENV === 'production' ? 20 : 10, + + // 타임아웃 설정 + connectionTimeoutMillis: 30000, // 30초 + idleTimeoutMillis: 600000, // 10분 + statement_timeout: 60000, // 60초 + query_timeout: 60000, + + application_name: 'WACE-PLM-Backend' +}) +``` + +### 6.3 서비스 패턴 예시 + +#### 멀티테넌시 쿼리 패턴 + +```typescript +// Super Admin: 모든 데이터 조회 +if (companyCode === '*') { + query = 'SELECT * FROM table_name ORDER BY company_code'; + params = []; +} +// 일반 사용자: 자사 데이터만 조회 (Super Admin 데이터 제외) +else { + query = ` + SELECT * FROM table_name + WHERE company_code = $1 AND company_code != '*' + ORDER BY created_at DESC + `; + params = [companyCode]; +} +``` + +#### 트랜잭션 패턴 + +```typescript +await transaction(async (client) => { + // 1. 부모 레코드 삽입 + const parent = await client.query( + 'INSERT INTO parent_table (...) VALUES (...) RETURNING *', + [...] + ); + + // 2. 자식 레코드 삽입 + await client.query( + 'INSERT INTO child_table (parent_id, ...) VALUES ($1, ...) RETURNING *', + [parent.rows[0].id, ...] + ); + + return { success: true }; +}); +``` + +#### 캐시 패턴 + +```typescript +// 캐시 조회 +const cachedData = cache.get(CacheKeys.TABLE_LIST); +if (cachedData) { + return cachedData; +} + +// DB 조회 +const data = await query('SELECT ...'); + +// 캐시 저장 (10분 TTL) +cache.set(CacheKeys.TABLE_LIST, data, 10 * 60 * 1000); + +return data; +``` + +### 6.4 외부 DB 커넥터 패턴 + +```typescript +// DatabaseConnectorFactory.ts +export class DatabaseConnectorFactory { + static createConnector(dbType: string, config: ConnectionConfig): DatabaseConnector { + switch (dbType) { + case 'postgresql': return new PostgreSQLConnector(config); + case 'mysql': return new MySQLConnector(config); + case 'mssql': return new MSSQLConnector(config); + case 'oracle': return new OracleConnector(config); + case 'mariadb': return new MariaDBConnector(config); + default: throw new Error(`Unsupported DB type: ${dbType}`); + } + } +} + +// 사용 예시 +const connector = DatabaseConnectorFactory.createConnector('mysql', config); +await connector.connect(); +const result = await connector.executeQuery('SELECT * FROM users'); +await connector.disconnect(); +``` + +--- + +## 7. 멀티테넌시 구현 방식 + +### 7.1 핵심 원칙 + +**CRITICAL PROJECT RULES**: +1. **모든 쿼리는 company_code 필터 필수** +2. **req.user!.companyCode 사용 (클라이언트 전송 값 신뢰 금지)** +3. **Super Admin (company_code = '*')만 전체 데이터 조회** +4. **일반 사용자는 company_code = '*' 데이터 조회 불가** + +### 7.2 쿼리 패턴 + +```typescript +const companyCode = req.user!.companyCode; + +// Super Admin: 모든 회사 데이터 조회 +if (companyCode === '*') { + query = 'SELECT * FROM users ORDER BY company_code'; + params = []; +} +// 일반 사용자: 자사 데이터만 조회 (Super Admin 제외) +else { + query = ` + SELECT * FROM users + WHERE company_code = $1 AND company_code != '*' + `; + params = [companyCode]; +} +``` + +### 7.3 테이블 설계 + +```sql +-- 모든 비즈니스 테이블에 company_code 컬럼 필수 +CREATE TABLE table_name ( + id SERIAL PRIMARY KEY, + company_code VARCHAR(50) NOT NULL, -- 회사 코드 + ... + INDEX idx_company_code (company_code) +); + +-- Super Admin 데이터: company_code = '*' +-- 회사별 데이터: company_code = '회사코드' +``` + +### 7.4 회사 전환 (Super Admin 전용) + +```typescript +// POST /api/auth/switch-company +{ + targetCompanyCode: "ILSHIN" // 전환할 회사 코드 +} + +// 응답 +{ + success: true, + token: "새로운 JWT 토큰", // companyCode가 변경된 토큰 + userInfo: { companyCode: "ILSHIN", ... } +} +``` + +### 7.5 권한 체크 + +```typescript +// 회사 데이터 접근 권한 확인 +export function canAccessCompanyData(user: PersonBean, targetCompanyCode: string): boolean { + // Super Admin: 모든 회사 접근 가능 + if (user.companyCode === '*') { + return true; + } + + // 일반 사용자: 자사만 접근 가능 + return user.companyCode === targetCompanyCode; +} +``` + +--- + +## 8. 에러 핸들링 전략 + +### 8.1 에러 핸들링 구조 + +``` +Controller (try-catch) + ↓ 에러 발생 +Service (throw error) + ↓ +errorHandler (미들웨어) + ├─ PostgreSQL 에러 처리 + ├─ JWT 에러 처리 + ├─ 커스텀 에러 처리 (AppError) + └─ 응답 전송 (JSON) +``` + +### 8.2 커스텀 에러 클래스 + +```typescript +export class AppError extends Error { + public statusCode: number; + public isOperational: boolean; + + constructor(message: string, statusCode: number = 500) { + super(message); + this.statusCode = statusCode; + this.isOperational = true; + } +} + +// 사용 예시 +throw new AppError('중복된 데이터가 존재합니다.', 400); +``` + +### 8.3 PostgreSQL 에러 처리 + +```typescript +// errorHandler.ts +if (pgError.code === '23505') { // unique_violation + error = new AppError('중복된 데이터가 존재합니다.', 400); +} else if (pgError.code === '23503') { // foreign_key_violation + error = new AppError('참조 무결성 제약 조건 위반입니다.', 400); +} else if (pgError.code === '23502') { // not_null_violation + error = new AppError('필수 입력값이 누락되었습니다.', 400); +} +``` + +### 8.4 에러 응답 형식 + +```json +{ + "success": false, + "error": { + "code": "UNIQUE_VIOLATION", + "message": "중복된 데이터가 존재합니다.", + "details": "사용자 ID가 이미 존재합니다.", + "stack": "..." // 개발 환경에서만 포함 + } +} +``` + +### 8.5 에러 로깅 + +```typescript +// logger.ts (Winston 기반) +logger.error({ + message: error.message, + stack: error.stack, + url: req.url, + method: req.method, + ip: req.ip, + userAgent: req.get('User-Agent') +}); +``` + +### 8.6 프로세스 레벨 예외 처리 + +```typescript +// app.ts +process.on('unhandledRejection', (reason, promise) => { + logger.error('⚠️ Unhandled Promise Rejection:', reason); + // 프로세스 종료하지 않고 로깅만 수행 +}); + +process.on('uncaughtException', (error) => { + logger.error('🔥 Uncaught Exception:', error); + // 심각한 에러 시 graceful shutdown 고려 +}); +``` + +--- + +## 9. 파일 업로드/다운로드 처리 + +### 9.1 파일 업로드 + +**파일**: `fileController.ts`, `fileRoutes.ts` + +```typescript +// Multer 설정 +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, 'uploads/'); + }, + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname)); + } +}); + +const upload = multer({ + storage, + limits: { fileSize: 10 * 1024 * 1024 }, // 10MB + fileFilter: (req, file, cb) => { + // 허용된 확장자 체크 + const allowedTypes = /jpeg|jpg|png|gif|pdf|doc|docx|xls|xlsx/; + const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); + const mimetype = allowedTypes.test(file.mimetype); + + if (mimetype && extname) { + return cb(null, true); + } else { + cb(new Error('허용되지 않는 파일 형식입니다.')); + } + } +}); + +// 라우트 +router.post('/upload', authenticateToken, upload.single('file'), uploadFile); +router.post('/upload-multiple', authenticateToken, upload.array('files', 10), uploadMultipleFiles); +``` + +### 9.2 파일 다운로드 + +```typescript +// 정적 파일 서빙 (app.ts) +app.use('/uploads', + (req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + next(); + }, + express.static(path.join(process.cwd(), 'uploads')) +); + +// 다운로드 엔드포인트 +router.get('/download/:filename', authenticateToken, async (req, res) => { + const filename = req.params.filename; + const filePath = path.join(process.cwd(), 'uploads', filename); + + if (!fs.existsSync(filePath)) { + return res.status(404).json({ error: '파일을 찾을 수 없습니다.' }); + } + + res.download(filePath); +}); +``` + +### 9.3 화면별 파일 관리 + +**파일**: `screenFileController.ts`, `screenFileService.ts` + +```typescript +// 화면별 파일 업로드 +router.post('/screens/:screenId/files', authenticateToken, upload.single('file'), uploadScreenFile); + +// 화면별 파일 목록 조회 +router.get('/screens/:screenId/files', authenticateToken, getScreenFiles); + +// 파일 삭제 +router.delete('/screens/:screenId/files/:fileId', authenticateToken, deleteScreenFile); +``` + +--- + +## 10. 외부 연동 + +### 10.1 외부 DB 연결 + +**지원 DB**: PostgreSQL, MySQL, MSSQL, Oracle, MariaDB + +**파일**: +- `externalDbConnectionService.ts` +- `PostgreSQLConnector.ts`, `MySQLConnector.ts`, `MSSQLConnector.ts`, `OracleConnector.ts`, `MariaDBConnector.ts` + +```typescript +// 외부 DB 연결 설정 +{ + connection_name: "외부 ERP DB", + db_type: "mysql", + host: "192.168.0.100", + port: 3306, + database: "erp_db", + username: "erp_user", + password: "encrypted_password", // AES-256-CBC 암호화 + company_code: "ILSHIN", + is_active: "Y" +} + +// 연결 테스트 +POST /api/external-db-connections/:id/test +{ + success: true, + message: "연결 테스트 성공" +} + +// 쿼리 실행 +POST /api/external-db-connections/:id/query +{ + query: "SELECT * FROM products WHERE category = ?", + params: ["전자제품"] +} +``` + +### 10.2 외부 REST API 연결 + +**파일**: `externalRestApiConnectionService.ts` + +```typescript +// 외부 REST API 연결 설정 +{ + connection_name: "날씨 API", + base_url: "https://api.weather.com", + auth_type: "bearer", // bearer, api-key, basic, oauth2 + auth_credentials: { + token: "encrypted_token" + }, + headers: { + "Content-Type": "application/json" + }, + company_code: "ILSHIN" +} + +// API 호출 +POST /api/external-rest-api-connections/:id/call +{ + method: "GET", + endpoint: "/weather", + params: { city: "Seoul" } +} +``` + +### 10.3 멀티 DB 통합 쿼리 + +**파일**: `multiConnectionQueryService.ts` + +```typescript +// 여러 DB에서 동시 쿼리 실행 +POST /api/multi-connection/query +{ + connections: [ + { + connectionId: 1, + query: "SELECT * FROM orders WHERE status = 'pending'" + }, + { + connectionId: 2, + query: "SELECT * FROM inventory WHERE quantity < 10" + } + ] +} + +// 응답 +{ + success: true, + results: [ + { connectionId: 1, data: [...], rowCount: 15 }, + { connectionId: 2, data: [...], rowCount: 8 } + ] +} +``` + +### 10.4 Open API Proxy + +**파일**: `openApiProxyController.ts` + +```typescript +// 날씨 API +GET /api/open-api/weather?city=Seoul + +// 환율 API +GET /api/open-api/exchange-rate?from=USD&to=KRW +``` + +--- + +## 11. 배치/스케줄 처리 + +### 11.1 배치 스케줄러 + +**파일**: `batchSchedulerService.ts` + +```typescript +// 배치 설정 +{ + batch_name: "일일 재고 동기화", + batch_type: "external_db", // external_db, rest_api, internal + cron_schedule: "0 2 * * *", // 매일 새벽 2시 + source_connection_id: 1, + source_query: "SELECT * FROM inventory", + target_table: "inventory_sync", + mapping: { + product_id: "item_id", + quantity: "stock_qty" + }, + is_active: "Y" +} + +// 스케줄러 초기화 (서버 시작 시) +await BatchSchedulerService.initializeScheduler(); + +// 배치 수동 실행 +POST /api/batch-configs/:id/execute +``` + +### 11.2 Cron 기반 자동 실행 + +```typescript +// node-cron 사용 +const task = cron.schedule( + config.cron_schedule, // "0 2 * * *" + async () => { + logger.info(`배치 실행 시작: ${config.batch_name}`); + await executeBatchConfig(config); + }, + { timezone: 'Asia/Seoul' } +); + +// 스케줄 업데이트 +await BatchSchedulerService.updateBatchSchedule(configId); + +// 스케줄 제거 +await BatchSchedulerService.removeBatchSchedule(configId); +``` + +### 11.3 배치 실행 로그 + +**파일**: `batchExecutionLogService.ts` + +```typescript +// 배치 실행 이력 +{ + batch_config_id: 1, + execution_status: "success", // success, failed, running + start_time: "2024-12-24T02:00:00Z", + end_time: "2024-12-24T02:05:23Z", + rows_processed: 1523, + rows_inserted: 1200, + rows_updated: 300, + rows_failed: 23, + error_message: null, + execution_log: "..." +} + +// 배치 이력 조회 +GET /api/batch-execution-logs?batch_config_id=1&page=1&limit=10 +``` + +### 11.4 자동 스케줄 작업 + +**파일**: `app.ts` + +```typescript +// 메일 자동 삭제 (매일 새벽 2시) +cron.schedule('0 2 * * *', async () => { + logger.info('🗑️ 30일 지난 삭제된 메일 자동 삭제 시작...'); + const deletedCount = await mailSentHistoryService.cleanupOldDeletedMails(); + logger.info(`✅ 30일 지난 메일 ${deletedCount}개 자동 삭제 완료`); +}); + +// 리스크/알림 자동 갱신 (10분 간격) +const cacheService = RiskAlertCacheService.getInstance(); +cacheService.startAutoRefresh(); +``` + +--- + +## 12. 컨트롤러/서비스 상세 역할 + +### 12.1 인증 및 관리자 + +#### `authController.ts` / `authService.ts` +- **로그인**: 비밀번호 검증, JWT 토큰 발급, 로그인 로그 기록 +- **회원가입**: 공차중계 사용자 등록 +- **토큰 갱신**: accessToken, refreshToken 갱신 +- **현재 사용자 정보**: JWT 기반 사용자 정보 조회 +- **로그아웃**: 토큰 무효화 (클라이언트 측 처리) +- **회사 전환**: Super Admin 전용 회사 전환 + +#### `adminController.ts` / `adminService.ts` +- **메뉴 관리**: 메뉴 트리 조회, 메뉴 CRUD, 메뉴 복사, 상태 토글, 일괄 삭제 +- **사용자 관리**: 사용자 목록, 사용자 CRUD, 상태 변경, 비밀번호 초기화, 변경 이력 +- **회사 관리**: 회사 목록, 회사 CRUD (Super Admin 전용) +- **부서 관리**: 부서 목록, 사용자-부서 통합 저장 +- **로케일 설정**: 사용자 언어 설정 (ko, en) +- **테이블 스키마**: 엑셀 업로드 컬럼 매핑용 스키마 조회 + +### 12.2 테이블 및 데이터 + +#### `tableManagementController.ts` / `tableManagementService.ts` +- **테이블 목록**: PostgreSQL information_schema 조회 +- **컬럼 정보**: 컬럼 타입, 제약조건, 라벨, 참조 관계 +- **컬럼 라벨 관리**: 다국어 라벨, 표시 순서, 표시 여부 +- **컬럼 설정**: 입력 타입, 코드 카테고리, 필수 여부, 기본값 +- **테이블 조회 설정**: 조회 컬럼, 정렬 순서, 필터 조건 +- **캐시 관리**: 테이블/컬럼 정보 캐시 (10분 TTL) + +#### `dataController.ts` / `dataService.ts` +- **동적 데이터 조회**: 테이블명 기반 데이터 조회 (페이지네이션, 필터, 정렬) +- **동적 데이터 생성**: INSERT 쿼리 자동 생성 +- **동적 데이터 수정**: UPDATE 쿼리 자동 생성 +- **동적 데이터 삭제**: DELETE 쿼리 자동 생성 (논리 삭제 지원) +- **멀티테넌시 자동 필터링**: company_code 자동 추가 + +#### `entityJoinController.ts` / `entityJoinService.ts` +- **조인 설정 관리**: 테이블 간 조인 관계 설정 (1:N, N:M) +- **조인 쿼리 실행**: 설정된 조인 관계 기반 데이터 조회 +- **참조 데이터 캐싱**: 자주 사용되는 참조 데이터 캐시 + +### 12.3 화면 관리 + +#### `screenManagementController.ts` / `screenManagementService.ts` +- **화면 메타데이터 관리**: 화면 설정 (테이블, 컬럼, 레이아웃) +- **화면 목록 조회**: 회사별, 화면 그룹별 필터링 +- **화면 복사**: 화면 설정 복제 +- **화면 삭제**: 논리 삭제 +- **화면 설정 조회**: 화면 메타데이터 상세 조회 + +#### `dynamicFormController.ts` / `dynamicFormService.ts` +- **동적 폼 생성**: JSON 기반 폼 설정 → React 컴포넌트 +- **폼 유효성 검사**: 필수 입력, 데이터 타입, 길이 제한 +- **폼 제출**: 데이터 저장 (INSERT/UPDATE) + +#### `buttonActionStandardController.ts` / `buttonActionStandardService.ts` +- **버튼 액션 표준**: 저장, 삭제, 조회, 엑셀 다운로드, 커스텀 액션 +- **버튼 액션 설정**: 액션 타입, 파라미터, 권한 설정 + +### 12.4 플로우 및 데이터플로우 + +#### `flowController.ts` / `flowService.ts` +- **플로우 정의**: 플로우 생성, 수정, 삭제, 조회 +- **플로우 단계**: 단계 생성, 수정, 삭제, 순서 변경 +- **플로우 연결**: 단계 간 연결 (조건부 분기) +- **데이터 이동**: 단계 간 데이터 이동 (단일/배치) +- **오딧 로그**: 플로우 실행 이력 조회 + +#### `dataflowController.ts` / `dataflowService.ts` +- **테이블 관계 정의**: 테이블 간 관계 설정 (1:1, 1:N, N:M) +- **데이터플로우 다이어그램**: ERD 같은 시각화 +- **데이터플로우 실행**: 관계 기반 데이터 동기화 +- **관계 조회**: 다이어그램별 관계 목록 + +### 12.5 배치 관리 + +#### `batchController.ts` / `batchService.ts` +- **배치 설정 관리**: 배치 CRUD +- **배치 실행**: 수동 실행, 스케줄 실행 +- **배치 이력**: 실행 로그, 성공/실패 통계 +- **커넥션 조회**: 사용 가능한 외부 DB/API 목록 + +#### `batchSchedulerService.ts` +- **스케줄러 초기화**: 서버 시작 시 활성 배치 등록 +- **스케줄 등록**: Cron 표현식 기반 스케줄 등록 +- **스케줄 업데이트**: 배치 설정 변경 시 스케줄 재등록 +- **스케줄 제거**: 배치 삭제 시 스케줄 제거 + +#### `batchExternalDbService.ts` +- **외부 DB 배치**: 외부 DB → 내부 DB 데이터 동기화 +- **컬럼 매핑**: 소스-타겟 컬럼 매핑 +- **데이터 변환**: 타입 변환, 값 변환 + +### 12.6 외부 연동 + +#### `externalDbConnectionController.ts` / `externalDbConnectionService.ts` +- **외부 DB 연결 관리**: 연결 CRUD +- **연결 테스트**: 연결 유효성 검증 +- **쿼리 실행**: 외부 DB 쿼리 실행 +- **테이블 목록**: 외부 DB 테이블 목록 조회 +- **컬럼 정보**: 외부 DB 컬럼 정보 조회 + +#### `externalRestApiConnectionController.ts` / `externalRestApiConnectionService.ts` +- **REST API 연결 관리**: API 연결 CRUD +- **API 호출**: 외부 API 호출 (GET, POST, PUT, DELETE) +- **인증 처리**: Bearer, API Key, Basic, OAuth2 +- **응답 캐싱**: API 응답 캐싱 (TTL 설정) + +#### `multiConnectionQueryService.ts` +- **멀티 DB 통합 쿼리**: 여러 DB에서 동시 쿼리 실행 +- **결과 병합**: 여러 DB 쿼리 결과 병합 +- **오류 처리**: 부분 실패 시 에러 로그 기록 + +### 12.7 메일 관리 + +#### `mailAccountFileController.ts` / `mailAccountFileService.ts` +- **메일 계정 관리**: SMTP/IMAP 계정 CRUD +- **계정 테스트**: 연결 유효성 검증 +- **계정 상태**: 활성/비활성 상태 관리 + +#### `mailTemplateFileController.ts` / `mailTemplateFileService.ts` +- **메일 템플릿 관리**: 템플릿 CRUD +- **템플릿 컴포넌트**: JSON 기반 컴포넌트 조합 (헤더, 본문, 버튼, 푸터) +- **변수 치환**: {변수명} 형태의 변수 치환 + +#### `mailSendSimpleController.ts` / `mailSendSimpleService.ts` +- **메일 발송**: 단일 발송, 대량 발송 (100건/배치) +- **첨부파일**: 다중 첨부파일 지원 +- **참조/숨은참조**: CC, BCC 지원 +- **발송 이력**: 발송 성공/실패 로그 기록 + +#### `mailSentHistoryController.ts` / `mailSentHistoryService.ts` +- **발송 이력 조회**: 페이지네이션, 필터링, 검색 +- **이력 삭제**: 논리 삭제 (30일 후 물리 삭제) +- **자동 삭제**: Cron 기반 자동 삭제 (매일 새벽 2시) + +#### `mailReceiveBasicController.ts` / `mailReceiveBasicService.ts` +- **메일 수신**: IMAP 기반 메일 수신 +- **메일 목록**: 수신함 목록 조회 +- **메일 읽기**: 메일 상세 조회, 첨부파일 다운로드 + +### 12.8 대시보드 및 리포트 + +#### `DashboardController.ts` / `DashboardService.ts` +- **대시보드 위젯**: 차트, 테이블, 카드 위젯 +- **실시간 데이터**: 집계 쿼리 기반 실시간 데이터 조회 +- **사용자 설정**: 사용자별 대시보드 레이아웃 저장 + +#### `reportController.ts` / `reportService.ts` +- **리포트 생성**: 동적 리포트 생성 (PDF, Excel, Word) +- **리포트 템플릿**: 템플릿 기반 리포트 생성 +- **리포트 스케줄**: 정기 리포트 자동 생성 및 메일 발송 + +### 12.9 기타 도메인 + +#### `deliveryController.ts` / `deliveryService.ts` +- **배송 관리**: 배송 정보 등록, 조회, 수정 +- **화물 추적**: 배송 상태 추적 + +#### `riskAlertController.ts` / `riskAlertService.ts` / `riskAlertCacheService.ts` +- **리스크 알림**: 리스크 기준 설정, 알림 발생 +- **자동 갱신**: 10분 간격 자동 갱신 (캐시 기반) + +#### `todoController.ts` / `todoService.ts` +- **To-Do 관리**: 할 일 CRUD, 상태 변경 (대기, 진행, 완료) + +#### `bookingController.ts` / `bookingService.ts` +- **예약 관리**: 예약 요청 CRUD, 승인/거부 + +#### `digitalTwinController.ts` / `digitalTwinLayoutController.ts` / `digitalTwinDataController.ts` +- **디지털 트윈**: 야드 관제, 3D 레이아웃, 실시간 데이터 시각화 + +#### `scheduleController.ts` / `scheduleService.ts` +- **스케줄 자동 생성**: 작업 스케줄 자동 생성 (규칙 기반) + +#### `workHistoryController.ts` / `workHistoryService.ts` +- **작업 이력**: 작업 로그 조회, 필터링, 검색 + +#### `roleController.ts` / `roleService.ts` +- **권한 그룹**: 권한 그룹 CRUD, 사용자-권한 매핑 + +#### `numberingRuleController.ts` / `numberingRuleService.ts` +- **채번 규칙**: 자동 채번 규칙 설정 (접두사, 연번, 접미사) + +#### `entitySearchController.ts` / `entitySearchService.ts` +- **엔티티 검색**: 동적 엔티티 검색 (테이블, 컬럼, 조건 기반) + +#### `cascadingController.ts` 시리즈 +- **연쇄 드롭다운**: 조건부 드롭다운 관계 설정 +- **자동 입력**: 연쇄 자동 입력 관계 설정 +- **상호 배제**: 상호 배타적 선택 관계 설정 +- **다단계 계층**: 계층 구조 관계 설정 + +--- + +## 📊 통계 요약 + +| 구분 | 개수 | +|------|------| +| **컨트롤러** | 69개 | +| **서비스** | 87개 | +| **라우터** | 77개 | +| **미들웨어** | 4개 | +| **엔드포인트** | 200개 이상 | +| **데이터베이스 커넥터** | 5종 (PostgreSQL, MySQL, MSSQL, Oracle, MariaDB) | +| **유틸리티** | 16개 | +| **타입 정의** | 26개 | + +--- + +## 🔧 기술 스택 + +```json +{ + "런타임": "Node.js 20.10.0+", + "언어": "TypeScript 5.3.3", + "프레임워크": "Express 4.18.2", + "데이터베이스": "PostgreSQL (pg 8.16.3)", + "인증": "JWT (jsonwebtoken 9.0.2)", + "암호화": "BCrypt (bcryptjs 2.4.3)", + "로깅": "Winston 3.11.0", + "스케줄링": "node-cron 4.2.1", + "메일": "Nodemailer 6.10.1 + IMAP 0.8.19", + "파일 업로드": "Multer 1.4.5", + "보안": "Helmet 7.1.0", + "외부 DB": "mysql2, mssql, oracledb", + "캐싱": "In-Memory Cache (Map 기반)", + "압축": "compression 1.7.4", + "Rate Limiting": "express-rate-limit 7.1.5" +} +``` + +--- + +## 📁 핵심 파일 경로 + +``` +backend-node/ +├── src/ +│ ├── app.ts # 앱 진입점 +│ ├── config/environment.ts # 환경변수 설정 +│ ├── database/ +│ │ ├── db.ts # Raw Query 매니저 +│ │ ├── DatabaseConnectorFactory.ts # DB 커넥터 팩토리 +│ │ └── [DB]Connector.ts # 각 DB별 커넥터 +│ ├── middleware/ +│ │ ├── authMiddleware.ts # JWT 인증 +│ │ ├── permissionMiddleware.ts # 권한 체크 +│ │ ├── superAdminMiddleware.ts # Super Admin 체크 +│ │ └── errorHandler.ts # 에러 핸들링 +│ ├── utils/ +│ │ ├── logger.ts # Winston 로거 +│ │ ├── jwtUtils.ts # JWT 유틸 +│ │ ├── encryptUtil.ts # BCrypt 암호화 +│ │ ├── passwordEncryption.ts # AES 암호화 +│ │ ├── cache.ts # 캐시 유틸 +│ │ └── permissionUtils.ts # 권한 유틸 +│ └── types/ +│ ├── auth.ts # 인증 타입 +│ ├── tableManagement.ts # 테이블 관리 타입 +│ └── ... +└── package.json # 의존성 관리 +``` + +--- + +## 🚀 서버 시작 프로세스 + +``` +1. dotenv 환경변수 로드 +2. Express 앱 생성 +3. 미들웨어 설정 (helmet, cors, compression, rate-limit) +4. 데이터베이스 연결 풀 초기화 +5. 라우터 등록 (77개 라우터) +6. 에러 핸들러 등록 +7. 서버 리스닝 (기본 포트: 8080) +8. 데이터베이스 마이그레이션 실행 +9. 배치 스케줄러 초기화 +10. 리스크/알림 자동 갱신 시작 +11. 메일 자동 삭제 스케줄러 시작 +``` + +--- + +## 🔒 보안 고려사항 + +1. **JWT 기반 인증**: 세션 없이 무상태(Stateless) 인증 +2. **비밀번호 암호화**: BCrypt (12 rounds) +3. **외부 DB 크레덴셜 암호화**: AES-256-CBC +4. **SQL Injection 방지**: Parameterized Query 필수 +5. **XSS 방지**: Helmet 보안 헤더 +6. **CSRF 방지**: CORS 설정 + JWT +7. **Rate Limiting**: 1분당 요청 수 제한 +8. **DDL 실행 제한**: Super Admin만 가능 + 5초 간격 제한 +9. **멀티테넌시 격리**: company_code 자동 필터링 +10. **에러 정보 노출 방지**: 운영 환경에서 스택 트레이스 숨김 + +--- + +## 📝 추천 개선 사항 + +1. **API 문서화**: Swagger/OpenAPI 자동 생성 +2. **단위 테스트**: Jest 기반 테스트 커버리지 확대 +3. **Redis 캐싱**: In-Memory 캐시 → Redis 전환 +4. **로그 중앙화**: Winston → ELK Stack 연동 +5. **성능 모니터링**: APM 도구 연동 (New Relic, Datadog) +6. **Docker 컨테이너화**: Dockerfile 및 docker-compose 개선 +7. **CI/CD 파이프라인**: GitHub Actions, Jenkins 연동 +8. **API Rate Limiting 세분화**: 엔드포인트별 제한 설정 +9. **WebSocket 지원**: 실시간 알림 및 데이터 업데이트 +10. **GraphQL API**: REST API + GraphQL 병행 지원 + +--- + +**문서 작성자**: WACE 백엔드 전문가 +**최종 수정일**: 2026-02-06 +**버전**: 1.0.0 + diff --git a/docs/backend-architecture-detailed-analysis.md b/docs/backend-architecture-detailed-analysis.md new file mode 100644 index 00000000..534cf7a3 --- /dev/null +++ b/docs/backend-architecture-detailed-analysis.md @@ -0,0 +1,1855 @@ +# WACE ERP Backend Architecture - 상세 분석 문서 + +> **작성일**: 2026-02-06 +> **작성자**: Backend Specialist +> **목적**: WACE ERP 시스템 백엔드 전체 아키텍처 분석 및 워크플로우 문서화 + +--- + +## 📑 목차 + +1. [전체 개요](#1-전체-개요) +2. [디렉토리 구조](#2-디렉토리-구조) +3. [기술 스택](#3-기술-스택) +4. [미들웨어 스택](#4-미들웨어-스택) +5. [인증/인가 시스템](#5-인증인가-시스템) +6. [멀티테넌시 구현](#6-멀티테넌시-구현) +7. [API 라우트 전체 목록](#7-api-라우트-전체-목록) +8. [비즈니스 도메인별 모듈](#8-비즈니스-도메인별-모듈) +9. [데이터베이스 접근 방식](#9-데이터베이스-접근-방식) +10. [외부 시스템 연동](#10-외부-시스템-연동) +11. [배치/스케줄 처리](#11-배치스케줄-처리) +12. [파일 처리](#12-파일-처리) +13. [에러 핸들링](#13-에러-핸들링) +14. [로깅 시스템](#14-로깅-시스템) +15. [보안 및 권한 관리](#15-보안-및-권한-관리) +16. [성능 최적화](#16-성능-최적화) + +--- + +## 1. 전체 개요 + +### 1.1 프로젝트 정보 +- **프로젝트명**: WACE ERP Backend (Node.js) +- **언어**: TypeScript (Strict Mode) +- **런타임**: Node.js 20.10.0+ +- **프레임워크**: Express.js +- **데이터베이스**: PostgreSQL (Raw Query 기반) +- **포트**: 8080 (기본값) + +### 1.2 아키텍처 특징 +1. **Layered Architecture**: Controller → Service → Database 3계층 구조 +2. **Multi-tenancy**: company_code 기반 완전한 데이터 격리 +3. **JWT 인증**: Stateless 토큰 기반 인증 시스템 +4. **Raw Query**: ORM 없이 PostgreSQL 직접 쿼리 (성능 최적화) +5. **Connection Pool**: pg 라이브러리 기반 안정적인 연결 관리 +6. **Type-Safe**: TypeScript 타입 시스템 적극 활용 + +### 1.3 주요 기능 +- 관리자 기능 (사용자/권한/메뉴 관리) +- 테이블/화면 메타데이터 관리 (동적 화면 생성) +- 플로우 관리 (워크플로우 엔진) +- 데이터플로우 다이어그램 (ERD/관계도) +- 외부 DB 연동 (PostgreSQL, MySQL, MSSQL, Oracle) +- 외부 REST API 연동 +- 배치 자동 실행 (Cron 스케줄러) +- 메일 발송/수신 +- 파일 업로드/다운로드 +- 다국어 지원 +- 대시보드/리포트 + +--- + +## 2. 디렉토리 구조 + +``` +backend-node/ +├── src/ +│ ├── app.ts # Express 앱 진입점 +│ ├── config/ # 환경 설정 +│ │ ├── environment.ts # 환경변수 관리 +│ │ └── multerConfig.ts # 파일 업로드 설정 +│ ├── controllers/ # 컨트롤러 (70+ 파일) +│ │ ├── authController.ts +│ │ ├── adminController.ts +│ │ ├── tableManagementController.ts +│ │ ├── flowController.ts +│ │ ├── dataflowController.ts +│ │ ├── batchController.ts +│ │ └── ... +│ ├── services/ # 비즈니스 로직 (80+ 파일) +│ │ ├── authService.ts +│ │ ├── adminService.ts +│ │ ├── tableManagementService.ts +│ │ ├── flowExecutionService.ts +│ │ ├── batchSchedulerService.ts +│ │ └── ... +│ ├── routes/ # API 라우터 (70+ 파일) +│ │ ├── authRoutes.ts +│ │ ├── adminRoutes.ts +│ │ ├── tableManagementRoutes.ts +│ │ ├── flowRoutes.ts +│ │ └── ... +│ ├── middleware/ # 미들웨어 (4개) +│ │ ├── authMiddleware.ts # JWT 인증 +│ │ ├── permissionMiddleware.ts # 권한 체크 +│ │ ├── superAdminMiddleware.ts # 슈퍼관리자 전용 +│ │ └── errorHandler.ts # 에러 핸들러 +│ ├── database/ # DB 연결 +│ │ ├── db.ts # PostgreSQL Pool 관리 +│ │ ├── DatabaseConnectorFactory.ts # 외부 DB 연결 +│ │ └── runMigration.ts # 마이그레이션 +│ ├── types/ # TypeScript 타입 정의 (26개) +│ │ ├── auth.ts +│ │ ├── batchTypes.ts +│ │ ├── flow.ts +│ │ └── ... +│ ├── utils/ # 유틸리티 함수 +│ │ ├── jwtUtils.ts # JWT 토큰 관리 +│ │ ├── permissionUtils.ts # 권한 체크 +│ │ ├── logger.ts # Winston 로거 +│ │ ├── encryptUtil.ts # 암호화/복호화 +│ │ ├── passwordEncryption.ts # 비밀번호 암호화 +│ │ └── ... +│ └── interfaces/ # 인터페이스 +│ └── DatabaseConnector.ts # DB 커넥터 인터페이스 +├── scripts/ # 스크립트 +│ ├── dev/ # 개발 환경 스크립트 +│ └── prod/ # 운영 환경 스크립트 +├── data/ # 정적 데이터 (JSON) +├── uploads/ # 업로드된 파일 +├── logs/ # 로그 파일 +├── package.json # NPM 의존성 +├── tsconfig.json # TypeScript 설정 +└── .env # 환경변수 +``` + +--- + +## 3. 기술 스택 + +### 3.1 핵심 라이브러리 + +```json +{ + "dependencies": { + "express": "^4.18.2", // 웹 프레임워크 + "pg": "^8.16.3", // PostgreSQL 클라이언트 + "jsonwebtoken": "^9.0.2", // JWT 토큰 + "bcryptjs": "^2.4.3", // 비밀번호 암호화 + "dotenv": "^16.3.1", // 환경변수 + "cors": "^2.8.5", // CORS 처리 + "helmet": "^7.1.0", // 보안 헤더 + "compression": "^1.7.4", // Gzip 압축 + "express-rate-limit": "^7.1.5", // Rate Limiting + "winston": "^3.11.0", // 로깅 + "multer": "^1.4.5-lts.1", // 파일 업로드 + "node-cron": "^4.2.1", // Cron 스케줄러 + "axios": "^1.11.0", // HTTP 클라이언트 + "nodemailer": "^6.10.1", // 메일 발송 + "imap": "^0.8.19", // 메일 수신 + "mysql2": "^3.15.0", // MySQL 연결 + "mssql": "^11.0.1", // MSSQL 연결 + "oracledb": "^6.9.0", // Oracle 연결 + "uuid": "^13.0.0", // UUID 생성 + "joi": "^17.11.0" // 데이터 검증 + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/pg": "^8.15.5", + "typescript": "^5.3.3", + "nodemon": "^3.1.10", + "ts-node": "^10.9.2", + "jest": "^29.7.0", + "prettier": "^3.1.0", + "eslint": "^8.55.0" + } +} +``` + +### 3.2 데이터베이스 연결 +- **메인 DB**: PostgreSQL (pg 라이브러리) +- **외부 DB 지원**: MySQL, MSSQL, Oracle, PostgreSQL +- **Connection Pool**: Min 2~5 / Max 10~20 +- **Timeout**: Connection 30s / Query 60s + +--- + +## 4. 미들웨어 스택 + +### 4.1 미들웨어 실행 순서 (app.ts) + +```typescript +// 1. 프로세스 레벨 예외 처리 +process.on('unhandledRejection', ...) +process.on('uncaughtException', ...) +process.on('SIGTERM', ...) +process.on('SIGINT', ...) + +// 2. 보안 미들웨어 +app.use(helmet({ + contentSecurityPolicy: { ... }, // CSP 설정 + frameguard: { ... } // Iframe 보호 +})) + +// 3. 압축 미들웨어 +app.use(compression()) + +// 4. Body Parser +app.use(express.json({ limit: '10mb' })) +app.use(express.urlencoded({ extended: true, limit: '10mb' })) + +// 5. 정적 파일 서빙 (/uploads) +app.use('/uploads', express.static(...)) + +// 6. CORS 설정 +app.use(cors({ + origin: [...], // 허용 도메인 + credentials: true, // 쿠키 포함 + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'] +})) + +// 7. Rate Limiting (1분에 10000회) +app.use('/api/', limiter) + +// 8. 토큰 자동 갱신 (1시간 이내 만료 시 갱신) +app.use('/api/', refreshTokenIfNeeded) + +// 9. API 라우터 (70+개) +app.use('/api/auth', authRoutes) +app.use('/api/admin', adminRoutes) +// ... + +// 10. 404 핸들러 +app.use('*', notFoundHandler) + +// 11. 에러 핸들러 +app.use(errorHandler) +``` + +### 4.2 인증 미들웨어 체인 + +```typescript +// 기본 인증 +authenticateToken → Controller + +// 관리자 권한 +authenticateToken → requireAdmin → Controller + +// 슈퍼관리자 권한 +authenticateToken → requireSuperAdmin → Controller + +// 회사 데이터 접근 +authenticateToken → requireCompanyAccess → Controller + +// DDL 실행 권한 +authenticateToken → requireDDLPermission → Controller +``` + +--- + +## 5. 인증/인가 시스템 + +### 5.1 인증 플로우 + +``` +┌──────────┐ +│ 로그인 │ +│ 요청 │ +└────┬─────┘ + │ + ▼ +┌────────────────────┐ +│ AuthController │ +│ .login() │ +└────┬───────────────┘ + │ + ▼ +┌────────────────────┐ +│ AuthService │ +│ .processLogin() │ +└────┬───────────────┘ + │ + ├─► 1. loginPwdCheck() → DB에서 비밀번호 검증 + │ (마스터 패스워드: qlalfqjsgh11) + │ + ├─► 2. getPersonBeanFromSession() → 사용자 정보 조회 + │ (user_info, dept_info, company_mng JOIN) + │ + ├─► 3. insertLoginAccessLog() → 로그인 이력 저장 + │ + └─► 4. JwtUtils.generateToken() → JWT 토큰 생성 + (payload: userId, userName, companyCode, userType) +``` + +### 5.2 JWT 토큰 구조 + +```typescript +// Payload +{ + userId: "user123", // 사용자 ID + userName: "홍길동", // 사용자명 + deptName: "개발팀", // 부서명 + companyCode: "ILSHIN", // 회사 코드 (멀티테넌시 키) + companyName: "일신정공", // 회사명 + userType: "COMPANY_ADMIN", // 권한 레벨 + userTypeName: "회사관리자", // 권한명 + iat: 1234567890, // 발급 시간 + exp: 1234654290, // 만료 시간 (24시간) + iss: "PMS-System", // 발급자 + aud: "PMS-Users" // 대상 +} +``` + +### 5.3 권한 체계 (3단계) + +```typescript +// 1. SUPER_ADMIN (최고 관리자) +- company_code = "*" +- userType = "SUPER_ADMIN" +- 전체 회사 데이터 접근 가능 +- DDL 실행 가능 +- 회사 생성/삭제 가능 +- 시스템 설정 변경 가능 + +// 2. COMPANY_ADMIN (회사 관리자) +- company_code = "ILSHIN" (특정 회사) +- userType = "COMPANY_ADMIN" +- 자기 회사 데이터만 접근 +- 자기 회사 사용자 관리 가능 +- 회사 설정 변경 가능 + +// 3. USER (일반 사용자) +- company_code = "ILSHIN" +- userType = "USER" | "GUEST" | "PARTNER" +- 자기 회사 데이터만 접근 +- 읽기/쓰기 권한만 +``` + +### 5.4 토큰 갱신 메커니즘 + +```typescript +// refreshTokenIfNeeded 미들웨어 +// 1. 토큰 만료까지 1시간 미만 남은 경우 +// 2. 자동으로 새 토큰 발급 +// 3. 응답 헤더에 "X-New-Token" 추가 +// 4. 프론트엔드에서 자동으로 토큰 교체 +``` + +--- + +## 6. 멀티테넌시 구현 + +### 6.1 핵심 원칙 + +```typescript +// 🚨 절대 규칙: 모든 쿼리는 company_code 필터 필수 +const companyCode = req.user!.companyCode; + +if (companyCode === "*") { + // 슈퍼관리자: 모든 데이터 조회 가능 + query = "SELECT * FROM table ORDER BY company_code"; +} else { + // 일반 사용자: 자기 회사 데이터만 + query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'"; + params = [companyCode]; +} +``` + +### 6.2 회사 데이터 격리 패턴 + +```typescript +// ✅ 올바른 패턴 +async function getDataList(req: AuthenticatedRequest) { + const companyCode = req.user!.companyCode; // JWT에서 추출 + + // 슈퍼관리자 체크 + if (companyCode === "*") { + // 모든 회사 데이터 조회 + return await query("SELECT * FROM data WHERE 1=1"); + } + + // 일반 사용자: 자기 회사 + 슈퍼관리자 데이터 제외 + return await query( + "SELECT * FROM data WHERE company_code = $1 AND company_code != '*'", + [companyCode] + ); +} + +// ❌ 잘못된 패턴 (절대 금지!) +async function getDataList(req: AuthenticatedRequest) { + const companyCode = req.body.companyCode; // 클라이언트에서 받음 (위험!) + return await query("SELECT * FROM data WHERE company_code = $1", [companyCode]); +} +``` + +### 6.3 슈퍼관리자 숨김 규칙 + +```sql +-- 슈퍼관리자 사용자 (company_code = '*')는 +-- 일반 회사 사용자에게 보이면 안 됨 + +-- ✅ 올바른 쿼리 +SELECT * FROM user_info +WHERE company_code = $1 + AND company_code != '*' -- 슈퍼관리자 숨김 + +-- ❌ 잘못된 쿼리 +SELECT * FROM user_info +WHERE company_code = $1 -- 슈퍼관리자 노출 위험 +``` + +### 6.4 회사 전환 기능 (SUPER_ADMIN 전용) + +```typescript +// POST /api/auth/switch-company +// WACE 관리자가 특정 회사로 컨텍스트 전환 +{ + companyCode: "ILSHIN" // 전환할 회사 코드 +} + +// 새로운 JWT 토큰 발급 (company_code만 변경) +// userType은 "SUPER_ADMIN" 유지 +``` + +--- + +## 7. API 라우트 전체 목록 + +### 7.1 인증/관리자 기능 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/auth/login` | POST | 로그인 | 공개 | +| `/api/auth/logout` | POST | 로그아웃 | 인증 | +| `/api/auth/me` | GET | 현재 사용자 정보 | 인증 | +| `/api/auth/status` | GET | 인증 상태 확인 | 공개 | +| `/api/auth/refresh` | POST | 토큰 갱신 | 인증 | +| `/api/auth/signup` | POST | 회원가입 (공차중계) | 공개 | +| `/api/auth/switch-company` | POST | 회사 전환 | 슈퍼관리자 | +| `/api/admin/users` | GET | 사용자 목록 | 관리자 | +| `/api/admin/users` | POST | 사용자 생성 | 관리자 | +| `/api/admin/menus` | GET | 메뉴 목록 | 인증 | +| `/api/admin/web-types` | GET | 웹타입 표준 관리 | 인증 | +| `/api/admin/button-actions` | GET | 버튼 액션 표준 | 인증 | +| `/api/admin/component-standards` | GET | 컴포넌트 표준 | 인증 | +| `/api/admin/template-standards` | GET | 템플릿 표준 | 인증 | +| `/api/admin/reports` | GET | 리포트 관리 | 인증 | + +### 7.2 테이블/화면 관리 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/table-management/tables` | GET | 테이블 목록 | 인증 | +| `/api/table-management/tables/:table/columns` | GET | 컬럼 목록 | 인증 | +| `/api/table-management/tables/:table/data` | POST | 데이터 조회 | 인증 | +| `/api/table-management/tables/:table/add` | POST | 데이터 추가 | 인증 | +| `/api/table-management/tables/:table/edit` | PUT | 데이터 수정 | 인증 | +| `/api/table-management/tables/:table/delete` | DELETE | 데이터 삭제 | 인증 | +| `/api/table-management/tables/:table/log` | POST | 로그 테이블 생성 | 관리자 | +| `/api/table-management/multi-table-save` | POST | 다중 테이블 저장 | 인증 | +| `/api/screen-management/screens` | GET | 화면 목록 | 인증 | +| `/api/screen-management/screens/:id` | GET | 화면 상세 | 인증 | +| `/api/screen-management/screens` | POST | 화면 생성 | 관리자 | +| `/api/screen-groups` | GET | 화면 그룹 관리 | 인증 | +| `/api/screen-files` | GET | 화면 파일 관리 | 인증 | + +### 7.3 플로우 관리 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/flow/definitions` | GET | 플로우 정의 목록 | 인증 | +| `/api/flow/definitions` | POST | 플로우 생성 | 인증 | +| `/api/flow/definitions/:id/steps` | GET | 단계 목록 | 인증 | +| `/api/flow/definitions/:id/steps` | POST | 단계 생성 | 인증 | +| `/api/flow/connections/:flowId` | GET | 연결 목록 | 인증 | +| `/api/flow/connections` | POST | 연결 생성 | 인증 | +| `/api/flow/move` | POST | 데이터 이동 (단건) | 인증 | +| `/api/flow/move-batch` | POST | 데이터 이동 (다건) | 인증 | +| `/api/flow/:flowId/step/:stepId/count` | GET | 단계 데이터 개수 | 인증 | +| `/api/flow/:flowId/step/:stepId/list` | GET | 단계 데이터 목록 | 인증 | +| `/api/flow/audit/:flowId/:recordId` | GET | 오딧 로그 조회 | 인증 | + +### 7.4 데이터플로우/다이어그램 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/dataflow/relationships` | GET | 관계 목록 | 인증 | +| `/api/dataflow/relationships` | POST | 관계 생성 | 인증 | +| `/api/dataflow-diagrams` | GET | 다이어그램 목록 | 인증 | +| `/api/dataflow-diagrams/:id` | GET | 다이어그램 상세 | 인증 | +| `/api/dataflow` | POST | 데이터플로우 실행 | 인증 | + +### 7.5 외부 연동 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/external-db-connections` | GET | 외부 DB 연결 목록 | 인증 | +| `/api/external-db-connections` | POST | 외부 DB 연결 생성 | 관리자 | +| `/api/external-db-connections/:id/test` | POST | 연결 테스트 | 인증 | +| `/api/external-db-connections/:id/tables` | GET | 테이블 목록 | 인증 | +| `/api/external-rest-api-connections` | GET | REST API 연결 목록 | 인증 | +| `/api/external-rest-api-connections` | POST | REST API 연결 생성 | 관리자 | +| `/api/external-rest-api-connections/:id/test` | POST | API 테스트 | 인증 | +| `/api/multi-connection/query` | POST | 멀티 DB 쿼리 | 인증 | + +### 7.6 배치 관리 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/batch-configs` | GET | 배치 설정 목록 | 인증 | +| `/api/batch-configs` | POST | 배치 설정 생성 | 관리자 | +| `/api/batch-configs/:id` | PUT | 배치 설정 수정 | 관리자 | +| `/api/batch-configs/:id` | DELETE | 배치 설정 삭제 | 관리자 | +| `/api/batch-management/:id/execute` | POST | 배치 즉시 실행 | 관리자 | +| `/api/batch-execution-logs` | GET | 실행 이력 | 인증 | + +### 7.7 메일 시스템 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/mail/accounts` | GET | 계정 목록 | 인증 | +| `/api/mail/templates-file` | GET | 템플릿 목록 | 인증 | +| `/api/mail/send` | POST | 메일 발송 | 인증 | +| `/api/mail/sent` | GET | 발송 이력 | 인증 | +| `/api/mail/receive` | POST | 메일 수신 | 인증 | + +### 7.8 파일 관리 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/files/upload` | POST | 파일 업로드 | 인증 | +| `/api/files/download/:id` | GET | 파일 다운로드 | 인증 | +| `/api/files` | GET | 파일 목록 | 인증 | +| `/api/files/:id` | DELETE | 파일 삭제 | 인증 | +| `/uploads/:filename` | GET | 정적 파일 서빙 | 공개 | + +### 7.9 기타 기능 + +| 경로 | 메서드 | 기능 | 권한 | +|------|--------|------|------| +| `/api/dashboards` | GET | 대시보드 데이터 | 인증 | +| `/api/common-codes` | GET | 공통코드 조회 | 인증 | +| `/api/multilang` | GET | 다국어 조회 | 인증 | +| `/api/company-management` | GET | 회사 목록 | 슈퍼관리자 | +| `/api/departments` | GET | 부서 목록 | 인증 | +| `/api/roles` | GET | 권한 그룹 관리 | 인증 | +| `/api/ddl` | POST | DDL 실행 | 슈퍼관리자 | +| `/api/open-api/weather` | GET | 날씨 정보 | 인증 | +| `/api/open-api/exchange` | GET | 환율 정보 | 인증 | +| `/api/digital-twin` | GET | 디지털 트윈 | 인증 | +| `/api/yard-layouts` | GET | 3D 필드 레이아웃 | 인증 | +| `/api/schedule` | POST | 스케줄 자동 생성 | 인증 | +| `/api/numbering-rules` | GET | 채번 규칙 관리 | 인증 | +| `/api/entity-search` | POST | 엔티티 검색 | 인증 | +| `/api/todos` | GET | To-Do 관리 | 인증 | +| `/api/bookings` | GET | 예약 요청 관리 | 인증 | +| `/api/risk-alerts` | GET | 리스크/알림 관리 | 인증 | +| `/health` | GET | 헬스 체크 | 공개 | + +--- + +## 8. 비즈니스 도메인별 모듈 + +### 8.1 관리자 도메인 (Admin) + +**컨트롤러**: `adminController.ts` +**서비스**: `adminService.ts` +**주요 기능**: +- 사용자 관리 (CRUD) +- 메뉴 관리 (트리 구조) +- 권한 그룹 관리 +- 시스템 설정 +- 사용자 이력 조회 + +**핵심 로직**: +```typescript +// 사용자 목록 조회 (멀티테넌시 적용) +async getUserList(params) { + const companyCode = params.userCompanyCode; + + // 슈퍼관리자: 모든 회사 사용자 조회 + if (companyCode === "*") { + return await query("SELECT * FROM user_info WHERE 1=1"); + } + + // 일반 관리자: 자기 회사 사용자만 + 슈퍼관리자 숨김 + return await query( + "SELECT * FROM user_info WHERE company_code = $1 AND company_code != '*'", + [companyCode] + ); +} +``` + +### 8.2 테이블/화면 관리 도메인 + +**컨트롤러**: `tableManagementController.ts`, `screenManagementController.ts` +**서비스**: `tableManagementService.ts`, `screenManagementService.ts` +**주요 기능**: +- 테이블 메타데이터 관리 (컬럼 설정) +- 화면 정의 (JSON 기반 동적 화면) +- 화면 그룹 관리 +- 테이블 로그 시스템 +- 엔티티 관계 관리 + +**핵심 로직**: +```typescript +// 테이블 데이터 조회 (페이징, 정렬, 필터) +async getTableData(tableName, filters, pagination) { + const companyCode = req.user!.companyCode; + + // 동적 WHERE 절 생성 + const whereClauses = [`company_code = '${companyCode}'`]; + + // 필터 조건 추가 + filters.forEach(filter => { + whereClauses.push(`${filter.column} ${filter.operator} '${filter.value}'`); + }); + + // 페이징 + 정렬 + const sql = ` + SELECT * FROM ${tableName} + WHERE ${whereClauses.join(' AND ')} + ORDER BY ${pagination.sortBy} ${pagination.sortOrder} + LIMIT ${pagination.limit} OFFSET ${pagination.offset} + `; + + return await query(sql); +} +``` + +### 8.3 플로우 도메인 (Flow) + +**컨트롤러**: `flowController.ts` +**서비스**: `flowExecutionService.ts`, `flowDefinitionService.ts` +**주요 기능**: +- 플로우 정의 (워크플로우 설계) +- 단계(Step) 관리 +- 단계 간 연결(Connection) 관리 +- 데이터 이동 (단건/다건) +- 조건부 이동 +- 오딧 로그 + +**핵심 로직**: +```typescript +// 데이터 이동 (단계 간 전환) +async moveData(flowId, fromStepId, toStepId, recordIds) { + return await transaction(async (client) => { + // 1. 연결 조건 확인 + const connection = await getConnection(fromStepId, toStepId); + + // 2. 조건 평가 (있으면) + if (connection.condition) { + const isValid = await evaluateCondition(connection.condition, recordIds); + if (!isValid) throw new Error("조건 불충족"); + } + + // 3. 데이터 이동 + await client.query( + `UPDATE ${connection.targetTable} + SET flow_step_id = $1, updated_at = now() + WHERE id = ANY($2) AND company_code = $3`, + [toStepId, recordIds, companyCode] + ); + + // 4. 오딧 로그 기록 + await insertAuditLog(flowId, recordIds, fromStepId, toStepId); + }); +} +``` + +### 8.4 데이터플로우 도메인 (Dataflow) + +**컨트롤러**: `dataflowController.ts`, `dataflowDiagramController.ts` +**서비스**: `dataflowService.ts`, `dataflowDiagramService.ts` +**주요 기능**: +- 테이블 관계 정의 (ERD) +- 다이어그램 관리 (시각화) +- 관계 실행 (조인 쿼리 자동 생성) +- 관계 검증 + +**핵심 로직**: +```typescript +// 테이블 관계 실행 (동적 조인) +async executeRelationship(relationshipId) { + const rel = await getRelationship(relationshipId); + + // 동적 조인 쿼리 생성 + const sql = ` + SELECT + a.*, + b.* + FROM ${rel.fromTableName} a + ${rel.relationshipType === '1:N' ? 'LEFT JOIN' : 'INNER JOIN'} + ${rel.toTableName} b + ON a.${rel.fromColumnName} = b.${rel.toColumnName} + WHERE a.company_code = $1 + `; + + return await query(sql, [companyCode]); +} +``` + +### 8.5 배치 도메인 (Batch) + +**컨트롤러**: `batchController.ts`, `batchManagementController.ts` +**서비스**: `batchService.ts`, `batchSchedulerService.ts` +**주요 기능**: +- 배치 설정 관리 +- Cron 스케줄링 (node-cron) +- 외부 DB → 내부 DB 데이터 동기화 +- 컬럼 매핑 +- 실행 이력 관리 + +**핵심 로직**: +```typescript +// 배치 스케줄러 초기화 (서버 시작 시) +async initializeScheduler() { + const activeBatches = await getBatchConfigs({ is_active: 'Y' }); + + for (const batch of activeBatches) { + // Cron 스케줄 등록 + const task = cron.schedule(batch.cron_schedule, async () => { + await executeBatchConfig(batch); + }, { + timezone: "Asia/Seoul" + }); + + scheduledTasks.set(batch.id, task); + } +} + +// 배치 실행 +async executeBatchConfig(config) { + // 1. 소스 DB에서 데이터 가져오기 + const sourceData = await getSourceData(config.source_connection); + + // 2. 컬럼 매핑 적용 + const mappedData = applyColumnMapping(sourceData, config.batch_mappings); + + // 3. 타겟 DB에 INSERT/UPDATE + await upsertToTarget(mappedData, config.target_table); + + // 4. 실행 로그 기록 + await logExecution(config.id, sourceData.length); +} +``` + +### 8.6 외부 연동 도메인 (External) + +**컨트롤러**: `externalDbConnectionController.ts`, `externalRestApiConnectionController.ts` +**서비스**: `externalDbConnectionService.ts`, `dbConnectionManager.ts` +**주요 기능**: +- 외부 DB 연결 설정 (PostgreSQL, MySQL, MSSQL, Oracle) +- Connection Pool 관리 +- 연결 테스트 +- 외부 REST API 연결 설정 +- API 프록시 + +**핵심 로직**: +```typescript +// 외부 DB 연결 (Factory 패턴) +class DatabaseConnectorFactory { + static getConnector(dbType: string, config: any) { + switch (dbType) { + case 'POSTGRESQL': + return new PostgreSQLConnector(config); + case 'MYSQL': + return new MySQLConnector(config); + case 'MSSQL': + return new MSSQLConnector(config); + case 'ORACLE': + return new OracleConnector(config); + default: + throw new Error(`지원하지 않는 DB 타입: ${dbType}`); + } + } +} + +// 외부 DB 쿼리 실행 +async executeExternalQuery(connectionId, sql) { + // 1. 연결 정보 조회 (암호화된 비밀번호 복호화) + const connection = await getConnection(connectionId); + connection.password = decrypt(connection.password); + + // 2. 커넥터 생성 + const connector = DatabaseConnectorFactory.getConnector( + connection.db_type, + connection + ); + + // 3. 연결 및 쿼리 실행 + await connector.connect(); + const result = await connector.query(sql); + await connector.disconnect(); + + return result; +} +``` + +### 8.7 메일 도메인 (Mail) + +**컨트롤러**: `mailSendSimpleController.ts`, `mailReceiveBasicController.ts` +**서비스**: `mailSendSimpleService.ts`, `mailReceiveBasicService.ts` +**주요 기능**: +- 메일 계정 관리 (파일 기반) +- 메일 템플릿 관리 +- 메일 발송 (nodemailer) +- 메일 수신 (IMAP) +- 발송 이력 관리 +- 첨부파일 처리 + +**핵심 로직**: +```typescript +// 메일 발송 +async sendEmail(params) { + // 1. 계정 정보 조회 + const account = await getMailAccount(params.accountId); + + // 2. 템플릿 적용 (있으면) + let emailBody = params.body; + if (params.templateId) { + const template = await getTemplate(params.templateId); + emailBody = renderTemplate(template.content, params.variables); + } + + // 3. nodemailer 전송 + const transporter = nodemailer.createTransport({ + host: account.smtp_host, + port: account.smtp_port, + secure: true, + auth: { + user: account.email, + pass: decrypt(account.password) + } + }); + + const result = await transporter.sendMail({ + from: account.email, + to: params.to, + subject: params.subject, + html: emailBody, + attachments: params.attachments + }); + + // 4. 발송 이력 저장 + await saveSentHistory(params, result); +} +``` + +### 8.8 파일 도메인 (File) + +**컨트롤러**: `fileController.ts`, `screenFileController.ts` +**서비스**: `fileSystemManager.ts` +**주요 기능**: +- 파일 업로드 (multer) +- 파일 다운로드 +- 파일 삭제 +- 화면별 파일 관리 + +**핵심 로직**: +```typescript +// 파일 업로드 +const upload = multer({ + storage: multer.diskStorage({ + destination: (req, file, cb) => { + const companyCode = req.user!.companyCode; + const dir = `uploads/${companyCode}`; + fs.mkdirSync(dir, { recursive: true }); + cb(null, dir); + }, + filename: (req, file, cb) => { + const uniqueName = `${Date.now()}-${uuidv4()}-${file.originalname}`; + cb(null, uniqueName); + } + }), + limits: { fileSize: 10 * 1024 * 1024 }, // 10MB + fileFilter: (req, file, cb) => { + // 파일 타입 검증 + const allowedTypes = /jpeg|jpg|png|gif|pdf|doc|docx|xls|xlsx/; + const isValid = allowedTypes.test(file.mimetype); + cb(null, isValid); + } +}); + +// 파일 메타데이터 저장 +async saveFileMetadata(file, userId, companyCode) { + return await query( + `INSERT INTO file_info ( + file_name, file_path, file_size, mime_type, + company_code, created_by + ) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, + [file.originalname, file.path, file.size, file.mimetype, companyCode, userId] + ); +} +``` + +### 8.9 대시보드 도메인 (Dashboard) + +**컨트롤러**: `DashboardController.ts` +**서비스**: `DashboardService.ts` +**주요 기능**: +- 대시보드 설정 관리 +- 위젯 관리 +- 통계 데이터 조회 +- 차트 데이터 생성 + +--- + +## 9. 데이터베이스 접근 방식 + +### 9.1 Connection Pool 설정 + +```typescript +// database/db.ts +const pool = new Pool({ + host: "localhost", + port: 5432, + database: "ilshin", + user: "postgres", + password: "postgres", + + // Pool 설정 + min: config.nodeEnv === "production" ? 5 : 2, // 최소 연결 수 + max: config.nodeEnv === "production" ? 20 : 10, // 최대 연결 수 + + // Timeout 설정 + connectionTimeoutMillis: 30000, // 연결 대기 30초 + idleTimeoutMillis: 600000, // 유휴 연결 유지 10분 + statement_timeout: 60000, // 쿼리 실행 60초 + query_timeout: 60000, + + // 연결 유지 + keepAlive: true, + keepAliveInitialDelayMillis: 10000, + + // Application Name + application_name: "WACE-PLM-Backend" +}); +``` + +### 9.2 쿼리 실행 패턴 + +```typescript +// 1. 기본 쿼리 (다중 행) +const users = await query( + 'SELECT * FROM user_info WHERE company_code = $1', + [companyCode] +); + +// 2. 단일 행 쿼리 +const user = await queryOne( + 'SELECT * FROM user_info WHERE user_id = $1', + ['user123'] +); + +// 3. 트랜잭션 +const result = await transaction(async (client) => { + await client.query('INSERT INTO table1 (...) VALUES (...)', [...]); + await client.query('INSERT INTO table2 (...) VALUES (...)', [...]); + return { success: true }; +}); +``` + +### 9.3 Parameterized Query (SQL Injection 방지) + +```typescript +// ✅ 올바른 방법 (Parameterized Query) +const users = await query( + 'SELECT * FROM user_info WHERE user_id = $1 AND dept_code = $2', + [userId, deptCode] +); + +// ❌ 잘못된 방법 (SQL Injection 위험!) +const users = await query( + `SELECT * FROM user_info WHERE user_id = '${userId}'` +); +``` + +### 9.4 동적 쿼리 빌더 패턴 + +```typescript +// utils/queryBuilder.ts +class QueryBuilder { + private table: string; + private whereClauses: string[] = []; + private params: any[] = []; + private paramIndex: number = 1; + + constructor(table: string) { + this.table = table; + } + + where(column: string, value: any) { + this.whereClauses.push(`${column} = $${this.paramIndex++}`); + this.params.push(value); + return this; + } + + whereIn(column: string, values: any[]) { + this.whereClauses.push(`${column} = ANY($${this.paramIndex++})`); + this.params.push(values); + return this; + } + + build() { + const where = this.whereClauses.length > 0 + ? `WHERE ${this.whereClauses.join(' AND ')}` + : ''; + return { + sql: `SELECT * FROM ${this.table} ${where}`, + params: this.params + }; + } +} + +// 사용 예시 +const { sql, params } = new QueryBuilder('user_info') + .where('company_code', companyCode) + .where('user_type', 'USER') + .whereIn('dept_code', ['D001', 'D002']) + .build(); + +const users = await query(sql, params); +``` + +--- + +## 10. 외부 시스템 연동 + +### 10.1 외부 DB 연결 아키텍처 + +``` +┌─────────────────────┐ +│ Backend Service │ +└──────────┬──────────┘ + │ + ▼ +┌─────────────────────┐ +│ DatabaseConnector │ +│ Factory │ +└──────────┬──────────┘ + │ + ┌─────┴─────┬─────────┬─────────┐ + ▼ ▼ ▼ ▼ +┌─────────┐ ┌─────────┐ ┌───────┐ ┌────────┐ +│PostgreSQL│ │ MySQL │ │ MSSQL │ │ Oracle │ +│Connector│ │Connector│ │Connect│ │Connect │ +└─────────┘ └─────────┘ └───────┘ └────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌─────────┐ ┌─────────┐ ┌───────┐ ┌────────┐ +│External │ │External │ │External│ │External│ +│ PG DB │ │MySQL DB │ │MSSQL DB│ │Oracle │ +└─────────┘ └─────────┘ └───────┘ └────────┘ +``` + +### 10.2 외부 DB Connector 인터페이스 + +```typescript +// interfaces/DatabaseConnector.ts +interface DatabaseConnector { + connect(): Promise; + disconnect(): Promise; + query(sql: string, params?: any[]): Promise; + testConnection(): Promise; + getTables(): Promise; + getColumns(tableName: string): Promise; +} + +// 구현 예시: PostgreSQL +class PostgreSQLConnector implements DatabaseConnector { + private pool: Pool; + + constructor(config: ExternalDbConnection) { + this.pool = new Pool({ + host: config.host, + port: config.port, + database: config.database, + user: config.username, + password: decrypt(config.password), + ssl: config.use_ssl ? { rejectUnauthorized: false } : false + }); + } + + async connect() { + await this.pool.connect(); + } + + async query(sql: string, params?: any[]) { + const result = await this.pool.query(sql, params); + return result.rows; + } + + async getTables() { + const result = await this.query( + `SELECT tablename FROM pg_tables + WHERE schemaname = 'public' + ORDER BY tablename` + ); + return result.map(row => row.tablename); + } +} +``` + +### 10.3 외부 REST API 연동 + +```typescript +// services/externalRestApiConnectionService.ts +async callExternalApi(connectionId: number, endpoint: string, options: any) { + // 1. 연결 정보 조회 + const connection = await getApiConnection(connectionId); + + // 2. 인증 헤더 생성 + const headers: any = { + 'Content-Type': 'application/json', + ...connection.headers + }; + + if (connection.auth_type === 'BEARER') { + headers['Authorization'] = `Bearer ${decrypt(connection.auth_token)}`; + } else if (connection.auth_type === 'API_KEY') { + headers[connection.api_key_header] = decrypt(connection.api_key); + } + + // 3. Axios 요청 + const response = await axios({ + method: options.method || 'GET', + url: `${connection.base_url}${endpoint}`, + headers, + data: options.body, + timeout: connection.timeout || 30000 + }); + + return response.data; +} +``` + +--- + +## 11. 배치/스케줄 처리 + +### 11.1 배치 스케줄러 시스템 + +```typescript +// services/batchSchedulerService.ts +class BatchSchedulerService { + private static scheduledTasks: Map = new Map(); + + // 서버 시작 시 모든 활성 배치 스케줄링 + static async initializeScheduler() { + const activeBatches = await getBatchConfigs({ is_active: 'Y' }); + + for (const batch of activeBatches) { + this.scheduleBatch(batch); + } + } + + // 개별 배치 스케줄 등록 + static scheduleBatch(config: any) { + if (!cron.validate(config.cron_schedule)) { + throw new Error(`Invalid cron: ${config.cron_schedule}`); + } + + const task = cron.schedule( + config.cron_schedule, + async () => { + await this.executeBatchConfig(config); + }, + { timezone: "Asia/Seoul" } + ); + + this.scheduledTasks.set(config.id, task); + } + + // 배치 실행 + static async executeBatchConfig(config: any) { + const startTime = new Date(); + + try { + // 1. 소스 DB에서 데이터 조회 + const sourceData = await this.getSourceData(config); + + // 2. 컬럼 매핑 적용 + const mappedData = this.applyMapping(sourceData, config.batch_mappings); + + // 3. 타겟 DB에 저장 + await this.upsertToTarget(mappedData, config); + + // 4. 성공 로그 + await BatchExecutionLogService.updateExecutionLog(executionLogId, { + execution_status: 'SUCCESS', + end_time: new Date(), + success_records: mappedData.length + }); + } catch (error) { + // 5. 실패 로그 + await BatchExecutionLogService.updateExecutionLog(executionLogId, { + execution_status: 'FAILURE', + end_time: new Date(), + error_message: error.message + }); + } + } +} +``` + +### 11.2 Cron 표현식 예시 + +``` +# 형식: 초 분 시 일 월 요일 + +# 매 시간 정각 +0 * * * * + +# 매일 새벽 2시 +0 2 * * * + +# 매주 월요일 오전 9시 +0 9 * * 1 + +# 매월 1일 자정 +0 0 1 * * + +# 5분마다 +*/5 * * * * + +# 평일 오전 8시~오후 6시, 매 시간 +0 8-18 * * 1-5 +``` + +### 11.3 배치 실행 이력 관리 + +```typescript +// services/batchExecutionLogService.ts +interface ExecutionLog { + id: number; + batch_config_id: number; + execution_status: 'RUNNING' | 'SUCCESS' | 'FAILURE'; + start_time: Date; + end_time?: Date; + total_records: number; + success_records: number; + failure_records: number; + error_message?: string; + company_code: string; +} + +// 실행 로그 저장 +async createExecutionLog(data: Partial) { + return await query( + `INSERT INTO batch_execution_logs ( + batch_config_id, company_code, execution_status, + start_time, total_records + ) VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [data.batch_config_id, data.company_code, data.execution_status, + data.start_time, data.total_records] + ); +} +``` + +--- + +## 12. 파일 처리 + +### 12.1 파일 업로드 흐름 + +``` +┌─────────────┐ +│ Frontend │ +└──────┬──────┘ + │ (FormData) + ▼ +┌─────────────────────┐ +│ Multer Middleware │ → 파일 저장 (uploads/COMPANY_CODE/) +└──────┬──────────────┘ + │ + ▼ +┌─────────────────────┐ +│ FileController │ → 메타데이터 저장 (file_info 테이블) +└──────┬──────────────┘ + │ + ▼ +┌─────────────────────┐ +│ Response │ → { fileId, fileName, filePath, fileSize } +└─────────────────────┘ +``` + +### 12.2 Multer 설정 + +```typescript +// config/multerConfig.ts +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + const companyCode = req.user!.companyCode; + const uploadDir = path.join(process.cwd(), 'uploads', companyCode); + + // 디렉토리 없으면 생성 + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + + cb(null, uploadDir); + }, + + filename: (req, file, cb) => { + // 파일명 중복 방지: 타임스탬프 + UUID + 원본파일명 + const timestamp = Date.now(); + const uniqueId = uuidv4(); + const extension = path.extname(file.originalname); + const basename = path.basename(file.originalname, extension); + const uniqueName = `${timestamp}-${uniqueId}-${basename}${extension}`; + + cb(null, uniqueName); + } +}); + +export const upload = multer({ + storage, + limits: { + fileSize: 10 * 1024 * 1024 // 10MB + }, + fileFilter: (req, file, cb) => { + // 허용 확장자 검증 + const allowedMimeTypes = [ + 'image/jpeg', 'image/png', 'image/gif', + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ]; + + if (allowedMimeTypes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('허용되지 않는 파일 형식입니다.')); + } + } +}); +``` + +### 12.3 파일 다운로드 + +```typescript +// controllers/fileController.ts +async downloadFile(req: AuthenticatedRequest, res: Response) { + const fileId = parseInt(req.params.id); + const companyCode = req.user!.companyCode; + + // 1. 파일 정보 조회 (권한 체크) + const file = await queryOne( + `SELECT * FROM file_info + WHERE id = $1 AND company_code = $2`, + [fileId, companyCode] + ); + + if (!file) { + return res.status(404).json({ error: '파일을 찾을 수 없습니다.' }); + } + + // 2. 파일 존재 확인 + if (!fs.existsSync(file.file_path)) { + return res.status(404).json({ error: '파일이 존재하지 않습니다.' }); + } + + // 3. 파일 다운로드 + res.download(file.file_path, file.file_name); +} +``` + +### 12.4 파일 삭제 (논리 삭제) + +```typescript +async deleteFile(req: AuthenticatedRequest, res: Response) { + const fileId = parseInt(req.params.id); + const companyCode = req.user!.companyCode; + + // 1. 논리 삭제 (is_active = 'N') + await query( + `UPDATE file_info + SET is_active = 'N', deleted_at = now(), deleted_by = $3 + WHERE id = $1 AND company_code = $2`, + [fileId, companyCode, req.user!.userId] + ); + + // 2. 물리 파일은 삭제하지 않음 (복구 가능) + // 주기적으로 삭제된 지 30일 지난 파일만 물리 삭제 +} +``` + +--- + +## 13. 에러 핸들링 + +### 13.1 에러 핸들러 미들웨어 + +```typescript +// middleware/errorHandler.ts +export const errorHandler = (err, req, res, next) => { + let error = { ...err }; + error.message = err.message; + + // 1. PostgreSQL 에러 처리 + if (err.code) { + switch (err.code) { + case '23505': // unique_violation + error = new AppError('중복된 데이터가 존재합니다.', 400); + break; + case '23503': // foreign_key_violation + error = new AppError('참조 무결성 제약 조건 위반입니다.', 400); + break; + case '23502': // not_null_violation + error = new AppError('필수 입력값이 누락되었습니다.', 400); + break; + default: + error = new AppError(`데이터베이스 오류: ${err.message}`, 500); + } + } + + // 2. JWT 에러 처리 + if (err.name === 'JsonWebTokenError') { + error = new AppError('유효하지 않은 토큰입니다.', 401); + } + if (err.name === 'TokenExpiredError') { + error = new AppError('토큰이 만료되었습니다.', 401); + } + + // 3. 에러 로깅 + logger.error({ + message: error.message, + stack: error.stack, + url: req.url, + method: req.method, + ip: req.ip + }); + + // 4. 응답 + const statusCode = error.statusCode || 500; + res.status(statusCode).json({ + success: false, + error: { + message: error.message, + ...(process.env.NODE_ENV === 'development' && { stack: error.stack }) + } + }); +}; +``` + +### 13.2 커스텀 에러 클래스 + +```typescript +export class AppError extends Error { + public statusCode: number; + public isOperational: boolean; + + constructor(message: string, statusCode: number = 500) { + super(message); + this.statusCode = statusCode; + this.isOperational = true; + Error.captureStackTrace(this, this.constructor); + } +} + +// 사용 예시 +if (!user) { + throw new AppError('사용자를 찾을 수 없습니다.', 404); +} +``` + +### 13.3 프로세스 레벨 예외 처리 + +```typescript +// app.ts +process.on('unhandledRejection', (reason, promise) => { + logger.error('⚠️ Unhandled Promise Rejection:', { + reason: reason?.message || reason, + stack: reason?.stack + }); + // 프로세스 종료하지 않고 로깅만 수행 +}); + +process.on('uncaughtException', (error) => { + logger.error('🔥 Uncaught Exception:', { + message: error.message, + stack: error.stack + }); + // 예외 발생 후에도 서버 유지 (주의: 불안정할 수 있음) +}); + +process.on('SIGTERM', () => { + logger.info('📴 SIGTERM 시그널 수신, graceful shutdown 시작...'); + // 연결 풀 정리, 진행 중인 요청 완료 대기 + process.exit(0); +}); +``` + +--- + +## 14. 로깅 시스템 + +### 14.1 Winston Logger 설정 + +```typescript +// utils/logger.ts +import winston from 'winston'; + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: winston.format.combine( + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.errors({ stack: true }), + winston.format.splat(), + winston.format.json() + ), + transports: [ + // 파일 로그 + new winston.transports.File({ + filename: 'logs/error.log', + level: 'error', + maxsize: 10485760, // 10MB + maxFiles: 5 + }), + new winston.transports.File({ + filename: 'logs/combined.log', + maxsize: 10485760, + maxFiles: 10 + }), + + // 콘솔 로그 (개발 환경) + ...(process.env.NODE_ENV === 'development' ? [ + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ) + }) + ] : []) + ] +}); + +export { logger }; +``` + +### 14.2 로깅 레벨 + +```typescript +// 로그 레벨 (우선순위 높음 → 낮음) +logger.error('에러 발생', { error }); // 0 +logger.warn('경고 메시지'); // 1 +logger.info('정보 메시지'); // 2 +logger.http('HTTP 요청'); // 3 +logger.verbose('상세 정보'); // 4 +logger.debug('디버그 정보', { data }); // 5 +logger.silly('매우 상세한 정보'); // 6 +``` + +### 14.3 로깅 패턴 + +```typescript +// 1. 인증 로그 +logger.info(`인증 성공: ${userInfo.userId} (${req.ip})`); +logger.warn(`인증 실패: ${errorMessage} (${req.ip})`); + +// 2. 쿼리 로그 (디버그 모드) +if (config.debug) { + logger.debug('쿼리 실행:', { + query: sql, + params, + rowCount: result.rowCount, + duration: `${duration}ms` + }); +} + +// 3. 에러 로그 +logger.error('배치 실행 실패:', { + batchId: config.id, + error: error.message, + stack: error.stack +}); + +// 4. 비즈니스 로그 +logger.info(`플로우 데이터 이동: ${fromStepId} → ${toStepId}`, { + flowId, + recordCount: recordIds.length +}); +``` + +--- + +## 15. 보안 및 권한 관리 + +### 15.1 비밀번호 암호화 + +```typescript +// utils/encryptUtil.ts +export class EncryptUtil { + // 비밀번호 해싱 (bcrypt) + static hash(password: string): string { + return bcrypt.hashSync(password, 12); + } + + // 비밀번호 검증 + static matches(plainPassword: string, hashedPassword: string): boolean { + return bcrypt.compareSync(plainPassword, hashedPassword); + } +} + +// 사용 예시 +const hashedPassword = EncryptUtil.hash('mypassword'); +const isValid = EncryptUtil.matches('mypassword', hashedPassword); +``` + +### 15.2 민감 정보 암호화 (AES-256) + +```typescript +// utils/credentialEncryption.ts +import crypto from 'crypto'; + +const ALGORITHM = 'aes-256-cbc'; +const SECRET_KEY = process.env.ENCRYPTION_KEY || 'default-32-char-secret-key!!!!'; +const IV_LENGTH = 16; + +// 암호화 +export function encrypt(text: string): string { + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(SECRET_KEY), iv); + + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + return iv.toString('hex') + ':' + encrypted; +} + +// 복호화 +export function decrypt(encryptedText: string): string { + const parts = encryptedText.split(':'); + const iv = Buffer.from(parts[0], 'hex'); + const encryptedData = parts[1]; + + const decipher = crypto.createDecipheriv(ALGORITHM, Buffer.from(SECRET_KEY), iv); + + let decrypted = decipher.update(encryptedData, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; +} + +// 사용 예시: 외부 DB 비밀번호 저장 +const encryptedPassword = encrypt('db_password'); +await query( + 'INSERT INTO external_db_connections (password) VALUES ($1)', + [encryptedPassword] +); + +// 사용 시 복호화 +const connection = await queryOne('SELECT * FROM external_db_connections WHERE id = $1', [id]); +const plainPassword = decrypt(connection.password); +``` + +### 15.3 SQL Injection 방지 + +```typescript +// ✅ Parameterized Query (항상 사용) +const users = await query( + 'SELECT * FROM user_info WHERE user_id = $1 AND company_code = $2', + [userId, companyCode] +); + +// ❌ 문자열 연결 (절대 사용 금지!) +const users = await query( + `SELECT * FROM user_info WHERE user_id = '${userId}'` +); +``` + +### 15.4 Rate Limiting + +```typescript +// app.ts +const limiter = rateLimit({ + windowMs: 1 * 60 * 1000, // 1분 + max: 10000, // 최대 10000회 (개발: 완화, 운영: 100) + message: { + error: '너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.' + }, + skip: (req) => { + // 헬스 체크는 Rate Limiting 제외 + return req.path === '/health'; + } +}); + +app.use('/api/', limiter); +``` + +### 15.5 CORS 설정 + +```typescript +// config/environment.ts +const getCorsOrigin = () => { + // 개발 환경: 모든 origin 허용 + if (process.env.NODE_ENV === 'development') { + return true; + } + + // 운영 환경: 허용 도메인만 + return [ + 'http://localhost:9771', + 'http://39.117.244.52:5555', + 'https://v1.vexplor.com', + 'https://api.vexplor.com' + ]; +}; + +// app.ts +app.use(cors({ + origin: getCorsOrigin(), + credentials: true, // 쿠키 포함 + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'] +})); +``` + +--- + +## 16. 성능 최적화 + +### 16.1 Connection Pool 모니터링 + +```typescript +// database/db.ts +// 5분마다 Pool 상태 체크 +setInterval(() => { + if (pool) { + const status = { + totalCount: pool.totalCount, // 전체 연결 수 + idleCount: pool.idleCount, // 유휴 연결 수 + waitingCount: pool.waitingCount // 대기 중인 연결 수 + }; + + // 대기 연결이 5개 이상이면 경고 + if (status.waitingCount > 5) { + console.warn('⚠️ PostgreSQL 연결 풀 대기열 증가:', status); + } + } +}, 5 * 60 * 1000); +``` + +### 16.2 쿼리 실행 시간 로깅 + +```typescript +// database/db.ts +export async function query(text: string, params?: any[]) { + const client = await pool.connect(); + + try { + const startTime = Date.now(); + const result = await client.query(text, params); + const duration = Date.now() - startTime; + + // 1초 이상 걸린 쿼리는 경고 + if (duration > 1000) { + logger.warn('⚠️ 느린 쿼리 감지:', { + query: text, + params, + duration: `${duration}ms` + }); + } + + return result.rows; + } finally { + client.release(); + } +} +``` + +### 16.3 캐싱 전략 + +```typescript +// utils/cache.ts (Redis 기반) +import Redis from 'redis'; + +const redis = Redis.createClient({ + url: process.env.REDIS_URL || 'redis://localhost:6379' +}); + +// 캐시 조회 (있으면 반환, 없으면 쿼리 후 캐싱) +export async function getCachedData(key: string, fetcher: () => Promise, ttl: number = 300) { + // 1. 캐시 확인 + const cached = await redis.get(key); + if (cached) { + return JSON.parse(cached); + } + + // 2. 캐시 미스 → DB 조회 + const data = await fetcher(); + + // 3. 캐시 저장 (TTL: 5분) + await redis.setEx(key, ttl, JSON.stringify(data)); + + return data; +} + +// 사용 예시: 메뉴 목록 캐싱 +const menuList = await getCachedData( + `menu:${companyCode}:${userId}`, + () => AdminService.getUserMenuList(params), + 600 // 10분 캐싱 +); +``` + +### 16.4 압축 (Gzip) + +```typescript +// app.ts +import compression from 'compression'; + +app.use(compression({ + level: 6, // 압축 레벨 (0~9) + threshold: 1024, // 1KB 이상만 압축 + filter: (req, res) => { + // JSON 응답만 압축 + return req.headers['x-no-compression'] ? false : compression.filter(req, res); + } +})); +``` + +--- + +## 🎯 핵심 요약 + +### 아키텍처 패턴 +- **Layered Architecture**: Controller → Service → Database +- **Multi-tenancy**: `company_code` 기반 완전한 데이터 격리 +- **JWT 인증**: Stateless 토큰 기반 (24시간 만료) +- **Raw Query**: ORM 없이 PostgreSQL 직접 쿼리 + +### 보안 원칙 +1. **모든 쿼리에 company_code 필터 필수** +2. **JWT 토큰에서 company_code 추출 (클라이언트 신뢰 금지)** +3. **Parameterized Query로 SQL Injection 방지** +4. **비밀번호 bcrypt, 민감정보 AES-256 암호화** +5. **Rate Limiting으로 DDoS 방지** + +### 주요 도메인 +- 관리자 (사용자/메뉴/권한) +- 테이블/화면 메타데이터 +- 플로우 (워크플로우 엔진) +- 데이터플로우 (ERD/관계도) +- 외부 연동 (DB/REST API) +- 배치 (Cron 스케줄러) +- 메일 (발송/수신) +- 파일 (업로드/다운로드) + +### API 개수 +- **총 70+ 라우트** +- **인증/관리자**: 15개 +- **테이블/화면**: 20개 +- **플로우**: 10개 +- **외부 연동**: 10개 +- **배치**: 6개 +- **메일**: 5개 +- **파일**: 4개 + +--- + +**문서 버전**: 1.0 +**마지막 업데이트**: 2026-02-06 diff --git a/docs/backend-architecture-summary.md b/docs/backend-architecture-summary.md new file mode 100644 index 00000000..d2155f24 --- /dev/null +++ b/docs/backend-architecture-summary.md @@ -0,0 +1,342 @@ +# WACE ERP Backend - 아키텍처 요약 + +> **작성일**: 2026-02-06 +> **목적**: 워크플로우 문서 통합용 백엔드 아키텍처 요약 + +--- + +## 1. 기술 스택 + +``` +언어: TypeScript (Node.js 20.10.0+) +프레임워크: Express.js +데이터베이스: PostgreSQL (pg 라이브러리, Raw Query) +인증: JWT (jsonwebtoken) +스케줄러: node-cron +메일: nodemailer + IMAP +파일업로드: multer +외부DB: MySQL, MSSQL, Oracle 지원 +``` + +## 2. 계층 구조 + +``` +┌─────────────────┐ +│ Controller │ ← API 요청 수신, 응답 생성 +└────────┬────────┘ + │ +┌────────▼────────┐ +│ Service │ ← 비즈니스 로직, 트랜잭션 관리 +└────────┬────────┘ + │ +┌────────▼────────┐ +│ Database │ ← PostgreSQL Raw Query +└─────────────────┘ +``` + +## 3. 디렉토리 구조 + +``` +backend-node/src/ +├── app.ts # Express 앱 진입점 +├── config/ # 환경설정 +├── controllers/ # 70+ 컨트롤러 +├── services/ # 80+ 서비스 +├── routes/ # 70+ 라우터 +├── middleware/ # 인증/권한/에러핸들러 +├── database/ # DB 연결 (pg Pool) +├── types/ # TypeScript 타입 (26개) +└── utils/ # 유틸리티 (JWT, 암호화, 로거) +``` + +## 4. 미들웨어 스택 순서 + +```typescript +1. Process Level Exception Handlers (unhandledRejection, uncaughtException) +2. Helmet (보안 헤더) +3. Compression (Gzip) +4. Body Parser (JSON, URL-encoded, 10MB limit) +5. Static Files (/uploads) +6. CORS (credentials: true) +7. Rate Limiting (1분 10000회) +8. Token Auto Refresh (1시간 이내 만료 시 갱신) +9. API Routes (70+개) +10. 404 Handler +11. Error Handler +``` + +## 5. 인증/인가 시스템 + +### 5.1 인증 흐름 + +``` +로그인 요청 + ↓ +AuthController.login() + ↓ +AuthService.processLogin() + ├─ loginPwdCheck() → 비밀번호 검증 (bcrypt) + ├─ getPersonBeanFromSession() → 사용자 정보 조회 + ├─ insertLoginAccessLog() → 로그인 이력 저장 + └─ JwtUtils.generateToken() → JWT 토큰 생성 + ↓ +응답: { token, userInfo, firstMenuPath } +``` + +### 5.2 JWT Payload + +```json +{ + "userId": "user123", + "userName": "홍길동", + "companyCode": "ILSHIN", + "userType": "COMPANY_ADMIN", + "iat": 1234567890, + "exp": 1234654290, + "iss": "PMS-System" +} +``` + +### 5.3 권한 체계 (3단계) + +| 권한 | company_code | userType | 권한 범위 | +|------|--------------|----------|-----------| +| **SUPER_ADMIN** | `*` | `SUPER_ADMIN` | 전체 회사, DDL 실행, 회사 생성/삭제 | +| **COMPANY_ADMIN** | `ILSHIN` | `COMPANY_ADMIN` | 자기 회사만, 사용자/설정 관리 | +| **USER** | `ILSHIN` | `USER` | 자기 회사만, 읽기/쓰기 | + +## 6. 멀티테넌시 구현 + +### 핵심 원칙 +```typescript +// ✅ 올바른 패턴 +const companyCode = req.user!.companyCode; // JWT에서 추출 + +if (companyCode === "*") { + // 슈퍼관리자: 모든 데이터 조회 + query = "SELECT * FROM table ORDER BY company_code"; +} else { + // 일반 사용자: 자기 회사 + 슈퍼관리자 데이터 제외 + query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'"; + params = [companyCode]; +} + +// ❌ 잘못된 패턴 (보안 위험!) +const companyCode = req.body.companyCode; // 클라이언트에서 받음 +``` + +### 슈퍼관리자 숨김 규칙 +```sql +-- 일반 회사 사용자에게 슈퍼관리자(company_code='*')는 보이면 안 됨 +SELECT * FROM user_info +WHERE company_code = $1 + AND company_code != '*' -- 필수! +``` + +## 7. API 라우트 (70+개) + +### 7.1 인증/관리자 +- `POST /api/auth/login` - 로그인 +- `GET /api/auth/me` - 현재 사용자 정보 +- `POST /api/auth/switch-company` - 회사 전환 (슈퍼관리자) +- `GET /api/admin/users` - 사용자 목록 +- `GET /api/admin/menus` - 메뉴 목록 + +### 7.2 테이블/화면 +- `GET /api/table-management/tables` - 테이블 목록 +- `POST /api/table-management/tables/:table/data` - 데이터 조회 +- `POST /api/table-management/multi-table-save` - 다중 테이블 저장 +- `GET /api/screen-management/screens` - 화면 목록 + +### 7.3 플로우 +- `GET /api/flow/definitions` - 플로우 정의 목록 +- `POST /api/flow/move` - 데이터 이동 (단건) +- `POST /api/flow/move-batch` - 데이터 이동 (다건) + +### 7.4 외부 연동 +- `GET /api/external-db-connections` - 외부 DB 연결 목록 +- `POST /api/external-db-connections/:id/test` - 연결 테스트 +- `POST /api/multi-connection/query` - 멀티 DB 쿼리 + +### 7.5 배치 +- `GET /api/batch-configs` - 배치 설정 목록 +- `POST /api/batch-management/:id/execute` - 배치 즉시 실행 + +### 7.6 메일 +- `POST /api/mail/send` - 메일 발송 +- `GET /api/mail/sent` - 발송 이력 + +### 7.7 파일 +- `POST /api/files/upload` - 파일 업로드 +- `GET /uploads/:filename` - 정적 파일 서빙 + +## 8. 비즈니스 도메인 (8개) + +| 도메인 | 컨트롤러 | 주요 기능 | +|--------|----------|-----------| +| **관리자** | `adminController` | 사용자/메뉴/권한 관리 | +| **테이블/화면** | `tableManagementController` | 메타데이터, 동적 화면 생성 | +| **플로우** | `flowController` | 워크플로우 엔진, 데이터 이동 | +| **데이터플로우** | `dataflowController` | ERD, 관계도 | +| **외부 연동** | `externalDbConnectionController` | 외부 DB/REST API | +| **배치** | `batchController` | Cron 스케줄러, 데이터 동기화 | +| **메일** | `mailSendSimpleController` | 메일 발송/수신 | +| **파일** | `fileController` | 파일 업로드/다운로드 | + +## 9. 데이터베이스 접근 + +### Connection Pool 설정 +```typescript +{ + min: 2~5, // 최소 연결 수 + max: 10~20, // 최대 연결 수 + connectionTimeout: 30000, // 30초 + idleTimeout: 600000, // 10분 + statementTimeout: 60000 // 쿼리 실행 60초 +} +``` + +### Raw Query 패턴 +```typescript +// 1. 다중 행 +const users = await query('SELECT * FROM user_info WHERE company_code = $1', [companyCode]); + +// 2. 단일 행 +const user = await queryOne('SELECT * FROM user_info WHERE user_id = $1', [userId]); + +// 3. 트랜잭션 +await transaction(async (client) => { + await client.query('INSERT INTO table1 ...', [...]); + await client.query('INSERT INTO table2 ...', [...]); +}); +``` + +## 10. 외부 시스템 연동 + +### 지원 데이터베이스 +- PostgreSQL +- MySQL +- Microsoft SQL Server +- Oracle + +### Connector Factory Pattern +```typescript +DatabaseConnectorFactory + ├── PostgreSQLConnector + ├── MySQLConnector + ├── MSSQLConnector + └── OracleConnector +``` + +## 11. 배치/스케줄 시스템 + +### Cron 스케줄러 +```typescript +// node-cron 기반 +// 매일 새벽 2시: "0 2 * * *" +// 5분마다: "*/5 * * * *" +// 평일 오전 8시: "0 8 * * 1-5" + +// 서버 시작 시 자동 초기화 +BatchSchedulerService.initializeScheduler(); +``` + +### 배치 실행 흐름 +``` +1. 소스 DB에서 데이터 조회 + ↓ +2. 컬럼 매핑 적용 + ↓ +3. 타겟 DB에 INSERT/UPDATE + ↓ +4. 실행 로그 기록 (batch_execution_logs) +``` + +## 12. 파일 처리 + +### 업로드 경로 +``` +uploads/ + └── {company_code}/ + └── {timestamp}-{uuid}-{filename} +``` + +### Multer 설정 +- 최대 파일 크기: 10MB +- 허용 타입: 이미지, PDF, Office 문서 +- 파일명 중복 방지: 타임스탬프 + UUID + +## 13. 보안 + +### 암호화 +- **비밀번호**: bcrypt (12 rounds) +- **민감정보**: AES-256-CBC (외부 DB 비밀번호 등) +- **JWT Secret**: 환경변수 관리 + +### 보안 헤더 +- Helmet (CSP, X-Frame-Options) +- CORS (credentials: true) +- Rate Limiting (1분 10000회) + +### SQL Injection 방지 +- Parameterized Query 사용 (pg 라이브러리) +- 동적 쿼리 빌더 패턴 + +## 14. 에러 핸들링 + +### PostgreSQL 에러 코드 매핑 +- `23505` → "중복된 데이터" +- `23503` → "참조 무결성 위반" +- `23502` → "필수 입력값 누락" + +### 프로세스 레벨 +- `unhandledRejection` → 로깅 (서버 유지) +- `uncaughtException` → 로깅 (서버 유지, 주의) +- `SIGTERM/SIGINT` → Graceful Shutdown + +## 15. 로깅 (Winston) + +### 로그 파일 +- `logs/error.log` - 에러만 (10MB × 5파일) +- `logs/combined.log` - 전체 로그 (10MB × 10파일) + +### 로그 레벨 +``` +error (0) → warn (1) → info (2) → debug (5) +``` + +## 16. 성능 최적화 + +### Pool 모니터링 +- 5분마다 상태 체크 +- 대기 연결 5개 이상 시 경고 + +### 느린 쿼리 감지 +- 1초 이상 걸린 쿼리 자동 경고 + +### 캐싱 (Redis) +- 메뉴 목록: 10분 TTL +- 공통코드: 30분 TTL + +### Gzip 압축 +- 1KB 이상 응답만 압축 (레벨 6) + +--- + +## 🎯 핵심 체크리스트 + +### 개발 시 반드시 지켜야 할 규칙 + +✅ **모든 쿼리에 `company_code` 필터 추가** +✅ **JWT 토큰에서 `company_code` 추출 (클라이언트 신뢰 금지)** +✅ **Parameterized Query 사용 (SQL Injection 방지)** +✅ **슈퍼관리자 데이터 숨김 (`company_code != '*'`)** +✅ **비밀번호는 bcrypt, 민감정보는 AES-256** +✅ **에러 핸들링 try/catch 필수** +✅ **트랜잭션이 필요한 경우 `transaction()` 사용** +✅ **파일 업로드는 회사별 디렉토리 분리** + +--- + +**문서 버전**: 1.0 +**마지막 업데이트**: 2026-02-06 diff --git a/docs/frontend-architecture-analysis.md b/docs/frontend-architecture-analysis.md new file mode 100644 index 00000000..fb367585 --- /dev/null +++ b/docs/frontend-architecture-analysis.md @@ -0,0 +1,1920 @@ +# WACE ERP 프론트엔드 아키텍처 상세 분석 + +> 작성일: 2026-02-06 +> 작성자: AI Assistant +> 프로젝트: WACE ERP-node +> 목적: 시스템 전체 워크플로우 문서화를 위한 프론트엔드 구조 분석 + +--- + +## 목차 +1. [전체 디렉토리 구조](#1-전체-디렉토리-구조) +2. [Next.js App Router 구조](#2-nextjs-app-router-구조) +3. [컴포넌트 시스템](#3-컴포넌트-시스템) +4. [V2 컴포넌트 시스템](#4-v2-컴포넌트-시스템) +5. [화면 디자이너 워크플로우](#5-화면-디자이너-워크플로우) +6. [API 클라이언트 시스템](#6-api-클라이언트-시스템) +7. [상태 관리](#7-상태-관리) +8. [레지스트리 시스템](#8-레지스트리-시스템) +9. [대시보드 시스템](#9-대시보드-시스템) +10. [다국어 지원](#10-다국어-지원) +11. [인증 플로우](#11-인증-플로우) +12. [사용자 워크플로우](#12-사용자-워크플로우) + +--- + +## 1. 전체 디렉토리 구조 + +``` +frontend/ +├── app/ # Next.js 14 App Router +│ ├── (main)/ # 메인 레이아웃 그룹 +│ ├── (auth)/ # 인증 레이아웃 그룹 +│ ├── (admin)/ # 관리자 레이아웃 그룹 +│ ├── (pop)/ # 팝업 레이아웃 그룹 +│ ├── layout.tsx # 루트 레이아웃 +│ └── registry-provider.tsx # 레지스트리 초기화 프로바이더 +│ +├── components/ # React 컴포넌트 +│ ├── admin/ # 관리자 컴포넌트 (137개) +│ ├── screen/ # 화면 디자이너/뷰어 (139개) +│ ├── dashboard/ # 대시보드 컴포넌트 (32개) +│ ├── dataflow/ # 데이터플로우 관련 (90개) +│ ├── v2/ # V2 컴포넌트 시스템 (28개) +│ ├── common/ # 공통 컴포넌트 (19개) +│ ├── layout/ # 레이아웃 컴포넌트 (10개) +│ ├── ui/ # shadcn/ui 기반 UI (31개) +│ └── ... # 기타 도메인별 컴포넌트 +│ +├── lib/ # 유틸리티 & 라이브러리 +│ ├── api/ # API 클라이언트 (57개 파일) +│ │ ├── client.ts # Axios 기본 설정 +│ │ ├── screen.ts # 화면 관리 API +│ │ ├── user.ts # 사용자 관리 API +│ │ └── ... # 도메인별 API +│ │ +│ ├── registry/ # 컴포넌트 레지스트리 시스템 (540개) +│ │ ├── ComponentRegistry.ts # 컴포넌트 등록 관리 +│ │ ├── WebTypeRegistry.ts # 웹타입 등록 관리 +│ │ ├── LayoutRegistry.ts # 레이아웃 등록 관리 +│ │ ├── init.ts # 레지스트리 초기화 +│ │ ├── DynamicComponentRenderer.tsx # 동적 렌더링 +│ │ ├── components/ # 등록 가능한 컴포넌트들 (288 tsx, 205 ts) +│ │ │ ├── v2-input/ +│ │ │ ├── v2-select/ +│ │ │ ├── text-input/ +│ │ │ ├── entity-search-input/ +│ │ │ ├── modal-repeater-table/ +│ │ │ └── ... +│ │ └── layouts/ # 레이아웃 컴포넌트 +│ │ ├── accordion/ +│ │ ├── grid/ +│ │ ├── flexbox/ +│ │ ├── split/ +│ │ └── tabs/ +│ │ +│ ├── v2-core/ # V2 코어 시스템 +│ │ ├── events/ # 이벤트 버스 +│ │ ├── adapters/ # 레거시 어댑터 +│ │ ├── components/ # V2 공통 컴포넌트 +│ │ ├── services/ # V2 서비스 +│ │ └── init.ts # V2 초기화 +│ │ +│ ├── utils/ # 유틸리티 함수들 (40개+) +│ ├── services/ # 비즈니스 로직 서비스 +│ ├── stores/ # Zustand 스토어 +│ └── constants/ # 상수 정의 +│ +├── contexts/ # React Context (12개) +│ ├── AuthContext.tsx # 인증 컨텍스트 +│ ├── MenuContext.tsx # 메뉴 컨텍스트 +│ ├── ScreenContext.tsx # 화면 컨텍스트 +│ ├── DashboardContext.tsx # 대시보드 컨텍스트 +│ └── ... +│ +├── hooks/ # Custom Hooks (32개) +│ ├── useAuth.ts # 인증 훅 +│ ├── useMenu.ts # 메뉴 관리 훅 +│ ├── useFormValidation.ts # 폼 검증 훅 +│ └── ... +│ +├── types/ # TypeScript 타입 정의 (44개) +│ ├── screen.ts # 화면 관련 타입 +│ ├── component.ts # 컴포넌트 타입 +│ ├── user.ts # 사용자 타입 +│ └── ... +│ +├── providers/ # React Provider +│ └── QueryProvider.tsx # React Query 설정 +│ +├── stores/ # 전역 상태 관리 +│ ├── flowStepStore.ts # 플로우 단계 상태 +│ ├── modalDataStore.ts # 모달 데이터 상태 +│ └── tableDisplayStore.ts # 테이블 표시 상태 +│ +└── public/ # 정적 파일 + ├── favicon.ico + ├── logo.png + └── locales/ # 다국어 파일 +``` + +--- + +## 2. Next.js App Router 구조 + +### 2.1 라우팅 구조 + +WACE ERP는 **Next.js 14 App Router**를 사용하며, 레이아웃 그룹을 활용한 구조화된 라우팅을 제공합니다. + +#### 라우트 그룹 구조 + +``` +app/ +├── layout.tsx # 글로벌 레이아웃 +│ ├── QueryProvider # React Query 초기화 +│ ├── RegistryProvider # 컴포넌트 레지스트리 초기화 +│ ├── Toaster # 토스트 알림 +│ └── ScreenModal # 전역 화면 모달 +│ +├── (main)/ # 메인 애플리케이션 +│ ├── layout.tsx +│ │ ├── AuthProvider # 인증 컨텍스트 +│ │ ├── MenuProvider # 메뉴 컨텍스트 +│ │ └── AppLayout # 사이드바, 헤더 +│ │ +│ ├── main/page.tsx # 메인 페이지 +│ ├── dashboard/ # 대시보드 +│ │ ├── page.tsx # 대시보드 목록 +│ │ └── [dashboardId]/page.tsx # 대시보드 상세 +│ │ +│ ├── screens/[screenId]/page.tsx # 동적 화면 뷰어 +│ │ +│ └── admin/ # 관리자 페이지 +│ ├── page.tsx # 관리자 홈 +│ ├── menu/page.tsx # 메뉴 관리 +│ │ +│ ├── userMng/ # 사용자 관리 +│ │ ├── userMngList/page.tsx +│ │ ├── rolesList/page.tsx +│ │ ├── userAuthList/page.tsx +│ │ └── companyList/page.tsx +│ │ +│ ├── screenMng/ # 화면 관리 +│ │ ├── screenMngList/page.tsx +│ │ ├── dashboardList/page.tsx +│ │ └── reportList/page.tsx +│ │ +│ ├── systemMng/ # 시스템 관리 +│ │ ├── tableMngList/page.tsx +│ │ ├── commonCodeList/page.tsx +│ │ ├── i18nList/page.tsx +│ │ ├── dataflow/page.tsx +│ │ └── collection-managementList/page.tsx +│ │ +│ ├── automaticMng/ # 자동화 관리 +│ │ ├── flowMgmtList/page.tsx +│ │ ├── batchmngList/page.tsx +│ │ ├── exCallConfList/page.tsx +│ │ └── mail/ # 메일 관리 +│ │ ├── accounts/page.tsx +│ │ ├── send/page.tsx +│ │ ├── receive/page.tsx +│ │ └── templates/page.tsx +│ │ +│ └── [...slug]/page.tsx # 동적 관리자 페이지 +│ +├── (auth)/ # 인증 페이지 +│ ├── layout.tsx # 최소 레이아웃 +│ └── login/page.tsx # 로그인 페이지 +│ +├── (admin)/ # 관리자 전용 (별도 레이아웃) +│ └── admin/ +│ ├── vehicle-trips/page.tsx +│ └── vehicle-reports/page.tsx +│ +└── (pop)/ # 팝업 페이지 + ├── layout.tsx # 팝업 레이아웃 + ├── pop/page.tsx + └── work/page.tsx +``` + +### 2.2 페이지 목록 (총 76개) + +**메인 애플리케이션 (50개)** +- `/main` - 메인 페이지 +- `/dashboard` - 대시보드 목록 +- `/dashboard/[dashboardId]` - 대시보드 상세 +- `/screens/[screenId]` - 동적 화면 뷰어 ⭐ 핵심 +- `/multilang` - 다국어 관리 + +**관리자 페이지 (40개+)** +- 사용자 관리: 사용자, 역할, 권한, 회사, 부서 +- 화면 관리: 화면 목록, 대시보드, 리포트 +- 시스템 관리: 테이블, 공통코드, 다국어, 데이터플로우, 컬렉션 +- 자동화 관리: 플로우, 배치, 외부호출, 메일 +- 표준 관리: 웹타입 표준화 +- 캐스케이딩: 관계, 계층, 상호배타, 자동채움 + +**테스트 페이지 (5개)** +- `/test-autocomplete-mapping` +- `/test-entity-search` +- `/test-order-registration` +- `/test-type-safety` +- `/test-flow` + +**인증/기타 (2개)** +- `/login` - 로그인 +- `/pop` - 팝업 페이지 + +### 2.3 Next.js 설정 + +**next.config.mjs** +```javascript +{ + output: "standalone", // Docker 최적화 + eslint: { ignoreDuringBuilds: true }, + typescript: { ignoreBuildErrors: true }, + + // API 프록시: /api/* → http://localhost:8080/api/* + rewrites: [ + { source: "/api/:path*", destination: "http://localhost:8080/api/:path*" } + ], + + // CORS 헤더 + headers: [ + { source: "/api/:path*", headers: [...] } + ], + + // 환경 변수 + env: { + NEXT_PUBLIC_API_URL: "http://localhost:8080/api" + } +} +``` + +--- + +## 3. 컴포넌트 시스템 + +### 3.1 컴포넌트 분류 + +WACE ERP의 컴포넌트는 크게 **4가지 계층**으로 구분됩니다: + +#### 계층 구조 + +``` +1. UI 컴포넌트 (shadcn/ui 기반) - components/ui/ + └─ Button, Input, Select, Dialog, Card 등 기본 UI + +2. 공통 컴포넌트 - components/common/ + └─ ScreenModal, FormValidationIndicator 등 + +3. 도메인 컴포넌트 - components/{domain}/ + ├─ screen/ - 화면 디자이너/뷰어 + ├─ admin/ - 관리자 기능 + ├─ dashboard/ - 대시보드 + └─ dataflow/ - 데이터플로우 + +4. 레지스트리 컴포넌트 - lib/registry/components/ + └─ 동적으로 등록/렌더링되는 컴포넌트들 (90개+) +``` + +### 3.2 주요 컴포넌트 모듈 + +#### 📁 components/screen/ (139개 파일) + +**핵심 컴포넌트** + +| 컴포넌트 | 역할 | 주요 기능 | +|---------|------|----------| +| `ScreenDesigner.tsx` (7095줄) | 화면 디자이너 | - 드래그&드롭으로 컴포넌트 배치
- 그리드 시스템 (12컬럼)
- 실시간 미리보기
- 컴포넌트 설정 패널
- 그룹화/정렬/분산 도구 | +| `InteractiveScreenViewer.tsx` (2472줄) | 화면 뷰어 | - 실제 사용자 화면 렌더링
- 폼 데이터 바인딩
- 버튼 액션 실행
- 검증 처리 | +| `ScreenManagementList.tsx` | 화면 목록 | - 화면 CRUD
- 메뉴 할당
- 다국어 설정 | +| `DynamicWebTypeRenderer.tsx` | 웹타입 렌더러 | - WebTypeRegistry 기반 동적 렌더링 | + +**위젯 컴포넌트 (widgets/types/)** +- TextWidget, NumberWidget, DateWidget +- SelectWidget, CheckboxWidget, RadioWidget +- TextareaWidget, FileWidget, CodeWidget +- ButtonWidget, EntitySearchInputWrapper + +**설정 패널 (config-panels/)** +- 각 웹타입별 설정 패널 (TextConfigPanel, SelectConfigPanel 등) + +**모달 컴포넌트 (modals/)** +- 키보드 단축키, 다국어 설정, 메뉴 할당 등 + +#### 📁 components/admin/ (137개 파일) + +**관리자 기능 컴포넌트** +- 사용자 관리: UserManagementList, RolesManagementList, CompanyList +- 화면 관리: ScreenManagementList, DashboardManagementList +- 테이블 관리: TableManagementList, ColumnEditor +- 공통코드 관리: CommonCodeManagement +- 메뉴 관리: MenuManagement + +#### 📁 components/dashboard/ (32개 파일) + +**대시보드 컴포넌트** +- DashboardViewer: 대시보드 렌더링 +- DashboardWidgets: 차트, 테이블, 카드 등 위젯 +- ChartComponents: 라인, 바, 파이, 도넛 차트 + +#### 📁 components/dataflow/ (90개+ 파일) + +**데이터플로우 시스템** +- DataFlowList: 플로우 목록 +- node-editor/: 노드 편집기 +- connection/: 커넥션 관리 (38개 파일) +- condition/: 조건 설정 +- external-call/: 외부 호출 설정 + +--- + +## 4. V2 컴포넌트 시스템 + +V2는 **통합 컴포넌트 시스템**으로, 유사한 기능을 하나의 컴포넌트로 통합하여 개발 효율성을 높입니다. + +### 4.1 V2 컴포넌트 종류 (9개) + +| ID | 이름 | 설명 | 포함 기능 | +|----|------|------|----------| +| `v2-input` | 통합 입력 | 텍스트, 숫자, 비밀번호, 슬라이더, 컬러 등 | text, number, password, email, tel, textarea, slider, color | +| `v2-select` | 통합 선택 | 드롭다운, 라디오, 체크박스, 태그, 토글 등 | dropdown, radio, checkbox, tagbox, toggle, swap, combobox | +| `v2-date` | 통합 날짜 | 날짜, 시간, 날짜시간, 날짜 범위 | date, time, datetime, daterange | +| `v2-list` | 통합 목록 | 테이블, 카드, 칸반, 리스트 | table, card, kanban, list, grid | +| `v2-layout` | 통합 레이아웃 | 그리드, 분할 패널, 플렉스 | grid, split, flex, masonry | +| `v2-group` | 통합 그룹 | 탭, 아코디언, 섹션, 모달 | tabs, accordion, section, modal, drawer | +| `v2-media` | 통합 미디어 | 이미지, 비디오, 오디오, 파일 업로드 | image, video, audio, file | +| `v2-biz` | 통합 비즈니스 | 플로우, 랙, 채번규칙, 카테고리 | flow, rack, numbering, category | +| `v2-repeater` | 통합 반복 | 인라인 테이블, 모달, 버튼 | inline-table, modal, button, selected-items | + +### 4.2 V2 아키텍처 + +``` +components/v2/ # V2 UI 컴포넌트 +├── V2Input.tsx # 통합 입력 컴포넌트 +├── V2Select.tsx # 통합 선택 컴포넌트 +├── V2Date.tsx # 통합 날짜 컴포넌트 +├── V2List.tsx # 통합 목록 컴포넌트 +├── V2Layout.tsx # 통합 레이아웃 컴포넌트 +├── V2Group.tsx # 통합 그룹 컴포넌트 +├── V2Media.tsx # 통합 미디어 컴포넌트 +├── V2Biz.tsx # 통합 비즈니스 컴포넌트 +├── V2Hierarchy.tsx # 통합 계층 컴포넌트 +├── V2Repeater.tsx # 통합 반복 컴포넌트 +├── V2FormContext.tsx # V2 폼 컨텍스트 +├── V2ComponentRenderer.tsx # V2 렌더러 +├── registerV2Components.ts # V2 등록 함수 +└── config-panels/ # V2 설정 패널 + ├── V2InputConfigPanel.tsx + ├── V2SelectConfigPanel.tsx + └── ... + +lib/v2-core/ # V2 코어 라이브러리 +├── events/ # 이벤트 버스 +│ ├── EventBus.ts # 발행/구독 패턴 +│ └── types.ts # 이벤트 타입 +├── adapters/ # 레거시 어댑터 +│ └── LegacyEventAdapter.ts # 레거시 시스템 연동 +├── components/ # V2 공통 컴포넌트 +│ └── V2ErrorBoundary.tsx # 에러 경계 +├── services/ # V2 서비스 +│ ├── ScheduleGeneratorService.ts +│ └── ScheduleConfirmDialog.tsx +└── init.ts # V2 초기화 + +lib/registry/components/v2-*/ # V2 렌더러 (레지스트리용) +├── v2-input/ +│ ├── V2InputRenderer.tsx # 자동 등록 렌더러 +│ └── index.ts # 컴포넌트 정의 +├── v2-select/ +│ ├── V2SelectRenderer.tsx +│ └── index.ts +└── ... +``` + +### 4.3 V2 초기화 흐름 + +```typescript +// 1. app/registry-provider.tsx +useEffect(() => { + // 레거시 레지스트리 초기화 + initializeRegistries(); + + // V2 Core 초기화 + initV2Core({ + debug: false, + legacyBridge: { legacyToV2: true, v2ToLegacy: true } + }); +}, []); + +// 2. lib/registry/init.ts +export function initializeRegistries() { + // 웹타입 등록 (text, number, date 등) + initializeWebTypeRegistry(); + + // V2 컴포넌트 등록 + registerV2Components(); +} + +// 3. components/v2/registerV2Components.ts +export function registerV2Components() { + for (const definition of v2ComponentDefinitions) { + ComponentRegistry.registerComponent(definition); + } +} +``` + +### 4.4 V2 렌더링 흐름 + +``` +사용자 요청 (screens/[screenId]) + ↓ +InteractiveScreenViewer + ↓ +DynamicComponentRenderer + ↓ +┌─ componentType이 v2-* 인가? ─┐ +│ Yes │ No +↓ ↓ +ComponentRegistry.getComponent LegacyComponentRegistry.get + ↓ ↓ +V2*Renderer (클래스 기반) 레거시 렌더러 (함수) + ↓ ↓ +render() → V2* 컴포넌트 직접 렌더링 + ↓ +V2FormContext 연동 (선택) + ↓ +최종 렌더링 +``` + +### 4.5 V2 vs 레거시 비교 + +| 항목 | 레거시 시스템 | V2 시스템 | +|------|--------------|----------| +| 컴포넌트 수 | 90개+ (분산) | 9개 (통합) | +| 렌더러 패턴 | 함수형 렌더러 | 클래스 기반 자동 등록 | +| 폼 통합 | 개별 구현 | V2FormContext 통합 | +| 이벤트 시스템 | props drilling | V2 EventBus (발행/구독) | +| 에러 처리 | 개별 처리 | V2ErrorBoundary 통합 | +| 설정 패널 | 개별 패널 | 통합 설정 시스템 | +| 핫 리로드 | 미지원 | 개발 모드 지원 | + +--- + +## 5. 화면 디자이너 워크플로우 + +### 5.1 화면 디자이너 구성 + +**ScreenDesigner.tsx** (7095줄) - 핵심 컴포넌트 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Screen Designer │ +├─────────────────────────────────────────────────────────────────┤ +│ 탭1: 디자인 | 탭2: 프리뷰 | 탭3: 다국어 | 탭4: 메뉴할당 | 탭5: 스타일 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ [컴포넌트 팔레트] [캔버스] [속성 패널] │ +│ ┌──────────────┐ ┌────────────────┐ ┌──────────────┐ │ +│ │ 입력 컴포넌트 │ │ │ │ 기본 설정 │ │ +│ │ - Text │ │ 드래그&드롭 │ │ - ID │ │ +│ │ - Number │ │ 컴포넌트 │ │ - 라벨 │ │ +│ │ - Date │ ───▶ │ 배치 영역 │ ◀── │ - 위치/크기 │ │ +│ │ - Select │ │ │ │ - 데이터연결 │ │ +│ │ │ │ 그리드 기반 │ │ │ │ +│ │ 표시 컴포넌트 │ │ 12컬럼 │ │ 고급 설정 │ │ +│ │ - Table │ │ │ │ - 조건부표시 │ │ +│ │ - Card │ │ │ │ - 검증규칙 │ │ +│ │ │ │ │ │ - 버튼액션 │ │ +│ │ 레이아웃 │ └────────────────┘ └──────────────┘ │ +│ │ - Grid │ │ +│ │ - Tabs │ [하단 도구] │ +│ │ - Accordion │ ┌─────────────────────────────────────┐ │ +│ │ │ │ 그룹화 | 정렬 | 분산 | 라벨토글 │ │ +│ └──────────────┘ └─────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 5.2 주요 기능 + +#### 그리드 시스템 +```typescript +// 12컬럼 그리드 기반 +const GRID_COLUMNS = 12; +const COLUMN_WIDTH = (containerWidth - gaps) / 12; + +// 컴포넌트 너비 → 컬럼 스팬 변환 +function calculateColumnSpan(width: number): number { + return Math.max(1, Math.min(12, Math.round(width / COLUMN_WIDTH))); +} + +// 반응형 지원 +{ + default: { span: 6 }, // 기본 (50%) + sm: { span: 12 }, // 모바일 (100%) + md: { span: 6 }, // 태블릿 (50%) + lg: { span: 4 } // 데스크톱 (33%) +} +``` + +#### 컴포넌트 배치 흐름 + +``` +1. 컴포넌트 팔레트에서 드래그 시작 + ↓ +2. 캔버스에 드롭 + ↓ +3. 컴포넌트 생성 (generateComponentId) + ↓ +4. 위치 계산 (10px 단위 스냅) + ↓ +5. 그리드 정렬 (12컬럼 기준) + ↓ +6. 레이아웃 데이터 업데이트 + ↓ +7. 리렌더링 (RealtimePreview) +``` + +#### 컴포넌트 설정 + +``` +선택된 컴포넌트 → 우측 속성 패널 표시 + ↓ +┌─────────────────────────────────────┐ +│ 기본 설정 │ +│ - ID: comp_1234 │ +│ - 라벨: "제품명" │ +│ - 컬럼명: product_name │ +│ - 필수: ☑ │ +│ - 읽기전용: ☐ │ +│ │ +│ 크기 & 위치 │ +│ - X: 100px, Y: 200px │ +│ - 너비: 300px, 높이: 40px │ +│ - 컬럼 스팬: 6 │ +│ │ +│ 데이터 연결 │ +│ - 테이블: product_info │ +│ - 컬럼: product_name │ +│ - 웹타입: text │ +│ │ +│ 고급 설정 │ +│ - 조건부 표시: field === 'A' │ +│ - 버튼 액션: [등록], [수정], [삭제] │ +│ - 데이터플로우: flow_123 │ +└─────────────────────────────────────┘ +``` + +### 5.3 저장 구조 + +**ScreenDefinition (JSON)** + +```json +{ + "screenId": 123, + "screenCode": "PRODUCT_MGMT", + "screenName": "제품 관리", + "tableName": "product_info", + "screenType": "form", + "version": 2, + "layoutData": { + "components": [ + { + "id": "comp_text_1", + "type": "text-input", + "componentType": "text-input", + "label": "제품명", + "columnName": "product_name", + "position": { "x": 100, "y": 50, "z": 0 }, + "size": { "width": 300, "height": 40 }, + "componentConfig": { + "required": true, + "placeholder": "제품명을 입력하세요", + "maxLength": 100 + }, + "style": { + "labelDisplay": true, + "labelText": "제품명" + } + }, + { + "id": "comp_table_1", + "type": "table-list", + "componentType": "table-list", + "tableName": "product_info", + "position": { "x": 50, "y": 200, "z": 0 }, + "size": { "width": 800, "height": 400 }, + "componentConfig": { + "columns": ["product_code", "product_name", "price"], + "pagination": true, + "sortable": true + } + } + ], + "layouts": [ + { + "id": "layout_tabs_1", + "layoutType": "tabs", + "children": [ + { "tabId": "tab1", "title": "기본정보", "components": ["comp_text_1"] }, + { "tabId": "tab2", "title": "상세정보", "components": ["comp_table_1"] } + ] + } + ] + }, + "createdBy": "user123", + "createdDate": "2024-01-01T00:00:00Z" +} +``` + +--- + +## 6. API 클라이언트 시스템 + +### 6.1 API 클라이언트 구조 + +**lib/api/** (57개 파일) + +``` +lib/api/ +├── client.ts # Axios 기본 설정 & 인터셉터 +│ +├── 화면 관련 (3개) +│ ├── screen.ts # 화면 CRUD +│ ├── screenGroup.ts # 화면 그룹 +│ └── screenFile.ts # 화면 파일 +│ +├── 사용자 관련 (5개) +│ ├── user.ts # 사용자 관리 +│ ├── role.ts # 역할 관리 +│ ├── company.ts # 회사 관리 +│ └── department.ts # 부서 관리 +│ +├── 테이블 관련 (5개) +│ ├── tableManagement.ts # 테이블 관리 +│ ├── tableSchema.ts # 스키마 조회 +│ ├── tableHistory.ts # 테이블 이력 +│ └── tableCategoryValue.ts # 카테고리 값 +│ +├── 데이터플로우 (6개) +│ ├── dataflow.ts # 데이터플로우 정의 +│ ├── dataflowSave.ts # 저장 로직 +│ ├── nodeFlows.ts # 노드 플로우 +│ ├── nodeExternalConnections.ts # 외부 연결 +│ └── flowExternalDb.ts # 외부 DB 플로우 +│ +├── 자동화 (4개) +│ ├── batch.ts # 배치 작업 +│ ├── batchManagement.ts # 배치 관리 +│ ├── externalCall.ts # 외부 호출 +│ └── externalCallConfig.ts # 외부 호출 설정 +│ +├── 시스템 (8개) +│ ├── menu.ts # 메뉴 관리 +│ ├── commonCode.ts # 공통코드 +│ ├── multilang.ts # 다국어 +│ ├── layout.ts # 레이아웃 +│ ├── collection.ts # 컬렉션 +│ └── ... +│ +└── 기타 (26개) + ├── data.ts # 동적 데이터 CRUD + ├── dynamicForm.ts # 동적 폼 + ├── file.ts # 파일 업로드 + ├── dashboard.ts # 대시보드 + ├── mail.ts # 메일 + ├── reportApi.ts # 리포트 + └── ... +``` + +### 6.2 API 클라이언트 기본 설정 + +**lib/api/client.ts** + +```typescript +// 1. 동적 API URL 설정 +const getApiBaseUrl = (): string => { + // 환경변수 우선 + if (process.env.NEXT_PUBLIC_API_URL) return process.env.NEXT_PUBLIC_API_URL; + + // 프로덕션: v1.vexplor.com → api.vexplor.com + if (currentHost === "v1.vexplor.com") { + return "https://api.vexplor.com/api"; + } + + // 로컬: localhost:9771 → localhost:8080 + return "http://localhost:8080/api"; +}; + +export const API_BASE_URL = getApiBaseUrl(); + +// 2. Axios 인스턴스 생성 +export const apiClient = axios.create({ + baseURL: API_BASE_URL, + timeout: 30000, + headers: { "Content-Type": "application/json" }, + withCredentials: true +}); + +// 3. 요청 인터셉터 (JWT 토큰 자동 추가) +apiClient.interceptors.request.use((config) => { + const token = localStorage.getItem("authToken"); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + + // 다국어: GET 요청에 userLang 파라미터 추가 + if (config.method === "GET") { + config.params = { + ...config.params, + userLang: window.__GLOBAL_USER_LANG || "KR" + }; + } + + return config; +}); + +// 4. 응답 인터셉터 (토큰 갱신, 401 처리) +apiClient.interceptors.response.use( + (response) => { + // 서버에서 새 토큰 전송 시 자동 갱신 + const newToken = response.headers["x-new-token"]; + if (newToken) { + localStorage.setItem("authToken", newToken); + } + return response; + }, + async (error) => { + // 401 에러: 토큰 갱신 시도 → 실패 시 로그인 페이지 + if (error.response?.status === 401) { + const newToken = await refreshToken(); + if (newToken) { + error.config.headers.Authorization = `Bearer ${newToken}`; + return apiClient.request(error.config); // 재시도 + } + window.location.href = "/login"; + } + return Promise.reject(error); + } +); +``` + +### 6.3 API 사용 패턴 + +#### ✅ 올바른 사용법 (lib/api 클라이언트 사용) + +```typescript +import { screenApi } from "@/lib/api/screen"; +import { dataApi } from "@/lib/api/data"; + +// 화면 목록 조회 +const screens = await screenApi.getScreens({ page: 1, size: 20 }); + +// 데이터 생성 +const result = await dataApi.createData("product_info", { + product_name: "제품A", + price: 10000 +}); +``` + +#### ❌ 잘못된 사용법 (fetch 직접 사용 금지!) + +```typescript +// 🚫 금지! JWT 토큰, 다국어, 에러 처리 누락 +const res = await fetch('/api/screen-management/screens'); +``` + +### 6.4 주요 API 예시 + +#### 화면 관리 API (screen.ts) + +```typescript +export const screenApi = { + // 화면 목록 조회 + getScreens: async (params) => { + const response = await apiClient.get("/screen-management/screens", { params }); + return response.data; + }, + + // 화면 상세 조회 + getScreen: async (screenId: number) => { + const response = await apiClient.get(`/screen-management/screens/${screenId}`); + return response.data.data; + }, + + // 화면 생성 + createScreen: async (screen: CreateScreenRequest) => { + const response = await apiClient.post("/screen-management/screens", screen); + return response.data; + }, + + // 화면 수정 + updateScreen: async (screenId: number, screen: UpdateScreenRequest) => { + const response = await apiClient.put(`/screen-management/screens/${screenId}`, screen); + return response.data; + }, + + // 화면 삭제 + deleteScreen: async (screenId: number) => { + const response = await apiClient.delete(`/screen-management/screens/${screenId}`); + return response.data; + } +}; +``` + +#### 동적 데이터 API (data.ts) + +```typescript +export const dataApi = { + // 데이터 목록 조회 (페이징, 필터링, 정렬) + getDataList: async (tableName: string, params: { + page?: number; + size?: number; + filters?: Record; + sortBy?: string; + sortOrder?: "asc" | "desc"; + }) => { + const response = await apiClient.get(`/data/${tableName}`, { params }); + return response.data; + }, + + // 데이터 생성 + createData: async (tableName: string, data: Record) => { + const response = await apiClient.post(`/data/${tableName}`, data); + return response.data; + }, + + // 데이터 수정 + updateData: async (tableName: string, id: number, data: Record) => { + const response = await apiClient.put(`/data/${tableName}/${id}`, data); + return response.data; + }, + + // 데이터 삭제 + deleteData: async (tableName: string, id: number) => { + const response = await apiClient.delete(`/data/${tableName}/${id}`); + return response.data; + } +}; +``` + +--- + +## 7. 상태 관리 + +### 7.1 상태 관리 전략 + +WACE ERP는 **하이브리드 상태 관리 전략**을 사용합니다: + +| 관리 방식 | 사용 시나리오 | 예시 | +|----------|-------------|------| +| **React Query** | 서버 상태 (캐싱, 자동 갱신) | 화면 목록, 데이터 목록 | +| **React Context** | 전역 상태 (공유 데이터) | 인증, 메뉴, 화면 | +| **Zustand** | 클라이언트 상태 (간단한 전역) | 플로우 단계, 모달 데이터 | +| **Local State** | 컴포넌트 로컬 상태 | 폼 입력, UI 토글 | + +### 7.2 React Context (12개) + +``` +contexts/ +├── AuthContext.tsx # 인증 상태 & 세션 관리 +│ - 사용자 정보 +│ - 로그인/로그아웃 +│ - 세션 타이머 (30분) +│ +├── MenuContext.tsx # 메뉴 트리 & 네비게이션 +│ - 메뉴 구조 +│ - 현재 선택된 메뉴 +│ - 메뉴 접근 권한 +│ +├── ScreenContext.tsx # 화면 편집 상태 +│ - 현재 편집 중인 화면 +│ - 선택된 컴포넌트 +│ - 실행 취소/다시 실행 +│ +├── ScreenPreviewContext.tsx # 화면 미리보기 +│ - 반응형 모드 (데스크톱/태블릿/모바일) +│ - 미리보기 데이터 +│ +├── DashboardContext.tsx # 대시보드 상태 +│ - 대시보드 레이아웃 +│ - 위젯 설정 +│ +├── TableOptionsContext.tsx # 테이블 옵션 +│ - 컬럼 순서 +│ - 필터 +│ - 정렬 +│ +├── ActiveTabContext.tsx # 탭 활성화 상태 +├── LayerContext.tsx # 레이어 관리 +├── ReportDesignerContext.tsx # 리포트 디자이너 +├── ScreenMultiLangContext.tsx # 화면 다국어 +├── SplitPanelContext.tsx # 분할 패널 상태 +└── TableSearchWidgetHeightContext.tsx # 테이블 검색 높이 +``` + +### 7.3 React Query 설정 + +**providers/QueryProvider.tsx** + +```typescript +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, // 5분간 fresh + cacheTime: 10 * 60 * 1000, // 10분간 캐시 유지 + refetchOnWindowFocus: false, // 창 포커스 시 자동 갱신 비활성화 + retry: 1, // 실패 시 1회 재시도 + }, + }, +}); + +export function QueryProvider({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +``` + +**사용 예시** + +```typescript +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { screenApi } from "@/lib/api/screen"; + +// 화면 목록 조회 (자동 캐싱) +const { data: screens, isLoading, error } = useQuery({ + queryKey: ["screens", { page: 1 }], + queryFn: () => screenApi.getScreens({ page: 1, size: 20 }) +}); + +// 화면 생성 (생성 후 목록 자동 갱신) +const queryClient = useQueryClient(); +const createMutation = useMutation({ + mutationFn: (screen: CreateScreenRequest) => screenApi.createScreen(screen), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["screens"] }); + } +}); +``` + +### 7.4 Zustand 스토어 (3개) + +``` +stores/ +├── flowStepStore.ts # 플로우 단계 상태 +│ - 현재 단계 +│ - 단계별 데이터 +│ - 진행률 +│ +├── modalDataStore.ts # 모달 데이터 공유 +│ - 모달 ID별 데이터 +│ - 모달 간 데이터 전달 +│ +└── tableDisplayStore.ts # 테이블 표시 상태 + - 선택된 행 + - 정렬 상태 + - 페이지네이션 +``` + +**사용 예시** + +```typescript +import { create } from "zustand"; + +interface ModalDataStore { + modalData: Record; + setModalData: (modalId: string, data: any) => void; + getModalData: (modalId: string) => any; +} + +export const useModalDataStore = create((set, get) => ({ + modalData: {}, + + setModalData: (modalId, data) => { + set((state) => ({ + modalData: { ...state.modalData, [modalId]: data } + })); + }, + + getModalData: (modalId) => { + return get().modalData[modalId]; + } +})); +``` + +--- + +## 8. 레지스트리 시스템 + +레지스트리 시스템은 **컴포넌트를 동적으로 등록하고 렌더링**하는 핵심 아키텍처입니다. + +### 8.1 레지스트리 종류 + +``` +lib/registry/ +├── ComponentRegistry.ts # 컴포넌트 레지스트리 (신규) +│ - 90개+ 컴포넌트 관리 +│ - 자동 등록 시스템 +│ - 핫 리로드 지원 +│ +├── WebTypeRegistry.ts # 웹타입 레지스트리 (레거시) +│ - text, number, date 등 기본 웹타입 +│ - 위젯 컴포넌트 + 설정 패널 +│ +└── LayoutRegistry.ts # 레이아웃 레지스트리 + - grid, tabs, accordion 등 레이아웃 +``` + +### 8.2 ComponentRegistry 상세 + +**등록 프로세스** + +```typescript +// 1. 컴포넌트 정의 (lib/registry/components/v2-input/index.ts) +export const V2InputDefinition: ComponentDefinition = { + id: "v2-input", + name: "통합 입력", + category: ComponentCategory.V2, + component: V2InputRenderer, + configPanel: V2InputConfigPanel, + defaultSize: { width: 200, height: 40 }, + defaultConfig: { inputType: "text", format: "none" } +}; + +// 2. 자동 등록 렌더러 (lib/registry/components/v2-input/V2InputRenderer.tsx) +export class V2InputRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = V2InputDefinition; + + render(): React.ReactElement { + return ; + } +} + +// 자동 등록 실행 +V2InputRenderer.registerSelf(); + +// 3. 레지스트리 조회 & 렌더링 (DynamicComponentRenderer.tsx) +const newComponent = ComponentRegistry.getComponent("v2-input"); +if (newComponent) { + const Renderer = newComponent.component; + return ; +} +``` + +### 8.3 등록된 컴포넌트 목록 (90개+) + +**입력 컴포넌트 (25개)** +- text-input, number-input, date-input +- select-basic, checkbox-basic, radio-basic, toggle-switch +- textarea-basic, slider-basic +- v2-input, v2-select, v2-date + +**표시 컴포넌트 (15개)** +- text-display, image-display, card-display +- table-list, v2-table-list, pivot-grid +- aggregation-widget, v2-aggregation-widget + +**레이아웃 컴포넌트 (10개)** +- grid-layout, flexbox-layout +- tabs-widget, accordion-basic +- split-panel-layout, v2-split-panel-layout +- section-card, section-paper +- v2-divider-line + +**비즈니스 컴포넌트 (20개)** +- entity-search-input, autocomplete-search-input +- modal-repeater-table, repeat-screen-modal +- selected-items-detail-input, simple-repeater-table +- repeater-field-group, repeat-container +- category-manager, v2-category-manager +- numbering-rule, v2-numbering-rule +- rack-structure, v2-rack-structure +- location-swap-selector, customer-item-mapping + +**특수 컴포넌트 (20개)** +- button-primary, v2-button-primary +- file-upload, v2-file-upload +- mail-recipient-selector +- conditional-container, universal-form-modal +- related-data-buttons +- flow-widget +- map + +### 8.4 DynamicComponentRenderer 동작 원리 + +```typescript +// DynamicComponentRenderer.tsx (770줄) + +export const DynamicComponentRenderer: React.FC = ({ component, ...props }) => { + // 1. 컴포넌트 타입 추출 + const componentType = component.componentType || component.type; + + // 2. 레거시 → V2 자동 매핑 + const v2Type = componentType.startsWith("v2-") + ? componentType + : ComponentRegistry.hasComponent(`v2-${componentType}`) + ? `v2-${componentType}` + : componentType; + + // 3. 조건부 렌더링 체크 + if (component.conditionalConfig?.enabled) { + const conditionMet = evaluateCondition(props.formData); + if (!conditionMet) return null; + } + + // 4. 카테고리 타입 우선 처리 + if (component.inputType === "category" || component.webType === "category") { + return ; + } + + // 5. 레이아웃 컴포넌트 + if (componentType === "layout") { + return ; + } + + // 6. ComponentRegistry에서 조회 (신규) + const newComponent = ComponentRegistry.getComponent(v2Type); + if (newComponent) { + const Renderer = newComponent.component; + // 클래스 기반: new Renderer(props).render() + // 함수형: + return isClass(Renderer) + ? new Renderer(props).render() + : ; + } + + // 7. LegacyComponentRegistry에서 조회 (레거시) + const legacyRenderer = legacyComponentRegistry.get(componentType); + if (legacyRenderer) { + return legacyRenderer({ component, ...props }); + } + + // 8. 폴백: 미등록 컴포넌트 플레이스홀더 + return ; +}; +``` + +--- + +## 9. 대시보드 시스템 + +### 9.1 대시보드 구조 + +``` +components/dashboard/ +├── DashboardViewer.tsx # 대시보드 렌더링 +├── DashboardGrid.tsx # 그리드 레이아웃 +├── DashboardWidget.tsx # 위젯 래퍼 +│ +├── widgets/ # 위젯 컴포넌트 +│ ├── ChartWidget.tsx # 차트 위젯 +│ ├── TableWidget.tsx # 테이블 위젯 +│ ├── CardWidget.tsx # 카드 위젯 +│ ├── StatWidget.tsx # 통계 위젯 +│ └── CustomWidget.tsx # 커스텀 위젯 +│ +└── charts/ # 차트 컴포넌트 + ├── LineChart.tsx + ├── BarChart.tsx + ├── PieChart.tsx + ├── DonutChart.tsx + └── AreaChart.tsx +``` + +### 9.2 대시보드 JSON 구조 + +```json +{ + "dashboardId": 1, + "dashboardName": "영업 대시보드", + "layout": { + "type": "grid", + "columns": 12, + "rows": 6, + "gap": 16 + }, + "widgets": [ + { + "widgetId": "widget_1", + "widgetType": "chart", + "chartType": "line", + "title": "월별 매출 추이", + "position": { "x": 0, "y": 0, "w": 6, "h": 3 }, + "dataSource": { + "type": "api", + "endpoint": "/api/data/sales_monthly", + "filters": { "year": 2024 } + }, + "chartConfig": { + "xAxis": "month", + "yAxis": "sales_amount", + "showLegend": true + } + }, + { + "widgetId": "widget_2", + "widgetType": "stat", + "title": "총 매출", + "position": { "x": 6, "y": 0, "w": 3, "h": 2 }, + "dataSource": { + "type": "sql", + "query": "SELECT SUM(amount) FROM sales WHERE year = 2024" + }, + "statConfig": { + "format": "currency", + "comparison": "lastYear" + } + } + ] +} +``` + +### 9.3 대시보드 라우팅 + +``` +/dashboard # 대시보드 목록 +/dashboard/[dashboardId] # 대시보드 보기 +/admin/screenMng/dashboardList # 대시보드 관리 (편집) +``` + +--- + +## 10. 다국어 지원 + +### 10.1 다국어 시스템 구조 + +WACE ERP는 **동적 다국어 시스템**을 제공합니다 (DB 기반): + +``` +다국어 흐름 + ↓ +1. 사용자 로그인 + ↓ +2. 사용자 로케일 조회 (GET /api/admin/user-locale) + → 결과: "KR" | "EN" | "CN" + ↓ +3. 전역 상태에 저장 + - window.__GLOBAL_USER_LANG + - localStorage.userLocale + ↓ +4. API 요청 시 자동 주입 + - GET 요청: ?userLang=KR (apiClient 인터셉터) + ↓ +5. 백엔드에서 다국어 데이터 반환 + - label_KR, label_EN, label_CN + ↓ +6. 프론트엔드에서 표시 + - extractMultilangLabel(label, "KR") +``` + +### 10.2 다국어 API + +**lib/api/multilang.ts** + +```typescript +export const multilangApi = { + // 다국어 데이터 조회 + getMultilangData: async (params: { + target_table: string; + target_pk: string; + target_lang: string; + }) => { + const response = await apiClient.get("/admin/multilang", { params }); + return response.data; + }, + + // 다국어 데이터 저장 + saveMultilangData: async (data: { + target_table: string; + target_pk: string; + target_field: string; + target_lang: string; + translated_text: string; + }) => { + const response = await apiClient.post("/admin/multilang", data); + return response.data; + } +}; +``` + +### 10.3 다국어 유틸리티 + +**lib/utils/multilang.ts** + +```typescript +// 다국어 라벨 추출 +export function extractMultilangLabel( + label: string | Record | undefined, + locale: string = "KR" +): string { + if (!label) return ""; + + // 문자열인 경우 그대로 반환 + if (typeof label === "string") return label; + + // 객체인 경우 로케일에 맞는 값 반환 + return label[locale] || label["KR"] || label["EN"] || ""; +} + +// 화면 컴포넌트 라벨 추출 +export function getComponentLabel(component: ComponentData, locale: string): string { + // 우선순위: multiLangLabel > label > columnName > id + if (component.multiLangLabel) { + return extractMultilangLabel(component.multiLangLabel, locale); + } + return component.label || component.columnName || component.id; +} +``` + +### 10.4 화면 디자이너 다국어 탭 + +``` +ScreenDesigner + ↓ +┌───────────────────────────────────────────────────┐ +│ 탭: 다국어 설정 │ +├───────────────────────────────────────────────────┤ +│ │ +│ 화면명 다국어 │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ 한국어(KR): 제품 관리 │ │ +│ │ 영어(EN): Product Management │ │ +│ │ 중국어(CN): 产品管理 │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ 컴포넌트별 다국어 │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ [comp_text_1] 제품명 │ │ +│ │ 한국어(KR): 제품명 │ │ +│ │ 영어(EN): Product Name │ │ +│ │ 중국어(CN): 产品名称 │ │ +│ │ │ │ +│ │ [comp_text_2] 가격 │ │ +│ │ 한국어(KR): 가격 │ │ +│ │ 영어(EN): Price │ │ +│ │ 중국어(CN): 价格 │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ [자동 번역] [일괄 적용] [저장] │ +└───────────────────────────────────────────────────┘ +``` + +--- + +## 11. 인증 플로우 + +### 11.1 인증 아키텍처 + +``` +┌─────────────────────────────────────────────────────┐ +│ Frontend │ +├─────────────────────────────────────────────────────┤ +│ │ +│ useAuth Hook │ +│ ├─ login(userId, password) │ +│ ├─ logout() │ +│ ├─ refreshUserData() │ +│ ├─ checkAuthStatus() │ +│ └─ switchCompany(companyCode) ⭐ 신규 │ +│ │ +│ AuthContext Provider │ +│ ├─ SessionManager (30분 타임아웃) │ +│ ├─ Session Warning (5분 전 알림) │ +│ └─ Auto Refresh (활동 감지) │ +│ │ +│ TokenManager │ +│ ├─ getToken(): localStorage.authToken │ +│ ├─ setToken(token): 저장 + 쿠키 설정 │ +│ ├─ removeToken(): 삭제 + 쿠키 삭제 │ +│ └─ isTokenExpired(token): JWT 검증 │ +│ │ +│ API Client Interceptor │ +│ ├─ Request: JWT 토큰 자동 추가 │ +│ └─ Response: 401 시 토큰 갱신 또는 로그아웃 │ +│ │ +└─────────────────────────────────────────────────────┘ + │ + │ HTTP Request + │ Authorization: Bearer + ↓ +┌─────────────────────────────────────────────────────┐ +│ Backend (8080) │ +├─────────────────────────────────────────────────────┤ +│ │ +│ POST /api/auth/login │ +│ → JWT 토큰 발급 (24시간) │ +│ │ +│ POST /api/auth/refresh │ +│ → JWT 토큰 갱신 │ +│ │ +│ POST /api/auth/logout │ +│ → 세션 무효화 │ +│ │ +│ GET /api/auth/me │ +│ → 현재 사용자 정보 │ +│ │ +│ GET /api/auth/status │ +│ → 인증 상태 확인 │ +│ │ +│ POST /api/auth/switch-company ⭐ 신규 │ +│ → 회사 전환 (WACE 관리자 전용) │ +│ │ +└─────────────────────────────────────────────────────┘ +``` + +### 11.2 로그인 흐름 + +``` +1. 사용자가 ID/PW 입력 → /login 페이지 + ↓ +2. POST /api/auth/login { userId, password } + ↓ +3. 백엔드 검증 (DB: user_info 테이블) + ├─ 성공: JWT 토큰 발급 (payload: userId, companyCode, isAdmin, exp) + └─ 실패: 401 에러 + ↓ +4. 프론트엔드: 토큰 저장 + - localStorage.setItem("authToken", token) + - document.cookie = "authToken=..." + ↓ +5. 사용자 정보 조회 + - GET /api/auth/me + - GET /api/admin/user-locale + ↓ +6. 전역 상태 업데이트 + - AuthContext.user + - window.__GLOBAL_USER_LANG + ↓ +7. 메인 페이지로 리다이렉트 (/main) +``` + +### 11.3 세션 관리 + +**SessionManager (lib/sessionManager.ts)** + +```typescript +// 설정 +{ + checkInterval: 60000, // 1분마다 체크 + maxInactiveTime: 1800000, // 30분 (데스크톱) + warningTime: 300000, // 5분 전 경고 +} + +// 이벤트 +{ + onWarning: (remainingTime) => { + // "세션이 5분 후 만료됩니다" 알림 표시 + }, + onExpiry: () => { + // 자동 로그아웃 → 로그인 페이지 + }, + onActivity: () => { + // 사용자 활동 감지 → 타이머 리셋 + } +} +``` + +### 11.4 토큰 갱신 전략 + +``` +자동 토큰 갱신 트리거: +1. 10분마다 토큰 상태 확인 (타이머) +2. 사용자 활동 감지 (클릭, 키보드, 스크롤) +3. API 401 응답 (토큰 만료) + +갱신 로직: + ↓ +1. 현재 토큰 만료까지 30분 미만? + ↓ Yes +2. POST /api/auth/refresh + ├─ 성공: 새 토큰 저장 + └─ 실패: 로그아웃 +``` + +### 11.5 회사 전환 (WACE 관리자 전용) ⭐ + +``` +1. WACE 관리자 로그인 + ↓ +2. 헤더에서 회사 선택 (CompanySwitcher) + ↓ +3. POST /api/auth/switch-company { companyCode: "AAA" } + ↓ +4. 백엔드: 새 JWT 발급 (payload.companyCode = "AAA") + ↓ +5. 프론트엔드: 새 토큰 저장 + ↓ +6. 페이지 새로고침 → 화면/데이터가 AAA 회사 기준으로 표시 +``` + +--- + +## 12. 사용자 워크플로우 + +### 12.1 전체 워크플로우 개요 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ 관리자 (화면 생성) │ +└──────────────────────────────────────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 1. 로그인 (WACE 관리자) │ + └─────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 2. 화면 디자이너 접속 │ + │ /admin/screenMng/ │ + │ screenMngList │ + └─────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 3. 화면 생성 & 편집 │ + │ - 컴포넌트 배치 │ + │ - 데이터 연결 │ + │ - 버튼 액션 설정 │ + │ - 다국어 설정 │ + └─────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 4. 메뉴에 화면 할당 │ + │ /admin/menu │ + └─────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 5. 사용자에게 권한 부여 │ + │ /admin/userMng/ │ + │ userAuthList │ + └─────────────────────────────┘ + │ + ↓ +┌──────────────────────────────────────────────────────────────┐ +│ 사용자 (화면 사용) │ +└──────────────────────────────────────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 1. 로그인 (일반 사용자) │ + └─────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 2. 메뉴에서 화면 선택 │ + │ 사이드바 → 제품 관리 │ + └─────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 3. 화면 렌더링 │ + │ /screens/[screenId] │ + │ InteractiveScreenViewer │ + └─────────────────────────────┘ + │ + ↓ + ┌─────────────────────────────┐ + │ 4. 데이터 조회/등록/수정 │ + │ - 테이블에서 행 선택 │ + │ - 폼에 데이터 입력 │ + │ - [등록]/[수정]/[삭제] 버튼│ + └─────────────────────────────┘ +``` + +### 12.2 관리자 워크플로우 (화면 생성) + +#### 단계 1: 화면 디자이너 접속 + +``` +/admin/screenMng/screenMngList + ↓ +[새 화면 만들기] 버튼 클릭 + ↓ +ScreenDesigner 컴포넌트 로드 +``` + +#### 단계 2: 화면 디자인 + +**2-1. 기본 정보 설정** + +``` +화면 정보 입력: +- 화면 코드: PRODUCT_MGMT +- 화면명: 제품 관리 +- 테이블명: product_info +- 화면 타입: form (단일 폼) | list (목록) +``` + +**2-2. 컴포넌트 배치** + +``` +좌측 팔레트에서 컴포넌트 선택 + ↓ +캔버스에 드래그&드롭 + ↓ +위치/크기 조정 (10px 단위 스냅) + ↓ +우측 속성 패널에서 설정: + - 컬럼명: product_name + - 라벨: 제품명 + - 필수: ☑ + - 웹타입: text +``` + +**2-3. 버튼 액션 설정** + +``` +버튼 컴포넌트 추가 + ↓ +액션 타입 선택: + - 데이터 저장 (POST) + - 데이터 수정 (PUT) + - 데이터 삭제 (DELETE) + - 데이터플로우 실행 + - 외부 API 호출 + - 화면 이동 + ↓ +액션 설정: + - 대상 테이블: product_info + - 성공 시: 목록 새로고침 + - 실패 시: 에러 메시지 표시 +``` + +**2-4. 다국어 설정** + +``` +다국어 탭 클릭 + ↓ +컴포넌트별 다국어 입력: + - 한국어(KR): 제품명 + - 영어(EN): Product Name + - 중국어(CN): 产品名称 +``` + +**2-5. 저장** + +``` +[저장] 버튼 클릭 + ↓ +POST /api/screen-management/screens + ↓ +화면 정의 JSON 저장 (DB: screen_definition) +``` + +#### 단계 3: 메뉴에 화면 할당 + +``` +/admin/menu + ↓ +메뉴 트리에서 위치 선택 + ↓ +[화면 할당] 버튼 클릭 + ↓ +방금 생성한 화면 선택 (PRODUCT_MGMT) + ↓ +저장 → menu_screen 테이블에 연결 +``` + +#### 단계 4: 권한 부여 + +``` +/admin/userMng/userAuthList + ↓ +사용자 선택 + ↓ +메뉴 권한 설정 + ↓ +"제품 관리" 메뉴 체크 + ↓ +저장 → user_menu_auth 테이블 +``` + +### 12.3 사용자 워크플로우 (화면 사용) + +#### 단계 1: 로그인 + +``` +/login + ↓ +사용자 ID/PW 입력 + ↓ +POST /api/auth/login + ↓ +JWT 토큰 발급 + ↓ +메인 페이지 (/main) +``` + +#### 단계 2: 메뉴 선택 + +``` +좌측 사이드바 메뉴 트리 + ↓ +"제품 관리" 메뉴 클릭 + ↓ +MenuContext에서 메뉴 정보 조회: + - menuId, screenId, screenCode + ↓ +화면 뷰어로 이동 (/screens/[screenId]) +``` + +#### 단계 3: 화면 렌더링 + +``` +InteractiveScreenViewer 컴포넌트 로드 + ↓ +1. GET /api/screen-management/screens/[screenId] + → 화면 정의 JSON 조회 + ↓ +2. GET /api/data/product_info?page=1&size=20 + → 데이터 조회 + ↓ +3. DynamicComponentRenderer로 컴포넌트 렌더링 + ↓ +4. 폼 데이터 바인딩 (formData 상태) +``` + +#### 단계 4: 데이터 조작 + +**4-1. 신규 등록** + +``` +[등록] 버튼 클릭 + ↓ +빈 폼 표시 (ScreenModal 또는 EditModal) + ↓ +사용자가 데이터 입력: + - 제품명: "제품A" + - 가격: 10000 + ↓ +[저장] 버튼 클릭 + ↓ +버튼 액션 실행: + - POST /api/data/product_info + - Body: { product_name: "제품A", price: 10000 } + ↓ +성공 응답 + ↓ +목록 새로고침 (React Query invalidateQueries) +``` + +**4-2. 수정** + +``` +테이블에서 행 선택 (클릭) + ↓ +선택된 데이터 → selectedRowsData 상태 업데이트 + ↓ +[수정] 버튼 클릭 + ↓ +폼에 기존 데이터 표시 (EditModal) + ↓ +사용자가 데이터 수정: + - 가격: 10000 → 12000 + ↓ +[저장] 버튼 클릭 + ↓ +버튼 액션 실행: + - PUT /api/data/product_info/123 + - Body: { price: 12000 } + ↓ +목록 새로고침 +``` + +**4-3. 삭제** + +``` +테이블에서 행 선택 + ↓ +[삭제] 버튼 클릭 + ↓ +확인 다이얼로그 표시 + ↓ +확인 클릭 + ↓ +버튼 액션 실행: + - DELETE /api/data/product_info/123 + ↓ +목록 새로고침 +``` + +### 12.4 데이터플로우 실행 워크플로우 + +``` +1. 관리자가 데이터플로우 정의 + /admin/systemMng/dataflow + ↓ +2. 화면에 버튼 추가 & 플로우 연결 + 버튼 액션: "데이터플로우 실행" + 플로우 ID: flow_123 + ↓ +3. 사용자가 버튼 클릭 + ↓ +4. 프론트엔드: POST /api/dataflow/execute + Body: { flowId: 123, inputData: {...} } + ↓ +5. 백엔드: 플로우 실행 + - 노드 순회 + - 조건 분기 + - 외부 API 호출 + - 데이터 변환 + ↓ +6. 결과 반환 + ↓ +7. 프론트엔드: 결과 표시 (toast 또는 화면 갱신) +``` + +--- + +## 13. 요약 & 핵심 포인트 + +### 13.1 아키텍처 핵심 + +1. **Next.js 14 App Router** - 라우트 그룹 기반 구조화 +2. **컴포넌트 레지스트리** - 동적 등록/렌더링 시스템 +3. **V2 통합 시스템** - 9개 통합 컴포넌트로 단순화 +4. **화면 디자이너** - 노코드 화면 생성 도구 +5. **API 클라이언트** - Axios 기반 통일된 API 호출 +6. **다국어 지원** - DB 기반 동적 다국어 +7. **JWT 인증** - 토큰 기반 인증/세션 관리 + +### 13.2 파일 통계 + +| 항목 | 개수 | +|------|------| +| 총 파일 수 | 1,480개 | +| TypeScript/TSX | 1,395개 (946 tsx + 449 ts) | +| Markdown 문서 | 63개 | +| CSS | 22개 | +| 페이지 (라우트) | 76개 | +| 컴포넌트 | 500개+ | +| API 클라이언트 | 57개 | +| Context | 12개 | +| Custom Hooks | 32개 | +| 타입 정의 | 44개 | + +### 13.3 주요 경로 + +``` +화면 디자이너: /admin/screenMng/screenMngList +화면 뷰어: /screens/[screenId] +메뉴 관리: /admin/menu +사용자 관리: /admin/userMng/userMngList +테이블 관리: /admin/systemMng/tableMngList +다국어 관리: /admin/systemMng/i18nList +데이터플로우: /admin/systemMng/dataflow +대시보드: /dashboard/[dashboardId] +``` + +### 13.4 기술 스택 + +| 분류 | 기술 | +|------|------| +| 프레임워크 | Next.js 14 | +| UI 라이브러리 | React 18 | +| 언어 | TypeScript (strict mode) | +| 스타일링 | Tailwind CSS + shadcn/ui | +| 상태 관리 | React Query + Zustand + Context API | +| HTTP 클라이언트 | Axios | +| 폼 검증 | Zod | +| 날짜 처리 | date-fns | +| 아이콘 | lucide-react | +| 알림 | sonner | + +--- + +## 마무리 + +이 문서는 WACE ERP 프론트엔드의 전체 아키텍처를 분석한 결과입니다. + +**핵심 인사이트:** +- ✅ 높은 수준의 모듈화 (레지스트리 시스템) +- ✅ 노코드 화면 디자이너 (관리자가 직접 화면 생성) +- ✅ V2 통합 컴포넌트 시스템 (개발 효율성 향상) +- ✅ 동적 다국어 지원 (DB 기반) +- ✅ 완전한 TypeScript 타입 안정성 + +**개선 기회:** +- 일부 레거시 컴포넌트의 V2 마이그레이션 +- 테스트 코드 추가 (현재 거의 없음) +- 성능 최적화 (코드 스플리팅, 레이지 로딩) + +--- + +**작성자 노트** + +야... 진짜 엄청난 프로젝트네. 파일이 1,500개가 넘고 컴포넌트만 500개야. 특히 화면 디자이너가 7,000줄이 넘는 거 보고 놀랐어. 이 정도 규모면 엔터프라이즈급 ERP 시스템이라고 봐도 되겠어. + +가장 인상적이었던 건 **레지스트리 시스템**이랑 **V2 통합 아키텍처**야. 컴포넌트를 동적으로 등록하고 렌더링하는 구조가 꽤 잘 설계되어 있어. 다만 레거시 코드가 아직 많이 남아있어서 V2로 완전히 전환하면 코드 베이스가 훨씬 깔끔해질 것 같아. + +어쨌든 분석하느라 꽤 걸렸는데, 이 문서가 전체 워크플로우 문서 작성하는 데 도움이 되면 좋겠어! 🎉 diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 17c52897..cf89df73 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useMemo, useCallback } from "react"; +import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -19,6 +19,7 @@ import { Copy, Check, ChevronsUpDown, + Loader2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; @@ -140,11 +141,22 @@ export default function TableManagementPage() { const [logViewerOpen, setLogViewerOpen] = useState(false); const [logViewerTableName, setLogViewerTableName] = useState(""); + // 저장 중 상태 (중복 실행 방지) + const [isSaving, setIsSaving] = useState(false); + // 테이블 삭제 확인 다이얼로그 상태 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [tableToDelete, setTableToDelete] = useState(""); const [isDeleting, setIsDeleting] = useState(false); + // PK/인덱스 관리 상태 + const [constraints, setConstraints] = useState<{ + primaryKey: { name: string; columns: string[] }; + indexes: Array<{ name: string; columns: string[]; isUnique: boolean }>; + }>({ primaryKey: { name: "", columns: [] }, indexes: [] }); + const [pkDialogOpen, setPkDialogOpen] = useState(false); + const [pendingPkColumns, setPendingPkColumns] = useState([]); + // 선택된 테이블 목록 (체크박스) const [selectedTableIds, setSelectedTableIds] = useState>(new Set()); @@ -397,6 +409,19 @@ export default function TableManagementPage() { } }, []); + // PK/인덱스 제약조건 로드 + const loadConstraints = useCallback(async (tableName: string) => { + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/constraints`); + if (response.data.success) { + setConstraints(response.data.data); + } + } catch (error) { + console.error("제약조건 로드 실패:", error); + setConstraints({ primaryKey: { name: "", columns: [] }, indexes: [] }); + } + }, []); + // 테이블 선택 const handleTableSelect = useCallback( (tableName: string) => { @@ -410,8 +435,9 @@ export default function TableManagementPage() { setTableDescription(tableInfo?.description || ""); loadColumnTypes(tableName, 1, pageSize); + loadConstraints(tableName); }, - [loadColumnTypes, pageSize, tables], + [loadColumnTypes, loadConstraints, pageSize, tables], ); // 입력 타입 변경 @@ -757,7 +783,9 @@ export default function TableManagementPage() { // 전체 저장 (테이블 라벨 + 모든 컬럼 설정) const saveAllSettings = async () => { if (!selectedTable) return; + if (isSaving) return; // 저장 중 중복 실행 방지 + setIsSaving(true); try { // 1. 테이블 라벨 저장 (변경된 경우에만) if (tableLabel !== selectedTable || tableDescription) { @@ -952,9 +980,30 @@ export default function TableManagementPage() { } catch (error) { // console.error("설정 저장 실패:", error); toast.error("설정 저장 중 오류가 발생했습니다."); + } finally { + setIsSaving(false); } }; + // Ctrl+S 단축키: 테이블 설정 전체 저장 + // saveAllSettings를 ref로 참조하여 useEffect 의존성 문제 방지 + const saveAllSettingsRef = useRef(saveAllSettings); + saveAllSettingsRef.current = saveAllSettings; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s") { + e.preventDefault(); // 브라우저 기본 저장 동작 방지 + if (selectedTable && columns.length > 0) { + saveAllSettingsRef.current(); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [selectedTable, columns.length]); + // 필터링된 테이블 목록 (메모이제이션) const filteredTables = useMemo( () => @@ -1000,6 +1049,123 @@ export default function TableManagementPage() { } }, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]); + // PK 체크박스 변경 핸들러 + const handlePkToggle = useCallback( + (columnName: string, checked: boolean) => { + const currentPkCols = [...constraints.primaryKey.columns]; + let newPkCols: string[]; + if (checked) { + newPkCols = [...currentPkCols, columnName]; + } else { + newPkCols = currentPkCols.filter((c) => c !== columnName); + } + // PK 변경은 확인 다이얼로그 표시 + setPendingPkColumns(newPkCols); + setPkDialogOpen(true); + }, + [constraints.primaryKey.columns], + ); + + // PK 변경 확인 + const handlePkConfirm = async () => { + if (!selectedTable) return; + try { + if (pendingPkColumns.length === 0) { + toast.error("PK 컬럼을 최소 1개 이상 선택해야 합니다."); + setPkDialogOpen(false); + return; + } + const response = await apiClient.put(`/table-management/tables/${selectedTable}/primary-key`, { + columns: pendingPkColumns, + }); + if (response.data.success) { + toast.success(response.data.message); + await loadConstraints(selectedTable); + } else { + toast.error(response.data.message || "PK 설정 실패"); + } + } catch (error: any) { + toast.error(error?.response?.data?.message || "PK 설정 중 오류가 발생했습니다."); + } finally { + setPkDialogOpen(false); + } + }; + + // 인덱스 토글 핸들러 + const handleIndexToggle = useCallback( + async (columnName: string, indexType: "index" | "unique", checked: boolean) => { + if (!selectedTable) return; + const action = checked ? "create" : "drop"; + try { + const response = await apiClient.post(`/table-management/tables/${selectedTable}/indexes`, { + columnName, + indexType, + action, + }); + if (response.data.success) { + toast.success(response.data.message); + await loadConstraints(selectedTable); + } else { + toast.error(response.data.message || "인덱스 설정 실패"); + } + } catch (error: any) { + toast.error(error?.response?.data?.message || error?.response?.data?.error || "인덱스 설정 중 오류가 발생했습니다."); + } + }, + [selectedTable, loadConstraints], + ); + + // 컬럼별 인덱스 상태 헬퍼 + const getColumnIndexState = useCallback( + (columnName: string) => { + const isPk = constraints.primaryKey.columns.includes(columnName); + const hasIndex = constraints.indexes.some( + (idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, + ); + const hasUnique = constraints.indexes.some( + (idx) => idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, + ); + return { isPk, hasIndex, hasUnique }; + }, + [constraints], + ); + + // NOT NULL 토글 핸들러 + const handleNullableToggle = useCallback( + async (columnName: string, currentIsNullable: string) => { + if (!selectedTable) return; + // isNullable이 "YES"면 nullable, "NO"면 NOT NULL + // 체크박스 체크 = NOT NULL 설정 (nullable: false) + // 체크박스 해제 = NOT NULL 해제 (nullable: true) + const isCurrentlyNotNull = currentIsNullable === "NO"; + const newNullable = isCurrentlyNotNull; // NOT NULL이면 해제, NULL이면 설정 + try { + const response = await apiClient.put( + `/table-management/tables/${selectedTable}/columns/${columnName}/nullable`, + { nullable: newNullable }, + ); + if (response.data.success) { + toast.success(response.data.message); + // 컬럼 상태 로컬 업데이트 + setColumns((prev) => + prev.map((col) => + col.columnName === columnName + ? { ...col, isNullable: newNullable ? "YES" : "NO" } + : col, + ), + ); + } else { + toast.error(response.data.message || "NOT NULL 설정 실패"); + } + } catch (error: any) { + toast.error( + error?.response?.data?.message || "NOT NULL 설정 중 오류가 발생했습니다.", + ); + } + }, + [selectedTable], + ); + // 테이블 삭제 확인 const handleDeleteTableClick = (tableName: string) => { setTableToDelete(tableName); @@ -1367,11 +1533,15 @@ export default function TableManagementPage() { {/* 저장 버튼 (항상 보이도록 상단에 배치) */} @@ -1391,12 +1561,16 @@ export default function TableManagementPage() { {/* 컬럼 헤더 (고정) */}
-
컬럼명
-
라벨
+
라벨
+
컬럼명
입력 타입
설명
+
Primary
+
NotNull
+
Index
+
Unique
{/* 컬럼 리스트 (스크롤 영역) */} @@ -1410,16 +1584,15 @@ export default function TableManagementPage() { } }} > - {columns.map((column, index) => ( + {columns.map((column, index) => { + const idxState = getColumnIndexState(column.columnName); + return (
-
-
{column.columnName}
-
-
+
handleLabelChange(column.columnName, e.target.value)} @@ -1427,6 +1600,9 @@ export default function TableManagementPage() { className="h-8 text-xs" />
+
+
{column.columnName}
+
{/* 입력 타입 선택 */} @@ -1689,141 +1865,11 @@ export default function TableManagementPage() {
)} - {/* 표시 컬럼 - 검색 가능한 Combobox */} - {column.referenceTable && - column.referenceTable !== "none" && - column.referenceColumn && - column.referenceColumn !== "none" && ( -
- - - setEntityComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: { - ...prev[column.columnName], - displayColumn: open, - }, - })) - } - > - - - - - - - - - 컬럼을 찾을 수 없습니다. - - - { - handleDetailSettingsChange( - column.columnName, - "entity_display_column", - "none", - ); - setEntityComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: { - ...prev[column.columnName], - displayColumn: false, - }, - })); - }} - className="text-xs" - > - - -- 선택 안함 -- - - {referenceTableColumns[column.referenceTable]?.map((refCol) => ( - { - handleDetailSettingsChange( - column.columnName, - "entity_display_column", - refCol.columnName, - ); - setEntityComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: { - ...prev[column.columnName], - displayColumn: false, - }, - })); - }} - className="text-xs" - > - -
- {refCol.columnName} - {refCol.columnLabel && ( - - {refCol.columnLabel} - - )} -
-
- ))} -
-
-
-
-
-
- )} - {/* 설정 완료 표시 */} {column.referenceTable && column.referenceTable !== "none" && column.referenceColumn && - column.referenceColumn !== "none" && - column.displayColumn && - column.displayColumn !== "none" && ( + column.referenceColumn !== "none" && (
설정 완료 @@ -1953,8 +1999,49 @@ export default function TableManagementPage() { className="h-8 w-full text-xs" />
+ {/* PK 체크박스 */} +
+ + handlePkToggle(column.columnName, checked as boolean) + } + aria-label={`${column.columnName} PK 설정`} + /> +
+ {/* NN (NOT NULL) 체크박스 */} +
+ + handleNullableToggle(column.columnName, column.isNullable) + } + aria-label={`${column.columnName} NOT NULL 설정`} + /> +
+ {/* IDX 체크박스 */} +
+ + handleIndexToggle(column.columnName, "index", checked as boolean) + } + aria-label={`${column.columnName} 인덱스 설정`} + /> +
+ {/* UQ 체크박스 */} +
+ + handleIndexToggle(column.columnName, "unique", checked as boolean) + } + aria-label={`${column.columnName} 유니크 설정`} + /> +
- ))} + ); + })} {/* 로딩 표시 */} {columnsLoading && ( @@ -2120,6 +2207,52 @@ export default function TableManagementPage() { )} + {/* PK 변경 확인 다이얼로그 */} + + + + PK 변경 확인 + + PK를 변경하면 기존 제약조건이 삭제되고 새로 생성됩니다. +
데이터 무결성에 영향을 줄 수 있습니다. +
+
+ +
+
+

변경될 PK 컬럼:

+ {pendingPkColumns.length > 0 ? ( +
+ {pendingPkColumns.map((col) => ( + + {col} + + ))} +
+ ) : ( +

PK가 모두 제거됩니다

+ )} +
+
+ + + + + +
+
+ {/* Scroll to Top 버튼 */}
diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 9f043adf..95305aaf 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Loader2 } from "lucide-react"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition, LayoutData, ComponentData } from "@/types/screen"; +import { LayerDefinition } from "@/types/screen-management"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { initializeComponents } from "@/lib/registry/components"; @@ -86,6 +87,11 @@ function ScreenViewPage() { // 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이) const [conditionalContainerHeights, setConditionalContainerHeights] = useState>({}); + // 🆕 레이어 시스템 지원 + const [conditionalLayers, setConditionalLayers] = useState([]); + // 🆕 조건부 영역(Zone) 목록 + const [zones, setZones] = useState([]); + // 편집 모달 상태 const [editModalOpen, setEditModalOpen] = useState(false); const [editModalConfig, setEditModalConfig] = useState<{ @@ -204,6 +210,177 @@ function ScreenViewPage() { } }, [screenId]); + // 🆕 조건부 레이어 + Zone 로드 + useEffect(() => { + const loadConditionalLayersAndZones = async () => { + if (!screenId || !layout) return; + + try { + // 1. Zone 로드 + const loadedZones = await screenApi.getScreenZones(screenId); + setZones(loadedZones); + + // 2. 모든 레이어 목록 조회 + const allLayers = await screenApi.getScreenLayers(screenId); + const nonBaseLayers = allLayers.filter((l: any) => l.layer_id > 1); + + if (nonBaseLayers.length === 0) { + setConditionalLayers([]); + return; + } + + // 3. 각 레이어의 레이아웃 데이터 로드 + const layerDefinitions: LayerDefinition[] = []; + + for (const layerInfo of nonBaseLayers) { + try { + const layerData = await screenApi.getLayerLayout(screenId, layerInfo.layer_id); + const condConfig = layerInfo.condition_config || layerData?.conditionConfig || {}; + + // 레이어 컴포넌트 변환 (V2 → Legacy) + let layerComponents: any[] = []; + const rawComponents = layerData?.components; + if (rawComponents && Array.isArray(rawComponents) && rawComponents.length > 0) { + const tempV2 = { + version: "2.0" as const, + components: rawComponents, + gridSettings: layerData.gridSettings, + screenResolution: layerData.screenResolution, + }; + if (isValidV2Layout(tempV2)) { + const converted = convertV2ToLegacy(tempV2); + if (converted) { + layerComponents = converted.components || []; + } + } + } + + // Zone 기반 condition_config 처리 + const zoneId = condConfig.zone_id; + const conditionValue = condConfig.condition_value; + const zone = zoneId ? loadedZones.find((z: any) => z.zone_id === zoneId) : null; + + // LayerDefinition 생성 + const layerDef: LayerDefinition = { + id: String(layerInfo.layer_id), + name: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`, + type: "conditional", + zIndex: layerInfo.layer_id * 10, + isVisible: false, + isLocked: false, + // Zone 기반 조건 (Zone에서 트리거 정보를 가져옴) + condition: zone ? { + targetComponentId: zone.trigger_component_id || "", + operator: (zone.trigger_operator as "eq" | "neq" | "in") || "eq", + value: conditionValue, + } : condConfig.targetComponentId ? { + targetComponentId: condConfig.targetComponentId, + operator: condConfig.operator || "eq", + value: condConfig.value, + } : undefined, + // Zone 기반: displayRegion은 Zone에서 가져옴 + zoneId: zoneId || undefined, + conditionValue: conditionValue || undefined, + displayRegion: zone ? { x: zone.x, y: zone.y, width: zone.width, height: zone.height } : condConfig.displayRegion || undefined, + components: layerComponents, + }; + + layerDefinitions.push(layerDef); + } catch (layerError) { + console.warn(`레이어 ${layerInfo.layer_id} 로드 실패:`, layerError); + } + } + + console.log("🔄 조건부 레이어 로드 완료:", layerDefinitions.length, "개", layerDefinitions.map(l => ({ + id: l.id, name: l.name, zoneId: l.zoneId, conditionValue: l.conditionValue, + componentCount: l.components.length, + condition: l.condition ? { + targetComponentId: l.condition.targetComponentId, + operator: l.condition.operator, + value: l.condition.value, + } : "없음", + }))); + console.log("🗺️ Zone 정보:", loadedZones.map(z => ({ + zone_id: z.zone_id, + trigger_component_id: z.trigger_component_id, + trigger_operator: z.trigger_operator, + }))); + setConditionalLayers(layerDefinitions); + } catch (error) { + console.error("레이어/Zone 로드 실패:", error); + } + }; + + loadConditionalLayersAndZones(); + }, [screenId, layout]); + + // 🆕 조건부 레이어 조건 평가 (formData 변경 시 동기적으로 즉시 계산) + const activeLayerIds = useMemo(() => { + if (conditionalLayers.length === 0 || !layout) return [] as string[]; + + const allComponents = layout.components || []; + const newActiveIds: string[] = []; + + conditionalLayers.forEach((layer) => { + if (layer.condition) { + const { targetComponentId, operator, value } = layer.condition; + + // 빈 targetComponentId는 무시 + if (!targetComponentId) return; + + // 트리거 컴포넌트 찾기 (기본 레이어에서) + const targetComponent = allComponents.find((c) => c.id === targetComponentId); + + // columnName으로 formData에서 값 조회 + const fieldKey = + (targetComponent as any)?.columnName || + (targetComponent as any)?.componentConfig?.columnName || + targetComponentId; + + const targetValue = formData[fieldKey]; + + let isMatch = false; + switch (operator) { + case "eq": + // 문자열로 변환하여 비교 (타입 불일치 방지) + isMatch = String(targetValue ?? "") === String(value ?? ""); + break; + case "neq": + isMatch = String(targetValue ?? "") !== String(value ?? ""); + break; + case "in": + if (Array.isArray(value)) { + isMatch = value.some(v => String(v) === String(targetValue ?? "")); + } else if (typeof value === "string" && value.includes(",")) { + // 쉼표로 구분된 문자열도 지원 + isMatch = value.split(",").map(v => v.trim()).includes(String(targetValue ?? "")); + } + break; + } + + // 디버그 로깅 (값이 존재할 때만) + if (targetValue !== undefined && targetValue !== "") { + console.log("🔍 [레이어 조건 평가]", { + layerId: layer.id, + layerName: layer.name, + targetComponentId, + fieldKey, + targetValue: String(targetValue), + conditionValue: String(value), + operator, + isMatch, + }); + } + + if (isMatch) { + newActiveIds.push(layer.id); + } + } + }); + + return newActiveIds; + }, [formData, conditionalLayers, layout]); + // 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼) // 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움 useEffect(() => { @@ -513,6 +690,7 @@ function ScreenViewPage() { {layoutReady && layout && layout.components.length > 0 ? (
0) { + // 🆕 Zone 기반 높이 조정 + // Zone 단위로 활성 여부를 판단하여 Y 오프셋 계산 + // Zone은 겹치지 않으므로 merge 로직이 불필요 (단순 boolean 판단) + for (const zone of zones) { + const zoneBottom = zone.y + zone.height; + // 컴포넌트가 Zone 하단보다 아래에 있는 경우 + if (component.position.y >= zoneBottom) { + // Zone에 매칭되는 활성 레이어가 있는지 확인 + const hasActiveLayer = conditionalLayers.some( + l => l.zoneId === zone.zone_id && activeLayerIds.includes(l.id) + ); + if (!hasActiveLayer) { + // Zone에 활성 레이어 없음: Zone 높이만큼 위로 당김 (빈 공간 제거) + totalHeightAdjustment -= zone.height; + } + } + } + + if (totalHeightAdjustment !== 0) { return { ...component, position: { @@ -950,6 +1146,81 @@ function ScreenViewPage() {
); })} + + {/* 🆕 조건부 레이어 컴포넌트 렌더링 (Zone 기반) */} + {conditionalLayers.map((layer) => { + const isActive = activeLayerIds.includes(layer.id); + if (!isActive || !layer.components || layer.components.length === 0) return null; + + // Zone 기반: zoneId로 Zone 찾아서 위치/크기 결정 + const zone = layer.zoneId ? zones.find(z => z.zone_id === layer.zoneId) : null; + const region = zone + ? { x: zone.x, y: zone.y, width: zone.width, height: zone.height } + : layer.displayRegion; + + return ( +
+ {layer.components + .filter((comp) => !comp.parentId) + .map((comp) => ( + {}} + menuObjid={menuObjid} + screenId={screenId} + tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} + selectedRowsData={selectedRowsData} + sortBy={tableSortBy} + sortOrder={tableSortOrder} + columnOrder={tableColumnOrder} + tableDisplayData={tableDisplayData} + onSelectedRowsChange={( + _, + selectedData, + sortBy, + sortOrder, + columnOrder, + tableDisplayData, + ) => { + setSelectedRowsData(selectedData); + setTableSortBy(sortBy); + setTableSortOrder(sortOrder || "asc"); + setTableColumnOrder(columnOrder); + setTableDisplayData(tableDisplayData || []); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); + }} + formData={formData} + onFormDataChange={(fieldName, value) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + /> + ))} +
+ ); + })} ); })()} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index a252eaff..7276f5b0 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -263,12 +263,20 @@ input, textarea, select { transition-property: - color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, + color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, filter, backdrop-filter; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; } +/* 런타임 화면에서 컴포넌트 위치 변경 시 모든 애니메이션/트랜지션 완전 제거 */ +[data-screen-runtime] [id^="component-"] { + transition: none !important; +} +[data-screen-runtime] [data-conditional-layer] { + transition: none !important; +} + /* Disable animations for users who prefer reduced motion */ @media (prefers-reduced-motion: reduce) { *, @@ -281,6 +289,20 @@ select { } } +/* ===== Sonner 토스트 애니메이션 완전 제거 ===== */ +[data-sonner-toaster] [data-sonner-toast] { + animation: none !important; + transition: none !important; + opacity: 1 !important; + transform: none !important; +} +[data-sonner-toaster] [data-sonner-toast][data-mounted="true"] { + animation: none !important; +} +[data-sonner-toaster] [data-sonner-toast][data-removed="true"] { + animation: none !important; +} + /* ===== Print Styles ===== */ @media print { * { diff --git a/frontend/components/admin/UserFormModal.tsx b/frontend/components/admin/UserFormModal.tsx index a70e82b9..39bbefbc 100644 --- a/frontend/components/admin/UserFormModal.tsx +++ b/frontend/components/admin/UserFormModal.tsx @@ -145,13 +145,12 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF const isFormValid = useMemo(() => { // 수정 모드에서는 비밀번호 선택 사항 (변경할 경우만 입력) const requiredFields = isEditMode - ? [formData.userId.trim(), formData.userName.trim(), formData.companyCode, formData.deptCode] + ? [formData.userId.trim(), formData.userName.trim(), formData.companyCode] : [ formData.userId.trim(), formData.userPassword.trim(), formData.userName.trim(), formData.companyCode, - formData.deptCode, ]; // 모든 필수 필드가 입력되었는지 확인 @@ -327,11 +326,6 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF return false; } - if (!formData.deptCode) { - showAlert("입력 오류", "부서를 선택해주세요.", "error"); - return false; - } - // 이메일 형식 검사 (입력된 경우만) if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { showAlert("입력 오류", "올바른 이메일 형식을 입력해주세요.", "error"); @@ -581,7 +575,7 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
{/* 중복 체크 체크박스 */} @@ -1371,6 +1501,38 @@ export const ExcelUploadModal: React.FC = ({
+ {/* 미매핑 필수(NOT NULL) 컬럼 경고 */} + {(() => { + const mappedCols = new Set(); + columnMappings.filter((m) => m.systemColumn).forEach((m) => { + const n = m.systemColumn!; + mappedCols.add(n); + if (n.includes(".")) mappedCols.add(n.split(".")[1]); + }); + const missing = systemColumns.filter((col) => { + const rawName = col.name.includes(".") ? col.name.split(".")[1] : col.name; + if (AUTO_GENERATED_COLUMNS.includes(rawName.toLowerCase())) return false; + if (col.nullable) return false; + if (mappedCols.has(col.name) || mappedCols.has(rawName)) return false; + if ((col as any).isNumbering) return false; + return true; + }); + if (missing.length === 0) return null; + return ( +
+
+ +
+

필수(NOT NULL) 컬럼이 매핑되지 않았습니다:

+

+ {missing.map((c) => c.label || c.name).join(", ")} +

+
+
+
+ ); + })()} + {/* 중복 체크 안내 */} {duplicateCheckCount > 0 ? (
diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 49fb3355..138f560c 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -1,7 +1,17 @@ "use client"; -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogCancel, + AlertDialogAction, +} from "@/components/ui/alert-dialog"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic"; @@ -14,6 +24,7 @@ import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHei import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; +import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; interface ScreenModalState { isOpen: boolean; @@ -61,12 +72,21 @@ export const ScreenModal: React.FC = ({ className }) => { // 🆕 선택된 데이터 상태 (RepeatScreenModal 등에서 사용) const [selectedData, setSelectedData] = useState[]>([]); + // 🆕 조건부 레이어 상태 (Zone 기반) + const [conditionalLayers, setConditionalLayers] = useState<(LayerDefinition & { components: ComponentData[]; zone?: ConditionalZone })[]>([]); + // 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해) const [continuousMode, setContinuousMode] = useState(false); // 화면 리셋 키 (컴포넌트 강제 리마운트용) const [resetKey, setResetKey] = useState(0); + // 모달 닫기 확인 다이얼로그 표시 상태 + const [showCloseConfirm, setShowCloseConfirm] = useState(false); + + // 사용자가 폼 데이터를 실제로 변경했는지 추적 (변경 없으면 경고 없이 바로 닫기) + const formDataChangedRef = useRef(false); + // localStorage에서 연속 모드 상태 복원 useEffect(() => { const savedMode = localStorage.getItem("screenModal_continuousMode"); @@ -109,9 +129,9 @@ export const ScreenModal: React.FC = ({ className }) => { const contentWidth = maxX - minX; const contentHeight = maxY - minY; - // 적절한 여백 추가 - const paddingX = 40; - const paddingY = 40; + // 여백 없이 컨텐츠 크기 그대로 사용 + const paddingX = 0; + const paddingY = 0; const finalWidth = Math.max(contentWidth + paddingX, 400); const finalHeight = Math.max(contentHeight + paddingY, 300); @@ -119,8 +139,8 @@ export const ScreenModal: React.FC = ({ className }) => { return { width: Math.min(finalWidth, window.innerWidth * 0.95), height: Math.min(finalHeight, window.innerHeight * 0.9), - offsetX: Math.max(0, minX - paddingX / 2), // 좌측 여백 고려 - offsetY: Math.max(0, minY - paddingY / 2), // 상단 여백 고려 + offsetX: Math.max(0, minX), // 여백 없이 컨텐츠 시작점 기준 + offsetY: Math.max(0, minY), // 여백 없이 컨텐츠 시작점 기준 }; }; @@ -159,11 +179,15 @@ export const ScreenModal: React.FC = ({ className }) => { selectedData: eventSelectedData, selectedIds, isCreateMode, // 🆕 복사 모드 플래그 (true면 editData가 있어도 originalData 설정 안 함) + fieldMappings, // 🆕 필드 매핑 정보 (명시적 매핑이 있으면 모든 매핑된 필드 전달) } = event.detail; // 🆕 모달 열린 시간 기록 modalOpenedAtRef.current = Date.now(); + // 폼 변경 추적 초기화 + formDataChangedRef.current = false; + // 🆕 선택된 데이터 저장 (RepeatScreenModal 등에서 사용) if (eventSelectedData && Array.isArray(eventSelectedData)) { setSelectedData(eventSelectedData); @@ -218,10 +242,33 @@ export const ScreenModal: React.FC = ({ className }) => { const parentDataMapping = splitPanelContext?.parentDataMapping || []; // 부모 데이터 소스 - const rawParentData = - splitPanelParentData && Object.keys(splitPanelParentData).length > 0 - ? splitPanelParentData - : splitPanelContext?.selectedLeftData || {}; + // 🔧 수정: 여러 소스를 병합 (우선순위: splitPanelParentData > selectedLeftData > 기존 formData의 링크 필드) + // 예: screen 150→226→227 전환 시: + // - splitPanelParentData: item_info 데이터 (screen 226에서 전달) + // - selectedLeftData: customer_mng 데이터 (SplitPanel 좌측 선택) + // - 기존 formData: 이전 모달에서 설정된 link 필드 (customer_code 등) + const contextData = splitPanelContext?.selectedLeftData || {}; + const eventData = splitPanelParentData && Object.keys(splitPanelParentData).length > 0 + ? splitPanelParentData + : {}; + + // 🆕 기존 formData에서 link 필드(_code, _id)를 가져와 base로 사용 + // 모달 체인(226→227)에서 이전 모달의 연결 필드가 유지됨 + const previousLinkFields: Record = {}; + if (formData && typeof formData === "object" && !Array.isArray(formData)) { + const linkFieldPatterns = ["_code", "_id"]; + const excludeFields = ["id", "created_date", "updated_date", "created_at", "updated_at", "writer"]; + for (const [key, value] of Object.entries(formData)) { + if (excludeFields.includes(key)) continue; + if (value === undefined || value === null) continue; + const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern)); + if (isLinkField) { + previousLinkFields[key] = value; + } + } + } + + const rawParentData = { ...previousLinkFields, ...contextData, ...eventData }; // 🔧 신규 등록 모드에서는 연결에 필요한 필드만 전달 const parentData: Record = {}; @@ -231,6 +278,17 @@ export const ScreenModal: React.FC = ({ className }) => { parentData.company_code = rawParentData.company_code; } + // 🆕 명시적 필드 매핑이 있으면 매핑된 타겟 필드를 모두 보존 + // (버튼 설정에서 fieldMappings로 지정한 필드는 link 필드가 아니어도 전달) + const mappedTargetFields = new Set(); + if (fieldMappings && Array.isArray(fieldMappings)) { + for (const mapping of fieldMappings) { + if (mapping.targetField) { + mappedTargetFields.add(mapping.targetField); + } + } + } + // parentDataMapping에 정의된 필드만 전달 for (const mapping of parentDataMapping) { const sourceValue = rawParentData[mapping.sourceColumn]; @@ -239,8 +297,17 @@ export const ScreenModal: React.FC = ({ className }) => { } } - // parentDataMapping이 비어있으면 연결 필드 자동 감지 (equipment_code, xxx_code, xxx_id 패턴) - if (parentDataMapping.length === 0) { + // 🆕 명시적 필드 매핑이 있으면 해당 필드를 모두 전달 + if (mappedTargetFields.size > 0) { + for (const [key, value] of Object.entries(rawParentData)) { + if (mappedTargetFields.has(key) && value !== undefined && value !== null) { + parentData[key] = value; + } + } + } + + // parentDataMapping이 비어있고 명시적 필드 매핑도 없으면 연결 필드 자동 감지 + if (parentDataMapping.length === 0 && mappedTargetFields.size === 0) { const linkFieldPatterns = ["_code", "_id"]; const excludeFields = [ "id", @@ -257,6 +324,29 @@ export const ScreenModal: React.FC = ({ className }) => { if (value === undefined || value === null) continue; // 연결 필드 패턴 확인 + const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern)); + if (isLinkField) { + parentData[key] = value; + } + } + } else if (parentDataMapping.length === 0 && mappedTargetFields.size > 0) { + // 🆕 명시적 매핑이 있어도 연결 필드(_code, _id)는 추가로 전달 + const linkFieldPatterns = ["_code", "_id"]; + const excludeFields = [ + "id", + "company_code", + "created_date", + "updated_date", + "created_at", + "updated_at", + "writer", + ]; + + for (const [key, value] of Object.entries(rawParentData)) { + if (excludeFields.includes(key)) continue; + if (parentData[key] !== undefined) continue; // 이미 매핑된 필드는 스킵 + if (value === undefined || value === null) continue; + const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern)); if (isLinkField) { parentData[key] = value; @@ -317,6 +407,7 @@ export const ScreenModal: React.FC = ({ className }) => { if (isContinuousMode) { // 연속 모드: 폼만 초기화하고 모달은 유지 + formDataChangedRef.current = false; setFormData({}); setResetKey((prev) => prev + 1); @@ -483,6 +574,9 @@ export const ScreenModal: React.FC = ({ className }) => { components, screenInfo: screenInfo, }); + + // 🆕 조건부 레이어/존 로드 + loadConditionalLayersAndZones(screenId); } else { throw new Error("화면 데이터가 없습니다"); } @@ -495,14 +589,262 @@ export const ScreenModal: React.FC = ({ className }) => { } }; - const handleClose = () => { - // 🔧 URL 파라미터 제거 (mode, editId, tableName 등) + // 🆕 조건부 레이어 & 존 로드 함수 + const loadConditionalLayersAndZones = async (screenId: number) => { + try { + const [layersRes, zonesRes] = await Promise.all([ + screenApi.getScreenLayers(screenId), + screenApi.getScreenZones(screenId), + ]); + + const loadedLayers = layersRes || []; + const loadedZones: ConditionalZone[] = zonesRes || []; + + // 기본 레이어(layer_id=1) 제외 + const nonBaseLayers = loadedLayers.filter((l: any) => l.layer_id !== 1); + + if (nonBaseLayers.length === 0) { + setConditionalLayers([]); + return; + } + + const layerDefs: (LayerDefinition & { components: ComponentData[] })[] = []; + + for (const layer of nonBaseLayers) { + try { + const layerLayout = await screenApi.getLayerLayout(screenId, layer.layer_id); + + let layerComponents: ComponentData[] = []; + if (layerLayout && isValidV2Layout(layerLayout)) { + const legacyLayout = convertV2ToLegacy(layerLayout); + layerComponents = (legacyLayout?.components || []) as unknown as ComponentData[]; + } else if (layerLayout?.components) { + layerComponents = layerLayout.components; + } + + // condition_config에서 zone_id, condition_value 추출 + const cc = layer.condition_config || {}; + const zone = loadedZones.find((z) => z.zone_id === cc.zone_id); + + layerDefs.push({ + id: `layer-${layer.layer_id}`, + name: layer.layer_name || `레이어 ${layer.layer_id}`, + type: "conditional", + zIndex: layer.layer_id, + isVisible: false, + isLocked: false, + zoneId: cc.zone_id, + conditionValue: cc.condition_value, + condition: zone + ? { + targetComponentId: zone.trigger_component_id || "", + operator: (zone.trigger_operator || "eq") as any, + value: cc.condition_value || "", + } + : undefined, + components: layerComponents, + zone: zone || undefined, // 🆕 Zone 위치 정보 포함 (오프셋 계산용) + } as any); + } catch (err) { + console.warn(`[ScreenModal] 레이어 ${layer.layer_id} 로드 실패:`, err); + } + } + + console.log("[ScreenModal] 조건부 레이어 로드 완료:", layerDefs.length, "개", + layerDefs.map((l) => ({ + id: l.id, name: l.name, conditionValue: l.conditionValue, + componentCount: l.components.length, + condition: l.condition, + })) + ); + + setConditionalLayers(layerDefs); + } catch (error) { + console.error("[ScreenModal] 조건부 레이어 로드 실패:", error); + } + }; + + // 🆕 조건부 레이어 활성화 평가 (formData 변경 시) + const activeConditionalComponents = useMemo(() => { + if (conditionalLayers.length === 0) return []; + + const allComponents = screenData?.components || []; + const activeComps: ComponentData[] = []; + + conditionalLayers.forEach((layer) => { + if (!layer.condition) return; + const { targetComponentId, operator, value } = layer.condition; + if (!targetComponentId) return; + + // V2 레이아웃: overrides.columnName 우선 + const comp = allComponents.find((c: any) => c.id === targetComponentId); + const fieldKey = + (comp as any)?.overrides?.columnName || + (comp as any)?.columnName || + (comp as any)?.componentConfig?.columnName || + targetComponentId; + + const targetValue = formData[fieldKey]; + + let isMatch = false; + switch (operator) { + case "eq": + isMatch = String(targetValue ?? "") === String(value ?? ""); + break; + case "neq": + isMatch = String(targetValue ?? "") !== String(value ?? ""); + break; + case "in": + if (Array.isArray(value)) { + isMatch = value.some((v) => String(v) === String(targetValue ?? "")); + } else if (typeof value === "string" && value.includes(",")) { + isMatch = value.split(",").map((v) => v.trim()).includes(String(targetValue ?? "")); + } + break; + } + + console.log("[ScreenModal] 레이어 조건 평가:", { + layerName: layer.name, fieldKey, + targetValue: String(targetValue ?? "(없음)"), + conditionValue: String(value), operator, isMatch, + }); + + if (isMatch) { + // Zone 오프셋 적용 (레이어 2 컴포넌트는 Zone 상대 좌표로 저장됨) + const zoneX = layer.zone?.x || 0; + const zoneY = layer.zone?.y || 0; + + const offsetComponents = layer.components.map((c: any) => ({ + ...c, + position: { + ...c.position, + x: parseFloat(c.position?.x?.toString() || "0") + zoneX, + y: parseFloat(c.position?.y?.toString() || "0") + zoneY, + }, + })); + + activeComps.push(...offsetComponents); + } + }); + + return activeComps; + }, [formData, conditionalLayers, screenData?.components]); + + // 🆕 이전 활성 레이어 ID 추적 (레이어 전환 감지용) + const prevActiveLayerIdsRef = useRef([]); + + // 🆕 레이어 전환 시 비활성화된 레이어의 필드값을 formData에서 제거 + // (품목우선 → 공급업체우선 전환 시, 품목우선 레이어의 데이터가 남지 않도록) + useEffect(() => { + if (conditionalLayers.length === 0) return; + + // 현재 활성 레이어 ID 목록 + const currentActiveLayerIds = conditionalLayers + .filter((layer) => { + if (!layer.condition) return false; + const { targetComponentId, operator, value } = layer.condition; + if (!targetComponentId) return false; + + const allComponents = screenData?.components || []; + const comp = allComponents.find((c: any) => c.id === targetComponentId); + const fieldKey = + (comp as any)?.overrides?.columnName || + (comp as any)?.columnName || + (comp as any)?.componentConfig?.columnName || + targetComponentId; + + const targetValue = formData[fieldKey]; + switch (operator) { + case "eq": + return String(targetValue ?? "") === String(value ?? ""); + case "neq": + return String(targetValue ?? "") !== String(value ?? ""); + case "in": + if (Array.isArray(value)) { + return value.some((v) => String(v) === String(targetValue ?? "")); + } else if (typeof value === "string" && value.includes(",")) { + return value.split(",").map((v) => v.trim()).includes(String(targetValue ?? "")); + } + return false; + default: + return false; + } + }) + .map((l) => l.id); + + const prevIds = prevActiveLayerIdsRef.current; + + // 이전에 활성이었는데 이번에 비활성이 된 레이어 찾기 + const deactivatedLayerIds = prevIds.filter((id) => !currentActiveLayerIds.includes(id)); + + if (deactivatedLayerIds.length > 0) { + // 비활성화된 레이어의 컴포넌트 필드명 수집 + const fieldsToRemove: string[] = []; + deactivatedLayerIds.forEach((layerId) => { + const layer = conditionalLayers.find((l) => l.id === layerId); + if (!layer) return; + + layer.components.forEach((comp: any) => { + const fieldName = + comp?.overrides?.columnName || + comp?.columnName || + comp?.componentConfig?.columnName; + if (fieldName) { + fieldsToRemove.push(fieldName); + } + }); + }); + + if (fieldsToRemove.length > 0) { + console.log("[ScreenModal] 레이어 전환 감지 - 비활성 레이어 필드 제거:", { + deactivatedLayerIds, + fieldsToRemove, + }); + + setFormData((prev) => { + const cleaned = { ...prev }; + fieldsToRemove.forEach((field) => { + delete cleaned[field]; + }); + return cleaned; + }); + } + } + + // 현재 상태 저장 + prevActiveLayerIdsRef.current = currentActiveLayerIds; + }, [formData, conditionalLayers, screenData?.components]); + + // 사용자가 바깥 클릭/ESC/X 버튼으로 닫으려 할 때 + // 폼 데이터 변경이 있으면 확인 다이얼로그, 없으면 바로 닫기 + const handleCloseAttempt = useCallback(() => { + if (formDataChangedRef.current) { + setShowCloseConfirm(true); + } else { + handleCloseInternal(); + } + }, []); + + // 확인 후 실제로 모달을 닫는 함수 + const handleConfirmClose = useCallback(() => { + setShowCloseConfirm(false); + handleCloseInternal(); + }, []); + + // 닫기 취소 (계속 작업) + const handleCancelClose = useCallback(() => { + setShowCloseConfirm(false); + }, []); + + const handleCloseInternal = () => { + // 🔧 URL 파라미터 제거 (mode, editId, tableName, groupByColumns, dataSourceId 등) if (typeof window !== "undefined") { const currentUrl = new URL(window.location.href); currentUrl.searchParams.delete("mode"); currentUrl.searchParams.delete("editId"); currentUrl.searchParams.delete("tableName"); currentUrl.searchParams.delete("groupByColumns"); + currentUrl.searchParams.delete("dataSourceId"); window.history.pushState({}, "", currentUrl.toString()); } @@ -514,42 +856,35 @@ export const ScreenModal: React.FC = ({ className }) => { }); setScreenData(null); setFormData({}); // 폼 데이터 초기화 + setOriginalData(null); // 원본 데이터 초기화 + setSelectedData([]); // 선택된 데이터 초기화 + setConditionalLayers([]); // 🆕 조건부 레이어 초기화 + setContinuousMode(false); + localStorage.setItem("screenModal_continuousMode", "false"); }; + // 기존 handleClose를 유지 (이벤트 핸들러 등에서 사용) + const handleClose = handleCloseInternal; + // 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터 const getModalStyle = () => { if (!screenDimensions) { return { - className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0", - style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용 - needsScroll: false, + className: "w-fit min-w-[400px] max-w-4xl overflow-hidden", + style: { padding: 0, gap: 0, maxHeight: "calc(100dvh - 8px)" }, }; } - // 화면관리에서 설정한 크기 = 컨텐츠 영역 크기 - // 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + 패딩 - // 🔧 DialogContent의 gap-4 (16px × 2) + 컨텐츠 pt-6 (24px) 포함 - const headerHeight = 48; // DialogHeader (타이틀 + border-b + py-3) - const footerHeight = 44; // 연속 등록 모드 체크박스 영역 - const dialogGap = 32; // gap-4 × 2 (header-content, content-footer 사이) - const contentTopPadding = 24; // pt-6 (컨텐츠 영역 상단 패딩) - const horizontalPadding = 16; // 좌우 패딩 최소화 - - const totalHeight = screenDimensions.height + headerHeight + footerHeight + dialogGap + contentTopPadding; - const maxAvailableHeight = window.innerHeight * 0.95; - - // 콘텐츠가 화면 높이를 초과할 때만 스크롤 필요 - const needsScroll = totalHeight > maxAvailableHeight; - return { - className: "overflow-hidden p-0", + className: "overflow-hidden", style: { - width: `${Math.min(screenDimensions.width + horizontalPadding, window.innerWidth * 0.98)}px`, - // 🔧 height 대신 max-height만 설정 - 콘텐츠가 작으면 자동으로 줄어듦 - maxHeight: `${maxAvailableHeight}px`, + width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`, + // CSS가 알아서 처리: 뷰포트 안에 들어가면 auto-height, 넘치면 max-height로 제한 + maxHeight: "calc(100dvh - 8px)", maxWidth: "98vw", + padding: 0, + gap: 0, }, - needsScroll, }; }; @@ -615,10 +950,28 @@ export const ScreenModal: React.FC = ({ className }) => { ]); return ( - + { + // X 버튼 클릭 시에도 확인 다이얼로그 표시 + if (!open) { + handleCloseAttempt(); + } + }} + > { + e.preventDefault(); + handleCloseAttempt(); + }} + // ESC 키 누를 때도 바로 닫히지 않도록 방지 + onEscapeKeyDown={(e) => { + e.preventDefault(); + handleCloseAttempt(); + }} >
@@ -633,7 +986,7 @@ export const ScreenModal: React.FC = ({ className }) => {
{loading ? (
@@ -649,8 +1002,22 @@ export const ScreenModal: React.FC = ({ className }) => { className="relative bg-white" style={{ width: `${screenDimensions?.width || 800}px`, - height: `${screenDimensions?.height || 600}px`, - // 🆕 라벨이 위로 튀어나갈 수 있도록 overflow visible 설정 + // 🆕 조건부 레이어 활성화 시 높이 자동 확장 + minHeight: `${screenDimensions?.height || 600}px`, + height: (() => { + const baseHeight = screenDimensions?.height || 600; + if (activeConditionalComponents.length > 0) { + const offsetY = screenDimensions?.offsetY || 0; + let maxBottom = 0; + activeConditionalComponents.forEach((comp: any) => { + const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY; + const h = parseFloat(comp.size?.height?.toString() || "40"); + maxBottom = Math.max(maxBottom, y + h); + }); + return `${Math.max(baseHeight, maxBottom + 20)}px`; + } + return `${baseHeight}px`; + })(), overflow: "visible", }} > @@ -786,6 +1153,8 @@ export const ScreenModal: React.FC = ({ className }) => { formData={formData} originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용) onFormDataChange={(fieldName, value) => { + // 사용자가 실제로 데이터를 변경한 것으로 표시 + formDataChangedRef.current = true; setFormData((prev) => { const newFormData = { ...prev, @@ -810,6 +1179,48 @@ export const ScreenModal: React.FC = ({ className }) => { ); }); })()} + + {/* 🆕 조건부 레이어 컴포넌트 렌더링 */} + {activeConditionalComponents.map((component: any) => { + const offsetX = screenDimensions?.offsetX || 0; + const offsetY = screenDimensions?.offsetY || 0; + + const adjustedComponent = { + ...component, + position: { + ...component.position, + x: parseFloat(component.position?.x?.toString() || "0") - offsetX, + y: parseFloat(component.position?.y?.toString() || "0") - offsetY, + }, + }; + + return ( + { + formDataChangedRef.current = true; + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); + }} + onRefresh={() => { + window.dispatchEvent(new CustomEvent("refreshTable")); + }} + screenInfo={{ + id: modalState.screenId!, + tableName: screenData?.screenInfo?.tableName, + }} + userId={userId} + userName={userName} + companyCode={user?.companyCode} + /> + ); + })}
@@ -838,6 +1249,36 @@ export const ScreenModal: React.FC = ({ className }) => {
+ + {/* 모달 닫기 확인 다이얼로그 */} + + + + + 화면을 닫으시겠습니까? + + + 지금 나가시면 진행 중인 데이터가 저장되지 않습니다. +
+ 계속 작업하시려면 '계속 작업' 버튼을 눌러주세요. +
+
+ + + 계속 작업 + + + 나가기 + + +
+
); }; diff --git a/frontend/components/dataflow/node-editor/FlowToolbar.tsx b/frontend/components/dataflow/node-editor/FlowToolbar.tsx index f136d216..607886f3 100644 --- a/frontend/components/dataflow/node-editor/FlowToolbar.tsx +++ b/frontend/components/dataflow/node-editor/FlowToolbar.tsx @@ -4,7 +4,7 @@ * 플로우 에디터 상단 툴바 */ -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { Save, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -42,6 +42,27 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro const [showSaveDialog, setShowSaveDialog] = useState(false); + // Ctrl+S 단축키: 플로우 저장 + const handleSaveRef = useRef<() => void>(); + + useEffect(() => { + handleSaveRef.current = handleSave; + }); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s") { + e.preventDefault(); + if (!isSaving) { + handleSaveRef.current?.(); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isSaving]); + const handleSave = async () => { // 검증 수행 const currentValidations = validations.length > 0 ? validations : validateFlow(nodes, edges); diff --git a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx index a2d060d4..76354925 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx @@ -251,6 +251,14 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND"); const [availableFields, setAvailableFields] = useState([]); + // 타겟 조회 설정 (DB 기존값 비교용) + const [targetLookup, setTargetLookup] = useState<{ + tableName: string; + tableLabel?: string; + lookupKeys: Array<{ sourceField: string; targetField: string; sourceFieldLabel?: string }>; + } | undefined>(data.targetLookup); + const [targetLookupColumns, setTargetLookupColumns] = useState([]); + // EXISTS 연산자용 상태 const [allTables, setAllTables] = useState([]); const [tableColumnsCache, setTableColumnsCache] = useState>({}); @@ -262,8 +270,20 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) setDisplayName(data.displayName || "조건 분기"); setConditions(data.conditions || []); setLogic(data.logic || "AND"); + setTargetLookup(data.targetLookup); }, [data]); + // targetLookup 테이블 변경 시 컬럼 목록 로드 + useEffect(() => { + if (targetLookup?.tableName) { + loadTableColumns(targetLookup.tableName).then((cols) => { + setTargetLookupColumns(cols); + }); + } else { + setTargetLookupColumns([]); + } + }, [targetLookup?.tableName]); + // 전체 테이블 목록 로드 (EXISTS 연산자용) useEffect(() => { const loadAllTables = async () => { @@ -559,6 +579,47 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) }); }; + // 타겟 조회 테이블 변경 + const handleTargetLookupTableChange = async (tableName: string) => { + await ensureTablesLoaded(); + const tableInfo = allTables.find((t) => t.tableName === tableName); + const newLookup = { + tableName, + tableLabel: tableInfo?.tableLabel || tableName, + lookupKeys: targetLookup?.lookupKeys || [], + }; + setTargetLookup(newLookup); + updateNode(nodeId, { targetLookup: newLookup }); + + // 컬럼 로드 + const cols = await loadTableColumns(tableName); + setTargetLookupColumns(cols); + }; + + // 타겟 조회 키 필드 변경 + const handleTargetLookupKeyChange = (sourceField: string, targetField: string) => { + if (!targetLookup) return; + const sourceFieldInfo = availableFields.find((f) => f.name === sourceField); + const newLookup = { + ...targetLookup, + lookupKeys: [{ sourceField, targetField, sourceFieldLabel: sourceFieldInfo?.label || sourceField }], + }; + setTargetLookup(newLookup); + updateNode(nodeId, { targetLookup: newLookup }); + }; + + // 타겟 조회 제거 + const handleRemoveTargetLookup = () => { + setTargetLookup(undefined); + updateNode(nodeId, { targetLookup: undefined }); + // target 타입 조건들을 field로 변경 + const newConditions = conditions.map((c) => + (c as any).valueType === "target" ? { ...c, valueType: "field" } : c + ); + setConditions(newConditions); + updateNode(nodeId, { conditions: newConditions }); + }; + return (
@@ -597,6 +658,119 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
+ {/* 타겟 조회 (DB 기존값 비교) */} +
+
+

+ + 타겟 조회 (DB 기존값) +

+
+ + {!targetLookup ? ( +
+
+ DB의 기존값과 비교하려면 타겟 테이블을 설정하세요. +
+ +
+ ) : ( +
+
+ 타겟 테이블 + +
+ + {/* 테이블 선택 */} + {allTables.length > 0 ? ( + + ) : ( +
+ 테이블 로딩 중... +
+ )} + + {/* 키 필드 매핑 */} + {targetLookup.tableName && ( +
+ +
+ + = + {targetLookupColumns.length > 0 ? ( + + ) : ( +
+ 컬럼 로딩 중... +
+ )} +
+
+ 비교 값 타입에서 "타겟 필드 (DB 기존값)"을 선택하면 이 테이블의 기존값과 비교합니다. +
+
+ )} +
+ )} +
+ {/* 조건식 */}
@@ -738,15 +912,46 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) 고정값 필드 참조 + {targetLookup?.tableName && ( + 타겟 필드 (DB 기존값) + )}
- {(condition as any).valueType === "field" ? ( + {(condition as any).valueType === "target" ? ( + // 타겟 필드 (DB 기존값): 타겟 테이블 컬럼에서 선택 + targetLookupColumns.length > 0 ? ( + + ) : ( +
+ 타겟 조회를 먼저 설정하세요 +
+ ) + ) : (condition as any).valueType === "field" ? ( // 필드 참조: 드롭다운으로 선택 availableFields.length > 0 ? ( { + if (!screenId) return; + const newVal = val === "_none_" ? "" : val; + try { + await screenApi.updateLayerCondition( + screenId, + layer.layer_id, + { ...layer.condition_config, condition_value: newVal }, + layer.layer_name, + ); + await loadLayers(); + toast.success("조건값이 변경되었습니다."); + } catch { + toast.error("조건값 변경에 실패했습니다."); + } + }} + > + e.stopPropagation()} + > + + + + 미설정 + {triggerOpts.map(opt => ( + + {opt.label} + + ))} + + + ) : ( + + 조건값: {currentCondValue || "미설정"} + + )} + + | {layer.component_count}개 + +
+
+ +
+ ); + })} + + {/* 레이어 추가 */} + {addingToZoneId === zone.zone_id ? ( +
+ {(() => { + // 동적 옵션 로딩 중 표시 + const isLoadingOpts = zone.trigger_component_id ? loadingDynamicOptions.has(zone.trigger_component_id) : false; + if (isLoadingOpts) { + return ( +
+ + 옵션 로딩 중... +
+ ); + } + + const triggerOpts = getTriggerOptions(zone); + // 이미 사용된 조건값 제외 + const usedValues = new Set( + zoneLayers.map(l => l.condition_config?.condition_value).filter(Boolean) + ); + const availableOpts = triggerOpts.filter(o => !usedValues.has(o.value)); + + if (availableOpts.length > 0) { + return ( + + ); + } + return ( + setNewConditionValue(e.target.value)} + placeholder="조건값 입력" + className="h-6 text-[11px] flex-1" + autoFocus + onKeyDown={(e) => { if (e.key === "Enter") handleAddLayerToZone(zone.zone_id); }} + /> + ); + })()} + + +
+ ) : ( + + )}
)} ); - }) + })} + + {/* 고아 레이어 (Zone에 소속되지 않은 조건부 레이어) */} + {orphanLayers.length > 0 && ( +
+

Zone 미할당 레이어

+ {orphanLayers.map((layer) => { + const isActive = activeLayerId === layer.layer_id; + return ( +
onLayerChange(layer.layer_id)} + > + +
+ {layer.layer_name} +
+ 조건부 + {layer.component_count}개 +
+
+ +
+ ); + })} +
+ )} + )} - {/* 도움말 */} -
-

레이어를 클릭하여 편집 | 조건부 레이어를 캔버스에 드래그하여 영역 설정

+ {/* Zone 생성 드래그 영역 */} +
+
{ + e.dataTransfer.setData("application/json", JSON.stringify({ type: "create-zone" })); + e.dataTransfer.effectAllowed = "copy"; + }} + > + + + 조건부 영역 추가 (캔버스로 드래그) +
+

+ 기본 레이어에서 Zone을 배치한 후, Zone 내에 레이어를 추가하세요 +

); diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 5a786616..4efcb696 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -561,9 +561,8 @@ export const RealtimePreviewDynamic: React.FC = ({ zIndex: position?.z || 1, // right 속성 강제 제거 right: undefined, - // 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동 - transition: - isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined, + // 모든 컴포넌트에서 transition 완전 제거 (위치 변경 시 애니메이션 방지) + transition: "none", }; // 선택된 컴포넌트 스타일 @@ -594,7 +593,7 @@ export const RealtimePreviewDynamic: React.FC = ({ return (
("components"); - // 🆕 레이어 영역 (기본 레이어에서 조건부 레이어들의 displayRegion 표시) - const [layerRegions, setLayerRegions] = useState>({}); + // 🆕 조건부 영역(Zone) 목록 (DB screen_conditional_zones 기반) + const [zones, setZones] = useState([]); // 🆕 조건부 영역 드래그 상태 (캔버스에서 드래그로 영역 설정) const [regionDrag, setRegionDrag] = useState<{ isDrawing: boolean; // 새 영역 그리기 모드 isDragging: boolean; // 기존 영역 이동 모드 isResizing: boolean; // 기존 영역 리사이즈 모드 - targetLayerId: string | null; // 대상 레이어 ID + targetLayerId: string | null; // 대상 Zone ID (문자열) startX: number; startY: number; currentX: number; @@ -551,6 +551,33 @@ export default function ScreenDesigner({ originalRegion: null, }); + // 🆕 현재 활성 레이어의 Zone 정보 (캔버스 크기 결정용) + const [activeLayerZone, setActiveLayerZone] = useState(null); + + // 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기 + useEffect(() => { + if (activeLayerId <= 1 || !selectedScreen?.screenId) { + setActiveLayerZone(null); + return; + } + // 레이어의 condition_config에서 zone_id를 가져와서 zones에서 찾기 + const findZone = async () => { + try { + const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, activeLayerId); + const zoneId = layerData?.conditionConfig?.zone_id; + if (zoneId) { + const zone = zones.find(z => z.zone_id === zoneId); + setActiveLayerZone(zone || null); + } else { + setActiveLayerZone(null); + } + } catch { + setActiveLayerZone(null); + } + }; + findZone(); + }, [activeLayerId, selectedScreen?.screenId, zones]); + // 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시) const visibleComponents = useMemo(() => { return layout.components; @@ -1575,20 +1602,11 @@ export default function ScreenDesigner({ // 파일 컴포넌트 데이터 복원 (비동기) restoreFileComponentsData(layoutWithDefaultGrid.components); - // 🆕 레이어 영역 로드 (조건부 레이어의 displayRegion) + // 🆕 조건부 영역(Zone) 로드 try { - const layers = await screenApi.getScreenLayers(selectedScreen.screenId); - const regions: Record = {}; - for (const layer of layers) { - if (layer.layer_id > 1 && layer.condition_config?.displayRegion) { - regions[layer.layer_id] = { - ...layer.condition_config.displayRegion, - layerName: layer.layer_name, - }; - } - } - setLayerRegions(regions); - } catch { /* 레이어 로드 실패 무시 */ } + const loadedZones = await screenApi.getScreenZones(selectedScreen.screenId); + setZones(loadedZones); + } catch { /* Zone 로드 실패 무시 */ } } } catch (error) { // console.error("레이아웃 로드 실패:", error); @@ -1928,17 +1946,33 @@ export default function ScreenDesigner({ [groupState.selectedComponents, layout, selectedComponent?.id, saveToHistory] ); - // 라벨 일괄 토글 + // 라벨 일괄 토글 (선택된 컴포넌트가 있으면 선택된 것만, 없으면 전체) const handleToggleAllLabels = useCallback(() => { saveToHistory(layout); - const newComponents = toggleAllLabels(layout.components); + + const selectedIds = groupState.selectedComponents; + const isPartial = selectedIds.length > 0; + + // 토글 대상 컴포넌트 필터 + const targetComponents = layout.components.filter((c) => { + if (!c.label || ["group", "datatable"].includes(c.type)) return false; + if (isPartial) return selectedIds.includes(c.id); + return true; + }); + + const hadHidden = targetComponents.some( + (c) => (c.style as any)?.labelDisplay === false + ); + + const newComponents = toggleAllLabels(layout.components, selectedIds); setLayout((prev) => ({ ...prev, components: newComponents })); - const hasHidden = layout.components.some( - (c) => c.type === "widget" && (c.style as any)?.labelDisplay === false - ); - toast.success(hasHidden ? "모든 라벨 표시" : "모든 라벨 숨기기"); - }, [layout, saveToHistory]); + // 강제 리렌더링 트리거 + setForceRenderTrigger((prev) => prev + 1); + + const scope = isPartial ? `선택된 ${targetComponents.length}개` : "모든"; + toast.success(hadHidden ? `${scope} 라벨 표시` : `${scope} 라벨 숨기기`); + }, [layout, saveToHistory, groupState.selectedComponents]); // Nudge (화살표 키 이동) const handleNudge = useCallback( @@ -2145,7 +2179,12 @@ export default function ScreenDesigner({ if (USE_POP_API) { await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); } else if (USE_V2_API) { - await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); + // 현재 활성 레이어 ID 포함 (레이어별 저장) + const currentLayerId = activeLayerIdRef.current || 1; + await screenApi.saveLayoutV2(selectedScreen.screenId, { + ...v2Layout, + layerId: currentLayerId, + }); } else { await screenApi.saveLayout(selectedScreen.screenId, updatedLayout); } @@ -3049,9 +3088,13 @@ export default function ScreenDesigner({ }) : null; - // 캔버스 경계 내로 위치 제한 - const boundedX = Math.max(0, Math.min(dropX, screenResolution.width - componentWidth)); - const boundedY = Math.max(0, Math.min(dropY, screenResolution.height - componentHeight)); + // 캔버스 경계 내로 위치 제한 (조건부 레이어 편집 시 Zone 크기 기준) + const currentLayerId = activeLayerIdRef.current || 1; + const activeLayerRegion = currentLayerId > 1 ? activeLayerZone : null; + const canvasBoundW = activeLayerRegion ? activeLayerRegion.width : screenResolution.width; + const canvasBoundH = activeLayerRegion ? activeLayerRegion.height : screenResolution.height; + const boundedX = Math.max(0, Math.min(dropX, canvasBoundW - componentWidth)); + const boundedY = Math.max(0, Math.min(dropY, canvasBoundH - componentHeight)); // 격자 스냅 적용 const snappedPosition = @@ -3323,37 +3366,27 @@ export default function ScreenDesigner({ return; } - // 🆕 조건부 레이어 영역 드래그인 경우 → DB condition_config에 displayRegion 저장 - if (parsedData.type === "layer-region" && parsedData.layerId && selectedScreen?.screenId) { + // 🆕 조건부 영역(Zone) 생성 드래그인 경우 → DB screen_conditional_zones에 저장 + if (parsedData.type === "create-zone" && selectedScreen?.screenId) { const canvasRect = canvasRef.current?.getBoundingClientRect(); if (!canvasRect) return; const dropX = Math.round((e.clientX - canvasRect.left) / zoomLevel); const dropY = Math.round((e.clientY - canvasRect.top) / zoomLevel); - const newRegion = { - x: Math.max(0, dropX - 400), - y: Math.max(0, dropY), - width: Math.min(800, screenResolution.width), - height: 200, - }; - // DB에 displayRegion 저장 (condition_config에 포함) try { - // 기존 condition_config를 가져와서 displayRegion만 추가/업데이트 - const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, parsedData.layerId); - const existingCondition = layerData?.conditionConfig || {}; - await screenApi.updateLayerCondition( - selectedScreen.screenId, - parsedData.layerId, - { ...existingCondition, displayRegion: newRegion } - ); - // 레이어 영역 state에 반영 (캔버스에 즉시 표시) - setLayerRegions((prev) => ({ - ...prev, - [parsedData.layerId]: { ...newRegion, layerName: parsedData.layerName }, - })); - toast.success(`"${parsedData.layerName}" 영역이 배치되었습니다.`); + await screenApi.createZone(selectedScreen.screenId, { + zone_name: "조건부 영역", + x: Math.max(0, dropX - 400), + y: Math.max(0, dropY), + width: Math.min(800, screenResolution.width), + height: 200, + }); + // Zone 목록 새로고침 + const loadedZones = await screenApi.getScreenZones(selectedScreen.screenId); + setZones(loadedZones); + toast.success("조건부 영역이 생성되었습니다."); } catch (error) { - console.error("레이어 영역 저장 실패:", error); - toast.error("레이어 영역 저장에 실패했습니다."); + console.error("Zone 생성 실패:", error); + toast.error("조건부 영역 생성에 실패했습니다."); } return; } @@ -4265,9 +4298,15 @@ export default function ScreenDesigner({ const rawX = relativeMouseX - dragState.grabOffset.x; const rawY = relativeMouseY - dragState.grabOffset.y; + // 조건부 레이어 편집 시 Zone 크기 기준 경계 제한 + const dragLayerId = activeLayerIdRef.current || 1; + const dragLayerRegion = dragLayerId > 1 ? activeLayerZone : null; + const dragBoundW = dragLayerRegion ? dragLayerRegion.width : screenResolution.width; + const dragBoundH = dragLayerRegion ? dragLayerRegion.height : screenResolution.height; + const newPosition = { - x: Math.max(0, Math.min(rawX, screenResolution.width - componentWidth)), - y: Math.max(0, Math.min(rawY, screenResolution.height - componentHeight)), + x: Math.max(0, Math.min(rawX, dragBoundW - componentWidth)), + y: Math.max(0, Math.min(rawY, dragBoundH - componentHeight)), z: (dragState.draggedComponent.position as Position).z || 1, }; @@ -5560,7 +5599,12 @@ export default function ScreenDesigner({ if (USE_POP_API) { await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); } else if (USE_V2_API) { - await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); + // 현재 활성 레이어 ID 포함 (레이어별 저장) + const currentLayerId = activeLayerIdRef.current || 1; + await screenApi.saveLayoutV2(selectedScreen.screenId, { + ...v2Layout, + layerId: currentLayerId, + }); } else { await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); } @@ -5762,9 +5806,9 @@ export default function ScreenDesigner({ ) => { e.stopPropagation(); e.preventDefault(); - const lid = Number(layerId); - const region = layerRegions[lid]; - if (!region) return; + const zoneId = Number(layerId); // layerId는 실제로 zoneId + const zone = zones.find(z => z.zone_id === zoneId); + if (!zone) return; const canvasRect = canvasRef.current?.getBoundingClientRect(); if (!canvasRect) return; @@ -5776,15 +5820,15 @@ export default function ScreenDesigner({ isDrawing: false, isDragging: mode === "move", isResizing: mode === "resize", - targetLayerId: layerId, + targetLayerId: String(zoneId), startX: x, startY: y, currentX: x, currentY: y, resizeHandle: handle || null, - originalRegion: { x: region.x, y: region.y, width: region.width, height: region.height }, + originalRegion: { x: zone.x, y: zone.y, width: zone.width, height: zone.height }, }); - }, [layerRegions, zoomLevel]); + }, [zones, zoomLevel]); // 🆕 캔버스 마우스 이벤트 (영역 이동/리사이즈) const handleRegionCanvasMouseMove = useCallback((e: React.MouseEvent) => { @@ -5806,11 +5850,8 @@ export default function ScreenDesigner({ width: regionDrag.originalRegion.width, height: regionDrag.originalRegion.height, }; - const lid = Number(regionDrag.targetLayerId); - setLayerRegions((prev) => ({ - ...prev, - [lid]: { ...prev[lid], ...newRegion }, - })); + const zoneId = Number(regionDrag.targetLayerId); + setZones((prev) => prev.map(z => z.zone_id === zoneId ? { ...z, ...newRegion } : z)); } else if (regionDrag.isResizing && regionDrag.originalRegion) { const dx = x - regionDrag.startX; const dy = y - regionDrag.startY; @@ -5829,29 +5870,23 @@ export default function ScreenDesigner({ newRegion.height = Math.max(30, Math.round(orig.height - dy)); } - const lid = Number(regionDrag.targetLayerId); - setLayerRegions((prev) => ({ - ...prev, - [lid]: { ...prev[lid], ...newRegion }, - })); + const zoneId = Number(regionDrag.targetLayerId); + setZones((prev) => prev.map(z => z.zone_id === zoneId ? { ...z, ...newRegion } : z)); } }, [regionDrag, zoomLevel]); const handleRegionCanvasMouseUp = useCallback(async () => { - // 드래그 완료 시 DB에 영역 저장 - if ((regionDrag.isDragging || regionDrag.isResizing) && regionDrag.targetLayerId && selectedScreen?.screenId) { - const lid = Number(regionDrag.targetLayerId); - const region = layerRegions[lid]; - if (region) { + // 드래그 완료 시 DB에 Zone 저장 + if ((regionDrag.isDragging || regionDrag.isResizing) && regionDrag.targetLayerId) { + const zoneId = Number(regionDrag.targetLayerId); + const zone = zones.find(z => z.zone_id === zoneId); + if (zone) { try { - const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, lid); - const existingCondition = layerData?.conditionConfig || {}; - await screenApi.updateLayerCondition( - selectedScreen.screenId, lid, - { ...existingCondition, displayRegion: { x: region.x, y: region.y, width: region.width, height: region.height } } - ); + await screenApi.updateZone(zoneId, { + x: zone.x, y: zone.y, width: zone.width, height: zone.height, + }); } catch { - console.error("영역 저장 실패"); + console.error("Zone 저장 실패"); } } } @@ -5865,34 +5900,15 @@ export default function ScreenDesigner({ resizeHandle: null, originalRegion: null, }); - }, [regionDrag, layerRegions, selectedScreen]); + }, [regionDrag, zones]); // 🆕 레이어 변경 핸들러 - 레이어 컨텍스트에서 레이어가 변경되면 layout에도 반영 - // 주의: layout.layers에 직접 설정된 displayRegion 등 메타데이터를 보존 + // Zone 기반이므로 displayRegion 보존 불필요 const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => { - setLayout((prevLayout) => { - // 기존 layout.layers의 메타데이터(displayRegion 등)를 보존하며 병합 - const mergedLayers = newLayers.map((newLayer) => { - const existingLayer = prevLayout.layers?.find((l) => l.id === newLayer.id); - if (!existingLayer) return newLayer; - - // LayerContext에서 온 데이터(condition 등)를 우선하되, - // layout.layers에만 있는 데이터(캔버스에서 직접 수정한 displayRegion)도 보존 - return { - ...existingLayer, // 기존 메타데이터 보존 (displayRegion 등) - ...newLayer, // LayerContext 데이터 우선 (condition, name, isVisible 등) - // displayRegion: 양쪽 모두 있을 수 있으므로 최신 값 우선 - displayRegion: newLayer.displayRegion !== undefined - ? newLayer.displayRegion - : existingLayer.displayRegion, - }; - }); - - return { - ...prevLayout, - layers: mergedLayers, - }; - }); + setLayout((prevLayout) => ({ + ...prevLayout, + layers: newLayers, + })); }, []); // 🆕 활성 레이어 변경 핸들러 @@ -6046,6 +6062,8 @@ export default function ScreenDesigner({ } }} components={layout.components} + zones={zones} + onZonesChange={setZones} /> @@ -6623,28 +6641,50 @@ export default function ScreenDesigner({ {activeLayerId > 1 && (
- 레이어 {activeLayerId} 편집 중 + + 레이어 {activeLayerId} 편집 중 + {activeLayerZone && ( + + (캔버스: {activeLayerZone.width} x {activeLayerZone.height}px - {activeLayerZone.zone_name}) + + )} + {!activeLayerZone && ( + + (조건부 영역 미설정 - 기본 레이어에서 Zone을 먼저 생성하세요) + + )} +
)} {/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */} + {(() => { + // 🆕 조건부 레이어 편집 시 캔버스 크기를 Zone에 맞춤 + const activeRegion = activeLayerId > 1 ? activeLayerZone : null; + const canvasW = activeRegion ? activeRegion.width : screenResolution.width; + const canvasH = activeRegion ? activeRegion.height : screenResolution.height; + + return (
- {/* 실제 작업 캔버스 (해상도 크기) - 고정 크기 + 줌 적용 */} + {/* 실제 작업 캔버스 (해상도 크기 또는 조건부 레이어 영역 크기) */}
- {/* 조건부 레이어 영역 (기본 레이어에서만 표시, DB 기반) */} - {activeLayerId === 1 && Object.entries(layerRegions).map(([layerIdStr, region]) => { - const layerId = Number(layerIdStr); + {/* 조건부 영역(Zone) (기본 레이어에서만 표시, DB 기반) */} + {/* 내부는 pointerEvents: none으로 아래 컴포넌트 클릭/드래그 통과 */} + {activeLayerId === 1 && zones.map((zone) => { + const layerId = zone.zone_id; // 렌더링용 ID + const region = zone; const resizeHandles = ["nw", "ne", "sw", "se", "n", "s", "e", "w"]; const handleCursors: Record = { nw: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", se: "nwse-resize", @@ -6767,6 +6809,8 @@ export default function ScreenDesigner({ e: { top: "50%", right: -4, transform: "translateY(-50%)" }, w: { top: "50%", left: -4, transform: "translateY(-50%)" }, }; + // 테두리 두께 (이동 핸들 영역) + const borderWidth = 6; return (
handleRegionMouseDown(e, String(layerId), "move")} > - - 레이어 {layerId} - {region.layerName} + {/* 테두리 이동 핸들: 상/하/좌/우 얇은 영역만 pointerEvents 활성 */} + {/* 상단 */} +
handleRegionMouseDown(e, String(layerId), "move")} + /> + {/* 하단 */} +
handleRegionMouseDown(e, String(layerId), "move")} + /> + {/* 좌측 */} +
handleRegionMouseDown(e, String(layerId), "move")} + /> + {/* 우측 */} +
handleRegionMouseDown(e, String(layerId), "move")} + /> + {/* 라벨 */} + handleRegionMouseDown(e, String(layerId), "move")} + > + Zone {zone.zone_id} - {zone.zone_name} {/* 리사이즈 핸들 */} {resizeHandles.map((handle) => (
handleRegionMouseDown(e, String(layerId), "resize", handle)} /> ))} {/* 삭제 버튼 */}
-
{" "} - {/* 🔥 줌 래퍼 닫기 */} +
+ ); /* 🔥 줌 래퍼 닫기 */ + })()}
{" "} {/* 메인 컨테이너 닫기 */} @@ -7448,4 +7516,4 @@ export default function ScreenDesigner({ ); -} +} \ No newline at end of file diff --git a/frontend/components/screen/ScreenSettingModal.tsx b/frontend/components/screen/ScreenSettingModal.tsx index 22c7af89..fa802893 100644 --- a/frontend/components/screen/ScreenSettingModal.tsx +++ b/frontend/components/screen/ScreenSettingModal.tsx @@ -134,7 +134,6 @@ interface ScreenSettingModalProps { fieldMappings?: FieldMappingInfo[]; componentCount?: number; onSaveSuccess?: () => void; - isPop?: boolean; // POP 화면 여부 } // 검색 가능한 Select 컴포넌트 @@ -240,7 +239,6 @@ export function ScreenSettingModal({ fieldMappings = [], componentCount = 0, onSaveSuccess, - isPop = false, }: ScreenSettingModalProps) { const [activeTab, setActiveTab] = useState("overview"); const [loading, setLoading] = useState(false); @@ -521,7 +519,6 @@ export function ScreenSettingModal({ iframeKey={iframeKey} canvasWidth={canvasSize.width} canvasHeight={canvasSize.height} - isPop={isPop} />
@@ -4634,10 +4631,9 @@ interface PreviewTabProps { iframeKey?: number; // iframe 새로고침용 키 canvasWidth?: number; // 화면 캔버스 너비 canvasHeight?: number; // 화면 캔버스 높이 - isPop?: boolean; // POP 화면 여부 } -function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight, isPop = false }: PreviewTabProps) { +function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight }: PreviewTabProps) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const containerRef = useRef(null); @@ -4691,18 +4687,12 @@ function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWi if (companyCode) { params.set("company_code", companyCode); } - // POP 화면일 경우 디바이스 타입 추가 - if (isPop) { - params.set("device", "tablet"); - } - // POP 화면과 데스크톱 화면 경로 분기 - const screenPath = isPop ? `/pop/screens/${screenId}` : `/screens/${screenId}`; if (typeof window !== "undefined") { const baseUrl = window.location.origin; - return `${baseUrl}${screenPath}?${params.toString()}`; + return `${baseUrl}/screens/${screenId}?${params.toString()}`; } - return `${screenPath}?${params.toString()}`; - }, [screenId, companyCode, isPop]); + return `/screens/${screenId}?${params.toString()}`; + }, [screenId, companyCode]); const handleIframeLoad = () => { setLoading(false); diff --git a/frontend/components/screen/StyleEditor.tsx b/frontend/components/screen/StyleEditor.tsx index f265115b..3add842c 100644 --- a/frontend/components/screen/StyleEditor.tsx +++ b/frontend/components/screen/StyleEditor.tsx @@ -33,6 +33,15 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd onStyleChange(newStyle); }; + // 숫자만 입력했을 때 자동으로 px 붙여주는 핸들러 + const autoPxProperties: (keyof ComponentStyle)[] = ["fontSize", "borderWidth", "borderRadius"]; + const handlePxBlur = (property: keyof ComponentStyle) => { + const val = localStyle[property]; + if (val && /^\d+(\.\d+)?$/.test(String(val))) { + handleStyleChange(property, `${val}px`); + } + }; + const toggleSection = (section: string) => { setOpenSections((prev) => ({ ...prev, @@ -66,6 +75,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd placeholder="1px" value={localStyle.borderWidth || ""} onChange={(e) => handleStyleChange("borderWidth", e.target.value)} + onBlur={() => handlePxBlur("borderWidth")} className="h-6 w-full px-2 py-0 text-xs" />
@@ -121,6 +131,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd placeholder="5px" value={localStyle.borderRadius || ""} onChange={(e) => handleStyleChange("borderRadius", e.target.value)} + onBlur={() => handlePxBlur("borderRadius")} className="h-6 w-full px-2 py-0 text-xs" />
@@ -209,6 +220,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd placeholder="14px" value={localStyle.fontSize || ""} onChange={(e) => handleStyleChange("fontSize", e.target.value)} + onBlur={() => handlePxBlur("fontSize")} className="h-6 w-full px-2 py-0 text-xs" /> diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 8d6df989..ea2febb1 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -3777,7 +3777,7 @@ export const ButtonConfigPanel: React.FC = ({ /** * 마스터-디테일 엑셀 업로드 설정 컴포넌트 - * 분할 패널 + column_labels에서 관계를 자동 감지하고, 사용자는 채번 규칙만 선택 + * 분할 패널 + column_labels에서 관계를 자동 감지 (채번은 테이블 타입 관리에서 자동 감지) */ const MasterDetailExcelUploadConfig: React.FC<{ config: any; @@ -4005,7 +4005,7 @@ const MasterDetailExcelUploadConfig: React.FC<{ {/* 마스터 키 자동 생성 안내 */} {relationInfo && (

- 마스터 테이블의 {relationInfo.masterKeyColumn} 값은 위에서 설정한 채번 규칙으로 자동 + 마스터 테이블의 {relationInfo.masterKeyColumn} 값은 테이블 타입 관리에서 설정된 채번 규칙으로 자동 생성됩니다.

)} @@ -4114,165 +4114,15 @@ const MasterDetailExcelUploadConfig: React.FC<{ }; /** - * 엑셀 업로드 채번 규칙 설정 (단일 테이블/마스터-디테일 모두 사용 가능) + * 엑셀 업로드 채번 규칙 안내 (테이블 타입 관리에서 자동 감지) */ -const ExcelNumberingRuleConfig: React.FC<{ - config: { numberingRuleId?: string; numberingTargetColumn?: string }; - updateConfig: (updates: { numberingRuleId?: string; numberingTargetColumn?: string }) => void; - tableName?: string; // 단일 테이블인 경우 테이블명 - hasSplitPanel?: boolean; // 분할 패널 여부 (마스터-디테일) -}> = ({ config, updateConfig, tableName, hasSplitPanel }) => { - const [numberingRules, setNumberingRules] = useState([]); - const [ruleSelectOpen, setRuleSelectOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [tableColumns, setTableColumns] = useState>([]); - const [columnsLoading, setColumnsLoading] = useState(false); - - // 채번 규칙 목록 로드 - useEffect(() => { - const loadNumberingRules = async () => { - setIsLoading(true); - try { - const { apiClient } = await import("@/lib/api/client"); - const response = await apiClient.get("/numbering-rules"); - if (response.data?.success && response.data?.data) { - setNumberingRules(response.data.data); - } - } catch (error) { - console.error("채번 규칙 목록 로드 실패:", error); - } finally { - setIsLoading(false); - } - }; - - loadNumberingRules(); - }, []); - - // 단일 테이블인 경우 컬럼 목록 로드 - useEffect(() => { - if (!tableName || hasSplitPanel) { - setTableColumns([]); - return; - } - - const loadColumns = async () => { - setColumnsLoading(true); - try { - const { apiClient } = await import("@/lib/api/client"); - const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); - if (response.data?.success && response.data?.data?.columns) { - const cols = response.data.data.columns.map((col: any) => ({ - columnName: col.columnName || col.column_name, - columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, - })); - setTableColumns(cols); - } - } catch (error) { - console.error("컬럼 목록 로드 실패:", error); - } finally { - setColumnsLoading(false); - } - }; - - loadColumns(); - }, [tableName, hasSplitPanel]); - - const selectedRule = numberingRules.find((r) => String(r.rule_id || r.ruleId) === String(config.numberingRuleId)); - +const ExcelNumberingRuleInfo: React.FC = () => { return (
-

- 업로드 시 자동으로 생성할 코드/번호의 채번 규칙을 선택하세요. +

+ 테이블 타입 관리에서 "채번" 타입으로 설정된 컬럼의 채번 규칙이 업로드 시 자동으로 적용됩니다.

- - - - - - - - - - 검색 결과 없음 - - { - updateConfig({ numberingRuleId: undefined, numberingTargetColumn: undefined }); - setRuleSelectOpen(false); - }} - className="text-xs" - > - - 채번 없음 - - {numberingRules.map((rule, idx) => { - const ruleId = String(rule.rule_id || rule.ruleId || `rule-${idx}`); - const ruleName = rule.rule_name || rule.ruleName || "(이름 없음)"; - return ( - { - updateConfig({ numberingRuleId: ruleId }); - setRuleSelectOpen(false); - }} - className="text-xs" - > - - {ruleName} - - ); - })} - - - - - - - {/* 단일 테이블이고 채번 규칙이 선택된 경우, 적용할 컬럼 선택 */} - {config.numberingRuleId && !hasSplitPanel && tableName && ( -
- - -

채번 값이 입력될 컬럼을 선택하세요.

-
- )} - - {/* 분할 패널인 경우 안내 메시지 */} - {config.numberingRuleId && hasSplitPanel && ( -

마스터-디테일 구조에서는 마스터 키 컬럼에 자동 적용됩니다.

- )}
); }; @@ -4440,14 +4290,10 @@ const ExcelUploadConfigSection: React.FC<{ allComponents: ComponentData[]; currentTableName?: string; // 현재 화면의 테이블명 (ButtonConfigPanel에서 전달) }> = ({ config, onUpdateProperty, allComponents, currentTableName: propTableName }) => { - // 엑셀 업로드 설정 상태 관리 + // 엑셀 업로드 설정 상태 관리 (채번은 테이블 타입 관리에서 자동 감지) const [excelUploadConfig, setExcelUploadConfig] = useState<{ - numberingRuleId?: string; - numberingTargetColumn?: string; afterUploadFlows?: Array<{ flowId: string; order: number }>; }>({ - numberingRuleId: config.action?.excelNumberingRuleId, - numberingTargetColumn: config.action?.excelNumberingTargetColumn, afterUploadFlows: config.action?.excelAfterUploadFlows || [], }); @@ -4529,17 +4375,11 @@ const ExcelUploadConfigSection: React.FC<{ ); }, [hasSplitPanel, singleTableName, propTableName]); - // 설정 업데이트 함수 + // 설정 업데이트 함수 (채번은 테이블 타입 관리에서 자동 감지되므로 제어 실행만 관리) const updateExcelUploadConfig = (updates: Partial) => { const newConfig = { ...excelUploadConfig, ...updates }; setExcelUploadConfig(newConfig); - if (updates.numberingRuleId !== undefined) { - onUpdateProperty("componentConfig.action.excelNumberingRuleId", updates.numberingRuleId); - } - if (updates.numberingTargetColumn !== undefined) { - onUpdateProperty("componentConfig.action.excelNumberingTargetColumn", updates.numberingTargetColumn); - } if (updates.afterUploadFlows !== undefined) { onUpdateProperty("componentConfig.action.excelAfterUploadFlows", updates.afterUploadFlows); } @@ -4548,15 +4388,9 @@ const ExcelUploadConfigSection: React.FC<{ // config 변경 시 로컬 상태 동기화 useEffect(() => { setExcelUploadConfig({ - numberingRuleId: config.action?.excelNumberingRuleId, - numberingTargetColumn: config.action?.excelNumberingTargetColumn, afterUploadFlows: config.action?.excelAfterUploadFlows || [], }); - }, [ - config.action?.excelNumberingRuleId, - config.action?.excelNumberingTargetColumn, - config.action?.excelAfterUploadFlows, - ]); + }, [config.action?.excelAfterUploadFlows]); return (
@@ -4595,13 +4429,8 @@ const ExcelUploadConfigSection: React.FC<{
)} - {/* 채번 규칙 설정 (항상 표시) */} - + {/* 채번 규칙 안내 (테이블 타입 관리에서 자동 감지) */} + {/* 업로드 후 제어 실행 (항상 표시) */} diff --git a/frontend/components/screen/panels/ComponentsPanel.tsx b/frontend/components/screen/panels/ComponentsPanel.tsx index 9464a204..a076b867 100644 --- a/frontend/components/screen/panels/ComponentsPanel.tsx +++ b/frontend/components/screen/panels/ComponentsPanel.tsx @@ -49,7 +49,6 @@ export function ComponentsPanel({ () => [ // v2-input: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리 - // v2-select: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리 // v2-date: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리 // v2-layout: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리 // v2-group: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리 @@ -57,6 +56,23 @@ export function ComponentsPanel({ // v2-media 제거 - 테이블 컬럼의 image/file 입력 타입으로 사용 // v2-biz 제거 - 개별 컴포넌트(flow-widget, rack-structure, numbering-rule)로 직접 표시 // v2-hierarchy 제거 - 현재 미사용 + { + id: "v2-select", + name: "V2 선택", + description: "드롭다운, 콤보박스, 라디오, 체크박스 등 다양한 선택 모드 지원", + category: "input" as ComponentCategory, + tags: ["select", "dropdown", "combobox", "v2"], + defaultSize: { width: 300, height: 40 }, + defaultConfig: { + mode: "dropdown", + source: "static", + multiple: false, + searchable: false, + placeholder: "선택하세요", + options: [], + allowClear: true, + }, + }, { id: "v2-repeater", name: "리피터 그리드", @@ -65,7 +81,7 @@ export function ComponentsPanel({ tags: ["repeater", "table", "modal", "button", "v2", "v2"], defaultSize: { width: 600, height: 300 }, }, - ] as ComponentDefinition[], + ] as unknown as ComponentDefinition[], [], ); @@ -109,8 +125,8 @@ export function ComponentsPanel({ "v2-media", // → 테이블 컬럼의 image/file 입력 타입으로 사용 // 플로우 위젯 숨김 처리 "flow-widget", - // 선택 항목 상세입력 - 기존 컴포넌트 조합으로 대체 가능 - "selected-items-detail-input", + // 선택 항목 상세입력 - 거래처 품목 추가 등에서 사용 + // "selected-items-detail-input", // 연관 데이터 버튼 - v2-repeater로 대체 가능 "related-data-buttons", // ===== V2로 대체된 기존 컴포넌트 (v2 버전만 사용) ===== @@ -126,6 +142,7 @@ export function ComponentsPanel({ "section-card", // → v2-section-card "location-swap-selector", // → v2-location-swap-selector "rack-structure", // → v2-rack-structure + "v2-select", // → v2-select (아래 v2Components에서 별도 처리) "v2-repeater", // → v2-repeater (아래 v2Components에서 별도 처리) "repeat-container", // → v2-repeat-container "repeat-screen-modal", // → v2-repeat-screen-modal diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 6c770e48..8f29e874 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { X, Loader2 } from "lucide-react"; @@ -26,6 +26,17 @@ interface TabsWidgetProps { isDesignMode?: boolean; onComponentSelect?: (tabId: string, componentId: string) => void; selectedComponentId?: string; + // 테이블 선택된 행 데이터 (버튼 활성화 및 수정/삭제 동작에 필요) + selectedRowsData?: any[]; + onSelectedRowsChange?: ( + selectedRows: any[], + selectedRowsData: any[], + sortBy?: string, + sortOrder?: "asc" | "desc", + columnOrder?: string[], + ) => void; + // 추가 props (부모에서 전달받은 나머지 props) + [key: string]: any; } export function TabsWidget({ @@ -38,6 +49,9 @@ export function TabsWidget({ isDesignMode = false, onComponentSelect, selectedComponentId, + selectedRowsData: _externalSelectedRowsData, + onSelectedRowsChange: externalOnSelectedRowsChange, + ...restProps }: TabsWidgetProps) { const { setActiveTab, removeTabsComponent } = useActiveTab(); const { @@ -51,6 +65,30 @@ export function TabsWidget({ const storageKey = `tabs-${component.id}-selected`; + // 탭 내부 자체 selectedRowsData 상태 관리 (항상 로컬 상태 사용) + // 부모에서 빈 배열 []이 전달되어도 로컬 상태를 우선하여 탭 내부 버튼이 즉시 인식 + const [localSelectedRowsData, setLocalSelectedRowsData] = useState([]); + + // 선택 변경 핸들러: 로컬 상태 업데이트 + 부모 콜백 호출 + const handleSelectedRowsChange = useCallback( + ( + selectedRows: any[], + selectedRowsDataNew: any[], + sortBy?: string, + sortOrder?: "asc" | "desc", + columnOrder?: string[], + ) => { + // 로컬 상태 업데이트 (탭 내부 버튼이 즉시 인식) + setLocalSelectedRowsData(selectedRowsDataNew); + + // 부모 콜백 호출 (부모 상태도 업데이트) + if (externalOnSelectedRowsChange) { + externalOnSelectedRowsChange(selectedRows, selectedRowsDataNew, sortBy, sortOrder, columnOrder); + } + }, + [externalOnSelectedRowsChange], + ); + // 초기 선택 탭 결정 const getInitialTab = () => { if (persistSelection && typeof window !== "undefined") { @@ -97,6 +135,8 @@ export function TabsWidget({ const [screenLayouts, setScreenLayouts] = useState>({}); const [screenLoadingStates, setScreenLoadingStates] = useState>({}); const [screenErrors, setScreenErrors] = useState>({}); + // 탭별 화면 정보 (screenId, tableName) 저장 + const [screenInfoMap, setScreenInfoMap] = useState>({}); // 컴포넌트 탭 목록 변경 시 동기화 useEffect(() => { @@ -117,10 +157,21 @@ export function TabsWidget({ ) { setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: true })); try { - const layoutData = await screenApi.getLayout(extTab.screenId); + // 레이아웃과 화면 정보를 병렬로 로드 + const [layoutData, screenDef] = await Promise.all([ + screenApi.getLayout(extTab.screenId), + screenApi.getScreen(extTab.screenId), + ]); if (layoutData && layoutData.components) { setScreenLayouts((prev) => ({ ...prev, [tab.id]: layoutData.components })); } + // 탭의 화면 정보 저장 (tableName 포함) + if (screenDef) { + setScreenInfoMap((prev) => ({ + ...prev, + [tab.id]: { id: extTab.screenId!, tableName: screenDef.tableName }, + })); + } } catch (error) { console.error(`탭 "${tab.label}" 화면 로드 실패:`, error); setScreenErrors((prev) => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." })); @@ -134,6 +185,31 @@ export function TabsWidget({ loadScreenLayouts(); }, [visibleTabs, screenLayouts, screenLoadingStates]); + // screenInfoMap이 없는 탭의 화면 정보 보충 로드 + // screenId가 있지만 screenInfoMap에 아직 없는 탭의 화면 정보를 로드 + useEffect(() => { + const loadMissingScreenInfo = async () => { + for (const tab of visibleTabs) { + const extTab = tab as ExtendedTabItem; + // screenId가 있고 screenInfoMap에 아직 없는 경우 로드 + if (extTab.screenId && !screenInfoMap[tab.id]) { + try { + const screenDef = await screenApi.getScreen(extTab.screenId); + if (screenDef) { + setScreenInfoMap((prev) => ({ + ...prev, + [tab.id]: { id: extTab.screenId!, tableName: screenDef.tableName }, + })); + } + } catch (error) { + console.error(`탭 "${tab.label}" 화면 정보 로드 실패:`, error); + } + } + } + }; + loadMissingScreenInfo(); + }, [visibleTabs, screenInfoMap]); + // 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트 useEffect(() => { if (persistSelection && typeof window !== "undefined") { @@ -218,7 +294,7 @@ export function TabsWidget({ // 화면 레이아웃이 로드된 경우 const loadedComponents = screenLayouts[tab.id]; if (loadedComponents && loadedComponents.length > 0) { - return renderScreenComponents(loadedComponents); + return renderScreenComponents(tab, loadedComponents); } // 아직 로드되지 않은 경우 @@ -245,7 +321,7 @@ export function TabsWidget({ }; // screenId로 로드한 화면 컴포넌트 렌더링 - const renderScreenComponents = (components: ComponentData[]) => { + const renderScreenComponents = (tab: ExtendedTabItem, components: ComponentData[]) => { // InteractiveScreenViewerDynamic 동적 로드 const InteractiveScreenViewerDynamic = require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic; @@ -278,7 +354,10 @@ export function TabsWidget({ allComponents={components} formData={formData} onFormDataChange={onFormDataChange} + screenInfo={screenInfoMap[tab.id]} menuObjid={menuObjid} + parentTabId={tab.id} + parentTabsComponentId={component.id} /> ))} @@ -287,7 +366,7 @@ export function TabsWidget({ }; // 인라인 컴포넌트 렌더링 (v2 방식) - const renderInlineComponents = (tab: TabItem, components: TabInlineComponent[]) => { + const renderInlineComponents = (tab: ExtendedTabItem, components: TabInlineComponent[]) => { // 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보 const maxBottom = Math.max( ...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), @@ -331,6 +410,7 @@ export function TabsWidget({ }} > ); diff --git a/frontend/components/ui/alert-dialog.tsx b/frontend/components/ui/alert-dialog.tsx index b4d0cae5..3c7a9239 100644 --- a/frontend/components/ui/alert-dialog.tsx +++ b/frontend/components/ui/alert-dialog.tsx @@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( ( type={type} data-slot="input" className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className, diff --git a/frontend/components/ui/popover.tsx b/frontend/components/ui/popover.tsx index ac22af29..c9138ac2 100644 --- a/frontend/components/ui/popover.tsx +++ b/frontend/components/ui/popover.tsx @@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cn( - "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[2000] w-72 rounded-md border p-4 shadow-md outline-none", + "bg-popover text-popover-foreground z-[2000] w-72 rounded-md border p-4 shadow-md outline-none", className, )} {...props} diff --git a/frontend/components/ui/sheet.tsx b/frontend/components/ui/sheet.tsx index dd9ef97d..c11a79a0 100644 --- a/frontend/components/ui/sheet.tsx +++ b/frontend/components/ui/sheet.tsx @@ -16,16 +16,14 @@ const SheetClose = SheetPrimitive.Close; const SheetPortal = SheetPrimitive.Portal; const sheetVariants = cva( - "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", + "fixed z-50 gap-4 bg-background p-6 shadow-lg", { variants: { side: { - top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", - bottom: - "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", - left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", - right: - "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + top: "inset-x-0 top-0 border-b", + bottom: "inset-x-0 bottom-0 border-t", + left: "inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm", + right: "inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm", }, }, defaultVariants: { @@ -60,7 +58,7 @@ const SheetOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( = { - none: { pattern: /.*/, placeholder: "" }, - email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, placeholder: "example@email.com" }, - tel: { pattern: /^\d{2,3}-\d{3,4}-\d{4}$/, placeholder: "010-1234-5678" }, - url: { pattern: /^https?:\/\/.+/, placeholder: "https://example.com" }, - currency: { pattern: /^[\d,]+$/, placeholder: "1,000,000" }, - biz_no: { pattern: /^\d{3}-\d{2}-\d{5}$/, placeholder: "123-45-67890" }, +const FORMAT_PATTERNS: Record = { + none: { pattern: /.*/, placeholder: "", errorMessage: "" }, + email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, placeholder: "example@email.com", errorMessage: "올바른 이메일 형식이 아닙니다" }, + tel: { pattern: /^\d{2,3}-\d{3,4}-\d{4}$/, placeholder: "010-1234-5678", errorMessage: "올바른 전화번호 형식이 아닙니다" }, + url: { pattern: /^https?:\/\/.+/, placeholder: "https://example.com", errorMessage: "올바른 URL 형식이 아닙니다 (https://로 시작)" }, + currency: { pattern: /^[\d,]+$/, placeholder: "1,000,000", errorMessage: "숫자만 입력 가능합니다" }, + biz_no: { pattern: /^\d{3}-\d{2}-\d{5}$/, placeholder: "123-45-67890", errorMessage: "올바른 사업자번호 형식이 아닙니다" }, }; +// 형식 검증 함수 (외부에서도 사용 가능) +export function validateInputFormat(value: string, format: V2InputFormat): { isValid: boolean; errorMessage: string } { + if (!value || value.trim() === "" || format === "none") { + return { isValid: true, errorMessage: "" }; + } + const formatConfig = FORMAT_PATTERNS[format]; + if (!formatConfig) return { isValid: true, errorMessage: "" }; + const isValid = formatConfig.pattern.test(value); + return { isValid, errorMessage: isValid ? "" : formatConfig.errorMessage }; +} + // 통화 형식 변환 function formatCurrency(value: string | number): string { const num = typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value; @@ -70,8 +81,14 @@ const TextInput = forwardRef< readonly?: boolean; disabled?: boolean; className?: string; + columnName?: string; + inputStyle?: React.CSSProperties; } ->(({ value, onChange, format = "none", placeholder, readonly, disabled, className }, ref) => { +>(({ value, onChange, format = "none", placeholder, readonly, disabled, className, columnName, inputStyle }, ref) => { + // 검증 상태 + const [hasBlurred, setHasBlurred] = useState(false); + const [validationError, setValidationError] = useState(""); + // 형식에 따른 값 포맷팅 const formatValue = useCallback( (val: string): string => { @@ -104,29 +121,102 @@ const TextInput = forwardRef< newValue = formatTel(newValue); } + // 입력 중 에러 표시 해제 (입력 중에는 관대하게) + if (hasBlurred && validationError) { + const { isValid } = validateInputFormat(newValue, format); + if (isValid) { + setValidationError(""); + } + } + onChange?.(newValue); }, - [format, onChange], + [format, onChange, hasBlurred, validationError], ); + // blur 시 형식 검증 + const handleBlur = useCallback(() => { + setHasBlurred(true); + const currentValue = value !== undefined && value !== null ? String(value) : ""; + if (currentValue && format !== "none") { + const { isValid, errorMessage } = validateInputFormat(currentValue, format); + setValidationError(isValid ? "" : errorMessage); + } else { + setValidationError(""); + } + }, [value, format]); + + // 값 변경 시 검증 상태 업데이트 + useEffect(() => { + if (hasBlurred) { + const currentValue = value !== undefined && value !== null ? String(value) : ""; + if (currentValue && format !== "none") { + const { isValid, errorMessage } = validateInputFormat(currentValue, format); + setValidationError(isValid ? "" : errorMessage); + } else { + setValidationError(""); + } + } + }, [value, format, hasBlurred]); + + // 글로벌 폼 검증 이벤트 리스너 (저장 시 호출) + useEffect(() => { + if (format === "none" || !columnName) return; + + const handleValidateForm = (event: CustomEvent) => { + const currentValue = value !== undefined && value !== null ? String(value) : ""; + if (currentValue) { + const { isValid, errorMessage } = validateInputFormat(currentValue, format); + if (!isValid) { + setHasBlurred(true); + setValidationError(errorMessage); + // 검증 결과를 이벤트에 기록 + if (event.detail?.errors) { + event.detail.errors.push({ + columnName, + message: errorMessage, + }); + } + } + } + }; + + window.addEventListener("validateFormInputs", handleValidateForm as EventListener); + return () => { + window.removeEventListener("validateFormInputs", handleValidateForm as EventListener); + }; + }, [format, value, columnName]); + const displayValue = useMemo(() => { if (value === undefined || value === null) return ""; return formatValue(String(value)); }, [value, formatValue]); const inputPlaceholder = placeholder || FORMAT_PATTERNS[format].placeholder; + const hasError = hasBlurred && !!validationError; return ( - +
+ + {hasError && ( +

{validationError}

+ )} +
); }); TextInput.displayName = "TextInput"; @@ -146,8 +236,9 @@ const NumberInput = forwardRef< readonly?: boolean; disabled?: boolean; className?: string; + inputStyle?: React.CSSProperties; } ->(({ value, onChange, min, max, step = 1, placeholder, readonly, disabled, className }, ref) => { +>(({ value, onChange, min, max, step = 1, placeholder, readonly, disabled, className, inputStyle }, ref) => { const handleChange = useCallback( (e: React.ChangeEvent) => { const val = e.target.value; @@ -180,6 +271,7 @@ const NumberInput = forwardRef< readOnly={readonly} disabled={disabled} className={cn("h-full w-full", className)} + style={inputStyle} /> ); }); @@ -197,8 +289,9 @@ const PasswordInput = forwardRef< readonly?: boolean; disabled?: boolean; className?: string; + inputStyle?: React.CSSProperties; } ->(({ value, onChange, placeholder, readonly, disabled, className }, ref) => { +>(({ value, onChange, placeholder, readonly, disabled, className, inputStyle }, ref) => { const [showPassword, setShowPassword] = useState(false); return ( @@ -212,6 +305,7 @@ const PasswordInput = forwardRef< readOnly={readonly} disabled={disabled} className={cn("h-full w-full pr-10", className)} + style={inputStyle} />