fix: complete InvesteMoz validation and error corrections
This commit is contained in:
@@ -5,6 +5,7 @@ namespace App\Services;
|
||||
use App\Http\Controllers\InvestmentPortfolioController;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use App\Support\StructuredData;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
@@ -40,6 +41,10 @@ class OpportunityClassification
|
||||
|
||||
public const EXTERNAL = ['not_assessed', 'pending', 'documented_granted', 'documented_denied', 'expired'];
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $schema
|
||||
* @return array<array-key, mixed>
|
||||
*/
|
||||
public function extend(array $schema): array
|
||||
{
|
||||
$fields = ['target_type' => ['type' => 'string', 'enum' => array_keys(self::TARGETS)],
|
||||
@@ -86,7 +91,8 @@ class OpportunityClassification
|
||||
$query->whereIn('cluster_id', $this->targets('cluster')->select('id'));
|
||||
}
|
||||
if ($kind === 'location') {
|
||||
$query->whereIn('id', app(InvestmentGeography::class)->publicReferences()->select('id'));
|
||||
// Use the authoritative public scope directly; wrapping it in a second scan multiplies recursive ancestry checks.
|
||||
$query = app(InvestmentGeography::class)->publicReferences();
|
||||
}
|
||||
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'));
|
||||
@@ -100,6 +106,9 @@ class OpportunityClassification
|
||||
return app(EconomicReferenceCatalog::class)->constrain($query->where('data_classification', 'public'), $kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array-key, mixed>
|
||||
*/
|
||||
public function options(): array
|
||||
{
|
||||
$targets = [];
|
||||
@@ -112,6 +121,9 @@ class OpportunityClassification
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $payload
|
||||
*/
|
||||
public function validate(array $payload, Opportunity $record): void
|
||||
{
|
||||
$previousPayload = $record->exists ? (app(OpportunityManagement::class)->values($record)['canonical'] ?? []) : [];
|
||||
@@ -185,16 +197,26 @@ class OpportunityClassification
|
||||
}
|
||||
}
|
||||
|
||||
/** Exact persisted relation retention is not permission to choose a withdrawn target anew. */
|
||||
/** Exact persisted relation retention is not permission to choose a withdrawn target anew.
|
||||
* @param array<array-key, mixed> $previousRows
|
||||
* @param array<array-key, mixed> $row
|
||||
*/
|
||||
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; }
|
||||
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)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $row
|
||||
*/
|
||||
public function hash(array $row): string
|
||||
{
|
||||
// Client identity/status cannot certify a relation. Object-order normalization is reused.
|
||||
@@ -212,6 +234,9 @@ class OpportunityClassification
|
||||
&& ($g->organization_id === null || $g->organization_id === $record->organization_id) && in_array($responsibility, json_decode($g->responsibilities, true), true));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $row
|
||||
*/
|
||||
public function state(Opportunity $record, array $row): string
|
||||
{
|
||||
$decision = $record->workflow_context['classification_decisions'][$this->hash($row)] ?? null;
|
||||
@@ -219,7 +244,7 @@ class OpportunityClassification
|
||||
return $decision['state'];
|
||||
}
|
||||
if ($decision) {
|
||||
$reviewer = User::find($decision['actor_id']);
|
||||
$reviewer = User::whereKey($decision['actor_id'])->first();
|
||||
$c = $row['classification'];
|
||||
if (! empty($c['document_id']) && ($decision['evidence_hash'] ?? '') !== $this->evidenceHash($row)) {
|
||||
return 'in_validation';
|
||||
@@ -239,6 +264,9 @@ class OpportunityClassification
|
||||
return $row['classification']['requested_state'] ?? 'preliminary';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $row
|
||||
*/
|
||||
private function evidenceHash(array $row): ?string
|
||||
{
|
||||
$id = $row['classification']['document_id'] ?? null;
|
||||
@@ -251,6 +279,9 @@ class OpportunityClassification
|
||||
return OpportunityCanonicalValue::hash($resource ? (array) $resource : ['unavailable' => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array-key, mixed>
|
||||
*/
|
||||
public function listing(User $actor, Opportunity $record): array
|
||||
{
|
||||
app(PortalProfileAccess::class)->authorize($actor, 'view', $record);
|
||||
@@ -271,11 +302,14 @@ class OpportunityClassification
|
||||
}
|
||||
|
||||
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,
|
||||
'metadata' => collect(StructuredData::collectionItems($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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $input
|
||||
*/
|
||||
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();
|
||||
@@ -291,7 +325,7 @@ class OpportunityClassification
|
||||
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']);
|
||||
$row = collect(StructuredData::collectionItems($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.');
|
||||
@@ -314,12 +348,18 @@ class OpportunityClassification
|
||||
});
|
||||
}
|
||||
|
||||
/** Frozen at the existing editorial publication, not on draft save or association confirmation. */
|
||||
/** Frozen at the existing editorial publication, not on draft save or association confirmation.
|
||||
* @param array<array-key, mixed> $payload
|
||||
* @return array<array-key, mixed>
|
||||
*/
|
||||
public function publication(array $payload): array
|
||||
{
|
||||
return array_values(array_filter($payload['relations'] ?? [], fn ($r) => isset($r['classification']) && ($r['classification']['disclosure'] ?? 'restricted') === 'public'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array-key, mixed>
|
||||
*/
|
||||
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') {
|
||||
@@ -343,7 +383,7 @@ class OpportunityClassification
|
||||
// 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 ?? []) : [],
|
||||
'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,
|
||||
@@ -353,7 +393,10 @@ class OpportunityClassification
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** Same public projection for count/list/map; AND across filters, OR across selected associations. */
|
||||
/** Same public projection for count/list/map; AND across filters, OR across selected associations.
|
||||
* @param array<array-key, mixed> $filters
|
||||
* @param Builder<Opportunity> $query
|
||||
*/
|
||||
public function constrain(Builder $query, array $filters): void
|
||||
{
|
||||
$keys = ['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id'];
|
||||
@@ -383,7 +426,7 @@ class OpportunityClassification
|
||||
$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']));
|
||||
$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;
|
||||
@@ -393,9 +436,13 @@ class OpportunityClassification
|
||||
$query->whereIn('opportunities.id', $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array-key, mixed>
|
||||
*/
|
||||
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'])
|
||||
|
||||
Reference in New Issue
Block a user