Files
Investidor-V2/app/Services/ExistingBusinessClassification.php
T
nala 2d23157e40
tests / ci (push) Canceled after 0s
versao nrgocios
2026-10-16 13:06:58 +02:00

274 lines
20 KiB
PHP

<?php
namespace App\Services;
use App\Models\ExistingBusiness;
use App\Models\User;
use App\Policies\ExistingBusinessPolicy;
use App\Services\Mne\BusinessRepository;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/** Supplement of the same UUID. All state remains in the existing immutable encrypted journal. */
class ExistingBusinessClassification
{
public const VERSION = 'business-classification-20261016-v1';
private const ACTIONS = ['classification_prepared', 'classification_reviewed', 'classification_disclosed', 'classification_withdrawn'];
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']);
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);
}
}