fix: complete InvesteMoz validation and error corrections

This commit is contained in:
2026-10-29 21:47:33 +02:00
parent 63147312e2
commit daf8623441
718 changed files with 127308 additions and 20959 deletions
+105 -26
View File
@@ -6,6 +6,7 @@ use App\Models\ExistingBusiness;
use App\Models\User;
use App\Policies\ExistingBusinessPolicy;
use App\Services\Mne\BusinessRepository;
use App\Support\StructuredData;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
@@ -19,8 +20,12 @@ use Illuminate\Validation\ValidationException;
class ExistingBusinessClassification
{
public const VERSION = 'business-classification-20261016-v1';
private const ACTIONS = ['classification_prepared', 'classification_reviewed', 'classification_disclosed', 'classification_withdrawn'];
/**
* @return array<array-key, mixed>
*/
public function context(ExistingBusiness $business): array
{
$revision = DB::table('existing_business_revisions')->where('business_id', $business->id)->whereIn('action', self::ACTIONS)->orderByDesc('version_no')->first(['snapshot']);
@@ -33,11 +38,18 @@ class ExistingBusinessClassification
return $state;
}
/**
* @param array<array-key, mixed> $row
*/
private function hash(array $row): string
{
return OpportunityCanonicalValue::hash($row);
}
/**
* @param array<array-key, mixed> $row
* @return array<array-key, mixed>|null
*/
private function evidence(array $row, ExistingBusiness $business): ?array
{
$id = $row['classification']['document_id'] ?? null;
@@ -51,6 +63,9 @@ class ExistingBusinessClassification
return $document ? (array) $document : ['unavailable' => $id];
}
/**
* @param array<array-key, mixed> $row
*/
private function evidenceHash(array $row, ExistingBusiness $business): ?string
{
$evidence = $this->evidence($row, $business);
@@ -58,6 +73,9 @@ class ExistingBusinessClassification
return $evidence === null ? null : OpportunityCanonicalValue::hash($evidence);
}
/**
* @param array<array-key, mixed> $rows
*/
private function validateRows(array $rows, ExistingBusiness $business, bool $confirming = false): void
{
$seen = [];
@@ -80,7 +98,8 @@ class ExistingBusinessClassification
if (array_diff(array_keys($row), ['related_id', 'relation_type', 'classification'])) {
$fail('classification', 'Metadados de decisão ou campos desconhecidos não podem ser enviados pelo autor.');
}
$c = $row['classification']; $kind = $c['target_type'];
$c = $row['classification'];
$kind = $c['target_type'];
if (($kind === 'sector' && ! ctype_digit($row['related_id'])) || ($kind !== 'sector' && ! Str::isUuid($row['related_id']))) {
$fail('related_id', 'Identificador de referência inválido.');
}
@@ -91,7 +110,9 @@ class ExistingBusinessClassification
$fail('classification.purpose', 'Regime, admissão e benefício usam a relação regulatória com a zona.');
}
$identity = OpportunityClassification::TARGETS[$kind][0].':'.$row['related_id'].':'.$c['purpose'];
if (isset($seen[$identity])) { $fail('related_id', 'Referência e finalidade já associadas; recupere a relação existente.'); }
if (isset($seen[$identity])) {
$fail('related_id', 'Referência e finalidade já 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.');
@@ -100,13 +121,17 @@ class ExistingBusinessClassification
$fail('classification.authority_id', 'Entidade competente não consta do catálogo publicado.');
}
$evidence = $this->evidence($row, $business);
if ($evidence && isset($evidence['unavailable'])) { $fail('classification.document_id', 'Documento não está associado a este anúncio.'); }
if ($evidence && isset($evidence['unavailable'])) {
$fail('classification.document_id', 'Documento não está associado a este anúncio.');
}
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.');
}
if ($confirming) {
foreach (['criteria', 'justification', 'source_reference', 'citation'] as $key) {
if (empty(trim($c[$key] ?? ''))) { $fail('classification.'.$key, 'Confirmação exige fundamento e fonte/citação por relação; a preparação pode continuar preliminar.'); }
if (empty(trim($c[$key] ?? ''))) {
$fail('classification.'.$key, 'Confirmação exige fundamento e fonte/citação por relação; a preparação pode continuar preliminar.');
}
}
if ($evidence && ($evidence['status'] !== 'active' || $evidence['deleted_at'] !== null || $evidence['malware_scan_status'] !== 'clean'
|| ! hash_equals(trim($evidence['sha256']), trim((string) $evidence['malware_scanned_sha256']))
@@ -117,13 +142,21 @@ class ExistingBusinessClassification
}
}
/**
* @param array<array-key, mixed> $row
* @param array<array-key, mixed> $state
*/
private function effective(ExistingBusiness $business, array $row, array $state): string
{
$decision = $state['decisions'][$this->hash($row)] ?? null;
if (! $decision) { return $row['classification']['requested_state'] ?? 'preliminary'; }
if ($decision['state'] !== 'confirmed') { return $decision['state']; }
if (! $decision) {
return $row['classification']['requested_state'] ?? 'preliminary';
}
if ($decision['state'] !== 'confirmed') {
return $decision['state'];
}
$dimension = OpportunityClassification::TARGETS[$row['classification']['target_type']][2];
$reviewer = User::find($decision['actor_id']);
$reviewer = User::whereKey($decision['actor_id'])->first();
if (! $reviewer || ! app(ExistingBusinessPolicy::class)->classificationReviewerScope($reviewer, $business, $dimension)
|| (int) ($state['authors'][$this->hash($row)] ?? 0) === (int) $reviewer->id
|| ($decision['evidence_hash'] ?? null) !== $this->evidenceHash($row, $business)) {
@@ -133,13 +166,17 @@ class ExistingBusinessClassification
return 'confirmed';
}
/**
* @return array<array-key, mixed>
*/
public function privateView(User $actor, ExistingBusiness $business): array
{
Gate::forUser($actor)->authorize('view', $business);
$state = $this->context($business);
$rows = [];
foreach ($state['relations'] as $row) {
$hash = $this->hash($row); $dimension = OpportunityClassification::TARGETS[$row['classification']['target_type']][2];
$hash = $this->hash($row);
$dimension = OpportunityClassification::TARGETS[$row['classification']['target_type']][2];
$rows[] = ['hash' => $hash, 'relation' => $row, 'dimension' => $dimension, 'state' => $this->effective($business, $row, $state),
'can_review' => Gate::forUser($actor)->allows('reviewClassification', $business) && app(ExistingBusinessPolicy::class)->classificationReviewerScope($actor, $business, $dimension)
&& (int) ($state['authors'][$hash] ?? 0) !== (int) $actor->id,
@@ -158,6 +195,9 @@ class ExistingBusinessClassification
'documents' => $documents->map(fn ($d) => ['id' => $d->id, 'payload' => ['title' => $d->file_name], 'file' => ['file_name' => $d->file_name]])];
}
/**
* @param array<array-key, mixed> $input
*/
public function act(User $actor, string $id, array $input): ExistingBusiness
{
$data = Validator::make($input, [
@@ -167,26 +207,32 @@ class ExistingBusinessClassification
'hash' => ['required_if:action,review', 'string', 'size:64'],
'state' => ['required_if:action,review', Rule::in(['in_validation', 'confirmed', 'rejected', 'archived', 'revoked'])],
], ['reason.required' => 'Indique o fundamento da ação.', 'reason.min' => 'O fundamento deve ter pelo menos 20 caracteres.'])->validate();
abort_if(array_diff(array_keys($input), ['action', 'version_no', 'reason', 'rows', 'hash', 'state']), 422, 'Esta ação aceita apenas campos da classificação.');
abort_if(array_diff(array_keys($input), ['action', 'version_no', 'reason', 'rows', 'hash', 'state']) !== [], 422, 'Esta ação aceita apenas campos da classificação.');
return DB::transaction(function () use ($actor, $id, $data) {
$business = app(BusinessRepository::class)->find($id, true);
$action = $data['action'];
Gate::forUser($actor)->authorize(match ($action) { 'prepare' => 'prepareClassification', 'review' => 'reviewClassification', default => 'discloseClassification' }, $business);
Gate::forUser($actor)->authorize(match ($action) {
'prepare' => 'prepareClassification', 'review' => 'reviewClassification', default => 'discloseClassification'
}, $business);
abort_unless($business->version_no === (int) $data['version_no'], 409, 'A versão mudou. Atualize antes de decidir; preserve os valores preparados.');
$state = $this->context($business);
if ($action === 'prepare') {
$this->validateRows($data['rows'], $business);
$state['relations'] = $data['rows'];
foreach ($data['rows'] as $row) { $state['authors'][$this->hash($row)] ??= $actor->id; }
foreach ($data['rows'] as $row) {
$state['authors'][$this->hash($row)] ??= $actor->id;
}
} elseif ($action === 'review') {
$search = in_array($data['state'], ['revoked', 'archived'], true) ? [...$state['relations'], ...$state['publication']] : $state['relations'];
$row = collect($search)->first(fn ($r) => hash_equals($this->hash($r), $data['hash']));
$row = collect(StructuredData::collectionItems($search))->first(fn ($r) => hash_equals($this->hash($r), $data['hash']));
abort_unless($row, 409, 'Associação inexistente nesta versão.');
$dimension = OpportunityClassification::TARGETS[$row['classification']['target_type']][2];
abort_unless(app(ExistingBusinessPolicy::class)->classificationReviewerScope($actor, $business, $dimension)
&& (int) ($state['authors'][$data['hash']] ?? 0) !== (int) $actor->id, 403, 'Revisão exige responsabilidade e independência aplicáveis.');
if ($data['state'] === 'confirmed') { $this->validateRows([$row], $business, true); }
if ($data['state'] === 'confirmed') {
$this->validateRows([$row], $business, true);
}
$prior = $state['decisions'][$data['hash']] ?? [];
$decision = ['state' => $data['state'], 'actor_id' => $actor->id, 'at' => now()->toIso8601String(), 'reason' => $data['reason'],
'evidence_hash' => $this->evidenceHash($row, $business), 'version' => ($prior['version'] ?? 0) + 1];
@@ -216,21 +262,35 @@ class ExistingBusinessClassification
});
}
/**
* @param array<array-key, mixed>|null $preparedState
* @return array<array-key, mixed>
*/
public function publicRows(ExistingBusiness $business, ?array $preparedState = null): array
{
if ($business->trashed() || $business->status !== 'published' || ! $business->published_at || $business->published_at->isFuture()
|| ($business->expires_at && $business->expires_at->isBefore(today()))) { return []; }
$state = $preparedState ?? $this->context($business); $rows = [];
|| ($business->expires_at && $business->expires_at->isBefore(today()))) {
return [];
}
$state = $preparedState ?? $this->context($business);
$rows = [];
foreach ($state['publication'] as $row) {
$c = $row['classification']; $kind = $c['target_type'];
if (($c['disclosure'] ?? '') !== 'public' || ! isset(OpportunityClassification::TARGETS[$kind])) { continue; }
$c = $row['classification'];
$kind = $c['target_type'];
if (($c['disclosure'] ?? '') !== 'public' || ! isset(OpportunityClassification::TARGETS[$kind])) {
continue;
}
$target = app(OpportunityClassification::class)->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; }
if (! $target || (! empty($c['valid_from']) && $c['valid_from'] > today()->toDateString()) || (! empty($c['valid_until']) && $c['valid_until'] < today()->toDateString())) {
continue;
}
$effective = $this->effective($business, $row, $state);
if (in_array($effective, ['archived', 'revoked'], true)) { continue; }
if (in_array($effective, ['archived', 'revoked'], true)) {
continue;
}
$rows[] = ['dimension' => OpportunityClassification::TARGETS[$kind][2], 'target_type' => $kind, 'target_id' => (string) $row['related_id'],
'label' => $target->{OpportunityClassification::TARGETS[$kind][1]}, 'target_code' => $target->code ?? null,
'reference_path' => $kind === 'activity' ? (app(EconomicReferenceCatalog::class)->activities()->firstWhere('id', $target->id)?->path ?? []) : [], 'purpose' => $c['purpose'], 'purpose_label' => OpportunityClassification::PURPOSES[$c['purpose']],
'reference_path' => $kind === 'activity' ? (app(EconomicReferenceCatalog::class)->activities()->firstWhere('id', $target->id)->path ?? []) : [], 'purpose' => $c['purpose'], 'purpose_label' => OpportunityClassification::PURPOSES[$c['purpose']],
'state' => $effective, 'source_reference' => $c['source_reference'] ?? null, 'citation' => $c['citation'] ?? null,
'authority_id' => $c['authority_id'] ?? null, 'production_stage' => $c['production_stage'] ?? null,
'external_status' => $c['external_status'], 'valid_from' => $c['valid_from'] ?? null, 'valid_until' => $c['valid_until'] ?? null];
@@ -239,6 +299,9 @@ class ExistingBusinessClassification
return $rows;
}
/**
* @return array<array-key, mixed>
*/
public function filterRules(): array
{
return ['association_type' => ['nullable', Rule::in(array_keys(OpportunityClassification::TARGETS))],
@@ -247,25 +310,41 @@ class ExistingBusinessClassification
'chain_id' => ['nullable', 'uuid'], 'authority_id' => ['nullable', 'uuid'], 'subsector_id' => ['nullable', 'uuid']];
}
/**
* @param array<array-key, mixed> $filters
* @param Builder<ExistingBusiness> $query
*/
public function constrain(Builder $query, array $filters): void
{
$filters = array_filter(array_intersect_key($filters, $this->filterRules()), fn ($v) => $v !== null && $v !== '');
if ($filters === []) { return; }
if ($filters === []) {
return;
}
$ids = [];
(clone $query)->reorder()->select('existing_businesses.*')->chunkById(200, function ($records) use ($filters, &$ids) {
foreach ($records as $business) {
$rows = $this->publicRows($business); $same = array_intersect_key($filters, array_flip(['association_type', 'association_target', 'association_purpose', 'association_state']));
$rows = $this->publicRows($business);
$same = array_intersect_key($filters, array_flip(['association_type', 'association_target', 'association_purpose', 'association_state']));
$matches = ! $same || collect($rows)->contains(function ($row) use ($same) {
foreach (['association_type' => 'target_type', 'association_target' => 'target_id', 'association_purpose' => 'purpose', 'association_state' => 'state'] as $key => $field) {
if (isset($same[$key]) && $same[$key] !== $row[$field]) { return false; }
if (isset($same[$key]) && $same[$key] !== $row[$field]) {
return false;
}
}
return true;
});
foreach (['chain_id' => 'chain', 'subsector_id' => 'activity'] as $key => $type) {
if (! empty($filters[$key])) { $matches = $matches && collect($rows)->contains(fn ($r) => $r['target_type'] === $type && $r['target_id'] === $filters[$key]); }
if (! empty($filters[$key])) {
$matches = $matches && collect($rows)->contains(fn ($r) => $r['target_type'] === $type && $r['target_id'] === $filters[$key]);
}
}
if (! empty($filters['authority_id'])) {
$matches = $matches && collect($rows)->contains(fn ($r) => ($r['authority_id'] ?? null) === $filters['authority_id']);
}
if ($matches) {
$ids[] = $business->id;
}
if (! empty($filters['authority_id'])) { $matches = $matches && collect($rows)->contains(fn ($r) => ($r['authority_id'] ?? null) === $filters['authority_id']); }
if ($matches) { $ids[] = $business->id; }
}
}, 'existing_businesses.id', 'id');
$query->whereIn('existing_businesses.id', $ids);