*/ public function validate(mixed $input, bool $complete = false): array { return Validator::make(['private_offer' => $input], [ 'private_offer' => 'present|array:price_mode,amount,currency,contact_id', 'private_offer.price_mode' => 'nullable|in:amount,negotiable', 'private_offer.amount' => ['nullable', 'string', ...($complete ? ['required_if:private_offer.price_mode,amount'] : []), 'prohibited_unless:private_offer.price_mode,amount', 'regex:/^\d{1,16}(\.\d{1,2})?$/'], 'private_offer.currency' => ['nullable', ...($complete ? ['required_if:private_offer.price_mode,amount'] : []), 'prohibited_unless:private_offer.price_mode,amount', 'in:MZN,USD'], 'private_offer.contact_id' => 'nullable|uuid', ])->validate()['private_offer']; } /** @param array $input */ public function save(User $actor, array $input, ?int $id = null): Opportunity { foreach (['title', 'description'] as $field) { if (is_string($input[$field] ?? null)) { $input[$field] = trim($input[$field]); } } $allowed = ['creation_key', 'etag', 'reason', 'title', 'description', 'opportunity_category', 'organization_id', 'private_offer', ...array_keys(InvestmentGeography::FIELDS)]; if (array_diff(array_keys($input), $allowed)) { throw ValidationException::withMessages(['offer' => 'O pedido contém campos fora da preparação privada.']); } Validator::make($input, ['opportunity_category' => ['sometimes', 'required', Rule::in(OpportunityManagement::CATEGORIES)]])->validate(); return DB::transaction(function () use ($actor, $input, $id) { $record = app(OpportunityManagement::class)->save($actor, $input, $id, true); $contactId = app(OpportunityManagement::class)->values($record)['private_offer']['contact_id'] ?? null; if ($contactId && ! app(OpportunityContacts::class)->related($record, $contactId)) { throw ValidationException::withMessages(['private_offer.contact_id' => 'Seleccione um contacto autorizado do promotor.']); } return $record; }); } public function record(User $actor, int $id, bool $edit = false): Opportunity { $record = Opportunity::findOrFail($id); abort_unless($record->isPrivateOfferPreparation(), 404); app(PortalProfileAccess::class)->authorize($actor, $edit ? 'edit' : 'view', $record); return $record; } /** Register private submission only: no editorial task, approval or publication. * @param array $input */ public function submit(User $actor, int $id, array $input): Opportunity { $data = Validator::make($input, ['etag' => 'required|string|size:64', 'confirm' => 'required|accepted'])->validate(); return DB::transaction(function () use ($actor, $id, $data) { $record = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail(); abort_unless($record->isPrivateOfferPreparation(), 404); $access = app(PortalProfileAccess::class); $access->authorize($actor, 'view', $record); abort_unless($access->promoterRecordScope($actor, $record) && $access->canCreate($actor, $record->organization_id, $record->opportunity_category) && $access->operational($actor, 'edit', $record->id), 403); $management = app(OpportunityManagement::class); abort_unless(hash_equals($management->etag($record), $data['etag']), 409, 'A oferta mudou. Recupere a versão actual antes de submeter.'); if (data_get($record->workflow_context, 'private_offer_preparation.state') === 'submitted') { return $record; } $access->authorize($actor, 'edit', $record); $values = $management->values($record); Validator::make($values, ['title' => 'required|string|max:150', 'description' => 'required|string|max:50000', 'opportunity_category' => 'required', 'private_offer.price_mode' => 'required|in:amount,negotiable', 'private_offer.contact_id' => 'required|uuid'])->validate(); $this->validate($values['private_offer'], true); if (! app(OpportunityContacts::class)->related($record, $values['private_offer']['contact_id'])) { throw ValidationException::withMessages(['private_offer.contact_id' => 'O contacto já não está disponível neste âmbito.']); } // Recheck revoked grants and representation inside the same transaction. abort_unless($access->canCreate($actor, $record->organization_id, $record->opportunity_category), 403); $path = app(InvestmentGeography::class)->path($record->primary_territory_id, true); if ($record->primary_territory_id && $path === []) { throw ValidationException::withMessages(['province_id' => 'Reconcilie a localização antes de submeter.']); } if ($path !== []) { app(InvestmentGeography::class)->selection(array_combine(array_slice(array_keys(InvestmentGeography::FIELDS), 0, count($path)), array_column($path, 'id')), true); } $context = $record->workflow_context; $context['private_offer_preparation'] = ['version' => 1, 'state' => 'submitted', 'submitted_by' => $actor->id, 'submitted_at' => now()->toIso8601String(), 'submitted_revision' => $record->management_version]; $record->workflow_context = $context; $record->management_version++; $record->save(); $access->audit($actor, 'private_offer_submitted', ['record_id' => $record->id, 'version' => $record->management_version, 'public' => false]); return $record; }); } /** @return array */ public function presentation(User $actor, Opportunity $record): array { app(PortalProfileAccess::class)->authorize($actor, 'view', $record); return ['id' => $record->id, 'reference' => $record->reference, 'values' => app(OpportunityManagement::class)->values($record), 'etag' => app(OpportunityManagement::class)->etag($record), 'state' => data_get($record->workflow_context, 'private_offer_preparation.state'), 'canEdit' => app(PortalProfileAccess::class)->canEdit($actor, $record), 'organization_id' => $record->organization_id, 'territory' => app(InvestmentGeography::class)->path($record->primary_territory_id, true)]; } }