Browse Source

Merge branch 'feature/gc-gab-proposals' of Softpar/sfp_api_laravel_ginastica_cerebro into development

Gabriel Alves 20 giờ trước cách đây
mục cha
commit
27874557a3
26 tập tin đã thay đổi với 1230 bổ sung16 xóa
  1. 12 0
      app/Http/Requests/ClassPackageRequest.php
  2. 12 0
      app/Http/Requests/ClassPackageUnitRequest.php
  3. 77 0
      app/Http/Requests/Concerns/ValidatesProposalPricing.php
  4. 6 0
      app/Http/Resources/ClassPackageResource.php
  5. 6 0
      app/Http/Resources/ClassPackageUnitResource.php
  6. 49 0
      app/Http/Resources/Concerns/SerializesProposalPricing.php
  7. 11 0
      app/Models/ClassPackage.php
  8. 11 0
      app/Models/ClassPackageUnit.php
  9. 68 0
      app/Models/IrrecusableProposal.php
  10. 40 0
      app/Models/IrrecusableProposalProduct.php
  11. 75 0
      app/Models/IrrecusableProposalUnit.php
  12. 40 0
      app/Models/IrrecusableProposalUnitProduct.php
  13. 67 0
      app/Models/PavaoProposal.php
  14. 40 0
      app/Models/PavaoProposalProduct.php
  15. 74 0
      app/Models/PavaoProposalUnit.php
  16. 40 0
      app/Models/PavaoProposalUnitProduct.php
  17. 27 6
      app/Services/ClassPackageService.php
  18. 58 9
      app/Services/ClassPackageUnitService.php
  19. 121 0
      app/Services/ProposalPricingService.php
  20. 1 1
      app/Services/UnitService.php
  21. 54 0
      database/migrations/2026_08_17_000001_create_pavao_and_irrecusable_proposals_table.php
  22. 39 0
      database/migrations/2026_08_17_000002_create_pavao_and_irrecusable_proposal_products_table.php
  23. 55 0
      database/migrations/2026_08_17_000003_create_pavao_and_irrecusable_proposal_units_table.php
  24. 39 0
      database/migrations/2026_08_17_000004_create_pavao_and_irrecusable_proposal_unit_products_table.php
  25. 68 0
      docs/proposta_comercial_v1.md
  26. 140 0
      tests/Unit/Http/Requests/Concerns/ValidatesProposalPricingTest.php

+ 12 - 0
app/Http/Requests/ClassPackageRequest.php

@@ -2,10 +2,14 @@
 
 namespace App\Http\Requests;
 
+use App\Http\Requests\Concerns\ValidatesProposalPricing;
+use Illuminate\Contracts\Validation\Validator as ValidatorContract;
 use Illuminate\Foundation\Http\FormRequest;
 
 class ClassPackageRequest extends FormRequest
 {
+    use ValidatesProposalPricing;
+
     public function rules(): array
     {
         $required = $this->isMethod('POST') ? 'required' : 'sometimes';
@@ -34,6 +38,14 @@ public function rules(): array
             'unit_visibilities'           => 'nullable|array',
             'unit_visibilities.*.unit_id' => 'required_with:unit_visibilities|integer|exists:units,id',
             'unit_visibilities.*.visible' => 'required_with:unit_visibilities|boolean',
+
+            ...$this->proposalPricingRules('pavao', $required),
+            ...$this->proposalPricingRules('irrecusavel', $required, withCondition: true),
         ];
     }
+
+    public function withValidator(ValidatorContract $validator): void
+    {
+        $this->validateProposalPricingSections($validator, ['pavao', 'irrecusavel']);
+    }
 }

+ 12 - 0
app/Http/Requests/ClassPackageUnitRequest.php

@@ -2,10 +2,14 @@
 
 namespace App\Http\Requests;
 
+use App\Http\Requests\Concerns\ValidatesProposalPricing;
+use Illuminate\Contracts\Validation\Validator as ValidatorContract;
 use Illuminate\Foundation\Http\FormRequest;
 
 class ClassPackageUnitRequest extends FormRequest
 {
+    use ValidatesProposalPricing;
+
     public function rules(): array
     {
         $required = $this->isMethod('POST') ? 'required' : 'sometimes';
@@ -28,6 +32,14 @@ public function rules(): array
             'materials.*.product_id' => 'required_with:materials|integer|exists:products,id',
             'materials.*.quantity'   => 'required_with:materials|integer|min:1',
             'materials.*.price'      => 'required_with:materials|numeric|min:0',
+
+            ...$this->proposalPricingRules('pavao', $required),
+            ...$this->proposalPricingRules('irrecusavel', $required, withCondition: true),
         ];
     }
+
+    public function withValidator(ValidatorContract $validator): void
+    {
+        $this->validateProposalPricingSections($validator, ['pavao', 'irrecusavel']);
+    }
 }

+ 77 - 0
app/Http/Requests/Concerns/ValidatesProposalPricing.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace App\Http\Requests\Concerns;
+
+use Illuminate\Validation\Validator;
+
+trait ValidatesProposalPricing
+{
+    /**
+     * Validation rules for a Pavão/Irrecusável pricing block (Matrícula, Aulas,
+     * Materiais, Valor Total do Curso), nested under $prefix ("pavao"/"irrecusavel").
+     */
+    protected function proposalPricingRules(string $prefix, string $required, bool $withCondition = false): array
+    {
+        $rules = [
+            $prefix => "$required|array",
+
+            "$prefix.registration_value"                => 'nullable|numeric|min:0',
+            "$prefix.registration_included_in_course"   => "$required|boolean",
+            "$prefix.registration_installments_allowed" => "$required|boolean",
+            "$prefix.registration_max_installments"     => 'nullable|integer|min:1',
+
+            "$prefix.classes_value"                => 'nullable|numeric|min:0',
+            "$prefix.classes_included_in_course"   => "$required|boolean",
+            "$prefix.classes_installments_allowed" => "$required|boolean",
+            "$prefix.classes_max_installments"     => 'nullable|integer|min:1',
+            "$prefix.classes_discount_percentage"  => 'nullable|numeric|min:0|max:100',
+
+            "$prefix.materials"              => 'nullable|array',
+            "$prefix.materials.*.product_id" => "required_with:$prefix.materials|integer|exists:products,id",
+            "$prefix.materials.*.quantity"   => "required_with:$prefix.materials|integer|min:1",
+            "$prefix.materials.*.price"      => "required_with:$prefix.materials|numeric|min:0",
+            "$prefix.materials_included_in_course"   => "$required|boolean",
+            "$prefix.materials_installments_allowed" => "$required|boolean",
+            "$prefix.materials_max_installments"     => 'nullable|integer|min:1',
+
+            "$prefix.total_value"            => 'nullable|numeric|min:0',
+            "$prefix.total_max_installments" => 'nullable|integer|min:1',
+        ];
+
+        if ($withCondition) {
+            $rules["$prefix.condition"] = 'nullable|string';
+        }
+
+        return $rules;
+    }
+
+    /**
+     * For each pricing block present in the request, enforce that exactly one of
+     * "included in course" / "allows installments" is selected per section.
+     */
+    protected function validateProposalPricingSections(Validator $validator, array $prefixes): void
+    {
+        $validator->after(function (Validator $validator) use ($prefixes) {
+            foreach ($prefixes as $prefix) {
+                if (!$this->has($prefix)) continue;
+
+                foreach (['registration', 'classes', 'materials'] as $section) {
+                    $includedKey     = "$prefix.{$section}_included_in_course";
+                    $installmentsKey = "$prefix.{$section}_installments_allowed";
+
+                    if (!$this->has($includedKey) && !$this->has($installmentsKey)) continue;
+
+                    $included     = $this->boolean($includedKey);
+                    $installments = $this->boolean($installmentsKey);
+
+                    if ($included === $installments) {
+                        $validator->errors()->add(
+                            $includedKey,
+                            'Selecione exatamente uma opção: incluso no valor do curso ou permite parcelar.'
+                        );
+                    }
+                }
+            }
+        });
+    }
+}

