491 lines
31 KiB
PHP
491 lines
31 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Sector;
|
|
use App\Models\User;
|
|
use Illuminate\Database\Query\Builder;
|
|
use Illuminate\Http\Response;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|
|
|
/** Informational funding catalogue, separate from applications, credit decisions and fiscal calculations. */
|
|
final class FundingProgrammes
|
|
{
|
|
public const KINDS = ['institution' => 'Instituição financeira', 'fund' => 'Fundo', 'programme' => 'Programa de apoio', 'incentive' => 'Incentivo'];
|
|
|
|
public const WINDOWS = ['dated' => 'Datas documentadas', 'rolling' => 'Recepção contínua documentada', 'unknown' => 'Janela por confirmar', 'closed' => 'Encerrada segundo a fonte'];
|
|
|
|
public function installed(): bool
|
|
{
|
|
return Schema::hasTable('funding_programme_interests') && Schema::hasTable('funding_programme_versions');
|
|
}
|
|
|
|
public function access(?User $actor): bool
|
|
{
|
|
return app(DocumentManagementAccess::class)->catalogueEditor($actor);
|
|
}
|
|
|
|
public function guard(?User $actor): void
|
|
{
|
|
abort_unless($this->installed(), 404);
|
|
abort_unless($this->access($actor), 403);
|
|
}
|
|
|
|
/** @param array<string,mixed> $reference */
|
|
public function matchesReference(User $actor, string $id, array $reference): bool
|
|
{
|
|
$this->guard($actor);
|
|
$row = DB::table('funding_programmes')->where('id', $id)->firstOrFail();
|
|
$expected = $this->normalize($actor, $reference, false);
|
|
$actual = json_decode($row->draft, true, 512, JSON_THROW_ON_ERROR);
|
|
abort_unless(is_array($actual), 503);
|
|
|
|
return json_encode(Arr::sortRecursive($actual), JSON_THROW_ON_ERROR)
|
|
=== json_encode(Arr::sortRecursive($expected), JSON_THROW_ON_ERROR);
|
|
}
|
|
|
|
public function publicQuery(): Builder
|
|
{
|
|
abort_unless($this->installed(), 404);
|
|
|
|
return DB::table('funding_programmes as p')->join('funding_programme_versions as v', fn ($join) => $join->on('v.id', '=', 'p.published_version_id')->on('v.programme_id', '=', 'p.id'))
|
|
->where('v.published_at', '<=', now());
|
|
}
|
|
|
|
public function readableVersions(): Builder
|
|
{
|
|
return DB::table('funding_programme_versions')->where('published_at', '<=', now())
|
|
->whereIn('programme_id', $this->publicQuery()->select('p.id'));
|
|
}
|
|
|
|
/** @param array<string,mixed> $filters */
|
|
public function catalogueQuery(array $filters): Builder
|
|
{
|
|
$query = $this->publicQuery();
|
|
app(FundingInstitutionDetails::class)->applyFilters($query, $filters);
|
|
$includeUnknown = ($filters['certainty'] ?? 'include_unknown') === 'include_unknown';
|
|
if (! empty($filters['sector'])) {
|
|
$query->where(function (Builder $scope) use ($filters, $includeUnknown): void {
|
|
$scope->whereRaw("v.payload->>'sectors_scope' = 'all'")
|
|
->orWhereJsonContains('v.payload->sector_ids', (int) $filters['sector']);
|
|
if ($includeUnknown) {
|
|
$scope->orWhereRaw("v.payload->>'sectors_scope' = 'unknown'");
|
|
}
|
|
});
|
|
}
|
|
if (! empty($filters['territory']) && ($filters['location_mode'] ?? '') !== 'physical') {
|
|
$path = array_column(app(InvestmentGeography::class)->path($filters['territory']), 'id');
|
|
$query->where(function (Builder $scope) use ($filters, $path, $includeUnknown): void {
|
|
$scope->whereRaw("v.payload->>'kind' = 'institution'")->orWhereRaw("v.payload->>'coverage' = 'national'")
|
|
->orWhereJsonContains('v.payload->territory_ancestor_ids', $filters['territory']);
|
|
foreach ($path as $id) {
|
|
$scope->orWhereJsonContains('v.payload->territory_ids', $id);
|
|
}
|
|
if ($includeUnknown) {
|
|
$scope->orWhereRaw("v.payload->>'coverage' = 'unknown'");
|
|
}
|
|
});
|
|
}
|
|
if (! empty($filters['state'])) {
|
|
$today = today()->toDateString();
|
|
$state = $filters['state'];
|
|
if ($state === 'unknown') {
|
|
$query->whereRaw("(jsonb_array_length(v.payload->'windows') = 0 OR EXISTS (SELECT 1 FROM jsonb_array_elements(v.payload->'windows') w WHERE w->>'mode' = 'unknown'))");
|
|
} elseif ($state === 'closed') {
|
|
$query->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(v.payload->'windows') w WHERE w->>'mode' = 'closed' OR (w->>'mode' = 'dated' AND w->>'end_on' < ?))", [$today]);
|
|
} elseif ($state === 'upcoming') {
|
|
$query->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(v.payload->'windows') w WHERE w->>'mode' = 'dated' AND w->>'start_on' > ?)", [$today]);
|
|
} else {
|
|
$query->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(v.payload->'windows') w WHERE w->>'mode' IN ('dated','rolling') AND COALESCE(NULLIF(w->>'start_on',''),?) <= ? AND COALESCE(NULLIF(w->>'end_on',''),?) >= ?)", [$today, $today, $today, $today]);
|
|
}
|
|
}
|
|
|
|
return $query;
|
|
}
|
|
|
|
/** @param array<string,mixed> $record
|
|
* @param array<string,mixed> $filters
|
|
* @return array<string,string> */
|
|
public function filterExplanation(array $record, array $filters): array
|
|
{
|
|
$payload = $record['payload'];
|
|
if ($payload['kind'] === 'institution') {
|
|
$products = array_values(array_filter($payload['products'] ?? [], fn ($p) => app(FundingInstitutionDetails::class)->match($p, $filters)));
|
|
|
|
if (($filters['location_mode'] ?? '') === 'physical') {
|
|
return ['institution' => 'Pesquisa por presença física documentada, não por cobertura de financiamento. Confira a precisão do local e as indicações por confirmar na ficha.'];
|
|
}
|
|
|
|
return ['institution' => $products === [] ? 'Instituição identificada; produtos, cobertura e disponibilidade por confirmar. Não é uma correspondência confirmada.' : 'Consulte os produtos correspondentes e as condições por confirmar. Sede e balcões não determinam a cobertura do financiamento.'];
|
|
}
|
|
$result = [];
|
|
if (! empty($filters['sector'])) {
|
|
$result['sector'] = $payload['sectors_scope'] === 'unknown'
|
|
? 'Âmbito sectorial por confirmar; não é uma correspondência confirmada.'
|
|
: 'O sector está abrangido pelo âmbito documentado do programa.';
|
|
}
|
|
if (! empty($filters['territory'])) {
|
|
$path = array_column(app(InvestmentGeography::class)->path($filters['territory']), 'id');
|
|
$result['territory'] = $payload['coverage'] === 'unknown' ? 'Cobertura territorial por confirmar; não é uma correspondência confirmada.'
|
|
: ($payload['coverage'] === 'national' || array_intersect($path, $payload['territory_ids']) !== []
|
|
? 'O local está abrangido pela cobertura documentada do programa.'
|
|
: 'O programa abrange uma parte da área seleccionada; confira os locais na ficha.');
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
public function published(string $id): array
|
|
{
|
|
$row = $this->publicQuery()->where('p.id', $id)->firstOrFail(['p.id', 'p.code', 'v.id as version_id', 'v.version', 'v.payload', 'v.published_at']);
|
|
|
|
return $this->present($row);
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
public function present(\stdClass $row): array
|
|
{
|
|
$payload = json_decode($row->payload, true, 512, JSON_THROW_ON_ERROR);
|
|
foreach ($payload['windows'] as &$window) {
|
|
$window['state'] = $this->windowState($window);
|
|
}
|
|
unset($window);
|
|
$payload['documents'] = array_map(fn ($document) => [...$document,
|
|
'available' => app(Procurement::class)->publicDocuments()->where('r.id', $document['resource_id'])->where('d.id', $document['document_id'])
|
|
->where('d.sha256', $document['sha256'])->where('d.version_no', $document['version'])->exists(),
|
|
], $payload['document_snapshots'] ?? []);
|
|
|
|
return ['id' => $row->id, 'code' => $row->code, 'version_id' => $row->version_id, 'version' => (int) $row->version,
|
|
'published_at' => $row->published_at, 'payload' => $payload];
|
|
}
|
|
|
|
/** @param array<string,mixed> $window */
|
|
public function windowState(array $window): string
|
|
{
|
|
if ($window['mode'] === 'closed') {
|
|
return 'closed';
|
|
}
|
|
if ($window['mode'] === 'unknown') {
|
|
return 'unknown';
|
|
}
|
|
if (! empty($window['end_on']) && $window['end_on'] < today()->toDateString()) {
|
|
return 'closed';
|
|
}
|
|
if (! empty($window['start_on']) && $window['start_on'] > today()->toDateString()) {
|
|
return 'upcoming';
|
|
}
|
|
|
|
return 'open';
|
|
}
|
|
|
|
/** Resolve only the exact library binary incorporated in the current public version. */
|
|
public function download(string $id, string $version, int $resource): Response|BinaryFileResponse
|
|
{
|
|
$record = $this->published($id);
|
|
abort_unless($record['version_id'] === $version, 404);
|
|
$snapshot = null;
|
|
foreach ($record['payload']['document_snapshots'] ?? [] as $candidate) {
|
|
if ($candidate['resource_id'] === $resource) {
|
|
$snapshot = $candidate;
|
|
break;
|
|
}
|
|
}
|
|
abort_unless($snapshot !== null, 404);
|
|
$document = app(Procurement::class)->publicDocuments()->where('r.id', $resource)
|
|
->where('d.id', $snapshot['document_id'])->where('d.version_no', $snapshot['version'])
|
|
->where('d.sha256', $snapshot['sha256'])->first(['d.*']);
|
|
abort_unless($document !== null, 404);
|
|
|
|
return app(VerifiedDocumentBinary::class)->respond($document);
|
|
}
|
|
|
|
/** @param array<string,mixed> $input */
|
|
public function save(User $actor, ?string $id, array $input): string
|
|
{
|
|
$this->guard($actor);
|
|
$data = Validator::make($input, ['version' => 'required|integer|min:0', 'code' => 'required|string|regex:/^[a-z0-9][a-z0-9-]{0,79}$/D',
|
|
'reason' => 'required|string|min:20|max:2000', 'draft' => 'required|array'])->validate();
|
|
$draft = $this->normalize($actor, $data['draft'], false);
|
|
|
|
return DB::transaction(function () use ($actor, $id, $data, $draft): string {
|
|
DB::select('select pg_advisory_xact_lock(261102, hashtext(?))', [$data['code']]);
|
|
$row = $id ? DB::table('funding_programmes')->where('id', $id)->lockForUpdate()->firstOrFail() : null;
|
|
abort_unless((int) ($row->version ?? 0) === (int) $data['version'], 409, 'A ficha foi alterada. Recupere a versão actual antes de guardar.');
|
|
abort_if($row && $row->code !== $data['code'], 422, 'O identificador da ficha é estável.');
|
|
if (! $row && DB::table('funding_programmes')->where('code', $data['code'])->exists()) {
|
|
throw ValidationException::withMessages(['code' => 'Este identificador já pertence a uma ficha. Abra-a para actualizar ou acrescentar uma janela.']);
|
|
}
|
|
if ($draft['kind'] === 'institution') {
|
|
DB::select('select pg_advisory_xact_lock(261102, hashtext(?))', ['institution:'.$draft['institution_key']]);
|
|
$duplicate = DB::table('funding_programmes')->whereRaw("draft->>'kind' = 'institution'")->whereRaw("draft->>'institution_key' = ?", [$draft['institution_key']])->when($id, fn ($q) => $q->where('id', '<>', $id))->exists();
|
|
if ($duplicate) {
|
|
throw ValidationException::withMessages(['institution_key' => 'A instituição já tem ficha. Reutilize a identidade existente; não substitua as suas edições.']);
|
|
}
|
|
}
|
|
$id ??= (string) Str::uuid();
|
|
DB::table('funding_programmes')->updateOrInsert(['id' => $id], ['code' => $data['code'], 'version' => (int) $data['version'] + 1,
|
|
'draft' => json_encode($draft, JSON_THROW_ON_ERROR), 'created_by' => $row->created_by ?? $actor->id,
|
|
'created_at' => $row->created_at ?? now(), 'updated_at' => now()]);
|
|
$this->audit($actor, $id, 'draft_saved', ['version' => (int) $data['version'] + 1, 'reason' => $data['reason']]);
|
|
|
|
return $id;
|
|
});
|
|
}
|
|
|
|
/** @param array<string,mixed> $input
|
|
* @return array<string,mixed> */
|
|
private function normalize(User $actor, array $input, bool $publishing): array
|
|
{
|
|
$required = $publishing ? 'required' : 'nullable';
|
|
$rules = [
|
|
'title' => "$required|string|max:250", 'kind' => ['required', Rule::in(array_keys(self::KINDS))], 'summary' => "$required|string|max:4000",
|
|
'manager_name' => 'nullable|string|max:250', 'manager_organization_id' => ['nullable', 'uuid', Rule::exists('organizations', 'id')->whereNull('deleted_at')],
|
|
'source_url' => 'nullable|url:http,https|max:2000', 'source_document_name' => 'nullable|string|max:250', 'source_document_sha256' => 'nullable|string|regex:/^[a-f0-9]{64}$/D', 'source_reference' => "$required|string|max:1500", 'source_checked_on' => "$required|date_format:Y-m-d|before_or_equal:today",
|
|
'source_published_on' => 'nullable|date_format:Y-m-d', 'source_status' => 'required|in:identified,unknown,example',
|
|
'coverage' => 'required|in:national,territorial,unknown', 'coverage_text' => 'nullable|string|max:2000',
|
|
'territory_ids' => 'array|max:30', 'territory_ids.*' => 'required|uuid|distinct',
|
|
'sectors_scope' => 'required|in:all,selected,unknown', 'sectors_text' => 'nullable|string|max:2000', 'sector_ids' => 'array|max:40',
|
|
'sector_ids.*' => ['required', 'integer', 'distinct', Rule::in(Sector::choices()->whereNotNull('published_at')->pluck('id')->all())],
|
|
'resource_ids' => 'array|max:30', 'resource_ids.*' => 'required|integer|distinct|exists:portal_resources,id',
|
|
'funding_needs' => 'array|max:6', 'funding_needs.*' => ['required', 'distinct', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))],
|
|
'criteria' => 'array|max:30', 'windows' => 'array|max:20',
|
|
'windows.*' => 'required|array:code,title,mode,start_on,end_on,coverage,coverage_text,territory_ids,source_url,source_reference,checked_on,application_url,criteria',
|
|
'windows.*.code' => 'required|string|regex:/^[a-z0-9][a-z0-9-]{0,79}$/D|distinct', 'windows.*.title' => "$required|string|max:250",
|
|
'windows.*.mode' => ['required', Rule::in(array_keys(self::WINDOWS))], 'windows.*.start_on' => 'nullable|date_format:Y-m-d', 'windows.*.end_on' => 'nullable|date_format:Y-m-d',
|
|
'windows.*.coverage' => 'required|in:programme,national,territorial,unknown', 'windows.*.coverage_text' => 'nullable|string|max:2000',
|
|
'windows.*.territory_ids' => 'array|max:30', 'windows.*.territory_ids.*' => 'required|uuid|distinct',
|
|
'windows.*.source_url' => "$required|url:http,https|max:2000", 'windows.*.source_reference' => "$required|string|max:1500",
|
|
'windows.*.checked_on' => "$required|date_format:Y-m-d|before_or_equal:today", 'windows.*.application_url' => 'nullable|url:http,https|max:2000', 'windows.*.criteria' => 'array|max:30',
|
|
];
|
|
foreach (['criteria', 'windows.*.criteria'] as $prefix) {
|
|
$rules[$prefix.'.*'] = 'required|array:code,question,applies_to,expected,source_reference,explanation';
|
|
$rules[$prefix.'.*.code'] = 'required|string|regex:/^[a-z0-9][a-z0-9-]{0,79}$/D';
|
|
$rules[$prefix.'.*.question'] = "$required|string|max:500";
|
|
$rules[$prefix.'.*.applies_to'] = 'required|in:all,individual,entity';
|
|
$rules[$prefix.'.*.expected'] = 'required|in:yes,no';
|
|
$rules[$prefix.'.*.source_reference'] = "$required|string|max:1500";
|
|
$rules[$prefix.'.*.explanation'] = "$required|string|max:2000";
|
|
}
|
|
$data = Validator::make($input, $rules)->validate();
|
|
if ($publishing && empty($data['source_url'])) {
|
|
Validator::make($data, ['source_document_name' => 'required|string', 'source_document_sha256' => 'required|string|size:64'])->validate();
|
|
}
|
|
if ($data['kind'] === 'institution') {
|
|
$data = [...$data, ...app(FundingInstitutionDetails::class)->normalize($input, $publishing)];
|
|
}
|
|
$data = [...$data, 'territory_ids' => $data['territory_ids'] ?? [], 'sector_ids' => $data['sector_ids'] ?? [], 'resource_ids' => $data['resource_ids'] ?? [],
|
|
'criteria' => $data['criteria'] ?? [], 'windows' => $data['windows'] ?? []];
|
|
if ($data['coverage'] !== 'territorial' && $data['territory_ids'] !== []) {
|
|
throw ValidationException::withMessages(['territory_ids' => 'Seleccione cobertura territorial para associar locais específicos.']);
|
|
}
|
|
if ($data['sectors_scope'] !== 'selected' && $data['sector_ids'] !== []) {
|
|
throw ValidationException::withMessages(['sector_ids' => 'Seleccione o âmbito por sectores para associar sectores específicos.']);
|
|
}
|
|
if ($publishing && $data['coverage'] === 'territorial' && $data['territory_ids'] === []) {
|
|
throw ValidationException::withMessages(['territory_ids' => 'Seleccione os locais documentados ou assinale a cobertura como por determinar.']);
|
|
}
|
|
if ($data['manager_organization_id'] ?? null) {
|
|
$data['manager_name'] = DB::table('organizations')->where('id', $data['manager_organization_id'])->value('name');
|
|
}
|
|
if ($publishing && empty($data['manager_name'])) {
|
|
throw ValidationException::withMessages(['manager_name' => 'Seleccione a entidade do cadastro ou identifique o gestor indicado pela fonte.']);
|
|
}
|
|
if ($publishing && $data['source_status'] !== 'identified') {
|
|
throw ValidationException::withMessages(['source_status' => 'Identifique a fonte efectiva. Conteúdo de exemplo ou origem por identificar não confirma um programa real.']);
|
|
}
|
|
if ($publishing && $data['sectors_scope'] === 'selected' && $data['sector_ids'] === []) {
|
|
throw ValidationException::withMessages(['sector_ids' => 'Seleccione os sectores documentados ou assinale o âmbito como por determinar.']);
|
|
}
|
|
$territories = $data['territory_ids'];
|
|
$codes = [];
|
|
foreach (array_merge([$data['criteria']], array_column($data['windows'], 'criteria')) as $criteria) {
|
|
foreach ($criteria as $criterion) {
|
|
if (in_array($criterion['code'], $codes, true)) {
|
|
throw ValidationException::withMessages(['criteria' => 'Cada critério deve ter um identificador único na ficha, sem repetir perguntas comuns por janela.']);
|
|
}
|
|
$codes[] = $criterion['code'];
|
|
}
|
|
}
|
|
foreach ($data['windows'] as $index => &$window) {
|
|
$window['criteria'] ??= [];
|
|
$window['territory_ids'] ??= [];
|
|
if ($window['coverage'] !== 'territorial' && $window['territory_ids'] !== []) {
|
|
throw ValidationException::withMessages(['windows.'.$index.'.territory_ids' => 'Associe locais apenas numa janela de cobertura territorial.']);
|
|
}
|
|
if ($publishing && $window['coverage'] === 'territorial' && $window['territory_ids'] === []) {
|
|
throw ValidationException::withMessages(['windows.'.$index.'.territory_ids' => 'Seleccione os locais documentados ou assinale a cobertura como por determinar.']);
|
|
}
|
|
$territories = array_merge($territories, $window['territory_ids']);
|
|
if ($publishing && $window['mode'] === 'dated' && empty($window['end_on'])) {
|
|
throw ValidationException::withMessages(['windows.'.$index.'.end_on' => 'Uma janela com datas documentadas precisa da data-limite.']);
|
|
}
|
|
if (! empty($window['start_on']) && ! empty($window['end_on']) && $window['start_on'] > $window['end_on']) {
|
|
throw ValidationException::withMessages(['windows.'.$index.'.end_on' => 'A data-limite não pode anteceder a abertura.']);
|
|
}
|
|
if (in_array($window['mode'], ['rolling', 'unknown'], true) && (! empty($window['start_on']) || ! empty($window['end_on']))) {
|
|
throw ValidationException::withMessages(['windows.'.$index.'.mode' => 'Conserve datas documentadas ou escolha uma janela sem datas; não misture as duas indicações.']);
|
|
}
|
|
}
|
|
unset($window);
|
|
foreach (array_unique($territories) as $territory) {
|
|
if (! app(InvestmentGeography::class)->publicReferences()->where('id', $territory)->exists()) {
|
|
throw ValidationException::withMessages(['territory_ids' => 'Seleccione referências do cadastro territorial público.']);
|
|
}
|
|
}
|
|
if ($publishing) {
|
|
$data['territory_paths'] = array_map(fn ($id) => array_map(fn ($node) => ['id' => $node->id, 'name' => $node->name, 'type' => $node->location_type], app(InvestmentGeography::class)->path($id)), $data['territory_ids']);
|
|
$data['territory_ancestor_ids'] = array_values(array_unique(array_merge([], ...array_map(fn ($path) => array_column($path, 'id'), $data['territory_paths']))));
|
|
$data['sector_snapshots'] = Sector::choices()->whereIn('id', $data['sector_ids'])->get(['id', 'name'])->toArray();
|
|
foreach ($data['windows'] as &$window) {
|
|
$window['territory_paths'] = array_map(fn ($id) => array_map(fn ($node) => ['id' => $node->id, 'name' => $node->name, 'type' => $node->location_type], app(InvestmentGeography::class)->path($id)), $window['territory_ids']);
|
|
}
|
|
unset($window);
|
|
}
|
|
foreach ($data['resource_ids'] as $resource) {
|
|
$public = app(Procurement::class)->publicDocuments()->where('r.id', $resource)->first(['r.title', 'd.id', 'd.version_no', 'd.sha256']);
|
|
$document = DB::table('portal_resources')->where('id', $resource)->value('document_id');
|
|
if (! $public && ($publishing || ! app(DocumentManagementAccess::class)->documents($actor)->where('id', $document)->exists())) {
|
|
throw ValidationException::withMessages(['resource_ids' => 'Um documento publicado exige acesso público, direitos e análise de segurança válidos na biblioteca.']);
|
|
}
|
|
if ($publishing) {
|
|
$data['document_snapshots'][] = ['resource_id' => $resource, 'document_id' => $public->id, 'version' => (int) $public->version_no,
|
|
'sha256' => trim($public->sha256), 'title' => $public->title];
|
|
}
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
public function preview(User $actor, string $id): array
|
|
{
|
|
$this->guard($actor);
|
|
$row = DB::table('funding_programmes')->where('id', $id)->firstOrFail();
|
|
$payload = $this->normalize($actor, json_decode($row->draft, true, 512, JSON_THROW_ON_ERROR), true);
|
|
unset($payload['manager_organization_id']);
|
|
$plan = ['id' => $id, 'code' => $row->code, 'version' => (int) $row->version, 'payload' => $payload];
|
|
$plan['hash'] = hash_hmac('sha256', json_encode(Arr::sortRecursive($plan), JSON_THROW_ON_ERROR), config('app.key'));
|
|
|
|
return $plan;
|
|
}
|
|
|
|
/** @param array<string,mixed> $input */
|
|
public function publish(User $actor, string $id, array $input): string
|
|
{
|
|
$this->guard($actor);
|
|
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'hash' => 'required|string|size:64',
|
|
'reason' => 'required|string|min:20|max:2000', 'confirm' => 'accepted'])->validate();
|
|
|
|
return DB::transaction(function () use ($actor, $id, $data): string {
|
|
$row = DB::table('funding_programmes')->where('id', $id)->lockForUpdate()->firstOrFail();
|
|
$plan = $this->preview($actor, $id);
|
|
abort_unless((int) $row->version === (int) $data['version'] && hash_equals($plan['hash'], $data['hash']), 409, 'A prévia mudou. Confira novamente antes de publicar.');
|
|
if ($row->published_version_id) {
|
|
$previous = DB::table('funding_programme_versions')->where('id', $row->published_version_id)->firstOrFail();
|
|
if (json_decode($previous->payload, false, 512, JSON_THROW_ON_ERROR) == json_decode(json_encode($plan['payload'], JSON_THROW_ON_ERROR), false, 512, JSON_THROW_ON_ERROR)) {
|
|
return $previous->id;
|
|
}
|
|
}
|
|
$version = (string) Str::uuid();
|
|
$publicationNumber = max((int) $row->version, (int) DB::table('funding_programme_versions')->where('programme_id', $id)->max('version') + 1);
|
|
DB::table('funding_programme_versions')->insert(['id' => $version, 'programme_id' => $id, 'version' => $publicationNumber,
|
|
'payload' => json_encode($plan['payload'], JSON_THROW_ON_ERROR), 'reason' => $data['reason'], 'actor_id' => $actor->id, 'published_at' => now()]);
|
|
DB::table('funding_programmes')->where('id', $id)->update(['published_version_id' => $version, 'updated_at' => now()]);
|
|
$this->audit($actor, $id, 'published', ['version_id' => $version, 'version' => $publicationNumber, 'draft_version' => (int) $row->version, 'reason' => $data['reason'], 'effect' => 'new_public_version_only']);
|
|
|
|
return $version;
|
|
});
|
|
}
|
|
|
|
public function withdraw(User $actor, string $id, int $version, string $reason): void
|
|
{
|
|
$this->guard($actor);
|
|
Validator::make(['reason' => $reason], ['reason' => 'required|string|min:20|max:2000'])->validate();
|
|
DB::transaction(function () use ($actor, $id, $version, $reason): void {
|
|
$row = DB::table('funding_programmes')->where('id', $id)->lockForUpdate()->firstOrFail();
|
|
abort_unless((int) $row->version === $version, 409);
|
|
DB::table('funding_programmes')->where('id', $id)->update(['published_version_id' => null, 'version' => $version + 1, 'updated_at' => now()]);
|
|
$this->audit($actor, $id, 'withdrawn', ['previous_version_id' => $row->published_version_id, 'reason' => $reason]);
|
|
});
|
|
}
|
|
|
|
/** @param array<string,mixed> $input
|
|
* @return array<string,mixed> */
|
|
public function assess(string $id, array $input): array
|
|
{
|
|
$record = $this->published($id);
|
|
$data = Validator::make($input, ['version_id' => 'required|uuid', 'applicant_kind' => 'required|in:individual,entity,unknown',
|
|
'window_code' => 'nullable|string|max:80', 'answers' => 'present|array|max:60', 'answers.*' => 'required|in:yes,no,unknown'])->validate();
|
|
abort_unless($data['version_id'] === $record['version_id'], 409, 'A versão pública mudou. Recupere os critérios actuais antes de conferir.');
|
|
$window = null;
|
|
foreach ($record['payload']['windows'] as $candidate) {
|
|
if ($candidate['code'] === ($data['window_code'] ?? null)) {
|
|
$window = $candidate;
|
|
break;
|
|
}
|
|
}
|
|
abort_if(! empty($data['window_code']) && ! $window, 422);
|
|
$criteria = array_merge($record['payload']['criteria'], $window['criteria'] ?? []);
|
|
abort_if(array_diff(array_keys($data['answers']), array_column($criteria, 'code')) !== [], 422, 'A resposta deve pertencer aos critérios desta versão e janela.');
|
|
$results = [];
|
|
foreach ($criteria as $criterion) {
|
|
$status = 'unknown';
|
|
if ($criterion['applies_to'] !== 'all' && $data['applicant_kind'] !== 'unknown' && $criterion['applies_to'] !== $data['applicant_kind']) {
|
|
$status = 'not_applicable';
|
|
} elseif (($criterion['applies_to'] === 'all' || $criterion['applies_to'] === $data['applicant_kind']) && ($data['answers'][$criterion['code']] ?? 'unknown') !== 'unknown') {
|
|
$status = $data['answers'][$criterion['code']] === $criterion['expected'] ? 'matches' : 'does_not_match';
|
|
}
|
|
$results[] = [...$criterion, 'status' => $status];
|
|
}
|
|
|
|
return ['version_id' => $record['version_id'], 'window_code' => $window['code'] ?? null, 'window_state' => $window ? $this->windowState($window) : 'unknown',
|
|
'criteria' => $results, 'counts' => array_count_values(array_column($results, 'status')),
|
|
'notice' => 'Correspondência preliminar das respostas declaradas com os critérios citados. Não é aprovação, candidatura enviada, concessão de financiamento ou benefício fiscal.'];
|
|
}
|
|
|
|
public function saveInterest(User $actor, string $id, string $version, bool $consent): void
|
|
{
|
|
abort_unless($consent, 422, 'Confirme que pretende guardar este programa na sua área privada.');
|
|
DB::transaction(function () use ($actor, $id, $version): void {
|
|
// Serialize this person's bookmarks without blocking audit FK KEY SHARE locks.
|
|
DB::table('users')->where('id', $actor->id)->lock('FOR NO KEY UPDATE')->firstOrFail();
|
|
DB::table('funding_programmes')->where('id', $id)->lockForUpdate()->firstOrFail();
|
|
$record = $this->published($id);
|
|
abort_unless($record['version_id'] === $version, 409, 'A versão pública mudou. Recupere a ficha antes de guardar.');
|
|
$saved = DB::table('funding_programme_interests')->where('user_id', $actor->id)->where('programme_id', $id)->first();
|
|
if ($saved && $saved->removed_at === null) {
|
|
return;
|
|
}
|
|
DB::table('funding_programme_interests')->updateOrInsert(['user_id' => $actor->id, 'programme_id' => $id], [
|
|
'version_id' => $version, 'saved_at' => now(), 'removed_at' => null,
|
|
]);
|
|
$this->audit($actor, $id, 'interest_saved', ['version_id' => $version, 'scope' => 'private_bookmark_not_application']);
|
|
});
|
|
}
|
|
|
|
public function removeInterest(User $actor, string $id): void
|
|
{
|
|
DB::transaction(function () use ($actor, $id): void {
|
|
DB::table('users')->where('id', $actor->id)->lock('FOR NO KEY UPDATE')->firstOrFail();
|
|
if (DB::table('funding_programme_interests')->where('user_id', $actor->id)->where('programme_id', $id)->whereNull('removed_at')->update(['removed_at' => now()])) {
|
|
$this->audit($actor, $id, 'interest_removed', ['scope' => 'private_bookmark']);
|
|
}
|
|
});
|
|
}
|
|
|
|
/** @param array<string,mixed> $data */
|
|
private function audit(User $actor, string $id, string $action, array $data): void
|
|
{
|
|
DB::table('audit_events')->insert(['source_system' => 'funding-catalogue', 'entity_type' => 'funding_programmes', 'entity_id' => $id,
|
|
'actor_user_id' => $actor->id, 'action' => $action, 'data_classification' => 'restricted', 'after_data' => json_encode($data, JSON_THROW_ON_ERROR)]);
|
|
}
|
|
}
|