'required|integer', 'STATUS' => 'required|in_list[PENDING,APPROVED,REJECTED,CANCELLED,EXPIRED,TERMINATED]', 'CHANGED_BY' => 'permit_empty|integer', // required 제거, permit_empty로 변경 'IS_CURRENT' => 'required|in_list[Y,N]' ]; protected $validationMessages = [ 'MAPPING_SEQ' => [ 'required' => '매핑 SEQ는 필수입니다.', 'integer' => '매핑 SEQ는 정수여야 합니다.' ], 'STATUS' => [ 'required' => '상태는 필수입니다.', 'in_list' => '유효하지 않은 상태입니다.' ], 'CHANGED_BY' => [ 'integer' => '변경자 SEQ는 정수여야 합니다.' ], 'IS_CURRENT' => [ 'required' => 'IS_CURRENT는 필수입니다.', 'in_list' => 'IS_CURRENT는 Y 또는 N이어야 합니다.' ] ]; protected $skipValidation = false; protected $cleanValidationRules = true; // Callbacks protected $allowCallbacks = true; protected $beforeInsert = ['beforeInsert']; protected $afterInsert = []; protected $beforeUpdate = []; protected $afterUpdate = []; protected $beforeFind = []; protected $afterFind = []; protected $beforeDelete = []; protected $afterDelete = []; /** * 상태 변경 전 처리 */ protected function beforeInsert(array $data) { // REG_DATE 자동 설정 if (!isset($data['data']['REG_DATE'])) { $data['data']['REG_DATE'] = date('Y-m-d H:i:s'); } // CHANGED_DATE 자동 설정 if (!isset($data['data']['CHANGED_DATE'])) { $data['data']['CHANGED_DATE'] = date('Y-m-d H:i:s'); } return $data; } /** * 특정 매핑의 현재 상태 조회 */ public function getCurrentStatus($mappingSeq) { return $this->where('MAPPING_SEQ', $mappingSeq) ->where('IS_CURRENT', 'Y') ->first(); } /** * 특정 매핑의 상태 히스토리 조회 */ public function getStatusHistory($mappingSeq, $limit = 10) { return $this->where('MAPPING_SEQ', $mappingSeq) ->orderBy('CHANGED_DATE', 'DESC') ->limit($limit) ->findAll(); } /** * 상태 변경 (트랜잭션 포함) - UNIQUE 제약조건 완전 안전 처리 */ public function changeStatus($mappingSeq, $newStatus, $statusMessage = '', $changedBy = null) { $db = \Config\Database::connect(); $db->transStart(); try { log_message('debug', "상태 변경 시작: mappingSeq={$mappingSeq}, newStatus={$newStatus}"); // CHANGED_BY가 null이면 기본값 설정 (NOT NULL 제약조건 대응) if ($changedBy === null) { $changedBy = 1; // 기본 시스템 사용자 log_message('warning', 'CHANGED_BY가 null이므로 기본값 1로 설정'); } // 1. 현재 상태 조회 (더 상세한 로깅) $currentStatus = $this->getCurrentStatus($mappingSeq); $previousStatus = $currentStatus ? $currentStatus['STATUS'] : null; log_message('debug', "현재 상태 조회 결과: " . json_encode($currentStatus)); log_message('debug', "이전 상태: " . ($previousStatus ?: 'NULL') . " → 새 상태: {$newStatus}"); // 2. UNIQUE 제약조건 완전 방지 - 강력한 중복 제거 log_message('debug', "UNIQUE 제약조건 방지: mappingSeq={$mappingSeq}에 대한 모든 IS_CURRENT='Y' 처리"); // 2-1. 현재 상태 개수 확인 $currentCount = $this->where('MAPPING_SEQ', $mappingSeq) ->where('IS_CURRENT', 'Y') ->countAllResults(); log_message('debug', "기존 IS_CURRENT='Y' 개수: {$currentCount}"); // 2-2. 강력한 UPDATE로 모든 현재 상태를 비활성화 (히스토리 보존) if ($currentCount > 0) { // 여러 번 시도하여 확실하게 업데이트 for ($attempt = 1; $attempt <= 3; $attempt++) { log_message('debug', "현재 상태 비활성화 시도 #{$attempt}"); $updateBuilder = $this->builder(); $updateCount = $updateBuilder->where('MAPPING_SEQ', $mappingSeq) ->where('IS_CURRENT', 'Y') ->update(['IS_CURRENT' => 'N']); log_message('debug', "업데이트 시도 #{$attempt}: 업데이트된 행 수={$updateCount}"); // 업데이트 후 확인 $remainingCount = $this->where('MAPPING_SEQ', $mappingSeq) ->where('IS_CURRENT', 'Y') ->countAllResults(); log_message('debug', "업데이트 후 남은 IS_CURRENT='Y' 개수: {$remainingCount}"); if ($remainingCount === 0) { log_message('debug', "모든 현재 상태 비활성화 완료 (시도 #{$attempt})"); break; } if ($attempt === 3) { log_message('error', "3번 시도 후에도 현재 상태가 남아있음: {$remainingCount}개"); throw new \Exception("기존 현재 상태 비활성화 실패: {$remainingCount}개 남음"); } // 잠시 대기 후 재시도 (동시성 문제 대응) usleep(10000); // 10ms 대기 } } // 2-3. 최종 안전 확인: UNIQUE 제약조건 위반 방지 $finalCheck = $this->where('MAPPING_SEQ', $mappingSeq) ->where('IS_CURRENT', 'Y') ->countAllResults(); if ($finalCheck > 0) { log_message('error', "최종 체크에서 중복 발견: {$finalCheck}개"); // 마지막 시도: 직접 SQL 실행 $sql = "UPDATE VENDOR_INFLUENCER_STATUS_HISTORY SET IS_CURRENT = 'N' WHERE MAPPING_SEQ = ? AND IS_CURRENT = 'Y'"; $this->db->query($sql, [$mappingSeq]); // 다시 확인 $finalFinalCheck = $this->where('MAPPING_SEQ', $mappingSeq) ->where('IS_CURRENT', 'Y') ->countAllResults(); if ($finalFinalCheck > 0) { throw new \Exception("UNIQUE 제약조건 위반 방지 실패: {$finalFinalCheck}개의 현재 상태 존재"); } log_message('debug', "직접 SQL로 현재 상태 정리 완료"); } log_message('debug', "UNIQUE 제약조건 완전 클리어 완료"); // 3. 새로운 상태 히스토리 추가 (이제 안전함) $historyData = [ 'MAPPING_SEQ' => (int)$mappingSeq, 'STATUS' => $newStatus, 'PREVIOUS_STATUS' => $previousStatus, 'STATUS_MESSAGE' => $statusMessage ?: '', 'CHANGED_BY' => (int)$changedBy, 'IS_CURRENT' => 'Y', 'CHANGED_DATE' => date('Y-m-d H:i:s'), 'REG_DATE' => date('Y-m-d H:i:s') ]; log_message('debug', '히스토리 데이터: ' . json_encode($historyData)); // validation 체크 $this->skipValidation = false; if (!$this->validate($historyData)) { $validationErrors = $this->errors(); log_message('error', 'Validation 실패: ' . json_encode($validationErrors)); throw new \Exception('Validation 오류: ' . implode(', ', $validationErrors)); } // validation을 통과했으므로 이제 insert $this->skipValidation = true; // UNIQUE 제약조건 오류에 대한 추가 방어: 재시도 로직 $insertSuccess = false; for ($insertAttempt = 1; $insertAttempt <= 2; $insertAttempt++) { try { log_message('debug', "Insert 시도 #{$insertAttempt}"); $result = $this->insert($historyData, false); if ($result) { $insertSuccess = true; log_message('debug', "Insert 성공 (시도 #{$insertAttempt}): ID={$result}"); break; } else { log_message('warning', "Insert 시도 #{$insertAttempt} 실패: result=false"); } } catch (\Exception $insertError) { log_message('error', "Insert 시도 #{$insertAttempt} 예외: " . $insertError->getMessage()); // UNIQUE 제약조건 오류인 경우 재정리 후 재시도 if (strpos($insertError->getMessage(), 'Duplicate entry') !== false && strpos($insertError->getMessage(), 'unique_current_mapping') !== false) { log_message('warning', "UNIQUE 제약조건 오류 감지 - 재정리 후 재시도"); // 응급 정리: 다시 한번 현재 상태 비활성화 $emergencyCleanup = $this->builder(); $emergencyCleanup->where('MAPPING_SEQ', $mappingSeq) ->where('IS_CURRENT', 'Y') ->update(['IS_CURRENT' => 'N']); // 잠시 대기 usleep(50000); // 50ms 대기 if ($insertAttempt === 2) { throw $insertError; // 마지막 시도에서도 실패하면 예외 던지기 } } else { throw $insertError; // 다른 오류는 즉시 던지기 } } } $this->skipValidation = false; if (!$insertSuccess || !$result) { $dbError = $this->db->error(); log_message('error', 'DB Insert 최종 실패: ' . json_encode($dbError)); log_message('error', 'Insert 데이터: ' . json_encode($historyData)); throw new \Exception('DB Insert 오류: ' . ($dbError['message'] ?? 'Unknown DB error')); } log_message('debug', "새 히스토리 추가 완료: ID={$result}"); // 4. 메인 테이블의 MOD_DATE 업데이트 try { $mappingModel = new VendorInfluencerMappingModel(); $mainUpdateResult = $mappingModel->update($mappingSeq, ['MOD_DATE' => date('Y-m-d H:i:s')]); if (!$mainUpdateResult) { log_message('warning', 'VENDOR_INFLUENCER_MAPPING 테이블 MOD_DATE 업데이트 실패 (비중요)'); } } catch (\Exception $mainUpdateError) { log_message('warning', '메인 테이블 업데이트 실패 (계속 진행): ' . $mainUpdateError->getMessage()); } $db->transComplete(); if ($db->transStatus() === false) { throw new \Exception('상태 변경 트랜잭션 실패'); } log_message('debug', "상태 변경 트랜잭션 완료: mappingSeq={$mappingSeq}"); return $result; } catch (\Exception $e) { $db->transRollback(); log_message('error', '상태 변경 실패: ' . $e->getMessage()); log_message('error', '상태 변경 스택 트레이스: ' . $e->getTraceAsString()); // 상세한 디버그 정보 추가 log_message('error', '실패한 파라미터: ' . json_encode([ 'mappingSeq' => $mappingSeq, 'newStatus' => $newStatus, 'statusMessage' => $statusMessage, 'changedBy' => $changedBy ])); throw $e; } } /** * 특정 상태의 매핑 목록 조회 */ public function getMappingsByStatus($status, $isActive = true) { $builder = $this->builder(); $builder->select('VENDOR_INFLUENCER_STATUS_HISTORY.*, VENDOR_INFLUENCER_MAPPING.*') ->join('VENDOR_INFLUENCER_MAPPING', 'VENDOR_INFLUENCER_MAPPING.SEQ = VENDOR_INFLUENCER_STATUS_HISTORY.MAPPING_SEQ') ->where('VENDOR_INFLUENCER_STATUS_HISTORY.STATUS', $status) ->where('VENDOR_INFLUENCER_STATUS_HISTORY.IS_CURRENT', 'Y'); if ($isActive) { $builder->where('VENDOR_INFLUENCER_MAPPING.IS_ACT', 'Y'); } return $builder->get()->getResultArray(); } /** * 벤더사별 상태 통계 */ public function getStatusStatsByVendor($vendorSeq) { $builder = $this->builder(); return $builder->select('VENDOR_INFLUENCER_STATUS_HISTORY.STATUS, COUNT(*) as count') ->join('VENDOR_INFLUENCER_MAPPING', 'VENDOR_INFLUENCER_MAPPING.SEQ = VENDOR_INFLUENCER_STATUS_HISTORY.MAPPING_SEQ') ->where('VENDOR_INFLUENCER_MAPPING.VENDOR_SEQ', $vendorSeq) ->where('VENDOR_INFLUENCER_STATUS_HISTORY.IS_CURRENT', 'Y') ->where('VENDOR_INFLUENCER_MAPPING.IS_ACT', 'Y') ->groupBy('VENDOR_INFLUENCER_STATUS_HISTORY.STATUS') ->get() ->getResultArray(); } /** * 인플루언서별 상태 통계 */ public function getStatusStatsByInfluencer($influencerSeq) { $builder = $this->builder(); return $builder->select('VENDOR_INFLUENCER_STATUS_HISTORY.STATUS, COUNT(*) as count') ->join('VENDOR_INFLUENCER_MAPPING', 'VENDOR_INFLUENCER_MAPPING.SEQ = VENDOR_INFLUENCER_STATUS_HISTORY.MAPPING_SEQ') ->where('VENDOR_INFLUENCER_MAPPING.INFLUENCER_SEQ', $influencerSeq) ->where('VENDOR_INFLUENCER_STATUS_HISTORY.IS_CURRENT', 'Y') ->where('VENDOR_INFLUENCER_MAPPING.IS_ACT', 'Y') ->groupBy('VENDOR_INFLUENCER_STATUS_HISTORY.STATUS') ->get() ->getResultArray(); } }