+ 6 - 0
app/Http/Resources/ClassPackageResource.php

@@ -6,10 +6,13 @@
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\JsonResource;
 use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
+use App\Http\Resources\Concerns\SerializesProposalPricing;
 use App\Models\ClassPackage;
 
 class ClassPackageResource extends JsonResource
 {
+    use SerializesProposalPricing;
+
     public function toArray(Request $request): array
     {
         return [
@@ -47,6 +50,9 @@ public function toArray(Request $request): array
             'group_ids' => $this->whenLoaded('groups', fn() =>
                 $this->groups->pluck('id')->values()
             ),
+
+            'pavao'       => $this->whenLoaded('pavaoProposal', fn() => $this->proposalPricingArray($this->pavaoProposal)),
+            'irrecusavel' => $this->whenLoaded('irrecusableProposal', fn() => $this->proposalPricingArray($this->irrecusableProposal, withCondition: true)),
         ];
     }
 

+ 6 - 0
app/Http/Resources/ClassPackageUnitResource.php

@@ -5,9 +5,12 @@
 use Carbon\Carbon;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\JsonResource;
+use App\Http\Resources\Concerns\SerializesProposalPricing;
 
 class ClassPackageUnitResource extends JsonResource
 {
+    use SerializesProposalPricing;
+
     public function toArray(Request $request): array
     {
         return [
@@ -37,6 +40,9 @@ public function toArray(Request $request): array
                     'price'      => (float) $item->price,
                 ])
             ),
+
+            'pavao'       => $this->whenLoaded('pavaoProposalUnit', fn() => $this->proposalPricingArray($this->pavaoProposalUnit)),
+            'irrecusavel' => $this->whenLoaded('irrecusableProposalUnit', fn() => $this->proposalPricingArray($this->irrecusableProposalUnit, withCondition: true)),
         ];
     }
 }

+ 49 - 0
app/Http/Resources/Concerns/SerializesProposalPricing.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Http\Resources\Concerns;
+
+use Illuminate\Database\Eloquent\Model;
+
+trait SerializesProposalPricing
+{
+    private function proposalPricingArray(?Model $proposal, bool $withCondition = false): ?array
+    {
+        if (!$proposal) return null;
+
+        $array = [
+            'registration_value'                => $proposal->registration_value,
+            'registration_included_in_course'   => (bool) $proposal->registration_included_in_course,
+            'registration_installments_allowed' => (bool) $proposal->registration_installments_allowed,
+            'registration_max_installments'     => $proposal->registration_max_installments,
+
+            'classes_value'                => $proposal->classes_value,
+            'classes_included_in_course'   => (bool) $proposal->classes_included_in_course,
+            'classes_installments_allowed' => (bool) $proposal->classes_installments_allowed,
+            'classes_max_installments'     => $proposal->classes_max_installments,
+            'classes_discount_percentage'  => $proposal->classes_discount_percentage,
+
+            'materials_total_value'              => $proposal->materials_total_value,
+            'materials_included_in_course'       => (bool) $proposal->materials_included_in_course,
+            'materials_installments_allowed'     => (bool) $proposal->materials_installments_allowed,
+            'materials_max_installments'         => $proposal->materials_max_installments,
+
+            'total_value'            => $proposal->total_value,
+            'total_max_installments' => $proposal->total_max_installments,
+
+            'materials' => $proposal->relationLoaded('products')
+                ? $proposal->products->map(fn($item) => [
+                    'product_id' => $item->product_id,
+                    'name'       => $item->product?->name,
+                    'quantity'   => $item->quantity,
+                    'price'      => (float) $item->price,
+                ])
+                : [],
+        ];
+
+        if ($withCondition) {
+            $array['condition'] = $proposal->condition;
+        }
+
+        return $array;
+    }
+}

+ 11 - 0
app/Models/ClassPackage.php

@@ -6,6 +6,7 @@
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\Relations\HasOne;
 use App\Models\Group;
 use Illuminate\Database\Eloquent\SoftDeletes;
 
@@ -91,4 +92,14 @@ public function groups(): BelongsToMany
     {
         return $this->belongsToMany(Group::class, 'class_package_groups');
     }
+
+    public function pavaoProposal(): HasOne
+    {
+        return $this->hasOne(PavaoProposal::class);
+    }
+
+    public function irrecusableProposal(): HasOne
+    {
+        return $this->hasOne(IrrecusableProposal::class);
+    }
 }

+ 11 - 0
app/Models/ClassPackageUnit.php

@@ -6,6 +6,7 @@
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\Relations\HasOne;
 use Illuminate\Database\Eloquent\SoftDeletes;
 
 /**
@@ -93,4 +94,14 @@ public function products(): HasMany
     {
         return $this->hasMany(ClassPackageUnitProduct::class);
     }
+
+    public function pavaoProposalUnit(): HasOne
+    {
+        return $this->hasOne(PavaoProposalUnit::class);
+    }
+
+    public function irrecusableProposalUnit(): HasOne
+    {
+        return $this->hasOne(IrrecusableProposalUnit::class);
+    }
 }

+ 68 - 0
app/Models/IrrecusableProposal.php

@@ -0,0 +1,68 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+/**
+ * @property int $id
+ * @property int $class_package_id
+ * @property float|null $registration_value
+ * @property bool $registration_included_in_course
+ * @property bool $registration_installments_allowed
+ * @property int $registration_max_installments
+ * @property float|null $classes_value
+ * @property bool $classes_included_in_course
+ * @property bool $classes_installments_allowed
+ * @property int $classes_max_installments
+ * @property float|null $classes_discount_percentage
+ * @property float $materials_total_value
+ * @property bool $materials_included_in_course
+ * @property bool $materials_installments_allowed
+ * @property int $materials_max_installments
+ * @property float|null $total_value
+ * @property int $total_max_installments
+ * @property string|null $condition
+ * @property-read \App\Models\ClassPackage $classPackage
+ * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\IrrecusableProposalProduct> $products
+ */
+class IrrecusableProposal extends Model
+{
+    use HasFactory, SoftDeletes;
+
+    protected $table = 'irrecusable_proposals';
+
+    protected $guarded = ['id'];
+
+    protected $casts = [
+        'registration_value'                 => 'float',
+        'registration_included_in_course'    => 'boolean',
+        'registration_installments_allowed'  => 'boolean',
+        'registration_max_installments'      => 'integer',
+        'classes_value'                      => 'float',
+        'classes_included_in_course'         => 'boolean',
+        'classes_installments_allowed'       => 'boolean',
+        'classes_max_installments'           => 'integer',
+        'classes_discount_percentage'        => 'float',
+        'materials_total_value'              => 'float',
+        'materials_included_in_course'       => 'boolean',
+        'materials_installments_allowed'     => 'boolean',
+        'materials_max_installments'         => 'integer',
+        'total_value'                        => 'float',
+        'total_max_installments'             => 'integer',
+    ];
+
+    public function classPackage(): BelongsTo
+    {
+        return $this->belongsTo(ClassPackage::class);
+    }
+
+    public function products(): HasMany
+    {
+        return $this->hasMany(IrrecusableProposalProduct::class);
+    }
+}

