where('business_id', $business->id)->whereIn('action', self::ACTIONS)->orderByDesc('version_no')->first(['snapshot']); if (! $revision) { return ['schema_version' => self::VERSION, 'relations' => [], 'authors' => [], 'decisions' => [], 'publication' => []]; } $state = json_decode(Crypt::decryptString($revision->snapshot), true, 512, JSON_THROW_ON_ERROR)['context']['multidimensional'] ?? null; abort_unless(is_array($state) && ($state['schema_version'] ?? '') === self::VERSION, 409, 'Versão do suplemento não reconhecida; preserve o histórico.'); return $state; } private function hash(array $row): string { return OpportunityCanonicalValue::hash($row); } private function evidence(array $row, ExistingBusiness $business): ?array { $id = $row['classification']['document_id'] ?? null; if (! $id) { return null; } $document = DB::table('existing_business_document_links as l')->join('documents as d', 'd.id', '=', 'l.document_id') ->where('l.existing_business_id', $business->id)->where('d.id', $id) ->first(['l.id as link_id', 'd.id', 'd.sha256', 'd.version_no', 'd.status', 'd.deleted_at', 'd.valid_until', 'd.malware_scan_status', 'd.malware_scanned_sha256']); return $document ? (array) $document : ['unavailable' => $id]; } private function evidenceHash(array $row, ExistingBusiness $business): ?string { $evidence = $this->evidence($row, $business); return $evidence === null ? null : OpportunityCanonicalValue::hash($evidence); } private function validateRows(array $rows, ExistingBusiness $business, bool $confirming = false): void { $seen = []; foreach ($rows as $i => $row) { $fail = fn ($key, $message) => throw ValidationException::withMessages(['canonical.relations.'.$i.'.'.$key => $message]); Validator::make($row, [ 'related_id' => ['required', 'string', 'max:80'], 'relation_type' => ['required', 'string', 'max:80'], 'classification' => ['required', 'array: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'], 'classification.target_type' => ['required', Rule::in(array_keys(OpportunityClassification::TARGETS))], 'classification.purpose' => ['required', Rule::in(array_keys(OpportunityClassification::PURPOSES))], 'classification.requested_state' => ['required', Rule::in(['draft', 'preliminary', 'in_validation'])], 'classification.disclosure' => ['required', Rule::in(['restricted', 'public'])], 'classification.criteria' => ['nullable', 'string', 'max:2000'], 'classification.justification' => ['nullable', 'string', 'max:4000'], 'classification.source_reference' => ['nullable', 'string', 'max:2000'], 'classification.citation' => ['nullable', 'string', 'max:1000'], 'classification.document_id' => ['nullable', 'uuid'], 'classification.authority_id' => ['nullable', 'uuid'], 'classification.valid_from' => ['nullable', 'date_format:Y-m-d'], 'classification.valid_until' => ['nullable', 'date_format:Y-m-d'], 'classification.external_status' => ['required', Rule::in(OpportunityClassification::EXTERNAL)], 'classification.external_act_reference' => ['nullable', 'string', 'max:2000'], 'classification.production_stage' => ['nullable', 'string', 'max:250'], ])->validate(); 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']; if (($kind === 'sector' && ! ctype_digit($row['related_id'])) || ($kind !== 'sector' && ! Str::isUuid($row['related_id']))) { $fail('related_id', 'Identificador de referência inválido.'); } if (! app(OpportunityClassification::class)->canRetainReference($kind, $row['related_id'], $row, $this->context($business)['relations'])) { $fail('related_id', 'Selecione uma referência existente e consultável.'); } if (in_array($c['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.'); } $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.'); } $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['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.'); } $evidence = $this->evidence($row, $business); 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 ($evidence && ($evidence['status'] !== 'active' || $evidence['deleted_at'] !== null || $evidence['malware_scan_status'] !== 'clean' || ! hash_equals(trim($evidence['sha256']), trim((string) $evidence['malware_scanned_sha256'])) || (! empty($evidence['valid_until']) && $evidence['valid_until'] < today()->toDateString()))) { $fail('classification.document_id', 'O suporte associado não está disponível, íntegro e analisado para esta confirmação.'); } } } } 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']; } $dimension = OpportunityClassification::TARGETS[$row['classification']['target_type']][2]; $reviewer = User::find($decision['actor_id']); 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)) { return 'in_validation'; } return 'confirmed'; } 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]; $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, 'history' => $state['decisions'][$hash]['history'] ?? []]; } $documents = DB::table('existing_business_document_links as l')->join('documents as d', 'd.id', '=', 'l.document_id') ->where('l.existing_business_id', $business->id)->whereNull('d.deleted_at')->get(['d.id', 'd.file_name', 'd.sha256']); return ['id' => $business->id, 'title' => $business->public_title, 'status' => $business->status, 'version_no' => $business->version_no, 'rows' => $rows, 'publication' => $this->publicRows($business), 'publication_version' => $state['publication_version'] ?? null, 'preview' => $this->publicRows($business, [...$state, 'publication' => array_values(array_filter($state['relations'], fn ($r) => $r['classification']['disclosure'] === 'public'))]), 'can_prepare' => Gate::forUser($actor)->allows('prepareClassification', $business), 'can_disclose' => Gate::forUser($actor)->allows('discloseClassification', $business), 'options' => app(OpportunityClassification::class)->options(), 'authorities' => DB::table('authorities')->where('status', 'published')->orderBy('label_pt_mz')->get(['id', 'label_pt_mz']), 'documents' => $documents->map(fn ($d) => ['id' => $d->id, 'payload' => ['title' => $d->file_name], 'file' => ['file_name' => $d->file_name]])]; } public function act(User $actor, string $id, array $input): ExistingBusiness { $data = Validator::make($input, [ 'action' => ['required', Rule::in(['prepare', 'review', 'disclose', 'withdraw'])], 'version_no' => ['required', 'integer', 'min:1'], 'reason' => ['required', 'string', 'min:20', 'max:1000'], 'rows' => ['required_if:action,prepare', 'array', 'max:100'], '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.'); 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); 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; } } 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'])); 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); } $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]; $state['decisions'][$data['hash']] = [...$decision, 'history' => [...($prior['history'] ?? []), $decision]]; if (in_array($data['state'], ['revoked', 'archived'], true)) { // Explicit withdrawal is retained: a later confirmation must not silently republish it. $state['publication'] = array_values(array_filter($state['publication'], fn ($r) => $this->hash($r) !== $data['hash'])); } } else { $publication = $action === 'withdraw' ? [] : array_values(array_filter($state['relations'], fn ($r) => $r['classification']['disclosure'] === 'public')); $this->validateRows($publication, $business); // Existing publication independence also applies to the actors deciding this supplement. foreach ($publication as $row) { abort_if((int) ($state['decisions'][$this->hash($row)]['actor_id'] ?? 0) === (int) $actor->id, 403, 'A divulgação deve ser feita por pessoa diferente de quem decidiu a associação.'); } $state['publication'] = $publication; $state['publication_version'] = $business->version_no + 1; $state['publication_actor'] = $actor->id; $state['publication_at'] = now()->toIso8601String(); } $business->version_no++; $business->save(); $event = ['prepare' => 'classification_prepared', 'review' => 'classification_reviewed', 'disclose' => 'classification_disclosed', 'withdraw' => 'classification_withdrawn'][$action]; app(ExistingBusinessDrafts::class)->snapshot($business, $actor, $event, $data['reason'], ['multidimensional' => $state]); return $business; }); } 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 = []; foreach ($state['publication'] as $row) { $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; } $effective = $this->effective($business, $row, $state); 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']], '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]; } return $rows; } public function filterRules(): array { return ['association_type' => ['nullable', Rule::in(array_keys(OpportunityClassification::TARGETS))], 'association_target' => ['nullable', 'string', 'max:80'], 'association_purpose' => ['nullable', Rule::in(array_keys(OpportunityClassification::PURPOSES))], 'association_state' => ['nullable', Rule::in(OpportunityClassification::STATES)], 'chain_id' => ['nullable', 'uuid'], 'authority_id' => ['nullable', 'uuid'], 'subsector_id' => ['nullable', 'uuid']]; } 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; } $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'])); $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; } } 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['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); } }