Просмотр исходного кода

feat: add track do historico dos contratos do estudante

Gustavo Mantovani 1 день назад
Родитель
Сommit
56d71e2cda

+ 2 - 2
app/Http/Controllers/FranchiseeDashboardController.php

@@ -14,10 +14,10 @@ class FranchiseeDashboardController extends Controller
     public function summary(): JsonResponse
     {
         $unitId = $this->activeUnitId();
-        abort_if(!$unitId, 404, 'Unidade não encontrada.');
+        abort_if(! $unitId, 404, 'Unidade não encontrada.');
 
         $students = Student::where('unit_id', $unitId)->get(['id', 'name', 'birth_date', 'created_at']);
-        $contracts = StudentContract::where('unit_id', $unitId)->get(['id', 'status']);
+        $contracts = StudentContract::latestVersion()->where('unit_id', $unitId)->get(['id', 'status']);
 
         return $this->successResponse(payload: [
             'students_count' => $students->count(),

+ 73 - 38
app/Http/Controllers/StudentContractController.php

@@ -3,13 +3,14 @@
 namespace App\Http\Controllers;
 
 use App\Http\Controllers\Concerns\ResolvesActiveUnit;
-use App\Models\UnitFinancial;
-use App\Services\StudentContractService;
 use App\Http\Requests\StudentContractRequest;
-use App\Http\Resources\StudentContractResource;
 use App\Http\Resources\StudentContractInstallmentResource;
+use App\Http\Resources\StudentContractResource;
+use App\Models\UnitFinancial;
+use App\Services\StudentContractService;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
 
 class StudentContractController extends Controller
 {
@@ -19,67 +20,101 @@ public function __construct(
         protected StudentContractService $service,
     ) {}
 
-    public function franchisorSummary(): JsonResponse
-    {
-        $unitIds = array_filter((array) request()->input('unit_ids', []));
-        return $this->successResponse(payload: $this->service->getFranchisorSummary($unitIds));
-    }
-
-    public function franchisorFrozen(): JsonResponse
-    {
-        $unitIds = array_filter((array) request()->input('unit_ids', []));
-        $items   = $this->service->getFranchisorByStatus('frozen', $unitIds);
-        return $this->successResponse(payload: StudentContractResource::collection($items));
-    }
-
-    public function franchisorCancelled(): JsonResponse
-    {
-        $unitIds = array_filter((array) request()->input('unit_ids', []));
-        $items   = $this->service->getFranchisorByStatus('cancelled', $unitIds);
-        return $this->successResponse(payload: StudentContractResource::collection($items));
-    }
-
     public function index(): JsonResponse
     {
-        $unitId    = $this->activeUnitId();
+        $unitId = $this->activeUnitId();
         $studentId = request()->integer('student_id') ?: null;
-        $items     = $this->service->getAll($unitId, $studentId);
+        $items = $this->service->getAll($unitId, $studentId);
+
         return $this->successResponse(payload: StudentContractResource::collection($items));
     }
 
     public function store(StudentContractRequest $request): JsonResponse
     {
         $unitId = $this->activeUnitId();
-        $data   = array_merge($request->validated(), ['unit_id' => $unitId]);
-        $item   = $this->service->create($data);
+        $data = array_merge($request->validated(), ['unit_id' => $unitId]);
+        $item = $this->service->create($data);
+
         return $this->successResponse(payload: new StudentContractResource($item), message: __('messages.created'), code: 201);
     }
 
     public function show(int $id): JsonResponse
     {
         $item = $this->service->findById($id);
+
         return $this->successResponse(payload: new StudentContractResource($item));
     }
 
     public function update(StudentContractRequest $request, int $id): JsonResponse
     {
-        $item = $this->service->update($id, $request->validated());
+        $item = $this->service->update($id, $request->validated(), Auth::id());
+
         return $this->successResponse(payload: new StudentContractResource($item), message: __('messages.updated'));
     }
 
+    public function destroy(int $id): JsonResponse
+    {
+        $this->service->delete($id);
+
+        return $this->successResponse(message: __('messages.deleted'), code: 204);
+    }
+
+    //
+
+    public function franchisorSummary(): JsonResponse
+    {
+        $unitIds = array_filter((array) request()->input('unit_ids', []));
+
+        return $this->successResponse(payload: $this->service->getFranchisorSummary($unitIds));
+    }
+
+    public function franchisorCancelled(): JsonResponse
+    {
+        $unitIds = array_filter((array) request()->input('unit_ids', []));
+
+        $items = $this->service->getFranchisorByStatus('cancelled', $unitIds);
+
+        return $this->successResponse(payload: StudentContractResource::collection($items));
+    }
+
+    public function franchisorFrozen(): JsonResponse
+    {
+        $unitIds = array_filter((array) request()->input('unit_ids', []));
+
+        $items = $this->service->getFranchisorByStatus('frozen', $unitIds);
+
+        return $this->successResponse(payload: StudentContractResource::collection($items));
+    }
+
+    //
+
     public function attachFile(Request $request, int $id): JsonResponse
     {
         $request->validate(['file' => 'required|file|mimes:jpeg,png,gif,pdf,mp4,mov,avi|max:20480']);
         $item = $this->service->attachFile($id, $request->file('file'));
+
         return $this->successResponse(payload: new StudentContractResource($item), message: __('messages.updated'));
     }
 
+    public function attachSignedFile(Request $request, int $id): JsonResponse
+    {
+        $request->validate(['file' => 'required|file|mimes:jpeg,png,gif,pdf,mp4,mov,avi|max:20480']);
+        $item = $this->service->attachFile($id, $request->file('file'), signed: true);
+
+        return $this->successResponse(payload: new StudentContractResource($item), message: __('messages.updated'));
+    }
+
+   //
+
     public function installments(int $id): JsonResponse
     {
         $items = $this->service->getInstallments($id);
+
         return $this->successResponse(payload: StudentContractInstallmentResource::collection($items));
     }
 
+    //
+
     public function freeze(int $id): JsonResponse
     {
         $months = request()->integer('months', 0);
@@ -89,11 +124,12 @@ public function freeze(int $id): JsonResponse
         }
 
         $contract = $this->service->findById($id);
-        if (!$contract) {
+        if (! $contract) {
             return $this->errorResponse(message: 'Contrato não encontrado.', code: 404);
         }
 
         $financial = UnitFinancial::where('unit_id', $contract->unit_id)->first();
+
         if ($financial && $financial->max_freeze_count !== null && $months > $financial->max_freeze_count) {
             return $this->errorResponse(
                 message: "Não é possível trancar por mais de {$financial->max_freeze_count} mês(es).",
@@ -101,25 +137,24 @@ public function freeze(int $id): JsonResponse
             );
         }
 
-        $item = $this->service->freeze($id, $months);
+        $item = $this->service->freeze($id, $months, Auth::id());
+
         return $this->successResponse(payload: new StudentContractResource($item), message: __('messages.updated'));
     }
 
+    //
+
     public function cancel(int $id): JsonResponse
     {
-        $item = $this->service->cancel($id);
+        $item = $this->service->cancel($id, Auth::id());
+
         return $this->successResponse(payload: new StudentContractResource($item), message: __('messages.updated'));
     }
 
     public function reactivate(int $id): JsonResponse
     {
-        $item = $this->service->reactivate($id);
-        return $this->successResponse(payload: new StudentContractResource($item), message: __('messages.updated'));
-    }
+        $item = $this->service->reactivate($id, Auth::id());
 
-    public function destroy(int $id): JsonResponse
-    {
-        $this->service->delete($id);
-        return $this->successResponse(message: __('messages.deleted'), code: 204);
+        return $this->successResponse(payload: new StudentContractResource($item), message: __('messages.updated'));
     }
 }

+ 27 - 0
app/Http/Requests/StudentContractRequest.php

@@ -2,7 +2,9 @@
 
 namespace App\Http\Requests;
 
+use Carbon\CarbonImmutable;
 use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Validator;
 
 class StudentContractRequest extends FormRequest
 {
@@ -34,4 +36,29 @@ public function rules(): array
             'fine_cancelled'           => 'sometimes|nullable|numeric|min:0|max:100',
         ];
     }
+
+    public function after(): array
+    {
+        return [
+            function (Validator $validator): void {
+                if (
+                    ! $this->isMethod('POST')
+                    || ! $this->filled(['signature_date', 'end_date'])
+                    || $validator->errors()->hasAny(['signature_date', 'end_date'])
+                ) {
+                    return;
+                }
+
+                $signatureDate = CarbonImmutable::createFromFormat('!Y-m-d', $this->string('signature_date')->toString());
+                $endDate = CarbonImmutable::createFromFormat('!Y-m-d', $this->string('end_date')->toString());
+
+                if ($endDate->greaterThan($signatureDate->addYearNoOverflow())) {
+                    $validator->errors()->add(
+                        'end_date',
+                        __('validation.student_contract_max_duration'),
+                    );
+                }
+            },
+        ];
+    }
 }

+ 62 - 46
app/Http/Resources/StudentContractResource.php

@@ -2,69 +2,85 @@
 
 namespace App\Http\Resources;
 
+use App\Models\StudentContract;
 use Carbon\Carbon;
 use Illuminate\Http\Request;
-use Illuminate\Http\Resources\Json\JsonResource;
 use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
+use Illuminate\Http\Resources\Json\JsonResource;
 use Illuminate\Support\Facades\Storage;
-use App\Models\StudentContract;
 
 class StudentContractResource extends JsonResource
 {
     public function toArray(Request $request): array
     {
         return [
-            'id'                      => $this->id,
-            'protocol'                => $this->protocol,
-            'student_id'              => $this->student_id,
-            'unit_id'                 => $this->unit_id,
-            'class_package_unit_id'   => $this->class_package_unit_id,
-            'signature_date'          => $this->signature_date ? Carbon::parse($this->signature_date)->format('d/m/Y') : null,
-            'end_date'                => $this->end_date ? Carbon::parse($this->end_date)->format('d/m/Y') : null,
-            'class_quantity'          => $this->class_quantity,
-            'weekday'                 => $this->weekday,
-            'start_time'              => $this->start_time,
-            'end_time'                => $this->end_time,
-            'second_weekday'          => $this->second_weekday,
-            'second_start_time'       => $this->second_start_time,
-            'second_end_time'         => $this->second_end_time,
-            'due_day'                 => $this->recurring_day,
-            'tax_register'            => $this->tax_register,
-            'installments'            => $this->installments,
-            'enrollment_due_date'     => $this->enrollment_due_date
-                                            ? Carbon::parse($this->enrollment_due_date)->format('d/m/Y')
-                                            : null,
-            'package_value'           => $this->package_value,
-            'package_installments'    => $this->package_installments,
-            'package_due_date'        => $this->package_due_date
-                                            ? Carbon::parse($this->package_due_date)->format('d/m/Y')
-                                            : null,
-            'early_payment_discount'  => $this->early_payment_discount,
-            'interest_rate'           => $this->interest_rate,
-            'payment_method'          => $this->payment_method,
-            'fine_cancelled'          => $this->fine_cancelled,
-            'status'                  => $this->status,
-            'package_name'            => $this->whenLoaded('classPackageUnit', fn () => $this->classPackageUnit?->name),
-            'unit_name'               => $this->whenLoaded('unit', fn () => $this->unit?->fantasy_name),
-            'student_name'            => $this->whenLoaded('student', fn () => $this->student->name),
-            'student_document'        => $this->whenLoaded('student', fn () => $this->student->document_number),
-            'student_birth_date'      => $this->whenLoaded('student', fn () => $this->student->birth_date?->format('d/m/Y')),
-            'student_phone'           => $this->whenLoaded('student', fn () => $this->student->phone),
-            'student_city'            => $this->whenLoaded('student', fn () => $this->student->city?->name),
-            'student_state_code'      => $this->whenLoaded('student', fn () => $this->student->state?->code),
-            'file_url'                => $this->file_url ? Storage::url($this->file_url) : null,
-            'file_type'               => $this->file_type,
-            'created_at'              => Carbon::parse($this->created_at)->format('Y-m-d H:i:s'),
-            'updated_at'              => Carbon::parse($this->updated_at)->format('Y-m-d H:i:s'),
+            'id'                       => $this->id,
+            'version'                  => $this->version,
+            'derived_from_contract_id' => $this->derived_from_contract_id,
+            'created_by_user_id'       => $this->created_by_user_id,
+            'created_by_user_name'     => $this->whenLoaded('createdBy', fn () => $this->createdBy?->name),
+            'protocol'                 => $this->protocol,
+            'student_id'               => $this->student_id,
+            'unit_id'                  => $this->unit_id,
+            'class_package_unit_id'    => $this->class_package_unit_id,
+            'signature_date'           => $this->signature_date ? Carbon::parse($this->signature_date)->format('d/m/Y') : null,
+            'end_date'                 => $this->end_date ? Carbon::parse($this->end_date)->format('d/m/Y') : null,
+            'class_quantity'           => $this->class_quantity,
+            'weekday'                  => $this->weekday,
+            'start_time'               => $this->start_time,
+            'end_time'                 => $this->end_time,
+            'second_weekday'           => $this->second_weekday,
+            'second_start_time'        => $this->second_start_time,
+            'second_end_time'          => $this->second_end_time,
+            'due_day'                  => $this->recurring_day,
+            'tax_register'             => $this->tax_register,
+            'installments'             => $this->installments,
+
+            'enrollment_due_date' => $this->enrollment_due_date
+                ? Carbon::parse($this->enrollment_due_date)->format('d/m/Y')
+                : null,
+
+            'package_value'        => $this->package_value,
+            'package_installments' => $this->package_installments,
+
+            'package_due_date' => $this->package_due_date
+                ? Carbon::parse($this->package_due_date)->format('d/m/Y')
+                : null,
+
+            'early_payment_discount' => $this->early_payment_discount,
+            'interest_rate'          => $this->interest_rate,
+            'payment_method'         => $this->payment_method,
+            'fine_cancelled'         => $this->fine_cancelled,
+            'status'                 => $this->status,
+            'package_name'           => $this->whenLoaded('classPackageUnit', fn () => $this->classPackageUnit?->name),
+            'unit_name'              => $this->whenLoaded('unit', fn () => $this->unit?->fantasy_name),
+            'student_name'           => $this->whenLoaded('student', fn () => $this->student->name),
+            'student_document'       => $this->whenLoaded('student', fn () => $this->student->document_number),
+            'student_birth_date'     => $this->whenLoaded('student', fn () => $this->student->birth_date?->format('d/m/Y')),
+            'student_phone'          => $this->whenLoaded('student', fn () => $this->student->phone),
+            'student_city'           => $this->whenLoaded('student', fn () => $this->student->city?->name),
+            'student_state_code'     => $this->whenLoaded('student', fn () => $this->student->state?->code),
+            'file_url'               => $this->file_url ? Storage::url($this->file_url) : null,
+            'file_type'              => $this->file_type,
+            'signed_file_url'        => $this->signed_file_url ? Storage::url($this->signed_file_url) : null,
+            'signed_file_type'       => $this->signed_file_type,
+
+            'version_history' => $this->whenLoaded(
+                'versionHistory',
+                fn () => self::collection($this->versionHistory),
+            ),
+
+            'created_at' => Carbon::parse($this->created_at)->format('Y-m-d H:i:s'),
+            'updated_at' => Carbon::parse($this->updated_at)->format('Y-m-d H:i:s'),
         ];
     }
 
     /**
-     * @param \Illuminate\Database\Eloquent\Collection<StudentContract> $resource
+     * @param  \Illuminate\Database\Eloquent\Collection<StudentContract>  $resource
      * @return \Illuminate\Http\Resources\Json\AnonymousResourceCollection<StudentContractResource>
      */
     public static function collection($resource): AnonymousResourceCollection
     {
         return parent::collection($resource);
     }
-}
+}

