diff --git a/app/Http/Controllers/Api/V1/ESign/EsignContractController.php b/app/Http/Controllers/Api/V1/ESign/EsignContractController.php new file mode 100644 index 0000000..0998ea4 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ESign/EsignContractController.php @@ -0,0 +1,116 @@ +service->list($request->all()); + }, __('message.fetched')); + } + + public function show(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.fetched')); + } + + public function store(ContractStoreRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->create($request->validated() + ['file' => $request->file('file')]); + }, __('message.created')); + } + + public function cancel(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->cancel($id); + }, __('message.esign.cancelled')); + } + + public function configureFields(int $id, FieldConfigureRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->configureFields($id, $request->validated()['fields']); + }, __('message.esign.fields_configured')); + } + + public function send(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->send($id); + }, __('message.esign.sent')); + } + + public function remind(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->remind($id); + }, __('message.esign.reminded')); + } + + public function stats(): JsonResponse + { + return ApiResponse::handle(function () { + return $this->service->stats(); + }, __('message.fetched')); + } + + public function download(int $id): \Symfony\Component\HttpFoundation\StreamedResponse|JsonResponse + { + try { + $contract = $this->service->show($id); + $filePath = $contract->signed_file_path ?? $contract->original_file_path; + + if (! $filePath || ! Storage::disk('local')->exists($filePath)) { + return ApiResponse::error(__('error.esign.file_not_found'), 404); + } + + $fileName = $contract->original_file_name ?? 'contract.pdf'; + + return Storage::disk('local')->download($filePath, $fileName); + } catch (\Throwable $e) { + return ApiResponse::error($e->getMessage(), 500); + } + } + + public function verify(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $contract = $this->service->show($id); + + if (! $contract->original_file_path || ! $contract->original_file_hash) { + return ['verified' => false, 'message' => '파일 정보가 없습니다.']; + } + + $isValid = $this->pdfService->verifyIntegrity( + $contract->original_file_path, + $contract->original_file_hash + ); + + return [ + 'verified' => $isValid, + 'original_hash' => $contract->original_file_hash, + ]; + }, __('message.esign.verified')); + } +} diff --git a/app/Http/Controllers/Api/V1/ESign/EsignSignController.php b/app/Http/Controllers/Api/V1/ESign/EsignSignController.php new file mode 100644 index 0000000..517fbb8 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ESign/EsignSignController.php @@ -0,0 +1,75 @@ +service->getByToken($token); + }, __('message.fetched')); + } + + public function sendOtp(string $token): JsonResponse + { + return ApiResponse::handle(function () use ($token) { + return $this->service->sendOtp($token); + }, __('message.esign.otp_sent')); + } + + public function verifyOtp(string $token, Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($token, $request) { + $request->validate(['otp_code' => 'required|string|size:6']); + + return $this->service->verifyOtp($token, $request->input('otp_code')); + }, __('message.esign.otp_verified')); + } + + public function getDocument(string $token): \Symfony\Component\HttpFoundation\StreamedResponse|JsonResponse + { + try { + $data = $this->service->getByToken($token); + $contract = $data['contract']; + $filePath = $contract->original_file_path; + + if (! $filePath || ! Storage::disk('local')->exists($filePath)) { + return ApiResponse::error(__('error.esign.file_not_found'), 404); + } + + return Storage::disk('local')->response($filePath, null, [ + 'Content-Type' => 'application/pdf', + ]); + } catch (\Throwable $e) { + return ApiResponse::error($e->getMessage(), $e->getCode() ?: 500); + } + } + + public function submit(string $token, SignSubmitRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($token, $request) { + return $this->service->submitSignature($token, $request->validated()); + }, __('message.esign.signed')); + } + + public function reject(string $token, SignRejectRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($token, $request) { + return $this->service->reject($token, $request->validated()['reason']); + }, __('message.esign.rejected')); + } +} diff --git a/app/Http/Requests/ESign/ContractStoreRequest.php b/app/Http/Requests/ESign/ContractStoreRequest.php new file mode 100644 index 0000000..f8efb30 --- /dev/null +++ b/app/Http/Requests/ESign/ContractStoreRequest.php @@ -0,0 +1,45 @@ + 'required|string|max:200', + 'description' => 'nullable|string|max:2000', + 'sign_order_type' => 'nullable|string|in:' . implode(',', EsignContract::SIGN_ORDERS), + 'file' => 'required|file|mimes:pdf|max:20480', + 'expires_at' => 'nullable|date|after:now', + 'creator_name' => 'required|string|max:100', + 'creator_email' => 'required|email|max:255', + 'creator_phone' => 'nullable|string|max:20', + 'counterpart_name' => 'required|string|max:100', + 'counterpart_email' => 'required|email|max:255', + 'counterpart_phone' => 'nullable|string|max:20', + ]; + } + + public function messages(): array + { + return [ + 'title.required' => __('validation.required', ['attribute' => '계약 제목']), + 'file.required' => __('validation.required', ['attribute' => 'PDF 파일']), + 'file.mimes' => __('validation.mimes', ['attribute' => '파일', 'values' => 'PDF']), + 'file.max' => __('validation.max.file', ['attribute' => '파일', 'max' => '20MB']), + 'creator_name.required' => __('validation.required', ['attribute' => '작성자 이름']), + 'creator_email.required' => __('validation.required', ['attribute' => '작성자 이메일']), + 'counterpart_name.required' => __('validation.required', ['attribute' => '상대방 이름']), + 'counterpart_email.required' => __('validation.required', ['attribute' => '상대방 이메일']), + ]; + } +} diff --git a/app/Http/Requests/ESign/FieldConfigureRequest.php b/app/Http/Requests/ESign/FieldConfigureRequest.php new file mode 100644 index 0000000..584c427 --- /dev/null +++ b/app/Http/Requests/ESign/FieldConfigureRequest.php @@ -0,0 +1,39 @@ + 'required|array|min:1', + 'fields.*.signer_id' => 'required|integer|exists:esign_signers,id', + 'fields.*.page_number' => 'required|integer|min:1', + 'fields.*.position_x' => 'required|numeric|min:0|max:100', + 'fields.*.position_y' => 'required|numeric|min:0|max:100', + 'fields.*.width' => 'required|numeric|min:1|max:100', + 'fields.*.height' => 'required|numeric|min:1|max:100', + 'fields.*.field_type' => 'nullable|string|in:' . implode(',', EsignSignField::FIELD_TYPES), + 'fields.*.field_label' => 'nullable|string|max:100', + 'fields.*.is_required' => 'nullable|boolean', + 'fields.*.sort_order' => 'nullable|integer|min:0', + ]; + } + + public function messages(): array + { + return [ + 'fields.required' => __('validation.required', ['attribute' => '서명 필드']), + 'fields.min' => '최소 1개 이상의 서명 필드가 필요합니다.', + ]; + } +} diff --git a/app/Http/Requests/ESign/SignRejectRequest.php b/app/Http/Requests/ESign/SignRejectRequest.php new file mode 100644 index 0000000..b74ce54 --- /dev/null +++ b/app/Http/Requests/ESign/SignRejectRequest.php @@ -0,0 +1,27 @@ + 'required|string|max:1000', + ]; + } + + public function messages(): array + { + return [ + 'reason.required' => __('validation.required', ['attribute' => '거절 사유']), + ]; + } +} diff --git a/app/Http/Requests/ESign/SignSubmitRequest.php b/app/Http/Requests/ESign/SignSubmitRequest.php new file mode 100644 index 0000000..f14aecf --- /dev/null +++ b/app/Http/Requests/ESign/SignSubmitRequest.php @@ -0,0 +1,27 @@ + 'required|string', + ]; + } + + public function messages(): array + { + return [ + 'signature_image.required' => __('validation.required', ['attribute' => '서명 이미지']), + ]; + } +} diff --git a/app/Mail/EsignRequestMail.php b/app/Mail/EsignRequestMail.php new file mode 100644 index 0000000..eddcf7e --- /dev/null +++ b/app/Mail/EsignRequestMail.php @@ -0,0 +1,44 @@ +contract->title}", + ); + } + + public function content(): Content + { + $signUrl = config('app.mng_url', config('app.url')) + . '/esign/sign/' . $this->signer->access_token; + + return new Content( + html: 'emails.esign.request', + with: [ + 'contractTitle' => $this->contract->title, + 'signerName' => $this->signer->name, + 'signUrl' => $signUrl, + 'expiresAt' => $this->contract->expires_at?->format('Y-m-d H:i'), + ], + ); + } +} diff --git a/app/Models/ESign/EsignAuditLog.php b/app/Models/ESign/EsignAuditLog.php new file mode 100644 index 0000000..94a2877 --- /dev/null +++ b/app/Models/ESign/EsignAuditLog.php @@ -0,0 +1,57 @@ + 'array', + 'created_at' => 'datetime', + ]; + + // === Relations === + + public function contract(): BelongsTo + { + return $this->belongsTo(EsignContract::class, 'contract_id'); + } + + public function signer(): BelongsTo + { + return $this->belongsTo(EsignSigner::class, 'signer_id'); + } +} diff --git a/app/Models/ESign/EsignContract.php b/app/Models/ESign/EsignContract.php new file mode 100644 index 0000000..e519134 --- /dev/null +++ b/app/Models/ESign/EsignContract.php @@ -0,0 +1,133 @@ + 'integer', + 'expires_at' => 'datetime', + 'completed_at' => 'datetime', + ]; + + // === Relations === + + public function signers(): HasMany + { + return $this->hasMany(EsignSigner::class, 'contract_id'); + } + + public function signFields(): HasMany + { + return $this->hasMany(EsignSignField::class, 'contract_id'); + } + + public function auditLogs(): HasMany + { + return $this->hasMany(EsignAuditLog::class, 'contract_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // === Scopes === + + public function scopeStatus($query, string $status) + { + return $query->where('status', $status); + } + + public function scopeActive($query) + { + return $query->whereNotIn('status', [ + self::STATUS_CANCELLED, + self::STATUS_EXPIRED, + self::STATUS_REJECTED, + ]); + } + + // === Helpers === + + public function isExpired(): bool + { + return $this->expires_at && $this->expires_at->isPast(); + } + + public function canSign(): bool + { + return in_array($this->status, [ + self::STATUS_PENDING, + self::STATUS_PARTIALLY_SIGNED, + ]) && ! $this->isExpired(); + } + + public function getNextSigner(): ?EsignSigner + { + return $this->signers() + ->whereIn('status', [EsignSigner::STATUS_WAITING, EsignSigner::STATUS_NOTIFIED, EsignSigner::STATUS_AUTHENTICATED]) + ->orderBy('sign_order') + ->first(); + } +} diff --git a/app/Models/ESign/EsignSignField.php b/app/Models/ESign/EsignSignField.php new file mode 100644 index 0000000..1d6b935 --- /dev/null +++ b/app/Models/ESign/EsignSignField.php @@ -0,0 +1,67 @@ + 'integer', + 'position_x' => 'decimal:2', + 'position_y' => 'decimal:2', + 'width' => 'decimal:2', + 'height' => 'decimal:2', + 'is_required' => 'boolean', + 'sort_order' => 'integer', + ]; + + // === Relations === + + public function contract(): BelongsTo + { + return $this->belongsTo(EsignContract::class, 'contract_id'); + } + + public function signer(): BelongsTo + { + return $this->belongsTo(EsignSigner::class, 'signer_id'); + } +} diff --git a/app/Models/ESign/EsignSigner.php b/app/Models/ESign/EsignSigner.php new file mode 100644 index 0000000..bfdf238 --- /dev/null +++ b/app/Models/ESign/EsignSigner.php @@ -0,0 +1,104 @@ + 'integer', + 'otp_attempts' => 'integer', + 'token_expires_at' => 'datetime', + 'otp_expires_at' => 'datetime', + 'auth_verified_at' => 'datetime', + 'signed_at' => 'datetime', + 'consent_agreed_at' => 'datetime', + ]; + + protected $hidden = [ + 'access_token', + 'otp_code', + ]; + + // === Relations === + + public function contract(): BelongsTo + { + return $this->belongsTo(EsignContract::class, 'contract_id'); + } + + public function signFields(): HasMany + { + return $this->hasMany(EsignSignField::class, 'signer_id'); + } + + // === Helpers === + + public function isVerified(): bool + { + return $this->auth_verified_at !== null; + } + + public function hasSigned(): bool + { + return $this->signed_at !== null; + } + + public function canSign(): bool + { + return $this->isVerified() + && ! $this->hasSigned() + && $this->status !== self::STATUS_REJECTED + && $this->contract->canSign(); + } +} diff --git a/app/Services/ESign/EsignAuditService.php b/app/Services/ESign/EsignAuditService.php new file mode 100644 index 0000000..227509d --- /dev/null +++ b/app/Services/ESign/EsignAuditService.php @@ -0,0 +1,58 @@ + $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(); + } +} diff --git a/app/Services/ESign/EsignContractService.php b/app/Services/ESign/EsignContractService.php new file mode 100644 index 0000000..6056b1b --- /dev/null +++ b/app/Services/ESign/EsignContractService.php @@ -0,0 +1,298 @@ +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']); + }); + } +} diff --git a/app/Services/ESign/EsignPdfService.php b/app/Services/ESign/EsignPdfService.php new file mode 100644 index 0000000..196a4ff --- /dev/null +++ b/app/Services/ESign/EsignPdfService.php @@ -0,0 +1,48 @@ +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; + } +} diff --git a/app/Services/ESign/EsignSignService.php b/app/Services/ESign/EsignSignService.php new file mode 100644 index 0000000..2259680 --- /dev/null +++ b/app/Services/ESign/EsignSignService.php @@ -0,0 +1,274 @@ +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; + } +} diff --git a/database/migrations/2026_02_12_100000_create_esign_contracts_table.php b/database/migrations/2026_02_12_100000_create_esign_contracts_table.php new file mode 100644 index 0000000..2ec9412 --- /dev/null +++ b/database/migrations/2026_02_12_100000_create_esign_contracts_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('tenant_id')->constrained()->onDelete('cascade')->comment('테넌트 ID'); + $table->string('contract_code', 50)->unique()->comment('계약 코드'); + $table->string('title', 200)->comment('계약 제목'); + $table->text('description')->nullable()->comment('계약 설명'); + $table->enum('sign_order_type', ['counterpart_first', 'creator_first'])->default('counterpart_first')->comment('서명 순서 (counterpart_first: 상대방 먼저, creator_first: 작성자 먼저)'); + $table->string('original_file_path', 500)->nullable()->comment('원본 PDF 파일 경로'); + $table->string('original_file_name', 255)->nullable()->comment('원본 파일명'); + $table->string('original_file_hash', 64)->nullable()->comment('원본 파일 SHA-256 해시'); + $table->unsignedBigInteger('original_file_size')->nullable()->comment('원본 파일 크기 (bytes)'); + $table->string('signed_file_path', 500)->nullable()->comment('서명 완료 PDF 파일 경로'); + $table->string('signed_file_hash', 64)->nullable()->comment('서명 완료 파일 SHA-256 해시'); + $table->enum('status', ['draft', 'pending', 'partially_signed', 'completed', 'expired', 'cancelled', 'rejected'])->default('draft')->comment('계약 상태'); + $table->timestamp('expires_at')->nullable()->comment('계약 만료일시'); + $table->timestamp('completed_at')->nullable()->comment('계약 완료일시'); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('생성자'); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자'); + $table->foreignId('deleted_by')->nullable()->constrained('users')->nullOnDelete()->comment('삭제자'); + $table->softDeletes(); + $table->timestamps(); + + $table->index('tenant_id', 'idx_esign_contracts_tenant'); + $table->index('status', 'idx_esign_contracts_status'); + $table->index('expires_at', 'idx_esign_contracts_expires'); + }); + } + + public function down(): void + { + Schema::dropIfExists('esign_contracts'); + } +}; diff --git a/database/migrations/2026_02_12_110000_create_esign_signers_table.php b/database/migrations/2026_02_12_110000_create_esign_signers_table.php new file mode 100644 index 0000000..0221849 --- /dev/null +++ b/database/migrations/2026_02_12_110000_create_esign_signers_table.php @@ -0,0 +1,45 @@ +id(); + $table->foreignId('tenant_id')->constrained()->onDelete('cascade')->comment('테넌트 ID'); + $table->foreignId('contract_id')->constrained('esign_contracts')->onDelete('cascade')->comment('계약 ID'); + $table->enum('role', ['creator', 'counterpart'])->comment('서명자 역할'); + $table->tinyInteger('sign_order')->default(1)->comment('서명 순서'); + $table->string('name', 100)->comment('서명자 이름'); + $table->string('email', 255)->comment('서명자 이메일'); + $table->string('phone', 20)->nullable()->comment('서명자 전화번호'); + $table->string('access_token', 128)->unique()->comment('서명 접근 토큰'); + $table->timestamp('token_expires_at')->nullable()->comment('토큰 만료일시'); + $table->string('otp_code', 6)->nullable()->comment('OTP 인증코드'); + $table->timestamp('otp_expires_at')->nullable()->comment('OTP 만료일시'); + $table->tinyInteger('otp_attempts')->default(0)->comment('OTP 시도 횟수'); + $table->timestamp('auth_verified_at')->nullable()->comment('본인인증 완료일시'); + $table->string('signature_image_path', 500)->nullable()->comment('서명 이미지 경로'); + $table->timestamp('signed_at')->nullable()->comment('서명 완료일시'); + $table->timestamp('consent_agreed_at')->nullable()->comment('동의 일시'); + $table->string('sign_ip_address', 45)->nullable()->comment('서명 시 IP 주소'); + $table->string('sign_user_agent', 500)->nullable()->comment('서명 시 User Agent'); + $table->enum('status', ['waiting', 'notified', 'authenticated', 'signed', 'rejected'])->default('waiting')->comment('서명자 상태'); + $table->text('rejected_reason')->nullable()->comment('거절 사유'); + $table->timestamps(); + + $table->index('tenant_id', 'idx_esign_signers_tenant'); + $table->index('contract_id', 'idx_esign_signers_contract'); + $table->index('status', 'idx_esign_signers_status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('esign_signers'); + } +}; diff --git a/database/migrations/2026_02_12_120000_create_esign_sign_fields_table.php b/database/migrations/2026_02_12_120000_create_esign_sign_fields_table.php new file mode 100644 index 0000000..6e724e0 --- /dev/null +++ b/database/migrations/2026_02_12_120000_create_esign_sign_fields_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('tenant_id')->constrained()->onDelete('cascade')->comment('테넌트 ID'); + $table->foreignId('contract_id')->constrained('esign_contracts')->onDelete('cascade')->comment('계약 ID'); + $table->foreignId('signer_id')->constrained('esign_signers')->onDelete('cascade')->comment('서명자 ID'); + $table->unsignedSmallInteger('page_number')->comment('페이지 번호'); + $table->decimal('position_x', 8, 2)->comment('X 좌표 (%)'); + $table->decimal('position_y', 8, 2)->comment('Y 좌표 (%)'); + $table->decimal('width', 8, 2)->comment('너비 (%)'); + $table->decimal('height', 8, 2)->comment('높이 (%)'); + $table->enum('field_type', ['signature', 'stamp', 'text', 'date', 'checkbox'])->default('signature')->comment('필드 유형'); + $table->string('field_label', 100)->nullable()->comment('필드 라벨'); + $table->text('field_value')->nullable()->comment('필드 값'); + $table->boolean('is_required')->default(true)->comment('필수 여부'); + $table->unsignedSmallInteger('sort_order')->default(0)->comment('정렬 순서'); + $table->timestamps(); + + $table->index('contract_id', 'idx_esign_fields_contract'); + $table->index('signer_id', 'idx_esign_fields_signer'); + }); + } + + public function down(): void + { + Schema::dropIfExists('esign_sign_fields'); + } +}; diff --git a/database/migrations/2026_02_12_130000_create_esign_audit_logs_table.php b/database/migrations/2026_02_12_130000_create_esign_audit_logs_table.php new file mode 100644 index 0000000..a8da0de --- /dev/null +++ b/database/migrations/2026_02_12_130000_create_esign_audit_logs_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('tenant_id')->constrained()->onDelete('cascade')->comment('테넌트 ID'); + $table->foreignId('contract_id')->constrained('esign_contracts')->onDelete('cascade')->comment('계약 ID'); + $table->foreignId('signer_id')->nullable()->constrained('esign_signers')->nullOnDelete()->comment('서명자 ID'); + $table->string('action', 50)->comment('액션 (created, sent, viewed, otp_sent, authenticated, signed, rejected, completed, cancelled, reminded)'); + $table->string('ip_address', 45)->nullable()->comment('IP 주소'); + $table->string('user_agent', 500)->nullable()->comment('User Agent'); + $table->json('metadata')->nullable()->comment('추가 메타데이터'); + $table->timestamp('created_at')->useCurrent()->comment('생성일시'); + + $table->index('contract_id', 'idx_esign_audit_contract'); + $table->index(['contract_id', 'action'], 'idx_esign_audit_contract_action'); + }); + } + + public function down(): void + { + Schema::dropIfExists('esign_audit_logs'); + } +}; diff --git a/lang/ko/error.php b/lang/ko/error.php index 13e783e..5d3503a 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -443,6 +443,26 @@ 'invalid_status' => '유효하지 않은 입찰 상태입니다.', ], + // 전자계약 (E-Sign) + 'esign' => [ + 'invalid_token' => '유효하지 않은 서명 링크입니다.', + 'token_expired' => '서명 링크가 만료되었습니다.', + 'contract_not_signable' => '현재 서명할 수 없는 계약입니다.', + 'already_completed' => '이미 완료된 계약입니다.', + 'already_cancelled' => '이미 취소된 계약입니다.', + 'already_signed' => '이미 서명이 완료되었습니다.', + 'invalid_status_for_send' => '초안 상태의 계약만 발송할 수 있습니다.', + 'no_sign_fields' => '서명 필드가 설정되지 않았습니다. 서명 위치를 먼저 설정해 주세요.', + 'cannot_remind' => '현재 상태에서는 리마인더를 발송할 수 없습니다.', + 'fields_only_in_draft' => '초안 상태에서만 서명 위치를 설정할 수 있습니다.', + 'not_verified' => '본인인증을 먼저 완료해 주세요.', + 'otp_max_attempts' => 'OTP 인증 시도 횟수를 초과했습니다. 새로운 인증 코드를 요청해 주세요.', + 'otp_not_sent' => '인증 코드가 발송되지 않았습니다. 먼저 인증 코드를 요청해 주세요.', + 'otp_expired' => '인증 코드가 만료되었습니다. 새로운 인증 코드를 요청해 주세요.', + 'otp_invalid' => '인증 코드가 올바르지 않습니다.', + 'file_not_found' => '계약 문서 파일을 찾을 수 없습니다.', + ], + // 계약 관련 'contract' => [ 'not_found' => '계약을 찾을 수 없습니다.', diff --git a/lang/ko/message.php b/lang/ko/message.php index d26021d..ec881e2 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -550,6 +550,22 @@ 'leave_default' => '휴가', ], + // 전자계약 (E-Sign) + 'esign' => [ + 'created' => '전자계약이 생성되었습니다.', + 'cancelled' => '전자계약이 취소되었습니다.', + 'sent' => '서명 요청이 발송되었습니다.', + 'reminded' => '리마인더가 발송되었습니다.', + 'fields_configured' => '서명 위치가 설정되었습니다.', + 'otp_sent' => '인증 코드가 발송되었습니다.', + 'otp_verified' => '본인인증이 완료되었습니다.', + 'signed' => '서명이 완료되었습니다.', + 'rejected' => '서명이 거절되었습니다.', + 'completed' => '모든 서명이 완료되어 계약이 체결되었습니다.', + 'verified' => '문서 무결성 검증 완료', + 'downloaded' => '문서가 다운로드되었습니다.', + ], + // CEO 대시보드 부가세 현황 'vat' => [ 'sales_tax' => '매출세액', diff --git a/resources/views/emails/esign/request.blade.php b/resources/views/emails/esign/request.blade.php new file mode 100644 index 0000000..fbcee88 --- /dev/null +++ b/resources/views/emails/esign/request.blade.php @@ -0,0 +1,68 @@ + + +
+ + +| + + | +