Compare commits

...
Author SHA1 Message Date
nala 4f35313d06 novas funcionalidades dos formulario
tests / ci (push) Canceled after 0s
2026-10-02 11:13:37 +02:00
nala a6afb4e97a correcao do seeder
tests / ci (push) Canceled after 0s
2026-09-30 13:57:28 +02:00
nala cbae1ce77b correcao do erro no ficheiro catalog.tsx
tests / ci (push) Canceled after 0s
2026-09-30 13:50:28 +02:00
nala 5e6b32b493 dados do seeder
tests / ci (push) Waiting to run
2026-09-30 13:47:51 +02:00
nala 3395424d82 resolucao dos inputs das provincias, distritos, localidades
tests / ci (push) Waiting to run
2026-09-30 12:59:28 +02:00
nala 3dc7c5c649 commit 30/9
tests / ci (push) Canceled after 0s
2026-09-30 09:05:43 +02:00
nala 67dad461d9 provincias
tests / ci (push) Canceled after 0s
2026-09-29 14:14:06 +02:00
214 changed files with 28762 additions and 413 deletions
+9
View File
@@ -66,6 +66,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=
+5
View File
@@ -66,5 +66,10 @@ jobs:
- name: Run CI Checks
run: composer ci:check
- 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 frontend API contracts
run: node --test tests/frontend/portal-api.test.mjs
+864
View File
@@ -0,0 +1,864 @@
openapi: 3.1.0
info:
title: InvestMoz API
version: 1.0.0
summary: Contrato consolidado da API do InvestMoz
description: |
Contrato consolidado para catálogos, oportunidades, manifestações de interesse,
simulações, legislação, jobs, relatórios e administração. Valores de taxas,
reduções e parâmetros de simulação são fornecidos pelo utilizador; não são
taxas oficiais nem decisões de elegibilidade.
contact:
name: Equipa InvestMoz
license:
name: MIT
identifier: MIT
servers:
- url: /
description: Mesmo host da aplicação
security:
- sessionCookie: []
tags:
- name: Catálogos
description: Referências versionadas, estáveis e localizadas em pt-MZ.
- name: Oportunidades
description: Consulta pública de oportunidades de investimento.
- name: Manifestações
description: Submissão de interesse em oportunidades.
- name: Simulações
description: Execuções indicativas com snapshots e controlo de concorrência.
- name: Jobs
description: Estado dos trabalhos assíncronos associados a uma simulação.
- name: Relatórios
description: Leitura de snapshots imutáveis de relatórios.
- name: Legislação
description: Fontes legais publicadas e versionadas.
- name: Administração
description: Operações administrativas protegidas por sessão e autorização.
paths:
/api/v1/catalogs/{catalog}:
get:
tags: [Catálogos]
operationId: listReferenceCatalog
summary: Consultar catálogo versionado
security: []
parameters:
- $ref: '#/components/parameters/CatalogName'
- $ref: '#/components/parameters/Version'
- $ref: '#/components/parameters/CatalogType'
- $ref: '#/components/parameters/ParentId'
- $ref: '#/components/parameters/JurisdictionId'
- $ref: '#/components/parameters/Page'
- $ref: '#/components/parameters/PerPage'
- $ref: '#/components/parameters/Search'
- $ref: '#/components/parameters/CorrelationId'
responses:
'200':
description: Página do catálogo na versão solicitada
headers:
ETag: {$ref: '#/components/headers/ETag'}
X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}
content:
application/json:
schema: {$ref: '#/components/schemas/CatalogPage'}
'404': {$ref: '#/components/responses/NotFound'}
'422': {$ref: '#/components/responses/ValidationError'}
'429': {$ref: '#/components/responses/RateLimited'}
'500': {$ref: '#/components/responses/ServerError'}
/api/v1/questionnaires/{code}:
get:
tags: [Simulações]
operationId: getPublishedQuestionnaire
summary: Consultar questionário publicado e versão imutável
security: []
parameters:
- $ref: '#/components/parameters/QuestionnaireCode'
- $ref: '#/components/parameters/CorrelationId'
responses:
'200':
description: Questionário publicado em pt-MZ
headers:
ETag: {$ref: '#/components/headers/ETag'}
X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}
content:
application/json:
schema: {$ref: '#/components/schemas/QuestionnaireResponse'}
'404': {$ref: '#/components/responses/NotFound'}
'429': {$ref: '#/components/responses/RateLimited'}
'500': {$ref: '#/components/responses/ServerError'}
/api/v1/simulations:
post:
tags: [Simulações]
operationId: createSimulation
summary: Criar simulação rastreável
description: Requer Idempotency-Key. Acesso anónimo requer token opaco gerado pelo cliente.
x-implementation-status: implemented
security:
- sessionCookie: []
- {}
parameters:
- $ref: '#/components/parameters/IdempotencyKey'
- $ref: '#/components/parameters/SimulationToken'
- $ref: '#/components/parameters/CorrelationId'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/SimulationInput'}
examples:
BoundaryValues:
summary: Exemplo técnico nos limites mínimos
description: Valores mínimos para demonstrar a forma do payload; não são valores fiscais recomendados.
value: {base: 0, rate: 0, reduction: 0}
responses:
'202':
description: Simulação aceite para processamento
headers:
ETag: {$ref: '#/components/headers/ETag'}
Location: {$ref: '#/components/headers/Location'}
X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}
content:
application/json:
schema: {$ref: '#/components/schemas/SimulationAccepted'}
'200':
description: Repetição idempotente da resposta original
headers:
ETag: {$ref: '#/components/headers/ETag'}
X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}
content:
application/json:
schema: {$ref: '#/components/schemas/SimulationAccepted'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
'422': {$ref: '#/components/responses/ValidationError'}
'428': {$ref: '#/components/responses/PreconditionRequired'}
'429': {$ref: '#/components/responses/RateLimited'}
'500': {$ref: '#/components/responses/ServerError'}
/api/v1/simulations/{id}:
get:
tags: [Simulações]
operationId: getSimulation
summary: Consultar simulação, cenários e estado de execução
x-implementation-status: implemented
security:
- sessionCookie: []
- {}
parameters:
- $ref: '#/components/parameters/SimulationId'
- $ref: '#/components/parameters/SimulationToken'
- $ref: '#/components/parameters/CorrelationId'
responses:
'200':
description: Simulação dentro do âmbito autorizado
headers:
ETag: {$ref: '#/components/headers/ETag'}
X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}
content:
application/json:
schema: {$ref: '#/components/schemas/SimulationResponse'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'429': {$ref: '#/components/responses/RateLimited'}
'500': {$ref: '#/components/responses/ServerError'}
/api/v1/simulations/{id}/scenarios/{scenarioId}:
patch:
tags: [Simulações]
operationId: updateSimulationScenario
summary: Actualizar cenário com controlo optimista de concorrência
x-implementation-status: implemented
security:
- sessionCookie: []
- {}
parameters:
- $ref: '#/components/parameters/SimulationId'
- $ref: '#/components/parameters/ScenarioId'
- $ref: '#/components/parameters/IfMatch'
- $ref: '#/components/parameters/SimulationToken'
- $ref: '#/components/parameters/CorrelationId'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/UpdateScenarioInput'}
responses:
'202':
description: Alteração aceite e reexecução colocada em fila
headers:
ETag: {$ref: '#/components/headers/ETag'}
X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}
content:
application/json:
schema: {$ref: '#/components/schemas/Scenario'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'412': {$ref: '#/components/responses/PreconditionFailed'}
'422': {$ref: '#/components/responses/ValidationError'}
'428': {$ref: '#/components/responses/PreconditionRequired'}
'429': {$ref: '#/components/responses/RateLimited'}
'500': {$ref: '#/components/responses/ServerError'}
/api/v1/simulations/{id}/jobs:
get:
tags: [Jobs]
operationId: listSimulationJobs
summary: Consultar jobs de execução e geração de relatórios
description: Contrato reservado para consulta dos jobs associados à simulação.
x-implementation-status: contract-only
security:
- sessionCookie: []
- {}
parameters:
- $ref: '#/components/parameters/SimulationId'
- $ref: '#/components/parameters/SimulationToken'
- $ref: '#/components/parameters/CorrelationId'
responses:
'200':
description: Jobs visíveis no âmbito autorizado
headers:
X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}
content:
application/json:
schema: {$ref: '#/components/schemas/JobCollection'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'429': {$ref: '#/components/responses/RateLimited'}
'500': {$ref: '#/components/responses/ServerError'}
/api/v1/simulations/{id}/reports/{reportId}:
get:
tags: [Relatórios]
operationId: getSimulationReport
summary: Obter snapshot imutável de relatório
x-implementation-status: implemented
security:
- sessionCookie: []
- {}
parameters:
- $ref: '#/components/parameters/SimulationId'
- $ref: '#/components/parameters/ReportId'
- $ref: '#/components/parameters/SimulationToken'
- $ref: '#/components/parameters/CorrelationId'
responses:
'200':
description: Snapshot e hash do relatório
headers:
X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}
content:
application/json:
schema: {$ref: '#/components/schemas/ReportResponse'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'429': {$ref: '#/components/responses/RateLimited'}
'500': {$ref: '#/components/responses/ServerError'}
/api/v1/legislation/sources:
get:
tags: [Legislação]
operationId: listLegislationSources
summary: Consultar fontes legais publicadas e versionadas
description: Contrato reservado para catálogo público de fontes; administração usa a API v2 existente.
x-implementation-status: contract-only
security: []
parameters:
- $ref: '#/components/parameters/Page'
- $ref: '#/components/parameters/PerPage'
- $ref: '#/components/parameters/Search'
- $ref: '#/components/parameters/CorrelationId'
responses:
'200':
description: Fontes legais publicadas
headers:
ETag: {$ref: '#/components/headers/ETag'}
X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}
content:
application/json:
schema: {$ref: '#/components/schemas/LegislationPage'}
'404': {$ref: '#/components/responses/NotFound'}
'422': {$ref: '#/components/responses/ValidationError'}
'429': {$ref: '#/components/responses/RateLimited'}
'500': {$ref: '#/components/responses/ServerError'}
/api/v2/investment/admin/legal-sources:
get:
tags: [Administração, Legislação]
operationId: adminListLegalSources
summary: Listar fontes legais para administração
x-implementation-status: implemented
security: [{sessionCookie: []}]
parameters:
- $ref: '#/components/parameters/CorrelationId'
responses:
'200':
description: Lista de fontes legais
headers:
X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}
content:
application/json:
schema: {$ref: '#/components/schemas/AdminLegalSourcePage'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'429': {$ref: '#/components/responses/RateLimited'}
'500': {$ref: '#/components/responses/ServerError'}
post:
tags: [Administração, Legislação]
operationId: adminCreateLegalSource
summary: Registar fonte legal para validação
x-implementation-status: implemented
security: [{sessionCookie: []}]
parameters:
- $ref: '#/components/parameters/CorrelationId'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/LegalSourceInput'}
responses:
'201':
description: Fonte legal registada
headers:
Location: {$ref: '#/components/headers/Location'}
X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}
content:
application/json:
schema: {$ref: '#/components/schemas/LegalSource'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
'422': {$ref: '#/components/responses/ValidationError'}
'429': {$ref: '#/components/responses/RateLimited'}
'500': {$ref: '#/components/responses/ServerError'}
/api/v2/investment/admin/legal-sources/{id}/{action}:
post:
tags: [Administração, Legislação]
operationId: adminTransitionLegalSource
summary: Validar ou revogar uma fonte legal
x-implementation-status: implemented
security: [{sessionCookie: []}]
parameters:
- $ref: '#/components/parameters/SourceId'
- name: action
in: path
required: true
schema: {type: string, enum: [validate, revoke]}
- $ref: '#/components/parameters/CorrelationId'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/LegalSourceTransitionInput'}
responses:
'200':
description: Estado actualizado
headers:
X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}
content:
application/json:
schema: {$ref: '#/components/schemas/LegalSource'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'409': {$ref: '#/components/responses/Conflict'}
'429': {$ref: '#/components/responses/RateLimited'}
'500': {$ref: '#/components/responses/ServerError'}
/api/v1/interest-requests:
post:
tags: [Manifestações]
operationId: submitInterestRequest
summary: Submeter manifestação de interesse
x-implementation-status: implemented
security: []
parameters:
- $ref: '#/components/parameters/IdempotencyKeyOptional'
- $ref: '#/components/parameters/CorrelationId'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/InterestRequestInput'}
responses:
'201':
description: Manifestação submetida
headers:
X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}
content:
application/json:
schema: {$ref: '#/components/schemas/InterestRequestResponse'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
'422': {$ref: '#/components/responses/ValidationError'}
'429': {$ref: '#/components/responses/RateLimited'}
'500': {$ref: '#/components/responses/ServerError'}
/api/v1/opportunities/{slug}:
get:
tags: [Oportunidades]
operationId: getOpportunity
summary: Consultar oportunidade publicada
x-implementation-status: implemented
security: []
parameters:
- name: slug
in: path
required: true
schema: {type: string, minLength: 1, maxLength: 180}
- $ref: '#/components/parameters/CorrelationId'
responses:
'200':
description: Oportunidade publicada
headers:
X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}
content:
application/json:
schema: {$ref: '#/components/schemas/OpportunityResponse'}
'404': {$ref: '#/components/responses/NotFound'}
'429': {$ref: '#/components/responses/RateLimited'}
'500': {$ref: '#/components/responses/ServerError'}
components:
securitySchemes:
sessionCookie:
type: apiKey
in: cookie
name: laravel-session
description: Sessão Laravel; o nome pode ser substituído por SESSION_COOKIE. Rotas públicas e simulações anónimas substituem explicitamente este requisito.
parameters:
CatalogName:
name: catalog
in: path
required: true
schema: {type: string, enum: [authorities, jurisdictions, economic-activities]}
Version:
name: version
in: query
required: false
description: Versão estável publicada do catálogo
schema: {type: integer, minimum: 1}
CatalogType:
name: type
in: query
required: false
description: Obrigatório para jurisdictions.
schema: {type: string, enum: [country, province, district, admin_post, locality]}
ParentId:
name: parent_id
in: query
schema: {type: string, format: uuid}
JurisdictionId:
name: jurisdiction_id
in: query
schema: {type: string, format: uuid}
Page:
name: page
in: query
schema: {type: integer, minimum: 1, default: 1}
PerPage:
name: per_page
in: query
schema: {type: integer, minimum: 1, maximum: 100, default: 20}
Search:
name: q
in: query
schema: {type: string, maxLength: 100}
QuestionnaireCode:
name: code
in: path
required: true
schema: {type: string, pattern: '^[a-z0-9][a-z0-9-]{0,79}$'}
SimulationId:
name: id
in: path
required: true
schema: {type: string, format: uuid}
ScenarioId:
name: scenarioId
in: path
required: true
schema: {type: string, format: uuid}
ReportId:
name: reportId
in: path
required: true
schema: {type: string, format: uuid}
SourceId:
name: id
in: path
required: true
schema: {type: string, format: uuid}
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
description: Repetições com a mesma chave e payload devolvem a resposta original; payload diferente produz conflito.
schema: {type: string, minLength: 16, maxLength: 128}
IdempotencyKeyOptional:
name: Idempotency-Key
in: header
required: false
description: Se fornecida, deve ser UUID; uma chave repetida com outro payload produz conflito.
schema: {type: string, format: uuid}
SimulationToken:
name: X-Simulation-Token
in: header
required: false
description: Obrigatório para acesso anónimo; token opaco do cliente, guardado apenas como hash.
schema: {type: string, minLength: 32, maxLength: 256}
IfMatch:
name: If-Match
in: header
required: true
description: ETag forte da representação lida previamente.
schema: {type: string, minLength: 1, maxLength: 128}
CorrelationId:
name: X-Correlation-ID
in: header
required: false
description: UUID opcional propagado pela aplicação; quando ausente, a aplicação cria um identificador.
schema: {type: string, format: uuid}
headers:
ETag:
description: Validador da representação; usado com If-Match em alterações concorrentes.
schema: {type: string}
Location:
description: URI relativa do recurso criado ou aceite.
schema: {type: string, format: uri-reference}
RetryAfter:
description: Segundos até à próxima tentativa.
schema: {type: integer, minimum: 0}
CorrelationId:
description: Identificador de correlação da operação.
schema: {type: string, format: uuid}
responses:
Unauthorized:
description: Autenticação necessária ou sessão inválida
headers: {X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}}
content: {application/json: {schema: {$ref: '#/components/schemas/Problem'}}}
Forbidden:
description: Actor autenticado sem autorização para a operação
headers: {X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}}
content: {application/json: {schema: {$ref: '#/components/schemas/Problem'}}}
NotFound:
description: Recurso inexistente ou fora do âmbito visível
headers: {X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}}
content: {application/json: {schema: {$ref: '#/components/schemas/Problem'}}}
Conflict:
description: Conflito de estado, versão ou chave idempotente
headers: {X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}}
content:
application/json:
schema: {$ref: '#/components/schemas/Problem'}
example: {message: A chave foi reutilizada com conteúdo diferente., code: idempotency_conflict}
PreconditionFailed:
description: If-Match não corresponde ao ETag actual
headers:
ETag: {$ref: '#/components/headers/ETag'}
X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}
content:
application/json:
schema: {$ref: '#/components/schemas/Problem'}
example: {message: A representação mudou; volte a consultar o recurso., code: stale_etag}
ValidationError:
description: Dados inválidos
headers: {X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}}
content: {application/json: {schema: {$ref: '#/components/schemas/ValidationProblem'}}}
RateLimited:
description: Limite de pedidos excedido
headers:
Retry-After: {$ref: '#/components/headers/RetryAfter'}
X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}
content: {application/json: {schema: {$ref: '#/components/schemas/Problem'}}}
ServerError:
description: Erro interno; resposta não contém detalhes sensíveis
headers: {X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}}
content: {application/json: {schema: {$ref: '#/components/schemas/Problem'}}}
PreconditionRequired:
description: Header obrigatório em falta, como If-Match ou Idempotency-Key
headers: {X-Correlation-ID: {$ref: '#/components/headers/CorrelationId'}}
content: {application/json: {schema: {$ref: '#/components/schemas/Problem'}}}
schemas:
Problem:
type: object
required: [message]
properties:
message: {type: string}
code: {type: string}
correlation_id: {type: string, format: uuid}
additionalProperties: true
ValidationProblem:
allOf:
- $ref: '#/components/schemas/Problem'
- type: object
properties:
errors:
type: object
additionalProperties:
type: array
items: {type: string}
CatalogItem:
type: object
required: [id, value, code, name, label, locale, version_no]
properties:
id: {type: string, format: uuid}
value: {type: string}
code: {type: string}
name: {type: string, description: Rótulo apresentado em português de Moçambique.}
label: {type: string}
locale: {type: string, const: pt-MZ}
parent_id: {type: [string, 'null']}
type: {type: [string, 'null'], enum: [country, province, district, admin_post, locality, 'null']}
authority_type: {type: [string, 'null']}
organization_id: {type: [string, 'null'], format: uuid}
jurisdiction_id: {type: [string, 'null'], format: uuid}
jurisdiction_name: {type: [string, 'null']}
source_code: {type: [string, 'null']}
sector_id: {type: [string, 'null']}
version_no: {type: integer}
CatalogPage:
type: object
required: [data, meta]
properties:
data: {type: array, items: {$ref: '#/components/schemas/CatalogItem'}}
links:
type: object
properties:
next: {type: [string, 'null'], format: uri-reference}
previous: {type: [string, 'null'], format: uri-reference}
next_page_url: {type: [string, 'null'], format: uri-reference}
prev_page_url: {type: [string, 'null'], format: uri-reference}
current_page: {type: integer}
per_page: {type: integer}
total: {type: integer}
last_page: {type: integer}
meta:
allOf:
- $ref: '#/components/schemas/Pagination'
- type: object
properties:
catalog: {type: string}
version: {type: [integer, 'null']}
locale: {type: string, const: pt-MZ}
source_version: {type: [string, 'null']}
status: {type: string, enum: [published, not_published]}
Pagination:
type: object
required: [current_page, per_page, total]
properties:
current_page: {type: integer, minimum: 1}
per_page: {type: integer, minimum: 1}
total: {type: integer, minimum: 0}
version: {type: string}
QuestionnaireResponse:
type: object
required: [data]
properties:
data:
type: object
required: [id, code, version_no, locale, schema_snapshot, status]
properties:
id: {type: string, format: uuid}
code: {type: string}
version_no: {type: integer, minimum: 1}
locale: {type: string, const: pt-MZ}
schema_snapshot: {type: object, additionalProperties: true}
status: {type: string, const: published}
valid_from: {type: [string, 'null'], format: date}
valid_to: {type: [string, 'null'], format: date}
SimulationInput:
type: object
required: [base, rate]
properties:
base: {type: number, minimum: 0, description: Valor de entrada sem moeda inferida.}
rate: {type: number, minimum: 0, maximum: 100, description: Parâmetro fornecido pelo utilizador; não representa taxa oficial.}
reduction: {type: number, minimum: 0, maximum: 100, default: 0}
organization_id: {type: [string, 'null'], format: uuid}
additionalProperties: false
UpdateScenarioInput:
type: object
required: [label, base, rate]
properties:
label: {type: string, minLength: 1, maxLength: 100}
base: {type: number, minimum: 0}
rate: {type: number, minimum: 0, maximum: 100}
reduction: {type: number, minimum: 0, maximum: 100, default: 0}
additionalProperties: false
SimulationAccepted:
type: object
required: [data, replayed]
properties:
data: {$ref: '#/components/schemas/Simulation'}
replayed: {type: boolean}
access_token: {type: string, minLength: 32, maxLength: 256, description: Devolvido apenas para execução anónima.}
SimulationResponse:
type: object
required: [data]
properties:
data: {$ref: '#/components/schemas/Simulation'}
Simulation:
type: object
required: [id, status, version_no, created_at, updated_at]
properties:
id: {type: string, format: uuid}
questionnaire_version_id: {type: [string, 'null'], format: uuid}
organization_id: {type: [string, 'null'], format: uuid}
version_no: {type: integer, minimum: 1}
status: {type: string, enum: [queued, running, completed, failed, cancelled]}
input_snapshot: {type: object, additionalProperties: true}
result_snapshot: {type: [object, 'null'], additionalProperties: true}
calculation_snapshot: {type: [object, 'null'], additionalProperties: true}
scenarios: {type: array, items: {$ref: '#/components/schemas/Scenario'}}
created_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time}
Scenario:
type: object
required: [id, code, label, version_no, parameters]
properties:
id: {type: string, format: uuid}
code: {type: string}
label: {type: string}
version_no: {type: integer, minimum: 1}
parameters: {type: object, additionalProperties: true}
JobCollection:
type: object
required: [data]
properties:
data: {type: array, items: {$ref: '#/components/schemas/SimulationJob'}}
SimulationJob:
type: object
required: [id, simulation_id, job_type, status, attempts, available_at]
properties:
id: {type: string, format: uuid}
simulation_id: {type: string, format: uuid}
job_type: {type: string, enum: [execute, report]}
status: {type: string, enum: [pending, running, succeeded, failed]}
attempts: {type: integer, minimum: 0, maximum: 3}
available_at: {type: string, format: date-time}
started_at: {type: [string, 'null'], format: date-time}
finished_at: {type: [string, 'null'], format: date-time}
error_code: {type: [string, 'null']}
ReportResponse:
type: object
required: [data]
properties:
data: {$ref: '#/components/schemas/SimulationReport'}
SimulationReport:
type: object
required: [id, simulation_id, version_no, status]
properties:
id: {type: string, format: uuid}
simulation_id: {type: string, format: uuid}
version_no: {type: integer, minimum: 1}
status: {type: string, enum: [queued, ready, failed]}
report_snapshot: {type: [object, 'null'], additionalProperties: true}
report_sha256: {type: [string, 'null'], pattern: '^[0-9a-f]{64}$'}
LegislationPage:
type: object
required: [data, meta]
properties:
data: {type: array, items: {$ref: '#/components/schemas/LegalSource'}}
meta: {$ref: '#/components/schemas/Pagination'}
AdminLegalSourcePage:
type: object
required: [data]
properties:
data: {type: array, items: {$ref: '#/components/schemas/LegalSource'}}
current_page: {type: integer}
per_page: {type: integer}
total: {type: integer}
last_page: {type: integer}
first_page_url: {type: [string, 'null'], format: uri-reference}
last_page_url: {type: [string, 'null'], format: uri-reference}
next_page_url: {type: [string, 'null'], format: uri-reference}
prev_page_url: {type: [string, 'null'], format: uri-reference}
LegalSourceInput:
type: object
required: [title, url, diploma, article, competent_entity, valid_from]
properties:
title: {type: string, minLength: 1, maxLength: 255}
url: {type: string, format: uri, pattern: '^https://', maxLength: 2000}
diploma: {type: string, maxLength: 255}
article: {type: string, maxLength: 255}
competent_entity: {type: string, maxLength: 255}
valid_from: {type: string, format: date}
valid_until: {type: [string, 'null'], format: date}
additionalProperties: false
LegalSourceTransitionInput:
type: object
required: [version_no]
properties:
version_no: {type: integer, minimum: 1}
additionalProperties: false
LegalSource:
type: object
properties:
id: {type: string, format: uuid}
title: {type: string}
url: {type: string, format: uri}
diploma: {type: string}
article: {type: string}
competent_entity: {type: string}
validation_status: {type: string, enum: [pending, validated, revoked]}
version_no: {type: integer}
valid_until: {type: [string, 'null'], format: date}
validator_id: {type: [integer, 'null']}
validated_at: {type: [string, 'null'], format: date-time}
valid_from: {type: [string, 'null'], format: date}
InterestRequestInput:
type: object
required: [fullName, email, investorType, country, sectorInterest, message, privacyConsent]
properties:
fullName: {type: string, minLength: 3, maxLength: 150, writeOnly: true}
email: {type: string, format: email, maxLength: 150, writeOnly: true}
phone: {type: [string, 'null'], maxLength: 30, writeOnly: true}
investorType: {type: string, enum: [individual, empresa, fundo, entidade_publica, outro]}
country: {type: string, maxLength: 100}
sectorInterest: {type: string, maxLength: 150}
opportunityReference: {type: [string, 'null'], maxLength: 80}
existingBusinessId: {type: [string, 'null'], format: uuid}
modality: {type: [string, 'null'], maxLength: 50}
investmentRange: {type: [string, 'null'], maxLength: 80}
message: {type: string, minLength: 20, maxLength: 2000, writeOnly: true}
privacyConsent: {type: boolean, const: true}
shareWithProponent: {type: boolean, default: false}
additionalProperties: false
not:
allOf:
- required: [opportunityReference]
properties:
opportunityReference: {type: string, minLength: 1}
- required: [existingBusinessId]
properties:
existingBusinessId: {type: string, format: uuid}
allOf:
- if:
required: [existingBusinessId]
properties:
existingBusinessId: {type: string}
then:
required: [modality]
properties:
modality: {type: string, minLength: 1, maxLength: 50}
InterestRequestResponse:
type: object
required: [success, message, data]
properties:
success: {type: boolean, const: true}
message: {type: string}
data:
type: object
required: [reference, status]
properties:
reference: {type: string}
status: {type: string, const: received}
OpportunityResponse:
type: object
required: [success, data]
properties:
success: {type: boolean, const: true}
data:
type: object
required: [id, slug, title]
properties:
id: {type: [string, integer]}
slug: {type: string}
title: {type: string}
summary: {type: [string, 'null']}
status: {type: string}
@@ -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,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,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,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,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;
@@ -33,8 +36,17 @@ 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'])),
@@ -47,7 +59,7 @@ class InterestRequestController extends Controller
'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(),
@@ -78,6 +90,12 @@ class InterestRequestController extends Controller
} 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');
}
}
@@ -0,0 +1,187 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ReferenceCatalogController extends Controller
{
private const CATALOGS = ['authorities', 'jurisdictions', 'economic-activities'];
public function index(Request $request, string $catalog): JsonResponse
{
abort_unless(in_array($catalog, self::CATALOGS, true), 404);
$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'];
}
$data = $request->validate($rules);
$versionQuery = DB::table('reference_catalog_versions')->where('catalog_code', $catalog)->where('locale', 'pt-MZ');
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, $row))->values();
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).'"');
}
/** @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 (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'])) {
$query->where('sector_id', $data['sector_id']);
}
if (! empty($data['q'])) {
$this->search($query, $data['q'], ['code', 'label_pt_mz']);
}
return $query;
}
/** @param list<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) {
$method = $index === 0 ? 'whereRaw' : 'orWhereRaw';
$nested->{$method}($column.' ILIKE ?', [$term]);
}
});
}
/** @return array<string,mixed> */
private function present(string $catalog, object $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],
};
}
}
@@ -0,0 +1,119 @@
<?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\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();
$data = ['base' => (string) $validated['base'], 'rate' => (string) $validated['rate'],
'reduction' => (string) ($validated['reduction'] ?? '0'), 'organization_id' => $validated['organization_id'] ?? null];
$organizationId = $data['organization_id'] ?? null;
$execution->authorizeOrganization($actorId, $organizationId);
$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 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,60 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreSimulationFormDraftRequest;
use App\Models\SimulationFormDraft;
use App\Services\SimulationFormDrafts;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
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->validated(), $request, $key);
$draft = $result['draft'];
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no, 'replayed' => $result['replayed']]], $result['replayed'] ? 200 : 201)
->header('ETag', $drafts->etag($draft))->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)])->header('ETag', $drafts->etag($draft))->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)])->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->validated(), $request);
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no]])
->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');
}
}
+4 -1
View File
@@ -62,7 +62,9 @@ class EducationController extends Controller
'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']),
'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(),
]);
}
@@ -148,6 +150,7 @@ 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']),
'territoryPath' => app(InvestmentGeography::class)->path($demand->getAttribute('geo_location_id'), true),
'eligible' => $eligible, 'gap' => $calculator->observed((int) $demand->getAttribute('quantity'), null, $eligible, $current)]);
}
}
@@ -5,6 +5,7 @@ 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;
@@ -27,6 +28,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 +43,10 @@ class EducationInvestmentController extends Controller
if (EducationInvestmentProposal::withTrashed()->where('labour_demand_id', $id)->exists()) {
return;
}
$territory = app(InvestmentGeography::class)->selection($data, true);
$province = $territory[0];
$district = $territory[1] ?? null;
$primaryTerritory = $territory[array_key_last($territory)];
$reference = 'EDU-'.Str::uuid();
$opportunity = new Opportunity;
PortalAudit::save($opportunity, [
@@ -45,6 +54,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, [
@@ -4,10 +4,14 @@ namespace App\Http\Controllers;
use App\Models\ExistingBusiness;
use App\Models\ExistingBusinessObject;
use App\Models\Sector;
use App\Services\BusinessFormSchema;
use App\Services\ExistingBusinessDrafts;
use App\Services\ExistingBusinessIntake;
use App\Services\ExistingBusinessObjects;
use App\Services\ExistingBusinessVerification;
use App\Services\Mne\BusinessRepository;
use App\Services\Mne\CompatibilityShadow;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
@@ -21,10 +25,12 @@ 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',
]);
} else {
$query->where('owner_user_id', $request->user()->id);
}
@@ -36,10 +42,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 = ['sectors' => Sector::where('is_active', true)->orderBy('name')->get(['id', 'name']), 'formSchema' => BusinessFormSchema::fields(), '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 +74,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 +90,16 @@ 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];
}
}
@@ -24,7 +24,7 @@ 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(),
'territorySelection' => app(InvestmentGeography::class)->selection($filters),
'results' => $catalogue->query($filters)->paginate(12)->withQueryString(),
'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [],
@@ -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));
});
}
@@ -82,7 +82,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;
@@ -30,13 +30,18 @@ class InvestmentTerritoryController extends Controller
$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
@@ -78,7 +78,7 @@ class InvestmentZoneController extends Controller
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;
@@ -6,12 +6,16 @@ use App\Models\InterestRequest;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use App\Services\InterestWorkflow;
use App\Services\InvestmentGeography;
use App\Services\InvestmentWorkspace;
use App\Services\PortalAudit;
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 +26,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
{
@@ -50,9 +54,10 @@ class PortalManagementController extends Controller
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in(self::STATES)],
'follow_up' => ['nullable', Rule::in(['overdue', 'upcoming', 'unassigned'])],
]);
$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', 'sector_interest', 'existing_business_id', 'modality', 'status', 'assigned_to', 'next_action', 'next_action_due_at', 'created_at',
]);
if ($term = $filters['q'] ?? null) {
$query->where(fn ($q) => $q->whereLike('reference', "%{$term}%")
@@ -61,6 +66,15 @@ 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(),
@@ -70,31 +84,94 @@ class PortalManagementController extends Controller
public function interest(InterestRequest $interest): Response
{
$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', [
'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',
]),
'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']]);
$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'))],
]);
$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' => $updated->reference, 'status' => $updated->status]) : back()->with('success', 'Estado do pedido actualizado.');
}
public function withdrawProponentSharingConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{
$data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
DB::transaction(function () use ($request, $interest, $data) {
$consent = DB::table('consents')->where('interest_request_id', $interest->id)
->where('purpose', 'share_contact_with_proponent')->where('status', 'granted')->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' => $interest->id,
'from_status' => $interest->status, 'to_status' => $interest->status,
'actor_id' => $request->user()->id, 'reason' => Crypt::encryptString($data['reason']),
'correlation_id' => $correlationId,
'metadata' => json_encode(['source' => 'management-interest', 'action' => 'consent_withdrawn',
'purpose' => 'share_contact_with_proponent'], 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' => $interest->integration_id, 'purpose' => 'share_contact_with_proponent'], JSON_THROW_ON_ERROR),
]);
});
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'status' => $validated['status']]) : back()->with('success', 'Estado do pedido actualizado.');
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'sharing_consent' => 'withdrawn'])
: back()->with('success', 'Consentimento de partilha revogado.');
}
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')->whereIn(DB::raw('lower(email)'), $emails)->orderBy('name');
}
public function catalog(Request $request, string $resource): Response
@@ -140,10 +217,12 @@ class PortalManagementController extends Controller
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,
'record' => $record,
'territoryPath' => $record instanceof Opportunity ? app(InvestmentGeography::class)->path($record->primary_territory_id, true) : [],
'sectors' => Sector::orderBy('name')->get(['id', 'name']),
]);
}
@@ -178,20 +257,13 @@ class PortalManagementController extends Controller
'status' => ['required', Rule::in(['draft', 'published', 'archived'])],
'is_featured' => ['required', 'boolean'],
];
if ($resource === 'opportunities' && ! $id) {
if ($resource === 'opportunities') {
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 === 'opportunities' && $data['status'] === 'published' && $record->exists
&& Schema::hasTable('education_investment_proposals')
&& DB::table('education_investment_proposals')->where('opportunity_id', $record->id)->exists()) {
@@ -205,7 +277,24 @@ 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);
$data = array_diff_key($data, InvestmentGeography::FIELDS);
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
$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,86 @@
<?php
namespace App\Http\Controllers;
use App\Http\Resources\PublicBusinessOpportunityResource;
use App\Models\ExistingBusiness;
use App\Services\ExistingBusinessDrafts;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class PublicBusinessOpportunityController extends Controller
{
public function index(Request $request): Response|JsonResponse
{
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:120'],
'sector' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
]);
$query = $this->publishedQuery();
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'])) {
$query->where('existing_businesses.sector_id', $filters['sector']);
}
$results = PublicBusinessOpportunityResource::collection($query->paginate($filters['per_page'] ?? 12)->withQueryString());
if ($request->expectsJson()) {
return $results->response();
}
return Inertia::render('investment/business-marketplace', ['results' => $results, 'filters' => $filters]);
}
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();
}
return Inertia::render('investment/business-opportunity', ['record' => $resource]);
}
public function interest(Request $request, string $slug): Response
{
$record = $this->publishedQuery()->where('existing_businesses.public_slug', $slug)->firstOrFail();
return Inertia::render('investment/business-interest', [
'record' => (new PublicBusinessOpportunityResource($record))->resolve($request),
'modalities' => ExistingBusinessDrafts::MODALITIES,
]);
}
/** @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.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',
'public_location.province', 'public_location.district', 'public_location.locality']);
}
}
@@ -18,9 +18,10 @@ class PublicGeographyController extends Controller
$query->where('parent_id', $parent ?? '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']));
}
}
+34
View File
@@ -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,20 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class PreserveFormFeedback
{
public function handle(Request $request, Closure $next): Response
{
// Auxiliary JSON reads must not consume feedback intended for the next Inertia page.
if ($request->isMethod('GET') && $request->expectsJson() && ! $request->header('X-Inertia') && $request->hasSession()) {
$request->session()->keep(['errors', '_old_input', 'success']);
}
return $next($request);
}
}
@@ -2,10 +2,12 @@
namespace App\Http\Requests;
use App\Services\ExistingBusinessDrafts;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class StoreInterestRequestRequest extends FormRequest
{
@@ -36,12 +38,28 @@ class StoreInterestRequestRequest extends FormRequest
->where('status', 'published')->whereNotNull('published_at')
->where('published_at', '<=', now())->whereNull('deleted_at')),
])],
'existingBusinessId' => ['nullable', 'uuid', ...($knownKey ? [] : [
Rule::exists('existing_businesses', 'id')->where(fn ($query) => $query
->where('status', 'published')->whereNotNull('published_at')->where('published_at', '<=', now())->whereNull('deleted_at')),
])],
'modality' => ['nullable', 'required_with:existingBusinessId', 'string', 'max:50', Rule::in(ExistingBusinessDrafts::MODALITIES)],
'investmentRange' => ['nullable', 'string', 'max:80'],
'message' => ['required', 'string', 'min:20', 'max:2000'],
'privacyConsent' => ['required', 'accepted'],
'shareWithProponent' => ['sometimes', 'boolean'],
];
}
/** @return list<callable(Validator): void> */
public function after(): array
{
return [function (Validator $validator): void {
if ($this->filled('existingBusinessId') && $this->filled('opportunityReference')) {
$validator->errors()->add('existingBusinessId', 'Seleccione apenas uma oportunidade.');
}
}];
}
public function messages(): array
{
return [
@@ -0,0 +1,195 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
use Illuminate\Support\Facades\DB;
class StoreSimulationFormDraftRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<mixed>> */
public function rules(): array
{
$personal = [
'person_type', 'investor_origin', 'tax_residency_country', 'entity_stage', 'legal_form', 'nuit',
'investor_profile_id', 'profile_confirmation', 'legal_name', 'commercial_name', 'registration_number',
'representative_name', 'representative_capacity', 'email', 'phone', 'address', 'contact_country', 'selection_labels',
'contact_province', 'contact_district', 'preferred_channel',
];
$economic = [
'purpose', 'manifestation_type', 'preferred_language', 'project_title', 'project_summary', 'selection_labels', 'project_type',
'project_type_other', 'sector_id', 'activity_id', 'products_services', 'maturity_stage', 'expected_start_date',
'project_duration_years', 'existing_project_id', 'province_id', 'district_id', 'admin_post_id', 'locality_id',
'site_known', 'latitude', 'longitude', 'area_required', 'area_unit', 'special_zone_id', 'land_status',
'infrastructure_needs', 'currency', 'total_investment', 'own_capital', 'domestic_debt', 'foreign_debt',
'grants', 'secured_amount', 'sought_amount', 'annual_revenue', 'annual_operating_costs', 'expected_profit',
'financing_term_months', 'funds_origin_countries', 'direct_jobs', 'indirect_jobs', 'national_jobs',
'foreign_jobs', 'skills_needed', 'local_content_percentage', 'exports_value', 'import_substitution_value',
'environmental_category', 'community_benefits', 'tax_calculation', 'base', 'rate', 'reduction',
'sales_taxable', 'sales_exempt', 'sales_zero_rate', 'exports', 'deductible_costs', 'non_deductible_costs',
'carried_losses', 'payroll', 'outbound_payments', 'imports', 'claimed_benefits', 'reference_date',
'offer_or_demand', 'opportunity_type', 'opportunity_type_other', 'opportunity_description',
'opportunity_value', 'opportunity_currency', 'opportunity_conditions', 'opportunity_deadline',
'required_documents', 'confidentiality', 'publish_requested', 'terms_accepted', 'truth_declaration', 'reuse_simulation',
'contact_authorisation', 'publication_authorisation', 'public_fields_confirmation', 'validation_acknowledged',
'final_declaration_name',
];
$rules = [
'personal_snapshot' => ['required', 'array:'.implode(',', $personal)],
'economic_snapshot' => ['required', 'array:'.implode(',', $economic)],
'economic_snapshot.purpose' => ['required', 'in:simulate_only,own_investment,seek_investor,seek_partner,present_opportunity,request_facilitation'],
'economic_snapshot.manifestation_type' => ['nullable', 'in:investment,business'],
'economic_snapshot.preferred_language' => ['nullable', 'in:pt-MZ,en'],
'economic_snapshot.project_type' => ['nullable', 'in:new,expansion,acquisition,partnership,concession,supplier,buyer,other'],
'economic_snapshot.maturity_stage' => ['nullable', 'in:idea,concept,pre_feasibility,feasibility,financing,implementation,operation'],
'economic_snapshot.land_status' => ['nullable', 'in:owned,duat,leased,requested,unknown'],
'economic_snapshot.offer_or_demand' => ['nullable', 'in:offer,demand,both'],
'economic_snapshot.opportunity_type' => ['nullable', 'in:capital,partner,buyer,supplier,land,technology,licence,services,other'],
'economic_snapshot.confidentiality' => ['nullable', 'in:public,registered_users,restricted,confidential'],
'economic_snapshot.site_known' => ['nullable', 'in:yes,no'],
'economic_snapshot.tax_calculation' => ['nullable', 'in:yes,no'],
'economic_snapshot.publish_requested' => ['nullable', 'in:yes,no'],
'economic_snapshot.terms_accepted' => ['nullable', 'in:yes,no'],
'economic_snapshot.truth_declaration' => ['nullable', 'in:yes,no'],
'economic_snapshot.reuse_simulation' => ['nullable', 'in:yes,no'],
'economic_snapshot.contact_authorisation' => ['nullable', 'in:yes,no'],
'economic_snapshot.publication_authorisation' => ['nullable', 'in:yes,no'],
'economic_snapshot.public_fields_confirmation' => ['nullable', 'in:yes,no'],
'economic_snapshot.validation_acknowledged' => ['nullable', 'in:yes,no'],
'economic_snapshot.currency' => ['nullable', 'string', 'regex:/^[A-Z]{3}$/'],
'economic_snapshot.opportunity_currency' => ['nullable', 'string', 'regex:/^[A-Z]{3}$/'],
'economic_snapshot.rate' => ['nullable', 'numeric', 'between:0,100'],
'economic_snapshot.reduction' => ['nullable', 'numeric', 'between:0,100'],
'economic_snapshot.local_content_percentage' => ['nullable', 'numeric', 'between:0,100'],
'economic_snapshot.latitude' => ['nullable', 'numeric', 'between:-90,90'],
'economic_snapshot.longitude' => ['nullable', 'numeric', 'between:-180,180'],
'economic_snapshot.expected_start_date' => ['nullable', 'date'],
'economic_snapshot.opportunity_deadline' => ['nullable', 'date'],
'economic_snapshot.reference_date' => ['nullable', 'date'],
'economic_snapshot.*' => ['nullable'],
];
foreach (['base', 'area_required', 'total_investment', 'own_capital', 'domestic_debt', 'foreign_debt', 'grants',
'secured_amount', 'sought_amount', 'annual_revenue', 'annual_operating_costs', 'expected_profit', 'exports_value',
'import_substitution_value', 'sales_taxable', 'sales_exempt', 'sales_zero_rate', 'exports', 'deductible_costs',
'non_deductible_costs', 'carried_losses', 'opportunity_value'] as $field) {
$rules['economic_snapshot.'.$field] = ['nullable', 'numeric', 'min:0'];
}
foreach (['direct_jobs', 'indirect_jobs', 'national_jobs', 'foreign_jobs', 'financing_term_months', 'project_duration_years'] as $field) {
$rules['economic_snapshot.'.$field] = ['nullable', 'integer', 'min:0'];
}
foreach (['infrastructure_needs', 'funds_origin_countries', 'required_documents'] as $field) {
$rules['economic_snapshot.'.$field] = ['nullable', 'array', 'max:100'];
$rules['economic_snapshot.'.$field.'.*'] = ['nullable', 'string', 'max:3000'];
}
$rules['economic_snapshot.infrastructure_needs.*'] = ['nullable', 'in:energia,água,estrada,porto,ferrovia,telecomunicações,armazenamento'];
$rules['economic_snapshot.required_documents.*'] = ['nullable', 'in:identity_document,company_registration,nuit_certificate,company_profile,business_plan,feasibility_study,proof_of_funds,special_zone_certificate,licence,site_map'];
foreach (['skills_needed', 'payroll', 'outbound_payments', 'imports', 'claimed_benefits', 'opportunity_conditions'] as $field) {
$rules['economic_snapshot.'.$field] = ['nullable', 'string', 'max:10000'];
}
$rules['economic_snapshot.selection_labels'] = ['nullable', 'array'];
foreach (['project_summary', 'opportunity_description', 'products_services', 'community_benefits'] as $field) {
$rules['economic_snapshot.'.$field] = ['nullable', 'string', 'max:10000'];
}
$rules['personal_snapshot.email'] = ['nullable', 'email:rfc', 'max:254'];
$rules['personal_snapshot.nuit'] = ['nullable', 'string', 'max:32'];
$rules['personal_snapshot.investor_profile_id'] = ['nullable', 'uuid'];
$rules['personal_snapshot.profile_confirmation'] = ['nullable', 'boolean'];
$rules['personal_snapshot.selection_labels'] = ['nullable', 'array'];
$rules['personal_snapshot.person_type'] = ['nullable', 'in:singular,collective,sole_trader,consortium'];
$rules['personal_snapshot.investor_origin'] = ['nullable', 'in:national,foreign,mixed'];
$rules['personal_snapshot.entity_stage'] = ['nullable', 'in:existing,being_formed'];
$rules['economic_snapshot.sector_id'] = ['nullable', 'integer', 'exists:sectors,id'];
$rules['economic_snapshot.activity_id'] = ['nullable', 'uuid', 'exists:economic_activities,id'];
foreach (['existing_project_id', 'special_zone_id'] as $field) {
$rules['economic_snapshot.'.$field] = ['nullable', 'string', 'max:100'];
}
foreach (['province_id', 'district_id', 'admin_post_id', 'locality_id'] as $field) {
$rules['economic_snapshot.'.$field] = ['nullable', 'uuid', 'exists:geo_locations,id'];
}
foreach (['person_type', 'investor_origin', 'tax_residency_country', 'entity_stage', 'legal_form', 'commercial_name',
'registration_number', 'representative_name', 'representative_capacity', 'phone', 'address', 'contact_country',
'contact_province', 'contact_district', 'preferred_channel'] as $field) {
$rules['personal_snapshot.'.$field] = ['nullable', 'string', 'max:3000'];
}
return $rules;
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$economic = $this->input('economic_snapshot', []);
if (! is_array($economic)) {
return;
}
$allowedLabels = [
'economic_snapshot.selection_labels' => ['purpose', 'manifestation_type', 'preferred_language', 'project_type', 'sector_id', 'activity_id',
'province_id', 'district_id', 'admin_post_id', 'locality_id', 'site_known', 'land_status', 'infrastructure_needs',
'area_unit', 'required_documents', 'opportunity_currency',
'currency', 'funds_origin_countries', 'environmental_category', 'tax_calculation', 'offer_or_demand',
'opportunity_type', 'confidentiality', 'publish_requested', 'terms_accepted', 'truth_declaration',
'reuse_simulation', 'contact_authorisation', 'publication_authorisation', 'public_fields_confirmation', 'validation_acknowledged'],
'personal_snapshot.selection_labels' => ['person_type', 'investor_origin', 'tax_residency_country', 'entity_stage', 'legal_form', 'preferred_channel'],
];
foreach ($allowedLabels as $path => $allowedFields) {
$snapshot = str_starts_with($path, 'personal_') ? $this->input('personal_snapshot.selection_labels', []) : ($economic['selection_labels'] ?? []);
foreach (is_array($snapshot) ? $snapshot : [] as $field => $label) {
$labels = is_array($label) ? $label : [$label];
if (! is_string($field) || ! in_array($field, $allowedFields, true) || count($labels) > 100
|| collect($labels)->contains(fn ($value) => ! is_string($value) || mb_strlen($value) > 255)) {
$validator->errors()->add($path, 'Os rótulos de selecção não são válidos.');
break 2;
}
}
}
if (isset($economic['activity_id'], $economic['sector_id'])) {
$matchesSector = DB::table('economic_activities as a')
->join('reference_catalog_versions as v', 'v.id', '=', 'a.catalog_version_id')
->where('a.id', $economic['activity_id'])->where('a.sector_id', $economic['sector_id'])
->where('a.status', 'published')->where('v.status', 'published')
->whereDate('a.valid_from', '<=', today())
->where(fn ($query) => $query->whereNull('a.valid_to')->orWhereDate('a.valid_to', '>', today()))->exists();
if (! $matchesSector) {
$validator->errors()->add('economic_snapshot.activity_id', 'A actividade não pertence ao sector seleccionado ou já não está publicada.');
}
} elseif (! empty($economic['activity_id'])) {
$validator->errors()->add('economic_snapshot.sector_id', 'Seleccione o sector antes da actividade económica.');
}
$parents = ['district_id' => ['province_id', 'district'], 'admin_post_id' => ['district_id', 'admin_post'], 'locality_id' => ['admin_post_id', 'locality']];
foreach ($parents as $child => [$parentField, $type]) {
if (empty($economic[$child])) {
continue;
}
$parentType = ['district' => 'province', 'admin_post' => 'district', 'locality' => 'admin_post'][$type];
$valid = ! empty($economic[$parentField]) && DB::table('geo_locations as child')
->join('geo_locations as parent', 'parent.id', '=', 'child.parent_id')
->where('child.id', $economic[$child])->where('child.location_type', $type)
->where('child.source_system', 'cartografia-workbook')->where('child.data_classification', 'public')->whereNull('child.deleted_at')
->where('parent.id', $economic[$parentField])->where('parent.location_type', $parentType)
->where('parent.source_system', 'cartografia-workbook')->where('parent.data_classification', 'public')->whereNull('parent.deleted_at')->exists();
if (! $valid) {
$validator->errors()->add('economic_snapshot.'.$child, 'Seleccione uma opção publicada dependente do nível territorial anterior.');
}
}
$financeKeys = ['total_investment', 'own_capital', 'domestic_debt', 'foreign_debt', 'grants'];
if (collect($financeKeys)->every(fn (string $key) => array_key_exists($key, $economic) && $economic[$key] !== null && $economic[$key] !== '')) {
$funding = (float) $economic['own_capital'] + (float) $economic['domestic_debt'] + (float) $economic['foreign_debt'] + (float) $economic['grants'];
if (abs($funding - (float) $economic['total_investment']) > 0.01) {
$validator->errors()->add('economic_snapshot.total_investment', 'A soma do capital, dívida e subvenções deve reconciliar com o investimento total.');
}
}
});
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreSimulationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<mixed>> */
public function rules(): array
{
return [
'base' => ['required', 'numeric', 'min:0'],
'rate' => ['required', 'numeric', 'between:0,100'],
'reduction' => ['required', 'numeric', 'between:0,100'],
'organization_id' => ['nullable', 'uuid', 'exists:organizations,id'],
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateSimulationScenarioRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<mixed>> */
public function rules(): array
{
return [
'label' => ['required', 'string', 'min:1', 'max:100'],
'base' => ['required', 'numeric', 'min:0'],
'rate' => ['required', 'numeric', 'between:0,100'],
'reduction' => ['required', 'numeric', 'between:0,100'],
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PublicBusinessOpportunityResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'slug' => $this->public_slug,
'title' => $this->public_title,
'activity_code' => $this->activity_code,
'stage' => $this->stage,
'objective' => $this->objective,
'currency' => $this->currency,
'amount_min' => $this->amount_min,
'amount_max' => $this->amount_max,
'available_percentage' => $this->available_percentage,
'published_at' => $this->published_at,
'sector' => $this->whenLoaded('sector', fn () => $this->sector?->only(['name', 'slug'])),
'province' => $this->province,
'district' => $this->district,
'locality' => $this->locality,
];
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Jobs;
use App\Services\Mne\LegacyBusinessRepository;
use App\Services\Mne\MneBusinessRepository;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
final class CompareMneBusiness implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public function __construct(public string $businessId) {}
public function handle(LegacyBusinessRepository $legacy, MneBusinessRepository $canonical): void
{
$start = hrtime(true);
try {
$source = $legacy->find($this->businessId);
$comparison = $canonical->comparison($this->businessId);
$expected = $source->only(['code', 'modality', 'status', 'classification', 'version_no']);
$outcome = $comparison === null ? 'unmapped' : ($expected == $comparison ? 'core_match' : 'divergence');
} catch (\Throwable) {
$outcome = 'comparison_error';
}
// No payloads, query parameters, identifiers or exception messages in telemetry.
Log::info('mne.compatibility.shadow', ['outcome' => $outcome, 'latency_ms' => (hrtime(true) - $start) / 1000000]);
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace App\Jobs;
use App\Models\Simulation;
use App\Services\SimulationExecution;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class RunSimulation implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 30;
public function __construct(public string $simulationId, public ?int $actorId, public ?string $tokenHash) {}
/** @return list<int> */
public function backoff(): array
{
return [10, 60];
}
public function handle(SimulationExecution $execution): void
{
$shouldRun = DB::transaction(function () use ($execution): bool {
$execution->setContext($this->actorId, $this->tokenHash);
$job = DB::table('simulation_jobs')->where('simulation_id', $this->simulationId)->where('job_type', 'execute')->lockForUpdate()->first();
if (! $job || in_array($job->status, ['succeeded', 'failed'], true)) {
return false;
}
DB::table('simulation_jobs')->where('id', $job->id)->update([
'status' => 'running', 'attempts' => min(3, max((int) $job->attempts + 1, (int) $this->attempts())),
'started_at' => now(), 'updated_at' => now(),
]);
return true;
});
if (! $shouldRun) {
return;
}
DB::transaction(function () use ($execution): void {
$execution->setContext($this->actorId, $this->tokenHash);
$simulation = Simulation::whereNull('deleted_at')->lockForUpdate()->find($this->simulationId);
if (! $simulation || $simulation->status === 'completed') {
return;
}
$job = DB::table('simulation_jobs')->where('simulation_id', $this->simulationId)->where('job_type', 'execute')->lockForUpdate()->first();
if (! $job || in_array($job->status, ['succeeded', 'failed'], true)) {
return;
}
$scenario = DB::table('simulation_scenarios')->where('simulation_id', $simulation->id)->orderBy('code')->first();
if (! $scenario) {
throw new \RuntimeException('Simulation has no scenario.');
}
$input = json_decode($scenario->parameters, true, flags: JSON_THROW_ON_ERROR);
$row = DB::selectOne('select (CAST(? AS numeric) * CAST(? AS numeric) / 100 * (1 - CAST(? AS numeric) / 100))::text as value', [$input['base'], $input['rate'], $input['reduction']]);
$result = ['base' => $input['base'], 'rate_percent' => $input['rate'], 'reduction_percent' => $input['reduction'], 'indicative_total' => $row->value,
'currency' => null, 'legal_interpretation' => false, 'formula_version' => 'portal-tax-arithmetic-v1'];
$json = json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
$canonicalResult = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$json])->canonical;
$resultHash = hash('sha256', $canonicalResult);
$resultId = (string) Str::uuid();
$engineVersion = 'portal-tax-arithmetic-v1+simulation-v'.$simulation->version_no;
DB::table('simulation_results')->insert([
'id' => $resultId, 'simulation_id' => $simulation->id, 'scenario_id' => $scenario->id, 'engine_version' => $engineVersion,
'result_snapshot' => $json, 'result_sha256' => $resultHash, 'created_at' => now(),
]);
$simulation->update(['status' => 'completed', 'version_no' => $simulation->version_no + 1, 'result_snapshot' => $result, 'error_code' => null]);
DB::table('simulation_jobs')->where('id', $job->id)->update(['status' => 'succeeded', 'finished_at' => now(), 'updated_at' => now()]);
DB::table('simulation_reports')->insert(['id' => (string) Str::uuid(), 'simulation_id' => $simulation->id, 'version_no' => $scenario->version_no,
'status' => 'ready', 'report_snapshot' => $json, 'report_sha256' => $resultHash, 'created_at' => now(), 'updated_at' => now()]);
$sourceJson = json_encode($input, JSON_THROW_ON_ERROR);
$canonicalSource = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$sourceJson])->canonical;
DB::table('simulation_result_sources')->insertOrIgnore(['id' => (string) Str::uuid(), 'result_id' => $resultId,
'source_type' => 'user_input', 'source_id' => $scenario->id, 'source_version' => (string) $scenario->version_no,
'source_snapshot' => $sourceJson, 'source_sha256' => hash('sha256', $canonicalSource), 'created_at' => now()]);
DB::table('simulation_audit_events')->insert(['simulation_id' => $simulation->id, 'actor_user_id' => $simulation->owner_user_id,
'action' => 'simulation.completed', 'correlation_id' => $simulation->id, 'data' => json_encode(['result_sha256' => hash('sha256', $json), 'attempt' => $job->attempts + 1], JSON_THROW_ON_ERROR), 'occurred_at' => now()]);
}, 3);
}
public function failed(\Throwable $exception): void
{
DB::transaction(function (): void {
app(SimulationExecution::class)->setContext($this->actorId, $this->tokenHash);
$job = DB::table('simulation_jobs')->where('simulation_id', $this->simulationId)->where('job_type', 'execute')->lockForUpdate()->first();
if (! $job || $job->status === 'succeeded') {
return;
}
DB::table('simulation_jobs')->where('id', $job->id)->update(['status' => 'failed', 'attempts' => 3, 'finished_at' => now(), 'error_code' => 'execution_failed', 'updated_at' => now()]);
$simulation = Simulation::where('id', $this->simulationId)->lockForUpdate()->first();
if ($simulation) {
if ($simulation->status !== 'failed') {
$simulation->update(['status' => 'failed', 'version_no' => $simulation->version_no + 1, 'error_code' => 'execution_failed']);
}
DB::table('simulation_audit_events')->insert(['simulation_id' => $simulation->id, 'actor_user_id' => $this->actorId,
'action' => 'simulation.failed', 'correlation_id' => $simulation->id, 'data' => json_encode(['error_code' => 'execution_failed'], JSON_THROW_ON_ERROR), 'occurred_at' => now()]);
}
});
}
}
+29 -1
View File
@@ -2,9 +2,37 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ExistingBusiness extends DomainRecord
{
protected $hidden = ['payload'];
protected $casts = ['payload' => 'encrypted:array', 'version_no' => 'integer'];
protected $casts = [
'payload' => 'encrypted:array',
'version_no' => 'integer',
'amount_min' => 'decimal:2',
'amount_max' => 'decimal:2',
'available_percentage' => 'decimal:2',
'submitted_at' => 'immutable_datetime',
'published_at' => 'immutable_datetime',
'expires_at' => 'immutable_datetime',
'next_action_due_at' => 'immutable_datetime',
];
/** @return BelongsTo<Sector, $this> */
public function sector(): BelongsTo
{
return $this->belongsTo(Sector::class);
}
/** @param Builder<ExistingBusiness> $query
* @return Builder<ExistingBusiness>
*/
public function scopePublished(Builder $query): Builder
{
return $query->where('status', 'published')->whereNotNull('published_at')->where('published_at', '<=', now())
->where(fn (Builder $q) => $q->whereNull('expires_at')->orWhere('expires_at', '>=', today()));
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class GeoLocation extends DomainRecord
{
protected $fillable = ['name', 'location_type', 'parent_id', 'code'];
/** @return BelongsTo<GeoLocation, $this> */
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
/** @return HasMany<GeoLocation, $this> */
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id');
}
/** @return HasMany<Opportunity, $this> */
public function opportunities(): HasMany
{
return $this->hasMany(Opportunity::class, 'primary_territory_id');
}
}
+24 -1
View File
@@ -15,7 +15,8 @@ class InterestRequest extends Model
'investor_type', 'country', 'sector_interest',
'opportunity_reference', 'investment_range', 'message',
'privacy_consent', 'consented_at', 'status', 'source_page',
'ip_address', 'user_agent', 'metadata', 'reviewed_at', 'reviewed_by',
'ip_address', 'user_agent', 'metadata', 'reviewed_at', 'reviewed_by', 'existing_business_id', 'investor_id',
'modality', 'requested_percentage', 'ticket_min', 'ticket_max', 'ticket_currency', 'assigned_to', 'next_action', 'next_action_due_at',
];
protected $casts = [
@@ -23,6 +24,10 @@ class InterestRequest extends Model
'consented_at' => 'datetime',
'metadata' => 'array',
'reviewed_at' => 'datetime',
'requested_percentage' => 'decimal:2',
'ticket_min' => 'decimal:2',
'ticket_max' => 'decimal:2',
'next_action_due_at' => 'immutable_datetime',
];
/** @return BelongsTo<Opportunity, $this> */
@@ -30,4 +35,22 @@ class InterestRequest extends Model
{
return $this->belongsTo(Opportunity::class);
}
/** @return BelongsTo<ExistingBusiness, $this> */
public function existingBusiness(): BelongsTo
{
return $this->belongsTo(ExistingBusiness::class);
}
/** @return BelongsTo<Investor, $this> */
public function investor(): BelongsTo
{
return $this->belongsTo(Investor::class);
}
/** @return BelongsTo<User, $this> */
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_to');
}
}
+5
View File
@@ -34,6 +34,11 @@ class Investor extends Model
'created_at' => 'immutable_datetime',
'updated_at' => 'immutable_datetime',
'deleted_at' => 'immutable_datetime',
'investment_sectors' => 'array',
'investment_geographies' => 'array',
'investment_horizon_months' => 'integer',
'ticket_min' => 'decimal:2',
'ticket_max' => 'decimal:2',
];
}
}
+11 -1
View File
@@ -7,7 +7,10 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/** @property string|null $primary_territory_id */
/**
* @property string|null $primary_territory_id
* @property int $territory_version
*/
class Opportunity extends Model
{
use SoftDeletes;
@@ -24,6 +27,7 @@ class Opportunity extends Model
protected $casts = [
'is_featured' => 'boolean',
'territory_version' => 'integer',
'published_at' => 'datetime',
'last_validated_at' => 'datetime',
'estimated_investment_amount' => 'decimal:2',
@@ -35,6 +39,12 @@ class Opportunity extends Model
return $this->belongsTo(Sector::class);
}
/** @return BelongsTo<GeoLocation, $this> */
public function primaryTerritory(): BelongsTo
{
return $this->belongsTo(GeoLocation::class, 'primary_territory_id');
}
/** @param Builder<Opportunity> $query
* @return Builder<Opportunity>
*/
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/** @property int $version_no */
class QuestionnaireVersion extends Model
{
public $incrementing = false;
protected $keyType = 'string';
protected $guarded = [];
protected function casts(): array
{
return ['schema_snapshot' => 'array', 'valid_from' => 'date', 'valid_to' => 'date'];
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property string $id
* @property int|null $owner_user_id
* @property string|null $organization_id
* @property string|null $anonymous_token_hash
* @property string $status
* @property int $version_no
* @property array<string, mixed> $input_snapshot
* @property array<string, mixed>|null $result_snapshot
*/
class Simulation extends Model
{
use SoftDeletes;
public $incrementing = false;
protected $keyType = 'string';
protected $guarded = [];
protected $casts = ['input_snapshot' => 'array', 'result_snapshot' => 'array', 'calculation_snapshot' => 'array'];
/** @return HasMany<SimulationScenario, $this> */
public function scenarios(): HasMany
{
return $this->hasMany(SimulationScenario::class);
}
/** @return HasMany<SimulationReport, $this> */
public function reports(): HasMany
{
return $this->hasMany(SimulationReport::class);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
class SimulationFormDraft extends Model
{
protected $table = 'simulation_form_drafts';
protected $fillable = [
'id', 'owner_user_id', 'anonymous_token_hash', 'version_no', 'create_key_hash', 'create_payload_hash', 'snapshot_sha256',
'personal_snapshot', 'economic_snapshot', 'deleted_at',
];
protected function casts(): array
{
return ['version_no' => 'integer', 'deleted_at' => 'immutable_datetime'];
}
/** @param array<string,mixed> $snapshot */
public static function encryptSnapshot(array $snapshot): string
{
return Crypt::encryptString(json_encode($snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
/** @return array<string,mixed> */
public static function decryptSnapshot(string $snapshot): array
{
$decoded = json_decode(Crypt::decryptString($snapshot), true, 32, JSON_THROW_ON_ERROR);
return is_array($decoded) ? $decoded : [];
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SimulationJob extends Model
{
public $incrementing = false;
protected $keyType = 'string';
protected $guarded = [];
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SimulationObligation extends Model
{
public $incrementing = false;
protected $keyType = 'string';
protected $guarded = [];
protected $casts = ['snapshot' => 'array'];
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SimulationReport extends Model
{
public $incrementing = false;
protected $keyType = 'string';
protected $guarded = [];
protected $casts = ['report_snapshot' => 'array'];
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SimulationResult extends Model
{
public $incrementing = false;
protected $keyType = 'string';
protected $guarded = [];
protected $casts = ['result_snapshot' => 'array'];
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SimulationResultSource extends Model
{
public $incrementing = false;
protected $keyType = 'string';
protected $guarded = [];
protected $casts = ['source_snapshot' => 'array'];
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SimulationScenario extends Model
{
public $incrementing = false;
protected $keyType = 'string';
protected $guarded = [];
protected $casts = ['parameters' => 'array'];
}
+52 -5
View File
@@ -19,7 +19,14 @@ class ExistingBusinessPolicy
public function view(User $user, ExistingBusiness $business): bool
{
return $this->owns($user, $business) || $this->review($user, $business);
if ($this->owns($user, $business) || $this->review($user, $business) || $this->triage($user, $business)
|| $this->approveLevelOne($user, $business) || $this->approveLevelTwo($user, $business) || $this->publish($user, $business)) {
return true;
}
return ! $business->trashed() && $business->owner_user_id !== $user->id
&& in_array($business->status, ['rejected', 'published', 'suspended', 'cancelled', 'archived'], true)
&& $this->reviewQueue($user);
}
public function delete(User $user, ExistingBusiness $business): bool
@@ -35,13 +42,53 @@ class ExistingBusinessPolicy
public function review(User $user, ExistingBusiness $business): bool
{
return ! $business->trashed() && $business->owner_user_id !== $user->id
&& in_array($business->status, ['submitted', 'in_review', 'needs_information'], true)
&& $this->reviewQueue($user);
&& in_array($business->status, ['triage', 'in_review', 'needs_information'], true)
&& $this->designated($user, 'reviewers');
}
public function triage(User $user, ExistingBusiness $business): bool
{
return ! $business->trashed() && $business->owner_user_id !== $user->id
&& in_array($business->status, ['submitted', 'triage'], true)
&& $this->designated($user, 'triagers');
}
public function approveLevelOne(User $user, ExistingBusiness $business): bool
{
return ! $business->trashed() && $business->owner_user_id !== $user->id
&& $business->status === 'approval_l1'
&& $this->designated($user, 'approvers_l1');
}
public function approveLevelTwo(User $user, ExistingBusiness $business): bool
{
return ! $business->trashed() && $business->owner_user_id !== $user->id
&& $business->status === 'approval_l2'
&& $this->designated($user, 'approvers_l2');
}
public function publish(User $user, ExistingBusiness $business): bool
{
return ! $business->trashed() && $business->owner_user_id !== $user->id
&& $business->status === 'approved'
&& $this->designated($user, 'publishers');
}
public function reviewQueue(User $user): bool
{
return $user->can('manage-portal') && (bool) $user->two_factor_confirmed_at && (bool) $user->two_factor_secret
&& in_array(strtolower($user->email), config('existing-business.reviewers', []), true);
foreach (['reviewers', 'triagers', 'approvers_l1', 'approvers_l2', 'publishers'] as $designation) {
if ($this->designated($user, $designation)) {
return true;
}
}
return false;
}
private function designated(User $user, string $designation): bool
{
return $user->hasVerifiedEmail() && $user->can('manage-portal')
&& (bool) $user->two_factor_confirmed_at && (bool) $user->two_factor_secret
&& in_array(strtolower($user->email), config('existing-business.'.$designation, []), true);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Policies;
use App\Models\Simulation;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class SimulationPolicy
{
public function view(User $user, Simulation $simulation): bool
{
if ($simulation->organization_id === null && $simulation->owner_user_id === $user->id && $user->hasVerifiedEmail()) {
return true;
}
return $simulation->organization_id !== null && $user->hasVerifiedEmail()
&& DB::table('memberships')->where('user_id', $user->id)
->where('organization_id', $simulation->organization_id)->whereNull('deleted_at')->exists();
}
public function update(User $user, Simulation $simulation): bool
{
if (! $user->hasVerifiedEmail() || ! in_array($simulation->status, ['queued', 'completed', 'failed'], true)) {
return false;
}
if ($simulation->organization_id === null) {
return $simulation->owner_user_id === $user->id;
}
return DB::table('memberships')->where('user_id', $user->id)->where('organization_id', $simulation->organization_id)
->whereNull('deleted_at')->whereIn('role', ['admin', 'manager', 'officer'])->exists();
}
}
+42 -2
View File
@@ -5,6 +5,8 @@ namespace App\Providers;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use App\Services\Mne\BusinessRepository;
use App\Services\Mne\CompatibleBusinessRepository;
use Carbon\CarbonImmutable;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
@@ -23,7 +25,7 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
$this->app->bind(BusinessRepository::class, CompatibleBusinessRepository::class);
}
/**
@@ -47,7 +49,45 @@ class AppServiceProvider extends ServiceProvider
$model::deleted($invalidate);
}
RateLimiter::for('api', fn (Request $request) => Limit::perMinute(60)->by($request->ip()));
$requestLimitKey = static function (Request $request): string {
if ($user = $request->user()) {
return 'user:'.$user->getAuthIdentifier();
}
$token = $request->header('X-Simulation-Token');
return is_string($token) && $token !== ''
? 'anonymous:'.hash('sha256', $token)
: 'ip:'.$request->ip();
};
RateLimiter::for('api', function (Request $request) use ($requestLimitKey) {
return Limit::perMinute(60)->by($requestLimitKey($request))
->response(fn (Request $request, array $headers) => response()->json([
'message' => 'Demasiadas solicitações. Aguarde um pouco e tente novamente.',
], 429, $headers));
});
RateLimiter::for('simulation-draft-writes', function (Request $request) use ($requestLimitKey) {
return Limit::perMinute(30)->by('simulation-draft:'.$requestLimitKey($request))
->response(fn (Request $request, array $headers) => response()->json([
'message' => 'Foram guardadas demasiadas alterações em pouco tempo. Aguarde um minuto e tente novamente.',
], 429, $headers));
});
RateLimiter::for('simulation-calculations', function (Request $request) use ($requestLimitKey) {
return Limit::perMinute(10)->by('simulation-calculation:'.$requestLimitKey($request))
->response(fn (Request $request, array $headers) => response()->json([
'message' => 'Foram solicitados demasiados cálculos em pouco tempo. Aguarde antes de tentar novamente.',
], 429, $headers));
});
RateLimiter::for('simulation-scenario-updates', function (Request $request) use ($requestLimitKey) {
return Limit::perMinute(10)->by('simulation-scenario-update:'.$requestLimitKey($request))
->response(fn (Request $request, array $headers) => response()->json([
'message' => 'Foram solicitadas demasiadas alterações de cenário em pouco tempo. Aguarde antes de tentar novamente.',
], 429, $headers));
});
}
/**
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Validator;
final class BusinessFormSchema
{
/** @return array<string, array{step:int,pt:string,en:string,type:string,max:int,options?:array<string,string>}> */
public static function fields(): array
{
return [
'proponent_type' => ['step' => 1, 'pt' => 'Tipo de proponente', 'en' => 'Applicant type', 'type' => 'select', 'max' => 30, 'options' => ['person' => 'Pessoa / Person', 'organization' => 'Organização / Organization']],
'proponent_name' => ['step' => 1, 'pt' => 'Nome ou denominação', 'en' => 'Name', 'type' => 'text', 'max' => 200],
'tax_identifier' => ['step' => 1, 'pt' => 'NUIT ou identificador', 'en' => 'Tax identifier', 'type' => 'text', 'max' => 50],
'contact_email' => ['step' => 1, 'pt' => 'Email de contacto', 'en' => 'Contact email', 'type' => 'email', 'max' => 254],
'contact_phone' => ['step' => 1, 'pt' => 'Telefone', 'en' => 'Phone', 'type' => 'tel', 'max' => 40],
'country' => ['step' => 1, 'pt' => 'País (código de duas letras)', 'en' => 'Country (two-letter code)', 'type' => 'text', 'max' => 2],
'acting_capacity' => ['step' => 2, 'pt' => 'Qualidade em que actua', 'en' => 'Acting capacity', 'type' => 'select', 'max' => 30, 'options' => ['holder' => 'Titular / Holder', 'representative' => 'Representante / Representative']],
'declared_holder' => ['step' => 2, 'pt' => 'Titular declarado', 'en' => 'Declared holder', 'type' => 'text', 'max' => 200],
'mandate_reference' => ['step' => 2, 'pt' => 'Referência do mandato', 'en' => 'Mandate reference', 'type' => 'text', 'max' => 500],
'mandate_valid_until' => ['step' => 2, 'pt' => 'Validade do mandato', 'en' => 'Mandate expiry', 'type' => 'date', 'max' => 10],
'legitimacy_declaration' => ['step' => 2, 'pt' => 'Declaro legitimidade para esta submissão', 'en' => 'I declare authority to submit', 'type' => 'select', 'max' => 3, 'options' => ['yes' => 'Sim / Yes', 'no' => 'Não / No']],
'title' => ['step' => 3, 'pt' => 'Título proposto', 'en' => 'Proposed title', 'type' => 'text', 'max' => 200],
'description' => ['step' => 3, 'pt' => 'Descrição reservada', 'en' => 'Private description', 'type' => 'textarea', 'max' => 10000],
'declared_reason' => ['step' => 3, 'pt' => 'Motivo e objectivo', 'en' => 'Reason and objective', 'type' => 'textarea', 'max' => 2000],
'valid_until' => ['step' => 3, 'pt' => 'Validade declarada', 'en' => 'Declared expiry', 'type' => 'date', 'max' => 10],
'asking_value' => ['step' => 4, 'pt' => 'Valor pedido', 'en' => 'Asking value', 'type' => 'decimal', 'max' => 24],
'currency' => ['step' => 4, 'pt' => 'Moeda (código de três letras)', 'en' => 'Currency (three-letter code)', 'type' => 'text', 'max' => 3],
'value_nature' => ['step' => 4, 'pt' => 'Natureza do valor', 'en' => 'Value basis', 'type' => 'select', 'max' => 30, 'options' => ['asking' => 'Pedido / Asking', 'indicative' => 'Indicativo / Indicative', 'appraised' => 'Avaliado, sujeito a prova / Appraised, evidence required']],
'equity_percentage' => ['step' => 4, 'pt' => 'Participação disponível (%)', 'en' => 'Available equity (%)', 'type' => 'decimal', 'max' => 8],
'funds_use' => ['step' => 4, 'pt' => 'Utilização dos fundos', 'en' => 'Use of funds', 'type' => 'textarea', 'max' => 2000],
'location_notes' => ['step' => 5, 'pt' => 'Localização declarada (reservada)', 'en' => 'Declared location (private)', 'type' => 'textarea', 'max' => 2000],
'asset_notes' => ['step' => 6, 'pt' => 'Resumo dos activos e direitos declarados', 'en' => 'Declared assets and rights summary', 'type' => 'textarea', 'max' => 2000],
'source_reference' => ['step' => 7, 'pt' => 'Referência da fonte', 'en' => 'Source reference', 'type' => 'textarea', 'max' => 2000],
'document_reference' => ['step' => 7, 'pt' => 'Referências documentais (não são ficheiros validados)', 'en' => 'Document references (not validated files)', 'type' => 'textarea', 'max' => 2000],
];
}
/** @return array<string, array<int,string>> */
public static function rules(): array
{
$rules = [];
foreach (self::fields() as $key => $field) {
$rules['payload.'.$key] = ['nullable', 'string', 'max:'.$field['max']];
if (isset($field['options'])) {
$rules['payload.'.$key][] = 'in:'.implode(',', array_keys($field['options']));
}
}
$rules['payload.contact_email'][] = 'email';
$rules['payload.country'][] = 'regex:/^[A-Z]{2}$/';
$rules['payload.currency'][] = 'regex:/^[A-Z]{3}$/';
$rules['payload.asking_value'][] = 'regex:/^\d{1,15}(\.\d{1,4})?$/';
$rules['payload.equity_percentage'][] = 'numeric';
$rules['payload.equity_percentage'][] = 'between:0,100';
foreach (['valid_until', 'mandate_valid_until'] as $date) {
$rules['payload.'.$date][] = 'date_format:Y-m-d';
}
$rules['payload.wizard_version'] = ['nullable', 'in:1'];
$rules['payload.wizard_step'] = ['nullable', 'integer', 'between:1,8'];
$rules['payload.title'] = ['required', 'string', 'max:200'];
return $rules;
}
/** @param array<string,mixed> $payload */
public static function validateSubmission(array $payload): void
{
$rules = [];
foreach (['proponent_type', 'proponent_name', 'tax_identifier', 'contact_email', 'country', 'acting_capacity', 'declared_holder', 'location_notes'] as $field) {
$rules[$field] = ['required'];
}
$rules['legitimacy_declaration'] = ['required', 'in:yes'];
$rules['mandate_reference'] = ['required_if:acting_capacity,representative'];
$rules['mandate_valid_until'] = ['required_if:acting_capacity,representative', 'nullable', 'date_format:Y-m-d', 'after_or_equal:today'];
$rules['currency'] = ['required_with:asking_value'];
$rules['value_nature'] = ['required_with:asking_value'];
Validator::make($payload, $rules)->validate();
}
}
+27 -3
View File
@@ -5,11 +5,13 @@ namespace App\Services;
use App\Models\ExistingBusiness;
use App\Models\ExistingBusinessObject;
use App\Models\User;
use App\Services\Mne\BusinessRepository;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
final class ExistingBusinessDrafts
{
@@ -20,20 +22,34 @@ final class ExistingBusinessDrafts
{
$data = Validator::make($input, [
'modality' => ['required', 'in:'.implode(',', self::MODALITIES)],
'sector_id' => ['sometimes', 'nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')->where('is_active', true)],
'public_title' => ['sometimes', 'nullable', 'string', 'max:180'],
'activity_code' => ['sometimes', 'nullable', 'string', 'max:60'],
'stage' => ['sometimes', 'nullable', 'string', 'max:40'],
'objective' => ['sometimes', 'nullable', 'string', 'max:5000'],
'currency' => ['sometimes', 'nullable', 'regex:/^[A-Z]{3}$/', 'required_with:amount_min,amount_max'],
'amount_min' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:999999999999999999.99'],
'amount_max' => ['sometimes', 'nullable', 'numeric', 'min:0', 'gte:amount_min', 'max:999999999999999999.99'],
'available_percentage' => ['sometimes', 'nullable', 'numeric', 'between:0,100'],
'funds_use' => ['sometimes', 'nullable', 'string', 'max:10000'],
'classification' => ['required', 'in:internal,confidential,restricted'],
'version_no' => [$id ? 'required' : 'prohibited', 'integer', 'min:1'],
'reason' => ['required', 'string', 'min:10', 'max:1000'],
'payload' => ['required', 'array:title,description,declared_reason,source_reference,valid_until'],
'payload' => ['required', 'array:'.implode(',', [...array_keys(BusinessFormSchema::fields()), 'wizard_version', 'wizard_step'])],
'payload.title' => ['required', 'string', 'max:200'],
'payload.description' => ['nullable', 'string', 'max:10000'],
'payload.declared_reason' => ['nullable', 'string', 'max:2000'],
'payload.source_reference' => ['nullable', 'string', 'max:2000'],
'payload.valid_until' => ['nullable', 'date_format:Y-m-d'],
...BusinessFormSchema::rules(),
])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
if ($id) {
$business = ExistingBusiness::whereKey($id)->lockForUpdate()->firstOrFail();
$business = app(BusinessRepository::class)->find($id, true);
if (($business->payload['wizard_version'] ?? null) === '1') {
$data['payload']['wizard_version'] = '1';
}
Gate::forUser($actor)->authorize('update', $business);
abort_unless($business->version_no === $data['version_no'], 409, 'O rascunho foi alterado. Recarregue antes de gravar.');
$business->version_no++;
@@ -49,6 +65,14 @@ final class ExistingBusinessDrafts
$business->status = 'draft';
}
$business->fill(['modality' => $data['modality'], 'classification' => $data['classification'], 'payload' => $data['payload']]);
foreach (['public_title', 'activity_code', 'stage', 'objective', 'currency', 'amount_min', 'amount_max', 'available_percentage', 'funds_use'] as $field) {
if (array_key_exists($field, $data)) {
$business->{$field} = $data[$field];
}
}
if (array_key_exists('sector_id', $data)) {
$business->sector_id = $data['sector_id'];
}
$business->save();
$this->snapshot($business, $actor, $id ? 'updated' : 'created', $data['reason']);
@@ -60,7 +84,7 @@ final class ExistingBusinessDrafts
{
Validator::make(['reason' => $reason], ['reason' => ['required', 'string', 'min:10', 'max:1000']])->validate();
DB::transaction(function () use ($actor, $id, $version, $reason) {
$business = ExistingBusiness::whereKey($id)->lockForUpdate()->firstOrFail();
$business = app(BusinessRepository::class)->find($id, true);
Gate::forUser($actor)->authorize('delete', $business);
abort_unless($business->version_no === $version, 409);
$business->version_no++;
+119 -7
View File
@@ -2,39 +2,151 @@
namespace App\Services;
use App\Domain\Opportunities\Enums\OpportunityStatus;
use App\Models\ExistingBusiness;
use App\Models\ExistingBusinessObject;
use App\Models\User;
use App\Services\Mne\BusinessRepository;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
final class ExistingBusinessIntake
{
public function transition(User $actor, string $id, int $version, string $action, string $note, bool $consent = false): ExistingBusiness
public function transition(User $actor, string $id, int $version, string $action, string $note, bool $consent = false, ?string $confirmationCode = null): ExistingBusiness
{
Validator::make(compact('action', 'note'), [
'action' => ['required', 'in:submitted,in_review,needs_information,withdrawn'],
'action' => ['required', Rule::in([
OpportunityStatus::Submitted->value,
OpportunityStatus::Triage->value,
OpportunityStatus::InReview->value,
OpportunityStatus::NeedsInformation->value,
OpportunityStatus::ApprovalL1->value,
OpportunityStatus::ApprovalL2->value,
OpportunityStatus::Approved->value,
OpportunityStatus::Published->value,
OpportunityStatus::Rejected->value,
OpportunityStatus::Withdrawn->value,
])],
'note' => ['required', 'string', 'min:10', 'max:2000'],
])->validate();
return DB::transaction(function () use ($actor, $id, $version, $action, $note, $consent) {
$business = ExistingBusiness::whereKey($id)->lockForUpdate()->firstOrFail();
Gate::forUser($actor)->authorize(in_array($action, ['submitted', 'withdrawn'], true) ? 'owns' : 'review', $business);
return DB::transaction(function () use ($actor, $id, $version, $action, $note, $consent, $confirmationCode) {
$business = app(BusinessRepository::class)->find($id, true);
$ability = match ($action) {
OpportunityStatus::Submitted->value, OpportunityStatus::Withdrawn->value => 'owns',
OpportunityStatus::Triage->value => 'triage',
OpportunityStatus::ApprovalL2->value => 'approveLevelOne',
OpportunityStatus::Approved->value => 'approveLevelTwo',
OpportunityStatus::Published->value => 'publish',
OpportunityStatus::Rejected->value => in_array($business->status, ['approval_l1', 'approval_l2'], true)
? ($business->status === 'approval_l1' ? 'approveLevelOne' : 'approveLevelTwo') : 'review',
default => 'review',
};
Gate::forUser($actor)->authorize($ability, $business);
abort_unless($business->version_no === $version, 409, 'Versão desactualizada.');
$allowed = ['submitted' => ['draft', 'needs_information'], 'in_review' => ['submitted'],
'needs_information' => ['in_review'], 'withdrawn' => ['draft', 'submitted', 'in_review', 'needs_information']];
$allowed = [
OpportunityStatus::Submitted->value => [OpportunityStatus::Draft->value, OpportunityStatus::NeedsInformation->value],
OpportunityStatus::Triage->value => [OpportunityStatus::Submitted->value],
OpportunityStatus::InReview->value => [OpportunityStatus::Triage->value],
OpportunityStatus::NeedsInformation->value => [OpportunityStatus::Triage->value, OpportunityStatus::InReview->value],
OpportunityStatus::ApprovalL1->value => [OpportunityStatus::InReview->value],
OpportunityStatus::ApprovalL2->value => [OpportunityStatus::ApprovalL1->value],
OpportunityStatus::Approved->value => [OpportunityStatus::ApprovalL2->value],
OpportunityStatus::Published->value => [OpportunityStatus::Approved->value],
OpportunityStatus::Rejected->value => [OpportunityStatus::Triage->value, OpportunityStatus::InReview->value,
OpportunityStatus::ApprovalL1->value, OpportunityStatus::ApprovalL2->value],
OpportunityStatus::Withdrawn->value => [OpportunityStatus::Draft->value, OpportunityStatus::Submitted->value,
OpportunityStatus::Triage->value, OpportunityStatus::InReview->value, OpportunityStatus::NeedsInformation->value],
];
abort_unless(in_array($business->status, $allowed[$action], true), 409, 'Transição indisponível neste estado.');
if ($action === 'submitted') {
if (($business->payload['wizard_version'] ?? null) === '1') {
BusinessFormSchema::validateSubmission($business->payload);
$requiredKind = ['asset_sale' => 'asset', 'asset_lease' => 'asset', 'real_estate' => 'property',
'agricultural_investment' => 'agricultural_investment', 'land_right' => 'land_right',
'mining_license' => 'license', 'forestry_license' => 'license', 'capital_increase' => 'capital_request',
'participation_sale' => 'participation'][$business->modality] ?? null;
if ($requiredKind !== null && ! ExistingBusinessObject::where('business_id', $business->id)->where('kind', $requiredKind)->exists()) {
throw ValidationException::withMessages(['objects' => 'Declare o elemento aplicável na etapa 6: '.$requiredKind]);
}
Validator::make(['confirmation_code' => $confirmationCode, 'sector_id' => $business->sector_id], [
'confirmation_code' => ['required', Rule::in([$business->code])],
'sector_id' => ['required', Rule::exists('sectors', 'id')->whereNull('deleted_at')->where('is_active', true)],
])->validate();
}
Validator::make([...$business->payload, 'consent' => $consent], [
'description' => ['required', 'string', 'min:30'], 'source_reference' => ['required', 'string', 'min:10'],
'valid_until' => ['required', 'date_format:Y-m-d', 'after_or_equal:today'], 'consent' => ['accepted'],
])->validate();
}
if ($action === OpportunityStatus::Approved->value) {
$levelOneActor = DB::table('business_workflow_transitions')->where('existing_business_id', $business->id)
->where('to_status', OpportunityStatus::ApprovalL2->value)->orderByDesc('created_at')->value('actor_id');
abort_unless($levelOneActor !== null && (int) $levelOneActor !== (int) $actor->id, 403,
'A aprovação de nível 2 deve ser feita por uma pessoa diferente da aprovação de nível 1.');
}
if ($action === OpportunityStatus::Published->value) {
abort_unless(is_string($business->public_title) && trim($business->public_title) !== '' && $business->sector_id,
422, 'Preencha o título público e o sector antes de publicar.');
$approvalActors = DB::table('business_workflow_transitions')->where('existing_business_id', $business->id)
->whereIn('to_status', [OpportunityStatus::ApprovalL2->value, OpportunityStatus::Approved->value])
->whereNotNull('actor_id')->pluck('actor_id')->map(fn ($id) => (int) $id)->all();
abort_unless(! in_array((int) $actor->id, $approvalActors, true), 403,
'A publicação deve ser executada por uma pessoa diferente dos aprovadores.');
}
$fromStatus = $business->status;
$business->status = $action;
if ($action === OpportunityStatus::Published->value) {
$business->published_at = now();
$business->public_slug ??= Str::slug($business->public_title).'-'.strtolower(substr(str_replace('-', '', $business->id), 0, 10));
}
$business->version_no++;
$business->save();
$correlationId = (string) Str::uuid();
DB::table('business_workflow_transitions')->insert([
'id' => (string) Str::uuid(),
'existing_business_id' => $business->id,
'from_status' => $fromStatus,
'to_status' => $action,
'actor_id' => $actor->id,
'reason' => Crypt::encryptString($note),
'correlation_id' => $correlationId,
'metadata' => json_encode(['version_no' => $business->version_no, 'source' => 'portal-intake'], JSON_THROW_ON_ERROR),
'created_at' => now(),
]);
if ($action === OpportunityStatus::Submitted->value) {
DB::table('tasks')->insertOrIgnore([
'source_system' => 'portal-intake',
'external_id' => 'BusinessListingTriage:'.$business->id.':'.$business->version_no,
'version_no' => 1,
'data_classification' => 'internal',
'correlation_id' => $correlationId,
'existing_business_id' => $business->id,
'task_type' => 'triage',
'title' => 'Triar oportunidade '.$business->code,
'status' => 'open',
'priority' => 3,
'due_at' => now()->addWeekdays(3),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('outbox_events')->insert([
'source_system' => 'portal-intake',
'external_id' => 'BusinessListingSubmitted:'.$business->id.':'.$business->version_no,
'aggregate_type' => 'business_listing',
'aggregate_id' => $business->id,
'event_type' => 'BusinessListingSubmitted',
'data_classification' => 'confidential',
'correlation_id' => $correlationId,
'status' => 'pending',
'payload' => json_encode(['business_id' => $business->id, 'version_no' => $business->version_no], JSON_THROW_ON_ERROR),
]);
}
DB::table('existing_business_reviews')->insert(['id' => (string) Str::uuid(), 'business_id' => $id, 'actor_id' => $actor->id,
'version_no' => $business->version_no, 'action' => $action,
'note' => Crypt::encryptString(json_encode(['note' => $note, 'review_consent' => $action === 'submitted' ? $consent : null], JSON_THROW_ON_ERROR))]);
+2 -2
View File
@@ -2,9 +2,9 @@
namespace App\Services;
use App\Models\ExistingBusiness;
use App\Models\ExistingBusinessObject;
use App\Models\User;
use App\Services\Mne\BusinessRepository;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
@@ -58,7 +58,7 @@ final class ExistingBusinessObjects
Validator::make($data['payload'], $rules)->validate();
return DB::transaction(function () use ($actor, $businessId, $objectId, $data) {
$business = ExistingBusiness::whereKey($businessId)->lockForUpdate()->firstOrFail();
$business = app(BusinessRepository::class)->find($businessId, true);
Gate::forUser($actor)->authorize('update', $business);
abort_unless($business->version_no === $data['version_no'], 409, 'Versão desactualizada.');
abort_if(! $objectId && ExistingBusinessObject::where('business_id', $businessId)->count() >= 100, 422, 'Máximo de 100 elementos por negócio.');
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Models\ExistingBusiness;
use App\Models\ExistingBusinessObject;
use App\Models\User;
use App\Services\Mne\BusinessRepository;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
@@ -34,7 +35,7 @@ final class ExistingBusinessVerification
])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
$business = ExistingBusiness::whereKey($id)->lockForUpdate()->firstOrFail();
$business = app(BusinessRepository::class)->find($id, true);
Gate::forUser($actor)->authorize('review', $business);
abort_unless($business->status === 'in_review' && $business->version_no === $data['version_no'], 409, 'Reabra a versão actual em revisão.');
if (! empty($data['object_id'])) {
+27 -2
View File
@@ -16,7 +16,8 @@ final class InterestSubmission
*/
public function receive(#[SensitiveParameter] array $payload, #[SensitiveParameter] ?string $key, Closure $create): InterestRequest
{
$canonical = array_replace(['phone' => null, 'opportunityReference' => null, 'investmentRange' => null], $payload);
$canonical = array_replace(['phone' => null, 'opportunityReference' => null, 'existingBusinessId' => null,
'modality' => null, 'investmentRange' => null, 'shareWithProponent' => false], $payload);
$canonical['email'] = strtolower(trim($canonical['email']));
$canonical['fullName'] = trim($canonical['fullName']);
$canonical['message'] = trim($canonical['message']);
@@ -25,7 +26,7 @@ final class InterestSubmission
$payloadHash = hash_hmac('sha256', json_encode($canonical, JSON_THROW_ON_ERROR), config()->string('app.key'));
$keyHash = $key === null ? null : hash('sha256', strtolower($key));
return DB::transaction(function () use ($keyHash, $payloadHash, $create): InterestRequest {
return DB::transaction(function () use ($keyHash, $payloadHash, $create, $canonical): InterestRequest {
if ($keyHash !== null) {
// Serialise concurrent submissions of this key across all web workers.
DB::select('select pg_advisory_xact_lock(250925, hashtext(?))', [$keyHash]);
@@ -41,6 +42,10 @@ final class InterestSubmission
$interest = $create();
$integrationId = (string) Str::uuid();
$interest->forceFill(['integration_id' => $integrationId])->save();
$this->recordConsent($interest, $integrationId, 'respond_to_interest', config('existing-business.consent_policy_version'));
if ($canonical['shareWithProponent'] === true || $canonical['shareWithProponent'] === 1 || $canonical['shareWithProponent'] === '1') {
$this->recordConsent($interest, $integrationId, 'share_contact_with_proponent', config('existing-business.sharing_policy_version'));
}
DB::table('outbox_events')->insert([
'source_system' => 'portal-intake',
'external_id' => 'ExpressionOfInterestSubmitted:'.$integrationId,
@@ -59,4 +64,24 @@ final class InterestSubmission
return $interest;
});
}
private function recordConsent(InterestRequest $interest, string $correlationId, string $purpose, string $policyVersion): void
{
DB::table('consents')->insert([
'id' => (string) Str::uuid(),
'source_system' => 'portal-intake',
'external_id' => 'InterestConsent:'.$interest->integration_id.':'.$purpose,
'version_no' => 1,
'data_classification' => 'confidential',
'correlation_id' => $correlationId,
'subject_ref' => $interest->reference,
'purpose' => $purpose,
'status' => 'granted',
'granted_at' => now(),
'interest_request_id' => $interest->id,
'policy_version' => $policyVersion,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace App\Services;
use App\Domain\Interests\Enums\InterestStatus;
use App\Models\InterestRequest;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
final class InterestWorkflow
{
/** @var array<string, list<string>> */
private const TRANSITIONS = [
'received' => ['in_review', 'qualification', 'contacted', 'closed', 'cancelled', 'refused'],
'submitted' => ['in_review', 'qualification', 'contacted', 'closed', 'cancelled', 'refused'],
'in_review' => ['qualification', 'qualified', 'contacted', 'referred', 'closed', 'cancelled', 'refused'],
'reviewed' => ['qualification', 'qualified', 'contacted', 'referred', 'closed', 'cancelled', 'refused'],
'qualification' => ['qualified', 'contacted', 'referred', 'closed', 'cancelled', 'refused'],
'qualified' => ['contacted', 'contact_accepted', 'due_diligence', 'referred', 'closed', 'cancelled', 'refused'],
'contacted' => ['contact_accepted', 'due_diligence', 'negotiation', 'referred', 'closed', 'cancelled', 'refused'],
'contact_accepted' => ['due_diligence', 'negotiation', 'closed', 'cancelled', 'refused'],
'referred' => ['in_review', 'contacted', 'closed', 'cancelled'],
'due_diligence' => ['negotiation', 'closed', 'cancelled', 'refused'],
'negotiation' => ['converted', 'closed', 'cancelled', 'refused'],
'pending' => ['in_review', 'contacted', 'closed', 'cancelled', 'refused'],
'closed' => ['in_review'],
'cancelled' => [],
'refused' => [],
'converted' => [],
];
public function transition(User $actor, InterestRequest $interest, string $status, ?string $note = null, ?string $nextAction = null, ?string $dueAt = null, ?int $assigneeId = null, bool $assignmentProvided = false, bool $actionProvided = false): InterestRequest
{
if (! in_array($status, array_column(InterestStatus::cases(), 'value'), true)) {
throw ValidationException::withMessages(['status' => 'Estado inválido.']);
}
return DB::transaction(function () use ($actor, $interest, $status, $note, $nextAction, $dueAt, $assigneeId, $assignmentProvided, $actionProvided) {
$locked = InterestRequest::query()->lockForUpdate()->findOrFail($interest->id);
$from = $locked->status;
$actionDate = $dueAt ? Carbon::parse($dueAt)->toDateString() : null;
$followUpChanged = ($actionProvided && ($locked->next_action !== $nextAction
|| $locked->next_action_due_at?->toDateString() !== $actionDate))
|| ($assignmentProvided && $locked->assigned_to !== $assigneeId);
if ($from !== $status && ! in_array($status, self::TRANSITIONS[$from] ?? [], true)) {
throw ValidationException::withMessages(['status' => 'Esta transição não é permitida a partir do estado actual.']);
}
$locked->forceFill([
'status' => $status,
'reviewed_at' => now(),
'reviewed_by' => $actor->id,
...($actionProvided ? ['next_action' => $nextAction, 'next_action_due_at' => $dueAt] : []),
...($assignmentProvided ? ['assigned_to' => $assigneeId] : []),
])->save();
if ($followUpChanged) {
DB::table('tasks')->where('interest_request_id', $locked->id)->where('task_type', 'interest_follow_up')
->whereIn('status', ['open', 'in_progress'])->update([
'status' => 'cancelled', 'completed_at' => now(), 'outcome' => 'superseded', 'updated_at' => now(),
]);
}
if ($followUpChanged && $locked->next_action && $locked->next_action_due_at) {
$correlationId = (string) Str::uuid();
$assignee = $assignmentProvided ? $assigneeId : $locked->assigned_to;
$externalId = 'InterestFollowUp:'.$locked->id.':'.hash('sha256', $locked->next_action.'|'.$locked->next_action_due_at.'|'.($assignee ?? ''));
DB::table('tasks')->insertOrIgnore([
'source_system' => 'portal-interest',
'external_id' => $externalId,
'version_no' => 1,
'data_classification' => 'confidential',
'correlation_id' => $correlationId,
'assignee_user_id' => $assignee,
'interest_request_id' => $locked->id,
'task_type' => 'interest_follow_up',
'title' => mb_substr($locked->next_action, 0, 200),
'status' => 'open',
'priority' => 3,
'due_at' => Carbon::parse($locked->next_action_due_at)->endOfDay(),
'created_at' => now(),
'updated_at' => now(),
]);
}
if ($from !== $status) {
$correlationId = (string) Str::uuid();
DB::table('business_workflow_transitions')->insert([
'id' => (string) Str::uuid(),
'interest_request_id' => $locked->id,
'from_status' => $from,
'to_status' => $status,
'actor_id' => $actor->id,
'reason' => $note ? Crypt::encryptString($note) : null,
'correlation_id' => $correlationId,
'metadata' => json_encode(['source' => 'management-interest'], JSON_THROW_ON_ERROR),
'created_at' => now(),
]);
}
return $locked->refresh();
});
}
}
+12 -4
View File
@@ -26,10 +26,17 @@ final class InvestmentGeography
});
}
public function references(bool $management = false): Builder
{
return $management
? DB::table('geo_locations')->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])
: $this->publicReferences();
}
/** @param array<string,mixed> $input
* @return list<\stdClass>
*/
public function selection(array $input): array
public function selection(array $input, bool $management = false): array
{
$path = [];
$parent = null;
@@ -40,7 +47,7 @@ final class InvestmentGeography
continue;
}
$node = $this->publicReferences()->where('id', $input[$field])->where('location_type', $type)->first(['id', 'name', 'parent_id', 'location_type']);
$node = $this->references($management)->where('id', $input[$field])->where('location_type', $type)->first(['id', 'name', 'parent_id', 'location_type']);
if ($gap || ! $node || ($parent && $node->parent_id !== $parent)) {
throw ValidationException::withMessages([$field => 'Seleccione a localização dentro da hierarquia indicada.']);
}
@@ -64,11 +71,12 @@ final class InvestmentGeography
}
/** @return list<\stdClass> */
public function path(?string $id): array
public function path(?string $id, bool $management = false): array
{
$path = [];
for ($i = 0; $id && $i < 5; $i++) {
$node = $this->publicReferences()->where('id', $id)->first(['id', 'name', 'parent_id', 'location_type']);
$query = $management ? DB::table('geo_locations') : $this->publicReferences();
$node = $query->where('id', $id)->first(['id', 'name', 'parent_id', 'location_type', 'deleted_at']);
if (! $node) {
return [];
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Services\Mne;
use App\Models\ExistingBusiness;
use Illuminate\Database\Eloquent\Builder;
interface BusinessRepository
{
/** @return Builder<ExistingBusiness> */
public function query(): Builder;
public function find(string $id, bool $forUpdate = false): ExistingBusiness;
}
@@ -0,0 +1,73 @@
<?php
namespace App\Services\Mne;
use Illuminate\Support\Facades\DB;
use Ramsey\Uuid\Uuid;
final class CompatibilityBackfill
{
/** @return array<string, int> */
public function run(int $batchSize = 100): array
{
$counts = [];
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]) {
// Start again on each invocation to detect edits behind a previous checkpoint.
$last = null;
$processed = 0;
do {
$batch = DB::transaction(function () use ($domain, $source, $target, $key, $last, $processed, $batchSize) {
DB::select('SELECT pg_advisory_xact_lock(290926,170004)');
$query = DB::table('public.'.$source)->orderBy('id')->limit($batchSize)->lockForUpdate();
if ($last !== null) {
$query->where('id', '>', $last);
}
$rows = $query->get();
$checksums = [];
foreach ($rows as $row) {
$checksum = hash('sha256', json_encode((array) $row, JSON_THROW_ON_ERROR));
$checksums[] = $checksum;
$stableId = (string) Uuid::uuid5(Uuid::NAMESPACE_URL, 'investmoz:'.$domain.':'.$row->id);
$data = ['id' => $stableId, $key => $row->id, 'source_system' => 'public.'.$source,
'source_checksum' => $checksum, 'status' => 'pending'];
if ($domain === 'sector') {
$data['canonical_sector_id'] = $stableId;
$data['source_code'] = $row->slug;
$data['status'] = $row->is_active && $row->deleted_at === null ? 'active' : 'pending';
}
$existing = DB::table('mne.'.$target)->where($key, $row->id)->first();
if (! $existing) {
DB::table('mne.'.$target)->insert($data);
} elseif ($existing->source_checksum !== $checksum) {
// Changed sources invalidate previous reconciliation; never activate by inference.
DB::table('mne.'.$target)->where($key, $row->id)->update(['source_checksum' => $checksum, 'status' => 'pending']);
}
if ($domain !== 'sector' && (! $existing || $existing->status !== 'active' || $existing->source_checksum !== $checksum)) {
DB::table('mne.compatibility_rejections')->upsert([[
'id' => $stableId, 'domain' => $domain, 'source_key' => (string) $row->id,
'code' => 'CANONICAL_MAPPING_REQUIRED', 'cause' => 'Explicit canonical mapping and reconciliation required',
'resolved_at' => null, 'resolution' => null,
]], ['domain', 'source_key', 'code'], ['cause', 'resolved_at', 'resolution']);
}
}
if ($rows->isNotEmpty() || $processed === 0) {
DB::table('mne.compatibility_checkpoints')->updateOrInsert(['domain' => $domain], [
'last_key' => $rows->isEmpty() ? $last : (string) $rows->last()->id,
'processed' => $processed + $rows->count(), 'checksum' => hash('sha256', implode('', $checksums)), 'updated_at' => now(),
]);
}
return $rows;
});
$processed += $batch->count();
if ($batch->isNotEmpty()) {
$last = $batch->last()->id;
}
} while ($batch->count() === $batchSize);
$counts[$domain] = $processed;
}
return $counts;
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Services\Mne;
use App\Jobs\CompareMneBusiness;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
final class CompatibilityShadow
{
/** Invoke only after authorization; optional comparison must never affect the response. */
public function schedule(string $businessId): void
{
if (! config('existing-business.compatibility_shadow', false)) {
return;
}
try {
$control = DB::transaction(fn () => DB::table('mne.compatibility_controls')->where('capability', 'shadow')->first());
if (! $control || ! $control->enabled || (hexdec(substr(hash('sha256', $businessId), 0, 6)) % 100) >= $control->percentage) {
return;
}
$connection = (string) config('existing-business.shadow_queue', 'database');
if (config('queue.connections.'.$connection.'.driver') !== 'database') {
Log::info('mne.compatibility.shadow', ['outcome' => 'async_queue_required']);
return;
}
CompareMneBusiness::dispatch($businessId)->onConnection($connection)->onQueue('mne-shadow')->afterCommit();
} catch (\Throwable) {
Log::info('mne.compatibility.shadow', ['outcome' => 'dispatch_error']);
}
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Services\Mne;
use App\Models\ExistingBusiness;
use Illuminate\Database\Eloquent\Builder;
final class CompatibleBusinessRepository implements BusinessRepository
{
public function __construct(private LegacyBusinessRepository $legacy) {}
/** @return Builder<ExistingBusiness> */
public function query(): Builder
{
return $this->legacy->query();
}
public function find(string $id, bool $forUpdate = false): ExistingBusiness
{
// No configuration can transfer writes or bypass existing policies.
return $this->legacy->find($id, $forUpdate);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Services\Mne;
use Illuminate\Support\Facades\DB;
final class IdentityResolver
{
public function resolve(int $userId): ?string
{
$id = DB::table('mne.identity_links as l')->join('mne.parties as p', 'p.id', '=', 'l.party_id')
->join('public.users as u', 'u.id', '=', 'l.user_id')
->where('l.user_id', $userId)->where('l.status', 'active')->where('p.party_type', 'person')
->whereNull('p.deleted_at')->where('l.valid_from', '<=', now())
->where(fn ($q) => $q->whereNull('l.valid_until')->orWhere('l.valid_until', '>', now()))
->value('l.party_id');
return is_string($id) ? $id : null;
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Services\Mne;
use App\Models\ExistingBusiness;
use Illuminate\Database\Eloquent\Builder;
final class LegacyBusinessRepository implements BusinessRepository
{
/** @return Builder<ExistingBusiness> */
public function query(): Builder
{
return ExistingBusiness::query();
}
public function find(string $id, bool $forUpdate = false): ExistingBusiness
{
$query = $this->query()->whereKey($id);
if ($forUpdate) {
$query->lockForUpdate();
}
return $query->firstOrFail();
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Services\Mne;
use Illuminate\Support\Facades\DB;
/** Read-only comparison adapter. Does not hydrate a legacy model or grant authority. */
final class MneBusinessRepository
{
/** @return array<string, mixed>|null */
public function comparison(string $legacyId): ?array
{
$row = DB::table('mne.business_links as l')
->join('mne.existing_businesses as b', 'b.id', '=', 'l.canonical_business_id')
->join('mne.business_modalities as m', 'm.id', '=', 'b.modality_id')
->join('mne.workflow_states as s', 's.id', '=', 'b.status_id')
->join('mne.data_classifications as c', 'c.id', '=', 'b.confidentiality_id')
->where('l.legacy_business_id', $legacyId)->where('l.status', 'active')
->where('l.valid_from', '<=', now())->where(fn ($q) => $q->whereNull('l.valid_until')->orWhere('l.valid_until', '>', now()))
->whereNull('b.deleted_at')->first(['b.public_code as code', 'm.code as modality', 's.code as status',
'c.code as classification', 'b.version as version_no']);
return $row ? (array) $row : null;
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Services\Mne;
use Illuminate\Support\Facades\DB;
final class SectorResolver
{
public const VERSION = 1;
public function resolve(int $legacyId): ?string
{
$id = DB::table('mne.sector_links as l')->join('public.sectors as s', 's.id', '=', 'l.sector_id')
->where('l.sector_id', $legacyId)->where('l.resolver_version', self::VERSION)
->where('l.status', 'active')->where('s.is_active', true)->whereNull('s.deleted_at')
->whereColumn('l.source_code', 's.slug')->where('l.valid_from', '<=', now())
->where(fn ($q) => $q->whereNull('l.valid_until')->orWhere('l.valid_until', '>', now()))
->value('l.canonical_sector_id');
return is_string($id) ? $id : null;
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
namespace App\Services;
use App\Jobs\RunSimulation;
use App\Models\Simulation;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
final class SimulationExecution
{
/**
* @param array{base: int|float|string, rate: int|float|string, reduction: int|float|string, organization_id?: string|null} $data
* @return array{simulation: Simulation, token: string|null, replayed: bool}
*/
public function create(array $data, ?int $actorId, ?string $organizationId, ?string $token, string $key): array
{
$tokenHash = $actorId === null ? hash_hmac('sha256', (string) $token, (string) config('app.key')) : null;
$scope = $actorId !== null ? (string) $actorId : ($tokenHash ?? 'anonymous');
$scopeHash = hash('sha256', $scope);
$keyHash = hash('sha256', strtolower($key));
$payload = ['base' => (string) $data['base'], 'rate' => (string) $data['rate'], 'reduction' => (string) $data['reduction'], 'organization_id' => $organizationId];
ksort($payload);
$payloadHash = hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($actorId, $organizationId, $token, $tokenHash, $scopeHash, $keyHash, $payloadHash, $payload): array {
$this->setContext($actorId, $tokenHash);
DB::select('select pg_advisory_xact_lock(261001, hashtext(?))', [$scopeHash.$keyHash]);
$receipt = DB::table('simulation_idempotency')->where('scope_hash', $scopeHash)->where('key_hash', $keyHash)->first();
if ($receipt) {
if (! hash_equals($receipt->payload_hash, $payloadHash)) {
throw new HttpException(409, 'Idempotency-Key já foi utilizada com outros parâmetros.');
}
return ['simulation' => Simulation::with(['scenarios', 'reports'])->whereKey((string) $receipt->simulation_id)->firstOrFail(), 'token' => null, 'replayed' => true];
}
$plainToken = $actorId === null ? $token : null;
$tokenHash = $actorId === null ? hash_hmac('sha256', $plainToken, (string) config('app.key')) : null;
$this->setContext($actorId, $tokenHash);
$id = (string) Str::uuid();
$simulation = Simulation::create([
'id' => $id, 'questionnaire_version_id' => '61000100-0000-4000-8000-000000000001', 'owner_user_id' => $actorId, 'organization_id' => $organizationId,
'anonymous_token_hash' => $tokenHash, 'status' => 'queued', 'version_no' => 1,
'input_snapshot' => $payload, 'calculation_snapshot' => ['engine' => 'portal-tax-arithmetic-v1', 'formula' => 'base * rate / 100 * (1 - reduction / 100)', 'legal_rate_source' => null],
]);
$scenarioId = (string) Str::uuid();
DB::table('simulation_scenarios')->insert([
'id' => $scenarioId, 'simulation_id' => $id, 'code' => 'baseline', 'label' => 'Cenário base', 'version_no' => 1,
'parameters' => json_encode($payload, JSON_THROW_ON_ERROR), 'created_at' => now(), 'updated_at' => now(),
]);
$jobId = (string) Str::uuid();
DB::table('simulation_jobs')->insert(['id' => $jobId, 'simulation_id' => $id, 'job_type' => 'execute', 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]);
DB::table('simulation_idempotency')->insert([
'scope_hash' => $scopeHash, 'key_hash' => $keyHash, 'payload_hash' => $payloadHash,
'simulation_id' => $id, 'response_snapshot' => json_encode(['simulation_id' => $id], JSON_THROW_ON_ERROR), 'created_at' => now(),
]);
DB::table('simulation_audit_events')->insert([
'simulation_id' => $id, 'actor_user_id' => $actorId, 'action' => 'simulation.created',
'correlation_id' => $id, 'data' => json_encode(['status' => 'queued', 'scenario_count' => 1], JSON_THROW_ON_ERROR), 'occurred_at' => now(),
]);
$simulation->load(['scenarios', 'reports']);
RunSimulation::dispatch($id, $actorId, $tokenHash)->afterCommit();
return ['simulation' => $simulation, 'token' => $plainToken, 'replayed' => false];
}, 3);
}
public function find(string $id, Request $request): Simulation
{
return DB::transaction(function () use ($id, $request): Simulation {
$this->setContext($request->user()?->id, $this->tokenHash($request));
return Simulation::with(['scenarios', 'reports'])->whereNull('deleted_at')->findOrFail($id);
});
}
/** @param array{label: string, base: int|float|string, rate: int|float|string, reduction: int|float|string} $data */
public function updateScenario(string $id, string $scenarioId, array $data, Request $request): Simulation
{
return DB::transaction(function () use ($id, $scenarioId, $data, $request): Simulation {
$this->setContext($request->user()?->id, $this->tokenHash($request));
$simulation = Simulation::whereNull('deleted_at')->lockForUpdate()->findOrFail($id);
$this->requireMatch($request, $simulation);
$scenario = DB::table('simulation_scenarios')->where('simulation_id', $id)->where('id', $scenarioId)->lockForUpdate()->first();
abort_if($scenario === null, 404);
$parameters = ['base' => (string) $data['base'], 'rate' => (string) $data['rate'], 'reduction' => (string) $data['reduction']];
DB::table('simulation_scenarios')->where('id', $scenarioId)->update([
'label' => $data['label'], 'parameters' => json_encode($parameters, JSON_THROW_ON_ERROR), 'version_no' => $scenario->version_no + 1, 'updated_at' => now(),
]);
$simulation->update(['version_no' => $simulation->version_no + 1, 'status' => 'queued', 'input_snapshot' => $parameters, 'result_snapshot' => null, 'error_code' => null]);
DB::table('simulation_jobs')->where('simulation_id', $id)->where('job_type', 'execute')->update([
'status' => 'pending', 'attempts' => 0, 'available_at' => now(), 'started_at' => null, 'finished_at' => null, 'error_code' => null, 'updated_at' => now(),
]);
DB::table('simulation_audit_events')->insert([
'simulation_id' => $id, 'actor_user_id' => $request->user()?->id, 'action' => 'scenario.updated', 'correlation_id' => $id,
'data' => json_encode(['scenario_id' => $scenarioId, 'version_no' => $scenario->version_no + 1], JSON_THROW_ON_ERROR), 'occurred_at' => now(),
]);
RunSimulation::dispatch($id, $simulation->owner_user_id, $simulation->anonymous_token_hash)->afterCommit();
return $simulation->refresh()->load(['scenarios', 'reports']);
}, 3);
}
public function tokenHash(Request $request): ?string
{
$token = $request->header('X-Simulation-Token');
return is_string($token) && $token !== '' ? hash_hmac('sha256', $token, (string) config('app.key')) : null;
}
public function setContext(?int $actorId, ?string $tokenHash): void
{
DB::select("select set_config('app.actor_user_id', ?, true), set_config('app.simulation_token_hash', ?, true)", [(string) ($actorId ?? ''), (string) ($tokenHash ?? '')]);
}
private function requireMatch(Request $request, Simulation $simulation): void
{
$provided = $request->header('If-Match');
if (! $provided) {
throw new HttpException(428, 'If-Match é obrigatório.');
}
if (! hash_equals($this->etag($simulation), trim($provided))) {
throw new HttpException(412, 'A simulação foi alterada desde a última leitura.');
}
}
public function etag(Simulation $simulation): string
{
return '"sim-'.$simulation->id.'-v'.$simulation->version_no.'"';
}
public function authorizeOrganization(?int $actorId, ?string $organizationId): void
{
if ($organizationId === null) {
return;
}
abort_unless($actorId !== null && DB::table('memberships')->where('user_id', $actorId)->where('organization_id', $organizationId)
->whereNull('deleted_at')->whereIn('role', ['admin', 'manager', 'officer'])->exists(), 403);
}
}
+162
View File
@@ -0,0 +1,162 @@
<?php
namespace App\Services;
use App\Models\SimulationFormDraft;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
final class SimulationFormDrafts
{
/** @param array{personal_snapshot: array<string,mixed>, economic_snapshot: array<string,mixed>} $data */
public function create(array $data, Request $request, string $key): array
{
[$actorId, $tokenHash] = $this->context($request);
$scope = $actorId === null ? 'anonymous:'.$tokenHash : 'user:'.$actorId;
$keyHash = hash_hmac('sha256', strtolower($key), (string) config('app.key'));
$canonicalPayload = json_encode($this->canonical($data), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
$payloadHash = hash('sha256', $canonicalPayload);
return DB::transaction(function () use ($actorId, $tokenHash, $scope, $keyHash, $payloadHash, $canonicalPayload, $data): array {
$this->setContext($actorId, $tokenHash);
DB::select('select pg_advisory_xact_lock(261010, hashtext(?))', [$scope]);
$query = SimulationFormDraft::query()->whereNull('deleted_at')->lockForUpdate();
$query = $actorId === null ? $query->where('anonymous_token_hash', $tokenHash) : $query->where('owner_user_id', $actorId);
$draft = $query->first();
if ($draft) {
if (hash_equals($draft->create_key_hash, $keyHash)) {
abort_unless(hash_equals($draft->create_payload_hash, $payloadHash), 409, 'Idempotency-Key já foi utilizada com outros dados.');
return ['draft' => $draft, 'replayed' => true];
}
throw new HttpException(409, 'Já existe um rascunho activo. Carregue-o antes de guardar novas alterações.');
}
$draft = SimulationFormDraft::query()->create([
'id' => (string) Str::uuid(), 'owner_user_id' => $actorId, 'anonymous_token_hash' => $tokenHash,
'version_no' => 1, 'create_key_hash' => $keyHash, 'create_payload_hash' => $payloadHash, 'snapshot_sha256' => $payloadHash,
'personal_snapshot' => SimulationFormDraft::encryptSnapshot($data['personal_snapshot']),
'economic_snapshot' => SimulationFormDraft::encryptSnapshot($data['economic_snapshot']),
]);
return ['draft' => $draft, 'replayed' => false];
}, 3);
}
/** @param array{personal_snapshot: array<string,mixed>, economic_snapshot: array<string,mixed>} $data */
public function update(string $id, array $data, Request $request): SimulationFormDraft
{
[$actorId, $tokenHash] = $this->context($request);
return DB::transaction(function () use ($id, $data, $request, $actorId, $tokenHash): SimulationFormDraft {
$this->setContext($actorId, $tokenHash);
$draft = SimulationFormDraft::query()->whereNull('deleted_at')->lockForUpdate()->findOrFail($id);
$this->requireMatch($request, $draft);
$canonicalPayload = json_encode($this->canonical($data), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
$draft->update([
'personal_snapshot' => SimulationFormDraft::encryptSnapshot($data['personal_snapshot']),
'economic_snapshot' => SimulationFormDraft::encryptSnapshot($data['economic_snapshot']),
'snapshot_sha256' => hash('sha256', $canonicalPayload), 'version_no' => $draft->version_no + 1,
]);
return $draft->refresh();
}, 3);
}
public function current(Request $request): ?SimulationFormDraft
{
[$actorId, $tokenHash] = $this->context($request);
return DB::transaction(function () use ($actorId, $tokenHash): ?SimulationFormDraft {
$this->setContext($actorId, $tokenHash);
$query = SimulationFormDraft::query()->whereNull('deleted_at');
return ($actorId === null ? $query->where('anonymous_token_hash', $tokenHash) : $query->where('owner_user_id', $actorId))->first();
});
}
public function find(string $id, Request $request): SimulationFormDraft
{
[$actorId, $tokenHash] = $this->context($request);
return DB::transaction(function () use ($id, $actorId, $tokenHash): SimulationFormDraft {
$this->setContext($actorId, $tokenHash);
return SimulationFormDraft::query()->whereNull('deleted_at')->findOrFail($id);
});
}
public function delete(string $id, Request $request): void
{
[$actorId, $tokenHash] = $this->context($request);
DB::transaction(function () use ($id, $request, $actorId, $tokenHash): void {
$this->setContext($actorId, $tokenHash);
$draft = SimulationFormDraft::query()->whereNull('deleted_at')->lockForUpdate()->findOrFail($id);
$this->requireMatch($request, $draft);
$draft->update(['deleted_at' => now()]);
}, 3);
}
/** @return array<string,mixed> */
public function present(SimulationFormDraft $draft): array
{
return [
'id' => $draft->id,
'version_no' => $draft->version_no,
'snapshot_sha256' => $draft->snapshot_sha256,
'personal_snapshot' => SimulationFormDraft::decryptSnapshot($draft->personal_snapshot),
'economic_snapshot' => SimulationFormDraft::decryptSnapshot($draft->economic_snapshot),
'updated_at' => $draft->updated_at,
];
}
public function etag(SimulationFormDraft $draft): string
{
return '"sfd-'.$draft->id.'-v'.$draft->version_no.'"';
}
/** @return array{0:int|null,1:string|null} */
private function context(Request $request): array
{
$actorId = $request->user()?->id;
$token = $request->header('X-Simulation-Token');
abort_unless($actorId !== null || (is_string($token) && strlen($token) >= 32 && strlen($token) <= 256), 422,
'X-Simulation-Token opaco é obrigatório para um rascunho anónimo.');
return [$actorId, $actorId === null ? hash_hmac('sha256', $token, (string) config('app.key')) : null];
}
private function setContext(?int $actorId, ?string $tokenHash): void
{
DB::select("select set_config('app.actor_user_id', ?, true), set_config('app.simulation_token_hash', ?, true)", [(string) ($actorId ?? ''), (string) ($tokenHash ?? '')]);
}
private function requireMatch(Request $request, SimulationFormDraft $draft): void
{
$provided = $request->header('If-Match');
if (! $provided) {
throw new HttpException(428, 'If-Match é obrigatório.');
}
if (! hash_equals($this->etag($draft), trim($provided))) {
throw new HttpException(412, 'O rascunho foi alterado desde a última leitura.');
}
}
private function canonical(mixed $value): mixed
{
if (! is_array($value)) {
return $value;
}
if (! array_is_list($value)) {
ksort($value);
}
foreach ($value as $key => $item) {
$value[$key] = $this->canonical($item);
}
return $value;
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace App\Support;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
abstract class SkillsEducationMigration extends Migration
{
protected int $block;
/** @var list<string> */
protected array $tables;
public function up(): void
{
if (DB::getDriverName() !== 'pgsql') {
throw new \RuntimeException('PostgreSQL 17 with existing PostGIS/pgcrypto required.');
}
DB::select("SELECT pg_advisory_xact_lock(hashtextextended('skills_edu:migrations',0))");
DB::statement("SET LOCAL TIME ZONE 'UTC'");
$version = (int) DB::selectOne("SELECT current_setting('server_version_num') AS n")->n;
if ($version < 170000 || $version >= 180000 || DB::table('pg_extension')->whereIn('extname', ['pgcrypto', 'postgis'])->count() !== 2) {
throw new \RuntimeException('Expected PostgreSQL 17 and extensions managed by existing migrations.');
}
foreach (['users' => 'bigint', 'sectors' => 'bigint', 'skills' => 'uuid', 'occupations' => 'uuid', 'courses' => 'uuid', 'training_providers' => 'uuid',
'organizations' => 'uuid', 'projects' => 'uuid', 'economic_clusters' => 'uuid', 'development_corridors' => 'uuid', 'investment_zones' => 'uuid',
'geo_locations' => 'uuid', 'labour_demands' => 'uuid', 'education_investment_proposals' => 'uuid', 'documents' => 'uuid', 'consents' => 'uuid'] as $table => $type) {
$actual = DB::selectOne("SELECT format_type(atttypid,atttypmod) AS type FROM pg_attribute WHERE attrelid=to_regclass(?) AND attname='id' AND NOT attisdropped", ['public.'.$table]);
if (! $actual || $actual->type !== $type) {
throw new \RuntimeException('Unexpected master identifier: public.'.$table);
}
}
if ($this->block === 1) {
if (DB::selectOne("SELECT to_regnamespace('skills_edu') AS id")->id !== null) {
throw new \RuntimeException('Unregistered skills_edu schema exists; reconcile before migration.');
}
DB::statement('CREATE SCHEMA skills_edu');
DB::unprepared('CREATE FUNCTION skills_edu.set_updated_at() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog AS $$ BEGIN NEW.updated_at := now(); RETURN NEW; END $$');
} else {
$predecessor = match ($this->block) {
2 => 'occupation_skills', 3 => 'institutional_candidate_registrations', 4 => 'institutional_authorisations',
5 => 'human_resource_need_certifications', 6 => 'profile_need_matches',
default => throw new \RuntimeException('Unknown skills education migration block'),
};
if (DB::selectOne('SELECT to_regclass(?) AS id', ['skills_edu.'.$predecessor])->id === null) {
throw new \RuntimeException('Missing previous skills_edu block: '.$predecessor);
}
}
$sql = file_get_contents(database_path('sql/skills-education/'.sprintf('%02d', $this->block).'.sql'));
if ($sql === false) {
throw new \RuntimeException('Versioned SQL block missing');
}
if (DB::connection()->getPdo()->exec($sql) === false) {
throw new \RuntimeException('Versioned SQL block failed');
}
DB::unprepared(<<<'SQL'
DO $indexes$
DECLARE fk record;
BEGIN
FOR fk IN SELECT c.conrelid,c.conkey,c.conname,t.relname,
(SELECT string_agg(quote_ident(a.attname),', ' ORDER BY k.p) FROM unnest(c.conkey) WITH ORDINALITY k(n,p)
JOIN pg_attribute a ON a.attrelid=c.conrelid AND a.attnum=k.n) AS cols
FROM pg_constraint c JOIN pg_class t ON t.oid=c.conrelid JOIN pg_namespace ns ON ns.oid=t.relnamespace
WHERE ns.nspname='skills_edu' AND c.contype='f'
LOOP
IF NOT EXISTS (SELECT 1 FROM pg_index i JOIN pg_class x ON x.oid=i.indexrelid JOIN pg_am am ON am.oid=x.relam
WHERE i.indrelid=fk.conrelid AND i.indisvalid AND i.indisready AND i.indpred IS NULL AND am.amname='btree'
AND i.indnkeyatts>=cardinality(fk.conkey)
AND ARRAY(SELECT n FROM unnest(i.indkey::smallint[]) WITH ORDINALITY k(n,p) WHERE p<=cardinality(fk.conkey) ORDER BY p)=fk.conkey)
THEN EXECUTE format('CREATE INDEX %I ON skills_edu.%I (%s)','se_fk_'||md5(fk.relname||fk.conname),fk.relname,fk.cols);
END IF;
END LOOP;
END $indexes$;
SQL);
if ($this->block === 6) {
DB::unprepared(<<<'SQL'
CREATE FUNCTION skills_edu.reject_audit_mutation() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog AS $$
BEGIN RAISE EXCEPTION 'Skills education audit is append-only' USING ERRCODE='23514'; END $$;
CREATE TRIGGER immutable_skills_education_audit BEFORE UPDATE OR DELETE OR TRUNCATE ON skills_edu.audit_events
FOR EACH STATEMENT EXECUTE FUNCTION skills_edu.reject_audit_mutation();
SQL);
}
}
public function down(): void
{
DB::select("SELECT pg_advisory_xact_lock(hashtextextended('skills_edu:migrations',0))");
foreach ($this->tables as $table) {
if (DB::table('skills_edu.'.$table)->exists()) {
throw new \RuntimeException('Skills education data exists; destructive rollback refused: '.$table);
}
}
foreach (array_reverse($this->tables) as $table) {
DB::statement('DROP TABLE skills_edu.'.$table.' RESTRICT');
}
if ($this->block === 6) {
DB::statement('DROP FUNCTION skills_edu.reject_audit_mutation() RESTRICT');
}
if ($this->block === 3) {
DB::statement('DROP FUNCTION skills_edu.validate_consent_dates() RESTRICT');
}
if ($this->block === 1) {
DB::statement('DROP FUNCTION skills_edu.set_updated_at() RESTRICT');
DB::statement('DROP SCHEMA skills_edu RESTRICT');
}
}
}
+16
View File
@@ -2,11 +2,15 @@
use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
use App\Http\Middleware\PreserveFormFeedback;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
@@ -21,6 +25,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->web(append: [
HandleAppearance::class,
HandleInertiaRequests::class,
PreserveFormFeedback::class,
AddLinkHeadersForPreloadedAssets::class,
]);
})
@@ -28,4 +33,15 @@ return Application::configure(basePath: dirname(__DIR__))
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
$exceptions->respond(function (Response $response, Throwable $exception, Request $request): Response {
if ($request->is('api/*')) {
$response->headers->set(
'X-Correlation-ID',
(string) ($request->attributes->get('correlation_id')
?? (Str::isUuid($request->header('X-Correlation-ID')) ? $request->header('X-Correlation-ID') : Str::uuid())),
);
}
return $response;
});
})->create();
+10
View File
@@ -1,7 +1,17 @@
<?php
return [
'compatibility_shadow' => (bool) env('INVESTMOZ_MNE_COMPATIBILITY_SHADOW', false),
'shadow_queue' => 'database',
'enabled' => (bool) env('INVESTMOZ_MNE_ENABLED', false),
'consent_policy_version' => env('INVESTMOZ_CONSENT_POLICY_VERSION', '2026-09-30'),
'sharing_policy_version' => env('INVESTMOZ_SHARING_POLICY_VERSION', '2026-09-30'),
'sla_escalation_enabled' => (bool) env('INVESTMOZ_SLA_ESCALATION_ENABLED', false),
// Designations do not establish legal competence or an institutional mandate.
'reviewers' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_MNE_REVIEWERS', '')))))),
// These lists control application workflow access; they do not replace institutional delegation.
'triagers' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_MNE_TRIAGERS', '')))))),
'approvers_l1' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_MNE_APPROVERS_L1', '')))))),
'approvers_l2' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_MNE_APPROVERS_L2', '')))))),
'publishers' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_MNE_PUBLISHERS', '')))))),
];
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::statement('ALTER TABLE geo_locations ADD COLUMN source_code text');
DB::statement('CREATE UNIQUE INDEX geo_source_parent_code_uq ON geo_locations(source_system, parent_id, location_type, source_code) NULLS NOT DISTINCT WHERE deleted_at IS NULL AND source_code IS NOT NULL');
DB::statement('CREATE INDEX geo_parent_type_name_idx ON geo_locations(parent_id, location_type, name) WHERE deleted_at IS NULL');
DB::unprepared(<<<'SQL'
CREATE OR REPLACE FUNCTION enforce_geo_hierarchy() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE parent_type text; expected_type text;
BEGIN
PERFORM pg_advisory_xact_lock(280926,150000);
IF NEW.deleted_at IS NOT NULL THEN RETURN NEW; END IF;
IF NEW.location_type='country' AND NEW.parent_id IS NOT NULL THEN
RAISE EXCEPTION 'Country cannot have a parent';
END IF;
expected_type := CASE NEW.location_type WHEN 'province' THEN 'country' WHEN 'district' THEN 'province' WHEN 'admin_post' THEN 'district' WHEN 'locality' THEN 'admin_post' END;
IF NEW.parent_id IS NOT NULL THEN
SELECT location_type INTO parent_type FROM geo_locations WHERE id=NEW.parent_id AND deleted_at IS NULL;
IF parent_type IS NULL OR (expected_type IS NOT NULL AND parent_type<>expected_type) THEN
RAISE EXCEPTION 'Invalid geographic parent';
END IF;
IF EXISTS(WITH RECURSIVE ancestors AS (
SELECT id,parent_id,ARRAY[id] as visited FROM geo_locations WHERE id=NEW.parent_id
UNION ALL SELECT g.id,g.parent_id,a.visited||g.id FROM geo_locations g JOIN ancestors a ON g.id=a.parent_id WHERE NOT g.id=ANY(a.visited)
) SELECT 1 FROM ancestors WHERE id=NEW.id) THEN RAISE EXCEPTION 'Geographic cycle'; END IF;
ELSIF expected_type IS NOT NULL AND NEW.source_system='cartografia-workbook' THEN
RAISE EXCEPTION 'Workbook administrative node requires a parent';
END IF;
IF EXISTS(SELECT 1 FROM geo_locations c WHERE c.parent_id=NEW.id AND c.deleted_at IS NULL AND
CASE c.location_type WHEN 'province' THEN 'country' WHEN 'district' THEN 'province' WHEN 'admin_post' THEN 'district' WHEN 'locality' THEN 'admin_post' END <> NEW.location_type) THEN
RAISE EXCEPTION 'Geographic children incompatible with new level';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER geo_hierarchy_guard BEFORE INSERT OR UPDATE OF parent_id,location_type,deleted_at,source_system ON geo_locations FOR EACH ROW EXECUTE FUNCTION enforce_geo_hierarchy();
SQL);
}
public function down(): void
{
if (DB::table('geo_locations')->whereNotNull('source_code')->exists()) {
throw new RuntimeException('Preserve imported source codes before rollback.');
}
DB::statement('DROP TRIGGER geo_hierarchy_guard ON geo_locations');
DB::statement('DROP FUNCTION enforce_geo_hierarchy()');
DB::statement('DROP INDEX geo_source_parent_code_uq');
DB::statement('DROP INDEX geo_parent_type_name_idx');
DB::statement('ALTER TABLE geo_locations DROP COLUMN source_code');
}
};
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('existing_businesses', function (Blueprint $table) {
$table->foreignId('sector_id')->nullable()->constrained('sectors')->restrictOnDelete();
$table->index('sector_id');
});
}
public function down(): void
{
if (DB::table('existing_businesses')->whereNotNull('sector_id')->exists()) {
throw new RuntimeException('Sector associations exist; destructive rollback refused.');
}
Schema::table('existing_businesses', function (Blueprint $table) {
$table->dropForeign(['sector_id']);
$table->dropIndex(['sector_id']);
$table->dropColumn('sector_id');
});
}
};
@@ -0,0 +1,10 @@
<?php
use App\Support\SkillsEducationMigration;
return new class extends SkillsEducationMigration
{
protected int $block = 1;
protected array $tables = ['skill_details', 'occupation_details', 'occupation_skills'];
};
@@ -0,0 +1,10 @@
<?php
use App\Support\SkillsEducationMigration;
return new class extends SkillsEducationMigration
{
protected int $block = 2;
protected array $tables = ['professional_profiles', 'profile_contacts', 'profile_skills', 'profile_experiences', 'profile_languages', 'institutional_candidate_registrations'];
};
@@ -0,0 +1,10 @@
<?php
use App\Support\SkillsEducationMigration;
return new class extends SkillsEducationMigration
{
protected int $block = 3;
protected array $tables = ['profile_qualifications', 'qualification_document_details', 'consent_details', 'institutional_authorisations'];
};
@@ -0,0 +1,10 @@
<?php
use App\Support\SkillsEducationMigration;
return new class extends SkillsEducationMigration
{
protected int $block = 4;
protected array $tables = ['human_resource_need_details', 'human_resource_need_skills', 'human_resource_need_languages', 'human_resource_need_certifications'];
};
@@ -0,0 +1,10 @@
<?php
use App\Support\SkillsEducationMigration;
return new class extends SkillsEducationMigration
{
protected int $block = 5;
protected array $tables = ['program_details', 'education_program_skills', 'skill_gaps', 'educational_opportunity_details', 'educational_opportunity_skills', 'profile_need_matches'];
};
@@ -0,0 +1,10 @@
<?php
use App\Support\SkillsEducationMigration;
return new class extends SkillsEducationMigration
{
protected int $block = 6;
protected array $tables = ['audit_events'];
};
@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::unprepared(<<<'SQL'
CREATE FUNCTION public.lock_project_dependency_graph() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF current_setting('transaction_isolation') <> 'read committed' THEN
RAISE EXCEPTION 'Dependency writes require READ COMMITTED isolation' USING ERRCODE='25000';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended('investmoz:project-dependency-graph', 0));
RETURN NULL;
END $$;
CREATE FUNCTION public.check_project_dependency_graph() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF EXISTS (
WITH RECURSIVE edges AS (
SELECT anchor_project_id AS origin, complementary_project_id AS destination
FROM public.complementary_project_links
WHERE relation_type='dependency' AND deleted_at IS NULL
), paths(origin,destination) AS (
SELECT origin,destination FROM edges
UNION
SELECT p.origin,e.destination FROM paths p JOIN edges e ON e.origin=p.destination
) SELECT 1 FROM paths WHERE origin=destination
) THEN
RAISE EXCEPTION 'Project dependency cycle is not permitted' USING ERRCODE='23514';
END IF;
RETURN NULL;
END $$;
CREATE TRIGGER project_dependency_lock BEFORE INSERT OR UPDATE ON public.complementary_project_links
FOR EACH STATEMENT EXECUTE FUNCTION public.lock_project_dependency_graph();
CREATE TRIGGER project_dependency_check AFTER INSERT OR UPDATE ON public.complementary_project_links
FOR EACH STATEMENT EXECUTE FUNCTION public.check_project_dependency_graph();
-- Validate the complete existing graph, without modifying a row.
UPDATE public.complementary_project_links SET relation_type=relation_type WHERE false;
SQL);
}
public function down(): void
{
DB::unprepared(<<<'SQL'
DROP TRIGGER project_dependency_check ON public.complementary_project_links;
DROP TRIGGER project_dependency_lock ON public.complementary_project_links;
DROP FUNCTION public.check_project_dependency_graph() RESTRICT;
DROP FUNCTION public.lock_project_dependency_graph() RESTRICT;
SQL);
}
};
@@ -0,0 +1,401 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private const BUSINESS_STATUSES = [
'draft', 'submitted', 'in_review', 'needs_information', 'withdrawn',
'triage', 'information_pending', 'review', 'approval_l1', 'approval_l2',
'approved', 'published', 'suspended', 'rejected', 'cancelled', 'archived',
];
private const INTEREST_STATUSES = [
'received', 'qualification', 'pending', 'referred', 'contact_accepted',
'due_diligence', 'negotiation', 'refused', 'converted', 'cancelled', 'closed',
'in_review', 'reviewed', 'qualified', 'contacted', 'submitted',
];
public function up(): void
{
if (DB::connection()->getDriverName() !== 'pgsql') {
throw new RuntimeException('Marketplace extension requires PostgreSQL with PostGIS.');
}
Schema::table('existing_businesses', function (Blueprint $table) {
$table->string('public_title', 180)->nullable();
$table->string('public_slug', 220)->nullable();
$table->string('activity_code', 60)->nullable();
$table->string('stage', 40)->nullable();
$table->text('objective')->nullable();
$table->char('currency', 3)->nullable();
$table->decimal('amount_min', 20, 2)->nullable();
$table->decimal('amount_max', 20, 2)->nullable();
$table->decimal('available_percentage', 5, 2)->nullable();
$table->text('funds_use')->nullable();
$table->timestampTz('submitted_at')->nullable();
$table->timestampTz('published_at')->nullable();
$table->timestampTz('expires_at')->nullable();
$table->string('next_action', 180)->nullable();
$table->timestampTz('next_action_due_at')->nullable();
$table->foreignId('assigned_to')->nullable();
});
DB::statement('ALTER TABLE existing_businesses ADD CONSTRAINT existing_businesses_assigned_to_fk FOREIGN KEY (assigned_to) REFERENCES users(id) ON DELETE SET NULL');
DB::statement(<<<'SQL'
DO $$
DECLARE constraint_row record;
BEGIN
FOR constraint_row IN
SELECT conname
FROM pg_constraint
WHERE conrelid = 'public.existing_businesses'::regclass
AND contype = 'c'
AND pg_get_constraintdef(oid) LIKE '%status%'
AND pg_get_constraintdef(oid) LIKE '%draft%'
LOOP
EXECUTE format('ALTER TABLE public.existing_businesses DROP CONSTRAINT %I', constraint_row.conname);
END LOOP;
END $$;
SQL);
DB::statement("ALTER TABLE existing_businesses ADD CONSTRAINT existing_businesses_status_check CHECK (status IN ('".implode("','", self::BUSINESS_STATUSES)."'))");
DB::statement("ALTER TABLE existing_business_reviews DROP CONSTRAINT existing_business_reviews_action_check, ADD CONSTRAINT existing_business_reviews_action_check CHECK (action IN ('created','updated','archived','object_added','object_updated','verification','submitted','triage','in_review','needs_information','approval_l1','approval_l2','approved','published','rejected','withdrawn'))");
DB::statement('ALTER TABLE existing_businesses ADD CONSTRAINT existing_businesses_amount_min_check CHECK (amount_min IS NULL OR amount_min >= 0)');
DB::statement('ALTER TABLE existing_businesses ADD CONSTRAINT existing_businesses_amount_max_check CHECK (amount_max IS NULL OR amount_min IS NULL OR amount_max >= amount_min)');
DB::statement('ALTER TABLE existing_businesses ADD CONSTRAINT existing_businesses_percentage_check CHECK (available_percentage IS NULL OR available_percentage BETWEEN 0 AND 100)');
DB::statement('ALTER TABLE existing_businesses ADD CONSTRAINT existing_businesses_currency_check CHECK ((amount_min IS NULL AND amount_max IS NULL) OR currency IS NOT NULL)');
DB::statement("ALTER TABLE existing_businesses ADD CONSTRAINT existing_businesses_published_check CHECK (status <> 'published' OR (published_at IS NOT NULL AND public_slug IS NOT NULL))");
DB::statement("ALTER TABLE existing_businesses ADD CONSTRAINT existing_businesses_public_slug_check CHECK (public_slug IS NULL OR public_slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$')");
DB::statement('CREATE UNIQUE INDEX existing_businesses_public_slug_uq ON existing_businesses(public_slug) WHERE public_slug IS NOT NULL');
DB::statement('CREATE INDEX existing_businesses_queue_idx ON existing_businesses(status,assigned_to,next_action_due_at) WHERE deleted_at IS NULL');
Schema::table('investors', function (Blueprint $table) {
$table->jsonb('investment_sectors')->nullable();
$table->jsonb('investment_geographies')->nullable();
$table->text('investment_strategy')->nullable();
$table->unsignedInteger('investment_horizon_months')->nullable();
$table->decimal('ticket_min', 20, 2)->nullable();
$table->decimal('ticket_max', 20, 2)->nullable();
$table->char('ticket_currency', 3)->nullable();
});
DB::statement('ALTER TABLE investors ADD CONSTRAINT investors_ticket_range_check CHECK (ticket_max IS NULL OR ticket_min IS NULL OR ticket_max >= ticket_min)');
DB::statement('ALTER TABLE investors ADD CONSTRAINT investors_ticket_min_check CHECK (ticket_min IS NULL OR ticket_min >= 0)');
DB::statement('ALTER TABLE investors ADD CONSTRAINT investors_ticket_max_check CHECK (ticket_max IS NULL OR ticket_max >= 0)');
DB::statement('ALTER TABLE investors ADD CONSTRAINT investors_ticket_currency_check CHECK ((ticket_min IS NULL AND ticket_max IS NULL) OR ticket_currency IS NOT NULL)');
Schema::table('interest_requests', function (Blueprint $table) {
$table->foreignUuid('existing_business_id')->nullable();
$table->foreignUuid('investor_id')->nullable();
$table->string('modality', 50)->nullable();
$table->decimal('requested_percentage', 5, 2)->nullable();
$table->decimal('ticket_min', 20, 2)->nullable();
$table->decimal('ticket_max', 20, 2)->nullable();
$table->char('ticket_currency', 3)->nullable();
$table->foreignId('assigned_to')->nullable();
$table->string('next_action', 180)->nullable();
$table->timestampTz('next_action_due_at')->nullable();
});
DB::statement('ALTER TABLE interest_requests ADD CONSTRAINT interest_requests_existing_business_fk FOREIGN KEY (existing_business_id) REFERENCES existing_businesses(id) ON DELETE RESTRICT');
DB::statement('ALTER TABLE interest_requests ADD CONSTRAINT interest_requests_investor_fk FOREIGN KEY (investor_id) REFERENCES investors(id) ON DELETE SET NULL');
DB::statement('ALTER TABLE interest_requests ADD CONSTRAINT interest_requests_assigned_to_fk FOREIGN KEY (assigned_to) REFERENCES users(id) ON DELETE SET NULL');
DB::statement('ALTER TABLE interest_requests ADD CONSTRAINT interest_requests_single_target_check CHECK (num_nonnulls(opportunity_id, existing_business_id) <= 1)');
DB::statement('ALTER TABLE interest_requests ADD CONSTRAINT interest_requests_percentage_check CHECK (requested_percentage IS NULL OR requested_percentage BETWEEN 0 AND 100)');
DB::statement('ALTER TABLE interest_requests ADD CONSTRAINT interest_requests_ticket_min_check CHECK (ticket_min IS NULL OR ticket_min >= 0)');
DB::statement('ALTER TABLE interest_requests ADD CONSTRAINT interest_requests_ticket_max_check CHECK (ticket_max IS NULL OR ticket_max >= 0)');
DB::statement('ALTER TABLE interest_requests ADD CONSTRAINT interest_requests_ticket_range_check CHECK (ticket_max IS NULL OR ticket_min IS NULL OR ticket_max >= ticket_min)');
DB::statement('ALTER TABLE interest_requests ADD CONSTRAINT interest_requests_ticket_currency_check CHECK ((ticket_min IS NULL AND ticket_max IS NULL) OR ticket_currency IS NOT NULL)');
DB::statement("ALTER TABLE interest_requests ADD CONSTRAINT interest_requests_status_check CHECK (status IN ('".implode("','", self::INTEREST_STATUSES)."'))");
Schema::table('interest_requests', fn (Blueprint $table) => $table->index(['existing_business_id', 'status'], 'interest_requests_business_status_idx'));
DB::statement('CREATE INDEX interest_requests_queue_idx ON interest_requests(status,assigned_to,next_action_due_at) WHERE deleted_at IS NULL');
DB::statement('ALTER TABLE interest_requests ADD CONSTRAINT interest_requests_business_modality_required_check CHECK (existing_business_id IS NULL OR modality IS NOT NULL)');
DB::statement("CREATE UNIQUE INDEX interest_business_email_modality_uq ON interest_requests(existing_business_id,lower(email),modality) WHERE existing_business_id IS NOT NULL AND deleted_at IS NULL AND status NOT IN ('refused','converted','cancelled','closed')");
Schema::table('consents', function (Blueprint $table) {
$table->foreignId('opportunity_id')->nullable();
$table->foreignUuid('existing_business_id')->nullable();
$table->foreignId('interest_request_id')->nullable();
$table->string('policy_version', 40)->nullable();
$table->char('technical_fingerprint_hash', 64)->nullable();
});
DB::statement('ALTER TABLE consents ADD CONSTRAINT consents_opportunity_fk FOREIGN KEY (opportunity_id) REFERENCES opportunities(id) ON DELETE SET NULL');
DB::statement('ALTER TABLE consents ADD CONSTRAINT consents_existing_business_fk FOREIGN KEY (existing_business_id) REFERENCES existing_businesses(id) ON DELETE RESTRICT');
DB::statement('ALTER TABLE consents ADD CONSTRAINT consents_interest_request_fk FOREIGN KEY (interest_request_id) REFERENCES interest_requests(id) ON DELETE RESTRICT');
DB::statement('ALTER TABLE consents ADD CONSTRAINT consents_subject_target_check CHECK (num_nonnulls(opportunity_id, existing_business_id, interest_request_id) <= 1)');
DB::statement(<<<'SQL'
ALTER TABLE consents ADD CONSTRAINT consents_fingerprint_check CHECK (technical_fingerprint_hash IS NULL OR technical_fingerprint_hash ~ '^[0-9a-f]{64}$')
SQL);
Schema::table('existing_business_objects', function (Blueprint $table) {
$table->string('asset_type', 50)->nullable();
$table->decimal('area', 18, 4)->nullable();
$table->string('area_unit', 20)->nullable();
$table->string('occupancy_status', 40)->nullable();
$table->string('condition_status', 40)->nullable();
});
DB::statement('ALTER TABLE existing_business_objects ADD CONSTRAINT existing_business_objects_area_check CHECK (area IS NULL OR area >= 0)');
Schema::table('documents', function (Blueprint $table) {
$table->unsignedBigInteger('size_bytes')->nullable();
$table->string('issuer', 180)->nullable();
$table->date('issued_on')->nullable();
$table->date('valid_until')->nullable();
$table->foreignId('uploaded_by')->nullable();
});
DB::statement('ALTER TABLE documents ADD CONSTRAINT documents_uploaded_by_fk FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL');
DB::statement('ALTER TABLE documents ADD CONSTRAINT documents_size_bytes_check CHECK (size_bytes IS NULL OR size_bytes > 0)');
DB::statement('ALTER TABLE documents ADD CONSTRAINT documents_valid_dates_check CHECK (valid_until IS NULL OR issued_on IS NULL OR valid_until >= issued_on)');
Schema::table('tasks', function (Blueprint $table) {
$table->foreignUuid('existing_business_id')->nullable()->constrained('existing_businesses')->restrictOnDelete();
$table->foreignId('interest_request_id')->nullable()->constrained('interest_requests')->restrictOnDelete();
$table->string('task_type', 60)->nullable();
$table->unsignedSmallInteger('priority')->default(3);
$table->timestampTz('completed_at')->nullable();
$table->string('outcome', 80)->nullable();
});
DB::statement('ALTER TABLE tasks ADD CONSTRAINT tasks_single_aggregate_check CHECK (num_nonnulls(opportunity_id, existing_business_id, interest_request_id) <= 1)');
DB::statement('ALTER TABLE tasks ADD CONSTRAINT tasks_priority_range_check CHECK (priority BETWEEN 1 AND 5)');
DB::statement('CREATE INDEX tasks_existing_business_idx ON tasks(existing_business_id,due_at) WHERE existing_business_id IS NOT NULL');
DB::statement('CREATE INDEX tasks_interest_request_idx ON tasks(interest_request_id,due_at) WHERE interest_request_id IS NOT NULL');
Schema::table('outbox_events', function (Blueprint $table) {
$table->string('idempotency_key', 120)->nullable()->unique();
$table->unsignedInteger('attempts')->default(0);
$table->timestampTz('failed_at')->nullable();
$table->text('last_error')->nullable();
});
$this->createBusinessLocations();
$this->createBusinessRights();
$this->createBusinessDocumentLinks();
$this->createWorkflowTransitions();
$this->createExternalRecordLinks();
}
public function down(): void
{
if (DB::connection()->getDriverName() !== 'pgsql') {
throw new RuntimeException('Marketplace extension rollback requires PostgreSQL.');
}
$this->refuseRollbackWithData();
DB::statement('DROP TRIGGER IF EXISTS existing_business_locations_hierarchy_trigger ON existing_business_locations');
DB::statement('DROP FUNCTION IF EXISTS enforce_existing_business_location_hierarchy()');
DB::statement('DROP TABLE IF EXISTS external_record_links RESTRICT');
DB::statement('DROP TABLE IF EXISTS business_workflow_transitions RESTRICT');
DB::statement('DROP TABLE IF EXISTS existing_business_document_links RESTRICT');
DB::statement('DROP TABLE IF EXISTS existing_business_rights RESTRICT');
DB::statement('DROP TABLE IF EXISTS existing_business_locations RESTRICT');
Schema::table('outbox_events', function (Blueprint $table) {
$table->dropUnique('outbox_events_idempotency_key_unique');
$table->dropColumn(['idempotency_key', 'attempts', 'failed_at', 'last_error']);
});
DB::statement('DROP INDEX tasks_existing_business_idx');
DB::statement('DROP INDEX tasks_interest_request_idx');
DB::statement('ALTER TABLE tasks DROP CONSTRAINT tasks_single_aggregate_check, DROP CONSTRAINT tasks_priority_range_check');
Schema::table('tasks', function (Blueprint $table) {
$table->dropForeign(['existing_business_id']);
$table->dropForeign(['interest_request_id']);
});
Schema::table('tasks', fn (Blueprint $table) => $table->dropColumn(['existing_business_id', 'interest_request_id', 'task_type', 'priority', 'completed_at', 'outcome']));
DB::statement('ALTER TABLE documents DROP CONSTRAINT documents_uploaded_by_fk, DROP CONSTRAINT documents_size_bytes_check, DROP CONSTRAINT documents_valid_dates_check');
Schema::table('documents', fn (Blueprint $table) => $table->dropColumn(['size_bytes', 'issuer', 'issued_on', 'valid_until', 'uploaded_by']));
DB::statement('ALTER TABLE existing_business_objects DROP CONSTRAINT existing_business_objects_area_check');
Schema::table('existing_business_objects', fn (Blueprint $table) => $table->dropColumn(['asset_type', 'area', 'area_unit', 'occupancy_status', 'condition_status']));
DB::statement('ALTER TABLE consents DROP CONSTRAINT consents_opportunity_fk, DROP CONSTRAINT consents_existing_business_fk, DROP CONSTRAINT consents_interest_request_fk, DROP CONSTRAINT consents_subject_target_check, DROP CONSTRAINT consents_fingerprint_check');
Schema::table('consents', fn (Blueprint $table) => $table->dropColumn(['opportunity_id', 'existing_business_id', 'interest_request_id', 'policy_version', 'technical_fingerprint_hash']));
Schema::table('interest_requests', fn (Blueprint $table) => $table->dropIndex('interest_requests_business_status_idx'));
DB::statement('DROP INDEX interest_requests_queue_idx');
DB::statement('DROP INDEX interest_business_email_modality_uq');
DB::statement('ALTER TABLE interest_requests DROP CONSTRAINT interest_requests_business_modality_required_check, DROP CONSTRAINT interest_requests_existing_business_fk, DROP CONSTRAINT interest_requests_investor_fk, DROP CONSTRAINT interest_requests_assigned_to_fk, DROP CONSTRAINT interest_requests_single_target_check, DROP CONSTRAINT interest_requests_percentage_check, DROP CONSTRAINT interest_requests_ticket_min_check, DROP CONSTRAINT interest_requests_ticket_max_check, DROP CONSTRAINT interest_requests_ticket_range_check, DROP CONSTRAINT interest_requests_ticket_currency_check, DROP CONSTRAINT interest_requests_status_check');
Schema::table('interest_requests', fn (Blueprint $table) => $table->dropColumn([
'existing_business_id', 'investor_id', 'modality', 'requested_percentage', 'ticket_min', 'ticket_max', 'ticket_currency', 'assigned_to', 'next_action', 'next_action_due_at',
]));
DB::statement('ALTER TABLE investors DROP CONSTRAINT investors_ticket_range_check, DROP CONSTRAINT investors_ticket_min_check, DROP CONSTRAINT investors_ticket_max_check, DROP CONSTRAINT investors_ticket_currency_check');
Schema::table('investors', fn (Blueprint $table) => $table->dropColumn([
'investment_sectors', 'investment_geographies', 'investment_strategy', 'investment_horizon_months', 'ticket_min', 'ticket_max', 'ticket_currency',
]));
DB::statement('ALTER TABLE existing_businesses DROP CONSTRAINT existing_businesses_assigned_to_fk, DROP CONSTRAINT existing_businesses_amount_min_check, DROP CONSTRAINT existing_businesses_amount_max_check, DROP CONSTRAINT existing_businesses_percentage_check, DROP CONSTRAINT existing_businesses_currency_check, DROP CONSTRAINT existing_businesses_published_check');
DB::statement('DROP INDEX existing_businesses_public_slug_uq');
DB::statement('ALTER TABLE existing_businesses DROP CONSTRAINT existing_businesses_public_slug_check');
DB::statement('DROP INDEX existing_businesses_queue_idx');
DB::statement("ALTER TABLE existing_business_reviews DROP CONSTRAINT existing_business_reviews_action_check, ADD CONSTRAINT existing_business_reviews_action_check CHECK (action IN ('submitted','in_review','needs_information','withdrawn'))");
DB::statement("ALTER TABLE existing_businesses DROP CONSTRAINT existing_businesses_status_check, ADD CONSTRAINT existing_businesses_status_check CHECK (status IN ('draft','submitted','in_review','needs_information','withdrawn'))");
Schema::table('existing_businesses', fn (Blueprint $table) => $table->dropColumn([
'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',
]));
}
private function createBusinessLocations(): void
{
Schema::create('existing_business_locations', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('existing_business_id')->constrained('existing_businesses')->cascadeOnDelete();
$table->foreignUuid('province_id')->nullable()->constrained('geo_locations')->restrictOnDelete();
$table->foreignUuid('district_id')->nullable()->constrained('geo_locations')->restrictOnDelete();
$table->foreignUuid('admin_post_id')->nullable()->constrained('geo_locations')->restrictOnDelete();
$table->foreignUuid('locality_id')->nullable()->constrained('geo_locations')->restrictOnDelete();
$table->string('precision_level', 20)->default('district');
$table->boolean('is_primary')->default(false);
$table->boolean('is_public')->default(false);
$table->timestampsTz();
});
DB::statement('ALTER TABLE existing_business_locations ADD COLUMN geom geometry(Geometry,4326)');
DB::statement("ALTER TABLE existing_business_locations ADD CONSTRAINT existing_business_locations_precision_check CHECK (precision_level IN ('province','district','admin_post','locality','exact'))");
DB::statement('ALTER TABLE existing_business_locations ADD CONSTRAINT existing_business_locations_geom_check CHECK (geom IS NULL OR (ST_SRID(geom)=4326 AND NOT ST_IsEmpty(geom) AND ST_IsValid(geom)))');
DB::statement("ALTER TABLE existing_business_locations ADD CONSTRAINT existing_business_locations_precision_target_check CHECK ((precision_level <> 'province' OR province_id IS NOT NULL) AND (precision_level <> 'district' OR district_id IS NOT NULL) AND (precision_level <> 'admin_post' OR admin_post_id IS NOT NULL) AND (precision_level <> 'locality' OR locality_id IS NOT NULL) AND (precision_level <> 'exact' OR geom IS NOT NULL))");
DB::statement('CREATE UNIQUE INDEX existing_business_primary_location_uq ON existing_business_locations(existing_business_id) WHERE is_primary');
DB::statement('CREATE INDEX existing_business_locations_gist ON existing_business_locations USING gist(geom)');
DB::unprepared(<<<'SQL'
CREATE FUNCTION enforce_existing_business_location_hierarchy() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE province_parent uuid; district_parent uuid; post_parent uuid;
BEGIN
IF NEW.province_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM geo_locations WHERE id=NEW.province_id AND location_type='province' AND deleted_at IS NULL) THEN RAISE EXCEPTION 'Invalid province reference'; END IF;
IF NEW.district_id IS NOT NULL THEN
SELECT parent_id INTO province_parent FROM geo_locations WHERE id=NEW.district_id AND location_type='district' AND deleted_at IS NULL;
IF province_parent IS NULL OR (NEW.province_id IS NOT NULL AND province_parent<>NEW.province_id) THEN RAISE EXCEPTION 'Invalid district/province relationship'; END IF;
END IF;
IF NEW.admin_post_id IS NOT NULL THEN
SELECT parent_id INTO district_parent FROM geo_locations WHERE id=NEW.admin_post_id AND location_type='admin_post' AND deleted_at IS NULL;
IF district_parent IS NULL OR (NEW.district_id IS NOT NULL AND district_parent<>NEW.district_id) THEN RAISE EXCEPTION 'Invalid administrative post/district relationship'; END IF;
END IF;
IF NEW.locality_id IS NOT NULL THEN
SELECT parent_id INTO post_parent FROM geo_locations WHERE id=NEW.locality_id AND location_type='locality' AND deleted_at IS NULL;
IF post_parent IS NULL OR (NEW.admin_post_id IS NOT NULL AND post_parent<>NEW.admin_post_id) THEN RAISE EXCEPTION 'Invalid locality/administrative post relationship'; END IF;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER existing_business_locations_hierarchy_trigger BEFORE INSERT OR UPDATE OF province_id,district_id,admin_post_id,locality_id ON existing_business_locations FOR EACH ROW EXECUTE FUNCTION enforce_existing_business_location_hierarchy();
SQL);
}
private function createBusinessRights(): void
{
Schema::create('existing_business_rights', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('existing_business_id')->constrained('existing_businesses')->restrictOnDelete();
$table->foreignUuid('business_object_id')->nullable()->constrained('existing_business_objects')->restrictOnDelete();
$table->string('right_type', 50);
$table->string('reference', 120)->nullable();
$table->string('issuer', 180)->nullable();
$table->date('valid_from')->nullable();
$table->date('valid_until')->nullable();
$table->timestampTz('verified_at')->nullable();
$table->text('confidential_payload')->nullable();
$table->timestampsTz();
});
DB::statement('ALTER TABLE existing_business_rights ADD CONSTRAINT existing_business_rights_valid_dates_check CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until >= valid_from)');
DB::statement('CREATE INDEX existing_business_rights_business_idx ON existing_business_rights(existing_business_id)');
}
private function createBusinessDocumentLinks(): void
{
Schema::create('existing_business_document_links', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('existing_business_id')->nullable()->constrained('existing_businesses')->restrictOnDelete();
$table->foreignId('interest_request_id')->nullable()->constrained('interest_requests')->restrictOnDelete();
$table->foreignUuid('document_id')->constrained('documents')->restrictOnDelete();
$table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestampsTz();
$table->unique(['existing_business_id', 'document_id'], 'existing_business_document_uq');
$table->unique(['interest_request_id', 'document_id'], 'interest_request_document_uq');
});
DB::statement('ALTER TABLE existing_business_document_links ADD CONSTRAINT existing_business_document_target_check CHECK (num_nonnulls(existing_business_id, interest_request_id)=1)');
}
private function createWorkflowTransitions(): void
{
Schema::create('business_workflow_transitions', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('existing_business_id')->nullable()->constrained('existing_businesses')->restrictOnDelete();
$table->foreignId('interest_request_id')->nullable()->constrained('interest_requests')->restrictOnDelete();
$table->string('from_status', 40)->nullable();
$table->string('to_status', 40);
$table->foreignId('actor_id')->nullable()->constrained('users')->nullOnDelete();
$table->text('reason')->nullable();
$table->uuid('correlation_id');
$table->jsonb('metadata')->default('{}');
$table->timestampTz('created_at')->useCurrent();
});
DB::statement('ALTER TABLE business_workflow_transitions ADD CONSTRAINT business_workflow_transition_target_check CHECK (num_nonnulls(existing_business_id, interest_request_id)=1)');
DB::statement('CREATE INDEX business_workflow_history_idx ON business_workflow_transitions(existing_business_id,created_at) WHERE existing_business_id IS NOT NULL');
DB::statement('CREATE INDEX interest_workflow_history_idx ON business_workflow_transitions(interest_request_id,created_at) WHERE interest_request_id IS NOT NULL');
}
private function createExternalRecordLinks(): void
{
Schema::create('external_record_links', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('existing_business_id')->nullable()->constrained('existing_businesses')->restrictOnDelete();
$table->foreignId('interest_request_id')->nullable()->constrained('interest_requests')->restrictOnDelete();
$table->foreignUuid('investor_id')->nullable()->constrained('investors')->restrictOnDelete();
$table->string('crm_system', 40);
$table->string('remote_id', 120);
$table->timestampTz('last_synced_at')->nullable();
$table->char('sync_hash', 64)->nullable();
$table->timestampsTz();
$table->unique(['crm_system', 'remote_id']);
});
DB::statement('ALTER TABLE external_record_links ADD CONSTRAINT external_record_target_check CHECK (num_nonnulls(existing_business_id, interest_request_id, investor_id)=1)');
DB::statement('CREATE UNIQUE INDEX external_record_business_crm_uq ON external_record_links(existing_business_id,crm_system) WHERE existing_business_id IS NOT NULL');
DB::statement('CREATE UNIQUE INDEX external_record_interest_crm_uq ON external_record_links(interest_request_id,crm_system) WHERE interest_request_id IS NOT NULL');
DB::statement('CREATE UNIQUE INDEX external_record_investor_crm_uq ON external_record_links(investor_id,crm_system) WHERE investor_id IS NOT NULL');
DB::statement(<<<'SQL'
ALTER TABLE external_record_links ADD CONSTRAINT external_record_sync_hash_check CHECK (sync_hash IS NULL OR sync_hash ~ '^[0-9a-f]{64}$')
SQL);
}
private function refuseRollbackWithData(): void
{
foreach (['existing_business_locations', 'existing_business_rights', 'existing_business_document_links', 'business_workflow_transitions', 'external_record_links'] as $table) {
if (DB::table($table)->exists()) {
throw new RuntimeException('Marketplace extension contains data; reconcile records before rollback.');
}
}
if (DB::table('existing_businesses')->whereIn('status', array_diff(self::BUSINESS_STATUSES, ['draft', 'submitted', 'in_review', 'needs_information', 'withdrawn']))->exists()
|| DB::table('existing_businesses')->where(function ($query) {
$query->whereNotNull('public_title')->orWhereNotNull('public_slug')->orWhereNotNull('activity_code')->orWhereNotNull('stage')->orWhereNotNull('objective')
->orWhereNotNull('currency')->orWhereNotNull('amount_min')->orWhereNotNull('amount_max')->orWhereNotNull('available_percentage')
->orWhereNotNull('funds_use')->orWhereNotNull('submitted_at')->orWhereNotNull('published_at')->orWhereNotNull('expires_at')
->orWhereNotNull('next_action')->orWhereNotNull('next_action_due_at')->orWhereNotNull('assigned_to');
})->exists()
|| DB::table('interest_requests')->where(function ($query) {
$query->whereNotNull('existing_business_id')->orWhereNotNull('investor_id')->orWhereNotNull('modality')
->orWhereNotNull('requested_percentage')->orWhereNotNull('ticket_min')->orWhereNotNull('ticket_max')
->orWhereNotNull('ticket_currency')->orWhereNotNull('assigned_to')->orWhereNotNull('next_action')->orWhereNotNull('next_action_due_at');
})->exists()
|| DB::table('documents')->where(function ($query) {
$query->whereNotNull('size_bytes')->orWhereNotNull('issuer')->orWhereNotNull('issued_on')->orWhereNotNull('valid_until')->orWhereNotNull('uploaded_by');
})->exists()
|| DB::table('tasks')->where(function ($query) {
$query->whereNotNull('existing_business_id')->orWhereNotNull('interest_request_id')->orWhereNotNull('task_type')
->orWhere('priority', '<>', 3)->orWhereNotNull('completed_at')->orWhereNotNull('outcome');
})->exists()
|| DB::table('outbox_events')->where(function ($query) {
$query->whereNotNull('idempotency_key')->orWhere('attempts', '<>', 0)->orWhereNotNull('failed_at')->orWhereNotNull('last_error');
})->exists()
|| DB::table('investors')->where(function ($query) {
$query->whereNotNull('investment_sectors')->orWhereNotNull('investment_geographies')->orWhereNotNull('investment_strategy')
->orWhereNotNull('investment_horizon_months')->orWhereNotNull('ticket_min')->orWhereNotNull('ticket_max')->orWhereNotNull('ticket_currency');
})->exists()
|| DB::table('existing_business_objects')->where(function ($query) {
$query->whereNotNull('asset_type')->orWhereNotNull('area')->orWhereNotNull('area_unit')->orWhereNotNull('occupancy_status')->orWhereNotNull('condition_status');
})->exists()
|| DB::table('consents')->where(function ($query) {
$query->whereNotNull('opportunity_id')->orWhereNotNull('existing_business_id')->orWhereNotNull('interest_request_id')
->orWhereNotNull('policy_version')->orWhereNotNull('technical_fingerprint_hash');
})->exists()) {
throw new RuntimeException('Marketplace extension fields contain data; reconcile records before rollback.');
}
}
};
@@ -0,0 +1,131 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (DB::connection()->getDriverName() !== 'pgsql') {
throw new RuntimeException('Reference catalogs require PostgreSQL.');
}
Schema::create('reference_catalog_versions', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('catalog_code', 60);
$table->unsignedInteger('version_no');
$table->string('locale', 10)->default('pt-MZ');
$table->string('source_system', 100);
$table->string('source_version', 100);
$table->text('source_reference');
$table->char('source_sha256', 64)->nullable();
$table->date('valid_from')->nullable();
$table->date('valid_to')->nullable();
$table->string('status', 16)->default('draft');
$table->timestampsTz();
$table->unique(['catalog_code', 'version_no', 'locale'], 'reference_catalog_version_uq');
$table->index(['catalog_code', 'status', 'valid_from', 'valid_to'], 'reference_catalog_current_idx');
});
DB::statement("ALTER TABLE reference_catalog_versions ADD CONSTRAINT reference_catalog_version_check CHECK (version_no > 0 AND (valid_to IS NULL OR valid_from IS NULL OR valid_to > valid_from) AND status IN ('draft','published','retired') AND (source_sha256 IS NULL OR source_sha256 ~ '^[0-9a-f]{64}$'))");
DB::statement("CREATE UNIQUE INDEX reference_catalog_one_published_uq ON reference_catalog_versions(catalog_code, locale) WHERE status='published'");
Schema::create('authorities', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('catalog_version_id')->constrained('reference_catalog_versions')->restrictOnDelete();
$table->foreignUuid('organization_id')->constrained('organizations')->restrictOnDelete();
$table->foreignUuid('jurisdiction_id')->nullable()->constrained('geo_locations')->restrictOnDelete();
$table->string('code', 100);
$table->string('authority_type', 60);
$table->string('label_pt_mz', 255);
$table->text('source_reference');
$table->unsignedInteger('version_no')->default(1);
$table->date('valid_from');
$table->date('valid_to')->nullable();
$table->string('status', 16)->default('draft');
$table->timestampsTz();
$table->unique(['catalog_version_id', 'code'], 'authorities_catalog_code_uq');
$table->index(['jurisdiction_id', 'status', 'valid_from', 'valid_to'], 'authorities_jurisdiction_status_idx');
$table->index(['authority_type', 'status'], 'authorities_type_status_idx');
});
DB::statement('ALTER TABLE authorities ADD CONSTRAINT authorities_check CHECK (version_no > 0 AND (valid_to IS NULL OR valid_to > valid_from))');
DB::statement("ALTER TABLE authorities ADD CONSTRAINT authorities_status_check CHECK (status IN ('draft','published','retired'))");
Schema::create('economic_activities', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('catalog_version_id')->constrained('reference_catalog_versions')->restrictOnDelete();
$table->uuid('parent_id')->nullable();
$table->foreignId('sector_id')->constrained('sectors')->restrictOnDelete();
$table->string('code', 100);
$table->string('label_pt_mz', 255);
$table->text('description_pt_mz')->nullable();
$table->unsignedInteger('version_no')->default(1);
$table->date('valid_from');
$table->date('valid_to')->nullable();
$table->string('status', 16)->default('published');
$table->timestampsTz();
$table->unique(['catalog_version_id', 'code'], 'economic_activities_catalog_code_uq');
$table->unique(['catalog_version_id', 'id'], 'economic_activities_version_id_uq');
$table->unique('sector_id', 'economic_activities_sector_uq');
$table->index(['parent_id', 'status', 'label_pt_mz'], 'economic_activities_parent_status_idx');
$table->index(['status', 'valid_from', 'valid_to'], 'economic_activities_validity_idx');
});
DB::statement('ALTER TABLE economic_activities ADD CONSTRAINT economic_activities_parent_same_version_fk FOREIGN KEY (catalog_version_id, parent_id) REFERENCES economic_activities(catalog_version_id, id) ON DELETE RESTRICT');
DB::statement('ALTER TABLE economic_activities ADD CONSTRAINT economic_activities_check CHECK (version_no > 0 AND (valid_to IS NULL OR valid_to > valid_from))');
DB::statement("ALTER TABLE economic_activities ADD CONSTRAINT economic_activities_status_check CHECK (status IN ('draft','published','retired'))");
DB::unprepared(<<<'SQL'
CREATE OR REPLACE FUNCTION public.investmoz_guard_reference_catalog_mutation() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public AS $$
BEGIN
IF TG_OP = 'DELETE' AND OLD.status = 'published' THEN
RAISE EXCEPTION 'Published reference catalog entries are immutable' USING ERRCODE = '42501';
END IF;
IF TG_OP = 'UPDATE' AND OLD.status = 'published' THEN
IF NEW.status <> 'retired'
OR NEW.valid_to IS NULL
OR (to_jsonb(NEW) - ARRAY['status','valid_to','updated_at'])
IS DISTINCT FROM
(to_jsonb(OLD) - ARRAY['status','valid_to','updated_at']) THEN
RAISE EXCEPTION 'Published reference catalog entries are immutable; publish a new version' USING ERRCODE = '42501';
END IF;
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER reference_catalog_version_immutable BEFORE UPDATE OR DELETE ON public.reference_catalog_versions
FOR EACH ROW EXECUTE FUNCTION public.investmoz_guard_reference_catalog_mutation();
CREATE TRIGGER authority_catalog_entry_immutable BEFORE UPDATE OR DELETE ON public.authorities
FOR EACH ROW EXECUTE FUNCTION public.investmoz_guard_reference_catalog_mutation();
CREATE TRIGGER economic_activity_catalog_entry_immutable BEFORE UPDATE OR DELETE ON public.economic_activities
FOR EACH ROW EXECUTE FUNCTION public.investmoz_guard_reference_catalog_mutation();
SQL);
}
public function down(): void
{
if (DB::connection()->getDriverName() !== 'pgsql') {
throw new RuntimeException('Reference catalog rollback requires PostgreSQL.');
}
if (Schema::hasTable('authorities') && DB::table('authorities')->exists()) {
throw new RuntimeException('Authority references exist; preserve them before rolling back reference catalogs.');
}
if (Schema::hasTable('economic_activities') && DB::table('economic_activities')->exists()) {
throw new RuntimeException('Economic activities exist; preserve them before rolling back reference catalogs.');
}
Schema::dropIfExists('economic_activities');
Schema::dropIfExists('authorities');
Schema::dropIfExists('reference_catalog_versions');
DB::statement('DROP FUNCTION IF EXISTS public.investmoz_guard_reference_catalog_mutation() RESTRICT');
}
};
@@ -0,0 +1,220 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private const TABLES = [
'simulation_audit_events', 'simulation_idempotency', 'simulation_jobs', 'simulation_reports',
'simulation_obligations', 'simulation_result_sources', 'simulation_results', 'simulation_scenarios',
'simulations', 'questionnaire_versions',
];
public function up(): void
{
if (DB::connection()->getDriverName() !== 'pgsql') {
throw new RuntimeException('Simulation execution requires PostgreSQL.');
}
DB::unprepared(<<<'SQL'
CREATE TABLE public.questionnaire_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code text NOT NULL,
version_no integer NOT NULL CHECK (version_no > 0),
locale text NOT NULL DEFAULT 'pt-MZ',
schema_snapshot jsonb NOT NULL,
status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published','retired')),
valid_from date,
valid_to date,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (code, version_no, locale),
CHECK (valid_to IS NULL OR valid_from IS NULL OR valid_to > valid_from)
);
INSERT INTO public.questionnaire_versions (id, code, version_no, locale, schema_snapshot, status, valid_from)
VALUES ('61000100-0000-4000-8000-000000000001', 'user-entered-arithmetic', 1, 'pt-MZ',
'{"inputs":{"base":{"type":"decimal","minimum":0},"rate":{"type":"decimal","minimum":0,"maximum":100},"reduction":{"type":"decimal","minimum":0,"maximum":100,"default":0}},"rate_origin":"user_input","currency":null}'::jsonb,
'published', DATE '2026-10-01');
CREATE TABLE public.simulations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
questionnaire_version_id uuid REFERENCES public.questionnaire_versions(id) ON DELETE RESTRICT,
owner_user_id bigint REFERENCES public.users(id) ON DELETE RESTRICT,
organization_id uuid REFERENCES public.organizations(id) ON DELETE RESTRICT,
anonymous_token_hash char(64),
version_no integer NOT NULL DEFAULT 1 CHECK (version_no > 0),
status text NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','running','completed','failed','cancelled')),
input_snapshot jsonb NOT NULL,
result_snapshot jsonb,
calculation_snapshot jsonb,
error_code text,
deleted_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK ((owner_user_id IS NOT NULL)::integer + (anonymous_token_hash IS NOT NULL)::integer = 1),
CHECK (anonymous_token_hash IS NULL OR anonymous_token_hash ~ '^[0-9a-f]{64}$')
);
CREATE INDEX simulations_owner_created_idx ON public.simulations(owner_user_id, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX simulations_org_created_idx ON public.simulations(organization_id, created_at DESC) WHERE deleted_at IS NULL;
CREATE TABLE public.simulation_scenarios (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
simulation_id uuid NOT NULL REFERENCES public.simulations(id) ON DELETE CASCADE,
code text NOT NULL,
label text NOT NULL,
version_no integer NOT NULL DEFAULT 1 CHECK (version_no > 0),
parameters jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (simulation_id, code)
);
CREATE TABLE public.simulation_results (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
simulation_id uuid NOT NULL REFERENCES public.simulations(id) ON DELETE CASCADE,
scenario_id uuid NOT NULL REFERENCES public.simulation_scenarios(id) ON DELETE RESTRICT,
engine_version text NOT NULL,
result_snapshot jsonb NOT NULL,
result_sha256 char(64) NOT NULL CHECK (result_sha256 ~ '^[0-9a-f]{64}$'),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (scenario_id, engine_version)
);
CREATE TABLE public.simulation_result_sources (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
result_id uuid NOT NULL REFERENCES public.simulation_results(id) ON DELETE CASCADE,
source_type text NOT NULL,
source_id text NOT NULL,
source_version text NOT NULL,
source_snapshot jsonb NOT NULL,
source_sha256 char(64) NOT NULL CHECK (source_sha256 ~ '^[0-9a-f]{64}$'),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (result_id, source_type, source_id, source_version)
);
CREATE TABLE public.simulation_obligations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
simulation_id uuid NOT NULL REFERENCES public.simulations(id) ON DELETE CASCADE,
scenario_id uuid REFERENCES public.simulation_scenarios(id) ON DELETE RESTRICT,
code text NOT NULL,
label text NOT NULL,
source_reference text,
source_version text,
status text NOT NULL DEFAULT 'indicative' CHECK (status IN ('indicative','confirmed','dismissed')),
snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (simulation_id, code)
);
CREATE TABLE public.simulation_reports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
simulation_id uuid NOT NULL REFERENCES public.simulations(id) ON DELETE CASCADE,
version_no integer NOT NULL DEFAULT 1 CHECK (version_no > 0),
status text NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','ready','failed')),
report_snapshot jsonb,
report_sha256 char(64),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (report_sha256 IS NULL OR report_sha256 ~ '^[0-9a-f]{64}$'),
UNIQUE (simulation_id, version_no)
);
CREATE TABLE public.simulation_jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
simulation_id uuid NOT NULL REFERENCES public.simulations(id) ON DELETE CASCADE,
job_type text NOT NULL CHECK (job_type IN ('execute','report')),
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','running','succeeded','failed')),
attempts smallint NOT NULL DEFAULT 0 CHECK (attempts BETWEEN 0 AND 3),
available_at timestamptz NOT NULL DEFAULT now(),
started_at timestamptz,
finished_at timestamptz,
error_code text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (simulation_id, job_type)
);
CREATE TABLE public.simulation_idempotency (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scope_hash char(64) NOT NULL CHECK (scope_hash ~ '^[0-9a-f]{64}$'),
key_hash char(64) NOT NULL CHECK (key_hash ~ '^[0-9a-f]{64}$'),
payload_hash char(64) NOT NULL CHECK (payload_hash ~ '^[0-9a-f]{64}$'),
simulation_id uuid REFERENCES public.simulations(id) ON DELETE RESTRICT,
response_snapshot jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (scope_hash, key_hash)
);
CREATE TABLE public.simulation_audit_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
simulation_id uuid NOT NULL REFERENCES public.simulations(id) ON DELETE RESTRICT,
actor_user_id bigint REFERENCES public.users(id) ON DELETE RESTRICT,
action text NOT NULL,
correlation_id uuid NOT NULL,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
occurred_at timestamptz NOT NULL DEFAULT now()
);
CREATE OR REPLACE FUNCTION public.investmoz_reject_simulation_audit_change() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public AS $$
BEGIN RAISE EXCEPTION 'Simulation audit is append-only' USING ERRCODE = '42501'; END;
$$;
CREATE TRIGGER simulation_audit_immutable BEFORE UPDATE OR DELETE ON public.simulation_audit_events FOR EACH ROW EXECUTE FUNCTION public.investmoz_reject_simulation_audit_change();
CREATE OR REPLACE FUNCTION public.investmoz_reject_simulation_snapshot_change() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public AS $$
BEGIN RAISE EXCEPTION 'Published simulation snapshots are immutable' USING ERRCODE = '42501'; END;
$$;
CREATE TRIGGER questionnaire_version_immutable BEFORE UPDATE OR DELETE ON public.questionnaire_versions FOR EACH ROW
WHEN (OLD.status = 'published') EXECUTE FUNCTION public.investmoz_reject_simulation_snapshot_change();
CREATE TRIGGER simulation_result_immutable BEFORE UPDATE OR DELETE ON public.simulation_results FOR EACH ROW EXECUTE FUNCTION public.investmoz_reject_simulation_snapshot_change();
CREATE TRIGGER simulation_result_source_immutable BEFORE UPDATE OR DELETE ON public.simulation_result_sources FOR EACH ROW EXECUTE FUNCTION public.investmoz_reject_simulation_snapshot_change();
CREATE TRIGGER simulation_report_immutable BEFORE UPDATE OR DELETE ON public.simulation_reports FOR EACH ROW
WHEN (OLD.status = 'ready') EXECUTE FUNCTION public.investmoz_reject_simulation_snapshot_change();
SQL);
$tables = array_diff(self::TABLES, ['simulations', 'simulation_audit_events', 'simulation_idempotency', 'questionnaire_versions']);
foreach ($tables as $table) {
$simulationId = $table === 'simulation_result_sources'
? '(SELECT parent.simulation_id FROM public.simulation_results parent WHERE parent.id = result_id)'
: ($table === 'simulations' ? 'id' : 'simulation_id');
$readAccess = "EXISTS (SELECT 1 FROM public.simulations s WHERE s.id = {$simulationId} AND ((s.organization_id IS NULL AND s.owner_user_id IS NOT NULL AND s.owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint) OR (s.organization_id IS NOT NULL AND EXISTS (SELECT 1 FROM public.memberships m WHERE m.organization_id = s.organization_id AND m.user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint AND m.deleted_at IS NULL)) OR (s.anonymous_token_hash IS NOT NULL AND s.anonymous_token_hash = NULLIF(current_setting('app.simulation_token_hash', true), ''))))";
$writeAccess = "EXISTS (SELECT 1 FROM public.simulations s WHERE s.id = {$simulationId} AND ((s.organization_id IS NULL AND s.owner_user_id IS NOT NULL AND s.owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint) OR (s.organization_id IS NOT NULL AND EXISTS (SELECT 1 FROM public.memberships m WHERE m.organization_id = s.organization_id AND m.user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint AND m.deleted_at IS NULL AND m.role IN ('admin','manager','officer'))) OR (s.anonymous_token_hash IS NOT NULL AND s.anonymous_token_hash = NULLIF(current_setting('app.simulation_token_hash', true), ''))) )";
DB::statement("ALTER TABLE public.{$table} ENABLE ROW LEVEL SECURITY");
DB::statement("ALTER TABLE public.{$table} FORCE ROW LEVEL SECURITY");
DB::statement("CREATE POLICY {$table}_read_policy ON public.{$table} FOR SELECT USING ({$readAccess})");
DB::statement("CREATE POLICY {$table}_write_policy ON public.{$table} FOR ALL USING ({$writeAccess}) WITH CHECK ({$writeAccess})");
}
foreach (['simulation_idempotency', 'simulation_audit_events'] as $table) {
DB::statement("ALTER TABLE public.{$table} ENABLE ROW LEVEL SECURITY");
DB::statement("ALTER TABLE public.{$table} FORCE ROW LEVEL SECURITY");
}
DB::statement('ALTER TABLE public.simulations ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE public.simulations FORCE ROW LEVEL SECURITY');
DB::statement("CREATE POLICY simulations_read_policy ON public.simulations FOR SELECT USING (((organization_id IS NULL AND owner_user_id IS NOT NULL AND owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint) OR (organization_id IS NOT NULL AND EXISTS (SELECT 1 FROM public.memberships m WHERE m.organization_id = simulations.organization_id AND m.user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint AND m.deleted_at IS NULL)) OR (anonymous_token_hash IS NOT NULL AND anonymous_token_hash = NULLIF(current_setting('app.simulation_token_hash', true), ''))) AND deleted_at IS NULL)");
$simulationWrite = "((organization_id IS NULL AND owner_user_id IS NOT NULL AND owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint) OR (organization_id IS NOT NULL AND EXISTS (SELECT 1 FROM public.memberships m WHERE m.organization_id = simulations.organization_id AND m.user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint AND m.deleted_at IS NULL AND m.role IN ('admin','manager','officer'))) OR (anonymous_token_hash IS NOT NULL AND anonymous_token_hash = NULLIF(current_setting('app.simulation_token_hash', true), '')))";
DB::statement("CREATE POLICY simulations_write_policy ON public.simulations FOR ALL USING ({$simulationWrite}) WITH CHECK ({$simulationWrite})");
DB::statement('ALTER TABLE public.questionnaire_versions ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE public.questionnaire_versions FORCE ROW LEVEL SECURITY');
DB::statement("CREATE POLICY questionnaire_published_read ON public.questionnaire_versions FOR SELECT USING (status = 'published')");
DB::statement("CREATE POLICY simulation_idempotency_actor ON public.simulation_idempotency USING (scope_hash = encode(digest(COALESCE(NULLIF(current_setting('app.actor_user_id', true), ''), NULLIF(current_setting('app.simulation_token_hash', true), ''), 'anonymous'), 'sha256'), 'hex')) WITH CHECK (scope_hash = encode(digest(COALESCE(NULLIF(current_setting('app.actor_user_id', true), ''), NULLIF(current_setting('app.simulation_token_hash', true), ''), 'anonymous'), 'sha256'), 'hex'))");
DB::statement("CREATE POLICY simulation_audit_read ON public.simulation_audit_events FOR SELECT USING (EXISTS (SELECT 1 FROM public.simulations s WHERE s.id = simulation_id AND s.owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint))");
DB::statement("CREATE POLICY simulation_audit_insert ON public.simulation_audit_events FOR INSERT WITH CHECK (actor_user_id IS NOT DISTINCT FROM NULLIF(current_setting('app.actor_user_id', true), '')::bigint AND EXISTS (SELECT 1 FROM public.simulations s WHERE s.id = simulation_id AND ((s.organization_id IS NULL AND s.owner_user_id IS NOT NULL AND s.owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint) OR (s.organization_id IS NOT NULL AND EXISTS (SELECT 1 FROM public.memberships m WHERE m.organization_id = s.organization_id AND m.user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint AND m.deleted_at IS NULL AND m.role IN ('admin','manager','officer'))) OR (s.anonymous_token_hash IS NOT NULL AND s.anonymous_token_hash = NULLIF(current_setting('app.simulation_token_hash', true), '')))))");
}
public function down(): void
{
if (DB::connection()->getDriverName() !== 'pgsql') {
throw new RuntimeException('Simulation execution rollback requires PostgreSQL.');
}
foreach (self::TABLES as $table) {
if (Schema::hasTable($table)) {
DB::statement("ALTER TABLE public.{$table} DISABLE ROW LEVEL SECURITY");
$query = DB::table($table);
if ($table === 'questionnaire_versions') {
$query->where('id', '<>', '61000100-0000-4000-8000-000000000001');
}
if ($query->exists()) {
throw new RuntimeException("Cannot rollback simulation execution: {$table} contains data.");
}
}
}
foreach (self::TABLES as $table) {
Schema::dropIfExists($table);
}
DB::statement('DROP FUNCTION IF EXISTS public.investmoz_reject_simulation_audit_change() RESTRICT');
DB::statement('DROP FUNCTION IF EXISTS public.investmoz_reject_simulation_snapshot_change() RESTRICT');
}
};
@@ -0,0 +1,60 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (DB::connection()->getDriverName() !== 'pgsql') {
throw new RuntimeException('Simulation form drafts require PostgreSQL.');
}
DB::unprepared(<<<'SQL'
CREATE TABLE public.simulation_form_drafts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_user_id bigint REFERENCES public.users(id) ON DELETE RESTRICT,
anonymous_token_hash char(64),
version_no integer NOT NULL DEFAULT 1 CHECK (version_no > 0),
create_key_hash char(64) NOT NULL CHECK (create_key_hash ~ '^[0-9a-f]{64}$'),
create_payload_hash char(64) NOT NULL CHECK (create_payload_hash ~ '^[0-9a-f]{64}$'),
snapshot_sha256 char(64) NOT NULL CHECK (snapshot_sha256 ~ '^[0-9a-f]{64}$'),
personal_snapshot text NOT NULL,
economic_snapshot text NOT NULL,
deleted_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK ((owner_user_id IS NOT NULL)::integer + (anonymous_token_hash IS NOT NULL)::integer = 1),
CHECK (anonymous_token_hash IS NULL OR anonymous_token_hash ~ '^[0-9a-f]{64}$')
);
CREATE UNIQUE INDEX simulation_form_drafts_owner_active_unique ON public.simulation_form_drafts(owner_user_id)
WHERE owner_user_id IS NOT NULL AND deleted_at IS NULL;
CREATE UNIQUE INDEX simulation_form_drafts_token_active_unique ON public.simulation_form_drafts(anonymous_token_hash)
WHERE anonymous_token_hash IS NOT NULL AND deleted_at IS NULL;
ALTER TABLE public.simulation_form_drafts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.simulation_form_drafts FORCE ROW LEVEL SECURITY;
CREATE POLICY simulation_form_drafts_read ON public.simulation_form_drafts FOR SELECT
USING (deleted_at IS NULL AND ((owner_user_id IS NOT NULL AND owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint)
OR (anonymous_token_hash IS NOT NULL AND anonymous_token_hash = NULLIF(current_setting('app.simulation_token_hash', true), ''))));
CREATE POLICY simulation_form_drafts_write ON public.simulation_form_drafts FOR ALL
USING (deleted_at IS NULL AND ((owner_user_id IS NOT NULL AND owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint)
OR (anonymous_token_hash IS NOT NULL AND anonymous_token_hash = NULLIF(current_setting('app.simulation_token_hash', true), ''))))
WITH CHECK ((owner_user_id IS NOT NULL AND owner_user_id = NULLIF(current_setting('app.actor_user_id', true), '')::bigint)
OR (anonymous_token_hash IS NOT NULL AND anonymous_token_hash = NULLIF(current_setting('app.simulation_token_hash', true), '')));
SQL);
}
public function down(): void
{
if (DB::connection()->getDriverName() !== 'pgsql') {
throw new RuntimeException('Simulation form draft rollback requires PostgreSQL.');
}
DB::statement('ALTER TABLE public.simulation_form_drafts DISABLE ROW LEVEL SECURITY');
if (DB::table('simulation_form_drafts')->exists()) {
throw new RuntimeException('Cannot rollback simulation form drafts while draft data exists.');
}
Schema::dropIfExists('simulation_form_drafts');
}
};
+3 -14
View File
@@ -2,11 +2,8 @@
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class DatabaseSeeder extends Seeder
{
@@ -17,21 +14,13 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
$this->call(ProductionReferenceSeeder::class);
if (! app()->environment(['local', 'testing'])) {
return;
}
$this->call(InvestMozReferenceSeeder::class);
$this->call(InvestmentReferenceSeeder::class);
// User::factory(10)->create();
User::firstOrCreate(
['email' => 'test@example.com'],
[
'name' => 'Test User',
'password' => Hash::make(Str::random(48)),
],
);
$this->call(LocalTestUserSeeder::class);
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace Database\Seeders;
use App\Models\Sector;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class InvestmentAreaSeeder extends Seeder
{
public function run(): void
{
DB::transaction(function () {
DB::select('SELECT pg_advisory_xact_lock(290926, 100000)');
foreach (['Turismo', 'Mineração', 'Infra-estruturas', 'Energia', 'Mercado Financeiro', 'Fintech', 'Imobiliário', 'Agronegócio', 'Bens de Consumo e Franquias', 'Tecnologias de Informação'] as $name) {
$key = str_replace('-', '', Str::slug($name));
$exists = Sector::withTrashed()->get(['name'])->contains(fn ($sector) => str_replace('-', '', Str::slug($sector->name)) === $key);
if ($exists) {
continue;
}
$slug = Str::slug($name);
if (Sector::withTrashed()->where('slug', $slug)->exists()) {
$slug .= '-especifica';
}
Sector::create(['name' => $name, 'slug' => $slug, 'is_active' => true, 'sort_order' => 100, 'published_at' => now()]);
}
});
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
class LocalTestUserSeeder extends Seeder
{
/**
* Create or reset the documented local development account.
*/
public function run(): void
{
if (! app()->environment(['local', 'testing'])) {
return;
}
$user = User::firstOrNew(['email' => 'test@example.com']);
$user->forceFill([
'name' => 'Test User',
'password' => 'password',
'email_verified_at' => now(),
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
])->save();
}
}
@@ -0,0 +1,21 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
/** Seeds version-controlled reference data and draft portfolio proposals only. */
class ProductionReferenceSeeder extends Seeder
{
public function run(): void
{
$this->call([
SectorSeeder::class,
InvestmentAreaSeeder::class,
InvestmentReferenceSeeder::class,
TerritoryReferenceSeeder::class,
ReferenceCatalogSeeder::class,
InvestmentPortfolioSeeder::class,
]);
}
}
+128
View File
@@ -0,0 +1,128 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use RuntimeException;
class ReferenceCatalogSeeder extends Seeder
{
private const CATALOG_IDS = [
'authorities' => 'a1000000-0000-4000-8000-000000000001',
'jurisdictions' => 'a1000000-0000-4000-8000-000000000002',
'economic-activities' => 'a1000000-0000-4000-8000-000000000003',
];
/** @var array<string,string> */
private const ACTIVITY_IDS = [
'agronegocio' => 'a2000000-0000-4000-8000-000000000001',
'energia' => 'a2000000-0000-4000-8000-000000000002',
'turismo' => 'a2000000-0000-4000-8000-000000000003',
'infraestruturas' => 'a2000000-0000-4000-8000-000000000004',
'economia-azul' => 'a2000000-0000-4000-8000-000000000005',
'mercado-financeiro' => 'a2000000-0000-4000-8000-000000000006',
];
public function run(): void
{
$territory = json_decode(File::get(database_path('data/territory-locations.json')), true, 512, JSON_THROW_ON_ERROR);
if (! is_array($territory) || ! is_array($territory['records'] ?? null) || ! is_int($territory['source_version'] ?? null)) {
throw new RuntimeException('Territorial catalog source is malformed.');
}
if (! is_string($territory['records_sha256'] ?? null)
|| ! hash_equals($territory['records_sha256'], hash('sha256', json_encode($territory['records'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)))
|| DB::table('geo_locations')->where('source_system', 'cartografia-workbook')->where('data_classification', 'public')->count() !== count($territory['records'])) {
throw new RuntimeException('Territorial catalog version does not match its verified seed records.');
}
DB::transaction(function () use ($territory): void {
DB::select('SELECT pg_advisory_xact_lock(20261001, 300100)');
$this->catalogVersion('authorities', 1, 'authority-register-pending-source', 'pending-authority-source',
'Awaiting an approved authority source; no institution or mandate is inferred.', null, null, 'draft');
$this->catalogVersion('jurisdictions', $territory['source_version'], 'cartografia-workbook', (string) $territory['source_version'],
'database/data/territory-locations.json', $territory['source_sha256'], null, 'published');
$activities = DB::table('sectors')->whereIn('slug', array_keys(self::ACTIVITY_IDS))->orderBy('slug')->get(['id', 'name', 'slug']);
if ($activities->count() !== count(self::ACTIVITY_IDS)) {
throw new RuntimeException('The curated sector source is incomplete; economic activity seed aborted.');
}
$canonical = $activities->map(fn ($row) => ['code' => $row->slug, 'label_pt_mz' => $row->name])->values()->all();
$activityChecksum = hash('sha256', json_encode($canonical, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
$versionId = $this->catalogVersion('economic-activities', 1, 'investmoz-curated', 'sector-seeder-v1',
'database/seeders/SectorSeeder.php; seed is a portal grouping, not an official activity classification.',
$activityChecksum, '2026-10-01', 'published');
foreach ($activities as $activity) {
$expected = [
'id' => self::ACTIVITY_IDS[$activity->slug],
'catalog_version_id' => $versionId,
'parent_id' => null,
'sector_id' => $activity->id,
'code' => $activity->slug,
'label_pt_mz' => $activity->name,
'description_pt_mz' => null,
'version_no' => 1,
'valid_from' => '2026-10-01',
'valid_to' => null,
'status' => 'published',
];
$existing = DB::table('economic_activities')->where('catalog_version_id', $versionId)->where('code', $activity->slug)->first();
if ($existing) {
foreach ($expected as $column => $value) {
if ((string) $existing->{$column} !== (string) $value) {
throw new RuntimeException('Economic activity differs from its versioned seed: '.$activity->slug);
}
}
continue;
}
DB::table('economic_activities')->insert([...$expected, 'created_at' => now(), 'updated_at' => now()]);
}
});
}
private function catalogVersion(
string $code,
int $version,
string $source,
string $sourceVersion,
string $reference,
?string $checksum,
?string $validFrom,
string $status,
): string {
$id = self::CATALOG_IDS[$code];
$expected = [
'id' => $id,
'catalog_code' => $code,
'version_no' => $version,
'locale' => 'pt-MZ',
'source_system' => $source,
'source_version' => $sourceVersion,
'source_reference' => $reference,
'source_sha256' => $checksum,
'valid_from' => $validFrom,
'valid_to' => null,
'status' => $status,
];
$existing = DB::table('reference_catalog_versions')->where('catalog_code', $code)->where('version_no', $version)->where('locale', 'pt-MZ')->first();
if ($existing) {
foreach ($expected as $column => $value) {
if ((string) $existing->{$column} !== (string) $value) {
throw new RuntimeException('Reference catalog version differs from its packaged source: '.$code);
}
}
return $id;
}
DB::table('reference_catalog_versions')->insert([...$expected, 'created_at' => now(), 'updated_at' => now()]);
return $id;
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ class SectorSeeder extends Seeder
];
foreach ($sectors as $index => $sector) {
Sector::updateOrCreate(
Sector::firstOrCreate(
['slug' => $sector['slug']],
[...$sector, 'is_active' => true, 'sort_order' => $index + 1, 'published_at' => now()]
);
@@ -0,0 +1,162 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use RuntimeException;
class TerritoryReferenceSeeder extends Seeder
{
private const SOURCE_CHECKSUM = '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff';
private const RECORDS_CHECKSUM = '630b574a3bfc5fa80dfb8d68314d7a2c413c86c3b1aa9e71603b393523b81c3c';
private const EXPECTED_COUNTS = [
'country' => 1,
'province' => 11,
'district' => 148,
'admin_post' => 437,
'locality' => 1324,
];
public function run(): void
{
$data = json_decode(File::get(database_path('data/territory-locations.json')), true, 512, JSON_THROW_ON_ERROR);
if (! is_array($data) || ! is_array($data['records'] ?? null)) {
throw new RuntimeException('Territory reference dataset is malformed.');
}
$records = $data['records'];
$actualCounts = is_array($records) ? array_count_values(array_column($records, 'location_type')) : [];
ksort($actualCounts);
$expectedCounts = self::EXPECTED_COUNTS;
ksort($expectedCounts);
if (($data['source_system'] ?? null) !== 'cartografia-workbook'
|| ($data['source_sha256'] ?? null) !== self::SOURCE_CHECKSUM
|| ($data['source_version'] ?? null) !== 3
|| ! is_string($data['records_sha256'] ?? null)
|| ! hash_equals(self::RECORDS_CHECKSUM, $data['records_sha256'])
|| ! hash_equals(self::RECORDS_CHECKSUM, hash('sha256', json_encode($records, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)))
|| ! is_array($records)
|| $actualCounts !== $expectedCounts) {
throw new RuntimeException('Territory reference dataset failed its source or completeness checks.');
}
$counts = DB::transaction(function () use ($records): array {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
$seen = [];
$types = [];
$counts = ['created' => 0, 'unchanged' => 0, 'made_public' => 0];
foreach ($records as $record) {
$this->validateRecord($record, $seen, $types);
$externalId = self::SOURCE_CHECKSUM.':'.$record['id'];
$existing = DB::table('geo_locations')->where('id', $record['id'])->first();
$externalMatch = DB::table('geo_locations')
->where('source_system', 'cartografia-workbook')
->where('external_id', $externalId)
->first();
if ($externalMatch && (! $existing || $externalMatch->id !== $existing->id)) {
throw new RuntimeException('Territory external identifier collision: '.$record['id']);
}
if ($existing) {
if ($existing->source_system !== 'cartografia-workbook' || $existing->external_id !== $externalId) {
throw new RuntimeException('Territory ID belongs to another source: '.$record['id']);
}
foreach (['name', 'parent_id', 'location_type', 'code', 'source_code'] as $field) {
if ($record[$field] !== $existing->{$field}) {
throw new RuntimeException('Territory reference differs from packaged source: '.$record['id']);
}
}
if ($existing->deleted_at !== null) {
throw new RuntimeException('Packaged territory reference is archived: '.$record['id']);
}
if ($existing->data_classification === 'internal') {
DB::table('geo_locations')->where('id', $record['id'])->update([
'data_classification' => 'public',
'updated_at' => now(),
]);
$counts['made_public']++;
} elseif ($existing->data_classification === 'public') {
$counts['unchanged']++;
} else {
throw new RuntimeException('Territory reference has an unexpected classification: '.$record['id']);
}
continue;
}
DB::table('geo_locations')->insert([
...$record,
'source_system' => 'cartografia-workbook',
'external_id' => $externalId,
'version_no' => 1,
'data_classification' => 'public',
'created_at' => now(),
'updated_at' => now(),
'deleted_at' => null,
]);
$counts['created']++;
}
return $counts;
});
$this->command?->info(sprintf(
'Referências territoriais: %d criadas, %d já existentes, %d classificadas como públicas.',
$counts['created'],
$counts['unchanged'],
$counts['made_public'],
));
}
/** @param array<string, mixed> $record
* @param array<string, bool> $seen
* @param array<string, string> $types
*/
private function validateRecord(array $record, array &$seen, array &$types): void
{
foreach (['id', 'name', 'location_type', 'code', 'source_code'] as $field) {
if (! isset($record[$field]) || $record[$field] === '') {
throw new RuntimeException('Territory reference is missing '.$field.'.');
}
}
$type = $record['location_type'];
$parentTypes = [
'country' => null,
'province' => 'country',
'district' => 'province',
'admin_post' => 'district',
'locality' => 'admin_post',
];
if (! array_key_exists($type, $parentTypes) || isset($seen[$record['id']])) {
throw new RuntimeException('Territory reference has an invalid type or duplicate ID.');
}
$expectedParent = $parentTypes[$type];
if ($expectedParent === null) {
if ($record['parent_id'] !== null) {
throw new RuntimeException('Country reference cannot have a parent.');
}
} elseif (! $record['parent_id'] || ($types[$record['parent_id']] ?? null) !== $expectedParent) {
throw new RuntimeException('Territory reference has a missing or invalid parent: '.$record['id']);
}
$seen[$record['id']] = true;
$types[$record['id']] = $type;
}
}
+63
View File
@@ -0,0 +1,63 @@
CREATE TABLE skills_edu.skill_details (
id uuid PRIMARY KEY REFERENCES public.skills(id) ON DELETE RESTRICT,
name_en varchar(255),
description_pt text,
description_en text,
category varchar(30) NOT NULL CHECK (category IN ('technical','transversal','digital','management','language','other')),
skill_type varchar(30) NOT NULL DEFAULT 'competency',
default_level varchar(30),
source_name varchar(255),
source_version varchar(100),
status varchar(20) NOT NULL DEFAULT 'active' CHECK (status IN ('draft','active','inactive','archived')),
created_by bigint,
updated_by bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE TABLE skills_edu.occupation_details (
id uuid PRIMARY KEY REFERENCES public.occupations(id) ON DELETE RESTRICT,
name_en varchar(255),
description_pt text,
description_en text,
occupational_family varchar(150),
sector_master_id bigint,
qualification_level varchar(80),
minimum_experience_months integer CHECK (minimum_experience_months IS NULL OR minimum_experience_months >= 0),
status varchar(20) NOT NULL DEFAULT 'active' CHECK (status IN ('draft','active','inactive','archived')),
created_by bigint,
updated_by bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE TABLE skills_edu.occupation_skills (
occupation_id uuid NOT NULL REFERENCES public.occupations(id) ON DELETE CASCADE,
skill_id uuid NOT NULL REFERENCES public.skills(id) ON DELETE RESTRICT,
requirement_type varchar(20) NOT NULL DEFAULT 'required' CHECK (requirement_type IN ('required','desired')),
minimum_level varchar(30),
weight numeric(5,2) CHECK (weight IS NULL OR weight BETWEEN 0 AND 100),
approved_at timestamptz,
approved_by bigint,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (occupation_id, skill_id, requirement_type)
);
CREATE INDEX idx_skills_category_status ON skills_edu.skill_details(category,status) WHERE deleted_at IS NULL;
CREATE INDEX idx_occupations_sector_status ON skills_edu.occupation_details(sector_master_id,status) WHERE deleted_at IS NULL;
CREATE TRIGGER trg_skills_updated_at BEFORE UPDATE ON skills_edu.skill_details FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
CREATE TRIGGER trg_occupations_updated_at BEFORE UPDATE ON skills_edu.occupation_details FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
ALTER TABLE skills_edu.skill_details ADD FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.skill_details ADD FOREIGN KEY (updated_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.occupation_details ADD FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.occupation_details ADD FOREIGN KEY (updated_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.occupation_details ADD FOREIGN KEY (sector_master_id) REFERENCES public.sectors(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.occupation_skills ADD FOREIGN KEY (approved_by) REFERENCES public.users(id) ON DELETE RESTRICT;
+123
View File
@@ -0,0 +1,123 @@
CREATE TABLE skills_edu.professional_profiles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_master_id bigint,
registration_mode varchar(20) NOT NULL CHECK (registration_mode IN ('individual','institution')),
full_name varchar(255) NOT NULL,
identity_hash char(64),
professional_title varchar(255),
professional_summary text,
occupation_id uuid REFERENCES public.occupations(id) ON DELETE SET NULL,
location_master_id uuid,
location_level varchar(30) CHECK (location_level IS NULL OR location_level IN ('national','region','corridor','province','district','municipality','influence_area','zee','zfi','industrial_park','indicative')),
available_from date,
availability_type varchar(30),
visibility_level varchar(20) NOT NULL DEFAULT 'private' CHECK (visibility_level IN ('private','validated_investors','institutional','public_summary')),
status varchar(30) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','submitted','in_validation','validated','rejected','suspended','deactivated')),
submitted_at timestamptz,
validated_at timestamptz,
validated_by bigint,
created_by bigint,
updated_by bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE UNIQUE INDEX uq_professional_profiles_user_active ON skills_edu.professional_profiles(user_master_id) WHERE user_master_id IS NOT NULL AND deleted_at IS NULL AND status <> 'deactivated';
CREATE UNIQUE INDEX uq_professional_profiles_identity_active ON skills_edu.professional_profiles(identity_hash) WHERE identity_hash IS NOT NULL AND deleted_at IS NULL AND status <> 'deactivated';
CREATE TABLE skills_edu.profile_contacts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id uuid NOT NULL REFERENCES skills_edu.professional_profiles(id) ON DELETE CASCADE,
contact_type varchar(20) NOT NULL CHECK (contact_type IN ('email','phone')),
contact_value_encrypted text NOT NULL,
contact_hash char(64) NOT NULL,
is_primary boolean NOT NULL DEFAULT false,
is_confirmed boolean NOT NULL DEFAULT false,
confirmed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (profile_id, contact_type, contact_hash)
);
CREATE TABLE skills_edu.profile_skills (
profile_id uuid NOT NULL REFERENCES skills_edu.professional_profiles(id) ON DELETE CASCADE,
skill_id uuid NOT NULL REFERENCES public.skills(id) ON DELETE RESTRICT,
proficiency_level varchar(30) NOT NULL,
verification_status varchar(20) NOT NULL DEFAULT 'declared' CHECK (verification_status IN ('declared','assessed','validated','rejected','expired')),
years_experience numeric(5,2) CHECK (years_experience IS NULL OR years_experience >= 0),
verified_by bigint,
verified_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (profile_id, skill_id)
);
CREATE TABLE skills_edu.profile_experiences (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id uuid NOT NULL REFERENCES skills_edu.professional_profiles(id) ON DELETE CASCADE,
job_title varchar(255) NOT NULL,
sector_master_id bigint,
organisation_name varchar(255),
started_on date NOT NULL,
ended_on date,
description text,
verification_status varchar(20) NOT NULL DEFAULT 'declared' CHECK (verification_status IN ('declared','in_validation','validated','rejected')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (ended_on IS NULL OR ended_on >= started_on)
);
CREATE TABLE skills_edu.profile_languages (
profile_id uuid NOT NULL REFERENCES skills_edu.professional_profiles(id) ON DELETE CASCADE,
language_code varchar(10) NOT NULL,
proficiency_level varchar(30) NOT NULL,
verification_status varchar(20) NOT NULL DEFAULT 'declared' CHECK (verification_status IN ('declared','assessed','validated')),
PRIMARY KEY (profile_id, language_code)
);
CREATE TABLE skills_edu.institutional_candidate_registrations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id uuid NOT NULL UNIQUE REFERENCES skills_edu.professional_profiles(id) ON DELETE CASCADE,
institution_master_id uuid NOT NULL,
program_master_id uuid,
internal_candidate_code varchar(100),
academic_level varchar(100),
class_name varchar(100),
study_status varchar(30) CHECK (study_status IS NULL OR study_status IN ('enrolled','finalist','completed','suspended','withdrawn')),
expected_completion_date date,
institution_operator_user_id bigint NOT NULL,
institution_validation_status varchar(20) NOT NULL DEFAULT 'pending' CHECK (institution_validation_status IN ('pending','validated','rejected','suspended')),
candidate_confirmation_status varchar(20) NOT NULL DEFAULT 'pending' CHECK (candidate_confirmation_status IN ('pending','confirmed','rejected','revoked')),
candidate_confirmed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (institution_master_id, internal_candidate_code)
);
CREATE INDEX idx_profiles_searchable ON skills_edu.professional_profiles(status,visibility_level,occupation_id,location_master_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_profile_skills_skill ON skills_edu.profile_skills(skill_id,verification_status);
CREATE INDEX idx_profile_contacts_hash ON skills_edu.profile_contacts(contact_hash);
CREATE UNIQUE INDEX uq_profile_primary_contact_type ON skills_edu.profile_contacts(profile_id,contact_type) WHERE is_primary = true;
CREATE TRIGGER trg_profiles_updated_at BEFORE UPDATE ON skills_edu.professional_profiles FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
CREATE TRIGGER trg_profile_skills_updated_at BEFORE UPDATE ON skills_edu.profile_skills FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
ALTER TABLE skills_edu.professional_profiles ADD FOREIGN KEY (user_master_id) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.professional_profiles ADD FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.professional_profiles ADD FOREIGN KEY (updated_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.professional_profiles ADD FOREIGN KEY (validated_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.professional_profiles ADD FOREIGN KEY (location_master_id) REFERENCES public.geo_locations(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.profile_skills ADD FOREIGN KEY (verified_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.profile_experiences ADD FOREIGN KEY (sector_master_id) REFERENCES public.sectors(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.institutional_candidate_registrations ADD FOREIGN KEY (institution_operator_user_id) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.institutional_candidate_registrations ADD FOREIGN KEY (institution_master_id) REFERENCES public.training_providers(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.institutional_candidate_registrations ADD FOREIGN KEY (program_master_id) REFERENCES public.courses(id) ON DELETE RESTRICT;
+101
View File
@@ -0,0 +1,101 @@
CREATE TABLE skills_edu.profile_qualifications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id uuid NOT NULL REFERENCES skills_edu.professional_profiles(id) ON DELETE CASCADE,
qualification_type varchar(30) NOT NULL CHECK (qualification_type IN ('degree','diploma','certificate','licence','completion_statement','other')),
name varchar(255) NOT NULL,
field_of_study varchar(255),
qualification_level varchar(100),
issuing_institution_name varchar(255) NOT NULL,
institution_master_id uuid,
issued_on date NOT NULL,
expires_on date,
holder_name varchar(255) NOT NULL,
verification_status varchar(20) NOT NULL DEFAULT 'declared' CHECK (verification_status IN ('declared','in_validation','validated','rejected','expired')),
verified_by bigint,
verified_at timestamptz,
verification_reason text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CHECK (expires_on IS NULL OR expires_on >= issued_on)
);
CREATE TABLE skills_edu.qualification_document_details (
id uuid PRIMARY KEY REFERENCES public.documents(id) ON DELETE RESTRICT,
qualification_id uuid REFERENCES skills_edu.profile_qualifications(id) ON DELETE RESTRICT,
experience_id uuid REFERENCES skills_edu.profile_experiences(id) ON DELETE RESTRICT,
document_type varchar(30) NOT NULL CHECK (document_type IN ('diploma','certificate','licence','completion_statement','academic_record','experience_proof','other')),
file_size_bytes bigint NOT NULL CHECK (file_size_bytes > 0),
document_group_id uuid NOT NULL DEFAULT gen_random_uuid(),
version_no integer NOT NULL DEFAULT 1 CHECK (version_no > 0),
replaces_document_id uuid REFERENCES skills_edu.qualification_document_details(id) ON DELETE RESTRICT,
malware_scan_status varchar(20) NOT NULL DEFAULT 'pending' CHECK (malware_scan_status IN ('pending','clean','infected','error')),
malware_scanned_at timestamptz,
quarantine_status varchar(20) NOT NULL DEFAULT 'quarantined' CHECK (quarantine_status IN ('quarantined','released','blocked','deleted')),
validation_status varchar(20) NOT NULL DEFAULT 'pending' CHECK (validation_status IN ('pending','in_validation','validated','rejected','expired','replaced')),
validation_reason text,
validated_by bigint,
validated_at timestamptz,
uploaded_by bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CHECK (((qualification_id IS NOT NULL)::integer + (experience_id IS NOT NULL)::integer) = 1),
CHECK (replaces_document_id IS NULL OR replaces_document_id <> id),
UNIQUE (document_group_id, version_no)
);
CREATE TABLE skills_edu.consent_details (
id uuid PRIMARY KEY REFERENCES public.consents(id) ON DELETE RESTRICT,
profile_id uuid NOT NULL REFERENCES skills_edu.professional_profiles(id) ON DELETE CASCADE,
legal_basis varchar(100),
policy_version varchar(50) NOT NULL,
channel varchar(30) NOT NULL,
expires_at timestamptz,
evidence jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE skills_edu.institutional_authorisations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id uuid NOT NULL REFERENCES skills_edu.professional_profiles(id) ON DELETE CASCADE,
institution_master_id uuid NOT NULL,
purpose_code varchar(100) NOT NULL,
policy_version varchar(50) NOT NULL,
status varchar(20) NOT NULL CHECK (status IN ('granted','expired','revoked')),
granted_at timestamptz NOT NULL,
expires_at timestamptz,
revoked_at timestamptz,
evidence jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (expires_at IS NULL OR expires_at > granted_at),
CHECK (revoked_at IS NULL OR revoked_at >= granted_at)
);
CREATE INDEX idx_qualifications_profile_status ON skills_edu.profile_qualifications(profile_id,verification_status) WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_qualification_status ON skills_edu.qualification_document_details(qualification_id,validation_status) WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_scan_status ON skills_edu.qualification_document_details(malware_scan_status,quarantine_status);
CREATE UNIQUE INDEX uq_documents_current_version ON skills_edu.qualification_document_details(document_group_id) WHERE deleted_at IS NULL AND validation_status <> 'replaced';
CREATE INDEX idx_authorisations_profile_institution ON skills_edu.institutional_authorisations(profile_id,institution_master_id,status);
ALTER TABLE skills_edu.profile_qualifications ADD FOREIGN KEY (verified_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.profile_qualifications ADD FOREIGN KEY (institution_master_id) REFERENCES public.training_providers(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.qualification_document_details ADD FOREIGN KEY (validated_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.qualification_document_details ADD FOREIGN KEY (uploaded_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.institutional_authorisations ADD FOREIGN KEY (institution_master_id) REFERENCES public.training_providers(id) ON DELETE RESTRICT;
CREATE FUNCTION skills_edu.validate_consent_dates() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog AS $$
DECLARE granted timestamptz; subject text;
BEGIN
SELECT granted_at,subject_ref INTO granted,subject FROM public.consents WHERE id=NEW.id;
IF subject IS DISTINCT FROM NEW.profile_id::text OR granted IS NULL OR (NEW.expires_at IS NOT NULL AND NEW.expires_at<=granted) THEN
RAISE EXCEPTION 'Consent master profile or validity mismatch' USING ERRCODE='23514';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER validate_consent_master BEFORE INSERT OR UPDATE ON skills_edu.consent_details
FOR EACH ROW EXECUTE FUNCTION skills_edu.validate_consent_dates();
+83
View File
@@ -0,0 +1,83 @@
CREATE TABLE skills_edu.human_resource_need_details (
id uuid PRIMARY KEY REFERENCES public.labour_demands(id) ON DELETE RESTRICT,
company_master_id uuid NOT NULL,
cluster_master_id uuid,
corridor_master_id uuid,
zee_master_id uuid,
zfi_master_id uuid,
requires_qualified_hr boolean NOT NULL,
profile_description text,
field_of_study varchar(255),
minimum_experience_months integer CHECK (minimum_experience_months IS NULL OR minimum_experience_months >= 0),
quantity_unit varchar(50) DEFAULT 'professionals',
engagement_type varchar(50),
training_available boolean NOT NULL DEFAULT false,
internship_places integer CHECK (internship_places IS NULL OR internship_places >= 0),
priority varchar(20) CHECK (priority IS NULL OR priority IN ('low','medium','high','critical')),
source_version varchar(100),
confidence_level varchar(20) CHECK (confidence_level IS NULL OR confidence_level IN ('low','medium','high','confirmed')),
visibility_level varchar(20) NOT NULL DEFAULT 'private' CHECK (visibility_level IN ('private','validated_users','public_summary')),
responsible_user_id bigint NOT NULL,
approved_by bigint,
approved_at timestamptz,
created_by bigint NOT NULL,
updated_by bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CHECK ((requires_qualified_hr = false AND profile_description IS NULL) OR (requires_qualified_hr = true AND profile_description IS NOT NULL))
);
CREATE TABLE skills_edu.human_resource_need_skills (
need_id uuid NOT NULL REFERENCES skills_edu.human_resource_need_details(id) ON DELETE CASCADE,
skill_id uuid NOT NULL REFERENCES public.skills(id) ON DELETE RESTRICT,
requirement_type varchar(20) NOT NULL CHECK (requirement_type IN ('required','desired')),
minimum_level varchar(30),
weight numeric(5,2) CHECK (weight IS NULL OR weight BETWEEN 0 AND 100),
is_approved boolean NOT NULL DEFAULT false,
approved_by bigint,
approved_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (need_id, skill_id, requirement_type)
);
CREATE TABLE skills_edu.human_resource_need_languages (
need_id uuid NOT NULL REFERENCES skills_edu.human_resource_need_details(id) ON DELETE CASCADE,
language_code varchar(10) NOT NULL,
minimum_level varchar(30) NOT NULL,
requirement_type varchar(20) NOT NULL CHECK (requirement_type IN ('required','desired')),
PRIMARY KEY (need_id, language_code)
);
CREATE TABLE skills_edu.human_resource_need_certifications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
need_id uuid NOT NULL REFERENCES skills_edu.human_resource_need_details(id) ON DELETE CASCADE,
certification_name varchar(255) NOT NULL,
issuer_name varchar(255),
requirement_type varchar(20) NOT NULL CHECK (requirement_type IN ('required','desired')),
must_be_valid_on date,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_hr_need_skills_skill ON skills_edu.human_resource_need_skills(skill_id,requirement_type,is_approved);
CREATE TRIGGER trg_hr_needs_updated_at BEFORE UPDATE ON skills_edu.human_resource_need_details FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
ALTER TABLE skills_edu.human_resource_need_details ADD FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.human_resource_need_details ADD FOREIGN KEY (updated_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.human_resource_need_details ADD FOREIGN KEY (approved_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.human_resource_need_details ADD FOREIGN KEY (responsible_user_id) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.human_resource_need_details ADD FOREIGN KEY (company_master_id) REFERENCES public.organizations(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.human_resource_need_details ADD FOREIGN KEY (cluster_master_id) REFERENCES public.economic_clusters(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.human_resource_need_details ADD FOREIGN KEY (corridor_master_id) REFERENCES public.development_corridors(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.human_resource_need_details ADD FOREIGN KEY (zee_master_id) REFERENCES public.investment_zones(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.human_resource_need_details ADD FOREIGN KEY (zfi_master_id) REFERENCES public.investment_zones(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.human_resource_need_skills ADD FOREIGN KEY (approved_by) REFERENCES public.users(id) ON DELETE RESTRICT;
+137
View File
@@ -0,0 +1,137 @@
CREATE TABLE skills_edu.program_details (
id uuid PRIMARY KEY REFERENCES public.courses(id) ON DELETE RESTRICT,
title_en varchar(255),
study_area varchar(255),
qualification_level varchar(100),
duration_value integer CHECK (duration_value IS NULL OR duration_value > 0),
duration_unit varchar(20),
delivery_mode varchar(20) CHECK (delivery_mode IS NULL OR delivery_mode IN ('in_person','hybrid','distance')),
capacity integer CHECK (capacity IS NULL OR capacity >= 0),
available_places integer CHECK (available_places IS NULL OR available_places >= 0),
cost_amount numeric(18,2) CHECK (cost_amount IS NULL OR cost_amount >= 0),
currency char(3),
location_master_id uuid,
accreditation_status varchar(30) NOT NULL DEFAULT 'declared' CHECK (accreditation_status IN ('declared','in_validation','confirmed','expired','not_accredited')),
accrediting_authority varchar(255),
accreditation_valid_until date,
source_name varchar(255),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CHECK (available_places IS NULL OR capacity IS NULL OR available_places <= capacity)
);
CREATE TABLE skills_edu.education_program_skills (
program_id uuid NOT NULL REFERENCES public.courses(id) ON DELETE CASCADE,
skill_id uuid NOT NULL REFERENCES public.skills(id) ON DELETE RESTRICT,
target_level varchar(30),
verification_status varchar(20) NOT NULL DEFAULT 'declared' CHECK (verification_status IN ('declared','validated','rejected')),
PRIMARY KEY (program_id, skill_id)
);
CREATE TABLE skills_edu.skill_gaps (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
skill_id uuid NOT NULL REFERENCES public.skills(id) ON DELETE RESTRICT,
occupation_id uuid REFERENCES public.occupations(id) ON DELETE SET NULL,
cluster_master_id uuid,
corridor_master_id uuid,
project_master_id uuid,
location_master_id uuid,
period_start date NOT NULL,
period_end date,
demand_quantity numeric(18,2) NOT NULL DEFAULT 0,
supply_quantity numeric(18,2) NOT NULL DEFAULT 0,
gap_quantity numeric(18,2) GENERATED ALWAYS AS (demand_quantity - supply_quantity) STORED,
criticality varchar(20) CHECK (criticality IS NULL OR criticality IN ('low','medium','high','critical')),
evidence jsonb NOT NULL DEFAULT '{}'::jsonb,
validation_status varchar(20) NOT NULL DEFAULT 'draft' CHECK (validation_status IN ('draft','in_validation','validated','rejected')),
responsible_user_id bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (period_end IS NULL OR period_end >= period_start)
);
CREATE TABLE skills_edu.educational_opportunity_details (
id uuid PRIMARY KEY REFERENCES public.education_investment_proposals(id) ON DELETE RESTRICT,
gap_id uuid REFERENCES skills_edu.skill_gaps(id) ON DELETE SET NULL,
title_en varchar(255),
opportunity_type varchar(40) NOT NULL CHECK (opportunity_type IN ('new_course','course_expansion','training_centre','laboratory','internship','apprenticeship','upskilling','reskilling','trainer_training','certification_support','digital_learning','infrastructure','scholarship_fund','partnership','other')),
description_en text,
target_audience text,
required_capacity integer CHECK (required_capacity IS NULL OR required_capacity > 0),
cluster_master_id uuid,
corridor_master_id uuid,
project_master_id uuid,
zee_master_id uuid,
zfi_master_id uuid,
location_master_id uuid,
investment_mode varchar(100),
maturity varchar(30),
risks jsonb NOT NULL DEFAULT '[]'::jsonb,
source_records jsonb NOT NULL DEFAULT '[]'::jsonb,
visibility_level varchar(20) NOT NULL DEFAULT 'private' CHECK (visibility_level IN ('private','validated_users','public_summary')),
valid_until date,
created_by bigint NOT NULL,
updated_by bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE TABLE skills_edu.educational_opportunity_skills (
opportunity_id uuid NOT NULL REFERENCES skills_edu.educational_opportunity_details(id) ON DELETE CASCADE,
skill_id uuid NOT NULL REFERENCES public.skills(id) ON DELETE RESTRICT,
target_level varchar(30),
PRIMARY KEY (opportunity_id, skill_id)
);
CREATE TABLE skills_edu.profile_need_matches (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id uuid NOT NULL REFERENCES skills_edu.professional_profiles(id) ON DELETE CASCADE,
need_id uuid NOT NULL REFERENCES skills_edu.human_resource_need_details(id) ON DELETE CASCADE,
rule_version varchar(50) NOT NULL,
score numeric(5,2) CHECK (score IS NULL OR score BETWEEN 0 AND 100),
required_conditions_met boolean NOT NULL DEFAULT false,
matched_skills jsonb NOT NULL DEFAULT '[]'::jsonb,
skill_gaps jsonb NOT NULL DEFAULT '[]'::jsonb,
explanation jsonb NOT NULL DEFAULT '{}'::jsonb,
confidence_level varchar(20) CHECK (confidence_level IS NULL OR confidence_level IN ('low','medium','high')),
review_status varchar(20) NOT NULL DEFAULT 'pending' CHECK (review_status IN ('pending','approved','rejected','contested')),
reviewed_by bigint,
reviewed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (profile_id, need_id, rule_version)
);
CREATE INDEX idx_skill_gaps_context ON skills_edu.skill_gaps(skill_id,cluster_master_id,corridor_master_id,project_master_id,validation_status);
CREATE INDEX idx_matches_need_score ON skills_edu.profile_need_matches(need_id,required_conditions_met,score DESC);
ALTER TABLE skills_edu.program_details ADD FOREIGN KEY (location_master_id) REFERENCES public.geo_locations(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.skill_gaps ADD FOREIGN KEY (responsible_user_id) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.skill_gaps ADD FOREIGN KEY (project_master_id) REFERENCES public.projects(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.skill_gaps ADD FOREIGN KEY (cluster_master_id) REFERENCES public.economic_clusters(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.skill_gaps ADD FOREIGN KEY (corridor_master_id) REFERENCES public.development_corridors(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.skill_gaps ADD FOREIGN KEY (location_master_id) REFERENCES public.geo_locations(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.educational_opportunity_details ADD FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.educational_opportunity_details ADD FOREIGN KEY (updated_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.educational_opportunity_details ADD FOREIGN KEY (project_master_id) REFERENCES public.projects(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.educational_opportunity_details ADD FOREIGN KEY (cluster_master_id) REFERENCES public.economic_clusters(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.educational_opportunity_details ADD FOREIGN KEY (corridor_master_id) REFERENCES public.development_corridors(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.educational_opportunity_details ADD FOREIGN KEY (zee_master_id) REFERENCES public.investment_zones(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.educational_opportunity_details ADD FOREIGN KEY (zfi_master_id) REFERENCES public.investment_zones(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.educational_opportunity_details ADD FOREIGN KEY (location_master_id) REFERENCES public.geo_locations(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.profile_need_matches ADD FOREIGN KEY (reviewed_by) REFERENCES public.users(id) ON DELETE RESTRICT;
+46
View File
@@ -0,0 +1,46 @@
CREATE TABLE skills_edu.audit_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
occurred_at timestamptz NOT NULL DEFAULT now(),
actor_user_id bigint,
actor_role varchar(100),
organisation_master_id uuid,
action varchar(100) NOT NULL,
object_type varchar(100) NOT NULL,
object_id uuid,
previous_state jsonb,
new_state jsonb,
result varchar(20) NOT NULL CHECK (result IN ('success','denied','failed')),
reason text,
correlation_id uuid NOT NULL,
ip_hash char(64),
user_agent_hash char(64),
metadata jsonb NOT NULL DEFAULT '{}'::jsonb
);
REVOKE UPDATE, DELETE, TRUNCATE ON skills_edu.audit_events FROM PUBLIC;
CREATE INDEX idx_audit_object ON skills_edu.audit_events(object_type,object_id,occurred_at DESC);
CREATE INDEX idx_audit_actor ON skills_edu.audit_events(actor_user_id,occurred_at DESC);
CREATE INDEX idx_audit_correlation ON skills_edu.audit_events(correlation_id);
CREATE INDEX idx_profiles_location ON skills_edu.professional_profiles(location_master_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_uploaded_by ON skills_edu.qualification_document_details(uploaded_by,created_at DESC);
CREATE INDEX idx_opportunity_skills_skill ON skills_edu.educational_opportunity_skills(skill_id);
CREATE INDEX idx_program_skills_skill ON skills_edu.education_program_skills(skill_id);
CREATE TRIGGER trg_profile_contacts_updated_at BEFORE UPDATE ON skills_edu.profile_contacts FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
CREATE TRIGGER trg_profile_experiences_updated_at BEFORE UPDATE ON skills_edu.profile_experiences FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
CREATE TRIGGER trg_institutional_registrations_updated_at BEFORE UPDATE ON skills_edu.institutional_candidate_registrations FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
CREATE TRIGGER trg_qualifications_updated_at BEFORE UPDATE ON skills_edu.profile_qualifications FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
CREATE TRIGGER trg_documents_updated_at BEFORE UPDATE ON skills_edu.qualification_document_details FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
CREATE TRIGGER trg_programs_updated_at BEFORE UPDATE ON skills_edu.program_details FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
CREATE TRIGGER trg_skill_gaps_updated_at BEFORE UPDATE ON skills_edu.skill_gaps FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
CREATE TRIGGER trg_opportunities_updated_at BEFORE UPDATE ON skills_edu.educational_opportunity_details FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
COMMENT ON COLUMN skills_edu.professional_profiles.identity_hash IS 'Hash normalizado para deduplicação; nunca guardar o identificador em claro nesta coluna.';
COMMENT ON COLUMN skills_edu.profile_contacts.contact_value_encrypted IS 'Valor cifrado pela aplicação ou serviço de segredos; não indexar.';
COMMENT ON COLUMN skills_edu.profile_need_matches.explanation IS 'Critérios, pesos, dados, fonte, limitações e lacunas usados na recomendação.';
ALTER TABLE skills_edu.audit_events ADD FOREIGN KEY (actor_user_id) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.audit_events ADD FOREIGN KEY (organisation_master_id) REFERENCES public.organizations(id) ON DELETE RESTRICT;
+1
View File
@@ -0,0 +1 @@
.env.local

Some files were not shown because too many files have changed in this diff Show More