['table' => 'projects', 'fields' => ['code', 'name', 'status', 'description', 'budget_amount', 'budget_currency', 'start_date', 'end_date', 'primary_territory_id', 'territory_version']], 'clusters' => ['table' => 'economic_clusters', 'fields' => ['code', 'name', 'status', 'sector', 'primary_territory_id', 'territory_version']], 'corridors' => ['table' => 'development_corridors', 'fields' => ['code', 'name', 'status', 'description', 'primary_territory_id', 'territory_version']], 'cluster-project' => ['table' => 'cluster_projects', 'fields' => ['cluster_id', 'project_id', 'role', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until']], 'corridor-project' => ['table' => 'corridor_projects', 'fields' => ['corridor_id', 'project_id', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until']], 'corridor-cluster' => ['table' => 'corridor_clusters', 'fields' => ['corridor_id', 'cluster_id', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until']], 'value-chains' => ['table' => 'value_chains', 'fields' => ['cluster_id', 'code', 'name', 'stage']], 'value-chain-links' => ['table' => 'value_chain_links', 'fields' => ['from_value_chain_id', 'to_value_chain_id', 'link_type']], 'investment-gaps' => ['table' => 'investment_gaps', 'fields' => ['cluster_id', 'value_chain_id', 'title', 'location', 'required_capacity', 'rationale', 'status', 'call_to_action']], 'zone-relations' => ['table' => 'investment_zone_relations', 'fields' => ['zone_id', 'cluster_id', 'corridor_id', 'project_id', 'association_type', 'role', 'source_reference', 'justification', 'legal_source_id', 'valid_from', 'valid_until', 'priority', 'is_primary']], ]; private const PARENTS = ['cluster_id' => 'economic_clusters', 'project_id' => 'projects', 'corridor_id' => 'development_corridors', 'zone_id' => 'investment_zones', 'value_chain_id' => 'value_chains', 'from_value_chain_id' => 'value_chains', 'to_value_chain_id' => 'value_chains', 'primary_territory_id' => 'geo_locations']; /** @return array{table:string,fields:list} */ public function definition(string $kind): array { abort_unless(isset(self::SUBJECTS[$kind]), 404); return self::SUBJECTS[$kind]; } public function available(): bool { return (bool) config('editorial_workflow.structural_subjects') && app(PortalEditorialWorkflow::class)->available() && Schema::hasColumn('portal_editorial_editions', 'subject_kind'); } public function writes(string $kind, string $key): bool { $this->definition($kind); if (config('editorial_workflow.structural_subjects')) { abort_unless($this->available(), 503, 'Estrutura editorial dos registos ainda não instalada.'); return true; } abort_if(Schema::hasColumn('portal_editorial_editions', 'subject_kind') && DB::table('portal_editorial_editions')->where('subject_kind', $kind)->where('subject_key', $key)->exists(), 503, 'Reactive o circuito para conservar as versões deste registo.'); return false; } public function query(string $kind, string $key): Builder { return DB::table($this->definition($kind)['table'])->where('id', $key); } public function editUrl(string $kind, \stdClass $record): string { return match ($kind) { 'projects', 'clusters', 'corridors' => '/management/'.$kind.'/'.$record->id.'/edit', 'value-chains', 'investment-gaps', 'cluster-project' => '/management/clusters/'.$record->cluster_id.'/edit', 'value-chain-links' => '/management/clusters/'.DB::table('value_chains')->where('id', $record->from_value_chain_id)->value('cluster_id').'/edit', 'corridor-project', 'corridor-cluster' => '/management/corridors/'.$record->corridor_id.'/edit', 'zone-relations' => '/management/investment/zones/'.$record->zone_id, default => abort(404), }; } public function working(string $kind, \stdClass $record): \stdClass { if (! $this->available()) { return $record; } $workflow = app(PortalEditorialWorkflow::class); $edition = $workflow->latestSubject($kind, $record->id); $copy = clone $record; if ($edition && $edition->published_at === null && $edition->state !== 'archived') { foreach ($workflow->payload($edition)['resource']['_subject']['fields'] as $field => $value) { $copy->{$field} = $value; } } $copy->editorial_version = (int) ($edition->revision_no ?? 1); $copy->editorial_url = '/management/editorial/subjects/'.$kind.'/'.$record->id; return $copy; } /** @param array $fields */ public function stageModel(User $actor, string $kind, Model $record, array $fields, int $expected): \stdClass { return DB::transaction(function () use ($actor, $kind, $record, $fields, $expected) { $new = ! $record->exists; if ($new) { $private = in_array($kind, ['value-chains', 'value-chain-links', 'investment-gaps'], true) ? ['deleted_at' => now()] : []; PortalAudit::save($record, [...$fields, ...$private], $actor->id); } $allowed = $this->definition($kind)['fields']; return app(PortalEditorialWorkflow::class)->saveSubject($actor, $kind, (string) $record->getKey(), $expected, Arr::only($fields, $allowed), $new); }); } /** @return array */ public function labels(): array { return ['code' => 'Código', 'name' => 'Designação', 'status' => 'Estado', 'description' => 'Descrição', 'sector' => 'Sector', 'budget_amount' => 'Orçamento', 'budget_currency' => 'Moeda', 'start_date' => 'Data de início', 'end_date' => 'Data de conclusão', 'primary_territory_id' => 'Localização principal', 'cluster_id' => 'Cluster', 'project_id' => 'Projecto', 'corridor_id' => 'Corredor', 'zone_id' => 'Zona', 'value_chain_id' => 'Etapa da cadeia', 'from_value_chain_id' => 'Etapa de origem', 'to_value_chain_id' => 'Etapa de destino', 'role' => 'Papel', 'association_type' => 'Tipo de associação', 'source_reference' => 'Fonte', 'evidence_reference' => 'Evidência', 'valid_from' => 'Válido desde', 'valid_until' => 'Válido até', 'stage' => 'Fase da cadeia', 'link_type' => 'Tipo de ligação', 'title' => 'Título', 'location' => 'Localização', 'required_capacity' => 'Capacidade necessária', 'rationale' => 'Fundamento', 'call_to_action' => 'Acção proposta', 'justification' => 'Justificação', 'legal_source_id' => 'Fonte jurídica', 'priority' => 'Prioridade', 'is_primary' => 'Relação principal']; } /** @return array> */ private function enums(string $kind): array { return ['status' => match ($kind) { 'projects' => ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'], 'clusters' => ['planned', 'active', 'inactive'], 'corridors' => ['proposed', 'active', 'closed'], 'investment-gaps' => ['identified', 'in_review', 'resolved', 'withdrawn'], default => [] }, 'stage' => ['input', 'production', 'processing', 'logistics', 'market'], 'link_type' => ['supplies', 'processes', 'distributes', 'supports'], 'association_type' => ['official', 'influence', 'proximity', 'potential', 'historical'], 'role' => $kind === 'zone-relations' ? ['primary', 'beneficiary', 'served', 'enabled', 'dependent', 'related', 'potential', 'historical'] : ['anchor', 'complementary', 'supporting']]; } /** @return list> */ public function form(string $kind): array { $labels = $this->labels(); $enums = $this->enums($kind); $result = []; foreach ($this->definition($kind)['fields'] as $field) { if ($field === 'territory_version') { continue; } $type = isset(self::PARENTS[$field]) || $field === 'legal_source_id' ? 'reference' : (isset($enums[$field]) ? 'enum' : (in_array($field, ['valid_from', 'valid_until', 'start_date', 'end_date'], true) ? 'date' : ($field === 'is_primary' ? 'boolean' : 'text'))); $result[] = ['name' => $field, 'label' => $labels[$field] ?? $field, 'type' => $type, 'options' => $enums[$field] ?? []]; } return $result; } /** @param array $input * @return array */ public function validate(string $kind, array $input): array { $allowed = array_values(array_diff($this->definition($kind)['fields'], ['territory_version'])); abort_unless(array_diff(array_keys($input), $allowed) === [], 422, 'Campos fora do âmbito da proposta.'); $enums = $this->enums($kind); $rules = []; foreach ($allowed as $field) { $rules[$field] = ['sometimes', 'nullable', 'string', 'max:10000']; if (isset(self::PARENTS[$field]) || $field === 'legal_source_id') { $table = self::PARENTS[$field] ?? 'investment_legal_sources'; $rules[$field] = ['sometimes', 'nullable', 'uuid', 'exists:'.$table.',id']; } elseif (isset($enums[$field])) { $rules[$field] = ['sometimes', 'required', Rule::in($enums[$field])]; } elseif (in_array($field, ['valid_from', 'valid_until', 'start_date', 'end_date'], true)) { $rules[$field] = ['sometimes', 'nullable', 'date_format:Y-m-d']; } } foreach (['name', 'code', 'title', 'sector'] as $field) { if (in_array($field, $allowed, true)) { $rules[$field] = ['sometimes', 'required', 'string', 'max:'.($field === 'code' ? '100' : '200')]; } } foreach (['source_reference' => 2000, 'evidence_reference' => 2000, 'justification' => 3000, 'location' => 1000, 'required_capacity' => 2000, 'rationale' => 5000, 'call_to_action' => 2000] as $field => $limit) { if (in_array($field, $allowed, true)) { $rules[$field] = ['sometimes', 'nullable', 'string', 'max:'.$limit]; } } $requiredReferences = match ($kind) { 'cluster-project' => ['cluster_id', 'project_id'], 'corridor-project' => ['corridor_id', 'project_id'], 'corridor-cluster' => ['corridor_id', 'cluster_id'], 'value-chains' => ['cluster_id'], 'value-chain-links' => ['from_value_chain_id', 'to_value_chain_id'], 'investment-gaps' => ['cluster_id', 'value_chain_id'], 'zone-relations' => ['zone_id'], default => [], }; foreach ($requiredReferences as $field) { $rules[$field] = ['sometimes', 'required', 'uuid', 'exists:'.self::PARENTS[$field].',id']; } if (! empty($input['valid_until']) && ! empty($input['valid_from'])) { $rules['valid_until'][] = 'after_or_equal:valid_from'; } if (! empty($input['end_date']) && ! empty($input['start_date'])) { $rules['end_date'][] = 'after_or_equal:start_date'; } if (in_array('budget_amount', $allowed, true)) { $rules['budget_amount'] = ['sometimes', 'nullable', 'required_with:budget_currency', 'regex:/^\d{1,18}(\.\d{1,2})?$/']; $rules['budget_currency'] = ['sometimes', 'nullable', 'required_with:budget_amount', 'regex:/^[A-Z]{3}$/']; } if (in_array('priority', $allowed, true)) { $rules['priority'] = ['sometimes', 'integer', 'between:0,100']; $rules['is_primary'] = ['sometimes', 'boolean']; } if ($kind === 'zone-relations' && count(array_filter(Arr::only($input, ['cluster_id', 'corridor_id', 'project_id']))) > 1) { throw ValidationException::withMessages(['project_id' => 'Seleccione apenas um projecto, cluster ou corredor para a relação.']); } return validator($input, $rules)->validate(); } /** @return list */ public function choices(string $kind, string $field, ?string $query = null): array { abort_unless(in_array($field, $this->definition($kind)['fields'], true), 404); $table = self::PARENTS[$field] ?? ($field === 'legal_source_id' ? 'investment_legal_sources' : abort(404)); $label = $table === 'investment_legal_sources' ? 'title' : 'name'; $builder = DB::table($table); if ($table === 'geo_locations') { $builder->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])->whereIn('data_classification', ['public', 'internal']); } if (Schema::hasColumn($table, 'deleted_at')) { $builder->whereNull('deleted_at'); } if ($query) { $builder->where($label, 'ilike', '%'.addcslashes($query, '%_\\').'%'); } return array_values($builder->orderBy($label)->orderBy('id')->limit(100)->get(['id', $label.' as name'])->map(fn ($row) => ['id' => (string) $row->id, 'name' => (string) $row->name])->all()); } public function authorize(User $actor, string $kind, string $key, bool $write = false): void { abort_unless($this->available(), 503); abort_unless($actor->hasVerifiedEmail() && $actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403); abort_unless($this->query($kind, $key)->exists(), 404); if ($write) { abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.'); } } public function independent(string $kind): bool { return in_array($kind, ['cluster-project', 'corridor-project', 'corridor-cluster', 'zone-relations'], true) || in_array($kind, config('editorial_workflow.independent_approval.kinds', []), true); } /** @param array $fields * @return array */ public function snapshot(string $kind, string $key, array $fields): array { $definition = $this->definition($kind); $record = $this->query($kind, $key)->first(); abort_unless($record !== null, 404); abort_unless(array_diff(array_keys($fields), $definition['fields']) === [], 422, 'Campos fora do âmbito do registo.'); $context = ['record' => Arr::except(get_object_vars($record), ['created_at', 'updated_at']), 'parents' => []]; if (! empty($fields['primary_territory_id'])) { $path = app(InvestmentGeography::class)->path($fields['primary_territory_id'], true); abort_unless($path !== [] && count($path) <= 4 && $path[0]->location_type === 'province', 422, 'Seleccione uma localização na hierarquia existente.'); } foreach (self::PARENTS as $field => $table) { $id = $fields[$field] ?? $record->{$field} ?? null; if (! $id) { continue; } $parent = DB::table($table)->where('id', $id)->whereNull('deleted_at')->first(); abort_unless($parent !== null, 422, 'Contexto relacionado indisponível.'); $context['parents'][$field] = Arr::except(get_object_vars($parent), ['created_at', 'updated_at']); } if (in_array($kind, ['projects', 'clusters', 'corridors'], true)) { $foreign = ['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'][$kind]; $context['portfolio'] = DB::table('investment_dossiers')->where($foreign, $key)->pluck('id')->all(); abort_unless($context['portfolio'] === [], 409, 'Este registo tem ficha associada. Continue na revisão da carteira.'); } if ($source = $fields['legal_source_id'] ?? $record->legal_source_id ?? null) { $sourceRecord = DB::table('investment_legal_sources')->where('id', $source)->first(); abort_unless($sourceRecord !== null, 422, 'Fonte jurídica indisponível.'); $context['legal_source'] = get_object_vars($sourceRecord); } return ['fields' => $fields, 'context' => $context]; } /** @param array $fields */ public function lock(string $kind, string $key, array $fields = []): \stdClass { $record = $this->query($kind, $key)->first(); abort_unless($record !== null, 404); $parents = []; foreach (self::PARENTS as $field => $table) { $id = $fields[$field] ?? $record->{$field} ?? null; if ($id) { $parents[$table][] = $id; } } ksort($parents); foreach ($parents as $table => $ids) { DB::table($table)->whereIn('id', array_unique($ids))->orderBy('id')->lockForUpdate()->get(); } return $this->query($kind, $key)->lockForUpdate()->firstOrFail(); } /** @param array $payload */ public function publish(User $actor, \stdClass $record, \stdClass $edition, array $payload): void { $subject = $payload['resource']['_subject']; $kind = $edition->subject_kind; $fields = $subject['fields']; $this->lock($kind, $edition->subject_key, $fields); if ($subject['withdraw'] ?? false) { $this->archive($actor, $record, $edition); return; } $pair = match ($kind) { 'cluster-project' => ['cluster_id', 'project_id'], 'corridor-project' => ['corridor_id', 'project_id'], 'corridor-cluster' => ['corridor_id', 'cluster_id'], 'value-chain-links' => ['from_value_chain_id', 'to_value_chain_id', 'link_type'], 'zone-relations' => ['zone_id', 'cluster_id', 'corridor_id', 'project_id'], default => [], }; if ($pair !== []) { abort_if($kind === 'zone-relations' && count(array_filter(Arr::only($fields, ['cluster_id', 'corridor_id', 'project_id']))) !== 1, 422, 'A relação exige um projecto, cluster ou corredor.'); $duplicates = DB::table($this->definition($kind)['table'])->where('id', '<>', $record->id); foreach ($pair as $field) { $duplicates->where($field, $fields[$field] ?? null); } if (property_exists($record, 'deleted_at')) { $duplicates->whereNull('deleted_at'); } abort_if($duplicates->exists(), 409, 'Esta relação já existe. Reveja a proposta sem duplicar o registo.'); } if (in_array($kind, ['projects', 'clusters', 'corridors', 'value-chains'], true) && isset($fields['code'])) { $query = DB::table($this->definition($kind)['table'])->where('code', $fields['code'])->where('id', '<>', $record->id)->whereNull('deleted_at'); if ($kind === 'value-chains') { $query->where('cluster_id', $fields['cluster_id'] ?? $record->cluster_id); } abort_if($query->exists(), 409, 'Código entretanto utilizado. Reveja a proposta.'); } if (in_array($kind, ['cluster-project', 'corridor-project', 'corridor-cluster', 'zone-relations'], true)) { foreach (['source_reference', 'valid_from'] as $required) { abort_unless(! empty($fields[$required]), 422, 'Fonte e vigência são necessárias.'); } abort_if($kind === 'zone-relations' && ($fields['association_type'] ?? '') === 'official' && empty($fields['legal_source_id']), 422, 'A associação oficial exige a fonte jurídica prevista.'); if (! empty($fields['legal_source_id'])) { $source = DB::table('investment_legal_sources')->where('id', $fields['legal_source_id'])->lockForUpdate()->first(); abort_unless($source && $source->validation_status === 'validated' && $source->valid_from <= today()->toDateString() && (! $source->valid_until || $source->valid_until >= today()->toDateString()), 422, 'Fonte jurídica fora de vigência.'); } $decision = json_decode($edition->approval, true, 512, JSON_THROW_ON_ERROR); $fields += ['validation_status' => 'validated', 'validator_id' => $decision['actor_id'], 'editor_id' => $edition->author_id]; if (property_exists($record, 'data_classification')) { $fields['data_classification'] = 'public'; } } if ($kind === 'value-chain-links') { $chains = DB::table('value_chains')->whereIn('id', [$fields['from_value_chain_id'], $fields['to_value_chain_id']])->whereNull('deleted_at')->get(['id', 'cluster_id']); abort_unless($chains->count() === 2 && $chains->pluck('cluster_id')->unique()->count() === 1, 422, 'As etapas devem ser distintas e pertencer ao mesmo cluster.'); } if ($kind === 'investment-gaps') { abort_unless(DB::table('value_chains')->where('id', $fields['value_chain_id'])->where('cluster_id', $fields['cluster_id'])->whereNull('deleted_at')->exists(), 422, 'Etapa fora do cluster da lacuna.'); } if ($subject['new_record'] ?? false) { $fields['deleted_at'] = null; } if (property_exists($record, 'data_classification')) { $fields['data_classification'] = 'public'; } if (property_exists($record, 'version_no')) { $fields['version_no'] = (int) $record->version_no + 1; } $fields['updated_at'] = now(); $this->query($kind, $record->id)->update($fields); $this->audit($actor, $edition, 'published', $record); } public function archive(User $actor, \stdClass $record, \stdClass $edition): void { $fields = property_exists($record, 'validation_status') ? ['validation_status' => 'revoked'] : (in_array($edition->subject_kind, ['projects', 'clusters', 'corridors'], true) ? ['data_classification' => 'internal'] : ['deleted_at' => now()]); if (property_exists($record, 'version_no')) { $fields['version_no'] = (int) $record->version_no + 1; } $this->query($edition->subject_kind, $record->id)->update([...$fields, 'updated_at' => now()]); $this->audit($actor, $edition, 'archived', $record); } private function audit(User $actor, \stdClass $edition, string $action, \stdClass $before): void { $after = $this->query($edition->subject_kind, $edition->subject_key)->firstOrFail(); $changed = []; foreach (get_object_vars($after) as $field => $value) { if (($before->{$field} ?? null) !== $value) { $changed[] = $field; } } DB::table('audit_events')->insert(['source_system' => 'editorial-structural-subjects', 'entity_type' => $this->definition($edition->subject_kind)['table'], 'entity_id' => $edition->subject_key, 'actor_user_id' => $actor->id, 'action' => $action, 'data_classification' => 'restricted', 'before_data' => json_encode(Arr::only(get_object_vars($before), ['id', 'version_no', 'validation_status', 'deleted_at', 'data_classification']), JSON_THROW_ON_ERROR), 'after_data' => json_encode(['edition_id' => $edition->id, 'hash' => $edition->content_hash, 'record_id' => $edition->subject_key, 'changed_fields' => $changed], JSON_THROW_ON_ERROR)]); } }