Compare commits
140
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1349789226 | ||
|
|
9cbd7fb97d | ||
|
|
2afbc5c9c7 | ||
|
|
3558647e8d | ||
|
|
d954c83b78 | ||
|
|
9a01426864 | ||
|
|
ed2fd86f6b | ||
|
|
528a2597ed | ||
|
|
e431de8b42 | ||
|
|
7691cab3bc | ||
|
|
91c1857368 | ||
|
|
6e302db62e | ||
|
|
1f2990e4da | ||
|
|
e8408c27d9 | ||
|
|
96f510965f | ||
|
|
88f18d0b7e | ||
|
|
dd20e74703 | ||
|
|
203c8f9193 | ||
|
|
73e1d7b964 | ||
|
|
ce8b744d1f | ||
|
|
a4541298c0 | ||
|
|
f40924f1be | ||
|
|
d5c960b62f | ||
|
|
fa69884ab0 | ||
|
|
e473542494 | ||
|
|
b3d4b7abe6 | ||
|
|
d3eeb70370 | ||
|
|
3c4c2599f7 | ||
|
|
078f770dea | ||
|
|
e9278ee446 | ||
|
|
2459d0ca37 | ||
|
|
95b33275aa | ||
|
|
a045162773 | ||
|
|
d2c72f5e13 | ||
|
|
f059c226d2 | ||
|
|
1218734eca | ||
|
|
d9f088587b | ||
|
|
b3849d850e | ||
|
|
2bad72c042 | ||
|
|
39bddad091 | ||
|
|
42b9324acb | ||
|
|
7e7bd4fde3 | ||
|
|
1263b4f6b7 | ||
|
|
2f1ef2fdfc | ||
|
|
61b4d9f1f9 | ||
|
|
1d52c3d8f4 | ||
|
|
0f79d0d54c | ||
|
|
d325b72fad | ||
|
|
8854b2928c | ||
|
|
f53a308327 | ||
|
|
723ce2a0a4 | ||
|
|
07182c7c73 | ||
|
|
bb6824d75d | ||
|
|
095280e29d | ||
|
|
e92519dd75 | ||
|
|
4988ca89dc | ||
|
|
258c8bd28b | ||
|
|
df543a4414 | ||
|
|
291a1c420f | ||
|
|
b20068d04b | ||
|
|
eab09dd61e | ||
|
|
71f2fcbb84 | ||
|
|
f6c1b60612 | ||
|
|
880e12890c | ||
|
|
f276781876 | ||
|
|
542ac2ff2c | ||
|
|
4971f8935b | ||
|
|
6d3bf2937e | ||
|
|
eca6719135 | ||
|
|
a400802b63 | ||
|
|
1b8b9f414a | ||
|
|
70423474d2 | ||
|
|
ee23a2cc2c | ||
|
|
a59142caa2 | ||
|
|
8ebe734d0d | ||
|
|
8f295f0551 | ||
|
|
a2033dd4a0 | ||
|
|
a708f079b3 | ||
|
|
aae79c712d | ||
|
|
bcbbe51c2f | ||
|
|
e549003e5d | ||
|
|
fbff2660f2 | ||
|
|
192a7e35e5 | ||
|
|
6805b606a1 | ||
|
|
bbc3c1c9bc | ||
|
|
9c067d6ae8 | ||
|
|
67cd583b94 | ||
|
|
0f280d2d6d | ||
|
|
0e8da995e0 | ||
|
|
ff293940a6 | ||
|
|
4f3dbcf864 | ||
|
|
d830fbca50 | ||
|
|
ef24cbdc92 | ||
|
|
5a5cc9e815 | ||
|
|
5ff9402fd0 | ||
|
|
c8e0a7ee39 | ||
|
|
1c75a30a60 | ||
|
|
c790159250 | ||
|
|
37854d0231 | ||
|
|
7476dc3430 | ||
|
|
d216830b06 | ||
|
|
d5cb41b111 | ||
|
|
89e6d72c1e | ||
|
|
7c8d4726e9 | ||
|
|
b10b35f308 | ||
|
|
60b52fc8c7 | ||
|
|
ac67f9c13a | ||
|
|
c359364de3 | ||
|
|
ff726e577f | ||
|
|
03bcaf38cd | ||
|
|
659c039b27 | ||
|
|
6734441855 | ||
|
|
95def7f75e | ||
|
|
786d5f78d1 | ||
|
|
5e00f1ee9e | ||
|
|
252f38bb0a | ||
|
|
7f13149723 | ||
|
|
dcecd25102 | ||
|
|
4e07557cb1 | ||
|
|
4e61480272 | ||
|
|
377c40a21a | ||
|
|
2209b83820 | ||
|
|
67aa015b4c | ||
|
|
4f630b9f2a | ||
|
|
5859a9810e | ||
|
|
dab85aee96 | ||
|
|
12cecd061b | ||
|
|
705dc69d68 | ||
|
|
6da444ae2e | ||
|
|
7e351348af | ||
|
|
c5756c0282 | ||
|
|
3f50f66633 | ||
|
|
a9241ff26b | ||
|
|
21ebad73e7 | ||
|
|
bbc05f1df6 | ||
|
|
6dde36b5ca | ||
|
|
dc0bbd74d5 | ||
|
|
66a5dbf311 | ||
|
|
bf2c054943 | ||
|
|
f3455432cf |
@@ -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=
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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')"));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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'] !== []) {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'] ?? []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.'¬ice_version='.$version,
|
||||
'reference' => ($cluster ? 'Competitividade — versão ' : 'Território — versão ').$version];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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']]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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']];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.'>';
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
@@ -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
@@ -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' => [
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user