Explorar o código

feat: obrigatoriedade de cadastro de responsavel caso estudante seja menor de idade

Gustavo Mantovani hai 1 día
pai
achega
eb72b156db

+ 38 - 14
app/Http/Requests/StudentRequest.php

@@ -3,7 +3,10 @@
 namespace App\Http\Requests;
 
 use App\ValueObjects\Cpf;
+use Carbon\Carbon;
 use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+use Throwable;
 
 class StudentRequest extends FormRequest
 {
@@ -27,31 +30,52 @@ public function rules(): array
             'how_did_you_know_us' => 'sometimes|nullable|string|in:referral,social_media,google,other',
             'notes'               => 'sometimes|nullable|string',
 
-            'responsible'                   => 'sometimes|nullable|array',
-            'responsible.name'              => 'sometimes|nullable|string|max:255',
-            'responsible.birth_date'        => 'sometimes|nullable|date',
-            'responsible.cpf'               => ['sometimes', 'nullable', 'string', 'max:20', Cpf::rule()],
+            'responsible' => [
+                'nullable',
+                'array',
+                Rule::requiredIf(fn (): bool => $this->studentIsMinor()),
+            ],
+
+            'responsible.name'              => 'required_with:responsible|string|max:255',
+            'responsible.birth_date'        => 'required_with:responsible|date',
+            'responsible.cpf'               => ['required_with:responsible', 'string', 'max:20', Cpf::rule()],
             'responsible.gender'            => 'sometimes|nullable|string|in:no_preference,male,female,other',
-            'responsible.degree'            => 'sometimes|nullable|string|max:255',
-            'responsible.email'             => 'sometimes|nullable|email',
-            'responsible.phone'             => 'sometimes|nullable|string|max:20',
-            'responsible.postal_code'       => 'sometimes|nullable|string|max:10',
-            'responsible.street'            => 'sometimes|nullable|string|max:255',
+            'responsible.degree'            => 'required_with:responsible|string|max:255',
+            'responsible.email'             => 'required_with:responsible|email|max:255',
+            'responsible.phone'             => 'required_with:responsible|string|max:20',
+            'responsible.postal_code'       => 'required_with:responsible|string|max:10',
+            'responsible.street'            => 'required_with:responsible|string|max:255',
             'responsible.address_number'    => 'sometimes|nullable|string|max:20',
-            'responsible.neighborhood'      => 'sometimes|nullable|string|max:255',
-            'responsible.city_id'           => 'sometimes|nullable|integer|exists:cities,id',
-            'responsible.state_id'          => 'sometimes|nullable|integer|exists:states,id',
+            'responsible.neighborhood'      => 'required_with:responsible|string|max:255',
+            'responsible.city_id'           => 'required_with:responsible|integer|exists:cities,id',
+            'responsible.state_id'          => 'required_with:responsible|integer|exists:states,id',
             'responsible.complement'        => 'sometimes|nullable|string|max:255',
             'responsible.notes'             => 'sometimes|nullable|string',
         ];
 
         if ($this->isMethod('post')) {
-            $rules['name']  = 'required|string|max:255';
-            $rules['email'] = 'sometimes|nullable|email|unique:students,email';
+            $rules['name']       = 'required|string|max:255';
+            $rules['birth_date'] = 'required|date';
+            $rules['email']      = 'sometimes|nullable|email|unique:students,email';
         } else {
             $rules['name'] = 'sometimes|string|max:255';
         }
 
         return $rules;
     }
+
+    private function studentIsMinor(): bool
+    {
+        $birthDate = $this->input('birth_date');
+
+        if (!is_string($birthDate) || $birthDate === '') {
+            return false;
+        }
+
+        try {
+            return Carbon::parse($birthDate)->age < 18;
+        } catch (Throwable) {
+            return false;
+        }
+    }
 }

+ 33 - 0
app/Models/Student.php