+ 40 - 0
app/Models/IrrecusableProposalProduct.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+/**
+ * @property int $id
+ * @property int $irrecusable_proposal_id
+ * @property int $product_id
+ * @property int $quantity
+ * @property float $price
+ * @property-read \App\Models\IrrecusableProposal $irrecusableProposal
+ * @property-read \App\Models\Product $product
+ */
+class IrrecusableProposalProduct extends Model
+{
+    use HasFactory;
+
+    protected $table = 'irrecusable_proposal_products';
+
+    protected $guarded = ['id'];
+
+    protected $casts = [
+        'quantity' => 'integer',
+        'price'    => 'float',
+    ];
+
+    public function irrecusableProposal(): BelongsTo
+    {
+        return $this->belongsTo(IrrecusableProposal::class);
+    }
+
+    public function product(): BelongsTo
+    {
+        return $this->belongsTo(Product::class);
+    }
+}

+ 75 - 0
app/Models/IrrecusableProposalUnit.php

@@ -0,0 +1,75 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+/**
+ * @property int $id
+ * @property int $class_package_unit_id
+ * @property int|null $irrecusable_proposal_id
+ * @property float|null $registration_value
+ * @property bool $registration_included_in_course
+ * @property bool $registration_installments_allowed
+ * @property int $registration_max_installments
+ * @property float|null $classes_value
+ * @property bool $classes_included_in_course
+ * @property bool $classes_installments_allowed
+ * @property int $classes_max_installments
+ * @property float|null $classes_discount_percentage
+ * @property float $materials_total_value
+ * @property bool $materials_included_in_course
+ * @property bool $materials_installments_allowed
+ * @property int $materials_max_installments
+ * @property float|null $total_value
+ * @property int $total_max_installments
+ * @property string|null $condition
+ * @property-read \App\Models\ClassPackageUnit $packageUnit
+ * @property-read \App\Models\IrrecusableProposal|null $baseIrrecusableProposal
+ * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\IrrecusableProposalUnitProduct> $products
+ */
+class IrrecusableProposalUnit extends Model
+{
+    use HasFactory, SoftDeletes;
+
+    protected $table = 'irrecusable_proposal_units';
+
+    protected $guarded = ['id'];
+
+    protected $casts = [
+        'registration_value'                 => 'float',
+        'registration_included_in_course'    => 'boolean',
+        'registration_installments_allowed'  => 'boolean',
+        'registration_max_installments'      => 'integer',
+        'classes_value'                      => 'float',
+        'classes_included_in_course'         => 'boolean',
+        'classes_installments_allowed'       => 'boolean',
+        'classes_max_installments'           => 'integer',
+        'classes_discount_percentage'        => 'float',
+        'materials_total_value'              => 'float',
+        'materials_included_in_course'       => 'boolean',
+        'materials_installments_allowed'     => 'boolean',
+        'materials_max_installments'         => 'integer',
+        'total_value'                        => 'float',
+        'total_max_installments'             => 'integer',
+    ];
+
+    public function packageUnit(): BelongsTo
+    {
+        return $this->belongsTo(ClassPackageUnit::class, 'class_package_unit_id');
+    }
+
+    public function baseIrrecusableProposal(): BelongsTo
+    {
+        return $this->belongsTo(IrrecusableProposal::class, 'irrecusable_proposal_id');
+    }
+
+    public function products(): HasMany
+    {
+        return $this->hasMany(IrrecusableProposalUnitProduct::class);
+    }
+}

+ 40 - 0
app/Models/IrrecusableProposalUnitProduct.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+/**
+ * @property int $id
+ * @property int $irrecusable_proposal_unit_id
+ * @property int $product_id
+ * @property int $quantity
+ * @property float $price
+ * @property-read \App\Models\IrrecusableProposalUnit $irrecusableProposalUnit
+ * @property-read \App\Models\Product $product
+ */
+class IrrecusableProposalUnitProduct extends Model
+{
+    use HasFactory;
+
+    protected $table = 'irrecusable_proposal_unit_products';
+
+    protected $guarded = ['id'];
+
+    protected $casts = [
+        'quantity' => 'integer',
+        'price'    => 'float',
+    ];
+
+    public function irrecusableProposalUnit(): BelongsTo
+    {
+        return $this->belongsTo(IrrecusableProposalUnit::class);
+    }
+
+    public function product(): BelongsTo
+    {
+        return $this->belongsTo(Product::class);
+    }
+}

+ 67 - 0
app/Models/PavaoProposal.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+/**
+ * @property int $id
+ * @property int $class_package_id
+ * @property float|null $registration_value
+ * @property bool $registration_included_in_course
+ * @property bool $registration_installments_allowed
+ * @property int $registration_max_installments
+ * @property float|null $classes_value
+ * @property bool $classes_included_in_course
+ * @property bool $classes_installments_allowed
+ * @property int $classes_max_installments
+ * @property float|null $classes_discount_percentage
+ * @property float $materials_total_value
+ * @property bool $materials_included_in_course
+ * @property bool $materials_installments_allowed
+ * @property int $materials_max_installments
+ * @property float|null $total_value
+ * @property int $total_max_installments
+ * @property-read \App\Models\ClassPackage $classPackage
+ * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\PavaoProposalProduct> $products
+ */
+class PavaoProposal extends Model
+{
+    use HasFactory, SoftDeletes;
+
+    protected $table = 'pavao_proposals';
+
+    protected $guarded = ['id'];
+
+    protected $casts = [
+        'registration_value'                 => 'float',
+        'registration_included_in_course'    => 'boolean',
+        'registration_installments_allowed'  => 'boolean',
+        'registration_max_installments'      => 'integer',
+        'classes_value'                      => 'float',
+        'classes_included_in_course'         => 'boolean',
+        'classes_installments_allowed'       => 'boolean',
+        'classes_max_installments'           => 'integer',
+        'classes_discount_percentage'        => 'float',
+        'materials_total_value'              => 'float',
+        'materials_included_in_course'       => 'boolean',
+        'materials_installments_allowed'     => 'boolean',
+        'materials_max_installments'         => 'integer',
+        'total_value'                        => 'float',
+        'total_max_installments'             => 'integer',
+    ];
+
+    public function classPackage(): BelongsTo
+    {
+        return $this->belongsTo(ClassPackage::class);
+    }
+
+    public function products(): HasMany
+    {
+        return $this->hasMany(PavaoProposalProduct::class);
+    }
+}

