Переглянути джерело

frefactor: add constraint unique para cpf em students

Gustavo Mantovani 1 день тому
батько
коміт
49a0c6c8e4

+ 10 - 11
app/Http/Requests/StoreStudentRequest.php

@@ -18,19 +18,18 @@ protected function prepareForValidation(): void
 
         $user = Auth::user();
 
-        if (!is_string($token) || !$user instanceof User) {
-            return;
-        }
-
-        $this->registrationDraft = app(StudentRegistrationDraftService::class)
-            ->findOwnedDraft($user, $token);
+        if (is_string($token) && $user instanceof User) {
+            $this->registrationDraft = app(StudentRegistrationDraftService::class)
+                ->findOwnedDraft($user, $token);
 
-        if ($this->registrationDraft !== null) {
-            // Os dados validados do rascunho são a fonte oficial. Assim, o
-            // payload final não consegue sobrescrevê-los sem nova validação.
-
-            $this->merge($this->registrationDraft['data']);
+            if ($this->registrationDraft !== null) {
+                // Os dados validados do rascunho são a fonte oficial. Assim, o
+                // payload final não consegue sobrescrevê-los sem nova validação.
+                $this->merge($this->registrationDraft['data']);
+            }
         }
+
+        parent::prepareForValidation();
     }
 
     public function rules(): array

+ 27 - 16
app/Http/Requests/StudentRegistrationDraftRequest.php

@@ -7,26 +7,37 @@
 
 class StudentRegistrationDraftRequest extends FormRequest
 {
+    protected function prepareForValidation(): void
+    {
+        $documentNumber = $this->input('document_number');
+
+        if (is_string($documentNumber)) {
+            $this->merge([
+                'document_number' => preg_replace('/\D/', '', $documentNumber),
+            ]);
+        }
+    }
+
     public function rules(): array
     {
         return [
             'registration_draft_token' => 'sometimes|nullable|uuid',
-            'name' => 'required|string|max:255',
-            'birth_date' => 'required|date',
-            'document_number' => ['sometimes', 'nullable', 'string', 'max:20', Cpf::rule()],
-            'gender' => 'sometimes|nullable|string|in:no_preference,male,female,other',
-            'email' => 'sometimes|nullable|email|unique:students,email',
-            'phone' => 'sometimes|nullable|string|max:20',
-            'postal_code' => 'sometimes|nullable|string|max:10',
-            'street' => 'sometimes|nullable|string|max:255',
-            'address_number' => 'sometimes|nullable|string|max:20',
-            'neighborhood' => 'sometimes|nullable|string|max:255',
-            'city_id' => 'sometimes|nullable|integer|exists:cities,id',
-            'state_id' => 'sometimes|nullable|integer|exists:states,id',
-            'complement' => 'sometimes|nullable|string|max:255',
-            'payer_name' => 'sometimes|nullable|string|max:255',
-            'how_did_you_know_us' => 'sometimes|nullable|string|in:referral,social_media,google,other',
-            'notes' => 'sometimes|nullable|string',
+            'name'                     => 'required|string|max:255',
+            'birth_date'               => 'required|date',
+            'document_number'          => ['sometimes', 'nullable', 'string', 'max:20', Cpf::rule(), 'unique:students,document_number'],
+            'gender'                   => 'sometimes|nullable|string|in:no_preference,male,female,other',
+            'email'                    => 'sometimes|nullable|email|unique:students,email',
+            'phone'                    => 'sometimes|nullable|string|max:20',
+            'postal_code'              => 'sometimes|nullable|string|max:10',
+            'street'                   => 'sometimes|nullable|string|max:255',
+            'address_number'           => 'sometimes|nullable|string|max:20',
+            'neighborhood'             => 'sometimes|nullable|string|max:255',
+            'city_id'                  => 'sometimes|nullable|integer|exists:cities,id',
+            'state_id'                 => 'sometimes|nullable|integer|exists:states,id',
+            'complement'               => 'sometimes|nullable|string|max:255',
+            'payer_name'               => 'sometimes|nullable|string|max:255',
+            'how_did_you_know_us'      => 'sometimes|nullable|string|in:referral,social_media,google,other',
+            'notes'                    => 'sometimes|nullable|string',
         ];
     }
 }