@@ -7,6 +7,7 @@
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Eloquent\SoftDeletes;
+use Illuminate\Validation\ValidationException;
 
 /**
  * @property int $id
@@ -35,6 +36,8 @@
  * @property-read \App\Models\City|null $city
  * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\StudentContract> $contracts
  * @property-read int|null $contracts_count
+ * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\StudentResponsible> $responsibles
+ * @property-read int|null $responsibles_count
  * @property-read \App\Models\State|null $state
  * @property-read \App\Models\Unit $unit
  * @method static \Illuminate\Database\Eloquent\Builder<static>|Student newModelQuery()
@@ -72,6 +75,8 @@ class Student extends Model
 {
     use HasFactory, SoftDeletes;
 
+    private ?array $responsibleForCreation = null;
+
     protected $table = 'students';
 
     protected $guarded = ['id'];
@@ -83,11 +88,39 @@ class Student extends Model
         'deleted_at' => 'datetime',
     ];
 
+    protected static function booted(): void
+    {
+        static::creating(function (Student $student): void {
+            if ($student->isMinor() && empty($student->responsibleForCreation)) {
+                throw ValidationException::withMessages([
+                    'responsible' => __('validation.minor_requires_responsible'),
+                ]);
+            }
+        });
+    }
+
+    public function withResponsibleForCreation(?array $responsible): self
+    {
+        $this->responsibleForCreation = $responsible;
+
+        return $this;
+    }
+
+    public function isMinor(): bool
+    {
+        return $this->birth_date !== null && $this->birth_date->age < 18;
+    }
+
     public function contracts(): HasMany
     {
         return $this->hasMany(StudentContract::class);
     }
 
+    public function responsibles(): HasMany
+    {
+        return $this->hasMany(StudentResponsible::class);
+    }
+
     public function unit(): BelongsTo
     {
         return $this->belongsTo(Unit::class, 'unit_id');

+ 1 - 1
app/Models/Unit.php

@@ -16,7 +16,7 @@
  * @property string|null $fantasy_name
  * @property string $social_reason
  * @property \App\ValueObjects\Cnpj $cnpj
- * @property string $phone_number
+ * @property string|null $phone_number
  * @property string|null $cell_number
  * @property string $street
  * @property string|null $address_number

+ 29 - 1
app/Services/StudentService.php

@@ -7,6 +7,7 @@
 use App\Models\User;
 use Illuminate\Database\Eloquent\Collection;
 use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Storage;
 
 class StudentService
@@ -14,7 +15,9 @@ class StudentService
     public function getAll(User $user, array $filters = []): Collection
     {
         $unitId = $this->resolveUnitId($user);
+
         $startDate = $filters['contract_start_date'] ?? null;
+
         $endDate = $filters['contract_end_date'] ?? null;
 
         return Student::where('unit_id', $unitId)
@@ -87,9 +90,26 @@ public function findById(int $id): ?Student
     public function create(User $user, array $data): Student
     {
         $unitId = $this->resolveUnitId($user);
+
+        $responsibleData = $data['responsible'] ?? null;
+
+        unset($data['responsible']);
+
         $data = $this->handlePhoto($data);
 
-        return Student::create(array_merge($data, ['unit_id' => $unitId]));
+        return DB::transaction(function () use ($data, $responsibleData, $unitId): Student {
+            $student = (new Student)
+                ->fill(array_merge($data, ['unit_id' => $unitId]))
+                ->withResponsibleForCreation($responsibleData);
+
+            $student->save();
+
+            if ($responsibleData !== null) {
+                $student->responsibles()->create($responsibleData);
+            }
+
+            return $student->load('responsibles');
+        });
     }
 
     public function update(int $id, array $data): ?Student
@@ -101,6 +121,7 @@ public function update(int $id, array $data): ?Student
         }
 
         $data = $this->handlePhoto($data, $model->photo_url);
+
         $model->update($data);
 
         return $model->fresh();
@@ -131,15 +152,18 @@ private function handlePhoto(array $data, ?string $oldPhotoPath = null): array
             if ($oldPhotoPath) {
                 Storage::delete($oldPhotoPath);
             }
+
             $data['photo_url'] = $data['avatar']->store('students/photos');
         } elseif (is_null($data['avatar'])) {
             if ($oldPhotoPath) {
                 Storage::delete($oldPhotoPath);
             }
+
             $data['photo_url'] = null;
         }
 
         unset($data['avatar']);
+
         return $data;
     }
 
@@ -149,12 +173,16 @@ private function resolveUnitId(User $user): int
 
         if ($activeUnitId) {
             $unit = $user->units()->where('units.id', $activeUnitId)->first();
+
             abort_if(!$unit, 403, 'Unidade não autorizada para este usuário.');
+
             return $unit->id;
         }
 
         $unit = $user->units()->first();
+
         abort_if(!$unit, 403, 'Usuário sem unidade associada.');
+
         return $unit->id;
     }
 }

+ 4 - 0
app/ValueObjects/Cnpj.php

@@ -78,7 +78,9 @@ private static function isValid(string $value): bool
         }
 
         $base = substr($value, 0, 12);
+
         $firstDigit = self::checkDigit($base);
+
         $secondDigit = self::checkDigit($base.$firstDigit);
 
         return hash_equals($base.$firstDigit.$secondDigit, $value);
@@ -87,10 +89,12 @@ private static function isValid(string $value): bool
     private static function checkDigit(string $value): int
     {
         $sum = 0;
+
         $weight = 2;
 
         for ($index = strlen($value) - 1; $index >= 0; $index--) {
             $sum += (ord($value[$index]) - 48) * $weight;
+
             $weight = $weight === 9 ? 2 : $weight + 1;
         }
 

+ 17 - 0
lang/en/validation.php

@@ -96,6 +96,7 @@
         'string' => 'The :attribute field must be less than or equal to :value characters.',
     ],
     'mac_address' => 'The :attribute field must be a valid MAC address.',
+    'minor_requires_responsible' => 'A responsible adult is required for students under 18 years old.',
     'max' => [
         'array' => 'The :attribute field must not have more than :max items.',
         'file' => 'The :attribute field must not be greater than :max kilobytes.',
@@ -223,6 +224,22 @@
         'avatar' => 'Logo',
         'contracts' => 'Contracts',
         'contracts.*' => 'Contract',
+        'responsible' => 'Guardian',
+        'responsible.name' => 'Guardian’s Name',
+        'responsible.birth_date' => 'Guardian’s Date of Birth',
+        'responsible.cpf' => 'Guardian’s CPF',
+        'responsible.gender' => 'Guardian’s Gender',
+        'responsible.degree' => 'Relationship to the Student',
+        'responsible.email' => 'Guardian’s Email',
+        'responsible.phone' => 'Guardian’s Phone',
+        'responsible.postal_code' => 'Guardian’s Postal Code',
+        'responsible.street' => 'Guardian’s Street',
+        'responsible.address_number' => 'Guardian’s Address Number',
+        'responsible.neighborhood' => 'Guardian’s Neighborhood',
+        'responsible.city_id' => 'Guardian’s City',
+        'responsible.state_id' => 'Guardian’s State',
+        'responsible.complement' => 'Guardian’s Address Complement',
+        'responsible.notes' => 'Guardian’s Notes',
         'partners' => 'Partners',
         'partners.*.name' => 'Partner’s Name',
         'partners.*.cpf' => 'Partner’s CPF',

+ 17 - 0
lang/es/validation.php

@@ -96,6 +96,7 @@
         'string' => 'El campo :attribute debe ser menor o igual a :value caracteres.',
     ],
     'mac_address' => 'El campo :attribute debe ser una dirección MAC válida.',
+    'minor_requires_responsible' => 'Es obligatorio informar un responsable para estudiantes menores de edad.',
     'max' => [
         'array' => 'El campo :attribute no debe tener más de :max elementos.',
         'file' => 'El campo :attribute no debe ser mayor que :max kilobytes.',
@@ -223,6 +224,22 @@
         'avatar' => 'Logotipo',
         'contracts' => 'Contratos',
         'contracts.*' => 'Contrato',
+        'responsible' => 'Responsable',
+        'responsible.name' => 'Nombre del Responsable',
+        'responsible.birth_date' => 'Fecha de Nacimiento del Responsable',
+        'responsible.cpf' => 'CPF del Responsable',
+        'responsible.gender' => 'Género del Responsable',
+        'responsible.degree' => 'Grado de Parentesco',
+        'responsible.email' => 'Correo Electrónico del Responsable',
+        'responsible.phone' => 'Teléfono del Responsable',
+        'responsible.postal_code' => 'Código Postal del Responsable',
+        'responsible.street' => 'Calle del Responsable',
+        'responsible.address_number' => 'Número del Responsable',
+        'responsible.neighborhood' => 'Barrio del Responsable',
+        'responsible.city_id' => 'Ciudad del Responsable',
+        'responsible.state_id' => 'Estado del Responsable',
+        'responsible.complement' => 'Complemento de Dirección del Responsable',
+        'responsible.notes' => 'Observaciones del Responsable',
         'partners' => 'Socios',
         'partners.*.name' => 'Nombre del Socio',
         'partners.*.cpf' => 'CPF del Socio',

+ 17 - 0
lang/pt/validation.php

@@ -97,6 +97,7 @@
         'string' => 'O campo :attribute deve ser menor ou igual a :value caracteres.',
     ],
     'mac_address' => 'O campo :attribute deve ser um endereço MAC válido.',
+    'minor_requires_responsible' => 'É obrigatório informar um responsável para estudantes menores de idade.',
     '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.',
@@ -224,6 +225,22 @@
         'avatar' => 'Logotipo',
         'contracts' => 'Contratos',
         'contracts.*' => 'Contrato',
+        'responsible' => 'Responsável',
+        'responsible.name' => 'Nome do Responsável',
+        'responsible.birth_date' => 'Data de Nascimento do Responsável',
+        'responsible.cpf' => 'CPF do Responsável',
+        'responsible.gender' => 'Gênero do Responsável',
+        'responsible.degree' => 'Grau de Parentesco',
+        'responsible.email' => 'E-mail do Responsável',
+        'responsible.phone' => 'Telefone do Responsável',
+        'responsible.postal_code' => 'CEP do Responsável',
+        'responsible.street' => 'Rua do Responsável',
+        'responsible.address_number' => 'Número do Responsável',
+        'responsible.neighborhood' => 'Bairro do Responsável',
+        'responsible.city_id' => 'Cidade do Responsável',
+        'responsible.state_id' => 'Estado do Responsável',
+        'responsible.complement' => 'Complemento do Responsável',
+        'responsible.notes' => 'Observações do Responsável',
         'partners' => 'Sócios',
         'partners.*.name' => 'Nome do Sócio',
         'partners.*.cpf' => 'CPF do Sócio',

+ 55 - 0
tests/Unit/Models/StudentTest.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace Tests\Unit\Models;
+
+use App\Models\Student;
+use Illuminate\Validation\ValidationException;
+use Tests\TestCase;
+
+class StudentTest extends TestCase
+{
+    public function test_it_rejects_a_minor_without_a_responsible_when_creating(): void
+    {
+        app()->setLocale('pt');
+
+        $student = new TestableStudent;
+
+        $student->birth_date = now()->subYears(10)->toDateString();
+
+        $this->expectException(ValidationException::class);
+        $this->expectExceptionMessage('É obrigatório informar um responsável para estudantes menores de idade.');
+
+        $student->fireCreatingEvent();
+    }
+
+    public function test_it_allows_a_minor_with_a_responsible_when_creating(): void
+    {
+        $student = (new TestableStudent)
+            ->withResponsibleForCreation(['name' => 'Responsável']);
+
+        $student->birth_date = now()->subYears(10)->toDateString();
+
+        $student->fireCreatingEvent();
+
+        $this->assertTrue($student->isMinor());
+    }
+
+    public function test_it_allows_an_adult_without_a_responsible_when_creating(): void
+    {
+        $student = new TestableStudent;
+
+        $student->birth_date = now()->subYears(18)->toDateString();
+
+        $student->fireCreatingEvent();
+
+        $this->assertFalse($student->isMinor());
+    }
+}
+
+class TestableStudent extends Student
+{
+    public function fireCreatingEvent(): mixed
+    {
+        return $this->fireModelEvent('creating');
+    }
+}

+ 1 - 0
tests/Unit/ValueObjects/CnpjTest.php

@@ -45,6 +45,7 @@ public function test_its_request_rule_validates_cnpj(): void
             ['cnpj' => '12.ABC.345/01DE-35'],
             ['cnpj' => ['required', Cnpj::rule()]],
         );
+
         $invalid = Validator::make(
             ['cnpj' => '12.ABC.345/01DE-36'],
             ['cnpj' => ['required', Cnpj::rule()]],