feat: [esign] 서명 완료 시 상대방에게 카카오톡 알림톡으로 PDF 계약서 전달
- sendCompletionAlimtalk: 승인된 '전자계약_완료' 템플릿 조회 후 변수 치환 발송 - 버튼 URL에 PDF 다운로드 링크(/api/document) 포함 - 상대방(counterpart)만 알림톡 발송, 본사(creator)는 이메일 유지 - 알림톡 실패 시 이메일 자동 폴백 처리 - 발송 후 3초 대기하여 전달 결과 확인 로직 추가 - getKakaotalkChannelId, getTemplateData 헬퍼 메서드 추가
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user