diff --git a/app/Http/Controllers/ESign/EsignApiController.php b/app/Http/Controllers/ESign/EsignApiController.php index ebb57816..dbef0bce 100644 --- a/app/Http/Controllers/ESign/EsignApiController.php +++ b/app/Http/Controllers/ESign/EsignApiController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Mail\EsignRequestMail; +use App\Models\Barobill\BarobillMember; use App\Models\ESign\EsignContract; use App\Models\User; use App\Services\ESign\DocxToPdfConverter; @@ -13,6 +14,7 @@ use App\Models\ESign\EsignSignField; use App\Models\ESign\EsignAuditLog; use App\Models\Tenants\TenantSetting; +use App\Services\Barobill\BarobillService; use App\Services\GoogleCloudStorageService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -692,27 +694,27 @@ public function send(Request $request, int $id): JsonResponse return response()->json(['success' => false, 'message' => '초안 상태에서만 발송할 수 있습니다.'], 422); } + $sendMethod = $request->input('send_method', 'alimtalk'); + $smsFallback = $request->boolean('sms_fallback', true); + $contract->update([ 'status' => 'pending', + 'send_method' => $sendMethod, + 'sms_fallback' => $smsFallback, 'updated_by' => auth()->id(), ]); - // 서명 순서 유형에 따라 알림 발송 + // 발송 대상 서명자 결정 if ($contract->sign_order_type === 'parallel') { - // 동시 서명: 모든 서명자에게 발송 - foreach ($contract->signers as $s) { - $s->update(['status' => 'notified']); - Mail::to($s->email)->send(new EsignRequestMail($contract, $s)); - } + $targetSigners = $contract->signers; } else { - // 순차 서명: 첫 번째 서명자에게 발송 - $firstSigner = $contract->signers() - ->orderBy('sign_order') - ->first(); - if ($firstSigner) { - $firstSigner->update(['status' => 'notified']); - Mail::to($firstSigner->email)->send(new EsignRequestMail($contract, $firstSigner)); - } + $first = $contract->signers()->orderBy('sign_order')->first(); + $targetSigners = $first ? collect([$first]) : collect(); + } + + foreach ($targetSigners as $signer) { + $signer->update(['status' => 'notified']); + $this->dispatchNotification($contract, $signer, $sendMethod, $smsFallback); } EsignAuditLog::create([ @@ -721,7 +723,7 @@ public function send(Request $request, int $id): JsonResponse 'action' => 'sign_request_sent', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), - 'metadata' => ['sent_by' => auth()->id()], + 'metadata' => ['sent_by' => auth()->id(), 'send_method' => $sendMethod], 'created_at' => now(), ]); @@ -748,7 +750,12 @@ public function remind(Request $request, int $id): JsonResponse if ($nextSigner) { $nextSigner->update(['status' => 'notified']); - Mail::to($nextSigner->email)->send(new EsignRequestMail($contract, $nextSigner, isReminder: true)); + $this->dispatchNotification( + $contract, $nextSigner, + $contract->send_method ?? 'alimtalk', + $contract->sms_fallback ?? true, + isReminder: true, + ); } EsignAuditLog::create([ @@ -772,6 +779,88 @@ public function remind(Request $request, int $id): JsonResponse ]); } + /** + * 발송 방식에 따라 알림톡/이메일 분기 발송 + */ + private function dispatchNotification( + EsignContract $contract, + EsignSigner $signer, + string $sendMethod, + bool $smsFallback, + bool $isReminder = false, + ): void { + // 알림톡 발송 + if (in_array($sendMethod, ['alimtalk', 'both']) && $signer->phone) { + $this->sendAlimtalk($contract, $signer, $smsFallback, $isReminder); + } + + // 이메일 발송 (email/both 선택 시, 또는 알림톡인데 번호 없으면 폴백) + if (in_array($sendMethod, ['email', 'both']) || ($sendMethod === 'alimtalk' && !$signer->phone)) { + if ($signer->email) { + Mail::to($signer->email)->send(new EsignRequestMail($contract, $signer, $isReminder)); + } + } + } + + /** + * 알림톡 발송 + */ + private function sendAlimtalk( + EsignContract $contract, + EsignSigner $signer, + bool $smsFallback = true, + bool $isReminder = false, + ): void { + try { + $member = BarobillMember::where('tenant_id', $contract->tenant_id)->first(); + if (!$member) { + return; + } + + $barobill = app(BarobillService::class); + $barobill->setServerMode($member->is_test_mode ? 'test' : 'production'); + + $signUrl = config('app.url') . '/esign/sign/' . $signer->access_token; + $expires = $contract->expires_at?->format('Y-m-d H:i') ?? '없음'; + + $templateName = $isReminder ? '전자계약_리마인드' : '전자계약_서명요청'; + + $message = $isReminder + ? "안녕하세요, {$signer->name}님.\n아직 서명이 완료되지 않은 전자계약이 있습니다.\n\n■ 계약명: {$contract->title}\n■ 서명 기한: {$expires}\n\n기한 내에 서명을 완료해 주세요." + : "안녕하세요, {$signer->name}님.\n전자계약 서명 요청이 도착했습니다.\n\n■ 계약명: {$contract->title}\n■ 서명 기한: {$expires}\n\n아래 버튼을 눌러 계약서를 확인하고 서명해 주세요."; + + $smsMessage = $smsFallback + ? "[SAM] {$signer->name}님, 전자계약 서명 요청이 도착했습니다. {$signUrl}" + : ''; + + $barobill->sendATKakaotalkEx( + corpNum: $member->corp_num, + certKey: $member->cert_key, + senderId: $member->kakaotalk_sender_id ?? '', + templateName: $templateName, + receiverName: $signer->name, + receiverNum: preg_replace('/[^0-9]/', '', $signer->phone), + title: $isReminder ? '전자계약 리마인드' : '전자계약 서명 요청', + message: $message, + buttons: [ + [ + 'Name' => $isReminder ? '서명하기' : '계약서 확인하기', + 'ButtonType' => 'WL', + 'Url1' => $signUrl, + 'Url2' => $signUrl, + ], + ], + smsMessage: $smsMessage, + ); + } catch (\Throwable $e) { + \Log::warning('E-Sign 알림톡 발송 실패', [ + 'contract_id' => $contract->id, + 'signer_id' => $signer->id, + 'error' => $e->getMessage(), + ]); + } + } + /** * PDF 다운로드 */ diff --git a/app/Http/Controllers/ESign/EsignPublicController.php b/app/Http/Controllers/ESign/EsignPublicController.php index c5f960f3..c3fc6576 100644 --- a/app/Http/Controllers/ESign/EsignPublicController.php +++ b/app/Http/Controllers/ESign/EsignPublicController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\ESign; use App\Http\Controllers\Controller; +use App\Models\Barobill\BarobillMember; use App\Models\ESign\EsignAuditLog; use App\Models\ESign\EsignContract; use App\Models\ESign\EsignSigner; @@ -12,6 +13,7 @@ use App\Mail\EsignRequestMail; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; +use App\Services\Barobill\BarobillService; use App\Services\ESign\PdfSignatureService; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -305,25 +307,36 @@ public function submitSignature(Request $request, string $token): JsonResponse 'created_at' => now(), ]); - // 모든 서명자에게 완료 이메일 발송 + // 모든 서명자에게 완료 알림 발송 + $sendMethod = $contract->send_method ?? 'alimtalk'; foreach ($allSigners as $completedSigner) { try { - Mail::to($completedSigner->email)->send( - new EsignCompletedMail($contract, $completedSigner, $allSigners) - ); + // 이메일 발송 + if (in_array($sendMethod, ['email', 'both']) || !$completedSigner->phone) { + if ($completedSigner->email) { + Mail::to($completedSigner->email)->send( + new EsignCompletedMail($contract, $completedSigner, $allSigners) + ); + } + } + + // 알림톡 발송 + if (in_array($sendMethod, ['alimtalk', 'both']) && $completedSigner->phone) { + $this->sendCompletionAlimtalk($contract, $completedSigner); + } EsignAuditLog::create([ 'tenant_id' => $contract->tenant_id, 'contract_id' => $contract->id, 'signer_id' => $completedSigner->id, - 'action' => 'completion_email_sent', + 'action' => 'completion_notification_sent', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), - 'metadata' => ['email' => $completedSigner->email], + 'metadata' => ['send_method' => $sendMethod], 'created_at' => now(), ]); } catch (\Throwable $e) { - Log::error('계약 완료 이메일 발송 실패', [ + Log::error('계약 완료 알림 발송 실패', [ 'contract_id' => $contract->id, 'signer_id' => $completedSigner->id, 'error' => $e->getMessage(), @@ -342,7 +355,42 @@ public function submitSignature(Request $request, string $token): JsonResponse if ($nextSigner) { $nextSigner->update(['status' => 'notified']); - Mail::to($nextSigner->email)->send(new EsignRequestMail($contract, $nextSigner)); + $nextSendMethod = $contract->send_method ?? 'alimtalk'; + $nextSmsFallback = $contract->sms_fallback ?? true; + + // 알림톡 발송 + if (in_array($nextSendMethod, ['alimtalk', 'both']) && $nextSigner->phone) { + try { + $member = BarobillMember::where('tenant_id', $contract->tenant_id)->first(); + if ($member) { + $barobill = app(BarobillService::class); + $barobill->setServerMode($member->is_test_mode ? 'test' : 'production'); + $nextSignUrl = config('app.url') . '/esign/sign/' . $nextSigner->access_token; + $nextExpires = $contract->expires_at?->format('Y-m-d H:i') ?? '없음'; + $barobill->sendATKakaotalkEx( + corpNum: $member->corp_num, + certKey: $member->cert_key, + senderId: $member->kakaotalk_sender_id ?? '', + templateName: '전자계약_서명요청', + receiverName: $nextSigner->name, + receiverNum: preg_replace('/[^0-9]/', '', $nextSigner->phone), + title: '전자계약 서명 요청', + message: "안녕하세요, {$nextSigner->name}님.\n전자계약 서명 요청이 도착했습니다.\n\n■ 계약명: {$contract->title}\n■ 서명 기한: {$nextExpires}\n\n아래 버튼을 눌러 계약서를 확인하고 서명해 주세요.", + buttons: [['Name' => '계약서 확인하기', 'ButtonType' => 'WL', 'Url1' => $nextSignUrl, 'Url2' => $nextSignUrl]], + smsMessage: $nextSmsFallback ? "[SAM] {$nextSigner->name}님, 전자계약 서명 요청이 도착했습니다. {$nextSignUrl}" : '', + ); + } + } catch (\Throwable $e) { + Log::warning('다음 서명자 알림톡 발송 실패', ['error' => $e->getMessage()]); + } + } + + // 이메일 발송 + if (in_array($nextSendMethod, ['email', 'both']) || ($nextSendMethod === 'alimtalk' && !$nextSigner->phone)) { + if ($nextSigner->email) { + Mail::to($nextSigner->email)->send(new EsignRequestMail($contract, $nextSigner)); + } + } EsignAuditLog::create([ 'tenant_id' => $contract->tenant_id, @@ -436,6 +484,50 @@ public function downloadDocument(string $token): StreamedResponse|JsonResponse // ─── Private ─── + private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $signer): void + { + try { + $member = BarobillMember::where('tenant_id', $contract->tenant_id)->first(); + if (!$member) { + return; + } + + $barobill = app(BarobillService::class); + $barobill->setServerMode($member->is_test_mode ? 'test' : 'production'); + + $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'); + + $barobill->sendATKakaotalkEx( + corpNum: $member->corp_num, + certKey: $member->cert_key, + senderId: $member->kakaotalk_sender_id ?? '', + templateName: '전자계약_완료', + receiverName: $signer->name, + receiverNum: preg_replace('/[^0-9]/', '', $signer->phone), + title: '전자계약 완료', + message: "안녕하세요, {$signer->name}님.\n전자계약이 모든 서명자의 서명 완료로 확정되었습니다.\n\n■ 계약명: {$contract->title}\n■ 완료일: {$completedAt}\n\n아래 버튼에서 서명 완료된 계약서를 확인할 수 있습니다.", + buttons: [ + [ + 'Name' => '계약서 확인하기', + 'ButtonType' => 'WL', + 'Url1' => $signUrl, + 'Url2' => $signUrl, + ], + ], + smsMessage: ($contract->sms_fallback ?? true) + ? "[SAM] {$signer->name}님, 전자계약이 완료되었습니다. {$signUrl}" + : '', + ); + } catch (\Throwable $e) { + Log::warning('E-Sign 완료 알림톡 발송 실패', [ + 'contract_id' => $contract->id, + 'signer_id' => $signer->id, + 'error' => $e->getMessage(), + ]); + } + } + private function findSigner(string $token): ?EsignSigner { $signer = EsignSigner::withoutGlobalScopes() diff --git a/app/Models/ESign/EsignContract.php b/app/Models/ESign/EsignContract.php index e160f2c7..b524b7c5 100644 --- a/app/Models/ESign/EsignContract.php +++ b/app/Models/ESign/EsignContract.php @@ -26,6 +26,8 @@ class EsignContract extends Model 'signed_file_path', 'signed_file_hash', 'status', + 'send_method', + 'sms_fallback', 'metadata', 'expires_at', 'completed_at', @@ -36,6 +38,7 @@ class EsignContract extends Model protected $casts = [ 'original_file_size' => 'integer', + 'sms_fallback' => 'boolean', 'metadata' => 'array', 'expires_at' => 'datetime', 'completed_at' => 'datetime', diff --git a/resources/views/esign/send.blade.php b/resources/views/esign/send.blade.php index baf55e13..c3f8e426 100644 --- a/resources/views/esign/send.blade.php +++ b/resources/views/esign/send.blade.php @@ -22,10 +22,18 @@ 'X-CSRF-TOKEN': csrfToken, }); +const SEND_METHODS = [ + { value: 'alimtalk', label: '카카오톡 알림톡', desc: '수신자 휴대폰으로 알림톡 발송 (카카오톡 미사용 시 SMS 자동 대체)', icon: '💬', recommended: true }, + { value: 'email', label: '이메일', desc: '수신자 이메일로 발송', icon: '✉' }, + { value: 'both', label: '알림톡 + 이메일 (동시)', desc: '두 채널 모두 발송하여 확인율 극대화', icon: '📡' }, +]; + const App = () => { const [contract, setContract] = useState(null); const [loading, setLoading] = useState(true); const [sending, setSending] = useState(false); + const [sendMethod, setSendMethod] = useState('alimtalk'); + const [smsFallback, setSmsFallback] = useState(true); const fetchContract = useCallback(async () => { try { @@ -39,11 +47,13 @@ useEffect(() => { fetchContract(); }, [fetchContract]); const handleSend = async () => { - if (!window.confirm('서명 요청을 발송하시겠습니까?\n첫 번째 서명자에게 이메일이 발송됩니다.')) return; + const methodLabel = SEND_METHODS.find(m => m.value === sendMethod)?.label || sendMethod; + if (!window.confirm(`서명 요청을 발송하시겠습니까?\n발송 방식: ${methodLabel}`)) return; setSending(true); try { const res = await fetch(`/esign/contracts/${CONTRACT_ID}/send`, { method: 'POST', headers: getHeaders(), + body: JSON.stringify({ send_method: sendMethod, sms_fallback: smsFallback }), }); const json = await res.json(); if (json.success) { @@ -61,53 +71,90 @@ const signers = contract.signers || []; const fieldsCount = contract.sign_fields?.length || 0; + const needsAlimtalk = sendMethod === 'alimtalk' || sendMethod === 'both'; + const needsEmail = sendMethod === 'email' || sendMethod === 'both'; + const missingPhoneSigners = signers.filter(s => !s.phone); + const missingEmailSigners = signers.filter(s => !s.email); return ( -
+

