This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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,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,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');
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,9 @@ class ExistingBusinessController extends Controller
|
||||
$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);
|
||||
}
|
||||
@@ -45,6 +47,10 @@ class ExistingBusinessController extends Controller
|
||||
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,
|
||||
@@ -90,6 +96,10 @@ class ExistingBusinessController extends Controller
|
||||
/** @return array<string,mixed> */
|
||||
private function record(ExistingBusiness $business): array
|
||||
{
|
||||
return [...$business->only(['id', 'code', 'modality', 'status', 'classification', 'version_no', 'sector_id']), '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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -49,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));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,15 @@ 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'],
|
||||
@@ -56,6 +65,11 @@ 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'];
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Domain\Opportunities\Enums\OpportunityStatus;
|
||||
use App\Models\ExistingBusiness;
|
||||
use App\Models\ExistingBusinessObject;
|
||||
use App\Models\User;
|
||||
@@ -19,16 +20,49 @@ final class ExistingBusinessIntake
|
||||
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, $confirmationCode) {
|
||||
$business = app(BusinessRepository::class)->find($id, true);
|
||||
Gate::forUser($actor)->authorize(in_array($action, ['submitted', 'withdrawn'], true) ? 'owns' : 'review', $business);
|
||||
$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') {
|
||||
@@ -50,9 +84,69 @@ final class ExistingBusinessIntake
|
||||
'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))]);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ 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(
|
||||
@@ -30,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();
|
||||
|
||||
@@ -4,6 +4,14 @@ 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', '')))))),
|
||||
];
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -24,15 +21,6 @@ class DatabaseSeeder extends Seeder
|
||||
}
|
||||
|
||||
$this->call(InvestMozReferenceSeeder::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,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();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ class ProductionReferenceSeeder extends Seeder
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADR-001 — Identidade, autorização e acesso anónimo
|
||||
|
||||
- Estado: Aceite para orientar contratos e novas alterações; não declara implementação completa.
|
||||
- Data: 2026-10-01
|
||||
- Contexto: Laravel 13, Fortify e sessões existentes; API pública versionada e páginas Inertia. Não foi encontrado emissor/verificador JWT configurado.
|
||||
|
||||
## Decisão
|
||||
|
||||
Usar a sessão Laravel como mecanismo primário para browser/Inertia e para operações privadas same-origin, com CSRF, verificação de email quando aplicável, MFA nas áreas já protegidas e autorização explícita por Policy/Gate para cada recurso. Endpoints anónimos são uma allowlist de leitura/submissão pública, com validação, limitação de pedidos e resposta mínima. A existência de uma rota sob `/api` não a torna pública. Não introduzir JWT nem bearer tokens sem consumidor externo, ciclo de vida, audiência, rotação e revogação contratados. Não confiar em IDs enviados pelo cliente para autorizar acesso.
|
||||
|
||||
## Alternativas consideradas
|
||||
|
||||
- JWT para toda a aplicação: rejeitado; não há necessidade demonstrada e duplicaria a sessão existente.
|
||||
- Um gate global de gestor: insuficiente para autorização por recurso; manter as verificações actuais como barreira adicional, com Policies por domínio.
|
||||
- Tornar toda a API pública: rejeitado; publicar só operações explicitamente inventariadas.
|
||||
|
||||
## Consequências e validação futura
|
||||
|
||||
Contratos devem declarar `security` por operação e casos anónimo/autenticado/negado. Privacidade SQL/RLS é decisão separada no ADR-007. Alterações futuras precisam de testes positivos e negativos. Esta ADR não converte o gate `manage-portal` em RBAC completo.
|
||||
@@ -0,0 +1,13 @@
|
||||
# ADR-002 — Concorrência de edição e ETag
|
||||
|
||||
- Estado: Aceite como padrão para contratos de edição concorrente; adopção gradual.
|
||||
- Data: 2026-10-01
|
||||
- Contexto: existem campos `version_no`, revisões e verificações de versão em alguns fluxos; não há convenção `ETag`/`If-Match` global.
|
||||
|
||||
## Decisão
|
||||
|
||||
Recursos mutáveis expostos por API devem devolver um ETag forte derivado de uma representação/versionamento estável. PUT/PATCH/DELETE concorrentes exigem `If-Match`; versão ausente resulta em `428 Precondition Required`, versão desactualizada em `412 Precondition Failed`. A comparação e escrita devem ser atómicas (update condicionado à versão ou lock transaccional). Respostas devolvem novo ETag. Eventos imutáveis e criação sem edição concorrente ficam fora do requisito. Endpoints web legados podem manter `version_no` durante migração documentada; não simular ETag onde não há representação estável.
|
||||
|
||||
## Alternativas e consequências
|
||||
|
||||
Versão no body com `409` é compatível com controladores actuais mas mistura metadados e conteúdo; mantém-se apenas em contratos legados. Lock pessimista isolado não protege clientes entre pedidos. Adoptar o padrão requer testes de corrida, documentação OpenAPI e migração compatível; não altera migrations existentes nesta etapa.
|
||||
@@ -0,0 +1,13 @@
|
||||
# ADR-003 — Idempotência de comandos e integrações
|
||||
|
||||
- Estado: Aceite como padrão para operações repetíveis com efeitos.
|
||||
- Data: 2026-10-01
|
||||
- Contexto: há recibos idempotentes territoriais, chaves em outbox e `insertOrIgnore` localizados, mas não contrato uniforme.
|
||||
|
||||
## Decisão
|
||||
|
||||
Comandos repetíveis com efeitos aceitam `Idempotency-Key` opaca, limitada em tamanho e escopo por actor/cliente, operação e recurso. Persistir chave, hash canónico dos parâmetros relevantes, estado e resultado mínimo por janela de retenção aprovada; a mesma chave e mesmo pedido reproduzem a resposta, a mesma chave com payload diferente produz conflito. A criação do recibo e a mutação de negócio pertencem à mesma transacção. Jobs e consumidores deduplicam por identificador de evento e guardam progresso sem reexecutar efeitos já confirmados. Não registar a chave em claro em logs; guardar apenas referência/hash adequado. Retenção não é fixada aqui.
|
||||
|
||||
## Alternativas e consequências
|
||||
|
||||
Deduplicar só na memória falha em reinícios e múltiplos workers. Usar só UUID do recurso não cobre criação repetida. Integrações externas exigem reconciliação e chave do destinatário; não se promete exactly-once, mas entrega pelo menos uma vez com consumidor idempotente. Cada API define respostas de replay e conflito.
|
||||
@@ -0,0 +1,12 @@
|
||||
# ADR-004 — Motor seguro de expressões
|
||||
|
||||
- Estado: Aceite como restrição para cálculos configuráveis futuros; não existe motor geral aprovado no inventário.
|
||||
- Data: 2026-10-01
|
||||
|
||||
## Decisão
|
||||
|
||||
Não executar código, SQL, PHP, JavaScript, templates ou expressões arbitrárias fornecidas por utilizador/configuração. Um futuro motor aceita apenas AST JSON versionada, com operadores e funções allowlist, tipos explícitos, unidades, limites de profundidade/custo e validação de operandos antes da avaliação. Sem reflexão, I/O, rede, acesso a relógio aleatório ou estado mutável. Avaliação determinística e pura; resultados guardam versão do AST, inputs de origem e versão do motor quando usados em decisão material. Invalidar entrada fora da gramática; sem fallback silencioso. Cada domínio aprova semântica, precisão e intervalos antes de publicar fórmulas.
|
||||
|
||||
## Alternativas e consequências
|
||||
|
||||
`eval` e SQL dinâmico são rejeitados por execução arbitrária/injecção. Um DSL de texto próprio é possível mas aumenta ambiguidade; começar por AST tipada torna contratos auditáveis. Este ADR não define fórmulas, taxas, limiares ou interpretação fiscal.
|
||||
@@ -0,0 +1,13 @@
|
||||
# ADR-005 — Vigência temporal e snapshots
|
||||
|
||||
- Estado: Aceite como convenção de contrato; aplicar por domínio.
|
||||
- Data: 2026-10-01
|
||||
- Contexto: schema usa `valid_from`/`valid_until`, `valid_to`, revisões e snapshots; não há convenção temporal única para todo o legado.
|
||||
|
||||
## Decisão
|
||||
|
||||
Separar tempo de validade no domínio do instante de registo/auditoria. Para novos intervalos de validade com datas, adoptar intervalo inclusivo no início e exclusivo no fim (`[valid_from, valid_to)`), `valid_from` obrigatório quando a regra de domínio o exigir e fim aberto representado por NULL; UTC/timestamptz para instantes e date para vigência civil. Snapshots imutáveis guardam versão do catálogo e IDs de origem quando resultado depende de dados versionados. Não reescrever semântica de colunas existentes (`valid_until`, `expires_at`, `published_at`) até migração contratual específica; documentar inclusividade em cada schema.
|
||||
|
||||
## Alternativas e consequências
|
||||
|
||||
Fim inclusivo cria ambiguidades em fronteiras e fusos; o intervalo semiaberto simplifica composição. Bitemporalidade integral foi adiada por não estar exigida em todas as entidades. Importações e correcções históricas preservam proveniência e não actualizam snapshots passados.
|
||||
@@ -0,0 +1,13 @@
|
||||
# ADR-006 — Moeda, precisão e arredondamento
|
||||
|
||||
- Estado: Aceite como contrato de representação; regra de cálculo requer aprovação do domínio.
|
||||
- Data: 2026-10-01
|
||||
- Contexto: valores exactos usam `numeric(20,2)` em partes do schema; moedas são códigos de 3 letras, mas a lista autorizada e políticas de arredondamento não estão completas.
|
||||
|
||||
## Decisão
|
||||
|
||||
Representar montante como decimal exacto em SQL e como string decimal no JSON/OpenAPI para evitar perda IEEE-754; transportar código monetário separadamente e exigir ambos ou nenhum nos campos emparelhados. `^[A-Z]{3}$` verifica formato, não validade ISO nem moeda permitida. Não converter moedas nem arredondar implicitamente. A escala, modo e momento de arredondamento dependem de regra aprovada pelo domínio e devem ser identificados em cada operação; resultados persistidos registam a moeda e, se aplicável, a regra/versão usada. Não inferir taxas fiscais, taxa de câmbio ou número de casas pela moeda.
|
||||
|
||||
## Alternativas e consequências
|
||||
|
||||
Float/double é rejeitado por erro binário. Inteiro em unidade mínima depende de escala monetária ainda não aprovada. É necessário decidir allowlist de moedas e regras específicas antes de cálculo financeiro público.
|
||||
@@ -0,0 +1,13 @@
|
||||
# ADR-007 — RLS e autorização SQL
|
||||
|
||||
- Estado: Aceite como gate de segurança; activação por tabela bloqueada até matriz de papéis e contexto aprovada.
|
||||
- Data: 2026-10-01
|
||||
- Contexto: baseline não encontrou `ENABLE/FORCE ROW LEVEL SECURITY` ou `CREATE POLICY`; permissões SQL e bypass não foram auditados. Decisões anteriores proíbem inventar roles/tenants.
|
||||
|
||||
## Decisão
|
||||
|
||||
Preservar Policies/Gates da aplicação como autorização obrigatória e não declarar RLS activo. Antes de expor dados protegidos a mais de um actor, aprovar matriz de papéis, fronteiras de isolamento/tenant, contas de runtime/migration e comportamento para jobs. Depois, aplicar RLS em tabelas e operações explicitamente mapeadas, usando papel runtime sem propriedade/`BYPASSRLS`, políticas `USING`/`WITH CHECK`, e `FORCE ROW LEVEL SECURITY` quando compatível com operações técnicas. Contexto por pedido deve ser transaccional e limpo ao devolver conexão ao pool. Garantir testes permitidos e negados, inclusive acesso SQL directo e papel privilegiado conforme ameaça.
|
||||
|
||||
## Alternativas e consequências
|
||||
|
||||
RLS genérico com tenant inventado é rejeitado; RLS desligado como se fosse controlo é igualmente rejeitado. Até a matriz e auditoria serem aprovadas, a cobertura permanece bloqueada e a aplicação não deve alegar isolamento na camada SQL. Sem alteração funcional nesta Etapa 1.
|
||||
@@ -0,0 +1,13 @@
|
||||
# ADR-008 — Outbox transaccional
|
||||
|
||||
- Estado: Aceite como padrão para integração assíncrona.
|
||||
- Data: 2026-10-01
|
||||
- Contexto: existem tabelas `outbox_events`/`inbox_events`, cliente SuiteCRM e emissão localizada; integração externa não está globalmente homologada.
|
||||
|
||||
## Decisão
|
||||
|
||||
Gravar alteração de domínio e evento outbox na mesma transacção local, com envelope versionado, identificador estável, tipo, aggregate/version, instante, classificação e payload mínimo allowlisted. Nunca incluir segredos, tokens ou PII desnecessária. Worker publica após commit, usa backoff configurado por operação, regista tentativas/erro sanitizado e marca sucesso; assumir entrega pelo menos uma vez. Consumidor valida schema/versão, deduplica por message/event ID via inbox e confirma efeito e recibo atomicamente. Falhas permanentes vão para estado de falha observável e exigem replay idempotente. Não prometer exactly-once entre sistemas.
|
||||
|
||||
## Alternativas e consequências
|
||||
|
||||
Chamada externa dentro da transacção é rejeitada por atomicidade impossível e latência. Dispatch directo após commit perde eventos em crash. Contratos e políticas de retenção/PII precisam validação por integração; existência das tabelas não significa operação activa.
|
||||
@@ -0,0 +1,13 @@
|
||||
# ADR-009 — Gestão segura de documentos
|
||||
|
||||
- Estado: Aceite como padrão de contrato e segurança; capacidades incompletas são explicitadas.
|
||||
- Data: 2026-10-01
|
||||
- Contexto: `documents` guarda metadados, chave de storage, SHA-256, classificação e estado; uploads privados existem em alguns fluxos, mas scanner/retention e acesso uniforme não estão demonstrados.
|
||||
|
||||
## Decisão
|
||||
|
||||
Separar metadados, blob privado e ligações tipadas. A API expõe ID opaco e metadados permitidos, nunca `storage_key` nem URL pública permanente. Autorização é avaliada em cada download pela ligação e Policy; URLs assinadas, se adoptadas, são temporárias e emitidas só depois da autorização. Upload segue quarentena, validação de tamanho/tipo real, nome seguro, hash e varrimento antimalware antes de estado descarregável. Versões são imutáveis; substituição cria nova versão, mantendo trilho. Eliminação lógica/retention segue política aprovada e não apaga automaticamente blobs ainda referenciados. Logs excluem conteúdo, nome original quando identificável, tokens e URLs assinadas.
|
||||
|
||||
## Alternativas e consequências
|
||||
|
||||
Pasta pública e path fornecido pelo utilizador são rejeitados. Preservar metadados sem scanner mantém documento não publicável até aprovação operacional. Limites, tipos permitidos, retenção e região de armazenamento devem ser definidos por domínio e legislação aplicável, sem os inventar aqui.
|
||||
@@ -0,0 +1,261 @@
|
||||
# Baseline técnico — 2026-10-01
|
||||
|
||||
## Âmbito e método
|
||||
|
||||
Auditoria somente de leitura do estado do repositório em `main`, commit `a6afb4e97ae8afc01f3455e9e67656a130c60432`. Não se inspeccionou o conteúdo de `.env` nem se ligou à base de desenvolvimento/produção. As consultas PostgreSQL abaixo usaram exclusivamente `portal_services_test` em `127.0.0.1:55441`, com o utilizador de teste `education_test` e senha vazia.
|
||||
|
||||
O repositório já tinha alterações funcionais locais no início da auditoria: 26 caminhos modificados e 12 não rastreados. Foram preservados. Esta entrega só cria este documento.
|
||||
|
||||
## Stack e versões confirmadas
|
||||
|
||||
| Componente | Baseline observado | Evidência |
|
||||
| --- | --- | --- |
|
||||
| Backend | Laravel 13.30.1 (`composer.lock`); `composer.json` exige PHP `^8.3` e Laravel `^13.17` | `composer.lock`, `composer.json` |
|
||||
| PHP CLI | PHP 8.5.11; `php.ini` em `C:\Users\macbook\.config\investmoz\php-8.5.11\php.ini`; módulos `pdo_pgsql`, `pgsql`, `mbstring`, `openssl`, `intl`, `fileinfo`, `sodium`, `curl` e `zip` carregados | `php.exe -v`, `--ini`, `-m` |
|
||||
| Composer | Composer 2.8.12; `composer.json` validado com o PHP 8.5.11 explícito | `composer.phar validate --no-check-publish` |
|
||||
| Frontend | React 19.2.8, React DOM 19.2.8, Inertia React 3.7.0, TypeScript 5.9.3, Vite 8.2.2, Vite Plus 0.3.0, Leaflet 1.9.4 | `package-lock.json` |
|
||||
| Node/npm | Node 24.20.0, npm 11.19.0 | `node --version`, `npm --version` |
|
||||
| Dados | PostgreSQL 17.11 na base isolada; 147 tabelas através dos schemas `public`, `investmoz` e `skills_edu` | `artisan db:show --database=pgsql` |
|
||||
| GIS | Migração requer PostGIS e CI usa `postgis/postgis:17-3.5`; a versão exacta da extensão na base isolada não foi consultada | migration `2026_09_24_000100_investmoz_extensions_functions.php`, `.github/workflows/tests.yml` |
|
||||
| UI/SSR | Laravel + Inertia + React/Vite; não é Next.js | `composer.json`, `package.json`, `vite.config.ts`, `resources/js` |
|
||||
|
||||
O comando global `composer` resolve para o wrapper Herd/PHP 8.5.0. Para esta baseline, a validação Composer foi executada chamando `composer.phar` com o binário PHP 8.5.11 indicado pelo utilizador.
|
||||
|
||||
## Git e estado de trabalho
|
||||
|
||||
- Branch actual: `main`, a rastrear `origin/main`; as referências locais indicavam divergência `0/0` na leitura.
|
||||
- Remoto configurado: `origin`.
|
||||
- Branches locais adicionais observadas: `feature/existing-business-phase-0`, `feature/gis-phase-0`, `feature/mne-canonical-governance`, `feature/mne-verification-evidence` e `feature/territorial-mvp`.
|
||||
- Não foi encontrada política de branching/PR versionada nesta leitura. `pull.rebase=false`; `pull.ff` não está configurado. O histórico recente contém commits directos em `main`; isto descreve evidência Git, não recomenda o processo.
|
||||
- Estado inicial: 26 caminhos modificados e 12 não rastreados. Entre eles há controllers, services, rotas, migração, React, testes e documentação da implementação de negócios existentes. A auditoria não os alterou nem os atribui ao trabalho deste relatório.
|
||||
|
||||
## Migrations e base de dados
|
||||
|
||||
`artisan migrate:status --database=pgsql` foi executado com estas substituições de ambiente, sem ler as credenciais de `.env`:
|
||||
|
||||
```powershell
|
||||
$env:DB_CONNECTION='pgsql'
|
||||
$env:DB_DATABASE='portal_services_test'
|
||||
$env:DB_HOST='127.0.0.1'
|
||||
$env:DB_PORT='55441'
|
||||
$env:DB_USERNAME='education_test'
|
||||
$env:DB_PASSWORD=''
|
||||
$env:DB_URL=''
|
||||
& 'C:\Users\macbook\.config\investmoz\php-8.5.11\php.exe' artisan migrate:status --database=pgsql
|
||||
```
|
||||
|
||||
Resultado: 52 migrations listadas como `Ran`; zero pendentes **nesta base de teste**. `artisan db:show` confirmou PostgreSQL 17.11, `portal_services_test`, porta 55441 e 147 tabelas. Isto não comprova o estado de migrations em desenvolvimento, staging ou produção. O repositório contém 52 ficheiros de migration no momento da auditoria. Migrações posteriores à linha base podem ainda estar apenas no working tree; não foram aplicadas a outra base.
|
||||
|
||||
## Estrutura e convenções observadas
|
||||
|
||||
- Backend organizado em `app/Http/Controllers`, `Requests`, `Resources`, `Policies`, `Services`, `Jobs`, `Console/Commands`, `Integrations` e `Models`.
|
||||
- React/Inertia em `resources/js/pages`, componentes em `resources/js/components`; rotas separadas por domínio em `routes/`.
|
||||
- PostgreSQL/PostGIS é definido principalmente por migrations Laravel com SQL PostgreSQL explícito. A migration de extensões instala `postgis` e `pgcrypto`.
|
||||
- Existe uma migração canónica de negócios existentes, além de catálogos de oportunidades, CRM, geografia e território. Não foi presumida uma segunda stack.
|
||||
- Catálogos e território têm documentação de dados/OpenAPI por domínio; não foi localizado um contrato OpenAPI canónico na raiz que cubra todas as rotas.
|
||||
- Os artefactos OpenAPI existentes são parciais e misturam versões 3.0.3/3.1.0. Há geradores locais, mas o workflow CI não apresenta validação semântica global dos contratos.
|
||||
|
||||
## Autenticação, autorização e segurança de dados
|
||||
|
||||
- Laravel Fortify fornece login, verificação de email, recuperação de password, 2FA e passkeys configuráveis.
|
||||
- `manage-portal` é um Gate baseado em email verificado e lista configurada. Rotas de gestão usam também middleware `can:manage-portal`; alguns domínios sensíveis acrescentam MFA e designações específicas.
|
||||
- A autorização é principalmente aplicada na aplicação via middleware, Gate/Policy e serviços. A pesquisa de migrations não encontrou `ENABLE ROW LEVEL SECURITY`, `FORCE ROW LEVEL SECURITY` ou `CREATE POLICY`.
|
||||
- Portanto, isolamento RLS não está demonstrado; a aplicação não deve ser considerada protegida por RLS. Privilégios SQL por papel e teste de bypass de RLS não foram auditados porque não se acedeu a ambientes fora do banco isolado.
|
||||
- Existem funções e triggers PostgreSQL para integridade/auditoria. Estes mecanismos não substituem políticas RLS.
|
||||
|
||||
## OpenAPI, integrações e catálogos
|
||||
|
||||
- OpenAPI está documentado por áreas, incluindo negócio existente, registo territorial, zonas e unificação do investimento. Os geradores estão em `tools/` e os JSON em `docs/investmoz/`.
|
||||
- Cobertura, versionamento e validação são específicos de cada domínio; não há evidência de contrato único para todas as rotas web/API ou verificação OpenAPI global no CI.
|
||||
- SuiteCRM tem cliente HTTP, configuração OAuth e comando de probe. `SUITECRM_ENABLED` é falso por omissão em `.env.example`; esta leitura não confirmou credenciais nem conectividade CRM.
|
||||
- O schema tem `outbox_events`, `inbox_events`, `sync_runs` e ligações externas. A existência dessas tabelas/clientes não demonstra um consumidor completo, integração homologada ou sincronização operacional de todos os domínios.
|
||||
- Há catálogos de referência, sectores, oportunidades e território. A existência de dados/seeders não comprova por si só processo completo de versionamento e aprovação para cada catálogo.
|
||||
|
||||
## CI e testes
|
||||
|
||||
Workflow observado: `.github/workflows/tests.yml`, executado em push para `main` e pull request. Usa PostgreSQL/PostGIS 17/3.5, PHP 8.5, Node 24; instala Composer/npm, compila assets, chama `composer ci:check`, executa ciclo de schema PostgreSQL/PostGIS opt-in e testes API frontend. A configuração indica `coverage: none`; não há evidência de limiar de cobertura.
|
||||
|
||||
Inventário local: 36 ficheiros de testes Feature e 5 Unit, mais testes Node. `phpunit.xml` inclui Feature e Unit; `phpunit.investmoz.xml` isola `tests/InvestMoz` para ciclo de schema destrutivo protegido. Os testes Laravel fazem reset apenas em banco de teste reconhecido pela guarda de `tests/TestCase.php`.
|
||||
|
||||
**Gate executado nesta auditoria:**
|
||||
|
||||
| Comando | Resultado |
|
||||
| --- | --- |
|
||||
| PHP 8.5.11 `-v`, `--ini`, `-m` | Aprovado; caminho/configuração e extensões verificados |
|
||||
| Composer 2.8.12 `validate --no-check-publish`, invocado pelo PHP 8.5.11 | Aprovado: `composer.json is valid` |
|
||||
| `artisan migrate:status --database=pgsql` no banco de teste 55441 | Aprovado: 52 Ran, zero pendentes nessa base |
|
||||
| `artisan db:show --database=pgsql` no banco de teste 55441 | Aprovado: PostgreSQL 17.11; 147 tabelas |
|
||||
| `npm.cmd run check` | **Falhou:** Vite Plus reportou problemas de formatação em 108 ficheiros. Não foi aplicado autofix. A execução parou aqui, conforme o gate pedido. |
|
||||
| PHPStan, Pint global, suite PHPUnit completa, cobertura, build e execução GitHub Actions | Não executados nesta auditoria após o gate falhar; sem resultados novos a declarar. |
|
||||
|
||||
As pastas/ficheiros listados pelo Vite Plus incluem código e documentos de diversas áreas, inclusive páginas e artefactos já alterados no working tree. Não foram modificados para preservar o escopo da Etapa 0.
|
||||
|
||||
## Matriz de bloqueadores
|
||||
|
||||
| Severidade | Bloqueador e evidência | Responsável por função | Condição de desbloqueio |
|
||||
| --- | --- | --- | --- |
|
||||
| Crítica — gate actual | `npm.cmd run check` falha em 108 ficheiros; o CI `composer ci:check` inclui esta verificação. | Responsável frontend / mantenedor CI | Rever os ficheiros reportados, acordar formatação sem autofix indiscriminado, obter `npm run check` verde e executar novamente o workflow. |
|
||||
| Alta | Working tree em `main` com 26 modificados e 12 não rastreados; inclui código, migration e testes não commitados. | Responsável de release / mantenedor Git | Identificar autoria e intenção, rever diff, separar trabalho por branch/PR e manter artefactos aprovados. |
|
||||
| Alta | Estado de migrations só foi confirmado em `portal_services_test`; nenhum acesso/estado de dev, staging ou produção foi verificado. | DBA / responsável de release | Consultar cada ambiente com acesso read-only aprovado e arquivar `migrate:status`/inventário, sem inferir pelo banco QA. |
|
||||
| Alta | Nenhuma política RLS foi encontrada nas migrations; isolamento depende das barreiras da aplicação e os privilégios SQL não foram auditados. | Arquitecto de segurança / DBA | Definir modelo de papéis e ameaça, decidir RLS ou controlo compensatório documentado, testar acesso directo e bypass com papéis reais. |
|
||||
| Alta | Suite completa, PHPStan/Pint e cobertura não foram executados após o gate falhar; CI declara `coverage: none`. | Mantenedor CI / QA | Depois de resolver e aprovar o gate de formatação, correr CI completo em ambiente isolado; decidir e documentar política de cobertura. |
|
||||
| Média | OpenAPI fragmentado por domínio, versões 3.0.3/3.1.0 e sem validação global observada no workflow. | API lead / arquitectura | Definir contratos canónicos/versionamento, cobertura de rotas, validação e testes de compatibilidade no CI. |
|
||||
| Média | SuiteCRM está desactivado por omissão e a auditoria não confirma homologação/consumidor para todos os eventos. | Responsável de integração / CRM | Confirmar ambiente e credenciais por processo seguro, mapear eventos/dados, idempotência/reconciliação e executar homologação sem dados reais. |
|
||||
| Média | Política de branches/PR não está documentada; commits recentes estão directamente em `main`. | Mantenedor do repositório | Aprovar política de revisão, protecção de branch, releases e rollback e registá-la no repositório. |
|
||||
|
||||
Responsáveis são funções, não pessoas; não foram inferidos nomes nem delegações.
|
||||
|
||||
## Reprodutibilidade, limites e rollback
|
||||
|
||||
Comandos relevantes usados: `git status --short --branch`, `git branch -vv`, `git log -5 --oneline --decorate`, `git rev-list --left-right --count main...origin/main`, leitura de `composer.json`/locks, `package.json`/lock, `phpunit*.xml`, migrations, policies, rotas e workflow; `php.exe -v`, `--ini`, `-m`; Composer validate pelo PHP 8.5.11; migrations status e `db:show` com overrides para `portal_services_test`; `npm.cmd run check`.
|
||||
|
||||
As consultas SQL foram read-only. `npm run check` foi verificação sem `--fix`. O único artefacto criado é `docs/implementation/baseline.md`; rollback: remover esse ficheiro e a pasta `docs/implementation` se ficar vazia. Não há migration, alteração funcional, teste ou dado de produção a reverter.
|
||||
|
||||
## Estado proposto do gate
|
||||
|
||||
**Etapa 0: baseline documentado; gate técnico BLOQUEADO para avançar.** A evidência suficiente para descrever stack e estado local foi recolhida, mas a verificação frontend falhou e existem bloqueadores críticos/altos por resolver. Não se propõe iniciar a Etapa 1 até revisão humana e desbloqueio da matriz.
|
||||
|
||||
## Gate da Etapa 1 — 2026-10-01
|
||||
|
||||
**Resultado: BLOQUEADO; Etapa 1 não iniciada.** A pré-condição expressa pelo pedido — Etapa 0 aprovada e bloqueadores arquitecturais resolvidos — não está satisfeita: o estado registado acima continua bloqueado e não há evidência de aprovação humana ou de resolução dos bloqueadores. Em particular, o comando `npm.cmd run check` falhou na baseline e a decisão sobre RLS/privilégios continua pendente. O working tree também foi registado como sujo em `main`; o estado de migrations em produção não foi verificado.
|
||||
|
||||
Por respeito ao gate, não foram criados ADRs nem o mapa SQL–OpenAPI, e não foi iniciada implementação funcional. Não foram executados novos testes nesta etapa, pois fazê-lo não resolveria nem substituiria a aprovação e os desbloqueios exigidos. Para retomar: rever e fechar a matriz de bloqueadores, obter aprovação explícita da Etapa 0 e só então executar a inspecção de contratos da Etapa 1.
|
||||
|
||||
## Execução da Etapa 1 — 2026-10-01
|
||||
|
||||
O utilizador instruiu assumir a Etapa 0 aprovada e autorizou as etapas subsequentes. Com base nessa autorização, a Etapa 1 foi executada apenas no âmbito documental; este registo actualiza o gate anterior, que permanece como histórico da auditoria inicial.
|
||||
|
||||
ADRs registados como aceites para orientar contratos e trabalho futuro:
|
||||
|
||||
- [ADR-001 — IAM e acesso anónimo](adr/ADR-001-iam-anonymous-access.md)
|
||||
- [ADR-002 — ETag e optimistic locking](adr/ADR-002-etag-optimistic-locking.md)
|
||||
- [ADR-003 — Idempotência](adr/ADR-003-idempotency.md)
|
||||
- [ADR-004 — Motor seguro de expressões](adr/ADR-004-safe-expression-engine.md)
|
||||
- [ADR-005 — Vigência temporal e snapshots](adr/ADR-005-temporal-validity.md)
|
||||
- [ADR-006 — Moeda e arredondamento](adr/ADR-006-currency-rounding.md)
|
||||
- [ADR-007 — RLS](adr/ADR-007-rls.md)
|
||||
- [ADR-008 — Outbox](adr/ADR-008-outbox.md)
|
||||
- [ADR-009 — Gestão de documentos](adr/ADR-009-document-management.md)
|
||||
|
||||
O [mapa SQL–OpenAPI](sql-openapi-map.csv) parte das 684 linhas do dicionário `docs/investmoz/data-dictionary.csv` e contém uma linha por campo, tipo/nulabilidade/restrições SQL, descrição, migration de origem, ponteiro/tipo/formato OpenAPI quando há coincidência nominal e estado de classificação dos dados. O cruzamento nominal encontrou 294 candidatos que ainda precisam validação semântica e 390 campos sem propriedade OpenAPI documentada. A classificação por campo não existe no dicionário de origem e está marcada como não registada; não foi inferida. Portanto, o inventário é completo, mas o requisito de correspondência SQL–OpenAPI sem lacunas **não foi satisfeito**. A matriz também deixa explícito que não se deve expor automaticamente campos SQL privados/internos.
|
||||
|
||||
Não foram alterados contratos OpenAPI existentes, migrations, aplicação, configurações ou dados. Não foi executado teste funcional. Revisão documental efectuada por contagem do CSV (684 linhas de dados), cruzamento mecânico exacto de nomes contra propriedades nos cinco OpenAPI JSON e leitura dos nove ADRs. O próximo gate da Etapa 1 requer confirmar candidatos, declarar por campo se é exposto ou interno, completar contratos autorizados e obter classificação de dados aprovada por domínio, sem inventar campos nem classificações.
|
||||
|
||||
**Estado proposto:** Etapa 0 assumida aprovada por instrução do utilizador; ADRs da Etapa 1 registados como aceites; gate de conclusão documental da Etapa 1 **PENDENTE** até fechar os 390 campos sem contrato, validar os 294 candidatos e aprovar classificações. Não iniciar estrutura persistente ou interface pública baseada em campos ainda não aprovados.
|
||||
|
||||
## Gate da Etapa 2 — 2026-10-01
|
||||
|
||||
**Resultado: BLOQUEADO antes de alterações de schema.** A aprovação das etapas anteriores foi assumida conforme instrução explícita do utilizador. Contudo, a pré-condição técnica desta etapa exige PostgreSQL 16 com PostGIS disponível para fresh/rollback. A base isolada acessível (`portal_services_test`, `127.0.0.1:55441`) reportou PostgreSQL 17.11; o único serviço local observado foi `postgresql-x64-17` (Running), não foi encontrado serviço PostgreSQL 16 e o comando `docker` não está disponível. A versão exacta de PostGIS nesta base não foi confirmada: a tentativa de consulta via PsySH falhou por erro de sintaxe/citação. A migration existente `2026_09_24_000100_investmoz_extensions_functions.php` já instala `postgis`/`pgcrypto` e define três funções; não foi alterada nem repetida.
|
||||
|
||||
Não foram criadas migrations ou tabelas e não foram executados testes de fresh/rollback, pois não existe o servidor PostgreSQL 16 requerido para validar compatibilidade real. Para desbloquear, disponibilizar um PostgreSQL 16 isolado com PostGIS, acesso de teste e possibilidade de criar/eliminar schema/base; confirmar a versão da extensão antes de implementar. Nenhum serviço de domínio deve ser migrado nesta etapa.
|
||||
|
||||
**Estado proposto:** Etapa 2 **BLOQUEADA por ambiente incompatível/não confirmado**. Rollback desta tentativa: remover somente esta secção da baseline; não há alterações de schema ou dados a reverter.
|
||||
|
||||
## Execução da Etapa 2 — PostgreSQL 17.11 — 2026-10-01
|
||||
|
||||
O utilizador corrigiu o requisito: a versão-alvo é PostgreSQL 17.11, não 16. A aprovação das etapas precedentes foi assumida pela instrução explícita do utilizador. O ambiente foi confirmado como PostgreSQL 17.11, PostGIS 3.6.2 e pgcrypto 1.3; existe o tipo PostGIS `geometry`/`geography` e estão instaladas as três funções `investmoz_*` existentes.
|
||||
|
||||
### Decisão de escopo
|
||||
|
||||
Não foi criada migration nova. A migration `database/migrations/2026_09_24_000100_investmoz_extensions_functions.php` já instala as duas extensões com `IF NOT EXISTS` e cria as funções utilitárias necessárias. O schema e testes existentes usam/verificam UUID, JSONB e tipos espaciais. Não existe decisão de domínio que justifique novo ENUM ou DOMAIN; criar tipos não usados acrescentaria objectos persistentes sem benefício demonstrado. O `down()` existente remove as funções próprias com `RESTRICT` e conserva extensões partilhadas, como confirmou o teste de ciclo. Migrations existentes não foram reescritas.
|
||||
|
||||
### Ficheiros e verificações
|
||||
|
||||
- `tests/InvestMoz/SchemaLifecycleTest.php`: removido `--seed` global das duas operações limitadas ao baseline histórico; esse seeder depende de `investment_reference_options`, criada depois do baseline. O teste continua a invocar explicitamente `InvestMozReferenceSeeder` no ponto em que as tabelas correspondentes existem.
|
||||
- Comando: `C:\Users\macbook\.config\investmoz\php-8.5.11\php.exe vendor/bin/phpunit --configuration=phpunit.investmoz.xml --do-not-cache-result`, com `INVESTMOZ_SCHEMA_TESTS=1` e ligação à base temporária `investmoz_test` no porto 55441.
|
||||
- Primeira execução falhou antes das asserções porque `DatabaseSeeder` tentou inserir em tabela posterior ao baseline. Após corrigir o harness, resultado: **1 teste aprovado, 3.106 asserções, 0 falhas**. O caso executa fresh, rollback por etapas, preservação de extensões partilhadas e reaplicação das migrations.
|
||||
- Consulta read-only na base de ciclo: `server=17.11`, `postgis=3.6.2`, `pgcrypto=1.3`, tipos `geometry/geography=2`, funções utilitárias próprias `=3`.
|
||||
- A base temporária `investmoz_test`, inicialmente ausente, foi criada apenas para o teste e removida no fim; a confirmação posterior encontrou zero bases com esse nome. A base pré-existente `portal_services_test` não foi limpa ou modificada por este ciclo.
|
||||
- Nenhuma tabela de domínio nova foi criada por esta etapa; o teste apenas recriou schema na base descartável e esta foi eliminada após a verificação.
|
||||
|
||||
**Estado proposto:** Etapa 2 **CONCLUÍDA para a fundação actualmente necessária em PostgreSQL 17.11/PostGIS 3.6.2**. Extensões, tipos espaciais, funções, fresh/rollback/reaplicação foram verificados. Não foram introduzidos enums/domínios novos por falta de necessidade contratual aprovada. Rollback desta entrega: reverter a alteração do teste; não há migration nova. A anotação documental pode ser removida sem afectar schema ou aplicação.
|
||||
|
||||
## Execução da Etapa 3 — Catálogos e geografia — 2026-10-01
|
||||
|
||||
### Implementação
|
||||
|
||||
Foram adicionadas migrations para `reference_catalog_versions`, `authorities` e `economic_activities`, com chaves estrangeiras para organizações, territórios e sectores existentes, índices, `pt-MZ`, vigência `[valid_from, valid_to)` e protecção contra alteração de versões publicadas. Não foram duplicados os `geo_locations` nem os catálogos de sectores já existentes.
|
||||
|
||||
O seeder `ReferenceCatalogSeeder` é determinístico e idempotente: valida a origem/versionamento geográfico já empacotado, cria a versão de actividades económicas com referências aos seis sectores disponíveis e deixa autoridades sem registos fictícios enquanto não houver fonte oficial aprovada. As actividades são agrupamentos do portal, não uma classificação estatística/legal oficial.
|
||||
|
||||
Foram expostos endpoints anónimos de leitura para autoridades, jurisdições e actividades económicas, com pesquisa, paginação, versão, metadados `pt-MZ` e ETag. Jurisdições respeitam o filtro pai-filho e só expõem referências públicas. A UI territorial passou a consumir o endpoint versionado e limpa a selecção e descendentes quando o pai muda. O contrato está em `docs/investmoz/catalogues/openapi.json`.
|
||||
|
||||
### Ficheiros desta etapa
|
||||
|
||||
- Criados: migration `2026_10_01_000100_create_reference_catalogs.php`; seeder `ReferenceCatalogSeeder.php`; controller `Api/V1/ReferenceCatalogController.php`; contrato OpenAPI `docs/investmoz/catalogues/openapi.json`; helper JS e declaração de tipos `resources/js/lib/territory-path.js` / `.d.ts`; testes PHP `ReferenceCatalogTest.php` e Node `territory-path.test.mjs`.
|
||||
- Alterados: `ProductionReferenceSeeder.php`, `routes/api.php`, `resources/js/components/investment-territory.tsx`, `resources/js/pages/investment/catalogue.tsx`.
|
||||
|
||||
### Comandos e evidências
|
||||
|
||||
- PHP 8.5.11 explícito: suíte PHPUnit focada (`ReferenceCatalogTest|InvestmentTerritoryTest|TerritorialRegistryTest`) — **22 testes, 188 asserções, aprovado**.
|
||||
- `node --test tests/frontend/territory-path.test.mjs tests/frontend/portal-api.test.mjs` — **15 testes, aprovado**; os testes do helper de geografia isolados — **2 aprovados**.
|
||||
- `npm.cmd run types:check` — aprovado.
|
||||
- `npm.cmd run build` — aprovado; aviso não bloqueante de ausência do pacote opcional `fontaine`.
|
||||
- Laravel Pint em modo `--test` nos ficheiros PHP novos/alterados — aprovado.
|
||||
- Contrato OpenAPI JSON e referências locais — parse/validação estrutural executados com PowerShell; válido.
|
||||
- `git diff --check` ainda por executar: o executável Git não está no PATH desta sessão; foi localizado em `C:\Program Files\Git\cmd\git.exe` para verificação final.
|
||||
- Os testes Laravel usaram a base descartável `investmoz_test`, inicialmente ausente, no serviço PostgreSQL 17.11/PostGIS 3.6.2; a base será removida após a execução. A base preexistente `portal_services_test` não foi resetada nesta etapa.
|
||||
|
||||
### Riscos, pendências e rollback
|
||||
|
||||
Não há nomes de autoridades sem fonte oficial aprovada; o endpoint informa catálogo ainda não publicado até haver dados e aprovação. O catálogo de actividades não substitui códigos oficiais nacionais/internacionais. O filtro territorial depende da classificação pública existente no pacote cartográfico; confirmar versão/origem com o responsável de dados antes da publicação operacional. Não foi aplicado migration em base de produção.
|
||||
|
||||
Rollback: executar `php artisan migrate:rollback --step=1` apenas num ambiente onde esta migration seja a última aplicada e após exportar/publicar nenhum conteúdo de catálogo; o `down()` protege-se contra remoção de dados de autoridade/actividade. Reverter também o seeder, controller/rotas, contrato e consumo UI desta etapa. O deploy deve publicar primeiro migration/seeds e só depois a UI que chama os endpoints.
|
||||
|
||||
**Estado proposto:** Etapa 3 tecnicamente implementada e testes aprovados; gate de publicação **PENDENTE** da revisão humana sobre fonte e publicação dos dados de autoridades e da aprovação da classificação de actividades. Não iniciar a etapa seguinte sem fechar esse gate.
|
||||
## Gate da Etapa 6 — Simulações, jobs, relatórios e segurança — 2026-10-01
|
||||
|
||||
**Resultado: BLOQUEADO antes de alterações funcionais.** A inspecção confirmou Laravel 13.30.1, PHP CLI 8.5.11, React 19 com Inertia, PostgreSQL 17.11/PostGIS 3.6.2 e CI com PostgreSQL/PostGIS 17. O working tree já contém alterações não commitadas anteriores; foram preservadas.
|
||||
|
||||
### Bloqueadores de pré-condição
|
||||
|
||||
1. **Modelo de parâmetros não aprovado.** Não foi localizado um contrato aprovado que defina questionários/versionamento, tipos de resposta, semântica de cenários, snapshots de entradas e resultados, nem os limites e estados de simulação. O ADR-004 decide que expressões devem ser seguras, mas não aprova o modelo funcional de parâmetros. Sem isso, criar `questionnaire_versions`, `simulations`, `scenarios`, `results`, `result_sources` ou `obligations` exigiria inferir estrutura e significado.
|
||||
2. **Matriz RLS/IAM ausente.** O ADR-007 declara explicitamente RLS bloqueado até aprovação de papéis, fronteiras de isolamento/tenant, papéis de runtime/migration e contexto de jobs. `docs/investmoz/decisions.md` também proíbe inventar roles, tenants ou policies. A baseline não encontrou políticas RLS activas. Não há autorização para assumir qual actor pode ver cada execução ou dado de relatório.
|
||||
3. **Contratos operacionais por domínio pendentes.** Os ADRs-002 e -003 aprovam padrões de ETag/If-Match e `Idempotency-Key`, mas deixam detalhes como representação/versionamento do recurso, escopo e retenção do ledger e replay por operação para cada API. Os requisitos de jobs também não fixam filas, limites de retry/backoff, idempotência de efeitos nem regras de reconciliação. Não foram encontrados contratos aprovados desta Etapa 6 que fechem esses detalhes.
|
||||
4. **Soft delete e token anónimo.** Não foi identificada decisão que determine em quais tabelas se aplica soft delete nem como o hash/token anónimo se relaciona com autenticação, escopo, retenção e revogação. O ADR-001 não permite tratar IDs enviados pelo cliente como autorização.
|
||||
|
||||
A autorização anterior para considerar aprovadas as etapas sequenciais não fornece os valores e decisões de domínio ausentes, e não revoga as condições explícitas dos ADRs. Por isso, nenhum schema ou comportamento foi inferido. Não foram executados migrations, seeds ou testes funcionais desta etapa.
|
||||
|
||||
### Plano proposto após desbloqueio
|
||||
|
||||
Após aprovação do modelo de parâmetros e das matrizes IAM/RLS, inspeccionar novamente os contratos aprovados e apresentar antes de editar a lista exacta de migrations, modelos, serviços, policies, jobs, rotas/OpenAPI e testes. A validação deverá incluir acesso autorizado e negado por actor/organização, teste SQL directo com papel runtime e contexto limpo entre transacções, ETag ausente/desactualizado, replay e conflito de idempotência, concorrência real, retry limitado, reconciliação após falha e auditoria sem PII. Até lá, não iniciar estes artefactos.
|
||||
|
||||
**Condição para desbloquear:** aprovar o esquema de parâmetros e snapshots; fornecer matriz actor × recurso × operação e tenant/contexto; aprovar papéis PostgreSQL separados, grants, comportamento de jobs e estratégia de conexão/pool; definir escopo/retensão de idempotência, política de soft delete e ciclo de vida do token anónimo; fixar retries/reconciliação por job.
|
||||
|
||||
**Estado proposto:** Etapa 6 **BLOQUEADA por pré-condições arquitecturais em falta**. Alteração desta tentativa: somente esta secção documental. Rollback: remover esta secção de `baseline.md`; não há alteração de código, migration, dado ou ambiente a reverter.
|
||||
## Execução da Etapa 6 — Simulações, jobs, relatórios e segurança — 2026-10-01
|
||||
|
||||
O utilizador instruiu assumir todas as aprovações anteriores. A secção de gate bloqueado acima fica como registo histórico e é substituída para esta execução pela aprovação assumida explicitamente nesta conversa. A inspecção confirmou Laravel 13.30.1, PHP 8.5.11, React 19/Inertia, PostgreSQL 17.11/PostGIS 3.6.2 e os padrões locais de membership, outbox, idempotência, auditoria e filas. Não foram alteradas migrations anteriores nem `.env`.
|
||||
|
||||
### Diff lógico
|
||||
|
||||
- Migration aditiva `2026_10_01_000200_create_simulation_execution_core.php`: cria versões de questionário, simulações, cenários, resultados/fontes, obrigações, snapshots de relatório, ledger de jobs, recibos idempotentes e auditoria append-only. Inclui FKs, unicidades, hashes, RLS habilitado e `FORCE ROW LEVEL SECURITY`, policies separadas de leitura/escrita e triggers de imutabilidade para questionário publicado, resultados, fontes, relatório pronto e auditoria. `down()` recusa rollback se houver dados, excepto a versão base fixa que a própria migration instala.
|
||||
- Questionário publicado `user-entered-arithmetic` v1 (`pt-MZ`): os três parâmetros aritméticos são entradas do utilizador. A taxa e redução ficam limitadas ao intervalo já usado pelo simulador existente (0–100); a base não tem tecto fiscal configurado. A fórmula é `base × taxa ÷ 100 × (1 − redução ÷ 100)`. A resposta não infere moeda, taxa legal, elegibilidade ou obrigação jurídica.
|
||||
- Models Eloquent novos: `QuestionnaireVersion`, `Simulation`, `SimulationScenario`, `SimulationResult`, `SimulationResultSource`, `SimulationObligation`, `SimulationReport` e `SimulationJob`.
|
||||
- `SimulationExecution` faz transacções, lock/advisory lock por chave, replay idempotente e conflito para payload divergente; o token anónimo é HMAC-SHA256 e só o hash chega à base/fila. Contexto RLS é `SET LOCAL` e é limpo com o fim da transacção. Edição compara ETag em lock transaccional, devolve 428/412 e incrementa versão; conclusão/falha do job também avança versão e ETag.
|
||||
- `RunSimulation` tem três tentativas, backoff `[10,60]`, estado reconciliável e erros persistidos apenas como códigos. Resultados, proveniência de entradas e relatórios guardam snapshots/hash imutáveis. Não grava texto de excepção, token ou PII no log de auditoria.
|
||||
- `SimulationPolicy` e policies SQL reaproveitam `memberships` e os papéis existentes (`admin`, `manager`, `officer`, `viewer`); não criam tenants ou papéis novos. Membros podem ler simulações da organização; escrita exige owner pessoal, token válido ou membership com papel de escrita. Revogar membership retira acesso mesmo ao criador de uma simulação organizacional.
|
||||
- `SimulationController`/requests/rotas API adicionam consulta da versão publicada, criação/consulta, edição de cenário e consulta do relatório. `routes/api.php` activa middleware web/sessão, CSRF e throttle nos endpoints do simulador.
|
||||
- `docs/investmoz/simulations/openapi.json` documenta cabeçalhos, payloads e resultados HTTP. `SimulationExecutionTest.php` cobre os fluxos e o ciclo vazio de rollback/reaplicação.
|
||||
|
||||
Ficheiros criados: a migration acima; oito models; `app/Policies/SimulationPolicy.php`; `app/Http/Requests/StoreSimulationRequest.php`; `app/Http/Requests/UpdateSimulationScenarioRequest.php`; `app/Http/Controllers/Api/V1/SimulationController.php`; `app/Services/SimulationExecution.php`; `app/Jobs/RunSimulation.php`; `docs/investmoz/simulations/openapi.json`; `tests/Feature/SimulationExecutionTest.php`. Ficheiros alterados: `routes/api.php` e este baseline.
|
||||
|
||||
### Comandos e evidências
|
||||
|
||||
- PHP 8.5.11 explícito, PHPUnit em `investmoz_test` isolada no PostgreSQL 17.11/PostGIS 3.6.2: `vendor/bin/phpunit --configuration=phpunit.xml --filter='SimulationExecutionTest|ReferenceCatalogTest|InvestmentTerritoryTest|TerritorialRegistryTest' --do-not-cache-result` — **32 testes, 252 asserções aprovados**. Inclui down/up da migration sem dados.
|
||||
- PHPStan nos ficheiros da etapa — **0 erros**.
|
||||
- Laravel Pint `--test` nos ficheiros PHP da etapa — aprovado.
|
||||
- OpenAPI JSON e referências locais — parse e verificação estrutural aprovados.
|
||||
- `artisan route:list --path=api/v1/simulations` — quatro rotas de simulação listadas; rota de questionário confirmada pelo teste de endpoint.
|
||||
- Teste de RLS foi executado sob role PostgreSQL temporário `NOLOGIN`, sem `BYPASSRLS`, com grants mínimos a `simulations`/`memberships`: owner autorizado lê, actor não relacionado não lê, viewer da organização lê mas não actualiza, e token anónimo incorrecto não lê. A role temporária foi removida pelos testes.
|
||||
- Teste de optimistic locking verifica ausência de `If-Match` (428), ETag antigo (412), edição concorrente com ETag original após outra edição (412) e incremento/reexecução com ETag actual.
|
||||
- Testes idempotentes cobrem replay igual e conflito de payload; retries confirmam limite três/backoff e reconciliação terminal. Hashes de resultados/fontes são comparados contra a representação canónica PostgreSQL; teste tenta actualizar auditoria e confirma que o trigger append-only rejeita a alteração.
|
||||
- `investmoz_test` estava ausente antes desta execução e foi criada apenas para estes testes. A base preexistente `portal_services_test` não foi limpa nesta etapa. Não se ligou a desenvolvimento, staging ou produção.
|
||||
|
||||
### Riscos, limites e rollback
|
||||
|
||||
Os registos de `simulation_obligations` não são auto-gerados sem fonte versionada aprovada; a tabela não transforma orientação em obrigação jurídica. O relatório nesta entrega é snapshot JSON com hash, não PDF/documento assinado. O motor limita-se à fórmula aritmética já apresentada no portal e só usa valores introduzidos pelo utilizador. Não houve verificação dos GRANT/roles reais de produção; a conta de runtime deve ser não-proprietária e sem `BYPASSRLS`, enquanto a conta de migration administra schema. O worker recebe somente ID de simulação, actor e hash de token, nunca o token em claro.
|
||||
|
||||
Rollback: primeiro retirar o consumo das rotas, depois executar rollback direccionado à migration `2026_10_01_000200_create_simulation_execution_core.php` com o comando Artisan de rollback por `--path` no ambiente pretendido. O `down()` só prossegue quando as tabelas não contêm dados de negócio; preservar/exportar simulações e relatórios antes de rollback com dados. O ciclo vazio down/up foi testado; rollback com dados deve falhar de forma segura e não foi executado sobre dados de negócio.
|
||||
|
||||
**Estado proposto:** Etapa 6 **IMPLEMENTADA; gate técnico VERDE** para revisão humana. Não foi iniciado trabalho de etapa posterior nem feito deploy.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Simulador Fiscal InvestMoz — Governação do MVP
|
||||
|
||||
**Fase:** 0 — Governação
|
||||
**Estado:** gate da Fase 0 **APROVADO PARA CONTINUAÇÃO** por instrução explícita do utilizador em 2026-10-01; blockers específicos de domínio continuam sujeitos aos gates das fases correspondentes.
|
||||
**Data da inspecção:** 2026-10-01
|
||||
**Branch de trabalho:** `codex/simulador-fiscal-phase-0`
|
||||
|
||||
## Objectivo e limites
|
||||
|
||||
Esta governação define o MVP fiscal sem substituir funcionalidades existentes do Portal do Investidor. O âmbito funcional do Simulador Fiscal fica limitado ao que está aprovado nesta especificação; não declara homologação, publicação de regras, integração operacional ou cobertura fiscal que não tenha evidência.
|
||||
|
||||
| Tratamento | Âmbito aprovado para o MVP |
|
||||
| --- | --- |
|
||||
| Calcular | IRPC; IRPS sobre remunerações; IVA; ISPC; retenções na fonte; INSS; valor aduaneiro; direitos aduaneiros; ICE; IVA na importação; obrigações fiscais/cambiais; cenários; relatórios PDF e JSON. |
|
||||
| Identificar e orientar, sem cálculo automático | Imposto do selo; impostos autárquicos; mais-valias; preços de transferência; convenções completas de dupla tributação; benefícios complexos ZEE/ZFI; regimes sectoriais. |
|
||||
| Fora desta release | Manifestações formais; oportunidades de negócio; conversões para projecto/oportunidade; CRM completo; conta avançada do investidor; assistente inteligente. |
|
||||
|
||||
Decisão de escopo registada em 2026-10-01: a especificação limita o MVP fiscal; funcionalidades existentes fora desse limite são preservadas no portal, mas não integram o MVP nem o fluxo anónimo fiscal.
|
||||
|
||||
## Baseline técnico inspeccionado
|
||||
|
||||
- Backend Laravel 13.30.1; PHP 8.5.11 CLI; React 19/Inertia/Vite; Node 24.20.0 e npm 11.19.0. Não é uma aplicação Next.js.
|
||||
- `composer.lock` e `package-lock.json` existem. `vendor/` e `node_modules/` estão presentes. O comando global `composer` não foi encontrado nesta sessão; não reinstalar dependências.
|
||||
- PostgreSQL/PostGIS está configurado no workflow CI e em `deploy/docker/compose.yaml`. O serviço local não foi consultado nesta execução; `docker` não está disponível no PATH.
|
||||
- Redis e armazenamento S3 aparecem como opções/configuração, mas não como serviços activos no compose de desenvolvimento inspeccionado. Não foi confirmada conectividade a IAM/SSO externo.
|
||||
- O workflow CI usa PHP 8.5, Node 24 e PostgreSQL/PostGIS 17/3.5. O PHPUnit 12.5.34 está instalado.
|
||||
- Existem contratos OpenAPI por domínio: alguns 3.1.0 e outros 3.0.3. Os cinco ficheiros JSON inspeccionados são parseáveis; não foi executada validação semântica integral nem verificada equivalência API/schema.
|
||||
- Há migrations PostgreSQL para vários domínios, ADRs 001–009 e `sql-openapi-map.csv`. O ADR-007 mantém RLS bloqueado por falta de matriz aprovada; migrations locais do simulador incluem policies RLS. A divergência entre decisão e artefactos requer revisão de segurança antes de declarar RLS demonstrado.
|
||||
- `.env.example` foi lido apenas para inventariar nomes de configuração. `.env`, segredos e serviços de produção não foram inspeccionados nem usados nesta fase.
|
||||
|
||||
## Matriz de conformidade e rastreabilidade
|
||||
|
||||
| Requisito/fase | Evidência actual | Estado | Lacuna ou divergência | Ficheiros/áreas de referência | Próximo gate |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Fase 0 — governação | Baseline, decisões e ADRs; este documento acrescenta escopo, RACI funcional, riscos, backlog e gates | CONFORME PARA INICIAR A FASE 1 | Responsáveis nominais e aprovações fiscal/jurídica/segurança/ops continuam necessários antes dos gates que delas dependem | `docs/implementation/baseline.md`, `docs/implementation/adr/`, este ficheiro | Gate da Fase 1 |
|
||||
| Fase 1 — fundação e contratos executáveis | Migrations, mapa SQL/OpenAPI, contratos por domínio e teste de ciclo de schema | PARCIAL | Contratos fragmentados em 3.0.3/3.1.0; validação integral e reconciliação de todos os caminhos/colunas não demonstradas neste gate | `database/migrations/`, `docs/implementation/sql-openapi-map.csv`, `docs/investmoz/*/openapi.json` | Inventário aprovado, validação OpenAPI integral e testes seguros em DB descartável |
|
||||
| Fase 2 — repositório legislativo | Existe rota de consulta de fontes legislativas e registos jurídicos noutros domínios | PARCIAL | Não há evidência suficiente de repositório fiscal versionado com disposições, vigência, hashes, relações, ingestão e pesquisa integral | `app/Http/Controllers/Api/V1/LegislationSourceController.php`, migrations, `routes/api.php` | Contrato e fontes oficiais aprovadas; verificar CRUD/importação e integridade |
|
||||
| Fase 3 — regras e publicação | Há catálogos versionados e padrões de revisão noutros domínios | AUSENTE para regras fiscais | Não foi identificada evidência de rule sets fiscais, parâmetros com fonte oficial, elegibilidade, revisão de quatro olhos, testes dourados e publicação imutável | migrations, models/services fiscais (a inventariar na Fase 3) | Aprovação de fontes, regras, responsabilidade fiscal/jurídica e vigência |
|
||||
| Fase 4 — API e formulário | `/Simulador`, catálogos, snapshots e execução aritmética existem | BLOQUEADO por divergência | O formulário contém identidade/contactos e finalidade de manifestação/oportunidade; a especificação limita o modo anónimo a dados económicos/fiscais/territoriais/operacionais e exclui esses fluxos. Decidir compatibilidade semântica antes de alterar a UI/API | `resources/js/lib/simulation-form.ts`, `resources/js/components/simulation-form.tsx`, `StoreSimulationFormDraftRequest.php`, `SimulationFormDrafts.php` | Decisão de produto/privacidade sobre fluxo anónimo e separação de funcionalidades fora da release |
|
||||
| Fase 5 — motor fiscal | Motor actual executa aritmética genérica introduzida pelo utilizador | AUSENTE para o âmbito fiscal | Não há evidência de cálculos fiscais oficiais de IRPC, IVA, ISPC, IRPS, INSS ou retenções com regras/fontes aprovadas, Decimal, arredondamento fiscal e casos dourados | `app/Services/SimulationExecution.php`, `app/Jobs/RunSimulation.php`, `docs/investmoz/simulations/openapi.json` | Aprovação de regras fiscais e especificação matemática antes de implementação |
|
||||
| Fase 6 — aduanas, câmbios e calendário | Campos/estrutura parcial de simulação e jobs genéricos | AUSENTE para os cálculos e calendário pedidos | Valor aduaneiro, direitos, ICE, IVA de importação, obrigações cambiais/fiscais e calendário não estão demonstrados como motor completo | app/migrations de simulação e integrações | Fontes e regras oficiais; contratos de moedas/origem e jobs/retries |
|
||||
| Fase 7 — relatórios e homologação | Snapshot de relatório JSON e hashes existem | PARCIAL | PDF, reconciliação integral API/DB/PDF/JSON, homologação, acessibilidade manual, resiliência e DR não demonstrados | `SimulationReport`, controller/serviço de simulação, testes | Contrato PDF/JSON, casos dourados, acessibilidade e prova de recuperação |
|
||||
| Fase 8 — release e estabilização | Workflow CI e compose de desenvolvimento existem | PARCIAL | Não há evidência de Go/No-Go fiscal, release imutável, backup/restauro, rollout/rollback ensaiados ou hypercare | `.github/workflows/tests.yml`, `deploy/docker/compose.yaml` | Aprovação de release, owners operacionais e evidência de rollout/rollback |
|
||||
|
||||
### Divergências que não serão resolvidas por inferência
|
||||
|
||||
1. **Identidade no modo anónimo:** decisão de escopo do utilizador aplica a regra da especificação: o simulador fiscal anónimo pede apenas dados económicos, fiscais, territoriais e operacionais. O formulário actual oferece campos pessoais; a Fase 4 deve separar esses fluxos sem apagar código/dados do portal.
|
||||
2. **Oportunidades/manifestações:** decisão de escopo do utilizador mantém esses fluxos fora desta release fiscal e preserva código/dados existentes. A Fase 4 deve removê-los da experiência fiscal sem declarar funcionalidades do portal como MVP fiscal.
|
||||
3. **RLS:** ADR-007 bloqueia activação sem matriz actor/recurso/operação e papéis SQL, enquanto migrations locais contêm policies. A prova de política eficaz, grants e papel runtime permanece BLOQUEADA.
|
||||
4. **Aprovação normativa:** nenhum cálculo ou taxa fiscal pode ser codificado/publicado com base apenas na fórmula aritmética existente ou em defaults do cliente. Cada regra necessita fonte oficial, período de vigência, aprovador fiscal e revisão jurídica.
|
||||
5. **Dados/ambientes:** estado actual de migrations e serviços só pode ser confirmado em ambiente de teste isolado; não inferir por CI ou documentação o estado de desenvolvimento/produção.
|
||||
|
||||
## RACI funcional proposto
|
||||
|
||||
Não foram atribuídos nomes. A nomeação de responsáveis e a aprovação desta matriz são pendências do gate.
|
||||
|
||||
| Actividade | Responsável (R) | Accountable (A) | Consultados (C) | Informados (I) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Aprovar fonte, regra, vigência e casos fiscais | Especialista fiscal | Dono de produto fiscal | Jurídico, dados, engenharia | QA, operações |
|
||||
| Publicar regra versionada | Engenharia fiscal | Dono de produto fiscal | Fiscal, jurídico, segurança | QA, operações |
|
||||
| Aprovar privacidade e fluxo anónimo | Privacidade/protecção de dados | Dono de produto | Jurídico, segurança, fiscal | Engenharia, QA |
|
||||
| Aprovar IAM/RLS/grants | DBA/segurança | Arquitectura de segurança | Engenharia, operações | Dono de produto, QA |
|
||||
| Homologar cálculos e relatórios | QA fiscal | Dono de produto fiscal | Fiscal, jurídico, engenharia | Operações |
|
||||
| Autorizar release, rollback e DR | Operações/release | Dono de serviço | DBA, segurança, QA, produto | Stakeholders |
|
||||
|
||||
RACI é uma proposta de funções e não atribui autoridade a pessoas. Gate bloqueado até os responsáveis formais serem indicados e aceitarem.
|
||||
|
||||
## Backlog e gates de aceitação
|
||||
|
||||
| Prioridade | Item | Dependência | Critério de conclusão |
|
||||
| --- | --- | --- | --- |
|
||||
| P0 | Resolver divergência do modo anónimo, PII e funcionalidades fora da release | Decisão de produto e privacidade | Formulário e contrato aprovados sem recolha anónima vedada; decisão registada |
|
||||
| P0 | Aprovar fontes, regras, vigência e arredondamento de cada cálculo | Autoridade fiscal e jurídico | Fonte oficial/hash, regra versionada, casos dourados e dupla revisão registados |
|
||||
| P0 | Aprovar matriz IAM/RLS e papéis SQL | Segurança/DBA | Testes actor×recurso×operação com papel runtime sem bypass e contexto limpo |
|
||||
| P1 | Reconciliar OpenAPI integral, migrations e esquema | API/DBA | Validação semântica de contratos e cobertura de operações/campos no CI |
|
||||
| P1 | Implementar cálculos e obrigações do escopo | Regras aprovadas | Testes críticos 100%, totais aplicáveis ≥95%, Decimal e resultados explicáveis |
|
||||
| P1 | Aduanas, câmbios, calendário e jobs | Fontes/regras e contrato operacional | Casos dourados, idempotência/retry, reconciliação e dead-letter demonstrados |
|
||||
| P1 | PDF/JSON e acessibilidade/homologação | Schema de relatório e casos aprovados | API/DB/PDF/JSON reconciliados; teclado/leitor de ecrã/zoom e homologação registados |
|
||||
| P1 | Preparar release, backup/restauro e rollback | Operações/DBA | Release imutável, backup restaurado em prova, smoke tests e rollback ensaiados |
|
||||
|
||||
## Métrica, aprovação e bloqueio
|
||||
|
||||
- Go exige 100% dos testes críticos aplicáveis aprovados e pelo menos 95% de todos os testes aplicáveis aprovados. Cobertura de código é métrica separada.
|
||||
- Ignorados, instáveis, não executados ou sem evidência não contam como aprovados. Um teste aplicável inexistente é lacuna a criar na fase correspondente.
|
||||
- Não há excepção para falha crítica. Uma falha alta só pode ter excepção formal com impacto, controlo compensatório, responsável, prazo e aprovação; após expiração volta a ser bloqueio.
|
||||
- É bloqueio de release qualquer cálculo sem fonte aprovada, elegibilidade presumida, inconsistência API/DB/relatórios, fuga de dados, OpenAPI inválido, RLS não demonstrado, migration irreversível ou recuperação/rollback não provados.
|
||||
|
||||
## Evidência desta execução
|
||||
|
||||
- Branch `codex/simulador-fiscal-phase-0` criada e seleccionada. O working tree tinha alterações locais em `main`; todas foram preservadas e transitam sem commit para a branch. A autoria/intenção de cada alteração anterior não foi auditada nesta fase.
|
||||
- A execução PHPUnit 12.5.34 com PHP 8.5.11 mais recente, anterior a esta fase, iniciou 15 testes de `SimulationExecutionTest|SimulationFormDraftTest`; os 15 pararam antes das asserções com `Unregistered skills_edu schema exists; reconcile before migration.`. A suite não foi aprovada. O `phpunit.xml` define `DB_DATABASE=investmoz_test`, mas não fixa host/porta/credenciais; esses valores podem vir do `.env`. Como o destino não foi confirmado antes daquela execução, o isolamento da base não pode ser afirmado. Não executar testes que migrem/escrevam até confirmar explicitamente uma base descartável isolada. Nesta Fase 0 não se repetiu a execução nem se inspeccionou `.env`.
|
||||
- `node` validou parsing dos cinco contratos OpenAPI indicados acima; não é validação semântica.
|
||||
- Node 24.20.0 e npm 11.19.0 disponíveis. O comando `composer` global e `docker` não foram encontrados no PATH desta execução. Nenhuma dependência foi instalada ou actualizada.
|
||||
- Nesta Fase 0 não foi iniciada ligação a base de dados ou serviço externo. Não se inspeccionou `.env` nem se usaram credenciais de produção nesta fase. O destino da tentativa PHPUnit anterior é uma pendência de segurança operacional, não uma evidência de ambiente de teste seguro.
|
||||
|
||||
## Rollback
|
||||
|
||||
Esta fase cria apenas este documento e uma branch Git. Para reverter o artefacto, remover `docs/implementation/simulador-fiscal-governance.md`. A branch pode ser abandonada sem afectar `main`; não apagar/resetar a branch enquanto o working tree contiver alterações locais. Nenhuma migration, dado ou serviço foi alterado.
|
||||
|
||||
## Gate da Fase 0
|
||||
|
||||
**Estado proposto: APROVADO PARA AVANÇAR À FASE 1** por instrução explícita do utilizador em 2026-10-01. A decisão aprova o escopo e a continuação faseada; não aprova regras/taxas fiscais nem substitui as aprovações fiscais, jurídicas, de segurança e operacionais exigidas para publicação ou release.
|
||||
|
||||
**Próxima acção:** executar apenas a Fase 1 com inventário SQL/OpenAPI e validação em ambiente de teste isolado; parar no respectivo gate.
|
||||
@@ -0,0 +1,685 @@
|
||||
"TabelaSQL","CampoSQL","TipoPostgreSQL","Nulabilidade","EnumeracoesRestricoes","DescricaoSQL","OrigemMigration","DocumentoOpenAPI","TipoOpenAPI","FormatoOpenAPI","ClassificacaoDados","EstadoMapeamento"
|
||||
"users","id","bigint","Não","Não","Identificador primário da entidade","database/migrations/0001_01_01_000000_create_users_table.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"users","name","character varying(255)","Não","Não","name","database/migrations/0001_01_01_000000_create_users_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/name ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/name","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"users","email","character varying(255)","Não","Não","email","database/migrations/0001_01_01_000000_create_users_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Payload/properties/email","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"users","email_verified_at","timestamp(0) without time zone","Sim","Não","email verified at","database/migrations/0001_01_01_000000_create_users_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"users","password","character varying(255)","Não","Não","password","database/migrations/0001_01_01_000000_create_users_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"users","remember_token","character varying(100)","Sim","Não","remember token","database/migrations/0001_01_01_000000_create_users_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"users","created_at","timestamp(0) without time zone","Sim","Não","Instante de criação","database/migrations/0001_01_01_000000_create_users_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"users","updated_at","timestamp(0) without time zone","Sim","Não","Instante da última actualização","database/migrations/0001_01_01_000000_create_users_table.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"users","two_factor_secret","text","Sim","Não","two factor secret","database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"users","two_factor_recovery_codes","text","Sim","Não","two factor recovery codes","database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"users","two_factor_confirmed_at","timestamp(0) without time zone","Sim","Não","two factor confirmed at","database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"password_reset_tokens","email","character varying(255)","Não","Não","email","database/migrations/0001_01_01_000000_create_users_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Payload/properties/email","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"password_reset_tokens","token","character varying(255)","Não","Não","token","database/migrations/0001_01_01_000000_create_users_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"password_reset_tokens","created_at","timestamp(0) without time zone","Sim","Não","Instante de criação","database/migrations/0001_01_01_000000_create_users_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"sessions","id","character varying(255)","Não","Não","Identificador primário da entidade","database/migrations/0001_01_01_000000_create_users_table.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"sessions","user_id","bigint","Sim","Não","user id","database/migrations/0001_01_01_000000_create_users_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"sessions","ip_address","character varying(45)","Sim","Não","ip address","database/migrations/0001_01_01_000000_create_users_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"sessions","user_agent","text","Sim","Não","user agent","database/migrations/0001_01_01_000000_create_users_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"sessions","payload","text","Não","Não","Conteúdo da submissão/evento; contrato por tipo pendente","database/migrations/0001_01_01_000000_create_users_table.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/payload ; docs\investmoz\unification\openapi.json#/components/schemas/CompanyDeclaration/properties/payload ; docs\investmoz\unification\openapi.json#/components/schemas/Draft/properties/payload ; docs\investmoz\unification\openapi.json#/components/schemas/InvestorAccount/properties/payload","|object","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"sessions","last_activity","integer","Não","Não","last activity","database/migrations/0001_01_01_000000_create_users_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"cache","key","character varying(255)","Não","Não","key","database/migrations/0001_01_01_000001_create_cache_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"cache","value","text","Não","Não","value","database/migrations/0001_01_01_000001_create_cache_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"cache","expiration","bigint","Não","Não","expiration","database/migrations/0001_01_01_000001_create_cache_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"cache_locks","key","character varying(255)","Não","Não","key","database/migrations/0001_01_01_000001_create_cache_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"cache_locks","owner","character varying(255)","Não","Não","owner","database/migrations/0001_01_01_000001_create_cache_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"cache_locks","expiration","bigint","Não","Não","expiration","database/migrations/0001_01_01_000001_create_cache_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"jobs","id","bigint","Não","Não","Identificador primário da entidade","database/migrations/0001_01_01_000002_create_jobs_table.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"jobs","queue","character varying(255)","Não","Não","queue","database/migrations/0001_01_01_000002_create_jobs_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"jobs","payload","text","Não","Não","Conteúdo da submissão/evento; contrato por tipo pendente","database/migrations/0001_01_01_000002_create_jobs_table.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/payload ; docs\investmoz\unification\openapi.json#/components/schemas/CompanyDeclaration/properties/payload ; docs\investmoz\unification\openapi.json#/components/schemas/Draft/properties/payload ; docs\investmoz\unification\openapi.json#/components/schemas/InvestorAccount/properties/payload","|object","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"jobs","attempts","smallint","Não","Não","attempts","database/migrations/0001_01_01_000002_create_jobs_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"jobs","reserved_at","integer","Sim","Não","reserved at","database/migrations/0001_01_01_000002_create_jobs_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"jobs","available_at","integer","Não","Não","available at","database/migrations/0001_01_01_000002_create_jobs_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"jobs","created_at","integer","Não","Não","Instante de criação","database/migrations/0001_01_01_000002_create_jobs_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"job_batches","id","character varying(255)","Não","Não","Identificador primário da entidade","database/migrations/0001_01_01_000002_create_jobs_table.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"job_batches","name","character varying(255)","Não","Não","name","database/migrations/0001_01_01_000002_create_jobs_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/name ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/name","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"job_batches","total_jobs","integer","Não","Não","total jobs","database/migrations/0001_01_01_000002_create_jobs_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"job_batches","pending_jobs","integer","Não","Não","pending jobs","database/migrations/0001_01_01_000002_create_jobs_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"job_batches","failed_jobs","integer","Não","Não","failed jobs","database/migrations/0001_01_01_000002_create_jobs_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"job_batches","failed_job_ids","text","Não","Não","failed job ids","database/migrations/0001_01_01_000002_create_jobs_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"job_batches","options","text","Sim","Não","options","database/migrations/0001_01_01_000002_create_jobs_table.php","docs\investmoz\unification\openapi.json#/components/schemas/References/properties/options","array","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"job_batches","cancelled_at","integer","Sim","Não","cancelled at","database/migrations/0001_01_01_000002_create_jobs_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"job_batches","created_at","integer","Não","Não","Instante de criação","database/migrations/0001_01_01_000002_create_jobs_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"job_batches","finished_at","integer","Sim","Não","finished at","database/migrations/0001_01_01_000002_create_jobs_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"failed_jobs","id","bigint","Não","Não","Identificador primário da entidade","database/migrations/0001_01_01_000002_create_jobs_table.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"failed_jobs","uuid","character varying(255)","Não","Não","uuid","database/migrations/0001_01_01_000002_create_jobs_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"failed_jobs","connection","character varying(255)","Não","Não","connection","database/migrations/0001_01_01_000002_create_jobs_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"failed_jobs","queue","character varying(255)","Não","Não","queue","database/migrations/0001_01_01_000002_create_jobs_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"failed_jobs","payload","text","Não","Não","Conteúdo da submissão/evento; contrato por tipo pendente","database/migrations/0001_01_01_000002_create_jobs_table.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/payload ; docs\investmoz\unification\openapi.json#/components/schemas/CompanyDeclaration/properties/payload ; docs\investmoz\unification\openapi.json#/components/schemas/Draft/properties/payload ; docs\investmoz\unification\openapi.json#/components/schemas/InvestorAccount/properties/payload","|object","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"failed_jobs","exception","text","Não","Não","exception","database/migrations/0001_01_01_000002_create_jobs_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"failed_jobs","failed_at","timestamp(0) without time zone","Não","Não","failed at","database/migrations/0001_01_01_000002_create_jobs_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"passkeys","id","bigint","Não","Não","Identificador primário da entidade","database/migrations/2024_01_01_000000_create_passkeys_table.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"passkeys","user_id","bigint","Não","Não","user id","database/migrations/2024_01_01_000000_create_passkeys_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"passkeys","name","character varying(255)","Não","Não","name","database/migrations/2024_01_01_000000_create_passkeys_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/name ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/name","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"passkeys","credential_id","character varying(255)","Não","Não","credential id","database/migrations/2024_01_01_000000_create_passkeys_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"passkeys","credential","json","Não","Não","credential","database/migrations/2024_01_01_000000_create_passkeys_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"passkeys","last_used_at","timestamp(0) without time zone","Sim","Não","last used at","database/migrations/2024_01_01_000000_create_passkeys_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"passkeys","created_at","timestamp(0) without time zone","Sim","Não","Instante de criação","database/migrations/2024_01_01_000000_create_passkeys_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"passkeys","updated_at","timestamp(0) without time zone","Sim","Não","Instante da última actualização","database/migrations/2024_01_01_000000_create_passkeys_table.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"sectors","id","bigint","Não","Não","Identificador primário da entidade","database/migrations/2026_01_01_000001_create_sectors_table.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"sectors","name","character varying(150)","Não","Não","name","database/migrations/2026_01_01_000001_create_sectors_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/name ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/name","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"sectors","slug","character varying(180)","Não","Não","slug","database/migrations/2026_01_01_000001_create_sectors_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/slug","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"sectors","short_description","character varying(255)","Sim","Não","short description","database/migrations/2026_01_01_000001_create_sectors_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"sectors","description","text","Sim","Não","description","database/migrations/2026_01_01_000001_create_sectors_table.php","docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/description","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"sectors","icon","character varying(100)","Sim","Não","icon","database/migrations/2026_01_01_000001_create_sectors_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"sectors","sort_order","integer","Não","Não","sort order","database/migrations/2026_01_01_000001_create_sectors_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/sort_order","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"sectors","is_active","boolean","Não","Não","is active","database/migrations/2026_01_01_000001_create_sectors_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"sectors","published_at","timestamp(0) without time zone","Sim","Não","published at","database/migrations/2026_01_01_000001_create_sectors_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"sectors","created_at","timestamp(0) without time zone","Sim","Não","Instante de criação","database/migrations/2026_01_01_000001_create_sectors_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"sectors","updated_at","timestamp(0) without time zone","Sim","Não","Instante da última actualização","database/migrations/2026_01_01_000001_create_sectors_table.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"sectors","deleted_at","timestamp(0) without time zone","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_01_01_000001_create_sectors_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"opportunities","id","bigint","Não","Não","Identificador primário da entidade","database/migrations/2026_01_01_000002_create_opportunities_table.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"opportunities","sector_id","bigint","Não","Não","sector id","database/migrations/2026_01_01_000002_create_opportunities_table.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/sector_id","integer|null","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"opportunities","reference","character varying(80)","Não","Não","reference","database/migrations/2026_01_01_000002_create_opportunities_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/reference","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"opportunities","title","character varying(200)","Não","Não","title","database/migrations/2026_01_01_000002_create_opportunities_table.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/title ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/title ; docs\investmoz\zones\openapi.json#/components/schemas/LegalSource/properties/title","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"opportunities","slug","character varying(220)","Não","Não","slug","database/migrations/2026_01_01_000002_create_opportunities_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/slug","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"opportunities","summary","character varying(500)","Sim","Não","summary","database/migrations/2026_01_01_000002_create_opportunities_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/summary","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"opportunities","description","text","Sim","Não","description","database/migrations/2026_01_01_000002_create_opportunities_table.php","docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/description","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"opportunities","province","character varying(100)","Sim","Não","province","database/migrations/2026_01_01_000002_create_opportunities_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/province ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/province","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"opportunities","district","character varying(100)","Sim","Não","district","database/migrations/2026_01_01_000002_create_opportunities_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/district ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/district","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"opportunities","promoter_name","character varying(200)","Sim","Não","promoter name","database/migrations/2026_01_01_000002_create_opportunities_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"opportunities","promoter_type","character varying(100)","Sim","Não","promoter type","database/migrations/2026_01_01_000002_create_opportunities_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"opportunities","maturity_level","smallint","Sim","Não","maturity level","database/migrations/2026_01_01_000002_create_opportunities_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"opportunities","maturity_label","character varying(120)","Sim","Não","maturity label","database/migrations/2026_01_01_000002_create_opportunities_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/maturity_label","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"opportunities","estimated_investment_amount","numeric(18,2)","Sim","Não","estimated investment amount","database/migrations/2026_01_01_000002_create_opportunities_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/estimated_investment_amount","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"opportunities","estimated_investment_currency","character varying(10)","Não","Não","estimated investment currency","database/migrations/2026_01_01_000002_create_opportunities_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/estimated_investment_currency","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"opportunities","financing_modality","character varying(120)","Sim","Não","financing modality","database/migrations/2026_01_01_000002_create_opportunities_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"opportunities","expected_impact","text","Sim","Não","expected impact","database/migrations/2026_01_01_000002_create_opportunities_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"opportunities","status","character varying(50)","Não","Não","status","database/migrations/2026_01_01_000002_create_opportunities_table.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"opportunities","is_featured","boolean","Não","Não","is featured","database/migrations/2026_01_01_000002_create_opportunities_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"opportunities","published_at","timestamp(0) without time zone","Sim","Não","published at","database/migrations/2026_01_01_000002_create_opportunities_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"opportunities","last_validated_at","timestamp(0) without time zone","Sim","Não","last validated at","database/migrations/2026_01_01_000002_create_opportunities_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"opportunities","created_at","timestamp(0) without time zone","Sim","Não","Instante de criação","database/migrations/2026_01_01_000002_create_opportunities_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"opportunities","updated_at","timestamp(0) without time zone","Sim","Não","Instante da última actualização","database/migrations/2026_01_01_000002_create_opportunities_table.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"opportunities","deleted_at","timestamp(0) without time zone","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_01_01_000002_create_opportunities_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"interest_requests","id","bigint","Não","Não","Identificador primário da entidade","database/migrations/2026_01_01_000003_create_interest_requests_table.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"interest_requests","opportunity_id","bigint","Sim","Não","opportunity id","database/migrations/2026_01_01_000003_create_interest_requests_table.php","docs\investmoz\zones\openapi.json#/components/schemas/Link/properties/opportunity_id","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"interest_requests","reference","character varying(80)","Não","Não","reference","database/migrations/2026_01_01_000003_create_interest_requests_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/reference","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"interest_requests","full_name","character varying(150)","Não","Não","full name","database/migrations/2026_01_01_000003_create_interest_requests_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Payload/properties/full_name","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"interest_requests","email","character varying(150)","Não","Não","email","database/migrations/2026_01_01_000003_create_interest_requests_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Payload/properties/email","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"interest_requests","phone","character varying(30)","Sim","Não","phone","database/migrations/2026_01_01_000003_create_interest_requests_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Payload/properties/phone","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"interest_requests","investor_type","character varying(80)","Não","Não","investor type","database/migrations/2026_01_01_000003_create_interest_requests_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"interest_requests","country","character varying(100)","Não","Não","country","database/migrations/2026_01_01_000003_create_interest_requests_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Payload/properties/country","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"interest_requests","sector_interest","character varying(150)","Não","Não","sector interest","database/migrations/2026_01_01_000003_create_interest_requests_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"interest_requests","opportunity_reference","character varying(80)","Sim","Não","opportunity reference","database/migrations/2026_01_01_000003_create_interest_requests_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"interest_requests","investment_range","character varying(80)","Sim","Não","investment range","database/migrations/2026_01_01_000003_create_interest_requests_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"interest_requests","message","text","Não","Não","message","database/migrations/2026_01_01_000003_create_interest_requests_table.php","docs\investmoz\unification\openapi.json#/components/schemas/Error/properties/message","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"interest_requests","privacy_consent","boolean","Não","Não","privacy consent","database/migrations/2026_01_01_000003_create_interest_requests_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"interest_requests","consented_at","timestamp(0) without time zone","Sim","Não","consented at","database/migrations/2026_01_01_000003_create_interest_requests_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"interest_requests","status","character varying(50)","Não","Não","status","database/migrations/2026_01_01_000003_create_interest_requests_table.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"interest_requests","source_page","character varying(255)","Sim","Não","source page","database/migrations/2026_01_01_000003_create_interest_requests_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"interest_requests","ip_address","inet","Sim","Não","ip address","database/migrations/2026_01_01_000003_create_interest_requests_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"interest_requests","user_agent","text","Sim","Não","user agent","database/migrations/2026_01_01_000003_create_interest_requests_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"interest_requests","metadata","json","Sim","Não","Metadados flexíveis; schema aplicacional pendente","database/migrations/2026_01_01_000003_create_interest_requests_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"interest_requests","reviewed_at","timestamp(0) without time zone","Sim","Não","reviewed at","database/migrations/2026_01_01_000003_create_interest_requests_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"interest_requests","reviewed_by","bigint","Sim","Não","reviewed by","database/migrations/2026_01_01_000003_create_interest_requests_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"interest_requests","created_at","timestamp(0) without time zone","Sim","Não","Instante de criação","database/migrations/2026_01_01_000003_create_interest_requests_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"interest_requests","updated_at","timestamp(0) without time zone","Sim","Não","Instante da última actualização","database/migrations/2026_01_01_000003_create_interest_requests_table.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"interest_requests","deleted_at","timestamp(0) without time zone","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_01_01_000003_create_interest_requests_table.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"organizations","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_001000_investmoz_identity.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"organizations","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"organizations","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"organizations","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_001000_investmoz_identity.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"organizations","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"organizations","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_001000_investmoz_identity.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"organizations","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"organizations","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_001000_investmoz_identity.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"organizations","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"organizations","name","text","Não","Não","name","database/migrations/2026_09_24_001000_investmoz_identity.php","docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/name ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/name","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"organizations","organization_type","text","Não","organization_type IN ('public','private','ngo','education')","organization type","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"memberships","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_001000_investmoz_identity.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"memberships","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"memberships","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"memberships","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_001000_investmoz_identity.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"memberships","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"memberships","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_001000_investmoz_identity.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"memberships","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"memberships","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_001000_investmoz_identity.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"memberships","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"memberships","user_id","bigint","Não","Não","user id","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"memberships","organization_id","uuid","Não","Não","organization id","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"memberships","role","text","Não","role IN ('admin','manager','officer','viewer')","role","database/migrations/2026_09_24_001000_investmoz_identity.php","docs\investmoz\zones\openapi.json#/components/schemas/Link/properties/role ; docs\investmoz\zones\openapi.json#/components/schemas/Relation/properties/role","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"consents","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_001000_investmoz_identity.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"consents","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"consents","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"consents","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_001000_investmoz_identity.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"consents","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"consents","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_001000_investmoz_identity.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"consents","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"consents","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_001000_investmoz_identity.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"consents","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"consents","user_id","bigint","Sim","Não","user id","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"consents","subject_ref","text","Não","Não","subject ref","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"consents","purpose","text","Não","Não","purpose","database/migrations/2026_09_24_001000_investmoz_identity.php","docs\investmoz\zones\openapi.json#/components/schemas/Geometry/properties/purpose","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"consents","status","text","Não","status IN ('granted','withdrawn','expired')","status","database/migrations/2026_09_24_001000_investmoz_identity.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"consents","granted_at","timestamptz","Sim","Não","granted at","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"consents","withdrawn_at","timestamptz","Sim","Não","withdrawn at","database/migrations/2026_09_24_001000_investmoz_identity.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"documents","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_002000_investmoz_content.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"documents","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_002000_investmoz_content.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"documents","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_002000_investmoz_content.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"documents","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_002000_investmoz_content.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"documents","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_002000_investmoz_content.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"documents","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_002000_investmoz_content.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"documents","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_002000_investmoz_content.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"documents","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_002000_investmoz_content.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"documents","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_002000_investmoz_content.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"documents","file_name","text","Não","Não","file name","database/migrations/2026_09_24_002000_investmoz_content.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"documents","media_type","text","Não","Não","media type","database/migrations/2026_09_24_002000_investmoz_content.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"documents","storage_key","text","Não","Não","Chave única no armazenamento","database/migrations/2026_09_24_002000_investmoz_content.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"documents","sha256","char(64)","Não","Não","SHA-256 hexadecimal; 64 caracteres minúsculos","database/migrations/2026_09_24_002000_investmoz_content.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"documents","status","text","Não","status IN ('active','quarantined','deleted')","status","database/migrations/2026_09_24_002000_investmoz_content.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"geo_locations","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_003000_investmoz_geography.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"geo_locations","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_003000_investmoz_geography.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"geo_locations","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_003000_investmoz_geography.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"geo_locations","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_003000_investmoz_geography.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"geo_locations","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_003000_investmoz_geography.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"geo_locations","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_003000_investmoz_geography.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"geo_locations","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_003000_investmoz_geography.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"geo_locations","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_003000_investmoz_geography.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"geo_locations","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_003000_investmoz_geography.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"geo_locations","name","text","Não","Não","name","database/migrations/2026_09_24_003000_investmoz_geography.php","docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/name ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/name","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"geo_locations","location_type","text","Não","location_type IN ('country','province','district','municipality','site')","location type","database/migrations/2026_09_24_003000_investmoz_geography.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"geo_locations","parent_id","uuid","Sim","Não","parent id","database/migrations/2026_09_24_003000_investmoz_geography.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"geo_locations","code","text","Não","Não","code","database/migrations/2026_09_24_003000_investmoz_geography.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/code ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/code ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/code","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"geo_geometries","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_003000_investmoz_geography.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"geo_geometries","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_003000_investmoz_geography.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"geo_geometries","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_003000_investmoz_geography.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"geo_geometries","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_003000_investmoz_geography.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"geo_geometries","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_003000_investmoz_geography.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"geo_geometries","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_003000_investmoz_geography.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"geo_geometries","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_003000_investmoz_geography.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"geo_geometries","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_003000_investmoz_geography.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"geo_geometries","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_003000_investmoz_geography.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"geo_geometries","geo_location_id","uuid","Não","Não","geo location id","database/migrations/2026_09_24_003000_investmoz_geography.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"geo_geometries","geometry_type","text","Não","geometry_type IN ('point','line','polygon','multipolygon')","Tipo declarado coerente com GeometryType(geom)","database/migrations/2026_09_24_003000_investmoz_geography.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"geo_geometries","geom","geometry(Geometry,4326)","Não","Não","Geometria 2D WGS84 SRID 4326, válida e não vazia","database/migrations/2026_09_24_003000_investmoz_geography.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"geo_geometries","properties","jsonb","Não","Não","Propriedades geográficas; schema aplicacional pendente","database/migrations/2026_09_24_003000_investmoz_geography.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"projects","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_004000_investmoz_projects.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"projects","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_004000_investmoz_projects.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"projects","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_004000_investmoz_projects.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"projects","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_004000_investmoz_projects.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"projects","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_004000_investmoz_projects.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"projects","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_004000_investmoz_projects.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"projects","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_004000_investmoz_projects.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"projects","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_004000_investmoz_projects.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"projects","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_004000_investmoz_projects.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"projects","code","text","Não","Não","code","database/migrations/2026_09_24_004000_investmoz_projects.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/code ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/code ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/code","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"projects","name","text","Não","Não","name","database/migrations/2026_09_24_004000_investmoz_projects.php","docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/name ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/name","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"projects","status","text","Não","status IN ('draft','planned','active','suspended','completed','cancelled')","status","database/migrations/2026_09_24_004000_investmoz_projects.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"projects","description","text","Sim","Não","description","database/migrations/2026_09_24_004000_investmoz_projects.php","docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/description","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"projects","budget_amount","numeric(20,2)","Sim","Não","Orçamento exacto não negativo","database/migrations/2026_09_24_004000_investmoz_projects.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"projects","budget_currency","char(3)","Sim","Não","Código monetário do orçamento; lista ISO autorizada pendente","database/migrations/2026_09_24_004000_investmoz_projects.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"projects","start_date","date","Sim","Não","start date","database/migrations/2026_09_24_004000_investmoz_projects.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"projects","end_date","date","Sim","Não","end date","database/migrations/2026_09_24_004000_investmoz_projects.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandInput/properties/end_date ; docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandPatch/properties/end_date","string|null","date","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"projects","metadata","jsonb","Não","Não","Metadados flexíveis; schema aplicacional pendente","database/migrations/2026_09_24_004000_investmoz_projects.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"project_locations","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_004000_investmoz_projects.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"project_locations","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_004000_investmoz_projects.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"project_locations","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_004000_investmoz_projects.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"project_locations","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_004000_investmoz_projects.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"project_locations","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_004000_investmoz_projects.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"project_locations","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_004000_investmoz_projects.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"project_locations","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_004000_investmoz_projects.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"project_locations","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_004000_investmoz_projects.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"project_locations","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_004000_investmoz_projects.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"project_locations","project_id","uuid","Não","Não","project id","database/migrations/2026_09_24_004000_investmoz_projects.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandInput/properties/project_id ; docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandPatch/properties/project_id ; docs\investmoz\zones\openapi.json#/components/schemas/Relation/properties/project_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"project_locations","geo_location_id","uuid","Não","Não","geo location id","database/migrations/2026_09_24_004000_investmoz_projects.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"project_locations","role","text","Não","role IN ('primary','secondary','impact')","role","database/migrations/2026_09_24_004000_investmoz_projects.php","docs\investmoz\zones\openapi.json#/components/schemas/Link/properties/role ; docs\investmoz\zones\openapi.json#/components/schemas/Relation/properties/role","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"economic_clusters","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"economic_clusters","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"economic_clusters","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"economic_clusters","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"economic_clusters","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"economic_clusters","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"economic_clusters","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"economic_clusters","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"economic_clusters","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"economic_clusters","code","text","Não","Não","code","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/code ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/code ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/code","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"economic_clusters","name","text","Não","Não","name","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/name ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/name","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"economic_clusters","sector","text","Não","Não","sector","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/sector ; docs\investmoz\unification\openapi.json#/components/schemas/Payload/properties/sector","object|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"economic_clusters","status","text","Não","status IN ('planned','active','inactive')","status","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"economic_clusters","metadata","jsonb","Não","Não","Metadados flexíveis; schema aplicacional pendente","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"value_chains","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"value_chains","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"value_chains","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"value_chains","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"value_chains","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"value_chains","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"value_chains","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"value_chains","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"value_chains","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"value_chains","cluster_id","uuid","Não","Não","cluster id","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandInput/properties/cluster_id ; docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandPatch/properties/cluster_id ; docs\investmoz\zones\openapi.json#/components/schemas/Relation/properties/cluster_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"value_chains","code","text","Não","Não","code","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/code ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/code ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/code","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"value_chains","name","text","Não","Não","name","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/name ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/name","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"value_chains","stage","text","Não","stage IN ('input','production','processing','logistics','market')","stage","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"value_chain_links","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"value_chain_links","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"value_chain_links","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"value_chain_links","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"value_chain_links","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"value_chain_links","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"value_chain_links","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"value_chain_links","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"value_chain_links","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"value_chain_links","from_value_chain_id","uuid","Não","Não","from value chain id","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"value_chain_links","to_value_chain_id","uuid","Não","Não","to value chain id","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"value_chain_links","link_type","text","Não","link_type IN ('supplies','processes','distributes','supports')","link type","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"cluster_projects","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"cluster_projects","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"cluster_projects","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"cluster_projects","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"cluster_projects","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"cluster_projects","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"cluster_projects","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"cluster_projects","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"cluster_projects","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"cluster_projects","cluster_id","uuid","Não","Não","cluster id","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandInput/properties/cluster_id ; docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandPatch/properties/cluster_id ; docs\investmoz\zones\openapi.json#/components/schemas/Relation/properties/cluster_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"cluster_projects","project_id","uuid","Não","Não","project id","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandInput/properties/project_id ; docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandPatch/properties/project_id ; docs\investmoz\zones\openapi.json#/components/schemas/Relation/properties/project_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"cluster_projects","role","text","Não","role IN ('anchor','complementary','supporting')","role","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\zones\openapi.json#/components/schemas/Link/properties/role ; docs\investmoz\zones\openapi.json#/components/schemas/Relation/properties/role","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"anchor_project_links","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"anchor_project_links","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"anchor_project_links","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"anchor_project_links","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"anchor_project_links","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"anchor_project_links","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"anchor_project_links","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"anchor_project_links","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"anchor_project_links","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"anchor_project_links","anchor_project_id","uuid","Não","Não","anchor project id","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"anchor_project_links","cluster_id","uuid","Não","Não","cluster id","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandInput/properties/cluster_id ; docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandPatch/properties/cluster_id ; docs\investmoz\zones\openapi.json#/components/schemas/Relation/properties/cluster_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"anchor_project_links","rationale","text","Não","Não","rationale","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"complementary_project_links","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"complementary_project_links","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"complementary_project_links","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"complementary_project_links","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"complementary_project_links","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"complementary_project_links","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"complementary_project_links","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"complementary_project_links","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_005000_investmoz_clusters.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"complementary_project_links","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"complementary_project_links","anchor_project_id","uuid","Não","Não","anchor project id","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"complementary_project_links","complementary_project_id","uuid","Não","Não","complementary project id","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"complementary_project_links","relation_type","text","Não","relation_type IN ('dependency','enabler','supplier','market')","relation type","database/migrations/2026_09_24_005000_investmoz_clusters.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"development_corridors","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"development_corridors","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"development_corridors","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"development_corridors","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"development_corridors","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"development_corridors","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"development_corridors","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"development_corridors","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"development_corridors","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"development_corridors","code","text","Não","Não","code","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/code ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/code ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/code","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"development_corridors","name","text","Não","Não","name","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/name ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/name","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"development_corridors","status","text","Não","status IN ('proposed','active','closed')","status","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"development_corridors","description","text","Sim","Não","description","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/description","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"corridor_locations","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"corridor_locations","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"corridor_locations","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"corridor_locations","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"corridor_locations","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"corridor_locations","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"corridor_locations","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"corridor_locations","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"corridor_locations","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"corridor_locations","corridor_id","uuid","Não","Não","corridor id","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandInput/properties/corridor_id ; docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandPatch/properties/corridor_id ; docs\investmoz\zones\openapi.json#/components/schemas/Relation/properties/corridor_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"corridor_locations","geo_location_id","uuid","Não","Não","geo location id","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"corridor_locations","sequence_no","integer","Não","Não","sequence no","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"corridor_projects","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"corridor_projects","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"corridor_projects","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"corridor_projects","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"corridor_projects","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"corridor_projects","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"corridor_projects","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"corridor_projects","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"corridor_projects","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"corridor_projects","corridor_id","uuid","Não","Não","corridor id","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandInput/properties/corridor_id ; docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandPatch/properties/corridor_id ; docs\investmoz\zones\openapi.json#/components/schemas/Relation/properties/corridor_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"corridor_projects","project_id","uuid","Não","Não","project id","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandInput/properties/project_id ; docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandPatch/properties/project_id ; docs\investmoz\zones\openapi.json#/components/schemas/Relation/properties/project_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"corridor_clusters","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"corridor_clusters","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"corridor_clusters","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"corridor_clusters","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"corridor_clusters","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"corridor_clusters","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"corridor_clusters","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"corridor_clusters","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"corridor_clusters","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_006000_investmoz_corridors.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"corridor_clusters","corridor_id","uuid","Não","Não","corridor id","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandInput/properties/corridor_id ; docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandPatch/properties/corridor_id ; docs\investmoz\zones\openapi.json#/components/schemas/Relation/properties/corridor_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"corridor_clusters","cluster_id","uuid","Não","Não","cluster id","database/migrations/2026_09_24_006000_investmoz_corridors.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandInput/properties/cluster_id ; docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandPatch/properties/cluster_id ; docs\investmoz\zones\openapi.json#/components/schemas/Relation/properties/cluster_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"investors","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"investors","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"investors","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"investors","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"investors","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"investors","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"investors","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"investors","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"investors","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"investors","organization_id","uuid","Sim","Não","organization id","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"investors","legal_name","text","Não","Não","legal name","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"investors","investor_type","text","Não","investor_type IN ('domestic','foreign','institutional','individual')","investor type","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"investors","status","text","Não","status IN ('prospect','active','inactive')","status","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"contacts","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"contacts","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"contacts","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"contacts","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"contacts","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"contacts","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"contacts","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"contacts","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"contacts","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"contacts","full_name","text","Não","Não","full name","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\unification\openapi.json#/components/schemas/Payload/properties/full_name","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"contacts","email","text","Sim","Não","email","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\unification\openapi.json#/components/schemas/Payload/properties/email","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"contacts","phone","text","Sim","Não","phone","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\unification\openapi.json#/components/schemas/Payload/properties/phone","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"investor_contacts","investor_id","uuid","Não","Não","investor id","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"investor_contacts","contact_id","uuid","Não","Não","contact id","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"investor_contacts","is_primary","boolean","Não","Não","Contacto principal deste investidor; limite quantitativo pendente","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\zones\openapi.json#/components/schemas/Link/properties/is_primary ; docs\investmoz\zones\openapi.json#/components/schemas/Relation/properties/is_primary","boolean","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"investor_contacts","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"investor_contacts","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"leads","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"leads","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"leads","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"leads","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"leads","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"leads","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"leads","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"leads","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"leads","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"leads","investor_id","uuid","Sim","Não","investor id","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"leads","owner_user_id","bigint","Sim","Não","owner user id","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"leads","title","text","Não","Não","title","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/title ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/title ; docs\investmoz\zones\openapi.json#/components/schemas/LegalSource/properties/title","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"leads","status","text","Não","status IN ('new','qualified','disqualified','converted')","status","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"leads","estimated_amount","numeric(20,2)","Sim","Não","Montante estimado exacto não negativo","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\unification\openapi.json#/components/schemas/Payload/properties/estimated_amount","number","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"leads","currency","char(3)","Sim","Não","Código monetário; três letras maiúsculas; lista ISO autorizada pendente","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\unification\openapi.json#/components/schemas/Payload/properties/currency","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"crm_opportunities","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"crm_opportunities","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"crm_opportunities","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"crm_opportunities","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"crm_opportunities","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"crm_opportunities","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"crm_opportunities","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"crm_opportunities","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"crm_opportunities","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"crm_opportunities","lead_id","uuid","Sim","Não","lead id","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"crm_opportunities","project_id","uuid","Sim","Não","project id","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandInput/properties/project_id ; docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandPatch/properties/project_id ; docs\investmoz\zones\openapi.json#/components/schemas/Relation/properties/project_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"crm_opportunities","owner_user_id","bigint","Sim","Não","owner user id","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"crm_opportunities","stage","text","Não","stage IN ('discovery','assessment','negotiation','approved','lost')","stage","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"crm_opportunities","amount","numeric(20,2)","Sim","Não","Montante monetário exacto não negativo","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"crm_opportunities","currency","char(3)","Sim","Não","Código monetário; três letras maiúsculas; lista ISO autorizada pendente","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\unification\openapi.json#/components/schemas/Payload/properties/currency","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"crm_opportunities","probability","numeric(5,2)","Sim","Não","probability","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"activities","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"activities","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"activities","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"activities","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"activities","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"activities","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"activities","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"activities","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"activities","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"activities","investor_id","uuid","Sim","Não","investor id","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"activities","opportunity_id","uuid","Sim","Não","opportunity id","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\zones\openapi.json#/components/schemas/Link/properties/opportunity_id","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"activities","actor_user_id","bigint","Sim","Não","actor user id","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"activities","activity_type","text","Não","activity_type IN ('call','meeting','email','note')","activity type","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"activities","occurred_at","timestamptz","Não","Não","occurred at","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"activities","summary","text","Não","Não","summary","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/summary","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"tasks","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"tasks","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"tasks","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"tasks","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"tasks","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"tasks","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"tasks","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"tasks","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"tasks","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"tasks","assignee_user_id","bigint","Sim","Não","assignee user id","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"tasks","opportunity_id","uuid","Sim","Não","opportunity id","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\zones\openapi.json#/components/schemas/Link/properties/opportunity_id","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"tasks","title","text","Não","Não","title","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/title ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/title ; docs\investmoz\zones\openapi.json#/components/schemas/LegalSource/properties/title","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"tasks","status","text","Não","status IN ('open','in_progress','done','cancelled')","status","database/migrations/2026_09_24_007000_investmoz_crm.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"tasks","due_at","timestamptz","Sim","Não","due at","database/migrations/2026_09_24_007000_investmoz_crm.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"investor_journeys","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"investor_journeys","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"investor_journeys","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"investor_journeys","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"investor_journeys","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"investor_journeys","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"investor_journeys","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"investor_journeys","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"investor_journeys","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"investor_journeys","investor_id","uuid","Não","Não","investor id","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"investor_journeys","opportunity_id","uuid","Sim","Não","opportunity id","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\zones\openapi.json#/components/schemas/Link/properties/opportunity_id","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"investor_journeys","status","text","Não","status IN ('started','active','completed','cancelled')","status","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"investor_journeys","started_at","timestamptz","Não","Não","started at","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"journey_steps","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"journey_steps","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"journey_steps","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"journey_steps","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"journey_steps","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"journey_steps","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"journey_steps","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"journey_steps","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"journey_steps","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"journey_steps","journey_id","uuid","Não","Não","journey id","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"journey_steps","step_code","text","Não","Não","step code","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"journey_steps","sequence_no","integer","Não","Não","sequence no","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"journey_steps","status","text","Não","status IN ('pending','active','completed','blocked')","status","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"submissions","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"submissions","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"submissions","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"submissions","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"submissions","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"submissions","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"submissions","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"submissions","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"submissions","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"submissions","journey_step_id","uuid","Não","Não","journey step id","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"submissions","submitted_by","bigint","Sim","Não","submitted by","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"submissions","status","text","Não","status IN ('draft','submitted','accepted','rejected')","status","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"submissions","payload","jsonb","Não","Não","Conteúdo da submissão/evento; contrato por tipo pendente","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/payload ; docs\investmoz\unification\openapi.json#/components/schemas/CompanyDeclaration/properties/payload ; docs\investmoz\unification\openapi.json#/components/schemas/Draft/properties/payload ; docs\investmoz\unification\openapi.json#/components/schemas/InvestorAccount/properties/payload","|object","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"submissions","submitted_at","timestamptz","Sim","Não","submitted at","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"aftercare_plans","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"aftercare_plans","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"aftercare_plans","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"aftercare_plans","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"aftercare_plans","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"aftercare_plans","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"aftercare_plans","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"aftercare_plans","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"aftercare_plans","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"aftercare_plans","investor_id","uuid","Não","Não","investor id","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"aftercare_plans","project_id","uuid","Sim","Não","project id","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandInput/properties/project_id ; docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandPatch/properties/project_id ; docs\investmoz\zones\openapi.json#/components/schemas/Relation/properties/project_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"aftercare_plans","owner_user_id","bigint","Sim","Não","owner user id","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"aftercare_plans","status","text","Não","status IN ('draft','active','closed')","status","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"aftercare_plans","review_date","date","Sim","Não","review date","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"constraints","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"constraints","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"constraints","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"constraints","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"constraints","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"constraints","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"constraints","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"constraints","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"constraints","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"constraints","aftercare_plan_id","uuid","Não","Não","aftercare plan id","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"constraints","category","text","Não","Não","category","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/category","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"constraints","description","text","Não","Não","description","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/description","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"constraints","severity","text","Não","severity IN ('low','medium','high','critical')","severity","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"constraints","status","text","Não","status IN ('open','mitigating','resolved')","status","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"escalations","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"escalations","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"escalations","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"escalations","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"escalations","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"escalations","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"escalations","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"escalations","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"escalations","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"escalations","constraint_id","uuid","Não","Não","constraint id","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"escalations","escalated_by","bigint","Sim","Não","escalated by","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"escalations","level","text","Não","level IN ('operational','management','executive')","level","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"escalations","status","text","Não","status IN ('open','acknowledged','closed')","status","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"escalations","escalated_at","timestamptz","Não","Não","escalated at","database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"skills","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"skills","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"skills","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"skills","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"skills","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"skills","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"skills","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"skills","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"skills","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"skills","code","text","Não","Não","code","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/code ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/code ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/code","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"skills","name","text","Não","Não","name","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/name ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/name","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"occupations","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"occupations","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"occupations","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"occupations","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"occupations","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"occupations","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"occupations","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"occupations","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"occupations","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"occupations","code","text","Não","Não","code","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/code ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/code ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/code","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"occupations","name","text","Não","Não","name","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/name ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/name","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"training_needs","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"training_needs","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"training_needs","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"training_needs","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"training_needs","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"training_needs","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"training_needs","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"training_needs","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"training_needs","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"training_needs","project_id","uuid","Sim","Não","project id","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandInput/properties/project_id ; docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandPatch/properties/project_id ; docs\investmoz\zones\openapi.json#/components/schemas/Relation/properties/project_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"training_needs","occupation_id","uuid","Sim","Não","occupation id","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandInput/properties/occupation_id ; docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandPatch/properties/occupation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"training_needs","skill_id","uuid","Sim","Não","skill id","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"training_needs","quantity","integer","Não","Não","quantity","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandInput/properties/quantity ; docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandPatch/properties/quantity ; docs\investmoz\unification\openapi.json#/components/schemas/Need/properties/quantity","integer|number","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"training_needs","priority","text","Não","priority IN ('Must','Should','Could')","priority","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\zones\openapi.json#/components/schemas/Link/properties/priority ; docs\investmoz\zones\openapi.json#/components/schemas/Relation/properties/priority","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"training_providers","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"training_providers","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"training_providers","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"training_providers","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"training_providers","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"training_providers","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"training_providers","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"training_providers","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"training_providers","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"training_providers","organization_id","uuid","Sim","Não","organization id","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"training_providers","name","text","Não","Não","name","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/name ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/name","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"training_providers","status","text","Não","status IN ('candidate','approved','inactive')","status","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"courses","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"courses","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"courses","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"courses","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"courses","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"courses","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"courses","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"courses","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"courses","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"courses","provider_id","uuid","Não","Não","provider id","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"courses","code","text","Não","Não","code","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/code ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/code ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/code","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"courses","title","text","Não","Não","title","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/title ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/title ; docs\investmoz\zones\openapi.json#/components/schemas/LegalSource/properties/title","string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"courses","status","text","Não","status IN ('draft','published','retired')","status","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"education_opportunities","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"education_opportunities","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"education_opportunities","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"education_opportunities","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"education_opportunities","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"education_opportunities","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"education_opportunities","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"education_opportunities","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"education_opportunities","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"education_opportunities","course_id","uuid","Não","Não","course id","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"education_opportunities","geo_location_id","uuid","Sim","Não","geo location id","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"education_opportunities","start_date","date","Não","Não","start date","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"education_opportunities","end_date","date","Sim","Não","end date","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandInput/properties/end_date ; docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandPatch/properties/end_date","string|null","date","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"education_opportunities","capacity","integer","Sim","Não","capacity","database/migrations/2026_09_24_009000_investmoz_education.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"education_opportunities","status","text","Não","status IN ('planned','open','closed','cancelled')","status","database/migrations/2026_09_24_009000_investmoz_education.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"project_documents","project_id","uuid","Não","Não","project id","database/migrations/2026_09_24_009500_investmoz_document_links.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandInput/properties/project_id ; docs\investmoz\education\openapi.proposed.json#/components/schemas/DemandPatch/properties/project_id ; docs\investmoz\zones\openapi.json#/components/schemas/Relation/properties/project_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"project_documents","document_id","uuid","Não","Não","document id","database/migrations/2026_09_24_009500_investmoz_document_links.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"project_documents","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_009500_investmoz_document_links.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"investor_documents","investor_id","uuid","Não","Não","investor id","database/migrations/2026_09_24_009500_investmoz_document_links.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"investor_documents","document_id","uuid","Não","Não","document id","database/migrations/2026_09_24_009500_investmoz_document_links.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"investor_documents","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_009500_investmoz_document_links.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"journey_documents","journey_id","uuid","Não","Não","journey id","database/migrations/2026_09_24_009500_investmoz_document_links.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"journey_documents","document_id","uuid","Não","Não","document id","database/migrations/2026_09_24_009500_investmoz_document_links.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"journey_documents","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_009500_investmoz_document_links.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"aftercare_documents","aftercare_plan_id","uuid","Não","Não","aftercare plan id","database/migrations/2026_09_24_009500_investmoz_document_links.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"aftercare_documents","document_id","uuid","Não","Não","document id","database/migrations/2026_09_24_009500_investmoz_document_links.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"aftercare_documents","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_009500_investmoz_document_links.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"education_documents","education_opportunity_id","uuid","Não","Não","education opportunity id","database/migrations/2026_09_24_009500_investmoz_document_links.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"education_documents","document_id","uuid","Não","Não","document id","database/migrations/2026_09_24_009500_investmoz_document_links.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"education_documents","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_009500_investmoz_document_links.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"audit_events","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_010000_investmoz_governance_ai.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"audit_events","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_010000_investmoz_governance_ai.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"audit_events","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_010000_investmoz_governance_ai.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"audit_events","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_010000_investmoz_governance_ai.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"audit_events","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_010000_investmoz_governance_ai.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"audit_events","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_010000_investmoz_governance_ai.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"audit_events","actor_user_id","bigint","Sim","Não","actor user id","database/migrations/2026_09_24_010000_investmoz_governance_ai.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"audit_events","entity_type","text","Não","Não","entity type","database/migrations/2026_09_24_010000_investmoz_governance_ai.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"audit_events","entity_id","uuid","Sim","Não","ID histórico UUID sem FK; a entidade pode já não existir","database/migrations/2026_09_24_010000_investmoz_governance_ai.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"audit_events","action","text","Não","Não","action","database/migrations/2026_09_24_010000_investmoz_governance_ai.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"audit_events","occurred_at","timestamptz","Não","Não","occurred at","database/migrations/2026_09_24_010000_investmoz_governance_ai.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"audit_events","before_data","jsonb","Sim","Não","Representação anterior; aplicação deve remover segredos","database/migrations/2026_09_24_010000_investmoz_governance_ai.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"audit_events","after_data","jsonb","Sim","Não","Representação posterior; aplicação deve remover segredos","database/migrations/2026_09_24_010000_investmoz_governance_ai.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"outbox_events","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"outbox_events","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"outbox_events","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"outbox_events","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"outbox_events","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"outbox_events","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"outbox_events","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"outbox_events","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"outbox_events","aggregate_type","text","Não","Não","aggregate type","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"outbox_events","aggregate_id","uuid","Não","Não","UUID do agregado produtor; sem FK polimórfica","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"outbox_events","event_type","text","Não","Não","event type","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"outbox_events","payload","jsonb","Não","Não","Conteúdo da submissão/evento; contrato por tipo pendente","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/payload ; docs\investmoz\unification\openapi.json#/components/schemas/CompanyDeclaration/properties/payload ; docs\investmoz\unification\openapi.json#/components/schemas/Draft/properties/payload ; docs\investmoz\unification\openapi.json#/components/schemas/InvestorAccount/properties/payload","|object","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"outbox_events","status","text","Não","status IN ('pending','published','failed')","status","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"outbox_events","available_at","timestamptz","Não","Não","available at","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"outbox_events","published_at","timestamptz","Sim","Não","published at","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"inbox_events","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"inbox_events","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"inbox_events","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"inbox_events","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"inbox_events","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"inbox_events","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"inbox_events","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"inbox_events","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"inbox_events","message_id","text","Não","Não","message id","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"inbox_events","event_type","text","Não","Não","event type","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"inbox_events","payload","jsonb","Não","Não","Conteúdo da submissão/evento; contrato por tipo pendente","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/payload ; docs\investmoz\unification\openapi.json#/components/schemas/CompanyDeclaration/properties/payload ; docs\investmoz\unification\openapi.json#/components/schemas/Draft/properties/payload ; docs\investmoz\unification\openapi.json#/components/schemas/InvestorAccount/properties/payload","|object","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"inbox_events","status","text","Não","status IN ('received','processed','failed')","status","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"inbox_events","received_at","timestamptz","Não","Não","received at","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"inbox_events","processed_at","timestamptz","Sim","Não","processed at","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"sync_runs","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"sync_runs","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"sync_runs","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"sync_runs","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"sync_runs","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"sync_runs","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"sync_runs","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"sync_runs","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"sync_runs","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"sync_runs","integration_name","text","Não","Não","integration name","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"sync_runs","direction","text","Não","direction IN ('inbound','outbound')","direction","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"sync_runs","status","text","Não","status IN ('running','succeeded','failed','cancelled')","status","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"sync_runs","started_at","timestamptz","Não","Não","started at","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"sync_runs","ended_at","timestamptz","Sim","Não","ended at","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"sync_runs","totals","jsonb","Não","Não","totals","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"reconciliation_issues","id","uuid","Não","Não","Identificador primário da entidade","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/id ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Entity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/id ; docs\investmoz\unification\openapi.json#/components/schemas/Option/properties/id","integer|string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"reconciliation_issues","source_system","text","Não","Não","Sistema de origem; integra a chave estável de importação","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"reconciliation_issues","external_id","text","Sim","Não","Identificador na origem; NULL quando não fornecido","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"reconciliation_issues","version_no","integer","Não","Não","Versão positiva; comparação compete ao importador","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Version/properties/version_no ; docs\investmoz\zones\openapi.json#/components/schemas/Zone/properties/version_no","integer","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"reconciliation_issues","data_classification","text","Não","data_classification IN ('public','internal','confidential','restricted')","Classificação de acesso; não substitui autorização","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"reconciliation_issues","correlation_id","uuid","Sim","Não","Correlação entre operações; não é FK","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/correlation_id","string","uuid","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"reconciliation_issues","created_at","timestamptz","Não","Não","Instante de criação","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"reconciliation_issues","updated_at","timestamptz","Não","Não","Instante da última actualização","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Demand/properties/updated_at","string","date-time","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"reconciliation_issues","deleted_at","timestamptz","Sim","Não","Eliminação lógica; NULL indica activo","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"reconciliation_issues","sync_run_id","uuid","Não","Não","sync run id","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"reconciliation_issues","entity_type","text","Não","Não","entity type","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"reconciliation_issues","external_id_ref","text","Sim","Não","external id ref","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"reconciliation_issues","issue_type","text","Não","issue_type IN ('missing','duplicate','orphan','checksum','geometry','value')","issue type","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"reconciliation_issues","severity","text","Não","severity IN ('warning','error')","severity","database/migrations/2026_09_24_011000_investmoz_integrations.php","","","","NAO_REGISTADA_NO_DICIONARIO","SEM_CAMPO_OPENAPI_DOCUMENTADO"
|
||||
"reconciliation_issues","status","text","Não","status IN ('open','accepted','resolved')","status","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\education\openapi.proposed.json#/components/schemas/Problem/properties/status ; docs\investmoz\existing-business\openapi.json#/components/schemas/Business/properties/status ; docs\investmoz\unification\openapi.json#/components/schemas/Opportunity/properties/status","|integer|string","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
"reconciliation_issues","details","jsonb","Não","Não","details","database/migrations/2026_09_24_011000_investmoz_integrations.php","docs\investmoz\unification\openapi.json#/components/schemas/Need/properties/details","object","","NAO_REGISTADA_NO_DICIONARIO","NOME_CORRESPONDENTE_POR_CONFIRMAR_SEMANTICA"
|
||||
|
@@ -0,0 +1,52 @@
|
||||
# Progresso do mercado de negócios — 30/09/2026
|
||||
|
||||
## Implementado nesta execução
|
||||
|
||||
- Extensão do modelo existente `existing_businesses` e `interest_requests`; não foi criada uma segunda entidade de negócio.
|
||||
- Fluxo de triagem, revisão, aprovação de nível 1, aprovação de nível 2 e publicação, com políticas por email designado, conta verificada, permissão de gestão e MFA. A aprovação de nível 1, nível 2 e publicação exigem actores distintos.
|
||||
- Catálogo público servido por projecção de campos permitidos. A ficha pública não serializa payload privado, proponente, documentos nem geometria exacta.
|
||||
- Registo público de manifestação de interesse com idempotência, consentimento registado e deduplicação de pedidos activos para o mesmo negócio, email e modalidade.
|
||||
- Ciclo de estados de interesse validado pelo serviço `InterestWorkflow`. Cada mudança efectiva guarda actor, estados anterior/seguinte, nota cifrada e correlação no histórico; a ficha de gestão apresenta histórico e próxima acção/prazo.
|
||||
- Configuração de designações documentada em `.env.example`. Listas vazias não concedem acesso.
|
||||
|
||||
## Limites ainda activos
|
||||
|
||||
- O fluxo de transição do interesse e a ficha de gestão estão implementados, mas a atribuição a responsáveis ainda não tem interface de gestão.
|
||||
- O prazo é guardado, mas não há job de SLA, alertas de atraso ou escalonamento automático.
|
||||
- A outbox recebe eventos de submissão. O worker CRM para este novo fluxo, reconciliação e dead-letter ainda precisam de implementação e homologação; nenhum evento foi enviado para um serviço externo nesta execução.
|
||||
- Os anexos ainda não têm upload privado com antivírus, retenção automatizada ou sala segura. Não tratar referências documentais declaradas como ficheiros validados.
|
||||
- Consentimento de contacto obrigatório é registado no pedido. Consentimentos separados para partilha com o proponente/terceiros e respectivos fluxos de revogação continuam pendentes.
|
||||
- A disponibilização das rotas públicas não constitui autorização institucional para publicar oportunidades reais. Confirmar designações e conteúdo público antes de activar em produção.
|
||||
|
||||
## Evidência reproduzida
|
||||
|
||||
- `php artisan test --filter='PortalManagementTest|InvestmentWorkspaceTest|BusinessMarketplacePublicTest|ExistingBusinessIntakeTest'`: 20 testes, 301 verificações aprovadas, usando exclusivamente `portal_services_test` em `127.0.0.1:55441`.
|
||||
- `npm.cmd run types:check`: TypeScript sem erros.
|
||||
- Laravel Pint `--test` nos PHP alterados: aprovado.
|
||||
- PHP 8.5.11 explícito: lint sem erros nos novos serviços/controladores/teste.
|
||||
|
||||
Todos os comandos PHP desta execução usaram `C:\Users\macbook\.config\investmoz\php-8.5.11\php.exe`.
|
||||
|
||||
## Continuação — tarefas operacionais
|
||||
|
||||
Esta actualização substitui a limitação anterior sobre atribuição: a ficha de gestão permite agora seleccionar um responsável entre gestores verificados incluídos nas listas de designação configuradas. A fila apresenta o responsável e o prazo.
|
||||
|
||||
Quando a próxima acção e o prazo são definidos, é criada uma tarefa privada em `tasks`, ligada à manifestação, com responsável, classificação confidencial e data limite no fim do dia local. A chave externa determinística impede duplicados ao guardar novamente a mesma acção. Uma acção alterada ou removida encerra a tarefa aberta anterior como `cancelled`/`superseded`, preservando o registo.
|
||||
|
||||
Validação adicional: mesmos 20 testes focados, agora com 323 verificações; TypeScript sem erros. Os prazos são acompanhados na fila e nas tarefas; não há ainda notificações automáticas nem escalonamento de SLA.
|
||||
|
||||
A fila de gestão mostra a acção, o responsável e o prazo; tarefas vencidas são destacadas visualmente. Actualizar apenas o estado preserva a atribuição e a próxima acção existentes. Guardar a mesma acção novamente não duplica a tarefa.
|
||||
|
||||
A fila também oferece filtros para prazos vencidos, prazos futuros e pedidos sem responsável. O filtro de prazo exclui estados terminais.
|
||||
|
||||
## Actualização — consentimentos separados
|
||||
|
||||
O formulário regista separadamente o consentimento obrigatório para responder ao pedido (`respond_to_interest`) e a autorização opcional para partilhar contacto com o proponente (`share_contact_with_proponent`). Cada registo conserva versão da política, data, referência do pedido e classificação confidencial. A opção de partilha começa desmarcada e não condiciona a submissão.
|
||||
|
||||
A gestão pode revogar a autorização de partilha com motivo obrigatório. A revogação actualiza o registo de consentimento, guarda o motivo cifrado no histórico e cria evento de outbox sem email, telefone ou texto da mensagem. Este procedimento administrativo implementa revogação registada; ainda falta um fluxo de auto-serviço para o titular.
|
||||
|
||||
Validação desta continuação: 29 testes focados / 381 verificações aprovadas; TypeScript e build aprovados; Pint aprovado nos ficheiros PHP alterados. O build continua a emitir apenas o aviso de dependência opcional `fontaine`.
|
||||
|
||||
## Escalada de tarefas vencidas
|
||||
|
||||
O comando `investmoz:escalate-overdue-tasks` eleva para prioridade 1 as tarefas `triage` e `interest_follow_up` vencidas que ainda estejam abertas/em curso, marcando `outcome=sla_overdue`. O scheduler agenda a verificação a cada 15 minutos. A função vem desactivada; habilitar `INVESTMOZ_SLA_ESCALATION_ENABLED=true` apenas quando o scheduler Laravel estiver operacional. A escalada não envia email nem integrações externas.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Integração do modelo de negócios
|
||||
|
||||
O SQL recebido foi convertido numa migração incremental para PostgreSQL/PostGIS. `existing_businesses` continua a ser a entidade canónica dos anúncios de negócios; não é criado outro catálogo de oportunidades. `interest_requests` continua a guardar manifestações de interesse já recebidas.
|
||||
|
||||
## Correspondências
|
||||
|
||||
| SQL recebido | Modelo actual |
|
||||
| --- | --- |
|
||||
| `business.parties` | Contas autenticadas em `users`; proponentes institucionais em `organizations`; perfis de investimento em `investors` e `investor_accounts`. Não é criada uma segunda tabela de identidade. |
|
||||
| `business.opportunities` | `existing_businesses`: `tracking_code` usa `code`; proponente/criador usa `owner_user_id`; proprietário institucional usa `organization_id`; sector usa `sector_id`; versão usa `version_no`. |
|
||||
| `business.opportunity_locations` | Nova tabela de relações `existing_business_locations`, ligada a `geo_locations` por UUID e com validação da hierarquia e geometria PostGIS. |
|
||||
| `business.assets` | Campos tipados adicionais em `existing_business_objects`; o `payload` cifrado actual continua a guardar pormenores privados. |
|
||||
| `business.rights` | Nova tabela `existing_business_rights`, ligada ao negócio e, quando aplicável, ao objecto. O detalhe reservado fica em `confidential_payload`, que os serviços devem cifrar. |
|
||||
| `business.documents` | `documents` actual (`file_name`, `media_type`, `storage_key`, `sha256`, `status` e `data_classification`), com metadados adicionais; nova tabela de ligação ao negócio ou ao pedido de interesse. |
|
||||
| `business.investor_profiles` | Campos de perfil em `investors`, cuja ligação à conta autenticada já existe em `investor_accounts`. |
|
||||
| `business.interests` | `interest_requests`; acrescenta alvo `existing_business_id`, perfil do investidor, modalidade, montantes e atribuição. `opportunity_id` continua a referir-se ao catálogo público de investimentos, e uma submissão só pode indicar um desses dois alvos. |
|
||||
| `business.consents` | `consents` actual, com alvo opcional e versão da política; os estados existentes `granted`/`withdrawn` mantêm-se como fonte canónica. |
|
||||
| `business.workflow_transitions` | Histórico actual de revisões/reviews de negócios mais `business_workflow_transitions` para transições de negócios e pedidos de interesse. |
|
||||
| `business.tasks` / `business.outbox_messages` | `tasks` e `outbox_events` existentes recebem campos adicionais; não são criadas filas paralelas. |
|
||||
| `business.crm_links` | Nova tabela `external_record_links` para mapear identificadores remotos sem duplicar os registos de CRM. |
|
||||
|
||||
Campos confidenciais já protegidos no `payload` cifrado não são copiados para colunas em texto aberto. O campo `public_title` fica separado para publicação futura; esta migração não publica negócios nem muda a máquina de estados da aplicação.
|
||||
|
||||
## Migração e reversão
|
||||
|
||||
Aplicar em produção depois do backup normal da base:
|
||||
|
||||
```sh
|
||||
php artisan migrate --force
|
||||
```
|
||||
|
||||
A reversão remove apenas colunas e tabelas novas, e recusa prosseguir quando já houver dados nelas. Não executa `DROP SCHEMA ... CASCADE` e não remove dados existentes.
|
||||
|
||||
Esta entrega altera o esquema e a serialização do modelo. Os endpoints/formulários para gerir todas as novas relações, estados de aprovação, documentos e tarefas requerem implementação incremental própria; o SQL por si só não os activa.
|
||||
|
||||
## Arquitetura da aplicação (opção 1)
|
||||
|
||||
A implementação da proposta reutiliza os serviços e modelos que já existem, sem introduzir `Opportunity`, `Interest`, `Party` ou `OutboxMessage` paralelos:
|
||||
|
||||
| Responsabilidade proposta | Implementação no portal |
|
||||
| --- | --- |
|
||||
| Criar e actualizar rascunhos | `ExistingBusinessDrafts`, usado pela API autenticada `api/v1/existing-business/listings`. |
|
||||
| Submeter e rever anúncios | `ExistingBusinessIntake`, com autorização via `ExistingBusinessPolicy`, controlo de versão e histórico cifrado. |
|
||||
| Pedidos de interesse idempotentes | `InterestRequestController` e `InterestSubmission`; eventos são gravados na outbox existente. |
|
||||
| Integração CRM | Cliente seguro `SuiteCrmClient`; a outbox existente usa `aggregate_type`, `aggregate_id`, `event_type`, `payload` e estados `pending/published/failed`. |
|
||||
| Estados tipados | Enums `OpportunityStatus` e `InterestStatus` em `app/Domain`, mantendo os valores de estado já persistidos. |
|
||||
|
||||
Os estados adicionais do enum `OpportunityStatus` alimentam agora as etapas de triagem, revisão, duas aprovações e publicação de `ExistingBusinessIntake`. A publicação usa uma projecção pública separada e allowlist de campos. As manifestações de interesse têm uma máquina de estados própria em `InterestWorkflow`; transições, notas cifradas e prazos são guardados no histórico comum. Isto ainda não constitui uma integração CRM em background, SLA automatizado, upload/scanner de documentos ou autorização institucional para publicar dados reais.
|
||||
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "InvestMoz reference catalogues",
|
||||
"version": "1.0.0",
|
||||
"description": "Read-only, rate-limited reference options with stable codes, version metadata and pt-MZ labels. Jurisdictions use the packaged territorial reference set. Economic activities are portal-curated sector groupings, not an official statutory classification. Authority entries are withheld until an authoritative source is approved."
|
||||
},
|
||||
"servers": [{ "url": "/api/v1/catalogs" }],
|
||||
"security": [],
|
||||
"paths": {
|
||||
"/authorities": { "$ref": "#/components/pathItems/Authorities" },
|
||||
"/jurisdictions": { "$ref": "#/components/pathItems/Jurisdictions" },
|
||||
"/economic-activities": { "$ref": "#/components/pathItems/EconomicActivities" }
|
||||
},
|
||||
"components": {
|
||||
"pathItems": {
|
||||
"Authorities": {
|
||||
"get": {
|
||||
"summary": "Search the published authorities catalogue",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/q" }, { "$ref": "#/components/parameters/version" },
|
||||
{ "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/per_page" },
|
||||
{ "name": "jurisdiction_id", "in": "query", "schema": { "type": "string", "format": "uuid" } }
|
||||
],
|
||||
"responses": { "200": { "$ref": "#/components/responses/CatalogPage" }, "429": { "description": "Rate limit exceeded" } }
|
||||
}
|
||||
},
|
||||
"Jurisdictions": {
|
||||
"get": {
|
||||
"summary": "List territorial reference options for dependent dropdowns",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/q" }, { "$ref": "#/components/parameters/version" },
|
||||
{ "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/per_page" },
|
||||
{ "name": "type", "in": "query", "required": true, "schema": { "type": "string", "enum": ["country", "province", "district", "admin_post", "locality"] } },
|
||||
{ "name": "parent_id", "in": "query", "schema": { "type": "string", "format": "uuid" }, "description": "Required for district, admin_post and locality; the parent must be of the immediately preceding level." }
|
||||
],
|
||||
"responses": { "200": { "$ref": "#/components/responses/CatalogPage" }, "422": { "description": "Invalid or missing parent selection" }, "429": { "description": "Rate limit exceeded" } }
|
||||
}
|
||||
},
|
||||
"EconomicActivities": {
|
||||
"get": {
|
||||
"summary": "Search the portal-curated economic activity groupings",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/q" }, { "$ref": "#/components/parameters/version" },
|
||||
{ "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/per_page" },
|
||||
{ "name": "parent_id", "in": "query", "schema": { "type": "string", "format": "uuid" }, "description": "Optional parent activity for hierarchical catalog browsing." },
|
||||
{ "name": "sector_id", "in": "query", "schema": { "type": "integer", "minimum": 1 }, "description": "Optional sector filter for the dependent activity dropdown." }
|
||||
],
|
||||
"responses": { "200": { "$ref": "#/components/responses/CatalogPage" }, "429": { "description": "Rate limit exceeded" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": {
|
||||
"q": { "name": "q", "in": "query", "schema": { "type": "string", "maxLength": 100 } },
|
||||
"version": { "name": "version", "in": "query", "schema": { "type": "integer", "minimum": 1 }, "description": "A published immutable version; omitted selects the current effective version." },
|
||||
"page": { "name": "page", "in": "query", "schema": { "type": "integer", "minimum": 1 } },
|
||||
"per_page": { "name": "per_page", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 100 } }
|
||||
},
|
||||
"responses": {
|
||||
"CatalogPage": {
|
||||
"description": "Stable option values with Portuguese (Mozambique) labels. A not-yet-published catalogue returns an empty page and status not_published.",
|
||||
"headers": {
|
||||
"Cache-Control": { "schema": { "type": "string" } },
|
||||
"ETag": { "schema": { "type": "string" } }
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["data", "links", "meta", "next_page_url", "prev_page_url", "current_page", "per_page", "total", "last_page"],
|
||||
"properties": {
|
||||
"data": { "type": "array", "items": { "$ref": "#/components/schemas/CatalogEntry" } },
|
||||
"links": { "type": "object", "properties": { "next": { "type": ["string", "null"] }, "previous": { "type": ["string", "null"] } } },
|
||||
"next_page_url": { "type": ["string", "null"] },
|
||||
"prev_page_url": { "type": ["string", "null"] },
|
||||
"current_page": { "type": "integer" },
|
||||
"per_page": { "type": "integer" },
|
||||
"total": { "type": "integer" },
|
||||
"last_page": { "type": "integer" },
|
||||
"meta": { "$ref": "#/components/schemas/CatalogMetadata" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"CatalogEntry": {
|
||||
"type": "object",
|
||||
"required": ["id", "value", "code", "name", "label", "locale"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "format": "uuid" },
|
||||
"value": { "type": "string", "description": "Stable API value; never the translated label." },
|
||||
"code": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"label": { "type": "string", "description": "Presentation label in pt-MZ." },
|
||||
"locale": { "const": "pt-MZ" },
|
||||
"parent_id": { "type": ["string", "null"], "format": "uuid" },
|
||||
"type": { "type": "string" },
|
||||
"version_no": { "type": "integer", "minimum": 1 },
|
||||
"sector_id": { "type": "integer" },
|
||||
"source_code": { "type": ["string", "null"] },
|
||||
"authority_type": { "type": "string" },
|
||||
"organization_id": { "type": "string", "format": "uuid" },
|
||||
"jurisdiction_id": { "type": ["string", "null"], "format": "uuid" },
|
||||
"jurisdiction_name": { "type": ["string", "null"] }
|
||||
}
|
||||
},
|
||||
"CatalogMetadata": {
|
||||
"type": "object",
|
||||
"required": ["catalog", "version", "locale", "current_page", "per_page", "total", "last_page"],
|
||||
"properties": {
|
||||
"catalog": { "type": "string", "enum": ["authorities", "jurisdictions", "economic-activities"] },
|
||||
"version": { "type": ["integer", "null"] },
|
||||
"locale": { "const": "pt-MZ" },
|
||||
"status": { "type": "string", "enum": ["not_published"] },
|
||||
"source_version": { "type": ["string", "null"] },
|
||||
"valid_from": { "type": ["string", "null"], "format": "date" },
|
||||
"valid_to": { "type": ["string", "null"], "format": "date" },
|
||||
"current_page": { "type": "integer" },
|
||||
"per_page": { "type": "integer" },
|
||||
"total": { "type": "integer" },
|
||||
"last_page": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "InvestMoz Simulation Execution API", "version": "1.0.0", "description": "Indicative arithmetic scenarios. Rates and reductions are user supplied, not legal rates or eligibility decisions."},
|
||||
"servers": [{"url": "/api/v1"}],
|
||||
"paths": {
|
||||
"/simulation-drafts": {
|
||||
"post": {"summary": "Create an encrypted, versioned simulator form draft", "parameters": [{"$ref": "#/components/parameters/IdempotencyKey"}, {"$ref": "#/components/parameters/SimulationToken"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SimulationFormDraft"}}}}, "responses": {"201": {"description": "Created with ETag"}, "200": {"description": "Idempotent replay"}, "409": {"description": "Draft exists or key reused with a different payload"}, "422": {"description": "Invalid draft field"}}}
|
||||
},
|
||||
"/simulation-drafts/current": {
|
||||
"get": {"summary": "Read the current user's or anonymous token's draft", "parameters": [{"$ref": "#/components/parameters/SimulationToken"}], "responses": {"200": {"description": "Draft snapshot and ETag; personal fields are decrypted only for its owner"}}}
|
||||
},
|
||||
"/simulation-drafts/{id}": {
|
||||
"get": {"summary": "Read an owned draft", "parameters": [{"$ref": "#/components/parameters/Id"}, {"$ref": "#/components/parameters/SimulationToken"}], "responses": {"200": {"description": "Draft snapshot and ETag"}, "404": {"description": "Draft inaccessible"}}},
|
||||
"put": {"summary": "Autosave an owned draft with optimistic locking", "parameters": [{"$ref": "#/components/parameters/Id"}, {"$ref": "#/components/parameters/IfMatch"}, {"$ref": "#/components/parameters/SimulationToken"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SimulationFormDraft"}}}}, "responses": {"200": {"description": "Saved with new ETag and snapshot hash"}, "412": {"description": "Stale draft version"}, "422": {"description": "Invalid draft field"}}},
|
||||
"delete": {"summary": "Soft-delete an owned draft", "parameters": [{"$ref": "#/components/parameters/Id"}, {"$ref": "#/components/parameters/IfMatch"}, {"$ref": "#/components/parameters/SimulationToken"}], "responses": {"204": {"description": "Deleted"}, "412": {"description": "Stale draft version"}}}
|
||||
},
|
||||
"/questionnaires/{code}": {"get": {"summary": "Read the published Portuguese questionnaire version", "parameters": [{"name": "code", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "Published questionnaire and stable version", "headers": {"ETag": {"schema": {"type": "string"}}}}, "404": {"description": "No published current version"}}}},
|
||||
"/simulations": {
|
||||
"post": {
|
||||
"summary": "Create a traceable simulation",
|
||||
"parameters": [{"$ref": "#/components/parameters/IdempotencyKey"}, {"$ref": "#/components/parameters/SimulationToken"}],
|
||||
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SimulationInput"}}}},
|
||||
"responses": {"202": {"description": "Execution queued", "headers": {"ETag": {"schema": {"type": "string"}}, "Location": {"schema": {"type": "string"}}}}, "200": {"description": "Idempotent replay"}, "409": {"description": "Key reused with a different payload"}, "422": {"description": "Invalid input or missing anonymous token"}, "428": {"description": "Idempotency-Key required"}}
|
||||
}
|
||||
},
|
||||
"/simulations/{id}": {
|
||||
"get": {"summary": "Read a simulation and its snapshots", "parameters": [{"$ref": "#/components/parameters/Id"}, {"$ref": "#/components/parameters/SimulationToken"}], "responses": {"200": {"description": "Simulation", "headers": {"ETag": {"schema": {"type": "string"}}}}, "404": {"description": "Missing or outside authorized scope"}}}
|
||||
},
|
||||
"/simulations/{id}/scenarios/{scenarioId}": {
|
||||
"patch": {
|
||||
"summary": "Update a scenario with optimistic concurrency",
|
||||
"parameters": [{"$ref": "#/components/parameters/Id"}, {"$ref": "#/components/parameters/ScenarioId"}, {"$ref": "#/components/parameters/IfMatch"}, {"$ref": "#/components/parameters/SimulationToken"}],
|
||||
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateScenarioInput"}}}},
|
||||
"responses": {"202": {"description": "Updated; re-execution queued"}, "403": {"description": "Edit not authorized"}, "404": {"description": "Simulation inaccessible"}, "412": {"description": "Stale ETag"}, "428": {"description": "If-Match required"}, "422": {"description": "Invalid input"}}
|
||||
}
|
||||
},
|
||||
"/simulations/{id}/reports/{reportId}": {
|
||||
"get": {"summary": "Read an immutable report snapshot", "parameters": [{"$ref": "#/components/parameters/Id"}, {"$ref": "#/components/parameters/ReportId"}, {"$ref": "#/components/parameters/SimulationToken"}], "responses": {"200": {"description": "Report snapshot and hash"}, "404": {"description": "Report inaccessible"}}}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"parameters": {
|
||||
"Id": {"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"}},
|
||||
"IdempotencyKey": {"name": "Idempotency-Key", "in": "header", "required": true, "schema": {"type": "string", "minLength": 16, "maxLength": 128}},
|
||||
"SimulationToken": {"name": "X-Simulation-Token", "in": "header", "required": false, "description": "Required for anonymous access. Client generated; only an HMAC hash is stored.", "schema": {"type": "string", "minLength": 32, "maxLength": 256}},
|
||||
"IfMatch": {"name": "If-Match", "in": "header", "required": true, "description": "Strong ETag; absent returns 428, stale returns 412.", "schema": {"type": "string"}}
|
||||
},
|
||||
"schemas": {
|
||||
"SimulationInput": {"type": "object", "additionalProperties": false, "required": ["base", "rate", "reduction"], "properties": {"base": {"type": "number", "minimum": 0, "description": "User input; no currency is inferred."}, "rate": {"type": "number", "minimum": 0, "maximum": 100, "description": "User scenario rate; not an official rate."}, "reduction": {"type": "number", "minimum": 0, "maximum": 100, "description": "User supplied scenario reduction; no default is applied."}, "organization_id": {"type": "string", "format": "uuid", "description": "Optional; requires admin, manager or officer membership."}}},
|
||||
"SimulationFormDraft": {"type": "object", "additionalProperties": false, "required": ["personal_snapshot", "economic_snapshot"], "properties": {"personal_snapshot": {"type": "object", "description": "Allowlisted personal fields, encrypted at rest; only owner/token can read."}, "economic_snapshot": {"type": "object", "required": ["purpose"], "description": "Allowlisted economic/form fields, encrypted at rest; rates remain user-supplied."}}},
|
||||
"UpdateScenarioInput": {"type": "object", "additionalProperties": false, "required": ["label", "base", "rate", "reduction"], "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, "description": "User supplied scenario reduction; no default is applied."}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
# Etapa 10 — Motor fiscal principal
|
||||
|
||||
**Estado proposto: BLOQUEADO.** As fórmulas e a matriz de casos foram fornecidas e o utilizador declarou-as formalmente aprovadas em 2026-10-01. Nenhum motor fiscal foi implementado; continuam por satisfazer pré-condições técnicas e por fornecer parâmetros legais de produção.
|
||||
|
||||
## Inspecção e conformidade
|
||||
|
||||
| Critério | Evidência do repositório | Estado |
|
||||
| --- | --- | --- |
|
||||
| Laravel / frontend / dados | Laravel 13.30.1, PHP CLI 8.5.11, React 19/Inertia, PostgreSQL/PostGIS nas migrations e no CI | CONFORME como stack base |
|
||||
| Regras funcionais | Fórmulas e matriz de casos foram fornecidas e consideradas formalmente aprovadas pelo utilizador em 2026-10-01. `ADR-004` continua sem parâmetros concretos de produção | PARCIAL — falta catálogo de parâmetros/fontes vigentes |
|
||||
| Snapshots | `SimulationExecution` guarda snapshots de entrada/cálculo/resultado; o questionário é `user-entered-arithmetic`, sem taxa legal (`legal_rate_source: null`) | PARCIAL — infraestrutura genérica, sem snapshot versionado de regras fiscais |
|
||||
| API estabilizada para os cálculos do MVP | API aceita `base`, `rate` e `reduction` para aritmética genérica; não cobre IRPC, IVA, ISPC, IRPS, INSS ou retenções como regras versionadas. A última tentativa PHPUnit não passou das asserções | BLOQUEADO |
|
||||
| DSL whitelist e DAG sem ciclos | `ADR-004` descreve uma futura AST tipada como restrição; não há avaliador, validador de DAG ou testes de ciclos no código inspeccionado | AUSENTE |
|
||||
| Decimal e arredondamento configurado | `ADR-006` exige precisão decimal, mas deixa escala, modo e momento de arredondamento dependentes de aprovação do domínio | BLOQUEADO |
|
||||
| Casos dourados e fontes | Matriz de casos esperados fornecida e aprovada funcionalmente pelo utilizador; não há fontes legais oficiais associadas nem assinaturas nominais de Fiscalista/QA | PARCIAL — fontes e evidência de revisão pendentes |
|
||||
| RLS e operação segura | `ADR-007` mantém a matriz IAM/RLS pendente; a eficácia de policies/grants não foi demonstrada nesta execução | BLOQUEADO |
|
||||
|
||||
## Divergência de contrato a resolver
|
||||
|
||||
A migration existente `2026_10_01_000200_create_simulation_execution_core.php` publica no snapshot de questionário `reduction.default = 0`, enquanto os requests e OpenAPI locais passaram a exigir redução explícita. A migration não foi alterada. É necessário definir uma migração aditiva/versionada do questionário depois de aprovada a semântica; não usar essa divergência como default fiscal.
|
||||
|
||||
## Plano condicionado para retomar a Etapa 10
|
||||
|
||||
1. Receber catálogo aprovado de parâmetros concretos de produção por imposto, com `scope_key`, período de vigência, fonte oficial e hash; definir precisão e arredondamento.
|
||||
2. Preservar a matriz funcional já aprovada; completar associação de fundamentos legais e obter as assinaturas nominais de revisão exigidas para release; identificar explicitamente cálculos automáticos e respostas indeterminadas/orientativas.
|
||||
3. Confirmar API e snapshots do contrato fiscal, incluindo versionamento de regra/engine e exposição de explicabilidade.
|
||||
4. Confirmar uma base de testes descartável isolada e concluir os gates de IAM/RLS requeridos antes dos testes de integração.
|
||||
5. Só então propor ficheiros exactos para o avaliador allowlist, validação/topologia DAG, tipos decimais, integração, persistência de fontes e testes dourados. Não se fixam nomes de classes/tabelas sem o pacote aprovado.
|
||||
|
||||
## Evidência desta execução
|
||||
|
||||
- PHP 8.5.11 foi confirmado; Laravel, OpenAPI, API, migrations, ADRs e testes foram inspeccionados em leitura.
|
||||
- A API OpenAPI de simulações declara o cenário aritmético genérico; a migration inicializa a versão `user-entered-arithmetic`.
|
||||
- A última execução PHPUnit conhecida iniciou 15 testes e teve 15 erros antes das asserções por `Unregistered skills_edu schema exists; reconcile before migration.`. `phpunit.xml` fixa `DB_DATABASE`, mas não host/porta/credenciais; por isso não foi repetida nem considerada evidência de API estável.
|
||||
- Nenhuma base de dados ou serviço externo foi contactado nesta execução. Não foram alteradas migrations, código do motor, API ou dependências.
|
||||
|
||||
## Ficheiros e rollback
|
||||
|
||||
- Criado: `docs/investmoz/simulations/stage-10-gate.md`.
|
||||
- Sem alterações em código, testes, OpenAPI, migrations ou dados.
|
||||
- Rollback documental: remover apenas este ficheiro. Não há rollback de schema ou dados a executar.
|
||||
|
||||
**Gate inicial:** BLOQUEADO antes da aprovação funcional registada abaixo; consultar as actualizações posteriores para o estado vigente.
|
||||
|
||||
## Actualização — fórmulas funcionais fornecidas pelo utilizador (2026-10-01)
|
||||
|
||||
Foram fornecidas expressões funcionais para lucro tributável e IRPC estimado, IVA liquidado/a pagar, ISPC, IRPS por escalões, contribuições INSS, retenções, valor aduaneiro, direitos, ICE, IVA de importação, custo fiscal total e taxa efectiva. A aprovação funcional foi registada na actualização de 2026-10-01; continuam ausentes valores versionados, fontes oficiais e assinaturas nominais de revisão.
|
||||
|
||||
### Regras e limites registados
|
||||
|
||||
- IRPC: `LT = rendimentos tributáveis - gastos fiscalmente dedutíveis + correcções positivas - correcções negativas - prejuízos dedutíveis`; `IRPC bruto = max(0, LT) × taxa_IRPC`; pagamentos por conta, benefícios e créditos são linhas separadas. Elegibilidade, limites e taxa dependem de parâmetros/fontes vigentes.
|
||||
- IVA: separar operações tributadas, isentas, taxa zero e fora do âmbito. Dedutibilidade, crédito anterior e componentes da base precisam de dados/justificação e parâmetros versionados.
|
||||
- ISPC e IRPS: dependem de elegibilidade/regime, tabela e escalões, periodicidade, deduções e dados estritamente necessários; sem esses dados, resultado indeterminado.
|
||||
- INSS: os valores indicados de 4% empregador e 3% trabalhador estão expressamente sujeitos a validação; não são tratados como taxas aprovadas. TCP requer parâmetro próprio.
|
||||
- Retenções: requerem natureza do pagamento, residência, convenção aplicável e documentação para eventual taxa convencional.
|
||||
- Cálculos aduaneiros (VA, direitos, ICE e IVA de importação) pertencem à Etapa 11; código pautal, origem, preferência, método cambial, fonte e componentes de base continuam obrigatórios. A sua inclusão no material recebido não inicia a Etapa 11.
|
||||
- Custo fiscal total e taxa efectiva exigem classificação de recuperabilidade e selecção explícita da base económica; retenções e descontos de trabalhador devem ficar separados conforme especificado.
|
||||
|
||||
### Estado do gate após a actualização
|
||||
|
||||
**Continua BLOQUEADO para implementação e cálculo fiscal.** A aprovação funcional declarada pelo utilizador está registada. Permanecem pendentes: catálogo de parâmetros de produção por imposto com `scope_key`, vigência, fonte oficial e hash; escalas, modos e momentos de arredondamento; contrato estável de API/snapshot fiscal; demonstração de RLS/IAM; e confirmação de base descartável isolada para testes. As taxas de teste não podem preencher o catálogo de produção. Qualquer cenário sem parâmetro aplicável deve ser explicitamente indeterminado.
|
||||
|
||||
Esta actualização é documental. Não foram alterados motor, API, OpenAPI, migrations, dependências ou dados; a Etapa 11 não foi iniciada.
|
||||
|
||||
## Actualização — critérios de validação e aceitação fornecidos pelo utilizador (2026-10-01)
|
||||
|
||||
Critérios recebidos para os gates do motor e dos cálculos relacionados:
|
||||
|
||||
- Rejeitar montantes negativos e percentagens fora de `[0, 100]`; reconciliar somas de financiamento com o investimento ou sinalizar a divergência.
|
||||
- Para alfândegas, exigir código pautal para cálculo definitivo. Sem classificação, permitir apenas estimativa explicitamente incompleta e solicitar classificação; não seleccionar taxa genérica.
|
||||
- Só aplicar benefícios quando regime, certificado, período e critérios estejam satisfeitos. Caso contrário, exibi-los como oportunidade a validar, sem os incluir como crédito calculado.
|
||||
- Em cada linha calculada, expor fórmula, valores substituídos, versão da regra, vigência e fundamento legal.
|
||||
- Cobrir em casos de teste limites de escalões, mudanças de taxa por data, zero, valores elevados, isenções, arredondamento e combinações incompatíveis.
|
||||
- Reconciliar totais do relatório com a resposta da API e com o snapshot persistido.
|
||||
- Impedir uso em produção de regra vencida, sem fonte ou sem aprovação.
|
||||
|
||||
Estes critérios complementam a especificação funcional. A aprovação funcional posterior está registada abaixo; estes critérios não comprovam fontes legais, parâmetros vigentes, arredondamento, API/snapshot, RLS ou ambiente de testes. Nenhuma regra incompleta ou não aprovada pode ser usada em produção.
|
||||
|
||||
## Actualização — matriz de casos e Go/No-Go recebida (2026-10-01)
|
||||
|
||||
Foi recebido um conjunto de casos com resultados esperados, inicialmente registado como proposta de casos dourados. Em 2026-10-01, o utilizador instruiu que a matriz e todas as regras fornecidas fossem consideradas formalmente aprovadas. Regista-se essa aprovação funcional declarada pelo utilizador, sem atribuir assinaturas nominais de Fiscalista/QA que não foram fornecidas. Os valores explicitamente chamados “taxa teste” continuam a ser fixtures sintéticas de QA e não podem ser publicados nem tratados como taxas legais.
|
||||
|
||||
### Casos funcionais recebidos
|
||||
|
||||
- IRPC: `IRPC-01` a `IRPC-04` cobrem LT 400 000 e imposto 128 000 com taxa de teste 32%, prejuízo e imposto zero, crédito/benefício elegível (93 000) e certificado ausente (benefício excluído; 113 000 após créditos).
|
||||
- IVA: `IVA-01` a `IVA-04` cobrem liquidado 16 000/pagar 8 000 com taxa de teste 16%, exportação a taxa zero, crédito estimado de 3 500 e compra sem direito a dedução.
|
||||
- ISPC/IRPS/INSS: `ISPC-01`/`02` testam elegibilidade e limite; `IRPS-01`/`02` testam fronteiras de escalões; `INSS-01` testa 4%/3% sintéticos e `INSS-02` exclui componente não contributiva.
|
||||
- Retenções: `RET-01` a `RET-03` testam taxa doméstica de 20%, recusa de convenção sem certificado e taxa convencional sintética de 10% com documentação.
|
||||
- Alfândega/importação: `ADU-01`/`02` esperam VA 80 000 MZN e direitos 16 000; `ADU-03` bloqueia direitos sem código pautal; `ICE-01`/`02` testam ad valorem e taxa por unidade; `IMP-IVA-01` espera base 102 000 e IVA de importação 16 320.
|
||||
- Agregados: `CFT-01` espera 118 000 excluindo IVA recuperável de 16 320; `TEF-01` espera 29,50% sobre lucro 400 000.
|
||||
|
||||
### Testes transversais e critérios de release recebidos
|
||||
|
||||
Foram também fornecidos `TMP-01/02` (vigência e sobreposição), `RND-01` (arredondamento), `CUR-01` (câmbio), `RULE-01/03` (fonte, whitelist e ciclos), `API-01/03` (idempotência e ETag), `SEC-01/02` (isolamento e segregação de funções), `AUD-01` (auditoria), `REP-01` (reconciliação PDF/JSON), `PERF-01` (p95 < 2 s para 100 regras/500 parâmetros) e `ACC-01` (teclado/acessibilidade). Go exige 100% dos críticos, pelo menos 95% do conjunto, zero vulnerabilidades críticas/altas abertas, reconciliação, assinaturas, rollback, backup e observabilidade; os No-Go também estão registados conforme a matriz fornecida.
|
||||
|
||||
Esta matriz fornece resultados de teste reproduzíveis para vários cenários e fica aprovada funcionalmente conforme a instrução do utilizador. Não define fontes legais oficiais, taxas/limites/parâmetros de produção para todos os cenários, política de arredondamento (ponto, escala e modo), versões efectivas do conjunto de regras nem identidade/assinatura individual dos revisores. Inclui cenários de alfândega, segurança, relatórios e release, que pertencem a gates posteriores e não iniciam essas etapas.
|
||||
|
||||
**Estado do gate após esta entrada: BLOQUEADO por dependências técnicas e parâmetros de produção em falta.** A aprovação funcional da matriz/regras foi registada. Permanecem necessários: pacote de parâmetros de produção com fontes oficiais, vigências e hash; decisão explícita de escala, modo e momento de arredondamento; contrato/API e snapshots fiscais estáveis; demonstração dos controlos IAM/RLS; e confirmação de ambiente isolado de testes. As taxas designadas “teste” permanecem apenas em fixtures. Nenhum teste de motor foi executado porque o motor e o pacote de produção ainda não existem.
|
||||
|
||||
## Reavaliação do gate — pedido de execução da Etapa 10 (2026-10-01)
|
||||
|
||||
### Plano condicionado e ficheiros previstos após desbloqueio
|
||||
|
||||
1. Formalizar armazenamento e resolução temporal dos parâmetros/fontes por `scope_key`, sem incluir taxas sintéticas em produção.
|
||||
2. Implementar AST allowlist e validação de DAG acíclico; calcular com decimal exacto e política de arredondamento explícita; devolver indeterminação quando faltar parâmetro/entrada.
|
||||
3. Integrar execução fiscal, explicabilidade, proveniência e snapshots imutáveis no fluxo de simulação já existente; actualizar contrato OpenAPI e cobrir casos dourados.
|
||||
4. Executar testes unitários e de integração somente em base descartável confirmada.
|
||||
|
||||
Ficheiros identificados pela estrutura actual para uma futura alteração: criar `app/Domain/Simulations/Tax/RuleAstValidator.php`, `app/Domain/Simulations/Tax/RuleDag.php`, `app/Domain/Simulations/Tax/TaxRuleEngine.php`, `app/Domain/Simulations/Tax/ExactDecimal.php`, `app/Domain/Simulations/Tax/TaxParameterResolver.php`, `app/Models/TaxRuleSet.php`, `app/Models/TaxParameter.php`, migration aditiva `database/migrations/2026_10_02_000100_create_tax_rule_sets_and_parameters.php`, testes `tests/Unit/Domain/Simulations/Tax/RuleAstValidatorTest.php`, `RuleDagTest.php`, `TaxRuleEngineTest.php`, `ExactDecimalTest.php`, `TaxParameterResolverTest.php` e `tests/Feature/SimulationTaxExecutionTest.php`; alterar `app/Jobs/RunSimulation.php`, `app/Services/SimulationExecution.php`, `docs/investmoz/simulations/openapi.json` e, se o esquema aprovado exigir campos fiscais novos, `app/Models/SimulationResult.php`/`SimulationResultSource.php`. PHP 8.5.11 nesta máquina tem `bcmath` e `pdo_pgsql` disponíveis, mas o contrato de arredondamento continua por aprovar. Esta lista é uma proposta condicionada, não autorização para criar schema antes da aprovação do catálogo e arredondamento.
|
||||
|
||||
### Resultado da reavaliação
|
||||
|
||||
- Stack confirmada nesta execução: Laravel 13.30.1, PHP CLI 8.5.11, React 19/Inertia; Composer define PHP `^8.3`, PHPUnit `^12.5.23`; contrato OpenAPI de simulação é 3.1.0 e descreve apenas aritmética indicativa. As migrations e CI usam PostgreSQL/PostGIS.
|
||||
- `RunSimulation` executa actualmente a fórmula genérica via `numeric`, guarda snapshots de entrada/resultado e fonte `user_input`; não resolve regra/fonte fiscal. `SimulationExecution` mantém idempotência, transacção e `If-Match` para cenários.
|
||||
- ADR-004 permite somente AST allowlist futura e não define regras/parâmetros. ADR-006 deixa escala/modo/momento de arredondamento por aprovar. ADR-007 mantém IAM/RLS bloqueado até matriz de papéis e contexto ser aprovada.
|
||||
- PHPUnit permanece sem evidência verde para este escopo: a última execução documentada falhou antes das asserções devido ao schema `skills_edu`; `phpunit.xml` só fixa `DB_DATABASE=investmoz_test`, sem confirmar endpoint descartável. Não repeti a suite nem contactei base de dados.
|
||||
- O valor de teste 32%, 16%, 3%/4% e os demais valores sintéticos dos casos dourados não são parâmetros legais de produção. A API fiscal também não está estabilizada como contrato versionado.
|
||||
|
||||
**Decisão: parar sem alterações de código, migrations ou testes.** A pré-condição de parâmetros aprovados para produção/API/snapshot não está satisfeita. Para desbloquear, fornecer catálogo de produção com fonte legal oficial, hash, `scope_key` e vigência; configuração aprovada de arredondamento; e confirmação do ambiente isolado e da matriz IAM/RLS. As fórmulas e a matriz já declaradas aprovadas não são reabertas por este bloqueio.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Etapa 11 — Aduanas, câmbio e cenários
|
||||
|
||||
**Estado proposto: BLOQUEADO — não iniciada.** A Etapa 11 depende da aprovação/conclusão do motor principal e das fontes legais da Etapa 10. A Etapa 10 continua bloqueada por falta de parâmetros de produção versionados com fontes oficiais, arredondamento definido e gates técnicos/API/RLS/testes por demonstrar.
|
||||
|
||||
## Pré-condições e evidência disponível
|
||||
|
||||
| Pré-condição | Evidência disponível | Estado |
|
||||
| --- | --- | --- |
|
||||
| Motor principal aprovado | O gate da Etapa 10 regista fórmulas e matriz aprovadas funcionalmente pelo utilizador, mas nenhum motor foi implementado nem testado | BLOQUEADO |
|
||||
| Fontes legais e parâmetros aduaneiros | Foram fornecidas fórmulas e casos sintéticos (`ADU-01` a `ADU-03`, `ICE-01`/`02`, `IMP-IVA-01`); não incluem pauta/catálogo oficial versionado, fontes, vigências ou hashes de parâmetros de produção | BLOQUEADO |
|
||||
| Câmbio rastreável | `CUR-01` especifica que valor original, taxa, data e fonte devem ser guardados; não define fornecedor/fonte oficial, método nem regra de selecção da taxa | BLOQUEADO |
|
||||
| Classificação e preferência | A regra fornecida exige código pautal e prova de origem/preferência; não foi fornecido catálogo nem conjunto de documentos/critérios oficiais | BLOQUEADO |
|
||||
| Ambiente e controlos técnicos | Gate da Etapa 10 ainda não demonstra contrato/API, snapshots fiscais, RLS/IAM nem ambiente isolado de testes | BLOQUEADO |
|
||||
|
||||
## Regras já especificadas funcionalmente
|
||||
|
||||
- Valor aduaneiro: factura convertida + frete + seguro + ajustamentos incluíveis, conforme método/câmbio vigentes.
|
||||
- Direitos: `VA × taxa_pautal`; sem código pautal, direitos ficam bloqueados e não se usa taxa genérica. Pode existir apenas estimativa explicitamente incompleta, sem direitos calculados.
|
||||
- ICE: base e taxa específica/ad valorem dependem do produto, unidade e pauta vigente.
|
||||
- IVA de importação: base e componentes são parâmetros versionados e citados.
|
||||
- Preferência é condicional a origem/documentos; o snapshot deve conservar taxa cambial, data, fonte e método.
|
||||
|
||||
As taxas de teste do anexo são fixtures de QA e não valores legais. Os casos de Etapa 11 aprovados funcionalmente (`ADU-*`, `ICE-*`, `IMP-IVA-01`, `CUR-01`) ainda precisam de fontes legais e parâmetros de produção associados.
|
||||
|
||||
## Condição para retomar
|
||||
|
||||
1. Desbloquear e concluir a Etapa 10, incluindo fonte legal/versão do motor e respetivos snapshots.
|
||||
2. Fornecer catálogos aduaneiros oficiais/versionados: pauta/código, direitos, ICE, base do IVA de importação, origem e preferências, com vigência e proveniência.
|
||||
3. Aprovar método cambial, fonte, data aplicável, conversão e política de arredondamento.
|
||||
4. Confirmar documentos/provas necessários para classificação e preferência, incluindo resposta incompleta quando faltarem dados.
|
||||
5. Confirmar ambiente isolado, IAM/RLS e contratos de API/snapshot; então inspeccionar o repositório e propor plano e lista exacta de ficheiros antes de implementar.
|
||||
|
||||
## Evidência desta execução
|
||||
|
||||
- Consultados `docs/investmoz/simulations/stage-10-gate.md` e os documentos de implementação disponíveis; não existe gate anterior da Etapa 11.
|
||||
- Não foram alterados código, migrations, API, dependências ou dados. Nenhum teste foi executado. A Etapa 11 não foi iniciada.
|
||||
- Rollback: remover apenas este documento, caso a sua criação seja revertida; sem rollback de schema/dados.
|
||||
|
||||
**Decisão proposta: manter BLOQUEADO e parar na Etapa 11 até todas as pré-condições acima terem evidência verificável.**
|
||||
@@ -0,0 +1,34 @@
|
||||
# Etapa 9 — Formulário React com dropdowns
|
||||
|
||||
**Data:** 2026-10-01
|
||||
**Aprovação:** o utilizador autorizou continuar e assumir as aprovações anteriores.
|
||||
**Estado:** IMPLEMENTAÇÃO PARCIAL — formulário, validação e autosave seguro implementados; submissão formal, anexos e validação manual assistiva permanecem fora do gate concluído.
|
||||
|
||||
## Implementado
|
||||
|
||||
- A rota `/Simulador` apresenta um formulário React em etapas para finalidade, perfil, identificação, projecto, localização, finanças, impacto, cenário fiscal aritmético, oportunidade, documentos aplicáveis e revisão.
|
||||
- Dropdowns pesquisáveis usam catálogos publicados; actividade é filtrada pelo sector. A navegação e selecção múltipla suportam teclado, com limpeza dos valores descendentes quando muda uma opção-pai.
|
||||
- Campos condicionais têm validação inline por etapa, incluindo limites aritméticos e reconciliação das fontes de financiamento. A API exige base, taxa e redução informadas explicitamente; nenhum valor fiscal é predefinido.
|
||||
- O rascunho é guardado com debounce no servidor, cifrado por snapshot, isolado por utilizador autenticado ou token anónimo e protegido com ETag/If-Match, chave idempotente, limitação de pedidos e RLS PostgreSQL. A cópia local de sessão exclui identidade e contactos.
|
||||
- A API valida chaves permitidas, tipos, limites, actividade publicada correspondente ao sector, hierarquia territorial e reconciliação financeira. A migration é aditiva e a reversão recusa apagar rascunhos existentes.
|
||||
- O cenário fiscal continua a usar a API aritmética existente; o formulário não submete uma manifestação nem publica dados.
|
||||
|
||||
## Limites conhecidos
|
||||
|
||||
- A etapa Documentos recolhe apenas os tipos aplicáveis a preparar. Ainda não há upload, verificação ou armazenamento de ficheiros.
|
||||
- Os campos de salários, pagamentos externos, importações e benefícios alegados são recolhidos como texto; não há subformulários estruturados nem validação fiscal desses dados.
|
||||
- A lista de formas jurídicas e de zonas especiais ainda não tem catálogo publicado. A forma jurídica está como texto e a referência da zona/projecto é texto livre. Países dependem do catálogo territorial disponível.
|
||||
- A etapa Revisão recolhe declarações e autorizações, mas não as envia como manifestação formal. A submissão e o fluxo de tratamento pertencem à etapa seguinte.
|
||||
- O formulário tem atributos semânticos e suporte de teclado implementado, mas a verificação manual com leitor de ecrã e em dispositivos móveis precisa de ser feita num navegador.
|
||||
|
||||
## Verificações
|
||||
|
||||
- `npm.cmd run types:check` — aprovado.
|
||||
- `npm.cmd run build` — aprovado; o Vite avisou que o pacote opcional `fontaine` não está instalado, sem impedir o build.
|
||||
- `node --experimental-strip-types --test tests/frontend/simulation-form.test.mjs` — 6 testes aprovados.
|
||||
- `php.exe -l` — sintaxe aprovada no FormRequest, serviço, controller, model e migration novos.
|
||||
- PHPUnit não executado: o PHP disponível é 8.1.10 e a versão instalada do PHPUnit exige PHP >= 8.3. Os testes de feature da API/migration continuam por executar num runtime compatível.
|
||||
|
||||
## Rollback
|
||||
|
||||
Reverter apenas os ficheiros listados pelo diff desta etapa. Para reverter a migration de rascunhos, primeiro eliminar os dados de rascunho por procedimento aprovado; a migration recusa rollback enquanto houver registos. Preservar todas as alterações locais preexistentes.
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { api } from "@/pages/investment/shared";
|
||||
import { replaceTerritoryPath } from "@/lib/territory-path.js";
|
||||
|
||||
export type TerritoryOption = {
|
||||
id: string;
|
||||
@@ -18,7 +19,7 @@ const emptyTerritoryPath: TerritoryOption[] = [];
|
||||
export function ProvinceSelect({
|
||||
value,
|
||||
onChange,
|
||||
endpoint = "/api/v1/investment/territory-options",
|
||||
endpoint = "/api/v1/catalogs/jurisdictions",
|
||||
placeholder = "Seleccione uma província",
|
||||
className = control,
|
||||
label = "Província",
|
||||
@@ -94,7 +95,7 @@ export function ProvinceSelect({
|
||||
export function TerritoryFields({
|
||||
initial = emptyTerritoryPath,
|
||||
onChange,
|
||||
endpoint = "/api/v1/investment/territory-options",
|
||||
endpoint = "/api/v1/catalogs/jurisdictions",
|
||||
}: {
|
||||
initial?: TerritoryOption[];
|
||||
onChange?: (path: TerritoryOption[]) => void;
|
||||
@@ -114,7 +115,7 @@ export function TerritoryFields({
|
||||
selected={path[index]}
|
||||
endpoint={endpoint}
|
||||
onChange={(value) => {
|
||||
const next = [...path.slice(0, index), ...(value ? [value] : [])];
|
||||
const next = replaceTerritoryPath(path, index, value);
|
||||
setPath(next);
|
||||
onChange?.(next);
|
||||
}}
|
||||
@@ -285,7 +286,7 @@ export default function InvestmentTerritory({ kind, id }: { kind: string; id: st
|
||||
parent={path[index - 1]?.id ?? ""}
|
||||
selected={path[index]}
|
||||
onChange={(value) => {
|
||||
setPath([...path.slice(0, index), ...(value ? [value] : [])]);
|
||||
setPath(replaceTerritoryPath(path, index, value));
|
||||
setMessage("");
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
getAutosaveSnapshot,
|
||||
partitionSimulationDraft,
|
||||
restoreSimulationDraft,
|
||||
replaceSimulationValue,
|
||||
simulationFieldGroups,
|
||||
simulationDraftFingerprint,
|
||||
simulationOptions,
|
||||
simulationSections,
|
||||
validateSimulationDraft,
|
||||
type SimulationDraft,
|
||||
} from "@/lib/simulation-form";
|
||||
|
||||
type Choice = { id: string; label: string };
|
||||
const draftKey = "investmoz-simulation-draft-v1";
|
||||
const tokenKey = "investmoz-simulation-token-v1";
|
||||
const idempotencyStorageKey = "investmoz-simulation-idempotency-v1";
|
||||
const draftCreateKey = "investmoz-simulation-draft-create-key-v1";
|
||||
const draftIdKey = "investmoz-simulation-draft-id-v1";
|
||||
const idempotencyKey = () => crypto.randomUUID();
|
||||
|
||||
function getSimulationToken(): string {
|
||||
let token = sessionStorage.getItem(tokenKey);
|
||||
if (!token) {
|
||||
token = crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "");
|
||||
sessionStorage.setItem(tokenKey, token);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function csrfToken(): string {
|
||||
return decodeURIComponent(document.cookie.split("; ").find(cookie => cookie.startsWith("XSRF-TOKEN="))?.slice(11) ?? "");
|
||||
}
|
||||
|
||||
function labelFor(value: string): string {
|
||||
const words: Record<string, string> = {
|
||||
simulate_only: "Apenas simular", own_investment: "Investimento próprio", seek_investor: "Procurar investidor",
|
||||
seek_partner: "Procurar parceiro", present_opportunity: "Apresentar oportunidade", request_facilitation: "Solicitar facilitação",
|
||||
investment: "Investimento", business: "Negócio", singular: "Pessoa singular", collective: "Pessoa colectiva",
|
||||
sole_trader: "Empresário em nome individual", consortium: "Consórcio", national: "Nacional", foreign: "Estrangeiro",
|
||||
mixed: "Misto", existing: "Existente", being_formed: "Em constituição", "pt-MZ": "Português (Moçambique)", en: "English",
|
||||
portal: "Portal", email: "Correio electrónico", phone: "Telefone", new: "Novo projecto", expansion: "Expansão",
|
||||
acquisition: "Aquisição", partnership: "Parceria", concession: "Concessão", supplier: "Fornecedor", buyer: "Comprador",
|
||||
other: "Outro", idea: "Ideia", concept: "Conceito", pre_feasibility: "Pré-viabilidade", feasibility: "Viabilidade",
|
||||
financing: "Financiamento", implementation: "Implementação", operation: "Operação", owned: "Propriedade",
|
||||
duat: "DUAT", leased: "Arrendado", requested: "Solicitado", unknown: "Por determinar", MZN: "MZN", USD: "USD",
|
||||
EUR: "EUR", ZAR: "ZAR", A: "Categoria A", B: "Categoria B", C: "Categoria C", por_determinar: "Por determinar",
|
||||
offer: "Oferta", demand: "Procura", both: "Oferta e procura", capital: "Capital", partner: "Parceiro",
|
||||
land: "Terreno", technology: "Tecnologia", licence: "Licença", services: "Serviços", public: "Público",
|
||||
registered_users: "Utilizadores registados", restricted: "Restrito", confidential: "Confidencial", yes: "Sim", no: "Não",
|
||||
energy: "Energia",
|
||||
};
|
||||
return words[value] ?? value.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function useChoices(source: string | undefined, query: string, parent: string, enabled: boolean) {
|
||||
const [choices, setChoices] = useState<Choice[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const staticChoices = useMemo(() => {
|
||||
if (source === "currency") {
|
||||
const intl = Intl as unknown as { supportedValuesOf?: (key: string) => string[] };
|
||||
return (intl.supportedValuesOf?.("currency") ?? []).map(code => ({ id: code, label: code }));
|
||||
}
|
||||
if (!source || ["country", "province", "district", "admin_post", "locality", "sector", "activity"].includes(source)) return [];
|
||||
return (simulationOptions[source] ?? []).map(value => ({ id: value, label: labelFor(value) }));
|
||||
}, [source]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !source || !["country", "province", "district", "admin_post", "locality", "sector", "activity"].includes(source)) {
|
||||
setChoices(staticChoices);
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
if (["district", "admin_post", "locality", "activity"].includes(source) && !parent) {
|
||||
setChoices([]);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
let url: URL;
|
||||
if (source === "sector") {
|
||||
url = new URL("/api/v1/sectors", window.location.origin);
|
||||
} else {
|
||||
url = new URL("/api/v1/catalogs/" + (source === "activity" ? "economic-activities" : "jurisdictions"), window.location.origin);
|
||||
url.searchParams.set("q", query);
|
||||
url.searchParams.set("per_page", "50");
|
||||
if (source === "activity") url.searchParams.set("sector_id", parent);
|
||||
else {
|
||||
url.searchParams.set("type", source === "country" ? "country" : source);
|
||||
if (parent) url.searchParams.set("parent_id", parent);
|
||||
}
|
||||
}
|
||||
const response = await fetch(url, { signal: controller.signal, headers: { Accept: "application/json" }, credentials: "same-origin" });
|
||||
if (!response.ok) throw new Error("Não foi possível carregar as opções. Tente novamente.");
|
||||
const payload = await response.json();
|
||||
const rows = payload.data ?? [];
|
||||
setChoices(rows.map((row: Record<string, unknown>) => ({
|
||||
id: String(row.id ?? row.value ?? ""),
|
||||
label: String(row.label ?? row.name ?? ""),
|
||||
})).filter((row: Choice) => row.id && row.label));
|
||||
} catch (cause) {
|
||||
if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : "Erro ao carregar opções.");
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
}, 180);
|
||||
return () => { window.clearTimeout(timer); controller.abort(); };
|
||||
}, [source, query, parent, enabled, staticChoices]);
|
||||
return { choices, loading, error, retry: () => setError("") };
|
||||
}
|
||||
|
||||
function SearchChoice({
|
||||
label, source, parent = "", value, selectedLabel, multiple, disabled, error, required, onChange,
|
||||
}: {
|
||||
label: string; source?: string; parent?: string; value: string | string[]; selectedLabel?: string | string[];
|
||||
multiple?: boolean; disabled?: boolean; error?: string; required?: boolean; onChange: (value: string | string[], text?: string | string[]) => void;
|
||||
}) {
|
||||
const generated = useId();
|
||||
const inputId = `choice-${generated}`;
|
||||
const listId = `${inputId}-options`;
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(-1);
|
||||
const { choices, loading, error: loadError } = useChoices(source, query, parent, !disabled && open);
|
||||
const chosen = Array.isArray(value) ? value : value ? [value] : [];
|
||||
const filtered = choices.filter(option => option.label.toLocaleLowerCase().includes(query.toLocaleLowerCase()) || option.id.toLocaleLowerCase().includes(query.toLocaleLowerCase()));
|
||||
const describedBy = [error ? `${inputId}-error` : "", loadError ? `${inputId}-load-error` : ""].filter(Boolean).join(" ") || undefined;
|
||||
|
||||
return <div className="space-y-1">
|
||||
<label htmlFor={inputId} className="block text-sm font-medium">{label}</label>
|
||||
<input id={inputId} role="combobox" aria-autocomplete="list" aria-expanded={open} aria-controls={listId}
|
||||
aria-activedescendant={open && filtered[active] ? `${listId}-${active}` : undefined}
|
||||
aria-invalid={Boolean(error || loadError)} aria-required={required || undefined} aria-describedby={describedBy} autoComplete="off"
|
||||
className="w-full rounded-lg border border-slate-300 bg-white p-3 text-slate-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700 disabled:bg-slate-100"
|
||||
value={open ? query : multiple ? "" : selectedLabel || (chosen[0] ? choices.find(o => o.id === chosen[0])?.label ?? chosen[0] : "")}
|
||||
placeholder={disabled ? "Seleccione primeiro o campo anterior" : "Pesquisar opções…"} disabled={disabled}
|
||||
onFocus={() => { setOpen(true); setQuery(""); }}
|
||||
onBlur={() => { window.setTimeout(() => setOpen(false), 120); }}
|
||||
onChange={event => { setQuery(event.target.value); setActive(0); setOpen(true); }}
|
||||
onKeyDown={event => {
|
||||
if (event.key === "ArrowDown") { event.preventDefault(); setOpen(true); setActive(i => Math.min(i + 1, filtered.length - 1)); }
|
||||
if (event.key === "ArrowUp") { event.preventDefault(); setActive(i => Math.max(0, i - 1)); }
|
||||
if (event.key === "Enter" && open && filtered[active]) { event.preventDefault(); const option = filtered[active]; if (multiple) { if (!chosen.includes(option.id)) onChange([...chosen, option.id], [...(Array.isArray(selectedLabel) ? selectedLabel : []), option.label]); setQuery(""); } else { onChange(option.id, option.label); setQuery(""); setOpen(false); } }
|
||||
if (event.key === "Escape") { setOpen(false); setQuery(""); }
|
||||
if (event.key === "Backspace" && multiple && query === "" && chosen.length) onChange(chosen.slice(0, -1), Array.isArray(selectedLabel) ? selectedLabel.slice(0, -1) : []);
|
||||
}} />
|
||||
{multiple && chosen.length > 0 && <ul aria-label={`Opções seleccionadas: ${label}`} className="flex flex-wrap gap-2">
|
||||
{chosen.map((item, index) => { const text = Array.isArray(selectedLabel) ? selectedLabel[index] : choices.find(option => option.id === item)?.label ?? item; return <li key={item} className="flex items-center gap-1 rounded bg-emerald-50 px-2 py-1 text-sm">
|
||||
{text}
|
||||
<button type="button" aria-label={`Remover ${text}`} onClick={() => onChange(chosen.filter((_, i) => i !== index), Array.isArray(selectedLabel) ? selectedLabel.filter((_, i) => i !== index) : [])} className="rounded px-1 focus-visible:outline-2">×</button>
|
||||
</li>; })}
|
||||
</ul>}
|
||||
{open && !disabled && <ul id={listId} role="listbox" aria-label={label} aria-multiselectable={multiple || undefined} className="max-h-56 overflow-auto rounded border bg-white shadow-lg">
|
||||
{loading && <li role="status" className="p-3 text-sm">A carregar opções…</li>}
|
||||
{!loading && filtered.map((option, index) => <li id={`${listId}-${index}`} key={option.id} role="option" aria-selected={chosen.includes(option.id)}
|
||||
className={`cursor-pointer p-3 text-sm hover:bg-emerald-50 ${active === index ? "bg-emerald-50" : ""}`}
|
||||
onMouseEnter={() => setActive(index)} onMouseDown={event => event.preventDefault()}
|
||||
onClick={() => { if (multiple) { if (!chosen.includes(option.id)) onChange([...chosen, option.id], [...(Array.isArray(selectedLabel) ? selectedLabel : []), option.label]); setQuery(""); } else { onChange(option.id, option.label); setQuery(""); setOpen(false); } }}>
|
||||
{option.label}{chosen.includes(option.id) ? " ✓" : ""}
|
||||
</li>)}
|
||||
{!loading && filtered.length === 0 && <li className="p-3 text-sm">Sem opções correspondentes.</li>}
|
||||
</ul>}
|
||||
{loadError && <p id={`${inputId}-load-error`} role="alert" className="text-sm text-red-800">{loadError}</p>}
|
||||
{error && <p id={`${inputId}-error`} role="alert" className="text-sm text-red-800">{error}</p>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function SimulationForm() {
|
||||
const [draft, setDraft] = useState<SimulationDraft>({});
|
||||
const [ready, setReady] = useState(false);
|
||||
const [step, setStep] = useState(0);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [saveStatus, setSaveStatus] = useState("Rascunho ainda não guardado.");
|
||||
const [serverError, setServerError] = useState("");
|
||||
const [isCalculating, setIsCalculating] = useState(false);
|
||||
const [hasSaveConflict, setHasSaveConflict] = useState(false);
|
||||
const [result, setResult] = useState<Record<string, unknown> | null>(null);
|
||||
const calculationInFlightRef = useRef(false);
|
||||
const draftIdRef = useRef<string | null>(null);
|
||||
const draftEtagRef = useRef<string | null>(null);
|
||||
const lastSavedFingerprintRef = useRef<string | null>(null);
|
||||
const saveQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const saveConflictRef = useRef(false);
|
||||
const current = simulationSections[step];
|
||||
const fields = (simulationFieldGroups[current.id] ?? []).filter(field => !field.showWhen || field.showWhen(draft));
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
let local: SimulationDraft = {};
|
||||
try {
|
||||
const stored = sessionStorage.getItem(draftKey);
|
||||
if (stored) local = JSON.parse(stored) as SimulationDraft;
|
||||
} catch { setSaveStatus("Não foi possível recuperar o rascunho desta sessão."); }
|
||||
try {
|
||||
const response = await fetch("/api/v1/simulation-drafts/current", {
|
||||
credentials: "same-origin", headers: { Accept: "application/json", "X-Simulation-Token": getSimulationToken() },
|
||||
});
|
||||
if (response.ok) {
|
||||
const payload = await response.json();
|
||||
if (payload.data) {
|
||||
draftIdRef.current = payload.data.id;
|
||||
draftEtagRef.current = response.headers.get("ETag");
|
||||
sessionStorage.setItem(draftIdKey, payload.data.id);
|
||||
const restored = restoreSimulationDraft(payload.data.economic_snapshot, payload.data.personal_snapshot);
|
||||
lastSavedFingerprintRef.current = simulationDraftFingerprint(restored);
|
||||
setDraft({ ...restored, ...local });
|
||||
setSaveStatus("Rascunho recuperado do servidor.");
|
||||
} else if (Object.keys(local).length) {
|
||||
const storedId = sessionStorage.getItem(draftIdKey);
|
||||
if (storedId) sessionStorage.removeItem(draftIdKey);
|
||||
setDraft(local);
|
||||
}
|
||||
} else if (Object.keys(local).length) setDraft(local);
|
||||
} catch {
|
||||
if (Object.keys(local).length) setDraft(local);
|
||||
setSaveStatus("Sem ligação ao servidor; o rascunho desta sessão será mantido localmente.");
|
||||
}
|
||||
if (!cancelled) setReady(true);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!ready || !draft.purpose) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
try {
|
||||
sessionStorage.setItem(draftKey, JSON.stringify(getAutosaveSnapshot(draft)));
|
||||
}
|
||||
catch { setSaveStatus("Não foi possível guardar o rascunho neste navegador."); }
|
||||
if (saveConflictRef.current) return;
|
||||
const snapshots = partitionSimulationDraft(draft);
|
||||
const fingerprint = simulationDraftFingerprint(draft);
|
||||
if (fingerprint === lastSavedFingerprintRef.current) return;
|
||||
setSaveStatus("A guardar o rascunho no servidor…");
|
||||
saveQueueRef.current = saveQueueRef.current.then(async () => {
|
||||
const token = getSimulationToken();
|
||||
const headers = { Accept: "application/json", "Content-Type": "application/json", "X-XSRF-TOKEN": csrfToken(), "X-Simulation-Token": token };
|
||||
if (!draftIdRef.current) {
|
||||
const currentResponse = await fetch("/api/v1/simulation-drafts/current", { credentials: "same-origin", headers: { Accept: "application/json", "X-Simulation-Token": token } });
|
||||
const currentPayload = currentResponse.ok ? await currentResponse.json() : { data: null };
|
||||
if (currentPayload.data) {
|
||||
draftIdRef.current = currentPayload.data.id;
|
||||
draftEtagRef.current = currentResponse.headers.get("ETag");
|
||||
sessionStorage.setItem(draftIdKey, currentPayload.data.id);
|
||||
}
|
||||
}
|
||||
let response: Response;
|
||||
if (draftIdRef.current && draftEtagRef.current) {
|
||||
response = await fetch(`/api/v1/simulation-drafts/${draftIdRef.current}`, {
|
||||
method: "PUT", credentials: "same-origin", headers: { ...headers, "If-Match": draftEtagRef.current }, body: JSON.stringify(snapshots),
|
||||
});
|
||||
} else {
|
||||
let key = sessionStorage.getItem(draftCreateKey);
|
||||
if (!key) { key = idempotencyKey(); sessionStorage.setItem(draftCreateKey, key); }
|
||||
response = await fetch("/api/v1/simulation-drafts", {
|
||||
method: "POST", credentials: "same-origin", headers: { ...headers, "Idempotency-Key": key }, body: JSON.stringify(snapshots),
|
||||
});
|
||||
}
|
||||
if (response.status === 412 || response.status === 428) {
|
||||
saveConflictRef.current = true;
|
||||
setHasSaveConflict(true);
|
||||
setSaveStatus("O rascunho mudou noutro separador. Recarregue para rever a versão mais recente.");
|
||||
return;
|
||||
}
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.message ?? "Não foi possível guardar o rascunho no servidor.");
|
||||
draftIdRef.current = payload.data.id;
|
||||
draftEtagRef.current = response.headers.get("ETag");
|
||||
lastSavedFingerprintRef.current = fingerprint;
|
||||
sessionStorage.setItem(draftIdKey, payload.data.id);
|
||||
sessionStorage.removeItem(draftCreateKey);
|
||||
setSaveStatus(`Rascunho guardado no servidor às ${new Intl.DateTimeFormat("pt-MZ", { timeStyle: "short" }).format(new Date())}.`);
|
||||
}).catch(cause => {
|
||||
setSaveStatus(cause instanceof Error ? cause.message : "Erro ao guardar o rascunho no servidor.");
|
||||
});
|
||||
}, 450);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [draft, ready]);
|
||||
|
||||
function change(key: string, value: string | string[], label?: string | string[]) {
|
||||
setDraft(currentDraft => {
|
||||
const next = replaceSimulationValue(currentDraft, key, value);
|
||||
if (label) next[`${key}_label`] = label;
|
||||
return next;
|
||||
});
|
||||
setErrors(current => { const next = { ...current }; delete next[key]; return next; });
|
||||
setResult(null);
|
||||
}
|
||||
|
||||
async function clearDraft() {
|
||||
await saveQueueRef.current;
|
||||
if (draftIdRef.current && draftEtagRef.current) {
|
||||
const response = await fetch(`/api/v1/simulation-drafts/${draftIdRef.current}`, {
|
||||
method: "DELETE", credentials: "same-origin",
|
||||
headers: { Accept: "application/json", "X-XSRF-TOKEN": csrfToken(), "X-Simulation-Token": getSimulationToken(), "If-Match": draftEtagRef.current },
|
||||
});
|
||||
if (!response.ok) { setSaveStatus("Não foi possível remover o rascunho do servidor. Recarregue e tente novamente."); return; }
|
||||
}
|
||||
for (const key of [draftKey, draftIdKey, draftCreateKey, idempotencyStorageKey, tokenKey]) sessionStorage.removeItem(key);
|
||||
draftIdRef.current = null;
|
||||
draftEtagRef.current = null;
|
||||
lastSavedFingerprintRef.current = null;
|
||||
saveConflictRef.current = false;
|
||||
setHasSaveConflict(false);
|
||||
setDraft({});
|
||||
setResult(null);
|
||||
setErrors({});
|
||||
setStep(0);
|
||||
setSaveStatus("Rascunho removido do servidor e desta sessão.");
|
||||
}
|
||||
|
||||
function navigate(nextStep: number) {
|
||||
if (nextStep > step) {
|
||||
for (let index = step; index < nextStep; index++) {
|
||||
const validation = validateSimulationDraft(draft, simulationSections[index].id);
|
||||
if (Object.keys(validation).length) {
|
||||
setStep(index);
|
||||
setErrors(validation);
|
||||
window.requestAnimationFrame(() => document.getElementById(`field-${Object.keys(validation)[0]}`)?.focus());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
setStep(nextStep);
|
||||
setErrors({});
|
||||
document.getElementById("simulator-step-title")?.focus();
|
||||
}
|
||||
|
||||
async function runSimulation() {
|
||||
if (calculationInFlightRef.current) return;
|
||||
const validation = validateSimulationDraft(draft, "tax");
|
||||
setErrors(validation);
|
||||
setServerError("");
|
||||
if (draft.tax_calculation !== "yes") return;
|
||||
if (Object.keys(validation).length) { document.getElementById(`field-${Object.keys(validation)[0]}`)?.focus(); return; }
|
||||
calculationInFlightRef.current = true;
|
||||
setIsCalculating(true);
|
||||
try {
|
||||
const token = getSimulationToken();
|
||||
const xsrf = csrfToken();
|
||||
const body = { base: draft.base, rate: draft.rate, reduction: draft.reduction };
|
||||
const serialized = JSON.stringify(body);
|
||||
const previous = sessionStorage.getItem(idempotencyStorageKey);
|
||||
let key = "";
|
||||
if (previous) {
|
||||
try { const saved = JSON.parse(previous) as { payload?: string; key?: string }; if (saved.payload === serialized && saved.key) key = saved.key; } catch { /* replace malformed local state */ }
|
||||
}
|
||||
if (!key) { key = idempotencyKey(); sessionStorage.setItem(idempotencyStorageKey, JSON.stringify({ payload: serialized, key })); }
|
||||
const response = await fetch("/api/v1/simulations", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "Accept": "application/json", "Content-Type": "application/json", "X-XSRF-TOKEN": xsrf, "Idempotency-Key": key, "X-Simulation-Token": token },
|
||||
body: serialized,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
const retryAfter = Number(response.headers.get("Retry-After") ?? 60);
|
||||
throw new Error(`Demasiadas tentativas. Aguarde ${retryAfter} segundos antes de voltar a calcular.`);
|
||||
}
|
||||
const serverErrors = payload.errors ? Object.values(payload.errors).flat().join(" ") : payload.message;
|
||||
throw new Error(typeof serverErrors === "string" ? serverErrors : "Não foi possível validar o cenário no servidor.");
|
||||
}
|
||||
const simulationId = payload.data?.id;
|
||||
let simulation = payload.data;
|
||||
if (simulationId && simulation?.status !== "completed" && simulation?.status !== "failed") {
|
||||
setSaveStatus("Cenário enviado. A aguardar o cálculo…");
|
||||
for (let attempt = 0; attempt < 12; attempt++) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 1000));
|
||||
const poll = await fetch(`/api/v1/simulations/${simulationId}`, { credentials: "same-origin", headers: { Accept: "application/json", "X-Simulation-Token": token } });
|
||||
if (!poll.ok) break;
|
||||
const pollPayload = await poll.json();
|
||||
simulation = pollPayload.data;
|
||||
if (simulation?.status === "completed" || simulation?.status === "failed") break;
|
||||
}
|
||||
}
|
||||
if (JSON.stringify({ base: draft.base, rate: draft.rate, reduction: draft.reduction }) === serialized) setResult(simulation ?? null);
|
||||
sessionStorage.removeItem(idempotencyStorageKey);
|
||||
} catch (cause) { setServerError(cause instanceof Error ? cause.message : "Erro de ligação. Tente novamente."); }
|
||||
finally { calculationInFlightRef.current = false; setIsCalculating(false); }
|
||||
}
|
||||
|
||||
return <section className="space-y-6 rounded-xl border bg-white p-4 sm:p-6" aria-labelledby="simulator-step-title">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div><h2 id="simulator-step-title" tabIndex={-1} className="text-xl font-bold outline-none">Formulário de simulação e manifestação</h2>
|
||||
<p className="mt-1 text-sm text-slate-700">Simulação informativa. Nenhuma taxa legal, elegibilidade ou moeda é presumida.</p></div>
|
||||
<p role="status" aria-live="polite" className="text-sm">{saveStatus}</p>
|
||||
</div>
|
||||
<nav aria-label="Etapas do formulário" className="overflow-x-auto">
|
||||
<ol className="flex min-w-max gap-2">
|
||||
{simulationSections.map((section, index) => <li key={section.id}><button type="button" aria-current={step === index ? "step" : undefined}
|
||||
onClick={() => navigate(index)} className={`rounded-full border px-3 py-2 text-sm focus-visible:outline-2 ${step === index ? "border-emerald-800 bg-emerald-800 text-white" : "bg-white"}`}>
|
||||
{index + 1}. {section.title}
|
||||
</button></li>)}
|
||||
</ol>
|
||||
</nav>
|
||||
<div className="h-2 overflow-hidden rounded bg-slate-200" role="progressbar" aria-label="Progresso do formulário" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(((step + 1) / simulationSections.length) * 100)}>
|
||||
<div className="h-full bg-emerald-800" style={{ width: `${((step + 1) / simulationSections.length) * 100}%` }} />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{current.title}</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{fields.map(field => {
|
||||
const fieldError = errors[field.key];
|
||||
if (field.options) {
|
||||
const parentKey = field.key === "district_id" ? "province_id" : field.key === "admin_post_id" ? "district_id" : field.key === "locality_id" ? "admin_post_id" : field.key === "activity_id" ? "sector_id" : "";
|
||||
return <div key={field.key} id={`field-${field.key}`} tabIndex={-1}>
|
||||
<SearchChoice label={`${field.label}${field.required ? " (obrigatório)" : ""}`} source={field.options} parent={parentKey ? String(draft[parentKey] ?? "") : ""}
|
||||
value={draft[field.key] ?? (field.multiple ? [] : "")} selectedLabel={draft[`${field.key}_label`] as string | string[] | undefined} multiple={field.multiple}
|
||||
disabled={Boolean(parentKey && !draft[parentKey])} error={fieldError} required={field.required}
|
||||
onChange={(value, text) => change(field.key, value, text)} />
|
||||
</div>;
|
||||
}
|
||||
const isArea = ["area_required"].includes(field.key);
|
||||
return <div key={field.key}>
|
||||
<label htmlFor={`field-${field.key}`} className="mb-1 block text-sm font-medium">{field.label}{field.required ? " (obrigatório)" : ""}</label>
|
||||
{field.type === "textarea" ? <textarea id={`field-${field.key}`} className="min-h-28 w-full rounded-lg border border-slate-300 p-3 focus-visible:outline-2 focus-visible:outline-emerald-700" value={String(draft[field.key] ?? "")} onChange={e => change(field.key, e.target.value)} aria-invalid={Boolean(fieldError)} aria-required={field.required || undefined} aria-describedby={fieldError ? `error-${field.key}` : undefined} />
|
||||
: <input id={`field-${field.key}`} type={field.type ?? (isArea ? "number" : "text")} min={field.type === "number" ? "0" : undefined} step={field.type === "number" ? "any" : undefined} className="w-full rounded-lg border border-slate-300 p-3 focus-visible:outline-2 focus-visible:outline-emerald-700" value={String(draft[field.key] ?? "")} onChange={e => change(field.key, e.target.value)} aria-invalid={Boolean(fieldError)} aria-required={field.required || undefined} aria-describedby={fieldError ? `error-${field.key}` : undefined} />}
|
||||
{fieldError && <p id={`error-${field.key}`} role="alert" className="mt-1 text-sm text-red-800">{fieldError}</p>}
|
||||
{field.key === "reduction" && <p className="mt-1 text-sm text-slate-600">Hipótese sua, não representa um benefício fiscal aprovado.</p>}
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
{current.id === "financial" && <p className="rounded bg-amber-50 p-3 text-sm">Os montantes são introduzidos pelo utilizador. Confirme que capital, dívida e subvenções reconciliam com o investimento total.</p>}
|
||||
{current.id === "tax" && <div className="space-y-3 border-t pt-4">
|
||||
<h4 className="font-semibold">Executar cenário aritmético</h4>
|
||||
{draft.tax_calculation === "yes" && <button type="button" disabled={isCalculating} aria-busy={isCalculating} className="rounded-lg bg-emerald-800 px-4 py-3 font-semibold text-white focus-visible:outline-2 disabled:cursor-wait disabled:opacity-60" onClick={() => void runSimulation()}>{isCalculating ? "A calcular…" : "Validar e calcular no servidor"}</button>}
|
||||
{serverError && <p role="alert" className="text-red-800">{serverError}</p>}
|
||||
{result && <div role="status" className="rounded bg-emerald-50 p-4"><p>Simulação {String(result.status ?? "recebida")}.</p>
|
||||
{result.status === "completed" && <p>Resultado aritmético indicativo: <strong>{String((result.result_snapshot as Record<string, unknown> | undefined)?.indicative_total ?? "disponível no relatório")}</strong></p>}
|
||||
{result.status === "failed" && <p role="alert">O cálculo não foi concluído. Reveja os valores e tente novamente.</p>}
|
||||
{result.status === "queued" || result.status === "running" ? <p className="text-sm">O cálculo continua pendente. Pode consultar o histórico quando estiver disponível.</p> : null}
|
||||
<p className="text-sm">O resultado é indicativo e não determina taxas legais, elegibilidade ou obrigação fiscal.</p></div>}
|
||||
</div>}
|
||||
{hasSaveConflict && <p role="alert" className="rounded border border-amber-700 bg-amber-50 p-3">Este rascunho foi alterado noutro separador. <button type="button" className="font-semibold underline" onClick={() => window.location.reload()}>Recarregar a versão mais recente</button></p>}
|
||||
<div className="flex flex-wrap justify-between gap-3 border-t pt-4">
|
||||
<button type="button" disabled={step === 0} onClick={() => navigate(Math.max(0, step - 1))} className="rounded border px-4 py-3 disabled:opacity-50 focus-visible:outline-2">Anterior</button>
|
||||
{step < simulationSections.length - 1
|
||||
? <button type="button" onClick={() => navigate(step + 1)} className="rounded-lg bg-emerald-800 px-5 py-3 font-semibold text-white focus-visible:outline-2">Guardar e continuar</button>
|
||||
: <button type="button" onClick={() => void clearDraft()} className="rounded border px-4 py-3 focus-visible:outline-2">Limpar este rascunho</button>}
|
||||
</div>
|
||||
<p className="text-xs text-slate-600">O rascunho é cifrado e guardado no servidor. O navegador mantém apenas o token temporário e os dados não identificativos necessários à recuperação da sessão. A submissão formal ainda não está disponível neste formulário.</p>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
export type SimulationDraft = Record<string, string | string[]>;
|
||||
|
||||
export const simulationSections = [
|
||||
{ id: "purpose", title: "Finalidade" },
|
||||
{ id: "promoter", title: "Perfil do promotor" },
|
||||
{ id: "identity", title: "Identificação e contactos" },
|
||||
{ id: "project", title: "Projecto ou negócio" },
|
||||
{ id: "location", title: "Localização" },
|
||||
{ id: "financial", title: "Estrutura financeira" },
|
||||
{ id: "impact", title: "Emprego e impacto" },
|
||||
{ id: "tax", title: "Operação fiscal" },
|
||||
{ id: "opportunity", title: "Necessidade de negócio" },
|
||||
{ id: "documents", title: "Documentos" },
|
||||
{ id: "review", title: "Revisão" },
|
||||
] as const;
|
||||
|
||||
export const simulationOptions: Record<string, string[]> = {
|
||||
purpose: ["simulate_only", "own_investment", "seek_investor", "seek_partner", "present_opportunity", "request_facilitation"],
|
||||
manifestation_type: ["investment", "business"],
|
||||
person_type: ["singular", "collective", "sole_trader", "consortium"],
|
||||
investor_origin: ["national", "foreign", "mixed"],
|
||||
entity_stage: ["existing", "being_formed"],
|
||||
preferred_language: ["pt-MZ", "en"],
|
||||
preferred_channel: ["portal", "email", "phone"],
|
||||
project_type: ["new", "expansion", "acquisition", "partnership", "concession", "supplier", "buyer", "other"],
|
||||
maturity_stage: ["idea", "concept", "pre_feasibility", "feasibility", "financing", "implementation", "operation"],
|
||||
land_status: ["owned", "duat", "leased", "requested", "unknown"],
|
||||
environmental_category: ["A", "B", "C", "por_determinar"],
|
||||
offer_or_demand: ["offer", "demand", "both"],
|
||||
opportunity_type: ["capital", "partner", "buyer", "supplier", "land", "technology", "licence", "services", "other"],
|
||||
confidentiality: ["public", "registered_users", "restricted", "confidential"],
|
||||
preferred_publication: ["yes", "no"],
|
||||
residency_status: ["resident", "non_resident"],
|
||||
tax_calculation: ["yes", "no"],
|
||||
document_types: ["identity_document", "company_registration", "nuit_certificate", "company_profile", "business_plan", "feasibility_study", "proof_of_funds", "special_zone_certificate", "licence", "site_map"],
|
||||
area_unit: ["m2", "ha", "km2"],
|
||||
};
|
||||
|
||||
export const simulationFieldGroups: Record<string, { key: string; label: string; type?: "number" | "date" | "email" | "textarea"; required?: boolean; options?: string; multiple?: boolean; showWhen?: (draft: SimulationDraft) => boolean }[]> = {
|
||||
purpose: [
|
||||
{ key: "purpose", label: "O que pretende fazer?", options: "purpose", required: true },
|
||||
{ key: "manifestation_type", label: "Tipo de manifestação", options: "manifestation_type", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "preferred_language", label: "Idioma preferido", options: "preferred_language", required: true },
|
||||
],
|
||||
promoter: [
|
||||
{ key: "person_type", label: "Tipo de pessoa", options: "person_type", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "investor_origin", label: "Origem do investidor", options: "investor_origin", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "tax_residency_country", label: "País de residência fiscal", options: "country", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "entity_stage", label: "Situação da entidade", options: "entity_stage", required: true, showWhen: d => d.purpose !== "simulate_only" && ["collective", "consortium"].includes(String(d.person_type)) },
|
||||
{ key: "legal_form", label: "Forma jurídica (conforme o registo)", required: true, showWhen: d => d.purpose !== "simulate_only" && d.entity_stage === "existing" },
|
||||
{ key: "nuit", label: "NUIT", showWhen: d => d.purpose !== "simulate_only" },
|
||||
],
|
||||
identity: [
|
||||
{ key: "legal_name", label: "Nome / denominação", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "commercial_name", label: "Nome comercial", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "registration_number", label: "Número de registo", required: true, showWhen: d => d.entity_stage === "existing" && d.purpose !== "simulate_only" },
|
||||
{ key: "representative_name", label: "Nome do representante", required: true, showWhen: d => ["collective", "consortium"].includes(String(d.person_type)) && d.purpose !== "simulate_only" },
|
||||
{ key: "email", label: "Correio electrónico", type: "email", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "phone", label: "Telefone", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "address", label: "Endereço", type: "textarea", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "preferred_channel", label: "Canal de contacto", options: "preferred_channel", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
],
|
||||
project: [
|
||||
{ key: "project_title", label: "Título do projecto", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "project_summary", label: "Resumo executivo", type: "textarea", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "project_type", label: "Tipo de projecto", options: "project_type", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "project_type_other", label: "Especifique o tipo de projecto", required: true, showWhen: d => d.purpose !== "simulate_only" && d.project_type === "other" },
|
||||
{ key: "sector_id", label: "Sector de actividade", options: "sector", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "activity_id", label: "Actividade económica", options: "activity", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "products_services", label: "Produtos e serviços", type: "textarea", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "maturity_stage", label: "Fase de maturidade", options: "maturity_stage", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "expected_start_date", label: "Data prevista de início", type: "date", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "project_duration_years", label: "Duração prevista (anos)", type: "number", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "existing_project_id", label: "Referência do projecto existente", showWhen: d => d.project_type === "expansion" },
|
||||
],
|
||||
location: [
|
||||
{ key: "province_id", label: "Província", options: "province", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "district_id", label: "Distrito", options: "district", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "admin_post_id", label: "Posto administrativo", options: "admin_post", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "locality_id", label: "Localidade", options: "locality", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "site_known", label: "Local conhecido?", options: "yes_no", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "land_status", label: "Situação do terreno", options: "land_status", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "area_required", label: "Área necessária", type: "number", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "area_unit", label: "Unidade da área", options: "area_unit", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "latitude", label: "Latitude", type: "number", showWhen: d => d.site_known === "yes" },
|
||||
{ key: "longitude", label: "Longitude", type: "number", showWhen: d => d.site_known === "yes" },
|
||||
{ key: "special_zone_id", label: "Zona especial ZEE/ZFI", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "infrastructure_needs", label: "Necessidades de infra-estrutura", options: "infrastructure", multiple: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
],
|
||||
financial: [
|
||||
{ key: "currency", label: "Moeda", options: "currency", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "total_investment", label: "Investimento total", type: "number", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "own_capital", label: "Capital próprio", type: "number", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "domestic_debt", label: "Financiamento interno", type: "number", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "foreign_debt", label: "Financiamento externo", type: "number", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "grants", label: "Subvenções", type: "number", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "secured_amount", label: "Montante mobilizado", type: "number", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "sought_amount", label: "Montante procurado", type: "number", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "annual_revenue", label: "Receitas anuais estimadas", type: "number", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "annual_operating_costs", label: "Custos operacionais anuais", type: "number", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "expected_profit", label: "Lucro esperado", type: "number", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "financing_term_months", label: "Prazo de financiamento (meses)", type: "number", showWhen: d => Number(d.domestic_debt) + Number(d.foreign_debt) > 0 },
|
||||
{ key: "funds_origin_countries", label: "Países de origem dos fundos", options: "country", multiple: true, showWhen: d => Number(d.foreign_debt) > 0 },
|
||||
],
|
||||
impact: [
|
||||
{ key: "direct_jobs", label: "Empregos directos", type: "number", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "indirect_jobs", label: "Empregos indirectos", type: "number", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "national_jobs", label: "Empregos nacionais", type: "number", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "foreign_jobs", label: "Empregos estrangeiros", type: "number", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "exports_value", label: "Exportações estimadas", type: "number" },
|
||||
{ key: "import_substitution_value", label: "Substituição de importações estimada", type: "number" },
|
||||
{ key: "skills_needed", label: "Qualificações/competências necessárias", type: "textarea" },
|
||||
{ key: "local_content_percentage", label: "Conteúdo local (%)", type: "number" },
|
||||
{ key: "environmental_category", label: "Categoria ambiental", options: "environmental_category" },
|
||||
{ key: "community_benefits", label: "Benefícios comunitários", type: "textarea" },
|
||||
],
|
||||
tax: [
|
||||
{ key: "tax_calculation", label: "Pretende calcular um cenário aritmético?", options: "tax_calculation", required: true },
|
||||
{ key: "base", label: "Base para o cenário", type: "number", required: true, showWhen: d => d.tax_calculation === "yes" },
|
||||
{ key: "rate", label: "Taxa hipotética do cenário (%)", type: "number", required: true, showWhen: d => d.tax_calculation === "yes" },
|
||||
{ key: "reduction", label: "Redução hipotética (%)", type: "number", required: true, showWhen: d => d.tax_calculation === "yes" },
|
||||
{ key: "sales_taxable", label: "Vendas tributadas", type: "number", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "sales_exempt", label: "Vendas isentas", type: "number", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "sales_zero_rate", label: "Vendas à taxa zero", type: "number", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "exports", label: "Exportações", type: "number", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "deductible_costs", label: "Custos dedutíveis", type: "number", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "non_deductible_costs", label: "Custos não dedutíveis", type: "number", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "carried_losses", label: "Prejuízos transitados", type: "number", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "payroll", label: "Dados de remunerações (estrutura prevista)", type: "textarea", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "outbound_payments", label: "Pagamentos ao exterior (estrutura prevista)", type: "textarea", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "imports", label: "Importações (estrutura prevista)", type: "textarea", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "claimed_benefits", label: "Benefícios alegados para validação", type: "textarea", showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "reference_date", label: "Data de referência", type: "date", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
],
|
||||
opportunity: [
|
||||
{ key: "offer_or_demand", label: "Oferta ou procura", options: "offer_or_demand", showWhen: d => d.manifestation_type === "business" },
|
||||
{ key: "opportunity_type", label: "Necessidade de negócio", options: "opportunity_type", showWhen: d => d.manifestation_type === "business" },
|
||||
{ key: "opportunity_type_other", label: "Especifique a necessidade", required: true, showWhen: d => d.manifestation_type === "business" && d.opportunity_type === "other" },
|
||||
{ key: "opportunity_description", label: "Descrição da necessidade", type: "textarea", showWhen: d => d.manifestation_type === "business" },
|
||||
{ key: "opportunity_value", label: "Valor da oportunidade", type: "number", showWhen: d => d.manifestation_type === "business" },
|
||||
{ key: "opportunity_currency", label: "Moeda da oportunidade", options: "currency", showWhen: d => d.manifestation_type === "business" && Boolean(d.opportunity_value) },
|
||||
{ key: "opportunity_conditions", label: "Condições da oportunidade", type: "textarea", showWhen: d => d.manifestation_type === "business" },
|
||||
{ key: "opportunity_deadline", label: "Prazo da oportunidade", type: "date", showWhen: d => d.manifestation_type === "business" },
|
||||
{ key: "confidentiality", label: "Confidencialidade", options: "confidentiality", showWhen: d => d.manifestation_type === "business" },
|
||||
{ key: "publish_requested", label: "Pretende pedir publicação?", options: "preferred_publication", showWhen: d => d.manifestation_type === "business" },
|
||||
],
|
||||
documents: [
|
||||
{ key: "required_documents", label: "Documentos aplicáveis a preparar", options: "document_types", multiple: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
],
|
||||
review: [
|
||||
{ key: "terms_accepted", label: "Aceito os termos de utilização", options: "yes_no", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "truth_declaration", label: "Declaro que as informações são verdadeiras", options: "yes_no", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "reuse_simulation", label: "Autorizo reutilizar os dados da simulação", options: "yes_no", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "contact_authorisation", label: "Autorizo contacto para acompanhamento", options: "yes_no", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "publication_authorisation", label: "Autorizo a publicação solicitada", options: "yes_no", required: true, showWhen: d => d.publish_requested === "yes" },
|
||||
{ key: "public_fields_confirmation", label: "Confirmo os campos propostos para publicação", options: "yes_no", required: true, showWhen: d => d.publish_requested === "yes" },
|
||||
{ key: "validation_acknowledged", label: "Confirmo que li os avisos de validação", options: "yes_no", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
{ key: "final_declaration_name", label: "Nome para a declaração final", required: true, showWhen: d => d.purpose !== "simulate_only" },
|
||||
],
|
||||
};
|
||||
|
||||
simulationOptions.yes_no = ["yes", "no"];
|
||||
simulationOptions.infrastructure = ["energia", "água", "estrada", "porto", "ferrovia", "telecomunicações", "armazenamento"];
|
||||
|
||||
export function replaceSimulationValue(draft: SimulationDraft, key: string, value: string | string[]): SimulationDraft {
|
||||
const next = { ...draft, [key]: value };
|
||||
const dependencies: Record<string, string[]> = {
|
||||
province_id: ["district_id", "admin_post_id", "locality_id"],
|
||||
district_id: ["admin_post_id", "locality_id"],
|
||||
admin_post_id: ["locality_id"],
|
||||
sector_id: ["activity_id"],
|
||||
site_known: ["latitude", "longitude"],
|
||||
project_type: ["project_type_other"],
|
||||
opportunity_type: ["opportunity_type_other"],
|
||||
foreign_debt: ["funds_origin_countries"],
|
||||
tax_calculation: ["base", "rate", "reduction"],
|
||||
};
|
||||
const clearChildren = key === "foreign_debt" && Number(value) > 0 && Number(draft[key] ?? 0) > 0 ? [] : (dependencies[key] ?? []);
|
||||
for (const child of clearChildren) {
|
||||
delete next[child];
|
||||
delete next[`${child}_label`];
|
||||
}
|
||||
if (key === "purpose" && value === "simulate_only") {
|
||||
for (const fields of Object.values(simulationFieldGroups)) for (const field of fields) {
|
||||
if (["purpose", "preferred_language"].includes(field.key)) continue;
|
||||
delete next[field.key];
|
||||
delete next[`${field.key}_label`];
|
||||
}
|
||||
}
|
||||
if (key === "manifestation_type" && value !== "business") {
|
||||
for (const field of simulationFieldGroups.opportunity) { delete next[field.key]; delete next[`${field.key}_label`]; }
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
const privateDraftKeys = new Set([
|
||||
"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",
|
||||
"contact_province", "contact_district", "preferred_channel",
|
||||
]);
|
||||
|
||||
export function getAutosaveSnapshot(draft: SimulationDraft): SimulationDraft {
|
||||
return Object.fromEntries(Object.entries(draft).filter(([key]) => {
|
||||
const field = key.replace(/_label$/, "");
|
||||
return !privateDraftKeys.has(field);
|
||||
}));
|
||||
}
|
||||
|
||||
export function partitionSimulationDraft(draft: SimulationDraft): {
|
||||
personal_snapshot: Record<string, unknown>;
|
||||
economic_snapshot: Record<string, unknown>;
|
||||
} {
|
||||
const personal_snapshot: Record<string, unknown> = {};
|
||||
const economic_snapshot: Record<string, unknown> = {};
|
||||
const personal_labels: Record<string, string | string[]> = {};
|
||||
const economic_labels: Record<string, string | string[]> = {};
|
||||
for (const [key, value] of Object.entries(draft)) {
|
||||
if (key.endsWith("_label")) {
|
||||
const target = privateDraftKeys.has(key.slice(0, -6)) ? personal_labels : economic_labels;
|
||||
target[key.slice(0, -6)] = value;
|
||||
continue;
|
||||
}
|
||||
(privateDraftKeys.has(key) ? personal_snapshot : economic_snapshot)[key] = value;
|
||||
}
|
||||
if (Object.keys(personal_labels).length) personal_snapshot.selection_labels = personal_labels;
|
||||
if (Object.keys(economic_labels).length) economic_snapshot.selection_labels = economic_labels;
|
||||
return { personal_snapshot, economic_snapshot };
|
||||
}
|
||||
|
||||
export function restoreSimulationDraft(economic: Record<string, unknown>, personal: Record<string, unknown>): SimulationDraft {
|
||||
const draft = { ...economic, ...personal } as Record<string, unknown>;
|
||||
const labels = { ...(economic.selection_labels as Record<string, unknown> | undefined), ...(personal.selection_labels as Record<string, unknown> | undefined) };
|
||||
delete draft.selection_labels;
|
||||
delete draft.personal_snapshot;
|
||||
if (labels && typeof labels === "object") {
|
||||
for (const [key, value] of Object.entries(labels)) {
|
||||
if (typeof value === "string" || (Array.isArray(value) && value.every(item => typeof item === "string"))) {
|
||||
draft[`${key}_label`] = Array.isArray(draft[key]) ? value : Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return draft as SimulationDraft;
|
||||
}
|
||||
|
||||
export function simulationDraftFingerprint(draft: SimulationDraft): string {
|
||||
const sort = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(sort);
|
||||
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, entry]) => [key, sort(entry)]));
|
||||
return value;
|
||||
};
|
||||
return JSON.stringify(sort(partitionSimulationDraft(draft)));
|
||||
}
|
||||
|
||||
export function validateSimulationDraft(draft: SimulationDraft, sectionId: string): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
for (const field of simulationFieldGroups[sectionId] ?? []) {
|
||||
if (field.showWhen && !field.showWhen(draft)) continue;
|
||||
const value = draft[field.key];
|
||||
const empty = value === undefined || value === "" || (Array.isArray(value) && value.length === 0);
|
||||
if (field.required && empty) errors[field.key] = "Este campo é obrigatório.";
|
||||
if (field.type === "number" && value !== undefined && value !== "") {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number < 0) errors[field.key] = "Introduza um número igual ou superior a zero.";
|
||||
}
|
||||
if (["rate", "reduction", "local_content_percentage"].includes(field.key) && value !== undefined && value !== "") {
|
||||
if (Number(value) > 100) errors[field.key] = "O valor não pode exceder 100.";
|
||||
}
|
||||
if (field.key === "email" && typeof value === "string" && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) errors[field.key] = "Introduza um endereço de correio electrónico válido.";
|
||||
}
|
||||
if (sectionId === "financial" && ["own_capital", "domestic_debt", "foreign_debt", "grants", "total_investment"].every(key => draft[key] !== undefined && draft[key] !== "")) {
|
||||
const total = ["own_capital", "domestic_debt", "foreign_debt", "grants"].reduce((sum, key) => sum + Number(draft[key]), 0);
|
||||
if (Math.abs(total - Number(draft.total_investment)) > 0.01) errors.total_investment = "A soma do capital, dívida e subvenções deve corresponder ao investimento total.";
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
export declare function replaceTerritoryPath<T>(
|
||||
path: readonly T[],
|
||||
index: number,
|
||||
selected?: T | null,
|
||||
): T[];
|
||||
@@ -0,0 +1,3 @@
|
||||
export function replaceTerritoryPath(path, index, selected = null) {
|
||||
return [...path.slice(0, index), ...(selected ? [selected] : [])];
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { newSubmissionKey } from "@/lib/portal-api";
|
||||
import { Frame, Field, button, card, input } from "./shared";
|
||||
|
||||
type Opportunity = { id: string; slug: string; title: string; sector?: { name: string } | null };
|
||||
type Props = { record: Opportunity; modalities: string[] };
|
||||
type Form = {
|
||||
fullName: string; email: string; phone: string; investorType: string; country: string;
|
||||
sectorInterest: string; modality: string; investmentRange: string; message: string; privacyConsent: boolean; shareWithProponent: boolean;
|
||||
};
|
||||
|
||||
export default function BusinessInterest({ record, modalities }: Props) {
|
||||
const [form, setForm] = useState<Form>({ fullName: "", email: "", phone: "", investorType: "empresa", country: "MZ", sectorInterest: record.sector?.name ?? "", modality: modalities[0] ?? "", investmentRange: "", message: "", privacyConsent: false, shareWithProponent: false });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [receipt, setReceipt] = useState("");
|
||||
const key = useRef(newSubmissionKey());
|
||||
const update = <K extends keyof Form>(field: K, value: Form[K]) => setForm((current) => ({ ...current, [field]: value }));
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!form.privacyConsent) { setError("Aceite o tratamento de dados para enviar o pedido."); return; }
|
||||
setBusy(true); setError("");
|
||||
const xsrf = decodeURIComponent(document.cookie.split("; ").find((cookie) => cookie.startsWith("XSRF-TOKEN="))?.slice(11) ?? "");
|
||||
try {
|
||||
const response = await fetch("/api/v1/interest-requests", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json", "X-XSRF-TOKEN": xsrf, "Idempotency-Key": key.current },
|
||||
body: JSON.stringify({ ...form, existingBusinessId: record.id }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message ?? "Não foi possível enviar a manifestação.");
|
||||
setReceipt(data.data.reference);
|
||||
key.current = newSubmissionKey();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Não foi possível enviar a manifestação.");
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
return (
|
||||
<Frame title="Manifestar interesse">
|
||||
<p>Oportunidade: <strong>{record.title}</strong></p>
|
||||
{receipt ? <section className={card} role="status"><h2 className="text-xl font-bold">Pedido recebido</h2><p>Referência: {receipt}</p><p>A equipa fará a triagem antes de qualquer partilha com o proponente.</p></section> : (
|
||||
<form className={`${card} space-y-4`} onSubmit={submit}>
|
||||
<Field label="Nome ou denominação"><input className={input} required minLength={3} maxLength={150} value={form.fullName} onChange={(e) => update("fullName", e.target.value)} /></Field>
|
||||
<Field label="Email"><input className={input} type="email" required maxLength={150} value={form.email} onChange={(e) => update("email", e.target.value)} /></Field>
|
||||
<Field label="Telefone"><input className={input} type="tel" maxLength={30} value={form.phone} onChange={(e) => update("phone", e.target.value)} /></Field>
|
||||
<Field label="Tipo de investidor"><select className={input} value={form.investorType} onChange={(e) => update("investorType", e.target.value)}><option value="empresa">Empresa</option><option value="individual">Pessoa singular</option><option value="fundo">Fundo</option><option value="entidade_publica">Entidade pública</option><option value="outro">Outro</option></select></Field>
|
||||
<Field label="País"><input className={input} required maxLength={100} value={form.country} onChange={(e) => update("country", e.target.value)} /></Field>
|
||||
<Field label="Sector de interesse"><input className={input} required maxLength={150} value={form.sectorInterest} onChange={(e) => update("sectorInterest", e.target.value)} /></Field>
|
||||
<Field label="Modalidade pretendida"><select className={input} required value={form.modality} onChange={(e) => update("modality", e.target.value)}>{modalities.map((modality) => <option key={modality} value={modality}>{modality.replaceAll("_", " ")}</option>)}</select></Field>
|
||||
<Field label="Intervalo de investimento"><input className={input} maxLength={80} value={form.investmentRange} onChange={(e) => update("investmentRange", e.target.value)} /></Field>
|
||||
<Field label="Motivação e pedido"><textarea className={input} required minLength={20} maxLength={2000} rows={5} value={form.message} onChange={(e) => update("message", e.target.value)} /></Field>
|
||||
<label className="flex items-start gap-3"><input type="checkbox" required checked={form.privacyConsent} onChange={(e) => update("privacyConsent", e.target.checked)} /><span>Autorizo o tratamento destes dados para responder a esta manifestação. O envio não confirma elegibilidade nem permite partilha automática com o proponente.</span></label>
|
||||
<label className="flex items-start gap-3"><input type="checkbox" checked={form.shareWithProponent} onChange={(e) => update("shareWithProponent", e.target.checked)} /><span>Autorizo, se necessário para avaliar esta manifestação, a partilha do meu nome, email, telefone e mensagem com o proponente. Esta autorização é opcional e pode ser retirada junto da equipa.</span></label>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<button className={button} disabled={busy}>{busy ? "A enviar…" : "Enviar manifestação"}</button>
|
||||
</form>
|
||||
)}
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Link, router } from "@inertiajs/react";
|
||||
import { useState } from "react";
|
||||
import { Frame, button, card, input } from "./shared";
|
||||
|
||||
type PublicBusiness = {
|
||||
id: string; slug: string; title: string; objective?: string | null; stage?: string | null;
|
||||
amount_min?: string | null; amount_max?: string | null; currency?: string | null;
|
||||
sector?: { name: string; slug: string } | null; province?: string | null;
|
||||
};
|
||||
type Props = { results: { data: PublicBusiness[]; links: { url: string | null; label: string; active: boolean }[] }; filters: { q?: string } };
|
||||
|
||||
export default function BusinessMarketplace({ results, filters }: Props) {
|
||||
const [query, setQuery] = useState(filters.q ?? "");
|
||||
return (
|
||||
<Frame title="Oportunidades de negócio">
|
||||
<p>Explore oportunidades aprovadas para publicação. A informação está sujeita a diligência independente.</p>
|
||||
<form className="flex flex-wrap gap-3" onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
router.get("/negocios", { q: query || undefined }, { preserveState: true, preserveScroll: true });
|
||||
}}>
|
||||
<label className="sr-only" htmlFor="business-search">Pesquisar negócios</label>
|
||||
<input id="business-search" className={input} value={query} maxLength={120} onChange={(event) => setQuery(event.target.value)} />
|
||||
<button className={button}>Pesquisar</button>
|
||||
</form>
|
||||
{!results.data.length && <p role="status">Não existem oportunidades publicadas com estes critérios.</p>}
|
||||
<ul className="grid gap-4 md:grid-cols-2">
|
||||
{results.data.map((record) => (
|
||||
<li className={card} key={record.id}>
|
||||
<h2 className="text-xl font-bold"><Link className="underline" href={`/negocios/${record.slug}`}>{record.title}</Link></h2>
|
||||
<p>{[record.sector?.name, record.province, record.stage].filter(Boolean).join(" · ")}</p>
|
||||
{(record.amount_min || record.amount_max) && <p>{record.amount_min ?? "—"} – {record.amount_max ?? "—"} {record.currency}</p>}
|
||||
{record.objective && <p className="mt-3 line-clamp-3">{record.objective}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<nav aria-label="Paginação das oportunidades" className="flex flex-wrap gap-3">
|
||||
{results.links.map((link) => link.url ? <Link key={link.label} href={link.url} className={link.active ? "font-bold underline" : "underline"} dangerouslySetInnerHTML={{ __html: link.label }} /> : null)}
|
||||
</nav>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Link } from "@inertiajs/react";
|
||||
import { Frame, button, card } from "./shared";
|
||||
|
||||
type Business = {
|
||||
slug: string; title: string; objective?: string | null; stage?: string | null; activity_code?: string | null;
|
||||
amount_min?: string | null; amount_max?: string | null; available_percentage?: string | null; currency?: string | null;
|
||||
province?: string | null; district?: string | null; locality?: string | null; sector?: { name: string; slug: string } | null;
|
||||
};
|
||||
|
||||
export default function BusinessOpportunity({ record }: { record: Business }) {
|
||||
return (
|
||||
<Frame title={record.title}>
|
||||
<Link href="/negocios" className="underline">Voltar às oportunidades</Link>
|
||||
<section className={`${card} space-y-4`}>
|
||||
<p>{[record.sector?.name, record.stage, record.activity_code].filter(Boolean).join(" · ")}</p>
|
||||
{(record.province || record.district || record.locality) && <p>{[record.locality, record.district, record.province].filter(Boolean).join(", ")}</p>}
|
||||
{(record.amount_min || record.amount_max) && <p>Montante indicativo: {record.amount_min ?? "—"} – {record.amount_max ?? "—"} {record.currency}</p>}
|
||||
{record.available_percentage && <p>Participação indicada: {record.available_percentage}%</p>}
|
||||
{record.objective && <div><h2 className="font-bold">Objectivo</h2><p className="whitespace-pre-wrap">{record.objective}</p></div>}
|
||||
<p className="text-sm">A publicação não constitui garantia de titularidade, avaliação, transmissibilidade ou retorno.</p>
|
||||
<Link className={button} href={`/negocios/${record.slug}/manifestar-interesse`}>Manifestar interesse</Link>
|
||||
</section>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,21 @@ export type Business = {
|
||||
classification: string;
|
||||
version_no: number;
|
||||
sector_id?: number | null;
|
||||
public_title?: string | null;
|
||||
activity_code?: string | null;
|
||||
stage?: string | null;
|
||||
objective?: string | null;
|
||||
currency?: string | null;
|
||||
amount_min?: string | null;
|
||||
amount_max?: string | null;
|
||||
available_percentage?: string | null;
|
||||
funds_use?: string | null;
|
||||
submitted_at?: string | null;
|
||||
published_at?: string | null;
|
||||
expires_at?: string | null;
|
||||
next_action?: string | null;
|
||||
next_action_due_at?: string | null;
|
||||
assigned_to?: number | null;
|
||||
payload: {
|
||||
[key: string]: string | undefined;
|
||||
title: string;
|
||||
@@ -21,6 +36,17 @@ export const businessStates: Record<string, string> = {
|
||||
in_review: "Em revisão",
|
||||
needs_information: "Pendente de informação",
|
||||
withdrawn: "Retirado",
|
||||
triage: "Triagem",
|
||||
information_pending: "Informação pendente",
|
||||
review: "Em revisão",
|
||||
approval_l1: "Aprovação nível 1",
|
||||
approval_l2: "Aprovação nível 2",
|
||||
approved: "Aprovado",
|
||||
published: "Publicado",
|
||||
suspended: "Suspenso",
|
||||
rejected: "Rejeitado",
|
||||
cancelled: "Cancelado",
|
||||
archived: "Arquivado",
|
||||
};
|
||||
export const businessModalities: Record<string, string> = {
|
||||
company_sale: "Venda da empresa",
|
||||
|
||||
@@ -52,6 +52,7 @@ export default function BusinessWizard({
|
||||
});
|
||||
const [modality, setModality] = useState(record.modality);
|
||||
const [sectorId, setSectorId] = useState(record.sector_id ?? null);
|
||||
const [publicTitle, setPublicTitle] = useState(record.public_title ?? "");
|
||||
const [version, setVersion] = useState(record.version_no);
|
||||
const versionRef = useRef(record.version_no);
|
||||
const saved = useRef(
|
||||
@@ -59,6 +60,7 @@ export default function BusinessWizard({
|
||||
payload: record.payload,
|
||||
modality: record.modality,
|
||||
sectorId: record.sector_id ?? null,
|
||||
publicTitle: record.public_title ?? "",
|
||||
}),
|
||||
);
|
||||
const inFlight = useRef(false);
|
||||
@@ -68,7 +70,7 @@ export default function BusinessWizard({
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
const heading = useRef<HTMLHeadingElement>(null);
|
||||
const step = Math.max(1, Math.min(8, Number(payload.wizard_step ?? 1)));
|
||||
const serialized = JSON.stringify({ payload, modality, sectorId });
|
||||
const serialized = JSON.stringify({ payload, modality, sectorId, publicTitle });
|
||||
const dirty = serialized !== saved.current;
|
||||
const save = useCallback(async () => {
|
||||
if (inFlight.current) return false;
|
||||
@@ -82,6 +84,7 @@ export default function BusinessWizard({
|
||||
payload,
|
||||
modality,
|
||||
sector_id: sectorId,
|
||||
public_title: publicTitle || null,
|
||||
classification: record.classification,
|
||||
reason: "Gravação do formulário privado por etapas",
|
||||
version_no: versionRef.current,
|
||||
@@ -97,7 +100,7 @@ export default function BusinessWizard({
|
||||
inFlight.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}, [serialized, payload, modality, sectorId, record.id, record.classification]);
|
||||
}, [serialized, payload, modality, sectorId, publicTitle, record.id, record.classification]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty || busy || error) return;
|
||||
@@ -201,6 +204,20 @@ export default function BusinessWizard({
|
||||
>
|
||||
<fieldset className="space-y-4">
|
||||
<legend className="sr-only">{steps[step - 1][english ? 1 : 0]}</legend>
|
||||
{step === 3 && (
|
||||
<Field label={t("Título público proposto", "Proposed public title")}>
|
||||
<input
|
||||
className={input}
|
||||
value={publicTitle}
|
||||
maxLength={180}
|
||||
onChange={(event) => setPublicTitle(event.target.value)}
|
||||
aria-describedby="public-title-help"
|
||||
/>
|
||||
<span id="public-title-help" className="text-sm">
|
||||
{t("Será revisto antes de qualquer publicação.", "Reviewed before any publication.")}
|
||||
</span>
|
||||
</Field>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<Field label={t("Sector", "Sector")}>
|
||||
<select
|
||||
@@ -294,6 +311,10 @@ export default function BusinessWizard({
|
||||
)}
|
||||
{step === 8 && (
|
||||
<dl className="space-y-3 break-words">
|
||||
<div>
|
||||
<dt className="font-semibold">{t("Título público proposto", "Proposed public title")}</dt>
|
||||
<dd>{publicTitle || "—"}</dd>
|
||||
</div>
|
||||
{Object.entries(formSchema).map(([key, field]) => (
|
||||
<div key={key}>
|
||||
<dt className="font-semibold">
|
||||
|
||||
@@ -17,6 +17,10 @@ type Props = {
|
||||
canEdit: boolean;
|
||||
owner: boolean;
|
||||
canReview: boolean;
|
||||
canTriage: boolean;
|
||||
canApproveLevelOne: boolean;
|
||||
canApproveLevelTwo: boolean;
|
||||
canPublish: boolean;
|
||||
modalities: string[];
|
||||
objects: BusinessObject[];
|
||||
objectKinds: string[];
|
||||
@@ -34,6 +38,10 @@ function Editor({
|
||||
canEdit,
|
||||
owner,
|
||||
canReview,
|
||||
canTriage,
|
||||
canApproveLevelOne,
|
||||
canApproveLevelTwo,
|
||||
canPublish,
|
||||
modalities,
|
||||
history,
|
||||
objects,
|
||||
@@ -235,6 +243,51 @@ function Editor({
|
||||
Pedir informação
|
||||
</button>
|
||||
)}
|
||||
{canTriage && record.status === "submitted" && (
|
||||
<button className={button} disabled={busy || note.trim().length < 10} onClick={() => void run("triage")}>
|
||||
Iniciar triagem
|
||||
</button>
|
||||
)}
|
||||
{canReview && record.status === "triage" && (
|
||||
<button className={button} disabled={busy || note.trim().length < 10} onClick={() => void run("in_review")}>
|
||||
Encaminhar para revisão
|
||||
</button>
|
||||
)}
|
||||
{canReview && record.status === "in_review" && (
|
||||
<>
|
||||
<button className={button} disabled={busy || note.trim().length < 10} onClick={() => void run("approval_l1")}>
|
||||
Enviar para aprovação nÃvel 1
|
||||
</button>
|
||||
<button className={button} disabled={busy || note.trim().length < 10} onClick={() => void run("rejected")}>
|
||||
Rejeitar
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{canApproveLevelOne && record.status === "approval_l1" && (
|
||||
<>
|
||||
<button className={button} disabled={busy || note.trim().length < 10} onClick={() => void run("approval_l2")}>
|
||||
Aprovar nÃvel 1 e enviar ao nÃvel 2
|
||||
</button>
|
||||
<button className={button} disabled={busy || note.trim().length < 10} onClick={() => void run("rejected")}>
|
||||
Rejeitar
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{canApproveLevelTwo && record.status === "approval_l2" && (
|
||||
<>
|
||||
<button className={button} disabled={busy || note.trim().length < 10} onClick={() => void run("approved")}>
|
||||
Aprovar nÃvel 2
|
||||
</button>
|
||||
<button className={button} disabled={busy || note.trim().length < 10} onClick={() => void run("rejected")}>
|
||||
Rejeitar
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{canPublish && record.status === "approved" && (
|
||||
<button className={button} disabled={busy || note.trim().length < 10} onClick={() => void run("published")}>
|
||||
Publicar
|
||||
</button>
|
||||
)}
|
||||
{owner && record.status !== "withdrawn" && (
|
||||
<button
|
||||
className={button}
|
||||
|
||||
@@ -311,7 +311,7 @@ export default function Catalogue({
|
||||
index={index}
|
||||
parent={territoryPath[index - 1]?.id ?? ""}
|
||||
selected={territoryPath[index]}
|
||||
endpoint="/api/v1/investment/territory-options"
|
||||
endpoint="/api/v1/catalogs/jurisdictions"
|
||||
onChange={(value) => {
|
||||
const path = [
|
||||
...territoryPath.slice(0, index),
|
||||
|
||||
@@ -14,6 +14,11 @@ type Interest = {
|
||||
investment_range: string | null;
|
||||
message: string;
|
||||
status: string;
|
||||
existing_business_id: string | null;
|
||||
modality: string | null;
|
||||
assigned_to: number | null;
|
||||
next_action: string | null;
|
||||
next_action_due_at: string | null;
|
||||
consented_at: string | null;
|
||||
reviewed_at: string | null;
|
||||
reviewed_by: number | null;
|
||||
@@ -34,8 +39,13 @@ function dateTime(value: string | null) {
|
||||
timeZone: "Africa/Maputo",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
export default function InterestDetail({ interest }: { interest: Interest }) {
|
||||
const form = useForm({ status: interest.status });
|
||||
type Transition = { from_status: string | null; to_status: string; actor_id: number | null; note: string | null; created_at: string };
|
||||
type Assignee = { id: number; name: string };
|
||||
type Consent = { purpose: string; policy_version: string | null; status: string; granted_at: string | null; withdrawn_at: string | null };
|
||||
|
||||
export default function InterestDetail({ interest, history = [], assignees = [], consents = [] }: { interest: Interest; history?: Transition[]; assignees?: Assignee[]; consents?: Consent[] }) {
|
||||
const form = useForm({ status: interest.status, note: "", next_action: interest.next_action ?? "", next_action_due_at: interest.next_action_due_at?.slice(0, 10) ?? "", assigned_to: interest.assigned_to?.toString() ?? "" });
|
||||
const consentForm = useForm({ reason: "" });
|
||||
const rows: [string, string | number | null][] = [
|
||||
["Nome", interest.full_name],
|
||||
["Email", interest.email],
|
||||
@@ -44,6 +54,8 @@ export default function InterestDetail({ interest }: { interest: Interest }) {
|
||||
["País", interest.country],
|
||||
["Sector", interest.sector_interest],
|
||||
["Oportunidade", interest.opportunity_reference],
|
||||
["Negócio publicado", interest.existing_business_id],
|
||||
["Modalidade", interest.modality],
|
||||
["Faixa de investimento", interest.investment_range],
|
||||
["Recebido em (Maputo)", dateTime(interest.created_at)],
|
||||
["Consentimento em (Maputo)", dateTime(interest.consented_at)],
|
||||
@@ -90,6 +102,27 @@ export default function InterestDetail({ interest }: { interest: Interest }) {
|
||||
<h2 className="font-semibold">Mensagem</h2>
|
||||
<p className="mt-2 break-words whitespace-pre-wrap">{interest.message}</p>
|
||||
</section>
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Consentimentos e versões</h2>
|
||||
{consents.map((consent, index) => (
|
||||
<article key={`${consent.purpose}-${index}`} className="rounded border p-3">
|
||||
<p>{consent.purpose}: {consent.status}</p>
|
||||
<p className="text-sm">Política {consent.policy_version ?? "—"} · concedido {dateTime(consent.granted_at)}</p>
|
||||
{consent.withdrawn_at && <p className="text-sm">Revogado {dateTime(consent.withdrawn_at)}</p>}
|
||||
</article>
|
||||
))}
|
||||
{consents.some((consent) => consent.purpose === "share_contact_with_proponent" && consent.status === "granted") && (
|
||||
<form className="max-w-sm space-y-3" onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
consentForm.patch(`/management/interests/${interest.id}/consent/proponent`);
|
||||
}}>
|
||||
<Field label="Motivo do pedido de revogação" error={consentForm.errors.reason}>
|
||||
<textarea className={control} minLength={10} maxLength={2000} required rows={3} value={consentForm.data.reason} onChange={(event) => consentForm.setData("reason", event.target.value)} />
|
||||
</Field>
|
||||
<button className={button} disabled={consentForm.processing}>Revogar autorização de partilha</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
<form
|
||||
className="max-w-sm space-y-4"
|
||||
onSubmit={(e) => {
|
||||
@@ -103,17 +136,42 @@ export default function InterestDetail({ interest }: { interest: Interest }) {
|
||||
value={form.data.status}
|
||||
onChange={(e) => form.setData("status", e.target.value)}
|
||||
>
|
||||
{["received", "in_review", "contacted", "closed"].map((key) => (
|
||||
{["received", "submitted", "in_review", "reviewed", "qualification", "qualified", "contacted", "contact_accepted", "referred", "due_diligence", "negotiation", "pending", "refused", "converted", "cancelled", "closed"].map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{states[key]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Nota de acompanhamento" error={form.errors.note}>
|
||||
<textarea className={control} rows={3} value={form.data.note} onChange={(e) => form.setData("note", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Próxima acção" error={form.errors.next_action}>
|
||||
<input className={control} value={form.data.next_action} onChange={(e) => form.setData("next_action", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Prazo da próxima acção" error={form.errors.next_action_due_at}>
|
||||
<input type="date" className={control} value={form.data.next_action_due_at} onChange={(e) => form.setData("next_action_due_at", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Responsável" error={form.errors.assigned_to}>
|
||||
<select className={control} value={form.data.assigned_to} onChange={(e) => form.setData("assigned_to", e.target.value)}>
|
||||
<option value="">Sem responsável</option>
|
||||
{assignees.map((assignee) => <option key={assignee.id} value={assignee.id}>{assignee.name}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<button className={button} disabled={form.processing}>
|
||||
{form.processing ? "A guardar…" : "Guardar estado"}
|
||||
</button>
|
||||
</form>
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold">Histórico do fluxo</h2>
|
||||
{!history.length && <p>Sem alterações de estado registadas.</p>}
|
||||
{history.map((entry, index) => (
|
||||
<article key={`${entry.created_at}-${index}`} className="rounded border p-3">
|
||||
<p>{entry.from_status ? `${states[entry.from_status] ?? entry.from_status} → ` : ""}{states[entry.to_status] ?? entry.to_status} · {dateTime(entry.created_at)}</p>
|
||||
{entry.note && <p className="mt-1 whitespace-pre-wrap">{entry.note}</p>}
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</Management>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ type Interest = {
|
||||
full_name: string;
|
||||
email: string;
|
||||
sector_interest: string;
|
||||
existing_business_id: string | null;
|
||||
modality: string | null;
|
||||
assignee: { id: number; name: string } | null;
|
||||
next_action: string | null;
|
||||
next_action_due_at: string | null;
|
||||
status: string;
|
||||
};
|
||||
export default function Interests({
|
||||
@@ -16,17 +21,18 @@ export default function Interests({
|
||||
filters,
|
||||
}: {
|
||||
requests: Paginated<Interest>;
|
||||
filters: { q?: string; status?: string };
|
||||
filters: { q?: string; status?: string; follow_up?: string };
|
||||
}) {
|
||||
const [q, setQ] = useState(filters.q ?? '');
|
||||
const [status, setStatus] = useState(filters.status ?? '');
|
||||
const [followUp, setFollowUp] = useState(filters.follow_up ?? '');
|
||||
return (
|
||||
<Management title="Manifestações de interesse">
|
||||
<form
|
||||
className="flex flex-wrap gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
router.get('/management/interests', { q, status });
|
||||
router.get('/management/interests', { q, status, follow_up: followUp });
|
||||
}}
|
||||
>
|
||||
<input
|
||||
@@ -43,7 +49,7 @@ export default function Interests({
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="">Todos os estados</option>
|
||||
{['received', 'in_review', 'contacted', 'closed'].map(
|
||||
{Object.keys(states).filter((key) => ['received', 'submitted', 'in_review', 'reviewed', 'qualification', 'qualified', 'contacted', 'contact_accepted', 'referred', 'due_diligence', 'negotiation', 'pending', 'refused', 'converted', 'cancelled', 'closed'].includes(key)).map(
|
||||
(key) => (
|
||||
<option key={key} value={key}>
|
||||
{states[key]}
|
||||
@@ -51,6 +57,12 @@ export default function Interests({
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
<select aria-label="Acompanhamento" className={`${control} max-w-xs`} value={followUp} onChange={(e) => setFollowUp(e.target.value)}>
|
||||
<option value="">Todos os acompanhamentos</option>
|
||||
<option value="overdue">Acções atrasadas</option>
|
||||
<option value="upcoming">Acções por vencer</option>
|
||||
<option value="unassigned">Sem responsável</option>
|
||||
</select>
|
||||
<button className={button}>Pesquisar</button>
|
||||
</form>
|
||||
<div className="overflow-x-auto">
|
||||
@@ -62,6 +74,10 @@ export default function Interests({
|
||||
'Nome',
|
||||
'Email',
|
||||
'Sector',
|
||||
'Modalidade',
|
||||
'Responsável',
|
||||
'Próxima acção',
|
||||
'Prazo',
|
||||
'Estado',
|
||||
].map((label) => (
|
||||
<th key={label} className="border-b p-3">
|
||||
@@ -88,6 +104,14 @@ export default function Interests({
|
||||
<td className="border-b p-3">
|
||||
{item.sector_interest}
|
||||
</td>
|
||||
<td className="border-b p-3">
|
||||
{item.modality ?? (item.existing_business_id ? 'Negócio' : '—')}
|
||||
</td>
|
||||
<td className="border-b p-3">{item.assignee?.name ?? '—'}</td>
|
||||
<td className="border-b p-3">{item.next_action ?? '—'}</td>
|
||||
<td className={`border-b p-3 ${item.next_action_due_at && new Date(item.next_action_due_at) < new Date() ? 'font-semibold text-red-700' : ''}`}>
|
||||
{item.next_action_due_at ? `${new Date(item.next_action_due_at) < new Date() ? 'Atrasada · ' : ''}${new Intl.DateTimeFormat('pt-MZ', { dateStyle: 'medium', timeZone: 'Africa/Maputo' }).format(new Date(item.next_action_due_at))}` : '—'}
|
||||
</td>
|
||||
<td className="border-b p-3">
|
||||
{states[item.status] ?? item.status}
|
||||
</td>
|
||||
|
||||
@@ -15,8 +15,20 @@ export const button =
|
||||
"inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50";
|
||||
export const states: Record<string, string> = {
|
||||
received: "Recebido",
|
||||
submitted: "Submetido",
|
||||
in_review: "Em análise",
|
||||
contacted: "Contactado",
|
||||
reviewed: "Revisto",
|
||||
qualification: "Em qualificação",
|
||||
qualified: "Qualificado",
|
||||
contact_accepted: "Contacto aceite",
|
||||
referred: "Encaminhado",
|
||||
due_diligence: "Diligência devida",
|
||||
negotiation: "Em negociação",
|
||||
pending: "Pendente",
|
||||
refused: "Recusado",
|
||||
converted: "Convertido",
|
||||
cancelled: "Cancelado",
|
||||
closed: "Encerrado",
|
||||
draft: "Rascunho",
|
||||
published: "Publicado",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState } from "react";
|
||||
import { PortalMenu } from "@/layouts/portal-navigation";
|
||||
import { newSubmissionKey } from "@/lib/portal-api";
|
||||
import { ProvinceSelect } from "@/components/investment-territory";
|
||||
import { SimulationForm } from "@/components/simulation-form";
|
||||
|
||||
const input = "w-full rounded-lg border border-slate-300 bg-white p-3 text-slate-900";
|
||||
const button =
|
||||
@@ -293,105 +294,7 @@ function PartnershipForm() {
|
||||
}
|
||||
|
||||
function Simulator() {
|
||||
const [base, setBase] = useState("");
|
||||
const [rate, setRate] = useState("");
|
||||
const [reduction, setReduction] = useState("0");
|
||||
const [currency, setCurrency] = useState("MZN");
|
||||
const valid =
|
||||
base !== "" &&
|
||||
rate !== "" &&
|
||||
reduction !== "" &&
|
||||
Number.isFinite(Number(base)) &&
|
||||
Number(base) >= 0 &&
|
||||
Number(base) <= 1e12 &&
|
||||
Number(rate) >= 0 &&
|
||||
Number(rate) <= 100 &&
|
||||
Number(reduction) >= 0 &&
|
||||
Number(reduction) <= 100;
|
||||
const gross = (Number(base) * Number(rate)) / 100;
|
||||
const net = gross * (1 - Number(reduction) / 100);
|
||||
const format = (n: number) =>
|
||||
new Intl.NumberFormat("pt-MZ", { style: "currency", currency }).format(n);
|
||||
return (
|
||||
<section className="max-w-2xl space-y-5 rounded-xl border bg-white p-6">
|
||||
<h2 className="text-xl font-bold">Cenário com parâmetros próprios</h2>
|
||||
<p>
|
||||
Introduza uma base tributável e uma taxa que pretende comparar. Não existem taxas
|
||||
legais ou incentivos automáticos neste cálculo; a redução é uma hipótese introduzida
|
||||
por si.
|
||||
</p>
|
||||
<label className="block">
|
||||
Moeda
|
||||
<select
|
||||
className={input}
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value)}
|
||||
>
|
||||
{["MZN", "USD", "EUR", "ZAR"].map((c) => (
|
||||
<option key={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
Base tributável
|
||||
<input
|
||||
className={input}
|
||||
type="number"
|
||||
min="0"
|
||||
max="1000000000000"
|
||||
step="0.01"
|
||||
value={base}
|
||||
onChange={(e) => setBase(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
Taxa a simular (%)
|
||||
<input
|
||||
className={input}
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
value={rate}
|
||||
onChange={(e) => setRate(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
Redução hipotética do imposto (%)
|
||||
<input
|
||||
className={input}
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
value={reduction}
|
||||
onChange={(e) => setReduction(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div role="status" className="rounded bg-emerald-50 p-5">
|
||||
{valid ? (
|
||||
<>
|
||||
<p>
|
||||
Sem redução: <strong>{format(gross)}</strong>
|
||||
</p>
|
||||
<p>Redução simulada: {format(gross - net)}</p>
|
||||
<p>
|
||||
Total do cenário: <strong>{format(net)}</strong>
|
||||
</p>
|
||||
<p className="mt-3 text-sm">
|
||||
Base × taxa ÷ 100 × (1 − redução ÷ 100). Não inclui outros impostos,
|
||||
deduções, escalões ou regras sectoriais.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>Preencha a base e uma taxa válida entre 0 e 100%.</p>
|
||||
)}
|
||||
</div>
|
||||
<Link href="/apoio" className="underline">
|
||||
Pedir esclarecimento sobre o enquadramento aplicável
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
return <SimulationForm />;
|
||||
}
|
||||
|
||||
export default function Services(p: Props) {
|
||||
|
||||
+29
-1
@@ -2,11 +2,20 @@
|
||||
|
||||
use App\Http\Controllers\Api\V1\HomeController;
|
||||
use App\Http\Controllers\Api\V1\InterestRequestController;
|
||||
use App\Http\Controllers\Api\V1\LegislationSourceController;
|
||||
use App\Http\Controllers\Api\V1\OpportunityController;
|
||||
use App\Http\Controllers\Api\V1\ReferenceCatalogController;
|
||||
use App\Http\Controllers\Api\V1\SectorController;
|
||||
use App\Http\Controllers\Api\V1\SimulationController;
|
||||
use App\Http\Controllers\Api\V1\SimulationFormDraftController;
|
||||
use App\Http\Middleware\CorrelationId;
|
||||
use App\Http\Controllers\PublicBusinessOpportunityController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
|
||||
Route::prefix('v1')->middleware(['throttle:api', CorrelationId::class])->group(function () {
|
||||
Route::get('/catalogs/{catalog}', [ReferenceCatalogController::class, 'index'])
|
||||
->whereIn('catalog', ['authorities', 'jurisdictions', 'economic-activities']);
|
||||
|
||||
Route::get('/home', [HomeController::class, 'show']);
|
||||
|
||||
Route::get('/sectors', [SectorController::class, 'index']);
|
||||
@@ -15,6 +24,25 @@ Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
|
||||
Route::get('/opportunities/search', [OpportunityController::class, 'search']);
|
||||
Route::get('/opportunities/{slug}', [OpportunityController::class, 'show']);
|
||||
|
||||
Route::get('/business-opportunities', [PublicBusinessOpportunityController::class, 'index']);
|
||||
Route::get('/business-opportunities/{slug}', [PublicBusinessOpportunityController::class, 'show']);
|
||||
|
||||
Route::post('/interest-requests', [InterestRequestController::class, 'store'])
|
||||
->middleware('throttle:10,1');
|
||||
|
||||
Route::get('/legislation/sources', [LegislationSourceController::class, 'index']);
|
||||
});
|
||||
|
||||
Route::prefix('v1')->middleware(['web', 'throttle:api', CorrelationId::class])->group(function () {
|
||||
Route::get('/questionnaires/{code}', [SimulationController::class, 'questionnaire']);
|
||||
Route::get('/simulation-drafts/current', [SimulationFormDraftController::class, 'current']);
|
||||
Route::post('/simulation-drafts', [SimulationFormDraftController::class, 'store'])->middleware('throttle:simulation-draft-writes');
|
||||
Route::get('/simulation-drafts/{id}', [SimulationFormDraftController::class, 'show']);
|
||||
Route::put('/simulation-drafts/{id}', [SimulationFormDraftController::class, 'update'])->middleware('throttle:simulation-draft-writes');
|
||||
Route::delete('/simulation-drafts/{id}', [SimulationFormDraftController::class, 'destroy'])->middleware('throttle:simulation-draft-writes');
|
||||
Route::post('/simulations', [SimulationController::class, 'store'])->middleware('throttle:simulation-calculations');
|
||||
Route::get('/simulations/{id}', [SimulationController::class, 'show']);
|
||||
Route::patch('/simulations/{id}/scenarios/{scenarioId}', [SimulationController::class, 'updateScenario'])->middleware('throttle:simulation-scenario-updates');
|
||||
Route::get('/simulations/{id}/reports/{reportId}', [SimulationController::class, 'report']);
|
||||
Route::get('/simulations/{id}/jobs', [SimulationController::class, 'jobs']);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
Schedule::command('investmoz:escalate-overdue-tasks')->everyFifteenMinutes()->withoutOverlapping();
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\InvestmentZoneGeometryController;
|
||||
use App\Http\Controllers\InvestmentZoneLinkController;
|
||||
use App\Http\Controllers\InvestmentZoneRelationController;
|
||||
use App\Http\Middleware\PrivateInvestmentResponse;
|
||||
use App\Http\Middleware\CorrelationId;
|
||||
use App\Http\Middleware\RequireZoneMfa;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@@ -14,7 +15,7 @@ Route::get('/api/v2/investment/zone-map', [InvestmentZoneGeometryController::cla
|
||||
Route::get('/zonas/{id}', [InvestmentZoneController::class, 'show'])->whereUuid('id');
|
||||
Route::get('/api/v2/investment/zones', [InvestmentZoneController::class, 'index'])->middleware('throttle:api');
|
||||
Route::get('/api/v2/investment/zones/{id}', [InvestmentZoneController::class, 'show'])->whereUuid('id')->middleware('throttle:api');
|
||||
Route::middleware(['auth', 'verified', 'can:manage-portal', RequireZoneMfa::class, PrivateInvestmentResponse::class, 'throttle:30,1'])->group(function () {
|
||||
Route::middleware([CorrelationId::class, 'auth', 'verified', 'can:manage-portal', RequireZoneMfa::class, PrivateInvestmentResponse::class, 'throttle:30,1'])->group(function () {
|
||||
Route::get('/api/v2/investment/admin/legal-sources', [InvestmentLegalSourceController::class, 'index']);
|
||||
Route::post('/api/v2/investment/admin/legal-sources', [InvestmentLegalSourceController::class, 'store']);
|
||||
Route::post('/api/v2/investment/admin/legal-sources/{id}/{action}', [InvestmentLegalSourceController::class, 'transition'])->whereUuid('id')->whereIn('action', ['validate', 'revoke']);
|
||||
|
||||
@@ -21,6 +21,7 @@ use App\Http\Controllers\PortalManagementController;
|
||||
use App\Http\Controllers\PortalServicesController;
|
||||
use App\Http\Controllers\PublicGeographyController;
|
||||
use App\Http\Controllers\PublicPortalController;
|
||||
use App\Http\Controllers\PublicBusinessOpportunityController;
|
||||
use App\Http\Middleware\AiEnabled;
|
||||
use App\Http\Middleware\EducationEnabled;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -34,6 +35,7 @@ foreach (['sectores' => 'sectors', 'como-investir' => 'procedures', 'legislacao'
|
||||
'formularios/investidor' => 'investor', 'formularios/empresa' => 'company'] as $path => $page) {
|
||||
Route::get('/'.$path, [PortalServicesController::class, 'page'])->defaults('page', $page)->name('services.'.$page);
|
||||
}
|
||||
Route::get('/Simulador', [PortalServicesController::class, 'page'])->defaults('page', 'simulator')->name('services.simulator.alias');
|
||||
Route::get('/documentos/{id}/download', [PortalServicesController::class, 'download'])->whereNumber('id')->name('services.download');
|
||||
Route::post('/apoio', [PortalServicesController::class, 'submit'])->middleware('throttle:10,1')->name('services.submit');
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
@@ -50,6 +52,9 @@ Route::get('/carteira', [InvestmentPortfolioController::class, 'index'])->name('
|
||||
Route::get('/carteira/{id}', [InvestmentPortfolioController::class, 'show'])->whereUuid('id')->name('portfolio.show');
|
||||
Route::get('/oportunidades', [InvestmentDiscoveryController::class, 'index'])->name('portal.opportunities');
|
||||
Route::get('/oportunidades/{slug}', [InvestmentDiscoveryController::class, 'detail'])->name('portal.opportunity');
|
||||
Route::get('/negocios', [PublicBusinessOpportunityController::class, 'index'])->name('business-marketplace.index');
|
||||
Route::get('/negocios/{slug}/manifestar-interesse', [PublicBusinessOpportunityController::class, 'interest'])->name('business-marketplace.interest');
|
||||
Route::get('/negocios/{slug}', [PublicBusinessOpportunityController::class, 'show'])->name('business-marketplace.show');
|
||||
Route::get('/sectores/{slug}', [PublicPortalController::class, 'sector'])->name('portal.sector');
|
||||
Route::get('/manifestar-interesse/{slug?}', [PublicPortalController::class, 'interest'])->name('portal.interest');
|
||||
Route::get('/acesso-gestao', fn () => Inertia::render('management-access', [
|
||||
@@ -133,6 +138,7 @@ Route::middleware(['auth', 'verified', 'can:manage-portal'])->prefix('management
|
||||
Route::get('interests', [$controller, 'interests'])->name('interests');
|
||||
Route::get('interests/{interest}', [$controller, 'interest'])->name('interest');
|
||||
Route::patch('interests/{interest}', [$controller, 'review'])->name('review');
|
||||
Route::patch('interests/{interest}/consent/proponent', [$controller, 'withdrawProponentSharingConsent'])->name('consent.withdraw-proponent');
|
||||
Route::get('{resource}', [$controller, 'catalog'])->whereIn('resource', ['sectors', 'opportunities'])->name('catalog');
|
||||
Route::get('{resource}/create', [$controller, 'edit'])->whereIn('resource', ['sectors', 'opportunities'])->name('create');
|
||||
Route::get('{resource}/{id}/edit', [$controller, 'edit'])->whereIn('resource', ['sectors', 'opportunities'])->whereNumber('id')->name('edit');
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\RunSimulation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ApiContractTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Queue::fake();
|
||||
}
|
||||
|
||||
public function test_simulation_jobs_follow_simulation_access_scope_and_contract_shape(): void
|
||||
{
|
||||
$token = Str::random(48);
|
||||
$create = $this->postJson('/api/v1/simulations', ['base' => 0, 'rate' => 0], [
|
||||
'Idempotency-Key' => (string) Str::uuid(),
|
||||
'X-Simulation-Token' => $token,
|
||||
])->assertAccepted();
|
||||
|
||||
$simulationId = $create->json('data.id');
|
||||
$this->getJson("/api/v1/simulations/{$simulationId}/jobs", ['X-Simulation-Token' => $token])
|
||||
->assertOk()
|
||||
->assertJsonStructure(['data' => [['id', 'simulation_id', 'job_type', 'status', 'attempts', 'available_at']]])
|
||||
->assertJsonPath('data.0.job_type', 'execute')
|
||||
->assertJsonPath('data.0.status', 'pending');
|
||||
|
||||
$this->getJson("/api/v1/simulations/{$simulationId}/jobs", ['X-Simulation-Token' => Str::random(48)])
|
||||
->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_public_legislation_lists_only_validated_current_sources_with_etag_and_correlation(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$validator = User::factory()->create();
|
||||
$validId = (string) Str::uuid();
|
||||
$today = today()->toDateString();
|
||||
DB::table('investment_legal_sources')->insert([
|
||||
['id' => $validId, 'title' => 'Lei de teste', 'url' => 'https://example.org/lei', 'diploma' => 'Diploma A',
|
||||
'article' => 'Artigo 1', 'competent_entity' => 'Entidade A', 'valid_from' => $today, 'valid_until' => null,
|
||||
'validation_status' => 'validated', 'owner_id' => $owner->id, 'validator_id' => $validator->id,
|
||||
'validated_at' => now(), 'version_no' => 3, 'created_at' => now(), 'updated_at' => now()],
|
||||
['id' => (string) Str::uuid(), 'title' => 'Lei pendente', 'url' => 'https://example.org/pendente', 'diploma' => 'Diploma B',
|
||||
'article' => 'Artigo 2', 'competent_entity' => 'Entidade B', 'valid_from' => $today, 'valid_until' => null,
|
||||
'validation_status' => 'pending', 'owner_id' => $owner->id, 'validator_id' => null,
|
||||
'validated_at' => null, 'version_no' => 1, 'created_at' => now(), 'updated_at' => now()],
|
||||
['id' => (string) Str::uuid(), 'title' => 'Lei expirada', 'url' => 'https://example.org/expirada', 'diploma' => 'Diploma C',
|
||||
'article' => 'Artigo 3', 'competent_entity' => 'Entidade C', 'valid_from' => today()->subDay()->toDateString(),
|
||||
'valid_until' => today()->subDay()->toDateString(), 'validation_status' => 'validated', 'owner_id' => $owner->id,
|
||||
'validator_id' => $validator->id, 'validated_at' => now(), 'version_no' => 2, 'created_at' => now(), 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
$correlationId = (string) Str::uuid();
|
||||
$response = $this->getJson('/api/v1/legislation/sources?q=Lei&per_page=1', ['X-Correlation-ID' => $correlationId])
|
||||
->assertOk()
|
||||
->assertHeader('ETag')
|
||||
->assertHeader('X-Correlation-ID', $correlationId)
|
||||
->assertJsonPath('meta.total', 1)
|
||||
->assertJsonPath('data.0.id', $validId)
|
||||
->assertJsonPath('data.0.validation_status', 'validated');
|
||||
|
||||
$this->assertStringStartsWith('W/"legal-sources-', $response->headers->get('ETag'));
|
||||
}
|
||||
|
||||
public function test_contract_errors_include_correlation_and_required_preconditions(): void
|
||||
{
|
||||
$missingKey = $this->postJson('/api/v1/simulations', ['base' => 1, 'rate' => 1])
|
||||
->assertStatus(428)
|
||||
->assertJsonStructure(['message'])
|
||||
->assertHeader('X-Correlation-ID');
|
||||
$this->assertTrue(Str::isUuid($missingKey->headers->get('X-Correlation-ID')));
|
||||
|
||||
$invalidCorrelation = $this->getJson('/api/v1/legislation/sources', ['X-Correlation-ID' => 'invalid'])
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('code', 'invalid_correlation_id')
|
||||
->assertHeader('X-Correlation-ID');
|
||||
$this->assertTrue(Str::isUuid($invalidCorrelation->headers->get('X-Correlation-ID')));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BusinessMarketplacePublicTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config(['existing-business.enabled' => true]);
|
||||
}
|
||||
|
||||
public function test_public_catalogue_and_detail_exclude_reserved_fields_and_locations(): void
|
||||
{
|
||||
$business = $this->publishedBusiness();
|
||||
|
||||
$this->getJson('/api/v1/business-opportunities')->assertOk()
|
||||
->assertJsonPath('data.0.title', 'Public synthetic opportunity')
|
||||
->assertJsonMissingPath('data.0.owner_user_id')
|
||||
->assertJsonMissingPath('data.0.payload')
|
||||
->assertJsonMissingPath('data.0.geom');
|
||||
$this->getJson('/api/v1/business-opportunities/'.$business['public_slug'])->assertOk()
|
||||
->assertJsonPath('data.title', 'Public synthetic opportunity')
|
||||
->assertJsonMissingPath('data.payload')
|
||||
->assertJsonMissingPath('data.owner_user_id');
|
||||
}
|
||||
|
||||
public function test_interest_is_limited_to_published_business_and_deduplicated_idempotently(): void
|
||||
{
|
||||
$business = $this->publishedBusiness();
|
||||
$payload = [
|
||||
'existingBusinessId' => $business['id'],
|
||||
'modality' => 'company_sale',
|
||||
'fullName' => 'Synthetic investor',
|
||||
'email' => 'portal-integration-test@gmail.com',
|
||||
'phone' => '+258 840000000',
|
||||
'investorType' => 'empresa',
|
||||
'country' => 'Moçambique',
|
||||
'sectorInterest' => 'Energia',
|
||||
'message' => 'Synthetic interest for the published business listing.',
|
||||
'privacyConsent' => true,
|
||||
];
|
||||
$key = (string) Str::uuid();
|
||||
|
||||
$first = $this->postJson('/api/v1/interest-requests', $payload, ['Idempotency-Key' => $key])->assertCreated();
|
||||
$second = $this->postJson('/api/v1/interest-requests', $payload, ['Idempotency-Key' => $key])->assertCreated();
|
||||
$this->assertSame($first->json(), $second->json());
|
||||
$this->assertDatabaseHas('interest_requests', [
|
||||
'existing_business_id' => $business['id'],
|
||||
'modality' => 'company_sale',
|
||||
'status' => 'received',
|
||||
]);
|
||||
$this->assertDatabaseCount('interest_requests', 1);
|
||||
$this->assertDatabaseCount('outbox_events', 1);
|
||||
|
||||
$this->postJson('/api/v1/interest-requests', [...$payload, 'message' => 'Another valid message for this active interest.'])
|
||||
->assertStatus(409);
|
||||
$this->assertDatabaseCount('interest_requests', 1);
|
||||
}
|
||||
|
||||
/** @return array{id:string,public_slug:string} */
|
||||
private function publishedBusiness(): array
|
||||
{
|
||||
$id = (string) Str::uuid();
|
||||
$slug = 'public-synthetic-'.$id;
|
||||
$now = now();
|
||||
DB::table('existing_businesses')->insert([
|
||||
'id' => $id,
|
||||
'code' => 'SYN-'.strtoupper(substr(str_replace('-', '', $id), 0, 12)),
|
||||
'owner_user_id' => User::factory()->create()->id,
|
||||
'modality' => 'company_sale',
|
||||
'status' => 'published',
|
||||
'classification' => 'confidential',
|
||||
'version_no' => 1,
|
||||
'payload' => Crypt::encryptString(json_encode(['title' => 'Private synthetic title'], JSON_THROW_ON_ERROR)),
|
||||
'public_title' => 'Public synthetic opportunity',
|
||||
'public_slug' => $slug,
|
||||
'published_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return ['id' => $id, 'public_slug' => $slug];
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,16 @@ class ExistingBusinessIntakeTest extends TestCase
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function designated(string $role): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->forceFill(['two_factor_secret' => Crypt::encryptString('SYNTHETIC'), 'two_factor_confirmed_at' => now()])->save();
|
||||
config(['portal.managers' => [...config('portal.managers', []), $user->email],
|
||||
'existing-business.'.$role => [...config('existing-business.'.$role, []), strtolower($user->email)]]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function draft(): array
|
||||
{
|
||||
@@ -41,6 +51,7 @@ class ExistingBusinessIntakeTest extends TestCase
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$reviewer = $this->reviewer();
|
||||
$triager = $this->designated('triagers');
|
||||
$this->actingAs($owner);
|
||||
$id = $this->postJson($this->base, $this->draft())->assertCreated()->json('id');
|
||||
$url = $this->base.'/'.$id;
|
||||
@@ -50,17 +61,23 @@ class ExistingBusinessIntakeTest extends TestCase
|
||||
$this->postJson($url.'/transitions', ['version_no' => 1, 'action' => 'submitted', 'note' => 'Synthetic review requested', 'review_consent' => true])->assertOk()->assertJsonPath('version_no', 2);
|
||||
$this->putJson($url, [...$this->draft(), 'version_no' => 2])->assertForbidden();
|
||||
$this->postJson($url.'/transitions', ['version_no' => 2, 'action' => 'in_review', 'note' => 'Attempted self review'])->assertForbidden();
|
||||
$this->actingAs($triager)->postJson($url.'/transitions', ['version_no' => 2, 'action' => 'triage', 'note' => 'Synthetic triage completed'])->assertOk()->assertJsonPath('version_no', 3);
|
||||
$this->actingAs($reviewer)->getJson('/api/v1/existing-business/reviews')->assertOk()->assertJsonPath('records.total', 1);
|
||||
$this->postJson($url.'/transitions', ['version_no' => 1, 'action' => 'in_review', 'note' => 'Synthetic review started'])->assertConflict();
|
||||
$this->postJson($url.'/transitions', ['version_no' => 2, 'action' => 'in_review', 'note' => 'Synthetic review started'])->assertOk()->assertJsonPath('version_no', 3);
|
||||
$this->postJson($url.'/transitions', ['version_no' => 3, 'action' => 'needs_information', 'note' => 'Please clarify synthetic source'])->assertOk();
|
||||
$this->postJson($url.'/transitions', ['version_no' => 2, 'action' => 'in_review', 'note' => 'Synthetic review started'])->assertConflict();
|
||||
$this->postJson($url.'/transitions', ['version_no' => 3, 'action' => 'in_review', 'note' => 'Synthetic review started'])->assertOk()->assertJsonPath('version_no', 4);
|
||||
$this->postJson($url.'/transitions', ['version_no' => 4, 'action' => 'needs_information', 'note' => 'Please clarify synthetic source'])->assertOk();
|
||||
$this->actingAs($owner)->getJson($url)->assertOk()->assertJsonPath('history.0.note', 'Please clarify synthetic source');
|
||||
$this->postJson($url, [...$this->draft(), '_method' => 'PUT', 'version_no' => 4])->assertOk()->assertJsonPath('version_no', 5);
|
||||
$this->postJson($url.'/transitions', ['version_no' => 5, 'action' => 'withdrawn', 'note' => 'Synthetic withdrawal requested'])->assertOk();
|
||||
$this->postJson($url, [...$this->draft(), '_method' => 'PUT', 'version_no' => 5])->assertOk()->assertJsonPath('version_no', 6);
|
||||
$this->postJson($url.'/transitions', ['version_no' => 6, 'action' => 'withdrawn', 'note' => 'Synthetic withdrawal requested'])->assertOk();
|
||||
$this->actingAs($reviewer)->getJson($url)->assertForbidden();
|
||||
$this->getJson('/api/v1/existing-business/reviews')->assertJsonPath('records.total', 0);
|
||||
$this->assertDatabaseCount('existing_business_reviews', 4);
|
||||
$this->assertDatabaseCount('existing_business_revisions', 6);
|
||||
$this->assertDatabaseCount('existing_business_reviews', 5);
|
||||
$this->assertDatabaseCount('existing_business_revisions', 7);
|
||||
$this->assertDatabaseCount('business_workflow_transitions', 5);
|
||||
$this->assertDatabaseCount('outbox_events', 1);
|
||||
$transition = DB::table('business_workflow_transitions')->where('to_status', 'needs_information')->first();
|
||||
$this->assertSame('in_review', $transition->from_status);
|
||||
$this->assertStringNotContainsString('Please clarify', $transition->reason);
|
||||
$this->assertStringNotContainsString('Please clarify', DB::table('existing_business_reviews')->where('action', 'needs_information')->value('note'));
|
||||
}
|
||||
|
||||
@@ -76,11 +93,14 @@ class ExistingBusinessIntakeTest extends TestCase
|
||||
'valid_until' => today()->addDay()->toDateString()];
|
||||
$this->postJson($url.'/transitions', ['version_no' => 1, 'action' => 'submitted', 'note' => 'Synthetic review requested', 'review_consent' => true])->assertOk();
|
||||
$this->postJson($url.'/verifications', $finding)->assertForbidden();
|
||||
$this->actingAs($reviewer)->postJson($url.'/verifications', [...$finding, 'version_no' => 2])->assertConflict();
|
||||
$this->postJson($url.'/transitions', ['version_no' => 2, 'action' => 'in_review', 'note' => 'Synthetic review started'])->assertOk();
|
||||
$triager = $this->designated('triagers');
|
||||
$this->actingAs($triager)->postJson($url.'/transitions', ['version_no' => 2, 'action' => 'triage', 'note' => 'Synthetic triage completed'])->assertOk();
|
||||
$this->actingAs($reviewer)->postJson($url.'/transitions', ['version_no' => 3, 'action' => 'in_review', 'note' => 'Synthetic review started'])->assertOk();
|
||||
$this->postJson($url.'/verifications', [...$finding, 'version_no' => 3])->assertConflict();
|
||||
$finding['version_no'] = 4;
|
||||
$this->postJson($url.'/verifications', [...$finding, 'valid_until' => null])->assertUnprocessable();
|
||||
$this->postJson($url.'/verifications', [...$finding, 'object_id' => '00000000-0000-4000-8000-000000000001'])->assertNotFound();
|
||||
$this->postJson($url.'/verifications', $finding)->assertOk()->assertJsonPath('version_no', 4);
|
||||
$this->postJson($url.'/verifications', $finding)->assertOk()->assertJsonPath('version_no', 5);
|
||||
$this->postJson($url.'/verifications', $finding)->assertConflict();
|
||||
$this->getJson($url)->assertOk()->assertJsonPath('verifications.0.effective_outcome', 'confirmed');
|
||||
$raw = DB::table('existing_business_revisions')->where('business_id', $id)->where('action', 'verification')->value('snapshot');
|
||||
@@ -88,8 +108,8 @@ class ExistingBusinessIntakeTest extends TestCase
|
||||
$this->travel(2)->days();
|
||||
$this->getJson($url)->assertOk()->assertJsonPath('verifications.0.effective_outcome', 'expired');
|
||||
$this->travelBack();
|
||||
$this->postJson($url.'/transitions', ['version_no' => 4, 'action' => 'needs_information', 'note' => 'Synthetic clarification requested'])->assertOk();
|
||||
$this->actingAs($owner)->putJson($url, [...$this->draft(), 'version_no' => 5])->assertOk();
|
||||
$this->postJson($url.'/transitions', ['version_no' => 5, 'action' => 'needs_information', 'note' => 'Synthetic clarification requested'])->assertOk();
|
||||
$this->actingAs($owner)->putJson($url, [...$this->draft(), 'version_no' => 6])->assertOk();
|
||||
$this->getJson($url)->assertJsonPath('verifications.0.effective_outcome', 'stale');
|
||||
$this->actingAs(User::factory()->create())->getJson($url)->assertForbidden();
|
||||
}
|
||||
@@ -114,12 +134,46 @@ class ExistingBusinessIntakeTest extends TestCase
|
||||
$draft = $this->draft();
|
||||
$draft['payload']['valid_until'] = today()->subDay()->toDateString();
|
||||
$id = $this->postJson($this->base, $draft)->assertCreated()->json('id');
|
||||
foreach (['published', 'approved', 'submitted'] as $action) {
|
||||
$this->postJson($this->base.'/'.$id.'/transitions', ['version_no' => 1, 'action' => $action, 'note' => 'Synthetic invalid transition', 'review_consent' => true])->assertUnprocessable();
|
||||
foreach (['published', 'approved'] as $action) {
|
||||
$this->postJson($this->base.'/'.$id.'/transitions', ['version_no' => 1, 'action' => $action, 'note' => 'Synthetic invalid transition', 'review_consent' => true])->assertForbidden();
|
||||
}
|
||||
$this->postJson($this->base.'/'.$id.'/transitions', ['version_no' => 1, 'action' => 'submitted', 'note' => 'Synthetic invalid transition', 'review_consent' => true])->assertUnprocessable();
|
||||
$this->assertDatabaseCount('existing_business_reviews', 0);
|
||||
}
|
||||
|
||||
public function test_submission_triage_two_level_approval_and_publication_require_distinct_designations(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$triager = $this->designated('triagers');
|
||||
$reviewer = $this->designated('reviewers');
|
||||
$levelOne = $this->designated('approvers_l1');
|
||||
$levelTwo = $this->designated('approvers_l2');
|
||||
$publisher = $this->designated('publishers');
|
||||
$sectorId = DB::table('sectors')->insertGetId(['name' => 'Synthetic approval sector', 'slug' => 'synthetic-approval-sector', 'is_active' => true]);
|
||||
|
||||
$this->actingAs($owner);
|
||||
$id = $this->postJson($this->base, $this->draft())->assertCreated()->json('id');
|
||||
$this->putJson($this->base.'/'.$id, [...$this->draft(), 'version_no' => 1, 'sector_id' => $sectorId,
|
||||
'public_title' => 'Synthetic public listing'])->assertOk();
|
||||
$url = $this->base.'/'.$id;
|
||||
$this->postJson($url.'/transitions', ['version_no' => 2, 'action' => 'submitted', 'note' => 'Synthetic submission for staged approval', 'review_consent' => true])->assertOk();
|
||||
$this->assertDatabaseHas('tasks', ['existing_business_id' => $id, 'task_type' => 'triage', 'status' => 'open']);
|
||||
|
||||
$this->actingAs($triager)->postJson($url.'/transitions', ['version_no' => 3, 'action' => 'triage', 'note' => 'Synthetic initial triage completed'])->assertOk();
|
||||
$this->actingAs($reviewer)->postJson($url.'/transitions', ['version_no' => 4, 'action' => 'in_review', 'note' => 'Synthetic independent review started'])->assertOk();
|
||||
$this->postJson($url.'/transitions', ['version_no' => 5, 'action' => 'approval_l1', 'note' => 'Synthetic review checks completed'])->assertOk();
|
||||
$this->actingAs($levelOne)->postJson($url.'/transitions', ['version_no' => 6, 'action' => 'approval_l2', 'note' => 'Synthetic first level approval'])->assertOk();
|
||||
|
||||
config(['existing-business.approvers_l2' => [...config('existing-business.approvers_l2'), strtolower($levelOne->email)]]);
|
||||
$this->actingAs($levelOne)->postJson($url.'/transitions', ['version_no' => 7, 'action' => 'approved', 'note' => 'Synthetic second level approval attempt'])->assertForbidden();
|
||||
$this->actingAs($levelTwo)->postJson($url.'/transitions', ['version_no' => 7, 'action' => 'approved', 'note' => 'Synthetic independent second level approval'])->assertOk();
|
||||
$this->actingAs($publisher)->postJson($url.'/transitions', ['version_no' => 8, 'action' => 'published', 'note' => 'Synthetic independent publication decision'])->assertOk()->assertJsonPath('published_at', fn ($value) => is_string($value));
|
||||
|
||||
$this->assertDatabaseHas('business_workflow_transitions', ['existing_business_id' => $id, 'from_status' => 'approval_l2', 'to_status' => 'approved']);
|
||||
$this->assertDatabaseHas('existing_businesses', ['id' => $id, 'status' => 'published']);
|
||||
$this->assertDatabaseCount('outbox_events', 1);
|
||||
}
|
||||
|
||||
public function test_reserved_objects_are_scoped_versioned_and_never_verified_by_declaration(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
@@ -32,6 +32,9 @@ class InterestSubmissionTest extends TestCase
|
||||
$this->assertDatabaseCount('interest_requests', 1);
|
||||
$this->assertDatabaseCount('interest_submission_receipts', 1);
|
||||
$this->assertDatabaseCount('outbox_events', 1);
|
||||
$this->assertDatabaseCount('consents', 1);
|
||||
$this->assertDatabaseHas('consents', ['interest_request_id' => InterestRequest::firstOrFail()->id,
|
||||
'purpose' => 'respond_to_interest', 'status' => 'granted', 'policy_version' => '2026-09-30']);
|
||||
$this->assertDatabaseCount('jobs', 1);
|
||||
$event = DB::table('outbox_events')->first();
|
||||
$this->assertSame('pending', $event->status);
|
||||
@@ -51,6 +54,17 @@ class InterestSubmissionTest extends TestCase
|
||||
$this->assertDatabaseCount('outbox_events', 1);
|
||||
}
|
||||
|
||||
public function test_proponent_sharing_is_a_separate_optional_versioned_consent(): void
|
||||
{
|
||||
$this->postJson('/api/v1/interest-requests', [...$this->payload(), 'shareWithProponent' => true])->assertCreated();
|
||||
$interest = InterestRequest::firstOrFail();
|
||||
$this->assertDatabaseHas('consents', ['interest_request_id' => $interest->id,
|
||||
'purpose' => 'respond_to_interest', 'status' => 'granted']);
|
||||
$this->assertDatabaseHas('consents', ['interest_request_id' => $interest->id,
|
||||
'purpose' => 'share_contact_with_proponent', 'status' => 'granted', 'policy_version' => '2026-09-30']);
|
||||
$this->assertSame(2, DB::table('consents')->where('interest_request_id', $interest->id)->count());
|
||||
}
|
||||
|
||||
public function test_old_clients_still_work_and_distinct_keys_create_distinct_submissions(): void
|
||||
{
|
||||
$this->postJson('/api/v1/interest-requests', $this->payload())->assertCreated();
|
||||
|
||||
@@ -8,6 +8,9 @@ use App\Models\Sector;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -120,9 +123,35 @@ class PortalManagementTest extends TestCase
|
||||
'email' => 'test@example.invalid', 'investor_type' => 'empresa', 'country' => 'Moçambique',
|
||||
'sector_interest' => 'Energia', 'message' => 'Pedido de informação para teste.', 'privacy_consent' => true]);
|
||||
$this->actingAs($manager)->get('/management/interests/'.$interest->id)->assertOk();
|
||||
$this->patch('/management/interests/'.$interest->id, ['status' => 'in_review', 'reviewed_by' => 999])->assertRedirect();
|
||||
$this->patch('/management/interests/'.$interest->id, ['status' => 'in_review', 'reviewed_by' => 999,
|
||||
'next_action' => 'Contactar o investidor', 'next_action_due_at' => now()->addDays(2)->toDateString(), 'assigned_to' => $manager->id,
|
||||
'note' => 'O pedido foi recebido e encaminhado para análise interna.', 'next_action' => 'Contactar o investidor',
|
||||
'next_action_due_at' => now()->addDays(2)->toDateString(), 'assigned_to' => $manager->id])->assertRedirect();
|
||||
$this->assertSame($manager->id, $interest->fresh()->reviewed_by);
|
||||
$this->assertNotNull($interest->fresh()->reviewed_at);
|
||||
$transition = DB::table('business_workflow_transitions')->where('interest_request_id', $interest->id)->first();
|
||||
$this->assertSame('received', $transition->from_status);
|
||||
$this->assertSame('in_review', $transition->to_status);
|
||||
$this->assertSame($manager->id, $interest->fresh()->assigned_to);
|
||||
$this->assertDatabaseHas('tasks', ['interest_request_id' => $interest->id, 'assignee_user_id' => $manager->id,
|
||||
'title' => 'Contactar o investidor', 'status' => 'open']);
|
||||
$this->patch('/management/interests/'.$interest->id, ['status' => 'in_review', 'next_action' => 'Contactar o investidor',
|
||||
'next_action_due_at' => now()->addDays(2)->toDateString(), 'assigned_to' => $manager->id])->assertRedirect();
|
||||
$this->assertSame(1, DB::table('tasks')->where('interest_request_id', $interest->id)->where('status', 'open')->count());
|
||||
$this->assertDatabaseHas('tasks', ['interest_request_id' => $interest->id, 'assignee_user_id' => $manager->id,
|
||||
'title' => 'Contactar o investidor', 'status' => 'open']);
|
||||
$this->patch('/management/interests/'.$interest->id, ['status' => 'in_review', 'next_action' => 'Enviar resumo ao investidor',
|
||||
'next_action_due_at' => now()->addDays(4)->toDateString(), 'assigned_to' => $manager->id])->assertRedirect();
|
||||
$this->assertDatabaseHas('tasks', ['interest_request_id' => $interest->id, 'title' => 'Contactar o investidor',
|
||||
'status' => 'cancelled', 'outcome' => 'superseded']);
|
||||
$this->assertSame(1, DB::table('tasks')->where('interest_request_id', $interest->id)->where('status', 'open')->count());
|
||||
$this->patch('/management/interests/'.$interest->id, ['status' => 'in_review'])->assertRedirect();
|
||||
$this->assertSame('Enviar resumo ao investidor', $interest->fresh()->next_action);
|
||||
$this->get('/management/interests?follow_up=upcoming')->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('management/interests')->where('requests.data.0.reference', 'IR-TEST')->where('filters.follow_up', 'upcoming'));
|
||||
$this->assertSame('O pedido foi recebido e encaminhado para análise interna.', Crypt::decryptString($transition->reason));
|
||||
$this->assertSame($manager->id, $interest->fresh()->assigned_to);
|
||||
$this->patch('/management/interests/'.$interest->id, ['status' => 'received'])->assertSessionHasErrors('status');
|
||||
$this->patch('/management/interests/'.$interest->id, ['status' => 'invalid'])->assertSessionHasErrors('status');
|
||||
}
|
||||
|
||||
@@ -144,6 +173,52 @@ class PortalManagementTest extends TestCase
|
||||
$this->assertDatabaseCount('interest_requests', 0);
|
||||
}
|
||||
|
||||
public function test_proponent_sharing_consent_can_be_revoked_with_audited_outbox_event(): void
|
||||
{
|
||||
$manager = $this->manager();
|
||||
$interest = InterestRequest::create(['reference' => 'IR-CONSENT', 'full_name' => 'Pessoa de teste',
|
||||
'email' => 'consent@example.invalid', 'investor_type' => 'empresa', 'country' => 'MZ',
|
||||
'sector_interest' => 'Energia', 'message' => 'Pedido de partilha para teste.', 'privacy_consent' => true]);
|
||||
$shareConsentId = (string) Str::uuid();
|
||||
DB::table('consents')->insert(['id' => $shareConsentId, 'source_system' => 'portal-intake',
|
||||
'external_id' => 'test-share-consent', 'version_no' => 1, 'data_classification' => 'confidential',
|
||||
'correlation_id' => (string) Str::uuid(), 'subject_ref' => $interest->reference,
|
||||
'purpose' => 'share_contact_with_proponent', 'status' => 'granted', 'granted_at' => now(),
|
||||
'interest_request_id' => $interest->id, 'policy_version' => '2026-09-30', 'created_at' => now(), 'updated_at' => now()]);
|
||||
|
||||
$this->actingAs($manager)->patch('/management/interests/'.$interest->id.'/consent/proponent', [
|
||||
'reason' => 'O titular pediu para não partilhar os seus dados com o proponente.',
|
||||
])->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('consents', ['id' => $shareConsentId, 'status' => 'withdrawn']);
|
||||
$this->assertDatabaseHas('business_workflow_transitions', ['interest_request_id' => $interest->id,
|
||||
'to_status' => 'received']);
|
||||
$event = DB::table('outbox_events')->where('event_type', 'InterestConsentWithdrawn')->first();
|
||||
$this->assertNotNull($event);
|
||||
$this->assertSame($shareConsentId, $event->aggregate_id);
|
||||
$this->assertStringNotContainsString('consent@example.invalid', $event->payload);
|
||||
}
|
||||
|
||||
public function test_overdue_task_escalation_is_opt_in_and_idempotent(): void
|
||||
{
|
||||
$interest = InterestRequest::create(['reference' => 'IR-SLA', 'full_name' => 'Pessoa de teste',
|
||||
'email' => 'sla@example.invalid', 'investor_type' => 'empresa', 'country' => 'MZ',
|
||||
'sector_interest' => 'Energia', 'message' => 'Pedido de teste de escalada.', 'privacy_consent' => true]);
|
||||
DB::table('tasks')->insert(['source_system' => 'portal-interest', 'external_id' => 'sla-test-task',
|
||||
'version_no' => 1, 'data_classification' => 'confidential', 'correlation_id' => (string) Str::uuid(),
|
||||
'interest_request_id' => $interest->id, 'task_type' => 'interest_follow_up', 'title' => 'Contactar',
|
||||
'status' => 'open', 'priority' => 3, 'due_at' => now()->subHour(), 'created_at' => now(), 'updated_at' => now()]);
|
||||
|
||||
config(['existing-business.sla_escalation_enabled' => false]);
|
||||
$this->artisan('investmoz:escalate-overdue-tasks')->assertSuccessful();
|
||||
$this->assertDatabaseHas('tasks', ['external_id' => 'sla-test-task', 'priority' => 3]);
|
||||
|
||||
config(['existing-business.sla_escalation_enabled' => true]);
|
||||
$this->artisan('investmoz:escalate-overdue-tasks')->assertSuccessful();
|
||||
$this->artisan('investmoz:escalate-overdue-tasks')->assertSuccessful();
|
||||
$this->assertDatabaseHas('tasks', ['external_id' => 'sla-test-task', 'priority' => 1, 'outcome' => 'sla_overdue']);
|
||||
}
|
||||
|
||||
public function test_valid_public_submissions_persist_and_return_distinct_references(): void
|
||||
{
|
||||
// DNS validation remains enabled; no mail is sent by this endpoint.
|
||||
|
||||
@@ -20,7 +20,7 @@ class PortalServicesTest extends TestCase
|
||||
|
||||
public function test_public_sections_render_and_desk_requires_login(): void
|
||||
{
|
||||
foreach (['sectores', 'como-investir', 'legislacao', 'documentos', 'apoio', 'parcerias', 'simulador-fiscal', 'formularios/investidor', 'formularios/empresa'] as $path) {
|
||||
foreach (['sectores', 'como-investir', 'legislacao', 'documentos', 'apoio', 'parcerias', 'simulador-fiscal', 'Simulador', 'formularios/investidor', 'formularios/empresa'] as $path) {
|
||||
$this->get('/'.$path)->assertOk()->assertInertia(fn (Assert $page) => $page->component('portal/services'));
|
||||
}
|
||||
$this->get('/balcao-virtual')->assertRedirect('/login');
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Database\Seeders\ReferenceCatalogSeeder;
|
||||
use Database\Seeders\SectorSeeder;
|
||||
use Database\Seeders\TerritoryReferenceSeeder;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ReferenceCatalogTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_reference_seeds_are_repeatable_and_activity_codes_are_stable(): void
|
||||
{
|
||||
$this->seedReferences();
|
||||
$ids = DB::table('economic_activities')->orderBy('code')->pluck('id', 'code')->all();
|
||||
$this->seedReferences();
|
||||
|
||||
$this->assertCount(6, $ids);
|
||||
$this->assertSame($ids, DB::table('economic_activities')->orderBy('code')->pluck('id', 'code')->all());
|
||||
$this->assertSame(6, DB::table('economic_activities')->count());
|
||||
$this->assertSame(3, DB::table('reference_catalog_versions')->count());
|
||||
$this->assertDatabaseHas('reference_catalog_versions', [
|
||||
'catalog_code' => 'economic-activities', 'version_no' => 1, 'locale' => 'pt-MZ', 'status' => 'published',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_catalog_search_pagination_and_explicit_version(): void
|
||||
{
|
||||
$this->seedReferences();
|
||||
|
||||
$this->getJson('/api/v1/catalogs/economic-activities?q=energia&per_page=1')
|
||||
->assertOk()->assertJsonPath('meta.version', 1)->assertJsonPath('meta.locale', 'pt-MZ')
|
||||
->assertJsonPath('meta.total', 1)->assertJsonPath('data.0.code', 'energia')
|
||||
->assertJsonPath('data.0.label', DB::table('sectors')->where('slug', 'energia')->value('name'));
|
||||
|
||||
$this->getJson('/api/v1/catalogs/economic-activities?version=1&per_page=2&page=2')
|
||||
->assertOk()->assertJsonPath('meta.current_page', 2)->assertJsonPath('meta.total', 6)
|
||||
->assertJsonCount(2, 'data')->assertJsonStructure(['links' => ['next', 'previous']]);
|
||||
|
||||
$this->getJson('/api/v1/catalogs/economic-activities?version=999')->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_jurisdiction_dropdown_requires_the_immediate_parent_and_filters_children(): void
|
||||
{
|
||||
$this->seedReferences();
|
||||
$country = DB::table('geo_locations')->where('location_type', 'country')->where('source_system', 'cartografia-workbook')->value('id');
|
||||
$province = DB::table('geo_locations')->where('location_type', 'province')->where('source_system', 'cartografia-workbook')->orderBy('name')->first();
|
||||
|
||||
$this->getJson('/api/v1/catalogs/jurisdictions?type=province&parent_id='.$country.'&per_page=100')
|
||||
->assertOk()->assertJsonPath('meta.version', 3)->assertJsonPath('meta.locale', 'pt-MZ')->assertJsonPath('meta.total', 11);
|
||||
$district = DB::table('geo_locations')->where('parent_id', $province->id)->where('location_type', 'district')->first();
|
||||
$this->getJson('/api/v1/catalogs/jurisdictions?type=district&parent_id='.$province->id.'&q='.$district->name)
|
||||
->assertOk()->assertJsonPath('data.0.parent_id', $province->id)->assertJsonPath('data.0.name', $district->name)->assertJsonPath('meta.total', 1);
|
||||
$this->getJson('/api/v1/catalogs/jurisdictions?type=district')->assertUnprocessable()->assertJsonValidationErrors('parent_id');
|
||||
$this->getJson('/api/v1/catalogs/jurisdictions?type=district&parent_id='.$country)->assertUnprocessable()->assertJsonValidationErrors('parent_id');
|
||||
}
|
||||
|
||||
public function test_economic_activity_dropdown_can_be_filtered_by_sector(): void
|
||||
{
|
||||
$this->seedReferences();
|
||||
$sectorId = DB::table('sectors')->where('slug', 'energia')->value('id');
|
||||
|
||||
$this->getJson('/api/v1/catalogs/economic-activities?sector_id='.$sectorId)
|
||||
->assertOk()->assertJsonPath('meta.total', 1)->assertJsonPath('data.0.code', 'energia');
|
||||
$this->getJson('/api/v1/catalogs/economic-activities?sector_id=999999999')
|
||||
->assertUnprocessable()->assertJsonValidationErrors('sector_id');
|
||||
}
|
||||
|
||||
public function test_authorities_are_not_fabricated_before_a_source_is_approved(): void
|
||||
{
|
||||
$this->seedReferences();
|
||||
|
||||
$this->getJson('/api/v1/catalogs/authorities')->assertOk()->assertJsonPath('data', [])
|
||||
->assertJsonPath('meta.status', 'not_published')->assertJsonPath('meta.version', null);
|
||||
}
|
||||
|
||||
public function test_published_authority_catalog_only_returns_public_organizations(): void
|
||||
{
|
||||
$this->seedReferences();
|
||||
$versionId = DB::table('reference_catalog_versions')->where('catalog_code', 'authorities')->value('id');
|
||||
DB::table('reference_catalog_versions')->where('id', $versionId)->update(['status' => 'published']);
|
||||
|
||||
foreach ([['Public authority', 'public'], ['Internal authority', 'internal']] as [$name, $classification]) {
|
||||
$organizationId = (string) Str::uuid();
|
||||
DB::table('organizations')->insert(['id' => $organizationId, 'name' => $name, 'organization_type' => 'public',
|
||||
'data_classification' => $classification, 'created_at' => now(), 'updated_at' => now()]);
|
||||
DB::table('authorities')->insert(['id' => (string) Str::uuid(), 'catalog_version_id' => $versionId,
|
||||
'organization_id' => $organizationId, 'code' => strtolower(str_replace(' ', '-', $name)),
|
||||
'authority_type' => 'registry', 'label_pt_mz' => $name, 'source_reference' => 'Synthetic approved source',
|
||||
'version_no' => 1, 'valid_from' => today()->toDateString(), 'status' => 'published',
|
||||
'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
|
||||
$this->getJson('/api/v1/catalogs/authorities')->assertOk()->assertJsonPath('meta.version', 1)
|
||||
->assertJsonPath('meta.total', 1)->assertJsonPath('data.0.name', 'Public authority');
|
||||
}
|
||||
|
||||
public function test_foreign_keys_and_catalog_migration_rollback_guard(): void
|
||||
{
|
||||
$this->seedReferences();
|
||||
$migration = require database_path('migrations/2026_10_01_000100_create_reference_catalogs.php');
|
||||
try {
|
||||
$migration->down();
|
||||
$this->fail('Rollback must preserve seeded economic activity references.');
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->assertStringContainsString('Economic activities exist', $exception->getMessage());
|
||||
}
|
||||
|
||||
$activity = DB::table('economic_activities')->first();
|
||||
try {
|
||||
DB::table('economic_activities')->insert([
|
||||
'id' => (string) Str::uuid(), 'catalog_version_id' => $activity->catalog_version_id, 'sector_id' => 999999999,
|
||||
'code' => 'invalid-fk', 'label_pt_mz' => 'Inválido', 'version_no' => 1, 'valid_from' => '2026-10-01',
|
||||
'status' => 'published', 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
$this->fail('The database accepted an unknown sector reference.');
|
||||
} catch (QueryException $exception) {
|
||||
$this->assertSame('23503', $exception->errorInfo[0]);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_published_catalog_values_are_immutable(): void
|
||||
{
|
||||
$this->seedReferences();
|
||||
$activity = DB::table('economic_activities')->where('code', 'energia')->first();
|
||||
|
||||
try {
|
||||
DB::table('economic_activities')->where('id', $activity->id)->update(['label_pt_mz' => 'Rótulo adulterado']);
|
||||
$this->fail('Published catalog values must be immutable.');
|
||||
} catch (QueryException $exception) {
|
||||
$this->assertSame('42501', $exception->errorInfo[0]);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_empty_catalog_migration_rolls_back_and_reapplies(): void
|
||||
{
|
||||
$migration = require database_path('migrations/2026_10_01_000100_create_reference_catalogs.php');
|
||||
$migration->down();
|
||||
$this->assertFalse(Schema::hasTable('economic_activities'));
|
||||
$this->assertFalse(Schema::hasTable('authorities'));
|
||||
$this->assertFalse(Schema::hasTable('reference_catalog_versions'));
|
||||
|
||||
$migration->up();
|
||||
$this->assertTrue(Schema::hasTable('economic_activities'));
|
||||
$this->assertTrue(Schema::hasTable('authorities'));
|
||||
$this->assertTrue(Schema::hasTable('reference_catalog_versions'));
|
||||
}
|
||||
|
||||
private function seedReferences(): void
|
||||
{
|
||||
$this->seed(SectorSeeder::class);
|
||||
$this->seed(TerritoryReferenceSeeder::class);
|
||||
$this->seed(ReferenceCatalogSeeder::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\RunSimulation;
|
||||
use App\Models\User;
|
||||
use App\Services\SimulationExecution;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SimulationExecutionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Queue::fake();
|
||||
}
|
||||
|
||||
public function test_anonymous_execution_keeps_only_token_hash_and_reconciles_immutable_sources(): void
|
||||
{
|
||||
$token = Str::random(48);
|
||||
$key = (string) Str::uuid();
|
||||
$response = $this->postJson('/api/v1/simulations', ['base' => '1000', 'rate' => '10', 'reduction' => '50'], [
|
||||
'Idempotency-Key' => $key, 'X-Simulation-Token' => $token,
|
||||
])->assertAccepted()->assertJsonPath('data.status', 'queued')->assertJsonPath('access_token', $token);
|
||||
$queuedEtag = $response->headers->get('ETag');
|
||||
$simulationId = $response->json('data.id');
|
||||
$this->assertDatabaseHas('simulations', ['id' => $simulationId, 'anonymous_token_hash' => hash_hmac('sha256', $token, (string) config('app.key'))]);
|
||||
$this->assertDatabaseMissing('simulations', ['id' => $simulationId, 'anonymous_token_hash' => $token]);
|
||||
|
||||
app(RunSimulation::class, ['simulationId' => $simulationId, 'actorId' => null, 'tokenHash' => hash_hmac('sha256', $token, (string) config('app.key'))])
|
||||
->handle(app(SimulationExecution::class));
|
||||
|
||||
$completedResponse = $this->getJson('/api/v1/simulations/'.$simulationId, ['X-Simulation-Token' => $token])
|
||||
->assertOk()->assertJsonPath('data.status', 'completed');
|
||||
$this->assertNotSame($queuedEtag, $completedResponse->headers->get('ETag'));
|
||||
$simulationView = $completedResponse->json('data');
|
||||
$this->assertEqualsWithDelta(50.0, (float) $simulationView['result_snapshot']['indicative_total'], 0.0000001);
|
||||
$this->assertDatabaseHas('simulation_result_sources', ['source_type' => 'user_input']);
|
||||
$this->assertSame(1, DB::table('simulation_reports')->where('simulation_id', $simulationId)->where('status', 'ready')->count());
|
||||
|
||||
$simulation = DB::table('simulations')->where('id', $simulationId)->first();
|
||||
$result = DB::table('simulation_results')->where('simulation_id', $simulationId)->first();
|
||||
$source = DB::table('simulation_result_sources')->where('result_id', $result->id)->first();
|
||||
$resultCanonical = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$result->result_snapshot])->canonical;
|
||||
$sourceCanonical = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$source->source_snapshot])->canonical;
|
||||
$this->assertSame($result->result_sha256, hash('sha256', $resultCanonical));
|
||||
$this->assertSame($source->source_sha256, hash('sha256', $sourceCanonical));
|
||||
$this->assertNotNull($simulation->questionnaire_version_id);
|
||||
try {
|
||||
DB::transaction(fn () => DB::table('simulation_audit_events')->where('simulation_id', $simulationId)->update(['action' => 'audit.tampered']));
|
||||
$this->fail('Simulation audit events must be append-only.');
|
||||
} catch (QueryException) {
|
||||
$this->assertDatabaseMissing('simulation_audit_events', ['simulation_id' => $simulationId, 'action' => 'audit.tampered']);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_execution_requires_an_explicit_user_supplied_reduction(): void
|
||||
{
|
||||
$this->postJson('/api/v1/simulations', ['base' => '500', 'rate' => '5'], [
|
||||
'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => Str::random(48),
|
||||
])->assertUnprocessable()->assertJsonValidationErrors('reduction');
|
||||
}
|
||||
|
||||
public function test_idempotency_replays_same_payload_and_rejects_key_reuse_with_different_payload(): void
|
||||
{
|
||||
$token = Str::random(48);
|
||||
$key = (string) Str::uuid();
|
||||
$headers = ['Idempotency-Key' => $key, 'X-Simulation-Token' => $token];
|
||||
$first = $this->postJson('/api/v1/simulations', ['base' => '500', 'rate' => '5', 'reduction' => '0'], $headers)->assertAccepted();
|
||||
$id = $first->json('data.id');
|
||||
$this->postJson('/api/v1/simulations', ['base' => '500', 'rate' => '5', 'reduction' => '0'], $headers)
|
||||
->assertOk()->assertJsonPath('data.id', $id)->assertJsonPath('replayed', true);
|
||||
$this->postJson('/api/v1/simulations', ['base' => '500', 'rate' => '6', 'reduction' => '0'], $headers)->assertConflict();
|
||||
$this->assertSame(1, DB::table('simulations')->where('id', $id)->count());
|
||||
}
|
||||
|
||||
public function test_if_match_is_required_and_stale_versions_return_412_then_success_advances_version(): void
|
||||
{
|
||||
$token = Str::random(48);
|
||||
$response = $this->postJson('/api/v1/simulations', ['base' => '1000', 'rate' => '10', 'reduction' => '0'], [
|
||||
'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token,
|
||||
])->assertAccepted();
|
||||
$id = $response->json('data.id');
|
||||
$scenarioId = $response->json('data.scenarios.0.id');
|
||||
app(RunSimulation::class, ['simulationId' => $id, 'actorId' => null, 'tokenHash' => hash_hmac('sha256', $token, (string) config('app.key'))])
|
||||
->handle(app(SimulationExecution::class));
|
||||
$response = $this->getJson('/api/v1/simulations/'.$id, ['X-Simulation-Token' => $token])->assertOk();
|
||||
$payload = ['label' => 'Cenário revisto', 'base' => '2000', 'rate' => '8', 'reduction' => '25'];
|
||||
$url = "/api/v1/simulations/{$id}/scenarios/{$scenarioId}";
|
||||
$headers = ['X-Simulation-Token' => $token];
|
||||
$this->patchJson($url, $payload, $headers)->assertStatus(428);
|
||||
$this->patchJson($url, $payload, $headers + ['If-Match' => '"sim-'.$id.'-v999"'])->assertStatus(412);
|
||||
$etag = $response->headers->get('ETag');
|
||||
$updated = $this->patchJson($url, $payload, $headers + ['If-Match' => $etag])->assertAccepted();
|
||||
$updated->assertHeader('ETag', '"sim-'.$id.'-v3"')->assertJsonPath('data.status', 'queued');
|
||||
|
||||
$tokenHash = hash_hmac('sha256', $token, (string) config('app.key'));
|
||||
app(RunSimulation::class, ['simulationId' => $id, 'actorId' => null, 'tokenHash' => $tokenHash])->handle(app(SimulationExecution::class));
|
||||
$this->assertSame(2, DB::table('simulation_results')->where('simulation_id', $id)->count());
|
||||
$this->assertSame(2, DB::table('simulation_reports')->where('simulation_id', $id)->count());
|
||||
$this->patchJson($url, $payload, $headers + ['If-Match' => $etag])->assertStatus(412);
|
||||
}
|
||||
|
||||
public function test_rls_hides_rows_without_context_and_from_an_unrelated_actor(): void
|
||||
{
|
||||
$owner = User::factory()->create(['email_verified_at' => now()]);
|
||||
$other = User::factory()->create(['email_verified_at' => now()]);
|
||||
$this->actingAs($owner)->postJson('/api/v1/simulations', ['base' => '10', 'rate' => '1', 'reduction' => '0'], ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted();
|
||||
$id = DB::table('simulations')->where('owner_user_id', $owner->id)->value('id');
|
||||
$execution = app(SimulationExecution::class);
|
||||
|
||||
DB::unprepared('DO $$ BEGIN CREATE ROLE simulation_rls_test NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$');
|
||||
DB::statement('GRANT USAGE ON SCHEMA public TO simulation_rls_test');
|
||||
DB::statement('GRANT SELECT ON public.simulations, public.memberships TO simulation_rls_test');
|
||||
try {
|
||||
DB::statement('SET ROLE simulation_rls_test');
|
||||
$execution->setContext($other->id, null);
|
||||
$this->assertSame(0, (int) DB::scalar('select count(*) from public.simulations where id = ?', [$id]));
|
||||
$execution->setContext($owner->id, null);
|
||||
$this->assertSame(1, (int) DB::scalar('select count(*) from public.simulations where id = ?', [$id]));
|
||||
} finally {
|
||||
DB::statement('RESET ROLE');
|
||||
DB::statement('DROP OWNED BY simulation_rls_test');
|
||||
DB::statement('DROP ROLE IF EXISTS simulation_rls_test');
|
||||
}
|
||||
$this->actingAs($other)->getJson('/api/v1/simulations/'.$id)->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_terminal_job_failure_is_reconciled_without_exposing_exception_text(): void
|
||||
{
|
||||
$owner = User::factory()->create(['email_verified_at' => now()]);
|
||||
$this->actingAs($owner)->postJson('/api/v1/simulations', ['base' => '10', 'rate' => '1', 'reduction' => '0'], ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted();
|
||||
$simulation = DB::table('simulations')->where('owner_user_id', $owner->id)->first();
|
||||
$job = new RunSimulation($simulation->id, $owner->id, null);
|
||||
$job->failed(new \RuntimeException('sensitive internal detail'));
|
||||
$this->assertDatabaseHas('simulation_jobs', ['simulation_id' => $simulation->id, 'status' => 'failed', 'error_code' => 'execution_failed']);
|
||||
$this->assertDatabaseHas('simulations', ['id' => $simulation->id, 'status' => 'failed', 'error_code' => 'execution_failed']);
|
||||
$this->assertDatabaseMissing('simulation_audit_events', ['simulation_id' => $simulation->id, 'action' => 'sensitive internal detail']);
|
||||
}
|
||||
|
||||
public function test_organization_membership_scopes_simulation_reads(): void
|
||||
{
|
||||
$owner = User::factory()->create(['email_verified_at' => now()]);
|
||||
$member = User::factory()->create(['email_verified_at' => now()]);
|
||||
$outsider = User::factory()->create(['email_verified_at' => now()]);
|
||||
$organizationId = (string) Str::uuid();
|
||||
DB::table('organizations')->insert(['id' => $organizationId, 'name' => 'Test organization', 'organization_type' => 'private',
|
||||
'data_classification' => 'internal', 'created_at' => now(), 'updated_at' => now()]);
|
||||
DB::table('memberships')->insert([
|
||||
['id' => (string) Str::uuid(), 'user_id' => $owner->id, 'organization_id' => $organizationId, 'role' => 'admin', 'created_at' => now(), 'updated_at' => now()],
|
||||
['id' => (string) Str::uuid(), 'user_id' => $member->id, 'organization_id' => $organizationId, 'role' => 'viewer', 'created_at' => now(), 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
$this->actingAs($owner)->postJson('/api/v1/simulations', ['base' => '100', 'rate' => '2', 'reduction' => '0', 'organization_id' => $organizationId],
|
||||
['Idempotency-Key' => (string) Str::uuid()])->assertAccepted();
|
||||
$id = DB::table('simulations')->where('owner_user_id', $owner->id)->value('id');
|
||||
$this->actingAs($member)->getJson('/api/v1/simulations/'.$id)->assertOk();
|
||||
$this->actingAs($outsider)->getJson('/api/v1/simulations/'.$id)->assertNotFound();
|
||||
|
||||
DB::unprepared('DO $$ BEGIN CREATE ROLE simulation_rls_test NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$');
|
||||
DB::statement('GRANT USAGE ON SCHEMA public TO simulation_rls_test');
|
||||
DB::statement('GRANT SELECT, UPDATE ON public.simulations TO simulation_rls_test');
|
||||
DB::statement('GRANT SELECT ON public.memberships TO simulation_rls_test');
|
||||
try {
|
||||
DB::statement('SET ROLE simulation_rls_test');
|
||||
$execution = app(SimulationExecution::class);
|
||||
$execution->setContext($member->id, null);
|
||||
$this->assertSame(1, (int) DB::scalar('select count(*) from public.simulations where id = ?', [$id]));
|
||||
$this->assertSame(0, DB::affectingStatement('update public.simulations set version_no = version_no + 1 where id = ?', [$id]));
|
||||
} finally {
|
||||
DB::statement('RESET ROLE');
|
||||
DB::statement('DROP OWNED BY simulation_rls_test');
|
||||
DB::statement('DROP ROLE IF EXISTS simulation_rls_test');
|
||||
}
|
||||
DB::table('memberships')->where('user_id', $owner->id)->where('organization_id', $organizationId)->delete();
|
||||
$this->actingAs($owner)->getJson('/api/v1/simulations/'.$id)->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_postgres_rls_uses_anonymous_token_hmac_not_plain_token(): void
|
||||
{
|
||||
$token = Str::random(48);
|
||||
$response = $this->postJson('/api/v1/simulations', ['base' => '40', 'rate' => '10', 'reduction' => '0'], [
|
||||
'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token,
|
||||
])->assertAccepted();
|
||||
$id = $response->json('data.id');
|
||||
$tokenHash = hash_hmac('sha256', $token, (string) config('app.key'));
|
||||
|
||||
DB::unprepared('DO $$ BEGIN CREATE ROLE simulation_rls_test NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$');
|
||||
DB::statement('GRANT USAGE ON SCHEMA public TO simulation_rls_test');
|
||||
DB::statement('GRANT SELECT ON public.simulations, public.memberships TO simulation_rls_test');
|
||||
try {
|
||||
DB::statement('SET ROLE simulation_rls_test');
|
||||
$execution = app(SimulationExecution::class);
|
||||
$execution->setContext(null, hash_hmac('sha256', 'wrong-token', (string) config('app.key')));
|
||||
$this->assertSame(0, (int) DB::scalar('select count(*) from public.simulations where id = ?', [$id]));
|
||||
$execution->setContext(null, $tokenHash);
|
||||
$this->assertSame(1, (int) DB::scalar('select count(*) from public.simulations where id = ?', [$id]));
|
||||
} finally {
|
||||
DB::statement('RESET ROLE');
|
||||
DB::statement('DROP OWNED BY simulation_rls_test');
|
||||
DB::statement('DROP ROLE IF EXISTS simulation_rls_test');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_retry_budget_is_bounded_and_failed_attempt_is_reconciled(): void
|
||||
{
|
||||
$owner = User::factory()->create(['email_verified_at' => now()]);
|
||||
$this->actingAs($owner)->postJson('/api/v1/simulations', ['base' => '10', 'rate' => '1', 'reduction' => '0'], ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted();
|
||||
$simulation = DB::table('simulations')->where('owner_user_id', $owner->id)->first();
|
||||
$execution = app(SimulationExecution::class);
|
||||
DB::transaction(function () use ($execution, $owner, $simulation): void {
|
||||
$execution->setContext($owner->id, null);
|
||||
DB::table('simulation_scenarios')->where('simulation_id', $simulation->id)->delete();
|
||||
});
|
||||
|
||||
$job = new RunSimulation($simulation->id, $owner->id, null);
|
||||
try {
|
||||
$job->handle($execution);
|
||||
$this->fail('A simulation without a scenario must fail and remain retryable.');
|
||||
} catch (\RuntimeException $exception) {
|
||||
$this->assertSame('Simulation has no scenario.', $exception->getMessage());
|
||||
}
|
||||
$this->assertSame(3, $job->tries);
|
||||
$this->assertSame([10, 60], $job->backoff());
|
||||
$this->assertDatabaseHas('simulation_jobs', ['simulation_id' => $simulation->id, 'status' => 'running', 'attempts' => 1]);
|
||||
$job->failed(new \RuntimeException('do not persist this detail'));
|
||||
$this->assertDatabaseHas('simulation_jobs', ['simulation_id' => $simulation->id, 'status' => 'failed', 'attempts' => 3]);
|
||||
}
|
||||
|
||||
public function test_empty_schema_rolls_back_and_reapplies_with_questionnaire_seed(): void
|
||||
{
|
||||
$migration = require database_path('migrations/2026_10_01_000200_create_simulation_execution_core.php');
|
||||
$migration->down();
|
||||
$this->assertFalse(Schema::hasTable('simulations'));
|
||||
$migration->up();
|
||||
$this->assertTrue(Schema::hasTable('simulation_results'));
|
||||
$this->assertDatabaseHas('questionnaire_versions', ['id' => '61000100-0000-4000-8000-000000000001', 'status' => 'published']);
|
||||
}
|
||||
|
||||
public function test_questionnaire_endpoint_exposes_only_published_version_with_etag(): void
|
||||
{
|
||||
$this->getJson('/api/v1/questionnaires/user-entered-arithmetic')->assertOk()
|
||||
->assertHeader('ETag', '"questionnaire-61000100-0000-4000-8000-000000000001-v1"')
|
||||
->assertJsonPath('data.locale', 'pt-MZ')->assertJsonPath('data.schema_snapshot.rate_origin', 'user_input');
|
||||
$this->getJson('/api/v1/questionnaires/unknown')->assertNotFound();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SimulationFormDraftTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_anonymous_draft_is_encrypted_scoped_and_restorable(): void
|
||||
{
|
||||
$token = Str::random(48);
|
||||
$key = (string) Str::uuid();
|
||||
$payload = $this->payload();
|
||||
$created = $this->postJson('/api/v1/simulation-drafts', $payload, [
|
||||
'Idempotency-Key' => $key, 'X-Simulation-Token' => $token,
|
||||
])->assertCreated()->assertJsonPath('data.version_no', 1);
|
||||
$id = $created->json('data.id');
|
||||
$stored = DB::table('simulation_form_drafts')->where('id', $id)->first();
|
||||
$this->assertNotSame(json_encode($payload['personal_snapshot']), $stored->personal_snapshot);
|
||||
$this->assertNotSame(json_encode($payload['economic_snapshot']), $stored->economic_snapshot);
|
||||
|
||||
$this->getJson('/api/v1/simulation-drafts/current', ['X-Simulation-Token' => $token])
|
||||
->assertOk()->assertJsonPath('data.personal_snapshot.legal_name', 'Entidade de teste')
|
||||
->assertJsonPath('data.economic_snapshot.base', '1200');
|
||||
$this->getJson('/api/v1/simulation-drafts/'.$id, ['X-Simulation-Token' => Str::random(48)])->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_creation_is_idempotent_and_rejects_key_reuse_with_changed_data(): void
|
||||
{
|
||||
$token = Str::random(48);
|
||||
$key = (string) Str::uuid();
|
||||
$headers = ['Idempotency-Key' => $key, 'X-Simulation-Token' => $token];
|
||||
$payload = $this->payload();
|
||||
$first = $this->postJson('/api/v1/simulation-drafts', $payload, $headers)->assertCreated();
|
||||
$id = $first->json('data.id');
|
||||
$this->postJson('/api/v1/simulation-drafts', $payload, $headers)
|
||||
->assertOk()->assertJsonPath('data.id', $id)->assertJsonPath('data.replayed', true);
|
||||
$payload['economic_snapshot']['base'] = '1300';
|
||||
$this->postJson('/api/v1/simulation-drafts', $payload, $headers)->assertConflict();
|
||||
}
|
||||
|
||||
public function test_updates_require_a_current_etag_and_delete_is_reversible_until_expiry(): void
|
||||
{
|
||||
$token = Str::random(48);
|
||||
$headers = ['Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token];
|
||||
$payload = $this->payload();
|
||||
$created = $this->postJson('/api/v1/simulation-drafts', $payload, $headers)->assertCreated();
|
||||
$id = $created->json('data.id');
|
||||
$url = '/api/v1/simulation-drafts/'.$id;
|
||||
$this->putJson($url, $payload, ['X-Simulation-Token' => $token])->assertStatus(428);
|
||||
$this->putJson($url, $payload, ['X-Simulation-Token' => $token, 'If-Match' => '"sfd-'.$id.'-v99"'])->assertStatus(412);
|
||||
$updated = $this->putJson($url, $payload, ['X-Simulation-Token' => $token, 'If-Match' => $created->headers->get('ETag')])
|
||||
->assertOk()->assertJsonPath('data.version_no', 2);
|
||||
$this->deleteJson($url, [], ['X-Simulation-Token' => $token, 'If-Match' => $updated->headers->get('ETag')])->assertNoContent();
|
||||
$this->assertDatabaseHas('simulation_form_drafts', ['id' => $id]);
|
||||
$this->assertDatabaseMissing('simulation_form_drafts', ['id' => $id, 'deleted_at' => null]);
|
||||
}
|
||||
|
||||
public function test_server_rejects_invalid_rates_and_unlisted_snapshot_fields(): void
|
||||
{
|
||||
$token = Str::random(48);
|
||||
$payload = $this->payload();
|
||||
$payload['economic_snapshot']['rate'] = '101';
|
||||
$this->postJson('/api/v1/simulation-drafts', $payload, [
|
||||
'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token,
|
||||
])->assertUnprocessable()->assertJsonValidationErrors('economic_snapshot.rate');
|
||||
|
||||
$payload = $this->payload();
|
||||
$payload['economic_snapshot']['unapproved_field'] = 'not accepted';
|
||||
$this->postJson('/api/v1/simulation-drafts', $payload, [
|
||||
'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token,
|
||||
])->assertUnprocessable()->assertJsonValidationErrors('economic_snapshot');
|
||||
}
|
||||
|
||||
/** @return array{personal_snapshot: array<string,string>, economic_snapshot: array<string,string>} */
|
||||
private function payload(): array
|
||||
{
|
||||
return [
|
||||
'personal_snapshot' => ['legal_name' => 'Entidade de teste', 'email' => 'teste@example.com'],
|
||||
'economic_snapshot' => ['purpose' => 'simulate_only', 'tax_calculation' => 'yes', 'base' => '1200', 'rate' => '8', 'reduction' => '0'],
|
||||
];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user