+ 40 - 0
app/Models/PavaoProposalProduct.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+/**
+ * @property int $id
+ * @property int $pavao_proposal_id
+ * @property int $product_id
+ * @property int $quantity
+ * @property float $price
+ * @property-read \App\Models\PavaoProposal $pavaoProposal
+ * @property-read \App\Models\Product $product
+ */
+class PavaoProposalProduct extends Model
+{
+    use HasFactory;
+
+    protected $table = 'pavao_proposal_products';
+
+    protected $guarded = ['id'];
+
+    protected $casts = [
+        'quantity' => 'integer',
+        'price'    => 'float',
+    ];
+
+    public function pavaoProposal(): BelongsTo
+    {
+        return $this->belongsTo(PavaoProposal::class);
+    }
+
+    public function product(): BelongsTo
+    {
+        return $this->belongsTo(Product::class);
+    }
+}

+ 74 - 0
app/Models/PavaoProposalUnit.php

@@ -0,0 +1,74 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+/**
+ * @property int $id
+ * @property int $class_package_unit_id
+ * @property int|null $pavao_proposal_id
+ * @property float|null $registration_value
+ * @property bool $registration_included_in_course
+ * @property bool $registration_installments_allowed
+ * @property int $registration_max_installments
+ * @property float|null $classes_value
+ * @property bool $classes_included_in_course
+ * @property bool $classes_installments_allowed
+ * @property int $classes_max_installments
+ * @property float|null $classes_discount_percentage
+ * @property float $materials_total_value
+ * @property bool $materials_included_in_course
+ * @property bool $materials_installments_allowed
+ * @property int $materials_max_installments
+ * @property float|null $total_value
+ * @property int $total_max_installments
+ * @property-read \App\Models\ClassPackageUnit $packageUnit
+ * @property-read \App\Models\PavaoProposal|null $basePavaoProposal
+ * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\PavaoProposalUnitProduct> $products
+ */
+class PavaoProposalUnit extends Model
+{
+    use HasFactory, SoftDeletes;
+
+    protected $table = 'pavao_proposal_units';
+
+    protected $guarded = ['id'];
+
+    protected $casts = [
+        'registration_value'                 => 'float',
+        'registration_included_in_course'    => 'boolean',
+        'registration_installments_allowed'  => 'boolean',
+        'registration_max_installments'      => 'integer',
+        'classes_value'                      => 'float',
+        'classes_included_in_course'         => 'boolean',
+        'classes_installments_allowed'       => 'boolean',
+        'classes_max_installments'           => 'integer',
+        'classes_discount_percentage'        => 'float',
+        'materials_total_value'              => 'float',
+        'materials_included_in_course'       => 'boolean',
+        'materials_installments_allowed'     => 'boolean',
+        'materials_max_installments'         => 'integer',
+        'total_value'                        => 'float',
+        'total_max_installments'             => 'integer',
+    ];
+
+    public function packageUnit(): BelongsTo
+    {
+        return $this->belongsTo(ClassPackageUnit::class, 'class_package_unit_id');
+    }
+
+    public function basePavaoProposal(): BelongsTo
+    {
+        return $this->belongsTo(PavaoProposal::class, 'pavao_proposal_id');
+    }
+
+    public function products(): HasMany
+    {
+        return $this->hasMany(PavaoProposalUnitProduct::class);
+    }
+}

+ 40 - 0
app/Models/PavaoProposalUnitProduct.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+/**
+ * @property int $id
+ * @property int $pavao_proposal_unit_id
+ * @property int $product_id
+ * @property int $quantity
+ * @property float $price
+ * @property-read \App\Models\PavaoProposalUnit $pavaoProposalUnit
+ * @property-read \App\Models\Product $product
+ */
+class PavaoProposalUnitProduct extends Model
+{
+    use HasFactory;
+
+    protected $table = 'pavao_proposal_unit_products';
+
+    protected $guarded = ['id'];
+
+    protected $casts = [
+        'quantity' => 'integer',
+        'price'    => 'float',
+    ];
+
+    public function pavaoProposalUnit(): BelongsTo
+    {
+        return $this->belongsTo(PavaoProposalUnit::class);
+    }
+
+    public function product(): BelongsTo
+    {
+        return $this->belongsTo(Product::class);
+    }
+}

+ 27 - 6
app/Services/ClassPackageService.php

@@ -4,6 +4,10 @@
 
 use App\Models\ClassPackage;
 use App\Models\ClassPackageUnit;
+use App\Models\IrrecusableProposal;
+use App\Models\IrrecusableProposalProduct;
+use App\Models\PavaoProposal;
+use App\Models\PavaoProposalProduct;
 use App\Models\Unit;
 use Illuminate\Database\Eloquent\Collection;
 use Illuminate\Support\Facades\DB;
@@ -12,6 +16,7 @@ class ClassPackageService
 {
     public function __construct(
         protected ClassPackageUnitService $unitService,
+        protected ProposalPricingService $pricingService,
     ) {}
 
     public function getAll(): Collection
@@ -21,7 +26,7 @@ public function getAll(): Collection
 
     public function findById(int $id): ?ClassPackage
     {
-        return ClassPackage::with(['products', 'unitPackages', 'groups'])->find($id);
+        return ClassPackage::with(['products', 'unitPackages', 'groups', 'pavaoProposal.products.product', 'irrecusableProposal.products.product'])->find($id);
     }
 
     public function create(array $data): ClassPackage
@@ -29,7 +34,9 @@ public function create(array $data): ClassPackage
         $materials        = $data['materials'] ?? [];
         $unitVisibilities = $data['unit_visibilities'] ?? null;
         $groupIds         = $data['group_ids'] ?? [];
-        unset($data['materials'], $data['unit_visibilities'], $data['group_ids']);
+        $pavao            = $data['pavao'] ?? [];
+        $irrecusavel      = $data['irrecusavel'] ?? [];
+        unset($data['materials'], $data['unit_visibilities'], $data['group_ids'], $data['pavao'], $data['irrecusavel']);
 
         $data['contract_material_value'] = $this->calcMaterialValue($materials);
 
@@ -37,8 +44,11 @@ public function create(array $data): ClassPackage
 
         $this->syncMaterials($package, $materials);
 
+        $this->pricingService->upsert(PavaoProposal::class, PavaoProposalProduct::class, 'class_package_id', $package->id, $pavao);
+        $this->pricingService->upsert(IrrecusableProposal::class, IrrecusableProposalProduct::class, 'class_package_id', $package->id, $irrecusavel, withCondition: true);
+
         // Replicate to all units (all start visible = true)
-        $this->replicateToAllUnits($package->load('products'));
+        $this->replicateToAllUnits($package->load(['products', 'pavaoProposal.products', 'irrecusableProposal.products']));
 
         // Sync selected groups
         if (!empty($groupIds)) {
@@ -48,7 +58,7 @@ public function create(array $data): ClassPackage
         // Apply visibility overrides
         $this->applyVisibilityOverrides($package, $unitVisibilities, $groupIds);
 
-        return $package->load(['products', 'unitPackages', 'groups']);
+        return $package->load(['products', 'unitPackages', 'groups', 'pavaoProposal.products.product', 'irrecusableProposal.products.product']);
     }
 
     public function update(int $id, array $data): ?ClassPackage
@@ -59,7 +69,9 @@ public function update(int $id, array $data): ?ClassPackage
         $materials        = $data['materials'] ?? null;
         $unitVisibilities = $data['unit_visibilities'] ?? null;
         $groupIds         = array_key_exists('group_ids', $data) ? ($data['group_ids'] ?? []) : null;
-        unset($data['materials'], $data['unit_visibilities'], $data['group_ids']);
+        $pavao            = $data['pavao'] ?? null;
+        $irrecusavel      = $data['irrecusavel'] ?? null;
+        unset($data['materials'], $data['unit_visibilities'], $data['group_ids'], $data['pavao'], $data['irrecusavel']);
 
         if ($materials !== null) {
             $data['contract_material_value'] = $this->calcMaterialValue($materials);
@@ -84,6 +96,15 @@ public function update(int $id, array $data): ?ClassPackage
             $this->syncUnitPackageProducts($package->load('products'));
         }
 
+        // Pavão/Irrecusável updates never propagate to already-cloned unit copies.
+        if ($pavao !== null) {
+            $this->pricingService->upsert(PavaoProposal::class, PavaoProposalProduct::class, 'class_package_id', $package->id, $pavao);
+        }
+
+        if ($irrecusavel !== null) {
+            $this->pricingService->upsert(IrrecusableProposal::class, IrrecusableProposalProduct::class, 'class_package_id', $package->id, $irrecusavel, withCondition: true);
+        }
+
         if ($groupIds !== null) {
             $package->groups()->sync($groupIds);
         }
@@ -95,7 +116,7 @@ public function update(int $id, array $data): ?ClassPackage
             $this->applyVisibilityOverrides($package, $unitVisibilities, $currentGroupIds);
         }
 