+ 2 - 2
app/Models/ClassPackage.php

@@ -13,9 +13,9 @@
  * @property int $id
  * @property string $name
  * @property int $quantity_classes
- * @property float $contract_value
+ * @property float|null $contract_value
  * @property float $contract_material_value
- * @property float $contract_register_value
+ * @property float|null $contract_register_value
  * @property float|null $contrat_discount_value
  * @property \Illuminate\Support\Carbon|null $created_at
  * @property \Illuminate\Support\Carbon|null $updated_at

+ 2 - 2
app/Models/ClassPackageUnit.php

@@ -14,9 +14,9 @@
  * @property int|null $class_package_id
  * @property string $name
  * @property int $quantity_classes
- * @property float $contract_value
+ * @property float|null $contract_value
  * @property float $contract_material_value
- * @property float $contract_register_value
+ * @property float|null $contract_register_value
  * @property float|null $contrat_discount_value
  * @property bool $visible
  * @property \Illuminate\Support\Carbon|null $created_at

+ 17 - 4
app/Models/Student.php

@@ -83,11 +83,11 @@ class Student extends Model
     protected $guarded = ['id'];
 
     protected $casts = [
-        'birth_date'      => 'date',
+        'birth_date' => 'date',
         'document_number' => Cpf::class,
-        'created_at'      => 'datetime',
-        'updated_at'      => 'datetime',
-        'deleted_at'      => 'datetime',
+        'created_at' => 'datetime',
+        'updated_at' => 'datetime',
+        'deleted_at' => 'datetime',
     ];
 
     protected static function booted(): void