서명 요청 발송

-
+ {/* 발송 전 확인 */} +

발송 전 확인

-
-
- - {contract.title ? '✓' : '!'} - - 계약 제목: {contract.title} -
-
- - {contract.original_file_name ? '✓' : '!'} - - PDF 파일: {contract.original_file_name || '미업로드'} -
-
- 0 ? 'bg-green-500' : 'bg-red-500'}`}> - {fieldsCount > 0 ? '✓' : '!'} - - 서명 필드: {fieldsCount}개 설정됨 -
+
+ 계약 제목: {contract.title}} /> + PDF 파일: {contract.original_file_name || '미업로드'}} /> + 0} label={<>서명 필드: {fieldsCount}개 설정됨} />
-
-

서명 순서

+ {/* 발송 방식 */} +
+

발송 방식

+
+ {SEND_METHODS.map(m => ( + + ))} +
+ + {/* SMS 대체발송 */} + {needsAlimtalk && ( + + )} +
+ + {/* 서명자 연락처 확인 */} +
+

서명자 연락처

{signers.sort((a, b) => a.sign_order - b.sign_order).map((s, i) => ( -
- {s.sign_order || i + 1} -
+
+ {s.sign_order || i + 1} +

{s.name} ({s.role === 'creator' ? '작성자' : '상대방'})

-

{s.email}

+
+ + +
))}
+ + {/* 경고 메시지 */} + {needsAlimtalk && missingPhoneSigners.length > 0 && ( +
+

+ 안내: {missingPhoneSigners.map(s => s.name).join(', ')}님의 휴대폰 번호가 없어 {sendMethod === 'both' ? '알림톡 없이 이메일만' : '이메일로 대체'} 발송됩니다. +

+
+ )}
+ {/* 발송 버튼 */}
돌아가기
+); + +const ContactStatus = ({ icon, value, label, needed }) => { + if (!value) { + return needed + ? {icon} {label} 미입력 + : {icon} {label} 미입력; + } + return {icon} {value}; +}; + ReactDOM.createRoot(document.getElementById('esign-send-root')).render(); @endverbatim