-        return $package->fresh(['products', 'unitPackages', 'groups']);
+        return $package->fresh(['products', 'unitPackages', 'groups', 'pavaoProposal.products.product', 'irrecusableProposal.products.product']);
     }
 
     public function delete(int $id): bool

+ 58 - 9
app/Services/ClassPackageUnitService.php

@@ -4,13 +4,27 @@
 
 use App\Models\ClassPackageUnit;
 use App\Models\ClassPackageUnitProduct;
+use App\Models\IrrecusableProposalUnit;
+use App\Models\IrrecusableProposalUnitProduct;
+use App\Models\PavaoProposalUnit;
+use App\Models\PavaoProposalUnitProduct;
 use Illuminate\Database\Eloquent\Collection;
 
 class ClassPackageUnitService
 {
+    public function __construct(
+        protected ProposalPricingService $pricingService,
+    ) {}
+
+    private const EAGER_LOAD = [
+        'products.product',
+        'pavaoProposalUnit.products.product',
+        'irrecusableProposalUnit.products.product',
+    ];
+
     public function getByUnit(int $unitId): Collection
     {
-        return ClassPackageUnit::with('products.product')
+        return ClassPackageUnit::with(self::EAGER_LOAD)
             ->where('unit_id', $unitId)
             ->where('visible', true)
             ->orderBy('name')
@@ -19,7 +33,7 @@ public function getByUnit(int $unitId): Collection
 
     public function getAllByUnit(int $unitId): Collection
     {
-        return ClassPackageUnit::with('products.product')
+        return ClassPackageUnit::with(self::EAGER_LOAD)
             ->where('unit_id', $unitId)
             ->orderBy('name')
             ->get();
@@ -27,15 +41,17 @@ public function getAllByUnit(int $unitId): Collection
 
     public function findByIdForUnit(int $id, int $unitId): ClassPackageUnit
     {
-        return ClassPackageUnit::with('products.product')
+        return ClassPackageUnit::with(self::EAGER_LOAD)
             ->where('unit_id', $unitId)
             ->findOrFail($id);
     }
 
     public function create(array $data): ClassPackageUnit
     {
-        $materials = $data['materials'] ?? [];
-        unset($data['materials']);
+        $materials   = $data['materials'] ?? [];
+        $pavao       = $data['pavao'] ?? [];
+        $irrecusavel = $data['irrecusavel'] ?? [];
+        unset($data['materials'], $data['pavao'], $data['irrecusavel']);
 
         $data['contract_material_value'] = $this->calcMaterialValue($materials);
 
@@ -43,15 +59,20 @@ public function create(array $data): ClassPackageUnit
 
         $this->syncProducts($packageUnit, $materials);
 
-        return $packageUnit->load('products.product');
+        $this->pricingService->upsert(PavaoProposalUnit::class, PavaoProposalUnitProduct::class, 'class_package_unit_id', $packageUnit->id, $pavao);
+        $this->pricingService->upsert(IrrecusableProposalUnit::class, IrrecusableProposalUnitProduct::class, 'class_package_unit_id', $packageUnit->id, $irrecusavel, withCondition: true);
+
+        return $packageUnit->load(self::EAGER_LOAD);
     }
 
     public function update(int $id, int $unitId, array $data): ClassPackageUnit
     {
         $packageUnit = $this->findByIdForUnit($id, $unitId);
 
-        $materials = $data['materials'] ?? null;
-        unset($data['materials']);
+        $materials   = $data['materials'] ?? null;
+        $pavao       = $data['pavao'] ?? null;
+        $irrecusavel = $data['irrecusavel'] ?? null;
+        unset($data['materials'], $data['pavao'], $data['irrecusavel']);
 
         if ($materials !== null) {
             $data['contract_material_value'] = $this->calcMaterialValue($materials);
@@ -63,7 +84,16 @@ public function update(int $id, int $unitId, array $data): ClassPackageUnit
             $this->syncProducts($packageUnit, $materials);
         }
 
-        return $packageUnit->fresh('products.product');
+        // Edits here only affect this unit's own copy — never propagate up to the base package.
+        if ($pavao !== null) {
+            $this->pricingService->upsert(PavaoProposalUnit::class, PavaoProposalUnitProduct::class, 'class_package_unit_id', $packageUnit->id, $pavao);
+        }
+
+        if ($irrecusavel !== null) {
+            $this->pricingService->upsert(IrrecusableProposalUnit::class, IrrecusableProposalUnitProduct::class, 'class_package_unit_id', $packageUnit->id, $irrecusavel, withCondition: true);
+        }
+
+        return $packageUnit->fresh(self::EAGER_LOAD);
     }
 
     public function toggleVisibility(int $id, int $unitId): ClassPackageUnit
@@ -127,6 +157,25 @@ public function replicateFromBasePackage(int $unitId, \App\Models\ClassPackage $
             ]);
         });
 
