/** * 상호 배제 (Mutual Exclusion) 컨트롤러 * 두 필드가 같은 값을 선택할 수 없도록 제한하는 기능 */ import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import { query, queryOne } from "../database/db"; import logger from "../utils/logger"; // ===================================================== // 상호 배제 규칙 CRUD // ===================================================== /** * 상호 배제 규칙 목록 조회 */ export const getExclusions = async ( req: AuthenticatedRequest, 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: AuthenticatedRequest, 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: AuthenticatedRequest, 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: AuthenticatedRequest, 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: AuthenticatedRequest, 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: AuthenticatedRequest, 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: AuthenticatedRequest, 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, }); } };