fix: complete InvesteMoz validation and error corrections
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user