+        $this->pricingService->cloneToUnit(
+            $basePackage->pavaoProposal,
+            PavaoProposalUnit::class,
+            PavaoProposalUnitProduct::class,
+            'class_package_unit_id',
+            $packageUnit->id,
+            'pavao_proposal_id',
+        );
+
+        $this->pricingService->cloneToUnit(
+            $basePackage->irrecusableProposal,
+            IrrecusableProposalUnit::class,
+            IrrecusableProposalUnitProduct::class,
+            'class_package_unit_id',
+            $packageUnit->id,
+            'irrecusable_proposal_id',
+            withCondition: true,
+        );
+
         return $packageUnit;
     }
 

+ 121 - 0
app/Services/ProposalPricingService.php

@@ -0,0 +1,121 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Str;
+
+/**
+ * Generic CRUD/clone helpers for the Pavão/Irrecusável pricing blocks, reused
+ * across the 4 tables (base/unit level x Pavão/Irrecusável) since they all
+ * share the same column shape (Matrícula, Aulas, Materiais, Valor Total).
+ */
+class ProposalPricingService
+{
+    private const VALUE_FIELDS = [
+        'registration_value',
+        'registration_included_in_course',
+        'registration_installments_allowed',
+        'registration_max_installments',
+        'classes_value',
+        'classes_included_in_course',
+        'classes_installments_allowed',
+        'classes_max_installments',
+        'classes_discount_percentage',
+        'materials_included_in_course',
+        'materials_installments_allowed',
+        'materials_max_installments',
+        'total_value',
+        'total_max_installments',
+    ];
+
+    /**
+     * Create or update the pricing block owned by $ownerId (a class_package or
+     * class_package_unit row), plus its material lines.
+     */
+    public function upsert(
+        string $modelClass,
+        string $productModelClass,
+        string $ownerColumn,
+        int $ownerId,
+        array $data,
+        bool $withCondition = false,
+    ): Model {
+        $materials  = $data['materials'] ?? [];
+        $attributes = array_intersect_key($data, array_flip($this->fields($withCondition)));
+
+        $attributes['materials_total_value'] = $this->calcMaterialValue($materials);
+
+        /** @var Model $proposal */
+        $proposal = $modelClass::updateOrCreate([$ownerColumn => $ownerId], $attributes);
+
+        $this->syncMaterials($proposal, $productModelClass, $materials);
+
+        return $proposal->fresh('products.product');
+    }
+
+    /**
+     * Clone a base-level Pavão/Irrecusável proposal (with materials) into a
+     * unit-level copy. Only called at replication time (new unit, or new
+     * proposal replicated to existing units) — unit copies are independent
+     * afterwards and are never overwritten by later base updates.
+     */
+    public function cloneToUnit(
+        ?Model $baseProposal,
+        string $unitModelClass,
+        string $unitProductModelClass,
+        string $unitOwnerColumn,
+        int $unitOwnerId,
+        string $baseForeignKeyColumn,
+        bool $withCondition = false,
+    ): void {
+        if (!$baseProposal) return;
+
+        $attributes = $baseProposal->only($this->fields($withCondition));
+
+        $attributes['materials_total_value']  = $baseProposal->materials_total_value;
+        $attributes[$unitOwnerColumn]         = $unitOwnerId;
+        $attributes[$baseForeignKeyColumn]    = $baseProposal->id;
+
+        /** @var Model $unitProposal */
+        $unitProposal = $unitModelClass::create($attributes);
+
+        $baseProposal->products->each(function ($product) use ($unitProposal, $unitProductModelClass) {
+            $unitProductModelClass::create([
+                $this->foreignKeyFor($unitProposal) => $unitProposal->id,
+                'product_id'                        => $product->product_id,
+                'quantity'                           => $product->quantity,
+                'price'                              => $product->price,
+            ]);
+        });
+    }
+
+    private function fields(bool $withCondition): array
+    {
+        return $withCondition ? [...self::VALUE_FIELDS, 'condition'] : self::VALUE_FIELDS;
+    }
+
+    private function syncMaterials(Model $proposal, string $productModelClass, array $materials): void
+    {
+        $proposal->products()->delete();
+
+        foreach ($materials as $material) {
+            $productModelClass::create([
+                $this->foreignKeyFor($proposal) => $proposal->id,
+                'product_id'                    => $material['product_id'],
+                'quantity'                       => $material['quantity'],
+                'price'                          => $material['price'],
+            ]);
+        }
+    }
+
+    private function foreignKeyFor(Model $model): string
+    {
+        return Str::snake(class_basename($model)) . '_id';
+    }
+
+    private function calcMaterialValue(array $materials): float
+    {
+        return array_reduce($materials, fn($carry, $m) => $carry + ($m['quantity'] * $m['price']), 0.0);
+    }
+}

+ 1 - 1
app/Services/UnitService.php

@@ -115,7 +115,7 @@ public function delete(int $id): bool
 
     private function replicatePackagesToUnit(int $unitId): void
     {
-        ClassPackage::with('products')->get()->each(function (ClassPackage $package) use ($unitId) {
+        ClassPackage::with(['products', 'pavaoProposal.products', 'irrecusableProposal.products'])->get()->each(function (ClassPackage $package) use ($unitId) {
             $this->packageUnitService->replicateFromBasePackage($unitId, $package);
         });
     }

+ 54 - 0
database/migrations/2026_08_17_000001_create_pavao_and_irrecusable_proposals_table.php

@@ -0,0 +1,54 @@
+<?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
+    {
+        $this->createProposalTable('pavao_proposals', 'class_packages');
+        $this->createProposalTable('irrecusable_proposals', 'class_packages', withCondition: true);
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('irrecusable_proposals');
+        Schema::dropIfExists('pavao_proposals');
+    }
+
+    private function createProposalTable(string $table, string $baseTable, bool $withCondition = false): void
+    {
+        Schema::create($table, function (Blueprint $table) use ($baseTable, $withCondition) {
+            $table->id();
+            $table->foreignId('class_package_id')->unique()->constrained($baseTable)->cascadeOnDelete();
+
+            $table->decimal('registration_value', 10, 2)->nullable();
+            $table->boolean('registration_included_in_course')->default(false);
+            $table->boolean('registration_installments_allowed')->default(false);
+            $table->unsignedSmallInteger('registration_max_installments')->default(13);
+
+            $table->decimal('classes_value', 10, 2)->nullable();
+            $table->boolean('classes_included_in_course')->default(false);
+            $table->boolean('classes_installments_allowed')->default(false);
+            $table->unsignedSmallInteger('classes_max_installments')->default(13);
+            $table->decimal('classes_discount_percentage', 5, 2)->nullable();
+
+            $table->decimal('materials_total_value', 10, 2)->default(0);
+            $table->boolean('materials_included_in_course')->default(false);
+            $table->boolean('materials_installments_allowed')->default(false);
+            $table->unsignedSmallInteger('materials_max_installments')->default(13);
+
+            $table->decimal('total_value', 10, 2)->nullable();
+            $table->unsignedSmallInteger('total_max_installments')->default(13);
+
+            if ($withCondition) {
+                $table->text('condition')->nullable();
+            }
+
+            $table->timestamps();
+            $table->softDeletes();
+        });
+    }
+};

+ 39 - 0
database/migrations/2026_08_17_000002_create_pavao_and_irrecusable_proposal_products_table.php

@@ -0,0 +1,39 @@
+<?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
+    {
+        $this->createProductsTable('pavao_proposal_products', 'pavao_proposal_id', 'pavao_proposals', 'pavao_prop_prod');
+        $this->createProductsTable('irrecusable_proposal_products', 'irrecusable_proposal_id', 'irrecusable_proposals', 'irrec_prop_prod');
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('irrecusable_proposal_products');
+        Schema::dropIfExists('pavao_proposal_products');
+    }
+
+    /**
+     * Postgres truncates identifiers at 63 chars, so long table/column combos
+     * risk colliding auto-generated FK/unique constraint names — pass short
+     * explicit names instead.
+     */
+    private function createProductsTable(string $table, string $foreignKey, string $parentTable, string $shortPrefix): void
+    {
+        Schema::create($table, function (Blueprint $table) use ($foreignKey, $parentTable, $shortPrefix) {
+            $table->id();
+            $table->foreignId($foreignKey)->constrained($parentTable, indexName: "{$shortPrefix}_fk")->cascadeOnDelete();
+            $table->foreignId('product_id')->constrained('products')->cascadeOnDelete();
+            $table->integer('quantity')->default(1);
+            $table->decimal('price', 10, 2);
+            $table->timestamps();
+
+            $table->unique([$foreignKey, 'product_id'], "{$shortPrefix}_unique");
+        });
+    }
+};

