diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 53a4fa4d..5c2415ea 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -76,6 +76,11 @@ import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리 +import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리 +import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자동 입력 관리 +import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리 +import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리 +import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -247,6 +252,11 @@ app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색 app.use("/api/orders", orderRoutes); // 수주 관리 app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리 app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리 +app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리 +app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 관리 +app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리 +app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리 +app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 5bcda820..a28712c1 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1256,8 +1256,17 @@ export async function updateMenu( } } - const requestCompanyCode = - menuData.companyCode || menuData.company_code || currentMenu.company_code; + let requestCompanyCode = + menuData.companyCode || menuData.company_code; + + // "none"이나 빈 값은 기존 메뉴의 회사 코드 유지 + if ( + requestCompanyCode === "none" || + requestCompanyCode === "" || + !requestCompanyCode + ) { + requestCompanyCode = currentMenu.company_code; + } // company_code 변경 시도하는 경우 권한 체크 if (requestCompanyCode !== currentMenu.company_code) { diff --git a/backend-node/src/controllers/cascadingAutoFillController.ts b/backend-node/src/controllers/cascadingAutoFillController.ts new file mode 100644 index 00000000..bf033880 --- /dev/null +++ b/backend-node/src/controllers/cascadingAutoFillController.ts @@ -0,0 +1,568 @@ +/** + * 자동 입력 (Auto-Fill) 컨트롤러 + * 마스터 선택 시 여러 필드 자동 입력 기능 + */ + +import { Request, Response } from "express"; +import { query, queryOne } from "../database/db"; +import logger from "../utils/logger"; + +// ===================================================== +// 자동 입력 그룹 CRUD +// ===================================================== + +/** + * 자동 입력 그룹 목록 조회 + */ +export const getAutoFillGroups = async (req: Request, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { isActive } = req.query; + + let sql = ` + SELECT + g.*, + COUNT(m.mapping_id) as mapping_count + FROM cascading_auto_fill_group g + LEFT JOIN cascading_auto_fill_mapping m + ON g.group_code = m.group_code AND g.company_code = m.company_code + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + // 회사 필터 + if (companyCode !== "*") { + sql += ` AND g.company_code = $${paramIndex++}`; + params.push(companyCode); + } + + // 활성 상태 필터 + if (isActive) { + sql += ` AND g.is_active = $${paramIndex++}`; + params.push(isActive); + } + + sql += ` GROUP BY g.group_id ORDER BY g.group_name`; + + const result = await query(sql, params); + + logger.info("자동 입력 그룹 목록 조회", { count: result.length, companyCode }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + logger.error("자동 입력 그룹 목록 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "자동 입력 그룹 목록 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 자동 입력 그룹 상세 조회 (매핑 포함) + */ +export const getAutoFillGroupDetail = async (req: Request, res: Response) => { + try { + const { groupCode } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 그룹 정보 조회 + let groupSql = ` + SELECT * FROM cascading_auto_fill_group + WHERE group_code = $1 + `; + const groupParams: any[] = [groupCode]; + + if (companyCode !== "*") { + groupSql += ` AND company_code = $2`; + groupParams.push(companyCode); + } + + const groupResult = await queryOne(groupSql, groupParams); + + if (!groupResult) { + return res.status(404).json({ + success: false, + message: "자동 입력 그룹을 찾을 수 없습니다.", + }); + } + + // 매핑 정보 조회 + const mappingSql = ` + SELECT * FROM cascading_auto_fill_mapping + WHERE group_code = $1 AND company_code = $2 + ORDER BY sort_order, mapping_id + `; + const mappingResult = await query(mappingSql, [groupCode, groupResult.company_code]); + + logger.info("자동 입력 그룹 상세 조회", { groupCode, companyCode }); + + res.json({ + success: true, + data: { + ...groupResult, + mappings: mappingResult, + }, + }); + } catch (error: any) { + logger.error("자동 입력 그룹 상세 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "자동 입력 그룹 상세 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 그룹 코드 자동 생성 함수 + */ +const generateAutoFillGroupCode = async (companyCode: string): Promise => { + const prefix = "AF"; + const result = await queryOne( + `SELECT COUNT(*) as cnt FROM cascading_auto_fill_group WHERE company_code = $1`, + [companyCode] + ); + const count = parseInt(result?.cnt || "0", 10) + 1; + const timestamp = Date.now().toString(36).toUpperCase().slice(-4); + return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`; +}; + +/** + * 자동 입력 그룹 생성 + */ +export const createAutoFillGroup = async (req: Request, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + const { + groupName, + description, + masterTable, + masterValueColumn, + masterLabelColumn, + mappings = [], + } = req.body; + + // 필수 필드 검증 + if (!groupName || !masterTable || !masterValueColumn) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (groupName, masterTable, masterValueColumn)", + }); + } + + // 그룹 코드 자동 생성 + const groupCode = await generateAutoFillGroupCode(companyCode); + + // 그룹 생성 + const insertGroupSql = ` + INSERT INTO cascading_auto_fill_group ( + group_code, group_name, description, + master_table, master_value_column, master_label_column, + company_code, is_active, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', CURRENT_TIMESTAMP) + RETURNING * + `; + + const groupResult = await queryOne(insertGroupSql, [ + groupCode, + groupName, + description || null, + masterTable, + masterValueColumn, + masterLabelColumn || null, + companyCode, + ]); + + // 매핑 생성 + if (mappings.length > 0) { + for (let i = 0; i < mappings.length; i++) { + const m = mappings[i]; + await query( + `INSERT INTO cascading_auto_fill_mapping ( + group_code, company_code, source_column, target_field, target_label, + is_editable, is_required, default_value, sort_order + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + groupCode, + companyCode, + m.sourceColumn, + m.targetField, + m.targetLabel || null, + m.isEditable || "Y", + m.isRequired || "N", + m.defaultValue || null, + m.sortOrder || i + 1, + ] + ); + } + } + + logger.info("자동 입력 그룹 생성", { groupCode, companyCode, userId }); + + res.status(201).json({ + success: true, + message: "자동 입력 그룹이 생성되었습니다.", + data: groupResult, + }); + } catch (error: any) { + logger.error("자동 입력 그룹 생성 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "자동 입력 그룹 생성에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 자동 입력 그룹 수정 + */ +export const updateAutoFillGroup = async (req: Request, res: Response) => { + try { + const { groupCode } = req.params; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + const { + groupName, + description, + masterTable, + masterValueColumn, + masterLabelColumn, + isActive, + mappings, + } = req.body; + + // 기존 그룹 확인 + let checkSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1`; + const checkParams: any[] = [groupCode]; + + if (companyCode !== "*") { + checkSql += ` AND company_code = $2`; + checkParams.push(companyCode); + } + + const existing = await queryOne(checkSql, checkParams); + + if (!existing) { + return res.status(404).json({ + success: false, + message: "자동 입력 그룹을 찾을 수 없습니다.", + }); + } + + // 그룹 업데이트 + const updateSql = ` + UPDATE cascading_auto_fill_group SET + group_name = COALESCE($1, group_name), + description = COALESCE($2, description), + master_table = COALESCE($3, master_table), + master_value_column = COALESCE($4, master_value_column), + master_label_column = COALESCE($5, master_label_column), + is_active = COALESCE($6, is_active), + updated_date = CURRENT_TIMESTAMP + WHERE group_code = $7 AND company_code = $8 + RETURNING * + `; + + const updateResult = await queryOne(updateSql, [ + groupName, + description, + masterTable, + masterValueColumn, + masterLabelColumn, + isActive, + groupCode, + existing.company_code, + ]); + + // 매핑 업데이트 (전체 교체 방식) + if (mappings !== undefined) { + // 기존 매핑 삭제 + await query( + `DELETE FROM cascading_auto_fill_mapping WHERE group_code = $1 AND company_code = $2`, + [groupCode, existing.company_code] + ); + + // 새 매핑 추가 + for (let i = 0; i < mappings.length; i++) { + const m = mappings[i]; + await query( + `INSERT INTO cascading_auto_fill_mapping ( + group_code, company_code, source_column, target_field, target_label, + is_editable, is_required, default_value, sort_order + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + groupCode, + existing.company_code, + m.sourceColumn, + m.targetField, + m.targetLabel || null, + m.isEditable || "Y", + m.isRequired || "N", + m.defaultValue || null, + m.sortOrder || i + 1, + ] + ); + } + } + + logger.info("자동 입력 그룹 수정", { groupCode, companyCode, userId }); + + res.json({ + success: true, + message: "자동 입력 그룹이 수정되었습니다.", + data: updateResult, + }); + } catch (error: any) { + logger.error("자동 입력 그룹 수정 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "자동 입력 그룹 수정에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 자동 입력 그룹 삭제 + */ +export const deleteAutoFillGroup = async (req: Request, res: Response) => { + try { + const { groupCode } = req.params; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + let deleteSql = `DELETE FROM cascading_auto_fill_group WHERE group_code = $1`; + const deleteParams: any[] = [groupCode]; + + if (companyCode !== "*") { + deleteSql += ` AND company_code = $2`; + deleteParams.push(companyCode); + } + + deleteSql += ` RETURNING group_code`; + + const result = await queryOne(deleteSql, deleteParams); + + if (!result) { + return res.status(404).json({ + success: false, + message: "자동 입력 그룹을 찾을 수 없습니다.", + }); + } + + logger.info("자동 입력 그룹 삭제", { groupCode, companyCode, userId }); + + res.json({ + success: true, + message: "자동 입력 그룹이 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("자동 입력 그룹 삭제 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "자동 입력 그룹 삭제에 실패했습니다.", + error: error.message, + }); + } +}; + +// ===================================================== +// 자동 입력 데이터 조회 (실제 사용) +// ===================================================== + +/** + * 마스터 옵션 목록 조회 + * 자동 입력 그룹의 마스터 테이블에서 선택 가능한 옵션 목록 + */ +export const getAutoFillMasterOptions = async (req: Request, res: Response) => { + try { + const { groupCode } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 그룹 정보 조회 + let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`; + const groupParams: any[] = [groupCode]; + + if (companyCode !== "*") { + groupSql += ` AND company_code = $2`; + groupParams.push(companyCode); + } + + const group = await queryOne(groupSql, groupParams); + + if (!group) { + return res.status(404).json({ + success: false, + message: "자동 입력 그룹을 찾을 수 없습니다.", + }); + } + + // 마스터 테이블에서 옵션 조회 + const labelColumn = group.master_label_column || group.master_value_column; + let optionsSql = ` + SELECT + ${group.master_value_column} as value, + ${labelColumn} as label + FROM ${group.master_table} + WHERE 1=1 + `; + const optionsParams: any[] = []; + let paramIndex = 1; + + // 멀티테넌시 필터 (테이블에 company_code가 있는 경우) + if (companyCode !== "*") { + // company_code 컬럼 존재 여부 확인 + const columnCheck = await queryOne( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [group.master_table] + ); + + if (columnCheck) { + optionsSql += ` AND company_code = $${paramIndex++}`; + optionsParams.push(companyCode); + } + } + + optionsSql += ` ORDER BY ${labelColumn}`; + + const optionsResult = await query(optionsSql, optionsParams); + + logger.info("자동 입력 마스터 옵션 조회", { groupCode, count: optionsResult.length }); + + res.json({ + success: true, + data: optionsResult, + }); + } catch (error: any) { + logger.error("자동 입력 마스터 옵션 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "자동 입력 마스터 옵션 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 자동 입력 데이터 조회 + * 마스터 값 선택 시 자동으로 입력할 데이터 조회 + */ +export const getAutoFillData = async (req: Request, res: Response) => { + try { + const { groupCode } = req.params; + const { masterValue } = req.query; + const companyCode = req.user?.companyCode || "*"; + + if (!masterValue) { + return res.status(400).json({ + success: false, + message: "masterValue 파라미터가 필요합니다.", + }); + } + + // 그룹 정보 조회 + let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`; + const groupParams: any[] = [groupCode]; + + if (companyCode !== "*") { + groupSql += ` AND company_code = $2`; + groupParams.push(companyCode); + } + + const group = await queryOne(groupSql, groupParams); + + if (!group) { + return res.status(404).json({ + success: false, + message: "자동 입력 그룹을 찾을 수 없습니다.", + }); + } + + // 매핑 정보 조회 + const mappingSql = ` + SELECT * FROM cascading_auto_fill_mapping + WHERE group_code = $1 AND company_code = $2 + ORDER BY sort_order + `; + const mappings = await query(mappingSql, [groupCode, group.company_code]); + + if (mappings.length === 0) { + return res.json({ + success: true, + data: {}, + mappings: [], + }); + } + + // 마스터 테이블에서 데이터 조회 + const sourceColumns = mappings.map((m: any) => m.source_column).join(", "); + let dataSql = ` + SELECT ${sourceColumns} + FROM ${group.master_table} + WHERE ${group.master_value_column} = $1 + `; + const dataParams: any[] = [masterValue]; + let paramIndex = 2; + + // 멀티테넌시 필터 + if (companyCode !== "*") { + const columnCheck = await queryOne( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [group.master_table] + ); + + if (columnCheck) { + dataSql += ` AND company_code = $${paramIndex++}`; + dataParams.push(companyCode); + } + } + + const dataResult = await queryOne(dataSql, dataParams); + + // 결과를 target_field 기준으로 변환 + const autoFillData: Record = {}; + const mappingInfo: any[] = []; + + for (const mapping of mappings) { + const sourceValue = dataResult?.[mapping.source_column]; + const finalValue = sourceValue !== null && sourceValue !== undefined + ? sourceValue + : mapping.default_value; + + autoFillData[mapping.target_field] = finalValue; + mappingInfo.push({ + targetField: mapping.target_field, + targetLabel: mapping.target_label, + value: finalValue, + isEditable: mapping.is_editable === "Y", + isRequired: mapping.is_required === "Y", + }); + } + + logger.info("자동 입력 데이터 조회", { groupCode, masterValue, fieldCount: mappingInfo.length }); + + res.json({ + success: true, + data: autoFillData, + mappings: mappingInfo, + }); + } catch (error: any) { + logger.error("자동 입력 데이터 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "자동 입력 데이터 조회에 실패했습니다.", + error: error.message, + }); + } +}; + diff --git a/backend-node/src/controllers/cascadingConditionController.ts b/backend-node/src/controllers/cascadingConditionController.ts new file mode 100644 index 00000000..cf30a725 --- /dev/null +++ b/backend-node/src/controllers/cascadingConditionController.ts @@ -0,0 +1,525 @@ +/** + * 조건부 연쇄 (Conditional Cascading) 컨트롤러 + * 특정 필드 값에 따라 드롭다운 옵션을 필터링하는 기능 + */ + +import { Request, Response } from "express"; +import { query, queryOne } from "../database/db"; +import logger from "../utils/logger"; + +// ===================================================== +// 조건부 연쇄 규칙 CRUD +// ===================================================== + +/** + * 조건부 연쇄 규칙 목록 조회 + */ +export const getConditions = async (req: Request, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { isActive, relationCode, relationType } = req.query; + + let sql = ` + SELECT * FROM cascading_condition + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + // 회사 필터 + if (companyCode !== "*") { + sql += ` AND company_code = $${paramIndex++}`; + params.push(companyCode); + } + + // 활성 상태 필터 + if (isActive) { + sql += ` AND is_active = $${paramIndex++}`; + params.push(isActive); + } + + // 관계 코드 필터 + if (relationCode) { + sql += ` AND relation_code = $${paramIndex++}`; + params.push(relationCode); + } + + // 관계 유형 필터 (RELATION / HIERARCHY) + if (relationType) { + sql += ` AND relation_type = $${paramIndex++}`; + params.push(relationType); + } + + sql += ` ORDER BY relation_code, priority, condition_name`; + + const result = await query(sql, params); + + logger.info("조건부 연쇄 규칙 목록 조회", { count: result.length, companyCode }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("조건부 연쇄 규칙 목록 조회 실패:", error); + logger.error("조건부 연쇄 규칙 목록 조회 실패", { + error: error.message, + stack: error.stack, + }); + res.status(500).json({ + success: false, + message: "조건부 연쇄 규칙 목록 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 조건부 연쇄 규칙 상세 조회 + */ +export const getConditionDetail = async (req: Request, res: Response) => { + try { + const { conditionId } = req.params; + const companyCode = req.user?.companyCode || "*"; + + let sql = `SELECT * FROM cascading_condition WHERE condition_id = $1`; + const params: any[] = [Number(conditionId)]; + + if (companyCode !== "*") { + sql += ` AND company_code = $2`; + params.push(companyCode); + } + + const result = await queryOne(sql, params); + + if (!result) { + return res.status(404).json({ + success: false, + message: "조건부 연쇄 규칙을 찾을 수 없습니다.", + }); + } + + logger.info("조건부 연쇄 규칙 상세 조회", { conditionId, companyCode }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + logger.error("조건부 연쇄 규칙 상세 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "조건부 연쇄 규칙 상세 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 조건부 연쇄 규칙 생성 + */ +export const createCondition = async (req: Request, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { + relationType = "RELATION", + relationCode, + conditionName, + conditionField, + conditionOperator = "EQ", + conditionValue, + filterColumn, + filterValues, + priority = 0, + } = req.body; + + // 필수 필드 검증 + if (!relationCode || !conditionName || !conditionField || !conditionValue || !filterColumn || !filterValues) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (relationCode, conditionName, conditionField, conditionValue, filterColumn, filterValues)", + }); + } + + const insertSql = ` + INSERT INTO cascading_condition ( + relation_type, relation_code, condition_name, + condition_field, condition_operator, condition_value, + filter_column, filter_values, priority, + company_code, is_active, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'Y', CURRENT_TIMESTAMP) + RETURNING * + `; + + const result = await queryOne(insertSql, [ + relationType, + relationCode, + conditionName, + conditionField, + conditionOperator, + conditionValue, + filterColumn, + filterValues, + priority, + companyCode, + ]); + + logger.info("조건부 연쇄 규칙 생성", { conditionId: result?.condition_id, relationCode, companyCode }); + + res.status(201).json({ + success: true, + message: "조건부 연쇄 규칙이 생성되었습니다.", + data: result, + }); + } catch (error: any) { + logger.error("조건부 연쇄 규칙 생성 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "조건부 연쇄 규칙 생성에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 조건부 연쇄 규칙 수정 + */ +export const updateCondition = async (req: Request, res: Response) => { + try { + const { conditionId } = req.params; + const companyCode = req.user?.companyCode || "*"; + const { + conditionName, + conditionField, + conditionOperator, + conditionValue, + filterColumn, + filterValues, + priority, + isActive, + } = req.body; + + // 기존 규칙 확인 + let checkSql = `SELECT * FROM cascading_condition WHERE condition_id = $1`; + const checkParams: any[] = [Number(conditionId)]; + + if (companyCode !== "*") { + checkSql += ` AND company_code = $2`; + checkParams.push(companyCode); + } + + const existing = await queryOne(checkSql, checkParams); + + if (!existing) { + return res.status(404).json({ + success: false, + message: "조건부 연쇄 규칙을 찾을 수 없습니다.", + }); + } + + const updateSql = ` + UPDATE cascading_condition SET + condition_name = COALESCE($1, condition_name), + condition_field = COALESCE($2, condition_field), + condition_operator = COALESCE($3, condition_operator), + condition_value = COALESCE($4, condition_value), + filter_column = COALESCE($5, filter_column), + filter_values = COALESCE($6, filter_values), + priority = COALESCE($7, priority), + is_active = COALESCE($8, is_active), + updated_date = CURRENT_TIMESTAMP + WHERE condition_id = $9 + RETURNING * + `; + + const result = await queryOne(updateSql, [ + conditionName, + conditionField, + conditionOperator, + conditionValue, + filterColumn, + filterValues, + priority, + isActive, + Number(conditionId), + ]); + + logger.info("조건부 연쇄 규칙 수정", { conditionId, companyCode }); + + res.json({ + success: true, + message: "조건부 연쇄 규칙이 수정되었습니다.", + data: result, + }); + } catch (error: any) { + logger.error("조건부 연쇄 규칙 수정 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "조건부 연쇄 규칙 수정에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 조건부 연쇄 규칙 삭제 + */ +export const deleteCondition = async (req: Request, res: Response) => { + try { + const { conditionId } = req.params; + const companyCode = req.user?.companyCode || "*"; + + let deleteSql = `DELETE FROM cascading_condition WHERE condition_id = $1`; + const deleteParams: any[] = [Number(conditionId)]; + + if (companyCode !== "*") { + deleteSql += ` AND company_code = $2`; + deleteParams.push(companyCode); + } + + deleteSql += ` RETURNING condition_id`; + + const result = await queryOne(deleteSql, deleteParams); + + if (!result) { + return res.status(404).json({ + success: false, + message: "조건부 연쇄 규칙을 찾을 수 없습니다.", + }); + } + + logger.info("조건부 연쇄 규칙 삭제", { conditionId, companyCode }); + + res.json({ + success: true, + message: "조건부 연쇄 규칙이 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("조건부 연쇄 규칙 삭제 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "조건부 연쇄 규칙 삭제에 실패했습니다.", + error: error.message, + }); + } +}; + +// ===================================================== +// 조건부 필터링 적용 API (실제 사용) +// ===================================================== + +/** + * 조건에 따른 필터링된 옵션 조회 + * 특정 관계 코드에 대해 조건 필드 값에 따라 필터링된 옵션 반환 + */ +export const getFilteredOptions = async (req: Request, res: Response) => { + try { + const { relationCode } = req.params; + const { conditionFieldValue, parentValue } = req.query; + const companyCode = req.user?.companyCode || "*"; + + // 1. 기본 연쇄 관계 정보 조회 + let relationSql = `SELECT * FROM cascading_relation WHERE relation_code = $1 AND is_active = 'Y'`; + const relationParams: any[] = [relationCode]; + + if (companyCode !== "*") { + relationSql += ` AND company_code = $2`; + relationParams.push(companyCode); + } + + const relation = await queryOne(relationSql, relationParams); + + if (!relation) { + return res.status(404).json({ + success: false, + message: "연쇄 관계를 찾을 수 없습니다.", + }); + } + + // 2. 해당 관계에 적용되는 조건 규칙 조회 + let conditionSql = ` + SELECT * FROM cascading_condition + WHERE relation_code = $1 AND is_active = 'Y' + `; + const conditionParams: any[] = [relationCode]; + let conditionParamIndex = 2; + + if (companyCode !== "*") { + conditionSql += ` AND company_code = $${conditionParamIndex++}`; + conditionParams.push(companyCode); + } + + conditionSql += ` ORDER BY priority DESC`; + + const conditions = await query(conditionSql, conditionParams); + + // 3. 조건에 맞는 규칙 찾기 + let matchedCondition: any = null; + + if (conditionFieldValue) { + for (const cond of conditions) { + const isMatch = evaluateCondition( + conditionFieldValue as string, + cond.condition_operator, + cond.condition_value + ); + + if (isMatch) { + matchedCondition = cond; + break; // 우선순위가 높은 첫 번째 매칭 규칙 사용 + } + } + } + + // 4. 옵션 조회 쿼리 생성 + let optionsSql = ` + SELECT + ${relation.child_value_column} as value, + ${relation.child_label_column} as label + FROM ${relation.child_table} + WHERE 1=1 + `; + const optionsParams: any[] = []; + let optionsParamIndex = 1; + + // 부모 값 필터 (기본 연쇄) + if (parentValue) { + optionsSql += ` AND ${relation.child_filter_column} = $${optionsParamIndex++}`; + optionsParams.push(parentValue); + } + + // 조건부 필터 적용 + if (matchedCondition) { + const filterValues = matchedCondition.filter_values.split(",").map((v: string) => v.trim()); + const placeholders = filterValues.map((_: any, i: number) => `$${optionsParamIndex + i}`).join(","); + optionsSql += ` AND ${matchedCondition.filter_column} IN (${placeholders})`; + optionsParams.push(...filterValues); + optionsParamIndex += filterValues.length; + } + + // 멀티테넌시 필터 + if (companyCode !== "*") { + const columnCheck = await queryOne( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [relation.child_table] + ); + + if (columnCheck) { + optionsSql += ` AND company_code = $${optionsParamIndex++}`; + optionsParams.push(companyCode); + } + } + + // 정렬 + if (relation.child_order_column) { + optionsSql += ` ORDER BY ${relation.child_order_column} ${relation.child_order_direction || "ASC"}`; + } else { + optionsSql += ` ORDER BY ${relation.child_label_column}`; + } + + const optionsResult = await query(optionsSql, optionsParams); + + logger.info("조건부 필터링 옵션 조회", { + relationCode, + conditionFieldValue, + parentValue, + matchedCondition: matchedCondition?.condition_name, + optionCount: optionsResult.length, + }); + + res.json({ + success: true, + data: optionsResult, + appliedCondition: matchedCondition + ? { + conditionId: matchedCondition.condition_id, + conditionName: matchedCondition.condition_name, + } + : null, + }); + } catch (error: any) { + logger.error("조건부 필터링 옵션 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "조건부 필터링 옵션 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 조건 평가 함수 + */ +function evaluateCondition( + actualValue: string, + operator: string, + expectedValue: string +): boolean { + const actual = actualValue.toLowerCase().trim(); + const expected = expectedValue.toLowerCase().trim(); + + switch (operator.toUpperCase()) { + case "EQ": + case "=": + case "EQUALS": + return actual === expected; + + case "NEQ": + case "!=": + case "<>": + case "NOT_EQUALS": + return actual !== expected; + + case "CONTAINS": + case "LIKE": + return actual.includes(expected); + + case "NOT_CONTAINS": + case "NOT_LIKE": + return !actual.includes(expected); + + case "STARTS_WITH": + return actual.startsWith(expected); + + case "ENDS_WITH": + return actual.endsWith(expected); + + case "IN": + const inValues = expected.split(",").map((v) => v.trim()); + return inValues.includes(actual); + + case "NOT_IN": + const notInValues = expected.split(",").map((v) => v.trim()); + return !notInValues.includes(actual); + + case "GT": + case ">": + return parseFloat(actual) > parseFloat(expected); + + case "GTE": + case ">=": + return parseFloat(actual) >= parseFloat(expected); + + case "LT": + case "<": + return parseFloat(actual) < parseFloat(expected); + + case "LTE": + case "<=": + return parseFloat(actual) <= parseFloat(expected); + + case "IS_NULL": + case "NULL": + return actual === "" || actual === "null" || actual === "undefined"; + + case "IS_NOT_NULL": + case "NOT_NULL": + return actual !== "" && actual !== "null" && actual !== "undefined"; + + default: + logger.warn(`알 수 없는 연산자: ${operator}`); + return false; + } +} + diff --git a/backend-node/src/controllers/cascadingHierarchyController.ts b/backend-node/src/controllers/cascadingHierarchyController.ts new file mode 100644 index 00000000..59d243e2 --- /dev/null +++ b/backend-node/src/controllers/cascadingHierarchyController.ts @@ -0,0 +1,752 @@ +/** + * 다단계 계층 (Hierarchy) 컨트롤러 + * 국가 > 도시 > 구/군 같은 다단계 연쇄 드롭다운 관리 + */ + +import { Request, Response } from "express"; +import { query, queryOne } from "../database/db"; +import logger from "../utils/logger"; + +// ===================================================== +// 계층 그룹 CRUD +// ===================================================== + +/** + * 계층 그룹 목록 조회 + */ +export const getHierarchyGroups = async (req: Request, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { isActive, hierarchyType } = req.query; + + let sql = ` + SELECT g.*, + (SELECT COUNT(*) FROM cascading_hierarchy_level l WHERE l.group_code = g.group_code AND l.company_code = g.company_code) as level_count + FROM cascading_hierarchy_group g + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + if (companyCode !== "*") { + sql += ` AND g.company_code = $${paramIndex++}`; + params.push(companyCode); + } + + if (isActive) { + sql += ` AND g.is_active = $${paramIndex++}`; + params.push(isActive); + } + + if (hierarchyType) { + sql += ` AND g.hierarchy_type = $${paramIndex++}`; + params.push(hierarchyType); + } + + sql += ` ORDER BY g.group_name`; + + const result = await query(sql, params); + + logger.info("계층 그룹 목록 조회", { count: result.length, companyCode }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + logger.error("계층 그룹 목록 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "계층 그룹 목록 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 계층 그룹 상세 조회 (레벨 포함) + */ +export const getHierarchyGroupDetail = async (req: Request, res: Response) => { + try { + const { groupCode } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 그룹 조회 + let groupSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`; + const groupParams: any[] = [groupCode]; + + if (companyCode !== "*") { + groupSql += ` AND company_code = $2`; + groupParams.push(companyCode); + } + + const group = await queryOne(groupSql, groupParams); + + if (!group) { + return res.status(404).json({ + success: false, + message: "계층 그룹을 찾을 수 없습니다.", + }); + } + + // 레벨 조회 + let levelSql = `SELECT * FROM cascading_hierarchy_level WHERE group_code = $1`; + const levelParams: any[] = [groupCode]; + + if (companyCode !== "*") { + levelSql += ` AND company_code = $2`; + levelParams.push(companyCode); + } + + levelSql += ` ORDER BY level_order`; + + const levels = await query(levelSql, levelParams); + + logger.info("계층 그룹 상세 조회", { groupCode, companyCode }); + + res.json({ + success: true, + data: { + ...group, + levels: levels, + }, + }); + } catch (error: any) { + logger.error("계층 그룹 상세 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "계층 그룹 상세 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 계층 그룹 코드 자동 생성 함수 + */ +const generateHierarchyGroupCode = async (companyCode: string): Promise => { + const prefix = "HG"; + const result = await queryOne( + `SELECT COUNT(*) as cnt FROM cascading_hierarchy_group WHERE company_code = $1`, + [companyCode] + ); + const count = parseInt(result?.cnt || "0", 10) + 1; + const timestamp = Date.now().toString(36).toUpperCase().slice(-4); + return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`; +}; + +/** + * 계층 그룹 생성 + */ +export const createHierarchyGroup = async (req: Request, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + const { + groupName, + description, + hierarchyType = "MULTI_TABLE", + maxLevels, + isFixedLevels = "Y", + // Self-reference 설정 + selfRefTable, + selfRefIdColumn, + selfRefParentColumn, + selfRefValueColumn, + selfRefLabelColumn, + selfRefLevelColumn, + selfRefOrderColumn, + // BOM 설정 + bomTable, + bomParentColumn, + bomChildColumn, + bomItemTable, + bomItemIdColumn, + bomItemLabelColumn, + bomQtyColumn, + bomLevelColumn, + // 메시지 + emptyMessage, + noOptionsMessage, + loadingMessage, + // 레벨 (MULTI_TABLE 타입인 경우) + levels = [], + } = req.body; + + // 필수 필드 검증 + if (!groupName || !hierarchyType) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (groupName, hierarchyType)", + }); + } + + // 그룹 코드 자동 생성 + const groupCode = await generateHierarchyGroupCode(companyCode); + + // 그룹 생성 + const insertGroupSql = ` + INSERT INTO cascading_hierarchy_group ( + group_code, group_name, description, hierarchy_type, + max_levels, is_fixed_levels, + self_ref_table, self_ref_id_column, self_ref_parent_column, + self_ref_value_column, self_ref_label_column, self_ref_level_column, self_ref_order_column, + bom_table, bom_parent_column, bom_child_column, + bom_item_table, bom_item_id_column, bom_item_label_column, bom_qty_column, bom_level_column, + empty_message, no_options_message, loading_message, + company_code, is_active, created_by, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, 'Y', $26, CURRENT_TIMESTAMP) + RETURNING * + `; + + const group = await queryOne(insertGroupSql, [ + groupCode, + groupName, + description || null, + hierarchyType, + maxLevels || null, + isFixedLevels, + selfRefTable || null, + selfRefIdColumn || null, + selfRefParentColumn || null, + selfRefValueColumn || null, + selfRefLabelColumn || null, + selfRefLevelColumn || null, + selfRefOrderColumn || null, + bomTable || null, + bomParentColumn || null, + bomChildColumn || null, + bomItemTable || null, + bomItemIdColumn || null, + bomItemLabelColumn || null, + bomQtyColumn || null, + bomLevelColumn || null, + emptyMessage || "선택해주세요", + noOptionsMessage || "옵션이 없습니다", + loadingMessage || "로딩 중...", + companyCode, + userId, + ]); + + // 레벨 생성 (MULTI_TABLE 타입인 경우) + if (hierarchyType === "MULTI_TABLE" && levels.length > 0) { + for (const level of levels) { + await query( + `INSERT INTO cascading_hierarchy_level ( + group_code, company_code, level_order, level_name, level_code, + table_name, value_column, label_column, parent_key_column, + filter_column, filter_value, order_column, order_direction, + placeholder, is_required, is_searchable, is_active, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP)`, + [ + groupCode, + companyCode, + level.levelOrder, + level.levelName, + level.levelCode || null, + level.tableName, + level.valueColumn, + level.labelColumn, + level.parentKeyColumn || null, + level.filterColumn || null, + level.filterValue || null, + level.orderColumn || null, + level.orderDirection || "ASC", + level.placeholder || `${level.levelName} 선택`, + level.isRequired || "Y", + level.isSearchable || "N", + ] + ); + } + } + + logger.info("계층 그룹 생성", { groupCode, hierarchyType, companyCode }); + + res.status(201).json({ + success: true, + message: "계층 그룹이 생성되었습니다.", + data: group, + }); + } catch (error: any) { + logger.error("계층 그룹 생성 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "계층 그룹 생성에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 계층 그룹 수정 + */ +export const updateHierarchyGroup = async (req: Request, res: Response) => { + try { + const { groupCode } = req.params; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + const { + groupName, + description, + maxLevels, + isFixedLevels, + emptyMessage, + noOptionsMessage, + loadingMessage, + isActive, + } = req.body; + + // 기존 그룹 확인 + let checkSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`; + const checkParams: any[] = [groupCode]; + + if (companyCode !== "*") { + checkSql += ` AND company_code = $2`; + checkParams.push(companyCode); + } + + const existing = await queryOne(checkSql, checkParams); + + if (!existing) { + return res.status(404).json({ + success: false, + message: "계층 그룹을 찾을 수 없습니다.", + }); + } + + const updateSql = ` + UPDATE cascading_hierarchy_group SET + group_name = COALESCE($1, group_name), + description = COALESCE($2, description), + max_levels = COALESCE($3, max_levels), + is_fixed_levels = COALESCE($4, is_fixed_levels), + empty_message = COALESCE($5, empty_message), + no_options_message = COALESCE($6, no_options_message), + loading_message = COALESCE($7, loading_message), + is_active = COALESCE($8, is_active), + updated_by = $9, + updated_date = CURRENT_TIMESTAMP + WHERE group_code = $10 AND company_code = $11 + RETURNING * + `; + + const result = await queryOne(updateSql, [ + groupName, + description, + maxLevels, + isFixedLevels, + emptyMessage, + noOptionsMessage, + loadingMessage, + isActive, + userId, + groupCode, + existing.company_code, + ]); + + logger.info("계층 그룹 수정", { groupCode, companyCode }); + + res.json({ + success: true, + message: "계층 그룹이 수정되었습니다.", + data: result, + }); + } catch (error: any) { + logger.error("계층 그룹 수정 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "계층 그룹 수정에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 계층 그룹 삭제 + */ +export const deleteHierarchyGroup = async (req: Request, res: Response) => { + try { + const { groupCode } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 레벨 먼저 삭제 + let deleteLevelsSql = `DELETE FROM cascading_hierarchy_level WHERE group_code = $1`; + const levelParams: any[] = [groupCode]; + + if (companyCode !== "*") { + deleteLevelsSql += ` AND company_code = $2`; + levelParams.push(companyCode); + } + + await query(deleteLevelsSql, levelParams); + + // 그룹 삭제 + let deleteGroupSql = `DELETE FROM cascading_hierarchy_group WHERE group_code = $1`; + const groupParams: any[] = [groupCode]; + + if (companyCode !== "*") { + deleteGroupSql += ` AND company_code = $2`; + groupParams.push(companyCode); + } + + deleteGroupSql += ` RETURNING group_code`; + + const result = await queryOne(deleteGroupSql, groupParams); + + if (!result) { + return res.status(404).json({ + success: false, + message: "계층 그룹을 찾을 수 없습니다.", + }); + } + + logger.info("계층 그룹 삭제", { groupCode, companyCode }); + + res.json({ + success: true, + message: "계층 그룹이 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("계층 그룹 삭제 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "계층 그룹 삭제에 실패했습니다.", + error: error.message, + }); + } +}; + +// ===================================================== +// 계층 레벨 관리 +// ===================================================== + +/** + * 레벨 추가 + */ +export const addLevel = async (req: Request, res: Response) => { + try { + const { groupCode } = req.params; + const companyCode = req.user?.companyCode || "*"; + const { + levelOrder, + levelName, + levelCode, + tableName, + valueColumn, + labelColumn, + parentKeyColumn, + filterColumn, + filterValue, + orderColumn, + orderDirection = "ASC", + placeholder, + isRequired = "Y", + isSearchable = "N", + } = req.body; + + // 그룹 존재 확인 + const groupCheck = await queryOne( + `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1 AND (company_code = $2 OR $2 = '*')`, + [groupCode, companyCode] + ); + + if (!groupCheck) { + return res.status(404).json({ + success: false, + message: "계층 그룹을 찾을 수 없습니다.", + }); + } + + const insertSql = ` + INSERT INTO cascading_hierarchy_level ( + group_code, company_code, level_order, level_name, level_code, + table_name, value_column, label_column, parent_key_column, + filter_column, filter_value, order_column, order_direction, + placeholder, is_required, is_searchable, is_active, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP) + RETURNING * + `; + + const result = await queryOne(insertSql, [ + groupCode, + groupCheck.company_code, + levelOrder, + levelName, + levelCode || null, + tableName, + valueColumn, + labelColumn, + parentKeyColumn || null, + filterColumn || null, + filterValue || null, + orderColumn || null, + orderDirection, + placeholder || `${levelName} 선택`, + isRequired, + isSearchable, + ]); + + logger.info("계층 레벨 추가", { groupCode, levelOrder, levelName }); + + res.status(201).json({ + success: true, + message: "레벨이 추가되었습니다.", + data: result, + }); + } catch (error: any) { + logger.error("계층 레벨 추가 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "레벨 추가에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 레벨 수정 + */ +export const updateLevel = async (req: Request, res: Response) => { + try { + const { levelId } = req.params; + const companyCode = req.user?.companyCode || "*"; + const { + levelName, + tableName, + valueColumn, + labelColumn, + parentKeyColumn, + filterColumn, + filterValue, + orderColumn, + orderDirection, + placeholder, + isRequired, + isSearchable, + isActive, + } = req.body; + + let checkSql = `SELECT * FROM cascading_hierarchy_level WHERE level_id = $1`; + const checkParams: any[] = [Number(levelId)]; + + if (companyCode !== "*") { + checkSql += ` AND company_code = $2`; + checkParams.push(companyCode); + } + + const existing = await queryOne(checkSql, checkParams); + + if (!existing) { + return res.status(404).json({ + success: false, + message: "레벨을 찾을 수 없습니다.", + }); + } + + const updateSql = ` + UPDATE cascading_hierarchy_level SET + level_name = COALESCE($1, level_name), + table_name = COALESCE($2, table_name), + value_column = COALESCE($3, value_column), + label_column = COALESCE($4, label_column), + parent_key_column = COALESCE($5, parent_key_column), + filter_column = COALESCE($6, filter_column), + filter_value = COALESCE($7, filter_value), + order_column = COALESCE($8, order_column), + order_direction = COALESCE($9, order_direction), + placeholder = COALESCE($10, placeholder), + is_required = COALESCE($11, is_required), + is_searchable = COALESCE($12, is_searchable), + is_active = COALESCE($13, is_active), + updated_date = CURRENT_TIMESTAMP + WHERE level_id = $14 + RETURNING * + `; + + const result = await queryOne(updateSql, [ + levelName, + tableName, + valueColumn, + labelColumn, + parentKeyColumn, + filterColumn, + filterValue, + orderColumn, + orderDirection, + placeholder, + isRequired, + isSearchable, + isActive, + Number(levelId), + ]); + + logger.info("계층 레벨 수정", { levelId }); + + res.json({ + success: true, + message: "레벨이 수정되었습니다.", + data: result, + }); + } catch (error: any) { + logger.error("계층 레벨 수정 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "레벨 수정에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 레벨 삭제 + */ +export const deleteLevel = async (req: Request, res: Response) => { + try { + const { levelId } = req.params; + const companyCode = req.user?.companyCode || "*"; + + let deleteSql = `DELETE FROM cascading_hierarchy_level WHERE level_id = $1`; + const deleteParams: any[] = [Number(levelId)]; + + if (companyCode !== "*") { + deleteSql += ` AND company_code = $2`; + deleteParams.push(companyCode); + } + + deleteSql += ` RETURNING level_id`; + + const result = await queryOne(deleteSql, deleteParams); + + if (!result) { + return res.status(404).json({ + success: false, + message: "레벨을 찾을 수 없습니다.", + }); + } + + logger.info("계층 레벨 삭제", { levelId }); + + res.json({ + success: true, + message: "레벨이 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("계층 레벨 삭제 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "레벨 삭제에 실패했습니다.", + error: error.message, + }); + } +}; + +// ===================================================== +// 계층 옵션 조회 API (실제 사용) +// ===================================================== + +/** + * 특정 레벨의 옵션 조회 + */ +export const getLevelOptions = async (req: Request, res: Response) => { + try { + const { groupCode, levelOrder } = req.params; + const { parentValue } = req.query; + const companyCode = req.user?.companyCode || "*"; + + // 레벨 정보 조회 + let levelSql = ` + SELECT l.*, g.hierarchy_type + FROM cascading_hierarchy_level l + JOIN cascading_hierarchy_group g ON l.group_code = g.group_code AND l.company_code = g.company_code + WHERE l.group_code = $1 AND l.level_order = $2 AND l.is_active = 'Y' + `; + const levelParams: any[] = [groupCode, Number(levelOrder)]; + + if (companyCode !== "*") { + levelSql += ` AND l.company_code = $3`; + levelParams.push(companyCode); + } + + const level = await queryOne(levelSql, levelParams); + + if (!level) { + return res.status(404).json({ + success: false, + message: "레벨을 찾을 수 없습니다.", + }); + } + + // 옵션 조회 + let optionsSql = ` + SELECT + ${level.value_column} as value, + ${level.label_column} as label + FROM ${level.table_name} + WHERE 1=1 + `; + const optionsParams: any[] = []; + let optionsParamIndex = 1; + + // 부모 값 필터 (레벨 2 이상) + if (level.parent_key_column && parentValue) { + optionsSql += ` AND ${level.parent_key_column} = $${optionsParamIndex++}`; + optionsParams.push(parentValue); + } + + // 고정 필터 + if (level.filter_column && level.filter_value) { + optionsSql += ` AND ${level.filter_column} = $${optionsParamIndex++}`; + optionsParams.push(level.filter_value); + } + + // 멀티테넌시 필터 + if (companyCode !== "*") { + const columnCheck = await queryOne( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [level.table_name] + ); + + if (columnCheck) { + optionsSql += ` AND company_code = $${optionsParamIndex++}`; + optionsParams.push(companyCode); + } + } + + // 정렬 + if (level.order_column) { + optionsSql += ` ORDER BY ${level.order_column} ${level.order_direction || "ASC"}`; + } else { + optionsSql += ` ORDER BY ${level.label_column}`; + } + + const optionsResult = await query(optionsSql, optionsParams); + + logger.info("계층 레벨 옵션 조회", { + groupCode, + levelOrder, + parentValue, + optionCount: optionsResult.length, + }); + + res.json({ + success: true, + data: optionsResult, + levelInfo: { + levelId: level.level_id, + levelName: level.level_name, + placeholder: level.placeholder, + isRequired: level.is_required, + isSearchable: level.is_searchable, + }, + }); + } catch (error: any) { + logger.error("계층 레벨 옵션 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "옵션 조회에 실패했습니다.", + error: error.message, + }); + } +}; + diff --git a/backend-node/src/controllers/cascadingMutualExclusionController.ts b/backend-node/src/controllers/cascadingMutualExclusionController.ts new file mode 100644 index 00000000..8714c73b --- /dev/null +++ b/backend-node/src/controllers/cascadingMutualExclusionController.ts @@ -0,0 +1,505 @@ +/** + * 상호 배제 (Mutual Exclusion) 컨트롤러 + * 두 필드가 같은 값을 선택할 수 없도록 제한하는 기능 + */ + +import { Request, Response } from "express"; +import { query, queryOne } from "../database/db"; +import logger from "../utils/logger"; + +// ===================================================== +// 상호 배제 규칙 CRUD +// ===================================================== + +/** + * 상호 배제 규칙 목록 조회 + */ +export const getExclusions = async (req: Request, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { isActive } = req.query; + + let sql = ` + SELECT * FROM cascading_mutual_exclusion + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + // 회사 필터 + if (companyCode !== "*") { + sql += ` AND company_code = $${paramIndex++}`; + params.push(companyCode); + } + + // 활성 상태 필터 + if (isActive) { + sql += ` AND is_active = $${paramIndex++}`; + params.push(isActive); + } + + sql += ` ORDER BY exclusion_name`; + + const result = await query(sql, params); + + logger.info("상호 배제 규칙 목록 조회", { count: result.length, companyCode }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + logger.error("상호 배제 규칙 목록 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "상호 배제 규칙 목록 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 상호 배제 규칙 상세 조회 + */ +export const getExclusionDetail = async (req: Request, res: Response) => { + try { + const { exclusionId } = req.params; + const companyCode = req.user?.companyCode || "*"; + + let sql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_id = $1`; + const params: any[] = [Number(exclusionId)]; + + if (companyCode !== "*") { + sql += ` AND company_code = $2`; + params.push(companyCode); + } + + const result = await queryOne(sql, params); + + if (!result) { + return res.status(404).json({ + success: false, + message: "상호 배제 규칙을 찾을 수 없습니다.", + }); + } + + logger.info("상호 배제 규칙 상세 조회", { exclusionId, companyCode }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + logger.error("상호 배제 규칙 상세 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "상호 배제 규칙 상세 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 배제 코드 자동 생성 함수 + */ +const generateExclusionCode = async (companyCode: string): Promise => { + const prefix = "EX"; + const result = await queryOne( + `SELECT COUNT(*) as cnt FROM cascading_mutual_exclusion WHERE company_code = $1`, + [companyCode] + ); + const count = parseInt(result?.cnt || "0", 10) + 1; + const timestamp = Date.now().toString(36).toUpperCase().slice(-4); + return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`; +}; + +/** + * 상호 배제 규칙 생성 + */ +export const createExclusion = async (req: Request, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { + exclusionName, + fieldNames, // 콤마로 구분된 필드명 (예: "source_warehouse,target_warehouse") + sourceTable, + valueColumn, + labelColumn, + exclusionType = "SAME_VALUE", + errorMessage = "동일한 값을 선택할 수 없습니다", + } = req.body; + + // 필수 필드 검증 + if (!exclusionName || !fieldNames || !sourceTable || !valueColumn) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (exclusionName, fieldNames, sourceTable, valueColumn)", + }); + } + + // 배제 코드 자동 생성 + const exclusionCode = await generateExclusionCode(companyCode); + + // 중복 체크 (생략 - 자동 생성이므로 중복 불가) + const existingCheck = await queryOne( + `SELECT exclusion_id FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND company_code = $2`, + [exclusionCode, companyCode] + ); + + if (existingCheck) { + return res.status(409).json({ + success: false, + message: "이미 존재하는 배제 코드입니다.", + }); + } + + const insertSql = ` + INSERT INTO cascading_mutual_exclusion ( + exclusion_code, exclusion_name, field_names, + source_table, value_column, label_column, + exclusion_type, error_message, + company_code, is_active, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y', CURRENT_TIMESTAMP) + RETURNING * + `; + + const result = await queryOne(insertSql, [ + exclusionCode, + exclusionName, + fieldNames, + sourceTable, + valueColumn, + labelColumn || null, + exclusionType, + errorMessage, + companyCode, + ]); + + logger.info("상호 배제 규칙 생성", { exclusionCode, companyCode }); + + res.status(201).json({ + success: true, + message: "상호 배제 규칙이 생성되었습니다.", + data: result, + }); + } catch (error: any) { + logger.error("상호 배제 규칙 생성 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "상호 배제 규칙 생성에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 상호 배제 규칙 수정 + */ +export const updateExclusion = async (req: Request, res: Response) => { + try { + const { exclusionId } = req.params; + const companyCode = req.user?.companyCode || "*"; + const { + exclusionName, + fieldNames, + sourceTable, + valueColumn, + labelColumn, + exclusionType, + errorMessage, + isActive, + } = req.body; + + // 기존 규칙 확인 + let checkSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_id = $1`; + const checkParams: any[] = [Number(exclusionId)]; + + if (companyCode !== "*") { + checkSql += ` AND company_code = $2`; + checkParams.push(companyCode); + } + + const existing = await queryOne(checkSql, checkParams); + + if (!existing) { + return res.status(404).json({ + success: false, + message: "상호 배제 규칙을 찾을 수 없습니다.", + }); + } + + const updateSql = ` + UPDATE cascading_mutual_exclusion SET + exclusion_name = COALESCE($1, exclusion_name), + field_names = COALESCE($2, field_names), + source_table = COALESCE($3, source_table), + value_column = COALESCE($4, value_column), + label_column = COALESCE($5, label_column), + exclusion_type = COALESCE($6, exclusion_type), + error_message = COALESCE($7, error_message), + is_active = COALESCE($8, is_active) + WHERE exclusion_id = $9 + RETURNING * + `; + + const result = await queryOne(updateSql, [ + exclusionName, + fieldNames, + sourceTable, + valueColumn, + labelColumn, + exclusionType, + errorMessage, + isActive, + Number(exclusionId), + ]); + + logger.info("상호 배제 규칙 수정", { exclusionId, companyCode }); + + res.json({ + success: true, + message: "상호 배제 규칙이 수정되었습니다.", + data: result, + }); + } catch (error: any) { + logger.error("상호 배제 규칙 수정 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "상호 배제 규칙 수정에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 상호 배제 규칙 삭제 + */ +export const deleteExclusion = async (req: Request, res: Response) => { + try { + const { exclusionId } = req.params; + const companyCode = req.user?.companyCode || "*"; + + let deleteSql = `DELETE FROM cascading_mutual_exclusion WHERE exclusion_id = $1`; + const deleteParams: any[] = [Number(exclusionId)]; + + if (companyCode !== "*") { + deleteSql += ` AND company_code = $2`; + deleteParams.push(companyCode); + } + + deleteSql += ` RETURNING exclusion_id`; + + const result = await queryOne(deleteSql, deleteParams); + + if (!result) { + return res.status(404).json({ + success: false, + message: "상호 배제 규칙을 찾을 수 없습니다.", + }); + } + + logger.info("상호 배제 규칙 삭제", { exclusionId, companyCode }); + + res.json({ + success: true, + message: "상호 배제 규칙이 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("상호 배제 규칙 삭제 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "상호 배제 규칙 삭제에 실패했습니다.", + error: error.message, + }); + } +}; + +// ===================================================== +// 상호 배제 검증 API (실제 사용) +// ===================================================== + +/** + * 상호 배제 검증 + * 선택하려는 값이 다른 필드와 충돌하는지 확인 + */ +export const validateExclusion = async (req: Request, res: Response) => { + try { + const { exclusionCode } = req.params; + const { fieldValues } = req.body; // { "source_warehouse": "WH001", "target_warehouse": "WH002" } + const companyCode = req.user?.companyCode || "*"; + + // 배제 규칙 조회 + let exclusionSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND is_active = 'Y'`; + const exclusionParams: any[] = [exclusionCode]; + + if (companyCode !== "*") { + exclusionSql += ` AND company_code = $2`; + exclusionParams.push(companyCode); + } + + const exclusion = await queryOne(exclusionSql, exclusionParams); + + if (!exclusion) { + return res.status(404).json({ + success: false, + message: "상호 배제 규칙을 찾을 수 없습니다.", + }); + } + + // 필드명 파싱 + const fields = exclusion.field_names.split(",").map((f: string) => f.trim()); + + // 필드 값 수집 + const values: string[] = []; + for (const field of fields) { + if (fieldValues[field]) { + values.push(fieldValues[field]); + } + } + + // 상호 배제 검증 + let isValid = true; + let errorMessage = null; + let conflictingFields: string[] = []; + + if (exclusion.exclusion_type === "SAME_VALUE") { + // 같은 값이 있는지 확인 + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + isValid = false; + errorMessage = exclusion.error_message; + + // 충돌하는 필드 찾기 + const valueCounts: Record = {}; + for (const field of fields) { + const val = fieldValues[field]; + if (val) { + if (!valueCounts[val]) { + valueCounts[val] = []; + } + valueCounts[val].push(field); + } + } + + for (const [, fieldList] of Object.entries(valueCounts)) { + if (fieldList.length > 1) { + conflictingFields = fieldList; + break; + } + } + } + } + + logger.info("상호 배제 검증", { + exclusionCode, + isValid, + fieldValues, + }); + + res.json({ + success: true, + data: { + isValid, + errorMessage: isValid ? null : errorMessage, + conflictingFields, + }, + }); + } catch (error: any) { + logger.error("상호 배제 검증 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "상호 배제 검증에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 필드에 대한 배제 옵션 조회 + * 다른 필드에서 이미 선택한 값을 제외한 옵션 반환 + */ +export const getExcludedOptions = async (req: Request, res: Response) => { + try { + const { exclusionCode } = req.params; + const { currentField, selectedValues } = req.query; // selectedValues: 이미 선택된 값들 (콤마 구분) + const companyCode = req.user?.companyCode || "*"; + + // 배제 규칙 조회 + let exclusionSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND is_active = 'Y'`; + const exclusionParams: any[] = [exclusionCode]; + + if (companyCode !== "*") { + exclusionSql += ` AND company_code = $2`; + exclusionParams.push(companyCode); + } + + const exclusion = await queryOne(exclusionSql, exclusionParams); + + if (!exclusion) { + return res.status(404).json({ + success: false, + message: "상호 배제 규칙을 찾을 수 없습니다.", + }); + } + + // 옵션 조회 + const labelColumn = exclusion.label_column || exclusion.value_column; + let optionsSql = ` + SELECT + ${exclusion.value_column} as value, + ${labelColumn} as label + FROM ${exclusion.source_table} + WHERE 1=1 + `; + const optionsParams: any[] = []; + let optionsParamIndex = 1; + + // 멀티테넌시 필터 + if (companyCode !== "*") { + const columnCheck = await queryOne( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [exclusion.source_table] + ); + + if (columnCheck) { + optionsSql += ` AND company_code = $${optionsParamIndex++}`; + optionsParams.push(companyCode); + } + } + + // 이미 선택된 값 제외 + if (selectedValues) { + const excludeValues = (selectedValues as string).split(",").map((v) => v.trim()).filter((v) => v); + if (excludeValues.length > 0) { + const placeholders = excludeValues.map((_, i) => `$${optionsParamIndex + i}`).join(","); + optionsSql += ` AND ${exclusion.value_column} NOT IN (${placeholders})`; + optionsParams.push(...excludeValues); + } + } + + optionsSql += ` ORDER BY ${labelColumn}`; + + const optionsResult = await query(optionsSql, optionsParams); + + logger.info("상호 배제 옵션 조회", { + exclusionCode, + currentField, + excludedCount: (selectedValues as string)?.split(",").length || 0, + optionCount: optionsResult.length, + }); + + res.json({ + success: true, + data: optionsResult, + }); + } catch (error: any) { + logger.error("상호 배제 옵션 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "상호 배제 옵션 조회에 실패했습니다.", + error: error.message, + }); + } +}; + diff --git a/backend-node/src/controllers/cascadingRelationController.ts b/backend-node/src/controllers/cascadingRelationController.ts new file mode 100644 index 00000000..3f7b5cb6 --- /dev/null +++ b/backend-node/src/controllers/cascadingRelationController.ts @@ -0,0 +1,750 @@ +import { Request, Response } from "express"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +const pool = getPool(); + +/** + * 연쇄 관계 목록 조회 + */ +export const getCascadingRelations = async (req: Request, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { isActive } = req.query; + + let query = ` + SELECT + relation_id, + relation_code, + relation_name, + description, + parent_table, + parent_value_column, + parent_label_column, + child_table, + child_filter_column, + child_value_column, + child_label_column, + child_order_column, + child_order_direction, + empty_parent_message, + no_options_message, + loading_message, + clear_on_parent_change, + company_code, + is_active, + created_by, + created_date, + updated_by, + updated_date + FROM cascading_relation + WHERE 1=1 + `; + + const params: any[] = []; + let paramIndex = 1; + + // 멀티테넌시 필터링 + // - 최고 관리자(company_code = "*"): 모든 데이터 조회 가능 + // - 일반 회사: 자기 회사 데이터만 조회 (공통 데이터는 조회 불가) + if (companyCode !== "*") { + query += ` AND company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + + // 활성 상태 필터링 + if (isActive !== undefined) { + query += ` AND is_active = $${paramIndex}`; + params.push(isActive); + paramIndex++; + } + + query += ` ORDER BY relation_name ASC`; + + const result = await pool.query(query, params); + + logger.info("연쇄 관계 목록 조회", { + companyCode, + count: result.rowCount, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("연쇄 관계 목록 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "연쇄 관계 목록 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 연쇄 관계 상세 조회 + */ +export const getCascadingRelationById = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = req.user?.companyCode || "*"; + + let query = ` + SELECT + relation_id, + relation_code, + relation_name, + description, + parent_table, + parent_value_column, + parent_label_column, + child_table, + child_filter_column, + child_value_column, + child_label_column, + child_order_column, + child_order_direction, + empty_parent_message, + no_options_message, + loading_message, + clear_on_parent_change, + company_code, + is_active, + created_by, + created_date, + updated_by, + updated_date + FROM cascading_relation + WHERE relation_id = $1 + `; + + const params: any[] = [id]; + + // 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용) + if (companyCode !== "*") { + query += ` AND company_code = $2`; + params.push(companyCode); + } + + const result = await pool.query(query, params); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "연쇄 관계를 찾을 수 없습니다.", + }); + } + + return res.json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("연쇄 관계 상세 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "연쇄 관계 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 연쇄 관계 코드로 조회 + */ +export const getCascadingRelationByCode = async ( + req: Request, + res: Response +) => { + try { + const { code } = req.params; + const companyCode = req.user?.companyCode || "*"; + + let query = ` + SELECT + relation_id, + relation_code, + relation_name, + description, + parent_table, + parent_value_column, + parent_label_column, + child_table, + child_filter_column, + child_value_column, + child_label_column, + child_order_column, + child_order_direction, + empty_parent_message, + no_options_message, + loading_message, + clear_on_parent_change, + company_code, + is_active + FROM cascading_relation + WHERE relation_code = $1 + AND is_active = 'Y' + `; + + const params: any[] = [code]; + + // 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용) + if (companyCode !== "*") { + query += ` AND company_code = $2`; + params.push(companyCode); + } + query += ` LIMIT 1`; + + const result = await pool.query(query, params); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "연쇄 관계를 찾을 수 없습니다.", + }); + } + + return res.json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("연쇄 관계 코드 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "연쇄 관계 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 연쇄 관계 생성 + */ +export const createCascadingRelation = async (req: Request, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + const { + relationCode, + relationName, + description, + parentTable, + parentValueColumn, + parentLabelColumn, + childTable, + childFilterColumn, + childValueColumn, + childLabelColumn, + childOrderColumn, + childOrderDirection, + emptyParentMessage, + noOptionsMessage, + loadingMessage, + clearOnParentChange, + } = req.body; + + // 필수 필드 검증 + if ( + !relationCode || + !relationName || + !parentTable || + !parentValueColumn || + !childTable || + !childFilterColumn || + !childValueColumn || + !childLabelColumn + ) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다.", + }); + } + + // 중복 코드 체크 + const duplicateCheck = await pool.query( + `SELECT relation_id FROM cascading_relation + WHERE relation_code = $1 AND company_code = $2`, + [relationCode, companyCode] + ); + + if (duplicateCheck.rowCount && duplicateCheck.rowCount > 0) { + return res.status(400).json({ + success: false, + message: "이미 존재하는 관계 코드입니다.", + }); + } + + const query = ` + INSERT INTO cascading_relation ( + relation_code, + relation_name, + description, + parent_table, + parent_value_column, + parent_label_column, + child_table, + child_filter_column, + child_value_column, + child_label_column, + child_order_column, + child_order_direction, + empty_parent_message, + no_options_message, + loading_message, + clear_on_parent_change, + company_code, + is_active, + created_by, + created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, 'Y', $18, CURRENT_TIMESTAMP) + RETURNING * + `; + + const result = await pool.query(query, [ + relationCode, + relationName, + description || null, + parentTable, + parentValueColumn, + parentLabelColumn || null, + childTable, + childFilterColumn, + childValueColumn, + childLabelColumn, + childOrderColumn || null, + childOrderDirection || "ASC", + emptyParentMessage || "상위 항목을 먼저 선택하세요", + noOptionsMessage || "선택 가능한 항목이 없습니다", + loadingMessage || "로딩 중...", + clearOnParentChange !== false ? "Y" : "N", + companyCode, + userId, + ]); + + logger.info("연쇄 관계 생성", { + relationId: result.rows[0].relation_id, + relationCode, + companyCode, + userId, + }); + + return res.status(201).json({ + success: true, + data: result.rows[0], + message: "연쇄 관계가 생성되었습니다.", + }); + } catch (error: any) { + logger.error("연쇄 관계 생성 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "연쇄 관계 생성에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 연쇄 관계 수정 + */ +export const updateCascadingRelation = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + const { + relationName, + description, + parentTable, + parentValueColumn, + parentLabelColumn, + childTable, + childFilterColumn, + childValueColumn, + childLabelColumn, + childOrderColumn, + childOrderDirection, + emptyParentMessage, + noOptionsMessage, + loadingMessage, + clearOnParentChange, + isActive, + } = req.body; + + // 권한 체크 + const existingCheck = await pool.query( + `SELECT relation_id, company_code FROM cascading_relation WHERE relation_id = $1`, + [id] + ); + + if (existingCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "연쇄 관계를 찾을 수 없습니다.", + }); + } + + // 다른 회사의 데이터는 수정 불가 (최고 관리자 제외) + const existingCompanyCode = existingCheck.rows[0].company_code; + if ( + companyCode !== "*" && + existingCompanyCode !== companyCode && + existingCompanyCode !== "*" + ) { + return res.status(403).json({ + success: false, + message: "수정 권한이 없습니다.", + }); + } + + const query = ` + UPDATE cascading_relation SET + relation_name = COALESCE($1, relation_name), + description = COALESCE($2, description), + parent_table = COALESCE($3, parent_table), + parent_value_column = COALESCE($4, parent_value_column), + parent_label_column = COALESCE($5, parent_label_column), + child_table = COALESCE($6, child_table), + child_filter_column = COALESCE($7, child_filter_column), + child_value_column = COALESCE($8, child_value_column), + child_label_column = COALESCE($9, child_label_column), + child_order_column = COALESCE($10, child_order_column), + child_order_direction = COALESCE($11, child_order_direction), + empty_parent_message = COALESCE($12, empty_parent_message), + no_options_message = COALESCE($13, no_options_message), + loading_message = COALESCE($14, loading_message), + clear_on_parent_change = COALESCE($15, clear_on_parent_change), + is_active = COALESCE($16, is_active), + updated_by = $17, + updated_date = CURRENT_TIMESTAMP + WHERE relation_id = $18 + RETURNING * + `; + + const result = await pool.query(query, [ + relationName, + description, + parentTable, + parentValueColumn, + parentLabelColumn, + childTable, + childFilterColumn, + childValueColumn, + childLabelColumn, + childOrderColumn, + childOrderDirection, + emptyParentMessage, + noOptionsMessage, + loadingMessage, + clearOnParentChange !== undefined + ? clearOnParentChange + ? "Y" + : "N" + : null, + isActive !== undefined ? (isActive ? "Y" : "N") : null, + userId, + id, + ]); + + logger.info("연쇄 관계 수정", { + relationId: id, + companyCode, + userId, + }); + + return res.json({ + success: true, + data: result.rows[0], + message: "연쇄 관계가 수정되었습니다.", + }); + } catch (error: any) { + logger.error("연쇄 관계 수정 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "연쇄 관계 수정에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 연쇄 관계 삭제 + */ +export const deleteCascadingRelation = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + // 권한 체크 + const existingCheck = await pool.query( + `SELECT relation_id, company_code FROM cascading_relation WHERE relation_id = $1`, + [id] + ); + + if (existingCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "연쇄 관계를 찾을 수 없습니다.", + }); + } + + // 다른 회사의 데이터는 삭제 불가 (최고 관리자 제외) + const existingCompanyCode = existingCheck.rows[0].company_code; + if ( + companyCode !== "*" && + existingCompanyCode !== companyCode && + existingCompanyCode !== "*" + ) { + return res.status(403).json({ + success: false, + message: "삭제 권한이 없습니다.", + }); + } + + // 소프트 삭제 (is_active = 'N') + await pool.query( + `UPDATE cascading_relation SET is_active = 'N', updated_by = $1, updated_date = CURRENT_TIMESTAMP WHERE relation_id = $2`, + [userId, id] + ); + + logger.info("연쇄 관계 삭제", { + relationId: id, + companyCode, + userId, + }); + + return res.json({ + success: true, + message: "연쇄 관계가 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("연쇄 관계 삭제 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "연쇄 관계 삭제에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 🆕 연쇄 관계로 부모 옵션 조회 (상위 선택 역할용) + * parent_table에서 전체 옵션을 조회합니다. + */ +export const getParentOptions = async (req: Request, res: Response) => { + try { + const { code } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 관계 정보 조회 + let relationQuery = ` + SELECT + parent_table, + parent_value_column, + parent_label_column + FROM cascading_relation + WHERE relation_code = $1 + AND is_active = 'Y' + `; + + const relationParams: any[] = [code]; + + // 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용) + if (companyCode !== "*") { + relationQuery += ` AND company_code = $2`; + relationParams.push(companyCode); + } + relationQuery += ` LIMIT 1`; + + const relationResult = await pool.query(relationQuery, relationParams); + + if (relationResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "연쇄 관계를 찾을 수 없습니다.", + }); + } + + const relation = relationResult.rows[0]; + + // 라벨 컬럼이 없으면 값 컬럼 사용 + const labelColumn = + relation.parent_label_column || relation.parent_value_column; + + // 부모 옵션 조회 + let optionsQuery = ` + SELECT + ${relation.parent_value_column} as value, + ${labelColumn} as label + FROM ${relation.parent_table} + WHERE 1=1 + `; + + // 멀티테넌시 적용 (테이블에 company_code가 있는 경우) + const tableInfoResult = await pool.query( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [relation.parent_table] + ); + + const optionsParams: any[] = []; + + // company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만 + if ( + tableInfoResult.rowCount && + tableInfoResult.rowCount > 0 && + companyCode !== "*" + ) { + optionsQuery += ` AND company_code = $1`; + optionsParams.push(companyCode); + } + + // status 컬럼이 있으면 활성 상태만 조회 + const statusInfoResult = await pool.query( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'status'`, + [relation.parent_table] + ); + + if (statusInfoResult.rowCount && statusInfoResult.rowCount > 0) { + optionsQuery += ` AND (status IS NULL OR status != 'N')`; + } + + // 정렬 + optionsQuery += ` ORDER BY ${labelColumn} ASC`; + + const optionsResult = await pool.query(optionsQuery, optionsParams); + + logger.info("부모 옵션 조회", { + relationCode: code, + parentTable: relation.parent_table, + optionsCount: optionsResult.rowCount, + }); + + return res.json({ + success: true, + data: optionsResult.rows, + }); + } catch (error: any) { + logger.error("부모 옵션 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "부모 옵션 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 연쇄 관계로 자식 옵션 조회 + * 실제 연쇄 드롭다운에서 사용하는 API + */ +export const getCascadingOptions = async (req: Request, res: Response) => { + try { + const { code } = req.params; + const { parentValue } = req.query; + const companyCode = req.user?.companyCode || "*"; + + if (!parentValue) { + return res.json({ + success: true, + data: [], + message: "부모 값이 없습니다.", + }); + } + + // 관계 정보 조회 + let relationQuery = ` + SELECT + child_table, + child_filter_column, + child_value_column, + child_label_column, + child_order_column, + child_order_direction + FROM cascading_relation + WHERE relation_code = $1 + AND is_active = 'Y' + `; + + const relationParams: any[] = [code]; + + // 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용) + if (companyCode !== "*") { + relationQuery += ` AND company_code = $2`; + relationParams.push(companyCode); + } + relationQuery += ` LIMIT 1`; + + const relationResult = await pool.query(relationQuery, relationParams); + + if (relationResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "연쇄 관계를 찾을 수 없습니다.", + }); + } + + const relation = relationResult.rows[0]; + + // 자식 옵션 조회 + let optionsQuery = ` + SELECT + ${relation.child_value_column} as value, + ${relation.child_label_column} as label + FROM ${relation.child_table} + WHERE ${relation.child_filter_column} = $1 + `; + + // 멀티테넌시 적용 (테이블에 company_code가 있는 경우) + const tableInfoResult = await pool.query( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [relation.child_table] + ); + + const optionsParams: any[] = [parentValue]; + + // company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만 + if ( + tableInfoResult.rowCount && + tableInfoResult.rowCount > 0 && + companyCode !== "*" + ) { + optionsQuery += ` AND company_code = $2`; + optionsParams.push(companyCode); + } + + // 정렬 + if (relation.child_order_column) { + optionsQuery += ` ORDER BY ${relation.child_order_column} ${relation.child_order_direction || "ASC"}`; + } else { + optionsQuery += ` ORDER BY ${relation.child_label_column} ASC`; + } + + const optionsResult = await pool.query(optionsQuery, optionsParams); + + logger.info("연쇄 옵션 조회", { + relationCode: code, + parentValue, + optionsCount: optionsResult.rowCount, + }); + + return res.json({ + success: true, + data: optionsResult.rows, + }); + } catch (error: any) { + logger.error("연쇄 옵션 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "연쇄 옵션 조회에 실패했습니다.", + error: error.message, + }); + } +}; diff --git a/backend-node/src/routes/cascadingAutoFillRoutes.ts b/backend-node/src/routes/cascadingAutoFillRoutes.ts new file mode 100644 index 00000000..5d922dd6 --- /dev/null +++ b/backend-node/src/routes/cascadingAutoFillRoutes.ts @@ -0,0 +1,52 @@ +/** + * 자동 입력 (Auto-Fill) 라우트 + */ + +import express from "express"; +import { + getAutoFillGroups, + getAutoFillGroupDetail, + createAutoFillGroup, + updateAutoFillGroup, + deleteAutoFillGroup, + getAutoFillMasterOptions, + getAutoFillData, +} from "../controllers/cascadingAutoFillController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); + +// 인증 미들웨어 적용 +router.use(authenticateToken); + +// ===================================================== +// 자동 입력 그룹 관리 API +// ===================================================== + +// 그룹 목록 조회 +router.get("/groups", getAutoFillGroups); + +// 그룹 상세 조회 (매핑 포함) +router.get("/groups/:groupCode", getAutoFillGroupDetail); + +// 그룹 생성 +router.post("/groups", createAutoFillGroup); + +// 그룹 수정 +router.put("/groups/:groupCode", updateAutoFillGroup); + +// 그룹 삭제 +router.delete("/groups/:groupCode", deleteAutoFillGroup); + +// ===================================================== +// 자동 입력 데이터 조회 API (실제 사용) +// ===================================================== + +// 마스터 옵션 목록 조회 +router.get("/options/:groupCode", getAutoFillMasterOptions); + +// 자동 입력 데이터 조회 +router.get("/data/:groupCode", getAutoFillData); + +export default router; + diff --git a/backend-node/src/routes/cascadingConditionRoutes.ts b/backend-node/src/routes/cascadingConditionRoutes.ts new file mode 100644 index 00000000..813dbff1 --- /dev/null +++ b/backend-node/src/routes/cascadingConditionRoutes.ts @@ -0,0 +1,48 @@ +/** + * 조건부 연쇄 (Conditional Cascading) 라우트 + */ + +import express from "express"; +import { + getConditions, + getConditionDetail, + createCondition, + updateCondition, + deleteCondition, + getFilteredOptions, +} from "../controllers/cascadingConditionController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); + +// 인증 미들웨어 적용 +router.use(authenticateToken); + +// ===================================================== +// 조건부 연쇄 규칙 관리 API +// ===================================================== + +// 규칙 목록 조회 +router.get("/", getConditions); + +// 규칙 상세 조회 +router.get("/:conditionId", getConditionDetail); + +// 규칙 생성 +router.post("/", createCondition); + +// 규칙 수정 +router.put("/:conditionId", updateCondition); + +// 규칙 삭제 +router.delete("/:conditionId", deleteCondition); + +// ===================================================== +// 조건부 필터링 적용 API (실제 사용) +// ===================================================== + +// 조건에 따른 필터링된 옵션 조회 +router.get("/filtered-options/:relationCode", getFilteredOptions); + +export default router; + diff --git a/backend-node/src/routes/cascadingHierarchyRoutes.ts b/backend-node/src/routes/cascadingHierarchyRoutes.ts new file mode 100644 index 00000000..be37da49 --- /dev/null +++ b/backend-node/src/routes/cascadingHierarchyRoutes.ts @@ -0,0 +1,64 @@ +/** + * 다단계 계층 (Hierarchy) 라우트 + */ + +import express from "express"; +import { + getHierarchyGroups, + getHierarchyGroupDetail, + createHierarchyGroup, + updateHierarchyGroup, + deleteHierarchyGroup, + addLevel, + updateLevel, + deleteLevel, + getLevelOptions, +} from "../controllers/cascadingHierarchyController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); + +// 인증 미들웨어 적용 +router.use(authenticateToken); + +// ===================================================== +// 계층 그룹 관리 API +// ===================================================== + +// 그룹 목록 조회 +router.get("/", getHierarchyGroups); + +// 그룹 상세 조회 (레벨 포함) +router.get("/:groupCode", getHierarchyGroupDetail); + +// 그룹 생성 +router.post("/", createHierarchyGroup); + +// 그룹 수정 +router.put("/:groupCode", updateHierarchyGroup); + +// 그룹 삭제 +router.delete("/:groupCode", deleteHierarchyGroup); + +// ===================================================== +// 계층 레벨 관리 API +// ===================================================== + +// 레벨 추가 +router.post("/:groupCode/levels", addLevel); + +// 레벨 수정 +router.put("/levels/:levelId", updateLevel); + +// 레벨 삭제 +router.delete("/levels/:levelId", deleteLevel); + +// ===================================================== +// 계층 옵션 조회 API (실제 사용) +// ===================================================== + +// 특정 레벨의 옵션 조회 +router.get("/:groupCode/options/:levelOrder", getLevelOptions); + +export default router; + diff --git a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts new file mode 100644 index 00000000..46bbf427 --- /dev/null +++ b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts @@ -0,0 +1,52 @@ +/** + * 상호 배제 (Mutual Exclusion) 라우트 + */ + +import express from "express"; +import { + getExclusions, + getExclusionDetail, + createExclusion, + updateExclusion, + deleteExclusion, + validateExclusion, + getExcludedOptions, +} from "../controllers/cascadingMutualExclusionController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); + +// 인증 미들웨어 적용 +router.use(authenticateToken); + +// ===================================================== +// 상호 배제 규칙 관리 API +// ===================================================== + +// 규칙 목록 조회 +router.get("/", getExclusions); + +// 규칙 상세 조회 +router.get("/:exclusionId", getExclusionDetail); + +// 규칙 생성 +router.post("/", createExclusion); + +// 규칙 수정 +router.put("/:exclusionId", updateExclusion); + +// 규칙 삭제 +router.delete("/:exclusionId", deleteExclusion); + +// ===================================================== +// 상호 배제 검증 및 옵션 API (실제 사용) +// ===================================================== + +// 상호 배제 검증 +router.post("/validate/:exclusionCode", validateExclusion); + +// 배제된 옵션 조회 +router.get("/options/:exclusionCode", getExcludedOptions); + +export default router; + diff --git a/backend-node/src/routes/cascadingRelationRoutes.ts b/backend-node/src/routes/cascadingRelationRoutes.ts new file mode 100644 index 00000000..28e66387 --- /dev/null +++ b/backend-node/src/routes/cascadingRelationRoutes.ts @@ -0,0 +1,44 @@ +import { Router } from "express"; +import { + getCascadingRelations, + getCascadingRelationById, + getCascadingRelationByCode, + createCascadingRelation, + updateCascadingRelation, + deleteCascadingRelation, + getCascadingOptions, + getParentOptions, +} from "../controllers/cascadingRelationController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 적용 +router.use(authenticateToken); + +// 연쇄 관계 목록 조회 +router.get("/", getCascadingRelations); + +// 연쇄 관계 상세 조회 (ID) +router.get("/:id", getCascadingRelationById); + +// 연쇄 관계 코드로 조회 +router.get("/code/:code", getCascadingRelationByCode); + +// 🆕 연쇄 관계로 부모 옵션 조회 (상위 선택 역할용) +router.get("/parent-options/:code", getParentOptions); + +// 연쇄 관계로 자식 옵션 조회 (실제 드롭다운에서 사용) +router.get("/options/:code", getCascadingOptions); + +// 연쇄 관계 생성 +router.post("/", createCascadingRelation); + +// 연쇄 관계 수정 +router.put("/:id", updateCascadingRelation); + +// 연쇄 관계 삭제 +router.delete("/:id", deleteCascadingRelation); + +export default router; + diff --git a/backend-node/src/routes/dataflow/node-flows.ts b/backend-node/src/routes/dataflow/node-flows.ts index f13d65cf..6de84866 100644 --- a/backend-node/src/routes/dataflow/node-flows.ts +++ b/backend-node/src/routes/dataflow/node-flows.ts @@ -218,46 +218,62 @@ router.delete("/:flowId", async (req: Request, res: Response) => { * 플로우 실행 * POST /api/dataflow/node-flows/:flowId/execute */ -router.post("/:flowId/execute", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - try { - const { flowId } = req.params; - const contextData = req.body; +router.post( + "/:flowId/execute", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const { flowId } = req.params; + const contextData = req.body; - logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, { - contextDataKeys: Object.keys(contextData), - userId: req.user?.userId, - companyCode: req.user?.companyCode, - }); + logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, { + contextDataKeys: Object.keys(contextData), + userId: req.user?.userId, + companyCode: req.user?.companyCode, + }); - // 사용자 정보를 contextData에 추가 - const enrichedContextData = { - ...contextData, - userId: req.user?.userId, - userName: req.user?.userName, - companyCode: req.user?.companyCode, - }; + // 🔍 디버깅: req.user 전체 확인 + logger.info(`🔍 req.user 전체 정보:`, { + user: req.user, + hasUser: !!req.user, + }); - // 플로우 실행 - const result = await NodeFlowExecutionService.executeFlow( - parseInt(flowId, 10), - enrichedContextData - ); + // 사용자 정보를 contextData에 추가 + const enrichedContextData = { + ...contextData, + userId: req.user?.userId, + userName: req.user?.userName, + companyCode: req.user?.companyCode, + }; - return res.json({ - success: result.success, - message: result.message, - data: result, - }); - } catch (error) { - logger.error("플로우 실행 실패:", error); - return res.status(500).json({ - success: false, - message: - error instanceof Error - ? error.message - : "플로우 실행 중 오류가 발생했습니다.", - }); + // 🔍 디버깅: enrichedContextData 확인 + logger.info(`🔍 enrichedContextData:`, { + userId: enrichedContextData.userId, + companyCode: enrichedContextData.companyCode, + }); + + // 플로우 실행 + const result = await NodeFlowExecutionService.executeFlow( + parseInt(flowId, 10), + enrichedContextData + ); + + return res.json({ + success: result.success, + message: result.message, + data: result, + }); + } catch (error) { + logger.error("플로우 실행 실패:", error); + return res.status(500).json({ + success: false, + message: + error instanceof Error + ? error.message + : "플로우 실행 중 오류가 발생했습니다.", + }); + } } -}); +); export default router; diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index a278eb97..a1a494f2 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -1,12 +1,12 @@ /** * 동적 데이터 서비스 - * + * * 주요 특징: * 1. 화이트리스트 제거 - 모든 테이블에 동적으로 접근 가능 * 2. 블랙리스트 방식 - 시스템 중요 테이블만 접근 금지 * 3. 자동 회사별 필터링 - company_code 컬럼 자동 감지 및 필터 적용 * 4. SQL 인젝션 방지 - 정규식 기반 테이블명/컬럼명 검증 - * + * * 보안: * - 테이블명은 영문, 숫자, 언더스코어만 허용 * - 시스템 테이블(pg_*, information_schema 등) 접근 금지 @@ -70,11 +70,11 @@ class DataService { // 그룹별로 데이터 분류 const groups: Record = {}; - + for (const row of data) { const groupKey = row[config.groupByColumn]; if (groupKey === undefined || groupKey === null) continue; - + if (!groups[groupKey]) { groups[groupKey] = []; } @@ -83,12 +83,12 @@ class DataService { // 각 그룹에서 하나의 행만 선택 const result: any[] = []; - + for (const [groupKey, rows] of Object.entries(groups)) { if (rows.length === 0) continue; - + let selectedRow: any; - + switch (config.keepStrategy) { case "latest": // 정렬 컬럼 기준 최신 (가장 큰 값) @@ -103,7 +103,7 @@ class DataService { } selectedRow = rows[0]; break; - + case "earliest": // 정렬 컬럼 기준 최초 (가장 작은 값) if (config.sortColumn) { @@ -117,38 +117,41 @@ class DataService { } selectedRow = rows[0]; break; - + case "base_price": // base_price = true인 행 찾기 - selectedRow = rows.find(row => row.base_price === true) || rows[0]; + selectedRow = rows.find((row) => row.base_price === true) || rows[0]; break; - + case "current_date": // start_date <= CURRENT_DATE <= end_date 조건에 맞는 행 const today = new Date(); today.setHours(0, 0, 0, 0); // 시간 제거 - - selectedRow = rows.find(row => { - const startDate = row.start_date ? new Date(row.start_date) : null; - const endDate = row.end_date ? new Date(row.end_date) : null; - - if (startDate) startDate.setHours(0, 0, 0, 0); - if (endDate) endDate.setHours(0, 0, 0, 0); - - const afterStart = !startDate || today >= startDate; - const beforeEnd = !endDate || today <= endDate; - - return afterStart && beforeEnd; - }) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행 + + selectedRow = + rows.find((row) => { + const startDate = row.start_date + ? new Date(row.start_date) + : null; + const endDate = row.end_date ? new Date(row.end_date) : null; + + if (startDate) startDate.setHours(0, 0, 0, 0); + if (endDate) endDate.setHours(0, 0, 0, 0); + + const afterStart = !startDate || today >= startDate; + const beforeEnd = !endDate || today <= endDate; + + return afterStart && beforeEnd; + }) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행 break; - + default: selectedRow = rows[0]; } - + result.push(selectedRow); } - + return result; } @@ -230,12 +233,17 @@ class DataService { // 4. 회사별 필터링 자동 적용 (company_code 컬럼이 있는 경우) if (userCompany && userCompany !== "*") { - const hasCompanyCode = await this.checkColumnExists(tableName, "company_code"); + const hasCompanyCode = await this.checkColumnExists( + tableName, + "company_code" + ); if (hasCompanyCode) { whereConditions.push(`company_code = $${paramIndex}`); queryParams.push(userCompany); paramIndex++; - console.log(`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`); + console.log( + `🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}` + ); } } @@ -508,7 +516,8 @@ class DataService { const entityJoinService = new EntityJoinService(); // Entity Join 구성 감지 - const joinConfigs = await entityJoinService.detectEntityJoins(tableName); + const joinConfigs = + await entityJoinService.detectEntityJoins(tableName); if (joinConfigs.length > 0) { console.log(`✅ Entity Join 감지: ${joinConfigs.length}개`); @@ -518,7 +527,7 @@ class DataService { tableName, joinConfigs, ["*"], - `main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결 + `main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결 ); const result = await pool.query(joinQuery, [id]); @@ -533,14 +542,14 @@ class DataService { // 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환 const normalizeDates = (rows: any[]) => { - return rows.map(row => { + return rows.map((row) => { const normalized: any = {}; for (const [key, value] of Object.entries(row)) { if (value instanceof Date) { // Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시) const year = value.getFullYear(); - const month = String(value.getMonth() + 1).padStart(2, '0'); - const day = String(value.getDate()).padStart(2, '0'); + const month = String(value.getMonth() + 1).padStart(2, "0"); + const day = String(value.getDate()).padStart(2, "0"); normalized[key] = `${year}-${month}-${day}`; } else { normalized[key] = value; @@ -551,17 +560,20 @@ class DataService { }; const normalizedRows = normalizeDates(result.rows); - console.log(`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`, normalizedRows[0]); + console.log( + `✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`, + normalizedRows[0] + ); // 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회 if (groupByColumns.length > 0) { const baseRecord = result.rows[0]; - + // 그룹핑 컬럼들의 값 추출 const groupConditions: string[] = []; const groupValues: any[] = []; let paramIndex = 1; - + for (const col of groupByColumns) { const value = normalizedRows[0][col]; if (value !== undefined && value !== null) { @@ -570,12 +582,15 @@ class DataService { paramIndex++; } } - + if (groupConditions.length > 0) { const groupWhereClause = groupConditions.join(" AND "); - - console.log(`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`, groupValues); - + + console.log( + `🔍 그룹핑 조회: ${groupByColumns.join(", ")}`, + groupValues + ); + // 그룹핑 기준으로 모든 레코드 조회 const { query: groupQuery } = entityJoinService.buildJoinQuery( tableName, @@ -583,12 +598,14 @@ class DataService { ["*"], groupWhereClause ); - + const groupResult = await pool.query(groupQuery, groupValues); - + const normalizedGroupRows = normalizeDates(groupResult.rows); - console.log(`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}개`); - + console.log( + `✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}개` + ); + return { success: true, data: normalizedGroupRows, // 🔧 배열로 반환! @@ -642,7 +659,8 @@ class DataService { dataFilter?: any, // 🆕 데이터 필터 enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화 displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등) - deduplication?: { // 🆕 중복 제거 설정 + deduplication?: { + // 🆕 중복 제거 설정 enabled: boolean; groupByColumn: string; keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; @@ -666,36 +684,41 @@ class DataService { if (enableEntityJoin) { try { const { entityJoinService } = await import("./entityJoinService"); - const joinConfigs = await entityJoinService.detectEntityJoins(rightTable); + const joinConfigs = + await entityJoinService.detectEntityJoins(rightTable); // 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등) if (displayColumns && Array.isArray(displayColumns)) { // 테이블별로 요청된 컬럼들을 그룹핑 const tableColumns: Record> = {}; - + for (const col of displayColumns) { - if (col.name && col.name.includes('.')) { - const [refTable, refColumn] = col.name.split('.'); + if (col.name && col.name.includes(".")) { + const [refTable, refColumn] = col.name.split("."); if (!tableColumns[refTable]) { tableColumns[refTable] = new Set(); } tableColumns[refTable].add(refColumn); } } - + // 각 테이블별로 처리 for (const [refTable, refColumns] of Object.entries(tableColumns)) { // 이미 조인 설정에 있는지 확인 - const existingJoins = joinConfigs.filter(jc => jc.referenceTable === refTable); - + const existingJoins = joinConfigs.filter( + (jc) => jc.referenceTable === refTable + ); + if (existingJoins.length > 0) { // 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리 for (const refColumn of refColumns) { // 이미 해당 컬럼을 표시하는 조인이 있는지 확인 const existingJoin = existingJoins.find( - jc => jc.displayColumns.length === 1 && jc.displayColumns[0] === refColumn + (jc) => + jc.displayColumns.length === 1 && + jc.displayColumns[0] === refColumn ); - + if (!existingJoin) { // 없으면 새 조인 설정 복제하여 추가 const baseJoin = existingJoins[0]; @@ -708,7 +731,9 @@ class DataService { referenceColumn: baseJoin.referenceColumn, // item_number 등 }; joinConfigs.push(newJoin); - console.log(`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`); + console.log( + `📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})` + ); } } } else { @@ -718,7 +743,9 @@ class DataService { } if (joinConfigs.length > 0) { - console.log(`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`); + console.log( + `🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정` + ); // WHERE 조건 생성 const whereConditions: string[] = []; @@ -735,7 +762,10 @@ class DataService { // 회사별 필터링 if (userCompany && userCompany !== "*") { - const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code"); + const hasCompanyCode = await this.checkColumnExists( + rightTable, + "company_code" + ); if (hasCompanyCode) { whereConditions.push(`main.company_code = $${paramIndex}`); values.push(userCompany); @@ -744,48 +774,64 @@ class DataService { } // 데이터 필터 적용 (buildDataFilterWhereClause 사용) - if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) { - const { buildDataFilterWhereClause } = await import("../utils/dataFilterUtil"); - const filterResult = buildDataFilterWhereClause(dataFilter, "main", paramIndex); + if ( + dataFilter && + dataFilter.enabled && + dataFilter.filters && + dataFilter.filters.length > 0 + ) { + const { buildDataFilterWhereClause } = await import( + "../utils/dataFilterUtil" + ); + const filterResult = buildDataFilterWhereClause( + dataFilter, + "main", + paramIndex + ); if (filterResult.whereClause) { whereConditions.push(filterResult.whereClause); values.push(...filterResult.params); paramIndex += filterResult.params.length; - console.log(`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause); + console.log( + `🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`, + filterResult.whereClause + ); console.log(`📊 필터 파라미터:`, filterResult.params); } } - const whereClause = whereConditions.length > 0 ? whereConditions.join(" AND ") : ""; + const whereClause = + whereConditions.length > 0 ? whereConditions.join(" AND ") : ""; // Entity 조인 쿼리 빌드 // buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달 const selectColumns = ["*"]; - const { query: finalQuery, aliasMap } = entityJoinService.buildJoinQuery( - rightTable, - joinConfigs, - selectColumns, - whereClause, - "", - undefined, - undefined - ); + const { query: finalQuery, aliasMap } = + entityJoinService.buildJoinQuery( + rightTable, + joinConfigs, + selectColumns, + whereClause, + "", + undefined, + undefined + ); console.log(`🔍 Entity 조인 쿼리 실행 (전체):`, finalQuery); console.log(`🔍 파라미터:`, values); const result = await pool.query(finalQuery, values); - + // 🔧 날짜 타입 타임존 문제 해결 const normalizeDates = (rows: any[]) => { - return rows.map(row => { + return rows.map((row) => { const normalized: any = {}; for (const [key, value] of Object.entries(row)) { if (value instanceof Date) { const year = value.getFullYear(); - const month = String(value.getMonth() + 1).padStart(2, '0'); - const day = String(value.getDate()).padStart(2, '0'); + const month = String(value.getMonth() + 1).padStart(2, "0"); + const day = String(value.getDate()).padStart(2, "0"); normalized[key] = `${year}-${month}-${day}`; } else { normalized[key] = value; @@ -794,18 +840,24 @@ class DataService { return normalized; }); }; - + const normalizedRows = normalizeDates(result.rows); - console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`); - + console.log( + `✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)` + ); + // 🆕 중복 제거 처리 let finalData = normalizedRows; if (deduplication?.enabled && deduplication.groupByColumn) { - console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`); + console.log( + `🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}` + ); finalData = this.deduplicateData(normalizedRows, deduplication); - console.log(`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}개`); + console.log( + `✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}개` + ); } - + return { success: true, data: finalData, @@ -838,23 +890,40 @@ class DataService { // 우측 테이블 회사별 필터링 (company_code 컬럼이 있는 경우) if (userCompany && userCompany !== "*") { - const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code"); + const hasCompanyCode = await this.checkColumnExists( + rightTable, + "company_code" + ); if (hasCompanyCode) { whereConditions.push(`r.company_code = $${paramIndex}`); values.push(userCompany); paramIndex++; - console.log(`🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}`); + console.log( + `🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}` + ); } } // 🆕 데이터 필터 적용 (우측 패널에 대해, 테이블 별칭 "r" 사용) - if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) { - const filterResult = buildDataFilterWhereClause(dataFilter, "r", paramIndex); + if ( + dataFilter && + dataFilter.enabled && + dataFilter.filters && + dataFilter.filters.length > 0 + ) { + const filterResult = buildDataFilterWhereClause( + dataFilter, + "r", + paramIndex + ); if (filterResult.whereClause) { whereConditions.push(filterResult.whereClause); values.push(...filterResult.params); paramIndex += filterResult.params.length; - console.log(`🔍 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause); + console.log( + `🔍 데이터 필터 적용 (${rightTable}):`, + filterResult.whereClause + ); } } @@ -871,9 +940,13 @@ class DataService { // 🆕 중복 제거 처리 let finalData = result; if (deduplication?.enabled && deduplication.groupByColumn) { - console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`); + console.log( + `🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}` + ); finalData = this.deduplicateData(result, deduplication); - console.log(`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}개`); + console.log( + `✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}개` + ); } return { @@ -909,8 +982,10 @@ class DataService { // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외) const tableColumns = await this.getTableColumnsSimple(tableName); - const validColumnNames = new Set(tableColumns.map((col: any) => col.column_name)); - + const validColumnNames = new Set( + tableColumns.map((col: any) => col.column_name) + ); + const invalidColumns: string[] = []; const filteredData = Object.fromEntries( Object.entries(data).filter(([key]) => { @@ -921,9 +996,11 @@ class DataService { return false; }) ); - + if (invalidColumns.length > 0) { - console.log(`⚠️ [createRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`); + console.log( + `⚠️ [createRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}` + ); } const columns = Object.keys(filteredData); @@ -975,8 +1052,10 @@ class DataService { // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외) const tableColumns = await this.getTableColumnsSimple(tableName); - const validColumnNames = new Set(tableColumns.map((col: any) => col.column_name)); - + const validColumnNames = new Set( + tableColumns.map((col: any) => col.column_name) + ); + const invalidColumns: string[] = []; cleanData = Object.fromEntries( Object.entries(cleanData).filter(([key]) => { @@ -987,9 +1066,11 @@ class DataService { return false; }) ); - + if (invalidColumns.length > 0) { - console.log(`⚠️ [updateRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`); + console.log( + `⚠️ [updateRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}` + ); } // Primary Key 컬럼 찾기 @@ -1031,8 +1112,14 @@ class DataService { } // 🔗 조인 관계가 있는 경우, 연결된 테이블의 FK도 업데이트 - if (relationInfo && relationInfo.rightTable && relationInfo.leftColumn && relationInfo.rightColumn) { - const { rightTable, leftColumn, rightColumn, oldLeftValue } = relationInfo; + if ( + relationInfo && + relationInfo.rightTable && + relationInfo.leftColumn && + relationInfo.rightColumn + ) { + const { rightTable, leftColumn, rightColumn, oldLeftValue } = + relationInfo; const newLeftValue = cleanData[leftColumn]; // leftColumn 값이 변경된 경우에만 우측 테이블 업데이트 @@ -1050,8 +1137,13 @@ class DataService { SET "${rightColumn}" = $1 WHERE "${rightColumn}" = $2 `; - const updateResult = await query(updateRelatedQuery, [newLeftValue, oldLeftValue]); - console.log(`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`); + const updateResult = await query(updateRelatedQuery, [ + newLeftValue, + oldLeftValue, + ]); + console.log( + `✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료` + ); } catch (relError) { console.error("❌ 연결된 테이블 업데이트 실패:", relError); // 연결된 테이블 업데이트 실패 시 롤백은 하지 않고 경고만 로그 @@ -1102,9 +1194,11 @@ class DataService { if (pkResult.length > 1) { // 복합키인 경우: id가 객체여야 함 - console.log(`🔑 복합키 테이블: ${tableName}, PK: [${pkResult.map(r => r.attname).join(', ')}]`); - - if (typeof id === 'object' && !Array.isArray(id)) { + console.log( + `🔑 복합키 테이블: ${tableName}, PK: [${pkResult.map((r) => r.attname).join(", ")}]` + ); + + if (typeof id === "object" && !Array.isArray(id)) { // id가 객체인 경우: { user_id: 'xxx', dept_code: 'yyy' } pkResult.forEach((pk, index) => { whereClauses.push(`"${pk.attname}" = $${index + 1}`); @@ -1119,15 +1213,17 @@ class DataService { // 단일키인 경우 const pkColumn = pkResult.length > 0 ? pkResult[0].attname : "id"; whereClauses.push(`"${pkColumn}" = $1`); - params.push(typeof id === 'object' ? id[pkColumn] : id); + params.push(typeof id === "object" ? id[pkColumn] : id); } - const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(' AND ')}`; + const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")}`; console.log(`🗑️ 삭제 쿼리:`, queryText, params); - + const result = await query(queryText, params); - - console.log(`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`); + + console.log( + `✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}` + ); return { success: true, @@ -1166,7 +1262,11 @@ class DataService { } if (whereConditions.length === 0) { - return { success: false, message: "삭제 조건이 없습니다.", error: "NO_CONDITIONS" }; + return { + success: false, + message: "삭제 조건이 없습니다.", + error: "NO_CONDITIONS", + }; } const whereClause = whereConditions.join(" AND "); @@ -1201,7 +1301,9 @@ class DataService { records: Array>, userCompany?: string, userId?: string - ): Promise> { + ): Promise< + ServiceResponse<{ inserted: number; updated: number; deleted: number }> + > { try { // 테이블 접근 권한 검증 const validation = await this.validateTableAccess(tableName); @@ -1239,11 +1341,14 @@ class DataService { const whereClause = whereConditions.join(" AND "); const selectQuery = `SELECT * FROM "${tableName}" WHERE ${whereClause}`; - - console.log(`📋 기존 레코드 조회:`, { query: selectQuery, values: whereValues }); - + + console.log(`📋 기존 레코드 조회:`, { + query: selectQuery, + values: whereValues, + }); + const existingRecords = await pool.query(selectQuery, whereValues); - + console.log(`✅ 기존 레코드: ${existingRecords.rows.length}개`); // 2. 새 레코드와 기존 레코드 비교 @@ -1254,50 +1359,53 @@ 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 만 추출 + if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) { + return value.split("T")[0]; // YYYY-MM-DD 만 추출 } - + return value; }; // 새 레코드 처리 (INSERT or UPDATE) for (const newRecord of records) { console.log(`🔍 처리할 새 레코드:`, newRecord); - + // 날짜 필드 정규화 const normalizedRecord: Record = {}; for (const [key, value] of Object.entries(newRecord)) { normalizedRecord[key] = normalizeDateValue(value); } - + console.log(`🔄 정규화된 레코드:`, normalizedRecord); - + // 전체 레코드 데이터 (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]; + if (existingValue instanceof Date && typeof newValue === "string") { + return ( + existingValue.toISOString().split("T")[0] === + newValue.split("T")[0] + ); } - + // 문자열 비교 return String(existingValue) === String(newValue); }); @@ -1310,7 +1418,8 @@ class DataService { let updateParamIndex = 1; for (const [key, value] of Object.entries(fullRecord)) { - if (key !== pkColumn) { // Primary Key는 업데이트하지 않음 + if (key !== pkColumn) { + // Primary Key는 업데이트하지 않음 updateFields.push(`"${key}" = $${updateParamIndex}`); updateValues.push(value); updateParamIndex++; @@ -1326,36 +1435,42 @@ class DataService { await pool.query(updateQuery, updateValues); updated++; - + console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`); } else { // INSERT: 기존 레코드가 없으면 삽입 - + // 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id) + // created_date는 프론트엔드에서 전달된 값 무시하고 항상 현재 시간 설정 + const { created_date: _, ...recordWithoutCreatedDate } = fullRecord; const recordWithMeta: Record = { - ...fullRecord, + ...recordWithoutCreatedDate, id: uuidv4(), // 새 ID 생성 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 insertFields = Object.keys(recordWithMeta).filter( + (key) => recordWithMeta[key] !== "NOW()" ); const insertPlaceholders: string[] = []; const insertValues: any[] = []; let insertParamIndex = 1; - + for (const field of Object.keys(recordWithMeta)) { if (recordWithMeta[field] === "NOW()") { insertPlaceholders.push("NOW()"); @@ -1367,15 +1482,20 @@ class DataService { } const insertQuery = ` - INSERT INTO "${tableName}" (${Object.keys(recordWithMeta).map(f => `"${f}"`).join(", ")}) + INSERT INTO "${tableName}" (${Object.keys(recordWithMeta) + .map((f) => `"${f}"`) + .join(", ")}) VALUES (${insertPlaceholders.join(", ")}) `; - console.log(`➕ INSERT 쿼리:`, { query: insertQuery, values: insertValues }); + console.log(`➕ INSERT 쿼리:`, { + query: insertQuery, + values: insertValues, + }); await pool.query(insertQuery, insertValues); inserted++; - + console.log(`➕ INSERT: 새 레코드`); } } @@ -1383,19 +1503,22 @@ class DataService { // 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]; + + if (existingValue instanceof Date && typeof newValue === "string") { + return ( + existingValue.toISOString().split("T")[0] === + newValue.split("T")[0] + ); } - + return String(existingValue) === String(newValue); }); }); @@ -1405,7 +1528,7 @@ class DataService { const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`; await pool.query(deleteQuery, [existingRecord[pkColumn]]); deleted++; - + console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`); } } diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 99d6257c..77593fa1 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -103,12 +103,16 @@ export class DynamicFormService { if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { // DATE 타입이면 문자열 그대로 유지 if (lowerDataType === "date") { - console.log(`📅 날짜 문자열 유지: ${value} -> "${value}" (DATE 타입)`); + console.log( + `📅 날짜 문자열 유지: ${value} -> "${value}" (DATE 타입)` + ); return value; // 문자열 그대로 반환 } // TIMESTAMP 타입이면 Date 객체로 변환 else { - console.log(`📅 날짜시간 변환: ${value} -> Date 객체 (TIMESTAMP 타입)`); + console.log( + `📅 날짜시간 변환: ${value} -> Date 객체 (TIMESTAMP 타입)` + ); return new Date(value + "T00:00:00"); } } @@ -250,7 +254,8 @@ export class DynamicFormService { if (tableColumns.includes("regdate") && !dataToInsert.regdate) { dataToInsert.regdate = new Date(); } - if (tableColumns.includes("created_date") && !dataToInsert.created_date) { + // created_date는 항상 현재 시간으로 설정 (기존 값 무시) + if (tableColumns.includes("created_date")) { dataToInsert.created_date = new Date(); } if (tableColumns.includes("updated_date") && !dataToInsert.updated_date) { @@ -313,7 +318,9 @@ export class DynamicFormService { } // YYYY-MM-DD 형태의 문자열은 그대로 유지 (DATE 타입으로 저장) else if (value.match(/^\d{4}-\d{2}-\d{2}$/)) { - console.log(`📅 날짜 유지: ${key} = "${value}" -> 문자열 그대로 (DATE 타입)`); + console.log( + `📅 날짜 유지: ${key} = "${value}" -> 문자열 그대로 (DATE 타입)` + ); // dataToInsert[key] = value; // 문자열 그대로 유지 (이미 올바른 형식) } } @@ -346,35 +353,37 @@ export class DynamicFormService { ) { try { parsedArray = JSON.parse(value); - console.log( + console.log( `🔄 JSON 문자열 Repeater 데이터 감지: ${key}, ${parsedArray?.length || 0}개 항목` - ); + ); } catch (parseError) { console.log(`⚠️ JSON 파싱 실패: ${key}`); } } // 파싱된 배열이 있으면 처리 - if (parsedArray && Array.isArray(parsedArray) && parsedArray.length > 0) { - // 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해) - // 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음 - let targetTable: string | undefined; - let actualData = parsedArray; + if ( + parsedArray && + Array.isArray(parsedArray) && + parsedArray.length > 0 + ) { + // 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해) + // 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음 + let targetTable: string | undefined; + let actualData = parsedArray; - // 첫 번째 항목에 _targetTable이 있는지 확인 (프론트엔드에서 메타데이터 전달) - if (parsedArray[0] && parsedArray[0]._targetTable) { - targetTable = parsedArray[0]._targetTable; - actualData = parsedArray.map( - ({ _targetTable, ...item }) => item - ); - } + // 첫 번째 항목에 _targetTable이 있는지 확인 (프론트엔드에서 메타데이터 전달) + if (parsedArray[0] && parsedArray[0]._targetTable) { + targetTable = parsedArray[0]._targetTable; + actualData = parsedArray.map(({ _targetTable, ...item }) => item); + } - repeaterData.push({ - data: actualData, - targetTable, - componentId: key, - }); - delete dataToInsert[key]; // 원본 배열 데이터는 제거 + repeaterData.push({ + data: actualData, + targetTable, + componentId: key, + }); + delete dataToInsert[key]; // 원본 배열 데이터는 제거 console.log(`✅ Repeater 데이터 추가: ${key}`, { targetTable: targetTable || "없음 (화면 설계에서 설정 필요)", @@ -387,8 +396,8 @@ export class DynamicFormService { // 🔥 Repeater targetTable이 메인 테이블과 같으면 분리해서 저장 const separateRepeaterData: typeof repeaterData = []; const mergedRepeaterData: typeof repeaterData = []; - - repeaterData.forEach(repeater => { + + repeaterData.forEach((repeater) => { if (repeater.targetTable && repeater.targetTable !== tableName) { // 다른 테이블: 나중에 별도 저장 separateRepeaterData.push(repeater); @@ -397,10 +406,10 @@ export class DynamicFormService { mergedRepeaterData.push(repeater); } }); - + console.log(`🔄 Repeater 데이터 분류:`, { separate: separateRepeaterData.length, // 별도 테이블 - merged: mergedRepeaterData.length, // 메인 테이블과 병합 + merged: mergedRepeaterData.length, // 메인 테이블과 병합 }); // 존재하지 않는 컬럼 제거 @@ -494,23 +503,30 @@ export class DynamicFormService { const clientIp = ipAddress || "unknown"; let result: any[]; - + // 🔥 메인 테이블과 병합할 Repeater가 있으면 각 품목별로 INSERT if (mergedRepeaterData.length > 0) { - console.log(`🔄 메인 테이블 병합 모드: ${mergedRepeaterData.length}개 Repeater를 개별 레코드로 저장`); - + console.log( + `🔄 메인 테이블 병합 모드: ${mergedRepeaterData.length}개 Repeater를 개별 레코드로 저장` + ); + result = []; - + for (const repeater of mergedRepeaterData) { for (const item of repeater.data) { // 헤더 + 품목을 병합 - const rawMergedData = { ...dataToInsert, ...item }; - + // item에서 created_date 제거 (dataToInsert의 현재 시간 유지) + const { created_date: _, ...itemWithoutCreatedDate } = item; + const rawMergedData = { + ...dataToInsert, + ...itemWithoutCreatedDate, + }; + // 🆕 새 레코드 저장 시 id 제거하여 새 UUID 생성되도록 함 // _existingRecord가 명시적으로 true인 경우에만 기존 레코드로 처리 (UPDATE) // 그 외의 경우는 모두 새 레코드로 처리 (INSERT) const isExistingRecord = rawMergedData._existingRecord === true; - + if (!isExistingRecord) { // 새 레코드: id 제거하여 새 UUID 자동 생성 const oldId = rawMergedData.id; @@ -519,37 +535,43 @@ export class DynamicFormService { } else { console.log(`📝 기존 레코드 수정 (id 유지: ${rawMergedData.id})`); } - + // 메타 플래그 제거 delete rawMergedData._isNewItem; delete rawMergedData._existingRecord; - + // 🆕 실제 테이블 컬럼만 필터링 (조인/계산 컬럼 제외) const validColumnNames = columnInfo.map((col) => col.column_name); const mergedData: Record = {}; - + Object.keys(rawMergedData).forEach((columnName) => { // 실제 테이블 컬럼인지 확인 if (validColumnNames.includes(columnName)) { - const column = columnInfo.find((col) => col.column_name === columnName); - if (column) { - // 타입 변환 - mergedData[columnName] = this.convertValueForPostgreSQL( - rawMergedData[columnName], - column.data_type + const column = columnInfo.find( + (col) => col.column_name === columnName ); + if (column) { + // 타입 변환 + mergedData[columnName] = this.convertValueForPostgreSQL( + rawMergedData[columnName], + column.data_type + ); } else { mergedData[columnName] = rawMergedData[columnName]; } } else { - console.log(`⚠️ 조인/계산 컬럼 제외: ${columnName} (값: ${rawMergedData[columnName]})`); + console.log( + `⚠️ 조인/계산 컬럼 제외: ${columnName} (값: ${rawMergedData[columnName]})` + ); } }); - + const mergedColumns = Object.keys(mergedData); const mergedValues: any[] = Object.values(mergedData); - const mergedPlaceholders = mergedValues.map((_, index) => `$${index + 1}`).join(", "); - + const mergedPlaceholders = mergedValues + .map((_, index) => `$${index + 1}`) + .join(", "); + let mergedUpsertQuery: string; if (primaryKeys.length > 0) { const conflictColumns = primaryKeys.join(", "); @@ -557,7 +579,7 @@ export class DynamicFormService { .filter((col) => !primaryKeys.includes(col)) .map((col) => `${col} = EXCLUDED.${col}`) .join(", "); - + mergedUpsertQuery = updateSet ? `INSERT INTO ${tableName} (${mergedColumns.join(", ")}) VALUES (${mergedPlaceholders}) @@ -574,20 +596,20 @@ export class DynamicFormService { VALUES (${mergedPlaceholders}) RETURNING *`; } - + console.log(`📝 병합 INSERT:`, { mergedData }); - + const itemResult = await transaction(async (client) => { await client.query(`SET LOCAL app.user_id = '${userId}'`); await client.query(`SET LOCAL app.ip_address = '${clientIp}'`); const res = await client.query(mergedUpsertQuery, mergedValues); return res.rows[0]; }); - + result.push(itemResult); } } - + console.log(`✅ 병합 저장 완료: ${result.length}개 레코드`); } else { // 일반 모드: 헤더만 저장 @@ -597,7 +619,7 @@ export class DynamicFormService { const res = await client.query(upsertQuery, values); return res.rows; }); - + console.log("✅ 서비스: 실제 테이블 저장 성공:", result); } @@ -732,12 +754,19 @@ export class DynamicFormService { // 🎯 제어관리 실행 (새로 추가) try { + // savedData 또는 insertedRecord에서 company_code 추출 + const recordCompanyCode = + (insertedRecord as Record)?.company_code || + dataToInsert.company_code || + "*"; + await this.executeDataflowControlIfConfigured( screenId, tableName, insertedRecord as Record, "insert", - created_by || "system" + created_by || "system", + recordCompanyCode ); } catch (controlError) { console.error("⚠️ 제어관리 실행 오류:", controlError); @@ -843,10 +872,10 @@ export class DynamicFormService { FROM information_schema.columns WHERE table_name = $1 AND table_schema = 'public' `; - const columnTypesResult = await query<{ column_name: string; data_type: string }>( - columnTypesQuery, - [tableName] - ); + const columnTypesResult = await query<{ + column_name: string; + data_type: string; + }>(columnTypesQuery, [tableName]); const columnTypes: Record = {}; columnTypesResult.forEach((row) => { columnTypes[row.column_name] = row.data_type; @@ -859,11 +888,20 @@ export class DynamicFormService { .map((key, index) => { const dataType = columnTypes[key]; // 숫자 타입인 경우 명시적 캐스팅 - if (dataType === 'integer' || dataType === 'bigint' || dataType === 'smallint') { + if ( + dataType === "integer" || + dataType === "bigint" || + dataType === "smallint" + ) { return `${key} = $${index + 1}::integer`; - } else if (dataType === 'numeric' || dataType === 'decimal' || dataType === 'real' || dataType === 'double precision') { + } else if ( + dataType === "numeric" || + dataType === "decimal" || + dataType === "real" || + dataType === "double precision" + ) { return `${key} = $${index + 1}::numeric`; - } else if (dataType === 'boolean') { + } else if (dataType === "boolean") { return `${key} = $${index + 1}::boolean`; } else { // 문자열 타입은 캐스팅 불필요 @@ -877,13 +915,17 @@ export class DynamicFormService { // 🔑 Primary Key 타입에 맞게 캐스팅 const pkDataType = columnTypes[primaryKeyColumn]; - let pkCast = ''; - if (pkDataType === 'integer' || pkDataType === 'bigint' || pkDataType === 'smallint') { - pkCast = '::integer'; - } else if (pkDataType === 'numeric' || pkDataType === 'decimal') { - pkCast = '::numeric'; - } else if (pkDataType === 'uuid') { - pkCast = '::uuid'; + let pkCast = ""; + if ( + pkDataType === "integer" || + pkDataType === "bigint" || + pkDataType === "smallint" + ) { + pkCast = "::integer"; + } else if (pkDataType === "numeric" || pkDataType === "decimal") { + pkCast = "::numeric"; + } else if (pkDataType === "uuid") { + pkCast = "::uuid"; } // text, varchar 등은 캐스팅 불필요 @@ -1072,12 +1114,19 @@ export class DynamicFormService { // 🎯 제어관리 실행 (UPDATE 트리거) try { + // updatedRecord에서 company_code 추출 + const recordCompanyCode = + (updatedRecord as Record)?.company_code || + company_code || + "*"; + await this.executeDataflowControlIfConfigured( 0, // UPDATE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요) tableName, updatedRecord as Record, "update", - updated_by || "system" + updated_by || "system", + recordCompanyCode ); } catch (controlError) { console.error("⚠️ 제어관리 실행 오류:", controlError); @@ -1216,12 +1265,17 @@ export class DynamicFormService { try { if (result && Array.isArray(result) && result.length > 0) { const deletedRecord = result[0] as Record; + // deletedRecord에서 company_code 추출 + const recordCompanyCode = + deletedRecord?.company_code || companyCode || "*"; + await this.executeDataflowControlIfConfigured( 0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요) tableName, deletedRecord, "delete", - userId || "system" + userId || "system", + recordCompanyCode ); } } catch (controlError) { @@ -1527,7 +1581,8 @@ export class DynamicFormService { tableName: string, savedData: Record, triggerType: "insert" | "update" | "delete", - userId: string = "system" + userId: string = "system", + companyCode: string = "*" ): Promise { try { console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`); @@ -1556,9 +1611,11 @@ export class DynamicFormService { componentId: layout.component_id, componentType: properties?.componentType, actionType: properties?.componentConfig?.action?.type, - enableDataflowControl: properties?.webTypeConfig?.enableDataflowControl, + enableDataflowControl: + properties?.webTypeConfig?.enableDataflowControl, hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig, - hasDiagramId: !!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId, + hasDiagramId: + !!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId, }); // 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우 @@ -1583,21 +1640,27 @@ export class DynamicFormService { // 노드 플로우 실행 (relationshipId가 없는 경우 노드 플로우로 간주) let controlResult: any; - + if (!relationshipId) { // 노드 플로우 실행 console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`); - const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService"); - - const executionResult = await NodeFlowExecutionService.executeFlow(diagramId, { - sourceData: [savedData], - dataSourceType: "formData", - buttonId: "save-button", - screenId: screenId, - userId: userId, - formData: savedData, - }); - + const { NodeFlowExecutionService } = await import( + "./nodeFlowExecutionService" + ); + + const executionResult = await NodeFlowExecutionService.executeFlow( + diagramId, + { + sourceData: [savedData], + dataSourceType: "formData", + buttonId: "save-button", + screenId: screenId, + userId: userId, + companyCode: companyCode, + formData: savedData, + } + ); + controlResult = { success: executionResult.success, message: executionResult.message, @@ -1612,15 +1675,18 @@ export class DynamicFormService { }; } else { // 관계 기반 제어관리 실행 - console.log(`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`); - controlResult = await this.dataflowControlService.executeDataflowControl( - diagramId, - relationshipId, - triggerType, - savedData, - tableName, - userId + console.log( + `🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})` ); + controlResult = + await this.dataflowControlService.executeDataflowControl( + diagramId, + relationshipId, + triggerType, + savedData, + tableName, + userId + ); } console.log(`🎯 제어관리 실행 결과:`, controlResult); @@ -1677,7 +1743,7 @@ export class DynamicFormService { ): Promise<{ affectedRows: number }> { const pool = getPool(); const client = await pool.connect(); - + try { console.log("🔄 [updateFieldValue] 업데이트 실행:", { tableName, @@ -1695,11 +1761,13 @@ export class DynamicFormService { WHERE table_name = $1 AND column_name IN ('updated_by', 'updated_at', 'company_code') `; const columnResult = await client.query(columnQuery, [tableName]); - const existingColumns = columnResult.rows.map((row: any) => row.column_name); - - const hasUpdatedBy = existingColumns.includes('updated_by'); - const hasUpdatedAt = existingColumns.includes('updated_at'); - const hasCompanyCode = existingColumns.includes('company_code'); + const existingColumns = columnResult.rows.map( + (row: any) => row.column_name + ); + + const hasUpdatedBy = existingColumns.includes("updated_by"); + const hasUpdatedAt = existingColumns.includes("updated_at"); + const hasCompanyCode = existingColumns.includes("company_code"); console.log("🔍 [updateFieldValue] 테이블 컬럼 확인:", { hasUpdatedBy, @@ -1896,7 +1964,8 @@ export class DynamicFormService { paramIndex++; } - const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const limitClause = params.limit ? `LIMIT ${params.limit}` : "LIMIT 1000"; const sqlQuery = ` diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 8a4fca31..a7333af4 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -28,10 +28,14 @@ export type NodeType = | "condition" | "dataTransform" | "aggregate" + | "formulaTransform" // 수식 변환 노드 | "insertAction" | "updateAction" | "deleteAction" | "upsertAction" + | "emailAction" // 이메일 발송 액션 + | "scriptAction" // 스크립트 실행 액션 + | "httpRequestAction" // HTTP 요청 액션 | "comment" | "log"; @@ -113,6 +117,18 @@ export class NodeFlowExecutionService { try { logger.info(`🚀 플로우 실행 시작: flowId=${flowId}`); + // 🔍 디버깅: contextData 상세 로그 + logger.info(`🔍 contextData 상세:`, { + directCompanyCode: contextData.companyCode, + nestedCompanyCode: contextData.context?.companyCode, + directUserId: contextData.userId, + nestedUserId: contextData.context?.userId, + contextKeys: Object.keys(contextData), + nestedContextKeys: contextData.context + ? Object.keys(contextData.context) + : "no nested context", + }); + // 1. 플로우 데이터 조회 const flow = await queryOne<{ flow_id: number; @@ -538,6 +554,9 @@ export class NodeFlowExecutionService { case "aggregate": return this.executeAggregate(node, inputData, context); + case "formulaTransform": + return this.executeFormulaTransform(node, inputData, context); + case "insertAction": return this.executeInsertAction(node, inputData, context, client); @@ -553,6 +572,15 @@ export class NodeFlowExecutionService { case "condition": return this.executeCondition(node, inputData, context); + case "emailAction": + return this.executeEmailAction(node, inputData, context); + + case "scriptAction": + return this.executeScriptAction(node, inputData, context); + + case "httpRequestAction": + return this.executeHttpRequestAction(node, inputData, context); + case "comment": case "log": // 로그/코멘트는 실행 없이 통과 @@ -841,16 +869,18 @@ export class NodeFlowExecutionService { const sql = `SELECT * FROM ${schemaPrefix}${tableName} ${whereResult.clause}`; logger.info(`📊 테이블 전체 데이터 조회 SQL: ${sql}`); - + const result = await query(sql, whereResult.values); logger.info( `📊 테이블 전체 데이터 조회: ${tableName}, ${result.length}건` ); - + // 디버깅: 조회된 데이터 샘플 출력 if (result.length > 0) { - logger.info(`📊 조회된 데이터 샘플: ${JSON.stringify(result[0])?.substring(0, 300)}`); + logger.info( + `📊 조회된 데이터 샘플: ${JSON.stringify(result[0])?.substring(0, 300)}` + ); } return result; @@ -956,19 +986,36 @@ export class NodeFlowExecutionService { }); // 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우) - const hasWriterMapping = fieldMappings.some((m: any) => m.targetField === "writer"); - const hasCompanyCodeMapping = fieldMappings.some((m: any) => m.targetField === "company_code"); + const hasWriterMapping = fieldMappings.some( + (m: any) => m.targetField === "writer" + ); + const hasCompanyCodeMapping = fieldMappings.some( + (m: any) => m.targetField === "company_code" + ); // 컨텍스트에서 사용자 정보 추출 const userId = context.buttonContext?.userId; const companyCode = context.buttonContext?.companyCode; + // 🔍 디버깅: 자동 추가 조건 확인 + console.log(` 🔍 INSERT 자동 추가 조건 확인:`, { + hasWriterMapping, + hasCompanyCodeMapping, + userId, + companyCode, + buttonContext: context.buttonContext, + }); + // writer 자동 추가 (매핑에 없고, 컨텍스트에 userId가 있는 경우) if (!hasWriterMapping && userId) { fields.push("writer"); values.push(userId); insertedData.writer = userId; console.log(` 🔧 자동 추가: writer = ${userId}`); + } else { + console.log( + ` ⚠️ writer 자동 추가 스킵: hasWriterMapping=${hasWriterMapping}, userId=${userId}` + ); } // company_code 자동 추가 (매핑에 없고, 컨텍스트에 companyCode가 있는 경우) @@ -977,6 +1024,10 @@ export class NodeFlowExecutionService { values.push(companyCode); insertedData.company_code = companyCode; console.log(` 🔧 자동 추가: company_code = ${companyCode}`); + } else { + console.log( + ` ⚠️ company_code 자동 추가 스킵: hasCompanyCodeMapping=${hasCompanyCodeMapping}, companyCode=${companyCode}` + ); } const sql = ` @@ -1374,8 +1425,12 @@ export class NodeFlowExecutionService { // 🆕 table-all 모드: 각 그룹별로 UPDATE 실행 (집계 결과 반영) if (context.currentNodeDataSourceType === "table-all") { - console.log("🚀 table-all 모드: 그룹별 업데이트 시작 (총 " + dataArray.length + "개 그룹)"); - + console.log( + "🚀 table-all 모드: 그룹별 업데이트 시작 (총 " + + dataArray.length + + "개 그룹)" + ); + // 🔥 각 그룹(데이터)별로 UPDATE 실행 for (let i = 0; i < dataArray.length; i++) { const data = dataArray[i]; @@ -1385,7 +1440,7 @@ export class NodeFlowExecutionService { console.log(`\n📦 그룹 ${i + 1}/${dataArray.length} 처리 중...`); console.log("🗺️ 필드 매핑 처리 중..."); - + fieldMappings.forEach((mapping: any) => { const value = mapping.staticValue !== undefined @@ -1424,7 +1479,7 @@ export class NodeFlowExecutionService { const result = await txClient.query(sql, values); const rowCount = result.rowCount || 0; updatedCount += rowCount; - + console.log(`✅ 그룹 ${i + 1} UPDATE 완료: ${rowCount}건`); } @@ -1438,7 +1493,7 @@ export class NodeFlowExecutionService { // 🆕 context-data 모드: 개별 업데이트 (PK 자동 추가) console.log("🎯 context-data 모드: 개별 업데이트 시작"); - + for (const data of dataArray) { const setClauses: string[] = []; const values: any[] = []; @@ -1810,12 +1865,16 @@ export class NodeFlowExecutionService { // 🆕 table-all 모드: 단일 SQL로 일괄 삭제 if (context.currentNodeDataSourceType === "table-all") { console.log("🚀 table-all 모드: 단일 SQL로 일괄 삭제 시작"); - + // 첫 번째 데이터를 참조하여 WHERE 절 생성 const firstData = dataArray[0]; - + // WHERE 조건 (사용자 정의 조건만 사용, PK 자동 추가 안 함) - const whereResult = this.buildWhereClause(whereConditions, firstData, 1); + const whereResult = this.buildWhereClause( + whereConditions, + firstData, + 1 + ); const sql = `DELETE FROM ${targetTable} ${whereResult.clause} RETURNING *`; @@ -1842,7 +1901,7 @@ export class NodeFlowExecutionService { for (const data of dataArray) { console.log("🔍 WHERE 조건 처리 중..."); - + // 🔑 Primary Key 자동 추가 (context-data 모드) console.log("🔑 context-data 모드: Primary Key 자동 추가"); const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK( @@ -1850,8 +1909,12 @@ export class NodeFlowExecutionService { data, targetTable ); - - const whereResult = this.buildWhereClause(enhancedWhereConditions, data, 1); + + const whereResult = this.buildWhereClause( + enhancedWhereConditions, + data, + 1 + ); const sql = `DELETE FROM ${targetTable} ${whereResult.clause} RETURNING *`; @@ -2223,6 +2286,34 @@ export class NodeFlowExecutionService { values.push(value); }); + // 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우) + const hasWriterMapping = fieldMappings.some( + (m: any) => m.targetField === "writer" + ); + const hasCompanyCodeMapping = fieldMappings.some( + (m: any) => m.targetField === "company_code" + ); + + // 컨텍스트에서 사용자 정보 추출 + const userId = context.buttonContext?.userId; + const companyCode = context.buttonContext?.companyCode; + + // writer 자동 추가 (매핑에 없고, 컨텍스트에 userId가 있는 경우) + if (!hasWriterMapping && userId) { + columns.push("writer"); + values.push(userId); + logger.info(` 🔧 UPSERT INSERT - 자동 추가: writer = ${userId}`); + } + + // company_code 자동 추가 (매핑에 없고, 컨텍스트에 companyCode가 있는 경우) + if (!hasCompanyCodeMapping && companyCode && companyCode !== "*") { + columns.push("company_code"); + values.push(companyCode); + logger.info( + ` 🔧 UPSERT INSERT - 자동 추가: company_code = ${companyCode}` + ); + } + const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); const insertSql = ` INSERT INTO ${targetTable} (${columns.join(", ")}) @@ -2706,13 +2797,15 @@ export class NodeFlowExecutionService { try { const result = await query(sql, [fullTableName]); const pkColumns = result.map((row: any) => row.column_name); - + if (pkColumns.length > 0) { - console.log(`🔑 테이블 ${tableName}의 Primary Key: ${pkColumns.join(", ")}`); + console.log( + `🔑 테이블 ${tableName}의 Primary Key: ${pkColumns.join(", ")}` + ); } else { console.log(`⚠️ 테이블 ${tableName}에 Primary Key가 없습니다`); } - + return pkColumns; } catch (error) { console.error(`❌ Primary Key 조회 실패 (${tableName}):`, error); @@ -2722,7 +2815,7 @@ export class NodeFlowExecutionService { /** * WHERE 조건에 Primary Key 자동 추가 (컨텍스트 데이터 사용 시) - * + * * 테이블의 실제 Primary Key를 자동으로 감지하여 WHERE 조건에 추가 */ private static async enhanceWhereConditionsWithPK( @@ -2745,8 +2838,8 @@ export class NodeFlowExecutionService { } // 🔍 데이터에 모든 PK 컬럼이 있는지 확인 - const missingPKColumns = pkColumns.filter(col => - data[col] === undefined || data[col] === null + const missingPKColumns = pkColumns.filter( + (col) => data[col] === undefined || data[col] === null ); if (missingPKColumns.length > 0) { @@ -2760,8 +2853,9 @@ export class NodeFlowExecutionService { const existingFields = new Set( (whereConditions || []).map((cond: any) => cond.field) ); - const allPKsExist = pkColumns.every(col => - existingFields.has(col) || existingFields.has(`${tableName}.${col}`) + const allPKsExist = pkColumns.every( + (col) => + existingFields.has(col) || existingFields.has(`${tableName}.${col}`) ); if (allPKsExist) { @@ -2770,17 +2864,17 @@ export class NodeFlowExecutionService { } // 🔥 Primary Key 조건들을 맨 앞에 추가 - const pkConditions = pkColumns.map(col => ({ + const pkConditions = pkColumns.map((col) => ({ field: col, - operator: 'EQUALS', - value: data[col] + operator: "EQUALS", + value: data[col], })); const enhanced = [...pkConditions, ...(whereConditions || [])]; - - const pkValues = pkColumns.map(col => `${col} = ${data[col]}`).join(", "); + + const pkValues = pkColumns.map((col) => `${col} = ${data[col]}`).join(", "); console.log(`🔑 WHERE 조건에 Primary Key 자동 추가: ${pkValues}`); - + return enhanced; } @@ -3230,20 +3324,30 @@ export class NodeFlowExecutionService { inputData: any, context: ExecutionContext ): Promise { - const { groupByFields = [], aggregations = [], havingConditions = [] } = node.data; + const { + groupByFields = [], + aggregations = [], + havingConditions = [], + } = node.data; logger.info(`📊 집계 노드 실행: ${node.data.displayName || node.id}`); // 입력 데이터가 없으면 빈 배열 반환 if (!inputData || !Array.isArray(inputData) || inputData.length === 0) { logger.warn("⚠️ 집계할 입력 데이터가 없습니다."); - logger.warn(`⚠️ inputData 타입: ${typeof inputData}, 값: ${JSON.stringify(inputData)?.substring(0, 200)}`); + logger.warn( + `⚠️ inputData 타입: ${typeof inputData}, 값: ${JSON.stringify(inputData)?.substring(0, 200)}` + ); return []; } logger.info(`📥 입력 데이터: ${inputData.length}건`); - logger.info(`📥 입력 데이터 샘플: ${JSON.stringify(inputData[0])?.substring(0, 300)}`); - logger.info(`📊 그룹 기준: ${groupByFields.length > 0 ? groupByFields.map((f: any) => f.field).join(", ") : "전체"}`); + logger.info( + `📥 입력 데이터 샘플: ${JSON.stringify(inputData[0])?.substring(0, 300)}` + ); + logger.info( + `📊 그룹 기준: ${groupByFields.length > 0 ? groupByFields.map((f: any) => f.field).join(", ") : "전체"}` + ); logger.info(`📊 집계 연산: ${aggregations.length}개`); // 그룹화 수행 @@ -3251,9 +3355,12 @@ export class NodeFlowExecutionService { for (const row of inputData) { // 그룹 키 생성 - const groupKey = groupByFields.length > 0 - ? groupByFields.map((f: any) => String(row[f.field] ?? "")).join("|||") - : "__ALL__"; + const groupKey = + groupByFields.length > 0 + ? groupByFields + .map((f: any) => String(row[f.field] ?? "")) + .join("|||") + : "__ALL__"; if (!groups.has(groupKey)) { groups.set(groupKey, []); @@ -3262,10 +3369,12 @@ export class NodeFlowExecutionService { } logger.info(`📊 그룹 수: ${groups.size}개`); - + // 디버깅: 각 그룹의 데이터 출력 for (const [groupKey, groupRows] of groups) { - logger.info(`📊 그룹 [${groupKey}]: ${groupRows.length}건, inbound_qty 합계: ${groupRows.reduce((sum, row) => sum + parseFloat(row.inbound_qty || 0), 0)}`); + logger.info( + `📊 그룹 [${groupKey}]: ${groupRows.length}건, inbound_qty 합계: ${groupRows.reduce((sum, row) => sum + parseFloat(row.inbound_qty || 0), 0)}` + ); } // 각 그룹에 대해 집계 수행 @@ -3285,7 +3394,7 @@ export class NodeFlowExecutionService { // 각 집계 연산 수행 for (const agg of aggregations) { const { sourceField, function: aggFunc, outputField } = agg; - + if (!outputField) continue; let aggregatedValue: any; @@ -3311,27 +3420,37 @@ export class NodeFlowExecutionService { break; case "MIN": - aggregatedValue = groupRows.reduce((min: number | null, row: any) => { - const val = parseFloat(row[sourceField]); - if (isNaN(val)) return min; - return min === null ? val : Math.min(min, val); - }, null); + aggregatedValue = groupRows.reduce( + (min: number | null, row: any) => { + const val = parseFloat(row[sourceField]); + if (isNaN(val)) return min; + return min === null ? val : Math.min(min, val); + }, + null + ); break; case "MAX": - aggregatedValue = groupRows.reduce((max: number | null, row: any) => { - const val = parseFloat(row[sourceField]); - if (isNaN(val)) return max; - return max === null ? val : Math.max(max, val); - }, null); + aggregatedValue = groupRows.reduce( + (max: number | null, row: any) => { + const val = parseFloat(row[sourceField]); + if (isNaN(val)) return max; + return max === null ? val : Math.max(max, val); + }, + null + ); break; case "FIRST": - aggregatedValue = groupRows.length > 0 ? groupRows[0][sourceField] : null; + aggregatedValue = + groupRows.length > 0 ? groupRows[0][sourceField] : null; break; case "LAST": - aggregatedValue = groupRows.length > 0 ? groupRows[groupRows.length - 1][sourceField] : null; + aggregatedValue = + groupRows.length > 0 + ? groupRows[groupRows.length - 1][sourceField] + : null; break; default: @@ -3340,7 +3459,9 @@ export class NodeFlowExecutionService { } resultRow[outputField] = aggregatedValue; - logger.info(` ${aggFunc}(${sourceField}) → ${outputField}: ${aggregatedValue}`); + logger.info( + ` ${aggFunc}(${sourceField}) → ${outputField}: ${aggregatedValue}` + ); } results.push(resultRow); @@ -3373,11 +3494,13 @@ export class NodeFlowExecutionService { }); }); - logger.info(`📊 HAVING 필터링: ${results.length}건 → ${filteredResults.length}건`); + logger.info( + `📊 HAVING 필터링: ${results.length}건 → ${filteredResults.length}건` + ); } logger.info(`✅ 집계 완료: ${filteredResults.length}건 결과`); - + // 결과 샘플 출력 if (filteredResults.length > 0) { logger.info(`📄 결과 샘플:`, JSON.stringify(filteredResults[0], null, 2)); @@ -3385,4 +3508,922 @@ export class NodeFlowExecutionService { return filteredResults; } + + // =================================================================== + // 외부 연동 액션 노드들 + // =================================================================== + + /** + * 이메일 발송 액션 노드 실행 + */ + private static async executeEmailAction( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { + from, + to, + cc, + bcc, + subject, + body, + bodyType, + isHtml, // 레거시 지원 + accountId: nodeAccountId, // 프론트엔드에서 선택한 계정 ID + smtpConfigId, // 레거시 지원 + attachments, + templateVariables, + } = node.data; + + logger.info( + `📧 이메일 발송 노드 실행: ${node.data.displayName || node.id}` + ); + + // 입력 데이터를 배열로 정규화 + const dataArray = Array.isArray(inputData) + ? inputData + : inputData + ? [inputData] + : [{}]; + const results: any[] = []; + + // 동적 임포트로 순환 참조 방지 + const { mailSendSimpleService } = await import("./mailSendSimpleService"); + const { mailAccountFileService } = await import("./mailAccountFileService"); + + // 계정 ID 우선순위: nodeAccountId > smtpConfigId > 첫 번째 활성 계정 + let accountId = nodeAccountId || smtpConfigId; + if (!accountId) { + const accounts = await mailAccountFileService.getAccounts(); + const activeAccount = accounts.find( + (acc: any) => acc.status === "active" + ); + if (activeAccount) { + accountId = activeAccount.id; + logger.info( + `📧 자동 선택된 메일 계정: ${activeAccount.name} (${activeAccount.email})` + ); + } else { + throw new Error( + "활성화된 메일 계정이 없습니다. 메일 계정을 먼저 설정해주세요." + ); + } + } + + // HTML 여부 판단 (bodyType 우선, isHtml 레거시 지원) + const useHtml = bodyType === "html" || isHtml === true; + + for (const data of dataArray) { + try { + // 템플릿 변수 치환 + const processedSubject = this.replaceTemplateVariables( + subject || "", + data + ); + const processedBody = this.replaceTemplateVariables(body || "", data); + const processedTo = this.replaceTemplateVariables(to || "", data); + const processedCc = cc + ? this.replaceTemplateVariables(cc, data) + : undefined; + const processedBcc = bcc + ? this.replaceTemplateVariables(bcc, data) + : undefined; + + // 수신자 파싱 (쉼표로 구분) + const toList = processedTo + .split(",") + .map((email: string) => email.trim()) + .filter((email: string) => email); + const ccList = processedCc + ? processedCc + .split(",") + .map((email: string) => email.trim()) + .filter((email: string) => email) + : undefined; + const bccList = processedBcc + ? processedBcc + .split(",") + .map((email: string) => email.trim()) + .filter((email: string) => email) + : undefined; + + if (toList.length === 0) { + throw new Error("수신자 이메일 주소가 지정되지 않았습니다."); + } + + // 메일 발송 요청 + const sendResult = await mailSendSimpleService.sendMail({ + accountId, + to: toList, + cc: ccList, + bcc: bccList, + subject: processedSubject, + customHtml: useHtml ? processedBody : `
${processedBody}
`, + attachments: attachments?.map((att: any) => ({ + filename: att.type === "dataField" ? data[att.value] : att.value, + path: att.type === "dataField" ? data[att.value] : att.value, + })), + }); + + if (sendResult.success) { + logger.info(`✅ 이메일 발송 성공: ${toList.join(", ")}`); + results.push({ + success: true, + to: toList, + messageId: sendResult.messageId, + }); + } else { + logger.error(`❌ 이메일 발송 실패: ${sendResult.error}`); + results.push({ + success: false, + to: toList, + error: sendResult.error, + }); + } + } catch (error: any) { + logger.error(`❌ 이메일 발송 오류:`, error); + results.push({ + success: false, + error: error.message, + }); + } + } + + const successCount = results.filter((r) => r.success).length; + const failedCount = results.filter((r) => !r.success).length; + + logger.info( + `📧 이메일 발송 완료: 성공 ${successCount}건, 실패 ${failedCount}건` + ); + + return { + action: "emailAction", + totalCount: results.length, + successCount, + failedCount, + results, + }; + } + + /** + * 스크립트 실행 액션 노드 실행 + */ + private static async executeScriptAction( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { + scriptType, + scriptPath, + arguments: scriptArgs, + workingDirectory, + environmentVariables, + timeout, + captureOutput, + } = node.data; + + logger.info( + `🖥️ 스크립트 실행 노드 실행: ${node.data.displayName || node.id}` + ); + logger.info(` 스크립트 타입: ${scriptType}, 경로: ${scriptPath}`); + + if (!scriptPath) { + throw new Error("스크립트 경로가 지정되지 않았습니다."); + } + + // 입력 데이터를 배열로 정규화 + const dataArray = Array.isArray(inputData) + ? inputData + : inputData + ? [inputData] + : [{}]; + const results: any[] = []; + + // child_process 모듈 동적 임포트 + const { spawn } = await import("child_process"); + const path = await import("path"); + + for (const data of dataArray) { + try { + // 인자 처리 + const processedArgs: string[] = []; + if (scriptArgs && Array.isArray(scriptArgs)) { + for (const arg of scriptArgs) { + if (arg.type === "dataField") { + // 데이터 필드 참조 + const value = this.replaceTemplateVariables(arg.value, data); + processedArgs.push(value); + } else { + processedArgs.push(arg.value); + } + } + } + + // 환경 변수 처리 + const env = { + ...process.env, + ...(environmentVariables || {}), + }; + + // 스크립트 타입에 따른 명령어 결정 + let command: string; + let args: string[]; + + switch (scriptType) { + case "python": + command = "python3"; + args = [scriptPath, ...processedArgs]; + break; + case "shell": + command = "bash"; + args = [scriptPath, ...processedArgs]; + break; + case "executable": + command = scriptPath; + args = processedArgs; + break; + default: + throw new Error(`지원하지 않는 스크립트 타입: ${scriptType}`); + } + + logger.info(` 실행 명령: ${command} ${args.join(" ")}`); + + // 스크립트 실행 (Promise로 래핑) + const result = await new Promise<{ + exitCode: number | null; + stdout: string; + stderr: string; + }>((resolve, reject) => { + const childProcess = spawn(command, args, { + cwd: workingDirectory || process.cwd(), + env, + timeout: timeout || 60000, // 기본 60초 + }); + + let stdout = ""; + let stderr = ""; + + if (captureOutput !== false) { + childProcess.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + + childProcess.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + } + + childProcess.on("close", (code) => { + resolve({ exitCode: code, stdout, stderr }); + }); + + childProcess.on("error", (error) => { + reject(error); + }); + }); + + if (result.exitCode === 0) { + logger.info(`✅ 스크립트 실행 성공 (종료 코드: ${result.exitCode})`); + results.push({ + success: true, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + data, + }); + } else { + logger.warn(`⚠️ 스크립트 실행 완료 (종료 코드: ${result.exitCode})`); + results.push({ + success: false, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + data, + }); + } + } catch (error: any) { + logger.error(`❌ 스크립트 실행 오류:`, error); + results.push({ + success: false, + error: error.message, + data, + }); + } + } + + const successCount = results.filter((r) => r.success).length; + const failedCount = results.filter((r) => !r.success).length; + + logger.info( + `🖥️ 스크립트 실행 완료: 성공 ${successCount}건, 실패 ${failedCount}건` + ); + + return { + action: "scriptAction", + scriptType, + scriptPath, + totalCount: results.length, + successCount, + failedCount, + results, + }; + } + + /** + * HTTP 요청 액션 노드 실행 + */ + private static async executeHttpRequestAction( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { + url, + method, + headers, + bodyTemplate, + bodyType, + authentication, + timeout, + retryCount, + responseMapping, + } = node.data; + + logger.info(`🌐 HTTP 요청 노드 실행: ${node.data.displayName || node.id}`); + logger.info(` 메서드: ${method}, URL: ${url}`); + + if (!url) { + throw new Error("HTTP 요청 URL이 지정되지 않았습니다."); + } + + // 입력 데이터를 배열로 정규화 + const dataArray = Array.isArray(inputData) + ? inputData + : inputData + ? [inputData] + : [{}]; + const results: any[] = []; + + for (const data of dataArray) { + let currentRetry = 0; + const maxRetries = retryCount || 0; + + while (currentRetry <= maxRetries) { + try { + // URL 템플릿 변수 치환 + const processedUrl = this.replaceTemplateVariables(url, data); + + // 헤더 처리 + const processedHeaders: Record = {}; + if (headers && Array.isArray(headers)) { + for (const header of headers) { + const headerValue = + header.valueType === "dataField" + ? this.replaceTemplateVariables(header.value, data) + : header.value; + processedHeaders[header.name] = headerValue; + } + } + + // 인증 헤더 추가 + if (authentication) { + switch (authentication.type) { + case "basic": + if (authentication.username && authentication.password) { + const credentials = Buffer.from( + `${authentication.username}:${authentication.password}` + ).toString("base64"); + processedHeaders["Authorization"] = `Basic ${credentials}`; + } + break; + case "bearer": + if (authentication.token) { + processedHeaders["Authorization"] = + `Bearer ${authentication.token}`; + } + break; + case "apikey": + if (authentication.apiKey) { + if (authentication.apiKeyLocation === "query") { + // 쿼리 파라미터로 추가 (URL에 추가) + const paramName = + authentication.apiKeyQueryParam || "api_key"; + const separator = processedUrl.includes("?") ? "&" : "?"; + // URL은 이미 처리되었으므로 여기서는 결과에 포함 + } else { + // 헤더로 추가 + const headerName = + authentication.apiKeyHeader || "X-API-Key"; + processedHeaders[headerName] = authentication.apiKey; + } + } + break; + } + } + + // Content-Type 기본값 + if ( + !processedHeaders["Content-Type"] && + ["POST", "PUT", "PATCH"].includes(method) + ) { + processedHeaders["Content-Type"] = + bodyType === "json" ? "application/json" : "text/plain"; + } + + // 바디 처리 + let processedBody: string | undefined; + if (["POST", "PUT", "PATCH"].includes(method) && bodyTemplate) { + processedBody = this.replaceTemplateVariables(bodyTemplate, data); + } + + logger.info(` 요청 URL: ${processedUrl}`); + logger.info(` 요청 헤더: ${JSON.stringify(processedHeaders)}`); + if (processedBody) { + logger.info(` 요청 바디: ${processedBody.substring(0, 200)}...`); + } + + // HTTP 요청 실행 + const response = await axios({ + method: method.toLowerCase() as any, + url: processedUrl, + headers: processedHeaders, + data: processedBody, + timeout: timeout || 30000, + validateStatus: () => true, // 모든 상태 코드 허용 + }); + + logger.info( + ` 응답 상태: ${response.status} ${response.statusText}` + ); + + // 응답 데이터 처리 + let responseData = response.data; + + // 응답 매핑 적용 + if (responseMapping && responseData) { + const paths = responseMapping.split("."); + for (const path of paths) { + if ( + responseData && + typeof responseData === "object" && + path in responseData + ) { + responseData = responseData[path]; + } else { + logger.warn( + `⚠️ 응답 매핑 경로를 찾을 수 없습니다: ${responseMapping}` + ); + break; + } + } + } + + const isSuccess = response.status >= 200 && response.status < 300; + + if (isSuccess) { + logger.info(`✅ HTTP 요청 성공`); + results.push({ + success: true, + statusCode: response.status, + data: responseData, + inputData: data, + }); + break; // 성공 시 재시도 루프 종료 + } else { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + } catch (error: any) { + currentRetry++; + if (currentRetry > maxRetries) { + logger.error( + `❌ HTTP 요청 실패 (재시도 ${currentRetry - 1}/${maxRetries}):`, + error.message + ); + results.push({ + success: false, + error: error.message, + inputData: data, + }); + } else { + logger.warn( + `⚠️ HTTP 요청 재시도 (${currentRetry}/${maxRetries}): ${error.message}` + ); + // 재시도 전 잠시 대기 + await new Promise((resolve) => + setTimeout(resolve, 1000 * currentRetry) + ); + } + } + } + } + + const successCount = results.filter((r) => r.success).length; + const failedCount = results.filter((r) => !r.success).length; + + logger.info( + `🌐 HTTP 요청 완료: 성공 ${successCount}건, 실패 ${failedCount}건` + ); + + return { + action: "httpRequestAction", + method, + url, + totalCount: results.length, + successCount, + failedCount, + results, + }; + } + + /** + * 수식 변환 노드 실행 + * - 타겟 테이블에서 기존 값 조회 (targetLookup) + * - 산술 연산, 함수, 조건, 정적 값 계산 + */ + private static async executeFormulaTransform( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { targetLookup, transformations = [] } = node.data; + + logger.info(`🧮 수식 변환 노드 실행: ${node.data.displayName || node.id}`); + logger.info(` 변환 규칙: ${transformations.length}개`); + + // 입력 데이터를 배열로 정규화 + const dataArray = Array.isArray(inputData) + ? inputData + : inputData + ? [inputData] + : []; + + if (dataArray.length === 0) { + logger.warn(`⚠️ 수식 변환 노드: 입력 데이터가 없습니다`); + return []; + } + + const results: any[] = []; + + for (const sourceRow of dataArray) { + let targetRow: any = null; + + // 타겟 테이블에서 기존 값 조회 + if (targetLookup?.tableName && targetLookup?.lookupKeys?.length > 0) { + try { + 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] + ); + + // 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}`); + logger.info(` 조회 조건: ${whereConditions}`); + logger.info(` 조회 값: ${JSON.stringify(lookupValues)}`); + + targetRow = await queryOne(sql, params); + + if (targetRow) { + logger.info(` ✅ 타겟 데이터 조회 성공`); + } else { + logger.info(` ℹ️ 타겟 데이터 없음 (신규)`); + } + } catch (error: any) { + logger.warn(` ⚠️ 타겟 조회 실패: ${error.message}`); + } + } + + // 결과 객체 (소스 데이터 복사) + const resultRow = { ...sourceRow }; + + // 중간 결과 저장소 (이전 변환 결과 참조용) + const resultValues: Record = {}; + + // 변환 규칙 순차 실행 + for (const trans of transformations) { + try { + const value = this.evaluateFormula( + trans, + sourceRow, + targetRow, + resultValues + ); + resultRow[trans.outputField] = value; + resultValues[trans.outputField] = value; + + logger.info( + ` ${trans.outputField} = ${JSON.stringify(value)} (${trans.formulaType})` + ); + } catch (error: any) { + logger.error( + ` ❌ 수식 계산 실패 [${trans.outputField}]: ${error.message}` + ); + resultRow[trans.outputField] = null; + } + } + + results.push(resultRow); + } + + logger.info(`✅ 수식 변환 완료: ${results.length}건`); + return results; + } + + /** + * 수식 계산 + */ + private static evaluateFormula( + trans: any, + sourceRow: any, + targetRow: any, + resultValues: Record + ): any { + const { + formulaType, + arithmetic, + function: func, + condition, + staticValue, + } = trans; + + switch (formulaType) { + case "arithmetic": + return this.evaluateArithmetic( + arithmetic, + sourceRow, + targetRow, + resultValues + ); + + case "function": + return this.evaluateFunction(func, sourceRow, targetRow, resultValues); + + case "condition": + return this.evaluateCondition( + condition, + sourceRow, + targetRow, + resultValues + ); + + case "static": + return this.parseStaticValue(staticValue); + + default: + throw new Error(`지원하지 않는 수식 타입: ${formulaType}`); + } + } + + /** + * 피연산자 값 가져오기 + */ + private static getOperandValue( + operand: any, + sourceRow: any, + targetRow: any, + resultValues: Record + ): any { + if (!operand) return null; + + switch (operand.type) { + case "source": + return sourceRow?.[operand.field] ?? null; + + case "target": + return targetRow?.[operand.field] ?? null; + + case "static": + return this.parseStaticValue(operand.value); + + case "result": + return resultValues?.[operand.resultField] ?? null; + + default: + return null; + } + } + + /** + * 정적 값 파싱 (숫자, 불린, 문자열) + */ + private static parseStaticValue(value: any): any { + if (value === null || value === undefined || value === "") return null; + + // 숫자 체크 + const numValue = Number(value); + if (!isNaN(numValue) && value !== "") return numValue; + + // 불린 체크 + if (value === "true") return true; + if (value === "false") return false; + + // 문자열 반환 + return value; + } + + /** + * 산술 연산 계산 + */ + private static evaluateArithmetic( + arithmetic: any, + sourceRow: any, + targetRow: any, + resultValues: Record + ): number | null { + if (!arithmetic) return null; + + const left = this.getOperandValue( + arithmetic.leftOperand, + sourceRow, + targetRow, + resultValues + ); + const right = this.getOperandValue( + arithmetic.rightOperand, + sourceRow, + targetRow, + resultValues + ); + + // COALESCE 처리: null이면 0으로 대체 + const leftNum = Number(left) || 0; + const rightNum = Number(right) || 0; + + switch (arithmetic.operator) { + case "+": + return leftNum + rightNum; + case "-": + return leftNum - rightNum; + case "*": + return leftNum * rightNum; + case "/": + if (rightNum === 0) { + logger.warn(`⚠️ 0으로 나누기 시도`); + return null; + } + return leftNum / rightNum; + case "%": + if (rightNum === 0) { + logger.warn(`⚠️ 0으로 나머지 연산 시도`); + return null; + } + return leftNum % rightNum; + default: + throw new Error(`지원하지 않는 연산자: ${arithmetic.operator}`); + } + } + + /** + * 함수 실행 + */ + private static evaluateFunction( + func: any, + sourceRow: any, + targetRow: any, + resultValues: Record + ): any { + if (!func) return null; + + const args = (func.arguments || []).map((arg: any) => + this.getOperandValue(arg, sourceRow, targetRow, resultValues) + ); + + switch (func.name) { + case "NOW": + return new Date().toISOString(); + + case "COALESCE": + // 첫 번째 non-null 값 반환 + for (const arg of args) { + if (arg !== null && arg !== undefined) return arg; + } + return null; + + case "CONCAT": + return args.filter((a: any) => a !== null && a !== undefined).join(""); + + case "UPPER": + return args[0] ? String(args[0]).toUpperCase() : null; + + case "LOWER": + return args[0] ? String(args[0]).toLowerCase() : null; + + case "TRIM": + return args[0] ? String(args[0]).trim() : null; + + case "ROUND": + return args[0] !== null ? Math.round(Number(args[0])) : null; + + case "ABS": + return args[0] !== null ? Math.abs(Number(args[0])) : null; + + case "SUBSTRING": + if (args[0] && args[1] !== undefined) { + const str = String(args[0]); + const start = Number(args[1]) || 0; + const length = args[2] !== undefined ? Number(args[2]) : undefined; + return length !== undefined + ? str.substring(start, start + length) + : str.substring(start); + } + return null; + + default: + throw new Error(`지원하지 않는 함수: ${func.name}`); + } + } + + /** + * 조건 평가 (CASE WHEN ... THEN ... ELSE) + */ + private static evaluateCondition( + condition: any, + sourceRow: any, + targetRow: any, + resultValues: Record + ): any { + if (!condition) return null; + + const { when, then: thenValue, else: elseValue } = condition; + + // WHEN 조건 평가 + const leftValue = this.getOperandValue( + when.leftOperand, + sourceRow, + targetRow, + resultValues + ); + const rightValue = when.rightOperand + ? this.getOperandValue( + when.rightOperand, + sourceRow, + targetRow, + resultValues + ) + : null; + + let conditionResult = false; + + switch (when.operator) { + case "=": + conditionResult = leftValue == rightValue; + break; + case "!=": + conditionResult = leftValue != rightValue; + break; + case ">": + conditionResult = Number(leftValue) > Number(rightValue); + break; + case "<": + conditionResult = Number(leftValue) < Number(rightValue); + break; + case ">=": + conditionResult = Number(leftValue) >= Number(rightValue); + break; + case "<=": + conditionResult = Number(leftValue) <= Number(rightValue); + break; + case "IS_NULL": + conditionResult = leftValue === null || leftValue === undefined; + break; + case "IS_NOT_NULL": + conditionResult = leftValue !== null && leftValue !== undefined; + break; + default: + throw new Error(`지원하지 않는 조건 연산자: ${when.operator}`); + } + + // THEN 또는 ELSE 값 반환 + if (conditionResult) { + return this.getOperandValue( + thenValue, + sourceRow, + targetRow, + resultValues + ); + } else { + return this.getOperandValue( + elseValue, + sourceRow, + targetRow, + resultValues + ); + } + } } diff --git a/db/migrations/RUN_063_064_MIGRATION.md b/db/migrations/RUN_063_064_MIGRATION.md new file mode 100644 index 00000000..98ca3b90 --- /dev/null +++ b/db/migrations/RUN_063_064_MIGRATION.md @@ -0,0 +1,238 @@ +# 마이그레이션 063-064: 재고 관리 테이블 생성 + +## 목적 + +재고 현황 관리 및 입출고 이력 추적을 위한 테이블 생성 + +**테이블 타입관리 UI와 동일한 방식으로 생성됩니다.** + +### 생성되는 테이블 + +| 테이블명 | 설명 | 용도 | +|----------|------|------| +| `inventory_stock` | 재고 현황 | 품목+로트별 현재 재고 상태 | +| `inventory_history` | 재고 이력 | 입출고 트랜잭션 기록 | + +--- + +## 테이블 타입관리 UI 방식 특징 + +1. **기본 컬럼 자동 포함**: `id`, `created_date`, `updated_date`, `writer`, `company_code` +2. **데이터 타입 통일**: 날짜는 `TIMESTAMP`, 나머지는 `VARCHAR(500)` +3. **메타데이터 등록**: + - `table_labels`: 테이블 정보 + - `column_labels`: 컬럼 정보 (라벨, input_type, detail_settings) + - `table_type_columns`: 회사별 컬럼 타입 정보 + +--- + +## 테이블 구조 + +### 1. inventory_stock (재고 현황) + +| 컬럼명 | 타입 | input_type | 설명 | +|--------|------|------------|------| +| id | VARCHAR(500) | text | PK (자동생성) | +| created_date | TIMESTAMP | date | 생성일시 | +| updated_date | TIMESTAMP | date | 수정일시 | +| writer | VARCHAR(500) | text | 작성자 | +| company_code | VARCHAR(500) | text | 회사코드 | +| item_code | VARCHAR(500) | text | 품목코드 | +| lot_number | VARCHAR(500) | text | 로트번호 | +| warehouse_id | VARCHAR(500) | entity | 창고 (FK → warehouse_info) | +| location_code | VARCHAR(500) | text | 위치코드 | +| current_qty | VARCHAR(500) | number | 현재고량 | +| safety_qty | VARCHAR(500) | number | 안전재고 | +| last_in_date | TIMESTAMP | date | 최종입고일 | +| last_out_date | TIMESTAMP | date | 최종출고일 | + +### 2. inventory_history (재고 이력) + +| 컬럼명 | 타입 | input_type | 설명 | +|--------|------|------------|------| +| id | VARCHAR(500) | text | PK (자동생성) | +| created_date | TIMESTAMP | date | 생성일시 | +| updated_date | TIMESTAMP | date | 수정일시 | +| writer | VARCHAR(500) | text | 작성자 | +| company_code | VARCHAR(500) | text | 회사코드 | +| stock_id | VARCHAR(500) | text | 재고ID (FK) | +| item_code | VARCHAR(500) | text | 품목코드 | +| lot_number | VARCHAR(500) | text | 로트번호 | +| transaction_type | VARCHAR(500) | code | 구분 (IN/OUT) | +| transaction_date | TIMESTAMP | date | 일자 | +| quantity | VARCHAR(500) | number | 수량 | +| balance_qty | VARCHAR(500) | number | 재고량 | +| manager_id | VARCHAR(500) | text | 담당자ID | +| manager_name | VARCHAR(500) | text | 담당자명 | +| remark | VARCHAR(500) | text | 비고 | +| reference_type | VARCHAR(500) | text | 참조문서유형 | +| reference_id | VARCHAR(500) | text | 참조문서ID | +| reference_number | VARCHAR(500) | text | 참조문서번호 | + +--- + +## 실행 방법 + +### Docker 환경 (권장) + +```bash +# 재고 현황 테이블 +docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/migrations/063_create_inventory_stock.sql + +# 재고 이력 테이블 +docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/migrations/064_create_inventory_history.sql +``` + +### 로컬 PostgreSQL + +```bash +psql -U postgres -d ilshin -f db/migrations/063_create_inventory_stock.sql +psql -U postgres -d ilshin -f db/migrations/064_create_inventory_history.sql +``` + +### pgAdmin / DBeaver + +1. 각 SQL 파일 열기 +2. 전체 내용 복사 +3. SQL 쿼리 창에 붙여넣기 +4. 실행 (F5 또는 Execute) + +--- + +## 검증 방법 + +### 1. 테이블 생성 확인 + +```sql +SELECT table_name +FROM information_schema.tables +WHERE table_name IN ('inventory_stock', 'inventory_history'); +``` + +### 2. 메타데이터 등록 확인 + +```sql +-- table_labels +SELECT * FROM table_labels WHERE table_name IN ('inventory_stock', 'inventory_history'); + +-- column_labels +SELECT table_name, column_name, column_label, input_type, display_order +FROM column_labels +WHERE table_name IN ('inventory_stock', 'inventory_history') +ORDER BY table_name, display_order; + +-- table_type_columns +SELECT table_name, column_name, company_code, input_type, display_order +FROM table_type_columns +WHERE table_name IN ('inventory_stock', 'inventory_history') +ORDER BY table_name, display_order; +``` + +### 3. 샘플 데이터 확인 + +```sql +-- 재고 현황 +SELECT * FROM inventory_stock WHERE company_code = 'WACE'; + +-- 재고 이력 +SELECT * FROM inventory_history WHERE company_code = 'WACE' ORDER BY transaction_date; +``` + +--- + +## 화면에서 사용할 조회 쿼리 예시 + +### 재고 현황 그리드 (좌측) + +```sql +SELECT + s.item_code, + i.item_name, + i.size as specification, + i.unit, + s.lot_number, + w.warehouse_name, + s.location_code, + s.current_qty::numeric as current_qty, + s.safety_qty::numeric as safety_qty, + CASE + WHEN s.current_qty::numeric < s.safety_qty::numeric THEN '부족' + WHEN s.current_qty::numeric > s.safety_qty::numeric * 2 THEN '과다' + ELSE '정상' + END AS stock_status, + s.last_in_date, + s.last_out_date +FROM inventory_stock s +LEFT JOIN item_info i ON s.item_code = i.item_number AND s.company_code = i.company_code +LEFT JOIN warehouse_info w ON s.warehouse_id = w.id +WHERE s.company_code = 'WACE' +ORDER BY s.item_code, s.lot_number; +``` + +### 재고 이력 패널 (우측) + +```sql +SELECT + h.transaction_type, + h.transaction_date, + h.quantity, + h.balance_qty, + h.manager_name, + h.remark +FROM inventory_history h +WHERE h.item_code = 'A001' + AND h.lot_number = 'LOT-2024-001' + AND h.company_code = 'WACE' +ORDER BY h.transaction_date DESC, h.created_date DESC; +``` + +--- + +## 데이터 흐름 + +``` +[입고 발생] + │ + ├─→ inventory_history에 INSERT (+수량, 잔량) + │ + └─→ inventory_stock에 UPDATE (current_qty 증가, last_in_date 갱신) + +[출고 발생] + │ + ├─→ inventory_history에 INSERT (-수량, 잔량) + │ + └─→ inventory_stock에 UPDATE (current_qty 감소, last_out_date 갱신) +``` + +--- + +## 롤백 방법 (문제 발생 시) + +```sql +-- 테이블 삭제 +DROP TABLE IF EXISTS inventory_history; +DROP TABLE IF EXISTS inventory_stock; + +-- 메타데이터 삭제 +DELETE FROM column_labels WHERE table_name IN ('inventory_stock', 'inventory_history'); +DELETE FROM table_labels WHERE table_name IN ('inventory_stock', 'inventory_history'); +DELETE FROM table_type_columns WHERE table_name IN ('inventory_stock', 'inventory_history'); +``` + +--- + +## 관련 테이블 (마스터 데이터) + +| 테이블 | 역할 | 연결 컬럼 | +|--------|------|-----------| +| item_info | 품목 마스터 | item_number | +| warehouse_info | 창고 마스터 | id | +| warehouse_location | 위치 마스터 | location_code | + +--- + +**작성일**: 2025-12-09 +**영향 범위**: 재고 관리 시스템 +**생성 방식**: 테이블 타입관리 UI와 동일 + + diff --git a/db/migrations/RUN_065_MIGRATION.md b/db/migrations/RUN_065_MIGRATION.md new file mode 100644 index 00000000..e63dba0d --- /dev/null +++ b/db/migrations/RUN_065_MIGRATION.md @@ -0,0 +1,30 @@ +# 065 마이그레이션 실행 가이드 + +## 연쇄 드롭다운 관계 관리 테이블 생성 + +### 실행 방법 + +```bash +# 로컬 환경 +psql -U postgres -d plm -f db/migrations/065_create_cascading_relation.sql + +# Docker 환경 +docker exec -i psql -U postgres -d plm < db/migrations/065_create_cascading_relation.sql + +# 또는 DBeaver/pgAdmin에서 직접 실행 +``` + +### 생성되는 테이블 + +- `cascading_relation`: 연쇄 드롭다운 관계 정의 테이블 + +### 샘플 데이터 + +마이그레이션 실행 시 "창고-위치" 관계 샘플 데이터가 자동으로 생성됩니다. + +### 확인 방법 + +```sql +SELECT * FROM cascading_relation; +``` + diff --git a/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md index 85ae186b..e80a1a61 100644 --- a/docs/노드플로우_개선사항.md +++ b/docs/노드플로우_개선사항.md @@ -581,3 +581,4 @@ const result = await executeNodeFlow(flowId, { - 프론트엔드 플로우 API: `frontend/lib/api/nodeFlows.ts` + diff --git a/docs/레벨기반_연쇄드롭다운_설계.md b/docs/레벨기반_연쇄드롭다운_설계.md new file mode 100644 index 00000000..f19a0d60 --- /dev/null +++ b/docs/레벨기반_연쇄드롭다운_설계.md @@ -0,0 +1,699 @@ +# 레벨 기반 연쇄 드롭다운 시스템 설계 + +## 1. 개요 + +### 1.1 목적 +다양한 계층 구조를 지원하는 범용 연쇄 드롭다운 시스템 구축 + +### 1.2 지원하는 계층 유형 + +| 유형 | 설명 | 예시 | +|------|------|------| +| **MULTI_TABLE** | 각 레벨이 다른 테이블 | 국가 → 시/도 → 구/군 → 동 | +| **SELF_REFERENCE** | 같은 테이블 내 자기참조 | 대분류 → 중분류 → 소분류 | +| **BOM** | BOM 구조 (수량 등 속성 포함) | 제품 → 어셈블리 → 부품 | +| **TREE** | 무한 깊이 트리 | 조직도, 메뉴 구조 | + +--- + +## 2. 데이터베이스 설계 + +### 2.1 테이블 구조 + +``` +┌─────────────────────────────────────┐ +│ cascading_hierarchy_group │ ← 계층 그룹 정의 +├─────────────────────────────────────┤ +│ group_code (PK) │ +│ group_name │ +│ hierarchy_type │ ← MULTI_TABLE/SELF_REFERENCE/BOM/TREE +│ max_levels │ +│ is_fixed_levels │ +│ self_ref_* (자기참조 설정) │ +│ bom_* (BOM 설정) │ +│ company_code │ +└─────────────────────────────────────┘ + │ + │ 1:N (MULTI_TABLE 유형만) + ▼ +┌─────────────────────────────────────┐ +│ cascading_hierarchy_level │ ← 레벨별 테이블/컬럼 정의 +├─────────────────────────────────────┤ +│ group_code (FK) │ +│ level_order │ ← 1, 2, 3... +│ level_name │ +│ table_name │ +│ value_column │ +│ label_column │ +│ parent_key_column │ ← 부모 테이블 참조 컬럼 +│ company_code │ +└─────────────────────────────────────┘ +``` + +### 2.2 기존 시스템과의 관계 + +``` +┌─────────────────────────────────────┐ +│ cascading_relation │ ← 기존 2단계 관계 (유지) +│ (2단계 전용) │ +└─────────────────────────────────────┘ + │ + │ 호환성 뷰 + ▼ +┌─────────────────────────────────────┐ +│ v_cascading_as_hierarchy │ ← 기존 관계를 계층 형태로 변환 +└─────────────────────────────────────┘ +``` + +--- + +## 3. 계층 유형별 상세 설계 + +### 3.1 MULTI_TABLE (다중 테이블 계층) + +**사용 사례**: 국가 → 시/도 → 구/군 → 동 + +**테이블 구조**: +``` +country_info province_info city_info district_info +├─ country_code (PK) ├─ province_code (PK) ├─ city_code (PK) ├─ district_code (PK) +├─ country_name ├─ province_name ├─ city_name ├─ district_name + ├─ country_code (FK) ├─ province_code (FK) ├─ city_code (FK) +``` + +**설정 예시**: +```sql +-- 그룹 정의 +INSERT INTO cascading_hierarchy_group ( + group_code, group_name, hierarchy_type, max_levels, company_code +) VALUES ( + 'REGION_HIERARCHY', '지역 계층', 'MULTI_TABLE', 4, 'EMAX' +); + +-- 레벨 정의 +INSERT INTO cascading_hierarchy_level VALUES +(1, 'REGION_HIERARCHY', 'EMAX', 1, '국가', 'country_info', 'country_code', 'country_name', NULL), +(2, 'REGION_HIERARCHY', 'EMAX', 2, '시/도', 'province_info', 'province_code', 'province_name', 'country_code'), +(3, 'REGION_HIERARCHY', 'EMAX', 3, '구/군', 'city_info', 'city_code', 'city_name', 'province_code'), +(4, 'REGION_HIERARCHY', 'EMAX', 4, '동', 'district_info', 'district_code', 'district_name', 'city_code'); +``` + +**API 호출 흐름**: +``` +1. 레벨 1 (국가): GET /api/cascading-hierarchy/options/REGION_HIERARCHY/1 + → [{ value: 'KR', label: '대한민국' }, { value: 'US', label: '미국' }] + +2. 레벨 2 (시/도): GET /api/cascading-hierarchy/options/REGION_HIERARCHY/2?parentValue=KR + → [{ value: 'SEOUL', label: '서울특별시' }, { value: 'BUSAN', label: '부산광역시' }] + +3. 레벨 3 (구/군): GET /api/cascading-hierarchy/options/REGION_HIERARCHY/3?parentValue=SEOUL + → [{ value: 'GANGNAM', label: '강남구' }, { value: 'SEOCHO', label: '서초구' }] +``` + +--- + +### 3.2 SELF_REFERENCE (자기참조 계층) + +**사용 사례**: 제품 카테고리 (대분류 → 중분류 → 소분류) + +**테이블 구조** (code_info 활용): +``` +code_info +├─ code_category = 'PRODUCT_CATEGORY' +├─ code_value (PK) = 'ELEC', 'ELEC_TV', 'ELEC_TV_LED' +├─ code_name = '전자제품', 'TV', 'LED TV' +├─ parent_code = NULL, 'ELEC', 'ELEC_TV' ← 자기참조 +├─ level = 1, 2, 3 +├─ sort_order +``` + +**설정 예시**: +```sql +INSERT INTO cascading_hierarchy_group ( + group_code, group_name, hierarchy_type, max_levels, + self_ref_table, self_ref_id_column, self_ref_parent_column, + self_ref_value_column, self_ref_label_column, self_ref_level_column, + self_ref_filter_column, self_ref_filter_value, + company_code +) VALUES ( + 'PRODUCT_CATEGORY', '제품 카테고리', 'SELF_REFERENCE', 3, + 'code_info', 'code_value', 'parent_code', + 'code_value', 'code_name', 'level', + 'code_category', 'PRODUCT_CATEGORY', + 'EMAX' +); +``` + +**API 호출 흐름**: +``` +1. 레벨 1 (대분류): GET /api/cascading-hierarchy/options/PRODUCT_CATEGORY/1 + → WHERE parent_code IS NULL AND code_category = 'PRODUCT_CATEGORY' + → [{ value: 'ELEC', label: '전자제품' }, { value: 'FURN', label: '가구' }] + +2. 레벨 2 (중분류): GET /api/cascading-hierarchy/options/PRODUCT_CATEGORY/2?parentValue=ELEC + → WHERE parent_code = 'ELEC' AND code_category = 'PRODUCT_CATEGORY' + → [{ value: 'ELEC_TV', label: 'TV' }, { value: 'ELEC_REF', label: '냉장고' }] + +3. 레벨 3 (소분류): GET /api/cascading-hierarchy/options/PRODUCT_CATEGORY/3?parentValue=ELEC_TV + → WHERE parent_code = 'ELEC_TV' AND code_category = 'PRODUCT_CATEGORY' + → [{ value: 'ELEC_TV_LED', label: 'LED TV' }, { value: 'ELEC_TV_OLED', label: 'OLED TV' }] +``` + +--- + +### 3.3 BOM (Bill of Materials) + +**사용 사례**: 제품 BOM 구조 + +**테이블 구조**: +``` +klbom_tbl (BOM 관계) item_info (품목 마스터) +├─ id (자식 품목) ├─ item_code (PK) +├─ pid (부모 품목) ├─ item_name +├─ qty (수량) ├─ item_spec +├─ aylevel (레벨) ├─ unit +├─ bom_report_objid +``` + +**설정 예시**: +```sql +INSERT INTO cascading_hierarchy_group ( + group_code, group_name, hierarchy_type, max_levels, is_fixed_levels, + bom_table, bom_parent_column, bom_child_column, + bom_item_table, bom_item_id_column, bom_item_label_column, + bom_qty_column, bom_level_column, + company_code +) VALUES ( + 'PRODUCT_BOM', '제품 BOM', 'BOM', NULL, 'N', + 'klbom_tbl', 'pid', 'id', + 'item_info', 'item_code', 'item_name', + 'qty', 'aylevel', + 'EMAX' +); +``` + +**API 호출 흐름**: +``` +1. 루트 품목 (레벨 1): GET /api/cascading-hierarchy/bom/PRODUCT_BOM/roots + → WHERE pid IS NULL OR pid = '' + → [{ value: 'PROD001', label: '완제품 A', level: 1 }] + +2. 하위 품목: GET /api/cascading-hierarchy/bom/PRODUCT_BOM/children?parentValue=PROD001 + → WHERE pid = 'PROD001' + → [ + { value: 'ASSY001', label: '어셈블리 A', qty: 1, level: 2 }, + { value: 'ASSY002', label: '어셈블리 B', qty: 2, level: 2 } + ] + +3. 더 하위: GET /api/cascading-hierarchy/bom/PRODUCT_BOM/children?parentValue=ASSY001 + → WHERE pid = 'ASSY001' + → [ + { value: 'PART001', label: '부품 A', qty: 4, level: 3 }, + { value: 'PART002', label: '부품 B', qty: 2, level: 3 } + ] +``` + +**BOM 전용 응답 형식**: +```typescript +interface BomOption { + value: string; // 품목 코드 + label: string; // 품목명 + qty: number; // 수량 + level: number; // BOM 레벨 + hasChildren: boolean; // 하위 품목 존재 여부 + spec?: string; // 규격 (선택) + unit?: string; // 단위 (선택) +} +``` + +--- + +### 3.4 TREE (무한 깊이 트리) + +**사용 사례**: 조직도, 메뉴 구조 + +**테이블 구조**: +``` +dept_info +├─ dept_code (PK) +├─ dept_name +├─ parent_dept_code ← 자기참조 (무한 깊이) +├─ sort_order +├─ is_active +``` + +**설정 예시**: +```sql +INSERT INTO cascading_hierarchy_group ( + group_code, group_name, hierarchy_type, max_levels, is_fixed_levels, + self_ref_table, self_ref_id_column, self_ref_parent_column, + self_ref_value_column, self_ref_label_column, self_ref_order_column, + company_code +) VALUES ( + 'ORG_CHART', '조직도', 'TREE', NULL, 'N', + 'dept_info', 'dept_code', 'parent_dept_code', + 'dept_code', 'dept_name', 'sort_order', + 'EMAX' +); +``` + +**API 호출 흐름** (BOM과 유사): +``` +1. 루트 노드: GET /api/cascading-hierarchy/tree/ORG_CHART/roots + → WHERE parent_dept_code IS NULL + → [{ value: 'HQ', label: '본사', hasChildren: true }] + +2. 하위 노드: GET /api/cascading-hierarchy/tree/ORG_CHART/children?parentValue=HQ + → WHERE parent_dept_code = 'HQ' + → [ + { value: 'DIV1', label: '사업부1', hasChildren: true }, + { value: 'DIV2', label: '사업부2', hasChildren: true } + ] +``` + +--- + +## 4. API 설계 + +### 4.1 계층 그룹 관리 API + +``` +GET /api/cascading-hierarchy/groups # 그룹 목록 +POST /api/cascading-hierarchy/groups # 그룹 생성 +GET /api/cascading-hierarchy/groups/:code # 그룹 상세 +PUT /api/cascading-hierarchy/groups/:code # 그룹 수정 +DELETE /api/cascading-hierarchy/groups/:code # 그룹 삭제 +``` + +### 4.2 레벨 관리 API (MULTI_TABLE용) + +``` +GET /api/cascading-hierarchy/groups/:code/levels # 레벨 목록 +POST /api/cascading-hierarchy/groups/:code/levels # 레벨 추가 +PUT /api/cascading-hierarchy/groups/:code/levels/:order # 레벨 수정 +DELETE /api/cascading-hierarchy/groups/:code/levels/:order # 레벨 삭제 +``` + +### 4.3 옵션 조회 API + +``` +# MULTI_TABLE / SELF_REFERENCE +GET /api/cascading-hierarchy/options/:groupCode/:level + ?parentValue=xxx # 부모 값 (레벨 2 이상) + &companyCode=xxx # 회사 코드 (선택) + +# BOM / TREE +GET /api/cascading-hierarchy/tree/:groupCode/roots # 루트 노드 +GET /api/cascading-hierarchy/tree/:groupCode/children # 자식 노드 + ?parentValue=xxx +GET /api/cascading-hierarchy/tree/:groupCode/path # 경로 조회 + ?value=xxx +GET /api/cascading-hierarchy/tree/:groupCode/search # 검색 + ?keyword=xxx +``` + +--- + +## 5. 프론트엔드 컴포넌트 설계 + +### 5.1 CascadingHierarchyDropdown + +```typescript +interface CascadingHierarchyDropdownProps { + groupCode: string; // 계층 그룹 코드 + level: number; // 현재 레벨 (1, 2, 3...) + parentValue?: string; // 부모 값 (레벨 2 이상) + value?: string; // 선택된 값 + onChange: (value: string, option: HierarchyOption) => void; + placeholder?: string; + disabled?: boolean; + required?: boolean; +} + +// 사용 예시 (지역 계층) + + + +``` + +### 5.2 CascadingHierarchyGroup (자동 연결) + +```typescript +interface CascadingHierarchyGroupProps { + groupCode: string; + values: Record; // { 1: 'KR', 2: 'SEOUL', 3: 'GANGNAM' } + onChange: (level: number, value: string) => void; + layout?: 'horizontal' | 'vertical'; +} + +// 사용 예시 + { + setRegionValues(prev => ({ ...prev, [level]: value })); + }} +/> +``` + +### 5.3 BomTreeSelect (BOM 전용) + +```typescript +interface BomTreeSelectProps { + groupCode: string; + value?: string; + onChange: (value: string, path: BomOption[]) => void; + showQty?: boolean; // 수량 표시 + showLevel?: boolean; // 레벨 표시 + maxDepth?: number; // 최대 깊이 제한 +} + +// 사용 예시 + { + setSelectedPart(value); + console.log('선택 경로:', path); // [완제품 → 어셈블리 → 부품] + }} + showQty +/> +``` + +--- + +## 6. 화면관리 시스템 통합 + +### 6.1 컴포넌트 설정 확장 + +```typescript +interface SelectBasicConfig { + // 기존 설정 + cascadingEnabled?: boolean; + cascadingRelationCode?: string; // 기존 2단계 관계 + cascadingRole?: 'parent' | 'child'; + cascadingParentField?: string; + + // 🆕 레벨 기반 계층 설정 + hierarchyEnabled?: boolean; + hierarchyGroupCode?: string; // 계층 그룹 코드 + hierarchyLevel?: number; // 이 컴포넌트의 레벨 + hierarchyParentField?: string; // 부모 레벨 필드명 +} +``` + +### 6.2 설정 UI 확장 + +``` +┌─────────────────────────────────────────┐ +│ 연쇄 드롭다운 설정 │ +├─────────────────────────────────────────┤ +│ ○ 2단계 관계 (기존) │ +│ └─ 관계 선택: [창고-위치 ▼] │ +│ └─ 역할: [부모] [자식] │ +│ │ +│ ● 다단계 계층 (신규) │ +│ └─ 계층 그룹: [지역 계층 ▼] │ +│ └─ 레벨: [2 - 시/도 ▼] │ +│ └─ 부모 필드: [country_code] (자동감지) │ +└─────────────────────────────────────────┘ +``` + +--- + +## 7. 구현 우선순위 + +### Phase 1: 기반 구축 +1. ✅ 기존 2단계 연쇄 드롭다운 완성 +2. 📋 데이터베이스 마이그레이션 (066_create_cascading_hierarchy.sql) +3. 📋 백엔드 API 구현 (계층 그룹 CRUD) + +### Phase 2: MULTI_TABLE 지원 +1. 📋 레벨 관리 API +2. 📋 옵션 조회 API +3. 📋 프론트엔드 컴포넌트 + +### Phase 3: SELF_REFERENCE 지원 +1. 📋 자기참조 쿼리 로직 +2. 📋 code_info 기반 카테고리 계층 + +### Phase 4: BOM/TREE 지원 +1. 📋 BOM 전용 API +2. 📋 트리 컴포넌트 +3. 📋 무한 깊이 지원 + +### Phase 5: 화면관리 통합 +1. 📋 설정 UI 확장 +2. 📋 자동 연결 기능 + +--- + +## 8. 성능 고려사항 + +### 8.1 쿼리 최적화 +- 인덱스: `(group_code, company_code, level_order)` +- 캐싱: 자주 조회되는 옵션 목록 Redis 캐싱 +- Lazy Loading: 하위 레벨은 필요 시에만 로드 + +### 8.2 BOM 재귀 쿼리 +```sql +-- PostgreSQL WITH RECURSIVE 활용 +WITH RECURSIVE bom_tree AS ( + -- 루트 노드 + SELECT id, pid, qty, 1 AS level + FROM klbom_tbl + WHERE pid IS NULL + + UNION ALL + + -- 하위 노드 + SELECT b.id, b.pid, b.qty, t.level + 1 + FROM klbom_tbl b + JOIN bom_tree t ON b.pid = t.id + WHERE t.level < 10 -- 최대 깊이 제한 +) +SELECT * FROM bom_tree; +``` + +### 8.3 트리 최적화 전략 +- Materialized Path: `/HQ/DIV1/DEPT1/TEAM1` +- Nested Set: left/right 값으로 범위 쿼리 +- Closure Table: 별도 관계 테이블 + +--- + +## 9. 추가 연쇄 패턴 + +### 9.1 조건부 연쇄 (Conditional Cascading) + +**사용 사례**: 특정 조건에 따라 다른 옵션 목록 표시 + +``` +입고유형: [구매입고] → 창고: [원자재창고, 부품창고] 만 표시 +입고유형: [생산입고] → 창고: [완제품창고, 반제품창고] 만 표시 +``` + +**테이블**: `cascading_condition` + +```sql +INSERT INTO cascading_condition ( + relation_code, condition_name, + condition_field, condition_operator, condition_value, + filter_column, filter_values, company_code +) VALUES +('WAREHOUSE_LOCATION', '구매입고 창고', + 'inbound_type', 'EQ', 'PURCHASE', + 'warehouse_type', 'RAW_MATERIAL,PARTS', 'EMAX'); +``` + +--- + +### 9.2 다중 부모 연쇄 (Multi-Parent Cascading) + +**사용 사례**: 여러 부모 필드의 조합으로 자식 필터링 + +``` +회사: [A사] + 사업부: [영업부문] → 부서: [영업1팀, 영업2팀] +``` + +**테이블**: `cascading_multi_parent`, `cascading_multi_parent_source` + +```sql +-- 관계 정의 +INSERT INTO cascading_multi_parent ( + relation_code, relation_name, + child_table, child_value_column, child_label_column, company_code +) VALUES ( + 'COMPANY_DIVISION_DEPT', '회사-사업부-부서', + 'dept_info', 'dept_code', 'dept_name', 'EMAX' +); + +-- 부모 소스 정의 +INSERT INTO cascading_multi_parent_source ( + relation_code, company_code, parent_order, parent_name, + parent_table, parent_value_column, child_filter_column +) VALUES +('COMPANY_DIVISION_DEPT', 'EMAX', 1, '회사', 'company_info', 'company_code', 'company_code'), +('COMPANY_DIVISION_DEPT', 'EMAX', 2, '사업부', 'division_info', 'division_code', 'division_code'); +``` + +--- + +### 9.3 자동 입력 그룹 (Auto-Fill Group) + +**사용 사례**: 마스터 선택 시 여러 필드 자동 입력 + +``` +고객사 선택 → 담당자, 연락처, 주소, 결제조건 자동 입력 +``` + +**테이블**: `cascading_auto_fill_group`, `cascading_auto_fill_mapping` + +```sql +-- 그룹 정의 +INSERT INTO cascading_auto_fill_group ( + group_code, group_name, + master_table, master_value_column, master_label_column, company_code +) VALUES ( + 'CUSTOMER_AUTO_FILL', '고객사 정보 자동입력', + 'customer_info', 'customer_code', 'customer_name', 'EMAX' +); + +-- 필드 매핑 +INSERT INTO cascading_auto_fill_mapping ( + group_code, company_code, source_column, target_field, target_label +) VALUES +('CUSTOMER_AUTO_FILL', 'EMAX', 'contact_person', 'contact_name', '담당자'), +('CUSTOMER_AUTO_FILL', 'EMAX', 'contact_phone', 'contact_phone', '연락처'), +('CUSTOMER_AUTO_FILL', 'EMAX', 'address', 'delivery_address', '배송주소'); +``` + +--- + +### 9.4 상호 배제 (Mutual Exclusion) + +**사용 사례**: 같은 값 선택 불가 + +``` +출발 창고: [창고A] → 도착 창고: [창고B, 창고C] (창고A 제외) +``` + +**테이블**: `cascading_mutual_exclusion` + +```sql +INSERT INTO cascading_mutual_exclusion ( + exclusion_code, exclusion_name, field_names, + source_table, value_column, label_column, + error_message, company_code +) VALUES ( + 'WAREHOUSE_TRANSFER', '창고간 이동', + 'from_warehouse_code,to_warehouse_code', + 'warehouse_info', 'warehouse_code', 'warehouse_name', + '출발 창고와 도착 창고는 같을 수 없습니다', + 'EMAX' +); +``` + +--- + +### 9.5 역방향 조회 (Reverse Lookup) + +**사용 사례**: 자식에서 부모 방향으로 조회 + +``` +품목: [부품A] 선택 → 사용처 BOM: [제품X, 제품Y, 제품Z] +``` + +**테이블**: `cascading_reverse_lookup` + +```sql +INSERT INTO cascading_reverse_lookup ( + lookup_code, lookup_name, + source_table, source_value_column, source_label_column, + target_table, target_value_column, target_label_column, target_link_column, + company_code +) VALUES ( + 'ITEM_USED_IN_BOM', '품목 사용처 BOM', + 'item_info', 'item_code', 'item_name', + 'klbom_tbl', 'pid', 'ayupgname', 'id', + 'EMAX' +); +``` + +--- + +## 10. 전체 테이블 구조 요약 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 연쇄 드롭다운 시스템 구조 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ [기존 - 2단계] │ +│ cascading_relation ─────────────────────────────────────────── │ +│ │ +│ [신규 - 다단계 계층] │ +│ cascading_hierarchy_group ──┬── cascading_hierarchy_level │ +│ │ (MULTI_TABLE용) │ +│ │ │ +│ [신규 - 조건부] │ +│ cascading_condition ────────┴── 조건에 따른 필터링 │ +│ │ +│ [신규 - 다중 부모] │ +│ cascading_multi_parent ─────┬── cascading_multi_parent_source │ +│ │ (여러 부모 조합) │ +│ │ +│ [신규 - 자동 입력] │ +│ cascading_auto_fill_group ──┬── cascading_auto_fill_mapping │ +│ │ (마스터→다중 필드) │ +│ │ +│ [신규 - 상호 배제] │ +│ cascading_mutual_exclusion ─┴── 같은 값 선택 불가 │ +│ │ +│ [신규 - 역방향] │ +│ cascading_reverse_lookup ───┴── 자식→부모 조회 │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 11. 마이그레이션 가이드 + +### 11.1 기존 데이터 마이그레이션 +```sql +-- 기존 cascading_relation → cascading_hierarchy_group 변환 +INSERT INTO cascading_hierarchy_group ( + group_code, group_name, hierarchy_type, max_levels, company_code +) +SELECT + 'LEGACY_' || relation_code, + relation_name, + 'MULTI_TABLE', + 2, + company_code +FROM cascading_relation +WHERE is_active = 'Y'; +``` + +### 11.2 호환성 유지 +- 기존 `cascading_relation` 테이블 유지 +- 기존 API 엔드포인트 유지 +- 점진적 마이그레이션 지원 + +--- + +## 12. 구현 우선순위 (업데이트) + +| Phase | 기능 | 복잡도 | 우선순위 | +|-------|------|--------|----------| +| 1 | 기존 2단계 연쇄 (cascading_relation) | 완료 | 완료 | +| 2 | 다단계 계층 - MULTI_TABLE | 중 | 높음 | +| 3 | 다단계 계층 - SELF_REFERENCE | 중 | 높음 | +| 4 | 자동 입력 그룹 (Auto-Fill) | 낮음 | 높음 | +| 5 | 조건부 연쇄 | 중 | 중간 | +| 6 | 상호 배제 | 낮음 | 중간 | +| 7 | 다중 부모 연쇄 | 높음 | 낮음 | +| 8 | BOM/TREE 구조 | 높음 | 낮음 | +| 9 | 역방향 조회 | 중 | 낮음 | + diff --git a/docs/메일발송_기능_사용_가이드.md b/docs/메일발송_기능_사용_가이드.md new file mode 100644 index 00000000..2ef68524 --- /dev/null +++ b/docs/메일발송_기능_사용_가이드.md @@ -0,0 +1,357 @@ +# 메일 발송 기능 사용 가이드 + +## 개요 + +노드 기반 제어관리 시스템을 통해 메일을 발송하는 방법을 설명합니다. +화면에서 데이터를 선택하고, 수신자를 지정하여 템플릿 기반의 메일을 발송할 수 있습니다. + +--- + +## 1. 사전 준비 + +### 1.1 메일 계정 등록 + +메일 발송을 위해 먼저 SMTP 계정을 등록해야 합니다. + +1. **관리자** > **메일관리** > **계정관리** 이동 +2. **새 계정 추가** 클릭 +3. SMTP 정보 입력: + - 계정명: 식별용 이름 (예: "회사 공식 메일") + - 이메일: 발신자 이메일 주소 + - SMTP 호스트: 메일 서버 주소 (예: smtp.gmail.com) + - SMTP 포트: 포트 번호 (예: 587) + - 보안: TLS/SSL 선택 + - 사용자명/비밀번호: SMTP 인증 정보 +4. **저장** 후 **테스트 발송**으로 동작 확인 + +--- + +## 2. 제어관리 설정 + +### 2.1 메일 발송 플로우 생성 + +**관리자** > **제어관리** > **플로우 관리**에서 새 플로우를 생성합니다. + +#### 기본 구조 + +``` +[테이블 소스] → [메일 발송] +``` + +#### 노드 구성 + +1. **테이블 소스 노드** 추가 + + - 데이터 소스: **컨텍스트 데이터** (화면에서 선택한 데이터 사용) + - 또는 **테이블 전체 데이터** (주의: 전체 데이터 건수만큼 메일 발송) + +2. **메일 발송 노드** 추가 + + - 노드 팔레트 > 외부 실행 > **메일 발송** 드래그 + +3. 두 노드 연결 (테이블 소스 → 메일 발송) + +--- + +### 2.2 메일 발송 노드 설정 + +메일 발송 노드를 클릭하면 우측에 속성 패널이 표시됩니다. + +#### 계정 탭 + +| 설정 | 설명 | +| -------------- | ----------------------------------- | +| 발송 계정 선택 | 사전에 등록한 메일 계정 선택 (필수) | + +#### 메일 탭 + +| 설정 | 설명 | +| -------------------- | ------------------------------------------------ | +| 수신자 컴포넌트 사용 | 체크 시 화면의 수신자 선택 컴포넌트 값 자동 사용 | +| 수신자 필드명 | 수신자 변수명 (기본: mailTo) | +| 참조 필드명 | 참조 변수명 (기본: mailCc) | +| 수신자 (To) | 직접 입력 또는 변수 사용 (예: `{{email}}`) | +| 참조 (CC) | 참조 수신자 | +| 숨은 참조 (BCC) | 숨은 참조 수신자 | +| 우선순위 | 높음 / 보통 / 낮음 | + +#### 본문 탭 + +| 설정 | 설명 | +| --------- | -------------------------------- | +| 제목 | 메일 제목 (변수 사용 가능) | +| 본문 형식 | 텍스트 (변수 태그 에디터) / HTML | +| 본문 내용 | 메일 본문 (변수 사용 가능) | + +#### 옵션 탭 + +| 설정 | 설명 | +| ----------- | ------------------- | +| 타임아웃 | 발송 제한 시간 (ms) | +| 재시도 횟수 | 실패 시 재시도 횟수 | + +--- + +### 2.3 변수 사용 방법 + +메일 제목과 본문에서 `{{변수명}}` 형식으로 데이터 필드를 참조할 수 있습니다. + +#### 텍스트 모드 (변수 태그 에디터) + +1. 본문 형식을 **텍스트 (변수 태그 에디터)** 선택 +2. 에디터에서 `@` 또는 `/` 키 입력 +3. 변수 목록에서 원하는 변수 선택 +4. 선택된 변수는 파란색 태그로 표시 + +#### HTML 모드 (직접 입력) + +```html +