@@ -99,6 +99,14 @@ protected static function booted(): void
                 ]);
             }
         });
+
+        static::deleting(function (Student $student): void {
+            if ($student->hasActiveContract()) {
+                throw ValidationException::withMessages([
+                    'student' => __('validation.student_has_active_contracts'),
+                ]);
+            }
+        });
     }
 
     public function withResponsibleForCreation(?array $responsible): self
@@ -113,6 +121,11 @@ public function isMinor(): bool
         return $this->birth_date !== null && $this->birth_date->age < 18;
     }
 
+    public function hasActiveContract(): bool
+    {
+        return $this->contracts()->latestVersion()->where('status', 'active')->exists();
+    }
+
     public function contracts(): HasMany
     {
         return $this->hasMany(StudentContract::class);

+ 84 - 45
app/Models/StudentContract.php

@@ -2,12 +2,12 @@
 
 namespace App\Models;
 
+use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
-use Illuminate\Database\Eloquent\SoftDeletes;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\HasMany;
-
+use Illuminate\Database\Eloquent\SoftDeletes;
 
 /**
  * @property int $id
@@ -42,50 +42,65 @@
  * @property string|null $package_value
  * @property int|null $package_installments
  * @property \Illuminate\Support\Carbon|null $package_due_date
+ * @property int $version
+ * @property int|null $derived_from_contract_id
+ * @property int|null $created_by_user_id
+ * @property string|null $signed_file_url
+ * @property string|null $signed_file_type
  * @property-read \App\Models\ClassPackageUnit|null $classPackageUnit
+ * @property-read \App\Models\User|null $createdBy
+ * @property-read StudentContract|null $derivedFrom
+ * @property-read \Illuminate\Database\Eloquent\Collection<int, StudentContract> $derivedVersions
+ * @property-read int|null $derived_versions_count
  * @property-read int|null $installments_count
  * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\StudentMedia> $medias
  * @property-read int|null $medias_count
  * @property-read \App\Models\Student $student
  * @property-read \App\Models\Unit $unit
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract newModelQuery()
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract newQuery()
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract onlyTrashed()
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract query()
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereClassPackageUnitId($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereClassQuantity($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereCreatedAt($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereDeletedAt($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereEarlyPaymentDiscount($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereEndDate($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereEndTime($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereEnrollmentDueDate($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereFileType($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereFileUrl($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereFineCancelled($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereId($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereInstallments($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereInterestRate($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereNotes($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract wherePackageDueDate($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract wherePackageInstallments($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract wherePackageValue($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract wherePaymentMethod($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereProtocol($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereRecurringDay($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereSecondEndTime($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereSecondStartTime($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereSecondWeekday($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereSignatureDate($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereStartTime($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereStatus($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereStudentId($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereTaxRegister($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereUnitId($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereUpdatedAt($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract whereWeekday($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract withTrashed(bool $withTrashed = true)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|StudentContract withoutTrashed()
+ * @method static Builder<static>|StudentContract latestVersion()
+ * @method static Builder<static>|StudentContract newModelQuery()
+ * @method static Builder<static>|StudentContract newQuery()
+ * @method static Builder<static>|StudentContract onlyTrashed()
+ * @method static Builder<static>|StudentContract query()
+ * @method static Builder<static>|StudentContract whereClassPackageUnitId($value)
+ * @method static Builder<static>|StudentContract whereClassQuantity($value)
+ * @method static Builder<static>|StudentContract whereCreatedAt($value)
+ * @method static Builder<static>|StudentContract whereCreatedByUserId($value)
+ * @method static Builder<static>|StudentContract whereDeletedAt($value)
+ * @method static Builder<static>|StudentContract whereDerivedFromContractId($value)
+ * @method static Builder<static>|StudentContract whereEarlyPaymentDiscount($value)
+ * @method static Builder<static>|StudentContract whereEndDate($value)
+ * @method static Builder<static>|StudentContract whereEndTime($value)
+ * @method static Builder<static>|StudentContract whereEnrollmentDueDate($value)
+ * @method static Builder<static>|StudentContract whereFileType($value)
+ * @method static Builder<static>|StudentContract whereFileUrl($value)
+ * @method static Builder<static>|StudentContract whereFineCancelled($value)
+ * @method static Builder<static>|StudentContract whereId($value)
+ * @method static Builder<static>|StudentContract whereInstallments($value)
+ * @method static Builder<static>|StudentContract whereInterestRate($value)
+ * @method static Builder<static>|StudentContract whereNotes($value)
+ * @method static Builder<static>|StudentContract wherePackageDueDate($value)
+ * @method static Builder<static>|StudentContract wherePackageInstallments($value)
+ * @method static Builder<static>|StudentContract wherePackageValue($value)
+ * @method static Builder<static>|StudentContract wherePaymentMethod($value)
+ * @method static Builder<static>|StudentContract whereProtocol($value)
+ * @method static Builder<static>|StudentContract whereRecurringDay($value)
+ * @method static Builder<static>|StudentContract whereSecondEndTime($value)
+ * @method static Builder<static>|StudentContract whereSecondStartTime($value)
+ * @method static Builder<static>|StudentContract whereSecondWeekday($value)
+ * @method static Builder<static>|StudentContract whereSignatureDate($value)
+ * @method static Builder<static>|StudentContract whereSignedFileType($value)
+ * @method static Builder<static>|StudentContract whereSignedFileUrl($value)
+ * @method static Builder<static>|StudentContract whereStartTime($value)
+ * @method static Builder<static>|StudentContract whereStatus($value)
+ * @method static Builder<static>|StudentContract whereStudentId($value)
+ * @method static Builder<static>|StudentContract whereTaxRegister($value)
+ * @method static Builder<static>|StudentContract whereUnitId($value)
+ * @method static Builder<static>|StudentContract whereUpdatedAt($value)
+ * @method static Builder<static>|StudentContract whereVersion($value)
+ * @method static Builder<static>|StudentContract whereWeekday($value)
+ * @method static Builder<static>|StudentContract withTrashed(bool $withTrashed = true)
+ * @method static Builder<static>|StudentContract withoutTrashed()
  * @mixin \Eloquent
  */
 class StudentContract extends Model
@@ -97,14 +112,38 @@ class StudentContract extends Model
     protected $guarded = ['id'];
 
     protected $casts = [
-        'signature_date'      => 'date:Y-m-d',
-        'end_date'            => 'date:Y-m-d',
+        'version' => 'integer',
+        'signature_date' => 'date:Y-m-d',
+        'end_date' => 'date:Y-m-d',
         'enrollment_due_date' => 'date:Y-m-d',
-        'package_due_date'    => 'date:Y-m-d',
-        'created_at'          => 'datetime',
-        'updated_at'          => 'datetime',
+        'package_due_date' => 'date:Y-m-d',
+        'created_at' => 'datetime',
+        'updated_at' => 'datetime',
     ];
 
+    public function scopeLatestVersion(Builder $query): Builder
+    {
+        return $query->whereDoesntHave(
+            'derivedVersions',
+            fn (Builder $derivedQuery) => $derivedQuery->withTrashed(),
+        );
+    }
+
+    public function derivedFrom(): BelongsTo
+    {
+        return $this->belongsTo(self::class, 'derived_from_contract_id');
+    }
+
+    public function derivedVersions(): HasMany
+    {
+        return $this->hasMany(self::class, 'derived_from_contract_id');
+    }
+
+    public function createdBy(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'created_by_user_id');
+    }
+
     public function student(): BelongsTo
     {
         return $this->belongsTo(Student::class);

+ 25 - 23
app/Services/ClassService.php

@@ -2,18 +2,17 @@
 
 namespace App\Services;
 
-use App\Models\SchoolClass;
 use App\Models\ClassAttendance;
+use App\Models\SchoolClass;
 use App\Models\Student;
 use Illuminate\Database\Eloquent\Collection;
 use Illuminate\Support\Facades\DB;
 
 class ClassService
 {
-    /**
-     * Aulas da unidade ativa, com nome do pacote e contadores
-     * (total de alunos do pacote e quantos foram marcados presentes).
-     */
+    // aulas da unidade ativa, com nome do pacote e contadores
+    // total de alunos do pacote e quantos foram marcados presentes
+
     public function getAllByUnit(int $unitId): Collection
     {
         return SchoolClass::with('packageUnit')
@@ -45,6 +44,7 @@ public function update(int $id, int $unitId, array $data): SchoolClass
         $model = $this->findByIdForUnit($id, $unitId);
 
         $model->update($data);
+
         return $model->fresh('packageUnit');
     }
 
@@ -55,10 +55,9 @@ public function delete(int $id, int $unitId): bool
         return (bool) $model->delete();
     }
 
-    /**
-     * Lista os alunos com contrato ATIVO no pacote vinculado à aula,
-     * já mesclando a presença registrada (se houver) para aquela aula.
-     */
+    // lista os alunos com contrato ativo no pacote vinculado a aula
+    // ja mesclando a presenca registrada se houver para aquela aula
+
     public function getStudentsForClass(int $classId, int $unitId): array
     {
         $class = $this->findByIdForUnit($classId, $unitId);
@@ -67,12 +66,14 @@ public function getStudentsForClass(int $classId, int $unitId): array
             ->orderBy('students.name')
             ->get();
 
-        // Presenças já registradas nesta aula, indexadas por aluno
+        // presencas ja registradas nesta aula, indexadas por aluno
+
         $attendances = ClassAttendance::where('class_id', $classId)
             ->get()
             ->keyBy('student_id');
 
-        // Total de aulas presenciadas por aluno (em qualquer aula)
+        // total de aulas presenciadas por aluno em qualquer aula
+
         $attendedCounts = ClassAttendance::select('student_id', DB::raw('COUNT(*) as total'))
             ->whereIn('student_id', $students->pluck('id'))
             ->where('in_class', true)
@@ -94,14 +95,13 @@ public function getStudentsForClass(int $classId, int $unitId): array
         })->all();
     }
 
