feat:E-Sign 전자계약 서명 솔루션 백엔드 구현
- 마이그레이션 4개 (esign_contracts, esign_signers, esign_sign_fields, esign_audit_logs) - 모델 4개 (EsignContract, EsignSigner, EsignSignField, EsignAuditLog) - 서비스 4개 (EsignContractService, EsignSignService, EsignPdfService, EsignAuditService) - 컨트롤러 2개 (EsignContractController, EsignSignController) - FormRequest 4개 (ContractStore, FieldConfigure, SignSubmit, SignReject) - Mail 1개 (EsignRequestMail + 이메일 템플릿) - API 라우트 (인증 계약 관리 + 토큰 기반 서명 프로세스) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
58
app/Services/ESign/EsignAuditService.php
Normal file
58
app/Services/ESign/EsignAuditService.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ESign;
|
||||
|
||||
use App\Models\ESign\EsignAuditLog;
|
||||
use App\Services\Service;
|
||||
|
||||
class EsignAuditService extends Service
|
||||
{
|
||||
public function log(
|
||||
int $contractId,
|
||||
string $action,
|
||||
?int $signerId = null,
|
||||
?array $metadata = null
|
||||
): EsignAuditLog {
|
||||
$request = request();
|
||||
|
||||
return EsignAuditLog::create([
|
||||
'tenant_id' => $this->tenantId(),
|
||||
'contract_id' => $contractId,
|
||||
'signer_id' => $signerId,
|
||||
'action' => $action,
|
||||
'ip_address' => $request?->ip(),
|
||||
'user_agent' => $request?->userAgent() ? mb_substr($request->userAgent(), 0, 500) : null,
|
||||
'metadata' => $metadata,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function logPublic(
|
||||
int $tenantId,
|
||||
int $contractId,
|
||||
string $action,
|
||||
?int $signerId = null,
|
||||
?array $metadata = null
|
||||
): EsignAuditLog {
|
||||
$request = request();
|
||||
|
||||
return EsignAuditLog::withoutGlobalScopes()->create([
|
||||
'tenant_id' => $tenantId,
|
||||
'contract_id' => $contractId,
|
||||
'signer_id' => $signerId,
|
||||
'action' => $action,
|
||||
'ip_address' => $request?->ip(),
|
||||
'user_agent' => $request?->userAgent() ? mb_substr($request->userAgent(), 0, 500) : null,
|
||||
'metadata' => $metadata,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function getContractLogs(int $contractId): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return EsignAuditLog::where('contract_id', $contractId)
|
||||
->with('signer:id,name,email,role')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
298
app/Services/ESign/EsignContractService.php
Normal file
298
app/Services/ESign/EsignContractService.php
Normal file
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ESign;
|
||||
|
||||
use App\Mail\EsignRequestMail;
|
||||
use App\Models\ESign\EsignAuditLog;
|
||||
use App\Models\ESign\EsignContract;
|
||||
use App\Models\ESign\EsignSignField;
|
||||
use App\Models\ESign\EsignSigner;
|
||||
use App\Services\Service;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class EsignContractService extends Service
|
||||
{
|
||||
public function __construct(
|
||||
private EsignAuditService $auditService,
|
||||
private EsignPdfService $pdfService,
|
||||
) {}
|
||||
|
||||
public function list(array $params): LengthAwarePaginator
|
||||
{
|
||||
$query = EsignContract::query()
|
||||
->with(['signers:id,contract_id,name,email,role,status,signed_at', 'creator:id,name'])
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if (! empty($params['status'])) {
|
||||
$query->where('status', $params['status']);
|
||||
}
|
||||
|
||||
if (! empty($params['search'])) {
|
||||
$search = $params['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('title', 'like', "%{$search}%")
|
||||
->orWhere('contract_code', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if (! empty($params['date_from'])) {
|
||||
$query->whereDate('created_at', '>=', $params['date_from']);
|
||||
}
|
||||
|
||||
if (! empty($params['date_to'])) {
|
||||
$query->whereDate('created_at', '<=', $params['date_to']);
|
||||
}
|
||||
|
||||
$perPage = $params['per_page'] ?? 20;
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
public function stats(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$counts = EsignContract::where('tenant_id', $tenantId)
|
||||
->selectRaw('status, COUNT(*) as count')
|
||||
->groupBy('status')
|
||||
->pluck('count', 'status')
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'total' => array_sum($counts),
|
||||
'draft' => $counts[EsignContract::STATUS_DRAFT] ?? 0,
|
||||
'pending' => $counts[EsignContract::STATUS_PENDING] ?? 0,
|
||||
'partially_signed' => $counts[EsignContract::STATUS_PARTIALLY_SIGNED] ?? 0,
|
||||
'completed' => $counts[EsignContract::STATUS_COMPLETED] ?? 0,
|
||||
'expired' => $counts[EsignContract::STATUS_EXPIRED] ?? 0,
|
||||
'cancelled' => $counts[EsignContract::STATUS_CANCELLED] ?? 0,
|
||||
'rejected' => $counts[EsignContract::STATUS_REJECTED] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function create(array $data): EsignContract
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return DB::transaction(function () use ($data, $tenantId, $userId) {
|
||||
// PDF 파일 저장
|
||||
$file = $data['file'];
|
||||
$filePath = $file->store("esign/{$tenantId}/originals", 'local');
|
||||
$fileHash = hash_file('sha256', $file->getRealPath());
|
||||
|
||||
// 계약 코드 생성
|
||||
$contractCode = 'ES-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6));
|
||||
|
||||
// 서명 순서 설정
|
||||
$signOrderType = $data['sign_order_type'] ?? EsignContract::SIGN_ORDER_COUNTERPART_FIRST;
|
||||
|
||||
$contract = EsignContract::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'contract_code' => $contractCode,
|
||||
'title' => $data['title'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'sign_order_type' => $signOrderType,
|
||||
'original_file_path' => $filePath,
|
||||
'original_file_name' => $file->getClientOriginalName(),
|
||||
'original_file_hash' => $fileHash,
|
||||
'original_file_size' => $file->getSize(),
|
||||
'status' => EsignContract::STATUS_DRAFT,
|
||||
'expires_at' => $data['expires_at'] ?? now()->addDays(14),
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
// 서명자 생성 - 작성자 (creator)
|
||||
$creatorOrder = $signOrderType === EsignContract::SIGN_ORDER_CREATOR_FIRST ? 1 : 2;
|
||||
$counterpartOrder = $signOrderType === EsignContract::SIGN_ORDER_CREATOR_FIRST ? 2 : 1;
|
||||
|
||||
EsignSigner::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'contract_id' => $contract->id,
|
||||
'role' => EsignSigner::ROLE_CREATOR,
|
||||
'sign_order' => $creatorOrder,
|
||||
'name' => $data['creator_name'],
|
||||
'email' => $data['creator_email'],
|
||||
'phone' => $data['creator_phone'] ?? null,
|
||||
'access_token' => Str::random(128),
|
||||
'token_expires_at' => $contract->expires_at,
|
||||
'status' => EsignSigner::STATUS_WAITING,
|
||||
]);
|
||||
|
||||
// 서명자 생성 - 상대방 (counterpart)
|
||||
EsignSigner::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'contract_id' => $contract->id,
|
||||
'role' => EsignSigner::ROLE_COUNTERPART,
|
||||
'sign_order' => $counterpartOrder,
|
||||
'name' => $data['counterpart_name'],
|
||||
'email' => $data['counterpart_email'],
|
||||
'phone' => $data['counterpart_phone'] ?? null,
|
||||
'access_token' => Str::random(128),
|
||||
'token_expires_at' => $contract->expires_at,
|
||||
'status' => EsignSigner::STATUS_WAITING,
|
||||
]);
|
||||
|
||||
$this->auditService->log($contract->id, EsignAuditLog::ACTION_CREATED);
|
||||
|
||||
return $contract->fresh(['signers', 'creator:id,name']);
|
||||
});
|
||||
}
|
||||
|
||||
public function show(int $id): EsignContract
|
||||
{
|
||||
$contract = EsignContract::with([
|
||||
'signers',
|
||||
'signFields',
|
||||
'auditLogs' => fn ($q) => $q->orderBy('created_at', 'desc'),
|
||||
'auditLogs.signer:id,name,email,role',
|
||||
'creator:id,name',
|
||||
])->find($id);
|
||||
|
||||
if (! $contract) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
return $contract;
|
||||
}
|
||||
|
||||
public function cancel(int $id): EsignContract
|
||||
{
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return DB::transaction(function () use ($id, $userId) {
|
||||
$contract = EsignContract::find($id);
|
||||
|
||||
if (! $contract) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
if ($contract->status === EsignContract::STATUS_COMPLETED) {
|
||||
throw new BadRequestHttpException(__('error.esign.already_completed'));
|
||||
}
|
||||
|
||||
if ($contract->status === EsignContract::STATUS_CANCELLED) {
|
||||
throw new BadRequestHttpException(__('error.esign.already_cancelled'));
|
||||
}
|
||||
|
||||
$contract->update([
|
||||
'status' => EsignContract::STATUS_CANCELLED,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
$this->auditService->log($contract->id, EsignAuditLog::ACTION_CANCELLED);
|
||||
|
||||
return $contract->fresh(['signers', 'creator:id,name']);
|
||||
});
|
||||
}
|
||||
|
||||
public function send(int $id): EsignContract
|
||||
{
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return DB::transaction(function () use ($id, $userId) {
|
||||
$contract = EsignContract::with('signers')->find($id);
|
||||
|
||||
if (! $contract) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
if (! in_array($contract->status, [EsignContract::STATUS_DRAFT])) {
|
||||
throw new BadRequestHttpException(__('error.esign.invalid_status_for_send'));
|
||||
}
|
||||
|
||||
// 서명 필드가 설정되어 있는지 확인
|
||||
$fieldsCount = EsignSignField::where('contract_id', $contract->id)->count();
|
||||
if ($fieldsCount === 0) {
|
||||
throw new BadRequestHttpException(__('error.esign.no_sign_fields'));
|
||||
}
|
||||
|
||||
$contract->update([
|
||||
'status' => EsignContract::STATUS_PENDING,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
// 첫 번째 서명자에게 알림 발송
|
||||
$nextSigner = $contract->getNextSigner();
|
||||
if ($nextSigner) {
|
||||
$nextSigner->update(['status' => EsignSigner::STATUS_NOTIFIED]);
|
||||
|
||||
Mail::to($nextSigner->email)->queue(
|
||||
new EsignRequestMail($contract, $nextSigner)
|
||||
);
|
||||
}
|
||||
|
||||
$this->auditService->log($contract->id, EsignAuditLog::ACTION_SENT);
|
||||
|
||||
return $contract->fresh(['signers', 'creator:id,name']);
|
||||
});
|
||||
}
|
||||
|
||||
public function remind(int $id): EsignContract
|
||||
{
|
||||
$contract = EsignContract::with('signers')->find($id);
|
||||
|
||||
if (! $contract) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
if (! $contract->canSign()) {
|
||||
throw new BadRequestHttpException(__('error.esign.cannot_remind'));
|
||||
}
|
||||
|
||||
$nextSigner = $contract->getNextSigner();
|
||||
if ($nextSigner) {
|
||||
Mail::to($nextSigner->email)->queue(
|
||||
new EsignRequestMail($contract, $nextSigner)
|
||||
);
|
||||
}
|
||||
|
||||
$this->auditService->log($contract->id, EsignAuditLog::ACTION_REMINDED);
|
||||
|
||||
return $contract;
|
||||
}
|
||||
|
||||
public function configureFields(int $id, array $fields): EsignContract
|
||||
{
|
||||
return DB::transaction(function () use ($id, $fields) {
|
||||
$contract = EsignContract::with('signers')->find($id);
|
||||
|
||||
if (! $contract) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
if ($contract->status !== EsignContract::STATUS_DRAFT) {
|
||||
throw new BadRequestHttpException(__('error.esign.fields_only_in_draft'));
|
||||
}
|
||||
|
||||
// 기존 필드 삭제 후 재생성
|
||||
EsignSignField::where('contract_id', $contract->id)->delete();
|
||||
|
||||
foreach ($fields as $field) {
|
||||
EsignSignField::create([
|
||||
'tenant_id' => $contract->tenant_id,
|
||||
'contract_id' => $contract->id,
|
||||
'signer_id' => $field['signer_id'],
|
||||
'page_number' => $field['page_number'],
|
||||
'position_x' => $field['position_x'],
|
||||
'position_y' => $field['position_y'],
|
||||
'width' => $field['width'],
|
||||
'height' => $field['height'],
|
||||
'field_type' => $field['field_type'] ?? EsignSignField::TYPE_SIGNATURE,
|
||||
'field_label' => $field['field_label'] ?? null,
|
||||
'is_required' => $field['is_required'] ?? true,
|
||||
'sort_order' => $field['sort_order'] ?? 0,
|
||||
]);
|
||||
}
|
||||
|
||||
return $contract->fresh(['signers', 'signFields']);
|
||||
});
|
||||
}
|
||||
}
|
||||
48
app/Services/ESign/EsignPdfService.php
Normal file
48
app/Services/ESign/EsignPdfService.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ESign;
|
||||
|
||||
use App\Services\Service;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class EsignPdfService extends Service
|
||||
{
|
||||
public function generateHash(string $filePath): string
|
||||
{
|
||||
$fullPath = Storage::disk('local')->path($filePath);
|
||||
|
||||
return hash_file('sha256', $fullPath);
|
||||
}
|
||||
|
||||
public function verifyIntegrity(string $filePath, string $expectedHash): bool
|
||||
{
|
||||
$actualHash = $this->generateHash($filePath);
|
||||
|
||||
return hash_equals($expectedHash, $actualHash);
|
||||
}
|
||||
|
||||
public function composeSigned(
|
||||
string $originalPath,
|
||||
array $signerImages,
|
||||
array $signFields
|
||||
): string {
|
||||
// FPDI/FPDF 기반 PDF 합성 - 추후 구현
|
||||
// 현재는 원본 파일을 signed 경로로 복사
|
||||
$signedPath = str_replace('originals/', 'signed/', $originalPath);
|
||||
$signedDir = dirname(Storage::disk('local')->path($signedPath));
|
||||
|
||||
if (! is_dir($signedDir)) {
|
||||
mkdir($signedDir, 0755, true);
|
||||
}
|
||||
|
||||
Storage::disk('local')->copy($originalPath, $signedPath);
|
||||
|
||||
return $signedPath;
|
||||
}
|
||||
|
||||
public function addAuditPage(string $pdfPath, array $auditData): string
|
||||
{
|
||||
// 감사 증적 페이지 추가 - 추후 FPDI/FPDF로 구현
|
||||
return $pdfPath;
|
||||
}
|
||||
}
|
||||
274
app/Services/ESign/EsignSignService.php
Normal file
274
app/Services/ESign/EsignSignService.php
Normal file
@@ -0,0 +1,274 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ESign;
|
||||
|
||||
use App\Mail\EsignRequestMail;
|
||||
use App\Models\ESign\EsignAuditLog;
|
||||
use App\Models\ESign\EsignContract;
|
||||
use App\Models\ESign\EsignSigner;
|
||||
use App\Services\Service;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class EsignSignService extends Service
|
||||
{
|
||||
public function __construct(
|
||||
private EsignAuditService $auditService,
|
||||
private EsignPdfService $pdfService,
|
||||
) {}
|
||||
|
||||
public function getByToken(string $token): array
|
||||
{
|
||||
$signer = EsignSigner::withoutGlobalScopes()
|
||||
->where('access_token', $token)
|
||||
->first();
|
||||
|
||||
if (! $signer) {
|
||||
throw new NotFoundHttpException(__('error.esign.invalid_token'));
|
||||
}
|
||||
|
||||
if ($signer->token_expires_at && $signer->token_expires_at->isPast()) {
|
||||
throw new BadRequestHttpException(__('error.esign.token_expired'));
|
||||
}
|
||||
|
||||
$contract = EsignContract::withoutGlobalScopes()
|
||||
->with(['signers:id,contract_id,name,role,status,signed_at'])
|
||||
->find($signer->contract_id);
|
||||
|
||||
if (! $contract) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
$this->auditService->logPublic(
|
||||
$contract->tenant_id,
|
||||
$contract->id,
|
||||
EsignAuditLog::ACTION_VIEWED,
|
||||
$signer->id
|
||||
);
|
||||
|
||||
return [
|
||||
'contract' => $contract,
|
||||
'signer' => $signer,
|
||||
];
|
||||
}
|
||||
|
||||
public function sendOtp(string $token): array
|
||||
{
|
||||
$signer = $this->findSignerByToken($token);
|
||||
$contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id);
|
||||
|
||||
if (! $contract || ! $contract->canSign()) {
|
||||
throw new BadRequestHttpException(__('error.esign.contract_not_signable'));
|
||||
}
|
||||
|
||||
// OTP 생성 (6자리)
|
||||
$otpCode = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
|
||||
$signer->update([
|
||||
'otp_code' => $otpCode,
|
||||
'otp_expires_at' => now()->addMinutes(5),
|
||||
'otp_attempts' => 0,
|
||||
]);
|
||||
|
||||
// OTP 이메일 발송 (실제 발송은 추후 Mail 클래스에서)
|
||||
// 현재는 개발 편의를 위해 로그에만 기록
|
||||
\Illuminate\Support\Facades\Log::info("E-Sign OTP: {$otpCode} for {$signer->email}");
|
||||
|
||||
$this->auditService->logPublic(
|
||||
$contract->tenant_id,
|
||||
$contract->id,
|
||||
EsignAuditLog::ACTION_OTP_SENT,
|
||||
$signer->id,
|
||||
['email' => $signer->email]
|
||||
);
|
||||
|
||||
return ['message' => __('message.esign.otp_sent')];
|
||||
}
|
||||
|
||||
public function verifyOtp(string $token, string $otpCode): array
|
||||
{
|
||||
$signer = $this->findSignerByToken($token);
|
||||
|
||||
if ($signer->otp_attempts >= 5) {
|
||||
throw new BadRequestHttpException(__('error.esign.otp_max_attempts'));
|
||||
}
|
||||
|
||||
if (! $signer->otp_code || ! $signer->otp_expires_at) {
|
||||
throw new BadRequestHttpException(__('error.esign.otp_not_sent'));
|
||||
}
|
||||
|
||||
if ($signer->otp_expires_at->isPast()) {
|
||||
throw new BadRequestHttpException(__('error.esign.otp_expired'));
|
||||
}
|
||||
|
||||
$signer->increment('otp_attempts');
|
||||
|
||||
if ($signer->otp_code !== $otpCode) {
|
||||
throw new BadRequestHttpException(__('error.esign.otp_invalid'));
|
||||
}
|
||||
|
||||
$contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id);
|
||||
|
||||
$signer->update([
|
||||
'auth_verified_at' => now(),
|
||||
'otp_code' => null,
|
||||
'otp_expires_at' => null,
|
||||
'status' => EsignSigner::STATUS_AUTHENTICATED,
|
||||
]);
|
||||
|
||||
$this->auditService->logPublic(
|
||||
$contract->tenant_id,
|
||||
$contract->id,
|
||||
EsignAuditLog::ACTION_AUTHENTICATED,
|
||||
$signer->id
|
||||
);
|
||||
|
||||
// sign_session_token 발급 (JWT 대신 단순 토큰)
|
||||
$sessionToken = Str::random(64);
|
||||
|
||||
return [
|
||||
'sign_session_token' => $sessionToken,
|
||||
'signer' => $signer->fresh(),
|
||||
];
|
||||
}
|
||||
|
||||
public function submitSignature(string $token, array $data): EsignSigner
|
||||
{
|
||||
$signer = $this->findSignerByToken($token);
|
||||
|
||||
if (! $signer->isVerified()) {
|
||||
throw new BadRequestHttpException(__('error.esign.not_verified'));
|
||||
}
|
||||
|
||||
if ($signer->hasSigned()) {
|
||||
throw new BadRequestHttpException(__('error.esign.already_signed'));
|
||||
}
|
||||
|
||||
$contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id);
|
||||
|
||||
if (! $contract || ! $contract->canSign()) {
|
||||
throw new BadRequestHttpException(__('error.esign.contract_not_signable'));
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($signer, $contract, $data) {
|
||||
// 서명 이미지 저장
|
||||
$signatureImagePath = null;
|
||||
if (! empty($data['signature_image'])) {
|
||||
$imageData = base64_decode($data['signature_image']);
|
||||
$signatureImagePath = "esign/{$contract->tenant_id}/signatures/{$contract->id}_{$signer->id}.png";
|
||||
\Illuminate\Support\Facades\Storage::disk('local')->put($signatureImagePath, $imageData);
|
||||
}
|
||||
|
||||
$request = request();
|
||||
$signer->update([
|
||||
'signature_image_path' => $signatureImagePath,
|
||||
'signed_at' => now(),
|
||||
'consent_agreed_at' => now(),
|
||||
'sign_ip_address' => $request->ip(),
|
||||
'sign_user_agent' => mb_substr($request->userAgent() ?? '', 0, 500),
|
||||
'status' => EsignSigner::STATUS_SIGNED,
|
||||
]);
|
||||
|
||||
$this->auditService->logPublic(
|
||||
$contract->tenant_id,
|
||||
$contract->id,
|
||||
EsignAuditLog::ACTION_SIGNED,
|
||||
$signer->id,
|
||||
['ip' => $request->ip()]
|
||||
);
|
||||
|
||||
// 완료 여부 확인 및 처리
|
||||
$this->checkAndComplete($contract);
|
||||
|
||||
return $signer->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function reject(string $token, string $reason): EsignSigner
|
||||
{
|
||||
$signer = $this->findSignerByToken($token);
|
||||
|
||||
if ($signer->hasSigned()) {
|
||||
throw new BadRequestHttpException(__('error.esign.already_signed'));
|
||||
}
|
||||
|
||||
$contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id);
|
||||
|
||||
return DB::transaction(function () use ($signer, $contract, $reason) {
|
||||
$signer->update([
|
||||
'status' => EsignSigner::STATUS_REJECTED,
|
||||
'rejected_reason' => $reason,
|
||||
]);
|
||||
|
||||
$contract->update([
|
||||
'status' => EsignContract::STATUS_REJECTED,
|
||||
]);
|
||||
|
||||
$this->auditService->logPublic(
|
||||
$contract->tenant_id,
|
||||
$contract->id,
|
||||
EsignAuditLog::ACTION_REJECTED,
|
||||
$signer->id,
|
||||
['reason' => $reason]
|
||||
);
|
||||
|
||||
return $signer->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
private function checkAndComplete(EsignContract $contract): void
|
||||
{
|
||||
$allSigned = $contract->signers()
|
||||
->where('status', '!=', EsignSigner::STATUS_SIGNED)
|
||||
->doesntExist();
|
||||
|
||||
if ($allSigned) {
|
||||
// 모든 서명 완료
|
||||
$contract->update([
|
||||
'status' => EsignContract::STATUS_COMPLETED,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$this->auditService->logPublic(
|
||||
$contract->tenant_id,
|
||||
$contract->id,
|
||||
EsignAuditLog::ACTION_COMPLETED
|
||||
);
|
||||
} else {
|
||||
// 부분 서명 상태 업데이트
|
||||
$signedCount = $contract->signers()->where('status', EsignSigner::STATUS_SIGNED)->count();
|
||||
if ($signedCount > 0 && $contract->status === EsignContract::STATUS_PENDING) {
|
||||
$contract->update(['status' => EsignContract::STATUS_PARTIALLY_SIGNED]);
|
||||
}
|
||||
|
||||
// 다음 서명자에게 알림
|
||||
$nextSigner = $contract->getNextSigner();
|
||||
if ($nextSigner && $nextSigner->status === EsignSigner::STATUS_WAITING) {
|
||||
$nextSigner->update(['status' => EsignSigner::STATUS_NOTIFIED]);
|
||||
Mail::to($nextSigner->email)->queue(
|
||||
new EsignRequestMail($contract, $nextSigner)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function findSignerByToken(string $token): EsignSigner
|
||||
{
|
||||
$signer = EsignSigner::withoutGlobalScopes()
|
||||
->where('access_token', $token)
|
||||
->first();
|
||||
|
||||
if (! $signer) {
|
||||
throw new NotFoundHttpException(__('error.esign.invalid_token'));
|
||||
}
|
||||
|
||||
if ($signer->token_expires_at && $signer->token_expires_at->isPast()) {
|
||||
throw new BadRequestHttpException(__('error.esign.token_expired'));
|
||||
}
|
||||
|
||||
return $signer;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user