405 lines
27 KiB
PHP
405 lines
27 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Http\Controllers\InvestmentPortfolioController;
|
|
use App\Models\Opportunity;
|
|
use App\Models\User;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
/** Additive adapter over canonical relations. Never creates master data or inferred pivots. */
|
|
class OpportunityClassification
|
|
{
|
|
public const VERSION = 'classification-relations-20261015-v1';
|
|
|
|
public const DIMENSIONS = ['territory' => 'Enquadramento territorial', 'production' => 'Organização produtiva', 'regulation' => 'Regime regulatório', 'activity' => 'Atividade económica'];
|
|
|
|
public const TARGETS = [
|
|
'corridor' => ['development_corridors', 'name', 'territory'],
|
|
'location' => ['geo_locations', 'name', 'territory'],
|
|
'cluster' => ['economic_clusters', 'name', 'production'],
|
|
'project' => ['projects', 'name', 'production'],
|
|
'chain' => ['value_chains', 'name', 'production'],
|
|
'park' => ['investment_zones', 'name', 'production'],
|
|
'zone' => ['investment_zones', 'name', 'regulation'],
|
|
'sector' => ['sectors', 'name', 'activity'],
|
|
'activity' => ['economic_activities', 'label_pt_mz', 'activity'],
|
|
];
|
|
|
|
public const PURPOSES = ['location' => 'Localização', 'coverage' => 'Abrangência documentada', 'proximity' => 'Proximidade, sem pertença', 'administrative_boundary' => 'Limite administrativo', 'parcel' => 'Parcela referenciada',
|
|
'connectivity' => 'Conectividade e fluxos', 'productive_complementarity' => 'Complementaridade produtiva', 'specialization' => 'Especialização do cluster', 'anchor' => 'Projeto âncora documentado',
|
|
'zone_location' => 'Localização na zona', 'regime_recognition' => 'Reconhecimento do regime', 'enterprise_admission' => 'Admissão do empreendimento', 'specific_benefit' => 'Benefício específico', 'park_operation' => 'Operação do parque',
|
|
'financing_need' => 'Necessidade de financiamento', 'payments' => 'Pagamentos', 'risk_management' => 'Gestão de riscos', 'financial_instrument' => 'Instrumento financeiro',
|
|
'logistics_service' => 'Serviço logístico', 'physical_asset' => 'Ativo físico/capacidade', 'economic_activity' => 'Atividade económica', 'production_stage' => 'Etapa da cadeia produtiva'];
|
|
|
|
public const STATES = ['draft', 'preliminary', 'in_validation', 'confirmed', 'rejected', 'archived', 'revoked'];
|
|
|
|
public const EXTERNAL = ['not_assessed', 'pending', 'documented_granted', 'documented_denied', 'expired'];
|
|
|
|
public function extend(array $schema): array
|
|
{
|
|
$fields = ['target_type' => ['type' => 'string', 'enum' => array_keys(self::TARGETS)],
|
|
'purpose' => ['type' => 'string', 'enum' => array_keys(self::PURPOSES)],
|
|
'criteria' => ['type' => 'string', 'maxLength' => 2000], 'justification' => ['type' => 'string', 'maxLength' => 4000],
|
|
'source_reference' => ['type' => 'string', 'maxLength' => 2000], 'citation' => ['type' => 'string', 'maxLength' => 1000],
|
|
'document_id' => ['type' => ['string', 'null'], 'format' => 'uuid'],
|
|
'authority_id' => ['type' => ['string', 'null'], 'format' => 'uuid'],
|
|
'requested_state' => ['type' => 'string', 'enum' => ['draft', 'preliminary', 'in_validation']],
|
|
'disclosure' => ['type' => 'string', 'enum' => ['restricted', 'public']],
|
|
'valid_from' => ['type' => ['string', 'null'], 'format' => 'date'], 'valid_until' => ['type' => ['string', 'null'], 'format' => 'date'],
|
|
'external_status' => ['type' => 'string', 'enum' => self::EXTERNAL],
|
|
'external_act_reference' => ['type' => 'string', 'maxLength' => 2000],
|
|
'production_stage' => ['type' => 'string', 'maxLength' => 250],
|
|
];
|
|
// Same collection for every approved modality; old untyped relations remain valid.
|
|
$schema['$defs']['relationCreate']['properties']['classification'] = ['type' => 'object', 'additionalProperties' => false, 'properties' => $fields];
|
|
$schema['$defs']['relation']['properties']['classification'] = ['type' => 'object', 'additionalProperties' => false, 'properties' => $fields];
|
|
foreach (['opportunity', 'opportunityCreate'] as $name) {
|
|
$schema['$defs'][$name]['properties']['relations'] ??= ['type' => 'array', 'items' => ['$ref' => '#/$defs/relation']];
|
|
$schema['$defs'][$name]['properties']['enterprise_size'] = ['type' => ['string', 'null'], 'maxLength' => 120];
|
|
}
|
|
// The extracted Create had generic objects here. Type only the new adapter;
|
|
// pre-existing relation shapes remain accepted without recertification.
|
|
$schema['$defs']['opportunityCreate']['properties']['relations']['items'] = ['x-investmoz-classification' => true, 'anyOf' => [
|
|
['type' => 'object', 'not' => ['required' => ['classification']]],
|
|
['$ref' => '#/$defs/relationCreate'],
|
|
]];
|
|
|
|
return $schema;
|
|
}
|
|
|
|
/** Only consultable targets: a promoter's draft permission is not access to private master data. */
|
|
public function targets(string $kind): \Illuminate\Database\Query\Builder
|
|
{
|
|
abort_unless(isset(self::TARGETS[$kind]), 422);
|
|
[$table] = self::TARGETS[$kind];
|
|
$query = DB::table($table);
|
|
if (in_array($kind, ['sector', 'activity'], true)) {
|
|
return $kind === 'sector' ? $query->whereNull('deleted_at')->where('is_active', true) : app(EconomicReferenceCatalog::class)->constrain($query->where('status', 'published'), 'activity');
|
|
}
|
|
$query->whereNull('deleted_at');
|
|
if ($kind === 'chain') {
|
|
$query->whereIn('cluster_id', $this->targets('cluster')->select('id'));
|
|
}
|
|
if ($kind === 'location') {
|
|
$query->whereIn('id', app(InvestmentGeography::class)->publicReferences()->select('id'));
|
|
}
|
|
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'));
|
|
}
|
|
if (in_array($kind, ['project', 'cluster', 'corridor'], true)) {
|
|
$foreign = ['project' => 'project_id', 'cluster' => 'cluster_id', 'corridor' => 'corridor_id'][$kind];
|
|
|
|
return $query->where(fn ($q) => $q->where('data_classification', 'public')->orWhereIn('id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->select($foreign)));
|
|
}
|
|
|
|
return app(EconomicReferenceCatalog::class)->constrain($query->where('data_classification', 'public'), $kind);
|
|
}
|
|
|
|
public function options(): array
|
|
{
|
|
$targets = [];
|
|
foreach (self::TARGETS as $kind => [$table, $label, $dimension]) {
|
|
$targets[$kind] = $kind === 'activity'
|
|
? app(EconomicReferenceCatalog::class)->activities()->map(fn ($r) => (object) ['id' => $r->id, 'label' => $r->display_label, 'code' => $r->code, 'level' => $r->level, 'path' => $r->path, 'source_version' => $r->source_version, 'edition' => $r->edition])
|
|
: $this->targets($kind)->orderBy($label)->get(['id', $label.' as label']);
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
public function validate(array $payload, Opportunity $record): void
|
|
{
|
|
$previousPayload = $record->exists ? (app(OpportunityManagement::class)->values($record)['canonical'] ?? []) : [];
|
|
$catalog = app(EconomicReferenceCatalog::class);
|
|
$lotIds = array_column($catalog->manifest()['activities'], 'id');
|
|
if (in_array($payload['economic_activity_id'] ?? null, $lotIds, true) && ! $catalog->available()) {
|
|
foreach (['sector_id', 'subsector_id', 'economic_activity_id'] as $field) {
|
|
if (($payload[$field] ?? null) !== ($previousPayload[$field] ?? null)) {
|
|
throw ValidationException::withMessages(['canonical.'.$field => 'Referência retirada: apenas a classificação histórica já guardada pode ser conservada.']);
|
|
}
|
|
}
|
|
}
|
|
$seen = [];
|
|
foreach ($payload['relations'] ?? [] as $i => $row) {
|
|
if (! isset($row['classification'])) {
|
|
continue;
|
|
}
|
|
$c = $row['classification'];
|
|
$prefix = 'canonical.relations.'.$i;
|
|
$fail = fn ($key, $message) => throw ValidationException::withMessages([$prefix.'.'.$key => $message]);
|
|
$allowed = ['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'];
|
|
if (array_diff(array_keys($c), $allowed)) {
|
|
$fail('classification', 'Campo de certificação ou metadado não permitido. A decisão é registada pelo revisor autorizado.');
|
|
}
|
|
$validator = Validator::make($c, [
|
|
'target_type' => 'required|in:'.implode(',', array_keys(self::TARGETS)), 'purpose' => 'required|in:'.implode(',', array_keys(self::PURPOSES)),
|
|
'requested_state' => 'sometimes|in:draft,preliminary,in_validation', 'disclosure' => 'sometimes|in:restricted,public',
|
|
'external_status' => 'sometimes|in:'.implode(',', self::EXTERNAL),
|
|
'criteria' => 'sometimes|nullable|string|max:2000', 'justification' => 'sometimes|nullable|string|max:4000',
|
|
'source_reference' => 'sometimes|nullable|string|max:2000', 'citation' => 'sometimes|nullable|string|max:1000',
|
|
'document_id' => 'sometimes|nullable|uuid', 'authority_id' => 'sometimes|nullable|uuid',
|
|
'valid_from' => 'sometimes|nullable|date_format:Y-m-d', 'valid_until' => 'sometimes|nullable|date_format:Y-m-d',
|
|
'external_act_reference' => 'sometimes|nullable|string|max:2000', 'production_stage' => 'sometimes|nullable|string|max:250',
|
|
]);
|
|
if ($validator->fails()) {
|
|
throw ValidationException::withMessages(collect($validator->errors()->messages())->mapWithKeys(fn ($v, $k) => [$prefix.'.classification.'.$k => $v])->all());
|
|
}
|
|
$kind = $c['target_type'] ?? '';
|
|
$id = (string) ($row['related_id'] ?? '');
|
|
if (($kind === 'sector' && ! ctype_digit($id)) || ($kind !== 'sector' && ! Str::isUuid($id))) {
|
|
$fail('related_id', 'Identificador de referência inválido.');
|
|
}
|
|
$previousRows = $record->exists ? (app(OpportunityManagement::class)->values($record)['canonical']['relations'] ?? []) : [];
|
|
if (! isset(self::TARGETS[$kind]) || ! $this->canRetainReference($kind, $id, $row, $previousRows)) {
|
|
$fail('related_id', 'Selecione uma referência existente e consultável; nenhum cadastro é criado por esta relação.');
|
|
}
|
|
$purpose = $c['purpose'] ?? '';
|
|
if (! isset(self::PURPOSES[$purpose])) {
|
|
$fail('classification.purpose', 'Indique a finalidade da relação.');
|
|
}
|
|
if (in_array($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, separada da ficha física do parque.');
|
|
}
|
|
$identity = self::TARGETS[$kind][0].':'.$row['related_id'].':'.$purpose;
|
|
if (isset($seen[$identity])) {
|
|
$fail('related_id', 'Esta referência e finalidade já estão 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['document_id']) && ! DB::table('opportunity_documents')->where('id', $c['document_id'])->where('opportunity_id', $record->id)->whereNull('deleted_at')->exists()) {
|
|
$fail('classification.document_id', 'Documento deve pertencer aos anexos desta oportunidade.');
|
|
}
|
|
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.');
|
|
}
|
|
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; a associação não concede o direito.');
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Exact persisted relation retention is not permission to choose a withdrawn target anew. */
|
|
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; }
|
|
$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)));
|
|
}
|
|
|
|
public function hash(array $row): string
|
|
{
|
|
// Client identity/status cannot certify a relation. Object-order normalization is reused.
|
|
return OpportunityCanonicalValue::hash($row);
|
|
}
|
|
|
|
public function canReview(User $actor, Opportunity $record, string $dimension): bool
|
|
{
|
|
$access = app(PortalProfileAccess::class);
|
|
$responsibility = $dimension === 'regulation' ? 'legal_reviewer' : 'technical_reviewer';
|
|
|
|
return $access->canView($actor, $record) && $access->scoped($actor, 'validator', $record->organization_id, $record->opportunity_category)
|
|
&& ! $access->conflict($actor, $record) && $access->operational($actor, 'validate', $record->id) && $access->mfa($actor)
|
|
&& collect($access->grants($actor, 'validator'))->contains(fn ($g) => in_array($record->opportunity_category, json_decode($g->opportunity_types, true), true)
|
|
&& ($g->organization_id === null || $g->organization_id === $record->organization_id) && in_array($responsibility, json_decode($g->responsibilities, true), true));
|
|
}
|
|
|
|
public function state(Opportunity $record, array $row): string
|
|
{
|
|
$decision = $record->workflow_context['classification_decisions'][$this->hash($row)] ?? null;
|
|
if ($decision && $decision['state'] !== 'confirmed') {
|
|
return $decision['state'];
|
|
}
|
|
if ($decision) {
|
|
$reviewer = User::find($decision['actor_id']);
|
|
$c = $row['classification'];
|
|
if (! empty($c['document_id']) && ($decision['evidence_hash'] ?? '') !== $this->evidenceHash($row)) {
|
|
return 'in_validation';
|
|
}
|
|
// Current grants/conflicts still apply; reading a public page never requires the reviewer's browser session.
|
|
$access = app(PortalProfileAccess::class);
|
|
$responsibility = self::TARGETS[$c['target_type']][2] === 'regulation' ? 'legal_reviewer' : 'technical_reviewer';
|
|
$responsible = $reviewer && collect($access->grants($reviewer, 'validator'))->contains(fn ($g) => ($g->organization_id === null || $g->organization_id === $record->organization_id)
|
|
&& in_array($record->opportunity_category, json_decode($g->opportunity_types, true), true) && in_array($responsibility, json_decode($g->responsibilities, true), true));
|
|
if ($responsible && ! $access->conflict($reviewer, $record)) {
|
|
return 'confirmed';
|
|
}
|
|
|
|
return 'in_validation';
|
|
}
|
|
|
|
return $row['classification']['requested_state'] ?? 'preliminary';
|
|
}
|
|
|
|
private function evidenceHash(array $row): ?string
|
|
{
|
|
$id = $row['classification']['document_id'] ?? null;
|
|
if (! $id) {
|
|
return null;
|
|
}
|
|
$resource = DB::table('opportunity_documents as r')->join('documents as d', 'd.id', '=', 'r.document_id')->where('r.id', $id)
|
|
->first(['r.id', 'r.version', 'r.deleted_at', 'd.sha256']);
|
|
|
|
return OpportunityCanonicalValue::hash($resource ? (array) $resource : ['unavailable' => $id]);
|
|
}
|
|
|
|
public function listing(User $actor, Opportunity $record): array
|
|
{
|
|
app(PortalProfileAccess::class)->authorize($actor, 'view', $record);
|
|
$rows = app(OpportunityManagement::class)->values($record)['canonical']['relations'] ?? [];
|
|
$rows = array_values(array_filter($rows, fn ($row) => isset($row['classification'])));
|
|
|
|
return array_map(function ($row) use ($record, $actor) {
|
|
$kind = $row['classification']['target_type'];
|
|
$dimension = self::TARGETS[$kind][2];
|
|
$metadata = $record->workflow_context['canonical_items'] ?? [];
|
|
$prior = [];
|
|
foreach ($metadata['deleted'] ?? [] as $item) {
|
|
$old = $item['last_value'] ?? [];
|
|
if (isset($old['classification']) && ($old['related_id'] ?? null) === $row['related_id'] && ($old['classification']['target_type'] ?? null) === $kind
|
|
&& ($old['classification']['purpose'] ?? null) === $row['classification']['purpose'] && $this->hash($old) !== $this->hash($row)) {
|
|
$prior[] = ['metadata' => array_diff_key($item, ['last_value' => true]), 'history' => $record->workflow_context['classification_decisions'][$this->hash($old)]['history'] ?? [], 'relation' => $old];
|
|
}
|
|
}
|
|
|
|
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,
|
|
'can_review' => $this->canReview($actor, $record, $dimension), 'history' => $record->workflow_context['classification_decisions'][$this->hash($row)]['history'] ?? []];
|
|
}, $rows);
|
|
}
|
|
|
|
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();
|
|
|
|
return DB::transaction(function () use ($actor, $record, $data) {
|
|
$record = Opportunity::whereKey($record->id)->lockForUpdate()->firstOrFail();
|
|
abort_if($record->status === 'archived', 409, 'Oportunidade arquivada; preserve o histórico e use o workflow aprovado.');
|
|
$access = app(PortalProfileAccess::class);
|
|
$access->authorize($actor, 'view', $record);
|
|
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409, 'A oportunidade mudou. Atualize antes de decidir; o rascunho permanece guardado.');
|
|
$rows = app(OpportunityManagement::class)->values($record)['canonical']['relations'] ?? [];
|
|
// A revocation also reaches a previously published version after a draft correction.
|
|
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']);
|
|
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.');
|
|
if ($data['state'] === 'confirmed') {
|
|
foreach (['criteria', 'justification', 'source_reference', 'citation'] as $key) {
|
|
abort_if(empty(trim($c[$key] ?? '')), 422, 'Confirmação exige critérios, justificação, fonte e citação por associação. A preparação pode continuar como preliminar.');
|
|
}
|
|
$this->validate(['relations' => [$row]], $record);
|
|
}
|
|
$context = $record->workflow_context ?? [];
|
|
$before = $context['classification_decisions'][$data['hash']] ?? null;
|
|
$decision = ['state' => $data['state'], 'actor_id' => $actor->id, 'at' => now()->toIso8601String(), 'reason' => $data['reason'], 'evidence_hash' => $this->evidenceHash($row), 'version' => ($before['version'] ?? 0) + 1];
|
|
$context['classification_decisions'][$data['hash']] = [...$decision, 'history' => [...($before['history'] ?? []), $decision]];
|
|
$record->workflow_context = $context;
|
|
$record->management_version++;
|
|
$record->save();
|
|
$access->audit($actor, 'classification_'.$data['state'], ['record_id' => $record->id, 'relation_hash' => $data['hash'], 'before' => $before, 'after' => $decision]);
|
|
|
|
return $record;
|
|
});
|
|
}
|
|
|
|
/** Frozen at the existing editorial publication, not on draft save or association confirmation. */
|
|
public function publication(array $payload): array
|
|
{
|
|
return array_values(array_filter($payload['relations'] ?? [], fn ($r) => isset($r['classification']) && ($r['classification']['disclosure'] ?? 'restricted') === 'public'));
|
|
}
|
|
|
|
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') {
|
|
return [];
|
|
}
|
|
$rows = [];
|
|
foreach ($record->workflow_context['publication']['classification'] ?? [] as $row) {
|
|
$c = $row['classification'];
|
|
$kind = $c['target_type'];
|
|
if (! isset(self::TARGETS[$kind]) || ($c['disclosure'] ?? '') !== 'public') {
|
|
continue;
|
|
}
|
|
$target = $this->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;
|
|
}
|
|
$state = $this->state($record, $row);
|
|
if (in_array($state, ['archived', 'revoked'], true)) {
|
|
continue;
|
|
}
|
|
// 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 ?? []) : [],
|
|
'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,
|
|
'production_stage' => $c['production_stage'] ?? null, 'valid_from' => $c['valid_from'] ?? null, 'valid_until' => $c['valid_until'] ?? null];
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/** Same public projection for count/list/map; AND across filters, OR across selected associations. */
|
|
public function constrain(Builder $query, array $filters): void
|
|
{
|
|
$keys = ['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id'];
|
|
if (! array_filter(array_intersect_key($filters, array_flip($keys)), fn ($v) => $v !== null && $v !== '')) {
|
|
return;
|
|
}
|
|
// Query candidates in bounded chunks to apply the exact visibility/decision rules used by detail.
|
|
// IDs only, no private payload leaves this service. A future indexed projection is an optimization, not another catalogue.
|
|
$ids = [];
|
|
(clone $query)->reorder()->select('opportunities.*')->chunkById(200, function ($records) use (&$ids, $filters) {
|
|
foreach ($records as $record) {
|
|
$rows = $this->publicRows($record);
|
|
$main = array_filter(array_intersect_key($filters, array_flip(['association_type', 'association_target', 'association_state', 'association_purpose'])), fn ($v) => $v !== null && $v !== '');
|
|
$matches = $main === [] || collect($rows)->contains(function ($r) use ($main) {
|
|
foreach (['association_type' => 'target_type', 'association_target' => 'target_id', 'association_state' => 'state', 'association_purpose' => 'purpose'] as $filter => $field) {
|
|
if (isset($main[$filter]) && ($r[$field] ?? null) !== $main[$filter]) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
});
|
|
if (! empty($filters['chain_id'])) {
|
|
$matches = $matches && collect($rows)->contains(fn ($r) => $r['target_type'] === 'chain' && $r['target_id'] === $filters['chain_id']);
|
|
}
|
|
if (! empty($filters['authority_id'])) {
|
|
$matches = $matches && collect($rows)->contains(fn ($r) => $r['authority_id'] === $filters['authority_id']);
|
|
}
|
|
if (! empty($filters['subsector_id'])) {
|
|
$matches = $matches && (($record->workflow_context['publication']['taxonomy']['subsector_id'] ?? '') === $filters['subsector_id'] || collect($rows)->contains(fn ($r) => $r['target_type'] === 'activity' && $r['target_id'] === $filters['subsector_id']));
|
|
}
|
|
if ($matches) {
|
|
$ids[] = $record->id;
|
|
}
|
|
}
|
|
}, 'opportunities.id', 'id');
|
|
$query->whereIn('opportunities.id', $ids);
|
|
}
|
|
|
|
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'])
|
|
->map(fn ($r) => ['id' => $r->id, 'reference' => $r->reference, 'reason' => 'Título exato normalizado; identidade do negócio por determinar. Nenhuma fusão executada.'])->all();
|
|
}
|
|
}
|