+ 31 - 1
app/Http/Requests/StudentRequest.php

@@ -11,6 +11,17 @@
 
 class StudentRequest extends FormRequest
 {
+    protected function prepareForValidation(): void
+    {
+        $documentNumber = $this->input('document_number');
+
+        if (is_string($documentNumber)) {
+            $this->merge([
+                'document_number' => preg_replace('/\D/', '', $documentNumber),
+            ]);
+        }
+    }
+
     public function rules(): array
     {
         $rules = [
@@ -35,7 +46,17 @@ public function rules(): array
         if ($this->isMethod('post')) {
             $rules['name']       = 'required|string|max:255';
             $rules['birth_date'] = 'required|date';
-            $rules['email']      = 'sometimes|nullable|email|unique:students,email';
+
+            $rules['document_number'] = [
+                'sometimes',
+                'nullable',
+                'string',
+                'max:20',
+                Cpf::rule(),
+                Rule::unique('students', 'document_number'),
+            ];
+
+            $rules['email'] = 'sometimes|nullable|email|unique:students,email';
 
             $rules['responsible'] = [
                 'nullable',
@@ -60,6 +81,15 @@ public function rules(): array
             $rules['responsible.notes']          = 'sometimes|nullable|string';
         } else {
             $rules['name'] = 'sometimes|string|max:255';
+
+            $rules['document_number'] = [
+                'sometimes',
+                'nullable',
+                'string',
+                'max:20',
+                Cpf::rule(),
+                Rule::unique('students', 'document_number')->ignore($this->route('id')),
+            ];
         }
 
         return $rules;

+ 5 - 1
app/Services/StudentRegistrationDraftService.php

@@ -18,6 +18,7 @@ class StudentRegistrationDraftService
     private const LOCK_SECONDS = 60;
 
     private const UNIQUE_FIELDS = [
+        'document_number',
         'email',
     ];
 
@@ -170,6 +171,8 @@ public function executeWithoutDraft(User $user, array $data, Closure $callback):
         });
     }
 
+    //
+
     private function resolveUnitId(User $user): int
     {
         $activeUnitId = request()->input('active_unit_id');
@@ -231,7 +234,8 @@ private function releaseReservations(array $reservations, string $token): void
     private function validateDatabaseUniques(array $data): void
     {
         Validator::make($data, [
-            'email' => 'sometimes|nullable|unique:students,email',
+            'document_number' => 'sometimes|nullable|unique:students,document_number',
+            'email'           => 'sometimes|nullable|unique:students,email',
         ])->validate();
     }
 

+ 27 - 8
app/Services/StudentService.php

@@ -9,6 +9,7 @@
 use Illuminate\Http\UploadedFile;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Storage;
+use Illuminate\Validation\ValidationException;
 
 class StudentService
 {
@@ -162,19 +163,37 @@ public function update(int $id, array $data): ?Student
 
     public function delete(int $id): bool
     {
-        $model = $this->findById($id);
+        return DB::transaction(function () use ($id): bool {
+            $model = Student::query()->lockForUpdate()->find($id);
 
-        if (!$model) {
-            return false;
-        }
+            if (!$model) {
+                return false;
+            }
 
-        if ($model->photo_url) {
-            Storage::delete($model->photo_url);
-        }
+            $contracts = $model->contracts()
+                ->lockForUpdate()
+                ->get(['id', 'status']);
+
+            if ($contracts->contains('status', 'active')) {
+                throw ValidationException::withMessages([
+                    'student' => __('validation.student_has_active_contracts'),
+                ]);
+            }
 
-        return $model->delete();
+            $photoUrl = $model->photo_url;
+
+            $deleted = $model->delete();
+
+            if ($deleted && $photoUrl) {
+                DB::afterCommit(fn () => Storage::delete($photoUrl));
+            }
+
+            return $deleted;
+        });
     }
 
+    //
+
     private function handlePhoto(array $data, ?string $oldPhotoPath = null): array
     {
         if (!isset($data['avatar'])) {

+ 66 - 0
database/migrations/2026_08_03_194323_add_unique_index_to_students_document_number.php

@@ -0,0 +1,66 @@
+<?php
+
+use App\ValueObjects\Cpf;
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        DB::transaction(function (): void {
+            DB::table('students')
+                ->select(['id', 'document_number'])
+                ->whereNotNull('document_number')
+                ->orderBy('id')
+                ->chunkById(500, function ($students): void {
+                    foreach ($students as $student) {
+                        if (trim((string) $student->document_number) === '') {
+                            DB::table('students')->where('id', $student->id)->update([
+                                'document_number' => null,
+                            ]);
+
+                            continue;
+                        }
+
+                        try {
+                            $cpf = new Cpf((string) $student->document_number);
+                        } catch (InvalidArgumentException $exception) {
+                            throw new RuntimeException(
+                                "O estudante {$student->id} possui um CPF inválido.",
+                                previous: $exception,
+                            );
+                        }
+
+                        DB::table('students')->where('id', $student->id)->update([
+                            'document_number' => $cpf->value(),
+                        ]);
+                    }
+                });
+
+            $duplicatedCpf = DB::table('students')
+                ->select('document_number')
+                ->whereNotNull('document_number')
+                ->groupBy('document_number')
+                ->havingRaw('COUNT(*) > 1')
+                ->value('document_number');
+
+            if ($duplicatedCpf !== null) {
+                throw new RuntimeException("Existem estudantes cadastrados com o CPF duplicado {$duplicatedCpf}.");
+            }
+        });
+
+        Schema::table('students', function (Blueprint $table) {
+            $table->unique('document_number');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('students', function (Blueprint $table) {
+            $table->dropUnique(['document_number']);
+        });
+    }
+};

+ 1 - 0
lang/en/validation.php

@@ -99,6 +99,7 @@
     'minor_requires_responsible' => 'A responsible adult is required for students under 18 years old.',
     'registration_draft_invalid' => 'The registration draft has expired or is invalid.',
     'responsible_must_be_adult' => 'The guardian must be at least 18 years old.',
+    'student_has_active_contracts' => 'A student with active contracts cannot be deleted.',
     'max' => [
         'array' => 'The :attribute field must not have more than :max items.',
         'file' => 'The :attribute field must not be greater than :max kilobytes.',

+ 1 - 0
lang/es/validation.php

@@ -99,6 +99,7 @@
     'minor_requires_responsible' => 'Es obligatorio informar un responsable para estudiantes menores de edad.',
     'registration_draft_invalid' => 'El borrador del registro ha caducado o no es válido.',
     'responsible_must_be_adult' => 'El responsable debe tener al menos 18 años.',
+    'student_has_active_contracts' => 'No se puede eliminar un estudiante que tenga contratos activos.',
     'max' => [
         'array' => 'El campo :attribute no debe tener más de :max elementos.',
         'file' => 'El campo :attribute no debe ser mayor que :max kilobytes.',

+ 1 - 0
lang/pt/validation.php

@@ -100,6 +100,7 @@
     'minor_requires_responsible' => 'É obrigatório informar um responsável para estudantes menores de idade.',
     'registration_draft_invalid' => 'O rascunho do cadastro expirou ou não é válido.',
     'responsible_must_be_adult' => 'O responsável deve ter pelo menos 18 anos.',
+    'student_has_active_contracts' => 'Não é possível excluir um estudante que possui contratos ativos.',
     'max' => [
         'array' => 'O campo :attribute não deve ter mais de :max itens.',
         'file' => 'O campo :attribute não deve ser maior que :max kilobytes.',