From 6707cca76da75b0a89f901929b6b2806a30882f2 Mon Sep 17 00:00:00 2001 From: Jefferson Oliveira Date: Thu, 30 Jul 2026 11:44:29 -0300 Subject: [PATCH] =?UTF-8?q?fix:=20campos=20obrigat=C3=B3rios=20para=20tram?= =?UTF-8?q?itar=20(aba=20de=20or=C3=A7amento)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Contracts/StageValidatorInterface.php | 10 ++ .../Controllers/ProjectStageController.php | 13 -- app/Services/BudgetService.php | 59 ++++++- app/Services/FormalizationService.php | 3 +- app/Services/OpeningUpdateService.php | 3 +- app/Services/ProjectStageService.php | 16 ++ .../Partials/Tabs/BudgetTab.vue | 148 ++++++++++-------- tests/Feature/ProjectStageFlowTest.php | 99 ++++++++++++ 8 files changed, 270 insertions(+), 81 deletions(-) create mode 100644 app/Contracts/StageValidatorInterface.php diff --git a/app/Contracts/StageValidatorInterface.php b/app/Contracts/StageValidatorInterface.php new file mode 100644 index 00000000..529fa49a --- /dev/null +++ b/app/Contracts/StageValidatorInterface.php @@ -0,0 +1,10 @@ +load('project'); - if ($stage->slug === ProjectStageSlug::ABERTURA) { - $this->openingUpdateService->ensureCanAdvance($project); - } - - if ($stage->slug === ProjectStageSlug::FORMALIZACAO) { - $this->formalizationService->ensureCanAdvance($project); - } - $nextStage = $this->stageService->advance($stage, $request->user()); $this->notificationService->notifyStageAdvanced($stage, $nextStage, $request->user()); diff --git a/app/Services/BudgetService.php b/app/Services/BudgetService.php index 55f3f105..aa333b19 100644 --- a/app/Services/BudgetService.php +++ b/app/Services/BudgetService.php @@ -2,11 +2,15 @@ namespace App\Services; +use App\Contracts\StageValidatorInterface; +use App\Enums\DocumentPhase; +use App\Enums\DocumentType; use App\Models\Budget; use App\Models\Project; use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; -class BudgetService +class BudgetService implements StageValidatorInterface { public function create(Project $project, array $data): Budget { @@ -85,4 +89,57 @@ private function installmentData(array $data): array 'observations' => $data['installment_observations'] ?? null, ]; } + + public function ensureCanAdvance(Project $project): void + { + $budget = $project->budgets; + + if (! $budget) { + throw ValidationException::withMessages([ + 'budget' => 'Preencha e salve os dados do orçamento antes de tramitar.', + ]); + } + + $this->validateRequiredFields($project, $budget); + $this->validateRequiredDocuments($project); + } + + private function validateRequiredFields(Project $project, Budget $budget): void + { + $currentInstallment = $budget->installments() + ->where('installment_number', $project->current_installment_cycle) + ->first(); + + $missingFields = collect(); + + if (blank($currentInstallment?->notice_installment_number)) { + $missingFields->push('Nº da parcela no edital'); + } + + if (blank($currentInstallment?->amount)) { + $missingFields->push('Valor da parcela'); + } + + if ($missingFields->isNotEmpty()) { + throw ValidationException::withMessages([ + 'budget' => 'Preencha todos os campos obrigatórios antes de tramitar: ' + .$missingFields->join(', ') + .'.', + ]); + } + } + + private function validateRequiredDocuments(Project $project): void + { + $hasBudgetOpinion = $project->documents() + ->where('phase', DocumentPhase::BUDGET->value ?? 'budget') + ->where('type', DocumentType::DO->value ?? 'do') + ->exists(); + + if (! $hasBudgetOpinion) { + throw ValidationException::withMessages([ + 'documents' => 'O despacho orçamentário precisa ser gerado antes da tramitação.', + ]); + } + } } diff --git a/app/Services/FormalizationService.php b/app/Services/FormalizationService.php index 597e6bc3..89392f97 100644 --- a/app/Services/FormalizationService.php +++ b/app/Services/FormalizationService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Contracts\StageValidatorInterface; use App\Enums\DocumentPhase; use App\Enums\DocumentType; use App\Models\File; @@ -12,7 +13,7 @@ use Illuminate\Support\Facades\Storage; use Illuminate\Validation\ValidationException; -class FormalizationService +class FormalizationService implements StageValidatorInterface { private const OFFICIAL_GAZETTE_FILE_GROUP = 'official_gazette'; diff --git a/app/Services/OpeningUpdateService.php b/app/Services/OpeningUpdateService.php index a9c7d2fa..e313f31b 100644 --- a/app/Services/OpeningUpdateService.php +++ b/app/Services/OpeningUpdateService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Contracts\StageValidatorInterface; use App\Enums\ProfileSnapshotSource; use App\Models\Opening; use App\Models\Project; @@ -10,7 +11,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; -class OpeningUpdateService +class OpeningUpdateService implements StageValidatorInterface { public function __construct( protected ProfileSnapshotService $snapshotService diff --git a/app/Services/ProjectStageService.php b/app/Services/ProjectStageService.php index aa5286e6..9e4f5118 100644 --- a/app/Services/ProjectStageService.php +++ b/app/Services/ProjectStageService.php @@ -32,6 +32,8 @@ public function advance( $this->ensureUserHasRole($stage, $user, 'Você não tem permissão para tramitar esta etapa.'); + $this->validateStageAdvance($stage, $stage->project); + if ($stage->slug === ProjectStageSlug::MONITORAMENTO) { $this->ensureIsPrincipalSupervisor($stage->project, $user); } @@ -56,6 +58,20 @@ public function advance( return $next?->fresh(); } + public function validateStageAdvance(ProjectStage $stage, Project $project): void + { + $validatorClass = match ($stage->slug) { + ProjectStageSlug::ABERTURA => OpeningUpdateService::class, + ProjectStageSlug::FORMALIZACAO => FormalizationService::class, + ProjectStageSlug::ORCAMENTO => BudgetService::class, + default => null, + }; + + if ($validatorClass) { + app($validatorClass)->ensureCanAdvance($project); + } + } + public function reject(ProjectStage $stage, string $reason, User $user): void { if ($stage->status !== ProjectStageStatus::EM_ANDAMENTO) { diff --git a/resources/js/Pages/ProjectDetails/Partials/Tabs/BudgetTab.vue b/resources/js/Pages/ProjectDetails/Partials/Tabs/BudgetTab.vue index 2d41f646..6d119fd6 100644 --- a/resources/js/Pages/ProjectDetails/Partials/Tabs/BudgetTab.vue +++ b/resources/js/Pages/ProjectDetails/Partials/Tabs/BudgetTab.vue @@ -19,6 +19,7 @@ import { viewSections, formSections } from '@/Schemas/Budget'; import { useDate } from '@/Composables/useDate'; import { useSnackbar } from '@/Composables/useSnackbar'; import { useAlert } from '@/Composables/useAlert'; +import { useSaveShortcut } from '@/Composables/useSaveShortcut'; import { useStageAdvance } from '@/Composables/useStageAdvance'; const props = defineProps({ @@ -38,6 +39,13 @@ const { canUserHandle: canUserHandleBudget } = useStageAdvance(props, STAGE_SLUG const stage = computed(() => props.project.stages?.find((s) => s.slug === STAGE_SLUG)); +const canEditBudget = computed(() => canUserHandleBudget.value && stage.value?.status === 'em_andamento'); + +useSaveShortcut( + () => submit(), + computed(() => canEditBudget.value && !form.processing) +); + const activeViewIndex = ref('all'); const activeEditIndex = ref('all'); const viewerOpen = ref(false); @@ -146,55 +154,31 @@ const submit = () => { saveBudget(); }; -const hasRequiredFields = computed(() => { - return Boolean( - form.processing_date_for_codip && - form.processing_date_for_coafi && - form.installment_amount && - form.installment_request_date && - form.installment_justification - ); -}); +const hasText = (value) => value !== null && value !== undefined && String(value).trim().length > 0; const hasBudgetOpinionDocument = computed(() => !!budgetOpinionDocument.value); -const canTramitBudget = computed(() => { - return canUserHandleBudget.value && hasRequiredFields.value && hasBudgetOpinionDocument.value; +const hasRequiredFieldsFilled = computed(() => { + return ( + hasText(form.notice_installment_number) && hasText(form.installment_amount) && hasBudgetOpinionDocument.value + ); }); -function showTramitBlockedMessage() { - if (canTramitBudget.value) { - return; - } - - if (stage.value?.status === 'bloqueado') { - showSnackbar('Este projeto está bloqueado e não pode receber alterações no momento.', 'warning'); - - return; - } - - if (stage.value?.status !== 'em_andamento') { - showSnackbar('Este projeto não pode ser tramitado no momento.', 'warning'); +const showValidationErrors = ref(false); - return; - } - - if (!canUserHandleBudget.value) { - showSnackbar('Usuário não tem permissão para tramitar orçamento.', 'warning'); - - return; - } +const errors = computed(() => { + if (!showValidationErrors.value) return {}; - if (!hasRequiredFields.value) { - showSnackbar('Preencha todos os campos obrigatórios antes de tramitar.', 'warning'); + const standardMessage = 'Preencha este campo'; - return; - } - - if (!hasBudgetOpinionDocument.value) { - showSnackbar('O despacho orçamentário precisa ser gerado antes da tramitação.', 'warning'); - } -} + return { + notice_installment_number: !hasText(form.notice_installment_number) ? standardMessage : null, + installment_amount: !hasText(form.installment_amount) ? standardMessage : null, + budget_opinion_document: !hasBudgetOpinionDocument.value + ? 'O despacho orçamentário precisa ser gerado antes de tramitar' + : null, + }; +}); const tramitLoading = ref(false); @@ -204,11 +188,18 @@ const tramit = async () => { return; } - if (!canTramitBudget.value) { - showTramitBlockedMessage(); + if (!canEditBudget.value) { + showSnackbar(permissionMessage.value || 'Você não tem permissão para alterar ou tramitar esta etapa.', 'error'); return; } + if (!hasRequiredFieldsFilled.value) { + showValidationErrors.value = true; + showSnackbar('Preencha e salve todos os campos obrigatórios em destaque antes de tramitar.', 'error'); + return; + } + + showValidationErrors.value = false; tramitLoading.value = true; const saved = await saveBudget({ showSuccess: false }); @@ -255,11 +246,19 @@ const permissionMessage = computed(() => { return 'Este projeto está bloqueado e não pode receber alterações no momento.'; } + if (!canUserHandleBudget.value) { + return 'Usuário não tem permissão para fazer alterações no Orçamento.'; + } + if (stage.value?.status !== 'em_andamento') { return 'Projeto já foi tramitado e não está mais na fase de Orçamento.'; } - return 'Usuário não tem permissão para fazer alterações no Orçamento.'; + if (!hasRequiredFieldsFilled.value) { + return 'Preencha e salve todos os campos obrigatórios antes de tramitar.'; + } + + return ''; }); @@ -306,7 +305,7 @@ const permissionMessage = computed(() => {

Links auxiliares

- + @@ -315,8 +314,7 @@ const permissionMessage = computed(() => {
{ @@ -337,6 +336,7 @@ const permissionMessage = computed(() => { @@ -347,8 +347,10 @@ const permissionMessage = computed(() => {
- -
+ +
{{ budgetOpinionDocument.type_name ?? @@ -391,8 +393,14 @@ const permissionMessage = computed(() => {
- -
+ +
O despacho ainda não foi gerado mdi-alert-circle @@ -405,26 +413,43 @@ const permissionMessage = computed(() => {
- + - - - +
+ + + +
{ -
- -
+
diff --git a/tests/Feature/ProjectStageFlowTest.php b/tests/Feature/ProjectStageFlowTest.php index f5d849d5..dc4bd7f6 100644 --- a/tests/Feature/ProjectStageFlowTest.php +++ b/tests/Feature/ProjectStageFlowTest.php @@ -2,6 +2,9 @@ namespace Tests\Feature; +use App\Enums\AccountType; +use App\Enums\DocumentPhase; +use App\Enums\DocumentType; use App\Enums\ProjectStageSlug; use App\Enums\ProjectStageStatus; use App\Models\Notice; @@ -53,6 +56,96 @@ private function makePrincipalSupervisor(Project $project, User $user): void ]); } + private function createValidOpening(Project $project): Opening + { + $opening = Opening::factory()->create([ + 'project_id' => $project->id, + 'creditor_number' => '123456', + 'allocation_code' => '123456', + 'bank' => 'Banco do Brasil', + 'account_type' => AccountType::cases()[0]->value, + 'branch' => '1234', + 'account' => '12345-6', + 'opening_nup' => '12345678901234567', + 'allocation_number' => '12345678901234567890123456789012345678901', + ]); + + OpeningSupervisor::create([ + 'opening_id' => $opening->id, + 'user_id' => User::factory()->create()->id, + 'type' => 'principal', + 'is_active' => true, + 'assigned_at' => now(), + ]); + + OpeningSupervisor::create([ + 'opening_id' => $opening->id, + 'user_id' => User::factory()->create()->id, + 'type' => 'alternate', + 'is_active' => true, + 'assigned_at' => now(), + ]); + + return $opening; + } + + private function createValidFormalization(Project $project): void + { + $formalization = $project->formalizations()->create([ + 'term_number' => '001/2026', + 'term_signed_at' => now(), + 'signed_by_office_at' => now(), + 'sacc_number' => '12345', + 'official_gazette_published_at' => now(), + 'validity_start_at' => now(), + 'validity_end_at' => now()->addYear(), + ]); + + $formalization->files()->create([ + 'mime_type' => 'application/pdf', + 'name' => 'gazette.pdf', + 'source' => 'upload', + 'grp' => 'official_gazette', + 'title' => 'Anexo do documento do Diário Oficial do Estado', + 'path' => 'projects/gazette.pdf', + 'private' => true, + ]); + + foreach (DocumentType::requiredForFormalizationAdvance() as $type) { + $project->documents()->create([ + 'notice_id' => $project->notice_id, + 'created_by' => User::factory()->create()->id, + 'phase' => DocumentPhase::FORMALIZATION->value, + 'type' => $type->value, + 'name' => $type->fullLabel(), + ]); + } + } + + private function createValidBudget(Project $project): void + { + $userId = User::factory()->create()->id; + + $budget = $project->budgets()->create([ + 'created_by' => $userId, + ]); + + $budget->installments()->create([ + 'installment_number' => $project->current_installment_cycle ?? 1, + 'notice_installment_number' => 1, + 'amount' => 1000.00, + 'created_by' => $userId, + ]); + + $project->documents()->create([ + 'notice_id' => $project->notice_id, + 'created_by' => $userId, + 'phase' => DocumentPhase::BUDGET->value ?? 'budget', + 'type' => DocumentType::DO->value ?? 'do', + 'name' => 'Despacho Orçamentário', + ]); + } + public function test_observer_creates_7_stages_on_project_creation(): void { $project = Project::factory()->create(); @@ -106,6 +199,10 @@ public function test_full_happy_path_flow(): void { $project = Project::factory()->create(); + $this->createValidOpening($project); + $this->createValidFormalization($project); + $this->createValidBudget($project); + $userByOrder = [ 1 => $this->createUserWithRoles('fomentation'), 2 => $this->createUserWithRoles('legal_analysis'), @@ -184,6 +281,7 @@ public function test_service_reject_throws_when_stage_not_em_andamento(): void public function test_reject_flow_blocks_all_subsequent_stages(): void { $project = Project::factory()->create(); + $this->createValidOpening($project); $first = $project->stages()->where('order', 1)->first(); $this->service->advance($first, $this->createUserWithRoles('fomentation')); @@ -229,6 +327,7 @@ public function test_progress_percentage_zero_at_start(): void public function test_progress_percentage_updates_after_approvals(): void { $project = Project::factory()->create(); + $this->createValidOpening($project); $first = $project->stages()->where('order', 1)->first(); $this->service->advance($first, $this->createUserWithRoles('fomentation'));