+ 55 - 0
database/migrations/2026_08_17_000003_create_pavao_and_irrecusable_proposal_units_table.php

@@ -0,0 +1,55 @@
+<?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
+    {
+        $this->createProposalUnitTable('pavao_proposal_units', 'pavao_proposal_id', 'pavao_proposals');
+        $this->createProposalUnitTable('irrecusable_proposal_units', 'irrecusable_proposal_id', 'irrecusable_proposals', withCondition: true);
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('irrecusable_proposal_units');
+        Schema::dropIfExists('pavao_proposal_units');
+    }
+
+    private function createProposalUnitTable(string $table, string $baseForeignKey, string $baseTable, bool $withCondition = false): void
+    {
+        Schema::create($table, function (Blueprint $table) use ($baseForeignKey, $baseTable, $withCondition) {
+            $table->id();
+            $table->foreignId('class_package_unit_id')->unique()->constrained('class_package_units')->cascadeOnDelete();
+            $table->foreignId($baseForeignKey)->nullable()->constrained($baseTable)->nullOnDelete();
+
+            $table->decimal('registration_value', 10, 2)->nullable();
+            $table->boolean('registration_included_in_course')->default(false);
+            $table->boolean('registration_installments_allowed')->default(false);
+            $table->unsignedSmallInteger('registration_max_installments')->default(13);
+
+            $table->decimal('classes_value', 10, 2)->nullable();
+            $table->boolean('classes_included_in_course')->default(false);
+            $table->boolean('classes_installments_allowed')->default(false);
+            $table->unsignedSmallInteger('classes_max_installments')->default(13);
+            $table->decimal('classes_discount_percentage', 5, 2)->nullable();
+
+            $table->decimal('materials_total_value', 10, 2)->default(0);
+            $table->boolean('materials_included_in_course')->default(false);
+            $table->boolean('materials_installments_allowed')->default(false);
+            $table->unsignedSmallInteger('materials_max_installments')->default(13);
+
+            $table->decimal('total_value', 10, 2)->nullable();
+            $table->unsignedSmallInteger('total_max_installments')->default(13);
+
+            if ($withCondition) {
+                $table->text('condition')->nullable();
+            }
+
+            $table->timestamps();
+            $table->softDeletes();
+        });
+    }
+};

+ 39 - 0
database/migrations/2026_08_17_000004_create_pavao_and_irrecusable_proposal_unit_products_table.php

@@ -0,0 +1,39 @@
+<?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
+    {
+        $this->createProductsTable('pavao_proposal_unit_products', 'pavao_proposal_unit_id', 'pavao_proposal_units', 'pavao_prop_unit_prod');
+        $this->createProductsTable('irrecusable_proposal_unit_products', 'irrecusable_proposal_unit_id', 'irrecusable_proposal_units', 'irrec_prop_unit_prod');
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('irrecusable_proposal_unit_products');
+        Schema::dropIfExists('pavao_proposal_unit_products');
+    }
+
+    /**
+     * Postgres truncates identifiers at 63 chars, so the default auto-generated
+     * FK/unique constraint names collide for these long table/column combos —
+     * pass short explicit names instead.
+     */
+    private function createProductsTable(string $table, string $foreignKey, string $parentTable, string $shortPrefix): void
+    {
+        Schema::create($table, function (Blueprint $table) use ($foreignKey, $parentTable, $shortPrefix) {
+            $table->id();
+            $table->foreignId($foreignKey)->constrained($parentTable, indexName: "{$shortPrefix}_fk")->cascadeOnDelete();
+            $table->foreignId('product_id')->constrained('products')->cascadeOnDelete();
+            $table->integer('quantity')->default(1);
+            $table->decimal('price', 10, 2);
+            $table->timestamps();
+
+            $table->unique([$foreignKey, 'product_id'], "{$shortPrefix}_unique");
+        });
+    }
+};

+ 68 - 0
docs/proposta_comercial_v1.md

@@ -9,3 +9,71 @@ # Proposta Comercial
 ## Adicionar Tabs Pavão e Irrecusável
 
 ![Imagem de Referência](./assets/proposta_comercial_v1.png)