-    /**
-     * Salva a presença da aula em lote (upsert por aluno).
-     *
-     * @param array<int, array{student_id:int, in_class:bool, justified?:bool, notes?:string|null}> $items
-     */
+    // salva a presenca da aula em lote upsert por aluno
+    // items: student_id, in_class, justified e notes
+
     public function saveAttendance(int $classId, int $unitId, array $items): void
     {
         $class = $this->findByIdForUnit($classId, $unitId);
+
         $allowedStudentIds = $this->activeStudentsQuery($class->class_package_unit_id, $unitId)
             ->whereIn('students.id', collect($items)->pluck('student_id'))
             ->pluck('students.id');
@@ -110,8 +110,10 @@ public function saveAttendance(int $classId, int $unitId, array $items): void
 
         DB::transaction(function () use ($classId, $items) {
             foreach ($items as $item) {
-                $inClass   = (bool) ($item['in_class'] ?? false);
-                // Justificativa só vale para faltas; presença nunca fica justificada.
+                $inClass = (bool) ($item['in_class'] ?? false);
+
+                // justificativa so vale para faltas, presenca nunca fica justificada
+
                 $justified = $inClass ? false : (bool) ($item['justified'] ?? false);
 
                 ClassAttendance::updateOrCreate(
@@ -129,14 +131,14 @@ public function saveAttendance(int $classId, int $unitId, array $items): void
         });
     }
 
-    /**
-     * Query base: alunos com contrato ativo no pacote informado (e na unidade).
-     */
+    // query base de alunos com contrato ativo no pacote informado e na unidade
+
     private function activeStudentsQuery(?int $classPackageUnitId, int $unitId)
     {
         return Student::query()
             ->whereHas('contracts', function ($q) use ($classPackageUnitId, $unitId) {
-                $q->where('status', 'active')
+                $q->latestVersion()
+                    ->where('status', 'active')
                     ->where('unit_id', $unitId);
 
                 if ($classPackageUnitId) {
@@ -144,4 +146,4 @@ private function activeStudentsQuery(?int $classPackageUnitId, int $unitId)
                 }
             });
     }
-}
+}

