/** * 조건부 연쇄 (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; } }