VendorInfluencerStatusHistoryModel.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. <?php
  2. namespace App\Models;
  3. use CodeIgniter\Model;
  4. class VendorInfluencerStatusHistoryModel extends Model
  5. {
  6. protected $table = 'VENDOR_INFLUENCER_STATUS_HISTORY';
  7. protected $primaryKey = 'SEQ';
  8. protected $useAutoIncrement = true;
  9. protected $returnType = 'array';
  10. protected $useSoftDeletes = false;
  11. protected $protectFields = true;
  12. protected $allowedFields = [
  13. 'MAPPING_SEQ',
  14. 'STATUS',
  15. 'PREVIOUS_STATUS',
  16. 'STATUS_MESSAGE',
  17. 'CHANGED_BY',
  18. 'CHANGED_DATE',
  19. 'IS_CURRENT',
  20. 'REG_DATE'
  21. ];
  22. // Dates
  23. protected $useTimestamps = false;
  24. protected $dateFormat = 'datetime';
  25. protected $createdField = 'REG_DATE';
  26. protected $updatedField = '';
  27. protected $deletedField = '';
  28. // Validation
  29. protected $validationRules = [
  30. 'MAPPING_SEQ' => 'required|integer',
  31. 'STATUS' => 'required|in_list[PENDING,APPROVED,REJECTED,CANCELLED,EXPIRED,TERMINATED]',
  32. 'CHANGED_BY' => 'permit_empty|integer', // required 제거, permit_empty로 변경
  33. 'IS_CURRENT' => 'required|in_list[Y,N]'
  34. ];
  35. protected $validationMessages = [
  36. 'MAPPING_SEQ' => [
  37. 'required' => '매핑 SEQ는 필수입니다.',
  38. 'integer' => '매핑 SEQ는 정수여야 합니다.'
  39. ],
  40. 'STATUS' => [
  41. 'required' => '상태는 필수입니다.',
  42. 'in_list' => '유효하지 않은 상태입니다.'
  43. ],
  44. 'CHANGED_BY' => [
  45. 'integer' => '변경자 SEQ는 정수여야 합니다.'
  46. ],
  47. 'IS_CURRENT' => [
  48. 'required' => 'IS_CURRENT는 필수입니다.',
  49. 'in_list' => 'IS_CURRENT는 Y 또는 N이어야 합니다.'
  50. ]
  51. ];
  52. protected $skipValidation = false;
  53. protected $cleanValidationRules = true;
  54. // Callbacks
  55. protected $allowCallbacks = true;
  56. protected $beforeInsert = ['beforeInsert'];
  57. protected $afterInsert = [];
  58. protected $beforeUpdate = [];
  59. protected $afterUpdate = [];
  60. protected $beforeFind = [];
  61. protected $afterFind = [];
  62. protected $beforeDelete = [];
  63. protected $afterDelete = [];
  64. /**
  65. * 상태 변경 전 처리
  66. */
  67. protected function beforeInsert(array $data)
  68. {
  69. // REG_DATE 자동 설정
  70. if (!isset($data['data']['REG_DATE'])) {
  71. $data['data']['REG_DATE'] = date('Y-m-d H:i:s');
  72. }
  73. // CHANGED_DATE 자동 설정
  74. if (!isset($data['data']['CHANGED_DATE'])) {
  75. $data['data']['CHANGED_DATE'] = date('Y-m-d H:i:s');
  76. }
  77. return $data;
  78. }
  79. /**
  80. * 특정 매핑의 현재 상태 조회
  81. */
  82. public function getCurrentStatus($mappingSeq)
  83. {
  84. return $this->where('MAPPING_SEQ', $mappingSeq)
  85. ->where('IS_CURRENT', 'Y')
  86. ->first();
  87. }
  88. /**
  89. * 특정 매핑의 상태 히스토리 조회
  90. */
  91. public function getStatusHistory($mappingSeq, $limit = 10)
  92. {
  93. return $this->where('MAPPING_SEQ', $mappingSeq)
  94. ->orderBy('CHANGED_DATE', 'DESC')
  95. ->limit($limit)
  96. ->findAll();
  97. }
  98. /**
  99. * 상태 변경 (트랜잭션 포함) - UNIQUE 제약조건 완전 안전 처리
  100. */
  101. public function changeStatus($mappingSeq, $newStatus, $statusMessage = '', $changedBy = null)
  102. {
  103. $db = \Config\Database::connect();
  104. $db->transStart();
  105. try {
  106. log_message('debug', "상태 변경 시작: mappingSeq={$mappingSeq}, newStatus={$newStatus}");
  107. // CHANGED_BY가 null이면 기본값 설정 (NOT NULL 제약조건 대응)
  108. if ($changedBy === null) {
  109. $changedBy = 1; // 기본 시스템 사용자
  110. log_message('warning', 'CHANGED_BY가 null이므로 기본값 1로 설정');
  111. }
  112. // 1. 현재 상태 조회 (더 상세한 로깅)
  113. $currentStatus = $this->getCurrentStatus($mappingSeq);
  114. $previousStatus = $currentStatus ? $currentStatus['STATUS'] : null;
  115. log_message('debug', "현재 상태 조회 결과: " . json_encode($currentStatus));
  116. log_message('debug', "이전 상태: " . ($previousStatus ?: 'NULL') . " → 새 상태: {$newStatus}");
  117. // 2. UNIQUE 제약조건 완전 방지 - 강력한 중복 제거
  118. log_message('debug', "UNIQUE 제약조건 방지: mappingSeq={$mappingSeq}에 대한 모든 IS_CURRENT='Y' 처리");
  119. // 2-1. 현재 상태 개수 확인
  120. $currentCount = $this->where('MAPPING_SEQ', $mappingSeq)
  121. ->where('IS_CURRENT', 'Y')
  122. ->countAllResults();
  123. log_message('debug', "기존 IS_CURRENT='Y' 개수: {$currentCount}");
  124. // 2-2. 강력한 UPDATE로 모든 현재 상태를 비활성화 (히스토리 보존)
  125. if ($currentCount > 0) {
  126. // 여러 번 시도하여 확실하게 업데이트
  127. for ($attempt = 1; $attempt <= 3; $attempt++) {
  128. log_message('debug', "현재 상태 비활성화 시도 #{$attempt}");
  129. $updateBuilder = $this->builder();
  130. $updateCount = $updateBuilder->where('MAPPING_SEQ', $mappingSeq)
  131. ->where('IS_CURRENT', 'Y')
  132. ->update(['IS_CURRENT' => 'N']);
  133. log_message('debug', "업데이트 시도 #{$attempt}: 업데이트된 행 수={$updateCount}");
  134. // 업데이트 후 확인
  135. $remainingCount = $this->where('MAPPING_SEQ', $mappingSeq)
  136. ->where('IS_CURRENT', 'Y')
  137. ->countAllResults();
  138. log_message('debug', "업데이트 후 남은 IS_CURRENT='Y' 개수: {$remainingCount}");
  139. if ($remainingCount === 0) {
  140. log_message('debug', "모든 현재 상태 비활성화 완료 (시도 #{$attempt})");
  141. break;
  142. }
  143. if ($attempt === 3) {
  144. log_message('error', "3번 시도 후에도 현재 상태가 남아있음: {$remainingCount}개");
  145. throw new \Exception("기존 현재 상태 비활성화 실패: {$remainingCount}개 남음");
  146. }
  147. // 잠시 대기 후 재시도 (동시성 문제 대응)
  148. usleep(10000); // 10ms 대기
  149. }
  150. }
  151. // 2-3. 최종 안전 확인: UNIQUE 제약조건 위반 방지
  152. $finalCheck = $this->where('MAPPING_SEQ', $mappingSeq)
  153. ->where('IS_CURRENT', 'Y')
  154. ->countAllResults();
  155. if ($finalCheck > 0) {
  156. log_message('error', "최종 체크에서 중복 발견: {$finalCheck}개");
  157. // 마지막 시도: 직접 SQL 실행
  158. $sql = "UPDATE VENDOR_INFLUENCER_STATUS_HISTORY SET IS_CURRENT = 'N' WHERE MAPPING_SEQ = ? AND IS_CURRENT = 'Y'";
  159. $this->db->query($sql, [$mappingSeq]);
  160. // 다시 확인
  161. $finalFinalCheck = $this->where('MAPPING_SEQ', $mappingSeq)
  162. ->where('IS_CURRENT', 'Y')
  163. ->countAllResults();
  164. if ($finalFinalCheck > 0) {
  165. throw new \Exception("UNIQUE 제약조건 위반 방지 실패: {$finalFinalCheck}개의 현재 상태 존재");
  166. }
  167. log_message('debug', "직접 SQL로 현재 상태 정리 완료");
  168. }
  169. log_message('debug', "UNIQUE 제약조건 완전 클리어 완료");
  170. // 3. 새로운 상태 히스토리 추가 (이제 안전함)
  171. $historyData = [
  172. 'MAPPING_SEQ' => (int)$mappingSeq,
  173. 'STATUS' => $newStatus,
  174. 'PREVIOUS_STATUS' => $previousStatus,
  175. 'STATUS_MESSAGE' => $statusMessage ?: '',
  176. 'CHANGED_BY' => (int)$changedBy,
  177. 'IS_CURRENT' => 'Y',
  178. 'CHANGED_DATE' => date('Y-m-d H:i:s'),
  179. 'REG_DATE' => date('Y-m-d H:i:s')
  180. ];
  181. log_message('debug', '히스토리 데이터: ' . json_encode($historyData));
  182. // validation 체크
  183. $this->skipValidation = false;
  184. if (!$this->validate($historyData)) {
  185. $validationErrors = $this->errors();
  186. log_message('error', 'Validation 실패: ' . json_encode($validationErrors));
  187. throw new \Exception('Validation 오류: ' . implode(', ', $validationErrors));
  188. }
  189. // validation을 통과했으므로 이제 insert
  190. $this->skipValidation = true;
  191. // UNIQUE 제약조건 오류에 대한 추가 방어: 재시도 로직
  192. $insertSuccess = false;
  193. for ($insertAttempt = 1; $insertAttempt <= 2; $insertAttempt++) {
  194. try {
  195. log_message('debug', "Insert 시도 #{$insertAttempt}");
  196. $result = $this->insert($historyData, false);
  197. if ($result) {
  198. $insertSuccess = true;
  199. log_message('debug', "Insert 성공 (시도 #{$insertAttempt}): ID={$result}");
  200. break;
  201. } else {
  202. log_message('warning', "Insert 시도 #{$insertAttempt} 실패: result=false");
  203. }
  204. } catch (\Exception $insertError) {
  205. log_message('error', "Insert 시도 #{$insertAttempt} 예외: " . $insertError->getMessage());
  206. // UNIQUE 제약조건 오류인 경우 재정리 후 재시도
  207. if (strpos($insertError->getMessage(), 'Duplicate entry') !== false &&
  208. strpos($insertError->getMessage(), 'unique_current_mapping') !== false) {
  209. log_message('warning', "UNIQUE 제약조건 오류 감지 - 재정리 후 재시도");
  210. // 응급 정리: 다시 한번 현재 상태 비활성화
  211. $emergencyCleanup = $this->builder();
  212. $emergencyCleanup->where('MAPPING_SEQ', $mappingSeq)
  213. ->where('IS_CURRENT', 'Y')
  214. ->update(['IS_CURRENT' => 'N']);
  215. // 잠시 대기
  216. usleep(50000); // 50ms 대기
  217. if ($insertAttempt === 2) {
  218. throw $insertError; // 마지막 시도에서도 실패하면 예외 던지기
  219. }
  220. } else {
  221. throw $insertError; // 다른 오류는 즉시 던지기
  222. }
  223. }
  224. }
  225. $this->skipValidation = false;
  226. if (!$insertSuccess || !$result) {
  227. $dbError = $this->db->error();
  228. log_message('error', 'DB Insert 최종 실패: ' . json_encode($dbError));
  229. log_message('error', 'Insert 데이터: ' . json_encode($historyData));
  230. throw new \Exception('DB Insert 오류: ' . ($dbError['message'] ?? 'Unknown DB error'));
  231. }
  232. log_message('debug', "새 히스토리 추가 완료: ID={$result}");
  233. // 4. 메인 테이블의 MOD_DATE 업데이트
  234. try {
  235. $mappingModel = new VendorInfluencerMappingModel();
  236. $mainUpdateResult = $mappingModel->update($mappingSeq, ['MOD_DATE' => date('Y-m-d H:i:s')]);
  237. if (!$mainUpdateResult) {
  238. log_message('warning', 'VENDOR_INFLUENCER_MAPPING 테이블 MOD_DATE 업데이트 실패 (비중요)');
  239. }
  240. } catch (\Exception $mainUpdateError) {
  241. log_message('warning', '메인 테이블 업데이트 실패 (계속 진행): ' . $mainUpdateError->getMessage());
  242. }
  243. $db->transComplete();
  244. if ($db->transStatus() === false) {
  245. throw new \Exception('상태 변경 트랜잭션 실패');
  246. }
  247. log_message('debug', "상태 변경 트랜잭션 완료: mappingSeq={$mappingSeq}");
  248. return $result;
  249. } catch (\Exception $e) {
  250. $db->transRollback();
  251. log_message('error', '상태 변경 실패: ' . $e->getMessage());
  252. log_message('error', '상태 변경 스택 트레이스: ' . $e->getTraceAsString());
  253. // 상세한 디버그 정보 추가
  254. log_message('error', '실패한 파라미터: ' . json_encode([
  255. 'mappingSeq' => $mappingSeq,
  256. 'newStatus' => $newStatus,
  257. 'statusMessage' => $statusMessage,
  258. 'changedBy' => $changedBy
  259. ]));
  260. throw $e;
  261. }
  262. }
  263. /**
  264. * 특정 상태의 매핑 목록 조회
  265. */
  266. public function getMappingsByStatus($status, $isActive = true)
  267. {
  268. $builder = $this->builder();
  269. $builder->select('VENDOR_INFLUENCER_STATUS_HISTORY.*, VENDOR_INFLUENCER_MAPPING.*')
  270. ->join('VENDOR_INFLUENCER_MAPPING',
  271. 'VENDOR_INFLUENCER_MAPPING.SEQ = VENDOR_INFLUENCER_STATUS_HISTORY.MAPPING_SEQ')
  272. ->where('VENDOR_INFLUENCER_STATUS_HISTORY.STATUS', $status)
  273. ->where('VENDOR_INFLUENCER_STATUS_HISTORY.IS_CURRENT', 'Y');
  274. if ($isActive) {
  275. $builder->where('VENDOR_INFLUENCER_MAPPING.IS_ACT', 'Y');
  276. }
  277. return $builder->get()->getResultArray();
  278. }
  279. /**
  280. * 벤더사별 상태 통계
  281. */
  282. public function getStatusStatsByVendor($vendorSeq)
  283. {
  284. $builder = $this->builder();
  285. return $builder->select('VENDOR_INFLUENCER_STATUS_HISTORY.STATUS, COUNT(*) as count')
  286. ->join('VENDOR_INFLUENCER_MAPPING',
  287. 'VENDOR_INFLUENCER_MAPPING.SEQ = VENDOR_INFLUENCER_STATUS_HISTORY.MAPPING_SEQ')
  288. ->where('VENDOR_INFLUENCER_MAPPING.VENDOR_SEQ', $vendorSeq)
  289. ->where('VENDOR_INFLUENCER_STATUS_HISTORY.IS_CURRENT', 'Y')
  290. ->where('VENDOR_INFLUENCER_MAPPING.IS_ACT', 'Y')
  291. ->groupBy('VENDOR_INFLUENCER_STATUS_HISTORY.STATUS')
  292. ->get()
  293. ->getResultArray();
  294. }
  295. /**
  296. * 인플루언서별 상태 통계
  297. */
  298. public function getStatusStatsByInfluencer($influencerSeq)
  299. {
  300. $builder = $this->builder();
  301. return $builder->select('VENDOR_INFLUENCER_STATUS_HISTORY.STATUS, COUNT(*) as count')
  302. ->join('VENDOR_INFLUENCER_MAPPING',
  303. 'VENDOR_INFLUENCER_MAPPING.SEQ = VENDOR_INFLUENCER_STATUS_HISTORY.MAPPING_SEQ')
  304. ->where('VENDOR_INFLUENCER_MAPPING.INFLUENCER_SEQ', $influencerSeq)
  305. ->where('VENDOR_INFLUENCER_STATUS_HISTORY.IS_CURRENT', 'Y')
  306. ->where('VENDOR_INFLUENCER_MAPPING.IS_ACT', 'Y')
  307. ->groupBy('VENDOR_INFLUENCER_STATUS_HISTORY.STATUS')
  308. ->get()
  309. ->getResultArray();
  310. }
  311. }