feat:서명 완료 시 계약서 이메일 발송 및 감사 로그 추가

- EsignCompletedMail Mailable 생성 (완료 알림 + PDF 다운로드 링크)
- completed.blade.php 이메일 뷰 템플릿 생성 (초록색 테마)
- submitSignature에 contract_completed 감사 로그 추가
- 모든 서명자에게 완료 이메일 발송 + completion_email_sent 감사 로그
- 이메일 발송 실패 시 try-catch로 계약 완료 보호

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-13 06:25:01 +09:00
parent f050be52fe
commit 8f7a441900
3 changed files with 176 additions and 1 deletions

View File

@@ -8,7 +8,9 @@
use App\Models\ESign\EsignSigner;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use App\Mail\EsignCompletedMail;
use App\Mail\EsignRequestMail;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use App\Services\ESign\PdfSignatureService;
use Illuminate\Support\Facades\Storage;
@@ -250,11 +252,49 @@ public function submitSignature(Request $request, string $token): JsonResponse
$pdfService = new PdfSignatureService();
$pdfService->mergeSignatures($contract);
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::error('PDF 서명 합성 실패', [
Log::error('PDF 서명 합성 실패', [
'contract_id' => $contract->id,
'error' => $e->getMessage(),
]);
}
// 계약 완료 감사 로그
EsignAuditLog::create([
'tenant_id' => $contract->tenant_id,
'contract_id' => $contract->id,
'signer_id' => $signer->id,
'action' => 'contract_completed',
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'metadata' => ['total_signers' => $allSigners->count()],
'created_at' => now(),
]);
// 모든 서명자에게 완료 이메일 발송
foreach ($allSigners as $completedSigner) {
try {
Mail::to($completedSigner->email)->send(
new EsignCompletedMail($contract, $completedSigner, $allSigners)
);
EsignAuditLog::create([
'tenant_id' => $contract->tenant_id,
'contract_id' => $contract->id,
'signer_id' => $completedSigner->id,
'action' => 'completion_email_sent',
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'metadata' => ['email' => $completedSigner->email],
'created_at' => now(),
]);
} catch (\Throwable $e) {
Log::error('계약 완료 이메일 발송 실패', [
'contract_id' => $contract->id,
'signer_id' => $completedSigner->id,
'error' => $e->getMessage(),
]);
}
}
} else {
$contract->update(['status' => 'partially_signed']);

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Mail;
use App\Models\ESign\EsignContract;
use App\Models\ESign\EsignSigner;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
class EsignCompletedMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public EsignContract $contract,
public EsignSigner $signer,
public Collection $allSigners,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "[SAM] 전자계약 서명 완료 - {$this->contract->title}",
);
}
public function content(): Content
{
$downloadUrl = config('app.url') . '/esign/sign/' . $this->signer->access_token . '/api/document';
return new Content(
html: 'emails.esign.completed',
with: [
'contractTitle' => $this->contract->title,
'signerName' => $this->signer->name,
'completedAt' => $this->contract->completed_at?->format('Y-m-d H:i'),
'allSigners' => $this->allSigners,
'downloadUrl' => $downloadUrl,
],
);
}
}

View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>전자계약 서명 완료</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="padding: 40px 20px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td style="background-color: #16a34a; padding: 32px 40px; text-align: center;">
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600;">
전자계약 서명 완료
</h1>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding: 40px;">
<p style="margin: 0 0 16px; font-size: 16px; color: #333;">안녕하세요, <strong>{{ $signerName }}</strong>.</p>
<p style="margin: 0 0 24px; font-size: 15px; color: #555; line-height: 1.6;">
아래 전자계약의 모든 서명이 완료되었습니다.<br>
완성된 계약서를 다운로드하여 보관해 주세요.
</p>
<!-- 계약 정보 -->
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f0fdf4; border-radius: 6px; margin-bottom: 24px;">
<tr>
<td style="padding: 20px;">
<p style="margin: 0 0 8px; font-size: 13px; color: #888;">계약 제목</p>
<p style="margin: 0 0 16px; font-size: 16px; color: #333; font-weight: 600;">{{ $contractTitle }}</p>
<p style="margin: 0 0 8px; font-size: 13px; color: #888;">완료 일시</p>
<p style="margin: 0; font-size: 15px; color: #16a34a; font-weight: 600;">{{ $completedAt }}</p>
</td>
</tr>
</table>
<!-- 서명자 목록 -->
<p style="margin: 0 0 12px; font-size: 14px; color: #555; font-weight: 600;">서명자 목록</p>
<table width="100%" cellpadding="0" cellspacing="0" style="border: 1px solid #e5e7eb; border-radius: 6px; overflow: hidden; margin-bottom: 24px;">
<tr style="background-color: #f9fafb;">
<td style="padding: 10px 16px; font-size: 13px; color: #666; font-weight: 600; border-bottom: 1px solid #e5e7eb;">서명자</td>
<td style="padding: 10px 16px; font-size: 13px; color: #666; font-weight: 600; border-bottom: 1px solid #e5e7eb;">서명 일시</td>
<td style="padding: 10px 16px; font-size: 13px; color: #666; font-weight: 600; border-bottom: 1px solid #e5e7eb; text-align: center;">상태</td>
</tr>
@foreach($allSigners as $s)
<tr>
<td style="padding: 12px 16px; font-size: 14px; color: #333; border-bottom: 1px solid #f3f4f6;">{{ $s->name }}</td>
<td style="padding: 12px 16px; font-size: 14px; color: #555; border-bottom: 1px solid #f3f4f6;">{{ $s->signed_at?->format('Y-m-d H:i') ?? '-' }}</td>
<td style="padding: 12px 16px; font-size: 14px; color: #16a34a; border-bottom: 1px solid #f3f4f6; text-align: center;">&#10003;</td>
</tr>
@endforeach
</table>
<!-- 다운로드 버튼 -->
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center" style="padding: 8px 0;">
<a href="{{ $downloadUrl }}" style="display: inline-block; background-color: #16a34a; color: #ffffff; text-decoration: none; padding: 14px 40px; border-radius: 6px; font-size: 16px; font-weight: 600;">
계약서 다운로드
</a>
</td>
</tr>
</table>
<p style="margin: 24px 0 0; font-size: 13px; color: #999; line-height: 1.6;">
버튼이 동작하지 않으면 아래 링크를 브라우저에 직접 입력해 주세요:<br>
<a href="{{ $downloadUrl }}" style="color: #16a34a; word-break: break-all;">{{ $downloadUrl }}</a>
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f8fafc; padding: 24px 40px; text-align: center; border-top: 1px solid #e5e7eb;">
<p style="margin: 0; font-size: 12px; color: #999;">
메일은 SAM 전자계약 시스템에서 자동 발송되었습니다.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>