Bladeren bron

feat: add Pavao and Irrecusable proposal models and services

- Introduced models for Pavao and Irrecusable proposals and their respective products and units.
- Implemented ProposalPricingService for handling CRUD operations and cloning for proposal pricing.
- Updated ClassPackageService and ClassPackageUnitService to integrate new proposal functionalities.
- Created migrations for the new proposal and product tables.
- Added validation tests for proposal pricing sections to ensure correct data handling.
alvesantos 19 uur geleden
bovenliggende
commit
570906bdbe
25 gewijzigde bestanden met toevoegingen van 1162 en 16 verwijderingen
  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. 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");
+        });
+    }
+};

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