Compare commits
168
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
276d833a4b | ||
|
|
5d45541a11 | ||
|
|
066707ece6 | ||
|
|
c1f4a71963 | ||
|
|
cf2ddf21a1 | ||
|
|
3fd5128c62 | ||
|
|
cdf8fcfba4 | ||
|
|
8036b9b071 | ||
|
|
6985f82e9c | ||
|
|
2856240cf4 | ||
|
|
32144aa2f3 | ||
|
|
1afdfee8c0 | ||
|
|
bc84fbdb3e | ||
|
|
b6a626c92d | ||
|
|
55136b446d | ||
|
|
4a01e9e2b7 | ||
|
|
ad4160f040 | ||
|
|
af9261809c | ||
|
|
d8224ba84b | ||
|
|
088f2c2cf4 | ||
|
|
603a88e1d3 | ||
|
|
43cd0be7cb | ||
|
|
89a9c3d994 | ||
|
|
4098192a05 | ||
|
|
d242482042 | ||
|
|
6b544548d3 | ||
|
|
c3f7c3ad10 | ||
|
|
445aa7e69d | ||
|
|
7c1f532134 | ||
|
|
115c773162 | ||
|
|
ec4b2f2af2 | ||
|
|
3d877ec745 | ||
|
|
6948ed62fc | ||
|
|
7d798579a6 | ||
|
|
5bda74aedc | ||
|
|
f2150b1912 | ||
|
|
7ca94967f3 | ||
|
|
e1f010f941 | ||
|
|
0025309b98 | ||
|
|
a0e80db4ec | ||
|
|
7226287526 | ||
|
|
f1ac82ae4d | ||
|
|
98474777f6 | ||
|
|
3848f29bea | ||
|
|
203492997e | ||
|
|
165c2f6171 | ||
|
|
4ad1bf402b | ||
|
|
e12ef6ad42 | ||
|
|
c641b267ac | ||
|
|
e2b0fb4b2d | ||
|
|
7d08c2c664 | ||
|
|
761035aefb | ||
|
|
20a4099d10 | ||
|
|
e03de682ca | ||
|
|
37a8eb015a | ||
|
|
65eddf6bc7 | ||
|
|
fb4e7d8970 | ||
|
|
bc93ccdb9e | ||
|
|
7ccf62adec | ||
|
|
f4d0970917 | ||
|
|
e46f973159 | ||
|
|
1bcf34c0c0 | ||
|
|
ef0c2acd36 | ||
|
|
30aed7cd12 | ||
|
|
95d4cf44d7 | ||
|
|
73c046e104 | ||
|
|
15d82611b6 | ||
|
|
8286d7504d | ||
|
|
aa2b3d05b0 | ||
|
|
97d51db60a | ||
|
|
a1b553372e | ||
|
|
f80d30f478 | ||
|
|
916819eb35 | ||
|
|
1d921c9d03 | ||
|
|
7b1c35793c | ||
|
|
1ec318561c | ||
|
|
9ac623a433 | ||
|
|
5f4eb5cfaa | ||
|
|
042ffbde44 | ||
|
|
064296e1fb | ||
|
|
a1fbda06ad | ||
|
|
52fbb1468a | ||
|
|
a76369eb86 | ||
|
|
57fc914821 | ||
|
|
0b0cf76137 | ||
|
|
3f5430c3ce | ||
|
|
0d7b70459d | ||
|
|
f3ec686f8c | ||
|
|
bd5166bd11 | ||
|
|
d3fe8a02ec | ||
|
|
f5b1d67ee5 | ||
|
|
90179149c7 | ||
|
|
364be835fe | ||
|
|
4a4d121275 | ||
|
|
480effa5fc | ||
|
|
226fd308dd | ||
|
|
2ced7b790c | ||
|
|
d186f94206 | ||
|
|
83d2f97d82 | ||
|
|
37085b40c1 | ||
|
|
325d58b694 | ||
|
|
0fafd348e7 | ||
|
|
13f68f1bd8 | ||
|
|
c22003a3da | ||
|
|
a690c665fb | ||
|
|
59ab4fe956 | ||
|
|
e69ab1499f | ||
|
|
3258a25f76 | ||
|
|
0e7d51f18f | ||
|
|
fd341b627e | ||
|
|
1350b4df73 | ||
|
|
8cf820ef49 | ||
|
|
210b7d273b | ||
|
|
be49f4c39c | ||
|
|
9b3c20fc2a | ||
|
|
de061b81aa | ||
|
|
6e439def02 | ||
|
|
74b081e93e | ||
|
|
9d148ca241 | ||
|
|
5be831ac44 | ||
|
|
f0e26e0d8a | ||
|
|
9ab721c97f | ||
|
|
02d4e6b2fd | ||
|
|
507387502c | ||
|
|
97a08df2a0 | ||
|
|
a128c5a1b8 | ||
|
|
6e57d1f601 | ||
|
|
9bc450e87c | ||
|
|
cb438b2b28 | ||
|
|
becb211f8c | ||
|
|
14ad2f60c5 | ||
|
|
3ca67a53c1 | ||
|
|
cf003c8623 | ||
|
|
6bc0cc6f52 | ||
|
|
cef6e201c7 | ||
|
|
2d23157e40 | ||
|
|
6a76aac80f | ||
|
|
c13f250b87 | ||
|
|
9a96ee8dfe | ||
|
|
5738418838 | ||
|
|
ba1c0340ea | ||
|
|
287e991486 | ||
|
|
20971d523f | ||
|
|
01405cbf8a | ||
|
|
6ddafc95ba | ||
|
|
e662230df7 | ||
|
|
9d6176f55b | ||
|
|
60784a2ed2 | ||
|
|
7ecfe3d543 | ||
|
|
ebfc188240 | ||
|
|
ae789ce024 | ||
|
|
344545430b | ||
|
|
6c7a9c9442 | ||
|
|
6a71aa47e3 | ||
|
|
30acdc9934 | ||
|
|
51c7fb92b1 | ||
|
|
1106509116 | ||
|
|
bdd766c68a | ||
|
|
0a086c3fa5 | ||
|
|
59d108a576 | ||
|
|
e65cb26901 | ||
|
|
4f35313d06 | ||
|
|
a6afb4e97a | ||
|
|
cbae1ce77b | ||
|
|
5e6b32b493 | ||
|
|
3395424d82 | ||
|
|
3dc7c5c649 | ||
|
|
67dad461d9 |
@@ -0,0 +1,16 @@
|
||||
.git
|
||||
.idea
|
||||
.agents
|
||||
.codex
|
||||
node_modules
|
||||
vendor
|
||||
public/build
|
||||
storage/logs
|
||||
storage/framework/cache
|
||||
storage/framework/sessions
|
||||
storage/framework/views
|
||||
tests
|
||||
Doc
|
||||
docs
|
||||
*.zip
|
||||
.phpunit.result.cache
|
||||
+40
-6
@@ -4,6 +4,11 @@ APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8000
|
||||
|
||||
# Separate from publication. Bind only an explicitly authorised published rule version.
|
||||
INVESTMOZ_IVA_CONSUMER_ENABLED=false
|
||||
INVESTMOZ_IVA_CONSUMER_RULE_ID=
|
||||
INVESTMOZ_IVA_CONSUMER_RULE_VERSION=0
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
@@ -20,12 +25,14 @@ LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
# DB_DATABASE=laravel
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
# Full application schema requires PostgreSQL with PostGIS; SQLite is unsupported.
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=investemoz
|
||||
# Configure a least-privilege application role; provision PostGIS before migrations.
|
||||
DB_USERNAME=investmoz_app
|
||||
DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
@@ -36,6 +43,16 @@ SESSION_DOMAIN=null
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
DB_QUEUE_RETRY_AFTER=210
|
||||
MALWARE_SCANNER=clamd
|
||||
CLAMD_HOST=127.0.0.1
|
||||
CLAMD_PORT=3310
|
||||
CLAMD_TIMEOUT_SECONDS=120
|
||||
CLAMD_MAX_SIGNATURE_AGE_HOURS=48
|
||||
CLAMD_MAX_FILE_BYTES=20971520
|
||||
CLAMD_MAX_STREAM_BYTES=26214400
|
||||
CLAMD_MAX_DECOMPRESSED_BYTES=104857600
|
||||
CLAMD_MAX_ARCHIVE_ENTRIES=1000
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
@@ -66,6 +83,15 @@ VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# Comma-separated emails of verified accounts authorised to manage the portal.
|
||||
PORTAL_MANAGER_EMAILS=
|
||||
# Explicit, separate application workflow designations. Empty means no access.
|
||||
INVESTMOZ_MNE_TRIAGERS=
|
||||
INVESTMOZ_MNE_REVIEWERS=
|
||||
INVESTMOZ_MNE_APPROVERS_L1=
|
||||
INVESTMOZ_MNE_APPROVERS_L2=
|
||||
INVESTMOZ_MNE_PUBLISHERS=
|
||||
INVESTMOZ_CONSENT_POLICY_VERSION=2026-09-30
|
||||
INVESTMOZ_SHARING_POLICY_VERSION=2026-09-30
|
||||
INVESTMOZ_SLA_ESCALATION_ENABLED=false
|
||||
PORTAL_NOTIFICATIONS_ENABLED=false
|
||||
PORTAL_NOTIFICATION_RECIPIENTS=
|
||||
PORTAL_SUPPORT_EMAIL=
|
||||
@@ -85,3 +111,11 @@ SUITECRM_BASE_URL=
|
||||
SUITECRM_CLIENT_ID=
|
||||
SUITECRM_CLIENT_SECRET=
|
||||
SUITECRM_CACHE_STORE=database
|
||||
|
||||
# Tax-rule governance roles are empty by default and require named verified managers with MFA.
|
||||
INVESTMOZ_TAX_RULE_EDITORS=
|
||||
INVESTMOZ_TAX_RULE_REVIEWERS=
|
||||
INVESTMOZ_TAX_RULE_APPROVERS=
|
||||
INVESTMOZ_TAX_RULE_PUBLISHERS=
|
||||
|
||||
INVESTMOZ_ZONE_TERRITORY_REVIEWERS=
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
# Preserve the exact bytes pinned by EconomicReferenceCatalog::MANIFEST_SHA256.
|
||||
database/data/economic-references-lot-01.json text eol=crlf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
|
||||
Binary file not shown.
@@ -20,7 +20,7 @@ jobs:
|
||||
POSTGRES_USER: investmoz_test
|
||||
POSTGRES_PASSWORD: test-only-password
|
||||
ports:
|
||||
- 5432:5432
|
||||
- 55441:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U investmoz_test -d investmoz_test"
|
||||
--health-interval 10s --health-timeout 5s --health-retries 5
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
APP_ENV: testing
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: 127.0.0.1
|
||||
DB_PORT: 5432
|
||||
DB_PORT: 55441
|
||||
DB_DATABASE: investmoz_test
|
||||
DB_USERNAME: investmoz_test
|
||||
DB_PASSWORD: test-only-password
|
||||
@@ -63,8 +63,18 @@ jobs:
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Prevent accidental diagnostic exposure (C132)
|
||||
run: |
|
||||
php tools/portal-diagnostics-deployment-guard.php --self-test
|
||||
php tools/portal-diagnostics-deployment-guard.php
|
||||
|
||||
- name: Run CI Checks
|
||||
run: composer ci:check
|
||||
|
||||
- name: Test frontend API contracts
|
||||
run: node --test tests/frontend/portal-api.test.mjs
|
||||
- name: Validate PostgreSQL/PostGIS schema lifecycle
|
||||
env:
|
||||
INVESTMOZ_SCHEMA_TESTS: '1'
|
||||
run: php vendor/bin/phpunit --configuration=phpunit.investmoz.xml --do-not-cache-result
|
||||
|
||||
- name: Test all frontend contracts and calculators
|
||||
run: node --test tests/frontend/*.test.mjs
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/.phpunit.cache
|
||||
/bootstrap/ssr
|
||||
/node_modules
|
||||
**/node_modules/
|
||||
**/vendor/
|
||||
/outputs/
|
||||
/public/build
|
||||
/public/build-qa-opportunities
|
||||
/public/fonts-manifest.dev.json
|
||||
/public/hot
|
||||
/public/storage
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Instruções permanentes do projeto InvesteMoz
|
||||
|
||||
## Percurso adaptativo — decisão expressa da Tempo, 23/10/2026
|
||||
|
||||
Carteira/projectos âncora e percurso adaptativo têm diagnósticos distintos, mas fazem parte da mesma experiência e responsabilidade de correcção. Separar o diagnóstico não autoriza adiamento, desligamento do percurso ou perda de contexto entre corredor/localização, oportunidades, associações documentadas a âncoras/clusters, dossier e simulador. A reposição prioritária só se considera demonstrada com causa delimitada e disponibilidade recuperada, reutilizando o inventário e os recibos existentes; melhorias noutro ponto não a comprovam. Consultar os registos de reposição antes de voltar a declarar causa desconhecida ou registos desaparecidos.
|
||||
|
||||
Apresentar apenas o necessário ao investimento em preparação, com entrada por ideia, oportunidade, sector ou localização, reutilizando informação válida e autorizada da oportunidade, perfil, organização, dossier e fontes anteriores. As etapas são adaptáveis, não seis formulários obrigatórios. Não sobrescrever edições deliberadas; explicar divergências e impactos antes da confirmação. Conservar escolhas ao avançar, regressar, actualizar e autenticar-se. Seleccionar opções únicas e pedir apenas dados em falta ou desactualizados.
|
||||
|
||||
Ocultar campos e acções sem aplicação, conservando explicação breve e histórico; desconhecido não significa não aplicável. Na selecção múltipla, partilhar dados comuns sem perder os requisitos específicos de cada oportunidade. Consulta pública livre; autenticação no momento de manifestar interesse ou iniciar operação privada. Relatórios e exportações devem reflectir o contexto e as etapas efectivamente aplicáveis. O utilizador deve perceber onde está, o que falta e a próxima acção. Aperfeiçoar os componentes existentes por incrementos e registar cobertura na matriz existente; esta instrução não declara concluída a sua implementação transversal.
|
||||
|
||||
## Regras permanentes do InvesteMoz — decisão da Tempo, 12/10/2026
|
||||
|
||||
1. O portal InvesteMoz é propriedade da Tempo, Lda.
|
||||
2. As regras de funcionamento são ditadas pela Tempo, Lda.
|
||||
3. Os papéis dos seus funcionários são determinados e regidos pela Tempo, Lda.
|
||||
4. As funcionalidades do portal são determinadas pela Tempo, Lda.
|
||||
5. Este portal é de âmbito nacional e serve tanto a pessoas nacionais como estrangeiras e exigirá vários intervenientes para a sua operação (manutenção, cadastro, viabilização dos negócios, etc.). Se as regras não são ditadas por quem opera e, ao mesmo tempo, é proprietário, a sua viabilidade é nula.
|
||||
6. Os constrangimentos criados sem a autorização da Tempo e que impedem o desenvolvimento normal devem ser removidos ou alterados.
|
||||
7. Antes de iniciar a execução de uma nova funcionalidade, verificar quais são os constrangimentos que podem criar atrasos e círculos viciosos de correcção de erros que poderiam ter sido evitados.
|
||||
8. As instruções anteriores ditadas pela Tempo mantêm-se.
|
||||
|
||||
Registar a origem das restrições: decisão expressa da Tempo, requisito anterior aprovado ou escolha de implementação. Uma escolha de implementação não constitui uma nova exigência organizacional. As autorizações e limites específicos de cada tarefa mantêm-se, incluindo a separação entre preparação em QA e alteração no ativo.
|
||||
|
||||
### Aplicação na execução — referência permanente confirmada em 12/10/2026
|
||||
|
||||
A Tempo, Lda define o produto, as regras operacionais e as funções dos seus funcionários. As instruções anteriores mantêm-se. Esta formulação consolida as oito regras acima e orienta a leitura das especificações e da matriz de auditoria existente.
|
||||
|
||||
- Cada restrição deve ter origem identificável: decisão expressa da Tempo, referência e versão de uma instrução anterior aprovada ou escolha técnica. Escolhas técnicas não se tornam regras de negócio sem autorização da Tempo.
|
||||
- Constrangimentos introduzidos sem autorização devem ser identificados e removidos ou ajustados, preservando dados, relações, histórico e instruções aprovadas.
|
||||
- Antes de implementar, verificar as dependências técnicas e operacionais que possam causar bloqueios previsíveis. Distinguir dependências reais de opções de implementação e apresentar antecipadamente as decisões concretas necessárias.
|
||||
- O portal deve contemplar investidores nacionais e estrangeiros e os diferentes intervenientes da operação, incluindo manutenção, cadastro e viabilização de negócios. O âmbito do portal é nacional.
|
||||
|
||||
Referenciar estas orientações nos requisitos correspondentes da matriz existente, sem criar outra matriz ou backlog. A referência documental não encerra um requisito funcional nem substitui a demonstração do seu critério de aceitação.
|
||||
|
||||
## Aceitação das fontes oficiais — decisão do responsável, 08/10/2026
|
||||
|
||||
- Dados e documentos com origem oficial/governamental identificada (incluindo BR, decretos e leis) são aceites pelo responsável como fontes fidedignas e válidas. Não exigir segunda ou terceira validação da autenticidade/proveniência oficial, nem certificação por outra fonte.
|
||||
- Registar e recuperar origem, exemplar, versão e citação é rastreabilidade; a ausência desses campos num cadastro não revoga a aceitação nem torna o dado inválido. Preservar as validações e decisões já documentadas.
|
||||
- Esta decisão tem precedência sobre pendências genéricas de revalidação de proveniência oficial em relatórios históricos. Não estender a classificação oficial a documentos de promotores ou de programação sem origem oficial identificada.
|
||||
- A aceitação da fonte não substitui a identificação do âmbito, período aplicável, alterações/revogações expressas no próprio acervo oficial ou dos parâmetros necessários a uma regra/cálculo. Conferir esses elementos no documento oficial é aplicação da fonte aceite, não uma segunda validação da sua credibilidade. Não ativar cálculos por importação/publicação.
|
||||
|
||||
- O responsável define o acervo e a finalidade. Inventariar todos os ficheiros e anexos acessíveis antes de seleccionar documentos para consulta; não omitir por não serem usados por uma funcionalidade.
|
||||
- Manter três estados independentes: disponibilidade para consulta, situação jurídica/documental e utilização numa regra ou cálculo. Importação/publicação nunca activa um cálculo fiscal.
|
||||
- Não usar associação a módulo, requisito ou artigo como pré-condição para cadastrar ou consultar um documento autorizado. Relações sem citação verificada ficam pendentes e visivelmente identificadas.
|
||||
- Respeitar a autorização de divulgação registada e o seu âmbito. Só bloquear por impedimento concreto (por exemplo malware, dados pessoais/confidenciais ou restrição efectiva de divulgação), registado por ficheiro. Não atribuir decisões a gestores inexistentes nem contornar autenticação.
|
||||
- Importações em lote devem ter pré-visualização, cálculo de hash, detecção de duplicados por bytes e conflitos de identidade/versão, escrita privada, análise antimalware e execução idempotente. Nunca publicar automaticamente um lote.
|
||||
- Preservar o ficheiro original, a proveniência, o identificador exacto, a edição/data, o hash e o histórico. Não marcar autenticidade, vigência, direitos ou scan como confirmados sem evidência.
|
||||
- Para fontes fiscais, inventariar também as fontes não analisadas e os diplomas/artigos sem correspondência documental. Parâmetros ausentes ou ambiguidades permanecem indeterminados; não usar zero nem a taxa mais recente como substituto.
|
||||
- Antes de aplicar mudanças em dados activos, confirmar backup/restauro e a acção autenticada que continua necessária. Testes e migrações devem usar exclusivamente uma base QA isolada com guarda contra a base activa.
|
||||
|
||||
|
||||
## Execução das fontes e decisões — orientação permanente da Tempo, 22/10/2026
|
||||
|
||||
A legislação, os formulários e os procedimentos oficiais disponibilizados são fontes válidas para implementar os requisitos. As decisões e procedimentos da Tempo orientam a sua utilização no portal. Traduzir essas fontes em campos, regras, documentos, cálculos e percursos executáveis com rastreabilidade, sem exigir nova validação jurídica genérica ou criar aprovadores, restrições ou condições por interpretação do Codex.
|
||||
|
||||
Uma divergência concreta deve identificar os trechos, períodos e comportamento afectados; prosseguir com os restantes requisitos. Uma decisão de implementação em falta deve ser apresentada especificamente à Tempo, sem a substituir por regra inventada. Rever e remover bloqueios anteriores sem fundamento numa fonte aplicável ou decisão expressa da Tempo, preservando dados e histórico. A rastreabilidade explica a execução e não constitui uma autoridade adicional. Mantêm-se os limites autorizados de QA e das alterações no activo.
|
||||
|
||||
## Preservação do concluído — pedido expresso da Tempo, 23/10/2026
|
||||
|
||||
Antes de avançar com novas rondas de testes/validações, inventariar na matriz e no manual existentes as funcionalidades concluídas e as funcionalidades desaparecidas, e repor as perdas confirmadas. Preservar as aprovações, dados, originais, relações, versões, histórico e acessos já concluídos. Não transformar uma entrega anterior em funcionalidade nova nem exigir novamente a decisão organizacional já registada.
|
||||
|
||||
Comparar código, schema, dados, configuração e permissões entre o último estado demonstrado e o destino. Distinguir eliminação, perda de visibilidade, diferença de ambiente, pendência real e estado documental desactualizado; uma página vazia ou a falta de nova prova não apagam a aceitação anterior. Reutilizar as provas aceites e conferir apenas o delta da reposição. Cada entrega seguinte deve explicar qualquer redução do âmbito previamente disponível, em vez de a aceitar como consequência normal da implementação.
|
||||
|
||||
Transporte privado não equivale à revogação de uma divulgação anterior aprovada. Reconciliar a decisão anterior de forma delimitada e auditada, sem publicar automaticamente conteúdos novos, reviver exemplares arquivados ou sobrescrever edições posteriores. Mantêm-se backup/restauro, autenticação e os limites concretos de cada tarefa.
|
||||
|
||||
Escolha técnica de verificação: comparar dados protegidos por segurança por linha (RLS) com identidades e contexto de acesso equivalentes. Uma leitura da aplicação sem actor pode ocultar registos que o utilizador do backup consegue ler; confirmar essa diferença antes de declarar perda ou propor reposição. Esta orientação não altera permissões do portal nem autoriza contornar RLS numa operação da equipa.
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
+342
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -1,4 +1,16 @@
|
||||
# Portal do Investidor — esqueleto Laravel
|
||||
# Portal do Investidor — InvesteMoz
|
||||
|
||||
## Executar esta plataforma no Windows
|
||||
|
||||
Consulte [Execução local](deploy/local/README.md). Com Docker Desktop iniciado,
|
||||
execute `./deploy/local/start.ps1`. Depois da preparação, `composer run dev`
|
||||
inicia a plataforma em http://127.0.0.1:8000.
|
||||
|
||||
O ambiente local usa uma base PostgreSQL/PostGIS de demonstração isolada.
|
||||
Não execute os exemplos históricos de criação de um projecto novo sobre esta
|
||||
instalação ou sobre dados existentes.
|
||||
|
||||
## Referência histórica do pacote inicial
|
||||
|
||||
Estes ficheiros correspondem ao domínio de **sectores**, **oportunidades** e
|
||||
**manifestações de interesse** do Portal do Investidor. Foram desenhados
|
||||
|
||||
+1112
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Mne\CompatibilityBackfill;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class BackfillMneCompatibility extends Command
|
||||
{
|
||||
protected $signature = 'mne:backfill-compatibility {--batch=100} {--dry-run : Execute validation and roll back all changes}';
|
||||
|
||||
protected $description = 'Reconcile compatibility identifiers without copying canonical business records';
|
||||
|
||||
public function handle(CompatibilityBackfill $backfill): int
|
||||
{
|
||||
$batch = filter_var($this->option('batch'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 1000]]);
|
||||
if ($batch === false) {
|
||||
$this->error('Batch must be between 1 and 1000');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($this->option('dry-run')) {
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$counts = $backfill->run($batch);
|
||||
} finally {
|
||||
DB::rollBack();
|
||||
}
|
||||
$this->line(json_encode(['dry_run' => true, 'rolled_back' => true, 'counts' => $counts], JSON_THROW_ON_ERROR));
|
||||
} else {
|
||||
$this->line(json_encode($backfill->run($batch), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Sleep;
|
||||
use Throwable;
|
||||
|
||||
class CheckDevelopmentEnvironment extends Command
|
||||
{
|
||||
protected $signature = 'portal:dev-check {--wait=0 : Segundos para aguardar uma falha transitória do PostgreSQL}';
|
||||
|
||||
protected $description = 'Verifica o arranque local sem alterar dados ou mostrar credenciais';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$wait = filter_var($this->option('wait'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 3600]]);
|
||||
if ($wait === false) {
|
||||
$this->error('--wait deve ser um inteiro entre 0 e 3600 segundos.');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
|
||||
$checks = [
|
||||
'PHP >= 8.4.1' => version_compare(PHP_VERSION, '8.4.1', '>='),
|
||||
'Driver PostgreSQL activo' => extension_loaded('pdo_pgsql'),
|
||||
'APP_KEY definida' => filled(config('app.key')),
|
||||
'Base PostgreSQL configurada' => config('database.default') === 'pgsql',
|
||||
'Cache de vistas gravável' => is_dir(config('view.compiled') ?: '') && is_writable(config('view.compiled') ?: ''),
|
||||
'Sessões e logs graváveis' => is_writable(storage_path('framework/sessions')) && is_writable(storage_path('logs')),
|
||||
'Dependências frontend instaladas' => is_file(base_path('node_modules/.bin/vp')) || is_file(base_path('node_modules/.bin/vp.cmd')),
|
||||
];
|
||||
|
||||
if ($checks['Driver PostgreSQL activo'] && $checks['Base PostgreSQL configurada']) {
|
||||
$checks['Ligação à base de dados'] = $this->checkConnection($wait);
|
||||
}
|
||||
|
||||
if ($checks['Ligação à base de dados'] ?? false) {
|
||||
try {
|
||||
$checks['Extensão PostGIS instalada'] = (bool) DB::selectOne("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'postgis') AS installed")->installed;
|
||||
$checks['Tabelas principais instaladas'] = collect(['migrations', 'users', 'sectors', 'opportunities', 'sessions', 'cache', 'jobs'])
|
||||
->every(fn (string $table): bool => Schema::hasTable($table));
|
||||
} catch (Throwable) {
|
||||
$checks['Esquema da base de dados verificável'] = false;
|
||||
$this->warn('A ligação foi estabelecida, mas a verificação do esquema falhou. Verifique as permissões e a preparação QA.');
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($checks as $label => $passed) {
|
||||
$this->line(($passed ? '[OK] ' : '[FALHA] ').$label);
|
||||
}
|
||||
|
||||
if (in_array(false, $checks, true)) {
|
||||
$this->error('Arranque interrompido. Consulte deploy/local/README.md e corrija os itens acima.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function checkConnection(int $wait): bool
|
||||
{
|
||||
$deadline = microtime(true) + $wait;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
DB::selectOne('SELECT 1');
|
||||
|
||||
return true;
|
||||
} catch (Throwable $error) {
|
||||
[$reason, $retryable] = $this->connectionFailure($error);
|
||||
$remaining = $deadline - microtime(true);
|
||||
if (! $retryable || $remaining <= 0) {
|
||||
$this->warn($reason);
|
||||
if ($retryable) {
|
||||
$this->warn('Inicie Docker Desktop e execute ./deploy/local/start.ps1. Consulte os registos com docker compose --env-file .env -f deploy/local/compose.yaml logs --tail 40 postgres.');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->line('[AGUARDA] '.$reason.' Nova tentativa; prazo restante: '.(int) ceil($remaining).' s.');
|
||||
DB::purge();
|
||||
Sleep::for(min(5, $remaining))->seconds();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{string, bool} */
|
||||
private function connectionFailure(Throwable $error): array
|
||||
{
|
||||
// Inspect the chain internally; never print exception messages, DSNs or credentials.
|
||||
$messages = '';
|
||||
do {
|
||||
$messages .= ' '.strtolower($error->getMessage()).' '.strtolower((string) $error->getCode());
|
||||
$error = $error->getPrevious();
|
||||
} while ($error !== null);
|
||||
|
||||
if (str_contains($messages, '28p01') || str_contains($messages, '28000')
|
||||
|| str_contains($messages, 'password authentication failed') || str_contains($messages, 'no pg_hba.conf entry')) {
|
||||
return ['Autenticação PostgreSQL recusada. Verifique as credenciais e as regras de acesso; aguardar não corrige este erro.', false];
|
||||
}
|
||||
if (str_contains($messages, '3d000') || preg_match('/database .* does not exist/', $messages)) {
|
||||
return ['A base PostgreSQL configurada não existe. Verifique a configuração e a preparação QA.', false];
|
||||
}
|
||||
if (str_contains($messages, '57p03') || str_contains($messages, 'recovery mode')
|
||||
|| str_contains($messages, 'not yet accepting connections') || str_contains($messages, 'database system is starting up')) {
|
||||
return ['PostgreSQL em arranque ou recuperação; aguarde a conclusão sem remover o volume.', true];
|
||||
}
|
||||
if (preg_match('/\b08[0-9a-z]{3}\b/', $messages) || str_contains($messages, 'connection refused')
|
||||
|| str_contains($messages, '57p01') || str_contains($messages, '57p02')) {
|
||||
return ['PostgreSQL indisponível. Verifique se Docker Desktop e o contentor local estão iniciados.', true];
|
||||
}
|
||||
|
||||
return ['Falha PostgreSQL não transitória. Verifique a configuração e os registos do servidor.', false];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
class CheckDocumentLibrarySchema extends Command
|
||||
{
|
||||
protected $signature = 'portal:check-document-library-schema';
|
||||
|
||||
protected $description = 'Confere o esquema e as migrações da biblioteca documental antes da activação';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$reference = (string) Str::uuid();
|
||||
|
||||
try {
|
||||
$required = [
|
||||
'portal_resources' => [
|
||||
'id', 'document_id', 'title', 'category', 'topic', 'legislation', 'essential', 'published',
|
||||
'source_reference', 'rights_status', 'description', 'document_type', 'document_number',
|
||||
'document_date', 'source_url', 'language', 'version_label', 'document_group_id',
|
||||
'applicable_from', 'applicable_until',
|
||||
'territorial_scope', 'territory_id', 'source_confirmation', 'legal_status', 'last_reviewed_at',
|
||||
'document_identity_key', 'duplicate_of_resource_id', 'metadata_version', 'created_by',
|
||||
'last_reviewed_by', 'independent_review',
|
||||
],
|
||||
'documents' => [
|
||||
'id', 'status', 'data_classification', 'sha256', 'deleted_at', 'file_name', 'media_type',
|
||||
'storage_key', 'size_bytes', 'version_no', 'issuer', 'issued_on', 'valid_until', 'uploaded_by',
|
||||
'rights_status', 'original_source', 'malware_scan_status', 'malware_scanned_sha256',
|
||||
'malware_engine', 'malware_engine_version', 'malware_signatures_at', 'malware_scanned_at',
|
||||
'malware_failure_reason', 'malware_scan_attempts',
|
||||
],
|
||||
'portal_resource_links' => ['id', 'resource_id', 'target_type', 'target_key', 'relationship', 'applicability'],
|
||||
'portal_resource_revisions' => ['id', 'resource_id', 'revision_no', 'change_type', 'snapshot'],
|
||||
'document_malware_scans' => ['id', 'document_id', 'file_sha256', 'result', 'engine', 'engine_version', 'started_at'],
|
||||
];
|
||||
|
||||
$missing = [];
|
||||
foreach ($required as $table => $columns) {
|
||||
if (! Schema::hasTable($table)) {
|
||||
$missing[] = "tabela {$table}";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (array_diff($columns, Schema::getColumnListing($table)) as $column) {
|
||||
$missing[] = "{$table}.{$column}";
|
||||
}
|
||||
}
|
||||
|
||||
$requiredMigrations = [
|
||||
'2026_10_05_120000_extend_portal_document_catalogue',
|
||||
'2026_10_05_130000_add_document_malware_scanning',
|
||||
];
|
||||
if (! Schema::hasTable('migrations')) {
|
||||
$missing[] = 'repositório de migrações';
|
||||
} else {
|
||||
$ran = DB::table('migrations')->whereIn('migration', $requiredMigrations)->pluck('migration')->all();
|
||||
foreach (array_diff($requiredMigrations, $ran) as $migration) {
|
||||
$missing[] = "migração {$migration}";
|
||||
}
|
||||
}
|
||||
|
||||
if ($missing !== []) {
|
||||
$this->error('Esquema da biblioteca incompatível. Não activar o catálogo; faltam elementos de esquema ou migrações.');
|
||||
foreach ($missing as $item) {
|
||||
$this->line('- '.$item);
|
||||
}
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
foreach ([
|
||||
['portal_resources', 'rights_status', 'unconfirmed'],
|
||||
['documents', 'rights_status', 'unconfirmed'],
|
||||
['documents', 'malware_scan_status', 'pending'],
|
||||
] as [$table, $column, $expectedDefault]) {
|
||||
$default = DB::selectOne(
|
||||
'select column_default from information_schema.columns where table_schema = current_schema() and table_name = ? and column_name = ?',
|
||||
[$table, $column],
|
||||
)?->column_default;
|
||||
if (! is_string($default) || ! str_contains(strtolower($default), $expectedDefault)) {
|
||||
$this->error("Valor predefinido inseguro ou ausente em {$table}.{$column}; preservar os registos e rever a migração.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
$invalidCleanRecords = DB::table('documents')->where('malware_scan_status', 'clean')
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('malware_scanned_sha256')->orWhereColumn('malware_scanned_sha256', '!=', 'sha256');
|
||||
})->count();
|
||||
if ($invalidCleanRecords > 0) {
|
||||
$this->error('Existem documentos marcados como limpos sem correspondência do hash analisado.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Esquema da biblioteca compatível; verificações de direitos e análise antimalware disponíveis.');
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('Document library schema compatibility check failed.', ['reference' => $reference, 'exception' => $exception]);
|
||||
$this->error('Não foi possível verificar a compatibilidade do esquema. Consulte o registo interno. Referência: '.$reference);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Malware\MalwareScanner;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CheckMalwareScanner extends Command
|
||||
{
|
||||
protected $signature = 'malware:health';
|
||||
|
||||
protected $description = 'Check ClamAV availability and signature freshness';
|
||||
|
||||
public function handle(MalwareScanner $scanner): int
|
||||
{
|
||||
$health = $scanner->health();
|
||||
$this->line('Scanner: '.($health['engine']));
|
||||
$this->line('Available: '.($health['available'] ? 'yes' : 'no'));
|
||||
$this->line('Engine version: '.($health['engine_version'] ?? 'unavailable'));
|
||||
$this->line('Signatures UTC: '.($health['signatures_at']?->format(DATE_ATOM) ?? 'unavailable'));
|
||||
$this->line('Signature age hours: '.($health['signature_age_hours'] === null ? 'unavailable' : number_format($health['signature_age_hours'], 2)));
|
||||
if (! $health['available']) {
|
||||
$this->error('Reason: '.($health['reason_code'] ?? 'scanner_unavailable'));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class EscalateOverdueInvestmentTasks extends Command
|
||||
{
|
||||
protected $signature = 'investmoz:escalate-overdue-tasks';
|
||||
|
||||
protected $description = 'Eleva a prioridade de tarefas operacionais vencidas, quando activado.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! config('existing-business.sla_escalation_enabled')) {
|
||||
$this->components->info('Escalada SLA desactivada.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$count = DB::table('tasks')->whereIn('task_type', ['triage', 'interest_follow_up'])
|
||||
->whereIn('status', ['open', 'in_progress'])->whereNotNull('due_at')->where('due_at', '<', now())
|
||||
->where('priority', '>', 1)->update(['priority' => 1, 'outcome' => 'sla_overdue', 'updated_at' => now()]);
|
||||
|
||||
$this->components->info("Tarefas elevadas para prioridade máxima: {$count}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ImportDocumentBatch extends Command
|
||||
{
|
||||
protected $signature = 'documents:import-batch {manifest : JSON manifest inside storage/app/private/document-import-staging} {--apply : Register private drafts and queue malware scans} {--actor= : Existing user ID responsible for an applied import} {--pilot-agronegocio : Restrict to the approved two-original pilot} {--sectors-lot20 : Restrict to the named 20-resource sector batch} {--sectors-trio : Restrict to the three named sector articles and reuse the existing image} {--backup-proof= : Fresh verified destination backup receipt for active pilot apply}';
|
||||
|
||||
protected $description = 'Preview or idempotently import a document batch into the existing private library';
|
||||
|
||||
private const MAX_BYTES = 10485760;
|
||||
|
||||
private const PILOT_MANIFEST_SHA256 = '0b720b0a6f7539de1d3eca9e32f89a6b9038848dc0b97ad21ace605154d9ddd8';
|
||||
|
||||
private const PILOT_HASHES = [
|
||||
'legacy-tempo-post-171985-20260420' => '0929184a6d4645b9046512675394ad4f60575273f6dab0955f71cc7474a6a4e2',
|
||||
'legacy-tempo-agronegocio-image-5e3840705cf98226' => '5e3840705cf98226d1f189cf665aaa98cb05da96c645159a1332a1d429d642eb',
|
||||
];
|
||||
|
||||
private const SECTORS_MANIFEST_SHA256 = 'ade9a00c031534f4341e8f6bd5e425b502ee03efc9fcfaec4f463a10fb28201b';
|
||||
|
||||
private const SECTORS_HASHES = [
|
||||
'legacy-tempo-post-171981-20260430' => '26a70f16cd511c579f08633655ff312db05122f81e44852dd4b163fa6eef85f1',
|
||||
'legacy-tempo-post-172679-20260430' => '2003755b7dac53b43f4608e707321db6d2f29878926a0e74f4cf27627b70a9cb',
|
||||
'legacy-tempo-post-172674-20260430' => '3ae780f082a8fa7c767635cce98563f5bc027c9656c9f41f8788843a17fa1f54',
|
||||
'legacy-tempo-post-171990-20260616' => '8cab862be245382dd4c1fd5fcb423c47aed7be3a22e0a7f8d5521510cc36bc72',
|
||||
'legacy-tempo-post-172263-20260414' => '538495cd5b31047ac1c5993fdaf298ec1303330368248b251c87d201d31ff211',
|
||||
'legacy-tempo-post-172256-20260414' => 'dcc29cbd36cf57e0a5c88dd312c84ee03c32a3d18ae9216d3715dc7b5a6960bf',
|
||||
'legacy-tempo-post-172261-20260414' => '88177fccd78d8516dba670c6f71272af062cc981d52d97adefe5bf77c613b16a',
|
||||
'legacy-tempo-post-172258-20260417' => '48314a576dea2f2dadf47d8f1eadd7ab58030606b02eebe17aa9bd6badffb7e9',
|
||||
'legacy-tempo-post-172253-20260414' => 'e90e39e49ba65bbbcbbc303c6eca456e445238daf9acc935ab03690819281678',
|
||||
'legacy-tempo-post-172240-20260414' => '8c5e91c937032680883793a12844f1128f7ac99e97c63b68c23d1396e009ba9d',
|
||||
'legacy-tempo-post-172237-20260417' => 'a62a43e31da32795a7286f4c152a93d5dc829233e72023b00ea61fd8e2fcb58c',
|
||||
'legacy-tempo-post-172233-20260417' => '345dfd72d8ebf459eae8eb025d699afddbae751df55ffa8d053568cd08096c97',
|
||||
'legacy-tempo-post-172230-20260414' => '4f4570f7bbd5599cb925ed996c1734a23582362fe485736931a53b9266565d4e',
|
||||
'legacy-tempo-post-172223-20260414' => '514a9d88c097d234038fa6341f5a5b01e8938cb6a9535a807145c84c37010a4d',
|
||||
'legacy-tempo-post-172219-20260414' => '0d468ae0de9a76d2b140bc8cb5c555f29626fb6422287c917762bec3e615a70c',
|
||||
'legacy-tempo-post-171992-20260430' => '05eb492dd6cfca6a7f305b633658a16723c315168f4b2c75103f22d1fac84032',
|
||||
'legacy-tempo-image-0a2c50d91dad3a60' => '0a2c50d91dad3a6007544634540e0f61b11d3506682a5a19066bae03cdcba490',
|
||||
'legacy-tempo-image-28f41f12e10ab345' => '28f41f12e10ab34545d8e432f6e8c74f86960e17e0c4a9956f514bcde4881eaa',
|
||||
'legacy-tempo-image-fe9a2e382e385b35' => 'fe9a2e382e385b35f6e61ae320e199a979678677fd327fb7d4b07d8ce0111c11',
|
||||
'legacy-tempo-image-8e58544e49e719f9' => '8e58544e49e719f9c865c91e07ef1030155a9e3d59260e6dc93e9c9e25674e39',
|
||||
];
|
||||
|
||||
private const TRIO_MANIFEST_SHA256 = '9210ad7e1bea557349c440fd8295b321a10c2ddd34cc170f3dad18b5517751f8';
|
||||
private const TRIO_REUSED_IMAGE_SHA256 = '28f41f12e10ab34545d8e432f6e8c74f86960e17e0c4a9956f514bcde4881eaa';
|
||||
private const TRIO_HASHES = [
|
||||
'legacy-tempo-post-173209-20260623' => '926f5e4096737c936c9d5002ebdc64be9e63b0c0822023d17ce618111a65c941',
|
||||
'legacy-tempo-post-173218-20260616' => '43612c358c7573475cea24ef4825c5b7c7925306fa5861051220eed3f4e333b5',
|
||||
'legacy-tempo-post-173213-20260616' => '0ba605ae43dec7f685610f828df884bc82cb4da67131fc9783b5cdbe4cc6d9f0',
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$staging = storage_path('app/private/document-import-staging');
|
||||
$manifestPath = realpath($staging.DIRECTORY_SEPARATOR.$this->argument('manifest'));
|
||||
$stagingReal = realpath($staging);
|
||||
if (! $manifestPath || ! $stagingReal || ! str_starts_with(strtolower($manifestPath), strtolower($stagingReal.DIRECTORY_SEPARATOR)) || ! is_file($manifestPath)) {
|
||||
$this->error('Manifest is missing or outside the private document-import staging directory.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'documents' => ['id', 'source_system', 'external_id', 'version_no', 'data_classification', 'file_name', 'media_type', 'storage_key', 'sha256', 'status', 'size_bytes', 'issuer', 'issued_on', 'uploaded_by', 'rights_status', 'original_source', 'malware_scan_status'],
|
||||
'portal_resources' => ['document_id', 'title', 'category', 'topic', 'published', 'source_reference', 'approved_by', 'description', 'document_type', 'document_number', 'document_date', 'source_url', 'language', 'version_label', 'source_confirmation', 'legal_status', 'rights_status', 'document_identity_key', 'metadata_version', 'created_by'],
|
||||
] as $table => $columns) {
|
||||
if (! Schema::hasColumns($table, $columns)) {
|
||||
$this->error('Document-library schema is incomplete; run the documented schema compatibility check before importing.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$manifestBytes = (string) file_get_contents($manifestPath);
|
||||
$manifest = json_decode($manifestBytes, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (Throwable) {
|
||||
$this->error('Manifest is not valid JSON.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
if (! is_array($manifest) || ! isset($manifest['documents']) || ! is_array($manifest['documents']) || count($manifest['documents']) > 100) {
|
||||
$this->error('Manifest must contain at most 100 documents in a documents array.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$apply = (bool) $this->option('apply');
|
||||
$actorId = $this->option('actor');
|
||||
$pilot = (bool) $this->option('pilot-agronegocio');
|
||||
$sectors = (bool) $this->option('sectors-lot20');
|
||||
$trio = (bool) $this->option('sectors-trio');
|
||||
if ((int) $pilot + (int) $sectors + (int) $trio > 1) {
|
||||
$this->error('Select exactly one named batch; agribusiness scope is unchanged.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($pilot || $sectors || $trio) {
|
||||
try {
|
||||
$this->validatePilot($manifestPath, $manifestBytes, $manifest, $stagingReal, $apply, $actorId, $sectors, $trio);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error('Pilot refused before import: '.$exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
if ($apply && ! $pilot && ! $sectors && ! $trio) {
|
||||
// CLI attribution is not an authenticated active installation. Rehearsals stay isolated.
|
||||
$target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
if (! app()->environment('testing') || ! str_ends_with($target->database, '_test') || (int) $target->port !== 55441
|
||||
|| config('database.connections.pgsql.host') !== '127.0.0.1' || config('database.connections.pgsql.url')) {
|
||||
$this->error('CLI apply is restricted to isolated QA. Active imports require the authenticated activation mechanism; --actor is attribution only.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
if ($apply && (! is_numeric($actorId) || ! DB::table('users')->where('id', (int) $actorId)->exists())) {
|
||||
$this->error('Apply requires --actor=<existing-user-id>; no author identity will be fabricated.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($apply && ! User::findOrFail((int) $actorId)->can('manage-portal')) {
|
||||
$this->error('QA import requires an authorized library manager; no permission is granted by naming an account.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$disk = Storage::disk('local');
|
||||
$seenHashes = [];
|
||||
$seenExternalIds = [];
|
||||
$dispatch = [];
|
||||
$failures = 0;
|
||||
|
||||
foreach ($manifest['documents'] as $index => $record) {
|
||||
$label = is_array($record) ? (string) ($record['external_id'] ?? '#'.($index + 1)) : '#'.($index + 1);
|
||||
try {
|
||||
$validated = $this->validateRecord($record, $stagingReal);
|
||||
if (($pilot || $sectors || $trio) && ! hash_equals(($trio ? self::TRIO_HASHES : ($sectors ? self::SECTORS_HASHES : self::PILOT_HASHES))[$validated['external_id']] ?? '', $validated['sha256'])) {
|
||||
throw new RuntimeException('Approved pilot bytes changed after preview validation.');
|
||||
}
|
||||
$hash = $validated['sha256'];
|
||||
if (isset($seenHashes[$hash])) {
|
||||
$this->line($label.' | duplicate bytes within batch; references item '.$seenHashes[$hash]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$seenHashes[$hash] = $label;
|
||||
if (isset($seenExternalIds[$validated['external_id']])) {
|
||||
$this->warn($label.' | external_id already occurs in this batch with different bytes; identity review required');
|
||||
|
||||
continue;
|
||||
}
|
||||
$seenExternalIds[$validated['external_id']] = $hash;
|
||||
|
||||
$existing = DB::table('documents')->where('sha256', $hash)->whereNull('deleted_at')->first();
|
||||
if ($existing) {
|
||||
$this->line($label.' | already imported by SHA-256; document='.$existing->id.'; no duplicate');
|
||||
|
||||
continue;
|
||||
}
|
||||
$externalMatch = DB::table('documents')->where('source_system', 'document-batch-import')
|
||||
->where('external_id', $validated['external_id'])->whereNull('deleted_at')->first();
|
||||
if ($externalMatch) {
|
||||
$this->warn($label.' | external_id already belongs to document '.$externalMatch->id.' with different bytes; version review required');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$identity = $this->identity($validated);
|
||||
$identityMatch = DB::table('portal_resources')->where('document_identity_key', $identity)->first();
|
||||
if ($identityMatch) {
|
||||
$this->warn($label.' | identity matches resource '.$identityMatch->id.' but bytes differ; version review required');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line($label.' | '.($apply ? 'will import as private draft' : 'preview: private draft')." | bytes={$validated['size_bytes']} | sha256={$hash}");
|
||||
if (! $apply) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$documentId = (string) Str::uuid();
|
||||
$storageKey = 'portal-documents/'.$documentId;
|
||||
$source = fopen($validated['source_path'], 'rb');
|
||||
if (! is_resource($source)) {
|
||||
throw new RuntimeException('Source could not be opened.');
|
||||
}
|
||||
try {
|
||||
$stored = $disk->put($storageKey, $source);
|
||||
} finally {
|
||||
fclose($source);
|
||||
}
|
||||
$storedPath = $disk->path($storageKey);
|
||||
$storedHash = $stored && is_file($storedPath) ? hash_file('sha256', $storedPath) : false;
|
||||
if (! $stored || ! is_string($storedHash) || ! hash_equals($hash, $storedHash)) {
|
||||
$disk->delete($storageKey);
|
||||
throw new RuntimeException('Private copy failed the checksum verification.');
|
||||
}
|
||||
|
||||
try {
|
||||
$resourceId = DB::transaction(function () use ($validated, $documentId, $storageKey, $hash, $identity, $actorId): int {
|
||||
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['document-batch:'.$hash]);
|
||||
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['document-batch-identity:'.$identity]);
|
||||
if (DB::table('documents')->where('sha256', $hash)->whereNull('deleted_at')->exists()) {
|
||||
throw new RuntimeException('Matching bytes were registered concurrently; rerun preview.');
|
||||
}
|
||||
if (DB::table('portal_resources')->where('document_identity_key', $identity)->exists()) {
|
||||
throw new RuntimeException('A matching document identity was registered concurrently; version review required.');
|
||||
}
|
||||
$now = now();
|
||||
DB::table('documents')->insert([
|
||||
'id' => $documentId, 'source_system' => 'document-batch-import', 'external_id' => $validated['external_id'],
|
||||
'version_no' => 1, 'data_classification' => 'internal', 'file_name' => $validated['file_name'],
|
||||
'media_type' => $validated['media_type'], 'storage_key' => $storageKey, 'sha256' => $hash,
|
||||
'status' => 'quarantined', 'size_bytes' => $validated['size_bytes'], 'issuer' => $validated['issuer'],
|
||||
'issued_on' => $validated['document_date'], 'uploaded_by' => (int) $actorId, 'rights_status' => 'unconfirmed',
|
||||
'original_source' => $validated['source_url'], 'malware_scan_status' => 'pending',
|
||||
'created_at' => $now, 'updated_at' => $now,
|
||||
]);
|
||||
$resourceId = DB::table('portal_resources')->insertGetId([
|
||||
'document_id' => $documentId, 'title' => $validated['title'], 'category' => $validated['category'],
|
||||
'topic' => $validated['topic'], 'legislation' => $validated['legislation'], 'essential' => false,
|
||||
'published' => false, 'source_reference' => $validated['source_reference'], 'approved_by' => null,
|
||||
'description' => $validated['description'], 'document_type' => $validated['document_type'],
|
||||
'document_number' => $validated['document_number'], 'document_date' => $validated['document_date'],
|
||||
'source_url' => $validated['source_url'], 'language' => $validated['language'],
|
||||
'version_label' => $validated['version_label'], 'territorial_scope' => 'unknown',
|
||||
'source_confirmation' => 'unconfirmed', 'legal_status' => 'unknown', 'rights_status' => 'unconfirmed',
|
||||
'document_identity_key' => $identity, 'metadata_version' => 1, 'created_by' => (int) $actorId,
|
||||
'last_reviewed_by' => null, 'independent_review' => false, 'created_at' => $now, 'updated_at' => $now,
|
||||
]);
|
||||
$resource = DB::table('portal_resources')->where('id', $resourceId)->first();
|
||||
DB::table('portal_resource_revisions')->insert([
|
||||
'resource_id' => $resourceId, 'revision_no' => 1, 'change_type' => 'created',
|
||||
'snapshot' => json_encode(['resource' => $resource, 'associations' => []], JSON_THROW_ON_ERROR),
|
||||
'actor_user_id' => (int) $actorId, 'created_at' => $now,
|
||||
]);
|
||||
DB::table('audit_events')->insert([
|
||||
'id' => (string) Str::uuid(), 'source_system' => 'document-batch-import',
|
||||
'external_id' => $validated['external_id'], 'data_classification' => 'internal',
|
||||
'actor_user_id' => (int) $actorId, 'entity_type' => 'document', 'entity_id' => $documentId,
|
||||
'action' => 'private_document_batch_imported',
|
||||
'after_data' => json_encode(['sha256' => $hash, 'resource_id' => $resourceId, 'published' => false], JSON_THROW_ON_ERROR),
|
||||
'occurred_at' => $now, 'created_at' => $now,
|
||||
]);
|
||||
|
||||
return $resourceId;
|
||||
});
|
||||
$dispatch[] = $documentId;
|
||||
$this->info($label.' | private draft created | resource='.$resourceId.'; pending scan; not published');
|
||||
} catch (Throwable $exception) {
|
||||
$disk->delete($storageKey);
|
||||
throw $exception;
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($label.' | not imported: '.$exception->getMessage());
|
||||
$failures++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($dispatch as $documentId) {
|
||||
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
|
||||
}
|
||||
$this->line($apply ? 'New scans queued: '.count($dispatch).'.' : 'Preview only; database and storage unchanged.');
|
||||
|
||||
return $failures > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/** The extra CLI path is a named, byte-bound pilot; it is not a general active import gate. */
|
||||
private function validatePilot(string $manifestPath, string $manifestBytes, array $manifest, string $staging, bool $apply, mixed $actorId, bool $sectors = false, bool $trio = false): void
|
||||
{
|
||||
$allowed = $trio ? self::TRIO_HASHES : ($sectors ? self::SECTORS_HASHES : self::PILOT_HASHES);
|
||||
$manifestName = $trio ? 'legacy-trio-20261015/manifest.json' : ($sectors ? 'legacy-setores-lote20-20261015/manifest.json' : 'legacy-agronegocio/manifest.json');
|
||||
$manifestHash = $trio ? self::TRIO_MANIFEST_SHA256 : ($sectors ? self::SECTORS_MANIFEST_SHA256 : self::PILOT_MANIFEST_SHA256);
|
||||
if ($manifestPath !== realpath($staging.'/'.$manifestName)
|
||||
|| ! hash_equals($manifestHash, hash('sha256', $manifestBytes))
|
||||
|| count($manifest['documents']) !== count($allowed)) {
|
||||
throw new RuntimeException('Only the exact selected manifest and its fixed resource list are allowed.');
|
||||
}
|
||||
$target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
$host = config('database.connections.pgsql.host');
|
||||
$qa = app()->environment('testing') && str_ends_with($target->database, '_test') && (int) $target->port === 55441;
|
||||
$active = ! app()->environment('testing') && $target->database === 'investemoz' && (int) $target->port === 5432;
|
||||
if ((! $qa && ! $active) || ! in_array($host, ['127.0.0.1', 'localhost'], true) || config('database.connections.pgsql.url')) {
|
||||
throw new RuntimeException('Pilot target identity is not permitted.');
|
||||
}
|
||||
if ($apply || $actorId !== null) {
|
||||
$actor = is_string($actorId) && preg_match('/^[1-9][0-9]*$/D', $actorId) ? User::find((int) $actorId) : null;
|
||||
if (! $actor || ! $actor->can('manage-portal')) {
|
||||
throw new RuntimeException('A real existing account with effective manage-portal authorization is required.');
|
||||
}
|
||||
}
|
||||
if ($active && $apply) {
|
||||
$this->validatePilotBackup($this->option('backup-proof'));
|
||||
}
|
||||
if ($trio) {
|
||||
$images = DB::table('documents as d')->join('portal_resources as r', 'r.document_id', '=', 'd.id')
|
||||
->where('d.external_id', 'legacy-tempo-image-28f41f12e10ab345')->where('d.sha256', self::TRIO_REUSED_IMAGE_SHA256)
|
||||
->whereNull('d.deleted_at')->where('d.status', 'active')->where('d.data_classification', 'public')
|
||||
->where('d.rights_status', 'authorized')->where('r.rights_status', 'authorized')->where('r.published', true)
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256')->get(['d.storage_key']);
|
||||
if ($images->count() !== 1 || ! Storage::disk('local')->exists($images[0]->storage_key)
|
||||
|| ! hash_equals(self::TRIO_REUSED_IMAGE_SHA256, hash_file('sha256', Storage::disk('local')->path($images[0]->storage_key)))) {
|
||||
throw new RuntimeException('The shared image must already exist once, published, authorized and clean with the exact original bytes; no image is imported.');
|
||||
}
|
||||
}
|
||||
foreach ($manifest['documents'] as $record) {
|
||||
$entry = $this->validateRecord($record, $staging);
|
||||
if (! hash_equals($allowed[$entry['external_id']] ?? '', $entry['sha256'])) {
|
||||
throw new RuntimeException('Article or image differs from the approved SHA-256.');
|
||||
}
|
||||
$existing = DB::table('documents')->where('sha256', $entry['sha256'])->whereNull('deleted_at')->first();
|
||||
if ($existing && ($existing->external_id !== $entry['external_id']
|
||||
|| ! DB::table('portal_resources')->where('document_id', $existing->id)->exists())) {
|
||||
throw new RuntimeException('Matching bytes have another identity or no library resource; reconcile explicitly.');
|
||||
}
|
||||
if (DB::table('documents')->where('external_id', $entry['external_id'])->whereNull('deleted_at')
|
||||
->where('sha256', '!=', $entry['sha256'])->exists()
|
||||
|| (! $existing && DB::table('portal_resources')->where('document_identity_key', $this->identity($entry))->exists())) {
|
||||
throw new RuntimeException('Existing identity/version conflicts with this pilot; no overwrite or partial preflight import.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function validatePilotBackup(mixed $path): void
|
||||
{
|
||||
if (! is_string($path) || ! is_file($path)) {
|
||||
throw new RuntimeException('Active pilot apply requires a fresh verified backup/restoration receipt.');
|
||||
}
|
||||
$proof = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
|
||||
$verified = strtotime($proof['verified_at'] ?? '');
|
||||
if (! ($proof['restore_verified'] ?? false) || ! ($proof['files_verified'] ?? false)
|
||||
|| ($proof['target']['database'] ?? '') !== 'investemoz' || (int) ($proof['target']['port'] ?? 0) !== 5432
|
||||
|| ! str_ends_with($proof['restore_target']['database'] ?? '', '_test') || (int) ($proof['restore_target']['port'] ?? 0) !== 55441
|
||||
|| $verified === false || $verified > time() || $verified < time() - 86400
|
||||
|| ! is_file($proof['dump_path'] ?? '') || ! is_file($proof['files_path'] ?? '')
|
||||
|| ! hash_equals($proof['sha256'] ?? '', hash_file('sha256', $proof['dump_path']))
|
||||
|| ! hash_equals($proof['files_sha256'] ?? '', hash_file('sha256', $proof['files_path']))) {
|
||||
throw new RuntimeException('Backup/restoration identity, freshness or integrity does not match the active pilot.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function validateRecord(mixed $record, string $stagingRoot): array
|
||||
{
|
||||
if (! is_array($record)) {
|
||||
throw new RuntimeException('Each documents entry must be an object.');
|
||||
}
|
||||
foreach (['external_id', 'relative_path', 'title', 'issuer'] as $required) {
|
||||
if (! isset($record[$required]) || ! is_string($record[$required]) || trim($record[$required]) === '') {
|
||||
throw new RuntimeException('Required manifest field is missing: '.$required.'.');
|
||||
}
|
||||
}
|
||||
if (strlen($record['external_id']) > 120 || strlen($record['title']) > 250 || strlen($record['issuer']) > 180) {
|
||||
throw new RuntimeException('A metadata value exceeds its permitted length.');
|
||||
}
|
||||
if (isset($record['file_name']) && (! is_string($record['file_name']) || strlen(basename($record['file_name'])) > 255)) {
|
||||
throw new RuntimeException('File name exceeds its permitted length.');
|
||||
}
|
||||
if (isset($record['document_number']) && (! is_string($record['document_number']) || strlen($record['document_number']) > 120)) {
|
||||
throw new RuntimeException('Document number exceeds its permitted length.');
|
||||
}
|
||||
if (isset($record['document_date'])) {
|
||||
if (! is_string($record['document_date']) || ! preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $record['document_date'], $dateParts)
|
||||
|| ! checkdate((int) $dateParts[2], (int) $dateParts[3], (int) $dateParts[1])) {
|
||||
throw new RuntimeException('Document date must use a valid YYYY-MM-DD value.');
|
||||
}
|
||||
}
|
||||
if (isset($record['document_type']) && ! in_array($record['document_type'], ['legislation', 'procedure', 'official_form', 'digital_adaptation', 'guidance_template', 'guide', 'study', 'reference_data', 'unknown'], true)) {
|
||||
throw new RuntimeException('Document type is not supported by the catalogue.');
|
||||
}
|
||||
|
||||
$path = realpath($stagingRoot.DIRECTORY_SEPARATOR.$record['relative_path']);
|
||||
if (! $path || ! str_starts_with(strtolower($path), strtolower($stagingRoot.DIRECTORY_SEPARATOR)) || ! is_file($path)) {
|
||||
throw new RuntimeException('Source file is missing or outside the private staging directory.');
|
||||
}
|
||||
$size = filesize($path);
|
||||
if (! is_int($size) || $size < 1 || $size > self::MAX_BYTES) {
|
||||
throw new RuntimeException('File size is outside the 1 byte to 10 MiB import limit.');
|
||||
}
|
||||
$mime = (new \finfo(FILEINFO_MIME_TYPE))->file($path);
|
||||
$allowed = ['application/pdf', 'application/json', 'text/plain', 'text/csv', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'image/png', 'image/jpeg', 'image/webp'];
|
||||
if (! is_string($mime) || ! in_array($mime, $allowed, true)) {
|
||||
throw new RuntimeException('Detected media type is not supported by the document library.');
|
||||
}
|
||||
$sha = hash_file('sha256', $path);
|
||||
if (! is_string($sha)) {
|
||||
throw new RuntimeException('File hash could not be calculated.');
|
||||
}
|
||||
|
||||
$sourceUrl = $record['source_url'] ?? null;
|
||||
if ($sourceUrl !== null && (! is_string($sourceUrl) || strlen($sourceUrl) > 2000 || ! filter_var($sourceUrl, FILTER_VALIDATE_URL) || strtolower((string) parse_url($sourceUrl, PHP_URL_SCHEME)) !== 'https')) {
|
||||
throw new RuntimeException('Source URL, when supplied, must be a valid HTTPS URL.');
|
||||
}
|
||||
|
||||
return [
|
||||
...$record, 'source_path' => $path, 'file_name' => basename((string) ($record['file_name'] ?? $path)),
|
||||
'size_bytes' => $size, 'media_type' => $mime, 'sha256' => $sha,
|
||||
'category' => (string) ($record['category'] ?? 'general'), 'topic' => (string) ($record['topic'] ?? 'unclassified'),
|
||||
'legislation' => (bool) ($record['legislation'] ?? false), 'document_type' => (string) ($record['document_type'] ?? 'unknown'),
|
||||
'document_number' => $record['document_number'] ?? null, 'document_date' => $record['document_date'] ?? null,
|
||||
'language' => (string) ($record['language'] ?? 'unknown'), 'version_label' => $record['version_label'] ?? null,
|
||||
'source_url' => $record['source_url'] ?? null, 'source_reference' => $record['source_reference'] ?? 'Origem por completar.',
|
||||
'description' => $record['description'] ?? 'Documento importado para revisão documental.',
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $record */
|
||||
private function identity(array $record): string
|
||||
{
|
||||
return hash('sha256', mb_strtolower(implode('|', array_map('trim', [
|
||||
(string) $record['issuer'], (string) ($record['document_number'] ?? ''), (string) ($record['document_date'] ?? ''),
|
||||
(string) $record['language'], (string) ($record['version_label'] ?? $record['title']),
|
||||
]))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\InvestmentZones;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class ImportIndustrialParkCandidates extends Command
|
||||
{
|
||||
protected $signature = 'investment-zones:import-industrial-candidates {--apply : Create missing private drafts} {--actor= : Existing eligible manager user ID required with --apply}';
|
||||
|
||||
protected $description = 'Reconcile and optionally create source-backed industrial-park/ZFI drafts without publishing';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$document = json_decode($this->readSource(database_path('data/industrial-park-candidate-drafts.json')), true, flags: JSON_THROW_ON_ERROR);
|
||||
if (($document['source_document_attached'] ?? null) !== true
|
||||
|| ($document['source_document_reconciled'] ?? null) !== true) {
|
||||
$this->error('The source document must be attached and reconciled before importing drafts. No records written.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$rows = [];
|
||||
foreach ($document['records'] as $record) {
|
||||
$existing = DB::table('investment_zones')->where('code', $record['code'])->first(['id', 'name', 'publication_status']);
|
||||
$territories = [];
|
||||
foreach ($record['territories'] as $territory) {
|
||||
$expected = $this->normalize($territory['name']);
|
||||
$matches = DB::table('geo_locations')->whereNull('deleted_at')->where('location_type', $territory['type'])
|
||||
->get(['id', 'name'])->filter(fn ($node) => $this->normalize($node->name) === $expected);
|
||||
if ($matches->count() !== 1) {
|
||||
throw new RuntimeException("Territory {$territory['name']} ({$territory['type']}) matched {$matches->count()} rows; approximate matching is disabled.");
|
||||
}
|
||||
$territories[] = [...$territory, 'geo_location_id' => $matches->first()->id, 'resolved_name' => $matches->first()->name];
|
||||
}
|
||||
$rows[] = ['record' => $record, 'existing' => $existing, 'territories' => $territories];
|
||||
}
|
||||
$this->table(['Code', 'Action', 'Territory IDs resolved', 'Areas'], array_map(fn ($row) => [
|
||||
$row['record']['code'], $row['existing'] ? 'reconcile/skip-existing' : ($this->option('apply') ? 'create-private-draft' : 'would-create-private-draft'),
|
||||
implode('; ', array_map(fn ($t) => $t['resolved_name'], $row['territories'])), count($row['record']['areas']),
|
||||
], $rows));
|
||||
|
||||
if (! $this->option('apply')) {
|
||||
$this->info('Dry run only. No writes made; every created record remains a private draft, with legal regime/availability not implied by the source.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('investment_zones', 'source_evidence')) {
|
||||
$this->error('The additive industrial-park schema is not migrated in this database. No records written.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$actor = filter_var($this->option('actor'), FILTER_VALIDATE_INT);
|
||||
$user = $actor ? DB::table('users')->where('id', $actor)->first(['id', 'email', 'email_verified_at', 'two_factor_confirmed_at']) : null;
|
||||
if (! $user || ! $user->email_verified_at || ! $user->two_factor_confirmed_at
|
||||
|| ! in_array(mb_strtolower($user->email), config('portal.managers', []), true)) {
|
||||
$this->error('An existing verified, MFA-enabled configured manager is required. No records written.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$created = DB::transaction(function () use ($rows, $actor, $document): array {
|
||||
$created = [];
|
||||
foreach ($rows as $row) {
|
||||
if ($row['existing']) {
|
||||
continue;
|
||||
}
|
||||
$record = $row['record'];
|
||||
$source = $record['source'];
|
||||
$sourceReference = $source['url'].' — '.$source['title'];
|
||||
$locations = array_map(fn ($location) => ['geo_location_id' => $location['geo_location_id'], 'scope_type' => $location['scope_type'],
|
||||
'source_reference' => $sourceReference, 'legal_source_id' => null], $row['territories']);
|
||||
$first = $row['territories'][0]['geo_location_id'] ?? null;
|
||||
$zone = app(InvestmentZones::class)->save(null, [
|
||||
'code' => $record['code'], 'name' => $record['name'], 'category' => $record['category'],
|
||||
'physical_category' => $record['physical_category'], 'legal_regime' => $record['legal_regime'],
|
||||
'development_status' => $record['development_status'], 'availability_status' => $record['availability_status'],
|
||||
'operational_status' => 'unknown', 'description' => $record['source']['supports'].(! empty($record['territory_note']) ? ' '.$record['territory_note'] : ''),
|
||||
'source_evidence' => [[...$source, 'source_date' => null]],
|
||||
'area_components' => array_map(fn ($area) => [...$area, 'source_reference' => $sourceReference.' — '.$area['source_reference'], 'source_date' => null], $record['areas']),
|
||||
'area_value' => null, 'area_unit' => null, 'activities' => [], 'resources' => [], 'aliases' => [],
|
||||
'sources_consulted_at' => $document['consulted_at'],
|
||||
'legal_source_id' => null, 'parent_zone_id' => null, 'primary_territory_id' => $first,
|
||||
'territory_version' => $first ? 1 : 0, 'locations' => $locations,
|
||||
], (int) $actor);
|
||||
$created[] = [$zone->code, $zone->id];
|
||||
}
|
||||
|
||||
return $created;
|
||||
});
|
||||
$this->info('Private drafts created: '.count($created).'. Existing codes skipped. No legal sources were approved and no records were published.');
|
||||
foreach ($created as [$code, $id]) {
|
||||
$this->line($code.' '.$id);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function normalize(string $value): string
|
||||
{
|
||||
return Str::upper(Str::ascii(trim($value)));
|
||||
}
|
||||
|
||||
private function readSource(string $path): string
|
||||
{
|
||||
$bytes = file_get_contents($path);
|
||||
if ($bytes === false) {
|
||||
throw new RuntimeException('Source file is unavailable: '.$path);
|
||||
}
|
||||
|
||||
return $bytes;
|
||||
}
|
||||
}
|
||||
@@ -5,54 +5,18 @@ namespace App\Console\Commands;
|
||||
use App\Models\InvestmentDossier;
|
||||
use Database\Seeders\InvestmentPortfolioSeeder;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class ImportInvestmentPortfolio extends Command
|
||||
{
|
||||
protected $signature = 'portfolio:import {--approval= : Recorded approval from the document owner}';
|
||||
protected $signature = 'portfolio:import';
|
||||
|
||||
protected $description = 'Import the v4 portfolio and optionally publish unchanged documentary dossiers with owner approval';
|
||||
protected $description = 'Import the documentary portfolio as private drafts; publication uses the authenticated review workflow';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$approval = trim((string) $this->option('approval'));
|
||||
if ($approval !== '' && (mb_strlen($approval) < 15 || mb_strlen($approval) > 3000)) {
|
||||
$this->error('A autorização deve conter entre 15 e 3000 caracteres.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$data = json_decode(File::get(database_path('data/investment-portfolio.json')), true, 512, JSON_THROW_ON_ERROR);
|
||||
$published = 0;
|
||||
$preserved = 0;
|
||||
DB::transaction(function () use ($data, $approval, &$published, &$preserved) {
|
||||
app(InvestmentPortfolioSeeder::class)->run();
|
||||
foreach (['projects' => ['project_id', 'projects'], 'clusters' => ['cluster_id', 'economic_clusters'], 'corridors' => ['corridor_id', 'development_corridors']] as $kind => [$foreign, $table]) {
|
||||
foreach ($data[$kind] as $item) {
|
||||
$entity = DB::table($table)->where('code', $item['code'])->where('source_system', 'upgrade-v4')->whereNull('deleted_at')->value('id');
|
||||
$dossier = InvestmentDossier::where($foreign, $entity)->lockForUpdate()->first();
|
||||
// Approval of this document never approves subsequent human edits or restores archived records.
|
||||
if (! $dossier || $dossier->title !== $item['name'] || $dossier->content != [...$item, 'notice' => $data['notice']] || $dossier->editor_id) {
|
||||
$preserved++;
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($approval === '' || $dossier->publication_status === 'published') {
|
||||
continue;
|
||||
}
|
||||
$dossier->update(['version_no' => $dossier->version_no + 1, 'publication_status' => 'published', 'published_at' => now(),
|
||||
'approval_basis' => 'document_owner', 'approval_evidence' => $approval]);
|
||||
DB::table('audit_events')->insert(['source_system' => 'portfolio-import', 'entity_type' => 'investment_dossiers',
|
||||
'entity_id' => $dossier->id, 'action' => 'owner_approved', 'after_data' => json_encode([
|
||||
'code' => $item['code'], 'approval_basis' => 'document_owner',
|
||||
'approval_sha256' => hash('sha256', $approval),
|
||||
'dataset_sha256' => hash_file('sha256', database_path('data/investment-portfolio.json')),
|
||||
], JSON_THROW_ON_ERROR)]);
|
||||
$published++;
|
||||
}
|
||||
}
|
||||
});
|
||||
$this->info("Carteira importada: 15 projectos, 8 clusters, 6 corredores. Publicados: {$published}. Fichas alteradas/arquivadas preservadas: {$preserved}.");
|
||||
app(InvestmentPortfolioSeeder::class)->run();
|
||||
$drafts = InvestmentDossier::where('publication_status', 'draft')->count();
|
||||
$this->info("Carteira importada/actualizada. Dossiers em rascunho privado: {$drafts}. Nenhuma publicação foi realizada.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\InvestmentZones;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class ImportInvestmentZones extends Command
|
||||
{
|
||||
protected $signature = 'investment-zones:import-initial {--apply : Create missing records as private drafts} {--actor= : Existing manager user ID required with --apply}';
|
||||
|
||||
protected $description = 'Import documented initial ZEE, ZFI and industrial park references without overwriting or publishing existing records';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$path = database_path('data/investment-zones-initial.json');
|
||||
$document = json_decode($this->readSource($path), true, flags: JSON_THROW_ON_ERROR);
|
||||
$rows = [];
|
||||
foreach ($document['records'] as $record) {
|
||||
$existing = DB::table('investment_zones')->where('code', $record['code'])->first(['id', 'name', 'category', 'publication_status']);
|
||||
$territories = [];
|
||||
foreach ($record['territories'] as $territory) {
|
||||
$expected = $this->normalize($territory['name']);
|
||||
$matches = DB::table('geo_locations')->whereNull('deleted_at')->where('location_type', $territory['type'])
|
||||
->get(['id', 'name', 'data_classification'])->filter(fn ($node) => $this->normalize($node->name) === $expected);
|
||||
if ($matches->count() !== 1) {
|
||||
throw new RuntimeException("Territory reference {$territory['name']} ({$territory['type']}) matched {$matches->count()} master records; no approximate match or new territory was created.");
|
||||
}
|
||||
$territories[] = [...$territory, 'geo_location_id' => $matches->first()->id, 'resolved_name' => $matches->first()->name];
|
||||
}
|
||||
$rows[] = ['code' => $record['code'], 'name' => $record['name'], 'action' => $existing ? 'skip-existing' : ($this->option('apply') ? 'create-draft' : 'would-create-draft'), 'existing' => $existing, 'territories' => $territories];
|
||||
}
|
||||
|
||||
if (! $this->option('apply')) {
|
||||
$this->table(['Code', 'Name', 'Action', 'Territories'], array_map(fn ($row) => [$row['code'], $row['name'], $row['action'], implode('; ', array_column($row['territories'], 'resolved_name'))], $rows));
|
||||
$this->info('Dry run: no records written. Sources are official references; records remain private drafts pending review.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$actor = filter_var($this->option('actor'), FILTER_VALIDATE_INT);
|
||||
$user = $actor ? DB::table('users')->where('id', $actor)->first(['id', 'email', 'email_verified_at', 'two_factor_confirmed_at']) : null;
|
||||
if (! $user || ! $user->email_verified_at || ! $user->two_factor_confirmed_at
|
||||
|| ! in_array(mb_strtolower($user->email), config('portal.managers', []), true)) {
|
||||
$this->error('An existing, verified, MFA-enabled portal manager is required with --apply --actor=.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$summary = DB::transaction(function () use ($rows, $document, $actor): array {
|
||||
$created = [];
|
||||
$skipped = [];
|
||||
foreach ($rows as $row) {
|
||||
if ($row['existing']) {
|
||||
$skipped[] = $row['code'];
|
||||
|
||||
continue;
|
||||
}
|
||||
$record = collect(is_array($document['records'] ?? null) ? $document['records'] : [])->firstWhere('code', $row['code']);
|
||||
$requiresLegalSource = in_array($record['category'], ['zee', 'zfi'], true);
|
||||
$source = $requiresLegalSource
|
||||
? DB::table('investment_legal_sources')->where('url', $record['source']['url'])->where('diploma', $record['source']['diploma'])->first()
|
||||
: null;
|
||||
$sourceId = $requiresLegalSource ? ($source->id ?? (string) Str::uuid()) : null;
|
||||
if ($requiresLegalSource && ! $source) {
|
||||
DB::table('investment_legal_sources')->insert([
|
||||
'id' => $sourceId, ...$record['source'], 'validation_status' => 'pending', 'owner_id' => $actor,
|
||||
'version_no' => 1, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
$parentId = null;
|
||||
if (isset($record['parent_code'])) {
|
||||
$parentId = DB::table('investment_zones')->where('code', $record['parent_code'])->value('id');
|
||||
if (! $parentId) {
|
||||
throw new RuntimeException("Parent zone {$record['parent_code']} must be imported before {$record['code']}.");
|
||||
}
|
||||
}
|
||||
$locations = array_map(fn ($location) => [
|
||||
'geo_location_id' => $location['geo_location_id'], 'scope_type' => $location['scope_type'],
|
||||
'source_reference' => $record['source']['url'].' — '.($record['source']['diploma'] ?? $record['source']['title']), 'legal_source_id' => $sourceId,
|
||||
], $row['territories']);
|
||||
$firstLocation = $row['territories'][0]['geo_location_id'] ?? null;
|
||||
$zone = app(InvestmentZones::class)->save(null, [
|
||||
'code' => $record['code'], 'category' => $record['category'], 'physical_category' => $record['physical_category'],
|
||||
'legal_regime' => $record['legal_regime'], 'operational_status' => $record['operational_status'],
|
||||
'development_status' => $record['development_status'] ?? match ($record['operational_status']) {
|
||||
'operational' => 'operational', 'implementation' => 'in_development', 'planned' => 'planned', default => 'unknown'
|
||||
},
|
||||
'availability_status' => $record['availability_status'] ?? 'not_informed',
|
||||
'name' => $record['name'], 'name_en' => $record['name_en'], 'description' => $record['description'], 'description_en' => $record['description_en'],
|
||||
'area_value' => $record['area_value'], 'area_unit' => $record['area_unit'], 'area_components' => $record['area_components'] ?? [],
|
||||
'source_evidence' => [[
|
||||
'title' => $record['source']['title'], 'url' => $record['source']['url'],
|
||||
'source_type' => str_contains($record['source']['url'], 'inm.gov.mz') ? 'government' : (str_contains($record['source']['url'], 'apiex.gov.mz') ? 'institutional' : 'promoter'),
|
||||
'supports' => $record['source']['supports'] ?? 'Referência institucional/promocional; não comprova regime jurídico, incentivos ou disponibilidade.',
|
||||
'source_date' => $record['source']['source_date'] ?? null,
|
||||
]], 'aliases' => $record['aliases'],
|
||||
'activities' => $record['activities'], 'resources' => [], 'historical_territory' => $record['historical_territory'] ?? null,
|
||||
'data_as_of' => null, 'sources_consulted_at' => $document['consulted_at'], 'operator_id' => null,
|
||||
'legal_source_id' => $sourceId, 'parent_zone_id' => $parentId, 'primary_territory_id' => $firstLocation,
|
||||
'territory_version' => $firstLocation ? 1 : 0, 'province' => null, 'district' => null, 'locations' => $locations,
|
||||
], (int) $actor);
|
||||
$created[] = ['code' => $record['code'], 'id' => $zone->id, 'source' => $source?->id ? 'reused' : 'created', 'locations_pending' => count($locations)];
|
||||
}
|
||||
|
||||
return compact('created', 'skipped');
|
||||
});
|
||||
|
||||
$this->table(['Code', 'ID', 'Source', 'Pending locations'], array_map(fn ($row) => [$row['code'], $row['id'], $row['source'], $row['locations_pending']], $summary['created']));
|
||||
$this->info('Created drafts: '.count($summary['created']).'; skipped without overwrite: '.count($summary['skipped']).'; publication: none.');
|
||||
$this->line('Skipped: '.implode(', ', $summary['skipped']));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function normalize(string $value): string
|
||||
{
|
||||
return Str::upper(Str::ascii(trim($value)));
|
||||
}
|
||||
|
||||
private function readSource(string $path): string
|
||||
{
|
||||
$bytes = file_get_contents($path);
|
||||
if ($bytes === false) {
|
||||
throw new RuntimeException('Source file is unavailable: '.$path);
|
||||
}
|
||||
|
||||
return $bytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ImportLegalSourceArchive extends Command
|
||||
{
|
||||
protected $signature = 'documents:import-legal-source-archive {--apply : Copy verified archive files into private storage and register draft metadata}';
|
||||
|
||||
protected $description = 'Idempotently imports the known local legal-source PDFs as private, unpublished library drafts';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$records = $this->records();
|
||||
$apply = (bool) $this->option('apply');
|
||||
$disk = Storage::disk('local');
|
||||
$dispatch = [];
|
||||
$failures = 0;
|
||||
|
||||
foreach ($records as $record) {
|
||||
$sourcePath = base_path($record['archive_path']);
|
||||
$realSource = realpath($sourcePath);
|
||||
$approvedRoots = array_filter([
|
||||
realpath(base_path('docs/investmoz/simulations/source-archive')),
|
||||
realpath(storage_path('app/private/geo-ine-source-20260928')),
|
||||
]);
|
||||
if (! $realSource && ($record['optional'] ?? false)) {
|
||||
$this->warn($record['external_id'].': approved local source copy is not present; skipped without changing records.');
|
||||
|
||||
continue;
|
||||
}
|
||||
$withinApprovedRoot = $realSource && collect($approvedRoots)->contains(fn (string $root) => str_starts_with(strtolower($realSource), strtolower($root.DIRECTORY_SEPARATOR)));
|
||||
if (! $realSource || ! $withinApprovedRoot || ! is_file($realSource)) {
|
||||
$this->error($record['external_id'].': archive file missing or outside the approved source archive.');
|
||||
$failures++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = hash_file('sha256', $realSource);
|
||||
$size = filesize($realSource);
|
||||
$header = file_get_contents($realSource, false, null, 0, 5);
|
||||
if (! is_string($hash) || ! hash_equals($record['expected_sha256'], strtolower($hash)) || $size !== $record['size_bytes'] || $header !== '%PDF-') {
|
||||
$this->error($record['external_id'].': checksum, size, or PDF signature differs from the reviewed manifest.');
|
||||
$failures++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$identity = hash('sha256', mb_strtolower(implode('|', array_map('trim', [$record['issuer'], $record['document_number'], $record['document_date'], $record['language'], $record['version_label']]))));
|
||||
$existing = DB::table('documents')->where('sha256', $hash)->whereNull('deleted_at')->first();
|
||||
if ($existing) {
|
||||
$storedPath = $disk->path($existing->storage_key);
|
||||
$storedReal = realpath($storedPath);
|
||||
$storageRoot = realpath($disk->path(''));
|
||||
if (! $storedReal || ! $storageRoot || ! str_starts_with(strtolower($storedReal), strtolower($storageRoot.DIRECTORY_SEPARATOR))
|
||||
|| ! is_file($storedReal) || ! hash_equals($hash, (string) hash_file('sha256', $storedReal))) {
|
||||
$this->error($record['external_id'].': matching hash exists but the stored copy could not be verified; no changes made.');
|
||||
$failures++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$resource = DB::table('portal_resources')->where('document_id', $existing->id)->first();
|
||||
if (! $resource) {
|
||||
$this->error($record['external_id'].': document hash already exists without its expected library record; manual reconciliation required.');
|
||||
$failures++;
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! hash_equals($identity, (string) $resource->document_identity_key)) {
|
||||
$this->error($record['external_id'].': identical bytes exist under a different document identity; manual reconciliation required.');
|
||||
$failures++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$missingEvidence = collect(is_array($record['associations'] ?? null) ? $record['associations'] : [])->filter(fn (array $association) => $association['target_type'] === 'requirement')
|
||||
->reject(fn (array $association) => DB::table('portal_resource_links')->where('resource_id', $resource->id)
|
||||
->where('target_type', 'requirement')->where('target_key', $association['target_key'])->exists())->values();
|
||||
if ($apply && $missingEvidence->isNotEmpty()) {
|
||||
DB::transaction(function () use ($resource, $missingEvidence): void {
|
||||
$locked = DB::table('portal_resources')->where('id', $resource->id)->lockForUpdate()->first();
|
||||
$now = now();
|
||||
foreach ($missingEvidence as $association) {
|
||||
DB::table('portal_resource_links')->insertOrIgnore(['resource_id' => $resource->id, ...$association,
|
||||
'created_at' => $now, 'updated_at' => $now]);
|
||||
}
|
||||
$inserted = DB::table('portal_resource_links')->where('resource_id', $resource->id)->where('target_type', 'requirement')
|
||||
->whereIn('target_key', $missingEvidence->pluck('target_key'))->count();
|
||||
if ($inserted > 0) {
|
||||
$version = (int) $locked->metadata_version + 1;
|
||||
DB::table('portal_resources')->where('id', $resource->id)->update(['metadata_version' => $version, 'updated_at' => $now]);
|
||||
$links = DB::table('portal_resource_links')->where('resource_id', $resource->id)->orderBy('id')->get();
|
||||
$snapshot = DB::table('portal_resources')->where('id', $resource->id)->first();
|
||||
DB::table('portal_resource_revisions')->insert(['resource_id' => $resource->id, 'revision_no' => $version,
|
||||
'change_type' => 'evidence_linked',
|
||||
'snapshot' => json_encode(['resource' => $snapshot, 'associations' => $links], JSON_THROW_ON_ERROR),
|
||||
'actor_user_id' => null, 'created_at' => $now]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$this->line($record['external_id'].' | reused/reconciled | document='.$existing->id.' | published='.($resource->published ? 'yes' : 'no'));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$identityConflict = DB::table('portal_resources')->where('document_identity_key', $identity)->exists();
|
||||
if ($identityConflict) {
|
||||
$this->error($record['external_id'].': document identity exists with different bytes; manual version reconciliation required.');
|
||||
$failures++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line($record['external_id'].' | '.($apply ? 'importing private draft' : 'would import')." | sha256={$hash} | bytes={$size}");
|
||||
if (! $apply) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$documentId = (string) Str::uuid();
|
||||
$storageKey = 'portal-documents/'.$documentId;
|
||||
$sourceHandle = fopen($realSource, 'rb');
|
||||
if (! is_resource($sourceHandle)) {
|
||||
$this->error($record['external_id'].': source could not be opened.');
|
||||
$failures++;
|
||||
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$stored = $disk->put($storageKey, $sourceHandle);
|
||||
} finally {
|
||||
fclose($sourceHandle);
|
||||
}
|
||||
$storedPath = $disk->path($storageKey);
|
||||
$storedHash = $stored && is_file($storedPath) ? hash_file('sha256', $storedPath) : false;
|
||||
if (! $stored || ! is_string($storedHash) || ! hash_equals($hash, $storedHash)) {
|
||||
$disk->delete($storageKey);
|
||||
$this->error($record['external_id'].': private copy failed the post-copy checksum; no database record created.');
|
||||
$failures++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = DB::transaction(function () use ($record, $documentId, $storageKey, $hash, $size, $identity): array {
|
||||
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['legal-source-archive:'.$hash]);
|
||||
if (DB::table('documents')->where('sha256', $hash)->whereNull('deleted_at')->exists()) {
|
||||
throw new RuntimeException('A matching document was registered concurrently; rerun the import to reconcile it.');
|
||||
}
|
||||
if (DB::table('portal_resources')->where('document_identity_key', $identity)->exists()) {
|
||||
throw new RuntimeException('A matching document identity was registered concurrently; manual reconciliation required.');
|
||||
}
|
||||
|
||||
$now = now();
|
||||
DB::table('documents')->insert([
|
||||
'id' => $documentId, 'source_system' => 'legal-source-archive', 'external_id' => $record['external_id'],
|
||||
'version_no' => 1, 'data_classification' => 'internal', 'file_name' => $record['file_name'],
|
||||
'media_type' => 'application/pdf', 'storage_key' => $storageKey, 'sha256' => $hash,
|
||||
'status' => 'quarantined', 'size_bytes' => $size, 'issuer' => $record['issuer'],
|
||||
'original_source' => $record['source_url'], 'issued_on' => $record['document_date'],
|
||||
'uploaded_by' => null, 'rights_status' => 'unconfirmed', 'malware_scan_status' => 'pending',
|
||||
'created_at' => $now, 'updated_at' => $now,
|
||||
]);
|
||||
$resourceId = DB::table('portal_resources')->insertGetId([
|
||||
'document_id' => $documentId, 'title' => $record['title'], 'category' => $record['category'] ?? 'legislation',
|
||||
'topic' => $record['topic'], 'legislation' => $record['is_legislation'] ?? true, 'essential' => false, 'published' => false,
|
||||
'source_reference' => $record['source_reference'], 'approved_by' => null, 'description' => $record['description'],
|
||||
'document_type' => $record['document_type'] ?? 'legislation', 'document_number' => $record['document_number'],
|
||||
'document_date' => $record['document_date'], 'source_url' => $record['source_url'],
|
||||
'language' => $record['language'], 'version_label' => $record['version_label'],
|
||||
'territorial_scope' => 'national', 'source_confirmation' => $record['source_confirmation'],
|
||||
'legal_status' => $record['legal_status'], 'rights_status' => 'unconfirmed', 'last_reviewed_at' => null,
|
||||
'document_identity_key' => $identity, 'metadata_version' => 1, 'created_by' => null,
|
||||
'last_reviewed_by' => null, 'independent_review' => false, 'created_at' => $now, 'updated_at' => $now,
|
||||
]);
|
||||
|
||||
foreach ($record['associations'] as $association) {
|
||||
$association['review_status'] ??= 'pending';
|
||||
DB::table('portal_resource_links')->insertOrIgnore([
|
||||
'resource_id' => $resourceId, ...$association, 'created_at' => $now, 'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
$links = DB::table('portal_resource_links')->where('resource_id', $resourceId)->orderBy('id')->get();
|
||||
$resource = DB::table('portal_resources')->where('id', $resourceId)->first();
|
||||
DB::table('portal_resource_revisions')->insert([
|
||||
'resource_id' => $resourceId, 'revision_no' => 1, 'change_type' => 'created',
|
||||
'snapshot' => json_encode(['resource' => $resource, 'associations' => $links], JSON_THROW_ON_ERROR),
|
||||
'actor_user_id' => null, 'created_at' => $now,
|
||||
]);
|
||||
|
||||
return ['document_id' => $documentId, 'resource_id' => $resourceId];
|
||||
});
|
||||
$dispatch[] = $result['document_id'];
|
||||
$this->info($record['external_id'].' | created private draft | document='.$result['document_id'].' | resource='.$result['resource_id']);
|
||||
} catch (Throwable $exception) {
|
||||
$disk->delete($storageKey);
|
||||
$this->error($record['external_id'].': import rolled back ('.$exception->getMessage().').');
|
||||
$failures++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($dispatch as $documentId) {
|
||||
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
|
||||
}
|
||||
|
||||
$this->line($apply ? 'New malware scans queued: '.count($dispatch).'.' : 'Preview only; no files or database rows were changed.');
|
||||
|
||||
return $failures > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
private function records(): array
|
||||
{
|
||||
$simulatorEvidence = fn (string $basis): array => [
|
||||
['target_type' => 'module', 'target_key' => 'simulator', 'relationship' => 'supports', 'applicability' => 'evidence',
|
||||
'condition_text' => 'Consulta documental; a fonte não determina por si só a regra fiscal aplicável.', 'basis_reference' => $basis],
|
||||
];
|
||||
|
||||
return [
|
||||
[
|
||||
'external_id' => 'inm-br250-supplement-2-law-22-2022-v1',
|
||||
'archive_path' => 'docs/investmoz/simulations/source-archive/Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf',
|
||||
'file_name' => 'Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf',
|
||||
'expected_sha256' => 'ea1b0dc6f5cf6e595f6d9a19e7042f398999e0087caef1785f6e8fac571b5f53', 'size_bytes' => 1834284,
|
||||
'title' => 'Lei n.º 22/2022, de 28 de Dezembro — alteração ao Código do IVA (republicação)',
|
||||
'document_number' => 'Lei n.º 22/2022', 'document_date' => '2022-12-28', 'issuer' => 'Assembleia da República',
|
||||
'language' => 'pt', 'version_label' => '2.º Suplemento do BR n.º 250/2022; diploma alterador, não consolidação integral',
|
||||
'topic' => 'iva', 'legal_status' => 'amended', 'source_confirmation' => 'official_confirmed',
|
||||
'source_url' => 'https://www.inm.gov.mz/pt-br/content/assembleia-da-rep%C3%BAblica-suplemento-n%C2%BA-2-de-281222-pag-2252-25-42-br-n%C2%BA-250-boletim-da',
|
||||
'source_reference' => 'INM, 2.º Suplemento do BR n.º 250 e catálogo oficial AT, registo Lei 22 IVA 2022. O ficheiro local foi descarregado novamente do endpoint AT em 2026-10-06; tamanho e SHA-256 coincidem exactamente. O artigo 17(1) está citado na revisão documental.',
|
||||
'description' => 'Diploma alterador do CIVA, sem afirmar consolidação ou vigência integral actual. O texto oficial está identificado; direitos de reprodução da composição PDF continuam por rever, pelo que o ficheiro permanece privado.',
|
||||
'associations' => [...$simulatorEvidence('Lei n.º 22/2022, art. 17(1), conforme revisão documental em docs/investmoz/simulations/primary-text-review-2025-2026.md; consultar alterações posteriores antes de determinar o período.'),
|
||||
['target_type' => 'theme', 'target_key' => 'iva', 'relationship' => 'cites', 'applicability' => 'evidence', 'condition_text' => null,
|
||||
'basis_reference' => 'Lei n.º 22/2022, art. 17(1); efeito temporal e alterações posteriores devem ser considerados.'],
|
||||
['target_type' => 'requirement', 'target_key' => 'fiscal.iva.lei_22_2022', 'relationship' => 'cites', 'applicability' => 'evidence', 'review_status' => 'confirmed',
|
||||
'condition_text' => 'Evidência documental; a citação não activa nem determina cálculos fiscais.', 'basis_reference' => 'Lei n.º 22/2022, art. 17(1), p. 3 do PDF oficial arquivado; a redacção diz 16%. Conferência da citação concluída; aplicação temporal e aprovação fiscal continuam separadas.']],
|
||||
],
|
||||
[
|
||||
'external_id' => 'inm-br248-supplement-1-2025-v1',
|
||||
'archive_path' => 'docs/investmoz/simulations/source-archive/BR-248-I-Suplemento-1-2025.pdf',
|
||||
'file_name' => 'BR-248-I-Suplemento-1-2025.pdf',
|
||||
'expected_sha256' => 'ccf2a8deeca820f2dd076b4b2141b1efe3f6a806568c3a587ca59a8e92f4c251', 'size_bytes' => 440018,
|
||||
'title' => 'Boletim da República n.º 248/2025 — Suplemento n.º 1',
|
||||
'document_number' => 'BR n.º 248/2025, Suplemento n.º 1', 'document_date' => '2025-12-29', 'issuer' => 'Imprensa Nacional de Moçambique',
|
||||
'language' => 'pt', 'version_label' => 'Suplemento n.º 1; edição arquivada com 21 páginas',
|
||||
'topic' => 'fiscalidade', 'legal_status' => 'unknown', 'source_confirmation' => 'official_confirmed',
|
||||
'source_url' => 'https://inm.gov.mz/pt-br/content/suplemento-n%C2%BA-1-de-291225-pag-2180-1-20-br-n%C2%BA-248-boletim-da-rep%C3%BAblica-i-serie',
|
||||
'source_reference' => 'Ficha INM do Suplemento n.º 1 do BR 248 e catálogo oficial AT. O ficheiro local foi descarregado novamente do endpoint WebDAV AT em 2026-10-06; tamanho e SHA-256 coincidem exactamente. Inclui diplomas fiscais identificados no registo de fontes; artigo, vigência e situação de cada lei devem ser consultados individualmente.',
|
||||
'description' => 'Edição que reúne diplomas sobre ICE, Pauta Aduaneira, ISPC, IVA, IRPS e IRPC. O estado de vigência é mantido por confirmar por diploma e dispositivo. O texto oficial está identificado; direitos de reprodução da composição PDF continuam por rever, pelo que o ficheiro permanece privado.',
|
||||
'associations' => [...$simulatorEvidence('BR n.º 248/2025, Suplemento n.º 1; esta ligação é evidência documental, não confirmação de vigência ou obrigação.'),
|
||||
...array_map(fn (string $theme) => ['target_type' => 'theme', 'target_key' => $theme, 'relationship' => 'cites', 'applicability' => 'evidence', 'condition_text' => null,
|
||||
'review_status' => 'pending', 'basis_reference' => 'Associação temática antiga; artigo e alcance por diploma ainda não foram especificados.'],
|
||||
['ice', 'aduaneiro', 'ispc', 'iva', 'irps', 'irpc']),
|
||||
...array_map(fn (array $item) => ['target_type' => 'requirement', 'target_key' => $item[0], 'relationship' => 'cites',
|
||||
'applicability' => 'evidence', 'review_status' => 'confirmed', 'condition_text' => 'Citação localizada no texto primário; não confirma interpretação, vigência integral, elegibilidade nem regra executável.',
|
||||
'basis_reference' => $item[1].' — '.$item[2].' BR n.º 248/2025, Suplemento n.º 1, Lei de 29-12-2025.'], [
|
||||
['fiscal.ice.lei_7_2025', 'Lei n.º 7/2025 (ICE)', 'arts. 1–5 e tabela anexa; pp. 1–2. Art. 1 prorroga as taxas para 2027; art. 5 indica entrada em vigor em 01-01-2026.'],
|
||||
['fiscal.customs.lei_8_2025', 'Lei n.º 8/2025 (Pauta Aduaneira)', 'art. 1 altera as Instruções Preliminares, arts. 21(2), 22 e 27; arts. 2–5 e tabela anexa; pp. 2–7.'],
|
||||
['fiscal.ispc.lei_9_2025', 'Lei n.º 9/2025 (ISPC)', 'arts. 1–7 e republicação anexa da Lei n.º 5/2009; pp. 8–11.'],
|
||||
['fiscal.iva.lei_10_2025', 'Lei n.º 10/2025 (IVA)', 'arts. 1–4; alterações do CIVA identificadas no diploma, incluindo arts. 18, 19, 21, 23, 25-A, 27 e 32–34; pp. 12–15.'],
|
||||
['fiscal.irps.lei_11_2025', 'Lei n.º 11/2025 (IRPS)', 'arts. 1–4; alterações do CIRPS, incluindo arts. 21, 22, 48, 50, 54-A, 57, 65 e 65-A; pp. 16–18.'],
|
||||
['fiscal.irpc.lei_12_2025', 'Lei n.º 12/2025 (IRPC)', 'arts. 1–4; alterações do CIRPC, incluindo arts. 3, 5, 20, 62, 67, 75 e aditamento do art. 61-A; pp. 19–20.'],
|
||||
]),
|
||||
],
|
||||
],
|
||||
[
|
||||
'external_id' => 'inm-br89-series-1-2017-iva-rectification-v1',
|
||||
'archive_path' => 'docs/investmoz/simulations/source-archive/BR-89-I-Serie-2017-06-08.pdf',
|
||||
'file_name' => 'BR-89-I-Serie-2017-06-08.pdf',
|
||||
'expected_sha256' => '11f0cab15eb6e51010952203ffa24fdc533a24fd642e19525fe4f46b17a1f5e5', 'size_bytes' => 9456358,
|
||||
'title' => 'Boletim da República n.º 89, I Série (08-06-2017) — aviso de rectificação da Lei n.º 13/2016',
|
||||
'document_number' => 'BR n.º 89/2017', 'document_date' => '2017-06-08', 'issuer' => 'Imprensa Nacional de Moçambique',
|
||||
'language' => 'pt', 'version_label' => 'Exemplar local de 16 páginas; autenticidade binária pendente',
|
||||
'topic' => 'iva', 'legal_status' => 'unknown', 'source_confirmation' => 'identified',
|
||||
'source_url' => 'https://www.inm.gov.mz/pt-br/content/assembleia-da-rep%C3%BAblica-br-n%C2%BA-89-de-080617-boletim-da-rep%C3%BAblica-i-serie-p%C3%A1g-569',
|
||||
'source_reference' => 'Ficha oficial INM identifica o aviso de rectificação da Lei n.º 13/2016 na p. 569. O exemplar local não tem assinatura digital validável; cadeia de custódia e efeito temporal por confirmar, conforme docs/investmoz/simulations/official-source-register.md.',
|
||||
'description' => 'Aviso de rectificação ligado ao CIVA de 2016. A identidade do aviso é corroborada pela ficha INM; autenticidade do ficheiro local, anexos corrigidos e efeito temporal permanecem por confirmar. Cópia mantida em privado.',
|
||||
'associations' => [...$simulatorEvidence('Ficha INM: aviso de rectificação da Lei n.º 13/2016, p. 569; exemplar local e efeito temporal por confirmar.'),
|
||||
['target_type' => 'theme', 'target_key' => 'iva', 'relationship' => 'cites', 'applicability' => 'evidence', 'condition_text' => null, 'review_status' => 'pending',
|
||||
'basis_reference' => 'Aviso de rectificação da Lei n.º 13/2016, BR n.º 89/2017, p. 569; exemplar local, anexos corrigidos e efeito temporal ainda não autenticados.'],
|
||||
['target_type' => 'requirement', 'target_key' => 'fiscal.iva.lei_13_2016_rectification_2017', 'relationship' => 'cites', 'applicability' => 'evidence', 'review_status' => 'pending',
|
||||
'condition_text' => 'Relação candidata pendente de conferência da cópia e dos anexos rectificados.',
|
||||
'basis_reference' => 'Aviso de rectificação da Lei n.º 13/2016, BR n.º 89/2017, p. 569; ficha INM identifica o aviso, mas a cópia local e os anexos não foram autenticados.']],
|
||||
],
|
||||
[
|
||||
'external_id' => 'ine-codificador-divisao-politico-administrativa-2012-v1',
|
||||
'optional' => true,
|
||||
'archive_path' => 'storage/app/private/geo-ine-source-20260928/original.pdf',
|
||||
'file_name' => 'INE-Codificador-Divisao-Politico-Administrativa-2012.pdf',
|
||||
'expected_sha256' => '5c69b6f189a1d3f5307e0cf7dd8224554705158a682637d3105afad615db26e2', 'size_bytes' => 67067,
|
||||
'title' => 'Codificador da Divisão Político-Administrativa (INE, 07-06-2012)',
|
||||
'document_number' => 'Codificador DPA 2012', 'document_date' => '2012-06-07', 'issuer' => 'Instituto Nacional de Estatística (INE)',
|
||||
'language' => 'pt', 'version_label' => 'Edição 2012; actualidade das designações por confirmar',
|
||||
'topic' => 'território', 'category' => 'Dados de referência', 'document_type' => 'reference_data',
|
||||
'is_legislation' => false, 'legal_status' => 'unknown', 'source_confirmation' => 'official_confirmed',
|
||||
'source_url' => 'https://www.ine.gov.mz/documents/20119/44337/Codificador%20da%20Divisao%20Politico%20administrativa.pdf/14aed3d0-9296-c038-dbac-041a5cf40f1d',
|
||||
'source_reference' => 'PDF disponibilizado no portal oficial do INE; download efectuado em 2026-10-06, 67.067 bytes. SHA-256 coincidente com o original privado e com a cópia oficial obtida no portal INE. A data impressa é 07-06-2012; a vigência territorial actual e os direitos de reutilização do ficheiro permanecem por confirmar.',
|
||||
'description' => 'Documento de referência com códigos e designações político-administrativas na edição indicada. A extracção anterior encontrou colunas com alinhamento ambíguo, sem autorizar correspondências automáticas de postos e distritos; não usar esta edição como catálogo territorial actual nem como fonte de relações não revistas.',
|
||||
'territorial_scope' => 'national', 'associations' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class ImportTerritoryWorkbook extends Command
|
||||
{
|
||||
protected $signature = 'geo:import-workbook {--inspect : Inspect the authorised workbook without database writes} {--python=python : Python executable}';
|
||||
|
||||
protected $description = 'Inspect and idempotently import the authorised cartography workbook, preserving existing IDs';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! app()->environment('local', 'testing')) {
|
||||
$this->error('Local/testing only.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
foreach ([[strval($this->option('python')), base_path('tools/prepare-territory-workbook.py')],
|
||||
...($this->option('inspect') ? [] : [[PHP_BINARY, base_path('tools/import-territory-workbook.php')]])] as $command) {
|
||||
$process = new Process($command, base_path());
|
||||
$process->setTimeout(300);
|
||||
$process->run();
|
||||
$this->output->write($process->getOutput());
|
||||
if (! $process->isSuccessful()) {
|
||||
$this->error($process->getErrorOutput());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class ReconcileMneCompatibility extends Command
|
||||
{
|
||||
protected $signature = 'mne:reconcile-compatibility {--fail-on-difference : Return failure while any reconciliation or activation gate remains open}';
|
||||
|
||||
protected $description = 'Read-only compatibility counts, fingerprints and activation blockers';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$report = DB::transaction(function (): array {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
|
||||
$domains = [];
|
||||
foreach (['identity' => ['users', 'identity_links', 'user_id'], 'sector' => ['sectors', 'sector_links', 'sector_id'],
|
||||
'business' => ['existing_businesses', 'business_links', 'legacy_business_id']] as $domain => [$source, $target, $key]) {
|
||||
$checksums = [];
|
||||
$stale = 0;
|
||||
$missing = 0;
|
||||
foreach (DB::table('public.'.$source)->orderBy('id')->cursor() as $row) {
|
||||
$checksum = hash('sha256', json_encode((array) $row, JSON_THROW_ON_ERROR));
|
||||
$checksums[] = $checksum;
|
||||
$link = DB::table('mne.'.$target)->where($key, $row->id)->first();
|
||||
$missing += $link === null ? 1 : 0;
|
||||
$stale += $link !== null && $link->source_checksum !== $checksum ? 1 : 0;
|
||||
}
|
||||
$domains[$domain] = ['source_count' => count($checksums), 'link_count' => DB::table('mne.'.$target)->count(),
|
||||
'pending_count' => DB::table('mne.'.$target)->where('status', '<>', 'active')->count(),
|
||||
'missing' => $missing, 'stale_checksums' => $stale, 'checksum' => hash('sha256', implode('', $checksums))];
|
||||
}
|
||||
$role = DB::selectOne('SELECT rolsuper,rolcreaterole FROM pg_roles WHERE rolname=current_user');
|
||||
|
||||
return ['domains' => $domains, 'unresolved_rejections' => DB::table('mne.compatibility_rejections')->whereNull('resolved_at')->count(),
|
||||
'runtime_is_superuser' => (bool) $role->rolsuper, 'runtime_can_create_roles' => (bool) $role->rolcreaterole,
|
||||
'canonical_reads_ready' => false, 'canonical_writes_ready' => false,
|
||||
'blockers' => ['Full canonical response mapping and authorization parity not certified',
|
||||
'Legacy intake does not capture a sector', 'Runtime privilege separation requires verification with deployment credentials']];
|
||||
});
|
||||
$this->line(json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
|
||||
// This release has no certified canonical adapter: strict gate checks cannot succeed yet.
|
||||
return $this->option('fail-on-difference') ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\InvestmentLegalSource;
|
||||
use App\Models\User;
|
||||
use App\Services\SimulationTaxParameterResolver;
|
||||
use App\Services\SimulationTaxRuleExpression;
|
||||
use App\Services\SimulationTaxRuleGovernance;
|
||||
use App\Support\IsolatedQaDatabase;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/** Explicit registration: never run during migration, deployment or document import. */
|
||||
class RegisterFiscalQaDraft extends Command
|
||||
{
|
||||
protected $signature = 'fiscal:qa-register-iva {actor : Existing QA editor ID} {source : Existing accepted legal source UUID}';
|
||||
|
||||
protected $description = 'Cadastrar apenas o rascunho IVA do primeiro lote numa base QA isolada, sem publicação';
|
||||
|
||||
public function handle(SimulationTaxRuleGovernance $governance): int
|
||||
{
|
||||
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 403, 'O cadastro deste lote só é permitido na base QA isolada.');
|
||||
$actor = User::findOrFail($this->argument('actor'));
|
||||
$source = InvestmentLegalSource::findOrFail($this->argument('source'));
|
||||
abort_unless($source->validation_status === 'validated' && $source->diploma === 'Lei 22/2022'
|
||||
&& $source->article === 'Artigo 17(1)', 422, 'Seleccione a referência aceite da taxa geral, Lei 22/2022, artigo 17(1).');
|
||||
$payload = json_decode(file_get_contents(database_path('qa/iva-general-net-v1.json')), true, flags: JSON_THROW_ON_ERROR);
|
||||
$actualHash = hash_file('sha256', base_path('docs/investmoz/simulations/source-archive/Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf'));
|
||||
abort_unless(hash_equals($payload['source_sha256'], $actualHash), 422, 'O exemplar não corresponde ao hash da ficha.');
|
||||
$payload['legal_source_id'] = (string) $source->id;
|
||||
app(SimulationTaxRuleExpression::class)->validate($payload['rule_definition']);
|
||||
app(SimulationTaxParameterResolver::class)->validateParameters($payload['parameters']);
|
||||
$existing = $governance->listing($actor, ['rule_code' => $payload['rule_code']])->getCollection()
|
||||
->firstWhere('rule_version', 1);
|
||||
if ($existing) {
|
||||
foreach ($payload as $field => $value) {
|
||||
$stored = $existing->attributesToArray()[$field] ?? null;
|
||||
abort_unless($stored == $value, 409, 'O cadastro existente difere desta ficha; não será sobrescrito.');
|
||||
}
|
||||
abort_unless($existing->status === 'draft', 409, 'A versão existente já não é um rascunho.');
|
||||
$this->line(json_encode(['id' => $existing->id, 'status' => 'draft', 'reused' => true], JSON_THROW_ON_ERROR));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
$rule = $governance->create($actor, $payload);
|
||||
$this->line(json_encode(['id' => $rule->id, 'status' => $rule->status, 'reused' => false], JSON_THROW_ON_ERROR));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ScanPendingDocuments extends Command
|
||||
{
|
||||
protected $signature = 'documents:scan-pending {--apply : Dispatch scans to the queue} {--limit=100 : Maximum documents to process in this batch}';
|
||||
|
||||
protected $description = 'List or dispatch a controlled batch of document malware scans';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$limit = max(1, min(1000, (int) $this->option('limit')));
|
||||
$ids = DB::table('documents')->whereNull('deleted_at')->where('status', '!=', 'deleted')
|
||||
->where(function ($query): void {
|
||||
$query->whereIn('malware_scan_status', ['pending', 'inconclusive', 'failed'])
|
||||
->orWhere(fn ($stale) => $stale->where('malware_scan_status', 'scanning')->where('updated_at', '<', now()->subMinutes(15)));
|
||||
})->orderBy('created_at')->limit($limit)->pluck('id');
|
||||
|
||||
$this->info(count($ids).' document(s) eligible for this batch.');
|
||||
if (! $this->option('apply')) {
|
||||
$this->comment('Dry run only. Add --apply to dispatch scans.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if (DB::table('documents')->where('id', $id)->where('malware_scan_status', 'scanning')->exists()) {
|
||||
DB::table('documents')->where('id', $id)->update(['malware_scan_status' => 'pending', 'status' => 'quarantined', 'updated_at' => now()]);
|
||||
}
|
||||
ScanDocumentForMalware::dispatch((string) $id);
|
||||
}
|
||||
|
||||
$this->info('Queued '.$ids->count().' scan(s). No editorial publication state was changed.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Interests\Enums;
|
||||
|
||||
enum InterestStatus: string
|
||||
{
|
||||
case Received = 'received';
|
||||
case Submitted = 'submitted';
|
||||
case InReview = 'in_review';
|
||||
case Reviewed = 'reviewed';
|
||||
case Qualification = 'qualification';
|
||||
case Qualified = 'qualified';
|
||||
case Contacted = 'contacted';
|
||||
case ContactAccepted = 'contact_accepted';
|
||||
case Referred = 'referred';
|
||||
case DueDiligence = 'due_diligence';
|
||||
case Negotiation = 'negotiation';
|
||||
case Pending = 'pending';
|
||||
case Refused = 'refused';
|
||||
case Converted = 'converted';
|
||||
case Cancelled = 'cancelled';
|
||||
case Closed = 'closed';
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Opportunities\Enums;
|
||||
|
||||
enum OpportunityStatus: string
|
||||
{
|
||||
case Draft = 'draft';
|
||||
case Submitted = 'submitted';
|
||||
case Triage = 'triage';
|
||||
case InformationPending = 'information_pending';
|
||||
case InReview = 'in_review';
|
||||
case Review = 'review';
|
||||
case ApprovalL1 = 'approval_l1';
|
||||
case ApprovalL2 = 'approval_l2';
|
||||
case Approved = 'approved';
|
||||
case Published = 'published';
|
||||
case NeedsInformation = 'needs_information';
|
||||
case Suspended = 'suspended';
|
||||
case Rejected = 'rejected';
|
||||
case Withdrawn = 'withdrawn';
|
||||
case Cancelled = 'cancelled';
|
||||
case Archived = 'archived';
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\AiRun;
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\Ai\AiGateway;
|
||||
@@ -25,6 +26,7 @@ final class AiController extends Controller
|
||||
'controls' => DB::table('ai_controls')->orderBy('capability')->get(),
|
||||
'sources' => DB::table('ai_sources')->orderByDesc('created_at')->limit(50)->get(['id', 'title', 'language', 'status', 'nature', 'valid_until', 'submitted_by']),
|
||||
'documents' => DB::table('documents')->where('status', 'active')->whereNull('deleted_at')->where('media_type', 'text/plain')
|
||||
->where('malware_scan_status', 'clean')->whereColumn('malware_scanned_sha256', 'sha256')
|
||||
->whereIn('data_classification', ['public', 'internal'])->limit(100)->get(['id', 'file_name']),
|
||||
'runs' => AiRun::orderByDesc('created_at')->limit(20)->get(['id', 'capability', 'mode', 'review_status', 'review_requested', 'created_at']),
|
||||
'today' => now()->toDateString(),
|
||||
@@ -118,7 +120,8 @@ final class AiController extends Controller
|
||||
DB::transaction(function () use ($request, $file, $text, $id, $key): void {
|
||||
DB::table('documents')->insert(['id' => $id, 'file_name' => basename($file->getClientOriginalName()),
|
||||
'media_type' => 'text/plain', 'storage_key' => $key, 'sha256' => hash('sha256', $text),
|
||||
'status' => 'active', 'data_classification' => 'internal']);
|
||||
'size_bytes' => strlen($text), 'uploaded_by' => $request->user()->id,
|
||||
'status' => 'quarantined', 'data_classification' => 'internal']);
|
||||
$this->audit('documents', $id, 'plain_text_registered', $request->user()->id);
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
@@ -126,6 +129,8 @@ final class AiController extends Controller
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
ScanDocumentForMalware::dispatch($id)->afterCommit();
|
||||
|
||||
return $request->expectsJson() ? response()->json(['document_id' => $id], 201)
|
||||
: back()->with('success', 'Texto guardado no cadastro documental. Registe a fonte e solicite revisão antes de o pesquisar.');
|
||||
}
|
||||
@@ -140,6 +145,7 @@ final class AiController extends Controller
|
||||
if ($data['status'] === 'approved') {
|
||||
abort_if((int) $source->submitted_by === $request->user()->id, 403, 'Outra pessoa deve validar a autorização.');
|
||||
$valid = DB::table('documents')->where('id', $source->document_id)->where('status', 'active')
|
||||
->where('malware_scan_status', 'clean')->whereColumn('malware_scanned_sha256', 'sha256')
|
||||
->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])
|
||||
->where('version_no', $source->document_version)->where('sha256', $source->document_checksum)->exists();
|
||||
abort_unless($valid && $source->valid_until >= now()->toDateString(), 422, 'Fonte alterada, expirada ou sem autorização.');
|
||||
|
||||
@@ -20,10 +20,8 @@ class HomeController extends Controller
|
||||
|
||||
$locale = $validated['locale'] ?? 'pt';
|
||||
|
||||
$data = Cache::remember("portal_home_v2_{$locale}", now()->addMinutes(10), function () {
|
||||
$sectors = Sector::withCount([
|
||||
'opportunities as opportunities_count' => fn ($q) => $q->published(),
|
||||
])->where('is_active', true)->orderBy('sort_order')->get();
|
||||
$data = Cache::remember("portal_home_sector_organization_v1_{$locale}", now()->addMinutes(10), function () {
|
||||
$sectors = \App\Services\SectorOrganization::catalog();
|
||||
|
||||
$featured = Opportunity::with('sector:id,name,slug')
|
||||
->published()->where('is_featured', true)
|
||||
@@ -31,14 +29,14 @@ class HomeController extends Controller
|
||||
|
||||
return [
|
||||
'sectors' => $sectors->map(fn ($s) => [
|
||||
'name' => $s->name,
|
||||
'id' => $s->id, 'specializations' => $s->specializations, 'name' => $s->name,
|
||||
'slug' => $s->slug,
|
||||
'shortDescription' => $s->short_description,
|
||||
'opportunitiesCount' => $s->opportunities_count,
|
||||
'url' => "/sectores/{$s->slug}",
|
||||
])->values()->all(),
|
||||
'featuredOpportunities' => $featured->map(fn ($o) => [
|
||||
'reference' => $o->reference,
|
||||
'id' => $o->id, 'reference' => $o->reference,
|
||||
'title' => $o->title,
|
||||
'sector' => $o->sector?->name,
|
||||
'province' => $o->province,
|
||||
@@ -51,7 +49,7 @@ class HomeController extends Controller
|
||||
])->values()->all(),
|
||||
'stats' => [
|
||||
'opportunities' => Opportunity::published()->count(),
|
||||
'sectors' => Sector::where('is_active', true)->count(),
|
||||
'sectors' => $sectors->count(),
|
||||
'provinces' => Opportunity::published()->distinct('province')->count('province'),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Domain\Interests\Enums\InterestStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreInterestRequestRequest;
|
||||
use App\Jobs\NotifyInterestReceived;
|
||||
use App\Models\ExistingBusiness;
|
||||
use App\Models\InterestRequest;
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\IdempotencyConflict;
|
||||
use App\Services\InterestSubmission;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
@@ -20,8 +23,9 @@ class InterestRequestController extends Controller
|
||||
{
|
||||
public function store(StoreInterestRequestRequest $request, InterestSubmission $submission): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$validated = [...$request->validated(), '_actor_id' => $request->user()->id];
|
||||
$key = $request->header('Idempotency-Key');
|
||||
abort_unless(is_string($key), 422, 'Identificador do envio em falta.');
|
||||
Validator::make(['idempotency_key' => $key], ['idempotency_key' => [$key === null ? 'nullable' : 'required', 'uuid']])->validate();
|
||||
|
||||
try {
|
||||
@@ -33,21 +37,31 @@ class InterestRequestController extends Controller
|
||||
throw ValidationException::withMessages(['opportunityReference' => 'A oportunidade seleccionada já não está disponível.']);
|
||||
}
|
||||
|
||||
$business = ! empty($validated['existingBusinessId'])
|
||||
? ExistingBusiness::published()->whereKey($validated['existingBusinessId'])->first()
|
||||
: null;
|
||||
if (! empty($validated['existingBusinessId']) && ! $business) {
|
||||
throw ValidationException::withMessages(['existingBusinessId' => 'A oportunidade seleccionada já não está disponível.']);
|
||||
}
|
||||
|
||||
$interest = InterestRequest::create([
|
||||
'opportunity_id' => $opportunity?->id,
|
||||
'existing_business_id' => $business?->id,
|
||||
'modality' => $business ? $validated['modality'] : null,
|
||||
'reference' => $this->generateReference(),
|
||||
'full_name' => trim($validated['fullName']),
|
||||
'email' => strtolower(trim($validated['email'])),
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'investor_type' => $validated['investorType'],
|
||||
'country' => $validated['country'],
|
||||
'metadata' => ['portal_user_id' => $request->user()->id, 'declared_origin_country_code' => $validated['declared_origin_country_code'] ?? null],
|
||||
'sector_interest' => $validated['sectorInterest'],
|
||||
'opportunity_reference' => $validated['opportunityReference'] ?? null,
|
||||
'investment_range' => $validated['investmentRange'] ?? null,
|
||||
'message' => trim($validated['message']),
|
||||
'privacy_consent' => (bool) $validated['privacyConsent'],
|
||||
'consented_at' => now(),
|
||||
'status' => 'received',
|
||||
'status' => InterestStatus::Received->value,
|
||||
'source_page' => Str::limit($request->headers->get('referer', ''), 255, ''),
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
@@ -64,6 +78,19 @@ class InterestRequestController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$row = \App\Models\InvestmentSubmission::create([
|
||||
'user_id' => $request->user()->id, 'opportunity_id' => $opportunity?->id,
|
||||
'interest_request_id' => $interest->id, 'status' => 'submitted', 'version' => 1,
|
||||
'payload' => ['simple_participation' => true, 'opportunity_title' => $opportunity?->title ?? $business?->title, 'opportunity_reference' => $opportunity?->reference, 'phone' => $validated['phone'] ?? null, 'declared_origin_country_code' => $validated['declared_origin_country_code'] ?? null, 'full_name' => $validated['fullName'], 'email' => $validated['email'],
|
||||
'objective' => $validated['message'], 'sector' => $validated['sectorInterest'],
|
||||
'country' => $validated['country'], 'existing_business_id' => $business?->id,
|
||||
'modality' => $validated['modality'] ?? null],
|
||||
]);
|
||||
\Illuminate\Support\Facades\DB::table('investment_submission_events')->insert([
|
||||
'submission_id' => $row->id, 'actor_id' => $request->user()->id,
|
||||
'action' => 'submitted', 'version' => 1, 'created_at' => now(),
|
||||
]);
|
||||
$interest->forceFill(['metadata' => [...$interest->metadata, 'submission_id' => $row->id]])->save();
|
||||
return $interest;
|
||||
});
|
||||
|
||||
@@ -72,12 +99,19 @@ class InterestRequestController extends Controller
|
||||
'message' => 'Manifestação de interesse registada com sucesso.',
|
||||
'data' => [
|
||||
'reference' => $interestRequest->reference,
|
||||
'status' => 'received',
|
||||
'status' => $interestRequest->status,
|
||||
'tracking_url' => '/investidor/manifestacoes',
|
||||
],
|
||||
], 201);
|
||||
} catch (IdempotencyConflict) {
|
||||
return response()->json(['success' => false, 'message' => 'A chave de envio já foi usada com dados diferentes.'], 409);
|
||||
} catch (ValidationException $exception) {
|
||||
throw $exception;
|
||||
} catch (QueryException $exception) {
|
||||
if ($exception->getCode() === '23505') {
|
||||
return response()->json(['success' => false, 'message' => 'Já existe uma manifestação activa para esta oportunidade e modalidade.'], 409);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
} catch (\Throwable $exception) {
|
||||
Log::error('Erro ao registar manifestação de interesse', [
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\InvestmentLegalSource;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LegislationSourceController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$filters = $request->validate([
|
||||
'q' => ['nullable', 'string', 'max:100'],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
]);
|
||||
|
||||
$today = today()->toDateString();
|
||||
$query = InvestmentLegalSource::query()
|
||||
->where('validation_status', 'validated')
|
||||
->whereDate('valid_from', '<=', $today)
|
||||
->where(fn ($query) => $query->whereNull('valid_until')->orWhereDate('valid_until', '>=', $today));
|
||||
|
||||
if (! empty($filters['q'])) {
|
||||
$term = '%'.addcslashes($filters['q'], '%_\\').'%';
|
||||
$query->where(fn ($query) => $query
|
||||
->whereRaw('title ILIKE ?', [$term])
|
||||
->orWhereRaw('diploma ILIKE ?', [$term])
|
||||
->orWhereRaw('article ILIKE ?', [$term])
|
||||
->orWhereRaw('competent_entity ILIKE ?', [$term]));
|
||||
}
|
||||
|
||||
$page = $query->orderBy('title')->orderBy('id')->paginate($filters['per_page'] ?? 25)->withQueryString();
|
||||
$data = collect($page->items())->map(fn (InvestmentLegalSource $source): array => [
|
||||
'id' => $source->id,
|
||||
'title' => $source->title,
|
||||
'url' => $source->url,
|
||||
'diploma' => $source->diploma,
|
||||
'article' => $source->article,
|
||||
'competent_entity' => $source->competent_entity,
|
||||
'validation_status' => $source->validation_status,
|
||||
'version_no' => (int) $source->version_no,
|
||||
'valid_from' => $source->valid_from,
|
||||
'valid_until' => $source->valid_until,
|
||||
'validator_id' => $source->validator_id === null ? null : (int) $source->validator_id,
|
||||
'validated_at' => $source->validated_at,
|
||||
])->values();
|
||||
$body = [
|
||||
'data' => $data,
|
||||
'meta' => [
|
||||
'current_page' => $page->currentPage(),
|
||||
'per_page' => $page->perPage(),
|
||||
'total' => $page->total(),
|
||||
],
|
||||
];
|
||||
$etag = 'W/"legal-sources-'.hash('sha256', json_encode([
|
||||
'filters' => $filters,
|
||||
'data' => $data,
|
||||
'total' => $page->total(),
|
||||
], JSON_THROW_ON_ERROR)).'"';
|
||||
|
||||
return response()->json($body)
|
||||
->header('ETag', $etag)
|
||||
->header('Cache-Control', 'public, max-age=300');
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ class OpportunityController extends Controller
|
||||
$term = $validated['q'];
|
||||
$query->where(fn ($q) => $q
|
||||
->whereLike('title', "%{$term}%")
|
||||
->orWhereLike('description', "%{$term}%")
|
||||
->orWhereLike('summary', "%{$term}%")
|
||||
->orWhereLike('reference', "%{$term}%")
|
||||
->orWhereLike('province', "%{$term}%")
|
||||
@@ -46,7 +47,7 @@ class OpportunityController extends Controller
|
||||
|
||||
if (! empty($validated['sector'])) {
|
||||
$sector = $validated['sector'];
|
||||
$query->whereHas('sector', fn ($q) => $q->where('slug', $sector));
|
||||
\App\Services\SectorOrganization::constrain($query, $sector);
|
||||
}
|
||||
|
||||
if (! empty($validated['province'])) {
|
||||
@@ -54,7 +55,7 @@ class OpportunityController extends Controller
|
||||
}
|
||||
|
||||
if (! empty($validated['maturity_level'])) {
|
||||
$query->where('maturity_level', $validated['maturity_level']);
|
||||
\App\Services\OpportunityMaturity::constrainSearch($query, 'historical:'.$validated['maturity_level']);
|
||||
}
|
||||
|
||||
$results = $query->orderByDesc('is_featured')
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\CountryCatalogue;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ReferenceCatalogController extends Controller
|
||||
{
|
||||
private const CATALOGS = ['authorities', 'jurisdictions', 'economic-activities', 'countries'];
|
||||
|
||||
public function index(Request $request, string $catalog): JsonResponse
|
||||
{
|
||||
$catalog = $this->validatedCatalog($catalog);
|
||||
if ($catalog === 'countries') {
|
||||
return $this->countries($request);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'q' => ['nullable', 'string', 'max:100'],
|
||||
'version' => ['nullable', 'integer', 'min:1'],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
'parent_id' => ['nullable', 'uuid'],
|
||||
'jurisdiction_id' => ['nullable', 'uuid'],
|
||||
];
|
||||
if ($catalog === 'jurisdictions') {
|
||||
$rules['type'] = ['required', 'in:country,province,district,admin_post,locality'];
|
||||
$rules['parent_id'][] = 'prohibited_if:type,country';
|
||||
$rules['parent_id'][] = 'required_if:type,district,admin_post,locality';
|
||||
}
|
||||
if ($catalog === 'economic-activities') {
|
||||
$rules['sector_id'] = ['nullable', 'integer', 'exists:sectors,id'];
|
||||
$rules['origin'] = ['nullable', 'in:portal,cae'];
|
||||
}
|
||||
$data = $request->validate($rules);
|
||||
|
||||
$cae = $catalog === 'economic-activities' && ($data['origin'] ?? 'portal') === 'cae';
|
||||
$versionQuery = DB::table('reference_catalog_versions')->where('catalog_code', $cae ? 'economic-activities-cae' : $catalog)->where('locale', 'pt-MZ');
|
||||
if ($cae && ! app(\App\Services\EconomicReferenceCatalog::class)->available()) { $versionQuery->whereRaw('false'); }
|
||||
if (isset($data['version'])) {
|
||||
$versionQuery->where('version_no', $data['version'])->whereIn('status', ['published', 'retired']);
|
||||
} else {
|
||||
$versionQuery->where('status', 'published')
|
||||
->where(fn (Builder $query) => $query->whereNull('valid_from')->orWhere('valid_from', '<=', today()->toDateString()))
|
||||
->where(fn (Builder $query) => $query->whereNull('valid_to')->orWhere('valid_to', '>', today()->toDateString()));
|
||||
}
|
||||
$version = $versionQuery->orderByDesc('version_no')->first();
|
||||
|
||||
if (isset($data['version']) && ! $version) {
|
||||
abort(404, 'Versão publicada do catálogo não encontrada.');
|
||||
}
|
||||
|
||||
if (! $version) {
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'links' => ['next' => null, 'previous' => null],
|
||||
'next_page_url' => null,
|
||||
'prev_page_url' => null,
|
||||
'current_page' => 1,
|
||||
'per_page' => $data['per_page'] ?? 20,
|
||||
'total' => 0,
|
||||
'last_page' => 1,
|
||||
'meta' => ['catalog' => $catalog, 'version' => null, 'locale' => 'pt-MZ', 'status' => 'not_published',
|
||||
'current_page' => 1, 'per_page' => $data['per_page'] ?? 20, 'total' => 0, 'last_page' => 1],
|
||||
])->header('Cache-Control', 'public, max-age=60');
|
||||
}
|
||||
|
||||
$query = match ($catalog) {
|
||||
'authorities' => $this->authorities((string) $version->id, $data),
|
||||
'jurisdictions' => $this->jurisdictions($data),
|
||||
'economic-activities' => $this->economicActivities((string) $version->id, $data),
|
||||
};
|
||||
|
||||
$page = $query->orderBy('code')->orderBy('id')->paginate($data['per_page'] ?? 20)->withQueryString();
|
||||
$items = collect($page->items())->map(fn (object $row): array => $this->present($catalog, (array) $row))->values();
|
||||
if ($cae) {
|
||||
$paths = app(\App\Services\EconomicReferenceCatalog::class)->activities()->keyBy('id');
|
||||
$items = $items->map(fn ($row) => [...$row, 'level' => $paths[$row['id']]->level, 'path' => $paths[$row['id']]->path, 'source_version' => $paths[$row['id']]->source_version, 'edition' => 'not_determined', 'divergence' => '600/579 documentalmente aberta']);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $items,
|
||||
'links' => ['next' => $page->nextPageUrl(), 'previous' => $page->previousPageUrl()],
|
||||
'next_page_url' => $page->nextPageUrl(),
|
||||
'prev_page_url' => $page->previousPageUrl(),
|
||||
'current_page' => $page->currentPage(),
|
||||
'per_page' => $page->perPage(),
|
||||
'total' => $page->total(),
|
||||
'last_page' => $page->lastPage(),
|
||||
'meta' => ['catalog' => $catalog, 'version' => (int) $version->version_no, 'locale' => $version->locale,
|
||||
'source_version' => $version->source_version, 'valid_from' => $version->valid_from, 'valid_to' => $version->valid_to,
|
||||
'current_page' => $page->currentPage(), 'per_page' => $page->perPage(), 'total' => $page->total(), 'last_page' => $page->lastPage()],
|
||||
])->header('Cache-Control', 'public, max-age=300')
|
||||
->header('ETag', 'W/"catalog-'.substr(hash('sha256', $catalog.'|'.$version->version_no.'|'.json_encode($data, JSON_THROW_ON_ERROR)), 0, 32).'"');
|
||||
}
|
||||
|
||||
private function countries(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['q' => 'nullable|string|max:100', 'page' => 'nullable|integer|min:1', 'per_page' => 'nullable|integer|min:1|max:100', 'parent_id' => 'prohibited', 'version' => 'prohibited']);
|
||||
$term = strtolower(Str::ascii(trim($data['q'] ?? '')));
|
||||
$items = collect(app(CountryCatalogue::class)->all())
|
||||
->filter(fn (array $country) => $term === '' || str_contains(strtolower(Str::ascii(implode(' ', $country))), $term))
|
||||
->values()->map(fn (array $country) => ['id' => $country['code'], 'value' => $country['code'], 'code' => $country['code'], 'name' => $country['name'], 'label' => $country['name']]);
|
||||
$page = (int) ($data['page'] ?? 1);
|
||||
$perPage = (int) ($data['per_page'] ?? 50);
|
||||
$result = new LengthAwarePaginator($items->forPage($page, $perPage)->values(), $items->count(), $perPage, $page, ['path' => $request->url(), 'query' => $request->query()]);
|
||||
|
||||
return response()->json([...$result->toArray(), 'meta' => ['catalog' => 'countries', 'source' => 'Local ISO 3166 catalogue', 'sha256' => hash_file('sha256', resource_path('data/iso3166.tab'))]])
|
||||
->header('Cache-Control', 'public, max-age=300');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
private function authorities(string $versionId, array $data): Builder
|
||||
{
|
||||
$query = DB::table('authorities as a')->join('organizations as o', 'o.id', '=', 'a.organization_id')
|
||||
->leftJoin('geo_locations as g', 'g.id', '=', 'a.jurisdiction_id')
|
||||
->where('a.catalog_version_id', $versionId)->where('a.status', 'published')
|
||||
->where('o.data_classification', 'public')->whereNull('o.deleted_at')
|
||||
->where('a.valid_from', '<=', today()->toDateString())
|
||||
->where(fn (Builder $builder) => $builder->whereNull('a.valid_to')->orWhere('a.valid_to', '>', today()->toDateString()))
|
||||
->select(['a.id', 'a.code', 'a.authority_type', 'a.label_pt_mz as name', 'a.organization_id', 'a.jurisdiction_id',
|
||||
'g.name as jurisdiction_name', 'a.version_no']);
|
||||
if (! empty($data['jurisdiction_id'])) {
|
||||
$query->where('a.jurisdiction_id', $data['jurisdiction_id']);
|
||||
}
|
||||
if (! empty($data['q'])) {
|
||||
$this->search($query, $data['q'], ['a.code', 'a.label_pt_mz', 'o.name']);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
private function jurisdictions(array $data): Builder
|
||||
{
|
||||
$query = DB::table('geo_locations')->where('source_system', 'cartografia-workbook')
|
||||
->where('data_classification', 'public')->whereNull('deleted_at')->where('location_type', $data['type'])
|
||||
->select(['id', 'code', 'name', 'parent_id', 'location_type', 'source_code', 'version_no']);
|
||||
$parentType = ['province' => 'country', 'district' => 'province', 'admin_post' => 'district', 'locality' => 'admin_post'][$data['type']] ?? null;
|
||||
if ($data['type'] !== 'country' && ! empty($data['parent_id'])) {
|
||||
$validParent = DB::table('geo_locations')->where('id', $data['parent_id'])->where('location_type', $parentType)
|
||||
->where('source_system', 'cartografia-workbook')->where('data_classification', 'public')->whereNull('deleted_at')->exists();
|
||||
if (! $validParent) {
|
||||
throw ValidationException::withMessages(['parent_id' => 'Seleccione um território pai activo do nível anterior.']);
|
||||
}
|
||||
$query->where('parent_id', $data['parent_id']);
|
||||
} elseif (in_array($data['type'], ['district', 'admin_post', 'locality'], true)) {
|
||||
throw ValidationException::withMessages(['parent_id' => 'Seleccione primeiro o território pai.']);
|
||||
}
|
||||
if (! empty($data['q'])) {
|
||||
$this->search($query, $data['q'], ['code', 'name', 'source_code']);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
private function economicActivities(string $versionId, array $data): Builder
|
||||
{
|
||||
$query = DB::table('economic_activities')->where('catalog_version_id', $versionId)->where('status', 'published')
|
||||
->where('valid_from', '<=', today()->toDateString())
|
||||
->where(fn (Builder $builder) => $builder->whereNull('valid_to')->orWhere('valid_to', '>', today()->toDateString()))
|
||||
->select(['id', 'code', 'label_pt_mz as name', 'parent_id', 'sector_id', 'version_no']);
|
||||
if (empty($data['version'])) $query->where('id', '!=', \App\Services\SectorOrganization::LEGACY_ACTIVITY_ID);
|
||||
if (array_key_exists('parent_id', $data) && $data['parent_id'] !== null) {
|
||||
$query->where('parent_id', $data['parent_id']);
|
||||
} else {
|
||||
$query->whereNull('parent_id');
|
||||
}
|
||||
if (! empty($data['sector_id'])) {
|
||||
$sector = \App\Models\Sector::find($data['sector_id']);
|
||||
$query->whereIn('sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
|
||||
}
|
||||
if (! empty($data['q'])) {
|
||||
$this->search($query, $data['q'], ['code', 'label_pt_mz']);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/** @param list<literal-string> $columns */
|
||||
private function search(Builder $query, string $search, array $columns): void
|
||||
{
|
||||
$term = '%'.addcslashes($search, '%_\\').'%';
|
||||
$query->where(function (Builder $nested) use ($columns, $term): void {
|
||||
foreach ($columns as $index => $column) {
|
||||
if ($index === 0) {
|
||||
$nested->whereRaw($column.' ILIKE ?', [$term]);
|
||||
} else {
|
||||
$nested->orWhereRaw($column.' ILIKE ?', [$term]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @return 'authorities'|'jurisdictions'|'economic-activities'|'countries' */
|
||||
private function validatedCatalog(string $catalog): string
|
||||
{
|
||||
abort_unless(in_array($catalog, self::CATALOGS, true), 404);
|
||||
|
||||
return $catalog;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param 'authorities'|'jurisdictions'|'economic-activities' $catalog
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function present(string $catalog, array $row): array
|
||||
{
|
||||
return match ($catalog) {
|
||||
'jurisdictions' => ['id' => $row['id'], 'value' => $row['id'], 'code' => $row['code'], 'name' => $row['name'],
|
||||
'label' => $row['name'], 'locale' => 'pt-MZ', 'parent_id' => $row['parent_id'], 'type' => $row['location_type'],
|
||||
'source_code' => $row['source_code'], 'version_no' => $row['version_no']],
|
||||
'economic-activities' => ['id' => $row['id'], 'value' => $row['code'], 'code' => $row['code'], 'name' => $row['name'],
|
||||
'label' => $row['name'], 'locale' => 'pt-MZ', 'parent_id' => $row['parent_id'], 'sector_id' => $row['sector_id'],
|
||||
'version_no' => $row['version_no']],
|
||||
'authorities' => ['id' => $row['id'], 'value' => $row['code'], 'code' => $row['code'], 'name' => $row['name'],
|
||||
'label' => $row['name'], 'locale' => 'pt-MZ', 'authority_type' => $row['authority_type'],
|
||||
'organization_id' => $row['organization_id'], 'jurisdiction_id' => $row['jurisdiction_id'],
|
||||
'jurisdiction_name' => $row['jurisdiction_name'], 'version_no' => $row['version_no']],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -10,22 +10,12 @@ class SectorController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$sectors = Sector::withCount([
|
||||
'opportunities as opportunities_count' => fn ($q) => $q->published(),
|
||||
])
|
||||
->where('is_active', true)
|
||||
->orderBy('sort_order')
|
||||
->get()
|
||||
->map(fn ($s) => [
|
||||
'id' => $s->id,
|
||||
'name' => $s->name,
|
||||
'slug' => $s->slug,
|
||||
'shortDescription' => $s->short_description,
|
||||
'description' => $s->description,
|
||||
'icon' => $s->icon,
|
||||
'opportunitiesCount' => $s->opportunities_count,
|
||||
'url' => "/sectores/{$s->slug}",
|
||||
]);
|
||||
$sectors = \App\Services\SectorOrganization::catalog()->map(fn ($s) => [
|
||||
'id' => $s->id, 'name' => $s->name, 'slug' => $s->slug,
|
||||
'shortDescription' => $s->short_description, 'description' => $s->description,
|
||||
'icon' => $s->icon, 'opportunitiesCount' => $s->opportunities_count,
|
||||
'url' => $s->url, 'specializations' => $s->specializations,
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true, 'data' => $sectors]);
|
||||
}
|
||||
@@ -35,6 +25,7 @@ class SectorController extends Controller
|
||||
$sector = Sector::with(['opportunities' => fn ($q) => $q->published()])
|
||||
->where('slug', $slug)->firstOrFail();
|
||||
|
||||
return response()->json(['success' => true, 'data' => $sector]);
|
||||
$sector->setRelation('opportunities', \App\Services\SectorOrganization::opportunities($sector)->get());
|
||||
return response()->json(['success' => true, 'data' => $sector, 'canonical_url' => \App\Services\SectorOrganization::href($slug), 'specialization_of' => $slug === 'fintech' ? 'mercado-financeiro' : null]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreSimulationRequest;
|
||||
use App\Http\Requests\UpdateSimulationScenarioRequest;
|
||||
use App\Models\QuestionnaireVersion;
|
||||
use App\Models\Simulation;
|
||||
use App\Models\SimulationJob;
|
||||
use App\Policies\SimulationPolicy;
|
||||
use App\Services\SimulationExecution;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SimulationController extends Controller
|
||||
{
|
||||
public function questionnaire(string $code): JsonResponse
|
||||
{
|
||||
abort_unless(preg_match('/^[a-z0-9][a-z0-9-]{0,79}$/', $code) === 1, 404);
|
||||
$version = QuestionnaireVersion::query()->where('code', $code)->where('locale', 'pt-MZ')->where('status', 'published')
|
||||
->where(fn ($query) => $query->whereNull('valid_from')->orWhereDate('valid_from', '<=', today()))
|
||||
->where(fn ($query) => $query->whereNull('valid_to')->orWhereDate('valid_to', '>', today()))
|
||||
->orderByDesc('version_no')->firstOrFail();
|
||||
$etag = '"questionnaire-'.$version->id.'-v'.$version->version_no.'"';
|
||||
|
||||
return response()->json(['data' => $version])->header('ETag', $etag)->header('Cache-Control', 'private, max-age=60');
|
||||
}
|
||||
|
||||
public function store(StoreSimulationRequest $request, SimulationExecution $execution): JsonResponse
|
||||
{
|
||||
$key = $request->header('Idempotency-Key');
|
||||
abort_unless(is_string($key) && strlen($key) >= 16 && strlen($key) <= 128, 428, 'Idempotency-Key é obrigatório.');
|
||||
$actorId = $request->user()?->id;
|
||||
$token = $actorId === null ? $request->header('X-Simulation-Token') : null;
|
||||
abort_if($actorId === null && (! is_string($token) || strlen($token) < 32 || strlen($token) > 256), 422, 'X-Simulation-Token opaco gerado pelo cliente é obrigatório para execução anónima.');
|
||||
$validated = $request->validated();
|
||||
$organizationId = $validated['organization_id'] ?? null;
|
||||
$execution->authorizeOrganization($actorId, $organizationId);
|
||||
if (($validated['scenario_kind'] ?? null) === 'fiscal_rule_published') {
|
||||
$input = array_intersect_key($validated, array_flip(['base', 'currency', 'reference_date', 'operation', 'scope_declared', 'monthly_sales', 'sales_vat_basis']));
|
||||
$input['scope_declared'] = (bool) $input['scope_declared'];
|
||||
$result = $execution->createPublishedFiscal($input, $actorId, $organizationId, $token, $key);
|
||||
} else {
|
||||
$data = ['base' => (string) $validated['base'], 'rate' => (string) $validated['rate'],
|
||||
'reduction' => (string) ($validated['reduction'] ?? '0')];
|
||||
$result = $execution->create($data, $actorId, $organizationId, $token, $key);
|
||||
}
|
||||
$simulation = $result['simulation'];
|
||||
$body = ['data' => $simulation, 'replayed' => $result['replayed']];
|
||||
if ($actorId === null) {
|
||||
$body['access_token'] = $token;
|
||||
}
|
||||
|
||||
return response()->json($body, $result['replayed'] ? 200 : 202)
|
||||
->header('ETag', $execution->etag($simulation))
|
||||
->header('Location', url('/api/v1/simulations/'.$simulation->id));
|
||||
}
|
||||
|
||||
public function show(Request $request, string $id, SimulationExecution $execution): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$simulation = $execution->find($id, $request);
|
||||
if ($request->user()) {
|
||||
abort_unless(app(SimulationPolicy::class)->view($request->user(), $simulation), 404);
|
||||
} else {
|
||||
abort_unless($simulation->anonymous_token_hash !== null && hash_equals($simulation->anonymous_token_hash, (string) $execution->tokenHash($request)), 404);
|
||||
}
|
||||
|
||||
return response()->json(['data' => $simulation])->header('ETag', $execution->etag($simulation));
|
||||
}
|
||||
|
||||
public function history(Request $request, SimulationExecution $execution): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
abort_unless($user !== null, 401);
|
||||
abort_unless($user->hasVerifiedEmail(), 403, 'Verifique o correio electrónico para consultar o histórico.');
|
||||
$perPage = min(50, max(1, $request->integer('per_page', 20)));
|
||||
$page = max(1, $request->integer('page', 1));
|
||||
|
||||
$history = DB::transaction(function () use ($execution, $user, $perPage, $page) {
|
||||
$execution->setContext($user->id, null);
|
||||
|
||||
return Simulation::query()
|
||||
->select([
|
||||
'simulations.id', 'simulations.organization_id', 'visible_organization.name as organization_name',
|
||||
'simulations.status', 'simulations.version_no', 'simulations.created_at', 'simulations.updated_at',
|
||||
])
|
||||
->leftJoin('organizations as visible_organization', 'visible_organization.id', '=', 'simulations.organization_id')
|
||||
->where(function ($scope) use ($user) {
|
||||
$scope->where(fn ($personal) => $personal->whereNull('simulations.organization_id')->where('simulations.owner_user_id', $user->id))
|
||||
->orWhere(fn ($organization) => $organization->whereNotNull('simulations.organization_id')->whereExists(
|
||||
fn ($membership) => $membership->selectRaw('1')->from('memberships')
|
||||
->whereColumn('memberships.organization_id', 'simulations.organization_id')
|
||||
->where('memberships.user_id', $user->id)->whereNull('memberships.deleted_at'),
|
||||
));
|
||||
})
|
||||
->withExists(['reports as has_ready_report' => fn ($query) => $query->where('status', 'ready')])
|
||||
->orderByDesc('simulations.created_at')->orderByDesc('simulations.id')
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $history->getCollection()->map(fn (Simulation $simulation): array => [
|
||||
'id' => $simulation->id,
|
||||
'scope' => $simulation->organization_id === null ? 'personal' : 'organization',
|
||||
'organization_name' => $simulation->getAttribute('organization_name'),
|
||||
'status' => $simulation->status,
|
||||
'version_no' => $simulation->version_no,
|
||||
'created_at' => $simulation->created_at?->toISOString(),
|
||||
'updated_at' => $simulation->updated_at?->toISOString(),
|
||||
'has_ready_report' => (bool) $simulation->getAttribute('has_ready_report'),
|
||||
]),
|
||||
'meta' => [
|
||||
'current_page' => $history->currentPage(),
|
||||
'last_page' => $history->lastPage(),
|
||||
'per_page' => $history->perPage(),
|
||||
'total' => $history->total(),
|
||||
],
|
||||
])->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function updateScenario(UpdateSimulationScenarioRequest $request, string $id, string $scenarioId, SimulationExecution $execution): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id) && Str::isUuid($scenarioId), 404);
|
||||
$simulation = $execution->find($id, $request);
|
||||
if ($request->user()) {
|
||||
abort_unless(app(SimulationPolicy::class)->update($request->user(), $simulation), 403);
|
||||
} else {
|
||||
abort_unless($simulation->anonymous_token_hash !== null && hash_equals($simulation->anonymous_token_hash, (string) $execution->tokenHash($request)), 404);
|
||||
}
|
||||
$validated = $request->validated();
|
||||
$data = ['label' => (string) $validated['label'], 'base' => (string) $validated['base'], 'rate' => (string) $validated['rate'],
|
||||
'reduction' => (string) ($validated['reduction'] ?? '0')];
|
||||
$updated = $execution->updateScenario($id, $scenarioId, $data, $request);
|
||||
|
||||
return response()->json(['data' => $updated], 202)->header('ETag', $execution->etag($updated));
|
||||
}
|
||||
|
||||
public function report(Request $request, string $id, string $reportId, SimulationExecution $execution): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id) && Str::isUuid($reportId), 404);
|
||||
$simulation = $execution->find($id, $request);
|
||||
if ($request->user()) {
|
||||
abort_unless(app(SimulationPolicy::class)->view($request->user(), $simulation), 404);
|
||||
} else {
|
||||
abort_unless($simulation->anonymous_token_hash !== null && hash_equals($simulation->anonymous_token_hash, (string) $execution->tokenHash($request)), 404);
|
||||
}
|
||||
$report = $simulation->reports->firstWhere('id', $reportId);
|
||||
abort_if($report === null, 404);
|
||||
|
||||
return response()->json(['data' => $report]);
|
||||
}
|
||||
|
||||
public function jobs(Request $request, string $id, SimulationExecution $execution): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$simulation = $execution->find($id, $request);
|
||||
if ($request->user()) {
|
||||
abort_unless(app(SimulationPolicy::class)->view($request->user(), $simulation), 404);
|
||||
} else {
|
||||
abort_unless($simulation->anonymous_token_hash !== null && hash_equals($simulation->anonymous_token_hash, (string) $execution->tokenHash($request)), 404);
|
||||
}
|
||||
|
||||
$jobs = DB::transaction(function () use ($request, $id, $execution) {
|
||||
$execution->setContext($request->user()?->id, $execution->tokenHash($request));
|
||||
|
||||
return SimulationJob::query()->where('simulation_id', $id)
|
||||
->orderBy('created_at')->orderBy('id')->get();
|
||||
});
|
||||
|
||||
return response()->json(['data' => $jobs]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AttachSimulationToDraftRequest;
|
||||
use App\Http\Requests\ClaimSimulationFormDraftRequest;
|
||||
use App\Http\Requests\LinkSimulationDraftBusinessRequest;
|
||||
use App\Http\Requests\StoreSimulationFormDraftRequest;
|
||||
use App\Policies\SimulationPolicy;
|
||||
use App\Services\SimulationExecution;
|
||||
use App\Services\SimulationFormDrafts;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SimulationFormDraftController extends Controller
|
||||
{
|
||||
public function store(StoreSimulationFormDraftRequest $request, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
$key = $request->header('Idempotency-Key');
|
||||
abort_unless(is_string($key) && strlen($key) >= 16 && strlen($key) <= 128, 428, 'Idempotency-Key é obrigatório.');
|
||||
$result = $drafts->create($request->snapshots(), $request, $key);
|
||||
$draft = $result['draft'];
|
||||
|
||||
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no, 'replayed' => $result['replayed']], 'expires_at' => $draft->expires_at?->toISOString()], $result['replayed'] ? 200 : 201)
|
||||
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function index(Request $request, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless($request->user() !== null, 401);
|
||||
|
||||
return response()->json(['data' => $drafts->ownedDrafts($request->user())])->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function claimPreview(Request $request, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless($request->user() !== null, 401);
|
||||
|
||||
return response()->json(['data' => $drafts->claimPreview($request)])->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function claim(ClaimSimulationFormDraftRequest $request, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
$result = $drafts->claim($request, $request->validated('choice'), $request->validated('existing_draft_id'));
|
||||
$draft = $result['draft'];
|
||||
|
||||
return response()->json(['data' => $drafts->present($draft, true), 'choice' => $result['choice'], 'replayed' => $result['replayed'] ?? false, 'expires_at' => $draft->expires_at?->toISOString()])
|
||||
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function linkableBusinesses(Request $request, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless($request->user()?->hasVerifiedEmail(), 403);
|
||||
|
||||
return response()->json(['data' => $drafts->linkableBusinesses($request->user())])->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function businessLink(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id) && $request->user()?->hasVerifiedEmail(), 404);
|
||||
|
||||
return response()->json(['data' => $drafts->activeBusinessLink($request->user(), $id)])->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function linkBusiness(LinkSimulationDraftBusinessRequest $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$result = $drafts->linkBusiness($request->user(), $id, $request->validated('business_id'));
|
||||
|
||||
return response()->json(['data' => $result], isset($result['replayed']) ? 200 : 201)->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function unlinkBusiness(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id) && $request->user()?->hasVerifiedEmail(), 404);
|
||||
|
||||
return response()->json(['data' => $drafts->unlinkBusiness($request->user(), $id)])->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function current(Request $request, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
$draft = $drafts->current($request);
|
||||
if (! $draft) {
|
||||
return response()->json(['data' => null], 200)->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
return response()->json(['data' => $drafts->present($draft, $request->user() !== null)])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function forgetCurrent(Request $request, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
$drafts->forgetCurrent($request);
|
||||
|
||||
return response()->json([], 204)->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function show(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$draft = $drafts->find($id, $request);
|
||||
|
||||
return response()->json(['data' => $drafts->present($draft, $request->user() !== null)])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function update(StoreSimulationFormDraftRequest $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$draft = $drafts->update($id, $request->snapshots(), $request);
|
||||
|
||||
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no], 'expires_at' => $draft->expires_at?->toISOString()])
|
||||
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function renew(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$draft = $drafts->renew($id, $request);
|
||||
|
||||
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no], 'expires_at' => $draft->expires_at?->toISOString()])
|
||||
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function attachSimulation(AttachSimulationToDraftRequest $request, string $id, SimulationFormDrafts $drafts, SimulationExecution $execution): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$validated = $request->validated();
|
||||
$simulation = $execution->find($validated['simulation_id'], $request);
|
||||
$tokenOwned = $simulation->anonymous_token_hash !== null
|
||||
&& hash_equals($simulation->anonymous_token_hash, (string) $execution->tokenHash($request));
|
||||
if ($request->user()) {
|
||||
$policy = app(SimulationPolicy::class);
|
||||
$canTransfer = $simulation->organization_id === null
|
||||
? $policy->view($request->user(), $simulation)
|
||||
: $policy->update($request->user(), $simulation);
|
||||
abort_unless($canTransfer || $tokenOwned, 404);
|
||||
} else {
|
||||
abort_unless($tokenOwned, 404);
|
||||
}
|
||||
abort_unless($simulation->status === 'completed' && is_array($simulation->result_snapshot), 409, 'Só é possível reutilizar uma simulação concluída.');
|
||||
$report = $simulation->reports->where('status', 'ready')->sortByDesc('created_at')->first();
|
||||
abort_unless($report !== null && is_array($report->report_snapshot), 409, 'O relatório versionado da simulação ainda não está disponível.');
|
||||
abort_unless(preg_match('/^[a-f0-9]{64}$/D', $report->report_sha256) === 1, 409,
|
||||
'O relatório da simulação não tem um hash verificável.');
|
||||
$canonicalSnapshots = DB::selectOne('select CAST(? AS jsonb)::text as report_canonical, CAST(? AS jsonb)::text as result_canonical', [
|
||||
json_encode($report->report_snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
json_encode($simulation->result_snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
abort_unless(hash_equals($report->report_sha256, hash('sha256', $canonicalSnapshots->report_canonical))
|
||||
&& hash_equals($canonicalSnapshots->report_canonical, $canonicalSnapshots->result_canonical), 409,
|
||||
'O relatório não corresponde ao resultado versionado da simulação.');
|
||||
$inputSnapshot = array_intersect_key($simulation->input_snapshot, array_flip(['base', 'rate', 'reduction']));
|
||||
|
||||
$sourceSnapshot = [
|
||||
'simulation_id' => $simulation->id,
|
||||
'source_version_no' => $simulation->version_no,
|
||||
'source_created_at' => $simulation->created_at?->toISOString(),
|
||||
'source_updated_at' => $simulation->updated_at?->toISOString(),
|
||||
'input_snapshot' => $inputSnapshot,
|
||||
'calculation_snapshot' => $simulation->calculation_snapshot,
|
||||
'result_snapshot' => $simulation->result_snapshot,
|
||||
'result_sha256' => $report->report_sha256,
|
||||
'report_id' => $report->id,
|
||||
'report_version_no' => $report->version_no,
|
||||
'report_sha256' => $report->report_sha256,
|
||||
'report_snapshot' => $report->report_snapshot,
|
||||
'consented_at' => now()->toISOString(),
|
||||
];
|
||||
$attached = $drafts->attachSimulation($id, $sourceSnapshot, $request);
|
||||
$draft = $attached['draft'];
|
||||
|
||||
return response()->json([
|
||||
'data' => $drafts->present($draft, $request->user() !== null),
|
||||
'replayed' => $attached['replayed'],
|
||||
'expires_at' => $draft->expires_at?->toISOString(),
|
||||
])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$drafts->delete($id, $request);
|
||||
|
||||
return response()->json([], 204)->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ResolveSimulationTaxParametersRequest;
|
||||
use App\Services\SimulationTaxParameterResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class SimulationTaxParameterController extends Controller
|
||||
{
|
||||
public function resolve(ResolveSimulationTaxParametersRequest $request, SimulationTaxParameterResolver $resolver): JsonResponse
|
||||
{
|
||||
$input = $request->validated();
|
||||
$data = $resolver->resolvePublishedForPeriod(
|
||||
$input['component'],
|
||||
$input['period_start'],
|
||||
$input['period_end'],
|
||||
$input['context'] ?? [],
|
||||
$input['component'] === 'IVA',
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'component' => $input['component'],
|
||||
'period' => ['start' => $input['period_start'], 'end' => $input['period_end']],
|
||||
'assumptions' => $input['context'] ?? [],
|
||||
'parameters' => $input['component'] === 'IVA' ? $data['parameters'] : $data,
|
||||
'diagnostics' => $input['component'] === 'IVA' ? $data['diagnostics'] : [],
|
||||
])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\CorridorAgribusinessOpportunities;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\OpportunityPromotion;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
|
||||
final class CorridorAgribusinessController extends Controller
|
||||
{
|
||||
private function manifest(CorridorAgribusinessOpportunities $service): string
|
||||
{
|
||||
return hash('sha256', json_encode($service->entries(), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public function show(Request $request, CorridorAgribusinessOpportunities $service)
|
||||
{
|
||||
$access = app(PortalProfileAccess::class);
|
||||
abort_unless($access->internalAssignment($request->user(), 'investment_project') || $access->has($request->user(), 'publisher'), 403);
|
||||
$records = Opportunity::withTrashed()->whereIn('management_creation_key', array_column($service->entries(), 'key'))->get()->keyBy('management_creation_key');
|
||||
foreach ($records as $record) abort_unless($access->canView($request->user(), $record), 403);
|
||||
return Inertia::render('management/corridor-agribusiness', ['manifestHash' => $this->manifest($service),
|
||||
'canRegister' => (bool) $access->internalAssignment($request->user(), 'investment_project'),
|
||||
'entries' => array_map(function ($entry) use ($records) {
|
||||
$r = $records->get($entry['key']);
|
||||
return [...$entry, 'id' => $r?->id, 'status' => $r?->status, 'href' => $r ? '/oportunidades/'.$r->slug : null];
|
||||
}, $service->entries())]);
|
||||
}
|
||||
|
||||
public function register(Request $request, CorridorAgribusinessOpportunities $service)
|
||||
{
|
||||
$data = $request->validate(['manifest_hash' => 'required|string|size:64', 'confirm' => 'required|accepted']);
|
||||
abort_unless(hash_equals($this->manifest($service), $data['manifest_hash']), 409, 'A fonte mudou; recupere a prévia.');
|
||||
$access = app(PortalProfileAccess::class);
|
||||
$grant = $access->internalAssignment($request->user(), 'investment_project');
|
||||
abort_unless($grant && $access->mfa($request->user()), 403, 'Use a designação existente e MFA recente.');
|
||||
return response()->json(['records' => $service->register($request->user(), $grant->organization_id)]);
|
||||
}
|
||||
|
||||
public function preview(Request $request, CorridorAgribusinessOpportunities $service)
|
||||
{
|
||||
$previews = [];
|
||||
foreach ($service->entries() as $entry) {
|
||||
$record = Opportunity::where('management_creation_key', $entry['key'])->firstOrFail();
|
||||
$input = data_get($record->workflow_context, 'promotional_summary.input');
|
||||
abort_unless(is_array($input), 409, 'Complete a selecção pública na divulgação individual.');
|
||||
$previews[] = ['id' => $record->id, ...app(OpportunityPromotion::class)->preview($request->user(), $record,
|
||||
[...$input, 'source_date' => $input['source_date'] ?: null, 'action' => 'disclose', 'etag' => app(OpportunityManagement::class)->etag($record)])];
|
||||
}
|
||||
return response()->json(['previews' => $previews]);
|
||||
}
|
||||
|
||||
public function disclose(Request $request, CorridorAgribusinessOpportunities $service)
|
||||
{
|
||||
$data = $request->validate(['confirm' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000',
|
||||
'previews' => 'required|array|size:34', 'previews.*.id' => 'required|integer|distinct',
|
||||
'previews.*.token' => 'required|string|max:300000', 'previews.*.operation_key' => 'required|uuid|distinct']);
|
||||
$expected = Opportunity::whereIn('management_creation_key', array_column($service->entries(), 'key'))->pluck('id')->sort()->values()->all();
|
||||
$supplied = array_column($data['previews'], 'id'); sort($supplied);
|
||||
abort_unless($expected === $supplied && count($expected) === 34, 409, 'A prévia deve identificar exactamente as 34 fichas deste lote.');
|
||||
return response()->json(DB::transaction(function () use ($request, $data) {
|
||||
$result = [];
|
||||
foreach ($data['previews'] as $preview) $result[] = app(OpportunityPromotion::class)->confirm($request->user(), $preview['id'],
|
||||
[...$preview, 'confirm' => true, 'reason' => $data['reason']]);
|
||||
return ['records' => $result];
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ use App\Models\Investor;
|
||||
use App\Models\Lead;
|
||||
use App\Models\User;
|
||||
use App\Services\ConvertInterestToLead;
|
||||
use App\Services\CountryCatalogue;
|
||||
use App\Services\DeclaredOriginStatistics;
|
||||
use App\Services\PortalAudit;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -77,7 +79,8 @@ class CrmController extends Controller
|
||||
{
|
||||
$filters = $request->validate(['q' => ['nullable', 'string', 'max:150'],
|
||||
'status' => ['nullable', Rule::in(['new', 'qualified', 'disqualified', 'converted'])],
|
||||
'owner_user_id' => ['nullable', 'integer', 'exists:users,id']]);
|
||||
'owner_user_id' => ['nullable', 'integer', 'exists:users,id'],
|
||||
'origin' => ['nullable', Rule::in(['unknown', ...array_column(app(CountryCatalogue::class)->all(), 'code')])]]);
|
||||
$query = Lead::with(['investor:id,legal_name', 'owner:id,name'])
|
||||
->withCount(['tasks as overdue_tasks_count' => fn ($q) => $q->whereIn('status', ['open', 'in_progress'])->where('due_at', '<', now())]);
|
||||
if ($term = $filters['q'] ?? null) {
|
||||
@@ -92,7 +95,9 @@ class CrmController extends Controller
|
||||
}
|
||||
|
||||
return Inertia::render('crm/leads', ['leads' => $query->latest()->orderBy('id')->paginate(20)->withQueryString(),
|
||||
'filters' => $filters, 'owners' => $this->owners()]);
|
||||
'filters' => $filters, 'owners' => $this->owners(),
|
||||
'countries' => app(CountryCatalogue::class)->all(),
|
||||
'originStatistics' => app(DeclaredOriginStatistics::class)->forManager($request->user(), $filters['origin'] ?? null)]);
|
||||
}
|
||||
|
||||
public function prepare(InterestRequest $interest): Response|RedirectResponse
|
||||
|
||||
@@ -5,7 +5,10 @@ namespace App\Http\Controllers;
|
||||
use App\Models\InterestRequest;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Sector;
|
||||
use App\Services\InvestorPath;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -14,18 +17,32 @@ class DashboardController extends Controller
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
if (! $request->user()->can('manage-portal')) {
|
||||
return Inertia::render('dashboard', ['summary' => null]);
|
||||
$path = app(InvestorPath::class);
|
||||
$contexts = $path->contexts($request->user());
|
||||
$current = $contexts ? $path->view($request->user(), $contexts[0]['id']) : null;
|
||||
|
||||
return Inertia::render('dashboard', ['summary' => null, 'investorPath' => $path->available() ? [
|
||||
'contexts' => $contexts,
|
||||
'current' => $current ? ['id' => $current['case']['id'], 'next' => $current['next'],
|
||||
'pending' => count(array_filter($current['steps'], fn ($step) => ! in_array($step['state'], ['completed', 'approved', 'dispensed'], true))),
|
||||
'documents' => count($current['documents']), 'results' => count($current['results']), 'tasks' => count($current['tasks'])] : null,
|
||||
] : null]);
|
||||
}
|
||||
|
||||
$counts = InterestRequest::query()->selectRaw('status, count(*) as total')
|
||||
->groupBy('status')->pluck('total', 'status');
|
||||
$drafts = Opportunity::where('status', 'draft');
|
||||
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
|
||||
$drafts = app(PortalProfileAccess::class)->scope($drafts, $request->user());
|
||||
}
|
||||
|
||||
return Inertia::render('dashboard', ['summary' => [
|
||||
'canCreateOpportunity' => app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()),
|
||||
'requestsByStatus' => collect(['received', 'in_review', 'contacted', 'closed'])
|
||||
->mapWithKeys(fn ($status) => [$status => (int) ($counts[$status] ?? 0)]),
|
||||
'publishedOpportunities' => Opportunity::published()->count(),
|
||||
'draftOpportunities' => Opportunity::where('status', 'draft')->count(),
|
||||
'activeSectors' => Sector::where('is_active', true)->count(),
|
||||
'draftOpportunities' => $drafts->count(),
|
||||
'activeSectors' => Sector::catalog()->count(),
|
||||
'recentRequests' => InterestRequest::orderByDesc('id')->limit(5)
|
||||
->get(['id', 'reference', 'sector_interest', 'status', 'created_at']),
|
||||
]]);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DocumentMalwareController extends Controller
|
||||
{
|
||||
public function retry(Request $request, string $id): RedirectResponse
|
||||
{
|
||||
DB::transaction(function () use ($id, $request): void {
|
||||
$document = app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($document && $document->status !== 'deleted' && $document->deleted_at === null, 404);
|
||||
abort_unless(in_array($document->malware_scan_status, ['pending', 'inconclusive', 'failed'], true), 422,
|
||||
'Só é possível repetir uma análise pendente, inconclusiva ou falhada. Para substituir um ficheiro, carregue uma nova versão.');
|
||||
|
||||
DB::table('documents')->where('id', $id)->update([
|
||||
'status' => 'quarantined', 'malware_scan_status' => 'pending', 'malware_failure_reason' => null, 'updated_at' => now(),
|
||||
]);
|
||||
ScanDocumentForMalware::dispatch($id)->afterCommit();
|
||||
});
|
||||
|
||||
return back()->with('success', 'Nova análise colocada na fila. O ficheiro permanece privado até haver resultado.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\EconomicReferenceInstallation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class EconomicReferenceController extends Controller
|
||||
{
|
||||
public function preview(Request $request, EconomicReferenceInstallation $service): JsonResponse|Response
|
||||
{
|
||||
$plan = $service->preview($request->user());
|
||||
|
||||
return $request->expectsJson() ? response()->json(['plan' => $plan]) : Inertia::render('management/economic-references', ['plan' => $plan]);
|
||||
}
|
||||
|
||||
public function apply(Request $request, string $operation, EconomicReferenceInstallation $service): JsonResponse
|
||||
{
|
||||
return response()->json(['receipt' => $service->apply($request->user(), $operation, $request->all())]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\VerifiedDocumentBinary;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class EditorialArticleController extends Controller
|
||||
{
|
||||
public function installation(Request $request, \App\Services\EditorialInstallation $installation)
|
||||
{
|
||||
$plan = $installation->preview($request->user());
|
||||
return $request->expectsJson() ? response()->json(['plan' => $plan])->header('Cache-Control', 'private, no-store')
|
||||
: response()->view('management.editorial-installation', ['plan' => $plan, 'operationKey' => (string) \Illuminate\Support\Str::uuid(), 'receipt' => null])->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function install(Request $request, \App\Services\EditorialInstallation $installation)
|
||||
{
|
||||
$receipt = $installation->apply($request->user(), $request->all());
|
||||
return $request->expectsJson() ? response()->json(['receipt' => $receipt])->header('Cache-Control', 'private, no-store')
|
||||
: response()->view('management.editorial-installation', ['plan' => $installation->preview($request->user()), 'operationKey' => (string) \Illuminate\Support\Str::uuid(), 'receipt' => $receipt])->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function showMfa(Request $request)
|
||||
{
|
||||
return $this->confirmMfa($request);
|
||||
}
|
||||
|
||||
public function confirmMfa(Request $request)
|
||||
{
|
||||
$actor = $request->user();
|
||||
abort_unless($actor->two_factor_secret !== null && $actor->two_factor_confirmed_at !== null, 403, 'MFA configurado e confirmado necessário.');
|
||||
if (app(\App\Services\PortalProfileAccess::class)->mfa($actor)) {
|
||||
return redirect()->route('editorial.installation');
|
||||
}
|
||||
if ($request->isMethod('get')) {
|
||||
return response()->view('management.editorial-mfa')->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
abort_if(array_diff(array_keys($request->all()), ['code', 'recovery_code', '_token']), 422, 'A conta e o destino são obtidos da sessão e do servidor.');
|
||||
$data = $request->validate(['code' => 'nullable|string|max:32', 'recovery_code' => 'nullable|string|max:255']);
|
||||
try {
|
||||
$recovery = collect($actor->recoveryCodes())->first(fn ($code) => isset($data['recovery_code']) && hash_equals($code, $data['recovery_code']));
|
||||
$valid = $recovery !== null || (!empty($data['code']) && app(\Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider::class)->verify(
|
||||
\Laravel\Fortify\Fortify::currentEncrypter()->decrypt($actor->two_factor_secret), $data['code']
|
||||
));
|
||||
} catch (\Illuminate\Contracts\Encryption\DecryptException $e) {
|
||||
$valid = false;
|
||||
}
|
||||
if (!$valid) {
|
||||
event(new \Laravel\Fortify\Events\TwoFactorAuthenticationFailed($actor));
|
||||
throw ValidationException::withMessages(['code' => 'Não foi possível confirmar o código MFA.']);
|
||||
}
|
||||
if ($recovery !== null) {
|
||||
$actor->replaceRecoveryCode($recovery);
|
||||
}
|
||||
event(new \Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided($actor));
|
||||
$request->session()->regenerate();
|
||||
return redirect()->route('editorial.installation')->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
private function installed(): void
|
||||
{
|
||||
abort_unless(Schema::hasColumn('portal_resources', 'editorial_content'), 503, 'Editor editorial ainda não instalado.');
|
||||
}
|
||||
|
||||
private function publicResources()
|
||||
{
|
||||
return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
|
||||
->where('r.published', true)->where('r.rights_status', 'authorized')->where('d.rights_status', 'authorized')
|
||||
->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at')
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256');
|
||||
}
|
||||
|
||||
public function edit(int $id)
|
||||
{
|
||||
$this->installed();
|
||||
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource(request()->user(), $id);
|
||||
$r = DB::table('portal_resources')->find($id);
|
||||
abort_unless($r, 404);
|
||||
$reading = app(\App\Services\EditorialReading::class);
|
||||
$original = $reading->original($r);
|
||||
$content = $reading->content($r, $original);
|
||||
return Inertia::render('portal/article-editor', ['resource' => ['id' => $r->id, 'title' => $r->title, 'metadata_version' => $r->metadata_version, 'published' => (bool) $r->published],
|
||||
'content' => $content, 'source' => $original ? array_intersect_key($original, array_flip(['source_url', 'published_at', 'modified_at', 'legacy_id', 'image_source_url'])) : null, 'images' => $this->publicResources()->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->get(['r.id', 'r.title']),
|
||||
'revisions' => DB::table('portal_resource_revisions')->where('resource_id', $id)->orderByDesc('revision_no')->get(['revision_no', 'change_type', 'created_at'])])->toResponse(request())->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function save(Request $request, int $id)
|
||||
{
|
||||
$this->installed();
|
||||
$data = $this->validateContent($request);
|
||||
DB::transaction(function () use ($request, $id, $data) {
|
||||
$r = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($r, 404);
|
||||
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
|
||||
abort_unless((int) $r->metadata_version === (int) $data['metadata_version'], 409, 'O artigo foi alterado noutra sessão. Recarregue antes de editar.');
|
||||
if (! empty($data['image_resource_id'])) {
|
||||
abort_unless($this->publicResources()->where('r.id', $data['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->exists(), 422, 'Escolha uma imagem pública disponível na biblioteca.');
|
||||
}
|
||||
$body = trim($data['body']);
|
||||
if ($body === '') {
|
||||
throw ValidationException::withMessages(['body' => 'Escreva o corpo do artigo.']);
|
||||
}
|
||||
$content = $this->edition($r, $data);
|
||||
$revision = (int) $r->metadata_version + 1;
|
||||
DB::table('portal_resources')->where('id', $id)->update(['editorial_content' => json_encode($content, JSON_THROW_ON_ERROR), 'published' => false, 'approved_by' => null, 'metadata_version' => $revision, 'last_reviewed_by' => $request->user()->id, 'last_reviewed_at' => now(), 'updated_at' => now()]);
|
||||
$resource = DB::table('portal_resources')->find($id);
|
||||
$links = DB::table('portal_resource_links')->where('resource_id', $id)->orderBy('id')->get();
|
||||
DB::table('portal_resource_revisions')->insert(['resource_id' => $id, 'revision_no' => $revision, 'change_type' => 'metadata_updated', 'snapshot' => json_encode(['resource' => $resource, 'associations' => $links], JSON_THROW_ON_ERROR), 'actor_user_id' => $request->user()->id, 'created_at' => now()]);
|
||||
DB::table('audit_events')->insert(['source_system' => 'portal-management', 'actor_user_id' => $request->user()->id, 'entity_type' => 'portal_resources', 'entity_id' => null, 'action' => 'editorial_updated', 'before_data' => json_encode(['record_id' => $id, 'metadata_version' => $r->metadata_version]), 'after_data' => json_encode(['record_id' => $id, 'metadata_version' => $revision, 'published' => false])]);
|
||||
});
|
||||
|
||||
return redirect('/management/portal-services/resources/'.$id.'/article')->with('success', 'Artigo guardado como rascunho. Publique expressamente no acervo quando estiver pronto.');
|
||||
}
|
||||
|
||||
private function validateContent(Request $request): array
|
||||
{
|
||||
return $request->validate(['body' => ['required', 'string', 'max:100000'], 'image_resource_id' => ['nullable', 'integer'], 'image_mode' => ['nullable', 'in:original,selected,none'], 'metadata_version' => ['required', 'integer', 'min:1']]);
|
||||
}
|
||||
|
||||
private function edition(object $resource, array $data): array
|
||||
{
|
||||
$reading = app(\App\Services\EditorialReading::class);
|
||||
$original = $reading->original($resource);
|
||||
$mode = $data['image_mode'] ?? (!empty($data['image_resource_id']) ? 'selected' : 'none');
|
||||
if ($mode === 'selected') abort_unless(!empty($data['image_resource_id']) && $reading->publicResources()->where('r.id', $data['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->exists(), 422, 'Escolha uma imagem pública disponível na biblioteca.');
|
||||
return ['format' => 1, 'body' => trim($data['body']), 'image_mode' => $mode, 'image_resource_id' => $mode === 'selected' ? (int)$data['image_resource_id'] : null,
|
||||
'preserve_original_html' => $original && trim($data['body']) === $reading->plain($original['content_html'])];
|
||||
}
|
||||
|
||||
public function preview(Request $request, int $id)
|
||||
{
|
||||
$this->installed();
|
||||
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
|
||||
$resource = DB::table('portal_resources')->find($id); abort_unless($resource, 404);
|
||||
return Inertia::render('portal/article', ['article' => app(\App\Services\EditorialReading::class)->reading($resource, null, true)])
|
||||
->toResponse($request)->header('Cache-Control', 'private, no-store')->header('X-Robots-Tag', 'noindex, nofollow');
|
||||
}
|
||||
|
||||
public function previewContent(Request $request, int $id)
|
||||
{
|
||||
$this->installed();
|
||||
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
|
||||
$resource = DB::table('portal_resources')->find($id); abort_unless($resource, 404);
|
||||
$data = $this->validateContent($request);
|
||||
abort_unless((int)$resource->metadata_version === (int)$data['metadata_version'], 409, 'A revisão mudou; recupere antes de rever a prévia.');
|
||||
return response()->json(['article' => app(\App\Services\EditorialReading::class)->reading($resource, $this->edition($resource, $data), true)])
|
||||
->header('Cache-Control', 'private, no-store')->header('X-Robots-Tag', 'noindex, nofollow');
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id)
|
||||
{
|
||||
$this->installed();
|
||||
$resource = app(\App\Services\EditorialReading::class)->publicResources()->where('r.id', $id)->first(['r.*']);
|
||||
abort_unless($resource, 404);
|
||||
return Inertia::render('portal/article', ['article' => app(\App\Services\EditorialReading::class)->reading($resource)])->toResponse($request)->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use App\Models\Skill;
|
||||
use App\Models\TrainingCapacity;
|
||||
use App\Models\TrainingProvider;
|
||||
use App\Services\EducationGapCalculator;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\PortalAudit;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -28,17 +29,22 @@ class EducationController extends Controller
|
||||
'q' => ['nullable', 'string', 'max:150'],
|
||||
'status' => ['nullable', Rule::in(['submitted', 'reviewed', 'rejected', 'expired'])],
|
||||
'geo_location_id' => ['nullable', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
|
||||
'demand_page' => ['nullable', 'integer', 'min:1'],
|
||||
'capacity_page' => ['nullable', 'integer', 'min:1'],
|
||||
]);
|
||||
$demands = LabourDemand::query();
|
||||
$capacities = TrainingCapacity::query();
|
||||
$territoryIds = [];
|
||||
foreach ([$demands, $capacities] as $query) {
|
||||
if ($term = trim($filters['q'] ?? '')) {
|
||||
$query->whereLike('qualification', '%'.addcslashes($term, '%_\\').'%');
|
||||
}
|
||||
if ($territory = $filters['geo_location_id'] ?? null) {
|
||||
$query->where('geo_location_id', $territory);
|
||||
$path = app(InvestmentGeography::class)->selection($filters);
|
||||
$territory = $filters['geo_location_id'] ?? ($path !== [] ? $path[array_key_last($path)]->id : null);
|
||||
if ($territory) {
|
||||
$territoryIds = app(InvestmentGeography::class)->descendants($territory, includeNonPublic: true);
|
||||
$query->whereIn('geo_location_id', $territoryIds);
|
||||
}
|
||||
if (($filters['status'] ?? null) === 'expired') {
|
||||
$query->where('valid_until', '<', now()->toDateString());
|
||||
@@ -47,6 +53,17 @@ class EducationController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$territorialCapacities = clone $capacities;
|
||||
$providers = TrainingProvider::where('status', '!=', 'inactive');
|
||||
$courses = Course::where('status', '!=', 'retired')
|
||||
->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id'));
|
||||
if ($territoryIds !== []) {
|
||||
// Provider and course location is evidenced by their submitted training capacity.
|
||||
// Do not infer locations from provider names, addresses, or unrelated records.
|
||||
$courses->whereIn('id', $territorialCapacities->select('course_id'));
|
||||
$providers->whereIn('id', (clone $courses)->select('provider_id'));
|
||||
}
|
||||
|
||||
return Inertia::render('education/index', [
|
||||
'demands' => $demands->latest()->orderBy('id')->paginate(20, ['*'], 'demand_page')->withQueryString(),
|
||||
'capacities' => $capacities->latest()->orderBy('id')->paginate(20, ['*'], 'capacity_page')->withQueryString(),
|
||||
@@ -59,10 +76,12 @@ class EducationController extends Controller
|
||||
],
|
||||
'skills' => Skill::orderBy('name')->get(['id', 'code', 'name']),
|
||||
'occupations' => Occupation::orderBy('name')->get(['id', 'code', 'name']),
|
||||
'providers' => TrainingProvider::where('status', '!=', 'inactive')->orderBy('name')->get(['id', 'name']),
|
||||
'courses' => Course::where('status', '!=', 'retired')->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id'))->orderBy('title')->get(['id', 'title', 'provider_id']),
|
||||
'providers' => $providers->orderBy('name')->get(['id', 'name']),
|
||||
'courses' => $courses->orderBy('title')->get(['id', 'title', 'provider_id']),
|
||||
'projects' => Project::orderBy('name')->get(['id', 'code', 'name']),
|
||||
'territories' => DB::table('geo_locations')->whereNull('deleted_at')->orderBy('name')->get(['id', 'name', 'code']),
|
||||
'territories' => DB::table('geo_locations')->whereNull('deleted_at')
|
||||
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])
|
||||
->orderBy('name')->get(['id', 'name', 'parent_id', 'location_type']),
|
||||
'today' => now()->toDateString(),
|
||||
]);
|
||||
}
|
||||
@@ -96,7 +115,8 @@ class EducationController extends Controller
|
||||
$data = $request->validate([
|
||||
'occupation_id' => ['required', 'uuid', Rule::exists('occupations', 'id')->whereNull('deleted_at')],
|
||||
'skill_id' => ['required', 'uuid', Rule::exists('skills', 'id')->whereNull('deleted_at')],
|
||||
'geo_location_id' => ['required', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')],
|
||||
'geo_location_id' => ['required', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')
|
||||
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])],
|
||||
$demand ? 'project_id' : 'course_id' => ['required', 'uuid', $demand
|
||||
? Rule::exists('projects', 'id')->whereNull('deleted_at')
|
||||
: Rule::exists('courses', 'id')->whereNull('deleted_at')->whereNot('status', 'retired')
|
||||
@@ -147,7 +167,8 @@ class EducationController extends Controller
|
||||
|
||||
return Inertia::render('education/analysis', ['demand' => $demand, 'candidates' => $candidates,
|
||||
'proposal' => EducationInvestmentProposal::where('labour_demand_id', $id)->first(),
|
||||
'sectors' => Sector::orderBy('name')->get(['id', 'name']),
|
||||
'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']),
|
||||
'territoryPath' => app(InvestmentGeography::class)->path($demand->getAttribute('geo_location_id'), true),
|
||||
'eligible' => $eligible, 'gap' => $calculator->observed((int) $demand->getAttribute('quantity'), null, $eligible, $current)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ namespace App\Http\Controllers;
|
||||
use App\Models\EducationInvestmentProposal;
|
||||
use App\Models\LabourDemand;
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\PortalAudit;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use LogicException;
|
||||
|
||||
class EducationInvestmentController extends Controller
|
||||
{
|
||||
@@ -27,6 +29,10 @@ class EducationInvestmentController extends Controller
|
||||
'risks' => ['required', 'string', 'min:10', 'max:5000'],
|
||||
'source' => ['required', 'string', 'min:10', 'max:5000'],
|
||||
'currency' => ['required', Rule::in(['MZN', 'USD', 'EUR', 'ZAR'])],
|
||||
'province_id' => ['required', 'uuid'],
|
||||
'district_id' => ['nullable', 'uuid'],
|
||||
'admin_post_id' => ['nullable', 'uuid'],
|
||||
'locality_id' => ['nullable', 'uuid'],
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($request, $id, $data): void {
|
||||
@@ -38,6 +44,14 @@ class EducationInvestmentController extends Controller
|
||||
if (EducationInvestmentProposal::withTrashed()->where('labour_demand_id', $id)->exists()) {
|
||||
return;
|
||||
}
|
||||
$territory = app(InvestmentGeography::class)->selection($data, true);
|
||||
$lastTerritoryKey = array_key_last($territory);
|
||||
if ($lastTerritoryKey === null) {
|
||||
throw new LogicException('A proposta educativa exige uma localização territorial.');
|
||||
}
|
||||
$province = $territory[0];
|
||||
$district = $territory[1] ?? null;
|
||||
$primaryTerritory = $territory[$lastTerritoryKey];
|
||||
$reference = 'EDU-'.Str::uuid();
|
||||
$opportunity = new Opportunity;
|
||||
PortalAudit::save($opportunity, [
|
||||
@@ -45,6 +59,8 @@ class EducationInvestmentController extends Controller
|
||||
'slug' => strtolower($reference), 'title' => $data['title'],
|
||||
'summary' => 'Proposta educativa em preparação. Lacuna e viabilidade pendentes de validação institucional.',
|
||||
'description' => $data['intervention'], 'estimated_investment_currency' => $data['currency'],
|
||||
'province' => $province->name, 'district' => $district?->name,
|
||||
'primary_territory_id' => $primaryTerritory->id, 'territory_version' => 1,
|
||||
'status' => 'draft', 'is_featured' => false,
|
||||
], $request->user()->id);
|
||||
PortalAudit::save(new EducationInvestmentProposal, [
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\ExistingBusinessClassification;
|
||||
use App\Services\Mne\BusinessRepository;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ExistingBusinessClassificationController extends Controller
|
||||
{
|
||||
public function show(Request $request, string $id, ExistingBusinessClassification $service): Response|JsonResponse
|
||||
{
|
||||
$props = ['supplement' => $service->privateView($request->user(), app(BusinessRepository::class)->find($id))];
|
||||
|
||||
return $request->expectsJson() ? response()->json($props) : Inertia::render('investment/business-classification', $props);
|
||||
}
|
||||
|
||||
public function act(Request $request, string $id, ExistingBusinessClassification $service): JsonResponse
|
||||
{
|
||||
$business = $service->act($request->user(), $id, $request->all());
|
||||
|
||||
return response()->json(['supplement' => $service->privateView($request->user(), $business)]);
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,24 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ExistingBusiness;
|
||||
use App\Models\ExistingBusinessObject;
|
||||
use App\Models\Sector;
|
||||
use App\Policies\ExistingBusinessPolicy;
|
||||
use App\Services\BusinessFormSchema;
|
||||
use App\Services\CountryCatalogue;
|
||||
use App\Services\ExistingBusinessDrafts;
|
||||
use App\Services\ExistingBusinessIntake;
|
||||
use App\Services\ExistingBusinessObjects;
|
||||
use App\Services\ExistingBusinessVerification;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\Mne\BusinessRepository;
|
||||
use App\Services\Mne\CompatibilityShadow;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -21,14 +30,33 @@ class ExistingBusinessController extends Controller
|
||||
public function index(Request $request): Response|JsonResponse
|
||||
{
|
||||
$management = $request->is('management/*', 'api/v1/existing-business/review*');
|
||||
$query = ExistingBusiness::query();
|
||||
$query = app(BusinessRepository::class)->query();
|
||||
if ($management) {
|
||||
Gate::authorize('reviewQueue', ExistingBusiness::class);
|
||||
$query->where('owner_user_id', '<>', $request->user()->id)->whereIn('status', ['submitted', 'in_review', 'needs_information']);
|
||||
$query->where('owner_user_id', '<>', $request->user()->id)->whereIn('status', [
|
||||
'submitted', 'triage', 'in_review', 'needs_information', 'approval_l1', 'approval_l2', 'approved',
|
||||
]);
|
||||
app(ExistingBusinessPolicy::class)->scopeReviewQueue($query, $request->user());
|
||||
} else {
|
||||
$query->where('owner_user_id', $request->user()->id);
|
||||
}
|
||||
$props = ['records' => $query->orderByDesc('updated_at')->paginate(20)->through(fn ($row) => $this->record($row)),
|
||||
$filters = $request->validate(['country' => ['nullable', app(CountryCatalogue::class)->rule()], 'origin' => ['nullable', Rule::in([...array_column(app(CountryCatalogue::class)->all(), 'code'), 'unknown'])], 'page' => ['nullable', 'integer', 'min:1']]);
|
||||
$query->orderByDesc('updated_at');
|
||||
if (! empty($filters['country']) || ! empty($filters['origin'])) {
|
||||
// The private payload is encrypted: filter only the already-authorized collection.
|
||||
$matches = $query->get()->filter(function ($business) use ($filters) {
|
||||
$payload = $business->payload;
|
||||
$origin = $payload['declared_origin_country_code'] ?? null;
|
||||
$validOrigin = in_array($origin, array_column(app(CountryCatalogue::class)->all(), 'code'), true) ? $origin : 'unknown';
|
||||
|
||||
return (empty($filters['country']) || ($payload['country'] ?? null) === $filters['country']) && (empty($filters['origin']) || $validOrigin === $filters['origin']);
|
||||
})->values();
|
||||
$page = (int) ($filters['page'] ?? 1);
|
||||
$records = new LengthAwarePaginator($matches->forPage($page, 20)->values(), $matches->count(), 20, $page, ['path' => $request->url(), 'query' => $request->query()]);
|
||||
} else {
|
||||
$records = $query->paginate(20)->withQueryString();
|
||||
}
|
||||
$props = ['records' => $records->through(fn ($row) => $this->record($row)), 'filters' => $filters, 'countries' => app(CountryCatalogue::class)->all(),
|
||||
'management' => $management, 'modalities' => ExistingBusinessDrafts::MODALITIES];
|
||||
|
||||
return $request->expectsJson() ? response()->json($props) : Inertia::render('investment/businesses', $props);
|
||||
@@ -36,10 +64,15 @@ class ExistingBusinessController extends Controller
|
||||
|
||||
public function show(Request $request, string $id): Response|JsonResponse
|
||||
{
|
||||
$business = ExistingBusiness::findOrFail($id);
|
||||
$business = app(BusinessRepository::class)->find($id);
|
||||
Gate::authorize('view', $business);
|
||||
$props = ['record' => $this->record($business), 'canEdit' => $request->user()->can('update', $business),
|
||||
app(CompatibilityShadow::class)->schedule($id);
|
||||
$props = ['countries' => app(CountryCatalogue::class)->all(), 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'formSchema' => BusinessFormSchema::fields(), 'modalityFormType' => \App\Services\ExistingBusinessProfileCategory::formType($business->modality), 'record' => $this->record($business), 'canEdit' => $request->user()->can('update', $business),
|
||||
'owner' => $request->user()->can('owns', $business), 'canReview' => $request->user()->can('review', $business),
|
||||
'canTriage' => $request->user()->can('triage', $business),
|
||||
'canApproveLevelOne' => $request->user()->can('approveLevelOne', $business),
|
||||
'canApproveLevelTwo' => $request->user()->can('approveLevelTwo', $business),
|
||||
'canPublish' => $request->user()->can('publish', $business),
|
||||
'modalities' => ExistingBusinessDrafts::MODALITIES,
|
||||
'objectKinds' => ExistingBusinessObjects::KINDS,
|
||||
'verificationChecks' => ExistingBusinessVerification::CHECKS,
|
||||
@@ -63,8 +96,8 @@ class ExistingBusinessController extends Controller
|
||||
public function transition(Request $request, ExistingBusinessIntake $intake, string $id): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'action' => ['required', 'string'],
|
||||
'note' => ['required', 'string'], 'review_consent' => ['nullable', 'boolean']]);
|
||||
$business = $intake->transition($request->user(), $id, $data['version_no'], $data['action'], $data['note'], (bool) ($data['review_consent'] ?? false));
|
||||
'note' => ['required', 'string'], 'review_consent' => ['nullable', 'boolean'], 'confirmation_code' => ['nullable', 'string', 'max:100']]);
|
||||
$business = $intake->transition($request->user(), $id, $data['version_no'], $data['action'], $data['note'], (bool) ($data['review_consent'] ?? false), $data['confirmation_code'] ?? null);
|
||||
|
||||
return response()->json($this->record($business));
|
||||
}
|
||||
@@ -79,12 +112,17 @@ class ExistingBusinessController extends Controller
|
||||
$saved = $objects->save($request->user(), $id, $request->all(), $object);
|
||||
|
||||
return response()->json(['id' => $saved->id, 'verification_status' => 'pending',
|
||||
'version_no' => ExistingBusiness::findOrFail($id)->version_no], $object ? 200 : 201);
|
||||
'version_no' => app(BusinessRepository::class)->find($id)->version_no], $object ? 200 : 201);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function record(ExistingBusiness $business): array
|
||||
{
|
||||
return [...$business->only(['id', 'code', 'modality', 'status', 'classification', 'version_no']), 'payload' => $business->payload];
|
||||
return [...$business->only([
|
||||
'id', 'code', 'modality', 'status', 'classification', 'version_no', 'sector_id', 'public_title', 'public_slug',
|
||||
'activity_code', 'stage', 'objective', 'currency', 'amount_min', 'amount_max', 'available_percentage',
|
||||
'funds_use', 'submitted_at', 'published_at', 'expires_at', 'next_action', 'next_action_due_at', 'assigned_to',
|
||||
]), 'payload' => $business->payload,
|
||||
'territory_paths' => app(InvestmentGeography::class)->displayPaths($business->payload)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\InvestmentComplements;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class InvestmentComplementController extends Controller
|
||||
{
|
||||
public function index(Request $request, InvestmentComplements $service)
|
||||
{
|
||||
$data = $request->validate(['origin' => [\Illuminate\Validation\Rule::requiredIf(!$request->has('contexts')), 'nullable', 'string', 'max:100'], 'territory_id' => 'nullable|uuid',
|
||||
'contexts' => 'sometimes|array|max:100', 'contexts.*' => 'array:origin,territory_id', 'contexts.*.origin' => 'required|string|max:100', 'contexts.*.territory_id' => 'nullable|uuid']);
|
||||
$items = [];
|
||||
$issues = [];
|
||||
foreach ($data['contexts'] ?? [['origin' => $data['origin'], 'territory_id' => $data['territory_id'] ?? null]] as $context) {
|
||||
try {
|
||||
$items = [...$items, ...$service->options($context['origin'], $context['territory_id'] ?? null)];
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException|\Symfony\Component\HttpKernel\Exception\HttpException $e) {
|
||||
$status = $e instanceof \Symfony\Component\HttpKernel\Exception\HttpException ? $e->getStatusCode() : 404;
|
||||
if (!isset($data['contexts']) || $status !== 404) throw $e;
|
||||
$issues[] = ['origin' => $context['origin'], 'message' => 'Uma referência escolhida deixou de estar disponível para consulta pública. A relação guardada conserva-se no dossier; os outros complementos continuam disponíveis.'];
|
||||
}
|
||||
}
|
||||
return response()->json(isset($data['contexts']) ? ['items' => $items, 'issues' => $issues] : $items)->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Services\InvestmentZones;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@@ -24,8 +25,10 @@ class InvestmentDiscoveryController extends Controller
|
||||
$filters['education'] = true;
|
||||
}
|
||||
|
||||
return Inertia::render('investment/catalogue', ['filters' => $filters, 'references' => $catalogue->references(),
|
||||
return Inertia::render('investment/catalogue', ['filters' => $filters, 'catalogueUrl' => $request->routeIs('investment.education') ? '/educacao' : '/oportunidades', 'references' => $catalogue->references(false, $filters),
|
||||
'corridorGroups' => ($filters['view'] ?? '') === 'corridors' ? app(\App\Services\OpportunityCorridorExplorer::class)->groups($filters) : [],
|
||||
'territorySelection' => app(InvestmentGeography::class)->selection($filters),
|
||||
'guides' => app(\App\Services\SectorInvestment::class)->guides(\App\Models\Sector::where('slug', $filters['sector'] ?? '')->where('is_active', true)->whereNotNull('published_at')->first()),
|
||||
'results' => $catalogue->query($filters)->paginate(12)->withQueryString(),
|
||||
'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [],
|
||||
'territorial' => $this->territorial($filters)]);
|
||||
@@ -41,11 +44,7 @@ class InvestmentDiscoveryController extends Controller
|
||||
$path = app(InvestmentGeography::class)->selection($filters);
|
||||
if ($path !== []) {
|
||||
$ids = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
|
||||
$query->where(function ($query) use ($ids) {
|
||||
foreach (['project_id' => 'projects', 'cluster_id' => 'economic_clusters', 'corridor_id' => 'development_corridors'] as $foreign => $table) {
|
||||
$query->orWhereIn($foreign, DB::table($table)->whereNull('deleted_at')->whereIn('primary_territory_id', $ids)->select('id'));
|
||||
}
|
||||
});
|
||||
app(InvestmentGeography::class)->constrainDossiersToTerritories($query, $ids);
|
||||
}
|
||||
if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) {
|
||||
return $query->whereRaw('false')->get();
|
||||
@@ -53,13 +52,16 @@ class InvestmentDiscoveryController extends Controller
|
||||
if (in_array($filters['dimension'] ?? null, ['cluster', 'corridor'], true)) {
|
||||
$query->whereNotNull($filters['dimension'].'_id');
|
||||
}
|
||||
foreach (['zee_id', 'zfi_id', 'park_id', 'education', 'facets', 'sector', 'province', 'district', 'geo_location_id', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level'] as $field) {
|
||||
if (! empty($filters['geo_location_id'])) {
|
||||
app(InvestmentGeography::class)->constrainDossiersToTerritories($query, app(InvestmentGeography::class)->descendants($filters['geo_location_id']));
|
||||
}
|
||||
foreach (['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id', 'category', 'publication_state', 'zee_id', 'zfi_id', 'park_id', 'education', 'facets', 'sector', 'province', 'district', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level', 'preparation'] as $field) {
|
||||
if (isset($filters[$field]) && $filters[$field] !== '' && $filters[$field] !== false && $filters[$field] !== []) {
|
||||
return $query->whereRaw('false')->get();
|
||||
}
|
||||
}
|
||||
if (! empty($filters['q'])) {
|
||||
$query->where('title', 'ilike', '%'.$filters['q'].'%');
|
||||
$query->where(fn ($q) => $q->where('title', 'ilike', '%'.$filters['q'].'%')->orWhereRaw('content::text ILIKE ?', ['%'.$filters['q'].'%']));
|
||||
}
|
||||
foreach (['cluster_id', 'corridor_id', 'project_id'] as $field) {
|
||||
if (! empty($filters[$field])) {
|
||||
@@ -75,16 +77,26 @@ class InvestmentDiscoveryController extends Controller
|
||||
return response()->json($catalogue->query($catalogue->filters($request))->paginate($request->integer('per_page', 12))->withQueryString());
|
||||
}
|
||||
|
||||
public function references(InvestmentCatalogue $catalogue): JsonResponse
|
||||
public function references(Request $request, InvestmentCatalogue $catalogue): JsonResponse
|
||||
{
|
||||
return response()->json($catalogue->references());
|
||||
return response()->json($catalogue->references(false, $catalogue->filters($request)));
|
||||
}
|
||||
|
||||
public function detail(Request $request, string $slug, InvestmentCatalogue $catalogue): Response
|
||||
{
|
||||
$record = Opportunity::published()->with('sector:id,name,slug')->where('slug', $slug)->firstOrFail();
|
||||
|
||||
$data = $request->validate(['zone_id' => ['nullable', 'uuid']]);
|
||||
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'from_dossier' => ['nullable', 'uuid']]);
|
||||
$dossierOrigin = isset($data['from_dossier']) ? InvestmentPortfolioController::visible()->whereKey($data['from_dossier'])->firstOrFail() : null;
|
||||
if ($dossierOrigin) {
|
||||
$linked = in_array($record->id, app(\App\Services\CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($dossierOrigin), true);
|
||||
foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) {
|
||||
if ($dossierOrigin->$foreign && DB::table('opportunity_'.$relation)->where('opportunity_id', $record->id)->where($foreign, $dossierOrigin->$foreign)->exists()) {
|
||||
$linked = true;
|
||||
}
|
||||
}
|
||||
abort_unless($linked, 404);
|
||||
}
|
||||
$origin = null;
|
||||
if (! empty($data['zone_id'])) {
|
||||
$origin = app(InvestmentZones::class)->links()->join('investment_item_opportunities as m', 'm.item_id', '=', 'zl.item_id')
|
||||
@@ -92,7 +104,13 @@ class InvestmentDiscoveryController extends Controller
|
||||
abort_unless($origin !== null, 404);
|
||||
}
|
||||
|
||||
return Inertia::render('investment/detail', ['record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin]);
|
||||
$returnFilters = $request->query();
|
||||
unset($returnFilters['zone_id'], $returnFilters['from_dossier']);
|
||||
|
||||
return Inertia::render('investment/detail', ['complements' => app(\App\Services\InvestmentComplements::class)->options('opportunity:'.$record->id), 'record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin,
|
||||
'guides' => app(\App\Services\SectorInvestment::class)->guides($record->sector),
|
||||
'dossierOrigin' => $dossierOrigin ? ['id' => $dossierOrigin->id, 'title' => $dossierOrigin->title] : null,
|
||||
'returnUrl' => $returnFilters === [] ? '/oportunidades' : '/oportunidades?'.http_build_query($returnFilters)]);
|
||||
}
|
||||
|
||||
public function detailApi(string $slug, InvestmentCatalogue $catalogue): JsonResponse
|
||||
@@ -114,7 +132,24 @@ class InvestmentDiscoveryController extends Controller
|
||||
|
||||
public function map(Request $request, InvestmentCatalogue $catalogue): JsonResponse
|
||||
{
|
||||
return response()->json($catalogue->map($catalogue->filters($request)));
|
||||
$filters = $catalogue->filters($request);
|
||||
$context = $this->territorial($filters)->map(fn ($d) => ['id' => 'dossier:'.$d->id, 'title' => $d->title, 'kind' => $d->cluster_id ? 'Cluster' : ($d->corridor_id ? 'Corredor' : 'Projeto âncora'), 'url' => '/carteira/'.$d->id]);
|
||||
$opportunityOnlyFilters = array_filter(Arr::only($filters, ['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id', 'category', 'publication_state', 'education', 'sector', 'facets', 'project_id', 'cluster_id', 'corridor_id', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level', 'preparation']), fn ($value) => $value !== null && $value !== '' && $value !== false && $value !== []);
|
||||
if ($opportunityOnlyFilters === [] && ! in_array($filters['dimension'] ?? null, ['cluster', 'corridor'], true)) {
|
||||
$zoneFilters = $filters;
|
||||
if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) {
|
||||
$zoneFilters['category'] = $filters['dimension'];
|
||||
}
|
||||
$zones = app(InvestmentZones::class)->search($zoneFilters);
|
||||
foreach (['zee_id', 'zfi_id', 'park_id'] as $key) {
|
||||
if (! empty($filters[$key])) {
|
||||
$zones->where('id', $filters[$key]);
|
||||
}
|
||||
}
|
||||
$context = $context->concat($zones->orderBy('name')->limit(30)->get(['id', 'name', 'category'])->map(fn ($z) => ['id' => 'zone:'.$z->id, 'title' => $z->name, 'kind' => ['zee' => 'ZEE', 'zfi' => 'ZFI', 'industrial_park' => 'Parque industrial'][$z->category] ?? $z->category, 'url' => '/zonas/'.$z->id]));
|
||||
}
|
||||
|
||||
return response()->json([...$catalogue->map($filters), 'territorial_context' => $context->values(), 'context_limit_per_collection' => 30]);
|
||||
}
|
||||
|
||||
public function compare(Request $request, InvestmentCatalogue $catalogue): JsonResponse
|
||||
|
||||
@@ -27,7 +27,8 @@ class InvestmentLegalSourceController extends Controller
|
||||
$source->refresh();
|
||||
$this->audit($source, $request->user()->id, 'created');
|
||||
|
||||
return response()->json($source->fresh(), 201);
|
||||
return response()->json($source->fresh(), 201)
|
||||
->header('Location', url('/api/v2/investment/admin/legal-sources/'.$source->id));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,9 +40,12 @@ class InvestmentLegalSourceController extends Controller
|
||||
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
|
||||
|
||||
return DB::transaction(function () use ($request, $id, $action, $data) {
|
||||
DB::select('select pg_advisory_xact_lock(261022, hashtext(?))', [$id]);
|
||||
$source = InvestmentLegalSource::whereKey($id)->lockForUpdate()->firstOrFail();
|
||||
abort_if((int) $source->version_no !== (int) $data['version_no'], 409, 'Versão alterada.');
|
||||
if ($action === 'validate') {
|
||||
abort_if(str_contains(mb_strtolower($source->article), 'pending') || str_contains(mb_strtolower($source->article), 'pendente')
|
||||
|| str_contains(mb_strtolower($source->article), 'a rever'), 422, 'Indique o artigo e confirme o texto oficial antes da validação.');
|
||||
abort_if((int) $source->owner_id === $request->user()->id, 403, 'Outra pessoa deve validar a fonte.');
|
||||
abort_unless($source->validation_status === 'pending', 422, 'Crie nova referência para substituir fonte revogada.');
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\InvestmentDossier;
|
||||
use App\Models\InvestmentSubmission;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Project;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\InvestmentRelations;
|
||||
use App\Services\InvestmentZones;
|
||||
use App\Services\PortalAudit;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -17,9 +22,15 @@ class InvestmentPortfolioController extends Controller
|
||||
{
|
||||
public static function related(InvestmentDossier $first, InvestmentDossier $second): bool
|
||||
{
|
||||
foreach ([[$first, $second], [$second, $first]] as [$cluster, $project]) {
|
||||
if ($cluster->cluster_id && $project->project_id && app(InvestmentRelations::class)->memberships()
|
||||
->where('cp.cluster_id', $cluster->cluster_id)->where('cp.project_id', $project->project_id)->exists()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
foreach ([['corridor_projects', 'corridor_id', 'project_id'], ['corridor_clusters', 'corridor_id', 'cluster_id'], ['cluster_projects', 'cluster_id', 'project_id']] as [$table, $left, $right]) {
|
||||
foreach ([[$first, $second], [$second, $first]] as [$a, $b]) {
|
||||
if ($a->$left && $b->$right && DB::table($table)->where($left, $a->$left)->where($right, $b->$right)->whereNull('deleted_at')->exists()) {
|
||||
if ($a->$left && $b->$right && self::currentRelation(DB::table($table)->where($left, $a->$left)->where($right, $b->$right))->exists()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +39,13 @@ class InvestmentPortfolioController extends Controller
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function currentRelation(QueryBuilder $query): QueryBuilder
|
||||
{
|
||||
return $query->whereNull('deleted_at')->where('validation_status', 'validated')
|
||||
->whereDate('valid_from', '<=', today()->toDateString())
|
||||
->where(fn ($q) => $q->whereNull('valid_until')->orWhereDate('valid_until', '>=', today()->toDateString()));
|
||||
}
|
||||
|
||||
/** @return Builder<InvestmentDossier> */
|
||||
public static function visible(): Builder
|
||||
{
|
||||
@@ -42,17 +60,30 @@ class InvestmentPortfolioController extends Controller
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$management = $request->routeIs('management.portfolio*');
|
||||
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'kind' => ['nullable', 'in:corridor,cluster,project']]);
|
||||
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'kind' => ['nullable', 'in:corridor,cluster,project'],
|
||||
'sector' => ['nullable', 'string', 'max:500'],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
|
||||
$query = $management ? InvestmentDossier::query() : self::visible();
|
||||
$sectorOptions = ($management ? InvestmentDossier::query() : self::visible())
|
||||
->whereNotNull('content->sector')->get()->pluck('content.sector')
|
||||
->filter(fn ($sector) => is_string($sector) && trim($sector) !== '')->unique()->sort()->values();
|
||||
if (! empty($filters['sector'])) {
|
||||
$query->where('content->sector', $filters['sector']);
|
||||
}
|
||||
if (! empty($filters['kind'])) {
|
||||
$query->whereNotNull($filters['kind'].'_id');
|
||||
}
|
||||
if (! empty($filters['q'])) {
|
||||
$query->where('title', 'ilike', '%'.$filters['q'].'%');
|
||||
$query->where(fn ($q) => $q->where('title', 'ilike', '%'.$filters['q'].'%')->orWhereRaw('content::text ILIKE ?', ['%'.$filters['q'].'%']));
|
||||
}
|
||||
$territorySelection = app(InvestmentGeography::class)->selection($filters);
|
||||
if ($territorySelection !== []) {
|
||||
$territoryIds = app(InvestmentGeography::class)->descendants($territorySelection[array_key_last($territorySelection)]->id);
|
||||
app(InvestmentGeography::class)->constrainDossiersToTerritories($query, $territoryIds);
|
||||
}
|
||||
|
||||
return Inertia::render('portfolio/index', ['records' => $query->orderBy('title')->paginate(30)->withQueryString(),
|
||||
'management' => $management, 'filters' => $filters]);
|
||||
'management' => $management, 'filters' => $filters, 'sectors' => $sectorOptions, 'territorySelection' => $territorySelection]);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $id): Response
|
||||
@@ -63,8 +94,8 @@ class InvestmentPortfolioController extends Controller
|
||||
foreach ([['corridor_projects', 'corridor_id', 'project_id'], ['corridor_clusters', 'corridor_id', 'cluster_id'], ['cluster_projects', 'cluster_id', 'project_id']] as [$table, $left, $right]) {
|
||||
foreach ([[$left, $right], [$right, $left]] as [$origin, $destination]) {
|
||||
if ($record->$origin) {
|
||||
$relatedIds[$destination] = array_merge($relatedIds[$destination] ?? [], DB::table($table)
|
||||
->where($origin, $record->$origin)->whereNull('deleted_at')->pluck($destination)->all());
|
||||
$relatedIds[$destination] = array_merge($relatedIds[$destination] ?? [], self::currentRelation(DB::table($table)
|
||||
->where($origin, $record->$origin))->pluck($destination)->all());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +112,7 @@ class InvestmentPortfolioController extends Controller
|
||||
|
||||
$opportunities = Opportunity::published()->where(function ($query) use ($record) {
|
||||
$query->whereRaw('false');
|
||||
$query->orWhereIn('id', app(\App\Services\CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record));
|
||||
foreach (['clusters' => 'cluster_id', 'corridors' => 'corridor_id', 'projects' => 'project_id'] as $relation => $foreign) {
|
||||
if ($record->$foreign) {
|
||||
$query->orWhereIn('id', DB::table('opportunity_'.$relation)->where($foreign, $record->$foreign)->select('opportunity_id'));
|
||||
@@ -98,10 +130,27 @@ class InvestmentPortfolioController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
return Inertia::render('portfolio/show', ['record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
|
||||
$anchorProject = $record->project_id
|
||||
? Project::whereKey($record->project_id)->whereNull('deleted_at')->where('status', '!=', 'draft')->first(['id', 'code', 'name', 'status', 'budget_amount', 'budget_currency', 'start_date', 'end_date'])
|
||||
: null;
|
||||
$existingSubmissions = ! $management && $request->user()
|
||||
? InvestmentSubmission::where('user_id', $request->user()->id)->where('dossier_id', $record->id)->where('status', 'draft')
|
||||
->orderByDesc('updated_at')->limit(10)->get(['id', 'version', 'updated_at'])
|
||||
: collect();
|
||||
|
||||
return Inertia::render('portfolio/show', ['complements' => $management ? [] : app(\App\Services\InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
|
||||
'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions,
|
||||
'geography' => app(InvestmentRelations::class)->dossier($record, $management),
|
||||
'origin' => $origin ? ['id' => $origin->id, 'title' => $origin->title] : null, 'opportunities' => $opportunities, 'chains' => $chains]);
|
||||
}
|
||||
|
||||
public function resumeInterest(Request $request, string $id): RedirectResponse
|
||||
{
|
||||
self::visible()->findOrFail($id);
|
||||
$data = $request->validate(['catalogue_query' => ['nullable', 'string', 'max:4000']]);
|
||||
return redirect('/carteira/'.$id.'?'.http_build_query([...$data, 'prepare_interest' => '1']));
|
||||
}
|
||||
|
||||
public function save(Request $request, InvestmentDossier $dossier): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'title' => ['required', 'string', 'max:255'], 'source' => ['required', 'string', 'max:2000'],
|
||||
@@ -114,7 +163,7 @@ class InvestmentPortfolioController extends Controller
|
||||
'publication_status' => 'draft', 'published_at' => null, 'approval_evidence' => null, 'approval_basis' => 'manager'], $request->user()->id);
|
||||
});
|
||||
|
||||
return back()->with('success', 'Rascunho guardado. Pode aprovar e publicar a ficha.');
|
||||
return back()->with('success', 'Rascunho guardado. A publicacao aguarda um revisor diferente do editor.');
|
||||
}
|
||||
|
||||
public function publish(Request $request, InvestmentDossier $dossier): RedirectResponse
|
||||
@@ -123,6 +172,8 @@ class InvestmentPortfolioController extends Controller
|
||||
DB::transaction(function () use ($request, $dossier, $data) {
|
||||
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
|
||||
abort_if($dossier->version_no !== (int) $data['version_no'], 409, 'A ficha foi alterada. Recarregue a página e reveja a versão actual antes de publicar.');
|
||||
abort_unless($dossier->editor_id !== null, 422, 'A ficha deve ser assumida e revista antes da publicacao.');
|
||||
abort_if((int) $dossier->editor_id === (int) $request->user()->id, 403, 'A publicacao exige um revisor diferente do editor.');
|
||||
PortalAudit::save($dossier, [...$data, 'version_no' => $dossier->version_no + 1, 'approval_basis' => 'manager', 'reviewer_id' => $request->user()->id, 'publication_status' => 'published', 'published_at' => now()], $request->user()->id);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\InvestmentRelationshipRepair;
|
||||
use App\Services\InvestmentTerritorialTraceability;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class InvestmentRelationshipRepairController extends Controller
|
||||
{
|
||||
public function preview(InvestmentRelationshipRepair $repair): Response
|
||||
{
|
||||
return Inertia::render('management/relationship-repair', [...$repair->preview(), 'traceability' => app(InvestmentTerritorialTraceability::class)->preview()]);
|
||||
}
|
||||
|
||||
public function apply(Request $request, InvestmentRelationshipRepair $repair): RedirectResponse|JsonResponse
|
||||
{
|
||||
if ($request->input('operation') === 'traceability') {
|
||||
$data = $request->validate(['hash' => ['required', 'string', 'size:64'], 'reverse' => ['required', 'boolean'],
|
||||
'reason' => ['required_if:reverse,true', 'nullable', 'string', 'max:1000'], 'confirm_reverse' => ['accepted_if:reverse,true']]);
|
||||
try {
|
||||
$result = app(InvestmentTerritorialTraceability::class)->apply($data['hash'], $request->user()->id, $data['reverse'], $data['reason'] ?? null);
|
||||
} catch (HttpException $exception) {
|
||||
if ($request->expectsJson()) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return back()->withErrors(['operation' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
return $request->expectsJson() ? response()->json($result) : back()->with('success', $data['reverse'] ? 'Rastreabilidade revertida, histórico preservado.' : 'Rastreabilidade aplicada; validações e dados territoriais preservados.');
|
||||
}
|
||||
$data = $request->validate(['hash' => ['required', 'string', 'size:64'], 'row_id' => ['nullable', 'string', 'size:64'],
|
||||
'validation_hash' => ['required_with:row_id', 'nullable', 'string', 'size:64']]);
|
||||
$result = $repair->apply($data['hash'], $request->user()->id, $data['row_id'] ?? null, $data['validation_hash'] ?? null);
|
||||
|
||||
return $request->expectsJson() ? response()->json($result) : back()->with('success', $result['replayed'] ? 'Este plano já foi aplicado; nenhum duplicado criado.' : 'Associações documentadas aplicadas. Registos de oportunidades permanecem preparados sem publicação automática.');
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ClusterProject;
|
||||
use App\Models\DevelopmentCorridor;
|
||||
use App\Models\EconomicCluster;
|
||||
use App\Models\InvestmentGap;
|
||||
use App\Models\Project;
|
||||
@@ -21,7 +22,7 @@ class InvestmentStructureController extends Controller
|
||||
public function index(Request $request, string $structure): Response
|
||||
{
|
||||
$filters = $request->validate(['q' => ['nullable', 'string', 'max:150']]);
|
||||
$model = $structure === 'projects' ? Project::class : EconomicCluster::class;
|
||||
$model = $this->model($structure);
|
||||
$query = $model::query();
|
||||
if ($term = $filters['q'] ?? null) {
|
||||
$query->where(function ($q) use ($term): void {
|
||||
@@ -33,9 +34,9 @@ class InvestmentStructureController extends Controller
|
||||
'records' => $query->orderBy('name')->orderBy('id')->paginate(20)->withQueryString()]);
|
||||
}
|
||||
|
||||
public function edit(string $structure, ?string $id = null): Response
|
||||
public function edit(Request $request, string $structure, ?string $id = null): Response
|
||||
{
|
||||
$model = $structure === 'projects' ? Project::class : EconomicCluster::class;
|
||||
$model = $this->model($structure);
|
||||
$record = $id ? $model::findOrFail($id) : null;
|
||||
|
||||
return Inertia::render('structures/editor', ['structure' => $structure, 'record' => $record,
|
||||
@@ -49,30 +50,36 @@ class InvestmentStructureController extends Controller
|
||||
->whereNull('origins.deleted_at')->whereNull('destinations.deleted_at')->whereNull('value_chain_links.deleted_at')
|
||||
->orderBy('origins.code')->orderBy('destinations.code')
|
||||
->get(['value_chain_links.id', 'origins.name as origin', 'destinations.name as destination', 'link_type']) : [],
|
||||
'viewerId' => $request->user()->id,
|
||||
'memberships' => $structure === 'clusters' && $id ? DB::table('cluster_projects')
|
||||
->join('projects', 'projects.id', '=', 'cluster_projects.project_id')
|
||||
->where('cluster_id', $id)->whereNull('cluster_projects.deleted_at')->whereNull('projects.deleted_at')
|
||||
->orderBy('projects.name')->get(['cluster_projects.id', 'project_id', 'role', 'projects.name']) : [],
|
||||
->orderBy('projects.name')->get(['cluster_projects.id', 'cluster_projects.project_id', 'cluster_projects.role', 'cluster_projects.association_type', 'cluster_projects.source_reference', 'cluster_projects.evidence_reference', 'cluster_projects.valid_from', 'cluster_projects.valid_until', 'cluster_projects.validation_status', 'cluster_projects.version_no', 'cluster_projects.editor_id', 'projects.name']) : [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(Request $request, string $structure, ?string $id = null): RedirectResponse
|
||||
{
|
||||
$isProject = $structure === 'projects';
|
||||
$model = $isProject ? Project::class : EconomicCluster::class;
|
||||
$isCluster = $structure === 'clusters';
|
||||
$isCorridor = $structure === 'corridors';
|
||||
$model = $this->model($structure);
|
||||
$record = $id ? $model::findOrFail($id) : new $model;
|
||||
$rules = [
|
||||
'code' => ['required', 'string', 'max:100', Rule::unique($record->getTable(), 'code')->whereNull('deleted_at')->ignore($record->getKey())],
|
||||
'name' => ['required', 'string', 'max:200'],
|
||||
'status' => ['required', Rule::in($isProject ? ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'] : ['planned', 'active', 'inactive'])],
|
||||
'status' => ['required', Rule::in($isProject ? ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'] : ($isCluster ? ['planned', 'active', 'inactive'] : ['proposed', 'active', 'closed']))],
|
||||
];
|
||||
if ($isProject || $isCorridor) {
|
||||
$rules['description'] = ['nullable', 'string', 'max:10000'];
|
||||
}
|
||||
if ($isProject) {
|
||||
$rules += ['description' => ['nullable', 'string', 'max:10000'],
|
||||
$rules += [
|
||||
'budget_amount' => ['nullable', 'required_with:budget_currency', 'regex:/^\d{1,18}(\.\d{1,2})?$/'],
|
||||
'budget_currency' => ['nullable', 'required_with:budget_amount', 'regex:/^[A-Z]{3}$/'],
|
||||
'start_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'end_date' => ['nullable', 'date_format:Y-m-d', ...($request->filled('start_date') ? ['after_or_equal:start_date'] : [])]];
|
||||
} else {
|
||||
} elseif ($isCluster) {
|
||||
$rules['sector'] = ['required', 'string', 'max:200'];
|
||||
}
|
||||
if (! $id) {
|
||||
@@ -82,7 +89,7 @@ class InvestmentStructureController extends Controller
|
||||
}
|
||||
$data = $request->validate($rules);
|
||||
if (! $id && $request->has('province_id')) {
|
||||
$path = app(InvestmentGeography::class)->selection($data);
|
||||
$path = app(InvestmentGeography::class)->selection($data, true);
|
||||
$data = array_diff_key($data, InvestmentGeography::FIELDS);
|
||||
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
|
||||
$data['territory_version'] = $path === [] ? 0 : 1;
|
||||
@@ -92,14 +99,52 @@ class InvestmentStructureController extends Controller
|
||||
return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Registo guardado.');
|
||||
}
|
||||
|
||||
/** @return class-string<Project|EconomicCluster|DevelopmentCorridor> */
|
||||
private function model(string $structure): string
|
||||
{
|
||||
return match ($structure) {
|
||||
'projects' => Project::class,
|
||||
'clusters' => EconomicCluster::class,
|
||||
'corridors' => DevelopmentCorridor::class,
|
||||
default => abort(404),
|
||||
};
|
||||
}
|
||||
|
||||
public function linkProject(Request $request, EconomicCluster $cluster): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['project_id' => ['required', 'uuid', Rule::exists('projects', 'id')->whereNull('deleted_at')],
|
||||
'role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])]]);
|
||||
'version_no' => ['required', 'integer', 'min:0'],
|
||||
'role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])],
|
||||
'association_type' => ['required', Rule::in(['official', 'influence', 'proximity', 'potential', 'historical'])],
|
||||
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
'evidence_reference' => ['required', 'string', 'min:3', 'max:2000'],
|
||||
'valid_from' => ['required', 'date_format:Y-m-d'],
|
||||
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from']]);
|
||||
DB::transaction(function () use ($cluster, $data, $request) {
|
||||
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
|
||||
$link = ClusterProject::where('cluster_id', $cluster->id)->where('project_id', $data['project_id'])->first() ?? new ClusterProject;
|
||||
PortalAudit::save($link, [...$data, 'cluster_id' => $cluster->id], $request->user()->id);
|
||||
$link = ClusterProject::where('cluster_id', $cluster->id)->where('project_id', $data['project_id'])->whereNull('deleted_at')->lockForUpdate()->first();
|
||||
if ($link) {
|
||||
abort_if((int) $link->version_no !== (int) $data['version_no'], 409, 'Versao da associacao alterada.');
|
||||
$changed = false;
|
||||
foreach (['role', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until'] as $field) {
|
||||
$current = $link->getAttribute($field);
|
||||
$current = $current instanceof \DateTimeInterface ? $current->format('Y-m-d') : $current;
|
||||
if (($current ?: null) !== (($data[$field] ?? null) ?: null)) {
|
||||
$changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (! $changed) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
abort_if((int) $data['version_no'] !== 0, 409, 'A associacao foi alterada. Actualize a pagina.');
|
||||
$link = new ClusterProject;
|
||||
}
|
||||
unset($data['version_no']);
|
||||
$nextVersion = $link->exists ? ((int) $link->version_no + 1) : 1;
|
||||
PortalAudit::save($link, [...$data, 'cluster_id' => $cluster->id, 'validation_status' => 'pending',
|
||||
'editor_id' => $request->user()->id, 'validator_id' => null, 'version_no' => $nextVersion], $request->user()->id);
|
||||
});
|
||||
|
||||
return back()->with('success', 'Associação ao projecto guardada.');
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class InvestmentStructureRelationController extends Controller
|
||||
{
|
||||
private const TABLES = [
|
||||
'cluster-project' => ['cluster_projects', 'cluster_id', 'project_id', 'economic_clusters', 'projects'],
|
||||
'corridor-project' => ['corridor_projects', 'corridor_id', 'project_id', 'development_corridors', 'projects'],
|
||||
'corridor-cluster' => ['corridor_clusters', 'corridor_id', 'cluster_id', 'development_corridors', 'economic_clusters'],
|
||||
];
|
||||
|
||||
public function store(Request $request, string $kind): RedirectResponse
|
||||
{
|
||||
[$table, $left, $right, $leftTable, $rightTable] = $this->definition($kind);
|
||||
$data = $request->validate([
|
||||
$left => ['required', 'uuid', Rule::exists($leftTable, 'id')->whereNull('deleted_at')],
|
||||
$right => ['required', 'uuid', Rule::exists($rightTable, 'id')->whereNull('deleted_at')],
|
||||
'association_type' => ['required', Rule::in(['official', 'influence', 'proximity', 'potential', 'historical'])],
|
||||
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
'evidence_reference' => ['required', 'string', 'min:3', 'max:2000'],
|
||||
'valid_from' => ['required', 'date_format:Y-m-d'],
|
||||
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
|
||||
...($kind === 'cluster-project' ? ['role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])]] : []),
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($data, $table, $left, $right, $leftTable, $request): void {
|
||||
DB::table($leftTable)->where('id', $data[$left])->lockForUpdate()->firstOrFail();
|
||||
$existing = DB::table($table)->where($left, $data[$left])->where($right, $data[$right])->whereNull('deleted_at')->lockForUpdate()->first();
|
||||
abort_if($existing !== null, 409, 'Associacao ja registada.');
|
||||
$values = [...$data, 'association_type' => $data['association_type'], 'validation_status' => 'pending',
|
||||
'editor_id' => $request->user()->id, 'validator_id' => null, 'version_no' => 1,
|
||||
'deleted_at' => null, 'updated_at' => now()];
|
||||
$id = (string) Str::uuid();
|
||||
DB::table($table)->insert([...$values, 'id' => $id, 'source_system' => 'portal-management']);
|
||||
$this->audit($request, $table, $id, 'submitted', 1);
|
||||
});
|
||||
|
||||
return back()->with('success', 'Associacao submetida para revisao independente.');
|
||||
}
|
||||
|
||||
public function review(Request $request, string $kind, string $id, string $action): RedirectResponse
|
||||
{
|
||||
abort_unless(in_array($action, ['validate', 'revoke'], true), 404);
|
||||
[$table] = $this->definition($kind);
|
||||
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
|
||||
|
||||
DB::transaction(function () use ($request, $table, $id, $action, $data): void {
|
||||
$relation = DB::table($table)->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($relation !== null, 404);
|
||||
abort_if((int) $relation->version_no !== (int) $data['version_no'], 409, 'Versao da associacao alterada.');
|
||||
if ($action === 'validate') {
|
||||
abort_if((int) $relation->editor_id === (int) $request->user()->id, 403, 'A associacao exige revisao independente.');
|
||||
abort_unless($relation->validation_status === 'pending' && $relation->source_reference && $relation->evidence_reference && $relation->valid_from, 422, 'Fonte, evidencia ou vigencia em falta.');
|
||||
}
|
||||
$nextVersion = (int) $relation->version_no + 1;
|
||||
DB::table($table)->where('id', $id)->update(['validation_status' => $action === 'validate' ? 'validated' : 'revoked',
|
||||
'validator_id' => $request->user()->id, 'version_no' => $nextVersion, 'updated_at' => now()]);
|
||||
$this->audit($request, $table, $id, $action, $nextVersion);
|
||||
});
|
||||
|
||||
return back()->with('success', $action === 'validate' ? 'Associacao validada.' : 'Associacao revogada.');
|
||||
}
|
||||
|
||||
/** @return array{string,string,string,string,string} */
|
||||
private function definition(string $kind): array
|
||||
{
|
||||
abort_unless(isset(self::TABLES[$kind]), 404);
|
||||
|
||||
return self::TABLES[$kind];
|
||||
}
|
||||
|
||||
private function audit(Request $request, string $table, string $id, string $action, int $version): void
|
||||
{
|
||||
DB::table('audit_events')->insert(['source_system' => 'investment-structure-relations', 'entity_type' => $table,
|
||||
'entity_id' => $id, 'actor_user_id' => $request->user()->id, 'action' => $action,
|
||||
'after_data' => json_encode(['version_no' => $version, 'validation_status' => $action === 'validate' ? 'validated' : null], JSON_THROW_ON_ERROR)]);
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,22 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\InterestRequest;
|
||||
use App\Models\InvestmentSpecificNeed;
|
||||
use App\Models\InvestmentSubmission;
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\CountryCatalogue;
|
||||
use App\Services\InterestSubmission;
|
||||
use App\Services\InvestmentCatalogue;
|
||||
use App\Services\InvestmentWorkspace;
|
||||
use App\Services\InvestmentZones;
|
||||
use App\Services\InvestorContextConsumers;
|
||||
use App\Services\VirtualDesk;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -26,7 +31,14 @@ class InvestmentSubmissionController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response|JsonResponse
|
||||
{
|
||||
$submissions = InvestmentSubmission::where('user_id', $request->user()->id)->latest()->paginate(20)->through(function ($row) {
|
||||
$query = InvestmentSubmission::where('user_id', $request->user()->id);
|
||||
if (! $request->user()->hasVerifiedEmail()) {
|
||||
// The existing payload is encrypted; scope after decryption, never infer ownership from email.
|
||||
$simpleIds = InvestmentSubmission::where('user_id', $request->user()->id)->where('status', 'submitted')->get(['id', 'payload'])
|
||||
->filter(fn ($row) => ($row->payload['simple_participation'] ?? false) === true)->modelKeys();
|
||||
$query->whereIn('id', $simpleIds);
|
||||
}
|
||||
$submissions = $query->latest()->paginate(20)->through(function ($row) {
|
||||
$interest = $row->interest_request_id ? InterestRequest::find($row->interest_request_id) : null;
|
||||
|
||||
return ['id' => $row->id, 'status' => $row->status, 'version' => $row->version, 'created_at' => $row->created_at,
|
||||
@@ -48,7 +60,10 @@ class InvestmentSubmissionController extends Controller
|
||||
$company = DB::table('investment_company_declarations')->where('id', $data['company_id'])->where('user_id', $request->user()->id)->first();
|
||||
abort_unless($company !== null, 404);
|
||||
$companyData = json_decode(Crypt::decryptString($company->payload), true, 512, JSON_THROW_ON_ERROR);
|
||||
$payload = [...$payload, 'organisation' => $companyData['name'], 'representative_role' => $companyData['role'], 'country' => $companyData['country']];
|
||||
$payload = [...$payload, 'organisation' => $companyData['name'], 'representative_role' => $companyData['role'], 'incorporation_country_code' => $companyData['incorporation_country_code'] ?? null];
|
||||
if (isset($companyData['organization_country_snapshot'])) {
|
||||
$payload['organization_country_snapshot'] = $companyData['organization_country_snapshot'];
|
||||
}
|
||||
}
|
||||
unset($data['company_id']);
|
||||
if (! empty($data['opportunity_id'])) {
|
||||
@@ -110,24 +125,58 @@ class InvestmentSubmissionController extends Controller
|
||||
$target = $row->opportunity_id ? Opportunity::published()->find($row->opportunity_id) : null;
|
||||
$dossier = $row->dossier_id ? InvestmentPortfolioController::visible()->find($row->dossier_id) : null;
|
||||
$zoneOrigin = DB::table('investment_submission_zones')->where('submission_id', $id)->first(['zone_id', 'zone_version', 'intent']);
|
||||
$data = ['submission' => $row->only(['id', 'payload', 'version', 'status', 'opportunity_id', 'dossier_id']), 'zone_origin' => $zoneOrigin,
|
||||
'target' => ($target ? $target->title : null) ?? ($dossier ? $dossier->title : null), 'references' => [
|
||||
$contextAvailable = app(VirtualDesk::class)->installed();
|
||||
$boundCase = $contextAvailable ? DB::table('desk_cases')->where('submission_id', $id)->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first() : null;
|
||||
$contextChoices = $contextAvailable && (int) $row->user_id === (int) $request->user()->id && $row->status === 'draft' && ! $boundCase
|
||||
? DB::table('desk_cases')->where('user_id', $request->user()->id)->whereNotNull('journey_id')->whereNull('closed_at')->whereNull('submission_id')->orderBy('title')->get(['id', 'title', 'version']) : [];
|
||||
$data = ['contextDossierId' => $boundCase?->id, 'contextChoices' => $contextChoices, 'boundContextRevision' => $contextAvailable ? app(InvestorContextConsumers::class)->submissionRevision($id) : 0, 'countries' => app(CountryCatalogue::class)->all(), 'submission' => $row->only(['id', 'payload', 'version', 'status', 'opportunity_id', 'dossier_id']), 'zone_origin' => $zoneOrigin,
|
||||
'target' => ($target ? $target->title : null) ?? ($dossier ? $dossier->title : null) ?? ($row->payload['opportunity_title'] ?? null), 'references' => [
|
||||
...$catalogue->references(), 'options' => DB::table('investment_reference_options')->orderBy('label')->get()],
|
||||
'needs' => InvestmentSpecificNeed::where('submission_id', $id)->get(),
|
||||
'attachments' => DB::table('investment_submission_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
|
||||
->where('a.submission_id', $id)->whereNull('d.deleted_at')->get(['a.id', 'd.file_name']),
|
||||
'events' => DB::table('investment_submission_events')->where('submission_id', $id)->orderBy('id')->get(['action', 'version', 'created_at']),
|
||||
'events' => DB::table('investment_submission_events')->where('submission_id', $id)->orderBy('id')->get(['action', 'version', 'created_at'])
|
||||
->concat($interest ? DB::table('business_workflow_transitions')->where('interest_request_id', $interest->id)->orderBy('created_at')->get(['to_status', 'created_at'])->map(fn ($event) => ['action' => $event->to_status, 'version' => $row->version, 'created_at' => $event->created_at]) : [])->sortBy('created_at')->values(),
|
||||
'revisions' => DB::table('investment_submission_revisions')->where('submission_id', $id)->orderByDesc('version')->get(['version', 'created_at']),
|
||||
'messages' => DB::table('investment_messages')->where('submission_id', $id)->orderBy('created_at')->get(['id', 'sender_role', 'body', 'created_at'])->map(function ($message) {
|
||||
$message->body = Crypt::decryptString($message->body);
|
||||
|
||||
return $message;
|
||||
}),
|
||||
'receipt' => $interest?->only(['reference', 'status']), 'management' => (int) $row->user_id !== $request->user()->id];
|
||||
'receipt' => $interest?->only(['reference', 'status', 'next_action', 'next_action_due_at']), 'management' => (int) $row->user_id !== $request->user()->id];
|
||||
|
||||
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/submission', $data);
|
||||
}
|
||||
|
||||
public function bindContext(Request $request, string $id): JsonResponse
|
||||
{
|
||||
abort_unless(app(VirtualDesk::class)->installed(), 422, 'O vínculo ao percurso do investimento não está disponível neste destino. A manifestação e a sua origem permanecem guardadas.');
|
||||
abort_if(array_diff(array_keys($request->all()), ['case_id', 'case_version', 'version', '_token']), 422, 'Parâmetros de associação inválidos.');
|
||||
$data = $request->validate(['case_id' => 'required|uuid', 'case_version' => 'required|integer|min:1', 'version' => 'required|integer|min:1']);
|
||||
$result = DB::transaction(function () use ($request, $id, $data) {
|
||||
// Serialize all links for this submission before locking the selected case.
|
||||
$row = $this->owned($request, $id, true, true);
|
||||
$desk = app(VirtualDesk::class);
|
||||
$case = $desk->case($request->user(), $data['case_id'], true, true);
|
||||
abort_unless((int) $case->user_id === (int) $request->user()->id && $case->journey_id, 403, 'Dossier próprio autorizado necessário.');
|
||||
$existing = DB::table('desk_cases')->where('submission_id', $id)->first(['id']);
|
||||
if ($existing && $existing->id === $case->id) {
|
||||
return ['case_id' => $case->id, 'version' => $row->version, 'case_version' => (int) $case->version, 'replayed' => true];
|
||||
}
|
||||
abort_if($existing || $case->submission_id, 409, 'Existe um vínculo anterior; não foi substituído nem criado outro dossier.');
|
||||
abort_unless($row->status === 'draft' && ! $case->closed_at && (int) $row->version === (int) $data['version'] && (int) $case->version === (int) $data['case_version'], 409, 'Rascunho ou dossier alterado. Recarregue antes de associar.');
|
||||
DB::table('desk_cases')->where('id', $case->id)->update(['submission_id' => $id, 'version' => $case->version + 1, 'updated_at' => now()]);
|
||||
$row->update(['version' => $row->version + 1]);
|
||||
// Binding does not save form edits or assert that the context has been reviewed.
|
||||
$this->event($row, $request, 'context_bound');
|
||||
$desk->event($desk->case($request->user(), $case->id), $request->user(), 'manifestation_context_bound', ['submission_id' => $id, 'previous_case_version' => (int) $case->version]);
|
||||
|
||||
return ['case_id' => $case->id, 'version' => $row->version, 'case_version' => (int) $case->version + 1, 'replayed' => false];
|
||||
}, 3);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
private function owned(Request $request, string $id, bool $lock = false, bool $ownerOnly = false): InvestmentSubmission
|
||||
{
|
||||
$query = InvestmentSubmission::whereKey($id);
|
||||
@@ -143,10 +192,17 @@ class InvestmentSubmissionController extends Controller
|
||||
{
|
||||
$required = $submitting ? 'required' : 'nullable';
|
||||
$data = $request->validate(['version' => ['required', 'integer', 'min:1'],
|
||||
'payload' => ['required', 'array:full_name,email,phone,country,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'],
|
||||
'payload' => ['required', 'array:context_opportunity,context_opportunity_snapshot,context_reuse,full_name,email,phone,country,residence_country_code,incorporation_country_code,organization_country_snapshot,declared_origin_country_code,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'],
|
||||
'payload.organization_country_snapshot' => ['sometimes', 'array', 'max:10'],
|
||||
'payload.residence_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
|
||||
'payload.incorporation_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
|
||||
'payload.declared_origin_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
|
||||
'payload.context_opportunity' => ['nullable', 'string', 'max:100'],
|
||||
'payload.context_opportunity_snapshot' => ['nullable', 'string', 'max:200000'],
|
||||
'payload.context_reuse' => ['nullable', 'string', 'max:10000'],
|
||||
'payload.other_details' => ['nullable', 'string', 'max:5000'],
|
||||
'payload.full_name' => [$required, 'string', ...($submitting ? ['min:3'] : []), 'max:150'], 'payload.email' => [$required, $submitting ? 'email:rfc' : 'string', 'max:150'],
|
||||
'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => [$required, 'string', 'max:100'],
|
||||
'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => [$submitting ? 'required_without:payload.declared_origin_country_code' : 'nullable', 'nullable', 'string', 'max:100'],
|
||||
'payload.organisation' => ['nullable', 'string', 'max:200'], 'payload.representative_role' => ['nullable', 'string', 'max:150'],
|
||||
'payload.experience' => ['nullable', 'string', 'max:2000'], 'payload.investment_capacity' => ['nullable', 'string', 'max:1000'],
|
||||
'payload.profile_id' => [$required, 'integer'], 'payload.sector' => [$required, 'string', 'max:150'],
|
||||
@@ -193,6 +249,9 @@ class InvestmentSubmissionController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$bound = DB::table('desk_cases')->where('submission_id', $request->route('id'))->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first();
|
||||
if ($bound) $data['payload'] = app(\App\Services\InvestmentConsumerScope::class)->bind($request->user(), $bound->id, $data['payload'], $submitting);
|
||||
else unset($data['payload']['context_opportunity'], $data['payload']['context_opportunity_snapshot']);
|
||||
return $data;
|
||||
}
|
||||
|
||||
@@ -212,7 +271,7 @@ class InvestmentSubmissionController extends Controller
|
||||
$version = DB::transaction(function () use ($request, $id, $data) {
|
||||
$row = $this->owned($request, $id, true, true);
|
||||
abort_unless($row->status === 'draft' && $row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue antes de guardar.');
|
||||
$row->update(['payload' => $data['payload'], 'version' => $row->version + 1]);
|
||||
$row->update([...(isset($data['payload']['context_opportunity']) ? ['opportunity_id'=>(int)substr($data['payload']['context_opportunity'],12)] : []), 'payload' => $this->preserveCountrySource($row, $data['payload']), 'version' => $row->version + 1]);
|
||||
InvestmentSpecificNeed::where('submission_id', $id)->delete();
|
||||
foreach ($data['needs'] ?? [] as $need) {
|
||||
InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]);
|
||||
@@ -225,6 +284,23 @@ class InvestmentSubmissionController extends Controller
|
||||
return $request->expectsJson() ? response()->json(['version' => $version]) : back()->with('success', 'Rascunho guardado.');
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $payload
|
||||
* @return array<string, mixed> */
|
||||
private function preserveCountrySource(InvestmentSubmission $row, array $payload): array
|
||||
{
|
||||
$source = $row->payload['organization_country_snapshot'] ?? null;
|
||||
if (isset($payload['organization_country_snapshot']) && (! is_array($payload['organization_country_snapshot']) || ! is_array($source) ||
|
||||
Arr::sortRecursive($payload['organization_country_snapshot']) !== Arr::sortRecursive($source))) {
|
||||
throw ValidationException::withMessages(['payload.organization_country_snapshot' => 'A proveniência é recuperada no servidor; não pode ser substituída pelo formulário.']);
|
||||
}
|
||||
unset($payload['organization_country_snapshot']);
|
||||
if ($source && ($row->payload['incorporation_country_code'] ?? null) === ($payload['incorporation_country_code'] ?? null)) {
|
||||
$payload['organization_country_snapshot'] = $source;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public function submit(Request $request, string $id, InterestSubmission $intake): RedirectResponse|JsonResponse
|
||||
{
|
||||
// Replaying a completed submission returns only the owner's existing receipt.
|
||||
@@ -239,11 +315,12 @@ class InvestmentSubmissionController extends Controller
|
||||
return InterestRequest::findOrFail($row->interest_request_id)->reference;
|
||||
}
|
||||
abort_unless($row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue.');
|
||||
$opportunity = $row->opportunity_id ? Opportunity::published()->findOrFail($row->opportunity_id) : null;
|
||||
$scopeKey = $data['payload']['context_opportunity'] ?? null;
|
||||
$opportunity = $scopeKey ? Opportunity::published()->findOrFail(substr($scopeKey,12)) : ($row->opportunity_id ? Opportunity::published()->findOrFail($row->opportunity_id) : null);
|
||||
if ($row->dossier_id) {
|
||||
InvestmentPortfolioController::visible()->findOrFail($row->dossier_id);
|
||||
}
|
||||
$p = $data['payload'];
|
||||
$p = $this->preserveCountrySource($row, $data['payload']);
|
||||
$zoneOrigin = DB::table('investment_submission_zones')->where('submission_id', $id)->first(['zone_id', 'zone_version', 'intent']);
|
||||
if ($zoneOrigin) {
|
||||
$zone = app(InvestmentZones::class)->visible()->whereKey($zoneOrigin->zone_id)->lockForUpdate()->firstOrFail();
|
||||
@@ -256,12 +333,18 @@ class InvestmentSubmissionController extends Controller
|
||||
$interest = $intake->receive(['email' => $p['email'], 'fullName' => $p['full_name'], 'message' => $p['objective'], 'submission_id' => $id], $id,
|
||||
fn () => InterestRequest::create(['reference' => 'IR-'.now()->year.'-'.Str::ulid(), 'opportunity_id' => $opportunity?->id,
|
||||
'opportunity_reference' => $opportunity?->reference, 'full_name' => $p['full_name'], 'email' => $p['email'], 'phone' => $p['phone'] ?? null,
|
||||
'country' => $p['country'], 'investor_type' => empty($p['organisation']) ? 'individual' : 'empresa',
|
||||
'country' => $p['country'] ?? 'Não informado', 'investor_type' => empty($p['organisation']) ? 'individual' : 'empresa',
|
||||
'sector_interest' => $p['sector'], 'message' => $p['objective'], 'privacy_consent' => true, 'consented_at' => now(),
|
||||
'status' => 'received', 'source_page' => '/investidor/manifestacoes/'.$id,
|
||||
'metadata' => ['submission_id' => $id, 'portal_user_id' => $request->user()->id, 'dossier_id' => $row->dossier_id,
|
||||
'context_opportunity' => $p['context_opportunity'] ?? null,
|
||||
'context_opportunity_snapshot' => $p['context_opportunity_snapshot'] ?? null,
|
||||
'declared_origin_country_code' => $p['declared_origin_country_code'] ?? null,
|
||||
'residence_country_code' => $p['residence_country_code'] ?? null,
|
||||
'incorporation_country_code' => $p['incorporation_country_code'] ?? null,
|
||||
...(isset($p['organization_country_snapshot']) ? ['organization_country_snapshot' => $p['organization_country_snapshot']] : []),
|
||||
...($zoneOrigin ? ['zone_origin' => (array) $zoneOrigin] : [])]]));
|
||||
$row->update(['payload' => $p, 'version' => $row->version + 1, 'interest_request_id' => $interest->id, 'status' => 'submitted']);
|
||||
$row->update(['opportunity_id'=>$opportunity?->id, 'payload' => $p, 'version' => $row->version + 1, 'interest_request_id' => $interest->id, 'status' => 'submitted']);
|
||||
InvestmentSpecificNeed::where('submission_id', $id)->delete();
|
||||
foreach ($data['needs'] ?? [] as $need) {
|
||||
InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]);
|
||||
@@ -293,7 +376,8 @@ class InvestmentSubmissionController extends Controller
|
||||
abort_unless($key !== false, 500);
|
||||
DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'investor-submission', 'file_name' => basename($file->getClientOriginalName()),
|
||||
'media_type' => $file->getMimeType(), 'storage_key' => $key, 'sha256' => hash_file('sha256', Storage::disk('local')->path($key)),
|
||||
'status' => 'active', 'data_classification' => 'confidential']);
|
||||
'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id,
|
||||
'status' => 'quarantined', 'data_classification' => 'confidential']);
|
||||
DB::table('investment_submission_attachments')->insert(['id' => (string) Str::uuid(), 'submission_id' => $id, 'document_id' => $documentId, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$row->increment('version');
|
||||
$this->event($row, $request, 'attachment_added');
|
||||
@@ -307,6 +391,8 @@ class InvestmentSubmissionController extends Controller
|
||||
throw $e;
|
||||
}
|
||||
|
||||
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
|
||||
|
||||
return $request->expectsJson() ? response()->json(['version' => $version]) : back()->with('success', 'Anexo privado guardado.');
|
||||
}
|
||||
|
||||
@@ -314,23 +400,34 @@ class InvestmentSubmissionController extends Controller
|
||||
{
|
||||
$this->owned($request, $id);
|
||||
$doc = DB::table('investment_submission_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
|
||||
->where('a.id', $attachment)->where('a.submission_id', $id)->where('d.status', 'active')->whereNull('d.deleted_at')->first(['d.file_name', 'd.storage_key', 'd.sha256']);
|
||||
->where('a.id', $attachment)->where('a.submission_id', $id)->where('d.status', 'active')->whereNull('d.deleted_at')
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')
|
||||
->first(['d.file_name', 'd.storage_key', 'd.sha256', 'd.malware_scanned_sha256']);
|
||||
abort_unless($doc !== null, 404);
|
||||
$root = realpath(Storage::disk('local')->path('investment-submissions/'.$id));
|
||||
$path = realpath(Storage::disk('local')->path($doc->storage_key));
|
||||
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
|
||||
$hash = hash_file('sha256', $path);
|
||||
abort_unless(is_string($hash) && hash_equals($doc->sha256, $hash), 404);
|
||||
abort_unless(is_string($hash) && hash_equals($doc->sha256, $hash)
|
||||
&& hash_equals($doc->sha256, (string) $doc->malware_scanned_sha256), 404);
|
||||
|
||||
return response()->download($path, basename($doc->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
|
||||
}
|
||||
|
||||
private function event(InvestmentSubmission $row, Request $request, string $action): void
|
||||
{
|
||||
$boundCase = app(VirtualDesk::class)->installed() ? DB::table('desk_cases')->where('submission_id', $row->id)->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first() : null;
|
||||
$binding = null;
|
||||
if ($boundCase) {
|
||||
$binding = app(InvestorContextConsumers::class)->submissionRevision($row->id);
|
||||
if ($action === 'submitted' || $request->boolean('context_reviewed')) {
|
||||
$binding = app(InvestorContextConsumers::class)->assertReviewed($request->user(), $boundCase->id, $request->all(), $binding);
|
||||
}
|
||||
}
|
||||
app(InvestmentWorkspace::class)->snapshot($row);
|
||||
DB::table('investment_submission_events')->insert(['submission_id' => $row->id, 'actor_id' => $request->user()->id, 'action' => $action, 'version' => $row->version, 'created_at' => now()]);
|
||||
DB::table('audit_events')->insert(['source_system' => 'investment-submission', 'entity_type' => 'investment_submissions',
|
||||
'entity_id' => $row->id, 'actor_user_id' => $request->user()->id, 'action' => $action,
|
||||
'after_data' => json_encode(['version' => $row->version, 'status' => $row->status], JSON_THROW_ON_ERROR)]);
|
||||
'after_data' => json_encode(['version' => $row->version, 'status' => $row->status, ...($binding !== null ? ['investment_context_revision' => $binding] : [])], JSON_THROW_ON_ERROR)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,40 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\InvestmentZones;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use App\Services\TerritoryGeometry;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class InvestmentTerritoryController extends Controller
|
||||
{
|
||||
public function geometries(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['territory_id' => ['required', 'uuid']]);
|
||||
$profile = $request->is('minhas-oportunidades/*');
|
||||
$management = $request->is('management/*') || $profile;
|
||||
$actor = $request->user();
|
||||
$access = app(PortalProfileAccess::class);
|
||||
// The general consumers use this same endpoint while authenticated.
|
||||
// Reuse the read access already granted by the private cadastral routes.
|
||||
$profileAccess = $actor && $actor->hasVerifiedEmail()
|
||||
&& ($access->grants($actor) !== [] || $access->superuser($actor));
|
||||
$internal = $profileAccess || ($actor?->hasVerifiedEmail() && $actor->can('manage-portal'));
|
||||
if ($management) {
|
||||
abort_unless($profile ? $profileAccess : $internal, 403);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
...app(TerritoryGeometry::class)->context($data['territory_id'], $internal),
|
||||
'geometry_scope' => $internal ? 'authorized_internal' : 'public',
|
||||
])->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
private const TABLES = ['opportunities' => 'opportunities', 'projects' => 'projects', 'clusters' => 'economic_clusters', 'corridors' => 'development_corridors', 'zones' => 'investment_zones'];
|
||||
|
||||
public function subjects(Request $request): JsonResponse
|
||||
@@ -17,6 +43,9 @@ class InvestmentTerritoryController extends Controller
|
||||
$data = $request->validate(['kind' => ['required', 'in:'.implode(',', array_keys(self::TABLES))], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
|
||||
$column = $data['kind'] === 'opportunities' ? 'title' : 'name';
|
||||
$query = DB::table(self::TABLES[$data['kind']])->whereNull('deleted_at');
|
||||
if ($data['kind'] === 'opportunities' && Schema::hasColumn('opportunities', 'owner_user_id')) {
|
||||
$query->whereIn('id', app(PortalProfileAccess::class)->scope(Opportunity::query(), $request->user())->select('id'));
|
||||
}
|
||||
if (! empty($data['q'])) {
|
||||
$query->whereRaw($column.' ILIKE ?', ['%'.addcslashes($data['q'], '%_\\').'%']);
|
||||
}
|
||||
@@ -26,17 +55,26 @@ class InvestmentTerritoryController extends Controller
|
||||
|
||||
public function options(Request $request): JsonResponse
|
||||
{
|
||||
if ($request->is('minhas-oportunidades/*')) {
|
||||
$access = app(PortalProfileAccess::class);
|
||||
abort_unless($access->grants($request->user()) !== [] || $access->superuser($request->user()), 403);
|
||||
}
|
||||
$data = $request->validate(['type' => ['required', 'in:province,district,admin_post,locality'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
|
||||
$query = DB::table('geo_locations')->whereNull('deleted_at')->where('location_type', $data['type'])
|
||||
->whereIn('data_classification', ['public', 'internal']);
|
||||
if ($data['type'] !== 'province') {
|
||||
$parentType = ['district' => 'province', 'admin_post' => 'district', 'locality' => 'admin_post'][$data['type']];
|
||||
if (! DB::table('geo_locations')->where('id', $data['parent_id'] ?? '00000000-0000-0000-0000-000000000000')->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])->where('location_type', $parentType)->exists()) {
|
||||
throw ValidationException::withMessages(['parent_id' => 'Seleccione um pai activo do nível anterior.']);
|
||||
}
|
||||
$query->where('parent_id', $data['parent_id'] ?? '00000000-0000-0000-0000-000000000000');
|
||||
}
|
||||
if (! empty($data['q'])) {
|
||||
$query->whereRaw('name ILIKE ?', ['%'.addcslashes($data['q'], '%_\\').'%']);
|
||||
$term = '%'.addcslashes($data['q'], '%_\\').'%';
|
||||
$query->where(fn ($q) => $q->whereRaw('name ILIKE ?', [$term])->orWhereRaw('code ILIKE ?', [$term])->orWhereRaw('source_code ILIKE ?', [$term]));
|
||||
}
|
||||
|
||||
return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type']));
|
||||
return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type', 'source_code']));
|
||||
}
|
||||
|
||||
public function show(string $kind, string $id): JsonResponse
|
||||
|
||||
@@ -4,7 +4,9 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\InvestmentSubmission;
|
||||
use App\Models\User;
|
||||
use App\Services\CountryCatalogue;
|
||||
use App\Services\InvestmentWorkspace;
|
||||
use App\Services\OrganizationCountry;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -18,9 +20,10 @@ class InvestmentWorkspaceController extends Controller
|
||||
{
|
||||
public function profile(Request $request, InvestmentWorkspace $workspace): Response|JsonResponse
|
||||
{
|
||||
$data = ['account' => $workspace->account($request->user()->id), 'companies' => DB::table('investment_company_declarations')
|
||||
$data = ['organizationCountryAvailable' => app(OrganizationCountry::class)->available(), 'organizations' => app(OrganizationCountry::class)->represented($request->user()), 'countries' => app(CountryCatalogue::class)->all(), 'account' => $workspace->account($request->user()->id), 'companies' => DB::table('investment_company_declarations')
|
||||
->where('user_id', $request->user()->id)->orderByDesc('created_at')->get(['id', 'version', 'payload'])->map(function ($company) {
|
||||
$company->payload = json_decode(Crypt::decryptString($company->payload), true, 512, JSON_THROW_ON_ERROR);
|
||||
$company->countrySource = $company->payload['organization_country_snapshot'] ?? null;
|
||||
|
||||
return $company;
|
||||
})];
|
||||
@@ -31,7 +34,8 @@ class InvestmentWorkspaceController extends Controller
|
||||
public function saveProfile(Request $request): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate(['version' => ['required', 'integer', 'min:0'],
|
||||
'payload' => ['required', 'array:phone,country,sector,experience,investment_capacity'],
|
||||
'payload' => ['required', 'array:phone,country,residence_country_code,sector,experience,investment_capacity'],
|
||||
'payload.residence_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
|
||||
'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => ['nullable', 'string', 'max:100'],
|
||||
'payload.sector' => ['nullable', 'string', 'max:150'], 'payload.experience' => ['nullable', 'string', 'max:2000'],
|
||||
'payload.investment_capacity' => ['nullable', 'string', 'max:1000']]);
|
||||
@@ -53,8 +57,9 @@ class InvestmentWorkspaceController extends Controller
|
||||
public function company(Request $request, ?string $id = null): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate(['version' => ['required', 'integer', 'min:0'],
|
||||
'payload' => ['required', 'array:name,country,registration,sector,role,description'],
|
||||
'payload.name' => ['required', 'string', 'min:2', 'max:200'], 'payload.country' => ['required', 'string', 'max:100'],
|
||||
'payload' => ['required', 'array:name,country,incorporation_country_code,registration,sector,role,description'],
|
||||
'payload.incorporation_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
|
||||
'payload.name' => ['required', 'string', 'min:2', 'max:200'], 'payload.country' => ['required_without:payload.incorporation_country_code', 'nullable', 'string', 'max:100'],
|
||||
'payload.registration' => ['nullable', 'string', 'max:100'], 'payload.sector' => ['nullable', 'string', 'max:150'],
|
||||
'payload.role' => ['required', 'string', 'max:150'], 'payload.description' => ['nullable', 'string', 'max:2000']]);
|
||||
$result = DB::transaction(function () use ($request, $id, $data) {
|
||||
@@ -65,9 +70,15 @@ class InvestmentWorkspaceController extends Controller
|
||||
abort_if(! $id && DB::table('investment_company_declarations')->where('user_id', $request->user()->id)->count() >= 20, 422, 'Máximo de vinte empresas declaradas.');
|
||||
$id ??= (string) Str::uuid();
|
||||
$version = (int) $data['version'] + 1;
|
||||
$previous = $row ? json_decode(Crypt::decryptString($row->payload), true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
$payload = [...$previous, ...$data['payload']];
|
||||
if (($previous['incorporation_country_code'] ?? null) !== ($payload['incorporation_country_code'] ?? null)) {
|
||||
unset($payload['organization_country_snapshot']);
|
||||
}
|
||||
$encrypted = Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR));
|
||||
DB::table('investment_company_declarations')->updateOrInsert(['id' => $id], ['user_id' => $request->user()->id, 'version' => $version,
|
||||
'payload' => Crypt::encryptString(json_encode($data['payload'], JSON_THROW_ON_ERROR)), 'created_at' => $row->created_at ?? now(), 'updated_at' => now()]);
|
||||
$this->audit($request, 'company_declared', 'investment_company_declarations', $id, ['version' => $version]);
|
||||
'payload' => $encrypted, 'created_at' => $row->created_at ?? now(), 'updated_at' => now()]);
|
||||
$this->audit($request, 'company_declared', 'investment_company_declarations', $id, ['version' => $version, 'before_payload' => $row?->payload, 'after_payload' => $encrypted]);
|
||||
|
||||
return ['id' => $id, 'version' => $version];
|
||||
});
|
||||
@@ -75,6 +86,36 @@ class InvestmentWorkspaceController extends Controller
|
||||
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'Declaração de empresa guardada.');
|
||||
}
|
||||
|
||||
public function organizationCountry(Request $request, string $id, OrganizationCountry $service): RedirectResponse|JsonResponse
|
||||
{
|
||||
$result = $service->save($request->user(), $id, $request->all());
|
||||
|
||||
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'País de constituição guardado no cadastro mestre; submissões anteriores preservadas.');
|
||||
}
|
||||
|
||||
public function reuseOrganizationCountry(Request $request, string $id, OrganizationCountry $service): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate(['version' => 'required|integer|min:1', 'organization_id' => 'required|uuid', 'organization_version' => 'required|integer|min:1']);
|
||||
$result = DB::transaction(function () use ($request, $id, $data, $service) {
|
||||
$row = DB::table('investment_company_declarations')->where('id', $id)->where('user_id', $request->user()->id)->lockForUpdate()->first();
|
||||
abort_unless($row !== null, 404);
|
||||
abort_unless((int) $row->version === (int) $data['version'], 409, 'Declaração alterada. Recarregue.');
|
||||
$snapshot = $service->snapshot($request->user(), $data['organization_id']);
|
||||
abort_unless((int) $snapshot['organization_version'] === (int) $data['organization_version'], 409, 'País mestre alterado. Recarregue.');
|
||||
$payload = json_decode(Crypt::decryptString($row->payload), true, 512, JSON_THROW_ON_ERROR);
|
||||
$payload['incorporation_country_code'] = $snapshot['country'];
|
||||
$payload['organization_country_snapshot'] = $snapshot;
|
||||
$encrypted = Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR));
|
||||
DB::table('investment_company_declarations')->where('id', $id)->update(['payload' => $encrypted, 'version' => $row->version + 1, 'updated_at' => now()]);
|
||||
$this->audit($request, 'organization_country_reused', 'investment_company_declarations', $id, ['version' => $row->version + 1,
|
||||
'before_payload' => $row->payload, 'after_payload' => $encrypted, 'organization_id' => $data['organization_id'], 'organization_version' => $snapshot['organization_version']]);
|
||||
|
||||
return ['version' => $row->version + 1, 'country' => $snapshot['country']];
|
||||
});
|
||||
|
||||
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'País mestre reutilizado nesta declaração; manifestações anteriores não alteradas.');
|
||||
}
|
||||
|
||||
public function notifications(Request $request): Response|JsonResponse
|
||||
{
|
||||
$query = DB::table('investment_notifications')->where('user_id', $request->user()->id);
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\InvestmentZone;
|
||||
use App\Services\InvestmentZoneAccess;
|
||||
use App\Services\InvestmentZones;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class InvestmentZoneAttachmentController extends Controller
|
||||
{
|
||||
private const ALLOWED_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
public function store(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$zone = InvestmentZone::whereKey($id)->firstOrFail();
|
||||
$data = $request->validate([
|
||||
'file' => ['required', 'file', 'mimetypes:application/pdf,image/jpeg,image/png,image/webp', 'max:20480'],
|
||||
'kind' => ['required', 'in:legal_instrument,map,photo,study,other'],
|
||||
'caption' => ['required', 'string', 'max:255'],
|
||||
'source_reference' => ['required', 'string', 'min:5', 'max:2000'],
|
||||
'source_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'rights_status' => ['required', 'in:pending,confirmed,restricted'],
|
||||
'rights_basis' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
$file = $request->file('file');
|
||||
abort_unless(in_array($file->getMimeType(), self::ALLOWED_MIME_TYPES, true), 422, 'Formato de ficheiro não permitido.');
|
||||
$this->validateFileContent($file->getRealPath(), $file->getMimeType());
|
||||
abort_if(DB::table('investment_zone_attachments')->where('zone_id', $id)->count() >= 20, 422, 'Máximo de vinte anexos por zona.');
|
||||
|
||||
$attachmentId = (string) Str::uuid();
|
||||
$documentId = (string) Str::uuid();
|
||||
$key = null;
|
||||
try {
|
||||
$key = $file->storeAs('investment-zones/'.$id, $documentId, 'local');
|
||||
abort_unless($key !== false, 500, 'Não foi possível guardar o ficheiro.');
|
||||
$hash = hash_file('sha256', Storage::disk('local')->path($key));
|
||||
abort_unless(is_string($hash), 500, 'Não foi possível verificar o ficheiro guardado.');
|
||||
|
||||
$attachment = DB::transaction(function () use ($request, $zone, $data, $file, $attachmentId, $documentId, $key, $hash): object {
|
||||
DB::table('documents')->insert([
|
||||
'id' => $documentId, 'source_system' => 'investment-zones', 'version_no' => 1,
|
||||
'data_classification' => 'confidential', 'file_name' => basename($file->getClientOriginalName()),
|
||||
'media_type' => $file->getMimeType(), 'storage_key' => $key, 'sha256' => $hash,
|
||||
// Follow the portal's existing file policy: no antivirus engine is configured, so uploads stay quarantined.
|
||||
'status' => 'quarantined', 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id,
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
DB::table('investment_zone_attachments')->insert([
|
||||
'id' => $attachmentId, 'zone_id' => $zone->id, 'document_id' => $documentId,
|
||||
'kind' => $data['kind'], 'caption' => $data['caption'], 'source_reference' => $data['source_reference'],
|
||||
'source_date' => $data['source_date'] ?? null, 'rights_status' => $data['rights_status'],
|
||||
'rights_basis' => $data['rights_basis'] ?? null, 'personal_data_status' => 'unknown',
|
||||
'review_status' => 'pending', 'uploaded_by' => $request->user()->id, 'version_no' => 1,
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
$this->event($attachmentId, $request->user()->id, 'uploaded', 1, ['kind' => $data['kind'], 'sha256' => $hash]);
|
||||
|
||||
return DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
|
||||
->where('a.id', $attachmentId)->first(['a.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.sha256', 'd.malware_scan_status', 'd.malware_failure_reason']);
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
if (is_string($key)) {
|
||||
Storage::disk('local')->delete($key);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
|
||||
|
||||
return response()->json($attachment, 201);
|
||||
}
|
||||
|
||||
public function review(Request $request, string $id, string $action): JsonResponse
|
||||
{
|
||||
abort_unless(in_array($action, ['review', 'select-public', 'withdraw-public'], true), 404);
|
||||
$rules = ['version_no' => ['required', 'integer', 'min:1']];
|
||||
if ($action === 'review') {
|
||||
$rules += ['decision' => ['required', 'in:reviewed,rejected'],
|
||||
'personal_data_status' => ['required', 'in:none,present,unknown'],
|
||||
'rights_status' => ['required', 'in:pending,confirmed,restricted'],
|
||||
'rights_basis' => ['nullable', 'string', 'max:2000']];
|
||||
}
|
||||
$data = $request->validate($rules);
|
||||
if ($action === 'select-public' || $action === 'withdraw-public') {
|
||||
InvestmentZoneAccess::requireRole($request->user(), 'publishers');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($request, $id, $action, $data): JsonResponse {
|
||||
$attachment = DB::table('investment_zone_attachments')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($attachment !== null, 404);
|
||||
abort_if((int) $attachment->version_no !== (int) $data['version_no'], 409, 'O anexo foi alterado. Recarregue antes de continuar.');
|
||||
$version = (int) $attachment->version_no + 1;
|
||||
$changes = ['version_no' => $version, 'updated_at' => now()];
|
||||
$metadata = [];
|
||||
|
||||
if ($action === 'review') {
|
||||
abort_if($data['rights_status'] === 'confirmed' && trim((string) ($data['rights_basis'] ?? $attachment->rights_basis)) === '', 422, 'Registe o fundamento dos direitos antes de os confirmar.');
|
||||
$changes += [
|
||||
'review_status' => $data['decision'], 'reviewed_by' => $request->user()->id, 'reviewed_at' => now(),
|
||||
'independent_review' => (int) $attachment->uploaded_by !== (int) $request->user()->id,
|
||||
'personal_data_status' => $data['personal_data_status'], 'rights_status' => $data['rights_status'],
|
||||
'rights_basis' => $data['rights_basis'] ?? $attachment->rights_basis,
|
||||
'publicly_selected' => false, 'publicly_selected_at' => null, 'publicly_selected_by' => null,
|
||||
];
|
||||
$metadata = ['decision' => $data['decision'], 'independent_review' => $changes['independent_review'], 'personal_data_status' => $data['personal_data_status'], 'rights_status' => $data['rights_status']];
|
||||
} elseif ($action === 'select-public') {
|
||||
abort_unless($attachment->review_status === 'reviewed' && $attachment->rights_status === 'confirmed'
|
||||
&& trim((string) $attachment->rights_basis) !== '' && $attachment->personal_data_status === 'none', 422, 'O anexo precisa de revisão registada, fundamento dos direitos, confirmação de divulgação e verificação de ausência de dados pessoais.');
|
||||
abort_unless(DB::table('documents')->where('id', $attachment->document_id)->where('status', 'active')
|
||||
->where('malware_scan_status', 'clean')->whereColumn('malware_scanned_sha256', 'sha256')->exists(), 422,
|
||||
'A análise antimalware tem de terminar sem ameaça antes da divulgação.');
|
||||
abort_unless(app(InvestmentZones::class)->visible()->whereKey($attachment->zone_id)->exists(), 422, 'A zona deve estar publicada antes da divulgação do anexo.');
|
||||
$changes += ['publicly_selected' => true, 'publicly_selected_at' => now(), 'publicly_selected_by' => $request->user()->id];
|
||||
} else {
|
||||
$changes += ['publicly_selected' => false, 'publicly_selected_at' => null, 'publicly_selected_by' => null];
|
||||
}
|
||||
|
||||
DB::table('investment_zone_attachments')->where('id', $id)->update($changes);
|
||||
$this->event($id, $request->user()->id, $action, $version, $metadata);
|
||||
|
||||
return response()->json(DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
|
||||
->where('a.id', $id)->first(['a.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.sha256', 'd.malware_scan_status', 'd.malware_failure_reason']));
|
||||
});
|
||||
}
|
||||
|
||||
public function downloadPrivate(string $zoneId, string $id): BinaryFileResponse
|
||||
{
|
||||
$record = DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
|
||||
->where('a.id', $id)->where('a.zone_id', $zoneId)->where('d.status', 'active')->whereNull('d.deleted_at')
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')
|
||||
->first(['d.file_name', 'd.storage_key', 'd.sha256', 'd.media_type', 'd.malware_scanned_sha256']);
|
||||
abort_unless($record !== null, 404);
|
||||
|
||||
return $this->verifiedDownload($zoneId, $record);
|
||||
}
|
||||
|
||||
public function downloadPublic(string $zoneId, string $id): BinaryFileResponse
|
||||
{
|
||||
abort_unless(app(InvestmentZones::class)->visible()->whereKey($zoneId)->exists(), 404);
|
||||
$record = DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
|
||||
->where('a.id', $id)->where('a.zone_id', $zoneId)->where('a.publicly_selected', true)
|
||||
->where('a.review_status', 'reviewed')->where('a.rights_status', 'confirmed')->where('a.personal_data_status', 'none')
|
||||
->where('d.status', 'active')->whereNull('d.deleted_at')->where('d.malware_scan_status', 'clean')
|
||||
->whereColumn('d.malware_scanned_sha256', 'd.sha256')->first(['d.file_name', 'd.storage_key', 'd.sha256', 'd.media_type', 'd.malware_scanned_sha256']);
|
||||
abort_unless($record !== null, 404);
|
||||
|
||||
$extension = match ($record->media_type) {
|
||||
'application/pdf' => 'pdf', 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', default => 'bin'
|
||||
};
|
||||
|
||||
return $this->verifiedDownload($zoneId, $record, str_starts_with($record->media_type, 'image/'), 'zone-attachment-'.$id.'.'.$extension);
|
||||
}
|
||||
|
||||
private function verifiedDownload(string $zoneId, \stdClass $document, bool $inline = false, ?string $downloadName = null): BinaryFileResponse
|
||||
{
|
||||
$root = realpath(Storage::disk('local')->path('investment-zones/'.$zoneId));
|
||||
$path = realpath(Storage::disk('local')->path($document->storage_key));
|
||||
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
|
||||
$hash = hash_file('sha256', $path);
|
||||
abort_unless(is_string($hash) && hash_equals(trim($document->sha256), $hash)
|
||||
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 404);
|
||||
|
||||
$headers = ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff'];
|
||||
$safeName = $downloadName ?? basename($document->file_name);
|
||||
|
||||
return $inline
|
||||
? response()->file($path, $headers + ['Content-Type' => $document->media_type, 'Content-Disposition' => 'inline; filename="'.$safeName.'"'])
|
||||
: response()->download($path, $safeName, $headers);
|
||||
}
|
||||
|
||||
private function validateFileContent(string $path, string $mime): void
|
||||
{
|
||||
if ($mime === 'application/pdf') {
|
||||
$handle = fopen($path, 'rb');
|
||||
$signature = $handle ? fread($handle, 5) : false;
|
||||
if (is_resource($handle)) {
|
||||
fclose($handle);
|
||||
}
|
||||
abort_unless($signature === '%PDF-', 422, 'O conteúdo não corresponde a um PDF válido.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$image = @getimagesize($path);
|
||||
abort_unless($image && $image[0] > 0 && $image[1] > 0 && $image[0] * $image[1] <= 40000000, 422, 'A imagem é inválida ou excede 40 megapíxeis.');
|
||||
$expected = ['image/jpeg' => IMAGETYPE_JPEG, 'image/png' => IMAGETYPE_PNG, 'image/webp' => IMAGETYPE_WEBP][$mime] ?? null;
|
||||
abort_unless($expected !== null && $image[2] === $expected, 422, 'O conteúdo da imagem não corresponde ao formato declarado.');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $metadata */
|
||||
private function event(string $attachmentId, int $actor, string $action, int $version, array $metadata = []): void
|
||||
{
|
||||
DB::table('investment_zone_attachment_events')->insert([
|
||||
'id' => (string) Str::uuid(), 'attachment_id' => $attachmentId, 'actor_id' => $actor,
|
||||
'action' => $action, 'version_no' => $version, 'metadata' => json_encode($metadata, JSON_THROW_ON_ERROR), 'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,21 @@ use App\Models\InvestmentZone;
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\InvestmentCatalogue;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\InvestmentTerritorialTraceability;
|
||||
use App\Services\InvestmentZoneAccess;
|
||||
use App\Services\InvestmentZones;
|
||||
use App\Services\InvestmentZoneOperationalProfile;
|
||||
use App\Services\InvestmentZonePathContext;
|
||||
use App\Services\InvestmentZoneProfileInstallation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class InvestmentZoneController extends Controller
|
||||
{
|
||||
@@ -22,8 +29,16 @@ class InvestmentZoneController extends Controller
|
||||
{
|
||||
$management = $request->is('management/*', 'api/v2/investment/admin/*');
|
||||
$zone = ($management ? InvestmentZone::query() : $zones->visible())->findOrFail($id);
|
||||
$publicFields = ['id', 'code', 'category', 'name', 'name_en', 'description', 'description_en', 'province', 'district', 'validation_status', 'updated_at'];
|
||||
$source = DB::table('investment_legal_sources')->where('id', $zone->legal_source_id)->first(['title', 'url', 'diploma', 'article', 'competent_entity', 'valid_from', 'valid_until', 'version_no']);
|
||||
$territoryPath = app(InvestmentGeography::class)->path($zone->primary_territory_id, $management);
|
||||
$zone->setAttribute('province', collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province);
|
||||
$zone->setAttribute('district', collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district);
|
||||
$zone->setAttribute('territory_path', $territoryPath);
|
||||
$zone->setAttribute('operational_details_public',app(\App\Services\InvestmentZoneOperationalDetails::class)->publicData($zone));
|
||||
$publicFields = ['operational_details_public', 'version_no', 'published_at', 'id', 'code', 'category', 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_value', 'area_unit', 'area_components', 'source_evidence', 'activities', 'resources', 'aliases', 'historical_territory', 'data_as_of', 'sources_consulted_at', 'name', 'name_en', 'description', 'description_en', 'province', 'district', 'territory_path', 'validation_status', 'updated_at'];
|
||||
$locations = DB::table('investment_zone_locations as zl')->join('geo_locations as g', 'g.id', '=', 'zl.geo_location_id')
|
||||
->where('zl.zone_id', $id)->when(! $management, fn ($q) => $q->where('zl.validation_status', 'validated')->where('g.data_classification', 'public'))
|
||||
->whereNull('g.deleted_at')->orderBy('g.location_type')->orderBy('g.name')->get(['g.id', 'g.name', 'g.location_type', 'g.parent_id', 'zl.scope_type', 'zl.source_reference', 'zl.validation_status']);
|
||||
$source = DB::table('investment_legal_sources')->where('id', $zone->legal_source_id)->first(['title', 'url', 'diploma', 'article', 'competent_entity', 'valid_from', 'valid_until', 'validation_status', 'version_no']);
|
||||
$operator = DB::table('organizations')->where('id', $zone->operator_id)->whereNull('deleted_at')
|
||||
->when(! $management, fn ($q) => $q->where('data_classification', 'public'))->value('name');
|
||||
$field = ['zee' => 'zee_id', 'zfi' => 'zfi_id', 'industrial_park' => 'park_id'][$zone->category] ?? null;
|
||||
@@ -33,7 +48,28 @@ class InvestmentZoneController extends Controller
|
||||
$relations = $zones->links(true)->where('zl.zone_id', $id)->whereNotNull('zl.'.$foreign);
|
||||
$related = $related->merge(InvestmentPortfolioController::visible()->whereIn($foreign, $relations->select('zl.'.$foreign))->get());
|
||||
}
|
||||
$props = ['record' => $management ? $zone : $zone->only($publicFields), 'source' => $source, 'operator' => $operator, 'management' => $management,
|
||||
$attachmentColumns = $management
|
||||
? ['a.id', 'a.kind', 'a.caption', 'a.source_reference', 'a.source_date', 'a.rights_status', 'a.rights_basis', 'a.personal_data_status',
|
||||
'a.review_status', 'a.reviewed_by', 'a.reviewed_at', 'a.independent_review', 'a.publicly_selected', 'a.publicly_selected_by',
|
||||
'a.version_no', 'a.uploaded_by', 'a.created_at', 'd.id as document_id', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.sha256', 'd.status as file_status',
|
||||
'd.malware_scan_status', 'd.malware_failure_reason', 'd.malware_scanned_at']
|
||||
: ['a.id', 'a.kind', 'a.caption', 'a.source_reference', 'a.source_date', 'd.media_type', 'd.size_bytes'];
|
||||
$attachments = DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
|
||||
->where('a.zone_id', $id)->when(! $management, fn ($query) => $query->where('d.status', 'active'))
|
||||
->when(! $management, fn ($query) => $query->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256'))
|
||||
->whereNull('d.deleted_at')
|
||||
->when(! $management, fn ($query) => $query->where('a.publicly_selected', true)->where('a.review_status', 'reviewed')
|
||||
->where('a.rights_status', 'confirmed')->where('a.personal_data_status', 'none'))
|
||||
->orderBy('a.created_at')->get($attachmentColumns);
|
||||
$attachments->each(function ($attachment) use ($id, $management): void {
|
||||
$attachment->download_url = $management
|
||||
? url('/management/investment/zones/'.$id.'/attachments/'.$attachment->id)
|
||||
: url('/zonas/'.$id.'/anexos/'.$attachment->id);
|
||||
});
|
||||
$props = ['record' => $management ? app(InvestmentZoneOperationalProfile::class)->management($zone) : app(InvestmentZoneOperationalProfile::class)->publicDetails($zone->only($publicFields)), 'source' => $source, 'operator' => $operator, 'management' => $management,
|
||||
'profileAvailable' => $management && app(InvestmentZoneOperationalProfile::class)->installed(),
|
||||
'zoneContext' => !$management && $zone->category === 'industrial_park' ? app(InvestmentZonePathContext::class)->context($id,(int)$zone->version_no) : null,
|
||||
'zoneExtensionsAvailable' => Schema::hasColumn('investment_zones', 'source_evidence'),
|
||||
'related' => $related->unique('id')->values(),
|
||||
'relations' => $management ? DB::table('investment_zone_relations')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [],
|
||||
'geometries' => $management ? DB::table('investment_zone_geometries')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [],
|
||||
@@ -41,8 +77,12 @@ class InvestmentZoneController extends Controller
|
||||
'dossiers' => InvestmentDossier::orderBy('title')->limit(200)->get(['id', 'title', 'project_id', 'cluster_id', 'corridor_id'])] : null,
|
||||
'opportunities' => $opportunities,
|
||||
'legalSources' => $management ? DB::table('investment_legal_sources')->orderByDesc('created_at')->limit(100)->get() : [],
|
||||
'economicActivities' => $management ? app(\App\Services\EconomicReferenceCatalog::class)->activities() : [],
|
||||
'operators' => $management ? DB::table('organizations')->whereNull('deleted_at')->orderBy('name')->limit(200)->get(['id', 'name']) : [],
|
||||
'links' => $management ? DB::table('investment_zone_links')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [],
|
||||
'locations' => $locations,
|
||||
'territorialSources' => app(InvestmentTerritorialTraceability::class)->sources('investment_zones', $id),
|
||||
'attachments' => $attachments,
|
||||
'revisions' => $management ? DB::table('investment_zone_revisions')->where('zone_id', $id)->orderByDesc('version_no')->limit(20)->get(['version_no', 'action', 'sha256', 'created_at']) : []];
|
||||
|
||||
return $request->is('api/*') ? response()->json($props) : Inertia::render('investment/zone', $props);
|
||||
@@ -50,53 +90,185 @@ class InvestmentZoneController extends Controller
|
||||
|
||||
public function index(Request $request, InvestmentZones $zones): Response|JsonResponse
|
||||
{
|
||||
$filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1'],
|
||||
$filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'physical_category' => ['nullable', 'in:economic_zone,industrial_park,agro_industrial_park,industrial_logistics_park,industrial_free_zone,integrated_tourism_area,other,unknown'],
|
||||
'legal_regime' => ['nullable', 'in:zee,zfi,general,pending,not_applicable'], 'development_status' => ['nullable', 'in:announced,planned,in_development,partially_operational,operational,suspended,unknown'],
|
||||
'availability_status' => ['nullable', 'in:confirmed,unavailable,reported_unverified,not_informed'], 'activity' => ['nullable', 'string', 'max:120'], 'q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1'],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
|
||||
$management = $request->is('management/*', 'api/v2/investment/admin/*');
|
||||
$query = $management ? InvestmentZone::query() : $zones->visible();
|
||||
abort_if((! empty($filters['development_status']) || ! empty($filters['availability_status'])) && ! Schema::hasColumn('investment_zones', 'source_evidence'), 503,
|
||||
'Os filtros de desenvolvimento e disponibilidade estarão disponíveis após a migração do catálogo de zonas.');
|
||||
$path = app(InvestmentGeography::class)->selection($filters);
|
||||
if ($path !== []) {
|
||||
$query->whereIn('primary_territory_id', app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id));
|
||||
}
|
||||
$rows = $query->when($filters['category'] ?? null, fn ($q, $v) => $q->where('category', $v))
|
||||
->when($filters['q'] ?? null, fn ($q, $v) => $q->where(fn ($q) => $q->where('name', 'ilike', '%'.$v.'%')->orWhere('code', 'ilike', '%'.$v.'%')))
|
||||
->orderBy('name')->orderBy('id')->paginate(12, $management ? ['*'] : ['id', 'code', 'name', 'name_en', 'category', 'province', 'district', 'description', 'description_en', 'validation_status', 'updated_at'])->withQueryString();
|
||||
$props = ['records' => $rows, 'filters' => $filters, 'management' => $management, 'territorySelection' => $path];
|
||||
$publicColumns = ['primary_territory_id', 'version_no', 'id', 'code', 'name', 'name_en', 'category', 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_value', 'area_unit', 'area_components', 'source_evidence', 'activities', 'resources', 'aliases', 'data_as_of', 'sources_consulted_at', 'province', 'district', 'description', 'description_en', 'validation_status', 'updated_at'];
|
||||
if(app(\App\Services\InvestmentZoneOperationalDetails::class)->installed()){$publicColumns[]='operational_details';}
|
||||
$publicColumns = array_values(array_filter($publicColumns, fn ($column) => Schema::hasColumn('investment_zones', $column)));
|
||||
$rows = $zones->search($filters, $management)->orderBy('name')->orderBy('id')
|
||||
->paginate(12, $management ? ['*'] : $publicColumns)->withQueryString();
|
||||
$rows->getCollection()->transform(function (InvestmentZone $zone) use ($management) {
|
||||
$territoryPath = app(InvestmentGeography::class)->path($zone->primary_territory_id, $management);
|
||||
$zone->setAttribute('province', collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province);
|
||||
$zone->setAttribute('district', collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district);
|
||||
$zone->setAttribute('territory_path', $territoryPath);
|
||||
$zone->setAttribute('locations', DB::table('investment_zone_locations as zl')->join('geo_locations as g', 'g.id', '=', 'zl.geo_location_id')
|
||||
->where('zl.zone_id', $zone->id)->whereNull('g.deleted_at')
|
||||
->when(! $management, fn ($q) => $q->where('zl.validation_status', 'validated')->where('g.data_classification', 'public'))
|
||||
->orderBy('g.location_type')->orderBy('g.name')->get(['g.id', 'g.name', 'g.location_type', 'zl.scope_type']));
|
||||
|
||||
if (! $management) { $zone->makeHidden('primary_territory_id'); }
|
||||
|
||||
return $management ? $zone : [...app(InvestmentZoneOperationalProfile::class)->publicDetails($zone->toArray()),'operational_details_public'=>app(\App\Services\InvestmentZoneOperationalDetails::class)->publicData($zone)];
|
||||
});
|
||||
$props = ['detailsInstallation'=>$management ? app(\App\Services\InvestmentZoneOperationalDetailsInstallation::class)->preview($request->user()) : null, 'installation' => $management ? app(InvestmentZoneProfileInstallation::class)->preview($request->user()) : null, 'records' => $rows, 'filters' => $filters, 'management' => $management, 'territorySelection' => $path,
|
||||
'zoneExtensionsAvailable' => Schema::hasColumn('investment_zones', 'source_evidence')];
|
||||
|
||||
return $request->is('api/*') ? response()->json($props) : Inertia::render('investment/zones', $props);
|
||||
}
|
||||
|
||||
public function export(Request $request, InvestmentZones $zones): StreamedResponse
|
||||
{
|
||||
$filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'physical_category' => ['nullable', 'in:economic_zone,industrial_park,agro_industrial_park,industrial_logistics_park,industrial_free_zone,integrated_tourism_area,other,unknown'],
|
||||
'legal_regime' => ['nullable', 'in:zee,zfi,general,pending,not_applicable'], 'development_status' => ['nullable', 'in:announced,planned,in_development,partially_operational,operational,suspended,unknown'],
|
||||
'availability_status' => ['nullable', 'in:confirmed,unavailable,reported_unverified,not_informed'], 'activity' => ['nullable', 'string', 'max:120'], 'q' => ['nullable', 'string', 'max:120'],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
|
||||
abort_if((! empty($filters['development_status']) || ! empty($filters['availability_status'])) && ! Schema::hasColumn('investment_zones', 'source_evidence'), 503,
|
||||
'Os filtros de desenvolvimento e disponibilidade estarão disponíveis após a migração do catálogo de zonas.');
|
||||
$query = $zones->search($filters)->orderBy('name')->orderBy('id');
|
||||
|
||||
return response()->streamDownload(function () use ($query): void {
|
||||
$geography = app(InvestmentGeography::class);
|
||||
$stream = fopen('php://output', 'wb');
|
||||
if ($stream === false) {
|
||||
throw new \RuntimeException('CSV output stream unavailable.');
|
||||
}
|
||||
fwrite($stream, "\xEF\xBB\xBF");
|
||||
fputcsv($stream, ['Código', 'Nome', 'Categoria física', 'Regime', 'Desenvolvimento', 'Disponibilidade', 'Província', 'Distrito', 'Área declarada', 'Unidade', 'Fontes'], ';');
|
||||
$query->chunk(200, function ($zones) use ($stream, $geography): void {
|
||||
foreach ($zones as $zone) {
|
||||
$territoryPath = $geography->path($zone->primary_territory_id);
|
||||
$province = collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province;
|
||||
$district = collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district;
|
||||
$sources = collect($zone->source_evidence ?? [])->map(fn ($source) => ($source['title'] ?? '').' — '.($source['url'] ?? '').' — '.($source['source_date'] ?? 'data não informada'))->implode(' | ');
|
||||
$values = [$zone->code, $zone->name, $zone->physical_category, $zone->legal_regime, $zone->development_status,
|
||||
$zone->availability_status, $province, $district, $zone->area_value, $zone->area_unit, $sources];
|
||||
$values = array_map(fn ($value) => is_string($value) && preg_match('/^[=+@\-\t\r]/u', $value) ? "'".$value : $value, $values);
|
||||
fputcsv($stream, $values, ';');
|
||||
}
|
||||
});
|
||||
fclose($stream);
|
||||
}, 'zonas-e-parques.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||
}
|
||||
|
||||
public function save(Request $request, InvestmentZones $zones, ?string $id = null): JsonResponse
|
||||
{
|
||||
if ($request->has('aliases_text')) {
|
||||
$request->merge(['aliases' => array_values(array_unique(array_filter(preg_split('/[\r\n,;]+/u', (string) $request->input('aliases_text')) ?: [], fn ($value) => trim($value) !== '')))]);
|
||||
}
|
||||
if ($request->filled('historical_name')) {
|
||||
$request->merge(['historical_territory' => [
|
||||
'name' => $request->input('historical_name'), 'district' => $request->input('historical_district'),
|
||||
'province' => $request->input('historical_province'), 'source_reference' => $request->input('historical_source_reference'),
|
||||
]]);
|
||||
}
|
||||
if ($request->filled('activity_name')) {
|
||||
$request->merge(['activities' => [['name' => $request->input('activity_name'), 'status' => $request->input('activity_status', 'potential')]]]);
|
||||
}
|
||||
$data = $request->validate(['version_no' => [$id ? 'required' : 'nullable', 'integer', 'min:1'],
|
||||
'code' => ['required', 'string', 'max:80', Rule::unique('investment_zones')->ignore($id)],
|
||||
'category' => ['required', 'in:zee,zfi,industrial_park,pending'], 'name' => ['required', 'string', 'max:255'],
|
||||
'name_en' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:15000'],
|
||||
'description_en' => ['nullable', 'string', 'max:15000'], 'province' => ['nullable', 'string', 'max:100'], 'district' => ['nullable', 'string', 'max:100'],
|
||||
'operator_id' => ['nullable', 'uuid', Rule::exists('organizations', 'id')->whereNull('deleted_at')],
|
||||
'legal_source_id' => ['nullable', 'uuid', 'exists:investment_legal_sources,id']]);
|
||||
'legal_source_id' => ['nullable', 'uuid', 'exists:investment_legal_sources,id'],
|
||||
'physical_category' => ['nullable', 'in:economic_zone,industrial_park,agro_industrial_park,industrial_logistics_park,industrial_free_zone,integrated_tourism_area,other,unknown'],
|
||||
'legal_regime' => ['nullable', 'in:zee,zfi,general,pending,not_applicable'],
|
||||
'operational_status' => ['nullable', 'in:operational,implementation,planned,suspended,unknown'],
|
||||
'development_status' => ['nullable', 'in:announced,planned,in_development,partially_operational,operational,suspended,unknown'],
|
||||
'availability_status' => ['nullable', 'in:confirmed,unavailable,reported_unverified,not_informed'],
|
||||
'area_components' => ['sometimes', 'array', 'max:30'], 'area_components.*' => ['array:type,value,unit,source_reference,source_date,status'],
|
||||
'area_components.*.type' => ['required', 'in:total,legal,developed,pilot,available'], 'area_components.*.value' => ['nullable', 'numeric', 'min:0'],
|
||||
'area_components.*.unit' => ['required', 'in:ha,km2,m2'], 'area_components.*.source_reference' => ['required', 'string', 'min:5', 'max:2000'],
|
||||
'area_components.*.source_date' => ['nullable', 'date_format:Y-m-d'], 'area_components.*.status' => ['required', 'in:reported,confirmed,disputed'],
|
||||
'source_evidence' => ['sometimes', 'array', 'max:30'], 'source_evidence.*' => ['array:title,url,source_type,supports,source_date'],
|
||||
'source_evidence.*.title' => ['required', 'string', 'max:255'], 'source_evidence.*.url' => ['required', 'url:https', 'max:2000'],
|
||||
'source_evidence.*.source_type' => ['required', 'in:government,institutional,promoter,other'], 'source_evidence.*.supports' => ['required', 'string', 'max:500'],
|
||||
'source_evidence.*.source_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'area_value' => ['nullable', 'numeric', 'min:0'], 'area_unit' => ['nullable', 'string', 'max:24'],
|
||||
'aliases' => ['sometimes', 'array', 'max:20'], 'aliases.*' => ['string', 'max:120'],
|
||||
'operational_details' => ['sometimes','array'],
|
||||
'activities' => ['sometimes', 'array', 'max:50'], 'activities.*' => ['array:id,name,status,source_reference,source_date,kind,scope,capacity_type,capacity_value,capacity_unit,focus,economic_activity_id,support_document_ids,private_note'],
|
||||
'activities.*.id' => ['nullable','uuid'],
|
||||
'activities.*.source_date' => ['nullable','date_format:Y-m-d'],
|
||||
'activities.*.kind' => ['nullable','in:infrastructure,service,access,other'],
|
||||
'activities.*.scope' => ['nullable','in:inside,regional,unknown'],
|
||||
'activities.*.capacity_type' => ['nullable','string','max:120'],
|
||||
'activities.*.capacity_value' => ['nullable','numeric','min:0'],
|
||||
'activities.*.capacity_unit' => ['nullable','string','max:40'],
|
||||
'activities.*.focus' => ['nullable','in:promotional,potential,installed,unknown'],
|
||||
'activities.*.economic_activity_id' => ['nullable','uuid','exists:economic_activities,id'],
|
||||
'activities.*.support_document_ids' => ['sometimes','array','max:10'],
|
||||
'activities.*.support_document_ids.*' => ['uuid'],
|
||||
'activities.*.private_note' => ['nullable','string','max:2000'],
|
||||
'activities.*.name' => ['required', 'string', 'max:300'], 'activities.*.status' => ['required', 'in:proposed,authorized,conditional,excluded,potential'],
|
||||
'activities.*.source_reference' => ['nullable', 'string', 'max:2000'],
|
||||
'resources' => ['sometimes', 'array', 'max:50'], 'resources.*' => ['array:id,name,status,source_reference,source_date,kind,scope,capacity_type,capacity_value,capacity_unit,focus,economic_activity_id,support_document_ids,private_note'],
|
||||
'resources.*.id' => ['nullable','uuid'],
|
||||
'resources.*.source_date' => ['nullable','date_format:Y-m-d'],
|
||||
'resources.*.kind' => ['nullable','in:infrastructure,service,access,other'],
|
||||
'resources.*.scope' => ['nullable','in:inside,regional,unknown'],
|
||||
'resources.*.capacity_type' => ['nullable','string','max:120'],
|
||||
'resources.*.capacity_value' => ['nullable','numeric','min:0'],
|
||||
'resources.*.capacity_unit' => ['nullable','string','max:40'],
|
||||
'resources.*.focus' => ['nullable','in:promotional,potential,installed,unknown'],
|
||||
'resources.*.economic_activity_id' => ['nullable','uuid','exists:economic_activities,id'],
|
||||
'resources.*.support_document_ids' => ['sometimes','array','max:10'],
|
||||
'resources.*.support_document_ids.*' => ['uuid'],
|
||||
'resources.*.private_note' => ['nullable','string','max:2000'],
|
||||
'resources.*.name' => ['required', 'string', 'max:120'], 'resources.*.status' => ['required', 'in:existing,planned,unconfirmed'],
|
||||
'resources.*.source_reference' => ['nullable', 'string', 'max:2000'], 'data_as_of' => ['nullable', 'date_format:Y-m-d'],
|
||||
'sources_consulted_at' => ['nullable', 'date_format:Y-m-d'], 'parent_zone_id' => ['nullable', 'uuid', 'exists:investment_zones,id'],
|
||||
'historical_territory' => ['nullable', 'array:name,district,province,source_reference'],
|
||||
'historical_territory.name' => ['required_with:historical_territory', 'string', 'max:255'],
|
||||
'historical_territory.district' => ['nullable', 'string', 'max:120'], 'historical_territory.province' => ['nullable', 'string', 'max:120'],
|
||||
'historical_territory.source_reference' => ['nullable', 'string', 'max:2000'],
|
||||
'locations' => ['sometimes', 'array', 'max:20'], 'locations.*.geo_location_id' => ['required', 'uuid', 'exists:geo_locations,id'],
|
||||
'locations.*.scope_type' => ['required', 'in:legal_scope,administrative,site,influence'],
|
||||
'locations.*.source_reference' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
'locations.*.legal_source_id' => ['nullable', 'uuid', 'exists:investment_legal_sources,id']]);
|
||||
|
||||
foreach (['development_status', 'availability_status', 'area_components', 'source_evidence'] as $column) {
|
||||
if (! Schema::hasColumn('investment_zones', $column)) {
|
||||
unset($data[$column]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($data['activities'] ?? [] as $index => $activity) {
|
||||
if (! in_array($activity['status'], ['potential', 'proposed'], true) && trim((string) ($activity['source_reference'] ?? '')) === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'activities.'.$index.'.source_reference' => 'Indique a fonte da actividade documentada antes de a classificar como autorizada, condicionada ou excluída.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $id && $request->has('province_id')) {
|
||||
$geo = $request->validate(['province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
|
||||
$path = app(InvestmentGeography::class)->selection($geo);
|
||||
$path = app(InvestmentGeography::class)->selection($geo, true);
|
||||
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
|
||||
$data['territory_version'] = $path === [] ? 0 : 1;
|
||||
$data['province'] = $path[0]->name ?? null;
|
||||
$data['district'] = $path[1]->name ?? null;
|
||||
}
|
||||
|
||||
return response()->json($zones->save($id, $data, $request->user()->id), $id ? 200 : 201);
|
||||
$saved=$zones->save($id, $data, $request->user()->id);
|
||||
return response()->json(app(InvestmentZoneOperationalProfile::class)->management($saved), $id ? 200 : 201);
|
||||
}
|
||||
|
||||
public function transition(Request $request, InvestmentZones $zones, string $id, string $action): JsonResponse
|
||||
{
|
||||
if ($action === 'publish') {
|
||||
InvestmentZoneAccess::requireRole($request->user(), 'publishers');
|
||||
}
|
||||
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
|
||||
|
||||
return response()->json($action === 'publish' ? $zones->publish($id, $data['version_no'], $request->user()->id)
|
||||
: $zones->suspend($id, $data['version_no'], $request->user()->id));
|
||||
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'base_public_version'=>['required_if:action,reconcile','nullable','integer','min:1']]);
|
||||
if($action==='reconcile'){
|
||||
$request->validate(['base_public_version'=>'required|integer|min:1']);
|
||||
$saved=DB::transaction(function()use($id,$data,$request){$zone=InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();abort_unless($zone->category==='industrial_park',422);return app(InvestmentZoneOperationalProfile::class)->reconcile($zone,(int)$data['version_no'],(int)$data['base_public_version'],$request->user()->id);});
|
||||
}else{$saved=$action === 'publish' ? $zones->publish($id,$data['version_no'],$request->user()->id) : $zones->suspend($id,$data['version_no'],$request->user()->id);}
|
||||
return response()->json(app(InvestmentZoneOperationalProfile::class)->management($saved));
|
||||
}
|
||||
|
||||
public function revisions(string $id): JsonResponse
|
||||
@@ -105,4 +277,12 @@ class InvestmentZoneController extends Controller
|
||||
|
||||
return response()->json(DB::table('investment_zone_revisions')->where('zone_id', $id)->orderByDesc('version_no')->paginate(20, ['id', 'version_no', 'actor_id', 'action', 'sha256', 'created_at']));
|
||||
}
|
||||
|
||||
public function validateLocation(Request $request, InvestmentZones $zones, string $id): JsonResponse
|
||||
{
|
||||
InvestmentZoneAccess::requireRole($request->user(), 'territory_reviewers');
|
||||
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
|
||||
|
||||
return response()->json($zones->validateLocation($id, $data['version_no'], $request->user()->id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@@ -98,9 +99,15 @@ class InvestmentZoneGeometryController extends Controller
|
||||
|
||||
public function map(Request $request, InvestmentZones $zones): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'bbox' => ['nullable', 'array', 'size:4'], 'bbox.*' => ['required', 'numeric']]);
|
||||
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'bbox' => ['nullable', 'array', 'size:4'], 'bbox.*' => ['required', 'numeric'],
|
||||
'category' => ['nullable', 'in:zee,zfi,industrial_park'], 'physical_category' => ['nullable', 'string', 'max:40'], 'legal_regime' => ['nullable', 'string', 'max:30'],
|
||||
'development_status' => ['nullable', 'string', 'max:32'], 'availability_status' => ['nullable', 'string', 'max:32'], 'activity' => ['nullable', 'string', 'max:120'], 'q' => ['nullable', 'string', 'max:120'],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
|
||||
abort_if((! empty($data['development_status']) || ! empty($data['availability_status'])) && ! Schema::hasColumn('investment_zones', 'source_evidence'), 503,
|
||||
'Os filtros de desenvolvimento e disponibilidade estarão disponíveis após a migração do catálogo de zonas.');
|
||||
$eligibleZones = $zones->search($data)->select('investment_zones.id');
|
||||
$query = DB::table('investment_zone_geometries as l')->join('geo_geometries as g', 'g.id', '=', 'l.geometry_id')
|
||||
->whereIn('l.zone_id', $zones->visible()->select('investment_zones.id'))->where('l.publication_authorized', true)->where('l.validation_status', 'validated')
|
||||
->whereIn('l.zone_id', $eligibleZones)->where('l.publication_authorized', true)->where('l.validation_status', 'validated')
|
||||
->whereNull('g.deleted_at')->where('g.data_classification', 'public')->when($data['zone_id'] ?? null, fn ($q, $v) => $q->where('l.zone_id', $v))
|
||||
->where(fn ($q) => $q->whereNull('l.legal_source_id')->orWhereExists(fn ($s) => $s->selectRaw('1')->from('investment_legal_sources as ls')
|
||||
->whereColumn('ls.id', 'l.legal_source_id')->where('ls.validation_status', 'validated')->whereDate('ls.valid_from', '<=', today())
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\InvestorContext;
|
||||
use App\Services\InvestorContextInstallation;
|
||||
use App\Services\InvestorPath;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
final class InvestorContextController extends Controller
|
||||
{
|
||||
private function entryComplements(Request $request): array
|
||||
{
|
||||
$data = $request->validate(['complement_origin' => 'nullable|string|max:100', 'complement_code' => 'required_with:complement_origin|string|max:40',
|
||||
'complement_target' => 'nullable|string|max:100', 'complement_territory' => 'nullable|uuid']);
|
||||
if (empty($data['complement_origin'])) return [];
|
||||
return app(\App\Services\InvestmentComplements::class)->resolve([['origin' => $data['complement_origin'], 'code' => $data['complement_code'],
|
||||
'target' => $data['complement_target'] ?? null, 'territory_id' => $data['complement_territory'] ?? null]]);
|
||||
}
|
||||
|
||||
private function entryChoices(Request $r, InvestorContext $context): array
|
||||
{
|
||||
$data = $r->validate(['source' => 'nullable|string|max:100', 'from_dossier' => 'nullable|uuid', 'opportunities' => 'sometimes|array|max:100', 'opportunities.*' => 'integer|distinct|min:1',
|
||||
'province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid']);
|
||||
$source = !empty($data['source']) ? $context->reference($data['source']) : null;
|
||||
$ids = $data['opportunities'] ?? (($source['kind'] ?? '') === 'opportunity' ? [$source['id']] : []);
|
||||
abort_if(!empty($data['from_dossier']) && !$ids, 422, 'Escolha a oportunidade associada à ficha da carteira.');
|
||||
$path = app(InvestmentGeography::class)->selection($data);
|
||||
$input = array_map(fn($id) => ['source' => 'opportunity:'.$id, ...($path ? ['territory' => 'territory:'.$path[array_key_last($path)]->id] : [])], $ids);
|
||||
if (!empty($data['from_dossier'])) $input[0]['portfolio_origins'] = [$data['from_dossier']];
|
||||
foreach ($this->entryComplements($r) as $complement) {
|
||||
$keys = array_filter([$complement['origin']['key'], $complement['target']['key'] ?? null], fn($key) => str_starts_with($key, 'opportunity:'));
|
||||
foreach ($keys as $key) {
|
||||
if (in_array($key, array_column($input, 'source'), true)) continue;
|
||||
$input[] = ['source' => $key, ...($complement['territory_id'] ? ['territory' => 'territory:'.$complement['territory_id']] : []),
|
||||
...(str_starts_with($complement['origin']['key'], 'portfolio:') ? ['portfolio_origins' => [substr($complement['origin']['key'], 10)]] : [])];
|
||||
}
|
||||
}
|
||||
return app(\App\Services\InvestmentOpportunityChoices::class)->resolve($input, [], true);
|
||||
}
|
||||
|
||||
public function current(Request $r, InvestorContext $context)
|
||||
{
|
||||
return response()->json($context->current($r->user()));
|
||||
}
|
||||
|
||||
public function focus(Request $r, InvestorContext $context)
|
||||
{
|
||||
return response()->json($context->focus($r->user(), $r->all()));
|
||||
}
|
||||
|
||||
public function show(Request $r, string $id, InvestorContext $context)
|
||||
{
|
||||
$data = $r->validate(['opportunity' => 'nullable|string|max:100']);
|
||||
return response()->json($context->view($r->user(), $id, $data['opportunity'] ?? null));
|
||||
}
|
||||
|
||||
public function options(Request $r, InvestorContext $context)
|
||||
{
|
||||
$d = $r->validate(['q' => 'nullable|string|max:120']);
|
||||
|
||||
return response()->json($context->options($d['q'] ?? ''));
|
||||
}
|
||||
|
||||
public function corridorOptions(Request $r)
|
||||
{
|
||||
$data = $r->validate(['corridor' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(config('investment_corridors')))], 'territory' => 'nullable|uuid']);
|
||||
$service = app(\App\Services\InvestmentCorridorContext::class);
|
||||
return response()->json(['corridors' => collect(config('investment_corridors'))->map(fn($entry,$key)=>['key'=>$key,'name'=>$entry['name'],'provinces'=>$service->provinces($key)])->values(),
|
||||
'parks' => $service->parks($data['corridor'] ?? null, $data['territory'] ?? null)]);
|
||||
}
|
||||
|
||||
public function saveDraft(Request $r, string $id, InvestorContext $context)
|
||||
{
|
||||
return response()->json($context->saveDraft($r->user(), $id, $r->all()));
|
||||
}
|
||||
|
||||
public function edit(Request $r, string $id, InvestorContext $context)
|
||||
{
|
||||
$data = $context->view($r->user(), $id);
|
||||
abort_unless($data['can_write'], 403);
|
||||
|
||||
$entries = $this->entryChoices($r, $context);
|
||||
$choices = $entries ?: null;
|
||||
$source = $r->query('source') ? $context->reference($r->validate(['source' => 'required|string|max:100'])['source']) : null;
|
||||
if ($choices === null && ($source['kind'] ?? '') === 'opportunity') $choices = app(\App\Services\InvestmentOpportunityChoices::class)->resolve([['source' => $source['key']]]);
|
||||
$explicitChoices=$choices;
|
||||
$selectionKey=$r->validate(['selection_key'=>'nullable|uuid'])['selection_key']??null;
|
||||
$entryKey=fn($c)=>$c['opportunity']['key'].($selectionKey?'@'.$selectionKey:'');
|
||||
$entrySources=array_map($entryKey,$explicitChoices??[]);
|
||||
if ($choices !== null) $choices = app(\App\Services\InvestmentOpportunityChoices::class)->mergeEntries($data['context']['opportunity_choices'] ?? [], $choices);
|
||||
$draft = $context->draft($r->user(), $id);
|
||||
if ($draft && isset($draft['opportunity_choices'])) {
|
||||
$recovered = app(\App\Services\InvestmentOpportunityChoices::class)->resolve($draft['opportunity_choices'], $data['context']['opportunity_choices'] ?? [], true);
|
||||
$new=array_filter($explicitChoices??[],fn($c)=>!in_array($entryKey($c),$draft['entry_sources']??[],true));
|
||||
$choices = app(\App\Services\InvestmentOpportunityChoices::class)->mergeEntries($recovered, array_values($new));
|
||||
}
|
||||
$differences = [];
|
||||
foreach ($explicitChoices ?? [] as $entry) {
|
||||
$kept = collect($choices ?? [])->firstWhere('opportunity.key', $entry['opportunity']['key']);
|
||||
if ($kept && ($kept['territory']['key'] ?? null) !== ($entry['territory']['key'] ?? null)) $differences[] = ['title'=>$entry['opportunity']['title'],'saved'=>$kept['territory']['title']??'Por definir','requested'=>$entry['territory']['title']??'Por definir'];
|
||||
}
|
||||
$complements = isset($draft['complements']) ? app(\App\Services\InvestmentComplements::class)->resolve($draft['complements'], $data['context']['complements'] ?? []) : ($data['context']['complements'] ?? []);
|
||||
$entryTerritory = null;
|
||||
if (($source['kind'] ?? null) === 'portfolio') {
|
||||
$path = app(InvestmentGeography::class)->selection($r->validate(['province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid']));
|
||||
if ($path) $entryTerritory = $context->reference('territory:'.$path[array_key_last($path)]->id);
|
||||
}
|
||||
foreach ($this->entryComplements($r) as $entry) {
|
||||
$tag = 'complement:'.hash('sha256', $entry['key'].'@'.($selectionKey ?? ''));
|
||||
$entrySources[] = $tag;
|
||||
if (!in_array($tag, $draft['entry_sources'] ?? [], true)) $complements = collect([...$complements, $entry])->unique('key')->values()->all();
|
||||
}
|
||||
return Inertia::render('investor-path/context', ['entryTerritory' => $entryTerritory, 'entryComplements' => $complements, 'entryLocationDifferences'=>$differences,'entrySources'=>$entrySources,'draft' => $draft, 'requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : ($entryTerritory ? app(InvestmentGeography::class)->path($entryTerritory['id']) : [])]);
|
||||
}
|
||||
|
||||
public function preview(Request $r, string $id, InvestorContext $context)
|
||||
{
|
||||
return response()->json($context->preview($r->user(), $id, $r->all()));
|
||||
}
|
||||
|
||||
public function commit(Request $r, string $id, InvestorContext $context)
|
||||
{
|
||||
return response()->json($context->commit($r->user(), $id, $r->all()));
|
||||
}
|
||||
|
||||
public function start(Request $r, InvestorPath $path, InvestorContext $context)
|
||||
{
|
||||
$d = $r->validate(['source' => 'nullable|string|max:100', 'step' => ['nullable', 'regex:/^S(0[1-9]|1[0-9])$/']]);
|
||||
$ref = isset($d['source']) ? $context->reference($d['source']) : null;
|
||||
$submissionId = $r->validate(['submission' => 'nullable|uuid'])['submission'] ?? null;
|
||||
$entrySubmission = null;
|
||||
if ($submissionId) {
|
||||
$submission = \App\Models\InvestmentSubmission::where('id', $submissionId)->where('user_id', $r->user()->id)->where('status', 'draft')->firstOrFail();
|
||||
abort_unless(($ref['kind'] ?? null) === 'portfolio' && $submission->dossier_id === $ref['id'], 422, 'Conserve a ficha de origem da manifestação.');
|
||||
$entrySubmission = ['id' => $submission->id, 'version' => $submission->version];
|
||||
}
|
||||
|
||||
$choices = $this->entryChoices($r, $context);
|
||||
return Inertia::render('investor-path/context-start', ['entrySubmission' => $entrySubmission, 'entryComplements' => $this->entryComplements($r), 'entryQuery' => $r->only(['complement_origin', 'complement_code', 'complement_target', 'complement_territory', 'from_dossier', 'province_id', 'district_id', 'admin_post_id', 'locality_id']), 'opportunityChoices' => $choices, 'contexts' => $path->contexts($r->user()), 'source' => $ref, 'step' => $d['step'] ?? null]);
|
||||
}
|
||||
|
||||
public function installation(Request $r, InvestorContextInstallation $install)
|
||||
{
|
||||
$plan = $install->preview($r->user());
|
||||
|
||||
return $r->expectsJson() ? response()->json($plan) : Inertia::render('investor-path/context-installation', ['plan' => $plan]);
|
||||
}
|
||||
|
||||
public function install(Request $r, InvestorContextInstallation $install)
|
||||
{
|
||||
return response()->json($install->apply($r->user(),$r->all()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Ai\EvidenceRepository;
|
||||
use App\Services\Ai\InvestorGuidance;
|
||||
use App\Services\InvestorPath;
|
||||
use App\Services\VirtualDesk;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class InvestorGuidanceController extends Controller
|
||||
{
|
||||
public function contexts(Request $request, VirtualDesk $desk): JsonResponse
|
||||
{
|
||||
$actor = $request->user();
|
||||
$organizations = DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->pluck('organization_id');
|
||||
$cases = DB::table('desk_cases')->whereNull('closed_at')->where(fn ($q) => $q->where(fn ($p) => $p->where('user_id', $actor->id)->whereNull('organization_id'))->orWhereIn('organization_id', $organizations))
|
||||
->orderByDesc('updated_at')->get(['id', 'title', 'journey_id'])->map(function ($case) {
|
||||
return ['id' => $case->id, 'title' => $case->title, 'path_supported' => app(InvestorPath::class)->available()
|
||||
&& $case->journey_id && DB::table('investor_journeys')->where('id', $case->journey_id)->whereNotNull('path_definition_id')->exists()];
|
||||
});
|
||||
|
||||
return response()->json(['cases' => $cases]);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $id, InvestorGuidance $guidance): JsonResponse
|
||||
{
|
||||
return response()->json($guidance->view($request->user(), $id));
|
||||
}
|
||||
|
||||
public function assumptions(Request $request, string $id, InvestorGuidance $guidance): JsonResponse|RedirectResponse
|
||||
{
|
||||
$guidance->assumptions($request->user(), $id, $request->all());
|
||||
|
||||
return $this->completed($request, 'Pressupostos corrigidos; os dados dos módulos de origem foram preservados.');
|
||||
}
|
||||
|
||||
public function feedback(Request $request, string $id, InvestorGuidance $guidance): JsonResponse|RedirectResponse
|
||||
{
|
||||
$guidance->feedback($request->user(), $id, $request->all());
|
||||
|
||||
return $this->completed($request, 'Revisão registada. Assumir uma tarefa continua a ser uma acção separada.');
|
||||
}
|
||||
|
||||
public function task(Request $request, string $id, InvestorGuidance $guidance): JsonResponse|RedirectResponse
|
||||
{
|
||||
$task = $guidance->acceptTask($request->user(), $id, $request->all());
|
||||
|
||||
return $request->expectsJson() ? response()->json(['id' => $task->id], 201) : back()->with('success', 'Tarefa assumida no CRM interno. Sincronização externa pendente de finalidade e autorização.');
|
||||
}
|
||||
|
||||
public function updateTask(Request $request, string $id, string $task, InvestorGuidance $guidance): JsonResponse|RedirectResponse
|
||||
{
|
||||
$guidance->updateTask($request->user(), $id, $task, $request->all());
|
||||
|
||||
return $this->completed($request, 'Resultado da tarefa registado no mesmo histórico do dossier.');
|
||||
}
|
||||
|
||||
public function search(Request $request, string $id, VirtualDesk $desk, EvidenceRepository $evidence): JsonResponse
|
||||
{
|
||||
$desk->case($request->user(), $id);
|
||||
$data = $request->validate(['query' => 'required|string|min:3|max:300']);
|
||||
$enabled = config('investmoz_ai.enabled') && Schema::hasTable('ai_controls') && DB::table('ai_controls')->where('capability', 'search')->where('suspended', false)->exists();
|
||||
$items = $enabled ? $evidence->search($data['query'], 'pt', ['public_only' => true]) : [];
|
||||
|
||||
return response()->json(['mode' => 'lexical_evidence', 'model' => null, 'status' => ! $enabled ? 'unavailable' : ($items ? 'evidence_found' : 'no_evidence'),
|
||||
'consulted' => $items, 'limitations' => ['Apenas excertos das fontes públicas autorizadas indexadas; não é uma consulta exaustiva nem validação jurídica.']]);
|
||||
}
|
||||
|
||||
private function completed(Request $request, string $message): JsonResponse|RedirectResponse
|
||||
{
|
||||
return $request->expectsJson() ? response()->json(['message' => $message]) : back()->with('success', $message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\FintechInvestment;
|
||||
use App\Services\InvestorPath;
|
||||
use App\Services\InvestmentZonePathContext;
|
||||
use App\Services\LegacyContent;
|
||||
use App\Services\SectorInvestment;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class InvestorPathController extends Controller
|
||||
{
|
||||
public function index(Request $request, InvestorPath $path): JsonResponse|Response
|
||||
{
|
||||
abort_unless($path->available(), 404);
|
||||
$data = ['contexts' => $path->contexts($request->user())];
|
||||
|
||||
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/index', $data);
|
||||
}
|
||||
|
||||
public function sectorStart(Request $request, string $sectorSlug, InvestorPath $path): JsonResponse|Response
|
||||
{
|
||||
abort_unless($path->available(), 404);
|
||||
$data = ['contexts' => $path->contexts($request->user()), 'sectorSource' => app(SectorInvestment::class)->context($sectorSlug)];
|
||||
|
||||
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/index', $data);
|
||||
}
|
||||
|
||||
public function sectorAttach(Request $request, string $id): JsonResponse|RedirectResponse
|
||||
{
|
||||
app(SectorInvestment::class)->attach($request->user(), $id, $request->all());
|
||||
|
||||
return $request->expectsJson() ? response()->json(['ok' => true]) : redirect('/investimentos/'.$id)->with('success', 'Setor associado ao mesmo dossier.');
|
||||
}
|
||||
|
||||
public function zoneStart(Request $request,string $zoneId,InvestorPath $path): JsonResponse|Response
|
||||
{
|
||||
abort_unless($path->available(),404);
|
||||
$data=$request->validate(['consulted_public_version'=>'required|integer|min:1']);
|
||||
$data=['contexts'=>$path->contexts($request->user()),'zoneSource'=>app(InvestmentZonePathContext::class)->context($zoneId,(int)$data['consulted_public_version'])];
|
||||
return $request->expectsJson()?response()->json($data):Inertia::render('investor-path/index',$data);
|
||||
}
|
||||
|
||||
public function zoneAttach(Request $request,string $id): JsonResponse|RedirectResponse
|
||||
{
|
||||
app(InvestmentZonePathContext::class)->attach($request->user(),$id,$request->all());
|
||||
return $request->expectsJson()?response()->json(['ok'=>true]):redirect('/investimentos/'.$id)->with('success','Referência do parque guardada; não constitui reserva ou autorização.');
|
||||
}
|
||||
|
||||
public function create(Request $request, InvestorPath $path): JsonResponse|RedirectResponse
|
||||
{
|
||||
$zone=$request->has('zone_id') ? app(InvestmentZonePathContext::class)->input($request->all()) : null;
|
||||
if($zone){ $request->validate(['confirm'=>'required|accepted']); app(InvestmentZonePathContext::class)->context($zone['zone_id'],(int)$zone['consulted_public_version']); }
|
||||
$sector = $request->validate(['sector_slug' => 'nullable|string|max:150'])['sector_slug'] ?? null;
|
||||
if ($sector !== null) {
|
||||
app(SectorInvestment::class)->context($sector);
|
||||
}
|
||||
$source = $request->input('content_resource_id');
|
||||
if ($source !== null) {
|
||||
$resourceId = filter_var($source, FILTER_VALIDATE_INT);
|
||||
abort_unless(is_int($resourceId) && $resourceId !== 0, 422);
|
||||
app(LegacyContent::class)->articleForResource($resourceId);
|
||||
}
|
||||
$id = DB::transaction(function () use ($path, $request, $source, $sector, $zone): string {
|
||||
$id = $path->create($request->user(), $request->all());
|
||||
if ($source !== null) {
|
||||
$case = $path->view($request->user(), $id)['case'];
|
||||
app(LegacyContent::class)->attach($request->user(), $id, ['version' => $case['version'], 'resource_id' => (int) $source]);
|
||||
}
|
||||
|
||||
if ($sector !== null) {
|
||||
$case = $path->view($request->user(), $id)['case'];
|
||||
app(SectorInvestment::class)->attach($request->user(), $id, ['version' => $case['version'], 'sector_slug' => $sector]);
|
||||
}
|
||||
|
||||
if($zone){$case=$path->view($request->user(),$id)['case']; app(InvestmentZonePathContext::class)->attach($request->user(),$id,[...$zone,'version'=>$case['version'],'confirm'=>true]);}
|
||||
return $id;
|
||||
});
|
||||
|
||||
return $request->expectsJson() ? response()->json(['id' => $id], 201) : redirect('/investimentos/'.$id)->with('success', 'Investimento guardado no seu dossier.');
|
||||
}
|
||||
|
||||
public function show(Request $request, string $id, InvestorPath $path): JsonResponse|Response
|
||||
{
|
||||
$choice = $request->validate(['opportunity' => 'nullable|string|max:100'])['opportunity'] ?? null;
|
||||
$data = $path->view($request->user(), $id, $choice);
|
||||
|
||||
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/show', $data);
|
||||
}
|
||||
|
||||
public function facts(Request $request, string $id, InvestorPath $path): JsonResponse|RedirectResponse
|
||||
{
|
||||
abort_unless($path->available(), 404);
|
||||
$path->facts($request->user(), $id, $request->all());
|
||||
|
||||
return $this->saved($request, 'Aplicabilidade registada; o histórico foi preservado.');
|
||||
}
|
||||
|
||||
public function stage(Request $request, string $id, string $code, InvestorPath $path): JsonResponse|RedirectResponse
|
||||
{
|
||||
abort_unless($path->available(), 404);
|
||||
$path->stage($request->user(), $id, $code, $request->all());
|
||||
|
||||
return $this->saved($request, 'Etapa e fundamento registados.');
|
||||
}
|
||||
|
||||
public function tool(Request $request, string $id, InvestorPath $path): JsonResponse
|
||||
{
|
||||
abort_unless($path->available(), 404);
|
||||
|
||||
return response()->json(['result' => $path->returnTool($request, $id, $request->all())]);
|
||||
}
|
||||
|
||||
public function task(Request $request, string $id, InvestorPath $path): JsonResponse|RedirectResponse
|
||||
{
|
||||
abort_unless($path->available(), 404);
|
||||
$task = $path->task($request->user(), $id, $request->all());
|
||||
|
||||
return $request->expectsJson() ? response()->json(['id' => $task], 201) : back()->with('success', 'Tarefa assumida no acompanhamento interno.');
|
||||
}
|
||||
|
||||
public function fintech(Request $request, string $id, InvestorPath $path): JsonResponse|RedirectResponse
|
||||
{
|
||||
abort_unless($path->available(), 404);
|
||||
app(FintechInvestment::class)->save($request->user(), $id, $request->all());
|
||||
|
||||
return $this->saved($request, 'Caracterização Fintech guardada no mesmo dossier; pendências e histórico preservados.');
|
||||
}
|
||||
|
||||
private function saved(Request $request, string $message): JsonResponse|RedirectResponse
|
||||
{
|
||||
return $request->expectsJson() ? response()->json(['message' => $message]) : back()->with('success', $message);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||
use App\Models\InterestRequest;
|
||||
use App\Models\Investor;
|
||||
use App\Models\InvestorJourney;
|
||||
use App\Services\CountryCatalogue;
|
||||
use App\Services\InterestSubmission;
|
||||
use App\Services\PortalAudit;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -23,10 +24,12 @@ class InvestorWorkspaceController extends Controller
|
||||
$interest = $journey->interest_request_id ? InterestRequest::find($journey->interest_request_id) : null;
|
||||
|
||||
return ['id' => $journey->id, 'dossier_id' => $dossier?->id, 'title' => $dossier->title ?? 'Ficha temporariamente indisponível',
|
||||
'status' => $journey->status, 'reference' => $interest?->reference, 'request_status' => $interest?->status];
|
||||
'status' => $journey->status, 'reference' => $interest?->reference, 'request_status' => $interest?->status,
|
||||
'country' => $interest?->country,
|
||||
'declared_origin' => app(CountryCatalogue::class)->describe($interest?->metadata['declared_origin_country_code'] ?? null)];
|
||||
});
|
||||
|
||||
return Inertia::render('portfolio/workspace', ['journeys' => $journeys]);
|
||||
return Inertia::render('portfolio/workspace', ['journeys' => $journeys, 'countries' => app(CountryCatalogue::class)->all()]);
|
||||
}
|
||||
|
||||
public function start(Request $request, string $id): RedirectResponse
|
||||
@@ -57,7 +60,8 @@ class InvestorWorkspaceController extends Controller
|
||||
|
||||
public function submit(Request $request, string $id, InterestSubmission $submission): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['message' => ['required', 'string', 'min:20', 'max:5000'], 'country' => ['required', 'string', 'max:100'],
|
||||
$data = $request->validate(['message' => ['required', 'string', 'min:20', 'max:5000'], 'country' => ['required', 'string', 'max:100', app(CountryCatalogue::class)->legacyCountryRule()],
|
||||
'declared_origin_country_code' => ['nullable', app(CountryCatalogue::class)->rule()],
|
||||
'privacy_consent' => ['accepted'], 'role' => ['required', 'in:investir,financiar,fornecer,operar,parceria'],
|
||||
'horizon' => ['required', 'string', 'max:100']]);
|
||||
DB::transaction(function () use ($request, $id, $data, $submission) {
|
||||
@@ -74,6 +78,7 @@ class InvestorWorkspaceController extends Controller
|
||||
'sector_interest' => Str::limit($dossier->content['sector'] ?? $dossier->title, 150, ''), 'message' => $data['message'],
|
||||
'privacy_consent' => true, 'consented_at' => now(), 'status' => 'received', 'source_page' => '/carteira/'.$dossier->id,
|
||||
'metadata' => ['journey_id' => $journey->id, 'dossier_id' => $dossier->id, 'origin_dossier_id' => $journey->origin_dossier_id,
|
||||
'declared_origin_country_code' => $data['declared_origin_country_code'] ?? null,
|
||||
'project_id' => $dossier->project_id ?? $origin?->project_id,
|
||||
'cluster_id' => $dossier->cluster_id ?? $origin?->cluster_id, 'corridor_id' => $dossier->corridor_id ?? $origin?->corridor_id,
|
||||
'role' => $data['role'], 'horizon' => $data['horizon'], 'purpose' => 'Acompanhamento da manifestação de interesse'],
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreLandAssistanceCaseRequest;
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\InterestRequest;
|
||||
use App\Models\LandAssistanceCase;
|
||||
use App\Services\CountryCatalogue;
|
||||
use App\Services\InterestSubmission;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\InvestorContext;
|
||||
use App\Services\InvestorContextConsumers;
|
||||
use App\Services\VerifiedDocumentBinary;
|
||||
use App\Services\VirtualDesk;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class LandAssistanceController extends Controller
|
||||
{
|
||||
private const DISCLAIMER = 'Este documento apoia a preparação e o acompanhamento. Não constitui requerimento oficialmente recebido, título de DUAT, confirmação de disponibilidade da terra ou decisão da autoridade competente.';
|
||||
|
||||
public function guide(): Response
|
||||
{
|
||||
return Inertia::render('land-assistance/guide', ['disclaimer' => self::DISCLAIMER]);
|
||||
}
|
||||
|
||||
public function blank(): Response
|
||||
{
|
||||
return Inertia::render('land-assistance/blank', ['disclaimer' => self::DISCLAIMER]);
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$cases = LandAssistanceCase::where('user_id', $request->user()->id)
|
||||
->latest('updated_at')->paginate(20)->through(fn (LandAssistanceCase $case): array => [
|
||||
'id' => $case->id,
|
||||
'reference' => $case->reference,
|
||||
'status' => $case->status,
|
||||
'version' => $case->version,
|
||||
'updated_at' => $case->updated_at,
|
||||
]);
|
||||
|
||||
return Inertia::render('land-assistance/workspace', ['cases' => $cases]);
|
||||
}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$context = $request->validate(['context' => ['nullable', 'uuid']])['context'] ?? null;
|
||||
$dossier = null;
|
||||
if ($context !== null) {
|
||||
$case = app(VirtualDesk::class)->case($request->user(), $context);
|
||||
abort_unless(config('investor-path.enabled') && (int) $case->user_id === $request->user()->id && ! $case->closed_at && $case->journey_id
|
||||
&& DB::table('investor_journeys')->where('id', $case->journey_id)->whereNotNull('path_definition_id')->whereNull('deleted_at')->exists(), 403);
|
||||
$dossier = ['id' => $case->id, 'title' => $case->title, 'url' => '/investimentos/'.$case->id];
|
||||
}
|
||||
|
||||
return Inertia::render('land-assistance/form', [
|
||||
'case' => null, 'boundContextRevision' => null,
|
||||
'returnDossier' => $dossier,
|
||||
'user' => ['name' => $request->user()->name, 'email' => $request->user()->email],
|
||||
'disclaimer' => self::DISCLAIMER,
|
||||
'modelVersion' => 'land-assistance-v1',
|
||||
'countries' => app(CountryCatalogue::class)->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Request $request, string $id): Response
|
||||
{
|
||||
$case = $this->owned($request, $id);
|
||||
abort_unless(in_array($case->status, ['draft', 'waiting_applicant'], true), 409, 'Este pedido não está aberto para edição.');
|
||||
|
||||
return Inertia::render('land-assistance/form', [
|
||||
'case' => $case->only(['id', 'reference', 'version', 'status', 'payload']), 'boundContextRevision' => app(InvestorContextConsumers::class)->landRevision($case->id),
|
||||
'returnDossier' => $this->dossierContext($case),
|
||||
'user' => ['name' => $request->user()->name, 'email' => $request->user()->email],
|
||||
'disclaimer' => self::DISCLAIMER,
|
||||
'modelVersion' => 'land-assistance-v1',
|
||||
'countries' => app(CountryCatalogue::class)->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreLandAssistanceCaseRequest $request): RedirectResponse|JsonResponse
|
||||
{
|
||||
$payload = $request->payload();
|
||||
$payload['full_name'] = $payload['full_name'] ?? $request->user()->name;
|
||||
$payload['email'] = $payload['email'] ?? $request->user()->email;
|
||||
$case = DB::transaction(function () use ($request, $payload): LandAssistanceCase {
|
||||
$case = LandAssistanceCase::create([
|
||||
'user_id' => $request->user()->id,
|
||||
'reference' => 'TERRA-'.now()->format('Y').'-'.Str::upper(Str::random(8)),
|
||||
'status' => 'draft',
|
||||
'version' => 1,
|
||||
'payload' => $payload,
|
||||
'crm_submission_key' => (string) Str::uuid(),
|
||||
]);
|
||||
$this->revision($case);
|
||||
$this->event($case, $request, 'draft_created');
|
||||
|
||||
return $case;
|
||||
});
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['id' => $case->id, 'reference' => $case->reference, 'version' => $case->version], 201)
|
||||
: redirect()->route('land-assistance.show', $case->id);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $id): Response|JsonResponse
|
||||
{
|
||||
$case = $this->owned($request, $id);
|
||||
$data = [
|
||||
...$this->presentation($case),
|
||||
'case' => $case->only(['id', 'reference', 'status', 'version', 'payload', 'updated_at']),
|
||||
'documents' => DB::table('land_assistance_documents as link')->join('documents as document', 'document.id', '=', 'link.document_id')
|
||||
->where('link.case_id', $id)->whereIn('document.status', ['active', 'quarantined'])->whereNull('document.deleted_at')
|
||||
->get(['link.id', 'link.category', 'document.file_name', 'document.status']),
|
||||
'events' => DB::table('land_assistance_events')->where('case_id', $id)->orderBy('created_at')
|
||||
->get(['action', 'version', 'metadata', 'created_at']),
|
||||
'supportTicket' => $case->interest_request_id
|
||||
? InterestRequest::query()->whereKey($case->interest_request_id)->first(['reference', 'status', 'created_at'])
|
||||
: null,
|
||||
'disclaimer' => self::DISCLAIMER,
|
||||
'management' => false,
|
||||
];
|
||||
|
||||
return $request->expectsJson() ? response()->json($data) : Inertia::render('land-assistance/case', $data);
|
||||
}
|
||||
|
||||
public function save(StoreLandAssistanceCaseRequest $request, string $id): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$payload = $request->payload();
|
||||
$case = DB::transaction(function () use ($request, $id, $data, $payload): LandAssistanceCase {
|
||||
$case = $this->owned($request, $id, true);
|
||||
abort_unless(in_array($case->status, ['draft', 'waiting_applicant'], true), 409, 'Este pedido está bloqueado para edição pelo requerente.');
|
||||
$this->checkVersion($case, (int) $data['version']);
|
||||
$payload['full_name'] = $payload['full_name'] ?? $case->payload['full_name'] ?? $request->user()->name;
|
||||
$payload['email'] = $payload['email'] ?? $case->payload['email'] ?? $request->user()->email;
|
||||
$case->forceFill(['payload' => $payload, 'version' => $case->version + 1])->save();
|
||||
$this->revision($case);
|
||||
$this->event($case, $request, 'draft_updated');
|
||||
|
||||
return $case;
|
||||
});
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['id' => $case->id, 'reference' => $case->reference, 'version' => $case->version])
|
||||
: redirect()->route('land-assistance.show', $case->id)->with('status', 'Rascunho guardado.');
|
||||
}
|
||||
|
||||
public function requestSupport(StoreLandAssistanceCaseRequest $request, string $id, InterestSubmission $submission): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$submittedPayload = $request->payload();
|
||||
$case = DB::transaction(function () use ($request, $id, $data, $submission, $submittedPayload): LandAssistanceCase {
|
||||
if ($boundId = $submittedPayload['investor_path_case_id'] ?? null) {
|
||||
app(InvestorContextConsumers::class)->assertReviewed($request->user(), $boundId, $request->all(), app(InvestorContextConsumers::class)->landRevision($id));
|
||||
}
|
||||
$case = $this->owned($request, $id, true);
|
||||
$replaying = $case->status === 'support_requested';
|
||||
abort_unless(in_array($case->status, ['draft', 'waiting_applicant', 'support_requested'], true), 409, 'Este pedido já não pode ser submetido neste estado.');
|
||||
if (! $replaying) {
|
||||
$this->checkVersion($case, (int) $data['version']);
|
||||
}
|
||||
$payload = [...$case->payload, ...$submittedPayload];
|
||||
$payload['full_name'] = $payload['full_name'] ?? $request->user()->name;
|
||||
if (! array_key_exists('email', $payload)) {
|
||||
$payload['email'] = $request->user()->email;
|
||||
}
|
||||
$payload['email'] = is_string($payload['email']) && trim($payload['email']) !== '' ? strtolower(trim($payload['email'])) : null;
|
||||
abort_if(empty($payload['email']) && empty($payload['phone']), 422, 'Indique pelo menos um contacto valido.');
|
||||
if ($replaying) {
|
||||
// A retry of the same accepted attempt recovers its original receipt.
|
||||
// Changed data, another version or a missing receipt must not reopen the case.
|
||||
abort_unless((int) $case->version === (int) $data['version'] + 1 && $case->interest_request_id
|
||||
&& hash_equals(hash('sha256', json_encode(Arr::sortRecursive($case->payload), JSON_THROW_ON_ERROR)),
|
||||
hash('sha256', json_encode(Arr::sortRecursive($payload), JSON_THROW_ON_ERROR))),
|
||||
409, 'O pedido mudou; recupere o estado atual antes de voltar a enviar.');
|
||||
}
|
||||
$message = trim(($payload['request_description'] ?? 'Pedido de apoio sobre Terra e DUAT.')."\nReferência interna: {$case->reference}. Tipo: ".($payload['support_kind'] ?? 'não indicado').'.');
|
||||
$interest = $submission->receive([
|
||||
'kind' => 'support', 'fullName' => $payload['full_name'], 'email' => $payload['email'],
|
||||
'phone' => $payload['phone'] ?? null, 'country' => $payload['country'] ?? 'Não indicado',
|
||||
'sectorInterest' => 'Terra e DUAT', 'message' => mb_substr($message, 0, 2000),
|
||||
'privacyConsent' => true, 'portal_user_id' => $request->user()->id,
|
||||
'land_case_id' => $case->id, 'land_case_reference' => $case->reference,
|
||||
'declared_origin_country_code' => $payload['declared_origin_country_code'] ?? null,
|
||||
], $case->crm_submission_key, fn () => InterestRequest::create([
|
||||
'reference' => 'AP-'.now()->year.'-'.Str::ulid(), 'full_name' => $payload['full_name'],
|
||||
'email' => $payload['email'], 'phone' => $payload['phone'] ?? null,
|
||||
'country' => $payload['country'] ?? 'Não indicado',
|
||||
// Use the existing intake vocabulary; retain the more specific declared category.
|
||||
// This factory only runs for a new receipt. Older submissions/replay hashes stay intact.
|
||||
'investor_type' => match ($payload['applicant_category'] ?? null) {
|
||||
'individual' => 'individual', 'entity' => 'empresa', default => 'outro',
|
||||
},
|
||||
'sector_interest' => 'Terra e DUAT', 'message' => mb_substr($message, 0, 2000),
|
||||
'privacy_consent' => true, 'consented_at' => now(), 'status' => 'received', 'source_page' => '/terra-duat',
|
||||
'metadata' => ['kind' => 'support', 'portal_user_id' => $request->user()->id,
|
||||
'land_case_id' => $case->id, 'land_case_reference' => $case->reference,
|
||||
'applicant_category' => $payload['applicant_category'] ?? null,
|
||||
'declared_origin_country_code' => $payload['declared_origin_country_code'] ?? null],
|
||||
]));
|
||||
if ($replaying) {
|
||||
abort_unless((int) $interest->getKey() === (int) $case->interest_request_id && ! $interest->trashed(),
|
||||
409, 'O recibo original não está disponível; recupere o estado do pedido.');
|
||||
|
||||
return $case;
|
||||
}
|
||||
$case->forceFill([
|
||||
'payload' => $payload,
|
||||
'status' => 'support_requested',
|
||||
'support_requested_at' => now(),
|
||||
'version' => $case->version + 1,
|
||||
'interest_request_id' => $interest->id,
|
||||
])->save();
|
||||
$this->revision($case);
|
||||
$this->event($case, $request, 'support_requested');
|
||||
|
||||
return $case;
|
||||
});
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['id' => $case->id, 'reference' => $case->reference, 'status' => $case->status, 'version' => $case->version], 202)
|
||||
: redirect()->route('land-assistance.show', $case->id)->with('status', 'Pedido de apoio registado no InvestMoz. Isto não é submissão oficial de DUAT.');
|
||||
}
|
||||
|
||||
public function reportUpdate(Request $request, string $id): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'version' => ['required', 'integer', 'min:1'],
|
||||
'reported_status' => ['required', 'string', 'max:1000'],
|
||||
'official_reference' => ['nullable', 'string', 'max:160'],
|
||||
'receiving_entity' => ['nullable', 'string', 'max:200'],
|
||||
'reported_at' => ['nullable', 'date_format:Y-m-d'],
|
||||
]);
|
||||
$case = DB::transaction(function () use ($request, $id, $data): LandAssistanceCase {
|
||||
$case = $this->owned($request, $id, true);
|
||||
abort_unless(in_array($case->status, ['support_requested', 'internal_review', 'waiting_applicant', 'referral_prepared'], true), 409);
|
||||
$this->checkVersion($case, (int) $data['version']);
|
||||
$payload = $case->payload;
|
||||
$updates = $payload['reported_updates'] ?? [];
|
||||
$updates[] = ['reported_status' => $data['reported_status'], 'official_reference' => $data['official_reference'] ?? null,
|
||||
'receiving_entity' => $data['receiving_entity'] ?? null, 'reported_at' => $data['reported_at'] ?? null, 'recorded_at' => now()->toIso8601String()];
|
||||
$payload['reported_updates'] = array_slice($updates, -20);
|
||||
$case->forceFill(['payload' => $payload, 'version' => $case->version + 1])->save();
|
||||
$this->revision($case);
|
||||
$this->event($case, $request, 'applicant_update_recorded');
|
||||
|
||||
return $case;
|
||||
});
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['version' => $case->version, 'reported_by_applicant' => true])
|
||||
: redirect()->route('land-assistance.show', $case->id)->with('status', 'Actualização registada como informação comunicada por si.');
|
||||
}
|
||||
|
||||
public function attach(Request $request, string $id): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'version' => ['required', 'integer', 'min:1'],
|
||||
'category' => ['required', Rule::in(['sketch', 'prior_right', 'exploration_plan', 'official_receipt', 'consultation_minutes', 'photo', 'other'])],
|
||||
'file' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'],
|
||||
]);
|
||||
$case = $this->owned($request, $id, true);
|
||||
$this->checkVersion($case, (int) $data['version']);
|
||||
abort_unless(! in_array($case->status, ['closed'], true), 409);
|
||||
abort_if(DB::table('land_assistance_documents')->where('case_id', $id)->count() >= 20, 422, 'Máximo de vinte documentos por pedido.');
|
||||
|
||||
$file = $request->file('file');
|
||||
$documentId = (string) Str::uuid();
|
||||
$key = 'land-assistance/'.$id.'/'.$documentId;
|
||||
$stored = $file->storeAs('land-assistance/'.$id, $documentId, 'local');
|
||||
if (! is_string($stored) || $stored === '') {
|
||||
throw ValidationException::withMessages(['file' => 'Não foi possível guardar o ficheiro em armazenamento privado.']);
|
||||
}
|
||||
try {
|
||||
DB::transaction(function () use ($request, $case, $data, $file, $documentId, $stored): void {
|
||||
$locked = $this->owned($request, $case->id, true);
|
||||
$this->checkVersion($locked, (int) $data['version']);
|
||||
DB::table('documents')->insert([
|
||||
'id' => $documentId,
|
||||
'source_system' => 'land-assistance',
|
||||
'data_classification' => 'restricted',
|
||||
'file_name' => basename($file->getClientOriginalName()),
|
||||
'media_type' => (string) $file->getMimeType(),
|
||||
'storage_key' => $stored,
|
||||
'sha256' => hash_file('sha256', Storage::disk('local')->path($stored)),
|
||||
'size_bytes' => $file->getSize(),
|
||||
'uploaded_by' => $request->user()->id,
|
||||
// The configured scanner releases only the exact original hash after a clean result.
|
||||
'status' => 'quarantined',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('land_assistance_documents')->insert([
|
||||
'id' => (string) Str::uuid(), 'case_id' => $locked->id, 'document_id' => $documentId,
|
||||
'category' => $data['category'], 'created_at' => now(),
|
||||
]);
|
||||
$locked->forceFill(['version' => $locked->version + 1])->save();
|
||||
$this->revision($locked);
|
||||
$this->event($locked, $request, 'attachment_added', ['document_id' => $documentId, 'category' => $data['category']]);
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
Storage::disk('local')->delete($stored);
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['uploaded' => true], 201)
|
||||
: back()->with('status', 'Documento privado anexado; a revisão ainda está pendente.');
|
||||
}
|
||||
|
||||
public function download(Request $request, string $id, string $attachment): \Illuminate\Http\Response|BinaryFileResponse
|
||||
{
|
||||
// Management uses its existing explicit permission; the owner URL stays owner-only.
|
||||
if ($request->routeIs('land-assistance.manage.download')) {
|
||||
abort_unless($request->user()->can('manage-portal'), 403);
|
||||
$case = LandAssistanceCase::whereKey($id)->firstOrFail();
|
||||
} else {
|
||||
$case = $this->owned($request, $id);
|
||||
}
|
||||
$document = DB::table('land_assistance_documents as link')->join('documents as document', 'document.id', '=', 'link.document_id')
|
||||
->where('link.case_id', $id)->where('link.id', $attachment)->where('document.status', 'active')->whereNull('document.deleted_at')
|
||||
->where('document.malware_scan_status', 'clean')->whereColumn('document.malware_scanned_sha256', 'document.sha256')
|
||||
->first(['document.*']);
|
||||
abort_if($document === null, 404);
|
||||
$response = app(VerifiedDocumentBinary::class)->respond($document);
|
||||
DB::table('audit_events')->insert(['source_system' => 'land-assistance', 'entity_type' => 'documents', 'entity_id' => $document->id,
|
||||
'actor_user_id' => $request->user()->id, 'action' => 'document_downloaded', 'data_classification' => 'restricted',
|
||||
'after_data' => json_encode(['case_id' => $case->id, 'attachment_id' => $attachment], JSON_THROW_ON_ERROR)]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function print(Request $request, string $id): Response
|
||||
{
|
||||
$case = $this->owned($request, $id);
|
||||
|
||||
return Inertia::render('land-assistance/print', [
|
||||
'case' => $case->only(['reference', 'version', 'payload', 'updated_at']),
|
||||
'disclaimer' => self::DISCLAIMER,
|
||||
'modelVersion' => 'land-assistance-v1',
|
||||
'country' => app(CountryCatalogue::class)->describe($case->payload['country'] ?? null),
|
||||
'declaredOrigin' => app(CountryCatalogue::class)->describe($case->payload['declared_origin_country_code'] ?? null),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function presentation(LandAssistanceCase $case, bool $management = false): array
|
||||
{
|
||||
$payload = $case->payload;
|
||||
$countries = app(CountryCatalogue::class);
|
||||
$country = $countries->describe($payload['country'] ?? null);
|
||||
$paths = [];
|
||||
foreach ($payload['parcels'] ?? [] as $parcel) {
|
||||
$id = $parcel['locality_id'] ?? $parcel['admin_post_id'] ?? $parcel['district_id'] ?? $parcel['province_id'] ?? null;
|
||||
$paths[] = app(InvestmentGeography::class)->path($id ?: null, $management);
|
||||
}
|
||||
|
||||
return ['countryName' => $country['code'] !== null ? $country['name'] : ($payload['country'] ?? 'Não informado'),
|
||||
'declaredOrigin' => $countries->describe($payload['declared_origin_country_code'] ?? null), 'territoryPaths' => $paths, 'returnDossier' => $this->dossierContext($case)];
|
||||
}
|
||||
|
||||
private function dossierContext(LandAssistanceCase $case): ?array
|
||||
{
|
||||
$id = $case->payload['investor_path_case_id'] ?? null;
|
||||
$actor = request()->user();
|
||||
if (! $actor || (int) $case->user_id !== $actor->id || ! is_string($id) || ! Str::isUuid($id) || ! config('investor-path.enabled')) {
|
||||
return null;
|
||||
}
|
||||
$dossier = DB::table('desk_cases')->where('id', $id)->where('user_id', $actor->id)->first();
|
||||
|
||||
return $dossier ? ['id' => $dossier->id, 'title' => $dossier->title, 'url' => '/investimentos/'.$dossier->id] : null;
|
||||
}
|
||||
|
||||
public function manage(Request $request): Response
|
||||
{
|
||||
$cases = LandAssistanceCase::query()->latest('updated_at')->paginate(30)->through(fn (LandAssistanceCase $case): array => [
|
||||
'id' => $case->id, 'reference' => $case->reference, 'status' => $case->status,
|
||||
'version' => $case->version, 'updated_at' => $case->updated_at,
|
||||
'applicant' => $case->payload['full_name'] ?? null,
|
||||
]);
|
||||
|
||||
return Inertia::render('land-assistance/management', ['cases' => $cases]);
|
||||
}
|
||||
|
||||
public function manageShow(Request $request, string $id): Response
|
||||
{
|
||||
$case = LandAssistanceCase::findOrFail($id);
|
||||
$events = DB::table('land_assistance_events')->where('case_id', $id)->orderBy('created_at')->get(['action', 'version', 'metadata', 'created_at']);
|
||||
|
||||
return Inertia::render('land-assistance/case', [
|
||||
...$this->presentation($case, true),
|
||||
'case' => $case->only(['id', 'reference', 'status', 'version', 'payload', 'updated_at']),
|
||||
'documents' => DB::table('land_assistance_documents as link')->join('documents as document', 'document.id', '=', 'link.document_id')
|
||||
->where('link.case_id', $id)->whereIn('document.status', ['active', 'quarantined'])->whereNull('document.deleted_at')->get(['link.id', 'link.category', 'document.file_name', 'document.status']),
|
||||
'events' => $events,
|
||||
'supportTicket' => $case->interest_request_id
|
||||
? InterestRequest::query()->whereKey($case->interest_request_id)->first(['reference', 'status', 'created_at'])
|
||||
: null,
|
||||
'disclaimer' => self::DISCLAIMER,
|
||||
'management' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function changeStatus(Request $request, string $id): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'version' => ['required', 'integer', 'min:1'],
|
||||
'status' => ['required', Rule::in(['internal_review', 'waiting_applicant', 'referral_prepared', 'closed'])],
|
||||
'evidence_reference' => ['nullable', 'string', 'max:300'],
|
||||
]);
|
||||
$case = DB::transaction(function () use ($request, $id, $data): LandAssistanceCase {
|
||||
$case = LandAssistanceCase::whereKey($id)->lockForUpdate()->firstOrFail();
|
||||
$this->checkVersion($case, (int) $data['version']);
|
||||
$allowed = [
|
||||
'support_requested' => ['internal_review', 'waiting_applicant', 'referral_prepared', 'closed'],
|
||||
'internal_review' => ['waiting_applicant', 'referral_prepared', 'closed'],
|
||||
'waiting_applicant' => ['internal_review', 'referral_prepared', 'closed'],
|
||||
'referral_prepared' => ['internal_review', 'waiting_applicant', 'closed'],
|
||||
];
|
||||
abort_unless(in_array($data['status'], $allowed[$case->status] ?? [], true), 409, 'Transição de estado não permitida.');
|
||||
if ($data['status'] === 'referral_prepared' && empty($data['evidence_reference'])) {
|
||||
throw ValidationException::withMessages(['evidence_reference' => 'Registe a referência interna do material preparado para encaminhamento.']);
|
||||
}
|
||||
$previousStatus = $case->status;
|
||||
$case->forceFill(['status' => $data['status'], 'version' => $case->version + 1])->save();
|
||||
$this->revision($case);
|
||||
$this->event($case, $request, 'status_changed', [
|
||||
'from' => $previousStatus, 'to' => $data['status'],
|
||||
'evidence_reference' => $data['evidence_reference'] ?? null,
|
||||
'external_submission_confirmed' => false,
|
||||
]);
|
||||
|
||||
return $case;
|
||||
});
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['status' => $case->status, 'version' => $case->version])
|
||||
: redirect()->route('land-assistance.manage.show', $case->id)->with('status', 'Estado interno actualizado. Nenhuma recepção pública foi presumida.');
|
||||
}
|
||||
|
||||
private function owned(Request $request, string $id, bool $lock = false): LandAssistanceCase
|
||||
{
|
||||
$query = LandAssistanceCase::whereKey($id)->where('user_id', $request->user()->id);
|
||||
|
||||
return ($lock ? $query->lockForUpdate() : $query)->firstOrFail();
|
||||
}
|
||||
|
||||
private function checkVersion(LandAssistanceCase $case, int $expected): void
|
||||
{
|
||||
abort_unless($case->version === $expected, 412, 'Este rascunho foi alterado. Recarregue antes de continuar.');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $metadata */
|
||||
private function event(LandAssistanceCase $case, Request $request, string $action, array $metadata = []): void
|
||||
{
|
||||
$contextId = $case->payload['investor_path_case_id'] ?? null;
|
||||
if ($contextId) {
|
||||
$binding = app(InvestorContextConsumers::class)->landRevision($case->id);
|
||||
if ($action === 'draft_created' || $request->boolean('context_reviewed')) {
|
||||
$binding = app(InvestorContextConsumers::class)->assertReviewed($request->user(), $contextId, $action === 'draft_created' ? ['context_reviewed' => true, 'context_revision' => app(InvestorContext::class)->state(app(VirtualDesk::class)->case($request->user(), $contextId))['revision']] : $request->all(), $binding);
|
||||
}
|
||||
$metadata['investment_context_revision'] = $binding;
|
||||
}
|
||||
DB::table('land_assistance_events')->insert([
|
||||
'case_id' => $case->id, 'actor_id' => $request->user()->id,
|
||||
'action' => $action, 'version' => $case->version, 'metadata' => json_encode($metadata, JSON_THROW_ON_ERROR), 'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function revision(LandAssistanceCase $case): void
|
||||
{
|
||||
DB::table('land_assistance_revisions')->insert([
|
||||
'case_id' => $case->id, 'version' => $case->version,
|
||||
'snapshot' => Crypt::encryptString(json_encode(['status' => $case->status, 'payload' => $case->payload], JSON_THROW_ON_ERROR)),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\InvestorPath;
|
||||
use App\Services\LegacyContent;
|
||||
use App\Services\VirtualDesk;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class LegacyContentController extends Controller
|
||||
{
|
||||
public function landing(Request $request, LegacyContent $legacy, string $sectorSlug = 'agronegocio'): Response
|
||||
{
|
||||
$filters = $request->validate(['q' => 'nullable|string|max:120']);
|
||||
$articles = $legacy->articlesForGroup($sectorSlug);
|
||||
$root = config('legacy_content.groups.'.$sectorSlug.'.root');
|
||||
$article = $legacy->article($root);
|
||||
$q = $filters['q'] ?? '';
|
||||
$articles = array_values(array_filter($articles, fn ($item) => $q === ''
|
||||
|| str_contains(mb_strtolower(strip_tags($item['html'].' '.$item['title'])), mb_strtolower($q))));
|
||||
$match = $articles !== [];
|
||||
|
||||
return Inertia::render('portal/legacy', ['article' => $article, 'articles' => $articles, 'view' => 'landing', 'query' => $q, 'match' => $match]);
|
||||
}
|
||||
|
||||
public function article(Request $request, LegacyContent $legacy, string $legacySlug = 'agronegocio'): Response
|
||||
{
|
||||
return Inertia::render('portal/legacy', ['article' => $legacy->article($legacySlug), 'view' => 'article', 'contentContext' => $this->context($request)]);
|
||||
}
|
||||
|
||||
public function start(Request $request, LegacyContent $legacy, InvestorPath $path, string $legacySlug = 'agronegocio'): Response
|
||||
{
|
||||
abort_unless($path->available(), 404);
|
||||
$article = $legacy->article($legacySlug);
|
||||
|
||||
return Inertia::render('investor-path/index', ['contexts' => $path->contexts($request->user()), 'contentSource' => $article]);
|
||||
}
|
||||
|
||||
public function attach(Request $request, string $id, LegacyContent $legacy): JsonResponse|RedirectResponse
|
||||
{
|
||||
$legacy->attach($request->user(), $id, $request->all());
|
||||
|
||||
return $request->expectsJson() ? response()->json(['saved' => true]) : redirect('/investimentos/'.$id)->with('success', 'Referência guardada no dossier.');
|
||||
}
|
||||
|
||||
/** @return array{id: string, title: string}|null */
|
||||
private function context(Request $request): ?array
|
||||
{
|
||||
$data = $request->validate(['context' => 'nullable|uuid']);
|
||||
if (! $request->user() || empty($data['context'])) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$case = app(VirtualDesk::class)->case($request->user(), $data['context'], true);
|
||||
|
||||
return $case->closed_at ? null : ['id' => $case->id, 'title' => $case->title];
|
||||
} catch (HttpException $error) {
|
||||
if (! in_array($error->getStatusCode(), [403, 404], true)) {
|
||||
throw $error;
|
||||
}
|
||||
|
||||
return null; // Consultation stays public; an unauthorized private context is never returned.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\OpportunityClassification;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class OpportunityClassificationController extends Controller
|
||||
{
|
||||
public function index(Request $request, int $id, OpportunityClassification $service): JsonResponse
|
||||
{
|
||||
$record = Opportunity::findOrFail($id);
|
||||
app(PortalProfileAccess::class)->authorize($request->user(), 'view', $record);
|
||||
|
||||
return response()->json(['version' => $service::VERSION, 'etag' => app(OpportunityManagement::class)->etag($record), 'rows' => $service->listing($request->user(), $record), 'duplicates' => $service->duplicates($request->user(), $record)])->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function review(Request $request, int $id, OpportunityClassification $service): JsonResponse
|
||||
{
|
||||
$record = $service->review($request->user(), Opportunity::findOrFail($id), $request->all());
|
||||
|
||||
return response()->json(['etag' => app(OpportunityManagement::class)->etag($record), 'rows' => $service->listing($request->user(), $record)])->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Domain\Opportunities\Enums\OpportunityStatus;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Sector;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\OpportunityCanonicalContract;
|
||||
use App\Services\OpportunityCanonicalResponse;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\OpportunityManagementInstallation;
|
||||
use App\Services\OpportunityMaturity;
|
||||
use App\Services\OpportunityProfileWorkflow;
|
||||
use App\Services\OpportunityResources;
|
||||
use App\Services\OpportunityWorkflowTasks;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class OpportunityManagementController extends Controller
|
||||
{
|
||||
public function installation(Request $request, OpportunityManagementInstallation $service): JsonResponse|Response
|
||||
{
|
||||
return $request->expectsJson() ? response()->json($service->preview()) : Inertia::render('management/opportunity-install', $service->preview());
|
||||
}
|
||||
|
||||
public function applyInstallation(Request $request, OpportunityManagementInstallation $service): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['hash' => 'required|string|size:64', 'confirm' => 'required|accepted']);
|
||||
$service->apply($request->user(), $data['hash']);
|
||||
|
||||
return back()->with('success', 'Gestão instalada; nenhuma oportunidade foi submetida ou publicada.');
|
||||
}
|
||||
|
||||
public function index(Request $request, OpportunityManagement $service): Response
|
||||
{
|
||||
if (! $service->installed()) {
|
||||
return app(PortalManagementController::class)->catalog($request, 'opportunities');
|
||||
}
|
||||
$filters = $request->validate(['queue' => 'nullable|in:review,publication,appeal,assigned', 'q' => 'nullable|string|max:150', 'status' => ['nullable', Rule::in([...array_column(OpportunityStatus::cases(), 'value'), 'returned', 'auto_validation_failed', 'technical_review', 'legal_territorial_review', 'approval_pending', 'publication_pending'])], 'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')], 'geography_scope' => ['nullable', Rule::in(['unspecified', 'specific', 'multiple', 'national', 'digital'])], 'category' => ['nullable', Rule::in(OpportunityManagement::CATEGORIES)], 'deleted' => 'nullable|boolean', 'page' => 'nullable|integer|min:1']);
|
||||
$query = Opportunity::query();
|
||||
$access = app(PortalProfileAccess::class);
|
||||
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
|
||||
$query = $access->scope($query, $request->user());
|
||||
if (str_starts_with($request->path(), 'minhas-oportunidades')) {
|
||||
$query->whereNotNull('owner_user_id');
|
||||
}
|
||||
}
|
||||
if ($filters['deleted'] ?? false) {
|
||||
$query->withoutGlobalScope(SoftDeletingScope::class)->whereNotNull('opportunities.deleted_at');
|
||||
}
|
||||
if ($filters['q'] ?? null) {
|
||||
$query->where(function ($q) use ($filters) {
|
||||
$q->whereLike('title', '%'.$filters['q'].'%')->orWhereLike('reference', '%'.$filters['q'].'%');
|
||||
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
|
||||
$q->orWhere(fn ($draft) => $draft->whereNotNull('owner_user_id')->whereRaw("management_draft::jsonb->'canonical'->>'official_title' ILIKE ?", ['%'.$filters['q'].'%']));
|
||||
}
|
||||
});
|
||||
}
|
||||
if ($filters['status'] ?? null) {
|
||||
if (Schema::hasColumn('opportunities', 'workflow_context')) {
|
||||
$query->whereRaw("COALESCE(workflow_context->>'working_state',status)=?", [$filters['status']]);
|
||||
} else {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
}
|
||||
if ($filters['category'] ?? null) {
|
||||
$query->where('opportunity_category', $filters['category']);
|
||||
}
|
||||
if ($filters['sector_id'] ?? null) {
|
||||
$sector = Sector::find($filters['sector_id']);
|
||||
$query->whereIn('sector_id', $sector ? Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
|
||||
}
|
||||
if ($filters['geography_scope'] ?? null) {
|
||||
$query->where('geography_scope', $filters['geography_scope']);
|
||||
}
|
||||
|
||||
if ($filters['queue'] ?? null) {
|
||||
$queue = $filters['queue'];
|
||||
if ($queue === 'review') {
|
||||
$query->whereRaw("COALESCE(workflow_context->>'working_state',status) IN ('submitted','in_review','information_pending')");
|
||||
} elseif ($queue === 'publication') {
|
||||
$query->whereRaw("COALESCE(workflow_context->>'working_state',status)='approved'");
|
||||
} elseif ($queue === 'appeal') {
|
||||
$query->whereRaw("workflow_context->'appeal'->>'pending'='true'");
|
||||
} else {
|
||||
$query->whereExists(fn ($q) => $q->selectRaw('1')->from('tasks')->where('tasks.source_system', 'opportunity-workflow')->where('tasks.assignee_user_id', $request->user()->id)->whereIn('tasks.status', ['open', 'in_progress'])->whereRaw("tasks.external_id LIKE 'portal-opportunity:' || opportunities.id::text || ':%'"));
|
||||
}
|
||||
}
|
||||
|
||||
$base = str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities';
|
||||
$stateSql = Schema::hasColumn('opportunities', 'workflow_context') ? "COALESCE(workflow_context->>'working_state',status)" : 'status';
|
||||
$counts = (clone $query)->selectRaw($stateSql.' AS working_status,count(*) AS total')->groupByRaw($stateSql)->pluck('total', 'working_status');
|
||||
$geographyReviewCount = (clone $query)->where('geography_scope', 'unspecified')->count();
|
||||
$records = $query->orderByDesc('id')->paginate(20)->withQueryString();
|
||||
$records->getCollection()->each(function (Opportunity $r) use ($access, $service, $request) {
|
||||
$r->setAttribute('can_edit', $access->canEdit($request->user(), $r));
|
||||
$r->setAttribute('can_promote', app(\App\Services\OpportunityPromotion::class)->allowed($request->user(), $r));
|
||||
$r->setAttribute('working_status', $access->state($r));
|
||||
$r->setAttribute('publication', $r->publicationPresentation());
|
||||
$r->setAttribute('draft_title', $service->values($r)['canonical']['official_title'] ?? $r->title);
|
||||
});
|
||||
|
||||
return Inertia::render('management/opportunities', ['basePath' => $base, 'canCreate' => $base === '/management/opportunities' ? $access->legacyWriteAllowed($request->user()) : (bool) ($access->navigation($request->user())['contexts'] ?? []), 'records' => $records, 'filters' => $filters, 'counts' => $counts, 'categories' => OpportunityManagement::CATEGORIES, 'geographyReviewCount' => $geographyReviewCount, 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug'])]);
|
||||
}
|
||||
|
||||
public function edit(Request $request, OpportunityManagement $service, ?int $id = null): Response
|
||||
{
|
||||
if (! $service->installed()) {
|
||||
return app(PortalManagementController::class)->edit('opportunities', $id);
|
||||
}
|
||||
$record = $id ? Opportunity::withTrashed()->findOrFail($id) : null;
|
||||
$access = app(PortalProfileAccess::class);
|
||||
if (! $record && ! str_starts_with($request->path(), 'minhas-oportunidades')) {
|
||||
abort_unless($access->legacyWriteAllowed($request->user()), 403);
|
||||
}
|
||||
if ($record) {
|
||||
$access->authorize($request->user(), 'view', $record);
|
||||
}
|
||||
Inertia::share(['basePath' => str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities',
|
||||
'canonicalOptions' => ($record?->owner_user_id !== null || str_starts_with($request->path(), 'minhas-oportunidades')) ? app(OpportunityCanonicalContract::class)->options($request->user(), $record) : null,
|
||||
'workflowState' => $record ? $access->state($record) : null,
|
||||
'profileReauthenticationRequired' => $record && $record->owner_user_id !== null
|
||||
? app(OpportunityProfileWorkflow::class)->reauthenticationRequired($request->user(), $record) : false,
|
||||
'publicationState' => $record?->publicationPresentation(),
|
||||
'promotionHref' => $record && app(\App\Services\OpportunityPromotion::class)->allowed($request->user(), $record) ? '/minhas-oportunidades/'.$record->id.'/divulgacao' : null,
|
||||
'canonicalResponse' => $record?->owner_user_id !== null ? app(OpportunityCanonicalResponse::class)->read($request->user(), $record) : null,
|
||||
'canEditDraft' => $record ? $access->canEdit($request->user(), $record) : false,
|
||||
'workflowParticipants' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->participants($request->user(), $record) : ['validators' => [], 'publishers' => []],
|
||||
'workflowTasks' => $record && $record->owner_user_id !== null ? app(OpportunityWorkflowTasks::class)->listing($record) : [],
|
||||
'profileMode' => $record ? $record->owner_user_id !== null : str_starts_with($request->path(), 'minhas-oportunidades'),
|
||||
'profileActions' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->actions($request->user(), $record) : [], 'profileContext' => $record?->workflow_context, 'profileOrganization' => $record?->organization_id]);
|
||||
$values = $record ? $service->values($record) : [];
|
||||
if (! $record && $request->filled('sector_id')) {
|
||||
$selection = $request->validate(['sector_id' => ['required', 'integer', Rule::exists('sectors', 'id')->where('is_active', true)->whereNull('deleted_at')]]);
|
||||
$values['sector_id'] = (int) $selection['sector_id'];
|
||||
$values['canonical']['sector_id'] = (int) $selection['sector_id'];
|
||||
}
|
||||
Inertia::share('publicationPreview', app(OpportunityCanonicalContract::class)->publicProjection($values['canonical'] ?? []));
|
||||
$classification = $id ? DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->where('after_data->opportunity_id', $id)->orderByDesc('occurred_at')->orderByDesc('id')->value('after_data') : null;
|
||||
|
||||
return Inertia::render('management/opportunity-draft', ['resourcesInstalled' => app(OpportunityResources::class)->installed(), 'record' => $record, 'values' => $values, 'etag' => $record ? $service->etag($record) : null, 'readOnly' => str_ends_with($request->path(), '/view') || ($record && ! $access->canEdit($request->user(), $record)), 'closedVersion' => $record ? $service->closed($record) : false, 'classification' => $classification ? json_decode($classification, true)['classification'] : 'undetermined', 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'categories' => OpportunityManagement::CATEGORIES,
|
||||
'maturityCatalogue' => ['version' => OpportunityMaturity::VERSION, 'stages' => OpportunityMaturity::STAGES, 'legacyVersion' => OpportunityMaturity::LEGACY, 'legacyLabels' => OpportunityMaturity::LEGACY_LABELS],
|
||||
'territoryPath' => app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true),
|
||||
'history' => $id ? DB::table('audit_events')->where('source_system', 'opportunity-management')->where('after_data->record_id', $id)->orderByDesc('occurred_at')->get(['id', 'action', 'actor_user_id', 'occurred_at']) : []]);
|
||||
}
|
||||
|
||||
public function save(Request $request, OpportunityManagement $service, ?int $id = null): JsonResponse
|
||||
{
|
||||
$record = $service->save($request->user(), $request->all(), $id);
|
||||
if (! $id) {
|
||||
$request->session()->flash('success', 'Rascunho guardado no servidor. Pode continuar o preenchimento; não foi submetido.');
|
||||
}
|
||||
|
||||
return response()->json(['id' => $record->id, 'etag' => $service->etag($record), 'message' => 'Rascunho guardado no servidor. Não foi submetido, aprovado ou publicado.'], $id ? 200 : 201)->header('ETag', '"'.$service->etag($record).'"');
|
||||
}
|
||||
|
||||
public function canonicalResponse(Request $request, int $id, OpportunityCanonicalResponse $response): JsonResponse
|
||||
{
|
||||
return response()->json($response->read($request->user(), Opportunity::withTrashed()->findOrFail($id)))
|
||||
->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function lifecycle(Request $request, int $id, OpportunityManagement $service): JsonResponse
|
||||
{
|
||||
abort_unless($service->installed(), 503);
|
||||
$record = $service->lifecycle($request->user(), $id, $request->all());
|
||||
|
||||
return response()->json(['id' => $record->id, 'message' => $request->input('action') === 'restore' ? 'Restaurado como rascunho; nova revisão necessária antes de publicar.' : 'Exclusão lógica/arquivo registado; histórico e relações preservados.']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\OpportunityOriginReview;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class OpportunityOriginReviewController extends Controller
|
||||
{
|
||||
public function index(OpportunityOriginReview $review): Response
|
||||
{
|
||||
return Inertia::render('management/opportunity-origin', ['records' => Opportunity::whereIn('reference', ['INV-MZ-AGR-001', 'INV-MZ-ENE-002', 'INV-MZ-TUR-003', 'INV-MZ-INF-004', 'INV-MZ-BLUE-005', 'INV-MZ-FIN-006'])->orderBy('id')->pluck('id')->map(fn ($id) => $review->state($id))->all(), 'ready' => $review->ready()]);
|
||||
}
|
||||
|
||||
public function preview(Request $request, OpportunityOriginReview $review): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['id' => ['required', 'integer', 'exists:opportunities,id'], 'classification' => ['required', 'in:real,example,undetermined'], 'reason' => ['required', 'string', 'max:2000']]);
|
||||
|
||||
return response()->json($review->preview($data['id'], $data['classification'], $data['reason']));
|
||||
}
|
||||
|
||||
public function apply(Request $request, OpportunityOriginReview $review): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['id' => ['required', 'integer', 'exists:opportunities,id'], 'classification' => ['required', 'in:real,example,undetermined'], 'reason' => ['required', 'string', 'max:2000'], 'snapshot_hash' => ['required', 'string', 'size:64'], 'confirm' => ['required', 'accepted']]);
|
||||
|
||||
return response()->json($review->apply($data['id'], $data['classification'], $data['reason'], $data['snapshot_hash'], $request->user()->id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\OpportunityPromotion;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
final class OpportunityPromotionController extends Controller
|
||||
{
|
||||
public function show(Request $request, int $id, OpportunityPromotion $service): \Inertia\Response
|
||||
{
|
||||
return Inertia::render('management/opportunity-promotion', $service->editor($request->user(), Opportunity::findOrFail($id)));
|
||||
}
|
||||
|
||||
public function preview(Request $request, int $id, OpportunityPromotion $service): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
return response()->json($service->preview($request->user(), Opportunity::findOrFail($id), $request->all()));
|
||||
}
|
||||
|
||||
public function confirm(Request $request, int $id, OpportunityPromotion $service): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
return response()->json($service->confirm($request->user(), $id, $request->all()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\CountryCatalogue;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\OpportunityImageProcessor;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\OpportunityResourceInstallation;
|
||||
use App\Services\OpportunityResources;
|
||||
use App\Services\OrganizationCountryInstallation;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use App\Services\VerifiedDocumentBinary;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class OpportunityResourceController extends Controller
|
||||
{
|
||||
public function installation(Request $request, OpportunityResourceInstallation $service): JsonResponse|Response
|
||||
{
|
||||
$preview = [...$service->preview(), 'imageProcessingAvailable' => app(OpportunityImageProcessor::class)->available(), 'actionUrl' => '/management/opportunity-resources-install'];
|
||||
$preview['changes'] = [...$preview['changes'], 'Código reconciliado: catálogo ISO comum; residência e constituição distintas; origem declarada guardada no payload existente e estatísticas internas autorizadas. Não converte países históricos nem usa IP.', 'Seletores territoriais reutilizados com pesquisa; filtros contextuais do catálogo e fichas territoriais consultáveis no percurso do mapa. Zero geometrias não permite desenhar estruturas sem dados.', 'Não reinstala a gestão básica nem acrescenta associações territoriais. Revisão transversal, cadastro completo em todos os campos e percursos educativos continuam parciais; consultar a matriz por módulo.'];
|
||||
|
||||
return $request->expectsJson() ? response()->json($preview) : Inertia::render('management/opportunity-install', $preview);
|
||||
}
|
||||
|
||||
public function applyInstallation(Request $request, OpportunityResourceInstallation $service): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['hash' => 'required|string|size:64', 'confirm' => 'required|accepted']);
|
||||
$service->apply($request->user(), $data['hash']);
|
||||
|
||||
return back()->with('success', 'Recursos instalados; nenhum ficheiro publicado ou oportunidade submetida.');
|
||||
}
|
||||
|
||||
public function organizationCountryPreview(Request $request, OrganizationCountryInstallation $service): JsonResponse|Response
|
||||
{
|
||||
$plan = $service->preview($request->user());
|
||||
$scope = $this->countryOperationScope($request, $plan);
|
||||
$operation = $request->session()->get('portal.country_installation_operation');
|
||||
if (! is_array($operation) || ($operation['scope'] ?? null) !== $scope) {
|
||||
$operation = ['scope' => $scope, 'key' => (string) Str::uuid()];
|
||||
$request->session()->put('portal.country_installation_operation', $operation);
|
||||
}
|
||||
$plan = [...$plan, 'operationKey' => $operation['key'], 'actionUrl' => '/management/opportunity-resources-install/organization-country', 'extension' => true];
|
||||
|
||||
return $request->expectsJson() ? response()->json($plan) : Inertia::render('management/opportunity-install', $plan);
|
||||
}
|
||||
|
||||
public function organizationCountryApply(Request $request, OrganizationCountryInstallation $service): JsonResponse|RedirectResponse
|
||||
{
|
||||
// Preview authorizes the actor; the UUID remains stable until this scope changes or logout.
|
||||
$plan = $service->preview($request->user());
|
||||
$operation = $request->session()->get('portal.country_installation_operation');
|
||||
$data = $request->validate(['idempotency_key' => 'required|uuid']);
|
||||
abort_unless(is_array($operation) && ($operation['scope'] ?? null) === $this->countryOperationScope($request, $plan)
|
||||
&& hash_equals($operation['key'], $data['idempotency_key']), 409, 'A operação não corresponde a esta prévia. Atualize a página antes de tentar novamente.');
|
||||
$result = $service->apply($request->user(), $request->all());
|
||||
|
||||
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'País mestre instalado; nenhuma submissão anterior alterada.');
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $plan */
|
||||
private function countryOperationScope(Request $request, array $plan): string
|
||||
{
|
||||
return hash('sha256', json_encode([$request->user()->getAuthIdentifier(), $plan['target'], $plan['migration'], $plan['code_hash']], JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public function index(int $id, OpportunityResources $service): Response
|
||||
{
|
||||
$opportunity = Opportunity::withTrashed()->findOrFail($id);
|
||||
app(PortalProfileAccess::class)->authorize(request()->user(), 'view', $opportunity);
|
||||
Inertia::share(['basePath' => str_starts_with(request()->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities', 'readOnly' => ! app(PortalProfileAccess::class)->canEdit(request()->user(), $opportunity)]);
|
||||
abort_unless($service->installed(), 503, 'Recursos ainda não instalados; consulte o plano autenticado.');
|
||||
$resources = [];
|
||||
Inertia::share('imageProcessingAvailable', app(OpportunityImageProcessor::class)->available());
|
||||
Inertia::share('countries', app(CountryCatalogue::class)->all());
|
||||
foreach (OpportunityResources::KINDS as $kind) {
|
||||
$resources[$kind] = $service->listing($id, $kind);
|
||||
}
|
||||
foreach ($resources['locations'] as $location) {
|
||||
$p = $location->payload;
|
||||
$location->territoryPath = app(InvestmentGeography::class)->path($p['locality_id'] ?? $p['admin_post_id'] ?? $p['district_id'] ?? $p['province_id'] ?? null, true);
|
||||
}
|
||||
foreach ($resources['images'] as $image) {
|
||||
$image->derivativePreviews = [];
|
||||
foreach ($image->payload['derivatives'] ?? [] as $derived) {
|
||||
if (DB::table('documents')->where('id', $derived['document_id'])->where('status', 'active')->where('malware_scan_status', 'clean')->whereColumn('sha256', 'malware_scanned_sha256')->exists()) {
|
||||
$image->derivativePreviews[] = [...$derived, 'url' => URL::temporarySignedRoute(str_starts_with(request()->path(), 'minhas-oportunidades') ? 'promoter.opportunity-image-preview' : 'management.opportunity-image-preview', now()->addMinutes(10), ['id' => $id, 'resourceId' => $image->id, 'documentId' => $derived['document_id']])];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Inertia::render('management/opportunity-resources', ['opportunity' => $opportunity, 'etag' => app(OpportunityManagement::class)->etag($opportunity), 'resources' => $resources, 'librarySources' => !app(\App\Services\PortalProfileAccess::class)->canEdit(request()->user(), $opportunity) ? [] : app(\App\Services\OpportunityLibrarySupport::class)->choices(request()->user()), 'existingLocations' => DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id')->where('opportunity_id', $id)->get(['g.id', 'g.name', 'g.location_type']), 'locationReference' => app(\App\Services\OpportunityTerritorialPresentation::class)->describe($opportunity, $resources['locations']), 'assessment' => $this->assessment($resources)]);
|
||||
}
|
||||
|
||||
public function reuseLibrary(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$row = app(\App\Services\OpportunityLibrarySupport::class)->attach($request->user(), Opportunity::findOrFail($id), $request->all());
|
||||
return response()->json(['id' => $row->id, 'message' => 'Original associado como suporte privado, sem copiar ficheiros ou publicar.']);
|
||||
}
|
||||
|
||||
public function save(Request $request, int $id, string $kind, OpportunityResources $service, ?string $resourceId = null): JsonResponse
|
||||
{
|
||||
$input = $request->all();
|
||||
if (is_string($input['payload'] ?? null)) {
|
||||
$input['payload'] = json_decode($input['payload'], true);
|
||||
}
|
||||
$record = $service->save($request->user(), $id, $kind, $input, $request->file('file'), $resourceId);
|
||||
|
||||
return response()->json(['id' => $record->id, 'message' => 'Recurso guardado no servidor. Não publicado; validação final e scan são independentes.', 'etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id))], $resourceId ? 200 : 201);
|
||||
}
|
||||
|
||||
public function lifecycle(Request $request, int $id, string $kind, string $resourceId, OpportunityResources $service): JsonResponse
|
||||
{
|
||||
$service->lifecycle($request->user(), $id, $kind, $resourceId, $request->all());
|
||||
|
||||
return response()->json(['message' => 'Exclusão lógica/restauração registada; original e versões preservados.']);
|
||||
}
|
||||
|
||||
public function prepareImage(Request $request, int $id, string $resourceId, OpportunityImageProcessor $service): JsonResponse
|
||||
{
|
||||
$service->prepare($request->user(), $id, $resourceId, $request->all());
|
||||
|
||||
return response()->json(['message' => 'Derivadas WebP geradas sem EXIF original; scan independente e revisão de divulgação pendentes.']);
|
||||
}
|
||||
|
||||
public function previewImage(int $id, string $resourceId, string $documentId): \Illuminate\Http\Response|BinaryFileResponse
|
||||
{
|
||||
$o = Opportunity::findOrFail($id);
|
||||
abort_if($o->status === 'archived', 404);
|
||||
$image = DB::table('opportunity_images')->where('opportunity_id', $id)->where('id', $resourceId)->whereNull('deleted_at')->first();
|
||||
abort_unless($image !== null, 404);
|
||||
$metadata = json_decode($image->payload, true);
|
||||
abort_unless(in_array($documentId, array_column($metadata['derivatives'] ?? [], 'document_id'), true), 404);
|
||||
$doc = DB::table('documents')->where('id', $documentId)->whereNull('deleted_at')->first();
|
||||
abort_unless($doc && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && hash_equals(trim($doc->sha256), (string) $doc->malware_scanned_sha256), 423, 'Scan da derivada pendente.');
|
||||
|
||||
return app(VerifiedDocumentBinary::class)->respond($doc, true);
|
||||
}
|
||||
|
||||
public function download(int $id, string $kind, string $resourceId, OpportunityResources $service): \Illuminate\Http\Response|BinaryFileResponse
|
||||
{
|
||||
abort_unless(in_array($kind, ['documents', 'images'], true), 404);
|
||||
$opportunity = Opportunity::findOrFail($id);
|
||||
app(\App\Services\PortalProfileAccess::class)->authorize(request()->user(), 'view', $opportunity);
|
||||
abort_if($opportunity->status === 'archived', 404);
|
||||
$record = DB::table($service->table($kind).' as r')->join('documents as d', 'd.id', '=', 'r.document_id')->where('r.id', $resourceId)->where('r.opportunity_id', $id)->whereNull('r.deleted_at')->whereNull('d.deleted_at')->first(['d.*']);
|
||||
abort_unless($record !== null, 404, 'Recurso indisponível ou excluído logicamente.');
|
||||
abort_unless($record->status === 'active' && $record->malware_scan_status === 'clean' && hash_equals(trim($record->sha256), (string) $record->malware_scanned_sha256), 423, 'Scan pendente, inconclusivo ou inseguro. O ficheiro permanece em quarentena.');
|
||||
|
||||
$support = DB::table($service->table($kind))->where('id', $resourceId)->first();
|
||||
app(\App\Services\OpportunityLibrarySupport::class)->assertAvailable($support, $record);
|
||||
if (request()->filled('source_sha256')) abort_unless(hash_equals(trim($record->sha256), (string)request('source_sha256')), 409, 'O original já não corresponde à versão consultada.');
|
||||
return app(VerifiedDocumentBinary::class)->respond($record);
|
||||
}
|
||||
|
||||
/** @param array<string,list<\stdClass>> $resources
|
||||
* @return list<string>
|
||||
*/
|
||||
private function assessment(array $resources): array
|
||||
{
|
||||
$issues = [];
|
||||
$locations = array_values(array_filter($resources['locations'], fn ($r) => ! $r->deleted_at));
|
||||
if (count($locations) < 1) {
|
||||
$issues[] = 'Localização operacional ainda não preparada; ligações históricas não são convertidas automaticamente.';
|
||||
}
|
||||
if (count(array_filter($locations, fn ($r) => $r->payload['is_primary'] ?? false)) !== 1) {
|
||||
$issues[] = 'Escolha exatamente uma localização principal.';
|
||||
}
|
||||
$allocated = array_filter($locations, fn ($r) => isset($r->payload['allocation_percentage']));
|
||||
if ($allocated && (count($allocated) !== count($locations) || abs(array_sum(array_map(fn ($r) => (float) $r->payload['allocation_percentage'], $allocated)) - 100) > 0.01)) {
|
||||
$issues[] = 'A distribuição deve abranger todas as localizações e somar 100% (tolerância 0,01).';
|
||||
}
|
||||
$provinces = [];
|
||||
$districts = [];
|
||||
foreach ($locations as $location) {
|
||||
$p = $location->payload;
|
||||
$mozambican = empty($p['country_code']) || $p['country_code'] === 'MZ';
|
||||
if ($mozambican && (empty($p['province_id']) || empty($p['district_id']))) {
|
||||
$issues[] = 'Uma localização em Moçambique tem província ou distrito pendente.';
|
||||
}
|
||||
if (! $mozambican && array_filter(array_intersect_key($p, array_flip(['province_id', 'district_id', 'admin_post_id', 'locality_id'])))) {
|
||||
$issues[] = 'Uma localização noutro país conserva uma seleção territorial de Moçambique incompatível; corrigir explicitamente sem converter o histórico.';
|
||||
}
|
||||
if (empty($p['role'])) {
|
||||
$issues[] = 'Uma localização tem papel pendente.';
|
||||
}
|
||||
if ($mozambican && ! empty($p['district_id'])) {
|
||||
$districts[$p['district_id']] = true;
|
||||
}
|
||||
if ($mozambican && ! empty($p['province_id'])) {
|
||||
$provinces[$p['province_id']] = true;
|
||||
}
|
||||
if (($p['role'] ?? null) === 'other' && empty($p['role_description'])) {
|
||||
$issues[] = 'Papel outro exige descrição.';
|
||||
}
|
||||
if (empty($p['source_reference'])) {
|
||||
$issues[] = 'Localização com rastreabilidade por recuperar; aceitação oficial anterior preservada.';
|
||||
}
|
||||
if (! empty($p['geometry'])) {
|
||||
$issues[] = 'Compatibilidade espacial depende de limite oficial correspondente; geometria não confirma disponibilidade.';
|
||||
}
|
||||
if (isset($p['area_value']) !== isset($p['area_unit'])) {
|
||||
$issues[] = 'Área exige valor e unidade juntos.';
|
||||
}
|
||||
}
|
||||
if (count($districts) > 1 && ! array_filter($locations, fn ($r) => mb_strlen($r->payload['multi_district_rationale'] ?? '') >= 50)) {
|
||||
$issues[] = 'Multidistrito/interprovincial exige fundamento operacional de 50–1000 caracteres.';
|
||||
}
|
||||
if ($locations) {
|
||||
$issues[] = 'Modo preparado: '.(count($locations) > 1 ? 'multiple' : 'single').'; âmbito administrativo moçambicano derivado: '.(count($provinces) > 1 ? 'interprovincial' : (count($districts) > 1 ? 'multi_district' : ($districts ? 'district' : 'sem seleção moçambicana'))).'. Sem conversão de contexto em implantação.';
|
||||
}
|
||||
foreach (['images', 'documents'] as $kind) {
|
||||
foreach ($resources[$kind] as $r) {
|
||||
if ($r->deleted_at) {
|
||||
continue;
|
||||
}
|
||||
if ($r->file->malware_scan_status !== 'clean') {
|
||||
$issues[] = 'Anexo '.$r->file->file_name.': scan '.$r->file->malware_scan_status.'; não é evidência de segurança.';
|
||||
}
|
||||
$required = $kind === 'images' ? ['title', 'caption', 'alt_text', 'author_source', 'rights_status'] : ['category', 'issuer', 'edition', 'classification'];
|
||||
foreach ($required as $field) {
|
||||
if (empty($r->payload[$field])) {
|
||||
$issues[] = 'Anexo '.$r->file->file_name.': '.$field.' pendente.';
|
||||
}
|
||||
}
|
||||
if ($kind === 'images') {
|
||||
if (empty($r->payload['derivatives'])) {
|
||||
$issues[] = 'Imagem '.$r->file->file_name.': derivadas sem EXIF ainda por preparar (GD/WebP e scan necessários).';
|
||||
}
|
||||
$issues[] = 'Imagem '.$r->file->file_name.': revisão de dados pessoais/direitos e aprovação de divulgação ainda pendentes.';
|
||||
foreach (['title' => 5, 'caption' => 10, 'alt_text' => 10, 'author_source' => 2] as $field => $minimum) {
|
||||
if (mb_strlen($r->payload[$field] ?? '') < $minimum) {
|
||||
$issues[] = 'Imagem '.$r->file->file_name.': '.$field.' abaixo do mínimo para validação final.';
|
||||
}
|
||||
}
|
||||
if (($r->payload['role'] ?? null) === 'location' && empty($r->payload['location_id'])) {
|
||||
$issues[] = 'Imagem de localização exige ligação a um local ativo da mesma oportunidade.';
|
||||
}
|
||||
if (in_array($r->payload['role'] ?? null, ['asset', 'product', 'evidence'], true)) {
|
||||
$issues[] = 'Imagem de ativo/produto/evidência: módulo e associação específica ainda pendentes.';
|
||||
}
|
||||
if (in_array($r->payload['rights_status'] ?? null, ['licensed', 'authorized'], true) && empty($r->payload['rights_document_id'])) {
|
||||
$issues[] = 'Imagem licenciada/autorizada exige comprovativo.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$issues[] = 'Esta conferência não submete nem publica; workflow final, revisão humana e derivados seguros permanecem separados.';
|
||||
|
||||
return array_values(array_unique($issues));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\ExistingBusiness;
|
||||
use App\Models\User;
|
||||
use App\Services\InvestmentCatalogue;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\InvestmentRelations;
|
||||
use App\Services\PartnershipExchange;
|
||||
use App\Services\PartnershipMatching;
|
||||
use App\Services\PartnershipSources;
|
||||
use App\Services\VerifiedDocumentBinary;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class PartnershipExchangeController extends Controller
|
||||
{
|
||||
public function source(Request $request, string $type, string $id): JsonResponse
|
||||
{
|
||||
if (! Schema::hasTable('partnership_origins')) {
|
||||
return response()->json(['available' => false, 'items' => []]);
|
||||
}
|
||||
app(PartnershipSources::class)->resolve($type, $id, $request->user());
|
||||
|
||||
return response()->json(['available' => true, 'items' => DB::table('portal_partnerships as p')->join('partnership_origins as origin', 'origin.partnership_id', '=', 'p.id')
|
||||
->where('origin.type', $type)->where('origin.target_id', $id)->where('p.status', 'published')->orderByDesc('p.id')->distinct()->get(['p.id', 'p.title'])])->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function index(Request $request): Response|JsonResponse
|
||||
{
|
||||
$filters = $request->validate(['q' => 'nullable|string|max:120', 'sector_id' => 'nullable|integer', 'partnership_type' => 'nullable|string|max:40',
|
||||
'kind' => 'nullable|in:offering,seeking', 'territory_id' => 'nullable|uuid', 'origin_type' => 'nullable|string|max:40', 'origin_id' => 'nullable|string|max:100']);
|
||||
$query = DB::table('portal_partnerships as p')->where('p.status', 'published');
|
||||
foreach (['sector_id', 'partnership_type', 'kind'] as $field) {
|
||||
if (! empty($filters[$field])) {
|
||||
if ($field === 'sector_id') {
|
||||
$sector = \App\Models\Sector::find($filters[$field]);
|
||||
$query->whereIn('p.sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
|
||||
} else { $query->where('p.'.$field, $filters[$field]); }
|
||||
}
|
||||
}
|
||||
if (! empty($filters['q'])) {
|
||||
$term = '%'.$filters['q'].'%';
|
||||
$query->where(fn ($q) => $q->where('p.title', 'ilike', $term)->orWhere('p.sector', 'ilike', $term)
|
||||
->orWhereRaw("p.payload->>'summary' ILIKE ?", [$term])->orWhere(fn ($details) => $details->whereRaw("COALESCE(p.payload->>'confidentiality','public')='public'")->where(fn ($body) => $body->where('p.offer', 'ilike', $term)->orWhere('p.seeking', 'ilike', $term))));
|
||||
}
|
||||
if (! empty($filters['origin_type']) && ! empty($filters['origin_id'])) {
|
||||
// Authenticate access to a private master before exposing a backlink query.
|
||||
app(PartnershipSources::class)->resolve($filters['origin_type'], $filters['origin_id'], $request->user());
|
||||
$query->whereExists(fn ($q) => $q->selectRaw('1')->from('partnership_origins')->whereColumn('partnership_id', 'p.id')->where('type', $filters['origin_type'])->where('target_id', $filters['origin_id']));
|
||||
}
|
||||
if (! empty($filters['territory_id'])) {
|
||||
abort_unless(app(InvestmentGeography::class)->publicReferences()->where('id', $filters['territory_id'])->exists(), 422);
|
||||
$ids = app(InvestmentGeography::class)->descendants($filters['territory_id']);
|
||||
$investmentIds = app(InvestmentCatalogue::class)->query(['geo_location_id' => $filters['territory_id']])->pluck('id')->map(fn ($id) => (string) $id)->all();
|
||||
$query->where(fn ($q) => $q->whereExists(fn ($places) => $places->selectRaw('1')->from('partnership_locations')->whereColumn('partnership_id', 'p.id')->whereIn('geo_location_id', $ids))
|
||||
->orWhereExists(fn ($origins) => $origins->selectRaw('1')->from('partnership_origins as origin')->join('project_locations as pl', DB::raw('pl.project_id::text'), '=', 'origin.target_id')
|
||||
->whereColumn('origin.partnership_id', 'p.id')->where('origin.type', 'project')->whereNull('pl.deleted_at')->where('pl.data_classification', 'public')->whereIn('pl.geo_location_id', $ids))
|
||||
->orWhereExists(fn ($origins) => $origins->selectRaw('1')->from('partnership_origins')->whereColumn('partnership_id', 'p.id')->where('type', 'investment')->whereIn('target_id', $investmentIds))
|
||||
->orWhereExists(fn ($members) => $members->selectRaw('1')->from('partnership_origins as origin')
|
||||
->joinSub(app(InvestmentRelations::class)->memberships()->select(['cp.cluster_id', 'cp.project_id']), 'member', DB::raw('member.cluster_id::text'), '=', 'origin.target_id')
|
||||
->join('project_locations as pl', 'pl.project_id', '=', 'member.project_id')->whereColumn('origin.partnership_id', 'p.id')->where('origin.type', 'cluster')
|
||||
->whereNull('pl.deleted_at')->where('pl.data_classification', 'public')->whereIn('pl.geo_location_id', $ids))
|
||||
->orWhereExists(fn ($zones) => $zones->selectRaw('1')->from('partnership_origins as origin')->join('investment_zone_locations as zl', DB::raw('zl.zone_id::text'), '=', 'origin.target_id')
|
||||
->join('investment_zones as zone', 'zone.id', '=', 'zl.zone_id')->whereColumn('origin.partnership_id', 'p.id')->where('origin.type', 'zone')->where('zone.publication_status', 'published')
|
||||
->whereNull('zone.deleted_at')->where('zl.validation_status', 'validated')->whereIn('zl.geo_location_id', $ids)));
|
||||
}
|
||||
$results = $query->orderByDesc('p.id')->select(['p.id', 'p.title', 'p.sector', 'p.province', 'p.kind', 'p.partnership_type', 'p.sector_id', 'p.deadline', 'p.published_at'])
|
||||
->selectRaw("p.payload->>'summary' as summary")->paginate(12)->withQueryString();
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json($results);
|
||||
}
|
||||
|
||||
$mapPoints = [];
|
||||
foreach ($results as $partnership) {
|
||||
$locations = app(InvestmentRelations::class)->locations(array_values(DB::table('partnership_locations')->where('partnership_id', $partnership->id)->get()
|
||||
->map(fn ($location) => ['id' => $location->geo_location_id, 'role' => $location->role, 'source_reference' => $location->source_reference])->all()));
|
||||
foreach ($locations as $location) {
|
||||
if ($location['location_type'] !== 'site') {
|
||||
continue;
|
||||
}
|
||||
foreach ($location['coordinates'] as $coordinate) {
|
||||
$mapPoints[] = ['geometry' => $coordinate['geometry'], 'properties' => ['id' => count($mapPoints), 'title' => $partnership->title,
|
||||
'location' => $location['name'].' · '.$location['role'].' · '.$location['source_reference'], 'url' => '/parcerias/'.$partnership->id]];
|
||||
}
|
||||
}
|
||||
foreach (app(PartnershipSources::class)->forPartnership($partnership->id) as $source) {
|
||||
$context = $source['geography']['locations'] ?? [];
|
||||
foreach ($source['geography']['members'] ?? [] as $member) {
|
||||
$context = [...$context, ...$member['locations']];
|
||||
}
|
||||
foreach ($context as $location) {
|
||||
foreach ($location['coordinates'] as $coordinate) {
|
||||
$mapPoints[] = ['geometry' => $coordinate['geometry'], 'properties' => ['id' => count($mapPoints), 'title' => $partnership->title,
|
||||
'location' => 'Contexto da origem: '.$location['name'].'; implantação da parceria não confirmada', 'url' => '/parcerias/'.$partnership->id]];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Inertia::render('partnerships/catalogue', ['results' => $results, 'filters' => $filters, 'mapPoints' => $mapPoints, ...$this->catalogues($request)]);
|
||||
}
|
||||
|
||||
public function dashboard(Request $request): Response
|
||||
{
|
||||
$actor = $request->user();
|
||||
$organizations = DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->pluck('organization_id');
|
||||
$mine = DB::table('portal_partnerships')->where(fn ($q) => $q->where(fn ($own) => $own->where('user_id', $actor->id)->whereNull('organization_id'))->orWhereIn('organization_id', $organizations))->latest()->limit(100)->get();
|
||||
$interests = DB::table('partnership_interests as i')->join('portal_partnerships as p', 'p.id', '=', 'i.partnership_id')
|
||||
->leftJoin('partnership_rooms as room', 'room.interest_id', '=', 'i.id')
|
||||
->where(fn ($q) => $q->where(fn ($own) => $own->where('i.user_id', $actor->id)->whereNull('i.organization_id'))->orWhereIn('i.organization_id', $organizations))
|
||||
->select(['i.id', 'i.partnership_id', 'i.status', 'i.decision_reason', 'p.title', 'room.id as room_id'])->orderByDesc('i.id')->limit(100)->get();
|
||||
$moderation = Gate::forUser($actor)->allows('manage-portal') ? DB::table('portal_partnerships')->whereIn('status', ['submitted', 'pending'])->orderBy('updated_at')->get(['id', 'title', 'status', 'version']) : [];
|
||||
|
||||
return Inertia::render('partnerships/dashboard', ['mine' => $mine->map(fn ($p) => ['id' => $p->id, 'title' => $p->title, 'status' => $p->status, 'version' => $p->version]), 'interests' => $interests, 'moderation' => $moderation,
|
||||
'notifications' => DB::table('partnership_notifications')->where('user_id', $actor->id)->orderByDesc('id')->limit(50)->get()]);
|
||||
}
|
||||
|
||||
public function edit(Request $request, ?int $id = null): Response
|
||||
{
|
||||
$record = $id ? app(PartnershipExchange::class)->find($id) : null;
|
||||
abort_if($record && ! app(PartnershipExchange::class)->owns($request->user(), $record), 403);
|
||||
$origins = $record ? DB::table('partnership_origins')->where('partnership_id', $id)->get() : [];
|
||||
if (! $record && $request->filled('origin_type') && $request->filled('origin_id')) {
|
||||
$source = app(PartnershipSources::class)->resolve($request->string('origin_type')->toString(), $request->string('origin_id')->toString(), $request->user());
|
||||
$origins = [['type' => $source['type'], 'target_id' => $source['target_id']]];
|
||||
}
|
||||
|
||||
return Inertia::render('partnerships/editor', ['record' => $record ? [...(array) $record, 'payload' => json_decode($record->payload, true)] : null, 'origins' => $origins,
|
||||
'locations' => $record ? DB::table('partnership_locations')->where('partnership_id', $id)->get() : [], ...$this->catalogues($request)]);
|
||||
}
|
||||
|
||||
public function save(Request $request, ?int $id = null): RedirectResponse|JsonResponse
|
||||
{
|
||||
$record = app(PartnershipExchange::class)->save($request->user(), $request->all(), $id);
|
||||
|
||||
return $request->expectsJson() ? response()->json(['id' => $record->id, 'version' => $record->version, 'status' => $record->status]) : redirect('/parcerias/'.$record->id.'/editar')->with('success', 'Rascunho guardado.');
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id): Response|JsonResponse
|
||||
{
|
||||
$record = app(PartnershipExchange::class)->find($id);
|
||||
$owner = $request->user() && app(PartnershipExchange::class)->owns($request->user(), $record, false);
|
||||
$moderator = $request->user() && Gate::forUser($request->user())->allows('manage-portal');
|
||||
abort_unless($record->status === 'published' || $owner || $moderator, 404);
|
||||
$payload = json_decode($record->payload, true);
|
||||
unset($payload['_creation_hash']);
|
||||
$privateDetails = ($payload['confidentiality'] ?? 'public') === 'private' && ! $owner && ! $moderator;
|
||||
$mine = $request->user() ? DB::table('partnership_interests')->where('partnership_id', $id)->get()->filter(fn ($i) => app(PartnershipExchange::class)->owns($request->user(), $i, false))->values() : collect();
|
||||
$interests = $owner ? DB::table('partnership_interests')->where('partnership_id', $id)->orderBy('id')->get() : $mine;
|
||||
$interestData = $interests->map(function ($interest) use ($record) {
|
||||
return [...(array) $interest, 'payload' => json_decode($interest->payload, true), 'assessment' => json_decode($interest->assessment, true),
|
||||
'match' => app(PartnershipMatching::class)->explain($record, $interest), 'room_id' => DB::table('partnership_rooms')->where('interest_id', $interest->id)->value('id')];
|
||||
});
|
||||
$props = ['record' => ['id' => $record->id, 'title' => $record->title, 'status' => $record->status, 'version' => $record->version, 'kind' => $record->kind,
|
||||
'partnership_type' => $record->partnership_type, 'sector' => $record->sector, 'province' => $record->province, 'sector_id' => $record->sector_id, 'deadline' => $record->deadline,
|
||||
'offer' => $privateDetails ? null : $record->offer, 'seeking' => $privateDetails ? null : $record->seeking, 'payload' => $privateDetails ? ['summary' => $payload['summary'] ?? '', 'confidentiality' => 'private'] : $payload],
|
||||
'owner' => (bool) $owner, 'canEdit' => $request->user() && app(PartnershipExchange::class)->owns($request->user(), $record), 'moderator' => (bool) $moderator,
|
||||
'origins' => app(PartnershipSources::class)->forPartnership($id, $owner ? $request->user() : null), 'interests' => $interestData,
|
||||
'locations' => app(InvestmentRelations::class)->locations(array_values(DB::table('partnership_locations')->where('partnership_id', $id)->get()->map(fn ($l) => ['id' => $l->geo_location_id, 'role' => $l->role, 'source_reference' => $l->source_reference])->all())),
|
||||
'history' => ($owner || $moderator) ? DB::table('partnership_events')->where('partnership_id', $id)->where('event_type', 'like', 'partnership_opportunity.%')->orderBy('occurred_at')->get(['event_type', 'actor_id', 'occurred_at', 'payload']) : [],
|
||||
...$this->catalogues($request)];
|
||||
if ($request->user()) {
|
||||
Inertia::encryptHistory();
|
||||
}
|
||||
|
||||
return $request->expectsJson() ? response()->json($props)->header('Cache-Control', 'private, no-store') : Inertia::render('partnerships/detail', $props);
|
||||
}
|
||||
|
||||
public function transition(Request $request, int $id): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate(['version' => 'required|integer|min:1', 'action' => 'required|string', 'reason' => 'required|string|max:2000']);
|
||||
$record = app(PartnershipExchange::class)->transition($request->user(), $id, $data['version'], $data['action'], $data['reason']);
|
||||
|
||||
return $request->expectsJson() ? response()->json(['status' => $record->status, 'version' => $record->version]) : back()->with('success', 'Decisão registada.');
|
||||
}
|
||||
|
||||
public function interest(Request $request, int $id): RedirectResponse|JsonResponse
|
||||
{
|
||||
$interest = app(PartnershipExchange::class)->interest($request->user(), $id, $request->all());
|
||||
|
||||
return $request->expectsJson() ? response()->json(['id' => $interest->id, 'version' => $interest->version]) : back()->with('success', 'Interesse guardado.');
|
||||
}
|
||||
|
||||
public function decide(Request $request, int $id): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate(['version' => 'required|integer|min:1', 'action' => 'required|string', 'reason' => 'required|string|max:2000', 'assessment' => 'array|max:7']);
|
||||
$interest = app(PartnershipExchange::class)->decideInterest($request->user(), $id, $data['version'], $data['action'], $data['reason'], $data['assessment'] ?? []);
|
||||
|
||||
return $request->expectsJson() ? response()->json(['status' => $interest->status, 'version' => $interest->version]) : back()->with('success', 'Candidatura atualizada.');
|
||||
}
|
||||
|
||||
public function room(Request $request, int $id): Response|JsonResponse
|
||||
{
|
||||
$context = app(PartnershipExchange::class)->room($request->user(), $id);
|
||||
$props = [...$context, 'room' => [...(array) $context['room'], 'agreement' => $context['room']->agreement ? json_decode($context['room']->agreement, true) : null],
|
||||
'messages' => DB::table('partnership_messages as m')->join('users as u', 'u.id', '=', 'm.author_id')->where('room_id', $id)->orderBy('m.id')->get(['m.id', 'm.body', 'm.created_at', 'm.author_id', 'u.name']),
|
||||
'documents' => DB::table('partnership_documents as link')->join('documents as d', 'd.id', '=', 'link.document_id')->where('room_id', $id)
|
||||
->where(fn ($q) => $q->where('link.shared', true)->orWhere('link.owner_id', $request->user()->id))
|
||||
->get(['link.id', 'link.shared', 'link.owner_id', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.id as document_id']),
|
||||
'milestones' => DB::table('partnership_milestones')->where('room_id', $id)->orderBy('id')->get(),
|
||||
'canWrite' => app(PartnershipExchange::class)->owns($request->user(), $context['partnership']) || app(PartnershipExchange::class)->owns($request->user(), $context['interest'])];
|
||||
|
||||
return $request->expectsJson() ? response()->json($props) : Inertia::render('partnerships/room', $props);
|
||||
}
|
||||
|
||||
public function message(Request $request, int $id): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate(['client_key' => 'required|uuid', 'body' => 'required|string|max:15000']);
|
||||
DB::transaction(function () use ($request, $id, $data) {
|
||||
$context = app(PartnershipExchange::class)->room($request->user(), $id, true);
|
||||
$locked = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($locked !== null && ! in_array($locked->status, ['closed', 'cancelled'], true), 409, 'Sala encerrada.');
|
||||
$key = ['room_id' => $id, 'author_id' => $request->user()->id, 'client_key' => $data['client_key']];
|
||||
$existing = DB::table('partnership_messages')->where($key)->first();
|
||||
if ($existing) {
|
||||
abort_unless($existing->body === $data['body'], 409, 'Chave já utilizada com outro conteúdo.');
|
||||
|
||||
return;
|
||||
}
|
||||
DB::table('partnership_messages')->insert([...$key, 'body' => $data['body']]);
|
||||
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.message_created.v1', ['room_id' => $id], 'Nova mensagem privada', [$context['partnership']->user_id, $context['interest']->user_id]);
|
||||
});
|
||||
|
||||
return $request->expectsJson() ? response()->json(['sent' => true]) : back()->with('success', 'Mensagem registada.');
|
||||
}
|
||||
|
||||
public function agreement(Request $request, int $id): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate(['version' => 'required|integer|min:1', 'action' => 'required|in:record,confirm,start,complete,cancel,renew', 'title' => 'required_if:action,record|nullable|string|max:200',
|
||||
'terms' => 'required_if:action,record|nullable|string|max:20000', 'reason' => 'required|string|max:2000']);
|
||||
DB::transaction(function () use ($request, $id, $data) {
|
||||
$context = app(PartnershipExchange::class)->room($request->user(), $id, true);
|
||||
$room = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless((int) $room->version === (int) $data['version'], 409, 'Acordo alterado.');
|
||||
$agreement = $room->agreement ? json_decode($room->agreement, true) : null;
|
||||
$status = $room->status;
|
||||
if ($data['action'] === 'record') {
|
||||
abort_unless(in_array($status, ['negotiation', 'agreement_recorded'], true), 409);
|
||||
$previous = $agreement['previous_versions'] ?? [];
|
||||
if ($agreement) {
|
||||
$previous[] = ['title' => $agreement['title'], 'terms' => $agreement['terms'], 'confirmations' => $agreement['confirmations'], 'recorded_at' => $agreement['recorded_at']];
|
||||
}
|
||||
$agreement = ['title' => $data['title'], 'terms' => $data['terms'], 'recorded_by' => $request->user()->id, 'recorded_at' => now()->toIso8601String(), 'confirmations' => [],
|
||||
'parties' => [$context['partnership']->user_id, $context['interest']->user_id], 'legal_signature_status' => 'not_verified', 'previous_versions' => $previous];
|
||||
$status = 'agreement_recorded';
|
||||
} elseif ($data['action'] === 'confirm') {
|
||||
abort_unless($status === 'agreement_recorded' && $agreement, 409);
|
||||
$party = app(PartnershipExchange::class)->owns($request->user(), $context['partnership']) ? 'proponent' : 'candidate';
|
||||
$agreement['confirmations'][$party] = ['actor_id' => $request->user()->id, 'date' => now()->toIso8601String(), 'reason' => $data['reason']];
|
||||
if (count($agreement['confirmations']) === 2) {
|
||||
$status = 'agreed';
|
||||
}
|
||||
} else {
|
||||
$allowed = ['start' => ['agreed'], 'complete' => ['execution'], 'cancel' => ['negotiation', 'agreement_recorded', 'agreed', 'execution'], 'renew' => ['agreed', 'execution']];
|
||||
abort_unless(in_array($status, $allowed[$data['action']] ?? [], true), 409);
|
||||
if ($data['action'] === 'complete') {
|
||||
abort_if(DB::table('partnership_milestones')->where('room_id', $id)->where('status', '!=', 'completed')->exists(), 409, 'Existem marcos pendentes.');
|
||||
}
|
||||
$status = ['start' => 'execution', 'complete' => 'closed', 'cancel' => 'cancelled', 'renew' => 'negotiation'][$data['action']];
|
||||
if ($data['action'] === 'renew') {
|
||||
$agreement['previous_versions'][] = ['title' => $agreement['title'], 'terms' => $agreement['terms'], 'confirmations' => $agreement['confirmations']];
|
||||
$agreement['confirmations'] = [];
|
||||
}
|
||||
}
|
||||
DB::table('partnership_rooms')->where('id', $id)->update(['agreement' => $agreement ? json_encode($agreement) : null, 'status' => $status, 'version' => $room->version + 1, 'updated_at' => now()]);
|
||||
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership_agreement.changed.v1', ['room_id' => $id, 'status' => $status, 'version' => $room->version + 1, 'reason' => $data['reason']], 'Acordo: '.$status, [$context['partnership']->user_id, $context['interest']->user_id]);
|
||||
});
|
||||
|
||||
return $request->expectsJson() ? response()->json(['recorded' => true]) : back()->with('success', 'Acordo atualizado; assinatura jurídica não verificada.');
|
||||
}
|
||||
|
||||
public function milestone(Request $request, int $id, ?int $milestone = null): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate(['title' => 'required|string|max:200', 'responsible_id' => 'required|integer', 'due_date' => 'nullable|date_format:Y-m-d', 'status' => 'required|in:pending,progress,completed',
|
||||
'evidence' => 'required_if:status,completed|nullable|string|max:10000', 'version' => ($milestone ? 'required' : 'nullable').'|integer|min:1']);
|
||||
DB::transaction(function () use ($request, $id, $milestone, $data) {
|
||||
$context = app(PartnershipExchange::class)->room($request->user(), $id, true);
|
||||
$locked = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($locked !== null && in_array($locked->status, ['agreement_recorded', 'agreed', 'execution'], true), 409);
|
||||
$responsible = User::query()->whereKey($data['responsible_id'])->first();
|
||||
abort_unless($responsible !== null && (app(PartnershipExchange::class)->owns($responsible, $context['partnership']) || app(PartnershipExchange::class)->owns($responsible, $context['interest'])), 422, 'Responsável deve ser participante identificado.');
|
||||
if ($milestone) {
|
||||
$existing = DB::table('partnership_milestones')->where('room_id', $id)->where('id', $milestone)->lockForUpdate()->first();
|
||||
abort_unless($existing !== null, 404);
|
||||
abort_unless((int) $existing->version === (int) $data['version'], 409);
|
||||
DB::table('partnership_milestones')->where('id', $milestone)->update([...$data, 'version' => $existing->version + 1, 'updated_at' => now()]);
|
||||
} else {
|
||||
unset($data['version']);
|
||||
$milestone = DB::table('partnership_milestones')->insertGetId([...$data, 'room_id' => $id, 'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership_milestone.changed.v1', ['milestone_id' => $milestone, 'status' => $data['status']], 'Marco atualizado', [$context['partnership']->user_id, $context['interest']->user_id]);
|
||||
});
|
||||
|
||||
return $request->expectsJson() ? response()->json(['saved' => true]) : back()->with('success', 'Marco guardado.');
|
||||
}
|
||||
|
||||
public function upload(Request $request, int $id): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate(['file' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240', 'shared' => 'required|boolean']);
|
||||
$context = app(PartnershipExchange::class)->room($request->user(), $id, true);
|
||||
$file = $request->file('file');
|
||||
$documentId = (string) Str::uuid();
|
||||
$stored = $file->storeAs('partnerships/'.$id, $documentId, 'local');
|
||||
abort_unless(is_string($stored) && $stored !== '', 503);
|
||||
try {
|
||||
DB::transaction(function () use ($request, $id, $data, $file, $documentId, $stored, $context) {
|
||||
app(PartnershipExchange::class)->room($request->user(), $id, true);
|
||||
$locked = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($locked !== null && ! in_array($locked->status, ['closed', 'cancelled'], true), 409);
|
||||
DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'partnership-exchange', 'data_classification' => 'restricted',
|
||||
'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $stored,
|
||||
'sha256' => hash_file('sha256', Storage::disk('local')->path($stored)), 'status' => 'quarantined', 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id]);
|
||||
DB::table('partnership_documents')->insert(['room_id' => $id, 'document_id' => $documentId, 'owner_id' => $request->user()->id, 'shared' => $data['shared']]);
|
||||
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.document_added.v1', ['room_id' => $id, 'document_id' => $documentId, 'shared' => $data['shared']], 'Documento privado anexado');
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
Storage::disk('local')->delete($stored);
|
||||
throw $e;
|
||||
}
|
||||
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
|
||||
|
||||
return $request->expectsJson() ? response()->json(['uploaded' => true]) : back()->with('success', 'Documento privado guardado; análise antimalware pendente.');
|
||||
}
|
||||
|
||||
public function download(Request $request, int $id, int $attachment): \Illuminate\Http\Response|BinaryFileResponse
|
||||
{
|
||||
$context = app(PartnershipExchange::class)->room($request->user(), $id);
|
||||
$document = DB::table('partnership_documents as link')->join('documents as d', 'd.id', '=', 'link.document_id')->where('link.room_id', $id)->where('link.id', $attachment)
|
||||
->where(fn ($q) => $q->where('link.shared', true)->orWhere('link.owner_id', $request->user()->id))->where('d.status', 'active')->whereNull('d.deleted_at')
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')->first(['d.*']);
|
||||
abort_unless($document !== null, 404);
|
||||
$response = app(VerifiedDocumentBinary::class)->respond($document);
|
||||
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.document_accessed.v1', ['room_id' => $id, 'attachment_id' => $attachment], 'Documento consultado');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function share(Request $request, int $id, int $attachment): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate(['shared' => 'required|boolean']);
|
||||
DB::transaction(function () use ($request, $id, $attachment, $data) {
|
||||
$context = app(PartnershipExchange::class)->room($request->user(), $id, (bool) $data['shared']);
|
||||
$link = DB::table('partnership_documents')->where('room_id', $id)->where('id', $attachment)->lockForUpdate()->first();
|
||||
abort_unless($link !== null && (int) $link->owner_id === $request->user()->id, 403, 'Só o autor pode autorizar ou retirar a partilha.');
|
||||
DB::table('partnership_documents')->where('id', $attachment)->update(['shared' => $data['shared']]);
|
||||
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.document_sharing_changed.v1', ['room_id' => $id, 'attachment_id' => $attachment, 'shared' => $data['shared']], 'Autorização documental atualizada');
|
||||
});
|
||||
|
||||
return $request->expectsJson() ? response()->json(['saved' => true]) : back()->with('success', 'Autorização de partilha atualizada.');
|
||||
}
|
||||
|
||||
public function notification(Request $request, int $id): RedirectResponse
|
||||
{
|
||||
abort_unless(DB::table('partnership_notifications')->where('id', $id)->where('user_id', $request->user()->id)->update(['read_at' => now()]) > 0, 404);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function catalogues(Request $request): array
|
||||
{
|
||||
$sources = [];
|
||||
foreach (InvestmentPortfolioController::visible()->where(fn ($q) => $q->whereNotNull('project_id')->orWhereNotNull('cluster_id'))->get(['id', 'title', 'project_id', 'cluster_id']) as $dossier) {
|
||||
$sources[] = ['type' => $dossier->project_id ? 'project' : 'cluster', 'target_id' => $dossier->project_id ?? $dossier->cluster_id, 'title' => $dossier->title, 'href' => '/carteira/'.$dossier->id];
|
||||
}
|
||||
foreach (DB::table('opportunities')->where('status', 'published')->whereNotNull('published_at')->get(['id', 'title', 'slug']) as $investment) {
|
||||
$sources[] = ['type' => 'investment', 'target_id' => (string) $investment->id, 'title' => $investment->title, 'href' => '/oportunidades/'.$investment->slug];
|
||||
}
|
||||
foreach (DB::table('investment_zones')->where('publication_status', 'published')->whereNull('deleted_at')->get(['id', 'name']) as $zone) {
|
||||
$sources[] = ['type' => 'zone', 'target_id' => $zone->id, 'title' => $zone->name, 'href' => '/zonas/'.$zone->id];
|
||||
}
|
||||
foreach (ExistingBusiness::query()->published()->get(['id', 'public_title', 'public_slug']) as $business) {
|
||||
$sources[] = ['type' => 'business', 'target_id' => $business->id, 'title' => $business->public_title, 'href' => '/negocios/'.$business->public_slug];
|
||||
}
|
||||
if ($request->user()) {
|
||||
foreach (DB::table('investment_company_declarations')->where('user_id', $request->user()->id)->get(['id']) as $company) {
|
||||
$sources[] = ['type' => 'company', 'target_id' => $company->id, 'title' => 'Declaração empresarial existente', 'href' => '/investidor/profile'];
|
||||
}
|
||||
foreach (DB::table('simulation_form_drafts')->where('owner_user_id', $request->user()->id)->whereNull('deleted_at')->get(['id', 'created_at']) as $draft) {
|
||||
$sources[] = ['type' => 'simulation', 'target_id' => $draft->id, 'title' => 'Ficha de projeto · '.$draft->created_at, 'href' => '/simulador-fiscal?draft='.$draft->id];
|
||||
}
|
||||
}
|
||||
|
||||
return ['types' => config('partnerships.types'), 'criteria' => config('partnerships.criteria'), 'sources' => $sources, 'sectors' => \App\Models\Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']),
|
||||
'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type', 'parent_id']),
|
||||
'organizations' => $request->user() ? DB::table('memberships as m')->join('organizations as o', 'o.id', '=', 'm.organization_id')->where('m.user_id', $request->user()->id)
|
||||
->whereNull('m.deleted_at')->whereNull('o.deleted_at')->whereIn('m.role', ['admin', 'manager', 'officer'])->get(['o.id', 'o.name']) : []];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\PartnershipInstallation;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class PartnershipInstallationController extends Controller
|
||||
{
|
||||
public function preview(PartnershipInstallation $installation): Response
|
||||
{
|
||||
return Inertia::render('partnerships/installation', $installation->preview());
|
||||
}
|
||||
|
||||
public function apply(Request $request, PartnershipInstallation $installation): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['hash' => 'required|string|size:64']);
|
||||
$installation->apply($data['hash'], $request->user());
|
||||
|
||||
return back()->with('success', 'Bolsa de Parcerias instalada. Registos antigos preservados.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Mail\ContactMessageReceivedMail;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Throwable;
|
||||
|
||||
class PortalContactController extends Controller
|
||||
{
|
||||
// Adapter for investidor's contact form: preserve its fields and validation,
|
||||
// using the configured mail transport without adding a database dependency.
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'min:2', 'max:150'],
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:30'],
|
||||
'subject' => ['required', 'string', 'min:3', 'max:100'],
|
||||
'message' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
]);
|
||||
|
||||
try {
|
||||
Mail::to(config('contact_messages.recipient'))
|
||||
->send(new ContactMessageReceivedMail([
|
||||
'name' => $data['name'], 'email' => $data['email'], 'phone' => $data['phone'] ?? null,
|
||||
'subject' => $data['subject'], 'message' => $data['message'], 'platform' => 'InvesteMoz',
|
||||
]));
|
||||
} catch (Throwable $exception) {
|
||||
Log::warning('Falha no envio de contacto do portal.', ['exception' => $exception::class]);
|
||||
|
||||
return response()->json(['success' => false, 'message' => 'Ocorreu um erro ao enviar a mensagem.'], 500);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'Mensagem enviada com sucesso.'], 201);
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,20 @@ use App\Models\InterestRequest;
|
||||
use App\Models\InvestmentSubmission;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Sector;
|
||||
use App\Models\User;
|
||||
use App\Services\CountryCatalogue;
|
||||
use App\Services\DeclaredOriginStatistics;
|
||||
use App\Services\InterestWorkflow;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\InvestmentWorkspace;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\PortalAudit;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -22,7 +30,7 @@ use Inertia\Response;
|
||||
|
||||
class PortalManagementController extends Controller
|
||||
{
|
||||
private const STATES = ['received', 'in_review', 'contacted', 'closed'];
|
||||
private const STATES = ['received', 'submitted', 'in_review', 'reviewed', 'qualification', 'qualified', 'contacted', 'contact_accepted', 'referred', 'due_diligence', 'negotiation', 'pending', 'refused', 'converted', 'cancelled', 'closed'];
|
||||
|
||||
public function history(): Response
|
||||
{
|
||||
@@ -45,15 +53,60 @@ class PortalManagementController extends Controller
|
||||
return Inertia::render('management/history', ['events' => $events]);
|
||||
}
|
||||
|
||||
private function interestAssignmentEtag(InterestRequest $interest): string
|
||||
{
|
||||
return hash('sha256',json_encode([$interest->id,$interest->status,$interest->assigned_to,$interest->updated_at?->format('Y-m-d H:i:s.u')],JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public function interestAssignments(Request $request): Response|JsonResponse
|
||||
{
|
||||
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403);
|
||||
$rows=InterestRequest::whereNotIn('status',['cancelled','closed','refused','converted'])->orderBy('id')->get(['id','reference','status','assigned_to','updated_at'])
|
||||
->map(fn($row)=>['id'=>$row->id,'protocol'=>$row->reference,'status'=>$row->status,'analyst_id'=>$row->assigned_to,'version'=>0,'etag'=>$this->interestAssignmentEtag($row),'due_at'=>null]);
|
||||
$data=['kind'=>'interest','requests'=>$rows,'analysts'=>$this->interestAssignees()->get(['id','name'])];
|
||||
return $request->expectsJson()?response()->json($data):Inertia::render('management/desk-assignments',$data);
|
||||
}
|
||||
|
||||
public function assignInterest(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
|
||||
{
|
||||
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403);
|
||||
$data=$request->validate(['etag'=>'required|string|size:64','analyst_id'=>['required','integer',Rule::in($this->interestAssignees()->pluck('id'))],'reason'=>'required|string|min:10|max:2000'],['reason.required'=>'Explique o motivo da atribuição.','reason.min'=>'Explique o motivo da atribuição com pelo menos 10 caracteres.','analyst_id.required'=>'Seleccione o responsável.','analyst_id.in'=>'Seleccione um responsável autorizado.']);
|
||||
DB::transaction(function()use($request,$interest,$data){
|
||||
$row=InterestRequest::whereKey($interest->id)->lockForUpdate()->firstOrFail();
|
||||
if (!hash_equals($this->interestAssignmentEtag($row),$data['etag'])) throw ValidationException::withMessages(['etag'=>'A atribuição mudou. Recupere a lista actual e tente novamente.']);
|
||||
if (in_array($row->status,['cancelled','closed','refused','converted'],true)) throw ValidationException::withMessages(['status'=>'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']);
|
||||
$owner=(int)($row->metadata['portal_user_id']??0);
|
||||
abort_if($owner===(int)$data['analyst_id'] || InvestmentSubmission::where('interest_request_id',$row->id)->where('user_id',$data['analyst_id'])->exists(),422,'O interessado não pode acompanhar o seu próprio pedido como equipa.');
|
||||
PortalAudit::save($row,['assigned_to'=>(int)$data['analyst_id']],$request->user()->id);
|
||||
app(\App\Services\PortalProfileAccess::class)->audit($request->user(),'interest_assigned',['interest_id'=>$row->id,'assigned_to'=>(int)$data['analyst_id'],'reason'=>$data['reason']]);
|
||||
});
|
||||
return $request->expectsJson()?response()->json(['ok'=>true]):back()->with('success','Responsável atribuído.');
|
||||
}
|
||||
|
||||
public function interests(Request $request): Response
|
||||
{
|
||||
$filters = $request->validate([
|
||||
'q' => ['nullable', 'string', 'max:150'],
|
||||
'status' => ['nullable', Rule::in(self::STATES)],
|
||||
'follow_up' => ['nullable', Rule::in(['overdue', 'upcoming', 'unassigned'])],
|
||||
'country' => ['nullable', app(CountryCatalogue::class)->rule()],
|
||||
'origin' => ['nullable', Rule::in([...array_column(app(CountryCatalogue::class)->all(), 'code'), 'unknown'])],
|
||||
]);
|
||||
$query = InterestRequest::query()->select([
|
||||
'id', 'reference', 'full_name', 'email', 'sector_interest', 'status', 'created_at',
|
||||
$query = InterestRequest::query()->with('assignee:id,name')->select([
|
||||
'id', 'reference', 'full_name', 'email', 'country', 'metadata', 'sector_interest', 'existing_business_id', 'modality', 'status', 'assigned_to', 'next_action', 'next_action_due_at', 'created_at',
|
||||
]);
|
||||
app(\App\Services\ScopedPortalAdministration::class)->interestQuery($query, $request->user());
|
||||
if ($country = $filters['country'] ?? null) {
|
||||
$values = app(CountryCatalogue::class)->filterValues($country);
|
||||
$query->whereIn(DB::raw('LOWER(BTRIM(country))'), $values);
|
||||
}
|
||||
if ($origin = $filters['origin'] ?? null) {
|
||||
if ($origin === 'unknown') {
|
||||
$query->where(fn ($q) => $q->whereNull('metadata->declared_origin_country_code')->orWhereNotIn('metadata->declared_origin_country_code', array_column(app(CountryCatalogue::class)->all(), 'code')));
|
||||
} else {
|
||||
$query->where('metadata->declared_origin_country_code', $origin);
|
||||
}
|
||||
}
|
||||
if ($term = $filters['q'] ?? null) {
|
||||
$query->where(fn ($q) => $q->whereLike('reference', "%{$term}%")
|
||||
->orWhereLike('full_name', "%{$term}%")->orWhereLike('email', "%{$term}%"));
|
||||
@@ -61,40 +114,152 @@ class PortalManagementController extends Controller
|
||||
if ($status = $filters['status'] ?? null) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
if (($filters['follow_up'] ?? null) === 'overdue') {
|
||||
$query->whereNotNull('next_action_due_at')->where('next_action_due_at', '<', now())
|
||||
->whereNotIn('status', ['refused', 'converted', 'cancelled', 'closed']);
|
||||
} elseif (($filters['follow_up'] ?? null) === 'upcoming') {
|
||||
$query->whereNotNull('next_action_due_at')->where('next_action_due_at', '>=', now())
|
||||
->whereNotIn('status', ['refused', 'converted', 'cancelled', 'closed']);
|
||||
} elseif (($filters['follow_up'] ?? null) === 'unassigned') {
|
||||
$query->whereNull('assigned_to')->whereNotIn('status', ['refused', 'converted', 'cancelled', 'closed']);
|
||||
}
|
||||
|
||||
return Inertia::render('management/interests', [
|
||||
'requests' => $query->orderByDesc('id')->paginate(20)->withQueryString(),
|
||||
'requests' => $query->orderByDesc('id')->paginate(20)->withQueryString()->through(function ($row) {
|
||||
$result = $row->toArray();
|
||||
$result['declaredOrigin'] = app(CountryCatalogue::class)->describe($row->metadata['declared_origin_country_code'] ?? null);
|
||||
$country = app(CountryCatalogue::class)->describe($row->country);
|
||||
$result['countryName'] = $country['code'] !== null ? $country['name'] : $row->country;
|
||||
unset($result['metadata']);
|
||||
|
||||
return $result;
|
||||
}),
|
||||
'filters' => $filters,
|
||||
'countries' => app(CountryCatalogue::class)->all(),
|
||||
'originStatistics' => app(DeclaredOriginStatistics::class)->forManager($request->user(), $filters['origin'] ?? null),
|
||||
]);
|
||||
}
|
||||
|
||||
public function interest(InterestRequest $interest): Response
|
||||
{
|
||||
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403);
|
||||
$history = DB::table('business_workflow_transitions')->where('interest_request_id', $interest->id)
|
||||
->orderByDesc('created_at')->limit(50)->get(['from_status', 'to_status', 'actor_id', 'reason', 'created_at'])
|
||||
->map(function ($row) {
|
||||
$row->note = $row->reason ? Crypt::decryptString($row->reason) : null;
|
||||
unset($row->reason);
|
||||
|
||||
return $row;
|
||||
});
|
||||
$consents = DB::table('consents')->where('interest_request_id', $interest->id)
|
||||
->orderBy('created_at')->get(['purpose', 'policy_version', 'status', 'granted_at', 'withdrawn_at']);
|
||||
|
||||
return Inertia::render('management/interest', [
|
||||
'declaredOrigin' => app(CountryCatalogue::class)->describe($interest->metadata['declared_origin_country_code'] ?? null),
|
||||
'interest' => $interest->only([
|
||||
'id', 'reference', 'full_name', 'email', 'phone', 'investor_type', 'country',
|
||||
'sector_interest', 'opportunity_reference', 'investment_range', 'message',
|
||||
'privacy_consent', 'consented_at', 'status', 'created_at', 'reviewed_at', 'reviewed_by', 'metadata',
|
||||
'existing_business_id', 'modality', 'assigned_to', 'next_action', 'next_action_due_at', 'source_page',
|
||||
]),
|
||||
'history' => $history,
|
||||
'consents' => $consents,
|
||||
'assignees' => $this->interestAssignees()->get(['id', 'name']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function review(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
|
||||
public function review(Request $request, InterestRequest $interest, InterestWorkflow $workflow): RedirectResponse|JsonResponse
|
||||
{
|
||||
$validated = $request->validate(['status' => ['required', Rule::in(self::STATES)]]);
|
||||
DB::transaction(function () use ($request, $interest, $validated) {
|
||||
$locked = InterestRequest::whereKey($interest->id)->lockForUpdate()->firstOrFail();
|
||||
$changed = $locked->status !== $validated['status'];
|
||||
PortalAudit::save($locked, [
|
||||
...$validated, 'reviewed_at' => now(), 'reviewed_by' => $request->user()->id,
|
||||
], $request->user()->id);
|
||||
$submission = InvestmentSubmission::where('interest_request_id', $interest->id)->first();
|
||||
if ($changed && $submission) {
|
||||
app(InvestmentWorkspace::class)->notify($submission->user_id, $submission, 'status', (string) Str::uuid(), ['status' => $validated['status']]);
|
||||
}
|
||||
});
|
||||
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403);
|
||||
$validated = $request->validate([
|
||||
'status' => ['required', Rule::in(self::STATES)],
|
||||
'note' => ['nullable', 'string', 'min:10', 'max:2000'],
|
||||
'next_action' => ['nullable', 'required_with:next_action_due_at', 'string', 'max:180'],
|
||||
'next_action_due_at' => ['nullable', 'required_with:next_action', 'date', 'after_or_equal:today'],
|
||||
'assigned_to' => ['nullable', 'integer', Rule::in($this->interestAssignees()->pluck('id'))],
|
||||
], [
|
||||
'next_action_due_at.required_with' => 'Indique o prazo da próxima acção.',
|
||||
'next_action_due_at.date' => 'Indique uma data válida para a próxima acção.',
|
||||
'next_action_due_at.after_or_equal' => 'O prazo da próxima acção deve ser hoje ou uma data futura.',
|
||||
'next_action.required_with' => 'Descreva a próxima acção correspondente ao prazo indicado.',
|
||||
]);
|
||||
$before = $interest->status;
|
||||
$updated = $workflow->transition($request->user(), $interest, $validated['status'], $validated['note'] ?? null,
|
||||
$validated['next_action'] ?? null, $validated['next_action_due_at'] ?? null, isset($validated['assigned_to']) ? (int) $validated['assigned_to'] : null,
|
||||
$request->exists('assigned_to'), $request->exists('next_action') || $request->exists('next_action_due_at'));
|
||||
PortalAudit::save($updated, ['status' => $updated->status, 'reviewed_at' => $updated->reviewed_at, 'reviewed_by' => $updated->reviewed_by], $request->user()->id);
|
||||
$submission = InvestmentSubmission::where('interest_request_id', $interest->id)->first();
|
||||
if ($before !== $updated->status && $submission) {
|
||||
app(InvestmentWorkspace::class)->notify($submission->user_id, $submission, 'status', (string) Str::uuid(), ['status' => $updated->status]);
|
||||
}
|
||||
|
||||
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'status' => $validated['status']]) : back()->with('success', 'Estado do pedido actualizado.');
|
||||
return $request->expectsJson() ? response()->json(['reference' => $updated->reference, 'status' => $updated->status]) : back()->with('success', 'Estado do pedido actualizado.');
|
||||
}
|
||||
|
||||
public function withdrawProponentSharingConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
|
||||
{
|
||||
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403);
|
||||
$data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
|
||||
$this->withdrawConsent($request, $interest, $data['reason'], 'share_contact_with_proponent');
|
||||
|
||||
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'sharing_consent' => 'withdrawn'])
|
||||
: back()->with('success', 'Consentimento de partilha revogado.');
|
||||
}
|
||||
|
||||
public function withdrawContactConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
|
||||
{
|
||||
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403);
|
||||
$data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
|
||||
$this->withdrawConsent($request, $interest, $data['reason'], 'respond_to_interest');
|
||||
|
||||
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'contact_consent' => 'withdrawn'])
|
||||
: back()->with('success', 'Consentimento de contacto revogado.');
|
||||
}
|
||||
|
||||
private function withdrawConsent(Request $request, InterestRequest $interest, string $reason, string $purpose): void
|
||||
{
|
||||
DB::transaction(function () use ($request, $interest, $reason, $purpose) {
|
||||
$lockedInterest = InterestRequest::query()->lockForUpdate()->findOrFail($interest->id);
|
||||
$consent = DB::table('consents')->where('interest_request_id', $lockedInterest->id)
|
||||
->where('purpose', $purpose)->where('status', 'granted')->whereNull('deleted_at')
|
||||
->lockForUpdate()->first();
|
||||
if (! $consent) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('consents')->where('id', $consent->id)->update(['status' => 'withdrawn', 'withdrawn_at' => now(), 'updated_at' => now()]);
|
||||
$correlationId = (string) Str::uuid();
|
||||
DB::table('business_workflow_transitions')->insert([
|
||||
'id' => (string) Str::uuid(), 'interest_request_id' => $lockedInterest->id,
|
||||
'from_status' => $lockedInterest->status, 'to_status' => $lockedInterest->status,
|
||||
'actor_id' => $request->user()->id, 'reason' => Crypt::encryptString($reason),
|
||||
'correlation_id' => $correlationId,
|
||||
'metadata' => json_encode(['source' => 'management-interest', 'action' => 'consent_withdrawn',
|
||||
'purpose' => $purpose], JSON_THROW_ON_ERROR),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
DB::table('outbox_events')->insertOrIgnore([
|
||||
'source_system' => 'portal-intake', 'external_id' => 'InterestConsentWithdrawn:'.$consent->id,
|
||||
'aggregate_type' => 'interest_consent', 'aggregate_id' => $consent->id,
|
||||
'event_type' => 'InterestConsentWithdrawn', 'data_classification' => 'confidential',
|
||||
'correlation_id' => $correlationId, 'status' => 'pending',
|
||||
'payload' => json_encode(['schema_version' => 1, 'consent_id' => $consent->id,
|
||||
'interest_id' => $lockedInterest->integration_id, 'purpose' => $purpose], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return Builder<User> */
|
||||
private function interestAssignees(): Builder
|
||||
{
|
||||
$emails = array_unique(array_merge(config('portal.managers', []), ...array_map(
|
||||
fn (string $designation) => config('existing-business.'.$designation, []),
|
||||
['triagers', 'reviewers', 'approvers_l1', 'approvers_l2', 'publishers'],
|
||||
)));
|
||||
|
||||
return User::query()->whereNotNull('email_verified_at')->where(function ($q) use ($emails) {
|
||||
$q->whereIn(DB::raw('lower(email)'), $emails)->orWhereIn('id', DB::table('portal_superuser_pair')->select('first_user_id'))->orWhereIn('id', DB::table('portal_superuser_pair')->select('second_user_id'));
|
||||
})->orderBy('name');
|
||||
}
|
||||
|
||||
public function catalog(Request $request, string $resource): Response
|
||||
@@ -104,9 +269,19 @@ class PortalManagementController extends Controller
|
||||
'q' => ['nullable', 'string', 'max:150'],
|
||||
'status' => ['nullable', Rule::in($resource === 'sectors' ? ['active', 'inactive'] : ['draft', 'published', 'archived'])],
|
||||
'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')],
|
||||
'geography_scope' => ['nullable', Rule::in(['unspecified', 'specific', 'multiple', 'national', 'digital'])],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
]);
|
||||
$query = $model::query();
|
||||
if ($resource === 'opportunities') {
|
||||
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
|
||||
$query = app(PortalProfileAccess::class)->scope(Opportunity::query(), $request->user());
|
||||
}
|
||||
$query->with('primaryTerritory:id,name,location_type,source_system,source_code');
|
||||
if (isset($filters['geography_scope'])) {
|
||||
$query->where('geography_scope', $filters['geography_scope']);
|
||||
}
|
||||
}
|
||||
if ($term = trim($filters['q'] ?? '')) {
|
||||
$query->where(function ($query) use ($term, $resource) {
|
||||
$query->whereLike($resource === 'sectors' ? 'name' : 'title', "%{$term}%")
|
||||
@@ -126,30 +301,39 @@ class PortalManagementController extends Controller
|
||||
}
|
||||
}
|
||||
if ($resource === 'opportunities' && ($filters['sector_id'] ?? null)) {
|
||||
$query->where('sector_id', $filters['sector_id']);
|
||||
$sector = Sector::find($filters['sector_id']);
|
||||
$query->whereIn('sector_id', $sector ? Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
|
||||
}
|
||||
|
||||
return Inertia::render('management/catalog', [
|
||||
'resource' => $resource,
|
||||
'records' => $query->orderByDesc('id')->paginate(20)->withQueryString(),
|
||||
'filters' => $filters,
|
||||
'sectors' => $resource === 'opportunities' ? Sector::orderBy('name')->get(['id', 'name']) : [],
|
||||
'sectors' => $resource === 'opportunities' ? Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']) : [],
|
||||
'geographyReviewCount' => $resource === 'opportunities'
|
||||
? (clone $query)->where('geography_scope', 'unspecified')->count()
|
||||
: 0,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(string $resource, ?int $id = null): Response
|
||||
{
|
||||
$model = $resource === 'sectors' ? Sector::class : Opportunity::class;
|
||||
$record = $id ? $model::findOrFail($id) : null;
|
||||
|
||||
return Inertia::render('management/editor', [
|
||||
'resource' => $resource,
|
||||
'record' => $id ? $model::findOrFail($id) : null,
|
||||
'sectors' => Sector::orderBy('name')->get(['id', 'name']),
|
||||
'record' => $record,
|
||||
'territoryPath' => $record instanceof Opportunity ? app(InvestmentGeography::class)->path($record->primary_territory_id, true) : [],
|
||||
'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(Request $request, string $resource, ?int $id = null): RedirectResponse
|
||||
{
|
||||
if ($resource === 'opportunities' && app(OpportunityManagement::class)->installed()) {
|
||||
throw ValidationException::withMessages(['status' => 'Use Gestão de Oportunidades para guardar uma versão de rascunho; publicação não é uma edição.']);
|
||||
}
|
||||
$model = $resource === 'sectors' ? Sector::class : Opportunity::class;
|
||||
$record = $id ? $model::findOrFail($id) : new $model;
|
||||
$rules = $resource === 'sectors' ? [
|
||||
@@ -159,6 +343,7 @@ class PortalManagementController extends Controller
|
||||
'description' => ['nullable', 'string', 'max:20000'],
|
||||
'sort_order' => ['required', 'integer', 'min:0', 'max:2147483647'],
|
||||
'is_active' => ['required', 'boolean'],
|
||||
'editorial_published' => ['required', 'boolean'],
|
||||
] : [
|
||||
'sector_id' => ['required', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')],
|
||||
'reference' => ['required', 'string', 'max:80', Rule::unique('opportunities')->ignore($record->id)],
|
||||
@@ -178,19 +363,17 @@ class PortalManagementController extends Controller
|
||||
'status' => ['required', Rule::in(['draft', 'published', 'archived'])],
|
||||
'is_featured' => ['required', 'boolean'],
|
||||
];
|
||||
if ($resource === 'opportunities' && ! $id) {
|
||||
if ($resource === 'opportunities') {
|
||||
$rules['geography_scope'] = ['nullable', Rule::in(['unspecified', 'specific', 'multiple', 'national', 'digital'])];
|
||||
foreach (InvestmentGeography::FIELDS as $field => $type) {
|
||||
$rules[$field] = ['nullable', 'uuid'];
|
||||
}
|
||||
$rules['territory_version'] = [$id && $request->hasAny(array_keys(InvestmentGeography::FIELDS)) ? 'required' : 'nullable', 'integer', 'min:0'];
|
||||
}
|
||||
$data = $request->validate($rules);
|
||||
if ($resource === 'opportunities' && ! $id && $request->has('province_id')) {
|
||||
$path = app(InvestmentGeography::class)->selection($data);
|
||||
$data = array_diff_key($data, InvestmentGeography::FIELDS);
|
||||
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
|
||||
$data['territory_version'] = $path === [] ? 0 : 1;
|
||||
$data['province'] = $path[0]->name ?? null;
|
||||
$data['district'] = $path[1]->name ?? null;
|
||||
if ($resource === 'sectors') {
|
||||
$data['published_at'] = $data['editorial_published'] ? ($record->published_at ?? now()) : null;
|
||||
unset($data['editorial_published']);
|
||||
}
|
||||
if ($resource === 'opportunities' && $data['status'] === 'published' && $record->exists
|
||||
&& Schema::hasTable('education_investment_proposals')
|
||||
@@ -205,7 +388,30 @@ class PortalManagementController extends Controller
|
||||
$data['maturity_label'] = $labels[$data['maturity_level'] ?? 0] ?? null;
|
||||
$data['published_at'] = $data['status'] === 'published' ? ($record->published_at ?? now()) : null;
|
||||
}
|
||||
PortalAudit::save($record, $data, $request->user()->id);
|
||||
DB::transaction(function () use ($record, $data, $request, $resource) {
|
||||
if ($resource === 'opportunities' && $request->hasAny(array_keys(InvestmentGeography::FIELDS))) {
|
||||
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
|
||||
if ($record->exists) {
|
||||
$current = Opportunity::whereKey($record->id)->lockForUpdate()->firstOrFail();
|
||||
abort_unless((int) $current->territory_version === (int) $data['territory_version'], 409, 'Localização alterada. Recarregue a ficha.');
|
||||
}
|
||||
$path = app(InvestmentGeography::class)->selection($data, true);
|
||||
if (($data['geography_scope'] ?? null) === 'national' && $path !== []) {
|
||||
throw ValidationException::withMessages(['geography_scope' => 'O âmbito nacional não pode ter uma localização territorial específica.']);
|
||||
}
|
||||
$data = array_diff_key($data, InvestmentGeography::FIELDS);
|
||||
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
|
||||
if ($path !== [] && empty($data['geography_scope'])) {
|
||||
$data['geography_scope'] = 'specific';
|
||||
}
|
||||
$data['territory_version'] = (int) ($data['territory_version'] ?? 0) + 1;
|
||||
$data['province'] = $path[0]->name ?? null;
|
||||
$data['district'] = $path[1]->name ?? null;
|
||||
} else {
|
||||
unset($data['territory_version']);
|
||||
}
|
||||
PortalAudit::save($record, $data, $request->user()->id);
|
||||
});
|
||||
|
||||
return redirect('/management/'.$resource)->with('success', 'Registo guardado.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\OpportunityCanonicalContract;
|
||||
use App\Services\OpportunityContacts;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\OpportunityProfileWorkflow;
|
||||
use App\Services\PortalInternalDesignation;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use App\Services\PortalProfileAdministration;
|
||||
use App\Services\PortalSuperusers;
|
||||
use App\Services\VerifiedDocumentBinary;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class PortalProfileController extends Controller
|
||||
{
|
||||
public function requests(Request $request, PortalProfileAccess $access): Response
|
||||
{
|
||||
return Inertia::render('profiles/request', ['requests' => DB::table('portal_profile_requests')->where('user_id', $request->user()->id)->latest()->get(),
|
||||
'types' => OpportunityManagement::CATEGORIES,
|
||||
'organizations' => DB::table('memberships as m')->join('organizations as o', 'o.id', '=', 'm.organization_id')->where('m.user_id', $request->user()->id)
|
||||
->whereNull('m.deleted_at')->whereNull('o.deleted_at')->whereIn('m.role', ['admin', 'manager', 'officer'])->get(['o.id', 'o.name']),
|
||||
'documents' => DB::table('documents')->where('uploaded_by', $request->user()->id)->whereNull('deleted_at')->where('status', 'active')->where('malware_scan_status', 'clean')
|
||||
->whereColumn('sha256', 'malware_scanned_sha256')->get(['id', 'file_name']), 'grants' => $access->grants($request->user(), 'promoter')]);
|
||||
}
|
||||
|
||||
public function requestProfile(Request $request, PortalProfileAdministration $service): JsonResponse
|
||||
{
|
||||
return response()->json(['id' => $service->request($request->user(), $request->all()), 'message' => 'Pedido recebido; perfil ainda não ativado.'], 201);
|
||||
}
|
||||
|
||||
public function requestStatus(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$row = DB::table('portal_profile_requests')->where('id', $id)->where('user_id', $request->user()->id)->first();
|
||||
abort_unless($row !== null, 403);
|
||||
|
||||
return response()->json($row);
|
||||
}
|
||||
|
||||
public function administration(Request $request, PortalProfileAdministration $service): Response
|
||||
{
|
||||
$service->administrative($request->user());
|
||||
|
||||
return Inertia::render('profiles/administration', ['requests' => DB::table('portal_profile_requests')->latest()->get(),
|
||||
'assignments' => DB::table('portal_role_assignments')->get(), 'pair' => DB::table('portal_superuser_pair')->first(),
|
||||
'operatorOrganizations' => app(PortalInternalDesignation::class)->organizations(),
|
||||
'internalTypes' => OpportunityCanonicalContract::BASE_CATEGORIES,
|
||||
'actorId' => $request->user()->id, 'types' => OpportunityManagement::CATEGORIES,
|
||||
'organizations' => DB::table('organizations')->whereNull('deleted_at')->get(['id', 'name']),
|
||||
'securityRequests' => DB::table('portal_security_approvals')->latest()->limit(30)->get(),
|
||||
'securityAlerts' => DB::table('audit_events')->where('source_system', 'portal-profiles-v1')->whereIn('action', ['superuser_pair_initialized', 'superuser_replaced', 'role_revoked'])->latest('occurred_at')->limit(20)->get(['id', 'action', 'actor_user_id', 'occurred_at'])]);
|
||||
}
|
||||
|
||||
public function decide(Request $request, string $id, PortalProfileAdministration $service): JsonResponse
|
||||
{
|
||||
$service->decide($request->user(), $id, $request->all());
|
||||
|
||||
return response()->json(['message' => 'Decisão registada; somente os tipos aprovados ficam autorizados.']);
|
||||
}
|
||||
|
||||
public function audit(Request $request, PortalProfileAccess $access): Response|JsonResponse
|
||||
{
|
||||
abort_unless($access->has($request->user(), 'auditor') || ($access->superuser($request->user()) && $access->mfa($request->user())), 403);
|
||||
$filters = $request->validate(['actor_id' => 'nullable|integer|min:1', 'action' => 'nullable|string|max:100', 'record_id' => 'nullable|integer|min:1', 'from' => 'nullable|date', 'until' => 'nullable|date|after_or_equal:from', 'format' => 'nullable|in:json']);
|
||||
$query = DB::table('audit_events')->when($filters['actor_id'] ?? null, fn ($q, $v) => $q->where('actor_user_id', $v))
|
||||
->when($filters['action'] ?? null, fn ($q, $v) => $q->where('action', $v))
|
||||
->when($filters['record_id'] ?? null, fn ($q, $v) => $q->where('after_data->record_id', $v))
|
||||
->when($filters['from'] ?? null, fn ($q, $v) => $q->where('occurred_at', '>=', $v))
|
||||
->when($filters['until'] ?? null, fn ($q, $v) => $q->where('occurred_at', '<=', $v));
|
||||
// Minimized metadata: never decrypt private snapshots or export authentication bodies.
|
||||
$query->select(['id', 'source_system', 'action', 'actor_user_id', 'entity_type', 'occurred_at', 'correlation_id']);
|
||||
if (($filters['format'] ?? null) === 'json') {
|
||||
$records = $query->orderByDesc('occurred_at')->limit(1000)->get();
|
||||
$access->audit($request->user(), 'audit_metadata_exported', ['filters' => $filters, 'count' => $records->count()]);
|
||||
|
||||
return response()->json(['filters' => $filters, 'records' => $records, 'limit' => 1000])->header('Content-Disposition', 'attachment; filename="audit-metadata.json"')->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
$records = $query->orderByDesc('occurred_at')->paginate(30)->withQueryString();
|
||||
$access->audit($request->user(), 'audit_metadata_read', ['filters' => $filters, 'count' => $records->count()]);
|
||||
|
||||
return Inertia::render('profiles/audit', ['records' => $records, 'filters' => $filters]);
|
||||
}
|
||||
|
||||
public function registerOperatorOrganization(Request $request, PortalInternalDesignation $service): JsonResponse
|
||||
{
|
||||
return response()->json($service->registerOrganization($request->user(), $request->all()), 201);
|
||||
}
|
||||
|
||||
public function grant(Request $request, PortalProfileAdministration $service): JsonResponse
|
||||
{
|
||||
return response()->json(['id' => $service->grant($request->user(), $request->all())], 201);
|
||||
}
|
||||
|
||||
public function requestDocument(Request $request, string $id, string $documentId, PortalProfileAdministration $service): \Illuminate\Http\Response|BinaryFileResponse
|
||||
{
|
||||
$service->administrative($request->user());
|
||||
$row = DB::table('portal_profile_requests')->where('id', $id)->first();
|
||||
abort_unless($row && in_array($documentId, json_decode($row->document_ids, true), true), 404);
|
||||
$document = DB::table('documents')->where('id', $documentId)->first();
|
||||
abort_unless($document !== null, 404);
|
||||
|
||||
return app(VerifiedDocumentBinary::class)->respond($document, true);
|
||||
}
|
||||
|
||||
public function revoke(Request $request, string $id, PortalProfileAdministration $service): JsonResponse
|
||||
{
|
||||
$service->revoke($request->user(), $id, (string) $request->input('reason'));
|
||||
|
||||
return response()->json(['message' => 'Papel revogado; sessões e tokens anteriores invalidados.']);
|
||||
}
|
||||
|
||||
public function opportunities(Request $request, PortalProfileAccess $access): Response
|
||||
{
|
||||
abort_unless($access->grants($request->user()) !== [] || $access->superuser($request->user()), 403);
|
||||
|
||||
return app(OpportunityManagementController::class)->index($request, app(OpportunityManagement::class));
|
||||
}
|
||||
|
||||
public function edit(Request $request, PortalProfileAccess $access, ?int $id = null): Response|\Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
if ($id) {
|
||||
$access->authorize($request->user(), str_ends_with($request->path(), '/view') ? 'view' : 'edit', Opportunity::withTrashed()->findOrFail($id));
|
||||
} else {
|
||||
if (! ($access->navigation($request->user())['contexts'] ?? [])) {
|
||||
$reason = $access->has($request->user(), 'auditor')
|
||||
? 'O papel de Auditor permite consulta e impede a criação de oportunidades.'
|
||||
: 'Esta conta não dispõe de um contexto ativo de Promotor para uma organização e modalidade autorizadas. Confirme a vigência, o âmbito e os tipos atribuídos na gestão de perfis. A elevação excecional de Superutilizador não substitui a autorização de Promotor.';
|
||||
if ($request->expectsJson()) {
|
||||
abort(403, $reason);
|
||||
}
|
||||
|
||||
return response()->view('profiles.creation-unavailable', ['reason' => $reason], 403);
|
||||
}
|
||||
}
|
||||
|
||||
return app(OpportunityManagementController::class)->edit($request, app(OpportunityManagement::class), $id);
|
||||
}
|
||||
|
||||
public function workflow(Request $request, int $id, OpportunityProfileWorkflow $service): JsonResponse
|
||||
{
|
||||
$record = $service->transition($request->user(), $id, $request->all());
|
||||
|
||||
return response()->json(['id' => $id, 'status' => app(PortalProfileAccess::class)->state($record), 'publication_status' => $record->status, 'etag' => app(OpportunityManagement::class)->etag($record), 'message' => 'Ação interna registada; não representa decisão externa.']);
|
||||
}
|
||||
|
||||
public function contact(Request $request, int $id, OpportunityContacts $service): JsonResponse
|
||||
{
|
||||
return response()->json($service->save($request->user(), $id, $request->all()));
|
||||
}
|
||||
|
||||
public function replacement(Request $request, PortalSuperusers $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['old_user_id' => 'required|integer', 'new_user_id' => 'required|integer', 'reason' => 'required|string|min:20|max:1000']);
|
||||
|
||||
return response()->json(['id' => $service->requestReplacement($request->user(), $data['old_user_id'], $data['new_user_id'], $data['reason'])], 201);
|
||||
}
|
||||
|
||||
public function approveSecurity(Request $request, string $id, PortalSuperusers $service): JsonResponse
|
||||
{
|
||||
$service->approve($request->user(), $id);
|
||||
|
||||
return response()->json(['message' => 'Aprovação independente registada.']);
|
||||
}
|
||||
|
||||
public function replace(Request $request, string $id, PortalSuperusers $service): JsonResponse
|
||||
{
|
||||
$service->applyReplacement($request->user(), $id);
|
||||
|
||||
return response()->json(['message' => 'Titular substituído atomicamente; continuam dois titulares.']);
|
||||
}
|
||||
|
||||
public function elevation(Request $request, PortalSuperusers $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['action' => 'required|string', 'record_id' => 'nullable|integer|exists:opportunities,id', 'reason' => 'required|string|min:20|max:1000']);
|
||||
|
||||
return response()->json(['id' => $service->requestElevation($request->user(), $data['action'], $data['record_id'] ?? null, $data['reason'])], 201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\PortalProfileInstallation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class PortalProfileInstallationController extends Controller
|
||||
{
|
||||
public function preview(Request $request, PortalProfileInstallation $installation): JsonResponse|Response
|
||||
{
|
||||
$plan = $installation->preview($request->user());
|
||||
|
||||
return $request->expectsJson() ? response()->json($plan) : Inertia::render('profiles/installation', $plan);
|
||||
}
|
||||
|
||||
public function apply(Request $request, PortalProfileInstallation $installation): JsonResponse
|
||||
{
|
||||
return response()->json($installation->apply($request->user(), $request->all()));
|
||||
}
|
||||
|
||||
public function bootstrap(Request $request, PortalProfileInstallation $installation): JsonResponse
|
||||
{
|
||||
return response()->json($installation->bootstrap($request->user(), $request->all()));
|
||||
}
|
||||
|
||||
public function bootstrapPreview(Request $request, string $id, PortalProfileInstallation $installation): JsonResponse|Response
|
||||
{
|
||||
$plan = $installation->bootstrapPreview($request->user(), $id);
|
||||
return $request->expectsJson() ? response()->json($plan) : Inertia::render('profiles/bootstrap-confirmation', $plan);
|
||||
}
|
||||
|
||||
public function confirmBootstrap(Request $request, string $id, PortalProfileInstallation $installation): JsonResponse
|
||||
{
|
||||
return response()->json($installation->confirmBootstrap($request->user(), $id, $request->all()));
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,27 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\InterestRequest;
|
||||
use App\Models\Sector;
|
||||
use App\Services\CountryCatalogue;
|
||||
use App\Services\IdempotencyConflict;
|
||||
use App\Services\InterestSubmission;
|
||||
use App\Services\LegacyContent;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use App\Services\Malware\MalwareScanner;
|
||||
use App\Services\VerifiedDocumentBinary;
|
||||
use App\Services\VirtualDesk;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
@@ -20,24 +31,46 @@ class PortalServicesController extends Controller
|
||||
{
|
||||
public function home(): Response
|
||||
{
|
||||
return Inertia::render('PortalInvestidor', ['essentialResources' => $this->resources()->where('r.essential', true)
|
||||
->orderBy('r.title')->limit(6)->get(['r.id', 'r.title'])]);
|
||||
try {
|
||||
$essentialResources = $this->resources()->where('r.essential', true)
|
||||
->orderBy('r.title')->limit(6)->get(['r.id', 'r.title']);
|
||||
} catch (QueryException $exception) {
|
||||
// A catalogue schema mismatch must not take down unrelated home content.
|
||||
// Keep the document list empty and retain the full exception in internal logs.
|
||||
report($exception);
|
||||
$essentialResources = collect();
|
||||
}
|
||||
|
||||
return Inertia::render('PortalInvestidor', ['essentialResources' => $essentialResources, 'countries' => app(CountryCatalogue::class)->all()]);
|
||||
}
|
||||
|
||||
private function resources(): Builder
|
||||
{
|
||||
return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
|
||||
->where('r.published', true)->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at');
|
||||
->where('r.published', true)->where('r.rights_status', '!=', 'restricted')->where('d.rights_status', '!=', 'restricted')
|
||||
->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at')
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256');
|
||||
}
|
||||
|
||||
public function page(Request $request): Response
|
||||
public function page(Request $request): Response|JsonResponse|RedirectResponse
|
||||
{
|
||||
$page = $request->route()->defaults['page'];
|
||||
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'category' => ['nullable', 'string', 'max:80'],
|
||||
'topic' => ['nullable', 'string', 'max:80'], 'sector' => ['nullable', 'string', 'max:150'], 'province' => ['nullable', 'string', 'max:100']]);
|
||||
$props = ['section' => $page, 'filters' => $filters];
|
||||
if ($page === 'partnerships' && Schema::hasColumn('portal_partnerships', 'partnership_type')) {
|
||||
return app(PartnershipExchangeController::class)->index($request);
|
||||
}
|
||||
$filters = $request->validate([
|
||||
'q' => ['nullable', 'string', 'max:120'], 'category' => ['nullable', 'string', 'max:80'],
|
||||
'topic' => ['nullable', 'string', 'max:80'], 'sector' => ['nullable', 'string', 'max:150'], 'province' => ['nullable', 'string', 'max:100'],
|
||||
'document_type' => ['nullable', 'string', 'max:40'], 'language' => ['nullable', 'string', 'max:12'],
|
||||
'issuer' => ['nullable', 'string', 'max:180'], 'legal_status' => ['nullable', 'string', 'max:24'],
|
||||
'source_confirmation' => ['nullable', 'string', 'max:24'], 'territory_id' => ['nullable', 'uuid'],
|
||||
'activity' => ['nullable', 'array', 'max:10'], 'activity.*' => ['string', 'max:180'],
|
||||
'stage' => ['nullable', 'array', 'max:10'], 'stage.*' => ['string', 'max:80'],
|
||||
'module' => ['nullable', 'array', 'max:10'], 'module.*' => ['string', 'max:80'],
|
||||
]);
|
||||
$props = ['section' => $page, 'filters' => $filters, 'countries' => app(CountryCatalogue::class)->all()];
|
||||
if ($page === 'sectors') {
|
||||
$props['sectors'] = Sector::where('is_active', true)->withCount(['opportunities' => fn ($q) => $q->published()])->orderBy('sort_order')->get(['id', 'name', 'slug', 'short_description']);
|
||||
$props['sectors'] = \App\Services\SectorOrganization::catalog();
|
||||
}
|
||||
if (in_array($page, ['documents', 'legislation'], true)) {
|
||||
$query = $this->resources();
|
||||
@@ -46,15 +79,59 @@ class PortalServicesController extends Controller
|
||||
}
|
||||
$props['categories'] = (clone $query)->distinct()->orderBy('r.category')->pluck('r.category');
|
||||
$props['topics'] = (clone $query)->distinct()->orderBy('r.topic')->pluck('r.topic');
|
||||
foreach (['category', 'topic'] as $field) {
|
||||
foreach (['category', 'topic', 'document_type', 'language', 'legal_status', 'source_confirmation'] as $field) {
|
||||
if (! empty($filters[$field])) {
|
||||
$query->where('r.'.$field, $filters[$field]);
|
||||
}
|
||||
}
|
||||
if (! empty($filters['q'])) {
|
||||
$query->where('r.title', 'ilike', '%'.$filters['q'].'%');
|
||||
if (! empty($filters['issuer'])) {
|
||||
$query->where('d.issuer', 'ilike', '%'.$filters['issuer'].'%');
|
||||
}
|
||||
if (! empty($filters['territory_id'])) {
|
||||
$territoryIds = $this->territoryScopeIds($filters['territory_id']);
|
||||
$query->where(function (Builder $territorial) use ($territoryIds): void {
|
||||
$territorial->where('r.territorial_scope', 'national')->orWhereIn('r.territory_id', $territoryIds)
|
||||
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')
|
||||
->whereColumn('l.resource_id', 'r.id')->where('l.target_type', 'territory')->whereIn('l.target_key', $territoryIds));
|
||||
});
|
||||
}
|
||||
foreach (['activity', 'stage', 'module'] as $dimension) {
|
||||
if (! empty($filters[$dimension])) {
|
||||
$query->whereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')
|
||||
->whereColumn('l.resource_id', 'r.id')->where('l.target_type', $dimension)
|
||||
->whereIn('l.target_key', array_values($filters[$dimension])));
|
||||
}
|
||||
}
|
||||
if (! empty($filters['q'])) {
|
||||
$term = '%'.$filters['q'].'%';
|
||||
$query->where(fn (Builder $search) => $search->where('r.title', 'ilike', $term)->orWhere('r.description', 'ilike', $term)
|
||||
->orWhere('r.document_number', 'ilike', $term)->orWhere('d.issuer', 'ilike', $term)
|
||||
->orWhere('r.source_reference', 'ilike', $term)->orWhere('r.category', 'ilike', $term)->orWhere('r.topic', 'ilike', $term));
|
||||
}
|
||||
$pageResult = $query->orderByRaw("CASE WHEN r.document_type IN ('official_form','digital_adaptation','guidance_template') THEN 1 ELSE 0 END")
|
||||
->orderByDesc('r.essential')->orderBy('r.title')->select([
|
||||
'r.id', 'r.document_id', 'r.title', 'r.description', 'r.category', 'r.topic', 'r.document_type', 'r.document_number', 'r.document_date',
|
||||
'r.applicable_from', 'r.applicable_until',
|
||||
'r.source_url', 'r.language', 'r.version_label', 'r.document_group_id', 'r.territorial_scope', 'r.territory_id',
|
||||
'r.source_confirmation', 'r.legal_status', 'r.rights_status', 'r.last_reviewed_at', 'r.last_reviewed_by', 'r.independent_review', 'r.metadata_version', 'r.source_reference', 'r.essential',
|
||||
'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.issued_on', 'd.valid_until', 'd.version_no',
|
||||
])->paginate(20)->withQueryString();
|
||||
$pageResult->getCollection()->transform(fn ($resource) => app(\App\Services\PublicDocumentPresentation::class)->resource($resource));
|
||||
$props['resources'] = $pageResult;
|
||||
$props['documentTypes'] = (clone $this->resources())->distinct()->orderBy('r.document_type')->pluck('r.document_type');
|
||||
$props['languages'] = (clone $this->resources())->distinct()->orderBy('r.language')->pluck('r.language');
|
||||
$props['issuers'] = (clone $this->resources())->whereNotNull('d.issuer')->distinct()->orderBy('d.issuer')->pluck('d.issuer');
|
||||
$props['legalStatuses'] = (clone $this->resources())->distinct()->orderBy('r.legal_status')->pluck('r.legal_status');
|
||||
$props['territories'] = DB::table('geo_locations')->where('data_classification', 'public')->whereNull('deleted_at')
|
||||
->whereIn('location_type', ['province', 'district', 'municipality', 'site'])->orderBy('name')->get(['id', 'name', 'location_type', 'parent_id']);
|
||||
foreach (['activities' => 'activity', 'stages' => 'stage', 'modules' => 'module'] as $key => $type) {
|
||||
$props[$key] = DB::table('portal_resource_links as l')->join('portal_resources as r', 'r.id', '=', 'l.resource_id')
|
||||
->join('documents as d', 'd.id', '=', 'r.document_id')->where('l.target_type', $type)->where('r.published', true)
|
||||
->where('r.rights_status', '!=', 'restricted')->where('d.rights_status', '!=', 'restricted')
|
||||
->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at')
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')
|
||||
->distinct()->orderBy('l.target_key')->pluck('l.target_key');
|
||||
}
|
||||
$props['resources'] = $query->orderByDesc('r.essential')->orderBy('r.title')->select(['r.id', 'r.title', 'r.category', 'r.topic', 'r.source_reference', 'r.essential'])->paginate(20)->withQueryString();
|
||||
}
|
||||
if ($page === 'partnerships') {
|
||||
$query = DB::table('portal_partnerships')->where('status', 'published');
|
||||
@@ -69,6 +146,9 @@ class PortalServicesController extends Controller
|
||||
$props['partnerships'] = $query->orderByDesc('id')->select(['id', 'title', 'sector', 'province', 'offer', 'seeking'])->paginate(20)->withQueryString();
|
||||
}
|
||||
if ($page === 'desk') {
|
||||
if (app(VirtualDesk::class)->installed()) {
|
||||
return redirect('/balcao-virtual/painel');
|
||||
}
|
||||
$props['requests'] = InterestRequest::where('metadata->portal_user_id', $request->user()->id)->latest()->limit(100)->get(['reference', 'status', 'created_at', 'metadata']);
|
||||
$props['myPartnerships'] = DB::table('portal_partnerships')->where('user_id', $request->user()->id)->latest()->limit(100)->get(['id', 'title', 'status']);
|
||||
}
|
||||
@@ -77,12 +157,49 @@ class PortalServicesController extends Controller
|
||||
return Inertia::render('portal/services', $props);
|
||||
}
|
||||
|
||||
public function recommendations(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'activity_id' => ['nullable', 'string', 'max:180'], 'province_id' => ['nullable', 'uuid'],
|
||||
'stage' => ['nullable', 'string', 'max:80'], 'module' => ['nullable', 'string', 'max:80'],
|
||||
]);
|
||||
$module = $data['module'] ?? 'simulator';
|
||||
$query = $this->resources()->whereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')
|
||||
->whereColumn('l.resource_id', 'r.id')->where('l.target_type', 'module')->where('l.target_key', $module));
|
||||
$matches = [['type' => 'module', 'key' => $module]];
|
||||
foreach (['activity_id' => 'activity', 'stage' => 'stage'] as $input => $type) {
|
||||
if (! empty($data[$input])) {
|
||||
$key = $data[$input];
|
||||
$query->where(function (Builder $contextual) use ($type, $key): void {
|
||||
$contextual->whereNotExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as general')
|
||||
->whereColumn('general.resource_id', 'r.id')->where('general.target_type', $type))
|
||||
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as matching')
|
||||
->whereColumn('matching.resource_id', 'r.id')->where('matching.target_type', $type)->where('matching.target_key', $key));
|
||||
});
|
||||
$matches[] = ['type' => $type, 'key' => $data[$input]];
|
||||
}
|
||||
}
|
||||
if (! empty($data['province_id'])) {
|
||||
$ids = $this->territoryScopeIds($data['province_id']);
|
||||
$query->where(fn (Builder $territorial) => $territorial->where('r.territorial_scope', 'national')->orWhereIn('r.territory_id', $ids)
|
||||
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')->whereColumn('l.resource_id', 'r.id')
|
||||
->where('l.target_type', 'territory')->whereIn('l.target_key', $ids)));
|
||||
$matches = [...$matches, ...array_map(fn ($id) => ['type' => 'territory', 'key' => $id], $ids)];
|
||||
}
|
||||
$resources = $query->orderByDesc('r.essential')->orderBy('r.title')->limit(10)
|
||||
->get(['r.id', 'r.title', 'r.description', 'r.document_type', 'r.document_date', 'r.document_number', 'r.applicable_from', 'r.applicable_until', 'r.language', 'r.territorial_scope', 'r.legal_status', 'r.source_confirmation', 'r.source_url', 'd.issuer', 'd.media_type', 'd.size_bytes']);
|
||||
$resources->transform(fn ($resource) => app(\App\Services\PublicDocumentPresentation::class)->resource($resource));
|
||||
|
||||
return response()->json(['data' => $resources]);
|
||||
}
|
||||
|
||||
public function submit(Request $request, InterestSubmission $submission): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'kind' => ['required', 'in:support,investor,company,partnership'],
|
||||
'fullName' => ['required', 'string', 'min:3', 'max:150'], 'email' => ['required', 'email:rfc', 'max:150'],
|
||||
'phone' => ['nullable', 'string', 'max:30'], 'country' => ['required', 'string', 'max:100'],
|
||||
'phone' => ['nullable', 'string', 'max:30'], 'country' => ['required', 'string', 'max:100', app(CountryCatalogue::class)->legacyCountryRule()],
|
||||
'declared_origin_country_code' => ['nullable', app(CountryCatalogue::class)->rule()],
|
||||
'company' => ['nullable', 'required_if:kind,company', 'string', 'max:150'],
|
||||
'sectorInterest' => ['required', 'string', 'max:150'], 'message' => ['required', 'string', 'min:20', 'max:2000'],
|
||||
'privacyConsent' => ['required', 'accepted'], 'submission_key' => ['required', 'uuid'],
|
||||
@@ -92,15 +209,25 @@ class PortalServicesController extends Controller
|
||||
abort_unless(DB::table('portal_partnerships')->where('id', $data['partnership_id'])->where('status', 'published')->exists(), 422);
|
||||
}
|
||||
$data['portal_user_id'] = $request->user()?->id;
|
||||
// Preserve the actual entry form, not only the shared POST endpoint. Never accept an external or administrative referrer as the entry page.
|
||||
$entry = parse_url($request->headers->get('referer', ''));
|
||||
$sourcePage = '/apoio';
|
||||
if (is_array($entry) && strtolower($entry['scheme'] ?? '') === $request->getScheme()
|
||||
&& strtolower($entry['host'] ?? '') === strtolower($request->getHost())
|
||||
&& (int) ($entry['port'] ?? (($entry['scheme'] ?? '') === 'https' ? 443 : 80)) === $request->getPort()
|
||||
&& in_array($entry['path'] ?? '', ['/apoio', '/formularios/investidor', '/formularios/empresa', '/parcerias', '/parcerias/painel'], true)) {
|
||||
$sourcePage = $entry['path'];
|
||||
}
|
||||
try {
|
||||
$interest = $submission->receive($data, $data['submission_key'], fn () => InterestRequest::create([
|
||||
'reference' => 'AP-'.now()->year.'-'.Str::ulid(), 'full_name' => $data['fullName'], 'email' => $data['email'],
|
||||
'phone' => $data['phone'] ?? null, 'country' => $data['country'],
|
||||
'investor_type' => $data['kind'] === 'company' ? 'empresa' : 'individual',
|
||||
'sector_interest' => $data['sectorInterest'], 'message' => $data['message'],
|
||||
'privacy_consent' => true, 'consented_at' => now(), 'status' => 'received', 'source_page' => '/apoio',
|
||||
'privacy_consent' => true, 'consented_at' => now(), 'status' => 'received', 'source_page' => $sourcePage,
|
||||
'metadata' => ['kind' => $data['kind'], 'company' => $data['company'] ?? null,
|
||||
'portal_user_id' => $data['portal_user_id'], 'partnership_id' => $data['partnership_id'] ?? null],
|
||||
'portal_user_id' => $data['portal_user_id'], 'partnership_id' => $data['partnership_id'] ?? null,
|
||||
'declared_origin_country_code' => $data['declared_origin_country_code'] ?? null],
|
||||
]));
|
||||
} catch (IdempotencyConflict) {
|
||||
abort(409, 'Este envio já foi usado com dados diferentes. Recarregue o formulário.');
|
||||
@@ -120,82 +247,636 @@ class PortalServicesController extends Controller
|
||||
return back()->with('success', 'Proposta recebida para publicação. Acompanhe no Balcão Virtual.');
|
||||
}
|
||||
|
||||
public function download(int $id): BinaryFileResponse
|
||||
public function download(int $id): BinaryFileResponse|\Illuminate\Http\Response
|
||||
{
|
||||
$resource = $this->resources()->where('r.id', $id)->first(['d.storage_key', 'd.file_name', 'd.sha256']);
|
||||
$resource = $this->resources()->where('r.id', $id)->first(['d.*']);
|
||||
abort_unless($resource !== null, 404);
|
||||
$root = realpath(Storage::disk('local')->path(''));
|
||||
$path = realpath(Storage::disk('local')->path($resource->storage_key));
|
||||
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
|
||||
$checksum = hash_file('sha256', $path);
|
||||
abort_unless($checksum !== false && hash_equals($resource->sha256, $checksum), 404);
|
||||
|
||||
return response()->download($path, basename($resource->file_name), ['X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'private, no-store']);
|
||||
return app(VerifiedDocumentBinary::class)->respond($resource);
|
||||
}
|
||||
|
||||
public function manage(): Response
|
||||
public function showDocument(Request $request, int $id): Response
|
||||
{
|
||||
return Inertia::render('portal/manage', ['resources' => DB::table('portal_resources')->latest()->limit(100)->get(),
|
||||
$resource = $this->resources()->where('r.id', $id)->first([
|
||||
'r.id', 'r.document_id', 'r.title', 'r.description', 'r.category', 'r.topic', 'r.document_type',
|
||||
'r.document_number', 'r.document_date', 'r.applicable_from', 'r.applicable_until', 'r.source_url', 'r.language', 'r.version_label',
|
||||
'r.territorial_scope', 'r.territory_id', 'r.source_confirmation', 'r.legal_status', 'r.source_reference',
|
||||
'r.essential', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.issued_on', 'd.valid_until', 'd.version_no',
|
||||
]);
|
||||
abort_unless($resource !== null, 404);
|
||||
$resource->territory_name = $resource->territory_id
|
||||
? DB::table('geo_locations')->where('id', $resource->territory_id)->value('name') : null;
|
||||
$resource = app(\App\Services\PublicDocumentPresentation::class)->resource($resource);
|
||||
$back = $request->query('back');
|
||||
$backUrl = is_string($back) && (str_starts_with($back, '/documentos') || str_starts_with($back, '/legislacao')) && ! str_starts_with($back, '//')
|
||||
? $back : '/documentos';
|
||||
|
||||
return Inertia::render('portal/document', [
|
||||
'resource' => $resource,
|
||||
'backUrl' => $backUrl,
|
||||
'downloadUrl' => route('services.download', ['id' => $id]),
|
||||
'articleUrl' => $this->articleUrl($id),
|
||||
]);
|
||||
}
|
||||
|
||||
public function viewDocument(int $id): BinaryFileResponse|\Illuminate\Http\Response
|
||||
{
|
||||
$document = $this->resources()->where('r.id', $id)->first(['d.*']);
|
||||
abort_unless($document && in_array($document->media_type, ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'], true), 404);
|
||||
|
||||
return app(VerifiedDocumentBinary::class)->respond($document, true);
|
||||
}
|
||||
|
||||
public function downloadForManager(Request $request, string $id): BinaryFileResponse|\Illuminate\Http\Response
|
||||
{
|
||||
$document = app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $id)->first();
|
||||
abort_unless($document && $document->status === 'active' && $document->malware_scan_status === 'clean'
|
||||
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 404);
|
||||
|
||||
return app(VerifiedDocumentBinary::class)->respond($document);
|
||||
}
|
||||
|
||||
public function manage(Request $request, MalwareScanner $scanner): Response
|
||||
{
|
||||
$managementFilters = $request->validate(['q' => 'nullable|string|max:150', 'state' => 'nullable|in:draft,published,withdrawn', 'intent' => 'nullable|in:document,legislation']);
|
||||
$access = app(\App\Services\DocumentManagementAccess::class);
|
||||
if (! $access->full($request->user())) {
|
||||
$documents = $access->documents($request->user())->where('status', '!=', 'deleted');
|
||||
$health = $scanner->health();
|
||||
$health['signatures_at'] = $health['signatures_at']?->format(DATE_ATOM);
|
||||
|
||||
$editorial = $access->editorial($request->user());
|
||||
$resources = $editorial ? $this->filterManagedResources($access->editorialResources($request->user()), $managementFilters)->orderByDesc('r.updated_at')
|
||||
->get(['r.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.status as file_status', 'd.data_classification', 'd.version_no as file_version', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_failure_reason']) : collect();
|
||||
$links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id');
|
||||
$revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id');
|
||||
$resources->each(function ($resource) use ($links, $revisions) {
|
||||
$resource->links = $links->get($resource->id, collect())->values();
|
||||
$resource->revisions = $revisions->get($resource->id, collect())->values();
|
||||
});
|
||||
return Inertia::render('portal/manage', [
|
||||
'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
|
||||
'documentAccessOnly' => ! $editorial, 'editorialScopeOnly' => $editorial, 'resources' => $resources, 'partnerships' => [], 'territories' => [],
|
||||
'requirementCatalogue' => [], 'requirementAssociationsEnabled' => false,
|
||||
'disclosureAuthorization' => null, 'incompleteCount' => $resources->filter(fn ($r) => ! $r->description || ! $r->issuer || ! $r->source_url || $r->source_confirmation === 'unconfirmed' || $r->legal_status === 'unknown' || $r->rights_status === 'unconfirmed')->count(), 'possibleDuplicates' => $resources->whereNotNull('duplicate_of_resource_id')->count(),
|
||||
'documents' => (clone $documents)->latest()->limit(250)->get([
|
||||
'id', 'file_name', 'media_type', 'size_bytes', 'issuer', 'issued_on', 'valid_until',
|
||||
'uploaded_by', 'created_at', 'status', 'data_classification', 'version_no', 'sha256',
|
||||
'rights_status', 'malware_scan_status', 'malware_scanned_at', 'malware_failure_reason',
|
||||
'malware_engine_version', 'malware_signatures_at', 'malware_scan_attempts',
|
||||
]),
|
||||
'malwareCounts' => (clone $documents)->select('malware_scan_status', DB::raw('count(*) as total'))
|
||||
->groupBy('malware_scan_status')->pluck('total', 'malware_scan_status'),
|
||||
'malwareHealth' => $health,
|
||||
]);
|
||||
}
|
||||
$disclosureAuthorization = DB::table('audit_events')->where('source_system', 'user-instruction')
|
||||
->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1')
|
||||
->where('action', 'public_disclosure_authorized')->first(['external_id', 'occurred_at', 'after_data']);
|
||||
$disclosureAuthorizationData = $disclosureAuthorization ? json_decode($disclosureAuthorization->after_data, true) : null;
|
||||
$resources = $this->filterManagedResources(DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at'), $managementFilters)->orderByDesc('r.updated_at')->limit(200)
|
||||
->get(['r.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.status as file_status', 'd.data_classification', 'd.version_no as file_version', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_failure_reason']);
|
||||
$links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id');
|
||||
$resources->each(fn ($resource) => $resource->links = $links->get($resource->id, collect())->values());
|
||||
$revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id');
|
||||
$resources->each(fn ($resource) => $resource->revisions = $revisions->get($resource->id, collect())->values());
|
||||
|
||||
$malwareHealth = $scanner->health();
|
||||
$malwareHealth['signatures_at'] = $malwareHealth['signatures_at']?->format(DATE_ATOM);
|
||||
|
||||
return Inertia::render('portal/manage', ['resources' => $resources,
|
||||
'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
|
||||
'requirementCatalogue' => $this->requirementEvidenceEnabled()
|
||||
? collect(is_array($catalogue = config('document-requirements.catalogue', [])) ? $catalogue : [])->map(fn ($item, $key) => ['key' => $key, ...$item])->values()
|
||||
: collect(),
|
||||
'requirementAssociationsEnabled' => $this->requirementEvidenceEnabled(),
|
||||
'partnerships' => DB::table('portal_partnerships')->latest()->limit(100)->get(),
|
||||
'documents' => DB::table('documents')->where('status', 'active')->where('data_classification', 'public')->whereNull('deleted_at')->get(['id', 'file_name'])]);
|
||||
'documents' => DB::table('documents')->where('status', '!=', 'deleted')->whereNull('deleted_at')->latest()->limit(250)
|
||||
->get(['id', 'file_name', 'media_type', 'size_bytes', 'issuer', 'issued_on', 'valid_until', 'uploaded_by', 'created_at', 'status', 'data_classification', 'version_no', 'sha256', 'rights_status', 'malware_scan_status', 'malware_scanned_at', 'malware_failure_reason', 'malware_engine_version', 'malware_signatures_at', 'malware_scan_attempts']),
|
||||
'malwareHealth' => $malwareHealth,
|
||||
'disclosureAuthorization' => $disclosureAuthorizationData ? [
|
||||
'external_id' => $disclosureAuthorization->external_id,
|
||||
'occurred_at' => $disclosureAuthorization->occurred_at,
|
||||
'resources' => DB::table('portal_resources')->whereIn('id', $disclosureAuthorizationData['resource_ids'] ?? [])
|
||||
->orderBy('id')->get(['id', 'title', 'published', 'rights_status']),
|
||||
] : null,
|
||||
'malwareCounts' => DB::table('documents')->whereNull('deleted_at')->where('status', '!=', 'deleted')
|
||||
->select('malware_scan_status', DB::raw('count(*) as total'))->groupBy('malware_scan_status')->pluck('total', 'malware_scan_status'),
|
||||
'territories' => DB::table('geo_locations')->where('data_classification', 'public')->whereNull('deleted_at')
|
||||
->whereIn('location_type', ['country', 'province', 'district', 'municipality', 'site'])->orderBy('name')->get(['id', 'name', 'location_type']),
|
||||
'incompleteCount' => DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
|
||||
->where(fn ($q) => $q->whereNull('r.description')->orWhereNull('d.issuer')->orWhereNull('r.source_url')
|
||||
->orWhere('r.source_confirmation', 'unconfirmed')->orWhere('r.legal_status', 'unknown')->orWhere('r.rights_status', 'unconfirmed'))
|
||||
->count(),
|
||||
'possibleDuplicates' => DB::table('portal_resources')->whereNotNull('duplicate_of_resource_id')->count()]);
|
||||
}
|
||||
|
||||
private function filterManagedResources(Builder $query, array $filters): Builder
|
||||
{
|
||||
if (! empty($filters['q'])) {
|
||||
$term = '%'.addcslashes($filters['q'], '%_\\').'%';
|
||||
$query->where(fn ($q) => $q->where('r.title', 'ilike', $term)->orWhere('r.document_number', 'ilike', $term)->orWhere('d.issuer', 'ilike', $term)->orWhere('d.file_name', 'ilike', $term));
|
||||
}
|
||||
if (! empty($filters['state'])) {
|
||||
$query->where('r.published', $filters['state'] === 'published');
|
||||
if ($filters['state'] !== 'published') {
|
||||
$method = $filters['state'] === 'withdrawn' ? 'whereExists' : 'whereNotExists';
|
||||
$query->$method(fn ($q) => $q->selectRaw('1')->from('portal_resource_revisions as history')->whereColumn('history.resource_id', 'r.id')->whereIn('history.change_type', ['unpublished', 'withdrawn']));
|
||||
}
|
||||
}
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function contentAssociations(Request $request): array
|
||||
{
|
||||
$access = app(\App\Services\DocumentManagementAccess::class);
|
||||
$resources = $access->full($request->user()) ? DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at')
|
||||
: ($access->editorial($request->user()) ? $access->editorialResources($request->user()) : null);
|
||||
$documents = $resources ? $resources->orderBy('r.title')->get(['r.id as value', 'r.title as label', 'r.document_type']) : collect();
|
||||
return [
|
||||
'sector' => \App\Models\Sector::choices()->orderBy('name')->get(['id as value', 'name as label']),
|
||||
'territory' => app(\App\Services\InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id as value', 'name as label']),
|
||||
'opportunity' => app(PortalProfileAccess::class)->scope(\App\Models\Opportunity::query(), $request->user())->orderBy('title')->get(['id as value', 'title as label']),
|
||||
'document' => $documents,
|
||||
'procedure' => $documents->where('document_type', 'procedure')->values(),
|
||||
'stage' => collect(config('virtual-desk.steps', []))->map(fn ($step, $key) => ['value' => $key, 'label' => $step['title']])->values(),
|
||||
'activity' => app(\App\Services\EconomicReferenceCatalog::class)->activities()->map(fn ($row) => ['value' => $row->id, 'label' => $row->display_label])->values(),
|
||||
];
|
||||
}
|
||||
|
||||
public function upload(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate(['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain', 'max:10240'], 'publication_consent' => ['accepted']]);
|
||||
$this->validateManagement($request, ['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,text/csv,image/jpeg,image/png,image/webp,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.text'.(app(LegacyContent::class)->available() ? ',application/json' : ''), 'max:10240'],
|
||||
'original_source' => ['nullable', 'url:https', 'max:2000'], 'issuer' => ['nullable', 'string', 'max:180']]);
|
||||
$file = $request->file('file');
|
||||
$checksum = hash_file('sha256', $file->getRealPath());
|
||||
if (DB::table('documents')->where('sha256', $checksum)->whereNull('deleted_at')->exists()) {
|
||||
return to_route('services.manage')->withErrors(['file' => 'Já existe um ficheiro com o mesmo conteúdo. Verifique se corresponde a uma nova versão ou a um duplicado.']);
|
||||
}
|
||||
$id = (string) Str::uuid();
|
||||
$key = $file->storeAs('portal-documents', $id, 'local');
|
||||
abort_unless($key !== false, 500);
|
||||
try {
|
||||
DB::transaction(function () use ($file, $id, $key, $request) {
|
||||
DB::transaction(function () use ($file, $id, $key, $request, $checksum) {
|
||||
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['investmoz-document-content:'.$checksum]);
|
||||
abort_unless(! DB::table('documents')->where('sha256', $checksum)->whereNull('deleted_at')->exists(), 409,
|
||||
'Já existe um ficheiro com o mesmo conteúdo. Verifique se corresponde a uma nova versão ou a um duplicado.');
|
||||
DB::table('documents')->insert(['id' => $id, 'source_system' => 'portal-services',
|
||||
'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $key,
|
||||
'sha256' => hash_file('sha256', Storage::disk('local')->path($key)), 'status' => 'active', 'data_classification' => 'public']);
|
||||
'sha256' => $checksum, 'size_bytes' => $file->getSize(), 'issuer' => $request->input('issuer'),
|
||||
'original_source' => $request->input('original_source'), 'uploaded_by' => $request->user()->id,
|
||||
'status' => 'quarantined', 'data_classification' => 'internal', 'rights_status' => 'unconfirmed']);
|
||||
DB::table('audit_events')->insert(['source_system' => 'portal-services', 'entity_type' => 'documents', 'entity_id' => $id,
|
||||
'actor_user_id' => $request->user()->id, 'action' => 'public_document_uploaded']);
|
||||
'actor_user_id' => $request->user()->id, 'action' => 'document_uploaded_to_quarantine']);
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
Storage::disk('local')->delete($key);
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return back()->with('success', 'Documento carregado. Seleccione-o no catálogo e registe a fonte para publicar.');
|
||||
ScanDocumentForMalware::dispatch($id)->afterCommit();
|
||||
|
||||
return to_route('services.manage', array_filter(['intent' => in_array($request->input('intent'), ['document', 'legislation'], true) ? $request->input('intent') : null]))->with('uploaded_document_id', $id)->with('success', 'Documento recebido em armazenamento privado e quarentena. Não aparece na biblioteca pública até validação e publicação explícitas.');
|
||||
}
|
||||
|
||||
public function resource(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['document_id' => ['required', 'uuid'], 'title' => ['required', 'string', 'max:200'],
|
||||
'category' => ['required', 'string', 'max:80'], 'topic' => ['required', 'string', 'max:80'],
|
||||
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
'legislation' => ['required', 'boolean'], 'essential' => ['required', 'boolean'], 'published' => ['required', 'boolean']]);
|
||||
DB::transaction(function () use ($data, $request) {
|
||||
abort_unless(DB::table('documents')->where('id', $data['document_id'])->where('status', 'active')->where('data_classification', 'public')->whereNull('deleted_at')->lockForUpdate()->exists(), 422, 'Seleccione um documento público activo.');
|
||||
$id = DB::table('portal_resources')->insertGetId([...$data, 'approved_by' => $request->user()->id, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$this->audit($request, 'resource_registered', 'portal_resources', $id);
|
||||
$data = $this->validateResource($request);
|
||||
$id = DB::transaction(function () use ($data, $request): int {
|
||||
$document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first();
|
||||
abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.');
|
||||
abort_unless(app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403);
|
||||
$identity = $this->identityKey($data);
|
||||
if ($identity) {
|
||||
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]);
|
||||
}
|
||||
$duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->value('id') : null;
|
||||
$fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, true);
|
||||
$this->assertPublicClassificationEligible($data, $document);
|
||||
$id = DB::table('portal_resources')->insertGetId([...$fields, 'approved_by' => null, 'published' => false, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$this->syncResourceLinks($id, $data['associations'] ?? []);
|
||||
$this->recordResourceRevision($id, 'created', $request->user()->id, 1);
|
||||
DB::table('documents')->where('id', $document->id)->update(['issuer' => ($data['issuer'] ?? null) ?: $document->issuer,
|
||||
'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'],
|
||||
'data_classification' => $data['data_classification'] ?? $document->data_classification, 'updated_at' => now()]);
|
||||
$this->audit($request, $duplicate ? 'resource_registered_possible_duplicate' : 'resource_registered', 'portal_resources', $id);
|
||||
|
||||
return $id;
|
||||
});
|
||||
|
||||
return back()->with('success', 'Documento catalogado.');
|
||||
return to_route('services.manage')->with('success', 'Documento registado como rascunho privado da biblioteca. A publicação requer uma acção separada.');
|
||||
}
|
||||
|
||||
public function updateResource(Request $request, int $id): RedirectResponse
|
||||
{
|
||||
$data = $this->validateResource($request);
|
||||
$expectedVersion = (int) $request->validate(['metadata_version' => ['required', 'integer', 'min:1']])['metadata_version'];
|
||||
DB::transaction(function () use ($data, $request, $id, $expectedVersion): void {
|
||||
$resource = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($resource !== null, 404);
|
||||
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
|
||||
abort_if((int) $resource->metadata_version !== $expectedVersion, 409, 'O documento foi alterado por outra sessão. Recarregue a revisão antes de editar.');
|
||||
$document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first();
|
||||
abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.');
|
||||
abort_unless(app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403);
|
||||
$identity = $this->identityKey($data);
|
||||
if ($identity) {
|
||||
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]);
|
||||
}
|
||||
$duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->where('id', '!=', $id)->value('id') : null;
|
||||
$fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, false);
|
||||
$this->assertPublicClassificationEligible($data, $document);
|
||||
$retainPublication = (bool) $resource->published && $data['rights_status'] === 'authorized'
|
||||
&& $data['source_confirmation'] !== 'unconfirmed' && ! empty($data['source_url'])
|
||||
&& (! ($data['legislation'] || $data['document_type'] === 'legislation') || $data['source_confirmation'] === 'official_confirmed')
|
||||
&& $document->status === 'active' && ($data['data_classification'] ?? $document->data_classification) === 'public' && $document->rights_status === 'authorized'
|
||||
&& $document->malware_scan_status === 'clean' && hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256));
|
||||
DB::table('portal_resources')->where('id', $id)->update([...$fields, 'approved_by' => $retainPublication ? $resource->approved_by : null, 'published' => $retainPublication,
|
||||
'metadata_version' => $expectedVersion + 1, 'updated_at' => now()]);
|
||||
DB::table('portal_resource_links')->where('resource_id', $id)->delete();
|
||||
$this->syncResourceLinks($id, $data['associations'] ?? []);
|
||||
$this->recordResourceRevision($id, 'metadata_updated', $request->user()->id, $expectedVersion + 1);
|
||||
DB::table('documents')->where('id', $document->id)->update(['issuer' => ($data['issuer'] ?? null) ?: $document->issuer,
|
||||
'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'],
|
||||
'data_classification' => $data['data_classification'] ?? $document->data_classification, 'updated_at' => now()]);
|
||||
$this->audit($request, 'resource_metadata_updated', 'portal_resources', $id);
|
||||
});
|
||||
|
||||
return to_route('services.manage')->with('success', 'Metadados e relações actualizados; o estado de publicação foi preservado.');
|
||||
}
|
||||
|
||||
public function moderate(Request $request, string $type, int $id): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['published' => ['required', 'boolean']]);
|
||||
$access = app(\App\Services\DocumentManagementAccess::class);
|
||||
if (! $access->full($request->user())) {
|
||||
abort_unless($type === 'resources', 403);
|
||||
$access->assertEditableResource($request->user(), $id);
|
||||
}
|
||||
if ($type === 'partnerships' && Schema::hasColumn('portal_partnerships', 'partnership_type')) {
|
||||
return redirect('/parcerias/'.$id)->with('success', 'Reveja a proposta e registe uma decisão fundamentada na ficha da Bolsa.');
|
||||
}
|
||||
$data = $this->validateManagement($request, ['published' => ['required', 'boolean']]);
|
||||
$table = $type === 'resources' ? 'portal_resources' : 'portal_partnerships';
|
||||
DB::transaction(function () use ($request, $data, $table, $type, $id) {
|
||||
abort_unless(DB::table($table)->where('id', $id)->lockForUpdate()->first() !== null, 404);
|
||||
if ($type === 'resources') app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
|
||||
if ($type === 'resources' && $data['published']) {
|
||||
$resource = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
|
||||
->where('r.id', $id)->first(['r.document_type', 'r.legislation', 'r.source_confirmation', 'r.legal_status', 'r.rights_status',
|
||||
'r.source_url', 'd.status', 'd.data_classification', 'd.deleted_at', 'd.rights_status as file_rights_status',
|
||||
'd.malware_scan_status', 'd.malware_scanned_sha256', 'd.sha256']);
|
||||
abort_unless($resource !== null && $resource->deleted_at === null, 404);
|
||||
if ($resource->status !== 'active') {
|
||||
throw $this->managementValidationException(['published' => 'A análise técnica ainda não libertou o ficheiro da quarentena. Aguarde a análise antimalware antes de publicar.']);
|
||||
}
|
||||
if ($resource->data_classification !== 'public') {
|
||||
throw $this->managementValidationException(['published' => 'O ficheiro já saiu da quarentena técnica, mas continua classificado como interno. Reveja os direitos e a possibilidade de divulgação; só então altere explicitamente a classificação para pública.']);
|
||||
}
|
||||
if ($resource->rights_status !== 'authorized' || $resource->file_rights_status !== 'authorized') {
|
||||
throw $this->managementValidationException(['published' => 'Confirme e registe os direitos de utilização antes de publicar.']);
|
||||
}
|
||||
if ($resource->source_confirmation === 'unconfirmed' || ! $resource->source_url) {
|
||||
throw $this->managementValidationException(['published' => 'Identifique e confirme a fonte documental antes de publicar.']);
|
||||
}
|
||||
if ($resource->malware_scan_status !== 'clean'
|
||||
|| ! hash_equals(trim($resource->sha256), trim((string) $resource->malware_scanned_sha256))) {
|
||||
throw $this->managementValidationException(['published' => 'A análise antimalware tem de terminar antes da publicação.']);
|
||||
}
|
||||
}
|
||||
DB::table($table)->where('id', $id)->update($type === 'resources'
|
||||
? ['published' => $data['published'], 'approved_by' => $request->user()->id, 'updated_at' => now()]
|
||||
? ['published' => $data['published'], 'approved_by' => $data['published'] ? $request->user()->id : null,
|
||||
'last_reviewed_by' => $request->user()->id, 'last_reviewed_at' => now(), 'metadata_version' => DB::raw('metadata_version + 1'), 'updated_at' => now()]
|
||||
: ['status' => $data['published'] ? 'published' : 'withdrawn', 'reviewed_by' => $request->user()->id, 'updated_at' => now()]);
|
||||
if ($type === 'resources') {
|
||||
$this->recordResourceRevision($id, $data['published'] ? 'published' : 'unpublished', $request->user()->id,
|
||||
(int) DB::table('portal_resources')->where('id', $id)->value('metadata_version'));
|
||||
}
|
||||
$this->audit($request, $data['published'] ? 'published' : 'withdrawn', $table, $id);
|
||||
});
|
||||
|
||||
return back()->with('success', 'Publicação actualizada.');
|
||||
return to_route('services.manage')->with('success', 'Publicação actualizada.');
|
||||
}
|
||||
|
||||
private function audit(Request $request, string $action, string $table, int $id): void
|
||||
public function applyDocumentDisclosureAuthorization(Request $request): RedirectResponse
|
||||
{
|
||||
$event = DB::table('audit_events')->where('source_system', 'user-instruction')
|
||||
->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1')
|
||||
->where('action', 'public_disclosure_authorized')->first();
|
||||
abort_unless($event !== null, 404);
|
||||
$authorization = json_decode($event->after_data, true);
|
||||
$resourceIds = array_values(array_unique(array_map('intval', $authorization['resource_ids'] ?? [])));
|
||||
abort_unless(count($resourceIds) === 4, 409, 'O âmbito da autorização registada foi alterado; interrompida a publicação.');
|
||||
|
||||
$published = [];
|
||||
$skipped = [];
|
||||
DB::transaction(function () use ($request, $event, $resourceIds, &$published, &$skipped): void {
|
||||
foreach ($resourceIds as $resourceId) {
|
||||
$resource = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
|
||||
->where('r.id', $resourceId)->lockForUpdate()->first([
|
||||
'r.id', 'r.title', 'r.document_type', 'r.legislation', 'r.source_url', 'r.source_confirmation',
|
||||
'r.rights_status as resource_rights', 'r.published', 'r.approved_by', 'r.metadata_version',
|
||||
'd.id as document_id', 'd.storage_key', 'd.sha256', 'd.status', 'd.deleted_at', 'd.rights_status as file_rights',
|
||||
'd.malware_scan_status', 'd.malware_scanned_sha256', 'd.data_classification',
|
||||
]);
|
||||
if ($resource === null) {
|
||||
$skipped[$resourceId] = 'Registo ou ficheiro inexistente.';
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($resource->published && $resource->data_classification === 'public' && $resource->resource_rights === 'authorized' && $resource->file_rights === 'authorized') {
|
||||
$published[$resourceId] = 'Já publicado.';
|
||||
|
||||
continue;
|
||||
}
|
||||
$path = Storage::disk('local')->path($resource->storage_key);
|
||||
$root = realpath(Storage::disk('local')->path(''));
|
||||
$realPath = realpath($path);
|
||||
$actualHash = $realPath && is_file($realPath) && $root
|
||||
&& str_starts_with(strtolower($realPath), strtolower($root.DIRECTORY_SEPARATOR))
|
||||
? hash_file('sha256', $realPath) : false;
|
||||
if ($resource->deleted_at !== null || $resource->status !== 'active'
|
||||
|| $resource->malware_scan_status !== 'clean' || ! is_string($actualHash)
|
||||
|| ! hash_equals((string) $resource->sha256, $actualHash)
|
||||
|| ! hash_equals((string) $resource->sha256, (string) $resource->malware_scanned_sha256)) {
|
||||
$skipped[$resourceId] = 'Ficheiro indisponível, não limpo ou diferente dos bytes analisados.';
|
||||
|
||||
continue;
|
||||
}
|
||||
if (in_array($resource->resource_rights, ['restricted'], true) || in_array($resource->file_rights, ['restricted'], true)
|
||||
|| $resource->source_confirmation === 'unconfirmed' || ! filter_var($resource->source_url, FILTER_VALIDATE_URL)
|
||||
|| parse_url($resource->source_url, PHP_URL_SCHEME) !== 'https') {
|
||||
$skipped[$resourceId] = 'Restrição de divulgação ou origem identificada em falta.';
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$nextVersion = (int) $resource->metadata_version + 1;
|
||||
DB::table('documents')->where('id', $resource->document_id)->update([
|
||||
'rights_status' => 'authorized', 'data_classification' => 'public', 'updated_at' => $now,
|
||||
]);
|
||||
DB::table('portal_resources')->where('id', $resourceId)->update([
|
||||
'rights_status' => 'authorized', 'published' => true, 'approved_by' => $request->user()->id,
|
||||
'last_reviewed_by' => $request->user()->id, 'last_reviewed_at' => $now,
|
||||
'metadata_version' => $nextVersion, 'updated_at' => $now,
|
||||
]);
|
||||
$this->attachAuthorizedDocumentaryEvidence($resourceId, $resource->title);
|
||||
$this->recordResourceRevision($resourceId, 'published', $request->user()->id, $nextVersion);
|
||||
$this->audit($request, 'published_under_user_authorization', 'portal_resources', $resourceId, [
|
||||
'authorization_source_system' => $event->source_system,
|
||||
'authorization_external_id' => $event->external_id,
|
||||
'source_confirmation' => $resource->source_confirmation,
|
||||
'legal_status_preserved' => true,
|
||||
]);
|
||||
$published[$resourceId] = 'Publicado com a situação de fonte e vigência já registada.';
|
||||
}
|
||||
});
|
||||
|
||||
return to_route('services.manage')->with('success', count($published).' documento(s) disponibilizado(s); '.count($skipped).' pendência(s) individual(is).')
|
||||
->with('disclosureResults', ['published' => $published, 'skipped' => $skipped]);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $additional */
|
||||
private function audit(Request $request, string $action, string $table, int $id, array $additional = []): void
|
||||
{
|
||||
DB::table('audit_events')->insert(['source_system' => 'portal-services', 'entity_type' => $table,
|
||||
'actor_user_id' => $request->user()->id, 'action' => $action, 'after_data' => json_encode(['record_id' => $id], JSON_THROW_ON_ERROR)]);
|
||||
'actor_user_id' => $request->user()->id, 'action' => $action,
|
||||
'after_data' => json_encode(['record_id' => $id, ...$additional], JSON_THROW_ON_ERROR)]);
|
||||
}
|
||||
|
||||
private function attachAuthorizedDocumentaryEvidence(int $resourceId, string $title): void
|
||||
{
|
||||
if (! $this->requirementEvidenceEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$evidence = match (true) {
|
||||
str_contains($title, 'Lei n.º 22/2022') => [['fiscal.iva.lei_22_2022', 'confirmed', 'Lei n.º 22/2022, art. 17(1); citação conferida no exemplar cujo SHA-256 coincide com o ficheiro oficial. Isto é evidência documental, não valida vigência ou cálculo.']],
|
||||
str_contains($title, 'BR 248/2025') || str_contains($title, 'n.º 248/2025') => array_map(fn (array $law) => ['fiscal.'.$law[0].'.'.$law[1], 'pending', 'BR n.º 248/2025, Suplemento n.º 1, '.$law[2].'; artigo/dispositivo específico não conferido nesta associação. Evidência identificada, não fundamento confirmado.'], [
|
||||
['ice', 'lei_7_2025', 'Lei n.º 7/2025'], ['customs', 'lei_8_2025', 'Lei n.º 8/2025'],
|
||||
['ispc', 'lei_9_2025', 'Lei n.º 9/2025'], ['iva', 'lei_10_2025', 'Lei n.º 10/2025'],
|
||||
['irps', 'lei_11_2025', 'Lei n.º 11/2025'], ['irpc', 'lei_12_2025', 'Lei n.º 12/2025'],
|
||||
]),
|
||||
str_contains($title, 'Lei n.º 13/2016') || str_contains($title, 'BR n.º 89') => [['fiscal.iva.lei_13_2016_rectification_2017', 'pending', 'BR n.º 89/2017, I Série, p. 569, aviso relativo à Lei n.º 13/2016; a correspondência do exemplar local e os dispositivos rectificados aguardam conferência. Evidência identificada, não fundamento confirmado.']],
|
||||
default => [],
|
||||
};
|
||||
|
||||
foreach ($evidence as [$key, $reviewStatus, $basis]) {
|
||||
if (! array_key_exists($key, config('document-requirements.catalogue', []))) {
|
||||
continue;
|
||||
}
|
||||
$exists = DB::table('portal_resource_links')->where('resource_id', $resourceId)
|
||||
->where('target_type', 'requirement')->where('target_key', $key)->exists();
|
||||
if ($exists) {
|
||||
continue;
|
||||
}
|
||||
DB::table('portal_resource_links')->insert([
|
||||
'resource_id' => $resourceId, 'target_type' => 'requirement', 'target_key' => $key,
|
||||
'relationship' => 'cites', 'applicability' => 'evidence', 'basis_reference' => $basis,
|
||||
'review_status' => $reviewStatus, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function validateResource(Request $request): array
|
||||
{
|
||||
$data = $this->validateManagement($request, [
|
||||
'document_id' => ['required', 'uuid'], 'title' => ['required', 'string', 'max:200'],
|
||||
'description' => ['nullable', 'string', 'max:4000'], 'category' => ['required', 'string', 'max:80'],
|
||||
// QA legacy originals may have no theme (e.g. a shared image). Do not invent a sector to disclose an authorized original.
|
||||
'topic' => [app(LegacyContent::class)->available() ? 'nullable' : 'required', 'string', 'max:80'],
|
||||
'document_type' => ['required', 'in:legislation,procedure,official_form,digital_adaptation,guidance_template,guide,study,reference_data,unknown'],
|
||||
'issuer' => ['nullable', 'string', 'max:180'], 'document_number' => ['nullable', 'string', 'max:120'],
|
||||
'document_date' => ['nullable', 'date_format:Y-m-d'], 'source_url' => ['nullable', 'url:https', 'max:2000'],
|
||||
'applicable_from' => ['nullable', 'date_format:Y-m-d'],
|
||||
'applicable_until' => ['nullable', 'date_format:Y-m-d'],
|
||||
'language' => ['required', 'string', 'max:12'], 'version_label' => ['nullable', 'string', 'max:120'],
|
||||
'document_group_id' => ['nullable', 'uuid'], 'territorial_scope' => ['required', 'in:national,provincial,district,locality,multiple,unknown'],
|
||||
'territory_id' => ['nullable', 'uuid', 'exists:geo_locations,id'],
|
||||
'source_confirmation' => ['required', 'in:unconfirmed,identified,official_confirmed'],
|
||||
'legal_status' => ['required', 'in:not_applicable,in_force,historical,proposed,repealed,amended,unknown'],
|
||||
'rights_status' => ['required', 'in:unconfirmed,authorized,restricted'], 'independent_review' => ['required', 'boolean'],
|
||||
'data_classification' => ['nullable', 'in:internal,public'],
|
||||
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
'legislation' => ['required', 'boolean'], 'essential' => ['required', 'boolean'],
|
||||
'associations' => ['nullable', 'array', 'max:40'], 'associations.*.target_type' => ['required', 'in:theme,module,activity,stage,procedure,entity,territory,document,investment_type,legal_source,sector,opportunity'.($this->requirementEvidenceEnabled() ? ',requirement' : '')],
|
||||
'associations.*.target_key' => ['required', 'string', 'max:180'],
|
||||
'associations.*.review_status' => ['nullable', 'in:pending,confirmed'],
|
||||
'associations.*.relationship' => ['required', 'in:applies_to,supports,version_of,translation_of,amends,repeals,implements,cites,related_to'],
|
||||
'associations.*.applicability' => ['required', 'in:required,conditional,support,optional_template,evidence'],
|
||||
'associations.*.condition_text' => ['nullable', 'string', 'max:2000'], 'associations.*.basis_reference' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
$data['topic'] = $data['topic'] ?? '';
|
||||
if (! empty($data['applicable_from']) && ! empty($data['applicable_until']) && $data['applicable_until'] < $data['applicable_from']) {
|
||||
throw $this->managementValidationException(['applicable_until' => 'A data final deve ser igual ou posterior à data inicial.']);
|
||||
}
|
||||
foreach ($data['associations'] ?? [] as $index => $association) {
|
||||
if (($association['target_type'] ?? null) === 'requirement' && ! $this->requirementEvidenceEnabled()) {
|
||||
throw $this->managementValidationException(["associations.$index.target_type" => 'As associações documentais a requisitos ainda aguardam a migração do catálogo.']);
|
||||
}
|
||||
if (($association['target_type'] ?? null) === 'requirement'
|
||||
&& ! array_key_exists($association['target_key'], config('document-requirements.catalogue', []))) {
|
||||
throw $this->managementValidationException(["associations.$index.target_key" => 'Seleccione um identificador técnico de requisito existente.']);
|
||||
}
|
||||
if (mb_strlen(trim($association['basis_reference'] ?? '')) < 5) {
|
||||
throw $this->managementValidationException(["associations.$index.basis_reference" => 'Indique o artigo, secção, página ou outro trecho que esta associação sustenta.']);
|
||||
}
|
||||
if ($association['applicability'] === 'conditional' && mb_strlen(trim($association['condition_text'] ?? '')) < 5) {
|
||||
throw $this->managementValidationException(["associations.$index.condition_text" => 'Descreva a condição de aplicação.']);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** Keep validation redirects on the management GET page even if the request
|
||||
* arrived with a stale Referer pointing at a write-only resource endpoint.
|
||||
*
|
||||
* @param array<string,mixed> $rules
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function validateManagement(Request $request, array $rules): array
|
||||
{
|
||||
try {
|
||||
return $request->validate($rules);
|
||||
} catch (ValidationException $exception) {
|
||||
throw $this->managementValidationException($exception->errors());
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,string|list<string>> $errors */
|
||||
private function managementValidationException(array $errors): ValidationException
|
||||
{
|
||||
$exception = ValidationException::withMessages($errors);
|
||||
$exception->redirectTo = route('services.manage');
|
||||
|
||||
return $exception;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data
|
||||
* @return array<string,mixed> */
|
||||
private function resourceFields(array $data, int $actor, ?string $identity, ?int $duplicate, bool $creating): array
|
||||
{
|
||||
unset($data['associations'], $data['issuer'], $data['data_classification']);
|
||||
$data['document_identity_key'] = $identity;
|
||||
$data['duplicate_of_resource_id'] = $duplicate;
|
||||
$data['last_reviewed_at'] = $data['source_confirmation'] !== 'unconfirmed' || $data['rights_status'] === 'authorized' ? now() : null;
|
||||
$data['last_reviewed_by'] = $actor;
|
||||
if ($creating) {
|
||||
$data['created_by'] = $actor;
|
||||
} else {
|
||||
unset($data['metadata_version']);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $links */
|
||||
private function syncResourceLinks(int $resourceId, array $links): void
|
||||
{
|
||||
foreach ($links as $link) {
|
||||
if ($link['target_type'] === 'document') {
|
||||
abort_unless(DB::table('portal_resources')->where('id', $link['target_key'])->exists(), 422, 'A relação documental seleccionada não existe.');
|
||||
}
|
||||
DB::table('portal_resource_links')->insert([
|
||||
'resource_id' => $resourceId, 'target_type' => $link['target_type'], 'target_key' => $link['target_key'],
|
||||
'relationship' => $link['relationship'], 'applicability' => $link['applicability'],
|
||||
'condition_text' => $link['condition_text'] ?? null, 'basis_reference' => $link['basis_reference'] ?? null,
|
||||
...($this->requirementEvidenceEnabled() ? ['review_status' => $link['review_status'] ?? 'pending'] : []),
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
private function assertPublicClassificationEligible(array $data, \stdClass $document): void
|
||||
{
|
||||
if (($data['data_classification'] ?? $document->data_classification) !== 'public') {
|
||||
return;
|
||||
}
|
||||
|
||||
abort_unless($document->status === 'active' && $document->malware_scan_status === 'clean'
|
||||
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 422,
|
||||
'A análise antimalware e a integridade do ficheiro têm de estar confirmadas antes de o classificar como público.');
|
||||
abort_unless($data['rights_status'] === 'authorized', 422,
|
||||
'Registe a autorização de utilização antes de classificar o ficheiro como público.');
|
||||
abort_unless($data['source_confirmation'] !== 'unconfirmed' && ! empty($data['source_url']), 422,
|
||||
'Identifique e confirme a fonte antes de classificar o ficheiro como público.');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
private function identityKey(array $data): ?string
|
||||
{
|
||||
if (empty($data['issuer']) || empty($data['document_number'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return hash('sha256', mb_strtolower(implode('|', [trim($data['issuer']), trim($data['document_number']),
|
||||
$data['document_date'] ?? '', $data['language'], $data['version_label'] ?? ''])));
|
||||
}
|
||||
|
||||
private function recordResourceRevision(int $id, string $changeType, int $actor, int $revision): void
|
||||
{
|
||||
$resource = DB::table('portal_resources')->where('id', $id)->first();
|
||||
$links = DB::table('portal_resource_links')->where('resource_id', $id)->orderBy('id')->get();
|
||||
DB::table('portal_resource_revisions')->insert(['resource_id' => $id, 'revision_no' => $revision, 'change_type' => $changeType,
|
||||
'snapshot' => json_encode(['resource' => $resource, 'associations' => $links], JSON_THROW_ON_ERROR),
|
||||
'actor_user_id' => $actor, 'created_at' => now()]);
|
||||
}
|
||||
|
||||
private function requirementEvidenceEnabled(): bool
|
||||
{
|
||||
return Schema::hasColumn('portal_resource_links', 'review_status')
|
||||
&& Schema::hasTable('migrations')
|
||||
&& DB::table('migrations')->where('migration', '2026_10_06_200000_add_document_requirement_evidence_links')->exists();
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function resourceLinkColumns(): array
|
||||
{
|
||||
$columns = ['target_type', 'target_key', 'relationship', 'applicability', 'condition_text', 'basis_reference'];
|
||||
if (Schema::hasColumn('portal_resource_links', 'review_status')) {
|
||||
$columns[] = 'review_status';
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function territoryScopeIds(string $id): array
|
||||
{
|
||||
$rows = DB::select(<<<'SQL'
|
||||
WITH RECURSIVE territory_tree AS (
|
||||
SELECT id FROM geo_locations WHERE id = ? AND deleted_at IS NULL AND data_classification = 'public'
|
||||
UNION ALL
|
||||
SELECT child.id FROM geo_locations child
|
||||
INNER JOIN territory_tree parent ON child.parent_id = parent.id
|
||||
WHERE child.deleted_at IS NULL AND child.data_classification = 'public'
|
||||
)
|
||||
SELECT id FROM territory_tree
|
||||
SQL, [$id]);
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
if (! is_string($row->id)) {
|
||||
throw new \RuntimeException('Territory identifier must be a string.');
|
||||
}
|
||||
$ids[] = $row->id;
|
||||
}
|
||||
|
||||
return $ids ?: [$id];
|
||||
}
|
||||
private function articleUrl(int $id): ?string
|
||||
{
|
||||
if (!Schema::hasColumn('portal_resources', 'editorial_content')) return null;
|
||||
$resource = DB::table('portal_resources')->find($id);
|
||||
if (!$resource) return null;
|
||||
if ($resource->editorial_content !== null) return '/artigos/'.$id;
|
||||
try {
|
||||
return app(\App\Services\EditorialReading::class)->original($resource) ? '/artigos/'.$resource->id : null;
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\HttpException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Procurement;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ProcurementController extends Controller
|
||||
{
|
||||
private const STATE_SQL = "CASE WHEN v.payload->>'procedural_state' IN ('cancelled','results') THEN v.payload->>'procedural_state' WHEN ((v.payload->>'deadline')::timestamp AT TIME ZONE (v.payload->>'timezone'))<=CURRENT_TIMESTAMP THEN 'deadline_ended' WHEN v.payload->>'extended'='true' THEN 'extended' ELSE 'open' END";
|
||||
|
||||
private function query(Procurement $service)
|
||||
{
|
||||
abort_unless($service->available(), 404);
|
||||
|
||||
return DB::table('portal_tenders as t')->join('portal_tender_versions as v', fn ($q) => $q->on('v.id', '=', 't.published_version_id')->on('v.tender_id', '=', 't.id'))->whereNotNull('v.published_at');
|
||||
}
|
||||
|
||||
public function index(Request $request, Procurement $service)
|
||||
{
|
||||
$filters = $request->validate(['q' => 'nullable|string|max:150', 'entity' => 'nullable|uuid', 'sector' => 'nullable|integer', 'territory' => 'nullable|uuid', 'modality' => 'nullable|string|max:200', 'financier' => 'nullable|string|max:200', 'state' => 'nullable|in:open,extended,deadline_ended,cancelled,results', 'deadline_before' => 'nullable|date_format:Y-m-d', 'regime' => 'nullable|in:state,external,private,other']);
|
||||
$filters = array_filter($filters, fn ($value) => $value !== null && $value !== '');
|
||||
$query = $this->query($service);
|
||||
if (! empty($filters['q'])) {
|
||||
$query->where(fn ($q) => $q->whereRaw("v.payload->>'title' ILIKE ?", ['%'.$filters['q'].'%'])->orWhereRaw("v.payload->>'object' ILIKE ?", ['%'.$filters['q'].'%'])->orWhereRaw("v.payload->>'reference' ILIKE ?", ['%'.$filters['q'].'%']));
|
||||
}
|
||||
foreach (['entity' => 'organization_id', 'sector' => 'sector_id', 'modality' => 'modality', 'financier' => 'financier', 'regime' => 'regime'] as $filter => $field) {
|
||||
if (! empty($filters[$filter])) {
|
||||
$query->whereRaw('v.payload->>? = ?', [$field, (string) $filters[$filter]]);
|
||||
}
|
||||
}
|
||||
if (! empty($filters['territory'])) {
|
||||
$query->whereRaw("v.payload->>'territory_id' IN (WITH RECURSIVE scope(id,depth) AS (SELECT id,0 FROM geo_locations WHERE id=? AND deleted_at IS NULL UNION ALL SELECT c.id,s.depth+1 FROM geo_locations c JOIN scope s ON c.parent_id=s.id WHERE c.deleted_at IS NULL AND s.depth<4) SELECT id::text FROM scope)", [$filters['territory']]);
|
||||
}
|
||||
if (! empty($filters['state'])) {
|
||||
$query->whereRaw(self::STATE_SQL.' = ?', [$filters['state']]);
|
||||
}
|
||||
if (! empty($filters['deadline_before'])) {
|
||||
$query->whereRaw("left(v.payload->>'deadline',10)<=?", [$filters['deadline_before']]);
|
||||
}
|
||||
$rows = $query->select('t.id', 'v.id as vid')->orderByDesc('v.published_at')->paginate(20)->withQueryString();
|
||||
$rows->through(fn ($r) => ['id' => $r->id, ...$this->summary($service, $r->vid)]);
|
||||
$facets = $this->query($service)->get(['v.payload'])->map(fn ($r) => json_decode($r->payload, true));
|
||||
$options = [];
|
||||
foreach (['entity' => ['organization_id', 'organization_name'], 'sector' => ['sector_id', 'sector_name'], 'territory' => ['territory_id', 'territory_name'], 'modality' => ['modality', 'modality'], 'financier' => ['financier', 'financier']] as $name => [$value,$label]) {
|
||||
$options[$name] = $facets->filter(fn ($p) => ! empty($p[$value]))->map(fn ($p) => ['id' => (string) $p[$value], 'name' => $p[$label]])->unique('id')->values();
|
||||
}
|
||||
|
||||
return Inertia::render('procurement/index', ['items' => $rows, 'filters' => $filters, 'options' => $options, 'regimes' => config('procurement.regimes'), 'management' => $service->access($request->user())])->toResponse($request)->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
private function summary(Procurement $service, string $vid): array
|
||||
{
|
||||
$v = $service->publishedVersion($vid);
|
||||
|
||||
return ['publication' => $v, 'state' => $service->state($v)];
|
||||
}
|
||||
|
||||
public function show(Request $request, string $id, Procurement $service)
|
||||
{
|
||||
return $this->detail($request, $id, $service, false);
|
||||
}
|
||||
|
||||
public function print(Request $request, string $id, Procurement $service)
|
||||
{
|
||||
return $this->detail($request, $id, $service, true);
|
||||
}
|
||||
|
||||
private function detail(Request $request, string $id, Procurement $service, bool $print)
|
||||
{
|
||||
$row = $this->query($service)->where('t.id', $id)->firstOrFail(['t.id', 'v.id as vid']);
|
||||
$filters = $request->only(['q', 'entity', 'sector', 'territory', 'modality', 'financier', 'state', 'deadline_before', 'regime', 'page']);
|
||||
|
||||
return Inertia::render('procurement/show', ['id' => $id, ...$this->summary($service, $row->vid), 'printMode' => $print, 'backUrl' => '/concursos'.($filters ? '?'.http_build_query($filters) : ''),
|
||||
'history' => DB::table('portal_tender_versions')->where('tender_id', $id)->whereNotNull('published_at')->orderBy('version')->pluck('id')->map(fn ($vid) => $service->publishedVersion($vid)),
|
||||
'watching' => $request->user() && DB::table('portal_tender_watches')->where('tender_id', $id)->where('user_id', $request->user()->id)->exists()])->toResponse($request)->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function manage(Request $request, Procurement $service)
|
||||
{
|
||||
$service->guard($request->user());
|
||||
|
||||
return Inertia::render('procurement/manage', ['items' => DB::table('portal_tenders')->orderByDesc('updated_at')->get(['id', 'draft', 'version', 'published_version_id'])->map(fn ($r) => ['id' => $r->id, 'title' => json_decode($r->draft, true)['title'] ?? 'Rascunho sem título', 'version' => $r->version, 'published' => (bool) $r->published_version_id])])->toResponse($request)->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function edit(Request $request, Procurement $service, ?string $id = null)
|
||||
{
|
||||
$service->guard($request->user());
|
||||
$r = $id ? $service->record($id) : null;
|
||||
|
||||
return Inertia::render('procurement/edit', ['record' => $r ? ['id' => $id, 'version' => $r->version, 'draft' => json_decode($r->draft, true), 'published' => (bool) $r->published_version_id] : null,
|
||||
'references' => $service->references($request->user()), 'history' => $id ? DB::table('portal_tender_versions')->where('tender_id', $id)->orderByDesc('version')->get(['version', 'act', 'reason', 'actor_id', 'published_at', 'created_at']) : []])->toResponse($request)->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function save(Request $request, Procurement $service, ?string $id = null)
|
||||
{
|
||||
$id = $service->save($request->user(), $id, $request->all());
|
||||
|
||||
return redirect('/management/concursos/'.$id)->with('success', 'Rascunho guardado. A publicação anterior permanece intacta.');
|
||||
}
|
||||
|
||||
public function preview(Request $request, string $id, Procurement $service)
|
||||
{
|
||||
return response()->json($service->preview($request->user(), $id, (string) $request->input('act')))->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function publish(Request $request, string $id, Procurement $service)
|
||||
{
|
||||
$service->publish($request->user(), $id, $request->all());
|
||||
|
||||
return redirect('/management/concursos/'.$id)->with('success', 'Acto publicado, com versão e auditoria. Nenhuma proposta recebida pelo portal.');
|
||||
}
|
||||
|
||||
public function watch(Request $request, string $id, Procurement $service)
|
||||
{
|
||||
$row = $this->query($service)->where('t.id', $id)->firstOrFail(['v.id']);
|
||||
DB::table('portal_tender_watches')->updateOrInsert(['tender_id' => $id, 'user_id' => $request->user()->id], ['seen_version_id' => $row->id, 'created_at' => now(), 'updated_at' => now()]);
|
||||
|
||||
return back()->with('success', 'Concurso guardado para acompanhamento. Isto não apresenta uma proposta.');
|
||||
}
|
||||
|
||||
public function unwatch(Request $request, string $id, Procurement $service)
|
||||
{
|
||||
abort_unless($service->available(), 404);
|
||||
DB::table('portal_tender_watches')->where('tender_id', $id)->where('user_id', $request->user()->id)->delete();
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function watched(Request $request, Procurement $service)
|
||||
{
|
||||
$rows = $this->query($service)->join('portal_tender_watches as w', 'w.tender_id', '=', 't.id')->where('w.user_id', $request->user()->id)->get(['t.id', 'v.id as vid', 'w.seen_version_id'])->map(fn ($r) => ['id' => $r->id, 'changed' => $r->seen_version_id !== $r->vid, ...$this->summary($service, $r->vid)]);
|
||||
|
||||
return Inertia::render('procurement/manage', ['watched' => true, 'items' => $rows])->toResponse($request)->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Resources\PublicBusinessOpportunityResource;
|
||||
use App\Models\ExistingBusiness;
|
||||
use App\Services\CountryCatalogue;
|
||||
use App\Services\ExistingBusinessDrafts;
|
||||
use App\Services\ExistingBusinessClassification;
|
||||
use App\Services\OpportunityClassification;
|
||||
use App\Services\InvestmentGeography;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class PublicBusinessOpportunityController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response|JsonResponse
|
||||
{
|
||||
$filters = $this->filters($request);
|
||||
|
||||
$query = $this->publishedQuery();
|
||||
app(ExistingBusinessClassification::class)->constrain($query, $filters);
|
||||
$path = app(InvestmentGeography::class)->selection($filters);
|
||||
if ($path !== []) {
|
||||
$territoryIds = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
|
||||
$query->whereExists(fn ($locations) => $locations->selectRaw('1')->from('existing_business_locations as matching_location')
|
||||
->whereColumn('matching_location.existing_business_id', 'existing_businesses.id')->where('matching_location.is_public', true)
|
||||
->where(function ($match) use ($territoryIds): void {
|
||||
foreach (['province_id', 'district_id', 'admin_post_id', 'locality_id'] as $field) {
|
||||
$match->orWhereIn('matching_location.'.$field, $territoryIds);
|
||||
}
|
||||
}));
|
||||
}
|
||||
if (! empty($filters['q'])) {
|
||||
$term = trim($filters['q']);
|
||||
$query->where(fn (Builder $builder) => $builder->whereLike('existing_businesses.public_title', '%'.$term.'%')
|
||||
->orWhereLike('existing_businesses.activity_code', '%'.$term.'%')
|
||||
->orWhereLike('existing_businesses.objective', '%'.$term.'%'));
|
||||
}
|
||||
if (! empty($filters['sector'])) {
|
||||
$sector = \App\Models\Sector::find($filters['sector']);
|
||||
$query->whereIn('existing_businesses.sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
|
||||
}
|
||||
if (! empty($filters['modality'])) {
|
||||
$query->where('existing_businesses.modality', $filters['modality']);
|
||||
}
|
||||
|
||||
$results = PublicBusinessOpportunityResource::collection($query->paginate($filters['per_page'] ?? 12)->appends($filters));
|
||||
if ($request->expectsJson()) {
|
||||
return $results->response();
|
||||
}
|
||||
|
||||
return Inertia::render('investment/business-marketplace', ['results' => $results, 'filters' => $filters, 'territorySelection' => $path,
|
||||
'classificationOptions' => app(OpportunityClassification::class)->options(),
|
||||
'authorities' => DB::table('authorities')->where('status', 'published')->orderBy('label_pt_mz')->get(['id', 'label_pt_mz'])]);
|
||||
}
|
||||
|
||||
private function filters(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'q' => ['nullable', 'string', 'max:120'],
|
||||
'modality' => ['nullable', Rule::in(ExistingBusinessDrafts::MODALITIES)],
|
||||
'sector' => ['nullable', 'integer', 'min:1'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'],
|
||||
'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
|
||||
...app(ExistingBusinessClassification::class)->filterRules(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $slug): Response|JsonResponse
|
||||
{
|
||||
$record = $this->publishedQuery()->where('existing_businesses.public_slug', $slug)->firstOrFail();
|
||||
$resource = new PublicBusinessOpportunityResource($record);
|
||||
if ($request->expectsJson()) {
|
||||
return $resource->response();
|
||||
}
|
||||
|
||||
$returnFilters = $this->filters($request);
|
||||
|
||||
return Inertia::render('investment/business-opportunity', ['record' => $resource->resolve($request),
|
||||
'returnUrl' => $returnFilters === [] ? '/negocios' : '/negocios?'.http_build_query($returnFilters),
|
||||
'interestUrl' => '/negocios/'.rawurlencode($slug).'/manifestar-interesse'.($returnFilters === [] ? '' : '?'.http_build_query($returnFilters))]);
|
||||
}
|
||||
|
||||
public function interest(Request $request, string $slug): Response
|
||||
{
|
||||
$record = $this->publishedQuery()->where('existing_businesses.public_slug', $slug)->firstOrFail();
|
||||
|
||||
$returnFilters = $this->filters($request);
|
||||
|
||||
return Inertia::render('investment/business-interest', [
|
||||
'record' => (new PublicBusinessOpportunityResource($record))->resolve($request),
|
||||
'modalities' => ExistingBusinessDrafts::MODALITIES,
|
||||
'countries' => app(CountryCatalogue::class)->all(),
|
||||
'returnUrl' => $returnFilters === [] ? '/negocios' : '/negocios?'.http_build_query($returnFilters),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return Builder<ExistingBusiness> */
|
||||
private function publishedQuery(): Builder
|
||||
{
|
||||
$publicLocations = DB::table('existing_business_locations as locations')
|
||||
->leftJoin('geo_locations as province', 'province.id', '=', 'locations.province_id')
|
||||
->leftJoin('geo_locations as district', 'district.id', '=', 'locations.district_id')
|
||||
->leftJoin('geo_locations as locality', 'locality.id', '=', 'locations.locality_id')
|
||||
->where('locations.is_public', true)->where('locations.is_primary', true)
|
||||
->whereNull('province.deleted_at')->whereNull('district.deleted_at')->whereNull('locality.deleted_at')
|
||||
->select('locations.existing_business_id', 'province.name as province')
|
||||
->selectRaw("CASE WHEN locations.precision_level IN ('district','admin_post','locality','exact') THEN district.name ELSE NULL END AS district")
|
||||
->selectRaw('NULL::text AS locality');
|
||||
|
||||
return ExistingBusiness::query()->published()->with('sector:id,name,slug')
|
||||
->leftJoinSub($publicLocations, 'public_location', 'public_location.existing_business_id', '=', 'existing_businesses.id')
|
||||
->select(['existing_businesses.id', 'existing_businesses.public_slug', 'existing_businesses.public_title', 'existing_businesses.modality',
|
||||
'existing_businesses.activity_code', 'existing_businesses.stage', 'existing_businesses.objective',
|
||||
'existing_businesses.currency', 'existing_businesses.amount_min', 'existing_businesses.amount_max',
|
||||
'existing_businesses.available_percentage', 'existing_businesses.published_at', 'existing_businesses.sector_id',
|
||||
'existing_businesses.owner_user_id', 'existing_businesses.organization_id', 'existing_businesses.version_no', 'existing_businesses.expires_at', 'existing_businesses.status', 'existing_businesses.modality',
|
||||
'public_location.province', 'public_location.district', 'public_location.locality']);
|
||||
}
|
||||
}
|
||||
@@ -5,22 +5,35 @@ namespace App\Http\Controllers;
|
||||
use App\Services\InvestmentGeography;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PublicGeographyController extends Controller
|
||||
{
|
||||
public function options(Request $request, InvestmentGeography $geography): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['type' => ['required', 'in:province,district,admin_post,locality'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
|
||||
$data = $request->validate(['corridor' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(config('investment_corridors')))], 'type' => ['required', 'in:province,district,admin_post,locality'], 'id' => ['nullable', 'uuid'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
|
||||
$query = $geography->publicReferences()->where('location_type', $data['type']);
|
||||
if (!empty($data['corridor'])) {
|
||||
$corridors = app(\App\Services\InvestmentCorridorContext::class);
|
||||
if ($data['type'] === 'province') $query->whereIn('id', array_column($corridors->provinces($data['corridor']), 'id'));
|
||||
elseif (!$corridors->compatible($data['corridor'], $data['parent_id'] ?? null)) abort(422, 'O território não pertence ao corredor escolhido.');
|
||||
}
|
||||
if (! empty($data['id'])) {
|
||||
$query->where('id', $data['id']);
|
||||
}
|
||||
if ($data['type'] !== 'province') {
|
||||
$parentType = ['district' => 'province', 'admin_post' => 'district', 'locality' => 'admin_post'][$data['type']];
|
||||
$parent = $geography->publicReferences()->where('id', $data['parent_id'] ?? '00000000-0000-0000-0000-000000000000')->where('location_type', $parentType)->value('id');
|
||||
$query->where('parent_id', $parent ?? '00000000-0000-0000-0000-000000000000');
|
||||
}
|
||||
if (! empty($data['q'])) {
|
||||
$query->whereRaw('name ILIKE ?', ['%'.addcslashes($data['q'], '%_\\').'%']);
|
||||
$term = '%'.addcslashes(Str::ascii($data['q']), '%_\\').'%';
|
||||
$query->where(fn ($q) => $q
|
||||
->whereRaw("translate(lower(name), 'áàâãäåéèêëíìîïóòôõöúùûüçýÿ', 'aaaaaaeeeeiiiiooooouuuucyy') ILIKE ?", [$term])
|
||||
->orWhereRaw('code ILIKE ?', [$term])
|
||||
->orWhereRaw('source_code ILIKE ?', [$term]));
|
||||
}
|
||||
|
||||
return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type']));
|
||||
return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type', 'source_code'])->withQueryString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Sector;
|
||||
use App\Services\CountryCatalogue;
|
||||
use App\Services\SectorInvestment;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@@ -17,12 +19,15 @@ class PublicPortalController extends Controller
|
||||
return Inertia::render('PortalInvestidor', ['initialView' => 'opportunities', 'initialQuery' => $filters['q'] ?? '', 'initialSector' => $filters['sector'] ?? '']);
|
||||
}
|
||||
|
||||
public function sector(string $slug): Response
|
||||
public function sector(string $slug): Response|\Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$sector = Sector::where('slug', $slug)->where('is_active', true)->firstOrFail();
|
||||
if (in_array($slug, ['energia', 'fintech'], true)) return redirect(\App\Services\SectorOrganization::href($slug), 301);
|
||||
if ($slug === 'mercado-financeiro/fintech') $slug = 'fintech';
|
||||
$sector = Sector::where('slug', $slug)->where('is_active', true)->whereNotNull('published_at')->firstOrFail();
|
||||
|
||||
return Inertia::render('portal/services', ['section' => 'sector', 'sector' => $sector->only(['name', 'slug', 'description', 'short_description']),
|
||||
'opportunities' => $sector->opportunities()->published()->latest('published_at')->limit(12)->get(['title', 'slug', 'province', 'summary'])]);
|
||||
return Inertia::render('portal/services', ['section' => 'sector', 'sector' => $sector->only(['id', 'name', 'slug', 'description', 'short_description']),
|
||||
'sectorEntry' => array_key_exists($sector->slug, SectorInvestment::CONTENT) ? app(SectorInvestment::class)->publicView($sector) : null,
|
||||
'opportunities' => \App\Services\SectorOrganization::opportunities($sector)->latest('published_at')->limit(12)->get(['id', 'title', 'slug', 'province', 'summary'])]);
|
||||
}
|
||||
|
||||
public function opportunity(string $slug): Response
|
||||
@@ -32,13 +37,13 @@ class PublicPortalController extends Controller
|
||||
|
||||
public function interest(?string $slug = null): Response
|
||||
{
|
||||
return $slug ? $this->detail($slug, 'interest') : Inertia::render('PortalInvestidor', ['initialView' => 'interest']);
|
||||
return $slug ? $this->detail($slug, 'interest') : Inertia::render('PortalInvestidor', ['initialView' => 'interest', 'countries' => app(CountryCatalogue::class)->all()]);
|
||||
}
|
||||
|
||||
private function detail(string $slug, string $view): Response
|
||||
{
|
||||
$opportunity = Opportunity::with('sector:id,name,slug')->published()->where('slug', $slug)->firstOrFail();
|
||||
|
||||
return Inertia::render('PortalInvestidor', ['initialView' => $view, 'initialOpportunity' => $opportunity]);
|
||||
return Inertia::render('PortalInvestidor', ['initialView' => $view, 'initialOpportunity' => $opportunity, 'countries' => app(CountryCatalogue::class)->all()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\SectorReconciliationExecution;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class SectorReconciliationController extends Controller
|
||||
{
|
||||
public function preview(Request $request, SectorReconciliationExecution $execution)
|
||||
{
|
||||
$plan = $execution->preview($request);
|
||||
return $request->expectsJson() ? response()->json($plan)->header('Cache-Control', 'private, no-store')
|
||||
: response()->view('management.sector-reconciliation', ['plan' => $plan, 'operationKey' => (string)Str::uuid()])->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function apply(Request $request, SectorReconciliationExecution $execution)
|
||||
{
|
||||
return response()->json($execution->apply($request))->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\InvestmentLegalSource;
|
||||
use App\Services\SimulationTaxGovernanceAccess;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Explicit fiscal curation of an identified official source; not an independent legal opinion. */
|
||||
final class SimulationFiscalSourceController extends Controller
|
||||
{
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
SimulationTaxGovernanceAccess::requireRole($request->user(), 'editors');
|
||||
$data = $request->validate([
|
||||
'title' => ['required', 'string', 'max:255'], 'url' => ['required', 'url:https', 'max:2000'],
|
||||
'diploma' => ['required', 'string', 'max:255'], 'article' => ['required', 'string', 'max:255'],
|
||||
'competent_entity' => ['required', 'string', 'max:255'], 'valid_from' => ['required', 'date_format:Y-m-d'],
|
||||
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
|
||||
'sha256' => ['required', 'regex:/^[a-f0-9]{64}$/D'],
|
||||
'citation' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
'official_origin' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
]);
|
||||
|
||||
return DB::transaction(function () use ($request, $data): JsonResponse {
|
||||
$actor = $request->user();
|
||||
$acceptance = ['scope' => 'fiscal_parameters', 'basis' => 'Tempo-official-sources-20261008',
|
||||
'responsible_user_id' => $actor->id, 'sha256' => $data['sha256'], 'citation' => $data['citation'],
|
||||
'official_origin' => $data['official_origin'], 'accepted_at' => now()->toISOString(),
|
||||
'legal_opinion' => false, 'independent_review' => false];
|
||||
unset($data['sha256'], $data['citation'], $data['official_origin']);
|
||||
$source = InvestmentLegalSource::create([...$data, 'owner_id' => $actor->id, 'validator_id' => null,
|
||||
'validation_status' => 'pending', 'validated_at' => null, 'version_no' => 1, 'fiscal_acceptance' => $acceptance]);
|
||||
DB::table('audit_events')->insert(['source_system' => 'simulation-fiscal-maintenance',
|
||||
'entity_type' => 'investment_legal_sources', 'entity_id' => $source->id, 'actor_user_id' => $actor->id,
|
||||
'action' => 'official_source_acceptance', 'after_data' => json_encode([
|
||||
'source' => $source->attributesToArray(), 'basis' => $acceptance,
|
||||
'sha256' => hash('sha256', $source->toJson()),
|
||||
], JSON_THROW_ON_ERROR)]);
|
||||
|
||||
return response()->json($source->fresh(), 201);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\SimulationFiscalQaPreview;
|
||||
use App\Services\SimulationTaxParameterResolver;
|
||||
use App\Services\SimulationTaxRuleExpression;
|
||||
use App\Services\SimulationTaxRuleGovernance;
|
||||
use App\Support\IsolatedQaDatabase;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class SimulationTaxRuleGovernanceController extends Controller
|
||||
{
|
||||
public function qaReview(Request $request, string $id, SimulationTaxRuleGovernance $governance): Response
|
||||
{
|
||||
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404);
|
||||
$rule = $governance->show($request->user(), $id);
|
||||
abort_unless($rule->status === 'draft' && $rule->rule_code === 'IVA-GERAL-BASE-LIQUIDA', 422,
|
||||
'Seleccione o rascunho do primeiro lote fiscal.');
|
||||
|
||||
return Inertia::render('management/fiscal-qa-review', ['rule' => [
|
||||
'id' => $rule->id, 'code' => $rule->rule_code, 'version' => $rule->rule_version,
|
||||
]]);
|
||||
}
|
||||
|
||||
public function qaPreview(Request $request, string $id, SimulationFiscalQaPreview $preview): JsonResponse
|
||||
{
|
||||
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404);
|
||||
$key = $request->header('Idempotency-Key');
|
||||
abort_unless(is_string($key) && strlen($key) >= 16 && strlen($key) <= 128, 428, 'Idempotency-Key é obrigatório.');
|
||||
$input = $request->validate([
|
||||
'base' => ['required', 'string', 'max:40', 'regex:/^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/D'],
|
||||
'reference_date' => ['required', 'date_format:Y-m-d'],
|
||||
'currency' => ['required', 'in:MZN'],
|
||||
'operation' => ['required', 'in:internal_general_net'],
|
||||
]);
|
||||
$result = $preview->create($request->user(), $id, $input, $key);
|
||||
$simulation = $result['simulation'];
|
||||
|
||||
return response()->json(['data' => $simulation, 'replayed' => $result['replayed']], $result['replayed'] ? 200 : 202)
|
||||
->header('Location', url('/api/v1/simulations/'.$simulation->id));
|
||||
}
|
||||
|
||||
public function index(Request $request, SimulationTaxRuleGovernance $governance): JsonResponse
|
||||
{
|
||||
$filters = $request->validate([
|
||||
'rule_code' => ['nullable', 'string', 'max:64'],
|
||||
'component' => ['nullable', 'in:IRPC,IVA,ISPC,IRPS,INSS,RETENCOES,DIREITOS_ADUANEIROS,ICE'],
|
||||
'scope_key' => ['nullable', 'string', 'max:160'],
|
||||
'status' => ['nullable', 'in:draft,submitted,reviewed,rejected,approved,published,suspended,archived'],
|
||||
'per_page' => ['nullable', 'integer', 'between:1,100'],
|
||||
]);
|
||||
|
||||
return response()->json($governance->listing($request->user(), $filters));
|
||||
}
|
||||
|
||||
public function store(Request $request, SimulationTaxRuleGovernance $governance): JsonResponse
|
||||
{
|
||||
$rule = $governance->create($request->user(), $this->validatedRule($request));
|
||||
|
||||
return response()->json($rule, 201)->header('Location', url('/api/v2/investment/admin/tax-rules/'.$rule->id));
|
||||
}
|
||||
|
||||
public function show(Request $request, SimulationTaxRuleGovernance $governance, string $id): JsonResponse
|
||||
{
|
||||
return response()->json($governance->show($request->user(), $id));
|
||||
}
|
||||
|
||||
public function update(Request $request, SimulationTaxRuleGovernance $governance, string $id): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
|
||||
|
||||
return response()->json($governance->update($request->user(), $id, (int) $data['version_no'], $this->validatedRule($request)));
|
||||
}
|
||||
|
||||
public function transition(Request $request, SimulationTaxRuleGovernance $governance, string $id, string $action): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'version_no' => ['required', 'integer', 'min:1'],
|
||||
'evidence' => ['required', 'string', 'min:20', 'max:2000'],
|
||||
]);
|
||||
|
||||
return response()->json($governance->transition($request->user(), $id, (int) $data['version_no'], $action, $data['evidence']));
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function validatedRule(Request $request): array
|
||||
{
|
||||
$data = $request->validate([
|
||||
'rule_code' => ['required', 'string', 'regex:/^[A-Z0-9][A-Z0-9._-]{2,63}$/'],
|
||||
'rule_version' => ['required', 'integer', 'min:1'],
|
||||
'component' => ['required', 'in:IRPC,IVA,ISPC,IRPS,INSS,RETENCOES,DIREITOS_ADUANEIROS,ICE'],
|
||||
'scope_key' => ['required', 'string', 'min:2', 'max:160'],
|
||||
'valid_from' => ['required', 'date_format:Y-m-d'],
|
||||
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
|
||||
'legal_source_id' => ['required', 'uuid', 'exists:investment_legal_sources,id'],
|
||||
'source_sha256' => ['required', 'regex:/^[a-f0-9]{64}$/'],
|
||||
'rule_definition' => ['required', 'array'],
|
||||
'parameters' => ['required', 'array'],
|
||||
'rounding_policy' => ['required', 'array'],
|
||||
]);
|
||||
foreach (['rule_definition', 'parameters', 'rounding_policy'] as $key) {
|
||||
$json = json_encode($data[$key], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
|
||||
Validator::make(['json' => $json], ['json' => ['max:50000']])->validate();
|
||||
}
|
||||
|
||||
if (($data['rule_definition']['language'] ?? null) === 'investmoz.tax.v1') {
|
||||
try {
|
||||
app(SimulationTaxRuleExpression::class)->validate($data['rule_definition']);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
throw ValidationException::withMessages(['rule_definition' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
try {
|
||||
app(SimulationTaxParameterResolver::class)->validateParameters($data['parameters']);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
throw ValidationException::withMessages(['parameters' => $exception->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\TerritoryGeometryImport;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class TerritoryGeometryImportController extends Controller
|
||||
{
|
||||
public function preview(Request $request, TerritoryGeometryImport $service): JsonResponse|Response
|
||||
{
|
||||
$data = $request->validate(['level' => 'nullable|integer|in:1,2,3']);
|
||||
$plan = $service->preview($request->user(), (int) ($data['level'] ?? 1));
|
||||
|
||||
return $request->expectsJson() ? response()->json($plan) : Inertia::render('management/territory-geometry-import', ['plan' => $plan]);
|
||||
}
|
||||
|
||||
public function apply(Request $request, TerritoryGeometryImport $service): JsonResponse
|
||||
{
|
||||
return response()->json($service->apply($request->user(), $request->all()));
|
||||
}
|
||||
|
||||
public function reverse(Request $request, string $batch, TerritoryGeometryImport $service): JsonResponse
|
||||
{
|
||||
return response()->json($service->reverse($request->user(), $batch, $request->all()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\InvestmentSubmission;
|
||||
use App\Models\User;
|
||||
use App\Services\Ai\InvestorGuidance;
|
||||
use App\Services\CountryCatalogue;
|
||||
use App\Services\InstitutionalLinkMonitor;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\InvestorContextConsumers;
|
||||
use App\Services\VerifiedDocumentBinary;
|
||||
use App\Services\VirtualDesk;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class VirtualDeskController extends Controller
|
||||
{
|
||||
public function catalogue(Request $request, VirtualDesk $desk): JsonResponse|Response
|
||||
{
|
||||
$filters = $request->validate(['q' => 'nullable|string|max:120', 'theme' => 'nullable|string|max:80', 'profile' => 'nullable|string|max:100']);
|
||||
$installed = $desk->installed();
|
||||
$services = $installed ? DB::table('desk_service_versions')->where('status', 'published')->orderBy('code')->orderByDesc('version')->get()->unique('code')->map(fn ($s) => ['id' => $s->id, 'version' => $s->version, 'definition' => json_decode($s->definition, true), 'source' => json_decode($s->source, true)])->values() : collect();
|
||||
$services = $services->filter(fn ($s) => (! ($filters['q'] ?? '') || str_contains(mb_strtolower(json_encode($s['definition'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)), mb_strtolower($filters['q'])))
|
||||
&& (! ($filters['theme'] ?? '') || $s['definition']['theme'] === $filters['theme']) && (! ($filters['profile'] ?? '') || $s['definition']['profile'] === $filters['profile']))->values();
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$data = ['installed' => $installed, 'services' => $services->slice(($page - 1) * 12, 12)->values(), 'total' => $services->count(), 'page' => $page, 'filters' => $filters,
|
||||
'prepared' => $installed ? [] : config('virtual-desk.services'), 'source' => config('virtual-desk.source'), 'authenticated' => (bool) $request->user(), 'support' => config('portal.support')];
|
||||
|
||||
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/catalogue', $data);
|
||||
}
|
||||
|
||||
public function panel(Request $request, VirtualDesk $desk): JsonResponse|Response
|
||||
{
|
||||
$actor = $request->user();
|
||||
$organizations = DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->pluck('organization_id');
|
||||
$cases = DB::table('desk_cases')->where(fn ($q) => $q->where(fn ($p) => $p->where('user_id', $actor->id)->whereNull('organization_id'))->orWhereIn('organization_id', $organizations))->orderByDesc('updated_at')->get(['id', 'title', 'version', 'updated_at']);
|
||||
$queue = DB::table('desk_requests as r')->join('desk_cases as c', 'c.id', '=', 'r.case_id')->whereNotIn('r.status', ['draft', 'cancelled'])->when(! $actor->can('manage-portal'), fn ($q) => $q->where('r.analyst_id', $actor->id))
|
||||
->orderByRaw('r.due_at ASC NULLS LAST')->get(['r.id', 'r.protocol', 'r.status', 'r.version', 'r.analyst_id', 'r.due_at', 'c.title']);
|
||||
$data = ['cases' => $cases, 'queue' => $queue, 'notifications' => DB::table('desk_notifications')->where('user_id', $actor->id)->orderByDesc('id')->limit(100)->get(),
|
||||
'legacyRequests' => DB::table('interest_requests')->where('metadata->portal_user_id', $actor->id)->whereNull('deleted_at')->orderByDesc('created_at')->get(['id', 'reference', 'status', 'created_at']),
|
||||
'companies' => DB::table('investment_company_declarations')->where('user_id', $actor->id)->get(['id', 'payload'])->map(fn ($c) => ['id' => $c->id, 'name' => $desk->decode($c->payload)['name'] ?? 'Declaração empresarial']),
|
||||
'submissions' => InvestmentSubmission::where('user_id', $actor->id)->get()->map(fn ($s) => ['id' => $s->id, 'status' => $s->status, 'name' => ($s->opportunity_id ? DB::table('opportunities')->where('id', $s->opportunity_id)->value('title') : null) ?? ($s->dossier_id ? DB::table('investment_dossiers')->where('id', $s->dossier_id)->value('title') : null) ?? ($s->payload['organisation'] ?? null)]),
|
||||
'journeys' => DB::table('investor_journeys as j')->leftJoin('investment_dossiers as d', 'd.id', '=', 'j.dossier_id')->where('j.user_id', $actor->id)->whereNull('j.deleted_at')->get(['j.id', 'j.dossier_id', 'j.status', 'd.title as name']),
|
||||
'simulationDrafts' => DB::table('simulation_form_drafts')->where('owner_user_id', $actor->id)->whereNull('deleted_at')->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))->get(['id', 'version_no']),
|
||||
'guidanceTasks' => app(InvestorGuidance::class)->installed() ? DB::table('tasks as t')->join('desk_cases as c', 'c.id', '=', 't.desk_case_id')
|
||||
->whereNull('t.deleted_at')->when(! $actor->can('manage-portal'), fn ($q) => $q->where(fn ($p) => $p->where('t.assignee_user_id', $actor->id)->orWhere(fn ($s) => $s->where('c.user_id', $actor->id)->whereNull('c.organization_id'))->orWhereIn('c.organization_id', $organizations)))
|
||||
->orderByRaw('t.due_at ASC NULLS LAST')->get(['t.id', 't.title', 't.status', 't.due_at', 'c.id as case_id', 'c.title as case_title']) : [],
|
||||
'organizations' => DB::table('organizations')->whereIn('id', $organizations)->get(['id', 'name']), 'manager' => $actor->can('manage-portal'), 'states' => config('virtual-desk.request_states')];
|
||||
|
||||
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/panel', $data);
|
||||
}
|
||||
|
||||
public function createCase(Request $request, VirtualDesk $desk): JsonResponse|RedirectResponse
|
||||
{
|
||||
$case = $desk->createCase($request->user(), $request->all());
|
||||
|
||||
return $request->expectsJson() ? response()->json(['id' => $case->id, 'version' => $case->version], 201) : redirect('/balcao-virtual/dossiers/'.$case->id)
|
||||
->with('success', 'Dossier guardado e disponível para continuar. Próxima ação: escolha um serviço publicado e prepare o rascunho de apoio. Nenhum pedido foi submetido.');
|
||||
}
|
||||
|
||||
public function showCase(Request $request, string $id, VirtualDesk $desk): JsonResponse|Response
|
||||
{
|
||||
$case = $desk->case($request->user(), $id);
|
||||
$owner = $desk->owns($request->user(), $case, false);
|
||||
$this->auditRead($request, 'desk_cases', $id);
|
||||
$hidden = $owner ? ['_creation_hash'] : ['_creation_hash', 'profile', 'company', 'investment', 'name', 'email'];
|
||||
if ((int) $case->user_id !== $request->user()->id) {
|
||||
$hidden[] = 'investor_path';
|
||||
}
|
||||
$data = ['case' => [...(array) $case, 'payload' => Arr::except($desk->decode($case->payload), $hidden), 'workflow' => json_decode($case->workflow, true)],
|
||||
'legacyRequest' => ($case->interest_request_id ?? null) ? DB::table('interest_requests as r')->leftJoin('users as u', 'u.id', '=', 'r.assigned_to')->where('r.id', $case->interest_request_id)->whereNull('r.deleted_at')
|
||||
->when(! $request->user()->can('manage-portal'), fn ($q) => $q->where('r.metadata->portal_user_id', $request->user()->id))
|
||||
->first(['r.id', 'r.reference', 'r.status', 'r.next_action', 'r.next_action_due_at', 'u.name as responsible_name']) : null,
|
||||
'owner' => $owner, 'canWrite' => $desk->owns($request->user(), $case), 'manager' => $request->user()->can('manage-portal'),
|
||||
'requests' => DB::table('desk_requests')->where('case_id', $id)->when(! $owner && ! $request->user()->can('manage-portal'), fn ($q) => $q->where('analyst_id', $request->user()->id))->orderByDesc('updated_at')->get(['id', 'status', 'protocol', 'service_version_id', 'version']),
|
||||
'services' => DB::table('desk_service_versions')->where('status', 'published')->orderByDesc('version')->get(['id', 'code', 'version', 'definition'])->unique('code')->values()->map(fn ($s) => ['id' => $s->id, 'title' => json_decode($s->definition, true)['title'], 'version' => $s->version]),
|
||||
'documents' => $this->documents($id, $request->user()), 'events' => $this->events($id, $desk, $request->user()), 'stepStates' => config('virtual-desk.step_states'), 'states' => config('virtual-desk.request_states'),
|
||||
'links' => DB::table('desk_institutional_links')->get()->map(fn ($link) => [...(array) $link, 'monitor' => app(InstitutionalLinkMonitor::class)->status($link)]), 'source' => config('virtual-desk.source'), 'support' => config('portal.support'),
|
||||
'guidance' => app(InvestorGuidance::class)->installed() ? app(InvestorGuidance::class)->view($request->user(), $id) : null];
|
||||
|
||||
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/case', $data);
|
||||
}
|
||||
|
||||
/** @return Collection<int,\stdClass> */
|
||||
private function documents(string $case, User $actor): Collection
|
||||
{
|
||||
return DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $case)->whereNull('d.deleted_at')
|
||||
->when(! app(VirtualDesk::class)->owns($actor, app(VirtualDesk::class)->case($actor, $case), false) && ! $actor->can('manage-portal'), fn ($q) => $q->where(fn ($p) => $p->whereNull('l.request_id')->orWhereIn('l.request_id', DB::table('desk_requests')->where('analyst_id', $actor->id)->select('id'))))
|
||||
->get(['l.id', 'l.document_id', 'l.request_id', 'l.category', 'l.stage', 'l.source_reference', 'l.valid_until', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.sha256']);
|
||||
}
|
||||
|
||||
/** @return Collection<int,array{id:mixed,request_id:mixed,action:mixed,version:mixed,created_at:mixed,name:mixed,payload:array<string,mixed>}> */
|
||||
private function events(string $case, VirtualDesk $desk, User $actor): Collection
|
||||
{
|
||||
return DB::table('desk_events as e')->join('users as u', 'u.id', '=', 'e.actor_id')->where('e.case_id', $case)
|
||||
->when(! $desk->owns($actor, $desk->case($actor, $case), false) && ! $actor->can('manage-portal'), fn ($q) => $q->where(fn ($p) => $p->whereNull('e.request_id')->orWhereIn('e.request_id', DB::table('desk_requests')->where('analyst_id', $actor->id)->select('id'))))
|
||||
->orderBy('e.event_order')->get(['e.id', 'e.request_id', 'e.action', 'e.payload', 'e.version', 'e.created_at', 'u.name'])->map(fn ($e) => ['id' => $e->id, 'request_id' => $e->request_id, 'action' => $e->action, 'version' => $e->version, 'created_at' => $e->created_at, 'name' => $e->name, 'payload' => $desk->decode($e->payload)]);
|
||||
}
|
||||
|
||||
public function stage(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
|
||||
{
|
||||
$case = $desk->stage($request->user(), $id, $request->all());
|
||||
|
||||
return $request->expectsJson() ? response()->json(['version' => $case->version]) : back()->with('success', 'Etapa e evidência registadas.');
|
||||
}
|
||||
|
||||
public function createRequest(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['service_version_id' => 'required|uuid', 'client_key' => 'required|uuid', 'opportunity' => 'nullable|string|max:100']);
|
||||
$row = $desk->createRequest($request->user(), $id, $data['service_version_id'], $data['client_key'], $data['opportunity'] ?? null);
|
||||
|
||||
return $request->expectsJson() ? response()->json(['id' => $row->id, 'version' => $row->version], 201) : redirect('/balcao-virtual/pedidos/'.$row->id);
|
||||
}
|
||||
|
||||
public function showRequest(Request $request, string $id, VirtualDesk $desk): JsonResponse|Response
|
||||
{
|
||||
$row = $desk->request($request->user(), $id);
|
||||
$case = $desk->case($request->user(), $row->case_id);
|
||||
$payload = $desk->decode($row->payload);
|
||||
$definition = $desk->definition($row);
|
||||
$territoryPaths = [];
|
||||
foreach ($definition['fields'] as $key => $field) {
|
||||
if ($field['type'] === 'territory') {
|
||||
$territoryPaths[$key] = app(InvestmentGeography::class)->path($payload[$key] ?? null);
|
||||
}
|
||||
}
|
||||
$messageKinds = [];
|
||||
foreach (DB::table('desk_events')->where('request_id', $id)->where('action', 'message_added')->get() as $event) {
|
||||
$original = $desk->decode($event->payload);
|
||||
if (isset($original['message_id'], $original['kind'])) {
|
||||
$messageKinds[$original['message_id']] = $original['kind'];
|
||||
}
|
||||
}
|
||||
$this->auditRead($request, 'desk_requests', $id);
|
||||
$data = ['boundContextRevision' => app(InvestorContextConsumers::class)->requestRevision($row), 'countries' => app(CountryCatalogue::class)->all(), 'toolPrefill' => $desk->requestToolPrefill($request->user(), $row), 'request' => [...(array) $row, 'payload' => $payload, 'submitted_snapshot' => $desk->decode($row->submitted_snapshot), 'editable_fields' => json_decode($row->editable_fields ?? '[]', true)],
|
||||
'case' => ['id' => $case->id, 'title' => $case->title], 'caseVersion' => $case->version, 'definition' => $definition, 'territoryPaths' => $territoryPaths,
|
||||
'serviceSource' => json_decode(DB::table('desk_service_versions')->where('id', $row->service_version_id)->value('source'), true),
|
||||
'owner' => $desk->owns($request->user(), $case), 'manager' => app(\App\Services\ScopedPortalAdministration::class)->deskDecision($request->user(),$row), 'analyst' => (int) $row->analyst_id === $request->user()->id,
|
||||
'fieldsEditable' => in_array($row->status, ['draft', 'returned'], true), 'states' => config('virtual-desk.request_states'), 'transitions' => config('virtual-desk.transitions.'.$row->status, []),
|
||||
'findings' => $desk->findings($payload), 'ai' => config('virtual-desk.ai'), 'documents' => $this->documents($case->id, $request->user())->filter(fn ($d) => ! $d->request_id || $d->request_id === $id)->values(),
|
||||
'messages' => DB::table('desk_messages as m')->join('users as u', 'u.id', '=', 'm.actor_id')->where('request_id', $id)->orderBy('m.created_at')->get(['m.id', 'm.actor_id', 'm.body', 'm.created_at', 'u.name'])->map(fn ($m) => [...(array) $m, 'body' => Crypt::decryptString($m->body),
|
||||
'kind' => $messageKinds[$m->id] ?? 'historical_unclassified']),
|
||||
'events' => $this->events($case->id, $desk, $request->user())->filter(fn ($e) => $e['request_id'] === $id)->values(),
|
||||
'revisions' => DB::table('desk_revisions')->where('request_id', $id)->orderByDesc('version')->get(['version', 'snapshot', 'created_at'])->map(fn ($r) => [...(array) $r, 'snapshot' => $desk->decode($r->snapshot)]),
|
||||
'payments' => DB::table('desk_payments')->where('request_id', $id)->get(),
|
||||
'analysts' => $request->user()->can('manage-portal') ? User::orderBy('name')->get(['id', 'name']) : [],
|
||||
'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type'])];
|
||||
|
||||
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/request', $data);
|
||||
}
|
||||
|
||||
public function save(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
|
||||
{
|
||||
$row = $desk->save($request->user(), $id, $request->all(), $request->boolean('submit'));
|
||||
|
||||
return $request->expectsJson() ? response()->json(['id' => $row->id, 'version' => $row->version, 'status' => $row->status, 'protocol' => $row->protocol]) : back()->with('success', $request->boolean('submit') ? 'Pedido submetido; protocolo disponível.' : 'Rascunho guardado.');
|
||||
}
|
||||
|
||||
public function transition(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
|
||||
{
|
||||
$row = $desk->transition($request->user(), $id, $request->all());
|
||||
|
||||
return $request->expectsJson() ? response()->json(['version' => $row->version, 'status' => $row->status]) : back()->with('success', 'Decisão interna registada.');
|
||||
}
|
||||
|
||||
public function assignments(Request $request): JsonResponse|Response
|
||||
{
|
||||
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403);
|
||||
// Triage projection deliberately excludes payloads, documents, investor identity and case titles.
|
||||
$data=['requests'=>DB::table('desk_requests')->whereNotIn('status',['draft','cancelled','closed'])->orderBy('created_at')->get(['id','protocol','status','version','analyst_id','due_at']),
|
||||
'analysts'=>User::whereNotNull('email_verified_at')->orderBy('name')->get(['id','name'])];
|
||||
return $request->expectsJson()?response()->json($data):Inertia::render('management/desk-assignments',$data);
|
||||
}
|
||||
|
||||
public function assign(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
|
||||
{
|
||||
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()), 403);
|
||||
$data = $request->validate(['version' => 'required|integer', 'analyst_id' => 'required|integer|exists:users,id', 'reason' => 'required|string|min:3|max:3000', 'due_at' => 'nullable|date'], ['reason.required'=>'Explique o motivo da atribuição.','reason.min'=>'Explique o motivo da atribuição com pelo menos 3 caracteres.','analyst_id.required'=>'Seleccione o responsável.','analyst_id.exists'=>'Seleccione um responsável com conta disponível.']);
|
||||
DB::transaction(function () use ($request, $id, $desk, $data) {
|
||||
$row = DB::table('desk_requests')->where('id',$id)->lockForUpdate()->first();
|
||||
abort_unless($row,404);
|
||||
$case = DB::table('desk_cases')->where('id',$row->case_id)->first();
|
||||
abort_unless($case,404);
|
||||
if ((int)$row->version !== (int)$data['version']) throw \Illuminate\Validation\ValidationException::withMessages(['version'=>'Este pedido foi actualizado. Recupere a lista e tente novamente.']);
|
||||
if (in_array($row->status,['draft','cancelled','closed'],true)) throw \Illuminate\Validation\ValidationException::withMessages(['status'=>'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']);
|
||||
$analyst = User::where('id', $data['analyst_id'])->firstOrFail();
|
||||
abort_unless($analyst->hasVerifiedEmail(), 422, 'O responsável deve ter conta verificada.');
|
||||
abort_if($desk->owns($analyst, $case, false), 422, 'O proponente não pode ser o analista.');
|
||||
DB::table('desk_requests')->where('id', $id)->update(['analyst_id' => $analyst->id, 'due_at' => $data['due_at'] ?? null, 'version' => $row->version + 1, 'updated_at' => now()]);
|
||||
$desk->event($case, $request->user(), 'analyst_assigned', [...$data, 'deadline_basis' => 'internal_target_not_legal_deadline'], DB::table('desk_requests')->where('id', $id)->first());
|
||||
});
|
||||
|
||||
return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Analista e meta interna atribuídos.');
|
||||
}
|
||||
|
||||
public function message(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['body' => 'required|string|min:1|max:10000', 'client_key' => 'required|uuid']);
|
||||
DB::transaction(function () use ($request, $id, $desk, $data) {
|
||||
$row = $desk->request($request->user(), $id, false, true);
|
||||
$case = $desk->case($request->user(), $row->case_id);
|
||||
abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409, 'Pedido encerrado.');
|
||||
abort_unless($desk->owns($request->user(), $case) || $request->user()->can('manage-portal') || (int) $row->analyst_id === $request->user()->id, 403);
|
||||
$existing = DB::table('desk_messages')->where('request_id', $id)->where('actor_id', $request->user()->id)->where('client_key', $data['client_key'])->first();
|
||||
if ($existing) {
|
||||
abort_unless(Crypt::decryptString($existing->body) === $data['body'], 409);
|
||||
|
||||
return;
|
||||
}
|
||||
$messageId = (string) Str::uuid();
|
||||
DB::table('desk_messages')->insert(['id' => $messageId, 'request_id' => $id, 'actor_id' => $request->user()->id, 'client_key' => $data['client_key'], 'body' => Crypt::encryptString($data['body'])]);
|
||||
$desk->event($case, $request->user(), 'message_added', ['message_id' => $messageId,
|
||||
'kind' => $desk->owns($request->user(), $case) ? 'investor_message' : 'responsible_response'], $row);
|
||||
});
|
||||
|
||||
return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Mensagem privada guardada.');
|
||||
}
|
||||
|
||||
public function receipt(Request $request, string $id, VirtualDesk $desk): StreamedResponse|\Illuminate\Http\Response
|
||||
{
|
||||
$row = $desk->request($request->user(), $id);
|
||||
abort_unless($row->protocol && $row->submitted_snapshot, 404);
|
||||
$snapshot = $desk->decode($row->submitted_snapshot);
|
||||
$format = $request->validate(['format' => 'nullable|in:html'])['format'] ?? null;
|
||||
$this->auditRead($request, 'desk_requests', $id, 'receipt_downloaded');
|
||||
|
||||
if ($format === 'html') {
|
||||
return response()->view('desk.receipt', ['record' => $row, 'snapshot' => $snapshot, 'definition' => $desk->definition($row)], 200, ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
|
||||
}
|
||||
|
||||
return response()->streamDownload(function () use ($row, $snapshot) {
|
||||
echo 'Balcão Virtual — recibo de submissão interna'.PHP_EOL.$row->protocol.PHP_EOL.'Data: '.$row->submitted_at.PHP_EOL.'Versão: '.$row->version.PHP_EOL;
|
||||
echo json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
}, $row->protocol.'.txt', ['Content-Type' => 'text/plain; charset=utf-8', 'Cache-Control' => 'private, no-store']);
|
||||
}
|
||||
|
||||
public function attach(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['version' => 'required|integer', 'file' => 'nullable|required_without:document_id|file|mimetypes:application/pdf,image/png,image/jpeg,text/plain|max:10240',
|
||||
'document_id' => 'nullable|required_without:file|uuid', 'category' => 'required|string|max:80', 'stage' => 'nullable|string|max:3', 'request_id' => 'nullable|uuid', 'valid_until' => 'nullable|date_format:Y-m-d', 'source_reference' => 'required|string|max:3000', 'consent' => 'accepted']);
|
||||
$key = null;
|
||||
$newId = null;
|
||||
try {
|
||||
DB::transaction(function () use ($request, $id, $desk, $data, &$key, &$newId) {
|
||||
$case = $desk->case($request->user(), $id, true, true);
|
||||
abort_if($case->closed_at, 409, 'Dossier encerrado.');
|
||||
abort_unless((int) $case->version === (int) $data['version'], 409);
|
||||
if ($data['stage'] ?? null) {
|
||||
abort_unless(array_key_exists($data['stage'], config('virtual-desk.steps')), 422);
|
||||
}
|
||||
if ($data['request_id'] ?? null) {
|
||||
$row = $desk->request($request->user(), $data['request_id'], true, true);
|
||||
abort_unless($row->case_id === $id && in_array($row->status, ['draft', 'returned'], true), 409, 'Anexos de pedidos submetidos são imutáveis; use a devolução para corrigir.');
|
||||
}
|
||||
abort_if(DB::table('desk_documents')->where('case_id', $id)->count() >= 100, 422, 'Limite de anexos do dossier atingido.');
|
||||
if ($request->hasFile('file')) {
|
||||
$file = $request->file('file');
|
||||
$hash = hash_file('sha256', $file->getRealPath());
|
||||
$duplicate = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->where('d.sha256', $hash)->first();
|
||||
if ($duplicate) {
|
||||
return;
|
||||
}
|
||||
$newId = (string) Str::uuid();
|
||||
$key = $file->storeAs('virtual-desk/'.$id, $newId, 'local');
|
||||
abort_unless(is_string($key) && $key !== '', 500);
|
||||
DB::table('documents')->insert(['id' => $newId, 'source_system' => 'virtual-desk', 'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $key,
|
||||
'sha256' => $hash, 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id, 'status' => 'quarantined', 'data_classification' => 'confidential']);
|
||||
$documentId = $newId;
|
||||
} else {
|
||||
$documentId = $data['document_id'];
|
||||
$document = DB::table('documents')->where('id', $documentId)->whereNull('deleted_at')->first();
|
||||
abort_unless($document !== null, 404);
|
||||
$owned = (int) $document->uploaded_by === $request->user()->id || DB::table('desk_documents as l')->join('desk_cases as c', 'c.id', '=', 'l.case_id')->where('l.document_id', $documentId)->where('c.user_id', $request->user()->id)->whereNull('c.organization_id')->exists();
|
||||
abort_unless($owned, 403, 'Não há autorização para reutilizar este documento.');
|
||||
if (DB::table('desk_documents')->where('case_id', $id)->where('document_id', $documentId)->exists()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
DB::table('desk_documents')->insert(['id' => (string) Str::uuid(), 'case_id' => $id, 'document_id' => $documentId, 'request_id' => $data['request_id'] ?? null,
|
||||
'category' => $data['category'], 'stage' => $data['stage'] ?? null, 'valid_until' => $data['valid_until'] ?? null, 'source_reference' => $data['source_reference'], 'linked_by' => $request->user()->id]);
|
||||
DB::table('desk_cases')->where('id', $id)->update(['version' => $case->version + 1, 'updated_at' => now()]);
|
||||
$desk->event(DB::table('desk_cases')->where('id', $id)->first(), $request->user(), 'document_linked', ['document_id' => $documentId, 'source' => $data['source_reference'], 'consent' => true]);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
if ($key) {
|
||||
Storage::disk('local')->delete($key);
|
||||
} throw $e;
|
||||
}
|
||||
if ($newId) {
|
||||
ScanDocumentForMalware::dispatch($newId)->afterCommit();
|
||||
}
|
||||
|
||||
return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Documento privado associado; consulte o estado da análise.');
|
||||
}
|
||||
|
||||
public function download(Request $request, string $id, string $document, VirtualDesk $desk): BinaryFileResponse|\Illuminate\Http\Response
|
||||
{
|
||||
$desk->case($request->user(), $id);
|
||||
$doc = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->where('l.id', $document)->whereNull('d.deleted_at')->first(['d.*', 'l.request_id']);
|
||||
if ($doc && $doc->request_id) {
|
||||
$desk->request($request->user(), $doc->request_id);
|
||||
}
|
||||
abort_unless($doc && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && trim($doc->sha256) === $doc->malware_scanned_sha256, 404);
|
||||
$response = app(VerifiedDocumentBinary::class)->respond($doc);
|
||||
$this->auditRead($request, 'documents', $doc->id, 'document_downloaded');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function notification(Request $request, int $id): RedirectResponse
|
||||
{
|
||||
DB::table('desk_notifications')->where('id', $id)->where('user_id', $request->user()->id)->update(['read_at' => now()]);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function redirectLink(Request $request, string $id, VirtualDesk $desk): RedirectResponse
|
||||
{
|
||||
$case = $desk->case($request->user(), $request->validate(['case_id' => 'required|uuid'])['case_id']);
|
||||
$link = DB::table('desk_institutional_links')->where('id', $id)->first();
|
||||
if (! $link || ! app(InstitutionalLinkMonitor::class)->status($link)['available'] || $request->boolean('unavailable')) {
|
||||
$desk->event($case, $request->user(), 'institutional_link_unavailable', ['link_id' => $id, 'contact' => $link?->contact, 'scope' => 'internal_services_remain_available']);
|
||||
|
||||
return redirect('/balcao-virtual/dossiers/'.$case->id)->with('warning', 'Ligação indisponível ou por verificar. Contacto alternativo: '.($link?->contact ?: (config('portal.support.email') ?: 'utilize o pedido de apoio neste dossier')).'. Pode continuar a preparação e os serviços internos.');
|
||||
}
|
||||
abort_unless(filter_var($link->url, FILTER_VALIDATE_URL) && parse_url($link->url, PHP_URL_SCHEME) === 'https', 422);
|
||||
$desk->event($case, $request->user(), 'institutional_referral', ['link_id' => $id, 'version' => $link->version, 'purpose' => $link->purpose]);
|
||||
|
||||
// No personal data, case ID or protocol appended to the external URL.
|
||||
return redirect()->away($link->url)->withHeaders(['Referrer-Policy' => 'no-referrer']);
|
||||
}
|
||||
|
||||
private function auditRead(Request $request, string $type, string $id, string $action = 'consulted'): void
|
||||
{
|
||||
DB::table('audit_events')->insert(['source_system' => 'virtual-desk', 'entity_type' => $type, 'entity_id' => $id, 'actor_user_id' => $request->user()->id, 'action' => $action]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\VirtualDeskInstallation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class VirtualDeskInstallationController extends Controller
|
||||
{
|
||||
public function reviewLink(Request $request, string $id): JsonResponse|RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['version' => 'required|integer|min:1', 'available' => 'required|boolean',
|
||||
'evidence' => 'required|string|min:10|max:3000', 'contact' => 'nullable|string|max:255']);
|
||||
DB::transaction(function () use ($request, $id, $data) {
|
||||
$link = DB::table('desk_institutional_links')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($link !== null, 404);
|
||||
abort_unless((int) $link->version === (int) $data['version'], 409);
|
||||
abort_unless(filter_var($link->url, FILTER_VALIDATE_URL) && parse_url($link->url, PHP_URL_SCHEME) === 'https', 422);
|
||||
DB::table('desk_institutional_links')->where('id', $id)->update(['status' => $data['available'] ? 'active' : 'unavailable',
|
||||
'contact' => $data['contact'] ?? $link->contact, 'verified_at' => now(), 'verified_by' => $request->user()->id,
|
||||
'version' => $link->version + 1, 'updated_at' => now()]);
|
||||
DB::table('audit_events')->insert(['source_system' => 'virtual-desk', 'entity_type' => 'desk_institutional_links', 'entity_id' => $id,
|
||||
'actor_user_id' => $request->user()->id, 'action' => 'institutional_link_checked',
|
||||
'after_data' => json_encode(['url' => $link->url, 'prior_version' => $link->version, ...$data, 'scope' => 'availability_only_not_legal_approval'])]);
|
||||
});
|
||||
|
||||
return $request->expectsJson() ? response()->json(['checked' => true]) : back()->with('success', 'Conferência de disponibilidade registada por sete dias.');
|
||||
}
|
||||
|
||||
public function sources(Request $request): JsonResponse|Response
|
||||
{
|
||||
$read = function (string $name): array {
|
||||
$file = storage_path('app/private/virtual-desk/'.$name.'.json');
|
||||
if (! is_file($file)) {
|
||||
return [];
|
||||
}
|
||||
$bytes = file_get_contents($file);
|
||||
abort_unless(is_string($bytes), 503);
|
||||
|
||||
return json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
|
||||
};
|
||||
$inventory = $read('source-inventory');
|
||||
$forms = $read('form-register');
|
||||
$data = ['total' => count($inventory), 'attachmentCount' => array_sum(array_map(fn ($item) => count($item['attachments'] ?? []), $inventory)),
|
||||
'forms' => array_map(fn ($item) => ['identifier' => $item['identifier'], 'sha256' => $item['sha256'], 'size' => $item['size'], 'edition' => $item['edition'],
|
||||
'provenance' => str_contains($item['path'], DIRECTORY_SEPARATOR.'Scan'.DIRECTORY_SEPARATOR) ? 'Acervo disponibilizado — digitalização' : 'Acervo disponibilizado — fonte de desenho ou template',
|
||||
'consultation' => $item['consultation'], 'legal_documentary_status' => $item['legal_documentary_status'], 'rule_use' => $item['rule_use'], 'official_confirmation' => $item['official_confirmation'], 'fields_status' => $item['fields_status']], $forms),
|
||||
'source' => config('virtual-desk.source')];
|
||||
|
||||
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/sources', $data);
|
||||
}
|
||||
|
||||
public function preview(Request $request, VirtualDeskInstallation $installation): JsonResponse|Response
|
||||
{
|
||||
$data = $installation->preview();
|
||||
|
||||
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/install', $data);
|
||||
}
|
||||
|
||||
public function apply(Request $request, VirtualDeskInstallation $installation): JsonResponse|RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['hash' => 'required|string|size:64', 'confirm' => 'accepted']);
|
||||
$installation->apply($data['hash'], $request->user());
|
||||
|
||||
return $request->expectsJson() ? response()->json(['installed' => true]) : back()->with('success', 'Instalação concluída; serviços ainda exigem publicação explícita.');
|
||||
}
|
||||
|
||||
public function publishService(Request $request): JsonResponse|RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['code' => ['required', Rule::in(array_keys(config('virtual-desk.services')))], 'confirm' => 'accepted']);
|
||||
$row = DB::transaction(function () use ($request, $data) {
|
||||
DB::select('select pg_advisory_xact_lock(261008, 2)');
|
||||
$definition = config('virtual-desk.services.'.$data['code']);
|
||||
$existing = DB::table('desk_service_versions')->where('code', $data['code'])->orderByDesc('version')->first();
|
||||
if ($existing && $existing->status === 'published' && json_decode($existing->definition, true) === $definition) {
|
||||
return $existing;
|
||||
}
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('desk_service_versions')->insert(['id' => $id, 'code' => $data['code'], 'version' => ($existing->version ?? 0) + 1, 'status' => 'published',
|
||||
'definition' => json_encode($definition), 'source' => json_encode(config('virtual-desk.source')), 'author_id' => $request->user()->id,
|
||||
'published_by' => $request->user()->id, 'published_at' => now(), 'created_at' => now(), 'updated_at' => now()]);
|
||||
DB::table('audit_events')->insert(['source_system' => 'virtual-desk', 'entity_type' => 'desk_service_versions', 'entity_id' => $id, 'actor_user_id' => $request->user()->id,
|
||||
'action' => 'internal_service_published', 'after_data' => json_encode(['code' => $data['code'], 'nature' => 'digital_preparation_not_official_form'])]);
|
||||
|
||||
return DB::table('desk_service_versions')->where('id', $id)->first();
|
||||
});
|
||||
|
||||
return $request->expectsJson() ? response()->json(['id' => $row->id], 201) : back()->with('success', 'Serviço de preparação interna publicado.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Ai\InvestorGuidanceEvidence;
|
||||
use App\Services\VirtualDesk;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/** Manual operations use the same requests, documents and encrypted audit history. */
|
||||
class VirtualDeskOperationsController extends Controller
|
||||
{
|
||||
private function result(Request $request): JsonResponse|RedirectResponse
|
||||
{
|
||||
return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Registo interno guardado; não constitui confirmação de uma instituição externa.');
|
||||
}
|
||||
|
||||
private function reviewer(Request $request, VirtualDesk $desk, \stdClass $case, ?\stdClass $row): void
|
||||
{
|
||||
abort_unless(! $desk->owns($request->user(), $case, false) && ($request->user()->can('manage-portal') || ($row && (int) $row->analyst_id === $request->user()->id)), 403);
|
||||
}
|
||||
|
||||
private function cleanDocument(string $caseId, string $documentId, ?string $requestId): \stdClass
|
||||
{
|
||||
$doc = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')
|
||||
->where('l.case_id', $caseId)->where('d.id', $documentId)->whereNull('d.deleted_at')
|
||||
->where(fn ($q) => $q->whereNull('l.request_id')->when($requestId, fn ($p) => $p->orWhere('l.request_id', $requestId)))
|
||||
->first(['d.*', 'l.id as link_id']);
|
||||
abort_unless($doc !== null && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && trim($doc->sha256) === $doc->malware_scanned_sha256, 422, 'Documento indisponível para conferência: verifique autorização, integridade e análise antimalware.');
|
||||
$path = realpath(Storage::disk('local')->path($doc->storage_key));
|
||||
$root = realpath(Storage::disk('local')->path(''));
|
||||
abort_unless($path && $root && str_starts_with($path, $root.DIRECTORY_SEPARATOR) && is_file($path) && is_string($hash = hash_file('sha256', $path)) && hash_equals(trim($doc->sha256), $hash), 422, 'O ficheiro original ou a sua integridade não pôde ser confirmado.');
|
||||
|
||||
return $doc;
|
||||
}
|
||||
|
||||
public function documentReview(Request $request, string $id, string $document, VirtualDesk $desk): JsonResponse|RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['version' => 'required|integer|min:1', 'request_id' => 'nullable|uuid', 'outcome' => 'required|in:internal_verified,internal_issue,contradiction_identified', 'what_checked' => 'required|string|min:3|max:3000', 'evidence' => 'required|string|min:3|max:5000', 'supports' => 'nullable|array|max:4', 'supports.*' => ['string', Rule::in(InvestorGuidanceEvidence::FIELDS)]]);
|
||||
DB::transaction(function () use ($request, $id, $document, $desk, $data) {
|
||||
$case = $desk->case($request->user(), $id, false, true);
|
||||
abort_unless(! $case->closed_at && (int) $case->version === (int) $data['version'], 409);
|
||||
$link = DB::table('desk_documents')->where('case_id', $id)->where('id', $document)->first();
|
||||
abort_unless($link !== null, 404);
|
||||
$requestId = $link->request_id ?: ($data['request_id'] ?? null);
|
||||
$row = $requestId ? $desk->request($request->user(), $requestId, false, true) : null;
|
||||
abort_if($row && $row->case_id !== $id, 422);
|
||||
$this->reviewer($request, $desk, $case, $row);
|
||||
$doc = $this->cleanDocument($id, $link->document_id, $requestId);
|
||||
DB::table('desk_cases')->where('id', $id)->update(['version' => $case->version + 1, 'updated_at' => now()]);
|
||||
$desk->event(DB::table('desk_cases')->where('id', $id)->firstOrFail(), $request->user(), 'document_reviewed', [
|
||||
...$data, 'document_id' => $doc->id, 'link_id' => $link->id, 'sha256' => trim($doc->sha256), 'document_version' => (int) $doc->version_no, 'reviewer_id' => $request->user()->id,
|
||||
'scope' => 'internal_documentary_check_not_official_approval_registration_or_license',
|
||||
], $row);
|
||||
});
|
||||
|
||||
return $this->result($request);
|
||||
}
|
||||
|
||||
public function payment(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['reference' => 'required|string|max:255', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/', 'numeric', 'gt:0'], 'currency' => 'required|in:MZN,USD,EUR', 'source_reference' => 'required|string|min:3|max:3000', 'document_id' => 'nullable|uuid', 'consent' => 'accepted']);
|
||||
DB::transaction(function () use ($request, $id, $desk, $data) {
|
||||
$row = $desk->request($request->user(), $id, true, true);
|
||||
$case = $desk->case($request->user(), $row->case_id);
|
||||
abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409);
|
||||
if ($data['document_id'] ?? null) {
|
||||
abort_unless(DB::table('desk_documents')->where('case_id', $case->id)->where('document_id', $data['document_id'])->where(fn ($q) => $q->whereNull('request_id')->orWhere('request_id', $id))->exists(), 422);
|
||||
}
|
||||
// References are supplied by the investor; no bank reference or fee is generated.
|
||||
$existing = DB::table('desk_payments')->where('reference', $data['reference'])->first();
|
||||
if ($existing) {
|
||||
$parts = explode('.', (string) $data['amount']);
|
||||
$amount = (ltrim($parts[0], '0') ?: '0').'.'.str_pad($parts[1] ?? '', 2, '0');
|
||||
abort_unless($existing->request_id === $id && (string) $existing->amount === $amount && $existing->currency === $data['currency'] && $existing->source_reference === $data['source_reference'] && $existing->document_id === ($data['document_id'] ?? null), 409);
|
||||
|
||||
return;
|
||||
}
|
||||
$payment = (string) Str::uuid();
|
||||
DB::table('desk_payments')->insert(['id' => $payment, 'request_id' => $id, 'reference' => $data['reference'], 'amount' => $data['amount'], 'currency' => $data['currency'], 'source_reference' => $data['source_reference'], 'document_id' => $data['document_id'] ?? null, 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]);
|
||||
$desk->event($case, $request->user(), 'payment_declared', ['payment_id' => $payment, 'consent' => true, 'scope' => 'manual_declaration_no_bank_confirmation'], $row);
|
||||
});
|
||||
|
||||
return $this->result($request);
|
||||
}
|
||||
|
||||
public function paymentReview(Request $request, string $id, string $payment, VirtualDesk $desk): JsonResponse|RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['version' => 'required|integer|min:1', 'status' => 'required|in:internal_verified,internal_issue', 'reason' => 'required|string|min:3|max:3000', 'evidence' => 'required|string|min:3|max:5000']);
|
||||
DB::transaction(function () use ($request, $id, $payment, $desk, $data) {
|
||||
$row = $desk->request($request->user(), $id, false, true);
|
||||
$case = $desk->case($request->user(), $row->case_id);
|
||||
$this->reviewer($request, $desk, $case, $row);
|
||||
abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409);
|
||||
$record = DB::table('desk_payments')->where('request_id', $id)->where('id', $payment)->lockForUpdate()->first();
|
||||
abort_unless($record !== null, 404);
|
||||
abort_unless((int) $record->version === (int) $data['version'], 409);
|
||||
$doc = null;
|
||||
if ($data['status'] === 'internal_verified') {
|
||||
abort_unless($record->document_id, 422, 'A conferência exige um comprovativo autorizado.');
|
||||
$doc = $this->cleanDocument($case->id, $record->document_id, $id);
|
||||
}
|
||||
DB::table('desk_payments')->where('id', $payment)->update(['status' => $data['status'], 'reason' => $data['reason'], 'verified_by' => $request->user()->id, 'version' => $record->version + 1, 'updated_at' => now()]);
|
||||
$desk->event($case, $request->user(), 'payment_reviewed', [...$data, 'payment_id' => $payment, 'document_id' => $doc?->id, 'sha256' => $doc ? trim($doc->sha256) : null, 'reviewer_id' => $request->user()->id, 'scope' => 'internal_proof_check_no_bank_confirmation'], $row);
|
||||
});
|
||||
|
||||
return $this->result($request);
|
||||
}
|
||||
|
||||
public function communication(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['channel' => 'required|in:phone,email,sms,whatsapp,in_person', 'direction' => 'required|in:received,sent', 'occurred_at' => 'required|date|before_or_equal:now', 'summary' => 'required|string|min:3|max:5000', 'evidence' => 'required|string|min:3|max:3000', 'client_key' => 'required|uuid']);
|
||||
DB::transaction(function () use ($request, $id, $desk, $data) {
|
||||
$row = $desk->request($request->user(), $id, false, true);
|
||||
$case = $desk->case($request->user(), $row->case_id);
|
||||
abort_unless($desk->owns($request->user(), $case) || $request->user()->can('manage-portal') || (int) $row->analyst_id === $request->user()->id, 403);
|
||||
abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409);
|
||||
foreach (DB::table('desk_events')->where('request_id', $id)->where('actor_id', $request->user()->id)->where('action', 'manual_communication_recorded')->get(['payload']) as $event) {
|
||||
$saved = $desk->decode($event->payload);
|
||||
if (($saved['client_key'] ?? null) === $data['client_key']) {
|
||||
abort_unless(array_intersect_key($saved, $data) === $data, 409);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
$desk->event($case, $request->user(), 'manual_communication_recorded', [...$data, 'scope' => 'manual_record_not_portal_delivery_confirmation'], $row);
|
||||
});
|
||||
|
||||
return $this->result($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\PortalProfileAccess;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
|
||||
class AuditProfileDecision
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$access = app(PortalProfileAccess::class);
|
||||
if (! $request->user() || ! $access->governanceAvailable() || ! preg_match('#^(perfil/|perfis/|minhas-oportunidades)#', $request->path())) {
|
||||
return $next($request);
|
||||
}
|
||||
try {
|
||||
$response = $next($request);
|
||||
} catch (\Throwable $error) {
|
||||
$status = $error instanceof HttpExceptionInterface ? $error->getStatusCode() : ($error instanceof ValidationException ? 422 : 500);
|
||||
$this->record($request, $access, $status);
|
||||
throw $error;
|
||||
}
|
||||
if ($response->getStatusCode() >= 400) {
|
||||
$this->record($request, $access, $response->getStatusCode());
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function record(Request $request, PortalProfileAccess $access, int $status): void
|
||||
{
|
||||
$access->audit($request->user(), 'request_denied', ['method' => $request->method(), 'path' => $request->path(), 'status' => $status]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CorrelationId
|
||||
{
|
||||
/** @param Closure(Request): Response $next */
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$provided = $request->header('X-Correlation-ID');
|
||||
$correlationId = is_string($provided) && Str::isUuid($provided)
|
||||
? $provided
|
||||
: (string) Str::uuid();
|
||||
|
||||
if ($provided !== null && ! Str::isUuid($provided)) {
|
||||
return response()->json([
|
||||
'message' => 'O identificador de correlação deve ser um UUID válido.',
|
||||
'code' => 'invalid_correlation_id',
|
||||
'correlation_id' => $correlationId,
|
||||
], 422)->header('X-Correlation-ID', $correlationId);
|
||||
}
|
||||
|
||||
$request->attributes->set('correlation_id', $correlationId);
|
||||
$response = $next($request);
|
||||
$response->headers->set('X-Correlation-ID', $correlationId);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\DocumentManagementAccess;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureDocumentManagement
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$actor = $request->user();
|
||||
abort_unless($actor, 403);
|
||||
$access = app(DocumentManagementAccess::class);
|
||||
if ($access->full($actor)) {
|
||||
return $next($request); // Preserve existing management controls.
|
||||
}
|
||||
$allowed = $access->limited($actor) || $access->editorial($actor);
|
||||
$access->audit($actor, $allowed ? 'document_access_allowed' : 'document_access_denied', [
|
||||
'scope' => $access->editorial($actor) ? 'approved-editorial-catalogue-and-own-files' : 'own-portal-services-files', 'method' => $request->method(), 'path' => $request->path(),
|
||||
]);
|
||||
abort_unless($allowed, 403, 'O acesso documental exige autorização, email confirmado e autenticação MFA nesta sessão.');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
namespace App\Http\Middleware;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
/** Environment reservation is not a portal identity or a business permission. */
|
||||
class EnsureHomologationAccess
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (!config('portal.homologation_only', false)) return $next($request);
|
||||
$mode=config('portal.homologation_access_mode', 'reviewers');
|
||||
if ($mode==='perimeter') {
|
||||
// Set only by the trusted server after its environment-access check.
|
||||
// A client HTTP_INVESTMOZ_* header is deliberately not accepted.
|
||||
abort_unless($request->server('INVESTMOZ_HOMOLOGATION_ACCESS')==='granted',403,
|
||||
'Este ambiente de homologação tem acesso reservado à Tempo.');
|
||||
$response=$next($request);
|
||||
$response->headers->set('Cache-Control','private, no-store');
|
||||
$response->headers->set('X-Robots-Tag','noindex, nofollow, noarchive');
|
||||
return $response;
|
||||
}
|
||||
abort_unless($mode==='reviewers',503,'A reserva da homologação não está configurada.');
|
||||
// Compatibility until an independent perimeter is explicitly configured.
|
||||
if ($request->is('login','logout','register','forgot-password','reset-password','reset-password/*',
|
||||
'email/verify','email/verify/*','email/verification-notification','two-factor-challenge',
|
||||
'user/confirm-password','user/confirmed-password-status','user/two-factor-*',
|
||||
'settings/security','settings/password','user/password','passkeys/*','user/passkeys/*','up')) return $next($request);
|
||||
$actor=$request->user();
|
||||
if (!$actor) return $request->expectsJson()
|
||||
? response()->json(['message'=>'Entre na conta autorizada para homologação.'],401)
|
||||
: redirect()->guest(route('login'));
|
||||
abort_unless($actor->hasVerifiedEmail() && in_array(strtolower(trim($actor->email)),config('portal.homologation_reviewers',[]),true),403,
|
||||
'Esta homologação está reservada às contas indicadas pela Tempo.');
|
||||
$response=$next($request);$response->headers->set('Cache-Control','private, no-store');return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\PortalProfileAccess;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsurePortalProfileAdministration
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
abort_unless(config('portal.profile_administration_enabled') || config('portal.profile_access_enabled'), 404);
|
||||
abort_unless(app(PortalProfileAccess::class)->governanceAvailable(), 503, 'Autorização por perfis ainda não instalada.');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\PortalProfileAccess;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsurePortalProfiles
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
abort_unless(config('portal.profile_access_enabled'), 404);
|
||||
abort_unless(app(PortalProfileAccess::class)->available(), 503, 'Autorização por perfis ainda não instalada.');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
namespace App\Http\Middleware;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
class EnsureScopedEditorial
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$actor = $request->user();
|
||||
abort_unless($actor, 403);
|
||||
$access = app(\App\Services\DocumentManagementAccess::class);
|
||||
if ($access->full($actor)) {
|
||||
return $next($request);
|
||||
}
|
||||
abort_unless($access->editorial($actor), 403);
|
||||
$allowed = in_array($request->route()->getName(), ['editorial.edit', 'editorial.save', 'editorial.preview', 'editorial.preview-content', 'services.resource', 'services.resource.update'], true)
|
||||
|| ($request->route()->getName() === 'services.moderate' && $request->route('type') === 'resources');
|
||||
abort_unless($allowed, 403);
|
||||
if ($request->route('id') !== null) {
|
||||
$access->assertEditableResource($actor, (int) $request->route('id'));
|
||||
}
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\PortalProfileAccess;
|
||||
use App\Services\ScopedPortalAdministration;
|
||||
use App\Services\SimulationTaxGovernanceAccess;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class EnsureScopedPortalAdministration
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$actor = $request->user();
|
||||
abort_unless($actor !== null, 403);
|
||||
$scope = app(ScopedPortalAdministration::class);
|
||||
$path = $request->path();
|
||||
$interests = $request->is('management/interests', 'management/interests/*', 'api/v1/investment/admin/interests/*');
|
||||
// Fiscal maintenance reuses authorised administration; unrelated gates remain unchanged.
|
||||
$geometryRead = $request->isMethod('GET') && $path === 'management/territory-geometries';
|
||||
$catalogue = $geometryRead || $request->is('management/territory-geometry-import', 'management/territory-geometry-import/*',
|
||||
'management/economic-references/lot-01', 'management/economic-references/lot-01/*',
|
||||
'management/education', 'management/education/*', 'management/investment/references', 'management/investment/references/*', 'api/v1/investment/admin/references', 'api/v1/investment/admin/references/*',
|
||||
'management/investment/zones', 'management/investment/zones/*', 'api/v2/investment/admin/zones', 'api/v2/investment/admin/zones/*',
|
||||
'management/balcao/atribuicoes', 'management/balcao/atribuicoes/*', 'management/balcao/servicos',
|
||||
'management/balcao/fontes') || ($request->isMethod('GET') && $path === 'management/balcao-install');
|
||||
$fiscal = $request->is('api/v2/investment/admin/tax-rules', 'api/v2/investment/admin/tax-rules/*',
|
||||
'api/v2/investment/admin/tax-rule-sources', 'management/investment/tax-rules/*');
|
||||
$allowed = ($fiscal && SimulationTaxGovernanceAccess::maintainer($actor)) || ($interests ? $scope->interestOperator($actor) : ($catalogue && $scope->administrative($actor)));
|
||||
abort_unless($allowed || ($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor')), 403, 'Acesso reservado ao âmbito já designado.');
|
||||
$response = $next($request);
|
||||
if (! $request->isMethod('GET') && $response->getStatusCode() < 400) {
|
||||
app(PortalProfileAccess::class)->audit($actor, 'scoped_administration', [
|
||||
'method' => $request->method(), 'route' => $request->route()?->uri(), 'scope' => $fiscal ? 'fiscal-maintenance' : ($interests ? 'assigned-interest' : 'catalogue-administration')]);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class EnsureSimulationRecoveryCookie
|
||||
{
|
||||
public const NAME = 'investmoz_simulation_recovery';
|
||||
|
||||
public const DAYS = 30;
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$token = $request->cookie(self::NAME);
|
||||
$issued = ! is_string($token) || strlen($token) < 32 || strlen($token) > 256;
|
||||
if (! is_string($token) || strlen($token) < 32 || strlen($token) > 256) {
|
||||
$provided = $request->header('X-Simulation-Token');
|
||||
$token = is_string($provided) && strlen($provided) >= 32 && strlen($provided) <= 256
|
||||
? $provided
|
||||
: Str::random(96);
|
||||
}
|
||||
$request->headers->set('X-Simulation-Token', $token);
|
||||
|
||||
$response = $next($request);
|
||||
$forget = $request->isMethod('DELETE')
|
||||
&& $request->path() === 'api/v1/simulation-drafts/current'
|
||||
&& $response->getStatusCode() === 204;
|
||||
$activity = $response->isSuccessful() && $this->isExplicitDraftActivity($request, $response);
|
||||
$replay = $response->isSuccessful() && $this->isDraftActivityReplay($request, $response);
|
||||
|
||||
if (! $issued && ! $activity && ! $replay && ! $forget) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Successful draft writes reset the server deadline to now + DAYS, so
|
||||
// refresh the cookie by the same duration. Routine reads never refresh it.
|
||||
$minutes = $forget ? 0 : self::DAYS * 24 * 60;
|
||||
if ($replay) {
|
||||
$payload = json_decode((string) $response->getContent(), true);
|
||||
$expiry = is_array($payload) ? ($payload['expires_at'] ?? null) : null;
|
||||
if (is_string($expiry)) {
|
||||
try {
|
||||
$secondsLeft = Carbon::parse($expiry)->getTimestamp() - now()->getTimestamp();
|
||||
$minutes = max(1, (int) ceil($secondsLeft / 60));
|
||||
} catch (\Throwable) {
|
||||
// Preserve the standard cookie lifetime if the response has no valid deadline.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $response->withCookie(cookie(
|
||||
self::NAME,
|
||||
$forget ? '' : $token,
|
||||
$minutes,
|
||||
'/api/v1',
|
||||
null,
|
||||
$request->isSecure(),
|
||||
true,
|
||||
false,
|
||||
'lax',
|
||||
));
|
||||
}
|
||||
|
||||
private function isExplicitDraftActivity(Request $request, Response $response): bool
|
||||
{
|
||||
if ($request->isMethod('POST') && $request->path() === 'api/v1/simulation-drafts') {
|
||||
return $response->getStatusCode() === 201;
|
||||
}
|
||||
|
||||
return ($request->isMethod('PUT') && preg_match('#^api/v1/simulation-drafts/[0-9a-f-]{36}$#i', $request->path()) === 1)
|
||||
|| ($request->isMethod('POST') && preg_match('#^api/v1/simulation-drafts/[0-9a-f-]{36}/(?:conversions|touch)$#i', $request->path()) === 1);
|
||||
}
|
||||
|
||||
private function isDraftActivityReplay(Request $request, Response $response): bool
|
||||
{
|
||||
if (! $request->isMethod('POST') || $response->getStatusCode() !== 200) {
|
||||
return false;
|
||||
}
|
||||
if ($request->path() !== 'api/v1/simulation-drafts'
|
||||
&& preg_match('#^api/v1/simulation-drafts/[0-9a-f-]{36}/conversions$#i', $request->path()) !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response->getContent(), true);
|
||||
|
||||
return is_array($payload) && ($payload['data']['replayed'] ?? $payload['replayed'] ?? false) === true;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user