diff --git a/app/Http/Controllers/ESign/EsignPublicController.php b/app/Http/Controllers/ESign/EsignPublicController.php index 9eab588a..255d54b5 100644 --- a/app/Http/Controllers/ESign/EsignPublicController.php +++ b/app/Http/Controllers/ESign/EsignPublicController.php @@ -381,9 +381,36 @@ public function submitSignature(Request $request, string $token): JsonResponse foreach ($allSigners as $completedSigner) { $completionResults = []; try { - // 이메일 발송 - if (in_array($sendMethod, ['email', 'both']) || ! $completedSigner->phone) { - if ($completedSigner->email) { + // 본사(creator): 이메일로 완료 알림 + // 상대방(counterpart): 알림톡(카카오톡) + PDF 다운로드 링크 + $isCounterpart = $completedSigner->role === EsignSigner::ROLE_COUNTERPART; + + // 이메일 발송 조건: + // 1) email/both 선택 시 + // 2) 본사(creator)는 항상 이메일 + // 3) 상대방이지만 전화번호 없으면 이메일 폴백 + $shouldSendEmail = in_array($sendMethod, ['email', 'both']) + || ! $isCounterpart + || ($isCounterpart && ! $completedSigner->phone); + + if ($shouldSendEmail && $completedSigner->email) { + try { + Mail::to($completedSigner->email)->send( + new EsignCompletedMail($contract, $completedSigner, $allSigners) + ); + $completionResults[] = ['success' => true, 'channel' => 'email', 'error' => null]; + } catch (\Throwable $e) { + $completionResults[] = ['success' => false, 'channel' => 'email', 'error' => $e->getMessage()]; + } + } + + // 알림톡 발송: 상대방(counterpart)에게 카카오톡으로 서명 완료 PDF 전달 + if (in_array($sendMethod, ['alimtalk', 'both']) && $isCounterpart && $completedSigner->phone) { + $alimtalkResult = $this->sendCompletionAlimtalk($contract, $completedSigner); + $completionResults[] = $alimtalkResult; + + // 알림톡 실패 시 이메일 폴백 (아직 이메일 안 보낸 경우) + if (! ($alimtalkResult['success'] ?? false) && ! $shouldSendEmail && $completedSigner->email) { try { Mail::to($completedSigner->email)->send( new EsignCompletedMail($contract, $completedSigner, $allSigners) @@ -395,11 +422,6 @@ public function submitSignature(Request $request, string $token): JsonResponse } } - // 알림톡 발송 - if (in_array($sendMethod, ['alimtalk', 'both']) && $completedSigner->phone) { - $completionResults[] = $this->sendCompletionAlimtalk($contract, $completedSigner); - } - EsignAuditLog::create([ 'tenant_id' => $contract->tenant_id, 'contract_id' => $contract->id, @@ -409,6 +431,7 @@ public function submitSignature(Request $request, string $token): JsonResponse 'user_agent' => $request->userAgent(), 'metadata' => [ 'send_method' => $sendMethod, + 'signer_role' => $completedSigner->role, 'notification_results' => [[ 'signer_id' => $completedSigner->id, 'signer_name' => $completedSigner->name, @@ -680,7 +703,7 @@ private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $si { try { $member = BarobillMember::where('tenant_id', $contract->tenant_id)->first(); - if (! $member) { + if (! $member || ! $member->biz_no) { return ['success' => false, 'channel' => 'alimtalk', 'error' => '바로빌 회원 미등록']; } @@ -688,41 +711,115 @@ private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $si $barobill->setServerMode($member->server_mode ?? 'production'); // 채널 ID 조회 - $channelResult = $barobill->getKakaotalkChannels($member->biz_no); - $yellowId = ''; - if ($channelResult['success'] ?? false) { - $chData = $channelResult['data']; - if (is_object($chData) && isset($chData->KakaotalkChannel)) { - $ch = is_array($chData->KakaotalkChannel) ? $chData->KakaotalkChannel[0] : $chData->KakaotalkChannel; - $yellowId = $ch->ChannelId ?? ''; - } + $channelId = $this->getKakaotalkChannelId($barobill, $member->biz_no); + if (! $channelId) { + return ['success' => false, 'channel' => 'alimtalk', 'error' => '등록된 카카오톡 채널이 없습니다']; } + $templateName = '전자계약_완료'; + $documentUrl = config('app.url').'/esign/sign/'.$signer->access_token.'/api/document'; $signUrl = config('app.url').'/esign/sign/'.$signer->access_token; $completedAt = $contract->completed_at?->format('Y-m-d H:i') ?? now()->format('Y-m-d H:i'); + // 등록된 템플릿 본문 + 버튼 조회 + $tplData = $this->getTemplateData($barobill, $member->biz_no, $channelId, $templateName); + $templateContent = $tplData['content']; + $templateButtons = $tplData['buttons']; + + if ($templateContent) { + $message = str_replace( + ['#{이름}', '#{계약명}', '#{완료일}'], + [$signer->name, $contract->title, $completedAt], + $templateContent + ); + } else { + Log::warning('E-Sign 완료 알림톡: 템플릿 내용 조회 실패, 하드코딩 폴백 사용', [ + 'template_name' => $templateName, + 'channel_id' => $channelId, + ]); + $message = "안녕하세요, {$signer->name}님.\n전자계약이 모든 서명자의 서명 완료로 확정되었습니다.\n\n■ 계약명: {$contract->title}\n■ 완료일: {$completedAt}\n\n아래 버튼에서 서명 완료된 계약서를 확인하고 다운로드할 수 있습니다."; + } + + // 버튼: 템플릿에서 가져온 URL의 #{토큰}만 치환 + $buttons = ! empty($templateButtons) ? $templateButtons : [ + [ + 'Name' => '계약서 다운로드', + 'ButtonType' => 'WL', + 'Url1' => $documentUrl, + 'Url2' => $documentUrl, + ], + ]; + + foreach ($buttons as &$btn) { + foreach (['Url1', 'Url2'] as $urlKey) { + if (! empty($btn[$urlKey])) { + $btn[$urlKey] = str_replace( + ['#{토큰}', '#{%ED%86%A0%ED%81%B0}'], + [$signer->access_token, $signer->access_token], + urldecode($btn[$urlKey]) + ); + } + } + } + unset($btn); + + $receiverNum = preg_replace('/[^0-9]/', '', $signer->phone); + + Log::info('E-Sign 완료 알림톡 발송 시도', [ + 'contract_id' => $contract->id, + 'signer_id' => $signer->id, + 'signer_role' => $signer->role, + 'template_name' => $templateName, + 'template_from_api' => (bool) $templateContent, + 'buttons_from_api' => ! empty($templateButtons), + 'receiver_num' => $receiverNum, + ]); + $result = $barobill->sendATKakaotalkEx( corpNum: $member->biz_no, senderId: $member->barobill_id, - yellowId: $yellowId, - templateName: '전자계약_완료', + yellowId: $channelId, + templateName: $templateName, receiverName: $signer->name, - receiverNum: preg_replace('/[^0-9]/', '', $signer->phone), + receiverNum: $receiverNum, title: '', - message: "안녕하세요, {$signer->name}님.\n전자계약이 모든 서명자의 서명 완료로 확정되었습니다.\n\n■ 계약명: {$contract->title}\n■ 완료일: {$completedAt}\n\n아래 버튼에서 서명 완료된 계약서를 확인할 수 있습니다.", - buttons: [ - [ - 'Name' => '계약서 확인하기', - 'ButtonType' => 'WL', - 'Url1' => $signUrl, - 'Url2' => $signUrl, - ], - ], + message: $message, + buttons: $buttons, smsMessage: ($contract->sms_fallback ?? true) - ? "[SAM] {$signer->name}님, 전자계약이 완료되었습니다. {$signUrl}" + ? "[SAM] {$signer->name}님, 전자계약이 완료되었습니다. 계약서 다운로드: {$documentUrl}" : '', ); + // 발송 접수 후 결과 확인 + if (($result['success'] ?? false) && ! empty($result['data']) && is_string($result['data'])) { + $sendKey = $result['data']; + Log::info('E-Sign 완료 알림톡 접수 성공', [ + 'contract_id' => $contract->id, + 'send_key' => $sendKey, + ]); + + sleep(3); + $sendResult = $barobill->getSendKakaotalk($member->biz_no, $sendKey); + $resultData = $sendResult['data'] ?? null; + $resultCode = is_object($resultData) ? ($resultData->ResultCode ?? null) : ($resultData['ResultCode'] ?? null); + $resultMsg = is_object($resultData) ? ($resultData->ResultMessage ?? null) : ($resultData['ResultMessage'] ?? null); + + Log::info('E-Sign 완료 알림톡 전달 결과', [ + 'contract_id' => $contract->id, + 'send_key' => $sendKey, + 'result_code' => $resultCode, + 'result_message' => $resultMsg, + ]); + + if ($resultCode !== null && $resultCode != 1) { + return [ + 'success' => false, + 'channel' => 'alimtalk', + 'error' => "카카오톡 전달 실패: {$resultMsg} (코드: {$resultCode})", + ]; + } + } + if (! ($result['success'] ?? false)) { Log::warning('E-Sign 완료 알림톡 발송 실패', [ 'contract_id' => $contract->id, @@ -745,6 +842,95 @@ private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $si } } + /** + * 카카오톡 채널 ID 조회 (바로빌 API) + */ + private function getKakaotalkChannelId(BarobillService $barobill, string $bizNo): ?string + { + $result = $barobill->getKakaotalkChannels($bizNo); + + if (! ($result['success'] ?? false) || empty($result['data'])) { + return null; + } + + $data = $result['data']; + + if (is_object($data) && isset($data->KakaotalkChannel)) { + $channels = is_array($data->KakaotalkChannel) + ? $data->KakaotalkChannel + : [$data->KakaotalkChannel]; + } elseif (is_array($data) && isset($data['KakaotalkChannel'])) { + $channels = is_array($data['KakaotalkChannel']) + ? $data['KakaotalkChannel'] + : [$data['KakaotalkChannel']]; + } else { + $channels = is_array($data) ? $data : [$data]; + } + + $channel = $channels[0] ?? null; + + if (! $channel) { + return null; + } + + return is_array($channel) + ? ($channel['ChannelId'] ?? null) + : ($channel->ChannelId ?? null); + } + + /** + * 바로빌 등록 템플릿의 본문 내용 및 버튼 정보 조회 + * + * @return array{content: string|null, buttons: array} + */ + private function getTemplateData(BarobillService $barobill, string $bizNo, string $channelId, string $templateName): array + { + $empty = ['content' => null, 'buttons' => []]; + + $result = $barobill->getKakaotalkTemplates($bizNo, $channelId); + + if (! ($result['success'] ?? false) || empty($result['data'])) { + return $empty; + } + + $data = $result['data']; + $items = []; + + if (is_object($data) && isset($data->KakaotalkTemplate)) { + $items = is_array($data->KakaotalkTemplate) + ? $data->KakaotalkTemplate + : [$data->KakaotalkTemplate]; + } + + foreach ($items as $tpl) { + if (($tpl->TemplateName ?? '') === $templateName) { + $buttons = []; + $btnData = $tpl->Buttons ?? null; + if ($btnData) { + $btnList = $btnData->KakaotalkButton ?? null; + if ($btnList) { + $btnList = is_array($btnList) ? $btnList : [$btnList]; + foreach ($btnList as $btn) { + $buttons[] = [ + 'Name' => $btn->Name ?? '', + 'ButtonType' => $btn->ButtonType ?? 'WL', + 'Url1' => $btn->Url1 ?? '', + 'Url2' => $btn->Url2 ?? '', + ]; + } + } + } + + return [ + 'content' => $tpl->TemplateContent ?? null, + 'buttons' => $buttons, + ]; + } + } + + return $empty; + } + private function findSigner(string $token): ?EsignSigner { $signer = EsignSigner::withoutGlobalScopes()