주문 확인

+

안녕하세요 {{customerName}}님,

+

주문번호 {{orderNo}}의 주문이 완료되었습니다.

+

금액: {{totalAmount}}원

+``` + +#### 사용 가능한 변수 + +| 변수 | 설명 | +| ---------------- | ------------------------ | +| `{{timestamp}}` | 메일 발송 시점 | +| `{{sourceData}}` | 전체 소스 데이터 (JSON) | +| `{{필드명}}` | 테이블 소스의 각 컬럼 값 | + +--- + +## 3. 화면 구성 + +### 3.1 기본 구조 + +메일 발송 화면은 보통 다음과 같이 구성합니다: + +``` +[부모 화면: 데이터 목록] + ↓ (모달 열기 버튼) +[모달: 수신자 입력 + 발송 버튼] +``` + +### 3.2 수신자 선택 컴포넌트 배치 + +1. **화면관리**에서 모달 화면 편집 +2. 컴포넌트 팔레트 > **메일 수신자 선택** 드래그 +3. 컴포넌트 설정: + - 수신자 필드명: `mailTo` (메일 발송 노드와 일치) + - 참조 필드명: `mailCc` (메일 발송 노드와 일치) + +#### 수신자 선택 기능 + +- **내부 사용자**: 회사 직원 목록에서 검색/선택 +- **외부 이메일**: 직접 이메일 주소 입력 +- 여러 명 선택 가능 (쉼표로 구분) + +### 3.3 발송 버튼 설정 + +1. **버튼** 컴포넌트 추가 +2. 버튼 설정: + - 액션 타입: **제어 실행** + - 플로우 선택: 생성한 메일 발송 플로우 + - 데이터 소스: **자동** 또는 **폼 + 테이블 선택** + +--- + +## 4. 전체 흐름 예시 + +### 4.1 시나리오: 선택한 주문 건에 대해 고객에게 메일 발송 + +#### Step 1: 제어관리 플로우 생성 + +``` +[테이블 소스: 컨텍스트 데이터] + ↓ +[메일 발송] + - 계정: 회사 공식 메일 + - 수신자 컴포넌트 사용: 체크 + - 제목: [주문확인] {{orderNo}} 주문이 완료되었습니다 + - 본문: + 안녕하세요 {{customerName}}님, + + 주문번호 {{orderNo}}의 주문이 정상 처리되었습니다. + + - 상품명: {{productName}} + - 수량: {{quantity}} + - 금액: {{totalAmount}}원 + + 감사합니다. +``` + +#### Step 2: 부모 화면 (주문 목록) + +- 주문 데이터 테이블 +- "메일 발송" 버튼 + - 액션: 모달 열기 + - 모달 화면: 메일 발송 모달 + - 선택된 데이터 전달: 체크 + +#### Step 3: 모달 화면 (메일 발송) + +- 메일 수신자 선택 컴포넌트 + - 수신자 (To) 입력 + - 참조 (CC) 입력 +- "발송" 버튼 + - 액션: 제어 실행 + - 플로우: 메일 발송 플로우 + +#### Step 4: 실행 흐름 + +1. 사용자가 주문 목록에서 주문 선택 +2. "메일 발송" 버튼 클릭 → 모달 열림 +3. 수신자/참조 입력 +4. "발송" 버튼 클릭 +5. 제어 실행: + - 부모 화면 데이터 (orderNo, customerName 등) + 모달 폼 데이터 (mailTo, mailCc) 병합 + - 변수 치환 후 메일 발송 + +--- + +## 5. 데이터 소스별 동작 + +### 5.1 컨텍스트 데이터 (권장) + +- 화면에서 **선택한 데이터**만 사용 +- 선택한 건수만큼 메일 발송 + +| 선택 건수 | 메일 발송 수 | +| --------- | ------------ | +| 1건 | 1통 | +| 5건 | 5통 | +| 10건 | 10통 | + +### 5.2 테이블 전체 데이터 (주의) + +- 테이블의 **모든 데이터** 사용 +- 전체 건수만큼 메일 발송 + +| 테이블 데이터 | 메일 발송 수 | +| ------------- | ------------ | +| 100건 | 100통 | +| 1000건 | 1000통 | + +**주의사항:** + +- 대량 발송 시 SMTP 서버 rate limit 주의 +- 테스트 시 반드시 데이터 건수 확인 + +--- + +## 6. 문제 해결 + +### 6.1 메일이 발송되지 않음 + +1. **계정 설정 확인**: 메일관리 > 계정관리에서 테스트 발송 확인 +2. **수신자 확인**: 수신자 이메일 주소가 올바른지 확인 +3. **플로우 연결 확인**: 테이블 소스 → 메일 발송 노드가 연결되어 있는지 확인 + +### 6.2 변수가 치환되지 않음 + +1. **변수명 확인**: `{{변수명}}`에서 변수명이 테이블 컬럼명과 일치하는지 확인 +2. **데이터 소스 확인**: 테이블 소스 노드가 올바른 데이터를 가져오는지 확인 +3. **데이터 전달 확인**: 부모 화면 → 모달로 데이터가 전달되는지 확인 + +### 6.3 수신자 컴포넌트 값이 전달되지 않음 + +1. **필드명 일치 확인**: + - 수신자 컴포넌트의 필드명과 메일 발송 노드의 필드명이 일치해야 함 + - 기본값: `mailTo`, `mailCc` +2. **수신자 컴포넌트 사용 체크**: 메일 발송 노드에서 "수신자 컴포넌트 사용" 활성화 + +### 6.4 부모 화면 데이터가 메일에 포함되지 않음 + +1. **모달 열기 설정 확인**: "선택된 데이터 전달" 옵션 활성화 +2. **데이터 소스 설정 확인**: 발송 버튼의 데이터 소스가 "자동" 또는 "폼 + 테이블 선택"인지 확인 + +--- + +## 7. 고급 기능 + +### 7.1 조건부 메일 발송 + +조건 분기 노드를 사용하여 특정 조건에서만 메일을 발송할 수 있습니다. + +``` +[테이블 소스] + ↓ +[조건 분기: status === 'approved'] + ↓ (true) +[메일 발송: 승인 알림] +``` + +### 7.2 다중 수신자 처리 + +수신자 필드에 쉼표로 구분하여 여러 명에게 동시 발송: + +``` +{{managerEmail}}, {{teamLeadEmail}}, external@example.com +``` + +### 7.3 HTML 템플릿 활용 + +본문 형식을 HTML로 설정하면 풍부한 형식의 메일을 보낼 수 있습니다: + +```html + + + + + + +
+