+ 264 - 150
app/Services/StudentContractService.php

@@ -7,30 +7,30 @@
 use App\Models\StudentMedia;
 use Carbon\Carbon;
 use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Storage;
+use Illuminate\Validation\ValidationException;
 
 class StudentContractService
 {
-    public function getFranchisorSummary(array $unitIds = []): array
-    {
-        $base = StudentContract::query()
-            ->when(!empty($unitIds), fn ($q) => $q->whereIn('unit_id', $unitIds));
-
-        return [
-            'active'    => (clone $base)->where('status', 'active')->count(),
-            'frozen'    => (clone $base)->where('status', 'frozen')->count(),
-            'cancelled' => (clone $base)->where('status', 'cancelled')->count(),
-        ];
-    }
-
-    public function getFranchisorByStatus(string $status, array $unitIds = []): Collection
-    {
-        return StudentContract::with(['student', 'unit'])
-            ->where('status', $status)
-            ->when(!empty($unitIds), fn ($q) => $q->whereIn('unit_id', $unitIds))
-            ->orderBy('created_at', 'desc')
-            ->get();
-    }
+    private const FILE_ATTRIBUTES = [
+        'file_url',
+        'file_type',
+        'signed_file_url',
+        'signed_file_type',
+    ];
+
+    private const VERSION_ATTRIBUTES = [
+        'id',
+        'version',
+        'derived_from_contract_id',
+        'created_by_user_id',
+        'created_at',
+        'updated_at',
+        'deleted_at',
+    ];
 
     public function getAll(int $unitId, ?int $studentId = null): Collection
     {
@@ -39,7 +39,9 @@ public function getAll(int $unitId, ?int $studentId = null): Collection
                 ->withTrashed()
                 ->with(['city', 'state']),
             'classPackageUnit',
+            'createdBy',
         ])
+            ->latestVersion()
             ->where('unit_id', $unitId)
             ->when($studentId, fn ($q) => $q->where('student_id', $studentId))
             ->orderBy('created_at', 'desc')
