*/ public function validate(mixed $input, bool $complete = false): array { $validated = Validator::make(['private_offer' => $input], [ 'private_offer' => 'present|array:price_mode,amount,currency,contact_id,business,property', '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', 'private_offer.business' => 'sometimes|nullable|array', 'private_offer.property' => 'sometimes|nullable|array', ])->validate()['private_offer']; return array_replace($validated, app(PrivateOfferDetails::class)->validate(array_intersect_key($validated, array_flip(['business', 'property'])))); } /** @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); $values = app(OpportunityManagement::class)->values($record); app(PrivateOfferDetails::class)->assertCategory($values['private_offer'] ?? [], $values['opportunity_category']); $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 submission through the existing editorial engine when available. * @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(); if (app(PortalEditorialWorkflow::class)->available()) { $record = $this->record($actor, $id); $access = app(PortalProfileAccess::class); if ($access->state($record) === 'submitted') { abort_unless($access->promoterRecordScope($actor, $record) && $access->canCreate($actor, $record->organization_id, $record->opportunity_category) && $access->operational($actor, 'edit', $id), 403); abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409); return $record; } return app(OpportunityProfileWorkflow::class)->transition($actor, $id, [...$data, 'action' => 'submit', 'reason' => 'Submissão privada da oferta conferida pelo promotor.']); } 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_if(($record->workflow_context['private_offer_preparation']['version'] ?? 1) >= 2, 503, 'O circuito editorial está indisponível. A versão e o histórico foram preservados; não é permitida a retoma pelo percurso anterior.'); 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; }); } /** Adapter of the five common fields; never relaxes the full canonical contract. * @return array */ public function snapshot(Opportunity $record): array { $values = app(OpportunityManagement::class)->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(); $offer = $this->validate($values['private_offer'], true); app(PrivateOfferDetails::class)->assertCategory($offer, $values['opportunity_category']); $resources = app(PrivateOfferDetails::class)->resources($record); if (! app(OpportunityContacts::class)->related($record, $offer['contact_id'])) { throw ValidationException::withMessages(['private_offer.contact_id' => 'O contacto já não está disponível neste âmbito.']); } $contact = DB::table('contacts')->where('id', $offer['contact_id'])->first(); $path = app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true); if (! empty($values['primary_territory_id']) && $path === []) { throw ValidationException::withMessages(['province_id' => 'Reconcilie a localização antes da decisão.']); } if ($path !== []) { app(InvestmentGeography::class)->selection(array_combine(array_slice(array_keys(InvestmentGeography::FIELDS), 0, count($path)), array_column($path, 'id')), true); } $payload = ['title' => $values['title'], 'description' => $values['description'], 'opportunity_category' => $values['opportunity_category'], 'owner_user_id' => $record->owner_user_id, 'organization_id' => $record->organization_id, 'private_offer' => $offer, 'contact_fingerprint' => hash('sha256', json_encode($contact, JSON_THROW_ON_ERROR)), 'territory' => $path]; $extended = isset($offer['business']) || isset($offer['property']) || array_filter($resources) !== []; if ($extended) { $payload['resources'] = $resources; } $cycle = $record->workflow_context['offer_operation']['cycle'] ?? 0; if ($cycle > 0) { $payload['operational_cycle'] = $cycle; } $normalize = function (mixed $value) use (&$normalize): mixed { if (is_object($value)) { $value = get_object_vars($value); } if (! is_array($value)) { return $value; } if (! array_is_list($value)) { ksort($value); } return array_map($normalize, $value); }; $payload = $normalize($payload); $contract = $cycle > 0 ? 'private-offer-editorial-v4' : ($extended ? 'private-offer-editorial-v3' : 'private-offer-editorial-v2'); return ['payload' => $payload, 'hash' => hash('sha256', json_encode(['contract' => $contract, 'payload' => $payload], JSON_THROW_ON_ERROR)), 'record_version' => $record->management_version, 'contract_version' => $contract, 'resources' => $resources]; } /** @return array */ public function presentation(User $actor, Opportunity $record): array { app(PortalProfileAccess::class)->authorize($actor, 'view', $record); $values = app(OpportunityManagement::class)->values($record); $fresh = null; $contactId = $values['private_offer']['contact_id'] ?? null; $contactLabel = $contactId && app(OpportunityContacts::class)->related($record, $contactId) ? DB::table('contacts')->where('id', $contactId)->value('full_name') : null; if (isset($record->workflow_context['approval'])) { try { $fresh = hash_equals($record->workflow_context['approval']['hash'], $this->snapshot($record)['hash']); } catch (ValidationException $error) { $fresh = false; } } $history = $record->workflow_context['events'] ?? []; $names = User::whereIn('id', array_filter([...array_column($history, 'actor_id'), ...array_column($history, 'assignee_user_id')]))->pluck('name', 'id'); $history = array_map(fn ($event) => [...$event, 'actor_name' => $names[$event['actor_id']] ?? 'Interveniente indisponível', 'assignee_name' => isset($event['assignee_user_id']) ? ($names[$event['assignee_user_id']] ?? 'Interveniente indisponível') : null], $history); $versions = [...($record->workflow_context['decision_history'] ?? []), ...($record->workflow_context['submission_history'] ?? [])]; if (isset($record->workflow_context['submission'])) { $versions[] = ['submission' => $record->workflow_context['submission'], 'approval' => $record->workflow_context['approval'] ?? null, 'rejection' => $record->workflow_context['rejection'] ?? null]; } $uniqueVersions = []; foreach ($versions as $version) { if (! isset($version['submission']['payload']['private_offer'])) { continue; } $key = $version['submission']['record_version'].':'.$version['submission']['hash'].':'.($version['submission']['review_cycle'] ?? 0); $previous = $uniqueVersions[$key] ?? []; $uniqueVersions[$key] = ['submission' => $version['submission'], 'approval' => $version['approval'] ?? $previous['approval'] ?? null, 'rejection' => $version['rejection'] ?? $previous['rejection'] ?? null]; } $workflow = app(OpportunityProfileWorkflow::class); $actions = $workflow->actions($actor, $record); return ['id' => $record->id, 'reference' => $record->reference, 'values' => $values, 'etag' => app(OpportunityManagement::class)->etag($record), 'state' => app(PortalProfileAccess::class)->state($record), 'canEdit' => app(PortalProfileAccess::class)->canEdit($actor, $record), 'organization_id' => $record->organization_id, 'actions' => $actions, 'reviewers' => in_array('assign_review', $actions, true) ? $workflow->participants($actor, $record)['validators'] : [], 'reauthenticationRequired' => app(OpportunityProfileWorkflow::class)->reauthenticationRequired($actor, $record), 'history' => $history, 'submittedVersions' => array_values($uniqueVersions), 'correctionItems' => $record->workflow_context['correction_items'] ?? [], 'approval' => $record->workflow_context['approval'] ?? null, 'approvalFresh' => $fresh, 'contactLabel' => $contactLabel, 'tasks' => app(OpportunityWorkflowTasks::class)->listing($record), 'territory' => app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true)]; } }