['접수', 'pill--muted'], // 회색(대기/접수) 'b' => ['분배완료', 'pill--info'], // 파랑(분배/할당 완료) 'c' => ['처리중', 'pill--warn'], // 주황(진행중) 'd' => ['보류', 'pill--bad'], // 빨강(중단/보류) 'e' => ['완료', 'pill--ok'], // 초록(완료) ]; } public function paginate(array $filters, int $perPage, int $adminId): array { $year = (int)($filters['year'] ?? date('Y')); if ($year <= 2000) $year = (int)date('Y'); $filters['admin_id'] = $adminId; $rows = $this->repo->paginate($year, $filters, $perPage); if (is_object($rows) && method_exists($rows, 'withQueryString')) { $rows->withQueryString(); } return [ 'year' => $year, 'rows' => $this->repo->paginate($year, $filters, $perPage), 'enquiryCodes' => $this->repo->getEnquiryCodes(), 'stateLabels' => $this->stateLabels(), 'categories' => array_values($this->categoriesByNum()), 'categoriesMap' => $this->categoriesByNum(), ]; } public function detail(int $seq, int $year, int $adminId): array { // 1) row $row = $this->repo->findOrFail($year, $seq); // 2) logs decode (state/memo) $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); $stateLog = array_reverse($log['state_log'] ?? []); $memoLog = array_reverse($log['memo'] ?? []); // 3) adminIds build (stateLog + memoLog + answer_admin_num) : set(연관배열)로 중복 제거 $adminSet = []; foreach ($stateLog as $x) { $id = (int)($x['admin_num'] ?? 0); if ($id > 0) $adminSet[$id] = true; } foreach ($memoLog as $x) { $id = (int)($x['admin_num'] ?? 0); if ($id > 0) $adminSet[$id] = true; } $ans = (int)($row->answer_admin_num ?? 0); if ($ans > 0) $adminSet[$ans] = true; $adminIds = array_keys($adminSet); // 4) admins $admins = $adminIds ? $this->repo->getAdminsByIds($adminIds) : collect(); // 5) member + decrypt phone $member = $this->repo->getMemberInfo((int)($row->member_num ?? 0)); if ($member && !empty($member->cell_phone)) { $seed = app(\App\Support\LegacyCrypto\CiSeedCrypto::class); $member->cell_phone = (string) $seed->decrypt((string)$member->cell_phone); } // 6) 최근 거래 10건: 당분간 무조건 0건 (쿼리 절대 X) $orders = []; // 7) codes cache $enquiryCodes = \Cache::remember('cs:enquiry_codes', 3600, fn () => $this->repo->getEnquiryCodes()); $deferCodes = \Cache::remember('cs:defer_codes', 3600, fn () => $this->repo->getDeferCodes()); // 8) actions + 내부 메모 권한(배정 후부터, 내 업무만) $actions = $this->allowedActions($row, $adminId); $state = (string)($row->state ?? ''); $assignedToMe = ((int)($row->answer_admin_num ?? 0) === $adminId); $actions['canMemo'] = ($state !== 'a') && $assignedToMe; // a(접수) 금지, b/c/d/e OK // 9) categories (config/cs_faq.php 기반 num 매핑) $categoriesMap = $this->categoriesByNum(); return [ 'row' => $row, 'member' => $member, 'orders' => $orders, 'stateLabels' => $this->stateLabels(), 'enquiryCodes' => $enquiryCodes, 'deferCodes' => $deferCodes, 'stateLog' => $stateLog, 'memoLog' => $memoLog, 'admins' => $admins, 'actions' => $actions, 'categories' => array_values($categoriesMap), 'categoriesMap' => $categoriesMap, ]; } public function assignToMe(int $seq, int $year, int $adminId): void { DB::transaction(function () use ($seq, $year, $adminId) { $row = $this->repo->lockForUpdate($year, $seq, [ 'seq','state','answer_admin_num','admin_change_memo' ]); if ($row->state !== 'a'){ throw ValidationException::withMessages([ 'answer_content' => '현재 상태에서는 배정할 수 없습니다.', ]); } $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); $log = $this->pushStateLog($log, $adminId, $row->state, 'b', '업무를 직접 배정'); $this->repo->update($year, $seq, [ 'state' => 'b', 'answer_admin_num' => $adminId, 'receipt_date' => now()->format('Y-m-d H:i:s'), 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), ]); }); } public function startWork(int $seq, int $year, int $adminId): void { DB::transaction(function () use ($seq, $year, $adminId) { $row = $this->repo->lockForUpdate($year, $seq, [ 'seq','state','answer_admin_num','admin_change_memo' ]); $assigned = (int)($row->answer_admin_num ?? 0); if ($assigned !== $adminId) abort(403); if (!in_array($row->state, ['b','d'], true)){ throw ValidationException::withMessages([ 'answer_content' => '현재 상태에서는 업무 시작이 불가합니다.', ]); } $msg = ($row->state === 'd') ? '업무를 재개합니다!' : '업무를 시작했습니다.'; $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); $log = $this->pushStateLog($log, $adminId, $row->state, 'c', $msg); $this->repo->update($year, $seq, [ 'state' => 'c', 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), ]); }); } public function returnWork(int $seq, int $year, int $adminId): void { DB::transaction(function () use ($seq, $year, $adminId) { $row = $this->repo->lockForUpdate($year, $seq, [ 'seq','state','answer_admin_num','admin_change_memo' ]); $assigned = (int)($row->answer_admin_num ?? 0); if ($assigned !== $adminId) abort(403); if (!in_array($row->state, ['b','c','d'], true)){ throw ValidationException::withMessages([ 'answer_content' => '현재 상태에서는 반납할 수 없습니다.', ]); } $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); $log = $this->pushStateLog($log, $adminId, $row->state, 'a', '업무를 종료/반납했습니다.'); $this->repo->update($year, $seq, [ 'state' => 'a', 'answer_admin_num' => null, 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), ]); }); } public function postponeWork(int $seq, int $year, int $adminId, string $deferCode, string $comment): void { DB::transaction(function () use ($seq, $year, $adminId, $deferCode, $comment) { $row = $this->repo->lockForUpdate($year, $seq, [ 'seq','state','answer_admin_num','admin_change_memo' ]); $assigned = (int)($row->answer_admin_num ?? 0); if ($assigned !== $adminId) abort(403); if (!in_array($row->state, ['b','c'], true)){ throw ValidationException::withMessages([ 'answer_content' => '현재 상태에서는 보류할 수 없습니다.', ]); } $comment = trim($comment); if ($comment === ''){ throw ValidationException::withMessages([ 'answer_content' => '보류 사유를 입력해 주세요.', ]); } $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); $log = $this->pushStateLog($log, $adminId, $row->state, 'd', $comment); $this->repo->update($year, $seq, [ 'state' => 'd', 'defer_date' => now()->format('Y-m-d H:i:s'), 'defer_comment' => $comment, 'defer_admin_num' => $adminId, 'defer_code' => $deferCode ?: null, 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), ]); }); } public function saveAnswer(int $seq, int $year, int $adminId, string $answerContent, string $answerSms): void { DB::transaction(function () use ($seq, $year, $adminId, $answerContent, $answerSms) { $row = $this->repo->lockForUpdate($year, $seq, [ 'seq','state','answer_admin_num','admin_change_memo' ]); $this->assertCanMemo($row, $adminId); $assigned = (int)($row->answer_admin_num ?? 0); if ($assigned !== $adminId) abort(403); if (!in_array($row->state, ['c'], true)){ throw ValidationException::withMessages([ 'answer_content' => '처리중 상태에서만 답변 저장이 가능합니다.', ]); } $answerContent = trim($answerContent); $answerSms = trim($answerSms); if ($answerContent === '') abort(422, '관리자 답변을 입력해 주세요.'); if ($answerSms === '') abort(422, 'SMS 답변을 입력해 주세요.'); $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); $log = $this->pushMemoLog($log, $adminId, '관리자 답변 수정'); $this->repo->update($year, $seq, [ 'answer_content' => $answerContent, 'answer_sms' => $answerSms, 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), ]); }); } public function completeWork(int $seq, int $year, int $adminId): void { $notify = null; DB::transaction(function () use ($seq, $year, $adminId, &$notify) { $row = $this->repo->lockForUpdate($year, $seq, [ 'seq','state','answer_admin_num','admin_change_memo', 'return_type','member_num','enquiry_title','enquiry_content', 'answer_content','answer_sms','regdate', ]); $assigned = (int)($row->answer_admin_num ?? 0); if ($assigned !== $adminId) abort(403); if (!in_array($row->state, ['b','c'], true)){ throw ValidationException::withMessages([ 'answer_content' => '현재 상태에서는 완료 처리가 불가합니다.', ]); } $answerContent = trim((string)($row->answer_content ?? '')); $answerSms = trim((string)($row->answer_sms ?? '')); if ($answerContent === '') { throw ValidationException::withMessages([ 'answer_content' => '답변 내용이 비어있습니다. 먼저 저장해 주세요.', ]); } $rt = (string)($row->return_type ?? 'web'); if (in_array($rt, ['sms','phone'], true) && $answerSms === '') { abort(422, 'SMS 답변이 비어있습니다.'); } $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); $log = $this->pushStateLog($log, $adminId, $row->state, 'e', '업무를 완료했습니다.'); $this->repo->update($year, $seq, [ 'state' => 'e', 'completion_date' => now()->format('Y-m-d H:i:s'), 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), ]); // 트랜잭션 밖에서 발송하도록 데이터만 준비 $notify = [ 'return_type' => $rt, 'member_num' => (int)$row->member_num, 'enquiry_title' => (string)$row->enquiry_title, 'enquiry_content' => (string)$row->enquiry_content, 'answer_content' => $answerContent, 'answer_sms' => $answerSms, 'regdate' => (string)$row->regdate, 'year' => $year, 'seq' => (int)$row->seq, ]; }); if (is_array($notify)) { DB::afterCommit(function () use ($notify) { $this->sendResultToUser($notify); }); } } public function addMemo(int $seq, int $year, int $adminId, string $memo): void { DB::transaction(function () use ($seq, $year, $adminId, $memo) { $memo = trim($memo); if ($memo === '') abort(422); $row = $this->repo->lockForUpdate($year, $seq, [ 'seq','answer_admin_num','admin_change_memo' ]); // 메모는 배정자만 제한할지, 모든 관리자 가능할지 선택. 관리자 모두 // if ((int)($row->answer_admin_num ?? 0) !== $adminId) abort(403); $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); $log = $this->pushMemoLog($log, $adminId, $memo); $this->repo->update($year, $seq, [ 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), ]); }); } private function allowedActions(object $row, int $adminId): array { $state = (string)$row->state; $assigned = (int)($row->answer_admin_num ?? 0); return [ 'can_assign' => ($state === 'a'), 'can_start' => ($assigned === $adminId && in_array($state, ['b','d'], true)), 'can_return' => ($assigned === $adminId && in_array($state, ['b','c','d'], true)), 'can_postpone' => ($assigned === $adminId && in_array($state, ['b','c'], true)), 'can_answer' => ($assigned === $adminId && $state === 'c'), 'can_complete' => ($assigned === $adminId && in_array($state, ['b','c'], true)), ]; } private function decodeJson(string $json): array { $json = trim($json); if ($json === '') return []; $arr = json_decode($json, true); return is_array($arr) ? $arr : []; } private function pushStateLog(array $log, int $adminId, string $before, string $after, string $why): array { $log['state_log'] ??= []; $log['state_log'][] = [ 'admin_num' => $adminId, 'state_before' => $before, 'state_after' => $after, 'Who' => 'admin', 'why' => $why, 'when' => now()->format('y-m-d H:i:s'), ]; return $log; } private function pushMemoLog(array $log, int $adminId, string $memo): array { $log['memo'] ??= []; $log['memo'][] = [ 'admin_num' => $adminId, 'memo' => $memo, 'when' => now()->format('y-m-d H:i:s'), ]; return $log; } private function maskEmail(string $email): string { $email = trim($email); if ($email === '' || !str_contains($email, '@')) return ''; [$id, $dom] = explode('@', $email, 2); $idLen = mb_strlen($id); if ($idLen <= 2) return str_repeat('*', $idLen) . '@' . $dom; return mb_substr($id, 0, 2) . str_repeat('*', max(1, $idLen - 2)) . '@' . $dom; } private function maskPhone(string $digits): string { $digits = preg_replace('/\D+/', '', $digits) ?? ''; $len = strlen($digits); if ($len <= 4) return str_repeat('*', $len); return substr($digits, 0, 3) . str_repeat('*', max(0, $len - 7)) . substr($digits, -4); } private function sendResultToUser(array $data): void { $memberNum = (int)($data['member_num'] ?? 0); $rt = (string)($data['return_type'] ?? 'web'); $year = (int)($data['year'] ?? date('Y')); $qnaId = (int)($data['seq'] ?? $data['qna_id'] ?? 0); Log::info('[QNA][result] enter', [ 'member_num' => $memberNum, 'qna_id' => $qnaId, 'year' => $year, 'return_type' => $rt, 'has_answer_content' => (trim((string)($data['answer_content'] ?? '')) !== ''), 'answer_sms_len' => mb_strlen((string)($data['answer_sms'] ?? '')), ]); if ($memberNum <= 0 || $qnaId <= 0) { Log::warning('[QNA][result] invalid keys', [ 'member_num' => $memberNum, 'qna_id' => $qnaId, ]); return; } $member = $this->repo->getMemberInfo($memberNum); if (!$member) { Log::warning('[QNA][result] member not found', [ 'member_num' => $memberNum, ]); return; } // EMAIL if ($rt === 'email') { $to = trim((string)($member->email ?? '')); if ($to === '') { Log::warning('[QNA][result][email] no email', [ 'member_num' => $memberNum, 'qna_id' => $qnaId, ]); return; } Log::info('[QNA][result][email] dispatching', [ 'to' => $this->maskEmail($to), 'connection' => 'redis', 'queue' => 'mail', 'after_commit' => true, ]); SendUserQnaAnsweredMailJob::dispatch( $to, $memberNum, $qnaId, [ 'year' => $year, 'enquiry_code' => (string)($data['enquiry_code'] ?? ''), 'enquiry_title' => (string)($data['enquiry_title'] ?? ''), 'enquiry_content' => (string)($data['enquiry_content'] ?? ''), 'answer_content' => (string)($data['answer_content'] ?? ''), 'answer_sms' => (string)($data['answer_sms'] ?? ''), 'regdate' => (string)($data['regdate'] ?? ''), 'completed_at' => (string)($data['completed_at'] ?? now()->toDateTimeString()), ] ) ->onConnection('redis') ->onQueue('mail') ->afterCommit(); Log::info('[QNA][result][email] dispatched', [ 'to' => $this->maskEmail($to), 'member_num' => $memberNum, 'qna_id' => $qnaId, ]); return; } // SMS if (in_array($rt, ['sms', 'phone'], true)) { $msg = trim((string)($data['answer_sms'] ?? '')); if ($msg === '') { Log::warning('[QNA][result][sms] empty answer_sms', [ 'member_num' => $memberNum, 'qna_id' => $qnaId, ]); return; } $phoneEnc = (string)($member->cell_phone ?? ''); if ($phoneEnc === '') { Log::warning('[QNA][result][sms] empty cell_phone', [ 'member_num' => $memberNum, 'qna_id' => $qnaId, ]); return; } try { $seed = app(CiSeedCrypto::class); $phonePlain = (string)$seed->decrypt($phoneEnc); $digits = preg_replace('/\D+/', '', $phonePlain) ?? ''; if ($digits === '') { Log::warning('[QNA][result][sms] decrypt ok but no digits', [ 'member_num' => $memberNum, 'qna_id' => $qnaId, ]); return; } Log::info('[QNA][result][sms] sending', [ 'to' => $this->maskPhone($digits), 'msg_len' => mb_strlen($msg), ]); app(SmsService::class)->send([ 'from_number' => '1833-4856', 'to_number' => $digits, 'message' => $msg, 'sms_type' => 'sms', 'country' => 82, ]); Log::info('[QNA][result][sms] sent', [ 'to' => $this->maskPhone($digits), ]); } catch (\Throwable $e) { Log::warning('[QNA][result][sms] failed', [ 'member_num' => $memberNum, 'qna_id' => $qnaId, 'err' => $e->getMessage(), ]); } } } private function categoriesByNum(): array { $cats = (array) config('cs_faq.categories', []); $map = []; foreach ($cats as $c) { $num = (int)($c['num'] ?? 0); if ($num > 0) $map[$num] = $c; // ['key','label','num'] } return $map; } private function categoryLabel($enquiryCode): string { $num = (int) $enquiryCode; $map = $this->categoriesByNum(); return $map[$num]['label'] ?? ((string)$enquiryCode ?: '-'); } private function assertCanMemo(object $row, int $adminId): void { if ((string)($row->state ?? '') === 'a') { throw ValidationException::withMessages([ 'answer_content' => '업무 배정 후 메모를 남길 수 있습니다.', ]); } if ((int)($row->answer_admin_num ?? 0) !== $adminId) { throw ValidationException::withMessages([ 'answer_content' => '내 업무로 배정된 건만 메모를 남길 수 있습니다.', ]); } } }