@@ -48,14 +50,23 @@ public function getAll(int $unitId, ?int $studentId = null): Collection
 
     public function findById(int $id): ?StudentContract
     {
-        return StudentContract::with(['student', 'classPackageUnit'])->find($id);
+        $contract = StudentContract::with(['student', 'classPackageUnit', 'createdBy'])->find($id);
+
+        if (! $contract) {
+            return null;
+        }
+
+        $this->loadVersionHistory($contract);
+
+        return $contract;
     }
 
     public function create(array $data): StudentContract
     {
-        if (!empty($data['due_day'])) {
+        if (! empty($data['due_day'])) {
             $data['recurring_day'] = (int) $data['due_day'];
         }
+
         unset($data['due_day']);
 
         $contract = StudentContract::create($data);
@@ -65,13 +76,224 @@ public function create(array $data): StudentContract
         return $contract;
     }
 
+    public function update(int $id, array $data, ?int $createdByUserId = null): ?StudentContract
+    {
+        if (! empty($data['due_day'])) {
+            $data['recurring_day'] = (int) $data['due_day'];
+        }
+
+        unset($data['due_day']);
+
+        return DB::transaction(function () use ($id, $data, $createdByUserId): ?StudentContract {
+            $contract = StudentContract::query()->lockForUpdate()->find($id);
+
+            if (! $contract) {
+                return null;
+            }
+
+            $originalAttributes = $contract->getAttributes();
+
+            $contract->fill($data);
+
+            $dirty = Arr::except(
+                $contract->getDirty(),
+
+                array_merge(self::FILE_ATTRIBUTES, self::VERSION_ATTRIBUTES),
+            );
+
+            if ($dirty === []) {
+                return $contract->refresh();
+            }
+
+            if ($contract->derivedVersions()->withTrashed()->lockForUpdate()->first()) {
+                throw ValidationException::withMessages([
+                    'contract' => __('validation.student_contract_old_version'),
+                ]);
+            }
+
+            $submittedAttributes = array_keys($data);
+
+            $copiedAttributes = Arr::except(
+                $originalAttributes,
+                array_merge(self::FILE_ATTRIBUTES, self::VERSION_ATTRIBUTES, $submittedAttributes),
+            );
+
+            $editedAttributes = Arr::only($contract->getAttributes(), $submittedAttributes);
+
+            $newVersion = StudentContract::create(array_merge(
+                $copiedAttributes,
+                $editedAttributes,
+                [
+                    'version'                  => $contract->version + 1,
+                    'derived_from_contract_id' => $contract->id,
+                    'created_by_user_id'       => $createdByUserId,
+                ],
+            ));
+
+            StudentContractInstallment::where('student_contract_id', $contract->id)
+                ->update(['student_contract_id' => $newVersion->id]);
+
+            return $newVersion->fresh();
+        });
+    }
+
+    public function delete(int $id): bool
+    {
+        $model = $this->findById($id);
+
+        if (! $model) {
+            return false;
+        }
+
+        if ($model->file_url) {
+            Storage::delete($model->file_url);
+        }
+
+        if ($model->signed_file_url) {
+            Storage::delete($model->signed_file_url);
+        }
+
+        return $model->delete();
+    }
+
+    //
+
+    public function getFranchisorSummary(array $unitIds = []): array
+    {
+        $base = StudentContract::query()->latestVersion()
+            ->when(! empty($unitIds), fn ($q) => $q->whereIn('unit_id', $unitIds));
+
+        return [
+            'active'    => (clone $base)->where('status', 'active')->count(),
+            'frozen'    => (clone $base)->where('status', 'frozen')->count(),
+            'cancelled' => (clone $base)->where('status', 'cancelled')->count(),
+        ];
+    }
+
+    public function getFranchisorByStatus(string $status, array $unitIds = []): Collection
+    {
+        return StudentContract::with(['student', 'unit', 'createdBy'])->latestVersion()
+            ->where('status', $status)
+            ->when(! empty($unitIds), fn ($q) => $q->whereIn('unit_id', $unitIds))
+            ->orderBy('created_at', 'desc')
+            ->get();
+    }
+
+    public function getInstallments(int $contractId): Collection
+    {
+        return StudentContractInstallment::where('student_contract_id', $contractId)
+            ->where('status', 'pending')
+            ->orderBy('due_date')
+            ->orderBy('installment_number')
+            ->get();
+    }
+
+    //
+
+    public function attachFile(int $id, UploadedFile $file, bool $signed = false): ?StudentContract
+    {
+        $model = StudentContract::find($id);
+
+        if (! $model) {
+            return null;
+        }
+
+        $path = $file->store('student-media');
+
+        StudentMedia::create([
+            'student_id'          => $model->student_id,
+            'student_contract_id' => $model->id,
+            'url'                 => $path,
+            'file_type'           => $file->getMimeType(),
+            'type'                => $signed ? 'signed_contract' : 'contract',
+        ]);
+
+        $urlAttribute = $signed ? 'signed_file_url' : 'file_url';
+
+        $typeAttribute = $signed ? 'signed_file_type' : 'file_type';
+
+        $model->update([
+            $urlAttribute  => $path,
+            $typeAttribute => $file->getMimeType(),
+        ]);
+
+        return $model->fresh();
+    }
+
+    //
+
+    public function cancel(int $id, ?int $createdByUserId = null): ?StudentContract
+    {
+        return $this->update($id, ['status' => 'cancelled'], $createdByUserId);
+    }
+
+    public function freeze(int $id, int $months = 0, ?int $createdByUserId = null): ?StudentContract
+    {
+        return DB::transaction(function () use ($id, $months, $createdByUserId): ?StudentContract {
+            $contract = $this->update($id, ['status' => 'frozen'], $createdByUserId);
+
+            if (! $contract) {
+                return null;
+            }
+
+            if ($months > 0) {
+                StudentContractInstallment::where('student_contract_id', $contract->id)
+                    ->where('status', 'pending')
+                    ->get()
+                    ->each(function ($installment) use ($months) {
+                        $newDate = Carbon::parse($installment->due_date)->addMonths($months);
+
+                        $installment->update(['due_date' => $newDate->format('Y-m-d')]);
+                    });
+            }
+
+            return $contract->fresh();
+        });
+    }
+
+    public function reactivate(int $id, ?int $createdByUserId = null): ?StudentContract
+    {
+        return $this->update($id, ['status' => 'active'], $createdByUserId);
+    }
+
+    //
+
+    // monta o array de datas para n parcelas
+    // primeira parcela usa exatamente firstdate, data escolhida pelo usuario
+    // segunda parcela em diante usa recurringday avancando mes a mes a partir do mes da primeira
+    // exemplo: firstdate=25/05/2026, recurringday=5, count=3
+    // resultado: [25/05/2026, 05/06/2026, 05/07/2026]
+
+    private function buildInstallmentDates(string $firstDate, int $recurringDay, int $count): array
+    {
+        $dates = [];
+        $first = Carbon::createFromFormat('Y-m-d', $firstDate);
+        $dates[] = $first->copy();
+
+        $baseMonth = $first->copy()->startOfMonth();
+
+        for ($i = 1; $i < $count; $i++) {
+            $next = $baseMonth->copy()->addMonths($i);
+            $day = min($recurringDay, $next->daysInMonth);
+
+            $next->setDay($day);
+
+            $dates[] = $next;
+        }
+
+        return $dates;
+    }
+
     private function generateInstallments(StudentContract $contract): void
     {
         $recurringDay = $contract->recurring_day ?? 1;
+
         $rows = [];
-        $now  = now();
+
+        $now = now();
 
         // Matrícula
+
         if ($contract->tax_register && $contract->installments && $contract->enrollment_due_date) {
             $value = round($contract->tax_register / $contract->installments, 2);
 
@@ -103,9 +325,10 @@ private function generateInstallments(StudentContract $contract): void
         }
 
         // Pacote
+
         if ($contract->package_value && $contract->package_installments && $contract->package_due_date) {
             $value = round($contract->package_value / $contract->package_installments, 2);
-           
+
             $dates = $this->buildInstallmentDates(
                 $contract->package_due_date->format('Y-m-d'),
                 $recurringDay,
@@ -133,148 +356,39 @@ private function generateInstallments(StudentContract $contract): void
             }
         }
 
-        if (!empty($rows)) {
+        if (! empty($rows)) {
             // As parcelas são registradas/calculadas normalmente. A cobrança no Asaas
             // da franquia será reintroduzida no módulo Franchisee (conta própria por unidade).
-            StudentContractInstallment::insert($rows);
-        }
-    }
-
-    /**
-     * Monta o array de datas para N parcelas.
-     *
-     * - 1ª parcela: usa exatamente $firstDate (data escolhida pelo usuário)
-     * - 2ª em diante: dia $recurringDay avançando mês a mês a partir do mês da 1ª
-     *
-     * Exemplo: firstDate=25/05/2026, recurringDay=5, count=3
-     *   → [25/05/2026, 05/06/2026, 05/07/2026]
-     */
-    private function buildInstallmentDates(string $firstDate, int $recurringDay, int $count): array
-    {
-        $dates     = [];
-        $first     = Carbon::createFromFormat('Y-m-d', $firstDate);
-        $dates[]   = $first->copy();
-
-        $baseMonth = $first->copy()->startOfMonth();
-
-        for ($i = 1; $i < $count; $i++) {
-            $next = $baseMonth->copy()->addMonths($i);
-            $day  = min($recurringDay, $next->daysInMonth);
-            $next->setDay($day);
-            $dates[] = $next;
-        }
-
-        return $dates;
-    }
-
-    public function update(int $id, array $data): ?StudentContract
-    {
-        $model = $this->findById($id);
-
-        if (!$model) {
-            return null;
-        }
-
-        if (!empty($data['due_day'])) {
-            $data['recurring_day'] = (int) $data['due_day'];
-        }
-        unset($data['due_day']);
 
-        $model->update($data);
-        return $model->fresh();
-    }
-
-    public function attachFile(int $id, $file): ?StudentContract
-    {
-        $model = $this->findById($id);
-
-        if (!$model) {
-            return null;
-        }
-
-        /** @var \Illuminate\Http\UploadedFile $file */
-        $path = $file->store('student-media');
-
-        StudentMedia::create([
-            'student_id'          => $model->student_id,
-            'student_contract_id' => $model->id,
-            'url'                 => $path,
-            'file_type'           => $file->getMimeType(),
-            'type'                => 'contract',
-        ]);
-
-        $model->update(['file_url' => Storage::url($path), 'file_type' => $file->getMimeType()]);
-
-        return $model->fresh();
-    }
-
-    public function getInstallments(int $contractId): Collection
-    {
-        return StudentContractInstallment::where('student_contract_id', $contractId)
-            ->where('status', 'pending')
-            ->orderBy('due_date')
-            ->orderBy('installment_number')
-            ->get();
-    }
-
-    public function freeze(int $id, int $months = 0): ?StudentContract
-    {
-        $model = $this->findById($id);
-
-        if (!$model) {
-            return null;
-        }
-
-        if ($months > 0) {
-            StudentContractInstallment::where('student_contract_id', $id)
-                ->where('status', 'pending')
-                ->get()
-                ->each(function ($installment) use ($months) {
-                    $newDate = Carbon::parse($installment->due_date)->addMonths($months);
-                    $installment->update(['due_date' => $newDate->format('Y-m-d')]);
-                });
+            StudentContractInstallment::insert($rows);
         }
-
-        $model->update(['status' => 'frozen']);
-        return $model->fresh();
     }
 
-    public function cancel(int $id): ?StudentContract
+    private function loadVersionHistory(StudentContract $contract): void
     {
-        $model = $this->findById($id);
+        $root = $contract;
 
-        if (!$model) {
-            return null;
-        }
-
-        $model->update(['status' => 'cancelled']);
-        return $model->fresh();
-    }
+        while ($root->derived_from_contract_id !== null) {
+            $parent = StudentContract::with('createdBy')->find($root->derived_from_contract_id);
 
-    public function reactivate(int $id): ?StudentContract
-    {
-        $model = $this->findById($id);
+            if (! $parent) {
+                break;
+            }
 
-        if (!$model) {
-            return null;
+            $root = $parent;
         }
 
-        $model->update(['status' => 'active']);
-        return $model->fresh();
-    }
+        $history = new Collection;
 
-    public function delete(int $id): bool
-    {
-        $model = $this->findById($id);
+        $current = $root;
 
-        if (!$model) {
-            return false;
-        }
+        while ($current) {
+            $current->loadMissing('createdBy');
+            $history->push($current);
 
-        if ($model->file_url) {
-            Storage::delete($model->file_url);
+            $current = $current->derivedVersions()->with('createdBy')->first();
         }
 
-        return $model->delete();
+        $contract->setRelation('versionHistory', $history->sortByDesc('version')->values());
     }
 }

+ 70 - 65
app/Services/StudentService.php

@@ -28,7 +28,7 @@ public function getAll(User $user, array $filters = []): Collection
         return Student::where('unit_id', $unitId)
             ->when($startDate || $endDate, function ($query) use ($startDate, $endDate) {
                 $query->whereHas('contracts', function ($contractQuery) use ($startDate, $endDate) {
-                    $contractQuery->where('status', 'active');
+                    $contractQuery->latestVersion()->where('status', 'active');
 
                     if ($endDate) {
                         $contractQuery->where('started_date', '<=', $endDate);
@@ -47,46 +47,6 @@ public function getAll(User $user, array $filters = []): Collection
             ->get();
     }
 
-    public function getFranchisorActive(array $unitIds = []): Collection
-    {
-        return Student::with('unit')
-            ->whereHas('contracts', fn ($q) => $q->where('status', 'active'))
-            ->when(!empty($unitIds), fn ($q) => $q->whereIn('unit_id', $unitIds))
-            ->orderBy('name')
-            ->get();
-    }
-
-    public function getFranchisorStudentDetail(int $id): ?array
-    {
-        $student = Student::with('unit')->find($id);
-
-        if (!$student) {
-            return null;
-        }
-
-        $contract = StudentContract::where('student_id', $id)
-            ->where('status', 'active')
-            ->first();
-
-        return [
-            'id'       => $student->id,
-            'name'     => $student->name,
-            'phone'    => $student->phone,
-            'unit'     => $student->unit ? ['fantasy_name' => $student->unit->fantasy_name] : null,
-            'protocol' => $contract?->protocol,
-        ];
-    }
-
-    public function getFranchisorSummary(array $unitIds = []): array
-    {
-        $query = Student::query()->when(!empty($unitIds), fn ($q) => $q->whereIn('unit_id', $unitIds));
-
-        $total  = $query->count();
-        $active = (clone $query)->where('status', 'active')->count();
-
-        return ['total' => $total, 'active' => $active];
-    }
-
     public function findById(int $id): ?Student
     {
         return Student::find($id);
@@ -127,30 +87,11 @@ function (array $studentData, int $unitId) use ($responsibleData, $avatar): Stud
         );
     }
 
-    private function persistStudent(array $data, ?array $responsibleData, int $unitId): Student
-    {
-        $data = $this->handlePhoto($data);
-
-        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
     {
         $model = $this->findById($id);
 
-        if (!$model) {
+        if (! $model) {
             return null;
         }
 
@@ -166,11 +107,12 @@ public function delete(int $id): bool
         return DB::transaction(function () use ($id): bool {
             $model = Student::query()->lockForUpdate()->find($id);
 
-            if (!$model) {
+            if (! $model) {
                 return false;
             }
 
             $contracts = $model->contracts()
+                ->latestVersion()
                 ->lockForUpdate()
                 ->get(['id', 'status']);
 
@@ -194,9 +136,53 @@ public function delete(int $id): bool
 
     //
 
+    public function getFranchisorActive(array $unitIds = []): Collection
+    {
+        return Student::with('unit')
+            ->whereHas('contracts', fn ($q) => $q->latestVersion()->where('status', 'active'))
+            ->when(! empty($unitIds), fn ($q) => $q->whereIn('unit_id', $unitIds))
+            ->orderBy('name')
+            ->get();
+    }
+
+    public function getFranchisorStudentDetail(int $id): ?array
+    {
+        $student = Student::with('unit')->find($id);
+
+        if (! $student) {
+            return null;
+        }
+
+        $contract = StudentContract::latestVersion()
+            ->where('student_id', $id)
+            ->where('status', 'active')
+            ->first();
+
+        return [
+            'id'       => $student->id,
+            'name'     => $student->name,
+            'phone'    => $student->phone,
+            'unit'     => $student->unit ? ['fantasy_name' => $student->unit->fantasy_name] : null,
+            'protocol' => $contract?->protocol,
+        ];
+    }
+
+    public function getFranchisorSummary(array $unitIds = []): array
+    {
+        $query = Student::query()->when(! empty($unitIds), fn ($q) => $q->whereIn('unit_id', $unitIds));
+
+        $total = $query->count();
+
+        $active = (clone $query)->where('status', 'active')->count();
+
+        return ['total' => $total, 'active' => $active];
+    }
+
+    //
+
     private function handlePhoto(array $data, ?string $oldPhotoPath = null): array
     {
-        if (!isset($data['avatar'])) {
+        if (! isset($data['avatar'])) {
             return $data;
         }
 
@@ -219,6 +205,25 @@ private function handlePhoto(array $data, ?string $oldPhotoPath = null): array
         return $data;
     }
 
+    private function persistStudent(array $data, ?array $responsibleData, int $unitId): Student
+    {
+        $data = $this->handlePhoto($data);
+
+        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');
+        });
+    }
+
     private function resolveUnitId(User $user): int
     {
         $activeUnitId = request()->input('active_unit_id');
@@ -226,14 +231,14 @@ 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.');
+            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.');
+        abort_if(! $unit, 403, 'Usuário sem unidade associada.');
 
         return $unit->id;
     }

+ 47 - 0
database/migrations/2026_08_10_000002_add_versioning_to_student_contracts.php

@@ -0,0 +1,47 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('student_contracts', function (Blueprint $table) {
+            $table->unsignedInteger('version')->default(1)->after('id');
+
+            $table->foreignId('derived_from_contract_id')
+                ->nullable()
+                ->unique()
+                ->after('version')
+                ->constrained('student_contracts')
+                ->nullOnDelete();
+
+            $table->foreignId('created_by_user_id')
+                ->nullable()
+                ->after('derived_from_contract_id')
+                ->constrained('users')
+                ->nullOnDelete();
+
+            $table->string('signed_file_url')->nullable()->after('file_type');
+            $table->string('signed_file_type')->nullable()->after('signed_file_url');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('student_contracts', function (Blueprint $table) {
+            $table->dropForeign(['created_by_user_id']);
+            $table->dropForeign(['derived_from_contract_id']);
+            $table->dropUnique(['derived_from_contract_id']);
+            $table->dropColumn([
+                'version',
+                'derived_from_contract_id',
+                'created_by_user_id',
+                'signed_file_url',
+                'signed_file_type',
+            ]);
+        });
+    }
+};

+ 2 - 0
lang/en/validation.php

@@ -100,6 +100,8 @@
     '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.',
+    'student_contract_max_duration' => 'The student contract must have a maximum duration of 1 year.',
+    'student_contract_old_version' => 'Only the latest contract version can be edited.',
     'max' => [
         'array' => 'The :attribute field must not have more than :max items.',
         'file' => 'The :attribute field must not be greater than :max kilobytes.',

+ 2 - 0
lang/es/validation.php

@@ -100,6 +100,8 @@
     '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.',
+    'student_contract_max_duration' => 'El contrato del estudiante debe tener una duración máxima de 1 año.',
+    'student_contract_old_version' => 'Solo se puede editar la versión más reciente del contrato.',
     'max' => [
         'array' => 'El campo :attribute no debe tener más de :max elementos.',
         'file' => 'El campo :attribute no debe ser mayor que :max kilobytes.',

+ 2 - 0
lang/pt/validation.php

@@ -101,6 +101,8 @@
     '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.',
+    'student_contract_max_duration' => 'O contrato do estudante deve ter duração máxima de 1 ano.',
+    'student_contract_old_version' => 'Somente a versão mais recente do contrato pode ser editada.',
     '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.',

+ 1 - 0
routes/authRoutes/franchisee_contracts.php

@@ -10,6 +10,7 @@
     Route::get('/{id}', [StudentContractController::class, 'show'])->whereNumber('id')->middleware('permission:franchisee_contracts,view');
     Route::put('/{id}', [StudentContractController::class, 'update'])->whereNumber('id')->middleware('permission:franchisee_contracts,edit');
     Route::post('/{id}/file', [StudentContractController::class, 'attachFile'])->whereNumber('id')->middleware('permission:franchisee_contracts,edit');
+    Route::post('/{id}/signed-file', [StudentContractController::class, 'attachSignedFile'])->whereNumber('id')->middleware('permission:franchisee_contracts,edit');
     Route::get('/{id}/installments', [StudentContractController::class, 'installments'])->whereNumber('id')->middleware('permission:franchisee_contracts,view');
     Route::post('/{id}/freeze', [StudentContractController::class, 'freeze'])->whereNumber('id')->middleware('permission:franchisee_contracts,edit');
     Route::post('/{id}/cancel', [StudentContractController::class, 'cancel'])->whereNumber('id')->middleware('permission:franchisee_contracts,edit');

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

@@ -55,12 +55,50 @@ public function test_it_casts_the_student_document_number_to_cpf(): void
         $this->assertInstanceOf(Cpf::class, $student->document_number);
         $this->assertSame('52998224725', $student->document_number->value());
     }
+
+    public function test_it_rejects_deletion_when_the_student_has_an_active_contract(): void
+    {
+        app()->setLocale('pt');
+
+        $student = (new TestableStudent)->withActiveContract();
+
+        $this->expectException(ValidationException::class);
+        $this->expectExceptionMessage('Não é possível excluir um estudante que possui contratos ativos.');
+
+        $student->fireDeletingEvent();
+    }
+
+    public function test_it_allows_deletion_when_the_student_has_no_active_contract(): void
+    {
+        $student = new TestableStudent;
+
+        $this->assertNull($student->fireDeletingEvent());
+    }
 }
 
 class TestableStudent extends Student
 {
+    private bool $hasActiveContract = false;
+
     public function fireCreatingEvent(): mixed
     {
         return $this->fireModelEvent('creating');
     }
+
+    public function fireDeletingEvent(): mixed
+    {
+        return $this->fireModelEvent('deleting');
+    }
+
+    public function withActiveContract(): self
+    {
+        $this->hasActiveContract = true;
+
+        return $this;
+    }
+
+    public function hasActiveContract(): bool
+    {
+        return $this->hasActiveContract;
+    }
 }