Files
Investidor-V2/app/Services/PortalEditorialSubjects.php
T
2026-11-14 11:15:09 +02:00

395 lines
23 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/** Subject mapping and publication effects for the existing common editorial engine. */
final class PortalEditorialSubjects
{
public const SUBJECTS = [
'projects' => ['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<string>} */
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<string,mixed> $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<string,string> */
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<string,list<string>> */
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<array<string,mixed>> */
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<string,mixed> $input
* @return array<string,mixed>
*/
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<array{id:string,name:string}> */
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<string,mixed> $fields
* @return array<string,mixed>
*/
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<string,mixed> $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<string,mixed> $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)]);
}
}