Files
Investidor-V2/app/Services/OpportunityClassification.php

405 lines
28 KiB
PHP

<?php
namespace App\Services;
use App\Http\Controllers\InvestmentPortfolioController;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/** Additive adapter over canonical relations. Never creates master data or inferred pivots. */
class OpportunityClassification
{
public const VERSION = 'classification-relations-20261015-v1';
public const DIMENSIONS = ['territory' => 'Enquadramento territorial', 'production' => 'Organização produtiva', 'regulation' => 'Regime regulatório', 'activity' => 'Atividade económica'];
public const TARGETS = [
'corridor' => ['development_corridors', 'name', 'territory'],
'location' => ['geo_locations', 'name', 'territory'],
'cluster' => ['economic_clusters', 'name', 'production'],
'project' => ['projects', 'name', 'production'],
'chain' => ['value_chains', 'name', 'production'],
'park' => ['investment_zones', 'name', 'production'],
'zone' => ['investment_zones', 'name', 'regulation'],
'sector' => ['sectors', 'name', 'activity'],
'activity' => ['economic_activities', 'label_pt_mz', 'activity'],
];
public const PURPOSES = ['location' => 'Localização', 'coverage' => 'Abrangência documentada', 'proximity' => 'Proximidade, sem pertença', 'administrative_boundary' => 'Limite administrativo', 'parcel' => 'Parcela referenciada',
'connectivity' => 'Conectividade e fluxos', 'productive_complementarity' => 'Complementaridade produtiva', 'specialization' => 'Especialização do cluster', 'anchor' => 'Projeto âncora documentado',
'zone_location' => 'Localização na zona', 'regime_recognition' => 'Reconhecimento do regime', 'enterprise_admission' => 'Admissão do empreendimento', 'specific_benefit' => 'Benefício específico', 'park_operation' => 'Operação do parque',
'financing_need' => 'Necessidade de financiamento', 'payments' => 'Pagamentos', 'risk_management' => 'Gestão de riscos', 'financial_instrument' => 'Instrumento financeiro',
'logistics_service' => 'Serviço logístico', 'physical_asset' => 'Ativo físico/capacidade', 'economic_activity' => 'Atividade económica', 'production_stage' => 'Etapa da cadeia produtiva'];
public const STATES = ['draft', 'preliminary', 'in_validation', 'confirmed', 'rejected', 'archived', 'revoked'];
public const EXTERNAL = ['not_assessed', 'pending', 'documented_granted', 'documented_denied', 'expired'];
public function extend(array $schema): array
{
$fields = ['target_type' => ['type' => 'string', 'enum' => array_keys(self::TARGETS)],
'purpose' => ['type' => 'string', 'enum' => array_keys(self::PURPOSES)],
'criteria' => ['type' => 'string', 'maxLength' => 2000], 'justification' => ['type' => 'string', 'maxLength' => 4000],
'source_reference' => ['type' => 'string', 'maxLength' => 2000], 'citation' => ['type' => 'string', 'maxLength' => 1000],
'document_id' => ['type' => ['string', 'null'], 'format' => 'uuid'],
'authority_id' => ['type' => ['string', 'null'], 'format' => 'uuid'],
'requested_state' => ['type' => 'string', 'enum' => ['draft', 'preliminary', 'in_validation']],
'disclosure' => ['type' => 'string', 'enum' => ['restricted', 'public']],
'valid_from' => ['type' => ['string', 'null'], 'format' => 'date'], 'valid_until' => ['type' => ['string', 'null'], 'format' => 'date'],
'external_status' => ['type' => 'string', 'enum' => self::EXTERNAL],
'external_act_reference' => ['type' => 'string', 'maxLength' => 2000],
'production_stage' => ['type' => 'string', 'maxLength' => 250],
];
// Same collection for every approved modality; old untyped relations remain valid.
$schema['$defs']['relationCreate']['properties']['classification'] = ['type' => 'object', 'additionalProperties' => false, 'properties' => $fields];
$schema['$defs']['relation']['properties']['classification'] = ['type' => 'object', 'additionalProperties' => false, 'properties' => $fields];
foreach (['opportunity', 'opportunityCreate'] as $name) {
$schema['$defs'][$name]['properties']['relations'] ??= ['type' => 'array', 'items' => ['$ref' => '#/$defs/relation']];
$schema['$defs'][$name]['properties']['enterprise_size'] = ['type' => ['string', 'null'], 'maxLength' => 120];
}
// The extracted Create had generic objects here. Type only the new adapter;
// pre-existing relation shapes remain accepted without recertification.
$schema['$defs']['opportunityCreate']['properties']['relations']['items'] = ['x-investmoz-classification' => true, 'anyOf' => [
['type' => 'object', 'not' => ['required' => ['classification']]],
['$ref' => '#/$defs/relationCreate'],
]];
return $schema;
}
/** Only consultable targets: a promoter's draft permission is not access to private master data. */
public function targets(string $kind): \Illuminate\Database\Query\Builder
{
abort_unless(isset(self::TARGETS[$kind]), 422);
[$table] = self::TARGETS[$kind];
$query = DB::table($table);
if (in_array($kind, ['sector', 'activity'], true)) {
return $kind === 'sector' ? $query->whereNull('deleted_at')->where('is_active', true) : app(EconomicReferenceCatalog::class)->constrain($query->where('status', 'published'), 'activity');
}
$query->whereNull('deleted_at');
if ($kind === 'chain') {
$query->whereIn('cluster_id', $this->targets('cluster')->select('id'));
}
if ($kind === 'location') {
$query->whereIn('id', app(InvestmentGeography::class)->publicReferences()->select('id'));
}
if (in_array($kind, ['zone', 'park'], true)) {
return $query->whereIn('id', app(InvestmentZones::class)->visible()->select('id'))->when($kind === 'park', fn ($q) => $q->where('category', 'industrial_park'));
}
if (in_array($kind, ['project', 'cluster', 'corridor'], true)) {
$foreign = ['project' => 'project_id', 'cluster' => 'cluster_id', 'corridor' => 'corridor_id'][$kind];
return $query->where(fn ($q) => $q->where('data_classification', 'public')->orWhereIn('id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->select($foreign)));
}
return app(EconomicReferenceCatalog::class)->constrain($query->where('data_classification', 'public'), $kind);
}
public function options(): array
{
$targets = [];
foreach (self::TARGETS as $kind => [$table, $label, $dimension]) {
$targets[$kind] = $kind === 'activity'
? app(EconomicReferenceCatalog::class)->activities()->map(fn ($r) => (object) ['id' => $r->id, 'label' => $r->display_label, 'code' => $r->code, 'level' => $r->level, 'path' => $r->path, 'source_version' => $r->source_version, 'edition' => $r->edition])
: $this->targets($kind)->when($kind === 'sector', fn ($q) => $q->whereNotNull('published_at'))->orderBy($label)->get(['id', $label.' as label']);
}
return ['version' => self::VERSION, 'dimensions' => self::DIMENSIONS, 'types' => array_map(fn ($v) => $v[2], self::TARGETS), 'purposes' => self::PURPOSES, 'states' => self::STATES, 'externalStates' => self::EXTERNAL, 'targets' => $targets];
}
public function validate(array $payload, Opportunity $record): void
{
$previousPayload = $record->exists ? (app(OpportunityManagement::class)->values($record)['canonical'] ?? []) : [];
$catalog = app(EconomicReferenceCatalog::class);
$lotIds = array_column($catalog->manifest()['activities'], 'id');
if (in_array($payload['economic_activity_id'] ?? null, $lotIds, true) && ! $catalog->available()) {
foreach (['sector_id', 'subsector_id', 'economic_activity_id'] as $field) {
if (($payload[$field] ?? null) !== ($previousPayload[$field] ?? null)) {
throw ValidationException::withMessages(['canonical.'.$field => 'Referência retirada: apenas a classificação histórica já guardada pode ser conservada.']);
}
}
}
$seen = [];
foreach ($payload['relations'] ?? [] as $i => $row) {
if (! isset($row['classification'])) {
continue;
}
$c = $row['classification'];
$prefix = 'canonical.relations.'.$i;
$fail = fn ($key, $message) => throw ValidationException::withMessages([$prefix.'.'.$key => $message]);
$allowed = ['target_type', 'purpose', 'criteria', 'justification', 'source_reference', 'citation', 'document_id', 'authority_id', 'requested_state', 'disclosure', 'valid_from', 'valid_until', 'external_status', 'external_act_reference', 'production_stage'];
if (array_diff(array_keys($c), $allowed)) {
$fail('classification', 'Campo de certificação ou metadado não permitido. A decisão é registada pelo revisor autorizado.');
}
$validator = Validator::make($c, [
'target_type' => 'required|in:'.implode(',', array_keys(self::TARGETS)), 'purpose' => 'required|in:'.implode(',', array_keys(self::PURPOSES)),
'requested_state' => 'sometimes|in:draft,preliminary,in_validation', 'disclosure' => 'sometimes|in:restricted,public',
'external_status' => 'sometimes|in:'.implode(',', self::EXTERNAL),
'criteria' => 'sometimes|nullable|string|max:2000', 'justification' => 'sometimes|nullable|string|max:4000',
'source_reference' => 'sometimes|nullable|string|max:2000', 'citation' => 'sometimes|nullable|string|max:1000',
'document_id' => 'sometimes|nullable|uuid', 'authority_id' => 'sometimes|nullable|uuid',
'valid_from' => 'sometimes|nullable|date_format:Y-m-d', 'valid_until' => 'sometimes|nullable|date_format:Y-m-d',
'external_act_reference' => 'sometimes|nullable|string|max:2000', 'production_stage' => 'sometimes|nullable|string|max:250',
]);
if ($validator->fails()) {
throw ValidationException::withMessages(collect($validator->errors()->messages())->mapWithKeys(fn ($v, $k) => [$prefix.'.classification.'.$k => $v])->all());
}
$kind = $c['target_type'] ?? '';
$id = (string) ($row['related_id'] ?? '');
if (($kind === 'sector' && ! ctype_digit($id)) || ($kind !== 'sector' && ! Str::isUuid($id))) {
$fail('related_id', 'Identificador de referência inválido.');
}
$previousRows = $record->exists ? (app(OpportunityManagement::class)->values($record)['canonical']['relations'] ?? []) : [];
if (! isset(self::TARGETS[$kind]) || ! $this->canRetainReference($kind, $id, $row, $previousRows)) {
$fail('related_id', 'Selecione uma referência existente e consultável; nenhum cadastro é criado por esta relação.');
}
$purpose = $c['purpose'] ?? '';
if (! isset(self::PURPOSES[$purpose])) {
$fail('classification.purpose', 'Indique a finalidade da relação.');
}
if (in_array($purpose, ['regime_recognition', 'enterprise_admission', 'specific_benefit'], true) && $kind !== 'zone') {
$fail('classification.purpose', 'Regime, admissão e benefício usam a relação regulatória com a zona, separada da ficha física do parque.');
}
$identity = self::TARGETS[$kind][0].':'.$row['related_id'].':'.$purpose;
if (isset($seen[$identity])) {
$fail('related_id', 'Esta referência e finalidade já estão associadas. Recupere a relação existente.');
}
$seen[$identity] = true;
if (! empty($c['valid_from']) && ! empty($c['valid_until']) && $c['valid_until'] < $c['valid_from']) {
$fail('classification.valid_until', 'O fim da vigência precede o início.');
}
if (! empty($c['document_id']) && ! DB::table('opportunity_documents')->where('id', $c['document_id'])->where('opportunity_id', $record->id)->whereNull('deleted_at')->exists()) {
$fail('classification.document_id', 'Documento deve pertencer aos anexos desta oportunidade.');
}
if (! empty($c['authority_id']) && ! DB::table('authorities')->where('id', $c['authority_id'])->where('status', 'published')->exists()) {
$fail('classification.authority_id', 'Entidade competente não consta do catálogo publicado.');
}
if (($c['external_status'] ?? '') === 'documented_granted' && (empty($c['document_id']) || empty(trim($c['external_act_reference'] ?? '')))) {
$fail('classification.external_act_reference', 'Um ato declarado como concedido exige documento e referência próprios; a associação não concede o direito.');
}
}
}
/** Exact persisted relation retention is not permission to choose a withdrawn target anew. */
public function canRetainReference(string $kind, string $id, array $row, array $previousRows): bool
{
if ($this->targets($kind)->where('id', $id)->exists()) { return true; }
if (! app(EconomicReferenceCatalog::class)->historicalTarget($kind, $id)) { return false; }
$content = fn ($r) => array_intersect_key($r, array_flip(['related_id', 'relation_type', 'classification']));
return collect($previousRows)->contains(fn ($old) => OpportunityCanonicalValue::hash($content($old)) === OpportunityCanonicalValue::hash($content($row)));
}
public function hash(array $row): string
{
// Client identity/status cannot certify a relation. Object-order normalization is reused.
return OpportunityCanonicalValue::hash($row);
}
public function canReview(User $actor, Opportunity $record, string $dimension): bool
{
$access = app(PortalProfileAccess::class);
$responsibility = $dimension === 'regulation' ? 'legal_reviewer' : 'technical_reviewer';
return $access->canView($actor, $record) && $access->scoped($actor, 'validator', $record->organization_id, $record->opportunity_category)
&& ! $access->conflict($actor, $record) && $access->operational($actor, 'validate', $record->id) && $access->mfa($actor)
&& collect($access->grants($actor, 'validator'))->contains(fn ($g) => in_array($record->opportunity_category, json_decode($g->opportunity_types, true), true)
&& ($g->organization_id === null || $g->organization_id === $record->organization_id) && in_array($responsibility, json_decode($g->responsibilities, true), true));
}
public function state(Opportunity $record, array $row): string
{
$decision = $record->workflow_context['classification_decisions'][$this->hash($row)] ?? null;
if ($decision && $decision['state'] !== 'confirmed') {
return $decision['state'];
}
if ($decision) {
$reviewer = User::find($decision['actor_id']);
$c = $row['classification'];
if (! empty($c['document_id']) && ($decision['evidence_hash'] ?? '') !== $this->evidenceHash($row)) {
return 'in_validation';
}
// Current grants/conflicts still apply; reading a public page never requires the reviewer's browser session.
$access = app(PortalProfileAccess::class);
$responsibility = self::TARGETS[$c['target_type']][2] === 'regulation' ? 'legal_reviewer' : 'technical_reviewer';
$responsible = $reviewer && collect($access->grants($reviewer, 'validator'))->contains(fn ($g) => ($g->organization_id === null || $g->organization_id === $record->organization_id)
&& in_array($record->opportunity_category, json_decode($g->opportunity_types, true), true) && in_array($responsibility, json_decode($g->responsibilities, true), true));
if ($responsible && ! $access->conflict($reviewer, $record)) {
return 'confirmed';
}
return 'in_validation';
}
return $row['classification']['requested_state'] ?? 'preliminary';
}
private function evidenceHash(array $row): ?string
{
$id = $row['classification']['document_id'] ?? null;
if (! $id) {
return null;
}
$resource = DB::table('opportunity_documents as r')->join('documents as d', 'd.id', '=', 'r.document_id')->where('r.id', $id)
->first(['r.id', 'r.version', 'r.deleted_at', 'd.sha256']);
return OpportunityCanonicalValue::hash($resource ? (array) $resource : ['unavailable' => $id]);
}
public function listing(User $actor, Opportunity $record): array
{
app(PortalProfileAccess::class)->authorize($actor, 'view', $record);
$rows = app(OpportunityManagement::class)->values($record)['canonical']['relations'] ?? [];
$rows = array_values(array_filter($rows, fn ($row) => isset($row['classification'])));
return array_map(function ($row) use ($record, $actor) {
$kind = $row['classification']['target_type'];
$dimension = self::TARGETS[$kind][2];
$metadata = $record->workflow_context['canonical_items'] ?? [];
$prior = [];
foreach ($metadata['deleted'] ?? [] as $item) {
$old = $item['last_value'] ?? [];
if (isset($old['classification']) && ($old['related_id'] ?? null) === $row['related_id'] && ($old['classification']['target_type'] ?? null) === $kind
&& ($old['classification']['purpose'] ?? null) === $row['classification']['purpose'] && $this->hash($old) !== $this->hash($row)) {
$prior[] = ['metadata' => array_diff_key($item, ['last_value' => true]), 'history' => $record->workflow_context['classification_decisions'][$this->hash($old)]['history'] ?? [], 'relation' => $old];
}
}
return ['hash' => $this->hash($row), 'dimension' => $dimension, 'relation' => $row, 'state' => $this->state($record, $row),
'metadata' => collect($metadata['active'] ?? [])->first(fn ($m) => ($m['fingerprint'] ?? '') === $this->hash($row)), 'previous_versions' => $prior,
'can_review' => $this->canReview($actor, $record, $dimension), 'history' => $record->workflow_context['classification_decisions'][$this->hash($row)]['history'] ?? []];
}, $rows);
}
public function review(User $actor, Opportunity $record, array $input): Opportunity
{
$data = Validator::make($input, ['etag' => 'required|string|size:64', 'hash' => 'required|string|size:64', 'state' => 'required|in:in_validation,confirmed,rejected,archived,revoked', 'reason' => 'required|string|min:20|max:1000'])->validate();
return DB::transaction(function () use ($actor, $record, $data) {
$record = Opportunity::whereKey($record->id)->lockForUpdate()->firstOrFail();
abort_if($record->status === 'archived', 409, 'Oportunidade arquivada; preserve o histórico e use o workflow aprovado.');
$access = app(PortalProfileAccess::class);
$access->authorize($actor, 'view', $record);
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409, 'A oportunidade mudou. Atualize antes de decidir; o rascunho permanece guardado.');
$rows = app(OpportunityManagement::class)->values($record)['canonical']['relations'] ?? [];
// A revocation also reaches a previously published version after a draft correction.
if (in_array($data['state'], ['revoked', 'archived'], true)) {
$rows = [...$rows, ...($record->workflow_context['publication']['classification'] ?? [])];
}
$row = collect($rows)->first(fn ($r) => isset($r['classification']) && $this->hash($r) === $data['hash']);
abort_unless($row, 409, 'Associação inexistente nesta versão.');
$c = $row['classification'];
abort_unless($this->canReview($actor, $record, self::TARGETS[$c['target_type']][2]), 403, 'Revisão exige âmbito, responsabilidade, MFA e independência aprovados.');
if ($data['state'] === 'confirmed') {
foreach (['criteria', 'justification', 'source_reference', 'citation'] as $key) {
abort_if(empty(trim($c[$key] ?? '')), 422, 'Confirmação exige critérios, justificação, fonte e citação por associação. A preparação pode continuar como preliminar.');
}
$this->validate(['relations' => [$row]], $record);
}
$context = $record->workflow_context ?? [];
$before = $context['classification_decisions'][$data['hash']] ?? null;
$decision = ['state' => $data['state'], 'actor_id' => $actor->id, 'at' => now()->toIso8601String(), 'reason' => $data['reason'], 'evidence_hash' => $this->evidenceHash($row), 'version' => ($before['version'] ?? 0) + 1];
$context['classification_decisions'][$data['hash']] = [...$decision, 'history' => [...($before['history'] ?? []), $decision]];
$record->workflow_context = $context;
$record->management_version++;
$record->save();
$access->audit($actor, 'classification_'.$data['state'], ['record_id' => $record->id, 'relation_hash' => $data['hash'], 'before' => $before, 'after' => $decision]);
return $record;
});
}
/** Frozen at the existing editorial publication, not on draft save or association confirmation. */
public function publication(array $payload): array
{
return array_values(array_filter($payload['relations'] ?? [], fn ($r) => isset($r['classification']) && ($r['classification']['disclosure'] ?? 'restricted') === 'public'));
}
public function publicRows(Opportunity $record): array
{
if ($record->trashed() || $record->status !== 'published' || ! $record->published_at || $record->published_at->isFuture() || ($record->workflow_context['publication']['visibility'] ?? '') !== 'public') {
return [];
}
$rows = [];
foreach ($record->workflow_context['publication']['classification'] ?? [] as $row) {
$c = $row['classification'];
$kind = $c['target_type'];
if (! isset(self::TARGETS[$kind]) || ($c['disclosure'] ?? '') !== 'public') {
continue;
}
$target = $this->targets($kind)->where('id', $row['related_id'])->first();
if (! $target || (! empty($c['valid_from']) && $c['valid_from'] > today()->toDateString()) || (! empty($c['valid_until']) && $c['valid_until'] < today()->toDateString())) {
continue;
}
$state = $this->state($record, $row);
if (in_array($state, ['archived', 'revoked'], true)) {
continue;
}
// Original documents, internal rationale, actor IDs and decision history stay private.
$rows[] = ['dimension' => self::TARGETS[$kind][2], 'target_type' => $kind, 'target_id' => (string) $row['related_id'], 'label' => $target->{self::TARGETS[$kind][1]},
'purpose' => $c['purpose'], 'purpose_label' => self::PURPOSES[$c['purpose']], 'target_code' => $target->code ?? null, 'catalog_version' => $target->catalog_version_id ?? null,
'reference_path' => $kind === 'activity' ? (app(EconomicReferenceCatalog::class)->activities()->firstWhere('id', $target->id)?->path ?? []) : [],
'physical_category' => $target->physical_category ?? null, 'legal_regime' => $target->legal_regime ?? null,
'state' => $state, 'source_reference' => $c['source_reference'] ?? null, 'citation' => $c['citation'] ?? null,
'authority_id' => $c['authority_id'] ?? null, 'external_status' => $c['external_status'] ?? 'not_assessed', 'availability_confirmed' => false,
'production_stage' => $c['production_stage'] ?? null, 'valid_from' => $c['valid_from'] ?? null, 'valid_until' => $c['valid_until'] ?? null];
}
return $rows;
}
/** Same public projection for count/list/map; AND across filters, OR across selected associations. */
public function constrain(Builder $query, array $filters): void
{
$keys = ['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id'];
if (! array_filter(array_intersect_key($filters, array_flip($keys)), fn ($v) => $v !== null && $v !== '')) {
return;
}
// Query candidates in bounded chunks to apply the exact visibility/decision rules used by detail.
// IDs only, no private payload leaves this service. A future indexed projection is an optimization, not another catalogue.
$ids = [];
(clone $query)->reorder()->select('opportunities.*')->chunkById(200, function ($records) use (&$ids, $filters) {
foreach ($records as $record) {
$rows = $this->publicRows($record);
$main = array_filter(array_intersect_key($filters, array_flip(['association_type', 'association_target', 'association_state', 'association_purpose'])), fn ($v) => $v !== null && $v !== '');
$matches = $main === [] || collect($rows)->contains(function ($r) use ($main) {
foreach (['association_type' => 'target_type', 'association_target' => 'target_id', 'association_state' => 'state', 'association_purpose' => 'purpose'] as $filter => $field) {
if (isset($main[$filter]) && ($r[$field] ?? null) !== $main[$filter]) {
return false;
}
}
return true;
});
if (! empty($filters['chain_id'])) {
$matches = $matches && collect($rows)->contains(fn ($r) => $r['target_type'] === 'chain' && $r['target_id'] === $filters['chain_id']);
}
if (! empty($filters['authority_id'])) {
$matches = $matches && collect($rows)->contains(fn ($r) => $r['authority_id'] === $filters['authority_id']);
}
if (! empty($filters['subsector_id'])) {
$matches = $matches && (in_array($filters['subsector_id'], array_intersect_key($record->workflow_context['publication']['taxonomy'] ?? [], array_flip(['subsector_id','economic_activity_id'])), true) || collect($rows)->contains(fn ($r) => $r['target_type'] === 'activity' && $r['target_id'] === $filters['subsector_id']));
}
if ($matches) {
$ids[] = $record->id;
}
}
}, 'opportunities.id', 'id');
$query->whereIn('opportunities.id', $ids);
}
public function duplicates(User $actor, Opportunity $record): array
{
app(PortalProfileAccess::class)->authorize($actor, 'view', $record);
// Exact-title candidates only within the actor's existing visibility; never merge or reveal another dossier.
return app(PortalProfileAccess::class)->scope(Opportunity::query(), $actor)->whereKeyNot($record->id)
->whereRaw('lower(trim(title))=lower(trim(?))', [$record->title])->limit(10)->get(['id', 'reference', 'title'])
->map(fn ($r) => ['id' => $r->id, 'reference' => $r->reference, 'reason' => 'Título exato normalizado; identidade do negócio por determinar. Nenhuma fusão executada.'])->all();
}
}