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']); }); } }