주문 확인

+
+
+

안녕하세요 {{customerName}}님,

+

주문번호 {{orderNo}}의 주문이 완료되었습니다.

+ + + + + + + + + +
상품명{{productName}}
금액{{totalAmount}}원
+
+ + + +``` + +--- + +## 8. 체크리스트 + +메일 발송 기능 구현 시 확인 사항: + +- [ ] 메일 계정이 등록되어 있는가? +- [ ] 메일 계정 테스트 발송이 성공하는가? +- [ ] 제어관리에 메일 발송 플로우가 생성되어 있는가? +- [ ] 테이블 소스 노드의 데이터 소스가 올바르게 설정되어 있는가? +- [ ] 메일 발송 노드에서 계정이 선택되어 있는가? +- [ ] 수신자 컴포넌트 사용 시 필드명이 일치하는가? +- [ ] 변수명이 테이블 컬럼명과 일치하는가? +- [ ] 부모 화면에서 모달로 데이터가 전달되는가? +- [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가? + diff --git a/frontend/app/(main)/admin/auto-fill/page.tsx b/frontend/app/(main)/admin/auto-fill/page.tsx new file mode 100644 index 00000000..64e5e789 --- /dev/null +++ b/frontend/app/(main)/admin/auto-fill/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +/** + * 기존 자동입력 페이지 → 통합 관리 페이지로 리다이렉트 + */ +export default function AutoFillRedirect() { + const router = useRouter(); + + useEffect(() => { + router.replace("/admin/cascading-management?tab=autofill"); + }, [router]); + + return ( +
+
리다이렉트 중...
+
+ ); +} diff --git a/frontend/app/(main)/admin/cascading-management/page.tsx b/frontend/app/(main)/admin/cascading-management/page.tsx new file mode 100644 index 00000000..70382dd9 --- /dev/null +++ b/frontend/app/(main)/admin/cascading-management/page.tsx @@ -0,0 +1,104 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Link2, Layers, Filter, FormInput, Ban } from "lucide-react"; + +// 탭별 컴포넌트 +import CascadingRelationsTab from "./tabs/CascadingRelationsTab"; +import AutoFillTab from "./tabs/AutoFillTab"; +import HierarchyTab from "./tabs/HierarchyTab"; +import ConditionTab from "./tabs/ConditionTab"; +import MutualExclusionTab from "./tabs/MutualExclusionTab"; + +export default function CascadingManagementPage() { + const searchParams = useSearchParams(); + const router = useRouter(); + const [activeTab, setActiveTab] = useState("relations"); + + // URL 쿼리 파라미터에서 탭 설정 + useEffect(() => { + const tab = searchParams.get("tab"); + if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion"].includes(tab)) { + setActiveTab(tab); + } + }, [searchParams]); + + // 탭 변경 시 URL 업데이트 + const handleTabChange = (value: string) => { + setActiveTab(value); + const url = new URL(window.location.href); + url.searchParams.set("tab", value); + router.replace(url.pathname + url.search); + }; + + return ( +
+
+ {/* 페이지 헤더 */} +
+

연쇄 드롭다운 통합 관리

+

+ 연쇄 드롭다운, 자동 입력, 다단계 계층, 조건부 필터 등을 관리합니다. +

+
+ + {/* 탭 네비게이션 */} + + + + + 2단계 연쇄관계 + 연쇄 + + + + 다단계 계층 + 계층 + + + + 조건부 필터 + 조건 + + + + 자동 입력 + 자동 + + + + 상호 배제 + 배제 + + + + {/* 탭 컨텐츠 */} +
+ + + + + + + + + + + + + + + + + + + +
+
+
+
+ ); +} + diff --git a/frontend/app/(main)/admin/cascading-management/tabs/AutoFillTab.tsx b/frontend/app/(main)/admin/cascading-management/tabs/AutoFillTab.tsx new file mode 100644 index 00000000..79208186 --- /dev/null +++ b/frontend/app/(main)/admin/cascading-management/tabs/AutoFillTab.tsx @@ -0,0 +1,686 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Separator } from "@/components/ui/separator"; +import { + Check, + ChevronsUpDown, + Plus, + Pencil, + Trash2, + Search, + RefreshCw, + ArrowRight, + X, + GripVertical, +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { cascadingAutoFillApi, AutoFillGroup, AutoFillMapping } from "@/lib/api/cascadingAutoFill"; +import { tableManagementApi } from "@/lib/api/tableManagement"; + +interface TableColumn { + columnName: string; + columnLabel?: string; + dataType?: string; +} + +export default function AutoFillTab() { + // 목록 상태 + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [searchText, setSearchText] = useState(""); + + // 모달 상태 + const [isModalOpen, setIsModalOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [editingGroup, setEditingGroup] = useState(null); + const [deletingGroupCode, setDeletingGroupCode] = useState(null); + + // 테이블/컬럼 목록 + const [tableList, setTableList] = useState>([]); + const [masterColumns, setMasterColumns] = useState([]); + + // 폼 데이터 + const [formData, setFormData] = useState({ + groupName: "", + description: "", + masterTable: "", + masterValueColumn: "", + masterLabelColumn: "", + isActive: "Y", + }); + + // 매핑 데이터 + const [mappings, setMappings] = useState([]); + + // 테이블 Combobox 상태 + const [tableComboOpen, setTableComboOpen] = useState(false); + + // 목록 로드 + const loadGroups = useCallback(async () => { + setLoading(true); + try { + const response = await cascadingAutoFillApi.getGroups(); + if (response.success && response.data) { + setGroups(response.data); + } + } catch (error) { + console.error("그룹 목록 로드 실패:", error); + toast.error("그룹 목록을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }, []); + + // 테이블 목록 로드 + const loadTableList = useCallback(async () => { + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setTableList(response.data); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } + }, []); + + // 테이블 컬럼 로드 + const loadColumns = useCallback(async (tableName: string) => { + if (!tableName) { + setMasterColumns([]); + return; + } + try { + const response = await tableManagementApi.getColumnList(tableName); + if (response.success && response.data?.columns) { + setMasterColumns( + response.data.columns.map((col: any) => ({ + columnName: col.columnName || col.column_name, + columnLabel: col.columnLabel || col.column_label || col.columnName, + dataType: col.dataType || col.data_type, + })), + ); + } + } catch (error) { + console.error("컬럼 목록 로드 실패:", error); + setMasterColumns([]); + } + }, []); + + useEffect(() => { + loadGroups(); + loadTableList(); + }, [loadGroups, loadTableList]); + + // 테이블 변경 시 컬럼 로드 + useEffect(() => { + if (formData.masterTable) { + loadColumns(formData.masterTable); + } + }, [formData.masterTable, loadColumns]); + + // 필터된 목록 + const filteredGroups = groups.filter( + (g) => + g.groupCode.toLowerCase().includes(searchText.toLowerCase()) || + g.groupName.toLowerCase().includes(searchText.toLowerCase()) || + g.masterTable?.toLowerCase().includes(searchText.toLowerCase()), + ); + + // 모달 열기 (생성) + const handleOpenCreate = () => { + setEditingGroup(null); + setFormData({ + groupName: "", + description: "", + masterTable: "", + masterValueColumn: "", + masterLabelColumn: "", + isActive: "Y", + }); + setMappings([]); + setMasterColumns([]); + setIsModalOpen(true); + }; + + // 모달 열기 (수정) + const handleOpenEdit = async (group: AutoFillGroup) => { + setEditingGroup(group); + + // 상세 정보 로드 + const detailResponse = await cascadingAutoFillApi.getGroupDetail(group.groupCode); + if (detailResponse.success && detailResponse.data) { + const detail = detailResponse.data; + + // 컬럼 먼저 로드 + if (detail.masterTable) { + await loadColumns(detail.masterTable); + } + + setFormData({ + groupCode: detail.groupCode, + groupName: detail.groupName, + description: detail.description || "", + masterTable: detail.masterTable, + masterValueColumn: detail.masterValueColumn, + masterLabelColumn: detail.masterLabelColumn || "", + isActive: detail.isActive || "Y", + }); + + // 매핑 데이터 변환 (snake_case → camelCase) + const convertedMappings = (detail.mappings || []).map((m: any) => ({ + sourceColumn: m.source_column || m.sourceColumn, + targetField: m.target_field || m.targetField, + targetLabel: m.target_label || m.targetLabel || "", + isEditable: m.is_editable || m.isEditable || "Y", + isRequired: m.is_required || m.isRequired || "N", + defaultValue: m.default_value || m.defaultValue || "", + sortOrder: m.sort_order || m.sortOrder || 0, + })); + setMappings(convertedMappings); + } + + setIsModalOpen(true); + }; + + // 삭제 확인 + const handleDeleteConfirm = (groupCode: string) => { + setDeletingGroupCode(groupCode); + setIsDeleteDialogOpen(true); + }; + + // 삭제 실행 + const handleDelete = async () => { + if (!deletingGroupCode) return; + + try { + const response = await cascadingAutoFillApi.deleteGroup(deletingGroupCode); + if (response.success) { + toast.success("자동 입력 그룹이 삭제되었습니다."); + loadGroups(); + } else { + toast.error(response.error || "삭제에 실패했습니다."); + } + } catch (error) { + toast.error("삭제 중 오류가 발생했습니다."); + } finally { + setIsDeleteDialogOpen(false); + setDeletingGroupCode(null); + } + }; + + // 저장 + const handleSave = async () => { + // 유효성 검사 + if (!formData.groupName || !formData.masterTable || !formData.masterValueColumn) { + toast.error("필수 항목을 모두 입력해주세요."); + return; + } + + try { + const saveData = { + ...formData, + mappings, + }; + + let response; + if (editingGroup) { + response = await cascadingAutoFillApi.updateGroup(editingGroup.groupCode!, saveData); + } else { + response = await cascadingAutoFillApi.createGroup(saveData); + } + + if (response.success) { + toast.success(editingGroup ? "수정되었습니다." : "생성되었습니다."); + setIsModalOpen(false); + loadGroups(); + } else { + toast.error(response.error || "저장에 실패했습니다."); + } + } catch (error) { + toast.error("저장 중 오류가 발생했습니다."); + } + }; + + // 매핑 추가 + const handleAddMapping = () => { + setMappings([ + ...mappings, + { + sourceColumn: "", + targetField: "", + targetLabel: "", + isEditable: "Y", + isRequired: "N", + defaultValue: "", + sortOrder: mappings.length + 1, + }, + ]); + }; + + // 매핑 삭제 + const handleRemoveMapping = (index: number) => { + setMappings(mappings.filter((_, i) => i !== index)); + }; + + // 매핑 수정 + const handleMappingChange = (index: number, field: keyof AutoFillMapping, value: any) => { + const updated = [...mappings]; + updated[index] = { ...updated[index], [field]: value }; + setMappings(updated); + }; + + return ( +
+ {/* 검색 및 액션 */} + + +
+
+ + setSearchText(e.target.value)} + className="pl-10" + /> +
+ +
+
+
+ + {/* 목록 */} + + +
+
+ 자동 입력 그룹 + + 마스터 선택 시 여러 필드를 자동으로 입력합니다. (총 {filteredGroups.length}개) + +
+ +
+
+ + {loading ? ( +
+ + 로딩 중... +
+ ) : filteredGroups.length === 0 ? ( +
+ {searchText ? "검색 결과가 없습니다." : "등록된 자동 입력 그룹이 없습니다."} +
+ ) : ( + + + + 그룹 코드 + 그룹명 + 마스터 테이블 + 매핑 수 + 상태 + 작업 + + + + {filteredGroups.map((group) => ( + + {group.groupCode} + {group.groupName} + {group.masterTable} + + {group.mappingCount || 0}개 + + + + {group.isActive === "Y" ? "활성" : "비활성"} + + + + + + + + ))} + +
+ )} +
+
+ + {/* 생성/수정 모달 */} + + + + {editingGroup ? "자동 입력 그룹 수정" : "자동 입력 그룹 생성"} + 마스터 데이터 선택 시 자동으로 입력할 필드들을 설정합니다. + + +
+ {/* 기본 정보 */} +
+

기본 정보

+ +
+ + setFormData({ ...formData, groupName: e.target.value })} + placeholder="예: 고객사 정보 자동입력" + /> +
+ +
+ +