Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f35313d06 | ||
|
|
a6afb4e97a | ||
|
|
cbae1ce77b | ||
|
|
5e6b32b493 | ||
|
|
3395424d82 | ||
|
|
3dc7c5c649 | ||
|
|
67dad461d9 |
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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']));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
@@ -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 = [];
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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++;
|
||||
|
||||
@@ -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,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'])) {
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
.env.local
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user