Compare commits

..
Author SHA1 Message Date
nala 1349789226 fix(offers): avoid Wayfinder report form name collision after safe deployment stop
tests / ci (push) Waiting to run
2026-11-14 20:10:32 +02:00
nala 9cbd7fb97d docs(offers): record mandatory stop on authorised revision mismatch 2026-11-14 19:47:07 +02:00
nala 2afbc5c9c7 docs(offers): verify live target backup and isolated recovery before install 2026-11-14 19:44:08 +02:00
nala 3558647e8d fix(offers): align private navigation with portal dropdown menus
tests / ci (push) Waiting to run
2026-11-14 19:07:14 +02:00
nala d954c83b78 docs(offers): prepare gated homologation readiness and recovery plan 2026-11-14 18:47:11 +02:00
nala 9a01426864 fix(offers): preserve description in authorised investment context 2026-11-14 18:39:25 +02:00
nala ed2fd86f6b audit(offers): record acceptance blockers before homologation 2026-11-14 18:25:09 +02:00
nala 528a2597ed feat(offers): complete phase five operational controls in QA 2026-11-14 18:08:46 +02:00
nala e431de8b42 feat(offers): implement phase four private details and confidentiality in QA 2026-11-14 17:28:12 +02:00
nala 7691cab3bc feat(offers): add gated phase three public catalogue in QA 2026-11-14 16:49:58 +02:00
nala 91c1857368 feat: integrate private offers with existing editorial workflow 2026-11-14 16:20:30 +02:00
nala 6e302db62e docs: confirm private offers QA and plan phase two 2026-11-14 13:58:33 +02:00
nala 1f2990e4da feat: prepare private offers through existing opportunities 2026-11-14 13:41:31 +02:00
nala e8408c27d9 Record verified administration dashboard hierarchy correction 2026-11-14 12:34:05 +02:00
nala 96f510965f Separate dashboard functions from grouped administration operations
tests / ci (push) Waiting to run
2026-11-14 12:25:27 +02:00
nala 88f18d0b7e Record verified dashboard HTML recovery dd20e747 2026-11-14 12:13:54 +02:00
nala dd20e74703 Fix authenticated editorial dashboard missing Vite page entry
tests / ci (push) Waiting to run
2026-11-14 12:04:52 +02:00
nala 203c8f9193 Record verified dashboard menu deployment 73e1d7b9 2026-11-14 11:59:02 +02:00
nala 73e1d7b964 Group dashboard activities under primary administration and tidy editorial overview
tests / ci (push) Waiting to run
2026-11-14 11:43:56 +02:00
nala ce8b744d1f Record verified grouped dashboard installation a4541298 2026-11-14 11:31:38 +02:00
nala a4541298c0 Complete grouped collaborative editorial queues and native review coverage
tests / ci (push) Waiting to run
2026-11-14 11:15:09 +02:00
nala f40924f1be Record authorised grouped editorial installation and preservation receipts 2026-11-14 10:11:23 +02:00
nala d5c960b62f Merge remote-tracking branch 'origin/main' into qa/editorial-fase1-20261113
tests / ci (push) Waiting to run
2026-11-14 09:12:25 +02:00
nala fa69884ab0 Integrate authorised editorial package with current homologation changes 2026-11-14 09:11:55 +02:00
nala e473542494 Support documented full annual noncivil PPC PEC estimates and dossier continuity
tests / ci (push) Waiting to run
2026-11-14 09:05:33 +02:00
nala b3d4b7abe6 Preserve published territories during private editorial review 2026-11-14 09:03:05 +02:00
nala d3eeb70370 Record authorised period consistency installation and preserved data 2026-11-14 09:02:37 +02:00
nala 3c4c2599f7 feat(editorial): review demographic batches before explicit publication 2026-11-14 08:35:11 +02:00
nala 078f770dea Keep PPC PEC estimates and reports consistent with the prepared L6 period
tests / ci (push) Waiting to run
2026-11-14 00:23:46 +02:00
nala e9278ee446 Record authorised grouped PEC installation and preserved data 2026-11-14 00:00:21 +02:00
nala 2459d0ca37 feat(editorial): stage portfolio base data and territory for atomic publication 2026-11-13 23:47:04 +02:00
nala 95b33275aa Integrate monthly PPC PEC cash projections across screen and dossier report
tests / ci (push) Canceled after 0s
2026-11-13 23:32:00 +02:00
nala a045162773 Record authorised grouped PEC installation and preserved data 2026-11-13 23:29:17 +02:00
nala d2c72f5e13 feat(editorial): preserve public portfolio fiches during private review 2026-11-13 23:23:16 +02:00
nala f059c226d2 Group documented prior PEC basis and excess PPC reconciliation for viability
tests / ci (push) Canceled after 0s
2026-11-13 23:14:22 +02:00
nala 1218734eca feat(editorial): separate cluster analysis approval and publication in QA 2026-11-13 23:08:02 +02:00
nala d9f088587b Record authorised observation nature delivery and preserved data 2026-11-13 23:07:27 +02:00
nala b3849d850e feat(editorial): preserve public editions and add QA review workflow 2026-11-13 22:56:44 +02:00
nala 2bad72c042 Expose calendar duration and overlap before comparing territorial observations
tests / ci (push) Canceled after 0s
2026-11-13 22:54:49 +02:00
nala 39bddad091 Record authorised observation nature delivery and preserved data 2026-11-13 22:54:46 +02:00
nala 42b9324acb Classify sourced territorial observations without inferring legacy values
tests / ci (push) Canceled after 0s
2026-11-13 22:44:42 +02:00
nala 7e7bd4fde3 Record authorised territorial comparison delivery and preserved data 2026-11-13 22:09:58 +02:00
nala 1263b4f6b7 Compare sourced territorial observations without inferring statistical equivalence
tests / ci (push) Canceled after 0s
2026-11-13 21:52:38 +02:00
nala 2f1ef2fdfc Align quick search with current financial directory while retaining historical records
tests / ci (push) Canceled after 0s
2026-11-13 21:14:25 +02:00
nala 61b4d9f1f9 Record authorised stage report installation and preservation 2026-11-13 15:19:12 +02:00
nala 1d52c3d8f4 Preserve installed dossier and support presentation while delivering authorised quick search
tests / ci (push) Canceled after 0s
2026-11-13 15:17:26 +02:00
nala 0f79d0d54c Keep institutional contact test independent of CSS module loading in Node CI
tests / ci (push) Canceled after 0s
2026-11-13 15:09:08 +02:00
nala d325b72fad Improve investor quick search with typed counts, product categories and licensing guidance 2026-11-13 15:06:11 +02:00
nala 8854b2928c Allow private stage report preview and explicit Word/HTML download 2026-11-13 15:02:15 +02:00
paulomassigue f53a308327 Merge branch 'main' of https://git.tempo.co.mz/nala/Investidor-V2
tests / ci (push) Canceled after 0s
2026-11-13 14:43:34 +02:00
nala 723ce2a0a4 Record grouped investor reports installed and preservation verified 2026-11-13 14:28:10 +02:00
nala 07182c7c73 Group investor reports and preserved financial copies in their existing dossier
tests / ci (push) Canceled after 0s
2026-11-13 14:09:52 +02:00
nala bb6824d75d Keep concurrent manual additions outside demographic delivery 2026-11-13 14:08:20 +02:00
nala 095280e29d Record demographic matrix status while preserving concurrent additions in working copy 2026-11-13 14:07:52 +02:00
nala e92519dd75 Record HTTP500 correction verified in real PHP-FPM with process execution blocked 2026-11-13 14:06:58 +02:00
nala 4988ca89dc Use verified CLI preparations when web PHP cannot execute processes
tests / ci (push) Canceled after 0s
2026-11-13 13:57:01 +02:00
nala 258c8bd28b Record installed demography access correction and refreshed readiness 2026-11-13 13:49:32 +02:00
nala df543a4414 Recognise designated superusers in authenticated demography installation
tests / ci (push) Canceled after 0s
2026-11-13 13:41:48 +02:00
nala 291a1c420f Record demography code installed in homologation, authenticated data confirmation pending 2026-11-13 13:40:16 +02:00
nala b20068d04b Deliver additive INE demography and authenticated homologation installation
tests / ci (push) Canceled after 0s
2026-11-13 13:27:36 +02:00
paulomassigue eab09dd61e ajuste na pesquisa de informacao, para passar a dar prioridade 2026-11-13 12:07:51 +02:00
paulomassigue 71f2fcbb84 Merge branch 'main' of https://git.tempo.co.mz/nala/Investidor-V2 into front 2026-11-13 09:54:01 +02:00
paulomassigue f6c1b60612 organizao de suporte 2026-11-12 11:26:33 +02:00
nala 880e12890c Record mining and energy reference delivery installed in f2767818 2026-11-11 14:20:23 +02:00
nala f276781876 Keep sector reference byte verification identical across Windows and Git
tests / ci (push) Canceled after 0s
2026-11-11 14:13:51 +02:00
nala 542ac2ff2c Integrate mining and energy references into sector and dossier journeys 2026-11-11 14:10:15 +02:00
nala 4971f8935b Record reconciled profile and six-menu delivery installed in 6d3bf293 2026-11-11 12:39:23 +02:00
nala 6d3bf2937e Merge remote-tracking branch 'origin/main' into entrega/directiva-20261110
tests / ci (push) Canceled after 0s
2026-11-11 12:31:10 +02:00
nala eca6719135 Unify institution profiles and reduce primary menu to six groups 2026-11-11 12:11:52 +02:00
paulomassigue a400802b63 Merge branch 'front' of https://git.tempo.co.mz/nala/Investidor-V2
tests / ci (push) Canceled after 0s
2026-11-11 11:53:57 +02:00
paulomassigue 1b8b9f414a . 2026-11-11 11:53:41 +02:00
paulomassigue 70423474d2 navbar fixa durante a rolagem 2026-11-11 11:46:12 +02:00
nala ee23a2cc2c Record financial editorial a59142ca installation and preservation 2026-11-11 11:39:22 +02:00
nala a59142caa2 Present financial institutions with professional investor-facing descriptions and products
tests / ci (push) Canceled after 0s
2026-11-11 11:19:27 +02:00
nala 8ebe734d0d Record PEC correction 8f295f05 installation and data preservation 2026-11-11 11:03:50 +02:00
nala 8f295f0551 Preserve documented turnover corrections in PEC viability estimates
tests / ci (push) Canceled after 0s
2026-11-11 10:55:53 +02:00
nala a2033dd4a0 Record viability simulation a708f079 installation and data preservation 2026-11-11 10:46:21 +02:00
nala a708f079b3 Clarify viability simulation purpose and preserve PEC cash-flow hypotheses
tests / ci (push) Canceled after 0s
2026-11-11 10:37:33 +02:00
nala aae79c712d Record PEC bcbbe51c installation and verified preservation 2026-11-11 10:28:46 +02:00
nala bcbbe51c2f Prepare PEC obligation total and calendar with private recovery and reports
tests / ci (push) Canceled after 0s
2026-11-11 09:59:54 +02:00
nala e549003e5d Record PPC fbff2660 installation and verified preservation 2026-11-11 09:05:24 +02:00
nala fbff2660f2 Preparar obriga??o PPC ordin?ria com recupera??o e relat?rio no dossier
tests / ci (push) Canceled after 0s
2026-11-11 08:55:14 +02:00
nala 192a7e35e5 Registar retoma educativa instalada em 6805b606 e preservação conferida 2026-11-11 08:46:32 +02:00
nala 6805b606a1 Complete durable private Education review preparation and recovery
tests / ci (push) Canceled after 0s
2026-11-11 08:21:56 +02:00
nala bbc3c1c9bc Registar combinação CBF15 instalada em 9c067d6a e dados preservados 2026-11-11 07:45:02 +02:00
nala 9c067d6ae8 Completar combinação CBF15 com saldos anteriores e conferir PEC residual
tests / ci (push) Canceled after 0s
2026-11-11 07:35:04 +02:00
nala 67cd583b94 Confirmar 270 instituições publicadas e consulta financeira por localização 2026-11-10 22:55:05 +02:00
nala 0f280d2d6d Confirmar acesso ao editor no Edge com a sessão autenticada correcta 2026-11-10 22:12:24 +02:00
nala 0e8da995e0 Diagnosticar recusa do editor em sessão Safari distinta do login Edge 2026-11-10 22:11:07 +02:00
nala ff293940a6 Registar retoma editorial instalada em 4f3dbcf8 e conteúdo ainda por publicar 2026-11-10 22:02:56 +02:00
nala 4f3dbcf864 Conservar progresso editorial entre renovações MFA e tornar a retoma visível
tests / ci (push) Canceled after 0s
2026-11-10 21:55:42 +02:00
nala d830fbca50 Registar instalação ef24cbdc e disponibilização editorial ainda por confirmar 2026-11-10 21:19:01 +02:00
nala ef24cbdc92 Conferir e publicar instituições pelo editor assistido com MFA nativo
tests / ci (push) Canceled after 0s
2026-11-10 21:09:18 +02:00
nala 5a5cc9e815 Integrate supplied institutions by location category and type through investor dossiers 2026-11-10 20:59:24 +02:00
nala 5ff9402fd0 Prepare supplied financial directory with product coverage and native dossier continuity 2026-11-10 18:51:23 +02:00
nala c8e0a7ee39 Record installed APIEX SWOT and prominent Concursos delivery receipt 2026-11-10 18:23:31 +02:00
nala 1c75a30a60 Preserve S08 hash lifecycle while revealing linked SWOT tasks
tests / ci (push) Canceled after 0s
2026-11-10 18:17:13 +02:00
nala c790159250 Restore prominent Concursos dropdown before authorized APIEX and SWOT delivery
tests / ci (push) Canceled after 0s
2026-11-10 18:07:50 +02:00
nala 37854d0231 Complete APIEX documentary preparation and private contextual SWOT in existing dossiers 2026-11-10 18:00:06 +02:00
nala 7476dc3430 feat: carry documented investment credits within original project window
tests / ci (push) Canceled after 0s
2026-11-10 16:17:48 +02:00
nala d216830b06 fix: unify horizontal portal menus and remove discovery duplicates
tests / ci (push) Canceled after 0s
2026-11-10 15:48:23 +02:00
nala d5cb41b111 feat: apply investment credit five-exercise window
tests / ci (push) Canceled after 0s
2026-11-10 15:35:24 +02:00
nala 89e6d72c1e feat: calculate qualified investment credits in annual settlement
tests / ci (push) Canceled after 0s
2026-11-10 15:09:31 +02:00
nala 7c8d4726e9 feat: qualify treaty interest under governmental financing agreements
tests / ci (push) Canceled after 0s
2026-11-10 14:18:47 +02:00
nala b10b35f308 feat: support public-payer treaty exemption and exact economic adjustments
tests / ci (push) Canceled after 0s
2026-11-10 13:54:06 +02:00
nala 60b52fc8c7 feat: derive qualified Portugal treaty credit limits
tests / ci (push) Canceled after 0s
2026-11-10 13:38:56 +02:00
nala ac67f9c13a feat: reconcile L6 economic income and extend L8 credits
tests / ci (push) Canceled after 0s
2026-11-10 13:23:11 +02:00
nala c359364de3 feat(portal): deliver ordinary national credit fraction and education continuity
tests / ci (push) Canceled after 0s
2026-11-10 12:47:57 +02:00
nala ff726e577f docs(portal): diagnose browser bridge and record scoped review
tests / ci (push) Canceled after 0s
2026-11-10 11:11:40 +02:00
nala 03bcaf38cd fix(licensing): keep hash target revealed after next-stage updates 2026-11-10 11:10:53 +02:00
nala 659c039b27 docs(licensing): record S08 coverage and keep visual review and installation separate 2026-11-10 11:01:29 +02:00
nala 6734441855 fix(licensing): simplify S08 and reveal linked stage without losing context 2026-11-10 11:00:56 +02:00
nala 95def7f75e docs(portal): preserve S08 pending scope after owner clarification 2026-11-10 10:52:49 +02:00
nala 786d5f78d1 docs(portal): record Portal V4 source recovery for S08 2026-11-10 10:49:00 +02:00
nala 5e00f1ee9e docs(portal): map scoped commits and preserve S08 and visual pending work 2026-11-10 10:42:40 +02:00
nala 252f38bb0a feat(portal): expose bounded public sitemap 2026-11-10 10:41:50 +02:00
nala 7f13149723 feat(opportunities): curate highlights with native authority and audited confirmation 2026-11-10 10:41:50 +02:00
nala dcecd25102 feat(desk): print authorized saved pre-dossier 2026-11-10 10:41:49 +02:00
nala 4e07557cb1 fix(opportunities): distinguish missing verification and improve metadata 2026-11-10 10:41:48 +02:00
nala 4e61480272 fix(portal): identify operator and institutional support contact 2026-11-10 10:41:47 +02:00
nala 377c40a21a fix(portal): preserve territorial totals and legacy redirect context 2026-11-10 10:41:46 +02:00
paulomassigue 2209b83820 login de cadastro padronizado
tests / ci (push) Canceled after 0s
2026-11-09 15:05:20 +02:00
paulomassigue 67aa015b4c Merge branch 'front' of https://git.tempo.co.mz/nala/Investidor-V2
tests / ci (push) Waiting to run
2026-11-05 14:59:29 +02:00
paulomassigue 4f630b9f2a pesquisa avancada 2026-11-05 14:54:39 +02:00
paulomassigue 5859a9810e Merge branch 'main' of https://git.tempo.co.mz/nala/Investidor-V2
tests / ci (push) Canceled after 0s
2026-11-05 12:21:50 +02:00
paulomassigue dab85aee96 correcao de conflitos 2026-11-05 12:21:25 +02:00
nala 12cecd061b commit de menus
tests / ci (push) Canceled after 0s
2026-11-05 08:36:28 +02:00
paulomassigue 705dc69d68 pesquisa intelignte
tests / ci (push) Canceled after 0s
2026-11-04 15:52:55 +02:00
paulomassigue 6da444ae2e .
tests / ci (push) Canceled after 0s
2026-11-04 15:10:29 +02:00
nala 7e351348af feat: filter public map indicators by indicator and reference period 2026-11-04 12:55:03 +02:00
paulomassigue c5756c0282 pesquisa avancada no portal 2026-11-04 12:38:21 +02:00
nala 3f50f66633 feat: display precise public labour and economic observations in map context
tests / ci (push) Canceled after 0s
2026-11-04 11:45:42 +02:00
nala a9241ff26b feat: add workforce evidence and sourced economic context map layers 2026-11-04 11:27:37 +02:00
nala 21ebad73e7 feat: add public resource and infrastructure evidence layers to catalogue map 2026-11-04 11:16:01 +02:00
nala bbc05f1df6 feat: show immediate administrative subdivisions and map coverage gaps 2026-11-04 10:58:28 +02:00
nala 6dde36b5ca fix: preserve territorial scope in catalogue CSV and record local visual QA 2026-11-04 10:49:22 +02:00
nala dc0bbd74d5 test: verify local main against isolated PostGIS QA 2026-11-04 10:16:58 +02:00
nala 66a5dbf311 docs: record verified local reconciliation integration without push 2026-11-04 09:18:28 +02:00
paulomassigue bf2c054943 Merge branch 'main' of https://git.tempo.co.mz/nala/Investidor-V2 into front 2026-11-03 15:31:43 +02:00
paulomassigue f3455432cf . 2026-11-03 15:31:25 +02:00
561 changed files with 95257 additions and 1744 deletions
+5
View File
@@ -119,3 +119,8 @@ INVESTMOZ_TAX_RULE_APPROVERS=
INVESTMOZ_TAX_RULE_PUBLISHERS=
INVESTMOZ_ZONE_TERRITORY_REVIEWERS=
# Local LibreTranslate service and installed language models.
LIBRETRANSLATE_URL=http://127.0.0.1:5000
LIBRETRANSLATE_TIMEOUT=60
LIBRETRANSLATE_API_KEY=
+13
View File
@@ -1,3 +1,16 @@
## Uniformização pública e menu — decisão expressa do proprietário,11/11/2026
Lista e detalhe das instituições financeiras devem usar a mesma apresentação:descrição única,categoria/tipo sem repetição,produtos/serviços,contactos e moradas úteis apenas quando disponíveis. Não apresentar secções vazias,avisos genéricos repetidos,notas de publicação/importação,preços antigos por reconfirmar ou a mesma informação várias vezes. Deduplicar apenas a apresentação,conservando dados e fontes no editor. Usar Quorus e Moza Banco como exemplos de conferência.
Menu principal:Início,Descoberta,Realização,Suporte,Sobre nós,Mais. Descoberta:Concursos e anúncios,Localizações,Instituições financeiras,Catálogos relacionados. Realização:Iniciar investimento,Licenciamento,Acesso à terra. Suporte:Biblioteca,Legislação,Serviços ao investidor,Acompanhamento do projecto,Apoio ao investidor. Preservar conteúdo deSobre nós/Mais,contexto dos dossiers e permissões. Escolha de apresentação:acessos de trabalho antes no nível principal passam paraMais,sem novas concessões.
## Comunicação pública — orientação expressa do proprietário,11/11/2026
Apresentar conteúdos públicos com linguagem profissional,clara e apelativa,centrada na entidade,oportunidade,produtos e serviços úteis ao investidor. Não usar descrições de importação,cadastro,fichas,ficheiros ou documentos geradores como apresentação comercial. Preservar as fontes,versões e rastreabilidade no cadastro/editor;ligações institucionais e datas úteis podem ser consultadas separadamente. Não inventar ofertas,condições,disponibilidade,cobertura ou garantias para preencher lacunas. Esta orientação aplica-se transversalmente aos conteúdos do portal.
## Simulação de viabilidade — clarificação expressa do proprietário,11/11/2026
O InvesteMoz simula impostos para avaliar a viabilidade do negócio; não actua como entidade que executa pagamentos. Apresentar PPC/PEC e distribuição por meses como estimativas fiscais e projecções de tesouraria,com fontes e pressupostos. Montantes já pagos,quando fornecidos,são dados documentais para o cálculo. Não confundir uma hipótese de caixa com pagamento efectuado,declaração submetida ou cumprimento fiscal certificado. Preservar os cálculos,históricos e dados aceites; não criar serviços de cobrança/execução de pagamentos como requisito de conclusão deste simulador.
# Instruções permanentes do projeto InvesteMoz
## Encerramento e reabertura do dossier — decisão do proprietário, 03/11/2026
@@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use App\Models\Opportunity;
use App\Services\OfferAvailability;
use App\Services\PrivateOfferOperations;
use Illuminate\Console\Command;
final class ExpirePrivateOffers extends Command
{
protected $signature = 'investmoz:offers-expire {--confirm : Persist the already effective expiry}';
protected $description = 'Conferir expiração de Ofertas sem publicar conteúdos ou alterar o NDA';
public function handle(): int
{
$count = 0;
Opportunity::query()->whereNotNull('workflow_context->private_offer_preparation')->orderBy('id')->chunkById(100, function ($records) use (&$count) {
foreach ($records as $record) {
if (app(OfferAvailability::class)->record($record)['state'] !== 'expired') {
continue;
}
if ($this->option('confirm')) {
if (app(PrivateOfferOperations::class)->expire($record->id)) {
$count++;
}
} else {
$count++;
}
}
});
$this->info(($this->option('confirm') ? 'Expirações registadas: ' : 'Expiradas, sem escrita: ').$count);
return self::SUCCESS;
}
}
@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Throwable;
class TranslationApiController extends Controller
{
public function languages(): JsonResponse
{
try {
$response = Http::connectTimeout(2)->timeout(config('services.translation.timeout'))->get($this->url().'/languages');
if ($response->successful() && is_array($response->json())) {
return response()->json($response->json())->header('Cache-Control', 'no-store');
}
} catch (Throwable) {
}
return response()->json(['message' => 'Serviço de tradução temporariamente indisponível.'], 503);
}
public function translate(Request $request): JsonResponse
{
$data = $request->validate([
'text' => is_array($request->input('text'))
? 'required|array|min:1|max:8'
: 'required|string|max:10000',
'text.*' => 'required|string|max:10000',
'source_lang' => 'required|string|max:20',
'target_lang' => 'required|string|max:20',
'is_html' => 'boolean',
]);
$isBatch = is_array($data['text']);
if ($isBatch && mb_strlen(implode('', $data['text'])) > 10000) {
return response()->json(['message' => 'O lote de tradução excede o limite permitido.'], 422);
}
if ($data['source_lang'] === $data['target_lang']) {
return response()->json([
'original_text' => $data['text'],
'translated_text' => $data['text'],
'source_lang' => $data['source_lang'],
'target_lang' => $data['target_lang'],
])->header('Cache-Control', 'no-store');
}
$payload = ['q' => $data['text'], 'source' => $data['source_lang'], 'target' => $data['target_lang'], 'format' => ($data['is_html'] ?? false) ? 'html' : 'text'];
if (config('services.translation.api_key')) {
$payload['api_key'] = config('services.translation.api_key');
}
try {
$response = Http::connectTimeout(2)->timeout(config('services.translation.timeout'))->post($this->url().'/translate', $payload);
$translated = $response->json('translatedText');
$validTranslation = $isBatch
? is_array($translated)
&& count($translated) === count($data['text'])
&& ! array_filter($translated, fn ($text) => ! is_string($text) || $text === '')
: is_string($translated) && $translated !== '';
if ($response->successful() && $validTranslation) {
return response()->json([
'original_text' => $data['text'],
'translated_text' => $translated,
'source_lang' => $data['source_lang'],
'target_lang' => $data['target_lang'],
])->header('Cache-Control', 'no-store');
}
} catch (Throwable) {
}
return response()->json(['message' => 'Serviço de tradução temporariamente indisponível.'], 503)->header('Cache-Control', 'no-store');
}
private function url(): string
{
return rtrim((string) config('services.translation.url'), '/');
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Opportunity;
use App\Services\InvestmentGeography;
use App\Services\SectorOrganization;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -20,8 +21,9 @@ class HomeController extends Controller
$locale = $validated['locale'] ?? 'pt';
$data = Cache::remember("portal_home_sector_organization_v1_{$locale}", now()->addMinutes(10), function () {
$data = Cache::remember("portal_home_territorial_v2_{$locale}", now()->addMinutes(10), function () {
$sectors = SectorOrganization::catalog();
$statistics = app(InvestmentGeography::class)->opportunityStatistics();
$featured = Opportunity::with('sector:id,name,slug')
->published()->where('is_featured', true)
@@ -48,9 +50,9 @@ class HomeController extends Controller
'url' => "/oportunidades/{$o->slug}",
])->values()->all(),
'stats' => [
'opportunities' => Opportunity::published()->count(),
'opportunities' => $statistics['opportunities'],
'sectors' => $sectors->count(),
'provinces' => Opportunity::published()->distinct('province')->count('province'),
'provinces' => $statistics['provinces'],
],
];
});
@@ -19,7 +19,7 @@ final class ClusterCompetitivenessController extends Controller
$data = $request->validate(['expected_version' => 'required|integer|min:0',
'draft' => $action === 'saved' ? 'required|array:version,clusterId,sector,region,forces' : 'prohibited',
'revision' => $action === 'restored' ? 'required|integer|min:1' : 'prohibited',
'reason' => in_array($action, ['approved', 'withdrawn'], true) ? 'required|string|min:10|max:3000' : 'nullable|string|max:3000']);
'reason' => in_array($action, ['saved', 'restored'], true) ? 'nullable|string|max:3000' : 'required|string|min:10|max:3000']);
return response()->json($service->change($cluster, $request->user()->id, (int) $data['expected_version'], $action, $data))
->header('Cache-Control', 'private, no-store');
@@ -7,6 +7,8 @@ use App\Models\InvestmentGap;
use App\Models\ValueChain;
use App\Models\ValueChainLink;
use App\Services\PortalAudit;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -21,6 +23,17 @@ class ClusterValueChainController extends Controller
'to_value_chain_id' => ['required', 'uuid', 'different:from_value_chain_id', Rule::exists('value_chains', 'id')->where('cluster_id', $cluster->id)->whereNull('deleted_at')],
'link_type' => ['required', Rule::in(['supplies', 'processes', 'distributes', 'supports'])],
]);
if (app(PortalEditorialSubjects::class)->available()) {
$edition = DB::transaction(function () use ($cluster, $data, $request) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
$link = ValueChainLink::where($data)->first();
abort_if($link !== null, 409, 'Ligação existente. Abra a edição desse registo para alterar.');
return app(PortalEditorialSubjects::class)->stageModel($request->user(), 'value-chain-links', new ValueChainLink, $data, 0);
});
return redirect('/management/editorial/subjects/value-chain-links/'.$edition->subject_key)->with('success', 'Ligação guardada numa proposta privada.');
}
DB::transaction(function () use ($cluster, $data, $request) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
$link = ValueChainLink::where($data)->first() ?? new ValueChainLink;
@@ -32,6 +45,13 @@ class ClusterValueChainController extends Controller
public function removeLink(Request $request, EconomicCluster $cluster, ValueChainLink $link): RedirectResponse
{
if (app(PortalEditorialSubjects::class)->writes('value-chain-links', $link->id)) {
abort_unless(ValueChain::where('cluster_id', $cluster->id)->whereIn('id', [$link->from_value_chain_id, $link->to_value_chain_id])->count() === 2, 404);
$workflow = app(PortalEditorialWorkflow::class);
$workflow->saveSubject($request->user(), 'value-chain-links', $link->id, $workflow->subjectVersion('value-chain-links', $link->id), [], false, true);
return redirect('/management/editorial/subjects/value-chain-links/'.$link->id)->with('success', 'Retirada proposta. A ligação actual mantém-se até à publicação da decisão.');
}
DB::transaction(function () use ($cluster, $link, $request) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
abort_unless(ValueChain::where('cluster_id', $cluster->id)->whereIn('id', [$link->from_value_chain_id, $link->to_value_chain_id])->count() === 2, 404);
@@ -44,6 +64,7 @@ class ClusterValueChainController extends Controller
public function gap(Request $request, EconomicCluster $cluster, ?InvestmentGap $gap = null): RedirectResponse
{
abort_if($gap && $gap->cluster_id !== $cluster->id, 404);
$editorial = $gap ? app(PortalEditorialSubjects::class)->writes('investment-gaps', $gap->id) : app(PortalEditorialSubjects::class)->available();
$data = $request->validate([
'value_chain_id' => ['required', 'uuid', Rule::exists('value_chains', 'id')->where('cluster_id', $cluster->id)->whereNull('deleted_at')],
'title' => ['required', 'string', 'max:200'],
@@ -52,7 +73,13 @@ class ClusterValueChainController extends Controller
'rationale' => ['required', 'string', 'min:10', 'max:5000'],
'status' => ['required', Rule::in(['identified', 'in_review', 'resolved', 'withdrawn'])],
'call_to_action' => ['required', 'string', 'max:2000'],
...($editorial && $gap ? ['editorial_version' => ['required', 'integer', 'min:1']] : []),
]);
if ($editorial) {
$edition = app(PortalEditorialSubjects::class)->stageModel($request->user(), 'investment-gaps', $gap ?? new InvestmentGap, [...array_diff_key($data, ['editorial_version' => true]), 'cluster_id' => $cluster->id], $gap ? (int) $data['editorial_version'] : 0);
return redirect('/management/editorial/subjects/investment-gaps/'.$edition->subject_key)->with('success', 'Lacuna guardada numa proposta privada.');
}
PortalAudit::save($gap ?? new InvestmentGap, [...$data, 'cluster_id' => $cluster->id], $request->user()->id);
return back()->with('success', 'Lacuna de investimento guardada.');
+3 -2
View File
@@ -7,6 +7,7 @@ use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\DashboardWorkQueue;
use App\Services\InvestorPath;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
@@ -28,7 +29,7 @@ class DashboardController extends Controller
$contexts = $path->contexts($request->user());
$current = $contexts ? $path->view($request->user(), $contexts[0]['id']) : null;
return Inertia::render('dashboard', ['summary' => null, 'workQueue' => $workQueue, 'investorPath' => $path->available() ? [
return Inertia::render('dashboard', ['editorialQueue' => app(PortalEditorialWorkflow::class)->dashboard($request->user()), 'summary' => null, 'workQueue' => $workQueue, 'investorPath' => $path->available() ? [
'contexts' => $contexts,
'current' => $current ? ['id' => $current['case']['id'], 'next' => $current['next'],
'pending' => count(array_filter($current['steps'], fn ($step) => ! in_array($step['state'], ['completed', 'approved', 'dispensed'], true))),
@@ -43,7 +44,7 @@ class DashboardController extends Controller
$drafts = app(PortalProfileAccess::class)->scope($drafts, $request->user());
}
return Inertia::render('dashboard', ['workQueue' => $workQueue, 'summary' => [
return Inertia::render('dashboard', ['editorialQueue' => app(PortalEditorialWorkflow::class)->dashboard($request->user()), 'workQueue' => $workQueue, 'summary' => [
'canCreateOpportunity' => app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()),
'requestsByStatus' => collect(['received', 'in_review', 'contacted', 'closed'])
->mapWithKeys(fn ($status) => [$status => (int) ($counts[$status] ?? 0)]),
@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers;
use App\Services\DemographicEvidence;
use App\Services\DemographicImport;
use App\Services\DemographicInstallation;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
final class DemographyController extends Controller
{
public function index(Request $request, DemographicEvidence $service): Response|JsonResponse
{
$data = $request->validate(['territories' => 'nullable|array|max:4', 'territories.*' => 'required|uuid|distinct',
'start_year' => 'nullable|integer|between:1900,2100', 'end_year' => 'nullable|integer|between:1900,2100|gte:start_year',
'sector' => 'nullable|in:'.implode(',', array_keys(DemographicEvidence::sectors())), 'edition' => 'nullable|uuid']);
$start = (int) ($data['start_year'] ?? date('Y'));
$end = (int) ($data['end_year'] ?? $start);
abort_unless($end >= $start && $end - $start <= 50, 422, 'Escolha um horizonte até 50 anos.');
$evidence = $service->analyse($data['territories'] ?? [], $start, $end, $data['sector'] ?? 'general', $data['edition'] ?? null);
if ($request->expectsJson()) {
return response()->json($evidence);
}
$territories = $service->territories()->orderBy('name')->get(['id', 'name', 'parent_id', 'location_type']);
return Inertia::render('investment/demography', ['evidence' => $evidence, 'filters' => $data, 'territories' => $territories, 'sectors' => DemographicEvidence::sectors()]);
}
public function management(Request $request, DemographicImport $service): Response|RedirectResponse
{
if (! Schema::hasTable('demographic_imports')) {
return redirect('/management/demography/install');
}
$service->authorize($request->user());
$data = $request->validate(['batch' => 'nullable|uuid']);
$workflow = app(PortalEditorialWorkflow::class);
$editorial = $workflow->demographyAvailable();
return Inertia::render('management/demography', ['editorialEnabled' => $editorial, 'editorialRevision' => $editorial && isset($data['batch']) ? (int) ($workflow->latestDemography($data['batch'])->revision_no ?? 0) : 0, 'qa' => ! app(DemographicInstallation::class)->activeTarget(), 'batches' => DB::table('demographic_imports')->orderByDesc('created_at')->get(['id', 'file_name', 'publication_version', 'status', 'created_at']),
'preview' => isset($data['batch']) ? $service->preview($request->user(), $data['batch']) : null]);
}
public function installation(Request $request, DemographicInstallation $service): Response
{
return Inertia::render('management/demography-install', ['plan' => $service->preview($request->user())]);
}
public function install(Request $request, DemographicInstallation $service): JsonResponse
{
return response()->json($service->apply($request->user(), $request->all()));
}
public function prepare(Request $request, DemographicImport $service): JsonResponse
{
$data = $request->validate(['file' => 'required|file|mimes:xlsx|max:19531', 'publication_version' => 'required|string|max:255', 'baseline_year' => 'nullable|integer|between:1900,2100']);
$file = $request->file('file');
return response()->json($service->prepare($request->user(), $file->getRealPath(), $file->getClientOriginalName(), $data['publication_version'], isset($data['baseline_year']) ? (int) $data['baseline_year'] : null));
}
public function confirm(Request $request, string $id, DemographicImport $service): JsonResponse
{
$data = $request->validate(['sha256' => 'required|string|size:64', 'mapping' => 'present|array', 'mapping.*' => 'required|uuid', 'reason' => 'required|string|min:10|max:2000']);
return response()->json($service->confirm($request->user(), $id, $data['sha256'], $data['mapping'], $data['reason']));
}
public function transition(Request $request, string $id, DemographicImport $service): JsonResponse
{
$data = $request->validate(['action' => 'required|in:approved,disabled', 'reason' => 'required|string|min:10|max:2000']);
$service->transition($request->user(), $id, $data['action'], $data['reason']);
return response()->json(['id' => $id, 'status' => $data['action']]);
}
}
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Services\DocumentManagementAccess;
use App\Services\EditorialInstallation;
use App\Services\EditorialReading;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Query\Builder;
@@ -97,11 +98,17 @@ class EditorialArticleController extends Controller
app(DocumentManagementAccess::class)->assertEditableResource(request()->user(), $id);
$r = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($r instanceof \stdClass, 404);
$workflow = app(PortalEditorialWorkflow::class);
if ($workflow->enabled()) {
$workflow->authorize(request()->user(), $id);
$r = $workflow->workingResource($r);
}
$reading = app(EditorialReading::class);
$original = $reading->original($r);
$content = $reading->content($r, $original);
return tap(Inertia::render('portal/article-editor', ['resource' => ['id' => $r->id, 'title' => $r->title, 'metadata_version' => $r->metadata_version, 'published' => (bool) $r->published],
'workflowEnabled' => $workflow->enabled(), 'workflow' => $workflow->latest($id),
'content' => $content, 'source' => $original ? array_intersect_key($original, array_flip(['source_url', 'published_at', 'modified_at', 'legacy_id', 'image_source_url'])) : null, 'images' => $this->publicResources()->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->get(['r.id', 'r.title']),
'revisions' => DB::table('portal_resource_revisions')->where('resource_id', $id)->orderByDesc('revision_no')->get(['revision_no', 'change_type', 'created_at'])])->toResponse(request()), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
@@ -114,7 +121,9 @@ class EditorialArticleController extends Controller
$r = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
abort_unless($r !== null, 404);
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
abort_unless((int) $r->metadata_version === (int) $data['metadata_version'], 409, 'O artigo foi alterado noutra sessão. Recarregue antes de editar.');
$workflow = app(PortalEditorialWorkflow::class);
$current = $workflow->enabled() ? $workflow->workingResource($r) : $r;
abort_unless((int) $current->metadata_version === (int) $data['metadata_version'], 409, 'O artigo foi alterado noutra sessão. Recarregue antes de editar.');
if (! empty($data['image_resource_id'])) {
abort_unless($this->publicResources()->where('r.id', $data['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->exists(), 422, 'Escolha uma imagem pública disponível na biblioteca.');
}
@@ -122,7 +131,13 @@ class EditorialArticleController extends Controller
if ($body === '') {
throw ValidationException::withMessages(['body' => 'Escreva o corpo do artigo.']);
}
$content = $this->edition($r, $data);
$workflow = app(PortalEditorialWorkflow::class);
$content = $this->edition($workflow->enabled() ? $workflow->workingResource($r) : $r, $data);
if ($workflow->enabled()) {
$workflow->save($request->user(), $id, (int) $data['metadata_version'], ['editorial_content' => json_encode($content, JSON_THROW_ON_ERROR)]);
return;
}
$revision = (int) $r->metadata_version + 1;
DB::table('portal_resources')->where('id', $id)->update(['editorial_content' => json_encode($content, JSON_THROW_ON_ERROR), 'published' => false, 'approved_by' => null, 'metadata_version' => $revision, 'last_reviewed_by' => $request->user()->id, 'last_reviewed_at' => now(), 'updated_at' => now()]);
$resource = DB::table('portal_resources')->where('id', $id)->first();
@@ -165,6 +180,11 @@ class EditorialArticleController extends Controller
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($resource instanceof \stdClass, 404);
$workflow = app(PortalEditorialWorkflow::class);
if ($workflow->enabled()) {
$workflow->authorize(request()->user(), $id);
$resource = $workflow->workingResource($resource);
}
return tap(Inertia::render('portal/article', ['article' => app(EditorialReading::class)->reading($resource, null, true)])
->toResponse($request), static function (Response $response): void {
@@ -179,6 +199,11 @@ class EditorialArticleController extends Controller
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($resource instanceof \stdClass, 404);
$workflow = app(PortalEditorialWorkflow::class);
if ($workflow->enabled()) {
$workflow->authorize($request->user(), $id);
$resource = $workflow->workingResource($resource);
}
$data = $this->validateContent($request);
abort_unless((int) $resource->metadata_version === (int) $data['metadata_version'], 409, 'A revisão mudou; recupere antes de rever a prévia.');
@@ -21,11 +21,13 @@ use App\Services\EducationWorkQueue;
use App\Services\InvestmentGeography;
use App\Services\PortalAudit;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@@ -166,6 +168,7 @@ class EducationController extends Controller
Inertia::encryptHistory();
return tap(Inertia::render('education/declaration', ['declaration' => $row, 'kind' => $kind, 'actorId' => $request->user()->id, 'today' => now()->toDateString(),
'preparation' => app(EducationReviews::class)->preparation($request->user(), $record),
'history' => app(EducationReviews::class)->history($request->user(), $record),
'backUrl' => route('management.education.index', $filters, false), 'references' => $references])->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
@@ -203,6 +206,29 @@ class EducationController extends Controller
return back()->with('success', 'Declaração registada para revisão independente.');
}
public function reviewPreparation(Request $request, string $kind, string $id): JsonResponse
{
$service = app(EducationReviews::class);
app(EducationAccess::class)->authorize($request->user());
if ($request->isMethod('POST')) {
// Like CRM preparations, retain partial text before business validation.
$input = json_decode($request->getContent(), true);
if (! is_array($input)) {
throw ValidationException::withMessages(['snapshot' => 'A preparação deve ser enviada em JSON.']);
}
$view = $service->savePreparation($request->user(), $kind, $id, $input);
} else {
$expected = $request->validate(['actor_id' => 'sometimes|required|integer|min:1']);
abort_if(isset($expected['actor_id']) && (int) $expected['actor_id'] !== $request->user()->id, 409, 'A conta mudou. Actualize a entrada antes de recuperar.');
$record = match ($kind) {
'demands' => LabourDemand::findOrFail($id), 'capacities' => TrainingCapacity::findOrFail($id), default => abort(404)
};
$view = $service->preparation($request->user(), $record);
}
return response()->json($view)->header('Cache-Control', 'private, no-store');
}
public function review(Request $request, string $kind, string $id): RedirectResponse
{
$result = app(EducationReviews::class)->decide($request->user(), $kind, $id, $request->all());
@@ -4,10 +4,16 @@ namespace App\Http\Controllers;
use App\Models\Sector;
use App\Services\DocumentManagementAccess;
use App\Services\FundingDossierPreparation;
use App\Services\FundingInstitutionClassification;
use App\Services\FundingInstitutionDetails;
use App\Services\FundingProgrammeReferences;
use App\Services\FundingProgrammes;
use App\Services\InvestmentGeography;
use App\Services\InvestorContext;
use App\Services\InvestorPath;
use App\Services\Procurement;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -22,20 +28,38 @@ final class FundingProgrammeController extends Controller
{
public function reference(Request $request, string $code, FundingProgrammeReferences $references): \Symfony\Component\HttpFoundation\Response
{
return tap(Inertia::render('funding/reference', ['reference' => $references->preview($request->user(), $code)])->toResponse($request),
$plan = $references->preview($request->user(), $code);
if ($request->expectsJson()) {
return response()->json($plan)->header('Cache-Control', 'private, no-store');
}
return tap(Inertia::render('funding/reference', ['reference' => $plan])->toResponse($request),
static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function prepareReference(Request $request, string $code, FundingProgrammeReferences $references): RedirectResponse
public function prepareReference(Request $request, string $code, FundingProgrammeReferences $references): RedirectResponse|JsonResponse
{
$data = $request->validate(['hash' => 'required|string|size:64']);
$result = $references->prepare($request->user(), $code, $data['hash']);
if ($request->expectsJson()) {
$reference = $references->preview($request->user(), $code);
$service = app(FundingProgrammes::class);
$matches = $service->matchesReference($request->user(), $result['id'], $reference['draft']);
$publication = $matches ? $service->preview($request->user(), $result['id']) : null;
return response()->json([...$result, 'matches_reference' => $matches, 'publication' => $publication ? ['version' => $publication['version'], 'hash' => $publication['hash']] : null])->header('Cache-Control', 'private, no-store');
}
return redirect('/management/fundos/'.$result['id'])->with('success', $result['created']
? 'Rascunho preparado com as fontes indicadas. Ainda não está publicado.'
: 'Esta referência já tem ficha. Aberta a versão existente, sem substituir alterações ou acrescentar chamadas.');
}
public function assisted(Request $request, FundingProgrammeReferences $references): \Symfony\Component\HttpFoundation\Response
{
return tap(Inertia::render('funding/assisted', ['batch' => $references->assisted($request->user())])->toResponse($request), static fn ($response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function manage(Request $request, FundingProgrammes $service): \Symfony\Component\HttpFoundation\Response
{
$service->guard($request->user());
@@ -43,7 +67,7 @@ final class FundingProgrammeController extends Controller
$items->through(fn ($row) => ['id' => $row->id, 'title' => json_decode($row->draft, true)['title'] ?? 'Rascunho sem título',
'version' => (int) $row->version, 'published' => $row->published_version_id !== null]);
return tap(Inertia::render('funding/manage', ['items' => $items])->toResponse($request),
return tap(Inertia::render('funding/manage', ['items' => $items, 'references' => app(FundingProgrammeReferences::class)->choices($request->user())])->toResponse($request),
static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
@@ -63,6 +87,7 @@ final class FundingProgrammeController extends Controller
return tap(Inertia::render('funding/edit', ['record' => $row ? ['id' => $row->id, 'code' => $row->code, 'version' => (int) $row->version,
'draft' => json_decode($row->draft, true), 'published' => $row->published_version_id !== null] : null,
'references' => Arr::only($references, ['organizations', 'sectors', 'territories', 'documents']),
'categories' => FundingInstitutionClassification::CATEGORIES, 'institutionTypes' => FundingInstitutionClassification::TYPES, 'institutionNatures' => FundingInstitutionDetails::NATURES, 'needs' => FundingInstitutionDetails::NEEDS,
'kinds' => FundingProgrammes::KINDS, 'windowModes' => FundingProgrammes::WINDOWS,
'history' => $id ? DB::table('funding_programme_versions')->where('programme_id', $id)->orderByDesc('version')->get(['version', 'reason', 'published_at']) : []])->toResponse($request),
static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
@@ -80,9 +105,12 @@ final class FundingProgrammeController extends Controller
return response()->json($service->preview($request->user(), $id))->header('Cache-Control', 'private, no-store');
}
public function publish(Request $request, string $id, FundingProgrammes $service): RedirectResponse
public function publish(Request $request, string $id, FundingProgrammes $service): RedirectResponse|JsonResponse
{
$service->publish($request->user(), $id, $request->all());
$versionId = $service->publish($request->user(), $id, $request->all());
if ($request->expectsJson()) {
return response()->json(['id' => $id, 'version_id' => $versionId, 'published' => true])->header('Cache-Control', 'private, no-store');
}
return back()->with('success', 'Nova versão publicada. Os interesses anteriores conservam a versão guardada.');
}
@@ -118,15 +146,22 @@ final class FundingProgrammeController extends Controller
public function index(Request $request, FundingProgrammes $service): \Symfony\Component\HttpFoundation\Response
{
$filters = $request->validate(['q' => 'nullable|string|max:150', 'kind' => 'nullable|in:fund,programme,incentive',
$filters = $request->validate(['q' => 'nullable|string|max:150', 'kind' => 'nullable|in:institution,fund,programme,incentive',
'category' => ['nullable', Rule::in(array_keys(FundingInstitutionClassification::CATEGORIES))], 'institution_type' => ['nullable', Rule::in(array_keys(FundingInstitutionClassification::TYPES))], 'location_mode' => 'nullable|in:physical,coverage', 'include_historical' => 'nullable|boolean',
'need' => ['nullable', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))],
'context' => 'nullable|uuid', 'opportunity' => 'nullable|string|max:150',
'territory' => ['nullable', 'uuid', Rule::in(app(InvestmentGeography::class)->publicReferences()->pluck('id')->all())],
'sector' => ['nullable', 'integer', Rule::in(Sector::choices()->whereNotNull('published_at')->pluck('id')->all())],
'certainty' => 'nullable|in:include_unknown,documented', 'state' => 'nullable|in:open,closed,upcoming,unknown']);
$filters = array_filter($filters, fn ($value) => $value !== null && $value !== '');
$dossier = $request->user() && ! empty($filters['context']) ? app(FundingDossierPreparation::class)->context($request->user(), $filters['context'], $filters['opportunity'] ?? null) : null;
if ($dossier && empty($filters['territory']) && $dossier['territory']) {
$filters['territory'] = $dossier['territory'];
}
$query = $service->catalogueQuery($filters);
if (! empty($filters['q'])) {
$term = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $filters['q']).'%';
$query->where(fn ($q) => $q->whereRaw("v.payload->>'title' ilike ?", [$term])->orWhereRaw("v.payload->>'summary' ilike ?", [$term]));
$query->where(fn ($q) => $q->whereRaw("v.payload->>'title' ilike ?", [$term])->orWhereRaw("v.payload->>'summary' ilike ?", [$term])->orWhereRaw("(v.payload->'aliases')::text ilike ?", [$term])->orWhereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'locations','[]'::jsonb)) l WHERE l->>'label' ilike ? OR l->>'address' ilike ? OR l->>'location_text' ilike ?)", [$term, $term, $term]));
}
if (! empty($filters['kind'])) {
$query->whereRaw("v.payload->>'kind' = ?", [$filters['kind']]);
@@ -138,7 +173,7 @@ final class FundingProgrammeController extends Controller
return [...$record, 'filter_explanation' => $service->filterExplanation($record, $filters)];
});
return tap(Inertia::render('funding/index', ['items' => $items, 'filters' => $filters, 'kinds' => FundingProgrammes::KINDS,
return tap(Inertia::render('funding/index', ['items' => $items, 'filters' => $filters, 'categories' => FundingInstitutionClassification::CATEGORIES, 'institutionTypes' => FundingInstitutionClassification::TYPES, 'kinds' => FundingProgrammes::KINDS, 'needs' => FundingInstitutionDetails::NEEDS, 'dossier' => $dossier,
'options' => ['territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name']),
'sectors' => Sector::choices()->whereNotNull('published_at')->orderBy('name')->get(['id', 'name'])], 'management' => $service->access($request->user())])
->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
@@ -146,14 +181,38 @@ final class FundingProgrammeController extends Controller
public function show(Request $request, string $id, FundingProgrammes $service): \Symfony\Component\HttpFoundation\Response
{
$request->validate(['context' => 'nullable|uuid', 'opportunity' => 'nullable|string|max:150', 'need' => ['nullable', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))], 'territory' => 'nullable|uuid', 'category' => 'nullable|string|max:80', 'institution_type' => 'nullable|string|max:80', 'location_mode' => 'nullable|in:physical,coverage']);
$dossier = $request->user() && $request->filled('context') ? app(FundingDossierPreparation::class)->context($request->user(), $request->string('context')->toString(), $request->query('opportunity')) : null;
if ($dossier && ! $request->filled('territory') && $dossier['territory']) {
$request->merge(['territory' => $dossier['territory']]);
}
$record = $service->published($id);
$destinations = [];
if ($request->user()) {
foreach (app(InvestorPath::class)->contexts($request->user()) as $item) {
$case = app(VirtualDesk::class)->case($request->user(), $item['id']);
$state = app(InvestorContext::class)->state($case);
$choices = $state['opportunity_choices'] ?? [];
if ($choices === []) {
$destinations[] = ['id' => $item['id'], 'opportunity' => null, 'title' => $item['title']];
}
foreach ($choices as $choice) {
$destinations[] = ['id' => $item['id'], 'opportunity' => $choice['opportunity']['key'], 'title' => $item['title'].' — '.$choice['opportunity']['title']];
}
}
}
$saved = $request->user() ? DB::table('funding_programme_interests as i')->join('funding_programme_versions as v', 'v.id', '=', 'i.version_id')
->where('i.programme_id', $id)->where('i.user_id', $request->user()->id)->whereNull('i.removed_at')->first(['v.version', 'i.saved_at']) : null;
return tap(Inertia::render('funding/show', ['record' => $record, 'saved' => $saved, 'authenticated' => $request->user() !== null])
return tap(Inertia::render('funding/show', ['record' => $record, 'saved' => $saved, 'authenticated' => $request->user() !== null, 'dossier' => $dossier, 'selection' => [...$request->only(['need', 'territory', 'context', 'opportunity', 'category', 'institution_type', 'location_mode']), 'territory_label' => app(InvestmentGeography::class)->publicReferences()->where('id', $request->query('territory'))->value('name') ?? 'Local por seleccionar'], 'needs' => FundingInstitutionDetails::NEEDS, 'destinations' => $destinations])
->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function dossier(Request $request, string $id, FundingDossierPreparation $service): JsonResponse
{
return response()->json($service->save($request->user(), $id, $request->all()))->header('Cache-Control', 'private, no-store');
}
public function assess(Request $request, string $id, FundingProgrammes $service): JsonResponse
{
return response()->json($service->assess($id, $request->all()))->header('Cache-Control', 'private, no-store');
@@ -0,0 +1,319 @@
<?php
namespace App\Http\Controllers;
use App\Models\ExistingBusiness;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\EditorialReading;
use App\Services\FederatedSearch;
use App\Services\FundingInstitutionDetails;
use App\Services\FundingProgrammes;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\InvestmentZones;
use App\Services\LegacyContent;
use App\Services\SearchTerms;
use App\Services\SectorOrganization;
use App\Services\SectorResourceReferences;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
class GlobalSearchController extends Controller
{
public function __invoke(Request $request, InvestmentCatalogue $catalogue, SearchTerms $search, FederatedSearch $federated): Response
{
$input = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1'], 'category' => ['nullable', 'in:all,physical,financial']]);
$original = trim($input['q'] ?? '');
$intent = $search->requestIntent($original);
if (isset($input['category']) && ($intent === null || $intent['type'] === 'products')) {
$intent = ['type' => 'products', 'label' => 'Produtos e localização', 'kind' => null,
'filter' => $intent['filter'] ?? $original,
'category' => match ($input['category']) {
'physical' => 'Produtos e localizações', 'financial' => 'Produtos financeiros', default => null,
}];
}
$term = $intent['filter'] ?? $original;
$tokens = $search->tokens($term);
$results = ['data' => [], 'total' => 0, 'current_page' => 1, 'last_page' => 1, 'per_page' => 5, 'unavailable' => []];
if ($tokens !== [] || $intent !== null) {
$factories = [
'Produtos e localizações' => function () use ($search, $tokens, $term) {
try {
return $this->productReferences($search, $tokens, $term);
} catch (\RuntimeException $exception) {
throw new HttpException(503, 'Referências de produtos temporariamente indisponíveis.', $exception);
}
},
'Produtos financeiros' => function () use ($search, $tokens, $term) {
$service = app(FundingProgrammes::class);
if (! $service->installed()) {
return ['count' => 0, 'fetch' => fn ($offset, $limit) => []];
}
$query = $service->catalogueQuery([])->select('p.id', 'v.payload')->selectRaw("v.payload->>'title' AS title");
foreach ($tokens as $token) {
$query->where(function ($match) use ($search, $token) {
$search->orFields($match, ["(v.payload->>'title')",
"(SELECT string_agg(product->>'title', ' ') FROM jsonb_array_elements(COALESCE(v.payload->'products','[]'::jsonb)) product)",
"(SELECT string_agg(place->>'location_text', ' ') FROM jsonb_array_elements(COALESCE(v.payload->'locations','[]'::jsonb)) place)",
"(SELECT string_agg(node->>'name', ' ') FROM jsonb_path_query(v.payload,'$.locations[*].territory_path[*]') node)",
"(SELECT string_agg(node->>'name', ' ') FROM jsonb_path_query(v.payload,'$.products[*].territory_paths[*][*]') node)"], $token);
foreach (FundingInstitutionDetails::NEEDS as $code => $label) {
$text = in_array($code, ['investment', 'working_capital'], true) ? 'Financiamento '.$label : $label;
if ($search->matches($text, [$token])) {
$match->orWhereJsonContains('v.payload->institution_services', $code)->orWhereJsonContains('v.payload->funding_needs', $code)
->orWhereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'products','[]'::jsonb)) product WHERE product->'needs' @> ?::jsonb)", [json_encode([$code], JSON_THROW_ON_ERROR)]);
}
}
foreach (FundingInstitutionDetails::NATURES as $code => $label) {
if ($search->matches($label, [$token])) {
$match->orWhere('v.payload->institution_nature', $code);
}
}
});
}
return $this->source($query, $search, $term, $tokens, "(v.payload->>'title')", 'p.id', function ($row) {
$p = json_decode($row->payload, true, 512, JSON_THROW_ON_ERROR);
$products = array_values(array_unique(array_filter(array_column($p['products'] ?? [], 'title'))));
$services = array_values(array_intersect_key(FundingInstitutionDetails::NEEDS, array_flip($p['institution_services'] ?? [])));
$places = [];
foreach ($p['locations'] ?? [] as $location) {
if (! empty($location['location_text'])) {
$places[] = $location['location_text'];
}
foreach ($location['territory_path'] ?? [] as $node) {
if ($node['type'] !== 'country') {
$places[] = $node['name'];
}
}
}
$national = array_any($p['products'] ?? [], fn ($product) => ($product['coverage'] ?? '') === 'national');
return ['title' => $row->title, 'description' => implode(' · ', $products ?: $services) ?: 'Instituição financeira.',
'location' => $places === [] ? null : 'Presença física: '.implode(' · ', array_unique($places)),
'coverage' => $national ? 'Existe produto com cobertura nacional indicada; consulte as condições.' : 'Cobertura financeira: consultar cada produto.',
'url' => '/fundos/'.$row->id];
});
},
'Páginas e serviços' => function () use ($search, $tokens, $term) {
$pages = array_values(array_filter($this->pages(), fn ($page) => $search->matches(implode(' ', $page), $tokens)));
usort($pages, fn ($a, $b) => $search->score($b['title'], $term, $tokens) <=> $search->score($a['title'], $term, $tokens)
?: strcmp($a['title'], $b['title']) ?: strcmp($a['url'], $b['url']));
return ['count' => count($pages), 'fetch' => fn ($offset, $limit) => array_slice($pages, $offset, $limit)];
},
'Oportunidades' => function () use ($catalogue, $search, $tokens, $term) {
$query = $catalogue->query(['include_national' => true]);
foreach ($tokens as $token) {
$query->where(function ($match) use ($catalogue, $search, $token) {
$search->orFields($match, ['opportunities.title', 'opportunities.summary', 'opportunities.description', 'opportunities.reference', 'opportunities.province'], $token);
$match->orWhereHas('sector', fn ($sector) => $search->constrain($sector, ['name'], [$token]));
$match->orWhereExists(fn ($location) => $location->selectRaw('1')->from('geo_locations as search_location')
->whereIn('search_location.id', app(InvestmentGeography::class)->publicReferences()->select('id'))
->where(fn ($name) => $search->orFields($name, ['search_location.name'], $token))
->where(fn ($direct) => $direct->whereColumn('search_location.id', 'opportunities.primary_territory_id')
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('opportunity_locations as search_link')
->whereColumn('search_link.opportunity_id', 'opportunities.id')->whereColumn('search_link.geo_location_id', 'search_location.id'))));
if (preg_match('/^[a-z0-9]+$/', $token)) {
$match->orWhereIn('opportunities.id', $catalogue->query(['q' => $token, 'include_national' => true])->reorder()->select('opportunities.id'));
}
});
}
return $this->source($query, $search, $term, $tokens, 'opportunities.title', 'opportunities.id',
fn ($r) => ['title' => $r->title, 'description' => $r->summary, 'url' => '/oportunidades/'.rawurlencode($r->slug)]);
},
'Negócios' => function () use ($search, $tokens, $term) {
$query = ExistingBusiness::query()->published();
$search->constrain($query, ['public_title', 'activity_code', 'objective'], $tokens);
return $this->source($query, $search, $term, $tokens, 'public_title', 'existing_businesses.id',
fn ($r) => ['title' => $r->public_title, 'description' => $r->objective, 'url' => '/negocios/'.rawurlencode($r->public_slug)]);
},
'Carteira: projectos, clusters e corredores' => function () use ($search, $tokens, $term, $intent) {
$query = InvestmentPortfolioController::visible();
$search->constrain($query, ['investment_dossiers.title', 'investment_dossiers.content'], $tokens);
if ($intent !== null && $intent['kind'] !== null) {
[$column, $table] = ['corridor' => ['corridor_id', 'development_corridors'], 'cluster' => ['cluster_id', 'economic_clusters'], 'project' => ['project_id', 'projects']][$intent['kind']];
$query->whereNotNull($column)->whereExists(fn ($entity) => $entity->selectRaw('1')->from($table)
->whereColumn($table.'.id', 'investment_dossiers.'.$column)->whereNull($table.'.deleted_at'));
// The existing unique entity key already prevents duplicate dossiers.
}
return $this->source($query, $search, $term, $tokens, 'investment_dossiers.title', 'investment_dossiers.id',
fn ($r) => ['title' => $r->title, 'description' => $r->corridor_id ? 'Corredor' : ($r->cluster_id ? 'Cluster' : 'Projecto âncora'), 'url' => '/carteira/'.$r->id]);
},
'Documentos e legislação' => function () use ($search, $tokens, $term) {
$query = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->where('r.published', true)->where('r.rights_status', '!=', 'restricted')->where('d.rights_status', '!=', 'restricted')
->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at')
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256');
$search->constrain($query, ['r.title', 'r.description', 'r.document_number', 'd.issuer', 'r.source_reference', 'r.category', 'r.topic'], $tokens);
$query->select('r.id', 'r.title', 'r.description');
return $this->source($query, $search, $term, $tokens, 'r.title', 'r.id',
fn ($r) => ['title' => $r->title, 'description' => $r->description, 'url' => '/documentos/'.$r->id]);
},
'Artigos' => function () use ($search, $tokens, $term) {
if (! Schema::hasColumn('portal_resources', 'editorial_content')) {
return ['count' => 0, 'fetch' => fn ($offset, $limit) => []];
}
$query = app(EditorialReading::class)->publicResources()
->whereRaw("nullif(btrim(r.editorial_content->>'body'), '') is not null");
$search->constrain($query, ['r.title', "(r.editorial_content->>'body')"], $tokens);
$query->select('r.id', 'r.title')->selectRaw("r.editorial_content->>'body' as body");
return $this->source($query, $search, $term, $tokens, 'r.title', 'r.id',
fn ($r) => ['title' => $r->title, 'description' => Str::limit(trim(strip_tags($r->body)), 220), 'url' => '/artigos/'.$r->id]);
},
'Sectores' => function () use ($search, $tokens, $term) {
$query = Sector::query()->where('is_active', true)->whereNotNull('published_at');
$search->constrain($query, ['name', 'description', 'short_description'], $tokens);
return $this->source($query, $search, $term, $tokens, 'name', 'sectors.id',
fn ($r) => ['title' => $r->name, 'description' => $r->short_description, 'url' => SectorOrganization::href($r->slug)]);
},
'Localizações' => function () use ($search, $tokens, $term) {
$query = app(InvestmentGeography::class)->publicReferences()
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality']);
$search->constrain($query, ['geo_locations.name', 'geo_locations.source_code'], $tokens);
$query->select('geo_locations.id', 'geo_locations.name', 'geo_locations.location_type');
return $this->source($query, $search, $term, $tokens, 'geo_locations.name', 'geo_locations.id',
fn ($r) => ['title' => $r->name, 'description' => ['province' => 'Província', 'district' => 'Distrito', 'admin_post' => 'Posto administrativo', 'locality' => 'Localidade'][$r->location_type],
'url' => '/oportunidades?'.http_build_query([$r->location_type.'_id' => $r->id])]);
},
'Zonas de investimento' => function () use ($search, $tokens, $term) {
$query = app(InvestmentZones::class)->visible();
$search->constrain($query, ['name', 'code'], $tokens);
return $this->source($query, $search, $term, $tokens, 'name', 'investment_zones.id',
fn ($r) => ['title' => $r->name, 'description' => $r->code, 'url' => '/zonas/'.$r->id]);
},
'Parcerias' => function () use ($search, $tokens, $term) {
$query = DB::table('portal_partnerships as p')->where('p.status', 'published');
$search->constrain($query, ['p.title', 'p.sector', 'p.province'], $tokens);
$query->select('p.id', 'p.title', 'p.sector', 'p.province');
return $this->source($query, $search, $term, $tokens, 'p.title', 'p.id',
fn ($r) => ['title' => $r->title, 'description' => implode(' · ', array_filter([$r->sector, $r->province])), 'url' => '/parcerias/'.$r->id]);
},
];
if ($intent !== null) {
$categories = $intent['category'] === null ? ['Produtos e localizações', 'Produtos financeiros'] : [$intent['category']];
$factories = array_intersect_key($factories, array_flip($categories));
}
$results = $federated->page($factories, (int) ($input['page'] ?? 1), 5,
fn ($item) => $search->score($item['title'], $term, $tokens));
}
$answer = $intent === null ? null : ['type' => $intent['type'], 'label' => $intent['label'], 'filter' => $term,
'count' => $results['unavailable'] === [] ? $results['total'] : null];
if ($answer !== null && $intent['type'] === 'products') {
$answer['category'] = match ($intent['category']) {
'Produtos e localizações' => 'physical', 'Produtos financeiros' => 'financial', default => 'all',
};
}
return Inertia::render('portal/search', ['query' => $original, 'results' => $results, 'answer' => $answer]);
}
/** @param list<string> $tokens
* @return array{count:int,fetch:callable(int,int):iterable<array<string,mixed>>}
*/
private function productReferences(SearchTerms $search, array $tokens, string $term): array
{
$rows = [];
foreach (['mineracao', 'energia-especifica'] as $sector) {
if (! Sector::where('slug', $sector)->where('is_active', true)->whereNotNull('published_at')->exists()) {
continue;
}
$data = app(SectorResourceReferences::class)->view($sector);
foreach ($data['groups'] as $group) {
$locations = array_values(array_filter($group['location_labels'], fn ($name) => $search->matches($term, $search->tokens($name))));
$rows[] = ['title' => $group['name'], 'description' => $sector === 'mineracao' ? 'Referência mineral; não identifica uma concessão ou produção disponível.' : 'Segmento energético e cadeia de valor.',
'location' => $group['locations_text'], 'url' => '/sectores/'.$sector.'?'.http_build_query(['resource_group' => $group['id'],
...(count($locations) === 1 ? ['resource_location' => $locations[0]] : [])])];
}
}
if (app(LegacyContent::class)->available() && Sector::where('slug', 'agronegocio')->where('is_active', true)->whereNotNull('published_at')->exists()) {
try {
app(LegacyContent::class)->article('agronegocio', false);
$entries = app(CorridorAgribusinessOpportunities::class)->entries();
$published = Opportunity::published()->whereIn('management_creation_key', array_column($entries, 'key'))->pluck('slug', 'management_creation_key');
foreach ($entries as $entry) {
if (! isset($published[$entry['key']])) {
continue;
}
$rows[] = ['title' => $entry['chain'], 'description' => 'Cadeia agrícola: '.$entry['summary'], 'location' => 'Enquadramento territorial: '.$entry['corridor'],
'url' => '/oportunidades/'.rawurlencode($published[$entry['key']])];
}
} catch (HttpException $exception) {
if ($exception->getStatusCode() !== 404) {
throw $exception;
}
}
}
$rows = array_values(array_filter($rows, fn ($row) => $search->matches(implode(' ', $row), $tokens)));
usort($rows, fn ($a, $b) => $search->score($b['title'], $term, $tokens) <=> $search->score($a['title'], $term, $tokens) ?: strcmp($a['title'], $b['title']));
return ['count' => count($rows), 'fetch' => fn ($offset, $limit) => array_slice($rows, $offset, $limit)];
}
/** @param \Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Query\Builder $query
* @param list<string> $tokens
* @param callable(mixed):array<string,mixed> $present
* @param literal-string $title
* @param literal-string $id
* @return array{count:int,fetch:callable(int,int):iterable<array<string,mixed>>}
*/
private function source($query, SearchTerms $search, string $term, array $tokens, string $title, string $id, callable $present): array
{
$count = (clone $query)->reorder()->count();
$query->reorder();
$search->orderByRelevance($query, $title, $term, $tokens, $id);
return ['count' => $count, 'fetch' => fn ($offset, $limit) => (clone $query)->offset($offset)->limit($limit)->get()->map($present)->all()];
}
/** Public service entry points and terms used to find them.
* @return list<array{title:string,description:string,url:string}>
*/
private function pages(): array
{
return [
['title' => 'Licenciamento da empresa', 'description' => 'Roteiro de constituição e licenciamento conforme a actividade e localização.', 'url' => '/como-investir#planeamento'],
['title' => 'Preparar licenciamento no dossier', 'description' => 'Continuar a preparação na etapa de licenciamento prévio.', 'url' => '/investimentos/iniciar?step=S08'],
['title' => 'Como investir em Moçambique', 'description' => 'Procedimentos e etapas para investir', 'url' => '/como-investir'],
['title' => 'Iniciar ou continuar investimento', 'description' => 'Preparar uma ideia e dossier de investimento', 'url' => '/investimentos/iniciar'],
['title' => 'Oportunidades de investimento', 'description' => 'Pesquisar por sector, localização e maturidade', 'url' => '/oportunidades'],
['title' => 'Carteira de investimento', 'description' => 'Projectos âncora, clusters e corredores', 'url' => '/carteira'],
['title' => 'Negócios existentes', 'description' => 'Compra, venda e participação em negócios', 'url' => '/negocios'],
['title' => 'Sectores de investimento', 'description' => 'Actividades económicas e sectores prioritários', 'url' => '/sectores'],
['title' => 'Zonas de investimento', 'description' => 'Zonas económicas especiais, ZEE, ZFI e parques industriais', 'url' => '/zonas'],
['title' => 'Contextos de investimento', 'description' => 'Formas de iniciar e preparar um investimento', 'url' => '/contextos-de-investimento'],
['title' => 'Documentos e formulários', 'description' => 'Acervo documental e formulários para investidores e empresas', 'url' => '/documentos'],
['title' => 'Formulário do investidor', 'description' => 'Dados e contacto para investir', 'url' => '/formularios/investidor'],
['title' => 'Formulário de empresa', 'description' => 'Dados e contacto para empresas', 'url' => '/formularios/empresa'],
['title' => 'Legislação e incentivos', 'description' => 'Leis, decretos, benefícios e enquadramento legal', 'url' => '/legislacao'],
['title' => 'Simulador fiscal', 'description' => 'Simular impostos e cenários fiscais de investimento', 'url' => '/simulador-fiscal'],
['title' => 'Formulários fiscais', 'description' => 'Preparação dos formulários IVA e IRPC', 'url' => '/formularios/fiscais'],
['title' => 'Terra e DUAT', 'description' => 'Apoio ao uso e aproveitamento da terra', 'url' => '/terra-duat'],
['title' => 'Bolsa de parcerias', 'description' => 'Encontrar parceiros e propostas', 'url' => '/parcerias'],
['title' => 'Apoio ao investidor', 'description' => 'Contactos e pedido de apoio', 'url' => '/apoio'],
['title' => 'Educação para investimento', 'description' => 'Conteúdos educativos e orientação', 'url' => '/educacao'],
['title' => 'Sobre o InvesteMoz', 'description' => 'Informação institucional do portal', 'url' => '/sobre'],
['title' => 'Contacto', 'description' => 'Contactar o portal InvesteMoz', 'url' => '/contacto'],
['title' => 'Política de privacidade', 'description' => 'Privacidade e tratamento de dados pessoais', 'url' => '/politica-de-privacidade'],
['title' => 'Termos de uso', 'description' => 'Condições de utilização do portal', 'url' => '/termos-de-uso'],
];
}
}
@@ -9,6 +9,8 @@ use App\Services\CorridorAgribusinessOpportunities;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentComplements;
use App\Services\InvestmentGeography;
use App\Services\InvestmentMapIndicators;
use App\Services\InvestmentZoneOperationalDetails;
use App\Services\InvestmentZones;
use App\Services\OpportunityCorridorExplorer;
use App\Services\OpportunityTextSearch;
@@ -155,6 +157,7 @@ class InvestmentDiscoveryController extends Controller
$filters = $catalogue->filters($request);
$context = $this->territorial($filters)->map(fn ($d) => ['id' => 'dossier:'.$d->id, 'title' => $d->title, 'kind' => $d->cluster_id ? 'Cluster' : ($d->corridor_id ? 'Corredor' : 'Projeto âncora'), 'url' => '/carteira/'.$d->id]);
$opportunityOnlyFilters = array_filter(Arr::only($filters, ['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id', 'category', 'publication_state', 'education', 'sector', 'facets', 'project_id', 'cluster_id', 'corridor_id', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level', 'preparation']), fn ($value) => $value !== null && $value !== '' && $value !== false && $value !== []);
$quantitative = ['state' => 'not_included', 'records' => [], 'truncated' => false];
if ($opportunityOnlyFilters === [] && ! in_array($filters['dimension'] ?? null, ['cluster', 'corridor'], true)) {
$zoneFilters = $filters;
if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) {
@@ -166,10 +169,12 @@ class InvestmentDiscoveryController extends Controller
$zones->where('id', $filters[$key]);
}
}
$context = $context->concat($zones->orderBy('name')->limit(30)->get(['id', 'name', 'category'])->map(fn ($z) => ['id' => 'zone:'.$z->id, 'title' => $z->name, 'kind' => ['zee' => 'ZEE', 'zfi' => 'ZFI', 'industrial_park' => 'Parque industrial'][$z->category] ?? $z->category, 'url' => '/zonas/'.$z->id]));
$zoneRows = $zones->orderBy('name')->orderBy('id')->limit(31)->get(['id', 'name', 'category', 'version_no', ...(app(InvestmentZoneOperationalDetails::class)->installed() ? ['operational_details'] : [])]);
$quantitative = app(InvestmentMapIndicators::class)->forZones($zoneRows->take(30)->values(), $zoneRows->count() > 30);
$context = $context->concat($zoneRows->take(30)->map(fn ($z) => ['id' => 'zone:'.$z->id, 'title' => $z->name, 'kind' => ['zee' => 'ZEE', 'zfi' => 'ZFI', 'industrial_park' => 'Parque industrial'][$z->category] ?? $z->category, 'url' => '/zonas/'.$z->id]));
}
return response()->json([...$catalogue->map($filters), 'territorial_context' => $context->values(), 'context_limit_per_collection' => 30]);
return response()->json([...$catalogue->map($filters), 'territorial_context' => $context->values(), 'quantitative_context' => $quantitative, 'context_limit_per_collection' => 30]);
}
public function compare(Request $request, InvestmentCatalogue $catalogue): JsonResponse
@@ -199,15 +204,16 @@ class InvestmentDiscoveryController extends Controller
{
$records = $catalogue->query($catalogue->filters($request))->limit(1000)->get();
return response()->streamDownload(function () use ($records) {
return response()->streamDownload(function () use ($records, $catalogue) {
$stream = fopen('php://output', 'w');
if ($stream === false) {
return;
}
fwrite($stream, "\xEF\xBB\xBF");
fputcsv($stream, ['Referência', 'Título', 'Sector', 'Província', 'Montante', 'Moeda', 'Maturidade', 'URL'], ';', '"', '');
fputcsv($stream, ['Referência', 'Título', 'Sector', 'Província', 'Montante', 'Moeda', 'Maturidade', 'URL', 'Localização e âmbito'], ';', '"', '');
foreach ($records as $r) {
$fields = [$r->reference, $r->title, $r->sector?->name, $r->province, $r->estimated_investment_amount, $r->estimated_investment_currency, $r->maturity_label, url('/oportunidades/'.$r->slug)];
$location = $catalogue->locationPresentation($r);
$fields = [$r->reference, $r->title, $r->sector?->name, implode(' / ', $location['provinces']), $r->estimated_investment_amount, $r->estimated_investment_currency, $r->maturity_label, url('/oportunidades/'.$r->slug), $location['summary']];
fputcsv($stream, array_map(fn ($v) => preg_match('/^[\s]*[=+@-]/u', (string) $v) ? "'".$v : $v, $fields), ';', '"', '');
}
fclose($stream);
@@ -13,6 +13,7 @@ use App\Services\InvestmentGeography;
use App\Services\InvestmentRelations;
use App\Services\InvestmentZones;
use App\Services\PortalAudit;
use App\Services\PortalEditorialWorkflow;
use App\Services\PublicSearchMetrics;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
@@ -99,6 +100,10 @@ class InvestmentPortfolioController extends Controller
{
$management = $request->routeIs('management.portfolio*');
$record = ($management ? InvestmentDossier::query() : self::visible())->findOrFail($id);
$editorial = $management && app(PortalEditorialWorkflow::class)->portfolioEnabled();
if ($editorial) {
$record = app(PortalEditorialWorkflow::class)->workingPortfolio($record);
}
$relatedIds = [];
foreach ([['corridor_projects', 'corridor_id', 'project_id'], ['corridor_clusters', 'corridor_id', 'cluster_id'], ['cluster_projects', 'cluster_id', 'project_id']] as [$table, $left, $right]) {
foreach ([[$left, $right], [$right, $left]] as [$origin, $destination]) {
@@ -147,7 +152,7 @@ class InvestmentPortfolioController extends Controller
->orderByDesc('updated_at')->limit(10)->get(['id', 'version', 'updated_at'])
: collect();
return Inertia::render('portfolio/show', ['complements' => $management ? [] : app(InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
return Inertia::render('portfolio/show', ['editorialEnabled' => $editorial, 'complements' => $management ? [] : app(InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions,
'competitiveness' => $record->cluster_id ? app(ClusterCompetitiveness::class)->publicData($record->cluster_id) : null,
'geography' => app(InvestmentRelations::class)->dossier($record, $management),
@@ -167,6 +172,11 @@ class InvestmentPortfolioController extends Controller
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'title' => ['required', 'string', 'max:255'], 'source' => ['required', 'string', 'max:2000'],
'content' => ['required', 'array:name,description,location,region,sector,promotion,facts,partners,cta,anchors,gaps,priority,clusters,focus,notice,code'],
'content.*' => ['nullable', 'string', 'max:15000']]);
if (app(PortalEditorialWorkflow::class)->portfolioWrites($dossier->id)) {
app(PortalEditorialWorkflow::class)->savePortfolio($request->user(), $dossier, (int) $data['version_no'], $data);
return back()->with('success', 'Edição privada guardada. A publicação anterior permanece disponível; continue no circuito editorial.');
}
DB::transaction(function () use ($request, $dossier, $data) {
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
abort_if($dossier->version_no !== (int) $data['version_no'], 409, 'A ficha foi alterada. Recarregue a página e reveja a versão actual antes de guardar.');
@@ -179,6 +189,7 @@ class InvestmentPortfolioController extends Controller
public function publish(Request $request, InvestmentDossier $dossier): RedirectResponse
{
abort_if(app(PortalEditorialWorkflow::class)->portfolioWrites($dossier->id), 409, 'Use a aprovação e publicação separadas da edição no circuito editorial.');
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'approval_evidence' => ['required', 'string', 'min:15', 'max:3000']]);
DB::transaction(function () use ($request, $dossier, $data) {
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestmentPreparationWorkspace;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class InvestmentPreparationController extends Controller
{
public function show(Request $request, string $id, InvestmentPreparationWorkspace $workspace): JsonResponse|Response
{
$request->validate(['opportunity' => 'nullable|string|max:150', 'history_scope' => 'nullable|in:dossier', 'history_before' => 'nullable|integer|min:1']);
$data = $workspace->view($request->user(), $id, $request->query('opportunity'), $request->filled('history_before') ? $request->integer('history_before') : null, $request->query('history_scope') === 'dossier');
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/preparation', ['workspace' => $data]);
}
public function change(Request $request, string $id, InvestmentPreparationWorkspace $workspace): JsonResponse
{
return response()->json($workspace->change($request->user(), $id, $request->all()));
}
public function preview(Request $request, string $id, InvestmentPreparationWorkspace $workspace): \Illuminate\Http\Response
{
$request->validate(['opportunity' => 'nullable|string|max:150', 'history_scope' => 'nullable|in:dossier']);
$data = $workspace->view($request->user(), $id, $request->query('opportunity'), null, $request->query('history_scope') === 'dossier');
return response()->view('investments.preparation-index', ['workspace' => $data, 'manifest' => null])->header('Content-Security-Policy', "sandbox; default-src 'none'; style-src 'unsafe-inline'")->header('X-Content-Type-Options', 'nosniff');
}
public function download(Request $request, string $id, string $exportId, InvestmentPreparationWorkspace $workspace): \Illuminate\Http\Response
{
$request->validate(['opportunity' => 'nullable|string|max:150', 'history_scope' => 'nullable|in:dossier']);
$bundle = $workspace->bundle($request->user(), $id, $request->query('opportunity'), $exportId, $request->query('history_scope') === 'dossier');
return response($bundle['bytes'], 200, ['Content-Type' => 'application/zip', 'Content-Disposition' => 'attachment; filename="preparacao-apiex-'.$id.'.zip"', 'X-Export-Id' => $exportId, 'X-Content-Type-Options' => 'nosniff']);
}
public function export(Request $request, string $id, InvestmentPreparationWorkspace $workspace): \Illuminate\Http\Response
{
$body = [...$request->all(), 'action' => 'apiex_export'];
$state = $workspace->change($request->user(), $id, $body);
$bundle = $workspace->bundle($request->user(), $id, $state['opportunity'], $body['operation_key']);
return response($bundle['bytes'], 200, ['Content-Type' => 'application/zip', 'Content-Disposition' => 'attachment; filename="preparacao-apiex-'.$id.'.zip"', 'X-Export-Id' => $bundle['export_id'], 'X-Content-Type-Options' => 'nosniff']);
}
}
@@ -11,6 +11,8 @@ use App\Models\ValueChain;
use App\Services\ClusterCompetitiveness;
use App\Services\InvestmentGeography;
use App\Services\PortalAudit;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -31,19 +33,35 @@ class InvestmentStructureController extends Controller
});
}
return Inertia::render('structures/index', ['structure' => $structure, 'filters' => $filters,
'records' => $query->orderBy('name')->orderBy('id')->paginate(20)->withQueryString()]);
$records = $query->orderBy('name')->orderBy('id')->paginate(20)->withQueryString();
if ($structure === 'clusters' && app(ClusterCompetitiveness::class)->editorialEnabled()) {
$records->through(function ($record) {
$analysis = app(ClusterCompetitiveness::class)->current($record->id);
$record->setAttribute('editorial_state', $analysis['workflow']['state'] ?? ($analysis['approved'] ? 'legacy_published' : 'not_started'));
return $record;
});
}
return Inertia::render('structures/index', ['structure' => $structure, 'filters' => $filters, 'records' => $records]);
}
public function edit(Request $request, string $structure, ?string $id = null): Response
{
$model = $this->model($structure);
$record = $id ? $model::findOrFail($id) : null;
$editorialDossier = $id ? app(PortalEditorialWorkflow::class)->structureDossier($structure, $id) : null;
if ($record && $editorialDossier) {
$record = app(PortalEditorialWorkflow::class)->workingStructure($record, $editorialDossier);
} elseif ($record && app(PortalEditorialSubjects::class)->available()) {
$record = clone $record;
$record->forceFill(get_object_vars(app(PortalEditorialSubjects::class)->working($structure, (object) $record->getAttributes())));
}
return Inertia::render('structures/editor', ['structure' => $structure, 'record' => $record,
return Inertia::render('structures/editor', ['editorialDossier' => $editorialDossier?->id, 'editorialSubject' => ! $editorialDossier && $record && app(PortalEditorialSubjects::class)->available() ? '/management/editorial/subjects/'.$structure.'/'.$id : null, 'structuralEditorial' => app(PortalEditorialSubjects::class)->available(), 'structure' => $structure, 'record' => $record,
'projects' => $structure === 'clusters' && $id ? Project::orderBy('name')->get(['id', 'name', 'code']) : [],
'chains' => $structure === 'clusters' && $id ? ValueChain::where('cluster_id', $id)->orderBy('code')->get() : [],
'gaps' => $structure === 'clusters' && $id ? InvestmentGap::where('cluster_id', $id)->orderBy('created_at')->get() : [],
'chains' => $structure === 'clusters' && $id ? ValueChain::where('cluster_id', $id)->orderBy('code')->get()->map(fn ($row) => app(PortalEditorialSubjects::class)->available() ? app(PortalEditorialSubjects::class)->working('value-chains', (object) $row->getAttributes()) : $row) : [],
'gaps' => $structure === 'clusters' && $id ? InvestmentGap::where('cluster_id', $id)->orderBy('created_at')->get()->map(fn ($row) => app(PortalEditorialSubjects::class)->available() ? app(PortalEditorialSubjects::class)->working('investment-gaps', (object) $row->getAttributes()) : $row) : [],
'chainLinks' => $structure === 'clusters' && $id ? DB::table('value_chain_links')
->join('value_chains as origins', 'origins.id', '=', 'value_chain_links.from_value_chain_id')
->join('value_chains as destinations', 'destinations.id', '=', 'value_chain_links.to_value_chain_id')
@@ -56,7 +74,7 @@ class InvestmentStructureController extends Controller
'memberships' => $structure === 'clusters' && $id ? DB::table('cluster_projects')
->join('projects', 'projects.id', '=', 'cluster_projects.project_id')
->where('cluster_id', $id)->whereNull('cluster_projects.deleted_at')->whereNull('projects.deleted_at')
->orderBy('projects.name')->get(['cluster_projects.id', 'cluster_projects.project_id', 'cluster_projects.role', 'cluster_projects.association_type', 'cluster_projects.source_reference', 'cluster_projects.evidence_reference', 'cluster_projects.valid_from', 'cluster_projects.valid_until', 'cluster_projects.validation_status', 'cluster_projects.version_no', 'cluster_projects.editor_id', 'projects.name']) : [],
->orderBy('projects.name')->get(['cluster_projects.id', 'cluster_projects.project_id', 'cluster_projects.role', 'cluster_projects.association_type', 'cluster_projects.source_reference', 'cluster_projects.evidence_reference', 'cluster_projects.valid_from', 'cluster_projects.valid_until', 'cluster_projects.validation_status', 'cluster_projects.version_no', 'cluster_projects.editor_id', 'projects.name'])->map(fn ($row) => app(PortalEditorialSubjects::class)->working('cluster-project', $row)) : [],
]);
}
@@ -67,11 +85,16 @@ class InvestmentStructureController extends Controller
$isCorridor = $structure === 'corridors';
$model = $this->model($structure);
$record = $id ? $model::findOrFail($id) : new $model;
$editorialDossier = $id ? app(PortalEditorialWorkflow::class)->structureDossier($structure, $id, true) : null;
$subjectWrites = ! $editorialDossier && ($id ? app(PortalEditorialSubjects::class)->writes($structure, $id) : app(PortalEditorialSubjects::class)->available());
$rules = [
'code' => ['required', 'string', 'max:100', Rule::unique($record->getTable(), 'code')->whereNull('deleted_at')->ignore($record->getKey())],
'name' => ['required', 'string', 'max:200'],
'status' => ['required', Rule::in($isProject ? ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'] : ($isCluster ? ['planned', 'active', 'inactive'] : ['proposed', 'active', 'closed']))],
];
if ($editorialDossier || ($subjectWrites && $id)) {
$rules['editorial_version'] = ['required', 'integer', 'min:1'];
}
if ($isProject || $isCorridor) {
$rules['description'] = ['nullable', 'string', 'max:10000'];
}
@@ -96,6 +119,22 @@ class InvestmentStructureController extends Controller
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
$data['territory_version'] = $path === [] ? 0 : 1;
}
if ($editorialDossier) {
app(PortalEditorialWorkflow::class)->saveStructure($request->user(), $record, $editorialDossier, (int) $data['editorial_version'], $data);
return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Dados-base guardados numa edição privada da carteira. A publicação anterior está preservada.');
}
if ($subjectWrites) {
DB::transaction(function () use ($request, $record, $structure, $id, $data): void {
$fields = array_diff_key($data, ['editorial_version' => true]);
if (! $id) {
PortalAudit::save($record, $fields, $request->user()->id);
}
app(PortalEditorialWorkflow::class)->saveSubject($request->user(), $structure, (string) $record->getKey(), $id ? (int) $data['editorial_version'] : 0, $fields, ! $id);
});
return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Proposta privada guardada. Reveja a edição antes de publicar as alterações.');
}
PortalAudit::save($record, $data, $request->user()->id);
return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Registo guardado.');
@@ -114,6 +153,7 @@ class InvestmentStructureController extends Controller
public function linkProject(Request $request, EconomicCluster $cluster): RedirectResponse
{
$editorial = app(PortalEditorialSubjects::class)->available();
$data = $request->validate(['project_id' => ['required', 'uuid', Rule::exists('projects', 'id')->whereNull('deleted_at')],
'version_no' => ['required', 'integer', 'min:0'],
'role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])],
@@ -121,10 +161,21 @@ class InvestmentStructureController extends Controller
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
'evidence_reference' => ['required', 'string', 'min:3', 'max:2000'],
'valid_from' => ['required', 'date_format:Y-m-d'],
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from']]);
DB::transaction(function () use ($cluster, $data, $request) {
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
...($editorial ? ['editorial_version' => ['required', 'integer', 'min:0']] : [])]);
DB::transaction(function () use ($cluster, $data, $request, $editorial) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
$link = ClusterProject::where('cluster_id', $cluster->id)->where('project_id', $data['project_id'])->whereNull('deleted_at')->lockForUpdate()->first();
if ($editorial || ($link && app(PortalEditorialSubjects::class)->writes('cluster-project', $link->id))) {
abort_unless((int) $data['version_no'] === ($link ? (int) $link->version_no : 0), 409, 'A associação foi alterada. Confira a versão actual.');
$fields = [...array_diff_key($data, ['version_no' => true, 'editorial_version' => true]), 'cluster_id' => $cluster->id];
if (! $link) {
$fields += ['validation_status' => 'pending', 'editor_id' => $request->user()->id, 'validator_id' => null, 'version_no' => 1];
}
app(PortalEditorialSubjects::class)->stageModel($request->user(), 'cluster-project', $link ?? new ClusterProject, $fields, (int) $data['editorial_version']);
return;
}
if ($link) {
abort_if((int) $link->version_no !== (int) $data['version_no'], 409, 'Versao da associacao alterada.');
$changed = false;
@@ -155,10 +206,17 @@ class InvestmentStructureController extends Controller
public function saveChain(Request $request, EconomicCluster $cluster, ?ValueChain $chain = null): RedirectResponse
{
abort_if($chain && $chain->cluster_id !== $cluster->id, 404);
$editorial = $chain ? app(PortalEditorialSubjects::class)->writes('value-chains', $chain->id) : app(PortalEditorialSubjects::class)->available();
$data = $request->validate(['code' => ['required', 'string', 'max:100',
Rule::unique('value_chains', 'code')->where('cluster_id', $cluster->id)->whereNull('deleted_at')->ignore($chain?->id)],
'name' => ['required', 'string', 'max:200'],
'stage' => ['required', Rule::in(['input', 'production', 'processing', 'logistics', 'market'])]]);
'stage' => ['required', Rule::in(['input', 'production', 'processing', 'logistics', 'market'])],
...($editorial && $chain ? ['editorial_version' => ['required', 'integer', 'min:1']] : [])]);
if ($editorial) {
$edition = app(PortalEditorialSubjects::class)->stageModel($request->user(), 'value-chains', $chain ?? new ValueChain, [...array_diff_key($data, ['editorial_version' => true]), 'cluster_id' => $cluster->id], $chain ? (int) $data['editorial_version'] : 0);
return redirect('/management/editorial/subjects/value-chains/'.$edition->subject_key)->with('success', 'Etapa guardada numa proposta privada.');
}
PortalAudit::save($chain ?? new ValueChain, [...$data, 'cluster_id' => $cluster->id], $request->user()->id);
return back()->with('success', 'Etapa da cadeia de valor guardada.');
@@ -2,6 +2,8 @@
namespace App\Http\Controllers;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -30,7 +32,8 @@ class InvestmentStructureRelationController extends Controller
...($kind === 'cluster-project' ? ['role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])]] : []),
]);
DB::transaction(function () use ($data, $table, $left, $right, $leftTable, $request): void {
$proposal = null;
DB::transaction(function () use ($data, $table, $left, $right, $leftTable, $request, $kind, &$proposal): void {
DB::table($leftTable)->where('id', $data[$left])->lockForUpdate()->firstOrFail();
$existing = DB::table($table)->where($left, $data[$left])->where($right, $data[$right])->whereNull('deleted_at')->lockForUpdate()->first();
abort_if($existing !== null, 409, 'Associacao ja registada.');
@@ -40,8 +43,15 @@ class InvestmentStructureRelationController extends Controller
$id = (string) Str::uuid();
DB::table($table)->insert([...$values, 'id' => $id, 'source_system' => 'portal-management']);
$this->audit($request, $table, $id, 'submitted', 1);
if (app(PortalEditorialSubjects::class)->available()) {
$proposal = app(PortalEditorialWorkflow::class)->saveSubject($request->user(), $kind, $id, 0, $data, true);
}
});
if ($proposal) {
return redirect('/management/editorial/subjects/'.$kind.'/'.$proposal->subject_key)->with('success', 'Associação guardada numa proposta privada.');
}
return back()->with('success', 'Associacao submetida para revisao independente.');
}
@@ -50,6 +60,12 @@ class InvestmentStructureRelationController extends Controller
abort_unless(in_array($action, ['validate', 'revoke'], true), 404);
[$table] = $this->definition($kind);
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
if (app(PortalEditorialSubjects::class)->writes($kind, $id)) {
app(PortalEditorialSubjects::class)->authorize($request->user(), $kind, $id, true);
abort_if($request->expectsJson(), 409, 'Continue em /management/editorial/subjects/'.$kind.'/'.$id.' para rever a edição e publicar a decisão separadamente.');
return redirect('/management/editorial/subjects/'.$kind.'/'.$id)->with('success', 'Prepare ou confira a edição antes de decidir. Para retirar, marque a proposta de retirada.');
}
DB::transaction(function () use ($request, $table, $id, $action, $data): void {
$relation = DB::table($table)->where('id', $id)->lockForUpdate()->first();
@@ -2,8 +2,16 @@
namespace App\Http\Controllers;
use App\Models\DevelopmentCorridor;
use App\Models\EconomicCluster;
use App\Models\InvestmentDossier;
use App\Models\InvestmentZone;
use App\Models\Opportunity;
use App\Models\Project;
use App\Services\InvestmentZoneOperationalProfile;
use App\Services\InvestmentZones;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess;
use App\Services\TerritoryGeometry;
use Illuminate\Http\JsonResponse;
@@ -16,7 +24,7 @@ class InvestmentTerritoryController extends Controller
{
public function geometries(Request $request): JsonResponse
{
$data = $request->validate(['territory_id' => ['required', 'uuid']]);
$data = $request->validate(['territory_id' => ['required', 'uuid'], 'include_children' => ['sometimes', 'boolean']]);
$profile = $request->is('minhas-oportunidades/*');
$management = $request->is('management/*') || $profile;
$actor = $request->user();
@@ -31,7 +39,7 @@ class InvestmentTerritoryController extends Controller
}
return response()->json([
...app(TerritoryGeometry::class)->context($data['territory_id'], $internal),
...app(TerritoryGeometry::class)->context($data['territory_id'], $internal, $request->boolean('include_children')),
'geometry_scope' => $internal ? 'authorized_internal' : 'public',
])->header('Cache-Control', 'private, no-store');
}
@@ -82,6 +90,18 @@ class InvestmentTerritoryController extends Controller
$table = self::TABLES[$kind] ?? abort(404);
$record = DB::table($table)->where('id', $id)->whereNull('deleted_at')->first();
abort_unless($record !== null, 404);
if ($dossier = app(PortalEditorialWorkflow::class)->structureDossier($kind, $id)) {
$model = ['projects' => Project::class, 'clusters' => EconomicCluster::class, 'corridors' => DevelopmentCorridor::class][$kind] ?? abort(404);
$working = app(PortalEditorialWorkflow::class)->workingStructure($model::findOrFail($id), $dossier);
$record->primary_territory_id = $working->getAttribute('primary_territory_id');
$record->territory_version = $working->getAttribute('territory_version');
} elseif (in_array($kind, ['projects', 'clusters', 'corridors'], true) && app(PortalEditorialSubjects::class)->available()) {
$record = app(PortalEditorialSubjects::class)->working($kind, $record);
} elseif ($kind === 'zones' && app(InvestmentZoneOperationalProfile::class)->installed()) {
$working = app(InvestmentZoneOperationalProfile::class)->management(InvestmentZone::findOrFail($id));
$record->primary_territory_id = $working['primary_territory_id'];
$record->territory_version = $working['territory_version'];
}
$path = [];
$current = $record->primary_territory_id;
for ($i = 0; $current && $i < 5; $i++) {
@@ -93,7 +113,7 @@ class InvestmentTerritoryController extends Controller
$current = $location->parent_id;
}
return response()->json(['version' => $record->territory_version, 'path' => $path]);
return response()->json(['version' => $record->territory_version, 'path' => $path])->header('Cache-Control', 'private, no-store');
}
public function save(Request $request, string $kind, string $id): JsonResponse
@@ -103,11 +123,29 @@ class InvestmentTerritoryController extends Controller
abort_unless($request->user()->two_factor_confirmed_at && $request->user()->two_factor_secret, 403);
}
$data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'path' => ['present', 'array', 'max:4'], 'path.*' => ['required', 'uuid', 'distinct']]);
$version = DB::transaction(function () use ($request, $table, $id, $data) {
$dossier = app(PortalEditorialWorkflow::class)->structureDossier($kind, $id, true);
$structural = ! $dossier && in_array($kind, ['projects', 'clusters', 'corridors'], true) && app(PortalEditorialSubjects::class)->writes($kind, $id);
$version = DB::transaction(function () use ($request, $table, $id, $data, $dossier, $kind, $structural) {
if ($dossier) {
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
}
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
if ($structural) {
app(PortalEditorialSubjects::class)->lock($kind, $id, ['primary_territory_id' => $data['path'] === [] ? null : $data['path'][array_key_last($data['path'])]]);
}
$record = DB::table($table)->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($record !== null, 404);
abort_unless((int) $record->territory_version === $data['version'], 409, 'Localização alterada. Recarregue a ficha.');
$effectiveVersion = (int) $record->territory_version;
if ($dossier) {
$model = ['projects' => Project::class, 'clusters' => EconomicCluster::class, 'corridors' => DevelopmentCorridor::class][$kind] ?? abort(404);
$effectiveVersion = (int) app(PortalEditorialWorkflow::class)->workingStructure($model::findOrFail($id), $dossier)->getAttribute('territory_version');
} elseif ($structural) {
$effectiveVersion = (int) app(PortalEditorialSubjects::class)->working($kind, $record)->territory_version;
} elseif ($kind === 'zones' && app(InvestmentZoneOperationalProfile::class)->installed()) {
$working = app(InvestmentZoneOperationalProfile::class)->management(InvestmentZone::findOrFail($id));
$effectiveVersion = (int) $working['territory_version'];
}
abort_unless($effectiveVersion === $data['version'], 409, 'Localização alterada. Recarregue a ficha.');
$parent = null;
$names = [];
foreach (array_values($data['path']) as $index => $locationId) {
@@ -119,13 +157,26 @@ class InvestmentTerritoryController extends Controller
$names[] = $location->name;
}
$version = $data['version'] + 1;
if ($dossier) {
$workflow = app(PortalEditorialWorkflow::class);
$workflow->saveStructure($request->user(), $model::findOrFail($id), $dossier, (int) $workflow->workingPortfolio($dossier)->getAttribute('version_no'), ['primary_territory_id' => $parent, 'territory_version' => $version]);
return $version;
}
if ($structural) {
$workflow = app(PortalEditorialWorkflow::class);
$workflow->saveSubject($request->user(), $kind, $id, $workflow->subjectVersion($kind, $id), ['primary_territory_id' => $parent, 'territory_version' => $version]);
return $version;
}
$changes = ['primary_territory_id' => $parent, 'territory_version' => $version, 'updated_at' => now()];
if (in_array($table, ['opportunities', 'investment_zones'], true)) {
$changes['province'] = $names[0] ?? null;
$changes['district'] = $names[1] ?? null;
}
if ($table === 'investment_zones') {
app(InvestmentZones::class)->save($id, [...$changes, 'version_no' => $record->version_no], $request->user()->id);
$workingVersion = app(InvestmentZoneOperationalProfile::class)->workingVersion(InvestmentZone::findOrFail($id));
app(InvestmentZones::class)->save($id, [...$changes, 'version_no' => $workingVersion], $request->user()->id);
} else {
DB::table($table)->where('id', $id)->update($changes);
}
@@ -136,6 +187,8 @@ class InvestmentTerritoryController extends Controller
return $version;
});
return response()->json(['version' => $version]);
$zoneDraft = $kind === 'zones' && (app(InvestmentZoneOperationalProfile::class)->editorialEnabled() || DB::table('investment_zones')->where('id', $id)->value('category') === 'industrial_park');
return response()->json(['version' => $version, 'pending_publication' => $dossier !== null || $structural || $zoneDraft]);
}
}
@@ -19,6 +19,8 @@ use App\Services\InvestmentZones;
use App\Services\PublicSearchMetrics;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
@@ -291,6 +293,13 @@ class InvestmentZoneController extends Controller
return response()->json(app(InvestmentZoneOperationalProfile::class)->management($saved));
}
public function editorialReview(Request $request, string $id, string $action): JsonResponse
{
$data = $request->validate(['version_no' => 'required|integer|min:1', 'reason' => 'required|string|min:10|max:3000']);
return response()->json(app(InvestmentZoneOperationalProfile::class)->review($id, $request->user(), (int) $data['version_no'], $action, $data['reason']));
}
public function revisions(string $id): JsonResponse
{
InvestmentZone::findOrFail($id);
@@ -298,6 +307,30 @@ class InvestmentZoneController extends Controller
return response()->json(DB::table('investment_zone_revisions')->where('zone_id', $id)->orderByDesc('version_no')->paginate(20, ['id', 'version_no', 'actor_id', 'action', 'sha256', 'created_at']));
}
public function revision(Request $request, string $id, int $version): JsonResponse
{
abort_unless(app(InvestmentZoneOperationalProfile::class)->reviewAccess($request->user()), 403);
InvestmentZone::findOrFail($id);
$revision = DB::table('investment_zone_revisions')->where('zone_id', $id)->where('version_no', $version)->first();
abort_unless($revision !== null, 404);
try {
$json = Crypt::decryptString($revision->snapshot);
abort_unless(hash_equals($revision->sha256, hash('sha256', $json)), 409);
$snapshot = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
$fields = $snapshot['draft']['fields'] ?? $snapshot['public'] ?? $snapshot;
$details = $fields['operational_details'] ?? [];
if (isset($details['private_ciphertext'])) {
$details = json_decode(Crypt::decryptString($details['private_ciphertext']), true, 512, JSON_THROW_ON_ERROR);
}
} catch (\Throwable $e) {
abort(409, 'Não foi possível conferir esta versão. O histórico foi preservado.');
}
return response()->json(['version_no' => $revision->version_no, 'action' => $revision->action,
'fields' => Arr::only($fields, ['name', 'description']),
'observations' => array_map(fn ($row) => Arr::only($row, ['label', 'value', 'unit', 'scope', 'period_start', 'period_end', 'source_reference']), $details['integral']['observations'] ?? [])]);
}
public function validateLocation(Request $request, InvestmentZones $zones, string $id): JsonResponse
{
InvestmentZoneAccess::requireRole($request->user(), 'territory_reviewers');
@@ -3,6 +3,8 @@
namespace App\Http\Controllers;
use App\Models\InvestmentZone;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -36,14 +38,28 @@ class InvestmentZoneRelationController extends Controller
DB::table('investment_zone_relations')->insert([...$data, 'id' => $relation, 'zone_id' => $id,
'editor_id' => $request->user()->id, 'created_at' => now(), 'updated_at' => now()]);
$this->audit($relation, $request->user()->id, 'created', 1);
if (app(PortalEditorialSubjects::class)->available()) {
app(PortalEditorialWorkflow::class)->saveSubject($request->user(), 'zone-relations', $relation, 0, [...$data, 'zone_id' => $id], true);
}
return response()->json(DB::table('investment_zone_relations')->where('id', $relation)->first(), 201);
$record = DB::table('investment_zone_relations')->where('id', $relation)->first();
if (app(PortalEditorialSubjects::class)->available()) {
$record->editorial_url = '/management/editorial/subjects/zone-relations/'.$relation;
$record->pending_publication = true;
}
return response()->json($record, 201);
});
}
public function review(Request $request, string $id, string $action): JsonResponse
{
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
if (app(PortalEditorialSubjects::class)->writes('zone-relations', $id)) {
app(PortalEditorialSubjects::class)->authorize($request->user(), 'zone-relations', $id, true);
return response()->json(['pending_publication' => true, 'editorial_url' => '/management/editorial/subjects/zone-relations/'.$id]);
}
return DB::transaction(function () use ($request, $id, $action, $data) {
$relation = DB::table('investment_zone_relations')->where('id', $id)->lockForUpdate()->first();
@@ -8,7 +8,9 @@ use App\Services\InvestmentGuides;
use App\Services\InvestmentZonePathContext;
use App\Services\InvestorPath;
use App\Services\LegacyContent;
use App\Services\SectorResourceReferences;
use App\Services\SectorInvestment;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -18,6 +20,16 @@ use Inertia\Response;
class InvestorPathController extends Controller
{
public function fiscalReport(Request $request, string $id, string $reportId, \App\Services\SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(app(InvestorPath::class)->available(), 404);
$request->validate(['opportunity' => 'nullable|string|max:150']);
$data = $drafts->dossierReport($request->user(), $id, $reportId, $request->query('opportunity'));
return response()->json($data)->header('Content-Disposition', 'attachment; filename="relatorio-fiscal-'.$reportId.'.json"')
->header('X-Content-Type-Options', 'nosniff');
}
public function index(Request $request, InvestorPath $path): JsonResponse|Response
{
abort_unless($path->available(), 404);
@@ -32,6 +44,9 @@ class InvestorPathController extends Controller
{
abort_unless($path->available(), 404);
$data = ['contexts' => $path->contexts($request->user()), 'sectorSource' => app(SectorInvestment::class)->context($sectorSlug)];
if (isset(SectorResourceReferences::SOURCES[$sectorSlug]) && $request->filled('resource_area')) {
$data['resourceSource'] = app(SectorResourceReferences::class)->selection($sectorSlug, ['area_id' => $request->query('resource_area'), 'location_label' => $request->query('resource_location'), 'group_id' => $request->query('resource_group'), 'source_sha256' => $request->query('resource_source')]);
}
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/index', $data);
}
@@ -73,14 +88,28 @@ class InvestorPathController extends Controller
if ($sector !== null) {
app(SectorInvestment::class)->context($sector);
}
$reference = $request->input('sector_reference');
if ($reference !== null) {
abort_unless(isset(SectorResourceReferences::SOURCES[$sector]) && is_array($reference), 422);
app(SectorResourceReferences::class)->selection($sector, $reference);
}
$source = $request->input('content_resource_id');
if ($source !== null) {
$resourceId = filter_var($source, FILTER_VALIDATE_INT);
abort_unless(is_int($resourceId) && $resourceId !== 0, 422);
app(LegacyContent::class)->articleForResource($resourceId);
}
$id = DB::transaction(function () use ($path, $request, $source, $sector, $zone): string {
$id = DB::transaction(function () use ($path, $request, $source, $sector, $zone, $reference): string {
if ($reference !== null) {
DB::table('users')->where('id', $request->user()->id)->lockForUpdate()->first();
}
$existingReferenceCase = $reference !== null ? DB::table('desk_cases')->where('user_id', $request->user()->id)->where('client_key', $request->input('client_key'))->first() : null;
$id = $path->create($request->user(), $request->all());
if ($existingReferenceCase) {
$selection = app(SectorResourceReferences::class)->selection($sector, $reference);
$oldPayload = app(VirtualDesk::class)->decode($existingReferenceCase->payload);
abort_unless(array_any($oldPayload['investor_path']['sector_references'] ?? [], fn ($row) => $row['key'] === $selection['key']), 409, 'A chave de criação já foi usada com outra referência sectorial; recupere o dossier existente.');
}
if ($source !== null) {
$case = $path->view($request->user(), $id)['case'];
app(LegacyContent::class)->attach($request->user(), $id, ['version' => $case['version'], 'resource_id' => (int) $source]);
@@ -88,7 +117,7 @@ class InvestorPathController extends Controller
if ($sector !== null) {
$case = $path->view($request->user(), $id)['case'];
app(SectorInvestment::class)->attach($request->user(), $id, ['version' => $case['version'], 'sector_slug' => $sector]);
app(SectorInvestment::class)->attach($request->user(), $id, ['version' => $case['version'], 'sector_slug' => $sector, 'sector_reference' => $reference]);
}
if ($zone) {
@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestmentGeography;
use App\Services\OfferPublicCatalogue;
use App\Services\OpportunityManagement;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class OfferPublicCatalogueController extends Controller
{
public function index(Request $request, OfferPublicCatalogue $catalogue): Response
{
$filters = $catalogue->filters($request);
return $this->uncached(Inertia::render('offers/catalogue', [...$catalogue->page($request, $filters), 'filters' => $filters,
'mapData' => in_array($filters['view'] ?? '', ['map', 'combined'], true) ? $catalogue->map($filters) : null,
'territorySelection' => app(InvestmentGeography::class)->selection($filters),
'references' => ['categories' => OpportunityManagement::CATEGORIES, 'options' => [], 'clusters' => [], 'corridors' => [], 'projects' => [], 'locations' => [], 'sectors' => []]])->toResponse($request));
}
public function show(Request $request, int $id, OfferPublicCatalogue $catalogue): Response
{
return $this->uncached(Inertia::render('offers/public-detail', ['offer' => $catalogue->detail($id)])->toResponse($request));
}
public function search(Request $request, OfferPublicCatalogue $catalogue): JsonResponse
{
return response()->json($catalogue->page($request, $catalogue->filters($request)))->header('Cache-Control', 'private, no-store');
}
public function detail(int $id, OfferPublicCatalogue $catalogue): JsonResponse
{
return response()->json(['data' => $catalogue->detail($id)])->header('Cache-Control', 'private, no-store');
}
public function map(Request $request, OfferPublicCatalogue $catalogue): JsonResponse
{
return response()->json($catalogue->map($catalogue->filters($request)))->header('Cache-Control', 'private, no-store');
}
public function export(Request $request, OfferPublicCatalogue $catalogue): StreamedResponse
{
$result = $catalogue->records($catalogue->filters($request));
return response()->streamDownload(function () use ($result) {
$stream = fopen('php://output', 'w');
if ($stream === false) {
return;
}
fwrite($stream, "\xEF\xBB\xBF");
fputcsv($stream, ['Título', 'Categoria', 'Localização', 'Preço', 'Moeda', 'Publicação', 'URL'], ';', '"', '');
foreach ($result['records'] as $record) {
$fields = [$record['title'], $record['category'], implode(' / ', array_column($record['location'], 'name')),
$record['price']['mode'] === 'negotiable' ? 'A negociar' : $record['price']['amount'], $record['price']['currency'], $record['published_at'], url($record['url'])];
fputcsv($stream, array_map(fn ($value) => preg_match('/^[\s]*[=+@-]/u', (string) $value) ? "'".$value : $value, $fields), ';', '"', '');
}
fclose($stream);
}, 'ofertas.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', 'X-Export-Limit' => '1000', 'X-Results-Truncated' => $result['truncated'] ? 'true' : 'false']);
}
private function uncached(Response $response): Response
{
$response->headers->set('Cache-Control', 'private, no-store');
return $response;
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers;
use App\Services\OpportunityCuration;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class OpportunityCurationController extends Controller
{
public function show(Request $request, int $id, OpportunityCuration $service): Response
{
$base = str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities';
$filters = $request->validate(['page' => 'nullable|integer|min:1|max:1000000']);
return Inertia::render('management/opportunity-curation', [...$service->state($request->user(), $id, (int) ($filters['page'] ?? 1)), 'basePath' => $base]);
}
public function preview(Request $request, int $id, OpportunityCuration $service): JsonResponse
{
return response()->json($service->preview($request->user(), $id, $request->all()))->header('Cache-Control', 'private, no-store');
}
public function confirm(Request $request, int $id, OpportunityCuration $service): JsonResponse
{
return response()->json($service->confirm($request->user(), $id, $request->all()))->header('Cache-Control', 'private, no-store');
}
}
@@ -8,6 +8,7 @@ use App\Models\Sector;
use App\Services\InvestmentGeography;
use App\Services\OpportunityCanonicalContract;
use App\Services\OpportunityCanonicalResponse;
use App\Services\OpportunityCuration;
use App\Services\OpportunityManagement;
use App\Services\OpportunityManagementInstallation;
use App\Services\OpportunityMaturity;
@@ -130,6 +131,8 @@ class OpportunityManagementController extends Controller
$access->authorize($request->user(), 'view', $record);
}
Inertia::share(['basePath' => str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities',
'curationHref' => $record && app(OpportunityCuration::class)->allowed($request->user(), $record)
? (str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades/' : '/management/opportunities/').$record->id.'/destaque' : null,
'canonicalOptions' => ($record?->owner_user_id !== null || str_starts_with($request->path(), 'minhas-oportunidades')) ? app(OpportunityCanonicalContract::class)->options($request->user(), $record) : null,
'workflowState' => $record ? $access->state($record) : null,
'profileReauthenticationRequired' => $record && $record->owner_user_id !== null
@@ -0,0 +1,265 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\ContentManagement;
use App\Services\DocumentManagementAccess;
use App\Services\PortalEditorialNativeNotices;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess;
use App\Services\TerritorialRegistry;
use App\Services\VerifiedDocumentBinary;
use App\Support\CheckedFile;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
final class PortalEditorialWorkflowController extends Controller
{
public function subjects(Request $request, PortalEditorialWorkflow $workflow): Response
{
$adapter = app(PortalEditorialSubjects::class);
abort_unless($adapter->available() && $request->user()->can('manage-portal') && ! app(PortalProfileAccess::class)->has($request->user(), 'auditor'), 403);
$items = $workflow->subjectQueue()->orderByDesc('e.updated_at')->paginate(25, ['e.id', 'e.subject_kind', 'e.subject_key', 'e.revision_no', 'e.state', DB::raw("e.payload->'resource'->>'title' AS title")]);
return $this->screen($request, 'management/editorial-subjects', ['items' => $items]);
}
public function subject(Request $request, string $kind, string $id, PortalEditorialWorkflow $workflow): Response
{
$adapter = app(PortalEditorialSubjects::class);
$adapter->authorize($request->user(), $kind, $id);
$record = $adapter->query($kind, $id)->firstOrFail();
$selected = $request->validate(['edition' => 'nullable|uuid'])['edition'] ?? null;
$latest = $workflow->latestSubject($kind, $id);
$editions = DB::table('portal_editorial_editions')->where('subject_kind', $kind)->where('subject_key', $id);
$edition = $selected ? (clone $editions)->where('id', $selected)->first() : $latest;
abort_if($selected && ! $edition, 404);
$payload = $edition ? $workflow->payload($edition) : null;
$independent = $edition ? $workflow->requiresIndependent($edition) : $adapter->independent($kind);
$canApprove = ! $independent || ! in_array($request->user()->id, $payload['contributors'] ?? [], true);
return $this->screen($request, 'management/editorial-edition', ['subject' => 'structural', 'subjectKind' => $kind, 'editUrl' => $adapter->editUrl($kind, $record), 'subjectFields' => $adapter->form($kind), 'subjectInitial' => Arr::only(get_object_vars($record), $adapter->definition($kind)['fields']),
'resource' => ['id' => $id, 'title' => $record->name ?? $record->title ?? $kind, 'published' => ($record->deleted_at ?? null) === null && ($record->validation_status ?? null) !== 'revoked' && (clone $editions)->where('state', 'published')->exists(), 'public_edition_no' => (clone $editions)->where('state', 'published')->max('revision_no')],
'edition' => $edition ? [...get_object_vars($edition), 'payload' => $payload, 'fresh' => $edition->state === 'published' || $workflow->fresh($edition)] : null,
'current' => $edition && $edition->id === $latest?->id, 'independentRequired' => $independent, 'canApprove' => $canApprove, 'manager' => true, 'legalReviewer' => false,
'reviewers' => User::whereIn('email', config('portal.managers', []))->whereNotNull('email_verified_at')->get(['id', 'name']),
'history' => DB::table('audit_events')->where('source_system', 'document-access-v1')->where('action', 'like', 'editorial_%')->where('after_data->subject_kind', $kind)->where('after_data->subject_key', $id)->orderByDesc('id')->get(['id', 'action', 'actor_user_id', 'after_data', 'occurred_at']),
'editions' => $editions->orderByDesc('revision_no')->get(['id', 'revision_no', 'state', 'content_hash', 'author_id', 'created_at'])]);
}
public function subjectTransition(Request $request, string $kind, string $id, string $edition, PortalEditorialWorkflow $workflow): Response
{
$result = $workflow->transition($request->user(), $id, $edition, $request->all(), false, false, $kind);
return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial registada.');
}
public function subjectSave(Request $request, string $kind, string $id, PortalEditorialWorkflow $workflow): Response
{
$adapter = app(PortalEditorialSubjects::class);
$adapter->authorize($request->user(), $kind, $id, true);
$data = $request->validate(['expected_revision' => 'required|integer|min:1', 'fields' => 'required|array', 'withdraw' => 'sometimes|boolean']);
$edition = $workflow->saveSubject($request->user(), $kind, $id, (int) $data['expected_revision'], $adapter->validate($kind, $data['fields']), false, $data['withdraw'] ?? null);
return $request->expectsJson() ? response()->json(['edition' => $edition], 201)->header('Cache-Control', 'private, no-store') : redirect('/management/editorial/subjects/'.$kind.'/'.$id)->with('success', 'Proposta privada guardada.');
}
public function subjectChoices(Request $request, string $kind, string $id): Response
{
$adapter = app(PortalEditorialSubjects::class);
$adapter->authorize($request->user(), $kind, $id);
$data = $request->validate(['field' => 'required|string|max:80', 'q' => 'nullable|string|max:100']);
return response()->json(['data' => $adapter->choices($kind, $data['field'], $data['q'] ?? null), 'limit' => 100])->header('Cache-Control', 'private, no-store');
}
private const KIND = "CASE WHEN e.payload->'resource'->>'legislation' = 'true' OR e.payload->'resource'->>'document_type' = 'legislation' THEN 'legislation' WHEN e.payload->'resource'->>'editorial_content' IS NOT NULL THEN 'article' ELSE 'document' END";
/** @param array<string,mixed> $data */
private function screen(Request $request, string $component, array $data): Response
{
$response = $request->expectsJson() ? response()->json($data) : Inertia::render($component, $data)->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store');
return $response;
}
public function index(Request $request, PortalEditorialWorkflow $workflow): Response
{
abort_unless($workflow->enabled(), 503);
$actor = $request->user();
$access = app(DocumentManagementAccess::class);
$query = $workflow->queue($actor);
$filters = array_filter($request->validate(['state' => 'nullable|in:draft,submitted,in_review,approved,published,returned,rejected,archived', 'kind' => 'nullable|in:article,legislation,document']), fn ($value) => $value !== null && $value !== '');
if (isset($filters['state'])) {
$query->where('e.state', $filters['state']);
}
if (isset($filters['kind'])) {
$query->whereRaw(self::KIND.' = ?', [$filters['kind']]);
}
$items = $query->orderByDesc('e.updated_at')->paginate(25, ['r.id', DB::raw("e.payload->'resource'->>'title' AS title"), 'r.document_type', 'r.published', 'e.id as edition_id', 'e.revision_no', 'e.state', 'e.legal_required', 'u.name as responsible'])->withQueryString();
$destinations = app(ContentManagement::class)->access($actor);
$data = ['items' => $items, 'filters' => $filters, 'notices' => $workflow->notices($actor), 'resourceAccess' => $access->full($actor) || $access->editorial($actor), 'opportunitiesUrl' => $destinations['opportunities']['index'] ?? null,
'offersUrl' => isset($destinations['opportunities']['index']) && app(PortalProfileAccess::class)->available() ? '/minhas-oportunidades/ofertas' : null,
'offerOperationsUrl' => ! app(PortalProfileAccess::class)->has($actor, 'auditor') && (app(PortalProfileAccess::class)->has($actor, 'validator') || app(PortalProfileAccess::class)->has($actor, 'publisher')) ? '/minhas-oportunidades/ofertas?operational=1' : null,
'opportunityPending' => $workflow->opportunityPending($actor), 'clustersUrl' => $destinations['clusters']['index'] ?? null,
'portfolioUrl' => $destinations['portfolio']['index'] ?? null, 'portfolioPending' => $workflow->portfolioPending($actor), 'demographyUrl' => $destinations['demography']['index'] ?? null, 'demographyPending' => $workflow->demographyPending($actor),
'territorialUrl' => app(TerritorialRegistry::class)->editorialAvailable() && $actor->can('manage-portal') ? '/management/territorial/editorial' : null,
'nativePending' => app(PortalEditorialNativeNotices::class)->pending($actor), 'zonesUrl' => app(PortalEditorialNativeNotices::class)->access($actor, 'investment-zones') ? '/management/investment/zones' : null,
'subjectsUrl' => app(PortalEditorialSubjects::class)->available() && $actor->can('manage-portal') ? '/management/editorial/subjects' : null, 'subjectsPending' => $workflow->subjectPending($actor)];
return $this->screen($request, 'management/editorial-workflow', $data);
}
public function show(Request $request, int $id, PortalEditorialWorkflow $workflow): Response
{
$workflow->authorize($request->user(), $id);
$resource = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($resource !== null, 404);
$selected = $request->validate(['edition' => 'nullable|uuid'])['edition'] ?? null;
$latest = $workflow->latest($id);
$e = $selected ? DB::table('portal_editorial_editions')->where('resource_id', $id)->where('id', $selected)->first() : $latest;
abort_if($selected && ! $e, 404);
$payload = $e ? $workflow->payload($e) : null;
$independent = ! $e || ! $workflow->requiresIndependent($e) || ! in_array($request->user()->id, $payload['contributors'], true);
$data = ['resource' => ['id' => $id, 'title' => $resource->title, 'published' => (bool) $resource->published, 'public_edition_no' => $resource->published ? DB::table('portal_editorial_editions')->where('resource_id', $id)->where('state', 'published')->max('revision_no') : null], 'edition' => $e ? [...(array) $e, 'payload' => $payload, 'fresh' => $e->state === 'published' || $workflow->fresh($e)] : null,
'current' => $e && $e->id === $latest?->id,
'independentRequired' => $e ? $workflow->requiresIndependent($e) : false,
'canApprove' => $independent && app(DocumentManagementAccess::class)->full($request->user()),
'manager' => app(DocumentManagementAccess::class)->full($request->user()),
'reviewers' => User::whereIn('email', config('portal.managers', []))->whereNotNull('email_verified_at')->get(['id', 'name']),
'legalReviewer' => $workflow->legalReviewer($request->user()),
'history' => DB::table('audit_events')->where('source_system', 'document-access-v1')->where('action', 'like', 'editorial_%')->where('after_data->resource_id', $id)->orderByDesc('id')->get(['id', 'action', 'actor_user_id', 'after_data', 'occurred_at']),
'editions' => DB::table('portal_editorial_editions')->where('resource_id', $id)->orderByDesc('revision_no')->get(['id', 'revision_no', 'state', 'content_hash', 'author_id', 'created_at'])];
return $this->screen($request, 'management/editorial-edition', $data);
}
public function transition(Request $request, int $id, string $edition, PortalEditorialWorkflow $workflow): Response
{
$result = $workflow->transition($request->user(), $id, $edition, $request->all());
return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial registada.');
}
public function portfolio(Request $request, string $id, PortalEditorialWorkflow $workflow): Response
{
$workflow->authorizePortfolio($request->user(), $id);
$record = DB::table('investment_dossiers')->where('id', $id)->first();
abort_unless($record !== null, 404);
$selected = $request->validate(['edition' => 'nullable|uuid'])['edition'] ?? null;
$latest = $workflow->latestPortfolio($id);
$edition = $selected ? DB::table('portal_editorial_editions')->where('dossier_id', $id)->where('id', $selected)->first() : $latest;
abort_if($selected && ! $edition, 404);
$payload = $edition ? $workflow->payload($edition) : null;
$independent = ! $edition || ! in_array($request->user()->id, $payload['contributors'], true);
return $this->screen($request, 'management/editorial-edition', ['subject' => 'portfolio',
'resource' => ['id' => $id, 'title' => $record->title, 'published' => $record->publication_status === 'published', 'public_edition_no' => $record->publication_status === 'published' ? DB::table('portal_editorial_editions')->where('dossier_id', $id)->where('state', 'published')->max('revision_no') : null],
'edition' => $edition ? [...get_object_vars($edition), 'payload' => $payload, 'fresh' => $edition->state === 'published' || $workflow->fresh($edition)] : null,
'current' => $edition && $edition->id === $latest?->id, 'independentRequired' => true, 'canApprove' => $independent, 'manager' => true, 'legalReviewer' => false,
'reviewers' => User::whereIn('email', config('portal.managers', []))->whereNotNull('email_verified_at')->get(['id', 'name']),
'history' => DB::table('audit_events')->where('source_system', 'document-access-v1')->where('action', 'like', 'editorial_%')->where('after_data->dossier_id', $id)->orderByDesc('id')->get(['id', 'action', 'actor_user_id', 'after_data', 'occurred_at']),
'editions' => DB::table('portal_editorial_editions')->where('dossier_id', $id)->orderByDesc('revision_no')->get(['id', 'revision_no', 'state', 'content_hash', 'author_id', 'created_at'])]);
}
public function portfolioTransition(Request $request, string $id, string $edition, PortalEditorialWorkflow $workflow): Response
{
$result = $workflow->transition($request->user(), $id, $edition, $request->all(), true);
return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial da carteira registada.');
}
public function demography(Request $request, string $id, PortalEditorialWorkflow $workflow): Response
{
$workflow->authorizeDemography($request->user(), $id);
$batch = DB::table('demographic_imports')->where('id', $id)->first();
abort_unless($batch !== null, 404);
$selected = $request->validate(['edition' => 'nullable|uuid'])['edition'] ?? null;
$latest = $workflow->latestDemography($id);
$edition = $selected ? DB::table('portal_editorial_editions')->where('demographic_import_id', $id)->where('id', $selected)->first() : $latest;
abort_if($selected && ! $edition, 404);
$payload = $edition ? $workflow->payload($edition) : null;
$independent = ! $edition || ! $workflow->requiresIndependent($edition) || ! in_array($request->user()->id, $payload['contributors'], true);
return $this->screen($request, 'management/editorial-edition', ['subject' => 'demography',
'resource' => ['id' => $id, 'title' => $batch->file_name, 'published' => $batch->status === 'approved', 'public_edition_no' => $batch->status === 'approved' ? DB::table('portal_editorial_editions')->where('demographic_import_id', $id)->where('state', 'published')->max('revision_no') : null],
'edition' => $edition ? [...get_object_vars($edition), 'payload' => $payload, 'fresh' => $edition->state === 'published' || $workflow->fresh($edition)] : null,
'current' => $edition && $edition->id === $latest?->id, 'independentRequired' => $edition ? $workflow->requiresIndependent($edition) : false, 'canApprove' => $independent, 'manager' => true, 'legalReviewer' => false,
'reviewers' => User::whereIn('email', config('portal.managers', []))->whereNotNull('email_verified_at')->get(['id', 'name']),
'history' => DB::table('audit_events')->where('source_system', 'document-access-v1')->where('action', 'like', 'editorial_%')->where('after_data->demographic_import_id', $id)->orderByDesc('id')->get(['id', 'action', 'actor_user_id', 'after_data', 'occurred_at']),
'editions' => DB::table('portal_editorial_editions')->where('demographic_import_id', $id)->orderByDesc('revision_no')->get(['id', 'revision_no', 'state', 'content_hash', 'author_id', 'created_at'])]);
}
public function demographyDraft(Request $request, string $id, PortalEditorialWorkflow $workflow): Response
{
$data = $request->validate(['expected_revision' => 'required|integer|min:0']);
$edition = $workflow->saveDemography($request->user(), $id, (int) $data['expected_revision']);
return $request->expectsJson() ? response()->json(['edition' => $edition], 201)->header('Cache-Control', 'private, no-store') : redirect('/management/editorial/demography/'.$id);
}
public function demographyTransition(Request $request, string $id, string $edition, PortalEditorialWorkflow $workflow): Response
{
$result = $workflow->transition($request->user(), $id, $edition, $request->all(), false, true);
return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial do lote registada.');
}
public function demographyOriginal(Request $request, string $id, PortalEditorialWorkflow $workflow): Response
{
$workflow->authorizeDemography($request->user(), $id);
$batch = DB::table('demographic_imports')->where('id', $id)->first();
abort_unless($batch !== null, 404);
$path = Storage::disk('local')->path('demography/'.$id.'/original.xlsx');
abort_unless(CheckedFile::matches($path, trim($batch->sha256)), 409, 'Original indisponível ou alterado.');
return response()->download($path, basename($batch->file_name), ['Cache-Control' => 'private, no-store', 'X-Robots-Tag' => 'noindex, nofollow']);
}
public function file(Request $request, int $id, string $edition, string $document, PortalEditorialWorkflow $workflow): Response
{
$workflow->authorize($request->user(), $id);
$e = DB::table('portal_editorial_editions')->where('id', $edition)->where('resource_id', $id)->first();
abort_unless($e !== null, 404);
$file = null;
foreach ($workflow->payload($e)['files'] as $entry) {
if ($entry['id'] === $document) {
$file = $entry;
break;
}
}
abort_unless($file !== null, 404);
abort_unless($workflow->canReadFile($request->user(), $document), 403);
$record = DB::table('documents')->where('id', $document)->first();
abort_unless($record && hash_equals(trim($file['sha256']), trim($record->sha256)) && (int) $file['version_no'] === (int) $record->version_no, 409, 'O anexo mudou; esta edição conserva a referência anterior.');
return $this->binary($record);
}
private function binary(\stdClass $record): Response
{
$response = app(VerifiedDocumentBinary::class)->respond($record);
$response->headers->set('Cache-Control', 'private, no-store');
$response->headers->set('X-Robots-Tag', 'noindex, nofollow');
return $response;
}
public function markRead(Request $request, int $notice, PortalEditorialWorkflow $workflow): Response
{
abort_unless($workflow->enabled(), 503);
$n = DB::table('portal_editorial_notifications')->where('id', $notice)->where('user_id', $request->user()->id)->first();
abort_unless($n !== null, 404);
abort_unless($workflow->visibleNotice($request->user(), $n), 404);
DB::table('portal_editorial_notifications')->where('id', $notice)->where('user_id', $request->user()->id)->whereNull('read_at')->update(['read_at' => now()]);
return $request->expectsJson() ? response()->json(['read' => true]) : back();
}
}
@@ -15,6 +15,7 @@ use App\Services\InterestSubmission;
use App\Services\InvestmentGeography;
use App\Services\LegacyContent;
use App\Services\Malware\MalwareScanner;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess;
use App\Services\PublicDocumentPresentation;
use App\Services\PublicSearchMetrics;
@@ -320,6 +321,9 @@ class PortalServicesController extends Controller
$editorial = $access->editorial($request->user());
$resources = $editorial ? $this->filterManagedResources($access->editorialResources($request->user()), $managementFilters)->orderByDesc('r.updated_at')
->get(['r.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.status as file_status', 'd.data_classification', 'd.version_no as file_version', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_failure_reason']) : collect();
if (app(PortalEditorialWorkflow::class)->enabled()) {
$resources = $resources->map(fn ($r) => app(PortalEditorialWorkflow::class)->workingResource($r));
}
$links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id');
$revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id');
$resources->each(function ($resource) use ($links, $revisions) {
@@ -328,7 +332,7 @@ class PortalServicesController extends Controller
});
return Inertia::render('portal/manage', [
'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'editorialWorkflowEnabled' => app(PortalEditorialWorkflow::class)->enabled(), 'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'documentAccessOnly' => ! $editorial, 'editorialScopeOnly' => $editorial, 'resources' => $resources, 'partnerships' => [], 'territories' => [],
'requirementCatalogue' => [], 'requirementAssociationsEnabled' => false,
'disclosureAuthorization' => null, 'incompleteCount' => $resources->filter(fn ($r) => ! $r->description || ! $r->issuer || ! $r->source_url || $r->source_confirmation === 'unconfirmed' || $r->legal_status === 'unknown' || $r->rights_status === 'unconfirmed')->count(), 'possibleDuplicates' => $resources->whereNotNull('duplicate_of_resource_id')->count(),
@@ -353,12 +357,15 @@ class PortalServicesController extends Controller
$resources->each(fn ($resource) => $resource->links = $links->get($resource->id, collect())->values());
$revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id');
$resources->each(fn ($resource) => $resource->revisions = $revisions->get($resource->id, collect())->values());
if (app(PortalEditorialWorkflow::class)->enabled()) {
$resources = $resources->map(fn ($r) => app(PortalEditorialWorkflow::class)->workingResource($r));
}
$malwareHealth = $scanner->health();
$malwareHealth['signatures_at'] = $malwareHealth['signatures_at']?->format(DATE_ATOM);
return Inertia::render('portal/manage', ['resources' => $resources,
'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'editorialWorkflowEnabled' => app(PortalEditorialWorkflow::class)->enabled(), 'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'requirementCatalogue' => $this->requirementEvidenceEnabled()
? collect(is_array($catalogue = config('document-requirements.catalogue', [])) ? $catalogue : [])->map(fn ($item, $key) => ['key' => $key, ...$item])->values()
: collect(),
@@ -474,9 +481,15 @@ class PortalServicesController extends Controller
$duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->value('id') : null;
$fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, true);
$this->assertPublicClassificationEligible($data, $document);
$workflow = app(PortalEditorialWorkflow::class);
$id = DB::table('portal_resources')->insertGetId([...$fields, 'approved_by' => null, 'published' => false, 'created_at' => now(), 'updated_at' => now()]);
$this->syncResourceLinks($id, $data['associations'] ?? []);
$this->recordResourceRevision($id, 'created', $request->user()->id, 1);
if ($workflow->enabled()) {
$workflow->save($request->user(), $id, 1, ['_document' => ['issuer' => ($data['issuer'] ?? null) ?: $document->issuer, 'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'], 'data_classification' => $data['data_classification'] ?? $document->data_classification]]);
return $id;
}
DB::table('documents')->where('id', $document->id)->update(['issuer' => ($data['issuer'] ?? null) ?: $document->issuer,
'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'],
'data_classification' => $data['data_classification'] ?? $document->data_classification, 'updated_at' => now()]);
@@ -496,7 +509,9 @@ class PortalServicesController extends Controller
$resource = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
abort_unless($resource !== null, 404);
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
abort_if((int) $resource->metadata_version !== $expectedVersion, 409, 'O documento foi alterado por outra sessão. Recarregue a revisão antes de editar.');
$workflow = app(PortalEditorialWorkflow::class);
$current = $workflow->enabled() ? $workflow->workingResource($resource) : $resource;
abort_if((int) $current->metadata_version !== $expectedVersion, 409, 'O documento foi alterado por outra sessão. Recarregue a revisão antes de editar.');
$document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.');
abort_unless(app(DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403);
@@ -507,6 +522,12 @@ class PortalServicesController extends Controller
$duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->where('id', '!=', $id)->value('id') : null;
$fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, false);
$this->assertPublicClassificationEligible($data, $document);
$workflow = app(PortalEditorialWorkflow::class);
if ($workflow->enabled()) {
$workflow->save($request->user(), $id, $expectedVersion, [...$fields, '_document' => ['issuer' => ($data['issuer'] ?? null) ?: $document->issuer, 'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'], 'data_classification' => $data['data_classification'] ?? $document->data_classification]], $data['associations'] ?? []);
return;
}
$retainPublication = (bool) $resource->published && $data['rights_status'] === 'authorized'
&& $data['source_confirmation'] !== 'unconfirmed' && ! empty($data['source_url'])
&& (! ($data['legislation'] || $data['document_type'] === 'legislation') || $data['source_confirmation'] === 'official_confirmed')
@@ -523,7 +544,7 @@ class PortalServicesController extends Controller
$this->audit($request, 'resource_metadata_updated', 'portal_resources', $id);
});
return to_route('services.manage')->with('success', 'Metadados e relações actualizados; o estado de publicação foi preservado.');
return to_route('services.manage')->with('success', 'Edição guardada; confira o circuito editorial quando estiver activo. A versão pública é preservada nesse circuito.');
}
public function moderate(Request $request, string $type, int $id): RedirectResponse
@@ -537,6 +558,12 @@ class PortalServicesController extends Controller
return redirect('/parcerias/'.$id)->with('success', 'Reveja a proposta e registe uma decisão fundamentada na ficha da Bolsa.');
}
$data = $this->validateManagement($request, ['published' => ['required', 'boolean']]);
if ($type === 'resources' && app(PortalEditorialWorkflow::class)->enabled()) {
app(PortalEditorialWorkflow::class)->authorize($request->user(), $id, true);
}
if ($type === 'resources' && $data['published'] && app(PortalEditorialWorkflow::class)->enabled()) {
abort(409, 'Use a aprovação e publicação explícitas da edição no circuito editorial.');
}
$table = $type === 'resources' ? 'portal_resources' : 'portal_partnerships';
DB::transaction(function () use ($request, $data, $table, $type, $id) {
abort_unless(DB::table($table)->where('id', $id)->lockForUpdate()->first() !== null, 404);
@@ -582,6 +609,7 @@ class PortalServicesController extends Controller
public function applyDocumentDisclosureAuthorization(Request $request): RedirectResponse
{
abort_if(app(PortalEditorialWorkflow::class)->enabled(), 409, 'A divulgação de novas edições requer aprovação e publicação no circuito editorial.');
$event = DB::table('audit_events')->where('source_system', 'user-instruction')
->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1')
->where('action', 'public_disclosure_authorized')->first();
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\PrivateOfferOperations;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
final class PrivateOfferOperationsController extends Controller
{
public function show(Request $request, int $id, PrivateOfferOperations $operations): Response
{
return response()->json($operations->presentation($request->user(), $id));
}
public function owner(Request $request, int $id, PrivateOfferOperations $operations): Response
{
$operations->ownerAction($request->user(), $id, $request->all());
return response()->json($operations->presentation($request->user(), $id));
}
public function reportPage(Request $request, int $id, PrivateOfferOperations $operations): Response
{
$record = Opportunity::findOrFail($id);
abort_unless($record->isPrivateOfferPreparation() && $operations->canReport($request->user(), $record), 404);
return Inertia::render('offers/report', ['offerId' => $id])->toResponse($request);
}
public function report(Request $request, int $id, PrivateOfferOperations $operations): Response
{
return response()->json($operations->report($request->user(), $id, $request->all()), 201);
}
public function handleReport(Request $request, int $id, string $reportId, PrivateOfferOperations $operations): Response
{
$operations->handleReport($request->user(), $id, $reportId, $request->all());
return response()->json($operations->presentation($request->user(), $id));
}
public function highlight(Request $request, int $id, PrivateOfferOperations $operations): Response
{
$operations->highlight($request->user(), $id, $request->all());
return response()->json($operations->presentation($request->user(), $id));
}
}
@@ -0,0 +1,153 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\OfferAvailability;
use App\Services\OpportunityCanonicalContract;
use App\Services\OpportunityManagement;
use App\Services\OpportunityProfileWorkflow;
use App\Services\OpportunityResources;
use App\Services\PortalProfileAccess;
use App\Services\PrivateOfferConfidentiality;
use App\Services\PrivateOfferOperations;
use App\Services\PrivateOfferPreparation;
use App\Services\VerifiedDocumentBinary;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
final class PrivateOfferPreparationController extends Controller
{
public function resources(Request $request, PrivateOfferPreparation $service, int $id): JsonResponse
{
$record = $service->record($request->user(), $id);
$resources = [];
foreach (['images', 'documents'] as $kind) {
$resources[$kind] = array_map(fn ($row) => ['id' => $row->id, 'version' => $row->version, 'deleted' => $row->deleted_at !== null,
'caption' => $row->payload['caption'] ?? null, 'digitally_edited' => $row->payload['digitally_edited'] ?? null,
'edit_description' => $row->payload['edit_description'] ?? null,
'scan' => $row->file->malware_scan_status ?? 'pending'], app(OpportunityResources::class)->listing($id, $kind));
}
return response()->json(['resources' => $resources, 'etag' => app(OpportunityManagement::class)->etag($record),
'internalProofAccess' => app(PrivateOfferConfidentiality::class)->internal($request->user(), $record)]);
}
public function attachment(Request $request, PrivateOfferPreparation $service, int $id, string $kind, string $resourceId): \Symfony\Component\HttpFoundation\Response
{
$record = $service->record($request->user(), $id);
abort_unless(in_array($kind, ['documents', 'images'], true), 404);
$link = DB::table('opportunity_'.$kind)->where('opportunity_id', $id)->where('id', $resourceId)->whereNull('deleted_at')->first();
abort_unless($link !== null, 404);
$document = DB::table('documents')->where('id', $link->document_id)->whereNull('deleted_at')->first();
abort_unless($document !== null, 404);
return app(VerifiedDocumentBinary::class)->respond($document);
}
public function confidentiality(Request $request, PrivateOfferConfidentiality $privacy, int $id): JsonResponse|Response
{
$overview = $privacy->overview($request->user(), $id);
return $request->expectsJson() ? response()->json($overview) : Inertia::render('offers/confidentiality', ['offerId' => $id]);
}
public function confidentialityAction(Request $request, PrivateOfferConfidentiality $privacy, int $id): JsonResponse
{
return response()->json($privacy->act($request->user(), $id, $request->all()));
}
public function confidentialRead(Request $request, PrivateOfferConfidentiality $privacy, int $id): JsonResponse
{
try {
$result = $privacy->read($request->user(), $id);
} catch (HttpExceptionInterface $error) {
app(PortalProfileAccess::class)->audit($request->user(), 'offer_confidential_denied', ['record_id' => $id, 'http_status' => $error->getStatusCode()]);
throw $error;
}
abort_if(isset($result['denied']), 403, 'É necessária aceitação do NDA e autorização expressa do vendedor para esta versão.');
return response()->json($result);
}
public function nda(Request $request, PrivateOfferConfidentiality $privacy, int $id): \Symfony\Component\HttpFoundation\Response
{
$overview = $privacy->overview($request->user(), $id);
abort_unless($overview['nda'], 409, 'O NDA aprovado ainda não foi disponibilizado.');
app(PortalProfileAccess::class)->audit($request->user(), 'offer_nda_access', ['record_id' => $id, 'version_hash' => $overview['version_hash'], 'nda' => $overview['nda']]);
$document = DB::table('documents')->where('id', $overview['nda']['document_id'])->firstOrFail();
abort_unless((int) $document->version_no === $overview['nda']['version'] && hash_equals(trim($document->sha256), $overview['nda']['sha256']), 409, 'O exemplar do NDA mudou; actualize antes de continuar.');
return app(VerifiedDocumentBinary::class)->respond($document);
}
public function index(Request $request, PortalProfileAccess $access): Response
{
$filters = $request->validate(['state' => 'nullable|in:draft,submitted,in_review,needs_information,rejected,approved', 'operational' => 'nullable|boolean', 'pending_reports' => 'nullable|boolean']);
$query = ($filters['operational'] ?? false) ? app(PrivateOfferOperations::class)->staffQuery($request->user()) : $access->scope(Opportunity::query(), $request->user());
if ($filters['pending_reports'] ?? false) {
$query = app(PrivateOfferOperations::class)->staffQuery($request->user());
$query->whereRaw("jsonb_path_exists(COALESCE(workflow_context->'offer_reports','{}'::jsonb), '$.* ? (@.status == \"open\" || @.status == \"in_review\")')");
}
$records = $query->whereNotNull('workflow_context->private_offer_preparation')
->when($filters['state'] ?? null, fn ($q, $state) => $q->where('workflow_context->private_offer_preparation->state', $state))
->orderByDesc('id')->paginate(20)->withQueryString();
return Inertia::render('offers/preparation-index', ['records' => $records->through(fn ($r) => [
'id' => $r->id, 'title' => app(OpportunityManagement::class)->values($r)['title'] ?? null, 'reference' => $r->reference,
'state' => $access->state($r),
'availability' => app(OfferAvailability::class)->record($r)['state'],
]), 'filters' => $filters, 'canCreate' => count($access->navigation($request->user())['contexts']) > 0]);
}
public function edit(Request $request, PrivateOfferPreparation $service, ?int $id = null): Response|JsonResponse
{
$record = $id ? $service->record($request->user(), $id) : null;
$contexts = app(PortalProfileAccess::class)->navigation($request->user())['contexts'];
abort_unless($record || count($contexts) > 0, 403, 'É necessária uma atribuição de promotor no âmbito da oferta.');
$presentation = $record ? $service->presentation($request->user(), $record) : null;
if ($request->expectsJson() && ! $request->header('X-Inertia')) {
return response()->json(['record' => $presentation]);
}
$options = app(OpportunityCanonicalContract::class)->options($request->user(), $record);
return Inertia::render('offers/preparation', ['record' => $presentation, 'contexts' => $contexts,
'contacts' => $options['contacts'], 'contactInvestors' => $options['contactInvestors'],
'sectors' => DB::table('sectors')->where('is_active', true)->orderBy('name')->get(['id', 'name']),
'propertyTypes' => array_values(array_filter($options['definitions']['realEstatePatch']['properties']['property_type']['enum'], fn ($type) => is_string($type)))]);
}
public function save(Request $request, PrivateOfferPreparation $service, ?int $id = null): JsonResponse
{
if ($id) {
$service->record($request->user(), $id, true);
}
$record = $service->save($request->user(), $request->all(), $id);
return response()->json(['record' => $service->presentation($request->user(), $record)], $id ? 200 : 201);
}
public function submit(Request $request, PrivateOfferPreparation $service, int $id): JsonResponse
{
$record = $service->submit($request->user(), $id, $request->all());
return response()->json(['record' => $service->presentation($request->user(), $record),
'message' => 'Submissão privada registada. A oferta não foi publicada.']);
}
public function transition(Request $request, PrivateOfferPreparation $service, OpportunityProfileWorkflow $workflow, int $id): JsonResponse
{
$service->record($request->user(), $id);
$data = $request->validate(['etag' => 'required|string|size:64', 'action' => 'required|in:start_review,assign_review,request_correction,reject,approve,request_revision',
'assignee_user_id' => 'required_if:action,assign_review|integer|min:1',
'reason' => 'required|string|min:20|max:1000', 'correction_items' => 'sometimes|array', 'correction_items.*' => 'string']);
$record = $workflow->transition($request->user(), $id, $data);
return response()->json(['record' => $service->presentation($request->user(), $record), 'message' => 'Decisão privada registada. A oferta não foi publicada.']);
}
}
@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Support\PublicSitemapXml;
use Generator;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PublicSitemapController extends Controller
{
private const PAGE_SIZE = 1000;
public function index(PublicSitemapXml $xml): StreamedResponse
{
$base = $this->base();
$pages = (int) ceil(Opportunity::published()->count() / self::PAGE_SIZE);
$locations = (static function () use ($base, $pages): Generator {
yield $base.'/sitemap/pages.xml';
for ($page = 1; $page <= $pages; $page++) {
yield $base.'/sitemap/opportunities/'.$page.'.xml';
}
})();
return $this->response($xml, $locations, true);
}
public function pages(PublicSitemapXml $xml): StreamedResponse
{
$base = $this->base();
$paths = ['/', '/oportunidades', '/sectores', '/como-investir', '/documentos', '/apoio', '/simulador-fiscal', '/politica-de-privacidade', '/termos-de-uso'];
return $this->response($xml, array_map(fn ($path) => $base.$path, $paths));
}
public function opportunities(string $page, PublicSitemapXml $xml): StreamedResponse
{
$number = filter_var($page, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 1000000]]);
abort_if($number === false, 404);
$base = $this->base();
$records = Opportunity::published()->orderBy('id')->offset(($number - 1) * self::PAGE_SIZE)->limit(self::PAGE_SIZE)->get(['slug']);
abort_if($records->isEmpty(), 404);
return $this->response($xml, $records->map(fn ($record) => $base.'/oportunidades/'.rawurlencode($record->slug)));
}
private function base(): string
{
$url = rtrim((string) config('app.url'), '/');
$parts = parse_url($url);
abort_unless(filter_var($url, FILTER_VALIDATE_URL) && is_array($parts)
&& in_array($parts['scheme'] ?? null, ['http', 'https'], true)
&& ! isset($parts['query']) && ! isset($parts['fragment']) && ! isset($parts['user']) && ! isset($parts['pass']), 503);
return $url;
}
/** @param iterable<string> $locations */
private function response(PublicSitemapXml $xml, iterable $locations, bool $index = false): StreamedResponse
{
return response()->stream(static function () use ($xml, $locations, $index): void {
foreach ($xml->chunks($locations, $index) as $chunk) {
echo $chunk;
}
}, 200, ['Content-Type' => 'application/xml; charset=UTF-8', 'Cache-Control' => 'no-store', 'X-Content-Type-Options' => 'nosniff']);
}
}
@@ -5,11 +5,32 @@ namespace App\Http\Controllers;
use App\Services\TerritorialRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class TerritorialRegistryController extends Controller
{
public function editor(Request $request, TerritorialRegistry $registry): Response
{
abort_unless($registry->editorialAvailable(), 404);
$actor = $request->user();
$roles = array_values(array_filter(['editors', 'reviewers', 'publishers'], fn ($role) => in_array(strtolower($actor->email), config('territorial.'.$role, []), true)));
$context = $request->validate(['territory' => 'nullable|uuid', 'notice_version' => 'nullable|integer|min:1']);
return Inertia::render('management/territorial-editorial', ['roles' => $roles, 'actorId' => $actor->id, 'initialTerritory' => $context['territory'] ?? null, 'noticeVersion' => $context['notice_version'] ?? null])->toResponse($request);
}
public function preview(Request $request, TerritorialRegistry $registry, string $id): JsonResponse
{
abort_unless($registry->editorialAvailable(), 404);
return response()->json($registry->preview($id));
}
public function sources(Request $request): JsonResponse
{
$data = $request->validate(['limit' => ['nullable', 'integer', 'between:1,100'], 'cursor' => ['nullable', 'string', 'max:512']]);
@@ -17,13 +38,25 @@ class TerritorialRegistryController extends Controller
return response()->json(DB::table('geo_sources')->orderBy('id')->cursorPaginate($data['limit'] ?? 20)->withQueryString());
}
public function drafts(Request $request): JsonResponse
public function drafts(Request $request, TerritorialRegistry $registry): JsonResponse
{
$data = $request->validate(['limit' => ['nullable', 'integer', 'between:1,100'], 'cursor' => ['nullable', 'string', 'max:512']]);
return response()->json(DB::table('geo_locations as g')->join('geo_territory_profiles as p', 'p.geo_location_id', '=', 'g.id')->whereNull('g.deleted_at')
$page = DB::table('geo_locations as g')->join('geo_territory_profiles as p', 'p.geo_location_id', '=', 'g.id')->whereNull('g.deleted_at')
->select(['g.id', 'g.name', 'g.code', 'g.location_type', 'g.parent_id', 'g.version_no', 'p.source_id', 'p.status', 'p.valid_from', 'p.valid_to', 'p.editor_id', 'p.reviewer_id', 'p.publisher_id'])
->orderBy('g.id')->cursorPaginate($data['limit'] ?? 20)->withQueryString());
->orderBy('g.id')->cursorPaginate($data['limit'] ?? 20)->withQueryString();
if ($registry->editorialAvailable()) {
abort_unless($page instanceof CursorPaginator, 503, 'Paginação territorial indisponível.');
$page->through(function ($row) use ($registry) {
$state = $registry->working($row->id);
return [...$state['location'], ...$state['profile'], 'id' => $row->id,
'public_version_no' => $state['public_version_no'], 'fresh' => $state['fresh'],
'private_revision' => $state['_editorial']['private'] ?? false];
});
}
return response()->json($page);
}
public function index(Request $request, TerritorialRegistry $registry, ?string $collection = null): JsonResponse
@@ -74,7 +107,8 @@ class TerritorialRegistryController extends Controller
{
$id = $registry->save($request->user(), $request->all(), $id);
return response()->json(['id' => $id, 'status' => 'draft', 'version_no' => DB::table('geo_locations')->where('id', $id)->value('version_no')]);
return response()->json(['id' => $id, 'status' => 'draft', 'version_no' => $registry->editorialAvailable()
? $registry->working($id)['location']['version_no'] : DB::table('geo_locations')->where('id', $id)->value('version_no')]);
}
public function source(Request $request): JsonResponse
@@ -95,9 +129,15 @@ class TerritorialRegistryController extends Controller
public function transition(Request $request, TerritorialRegistry $registry, string $id): JsonResponse
{
$data = $request->validate(['action' => ['required', 'in:pending_review,approved,published,rejected,archived'],
$data = $request->validate(['action' => ['required', 'in:pending_review,in_review,approved,published,rejected,archived,withdrawn'],
'version_no' => ['required', 'integer', 'min:1'], 'reason' => ['required', 'string', 'min:10', 'max:1000']]);
$registry->ensureWriteMode($id);
if ($registry->editorialAvailable() && $registry->hasEditorialHistory($id)) {
return response()->json($registry->transitionEditorial($request->user(), $id, $data));
}
abort_if(in_array($data['action'], ['in_review', 'withdrawn'], true), 422);
return DB::transaction(function () use ($request, $registry, $id, $data) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
$location = DB::table('geo_locations')->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first();
+18 -1
View File
@@ -78,6 +78,23 @@ class VirtualDeskController extends Controller
}
public function showCase(Request $request, string $id, VirtualDesk $desk): JsonResponse|Response
{
$data = $this->caseViewData($request, $id, $desk);
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/case', $data);
}
public function preDossier(Request $request, string $id, VirtualDesk $desk): \Illuminate\Http\Response
{
return response()->view('desk.pre-dossier', $this->caseViewData($request, $id, $desk), 200, [
'Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff',
]);
}
/** Same authorised projection for the workspace and its preparation document.
* @return array<string,mixed>
*/
private function caseViewData(Request $request, string $id, VirtualDesk $desk): array
{
$case = $desk->case($request->user(), $id);
$owner = $desk->owns($request->user(), $case, false);
@@ -98,7 +115,7 @@ class VirtualDeskController extends Controller
'links' => DB::table('desk_institutional_links')->get()->map(fn ($link) => [...(array) $link, 'monitor' => app(InstitutionalLinkMonitor::class)->status($link)]), 'source' => config('virtual-desk.source'), 'support' => config('portal.support'),
'guidance' => app(InvestorGuidance::class)->installed() ? app(InvestorGuidance::class)->view($request->user(), $id) : null];
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/case', $data);
return $data;
}
/** @return Collection<int,\stdClass> */
@@ -57,6 +57,8 @@ class HandleInertiaRequests extends Middleware
return [
...parent::share($request),
'name' => config('app.name'),
'administrationNavigation' => app(ContentManagement::class)->administrationNavigation($request->user(), $contentManagementAccess),
'dashboardNavigation' => app(ContentManagement::class)->dashboardNavigation($request->user(), $contentManagementAccess),
'procurementEnabled' => app(Procurement::class)->available(),
'fundingEnabled' => app(FundingProgrammes::class)->installed(),
'homologationReserved' => (bool) config('portal.homologation_only', false),
@@ -23,6 +23,14 @@ class ProtectProfileOpportunity
}
if ($request->user() && $id !== null && Schema::hasColumn('opportunities', 'owner_user_id')) {
$record = Opportunity::withTrashed()->find($id);
if ($record?->isPrivateOfferPreparation()) {
app(PortalProfileAccess::class)->authorize($request->user(), $request->isMethod('GET') ? 'view' : 'edit', $record);
if ($request->isMethod('GET') && $request->is('minhas-oportunidades/*/edit', 'minhas-oportunidades/*/view', 'management/opportunities/*/edit', 'management/opportunities/*/view')) {
return redirect('/minhas-oportunidades/ofertas/'.$record->id)->header('Cache-Control', 'private, no-store');
}
// Contact registration reuses the CRM master and its fresh-MFA gate.
abort_unless($request->isMethod('POST') && $request->is('minhas-oportunidades/*/contacts'), 403, 'Use a preparação privada da oferta.');
}
if ($record && $record->owner_user_id === null && ! $request->isMethod('GET')) {
abort_unless(app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()), 403, 'O papel v1 não contorna os controlos através de um registo legado.');
}
+10 -1
View File
@@ -2,6 +2,8 @@
namespace App\Http\Middleware;
use App\Services\PortalProfileAccess;
use App\Services\TerritorialRegistry;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
@@ -24,9 +26,16 @@ class TerritorialRequest
} else {
abort_unless($request->user()->can('manage-portal') && $request->user()->two_factor_confirmed_at && $request->user()->two_factor_secret, 403);
$role = $request->is('*/transitions') ? match ($request->input('action')) {
'approved', 'rejected' => 'reviewers', 'published', 'archived', 'revoked' => 'publishers', default => 'editors',
'in_review', 'approved', 'rejected' => 'reviewers', 'published', 'archived', 'withdrawn', 'revoked' => 'publishers', default => 'editors',
} : 'editors';
abort_unless(in_array(strtolower($request->user()->email), config('territorial.'.$role, []), true), 403, 'Função editorial não atribuída.');
if ($request->is('api/v1/geo/territories*')) {
app(TerritorialRegistry::class)->ensureWriteMode($request->route('id'));
if (config('editorial_workflow.territorial_versions')) {
abort_unless(app(PortalProfileAccess::class)->mfa($request->user())
&& ! app(PortalProfileAccess::class)->has($request->user(), 'auditor'), 403, 'MFA válido necessário.');
}
}
$key = $request->header('Idempotency-Key', '');
abort_unless(Str::isUuid($key), 422, 'Idempotency-Key UUID obrigatório.');
$scope = $request->method().':'.$request->path();
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Jobs;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/** Existing email transport and operational allowlist; no confidential payload. */
final class NotifyPrivateOfferDecision implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $timeout = 30;
public function __construct(public string $noticeId) {}
/** @return list<int> */
public function backoff(): array
{
return [60, 300, 900];
}
public function handle(): void
{
if (! config('portal.notifications_enabled')) {
return;
}
$notice = DB::table('portal_editorial_notifications')->where('id', $this->noticeId)->first();
$user = $notice ? User::query()->whereKey($notice->user_id)->first() : null;
$record = $notice ? Opportunity::query()->whereKey($notice->opportunity_id)->first() : null;
if (! $notice || ! $user || ! $record?->isPrivateOfferPreparation()
|| ! in_array($user->email, config('portal.notification_recipients', []), true)
|| ! app(PortalEditorialWorkflow::class)->visibleNotice($user, $notice)) {
return;
}
$body = 'Existe uma actualização na revisão privada da oferta '.$record->reference.'. Consulte a decisão e a versão na área protegida: '.url('/minhas-oportunidades/ofertas/'.$record->id);
Mail::raw($body, fn ($message) => $message->to($user->email)->subject('Actualização da oferta — '.$record->reference));
}
}
+18
View File
@@ -30,6 +30,23 @@ class Opportunity extends Model
{
use SoftDeletes;
public function isPrivateOfferPreparation(): bool
{
return isset($this->workflow_context['private_offer_preparation']);
}
protected static function booted(): void
{
static::saving(function (self $record) {
$original = $record->getRawOriginal('workflow_context');
$wasPrivate = is_string($original) && isset(json_decode($original, true)['private_offer_preparation']);
abort_if($wasPrivate && ! $record->isPrivateOfferPreparation(), 403, 'A preparação privada não pode ser convertida neste percurso.');
if ($record->isPrivateOfferPreparation()) {
abort_unless($record->status === 'draft' && $record->published_at === null, 403, 'A publicação de Ofertas não está disponível nesta fase.');
}
});
}
protected $hidden = ['management_draft', 'management_creation_key', 'management_version', 'first_submitted_at', 'management_closed_once', 'owner_user_id', 'organization_id', 'workflow_context'];
protected $appends = ['maturity_catalogue_version'];
@@ -115,6 +132,7 @@ class Opportunity extends Model
public function scopePublished(Builder $query): Builder
{
if (Schema::hasColumn('opportunities', 'workflow_context')) {
$query->whereNull('workflow_context->private_offer_preparation');
$query->where(fn ($q) => $q->whereNull('owner_user_id')->orWhere('workflow_context->publication->visibility', 'public'));
}
+2
View File
@@ -81,6 +81,8 @@ class AppServiceProvider extends ServiceProvider
Cache::forget('portal_home_v2_en');
Cache::forget('portal_home_sector_organization_v1_pt');
Cache::forget('portal_home_sector_organization_v1_en');
Cache::forget('portal_home_territorial_v2_pt');
Cache::forget('portal_home_territorial_v2_en');
};
$model::saved($invalidate);
$model::deleted($invalidate);
+116 -5
View File
@@ -3,10 +3,13 @@
namespace App\Services;
use App\Models\EconomicCluster;
use App\Models\User;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
final class ClusterCompetitiveness
@@ -46,6 +49,12 @@ final class ClusterCompetitiveness
public function management(string $cluster): array
{
$state = $this->current($cluster);
$state['editorial_enabled'] = $this->editorialEnabled();
if (isset($state['workflow'])) {
$record = EconomicCluster::findOrFail($cluster);
$state['workflow']['fresh'] = hash_equals($state['workflow']['hash'], $this->editionHash($record, $state['draft']));
$state['workflow']['independent_required'] = $this->independentRequired($state['workflow']);
}
$state['history'] = $this->events($cluster)->orderByRaw("(after_data->>'version')::integer DESC")->limit(50)->get()
->map(fn ($event) => ['version' => json_decode($event->after_data, true)['version'],
'action' => $event->action, 'at' => $event->occurred_at, 'actor' => $event->actor_user_id])->all();
@@ -106,12 +115,17 @@ final class ClusterCompetitiveness
*/
public function change(EconomicCluster $cluster, int $actor, int $expected, string $action, array $input = []): array
{
abort_if(! in_array($action, ['saved', 'restored', 'approved', 'withdrawn'], true) && ! $this->editorialEnabled(), 503, 'Circuito editorial dos clusters ainda não activado.');
return DB::transaction(function () use ($cluster, $actor, $expected, $action, $input) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
$cluster = EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
$before = $this->current($cluster->id);
abort_if($before['version'] !== $expected, 409, 'A análise foi alterada. Conserve os campos actuais e compare a versão do servidor antes de voltar a guardar.');
$state = $before;
if ($action === 'saved') {
if ($this->editorialEnabled() || isset($before['workflow'])) {
abort_unless($this->editorialEnabled(), 503, 'Reactive o circuito editorial para continuar esta análise; o histórico e a publicação foram conservados.');
$state = $this->editorialChange($cluster, $actor, $action, $input, $before);
} elseif ($action === 'saved') {
$state['draft'] = $this->validateDraft($input['draft'], $cluster->id);
} elseif ($action === 'restored') {
$event = $this->events($cluster->id)->where('after_data->version', $input['revision'])->first();
@@ -127,13 +141,18 @@ final class ClusterCompetitiveness
throw new \LogicException('Unsupported competitiveness action');
}
$state['version']++;
DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'economic_clusters',
$eventId = (string) Str::uuid();
DB::table('audit_events')->insert(['id' => $eventId, 'source_system' => self::SOURCE, 'entity_type' => 'economic_clusters',
'entity_id' => $cluster->id, 'external_id' => $cluster->id.':'.$state['version'], 'actor_user_id' => $actor,
'action' => $action, 'data_classification' => 'restricted',
'before_data' => json_encode(['version' => $before['version']], JSON_THROW_ON_ERROR),
'after_data' => json_encode(['version' => $state['version'], 'restored_from' => $input['revision'] ?? null,
'after_data' => json_encode(['version' => $state['version'], 'editorial_state' => $state['workflow']['state'] ?? null, 'restored_from' => $input['revision'] ?? null,
'reason' => Crypt::encryptString($input['reason'] ?? ''),
'snapshot' => Crypt::encryptString(json_encode($state, JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR)]);
if ($this->editorialEnabled()) {
$user = User::findOrFail($actor);
app(PortalEditorialNativeNotices::class)->emit($user, $eventId, $state['workflow']['contributors']);
}
return $this->management($cluster->id);
});
@@ -144,9 +163,101 @@ final class ClusterCompetitiveness
*/
public function publicData(string $cluster): ?array
{
$approved = $this->current($cluster)['approved'];
$state = $this->current($cluster);
$approved = isset($state['workflow']) ? $state['published'] : $state['approved'];
// Explicit projection: never return editor identity, approval evidence, history or the unapproved draft.
return $approved ? ['draft' => $approved['draft'], 'version' => $approved['version'], 'at' => $approved['at']] : null;
}
public function editorialEnabled(): bool
{
return (bool) config('editorial_workflow.cluster_competitiveness');
}
/** @param array<array-key,mixed>|null $draft */
private function editionHash(EconomicCluster $cluster, ?array $draft): string
{
$context = Arr::only($cluster->getAttributes(), ['id', 'code', 'name', 'sector', 'status', 'primary_territory_id', 'territory_version']);
return hash('sha256', json_encode(Arr::sortRecursive(['context' => $context, 'draft' => $draft]), JSON_THROW_ON_ERROR));
}
/** @param array<array-key,mixed> $input
* @param array<array-key,mixed> $state
* @return array<array-key,mixed>
*/
private function editorialChange(EconomicCluster $cluster, int $actorId, string $action, array $input, array $state): array
{
$actor = User::whereKey($actorId)->firstOrFail();
abort_unless($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403);
abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.');
if (! isset($state['workflow'])) {
abort_unless(in_array($action, ['saved', 'restored', 'withdrawn'], true), 409, 'Guarde uma edição privada antes de iniciar o novo circuito.');
// An accepted legacy publication remains public; no retrospective approval is invented.
$state['published'] = $state['approved'];
$state['approved'] = null;
$state['workflow'] = ['state' => 'draft', 'edition' => $state['version'], 'contributors' => [], 'approval' => null,
'hash' => $this->editionHash($cluster, $state['draft']), 'independent_required' => false];
}
$workflow = $state['workflow'];
if (in_array($action, ['saved', 'restored'], true)) {
if ($action === 'saved') {
$state['draft'] = $this->validateDraft($input['draft'], $cluster->id);
$restoredContributors = [];
} else {
$event = $this->events($cluster->id)->where('after_data->version', $input['revision'])->first();
abort_unless($event !== null, 404);
$restored = $this->decode($event);
$state['draft'] = $restored['draft'];
$restoredContributors = $restored['workflow']['contributors'] ?? [$event->actor_user_id];
}
$state['approved'] = null;
$state['workflow'] = ['state' => 'draft', 'edition' => $state['version'] + 1,
'contributors' => array_values(array_unique([...($workflow['state'] === 'published' ? [] : $workflow['contributors']), ...$restoredContributors, $actorId])),
'hash' => $this->editionHash($cluster, $state['draft']), 'approval' => null,
'independent_required' => $this->independentRequired($workflow)];
return $state;
}
validator($input, ['reason' => 'required|string|min:10|max:3000'])->validate();
$allowed = ['submitted' => ['draft', 'returned'], 'review_started' => ['submitted'], 'returned' => ['in_review'], 'rejected' => ['in_review'],
'approved' => ['in_review'], 'published' => ['approved'], 'archived' => ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved', 'published'], 'withdrawn' => ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved', 'published', 'archived']];
abort_unless(isset($allowed[$action]) && in_array($workflow['state'], $allowed[$action], true), 409, 'Transição incompatível com o estado da edição.');
if (in_array($action, ['submitted', 'approved', 'published'], true)) {
abort_unless(hash_equals($workflow['hash'], $this->editionHash($cluster, $state['draft'])), 409, 'O contexto do cluster mudou. Guarde nova edição e volte a conferir.');
}
if ($action === 'submitted' || $action === 'approved') {
$this->assertComplete($state['draft']);
}
if ($action === 'approved') {
abort_if($this->independentRequired($workflow) && in_array($actorId, $workflow['contributors'], true), 403, 'Aprovação independente necessária para esta edição.');
$workflow['approval'] = ['actor' => $actorId, 'edition' => $workflow['edition'], 'hash' => $workflow['hash'], 'at' => now()->toISOString(), 'reason' => $input['reason']];
$state['approved'] = ['draft' => $state['draft'], 'at' => now()->toISOString(), 'actor' => $actorId, 'version' => $state['version'] + 1, 'evidence' => $input['reason']];
}
if ($action === 'published') {
$decision = $workflow['approval'];
abort_unless($decision && $decision['edition'] === $workflow['edition'] && hash_equals($decision['hash'], $workflow['hash']), 422, 'Aprovação desta edição necessária.');
$reviewer = User::whereKey($decision['actor'])->first();
abort_unless($reviewer && $reviewer->can('manage-portal') && ! app(PortalProfileAccess::class)->has($reviewer, 'auditor'), 403, 'Autoridade da aprovação revogada.');
abort_if($this->independentRequired($workflow) && in_array($reviewer->id, $workflow['contributors'], true), 403, 'Aprovação independente necessária.');
$state['published'] = [...$state['approved'], 'at' => now()->toISOString(), 'version' => $state['version'] + 1];
}
if ($action === 'withdrawn' || ($action === 'archived' && $workflow['state'] === 'published')) {
$state['published'] = null;
}
$workflow['state'] = match ($action) {
'submitted' => 'submitted', 'review_started' => 'in_review', 'returned' => 'returned', 'rejected' => 'rejected',
'approved' => 'approved', 'published' => 'published', 'archived' => 'archived', 'withdrawn' => $workflow['state'] === 'published' ? 'archived' : $workflow['state'],
};
$state['workflow'] = $workflow;
return $state;
}
/** @param array<array-key,mixed> $workflow */
private function independentRequired(array $workflow): bool
{
return ($workflow['independent_required'] ?? false) || in_array('cluster_competitiveness', config('editorial_workflow.independent_approval.kinds', []), true);
}
}
+74
View File
@@ -24,6 +24,15 @@ final class ContentManagement
$editor = $documents->full($actor) || $documents->editorial($actor);
$upload = $editor || $documents->limited($actor);
$result = [];
if (app(PortalEditorialWorkflow::class)->demographyAccess($actor)) {
$result['demography'] = ['index' => '/management/demography'];
}
if (app(PortalEditorialWorkflow::class)->portfolioAvailable() && $actor->can('manage-portal') && ! $profiles->has($actor, 'auditor')) {
$result['portfolio'] = ['index' => '/management/portfolio'];
}
if (app(ClusterCompetitiveness::class)->editorialEnabled() && $actor->can('manage-portal') && ! $profiles->has($actor, 'auditor')) {
$result['clusters'] = ['index' => '/management/clusters'];
}
if (app(Procurement::class)->available() && app(Procurement::class)->access($actor)) {
$result['procurement'] = ['index' => '/management/concursos', 'create' => '/management/concursos/novo'];
}
@@ -35,9 +44,74 @@ final class ContentManagement
$result['documents'] = ['index' => '/management/portal-services', 'edit' => $editor];
}
if (($editor || isset($result['opportunities'])) && ! $profiles->has($actor, 'auditor') && app(PortalEditorialWorkflow::class)->available()) {
$result['editorial'] = ['index' => '/management/editorial'];
}
return $result;
}
/** @param array<array-key,mixed> $access
* @return list<array{label:string,href:string}>
*/
public function administrationNavigation(?User $actor, array $access): array
{
if (! $actor || ! $actor->hasVerifiedEmail()) {
return [];
}
$items = [];
foreach (['documents' => 'Biblioteca e documentos', 'opportunities' => 'Oportunidades', 'clusters' => 'Competitividade', 'portfolio' => 'Carteira', 'demography' => 'Demografia', 'procurement' => 'Concursos e anúncios'] as $key => $label) {
if (isset($access[$key]['index'])) {
$items[] = ['label' => $label, 'href' => $access[$key]['index']];
}
}
if (app(PortalEditorialSubjects::class)->available() && $actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor')) {
$items[] = ['label' => 'Estruturas e relações', 'href' => '/management/editorial/subjects'];
}
$native = app(PortalEditorialNativeNotices::class);
if ($native->access($actor, 'territorial-editorial-v1')) {
$items[] = ['label' => 'Conteúdos territoriais', 'href' => '/management/territorial/editorial'];
}
if ($native->access($actor, 'investment-zones')) {
$items[] = ['label' => 'Zonas e indicadores', 'href' => '/management/investment/zones'];
}
return $items;
}
/** @param array<array-key,mixed> $access
* @return list<array{label:string,href:string}>
*/
public function dashboardNavigation(?User $actor, array $access): array
{
if (! $actor || ! $actor->hasVerifiedEmail() || (! $actor->can('manage-portal') && $access === [] && ! app(ScopedPortalAdministration::class)->administrative($actor))) {
return [];
}
$items = [['label' => 'Visão geral', 'href' => '/dashboard'], ['label' => 'O meu trabalho', 'href' => '/dashboard#meu-trabalho']];
if ($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor')) {
foreach (['manifestacoes' => 'Manifestações de interesse', 'indicadores-oportunidades' => 'Indicadores de oportunidades', 'pedidos-recentes' => 'Pedidos recentes', 'accoes-rapidas' => 'Acções rápidas'] as $anchor => $label) {
$items[] = ['label' => $label, 'href' => '/dashboard#'.$anchor];
}
$items[] = ['label' => 'Indicadores de utilização', 'href' => '/management/product-usage'];
}
if (! $actor->can('manage-portal') && app(InvestorPath::class)->available()) {
$items[] = ['label' => 'Os meus investimentos', 'href' => '/dashboard#meus-investimentos'];
}
$workflow = app(PortalEditorialWorkflow::class);
if ($workflow->available() && $workflow->canEnter($actor)) {
$items[] = ['label' => 'Circuito editorial', 'href' => '/management/editorial'];
$documents = app(DocumentManagementAccess::class);
if ($documents->full($actor) || $documents->editorial($actor)) {
foreach (['article' => 'Revisão de artigos', 'legislation' => 'Revisão de legislação', 'document' => 'Revisão de documentos'] as $kind => $label) {
$items[] = ['label' => $label, 'href' => '/management/editorial?kind='.$kind];
}
}
$items[] = ['label' => 'Notificações editoriais', 'href' => '/management/editorial#notificacoes'];
}
return $items;
}
/**
* @return array<array-key, mixed>
*/
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace App\Services;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class DemographicEvidence
{
public function territories(): Builder
{
$public = app(InvestmentGeography::class)->publicReferences();
return DB::table('geo_locations')->whereNull('deleted_at')->whereIn('location_type', ['province', 'district'])
->where(function ($q) use ($public) {
$q->whereIn('id', (clone $public)->select('id'));
if ($this->installed() && str_ends_with(DB::connection()->getDatabaseName(), '_test')) {
// Macate/Vanduzi: expressly authorised QA references, never active registry changes.
$q->orWhere(fn ($qa) => $qa->where('source_system', 'ine-demography-qa')->where('data_classification', 'public')->whereIn('parent_id', (clone $public)->select('id')));
}
});
}
public function installed(): bool
{
return (bool) config('demography.enabled', app()->environment('local', 'testing'))
&& Schema::hasTable('demographic_imports') && Schema::hasTable('demographic_observations');
}
/** @return array<string,string> */
public static function sectors(): array
{
return ['commerce' => 'Comércio e serviços', 'industry' => 'Indústria', 'agriculture' => 'Agricultura e agro-indústria', 'education' => 'Educação', 'health' => 'Saúde', 'general' => 'Contexto geral'];
}
/** @param list<string> $territories
* @return array<string,mixed> */
public function analyse(array $territories, int $start, int $end, string $sector = 'general', ?string $edition = null): array
{
$result = ['state' => 'unavailable', 'start_year' => $start, 'end_year' => $end, 'sector' => $sector, 'territories' => [], 'differences' => [], 'comparisons' => [],
'limitations' => ['População residente não mede procura efectiva ou poder de compra.', 'Idade activa não demonstra disponibilidade ou qualificação de mão-de-obra.',
'Sem extrapolação: anos sem série oficial permanecem sem dados.', 'A demografia não altera impostos, incentivos ou probabilidades de sucesso.']];
if (! $this->installed() || $territories === []) {
return $result;
}
$locations = $this->territories()->whereIn('id', $territories)->get(['id', 'name', 'location_type']);
$records = DB::table('demographic_observations as o')->join('demographic_imports as b', 'b.id', '=', 'o.import_id')
->where('b.status', 'approved')->whereIn('o.geo_location_id', $locations->pluck('id'))->whereBetween('o.year', [$start, $end])
->where('o.sex', 'all')->when($edition, fn ($q) => $q->where('b.id', $edition))
->orderBy('o.year')->orderBy('o.import_id')->orderBy('o.id')
->get(['o.*', 'b.source', 'b.publication_version', 'b.sha256', 'b.file_name']);
$issuesByImport = DB::table('demographic_imports')->whereIn('id', $records->pluck('import_id')->unique()->all())
->get(['id', DB::raw("report->'issue_counts' AS issue_counts")])->keyBy('id');
foreach ($locations as $location) {
$series = [];
foreach ($records->where('geo_location_id', $location->id)->groupBy('import_id') as $importId => $rows) {
$first = $rows->first();
$issueCounts = json_decode($issuesByImport->get($importId)->issue_counts ?? '{}', true, 512, JSON_THROW_ON_ERROR);
$values = $rows->filter(function ($r) use ($sector) {
if ($r->indicator !== 'population') {
return in_array($sector, ['health', 'general'], true);
}
return $r->age_group === 'all' || in_array($sector, ['education', 'health', 'industry', 'agriculture', 'general'], true);
})->map(fn ($r) => ['year' => $r->year, 'indicator' => $r->indicator, 'age_group' => $r->age_group, 'residence' => $r->residence,
'value' => $r->numeric_value, 'original_value' => $r->original_value, 'unit' => $r->unit, 'data_kind' => $r->data_kind,
'sheet' => $r->sheet, 'cell' => $r->cell, 'validation_status' => $r->validation_status])->values()->all();
$years = array_values(array_unique(array_column($values, 'year')));
$series[] = ['import_id' => $importId, 'source' => $first->source, 'version' => $first->publication_version, 'sha256' => $first->sha256, 'file_name' => $first->file_name,
'values' => $values, 'missing_years' => array_values(array_diff(range($start, $end), $years)), 'issue_counts' => $issueCounts];
}
$result['territories'][] = ['id' => $location->id, 'name' => $location->name, 'level' => $location->location_type, 'series' => $series,
'factors_to_assess' => match ($sector) {
'industry' => ['Estrutura etária e evolução da população; verificar qualificações, mobilidade e emprego.'],
'agriculture' => ['População rural e mercados locais; relacionar com os clusters documentados.'],
'education' => ['Grupos etários da população-alvo; verificar cobertura e capacidade dos serviços.'],
'health' => ['Estrutura etária e estatísticas vitais com unidades conhecidas; verificar cobertura dos serviços.'],
default => ['Dimensão e evolução da população; confrontar com rendimento, acesso e condições sectoriais.'],
}];
}
// Identify concurrent values without summing editions or choosing a supposedly newer one.
foreach ($result['territories'] as $territory) {
$byDimension = [];
foreach ($territory['series'] as $series) {
foreach ($series['values'] as $row) {
$key = implode('|', [$row['year'], $row['indicator'], $row['age_group'], $row['residence'], $row['unit']]);
$byDimension[$key][] = ['import_id' => $series['import_id'], 'value' => $row['value'], 'data_kind' => $row['data_kind']];
}
}
foreach ($byDimension as $key => $values) {
if (count($values) > 1) {
$result['differences'][] = ['territory_id' => $territory['id'], 'dimension' => $key, 'state' => count(array_unique(array_column($values, 'value'))) === 1 ? 'matching_values' : 'divergent_values', 'values' => $values];
}
}
}
$result['state'] = $records->isEmpty() ? 'no_data' : 'available';
foreach ($result['territories'] as $i => $left) {
foreach (array_slice($result['territories'], $i + 1) as $right) {
foreach ($left['series'] as $a) {
foreach ($right['series'] as $b) {
foreach (array_unique([$start, $end]) as $year) {
$isTotal = fn ($r) => $r['year'] === $year && $r['indicator'] === 'population' && $r['age_group'] === 'all' && $r['residence'] === 'all';
$x = array_values(array_filter($a['values'], $isTotal))[0] ?? null;
$y = array_values(array_filter($b['values'], $isTotal))[0] ?? null;
if (! $x || ! $y) {
continue;
}
$comparable = $left['level'] === $right['level'] && $x['unit'] === $y['unit'] && $x['data_kind'] === $y['data_kind'] && $a['version'] === $b['version'];
$delta = $comparable ? DB::selectOne('SELECT (?::numeric - ?::numeric)::text AS value', [$x['value'], $y['value']])->value : null;
$result['comparisons'][] = ['year' => $year, 'left' => $left['name'], 'right' => $right['name'], 'left_value' => $x['value'], 'right_value' => $y['value'],
'left_import' => $a['import_id'], 'right_import' => $b['import_id'], 'difference' => $delta,
'explanation' => $comparable ? 'Diferença dos totais de população no mesmo ano, nível, tipo de dado, unidade e identificação de edição. Não mede procura efectiva.' : 'Âmbitos ou metadados distintos; não se calculou uma diferença como se fossem equivalentes.'];
}
}
}
}
}
return $result;
}
/** @param array<string,mixed> $context
* @return array<string,mixed> */
public function forContext(array $context): array
{
$fields = $context['fields'] ?? [];
$id = ($fields['district_id'] ?? null) ?: ($fields['province_id'] ?? null);
// No inference from an influence area to a project's implantation territory.
return $this->analyse($id ? [$id] : [], (int) date('Y'), (int) date('Y'), 'general');
}
}
+283
View File
@@ -0,0 +1,283 @@
<?php
namespace App\Services;
use App\Models\User;
use App\Services\Malware\MalwareScanner;
use App\Support\CheckedFile;
use App\Support\IsolatedQaDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
final class DemographicImport
{
/**
* CLI preparation is read-only input, never an import or approval.
*
* @return array{preview:string,records:string,receipt:array<string,mixed>}|null
*/
public function preparedSource(string $sha, ?int $baselineYear, bool $verifyRecords = true): ?array
{
$root = config('demography.prepared_directory');
if (! is_string($root) || $root === '') {
return null;
}
abort_unless(preg_match('/^[a-f0-9]{64}$/', $sha) === 1, 422);
$directory = $root.'/'.$sha;
if (! is_file($directory.'/preparation.json')) {
return null;
}
abort_unless(is_readable($directory.'/preparation.json'), 409, 'Recibo CLI indisponível.');
try {
$receipt = json_decode(CheckedFile::read($directory.'/preparation.json'), true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
abort(409, 'Recibo CLI inválido.');
}
abort_unless(is_array($receipt), 409, 'Recibo CLI inválido.');
$preview = $directory.'/preview.json';
$records = $directory.'/records.ndjson';
abort_unless(($receipt['sha256'] ?? '') === $sha && array_key_exists('baseline_year', $receipt) && $receipt['baseline_year'] === $baselineYear
&& ($receipt['parser_sha256'] ?? '') === CheckedFile::sha256(base_path('tools/demography_workbook.py'))
&& is_file($preview) && CheckedFile::matches($preview, $receipt['preview_sha256'] ?? '') && is_file($records), 409, 'A preparação CLI mudou; conservar o original e preparar novamente fora do servidor web.');
try {
$report = json_decode(CheckedFile::read($preview), true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
abort(409, 'Prévia CLI inválida.');
}
abort_unless(is_array($report), 409, 'Prévia CLI inválida.');
abort_unless(($report['sha256'] ?? '') === $sha && ($report['record_count'] ?? null) === ($receipt['record_count'] ?? null)
&& ($report['records_file_sha256'] ?? '') === ($receipt['records_sha256'] ?? '')
&& (! $verifyRecords || CheckedFile::matches($records, $receipt['records_sha256'] ?? '')), 409, 'Resultados da preparação CLI alterados.');
return ['preview' => $preview, 'records' => $records, 'receipt' => $receipt];
}
/** @param array<string,array<string,mixed>> $files */
public function preparedSourcesReady(array $files): bool
{
try {
foreach ($files as $name => $expected) {
$source = $this->preparedSource($expected['sha256'], $name === 'Dados demograficos.xlsx' ? 2017 : null, false);
if ($source === null || $source['receipt']['record_count'] !== $expected['records']) {
return false;
}
}
return $files !== [];
} catch (\Throwable) {
return false;
}
}
public function authorize(User $actor): void
{
$access = app(PortalProfileAccess::class);
abort_unless(($actor->can('manage-portal') || $access->superuser($actor)) && ! $access->has($actor, 'auditor'), 403);
abort_unless($actor->two_factor_confirmed_at && $actor->two_factor_secret, 403, 'Confirme MFA para gerir dados demográficos.');
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
abort_unless(app(DemographicInstallation::class)->activeTarget() && app(DemographicEvidence::class)->installed(), 403, 'Destino demográfico não instalado ou não autorizado.');
abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Conclua MFA nesta sessão para gerir dados demográficos.');
}
}
/** @return array<string,mixed> */
public function prepare(User $actor, string $file, string $name, string $version, ?int $baselineYear): array
{
$this->authorize($actor);
abort_unless(is_file($file) && filesize($file) <= 20_000_000 && strtolower(pathinfo($name, PATHINFO_EXTENSION)) === 'xlsx', 422);
$sha = CheckedFile::sha256($file);
$existing = DB::table('demographic_imports')->where('sha256', $sha)->first();
if ($existing) {
return $this->preview($actor, $existing->id);
}
$id = (string) Str::uuid();
$directory = 'demography/'.$id;
$disk = Storage::disk('local');
$disk->makeDirectory($directory);
$path = $disk->path($directory.'/original.xlsx');
abort_unless(copy($file, $path) && CheckedFile::matches($path, $sha), 409);
$scan = app(MalwareScanner::class)->scan($path, (int) filesize($path), $disk->path($directory));
abort_unless($scan['result'] === 'clean', 423, 'Análise antimalware pendente ou não limpa; original privado conservado.');
$output = $disk->path($directory.'/preview.json');
$prepared = $this->preparedSource($sha, $baselineYear);
if ($prepared !== null) {
abort_unless(copy($prepared['preview'], $output) && CheckedFile::matches($output, $prepared['receipt']['preview_sha256'])
&& copy($prepared['records'], $disk->path($directory.'/records.ndjson'))
&& CheckedFile::matches($disk->path($directory.'/records.ndjson'), $prepared['receipt']['records_sha256']), 409, 'Preparação CLI alterada durante a cópia.');
} else {
abort_unless(function_exists('proc_open'), 503, 'Este servidor web não executa o preparador. Prepare este original pelo importador CLI e confira o respectivo recibo antes de continuar.');
$command = [config('demography.python', 'python'), base_path('tools/demography_workbook.py'), $path, '--output', $output, '--records-output', $disk->path($directory.'/records.ndjson')];
if ($baselineYear !== null) {
array_push($command, '--baseline-year', (string) $baselineYear);
}
$process = new Process($command, base_path(), timeout: 120);
$process->run();
abort_unless($process->isSuccessful(), 422, 'Não foi possível reconhecer o Excel; confira a estrutura e o ano de referência.');
}
$report = json_decode(CheckedFile::read($output), true, 512, JSON_THROW_ON_ERROR);
abort_unless(hash_equals($sha, $report['sha256']) && CheckedFile::matches($path, $sha), 409);
$summary = array_diff_key($report, ['records' => true]);
$summary['scan'] = ['result' => $scan['result'], 'engine' => $scan['engine'], 'engine_version' => $scan['engine_version'], 'sha256' => $sha];
// The supplied publication label is provenance metadata, never an invented edition/date.
DB::table('demographic_imports')->insert(['id' => $id, 'sha256' => $sha, 'file_name' => basename($name),
'source' => $report['source'], 'publication_version' => $version, 'status' => 'prepared',
'report' => json_encode($summary, JSON_THROW_ON_ERROR), 'preview_sha256' => CheckedFile::sha256($output),
'prepared_by' => $actor->id, 'created_at' => now(), 'updated_at' => now()]);
$this->event($id, $actor, 'prepared', 'Pré-visualização do original; sem aprovação de utilização.', ['sha256' => $sha]);
return $this->preview($actor, $id);
}
public static function key(string $province, string $territory, string $level): string
{
return $province.'|'.$territory.'|'.$level;
}
/** @return array<string,mixed> */
public function preview(User $actor, string $id): array
{
$this->authorize($actor);
$batch = DB::table('demographic_imports')->where('id', $id)->first();
abort_unless($batch !== null, 404);
$report = json_decode($batch->report, true, 512, JSON_THROW_ON_ERROR);
$locations = DB::table('geo_locations')->whereIn('location_type', ['province', 'district'])->whereNull('deleted_at')->get(['id', 'name', 'parent_id', 'location_type']);
$matches = [];
foreach ($report['territories'] as [$province, $territory, $level]) {
$provinces = $locations->filter(fn ($g) => $g->location_type === 'province' && $this->normalize($g->name) === $this->normalize($province));
$candidates = $level === 'province' ? $provinces : $locations->filter(fn ($g) => $g->location_type === 'district' && $provinces->contains('id', $g->parent_id) && $this->normalize($g->name) === $this->normalize($territory));
$key = self::key($province, $territory, $level);
if ($level === 'district') {
$headers = $report['territory_headers'][$key] ?? [];
if ($headers !== [] && $candidates->count() > 1 && array_all($headers, fn ($h) => preg_match('/^Distrito de /i', $h) === 1)) {
$candidates = $candidates->filter(fn ($g) => ! preg_match('/^CIDADE /i', $g->name));
}
if ($headers !== [] && $candidates->count() > 1 && array_all($headers, fn ($h) => preg_match('/^Cidade (de|da) /i', $h) === 1)) {
$candidates = $candidates->filter(fn ($g) => preg_match('/^CIDADE /i', $g->name) === 1);
}
$manifest = DemographicInstallation::remainingManifest();
$hashes = array_column($manifest['files'], 'sha256');
$alias = in_array($batch->sha256, $hashes, true) ? ($manifest['aliases'][$key] ?? null) : null;
if ($alias !== null) {
$candidates = $locations->filter(fn ($g) => $g->location_type === 'district' && $provinces->contains('id', $g->parent_id) && $this->literal($g->name) === $this->literal($alias));
}
}
$matches[] = ['key' => self::key($province, $territory, $level), 'province' => $province, 'territory' => $territory, 'level' => $level,
'geo_location_id' => $candidates->count() === 1 ? $candidates->first()->id : null, 'state' => $candidates->count() === 1 ? 'matched' : ($candidates->isEmpty() ? 'missing' : 'ambiguous')];
}
return ['id' => $id, 'file_name' => $batch->file_name, 'sha256' => $batch->sha256, 'publication_version' => $batch->publication_version,
'status' => $batch->status, 'report' => $report, 'matches' => $matches,
'territory_options' => $locations->values()->all(), 'events' => DB::table('demographic_events')->where('import_id', $id)->orderBy('id')->get()];
}
private function normalize(string $name): string
{
return preg_replace('/^(cidade|distrito) (de|da) /', '', $this->literal($name));
}
private function literal(string $name): string
{
$name = str_replace(['`', '’', '‘', '´'], "'", Str::ascii($name));
return preg_replace('/\s+/', ' ', trim(strtolower(str_replace(['-', '–'], ' ', $name))));
}
/** @param array<string,string> $mapping
* @return array<string,mixed> */
public function confirm(User $actor, string $id, string $sha, array $mapping, string $reason): array
{
$this->authorize($actor);
return DB::transaction(function () use ($actor, $id, $sha, $mapping, $reason) {
$batch = DB::table('demographic_imports')->where('id', $id)->lockForUpdate()->first();
abort_unless($batch && hash_equals($batch->sha256, $sha), 409);
if ($batch->status !== 'prepared') {
return ['id' => $id, 'status' => $batch->status, 'records' => DB::table('demographic_observations')->where('import_id', $id)->count(), 'repeated' => true];
}
$disk = Storage::disk('local');
$file = $disk->path('demography/'.$id.'/preview.json');
abort_unless(is_file($file) && CheckedFile::matches($file, $batch->preview_sha256) && CheckedFile::matches($disk->path('demography/'.$id.'/original.xlsx'), $sha), 409, 'Prévia ou original alterados.');
$report = json_decode(CheckedFile::read($file), true, 512, JSON_THROW_ON_ERROR);
$blocking = array_intersect(array_keys($report['issue_counts']), ['conflicting_value', 'duplicate_value', 'unsupported_sheet', 'unsupported_population_headers', 'reference_year_required', 'title_year_mismatch', 'invalid_numeric_or_formula']);
abort_if($blocking !== [], 422, 'Corrija os erros estruturais antes de confirmar: '.implode(', ', $blocking));
$plan = $this->preview($actor, $id);
$resolved = [];
foreach ($plan['matches'] as $match) {
$locationId = $mapping[$match['key']] ?? $match['geo_location_id'];
$location = DB::table('geo_locations')->where('id', $locationId)->whereNull('deleted_at')->first();
abort_unless($location && $location->location_type === $match['level'], 422, 'Correspondência territorial por resolver.');
if ($match['level'] === 'district') {
$provinceKey = self::key($match['province'], $match['province'], 'province');
$provinceMatch = array_values(array_filter($plan['matches'], fn ($m) => $m['key'] === $provinceKey))[0] ?? null;
abort_unless($location->parent_id === ($mapping[$provinceKey] ?? $provinceMatch['geo_location_id'] ?? null), 422, 'Distrito fora da província indicada.');
}
abort_if(in_array($locationId, $resolved, true), 422, 'Duas designações não podem ser fundidas na mesma localização.');
$resolved[$match['key']] = $locationId;
}
$chunk = [];
$recordCount = 0;
foreach ($this->recordRows($report, $disk->path('demography/'.$id.'/records.ndjson')) as $row) {
$recordCount++;
$chunk[] = ['import_id' => $id, 'geo_location_id' => $resolved[self::key($row['province'], $row['territory'], $row['level'])],
...array_intersect_key($row, array_flip(['year', 'indicator', 'sex', 'residence', 'age_group', 'unit', 'data_kind', 'original_value', 'numeric_value', 'sheet', 'cell'])),
'validation_status' => $report['issues'] === [] ? 'valid' : 'warning'];
if (count($chunk) === 500) {
DB::table('demographic_observations')->insert($chunk);
$chunk = [];
}
}
if ($chunk !== []) {
DB::table('demographic_observations')->insert($chunk);
}
abort_unless($recordCount === $report['record_count'], 409, 'O número de observações difere da prévia.');
DB::table('demographic_imports')->where('id', $id)->update(['status' => 'imported', 'updated_at' => now()]);
$this->event($id, $actor, 'imported', $reason, ['mapping' => $resolved, 'records' => $recordCount, 'sha256' => $sha]);
return ['id' => $id, 'status' => 'imported', 'records' => $recordCount, 'repeated' => false];
});
}
/** @param array<string,mixed> $report
* @return iterable<array<string,mixed>> */
private function recordRows(array $report, string $path): iterable
{
if (! isset($report['records_file_sha256'])) {
yield from $report['records'];
return;
}
abort_unless(is_file($path) && CheckedFile::matches($path, $report['records_file_sha256']), 409, 'As observações diferem da prévia conferida.');
$stream = new \SplFileObject($path, 'r');
while (! $stream->eof()) {
$line = $stream->fgets();
if (trim($line) === '') {
continue;
}
yield json_decode($line, true, 512, JSON_THROW_ON_ERROR);
}
}
public function transition(User $actor, string $id, string $action, string $reason): void
{
$this->authorize($actor);
DB::transaction(function () use ($actor, $id, $action, $reason) {
$batch = DB::table('demographic_imports')->where('id', $id)->lockForUpdate()->first();
abort_unless($batch !== null, 404);
abort_unless(in_array($action, ['approved', 'disabled'], true) && $batch->status !== 'prepared', 422);
abort_if($action === 'approved' && $batch->status !== 'approved' && app(PortalEditorialWorkflow::class)->demographyWrites($id), 409, 'Use a aprovação e publicação separadas do lote no circuito editorial.');
abort_if($action === 'disabled' && Schema::hasColumn('portal_editorial_editions', 'demographic_import_id') && DB::table('portal_editorial_editions')->where('demographic_import_id', $id)->exists(), 409, 'Arquive a edição no circuito editorial para retirar a sua publicação.');
// No deletes, replacement of other editions, or automatic publication in production.
DB::table('demographic_imports')->where('id', $id)->update(['status' => $action, 'updated_at' => now()]);
$this->event($id, $actor, $action, $reason, ['previous_status' => $batch->status]);
});
}
/** @param array<string,mixed> $details */
private function event(string $id, User $actor, string $action, string $reason, array $details): void
{
DB::table('demographic_events')->insert(['import_id' => $id, 'actor_id' => $actor->id, 'action' => $action, 'reason' => $reason, 'details' => json_encode($details, JSON_THROW_ON_ERROR), 'created_at' => now()]);
}
}
+307
View File
@@ -0,0 +1,307 @@
<?php
namespace App\Services;
use App\Models\User;
use App\Support\CheckedFile;
use App\Support\IsolatedQaDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Symfony\Component\Process\Exception\ExceptionInterface;
use Symfony\Component\Process\Process;
/** Owner-authorised release; schema and data still require existing session MFA. */
final class DemographicInstallation
{
public const MIGRATION = '2026_11_13_100000_create_demographic_series';
public const MANICA_SHA256 = '0c6d8feaefaed797fa57d61f923af5a86f37ac4b16f12d7d72ae1be8087ea698';
public const FILES = [
'Manica.xlsx' => ['sha256' => self::MANICA_SHA256, 'records' => 71604],
'Inhambane.xlsx' => ['sha256' => '51a9b91dfb6e32e5b49def115cb007f21faa97b3d0584b16ef911b22b89bd818', 'records' => 82620],
'Dados demograficos.xlsx' => ['sha256' => '4763aaa7476f3ae24564e9750ac2ac8babb07dfbba67473a180fb9b1f47eb335', 'records' => 482],
];
/** @return array<string,mixed> */
public static function remainingManifest(): array
{
return json_decode(CheckedFile::read(database_path('data/demography-remaining-20261113.json')), true, 512, JSON_THROW_ON_ERROR);
}
/** @return array<string,mixed> */
public static function zambeziaManifest(): array
{
return json_decode(CheckedFile::read(database_path('data/demography-zambezia-20261113.json')), true, 512, JSON_THROW_ON_ERROR);
}
/** @return list<string> */
public static function publicDistrictRefs(): array
{
$refs = [self::MANICA_SHA256.':Macate', self::MANICA_SHA256.':Vanduzi'];
foreach ([self::remainingManifest(), self::zambeziaManifest()] as $manifest) {
foreach ($manifest['new_districts'] as $province => $names) {
$source = array_values(array_filter($manifest['files'], fn ($f) => $f['province'] === $province))[0]['sha256'];
foreach ($names as $name) { $refs[] = $source.':'.$name; }
}
}
return $refs;
}
/** @return list<string> */
public function files(): array
{
return ['database/data/demography-zambezia-20261113.json', 'database/data/demography-remaining-20261113.json', 'app/Services/DemographicInstallation.php', 'app/Services/DemographicImport.php', 'app/Services/DemographicEvidence.php',
'app/Services/InvestmentGeography.php', 'app/Services/InvestmentPreparationWorkspace.php', 'app/Http/Controllers/DemographyController.php',
'database/migrations/'.self::MIGRATION.'.php', 'config/demography.php', 'routes/demography.php', 'routes/web.php', 'tools/demography_workbook.py',
'resources/js/components/demographic-context.tsx', 'resources/js/components/demographic-evidence.tsx', 'resources/js/components/simulation-form.tsx',
'resources/js/components/structure-geography.tsx', 'resources/js/pages/investment/demography.tsx', 'resources/js/pages/investment/catalogue.tsx',
'resources/js/pages/investment/detail.tsx', 'resources/js/pages/management/demography.tsx', 'resources/js/pages/management/demography-install.tsx',
'resources/js/pages/management/shared.tsx'];
}
public function fingerprint(): string
{
$files = [];
foreach ($this->files() as $path) {
$files[$path] = CheckedFile::sha256(base_path($path));
}
return hash('sha256', json_encode($files, JSON_THROW_ON_ERROR));
}
public function activeTarget(): bool
{
$c = config('database.connections.pgsql');
$homologation = config('app.url') === 'https://teste-investidor.tempo.co.mz'
&& config('demography.enabled', false) && $c['database'] === 'teste_investidor';
if (app()->environment('testing') || config('database.default') !== 'pgsql' || ! empty($c['url'])
|| ! in_array($c['host'], ['127.0.0.1', 'localhost'], true) || (int) $c['port'] !== 5432
|| ($c['database'] !== 'investemoz' && ! $homologation)) {
return false;
}
$target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
return $target->database === $c['database'] && (int) $target->port === 5432;
}
public function schemaAllowed(): bool
{
if (IsolatedQaDatabase::allowsSchemaChanges()) {
return true;
}
$actor = auth()->user();
if (! config('demography.authenticated_installation', false) || ! $actor || ! $this->activeTarget()) {
return false;
}
app(PortalProfileInstallation::class)->authorize($actor, true);
return true;
}
private function authorize(User $actor, bool $write): void
{
app(PortalProfileInstallation::class)->authorize($actor, $write);
abort_unless($this->activeTarget() || IsolatedQaDatabase::allowsSchemaChanges(), 403, 'Destino não autorizado para esta entrega.');
}
/** @return array<string,mixed> */
public function preview(User $actor): array
{
$this->authorize($actor, false);
$proofFile = storage_path('app/private/demography-deployment/readiness.json');
$proof = is_file($proofFile) ? json_decode(CheckedFile::read($proofFile), true, 512, JSON_THROW_ON_ERROR) : [];
$code = $this->fingerprint();
$target = (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$qa = IsolatedQaDatabase::allowsSchemaChanges();
$ready = $qa || (($proof['code_sha256'] ?? '') === $code && ($proof['qa_passed'] ?? false) && ($proof['compatibility_verified'] ?? false)
&& ($proof['restore_verified'] ?? false) && ($proof['files_verified'] ?? false) && ($proof['target']['database'] ?? '') === $target['database']
&& (int) ($proof['target']['port'] ?? 0) === (int) $target['port'] && ($proof['decision'] ?? '') !== ''
&& strtotime($proof['verified_at'] ?? '') >= time() - 86400 && strtotime($proof['verified_at'] ?? '') <= time());
if (! $qa) {
foreach (['dump', 'files'] as $kind) {
$file = $proof[$kind.'_path'] ?? '';
$ready = $ready && is_file($file) && CheckedFile::matches($file, $proof[$kind.'_sha256'] ?? '');
}
$ready = $ready && CheckedFile::matches(base_path('.env'), $proof['environment_sha256'] ?? '')
&& is_file(public_path('build/manifest.json')) && CheckedFile::matches(public_path('build/manifest.json'), $proof['release_manifest_sha256'] ?? '');
foreach ($proof['release_files'] ?? [] as $relative => $sha) {
$file = public_path('build/'.$relative);
$ready = $ready && ! str_contains($relative, '..') && ! str_contains($relative, ':') && is_file($file) && CheckedFile::matches($file, $sha);
}
}
$tables = array_map(fn ($t) => Schema::hasTable($t), ['demographic_imports', 'demographic_observations', 'demographic_events']);
$ledger = DB::table('migrations')->where('migration', self::MIGRATION)->exists();
$consistent = ($ledger && ! in_array(false, $tables, true)) || (! $ledger && ! in_array(true, $tables, true));
$preparedFiles = self::FILES + self::remainingManifest()['files'] + self::zambeziaManifest()['files'];
$runtimeReady = app(DemographicImport::class)->preparedSourcesReady($preparedFiles);
try {
if (! $runtimeReady && function_exists('proc_open')) {
$runtime = new Process([config('demography.python', 'python'), '-c', 'import sys; assert sys.version_info >= (3, 9)'], base_path(), timeout: 15);
$runtime->run();
$runtimeReady = $runtime->isSuccessful();
}
} catch (ExceptionInterface) {
$runtimeReady = false;
}
$mode = $ledger ? 'remaining' : 'initial';
$manifest = self::remainingManifest();
if ($ledger && DB::table('demographic_imports')->whereIn('sha256', array_column($manifest['files'], 'sha256'))->where('status', 'approved')->count() === count($manifest['files'])) { $mode = 'zambezia'; $manifest = self::zambeziaManifest(); }
$files = $mode !== 'initial' ? $manifest['files'] : self::FILES;
$already = $ledger ? DB::table('demographic_imports')->whereIn('sha256', array_column($files, 'sha256'))->where('status', 'approved')->pluck('sha256')->all() : [];
$pending = array_filter($files, fn ($f) => ! in_array($f['sha256'], $already, true));
$state = ['mode' => $mode, 'pending_count' => count($pending), 'aliases' => $mode !== 'initial' ? $manifest['aliases'] : [], 'alias_sources' => $mode !== 'initial' ? $manifest['sources'] : [], 'missing_province_source' => $manifest['missing_province_source'], 'target' => (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'),
'migration' => self::MIGRATION, 'code_sha256' => $code, 'backup_verified' => $ready, 'consistent' => $consistent,
'ready' => $ready && $consistent && $runtimeReady && count($pending) > 0, 'runtime_ready' => $runtimeReady, 'installed' => $ledger, 'files' => $files, 'new_districts' => $mode !== 'initial' ? array_merge(...array_values($manifest['new_districts'])) : ['Macate', 'Vanduzi'],
'mfa_confirmed' => app(PortalProfileAccess::class)->mfa($actor),
'notice' => ($runtimeReady ? '' : 'O interpretador do importador está indisponível; a execução permanece bloqueada. ').'Preservar seis advertências de Manica/2031, unidades ausentes e versões estatísticas. A instalação não corrige os originais nem concede permissões.'];
return [...$state, 'hash' => hash_hmac('sha256', json_encode([$actor->id, session()->getId(), $state], JSON_THROW_ON_ERROR), config('app.key'))];
}
/** @param list<string> $preservedHashes
* @return array<string,array{count:int,sha256:string}> */
private function protectedState(array $preservedHashes = []): array
{
$state = [];
foreach (['desk_cases', 'simulation_form_drafts', 'funding_programmes', 'funding_programme_versions', 'funding_programme_interests',
'labour_demands', 'training_capacities', 'opportunities', 'investment_dossiers', 'projects', 'economic_clusters'] as $table) {
if (Schema::hasTable($table)) {
$rows = DB::select('SELECT row_to_json(t)::text AS row FROM "'.$table.'" t ORDER BY row_to_json(t)::text');
$state[$table] = ['count' => count($rows), 'sha256' => hash('sha256', implode("\n", array_map(fn ($r) => $r->row, $rows)))];
}
}
if (Schema::hasTable('demographic_imports')) {
$ids = DB::table('demographic_imports')->whereIn('sha256', $preservedHashes ?: array_column(self::FILES, 'sha256'))->pluck('id');
foreach (['demographic_imports' => 'id', 'demographic_observations' => 'import_id', 'demographic_events' => 'import_id'] as $table => $key) {
$digest = hash_init('sha256');
$count = 0;
DB::table($table)->whereIn($key, $ids)->chunkById(500, function ($rows) use ($digest, &$count) {
foreach ($rows as $row) {
if ($count > 0) { hash_update($digest, "\n"); }
hash_update($digest, json_encode($row, JSON_THROW_ON_ERROR));
$count++;
}
});
$state['accepted_'.$table] = ['count' => $count, 'sha256' => hash_final($digest)];
}
}
return $state;
}
private function territories(User $actor): void
{
$province = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->whereRaw('lower(name)=?', ['manica'])->get(['id']);
abort_unless($province->count() === 1, 422, 'Província de Manica ambígua ou indisponível.');
foreach (['Macate', 'Vanduzi'] as $name) {
$matches = DB::table('geo_locations')->where('parent_id', $province->first()->id)->where('location_type', 'district')->whereRaw('lower(trim(name))=?', [strtolower($name)])->get();
if ($matches->isNotEmpty()) {
abort_unless($matches->count() === 1 && $matches->first()->deleted_at === null, 409, 'Cadastro territorial divergente; não restaurar nem fundir automaticamente.');
continue;
}
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $province->first()->id, 'name' => $name, 'location_type' => 'district',
'code' => 'INE-DEMOGRAPHY-'.strtoupper($name), 'source_system' => 'ine-demography', 'external_id' => self::MANICA_SHA256.':'.$name,
'data_classification' => 'public', 'version_no' => 1, 'created_at' => now(), 'updated_at' => now()]);
app(PortalProfileAccess::class)->audit($actor, 'demography_territory_created', ['territory_id' => $id, 'name' => $name,
'source_sha256' => self::MANICA_SHA256, 'decision' => 'Autorização expressa do proprietário em 13/11/2026; destino conferido pelo instalador.',
'scope' => 'Identificação territorial da série INE; sem limites geográficos ou direitos territoriais presumidos.']);
}
}
/** @param array<string,mixed> $manifest */
private function remainingTerritories(User $actor, array $manifest): void
{
foreach ($manifest['new_districts'] as $province => $names) {
$parents = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->whereRaw('lower(name)=?', [mb_strtolower($province)])->pluck('id');
abort_unless($parents->count() === 1, 409);
$source = array_values(array_filter($manifest['files'], fn ($f) => $f['province'] === $province))[0]['sha256'];
foreach ($names as $name) {
$existing = DB::table('geo_locations')->where('parent_id', $parents[0])->where('location_type', 'district')->whereRaw('lower(trim(name))=?', [mb_strtolower($name)])->get();
if ($existing->isNotEmpty()) { abort_unless($existing->count() === 1 && $existing[0]->deleted_at === null, 409); continue; }
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parents[0], 'name' => $name, 'location_type' => 'district', 'code' => 'INE-DEMOGRAPHY-'.strtoupper($name), 'source_system' => 'ine-demography', 'external_id' => $source.':'.$name, 'data_classification' => 'public', 'version_no' => 1]);
app(PortalProfileAccess::class)->audit($actor, 'demography_territory_created', ['territory_id' => $id, 'name' => $name, 'source_sha256' => $source, 'basis' => $manifest['authority']]);
}
}
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function apply(User $actor, array $input): array
{
$this->authorize($actor, true);
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'operation_key' => 'required|uuid',
'mode' => 'nullable|in:initial,remaining,zambezia', 'confirm' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000'])->validate();
$mode = $data['mode'] ?? 'initial';
$files = $mode === 'initial' ? self::FILES : ($mode === 'zambezia' ? self::zambeziaManifest()['files'] : self::remainingManifest()['files']);
$preservedHashes = $mode === 'zambezia' ? array_column([...self::FILES, ...self::remainingManifest()['files']], 'sha256') : array_column(self::FILES, 'sha256');
$action = $mode === 'zambezia' ? 'extend_zambezia' : ($mode === 'remaining' ? 'extend_provinces' : 'install_and_import');
$payload = hash('sha256', json_encode($mode === 'initial' ? [$actor->id, $data['reason']] : [$actor->id, $data['reason'], $mode, $files], JSON_THROW_ON_ERROR));
$existing = DB::table('audit_events')->where('source_system', 'demography-installation')->where('action', $action)->where('correlation_id', $data['operation_key'])->first();
if ($existing) {
$event = json_decode($existing->after_data, true, 512, JSON_THROW_ON_ERROR);
abort_unless((int) $existing->actor_user_id === $actor->id && hash_equals($event['payload_sha256'], $payload), 409);
return [...$event['result'], 'repeated' => true];
}
$plan = $this->preview($actor);
abort_unless($plan['ready'] && $plan['mode'] === $mode && hash_equals($plan['hash'], $data['hash']), 409, 'Consulte a prévia actual; código, destino ou provas mudaram.');
set_time_limit(300);
return DB::transaction(function () use ($actor, $data, $payload, $mode, $files, $action, $preservedHashes) {
DB::select('SELECT pg_advisory_xact_lock(131126, 100000)');
// Recheck after the lock as another request may have completed while we waited.
$existing = DB::table('audit_events')->where('source_system', 'demography-installation')->where('action', $action)->where('correlation_id', $data['operation_key'])->first();
if ($existing) {
$event = json_decode($existing->after_data, true, 512, JSON_THROW_ON_ERROR);
abort_unless((int) $existing->actor_user_id === $actor->id && hash_equals($event['payload_sha256'], $payload), 409);
return [...$event['result'], 'repeated' => true];
}
$before = $this->protectedState($preservedHashes);
$previous = config('demography.authenticated_installation');
config(['demography.authenticated_installation' => true]);
try {
if ($mode === 'initial') {
abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]) === 0, 500, 'Falha ao instalar a estrutura demográfica.');
} else { abort_unless(Schema::hasTable('demographic_observations'), 409); }
} finally {
config(['demography.authenticated_installation' => $previous]);
}
if ($mode === 'initial') { $this->territories($actor); } else { $this->remainingTerritories($actor, $mode === 'zambezia' ? self::zambeziaManifest() : self::remainingManifest()); }
$receipts = [];
$directory = config('demography.source_directory', 'C:/Mac/Home/Documents/TEMPO/Portal Tempo/Servicos Tempo/portal do investidor/demografia/Provincias');
$service = app(DemographicImport::class);
foreach ($files as $name => $expected) {
$old = DB::table('demographic_imports')->where('sha256', $expected['sha256'])->first();
if ($old && $old->status === 'approved') {
$count = DB::table('demographic_observations')->where('import_id', $old->id)->count();
abort_unless($count === $expected['records'], 409);
$receipts[] = ['file' => $name, 'sha256' => $expected['sha256'], 'id' => $old->id, 'records' => $count, 'preserved' => true];
continue;
}
$file = $directory.'/'.$name;
abort_unless(is_file($file) && CheckedFile::matches($file, $expected['sha256']), 409, 'O exemplar aprovado mudou: '.$name);
$plan = $service->prepare($actor, $file, $name, 'Exemplar INE fornecido pela Tempo; edição não indicada; SHA256 identifica a versão', $name === 'Dados demograficos.xlsx' ? 2017 : null);
$receipt = $service->confirm($actor, $plan['id'], $expected['sha256'], [], $data['reason']);
abort_unless($receipt['records'] === $expected['records'], 409, 'Contagem divergente da homologação: '.$name);
$service->transition($actor, $plan['id'], 'approved', $data['reason']);
$receipts[] = ['file' => $name, 'sha256' => $expected['sha256'], 'id' => $plan['id'], 'records' => $receipt['records']];
}
abort_unless($before === ($mode === 'initial' ? array_filter($this->protectedState($preservedHashes), fn ($key) => ! str_starts_with($key, 'accepted_'), ARRAY_FILTER_USE_KEY) : $this->protectedState($preservedHashes)), 409, 'Estado protegido alterado; instalação revertida.');
$result = ['installed' => true, 'target' => (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'),
'imports' => $receipts, 'protected_state' => $before, 'record_count' => array_sum(array_column($receipts, 'records')), 'at' => now()->toIso8601String()];
DB::table('audit_events')->insert(['source_system' => 'demography-installation', 'entity_type' => 'demographic_series',
'actor_user_id' => $actor->id, 'action' => $action, 'correlation_id' => $data['operation_key'], 'data_classification' => 'restricted',
'after_data' => json_encode(['payload_sha256' => $payload, 'reason' => $data['reason'], 'result' => $result], JSON_THROW_ON_ERROR)]);
return $result;
});
}
}
+131
View File
@@ -6,10 +6,13 @@ use App\Models\LabourDemand;
use App\Models\TrainingCapacity;
use App\Models\User;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use JsonException;
final class EducationReviews
{
@@ -55,6 +58,134 @@ final class EducationReviews
});
}
/** Private preparation only; never a decision or an authority grant.
* @return array<string,mixed> */
public function preparation(User $actor, LabourDemand|TrainingCapacity $record): array
{
app(EducationAccess::class)->authorize($actor);
$event = $this->preparations($actor, $record)->orderByRaw("CASE WHEN (after_data->'education_preparation'->>'version') ~ '^[0-9]{1,10}$' THEN (after_data->'education_preparation'->>'version')::bigint END DESC NULLS LAST")->orderByDesc('occurred_at')->orderByDesc('id')->first(['after_data']);
$value = $event ? json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['education_preparation'] : null;
$snapshot = null;
$unavailable = false;
if ($value) {
try {
$decoded = json_decode(Crypt::decryptString($value['snapshot']), true, 512, JSON_THROW_ON_ERROR);
$snapshot = Validator::make(['snapshot' => $decoded], $this->preparationRules($decoded))->validate()['snapshot'];
} catch (DecryptException|JsonException|ValidationException) {
$unavailable = true;
}
}
// Empty reference maps remain JSON objects in the browser, like the declaration props.
if (is_array($snapshot)) {
if ($snapshot['base']['references'] === []) {
$snapshot['base']['references'] = (object) [];
}
if (is_array($snapshot['pending']) && $snapshot['pending']['base']['references'] === []) {
$snapshot['pending']['base']['references'] = (object) [];
}
}
return ['actor_id' => $actor->id, 'kind' => $record instanceof LabourDemand ? 'demands' : 'capacities', 'record_id' => $record->id, 'version' => (int) ($value['version'] ?? 0), 'operation_key' => $value['operation_key'] ?? null,
'snapshot' => $snapshot, 'unavailable' => $unavailable];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function savePreparation(User $actor, string $kind, string $id, array $input): array
{
app(EducationAccess::class)->authorize($actor);
$model = match ($kind) {
'demands' => LabourDemand::class, 'capacities' => TrainingCapacity::class, default => abort(404)
};
$data = Validator::make($input, [...$this->preparationRules($input['snapshot'] ?? null),
'actor_id' => 'required|integer|min:1', 'version' => 'required|integer|min:0', 'operation_key' => 'required|uuid'])->validate();
abort_unless((int) $data['actor_id'] === $actor->id, 409, 'A conta mudou. Actualize a entrada antes de guardar a preparação.');
$key = strtolower($data['operation_key']);
$snapshot = $data['snapshot'];
foreach (['values', 'base'] as $field) {
$snapshot[$field]['version_no'] = (int) $snapshot[$field]['version_no'];
}
$snapshot['base']['quantity'] = (int) $snapshot['base']['quantity'];
foreach (['status', 'review_reason'] as $field) {
$snapshot['generation'][$field] = (int) $snapshot['generation'][$field];
}
if ($snapshot['pending'] !== null) {
$snapshot['pending']['values']['version_no'] = (int) $snapshot['pending']['values']['version_no'];
$snapshot['pending']['base']['version_no'] = (int) $snapshot['pending']['base']['version_no'];
$snapshot['pending']['base']['quantity'] = (int) $snapshot['pending']['base']['quantity'];
$snapshot['pending']['values']['client_key'] = strtolower($snapshot['pending']['values']['client_key']);
foreach (['reasonGeneration', 'statusGeneration'] as $field) {
$snapshot['pending'][$field] = (int) $snapshot['pending'][$field];
}
abort_unless($snapshot['pending']['values']['version_no'] === $snapshot['pending']['base']['version_no'], 422);
abort_unless($snapshot['pending']['reasonGeneration'] <= $snapshot['generation']['review_reason'] && $snapshot['pending']['statusGeneration'] <= $snapshot['generation']['status'], 422);
}
abort_unless($snapshot['values']['version_no'] === $snapshot['base']['version_no'], 422);
$hash = hash('sha256', json_encode(Arr::sortRecursive(['actor' => $actor->id, 'kind' => $kind, 'id' => $id,
'version' => (int) $data['version'], 'snapshot' => $snapshot]), JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($actor, $model, $id, $data, $snapshot, $key, $hash): array {
$record = $model::lockForUpdate()->findOrFail($id);
$previous = $this->preparations($actor, $record)->whereRaw("after_data->'education_preparation'->>'operation_key' = ?", [$key])->first(['after_data']);
if ($previous) {
$receipt = json_decode($previous->after_data, true, 512, JSON_THROW_ON_ERROR)['education_preparation'];
abort_unless(hash_equals($receipt['hash'], $hash), 409, 'Esta tentativa de guardar já foi usada com outros dados.');
return $this->preparation($actor, $record);
}
$current = $this->preparation($actor, $record);
abort_unless($current['version'] === (int) $data['version'], 409, 'A preparação mudou noutra janela. Compare antes de substituir.');
abort_if($current['unavailable'], 409, 'A preparação anterior não pôde ser recuperada e foi preservada.');
abort_if($snapshot['base']['version_no'] > (int) $record->getAttribute('version_no'), 422, 'A versão analisada não existe nesta declaração.');
DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'actor_user_id' => $actor->id,
'entity_type' => $record->getTable(), 'entity_id' => $id, 'action' => 'review_preparation_saved', 'data_classification' => 'restricted',
'after_data' => json_encode(['education_preparation' => ['version' => $current['version'] + 1, 'operation_key' => $key, 'hash' => $hash,
'snapshot' => Crypt::encryptString(json_encode($snapshot, JSON_THROW_ON_ERROR))]], JSON_THROW_ON_ERROR)]);
return $this->preparation($actor, $record);
});
}
private function preparations(User $actor, LabourDemand|TrainingCapacity $record): Builder
{
return DB::table('audit_events')->where('source_system', self::SOURCE)->where('actor_user_id', $actor->id)
->where('entity_type', $record->getTable())->where('entity_id', $record->id)->where('action', 'review_preparation_saved');
}
/** @return array<string,string> */
private function preparationRules(mixed $snapshot): array
{
$rules = ['snapshot' => 'required|array:values,generation,base,pending', 'snapshot.values' => 'required|array:version_no,status,review_reason',
'snapshot.generation' => 'required|array:status,review_reason', 'snapshot.pending' => 'present|nullable|array:values,reasonGeneration,statusGeneration,base'];
$roots = ['snapshot'];
if (is_array($snapshot) && is_array($snapshot['pending'] ?? null)) {
$roots[] = 'snapshot.pending';
$rules['snapshot.pending.values'] = 'required|array:version_no,status,review_reason,client_key';
$rules['snapshot.pending.values.client_key'] = 'required|uuid';
foreach (['reasonGeneration', 'statusGeneration'] as $field) {
$rules['snapshot.pending.'.$field] = 'required|integer|min:0|max:1000000000';
}
}
foreach (['status', 'review_reason'] as $field) {
$rules['snapshot.generation.'.$field] = 'required|integer|min:0|max:1000000000';
}
foreach ($roots as $root) {
$rules[$root.'.values.version_no'] = 'required|integer|min:1';
$rules[$root.'.values.status'] = 'required|in:reviewed,rejected';
$rules[$root.'.values.review_reason'] = 'present|string|max:3000';
$rules[$root.'.base'] = 'required|array:version_no,qualification,quantity,status,period_start,period_end,valid_until,source,reference_date,employer,references';
$rules[$root.'.base.version_no'] = 'required|integer|min:1';
$rules[$root.'.base.quantity'] = 'required|integer|min:0';
foreach (['qualification' => 250, 'status' => 40, 'period_start' => 40, 'period_end' => 40, 'valid_until' => 40, 'source' => 5000, 'reference_date' => 40, 'employer' => 1000] as $field => $max) {
$rules[$root.'.base.'.$field] = 'present|string|max:'.$max;
}
$rules[$root.'.base.references'] = 'present|array:occupation,skill,project,course,location';
$rules[$root.'.base.references.*'] = 'nullable|string|max:1000';
}
return $rules;
}
/** @return array{actor_id:int,commands:list<array{key:string,version:int}>} */
public function context(User $actor, LabourDemand|TrainingCapacity $record): array
{
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Services;
use Illuminate\Database\QueryException;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** Combines independent, already-authorised sources without loading complete catalogues. */
final class FederatedSearch
{
/** @param array<string, callable(): array{count:int, fetch:callable(int,int): iterable<array<string,mixed>>}> $factories
* @return array{data:list<array<string,mixed>>,total:int,current_page:int,last_page:int,per_page:int,unavailable:list<string>}
*/
public function page(array $factories, int $requestedPage, int $size = 5, ?callable $score = null): array
{
$sources = [];
$unavailable = [];
foreach ($factories as $label => $factory) {
try {
$sources[$label] = $factory();
} catch (QueryException|HttpException $exception) {
if ($exception instanceof HttpException && ! in_array($exception->getStatusCode(), [404, 503], true)) {
throw $exception;
}
report($exception);
$unavailable[] = $label;
}
}
// If a source fails after its count, remove it and recompute the page offsets.
do {
$failed = false;
$total = array_sum(array_column($sources, 'count'));
$last = max(1, (int) ceil($total / $size));
$page = min(max(1, $requestedPage), $last);
$first = ($page - 1) * $size;
$items = [];
$positions = array_fill_keys(array_keys($sources), 0);
$buffers = array_fill_keys(array_keys($sources), []);
for ($position = 0; $position < min($total, $first + $size); $position++) {
$best = null;
$bestScore = PHP_INT_MIN;
foreach ($sources as $label => $source) {
if ($buffers[$label] === [] && $positions[$label] < $source['count']) {
try {
$batch = iterator_to_array((function () use ($source, $positions, $label, $size) {
yield from ($source['fetch'])($positions[$label], $size);
})(), false);
$positions[$label] += count($batch);
$buffers[$label] = $batch;
if ($batch === []) {
$positions[$label] = $source['count'];
}
} catch (QueryException|HttpException $exception) {
if ($exception instanceof HttpException && ! in_array($exception->getStatusCode(), [404, 503], true)) {
throw $exception;
}
report($exception);
$unavailable[] = $label;
unset($sources[$label]);
$failed = true;
break;
}
}
if ($buffers[$label] === []) {
continue;
}
$candidateScore = $score ? $score($buffers[$label][0]) : 0;
if ($best === null || $candidateScore > $bestScore) {
$best = $label;
$bestScore = $candidateScore;
}
}
if ($failed || $best === null) {
break;
}
$item = array_shift($buffers[$best]);
if ($position >= $first) {
$items[] = [...$item, 'category' => $best];
}
}
} while ($failed);
return ['data' => $items, 'total' => $total, 'current_page' => $page, 'last_page' => $last,
'per_page' => $size, 'unavailable' => $unavailable];
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
/** A selected public funding reference in the existing encrypted investment dossier. */
final class FundingDossierPreparation
{
/** @return array<string,mixed> */
public function context(User $actor, string $id, ?string $opportunity): array
{
$case = app(VirtualDesk::class)->case($actor, $id);
abort_unless(app(VirtualDesk::class)->owns($actor, $case) || $actor->can('manage-portal'), 403);
$state = app(InvestorContext::class)->state($case);
$scope = app(InvestmentConsumerScope::class);
$opportunity = $scope->key($state, $opportunity, true);
$specific = $scope->context($state, $opportunity);
$selections = app(VirtualDesk::class)->decode($case->payload)['investor_path']['funding_selections'] ?? [];
return ['id' => $id, 'version' => (int) $case->version, 'closed' => $case->closed_at !== null, 'opportunity' => $opportunity,
'territory' => $specific['selection']['territory']['id'] ?? null,
'selections' => array_values(array_filter($selections, fn ($row) => ($row['opportunity'] ?? null) === $opportunity)),
'href' => '/investimentos/'.$id.($opportunity ? '?opportunity='.urlencode($opportunity) : '').'#financiamento'];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function save(User $actor, string $programme, array $input): array
{
$data = Validator::make($input, ['context' => 'required|uuid', 'opportunity' => 'nullable|string|max:150', 'version' => 'required|integer|min:1',
'version_id' => 'required|uuid', 'product' => 'nullable|string|max:80', 'need' => ['required', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))],
'territory' => 'nullable|uuid', 'location_id' => 'nullable|string|max:100', 'location_mode' => 'nullable|in:physical,coverage', 'operation_key' => 'required|uuid', 'consent' => 'accepted'])->validate();
$intent = hash('sha256', json_encode(Arr::sortRecursive([...$data, 'actor' => $actor->id, 'programme' => $programme]), JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($actor, $programme, $data, $intent): array {
$desk = app(VirtualDesk::class);
$case = $desk->case($actor, $data['context'], true, true);
abort_if($case->closed_at, 409, 'O dossier está encerrado.');
$context = $this->context($actor, $case->id, $data['opportunity'] ?? null);
$payload = $desk->decode($case->payload);
$selections = $payload['investor_path']['funding_selections'] ?? [];
foreach ($selections as $selection) {
if ($selection['id'] === $data['operation_key']) {
abort_unless(hash_equals($selection['intent'], $intent), 409, 'A intenção já tem outro conteúdo.');
return $context;
}
}
foreach ($selections as $selection) {
if (($selection['opportunity'] ?? null) === $context['opportunity'] && $selection['programme_id'] === $programme && $selection['version_id'] === $data['version_id'] && ($selection['product']['code'] ?? null) === ($data['product'] ?? null) && $selection['need'] === $data['need'] && ($selection['territory'] ?? null) === ($data['territory'] ?? null) && ($selection['location']['id'] ?? null) === ($data['location_id'] ?? null)) {
return $context;
}
}
abort_unless((int) $case->version === (int) $data['version'], 409, 'O dossier mudou. Consulte-o antes de guardar esta escolha.');
$catalogue = app(FundingProgrammes::class);
$record = $catalogue->published($programme);
abort_unless($record['version_id'] === $data['version_id'], 409, 'A ficha pública mudou. Confira a versão actual.');
if (! empty($data['territory'])) {
abort_unless(app(InvestmentGeography::class)->publicReferences()->where('id', $data['territory'])->exists(), 422);
}
$location = null;
if (! empty($data['location_id'])) {
foreach ($record['payload']['locations'] ?? [] as $candidate) {
if (($candidate['id'] ?? null) === $data['location_id']) {
$location = $candidate;
}
}
abort_unless($location, 422, 'O local seleccionado não pertence à versão consultada da instituição.');
}
$product = null;
if ($record['payload']['kind'] === 'institution') {
foreach ($record['payload']['products'] as $candidate) {
if (($candidate['code'] ?? null) === ($data['product'] ?? null)) {
$product = $candidate;
break;
}
}
abort_unless(($record['payload']['products'] === [] && empty($data['product']) && app(FundingInstitutionClassification::class)->permitsUnknownProduct($record['payload'], $data['need'])) || ($product && app(FundingInstitutionDetails::class)->match($product, $data)), 422, 'O serviço ou produto escolhido não corresponde à informação da ficha para esta necessidade/local. Confira as condições ou seleccione outra referência.');
}
abort_if(count($selections) >= 200, 422, 'O dossier atingiu o limite de referências de financiamento.');
$selection = ['id' => $data['operation_key'], 'intent' => $intent, 'actor' => $actor->id, 'opportunity' => $context['opportunity'],
'programme_id' => $programme, 'version_id' => $record['version_id'], 'version' => $record['version'], 'title' => $record['payload']['title'],
'record' => $record['payload'], 'product' => $product, 'need' => $data['need'], 'territory' => $data['territory'] ?? null, 'location' => $location, 'location_mode' => $data['location_mode'] ?? 'coverage', 'categories' => $record['payload']['institution_categories'] ?? [], 'institution_types' => $record['payload']['institution_types'] ?? [], 'institution_key' => $record['payload']['institution_key'] ?? null, 'recorded_at' => now()->toIso8601String(),
'notice' => 'Referência para preparação. Não é candidatura, contacto enviado, aprovação ou garantia de financiamento.'];
$payload['investor_path']['funding_selections'][] = $selection;
DB::table('desk_cases')->where('id', $case->id)->update(['payload' => $desk->encode($payload), 'version' => (int) $case->version + 1, 'updated_at' => now()]);
$desk->event($case, $actor, 'funding_preparation_saved', ['selection_id' => $selection['id'], 'programme_id' => $programme, 'version_id' => $record['version_id'], 'opportunity' => $context['opportunity']]);
return $this->context($actor, $case->id, $context['opportunity']);
});
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Services;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
/** Documentary classification and physical presence; neither is lending eligibility. */
final class FundingInstitutionClassification
{
public const CATEGORIES = ['banking' => 'Banca', 'credit' => 'Crédito e microfinanças', 'investment' => 'Gestão de investimento', 'pensions' => 'Fundos de pensões', 'insurance' => 'Seguros e resseguros', 'intermediation' => 'Mediação de seguros', 'exchange' => 'Câmbio', 'payments' => 'Pagamentos e serviços interbancários', 'supervision' => 'Supervisão', 'other' => 'Outras instituições'];
public const TYPES = ['commercial_bank' => 'Banco comercial', 'investment_bank' => 'Banco de investimento', 'bank' => 'Banco (tipo não especificado)', 'microbank' => 'Microbanco', 'cooperative' => 'Cooperativa de crédito', 'savings' => 'Organização de poupança e empréstimo', 'investment_manager' => 'Gestora de fundos de investimento', 'investment_company' => 'Sociedade de investimento', 'pension_manager' => 'Gestora de fundos de pensões', 'insurer' => 'Seguradora', 'reinsurer' => 'Resseguradora', 'microinsurer' => 'Micro-seguradora', 'broker' => 'Corretora de seguros', 'agent' => 'Agente de seguros', 'reinsurance_broker' => 'Corretor de resseguro', 'exchange_house' => 'Casa de câmbio', 'electronic_money' => 'Instituição de moeda electrónica', 'interbank' => 'Serviços interbancários', 'supervisor' => 'Supervisor', 'other' => 'Outro tipo documentado'];
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function normalize(array $input): array
{
return Validator::make($input, [
'institution_categories' => 'array|max:10', 'institution_categories.*' => ['required', 'distinct', Rule::in(array_keys(self::CATEGORIES))],
'institution_types' => 'array|max:10', 'institution_types.*' => ['required', 'distinct', Rule::in(array_keys(self::TYPES))],
'institution_services' => 'array|max:12', 'institution_services.*' => ['required', 'distinct', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))],
'institution_status' => 'nullable|in:reported,historical,merged,unknown', 'successor_key' => 'nullable|string|max:100',
'register_reference' => 'nullable|string|max:2000', 'aliases' => 'array|max:100', 'aliases.*' => 'required|string|max:500',
'source_rows' => 'array|max:250', 'source_rows.*' => 'required|array:id,section,text', 'source_rows.*.id' => 'required|string|max:100',
'source_rows.*.section' => 'required|string|max:250', 'source_rows.*.text' => 'required|string|max:8000',
])->validate();
}
/** @param array<string,mixed> $filters */
public function filter(Builder $query, array $filters): void
{
foreach (['category' => 'institution_categories', 'institution_type' => 'institution_types'] as $key => $field) {
if (! empty($filters[$key])) {
$query->whereRaw('v.payload->? @> ?::jsonb', [$field, json_encode([$filters[$key]], JSON_THROW_ON_ERROR)]);
}
}
if (empty($filters['include_historical'])) {
$query->whereRaw("COALESCE(v.payload->>'institution_status','reported') NOT IN ('historical','merged')");
}
if (($filters['location_mode'] ?? 'coverage') !== 'physical') {
return;
}
$query->whereRaw("v.payload->>'kind'='institution'");
if (empty($filters['territory'])) {
return;
}
$unknown = ($filters['certainty'] ?? 'include_unknown') === 'include_unknown';
$path = array_column(app(InvestmentGeography::class)->path($filters['territory']), 'id');
$clauses = ["l->'territory_path' @> ?::jsonb"];
$bindings = [json_encode([['id' => $filters['territory']]], JSON_THROW_ON_ERROR)];
if ($unknown) {
foreach ($path as $id) {
$clauses[] = "l->>'territory_id' = ?";
$bindings[] = $id;
}
}
$status = $unknown ? '' : " AND COALESCE(l->>'evidence_status','reported') NOT IN ('to_confirm','historical','conflict','unlocated')";
$query->where(function (Builder $q) use ($clauses, $bindings, $status, $unknown): void {
$q->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'locations','[]'::jsonb)) l WHERE (".implode(' OR ', $clauses).')'.$status.')', $bindings);
if ($unknown) {
$q->orWhereRaw("NOT EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'locations','[]'::jsonb)) l WHERE NULLIF(l->>'territory_id','') IS NOT NULL)");
}
});
}
/** @param array<string,mixed> $payload */
public function permitsUnknownProduct(array $payload, ?string $need): bool
{
return ! $need || ! isset($payload['institution_services']) || in_array($need, $payload['institution_services'], true);
}
}
+202
View File
@@ -0,0 +1,202 @@
<?php
namespace App\Services;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
/** Additional factual fields in the existing versioned funding catalogue. */
final class FundingInstitutionDetails
{
public const NATURES = ['bank' => 'Banco', 'credit' => 'Instituição de crédito', 'microfinance' => 'Microfinanças', 'investment_fund' => 'Fundo de investimento', 'development' => 'Agência de desenvolvimento/financiamento', 'other' => 'Outra natureza documentada', 'unknown' => 'Natureza por confirmar'];
public const NEEDS = ['investment' => 'Investimento/equipamento', 'working_capital' => 'Fundo de maneio', 'equity' => 'Participação de capital', 'guarantee' => 'Garantia', 'technical_assistance' => 'Assistência técnica', 'insurance' => 'Seguros', 'foreign_exchange' => 'Câmbio', 'payments' => 'Pagamentos', 'pension' => 'Pensões', 'investment_management' => 'Gestão de investimento', 'other' => 'Outra necessidade documentada'];
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function normalize(array $input, bool $publishing): array
{
$required = $publishing ? 'required' : 'nullable';
$rules = [
'institution_key' => 'required|string|regex:/^[a-z0-9][a-z0-9.-]{0,99}$/D',
'institution_nature' => ['required', Rule::in(array_keys(self::NATURES))],
'contacts' => 'array|max:100', 'locations' => 'array|max:100', 'products' => 'array|max:40',
'contacts.*' => 'array:id,kind,value,source_url,source_reference,checked_on,source_document_name,source_document_sha256,evidence_status,origin,source_refs',
'contacts.*.kind' => 'required|in:website,email,phone,address', 'contacts.*.value' => "$required|string|max:1000",
'locations.*' => 'array:id,role,label,address,territory_id,source_url,source_reference,checked_on,source_document_name,source_document_sha256,evidence_status,origin,source_refs,location_text,hours',
'locations.*.role' => 'required|in:headquarters,branch,contact_point', 'locations.*.label' => "$required|string|max:250",
'locations.*.address' => 'nullable|string|max:1500', 'locations.*.territory_id' => 'nullable|uuid',
'products.*' => 'array:code,title,instrument,needs,coverage,coverage_text,territory_ids,conditions,amounts,availability,contact_url,source_url,source_reference,checked_on,source_document_name,source_document_sha256',
'products.*.code' => 'required|string|regex:/^[a-z0-9][a-z0-9-]{0,79}$/D|distinct', 'products.*.title' => "$required|string|max:250",
'products.*.instrument' => 'nullable|string|max:250', 'products.*.needs' => 'array|max:6',
'products.*.needs.*' => ['required', 'distinct', Rule::in(array_keys(self::NEEDS))],
'products.*.coverage' => 'required|in:national,territorial,unknown', 'products.*.coverage_text' => 'nullable|string|max:2000',
'products.*.territory_ids' => 'array|max:30', 'products.*.territory_ids.*' => 'required|uuid|distinct',
'products.*.conditions' => 'nullable|string|max:4000', 'products.*.amounts' => 'nullable|string|max:2000',
'products.*.availability' => 'required|in:documented,unknown,closed', 'products.*.contact_url' => 'nullable|url:http,https|max:2000',
];
foreach (['contacts', 'locations', 'products'] as $prefix) {
$rules[$prefix.'.*.source_url'] = 'nullable|url:http,https|max:2000';
$rules[$prefix.'.*.source_document_name'] = 'nullable|string|max:250';
$rules[$prefix.'.*.source_document_sha256'] = 'nullable|string|regex:/^[a-f0-9]{64}$/D';
$rules[$prefix.'.*.source_reference'] = "$required|string|max:1500";
$rules[$prefix.'.*.checked_on'] = "$required|date_format:Y-m-d|before_or_equal:today";
}
foreach (['contacts', 'locations'] as $prefix) {
$rules[$prefix.'.*.id'] = 'nullable|string|max:100';
$rules[$prefix.'.*.evidence_status'] = 'nullable|in:reported,to_confirm,historical,conflict,unlocated';
$rules[$prefix.'.*.origin'] = 'nullable|string|max:500';
$rules[$prefix.'.*.source_refs'] = 'array|max:100';
$rules[$prefix.'.*.source_refs.*'] = 'required|string|max:100';
}
$rules['locations.*.location_text'] = 'nullable|string|max:1000';
$rules['locations.*.hours'] = 'nullable|string|max:1000';
$data = [...Validator::make($input, $rules)->validate(), ...app(FundingInstitutionClassification::class)->normalize($input)];
if ($publishing) {
foreach (['contacts', 'locations', 'products'] as $prefix) {
foreach ($data[$prefix] ?? [] as $item) {
Validator::make($item, ['source_url' => empty($item['source_document_sha256']) ? 'required|url:http,https' : 'nullable|url:http,https', 'source_document_name' => empty($item['source_url']) ? 'required|string' : 'nullable|string'])->validate();
}
}
}
foreach (['contacts', 'locations', 'products'] as $key) {
$data[$key] ??= [];
}
foreach ($data['contacts'] as $contact) {
Validator::make(['value' => $contact['value'] ?? null], ['value' => match ($contact['kind']) {
'website' => "$required|url:http,https|max:1000",
'email' => "$required|email|max:250",
default => "$required|string|max:1000",
}])->validate();
}
foreach ($data['locations'] as &$location) {
if (! empty($location['territory_id'])) {
$this->territory($location['territory_id']);
if ($publishing) {
$location['territory_path'] = $this->path($location['territory_id']);
}
}
}
unset($location);
foreach ($data['products'] as &$product) {
$product['needs'] ??= [];
$product['territory_ids'] ??= [];
Validator::make($product, ['territory_ids' => $product['coverage'] === 'territorial' ? ($publishing ? 'required|array|min:1' : 'array') : 'array|size:0'])->validate();
// Territorial drafts may be incomplete; other coverage modes never inherit the headquarters.
foreach ($product['territory_ids'] as $id) {
$this->territory($id);
}
if ($publishing) {
$product['territory_paths'] = array_map($this->path(...), $product['territory_ids']);
}
}
unset($product);
return $data;
}
private function territory(string $id): void
{
Validator::make(['territory' => $id], ['territory' => [Rule::in(app(InvestmentGeography::class)->publicReferences()->pluck('id')->all())]])->validate();
}
/** @return list<array{id:string,name:string,type:string}> */
private function path(string $id): array
{
return array_map(fn ($node) => ['id' => $node->id, 'name' => $node->name, 'type' => $node->location_type], app(InvestmentGeography::class)->path($id));
}
/** Same product must satisfy both location and need; branch addresses never imply lending coverage.
* @param array<string,mixed> $product
* @param array<string,mixed> $filters */
public function match(array $product, array $filters): bool
{
$unknown = ($filters['certainty'] ?? 'include_unknown') === 'include_unknown';
if ($product['availability'] === 'closed') {
return false;
}
if (! $unknown && $product['availability'] === 'unknown') {
return false;
}
if (! empty($filters['need']) && ! in_array($filters['need'], $product['needs'], true) && ! ($unknown && $product['needs'] === [])) {
return false;
}
if (empty($filters['territory'])) {
return true;
}
if ($product['coverage'] === 'national') {
return true;
}
if ($product['coverage'] === 'unknown') {
return $unknown;
}
$path = array_column(app(InvestmentGeography::class)->path($filters['territory']), 'id');
foreach ($product['territory_paths'] ?? [] as $covered) {
$ids = array_column($covered, 'id');
if (array_intersect($path, $product['territory_ids']) !== [] || in_array($filters['territory'], $ids, true)) {
return true;
}
}
return false;
}
/** @param array<string,mixed> $filters */
public function applyFilters(Builder $query, array $filters): void
{
app(FundingInstitutionClassification::class)->filter($query, $filters);
if (empty($filters['need']) && (empty($filters['territory']) || ($filters['location_mode'] ?? '') === 'physical')) {
return;
}
$unknown = ($filters['certainty'] ?? 'include_unknown') === 'include_unknown';
$conditions = ["p->>'availability' <> 'closed'"];
$bindings = [];
if (! $unknown) {
$conditions[] = "p->>'availability' <> 'unknown'";
}
if (! empty($filters['need'])) {
$conditions[] = "(p->'needs' @> ?::jsonb".($unknown ? " OR jsonb_array_length(p->'needs')=0" : '').')';
$bindings[] = json_encode([$filters['need']], JSON_THROW_ON_ERROR);
}
if (! empty($filters['territory']) && ($filters['location_mode'] ?? '') !== 'physical') {
$path = array_column(app(InvestmentGeography::class)->path($filters['territory']), 'id');
$parts = ["p->>'coverage'='national'", "EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(p->'territory_paths','[]'::jsonb)) t WHERE t @> ?::jsonb)"];
$bindings[] = json_encode([['id' => $filters['territory']]], JSON_THROW_ON_ERROR);
foreach ($path as $id) {
$parts[] = "p->'territory_ids' @> ?::jsonb";
$bindings[] = json_encode([$id], JSON_THROW_ON_ERROR);
}
if ($unknown) {
$parts[] = "p->>'coverage'='unknown'";
}
$conditions[] = '('.implode(' OR ', $parts).')';
}
$sql = "EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'products','[]'::jsonb)) p WHERE ".implode(' AND ', $conditions).')';
$query->where(function (Builder $q) use ($sql, $bindings, $unknown, $filters): void {
$q->where(function (Builder $q) use ($unknown, $filters): void {
$q->whereRaw("v.payload->>'kind' <> 'institution'");
if (! empty($filters['need'])) {
$q->where(function (Builder $q) use ($filters, $unknown): void {
$q->whereRaw("v.payload->'funding_needs' @> ?::jsonb", [json_encode([$filters['need']], JSON_THROW_ON_ERROR)]);
if ($unknown) {
$q->orWhereRaw("jsonb_array_length(COALESCE(v.payload->'funding_needs','[]'::jsonb))=0");
}
});
}
})->orWhere(function (Builder $q) use ($sql, $bindings, $unknown, $filters): void {
$q->whereRaw("v.payload->>'kind' = 'institution'")->where(function (Builder $q) use ($sql, $bindings, $unknown, $filters): void {
$q->whereRaw($sql, $bindings);
if ($unknown) {
$q->orWhere(function (Builder $p) use ($filters): void {
$p->whereRaw("jsonb_array_length(COALESCE(v.payload->'products','[]'::jsonb))=0");
if (! empty($filters['need'])) {
$p->where(fn ($z) => $z->whereRaw("v.payload->'institution_services' @> ?::jsonb", [json_encode([$filters['need']], JSON_THROW_ON_ERROR)])->orWhereRaw("NOT jsonb_exists(v.payload, 'institution_services')"));
}
});
}
});
});
});
}
}
+81 -7
View File
@@ -6,26 +6,100 @@ use App\Models\User;
use App\Support\CheckedFile;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/** Packaged official references prepare private drafts through existing editorial authority. */
/** Packaged documentary references prepare private drafts through existing editorial authority. */
final class FundingProgrammeReferences
{
/** @return array{references:array<string,mixed>,sha256:string} */
private function package(): array
{
$references = [];
$hashes = [];
foreach (['funding-references.json', 'funding-institutions-nov2026.json'] as $file) {
$bytes = CheckedFile::read(database_path('data/'.$file));
$package = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
abort_unless($package['format'] === 'investmoz-funding-references-v1', 503);
$references = [...$references, ...$package['references']];
$hashes[$file] = hash('sha256', $bytes);
}
return ['references' => $references, 'sha256' => hash('sha256', json_encode($hashes, JSON_THROW_ON_ERROR))];
}
/** @return array<string,mixed> */
public function preview(User $actor, string $code): array
{
app(FundingProgrammes::class)->guard($actor);
$bytes = CheckedFile::read(database_path('data/funding-references.json'));
$package = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
abort_unless($package['format'] === 'investmoz-funding-references-v1', 503);
$package = $this->package();
$reference = $package['references'][$code] ?? null;
abort_unless(is_array($reference), 404);
$plan = ['code' => $code, 'edition' => $reference['edition'], 'draft' => $reference['draft'], 'provenance' => $reference['provenance'],
'package_sha256' => hash('sha256', $bytes)];
$unresolved = [];
$resolve = function (string $name) use (&$unresolved): ?string {
$matches = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->get(['id', 'name'])->filter(fn ($row) => Str::lower(Str::ascii($row->name)) === Str::lower(Str::ascii($name)));
if ($matches->count() !== 1) {
$unresolved[] = $name;
return null;
}
return $matches->first()->id;
};
if (! empty($reference['territory_names'])) {
$ids = array_values(array_filter(array_map($resolve, $reference['territory_names'])));
if (count($ids) === count($reference['territory_names'])) {
$reference['draft']['territory_ids'] = $ids;
} else {
$reference['draft']['coverage'] = 'unknown';
$reference['draft']['territory_ids'] = [];
$reference['draft']['coverage_text'] .= ' Correspondência ao cadastro territorial por confirmar.';
}
}
foreach ($reference['location_territory_names'] ?? [] as $index => $name) {
$reference['draft']['locations'][$index]['territory_id'] = $resolve($name);
if (! $reference['draft']['locations'][$index]['territory_id']) {
$reference['draft']['locations'][$index]['evidence_status'] = 'unlocated';
}
}
$plan = ['unresolved_territories' => $unresolved, 'code' => $code, 'edition' => $reference['edition'], 'draft' => $reference['draft'], 'provenance' => $reference['provenance'],
'package_sha256' => $package['sha256']];
$plan['hash'] = hash_hmac('sha256', json_encode(Arr::sortRecursive($plan), JSON_THROW_ON_ERROR), config('app.key'));
return $plan;
}
/** @return list<array{code:string,title:string}> */
public function choices(User $actor): array
{
app(FundingProgrammes::class)->guard($actor);
$package = $this->package();
$choices = [];
foreach ($package['references'] as $code => $reference) {
$choices[] = ['code' => $code, 'title' => $reference['draft']['title']];
}
return $choices;
}
/** @return array<string,mixed> */
public function assisted(User $actor): array
{
app(FundingProgrammes::class)->guard($actor);
$package = $this->package();
$existing = DB::table('funding_programmes')->get(['id', 'code', 'version', 'published_version_id'])->keyBy('code');
$items = [];
foreach ($package['references'] as $code => $reference) {
$draft = $reference['draft'];
$row = $existing->get($code);
$items[] = ['code' => $code, 'title' => $draft['title'], 'summary' => $draft['summary'], 'categories' => $draft['institution_categories'] ?? [],
'types' => $draft['institution_types'] ?? [], 'status' => $draft['institution_status'] ?? 'reported', 'source_document' => $draft['source_document_name'] ?? null,
'source_sha256' => $draft['source_document_sha256'] ?? null, 'locations' => $draft['locations'] ?? [], 'contacts' => $draft['contacts'] ?? [],
'id' => $row?->id, 'published' => $row?->published_version_id !== null];
}
return ['dataset_hash' => $package['sha256'], 'editor_id' => $actor->id, 'items' => $items];
}
/** @return array{id:string,created:bool} */
public function prepare(User $actor, string $code, string $hash): array
{
@@ -39,7 +113,7 @@ final class FundingProgrammeReferences
return ['id' => $existing->id, 'created' => false];
}
$id = app(FundingProgrammes::class)->save($actor, null, ['version' => 0, 'code' => $code,
'reason' => 'Preparação de referência oficial '.$plan['edition'].'; rascunho sem publicação automática.', 'draft' => $plan['draft']]);
'reason' => 'Preparação de referência documental '.$plan['edition'].'; rascunho sem publicação automática.', 'draft' => $plan['draft']]);
DB::table('audit_events')->insert(['source_system' => 'funding-catalogue', 'entity_type' => 'funding_programmes', 'entity_id' => $id,
'actor_user_id' => $actor->id, 'action' => 'reference_prepared', 'data_classification' => 'restricted',
'after_data' => json_encode(['edition' => $plan['edition'], 'package_sha256' => $plan['package_sha256'], 'provenance' => $plan['provenance'], 'effect' => 'private_draft_only'], JSON_THROW_ON_ERROR)]);
+41 -4
View File
@@ -18,7 +18,7 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
/** Informational funding catalogue, separate from applications, credit decisions and fiscal calculations. */
final class FundingProgrammes
{
public const KINDS = ['fund' => 'Fundo', 'programme' => 'Programa de apoio', 'incentive' => 'Incentivo'];
public const KINDS = ['institution' => 'Instituição financeira', 'fund' => 'Fundo', 'programme' => 'Programa de apoio', 'incentive' => 'Incentivo'];
public const WINDOWS = ['dated' => 'Datas documentadas', 'rolling' => 'Recepção contínua documentada', 'unknown' => 'Janela por confirmar', 'closed' => 'Encerrada segundo a fonte'];
@@ -38,6 +38,19 @@ final class FundingProgrammes
abort_unless($this->access($actor), 403);
}
/** @param array<string,mixed> $reference */
public function matchesReference(User $actor, string $id, array $reference): bool
{
$this->guard($actor);
$row = DB::table('funding_programmes')->where('id', $id)->firstOrFail();
$expected = $this->normalize($actor, $reference, false);
$actual = json_decode($row->draft, true, 512, JSON_THROW_ON_ERROR);
abort_unless(is_array($actual), 503);
return json_encode(Arr::sortRecursive($actual), JSON_THROW_ON_ERROR)
=== json_encode(Arr::sortRecursive($expected), JSON_THROW_ON_ERROR);
}
public function publicQuery(): Builder
{
abort_unless($this->installed(), 404);
@@ -56,6 +69,7 @@ final class FundingProgrammes
public function catalogueQuery(array $filters): Builder
{
$query = $this->publicQuery();
app(FundingInstitutionDetails::class)->applyFilters($query, $filters);
$includeUnknown = ($filters['certainty'] ?? 'include_unknown') === 'include_unknown';
if (! empty($filters['sector'])) {
$query->where(function (Builder $scope) use ($filters, $includeUnknown): void {
@@ -66,10 +80,10 @@ final class FundingProgrammes
}
});
}
if (! empty($filters['territory'])) {
if (! empty($filters['territory']) && ($filters['location_mode'] ?? '') !== 'physical') {
$path = array_column(app(InvestmentGeography::class)->path($filters['territory']), 'id');
$query->where(function (Builder $scope) use ($filters, $path, $includeUnknown): void {
$scope->whereRaw("v.payload->>'coverage' = 'national'")
$scope->whereRaw("v.payload->>'kind' = 'institution'")->orWhereRaw("v.payload->>'coverage' = 'national'")
->orWhereJsonContains('v.payload->territory_ancestor_ids', $filters['territory']);
foreach ($path as $id) {
$scope->orWhereJsonContains('v.payload->territory_ids', $id);
@@ -102,6 +116,15 @@ final class FundingProgrammes
public function filterExplanation(array $record, array $filters): array
{
$payload = $record['payload'];
if ($payload['kind'] === 'institution') {
$products = array_values(array_filter($payload['products'] ?? [], fn ($p) => app(FundingInstitutionDetails::class)->match($p, $filters)));
if (($filters['location_mode'] ?? '') === 'physical') {
return ['institution' => 'Pesquisa por presença física documentada, não por cobertura de financiamento. Confira a precisão do local e as indicações por confirmar na ficha.'];
}
return ['institution' => $products === [] ? 'Instituição identificada; produtos, cobertura e disponibilidade por confirmar. Não é uma correspondência confirmada.' : 'Consulte os produtos correspondentes e as condições por confirmar. Sede e balcões não determinam a cobertura do financiamento.'];
}
$result = [];
if (! empty($filters['sector'])) {
$result['sector'] = $payload['sectors_scope'] === 'unknown'
@@ -200,6 +223,13 @@ final class FundingProgrammes
if (! $row && DB::table('funding_programmes')->where('code', $data['code'])->exists()) {
throw ValidationException::withMessages(['code' => 'Este identificador já pertence a uma ficha. Abra-a para actualizar ou acrescentar uma janela.']);
}
if ($draft['kind'] === 'institution') {
DB::select('select pg_advisory_xact_lock(261102, hashtext(?))', ['institution:'.$draft['institution_key']]);
$duplicate = DB::table('funding_programmes')->whereRaw("draft->>'kind' = 'institution'")->whereRaw("draft->>'institution_key' = ?", [$draft['institution_key']])->when($id, fn ($q) => $q->where('id', '<>', $id))->exists();
if ($duplicate) {
throw ValidationException::withMessages(['institution_key' => 'A instituição já tem ficha. Reutilize a identidade existente; não substitua as suas edições.']);
}
}
$id ??= (string) Str::uuid();
DB::table('funding_programmes')->updateOrInsert(['id' => $id], ['code' => $data['code'], 'version' => (int) $data['version'] + 1,
'draft' => json_encode($draft, JSON_THROW_ON_ERROR), 'created_by' => $row->created_by ?? $actor->id,
@@ -218,13 +248,14 @@ final class FundingProgrammes
$rules = [
'title' => "$required|string|max:250", 'kind' => ['required', Rule::in(array_keys(self::KINDS))], 'summary' => "$required|string|max:4000",
'manager_name' => 'nullable|string|max:250', 'manager_organization_id' => ['nullable', 'uuid', Rule::exists('organizations', 'id')->whereNull('deleted_at')],
'source_url' => "$required|url:http,https|max:2000", 'source_reference' => "$required|string|max:1500", 'source_checked_on' => "$required|date_format:Y-m-d|before_or_equal:today",
'source_url' => 'nullable|url:http,https|max:2000', 'source_document_name' => 'nullable|string|max:250', 'source_document_sha256' => 'nullable|string|regex:/^[a-f0-9]{64}$/D', 'source_reference' => "$required|string|max:1500", 'source_checked_on' => "$required|date_format:Y-m-d|before_or_equal:today",
'source_published_on' => 'nullable|date_format:Y-m-d', 'source_status' => 'required|in:identified,unknown,example',
'coverage' => 'required|in:national,territorial,unknown', 'coverage_text' => 'nullable|string|max:2000',
'territory_ids' => 'array|max:30', 'territory_ids.*' => 'required|uuid|distinct',
'sectors_scope' => 'required|in:all,selected,unknown', 'sectors_text' => 'nullable|string|max:2000', 'sector_ids' => 'array|max:40',
'sector_ids.*' => ['required', 'integer', 'distinct', Rule::in(Sector::choices()->whereNotNull('published_at')->pluck('id')->all())],
'resource_ids' => 'array|max:30', 'resource_ids.*' => 'required|integer|distinct|exists:portal_resources,id',
'funding_needs' => 'array|max:6', 'funding_needs.*' => ['required', 'distinct', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))],
'criteria' => 'array|max:30', 'windows' => 'array|max:20',
'windows.*' => 'required|array:code,title,mode,start_on,end_on,coverage,coverage_text,territory_ids,source_url,source_reference,checked_on,application_url,criteria',
'windows.*.code' => 'required|string|regex:/^[a-z0-9][a-z0-9-]{0,79}$/D|distinct', 'windows.*.title' => "$required|string|max:250",
@@ -244,6 +275,12 @@ final class FundingProgrammes
$rules[$prefix.'.*.explanation'] = "$required|string|max:2000";
}
$data = Validator::make($input, $rules)->validate();
if ($publishing && empty($data['source_url'])) {
Validator::make($data, ['source_document_name' => 'required|string', 'source_document_sha256' => 'required|string|size:64'])->validate();
}
if ($data['kind'] === 'institution') {
$data = [...$data, ...app(FundingInstitutionDetails::class)->normalize($input, $publishing)];
}
$data = [...$data, 'territory_ids' => $data['territory_ids'] ?? [], 'sector_ids' => $data['sector_ids'] ?? [], 'resource_ids' => $data['resource_ids'] ?? [],
'criteria' => $data['criteria'] ?? [], 'windows' => $data['windows'] ?? []];
if ($data['coverage'] !== 'territorial' && $data['territory_ids'] !== []) {
+34 -10
View File
@@ -21,7 +21,7 @@ class InvestmentCatalogue
'projects' => ['project_id', 'projects'], 'locations' => ['geo_location_id', 'geo_locations']];
/** @return array<string,mixed> */
public function filters(Request $request): array
public function filters(Request $request, bool $offerPrices = false): array
{
$filters = $request->validate(['perspective' => ['nullable', 'in:exists,needs,ideas,opportunities'], 'q' => ['nullable', 'string', 'max:120'], 'sector' => ['nullable', 'string', 'max:80'],
'preparation' => ['nullable', Rule::in(array_column(OpportunityMaturity::searchOptions(), 'value'))],
@@ -34,9 +34,9 @@ class InvestmentCatalogue
'zee_id' => ['nullable', 'uuid'], 'zfi_id' => ['nullable', 'uuid'], 'park_id' => ['nullable', 'uuid'],
'dimension' => ['nullable', 'in:cluster,corridor,zee,zfi'], 'view' => ['nullable', 'in:grid,list,map,combined,corridors'],
'maturity_level' => ['nullable', 'integer', 'between:1,8'], 'facets' => ['nullable', 'array', 'max:20'], 'facets.*' => ['integer', 'min:1'],
'min_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999'],
'max_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999', ...($request->filled('min_amount') ? ['gte:min_amount'] : [])],
'currency' => ['nullable', 'required_with:min_amount,max_amount', 'in:MZN,USD,EUR,ZAR'],
'min_amount' => $offerPrices ? ['nullable', 'string', 'regex:/^\d{1,16}(\.\d{1,2})?$/'] : ['nullable', 'numeric', 'min:0', 'max:999999999999'],
'max_amount' => $offerPrices ? ['nullable', 'string', 'regex:/^\d{1,16}(\.\d{1,2})?$/'] : ['nullable', 'numeric', 'min:0', 'max:999999999999', ...($request->filled('min_amount') ? ['gte:min_amount'] : [])],
'currency' => ['nullable', 'required_with:min_amount,max_amount', $offerPrices ? 'in:MZN,USD' : 'in:MZN,USD,EUR,ZAR'],
'before' => ['nullable', 'date_format:Y-m-d'], 'sort' => ['nullable', 'in:recent,title,amount_asc,amount_desc'],
'association_type' => ['nullable', 'in:'.implode(',', array_keys(OpportunityClassification::TARGETS))],
'association_target' => ['nullable', 'string', 'max:80'],
@@ -260,26 +260,41 @@ class InvestmentCatalogue
/** Public result presentation only; no editorial or territorial data is saved. */
public function presentLocation(Opportunity $record): Opportunity
{
$record->setAttribute('territorial_summary', $this->locationPresentation($record)['summary']);
return $record;
}
/** @return array{summary: string, provinces: list<string>} */
public function locationPresentation(Opportunity $record): array
{
$geo = app(InvestmentGeography::class);
$paths = [];
$provinceNames = [];
$ids = array_filter([$record->primary_territory_id, ...$record->additionalTerritories()->pluck('geo_locations.id')->all()]);
foreach (array_unique($ids) as $id) {
$path = $geo->path($id);
if ($path) {
$paths[] = implode(' / ', array_column($path, 'name'));
foreach ($path as $node) {
if ($node->location_type === 'province') {
$provinceNames[] = (string) $node->name;
}
}
}
}
if ($paths) {
$label = implode('; ', array_unique($paths));
} elseif ($provinces = app(InvestmentCorridorContext::class)->catalogueProvinces($record)) {
$label = implode(' / ', array_column($provinces, 'name')).' — contexto provincial do corredor; distrito e posto administrativo não indicados';
$provinceNames = array_map(fn ($province) => (string) $province->name, $provinces);
$label = implode(' / ', $provinceNames).' — contexto provincial do corredor; distrito e posto administrativo não indicados';
} else {
$label = implode(' / ', array_filter([$record->province, $record->district])) ?: 'Localização não indicada';
$provinceNames = $record->province ? [(string) $record->province] : [];
}
$record->setAttribute('territorial_summary', $label);
return $record;
return ['summary' => $label, 'provinces' => array_values(array_unique($provinceNames))];
}
/** @param array<string,mixed> $territoryFilters
@@ -507,11 +522,11 @@ class InvestmentCatalogue
->whereIn('t.id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->select($foreign))
->get(['l.opportunity_id', 't.name'])->groupBy('opportunity_id');
}
$features = $missing = [];
$features = $missing = $thematic = [];
$areas = (clone $spatial)->whereIn('shape.geometry_type', ['polygon', 'multipolygon'])->orderBy('shape.id')
->get(['o.id', 'o.title', 'o.slug', 'g.name', 'shape.source_system', 'shape.properties', DB::raw('ST_AsGeoJSON(shape.geom) as geometry'),
DB::raw("CASE WHEN o.primary_territory_id=g.id OR EXISTS (SELECT 1 FROM opportunity_locations direct WHERE direct.opportunity_id=o.id AND direct.geo_location_id=g.id) THEN 'território declarado da oportunidade' ELSE 'contexto do projeto associado; implantação da oportunidade por confirmar' END AS basis")])
->map(fn ($area) => ['geometry' => json_decode($area->geometry, true), 'title' => $area->title.' — '.$area->name.' ('.$area->basis.')',
->map(fn ($area) => ['opportunity_id' => (int) $area->id, 'geometry' => json_decode($area->geometry, true), 'title' => $area->title.' — '.$area->name.' ('.$area->basis.')',
'source_reference' => $area->source_system, 'source_date' => json_decode($area->properties ?? '{}', true)['source_date'] ?? null,
'url' => '/oportunidades/'.$area->slug.($filters === [] ? '' : '?'.http_build_query($filters))])->all();
$located = 0;
@@ -521,6 +536,15 @@ class InvestmentCatalogue
foreach ($relations as $relation => $groups) {
$properties[$relation] = ($groups->get($record->id) ?? collect())->pluck('name')->unique()->values()->all();
}
$public = app(ProactiveInvestment::class)->publicData($record);
$evidence = array_values(array_filter(StructuredData::collectionItems($public['evidence'] ?? []),
fn ($proof) => in_array($proof['category'] ?? null, ['resource', 'infrastructure', 'workforce', 'activity', 'supply', 'demand'], true) && ($proof['purpose'] ?? null) === 'local'));
if ($evidence !== []) {
$thematic[] = ['id' => $record->id, 'title' => $record->title, 'url' => $properties['url'], 'location' => $properties['location'],
'state' => $public['state'] ?? 'hypothesis', 'limitations' => $public['limitations'] ?? null,
'evidence' => array_map(fn ($proof) => [...array_intersect_key($proof, array_flip(['id', 'statement', 'source', 'locator', 'period', 'scope', 'coverage', 'verified_on'])),
'source_category' => $proof['category'], 'category' => in_array($proof['category'], ['activity', 'supply', 'demand'], true) ? 'economic_potential' : $proof['category']], $evidence)];
}
$sites = collect($points->get($record->id) ?? []);
if ($sites->isNotEmpty()) {
$located++;
@@ -537,6 +561,6 @@ class InvestmentCatalogue
return ['type' => 'FeatureCollection', 'limit' => 1000, 'matched_count' => $total, 'truncated' => $total > $records->count(),
'georeferenced_count' => $located, 'marker_count' => count($features), 'unlocated' => $missing, 'features' => $features,
'areas' => $areas, 'complete' => $total === $located];
'thematic_records' => $thematic, 'areas' => $areas, 'complete' => $total === $located];
}
}
+51
View File
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
@@ -12,6 +13,52 @@ final class InvestmentGeography
{
public const FIELDS = ['province_id' => 'province', 'district_id' => 'district', 'admin_post_id' => 'admin_post', 'locality_id' => 'locality'];
/** Public declared coverage; a territory join never multiplies the opportunity total.
*
* @return array{opportunities: int, provinces: int}
*/
public function opportunityStatistics(): array
{
$opportunities = Opportunity::published()->select(['id', 'primary_territory_id', 'province', 'geography_scope'])->toBase();
$references = $this->publicReferences()->select(['id', 'parent_id', 'name', 'location_type']);
$result = DB::selectOne('WITH RECURSIVE
published_opportunities AS ('.$opportunities->toSql().'),
public_references AS ('.$references->toSql().'),
declared_locations AS (
SELECT id AS opportunity_id, primary_territory_id AS location_id
FROM published_opportunities WHERE primary_territory_id IS NOT NULL
UNION
SELECT l.opportunity_id, l.geo_location_id FROM opportunity_locations l
JOIN published_opportunities o ON o.id=l.opportunity_id
),
territory_paths AS (
SELECT d.opportunity_id, d.location_id, g.id, g.parent_id, g.name, g.location_type,
ARRAY[g.id] AS visited, 1 AS depth
FROM declared_locations d JOIN public_references g ON g.id=d.location_id
UNION ALL
SELECT t.opportunity_id, t.location_id, g.id, g.parent_id, g.name, g.location_type,
t.visited||g.id, t.depth+1
FROM territory_paths t JOIN public_references g ON g.id=t.parent_id
WHERE t.depth<5 AND NOT g.id=ANY(t.visited)
),
covered_provinces AS (
SELECT t.name FROM territory_paths t WHERE t.location_type=\'province\'
AND EXISTS (SELECT 1 FROM territory_paths root
WHERE root.opportunity_id=t.opportunity_id AND root.location_id=t.location_id AND root.parent_id IS NULL)
UNION
SELECT g.name FROM public_references g JOIN published_opportunities o
ON lower(btrim(o.province))=lower(btrim(g.name))
WHERE g.location_type=\'province\' AND o.primary_territory_id IS NULL
AND COALESCE(o.geography_scope, \'unspecified\')<>\'national\'
AND NOT EXISTS (SELECT 1 FROM opportunity_locations l WHERE l.opportunity_id=o.id)
)
SELECT (SELECT count(DISTINCT id) FROM published_opportunities) AS opportunities,
(SELECT count(DISTINCT lower(btrim(name))) FROM covered_provinces) AS provinces',
[...$opportunities->getBindings(), ...$references->getBindings()]);
return ['opportunities' => (int) $result->opportunities, 'provinces' => (int) $result->provinces];
}
/** Resolve an old province-name search only when the public register has exactly one match.
* @param array<array-key, mixed> $filters
* @return array<array-key, mixed>
@@ -91,6 +138,10 @@ final class InvestmentGeography
$query->where(function ($workbook) {
$workbook->where('source_system', 'cartografia-workbook')
->where('external_id', 'like', '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:%');
})->orWhere(function ($demography) {
// Owner, 13/11/2026: only these source-backed additions are authorised.
$demography->where('source_system', 'ine-demography')->where('location_type', 'district')
->whereIn('external_id', DemographicInstallation::publicDistrictRefs());
})->orWhereIn('id', app(TerritorialRegistry::class)->published()->select('g.id'));
});
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Services;
use App\Models\InvestmentZone;
use App\Support\StructuredData;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/** Public park observations remain individual source records, never regional estimates. */
final class InvestmentMapIndicators
{
public const INDICATORS = ['employment', 'investment', 'production', 'exports', 'local_procurement'];
/** @param Collection<int, InvestmentZone> $zones
* @return array<string,mixed> */
public function forZones(Collection $zones, bool $zoneLimitReached = false): array
{
$zoneLimitReached = $zoneLimitReached || $zones->count() > 30;
$zones = $zones->take(30)->values();
$details = app(InvestmentZoneOperationalDetails::class);
$result = ['state' => $details->installed() ? 'available' : 'unavailable', 'records' => [],
'zones_consulted' => $zones->count(), 'zone_limit' => 30, 'record_limit' => 200, 'truncated' => $zoneLimitReached];
if ($result['state'] === 'unavailable') {
return $result;
}
foreach ($zones as $zone) {
// This is the already selected public envelope; never decrypt the private preparation.
$public = $details->publicData($zone);
$observations = StructuredData::collectionItems($public['integral']['observations'] ?? []);
$values = [];
if ($observations !== []) {
// Extract decimal text from this exact loaded snapshot, not a newer database version or a PHP float.
foreach (DB::select("SELECT item->>'id' AS id, item->>'value' AS value FROM jsonb_array_elements((?::jsonb)->'public'->'integral'->'observations') AS item", [$zone->getRawOriginal('operational_details')]) as $number) {
$values[$number->id] = $number->value;
}
}
foreach ($observations as $row) {
if (! in_array($row['indicator'] ?? null, self::INDICATORS, true)) {
continue;
}
if (count($result['records']) === 200) {
$result['truncated'] = true;
return $result;
}
$knowledge = $row['knowledge'] ?? 'unknown';
$rawValue = $values[$row['id'] ?? ''] ?? null;
$value = $knowledge !== 'unknown' && is_numeric($rawValue) ? $rawValue : null;
$missing = [];
foreach (['unit', 'period_start', 'period_end', 'scope', 'population', 'methodology', 'source_reference', 'source_date'] as $field) {
if (trim((string) ($row[$field] ?? '')) === '') {
$missing[] = $field;
}
}
$result['records'][] = [
...array_intersect_key($row, array_flip(['id', 'label', 'description', 'indicator', 'unit', 'period_start', 'period_end', 'scope', 'population', 'methodology', 'source_reference', 'source_date', 'phase_id', 'plot_id'])),
'zone_id' => $zone->id, 'zone_name' => $zone->name, 'zone_version' => (int) $zone->version_no, 'url' => '/zonas/'.$zone->id,
'theme' => $row['indicator'] === 'employment' ? 'workforce' : 'economic_potential',
'value' => $value, 'knowledge' => $knowledge, 'missing_metadata' => $missing,
'measure_kind' => in_array($row['measure_kind'] ?? null, ['observed', 'planned', 'projected'], true) ? $row['measure_kind'] : 'unknown',
];
}
}
return $result;
}
}
@@ -0,0 +1,516 @@
<?php
namespace App\Services;
use App\Http\Controllers\InvestmentPortfolioController;
use App\Models\EconomicCluster;
use App\Models\User;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** Continuation of the existing private dossier, its documentary assessment and proactive evidence. */
final class InvestmentPreparationWorkspace
{
private const SOURCE = 'investor-preparation';
public function __construct(private readonly VirtualDesk $desk) {}
/** @return list<array<array-key,mixed>> */
private function rows(mixed $value): array
{
if (! is_array($value)) {
throw new \LogicException('Dossier list is not recoverable; original retained.');
}
$rows = [];
foreach ($value as $row) {
if (! is_array($row)) {
throw new \LogicException('Dossier row is not recoverable; original retained.');
} $rows[] = $row;
}
return $rows;
}
/** @param array<array-key,mixed> $data */
private function hash(array $data): string
{
return hash('sha256', json_encode(Arr::sortRecursive($data), JSON_THROW_ON_ERROR));
}
private function events(string $id, ?string $opportunity): Builder
{
return DB::table('audit_events')->where('source_system', self::SOURCE)->where('entity_type', 'desk_cases')->where('entity_id', $id)
->where('after_data->scope', $opportunity ?? 'dossier');
}
/** @return array<array-key,mixed> */
private function decode(?\stdClass $event): array
{
return $event ? json_decode(Crypt::decryptString(json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['snapshot']), true, 512, JSON_THROW_ON_ERROR)
: ['version' => 0, 'apiex' => ['additional_documents' => [], 'note' => '', 'review' => null, 'exports' => [], 'external' => []],
'swot' => ['object_key' => '', 'scope' => '', 'rows' => [], 'source_fingerprint' => '', 'review' => null, 'actions' => []]];
}
/** @return array<array-key,mixed> */
private function current(string $id, ?string $opportunity): array
{
return $this->decode($this->events($id, $opportunity)->orderByRaw("(after_data->>'version')::integer DESC")->first());
}
/** @return array{0: \stdClass, 1: array<array-key,mixed>, 2: ?string, 3: array<array-key,mixed>, 4: bool} */
private function context(User $actor, string $id, ?string $opportunity, bool $initialScope = false): array
{
abort_unless(app(InvestorPath::class)->available() && app(PrivateInvestmentPreparation::class)->available(), 404);
$case = $this->desk->case($actor, $id);
abort_unless($this->desk->owns($actor, $case, false) || $actor->can('manage-portal'), 403);
$context = app(InvestorContext::class)->state($case);
$scope = app(InvestmentConsumerScope::class);
$historical = false;
abort_if($initialScope && ! $this->events($id, null)->exists(), 404);
$choices = array_column(array_column($context['opportunity_choices'] ?? [], 'opportunity'), 'key');
if ((($opportunity && ! in_array($opportunity, $choices, true)) || ($initialScope && $choices)) && $this->events($id, $opportunity)->exists()) {
$saved = $this->current($id, $opportunity);
$last = $saved['apiex']['exports'] ? $saved['apiex']['exports'][array_key_last($saved['apiex']['exports'])] : null;
$oldContext = $saved['context_snapshot'] ?? $last['prepared']['context'] ?? $saved['swot']['sources_snapshot']['context'] ?? null;
abort_unless(is_array($oldContext), 409, 'Contexto histórico conservado; snapshot de consulta indisponível.');
$context = $oldContext;
$historical = true;
} else {
$opportunity = $scope->key($context, $opportunity, true);
$context = $scope->context($context, $opportunity);
}
$documents = app(InvestorContext::class)->preparationDocuments($id);
// Reuse request-level document authorization, including for a dossier team member.
$documents = array_values(array_filter($documents, function ($document) use ($actor, $id) {
$link = DB::table('desk_documents')->where('id', $document['id'])->where('case_id', $id)->first();
if ($link?->request_id) {
try {
$this->desk->request($actor, $link->request_id);
} catch (HttpException $e) {
if (in_array($e->getStatusCode(), [403, 404], true)) {
return false;
} throw $e;
}
}
return true;
}));
foreach ($documents as &$document) {
$record = DB::table('documents')->where('id', $document['document_id'])->first();
$document['available'] = $record && $record->status === 'active' && $record->malware_scan_status === 'clean'
&& hash_equals(trim($record->sha256), trim((string) $record->malware_scanned_sha256));
}
unset($document);
$unique = [];
foreach ($documents as $document) {
$unique[$document['document_id']] = $document;
}
return [$case, $context, $opportunity, array_values($unique), $historical];
}
/** @param array<array-key,mixed> $context
* @return array<array-key,mixed> */
private function sources(User $actor, \stdClass $case, array $context, string $object): array
{
$objects = [['key' => 'investment:'.$case->id, 'label' => $case->title, 'kind' => 'investment']];
$evidence = [];
$demography = app(DemographicEvidence::class)->forContext($context);
if ($demography['state'] === 'available') {
$evidence['demography'] = ['key' => 'demography', 'label' => 'Contexto demográfico INE', 'current' => $demography,
'notice' => 'Evidência para análise SWOT; não produz conclusões automáticas. Consulte ano, edição e limitações.'];
}
foreach ($context['sector_references'] ?? [] as $reference) {
$current = hash_equals((SectorResourceReferences::SOURCES[$reference['sector_slug']] ?? ''), $reference['source_sha256'] ?? '') ? $reference : ['unavailable' => true, 'source_sha256' => (SectorResourceReferences::SOURCES[$reference['sector_slug']] ?? '')];
$evidence['sector-resource:'.$reference['key']] = ['key' => 'sector-resource:'.$reference['key'], 'label' => $reference['title'], 'consulted' => $reference, 'current' => $current, 'notice' => 'Referência sectorial e de cadeia de valor; não certifica direitos, licenças nem disponibilidade de projecto.'];
}
$refs = array_values($context['selection'] ?? []);
foreach ($context['opportunity_choices'] ?? [] as $choice) {
$refs[] = $choice['opportunity'];
}
foreach ($refs as $ref) {
if (! is_array($ref) || empty($ref['key'])) {
continue;
}
try {
$live = app(InvestorContext::class)->reference($ref['key']);
} catch (ModelNotFoundException|HttpException $e) {
$live = ['key' => $ref['key'], 'unavailable' => true];
}
$evidence[$ref['key']] = ['key' => $ref['key'], 'label' => $ref['title'] ?? $ref['key'], 'consulted' => $ref, 'current' => $live];
if (in_array($ref['kind'] ?? '', ['territory', 'sector'], true)) {
$objects[] = ['key' => $ref['key'], 'label' => $ref['title'], 'kind' => $ref['kind']];
}
if (($ref['kind'] ?? '') === 'portfolio') {
$portfolio = InvestmentPortfolioController::visible()->whereKey($ref['id'])->first();
if ($portfolio?->cluster_id) {
$cluster = EconomicCluster::find($portfolio->cluster_id);
if ($cluster) {
$objects[] = ['key' => 'cluster:'.$cluster->id, 'label' => $cluster->getAttribute('name'), 'kind' => 'cluster'];
}
}
}
}
// Sector/territory may have been deliberately entered rather than selected as a source.
foreach ([['sector', 'sector_id'], ['territory', 'province_id'], ['territory', 'district_id'], ['territory', 'admin_post_id'], ['territory', 'locality_id']] as [$kind, $field]) {
if (! empty($context['fields'][$field])) {
$key = $kind.':'.$context['fields'][$field];
try {
$ref = app(InvestorContext::class)->reference($key);
$objects[] = ['key' => $key, 'label' => $ref['title'], 'kind' => $kind];
$evidence[$key] = ['key' => $key, 'label' => $ref['title'], 'consulted' => $ref, 'current' => $ref];
} catch (ModelNotFoundException|HttpException $e) { /* Keep the declared context; do not invent evidence. */
}
}
}
// A manually chosen cluster must already be a public, visible portfolio association.
if (str_starts_with($object, 'cluster:')) {
$clusterId = substr($object, 8);
$visible = InvestmentPortfolioController::visible()->where('cluster_id', $clusterId)->exists();
$cluster = $visible ? EconomicCluster::find($clusterId) : null;
$objects[] = ['key' => $object, 'label' => $cluster ? $cluster->getAttribute('name') : 'Cluster da análise guardada — fonte actual indisponível', 'kind' => 'cluster'];
}
foreach ($objects as $item) {
if ($item['kind'] === 'cluster') {
$evidence[$item['key']] = ['key' => $item['key'], 'label' => 'Competitividade — '.$item['label'],
'current' => InvestmentPortfolioController::visible()->where('cluster_id', substr($item['key'], 8))->exists() ? app(ClusterCompetitiveness::class)->publicData(substr($item['key'], 8)) : null, 'notice' => 'Versão pública aprovada; ausência de análise não prova ausência de oferta/concorrência.'];
}
}
$unique = [];
foreach ($objects as $item) {
$unique[$item['key']] = $item;
}
return [array_values($unique), array_values($evidence)];
}
/** @return array<array-key,mixed> */
public function view(User $actor, string $id, ?string $opportunity = null, ?int $historyBefore = null, bool $initialScope = false): array
{
[$case, $context, $opportunity, $documents, $historical] = $this->context($actor, $id, $opportunity, $initialScope);
$state = $this->current($id, $opportunity);
[$objects, $sources] = $this->sources($actor, $case, $context, $state['swot']['object_key']);
$payload = $this->desk->decode($case->payload);
$reused = ['investor' => Arr::only($payload, ['name', 'email']), 'profile' => Arr::only($payload['profile'] ?? [], ['phone', 'nuit', 'address', 'country_code', 'nationality', 'residence_country_code']), 'company' => Arr::only($payload['company'] ?? [], ['id', 'name', 'nuit', 'registration', 'address', 'type']), 'company_version' => $payload['company_version'] ?? null, 'notice' => 'Valores autorizados conservados no dossier; não foram substituídos por dados recentes de outra origem.'];
if ($historical && isset($state['reused_snapshot'])) {
$reused = $state['reused_snapshot'];
}
foreach ($documents as $document) {
$sources[] = ['key' => 'document:'.$document['document_id'], 'label' => $document['file_name'], 'current' => $document, 'notice' => 'Documento privado do dossier; disponibilidade não certifica o conteúdo.'];
}
$assessment = app(PrivateInvestmentPreparation::class)->assess($context, $documents);
$issues = $assessment['missing_labels'];
if (! $assessment['enabled'] || ! $assessment['period_covered'] || ! $assessment['scope_covered'] || $assessment['mode'] === 'indeterminate') {
$issues[] = 'Completar o enquadramento da preparação existente.';
}
foreach ($assessment['requirements'] as &$requirement) {
$document = collect($this->rows($documents))->firstWhere('document_id', $requirement['document_id']);
$requirement['available'] = $document['available'] ?? false;
if ($requirement['state'] === 'indeterminate' || ($requirement['required'] && ! $requirement['available'])) {
$issues[] = $requirement['label'];
}
}
unset($requirement);
$selected = array_values(array_unique([...collect($this->rows($assessment['requirements']))->where('required', true)->pluck('document_id')->filter()->all(), ...$state['apiex']['additional_documents']]));
foreach ($selected as $doc) {
if (! (collect($this->rows($documents))->firstWhere('document_id', $doc)['available'] ?? false)) {
$issues[] = 'Anexo indisponível: '.$doc;
}
}
$documentary = array_values(array_filter($documents, fn ($d) => in_array($d['document_id'], $selected, true)));
$fingerprint = $this->hash(['context' => $context, 'reused' => $reused, 'assessment' => $assessment, 'documents' => $documentary, 'note' => $state['apiex']['note']]);
$swotFingerprint = $this->hash($this->sourceSnapshot($context, $reused, $sources, $state['swot']['object_key'], $state['swot']['rows']));
$external = collect($this->rows($state['apiex']['external']));
$apiState = $external->contains('kind', 'decision') ? 'decision_received' : ($external->contains('kind', 'delivery') ? 'delivery_recorded' : (! $issues && ($state['apiex']['review']['fingerprint'] ?? null) === $fingerprint ? 'ready' : 'preparing'));
$query = $opportunity ? '?opportunity='.urlencode($opportunity) : '';
$editQuery = $historical ? '' : $query;
$savedScopes = DB::table('audit_events')->where('source_system', self::SOURCE)->where('entity_type', 'desk_cases')->where('entity_id', $id)->selectRaw("DISTINCT after_data->>'scope' as scope")->get()->map(fn ($row) => ['key' => $row->scope, 'label' => $row->scope === 'dossier' ? 'Contexto inicial do dossier' : $row->scope, 'href' => '/investimentos/'.$id.'/preparacao?'.($row->scope === 'dossier' ? 'history_scope=dossier' : 'opportunity='.urlencode($row->scope))])->all();
$historyRows = $this->events($id, $opportunity)->when($historyBefore !== null, fn ($q) => $q->whereRaw("(after_data->>'version')::integer < ?", [$historyBefore]))->orderByRaw("(after_data->>'version')::integer DESC")->limit(26)->get();
$history = $historyRows->take(25)->map(fn ($e) => ['version' => json_decode($e->after_data, true)['version'], 'action' => $e->action, 'at' => $e->occurred_at, 'actor' => $e->actor_user_id, 'operation_key' => $e->correlation_id])->values()->all();
return ['case' => ['id' => $id, 'title' => $case->title, 'closed' => $case->closed_at !== null], 'opportunity' => $opportunity, 'opportunity_label' => $context['selection']['opportunity']['title'] ?? null,
'historical' => $historical, 'initial_scope' => $initialScope, 'saved_scopes' => $savedScopes, 'version' => $state['version'], 'apiex' => $state['apiex'], 'swot' => $state['swot'], 'assessment' => $assessment, 'context' => $context, 'reused' => $reused,
'documents' => $documents, 'selected_documents' => $documentary, 'api_issues' => array_values(array_unique($issues)), 'api_state' => $apiState,
'api_fingerprint' => $fingerprint, 'swot_fingerprint' => $swotFingerprint,
'swot_review_required' => $state['swot']['source_fingerprint'] !== $swotFingerprint || ($state['swot']['review']['fingerprint'] ?? null) !== $swotFingerprint,
'objects' => $objects, 'sources' => $sources, 'can_write' => ! $historical && ! $case->closed_at && $this->desk->owns($actor, $case),
'can_review' => ! $historical && ! $case->closed_at && ($this->desk->owns($actor, $case) || $actor->can('manage-portal')),
'links' => ['dossier' => '/investimentos/'.$id.$editQuery, 'context' => '/investimentos/'.$id.'/contexto/editar'.$editQuery,
'documents' => '/balcao-virtual/dossiers/'.$id.$query, 'partners' => '/parcerias/painel?context='.$id.($opportunity ? '&opportunity='.urlencode($opportunity) : ''),
'library' => '/documentos?context='.$id.($opportunity ? '&opportunity='.urlencode($opportunity) : ''),
'editorial' => '/acesso-gestao', 'idea' => '/investimentos/'.$id.'/contexto/editar'.$query.'#project-summary', 'opportunity' => '/investimentos/'.$id.'/contexto/editar'.$query.'#project-summary', 'risk' => '/investimentos/'.$id.$query.'#tarefas'],
'history' => $history, 'history_next' => $historyRows->count() > 25 ? ($history[count($history) - 1]['version'] ?? null) : null];
}
/** @param array<array-key,mixed> $context
* @param array<array-key,mixed> $reused
* @param array<array-key,mixed> $sources
* @param array<array-key,mixed> $rows
* @return array<array-key,mixed> */
private function sourceSnapshot(array $context, array $reused, array $sources, string $object, array $rows): array
{
$used = array_column($rows, 'source_key');
$tracked = array_values(array_filter($sources, fn ($source) => ! str_starts_with($source['key'], 'document:') || in_array($source['key'], $used, true)));
return ['context' => $context, 'reused' => $reused, 'sources' => $tracked, 'object' => $object];
}
/** @return array<array-key,mixed>|null */
public function continuation(User $actor, string $id, ?string $opportunity, mixed $actionId): ?array
{
if (! is_string($actionId) || ! Str::isUuid($actionId)) {
return null;
}
[, , $scope] = $this->context($actor, $id, $opportunity);
return collect($this->rows($this->current($id, $scope)['swot']['actions']))->firstWhere('id', $actionId);
}
/** Reuses the authorised workspace and its preserved exports; never creates an export or a review.
* @return array<string,mixed> */
public function reportSummary(User $actor, string $id, ?string $opportunity): array
{
$view = $this->view($actor, $id, $opportunity);
$query = $view['opportunity'] ? '?opportunity='.urlencode($view['opportunity']) : '';
$base = '/investimentos/'.$id.'/preparacao';
return ['case_id' => $id, 'opportunity' => $view['opportunity'], 'version' => $view['version'],
'context_revision' => $view['context']['revision'], 'href' => $base.$query, 'api_state' => $view['api_state'],
'api_issues' => $view['api_issues'], 'external' => $view['apiex']['external'],
'exports' => array_map(fn ($export) => ['id' => $export['id'], 'at' => $export['at'],
'context_revision' => $export['context_revision'], 'fingerprint' => $export['fingerprint'],
'historical' => $export['fingerprint'] !== $view['api_fingerprint'],
'href' => $base.'/exportar/'.$export['id'].$query], $this->rows($view['apiex']['exports'])),
'swot' => $view['swot'], 'swot_review_required' => $view['swot_review_required']];
}
/** @return list<string> */
public function taskKeys(User $actor, string $id, string $opportunity): array
{
[, , $scope] = $this->context($actor, $id, $opportunity);
return array_values(array_map(fn ($action) => 'swot:'.$action['id'], $this->current($id, $scope)['swot']['actions']));
}
/** @param array<array-key,mixed> $input
* @param array<array-key,mixed> $view
* @return array<array-key,mixed> */
private function draft(array $input, array $view): array
{
$data = Validator::make($input, ['object_key' => ['required', Rule::in(array_column($view['objects'], 'key'))], 'scope' => 'present|nullable|string|max:4000',
'source_fingerprint' => 'present|string|size:64', 'rows' => 'present|array|list|max:80', 'rows.*' => 'array:id,dimension,text,reference,source_key,date,classification',
'rows.*.id' => 'required|uuid|distinct', 'rows.*.dimension' => 'required|in:strength,weakness,opportunity,threat',
'rows.*.classification' => 'required|in:documented_fact,hypothesis,unconfirmed', 'rows.*.text' => 'present|nullable|string|max:4000',
'rows.*.reference' => 'present|nullable|string|max:4000', 'rows.*.source_key' => ['nullable', Rule::in(array_column($view['sources'], 'key'))], 'rows.*.date' => 'nullable|date_format:Y-m-d'])->validate();
$snapshot = $this->sourceSnapshot($view['context'], $view['reused'], $view['sources'], $data['object_key'], $data['rows']);
return [...$data, 'sources_snapshot' => $snapshot, 'snapshot_fingerprint' => $this->hash($snapshot), 'review' => null, 'actions' => $view['swot']['actions']];
}
/** @param array<array-key,mixed> $input
* @return array<array-key,mixed> */
public function change(User $actor, string $id, array $input): array
{
$control = Validator::make($input, ['opportunity' => 'nullable|string|max:150', 'version' => 'required|integer|min:0', 'operation_key' => 'required|uuid',
'action' => 'required|in:apiex_save,apiex_review,apiex_export,external,swot_save,swot_review,swot_action,swot_restore', 'reason' => 'required|string|min:3|max:4000'])->validate();
return DB::transaction(function () use ($actor, $id, $input, $control) {
$case = $this->desk->case($actor, $id, false, true);
$this->desk->assertOpen($case);
$view = $this->view($actor, $id, $control['opportunity'] ?? null);
$review = in_array($control['action'], ['apiex_review', 'swot_review'], true);
abort_unless($review ? $view['can_review'] : $view['can_write'], 403);
$requestHash = $this->hash([...$input, 'actor' => $actor->id]);
$prior = $this->events($id, $view['opportunity'])->where('correlation_id', $control['operation_key'])->first();
if ($prior) {
abort_unless(hash_equals(json_decode($prior->after_data, true)['request_hash'], $requestHash), 409, 'A tentativa já tem outro conteúdo.');
return [...$this->view($actor, $id, $view['opportunity']), 'replayed' => true];
}
abort_unless((int) $control['version'] === $view['version'], 409, 'Existe outra versão. Os seus campos foram preservados; consulte e compare antes de substituir.');
$state = Arr::only($view, ['version', 'apiex', 'swot']);
$receipt = ['actor' => $actor->id, 'at' => now()->toISOString(), 'reason' => $control['reason']];
switch ($control['action']) {
case 'apiex_save':
$data = Validator::make($input, ['note' => 'present|nullable|string|max:6000', 'additional_documents' => 'present|array|list|max:60', 'additional_documents.*' => ['required', 'distinct', Rule::in(array_column($view['documents'], 'document_id'))]])->validate();
$state['apiex'] = [...$state['apiex'], ...$data, 'review' => null];
break;
case 'apiex_review':
abort_if($view['api_issues'], 422, 'Complete os requisitos e documentos aplicáveis.');
abort_unless(($input['fingerprint'] ?? '') === $view['api_fingerprint'], 409, 'O dossier/documentos mudaram; reveja a versão actual.');
foreach ($view['selected_documents'] as $document) {
$record = DB::table('documents')->where('id', $document['document_id'])->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($record !== null, 404);
app(VerifiedDocumentBinary::class)->respond($record);
}
$state['apiex']['review'] = [...$receipt, 'fingerprint' => $view['api_fingerprint']];
break;
case 'apiex_export':
abort_if($view['api_issues'] || ($state['apiex']['review']['fingerprint'] ?? '') !== $view['api_fingerprint'], 409, 'Reveja a preparação actual antes de exportar.');
$prepared = Arr::only($view, ['case', 'opportunity', 'context', 'reused', 'assessment', 'selected_documents']);
$prepared['apiex'] = Arr::only($state['apiex'], ['note', 'review']);
$state['apiex']['exports'][] = [...$receipt, 'id' => $control['operation_key'], 'fingerprint' => $view['api_fingerprint'], 'context_revision' => $view['context']['revision'], 'documents' => $view['selected_documents'], 'prepared' => $prepared];
break;
case 'external':
$data = Validator::make($input, ['kind' => 'required|in:delivery,clarification,response,decision', 'date' => 'required|date_format:Y-m-d|before_or_equal:today', 'reference' => 'required|string|max:2000',
'proof_document' => ['required', Rule::in(collect($this->rows($view['documents']))->where('available', true)->pluck('document_id')->all())], 'export_id' => 'required|uuid', 'related_id' => 'nullable|uuid', 'outcome' => 'nullable|in:approved,refused,conditional,other'])->validate();
abort_unless(collect($this->rows($state['apiex']['exports']))->contains('id', $data['export_id']), 422, 'Escolha a versão exportada deste dossier/oportunidade.');
$same = collect($this->rows($state['apiex']['external']))->where('export_id', $data['export_id']);
if ($data['kind'] !== 'delivery') {
abort_unless($same->contains('kind', 'delivery'), 422, 'Registe a entrega externa desta versão.');
}
if ($data['kind'] === 'response') {
abort_unless($same->where('kind', 'clarification')->contains('id', $data['related_id'] ?? ''), 422, 'Identifique o pedido de esclarecimento a que responde.');
}
if ($data['kind'] === 'decision') {
abort_unless(! empty($data['outcome']), 422, 'Identifique a decisão efectivamente recebida.');
}
$record = DB::table('documents')->where('id', $data['proof_document'])->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($record !== null, 404);
app(VerifiedDocumentBinary::class)->respond($record);
$state['apiex']['external'][] = [...$data, ...$receipt, 'id' => $control['operation_key'], 'proof_snapshot' => collect($this->rows($view['documents']))->firstWhere('document_id', $data['proof_document'])];
break;
case 'swot_save':
$state['swot'] = $this->draft($input['draft'] ?? [], $view);
break;
case 'swot_review':
$swot = $state['swot'];
abort_unless(($input['fingerprint'] ?? '') === $view['swot_fingerprint'], 409, 'As fontes mudaram; compare antes de rever.');
abort_unless(trim($swot['scope'] ?? '') !== '' && in_array($swot['object_key'], array_column($view['objects'], 'key'), true), 422, 'Identifique objecto e âmbito.');
foreach (['strength', 'weakness', 'opportunity', 'threat'] as $dimension) {
abort_unless(collect($this->rows($swot['rows']))->contains('dimension', $dimension), 422, 'Trate as quatro dimensões; incerteza deve ficar classificada.');
}
foreach ($swot['rows'] as $row) {
abort_unless(trim($row['text'] ?? '') !== '' && trim($row['reference'] ?? '') !== '' && ! empty($row['date']), 422, 'Cada conclusão exige fonte, data e classificação; desconhecido não é ausência.');
}
$state['swot']['source_fingerprint'] = $view['swot_fingerprint'];
$state['swot']['sources_snapshot'] = $this->sourceSnapshot($view['context'], $view['reused'], $view['sources'], $state['swot']['object_key'], $state['swot']['rows']);
$state['swot']['snapshot_fingerprint'] = $view['swot_fingerprint'];
$state['swot']['review'] = [...$receipt, 'fingerprint' => $view['swot_fingerprint'], 'version' => $state['version'] + 1];
break;
case 'swot_action':
abort_if($view['swot_review_required'], 409, 'Reveja a análise e as fontes antes de ligar uma acção.');
$data = Validator::make($input, ['conclusion_id' => ['required', Rule::in(array_column($state['swot']['rows'], 'id'))], 'kind' => 'required|in:idea,opportunity,partners,risk', 'title' => 'required|string|min:3|max:200'])->validate();
$href = $view['links'][$data['kind']];
if ($data['kind'] === 'risk') {
$href = str_replace('#tarefas', (str_contains($href, '?') ? '&' : '?').'swot_action='.$control['operation_key'].'#tarefas', $href);
}
$state['swot']['actions'][] = [...$data, ...$receipt, 'id' => $control['operation_key'], 'case_id' => $id, 'opportunity' => $view['opportunity'], 'review_version' => $state['swot']['review']['version'], 'href' => $href];
break;
case 'swot_restore':
$data = Validator::make($input, ['revision' => 'required|integer|min:1'])->validate();
$event = $this->events($id, $view['opportunity'])->where('after_data->version', $data['revision'])->first();
abort_unless($event !== null, 404);
$old = $this->decode($event)['swot'];
$state['swot'] = [...$old, 'review' => null, 'actions' => $state['swot']['actions']];
break;
}
$state['context_snapshot'] = $view['context'];
$state['reused_snapshot'] = $view['reused'];
$state['version']++;
DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'desk_cases', 'entity_id' => $id, 'external_id' => $id.':'.hash('sha256', $view['opportunity'] ?? 'dossier').':'.$state['version'],
'correlation_id' => $control['operation_key'], 'actor_user_id' => $actor->id, 'action' => $control['action'], 'data_classification' => 'restricted',
'before_data' => json_encode(['version' => $view['version']], JSON_THROW_ON_ERROR), 'after_data' => json_encode(['version' => $state['version'], 'scope' => $view['opportunity'] ?? 'dossier', 'request_hash' => $requestHash, 'snapshot' => Crypt::encryptString(json_encode($state, JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR)]);
return [...$this->view($actor, $id, $view['opportunity']), 'replayed' => false];
}, 3);
}
/** Export uses the existing verified binary reader, never a browser-supplied path.
* @return array{bytes:string,export_id:string} */
public function bundle(User $actor, string $id, ?string $opportunity, string $exportId, bool $initialScope = false): array
{
return DB::transaction(function () use ($actor, $id, $opportunity, $exportId, $initialScope) {
$this->desk->case($actor, $id, false, true);
if ($opportunity === null && ! $initialScope) {
$scopes = DB::table('audit_events')->where('source_system', self::SOURCE)->where('entity_type', 'desk_cases')->where('entity_id', $id)->where('correlation_id', $exportId)->where('action', 'apiex_export')->selectRaw("DISTINCT after_data->>'scope' as scope")->get();
abort_unless($scopes->count() === 1, 422, 'Identifique o âmbito da exportação preservada.');
$scope = $scopes->first()->scope;
$initialScope = $scope === 'dossier';
$opportunity = $initialScope ? null : $scope;
}
$view = $this->view($actor, $id, $opportunity, null, $initialScope);
$export = collect($this->rows($view['apiex']['exports']))->firstWhere('id', $exportId);
abort_unless($export !== null, 404);
$records = [];
foreach ($export['documents'] as $document) {
abort_unless(collect($this->rows($view['documents']))->contains('document_id', $document['document_id']), 403);
$record = DB::table('documents')->where('id', $document['document_id'])->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($record !== null && $record->status === 'active' && $record->malware_scan_status === 'clean' && hash_equals(trim($document['sha256']), trim($record->sha256)) && hash_equals(trim($record->sha256), trim((string) $record->malware_scanned_sha256)), 404, 'Documento da versão preparada indisponível para descarga; o histórico foi preservado.');
app(VerifiedDocumentBinary::class)->authorizeOpportunityAttachment($record);
$records[$document['document_id']] = $record;
}
$disk = Storage::disk('local');
$key = 'investor-preparations/'.$id.'/'.hash('sha256', $view['opportunity'] ?? 'dossier').'/'.$exportId.'.zip.enc';
if ($disk->exists($key)) {
try {
$sealed = json_decode(Crypt::decryptString($disk->get($key)), true, 512, JSON_THROW_ON_ERROR);
} catch (DecryptException|\JsonException $e) {
abort(404, 'Pacote preservado indisponível; não foi substituído.');
}
abort_unless(($sealed['case_id'] ?? '') === $id && ($sealed['opportunity'] ?? null) === $view['opportunity'] && ($sealed['export_id'] ?? '') === $exportId && ($sealed['fingerprint'] ?? '') === $export['fingerprint'], 404);
$bytes = base64_decode($sealed['binary'] ?? '', true);
abort_unless(is_string($bytes) && hash_equals($sealed['sha256'] ?? '', hash('sha256', $bytes)), 404);
return ['bytes' => $bytes, 'export_id' => $exportId];
}
$prepared = $export['prepared'];
abort_unless(is_array($prepared), 409, 'Snapshot de exportação indisponível; original conservado.');
$file = tempnam(sys_get_temp_dir(), 'investmoz-apiex-');
abort_unless(is_string($file), 503);
$zip = new \ZipArchive;
$open = false;
try {
abort_unless($zip->open($file, \ZipArchive::OVERWRITE) === true, 503);
$open = true;
$manifest = ['format' => 'investmoz-apiex-preparation/1', 'case_id' => $id, 'opportunity' => $view['opportunity'], 'export' => $export, 'context' => $prepared['context'], 'reused' => $prepared['reused'], 'assessment' => $prepared['assessment'], 'notice' => 'Preparação para entrega externa. Não é formulário oficial, submissão electrónica ou aprovação.'];
$index = view('investments.preparation-index', ['workspace' => $prepared, 'manifest' => $manifest])->render();
abort_unless($zip->addFromString('indice.html', $index), 503);
abort_unless($zip->addFromString('manifesto.json', json_encode($manifest, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)), 503);
foreach ($export['documents'] as $n => $document) {
$record = $records[$document['document_id']];
$response = app(VerifiedDocumentBinary::class)->respond($record);
$bytes = $response instanceof BinaryFileResponse ? file_get_contents($response->getFile()->getPathname()) : $response->getContent();
abort_unless(is_string($bytes), 503);
abort_unless($zip->addFromString('anexos/'.sprintf('%02d-', $n + 1).preg_replace('/[^\pL\pN._-]+/u', '-', basename($document['file_name'])), $bytes), 503);
}
abort_unless($zip->close(), 503);
$open = false;
$bytes = file_get_contents($file);
abort_unless(is_string($bytes), 503);
$sealed = ['case_id' => $id, 'opportunity' => $view['opportunity'], 'export_id' => $exportId, 'fingerprint' => $export['fingerprint'], 'sha256' => hash('sha256', $bytes), 'binary' => base64_encode($bytes)];
$temporary = $key.'.'.Str::uuid().'.tmp';
try {
abort_unless($disk->put($temporary, Crypt::encryptString(json_encode($sealed, JSON_THROW_ON_ERROR))) && $disk->move($temporary, $key), 503, 'Não foi possível preservar o pacote; repita a mesma exportação.');
} finally {
$disk->delete($temporary);
}
return ['bytes' => $bytes, 'export_id' => $exportId];
} finally {
if ($open) {
$zip->close();
}
if (is_file($file)) {
unlink($file);
}
}
});
}
}
@@ -18,7 +18,7 @@ class InvestmentZoneIntegralDetails
'phases' => ['code', 'status', 'starts_at', 'ends_at', 'expansion'],
'plots' => ['code', 'phase_id', 'area', 'area_unit', 'status', 'geometry_id'],
'occupants' => ['organization_id', 'documentary_name', 'phase_id', 'plot_id', 'status', 'valid_from', 'valid_until'],
'observations' => ['indicator', 'value', 'unit', 'period_start', 'period_end', 'population', 'methodology', 'phase_id', 'plot_id'],
'observations' => ['indicator', 'measure_kind', 'value', 'unit', 'period_start', 'period_end', 'population', 'methodology', 'phase_id', 'plot_id'],
'chains' => ['kind', 'organization_id', 'documentary_name', 'zone_link_id', 'phase_id', 'plot_id'],
];
@@ -59,6 +59,7 @@ class InvestmentZoneIntegralDetails
$rules['titles.*.kind'] = 'nullable|in:creation,land_use,licence,special_regime,other';
$rules['chains.*.kind'] = 'nullable|in:anchor,supplier,unmet_need,other';
$rules['observations.*.indicator'] = 'required|in:employment,investment,production,exports,local_procurement,resource_use,waste,emissions,other';
$rules['observations.*.measure_kind'] = 'nullable|in:unknown,observed,planned,projected';
$data = Validator::make($input, $rules)->validate();
$result = $before;
foreach (self::GROUPS as $group) {
@@ -148,7 +149,7 @@ class InvestmentZoneIntegralDetails
foreach ($result['observations'] as $item) {
if (! isset($item['period_start'],$item['period_end'])) {
continue;
}$key = implode('|', [$item['indicator'], $item['period_start'], $item['period_end'], $item['scope'] ?? '', $item['population'] ?? '', $item['unit'] ?? '', $item['methodology'] ?? '', $item['source_reference'] ?? '', $item['source_date'] ?? '', $item['phase_id'] ?? '', $item['plot_id'] ?? '']);
}$key = implode('|', [$item['indicator'], $item['measure_kind'] ?? 'unknown', $item['period_start'], $item['period_end'], $item['scope'] ?? '', $item['population'] ?? '', $item['unit'] ?? '', $item['methodology'] ?? '', $item['source_reference'] ?? '', $item['source_date'] ?? '', $item['phase_id'] ?? '', $item['plot_id'] ?? '']);
abort_if(in_array($key, $keys, true), 422, 'A mesma observação/fonte já está registada; altere o detalhe existente para criar a sua nova revisão.');
$keys[] = $key;
}
@@ -3,11 +3,14 @@
namespace App\Services;
use App\Models\InvestmentZone;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** Editorial preparation in the existing master. No operational/land entitlement is inferred. */
class InvestmentZoneOperationalProfile
@@ -16,7 +19,7 @@ class InvestmentZoneOperationalProfile
public const FIELDS = ['code', 'category', 'name', 'name_en', 'description', 'description_en', 'province', 'district', 'operator_id', 'legal_source_id',
'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_components', 'source_evidence',
'area_value', 'area_unit', 'aliases', 'activities', 'resources', 'data_as_of', 'sources_consulted_at', 'parent_zone_id', 'historical_territory'];
'area_value', 'area_unit', 'aliases', 'activities', 'resources', 'data_as_of', 'sources_consulted_at', 'parent_zone_id', 'historical_territory', 'primary_territory_id', 'territory_version'];
public const DETAIL_PUBLIC = ['id', 'name', 'status', 'source_reference', 'source_date', 'kind', 'scope', 'capacity_type', 'capacity_value', 'capacity_unit', 'focus', 'economic_activity_id'];
@@ -25,6 +28,159 @@ class InvestmentZoneOperationalProfile
return Schema::hasColumn('investment_zones', 'editorial_draft');
}
public function editorialEnabled(): bool
{
return (bool) config('editorial_workflow.zone_review') && $this->installed();
}
public function ensureWriteMode(InvestmentZone $zone): void
{
if (config('editorial_workflow.zone_review')) {
abort_unless($this->editorialEnabled(), 503, 'A preparação privada das zonas ainda não está instalada.');
} elseif ($zone->exists) {
abort_if(DB::table('audit_events')->where('source_system', 'investment-zones')->where('entity_id', $zone->id)->where('after_data->editorial_workflow', true)->exists(), 503, 'Reactive a revisão das zonas para preservar o histórico.');
}
}
public function reviewAccess(User $actor): bool
{
$access = app(PortalProfileAccess::class);
return $actor->hasVerifiedEmail() && $actor->two_factor_confirmed_at && $actor->two_factor_secret && ! $access->has($actor, 'auditor')
&& ($actor->can('manage-portal') || $access->superuser($actor) || $access->legacyWriteAllowed($actor));
}
private function reviewActor(User $actor): void
{
abort_unless($this->reviewAccess($actor) && app(PortalProfileAccess::class)->mfa($actor), 403, 'Âmbito administrativo e MFA válido necessários.');
}
public function authorizeWrite(int $actor): void
{
$this->reviewActor(User::findOrFail($actor));
}
/** @param array<array-key,mixed> $draft */
private function reviewHash(InvestmentZone $zone, array $draft): string
{
$context = ['public_fields' => Arr::only($zone->getRawOriginal(), [...self::FIELDS, 'primary_territory_id', 'territory_version', 'operational_details']), 'locations' => [], 'documents' => []];
foreach (['legal_source_id' => 'investment_legal_sources', 'operator_id' => 'organizations', 'primary_territory_id' => 'geo_locations'] as $field => $table) {
$id = $draft['fields'][$field] ?? $zone->getAttribute($field);
if ($id) {
$row = DB::table($table)->where('id', $id)->first();
abort_unless($row !== null, 422, 'Fonte ou contexto da zona indisponível.');
$context[$field] = Arr::except(get_object_vars($row), ['created_at', 'updated_at']);
}
}
$context['locations'] = DB::table('investment_zone_locations')->where('zone_id', $zone->id)->orderBy('id')->get()->map(fn ($row) => Arr::except(get_object_vars($row), ['created_at', 'updated_at']))->all();
$geography = [];
$locations = array_filter([$draft['fields']['primary_territory_id'] ?? $zone->getAttribute('primary_territory_id'), ...array_column($context['locations'], 'geo_location_id')]);
foreach (array_unique($locations) as $location) {
$seen = [];
while ($location) {
abort_if(in_array($location, $seen, true) || count($seen) >= 5, 422, 'Hierarquia territorial incoerente.');
$seen[] = $location;
$node = DB::table('geo_locations')->where('id', $location)->whereNull('deleted_at')->first();
abort_unless($node !== null, 422, 'Contexto territorial indisponível.');
$geography[$node->id] = Arr::only(get_object_vars($node), ['id', 'name', 'parent_id', 'location_type', 'version_no', 'data_classification', 'source_system', 'source_code']);
$location = $node->parent_id;
}
}
ksort($geography);
$context['geography'] = $geography;
foreach (DB::table('investment_zone_attachments')->where('zone_id', $zone->id)->orderBy('id')->get() as $attachment) {
$document = DB::table('documents')->where('id', $attachment->document_id)->first();
abort_unless($document !== null, 422, 'Anexo indisponível.');
if ($document->status === 'active' && $document->malware_scan_status === 'clean' && $document->deleted_at === null) {
app(VerifiedDocumentBinary::class)->respond($document);
}
$context['documents'][] = ['attachment' => Arr::except(get_object_vars($attachment), ['created_at', 'updated_at']), 'document' => Arr::only(get_object_vars($document), ['id', 'sha256', 'version_no', 'status', 'deleted_at', 'rights_status', 'malware_scan_status', 'malware_scanned_sha256'])];
}
return hash('sha256', json_encode(Arr::sortRecursive(['fields' => $draft['fields'], 'context' => $context, 'contributors' => $draft['editorial']['contributors'] ?? []]), JSON_THROW_ON_ERROR));
}
/** @param array<array-key,mixed> $draft */
private function freshReview(InvestmentZone $zone, array $draft): bool
{
try {
return (int) $draft['base_public_version'] === (int) $zone->version_no && hash_equals($draft['editorial']['hash'], $this->reviewHash($zone, $draft));
} catch (HttpException $exception) {
if (in_array($exception->getStatusCode(), [404, 409, 422], true)) {
return false;
}
throw $exception;
}
}
/** @param array<array-key,mixed> $workflow */
private function independent(array $workflow): bool
{
return ($workflow['independent_required'] ?? false) || in_array('zone_statistics', config('editorial_workflow.independent_approval.kinds', []), true);
}
/** @param array<array-key,mixed> $draft
* @param array<array-key,mixed>|null $previous
* @return array<array-key,mixed>
*/
private function newReview(InvestmentZone $zone, array $draft, int $actor, ?array $previous): array
{
$draft['editorial'] = ['state' => 'draft', 'content_version' => $draft['working_version'], 'contributors' => array_values(array_unique([...($previous['editorial']['contributors'] ?? []), $actor])),
'independent_required' => ($previous['editorial']['independent_required'] ?? false) || in_array('zone_statistics', config('editorial_workflow.independent_approval.kinds', []), true), 'approval' => null];
$draft['editorial']['hash'] = $this->reviewHash($zone, $draft);
return $draft;
}
/** @return array<array-key,mixed> */
public function review(string $id, User $actor, int $expected, string $action, string $reason): array
{
$this->reviewActor($actor);
validator(['reason' => $reason], ['reason' => 'required|string|min:10|max:3000'])->validate();
abort_unless($this->editorialEnabled(), 503);
return DB::transaction(function () use ($id, $actor, $expected, $action, $reason) {
$zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();
$draft = $this->draft($zone);
abort_unless($expected === $this->workingVersion($zone), 409, 'A preparação mudou. Conserve os campos e confira a versão actual.');
if (! $draft || ! isset($draft['editorial'])) {
abort_unless($action === 'submit', 409, 'Prepare a revisão antes de decidir.');
$zone = $this->prepare($zone, ['version_no' => $expected], $actor->id);
$draft = $this->draft($zone);
}
abort_unless($draft !== null && isset($draft['editorial']), 409);
$workflow = $draft['editorial'];
$allowed = ['submit' => ['draft', 'returned'], 'start_review' => ['submitted'], 'approve' => ['in_review'], 'return' => ['in_review'], 'reject' => ['in_review'], 'archive' => ['draft', 'submitted', 'in_review', 'approved', 'returned', 'rejected']];
abort_unless(isset($allowed[$action]) && in_array($workflow['state'], $allowed[$action], true), 409);
if (in_array($action, ['submit', 'approve'], true)) {
abort_unless($this->freshReview($zone, $draft), 409, 'Conteúdo, fonte ou anexos alterados. Guarde nova edição e volte a conferir.');
}
if ($action === 'approve') {
abort_if($this->independent($workflow) && in_array($actor->id, $workflow['contributors'], true), 403, 'Esta edição exige aprovação independente.');
$workflow['approval'] = ['actor_id' => $actor->id, 'content_version' => $workflow['content_version'], 'hash' => $workflow['hash'], 'reason' => $reason, 'at' => now()->toISOString()];
}
$workflow['state'] = ['submit' => 'submitted', 'start_review' => 'in_review', 'approve' => 'approved', 'return' => 'returned', 'reject' => 'rejected', 'archive' => 'archived'][$action];
$draft['editorial'] = $workflow;
$draft['working_version'] = $this->nextVersion($zone);
DB::table('investment_zones')->where('id', $id)->update(['editorial_draft' => Crypt::encryptString(json_encode($draft, JSON_THROW_ON_ERROR))]);
$this->snapshot($zone, $draft['working_version'], $actor->id, 'zone_editorial_'.$action, ['draft' => $draft, 'reason' => $reason]);
return $this->management($zone->fresh());
});
}
public function suspensionPreservesReview(InvestmentZone $zone, int $actor): void
{
$draft = $this->draft($zone);
if (! $this->editorialEnabled() || ! $draft) {
return;
}
$draft['base_public_version'] = (int) $zone->version_no;
$draft['working_version'] = $this->nextVersion($zone);
DB::table('investment_zones')->where('id', $zone->id)->update(['editorial_draft' => Crypt::encryptString(json_encode($draft, JSON_THROW_ON_ERROR))]);
$this->snapshot($zone, $draft['working_version'], $actor, 'zone_editorial_suspension_rebased', ['draft' => $draft]);
}
/**
* @return array<array-key, mixed>|null
*/
@@ -60,8 +216,15 @@ class InvestmentZoneOperationalProfile
public function management(InvestmentZone $zone): array
{
$draft = $this->draft($zone);
$workflow = $draft['editorial'] ?? null;
if ($workflow && $draft) {
$workflow['fresh'] = $this->freshReview($zone, $draft);
$workflow['independent_required'] = $this->independent($workflow);
$viewer = auth()->user();
$workflow['can_approve'] = ! $workflow['independent_required'] || ($viewer instanceof User && ! in_array($viewer->id, $workflow['contributors'], true));
}
return [...$zone->toArray(), ...($draft['fields'] ?? []), 'version_no' => $draft['working_version'] ?? $zone->version_no,
return [...$zone->toArray(), ...($draft['fields'] ?? []), 'version_no' => $draft['working_version'] ?? $zone->version_no, 'editorial_enabled' => $this->editorialEnabled(), 'editorial_workflow' => $workflow,
'public_version_no' => $zone->version_no, 'base_public_version' => $draft['base_public_version'] ?? $zone->version_no,
'has_private_draft' => $draft !== null, 'profile_available' => $this->installed(),
'operational_details' => ($draft['fields']['operational_details'] ?? app(InvestmentZoneOperationalDetails::class)->recover($zone)),
@@ -118,6 +281,10 @@ class InvestmentZoneOperationalProfile
*/
public function prepare(InvestmentZone $zone, array $data, int $actor): InvestmentZone
{
$this->ensureWriteMode($zone);
if ($this->editorialEnabled()) {
$this->reviewActor(User::findOrFail($actor));
}
abort_unless($this->installed(), 503, 'A preparação privada requer a instalação nominal da ficha de parques. A publicação anterior foi preservada.');
abort_unless((int) $data['version_no'] === $this->workingVersion($zone), 409, 'A preparação mudou; recupere a versão atual antes de guardar.');
$previous = $this->draft($zone);
@@ -142,19 +309,38 @@ class InvestmentZoneOperationalProfile
}
$draft = ['schema_version' => 1, 'base_public_version' => $previous['base_public_version'] ?? (int) $zone->version_no,
'working_version' => $this->nextVersion($zone), 'fields' => $fields, 'actor_id' => $actor, 'prepared_at' => now()->toIso8601String()];
if ($this->editorialEnabled()) {
$draft = $this->newReview($zone, $draft, $actor, $previous);
}
DB::table('investment_zones')->where('id', $zone->id)->update(['editorial_draft' => Crypt::encryptString(json_encode($draft, JSON_THROW_ON_ERROR))]);
$this->snapshot($zone, $draft['working_version'], $actor, 'profile_prepared', ['public' => [...$zone->toArray(), 'operational_details' => $details->stored($zone)], 'draft' => $draft]);
return $zone->fresh();
}
/** Apply only inside the publication transaction; a failed validation rolls everything back. */
public function promote(InvestmentZone $zone, int $version): void
/** Apply only inside the publication transaction; a failed validation rolls everything back.
* @return array<array-key,mixed>|null
*/
public function promote(InvestmentZone $zone, int $version, ?int $actor = null): ?array
{
$draft = $this->draft($zone);
abort_unless($version === $this->workingVersion($zone), 409, 'A preparação mudou; recupere a versão atual antes de publicar.');
if (! $draft) {
return;
abort_if($this->editorialEnabled(), 409, 'Prepare e aprove a revisão antes de publicar.');
return null;
}
if ($this->editorialEnabled()) {
$this->lockReviewContext($zone, $draft);
$publisher = User::where('id', $actor)->firstOrFail();
$this->reviewActor($publisher);
$review = $draft['editorial'] ?? null;
abort_unless($review && $review['state'] === 'approved' && $this->freshReview($zone, $draft), 409, 'Aprovação desta edição necessária.');
$approval = $review['approval'];
abort_unless($approval && $approval['content_version'] === $review['content_version'] && hash_equals($approval['hash'], $review['hash']), 409, 'A aprovação não corresponde à edição.');
$reviewer = User::where('id', $approval['actor_id'])->first();
abort_unless($reviewer && $this->reviewAccess($reviewer), 403, 'Autoridade da decisão revogada.');
abort_if($this->independent($review) && (in_array($reviewer->id, $review['contributors'], true) || in_array($publisher->id, $review['contributors'], true)), 403, 'Esta edição exige aprovação/publicação independente.');
}
abort_unless((int) $draft['base_public_version'] === (int) $zone->version_no, 409, 'A publicação base mudou. Reconcilie a preparação antes de publicar; ambos os estados foram preservados.');
$zone->fill(array_intersect_key($draft['fields'], array_flip(self::FIELDS)));
@@ -166,10 +352,39 @@ class InvestmentZoneOperationalProfile
$zone->setAttribute($key, $this->publicDetails([$key => $zone->$key ?? []])[$key]);
}
$zone->setAttribute('editorial_draft', null);
return $draft['editorial'] ?? null;
}
/** @param array<array-key,mixed> $draft */
private function lockReviewContext(InvestmentZone $zone, array $draft): void
{
foreach (['legal_source_id' => 'investment_legal_sources', 'operator_id' => 'organizations'] as $field => $table) {
$id = $draft['fields'][$field] ?? $zone->getAttribute($field);
if ($id) {
DB::table($table)->where('id', $id)->lockForUpdate()->first();
}
}
$locations = DB::table('investment_zone_locations')->where('zone_id', $zone->id)->orderBy('id')->lockForUpdate()->pluck('geo_location_id')->all();
$locations[] = $draft['fields']['primary_territory_id'] ?? $zone->getAttribute('primary_territory_id');
$all = [];
foreach (array_filter($locations) as $location) {
for ($i = 0; $location && $i < 5; $i++) {
$all[] = $location;
$location = DB::table('geo_locations')->where('id', $location)->value('parent_id');
}
}
DB::table('geo_locations')->whereIn('id', array_unique($all))->orderBy('id')->lockForUpdate()->get();
$documents = DB::table('investment_zone_attachments')->where('zone_id', $zone->id)->orderBy('id')->lockForUpdate()->pluck('document_id')->all();
DB::table('documents')->whereIn('id', $documents)->orderBy('id')->lockForUpdate()->get();
}
public function reconcile(InvestmentZone $zone, int $working, int $public, int $actor): InvestmentZone
{
$this->ensureWriteMode($zone);
if ($this->editorialEnabled()) {
$this->reviewActor(User::findOrFail($actor));
}
$draft = $this->draft($zone);
abort_unless($draft && $working === (int) $draft['working_version'] && $public === (int) $zone->version_no, 409, 'Versões alteradas; recupere a ficha pública e a preparação antes de reconciliar.');
abort_if((int) $draft['base_public_version'] === $public, 422, 'A preparação já corresponde à versão pública atual.');
@@ -177,6 +392,9 @@ class InvestmentZoneOperationalProfile
$draft['working_version'] = $this->nextVersion($zone);
$draft['actor_id'] = $actor;
$draft['prepared_at'] = now()->toIso8601String();
if ($this->editorialEnabled()) {
$draft = $this->newReview($zone, $draft, $actor, $draft);
}
DB::table('investment_zones')->where('id', $zone->id)->update(['editorial_draft' => Crypt::encryptString(json_encode($draft, JSON_THROW_ON_ERROR))]);
$this->snapshot($zone, $draft['working_version'], $actor, 'profile_reconciled', ['public' => [...$zone->toArray(), 'operational_details' => app(InvestmentZoneOperationalDetails::class)->stored($zone)], 'draft' => $draft]);
@@ -192,7 +410,15 @@ class InvestmentZoneOperationalProfile
$hash = hash('sha256', $json);
DB::table('investment_zone_revisions')->insert(['id' => (string) Str::uuid(), 'zone_id' => $zone->id, 'version_no' => $version,
'actor_id' => $actor, 'action' => $action, 'snapshot' => Crypt::encryptString($json), 'sha256' => $hash]);
DB::table('audit_events')->insert(['source_system' => 'investment-zones', 'entity_type' => 'investment_zones', 'entity_id' => $zone->id,
'actor_user_id' => $actor, 'action' => $action, 'after_data' => json_encode(['version_no' => $version, 'sha256' => $hash], JSON_THROW_ON_ERROR)]);
$eventId = (string) Str::uuid();
$metadata = ['version_no' => $version, 'sha256' => $hash];
if ($this->editorialEnabled()) {
$metadata += ['editorial_workflow' => true, 'editorial_state' => $data['draft']['editorial']['state'] ?? ($action === 'published' ? 'published' : null)];
}
DB::table('audit_events')->insert(['id' => $eventId, 'source_system' => 'investment-zones', 'entity_type' => 'investment_zones', 'entity_id' => $zone->id,
'actor_user_id' => $actor, 'action' => $action, 'after_data' => json_encode($metadata, JSON_THROW_ON_ERROR)]);
if ($this->editorialEnabled()) {
app(PortalEditorialNativeNotices::class)->emit(User::findOrFail($actor), $eventId, $data['draft']['editorial']['contributors'] ?? $data['decision']['contributors'] ?? []);
}
}
}
+28 -10
View File
@@ -96,9 +96,14 @@ class InvestmentZones
{
return DB::transaction(function () use ($id, $data, $actor) {
$zone = $id ? InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail() : new InvestmentZone;
if ($id && $zone->category === 'industrial_park') {
$prepared = app(InvestmentZoneOperationalProfile::class)->prepare($zone, $data, $actor);
$profile = app(InvestmentZoneOperationalProfile::class);
$profile->ensureWriteMode($zone);
if ($profile->editorialEnabled()) {
$profile->authorizeWrite($actor);
}
if ($id && ($zone->category === 'industrial_park' || $profile->editorialEnabled())) {
$this->storePendingLocations($zone, $data['locations'] ?? [], $actor);
$prepared = app(InvestmentZoneOperationalProfile::class)->prepare($zone, $data, $actor);
return $prepared;
}
@@ -128,8 +133,10 @@ class InvestmentZones
return DB::transaction(function () use ($id, $version, $actor) {
$zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();
$profile = app(InvestmentZoneOperationalProfile::class);
if ($zone->category === 'industrial_park') {
$profile->promote($zone, $version);
$profile->ensureWriteMode($zone);
$proof = null;
if ($zone->category === 'industrial_park' || $profile->editorialEnabled()) {
$proof = $profile->promote($zone, $version, $actor);
} else {
abort_if((int) $zone->version_no !== $version, 409, 'Versão alterada. Recarregue antes de aprovar.');
}
@@ -158,9 +165,9 @@ class InvestmentZones
->where('zl.zone_id', $id)->where('zl.validation_status', 'validated')->where('g.data_classification', 'public')->whereNull('g.deleted_at')
->whereIn('g.id', app(InvestmentGeography::class)->publicReferences()->select('id'))->exists();
abort_unless($publicLocation, 422, 'Seleccione uma província ou distrito existente e público.');
$zone->fill(['version_no' => $zone->category === 'industrial_park' ? $profile->nextVersion($zone) : $version + 1, 'reviewer_id' => $actor, 'publication_status' => 'published',
$zone->fill(['version_no' => $zone->category === 'industrial_park' || $profile->editorialEnabled() ? $profile->nextVersion($zone) : $version + 1, 'reviewer_id' => $actor, 'publication_status' => 'published',
'validation_status' => 'validated', 'published_at' => now()])->save();
$this->snapshot($zone, $actor, 'published');
$this->snapshot($zone, $actor, 'published', $proof);
return $zone;
});
@@ -171,11 +178,16 @@ class InvestmentZones
return DB::transaction(function () use ($id, $version, $actor) {
$zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();
$profile = app(InvestmentZoneOperationalProfile::class);
abort_if(($zone->category === 'industrial_park' ? $profile->workingVersion($zone) : (int) $zone->version_no) !== $version, 409, 'Versão alterada.');
$zone->fill(['version_no' => $zone->category === 'industrial_park' ? $profile->nextVersion($zone) : $version + 1, 'publication_status' => 'suspended'])->save();
$profile->ensureWriteMode($zone);
if ($profile->editorialEnabled()) {
$profile->authorizeWrite($actor);
}
abort_if(($zone->category === 'industrial_park' || $profile->editorialEnabled() ? $profile->workingVersion($zone) : (int) $zone->version_no) !== $version, 409, 'Versão alterada.');
$zone->fill(['version_no' => $zone->category === 'industrial_park' || $profile->editorialEnabled() ? $profile->nextVersion($zone) : $version + 1, 'publication_status' => 'suspended'])->save();
$this->snapshot($zone, $actor, 'suspended');
$profile->suspensionPreservesReview($zone, $actor);
return $zone;
return $profile->editorialEnabled() ? $zone->fresh() : $zone;
});
}
@@ -203,8 +215,14 @@ class InvestmentZones
});
}
private function snapshot(InvestmentZone $zone, int $actor, string $action): void
/** @param array<array-key,mixed>|null $decision */
private function snapshot(InvestmentZone $zone, int $actor, string $action, ?array $decision = null): void
{
if (app(InvestmentZoneOperationalProfile::class)->editorialEnabled()) {
app(InvestmentZoneOperationalProfile::class)->snapshot($zone, (int) $zone->version_no, $actor, $action, ['public' => [...$zone->toArray(), 'operational_details' => app(InvestmentZoneOperationalDetails::class)->stored($zone)], 'decision' => $decision]);
return;
}
$json = json_encode([...$zone->toArray(), 'operational_details' => app(InvestmentZoneOperationalDetails::class)->stored($zone)], JSON_THROW_ON_ERROR);
DB::table('investment_zone_revisions')->insert(['id' => (string) Str::uuid(), 'zone_id' => $zone->id,
'version_no' => $zone->version_no, 'actor_id' => $actor, 'action' => $action,
+14
View File
@@ -142,6 +142,9 @@ final class InvestorContext
$zone = $context['selection']['park'] ?? null;
$context['fields']['investment_regime'] = $zone['fields']['investment_regime'] ?? 'general';
$context['fields']['special_zone_id'] = $zone['id'] ?? '';
if (! empty($payload['investor_path']['sector_references'])) {
$context['sector_references'] = $payload['investor_path']['sector_references'];
}
return $context;
}
@@ -190,6 +193,17 @@ final class InvestorContext
$href = $published ? '/oportunidades/'.$o->slug : '/minhas-oportunidades/'.$o->id.'/view';
$version = $o->management_version;
$fields = ['project_title' => $title, 'project_summary' => $draft['canonical']['executive_summary'] ?? $o->summary];
if ($o->isPrivateOfferPreparation()) {
// Offers reuse Opportunity, but their preparation writes description,
// not the canonical executive summary. Keep this authorised private
// projection explicit: never copy private_offer (price/finances/contact).
$title = $draft['title'] ?? $o->title;
$summary = $fields['project_summary'];
$fields['project_title'] = $title;
$fields['project_summary'] = is_string($summary) && trim($summary) !== ''
? $summary : ($draft['description'] ?? null);
$href = '/minhas-oportunidades/ofertas/'.$o->id;
}
if ($o->sector) {
$fields['sector_id'] = (string) $o->sector_id;
$fields['sector'] = $o->sector->name;
+26 -5
View File
@@ -8,6 +8,7 @@ use App\Models\SimulationFormDraft;
use App\Models\User;
use App\Support\StructuredData;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -160,7 +161,9 @@ final class InvestorPath
$next = ['code' => 'S06', 'title' => $preparation['next'], 'action' => 'Preparar', 'href' => '/investimentos/'.$id.'/contexto/editar#preparation-questions'];
}
return ['private_investment' => $preparation, 'case' => ['id' => $id, 'title' => $case->title, 'version' => $case->version, 'closed' => $case->closed_at !== null],
$preparationTaskKeys = $opportunity !== null && ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? app(InvestmentPreparationWorkspace::class)->taskKeys($actor, $id, $opportunity) : null;
return ['funding_selections' => ($owner || $manager) ? array_values(array_filter($path['funding_selections'] ?? [], fn ($row) => ($row['opportunity'] ?? null) === $opportunity)) : [], 'preparation_action' => ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? app(InvestmentPreparationWorkspace::class)->continuation($actor, $id, $opportunity, request()->query('swot_action')) : null, 'private_investment' => $preparation, 'case' => ['id' => $id, 'title' => $case->title, 'version' => $case->version, 'closed' => $case->closed_at !== null],
'journey_id' => $journey->id, 'definition_id' => $version->id, 'definition_version' => $version->version_no, 'source_sha256' => $definition['source_sha256'],
'phases' => $definition['phases'], 'steps' => $steps, 'facts' => $facts, 'fact_reasons' => $factReasons,
'context_opportunity' => $opportunity, 'opportunity_options' => $allChoices,
@@ -169,15 +172,29 @@ final class InvestorPath
'next' => $next ? ['code' => $next['code'], 'title' => $next['title'], 'action' => 'Preparar', 'href' => $next['href'] ?? '#'.$next['code']] : null,
'investment_context' => ($owner || $manager) ? [...$context, 'opportunity_choices' => $opportunity ? $context['opportunity_choices'] : $allChoices] : null,
'results' => $results, 'requests' => $requests, 'documents' => $documents,
'fiscal_reports' => (int) $case->user_id === $actor->id && (count($allChoices) < 2 || $opportunity !== null)
? app(SimulationFormDrafts::class)->dossierReportSources($actor, $id, $opportunity) : [],
'land_report' => $landCase ? ['id' => $landCase->id, 'version' => $landCase->version, 'updated_at' => $landCase->updated_at?->toIso8601String(),
'href' => '/investidor/terra-duat/'.$landCase->id.'/imprimir'] : null,
'preparation_report' => ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() && (count($allChoices) < 2 || $opportunity !== null)
? app(InvestmentPreparationWorkspace::class)->reportSummary($actor, $id, $opportunity) : null,
'company_reports' => ($owner || $manager) ? array_filter([
'comparison' => isset($payload['company_comparison']) ? Arr::only($payload['company_comparison'], ['revision', 'at']) : null,
'preparation' => isset($payload['company_preparation']) ? Arr::only($payload['company_preparation'], ['revision', 'at']) : null,
'guides' => array_map(fn ($guide) => ['revision' => $guide['revision'], 'at' => $guide['at'], 'slug' => $guide['guide']['slug'], 'title' => $guide['guide']['title'] ?? $guide['guide']['slug']], $payload['investment_guides'] ?? []),
]) : [],
'zone_contexts' => ($owner || $manager) ? ($path['zone_contexts'] ?? []) : [],
'sector_contexts' => ($owner || $manager) ? ($path['sector_contexts'] ?? []) : [],
'sector_references' => ($owner || $manager) ? ($path['sector_references'] ?? []) : [],
'content_sources' => ($owner || $manager) ? ($path['content_sources'] ?? []) : [],
'tasks' => DB::table('tasks')->where('desk_case_id', $id)
->whereNull('deleted_at')
->when($preparationTaskKeys !== null, fn ($q) => $q->where(fn ($p) => $p->whereNull('guidance_key')->orWhere('guidance_key', 'not like', 'swot:%')->orWhereIn('guidance_key', $preparationTaskKeys ?? [])))
->when(! $owner && ! $manager, fn ($q) => $q->where('assignee_user_id', $actor->id))
->get(['id', 'title', 'status', 'version_no', 'due_at', 'assignee_user_id']),
'services' => app(DeskServiceAvailability::class)->current()->get()->map(fn ($s) => ['id' => $s->id, 'title' => json_decode($s->definition, true)['title'], 'effective_from' => $s->effective_from ?? null]),
'tools' => ['simulator' => (int) $case->user_id === $actor->id ? '/simulador-fiscal?context='.$id.($case->simulation_id ? '&draft='.$case->simulation_id : '').($opportunity ? '&opportunity='.urlencode($opportunity) : '') : null,
'tools' => ['funding' => ($owner || $manager) && app(FundingProgrammes::class)->installed() ? '/fundos?kind=institution&location_mode=physical&context='.$id.($opportunity ? '&opportunity='.urlencode($opportunity) : '') : null, 'simulator' => (int) $case->user_id === $actor->id ? '/simulador-fiscal?context='.$id.($case->simulation_id ? '&draft='.$case->simulation_id : '').($opportunity ? '&opportunity='.urlencode($opportunity) : '') : null,
'preparation' => ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? '/investimentos/'.$id.'/preparacao'.($opportunity ? '?opportunity='.urlencode($opportunity) : '') : null,
'company' => '/investimentos/'.$id.'/forma-juridica',
'desk' => '/balcao-virtual/dossiers/'.$id.($opportunity ? '?opportunity='.urlencode($opportunity) : ''), 'guidance' => '/balcao-virtual/dossiers/'.$id.'/orientacao',
'library' => '/documentos?context='.$id, 'opportunities' => '/oportunidades?context='.$id, 'territory' => '/zonas?context='.$id, 'partners' => '/parcerias/painel?context='.$id,
@@ -337,12 +354,16 @@ final class InvestorPath
public function task(User $actor, string $id, array $input): string
{
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'code' => ['required', Rule::in(array_map(fn ($n) => sprintf('S%02d', $n), range(1, 19)))],
'preparation_action' => 'nullable|uuid', 'opportunity' => 'nullable|string|max:150',
'title' => 'required|string|min:3|max:200', 'due_at' => 'required|date|after:now', 'consent' => 'accepted'])->validate();
return DB::transaction(function () use ($actor, $id, $data): string {
$case = $this->desk->case($actor, $id, true, true);
abort_if($case->closed_at, 409);
$key = 'path:'.$data['code'];
if (!empty($data['preparation_action'])) { app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $data['opportunity'] ?? null, true); }
$origin = !empty($data['preparation_action']) ? app(InvestmentPreparationWorkspace::class)->continuation($actor, $id, $data['opportunity'] ?? null, $data['preparation_action']) : null;
abort_if(!empty($data['preparation_action']) && (!$origin || $origin['kind'] !== 'risk'), 422, 'A acção SWOT não pertence a este dossier/oportunidade ou não é uma acção de risco.');
$key = $origin ? 'swot:'.$origin['id'] : 'path:'.$data['code'];
$existing = DB::table('tasks')->where('desk_case_id', $id)->where('guidance_key', $key)->first();
if ($existing) {
abort_unless($existing->title === $data['title'] && (int) $existing->accepted_by === $actor->id
@@ -351,12 +372,12 @@ final class InvestorPath
return $existing->id;
}
$this->writable($actor, $id, $data['version']);
$step = $this->findStep($this->view($actor, $id), $data['code']);
$step = $this->findStep($this->view($actor, $id, $origin['opportunity'] ?? null), $data['code']);
abort_unless($step !== null && $step['preparation_allowed'], 422);
$task = CrmTask::create(['desk_case_id' => $id, 'guidance_key' => $key, 'title' => $data['title'], 'due_at' => $data['due_at'],
'status' => 'open', 'assignee_user_id' => $actor->id, 'accepted_by' => $actor->id, 'accepted_at' => now(),
'external_sync_status' => 'pending_authorization', 'data_classification' => 'confidential', 'source_system' => 'investor-path']);
$this->desk->event($case, $actor, 'path_task_accepted', ['task_id' => $task->id, 'code' => $data['code'], 'scope' => 'internal_crm']);
$this->desk->event($case, $actor, 'path_task_accepted', ['task_id' => $task->id, 'code' => $data['code'], 'scope' => 'internal_crm', 'preparation_action' => $origin['id'] ?? null, 'opportunity' => $origin['opportunity'] ?? null, 'swot_review_version' => $origin['review_version'] ?? null]);
return $task->id;
});
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use Carbon\CarbonImmutable;
/** Read-time exclusion never depends on the scheduler or the NDA. */
final class OfferAvailability
{
/** @return array{state:string,expires_at:?string,policy_pending:bool} */
public function view(mixed $operation, mixed $publication): array
{
// Legacy public snapshots without an editorial hash retain their first frozen policy.
$key = is_array($publication) ? ($publication['editorial_hash'] ?? '') : null;
$key ??= is_array($operation) ? ($operation['last_approval_hash'] ?? '') : '';
if (! is_string($key)) {
return ['state' => 'unavailable', 'expires_at' => null, 'policy_pending' => true];
}
$policy = is_array($operation) ? ($operation['validity_policies'][$key] ?? $operation['validity'] ?? null) : null;
$pending = ! is_array($policy) || ($policy['days'] ?? null) === null;
$result = ['state' => 'available', 'expires_at' => null, 'policy_pending' => $pending];
if ($operation !== null && ! is_array($operation)) {
return [...$result, 'state' => 'unavailable'];
}
$state = $operation['status'] ?? 'available';
if (! is_int($operation['cycle'] ?? 0) || ($operation['cycle'] ?? 0) < 0) {
return [...$result, 'state' => 'unavailable'];
}
if (! in_array($state, ['available', 'withdrawn', 'expired', 'revalidation'], true)) {
return [...$result, 'state' => 'unavailable'];
}
if ($state !== 'available') {
return [...$result, 'state' => $state];
}
$days = $policy['days'] ?? null;
if (($operation['cycle'] ?? 0) > 0 && ($publication['offer_cycle'] ?? null) !== $operation['cycle']) {
return $result;
}
if ($days === null || ! is_array($publication) || ($publication['publication']['state'] ?? null) !== 'published') {
return $result;
}
if (! is_int($days) || $days < 1 || $days > 36500) {
return [...$result, 'state' => 'unavailable'];
}
try {
$at = $publication['publication']['at'] ?? null;
if (! is_string($at) || ! preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/D', $at)) {
return [...$result, 'state' => 'unavailable'];
}
$start = CarbonImmutable::parse($at);
$end = $start->addDays($days)->utc();
} catch (\Throwable) {
return [...$result, 'state' => 'unavailable'];
}
return [...$result, 'expires_at' => $end->toIso8601String(), 'state' => $end->lte(CarbonImmutable::now()) ? 'expired' : 'available'];
}
/** @return array{state:string,expires_at:?string,policy_pending:bool} */
public function record(Opportunity $record): array
{
return $this->view($record->workflow_context['offer_operation'] ?? null, $record->workflow_context['offer_publication'] ?? null);
}
public function publicAllowed(mixed $operation, mixed $publication): bool
{
if ($this->view($operation, $publication)['state'] !== 'available') {
return false;
}
$cycle = $operation['cycle'] ?? 0;
if (! is_int($cycle) || $cycle < 0) {
return false;
}
// A new validation must never resurrect the previous public snapshot.
return $cycle === 0 || (is_array($publication) && ($publication['offer_cycle'] ?? null) === $cycle
&& is_string($operation['activated_hash'] ?? null) && ($publication['editorial_hash'] ?? null) === $operation['activated_hash']);
}
}
+250
View File
@@ -0,0 +1,250 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Support\IsolatedQaDatabase;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/** Read-only consumer. Effective publication remains unavailable until confidentiality is delivered.
* The injected fixture mode is never configurable by HTTP, environment flags or stored content. */
final class OfferPublicCatalogue
{
public const CONTRACT = 'offer-public-projection-v1';
public function __construct(private bool $syntheticFixtureMode = false) {}
private function fixtureReadAllowed(): bool
{
return $this->syntheticFixtureMode && IsolatedQaDatabase::allowsSchemaChanges();
}
/** Exact ordering of decimal strings, without currency conversion or floating point.
* @param string $a @param string $b */
public static function comparePrice(string $a, string $b): int
{
$parts = static function (string $value): array {
[$whole, $fraction] = [...explode('.', $value), ''];
return [ltrim($whole, '0') ?: '0', str_pad($fraction, 2, '0')];
};
[$aw, $af] = $parts($a);
[$bw, $bf] = $parts($b);
return (strlen($aw) <=> strlen($bw)) ?: (strcmp($aw, $bw) <=> 0) ?: (strcmp($af, $bf) <=> 0);
}
/** @return array<string,mixed> */
public function filters(Request $request): array
{
$filters = array_intersect_key(app(InvestmentCatalogue::class)->filters($request, true), array_flip([
'q', 'category', 'province_id', 'district_id', 'currency', 'min_amount', 'max_amount', 'sort', 'view', 'page', 'per_page',
]));
$extra = $request->validate(['price_mode' => 'nullable|in:amount,negotiable']);
$filters = [...$filters, ...$extra];
if (isset($filters['min_amount'], $filters['max_amount']) && self::comparePrice($filters['min_amount'], $filters['max_amount']) > 0) {
throw ValidationException::withMessages(['max_amount' => 'O preço máximo deve ser igual ou superior ao mínimo.']);
}
if (in_array($filters['sort'] ?? '', ['amount_asc', 'amount_desc'], true) && empty($filters['currency'])) {
throw ValidationException::withMessages(['currency' => 'Seleccione uma moeda para ordenar por preço.']);
}
if (($filters['price_mode'] ?? '') === 'negotiable' && (isset($filters['min_amount']) || isset($filters['max_amount']))) {
throw ValidationException::withMessages(['price_mode' => 'Retire o intervalo de montantes para procurar preços a negociar.']);
}
if (($filters['price_mode'] ?? '') === 'negotiable' && ! empty($filters['currency'])) {
throw ValidationException::withMessages(['currency' => 'A moeda de um preço a negociar não foi indicada. Retire este filtro.']);
}
return $filters;
}
/** The explicit public snapshot, never a projection of current private columns.
* @return array<string,mixed>|null */
private function project(int $id, mixed $publication): ?array
{
if (! is_array($publication) || ($publication['contract'] ?? null) !== self::CONTRACT || ($publication['qa_fixture'] ?? false) !== true) {
return null;
}
$v = Validator::make($publication, [
'payload' => 'required|array:title,summary,category,province_id,district_id,price_mode,amount,currency',
'payload.title' => 'required|string|max:150', 'payload.summary' => 'required|string|max:1500',
'payload.category' => ['required', Rule::in(OpportunityManagement::CATEGORIES)],
'payload.province_id' => 'nullable|uuid', 'payload.district_id' => 'nullable|uuid',
'payload.price_mode' => 'required|in:amount,negotiable',
'payload.amount' => ['nullable', 'string', 'required_if:payload.price_mode,amount', 'prohibited_unless:payload.price_mode,amount', 'regex:/^\d{1,16}(\.\d{1,2})?$/'],
'payload.currency' => 'nullable|required_if:payload.price_mode,amount|prohibited_unless:payload.price_mode,amount|in:MZN,USD',
'approval.state' => 'required|in:approved', 'approval.hash' => 'required|string|size:64', 'approval.actor_id' => 'required|integer|min:1',
'approval.at' => 'required|date', 'publication.state' => 'required|in:published', 'publication.hash' => 'required|string|size:64',
'publication.actor_id' => 'required|integer|min:1|different:approval.actor_id', 'publication.at' => 'required|date|after_or_equal:approval.at|before_or_equal:now',
'revision' => 'required|integer|min:1',
]);
if ($v->fails()) {
return null;
}
$p = $v->validated()['payload'];
$hash = self::projectionHash($p);
if (! hash_equals($hash, $publication['approval']['hash']) || ! hash_equals($hash, $publication['publication']['hash'])) {
return null;
}
try {
$path = app(InvestmentGeography::class)->selection(['province_id' => $p['province_id'] ?? null, 'district_id' => $p['district_id'] ?? null]);
} catch (ValidationException $error) {
return null;
}
return ['id' => $id, 'title' => $p['title'], 'summary' => $p['summary'], 'category' => $p['category'],
'location' => array_map(fn ($node) => ['id' => $node->id, 'name' => $node->name, 'level' => $node->location_type], $path),
'price' => ['mode' => $p['price_mode'], 'amount' => $p['amount'] ?? null, 'currency' => $p['currency'] ?? null],
'status' => 'published', 'published_at' => CarbonImmutable::parse($publication['publication']['at'])->utc()->toIso8601String(), 'revision' => $publication['revision'], 'url' => '/ofertas/'.$id];
}
/** @param array<string,mixed> $payload */
public static function projectionHash(array $payload): string
{
ksort($payload);
return hash('sha256', json_encode(['contract' => self::CONTRACT, 'payload' => $payload], JSON_THROW_ON_ERROR));
}
/** @param array<string,mixed> $filters
* @return array{records:list<array<string,mixed>>,truncated:bool} */
public function records(array $filters = []): array
{
if (! $this->fixtureReadAllowed()) {
return ['records' => [], 'truncated' => false];
}
$rows = Opportunity::query()->whereNotNull('workflow_context->private_offer_preparation')->whereNotNull('workflow_context->offer_publication')
->orderBy('id')->limit(1001)->get(['id', DB::raw("workflow_context->'offer_publication' AS public_snapshot"), DB::raw("workflow_context->'offer_operation' AS operation_state")]);
$records = [];
foreach ($rows->take(1000) as $row) {
if (! is_string($row->getAttribute('public_snapshot'))) {
continue;
}
try {
$publication = json_decode($row->getAttribute('public_snapshot'), true, 512, JSON_THROW_ON_ERROR);
$operation = json_decode($row->getAttribute('operation_state') ?? 'null', true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $error) {
continue;
}
if (! app(OfferAvailability::class)->publicAllowed($operation, $publication)) {
continue;
}
$p = $this->project($row->id, $publication);
if (! $p) {
continue;
}
$expires = app(OfferAvailability::class)->view($operation, $publication)['expires_at'];
if ($expires !== null) {
$p['expires_at'] = $expires;
}
if (! empty($filters['q']) && ! str_contains(mb_strtolower($p['title'].' '.$p['summary']), mb_strtolower($filters['q']))) {
continue;
}
if (! empty($filters['category']) && $p['category'] !== $filters['category']) {
continue;
}
if (! empty($filters['province_id']) && ($p['location'][0]['id'] ?? null) !== $filters['province_id']) {
continue;
}
if (! empty($filters['district_id']) && ($p['location'][1]['id'] ?? null) !== $filters['district_id']) {
continue;
}
if (! empty($filters['price_mode']) && $p['price']['mode'] !== $filters['price_mode']) {
continue;
}
if (! empty($filters['currency']) && $p['price']['currency'] !== $filters['currency']) {
continue;
}
if (isset($filters['min_amount']) && ($p['price']['amount'] === null || self::comparePrice($p['price']['amount'], $filters['min_amount']) < 0)) {
continue;
}
if (isset($filters['max_amount']) && ($p['price']['amount'] === null || self::comparePrice($p['price']['amount'], $filters['max_amount']) > 0)) {
continue;
}
$records[] = $p;
}
$sort = $filters['sort'] ?? 'recent';
usort($records, static function ($a, $b) use ($sort): int {
if ($sort === 'title') {
return strnatcasecmp($a['title'], $b['title']) ?: ($a['id'] <=> $b['id']);
}
if (in_array($sort, ['amount_asc', 'amount_desc'], true)) {
if ($a['price']['amount'] === null || $b['price']['amount'] === null) {
return ($a['price']['amount'] === null) <=> ($b['price']['amount'] === null);
}
return self::comparePrice($a['price']['amount'], $b['price']['amount']) * ($sort === 'amount_desc' ? -1 : 1) ?: ($a['id'] <=> $b['id']);
}
return strcmp($b['published_at'], $a['published_at']) ?: ($b['id'] <=> $a['id']);
});
return ['records' => $records, 'truncated' => $rows->count() > 1000];
}
/** @return array<string,mixed> */
public function detail(int $id): array
{
abort_unless($this->fixtureReadAllowed(), 404);
$row = Opportunity::query()->whereKey($id)->whereNotNull('workflow_context->private_offer_preparation')->first(['id', DB::raw("workflow_context->'offer_publication' AS public_snapshot"), DB::raw("workflow_context->'offer_operation' AS operation_state")]);
if (! $row || ! is_string($row->getAttribute('public_snapshot'))) {
abort(404);
}
try {
$publication = json_decode($row->getAttribute('public_snapshot'), true, 512, JSON_THROW_ON_ERROR);
$operation = json_decode($row->getAttribute('operation_state') ?? 'null', true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $error) {
abort(404);
}
abort_unless(app(OfferAvailability::class)->publicAllowed($operation, $publication), 404);
$record = $this->project($id, $publication);
abort_unless($record !== null, 404);
$expires = app(OfferAvailability::class)->view($operation, $publication)['expires_at'];
if ($expires !== null) {
$record['expires_at'] = $expires;
}
return $record;
}
/** @param array<string,mixed> $filters
* @return array{results:LengthAwarePaginator<int,array<string,mixed>>,truncated:bool} */
public function page(Request $request, array $filters): array
{
$result = $this->records($filters);
$size = (int) ($filters['per_page'] ?? 12);
$page = (int) ($filters['page'] ?? 1);
return ['results' => new LengthAwarePaginator(array_slice($result['records'], ($page - 1) * $size, $size), count($result['records']), $size, $page,
['path' => $request->url(), 'query' => $filters]), 'truncated' => $result['truncated']];
}
/** @param array<string,mixed> $filters
* @return array<string,mixed> */
public function map(array $filters): array
{
$result = $this->records($filters);
$areas = [];
$seen = [];
foreach ($result['records'] as $record) {
$id = $record['location'][array_key_last($record['location'])]['id'] ?? null;
if (! $id || isset($seen[$id])) {
continue;
}
$seen[$id] = true;
$areas = [...$areas, ...app(TerritoryGeometry::class)->context($id)['areas']];
if (count($seen) >= 30) {
break;
}
}
return ['features' => [], 'areas' => $areas, 'matched_count' => count($result['records']), 'exact_locations_disclosed' => false,
'truncated' => $result['truncated'] || count($seen) >= 30];
}
}
@@ -279,14 +279,7 @@ class OpportunityCanonicalContract
'economic_activity_id', 'Sector, subsector e atividade devem corresponder à hierarquia/versão publicada; os cinco níveis originais são conservados.');
foreach (['internal_contact_id', 'public_contact_id'] as $field) {
if (! empty($payload[$field])) {
$contacts = DB::table('contacts')->where('contacts.id', $payload[$field])->whereNull('contacts.deleted_at');
if ($record->organization_id) {
$contacts->whereExists(fn ($q) => $q->selectRaw('1')->from('investor_contacts as ic')->join('investors as i', 'i.id', '=', 'ic.investor_id')
->whereColumn('ic.contact_id', 'contacts.id')->where('i.organization_id', $record->organization_id)->whereNull('i.deleted_at'));
} else {
$contacts->where('source_system', 'portal-user-contact')->where('external_id', 'user:'.$record->owner_user_id);
}
$check($contacts->exists(), $field, 'Contacto inexistente ou sem relação com o Promotor.');
$check(app(OpportunityContacts::class)->related($record, $payload[$field]), $field, 'Contacto inexistente ou sem relação com o Promotor.');
}
}
$resources = [];
@@ -308,6 +301,9 @@ class OpportunityCanonicalContract
app(OpportunityLibrarySupport::class)->assertAvailable($row, $file);
app(VerifiedDocumentBinary::class)->respond($file);
$row->sha256 = trim($file->sha256);
if (data_get($record->workflow_context, 'editorial_version_binding') === 'phase1-exact-v1') {
$row->file_version_no = (int) $file->version_no;
}
}
} else {
$location = $row->payload;
+13
View File
@@ -14,6 +14,19 @@ use Illuminate\Validation\ValidationException;
/** Reuse the CRM master without manufacturing an interest, lead or consent. */
class OpportunityContacts
{
public function related(Opportunity $record, string $id): bool
{
$contacts = DB::table('contacts')->where('contacts.id', $id)->whereNull('contacts.deleted_at');
if ($record->organization_id) {
$contacts->whereExists(fn ($q) => $q->selectRaw('1')->from('investor_contacts as ic')->join('investors as i', 'i.id', '=', 'ic.investor_id')
->whereColumn('ic.contact_id', 'contacts.id')->where('i.organization_id', $record->organization_id)->whereNull('i.deleted_at'));
} else {
$contacts->where('source_system', 'portal-user-contact')->where('external_id', 'user:'.$record->owner_user_id);
}
return $contacts->exists();
}
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class OpportunityCuration
{
public function __construct(private PortalProfileAccess $access, private OpportunityManagement $management) {}
public function allowed(User $actor, Opportunity $record): bool
{
if (! $this->management->installed() || ! $this->access->canView($actor, $record)) {
return false;
}
if ($record->owner_user_id === null) {
return $this->access->legacyWriteAllowed($actor);
}
return $this->access->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category)
&& $this->access->operational($actor, 'publish', $record->id) && ! $this->access->conflict($actor, $record)
&& (int) data_get($record->workflow_context, 'approval.actor_id') !== $actor->id
&& (! data_get($record->workflow_context, 'publication_assignment.assignee_user_id')
|| (int) data_get($record->workflow_context, 'publication_assignment.assignee_user_id') === $actor->id);
}
private function authorize(User $actor, Opportunity $record): void
{
abort_unless($this->allowed($actor, $record), 403);
if ($record->owner_user_id !== null) {
app(OpportunityProfileWorkflow::class)->authorizeEditorialCuration($actor, $record);
}
}
/** @return array<string,mixed> */
public function state(User $actor, int $id, int $page = 1): array
{
$record = Opportunity::findOrFail($id);
$this->authorize($actor, $record);
$history = DB::table('audit_events')->where('source_system', 'opportunity-curation')->where('after_data->record_id', $id)
->orderByDesc('id')->paginate(25, ['id', 'actor_user_id', 'occurred_at', 'after_data'], 'page', $page)->through(function ($row) {
$data = json_decode($row->after_data, true, 512, JSON_THROW_ON_ERROR);
return ['id' => $row->id, 'actor' => $row->actor_user_id, 'at' => $row->occurred_at,
'featured' => $data['featured'], 'reason' => Crypt::decryptString($data['reason'])];
});
return ['record' => ['id' => $record->id, 'title' => $record->title, 'featured' => $record->is_featured],
'etag' => $this->management->etag($record), 'history' => $history];
}
/** @param array<string,mixed> $input
* @return array<string,mixed>
*/
public function preview(User $actor, int $id, array $input): array
{
$data = $this->validate($input);
$record = Opportunity::findOrFail($id);
$this->authorize($actor, $record);
$this->check($record, $data);
return ['hash' => $this->hash($actor, $id, $data), 'title' => $record->title,
'before' => $record->is_featured, 'featured' => $data['featured'], 'reason' => $data['reason']];
}
/** @param array<string,mixed> $input
* @return array<string,mixed>
*/
public function confirm(User $actor, int $id, array $input): array
{
$data = $this->validate($input);
Validator::make($input, ['hash' => 'required|string|size:64', 'confirm' => 'required|accepted'])->validate();
abort_unless(hash_equals($this->hash($actor, $id, $data), $input['hash']), 409, 'A conferência mudou; prepare nova prévia.');
return DB::transaction(function () use ($actor, $id, $data) {
$record = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail();
$this->authorize($actor, $record);
$receipt = DB::table('audit_events')->where('source_system', 'opportunity-curation')->where('after_data->record_id', $id)
->where('after_data->client_key', $data['client_key'])->first(['actor_user_id', 'after_data']);
$intent = $this->hash($actor, $id, $data);
if ($receipt) {
$prior = json_decode($receipt->after_data, true, 512, JSON_THROW_ON_ERROR);
abort_unless((int) $receipt->actor_user_id === $actor->id && hash_equals($prior['intent'], $intent), 409, 'Chave já utilizada com outro conteúdo.');
return ['replayed' => true, 'featured' => $record->is_featured, 'etag' => $this->management->etag($record)];
}
$this->check($record, $data);
$before = $record->is_featured;
$record->management_version++;
PortalAudit::save($record, ['is_featured' => $data['featured']], $actor->id);
DB::table('audit_events')->insert(['source_system' => 'opportunity-curation', 'entity_type' => 'opportunities',
'actor_user_id' => $actor->id, 'action' => 'featured_changed', 'data_classification' => 'restricted',
'before_data' => json_encode(['record_id' => $id, 'featured' => $before], JSON_THROW_ON_ERROR),
'after_data' => json_encode(['record_id' => $id, 'featured' => $record->is_featured, 'version' => $record->management_version,
'reason' => Crypt::encryptString($data['reason']), 'client_key' => $data['client_key'], 'intent' => $intent], JSON_THROW_ON_ERROR)]);
return ['replayed' => false, 'featured' => $record->is_featured, 'etag' => $this->management->etag($record)];
});
}
/** @param array<string,mixed> $input
* @return array{etag:string,client_key:string,featured:bool,reason:string}
*/
private function validate(array $input): array
{
$data = Validator::make($input, ['etag' => 'required|string|size:64', 'client_key' => 'required|uuid',
'featured' => 'required|boolean', 'reason' => 'required|string|min:20|max:1000'])->validate();
return ['etag' => $data['etag'], 'client_key' => $data['client_key'],
'featured' => (bool) $data['featured'], 'reason' => $data['reason']];
}
/** @param array{etag:string,client_key:string,featured:bool,reason:string} $data */
private function check(Opportunity $record, array $data): void
{
abort_unless(hash_equals($this->management->etag($record), $data['etag']), 409, 'A oportunidade mudou; recupere a versão actual antes de confirmar.');
abort_if($record->is_featured === $data['featured'], 422, 'O estado de destaque já corresponde à escolha.');
if ($data['featured']) {
abort_unless(Opportunity::published()->whereKey($record->id)->exists(), 409, 'Só uma oportunidade actualmente pública pode entrar em destaque.');
}
}
/** @param array{etag:string,client_key:string,featured:bool,reason:string} $data */
private function hash(User $actor, int $id, array $data): string
{
return hash_hmac('sha256', json_encode([$actor->id, $id, $data], JSON_THROW_ON_ERROR), (string) config('app.key'));
}
}
@@ -45,6 +45,7 @@ class OpportunityLibrarySupport
*/
public function attach(User $actor, Opportunity $opportunity, array $input): \stdClass
{
abort_if($opportunity->isPrivateOfferPreparation(), 422, 'Os comprovativos de Ofertas usam originais reservados à validação interna.');
app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity);
$data = Validator::make($input, ['resource_id' => 'required|integer|min:1', 'creation_key' => 'required|uuid', 'etag' => 'required|string|size:64', 'reason' => 'required|string|min:20|max:1000', 'section' => 'nullable|string|max:255', 'supported_statement' => 'nullable|string|max:2000'])->validate();
+20 -5
View File
@@ -21,7 +21,7 @@ class OpportunityManagement
public const MATURITY_FIELDS = ['maturity_level', 'maturity_label', 'maturity_stage', 'maturity_catalogue_version'];
public const PRIVATE_DRAFT_FIELDS = [...self::MATURITY_FIELDS, 'real_estate', 'canonical', 'proactive'];
public const PRIVATE_DRAFT_FIELDS = [...self::MATURITY_FIELDS, 'real_estate', 'canonical', 'proactive', 'private_offer'];
/** @return array<string,mixed> */
public function values(Opportunity $record): array
@@ -51,12 +51,13 @@ class OpportunityManagement
}
/** @param array<string,mixed> $input */
public function save(User $actor, array $input, ?int $id = null): Opportunity
public function save(User $actor, array $input, ?int $id = null, bool $privateOffer = false): Opportunity
{
$access = app(PortalProfileAccess::class);
$profileCreation = $id === null && str_starts_with(request()->path(), 'minhas-oportunidades');
if ($id !== null) {
$existingRecord = Opportunity::withTrashed()->findOrFail($id);
abort_if($existingRecord->isPrivateOfferPreparation() && ! $privateOffer, 403, 'Use a preparação privada da oferta.');
$access->authorize($actor, 'edit', $existingRecord);
if ($existingRecord->owner_user_id !== null) {
abort_unless($access->canEdit($actor, $existingRecord, $input['opportunity_category'] ?? null), 403);
@@ -100,6 +101,9 @@ class OpportunityManagement
'owner_user_id' => 'prohibited', 'workflow_context' => 'prohibited', 'status' => 'prohibited', 'first_submitted_at' => 'prohibited',
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
])->validate();
if ($privateOffer && ($id === null || array_key_exists('private_offer', $input))) {
$data['private_offer'] = app(PrivateOfferPreparation::class)->validate($input['private_offer'] ?? []);
}
$data = app(OpportunityMaturity::class)->normalize($data);
if (array_key_exists('real_estate', $data)) {
$data['real_estate'] = app(OpportunityRealEstateDraft::class)->validate($data['real_estate']);
@@ -108,10 +112,11 @@ class OpportunityManagement
$data['canonical'] = app(OpportunityCanonicalContract::class)->draft($data['canonical']);
}
return DB::transaction(function () use ($actor, $data, $id, $profileCreation, $access) {
return DB::transaction(function () use ($actor, $data, $id, $profileCreation, $access, $privateOffer) {
DB::select('SELECT pg_advisory_xact_lock(91026, 100000)');
if (! $id && ($existing = Opportunity::withTrashed()->where('management_creation_key', $data['creation_key'])->first())) {
$access->authorize($actor, 'view', $existing);
abort_unless($existing->isPrivateOfferPreparation() === $privateOffer, 409);
abort_unless($profileCreation === ($existing->owner_user_id !== null) && (! $profileCreation || $existing->organization_id === ($data['organization_id'] ?? null)), 409);
$snapshot = $this->values($existing);
foreach (array_intersect_key($data, array_flip([...self::FIELDS, ...self::PRIVATE_DRAFT_FIELDS])) as $key => $value) {
@@ -124,6 +129,7 @@ class OpportunityManagement
}
$record = $id ? Opportunity::withTrashed()->whereKey($id)->lockForUpdate()->firstOrFail() : new Opportunity;
if ($id) {
abort_unless($record->isPrivateOfferPreparation() === $privateOffer, 409);
$access->authorize($actor, 'edit', $record);
$this->check($record, $data['etag']);
abort_if($record->trashed() || $record->status === 'archived', 409, 'Restaure antes de editar.');
@@ -189,14 +195,20 @@ class OpportunityManagement
$record->management_closed_once = true;
$previous = $record->management_draft ? json_decode($record->management_draft, true) : $record->only(self::FIELDS);
$record->management_draft = json_encode([...$previous, ...$changes], JSON_THROW_ON_ERROR);
if ($privateOffer) {
// All columns of an Offer remain private; keep scope/category and location current.
foreach (array_intersect_key($changes, array_flip(self::FIELDS)) as $field => $value) {
$record->{$field} = $value;
}
}
} else {
foreach ($changes as $key => $value) {
if (in_array($key, ['real_estate', 'canonical', 'proactive', 'maturity_stage', 'maturity_catalogue_version'], true) || (($changes['maturity_catalogue_version'] ?? null) === OpportunityMaturity::VERSION && in_array($key, ['maturity_level', 'maturity_label'], true))) {
if (in_array($key, ['real_estate', 'canonical', 'proactive', 'private_offer', 'maturity_stage', 'maturity_catalogue_version'], true) || (($changes['maturity_catalogue_version'] ?? null) === OpportunityMaturity::VERSION && in_array($key, ['maturity_level', 'maturity_label'], true))) {
continue;
}
$record->{$key} = $value;
}
if (isset($changes['maturity_catalogue_version']) || array_key_exists('real_estate', $changes) || array_key_exists('canonical', $changes) || array_key_exists('proactive', $changes)) {
if (isset($changes['maturity_catalogue_version']) || array_key_exists('real_estate', $changes) || array_key_exists('canonical', $changes) || array_key_exists('proactive', $changes) || array_key_exists('private_offer', $changes)) {
// The existing draft JSON holds the versioned catalogue; legacy columns stay intact.
$previous = $record->management_draft ? json_decode($record->management_draft, true, 512, JSON_THROW_ON_ERROR) : [];
$record->management_draft = json_encode([...$previous, ...array_intersect_key($changes, array_flip(self::PRIVATE_DRAFT_FIELDS))], JSON_THROW_ON_ERROR);
@@ -204,6 +216,9 @@ class OpportunityManagement
}
if ($record->owner_user_id !== null) {
$context = $record->workflow_context ?? [];
if ($privateOffer) {
$context['private_offer_preparation'] ??= ['version' => 1, 'state' => 'draft'];
}
unset($context['approval']);
$context['content_revision'] = ($context['content_revision'] ?? 0) + 1;
$context['contributors'] = array_values(array_unique([...($context['contributors'] ?? []), $actor->id]));
+124 -10
View File
@@ -4,6 +4,7 @@ namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
@@ -16,12 +17,20 @@ class OpportunityProfileWorkflow
public function __construct(private PortalProfileAccess $access, private OpportunityManagement $management,
private OpportunityCanonicalContract $contract, private OpportunityWorkflowTasks $tasks) {}
public function authorizeEditorialCuration(User $actor, Opportunity $record): void
{
$this->authorizeAction($actor, $record, 'publish');
}
/** @return list<string> */
public function actions(User $actor, Opportunity $record): array
{
if ($record->owner_user_id === null || ! $this->access->canView($actor, $record)) {
return [];
}
if ($record->isPrivateOfferPreparation()) {
return $this->offerActions($actor, $record);
}
$actions = [];
$state = $this->access->state($record);
if ($this->access->promoterRecordScope($actor, $record)
@@ -94,6 +103,44 @@ class OpportunityProfileWorkflow
return $actions;
}
/** Existing roles and transition engine, bounded to the private offer contract.
* @return list<string> */
private function offerActions(User $actor, Opportunity $record): array
{
if (! app(PortalEditorialWorkflow::class)->available()) {
return [];
}
$state = $this->access->state($record);
$actions = [];
if ($this->access->promoterRecordScope($actor, $record) && $this->access->operational($actor, 'edit', $record->id)) {
if (in_array($state, ['draft', 'needs_information'], true)) {
$actions[] = 'submit';
}
if (in_array($state, ['rejected', 'approved'], true) || (isset($record->workflow_context['submission']) && ($record->workflow_context['offer_operation']['status'] ?? null) === 'revalidation'
&& ($record->workflow_context['offer_operation']['requested_by'] ?? null) === $actor->id && (int) $record->owner_user_id === $actor->id
&& in_array($state, ['draft', 'needs_information', 'submitted', 'in_review', 'information_pending'], true))) {
$actions[] = 'request_revision';
}
}
if ($this->access->scoped($actor, 'validator', $record->organization_id, $record->opportunity_category)
&& $this->access->operational($actor, 'validate', $record->id) && ! $this->access->conflict($actor, $record)) {
if (in_array($state, ['submitted', 'in_review'], true) && $this->responsibility($actor, $record, 'validation_coordinator')) {
$actions[] = 'assign_review';
}
$assigned = data_get($record->workflow_context, 'assignment.assignee_user_id');
if ($assigned === null || (int) $assigned === $actor->id) {
if ($state === 'submitted') {
$actions[] = 'start_review';
}
if ($state === 'in_review') {
array_push($actions, 'request_correction', 'reject', 'approve');
}
}
}
return $actions;
}
public function reauthenticationRequired(User $actor, Opportunity $record): bool
{
return ! $this->access->mfa($actor)
@@ -173,6 +220,12 @@ class OpportunityProfileWorkflow
abort_unless(hash_equals($this->management->etag($record), $data['etag']), 409, 'Versão alterada.');
$this->authorizeAction($actor, $record, $action);
$context = $record->workflow_context ?? [];
$offer = $record->isPrivateOfferPreparation();
if ($action === 'submit' && app(PortalEditorialWorkflow::class)->available()) {
$context['editorial_version_binding'] = 'phase1-exact-v1';
$record->workflow_context = $context;
}
$strictEditorial = ($context['editorial_version_binding'] ?? null) === 'phase1-exact-v1';
$state = $this->access->state($record);
$before = ['status' => $state, 'publication_status' => $record->status, 'version' => $record->management_version];
$from = match ($action) {
@@ -181,7 +234,8 @@ class OpportunityProfileWorkflow
'review_gate' => ['submitted', 'in_review', 'information_pending', 'approved'],
'publish' => ['approved'], 'suspend' => [$state],
'assign_publication' => ['approved'],
'request_revision' => ['approved', 'published', 'suspended'],
'request_revision' => $offer ? (($context['offer_operation']['status'] ?? null) === 'revalidation'
? ['draft', 'needs_information', 'submitted', 'in_review', 'information_pending', 'approved', 'rejected'] : ['approved', 'rejected']) : ['approved', 'published', 'suspended'],
'archive' => ['draft', 'submitted', 'in_review', 'information_pending', 'needs_information', 'approved', 'rejected', 'published', 'suspended'],
'request_archive' => ['draft', 'submitted', 'in_review', 'information_pending', 'needs_information', 'approved', 'published', 'suspended'],
'appeal', 'appeal_uphold', 'appeal_reopen' => ['rejected'],
@@ -195,11 +249,35 @@ class OpportunityProfileWorkflow
}
$next = $state;
$snapshot = [];
if ($action === 'submit' && $record->opportunity_category === 'land_rights') {
if ($offer) {
$snapshot = in_array($action, ['request_revision', 'request_correction'], true)
? ($context['submission'] ?? []) : app(PrivateOfferPreparation::class)->snapshot($record);
// Explicit first review of an immutable phase-1 submission; no writes on read.
if (in_array($action, ['start_review', 'assign_review'], true) && empty($context['submission'])) {
abort_unless(($context['private_offer_preparation']['version'] ?? null) === 1
&& $record->management_version === (int) ($context['private_offer_preparation']['submitted_revision'] ?? -2) + 1, 409, 'Reconcilie a submissão privada anterior.');
$snapshot['record_version'] = $context['private_offer_preparation']['submitted_revision'];
$context['submission'] = [...$snapshot, 'actor_id' => $context['private_offer_preparation']['submitted_by'], 'at' => $context['private_offer_preparation']['submitted_at']];
$context['phase1_binding'] = ['bound_at' => $decision['at'], 'bound_by' => $actor->id, 'hash' => $snapshot['hash'], 'original_reference_fingerprint' => 'not_recorded'];
$context['review_cycle'] = 1;
$decision['review_cycle'] = 1;
$context['phase1_submission'] = $context['private_offer_preparation'];
$record->first_submitted_at ??= $context['private_offer_preparation']['submitted_at'];
$record->management_closed_once = true;
}
if ($action !== 'submit') {
abort_unless(hash_equals($context['submission']['hash'] ?? '', $snapshot['hash']), 409, 'A oferta ou as referências mudaram; prepare nova revisão antes da decisão.');
}
$decision['hash'] = $snapshot['hash'];
$decision['opportunity_version'] = $record->management_version;
$decision['submitted_revision'] = $action === 'submit' ? $snapshot['record_version'] : ($context['submission']['record_version'] ?? $record->management_version);
$record->workflow_context = $context;
}
if (! $offer && $action === 'submit' && $record->opportunity_category === 'land_rights') {
// Serialize the existing JSON-backed §27 uniqueness check across records.
DB::select('SELECT pg_advisory_xact_lock(111026,270000)');
}
if (in_array($action, ['submit', 'review_gate', 'pending_opinion', 'respond_opinion', 'approve', 'publish'], true)) {
if (! $offer && in_array($action, ['submit', 'review_gate', 'pending_opinion', 'respond_opinion', 'approve', 'publish'], true)) {
$blockers = $this->contract->deliveryBlockers($this->management->values($record)['canonical'] ?? []);
try {
$snapshot = $this->contract->validateSubmission($record);
@@ -218,6 +296,12 @@ class OpportunityProfileWorkflow
}
}
if ($action === 'submit') {
if ($offer && isset($context['submission'])) {
$context['submission_history'][] = ['submission' => $context['submission'], 'approval' => $context['approval'] ?? null, 'rejection' => $context['rejection'] ?? null];
}
if ($offer) {
unset($context['rejection'], $context['correction_items']);
}
// New review attempt, even when the canonical content is unchanged.
// Historical decisions/tasks and the content revision keep their IDs.
$context['review_cycle'] = max((int) ($context['review_cycle'] ?? 0), $this->submissionCount($context)) + 1;
@@ -229,7 +313,7 @@ class OpportunityProfileWorkflow
}
}
$baseline = $context['review_baseline'] ?? $context['submission'] ?? [];
$materiality = app(OpportunityReviewMateriality::class)->reconcile($baseline, $snapshot, $context['reviews'] ?? [], $this->contract->gates($record), ! empty($context['force_review']));
$materiality = app(OpportunityReviewMateriality::class)->reconcile($baseline, $snapshot, $context['reviews'] ?? [], $offer ? [] : $this->contract->gates($record), ! empty($context['force_review']), $strictEditorial);
$context['materiality'] = array_diff_key($materiality, ['reviews' => true]);
$context['reviews'] = $materiality['reviews'];
app(OpportunityReviewConditions::class)->reconcile($context, $snapshot, $materiality['reopened']);
@@ -260,6 +344,11 @@ class OpportunityProfileWorkflow
}
$context['review_task_id'] ??= $this->tasks->ensure($record, 'opportunity_review', 'Validação da oportunidade '.$record->reference);
$this->tasks->assign($record, $context['review_task_id'], $subject);
if ($offer) {
$decision['previous_assignee_user_id'] = $context['assignment']['assignee_user_id'] ?? null;
$decision['assignee_user_id'] = $subject->id;
$context['assignment_history'][] = $decision;
}
$context['assignment'] = [...$decision, 'assignee_user_id' => $subject->id];
$next = 'in_review';
} elseif ($action === 'review_gate') {
@@ -282,19 +371,24 @@ class OpportunityProfileWorkflow
app(OpportunityReviewConditions::class)->authorize($context, $snapshot, 'approve');
abort_if(count(array_filter($context['opinions'] ?? [], fn ($o) => ($o['hash'] ?? '') === $snapshot['hash'] && ($o['status'] ?? '') === 'pending')) > 0, 422, 'Parecer da versão atual ainda pendente.');
abort_if(count(array_filter($context['opinions'] ?? [], fn ($o) => ($o['hash'] ?? '') === $snapshot['hash'] && ($o['response']['decision'] ?? '') === 'rejected')) > 0, 422, 'Parecer desfavorável exige correção e nova versão.');
foreach ($this->contract->gates($record) as $gate) {
foreach ($offer ? [] : $this->contract->gates($record) as $gate) {
$review = $context['reviews'][$gate] ?? [];
abort_unless(($review['decision'] ?? null) === 'approved' && (($review['hash'] ?? '') === $snapshot['hash'] || ($review['accepted_submission_hash'] ?? '') === $snapshot['hash']), 422, 'Gate '.$gate.' pendente, rejeitado ou de outra versão.');
abort_unless(app(OpportunityReviewMateriality::class)->matches($snapshot, $gate, $review), 409, 'Âmbito da revisão alterado.');
abort_unless(app(OpportunityReviewMateriality::class)->matches($snapshot, $gate, $review, $strictEditorial), 409, 'Âmbito ou versão da revisão alterado.');
$reviewer = User::whereKey($review['actor_id'])->firstOrFail();
abort_unless($this->access->scoped($reviewer, 'validator', $record->organization_id, $record->opportunity_category)
&& ! $this->access->conflict($reviewer, $record) && $this->access->operational($reviewer, 'validate', $record->id)
&& $this->responsibility($reviewer, $record, preg_match('/legal|registry|authority|confidentiality|license/', $gate) === 1 ? 'legal_reviewer' : 'technical_reviewer'), 403, 'Revisor revogado, sem responsabilidade ou com conflito.');
}
$context['approval'] = [...$decision, 'hash' => $snapshot['hash'], 'gates' => $context['reviews'] ?? []];
if ($offer) {
$context['offer_operation'] = app(PrivateOfferOperations::class)->afterApproval($context);
}
$this->tasks->complete($context['review_task_id'] ?? null, $data['reason']);
$record->workflow_context = $context;
$context['publication_task_id'] = $this->tasks->ensure($record, 'opportunity_publication', 'Preparação editorial '.$record->reference);
if (! $offer) {
$context['publication_task_id'] = $this->tasks->ensure($record, 'opportunity_publication', 'Preparação editorial '.$record->reference);
}
$next = 'approved';
} elseif ($action === 'assign_publication') {
$subject = User::whereKey($data['assignee_user_id'] ?? 0)->firstOrFail();
@@ -311,6 +405,7 @@ class OpportunityProfileWorkflow
foreach ($this->contract->gates($record) as $gate) {
$review = $context['approval']['gates'][$gate] ?? [];
abort_unless(($review['decision'] ?? null) === 'approved' && (($review['hash'] ?? '') === $snapshot['hash'] || ($review['accepted_submission_hash'] ?? '') === $snapshot['hash']), 422, 'Revisão aprovada desta versão necessária.');
abort_unless(app(OpportunityReviewMateriality::class)->matches($snapshot, $gate, $review, $strictEditorial), 409, 'Âmbito ou versão jurídica alterados após a aprovação.');
$reviewer = User::whereKey($review['actor_id'])->firstOrFail();
abort_unless($this->access->scoped($reviewer, 'validator', $record->organization_id, $record->opportunity_category)
&& ! $this->access->conflict($reviewer, $record) && $this->access->operational($reviewer, 'validate', $record->id)
@@ -403,6 +498,9 @@ class OpportunityProfileWorkflow
$next = count(array_filter($context['opinions'], fn ($o) => ($o['hash'] ?? '') === $snapshot['hash'] && ($o['status'] ?? '') === 'pending')) > 0 ? 'information_pending' : 'in_review';
} elseif ($action === 'request_correction') {
abort_unless($data['correction_items'] ?? [], 422, 'Indique campos e documentos a corrigir.');
if ($offer) {
abort_if(array_diff($data['correction_items'], ['title', 'description', 'opportunity_category', 'private_offer', 'contact', 'location']) !== [], 422, 'Seleccione campos desta oferta.');
}
$this->tasks->complete($context['review_task_id'] ?? null, $data['reason']);
$context['correction_items'] = $data['correction_items'];
$context['correction_cycles'] = ($context['correction_cycles'] ?? 0) + 1;
@@ -412,7 +510,7 @@ class OpportunityProfileWorkflow
} elseif ($action === 'request_revision' || $action === 'appeal_reopen') {
$this->tasks->complete($context['review_task_id'] ?? null, $data['reason']);
$this->tasks->complete($context['publication_task_id'] ?? null, $data['reason']);
$context['decision_history'][] = ['approval' => $context['approval'] ?? null, 'reviews' => $context['reviews'] ?? null, 'submission' => $context['submission'] ?? null];
$context['decision_history'][] = ['approval' => $context['approval'] ?? null, 'reviews' => $context['reviews'] ?? null, 'submission' => $context['submission'] ?? null, ...($offer ? ['rejection' => $context['rejection'] ?? null] : [])];
$context['review_baseline'] = $context['submission'] ?? [];
$context['force_review'] = $action === 'appeal_reopen' || $state === 'suspended';
unset($context['approval']);
@@ -465,14 +563,27 @@ class OpportunityProfileWorkflow
$context['working_state'] = $next;
$context['last_decision'] = $decision;
$context['events'][] = [...$decision, 'from' => $state, 'to' => $next];
if ($record->status !== 'published' || in_array($action, ['suspend', 'archive'], true)) {
if ($offer) {
$context['private_offer_preparation'] = [...$context['private_offer_preparation'], 'version' => 2, 'state' => $next];
if ($action === 'submit') {
$context['private_offer_preparation'] = [...$context['private_offer_preparation'], 'submitted_by' => $actor->id,
'submitted_at' => $decision['at'], 'submitted_revision' => $snapshot['record_version']];
}
}
if (! $offer && ($record->status !== 'published' || in_array($action, ['suspend', 'archive'], true))) {
$record->status = $next;
}
$record->management_version++;
$record->workflow_context = $context;
$record->save();
app(PortalEditorialWorkflow::class)->notifyOpportunity($actor, $record, $action);
$this->access->audit($actor, 'workflow_'.$action, ['record_id' => $record->id, 'before' => $before,
'after' => ['status' => $next, 'publication_status' => $record->status, 'version' => $record->management_version], 'reason' => $data['reason']]);
'after' => ['status' => $next, 'publication_status' => $record->status, 'version' => $record->management_version], 'reason' => $data['reason'],
...($offer ? ['decision' => $decision, 'submission_hash' => $context['submission']['hash'] ?? null,
'snapshot' => Crypt::encryptString(json_encode($snapshot, JSON_THROW_ON_ERROR))] : [])]);
if ($offer && $action === 'submit') {
$this->access->audit($actor, 'private_offer_submitted', ['record_id' => $record->id, 'version' => $record->management_version, 'public' => false]);
}
return $record;
});
@@ -508,6 +619,9 @@ class OpportunityProfileWorkflow
private function authorizeAction(User $actor, Opportunity $record, string $action): void
{
if ($record->isPrivateOfferPreparation()) {
abort_unless(in_array($action, $this->actions($actor, $record), true), 403, 'Operação editorial não autorizada para esta oferta privada.');
}
$this->access->authorize($actor, 'view', $record);
$promoter = $this->access->promoterRecordScope($actor, $record)
&& ($record->organization_id !== null || (int) $record->owner_user_id === $actor->id);
+1 -1
View File
@@ -22,7 +22,7 @@ final class OpportunityPromotion
public function allowed(User $actor, Opportunity $record): bool
{
$access = app(PortalProfileAccess::class);
if ($record->trashed() || $record->owner_user_id === null || ! $access->canView($actor, $record)
if ($record->isPrivateOfferPreparation() || $record->trashed() || $record->owner_user_id === null || ! $access->canView($actor, $record)
|| $access->has($actor, 'auditor') || in_array($record->status, ['archived', 'rejected'], true)) {
return false;
}
+17 -2
View File
@@ -92,7 +92,7 @@ class OpportunityResources
}
abort_if(! $id && DB::table($table)->where('id', $resourceId)->exists(), 409, 'Esta criação já foi registada; recupere o recurso existente.');
$active = DB::table($table)->where('opportunity_id', $opportunityId)->whereNull('deleted_at');
abort_if(! $id && $active->count() >= ($kind === 'locations' ? 50 : ($kind === 'images' ? 20 : 100)), 422, 'Limite de recursos atingido.');
abort_if(! $id && $active->count() >= $this->limit($opportunity, $kind), 422, 'Limite de recursos atingido.');
if ($kind === 'locations') {
$others = (clone $active)->when($id, fn ($q) => $q->where('id', '!=', $id))->get();
foreach ($others as $other) {
@@ -172,7 +172,7 @@ class OpportunityResources
abort_if(($data['action'] === 'delete') === ($record->deleted_at !== null), 409, 'Estado já alterado.');
$payload = json_decode($record->payload, true);
if ($data['action'] === 'restore') {
abort_if(DB::table($table)->where('opportunity_id', $opportunityId)->whereNull('deleted_at')->count() >= ($kind === 'locations' ? 50 : ($kind === 'images' ? 20 : 100)), 422, 'Limite de recursos ativos atingido; reconcilie antes de restaurar.');
abort_if(DB::table($table)->where('opportunity_id', $opportunityId)->whereNull('deleted_at')->count() >= $this->limit($opportunity, $kind), 422, 'Limite de recursos ativos atingido; reconcilie antes de restaurar.');
$this->validatePayload($kind, $payload, $opportunityId);
// Restoration never silently reclaims a primary/cover/order occupied by another resource.
if ($kind === 'images') {
@@ -221,12 +221,22 @@ class OpportunityResources
*/
private function validatePayload(string $kind, array $payload, int $opportunityId): array
{
$offer = Opportunity::findOrFail($opportunityId);
if ($offer->isPrivateOfferPreparation()) {
Validator::make($payload, ['visibility' => 'required|in:internal',
...($kind === 'images' ? ['digitally_edited' => 'required|boolean', 'edit_description' => 'nullable|required_if:digitally_edited,true|string|max:1000'] : []),
...($kind === 'documents' ? ['category' => 'required|in:real_estate_proof', 'classification' => 'required|in:restricted'] : [])])->validate();
abort_if($kind === 'documents' && $offer->opportunity_category !== 'real_estate', 422, 'Este percurso aceita comprovativos imobiliários.');
}
$rules = ['title' => 'nullable|string|max:120', 'source_reference' => 'nullable|string|max:2000', 'source_date' => 'nullable|date_format:Y-m-d', 'visibility' => 'nullable|in:internal,authenticated,public'];
if ($kind === 'locations') {
$rules += ['country_code' => ['nullable', app(CountryCatalogue::class)->rule()]];
$rules += ['province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid', 'role' => 'nullable|in:headquarters,production,raw_material,storage,logistics,market,support,other', 'role_description' => 'nullable|string|max:1000', 'operational_unit' => 'nullable|string|max:120', 'is_primary' => 'nullable|boolean', 'address' => 'nullable|string|max:300', 'geometry' => 'nullable|array', 'area_value' => 'nullable|numeric|gt:0', 'area_unit' => ['nullable', Rule::in(self::AREA_UNITS)], 'allocation_percentage' => 'nullable|numeric|between:0,100', 'territorial_purpose' => 'nullable|in:implementation,context,impact,coverage', 'multi_district_rationale' => 'nullable|string|max:1000'];
} elseif ($kind === 'images') {
$rules += ['role' => 'nullable|in:cover,gallery,location,asset,product,plan,rendering,evidence,other', 'role_description' => 'nullable|string|max:1000', 'is_cover' => 'nullable|boolean', 'caption' => 'nullable|string|max:300', 'alt_text' => 'nullable|string|max:250', 'location_id' => ['nullable', 'uuid', Rule::exists('opportunity_location_drafts', 'id')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')], 'captured_at' => 'nullable|date_format:Y-m-d|before_or_equal:today', 'author_source' => 'nullable|string|max:150', 'rights_status' => 'nullable|in:owned,licensed,authorized,public_domain', 'rights_document_id' => ['nullable', 'uuid', Rule::exists('opportunity_documents', 'id')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')], 'rights_valid_until' => 'nullable|date_format:Y-m-d', 'sort_order' => 'nullable|integer|min:0'];
if ($offer->isPrivateOfferPreparation()) {
$rules += ['digitally_edited' => 'boolean', 'edit_description' => 'nullable|string|max:1000'];
}
} else {
$rules += ['category' => 'nullable|string|max:100', 'issuer' => 'nullable|string|max:255', 'edition' => 'nullable|string|max:120', 'valid_until' => 'nullable|date_format:Y-m-d', 'classification' => 'nullable|in:public,internal,confidential,restricted', 'section' => 'nullable|string|max:255', 'supported_statement' => 'nullable|string|max:2000'];
}
@@ -257,6 +267,11 @@ class OpportunityResources
return $data;
}
private function limit(Opportunity $record, string $kind): int
{
return $kind === 'locations' ? 50 : ($kind === 'images' ? ($record->isPrivateOfferPreparation() ? 6 : 20) : 100);
}
/** @return array{string,string} */
private function store(string $kind, UploadedFile $file): array
{
@@ -134,8 +134,12 @@ class OpportunityReviewMateriality
/** @param array<string,mixed> $snapshot
* @param array<string,mixed> $review */
public function matches(array $snapshot, string $gate, array $review): bool
public function matches(array $snapshot, string $gate, array $review, bool $exactLegalVersion = false): bool
{
if ($exactLegalVersion && preg_match('/legal|registry|authority|confidentiality|license/', $gate) === 1
&& ($review['hash'] ?? null) !== ($snapshot['hash'] ?? null)) {
return false;
}
if (! isset($review['scope_hash'])) {
return true; // Caller must independently verify the original signed submission hash.
}
@@ -151,7 +155,7 @@ class OpportunityReviewMateriality
* @param array<string,mixed> $reviews
* @param list<string> $gates
* @return array<string,mixed> */
public function reconcile(array $before, array $after, array $reviews, array $gates, bool $force = false): array
public function reconcile(array $before, array $after, array $reviews, array $gates, bool $force = false, bool $exactLegalVersion = false): array
{
$kept = [];
$reopened = [];
@@ -161,7 +165,8 @@ class OpportunityReviewMateriality
// separately validate old scope signatures under their original rule.
$priorHash = $this->hash($before, $gate, $review);
$currentHash = $this->hash($after, $gate, $review);
if (! $force && $before !== [] && ($review['decision'] ?? '') === 'approved' && $this->matches($before, $gate, $review) && hash_equals($priorHash, $currentHash)) {
$changedLegalVersion = $exactLegalVersion && preg_match('/legal|registry|authority|confidentiality|license/', $gate) === 1 && ($review['hash'] ?? null) !== ($after['hash'] ?? null);
if (! $force && ! $changedLegalVersion && $before !== [] && ($review['decision'] ?? '') === 'approved' && $this->matches($before, $gate, $review) && hash_equals($priorHash, $currentHash)) {
$kept[$gate] = [...$review, 'scope_hash' => $priorHash, 'scope_rule' => self::VERSION, 'accepted_submission_hash' => $after['hash'],
'carried_forward' => ['rule' => self::VERSION, 'from_scope_hash' => $review['scope_hash'] ?? null, 'from_scope_rule' => $review['scope_rule'] ?? 'legacy_unspecified', 'from_submission_hash' => $before['hash'] ?? null, 'at' => now()->toIso8601String()]];
} else {
@@ -0,0 +1,146 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/** Adapts existing immutable native revisions to the common inbox; never changes a workflow. */
final class PortalEditorialNativeNotices
{
public function available(): bool
{
return app(PortalEditorialWorkflow::class)->available() && Schema::hasColumn('portal_editorial_notifications', 'native_event_id');
}
public function access(User $actor, string $source): bool
{
if (! $actor->hasVerifiedEmail() || ! $actor->two_factor_confirmed_at || ! $actor->two_factor_secret
|| app(PortalProfileAccess::class)->has($actor, 'auditor')) {
return false;
}
if ($source === 'investment-zones') {
return app(InvestmentZoneOperationalProfile::class)->editorialEnabled() && app(InvestmentZoneOperationalProfile::class)->reviewAccess($actor);
}
if (! $actor->can('manage-portal')) {
return false;
}
return match ($source) {
'cluster-competitiveness' => app(ClusterCompetitiveness::class)->editorialEnabled(),
'territorial-editorial-v1' => app(TerritorialRegistry::class)->editorialAvailable(),
default => false,
};
}
/** @return array{clusters:int,territories:int,zones:int} */
public function pending(User $actor): array
{
$counts = ['clusters' => 0, 'territories' => 0, 'zones' => 0];
if (! $this->available()) {
return $counts;
}
if ($this->access($actor, 'cluster-competitiveness')) {
$events = DB::table('audit_events as a')->join('economic_clusters as c', 'c.id', '=', 'a.entity_id')
->where('a.source_system', 'cluster-competitiveness')->whereNull('c.deleted_at')
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('audit_events as newer')->whereColumn('newer.entity_id', 'a.entity_id')
->where('newer.source_system', 'cluster-competitiveness')->whereRaw("(newer.after_data->>'version')::integer > (a.after_data->>'version')::integer"))
->select(['a.id', 'a.after_data'])->orderBy('a.id')->cursor();
foreach ($events as $event) {
$payload = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR);
$stage = $payload['editorial_state'] ?? null;
if ($stage === null) {
$state = json_decode(Crypt::decryptString($payload['snapshot']), true, 512, JSON_THROW_ON_ERROR);
$stage = $state['workflow']['state'] ?? null;
}
if ($stage !== null && ! in_array($stage, ['published', 'archived'], true)) {
$counts['clusters']++;
}
}
}
if ($this->access($actor, 'territorial-editorial-v1')) {
$latest = DB::table('audit_events')->where('source_system', 'territorial-editorial-v1')
->selectRaw("DISTINCT ON (entity_id) entity_id,after_data->>'state' AS editorial_state")
->orderBy('entity_id')->orderByRaw("(after_data->>'version_no')::integer DESC");
$counts['territories'] = DB::table('geo_territory_profiles as p')->join('geo_locations as g', 'g.id', '=', 'p.geo_location_id')
->leftJoinSub($latest, 'a', 'a.entity_id', '=', 'g.id')->whereNull('g.deleted_at')
->whereNotIn(DB::raw('COALESCE(a.editorial_state,p.status)'), ['published', 'archived'])->count();
}
if ($this->access($actor, 'investment-zones')) {
$latest = DB::table('audit_events')->where('source_system', 'investment-zones')->where('entity_type', 'investment_zones')->where('after_data->editorial_workflow', true)
->selectRaw("DISTINCT ON (entity_id) entity_id,after_data->>'editorial_state' AS editorial_state")
->orderBy('entity_id')->orderByRaw("(after_data->>'version_no')::integer DESC");
$counts['zones'] = DB::query()->fromSub($latest, 'a')->join('investment_zones as z', 'z.id', '=', 'a.entity_id')->whereNull('z.deleted_at')
->whereIn('a.editorial_state', ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved'])->count();
}
return $counts;
}
/** @param list<int> $participants */
public function emit(User $actor, string $eventId, array $participants): void
{
if (! $this->available()) {
return;
}
$event = DB::table('audit_events')->where('id', $eventId)->first();
abort_unless($event !== null, 409);
if (in_array($event->action, ['saved', 'restored', 'draft_saved', 'profile_prepared', 'profile_reconciled', 'zone_editorial_suspension_rebased'], true)) {
return;
}
$emails = $event->source_system === 'territorial-editorial-v1'
? [...config('territorial.editors', []), ...config('territorial.reviewers', []), ...config('territorial.publishers', [])]
: config('portal.managers', []);
$recipients = User::query()->where(fn ($q) => $q->whereIn('id', $participants)->orWhereIn(DB::raw('lower(email)'), array_map('strtolower', $emails)))->get();
foreach ($recipients as $recipient) {
if ($recipient->id === $actor->id || ! $this->access($recipient, $event->source_system)) {
continue;
}
DB::table('portal_editorial_notifications')->insertOrIgnore(['user_id' => $recipient->id, 'native_event_id' => $eventId,
'edition_id' => null, 'opportunity_id' => null, 'action' => $event->action, 'content_hash' => $this->eventHash($event), 'created_at' => now()]);
}
}
public function visible(User $actor, \stdClass $notice): bool
{
if (! $this->available() || (int) $notice->user_id !== $actor->id) {
return false;
}
$event = DB::table('audit_events')->where('id', $notice->native_event_id)->first();
if (! $event || ! $this->access($actor, $event->source_system) || ! is_string($notice->content_hash)
|| ! hash_equals(trim($notice->content_hash), $this->eventHash($event))) {
return false;
}
$table = match ($event->source_system) {
'cluster-competitiveness' => 'economic_clusters', 'investment-zones' => 'investment_zones', default => 'geo_locations'
};
return DB::table($table)->where('id', $event->entity_id)->whereNull('deleted_at')->exists();
}
private function eventHash(\stdClass $event): string
{
return hash('sha256', json_encode([$event->id, $event->source_system, $event->entity_type, $event->entity_id, $event->action, $event->after_data], JSON_THROW_ON_ERROR));
}
/** @return array<string,mixed> */
public function present(\stdClass $notice): array
{
$event = DB::table('audit_events')->where('id', $notice->native_event_id)->first();
abort_unless($event !== null, 404);
$data = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR);
$cluster = $event->source_system === 'cluster-competitiveness';
$version = $data[$cluster ? 'version' : 'version_no'];
if ($event->source_system === 'investment-zones') {
return ['id' => $notice->id, 'action' => str_replace('zone_editorial_', '', $notice->action), 'read_at' => $notice->read_at,
'href' => '/management/investment/zones/'.$event->entity_id.'?notice_version='.$version, 'reference' => 'Zona e indicadores — versão '.$version];
}
return ['id' => $notice->id, 'action' => $notice->action, 'read_at' => $notice->read_at,
'href' => $cluster ? '/management/clusters/'.$event->entity_id.'/edit?notice_version='.$version : '/management/territorial/editorial?territory='.$event->entity_id.'&notice_version='.$version,
'reference' => ($cluster ? 'Competitividade — versão ' : 'Território — versão ').$version];
}
}
+394
View File
@@ -0,0 +1,394 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/** Subject mapping and publication effects for the existing common editorial engine. */
final class PortalEditorialSubjects
{
public const SUBJECTS = [
'projects' => ['table' => 'projects', 'fields' => ['code', 'name', 'status', 'description', 'budget_amount', 'budget_currency', 'start_date', 'end_date', 'primary_territory_id', 'territory_version']],
'clusters' => ['table' => 'economic_clusters', 'fields' => ['code', 'name', 'status', 'sector', 'primary_territory_id', 'territory_version']],
'corridors' => ['table' => 'development_corridors', 'fields' => ['code', 'name', 'status', 'description', 'primary_territory_id', 'territory_version']],
'cluster-project' => ['table' => 'cluster_projects', 'fields' => ['cluster_id', 'project_id', 'role', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until']],
'corridor-project' => ['table' => 'corridor_projects', 'fields' => ['corridor_id', 'project_id', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until']],
'corridor-cluster' => ['table' => 'corridor_clusters', 'fields' => ['corridor_id', 'cluster_id', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until']],
'value-chains' => ['table' => 'value_chains', 'fields' => ['cluster_id', 'code', 'name', 'stage']],
'value-chain-links' => ['table' => 'value_chain_links', 'fields' => ['from_value_chain_id', 'to_value_chain_id', 'link_type']],
'investment-gaps' => ['table' => 'investment_gaps', 'fields' => ['cluster_id', 'value_chain_id', 'title', 'location', 'required_capacity', 'rationale', 'status', 'call_to_action']],
'zone-relations' => ['table' => 'investment_zone_relations', 'fields' => ['zone_id', 'cluster_id', 'corridor_id', 'project_id', 'association_type', 'role', 'source_reference', 'justification', 'legal_source_id', 'valid_from', 'valid_until', 'priority', 'is_primary']],
];
private const PARENTS = ['cluster_id' => 'economic_clusters', 'project_id' => 'projects', 'corridor_id' => 'development_corridors', 'zone_id' => 'investment_zones',
'value_chain_id' => 'value_chains', 'from_value_chain_id' => 'value_chains', 'to_value_chain_id' => 'value_chains', 'primary_territory_id' => 'geo_locations'];
/** @return array{table:string,fields:list<string>} */
public function definition(string $kind): array
{
abort_unless(isset(self::SUBJECTS[$kind]), 404);
return self::SUBJECTS[$kind];
}
public function available(): bool
{
return (bool) config('editorial_workflow.structural_subjects') && app(PortalEditorialWorkflow::class)->available() && Schema::hasColumn('portal_editorial_editions', 'subject_kind');
}
public function writes(string $kind, string $key): bool
{
$this->definition($kind);
if (config('editorial_workflow.structural_subjects')) {
abort_unless($this->available(), 503, 'Estrutura editorial dos registos ainda não instalada.');
return true;
}
abort_if(Schema::hasColumn('portal_editorial_editions', 'subject_kind') && DB::table('portal_editorial_editions')->where('subject_kind', $kind)->where('subject_key', $key)->exists(), 503, 'Reactive o circuito para conservar as versões deste registo.');
return false;
}
public function query(string $kind, string $key): Builder
{
return DB::table($this->definition($kind)['table'])->where('id', $key);
}
public function editUrl(string $kind, \stdClass $record): string
{
return match ($kind) {
'projects', 'clusters', 'corridors' => '/management/'.$kind.'/'.$record->id.'/edit',
'value-chains', 'investment-gaps', 'cluster-project' => '/management/clusters/'.$record->cluster_id.'/edit',
'value-chain-links' => '/management/clusters/'.DB::table('value_chains')->where('id', $record->from_value_chain_id)->value('cluster_id').'/edit',
'corridor-project', 'corridor-cluster' => '/management/corridors/'.$record->corridor_id.'/edit',
'zone-relations' => '/management/investment/zones/'.$record->zone_id,
default => abort(404),
};
}
public function working(string $kind, \stdClass $record): \stdClass
{
if (! $this->available()) {
return $record;
}
$workflow = app(PortalEditorialWorkflow::class);
$edition = $workflow->latestSubject($kind, $record->id);
$copy = clone $record;
if ($edition && $edition->published_at === null && $edition->state !== 'archived') {
foreach ($workflow->payload($edition)['resource']['_subject']['fields'] as $field => $value) {
$copy->{$field} = $value;
}
}
$copy->editorial_version = (int) ($edition->revision_no ?? 1);
$copy->editorial_url = '/management/editorial/subjects/'.$kind.'/'.$record->id;
return $copy;
}
/** @param array<string,mixed> $fields */
public function stageModel(User $actor, string $kind, Model $record, array $fields, int $expected): \stdClass
{
return DB::transaction(function () use ($actor, $kind, $record, $fields, $expected) {
$new = ! $record->exists;
if ($new) {
$private = in_array($kind, ['value-chains', 'value-chain-links', 'investment-gaps'], true) ? ['deleted_at' => now()] : [];
PortalAudit::save($record, [...$fields, ...$private], $actor->id);
}
$allowed = $this->definition($kind)['fields'];
return app(PortalEditorialWorkflow::class)->saveSubject($actor, $kind, (string) $record->getKey(), $expected, Arr::only($fields, $allowed), $new);
});
}
/** @return array<string,string> */
public function labels(): array
{
return ['code' => 'Código', 'name' => 'Designação', 'status' => 'Estado', 'description' => 'Descrição', 'sector' => 'Sector', 'budget_amount' => 'Orçamento', 'budget_currency' => 'Moeda', 'start_date' => 'Data de início', 'end_date' => 'Data de conclusão',
'primary_territory_id' => 'Localização principal', 'cluster_id' => 'Cluster', 'project_id' => 'Projecto', 'corridor_id' => 'Corredor', 'zone_id' => 'Zona', 'value_chain_id' => 'Etapa da cadeia', 'from_value_chain_id' => 'Etapa de origem', 'to_value_chain_id' => 'Etapa de destino',
'role' => 'Papel', 'association_type' => 'Tipo de associação', 'source_reference' => 'Fonte', 'evidence_reference' => 'Evidência', 'valid_from' => 'Válido desde', 'valid_until' => 'Válido até', 'stage' => 'Fase da cadeia', 'link_type' => 'Tipo de ligação',
'title' => 'Título', 'location' => 'Localização', 'required_capacity' => 'Capacidade necessária', 'rationale' => 'Fundamento', 'call_to_action' => 'Acção proposta', 'justification' => 'Justificação', 'legal_source_id' => 'Fonte jurídica', 'priority' => 'Prioridade', 'is_primary' => 'Relação principal'];
}
/** @return array<string,list<string>> */
private function enums(string $kind): array
{
return ['status' => match ($kind) {
'projects' => ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'], 'clusters' => ['planned', 'active', 'inactive'], 'corridors' => ['proposed', 'active', 'closed'], 'investment-gaps' => ['identified', 'in_review', 'resolved', 'withdrawn'], default => []
},
'stage' => ['input', 'production', 'processing', 'logistics', 'market'], 'link_type' => ['supplies', 'processes', 'distributes', 'supports'],
'association_type' => ['official', 'influence', 'proximity', 'potential', 'historical'], 'role' => $kind === 'zone-relations' ? ['primary', 'beneficiary', 'served', 'enabled', 'dependent', 'related', 'potential', 'historical'] : ['anchor', 'complementary', 'supporting']];
}
/** @return list<array<string,mixed>> */
public function form(string $kind): array
{
$labels = $this->labels();
$enums = $this->enums($kind);
$result = [];
foreach ($this->definition($kind)['fields'] as $field) {
if ($field === 'territory_version') {
continue;
}
$type = isset(self::PARENTS[$field]) || $field === 'legal_source_id' ? 'reference' : (isset($enums[$field]) ? 'enum' : (in_array($field, ['valid_from', 'valid_until', 'start_date', 'end_date'], true) ? 'date' : ($field === 'is_primary' ? 'boolean' : 'text')));
$result[] = ['name' => $field, 'label' => $labels[$field] ?? $field, 'type' => $type, 'options' => $enums[$field] ?? []];
}
return $result;
}
/** @param array<string,mixed> $input
* @return array<string,mixed>
*/
public function validate(string $kind, array $input): array
{
$allowed = array_values(array_diff($this->definition($kind)['fields'], ['territory_version']));
abort_unless(array_diff(array_keys($input), $allowed) === [], 422, 'Campos fora do âmbito da proposta.');
$enums = $this->enums($kind);
$rules = [];
foreach ($allowed as $field) {
$rules[$field] = ['sometimes', 'nullable', 'string', 'max:10000'];
if (isset(self::PARENTS[$field]) || $field === 'legal_source_id') {
$table = self::PARENTS[$field] ?? 'investment_legal_sources';
$rules[$field] = ['sometimes', 'nullable', 'uuid', 'exists:'.$table.',id'];
} elseif (isset($enums[$field])) {
$rules[$field] = ['sometimes', 'required', Rule::in($enums[$field])];
} elseif (in_array($field, ['valid_from', 'valid_until', 'start_date', 'end_date'], true)) {
$rules[$field] = ['sometimes', 'nullable', 'date_format:Y-m-d'];
}
}
foreach (['name', 'code', 'title', 'sector'] as $field) {
if (in_array($field, $allowed, true)) {
$rules[$field] = ['sometimes', 'required', 'string', 'max:'.($field === 'code' ? '100' : '200')];
}
}
foreach (['source_reference' => 2000, 'evidence_reference' => 2000, 'justification' => 3000, 'location' => 1000, 'required_capacity' => 2000, 'rationale' => 5000, 'call_to_action' => 2000] as $field => $limit) {
if (in_array($field, $allowed, true)) {
$rules[$field] = ['sometimes', 'nullable', 'string', 'max:'.$limit];
}
}
$requiredReferences = match ($kind) {
'cluster-project' => ['cluster_id', 'project_id'], 'corridor-project' => ['corridor_id', 'project_id'], 'corridor-cluster' => ['corridor_id', 'cluster_id'],
'value-chains' => ['cluster_id'], 'value-chain-links' => ['from_value_chain_id', 'to_value_chain_id'], 'investment-gaps' => ['cluster_id', 'value_chain_id'], 'zone-relations' => ['zone_id'], default => [],
};
foreach ($requiredReferences as $field) {
$rules[$field] = ['sometimes', 'required', 'uuid', 'exists:'.self::PARENTS[$field].',id'];
}
if (! empty($input['valid_until']) && ! empty($input['valid_from'])) {
$rules['valid_until'][] = 'after_or_equal:valid_from';
}
if (! empty($input['end_date']) && ! empty($input['start_date'])) {
$rules['end_date'][] = 'after_or_equal:start_date';
}
if (in_array('budget_amount', $allowed, true)) {
$rules['budget_amount'] = ['sometimes', 'nullable', 'required_with:budget_currency', 'regex:/^\d{1,18}(\.\d{1,2})?$/'];
$rules['budget_currency'] = ['sometimes', 'nullable', 'required_with:budget_amount', 'regex:/^[A-Z]{3}$/'];
}
if (in_array('priority', $allowed, true)) {
$rules['priority'] = ['sometimes', 'integer', 'between:0,100'];
$rules['is_primary'] = ['sometimes', 'boolean'];
}
if ($kind === 'zone-relations' && count(array_filter(Arr::only($input, ['cluster_id', 'corridor_id', 'project_id']))) > 1) {
throw ValidationException::withMessages(['project_id' => 'Seleccione apenas um projecto, cluster ou corredor para a relação.']);
}
return validator($input, $rules)->validate();
}
/** @return list<array{id:string,name:string}> */
public function choices(string $kind, string $field, ?string $query = null): array
{
abort_unless(in_array($field, $this->definition($kind)['fields'], true), 404);
$table = self::PARENTS[$field] ?? ($field === 'legal_source_id' ? 'investment_legal_sources' : abort(404));
$label = $table === 'investment_legal_sources' ? 'title' : 'name';
$builder = DB::table($table);
if ($table === 'geo_locations') {
$builder->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])->whereIn('data_classification', ['public', 'internal']);
}
if (Schema::hasColumn($table, 'deleted_at')) {
$builder->whereNull('deleted_at');
}
if ($query) {
$builder->where($label, 'ilike', '%'.addcslashes($query, '%_\\').'%');
}
return array_values($builder->orderBy($label)->orderBy('id')->limit(100)->get(['id', $label.' as name'])->map(fn ($row) => ['id' => (string) $row->id, 'name' => (string) $row->name])->all());
}
public function authorize(User $actor, string $kind, string $key, bool $write = false): void
{
abort_unless($this->available(), 503);
abort_unless($actor->hasVerifiedEmail() && $actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403);
abort_unless($this->query($kind, $key)->exists(), 404);
if ($write) {
abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.');
}
}
public function independent(string $kind): bool
{
return in_array($kind, ['cluster-project', 'corridor-project', 'corridor-cluster', 'zone-relations'], true)
|| in_array($kind, config('editorial_workflow.independent_approval.kinds', []), true);
}
/** @param array<string,mixed> $fields
* @return array<string,mixed>
*/
public function snapshot(string $kind, string $key, array $fields): array
{
$definition = $this->definition($kind);
$record = $this->query($kind, $key)->first();
abort_unless($record !== null, 404);
abort_unless(array_diff(array_keys($fields), $definition['fields']) === [], 422, 'Campos fora do âmbito do registo.');
$context = ['record' => Arr::except(get_object_vars($record), ['created_at', 'updated_at']), 'parents' => []];
if (! empty($fields['primary_territory_id'])) {
$path = app(InvestmentGeography::class)->path($fields['primary_territory_id'], true);
abort_unless($path !== [] && count($path) <= 4 && $path[0]->location_type === 'province', 422, 'Seleccione uma localização na hierarquia existente.');
}
foreach (self::PARENTS as $field => $table) {
$id = $fields[$field] ?? $record->{$field} ?? null;
if (! $id) {
continue;
}
$parent = DB::table($table)->where('id', $id)->whereNull('deleted_at')->first();
abort_unless($parent !== null, 422, 'Contexto relacionado indisponível.');
$context['parents'][$field] = Arr::except(get_object_vars($parent), ['created_at', 'updated_at']);
}
if (in_array($kind, ['projects', 'clusters', 'corridors'], true)) {
$foreign = ['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'][$kind];
$context['portfolio'] = DB::table('investment_dossiers')->where($foreign, $key)->pluck('id')->all();
abort_unless($context['portfolio'] === [], 409, 'Este registo tem ficha associada. Continue na revisão da carteira.');
}
if ($source = $fields['legal_source_id'] ?? $record->legal_source_id ?? null) {
$sourceRecord = DB::table('investment_legal_sources')->where('id', $source)->first();
abort_unless($sourceRecord !== null, 422, 'Fonte jurídica indisponível.');
$context['legal_source'] = get_object_vars($sourceRecord);
}
return ['fields' => $fields, 'context' => $context];
}
/** @param array<string,mixed> $fields */
public function lock(string $kind, string $key, array $fields = []): \stdClass
{
$record = $this->query($kind, $key)->first();
abort_unless($record !== null, 404);
$parents = [];
foreach (self::PARENTS as $field => $table) {
$id = $fields[$field] ?? $record->{$field} ?? null;
if ($id) {
$parents[$table][] = $id;
}
}
ksort($parents);
foreach ($parents as $table => $ids) {
DB::table($table)->whereIn('id', array_unique($ids))->orderBy('id')->lockForUpdate()->get();
}
return $this->query($kind, $key)->lockForUpdate()->firstOrFail();
}
/** @param array<string,mixed> $payload */
public function publish(User $actor, \stdClass $record, \stdClass $edition, array $payload): void
{
$subject = $payload['resource']['_subject'];
$kind = $edition->subject_kind;
$fields = $subject['fields'];
$this->lock($kind, $edition->subject_key, $fields);
if ($subject['withdraw'] ?? false) {
$this->archive($actor, $record, $edition);
return;
}
$pair = match ($kind) {
'cluster-project' => ['cluster_id', 'project_id'], 'corridor-project' => ['corridor_id', 'project_id'], 'corridor-cluster' => ['corridor_id', 'cluster_id'],
'value-chain-links' => ['from_value_chain_id', 'to_value_chain_id', 'link_type'],
'zone-relations' => ['zone_id', 'cluster_id', 'corridor_id', 'project_id'], default => [],
};
if ($pair !== []) {
abort_if($kind === 'zone-relations' && count(array_filter(Arr::only($fields, ['cluster_id', 'corridor_id', 'project_id']))) !== 1, 422, 'A relação exige um projecto, cluster ou corredor.');
$duplicates = DB::table($this->definition($kind)['table'])->where('id', '<>', $record->id);
foreach ($pair as $field) {
$duplicates->where($field, $fields[$field] ?? null);
}
if (property_exists($record, 'deleted_at')) {
$duplicates->whereNull('deleted_at');
}
abort_if($duplicates->exists(), 409, 'Esta relação já existe. Reveja a proposta sem duplicar o registo.');
}
if (in_array($kind, ['projects', 'clusters', 'corridors', 'value-chains'], true) && isset($fields['code'])) {
$query = DB::table($this->definition($kind)['table'])->where('code', $fields['code'])->where('id', '<>', $record->id)->whereNull('deleted_at');
if ($kind === 'value-chains') {
$query->where('cluster_id', $fields['cluster_id'] ?? $record->cluster_id);
}
abort_if($query->exists(), 409, 'Código entretanto utilizado. Reveja a proposta.');
}
if (in_array($kind, ['cluster-project', 'corridor-project', 'corridor-cluster', 'zone-relations'], true)) {
foreach (['source_reference', 'valid_from'] as $required) {
abort_unless(! empty($fields[$required]), 422, 'Fonte e vigência são necessárias.');
}
abort_if($kind === 'zone-relations' && ($fields['association_type'] ?? '') === 'official' && empty($fields['legal_source_id']), 422, 'A associação oficial exige a fonte jurídica prevista.');
if (! empty($fields['legal_source_id'])) {
$source = DB::table('investment_legal_sources')->where('id', $fields['legal_source_id'])->lockForUpdate()->first();
abort_unless($source && $source->validation_status === 'validated' && $source->valid_from <= today()->toDateString()
&& (! $source->valid_until || $source->valid_until >= today()->toDateString()), 422, 'Fonte jurídica fora de vigência.');
}
$decision = json_decode($edition->approval, true, 512, JSON_THROW_ON_ERROR);
$fields += ['validation_status' => 'validated', 'validator_id' => $decision['actor_id'], 'editor_id' => $edition->author_id];
if (property_exists($record, 'data_classification')) {
$fields['data_classification'] = 'public';
}
}
if ($kind === 'value-chain-links') {
$chains = DB::table('value_chains')->whereIn('id', [$fields['from_value_chain_id'], $fields['to_value_chain_id']])->whereNull('deleted_at')->get(['id', 'cluster_id']);
abort_unless($chains->count() === 2 && $chains->pluck('cluster_id')->unique()->count() === 1, 422, 'As etapas devem ser distintas e pertencer ao mesmo cluster.');
}
if ($kind === 'investment-gaps') {
abort_unless(DB::table('value_chains')->where('id', $fields['value_chain_id'])->where('cluster_id', $fields['cluster_id'])->whereNull('deleted_at')->exists(), 422, 'Etapa fora do cluster da lacuna.');
}
if ($subject['new_record'] ?? false) {
$fields['deleted_at'] = null;
}
if (property_exists($record, 'data_classification')) {
$fields['data_classification'] = 'public';
}
if (property_exists($record, 'version_no')) {
$fields['version_no'] = (int) $record->version_no + 1;
}
$fields['updated_at'] = now();
$this->query($kind, $record->id)->update($fields);
$this->audit($actor, $edition, 'published', $record);
}
public function archive(User $actor, \stdClass $record, \stdClass $edition): void
{
$fields = property_exists($record, 'validation_status') ? ['validation_status' => 'revoked']
: (in_array($edition->subject_kind, ['projects', 'clusters', 'corridors'], true) ? ['data_classification' => 'internal'] : ['deleted_at' => now()]);
if (property_exists($record, 'version_no')) {
$fields['version_no'] = (int) $record->version_no + 1;
}
$this->query($edition->subject_kind, $record->id)->update([...$fields, 'updated_at' => now()]);
$this->audit($actor, $edition, 'archived', $record);
}
private function audit(User $actor, \stdClass $edition, string $action, \stdClass $before): void
{
$after = $this->query($edition->subject_kind, $edition->subject_key)->firstOrFail();
$changed = [];
foreach (get_object_vars($after) as $field => $value) {
if (($before->{$field} ?? null) !== $value) {
$changed[] = $field;
}
}
DB::table('audit_events')->insert(['source_system' => 'editorial-structural-subjects', 'entity_type' => $this->definition($edition->subject_kind)['table'],
'entity_id' => $edition->subject_key, 'actor_user_id' => $actor->id, 'action' => $action, 'data_classification' => 'restricted',
'before_data' => json_encode(Arr::only(get_object_vars($before), ['id', 'version_no', 'validation_status', 'deleted_at', 'data_classification']), JSON_THROW_ON_ERROR),
'after_data' => json_encode(['edition_id' => $edition->id, 'hash' => $edition->content_hash, 'record_id' => $edition->subject_key, 'changed_fields' => $changed], JSON_THROW_ON_ERROR)]);
}
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -22,6 +22,8 @@ class PortalProfileAccess
app_path('Services/PortalProfileAdministration.php'), app_path('Services/PortalSuperusers.php'),
app_path('Services/OpportunityProfileWorkflow.php'), app_path('Http/Controllers/PortalProfileController.php'), app_path('Http/Controllers/DashboardController.php'),
app_path('Http/Controllers/OpportunityManagementController.php'),
app_path('Services/OpportunityCuration.php'), app_path('Http/Controllers/OpportunityCurationController.php'),
resource_path('js/pages/management/opportunity-curation.tsx'),
app_path('Services/OpportunityCanonicalContract.php'), app_path('Services/OpportunityCanonicalResponse.php'), app_path('Services/OpportunityCategoryFields.php'), app_path('Services/OpportunityModularProfiles.php'), app_path('Services/OpportunityWorkflowTasks.php'), base_path('composer.json'), base_path('composer.lock'),
app_path('Services/OpportunityReviewMateriality.php'), app_path('Services/OpportunityReviewConditions.php'), app_path('Services/OpportunityCanonicalMetadata.php'), app_path('Services/OpportunityCompleteness.php'), app_path('Services/OpportunityCanonicalValue.php'),
resource_path('data/investmoz-opportunity-modular.schema.json'), resource_path('js/components/opportunity-canonical-fields.tsx'), resource_path('js/components/opportunity-parcel-geometry.tsx'),
@@ -239,6 +241,9 @@ class PortalProfileAccess
public function canEdit(User $actor, Opportunity $record, ?string $newType = null): bool
{
if ($record->isPrivateOfferPreparation() && ! in_array($this->state($record), ['draft', 'needs_information'], true)) {
return false;
}
if ($record->owner_user_id === null) {
return $this->legacyWriteAllowed($actor);
}
@@ -262,6 +267,10 @@ class PortalProfileAccess
public function state(Opportunity $record): string
{
if ($record->isPrivateOfferPreparation()) {
return data_get($record->workflow_context, 'private_offer_preparation.state', 'draft');
}
return $record->workflow_context['working_state'] ?? $record->status;
}
@@ -0,0 +1,217 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
/** The same opportunity owns consent, grants and history. No automatic publication. */
final class PrivateOfferConfidentiality
{
public function internal(User $actor, Opportunity $record): bool
{
$access = app(PortalProfileAccess::class);
return $access->canView($actor, $record) && $access->mfa($actor) && ! $access->has($actor, 'auditor') && ! $access->conflict($actor, $record)
&& $access->scoped($actor, 'validator', $record->organization_id, $record->opportunity_category);
}
public function seller(User $actor, Opportunity $record): bool
{
$access = app(PortalProfileAccess::class);
return (int) $record->owner_user_id === $actor->id && $access->promoterRecordScope($actor, $record)
&& $access->canCreate($actor, $record->organization_id, $record->opportunity_category) && $access->operational($actor, 'edit', $record->id);
}
/** @return array<string,mixed>|null */
public function nda(): ?array
{
$definition = config('private_offers.nda');
if (! is_array($definition) || empty($definition['approval_reference']) || empty($definition['document_id']) || empty($definition['version']) || empty($definition['sha256'])) {
return null;
}
$doc = DB::table('documents')->where('id', $definition['document_id'])->whereNull('deleted_at')->first();
if (! $doc || $doc->status !== 'active' || $doc->malware_scan_status !== 'clean' || (int) $doc->version_no !== (int) $definition['version']
|| ! hash_equals(trim($doc->sha256), $definition['sha256']) || ! hash_equals(trim($doc->sha256), trim((string) $doc->malware_scanned_sha256))) {
return null;
}
try {
app(VerifiedDocumentBinary::class)->respond($doc);
} catch (HttpExceptionInterface $error) {
return null;
}
$result = ['document_id' => $doc->id, 'version' => (int) $doc->version_no, 'sha256' => trim($doc->sha256), 'approval_reference' => $definition['approval_reference']];
return [...$result, 'fingerprint' => hash('sha256', json_encode($result, JSON_THROW_ON_ERROR))];
}
private function company(int $id, bool $lock = false): Opportunity
{
$record = Opportunity::query()->when($lock, fn ($q) => $q->lockForUpdate())->findOrFail($id);
abort_unless($record->isPrivateOfferPreparation() && $record->opportunity_category === 'business_sale', 404);
return $record;
}
private function approvedHash(Opportunity $record): string
{
abort_unless(app(OfferAvailability::class)->record($record)['state'] === 'available', 409, 'A oferta não está disponível.');
$hash = $record->workflow_context['approval']['hash'] ?? null;
abort_unless(is_string($hash) && app(PortalProfileAccess::class)->state($record) === 'approved', 409, 'Esta versão não tem aprovação editorial actual.');
try {
$snapshot = app(PrivateOfferPreparation::class)->snapshot($record);
} catch (ValidationException $error) {
abort(409, 'A versão ou os suportes exigem reconferência editorial.');
}
abort_unless(hash_equals($hash, $snapshot['hash']), 409, 'A versão mudou; é necessária reconferência editorial.');
return $hash;
}
/** PostgreSQL jsonb reorders object keys; identity is the version and checksum, not array order.
* @param array<string,mixed> $nda */
private function sameNda(mixed $accepted, array $nda): bool
{
if (! is_array($accepted)) {
return false;
}
foreach (['document_id', 'version', 'sha256', 'approval_reference', 'fingerprint'] as $key) {
if (($accepted[$key] ?? null) !== $nda[$key]) {
return false;
}
}
return true;
}
/** @return array<string,mixed> */
public function overview(User $actor, int $id): array
{
$record = $this->company($id);
// An interested party learns no private title, contacts, documents or financial values.
$seller = $this->seller($actor, $record);
$nda = $this->nda();
$context = $record->workflow_context['offer_confidentiality'] ?? [];
$currentHash = null;
try {
$currentHash = $this->approvedHash($record);
} catch (HttpExceptionInterface $error) {
// A previous acceptance remains withdrawable even if the offer was revised.
abort_unless($seller || isset($context['acceptances'][(string) $actor->id]), 404);
}
$history = array_values(array_filter($context['events'] ?? [], fn ($event) => $seller || $event['interested_user_id'] === $actor->id));
return ['offer_id' => $id, 'nda' => $nda, 'nda_pending' => $nda === null, 'version_hash' => $record->workflow_context['approval']['hash'] ?? null,
'etag' => $seller ? app(OpportunityManagement::class)->etag($record) : null,
'can_accept' => ! $seller && $nda !== null && $currentHash !== null && ! app(PortalProfileAccess::class)->conflict($actor, $record),
'can_authorize' => $seller && $nda !== null && $currentHash !== null,
'seller' => $seller, 'requests' => $seller ? array_values($context['acceptances'] ?? []) : [],
'grants' => $seller ? array_values($context['grants'] ?? []) : [],
'history' => array_map(fn ($event) => array_intersect_key($event, array_flip(['id', 'action', 'actor_id', 'at', 'version_hash', 'nda'])), $history),
'acceptance' => $context['acceptances'][(string) $actor->id] ?? null];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function act(User $actor, int $id, array $input): array
{
$data = validator($input, ['action' => 'required|in:accept,authorize,revoke,withdraw', 'version_hash' => 'required|string|size:64',
'nda_sha256' => 'required_unless:action,revoke,withdraw|string|size:64', 'acceptance_id' => 'required_if:action,authorize|uuid',
'nda_fingerprint' => 'required_if:action,accept,authorize|string|size:64',
'interested_user_id' => 'required_if:action,authorize,revoke|integer|min:1', 'confirm' => 'required|accepted',
'reason' => ['nullable', 'required_if:action,authorize,revoke', 'string', 'max:1000', ...(in_array($input['action'] ?? null, ['authorize', 'revoke'], true) ? ['min:20'] : [])]])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
$record = $this->company($id, true);
$context = $record->workflow_context;
$privacy = $context['offer_confidentiality'] ?? ['acceptances' => [], 'grants' => [], 'events' => []];
$action = $data['action'];
$target = in_array($action, ['authorize', 'revoke'], true) ? (string) $data['interested_user_id'] : (string) $actor->id;
$before = ['acceptance' => $privacy['acceptances'][$target] ?? null, 'grant' => $privacy['grants'][$target] ?? null];
if (in_array($action, ['authorize', 'revoke'], true)) {
abort_unless($this->seller($actor, $record), 403);
} elseif ($action === 'accept') {
abort_if($this->seller($actor, $record) || app(PortalProfileAccess::class)->conflict($actor, $record), 403);
}
if (in_array($action, ['accept', 'authorize'], true)) {
$nda = $this->nda();
abort_unless($nda !== null, 409, 'O exemplar aprovado do NDA ainda não foi disponibilizado.');
abort_unless(hash_equals($this->approvedHash($record), $data['version_hash']) && hash_equals($nda['sha256'], $data['nda_sha256'])
&& hash_equals($nda['fingerprint'], $data['nda_fingerprint']), 409);
$subject = User::findOrFail((int) $target);
abort_unless($subject->hasVerifiedEmail() && ! app(PortalProfileAccess::class)->conflict($subject, $record), 403);
if ($action === 'accept') {
$privacy['acceptances'][$target] = ['id' => (string) Str::uuid(), 'actor_id' => $actor->id, 'at' => now()->toIso8601String(),
'version_hash' => $data['version_hash'], 'nda' => $nda, 'access_revision' => (int) $subject->portal_access_revision];
unset($privacy['grants'][$target]); // A fresh acceptance never reuses a previous seller decision.
} else {
$acceptance = $privacy['acceptances'][$target] ?? null;
abort_unless($acceptance && $acceptance['id'] === $data['acceptance_id'] && $acceptance['version_hash'] === $data['version_hash']
&& $this->sameNda($acceptance['nda'], $nda) && $acceptance['access_revision'] === (int) $subject->portal_access_revision, 409);
$privacy['grants'][$target] = ['interested_user_id' => (int) $target, 'seller_id' => $actor->id, 'at' => now()->toIso8601String(),
'version_hash' => $data['version_hash'], 'acceptance_id' => $acceptance['id'], 'nda' => $nda];
}
} else {
$previous = $privacy[$action === 'withdraw' ? 'acceptances' : 'grants'][$target] ?? null;
abort_unless($previous && $previous['version_hash'] === $data['version_hash'], 409);
unset($privacy['grants'][$target]);
if ($action === 'withdraw') {
unset($privacy['acceptances'][$target]);
}
}
$event = ['id' => (string) Str::uuid(), 'action' => $action, 'actor_id' => $actor->id, 'interested_user_id' => (int) $target,
'version_hash' => $data['version_hash'], 'at' => now()->toIso8601String(), 'nda_sha256' => $data['nda_sha256'] ?? null,
'nda' => $nda ?? ($before['acceptance']['nda'] ?? $before['grant']['nda'] ?? null)];
$privacy['events'][] = [...$event, 'reason' => Crypt::encryptString($data['reason'] ?? ''), 'before' => $before,
'after' => ['acceptance' => $privacy['acceptances'][$target] ?? null, 'grant' => $privacy['grants'][$target] ?? null]];
$context['offer_confidentiality'] = $privacy;
$record->workflow_context = $context;
$record->management_version++;
$record->save();
app(PortalProfileAccess::class)->audit($actor, 'offer_confidentiality_'.$action, ['record_id' => $id, ...$event]);
return ['recorded' => true, 'event_id' => $event['id']];
});
}
/** @return array<string,mixed> */
public function read(User $actor, int $id): array
{
return DB::transaction(function () use ($actor, $id) {
$record = $this->company($id, true);
$nda = $this->nda();
$privacy = $record->workflow_context['offer_confidentiality'] ?? [];
$accept = $privacy['acceptances'][(string) $actor->id] ?? null;
$grant = $privacy['grants'][(string) $actor->id] ?? null;
$hash = $this->approvedHash($record);
$seller = User::find($record->owner_user_id);
$subject = User::find($actor->id);
$allowed = $nda && $accept && $grant && $seller && $subject && $this->seller($seller, $record) && $subject->hasVerifiedEmail()
&& $accept['access_revision'] === (int) $subject->portal_access_revision && $grant['seller_id'] === $seller->id
&& $grant['acceptance_id'] === $accept['id'] && $grant['version_hash'] === $hash && $accept['version_hash'] === $hash
&& $this->sameNda($grant['nda'], $nda) && $this->sameNda($accept['nda'], $nda) && ! app(PortalProfileAccess::class)->conflict($actor, $record);
app(PortalProfileAccess::class)->audit($actor, $allowed ? 'offer_confidential_access' : 'offer_confidential_denied', ['record_id' => $id, 'version_hash' => $hash,
'nda' => $nda, 'acceptance_id' => $accept['id'] ?? null]);
// Return the refusal after transaction commit so the denied-access audit survives.
if (! $allowed) {
return ['denied' => true];
}
$business = $record->workflow_context['submission']['payload']['private_offer']['business'] ?? [];
foreach (['sector_id' => 'sector', 'secondary_sector_id' => 'secondary_sector'] as $field => $label) {
if (isset($business[$field])) {
$business[$label] = DB::table('sectors')->where('id', $business[$field])->value('name');
}
unset($business[$field]);
}
return ['offer_id' => $id, 'version_hash' => $hash, 'business' => $business];
});
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/** Optional commercial fields confirmed by Tempo for phase four; no title certification. */
final class PrivateOfferDetails
{
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function validate(array $input): array
{
$bytes = file_get_contents(resource_path('data/investmoz-opportunity-modular.schema.json'));
if ($bytes === false) {
throw new \RuntimeException('Opportunity schema unavailable.');
}
$schema = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
$property = $schema['$defs']['realEstatePatch']['properties'];
return Validator::make($input, [
'business' => 'sometimes|nullable|array:sector_id,secondary_sector_id,revenue,cash_flow,currency,reason,availability',
'business.sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->where('is_active', true)],
'business.secondary_sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->where('is_active', true)],
'business.revenue' => ['nullable', 'string', 'regex:/^\d{1,16}(\.\d{1,2})?$/'],
'business.cash_flow' => ['nullable', 'string', 'regex:/^-?\d{1,16}(\.\d{1,2})?$/'],
'business.currency' => 'nullable|required_with:business.revenue,business.cash_flow|in:MZN,USD',
'business.reason' => 'nullable|string|max:1000', 'business.availability' => 'nullable|in:available,sold',
'property' => 'sometimes|nullable|array:transaction_type,property_type,land_area,built_area,area_unit,bedrooms,bathrooms,amenities,availability',
'property.transaction_type' => 'nullable|in:sale,lease',
'property.property_type' => ['nullable', Rule::in($property['property_type']['enum'])],
'property.land_area' => 'nullable|numeric|gt:0', 'property.built_area' => 'nullable|numeric|gt:0',
'property.area_unit' => ['nullable', Rule::in(OpportunityResources::AREA_UNITS)],
'property.bedrooms' => 'nullable|integer|min:0|max:10000', 'property.bathrooms' => 'nullable|integer|min:0|max:10000',
'property.amenities' => 'nullable|array|max:100', 'property.amenities.*' => 'string|max:250',
'property.availability' => 'nullable|in:available,sold,leased',
])->validate();
}
/** @param array<string,mixed> $offer */
public function assertCategory(array $offer, string $category): void
{
Validator::make($offer, [
'business' => $category === 'business_sale' ? 'nullable|array' : 'prohibited',
'property' => $category === 'real_estate' ? 'nullable|array' : 'prohibited',
])->validate();
}
/** Snapshot all active linked originals and metadata in the existing editorial version.
* @return array<string,array<string,mixed>> */
public function resources(Opportunity $record): array
{
$result = ['documents' => [], 'images' => [], 'locations' => []];
foreach (array_keys($result) as $kind) {
foreach (app(OpportunityResources::class)->listing($record->id, $kind) as $row) {
if ($row->deleted_at !== null) {
continue;
}
if ($kind !== 'locations') {
$file = $row->file;
$doc = DB::table('documents')->where('id', $row->document_id)->first();
if (! $doc || $doc->deleted_at !== null || $doc->status !== 'active' || $doc->malware_scan_status !== 'clean'
|| ! hash_equals(trim($doc->sha256), trim((string) $doc->malware_scanned_sha256))) {
throw ValidationException::withMessages(['attachments' => 'Aguarde uma análise de segurança válida dos anexos.']);
}
$result[$kind][$row->id] = ['version' => $row->version, 'document_id' => $row->document_id, 'payload' => $row->payload, 'file' => $file];
} else {
$result[$kind][$row->id] = ['version' => $row->version, 'payload' => $row->payload];
}
}
ksort($result[$kind]);
}
if (count($result['images']) > 6) {
throw ValidationException::withMessages(['images' => 'A oferta admite no máximo seis fotografias.']);
}
return $result;
}
}
+296
View File
@@ -0,0 +1,296 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
/** Availability, reports and administrative plans in the existing opportunity and CRM tasks. */
final class PrivateOfferOperations
{
/** @param array<string,mixed> $context
* @return array<string,mixed> */
public function afterApproval(array $context): array
{
$days = config('private_offers.validity_days');
$reference = config('private_offers.validity_policy_reference');
if ($days !== null && (! is_int($days) || $days < 1 || $days > 36500 || ! is_string($reference) || trim($reference) === '')) {
throw ValidationException::withMessages(['validity' => 'Configuração de validade incompleta. Não foi aplicado um prazo presumido.']);
}
$operation = $context['offer_operation'] ?? ['status' => 'available', 'cycle' => 0];
$hash = $context['approval']['hash'];
$policy = ['days' => $days, 'policy_reference' => $reference, 'approval_hash' => $hash];
$operation['validity_policies'][$hash] ??= $policy;
$operation['validity'] ??= $operation['validity_policies'][$hash];
$operation['last_approval_hash'] = $hash;
return $operation;
}
private function offer(int $id, bool $lock = false): Opportunity
{
$record = Opportunity::query()->when($lock, fn ($q) => $q->lockForUpdate())->findOrFail($id);
abort_unless($record->isPrivateOfferPreparation(), 404);
return $record;
}
public function publisher(User $actor, Opportunity $record): bool
{
$access = app(PortalProfileAccess::class);
return $access->canView($actor, $record) && $access->mfa($actor) && ! $access->has($actor, 'auditor') && ! $access->conflict($actor, $record)
&& $access->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category) && $access->operational($actor, 'publish', $record->id);
}
/** @return Builder<Opportunity> */
public function staffQuery(User $actor): Builder
{
$access = app(PortalProfileAccess::class);
abort_unless($access->mfa($actor) && ! $access->has($actor, 'auditor'), 403);
$grants = [...$access->grants($actor, 'validator'), ...$access->grants($actor, 'publisher')];
abort_unless($grants !== [], 403);
return $access->scope(Opportunity::query(), $actor)->whereNotNull('workflow_context->private_offer_preparation')
->where('owner_user_id', '!=', $actor->id)->whereRaw("NOT COALESCE(workflow_context->'contributors','[]'::jsonb) @> ?::jsonb", [json_encode([$actor->id])])
->where(fn ($q) => $q->whereNull('organization_id')->orWhereNotIn('organization_id', DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->select('organization_id')))
->where(function ($q) use ($grants) {
foreach ($grants as $grant) {
$q->orWhere(function ($scope) use ($grant) {
$scope->whereIn('opportunity_category', json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR));
if ($grant->organization_id !== null) {
$scope->where('organization_id', $grant->organization_id);
}
});
}
});
}
/** @return array<string,mixed> */
public function presentation(User $actor, int $id): array
{
$record = app(PrivateOfferPreparation::class)->record($actor, $id);
$reviewer = app(PrivateOfferConfidentiality::class)->internal($actor, $record);
$publisher = $this->publisher($actor, $record);
$reports = [];
if ($reviewer) {
foreach ($record->workflow_context['offer_reports'] ?? [] as $report) {
$reports[] = [...$report, 'reason' => Crypt::decryptString($report['reason']), 'resolution_reason' => isset($report['resolution_reason']) ? Crypt::decryptString($report['resolution_reason']) : null,
'source_snapshot' => isset($report['source_snapshot']) ? json_decode(Crypt::decryptString($report['source_snapshot']), true, 512, JSON_THROW_ON_ERROR) : null];
}
}
$highlight = $record->workflow_context['offer_highlight'] ?? null;
return ['id' => $id, 'etag' => app(OpportunityManagement::class)->etag($record),
'availability' => app(OfferAvailability::class)->record($record), 'cycle' => $record->workflow_context['offer_operation']['cycle'] ?? 0,
'owner' => app(PrivateOfferConfidentiality::class)->seller($actor, $record), 'reviewer' => $reviewer, 'publisher' => $publisher,
'editorial_state' => app(PortalProfileAccess::class)->state($record), 'reports' => $reports,
'highlight' => $publisher && $highlight ? [...$highlight, 'note' => Crypt::decryptString($highlight['note'])] : null,
'commercial_activation' => false,
'history' => array_map(fn ($event) => array_intersect_key($event, array_flip(['id', 'action', 'actor_id', 'at', 'cycle'])), $record->workflow_context['offer_operation_history'] ?? [])];
}
/** @param array<string,mixed> $context
* @param array<string,mixed> $metadata */
private function save(Opportunity $record, ?User $actor, array $context, string $action, array $metadata = []): void
{
$event = ['id' => (string) Str::uuid(), 'action' => $action, 'actor_id' => $actor?->id, 'at' => now()->toIso8601String(),
'cycle' => $context['offer_operation']['cycle'] ?? 0, ...$metadata];
$context['offer_operation_history'][] = $event;
$record->workflow_context = $context;
$record->management_version++;
$record->save();
DB::table('audit_events')->insert(['source_system' => 'private-offers', 'entity_type' => 'opportunity', 'actor_user_id' => $actor?->id,
'action' => $action, 'data_classification' => 'restricted', 'correlation_id' => (string) Str::uuid(),
'after_data' => json_encode(['record_id' => $record->id, 'record_version' => $record->management_version, ...$event], JSON_THROW_ON_ERROR)]);
}
/** @param array<string,mixed> $context
* @return array<string,mixed> */
private function withdraw(array $context, string $origin): array
{
$context['offer_operation'] = [...($context['offer_operation'] ?? ['cycle' => 0]), 'status' => 'withdrawn', 'withdrawn_at' => now()->toIso8601String(), 'origin' => $origin];
return $context;
}
/** @param array<string,mixed> $input */
public function ownerAction(User $actor, int $id, array $input): Opportunity
{
$data = validator($input, ['action' => 'required|in:withdraw,request_reactivation,reactivate', 'confirm' => 'required|accepted',
'etag' => 'required_unless:action,withdraw|string|size:64', 'reason' => 'nullable|string|max:1000'])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
$record = $this->offer($id, true);
abort_unless(app(PrivateOfferConfidentiality::class)->seller($actor, $record), 403);
$context = $record->workflow_context;
$state = app(OfferAvailability::class)->record($record)['state'];
if ($data['action'] === 'withdraw') {
if (($context['offer_operation']['status'] ?? null) === 'withdrawn') {
return $record;
}
$this->save($record, $actor, $this->withdraw($context, 'owner'), 'offer_withdrawn', ['reason' => Crypt::encryptString($data['reason'] ?? '')]);
return $record;
}
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409);
if ($data['action'] === 'request_reactivation') {
abort_unless(in_array($state, ['withdrawn', 'expired'], true), 409, 'A oferta não está retirada ou expirada.');
$editorialState = app(PortalProfileAccess::class)->state($record);
abort_unless(isset($context['submission']) || in_array($editorialState, ['draft', 'needs_information'], true), 409,
'A preparação anterior ainda precisa da vinculação editorial existente antes de abrir nova revisão.');
$context['offer_operation'] = [...($context['offer_operation'] ?? []), 'status' => 'revalidation', 'cycle' => ($context['offer_operation']['cycle'] ?? 0) + 1,
'requested_by' => $actor->id, 'requested_at' => now()->toIso8601String()];
// Reopen the same editorial engine, retaining old submissions and decisions.
$record->workflow_context = $context;
$record->save();
if (isset($context['submission'])) {
$record = app(OpportunityProfileWorkflow::class)->transition($actor, $id, ['etag' => app(OpportunityManagement::class)->etag($record),
'action' => 'request_revision', 'reason' => 'Pedido explícito de nova validação para reactivar a oferta. '.($data['reason'] ?? '')]);
}
$this->save($record, $actor, $record->workflow_context, 'offer_reactivation_requested');
return $record;
}
abort_unless($state === 'revalidation' && app(PortalProfileAccess::class)->state($record) === 'approved', 409, 'É necessária nova validação editorial.');
$snapshot = app(PrivateOfferPreparation::class)->snapshot($record);
abort_unless(($snapshot['payload']['operational_cycle'] ?? null) === $context['offer_operation']['cycle']
&& hash_equals($context['approval']['hash'] ?? '', $snapshot['hash'])
&& ($context['submission']['payload']['operational_cycle'] ?? null) === $context['offer_operation']['cycle'], 409);
$context['offer_operation']['status'] = 'available';
$context['offer_operation']['activated_hash'] = $snapshot['hash'];
$this->save($record, $actor, $context, 'offer_reactivated_privately');
return $record;
});
}
public function expire(int $id): bool
{
return DB::transaction(function () use ($id) {
$record = $this->offer($id, true);
$availability = app(OfferAvailability::class)->record($record);
if ($availability['state'] !== 'expired' || ($record->workflow_context['offer_operation']['status'] ?? null) === 'expired') {
return false;
}
$context = $record->workflow_context;
$context['offer_operation'] = [...($context['offer_operation'] ?? []), 'status' => 'expired', 'expired_at' => $availability['expires_at']];
$this->save($record, null, $context, 'offer_expired', ['expires_at' => $availability['expires_at']]);
return true;
});
}
public function canReport(User $actor, Opportunity $record): bool
{
if (app(PortalProfileAccess::class)->canView($actor, $record)) {
return true;
}
try {
app(OfferPublicCatalogue::class)->detail($record->id);
return true;
} catch (HttpExceptionInterface $error) {
return false;
}
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function report(User $actor, int $id, array $input): array
{
$data = validator($input, ['client_key' => 'required|uuid', 'reason' => 'required|string|min:20|max:2000', 'confirm' => 'required|accepted'])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
$record = $this->offer($id, true);
$context = $record->workflow_context;
$key = $data['client_key'];
$hash = hash('sha256', $data['reason']);
if (isset($context['offer_reports'][$key])) {
$previous = $context['offer_reports'][$key];
abort_unless($previous['reporter_id'] === $actor->id, 404);
abort_unless($previous['command_hash'] === $hash, 409);
return ['id' => $key, 'replayed' => true];
}
abort_unless($this->canReport($actor, $record), 404);
try {
$source = app(OfferPublicCatalogue::class)->detail($id);
} catch (HttpExceptionInterface $error) {
$source = Arr::only(app(OpportunityManagement::class)->values($record), ['title', 'description', 'opportunity_category', 'private_offer']);
}
$task = app(OpportunityWorkflowTasks::class)->ensure($record, 'offer_report_'.$key, 'Denúncia da Oferta '.$record->reference);
$context['offer_reports'][$key] = ['id' => $key, 'reporter_id' => $actor->id, 'at' => now()->toIso8601String(), 'command_hash' => $hash,
'reason' => Crypt::encryptString($data['reason']), 'version' => 1, 'status' => 'open', 'task_id' => $task,
'source_snapshot' => Crypt::encryptString(json_encode($source, JSON_THROW_ON_ERROR)),
'source_record_version' => $record->management_version, 'source_approval_hash' => $context['approval']['hash'] ?? null,
'source_public_hash' => $context['offer_publication']['publication']['hash'] ?? null];
$this->save($record, $actor, $context, 'offer_reported', ['report_id' => $key]);
return ['id' => $key, 'replayed' => false];
});
}
/** @param array<string,mixed> $input */
public function handleReport(User $actor, int $id, string $reportId, array $input): void
{
$data = validator($input, ['etag' => 'required|string|size:64', 'version' => 'required|integer|min:1', 'action' => 'required|in:start_review,resolve_no_withdrawal,resolve_withdrawal',
'reason' => 'required|string|min:20|max:2000', 'confirm' => 'required|accepted'])->validate();
DB::transaction(function () use ($actor, $id, $reportId, $data) {
$record = $this->offer($id, true);
abort_unless(app(PrivateOfferConfidentiality::class)->internal($actor, $record), 403);
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409);
$context = $record->workflow_context;
$report = $context['offer_reports'][$reportId] ?? null;
abort_unless($report !== null, 404);
abort_unless($report['version'] === (int) $data['version'] && in_array($report['status'], ['open', 'in_review'], true), 409);
if ($data['action'] === 'start_review') {
abort_unless($report['status'] === 'open', 409);
app(OpportunityWorkflowTasks::class)->assign($record, $report['task_id'], $actor);
$report['status'] = 'in_review';
} else {
abort_unless($report['status'] === 'in_review' && $report['reviewer_id'] === $actor->id, 409, 'A denúncia deve ser tratada pelo revisor que a iniciou.');
app(OpportunityWorkflowTasks::class)->complete($report['task_id'], 'Denúncia tratada; decisão no histórico privado.');
$report['status'] = 'resolved';
$report['resolution'] = $data['action'];
if ($data['action'] === 'resolve_withdrawal') {
$context = $this->withdraw($context, 'moderation');
}
}
$report['reviewer_id'] = $actor->id;
$report['version']++;
$report['updated_at'] = now()->toIso8601String();
$report['resolution_reason'] = Crypt::encryptString($data['reason']);
$report['history'][] = ['action' => $data['action'], 'actor_id' => $actor->id, 'at' => now()->toIso8601String(), 'reason' => Crypt::encryptString($data['reason'])];
$context['offer_reports'][$reportId] = $report;
$this->save($record, $actor, $context, 'offer_report_'.$data['action'], ['report_id' => $reportId]);
});
}
/** @param array<string,mixed> $input */
public function highlight(User $actor, int $id, array $input): void
{
$data = validator($input, ['etag' => 'required|string|size:64', 'version' => 'required|integer|min:0', 'status' => 'required|in:draft,archived',
'label' => 'required|string|max:150', 'starts_at' => 'nullable|date', 'ends_at' => 'nullable|required_with:starts_at|date|after:starts_at',
'note' => 'required|string|min:20|max:1000', 'confirm' => 'required|accepted'])->validate();
DB::transaction(function () use ($actor, $id, $data) {
$record = $this->offer($id, true);
abort_unless($this->publisher($actor, $record), 403);
$context = $record->workflow_context;
$before = $context['offer_highlight'] ?? null;
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']) && (int) $data['version'] === ($before['version'] ?? 0), 409);
$plan = ['version' => (int) $data['version'] + 1, 'status' => $data['status'], 'label' => $data['label'], 'starts_at' => $data['starts_at'] ?? null,
'ends_at' => $data['ends_at'] ?? null, 'note' => Crypt::encryptString($data['note']), 'actor_id' => $actor->id, 'at' => now()->toIso8601String()];
$context['offer_highlight_history'][] = $before;
$context['offer_highlight'] = $plan;
$this->save($record, $actor, $context, 'offer_highlight_prepared', ['plan_version' => $plan['version'], 'status' => $plan['status']]);
});
}
}
+230
View File
@@ -0,0 +1,230 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/** Private commercial adapter: the existing opportunity owns identity, workflow and audit. */
final class PrivateOfferPreparation
{
/** @return array<string,mixed> */
public function validate(mixed $input, bool $complete = false): array
{
$validated = Validator::make(['private_offer' => $input], [
'private_offer' => 'present|array:price_mode,amount,currency,contact_id,business,property',
'private_offer.price_mode' => 'nullable|in:amount,negotiable',
'private_offer.amount' => ['nullable', 'string', ...($complete ? ['required_if:private_offer.price_mode,amount'] : []), 'prohibited_unless:private_offer.price_mode,amount', 'regex:/^\d{1,16}(\.\d{1,2})?$/'],
'private_offer.currency' => ['nullable', ...($complete ? ['required_if:private_offer.price_mode,amount'] : []), 'prohibited_unless:private_offer.price_mode,amount', 'in:MZN,USD'],
'private_offer.contact_id' => 'nullable|uuid',
'private_offer.business' => 'sometimes|nullable|array',
'private_offer.property' => 'sometimes|nullable|array',
])->validate()['private_offer'];
return array_replace($validated, app(PrivateOfferDetails::class)->validate(array_intersect_key($validated, array_flip(['business', 'property']))));
}
/** @param array<string,mixed> $input */
public function save(User $actor, array $input, ?int $id = null): Opportunity
{
foreach (['title', 'description'] as $field) {
if (is_string($input[$field] ?? null)) {
$input[$field] = trim($input[$field]);
}
}
$allowed = ['creation_key', 'etag', 'reason', 'title', 'description', 'opportunity_category', 'organization_id', 'private_offer', ...array_keys(InvestmentGeography::FIELDS)];
if (array_diff(array_keys($input), $allowed)) {
throw ValidationException::withMessages(['offer' => 'O pedido contém campos fora da preparação privada.']);
}
Validator::make($input, ['opportunity_category' => ['sometimes', 'required', Rule::in(OpportunityManagement::CATEGORIES)]])->validate();
return DB::transaction(function () use ($actor, $input, $id) {
$record = app(OpportunityManagement::class)->save($actor, $input, $id, true);
$values = app(OpportunityManagement::class)->values($record);
app(PrivateOfferDetails::class)->assertCategory($values['private_offer'] ?? [], $values['opportunity_category']);
$contactId = app(OpportunityManagement::class)->values($record)['private_offer']['contact_id'] ?? null;
if ($contactId && ! app(OpportunityContacts::class)->related($record, $contactId)) {
throw ValidationException::withMessages(['private_offer.contact_id' => 'Seleccione um contacto autorizado do promotor.']);
}
return $record;
});
}
public function record(User $actor, int $id, bool $edit = false): Opportunity
{
$record = Opportunity::findOrFail($id);
abort_unless($record->isPrivateOfferPreparation(), 404);
app(PortalProfileAccess::class)->authorize($actor, $edit ? 'edit' : 'view', $record);
return $record;
}
/** Register submission through the existing editorial engine when available.
* @param array<string,mixed> $input */
public function submit(User $actor, int $id, array $input): Opportunity
{
$data = Validator::make($input, ['etag' => 'required|string|size:64', 'confirm' => 'required|accepted'])->validate();
if (app(PortalEditorialWorkflow::class)->available()) {
$record = $this->record($actor, $id);
$access = app(PortalProfileAccess::class);
if ($access->state($record) === 'submitted') {
abort_unless($access->promoterRecordScope($actor, $record) && $access->canCreate($actor, $record->organization_id, $record->opportunity_category)
&& $access->operational($actor, 'edit', $id), 403);
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409);
return $record;
}
return app(OpportunityProfileWorkflow::class)->transition($actor, $id, [...$data, 'action' => 'submit', 'reason' => 'Submissão privada da oferta conferida pelo promotor.']);
}
return DB::transaction(function () use ($actor, $id, $data) {
$record = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail();
abort_unless($record->isPrivateOfferPreparation(), 404);
$access = app(PortalProfileAccess::class);
$access->authorize($actor, 'view', $record);
abort_if(($record->workflow_context['private_offer_preparation']['version'] ?? 1) >= 2, 503, 'O circuito editorial está indisponível. A versão e o histórico foram preservados; não é permitida a retoma pelo percurso anterior.');
abort_unless($access->promoterRecordScope($actor, $record)
&& $access->canCreate($actor, $record->organization_id, $record->opportunity_category)
&& $access->operational($actor, 'edit', $record->id), 403);
$management = app(OpportunityManagement::class);
abort_unless(hash_equals($management->etag($record), $data['etag']), 409, 'A oferta mudou. Recupere a versão actual antes de submeter.');
if (data_get($record->workflow_context, 'private_offer_preparation.state') === 'submitted') {
return $record;
}
$access->authorize($actor, 'edit', $record);
$values = $management->values($record);
Validator::make($values, ['title' => 'required|string|max:150', 'description' => 'required|string|max:50000',
'opportunity_category' => 'required', 'private_offer.price_mode' => 'required|in:amount,negotiable', 'private_offer.contact_id' => 'required|uuid'])->validate();
$this->validate($values['private_offer'], true);
if (! app(OpportunityContacts::class)->related($record, $values['private_offer']['contact_id'])) {
throw ValidationException::withMessages(['private_offer.contact_id' => 'O contacto já não está disponível neste âmbito.']);
}
// Recheck revoked grants and representation inside the same transaction.
abort_unless($access->canCreate($actor, $record->organization_id, $record->opportunity_category), 403);
$path = app(InvestmentGeography::class)->path($record->primary_territory_id, true);
if ($record->primary_territory_id && $path === []) {
throw ValidationException::withMessages(['province_id' => 'Reconcilie a localização antes de submeter.']);
}
if ($path !== []) {
app(InvestmentGeography::class)->selection(array_combine(array_slice(array_keys(InvestmentGeography::FIELDS), 0, count($path)), array_column($path, 'id')), true);
}
$context = $record->workflow_context;
$context['private_offer_preparation'] = ['version' => 1, 'state' => 'submitted', 'submitted_by' => $actor->id,
'submitted_at' => now()->toIso8601String(), 'submitted_revision' => $record->management_version];
$record->workflow_context = $context;
$record->management_version++;
$record->save();
$access->audit($actor, 'private_offer_submitted', ['record_id' => $record->id, 'version' => $record->management_version, 'public' => false]);
return $record;
});
}
/** Adapter of the five common fields; never relaxes the full canonical contract.
* @return array<string,mixed> */
public function snapshot(Opportunity $record): array
{
$values = app(OpportunityManagement::class)->values($record);
Validator::make($values, ['title' => 'required|string|max:150', 'description' => 'required|string|max:50000',
'opportunity_category' => 'required', 'private_offer.price_mode' => 'required|in:amount,negotiable', 'private_offer.contact_id' => 'required|uuid'])->validate();
$offer = $this->validate($values['private_offer'], true);
app(PrivateOfferDetails::class)->assertCategory($offer, $values['opportunity_category']);
$resources = app(PrivateOfferDetails::class)->resources($record);
if (! app(OpportunityContacts::class)->related($record, $offer['contact_id'])) {
throw ValidationException::withMessages(['private_offer.contact_id' => 'O contacto já não está disponível neste âmbito.']);
}
$contact = DB::table('contacts')->where('id', $offer['contact_id'])->first();
$path = app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true);
if (! empty($values['primary_territory_id']) && $path === []) {
throw ValidationException::withMessages(['province_id' => 'Reconcilie a localização antes da decisão.']);
}
if ($path !== []) {
app(InvestmentGeography::class)->selection(array_combine(array_slice(array_keys(InvestmentGeography::FIELDS), 0, count($path)), array_column($path, 'id')), true);
}
$payload = ['title' => $values['title'], 'description' => $values['description'], 'opportunity_category' => $values['opportunity_category'],
'owner_user_id' => $record->owner_user_id, 'organization_id' => $record->organization_id, 'private_offer' => $offer,
'contact_fingerprint' => hash('sha256', json_encode($contact, JSON_THROW_ON_ERROR)), 'territory' => $path];
$extended = isset($offer['business']) || isset($offer['property']) || array_filter($resources) !== [];
if ($extended) {
$payload['resources'] = $resources;
}
$cycle = $record->workflow_context['offer_operation']['cycle'] ?? 0;
if ($cycle > 0) {
$payload['operational_cycle'] = $cycle;
}
$normalize = function (mixed $value) use (&$normalize): mixed {
if (is_object($value)) {
$value = get_object_vars($value);
}
if (! is_array($value)) {
return $value;
}
if (! array_is_list($value)) {
ksort($value);
}
return array_map($normalize, $value);
};
$payload = $normalize($payload);
$contract = $cycle > 0 ? 'private-offer-editorial-v4' : ($extended ? 'private-offer-editorial-v3' : 'private-offer-editorial-v2');
return ['payload' => $payload, 'hash' => hash('sha256', json_encode(['contract' => $contract, 'payload' => $payload], JSON_THROW_ON_ERROR)),
'record_version' => $record->management_version, 'contract_version' => $contract, 'resources' => $resources];
}
/** @return array<string,mixed> */
public function presentation(User $actor, Opportunity $record): array
{
app(PortalProfileAccess::class)->authorize($actor, 'view', $record);
$values = app(OpportunityManagement::class)->values($record);
$fresh = null;
$contactId = $values['private_offer']['contact_id'] ?? null;
$contactLabel = $contactId && app(OpportunityContacts::class)->related($record, $contactId)
? DB::table('contacts')->where('id', $contactId)->value('full_name') : null;
if (isset($record->workflow_context['approval'])) {
try {
$fresh = hash_equals($record->workflow_context['approval']['hash'], $this->snapshot($record)['hash']);
} catch (ValidationException $error) {
$fresh = false;
}
}
$history = $record->workflow_context['events'] ?? [];
$names = User::whereIn('id', array_filter([...array_column($history, 'actor_id'), ...array_column($history, 'assignee_user_id')]))->pluck('name', 'id');
$history = array_map(fn ($event) => [...$event, 'actor_name' => $names[$event['actor_id']] ?? 'Interveniente indisponível',
'assignee_name' => isset($event['assignee_user_id']) ? ($names[$event['assignee_user_id']] ?? 'Interveniente indisponível') : null], $history);
$versions = [...($record->workflow_context['decision_history'] ?? []), ...($record->workflow_context['submission_history'] ?? [])];
if (isset($record->workflow_context['submission'])) {
$versions[] = ['submission' => $record->workflow_context['submission'], 'approval' => $record->workflow_context['approval'] ?? null,
'rejection' => $record->workflow_context['rejection'] ?? null];
}
$uniqueVersions = [];
foreach ($versions as $version) {
if (! isset($version['submission']['payload']['private_offer'])) {
continue;
}
$key = $version['submission']['record_version'].':'.$version['submission']['hash'].':'.($version['submission']['review_cycle'] ?? 0);
$previous = $uniqueVersions[$key] ?? [];
$uniqueVersions[$key] = ['submission' => $version['submission'], 'approval' => $version['approval'] ?? $previous['approval'] ?? null,
'rejection' => $version['rejection'] ?? $previous['rejection'] ?? null];
}
$workflow = app(OpportunityProfileWorkflow::class);
$actions = $workflow->actions($actor, $record);
return ['id' => $record->id, 'reference' => $record->reference, 'values' => $values,
'etag' => app(OpportunityManagement::class)->etag($record), 'state' => app(PortalProfileAccess::class)->state($record),
'canEdit' => app(PortalProfileAccess::class)->canEdit($actor, $record), 'organization_id' => $record->organization_id,
'actions' => $actions,
'reviewers' => in_array('assign_review', $actions, true) ? $workflow->participants($actor, $record)['validators'] : [],
'reauthenticationRequired' => app(OpportunityProfileWorkflow::class)->reauthenticationRequired($actor, $record),
'history' => $history, 'submittedVersions' => array_values($uniqueVersions), 'correctionItems' => $record->workflow_context['correction_items'] ?? [],
'approval' => $record->workflow_context['approval'] ?? null, 'approvalFresh' => $fresh, 'contactLabel' => $contactLabel, 'tasks' => app(OpportunityWorkflowTasks::class)->listing($record),
'territory' => app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true)];
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ final class ProactiveInvestment
'proactive.evidence.*.observation_id' => 'sometimes|nullable|string|size:64',
'proactive.supply_comparisons' => 'sometimes|array|max:40',
'proactive.evidence.*.id' => 'required|uuid|distinct',
'proactive.evidence.*.category' => 'required|in:activity,resource,infrastructure,supply,demand',
'proactive.evidence.*.category' => 'required|in:activity,resource,infrastructure,supply,demand,workforce',
'proactive.evidence.*.purpose' => 'required|in:local,methodology',
'proactive.evidence.*.access' => 'required|in:internal,public',
'proactive.evidence.*.document_id' => 'nullable|uuid',
+196
View File
@@ -0,0 +1,196 @@
<?php
namespace App\Services;
use Illuminate\Support\Str;
/** Text matching shared by the database and the built-in page catalogue. */
final class SearchTerms
{
/** @return array{type:string,category:?string,label:string,kind:?string,filter:string}|null */
public function requestIntent(string $query): ?array
{
if ($count = $this->countIntent($query)) {
return ['type' => 'count', ...$count];
}
$normalized = trim(mb_strtolower(Str::ascii($query)), " \t\n\r\0\x0B?!.");
if (preg_match('/^(?:como|onde|quero|preciso de)\s+(?:(?:posso|faco para|devo)\s+)?licenciar(?:$|\s+(?:(?:uma|a|minha|o|um|meu)\s+)?(?:empresa|negocio|actividade|atividade|investimento)\b)/', $normalized)) {
return ['type' => 'guide', 'category' => 'Páginas e serviços', 'label' => 'Licenciamento da empresa', 'kind' => null, 'filter' => 'licenciamento'];
}
$product = null;
if (preg_match('/^(?:(?:tipos?|lista|catalogo) de |quais (?:sao )?(?:os )?)?produtos?\b(.*)$/', $normalized, $match)) {
$product = $match[1];
} elseif (preg_match('/^onde (?:encontrar|produzir|se produz|existem|ha)\s+(.+)$/', $normalized, $match)) {
$product = $match[1];
}
if ($product !== null) {
$category = preg_match('/\bfinanceiros?\b/', $product) ? 'Produtos financeiros' : (preg_match('/\bfisicos?\b/', $product) ? 'Produtos e localizações' : null);
$product = preg_replace('/\b(?:e (?:a )?(?:sua )?localizacao|e (?:as )?(?:suas )?localizacoes|financeiros?|fisicos?|existem|disponiveis|no portal)\b/', ' ', $product);
return ['type' => 'products', 'category' => $category, 'label' => 'Produtos e localização', 'kind' => null, 'filter' => trim(preg_replace('/\s+/', ' ', $product))];
}
if (preg_match('/^(?:listar|mostrar|mostre|ver|quais (?:sao )?(?:os|as))\s+(.+)$/', $normalized, $match)
&& ($list = $this->countIntent('quantos '.$match[1]))) {
return ['type' => 'list', ...$list];
}
return null;
}
/** @return array{category:string,label:string,kind:?string,filter:string}|null */
public function countIntent(string $query): ?array
{
$query = trim(mb_strtolower(Str::ascii($query)), " \t\n\r\0\x0B?!.");
$types = [
'corredor|corredores' => ['Carteira: projectos, clusters e corredores', 'corredores', 'corridor'],
'cluster|clusters' => ['Carteira: projectos, clusters e corredores', 'clusters', 'cluster'],
'projectos? ancora|projetos? ancora' => ['Carteira: projectos, clusters e corredores', 'projectos âncora', 'project'],
'oportunidade|oportunidades' => ['Oportunidades', 'oportunidades', null],
'negocio|negocios' => ['Negócios', 'negócios', null],
'documento|documentos' => ['Documentos e legislação', 'documentos', null],
'artigo|artigos' => ['Artigos', 'artigos', null],
'sector|sectores|setor|setores' => ['Sectores', 'sectores', null],
'localizacao|localizacoes' => ['Localizações', 'localizações', null],
'zonas? de investimento|zona|zonas' => ['Zonas de investimento', 'zonas de investimento', null],
'parceria|parcerias' => ['Parcerias', 'parcerias', null],
];
foreach ($types as $pattern => [$category, $label, $kind]) {
if (preg_match('/^(?:quantos|quantas|numero de|quantidade de|total de|qual (?:e )?o numero de)\s+(?:'.$pattern.')\b(.*)$/u', $query, $match)) {
$filter = preg_replace('/\b(?:no portal|no investemoz|em mocambique|de investimento|existem|existe|existentes|existente|ha|temos|estao|sao|disponiveis|disponivel|publicados|publicadas|registados|registadas|cadastrados|cadastradas)\b/u', ' ', $match[1]);
return ['category' => $category, 'label' => $label, 'kind' => $kind, 'filter' => trim(preg_replace('/\s+/', ' ', $filter))];
}
}
return null;
}
/** @return list<string> */
public function tokens(string $query): array
{
$normalized = mb_strtolower(Str::ascii($query));
$tokens = array_values(array_filter(array_unique(array_map(
fn ($word) => trim($word, ".,;:!?\"'()[]{}"),
preg_split('/\s+/u', trim($normalized), -1, PREG_SPLIT_NO_EMPTY) ?: []
)), fn ($word) => $word !== ''));
if (count($tokens) > 1) {
$meaningful = array_values(array_diff($tokens, ['a', 'as', 'o', 'os', 'de', 'do', 'da', 'dos', 'das', 'em', 'no', 'na', 'nos', 'nas', 'para', 'por', 'e', 'como', 'qual', 'quais', 'um', 'uma']));
return $meaningful === [] ? $tokens : $meaningful;
}
return $tokens;
}
/** Known Portuguese spelling and number variants; do not infer semantic equivalence.
* @return list<string>
*/
public function variants(string $token): array
{
foreach ([['sector', 'sectores', 'setor', 'setores'], ['projecto', 'projectos', 'projeto', 'projetos'],
['investir', 'investimento', 'investimentos'], ['investidor', 'investidores'], ['oportunidade', 'oportunidades'], ['documento', 'documentos'],
['empresa', 'empresas'], ['parceria', 'parcerias'], ['zona', 'zonas'], ['formulario', 'formularios'],
['provincia', 'provincias'], ['corredor', 'corredores'], ['cluster', 'clusters'], ['imposto', 'impostos'], ['licenca', 'licencas'],
['banco', 'bancos'], ['seguro', 'seguros'], ['produto', 'produtos'], ['agricola', 'agricolas']] as $group) {
if (in_array($token, $group, true)) {
return $group;
}
}
return [$token];
}
public function pattern(string $token): string
{
return '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $token).'%';
}
/** Columns are trusted application constants, never user input.
* @param \Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Query\Builder $query
* @param list<literal-string> $columns
*/
public function orFields($query, array $columns, string $token): void
{
foreach ($columns as $column) {
foreach ($this->variants($token) as $variant) {
$query->orWhereRaw($this->normalizedColumn($column)." LIKE ? ESCAPE '!'", [$this->pattern($variant)]);
}
}
}
/** @param \Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Query\Builder $query
* @param list<literal-string> $columns
* @param list<string> $tokens
*/
public function constrain($query, array $columns, array $tokens): void
{
foreach ($tokens as $token) {
$query->where(fn ($match) => $this->orFields($match, $columns, $token));
}
}
/** @param list<string> $tokens */
public function matches(string $text, array $tokens): bool
{
$text = mb_strtolower(Str::ascii($text));
foreach ($tokens as $token) {
if (! array_filter($this->variants($token), fn ($variant) => str_contains($text, $variant))) {
return false;
}
}
return true;
}
/** @param literal-string $column
* @return literal-string
*/
private function normalizedColumn(string $column): string
{
return "translate(lower(coalesce({$column}::text, '')), 'áàâãäéèêëíìîïóòôõöúùûüçñ', 'aaaaaeeeeiiiiooooouuuucn')";
}
/** @param list<string> $tokens */
public function score(string $title, string $phrase, array $tokens): int
{
$title = mb_strtolower(Str::ascii($title));
$phrase = mb_strtolower(Str::ascii(trim($phrase)));
$score = $title === $phrase ? 1000 : (str_starts_with($title, $phrase) ? 600 : (str_contains($title, $phrase) ? 400 : 0));
foreach ($tokens as $token) {
if ($this->matches($title, [$token])) {
$score++;
}
}
return $score;
}
/** The same title score used for in-memory pages, with bound search terms.
* @param \Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Query\Builder $query
* @param list<string> $tokens
* @param literal-string $titleColumn
* @param literal-string $tieColumn
*/
public function orderByRelevance($query, string $titleColumn, string $phrase, array $tokens, string $tieColumn): void
{
$column = $this->normalizedColumn($titleColumn);
$phrase = mb_strtolower(Str::ascii(trim($phrase)));
$sql = "CASE WHEN {$column} = ? THEN 1000 WHEN {$column} LIKE ? ESCAPE '!' THEN 600 WHEN {$column} LIKE ? ESCAPE '!' THEN 400 ELSE 0 END";
$bindings = [$phrase, ltrim($this->pattern($phrase), '%'), $this->pattern($phrase)];
foreach ($tokens as $token) {
$conditions = [];
foreach ($this->variants($token) as $variant) {
$conditions[] = "{$column} LIKE ? ESCAPE '!'";
$bindings[] = $this->pattern($variant);
}
$sql .= ' + CASE WHEN ('.implode(' OR ', $conditions).') THEN 1 ELSE 0 END';
}
$query->orderByRaw('('.$sql.') DESC', $bindings);
if (str_contains($titleColumn, '(')) {
$query->orderByRaw($titleColumn);
} else {
$query->orderBy($titleColumn);
}
$query->orderBy($tieColumn);
}
}
+16 -9
View File
@@ -74,7 +74,7 @@ final class SectorInvestment
}
}
return ['articles' => $articles, 'relations' => collect($relations)->unique('id')->values()->all(), 'locations' => $locations,
return ['resource_reference' => isset(SectorResourceReferences::SOURCES[$sector->slug]) ? app(SectorResourceReferences::class)->view($sector->slug) : null, 'articles' => $articles, 'relations' => collect($relations)->unique('id')->values()->all(), 'locations' => $locations,
'start_href' => app(InvestorPath::class)->available() ? '/investimentos/a-partir-do-setor/'.$sector->slug : null,
'start_notice' => app(InvestorPath::class)->available() ? null : 'O percurso para guardar a ideia está temporariamente indisponível. A consulta e o apoio continuam disponíveis.',
'register_href' => app(PortalProfileAccess::class)->available() ? ($creation ? $creation.'?sector_id='.$sector->id : '/perfil/promotor') : null,
@@ -114,24 +114,31 @@ final class SectorInvestment
*/
public function attach(User $actor, string $id, array $input): void
{
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'sector_slug' => 'required|string|max:150'])->validate();
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'sector_slug' => 'required|string|max:150', 'sector_reference' => 'nullable|array'])->validate();
$context = $this->context($data['sector_slug']);
abort_if(! empty($data['sector_reference']) && ! isset(SectorResourceReferences::SOURCES[$context['slug']]), 422);
$reference = isset($data['sector_reference']) ? app(SectorResourceReferences::class)->selection($context['slug'], $data['sector_reference']) : null;
abort_unless(app(InvestorPath::class)->available(), 404);
DB::transaction(function () use ($actor, $id, $data, $context) {
DB::transaction(function () use ($actor, $id, $data, $context, $reference) {
$desk = app(VirtualDesk::class);
$case = $desk->case($actor, $id, true, true);
abort_if($case->closed_at, 409, 'O dossier está encerrado.');
abort_unless(DB::table('investor_journeys')->where('id', $case->journey_id)->whereNotNull('path_definition_id')->whereNull('deleted_at')->exists(), 409, 'Este dossier conserva outro roteiro.');
$payload = $desk->decode($case->payload);
foreach ($payload['investor_path']['sector_contexts'] ?? [] as $existing) {
if ($existing['id'] === $context['id']) {
return;
}
$hasSector = array_any($payload['investor_path']['sector_contexts'] ?? [], fn ($row) => $row['id'] === $context['id']);
$hasReference = $reference !== null && array_any($payload['investor_path']['sector_references'] ?? [], fn ($row) => $row['key'] === $reference['key']);
if ($hasSector && ($reference === null || $hasReference)) {
return;
}
abort_unless((int) $case->version === $data['version'], 409, 'O dossier foi alterado; recupere a versão atual.');
$payload['investor_path']['sector_contexts'][] = [...$context, 'actor_id' => $actor->id, 'saved_at' => now()->toIso8601String()];
if (! $hasSector) {
$payload['investor_path']['sector_contexts'][] = [...$context, 'actor_id' => $actor->id, 'saved_at' => now()->toIso8601String()];
}
if ($reference !== null && ! $hasReference) {
$payload['investor_path']['sector_references'][] = [...$reference, 'actor_id' => $actor->id, 'saved_at' => now()->toIso8601String()];
}
DB::table('desk_cases')->where('id', $id)->update(['payload' => $desk->encode($payload), 'version' => $case->version + 1, 'updated_at' => now()]);
$desk->event($desk->case($actor, $id), $actor, 'sector_context_referenced', ['sector_id' => $context['id'], 'sector_slug' => $context['slug']]);
$desk->event($desk->case($actor, $id), $actor, 'sector_context_referenced', ['sector_id' => $context['id'], 'sector_slug' => $context['slug'], 'sector_reference_key' => $reference['key'] ?? null]);
});
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace App\Services;
use App\Support\CheckedFile;
use Illuminate\Support\Facades\Validator;
/** Versioned reference content over existing sector pages and private dossiers. */
final class SectorResourceReferences
{
public const SOURCES = [
'mineracao' => '7202fb5986b40e62347867c653770030683ffec2701a2921e84342f15e9f3d8a',
'energia-especifica' => '2d9d94ac70115ade43c4093a4d5b302aaa93623a149f936615c38ba67b1e2bd2',
];
// Generated data are checked independently from the unchanged Word originals.
public const DATA = [
'mineracao' => ['file' => 'mining-reference-20261111.json', 'hash' => '9274f4059ef5d7aa8ec625f1c6f8fd4a11c8e314c5282c5f24c38dbcf7c68bb5'],
'energia-especifica' => ['file' => 'energy-reference-20261111.json', 'hash' => '3c0fe65a0682d952987f63bb48aaaada9ba4f8d10ae40149658211f56fa8e7ac'],
];
/** @return array<string,mixed> */
public function data(string $sector): array
{
abort_unless(isset(self::DATA[$sector]), 404);
$path = database_path('data/'.self::DATA[$sector]['file']);
abort_unless(hash_equals(self::DATA[$sector]['hash'], CheckedFile::sha256($path)), 503, 'A versão das referências sectoriais mudou.');
$data = json_decode(CheckedFile::read($path), true, 512, JSON_THROW_ON_ERROR);
abort_unless(($data['source_sha256'] ?? null) === self::SOURCES[$sector], 503, 'Reconciliar a versão da referência antes de usar.');
return $data;
}
/** @return array<string,mixed> */
public function view(string $sector): array
{
$data = $this->data($sector);
$mining = $sector === 'mineracao';
$groups = $mining ? $data['minerals'] : $data['groups'];
$risks = $data['risks'];
if ($mining) {
$start = array_find_key($risks, fn ($text) => str_starts_with($text, 'Riscos e pontos'));
$end = array_find_key($risks, fn ($text) => str_starts_with($text, 'Próximos passos'));
$risks = $start !== null && $end !== null ? array_slice($risks, $start + 1, $end - $start - 1) : [];
}
return ['sector_slug' => $sector, 'title' => $mining ? 'Mineração e cadeias de valor' : 'Energia e cadeias de valor',
'introduction' => $mining ? 'Explore recursos minerais por localização e possibilidades de fornecimento, logística, energia e transformação. São referências para preparar o investimento; uma ocorrência não identifica uma concessão ou um projecto disponível.' : 'Explore gás natural, electricidade, renováveis e as actividades da cadeia de valor. Distinga projectos de referência, propostas, previsões e possibilidades de fornecimento ao preparar o investimento.',
'group_label' => $mining ? 'Grupo mineral' : 'Segmento energético',
'resource_heading' => $mining ? 'Recursos e localização' : 'Segmentos e âmbito territorial',
'resource_notice' => $mining ? 'A localização é a indicada para o grupo de minérios. Quando a fonte não discrimina a distribuição de cada minério, não se presume que todos ocorram em todos os locais.' : 'Bacias, localidades, corredores e províncias conservam o âmbito indicado na fonte. Uma localização não delimitada fica por definir no investimento; uma referência não significa concurso aberto ou projecto disponível.',
'source_sha256' => self::SOURCES[$sector], 'reference_date' => $data['reference_date'],
'groups' => array_map(fn ($group) => [...array_diff_key($group, ['province_names' => true]), 'location_labels' => $group['location_labels'] ?? $group['province_names']], $groups),
'areas' => array_map(fn ($area) => [...array_diff_key($area, ['source_fields' => true, 'province_names' => true, 'mineral_ids' => true]), 'location_labels' => $area['location_labels'] ?? $area['province_names'], 'group_ids' => $area['group_ids'] ?? $area['mineral_ids']], $data['areas']),
'projects' => $data['projects'], 'indicators' => array_map(fn ($row) => ['label' => $row['mineral'], 'value' => $row['value'], 'source' => $row['source']], $data['indicators']),
'risks' => $risks, 'sources' => $data['sources'], 'source_links' => $data['source_links'] ?? [], 'background' => $data['background'] ?? []];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function selection(string $sector, array $input): array
{
abort_unless(isset(self::SOURCES[$sector]), 422);
$input = Validator::make($input, ['area_id' => 'required|string', 'source_sha256' => 'required|string|size:64', 'location_label' => 'nullable|string|max:150', 'group_id' => 'nullable|string'])->validate();
abort_unless(hash_equals(self::SOURCES[$sector], $input['source_sha256']), 409, 'A referência foi actualizada; recupere a selecção actual.');
$data = $this->view($sector);
$area = array_find($data['areas'], fn ($row) => $row['id'] === $input['area_id']);
abort_unless($area, 422, 'Escolha uma área de referência existente.');
$group = isset($input['group_id']) ? array_find($data['groups'], fn ($row) => $row['id'] === $input['group_id']) : null;
abort_if(isset($input['group_id']) && (! $group || ! in_array($group['id'], $area['group_ids'], true)), 422, 'O grupo escolhido não corresponde à cadeia de valor.');
$location = $input['location_label'] ?? null;
abort_if($location !== null && ! in_array($location, $area['location_labels'], true), 422, 'A localização escolhida não consta desta área de referência.');
$snapshot = ['sector_slug' => $sector, 'source_sha256' => self::SOURCES[$sector], 'reference_date' => $data['reference_date'],
'group_id' => $group['id'] ?? null, 'group_name' => $group['name'] ?? null, 'area_id' => $area['id'], 'title' => $area['title'], 'location_label' => $location,
'locations_text' => $area['locations_text'], 'indicative_location' => $area['indicative_location'], 'value_chain' => $area['value_chain'], 'market_evidence' => $area['market_evidence'],
'source_row' => $area['source_row'], 'source_state' => $area['source_state'], 'coordinates' => null, 'promotion_ready' => false,
'territory_precision' => $location === null ? 'source_area_reference' : ($location === 'Maputo' ? 'province_or_city_unspecified' : 'source_location_reference'),
'href' => '/sectores/'.$sector.'?'.http_build_query(['resource_area' => $area['id'], 'resource_location' => $location, 'resource_group' => $group['id'] ?? null])];
return ['key' => hash('sha256', json_encode([$sector, self::SOURCES[$sector], $area['id'], $location, $group['id'] ?? null], JSON_THROW_ON_ERROR)), ...$snapshot];
}
}
+58
View File
@@ -146,6 +146,9 @@ final class SimulationFormDrafts
}
$this->requireMatch($request, $draft);
$associatedContext = json_decode($economic['context_applicability_snapshot'] ?? '', true);
$sourceSnapshot['association_opportunity'] = $economic['context_opportunity'] ?? $associatedContext['consumer_opportunity'] ?? null;
$sourceSnapshot['association_context_revision'] = $associatedContext['context_revision'] ?? null;
$sources[] = $sourceSnapshot;
$economic['simulation_sources'] = $sources;
$economic['reuse_simulation'] = 'yes';
@@ -223,6 +226,61 @@ final class SimulationFormDrafts
});
}
/** Metadata only, from the draft owned by this dossier's titular; no broader dossier ACL grants financial access.
* @return list<array<string,mixed>> */
public function dossierReportSources(User $actor, string $caseId, ?string $opportunity): array
{
$case = app(VirtualDesk::class)->case($actor, $caseId);
if ((int) $case->user_id !== $actor->id || ! $case->simulation_id) { return []; }
$opportunity = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $opportunity, true);
return DB::transaction(function () use ($actor, $case, $opportunity): array {
$this->setContext($actor->id, null);
$draft = $this->ownedQuery($actor->id, null)->whereKey($case->simulation_id)->first();
if (! $draft) { return []; }
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
$sources = [];
$records = $economic['simulation_sources'] ?? [];
abort_unless(is_array($records), 409, 'Referências de relatórios incompatíveis; original preservado.');
foreach ($records as $source) {
if (! is_array($source) || ! Str::isUuid($source['simulation_id'] ?? '') || ! Str::isUuid($source['report_id'] ?? '')) { continue; }
if (isset($source['association_opportunity']) && $source['association_opportunity'] !== $opportunity) { continue; }
$sources[] = [...array_intersect_key($source, array_flip(['simulation_id', 'report_id', 'report_version_no', 'report_sha256', 'source_updated_at', 'consented_at'])),
'opportunity' => $source['association_opportunity'] ?? null, 'scope_state' => ! array_key_exists('association_opportunity', $source) ? 'unconfirmed' : ($source['association_opportunity'] === null ? 'common' : 'identified'),
'href' => '/investimentos/'.$case->id.'/relatorios/fiscais/'.$source['report_id'].($opportunity ? '?opportunity='.urlencode($opportunity) : '')];
}
return $sources;
});
}
/** Reads the already preserved copy, including a source originally calculated anonymously.
* @return array<string,mixed> */
public function dossierReport(User $actor, string $caseId, string $reportId, ?string $opportunity): array
{
$case = app(VirtualDesk::class)->case($actor, $caseId);
abort_unless((int) $case->user_id === $actor->id && $case->simulation_id, 403);
$opportunity = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $opportunity, true);
return DB::transaction(function () use ($actor, $case, $reportId, $opportunity): array {
$this->setContext($actor->id, null);
$draft = $this->ownedQuery($actor->id, null)->whereKey($case->simulation_id)->firstOrFail();
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
$records = $economic['simulation_sources'] ?? [];
abort_unless(is_array($records), 409, 'Referências de relatórios incompatíveis; original preservado.');
$source = null;
foreach ($records as $item) {
if (is_array($item) && ($item['report_id'] ?? null) === $reportId) { $source = $item; break; }
}
abort_unless(is_array($source) && is_array($source['report_snapshot'] ?? null), 404, 'Cópia preservada indisponível; original não substituído.');
abort_if(isset($source['association_opportunity']) && $source['association_opportunity'] !== $opportunity, 404);
return ['case_id' => $case->id, 'opportunity' => $source['association_opportunity'] ?? null,
'scope_state' => ! array_key_exists('association_opportunity', $source) ? 'unconfirmed' : ($source['association_opportunity'] === null ? 'common' : 'identified'), 'draft_id' => $draft->id, 'draft_version' => $draft->version_no,
...array_intersect_key($source, array_flip(['simulation_id', 'report_id', 'report_version_no', 'report_sha256', 'report_snapshot', 'consented_at']))];
});
}
/** @return array{id:string,title:string,updated_at:string|null,expires_at:string|null,version_no:int}|null */
public function claimPreview(Request $request): ?array
{
+302 -4
View File
@@ -3,9 +3,12 @@
namespace App\Services;
use App\Models\User;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
@@ -36,8 +39,12 @@ final class TerritorialRegistry
'location_type' => ['required', 'in:'.implode(',', array_keys(self::PARENTS))],
'parent_id' => ['nullable', 'uuid', 'exists:geo_locations,id'], 'source_id' => ['required', 'uuid', 'exists:geo_sources,id'],
'valid_from' => ['required', 'date_format:Y-m-d'], 'valid_to' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
'version_no' => [$id ? 'required' : 'prohibited', 'integer', 'min:1'], 'reason' => ['required', 'string', 'min:10', 'max:1000'],
'version_no' => [$id ? 'required' : 'prohibited', 'integer', 'min:1'], 'reason' => ['required', 'string', 'min:10', 'max:1000'], 'confirm_baseline_change' => ['sometimes', 'boolean'],
])->validate();
$this->ensureWriteMode($id);
if ($this->editorialAvailable()) {
return $this->saveEditorial($actor, $data, $id);
}
return DB::transaction(function () use ($actor, $data, $id) {
// Serialise hierarchy changes to prevent concurrent parent cycles and duplicate paths.
@@ -68,12 +75,303 @@ final class TerritorialRegistry
});
}
public function snapshot(string $id, int $version, User $actor, string $reason): void
/** @param array<string,mixed>|null $proposal */
public function snapshot(string $id, int $version, User $actor, string $reason, ?array $proposal = null): string
{
$snapshot = json_encode(['location' => DB::table('geo_locations')->find($id),
$snapshot = json_encode($proposal ?? ['location' => DB::table('geo_locations')->find($id),
'profile' => DB::table('geo_territory_profiles')->where('geo_location_id', $id)->first()], JSON_THROW_ON_ERROR);
DB::table('geo_territory_revisions')->insert(['id' => (string) Str::uuid(), 'geo_location_id' => $id,
$revision = (string) Str::uuid();
DB::table('geo_territory_revisions')->insert(['id' => $revision, 'geo_location_id' => $id,
'version_no' => $version, 'actor_id' => $actor->id, 'reason' => Crypt::encryptString($reason),
'snapshot' => Crypt::encryptString($snapshot), 'checksum' => hash('sha256', $snapshot)]);
return $revision;
}
private const EDITORIAL_SOURCE = 'territorial-editorial-v1';
public function editorialAvailable(): bool
{
return (bool) config('editorial_workflow.territorial_versions') && (bool) config('territorial.enabled') && Schema::hasTable('geo_territory_revisions') && Schema::hasTable('audit_events');
}
public function ensureWriteMode(?string $id): void
{
if (config('editorial_workflow.territorial_versions')) {
abort_unless($this->editorialAvailable(), 503, 'Circuito territorial ainda não disponível.');
} elseif ($id) {
abort_if(DB::table('audit_events')->where('source_system', self::EDITORIAL_SOURCE)->where('entity_id', $id)->exists(), 503, 'Reactive o circuito para conservar a revisão territorial privada.');
}
}
public function hasEditorialHistory(string $id): bool
{
return DB::table('audit_events')->where('source_system', self::EDITORIAL_SOURCE)->where('entity_id', $id)->exists();
}
/** @return array<string,mixed> */
public function preview(string $id): array
{
$state = $this->working($id);
$history = DB::table('geo_territory_revisions')->where('geo_location_id', $id)->orderByDesc('version_no')->limit(100)->get()
->map(function ($revision) {
try {
$json = Crypt::decryptString($revision->snapshot);
$reason = Crypt::decryptString($revision->reason);
} catch (DecryptException) {
abort(409, 'Histórico territorial indisponível; preserve o original.');
}
abort_unless(hash_equals(trim($revision->checksum), hash('sha256', $json)), 409, 'Histórico territorial alterado.');
return ['id' => $revision->id, 'version_no' => $revision->version_no, 'actor_id' => $revision->actor_id,
'reason' => $reason, 'snapshot' => json_decode($json, true, 512, JSON_THROW_ON_ERROR)];
});
return ['working' => $state, 'public' => $this->live($id), 'history' => $history];
}
/** @return array<string,mixed> */
private function live(string $id): array
{
$location = DB::table('geo_locations')->where('id', $id)->whereNull('deleted_at')->first();
$profile = DB::table('geo_territory_profiles')->where('geo_location_id', $id)->first();
abort_unless($location && $profile, 404);
return ['location' => get_object_vars($location), 'profile' => get_object_vars($profile)];
}
/** @param array<string,mixed> $value */
private function digest(array $value): string
{
return hash('sha256', json_encode(Arr::sortRecursive($value), JSON_THROW_ON_ERROR));
}
/** @return array<string,mixed> */
public function working(string $id): array
{
$live = $this->live($id);
$event = DB::table('audit_events')->where('source_system', self::EDITORIAL_SOURCE)->where('entity_id', $id)->orderByRaw("(after_data->>'version_no')::integer DESC")->first();
if ($event) {
$details = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR);
$revision = DB::table('geo_territory_revisions')->where('id', $details['revision_id'])->where('geo_location_id', $id)->first();
abort_unless($revision && hash_equals(trim($revision->checksum), $details['checksum']), 409, 'Referência de revisão territorial alterada.');
try {
$json = Crypt::decryptString($revision->snapshot);
} catch (DecryptException) {
abort(409, 'Revisão territorial indisponível; conserve o histórico.');
}
abort_unless(hash_equals(trim($revision->checksum), hash('sha256', $json)), 409, 'Conteúdo da revisão territorial alterado.');
$state = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
abort_unless($state['location']['id'] === $id && $state['location']['version_no'] === (int) $revision->version_no, 409);
if ($state['_editorial']['private']) {
$state['public_version_no'] = $live['profile']['status'] === 'published' ? $live['location']['version_no'] : null;
$state['baseline_changed'] = ! hash_equals($state['_editorial']['baseline_hash'], $this->digest($live));
$state['fresh'] = ! $state['baseline_changed'] && hash_equals($state['_editorial']['content_hash'], $this->contentHash($state));
return $state;
}
$live['_editorial'] = $state['_editorial'];
}
$live['public_version_no'] = $live['profile']['status'] === 'published' ? $live['location']['version_no'] : null;
$live['location']['version_no'] = max((int) $live['location']['version_no'], (int) DB::table('geo_territory_revisions')->where('geo_location_id', $id)->max('version_no'));
$live['baseline_changed'] = false;
$live['fresh'] = true;
return $live;
}
/** @param array<string,mixed> $state
* @return array<string,mixed>
*/
private function evidence(array $state): array
{
$source = DB::table('geo_sources')->where('id', $state['profile']['source_id'])->first();
abort_unless($source !== null, 422, 'Fonte indisponível.');
$parents = [];
$parent = $state['location']['parent_id'];
$seen = [$state['location']['id']];
while ($parent) {
abort_if(in_array($parent, $seen, true) || count($seen) > 5, 422, 'Hierarquia territorial incoerente.');
$seen[] = $parent;
$row = DB::table('geo_locations')->where('id', $parent)->whereNull('deleted_at')->first(['id', 'name', 'code', 'parent_id', 'location_type', 'version_no']);
abort_unless($row !== null, 422, 'Ascendente indisponível.');
$parents[] = get_object_vars($row);
$parent = $row->parent_id;
}
return ['source' => Arr::only(get_object_vars($source), ['id', 'name', 'responsible_entity', 'reference', 'license', 'checksum', 'source_version', 'collected_at', 'original_crs']), 'parents' => $parents];
}
/** @param array<string,mixed> $state */
private function contentHash(array $state): string
{
return $this->digest(['location' => Arr::only($state['location'], ['id', 'name', 'code', 'location_type', 'parent_id']),
'profile' => Arr::only($state['profile'], ['source_id', 'valid_from', 'valid_to']),
'contributors' => $state['_editorial']['contributors'], 'evidence' => $this->evidence($state)]);
}
private function editorialActor(User $actor, string $role): void
{
abort_unless($actor->can('manage-portal') && $actor->two_factor_confirmed_at && $actor->two_factor_secret && ! app(PortalProfileAccess::class)->has($actor, 'auditor')
&& in_array(strtolower($actor->email), config('territorial.'.$role, []), true) && app(PortalProfileAccess::class)->mfa($actor), 403, 'Função territorial e MFA válido necessários.');
}
/** @param array<string,mixed> $data */
private function shape(array $data, ?string $id): void
{
$parentType = self::PARENTS[$data['location_type']];
$parent = empty($data['parent_id']) ? null : DB::table('geo_locations')->where('id', $data['parent_id'])->whereNull('deleted_at')->first();
abort_unless($parentType ? $parent && $parent->location_type === $parentType : ! $parent, 422, 'Hierarquia territorial incoerente.');
$duplicate = DB::table('geo_locations')->where('location_type', $data['location_type'])->whereNull('deleted_at')->when($id, fn ($q) => $q->where('id', '<>', $id))
->where(fn ($q) => $q->where('code', $data['code'])->orWhere(fn ($q) => $q->where('parent_id', $data['parent_id'] ?? null)->whereRaw('lower(trim(name))=lower(trim(?))', [$data['name']])))->exists();
abort_if($duplicate, 422, 'Código ou nome no mesmo pai já existente.');
}
/** @param array<string,mixed> $state */
private function appendEditorial(User $actor, string $id, array $state, string $reason, string $action): void
{
unset($state['fresh'], $state['baseline_changed'], $state['public_version_no']);
$version = (int) $state['location']['version_no'];
$revision = $this->snapshot($id, $version, $actor, $reason, $state);
$checksum = DB::table('geo_territory_revisions')->where('id', $revision)->value('checksum');
$eventId = (string) Str::uuid();
DB::table('audit_events')->insert(['id' => $eventId, 'source_system' => self::EDITORIAL_SOURCE, 'entity_type' => 'geo_locations', 'entity_id' => $id, 'actor_user_id' => $actor->id,
'action' => $action, 'data_classification' => 'restricted', 'after_data' => json_encode(['version_no' => $version, 'revision_id' => $revision, 'checksum' => trim($checksum), 'state' => $state['profile']['status']], JSON_THROW_ON_ERROR)]);
app(PortalEditorialNativeNotices::class)->emit($actor, $eventId, $state['_editorial']['contributors'] ?? []);
}
/** @param array<string,mixed> $data */
private function saveEditorial(User $actor, array $data, ?string $id): string
{
$this->editorialActor($actor, 'editors');
return DB::transaction(function () use ($actor, $data, $id) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
$this->shape($data, $id);
if (! $id) {
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'name' => $data['name'], 'code' => $data['code'], 'location_type' => $data['location_type'], 'parent_id' => $data['parent_id'] ?? null,
'version_no' => 1, 'data_classification' => 'internal', 'updated_at' => now()]);
DB::table('geo_territory_profiles')->insert(['geo_location_id' => $id, 'source_id' => $data['source_id'], 'valid_from' => $data['valid_from'], 'valid_to' => $data['valid_to'] ?? null, 'status' => 'draft', 'editor_id' => $actor->id]);
$state = $this->live($id);
} else {
DB::table('geo_locations')->where('id', $id)->lockForUpdate()->first();
$state = $this->working($id);
abort_unless((int) $state['location']['version_no'] === (int) $data['version_no'], 409, 'Versão territorial alterada.');
abort_unless($state['location']['location_type'] === $data['location_type'], 422, 'O tipo territorial é imutável.');
abort_if(($state['baseline_changed'] ?? false) && ! ($data['confirm_baseline_change'] ?? false), 409, 'Compare a publicação actual e confirme a reconciliação antes de guardar.');
if (! $this->hasEditorialHistory($id) && ! DB::table('geo_territory_revisions')->where('geo_location_id', $id)->where('version_no', $state['location']['version_no'])->exists()) {
$this->snapshot($id, $state['location']['version_no'], $actor, 'Conservação do estado anterior ao circuito editorial; não constitui nova aprovação.');
}
$state['location']['version_no']++;
}
$private = $state['_editorial']['private'] ?? false;
$contributors = array_values(array_unique([...($private ? $state['_editorial']['contributors'] : []), $actor->id]));
$state['location'] = array_replace($state['location'], Arr::only($data, ['name', 'code', 'location_type', 'parent_id']), ['data_classification' => 'internal']);
$state['profile'] = array_replace($state['profile'], Arr::only($data, ['source_id', 'valid_from', 'valid_to']), ['status' => 'draft', 'editor_id' => $actor->id, 'reviewer_id' => null, 'publisher_id' => null]);
$state['_editorial'] = ['private' => true, 'edition_no' => $state['location']['version_no'], 'contributors' => $contributors, 'approval' => null, 'baseline_hash' => $this->digest($this->live($id)), 'evidence' => $this->evidence($state)];
$state['_editorial']['content_hash'] = $this->contentHash($state);
$this->appendEditorial($actor, $id, $state, $data['reason'], 'draft_saved');
return $id;
});
}
/** @param array<string,mixed> $data
* @return array<string,mixed>
*/
public function transitionEditorial(User $actor, string $id, array $data): array
{
$this->ensureWriteMode($id);
abort_unless($this->editorialAvailable(), 503);
$action = $data['action'];
$role = match ($action) {
'in_review', 'approved', 'rejected' => 'reviewers',
'published', 'archived', 'withdrawn' => 'publishers',
default => 'editors',
};
$this->editorialActor($actor, $role);
return DB::transaction(function () use ($actor, $id, $data, $action) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
DB::table('geo_locations')->where('id', $id)->lockForUpdate()->first();
DB::table('geo_territory_profiles')->where('geo_location_id', $id)->lockForUpdate()->first();
$state = $this->working($id);
abort_unless((int) $state['location']['version_no'] === (int) $data['version_no'], 409, 'Versão territorial alterada.');
if ($action === 'published') {
$evidence = $this->evidence($state);
$parentIds = array_column($evidence['parents'], 'id');
DB::table('geo_locations')->whereIn('id', $parentIds)->orderBy('id')->lockForUpdate()->get();
DB::table('geo_territory_profiles')->whereIn('geo_location_id', $parentIds)->orderBy('geo_location_id')->lockForUpdate()->get();
$sourceIds = DB::table('geo_territory_profiles')->whereIn('geo_location_id', $parentIds)->pluck('source_id')->all();
$sourceIds[] = $state['profile']['source_id'];
DB::table('geo_sources')->whereIn('id', $sourceIds)->orderBy('id')->lockForUpdate()->get();
$state = $this->working($id);
}
$private = $state['_editorial']['private'] ?? false;
$live = $this->live($id);
if ($action === 'withdrawn' || ($action === 'archived' && ! $private)) {
abort_unless($live['profile']['status'] === 'published', 409);
$version = $state['location']['version_no'] + 1;
DB::table('geo_territory_profiles')->where('geo_location_id', $id)->update(['status' => 'archived', 'updated_at' => now()]);
DB::table('geo_locations')->where('id', $id)->update(['data_classification' => 'internal', 'version_no' => $version, 'updated_at' => now()]);
if ($private) {
$state['_editorial']['baseline_hash'] = $this->digest($this->live($id));
} else {
$state = $this->live($id);
$state['_editorial'] = ['private' => false];
}
$state['location']['version_no'] = $version;
$this->appendEditorial($actor, $id, $state, $data['reason'], 'publication_withdrawn');
return ['id' => $id, 'status' => $state['profile']['status'], 'version_no' => $version];
}
abort_unless($private, 409, 'Guarde uma versão privada antes de iniciar o novo circuito.');
$from = ['pending_review' => ['draft', 'rejected'], 'in_review' => ['pending_review'], 'approved' => ['in_review'],
'rejected' => ['pending_review', 'in_review'], 'published' => ['approved'], 'archived' => ['draft', 'pending_review', 'in_review', 'approved', 'rejected']];
abort_unless(isset($from[$action]) && in_array($state['profile']['status'], $from[$action], true), 409);
if (in_array($action, ['pending_review', 'approved', 'published'], true)) {
abort_unless($state['fresh'], 409, 'A publicação, fonte ou hierarquia mudou; reveja a versão.');
}
if ($action === 'pending_review') {
abort_unless($state['profile']['editor_id'] === $actor->id, 403);
}
if (in_array($action, ['in_review', 'approved', 'rejected'], true)) {
abort_if(in_array($actor->id, $state['_editorial']['contributors'], true), 403, 'Revisão independente obrigatória.');
$state['profile']['reviewer_id'] = $actor->id;
}
if ($action === 'approved') {
$state['_editorial']['approval'] = ['reviewer_id' => $actor->id, 'edition_no' => $state['_editorial']['edition_no'],
'content_hash' => $state['_editorial']['content_hash'], 'reason' => $data['reason'], 'at' => now()->toIso8601String()];
}
if ($action === 'published') {
$approval = $state['_editorial']['approval'];
abort_unless(is_array($approval) && $approval['edition_no'] === $state['_editorial']['edition_no']
&& hash_equals($approval['content_hash'], $this->contentHash($state)), 409, 'Aprovação não corresponde a esta versão.');
abort_if(in_array($actor->id, [...$state['_editorial']['contributors'], $approval['reviewer_id']], true), 403, 'Publicação exige terceiro interveniente.');
$reviewer = User::where('id', $approval['reviewer_id'])->first();
abort_unless($reviewer && $reviewer->can('manage-portal') && $reviewer->two_factor_confirmed_at && $reviewer->two_factor_secret
&& ! app(PortalProfileAccess::class)->has($reviewer, 'auditor') && in_array(strtolower($reviewer->email), config('territorial.reviewers', []), true), 409, 'Revisor deixou de ter autoridade.');
abort_unless(DB::table('geo_sources')->where('id', $state['profile']['source_id'])->where('status', 'approved')->exists(), 422, 'Fonte não aprovada.');
abort_if($state['profile']['valid_from'] > today()->toDateString() || ($state['profile']['valid_to'] && $state['profile']['valid_to'] < today()->toDateString()), 422, 'Vigência inválida.');
abort_if($state['location']['parent_id'] && ! $this->published()->where('g.id', $state['location']['parent_id'])->exists(), 422, 'Ascendente não publicado.');
$this->shape($state['location'], $id);
$state['profile']['publisher_id'] = $actor->id;
}
$state['location']['version_no']++;
$state['profile']['status'] = $action;
if ($action === 'published') {
DB::table('geo_locations')->where('id', $id)->update([...Arr::only($state['location'], ['name', 'code', 'location_type', 'parent_id', 'version_no']), 'data_classification' => 'public', 'updated_at' => now()]);
DB::table('geo_territory_profiles')->where('geo_location_id', $id)->update([...Arr::only($state['profile'], ['source_id', 'valid_from', 'valid_to', 'status', 'editor_id', 'reviewer_id', 'publisher_id']), 'updated_at' => now()]);
$state['_editorial']['private'] = false;
}
if ($action === 'archived') {
// Archiving a proposal never withdraws the existing public version.
$state['_editorial']['private'] = false;
}
$this->appendEditorial($actor, $id, $state, $data['reason'], $action);
return ['id' => $id, 'status' => $action, 'version_no' => $state['location']['version_no']];
});
}
}
+17 -7
View File
@@ -8,15 +8,20 @@ use Illuminate\Support\Facades\DB;
final class TerritoryGeometry
{
/** @return array<string,mixed> */
public function context(string $id, bool $management = false): array
public function context(string $id, bool $management = false, bool $includeChildren = false): array
{
$geography = app(InvestmentGeography::class);
abort_unless($geography->references($management)->where('id', $id)->exists(), 404);
$selected = $geography->references($management)->where('id', $id)->first(['location_type']);
abort_unless($selected !== null, 404);
$path = $geography->path($id, $management);
$ids = array_map(fn ($node) => $node->id, $path);
$ids = array_values(array_unique([$id, ...array_map(fn ($node) => $node->id, $path)]));
$childType = ['country' => 'province', 'province' => 'district', 'district' => 'admin_post', 'admin_post' => 'locality'][$selected->location_type] ?? null;
$children = $geography->references($management)->where('parent_id', $id)->where('location_type', $childType ?? 'none');
$childCount = $includeChildren && $childType ? (clone $children)->count() : 0;
$childIds = $includeChildren && $childType ? $children->orderBy('name')->orderBy('id')->limit(500)->pluck('id')->all() : [];
$approved = app(ApprovedAdministrativeBoundaries::class);
$rows = DB::table('geo_geometries as g')->join('geo_locations as l', 'l.id', '=', 'g.geo_location_id')
->whereIn('g.geo_location_id', $ids)->whereNull('g.deleted_at')->whereNull('l.deleted_at')
->whereIn('g.geo_location_id', [...$ids, ...$childIds])->whereNull('g.deleted_at')->whereNull('l.deleted_at')
->where(function ($query) use ($management, $approved) {
$query->where('g.data_classification', 'public');
$query->orWhere(function ($internal) use ($management, $approved) {
@@ -29,20 +34,22 @@ final class TerritoryGeometry
->whereRaw("g.properties->>'purpose' = 'administrative_boundary'")
->whereIn('g.geometry_type', ['polygon', 'multipolygon'])
->whereRaw('ST_IsValid(g.geom) AND NOT ST_IsEmpty(g.geom) AND ST_SRID(g.geom)=4326')
->orderByRaw('CASE WHEN g.geo_location_id = ? THEN 0 WHEN g.geo_location_id IN ('.implode(',', array_fill(0, count($ids), '?')).') THEN 1 ELSE 2 END', [$id, ...$ids])
->orderBy('g.geo_location_id')->orderBy('g.id')->limit(101)
->get(['g.id', 'g.geo_location_id', 'l.name', 'l.location_type', 'g.properties', 'g.data_classification',
DB::raw('ST_AsGeoJSON(ST_SimplifyPreserveTopology(g.geom,0.0001),6) as geometry'),
DB::raw("encode(sha256(ST_AsEWKB(g.geom)),'hex') as geometry_sha256"),
DB::raw("encode(sha256(convert_to(g.properties::text,'UTF8')),'hex') as properties_sha256")]);
$queryTruncated = $rows->count() > 100;
if (! $management) {
$rows = $rows->filter(fn ($row) => $row->data_classification === 'public'
|| $approved->allows($row->id, $row->geo_location_id, $row->geometry_sha256, $row->properties_sha256))->values();
}
$areas = $rows->take(100)->map(function ($row) use ($id, $management) {
$areas = $rows->take(100)->map(function ($row) use ($id, $management, $childIds) {
$source = json_decode($row->properties, true, 512, JSON_THROW_ON_ERROR);
return ['id' => $row->id, 'territory_id' => $row->geo_location_id, 'location_type' => $row->location_type,
'selected_territory' => $row->geo_location_id === $id, 'geometry' => json_decode($row->geometry, true, 512, JSON_THROW_ON_ERROR),
'selected_territory' => $row->geo_location_id === $id, 'child_territory' => in_array($row->geo_location_id, $childIds, true), 'geometry' => json_decode($row->geometry, true, 512, JSON_THROW_ON_ERROR),
'title' => 'Limite administrativo: '.$row->name, 'purpose' => 'administrative_boundary',
'source_reference' => $source['authority'] ?? null, 'source_date' => $source['source_version'] ?? null,
'source_url' => $source['source_url'] ?? null, 'source_sha256' => $source['source_sha256'] ?? null,
@@ -51,6 +58,9 @@ final class TerritoryGeometry
return ['territory_id' => $id, 'territory_registered' => true,
'selected_geometry_available' => $rows->contains('geo_location_id', $id), 'context_geometry_count' => count($areas),
'areas' => $areas, 'truncated' => $rows->count() > 100, 'investment_site' => false];
'areas' => $areas, 'truncated' => $queryTruncated || $childCount > 500, 'investment_site' => false,
'child_coverage' => $includeChildren ? ['location_type' => $childType, 'registered_count' => $childCount,
'represented_count' => count(array_unique(array_column(array_filter($areas, fn ($area) => $area['child_territory']), 'territory_id'))),
'reference_limit' => 500, 'geometry_limit' => 100] : null];
}
}
+14 -1
View File
@@ -61,7 +61,11 @@ class VerifiedDocumentBinary
if ($table === 'opportunity_images') {
$query->orWhereRaw("payload->'derivatives' @> ?::jsonb", [json_encode([['document_id' => $document->id]], JSON_THROW_ON_ERROR)]);
}
$links = $links->concat($query->get(['opportunity_id', 'deleted_at', 'payload']));
$rows = $query->get(['opportunity_id', 'deleted_at', 'payload']);
foreach ($rows as $link) {
$link->resource_kind = $table;
}
$links = $links->concat($rows);
}
$privateOrigin = in_array($document->source_system ?? null, ['opportunity-resources', 'opportunity-image-derivatives'], true);
if (! $privateOrigin) {
@@ -72,6 +76,15 @@ class VerifiedDocumentBinary
}
$actor = request()->user();
abort_unless($actor instanceof User, 403, 'Anexo privado: é necessário acesso autorizado à oportunidade.');
foreach ($links as $link) {
$offer = Opportunity::withTrashed()->whereKey($link->opportunity_id)->first();
if ($offer?->isPrivateOfferPreparation() && $link->resource_kind === 'opportunity_documents') {
$allowed = $link->deleted_at === null && ! $offer->trashed() && app(PrivateOfferConfidentiality::class)->internal($actor, $offer);
app(PortalProfileAccess::class)->audit($actor, $allowed ? 'offer_proof_access_authorized' : 'offer_proof_access_denied',
['record_id' => $offer->id, 'document_id' => $document->id, 'record_version' => $offer->management_version, 'sha256' => trim($document->sha256)]);
abort_unless($allowed, 403, 'Comprovativo reservado à validação interna autorizada.');
}
}
foreach ($links as $link) {
$opportunity = Opportunity::whereKey($link->opportunity_id)->first();
if ($link->deleted_at === null && $opportunity && $opportunity->status !== 'archived'
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Support;
use Generator;
final class PublicSitemapXml
{
/** @param iterable<string> $locations
* @return Generator<int, string>
*/
public function chunks(iterable $locations, bool $index = false): Generator
{
$root = $index ? 'sitemapindex' : 'urlset';
$item = $index ? 'sitemap' : 'url';
yield '<?xml version="1.0" encoding="UTF-8"?>'."\n";
yield '<'.$root.' xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'."\n";
foreach ($locations as $location) {
yield '<'.$item.'><loc>'.htmlspecialchars($location, ENT_XML1 | ENT_QUOTES, 'UTF-8').'</loc></'.$item.'>'."\n";
}
yield '</'.$root.'>';
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
return [
// Remote activation is explicit; local/QA behaviour remains unchanged.
'enabled' => env('DEMOGRAPHY_ENABLED', env('APP_URL') !== 'https://teste-investidor.tempo.co.mz'
&& in_array(env('APP_ENV', 'production'), ['local', 'testing'], true)),
'source_directory' => env('DEMOGRAPHY_SOURCE_DIRECTORY', 'C:/Mac/Home/Documents/TEMPO/Portal Tempo/Servicos Tempo/portal do investidor/demografia/Provincias'),
'prepared_directory' => env('DEMOGRAPHY_PREPARED_DIRECTORY'),
// An explicit interpreter avoids Windows Store execution aliases in web workers.
'python' => env('DEMOGRAPHY_PYTHON', 'python'),
];
+19
View File
@@ -0,0 +1,19 @@
<?php
$legalReviewerEmails = env('PORTAL_EDITORIAL_LEGAL_REVIEWERS', '');
return [
// Development/QA opt-in. Installation and activation in an operational target are separate decisions.
'enabled' => env('PORTAL_EDITORIAL_WORKFLOW_ENABLED', false),
'cluster_competitiveness' => env('PORTAL_EDITORIAL_CLUSTER_WORKFLOW_ENABLED', false),
'portfolio' => env('PORTAL_EDITORIAL_PORTFOLIO_ENABLED', false),
'demography' => env('PORTAL_EDITORIAL_DEMOGRAPHY_ENABLED', false),
'territorial_versions' => env('PORTAL_EDITORIAL_TERRITORIAL_ENABLED', false),
'structural_subjects' => env('PORTAL_EDITORIAL_STRUCTURES_ENABLED', false),
'zone_review' => env('PORTAL_EDITORIAL_ZONE_REVIEW_ENABLED', false),
// Legal designation is explicit; management authority alone is not a legal designation.
'legal_reviewers' => [],
'legal_reviewer_emails' => is_string($legalReviewerEmails) ? array_values(array_filter(array_map(static fn (string $email): string => strtolower(trim($email)), explode(',', $legalReviewerEmails)))) : [],
// Required separation is a policy, distinct from the legacy fact "independent_review".
'independent_approval' => ['resources' => [], 'kinds' => []],
];
+1 -1
View File
@@ -21,7 +21,7 @@ return [
// Experimental proposal only; functional approval and operational SLA remain pending.
'opportunity_modular_profiles_qa_enabled' => env('PORTAL_OPPORTUNITY_MODULAR_PROFILES_QA_ENABLED', false),
'opportunity_other_qa_subtypes' => [],
'operator_name' => env('PORTAL_OPERATOR_NAME'),
'operator_name' => env('PORTAL_OPERATOR_NAME') ?: 'Tempo, Lda.',
// Tempo, 19/10/2026: institutional contact; commercial department remains separate.
'institutional_contact_email' => env('PORTAL_INSTITUTIONAL_CONTACT_EMAIL', 'comercial@tempo.co.mz'),
'support' => [
+10
View File
@@ -0,0 +1,10 @@
<?php
return [
// No legal text or operational NDA is supplied/approved. Never infer it from an upload.
'nda' => null,
// No definitive publication lifetime was decided. Freeze an explicit policy on approval.
'validity_days' => null,
'validity_policy_reference' => null,
'expiry_scheduler_enabled' => false,
];
+6
View File
@@ -28,6 +28,12 @@ return [
|
*/
'translation' => [
'url' => env('LIBRETRANSLATE_URL', 'http://127.0.0.1:5000'),
'timeout' => (int) env('LIBRETRANSLATE_TIMEOUT', 60),
'api_key' => env('LIBRETRANSLATE_API_KEY'),
],
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
@@ -0,0 +1,120 @@
{
"files": {
"Cabo Delgado.xlsx": {
"sha256": "419ebf09f26055a2d46dac7027143835ee2e2dc8a91e3b1f498c5b459566ce16",
"records": 92808,
"province": "Cabo Delgado",
"issues": {
"missing_value": 6336
}
},
"Gaza.xlsx": {
"sha256": "b66ccde3478a74a854e8e2c4d51209401d844a15a701b16267e005fa44b70dc4",
"records": 82618,
"province": "Gaza",
"issues": {
"age_total_mismatch": 4,
"missing_value": 2,
"residence_total_mismatch": 4,
"sex_total_mismatch": 1
}
},
"Maputo Provincia.xlsx": {
"sha256": "1bf2921ef43d18817878a6d05e487ce5b107c14aa149ad17bedae6f1c652430e",
"records": 49504,
"province": "Maputo Província",
"issues": {
"cached_formula_value": 42972,
"missing_value": 68
}
},
"Maputo Cidade.xlsx": {
"sha256": "ba72c0b64200e5bdb4b1dda60a0a974bc7ce7c6d28d37a490735437261f1e513",
"records": 44064,
"province": "Maputo Cidade",
"issues": {}
},
"Nampula.xlsx": {
"sha256": "cc960b06d7dfd0cb3f9a16edd94ff8db0451e8f3e24f3ad8442d03f7a053ac3b",
"records": 131400,
"province": "Nampula",
"issues": {
"missing_value": 792
}
},
"Niassa.xlsx": {
"sha256": "3116278d32de729058c7133ccc8204b42f14a0f8ba2b8d0aa8be46c9ff08d160",
"records": 93632,
"province": "Niassa",
"issues": {
"age_total_mismatch": 3,
"cached_formula_value": 12,
"district_total_mismatch": 2,
"missing_value": 4,
"residence_total_mismatch": 1
}
},
"Sofala.xlsx": {
"sha256": "4eaf5ef5e332ed50d5739330e14d78e2ead2551f407de7e31e0d1a96a76c956e",
"records": 77108,
"province": "Sofala",
"issues": {
"age_total_mismatch": 6,
"missing_value": 4,
"residence_total_mismatch": 6
}
},
"Tete.xlsx": {
"sha256": "4bbd7856107c4a3716b533caf7968a691deab4662e42689a7c529b58b80e2d58",
"records": 67932,
"province": "Tete",
"issues": {
"missing_value": 20196
}
}
},
"new_districts": {
"Gaza": [
"Chongoene",
"Limpopo",
"Mapai"
],
"Nampula": [
"Larde",
"Liupo"
],
"Niassa": [
"Chimbonila"
],
"Tete": [
"Doa",
"Marara"
]
},
"aliases": {
"Cabo Delgado|Metuge|district": "PEMBA - METUGE",
"Gaza|Mandlakaze|district": "MANDLACAZE",
"Nampula|Erati|district": "NAMAPA-ERATI",
"Nampula|Ilha de Mocambique|district": "CIDADE ILHA DE MOCAMBIQUE",
"Nampula|Nacala-a-Velha|district": "NACALA-VELHA",
"Nampula|Rapale|district": "NAMPULA",
"Maputo Cidade|KaMphumu|district": "DISTRITO URBANO 1",
"Maputo Cidade|Nlhamankulu|district": "DISTRITO URBANO 2",
"Maputo Cidade|KaMaxakeni|district": "DISTRITO URBANO 3",
"Maputo Cidade|KaMavota|district": "DISTRITO URBANO 4",
"Maputo Cidade|KaMubukwana|district": "DISTRITO URBANO 5",
"Maputo Cidade|KaTembe|district": "DISTRITO DE CATEMBE",
"Maputo Cidade|KaNyaka|district": "DISTRITO DE INHACA",
"Nampula|Nampula|district": "CIDADE DE NAMPULA",
"Niassa|Lichinga|district": "CIDADE DE LICHINGA",
"Gaza|Xai-Xai|district": "CIDADE DE XAI-XAI"
},
"authority": "Pedido de instalar restantes províncias e autorização expressa de oito distritos,13/11/2026. Correspondências históricas apresentadas na prévia autenticada,sem renomear nem fundir IDs.",
"sources": [
"https://archive.gazettes.africa/archive/mz/2013/mz-government-gazette-series-i-dated-2013-12-18-no-101.pdf",
"https://international.ipums.org/international-action/variables/MZ2017A_0436",
"https://archive.gazettes.africa/archive/mz/2020/mz-government-gazette-series-i-dated-2020-05-04-no-83.pdf"
],
"missing_province_source": "Zambézia",
"city_mapping_decision": "Proprietário confirmou expressamente em13/11/2026 a ligação destes três rótulos às respectivas cidades,preservando Rapale,Chimbonila,Limpopo separados."
}
@@ -0,0 +1,23 @@
{
"files": {
"Zambezia.xlsx": {
"sha256": "8d502e25e1e1fec0cc381ba0aa47b35cfa1eea586de53cb69ffa201d3b5ffeb7",
"records": 126684,
"province": "Zambezia",
"issues": {}
}
},
"new_districts": {
"Zambezia": [
"Derre",
"Luabo",
"Mocubela",
"Molumbo",
"Mulevala"
]
},
"aliases": {},
"sources": [],
"authority": "Proprietário autorizou expressamente Derre,Luabo,Mocubela,Molumbo e Mulevala e a incorporação de Zambezia.xlsx em13/11/2026,sem códigos oficiais ou geometria inferidos.",
"missing_province_source": null
}
@@ -0,0 +1,807 @@
{
"format": "investmoz-energy-reference-v1",
"source_sha256": "2d9d94ac70115ade43c4093a4d5b302aaa93623a149f936615c38ba67b1e2bd2",
"source_document_name": "Oportunidades de investimento em energia.docx",
"reference_date": "2026-11-11",
"title": "Energia e cadeias de valor em Moçambique",
"groups": [
{
"id": "EN-GAS",
"name": "Gás natural e GNL",
"locations_text": "Bacia do Rovuma; Buzi. A fonte não atribui todas as áreas a uma província. Projectos de GNL; base logística de Pemba. Gasoduto Norte–Sul; corredor Palma–Maputo, apresentado como proposta de estudo. Corredor Palma–Maputo proposto no estudo; implantação de cada indústria por definir. Implantação a definir no investimento; sem localização discriminada na fonte.",
"location_labels": [
"Bacia do Rovuma",
"Buzi",
"Pemba",
"Norte–Sul",
"Palma–Maputo"
],
"coordinates": null
},
{
"id": "EN-POWER",
"name": "Electricidade, hidroeléctricas e redes",
"locations_text": "Rio Zambeze; Maputo–Temane; interligação Moçambique–Zâmbia. Âmbitos de projecto, sem coordenadas.",
"location_labels": [
"Rio Zambeze",
"Maputo–Temane",
"Moçambique–Zâmbia"
],
"coordinates": null
},
{
"id": "EN-SOLAR",
"name": "Solar, armazenamento e sistemas fora da rede",
"locations_text": "Mocuba (Zambézia), Metoro (Cabo Delgado), Cuamba (Niassa) e Afungi. Sistemas fora da rede: implantação a definir.",
"location_labels": [
"Zambézia",
"Mocuba",
"Cabo Delgado",
"Metoro",
"Niassa",
"Cuamba",
"Afungi"
],
"coordinates": null
},
{
"id": "EN-WIND",
"name": "Energia eólica",
"locations_text": "Namaacha (Maputo, sem distinção entre província e cidade na fonte).",
"location_labels": [
"Namaacha",
"Maputo"
],
"coordinates": null
},
{
"id": "EN-HYDROGEN",
"name": "Hidrogénio verde",
"locations_text": "Sem implantação concreta identificada na fonte.",
"location_labels": [],
"coordinates": null
},
{
"id": "EN-BIO",
"name": "Biocombustíveis",
"locations_text": "Implantação a definir; a fonte não discrimina locais de produção.",
"location_labels": [],
"coordinates": null
},
{
"id": "EN-GREEN",
"name": "Parques industriais verdes",
"locations_text": "Corredores e parques sem implantação discriminada na fonte.",
"location_labels": [],
"coordinates": null
}
],
"areas": [
{
"id": "ENE-01",
"title": "Gás natural — Upstream",
"group_ids": [
"EN-GAS"
],
"locations_text": "Bacia do Rovuma; Buzi. A fonte não atribui todas as áreas a uma província.",
"location_labels": [
"Bacia do Rovuma",
"Buzi"
],
"value_chain": "Concursos de áreas, campos maduros (Buzi), parcerias com empresas locais. Perfil de investidor: Operadores e parceiros locais.",
"market_evidence": "A bacia do Rovuma concentra as maiores oportunidades do país. O Mozambique LNG (Área 1) prevê uma capacidade anual de 12,9 milhões de toneladas; o Coral Norte FLNG (Eni) teve decisão final de investimento em 2025, com 3,6 milhões de toneladas anuais a partir de 2028; e o Rovuma LNG (ExxonMobil), estimado em cerca de 24 mil milhões de dólares, aproxima-se da decisão final de investimento. Para investidores e empresas nacionais, as oportunidades estendem-se a engenharia, construção, logística, serviços marítimos, telecomunicações e manutenção industrial. No mercado interno, o Instituto Nacional de Petróleo identifica áreas como o gasoduto Norte-Sul, o campo maduro de Buzi, o GNL de pequena escala, o armazenamento de GPL e a base logística de Pemba.",
"indicative_location": "Bacia do Rovuma; Buzi. A fonte não atribui todas as áreas a uma província.",
"source_row": "table4:row1; paragraph:34,paragraph:35",
"source_state": "Referência para preparação",
"availability": "reference_only",
"promotion_ready": false,
"coordinates": null
},
{
"id": "ENE-02",
"title": "Gás natural — Construção e serviços",
"group_ids": [
"EN-GAS"
],
"locations_text": "Projectos de GNL; base logística de Pemba.",
"location_labels": [
"Pemba"
],
"value_chain": "Engenharia, logística, serviços marítimos, telecomunicações, manutenção, formação. Perfil de investidor: PME nacionais e parcerias internacionais.",
"market_evidence": "Para investidores e empresas nacionais, as oportunidades estendem-se a engenharia, construção, logística, serviços marítimos, telecomunicações e manutenção industrial. No mercado interno, o Instituto Nacional de Petróleo identifica áreas como o gasoduto Norte-Sul, o campo maduro de Buzi, o GNL de pequena escala, o armazenamento de GPL e a base logística de Pemba. Está prevista uma componente de formação para 15.734 moçambicanos (custo estimado em 126 milhões de dólares) e a capacitação de 355 pequenas e médias empresas na fase de construção e cerca de 1.500 na fase operacional. O ministro apelou a parcerias com entidades estrangeiras especializadas para transferência de conhecimento e tecnologia.",
"indicative_location": "Projectos de GNL; base logística de Pemba.",
"source_row": "table4:row2; paragraph:35,paragraph:58",
"source_state": "Referência para preparação",
"availability": "reference_only",
"promotion_ready": false,
"coordinates": null
},
{
"id": "ENE-03",
"title": "Gás natural — Midstream",
"group_ids": [
"EN-GAS"
],
"locations_text": "Gasoduto Norte–Sul; corredor Palma–Maputo, apresentado como proposta de estudo.",
"location_labels": [
"Norte–Sul",
"Palma–Maputo"
],
"value_chain": "Gasodutos Norte-Sul e Palma–Maputo, armazenamento de GPL, GNL de pequena escala. Perfil de investidor: Investidores em infra-estruturas.",
"market_evidence": "Para investidores e empresas nacionais, as oportunidades estendem-se a engenharia, construção, logística, serviços marítimos, telecomunicações e manutenção industrial. No mercado interno, o Instituto Nacional de Petróleo identifica áreas como o gasoduto Norte-Sul, o campo maduro de Buzi, o GNL de pequena escala, o armazenamento de GPL e a base logística de Pemba. Um estudo do Programa de Partilha de Conhecimentos (KSP) propõe um corredor energético Palma–Maputo para integrar petroquímica, fertilizantes e geração eléctrica, e destaca o gás natural veicular (GNV) nos transportes.",
"indicative_location": "Gasoduto Norte–Sul; corredor Palma–Maputo, apresentado como proposta de estudo.",
"source_row": "table4:row3; paragraph:35,paragraph:51",
"source_state": "Referência para preparação",
"availability": "reference_only",
"promotion_ready": false,
"coordinates": null
},
{
"id": "ENE-04",
"title": "Gás natural — Industrialização",
"group_ids": [
"EN-GAS"
],
"locations_text": "Corredor Palma–Maputo proposto no estudo; implantação de cada indústria por definir.",
"location_labels": [
"Palma–Maputo"
],
"value_chain": "Fertilizantes, petroquímica, geração eléctrica a gás. Perfil de investidor: Grupos industriais e produtores independentes de energia.",
"market_evidence": "O Governo defende que o gás natural não deve ser visto apenas como produto de exportação, mas como matéria-prima para criar indústrias, empresas e emprego, com usos que vão da electricidade aos fertilizantes. O ministro dos Recursos Minerais e Energia identificou como desafios do sector privado o acesso a financiamento, a certificação e a tecnologia. Um estudo do Programa de Partilha de Conhecimentos (KSP) propõe um corredor energético Palma–Maputo para integrar petroquímica, fertilizantes e geração eléctrica, e destaca o gás natural veicular (GNV) nos transportes. Fertilizantes: o grupo Export Trading Group (ETG) manifestou, em Outubro de 2026, interesse em produzir fertilizantes a partir do gás natural, com o objectivo de liderar a produção na África Subsariana.",
"indicative_location": "Corredor Palma–Maputo proposto no estudo; implantação de cada indústria por definir.",
"source_row": "table4:row4; paragraph:50,paragraph:51,paragraph:53",
"source_state": "Referência para preparação",
"availability": "reference_only",
"promotion_ready": false,
"coordinates": null
},
{
"id": "ENE-05",
"title": "Gás natural — Mercado final",
"group_ids": [
"EN-GAS"
],
"locations_text": "Implantação a definir no investimento; sem localização discriminada na fonte.",
"location_labels": [],
"value_chain": "GNV, biocombustíveis, mobilidade eléctrica. Perfil de investidor: Empresas de distribuição e mobilidade.",
"market_evidence": "Gás natural veicular e biocombustíveis: o Governo considera GNV, conversão de gás em electricidade, mobilidade eléctrica e biocombustíveis respostas à volatilidade dos preços dos combustíveis. Mercado interno: gasodutos e projectos de GNV orientados para o consumo doméstico abrem espaço ao sector privado.",
"indicative_location": "Implantação a definir no investimento; sem localização discriminada na fonte.",
"source_row": "table4:row5; paragraph:55,paragraph:56",
"source_state": "Referência para preparação",
"availability": "reference_only",
"promotion_ready": false,
"coordinates": null
},
{
"id": "ENE-06",
"title": "Hidroeléctricas, redes e interligações",
"group_ids": [
"EN-POWER"
],
"locations_text": "Rio Zambeze; Maputo–Temane; interligação Moçambique–Zâmbia. Âmbitos de projecto, sem coordenadas.",
"location_labels": [
"Rio Zambeze",
"Maputo–Temane",
"Moçambique–Zâmbia"
],
"value_chain": "Fornecimento de subestações, transformadores, protecções eléctricas, SCADA e modernização de redes.",
"market_evidence": "A Hidroeléctrica de Cahora Bassa, com 2.075 MW instalados, mantém um papel central. Estão em preparação a barragem de Mphanda Nkuwa (1.500 MW no rio Zambeze, com conclusão prevista para 2031), a expansão de Cahora Bassa Norte (1.250 MW) e a linha de transmissão Maputo-Temane. A modernização da rede da EDM e a interligação Moçambique-Zâmbia de 400 kV, apoiada pelo BAD, abrem espaço para fornecimento de subestações, transformadores, protecções eléctricas e sistemas SCADA, além de reforçar a exportação regional de energia. Hidroeléctrica: a ETS prevê 3,5 GW de nova capacidade hídrica entre 2024 e 2030, incluindo a conclusão de Mphanda Nkuwa (1.500 MW), um projecto que o Governo procurou apresentar a parceiros estratégicos. Solar e eólica: a ETS prevê um programa de leilões de energia renovável para impulsionar estas fontes, bem como a expansão e modernização da rede para absorver a nova geração.",
"indicative_location": "Rio Zambeze; Maputo–Temane; interligação Moçambique–Zâmbia. Âmbitos de projecto, sem coordenadas.",
"source_row": "paragraphs:37,38,67,68",
"source_state": "Referência para preparação",
"availability": "reference_only",
"promotion_ready": false,
"coordinates": null
},
{
"id": "ENE-07",
"title": "Solar, baterias e sistemas fora da rede",
"group_ids": [
"EN-SOLAR"
],
"locations_text": "Mocuba (Zambézia), Metoro (Cabo Delgado), Cuamba (Niassa) e Afungi. Sistemas fora da rede: implantação a definir.",
"location_labels": [
"Zambézia",
"Mocuba",
"Cabo Delgado",
"Metoro",
"Niassa",
"Cuamba",
"Afungi"
],
"value_chain": "Produção solar, sistemas de armazenamento, fornecimento e manutenção de equipamentos e soluções fora da rede.",
"market_evidence": "Moçambique recebe cerca de 2.500 horas de sol por ano e dispõe de potencial eólico ao longo da costa. A estratégia nacional prevê 2 GW de energia solar até 2030, e já existem projectos como a central solar de 7,1 MW associada ao complexo de Afungi e um parque solar de 400 MW ligado a Cahora Bassa. Os sistemas fora da rede permitem ainda alargar o acesso à electricidade onde a rede é mais lenta ou dispendiosa de expandir. Os projectos abaixo estão documentados em fontes oficiais (MIREME e EDM) ou em comunicados dos promotores. Mostram o modelo dominante: produtores independentes em parceria com a EDM, com contratos de compra de energia de longo prazo e financiamento de instituições de desenvolvimento. Os dados de Metoro diferem entre fontes, sobretudo no valor do investimento, por isso devem ser confirmados com a EDM. Em Mocuba, parte da receita anual destina-se a programas sociais na comunidade, e a Cuamba é um dos primeiros projectos do país a integrar armazenamento em baterias, o que sugere oportunidades em sistemas de armazenamento. Estado actual: segundo o director-executivo da Globeleq em Moçambique, as centrais de Mocuba e Cuamba estão em operação, foram adquiridas pela empresa em Dezembro de 2023 e injectam 48 MW na rede da EDM (33 MW em Mocuba e 15 MW em Cuamba), reduzindo as perdas de transmissão nas regiões centro e norte. Esta informação vem de uma declaração do promotor divulgada pela imprensa; a referência consultada não apresenta confirmação directa da EDM nem dados de 2026 sobre Metoro .",
"indicative_location": "Mocuba (Zambézia), Metoro (Cabo Delgado), Cuamba (Niassa) e Afungi. Sistemas fora da rede: implantação a definir.",
"source_row": "paragraphs:40,43,45,46; table3:rows1–3",
"source_state": "Referência para preparação",
"availability": "reference_only",
"promotion_ready": false,
"coordinates": null
},
{
"id": "ENE-08",
"title": "Energia eólica — Namaacha",
"group_ids": [
"EN-WIND"
],
"locations_text": "Namaacha (Maputo, sem distinção entre província e cidade na fonte).",
"location_labels": [
"Namaacha",
"Maputo"
],
"value_chain": "Desenvolvimento e fornecimento para geração eólica; obras previstas dependem do fecho financeiro.",
"market_evidence": "Quanto à eólica, a Globeleq indicou em 2026 que desenvolve, com a EDM e a Source Energia, o parque eólico de Namaacha (Maputo), com 120 MW previstos e investimento global estimado em cerca de 260 milhões de dólares. O início das obras está previsto para 2027, depois do fecho financeiro, pelo que o projecto ainda não está em construção.",
"indicative_location": "Namaacha (Maputo, sem distinção entre província e cidade na fonte).",
"source_row": "paragraph:47",
"source_state": "Referência para preparação",
"availability": "reference_only",
"promotion_ready": false,
"coordinates": null
},
{
"id": "ENE-09",
"title": "Hidrogénio verde — estudos e infra-estruturas",
"group_ids": [
"EN-HYDROGEN"
],
"locations_text": "Sem implantação concreta identificada na fonte.",
"location_labels": [],
"value_chain": "Estudos de mercado e viabilidade, consultoria, parcerias, produção, armazenamento e transporte.",
"market_evidence": "A Estratégia de Transição Energética (ETS) prevê investimentos de cerca de 80 mil milhões de dólares até 2050 e fixa a meta de posicionar Moçambique entre os líderes na produção de hidrogénio na África austral até 2030. O Governo aponta a combinação de recursos hídricos, gás, solar e eólico como base para a produção, sobretudo de hidrogénio verde, e prevê que seja maioritariamente exportado. A ETS refere ainda a necessidade de instalações de produção, armazenamento e redes de transporte, e parcerias regionais. O sector está numa fase inicial: o banco alemão KfW e a EDM assinaram, em Março de 2025, um acordo de 500 mil euros para estudos de mercado e de viabilidade técnica e económica do hidrogénio verde. Existem, por isso, oportunidades em estudos, consultoria, parcerias e infra-estruturas de apoio, mas o levantamento não identifica projectos com decisão final de investimento.",
"indicative_location": "Sem implantação concreta identificada na fonte.",
"source_row": "paragraphs:61,62",
"source_state": "Referência para preparação",
"availability": "reference_only",
"promotion_ready": false,
"coordinates": null
},
{
"id": "ENE-10",
"title": "Biocombustíveis e respectivas cadeias de fornecimento",
"group_ids": [
"EN-BIO"
],
"locations_text": "Implantação a definir; a fonte não discrimina locais de produção.",
"location_labels": [],
"value_chain": "Matérias-primas agrícolas, produção, armazenagem, transporte, distribuição e mistura de biocombustíveis.",
"market_evidence": "O Decreto n.º 61/2023 regula a produção, transporte, armazenagem, distribuição, comercialização e exportação de biocombustíveis e prevê que importadores e distribuidores de combustíveis líquidos adquiram biocombustível produzido no país para mistura. A produção acima de 1.000.000 de litros por ano é licenciada pelo ministro da Energia. Um estudo do programa USAID SPEED indica que as misturas B3 (biodiesel) e E10 (etanol) estavam previstas para iniciar em Agosto de 2024 e que as matérias-primas imediatamente disponíveis são a cana-de-açúcar (etanol) e o óleo de soja e de algodão (biodiesel). O mesmo estudo lembra que a anterior tentativa de criar a indústria (2005–2013, centrada na jatrofa e na exportação) não teve sucesso, por falta de sementes de qualidade, apoio à cadeia de valor e mercado, e que há preocupações com terra, água e segurança alimentar. Em Junho de 2026, o MIREME promoveu o primeiro seminário nacional de biocombustíveis para identificar instrumentos de financiamento e operacionalizar a mistura obrigatória.",
"indicative_location": "Implantação a definir; a fonte não discrimina locais de produção.",
"source_row": "paragraphs:64,65",
"source_state": "Referência para preparação",
"availability": "reference_only",
"promotion_ready": false,
"coordinates": null
},
{
"id": "ENE-11",
"title": "Parques industriais verdes",
"group_ids": [
"EN-GREEN"
],
"locations_text": "Corredores e parques sem implantação discriminada na fonte.",
"location_labels": [],
"value_chain": "Indústria e infra-estruturas abastecidas por energia limpa; preparação de parcerias e localização.",
"market_evidence": "Parques industriais verdes: a ETS refere corredores e parques industriais abastecidos por energia limpa, uma oportunidade para indústria e infra-estruturas.",
"indicative_location": "Corredores e parques sem implantação discriminada na fonte.",
"source_row": "paragraph:69",
"source_state": "Referência para preparação",
"availability": "reference_only",
"promotion_ready": false,
"coordinates": null
}
],
"projects": [
"Mozambique LNG (TotalEnergies) — Gás natural. Investimento estimado: ~20 mil milhões USD. Estado/calendário na fonte: Relançado em Jan. 2026; primeira produção em 2029.",
"Rovuma LNG (ExxonMobil) — Gás natural. Investimento estimado: ~24 mil milhões USD. Estado/calendário na fonte: Pré-decisão final de investimento.",
"Coral Norte FLNG (Eni) — Gás natural. Investimento estimado: 6 a 7 mil milhões USD. Estado/calendário na fonte: Decisão final tomada em 2025; capacidade a partir de 2028.",
"Mphanda Nkuwa — Hidroeléctrica. Investimento estimado: ~4,5 mil milhões EUR. Estado/calendário na fonte: 1.500 MW; conclusão prevista para 2031.",
"Interligação Moçambique-Zâmbia — Transmissão. Estado/calendário na fonte: Em execução, com apoio do BAD.",
"Central solar de Afungi — Solar. Investimento estimado: 10 a 15 milhões USD. Estado/calendário na fonte: Concurso EPC lançado (7,1 MW).",
"Central Solar de Mocuba (Zambézia) — Solar. Capacidade: 40 MW instalados (cerca de 33 MW injectados na rede); cerca de 79 GWh /ano. Investimento referido: 76 milhões USD. Parcerias: Origem: Scatec (52,5%), EDM (25%), Norfund (22,5%); financiamento IFC e Fundo Africano de Infra-estruturas Emergentes; contrato de compra de energia de 25 anos. Adquirida pela Globeleq em Dezembro de 2023. Estado na fonte: Inaugurada em Agosto de 2019, a primeira de grandes dimensões no país; em operação em 2026.",
"Central Solar de Metoro (Cabo Delgado) — Solar. Capacidade: 41 MWp; cerca de 69 GWh /ano. Investimento referido: 56 milhões USD (MIREME); 47,3 milhões EUR (Lusa/ ALER). Parcerias: Neoen (75%) e EDM (25%); empréstimo da AFD de 40 milhões USD. Estado na fonte: Inaugurada em 1 de Abril de 2022.",
"Central Solar de Cuamba (Niassa) — Solar com baterias. Capacidade: 19 MWp (15 MWac) com baterias de 2 MW / 7 MWh. Investimento referido: 36 milhões USD. Parcerias: Globeleq, Source Energia e EDM; dívida de 19 milhões USD do EAIF / PIDG; contrato de compra de energia de 25 anos. Estado na fonte: Em operação e a injectar cerca de 15 MW na rede em 2026 (fecho financeiro em Dezembro de 2021).",
"Parque eólico de Namaacha — Eólica. Investimento estimado: ~260 milhões USD. Estado/calendário na fonte: Em desenvolvimento (120 MW); obras previstas para 2027, após fecho financeiro."
],
"indicators": [],
"risks": [
"Segurança: Instabilidade em Cabo Delgado já adiou projectos de GNL. Resposta a considerar: Acompanhar a situação operacional e exigir planos de segurança.",
"Macrofiscal: A consolidação fiscal é essencial para atrair o sector privado. Resposta a considerar: Parcerias com financiadores multilaterais e garantias.",
"Ambiental e social: Barragens e GNL exigem gestão de impactos e realojamento. Resposta a considerar: Estudos de impacto e planos de envolvimento comunitário.",
"Mercado: Maior concorrência global entre projectos de GNL e financiamento condicionado. Resposta a considerar: Diversificar entre gás, renováveis e redes."
],
"sources": [
"Instituto Nacional de Petróleo – Oportunidades de Investimento",
"Novimoz – Gás natural em Moçambique: exportação e renováveis aceleram debate",
"Efacont – Energia Moçambique: Projetos e Oportunidades de Negócio",
"Integrity Magazine – Moçambique poderá avançar com dois grandes projectos energéticos",
"EY Moçambique – Transição Energética em Moçambique",
"Boletim da República – Lei n.º 9/2026 (Lei do Conteúdo Local)",
"BCL – A Lei do Conteúdo Local está disponível para consulta pública",
"APIEX – Legislação de Investimentos em Moçambique",
"INP – Políticas e Quadro Legal",
"Gasoduto – Ministro da Energia sugere cadeias de valor do gás",
"O Económico – Oportunidades Industriais do Gás Natural em Moçambique",
"MMO – Ministro incentiva empresários moçambicanos a aproveitarem oportunidades do gás natural",
"Diário Económico – Grupo ETG quer produzir fertilizantes a partir do gás natural",
"Agroportal – Empresas de fertilizantes e energia interessadas em investir em Moçambique",
"RTP – Moçambique quer entrar na produção de hidrogénio e ser líder na África austral até 2030",
"Jornal Notícias – Estuda-se viabilidade do hidrogénio verde",
"Boletim da República – Decreto n.º 61/2023 (Regulamento de Biocombustíveis)",
"USAID SPEED – Avaliação da política de mistura obrigatória de biocombustíveis em Moçambique",
"Jornal Domingo – MIREME apresenta quadro regulatório de biocombustível",
"EDM – Inauguração da Central Solar de Mocuba",
"MIREME – O país dispõe de mais uma central solar (Metoro)",
"ALER – Presidente moçambicano inaugura maior central solar do país",
"Camaramen – Acordo de financiamento da Central Solar de Cuamba",
"Diário Económico – EDM: Centrais de Cuamba e Mocuba reduzem perdas de energia na rede nacional"
],
"source_links": [
{
"title": "Instituto Nacional de Petróleo – Oportunidades de Investimento",
"url": "https://www.inp.gov.mz/oportunidades-de-investimento/",
"source_paragraph": 90
},
{
"title": "Novimoz – Gás natural em Moçambique: exportação e renováveis aceleram debate",
"url": "https://novimz.com/2026/08/21/gas-natural-em-mocambique-exportacao-e-renovaveis-aceleram-debate/",
"source_paragraph": 91
},
{
"title": "Efacont – Energia Moçambique: Projetos e Oportunidades de Negócio",
"url": "https://www.efacont.pt/blog/setor-energia-mocambique/",
"source_paragraph": 92
},
{
"title": "Integrity Magazine – Moçambique poderá avançar com dois grandes projectos energéticos",
"url": "https://integritymagazine.co.mz/arquivos/45893",
"source_paragraph": 93
},
{
"title": "EY Moçambique – Transição Energética em Moçambique",
"url": "https://www.ey.com/pt_mz/insights/energy-resources/transicao-energetica-em-mocambique",
"source_paragraph": 94
},
{
"title": "Boletim da República – Lei n.º 9/2026 (Lei do Conteúdo Local)",
"url": "https://mireme.gov.mz/wp-content/uploads/2026/09/Lei-de-Conteudo-Local-BR_104_I_SERIE_3.o_SUPLEMENTO_2026.pdf",
"source_paragraph": 95
},
{
"title": "BCL – A Lei do Conteúdo Local está disponível para consulta pública",
"url": "https://bcl.org.mz/noticia/a-lei-do-conteudo-local-esta-disponivel-para-consulta-publica-no-website-do-bcl/",
"source_paragraph": 96
},
{
"title": "APIEX – Legislação de Investimentos em Moçambique",
"url": "https://pt.apiex.gov.mz/wp-content/uploads/2025/02/LEGISLACAO-DE-INVESTIMENTOS-EM-MOCAMBIQUE.pdf",
"source_paragraph": 97
},
{
"title": "INP – Políticas e Quadro Legal",
"url": "https://www.inp.gov.mz/politicas-e-quadro-legal/",
"source_paragraph": 98
},
{
"title": "Gasoduto – Ministro da Energia sugere cadeias de valor do gás",
"url": "https://gasoduto.co.mz/2026/07/16/ministro-da-energia-sugere-cadeias-de-valor-do-gas/",
"source_paragraph": 99
},
{
"title": "O Económico – Oportunidades Industriais do Gás Natural em Moçambique",
"url": "https://www.oeconomico.com/oportunidades-industriais-do-gas-natural-em-mocambique/",
"source_paragraph": 100
},
{
"title": "MMO – Ministro incentiva empresários moçambicanos a aproveitarem oportunidades do gás natural",
"url": "https://noticias.mmo.co.mz/2026/06/ministro-incentiva-empresarios-mocambicanos-a-aproveitarem-oportunidades-do-gas-natural.html",
"source_paragraph": 101
},
{
"title": "Diário Económico – Grupo ETG quer produzir fertilizantes a partir do gás natural",
"url": "https://www.diarioeconomico.co.mz/2026/10/23/oilgas/grupo-etg-quer-produzir-fertilizantes-a-partir-do-gas-natural-no-pais/",
"source_paragraph": 102
},
{
"title": "Agroportal – Empresas de fertilizantes e energia interessadas em investir em Moçambique",
"url": "https://www.agroportal.pt/empresas-na-areas-dos-fertilizantes-e-da-energia-interessados-em-investir-mais-em-mocambique/",
"source_paragraph": 103
},
{
"title": "RTP – Moçambique quer entrar na produção de hidrogénio e ser líder na África austral até 2030",
"url": "https://www.rtp.pt/noticias/economia/mocambique-quer-entrar-na-producao-de-hidrogenio-e-ser-lider-na-africa-austral-ate-2030_n1551096",
"source_paragraph": 104
},
{
"title": "Jornal Notícias – Estuda-se viabilidade do hidrogénio verde",
"url": "https://www.jornalnoticias.co.mz/destaque/estuda-se-viabilidade-do-hidrogenio-verde/",
"source_paragraph": 105
},
{
"title": "Boletim da República – Decreto n.º 61/2023 (Regulamento de Biocombustíveis)",
"url": "https://faolex.fao.org/docs/pdf/moz223152.pdf",
"source_paragraph": 106
},
{
"title": "USAID SPEED – Avaliação da política de mistura obrigatória de biocombustíveis em Moçambique",
"url": "https://repositorio.up.ac.mz/server/api/core/bitstreams/6a5d12fc-736e-4a7b-8ccc-84365279377d/content",
"source_paragraph": 107
},
{
"title": "Jornal Domingo – MIREME apresenta quadro regulatório de biocombustível",
"url": "https://www.jornaldomingo.co.mz/economia/mireme-apresenta-quadro-regulatorio-de-biocombustivel/",
"source_paragraph": 108
},
{
"title": "EDM – Inauguração da Central Solar de Mocuba",
"url": "https://portal.edm.co.mz/en/node/1294",
"source_paragraph": 109
},
{
"title": "MIREME – O país dispõe de mais uma central solar (Metoro)",
"url": "https://mireme.gov.mz/o-pais-dispoe-de-mais-uma-central-solar/",
"source_paragraph": 110
},
{
"title": "ALER – Presidente moçambicano inaugura maior central solar do país",
"url": "https://www.aler-energia.org/pt/comunicacao/noticias/presidente-mocambicano-inaugura-maior-central-solar-do-pais/",
"source_paragraph": 111
},
{
"title": "Camaramen – Acordo de financiamento da Central Solar de Cuamba",
"url": "https://camaramen.co.mz/665/concluido-acordo-para-o-financiamento-da-central-solar-de-cuamba-em-niassa/",
"source_paragraph": 112
},
{
"title": "Diário Económico – EDM: Centrais de Cuamba e Mocuba reduzem perdas de energia na rede nacional",
"url": "https://www.diarioeconomico.co.mz/2026/05/28/oilgas/energia/edm-centrais-de-cuamba-e-mocuba-reduzem-perdas-de-energia-na-rede-nacional/",
"source_paragraph": 113
}
],
"background": [
{
"title": "Financiamento referido nas fontes",
"paragraphs": [
"Instituições como o Banco Mundial, o BAD e a União Europeia mobilizam financiamento para redes, interligações regionais e renováveis.",
"O novo programa do Banco Mundial (cerca de 6 mil milhões de dólares), as linhas do BAD, a iniciativa Global Gateway da União Europeia e o apoio do Exim Bank dos EUA ao Mozambique LNG (4,7 mil milhões de dólares) sustentam o ciclo actual. Parcerias público-privadas e quadros regulatórios estáveis são factores apontados para atrair capital."
]
},
{
"title": "Enquadramento sectorial referido nas fontes",
"paragraphs": [
"O investimento privado rege-se pela Lei de Investimentos (Lei n.º 8/2023, de 9 de Junho), que estabelece garantias, direitos e deveres dos investidores e prevê incentivos fiscais e não fiscais. Esta lei não se aplica aos investimentos feitos ao abrigo de legislação específica, como a prospecção, pesquisa e produção de petróleo e gás, mas aplica-se, nas matérias não reguladas, ao processamento, comercialização e transporte de produtos petrolíferos. O Código dos Benefícios Fiscais (Lei n.º 4/2009) completa o regime geral.",
"As operações petrolíferas têm regime próprio: a Lei n.º 27/2014 (alterada pela Lei n.º 14/2017) define a tributação e os benefícios fiscais do sector. Em 3 de Junho de 2026 foi aprovada uma nova Lei do Petróleo (Lei n.º 8/2026), que revoga a Lei n.º 21/2014 e a Lei n.º 16/2022. Os investidores devem verificar o texto actualizado e os respectivos contratos de concessão.",
"A Lei n.º 9/2026, de 3 de Junho, estabelece o regime de aquisição de bens, contratação de serviços e emprego de mão-de-obra nacional nos projectos de petróleo e gás natural. Aplica-se aos titulares de direitos de operações petrolíferas, a terceiros que executem essas operações e às entidades que com eles contratam, que devem garantir, por via contratual, que os subcontratados cumprem as regras, sob pena de responsabilidade solidária.",
"Entre os princípios da lei estão a preservação do interesse nacional, a capacitação dos empresários moçambicanos, a contratação de mão-de-obra nacional com transferência de conhecimento, o incentivo a parcerias entre empresas nacionais e estrangeiras e a transparência nas aquisições. A lei prevê a criação de uma Autoridade de Conteúdo Local, e um regulamento deverá definir os planos de aquisição, a certificação e a monitoria. A sua aprovação pelo Conselho de Ministros era esperada em cerca de 90 dias após a publicação."
]
},
{
"title": "Âmbito dos dados solares",
"paragraphs": [
"Os dados de Metoro diferem entre fontes, sobretudo no valor do investimento, por isso devem ser confirmados com a EDM. Em Mocuba, parte da receita anual destina-se a programas sociais na comunidade, e a Cuamba é um dos primeiros projectos do país a integrar armazenamento em baterias, o que sugere oportunidades em sistemas de armazenamento.",
"Estado actual: segundo o director-executivo da Globeleq em Moçambique, as centrais de Mocuba e Cuamba estão em operação, foram adquiridas pela empresa em Dezembro de 2023 e injectam 48 MW na rede da EDM (33 MW em Mocuba e 15 MW em Cuamba), reduzindo as perdas de transmissão nas regiões centro e norte. Esta informação vem de uma declaração do promotor divulgada pela imprensa; a referência consultada não apresenta confirmação directa da EDM nem dados de 2026 sobre Metoro ."
]
}
],
"original_tables": [
[
[
"Preparado por",
"Osmane Nala"
],
[
"Data",
"11 de Novembro de 2026"
],
[
"Âmbito",
"Gás natural, hidroeléctrica, redes e energias renováveis"
]
],
[
[
"Projecto",
"Segmento",
"Investimento estimado",
"Estado / calendário"
],
[
"Mozambique LNG (TotalEnergies)",
"Gás natural",
"~20 mil milhões USD",
"Relançado em Jan. 2026; primeira produção em 2029"
],
[
"Rovuma LNG (ExxonMobil)",
"Gás natural",
"~24 mil milhões USD",
"Pré-decisão final de investimento"
],
[
"Coral Norte FLNG (Eni)",
"Gás natural",
"6 a 7 mil milhões USD",
"Decisão final tomada em 2025; capacidade a partir de 2028"
],
[
"Mphanda Nkuwa",
"Hidroeléctrica",
"~4,5 mil milhões EUR",
"1.500 MW; conclusão prevista para 2031"
],
[
"Interligação Moçambique-Zâmbia",
"Transmissão",
"n.d.",
"Em execução, com apoio do BAD"
],
[
"Central solar de Afungi",
"Solar",
"10 a 15 milhões USD",
"Concurso EPC lançado (7,1 MW)"
],
[
"Central Solar de Mocuba",
"Solar",
"76 milhões USD",
"Em operação; injecta cerca de 33 MW na rede (2026)"
],
[
"Central Solar de Metoro",
"Solar",
"56 milhões USD (MIREME)",
"Inaugurada em 2022; 41 MWp"
],
[
"Central Solar de Cuamba",
"Solar com baterias",
"36 milhões USD",
"Em operação; injecta cerca de 15 MW na rede (2026)"
],
[
"Parque eólico de Namaacha",
"Eólica",
"~260 milhões USD",
"Em desenvolvimento (120 MW); obras previstas para 2027, após fecho financeiro"
]
],
[
[
"Projecto",
"Capacidade",
"Investimento",
"Parceiros e financiamento",
"Estado"
],
[
"Central Solar de Mocuba (Zambézia)",
"40 MW instalados (cerca de 33 MW injectados na rede); cerca de 79 GWh /ano",
"76 milhões USD",
"Origem: Scatec (52,5%), EDM (25%), Norfund (22,5%); financiamento IFC e Fundo Africano de Infra-estruturas Emergentes; contrato de compra de energia de 25 anos. Adquirida pela Globeleq em Dezembro de 2023",
"Inaugurada em Agosto de 2019, a primeira de grandes dimensões no país; em operação em 2026"
],
[
"Central Solar de Metoro (Cabo Delgado)",
"41 MWp; cerca de 69 GWh /ano",
"56 milhões USD (MIREME); 47,3 milhões EUR (Lusa/ ALER)",
"Neoen (75%) e EDM (25%); empréstimo da AFD de 40 milhões USD",
"Inaugurada em 1 de Abril de 2022"
],
[
"Central Solar de Cuamba (Niassa)",
"19 MWp (15 MWac) com baterias de 2 MW / 7 MWh",
"36 milhões USD",
"Globeleq, Source Energia e EDM; dívida de 19 milhões USD do EAIF / PIDG; contrato de compra de energia de 25 anos",
"Em operação e a injectar cerca de 15 MW na rede em 2026 (fecho financeiro em Dezembro de 2021)"
]
],
[
[
"Elo da cadeia",
"Exemplos de oportunidades",
"Perfil de investidor"
],
[
"Upstream",
"Concursos de áreas, campos maduros (Buzi), parcerias com empresas locais",
"Operadores e parceiros locais"
],
[
"Construção e serviços",
"Engenharia, logística, serviços marítimos, telecomunicações, manutenção, formação",
"PME nacionais e parcerias internacionais"
],
[
"Midstream",
"Gasodutos Norte-Sul e Palma–Maputo, armazenamento de GPL, GNL de pequena escala",
"Investidores em infra-estruturas"
],
[
"Industrialização",
"Fertilizantes, petroquímica, geração eléctrica a gás",
"Grupos industriais e produtores independentes de energia"
],
[
"Mercado final",
"GNV, biocombustíveis, mobilidade eléctrica",
"Empresas de distribuição e mobilidade"
]
],
[
[
"Diploma",
"Matéria",
"Relevância para o investidor"
],
[
"Lei n.º 8/2023 (Lei de Investimentos)",
"Garantias, direitos, deveres e incentivos",
"Regime geral; não cobre a pesquisa e produção de petróleo e gás"
],
[
"Lei n.º 4/2009 (Código dos Benefícios Fiscais)",
"Benefícios fiscais",
"Base dos incentivos fiscais"
],
[
"Leis n.º 27/2014 e 14/2017",
"Tributação e benefícios das operações petrolíferas",
"Regime fiscal específico do sector"
],
[
"Lei n.º 8/2026 (Lei do Petróleo)",
"Atribuição de direitos petrolíferos",
"Substitui a Lei n.º 21/2014"
],
[
"Lei n.º 9/2026 (Conteúdo Local)",
"Bens, serviços e mão-de-obra nacionais",
"Obriga a priorizar fornecedores e trabalhadores moçambicanos"
]
],
[
[
"Risco",
"Descrição",
"Mitigação"
],
[
"Segurança",
"Instabilidade em Cabo Delgado já adiou projectos de GNL.",
"Acompanhar a situação operacional e exigir planos de segurança."
],
[
"Macrofiscal",
"A consolidação fiscal é essencial para atrair o sector privado.",
"Parcerias com financiadores multilaterais e garantias."
],
[
"Ambiental e social",
"Barragens e GNL exigem gestão de impactos e realojamento.",
"Estudos de impacto e planos de envolvimento comunitário."
],
[
"Mercado",
"Maior concorrência global entre projectos de GNL e financiamento condicionado.",
"Diversificar entre gás, renováveis e redes."
]
]
],
"original_paragraphs": [
"Oportunidades de investimento em energia",
"Panorama do sector energético em Moçambique",
"",
"Índice",
"Resumo executivo 3",
"Gás natural e GNL 3",
"Electricidade, hidroeléctricas e redes 3",
"Energias renováveis 4",
"Projectos solares em operação ou financiados 5",
"Outras oportunidades e cadeia de valor 6",
"Do gás exportado ao gás transformado 6",
"Oportunidades adicionais 7",
"Cadeia de valor e papel das empresas nacionais 7",
"Hidrogénio verde 8",
"Biocombustíveis 8",
"Outras fontes de energia 8",
"Financiamento 9",
"Incentivos fiscais, quadro legal e conteúdo local 9",
"Quadro legal geral 9",
"Regime específico do petróleo e gás 9",
"Lei do Conteúdo Local 9",
"Riscos e desafios 10",
"Recomendações 10",
"Conclusão 11",
"Fontes 11",
"Resumo executivo",
"Moçambique atravessa um ciclo de investimento energético particularmente relevante, assente no relançamento dos projectos de gás natural liquefeito (GNL) na bacia do Rovuma, na expansão da capacidade hidroeléctrica e na aposta crescente em energia solar e nas redes de transmissão.",
"O Mozambique LNG (TotalEnergies), com investimento estimado em cerca de 20 mil milhões de dólares, foi relançado em Janeiro de 2026, com primeira produção apontada para 2029.",
"A hidroeléctrica representa cerca de 70% da electricidade nacional, e a estratégia de transição energética prevê mais 2 a 4 GW hídricos e 2 GW solares até 2030.",
"Instituições como o Banco Mundial, o BAD e a União Europeia mobilizam financiamento para redes, interligações regionais e renováveis.",
"O investimento exige, contudo, atenção à segurança em Cabo Delgado, à estabilidade macrofiscal e à gestão dos impactos ambientais e sociais.",
"Principal conclusão",
"As maiores oportunidades concentram-se no gás natural (upstream, midstream e cadeia de fornecimento), na expansão de redes e interligações e em projectos solares e hídricos com apoio de financiadores multilaterais.",
"Gás natural e GNL",
"A bacia do Rovuma concentra as maiores oportunidades do país. O Mozambique LNG (Área 1) prevê uma capacidade anual de 12,9 milhões de toneladas; o Coral Norte FLNG (Eni) teve decisão final de investimento em 2025, com 3,6 milhões de toneladas anuais a partir de 2028; e o Rovuma LNG (ExxonMobil), estimado em cerca de 24 mil milhões de dólares, aproxima-se da decisão final de investimento.",
"Para investidores e empresas nacionais, as oportunidades estendem-se a engenharia, construção, logística, serviços marítimos, telecomunicações e manutenção industrial. No mercado interno, o Instituto Nacional de Petróleo identifica áreas como o gasoduto Norte-Sul, o campo maduro de Buzi, o GNL de pequena escala, o armazenamento de GPL e a base logística de Pemba.",
"Electricidade, hidroeléctricas e redes",
"A Hidroeléctrica de Cahora Bassa, com 2.075 MW instalados, mantém um papel central. Estão em preparação a barragem de Mphanda Nkuwa (1.500 MW no rio Zambeze, com conclusão prevista para 2031), a expansão de Cahora Bassa Norte (1.250 MW) e a linha de transmissão Maputo-Temane.",
"A modernização da rede da EDM e a interligação Moçambique-Zâmbia de 400 kV, apoiada pelo BAD, abrem espaço para fornecimento de subestações, transformadores, protecções eléctricas e sistemas SCADA, além de reforçar a exportação regional de energia.",
"Energias renováveis",
"Moçambique recebe cerca de 2.500 horas de sol por ano e dispõe de potencial eólico ao longo da costa. A estratégia nacional prevê 2 GW de energia solar até 2030, e já existem projectos como a central solar de 7,1 MW associada ao complexo de Afungi e um parque solar de 400 MW ligado a Cahora Bassa. Os sistemas fora da rede permitem ainda alargar o acesso à electricidade onde a rede é mais lenta ou dispendiosa de expandir.",
"Projectos energéticos em destaque",
"Projectos solares em operação ou financiados",
"Os projectos abaixo estão documentados em fontes oficiais (MIREME e EDM) ou em comunicados dos promotores. Mostram o modelo dominante: produtores independentes em parceria com a EDM, com contratos de compra de energia de longo prazo e financiamento de instituições de desenvolvimento.",
"Centrais solares concretas em Moçambique",
"Os dados de Metoro diferem entre fontes, sobretudo no valor do investimento, por isso devem ser confirmados com a EDM. Em Mocuba, parte da receita anual destina-se a programas sociais na comunidade, e a Cuamba é um dos primeiros projectos do país a integrar armazenamento em baterias, o que sugere oportunidades em sistemas de armazenamento.",
"Estado actual: segundo o director-executivo da Globeleq em Moçambique, as centrais de Mocuba e Cuamba estão em operação, foram adquiridas pela empresa em Dezembro de 2023 e injectam 48 MW na rede da EDM (33 MW em Mocuba e 15 MW em Cuamba), reduzindo as perdas de transmissão nas regiões centro e norte. Esta informação vem de uma declaração do promotor divulgada pela imprensa; não encontrei uma confirmação directa da EDM, nem dados de 2026 sobre Metoro .",
"Quanto à eólica, a Globeleq indicou em 2026 que desenvolve, com a EDM e a Source Energia, o parque eólico de Namaacha (Maputo), com 120 MW previstos e investimento global estimado em cerca de 260 milhões de dólares. O início das obras está previsto para 2027, depois do fecho financeiro, pelo que o projecto ainda não está em construção.",
"Outras oportunidades e cadeia de valor",
"Do gás exportado ao gás transformado",
"O Governo defende que o gás natural não deve ser visto apenas como produto de exportação, mas como matéria-prima para criar indústrias, empresas e emprego, com usos que vão da electricidade aos fertilizantes. O ministro dos Recursos Minerais e Energia identificou como desafios do sector privado o acesso a financiamento, a certificação e a tecnologia.",
"Um estudo do Programa de Partilha de Conhecimentos (KSP) propõe um corredor energético Palma–Maputo para integrar petroquímica, fertilizantes e geração eléctrica, e destaca o gás natural veicular (GNV) nos transportes.",
"Oportunidades adicionais",
"Fertilizantes: o grupo Export Trading Group (ETG) manifestou, em Outubro de 2026, interesse em produzir fertilizantes a partir do gás natural, com o objectivo de liderar a produção na África Subsariana.",
"Geração e transmissão de electricidade: a Azura Power, co-proprietária da central térmica de Ressano Garcia, declarou ambição de investir mais em geração, transmissão e infra-estruturas relacionadas, sem indicar projectos ou valores.",
"Gás natural veicular e biocombustíveis: o Governo considera GNV, conversão de gás em electricidade, mobilidade eléctrica e biocombustíveis respostas à volatilidade dos preços dos combustíveis.",
"Mercado interno: gasodutos e projectos de GNV orientados para o consumo doméstico abrem espaço ao sector privado.",
"Cadeia de valor e papel das empresas nacionais",
"Está prevista uma componente de formação para 15.734 moçambicanos (custo estimado em 126 milhões de dólares) e a capacitação de 355 pequenas e médias empresas na fase de construção e cerca de 1.500 na fase operacional. O ministro apelou a parcerias com entidades estrangeiras especializadas para transferência de conhecimento e tecnologia.",
"Elos da cadeia de valor do gás e oportunidades",
"Hidrogénio verde",
"A Estratégia de Transição Energética (ETS) prevê investimentos de cerca de 80 mil milhões de dólares até 2050 e fixa a meta de posicionar Moçambique entre os líderes na produção de hidrogénio na África austral até 2030. O Governo aponta a combinação de recursos hídricos, gás, solar e eólico como base para a produção, sobretudo de hidrogénio verde, e prevê que seja maioritariamente exportado. A ETS refere ainda a necessidade de instalações de produção, armazenamento e redes de transporte, e parcerias regionais.",
"O sector está numa fase inicial: o banco alemão KfW e a EDM assinaram, em Março de 2025, um acordo de 500 mil euros para estudos de mercado e de viabilidade técnica e económica do hidrogénio verde. Existem, por isso, oportunidades em estudos, consultoria, parcerias e infra-estruturas de apoio, mas ainda não encontrei projectos com decisão final de investimento.",
"Biocombustíveis",
"O Decreto n.º 61/2023 regula a produção, transporte, armazenagem, distribuição, comercialização e exportação de biocombustíveis e prevê que importadores e distribuidores de combustíveis líquidos adquiram biocombustível produzido no país para mistura. A produção acima de 1.000.000 de litros por ano é licenciada pelo ministro da Energia. Um estudo do programa USAID SPEED indica que as misturas B3 (biodiesel) e E10 (etanol) estavam previstas para iniciar em Agosto de 2024 e que as matérias-primas imediatamente disponíveis são a cana-de-açúcar (etanol) e o óleo de soja e de algodão (biodiesel).",
"O mesmo estudo lembra que a anterior tentativa de criar a indústria (2005–2013, centrada na jatrofa e na exportação) não teve sucesso, por falta de sementes de qualidade, apoio à cadeia de valor e mercado, e que há preocupações com terra, água e segurança alimentar. Em Junho de 2026, o MIREME promoveu o primeiro seminário nacional de biocombustíveis para identificar instrumentos de financiamento e operacionalizar a mistura obrigatória.",
"Outras fontes de energia",
"Hidroeléctrica: a ETS prevê 3,5 GW de nova capacidade hídrica entre 2024 e 2030, incluindo a conclusão de Mphanda Nkuwa (1.500 MW), um projecto que o Governo procurou apresentar a parceiros estratégicos.",
"Solar e eólica: a ETS prevê um programa de leilões de energia renovável para impulsionar estas fontes, bem como a expansão e modernização da rede para absorver a nova geração.",
"Parques industriais verdes: a ETS refere corredores e parques industriais abastecidos por energia limpa, uma oportunidade para indústria e infra-estruturas.",
"Financiamento",
"O novo programa do Banco Mundial (cerca de 6 mil milhões de dólares), as linhas do BAD, a iniciativa Global Gateway da União Europeia e o apoio do Exim Bank dos EUA ao Mozambique LNG (4,7 mil milhões de dólares) sustentam o ciclo actual. Parcerias público-privadas e quadros regulatórios estáveis são factores apontados para atrair capital.",
"Incentivos fiscais, quadro legal e conteúdo local",
"Quadro legal geral",
"O investimento privado rege-se pela Lei de Investimentos (Lei n.º 8/2023, de 9 de Junho), que estabelece garantias, direitos e deveres dos investidores e prevê incentivos fiscais e não fiscais. Esta lei não se aplica aos investimentos feitos ao abrigo de legislação específica, como a prospecção, pesquisa e produção de petróleo e gás, mas aplica-se, nas matérias não reguladas, ao processamento, comercialização e transporte de produtos petrolíferos. O Código dos Benefícios Fiscais (Lei n.º 4/2009) completa o regime geral.",
"Regime específico do petróleo e gás",
"As operações petrolíferas têm regime próprio: a Lei n.º 27/2014 (alterada pela Lei n.º 14/2017) define a tributação e os benefícios fiscais do sector. Em 3 de Junho de 2026 foi aprovada uma nova Lei do Petróleo (Lei n.º 8/2026), que revoga a Lei n.º 21/2014 e a Lei n.º 16/2022. Os investidores devem verificar o texto actualizado e os respectivos contratos de concessão.",
"Lei do Conteúdo Local",
"A Lei n.º 9/2026, de 3 de Junho, estabelece o regime de aquisição de bens, contratação de serviços e emprego de mão-de-obra nacional nos projectos de petróleo e gás natural. Aplica-se aos titulares de direitos de operações petrolíferas, a terceiros que executem essas operações e às entidades que com eles contratam, que devem garantir, por via contratual, que os subcontratados cumprem as regras, sob pena de responsabilidade solidária.",
"Entre os princípios da lei estão a preservação do interesse nacional, a capacitação dos empresários moçambicanos, a contratação de mão-de-obra nacional com transferência de conhecimento, o incentivo a parcerias entre empresas nacionais e estrangeiras e a transparência nas aquisições. A lei prevê a criação de uma Autoridade de Conteúdo Local, e um regulamento deverá definir os planos de aquisição, a certificação e a monitoria. A sua aprovação pelo Conselho de Ministros era esperada em cerca de 90 dias após a publicação.",
"Resumo do enquadramento para investidores",
"Riscos e desafios",
"Recomendações",
"Posicionar-se na cadeia de valor — Conteúdo local : registar-se junto dos grandes projectos de GNL e fornecer serviços de engenharia, logística e manutenção.",
"Explorar redes e interligações — Transmissão : acompanhar os concursos da EDM e da interligação Moçambique-Zâmbia para equipamentos e sistemas.",
"Apostar em solar e sistemas fora da rede — Diversificação : desenvolver projectos de menor escala com acesso a financiamento multilateral.",
"Gerir o risco desde o início — Segurança e conformidade : estruturar parcerias com empresas locais e prever cenários de atraso.",
"Conclusão",
"O sector energético moçambicano combina um ciclo de grandes projectos de gás com necessidades concretas de electricidade, redes e renováveis. Os investidores que conciliarem a escala dos megaprojectos com oportunidades de menor dimensão e uma gestão rigorosa do risco estarão melhor posicionados.",
"Fontes",
"Instituto Nacional de Petróleo – Oportunidades de Investimento",
"Novimoz – Gás natural em Moçambique: exportação e renováveis aceleram debate",
"Efacont – Energia Moçambique: Projetos e Oportunidades de Negócio",
"Integrity Magazine – Moçambique poderá avançar com dois grandes projectos energéticos",
"EY Moçambique – Transição Energética em Moçambique",
"Boletim da República – Lei n.º 9/2026 (Lei do Conteúdo Local)",
"BCL – A Lei do Conteúdo Local está disponível para consulta pública",
"APIEX – Legislação de Investimentos em Moçambique",
"INP – Políticas e Quadro Legal",
"Gasoduto – Ministro da Energia sugere cadeias de valor do gás",
"O Económico – Oportunidades Industriais do Gás Natural em Moçambique",
"MMO – Ministro incentiva empresários moçambicanos a aproveitarem oportunidades do gás natural",
"Diário Económico – Grupo ETG quer produzir fertilizantes a partir do gás natural",
"Agroportal – Empresas de fertilizantes e energia interessadas em investir em Moçambique",
"RTP – Moçambique quer entrar na produção de hidrogénio e ser líder na África austral até 2030",
"Jornal Notícias – Estuda-se viabilidade do hidrogénio verde",
"Boletim da República – Decreto n.º 61/2023 (Regulamento de Biocombustíveis)",
"USAID SPEED – Avaliação da política de mistura obrigatória de biocombustíveis em Moçambique",
"Jornal Domingo – MIREME apresenta quadro regulatório de biocombustível",
"EDM – Inauguração da Central Solar de Mocuba",
"MIREME – O país dispõe de mais uma central solar (Metoro)",
"ALER – Presidente moçambicano inaugura maior central solar do país",
"Camaramen – Acordo de financiamento da Central Solar de Cuamba",
"Diário Económico – EDM: Centrais de Cuamba e Mocuba reduzem perdas de energia na rede nacional"
],
"source_instructions_are_not_product_rules": true
}
File diff suppressed because it is too large Load Diff
+806 -9
View File
@@ -4,8 +4,18 @@
"fdel": {
"edition": "fdel-br43-20250305-tete20251020-v1",
"provenance": [
{ "url": "https://mpd.gov.mz/wp-content/uploads/2025/10/BR_43_I_SERIE_2025.pdf", "sha256": "0ea99c5f894760e3b715e377e87e9715fad9423e322e5753de1b9bb345c64753", "checked_on": "2026-11-02", "reference": "BR I Série n.º 43, 05/03/2025, pp. 157–160; Decreto n.º 4/2025 e Regulamento anexo" },
{ "url": "https://cmct.gov.mz/fundo-de-desenvolvimento-economico-local-fdel/", "sha256": "c69c2c3883766aa6a238ae44d8ee3fc99570adca2f3e647e50df59fde2786403", "checked_on": "2026-11-02", "reference": "Aviso municipal FDEL, prazo 20/10/2025" }
{
"url": "https://mpd.gov.mz/wp-content/uploads/2025/10/BR_43_I_SERIE_2025.pdf",
"sha256": "0ea99c5f894760e3b715e377e87e9715fad9423e322e5753de1b9bb345c64753",
"checked_on": "2026-11-02",
"reference": "BR I Série n.º 43, 05/03/2025, pp. 157–160; Decreto n.º 4/2025 e Regulamento anexo"
},
{
"url": "https://cmct.gov.mz/fundo-de-desenvolvimento-economico-local-fdel/",
"sha256": "c69c2c3883766aa6a238ae44d8ee3fc99570adca2f3e647e50df59fde2786403",
"checked_on": "2026-11-02",
"reference": "Aviso municipal FDEL, prazo 20/10/2025"
}
],
"draft": {
"title": "Fundo de Desenvolvimento Económico Local — FDEL",
@@ -25,8 +35,22 @@
"sector_ids": [],
"resource_ids": [],
"criteria": [
{ "code": "nationality-individual", "question": "Tem nacionalidade moçambicana?", "applies_to": "individual", "expected": "yes", "source_reference": "Decreto n.º 4/2025, art. 3", "explanation": "Condição declarada; não substitui a apreciação competente." },
{ "code": "economic-viability", "question": "O projecto apresenta elementos de viabilidade económica?", "applies_to": "all", "expected": "yes", "source_reference": "Decreto n.º 4/2025, art. 3; Regulamento, art. 13", "explanation": "A avaliação do projecto cabe às entidades competentes." }
{
"code": "nationality-individual",
"question": "Tem nacionalidade moçambicana?",
"applies_to": "individual",
"expected": "yes",
"source_reference": "Decreto n.º 4/2025, art. 3",
"explanation": "Condição declarada; não substitui a apreciação competente."
},
{
"code": "economic-viability",
"question": "O projecto apresenta elementos de viabilidade económica?",
"applies_to": "all",
"expected": "yes",
"source_reference": "Decreto n.º 4/2025, art. 3; Regulamento, art. 13",
"explanation": "A avaliação do projecto cabe às entidades competentes."
}
],
"windows": [
{
@@ -42,15 +66,788 @@
"checked_on": "2026-11-02",
"application_url": "https://cmct.gov.mz/anexo-5-plano-de-negocio/",
"criteria": [
{ "code": "tete-nuit", "question": "Dispõe de NUIT?", "applies_to": "all", "expected": "yes", "source_reference": "Aviso municipal, Requisitos", "explanation": "Documentação indicada para esta chamada histórica." },
{ "code": "tete-identity", "question": "Dispõe de cópia do BI?", "applies_to": "individual", "expected": "yes", "source_reference": "Aviso municipal, Candidatos individuais", "explanation": "Aplica-se a pessoas singulares nesta chamada." },
{ "code": "tete-registration", "question": "A entidade dispõe de comprovativo de registo legal?", "applies_to": "entity", "expected": "yes", "source_reference": "Aviso municipal, Entidades", "explanation": "Aplica-se às entidades indicadas no aviso." },
{ "code": "tete-idoneity", "question": "Dispõe de atestado de idoneidade emitido pela sede do bairro?", "applies_to": "individual", "expected": "yes", "source_reference": "Aviso municipal, Candidatos individuais", "explanation": "Documento indicado no aviso." },
{ "code": "tete-national-entity", "question": "A entidade é constituída por cidadãos nacionais?", "applies_to": "entity", "expected": "yes", "source_reference": "Aviso municipal, Entidades", "explanation": "Condição específica desta chamada." }
{
"code": "tete-nuit",
"question": "Dispõe de NUIT?",
"applies_to": "all",
"expected": "yes",
"source_reference": "Aviso municipal, Requisitos",
"explanation": "Documentação indicada para esta chamada histórica."
},
{
"code": "tete-identity",
"question": "Dispõe de cópia do BI?",
"applies_to": "individual",
"expected": "yes",
"source_reference": "Aviso municipal, Candidatos individuais",
"explanation": "Aplica-se a pessoas singulares nesta chamada."
},
{
"code": "tete-registration",
"question": "A entidade dispõe de comprovativo de registo legal?",
"applies_to": "entity",
"expected": "yes",
"source_reference": "Aviso municipal, Entidades",
"explanation": "Aplica-se às entidades indicadas no aviso."
},
{
"code": "tete-idoneity",
"question": "Dispõe de atestado de idoneidade emitido pela sede do bairro?",
"applies_to": "individual",
"expected": "yes",
"source_reference": "Aviso municipal, Candidatos individuais",
"explanation": "Documento indicado no aviso."
},
{
"code": "tete-national-entity",
"question": "A entidade é constituída por cidadãos nacionais?",
"applies_to": "entity",
"expected": "yes",
"source_reference": "Aviso municipal, Entidades",
"explanation": "Condição específica desta chamada."
}
]
}
]
}
},
"bci": {
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
"provenance": [
{
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
"checked_on": "2026-11-10"
},
{
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; BCI Exclusivo PME",
"url": "https://www.bci.co.mz/bci-exclusivo-pme/",
"checked_on": "2026-11-10"
}
],
"draft": {
"title": "BCI",
"kind": "institution",
"summary": "Conta corrente caucionada, empréstimo de médio/longo prazo, garantias e comércio externo. Público/finalidade: ENI, micro, pequenas e médias empresas.. Referência informativa, não concessão de financiamento.",
"manager_name": "BCI",
"source_url": "https://www.bci.co.mz/bci-exclusivo-pme/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; BCI Exclusivo PME",
"source_checked_on": "2026-11-10",
"source_status": "identified",
"coverage": "national",
"coverage_text": "Nacional; atendimento conforme rede e canais do banco.",
"territory_ids": [],
"sectors_scope": "unknown",
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
"sector_ids": [],
"resource_ids": [],
"criteria": [],
"windows": [],
"funding_needs": [
"working_capital",
"investment",
"guarantee"
],
"institution_key": "bci",
"institution_nature": "bank",
"contacts": [
{
"source_url": "https://www.bci.co.mz/bci-exclusivo-pme/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; BCI Exclusivo PME",
"checked_on": "2026-11-10",
"kind": "website",
"value": "https://www.bci.co.mz/bci-exclusivo-pme/"
}
],
"locations": [],
"products": [
{
"source_url": "https://www.bci.co.mz/bci-exclusivo-pme/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; BCI Exclusivo PME",
"checked_on": "2026-11-10",
"code": "bci-oferta",
"title": "BCI — Soluções PME",
"instrument": "Conta corrente caucionada, empréstimo de médio/longo prazo, garantias e comércio externo.",
"needs": [
"working_capital",
"investment",
"guarantee"
],
"coverage": "national",
"coverage_text": "Nacional; atendimento conforme rede e canais do banco.",
"territory_ids": [],
"conditions": "ENI, micro, pequenas e médias empresas. Oferta permanente publicada. A antiga “Linha BCI Negócios PME” terminou em 31 de Dezembro de 2018 e fica excluída das oportunidades activas.",
"amounts": "Variável segundo necessidade, produto e análise.",
"availability": "documented",
"contact_url": "https://www.bci.co.mz/bci-exclusivo-pme/"
}
]
}
},
"millennium-bim": {
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
"provenance": [
{
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
"checked_on": "2026-11-10"
},
{
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Financiamento para empresas",
"url": "https://www.millenniumbim.co.mz/pt/empresas/produtos/financiamento",
"checked_on": "2026-11-10"
}
],
"draft": {
"title": "Millennium bim",
"kind": "institution",
"summary": "Conta empréstimo, conta corrente caucionada, factoring, confirming , leasing e garantias. Público/finalidade: Empresas e ENI elegíveis.. Referência informativa, não concessão de financiamento.",
"manager_name": "Millennium bim",
"source_url": "https://www.millenniumbim.co.mz/pt/empresas/produtos/financiamento",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Financiamento para empresas",
"source_checked_on": "2026-11-10",
"source_status": "identified",
"coverage": "national",
"coverage_text": "Nacional; balcões e canais do banco.",
"territory_ids": [],
"sectors_scope": "unknown",
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
"sector_ids": [],
"resource_ids": [],
"criteria": [],
"windows": [],
"funding_needs": [
"working_capital",
"investment",
"guarantee"
],
"institution_key": "millennium-bim",
"institution_nature": "bank",
"contacts": [
{
"source_url": "https://www.millenniumbim.co.mz/pt/empresas/produtos/financiamento",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Financiamento para empresas",
"checked_on": "2026-11-10",
"kind": "website",
"value": "https://www.millenniumbim.co.mz/pt/empresas/produtos/financiamento"
}
],
"locations": [],
"products": [
{
"source_url": "https://www.millenniumbim.co.mz/pt/empresas/produtos/financiamento",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Financiamento para empresas",
"checked_on": "2026-11-10",
"code": "millennium-bim-oferta",
"title": "Millennium bim — Financiamento empresarial",
"instrument": "Conta empréstimo, conta corrente caucionada, factoring, confirming , leasing e garantias.",
"needs": [
"working_capital",
"investment",
"guarantee"
],
"coverage": "national",
"coverage_text": "Nacional; balcões e canais do banco.",
"territory_ids": [],
"conditions": "Empresas e ENI elegíveis. Oferta permanente publicada. Condições finais dependem da análise de risco e contrato.",
"amounts": "Conta empréstimo: investimento entre 3 e 180 meses na ficha publicada; restantes termos após análise.",
"availability": "documented",
"contact_url": "https://www.millenniumbim.co.mz/pt/empresas/produtos/financiamento"
}
]
}
},
"absa": {
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
"provenance": [
{
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
"checked_on": "2026-11-10"
},
{
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Empréstimos comerciais",
"url": "https://www.absa.co.mz/pt/banca-de-negocios/borrow/",
"checked_on": "2026-11-10"
}
],
"draft": {
"title": "Absa",
"kind": "institution",
"summary": "CAPEX , activos, importação, descoberto e leasing. Público/finalidade: PME e empresas com capacidade de endividamento.. Referência informativa, não concessão de financiamento.",
"manager_name": "Absa",
"source_url": "https://www.absa.co.mz/pt/banca-de-negocios/borrow/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Empréstimos comerciais",
"source_checked_on": "2026-11-10",
"source_status": "identified",
"coverage": "national",
"coverage_text": "Nacional; localização da empresa é recolhida no pedido.",
"territory_ids": [],
"sectors_scope": "unknown",
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
"sector_ids": [],
"resource_ids": [],
"criteria": [],
"windows": [],
"funding_needs": [
"working_capital",
"investment"
],
"institution_key": "absa",
"institution_nature": "bank",
"contacts": [
{
"source_url": "https://www.absa.co.mz/pt/banca-de-negocios/borrow/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Empréstimos comerciais",
"checked_on": "2026-11-10",
"kind": "website",
"value": "https://www.absa.co.mz/pt/banca-de-negocios/borrow/"
},
{
"source_url": "https://www.absa.co.mz/pt/banca-de-negocios/borrow/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Empréstimos comerciais",
"checked_on": "2026-11-10",
"kind": "phone",
"value": "+258 21 344 400"
},
{
"source_url": "https://www.absa.co.mz/pt/banca-de-negocios/borrow/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Empréstimos comerciais",
"checked_on": "2026-11-10",
"kind": "email",
"value": "linhacliente@absa.africa"
}
],
"locations": [],
"products": [
{
"source_url": "https://www.absa.co.mz/pt/banca-de-negocios/borrow/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Empréstimos comerciais",
"checked_on": "2026-11-10",
"code": "absa-oferta",
"title": "Absa — Empréstimos PME e activos",
"instrument": "CAPEX , activos, importação, descoberto e leasing.",
"needs": [
"working_capital",
"investment"
],
"coverage": "national",
"coverage_text": "Nacional; localização da empresa é recolhida no pedido.",
"territory_ids": [],
"conditions": "PME e empresas com capacidade de endividamento. Oferta permanente publicada. Critérios, documentos, garantias e ausência de registo desabonatório aplicam-se conforme produto.",
"amounts": "Leasing com prazo publicado até 5 anos; demais condições conforme análise.",
"availability": "documented",
"contact_url": "https://www.absa.co.mz/pt/banca-de-negocios/borrow/"
}
]
}
},
"standard-bank": {
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
"provenance": [
{
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
"checked_on": "2026-11-10"
},
{
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Conta Empréstimo",
"url": "https://www.standardbank.co.mz/Produtos/Empresas/Conta-Emprestimo",
"checked_on": "2026-11-10"
}
],
"draft": {
"title": "Standard Bank",
"kind": "institution",
"summary": "Crédito de médio/longo prazo para bens de capital e CAPEX . Público/finalidade: Empresas com capacidade de endividamento.. Referência informativa, não concessão de financiamento.",
"manager_name": "Standard Bank",
"source_url": "https://www.standardbank.co.mz/Produtos/Empresas/Conta-Emprestimo",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Conta Empréstimo",
"source_checked_on": "2026-11-10",
"source_status": "identified",
"coverage": "national",
"coverage_text": "Nacional; balcões e canais empresariais.",
"territory_ids": [],
"sectors_scope": "unknown",
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
"sector_ids": [],
"resource_ids": [],
"criteria": [],
"windows": [],
"funding_needs": [
"investment"
],
"institution_key": "standard-bank",
"institution_nature": "bank",
"contacts": [
{
"source_url": "https://www.standardbank.co.mz/Produtos/Empresas/Conta-Emprestimo",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Conta Empréstimo",
"checked_on": "2026-11-10",
"kind": "website",
"value": "https://www.standardbank.co.mz/Produtos/Empresas/Conta-Emprestimo"
},
{
"source_url": "https://www.standardbank.co.mz/Produtos/Empresas/Conta-Emprestimo",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Conta Empréstimo",
"checked_on": "2026-11-10",
"kind": "phone",
"value": "+258 21 355700"
}
],
"locations": [],
"products": [
{
"source_url": "https://www.standardbank.co.mz/Produtos/Empresas/Conta-Emprestimo",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Conta Empréstimo",
"checked_on": "2026-11-10",
"code": "standard-bank-oferta",
"title": "Standard Bank — Conta Empréstimo Empresas",
"instrument": "Crédito de médio/longo prazo para bens de capital e CAPEX .",
"needs": [
"investment"
],
"coverage": "national",
"coverage_text": "Nacional; balcões e canais empresariais.",
"territory_ids": [],
"conditions": "Empresas com capacidade de endividamento. Produto activo e publicado. Sujeito a análise e garantias.",
"amounts": "Até 7 anos; limite definido segundo capacidade e investimento; garantias reais.",
"availability": "documented",
"contact_url": "https://www.standardbank.co.mz/Produtos/Empresas/Conta-Emprestimo"
}
]
}
},
"moza": {
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
"provenance": [
{
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
"checked_on": "2026-11-10"
},
{
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Financiamento empresarial",
"url": "https://www.mozabanco.co.mz/pt/empresas/investimento/financiamento/",
"checked_on": "2026-11-10"
}
],
"draft": {
"title": "Moza Banco",
"kind": "institution",
"summary": "Tesouraria, investimento, construção e equipamento. Público/finalidade: Pequenas e médias empresas.. Referência informativa, não concessão de financiamento.",
"manager_name": "Moza Banco",
"source_url": "https://www.mozabanco.co.mz/pt/empresas/investimento/financiamento/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Financiamento empresarial",
"source_checked_on": "2026-11-10",
"source_status": "identified",
"coverage": "national",
"coverage_text": "Nacional; sem restrição territorial indicada no produto geral.",
"territory_ids": [],
"sectors_scope": "unknown",
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
"sector_ids": [],
"resource_ids": [],
"criteria": [],
"windows": [],
"funding_needs": [
"working_capital",
"investment"
],
"institution_key": "moza",
"institution_nature": "bank",
"contacts": [
{
"source_url": "https://www.mozabanco.co.mz/pt/empresas/investimento/financiamento/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Financiamento empresarial",
"checked_on": "2026-11-10",
"kind": "website",
"value": "https://www.mozabanco.co.mz/pt/empresas/investimento/financiamento/"
}
],
"locations": [],
"products": [
{
"source_url": "https://www.mozabanco.co.mz/pt/empresas/investimento/financiamento/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Financiamento empresarial",
"checked_on": "2026-11-10",
"code": "moza-oferta",
"title": "Moza Banco — Crédito PME",
"instrument": "Tesouraria, investimento, construção e equipamento.",
"needs": [
"working_capital",
"investment"
],
"coverage": "national",
"coverage_text": "Nacional; sem restrição territorial indicada no produto geral.",
"territory_ids": [],
"conditions": "Pequenas e médias empresas. Produto apresentado no portal. A ficha contém condições que devem ser reconfirmadas antes da publicação, incluindo montante, comissão e garantias.",
"amounts": "Ficha publicada: 500.000–30.000.000 MT; 6–60 meses; Prime + spread.",
"availability": "unknown",
"contact_url": "https://www.mozabanco.co.mz/pt/empresas/investimento/financiamento/"
}
]
}
},
"bni": {
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
"provenance": [
{
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
"checked_on": "2026-11-10"
},
{
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Banca de Desenvolvimento",
"url": "https://www.bni.co.mz/banca-de-desenvolvimento/",
"checked_on": "2026-11-10"
},
{
"reference": "BNI Capital: veículo do BNI, registado como produto sem duplicar a instituição",
"url": "https://www.bni.co.mz/banca-de-investimento/capital-de-risco/",
"checked_on": "2026-11-10"
}
],
"draft": {
"title": "BNI",
"kind": "institution",
"summary": "Crédito para sectores produtivos, infra-estruturas e projectos com impacto. Público/finalidade: Empresas e projectos de desenvolvimento.. Referência informativa, não concessão de financiamento.",
"manager_name": "BNI",
"source_url": "https://www.bni.co.mz/banca-de-desenvolvimento/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Banca de Desenvolvimento",
"source_checked_on": "2026-11-10",
"source_status": "identified",
"coverage": "national",
"coverage_text": "Nacional ou conforme fundo/linha específica.",
"territory_ids": [],
"sectors_scope": "unknown",
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
"sector_ids": [],
"resource_ids": [],
"criteria": [],
"windows": [],
"funding_needs": [
"investment"
],
"institution_key": "bni",
"institution_nature": "bank",
"contacts": [
{
"source_url": "https://www.bni.co.mz/banca-de-desenvolvimento/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Banca de Desenvolvimento",
"checked_on": "2026-11-10",
"kind": "website",
"value": "https://www.bni.co.mz/banca-de-desenvolvimento/"
},
{
"source_url": "https://www.bni.co.mz/contactos/",
"source_reference": "BNI — Contactos, endereço e telefone",
"checked_on": "2026-11-10",
"kind": "phone",
"value": "+258 21 498 581"
}
],
"locations": [
{
"source_url": "https://www.bni.co.mz/contactos/",
"source_reference": "BNI — Contactos, endereço e telefone",
"checked_on": "2026-11-10",
"role": "contact_point",
"label": "BNI — endereço institucional de Maputo",
"address": "Av. Julius Nyerere, 3504, Bloco A2, 4668 Maputo, Moçambique",
"territory_id": null
}
],
"products": [
{
"source_url": "https://www.bni.co.mz/banca-de-desenvolvimento/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Banca de Desenvolvimento",
"checked_on": "2026-11-10",
"code": "bni-oferta",
"title": "BNI — Crédito de desenvolvimento",
"instrument": "Crédito para sectores produtivos, infra-estruturas e projectos com impacto.",
"needs": [
"investment"
],
"coverage": "national",
"coverage_text": "Nacional ou conforme fundo/linha específica.",
"territory_ids": [],
"conditions": "Empresas e projectos de desenvolvimento. Serviço institucional publicado. Cada linha deve ter o seu próprio estado e regulamento.",
"amounts": "Definido por operação; o simulador oficial é apenas indicativo.",
"availability": "documented",
"contact_url": "https://www.bni.co.mz/banca-de-desenvolvimento/"
},
{
"source_url": "https://www.bni.co.mz/banca-de-investimento/capital-de-risco/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Capital de Risco",
"checked_on": "2026-11-10",
"code": "bni-capital-oferta",
"title": "BNI Capital",
"instrument": "Participação temporária e geralmente minoritária no capital; investimento em fundos.",
"needs": [
"equity"
],
"coverage": "national",
"coverage_text": "Moçambique, conforme tese e avaliação de investimento.",
"territory_ids": [],
"conditions": "Empresas não cotadas com crescimento, impacto, gestão profissional e insuficiência de capital. Serviço institucional publicado. Manifestação e processo dependem de avaliação do BNI .",
"amounts": "Negociado por operação; não é crédito.",
"availability": "documented",
"contact_url": "https://www.bni.co.mz/banca-de-investimento/capital-de-risco/"
}
]
},
"location_territory_names": {
"0": "Maputo Cidade"
}
},
"gapi": {
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
"provenance": [
{
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
"checked_on": "2026-11-10"
},
{
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Portal Gapi",
"url": "https://www.gapi.co.mz/",
"checked_on": "2026-10-04"
}
],
"draft": {
"title": "Gapi",
"kind": "institution",
"summary": "Crédito, garantias, capacitação e apoio empresarial. Público/finalidade: MPME , empresas rurais, agronegócio, jovens e mulheres, conforme programa.. Referência informativa, não concessão de financiamento.",
"manager_name": "Gapi",
"source_url": "https://www.gapi.co.mz/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Portal Gapi",
"source_checked_on": "2026-10-04",
"source_status": "identified",
"coverage": "unknown",
"coverage_text": "Presença nacional, com programas de cobertura territorial específica.",
"territory_ids": [],
"sectors_scope": "unknown",
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
"sector_ids": [],
"resource_ids": [],
"criteria": [],
"windows": [],
"funding_needs": [
"working_capital",
"investment",
"guarantee",
"technical_assistance"
],
"institution_key": "gapi",
"institution_nature": "other",
"contacts": [
{
"source_url": "https://www.gapi.co.mz/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Portal Gapi",
"checked_on": "2026-10-04",
"kind": "website",
"value": "https://www.gapi.co.mz/"
}
],
"locations": [],
"products": [
{
"source_url": "https://www.gapi.co.mz/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Portal Gapi",
"checked_on": "2026-10-04",
"code": "gapi-oferta",
"title": "Gapi — Financiamento e investimento de impacto",
"instrument": "Crédito, garantias, capacitação e apoio empresarial.",
"needs": [
"working_capital",
"investment",
"guarantee",
"technical_assistance"
],
"coverage": "unknown",
"coverage_text": "Presença nacional, com programas de cobertura territorial específica.",
"territory_ids": [],
"conditions": "MPME , empresas rurais, agronegócio, jovens e mulheres, conforme programa. Actividade de financiamento publicada. A Gapi declara não usar intermediários para conceder financiamento.",
"amounts": "Variável por linha; avaliação própria.",
"availability": "unknown",
"contact_url": "https://www.gapi.co.mz/"
}
]
}
},
"fecop": {
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
"provenance": [
{
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
"checked_on": "2026-11-10"
},
{
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Comunicado oficial Millennium bim",
"url": "https://www.millenniumbim.co.mz/media/gusl5uoe/20260624-comunicado-de-imprensa_millennium-bim-reforca-solucoes-de-financiamento-para-as-pme-atraves-do-fecop.pdf",
"checked_on": "2026-11-10"
}
],
"draft": {
"title": "FECOP — crédito por bancos aderentes",
"kind": "fund",
"summary": "Partilha de risco, crédito e assistência técnica. Público/finalidade: MPME e cooperativas de produtores elegíveis.. Referência informativa, não concessão de financiamento. Condições e montantes no quadro de 04/10/2026: Dotação global anunciada de cerca de €17 milhões; condições por banco e operação. Operacionalização confirmada em Junho de 2026. O Millennium bim mantém comunicado oficial; critérios e canal exacto devem ser confirmados em cada banco.",
"manager_name": "Entidades do programa identificadas na fonte",
"source_url": "https://www.millenniumbim.co.mz/media/gusl5uoe/20260624-comunicado-de-imprensa_millennium-bim-reforca-solucoes-de-financiamento-para-as-pme-atraves-do-fecop.pdf",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Comunicado oficial Millennium bim",
"source_checked_on": "2026-11-10",
"source_status": "identified",
"coverage": "national",
"coverage_text": "Moçambique; operacionalizado por Millennium bim , BCI e Vista Bank .",
"territory_ids": [],
"sectors_scope": "unknown",
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
"sector_ids": [],
"resource_ids": [],
"criteria": [],
"windows": [],
"funding_needs": [
"investment",
"working_capital",
"technical_assistance"
]
}
},
"semeia": {
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
"provenance": [
{
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
"checked_on": "2026-11-10"
},
{
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Semeia Negócios",
"url": "https://semeia.gapi.co.mz/",
"checked_on": "2026-11-10"
}
],
"draft": {
"title": "Semeia Negócios — FECOP",
"kind": "programme",
"summary": "Subvenção em espécie, formalização, capacitação e equipamento produtivo. Público/finalidade: Startups , micro e pequenas empresas; foco em jovens e mulheres.. Referência informativa, não concessão de financiamento. Condições e montantes no quadro de 04/10/2026: Até 450.000 MT; até 75% do investimento; subvenção sem juro. Candidaturas encerradas. O portal permite consultar candidaturas já submetidas.",
"manager_name": "Gapi / FECOP",
"source_url": "https://semeia.gapi.co.mz/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Semeia Negócios",
"source_checked_on": "2026-11-10",
"source_status": "identified",
"coverage": "national",
"coverage_text": "Moçambique; província e distrito constam da candidatura.",
"territory_ids": [],
"sectors_scope": "unknown",
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
"sector_ids": [],
"resource_ids": [],
"criteria": [],
"windows": [
{
"code": "quadro-20261004",
"title": "Chamada indicada no quadro fornecido — encerrada",
"mode": "closed",
"coverage": "programme",
"territory_ids": [],
"source_url": "https://semeia.gapi.co.mz/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; Semeia Negócios",
"checked_on": "2026-11-10",
"application_url": "https://semeia.gapi.co.mz/",
"criteria": []
}
],
"funding_needs": [
"investment",
"technical_assistance"
]
}
},
"fcid-viii": {
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
"provenance": [
{
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
"checked_on": "2026-11-10"
},
{
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; VIII Edição FCID",
"url": "https://fundocatalitico.gov.mz/viii-edicao-fcid/",
"checked_on": "2026-11-10"
}
],
"draft": {
"title": "FCID VIII — Jobs Now",
"kind": "fund",
"summary": "Subvenção comparticipada para expansão produtiva, cadeias de valor e emprego. Público/finalidade: Pequenas e médias empresas e grandes empresas âncora formalizadas.. Referência informativa, não concessão de financiamento. Condições e montantes no quadro de 04/10/2026: Pequenas: USD 50.000–150.000; médias: USD 151.000–250.000; âncora: USD 251.000–500.000. Activo na data da verificação, com prazo iminente. O portal oficial mostrava candidaturas abertas e contagem decrescente; exige validação diária até ao encerramento.",
"manager_name": "Entidades do programa identificadas na fonte",
"source_url": "https://fundocatalitico.gov.mz/viii-edicao-fcid/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; VIII Edição FCID",
"source_checked_on": "2026-11-10",
"source_status": "identified",
"coverage": "territorial",
"coverage_text": "Províncias de Maputo, Gaza, Inhambane, Manica, Sofala e Zambézia. Não pressupõe cobertura nacional. Disponibilidade actual da janela por confirmar; a página indica candidaturas sem prazo final inequívoco.",
"territory_ids": [],
"sectors_scope": "unknown",
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
"sector_ids": [],
"resource_ids": [],
"criteria": [],
"windows": [],
"funding_needs": [
"investment"
]
},
"territory_names": [
"Maputo Província",
"Gaza",
"Inhambane",
"Manica",
"Sofala",
"Zambezia"
]
},
"fae": {
"edition": "tempo-benchmark-20261004-directory-20261110-v1",
"provenance": [
{
"reference": "benchmark de melhorias investeMoz.docx; quadro comparativo 04/10/2026",
"sha256": "065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617",
"checked_on": "2026-11-10"
},
{
"reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; FAE",
"url": "https://www.bni.co.mz/banca-de-desenvolvimento/fae/",
"checked_on": "2026-11-10"
}
],
"draft": {
"title": "FAE — BNI /Agência do Zambeze",
"kind": "programme",
"summary": "Crédito em desembolso único, tranches ou espécie para agronegócio e empreendedorismo. Público/finalidade: Recém-graduados, MPME , cooperativas e associações lucrativas elegíveis.. Referência informativa, não concessão de financiamento. Condições e montantes no quadro de 04/10/2026: Segundo necessidade real do ciclo de produção e comercialização. Linha e processo publicados. A página não indica uma janela corrente com prazo; marcar “confirmação necessária” antes de aceitar candidatura.",
"manager_name": "BNI / Agência do Zambeze",
"source_url": "https://www.bni.co.mz/banca-de-desenvolvimento/fae/",
"source_reference": "Fonte institucional indicada no quadro Tempo de 04/10/2026; FAE",
"source_checked_on": "2026-11-10",
"source_status": "identified",
"coverage": "unknown",
"coverage_text": "Vale do Zambeze; projectos e actividades elegíveis na região.",
"territory_ids": [],
"sectors_scope": "unknown",
"sectors_text": "Critérios sectoriais por conferir no produto/programa; nenhuma elegibilidade automática.",
"sector_ids": [],
"resource_ids": [],
"criteria": [],
"windows": [],
"funding_needs": [
"investment",
"working_capital"
]
}
}
}
}
@@ -0,0 +1,744 @@
{
"format": "investmoz-mining-reference-v1",
"source_sha256": "7202fb5986b40e62347867c653770030683ffec2701a2921e84342f15e9f3d8a",
"source_document_name": "Minérios de Moçambique e a sua localização.docx",
"reference_date": "2026-11-11",
"title": "Mineração e cadeias de valor em Moçambique",
"minerals": [
{
"id": "MINERAL-01",
"name": "Carvão (térmico e metalúrgico)",
"locations_text": "Tete (Moatize, Changara, Mutarara, Marávia, Zumbo); ocorrências em Niassa (Lago) e Sofala",
"province_names": [
"Niassa",
"Sofala",
"Tete"
],
"source_row": "table1:row1",
"coordinates": null,
"classification": "reported_occurrence_group"
},
{
"id": "MINERAL-02",
"name": "Gás natural e petróleo",
"locations_text": "Inhambane (Pande e Temane); Cabo Delgado (Palma, Mocímboa, bacia do Rovuma); Sofala (Búzi); pesquisa na baía do Bazaruto",
"province_names": [
"Cabo Delgado",
"Inhambane",
"Sofala"
],
"source_row": "table1:row2",
"coordinates": null,
"classification": "reported_occurrence_group"
},
{
"id": "MINERAL-03",
"name": "Areias pesadas (ilmenite, zircão, rútilo, monazite)",
"locations_text": "Nampula (Moma, Angoche); Zambézia (Pebane, Chinde, Moebase); Inhambane (Jangamo); Gaza (Chibuto, Xai-Xai); Maputo (Ponta do Ouro, Inhaca)",
"province_names": [
"Gaza",
"Inhambane",
"Maputo",
"Nampula",
"Zambézia"
],
"source_row": "table1:row3",
"coordinates": null,
"classification": "reported_occurrence_group"
},
{
"id": "MINERAL-04",
"name": "Grafite",
"locations_text": "Cabo Delgado (Balama, Ancuabe); Niassa (Nipepe); Tete (Angónia)",
"province_names": [
"Cabo Delgado",
"Niassa",
"Tete"
],
"source_row": "table1:row4",
"coordinates": null,
"classification": "reported_occurrence_group"
},
{
"id": "MINERAL-05",
"name": "Terras raras, fluorite e gálio",
"locations_text": "Tete (Monte Muambe); Sofala (ocorrências aluvionares preliminares)",
"province_names": [
"Sofala",
"Tete"
],
"source_row": "table1:row5",
"coordinates": null,
"classification": "reported_occurrence_group"
},
{
"id": "MINERAL-06",
"name": "Ouro",
"locations_text": "Manica; Zambézia; Nampula; Tete; Niassa; ouro aluvial em rios como Revuè e Chua",
"province_names": [
"Manica",
"Nampula",
"Niassa",
"Tete",
"Zambézia"
],
"source_row": "table1:row6",
"coordinates": null,
"classification": "reported_occurrence_group"
},
{
"id": "MINERAL-07",
"name": "Ferro, titânio, vanádio e manganês",
"locations_text": "Manica, Tete (Chiúta), Nampula",
"province_names": [
"Manica",
"Nampula",
"Tete"
],
"source_row": "table1:row7",
"coordinates": null,
"classification": "reported_occurrence_group"
},
{
"id": "MINERAL-08",
"name": "Cobre, níquel, zinco e bauxite",
"locations_text": "Tete (Chidué, Fíngoè), Manica, Nampula (Monapo, Murrupula), Zambézia, Niassa",
"province_names": [
"Manica",
"Nampula",
"Niassa",
"Tete",
"Zambézia"
],
"source_row": "table1:row8",
"coordinates": null,
"classification": "reported_occurrence_group"
},
{
"id": "MINERAL-09",
"name": "Tantalite, lítio e outros metais raros",
"locations_text": "Zambézia (Ile, Alto Molócue); Nampula; Zambézia e Cabo Delgado em pegmatitos",
"province_names": [
"Cabo Delgado",
"Nampula",
"Zambézia"
],
"source_row": "table1:row9",
"coordinates": null,
"classification": "reported_occurrence_group"
},
{
"id": "MINERAL-10",
"name": "Pedras preciosas e semipreciosas",
"locations_text": "Cabo Delgado (rubis em Montepuez); Zambézia, Nampula, Manica, Tete (turmalina, água-marinha, granada, esmeralda)",
"province_names": [
"Cabo Delgado",
"Manica",
"Nampula",
"Tete",
"Zambézia"
],
"source_row": "table1:row10",
"coordinates": null,
"classification": "reported_occurrence_group"
},
{
"id": "MINERAL-11",
"name": "Mármore, apatite, calcário, argilas e diatomito",
"locations_text": "Cabo Delgado (mármore, Montepuez); Nampula (apatite); Sofala, Inhambane e Maputo (calcário); Maputo e Gaza (diatomito)",
"province_names": [
"Cabo Delgado",
"Gaza",
"Inhambane",
"Maputo",
"Nampula",
"Sofala"
],
"source_row": "table1:row11",
"coordinates": null,
"classification": "reported_occurrence_group"
}
],
"areas": [
{
"id": "MIN-01",
"title": "Carvão: fornecimento de bens e serviços e logística",
"mineral_ids": [
"MINERAL-01"
],
"locations_text": "Tete (Moatize, Revobue); corredores de Nacala e Sena (Beira)",
"province_names": [
"Tete"
],
"market_evidence": "Produção nacional de 16,3 Mt em 2024, com previsão de mais de 22 Mt em 2026; Revobue prevê 3,5 Mt /ano, subindo para 7 Mt /ano a partir de 2032",
"value_chain": "Conteúdo local, serviços à mina, manutenção ferroviária e portuária (indicativo)",
"source_state": "A estruturar",
"indicative_location": "Moatize (Tete); porto de Nacala-a-Velha e cidade da Beira, nos corredores logísticos. Distrito do Revobue não indicado nas fontes. Indicação não oficial, a partir das fontes citadas; sem valor de georreferenciação.",
"source_fields": {
"ID InvesteMoz": "[a atribuir]",
"Título": "MIN-01: Carvão: fornecimento de bens e serviços e logística",
"Sector / subsector": "Energia e minas / carvão e logística",
"Localização": "Tete (Moatize, Revobue); corredores de Nacala e Sena (Beira)",
"Localização indicativa (distrito, não oficial)": "Moatize (Tete); porto de Nacala-a-Velha e cidade da Beira, nos corredores logísticos. Distrito do Revobue não indicado nas fontes. Indicação não oficial, a partir das fontes citadas; sem valor de georreferenciação.",
"Georreferenciação": "[a confirmar]",
"Evidência de mercado": "Produção nacional de 16,3 Mt em 2024, com previsão de mais de 22 Mt em 2026; Revobue prevê 3,5 Mt /ano, subindo para 7 Mt /ano a partir de 2032.",
"Modelo possível": "Conteúdo local, serviços à mina, manutenção ferroviária e portuária (indicativo)",
"Promotor e ponto focal": "[a confirmar]",
"DUAT ou direito de uso": "[a confirmar]",
"Estudos de viabilidade e técnicos": "[a confirmar]",
"Licenças e autorizações": "[a confirmar]",
"Modelo contratual": "[a confirmar]",
"CAPEX (com data-base)": "[a confirmar]",
"Estado": "A estruturar",
"": ""
},
"source_row": "table3:row1",
"coordinates": null,
"availability": "reference_only",
"promotion_ready": false
},
{
"id": "MIN-02",
"title": "Grafite: cadeia de valor para baterias",
"mineral_ids": [
"MINERAL-04"
],
"locations_text": "Cabo Delgado (Balama, Ancuabe); Niassa (Nipepe)",
"province_names": [
"Cabo Delgado",
"Niassa"
],
"market_evidence": "4.º produtor mundial em 2024; Balama com capacidade nominal de 350 000 t/ano; produção caiu 64% em 2024",
"value_chain": "Beneficiamento e parcerias com operadores (indicativo)",
"source_state": "A estruturar",
"indicative_location": "Balama e Ancuabe (Cabo Delgado); Nipepe (Niassa). Indicação não oficial, a partir das fontes citadas; sem valor de georreferenciação.",
"source_fields": {
"ID InvesteMoz": "[a atribuir]",
"Título": "MIN-02: Grafite: cadeia de valor para baterias",
"Sector / subsector": "Minas / grafite e materiais para baterias",
"Localização": "Cabo Delgado (Balama, Ancuabe); Niassa (Nipepe)",
"Localização indicativa (distrito, não oficial)": "Balama e Ancuabe (Cabo Delgado); Nipepe (Niassa). Indicação não oficial, a partir das fontes citadas; sem valor de georreferenciação.",
"Georreferenciação": "[a confirmar]",
"Evidência de mercado": "4.º produtor mundial em 2024; Balama com capacidade nominal de 350 000 t/ano; produção caiu 64% em 2024.",
"Modelo possível": "Beneficiamento e parcerias com operadores (indicativo)",
"Promotor e ponto focal": "[a confirmar]",
"DUAT ou direito de uso": "[a confirmar]",
"Estudos de viabilidade e técnicos": "[a confirmar]",
"Licenças e autorizações": "[a confirmar]",
"Modelo contratual": "[a confirmar]",
"CAPEX (com data-base)": "[a confirmar]",
"Estado": "A estruturar",
"Fonte e data": "Fontes públicas: USGS; Diário Económico. Actualizado em 11/11/2026."
},
"source_row": "table3:row2",
"coordinates": null,
"availability": "reference_only",
"promotion_ready": false
},
{
"id": "MIN-03",
"title": "Areias pesadas e terras raras: processamento local",
"mineral_ids": [
"MINERAL-03",
"MINERAL-05"
],
"locations_text": "Nampula (Moma); Tete (Monte Muambe); Sofala (fase preliminar)",
"province_names": [
"Nampula",
"Sofala",
"Tete"
],
"market_evidence": "Moçambique é o 2.º produtor mundial de ilmenite; a monazite de Moma é exportada como concentrado, sem separação no país; Monte Muambe tem recurso de 13,6 Mt com 2,42% de óxidos de terras raras",
"value_chain": "Separação e refinação de terras raras, parcerias com operadores (indicativo)",
"source_state": "A estruturar",
"indicative_location": "Moma (Nampula); Monte Muambe (Tete). Distrito de Monte Muambe e localização em Sofala não indicados nas fontes. Indicação não oficial, a partir das fontes citadas; sem valor de georreferenciação.",
"source_fields": {
"ID InvesteMoz": "[a atribuir]",
"Título": "MIN-03: Areias pesadas e terras raras: processamento local",
"Sector / subsector": "Minas / areias pesadas e terras raras",
"Localização": "Nampula (Moma); Tete (Monte Muambe); Sofala (fase preliminar)",
"Localização indicativa (distrito, não oficial)": "Moma (Nampula); Monte Muambe (Tete). Distrito de Monte Muambe e localização em Sofala não indicados nas fontes. Indicação não oficial, a partir das fontes citadas; sem valor de georreferenciação.",
"Georreferenciação": "[a confirmar]",
"Evidência de mercado": "2.º produtor mundial de ilmenite em 2024; a monazite de Moma é exportada como concentrado, sem separação no país; Monte Muambe tem recurso de 13,6 Mt com 2,42% de óxidos totais de terras raras.",
"Modelo possível": "Separação e refinação de terras raras, parcerias com operadores (indicativo)",
"Promotor e ponto focal": "[a confirmar]",
"DUAT ou direito de uso": "[a confirmar]",
"Estudos de viabilidade e técnicos": "[a confirmar]",
"Licenças e autorizações": "[a confirmar]",
"Modelo contratual": "[a confirmar]",
"CAPEX (com data-base)": "[a confirmar]",
"Estado": "A estruturar",
"": ""
},
"source_row": "table3:row3",
"coordinates": null,
"availability": "reference_only",
"promotion_ready": false
},
{
"id": "MIN-04",
"title": "Gás natural: GNL e energia",
"mineral_ids": [
"MINERAL-02"
],
"locations_text": "Inhambane (Pande e Temane); Cabo Delgado (Coral Sul FLNG); Maputo (terminal GNL da Matola)",
"province_names": [
"Cabo Delgado",
"Inhambane",
"Maputo"
],
"market_evidence": "Pande e Temane em declínio, com fim da extracção previsto para 2030; terminal da Matola visa fecho financeiro até ao fim de 2027; Coral Sul com 3,4 Mtpa",
"value_chain": "Infra-estrutura de GNL, geração de electricidade, concessão ou parceria (indicativo)",
"source_state": "A estruturar",
"indicative_location": "Inhassoro (Inhambane, para Pande e Temane); Palma e Mocímboa (Cabo Delgado, para o gás da bacia do Rovuma); Matola (Maputo, para o terminal de GNL). Distrito do Coral Sul FLNG não indicado nas fontes. Indicação não oficial, a partir das fontes citadas; sem valor de georreferenciação.",
"source_fields": {
"ID InvesteMoz": "[a atribuir]",
"Título": "MIN-04: Gás natural: GNL e energia",
"Sector / subsector": "Energia / gás natural e GNL",
"Localização": "Inhambane (Pande e Temane); Cabo Delgado (Coral Sul FLNG); Maputo (terminal GNL da Matola)",
"Localização indicativa (distrito, não oficial)": "Inhassoro (Inhambane, para Pande e Temane); Palma e Mocímboa (Cabo Delgado, para o gás da bacia do Rovuma); Matola (Maputo, para o terminal de GNL). Distrito do Coral Sul FLNG não indicado nas fontes. Indicação não oficial, a partir das fontes citadas; sem valor de georreferenciação.",
"Georreferenciação": "[a confirmar]",
"Evidência de mercado": "Pande e Temane em declínio, com fim da extracção previsto para 2030; terminal da Matola visa fecho financeiro até ao fim de 2027; Coral Sul com capacidade de 3,4 Mtpa .",
"Modelo possível": "Infra-estrutura de GNL, geração de electricidade, concessão ou parceria (indicativo)",
"Promotor e ponto focal": "[a confirmar]",
"DUAT ou direito de uso": "[a confirmar]",
"Estudos de viabilidade e técnicos": "[a confirmar]",
"Licenças e autorizações": "[a confirmar]",
"Modelo contratual": "[a confirmar]",
"CAPEX (com data-base)": "[a confirmar]",
"Estado": "A estruturar",
"": ""
},
"source_row": "table3:row4",
"coordinates": null,
"availability": "reference_only",
"promotion_ready": false
},
{
"id": "MIN-05",
"title": "Gemas: rubis e pedras semipreciosas",
"mineral_ids": [
"MINERAL-10"
],
"locations_text": "Cabo Delgado (Montepuez); Zambézia, Nampula, Manica, Tete",
"province_names": [
"Cabo Delgado",
"Manica",
"Nampula",
"Tete",
"Zambézia"
],
"market_evidence": "Produção de rubi de 5,10 milhões de quilates em 2025 (mais 29%) e 3,18 milhões no primeiro semestre de 2026; cerca de 70% é exportada, sobretudo para os Emirados Árabes Unidos (Governo; USGS).",
"value_chain": "Lapidação, certificação e comercialização (indicativo)",
"source_state": "A estruturar",
"indicative_location": "Montepuez (Cabo Delgado). Distritos das restantes ocorrências não indicados nas fontes. Indicação não oficial, a partir das fontes citadas; sem valor de georreferenciação.",
"source_fields": {
"ID InvesteMoz": "[a atribuir]",
"Título": "MIN-05: Gemas: rubis e pedras semipreciosas",
"Sector / subsector": "Minas / gemas",
"Localização": "Cabo Delgado (Montepuez); Zambézia, Nampula, Manica, Tete",
"Localização indicativa (distrito, não oficial)": "Montepuez (Cabo Delgado). Distritos das restantes ocorrências não indicados nas fontes. Indicação não oficial, a partir das fontes citadas; sem valor de georreferenciação.",
"Georreferenciação": "[a confirmar]",
"Evidência de mercado": "Produção de rubi segundo o Ministério dos Recursos Minerais e Energia: 3,95 milhões de quilates em 2023, 4,14 em 2024 e 5,10 em 2025 (aumento de 29%; 123% da meta). Em 2026, 3,18 milhões de quilates no primeiro semestre (mais 61%), cerca de 78% da meta anual de 4,06 milhões; cerca de 70% da produção é exportada. Outra fonte indica valores mais baixos para 2023 (2,71 milhões) e 2024 (3,95 milhões), por isso os valores de anos anteriores podem ter sido revistos. Não encontrei dados de produção das outras gemas. O Montepuez Ruby Mining (Gemfields 75%, Mwiriti 25%) exportou 1,3 milhões de quilates em 2023, com receitas de leilão de 151,3 milhões de dólares nesse ano e de pelo menos 49,9 milhões de dólares em 2025. O USGS indica que os rubis são exportados sobretudo para os Emirados Árabes Unidos.",
"Modelo possível": "Lapidação, certificação e comercialização (indicativo)",
"Promotor e ponto focal": "[a confirmar]",
"DUAT ou direito de uso": "[a confirmar]",
"Estudos de viabilidade e técnicos": "[a confirmar]",
"Licenças e autorizações": "[a confirmar]",
"Modelo contratual": "[a confirmar]",
"CAPEX (com data-base)": "[a confirmar]",
"Estado": "A estruturar",
"": ""
},
"source_row": "table3:row5",
"coordinates": null,
"availability": "reference_only",
"promotion_ready": false
},
{
"id": "MIN-06",
"title": "Minerais industriais e materiais de construção",
"mineral_ids": [
"MINERAL-11"
],
"locations_text": "Cabo Delgado (mármore); Nampula (apatite); Sofala, Inhambane e Maputo (calcário); Maputo e Gaza (diatomito)",
"province_names": [
"Cabo Delgado",
"Gaza",
"Inhambane",
"Maputo",
"Nampula",
"Sofala"
],
"market_evidence": "Ocorrências identificadas; sem dados de produção confirmados neste documento",
"value_chain": "Cimento, cerâmica, agricultura (indicativo)",
"source_state": "A estruturar",
"indicative_location": "Montepuez (Cabo Delgado, mármore); Manhiça, Boane, Macia e Manjacaze (Maputo e Gaza, diatomito). Distritos das restantes ocorrências não indicados nas fontes. Indicação não oficial, a partir das fontes citadas; sem valor de georreferenciação.",
"source_fields": {
"ID InvesteMoz": "[a atribuir]",
"Título": "MIN-06: Minerais industriais e materiais de construção",
"Sector / subsector": "Minas / minerais industriais",
"Localização": "Cabo Delgado (mármore); Nampula (apatite); Sofala, Inhambane e Maputo (calcário); Maputo e Gaza (diatomito)",
"Localização indicativa (distrito, não oficial)": "Montepuez (Cabo Delgado, mármore); Manhiça, Boane, Macia e Manjacaze (Maputo e Gaza, diatomito). Distritos das restantes ocorrências não indicados nas fontes. Indicação não oficial, a partir das fontes citadas; sem valor de georreferenciação.",
"Georreferenciação": "[a confirmar]",
"Evidência de mercado": "Ocorrências identificadas; sem dados de produção confirmados neste documento.",
"Modelo possível": "Cimento, cerâmica, agricultura (indicativo)",
"Promotor e ponto focal": "[a confirmar]",
"DUAT ou direito de uso": "[a confirmar]",
"Estudos de viabilidade e técnicos": "[a confirmar]",
"Licenças e autorizações": "[a confirmar]",
"Modelo contratual": "[a confirmar]",
"CAPEX (com data-base)": "[a confirmar]",
"Estado": "A estruturar",
"": ""
},
"source_row": "table3:row6",
"coordinates": null,
"availability": "reference_only",
"promotion_ready": false
},
{
"id": "MIN-07",
"title": "Ouro: exploração e comercialização",
"mineral_ids": [
"MINERAL-06"
],
"locations_text": "Manica; Zambézia; Nampula; Tete; Niassa; ouro aluvial em rios como Revuè e Chua",
"province_names": [
"Manica",
"Nampula",
"Niassa",
"Tete",
"Zambézia"
],
"market_evidence": "Produção nacional de 1 349 kg em 2025 (queda de 17%) e meta de 1 723 kg para 2026, que seria recorde; Manica é a província que mais contribui (Governo).",
"value_chain": "Parcerias com operadores e mineração artesanal organizada (indicativo)",
"source_state": "A estruturar",
"indicative_location": "Rios Revuè, Inhamucarara, Musa, Chimesi, Mimosa e Chua (sem distritos indicados nas fontes). Indicação não oficial, a partir das fontes citadas; sem valor de georreferenciação.",
"source_fields": {
"ID InvesteMoz": "[a atribuir]",
"Título": "MIN-07: Ouro: exploração e comercialização",
"Sector / subsector": "Minas / ouro",
"Localização": "Manica; Zambézia; Nampula; Tete; Niassa; ouro aluvial em rios como Revuè e Chua",
"Localização indicativa (distrito, não oficial)": "Rios Revuè, Inhamucarara, Musa, Chimesi, Mimosa e Chua (sem distritos indicados nas fontes). Indicação não oficial, a partir das fontes citadas; sem valor de georreferenciação.",
"Georreferenciação": "[a confirmar]",
"Evidência de mercado": "Produção nacional segundo o Governo: 1 263 kg em 2022; 1 641 kg em 2024 (recorde); 1 349 kg em 2025 (queda de 17%, por suspensão da mineração em Manica); meta de 1 723 kg para 2026, com 713 kg no primeiro semestre (queda de 12%). O USGS (via CEIC) regista 1 666 kg em 2023 e 1 626 kg em 2024, valor ligeiramente diferente do do Governo para 2024. Manica é a província que mais contribui para a produção; em Setembro de 2025 o Governo suspendeu a mineração em Manica, e 30 das 41 empresas suspensas foram autorizadas a retomar em Agosto de 2026.",
"Modelo possível": "Parcerias com operadores e mineração artesanal organizada (indicativo)",
"Promotor e ponto focal": "[a confirmar]",
"DUAT ou direito de uso": "[a confirmar]",
"Estudos de viabilidade e técnicos": "[a confirmar]",
"Licenças e autorizações": "[a confirmar]",
"Modelo contratual": "[a confirmar]",
"CAPEX (com data-base)": "[a confirmar]",
"Estado": "A estruturar",
"": ""
},
"source_row": "table3:row7",
"coordinates": null,
"availability": "reference_only",
"promotion_ready": false
},
{
"id": "MIN-08",
"title": "Ferro, titânio, vanádio e manganês",
"mineral_ids": [
"MINERAL-07"
],
"locations_text": "Manica, Tete (Chiúta), Nampula",
"province_names": [
"Manica",
"Nampula",
"Tete"
],
"market_evidence": "Ocorrências identificadas; titânio associado às areias pesadas (concentrados de titânio cresceram 4% em 2024, USGS); sem dados de produção de ferro, vanádio e manganês.",
"value_chain": "Exploração e parcerias com operadores (indicativo)",
"source_state": "A estruturar",
"indicative_location": "Chiúta (Tete). Distritos de Manica e Nampula não indicados nas fontes. Indicação não oficial, a partir das fontes citadas; sem valor de georreferenciação.",
"source_fields": {
"ID InvesteMoz": "[a atribuir]",
"Título": "MIN-08: Ferro, titânio, vanádio e manganês",
"Sector / subsector": "Minas / metais ferrosos",
"Localização": "Manica, Tete (Chiúta), Nampula",
"Localização indicativa (distrito, não oficial)": "Chiúta (Tete). Distritos de Manica e Nampula não indicados nas fontes. Indicação não oficial, a partir das fontes citadas; sem valor de georreferenciação.",
"Georreferenciação": "[a confirmar]",
"Evidência de mercado": "Ocorrências identificadas; titânio associado às areias pesadas (concentrados de titânio cresceram 4% em 2024, USGS); sem dados de produção de ferro, vanádio e manganês.",
"Modelo possível": "Exploração e parcerias com operadores (indicativo)",
"Promotor e ponto focal": "[a confirmar]",
"DUAT ou direito de uso": "[a confirmar]",
"Estudos de viabilidade e técnicos": "[a confirmar]",
"Licenças e autorizações": "[a confirmar]",
"Modelo contratual": "[a confirmar]",
"CAPEX (com data-base)": "[a confirmar]",
"Estado": "A estruturar",
"": ""
},
"source_row": "table3:row8",
"coordinates": null,
"availability": "reference_only",
"promotion_ready": false
},
{
"id": "MIN-09",
"title": "Cobre, níquel, zinco e bauxite",
"mineral_ids": [
"MINERAL-08"
],
"locations_text": "Tete (Chidué, Fíngoè), Manica, Nampula (Monapo, Murrupula), Zambézia, Niassa",
"province_names": [
"Manica",
"Nampula",
"Niassa",
"Tete",
"Zambézia"
],
"market_evidence": "Ocorrências de metais básicos associados a ouro, vanádio e prata; USGS regista bauxite e alumínio entre os produtos do país, com alumínio exportado para Singapura; sem dados de produção de cobre, níquel e zinco.",
"value_chain": "Pesquisa e exploração, parcerias com operadores (indicativo)",
"source_state": "A estruturar",
"indicative_location": "Chidué e Fíngoè (Tete); Monapo e Murrupula (Nampula). Indicação não oficial, a partir das fontes citadas; sem valor de georreferenciação.",
"source_fields": {
"ID InvesteMoz": "[a atribuir]",
"Título": "MIN-09: Cobre, níquel, zinco e bauxite",
"Sector / subsector": "Minas / metais básicos e bauxite",
"Localização": "Tete (Chidué, Fíngoè), Manica, Nampula (Monapo, Murrupula), Zambézia, Niassa",
"Localização indicativa (distrito, não oficial)": "Chidué e Fíngoè (Tete); Monapo e Murrupula (Nampula). Indicação não oficial, a partir das fontes citadas; sem valor de georreferenciação.",
"Georreferenciação": "[a confirmar]",
"Evidência de mercado": "Ocorrências de metais básicos associados a ouro, vanádio e prata; USGS regista bauxite e alumínio entre os produtos do país, com alumínio exportado para Singapura; sem dados de produção de cobre, níquel e zinco.",
"Modelo possível": "Pesquisa e exploração, parcerias com operadores (indicativo)",
"Promotor e ponto focal": "[a confirmar]",
"DUAT ou direito de uso": "[a confirmar]",
"Estudos de viabilidade e técnicos": "[a confirmar]",
"Licenças e autorizações": "[a confirmar]",
"Modelo contratual": "[a confirmar]",
"CAPEX (com data-base)": "[a confirmar]",
"Estado": "A estruturar",
"": ""
},
"source_row": "table3:row9",
"coordinates": null,
"availability": "reference_only",
"promotion_ready": false
},
{
"id": "MIN-10",
"title": "Tantalite, lítio e outros metais raros",
"mineral_ids": [
"MINERAL-09"
],
"locations_text": "Zambézia (Ile, Alto Molócue); Nampula; Zambézia e Cabo Delgado em pegmatitos",
"province_names": [
"Cabo Delgado",
"Nampula",
"Zambézia"
],
"market_evidence": "6.º produtor mundial de tântalo em 2024 (USGS); depósitos de tantalite subexplorados na Zambézia; lítio em pegmatitos; o Diário Económico refere que a Britannica indica que Moçambique poderá ter as maiores reservas mundiais de tantalite.",
"value_chain": "Pesquisa, exploração e beneficiamento, parcerias com operadores (indicativo)",
"source_state": "A estruturar",
"indicative_location": "Ile e Alto Molócue (Zambézia). Indicação não oficial, a partir das fontes citadas; sem valor de georreferenciação.",
"source_fields": {
"ID InvesteMoz": "[a atribuir]",
"Título": "MIN-10: Tantalite, lítio e outros metais raros",
"Sector / subsector": "Minas / metais raros e críticos",
"Localização": "Zambézia (Ile, Alto Molócue); Nampula; Zambézia e Cabo Delgado em pegmatitos",
"Localização indicativa (distrito, não oficial)": "Ile e Alto Molócue (Zambézia). Indicação não oficial, a partir das fontes citadas; sem valor de georreferenciação.",
"Georreferenciação": "[a confirmar]",
"Evidência de mercado": "6.º produtor mundial de tântalo em 2024 (USGS); depósitos de tantalite subexplorados na Zambézia; lítio em pegmatitos; o Diário Económico refere que a Britannica indica que Moçambique poderá ter as maiores reservas mundiais de tantalite.",
"Modelo possível": "Pesquisa, exploração e beneficiamento, parcerias com operadores (indicativo)",
"Promotor e ponto focal": "[a confirmar]",
"DUAT ou direito de uso": "[a confirmar]",
"Estudos de viabilidade e técnicos": "[a confirmar]",
"Licenças e autorizações": "[a confirmar]",
"Modelo contratual": "[a confirmar]",
"CAPEX (com data-base)": "[a confirmar]",
"Estado": "A estruturar",
"": ""
},
"source_row": "table3:row10",
"coordinates": null,
"availability": "reference_only",
"promotion_ready": false
}
],
"indicators": [
{
"mineral": "Ilmenite (areias pesadas)",
"value": "2.º produtor mundial em 2024, cerca de 21% da produção global; Moma tem cerca de 200 milhões de toneladas de ilmenite em recursos",
"source": "USGS (2024); Diário Económico",
"source_row": "table2:row1"
},
{
"mineral": "Zircão",
"value": "3.º produtor mundial em 2024, cerca de 10% da produção global",
"source": "USGS (2024)",
"source_row": "table2:row2"
},
{
"mineral": "Concentrados de titânio",
"value": "Produção aumentou 4% em 2024",
"source": "USGS (2024)",
"source_row": "table2:row3"
},
{
"mineral": "Grafite",
"value": "4.º produtor mundial em 2024 (cerca de 3%); produção caiu 64% após interrupções em Ancuabe e Balama; Balama tem capacidade nominal de 350 000 t/ano",
"source": "USGS (2024); Diário Económico",
"source_row": "table2:row4"
},
{
"mineral": "Berílio e tântalo",
"value": "5.º produtor mundial de berílio (1%) e 6.º de tântalo (2%) em 2024",
"source": "USGS (2024)",
"source_row": "table2:row5"
},
{
"mineral": "Terras raras (Monte Muambe)",
"value": "Recurso de 13,6 milhões de toneladas com 2,42% de óxidos totais de terras raras (código JORC)",
"source": "Diário Económico",
"source_row": "table2:row6"
},
{
"mineral": "Produção mineral total",
"value": "27,3 milhões de toneladas em 2023, contra 20,9 milhões em 2022",
"source": "CEIC (2023)",
"source_row": "table2:row7"
},
{
"mineral": "Carvão (Tete)",
"value": "Produção nacional, concentrada em Tete: 14,8 milhões de toneladas em 2022, 14,9 em 2023 e 16,3 em 2024; previsão do Governo de 19 milhões em 2025 e mais de 22 milhões em 2026. A mina de Moatize (Vulcan) é a maior; Revobue prevê 3,5 milhões de t/ano na fase inicial",
"source": "Club of Mozambique / Africa-Press (PESOE 2026); Without Borders Energy",
"source_row": "table2:row8"
},
{
"mineral": "Gás natural (Pande e Temane)",
"value": "Produção acumulada de 79,48 mil milhões de m³ até Outubro de 2025; reservas provadas da concessão PPA de 327,9 mil milhões de pés cúbicos em Junho de 2026 (440,6 em Junho de 2025); a produção total da Sasol em Moçambique caiu 7% no exercício de 2026; fim da extracção previsto para 2030",
"source": "Club of Mozambique (Lusa); MZ News (Sasol 20-F)",
"source_row": "table2:row9"
},
{
"mineral": "Gás natural (Coral Sul FLNG)",
"value": "Capacidade de liquefacção de cerca de 3,4 milhões de toneladas por ano (Bacia do Rovuma, Cabo Delgado)",
"source": "Eni",
"source_row": "table2:row10"
},
{
"mineral": "Ouro",
"value": "Produção nacional segundo o Governo: 1 263 kg em 2022; 1 641 kg em 2024 (recorde); 1 349 kg em 2025 (queda de 17%, por suspensão da mineração em Manica); meta de 1 723 kg para 2026, com 713 kg no primeiro semestre (queda de 12%). O USGS (via CEIC) regista 1 666 kg em 2023 e 1 626 kg em 2024, valor ligeiramente diferente do do Governo para 2024.",
"source": "Ministério dos Recursos Minerais e Energia (via Club of Mozambique/Lusa e 360 Mozambique/Diário Económico); USGS (via CEIC)",
"source_row": "table2:row11"
},
{
"mineral": "Rubis e outras gemas",
"value": "Produção de rubi segundo o Ministério dos Recursos Minerais e Energia: 3,95 milhões de quilates em 2023, 4,14 em 2024 e 5,10 em 2025 (aumento de 29%; 123% da meta). Em 2026, 3,18 milhões de quilates no primeiro semestre (mais 61%), cerca de 78% da meta anual de 4,06 milhões; cerca de 70% da produção é exportada. Outra fonte indica valores mais baixos para 2023 (2,71 milhões) e 2024 (3,95 milhões), por isso os valores de anos anteriores podem ter sido revistos. Não encontrei dados de produção das outras gemas.",
"source": "Ministério dos Recursos Minerais e Energia (via Club of Mozambique/Lusa e 360 Mozambique); Beyond The Bay",
"source_row": "table2:row12"
}
],
"projects": [
"Balama (Cabo Delgado): a maior mina de grafite de África, operada pela Syrah Resources .",
"Moma (Nampula): mina de areias pesadas operada pela Kenmare Resources, com ilmenite, zircão e rútilo.",
"Monte Muambe (Tete): projecto de terras raras, fluorite e gálio associado.",
"Pande e Temane (Inhambane): jazigos de gás natural, com gasoduto para a África do Sul.",
"Coral Sul FLNG (Cabo Delgado): primeira unidade flutuante de GNL instalada em águas ultraprofundas, na bacia do Rovuma, com capacidade de liquefacção de cerca de 3,4 milhões de toneladas por ano. É operada pela Eni em nome dos parceiros da Área 4 (ExxonMobil, CNPC, Galp, KOGAS e ENH).",
"Revobue (Tete): mina de carvão inaugurada em Março de 2026, com o grupo indiano Jindal como principal accionista. Prevê produzir 3,5 milhões de toneladas por ano na fase inicial e 7 milhões a partir de 2032, com vida útil de 35 anos. O carvão será exportado pela linha de Sena até ao porto da Beira."
],
"risks": [
"Segundo a regra de publicação do InvesteMoz, uma oportunidade só recebe o estado \"Pronta para promoção\" quando estiverem documentados, em simultâneo, os seis requisitos abaixo. Nenhum está confirmado para as áreas listadas, por isso todas ficam \"A estruturar\".",
"Situação fundiária (DUAT) ou direito de uso aplicável.",
"Promotor e ponto focal.",
"Estudos de viabilidade e técnicos requeridos.",
"Licenças e autorizações relevantes.",
"Modelo contratual aprovado.",
"CAPEX actualizado, com data-base.",
"Riscos e pontos de diligência identificados nas fontes",
"Volatilidade de produção: a produção de grafite caiu 64% em 2024 e as vendas de carvão caíram 35% no primeiro trimestre de 2025, por paragens de minas e perturbações ferroviárias.",
"Declínio do gás: as reservas provadas de Pande e Temane caíram 25,6% num ano.",
"Comunidades e ambiente: há críticas à poluição e às detonações em Moatize, e o Revobue exige o reassentamento de cinco aldeias.",
"Ouro e licenciamento: a mineração foi suspensa em Manica desde 30 de Setembro de 2025 e a produção de 2025 caiu 17%; também houve paragens no Niassa para regularização de licenças.",
"Rubis e mineração ilegal: o Montepuez Ruby Mining refere centenas de mineiros ilegais por dia na sua concessão, o que atrasou a segunda fábrica de processamento e o leilão de 2025; a produção da terceira maior produtora está suspensa.",
"Dados: faltam valores de produção de outras gemas e de gás por ano; os dados do EITI e do Ministério dos Recursos Minerais e Energia devem ser usados para os confirmar. Os valores de ouro e de rubi divergem entre fontes.",
"Próximos passos para publicação",
"Confirmar a entidade responsável, validar os dados com o Ministério dos Recursos Minerais e Energia, georreferenciar as áreas e obter validação jurídica e editorial antes de publicar."
],
"sources": [
"EITI Moçambique – Ocorrência de minerais em Moçambique (anexos 2 e 4), eiti.org.",
"Escola MZ – Principais rochas e minerais e suas áreas de ocorrência (escolamz.com).",
"Fluxo Arte – Distribuição por província de recursos naturais (mozconteudo.blogspot.com).",
"Diário Económico – A janela estreita entre geologia e geoeconomia, Parte 2 (23/07/2026).",
"USGS – Mozambique, National Minerals Information Center (usgs.gov).",
"CEIC – Moçambique: Produção de minerais (ceicdata.com).",
"Ministério dos Recursos Minerais e Energia – Relatório Estatístico da Indústria Extractiva 2024; EITI – Relatório ITIE 2023-2024 (eiti.org).",
"Club of Mozambique – Mozambique expects coal production to reach record highs next year (26/11/2025); Mozambique prepares alternative to declining Pande and Temane gas production (05/11/2026).",
"Without Borders Energy – Mozambique Coal; MZ News – Reservas de gás da Sasol caem 25,6% (08/11/2026); Eni – Coral Sul FLNG Key Facts .",
"Club of Mozambique – Mozambique: Chapo inaugurates coal mine in Tete (16/03/2026).",
"Integrity Magazine – Da Vale a Vulcan : comunidades de Moatize (15/09/2024).",
"Club of Mozambique – Mozambique: Gold production rises 4% in Q1 to 283 kilogrammes (17/06/2026); 360 Mozambique – Mozambique Gold Output Falls 12% in First Half on Mining Suspensions (18/09/2026); CEIC – Mozambique Gold Production (usgs.gov/ceicdata.com).",
"Club of Mozambique – Mozambique: ruby production soars past five million carats in 2025 (09/04/2026); Montepuez Ruby Mining second plant set for 2025 (29/08/2024); 360 Mozambique – Ruby Production Increased by 29% (09/04/2026); Beyond The Bay – Mozambique ruby production surges in first half of 2026 (24/09/2026)."
],
"original_paragraphs": [
"Recursos Minerais de Moçambique: Referência de Oportunidades de Investimento",
"Ficha de referência para o portal InvesteMoz",
"Estado: A estruturar (informação de referência; nenhuma oportunidade classificada como \"Pronta para promoção\") Fonte: fontes públicas listadas na secção 8 .0 Data de actualização: 11 de Novembro de 2026 Entidade responsável / ponto focal: [a confirmar antes da publicação] Natureza: informação orientadora; não constitui oferta nem aconselhamento de investimento. Os dados carecem de validação institucional e jurídica.",
"1.0 Resumo para o Investidor",
"Moçambique combina carvão e gás de escala exportadora, grafite e areias pesadas de relevância mundial, terras raras, rubis e minerais industriais, distribuídos por todas as províncias. Segundo o USGS, a mineração contribuiu com cerca de 15% do PIB em 2024. Este documento organiza a informação pública disponível por minério e localização e identifica áreas de oportunidade (fornecimento de serviços, logística, processamento local e energia), com as respectivas condições de prontidão e riscos.",
"2.0 Principais Minérios e Localização",
"Minérios por província e zona de ocorrência",
"3.0 Projectos de Destaque",
"Balama (Cabo Delgado): a maior mina de grafite de África, operada pela Syrah Resources .",
"Moma (Nampula): mina de areias pesadas operada pela Kenmare Resources, com ilmenite, zircão e rútilo.",
"Monte Muambe (Tete): projecto de terras raras, fluorite e gálio associado.",
"Pande e Temane (Inhambane): jazigos de gás natural, com gasoduto para a África do Sul.",
"Coral Sul FLNG (Cabo Delgado): primeira unidade flutuante de GNL instalada em águas ultraprofundas, na bacia do Rovuma, com capacidade de liquefacção de cerca de 3,4 milhões de toneladas por ano. É operada pela Eni em nome dos parceiros da Área 4 (ExxonMobil, CNPC, Galp, KOGAS e ENH).",
"Revobue (Tete): mina de carvão inaugurada em Março de 2026, com o grupo indiano Jindal como principal accionista. Prevê produzir 3,5 milhões de toneladas por ano na fase inicial e 7 milhões a partir de 2032, com vida útil de 35 anos. O carvão será exportado pela linha de Sena até ao porto da Beira.",
"4.0 Reservas e Produção Anual",
"Segundo o Serviço Geológico dos EUA (USGS), a indústria mineira contribuiu com cerca de 15% do PIB de Moçambique em 2024. Os valores abaixo vêm de fontes públicas e referem-se aos anos indicados.",
"Produção e recursos dos principais minérios",
"",
"A s quantidades das outras gemas (além do rubi) constam do Relatório Estatístico da Indústria Extractiva 2024, do Ministério dos Recursos Minerais e Energia, e do Relatório ITIE 2023-2024. Não as incluí aqui por não ter conseguido confirmar os valores exactos. Os valores de carvão são nacionais; a maior parte provém de Tete. Os valores de gás por ano de produção não foram encontrados nas fontes consultadas. Os valores de ouro e de rubi divergem entre fontes, e a produção artesanal pode não estar totalmente registada.",
"5.0 Oportunidades de Investimento",
"A tabela cruza a informação das secções anteriores com áreas onde a literatura pública aponta espaço para investimento. Os modelos são indicativos e não representam projectos aprovados nem promotores confirmados.",
"Áreas de oportunidade por cadeia de valor",
"6.0 Fichas Individuais InvesteMoz",
"Cada ficha segue os campos de publicação do InvesteMoz. Os campos marcados \"[a confirmar]\" devem ser preenchidos e documentados antes de a oportunidade passar de \"A estruturar\" para \"Pronta para promoção\".",
"Ficha 1: Carvão: fornecimento de bens e serviços e logística",
"Ficha 2: Grafite: cadeia de valor para baterias",
"Ficha 3: Areias pesadas e terras raras: processamento local",
"Ficha 4: Gás natural: GNL e energia",
"Ficha 5: Gemas: rubis e pedras semipreciosas",
"Ficha 6: Minerais industriais e materiais de construção",
"Ficha 7: Ouro: exploração e comercialização",
"Ficha 8: Ferro, titânio, vanádio e manganês",
"Ficha 9: Cobre, níquel, zinco e bauxite",
"Ficha 10: Tantalite, lítio e outros metais raros",
"7 .0 Prontidão para Publicação e Riscos",
"Segundo a regra de publicação do InvesteMoz, uma oportunidade só recebe o estado \"Pronta para promoção\" quando estiverem documentados, em simultâneo, os seis requisitos abaixo. Nenhum está confirmado para as áreas listadas, por isso todas ficam \"A estruturar\".",
"Situação fundiária (DUAT) ou direito de uso aplicável.",
"Promotor e ponto focal.",
"Estudos de viabilidade e técnicos requeridos.",
"Licenças e autorizações relevantes.",
"Modelo contratual aprovado.",
"CAPEX actualizado, com data-base.",
"Riscos e pontos de diligência identificados nas fontes",
"Volatilidade de produção: a produção de grafite caiu 64% em 2024 e as vendas de carvão caíram 35% no primeiro trimestre de 2025, por paragens de minas e perturbações ferroviárias.",
"Declínio do gás: as reservas provadas de Pande e Temane caíram 25,6% num ano.",
"Comunidades e ambiente: há críticas à poluição e às detonações em Moatize, e o Revobue exige o reassentamento de cinco aldeias.",
"Ouro e licenciamento: a mineração foi suspensa em Manica desde 30 de Setembro de 2025 e a produção de 2025 caiu 17%; também houve paragens no Niassa para regularização de licenças.",
"Rubis e mineração ilegal: o Montepuez Ruby Mining refere centenas de mineiros ilegais por dia na sua concessão, o que atrasou a segunda fábrica de processamento e o leilão de 2025; a produção da terceira maior produtora está suspensa.",
"Dados: faltam valores de produção de outras gemas e de gás por ano; os dados do EITI e do Ministério dos Recursos Minerais e Energia devem ser usados para os confirmar. Os valores de ouro e de rubi divergem entre fontes.",
"Próximos passos para publicação",
"Confirmar a entidade responsável, validar os dados com o Ministério dos Recursos Minerais e Energia, georreferenciar as áreas e obter validação jurídica e editorial antes de publicar.",
"8 .0 Fontes",
"EITI Moçambique – Ocorrência de minerais em Moçambique (anexos 2 e 4), eiti.org.",
"Escola MZ – Principais rochas e minerais e suas áreas de ocorrência (escolamz.com).",
"Fluxo Arte – Distribuição por província de recursos naturais (mozconteudo.blogspot.com).",
"Diário Económico – A janela estreita entre geologia e geoeconomia, Parte 2 (23/07/2026).",
"USGS – Mozambique, National Minerals Information Center (usgs.gov).",
"CEIC – Moçambique: Produção de minerais (ceicdata.com).",
"Ministério dos Recursos Minerais e Energia – Relatório Estatístico da Indústria Extractiva 2024; EITI – Relatório ITIE 2023-2024 (eiti.org).",
"Club of Mozambique – Mozambique expects coal production to reach record highs next year (26/11/2025); Mozambique prepares alternative to declining Pande and Temane gas production (05/11/2026).",
"Without Borders Energy – Mozambique Coal; MZ News – Reservas de gás da Sasol caem 25,6% (08/11/2026); Eni – Coral Sul FLNG Key Facts .",
"Club of Mozambique – Mozambique: Chapo inaugurates coal mine in Tete (16/03/2026).",
"Integrity Magazine – Da Vale a Vulcan : comunidades de Moatize (15/09/2024).",
"Club of Mozambique – Mozambique: Gold production rises 4% in Q1 to 283 kilogrammes (17/06/2026); 360 Mozambique – Mozambique Gold Output Falls 12% in First Half on Mining Suspensions (18/09/2026); CEIC – Mozambique Gold Production (usgs.gov/ceicdata.com).",
"Club of Mozambique – Mozambique: ruby production soars past five million carats in 2025 (09/04/2026); Montepuez Ruby Mining second plant set for 2025 (29/08/2024); 360 Mozambique – Ruby Production Increased by 29% (09/04/2026); Beyond The Bay – Mozambique ruby production surges in first half of 2026 (24/09/2026)."
],
"source_instructions_are_not_product_rules": true
}
@@ -0,0 +1,70 @@
<?php
use App\Services\DemographicInstallation;
use App\Support\IsolatedQaDatabase;
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 (! app(DemographicInstallation::class)->schemaAllowed()) {
throw new RuntimeException('Demografia: instalação exige QA isolado ou a acção autenticada autorizada.');
}
Schema::create('demographic_imports', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->char('sha256', 64)->unique();
$t->string('file_name');
$t->string('source');
$t->string('publication_version');
$t->string('status')->default('prepared');
$t->jsonb('report');
$t->char('preview_sha256', 64);
$t->foreignId('prepared_by')->constrained('users')->restrictOnDelete();
$t->timestampsTz();
});
Schema::create('demographic_observations', function (Blueprint $t) {
$t->bigIncrements('id');
$t->foreignUuid('import_id')->constrained('demographic_imports')->restrictOnDelete();
$t->foreignUuid('geo_location_id')->constrained('geo_locations')->restrictOnDelete();
$t->unsignedSmallInteger('year');
$t->string('indicator', 80);
$t->string('sex', 10);
$t->string('residence', 10);
$t->string('age_group', 30);
$t->string('unit', 80);
$t->string('data_kind', 20);
$t->string('original_value', 100);
$t->text('numeric_value'); // Exact decimal text, including Excel storage precision.
$t->string('sheet');
$t->string('cell', 20);
$t->string('validation_status', 20);
$t->unique(['import_id', 'geo_location_id', 'year', 'indicator', 'sex', 'residence', 'age_group'], 'demography_dimension_unique');
$t->index(['geo_location_id', 'year', 'indicator']);
});
Schema::create('demographic_events', function (Blueprint $t) {
$t->bigIncrements('id');
$t->foreignUuid('import_id')->constrained('demographic_imports')->restrictOnDelete();
$t->foreignId('actor_id')->constrained('users')->restrictOnDelete();
$t->string('action', 30);
$t->text('reason');
$t->jsonb('details');
$t->timestampTz('created_at')->useCurrent();
});
DB::statement("ALTER TABLE demographic_imports ADD CONSTRAINT demographic_import_state CHECK (status IN ('prepared','imported','approved','disabled'))");
DB::statement("ALTER TABLE demographic_observations ADD CONSTRAINT demographic_observation_state CHECK (validation_status IN ('valid','warning'))");
}
public function down(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('demographic_imports')->exists()) {
throw new RuntimeException('Preserve originais e histórico demográfico antes de reverter.');
}
Schema::dropIfExists('demographic_events');
Schema::dropIfExists('demographic_observations');
Schema::dropIfExists('demographic_imports');
}
};
@@ -0,0 +1,54 @@
<?php
use App\Support\IsolatedQaDatabase;
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 (! IsolatedQaDatabase::allowsSchemaChanges()) {
throw new RuntimeException('This first-stage migration is authorised only in isolated QA.');
}
Schema::create('portal_editorial_editions', function (Blueprint $t): void {
$t->uuid('id')->primary();
$t->foreignId('resource_id')->constrained('portal_resources')->restrictOnDelete();
$t->unsignedInteger('revision_no');
$t->unsignedInteger('version')->default(1);
$t->string('state', 24)->default('draft');
$t->jsonb('payload');
$t->char('content_hash', 64);
$t->foreignId('author_id')->constrained('users')->restrictOnDelete();
$t->foreignId('assigned_to')->nullable()->constrained('users')->restrictOnDelete();
$t->boolean('legal_required')->default(false);
$t->jsonb('approval')->nullable();
$t->jsonb('legal_validation')->nullable();
$t->timestampTz('published_at')->nullable();
$t->timestampsTz();
$t->unique(['resource_id', 'revision_no']);
$t->index(['state', 'assigned_to']);
});
Schema::create('portal_editorial_notifications', function (Blueprint $t): void {
$t->id();
$t->uuid('edition_id');
$t->foreign('edition_id')->references('id')->on('portal_editorial_editions')->restrictOnDelete();
$t->foreignId('user_id')->constrained('users')->restrictOnDelete();
$t->string('action', 40);
$t->timestampTz('read_at')->nullable();
$t->timestampTz('created_at');
$t->index(['user_id', 'read_at']);
});
}
public function down(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('portal_editorial_editions')->exists() || DB::table('portal_editorial_notifications')->exists()) {
throw new RuntimeException('Preserve editions, decisions and history.');
}
Schema::dropIfExists('portal_editorial_notifications');
Schema::dropIfExists('portal_editorial_editions');
}
};

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