+
+- Menu Pacotes deve ser alterado para Proposta Comercial em Franchisor e Franchisee
+- Titulo do DefaultHeaderPage deve ser alterado para Proposta Comercial em Franchisor e Franchisee
+- Os dados atuais em uma plano de Comercial, agora deve estar em uma tab chamada Dados
+- Nova Tab ao Criar / Editar uma nova proposta comercial:
+    - Pavão
+    - Irrecusável
+
+- As tabs ficarão: Dados, Pavão, Irrecusável
+
+**Campos de Pavão**:
+
+- Valor da Matricula (col-12)
+- dois checkbox abaixo:
+    - Incluso no valor do curso, Permite Parcelar (Se selecionar um, não pode selecionar o outro. Selecionar um dos dois é obrigatório)
+    - Se Permite Parcelar, dois novos campoes são abertos:
+        - Quantidade de Parcelas Máximas (Padrão 13)
+        - Valor da Parcela (Computed = Valor total da Matricula / Quantidade de Parcelas selecionadas)
+    - Se Incluso no Valor do curso, esses campos não devem existir, nem deve poder parcelar individualmente o valor da matrícula. 
+    - Se Incluso no Valor do curso tiver em R$1.000,00, automaticamente, se soma abaixo em Valor Total do Curso. Se tiver valor em Valor Total do Curso, deve ficar o valor atual + o valor da matricula.
+
+
+    
+- Section do Valor de Aulas
+    - Valor de Aulas
+    - Mesmos checkbox de Incluso no Valor do Curso e Permite Parcelar, seguindo as mesmas lógicas
+    - Ao lado do valor da parcela deve ter um novo campo de Desconto (%)
+
+- Section de Materiais
+    - Segue a mesma lógica da Tab de Dados Básicos, podendo adicionar quantos itens quiser
+    - Select de Produtos vindo de products
+    - Checkbox de Incluso no valor do Curso e Permite Parcelar, ao final da lista de produtos, abrindo os mesmos campos. Soma o valor dos itens para considerar o valor total dos campos de Qtd PArcelas Max e Valor da Parcela
+
+- Section Final de Valor Total do Curso
+    - Campo de Valor Total do Curso com os valores acumulados (Valor da Matricula + Valor de Aulas + Valor dos itens), se tiverem inclusos.
+    - Campo do Valor Total é editável, caso o usuário quiser mudar, é possível.
+    - Abaixo dois campos obrigatórios, Qtd de Parcelas Max começando com 13, Valor da Parcela, seguindo a mesma regra das demais.
+
+
+**Campos de Irrecusável**:
+
+Devem ter exatamente os mesmos campos de Pavão, mas adicionar um campo a mais textarea chamando Condição, que deve ser a condição que essa proposta é acionada. Campo livre para escrever.
+
+**Backend**:
+
+- Além da criação dos campos, devem ser separados em tabelas para melhor reutilização.
+
+Agora teremos nível da proposta.
+
+- Dados Básicos (Dados básicos da proposta, seguindo exatamente como está hoje em pacotes)
+
+Estrutura inicial pensada:
+
+proposals: com os dados da proposta básica, como é hoje em pacotes
+pavao_proposals: linka a proposal base, mas os campos são independentes. Ou seja, adicionado novamente o valor do curso e etc + os campos novos
+
+irrecusable_proposals: linka a proposal base, mas os campos são independentes. Da mesma maneira que o pavao. Uma proposta pode ter um pavao e uma irrecusavel para ela.
+
+*Extra*
+
+Isso tudo é pensado para Franchisor, mas Franchisee, também possui essa mesma lógica com um detalhe importante:
+
+Ao criar-se propostas comerciais em franchisor, elas são enviadas para suas unidades também. Ao criar novas unidades, varre-se as propostas existentes e "clona" em unidades, ao criar novas propostas, deve-se registrar nas unidades existentes.
+
+As unidades podem alterar as propostas criadas pela matriz, mas não deve refletir em todas, mas apenas na proposta daquela unidade específica.
+
+
+## Contrato com Estudante (Aguardando Definições)

+ 140 - 0
tests/Unit/Http/Requests/Concerns/ValidatesProposalPricingTest.php

@@ -0,0 +1,140 @@
+<?php
+
+namespace Tests\Unit\Http\Requests\Concerns;
+
+use App\Http\Requests\Concerns\ValidatesProposalPricing;
+use Illuminate\Contracts\Validation\Validator as ValidatorContract;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Support\Facades\Validator as ValidatorFacade;
+use Illuminate\Validation\Validator;
+use Tests\TestCase;
+
+class ValidatesProposalPricingTest extends TestCase
+{
+    public function test_it_rejects_the_registration_section_when_neither_option_is_selected(): void
+    {
+        $validator = $this->validatorFor([
+            'pavao' => [
+                'registration_included_in_course'   => false,
+                'registration_installments_allowed' => false,
+            ],
+        ]);
+
+        $this->assertSame(
+            ['Selecione exatamente uma opção: incluso no valor do curso ou permite parcelar.'],
+            $validator->errors()->get('pavao.registration_included_in_course'),
+        );
+    }
+
+    public function test_it_rejects_the_registration_section_when_both_options_are_selected(): void
+    {
+        $validator = $this->validatorFor([
+            'pavao' => [
+                'registration_included_in_course'   => true,
+                'registration_installments_allowed' => true,
+            ],
+        ]);
+
+        $this->assertTrue($validator->errors()->has('pavao.registration_included_in_course'));
+    }
+
+    public function test_it_accepts_the_registration_section_when_exactly_one_option_is_selected(): void
+    {
+        $validator = $this->validatorFor([
+            'pavao' => [
+                'registration_included_in_course'   => true,
+                'registration_installments_allowed' => false,
+            ],
+        ]);
+
+        $this->assertFalse($validator->errors()->has('pavao.registration_included_in_course'));
+    }
+
+    public function test_it_validates_the_classes_and_materials_sections_independently(): void
+    {
+        $validator = $this->validatorFor([
+            'pavao' => [
+                'registration_included_in_course'   => true,
+                'registration_installments_allowed' => false,
+                'classes_included_in_course'        => false,
+                'classes_installments_allowed'      => false,
+                'materials_included_in_course'      => true,
+                'materials_installments_allowed'    => true,
+            ],
+        ]);
+
+        $this->assertFalse($validator->errors()->has('pavao.registration_included_in_course'));
+        $this->assertTrue($validator->errors()->has('pavao.classes_included_in_course'));
+        $this->assertTrue($validator->errors()->has('pavao.materials_included_in_course'));
+    }
+
+    public function test_it_skips_a_block_that_was_not_sent(): void
+    {
+        $validator = $this->validatorFor([
+            'irrecusavel' => [
+                'registration_included_in_course'   => true,
+                'registration_installments_allowed' => false,
+                'classes_included_in_course'        => true,
+                'classes_installments_allowed'      => false,
+                'materials_included_in_course'      => true,
+                'materials_installments_allowed'    => false,
+            ],
+        ]);
+
+        $this->assertFalse($validator->errors()->has('pavao.registration_included_in_course'));
+        $this->assertFalse($validator->errors()->any());
+    }
+
+    public function test_it_validates_pavao_and_irrecusavel_blocks_independently(): void
+    {
+        $validator = $this->validatorFor([
+            'pavao' => [
+                'registration_included_in_course'   => true,
+                'registration_installments_allowed' => false,
+                'classes_included_in_course'        => true,
+                'classes_installments_allowed'      => false,
+                'materials_included_in_course'      => true,
+                'materials_installments_allowed'    => false,
+            ],
+            'irrecusavel' => [
+                'registration_included_in_course'   => false,
+                'registration_installments_allowed' => false,
+                'classes_included_in_course'        => true,
+                'classes_installments_allowed'      => false,
+                'materials_included_in_course'      => true,
+                'materials_installments_allowed'    => false,
+            ],
+        ]);
+
+        $this->assertFalse($validator->errors()->has('pavao.registration_included_in_course'));
+        $this->assertTrue($validator->errors()->has('irrecusavel.registration_included_in_course'));
+    }
+
+    private function validatorFor(array $data): Validator
+    {
+        $request   = TestableProposalPricingRequest::create('/', 'POST', $data);
+        $validator = ValidatorFacade::make($request->all(), $request->rules());
+
+        $request->withValidator($validator);
+
+        return $validator;
+    }
+}
+
+class TestableProposalPricingRequest extends FormRequest
+{
+    use ValidatesProposalPricing;
+
+    public function rules(): array
+    {
+        return [
+            ...$this->proposalPricingRules('pavao', 'sometimes'),
+            ...$this->proposalPricingRules('irrecusavel', 'sometimes', withCondition: true),
+        ];
+    }
+
+    public function withValidator(ValidatorContract $validator): void
+    {
+        $this->validateProposalPricingSections($validator, ['pavao', 'irrecusavel']);
+    }
+}