Compare commits

...
61 Commits
Author SHA1 Message Date
nala 1349789226 fix(offers): avoid Wayfinder report form name collision after safe deployment stop
tests / ci (push) Waiting to run
2026-11-14 20:10:32 +02:00
nala 9cbd7fb97d docs(offers): record mandatory stop on authorised revision mismatch 2026-11-14 19:47:07 +02:00
nala 2afbc5c9c7 docs(offers): verify live target backup and isolated recovery before install 2026-11-14 19:44:08 +02:00
nala 3558647e8d fix(offers): align private navigation with portal dropdown menus
tests / ci (push) Waiting to run
2026-11-14 19:07:14 +02:00
nala d954c83b78 docs(offers): prepare gated homologation readiness and recovery plan 2026-11-14 18:47:11 +02:00
nala 9a01426864 fix(offers): preserve description in authorised investment context 2026-11-14 18:39:25 +02:00
nala ed2fd86f6b audit(offers): record acceptance blockers before homologation 2026-11-14 18:25:09 +02:00
nala 528a2597ed feat(offers): complete phase five operational controls in QA 2026-11-14 18:08:46 +02:00
nala e431de8b42 feat(offers): implement phase four private details and confidentiality in QA 2026-11-14 17:28:12 +02:00
nala 7691cab3bc feat(offers): add gated phase three public catalogue in QA 2026-11-14 16:49:58 +02:00
nala 91c1857368 feat: integrate private offers with existing editorial workflow 2026-11-14 16:20:30 +02:00
nala 6e302db62e docs: confirm private offers QA and plan phase two 2026-11-14 13:58:33 +02:00
nala 1f2990e4da feat: prepare private offers through existing opportunities 2026-11-14 13:41:31 +02:00
nala e8408c27d9 Record verified administration dashboard hierarchy correction 2026-11-14 12:34:05 +02:00
nala 96f510965f Separate dashboard functions from grouped administration operations
tests / ci (push) Waiting to run
2026-11-14 12:25:27 +02:00
nala 88f18d0b7e Record verified dashboard HTML recovery dd20e747 2026-11-14 12:13:54 +02:00
nala dd20e74703 Fix authenticated editorial dashboard missing Vite page entry
tests / ci (push) Waiting to run
2026-11-14 12:04:52 +02:00
nala 203c8f9193 Record verified dashboard menu deployment 73e1d7b9 2026-11-14 11:59:02 +02:00
nala 73e1d7b964 Group dashboard activities under primary administration and tidy editorial overview
tests / ci (push) Waiting to run
2026-11-14 11:43:56 +02:00
nala ce8b744d1f Record verified grouped dashboard installation a4541298 2026-11-14 11:31:38 +02:00
nala a4541298c0 Complete grouped collaborative editorial queues and native review coverage
tests / ci (push) Waiting to run
2026-11-14 11:15:09 +02:00
nala f40924f1be Record authorised grouped editorial installation and preservation receipts 2026-11-14 10:11:23 +02:00
nala d5c960b62f Merge remote-tracking branch 'origin/main' into qa/editorial-fase1-20261113
tests / ci (push) Waiting to run
2026-11-14 09:12:25 +02:00
nala fa69884ab0 Integrate authorised editorial package with current homologation changes 2026-11-14 09:11:55 +02:00
nala e473542494 Support documented full annual noncivil PPC PEC estimates and dossier continuity
tests / ci (push) Waiting to run
2026-11-14 09:05:33 +02:00
nala b3d4b7abe6 Preserve published territories during private editorial review 2026-11-14 09:03:05 +02:00
nala d3eeb70370 Record authorised period consistency installation and preserved data 2026-11-14 09:02:37 +02:00
nala 3c4c2599f7 feat(editorial): review demographic batches before explicit publication 2026-11-14 08:35:11 +02:00
nala 078f770dea Keep PPC PEC estimates and reports consistent with the prepared L6 period
tests / ci (push) Waiting to run
2026-11-14 00:23:46 +02:00
nala e9278ee446 Record authorised grouped PEC installation and preserved data 2026-11-14 00:00:21 +02:00
nala 2459d0ca37 feat(editorial): stage portfolio base data and territory for atomic publication 2026-11-13 23:47:04 +02:00
nala 95b33275aa Integrate monthly PPC PEC cash projections across screen and dossier report
tests / ci (push) Canceled after 0s
2026-11-13 23:32:00 +02:00
nala a045162773 Record authorised grouped PEC installation and preserved data 2026-11-13 23:29:17 +02:00
nala d2c72f5e13 feat(editorial): preserve public portfolio fiches during private review 2026-11-13 23:23:16 +02:00
nala f059c226d2 Group documented prior PEC basis and excess PPC reconciliation for viability
tests / ci (push) Canceled after 0s
2026-11-13 23:14:22 +02:00
nala 1218734eca feat(editorial): separate cluster analysis approval and publication in QA 2026-11-13 23:08:02 +02:00
nala d9f088587b Record authorised observation nature delivery and preserved data 2026-11-13 23:07:27 +02:00
nala b3849d850e feat(editorial): preserve public editions and add QA review workflow 2026-11-13 22:56:44 +02:00
nala 2bad72c042 Expose calendar duration and overlap before comparing territorial observations
tests / ci (push) Canceled after 0s
2026-11-13 22:54:49 +02:00
nala 39bddad091 Record authorised observation nature delivery and preserved data 2026-11-13 22:54:46 +02:00
nala 42b9324acb Classify sourced territorial observations without inferring legacy values
tests / ci (push) Canceled after 0s
2026-11-13 22:44:42 +02:00
nala 7e7bd4fde3 Record authorised territorial comparison delivery and preserved data 2026-11-13 22:09:58 +02:00
nala 1263b4f6b7 Compare sourced territorial observations without inferring statistical equivalence
tests / ci (push) Canceled after 0s
2026-11-13 21:52:38 +02:00
nala 2f1ef2fdfc Align quick search with current financial directory while retaining historical records
tests / ci (push) Canceled after 0s
2026-11-13 21:14:25 +02:00
nala 61b4d9f1f9 Record authorised stage report installation and preservation 2026-11-13 15:19:12 +02:00
nala 1d52c3d8f4 Preserve installed dossier and support presentation while delivering authorised quick search
tests / ci (push) Canceled after 0s
2026-11-13 15:17:26 +02:00
nala 0f79d0d54c Keep institutional contact test independent of CSS module loading in Node CI
tests / ci (push) Canceled after 0s
2026-11-13 15:09:08 +02:00
nala d325b72fad Improve investor quick search with typed counts, product categories and licensing guidance 2026-11-13 15:06:11 +02:00
nala 8854b2928c Allow private stage report preview and explicit Word/HTML download 2026-11-13 15:02:15 +02:00
paulomassigue f53a308327 Merge branch 'main' of https://git.tempo.co.mz/nala/Investidor-V2
tests / ci (push) Canceled after 0s
2026-11-13 14:43:34 +02:00
nala 723ce2a0a4 Record grouped investor reports installed and preservation verified 2026-11-13 14:28:10 +02:00
nala 07182c7c73 Group investor reports and preserved financial copies in their existing dossier
tests / ci (push) Canceled after 0s
2026-11-13 14:09:52 +02:00
nala bb6824d75d Keep concurrent manual additions outside demographic delivery 2026-11-13 14:08:20 +02:00
nala 095280e29d Record demographic matrix status while preserving concurrent additions in working copy 2026-11-13 14:07:52 +02:00
nala e92519dd75 Record HTTP500 correction verified in real PHP-FPM with process execution blocked 2026-11-13 14:06:58 +02:00
nala 4988ca89dc Use verified CLI preparations when web PHP cannot execute processes
tests / ci (push) Canceled after 0s
2026-11-13 13:57:01 +02:00
nala 258c8bd28b Record installed demography access correction and refreshed readiness 2026-11-13 13:49:32 +02:00
nala df543a4414 Recognise designated superusers in authenticated demography installation
tests / ci (push) Canceled after 0s
2026-11-13 13:41:48 +02:00
nala 291a1c420f Record demography code installed in homologation, authenticated data confirmation pending 2026-11-13 13:40:16 +02:00
nala b20068d04b Deliver additive INE demography and authenticated homologation installation
tests / ci (push) Canceled after 0s
2026-11-13 13:27:36 +02:00
nala 880e12890c Record mining and energy reference delivery installed in f2767818 2026-11-11 14:20:23 +02:00
318 changed files with 30442 additions and 418 deletions
@@ -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;
}
}
@@ -19,7 +19,7 @@ final class ClusterCompetitivenessController extends Controller
$data = $request->validate(['expected_version' => 'required|integer|min:0', $data = $request->validate(['expected_version' => 'required|integer|min:0',
'draft' => $action === 'saved' ? 'required|array:version,clusterId,sector,region,forces' : 'prohibited', 'draft' => $action === 'saved' ? 'required|array:version,clusterId,sector,region,forces' : 'prohibited',
'revision' => $action === 'restored' ? 'required|integer|min:1' : '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)) return response()->json($service->change($cluster, $request->user()->id, (int) $data['expected_version'], $action, $data))
->header('Cache-Control', 'private, no-store'); ->header('Cache-Control', 'private, no-store');
@@ -7,6 +7,8 @@ use App\Models\InvestmentGap;
use App\Models\ValueChain; use App\Models\ValueChain;
use App\Models\ValueChainLink; use App\Models\ValueChainLink;
use App\Services\PortalAudit; use App\Services\PortalAudit;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; 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')], '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'])], '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) { DB::transaction(function () use ($cluster, $data, $request) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail(); EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
$link = ValueChainLink::where($data)->first() ?? new ValueChainLink; $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 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) { DB::transaction(function () use ($cluster, $link, $request) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail(); 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); 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 public function gap(Request $request, EconomicCluster $cluster, ?InvestmentGap $gap = null): RedirectResponse
{ {
abort_if($gap && $gap->cluster_id !== $cluster->id, 404); 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([ $data = $request->validate([
'value_chain_id' => ['required', 'uuid', Rule::exists('value_chains', 'id')->where('cluster_id', $cluster->id)->whereNull('deleted_at')], 'value_chain_id' => ['required', 'uuid', Rule::exists('value_chains', 'id')->where('cluster_id', $cluster->id)->whereNull('deleted_at')],
'title' => ['required', 'string', 'max:200'], 'title' => ['required', 'string', 'max:200'],
@@ -52,7 +73,13 @@ class ClusterValueChainController extends Controller
'rationale' => ['required', 'string', 'min:10', 'max:5000'], 'rationale' => ['required', 'string', 'min:10', 'max:5000'],
'status' => ['required', Rule::in(['identified', 'in_review', 'resolved', 'withdrawn'])], 'status' => ['required', Rule::in(['identified', 'in_review', 'resolved', 'withdrawn'])],
'call_to_action' => ['required', 'string', 'max:2000'], '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); PortalAudit::save($gap ?? new InvestmentGap, [...$data, 'cluster_id' => $cluster->id], $request->user()->id);
return back()->with('success', 'Lacuna de investimento guardada.'); return back()->with('success', 'Lacuna de investimento guardada.');
+3 -2
View File
@@ -7,6 +7,7 @@ use App\Models\Opportunity;
use App\Models\Sector; use App\Models\Sector;
use App\Services\DashboardWorkQueue; use App\Services\DashboardWorkQueue;
use App\Services\InvestorPath; use App\Services\InvestorPath;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess; use App\Services\PortalProfileAccess;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
@@ -28,7 +29,7 @@ class DashboardController extends Controller
$contexts = $path->contexts($request->user()); $contexts = $path->contexts($request->user());
$current = $contexts ? $path->view($request->user(), $contexts[0]['id']) : null; $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, 'contexts' => $contexts,
'current' => $current ? ['id' => $current['case']['id'], 'next' => $current['next'], '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))), '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()); $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()), 'canCreateOpportunity' => app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()),
'requestsByStatus' => collect(['received', 'in_review', 'contacted', 'closed']) 'requestsByStatus' => collect(['received', 'in_review', 'contacted', 'closed'])
->mapWithKeys(fn ($status) => [$status => (int) ($counts[$status] ?? 0)]), ->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\DocumentManagementAccess;
use App\Services\EditorialInstallation; use App\Services\EditorialInstallation;
use App\Services\EditorialReading; use App\Services\EditorialReading;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess; use App\Services\PortalProfileAccess;
use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
@@ -97,11 +98,17 @@ class EditorialArticleController extends Controller
app(DocumentManagementAccess::class)->assertEditableResource(request()->user(), $id); app(DocumentManagementAccess::class)->assertEditableResource(request()->user(), $id);
$r = DB::table('portal_resources')->where('id', $id)->first(); $r = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($r instanceof \stdClass, 404); 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); $reading = app(EditorialReading::class);
$original = $reading->original($r); $original = $reading->original($r);
$content = $reading->content($r, $original); $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], 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']), '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')); '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(); $r = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
abort_unless($r !== null, 404); abort_unless($r !== null, 404);
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); 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'])) { 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.'); 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 === '') { if ($body === '') {
throw ValidationException::withMessages(['body' => 'Escreva o corpo do artigo.']); 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; $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()]); 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(); $resource = DB::table('portal_resources')->where('id', $id)->first();
@@ -165,6 +180,11 @@ class EditorialArticleController extends Controller
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->where('id', $id)->first(); $resource = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($resource instanceof \stdClass, 404); 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)]) return tap(Inertia::render('portal/article', ['article' => app(EditorialReading::class)->reading($resource, null, true)])
->toResponse($request), static function (Response $response): void { ->toResponse($request), static function (Response $response): void {
@@ -179,6 +199,11 @@ class EditorialArticleController extends Controller
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->where('id', $id)->first(); $resource = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($resource instanceof \stdClass, 404); 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); $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.'); abort_unless((int) $resource->metadata_version === (int) $data['metadata_version'], 409, 'A revisão mudou; recupere antes de rever a prévia.');
+152 -6
View File
@@ -3,31 +3,105 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\ExistingBusiness; use App\Models\ExistingBusiness;
use App\Models\Opportunity;
use App\Models\Sector; use App\Models\Sector;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\EditorialReading; use App\Services\EditorialReading;
use App\Services\FederatedSearch; use App\Services\FederatedSearch;
use App\Services\FundingInstitutionDetails;
use App\Services\FundingProgrammes;
use App\Services\InvestmentCatalogue; use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\InvestmentZones; use App\Services\InvestmentZones;
use App\Services\LegacyContent;
use App\Services\SearchTerms; use App\Services\SearchTerms;
use App\Services\SectorOrganization; use App\Services\SectorOrganization;
use App\Services\SectorResourceReferences;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
class GlobalSearchController extends Controller class GlobalSearchController extends Controller
{ {
public function __invoke(Request $request, InvestmentCatalogue $catalogue, SearchTerms $search, FederatedSearch $federated): Response 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']]); $input = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1'], 'category' => ['nullable', 'in:all,physical,financial']]);
$term = trim($input['q'] ?? ''); $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); $tokens = $search->tokens($term);
$results = ['data' => [], 'total' => 0, 'current_page' => 1, 'last_page' => 1, 'per_page' => 5, 'unavailable' => []]; $results = ['data' => [], 'total' => 0, 'current_page' => 1, 'last_page' => 1, 'per_page' => 5, 'unavailable' => []];
if ($tokens !== []) { if ($tokens !== [] || $intent !== null) {
$factories = [ $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) { 'Páginas e serviços' => function () use ($search, $tokens, $term) {
$pages = array_values(array_filter($this->pages(), fn ($page) => $search->matches(implode(' ', $page), $tokens))); $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) usort($pages, fn ($a, $b) => $search->score($b['title'], $term, $tokens) <=> $search->score($a['title'], $term, $tokens)
@@ -63,9 +137,15 @@ class GlobalSearchController extends Controller
return $this->source($query, $search, $term, $tokens, 'public_title', 'existing_businesses.id', 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)]); 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) { 'Carteira: projectos, clusters e corredores' => function () use ($search, $tokens, $term, $intent) {
$query = InvestmentPortfolioController::visible(); $query = InvestmentPortfolioController::visible();
$search->constrain($query, ['investment_dossiers.title', 'investment_dossiers.content'], $tokens); $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', 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]); fn ($r) => ['title' => $r->title, 'description' => $r->corridor_id ? 'Corredor' : ($r->cluster_id ? 'Cluster' : 'Projecto âncora'), 'url' => '/carteira/'.$r->id]);
@@ -126,13 +206,75 @@ class GlobalSearchController extends Controller
fn ($r) => ['title' => $r->title, 'description' => implode(' · ', array_filter([$r->sector, $r->province])), 'url' => '/parcerias/'.$r->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, $results = $federated->page($factories, (int) ($input['page'] ?? 1), 5,
fn ($item) => $search->score($item['title'], $term, $tokens)); fn ($item) => $search->score($item['title'], $term, $tokens));
} }
return Inertia::render('portal/search', ['query' => $term, 'results' => $results]); $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 private function source($query, SearchTerms $search, string $term, array $tokens, string $title, string $id, callable $present): array
{ {
$count = (clone $query)->reorder()->count(); $count = (clone $query)->reorder()->count();
@@ -142,10 +284,14 @@ class GlobalSearchController extends Controller
return ['count' => $count, 'fetch' => fn ($offset, $limit) => (clone $query)->offset($offset)->limit($limit)->get()->map($present)->all()]; 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. */ /** Public service entry points and terms used to find them.
* @return list<array{title:string,description:string,url:string}>
*/
private function pages(): array private function pages(): array
{ {
return [ 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' => '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' => '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' => 'Oportunidades de investimento', 'description' => 'Pesquisar por sector, localização e maturidade', 'url' => '/oportunidades'],
@@ -13,6 +13,7 @@ use App\Services\InvestmentGeography;
use App\Services\InvestmentRelations; use App\Services\InvestmentRelations;
use App\Services\InvestmentZones; use App\Services\InvestmentZones;
use App\Services\PortalAudit; use App\Services\PortalAudit;
use App\Services\PortalEditorialWorkflow;
use App\Services\PublicSearchMetrics; use App\Services\PublicSearchMetrics;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Database\Query\Builder as QueryBuilder;
@@ -99,6 +100,10 @@ class InvestmentPortfolioController extends Controller
{ {
$management = $request->routeIs('management.portfolio*'); $management = $request->routeIs('management.portfolio*');
$record = ($management ? InvestmentDossier::query() : self::visible())->findOrFail($id); $record = ($management ? InvestmentDossier::query() : self::visible())->findOrFail($id);
$editorial = $management && app(PortalEditorialWorkflow::class)->portfolioEnabled();
if ($editorial) {
$record = app(PortalEditorialWorkflow::class)->workingPortfolio($record);
}
$relatedIds = []; $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 ([['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]) { 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']) ->orderByDesc('updated_at')->limit(10)->get(['id', 'version', 'updated_at'])
: collect(); : 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, 'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions,
'competitiveness' => $record->cluster_id ? app(ClusterCompetitiveness::class)->publicData($record->cluster_id) : null, 'competitiveness' => $record->cluster_id ? app(ClusterCompetitiveness::class)->publicData($record->cluster_id) : null,
'geography' => app(InvestmentRelations::class)->dossier($record, $management), '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'], $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' => ['required', 'array:name,description,location,region,sector,promotion,facts,partners,cta,anchors,gaps,priority,clusters,focus,notice,code'],
'content.*' => ['nullable', 'string', 'max:15000']]); '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) { DB::transaction(function () use ($request, $dossier, $data) {
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail(); $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.'); 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 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']]); $data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'approval_evidence' => ['required', 'string', 'min:15', 'max:3000']]);
DB::transaction(function () use ($request, $dossier, $data) { DB::transaction(function () use ($request, $dossier, $data) {
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail(); $dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
@@ -11,6 +11,8 @@ use App\Models\ValueChain;
use App\Services\ClusterCompetitiveness; use App\Services\ClusterCompetitiveness;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\PortalAudit; use App\Services\PortalAudit;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; 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 public function edit(Request $request, string $structure, ?string $id = null): Response
{ {
$model = $this->model($structure); $model = $this->model($structure);
$record = $id ? $model::findOrFail($id) : null; $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']) : [], 'projects' => $structure === 'clusters' && $id ? Project::orderBy('name')->get(['id', 'name', 'code']) : [],
'chains' => $structure === 'clusters' && $id ? ValueChain::where('cluster_id', $id)->orderBy('code')->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() : [], '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') '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 origins', 'origins.id', '=', 'value_chain_links.from_value_chain_id')
->join('value_chains as destinations', 'destinations.id', '=', 'value_chain_links.to_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') 'memberships' => $structure === 'clusters' && $id ? DB::table('cluster_projects')
->join('projects', 'projects.id', '=', 'cluster_projects.project_id') ->join('projects', 'projects.id', '=', 'cluster_projects.project_id')
->where('cluster_id', $id)->whereNull('cluster_projects.deleted_at')->whereNull('projects.deleted_at') ->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'; $isCorridor = $structure === 'corridors';
$model = $this->model($structure); $model = $this->model($structure);
$record = $id ? $model::findOrFail($id) : new $model; $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 = [ $rules = [
'code' => ['required', 'string', 'max:100', Rule::unique($record->getTable(), 'code')->whereNull('deleted_at')->ignore($record->getKey())], 'code' => ['required', 'string', 'max:100', Rule::unique($record->getTable(), 'code')->whereNull('deleted_at')->ignore($record->getKey())],
'name' => ['required', 'string', 'max:200'], 'name' => ['required', 'string', 'max:200'],
'status' => ['required', Rule::in($isProject ? ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'] : ($isCluster ? ['planned', 'active', 'inactive'] : ['proposed', 'active', 'closed']))], '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) { if ($isProject || $isCorridor) {
$rules['description'] = ['nullable', 'string', 'max:10000']; $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['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
$data['territory_version'] = $path === [] ? 0 : 1; $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); PortalAudit::save($record, $data, $request->user()->id);
return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Registo guardado.'); 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 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')], $data = $request->validate(['project_id' => ['required', 'uuid', Rule::exists('projects', 'id')->whereNull('deleted_at')],
'version_no' => ['required', 'integer', 'min:0'], 'version_no' => ['required', 'integer', 'min:0'],
'role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])], 'role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])],
@@ -121,10 +161,21 @@ class InvestmentStructureController extends Controller
'source_reference' => ['required', 'string', 'min:10', 'max:2000'], 'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
'evidence_reference' => ['required', 'string', 'min:3', 'max:2000'], 'evidence_reference' => ['required', 'string', 'min:3', 'max:2000'],
'valid_from' => ['required', 'date_format:Y-m-d'], 'valid_from' => ['required', 'date_format:Y-m-d'],
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from']]); 'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
DB::transaction(function () use ($cluster, $data, $request) { ...($editorial ? ['editorial_version' => ['required', 'integer', 'min:0']] : [])]);
DB::transaction(function () use ($cluster, $data, $request, $editorial) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail(); EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
$link = ClusterProject::where('cluster_id', $cluster->id)->where('project_id', $data['project_id'])->whereNull('deleted_at')->lockForUpdate()->first(); $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) { if ($link) {
abort_if((int) $link->version_no !== (int) $data['version_no'], 409, 'Versao da associacao alterada.'); abort_if((int) $link->version_no !== (int) $data['version_no'], 409, 'Versao da associacao alterada.');
$changed = false; $changed = false;
@@ -155,10 +206,17 @@ class InvestmentStructureController extends Controller
public function saveChain(Request $request, EconomicCluster $cluster, ?ValueChain $chain = null): RedirectResponse public function saveChain(Request $request, EconomicCluster $cluster, ?ValueChain $chain = null): RedirectResponse
{ {
abort_if($chain && $chain->cluster_id !== $cluster->id, 404); 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', $data = $request->validate(['code' => ['required', 'string', 'max:100',
Rule::unique('value_chains', 'code')->where('cluster_id', $cluster->id)->whereNull('deleted_at')->ignore($chain?->id)], Rule::unique('value_chains', 'code')->where('cluster_id', $cluster->id)->whereNull('deleted_at')->ignore($chain?->id)],
'name' => ['required', 'string', 'max:200'], '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); PortalAudit::save($chain ?? new ValueChain, [...$data, 'cluster_id' => $cluster->id], $request->user()->id);
return back()->with('success', 'Etapa da cadeia de valor guardada.'); return back()->with('success', 'Etapa da cadeia de valor guardada.');
@@ -2,6 +2,8 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -30,7 +32,8 @@ class InvestmentStructureRelationController extends Controller
...($kind === 'cluster-project' ? ['role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])]] : []), ...($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(); 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(); $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.'); abort_if($existing !== null, 409, 'Associacao ja registada.');
@@ -40,8 +43,15 @@ class InvestmentStructureRelationController extends Controller
$id = (string) Str::uuid(); $id = (string) Str::uuid();
DB::table($table)->insert([...$values, 'id' => $id, 'source_system' => 'portal-management']); DB::table($table)->insert([...$values, 'id' => $id, 'source_system' => 'portal-management']);
$this->audit($request, $table, $id, 'submitted', 1); $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.'); 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); abort_unless(in_array($action, ['validate', 'revoke'], true), 404);
[$table] = $this->definition($kind); [$table] = $this->definition($kind);
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]); $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 { DB::transaction(function () use ($request, $table, $id, $action, $data): void {
$relation = DB::table($table)->where('id', $id)->lockForUpdate()->first(); $relation = DB::table($table)->where('id', $id)->lockForUpdate()->first();
@@ -2,8 +2,16 @@
namespace App\Http\Controllers; 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\Opportunity;
use App\Models\Project;
use App\Services\InvestmentZoneOperationalProfile;
use App\Services\InvestmentZones; use App\Services\InvestmentZones;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess; use App\Services\PortalProfileAccess;
use App\Services\TerritoryGeometry; use App\Services\TerritoryGeometry;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -82,6 +90,18 @@ class InvestmentTerritoryController extends Controller
$table = self::TABLES[$kind] ?? abort(404); $table = self::TABLES[$kind] ?? abort(404);
$record = DB::table($table)->where('id', $id)->whereNull('deleted_at')->first(); $record = DB::table($table)->where('id', $id)->whereNull('deleted_at')->first();
abort_unless($record !== null, 404); 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 = []; $path = [];
$current = $record->primary_territory_id; $current = $record->primary_territory_id;
for ($i = 0; $current && $i < 5; $i++) { for ($i = 0; $current && $i < 5; $i++) {
@@ -93,7 +113,7 @@ class InvestmentTerritoryController extends Controller
$current = $location->parent_id; $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 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); 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']]); $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)'); 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(); $record = DB::table($table)->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($record !== null, 404); 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; $parent = null;
$names = []; $names = [];
foreach (array_values($data['path']) as $index => $locationId) { foreach (array_values($data['path']) as $index => $locationId) {
@@ -119,13 +157,26 @@ class InvestmentTerritoryController extends Controller
$names[] = $location->name; $names[] = $location->name;
} }
$version = $data['version'] + 1; $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()]; $changes = ['primary_territory_id' => $parent, 'territory_version' => $version, 'updated_at' => now()];
if (in_array($table, ['opportunities', 'investment_zones'], true)) { if (in_array($table, ['opportunities', 'investment_zones'], true)) {
$changes['province'] = $names[0] ?? null; $changes['province'] = $names[0] ?? null;
$changes['district'] = $names[1] ?? null; $changes['district'] = $names[1] ?? null;
} }
if ($table === 'investment_zones') { 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 { } else {
DB::table($table)->where('id', $id)->update($changes); DB::table($table)->where('id', $id)->update($changes);
} }
@@ -136,6 +187,8 @@ class InvestmentTerritoryController extends Controller
return $version; 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 App\Services\PublicSearchMetrics;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -291,6 +293,13 @@ class InvestmentZoneController extends Controller
return response()->json(app(InvestmentZoneOperationalProfile::class)->management($saved)); 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 public function revisions(string $id): JsonResponse
{ {
InvestmentZone::findOrFail($id); 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'])); 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 public function validateLocation(Request $request, InvestmentZones $zones, string $id): JsonResponse
{ {
InvestmentZoneAccess::requireRole($request->user(), 'territory_reviewers'); InvestmentZoneAccess::requireRole($request->user(), 'territory_reviewers');
@@ -3,6 +3,8 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\InvestmentZone; use App\Models\InvestmentZone;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; 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, DB::table('investment_zone_relations')->insert([...$data, 'id' => $relation, 'zone_id' => $id,
'editor_id' => $request->user()->id, 'created_at' => now(), 'updated_at' => now()]); 'editor_id' => $request->user()->id, 'created_at' => now(), 'updated_at' => now()]);
$this->audit($relation, $request->user()->id, 'created', 1); $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 public function review(Request $request, string $id, string $action): JsonResponse
{ {
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]); $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) { return DB::transaction(function () use ($request, $id, $action, $data) {
$relation = DB::table('investment_zone_relations')->where('id', $id)->lockForUpdate()->first(); $relation = DB::table('investment_zone_relations')->where('id', $id)->lockForUpdate()->first();
@@ -20,6 +20,16 @@ use Inertia\Response;
class InvestorPathController extends Controller 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 public function index(Request $request, InvestorPath $path): JsonResponse|Response
{ {
abort_unless($path->available(), 404); abort_unless($path->available(), 404);
@@ -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,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\InvestmentGeography;
use App\Services\LegacyContent; use App\Services\LegacyContent;
use App\Services\Malware\MalwareScanner; use App\Services\Malware\MalwareScanner;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess; use App\Services\PortalProfileAccess;
use App\Services\PublicDocumentPresentation; use App\Services\PublicDocumentPresentation;
use App\Services\PublicSearchMetrics; use App\Services\PublicSearchMetrics;
@@ -320,6 +321,9 @@ class PortalServicesController extends Controller
$editorial = $access->editorial($request->user()); $editorial = $access->editorial($request->user());
$resources = $editorial ? $this->filterManagedResources($access->editorialResources($request->user()), $managementFilters)->orderByDesc('r.updated_at') $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(); ->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'); $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'); $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) { $resources->each(function ($resource) use ($links, $revisions) {
@@ -328,7 +332,7 @@ class PortalServicesController extends Controller
}); });
return Inertia::render('portal/manage', [ 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' => [], 'documentAccessOnly' => ! $editorial, 'editorialScopeOnly' => $editorial, 'resources' => $resources, 'partnerships' => [], 'territories' => [],
'requirementCatalogue' => [], 'requirementAssociationsEnabled' => false, '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(), '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()); $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'); $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()); $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 = $scanner->health();
$malwareHealth['signatures_at'] = $malwareHealth['signatures_at']?->format(DATE_ATOM); $malwareHealth['signatures_at'] = $malwareHealth['signatures_at']?->format(DATE_ATOM);
return Inertia::render('portal/manage', ['resources' => $resources, 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() 'requirementCatalogue' => $this->requirementEvidenceEnabled()
? collect(is_array($catalogue = config('document-requirements.catalogue', [])) ? $catalogue : [])->map(fn ($item, $key) => ['key' => $key, ...$item])->values() ? collect(is_array($catalogue = config('document-requirements.catalogue', [])) ? $catalogue : [])->map(fn ($item, $key) => ['key' => $key, ...$item])->values()
: collect(), : collect(),
@@ -474,9 +481,15 @@ class PortalServicesController extends Controller
$duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->value('id') : null; $duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->value('id') : null;
$fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, true); $fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, true);
$this->assertPublicClassificationEligible($data, $document); $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()]); $id = DB::table('portal_resources')->insertGetId([...$fields, 'approved_by' => null, 'published' => false, 'created_at' => now(), 'updated_at' => now()]);
$this->syncResourceLinks($id, $data['associations'] ?? []); $this->syncResourceLinks($id, $data['associations'] ?? []);
$this->recordResourceRevision($id, 'created', $request->user()->id, 1); $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, 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'], '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()]); '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(); $resource = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
abort_unless($resource !== null, 404); abort_unless($resource !== null, 404);
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); 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(); $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($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); 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; $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); $fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, false);
$this->assertPublicClassificationEligible($data, $document); $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' $retainPublication = (bool) $resource->published && $data['rights_status'] === 'authorized'
&& $data['source_confirmation'] !== 'unconfirmed' && ! empty($data['source_url']) && $data['source_confirmation'] !== 'unconfirmed' && ! empty($data['source_url'])
&& (! ($data['legislation'] || $data['document_type'] === 'legislation') || $data['source_confirmation'] === 'official_confirmed') && (! ($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); $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 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.'); 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']]); $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'; $table = $type === 'resources' ? 'portal_resources' : 'portal_partnerships';
DB::transaction(function () use ($request, $data, $table, $type, $id) { DB::transaction(function () use ($request, $data, $table, $type, $id) {
abort_unless(DB::table($table)->where('id', $id)->lockForUpdate()->first() !== null, 404); 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 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') $event = DB::table('audit_events')->where('source_system', 'user-instruction')
->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1') ->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1')
->where('action', 'public_disclosure_authorized')->first(); ->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.']);
}
}
@@ -5,11 +5,32 @@ namespace App\Http\Controllers;
use App\Services\TerritorialRegistry; use App\Services\TerritorialRegistry;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class TerritorialRegistryController extends Controller 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 public function sources(Request $request): JsonResponse
{ {
$data = $request->validate(['limit' => ['nullable', 'integer', 'between:1,100'], 'cursor' => ['nullable', 'string', 'max:512']]); $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()); 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']]); $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']) ->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 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); $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 public function source(Request $request): JsonResponse
@@ -95,9 +129,15 @@ class TerritorialRegistryController extends Controller
public function transition(Request $request, TerritorialRegistry $registry, string $id): JsonResponse 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']]); '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) { return DB::transaction(function () use ($request, $registry, $id, $data) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)'); DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
$location = DB::table('geo_locations')->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first(); $location = DB::table('geo_locations')->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first();
@@ -57,6 +57,8 @@ class HandleInertiaRequests extends Middleware
return [ return [
...parent::share($request), ...parent::share($request),
'name' => config('app.name'), '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(), 'procurementEnabled' => app(Procurement::class)->available(),
'fundingEnabled' => app(FundingProgrammes::class)->installed(), 'fundingEnabled' => app(FundingProgrammes::class)->installed(),
'homologationReserved' => (bool) config('portal.homologation_only', false), 'homologationReserved' => (bool) config('portal.homologation_only', false),
@@ -23,6 +23,14 @@ class ProtectProfileOpportunity
} }
if ($request->user() && $id !== null && Schema::hasColumn('opportunities', 'owner_user_id')) { if ($request->user() && $id !== null && Schema::hasColumn('opportunities', 'owner_user_id')) {
$record = Opportunity::withTrashed()->find($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')) { 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.'); abort_unless(app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()), 403, 'O papel v1 não contorna os controlos através de um registo legado.');
} }
+10 -1
View File
@@ -2,6 +2,8 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Services\PortalProfileAccess;
use App\Services\TerritorialRegistry;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
@@ -24,9 +26,16 @@ class TerritorialRequest
} else { } else {
abort_unless($request->user()->can('manage-portal') && $request->user()->two_factor_confirmed_at && $request->user()->two_factor_secret, 403); 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')) { $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'; } : 'editors';
abort_unless(in_array(strtolower($request->user()->email), config('territorial.'.$role, []), true), 403, 'Função editorial não atribuída.'); 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', ''); $key = $request->header('Idempotency-Key', '');
abort_unless(Str::isUuid($key), 422, 'Idempotency-Key UUID obrigatório.'); abort_unless(Str::isUuid($key), 422, 'Idempotency-Key UUID obrigatório.');
$scope = $request->method().':'.$request->path(); $scope = $request->method().':'.$request->path();
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Jobs;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/** Existing email transport and operational allowlist; no confidential payload. */
final class NotifyPrivateOfferDecision implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $timeout = 30;
public function __construct(public string $noticeId) {}
/** @return list<int> */
public function backoff(): array
{
return [60, 300, 900];
}
public function handle(): void
{
if (! config('portal.notifications_enabled')) {
return;
}
$notice = DB::table('portal_editorial_notifications')->where('id', $this->noticeId)->first();
$user = $notice ? User::query()->whereKey($notice->user_id)->first() : null;
$record = $notice ? Opportunity::query()->whereKey($notice->opportunity_id)->first() : null;
if (! $notice || ! $user || ! $record?->isPrivateOfferPreparation()
|| ! in_array($user->email, config('portal.notification_recipients', []), true)
|| ! app(PortalEditorialWorkflow::class)->visibleNotice($user, $notice)) {
return;
}
$body = 'Existe uma actualização na revisão privada da oferta '.$record->reference.'. Consulte a decisão e a versão na área protegida: '.url('/minhas-oportunidades/ofertas/'.$record->id);
Mail::raw($body, fn ($message) => $message->to($user->email)->subject('Actualização da oferta — '.$record->reference));
}
}
+18
View File
@@ -30,6 +30,23 @@ class Opportunity extends Model
{ {
use SoftDeletes; 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 $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']; protected $appends = ['maturity_catalogue_version'];
@@ -115,6 +132,7 @@ class Opportunity extends Model
public function scopePublished(Builder $query): Builder public function scopePublished(Builder $query): Builder
{ {
if (Schema::hasColumn('opportunities', 'workflow_context')) { 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')); $query->where(fn ($q) => $q->whereNull('owner_user_id')->orWhere('workflow_context->publication->visibility', 'public'));
} }
+116 -5
View File
@@ -3,10 +3,13 @@
namespace App\Services; namespace App\Services;
use App\Models\EconomicCluster; use App\Models\EconomicCluster;
use App\Models\User;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
final class ClusterCompetitiveness final class ClusterCompetitiveness
@@ -46,6 +49,12 @@ final class ClusterCompetitiveness
public function management(string $cluster): array public function management(string $cluster): array
{ {
$state = $this->current($cluster); $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() $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'], ->map(fn ($event) => ['version' => json_decode($event->after_data, true)['version'],
'action' => $event->action, 'at' => $event->occurred_at, 'actor' => $event->actor_user_id])->all(); '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 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) { 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); $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.'); 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; $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); $state['draft'] = $this->validateDraft($input['draft'], $cluster->id);
} elseif ($action === 'restored') { } elseif ($action === 'restored') {
$event = $this->events($cluster->id)->where('after_data->version', $input['revision'])->first(); $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'); throw new \LogicException('Unsupported competitiveness action');
} }
$state['version']++; $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, 'entity_id' => $cluster->id, 'external_id' => $cluster->id.':'.$state['version'], 'actor_user_id' => $actor,
'action' => $action, 'data_classification' => 'restricted', 'action' => $action, 'data_classification' => 'restricted',
'before_data' => json_encode(['version' => $before['version']], JSON_THROW_ON_ERROR), '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'] ?? ''), 'reason' => Crypt::encryptString($input['reason'] ?? ''),
'snapshot' => Crypt::encryptString(json_encode($state, JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR)]); '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); return $this->management($cluster->id);
}); });
@@ -144,9 +163,101 @@ final class ClusterCompetitiveness
*/ */
public function publicData(string $cluster): ?array 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. // 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; return $approved ? ['draft' => $approved['draft'], 'version' => $approved['version'], 'at' => $approved['at']] : null;
} }
public function editorialEnabled(): bool
{
return (bool) config('editorial_workflow.cluster_competitiveness');
}
/** @param array<array-key,mixed>|null $draft */
private function editionHash(EconomicCluster $cluster, ?array $draft): string
{
$context = Arr::only($cluster->getAttributes(), ['id', 'code', 'name', 'sector', 'status', 'primary_territory_id', 'territory_version']);
return hash('sha256', json_encode(Arr::sortRecursive(['context' => $context, 'draft' => $draft]), JSON_THROW_ON_ERROR));
}
/** @param array<array-key,mixed> $input
* @param array<array-key,mixed> $state
* @return array<array-key,mixed>
*/
private function editorialChange(EconomicCluster $cluster, int $actorId, string $action, array $input, array $state): array
{
$actor = User::whereKey($actorId)->firstOrFail();
abort_unless($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403);
abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.');
if (! isset($state['workflow'])) {
abort_unless(in_array($action, ['saved', 'restored', 'withdrawn'], true), 409, 'Guarde uma edição privada antes de iniciar o novo circuito.');
// An accepted legacy publication remains public; no retrospective approval is invented.
$state['published'] = $state['approved'];
$state['approved'] = null;
$state['workflow'] = ['state' => 'draft', 'edition' => $state['version'], 'contributors' => [], 'approval' => null,
'hash' => $this->editionHash($cluster, $state['draft']), 'independent_required' => false];
}
$workflow = $state['workflow'];
if (in_array($action, ['saved', 'restored'], true)) {
if ($action === 'saved') {
$state['draft'] = $this->validateDraft($input['draft'], $cluster->id);
$restoredContributors = [];
} else {
$event = $this->events($cluster->id)->where('after_data->version', $input['revision'])->first();
abort_unless($event !== null, 404);
$restored = $this->decode($event);
$state['draft'] = $restored['draft'];
$restoredContributors = $restored['workflow']['contributors'] ?? [$event->actor_user_id];
}
$state['approved'] = null;
$state['workflow'] = ['state' => 'draft', 'edition' => $state['version'] + 1,
'contributors' => array_values(array_unique([...($workflow['state'] === 'published' ? [] : $workflow['contributors']), ...$restoredContributors, $actorId])),
'hash' => $this->editionHash($cluster, $state['draft']), 'approval' => null,
'independent_required' => $this->independentRequired($workflow)];
return $state;
}
validator($input, ['reason' => 'required|string|min:10|max:3000'])->validate();
$allowed = ['submitted' => ['draft', 'returned'], 'review_started' => ['submitted'], 'returned' => ['in_review'], 'rejected' => ['in_review'],
'approved' => ['in_review'], 'published' => ['approved'], 'archived' => ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved', 'published'], 'withdrawn' => ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved', 'published', 'archived']];
abort_unless(isset($allowed[$action]) && in_array($workflow['state'], $allowed[$action], true), 409, 'Transição incompatível com o estado da edição.');
if (in_array($action, ['submitted', 'approved', 'published'], true)) {
abort_unless(hash_equals($workflow['hash'], $this->editionHash($cluster, $state['draft'])), 409, 'O contexto do cluster mudou. Guarde nova edição e volte a conferir.');
}
if ($action === 'submitted' || $action === 'approved') {
$this->assertComplete($state['draft']);
}
if ($action === 'approved') {
abort_if($this->independentRequired($workflow) && in_array($actorId, $workflow['contributors'], true), 403, 'Aprovação independente necessária para esta edição.');
$workflow['approval'] = ['actor' => $actorId, 'edition' => $workflow['edition'], 'hash' => $workflow['hash'], 'at' => now()->toISOString(), 'reason' => $input['reason']];
$state['approved'] = ['draft' => $state['draft'], 'at' => now()->toISOString(), 'actor' => $actorId, 'version' => $state['version'] + 1, 'evidence' => $input['reason']];
}
if ($action === 'published') {
$decision = $workflow['approval'];
abort_unless($decision && $decision['edition'] === $workflow['edition'] && hash_equals($decision['hash'], $workflow['hash']), 422, 'Aprovação desta edição necessária.');
$reviewer = User::whereKey($decision['actor'])->first();
abort_unless($reviewer && $reviewer->can('manage-portal') && ! app(PortalProfileAccess::class)->has($reviewer, 'auditor'), 403, 'Autoridade da aprovação revogada.');
abort_if($this->independentRequired($workflow) && in_array($reviewer->id, $workflow['contributors'], true), 403, 'Aprovação independente necessária.');
$state['published'] = [...$state['approved'], 'at' => now()->toISOString(), 'version' => $state['version'] + 1];
}
if ($action === 'withdrawn' || ($action === 'archived' && $workflow['state'] === 'published')) {
$state['published'] = null;
}
$workflow['state'] = match ($action) {
'submitted' => 'submitted', 'review_started' => 'in_review', 'returned' => 'returned', 'rejected' => 'rejected',
'approved' => 'approved', 'published' => 'published', 'archived' => 'archived', 'withdrawn' => $workflow['state'] === 'published' ? 'archived' : $workflow['state'],
};
$state['workflow'] = $workflow;
return $state;
}
/** @param array<array-key,mixed> $workflow */
private function independentRequired(array $workflow): bool
{
return ($workflow['independent_required'] ?? false) || in_array('cluster_competitiveness', config('editorial_workflow.independent_approval.kinds', []), true);
}
} }
+74
View File
@@ -24,6 +24,15 @@ final class ContentManagement
$editor = $documents->full($actor) || $documents->editorial($actor); $editor = $documents->full($actor) || $documents->editorial($actor);
$upload = $editor || $documents->limited($actor); $upload = $editor || $documents->limited($actor);
$result = []; $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)) { if (app(Procurement::class)->available() && app(Procurement::class)->access($actor)) {
$result['procurement'] = ['index' => '/management/concursos', 'create' => '/management/concursos/novo']; $result['procurement'] = ['index' => '/management/concursos', 'create' => '/management/concursos/novo'];
} }
@@ -35,9 +44,74 @@ final class ContentManagement
$result['documents'] = ['index' => '/management/portal-services', 'edit' => $editor]; $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; 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> * @return array<array-key, mixed>
*/ */
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace App\Services;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class DemographicEvidence
{
public function territories(): Builder
{
$public = app(InvestmentGeography::class)->publicReferences();
return DB::table('geo_locations')->whereNull('deleted_at')->whereIn('location_type', ['province', 'district'])
->where(function ($q) use ($public) {
$q->whereIn('id', (clone $public)->select('id'));
if ($this->installed() && str_ends_with(DB::connection()->getDatabaseName(), '_test')) {
// Macate/Vanduzi: expressly authorised QA references, never active registry changes.
$q->orWhere(fn ($qa) => $qa->where('source_system', 'ine-demography-qa')->where('data_classification', 'public')->whereIn('parent_id', (clone $public)->select('id')));
}
});
}
public function installed(): bool
{
return (bool) config('demography.enabled', app()->environment('local', 'testing'))
&& Schema::hasTable('demographic_imports') && Schema::hasTable('demographic_observations');
}
/** @return array<string,string> */
public static function sectors(): array
{
return ['commerce' => 'Comércio e serviços', 'industry' => 'Indústria', 'agriculture' => 'Agricultura e agro-indústria', 'education' => 'Educação', 'health' => 'Saúde', 'general' => 'Contexto geral'];
}
/** @param list<string> $territories
* @return array<string,mixed> */
public function analyse(array $territories, int $start, int $end, string $sector = 'general', ?string $edition = null): array
{
$result = ['state' => 'unavailable', 'start_year' => $start, 'end_year' => $end, 'sector' => $sector, 'territories' => [], 'differences' => [], 'comparisons' => [],
'limitations' => ['População residente não mede procura efectiva ou poder de compra.', 'Idade activa não demonstra disponibilidade ou qualificação de mão-de-obra.',
'Sem extrapolação: anos sem série oficial permanecem sem dados.', 'A demografia não altera impostos, incentivos ou probabilidades de sucesso.']];
if (! $this->installed() || $territories === []) {
return $result;
}
$locations = $this->territories()->whereIn('id', $territories)->get(['id', 'name', 'location_type']);
$records = DB::table('demographic_observations as o')->join('demographic_imports as b', 'b.id', '=', 'o.import_id')
->where('b.status', 'approved')->whereIn('o.geo_location_id', $locations->pluck('id'))->whereBetween('o.year', [$start, $end])
->where('o.sex', 'all')->when($edition, fn ($q) => $q->where('b.id', $edition))
->orderBy('o.year')->orderBy('o.import_id')->orderBy('o.id')
->get(['o.*', 'b.source', 'b.publication_version', 'b.sha256', 'b.file_name']);
$issuesByImport = DB::table('demographic_imports')->whereIn('id', $records->pluck('import_id')->unique()->all())
->get(['id', DB::raw("report->'issue_counts' AS issue_counts")])->keyBy('id');
foreach ($locations as $location) {
$series = [];
foreach ($records->where('geo_location_id', $location->id)->groupBy('import_id') as $importId => $rows) {
$first = $rows->first();
$issueCounts = json_decode($issuesByImport->get($importId)->issue_counts ?? '{}', true, 512, JSON_THROW_ON_ERROR);
$values = $rows->filter(function ($r) use ($sector) {
if ($r->indicator !== 'population') {
return in_array($sector, ['health', 'general'], true);
}
return $r->age_group === 'all' || in_array($sector, ['education', 'health', 'industry', 'agriculture', 'general'], true);
})->map(fn ($r) => ['year' => $r->year, 'indicator' => $r->indicator, 'age_group' => $r->age_group, 'residence' => $r->residence,
'value' => $r->numeric_value, 'original_value' => $r->original_value, 'unit' => $r->unit, 'data_kind' => $r->data_kind,
'sheet' => $r->sheet, 'cell' => $r->cell, 'validation_status' => $r->validation_status])->values()->all();
$years = array_values(array_unique(array_column($values, 'year')));
$series[] = ['import_id' => $importId, 'source' => $first->source, 'version' => $first->publication_version, 'sha256' => $first->sha256, 'file_name' => $first->file_name,
'values' => $values, 'missing_years' => array_values(array_diff(range($start, $end), $years)), 'issue_counts' => $issueCounts];
}
$result['territories'][] = ['id' => $location->id, 'name' => $location->name, 'level' => $location->location_type, 'series' => $series,
'factors_to_assess' => match ($sector) {
'industry' => ['Estrutura etária e evolução da população; verificar qualificações, mobilidade e emprego.'],
'agriculture' => ['População rural e mercados locais; relacionar com os clusters documentados.'],
'education' => ['Grupos etários da população-alvo; verificar cobertura e capacidade dos serviços.'],
'health' => ['Estrutura etária e estatísticas vitais com unidades conhecidas; verificar cobertura dos serviços.'],
default => ['Dimensão e evolução da população; confrontar com rendimento, acesso e condições sectoriais.'],
}];
}
// Identify concurrent values without summing editions or choosing a supposedly newer one.
foreach ($result['territories'] as $territory) {
$byDimension = [];
foreach ($territory['series'] as $series) {
foreach ($series['values'] as $row) {
$key = implode('|', [$row['year'], $row['indicator'], $row['age_group'], $row['residence'], $row['unit']]);
$byDimension[$key][] = ['import_id' => $series['import_id'], 'value' => $row['value'], 'data_kind' => $row['data_kind']];
}
}
foreach ($byDimension as $key => $values) {
if (count($values) > 1) {
$result['differences'][] = ['territory_id' => $territory['id'], 'dimension' => $key, 'state' => count(array_unique(array_column($values, 'value'))) === 1 ? 'matching_values' : 'divergent_values', 'values' => $values];
}
}
}
$result['state'] = $records->isEmpty() ? 'no_data' : 'available';
foreach ($result['territories'] as $i => $left) {
foreach (array_slice($result['territories'], $i + 1) as $right) {
foreach ($left['series'] as $a) {
foreach ($right['series'] as $b) {
foreach (array_unique([$start, $end]) as $year) {
$isTotal = fn ($r) => $r['year'] === $year && $r['indicator'] === 'population' && $r['age_group'] === 'all' && $r['residence'] === 'all';
$x = array_values(array_filter($a['values'], $isTotal))[0] ?? null;
$y = array_values(array_filter($b['values'], $isTotal))[0] ?? null;
if (! $x || ! $y) {
continue;
}
$comparable = $left['level'] === $right['level'] && $x['unit'] === $y['unit'] && $x['data_kind'] === $y['data_kind'] && $a['version'] === $b['version'];
$delta = $comparable ? DB::selectOne('SELECT (?::numeric - ?::numeric)::text AS value', [$x['value'], $y['value']])->value : null;
$result['comparisons'][] = ['year' => $year, 'left' => $left['name'], 'right' => $right['name'], 'left_value' => $x['value'], 'right_value' => $y['value'],
'left_import' => $a['import_id'], 'right_import' => $b['import_id'], 'difference' => $delta,
'explanation' => $comparable ? 'Diferença dos totais de população no mesmo ano, nível, tipo de dado, unidade e identificação de edição. Não mede procura efectiva.' : 'Âmbitos ou metadados distintos; não se calculou uma diferença como se fossem equivalentes.'];
}
}
}
}
}
return $result;
}
/** @param array<string,mixed> $context
* @return array<string,mixed> */
public function forContext(array $context): array
{
$fields = $context['fields'] ?? [];
$id = ($fields['district_id'] ?? null) ?: ($fields['province_id'] ?? null);
// No inference from an influence area to a project's implantation territory.
return $this->analyse($id ? [$id] : [], (int) date('Y'), (int) date('Y'), 'general');
}
}
+283
View File
@@ -0,0 +1,283 @@
<?php
namespace App\Services;
use App\Models\User;
use App\Services\Malware\MalwareScanner;
use App\Support\CheckedFile;
use App\Support\IsolatedQaDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
final class DemographicImport
{
/**
* CLI preparation is read-only input, never an import or approval.
*
* @return array{preview:string,records:string,receipt:array<string,mixed>}|null
*/
public function preparedSource(string $sha, ?int $baselineYear, bool $verifyRecords = true): ?array
{
$root = config('demography.prepared_directory');
if (! is_string($root) || $root === '') {
return null;
}
abort_unless(preg_match('/^[a-f0-9]{64}$/', $sha) === 1, 422);
$directory = $root.'/'.$sha;
if (! is_file($directory.'/preparation.json')) {
return null;
}
abort_unless(is_readable($directory.'/preparation.json'), 409, 'Recibo CLI indisponível.');
try {
$receipt = json_decode(CheckedFile::read($directory.'/preparation.json'), true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
abort(409, 'Recibo CLI inválido.');
}
abort_unless(is_array($receipt), 409, 'Recibo CLI inválido.');
$preview = $directory.'/preview.json';
$records = $directory.'/records.ndjson';
abort_unless(($receipt['sha256'] ?? '') === $sha && array_key_exists('baseline_year', $receipt) && $receipt['baseline_year'] === $baselineYear
&& ($receipt['parser_sha256'] ?? '') === CheckedFile::sha256(base_path('tools/demography_workbook.py'))
&& is_file($preview) && CheckedFile::matches($preview, $receipt['preview_sha256'] ?? '') && is_file($records), 409, 'A preparação CLI mudou; conservar o original e preparar novamente fora do servidor web.');
try {
$report = json_decode(CheckedFile::read($preview), true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
abort(409, 'Prévia CLI inválida.');
}
abort_unless(is_array($report), 409, 'Prévia CLI inválida.');
abort_unless(($report['sha256'] ?? '') === $sha && ($report['record_count'] ?? null) === ($receipt['record_count'] ?? null)
&& ($report['records_file_sha256'] ?? '') === ($receipt['records_sha256'] ?? '')
&& (! $verifyRecords || CheckedFile::matches($records, $receipt['records_sha256'] ?? '')), 409, 'Resultados da preparação CLI alterados.');
return ['preview' => $preview, 'records' => $records, 'receipt' => $receipt];
}
/** @param array<string,array<string,mixed>> $files */
public function preparedSourcesReady(array $files): bool
{
try {
foreach ($files as $name => $expected) {
$source = $this->preparedSource($expected['sha256'], $name === 'Dados demograficos.xlsx' ? 2017 : null, false);
if ($source === null || $source['receipt']['record_count'] !== $expected['records']) {
return false;
}
}
return $files !== [];
} catch (\Throwable) {
return false;
}
}
public function authorize(User $actor): void
{
$access = app(PortalProfileAccess::class);
abort_unless(($actor->can('manage-portal') || $access->superuser($actor)) && ! $access->has($actor, 'auditor'), 403);
abort_unless($actor->two_factor_confirmed_at && $actor->two_factor_secret, 403, 'Confirme MFA para gerir dados demográficos.');
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
abort_unless(app(DemographicInstallation::class)->activeTarget() && app(DemographicEvidence::class)->installed(), 403, 'Destino demográfico não instalado ou não autorizado.');
abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Conclua MFA nesta sessão para gerir dados demográficos.');
}
}
/** @return array<string,mixed> */
public function prepare(User $actor, string $file, string $name, string $version, ?int $baselineYear): array
{
$this->authorize($actor);
abort_unless(is_file($file) && filesize($file) <= 20_000_000 && strtolower(pathinfo($name, PATHINFO_EXTENSION)) === 'xlsx', 422);
$sha = CheckedFile::sha256($file);
$existing = DB::table('demographic_imports')->where('sha256', $sha)->first();
if ($existing) {
return $this->preview($actor, $existing->id);
}
$id = (string) Str::uuid();
$directory = 'demography/'.$id;
$disk = Storage::disk('local');
$disk->makeDirectory($directory);
$path = $disk->path($directory.'/original.xlsx');
abort_unless(copy($file, $path) && CheckedFile::matches($path, $sha), 409);
$scan = app(MalwareScanner::class)->scan($path, (int) filesize($path), $disk->path($directory));
abort_unless($scan['result'] === 'clean', 423, 'Análise antimalware pendente ou não limpa; original privado conservado.');
$output = $disk->path($directory.'/preview.json');
$prepared = $this->preparedSource($sha, $baselineYear);
if ($prepared !== null) {
abort_unless(copy($prepared['preview'], $output) && CheckedFile::matches($output, $prepared['receipt']['preview_sha256'])
&& copy($prepared['records'], $disk->path($directory.'/records.ndjson'))
&& CheckedFile::matches($disk->path($directory.'/records.ndjson'), $prepared['receipt']['records_sha256']), 409, 'Preparação CLI alterada durante a cópia.');
} else {
abort_unless(function_exists('proc_open'), 503, 'Este servidor web não executa o preparador. Prepare este original pelo importador CLI e confira o respectivo recibo antes de continuar.');
$command = [config('demography.python', 'python'), base_path('tools/demography_workbook.py'), $path, '--output', $output, '--records-output', $disk->path($directory.'/records.ndjson')];
if ($baselineYear !== null) {
array_push($command, '--baseline-year', (string) $baselineYear);
}
$process = new Process($command, base_path(), timeout: 120);
$process->run();
abort_unless($process->isSuccessful(), 422, 'Não foi possível reconhecer o Excel; confira a estrutura e o ano de referência.');
}
$report = json_decode(CheckedFile::read($output), true, 512, JSON_THROW_ON_ERROR);
abort_unless(hash_equals($sha, $report['sha256']) && CheckedFile::matches($path, $sha), 409);
$summary = array_diff_key($report, ['records' => true]);
$summary['scan'] = ['result' => $scan['result'], 'engine' => $scan['engine'], 'engine_version' => $scan['engine_version'], 'sha256' => $sha];
// The supplied publication label is provenance metadata, never an invented edition/date.
DB::table('demographic_imports')->insert(['id' => $id, 'sha256' => $sha, 'file_name' => basename($name),
'source' => $report['source'], 'publication_version' => $version, 'status' => 'prepared',
'report' => json_encode($summary, JSON_THROW_ON_ERROR), 'preview_sha256' => CheckedFile::sha256($output),
'prepared_by' => $actor->id, 'created_at' => now(), 'updated_at' => now()]);
$this->event($id, $actor, 'prepared', 'Pré-visualização do original; sem aprovação de utilização.', ['sha256' => $sha]);
return $this->preview($actor, $id);
}
public static function key(string $province, string $territory, string $level): string
{
return $province.'|'.$territory.'|'.$level;
}
/** @return array<string,mixed> */
public function preview(User $actor, string $id): array
{
$this->authorize($actor);
$batch = DB::table('demographic_imports')->where('id', $id)->first();
abort_unless($batch !== null, 404);
$report = json_decode($batch->report, true, 512, JSON_THROW_ON_ERROR);
$locations = DB::table('geo_locations')->whereIn('location_type', ['province', 'district'])->whereNull('deleted_at')->get(['id', 'name', 'parent_id', 'location_type']);
$matches = [];
foreach ($report['territories'] as [$province, $territory, $level]) {
$provinces = $locations->filter(fn ($g) => $g->location_type === 'province' && $this->normalize($g->name) === $this->normalize($province));
$candidates = $level === 'province' ? $provinces : $locations->filter(fn ($g) => $g->location_type === 'district' && $provinces->contains('id', $g->parent_id) && $this->normalize($g->name) === $this->normalize($territory));
$key = self::key($province, $territory, $level);
if ($level === 'district') {
$headers = $report['territory_headers'][$key] ?? [];
if ($headers !== [] && $candidates->count() > 1 && array_all($headers, fn ($h) => preg_match('/^Distrito de /i', $h) === 1)) {
$candidates = $candidates->filter(fn ($g) => ! preg_match('/^CIDADE /i', $g->name));
}
if ($headers !== [] && $candidates->count() > 1 && array_all($headers, fn ($h) => preg_match('/^Cidade (de|da) /i', $h) === 1)) {
$candidates = $candidates->filter(fn ($g) => preg_match('/^CIDADE /i', $g->name) === 1);
}
$manifest = DemographicInstallation::remainingManifest();
$hashes = array_column($manifest['files'], 'sha256');
$alias = in_array($batch->sha256, $hashes, true) ? ($manifest['aliases'][$key] ?? null) : null;
if ($alias !== null) {
$candidates = $locations->filter(fn ($g) => $g->location_type === 'district' && $provinces->contains('id', $g->parent_id) && $this->literal($g->name) === $this->literal($alias));
}
}
$matches[] = ['key' => self::key($province, $territory, $level), 'province' => $province, 'territory' => $territory, 'level' => $level,
'geo_location_id' => $candidates->count() === 1 ? $candidates->first()->id : null, 'state' => $candidates->count() === 1 ? 'matched' : ($candidates->isEmpty() ? 'missing' : 'ambiguous')];
}
return ['id' => $id, 'file_name' => $batch->file_name, 'sha256' => $batch->sha256, 'publication_version' => $batch->publication_version,
'status' => $batch->status, 'report' => $report, 'matches' => $matches,
'territory_options' => $locations->values()->all(), 'events' => DB::table('demographic_events')->where('import_id', $id)->orderBy('id')->get()];
}
private function normalize(string $name): string
{
return preg_replace('/^(cidade|distrito) (de|da) /', '', $this->literal($name));
}
private function literal(string $name): string
{
$name = str_replace(['`', '’', '‘', '´'], "'", Str::ascii($name));
return preg_replace('/\s+/', ' ', trim(strtolower(str_replace(['-', '–'], ' ', $name))));
}
/** @param array<string,string> $mapping
* @return array<string,mixed> */
public function confirm(User $actor, string $id, string $sha, array $mapping, string $reason): array
{
$this->authorize($actor);
return DB::transaction(function () use ($actor, $id, $sha, $mapping, $reason) {
$batch = DB::table('demographic_imports')->where('id', $id)->lockForUpdate()->first();
abort_unless($batch && hash_equals($batch->sha256, $sha), 409);
if ($batch->status !== 'prepared') {
return ['id' => $id, 'status' => $batch->status, 'records' => DB::table('demographic_observations')->where('import_id', $id)->count(), 'repeated' => true];
}
$disk = Storage::disk('local');
$file = $disk->path('demography/'.$id.'/preview.json');
abort_unless(is_file($file) && CheckedFile::matches($file, $batch->preview_sha256) && CheckedFile::matches($disk->path('demography/'.$id.'/original.xlsx'), $sha), 409, 'Prévia ou original alterados.');
$report = json_decode(CheckedFile::read($file), true, 512, JSON_THROW_ON_ERROR);
$blocking = array_intersect(array_keys($report['issue_counts']), ['conflicting_value', 'duplicate_value', 'unsupported_sheet', 'unsupported_population_headers', 'reference_year_required', 'title_year_mismatch', 'invalid_numeric_or_formula']);
abort_if($blocking !== [], 422, 'Corrija os erros estruturais antes de confirmar: '.implode(', ', $blocking));
$plan = $this->preview($actor, $id);
$resolved = [];
foreach ($plan['matches'] as $match) {
$locationId = $mapping[$match['key']] ?? $match['geo_location_id'];
$location = DB::table('geo_locations')->where('id', $locationId)->whereNull('deleted_at')->first();
abort_unless($location && $location->location_type === $match['level'], 422, 'Correspondência territorial por resolver.');
if ($match['level'] === 'district') {
$provinceKey = self::key($match['province'], $match['province'], 'province');
$provinceMatch = array_values(array_filter($plan['matches'], fn ($m) => $m['key'] === $provinceKey))[0] ?? null;
abort_unless($location->parent_id === ($mapping[$provinceKey] ?? $provinceMatch['geo_location_id'] ?? null), 422, 'Distrito fora da província indicada.');
}
abort_if(in_array($locationId, $resolved, true), 422, 'Duas designações não podem ser fundidas na mesma localização.');
$resolved[$match['key']] = $locationId;
}
$chunk = [];
$recordCount = 0;
foreach ($this->recordRows($report, $disk->path('demography/'.$id.'/records.ndjson')) as $row) {
$recordCount++;
$chunk[] = ['import_id' => $id, 'geo_location_id' => $resolved[self::key($row['province'], $row['territory'], $row['level'])],
...array_intersect_key($row, array_flip(['year', 'indicator', 'sex', 'residence', 'age_group', 'unit', 'data_kind', 'original_value', 'numeric_value', 'sheet', 'cell'])),
'validation_status' => $report['issues'] === [] ? 'valid' : 'warning'];
if (count($chunk) === 500) {
DB::table('demographic_observations')->insert($chunk);
$chunk = [];
}
}
if ($chunk !== []) {
DB::table('demographic_observations')->insert($chunk);
}
abort_unless($recordCount === $report['record_count'], 409, 'O número de observações difere da prévia.');
DB::table('demographic_imports')->where('id', $id)->update(['status' => 'imported', 'updated_at' => now()]);
$this->event($id, $actor, 'imported', $reason, ['mapping' => $resolved, 'records' => $recordCount, 'sha256' => $sha]);
return ['id' => $id, 'status' => 'imported', 'records' => $recordCount, 'repeated' => false];
});
}
/** @param array<string,mixed> $report
* @return iterable<array<string,mixed>> */
private function recordRows(array $report, string $path): iterable
{
if (! isset($report['records_file_sha256'])) {
yield from $report['records'];
return;
}
abort_unless(is_file($path) && CheckedFile::matches($path, $report['records_file_sha256']), 409, 'As observações diferem da prévia conferida.');
$stream = new \SplFileObject($path, 'r');
while (! $stream->eof()) {
$line = $stream->fgets();
if (trim($line) === '') {
continue;
}
yield json_decode($line, true, 512, JSON_THROW_ON_ERROR);
}
}
public function transition(User $actor, string $id, string $action, string $reason): void
{
$this->authorize($actor);
DB::transaction(function () use ($actor, $id, $action, $reason) {
$batch = DB::table('demographic_imports')->where('id', $id)->lockForUpdate()->first();
abort_unless($batch !== null, 404);
abort_unless(in_array($action, ['approved', 'disabled'], true) && $batch->status !== 'prepared', 422);
abort_if($action === 'approved' && $batch->status !== 'approved' && app(PortalEditorialWorkflow::class)->demographyWrites($id), 409, 'Use a aprovação e publicação separadas do lote no circuito editorial.');
abort_if($action === 'disabled' && Schema::hasColumn('portal_editorial_editions', 'demographic_import_id') && DB::table('portal_editorial_editions')->where('demographic_import_id', $id)->exists(), 409, 'Arquive a edição no circuito editorial para retirar a sua publicação.');
// No deletes, replacement of other editions, or automatic publication in production.
DB::table('demographic_imports')->where('id', $id)->update(['status' => $action, 'updated_at' => now()]);
$this->event($id, $actor, $action, $reason, ['previous_status' => $batch->status]);
});
}
/** @param array<string,mixed> $details */
private function event(string $id, User $actor, string $action, string $reason, array $details): void
{
DB::table('demographic_events')->insert(['import_id' => $id, 'actor_id' => $actor->id, 'action' => $action, 'reason' => $reason, 'details' => json_encode($details, JSON_THROW_ON_ERROR), 'created_at' => now()]);
}
}
+307
View File
@@ -0,0 +1,307 @@
<?php
namespace App\Services;
use App\Models\User;
use App\Support\CheckedFile;
use App\Support\IsolatedQaDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Symfony\Component\Process\Exception\ExceptionInterface;
use Symfony\Component\Process\Process;
/** Owner-authorised release; schema and data still require existing session MFA. */
final class DemographicInstallation
{
public const MIGRATION = '2026_11_13_100000_create_demographic_series';
public const MANICA_SHA256 = '0c6d8feaefaed797fa57d61f923af5a86f37ac4b16f12d7d72ae1be8087ea698';
public const FILES = [
'Manica.xlsx' => ['sha256' => self::MANICA_SHA256, 'records' => 71604],
'Inhambane.xlsx' => ['sha256' => '51a9b91dfb6e32e5b49def115cb007f21faa97b3d0584b16ef911b22b89bd818', 'records' => 82620],
'Dados demograficos.xlsx' => ['sha256' => '4763aaa7476f3ae24564e9750ac2ac8babb07dfbba67473a180fb9b1f47eb335', 'records' => 482],
];
/** @return array<string,mixed> */
public static function remainingManifest(): array
{
return json_decode(CheckedFile::read(database_path('data/demography-remaining-20261113.json')), true, 512, JSON_THROW_ON_ERROR);
}
/** @return array<string,mixed> */
public static function zambeziaManifest(): array
{
return json_decode(CheckedFile::read(database_path('data/demography-zambezia-20261113.json')), true, 512, JSON_THROW_ON_ERROR);
}
/** @return list<string> */
public static function publicDistrictRefs(): array
{
$refs = [self::MANICA_SHA256.':Macate', self::MANICA_SHA256.':Vanduzi'];
foreach ([self::remainingManifest(), self::zambeziaManifest()] as $manifest) {
foreach ($manifest['new_districts'] as $province => $names) {
$source = array_values(array_filter($manifest['files'], fn ($f) => $f['province'] === $province))[0]['sha256'];
foreach ($names as $name) { $refs[] = $source.':'.$name; }
}
}
return $refs;
}
/** @return list<string> */
public function files(): array
{
return ['database/data/demography-zambezia-20261113.json', 'database/data/demography-remaining-20261113.json', 'app/Services/DemographicInstallation.php', 'app/Services/DemographicImport.php', 'app/Services/DemographicEvidence.php',
'app/Services/InvestmentGeography.php', 'app/Services/InvestmentPreparationWorkspace.php', 'app/Http/Controllers/DemographyController.php',
'database/migrations/'.self::MIGRATION.'.php', 'config/demography.php', 'routes/demography.php', 'routes/web.php', 'tools/demography_workbook.py',
'resources/js/components/demographic-context.tsx', 'resources/js/components/demographic-evidence.tsx', 'resources/js/components/simulation-form.tsx',
'resources/js/components/structure-geography.tsx', 'resources/js/pages/investment/demography.tsx', 'resources/js/pages/investment/catalogue.tsx',
'resources/js/pages/investment/detail.tsx', 'resources/js/pages/management/demography.tsx', 'resources/js/pages/management/demography-install.tsx',
'resources/js/pages/management/shared.tsx'];
}
public function fingerprint(): string
{
$files = [];
foreach ($this->files() as $path) {
$files[$path] = CheckedFile::sha256(base_path($path));
}
return hash('sha256', json_encode($files, JSON_THROW_ON_ERROR));
}
public function activeTarget(): bool
{
$c = config('database.connections.pgsql');
$homologation = config('app.url') === 'https://teste-investidor.tempo.co.mz'
&& config('demography.enabled', false) && $c['database'] === 'teste_investidor';
if (app()->environment('testing') || config('database.default') !== 'pgsql' || ! empty($c['url'])
|| ! in_array($c['host'], ['127.0.0.1', 'localhost'], true) || (int) $c['port'] !== 5432
|| ($c['database'] !== 'investemoz' && ! $homologation)) {
return false;
}
$target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
return $target->database === $c['database'] && (int) $target->port === 5432;
}
public function schemaAllowed(): bool
{
if (IsolatedQaDatabase::allowsSchemaChanges()) {
return true;
}
$actor = auth()->user();
if (! config('demography.authenticated_installation', false) || ! $actor || ! $this->activeTarget()) {
return false;
}
app(PortalProfileInstallation::class)->authorize($actor, true);
return true;
}
private function authorize(User $actor, bool $write): void
{
app(PortalProfileInstallation::class)->authorize($actor, $write);
abort_unless($this->activeTarget() || IsolatedQaDatabase::allowsSchemaChanges(), 403, 'Destino não autorizado para esta entrega.');
}
/** @return array<string,mixed> */
public function preview(User $actor): array
{
$this->authorize($actor, false);
$proofFile = storage_path('app/private/demography-deployment/readiness.json');
$proof = is_file($proofFile) ? json_decode(CheckedFile::read($proofFile), true, 512, JSON_THROW_ON_ERROR) : [];
$code = $this->fingerprint();
$target = (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$qa = IsolatedQaDatabase::allowsSchemaChanges();
$ready = $qa || (($proof['code_sha256'] ?? '') === $code && ($proof['qa_passed'] ?? false) && ($proof['compatibility_verified'] ?? false)
&& ($proof['restore_verified'] ?? false) && ($proof['files_verified'] ?? false) && ($proof['target']['database'] ?? '') === $target['database']
&& (int) ($proof['target']['port'] ?? 0) === (int) $target['port'] && ($proof['decision'] ?? '') !== ''
&& strtotime($proof['verified_at'] ?? '') >= time() - 86400 && strtotime($proof['verified_at'] ?? '') <= time());
if (! $qa) {
foreach (['dump', 'files'] as $kind) {
$file = $proof[$kind.'_path'] ?? '';
$ready = $ready && is_file($file) && CheckedFile::matches($file, $proof[$kind.'_sha256'] ?? '');
}
$ready = $ready && CheckedFile::matches(base_path('.env'), $proof['environment_sha256'] ?? '')
&& is_file(public_path('build/manifest.json')) && CheckedFile::matches(public_path('build/manifest.json'), $proof['release_manifest_sha256'] ?? '');
foreach ($proof['release_files'] ?? [] as $relative => $sha) {
$file = public_path('build/'.$relative);
$ready = $ready && ! str_contains($relative, '..') && ! str_contains($relative, ':') && is_file($file) && CheckedFile::matches($file, $sha);
}
}
$tables = array_map(fn ($t) => Schema::hasTable($t), ['demographic_imports', 'demographic_observations', 'demographic_events']);
$ledger = DB::table('migrations')->where('migration', self::MIGRATION)->exists();
$consistent = ($ledger && ! in_array(false, $tables, true)) || (! $ledger && ! in_array(true, $tables, true));
$preparedFiles = self::FILES + self::remainingManifest()['files'] + self::zambeziaManifest()['files'];
$runtimeReady = app(DemographicImport::class)->preparedSourcesReady($preparedFiles);
try {
if (! $runtimeReady && function_exists('proc_open')) {
$runtime = new Process([config('demography.python', 'python'), '-c', 'import sys; assert sys.version_info >= (3, 9)'], base_path(), timeout: 15);
$runtime->run();
$runtimeReady = $runtime->isSuccessful();
}
} catch (ExceptionInterface) {
$runtimeReady = false;
}
$mode = $ledger ? 'remaining' : 'initial';
$manifest = self::remainingManifest();
if ($ledger && DB::table('demographic_imports')->whereIn('sha256', array_column($manifest['files'], 'sha256'))->where('status', 'approved')->count() === count($manifest['files'])) { $mode = 'zambezia'; $manifest = self::zambeziaManifest(); }
$files = $mode !== 'initial' ? $manifest['files'] : self::FILES;
$already = $ledger ? DB::table('demographic_imports')->whereIn('sha256', array_column($files, 'sha256'))->where('status', 'approved')->pluck('sha256')->all() : [];
$pending = array_filter($files, fn ($f) => ! in_array($f['sha256'], $already, true));
$state = ['mode' => $mode, 'pending_count' => count($pending), 'aliases' => $mode !== 'initial' ? $manifest['aliases'] : [], 'alias_sources' => $mode !== 'initial' ? $manifest['sources'] : [], 'missing_province_source' => $manifest['missing_province_source'], 'target' => (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'),
'migration' => self::MIGRATION, 'code_sha256' => $code, 'backup_verified' => $ready, 'consistent' => $consistent,
'ready' => $ready && $consistent && $runtimeReady && count($pending) > 0, 'runtime_ready' => $runtimeReady, 'installed' => $ledger, 'files' => $files, 'new_districts' => $mode !== 'initial' ? array_merge(...array_values($manifest['new_districts'])) : ['Macate', 'Vanduzi'],
'mfa_confirmed' => app(PortalProfileAccess::class)->mfa($actor),
'notice' => ($runtimeReady ? '' : 'O interpretador do importador está indisponível; a execução permanece bloqueada. ').'Preservar seis advertências de Manica/2031, unidades ausentes e versões estatísticas. A instalação não corrige os originais nem concede permissões.'];
return [...$state, 'hash' => hash_hmac('sha256', json_encode([$actor->id, session()->getId(), $state], JSON_THROW_ON_ERROR), config('app.key'))];
}
/** @param list<string> $preservedHashes
* @return array<string,array{count:int,sha256:string}> */
private function protectedState(array $preservedHashes = []): array
{
$state = [];
foreach (['desk_cases', 'simulation_form_drafts', 'funding_programmes', 'funding_programme_versions', 'funding_programme_interests',
'labour_demands', 'training_capacities', 'opportunities', 'investment_dossiers', 'projects', 'economic_clusters'] as $table) {
if (Schema::hasTable($table)) {
$rows = DB::select('SELECT row_to_json(t)::text AS row FROM "'.$table.'" t ORDER BY row_to_json(t)::text');
$state[$table] = ['count' => count($rows), 'sha256' => hash('sha256', implode("\n", array_map(fn ($r) => $r->row, $rows)))];
}
}
if (Schema::hasTable('demographic_imports')) {
$ids = DB::table('demographic_imports')->whereIn('sha256', $preservedHashes ?: array_column(self::FILES, 'sha256'))->pluck('id');
foreach (['demographic_imports' => 'id', 'demographic_observations' => 'import_id', 'demographic_events' => 'import_id'] as $table => $key) {
$digest = hash_init('sha256');
$count = 0;
DB::table($table)->whereIn($key, $ids)->chunkById(500, function ($rows) use ($digest, &$count) {
foreach ($rows as $row) {
if ($count > 0) { hash_update($digest, "\n"); }
hash_update($digest, json_encode($row, JSON_THROW_ON_ERROR));
$count++;
}
});
$state['accepted_'.$table] = ['count' => $count, 'sha256' => hash_final($digest)];
}
}
return $state;
}
private function territories(User $actor): void
{
$province = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->whereRaw('lower(name)=?', ['manica'])->get(['id']);
abort_unless($province->count() === 1, 422, 'Província de Manica ambígua ou indisponível.');
foreach (['Macate', 'Vanduzi'] as $name) {
$matches = DB::table('geo_locations')->where('parent_id', $province->first()->id)->where('location_type', 'district')->whereRaw('lower(trim(name))=?', [strtolower($name)])->get();
if ($matches->isNotEmpty()) {
abort_unless($matches->count() === 1 && $matches->first()->deleted_at === null, 409, 'Cadastro territorial divergente; não restaurar nem fundir automaticamente.');
continue;
}
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $province->first()->id, 'name' => $name, 'location_type' => 'district',
'code' => 'INE-DEMOGRAPHY-'.strtoupper($name), 'source_system' => 'ine-demography', 'external_id' => self::MANICA_SHA256.':'.$name,
'data_classification' => 'public', 'version_no' => 1, 'created_at' => now(), 'updated_at' => now()]);
app(PortalProfileAccess::class)->audit($actor, 'demography_territory_created', ['territory_id' => $id, 'name' => $name,
'source_sha256' => self::MANICA_SHA256, 'decision' => 'Autorização expressa do proprietário em 13/11/2026; destino conferido pelo instalador.',
'scope' => 'Identificação territorial da série INE; sem limites geográficos ou direitos territoriais presumidos.']);
}
}
/** @param array<string,mixed> $manifest */
private function remainingTerritories(User $actor, array $manifest): void
{
foreach ($manifest['new_districts'] as $province => $names) {
$parents = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->whereRaw('lower(name)=?', [mb_strtolower($province)])->pluck('id');
abort_unless($parents->count() === 1, 409);
$source = array_values(array_filter($manifest['files'], fn ($f) => $f['province'] === $province))[0]['sha256'];
foreach ($names as $name) {
$existing = DB::table('geo_locations')->where('parent_id', $parents[0])->where('location_type', 'district')->whereRaw('lower(trim(name))=?', [mb_strtolower($name)])->get();
if ($existing->isNotEmpty()) { abort_unless($existing->count() === 1 && $existing[0]->deleted_at === null, 409); continue; }
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parents[0], 'name' => $name, 'location_type' => 'district', 'code' => 'INE-DEMOGRAPHY-'.strtoupper($name), 'source_system' => 'ine-demography', 'external_id' => $source.':'.$name, 'data_classification' => 'public', 'version_no' => 1]);
app(PortalProfileAccess::class)->audit($actor, 'demography_territory_created', ['territory_id' => $id, 'name' => $name, 'source_sha256' => $source, 'basis' => $manifest['authority']]);
}
}
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function apply(User $actor, array $input): array
{
$this->authorize($actor, true);
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'operation_key' => 'required|uuid',
'mode' => 'nullable|in:initial,remaining,zambezia', 'confirm' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000'])->validate();
$mode = $data['mode'] ?? 'initial';
$files = $mode === 'initial' ? self::FILES : ($mode === 'zambezia' ? self::zambeziaManifest()['files'] : self::remainingManifest()['files']);
$preservedHashes = $mode === 'zambezia' ? array_column([...self::FILES, ...self::remainingManifest()['files']], 'sha256') : array_column(self::FILES, 'sha256');
$action = $mode === 'zambezia' ? 'extend_zambezia' : ($mode === 'remaining' ? 'extend_provinces' : 'install_and_import');
$payload = hash('sha256', json_encode($mode === 'initial' ? [$actor->id, $data['reason']] : [$actor->id, $data['reason'], $mode, $files], JSON_THROW_ON_ERROR));
$existing = DB::table('audit_events')->where('source_system', 'demography-installation')->where('action', $action)->where('correlation_id', $data['operation_key'])->first();
if ($existing) {
$event = json_decode($existing->after_data, true, 512, JSON_THROW_ON_ERROR);
abort_unless((int) $existing->actor_user_id === $actor->id && hash_equals($event['payload_sha256'], $payload), 409);
return [...$event['result'], 'repeated' => true];
}
$plan = $this->preview($actor);
abort_unless($plan['ready'] && $plan['mode'] === $mode && hash_equals($plan['hash'], $data['hash']), 409, 'Consulte a prévia actual; código, destino ou provas mudaram.');
set_time_limit(300);
return DB::transaction(function () use ($actor, $data, $payload, $mode, $files, $action, $preservedHashes) {
DB::select('SELECT pg_advisory_xact_lock(131126, 100000)');
// Recheck after the lock as another request may have completed while we waited.
$existing = DB::table('audit_events')->where('source_system', 'demography-installation')->where('action', $action)->where('correlation_id', $data['operation_key'])->first();
if ($existing) {
$event = json_decode($existing->after_data, true, 512, JSON_THROW_ON_ERROR);
abort_unless((int) $existing->actor_user_id === $actor->id && hash_equals($event['payload_sha256'], $payload), 409);
return [...$event['result'], 'repeated' => true];
}
$before = $this->protectedState($preservedHashes);
$previous = config('demography.authenticated_installation');
config(['demography.authenticated_installation' => true]);
try {
if ($mode === 'initial') {
abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]) === 0, 500, 'Falha ao instalar a estrutura demográfica.');
} else { abort_unless(Schema::hasTable('demographic_observations'), 409); }
} finally {
config(['demography.authenticated_installation' => $previous]);
}
if ($mode === 'initial') { $this->territories($actor); } else { $this->remainingTerritories($actor, $mode === 'zambezia' ? self::zambeziaManifest() : self::remainingManifest()); }
$receipts = [];
$directory = config('demography.source_directory', 'C:/Mac/Home/Documents/TEMPO/Portal Tempo/Servicos Tempo/portal do investidor/demografia/Provincias');
$service = app(DemographicImport::class);
foreach ($files as $name => $expected) {
$old = DB::table('demographic_imports')->where('sha256', $expected['sha256'])->first();
if ($old && $old->status === 'approved') {
$count = DB::table('demographic_observations')->where('import_id', $old->id)->count();
abort_unless($count === $expected['records'], 409);
$receipts[] = ['file' => $name, 'sha256' => $expected['sha256'], 'id' => $old->id, 'records' => $count, 'preserved' => true];
continue;
}
$file = $directory.'/'.$name;
abort_unless(is_file($file) && CheckedFile::matches($file, $expected['sha256']), 409, 'O exemplar aprovado mudou: '.$name);
$plan = $service->prepare($actor, $file, $name, 'Exemplar INE fornecido pela Tempo; edição não indicada; SHA256 identifica a versão', $name === 'Dados demograficos.xlsx' ? 2017 : null);
$receipt = $service->confirm($actor, $plan['id'], $expected['sha256'], [], $data['reason']);
abort_unless($receipt['records'] === $expected['records'], 409, 'Contagem divergente da homologação: '.$name);
$service->transition($actor, $plan['id'], 'approved', $data['reason']);
$receipts[] = ['file' => $name, 'sha256' => $expected['sha256'], 'id' => $plan['id'], 'records' => $receipt['records']];
}
abort_unless($before === ($mode === 'initial' ? array_filter($this->protectedState($preservedHashes), fn ($key) => ! str_starts_with($key, 'accepted_'), ARRAY_FILTER_USE_KEY) : $this->protectedState($preservedHashes)), 409, 'Estado protegido alterado; instalação revertida.');
$result = ['installed' => true, 'target' => (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'),
'imports' => $receipts, 'protected_state' => $before, 'record_count' => array_sum(array_column($receipts, 'records')), 'at' => now()->toIso8601String()];
DB::table('audit_events')->insert(['source_system' => 'demography-installation', 'entity_type' => 'demographic_series',
'actor_user_id' => $actor->id, 'action' => $action, 'correlation_id' => $data['operation_key'], 'data_classification' => 'restricted',
'after_data' => json_encode(['payload_sha256' => $payload, 'reason' => $data['reason'], 'result' => $result], JSON_THROW_ON_ERROR)]);
return $result;
});
}
}
+12 -3
View File
@@ -3,11 +3,14 @@
namespace App\Services; namespace App\Services;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** Combines independent, already-authorised sources without loading complete catalogues. */ /** Combines independent, already-authorised sources without loading complete catalogues. */
final class FederatedSearch final class FederatedSearch
{ {
/** @param array<string, callable(): array{count:int, fetch:callable(int,int): iterable}> $factories */ /** @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 public function page(array $factories, int $requestedPage, int $size = 5, ?callable $score = null): array
{ {
$sources = []; $sources = [];
@@ -15,7 +18,10 @@ final class FederatedSearch
foreach ($factories as $label => $factory) { foreach ($factories as $label => $factory) {
try { try {
$sources[$label] = $factory(); $sources[$label] = $factory();
} catch (QueryException $exception) { } catch (QueryException|HttpException $exception) {
if ($exception instanceof HttpException && ! in_array($exception->getStatusCode(), [404, 503], true)) {
throw $exception;
}
report($exception); report($exception);
$unavailable[] = $label; $unavailable[] = $label;
} }
@@ -45,7 +51,10 @@ final class FederatedSearch
if ($batch === []) { if ($batch === []) {
$positions[$label] = $source['count']; $positions[$label] = $source['count'];
} }
} catch (QueryException $exception) { } catch (QueryException|HttpException $exception) {
if ($exception instanceof HttpException && ! in_array($exception->getStatusCode(), [404, 503], true)) {
throw $exception;
}
report($exception); report($exception);
$unavailable[] = $label; $unavailable[] = $label;
unset($sources[$label]); unset($sources[$label]);
+4 -4
View File
@@ -21,7 +21,7 @@ class InvestmentCatalogue
'projects' => ['project_id', 'projects'], 'locations' => ['geo_location_id', 'geo_locations']]; 'projects' => ['project_id', 'projects'], 'locations' => ['geo_location_id', 'geo_locations']];
/** @return array<string,mixed> */ /** @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'], $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'))], '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'], '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'], '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'], 'maturity_level' => ['nullable', 'integer', 'between:1,8'], 'facets' => ['nullable', 'array', 'max:20'], 'facets.*' => ['integer', 'min:1'],
'min_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999'], 'min_amount' => $offerPrices ? ['nullable', 'string', 'regex:/^\d{1,16}(\.\d{1,2})?$/'] : ['nullable', 'numeric', 'min:0', 'max:999999999999'],
'max_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999', ...($request->filled('min_amount') ? ['gte:min_amount'] : [])], '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', 'in:MZN,USD,EUR,ZAR'], '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'], '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_type' => ['nullable', 'in:'.implode(',', array_keys(OpportunityClassification::TARGETS))],
'association_target' => ['nullable', 'string', 'max:80'], 'association_target' => ['nullable', 'string', 'max:80'],
+4
View File
@@ -138,6 +138,10 @@ final class InvestmentGeography
$query->where(function ($workbook) { $query->where(function ($workbook) {
$workbook->where('source_system', 'cartografia-workbook') $workbook->where('source_system', 'cartografia-workbook')
->where('external_id', 'like', '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:%'); ->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')); })->orWhereIn('id', app(TerritorialRegistry::class)->published()->select('g.id'));
}); });
} }
+1
View File
@@ -58,6 +58,7 @@ final class InvestmentMapIndicators
'zone_id' => $zone->id, 'zone_name' => $zone->name, 'zone_version' => (int) $zone->version_no, 'url' => '/zonas/'.$zone->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', 'theme' => $row['indicator'] === 'employment' ? 'workforce' : 'economic_potential',
'value' => $value, 'knowledge' => $knowledge, 'missing_metadata' => $missing, 'value' => $value, 'knowledge' => $knowledge, 'missing_metadata' => $missing,
'measure_kind' => in_array($row['measure_kind'] ?? null, ['observed', 'planned', 'projected'], true) ? $row['measure_kind'] : 'unknown',
]; ];
} }
} }
@@ -127,6 +127,11 @@ final class InvestmentPreparationWorkspace
{ {
$objects = [['key' => 'investment:'.$case->id, 'label' => $case->title, 'kind' => 'investment']]; $objects = [['key' => 'investment:'.$case->id, 'label' => $case->title, 'kind' => 'investment']];
$evidence = []; $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) { 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']] ?? '')]; $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.']; $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.'];
@@ -275,6 +280,24 @@ final class InvestmentPreparationWorkspace
return collect($this->rows($this->current($id, $scope)['swot']['actions']))->firstWhere('id', $actionId); 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> */ /** @return list<string> */
public function taskKeys(User $actor, string $id, string $opportunity): array public function taskKeys(User $actor, string $id, string $opportunity): array
{ {
@@ -18,7 +18,7 @@ class InvestmentZoneIntegralDetails
'phases' => ['code', 'status', 'starts_at', 'ends_at', 'expansion'], 'phases' => ['code', 'status', 'starts_at', 'ends_at', 'expansion'],
'plots' => ['code', 'phase_id', 'area', 'area_unit', 'status', 'geometry_id'], 'plots' => ['code', 'phase_id', 'area', 'area_unit', 'status', 'geometry_id'],
'occupants' => ['organization_id', 'documentary_name', 'phase_id', 'plot_id', 'status', 'valid_from', 'valid_until'], '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'], '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['titles.*.kind'] = 'nullable|in:creation,land_use,licence,special_regime,other';
$rules['chains.*.kind'] = 'nullable|in:anchor,supplier,unmet_need,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.*.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(); $data = Validator::make($input, $rules)->validate();
$result = $before; $result = $before;
foreach (self::GROUPS as $group) { foreach (self::GROUPS as $group) {
@@ -148,7 +149,7 @@ class InvestmentZoneIntegralDetails
foreach ($result['observations'] as $item) { foreach ($result['observations'] as $item) {
if (! isset($item['period_start'],$item['period_end'])) { if (! isset($item['period_start'],$item['period_end'])) {
continue; 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.'); 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; $keys[] = $key;
} }
@@ -3,11 +3,14 @@
namespace App\Services; namespace App\Services;
use App\Models\InvestmentZone; use App\Models\InvestmentZone;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** Editorial preparation in the existing master. No operational/land entitlement is inferred. */ /** Editorial preparation in the existing master. No operational/land entitlement is inferred. */
class InvestmentZoneOperationalProfile 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', 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', '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']; 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'); 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 * @return array<array-key, mixed>|null
*/ */
@@ -60,8 +216,15 @@ class InvestmentZoneOperationalProfile
public function management(InvestmentZone $zone): array public function management(InvestmentZone $zone): array
{ {
$draft = $this->draft($zone); $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, '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(), 'has_private_draft' => $draft !== null, 'profile_available' => $this->installed(),
'operational_details' => ($draft['fields']['operational_details'] ?? app(InvestmentZoneOperationalDetails::class)->recover($zone)), '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 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($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.'); 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); $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, $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()]; '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))]); 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]); $this->snapshot($zone, $draft['working_version'], $actor, 'profile_prepared', ['public' => [...$zone->toArray(), 'operational_details' => $details->stored($zone)], 'draft' => $draft]);
return $zone->fresh(); return $zone->fresh();
} }
/** Apply only inside the publication transaction; a failed validation rolls everything back. */ /** Apply only inside the publication transaction; a failed validation rolls everything back.
public function promote(InvestmentZone $zone, int $version): void * @return array<array-key,mixed>|null
*/
public function promote(InvestmentZone $zone, int $version, ?int $actor = null): ?array
{ {
$draft = $this->draft($zone); $draft = $this->draft($zone);
abort_unless($version === $this->workingVersion($zone), 409, 'A preparação mudou; recupere a versão atual antes de publicar.'); abort_unless($version === $this->workingVersion($zone), 409, 'A preparação mudou; recupere a versão atual antes de publicar.');
if (! $draft) { 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.'); 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))); $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($key, $this->publicDetails([$key => $zone->$key ?? []])[$key]);
} }
$zone->setAttribute('editorial_draft', null); $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 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); $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_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.'); 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['working_version'] = $this->nextVersion($zone);
$draft['actor_id'] = $actor; $draft['actor_id'] = $actor;
$draft['prepared_at'] = now()->toIso8601String(); $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))]); 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]); $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); $hash = hash('sha256', $json);
DB::table('investment_zone_revisions')->insert(['id' => (string) Str::uuid(), 'zone_id' => $zone->id, 'version_no' => $version, 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]); '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, $eventId = (string) Str::uuid();
'actor_user_id' => $actor, 'action' => $action, 'after_data' => json_encode(['version_no' => $version, 'sha256' => $hash], JSON_THROW_ON_ERROR)]); $metadata = ['version_no' => $version, 'sha256' => $hash];
if ($this->editorialEnabled()) {
$metadata += ['editorial_workflow' => true, 'editorial_state' => $data['draft']['editorial']['state'] ?? ($action === 'published' ? 'published' : null)];
}
DB::table('audit_events')->insert(['id' => $eventId, 'source_system' => 'investment-zones', 'entity_type' => 'investment_zones', 'entity_id' => $zone->id,
'actor_user_id' => $actor, 'action' => $action, 'after_data' => json_encode($metadata, JSON_THROW_ON_ERROR)]);
if ($this->editorialEnabled()) {
app(PortalEditorialNativeNotices::class)->emit(User::findOrFail($actor), $eventId, $data['draft']['editorial']['contributors'] ?? $data['decision']['contributors'] ?? []);
}
} }
} }
+28 -10
View File
@@ -96,9 +96,14 @@ class InvestmentZones
{ {
return DB::transaction(function () use ($id, $data, $actor) { return DB::transaction(function () use ($id, $data, $actor) {
$zone = $id ? InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail() : new InvestmentZone; $zone = $id ? InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail() : new InvestmentZone;
if ($id && $zone->category === 'industrial_park') { $profile = app(InvestmentZoneOperationalProfile::class);
$prepared = app(InvestmentZoneOperationalProfile::class)->prepare($zone, $data, $actor); $profile->ensureWriteMode($zone);
if ($profile->editorialEnabled()) {
$profile->authorizeWrite($actor);
}
if ($id && ($zone->category === 'industrial_park' || $profile->editorialEnabled())) {
$this->storePendingLocations($zone, $data['locations'] ?? [], $actor); $this->storePendingLocations($zone, $data['locations'] ?? [], $actor);
$prepared = app(InvestmentZoneOperationalProfile::class)->prepare($zone, $data, $actor);
return $prepared; return $prepared;
} }
@@ -128,8 +133,10 @@ class InvestmentZones
return DB::transaction(function () use ($id, $version, $actor) { return DB::transaction(function () use ($id, $version, $actor) {
$zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail(); $zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();
$profile = app(InvestmentZoneOperationalProfile::class); $profile = app(InvestmentZoneOperationalProfile::class);
if ($zone->category === 'industrial_park') { $profile->ensureWriteMode($zone);
$profile->promote($zone, $version); $proof = null;
if ($zone->category === 'industrial_park' || $profile->editorialEnabled()) {
$proof = $profile->promote($zone, $version, $actor);
} else { } else {
abort_if((int) $zone->version_no !== $version, 409, 'Versão alterada. Recarregue antes de aprovar.'); 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') ->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(); ->whereIn('g.id', app(InvestmentGeography::class)->publicReferences()->select('id'))->exists();
abort_unless($publicLocation, 422, 'Seleccione uma província ou distrito existente e público.'); 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(); 'validation_status' => 'validated', 'published_at' => now()])->save();
$this->snapshot($zone, $actor, 'published'); $this->snapshot($zone, $actor, 'published', $proof);
return $zone; return $zone;
}); });
@@ -171,11 +178,16 @@ class InvestmentZones
return DB::transaction(function () use ($id, $version, $actor) { return DB::transaction(function () use ($id, $version, $actor) {
$zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail(); $zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();
$profile = app(InvestmentZoneOperationalProfile::class); $profile = app(InvestmentZoneOperationalProfile::class);
abort_if(($zone->category === 'industrial_park' ? $profile->workingVersion($zone) : (int) $zone->version_no) !== $version, 409, 'Versão alterada.'); $profile->ensureWriteMode($zone);
$zone->fill(['version_no' => $zone->category === 'industrial_park' ? $profile->nextVersion($zone) : $version + 1, 'publication_status' => 'suspended'])->save(); 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'); $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); $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, DB::table('investment_zone_revisions')->insert(['id' => (string) Str::uuid(), 'zone_id' => $zone->id,
'version_no' => $zone->version_no, 'actor_id' => $actor, 'action' => $action, 'version_no' => $zone->version_no, 'actor_id' => $actor, 'action' => $action,
+11
View File
@@ -193,6 +193,17 @@ final class InvestorContext
$href = $published ? '/oportunidades/'.$o->slug : '/minhas-oportunidades/'.$o->id.'/view'; $href = $published ? '/oportunidades/'.$o->slug : '/minhas-oportunidades/'.$o->id.'/view';
$version = $o->management_version; $version = $o->management_version;
$fields = ['project_title' => $title, 'project_summary' => $draft['canonical']['executive_summary'] ?? $o->summary]; $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) { if ($o->sector) {
$fields['sector_id'] = (string) $o->sector_id; $fields['sector_id'] = (string) $o->sector_id;
$fields['sector'] = $o->sector->name; $fields['sector'] = $o->sector->name;
+12
View File
@@ -8,6 +8,7 @@ use App\Models\SimulationFormDraft;
use App\Models\User; use App\Models\User;
use App\Support\StructuredData; use App\Support\StructuredData;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
@@ -171,6 +172,17 @@ final class InvestorPath
'next' => $next ? ['code' => $next['code'], 'title' => $next['title'], 'action' => 'Preparar', 'href' => $next['href'] ?? '#'.$next['code']] : null, '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, 'investment_context' => ($owner || $manager) ? [...$context, 'opportunity_choices' => $opportunity ? $context['opportunity_choices'] : $allChoices] : null,
'results' => $results, 'requests' => $requests, 'documents' => $documents, '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'] ?? []) : [], 'zone_contexts' => ($owner || $manager) ? ($path['zone_contexts'] ?? []) : [],
'sector_contexts' => ($owner || $manager) ? ($path['sector_contexts'] ?? []) : [], 'sector_contexts' => ($owner || $manager) ? ($path['sector_contexts'] ?? []) : [],
'sector_references' => ($owner || $manager) ? ($path['sector_references'] ?? []) : [], 'sector_references' => ($owner || $manager) ? ($path['sector_references'] ?? []) : [],
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use Carbon\CarbonImmutable;
/** Read-time exclusion never depends on the scheduler or the NDA. */
final class OfferAvailability
{
/** @return array{state:string,expires_at:?string,policy_pending:bool} */
public function view(mixed $operation, mixed $publication): array
{
// Legacy public snapshots without an editorial hash retain their first frozen policy.
$key = is_array($publication) ? ($publication['editorial_hash'] ?? '') : null;
$key ??= is_array($operation) ? ($operation['last_approval_hash'] ?? '') : '';
if (! is_string($key)) {
return ['state' => 'unavailable', 'expires_at' => null, 'policy_pending' => true];
}
$policy = is_array($operation) ? ($operation['validity_policies'][$key] ?? $operation['validity'] ?? null) : null;
$pending = ! is_array($policy) || ($policy['days'] ?? null) === null;
$result = ['state' => 'available', 'expires_at' => null, 'policy_pending' => $pending];
if ($operation !== null && ! is_array($operation)) {
return [...$result, 'state' => 'unavailable'];
}
$state = $operation['status'] ?? 'available';
if (! is_int($operation['cycle'] ?? 0) || ($operation['cycle'] ?? 0) < 0) {
return [...$result, 'state' => 'unavailable'];
}
if (! in_array($state, ['available', 'withdrawn', 'expired', 'revalidation'], true)) {
return [...$result, 'state' => 'unavailable'];
}
if ($state !== 'available') {
return [...$result, 'state' => $state];
}
$days = $policy['days'] ?? null;
if (($operation['cycle'] ?? 0) > 0 && ($publication['offer_cycle'] ?? null) !== $operation['cycle']) {
return $result;
}
if ($days === null || ! is_array($publication) || ($publication['publication']['state'] ?? null) !== 'published') {
return $result;
}
if (! is_int($days) || $days < 1 || $days > 36500) {
return [...$result, 'state' => 'unavailable'];
}
try {
$at = $publication['publication']['at'] ?? null;
if (! is_string($at) || ! preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/D', $at)) {
return [...$result, 'state' => 'unavailable'];
}
$start = CarbonImmutable::parse($at);
$end = $start->addDays($days)->utc();
} catch (\Throwable) {
return [...$result, 'state' => 'unavailable'];
}
return [...$result, 'expires_at' => $end->toIso8601String(), 'state' => $end->lte(CarbonImmutable::now()) ? 'expired' : 'available'];
}
/** @return array{state:string,expires_at:?string,policy_pending:bool} */
public function record(Opportunity $record): array
{
return $this->view($record->workflow_context['offer_operation'] ?? null, $record->workflow_context['offer_publication'] ?? null);
}
public function publicAllowed(mixed $operation, mixed $publication): bool
{
if ($this->view($operation, $publication)['state'] !== 'available') {
return false;
}
$cycle = $operation['cycle'] ?? 0;
if (! is_int($cycle) || $cycle < 0) {
return false;
}
// A new validation must never resurrect the previous public snapshot.
return $cycle === 0 || (is_array($publication) && ($publication['offer_cycle'] ?? null) === $cycle
&& is_string($operation['activated_hash'] ?? null) && ($publication['editorial_hash'] ?? null) === $operation['activated_hash']);
}
}
+250
View File
@@ -0,0 +1,250 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Support\IsolatedQaDatabase;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/** Read-only consumer. Effective publication remains unavailable until confidentiality is delivered.
* The injected fixture mode is never configurable by HTTP, environment flags or stored content. */
final class OfferPublicCatalogue
{
public const CONTRACT = 'offer-public-projection-v1';
public function __construct(private bool $syntheticFixtureMode = false) {}
private function fixtureReadAllowed(): bool
{
return $this->syntheticFixtureMode && IsolatedQaDatabase::allowsSchemaChanges();
}
/** Exact ordering of decimal strings, without currency conversion or floating point.
* @param string $a @param string $b */
public static function comparePrice(string $a, string $b): int
{
$parts = static function (string $value): array {
[$whole, $fraction] = [...explode('.', $value), ''];
return [ltrim($whole, '0') ?: '0', str_pad($fraction, 2, '0')];
};
[$aw, $af] = $parts($a);
[$bw, $bf] = $parts($b);
return (strlen($aw) <=> strlen($bw)) ?: (strcmp($aw, $bw) <=> 0) ?: (strcmp($af, $bf) <=> 0);
}
/** @return array<string,mixed> */
public function filters(Request $request): array
{
$filters = array_intersect_key(app(InvestmentCatalogue::class)->filters($request, true), array_flip([
'q', 'category', 'province_id', 'district_id', 'currency', 'min_amount', 'max_amount', 'sort', 'view', 'page', 'per_page',
]));
$extra = $request->validate(['price_mode' => 'nullable|in:amount,negotiable']);
$filters = [...$filters, ...$extra];
if (isset($filters['min_amount'], $filters['max_amount']) && self::comparePrice($filters['min_amount'], $filters['max_amount']) > 0) {
throw ValidationException::withMessages(['max_amount' => 'O preço máximo deve ser igual ou superior ao mínimo.']);
}
if (in_array($filters['sort'] ?? '', ['amount_asc', 'amount_desc'], true) && empty($filters['currency'])) {
throw ValidationException::withMessages(['currency' => 'Seleccione uma moeda para ordenar por preço.']);
}
if (($filters['price_mode'] ?? '') === 'negotiable' && (isset($filters['min_amount']) || isset($filters['max_amount']))) {
throw ValidationException::withMessages(['price_mode' => 'Retire o intervalo de montantes para procurar preços a negociar.']);
}
if (($filters['price_mode'] ?? '') === 'negotiable' && ! empty($filters['currency'])) {
throw ValidationException::withMessages(['currency' => 'A moeda de um preço a negociar não foi indicada. Retire este filtro.']);
}
return $filters;
}
/** The explicit public snapshot, never a projection of current private columns.
* @return array<string,mixed>|null */
private function project(int $id, mixed $publication): ?array
{
if (! is_array($publication) || ($publication['contract'] ?? null) !== self::CONTRACT || ($publication['qa_fixture'] ?? false) !== true) {
return null;
}
$v = Validator::make($publication, [
'payload' => 'required|array:title,summary,category,province_id,district_id,price_mode,amount,currency',
'payload.title' => 'required|string|max:150', 'payload.summary' => 'required|string|max:1500',
'payload.category' => ['required', Rule::in(OpportunityManagement::CATEGORIES)],
'payload.province_id' => 'nullable|uuid', 'payload.district_id' => 'nullable|uuid',
'payload.price_mode' => 'required|in:amount,negotiable',
'payload.amount' => ['nullable', 'string', 'required_if:payload.price_mode,amount', 'prohibited_unless:payload.price_mode,amount', 'regex:/^\d{1,16}(\.\d{1,2})?$/'],
'payload.currency' => 'nullable|required_if:payload.price_mode,amount|prohibited_unless:payload.price_mode,amount|in:MZN,USD',
'approval.state' => 'required|in:approved', 'approval.hash' => 'required|string|size:64', 'approval.actor_id' => 'required|integer|min:1',
'approval.at' => 'required|date', 'publication.state' => 'required|in:published', 'publication.hash' => 'required|string|size:64',
'publication.actor_id' => 'required|integer|min:1|different:approval.actor_id', 'publication.at' => 'required|date|after_or_equal:approval.at|before_or_equal:now',
'revision' => 'required|integer|min:1',
]);
if ($v->fails()) {
return null;
}
$p = $v->validated()['payload'];
$hash = self::projectionHash($p);
if (! hash_equals($hash, $publication['approval']['hash']) || ! hash_equals($hash, $publication['publication']['hash'])) {
return null;
}
try {
$path = app(InvestmentGeography::class)->selection(['province_id' => $p['province_id'] ?? null, 'district_id' => $p['district_id'] ?? null]);
} catch (ValidationException $error) {
return null;
}
return ['id' => $id, 'title' => $p['title'], 'summary' => $p['summary'], 'category' => $p['category'],
'location' => array_map(fn ($node) => ['id' => $node->id, 'name' => $node->name, 'level' => $node->location_type], $path),
'price' => ['mode' => $p['price_mode'], 'amount' => $p['amount'] ?? null, 'currency' => $p['currency'] ?? null],
'status' => 'published', 'published_at' => CarbonImmutable::parse($publication['publication']['at'])->utc()->toIso8601String(), 'revision' => $publication['revision'], 'url' => '/ofertas/'.$id];
}
/** @param array<string,mixed> $payload */
public static function projectionHash(array $payload): string
{
ksort($payload);
return hash('sha256', json_encode(['contract' => self::CONTRACT, 'payload' => $payload], JSON_THROW_ON_ERROR));
}
/** @param array<string,mixed> $filters
* @return array{records:list<array<string,mixed>>,truncated:bool} */
public function records(array $filters = []): array
{
if (! $this->fixtureReadAllowed()) {
return ['records' => [], 'truncated' => false];
}
$rows = Opportunity::query()->whereNotNull('workflow_context->private_offer_preparation')->whereNotNull('workflow_context->offer_publication')
->orderBy('id')->limit(1001)->get(['id', DB::raw("workflow_context->'offer_publication' AS public_snapshot"), DB::raw("workflow_context->'offer_operation' AS operation_state")]);
$records = [];
foreach ($rows->take(1000) as $row) {
if (! is_string($row->getAttribute('public_snapshot'))) {
continue;
}
try {
$publication = json_decode($row->getAttribute('public_snapshot'), true, 512, JSON_THROW_ON_ERROR);
$operation = json_decode($row->getAttribute('operation_state') ?? 'null', true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $error) {
continue;
}
if (! app(OfferAvailability::class)->publicAllowed($operation, $publication)) {
continue;
}
$p = $this->project($row->id, $publication);
if (! $p) {
continue;
}
$expires = app(OfferAvailability::class)->view($operation, $publication)['expires_at'];
if ($expires !== null) {
$p['expires_at'] = $expires;
}
if (! empty($filters['q']) && ! str_contains(mb_strtolower($p['title'].' '.$p['summary']), mb_strtolower($filters['q']))) {
continue;
}
if (! empty($filters['category']) && $p['category'] !== $filters['category']) {
continue;
}
if (! empty($filters['province_id']) && ($p['location'][0]['id'] ?? null) !== $filters['province_id']) {
continue;
}
if (! empty($filters['district_id']) && ($p['location'][1]['id'] ?? null) !== $filters['district_id']) {
continue;
}
if (! empty($filters['price_mode']) && $p['price']['mode'] !== $filters['price_mode']) {
continue;
}
if (! empty($filters['currency']) && $p['price']['currency'] !== $filters['currency']) {
continue;
}
if (isset($filters['min_amount']) && ($p['price']['amount'] === null || self::comparePrice($p['price']['amount'], $filters['min_amount']) < 0)) {
continue;
}
if (isset($filters['max_amount']) && ($p['price']['amount'] === null || self::comparePrice($p['price']['amount'], $filters['max_amount']) > 0)) {
continue;
}
$records[] = $p;
}
$sort = $filters['sort'] ?? 'recent';
usort($records, static function ($a, $b) use ($sort): int {
if ($sort === 'title') {
return strnatcasecmp($a['title'], $b['title']) ?: ($a['id'] <=> $b['id']);
}
if (in_array($sort, ['amount_asc', 'amount_desc'], true)) {
if ($a['price']['amount'] === null || $b['price']['amount'] === null) {
return ($a['price']['amount'] === null) <=> ($b['price']['amount'] === null);
}
return self::comparePrice($a['price']['amount'], $b['price']['amount']) * ($sort === 'amount_desc' ? -1 : 1) ?: ($a['id'] <=> $b['id']);
}
return strcmp($b['published_at'], $a['published_at']) ?: ($b['id'] <=> $a['id']);
});
return ['records' => $records, 'truncated' => $rows->count() > 1000];
}
/** @return array<string,mixed> */
public function detail(int $id): array
{
abort_unless($this->fixtureReadAllowed(), 404);
$row = Opportunity::query()->whereKey($id)->whereNotNull('workflow_context->private_offer_preparation')->first(['id', DB::raw("workflow_context->'offer_publication' AS public_snapshot"), DB::raw("workflow_context->'offer_operation' AS operation_state")]);
if (! $row || ! is_string($row->getAttribute('public_snapshot'))) {
abort(404);
}
try {
$publication = json_decode($row->getAttribute('public_snapshot'), true, 512, JSON_THROW_ON_ERROR);
$operation = json_decode($row->getAttribute('operation_state') ?? 'null', true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $error) {
abort(404);
}
abort_unless(app(OfferAvailability::class)->publicAllowed($operation, $publication), 404);
$record = $this->project($id, $publication);
abort_unless($record !== null, 404);
$expires = app(OfferAvailability::class)->view($operation, $publication)['expires_at'];
if ($expires !== null) {
$record['expires_at'] = $expires;
}
return $record;
}
/** @param array<string,mixed> $filters
* @return array{results:LengthAwarePaginator<int,array<string,mixed>>,truncated:bool} */
public function page(Request $request, array $filters): array
{
$result = $this->records($filters);
$size = (int) ($filters['per_page'] ?? 12);
$page = (int) ($filters['page'] ?? 1);
return ['results' => new LengthAwarePaginator(array_slice($result['records'], ($page - 1) * $size, $size), count($result['records']), $size, $page,
['path' => $request->url(), 'query' => $filters]), 'truncated' => $result['truncated']];
}
/** @param array<string,mixed> $filters
* @return array<string,mixed> */
public function map(array $filters): array
{
$result = $this->records($filters);
$areas = [];
$seen = [];
foreach ($result['records'] as $record) {
$id = $record['location'][array_key_last($record['location'])]['id'] ?? null;
if (! $id || isset($seen[$id])) {
continue;
}
$seen[$id] = true;
$areas = [...$areas, ...app(TerritoryGeometry::class)->context($id)['areas']];
if (count($seen) >= 30) {
break;
}
}
return ['features' => [], 'areas' => $areas, 'matched_count' => count($result['records']), 'exact_locations_disclosed' => false,
'truncated' => $result['truncated'] || count($seen) >= 30];
}
}
@@ -279,14 +279,7 @@ class OpportunityCanonicalContract
'economic_activity_id', 'Sector, subsector e atividade devem corresponder à hierarquia/versão publicada; os cinco níveis originais são conservados.'); '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) { foreach (['internal_contact_id', 'public_contact_id'] as $field) {
if (! empty($payload[$field])) { if (! empty($payload[$field])) {
$contacts = DB::table('contacts')->where('contacts.id', $payload[$field])->whereNull('contacts.deleted_at'); $check(app(OpportunityContacts::class)->related($record, $payload[$field]), $field, 'Contacto inexistente ou sem relação com o Promotor.');
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.');
} }
} }
$resources = []; $resources = [];
@@ -308,6 +301,9 @@ class OpportunityCanonicalContract
app(OpportunityLibrarySupport::class)->assertAvailable($row, $file); app(OpportunityLibrarySupport::class)->assertAvailable($row, $file);
app(VerifiedDocumentBinary::class)->respond($file); app(VerifiedDocumentBinary::class)->respond($file);
$row->sha256 = trim($file->sha256); $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 { } else {
$location = $row->payload; $location = $row->payload;
+13
View File
@@ -14,6 +14,19 @@ use Illuminate\Validation\ValidationException;
/** Reuse the CRM master without manufacturing an interest, lead or consent. */ /** Reuse the CRM master without manufacturing an interest, lead or consent. */
class OpportunityContacts 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 * @param array<array-key, mixed> $input
* @return array<array-key, mixed> * @return array<array-key, mixed>
@@ -45,6 +45,7 @@ class OpportunityLibrarySupport
*/ */
public function attach(User $actor, Opportunity $opportunity, array $input): \stdClass 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); 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(); $data = Validator::make($input, ['resource_id' => 'required|integer|min:1', 'creation_key' => 'required|uuid', 'etag' => 'required|string|size:64', 'reason' => 'required|string|min:20|max:1000', 'section' => 'nullable|string|max:255', 'supported_statement' => 'nullable|string|max:2000'])->validate();
+20 -5
View File
@@ -21,7 +21,7 @@ class OpportunityManagement
public const MATURITY_FIELDS = ['maturity_level', 'maturity_label', 'maturity_stage', 'maturity_catalogue_version']; public const 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> */ /** @return array<string,mixed> */
public function values(Opportunity $record): array public function values(Opportunity $record): array
@@ -51,12 +51,13 @@ class OpportunityManagement
} }
/** @param array<string,mixed> $input */ /** @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); $access = app(PortalProfileAccess::class);
$profileCreation = $id === null && str_starts_with(request()->path(), 'minhas-oportunidades'); $profileCreation = $id === null && str_starts_with(request()->path(), 'minhas-oportunidades');
if ($id !== null) { if ($id !== null) {
$existingRecord = Opportunity::withTrashed()->findOrFail($id); $existingRecord = Opportunity::withTrashed()->findOrFail($id);
abort_if($existingRecord->isPrivateOfferPreparation() && ! $privateOffer, 403, 'Use a preparação privada da oferta.');
$access->authorize($actor, 'edit', $existingRecord); $access->authorize($actor, 'edit', $existingRecord);
if ($existingRecord->owner_user_id !== null) { if ($existingRecord->owner_user_id !== null) {
abort_unless($access->canEdit($actor, $existingRecord, $input['opportunity_category'] ?? null), 403); 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', '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'], 'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
])->validate(); ])->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); $data = app(OpportunityMaturity::class)->normalize($data);
if (array_key_exists('real_estate', $data)) { if (array_key_exists('real_estate', $data)) {
$data['real_estate'] = app(OpportunityRealEstateDraft::class)->validate($data['real_estate']); $data['real_estate'] = app(OpportunityRealEstateDraft::class)->validate($data['real_estate']);
@@ -108,10 +112,11 @@ class OpportunityManagement
$data['canonical'] = app(OpportunityCanonicalContract::class)->draft($data['canonical']); $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)'); DB::select('SELECT pg_advisory_xact_lock(91026, 100000)');
if (! $id && ($existing = Opportunity::withTrashed()->where('management_creation_key', $data['creation_key'])->first())) { if (! $id && ($existing = Opportunity::withTrashed()->where('management_creation_key', $data['creation_key'])->first())) {
$access->authorize($actor, 'view', $existing); $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); abort_unless($profileCreation === ($existing->owner_user_id !== null) && (! $profileCreation || $existing->organization_id === ($data['organization_id'] ?? null)), 409);
$snapshot = $this->values($existing); $snapshot = $this->values($existing);
foreach (array_intersect_key($data, array_flip([...self::FIELDS, ...self::PRIVATE_DRAFT_FIELDS])) as $key => $value) { 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; $record = $id ? Opportunity::withTrashed()->whereKey($id)->lockForUpdate()->firstOrFail() : new Opportunity;
if ($id) { if ($id) {
abort_unless($record->isPrivateOfferPreparation() === $privateOffer, 409);
$access->authorize($actor, 'edit', $record); $access->authorize($actor, 'edit', $record);
$this->check($record, $data['etag']); $this->check($record, $data['etag']);
abort_if($record->trashed() || $record->status === 'archived', 409, 'Restaure antes de editar.'); abort_if($record->trashed() || $record->status === 'archived', 409, 'Restaure antes de editar.');
@@ -189,14 +195,20 @@ class OpportunityManagement
$record->management_closed_once = true; $record->management_closed_once = true;
$previous = $record->management_draft ? json_decode($record->management_draft, true) : $record->only(self::FIELDS); $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); $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 { } else {
foreach ($changes as $key => $value) { 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; continue;
} }
$record->{$key} = $value; $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. // 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) : []; $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); $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) { if ($record->owner_user_id !== null) {
$context = $record->workflow_context ?? []; $context = $record->workflow_context ?? [];
if ($privateOffer) {
$context['private_offer_preparation'] ??= ['version' => 1, 'state' => 'draft'];
}
unset($context['approval']); unset($context['approval']);
$context['content_revision'] = ($context['content_revision'] ?? 0) + 1; $context['content_revision'] = ($context['content_revision'] ?? 0) + 1;
$context['contributors'] = array_values(array_unique([...($context['contributors'] ?? []), $actor->id])); $context['contributors'] = array_values(array_unique([...($context['contributors'] ?? []), $actor->id]));
+118 -9
View File
@@ -4,6 +4,7 @@ namespace App\Services;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -27,6 +28,9 @@ class OpportunityProfileWorkflow
if ($record->owner_user_id === null || ! $this->access->canView($actor, $record)) { if ($record->owner_user_id === null || ! $this->access->canView($actor, $record)) {
return []; return [];
} }
if ($record->isPrivateOfferPreparation()) {
return $this->offerActions($actor, $record);
}
$actions = []; $actions = [];
$state = $this->access->state($record); $state = $this->access->state($record);
if ($this->access->promoterRecordScope($actor, $record) if ($this->access->promoterRecordScope($actor, $record)
@@ -99,6 +103,44 @@ class OpportunityProfileWorkflow
return $actions; 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 public function reauthenticationRequired(User $actor, Opportunity $record): bool
{ {
return ! $this->access->mfa($actor) return ! $this->access->mfa($actor)
@@ -178,6 +220,12 @@ class OpportunityProfileWorkflow
abort_unless(hash_equals($this->management->etag($record), $data['etag']), 409, 'Versão alterada.'); abort_unless(hash_equals($this->management->etag($record), $data['etag']), 409, 'Versão alterada.');
$this->authorizeAction($actor, $record, $action); $this->authorizeAction($actor, $record, $action);
$context = $record->workflow_context ?? []; $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); $state = $this->access->state($record);
$before = ['status' => $state, 'publication_status' => $record->status, 'version' => $record->management_version]; $before = ['status' => $state, 'publication_status' => $record->status, 'version' => $record->management_version];
$from = match ($action) { $from = match ($action) {
@@ -186,7 +234,8 @@ class OpportunityProfileWorkflow
'review_gate' => ['submitted', 'in_review', 'information_pending', 'approved'], 'review_gate' => ['submitted', 'in_review', 'information_pending', 'approved'],
'publish' => ['approved'], 'suspend' => [$state], 'publish' => ['approved'], 'suspend' => [$state],
'assign_publication' => ['approved'], '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'], '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'], 'request_archive' => ['draft', 'submitted', 'in_review', 'information_pending', 'needs_information', 'approved', 'published', 'suspended'],
'appeal', 'appeal_uphold', 'appeal_reopen' => ['rejected'], 'appeal', 'appeal_uphold', 'appeal_reopen' => ['rejected'],
@@ -200,11 +249,35 @@ class OpportunityProfileWorkflow
} }
$next = $state; $next = $state;
$snapshot = []; $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. // Serialize the existing JSON-backed §27 uniqueness check across records.
DB::select('SELECT pg_advisory_xact_lock(111026,270000)'); 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'] ?? []); $blockers = $this->contract->deliveryBlockers($this->management->values($record)['canonical'] ?? []);
try { try {
$snapshot = $this->contract->validateSubmission($record); $snapshot = $this->contract->validateSubmission($record);
@@ -223,6 +296,12 @@ class OpportunityProfileWorkflow
} }
} }
if ($action === 'submit') { 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. // New review attempt, even when the canonical content is unchanged.
// Historical decisions/tasks and the content revision keep their IDs. // Historical decisions/tasks and the content revision keep their IDs.
$context['review_cycle'] = max((int) ($context['review_cycle'] ?? 0), $this->submissionCount($context)) + 1; $context['review_cycle'] = max((int) ($context['review_cycle'] ?? 0), $this->submissionCount($context)) + 1;
@@ -234,7 +313,7 @@ class OpportunityProfileWorkflow
} }
} }
$baseline = $context['review_baseline'] ?? $context['submission'] ?? []; $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['materiality'] = array_diff_key($materiality, ['reviews' => true]);
$context['reviews'] = $materiality['reviews']; $context['reviews'] = $materiality['reviews'];
app(OpportunityReviewConditions::class)->reconcile($context, $snapshot, $materiality['reopened']); app(OpportunityReviewConditions::class)->reconcile($context, $snapshot, $materiality['reopened']);
@@ -265,6 +344,11 @@ class OpportunityProfileWorkflow
} }
$context['review_task_id'] ??= $this->tasks->ensure($record, 'opportunity_review', 'Validação da oportunidade '.$record->reference); $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); $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]; $context['assignment'] = [...$decision, 'assignee_user_id' => $subject->id];
$next = 'in_review'; $next = 'in_review';
} elseif ($action === 'review_gate') { } elseif ($action === 'review_gate') {
@@ -287,19 +371,24 @@ class OpportunityProfileWorkflow
app(OpportunityReviewConditions::class)->authorize($context, $snapshot, 'approve'); 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['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.'); 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] ?? []; $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(($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(); $reviewer = User::whereKey($review['actor_id'])->firstOrFail();
abort_unless($this->access->scoped($reviewer, 'validator', $record->organization_id, $record->opportunity_category) 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->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.'); && $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'] ?? []]; $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']); $this->tasks->complete($context['review_task_id'] ?? null, $data['reason']);
$record->workflow_context = $context; $record->workflow_context = $context;
if (! $offer) {
$context['publication_task_id'] = $this->tasks->ensure($record, 'opportunity_publication', 'Preparação editorial '.$record->reference); $context['publication_task_id'] = $this->tasks->ensure($record, 'opportunity_publication', 'Preparação editorial '.$record->reference);
}
$next = 'approved'; $next = 'approved';
} elseif ($action === 'assign_publication') { } elseif ($action === 'assign_publication') {
$subject = User::whereKey($data['assignee_user_id'] ?? 0)->firstOrFail(); $subject = User::whereKey($data['assignee_user_id'] ?? 0)->firstOrFail();
@@ -316,6 +405,7 @@ class OpportunityProfileWorkflow
foreach ($this->contract->gates($record) as $gate) { foreach ($this->contract->gates($record) as $gate) {
$review = $context['approval']['gates'][$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(($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(); $reviewer = User::whereKey($review['actor_id'])->firstOrFail();
abort_unless($this->access->scoped($reviewer, 'validator', $record->organization_id, $record->opportunity_category) 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->access->conflict($reviewer, $record) && $this->access->operational($reviewer, 'validate', $record->id)
@@ -408,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'; $next = count(array_filter($context['opinions'], fn ($o) => ($o['hash'] ?? '') === $snapshot['hash'] && ($o['status'] ?? '') === 'pending')) > 0 ? 'information_pending' : 'in_review';
} elseif ($action === 'request_correction') { } elseif ($action === 'request_correction') {
abort_unless($data['correction_items'] ?? [], 422, 'Indique campos e documentos a corrigir.'); 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']); $this->tasks->complete($context['review_task_id'] ?? null, $data['reason']);
$context['correction_items'] = $data['correction_items']; $context['correction_items'] = $data['correction_items'];
$context['correction_cycles'] = ($context['correction_cycles'] ?? 0) + 1; $context['correction_cycles'] = ($context['correction_cycles'] ?? 0) + 1;
@@ -417,7 +510,7 @@ class OpportunityProfileWorkflow
} elseif ($action === 'request_revision' || $action === 'appeal_reopen') { } elseif ($action === 'request_revision' || $action === 'appeal_reopen') {
$this->tasks->complete($context['review_task_id'] ?? null, $data['reason']); $this->tasks->complete($context['review_task_id'] ?? null, $data['reason']);
$this->tasks->complete($context['publication_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['review_baseline'] = $context['submission'] ?? [];
$context['force_review'] = $action === 'appeal_reopen' || $state === 'suspended'; $context['force_review'] = $action === 'appeal_reopen' || $state === 'suspended';
unset($context['approval']); unset($context['approval']);
@@ -470,14 +563,27 @@ class OpportunityProfileWorkflow
$context['working_state'] = $next; $context['working_state'] = $next;
$context['last_decision'] = $decision; $context['last_decision'] = $decision;
$context['events'][] = [...$decision, 'from' => $state, 'to' => $next]; $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->status = $next;
} }
$record->management_version++; $record->management_version++;
$record->workflow_context = $context; $record->workflow_context = $context;
$record->save(); $record->save();
app(PortalEditorialWorkflow::class)->notifyOpportunity($actor, $record, $action);
$this->access->audit($actor, 'workflow_'.$action, ['record_id' => $record->id, 'before' => $before, $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; return $record;
}); });
@@ -513,6 +619,9 @@ class OpportunityProfileWorkflow
private function authorizeAction(User $actor, Opportunity $record, string $action): void 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); $this->access->authorize($actor, 'view', $record);
$promoter = $this->access->promoterRecordScope($actor, $record) $promoter = $this->access->promoterRecordScope($actor, $record)
&& ($record->organization_id !== null || (int) $record->owner_user_id === $actor->id); && ($record->organization_id !== null || (int) $record->owner_user_id === $actor->id);
+1 -1
View File
@@ -22,7 +22,7 @@ final class OpportunityPromotion
public function allowed(User $actor, Opportunity $record): bool public function allowed(User $actor, Opportunity $record): bool
{ {
$access = app(PortalProfileAccess::class); $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)) { || $access->has($actor, 'auditor') || in_array($record->status, ['archived', 'rejected'], true)) {
return false; return false;
} }
+17 -2
View File
@@ -92,7 +92,7 @@ class OpportunityResources
} }
abort_if(! $id && DB::table($table)->where('id', $resourceId)->exists(), 409, 'Esta criação já foi registada; recupere o recurso existente.'); 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'); $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') { if ($kind === 'locations') {
$others = (clone $active)->when($id, fn ($q) => $q->where('id', '!=', $id))->get(); $others = (clone $active)->when($id, fn ($q) => $q->where('id', '!=', $id))->get();
foreach ($others as $other) { foreach ($others as $other) {
@@ -172,7 +172,7 @@ class OpportunityResources
abort_if(($data['action'] === 'delete') === ($record->deleted_at !== null), 409, 'Estado já alterado.'); abort_if(($data['action'] === 'delete') === ($record->deleted_at !== null), 409, 'Estado já alterado.');
$payload = json_decode($record->payload, true); $payload = json_decode($record->payload, true);
if ($data['action'] === 'restore') { 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); $this->validatePayload($kind, $payload, $opportunityId);
// Restoration never silently reclaims a primary/cover/order occupied by another resource. // Restoration never silently reclaims a primary/cover/order occupied by another resource.
if ($kind === 'images') { if ($kind === 'images') {
@@ -221,12 +221,22 @@ class OpportunityResources
*/ */
private function validatePayload(string $kind, array $payload, int $opportunityId): array 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']; $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') { if ($kind === 'locations') {
$rules += ['country_code' => ['nullable', app(CountryCatalogue::class)->rule()]]; $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']; $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') { } 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']; $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 { } 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']; $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; 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} */ /** @return array{string,string} */
private function store(string $kind, UploadedFile $file): array private function store(string $kind, UploadedFile $file): array
{ {
@@ -134,8 +134,12 @@ class OpportunityReviewMateriality
/** @param array<string,mixed> $snapshot /** @param array<string,mixed> $snapshot
* @param array<string,mixed> $review */ * @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'])) { if (! isset($review['scope_hash'])) {
return true; // Caller must independently verify the original signed submission hash. return true; // Caller must independently verify the original signed submission hash.
} }
@@ -151,7 +155,7 @@ class OpportunityReviewMateriality
* @param array<string,mixed> $reviews * @param array<string,mixed> $reviews
* @param list<string> $gates * @param list<string> $gates
* @return array<string,mixed> */ * @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 = []; $kept = [];
$reopened = []; $reopened = [];
@@ -161,7 +165,8 @@ class OpportunityReviewMateriality
// separately validate old scope signatures under their original rule. // separately validate old scope signatures under their original rule.
$priorHash = $this->hash($before, $gate, $review); $priorHash = $this->hash($before, $gate, $review);
$currentHash = $this->hash($after, $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'], $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()]]; '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 { } else {
@@ -0,0 +1,146 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/** Adapts existing immutable native revisions to the common inbox; never changes a workflow. */
final class PortalEditorialNativeNotices
{
public function available(): bool
{
return app(PortalEditorialWorkflow::class)->available() && Schema::hasColumn('portal_editorial_notifications', 'native_event_id');
}
public function access(User $actor, string $source): bool
{
if (! $actor->hasVerifiedEmail() || ! $actor->two_factor_confirmed_at || ! $actor->two_factor_secret
|| app(PortalProfileAccess::class)->has($actor, 'auditor')) {
return false;
}
if ($source === 'investment-zones') {
return app(InvestmentZoneOperationalProfile::class)->editorialEnabled() && app(InvestmentZoneOperationalProfile::class)->reviewAccess($actor);
}
if (! $actor->can('manage-portal')) {
return false;
}
return match ($source) {
'cluster-competitiveness' => app(ClusterCompetitiveness::class)->editorialEnabled(),
'territorial-editorial-v1' => app(TerritorialRegistry::class)->editorialAvailable(),
default => false,
};
}
/** @return array{clusters:int,territories:int,zones:int} */
public function pending(User $actor): array
{
$counts = ['clusters' => 0, 'territories' => 0, 'zones' => 0];
if (! $this->available()) {
return $counts;
}
if ($this->access($actor, 'cluster-competitiveness')) {
$events = DB::table('audit_events as a')->join('economic_clusters as c', 'c.id', '=', 'a.entity_id')
->where('a.source_system', 'cluster-competitiveness')->whereNull('c.deleted_at')
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('audit_events as newer')->whereColumn('newer.entity_id', 'a.entity_id')
->where('newer.source_system', 'cluster-competitiveness')->whereRaw("(newer.after_data->>'version')::integer > (a.after_data->>'version')::integer"))
->select(['a.id', 'a.after_data'])->orderBy('a.id')->cursor();
foreach ($events as $event) {
$payload = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR);
$stage = $payload['editorial_state'] ?? null;
if ($stage === null) {
$state = json_decode(Crypt::decryptString($payload['snapshot']), true, 512, JSON_THROW_ON_ERROR);
$stage = $state['workflow']['state'] ?? null;
}
if ($stage !== null && ! in_array($stage, ['published', 'archived'], true)) {
$counts['clusters']++;
}
}
}
if ($this->access($actor, 'territorial-editorial-v1')) {
$latest = DB::table('audit_events')->where('source_system', 'territorial-editorial-v1')
->selectRaw("DISTINCT ON (entity_id) entity_id,after_data->>'state' AS editorial_state")
->orderBy('entity_id')->orderByRaw("(after_data->>'version_no')::integer DESC");
$counts['territories'] = DB::table('geo_territory_profiles as p')->join('geo_locations as g', 'g.id', '=', 'p.geo_location_id')
->leftJoinSub($latest, 'a', 'a.entity_id', '=', 'g.id')->whereNull('g.deleted_at')
->whereNotIn(DB::raw('COALESCE(a.editorial_state,p.status)'), ['published', 'archived'])->count();
}
if ($this->access($actor, 'investment-zones')) {
$latest = DB::table('audit_events')->where('source_system', 'investment-zones')->where('entity_type', 'investment_zones')->where('after_data->editorial_workflow', true)
->selectRaw("DISTINCT ON (entity_id) entity_id,after_data->>'editorial_state' AS editorial_state")
->orderBy('entity_id')->orderByRaw("(after_data->>'version_no')::integer DESC");
$counts['zones'] = DB::query()->fromSub($latest, 'a')->join('investment_zones as z', 'z.id', '=', 'a.entity_id')->whereNull('z.deleted_at')
->whereIn('a.editorial_state', ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved'])->count();
}
return $counts;
}
/** @param list<int> $participants */
public function emit(User $actor, string $eventId, array $participants): void
{
if (! $this->available()) {
return;
}
$event = DB::table('audit_events')->where('id', $eventId)->first();
abort_unless($event !== null, 409);
if (in_array($event->action, ['saved', 'restored', 'draft_saved', 'profile_prepared', 'profile_reconciled', 'zone_editorial_suspension_rebased'], true)) {
return;
}
$emails = $event->source_system === 'territorial-editorial-v1'
? [...config('territorial.editors', []), ...config('territorial.reviewers', []), ...config('territorial.publishers', [])]
: config('portal.managers', []);
$recipients = User::query()->where(fn ($q) => $q->whereIn('id', $participants)->orWhereIn(DB::raw('lower(email)'), array_map('strtolower', $emails)))->get();
foreach ($recipients as $recipient) {
if ($recipient->id === $actor->id || ! $this->access($recipient, $event->source_system)) {
continue;
}
DB::table('portal_editorial_notifications')->insertOrIgnore(['user_id' => $recipient->id, 'native_event_id' => $eventId,
'edition_id' => null, 'opportunity_id' => null, 'action' => $event->action, 'content_hash' => $this->eventHash($event), 'created_at' => now()]);
}
}
public function visible(User $actor, \stdClass $notice): bool
{
if (! $this->available() || (int) $notice->user_id !== $actor->id) {
return false;
}
$event = DB::table('audit_events')->where('id', $notice->native_event_id)->first();
if (! $event || ! $this->access($actor, $event->source_system) || ! is_string($notice->content_hash)
|| ! hash_equals(trim($notice->content_hash), $this->eventHash($event))) {
return false;
}
$table = match ($event->source_system) {
'cluster-competitiveness' => 'economic_clusters', 'investment-zones' => 'investment_zones', default => 'geo_locations'
};
return DB::table($table)->where('id', $event->entity_id)->whereNull('deleted_at')->exists();
}
private function eventHash(\stdClass $event): string
{
return hash('sha256', json_encode([$event->id, $event->source_system, $event->entity_type, $event->entity_id, $event->action, $event->after_data], JSON_THROW_ON_ERROR));
}
/** @return array<string,mixed> */
public function present(\stdClass $notice): array
{
$event = DB::table('audit_events')->where('id', $notice->native_event_id)->first();
abort_unless($event !== null, 404);
$data = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR);
$cluster = $event->source_system === 'cluster-competitiveness';
$version = $data[$cluster ? 'version' : 'version_no'];
if ($event->source_system === 'investment-zones') {
return ['id' => $notice->id, 'action' => str_replace('zone_editorial_', '', $notice->action), 'read_at' => $notice->read_at,
'href' => '/management/investment/zones/'.$event->entity_id.'?notice_version='.$version, 'reference' => 'Zona e indicadores — versão '.$version];
}
return ['id' => $notice->id, 'action' => $notice->action, 'read_at' => $notice->read_at,
'href' => $cluster ? '/management/clusters/'.$event->entity_id.'/edit?notice_version='.$version : '/management/territorial/editorial?territory='.$event->entity_id.'&notice_version='.$version,
'reference' => ($cluster ? 'Competitividade — versão ' : 'Território — versão ').$version];
}
}
+394
View File
@@ -0,0 +1,394 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/** Subject mapping and publication effects for the existing common editorial engine. */
final class PortalEditorialSubjects
{
public const SUBJECTS = [
'projects' => ['table' => 'projects', 'fields' => ['code', 'name', 'status', 'description', 'budget_amount', 'budget_currency', 'start_date', 'end_date', 'primary_territory_id', 'territory_version']],
'clusters' => ['table' => 'economic_clusters', 'fields' => ['code', 'name', 'status', 'sector', 'primary_territory_id', 'territory_version']],
'corridors' => ['table' => 'development_corridors', 'fields' => ['code', 'name', 'status', 'description', 'primary_territory_id', 'territory_version']],
'cluster-project' => ['table' => 'cluster_projects', 'fields' => ['cluster_id', 'project_id', 'role', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until']],
'corridor-project' => ['table' => 'corridor_projects', 'fields' => ['corridor_id', 'project_id', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until']],
'corridor-cluster' => ['table' => 'corridor_clusters', 'fields' => ['corridor_id', 'cluster_id', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until']],
'value-chains' => ['table' => 'value_chains', 'fields' => ['cluster_id', 'code', 'name', 'stage']],
'value-chain-links' => ['table' => 'value_chain_links', 'fields' => ['from_value_chain_id', 'to_value_chain_id', 'link_type']],
'investment-gaps' => ['table' => 'investment_gaps', 'fields' => ['cluster_id', 'value_chain_id', 'title', 'location', 'required_capacity', 'rationale', 'status', 'call_to_action']],
'zone-relations' => ['table' => 'investment_zone_relations', 'fields' => ['zone_id', 'cluster_id', 'corridor_id', 'project_id', 'association_type', 'role', 'source_reference', 'justification', 'legal_source_id', 'valid_from', 'valid_until', 'priority', 'is_primary']],
];
private const PARENTS = ['cluster_id' => 'economic_clusters', 'project_id' => 'projects', 'corridor_id' => 'development_corridors', 'zone_id' => 'investment_zones',
'value_chain_id' => 'value_chains', 'from_value_chain_id' => 'value_chains', 'to_value_chain_id' => 'value_chains', 'primary_territory_id' => 'geo_locations'];
/** @return array{table:string,fields:list<string>} */
public function definition(string $kind): array
{
abort_unless(isset(self::SUBJECTS[$kind]), 404);
return self::SUBJECTS[$kind];
}
public function available(): bool
{
return (bool) config('editorial_workflow.structural_subjects') && app(PortalEditorialWorkflow::class)->available() && Schema::hasColumn('portal_editorial_editions', 'subject_kind');
}
public function writes(string $kind, string $key): bool
{
$this->definition($kind);
if (config('editorial_workflow.structural_subjects')) {
abort_unless($this->available(), 503, 'Estrutura editorial dos registos ainda não instalada.');
return true;
}
abort_if(Schema::hasColumn('portal_editorial_editions', 'subject_kind') && DB::table('portal_editorial_editions')->where('subject_kind', $kind)->where('subject_key', $key)->exists(), 503, 'Reactive o circuito para conservar as versões deste registo.');
return false;
}
public function query(string $kind, string $key): Builder
{
return DB::table($this->definition($kind)['table'])->where('id', $key);
}
public function editUrl(string $kind, \stdClass $record): string
{
return match ($kind) {
'projects', 'clusters', 'corridors' => '/management/'.$kind.'/'.$record->id.'/edit',
'value-chains', 'investment-gaps', 'cluster-project' => '/management/clusters/'.$record->cluster_id.'/edit',
'value-chain-links' => '/management/clusters/'.DB::table('value_chains')->where('id', $record->from_value_chain_id)->value('cluster_id').'/edit',
'corridor-project', 'corridor-cluster' => '/management/corridors/'.$record->corridor_id.'/edit',
'zone-relations' => '/management/investment/zones/'.$record->zone_id,
default => abort(404),
};
}
public function working(string $kind, \stdClass $record): \stdClass
{
if (! $this->available()) {
return $record;
}
$workflow = app(PortalEditorialWorkflow::class);
$edition = $workflow->latestSubject($kind, $record->id);
$copy = clone $record;
if ($edition && $edition->published_at === null && $edition->state !== 'archived') {
foreach ($workflow->payload($edition)['resource']['_subject']['fields'] as $field => $value) {
$copy->{$field} = $value;
}
}
$copy->editorial_version = (int) ($edition->revision_no ?? 1);
$copy->editorial_url = '/management/editorial/subjects/'.$kind.'/'.$record->id;
return $copy;
}
/** @param array<string,mixed> $fields */
public function stageModel(User $actor, string $kind, Model $record, array $fields, int $expected): \stdClass
{
return DB::transaction(function () use ($actor, $kind, $record, $fields, $expected) {
$new = ! $record->exists;
if ($new) {
$private = in_array($kind, ['value-chains', 'value-chain-links', 'investment-gaps'], true) ? ['deleted_at' => now()] : [];
PortalAudit::save($record, [...$fields, ...$private], $actor->id);
}
$allowed = $this->definition($kind)['fields'];
return app(PortalEditorialWorkflow::class)->saveSubject($actor, $kind, (string) $record->getKey(), $expected, Arr::only($fields, $allowed), $new);
});
}
/** @return array<string,string> */
public function labels(): array
{
return ['code' => 'Código', 'name' => 'Designação', 'status' => 'Estado', 'description' => 'Descrição', 'sector' => 'Sector', 'budget_amount' => 'Orçamento', 'budget_currency' => 'Moeda', 'start_date' => 'Data de início', 'end_date' => 'Data de conclusão',
'primary_territory_id' => 'Localização principal', 'cluster_id' => 'Cluster', 'project_id' => 'Projecto', 'corridor_id' => 'Corredor', 'zone_id' => 'Zona', 'value_chain_id' => 'Etapa da cadeia', 'from_value_chain_id' => 'Etapa de origem', 'to_value_chain_id' => 'Etapa de destino',
'role' => 'Papel', 'association_type' => 'Tipo de associação', 'source_reference' => 'Fonte', 'evidence_reference' => 'Evidência', 'valid_from' => 'Válido desde', 'valid_until' => 'Válido até', 'stage' => 'Fase da cadeia', 'link_type' => 'Tipo de ligação',
'title' => 'Título', 'location' => 'Localização', 'required_capacity' => 'Capacidade necessária', 'rationale' => 'Fundamento', 'call_to_action' => 'Acção proposta', 'justification' => 'Justificação', 'legal_source_id' => 'Fonte jurídica', 'priority' => 'Prioridade', 'is_primary' => 'Relação principal'];
}
/** @return array<string,list<string>> */
private function enums(string $kind): array
{
return ['status' => match ($kind) {
'projects' => ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'], 'clusters' => ['planned', 'active', 'inactive'], 'corridors' => ['proposed', 'active', 'closed'], 'investment-gaps' => ['identified', 'in_review', 'resolved', 'withdrawn'], default => []
},
'stage' => ['input', 'production', 'processing', 'logistics', 'market'], 'link_type' => ['supplies', 'processes', 'distributes', 'supports'],
'association_type' => ['official', 'influence', 'proximity', 'potential', 'historical'], 'role' => $kind === 'zone-relations' ? ['primary', 'beneficiary', 'served', 'enabled', 'dependent', 'related', 'potential', 'historical'] : ['anchor', 'complementary', 'supporting']];
}
/** @return list<array<string,mixed>> */
public function form(string $kind): array
{
$labels = $this->labels();
$enums = $this->enums($kind);
$result = [];
foreach ($this->definition($kind)['fields'] as $field) {
if ($field === 'territory_version') {
continue;
}
$type = isset(self::PARENTS[$field]) || $field === 'legal_source_id' ? 'reference' : (isset($enums[$field]) ? 'enum' : (in_array($field, ['valid_from', 'valid_until', 'start_date', 'end_date'], true) ? 'date' : ($field === 'is_primary' ? 'boolean' : 'text')));
$result[] = ['name' => $field, 'label' => $labels[$field] ?? $field, 'type' => $type, 'options' => $enums[$field] ?? []];
}
return $result;
}
/** @param array<string,mixed> $input
* @return array<string,mixed>
*/
public function validate(string $kind, array $input): array
{
$allowed = array_values(array_diff($this->definition($kind)['fields'], ['territory_version']));
abort_unless(array_diff(array_keys($input), $allowed) === [], 422, 'Campos fora do âmbito da proposta.');
$enums = $this->enums($kind);
$rules = [];
foreach ($allowed as $field) {
$rules[$field] = ['sometimes', 'nullable', 'string', 'max:10000'];
if (isset(self::PARENTS[$field]) || $field === 'legal_source_id') {
$table = self::PARENTS[$field] ?? 'investment_legal_sources';
$rules[$field] = ['sometimes', 'nullable', 'uuid', 'exists:'.$table.',id'];
} elseif (isset($enums[$field])) {
$rules[$field] = ['sometimes', 'required', Rule::in($enums[$field])];
} elseif (in_array($field, ['valid_from', 'valid_until', 'start_date', 'end_date'], true)) {
$rules[$field] = ['sometimes', 'nullable', 'date_format:Y-m-d'];
}
}
foreach (['name', 'code', 'title', 'sector'] as $field) {
if (in_array($field, $allowed, true)) {
$rules[$field] = ['sometimes', 'required', 'string', 'max:'.($field === 'code' ? '100' : '200')];
}
}
foreach (['source_reference' => 2000, 'evidence_reference' => 2000, 'justification' => 3000, 'location' => 1000, 'required_capacity' => 2000, 'rationale' => 5000, 'call_to_action' => 2000] as $field => $limit) {
if (in_array($field, $allowed, true)) {
$rules[$field] = ['sometimes', 'nullable', 'string', 'max:'.$limit];
}
}
$requiredReferences = match ($kind) {
'cluster-project' => ['cluster_id', 'project_id'], 'corridor-project' => ['corridor_id', 'project_id'], 'corridor-cluster' => ['corridor_id', 'cluster_id'],
'value-chains' => ['cluster_id'], 'value-chain-links' => ['from_value_chain_id', 'to_value_chain_id'], 'investment-gaps' => ['cluster_id', 'value_chain_id'], 'zone-relations' => ['zone_id'], default => [],
};
foreach ($requiredReferences as $field) {
$rules[$field] = ['sometimes', 'required', 'uuid', 'exists:'.self::PARENTS[$field].',id'];
}
if (! empty($input['valid_until']) && ! empty($input['valid_from'])) {
$rules['valid_until'][] = 'after_or_equal:valid_from';
}
if (! empty($input['end_date']) && ! empty($input['start_date'])) {
$rules['end_date'][] = 'after_or_equal:start_date';
}
if (in_array('budget_amount', $allowed, true)) {
$rules['budget_amount'] = ['sometimes', 'nullable', 'required_with:budget_currency', 'regex:/^\d{1,18}(\.\d{1,2})?$/'];
$rules['budget_currency'] = ['sometimes', 'nullable', 'required_with:budget_amount', 'regex:/^[A-Z]{3}$/'];
}
if (in_array('priority', $allowed, true)) {
$rules['priority'] = ['sometimes', 'integer', 'between:0,100'];
$rules['is_primary'] = ['sometimes', 'boolean'];
}
if ($kind === 'zone-relations' && count(array_filter(Arr::only($input, ['cluster_id', 'corridor_id', 'project_id']))) > 1) {
throw ValidationException::withMessages(['project_id' => 'Seleccione apenas um projecto, cluster ou corredor para a relação.']);
}
return validator($input, $rules)->validate();
}
/** @return list<array{id:string,name:string}> */
public function choices(string $kind, string $field, ?string $query = null): array
{
abort_unless(in_array($field, $this->definition($kind)['fields'], true), 404);
$table = self::PARENTS[$field] ?? ($field === 'legal_source_id' ? 'investment_legal_sources' : abort(404));
$label = $table === 'investment_legal_sources' ? 'title' : 'name';
$builder = DB::table($table);
if ($table === 'geo_locations') {
$builder->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])->whereIn('data_classification', ['public', 'internal']);
}
if (Schema::hasColumn($table, 'deleted_at')) {
$builder->whereNull('deleted_at');
}
if ($query) {
$builder->where($label, 'ilike', '%'.addcslashes($query, '%_\\').'%');
}
return array_values($builder->orderBy($label)->orderBy('id')->limit(100)->get(['id', $label.' as name'])->map(fn ($row) => ['id' => (string) $row->id, 'name' => (string) $row->name])->all());
}
public function authorize(User $actor, string $kind, string $key, bool $write = false): void
{
abort_unless($this->available(), 503);
abort_unless($actor->hasVerifiedEmail() && $actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403);
abort_unless($this->query($kind, $key)->exists(), 404);
if ($write) {
abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.');
}
}
public function independent(string $kind): bool
{
return in_array($kind, ['cluster-project', 'corridor-project', 'corridor-cluster', 'zone-relations'], true)
|| in_array($kind, config('editorial_workflow.independent_approval.kinds', []), true);
}
/** @param array<string,mixed> $fields
* @return array<string,mixed>
*/
public function snapshot(string $kind, string $key, array $fields): array
{
$definition = $this->definition($kind);
$record = $this->query($kind, $key)->first();
abort_unless($record !== null, 404);
abort_unless(array_diff(array_keys($fields), $definition['fields']) === [], 422, 'Campos fora do âmbito do registo.');
$context = ['record' => Arr::except(get_object_vars($record), ['created_at', 'updated_at']), 'parents' => []];
if (! empty($fields['primary_territory_id'])) {
$path = app(InvestmentGeography::class)->path($fields['primary_territory_id'], true);
abort_unless($path !== [] && count($path) <= 4 && $path[0]->location_type === 'province', 422, 'Seleccione uma localização na hierarquia existente.');
}
foreach (self::PARENTS as $field => $table) {
$id = $fields[$field] ?? $record->{$field} ?? null;
if (! $id) {
continue;
}
$parent = DB::table($table)->where('id', $id)->whereNull('deleted_at')->first();
abort_unless($parent !== null, 422, 'Contexto relacionado indisponível.');
$context['parents'][$field] = Arr::except(get_object_vars($parent), ['created_at', 'updated_at']);
}
if (in_array($kind, ['projects', 'clusters', 'corridors'], true)) {
$foreign = ['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'][$kind];
$context['portfolio'] = DB::table('investment_dossiers')->where($foreign, $key)->pluck('id')->all();
abort_unless($context['portfolio'] === [], 409, 'Este registo tem ficha associada. Continue na revisão da carteira.');
}
if ($source = $fields['legal_source_id'] ?? $record->legal_source_id ?? null) {
$sourceRecord = DB::table('investment_legal_sources')->where('id', $source)->first();
abort_unless($sourceRecord !== null, 422, 'Fonte jurídica indisponível.');
$context['legal_source'] = get_object_vars($sourceRecord);
}
return ['fields' => $fields, 'context' => $context];
}
/** @param array<string,mixed> $fields */
public function lock(string $kind, string $key, array $fields = []): \stdClass
{
$record = $this->query($kind, $key)->first();
abort_unless($record !== null, 404);
$parents = [];
foreach (self::PARENTS as $field => $table) {
$id = $fields[$field] ?? $record->{$field} ?? null;
if ($id) {
$parents[$table][] = $id;
}
}
ksort($parents);
foreach ($parents as $table => $ids) {
DB::table($table)->whereIn('id', array_unique($ids))->orderBy('id')->lockForUpdate()->get();
}
return $this->query($kind, $key)->lockForUpdate()->firstOrFail();
}
/** @param array<string,mixed> $payload */
public function publish(User $actor, \stdClass $record, \stdClass $edition, array $payload): void
{
$subject = $payload['resource']['_subject'];
$kind = $edition->subject_kind;
$fields = $subject['fields'];
$this->lock($kind, $edition->subject_key, $fields);
if ($subject['withdraw'] ?? false) {
$this->archive($actor, $record, $edition);
return;
}
$pair = match ($kind) {
'cluster-project' => ['cluster_id', 'project_id'], 'corridor-project' => ['corridor_id', 'project_id'], 'corridor-cluster' => ['corridor_id', 'cluster_id'],
'value-chain-links' => ['from_value_chain_id', 'to_value_chain_id', 'link_type'],
'zone-relations' => ['zone_id', 'cluster_id', 'corridor_id', 'project_id'], default => [],
};
if ($pair !== []) {
abort_if($kind === 'zone-relations' && count(array_filter(Arr::only($fields, ['cluster_id', 'corridor_id', 'project_id']))) !== 1, 422, 'A relação exige um projecto, cluster ou corredor.');
$duplicates = DB::table($this->definition($kind)['table'])->where('id', '<>', $record->id);
foreach ($pair as $field) {
$duplicates->where($field, $fields[$field] ?? null);
}
if (property_exists($record, 'deleted_at')) {
$duplicates->whereNull('deleted_at');
}
abort_if($duplicates->exists(), 409, 'Esta relação já existe. Reveja a proposta sem duplicar o registo.');
}
if (in_array($kind, ['projects', 'clusters', 'corridors', 'value-chains'], true) && isset($fields['code'])) {
$query = DB::table($this->definition($kind)['table'])->where('code', $fields['code'])->where('id', '<>', $record->id)->whereNull('deleted_at');
if ($kind === 'value-chains') {
$query->where('cluster_id', $fields['cluster_id'] ?? $record->cluster_id);
}
abort_if($query->exists(), 409, 'Código entretanto utilizado. Reveja a proposta.');
}
if (in_array($kind, ['cluster-project', 'corridor-project', 'corridor-cluster', 'zone-relations'], true)) {
foreach (['source_reference', 'valid_from'] as $required) {
abort_unless(! empty($fields[$required]), 422, 'Fonte e vigência são necessárias.');
}
abort_if($kind === 'zone-relations' && ($fields['association_type'] ?? '') === 'official' && empty($fields['legal_source_id']), 422, 'A associação oficial exige a fonte jurídica prevista.');
if (! empty($fields['legal_source_id'])) {
$source = DB::table('investment_legal_sources')->where('id', $fields['legal_source_id'])->lockForUpdate()->first();
abort_unless($source && $source->validation_status === 'validated' && $source->valid_from <= today()->toDateString()
&& (! $source->valid_until || $source->valid_until >= today()->toDateString()), 422, 'Fonte jurídica fora de vigência.');
}
$decision = json_decode($edition->approval, true, 512, JSON_THROW_ON_ERROR);
$fields += ['validation_status' => 'validated', 'validator_id' => $decision['actor_id'], 'editor_id' => $edition->author_id];
if (property_exists($record, 'data_classification')) {
$fields['data_classification'] = 'public';
}
}
if ($kind === 'value-chain-links') {
$chains = DB::table('value_chains')->whereIn('id', [$fields['from_value_chain_id'], $fields['to_value_chain_id']])->whereNull('deleted_at')->get(['id', 'cluster_id']);
abort_unless($chains->count() === 2 && $chains->pluck('cluster_id')->unique()->count() === 1, 422, 'As etapas devem ser distintas e pertencer ao mesmo cluster.');
}
if ($kind === 'investment-gaps') {
abort_unless(DB::table('value_chains')->where('id', $fields['value_chain_id'])->where('cluster_id', $fields['cluster_id'])->whereNull('deleted_at')->exists(), 422, 'Etapa fora do cluster da lacuna.');
}
if ($subject['new_record'] ?? false) {
$fields['deleted_at'] = null;
}
if (property_exists($record, 'data_classification')) {
$fields['data_classification'] = 'public';
}
if (property_exists($record, 'version_no')) {
$fields['version_no'] = (int) $record->version_no + 1;
}
$fields['updated_at'] = now();
$this->query($kind, $record->id)->update($fields);
$this->audit($actor, $edition, 'published', $record);
}
public function archive(User $actor, \stdClass $record, \stdClass $edition): void
{
$fields = property_exists($record, 'validation_status') ? ['validation_status' => 'revoked']
: (in_array($edition->subject_kind, ['projects', 'clusters', 'corridors'], true) ? ['data_classification' => 'internal'] : ['deleted_at' => now()]);
if (property_exists($record, 'version_no')) {
$fields['version_no'] = (int) $record->version_no + 1;
}
$this->query($edition->subject_kind, $record->id)->update([...$fields, 'updated_at' => now()]);
$this->audit($actor, $edition, 'archived', $record);
}
private function audit(User $actor, \stdClass $edition, string $action, \stdClass $before): void
{
$after = $this->query($edition->subject_kind, $edition->subject_key)->firstOrFail();
$changed = [];
foreach (get_object_vars($after) as $field => $value) {
if (($before->{$field} ?? null) !== $value) {
$changed[] = $field;
}
}
DB::table('audit_events')->insert(['source_system' => 'editorial-structural-subjects', 'entity_type' => $this->definition($edition->subject_kind)['table'],
'entity_id' => $edition->subject_key, 'actor_user_id' => $actor->id, 'action' => $action, 'data_classification' => 'restricted',
'before_data' => json_encode(Arr::only(get_object_vars($before), ['id', 'version_no', 'validation_status', 'deleted_at', 'data_classification']), JSON_THROW_ON_ERROR),
'after_data' => json_encode(['edition_id' => $edition->id, 'hash' => $edition->content_hash, 'record_id' => $edition->subject_key, 'changed_fields' => $changed], JSON_THROW_ON_ERROR)]);
}
}
File diff suppressed because it is too large Load Diff
+7
View File
@@ -241,6 +241,9 @@ class PortalProfileAccess
public function canEdit(User $actor, Opportunity $record, ?string $newType = null): bool 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) { if ($record->owner_user_id === null) {
return $this->legacyWriteAllowed($actor); return $this->legacyWriteAllowed($actor);
} }
@@ -264,6 +267,10 @@ class PortalProfileAccess
public function state(Opportunity $record): string 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; return $record->workflow_context['working_state'] ?? $record->status;
} }
@@ -0,0 +1,217 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
/** The same opportunity owns consent, grants and history. No automatic publication. */
final class PrivateOfferConfidentiality
{
public function internal(User $actor, Opportunity $record): bool
{
$access = app(PortalProfileAccess::class);
return $access->canView($actor, $record) && $access->mfa($actor) && ! $access->has($actor, 'auditor') && ! $access->conflict($actor, $record)
&& $access->scoped($actor, 'validator', $record->organization_id, $record->opportunity_category);
}
public function seller(User $actor, Opportunity $record): bool
{
$access = app(PortalProfileAccess::class);
return (int) $record->owner_user_id === $actor->id && $access->promoterRecordScope($actor, $record)
&& $access->canCreate($actor, $record->organization_id, $record->opportunity_category) && $access->operational($actor, 'edit', $record->id);
}
/** @return array<string,mixed>|null */
public function nda(): ?array
{
$definition = config('private_offers.nda');
if (! is_array($definition) || empty($definition['approval_reference']) || empty($definition['document_id']) || empty($definition['version']) || empty($definition['sha256'])) {
return null;
}
$doc = DB::table('documents')->where('id', $definition['document_id'])->whereNull('deleted_at')->first();
if (! $doc || $doc->status !== 'active' || $doc->malware_scan_status !== 'clean' || (int) $doc->version_no !== (int) $definition['version']
|| ! hash_equals(trim($doc->sha256), $definition['sha256']) || ! hash_equals(trim($doc->sha256), trim((string) $doc->malware_scanned_sha256))) {
return null;
}
try {
app(VerifiedDocumentBinary::class)->respond($doc);
} catch (HttpExceptionInterface $error) {
return null;
}
$result = ['document_id' => $doc->id, 'version' => (int) $doc->version_no, 'sha256' => trim($doc->sha256), 'approval_reference' => $definition['approval_reference']];
return [...$result, 'fingerprint' => hash('sha256', json_encode($result, JSON_THROW_ON_ERROR))];
}
private function company(int $id, bool $lock = false): Opportunity
{
$record = Opportunity::query()->when($lock, fn ($q) => $q->lockForUpdate())->findOrFail($id);
abort_unless($record->isPrivateOfferPreparation() && $record->opportunity_category === 'business_sale', 404);
return $record;
}
private function approvedHash(Opportunity $record): string
{
abort_unless(app(OfferAvailability::class)->record($record)['state'] === 'available', 409, 'A oferta não está disponível.');
$hash = $record->workflow_context['approval']['hash'] ?? null;
abort_unless(is_string($hash) && app(PortalProfileAccess::class)->state($record) === 'approved', 409, 'Esta versão não tem aprovação editorial actual.');
try {
$snapshot = app(PrivateOfferPreparation::class)->snapshot($record);
} catch (ValidationException $error) {
abort(409, 'A versão ou os suportes exigem reconferência editorial.');
}
abort_unless(hash_equals($hash, $snapshot['hash']), 409, 'A versão mudou; é necessária reconferência editorial.');
return $hash;
}
/** PostgreSQL jsonb reorders object keys; identity is the version and checksum, not array order.
* @param array<string,mixed> $nda */
private function sameNda(mixed $accepted, array $nda): bool
{
if (! is_array($accepted)) {
return false;
}
foreach (['document_id', 'version', 'sha256', 'approval_reference', 'fingerprint'] as $key) {
if (($accepted[$key] ?? null) !== $nda[$key]) {
return false;
}
}
return true;
}
/** @return array<string,mixed> */
public function overview(User $actor, int $id): array
{
$record = $this->company($id);
// An interested party learns no private title, contacts, documents or financial values.
$seller = $this->seller($actor, $record);
$nda = $this->nda();
$context = $record->workflow_context['offer_confidentiality'] ?? [];
$currentHash = null;
try {
$currentHash = $this->approvedHash($record);
} catch (HttpExceptionInterface $error) {
// A previous acceptance remains withdrawable even if the offer was revised.
abort_unless($seller || isset($context['acceptances'][(string) $actor->id]), 404);
}
$history = array_values(array_filter($context['events'] ?? [], fn ($event) => $seller || $event['interested_user_id'] === $actor->id));
return ['offer_id' => $id, 'nda' => $nda, 'nda_pending' => $nda === null, 'version_hash' => $record->workflow_context['approval']['hash'] ?? null,
'etag' => $seller ? app(OpportunityManagement::class)->etag($record) : null,
'can_accept' => ! $seller && $nda !== null && $currentHash !== null && ! app(PortalProfileAccess::class)->conflict($actor, $record),
'can_authorize' => $seller && $nda !== null && $currentHash !== null,
'seller' => $seller, 'requests' => $seller ? array_values($context['acceptances'] ?? []) : [],
'grants' => $seller ? array_values($context['grants'] ?? []) : [],
'history' => array_map(fn ($event) => array_intersect_key($event, array_flip(['id', 'action', 'actor_id', 'at', 'version_hash', 'nda'])), $history),
'acceptance' => $context['acceptances'][(string) $actor->id] ?? null];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function act(User $actor, int $id, array $input): array
{
$data = validator($input, ['action' => 'required|in:accept,authorize,revoke,withdraw', 'version_hash' => 'required|string|size:64',
'nda_sha256' => 'required_unless:action,revoke,withdraw|string|size:64', 'acceptance_id' => 'required_if:action,authorize|uuid',
'nda_fingerprint' => 'required_if:action,accept,authorize|string|size:64',
'interested_user_id' => 'required_if:action,authorize,revoke|integer|min:1', 'confirm' => 'required|accepted',
'reason' => ['nullable', 'required_if:action,authorize,revoke', 'string', 'max:1000', ...(in_array($input['action'] ?? null, ['authorize', 'revoke'], true) ? ['min:20'] : [])]])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
$record = $this->company($id, true);
$context = $record->workflow_context;
$privacy = $context['offer_confidentiality'] ?? ['acceptances' => [], 'grants' => [], 'events' => []];
$action = $data['action'];
$target = in_array($action, ['authorize', 'revoke'], true) ? (string) $data['interested_user_id'] : (string) $actor->id;
$before = ['acceptance' => $privacy['acceptances'][$target] ?? null, 'grant' => $privacy['grants'][$target] ?? null];
if (in_array($action, ['authorize', 'revoke'], true)) {
abort_unless($this->seller($actor, $record), 403);
} elseif ($action === 'accept') {
abort_if($this->seller($actor, $record) || app(PortalProfileAccess::class)->conflict($actor, $record), 403);
}
if (in_array($action, ['accept', 'authorize'], true)) {
$nda = $this->nda();
abort_unless($nda !== null, 409, 'O exemplar aprovado do NDA ainda não foi disponibilizado.');
abort_unless(hash_equals($this->approvedHash($record), $data['version_hash']) && hash_equals($nda['sha256'], $data['nda_sha256'])
&& hash_equals($nda['fingerprint'], $data['nda_fingerprint']), 409);
$subject = User::findOrFail((int) $target);
abort_unless($subject->hasVerifiedEmail() && ! app(PortalProfileAccess::class)->conflict($subject, $record), 403);
if ($action === 'accept') {
$privacy['acceptances'][$target] = ['id' => (string) Str::uuid(), 'actor_id' => $actor->id, 'at' => now()->toIso8601String(),
'version_hash' => $data['version_hash'], 'nda' => $nda, 'access_revision' => (int) $subject->portal_access_revision];
unset($privacy['grants'][$target]); // A fresh acceptance never reuses a previous seller decision.
} else {
$acceptance = $privacy['acceptances'][$target] ?? null;
abort_unless($acceptance && $acceptance['id'] === $data['acceptance_id'] && $acceptance['version_hash'] === $data['version_hash']
&& $this->sameNda($acceptance['nda'], $nda) && $acceptance['access_revision'] === (int) $subject->portal_access_revision, 409);
$privacy['grants'][$target] = ['interested_user_id' => (int) $target, 'seller_id' => $actor->id, 'at' => now()->toIso8601String(),
'version_hash' => $data['version_hash'], 'acceptance_id' => $acceptance['id'], 'nda' => $nda];
}
} else {
$previous = $privacy[$action === 'withdraw' ? 'acceptances' : 'grants'][$target] ?? null;
abort_unless($previous && $previous['version_hash'] === $data['version_hash'], 409);
unset($privacy['grants'][$target]);
if ($action === 'withdraw') {
unset($privacy['acceptances'][$target]);
}
}
$event = ['id' => (string) Str::uuid(), 'action' => $action, 'actor_id' => $actor->id, 'interested_user_id' => (int) $target,
'version_hash' => $data['version_hash'], 'at' => now()->toIso8601String(), 'nda_sha256' => $data['nda_sha256'] ?? null,
'nda' => $nda ?? ($before['acceptance']['nda'] ?? $before['grant']['nda'] ?? null)];
$privacy['events'][] = [...$event, 'reason' => Crypt::encryptString($data['reason'] ?? ''), 'before' => $before,
'after' => ['acceptance' => $privacy['acceptances'][$target] ?? null, 'grant' => $privacy['grants'][$target] ?? null]];
$context['offer_confidentiality'] = $privacy;
$record->workflow_context = $context;
$record->management_version++;
$record->save();
app(PortalProfileAccess::class)->audit($actor, 'offer_confidentiality_'.$action, ['record_id' => $id, ...$event]);
return ['recorded' => true, 'event_id' => $event['id']];
});
}
/** @return array<string,mixed> */
public function read(User $actor, int $id): array
{
return DB::transaction(function () use ($actor, $id) {
$record = $this->company($id, true);
$nda = $this->nda();
$privacy = $record->workflow_context['offer_confidentiality'] ?? [];
$accept = $privacy['acceptances'][(string) $actor->id] ?? null;
$grant = $privacy['grants'][(string) $actor->id] ?? null;
$hash = $this->approvedHash($record);
$seller = User::find($record->owner_user_id);
$subject = User::find($actor->id);
$allowed = $nda && $accept && $grant && $seller && $subject && $this->seller($seller, $record) && $subject->hasVerifiedEmail()
&& $accept['access_revision'] === (int) $subject->portal_access_revision && $grant['seller_id'] === $seller->id
&& $grant['acceptance_id'] === $accept['id'] && $grant['version_hash'] === $hash && $accept['version_hash'] === $hash
&& $this->sameNda($grant['nda'], $nda) && $this->sameNda($accept['nda'], $nda) && ! app(PortalProfileAccess::class)->conflict($actor, $record);
app(PortalProfileAccess::class)->audit($actor, $allowed ? 'offer_confidential_access' : 'offer_confidential_denied', ['record_id' => $id, 'version_hash' => $hash,
'nda' => $nda, 'acceptance_id' => $accept['id'] ?? null]);
// Return the refusal after transaction commit so the denied-access audit survives.
if (! $allowed) {
return ['denied' => true];
}
$business = $record->workflow_context['submission']['payload']['private_offer']['business'] ?? [];
foreach (['sector_id' => 'sector', 'secondary_sector_id' => 'secondary_sector'] as $field => $label) {
if (isset($business[$field])) {
$business[$label] = DB::table('sectors')->where('id', $business[$field])->value('name');
}
unset($business[$field]);
}
return ['offer_id' => $id, 'version_hash' => $hash, 'business' => $business];
});
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/** Optional commercial fields confirmed by Tempo for phase four; no title certification. */
final class PrivateOfferDetails
{
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function validate(array $input): array
{
$bytes = file_get_contents(resource_path('data/investmoz-opportunity-modular.schema.json'));
if ($bytes === false) {
throw new \RuntimeException('Opportunity schema unavailable.');
}
$schema = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
$property = $schema['$defs']['realEstatePatch']['properties'];
return Validator::make($input, [
'business' => 'sometimes|nullable|array:sector_id,secondary_sector_id,revenue,cash_flow,currency,reason,availability',
'business.sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->where('is_active', true)],
'business.secondary_sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->where('is_active', true)],
'business.revenue' => ['nullable', 'string', 'regex:/^\d{1,16}(\.\d{1,2})?$/'],
'business.cash_flow' => ['nullable', 'string', 'regex:/^-?\d{1,16}(\.\d{1,2})?$/'],
'business.currency' => 'nullable|required_with:business.revenue,business.cash_flow|in:MZN,USD',
'business.reason' => 'nullable|string|max:1000', 'business.availability' => 'nullable|in:available,sold',
'property' => 'sometimes|nullable|array:transaction_type,property_type,land_area,built_area,area_unit,bedrooms,bathrooms,amenities,availability',
'property.transaction_type' => 'nullable|in:sale,lease',
'property.property_type' => ['nullable', Rule::in($property['property_type']['enum'])],
'property.land_area' => 'nullable|numeric|gt:0', 'property.built_area' => 'nullable|numeric|gt:0',
'property.area_unit' => ['nullable', Rule::in(OpportunityResources::AREA_UNITS)],
'property.bedrooms' => 'nullable|integer|min:0|max:10000', 'property.bathrooms' => 'nullable|integer|min:0|max:10000',
'property.amenities' => 'nullable|array|max:100', 'property.amenities.*' => 'string|max:250',
'property.availability' => 'nullable|in:available,sold,leased',
])->validate();
}
/** @param array<string,mixed> $offer */
public function assertCategory(array $offer, string $category): void
{
Validator::make($offer, [
'business' => $category === 'business_sale' ? 'nullable|array' : 'prohibited',
'property' => $category === 'real_estate' ? 'nullable|array' : 'prohibited',
])->validate();
}
/** Snapshot all active linked originals and metadata in the existing editorial version.
* @return array<string,array<string,mixed>> */
public function resources(Opportunity $record): array
{
$result = ['documents' => [], 'images' => [], 'locations' => []];
foreach (array_keys($result) as $kind) {
foreach (app(OpportunityResources::class)->listing($record->id, $kind) as $row) {
if ($row->deleted_at !== null) {
continue;
}
if ($kind !== 'locations') {
$file = $row->file;
$doc = DB::table('documents')->where('id', $row->document_id)->first();
if (! $doc || $doc->deleted_at !== null || $doc->status !== 'active' || $doc->malware_scan_status !== 'clean'
|| ! hash_equals(trim($doc->sha256), trim((string) $doc->malware_scanned_sha256))) {
throw ValidationException::withMessages(['attachments' => 'Aguarde uma análise de segurança válida dos anexos.']);
}
$result[$kind][$row->id] = ['version' => $row->version, 'document_id' => $row->document_id, 'payload' => $row->payload, 'file' => $file];
} else {
$result[$kind][$row->id] = ['version' => $row->version, 'payload' => $row->payload];
}
}
ksort($result[$kind]);
}
if (count($result['images']) > 6) {
throw ValidationException::withMessages(['images' => 'A oferta admite no máximo seis fotografias.']);
}
return $result;
}
}
+296
View File
@@ -0,0 +1,296 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
/** Availability, reports and administrative plans in the existing opportunity and CRM tasks. */
final class PrivateOfferOperations
{
/** @param array<string,mixed> $context
* @return array<string,mixed> */
public function afterApproval(array $context): array
{
$days = config('private_offers.validity_days');
$reference = config('private_offers.validity_policy_reference');
if ($days !== null && (! is_int($days) || $days < 1 || $days > 36500 || ! is_string($reference) || trim($reference) === '')) {
throw ValidationException::withMessages(['validity' => 'Configuração de validade incompleta. Não foi aplicado um prazo presumido.']);
}
$operation = $context['offer_operation'] ?? ['status' => 'available', 'cycle' => 0];
$hash = $context['approval']['hash'];
$policy = ['days' => $days, 'policy_reference' => $reference, 'approval_hash' => $hash];
$operation['validity_policies'][$hash] ??= $policy;
$operation['validity'] ??= $operation['validity_policies'][$hash];
$operation['last_approval_hash'] = $hash;
return $operation;
}
private function offer(int $id, bool $lock = false): Opportunity
{
$record = Opportunity::query()->when($lock, fn ($q) => $q->lockForUpdate())->findOrFail($id);
abort_unless($record->isPrivateOfferPreparation(), 404);
return $record;
}
public function publisher(User $actor, Opportunity $record): bool
{
$access = app(PortalProfileAccess::class);
return $access->canView($actor, $record) && $access->mfa($actor) && ! $access->has($actor, 'auditor') && ! $access->conflict($actor, $record)
&& $access->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category) && $access->operational($actor, 'publish', $record->id);
}
/** @return Builder<Opportunity> */
public function staffQuery(User $actor): Builder
{
$access = app(PortalProfileAccess::class);
abort_unless($access->mfa($actor) && ! $access->has($actor, 'auditor'), 403);
$grants = [...$access->grants($actor, 'validator'), ...$access->grants($actor, 'publisher')];
abort_unless($grants !== [], 403);
return $access->scope(Opportunity::query(), $actor)->whereNotNull('workflow_context->private_offer_preparation')
->where('owner_user_id', '!=', $actor->id)->whereRaw("NOT COALESCE(workflow_context->'contributors','[]'::jsonb) @> ?::jsonb", [json_encode([$actor->id])])
->where(fn ($q) => $q->whereNull('organization_id')->orWhereNotIn('organization_id', DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->select('organization_id')))
->where(function ($q) use ($grants) {
foreach ($grants as $grant) {
$q->orWhere(function ($scope) use ($grant) {
$scope->whereIn('opportunity_category', json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR));
if ($grant->organization_id !== null) {
$scope->where('organization_id', $grant->organization_id);
}
});
}
});
}
/** @return array<string,mixed> */
public function presentation(User $actor, int $id): array
{
$record = app(PrivateOfferPreparation::class)->record($actor, $id);
$reviewer = app(PrivateOfferConfidentiality::class)->internal($actor, $record);
$publisher = $this->publisher($actor, $record);
$reports = [];
if ($reviewer) {
foreach ($record->workflow_context['offer_reports'] ?? [] as $report) {
$reports[] = [...$report, 'reason' => Crypt::decryptString($report['reason']), 'resolution_reason' => isset($report['resolution_reason']) ? Crypt::decryptString($report['resolution_reason']) : null,
'source_snapshot' => isset($report['source_snapshot']) ? json_decode(Crypt::decryptString($report['source_snapshot']), true, 512, JSON_THROW_ON_ERROR) : null];
}
}
$highlight = $record->workflow_context['offer_highlight'] ?? null;
return ['id' => $id, 'etag' => app(OpportunityManagement::class)->etag($record),
'availability' => app(OfferAvailability::class)->record($record), 'cycle' => $record->workflow_context['offer_operation']['cycle'] ?? 0,
'owner' => app(PrivateOfferConfidentiality::class)->seller($actor, $record), 'reviewer' => $reviewer, 'publisher' => $publisher,
'editorial_state' => app(PortalProfileAccess::class)->state($record), 'reports' => $reports,
'highlight' => $publisher && $highlight ? [...$highlight, 'note' => Crypt::decryptString($highlight['note'])] : null,
'commercial_activation' => false,
'history' => array_map(fn ($event) => array_intersect_key($event, array_flip(['id', 'action', 'actor_id', 'at', 'cycle'])), $record->workflow_context['offer_operation_history'] ?? [])];
}
/** @param array<string,mixed> $context
* @param array<string,mixed> $metadata */
private function save(Opportunity $record, ?User $actor, array $context, string $action, array $metadata = []): void
{
$event = ['id' => (string) Str::uuid(), 'action' => $action, 'actor_id' => $actor?->id, 'at' => now()->toIso8601String(),
'cycle' => $context['offer_operation']['cycle'] ?? 0, ...$metadata];
$context['offer_operation_history'][] = $event;
$record->workflow_context = $context;
$record->management_version++;
$record->save();
DB::table('audit_events')->insert(['source_system' => 'private-offers', 'entity_type' => 'opportunity', 'actor_user_id' => $actor?->id,
'action' => $action, 'data_classification' => 'restricted', 'correlation_id' => (string) Str::uuid(),
'after_data' => json_encode(['record_id' => $record->id, 'record_version' => $record->management_version, ...$event], JSON_THROW_ON_ERROR)]);
}
/** @param array<string,mixed> $context
* @return array<string,mixed> */
private function withdraw(array $context, string $origin): array
{
$context['offer_operation'] = [...($context['offer_operation'] ?? ['cycle' => 0]), 'status' => 'withdrawn', 'withdrawn_at' => now()->toIso8601String(), 'origin' => $origin];
return $context;
}
/** @param array<string,mixed> $input */
public function ownerAction(User $actor, int $id, array $input): Opportunity
{
$data = validator($input, ['action' => 'required|in:withdraw,request_reactivation,reactivate', 'confirm' => 'required|accepted',
'etag' => 'required_unless:action,withdraw|string|size:64', 'reason' => 'nullable|string|max:1000'])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
$record = $this->offer($id, true);
abort_unless(app(PrivateOfferConfidentiality::class)->seller($actor, $record), 403);
$context = $record->workflow_context;
$state = app(OfferAvailability::class)->record($record)['state'];
if ($data['action'] === 'withdraw') {
if (($context['offer_operation']['status'] ?? null) === 'withdrawn') {
return $record;
}
$this->save($record, $actor, $this->withdraw($context, 'owner'), 'offer_withdrawn', ['reason' => Crypt::encryptString($data['reason'] ?? '')]);
return $record;
}
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409);
if ($data['action'] === 'request_reactivation') {
abort_unless(in_array($state, ['withdrawn', 'expired'], true), 409, 'A oferta não está retirada ou expirada.');
$editorialState = app(PortalProfileAccess::class)->state($record);
abort_unless(isset($context['submission']) || in_array($editorialState, ['draft', 'needs_information'], true), 409,
'A preparação anterior ainda precisa da vinculação editorial existente antes de abrir nova revisão.');
$context['offer_operation'] = [...($context['offer_operation'] ?? []), 'status' => 'revalidation', 'cycle' => ($context['offer_operation']['cycle'] ?? 0) + 1,
'requested_by' => $actor->id, 'requested_at' => now()->toIso8601String()];
// Reopen the same editorial engine, retaining old submissions and decisions.
$record->workflow_context = $context;
$record->save();
if (isset($context['submission'])) {
$record = app(OpportunityProfileWorkflow::class)->transition($actor, $id, ['etag' => app(OpportunityManagement::class)->etag($record),
'action' => 'request_revision', 'reason' => 'Pedido explícito de nova validação para reactivar a oferta. '.($data['reason'] ?? '')]);
}
$this->save($record, $actor, $record->workflow_context, 'offer_reactivation_requested');
return $record;
}
abort_unless($state === 'revalidation' && app(PortalProfileAccess::class)->state($record) === 'approved', 409, 'É necessária nova validação editorial.');
$snapshot = app(PrivateOfferPreparation::class)->snapshot($record);
abort_unless(($snapshot['payload']['operational_cycle'] ?? null) === $context['offer_operation']['cycle']
&& hash_equals($context['approval']['hash'] ?? '', $snapshot['hash'])
&& ($context['submission']['payload']['operational_cycle'] ?? null) === $context['offer_operation']['cycle'], 409);
$context['offer_operation']['status'] = 'available';
$context['offer_operation']['activated_hash'] = $snapshot['hash'];
$this->save($record, $actor, $context, 'offer_reactivated_privately');
return $record;
});
}
public function expire(int $id): bool
{
return DB::transaction(function () use ($id) {
$record = $this->offer($id, true);
$availability = app(OfferAvailability::class)->record($record);
if ($availability['state'] !== 'expired' || ($record->workflow_context['offer_operation']['status'] ?? null) === 'expired') {
return false;
}
$context = $record->workflow_context;
$context['offer_operation'] = [...($context['offer_operation'] ?? []), 'status' => 'expired', 'expired_at' => $availability['expires_at']];
$this->save($record, null, $context, 'offer_expired', ['expires_at' => $availability['expires_at']]);
return true;
});
}
public function canReport(User $actor, Opportunity $record): bool
{
if (app(PortalProfileAccess::class)->canView($actor, $record)) {
return true;
}
try {
app(OfferPublicCatalogue::class)->detail($record->id);
return true;
} catch (HttpExceptionInterface $error) {
return false;
}
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function report(User $actor, int $id, array $input): array
{
$data = validator($input, ['client_key' => 'required|uuid', 'reason' => 'required|string|min:20|max:2000', 'confirm' => 'required|accepted'])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
$record = $this->offer($id, true);
$context = $record->workflow_context;
$key = $data['client_key'];
$hash = hash('sha256', $data['reason']);
if (isset($context['offer_reports'][$key])) {
$previous = $context['offer_reports'][$key];
abort_unless($previous['reporter_id'] === $actor->id, 404);
abort_unless($previous['command_hash'] === $hash, 409);
return ['id' => $key, 'replayed' => true];
}
abort_unless($this->canReport($actor, $record), 404);
try {
$source = app(OfferPublicCatalogue::class)->detail($id);
} catch (HttpExceptionInterface $error) {
$source = Arr::only(app(OpportunityManagement::class)->values($record), ['title', 'description', 'opportunity_category', 'private_offer']);
}
$task = app(OpportunityWorkflowTasks::class)->ensure($record, 'offer_report_'.$key, 'Denúncia da Oferta '.$record->reference);
$context['offer_reports'][$key] = ['id' => $key, 'reporter_id' => $actor->id, 'at' => now()->toIso8601String(), 'command_hash' => $hash,
'reason' => Crypt::encryptString($data['reason']), 'version' => 1, 'status' => 'open', 'task_id' => $task,
'source_snapshot' => Crypt::encryptString(json_encode($source, JSON_THROW_ON_ERROR)),
'source_record_version' => $record->management_version, 'source_approval_hash' => $context['approval']['hash'] ?? null,
'source_public_hash' => $context['offer_publication']['publication']['hash'] ?? null];
$this->save($record, $actor, $context, 'offer_reported', ['report_id' => $key]);
return ['id' => $key, 'replayed' => false];
});
}
/** @param array<string,mixed> $input */
public function handleReport(User $actor, int $id, string $reportId, array $input): void
{
$data = validator($input, ['etag' => 'required|string|size:64', 'version' => 'required|integer|min:1', 'action' => 'required|in:start_review,resolve_no_withdrawal,resolve_withdrawal',
'reason' => 'required|string|min:20|max:2000', 'confirm' => 'required|accepted'])->validate();
DB::transaction(function () use ($actor, $id, $reportId, $data) {
$record = $this->offer($id, true);
abort_unless(app(PrivateOfferConfidentiality::class)->internal($actor, $record), 403);
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409);
$context = $record->workflow_context;
$report = $context['offer_reports'][$reportId] ?? null;
abort_unless($report !== null, 404);
abort_unless($report['version'] === (int) $data['version'] && in_array($report['status'], ['open', 'in_review'], true), 409);
if ($data['action'] === 'start_review') {
abort_unless($report['status'] === 'open', 409);
app(OpportunityWorkflowTasks::class)->assign($record, $report['task_id'], $actor);
$report['status'] = 'in_review';
} else {
abort_unless($report['status'] === 'in_review' && $report['reviewer_id'] === $actor->id, 409, 'A denúncia deve ser tratada pelo revisor que a iniciou.');
app(OpportunityWorkflowTasks::class)->complete($report['task_id'], 'Denúncia tratada; decisão no histórico privado.');
$report['status'] = 'resolved';
$report['resolution'] = $data['action'];
if ($data['action'] === 'resolve_withdrawal') {
$context = $this->withdraw($context, 'moderation');
}
}
$report['reviewer_id'] = $actor->id;
$report['version']++;
$report['updated_at'] = now()->toIso8601String();
$report['resolution_reason'] = Crypt::encryptString($data['reason']);
$report['history'][] = ['action' => $data['action'], 'actor_id' => $actor->id, 'at' => now()->toIso8601String(), 'reason' => Crypt::encryptString($data['reason'])];
$context['offer_reports'][$reportId] = $report;
$this->save($record, $actor, $context, 'offer_report_'.$data['action'], ['report_id' => $reportId]);
});
}
/** @param array<string,mixed> $input */
public function highlight(User $actor, int $id, array $input): void
{
$data = validator($input, ['etag' => 'required|string|size:64', 'version' => 'required|integer|min:0', 'status' => 'required|in:draft,archived',
'label' => 'required|string|max:150', 'starts_at' => 'nullable|date', 'ends_at' => 'nullable|required_with:starts_at|date|after:starts_at',
'note' => 'required|string|min:20|max:1000', 'confirm' => 'required|accepted'])->validate();
DB::transaction(function () use ($actor, $id, $data) {
$record = $this->offer($id, true);
abort_unless($this->publisher($actor, $record), 403);
$context = $record->workflow_context;
$before = $context['offer_highlight'] ?? null;
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']) && (int) $data['version'] === ($before['version'] ?? 0), 409);
$plan = ['version' => (int) $data['version'] + 1, 'status' => $data['status'], 'label' => $data['label'], 'starts_at' => $data['starts_at'] ?? null,
'ends_at' => $data['ends_at'] ?? null, 'note' => Crypt::encryptString($data['note']), 'actor_id' => $actor->id, 'at' => now()->toIso8601String()];
$context['offer_highlight_history'][] = $before;
$context['offer_highlight'] = $plan;
$this->save($record, $actor, $context, 'offer_highlight_prepared', ['plan_version' => $plan['version'], 'status' => $plan['status']]);
});
}
}
+230
View File
@@ -0,0 +1,230 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/** Private commercial adapter: the existing opportunity owns identity, workflow and audit. */
final class PrivateOfferPreparation
{
/** @return array<string,mixed> */
public function validate(mixed $input, bool $complete = false): array
{
$validated = Validator::make(['private_offer' => $input], [
'private_offer' => 'present|array:price_mode,amount,currency,contact_id,business,property',
'private_offer.price_mode' => 'nullable|in:amount,negotiable',
'private_offer.amount' => ['nullable', 'string', ...($complete ? ['required_if:private_offer.price_mode,amount'] : []), 'prohibited_unless:private_offer.price_mode,amount', 'regex:/^\d{1,16}(\.\d{1,2})?$/'],
'private_offer.currency' => ['nullable', ...($complete ? ['required_if:private_offer.price_mode,amount'] : []), 'prohibited_unless:private_offer.price_mode,amount', 'in:MZN,USD'],
'private_offer.contact_id' => 'nullable|uuid',
'private_offer.business' => 'sometimes|nullable|array',
'private_offer.property' => 'sometimes|nullable|array',
])->validate()['private_offer'];
return array_replace($validated, app(PrivateOfferDetails::class)->validate(array_intersect_key($validated, array_flip(['business', 'property']))));
}
/** @param array<string,mixed> $input */
public function save(User $actor, array $input, ?int $id = null): Opportunity
{
foreach (['title', 'description'] as $field) {
if (is_string($input[$field] ?? null)) {
$input[$field] = trim($input[$field]);
}
}
$allowed = ['creation_key', 'etag', 'reason', 'title', 'description', 'opportunity_category', 'organization_id', 'private_offer', ...array_keys(InvestmentGeography::FIELDS)];
if (array_diff(array_keys($input), $allowed)) {
throw ValidationException::withMessages(['offer' => 'O pedido contém campos fora da preparação privada.']);
}
Validator::make($input, ['opportunity_category' => ['sometimes', 'required', Rule::in(OpportunityManagement::CATEGORIES)]])->validate();
return DB::transaction(function () use ($actor, $input, $id) {
$record = app(OpportunityManagement::class)->save($actor, $input, $id, true);
$values = app(OpportunityManagement::class)->values($record);
app(PrivateOfferDetails::class)->assertCategory($values['private_offer'] ?? [], $values['opportunity_category']);
$contactId = app(OpportunityManagement::class)->values($record)['private_offer']['contact_id'] ?? null;
if ($contactId && ! app(OpportunityContacts::class)->related($record, $contactId)) {
throw ValidationException::withMessages(['private_offer.contact_id' => 'Seleccione um contacto autorizado do promotor.']);
}
return $record;
});
}
public function record(User $actor, int $id, bool $edit = false): Opportunity
{
$record = Opportunity::findOrFail($id);
abort_unless($record->isPrivateOfferPreparation(), 404);
app(PortalProfileAccess::class)->authorize($actor, $edit ? 'edit' : 'view', $record);
return $record;
}
/** Register submission through the existing editorial engine when available.
* @param array<string,mixed> $input */
public function submit(User $actor, int $id, array $input): Opportunity
{
$data = Validator::make($input, ['etag' => 'required|string|size:64', 'confirm' => 'required|accepted'])->validate();
if (app(PortalEditorialWorkflow::class)->available()) {
$record = $this->record($actor, $id);
$access = app(PortalProfileAccess::class);
if ($access->state($record) === 'submitted') {
abort_unless($access->promoterRecordScope($actor, $record) && $access->canCreate($actor, $record->organization_id, $record->opportunity_category)
&& $access->operational($actor, 'edit', $id), 403);
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409);
return $record;
}
return app(OpportunityProfileWorkflow::class)->transition($actor, $id, [...$data, 'action' => 'submit', 'reason' => 'Submissão privada da oferta conferida pelo promotor.']);
}
return DB::transaction(function () use ($actor, $id, $data) {
$record = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail();
abort_unless($record->isPrivateOfferPreparation(), 404);
$access = app(PortalProfileAccess::class);
$access->authorize($actor, 'view', $record);
abort_if(($record->workflow_context['private_offer_preparation']['version'] ?? 1) >= 2, 503, 'O circuito editorial está indisponível. A versão e o histórico foram preservados; não é permitida a retoma pelo percurso anterior.');
abort_unless($access->promoterRecordScope($actor, $record)
&& $access->canCreate($actor, $record->organization_id, $record->opportunity_category)
&& $access->operational($actor, 'edit', $record->id), 403);
$management = app(OpportunityManagement::class);
abort_unless(hash_equals($management->etag($record), $data['etag']), 409, 'A oferta mudou. Recupere a versão actual antes de submeter.');
if (data_get($record->workflow_context, 'private_offer_preparation.state') === 'submitted') {
return $record;
}
$access->authorize($actor, 'edit', $record);
$values = $management->values($record);
Validator::make($values, ['title' => 'required|string|max:150', 'description' => 'required|string|max:50000',
'opportunity_category' => 'required', 'private_offer.price_mode' => 'required|in:amount,negotiable', 'private_offer.contact_id' => 'required|uuid'])->validate();
$this->validate($values['private_offer'], true);
if (! app(OpportunityContacts::class)->related($record, $values['private_offer']['contact_id'])) {
throw ValidationException::withMessages(['private_offer.contact_id' => 'O contacto já não está disponível neste âmbito.']);
}
// Recheck revoked grants and representation inside the same transaction.
abort_unless($access->canCreate($actor, $record->organization_id, $record->opportunity_category), 403);
$path = app(InvestmentGeography::class)->path($record->primary_territory_id, true);
if ($record->primary_territory_id && $path === []) {
throw ValidationException::withMessages(['province_id' => 'Reconcilie a localização antes de submeter.']);
}
if ($path !== []) {
app(InvestmentGeography::class)->selection(array_combine(array_slice(array_keys(InvestmentGeography::FIELDS), 0, count($path)), array_column($path, 'id')), true);
}
$context = $record->workflow_context;
$context['private_offer_preparation'] = ['version' => 1, 'state' => 'submitted', 'submitted_by' => $actor->id,
'submitted_at' => now()->toIso8601String(), 'submitted_revision' => $record->management_version];
$record->workflow_context = $context;
$record->management_version++;
$record->save();
$access->audit($actor, 'private_offer_submitted', ['record_id' => $record->id, 'version' => $record->management_version, 'public' => false]);
return $record;
});
}
/** Adapter of the five common fields; never relaxes the full canonical contract.
* @return array<string,mixed> */
public function snapshot(Opportunity $record): array
{
$values = app(OpportunityManagement::class)->values($record);
Validator::make($values, ['title' => 'required|string|max:150', 'description' => 'required|string|max:50000',
'opportunity_category' => 'required', 'private_offer.price_mode' => 'required|in:amount,negotiable', 'private_offer.contact_id' => 'required|uuid'])->validate();
$offer = $this->validate($values['private_offer'], true);
app(PrivateOfferDetails::class)->assertCategory($offer, $values['opportunity_category']);
$resources = app(PrivateOfferDetails::class)->resources($record);
if (! app(OpportunityContacts::class)->related($record, $offer['contact_id'])) {
throw ValidationException::withMessages(['private_offer.contact_id' => 'O contacto já não está disponível neste âmbito.']);
}
$contact = DB::table('contacts')->where('id', $offer['contact_id'])->first();
$path = app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true);
if (! empty($values['primary_territory_id']) && $path === []) {
throw ValidationException::withMessages(['province_id' => 'Reconcilie a localização antes da decisão.']);
}
if ($path !== []) {
app(InvestmentGeography::class)->selection(array_combine(array_slice(array_keys(InvestmentGeography::FIELDS), 0, count($path)), array_column($path, 'id')), true);
}
$payload = ['title' => $values['title'], 'description' => $values['description'], 'opportunity_category' => $values['opportunity_category'],
'owner_user_id' => $record->owner_user_id, 'organization_id' => $record->organization_id, 'private_offer' => $offer,
'contact_fingerprint' => hash('sha256', json_encode($contact, JSON_THROW_ON_ERROR)), 'territory' => $path];
$extended = isset($offer['business']) || isset($offer['property']) || array_filter($resources) !== [];
if ($extended) {
$payload['resources'] = $resources;
}
$cycle = $record->workflow_context['offer_operation']['cycle'] ?? 0;
if ($cycle > 0) {
$payload['operational_cycle'] = $cycle;
}
$normalize = function (mixed $value) use (&$normalize): mixed {
if (is_object($value)) {
$value = get_object_vars($value);
}
if (! is_array($value)) {
return $value;
}
if (! array_is_list($value)) {
ksort($value);
}
return array_map($normalize, $value);
};
$payload = $normalize($payload);
$contract = $cycle > 0 ? 'private-offer-editorial-v4' : ($extended ? 'private-offer-editorial-v3' : 'private-offer-editorial-v2');
return ['payload' => $payload, 'hash' => hash('sha256', json_encode(['contract' => $contract, 'payload' => $payload], JSON_THROW_ON_ERROR)),
'record_version' => $record->management_version, 'contract_version' => $contract, 'resources' => $resources];
}
/** @return array<string,mixed> */
public function presentation(User $actor, Opportunity $record): array
{
app(PortalProfileAccess::class)->authorize($actor, 'view', $record);
$values = app(OpportunityManagement::class)->values($record);
$fresh = null;
$contactId = $values['private_offer']['contact_id'] ?? null;
$contactLabel = $contactId && app(OpportunityContacts::class)->related($record, $contactId)
? DB::table('contacts')->where('id', $contactId)->value('full_name') : null;
if (isset($record->workflow_context['approval'])) {
try {
$fresh = hash_equals($record->workflow_context['approval']['hash'], $this->snapshot($record)['hash']);
} catch (ValidationException $error) {
$fresh = false;
}
}
$history = $record->workflow_context['events'] ?? [];
$names = User::whereIn('id', array_filter([...array_column($history, 'actor_id'), ...array_column($history, 'assignee_user_id')]))->pluck('name', 'id');
$history = array_map(fn ($event) => [...$event, 'actor_name' => $names[$event['actor_id']] ?? 'Interveniente indisponível',
'assignee_name' => isset($event['assignee_user_id']) ? ($names[$event['assignee_user_id']] ?? 'Interveniente indisponível') : null], $history);
$versions = [...($record->workflow_context['decision_history'] ?? []), ...($record->workflow_context['submission_history'] ?? [])];
if (isset($record->workflow_context['submission'])) {
$versions[] = ['submission' => $record->workflow_context['submission'], 'approval' => $record->workflow_context['approval'] ?? null,
'rejection' => $record->workflow_context['rejection'] ?? null];
}
$uniqueVersions = [];
foreach ($versions as $version) {
if (! isset($version['submission']['payload']['private_offer'])) {
continue;
}
$key = $version['submission']['record_version'].':'.$version['submission']['hash'].':'.($version['submission']['review_cycle'] ?? 0);
$previous = $uniqueVersions[$key] ?? [];
$uniqueVersions[$key] = ['submission' => $version['submission'], 'approval' => $version['approval'] ?? $previous['approval'] ?? null,
'rejection' => $version['rejection'] ?? $previous['rejection'] ?? null];
}
$workflow = app(OpportunityProfileWorkflow::class);
$actions = $workflow->actions($actor, $record);
return ['id' => $record->id, 'reference' => $record->reference, 'values' => $values,
'etag' => app(OpportunityManagement::class)->etag($record), 'state' => app(PortalProfileAccess::class)->state($record),
'canEdit' => app(PortalProfileAccess::class)->canEdit($actor, $record), 'organization_id' => $record->organization_id,
'actions' => $actions,
'reviewers' => in_array('assign_review', $actions, true) ? $workflow->participants($actor, $record)['validators'] : [],
'reauthenticationRequired' => app(OpportunityProfileWorkflow::class)->reauthenticationRequired($actor, $record),
'history' => $history, 'submittedVersions' => array_values($uniqueVersions), 'correctionItems' => $record->workflow_context['correction_items'] ?? [],
'approval' => $record->workflow_context['approval'] ?? null, 'approvalFresh' => $fresh, 'contactLabel' => $contactLabel, 'tasks' => app(OpportunityWorkflowTasks::class)->listing($record),
'territory' => app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true)];
}
}
+89 -5
View File
@@ -7,6 +7,64 @@ use Illuminate\Support\Str;
/** Text matching shared by the database and the built-in page catalogue. */ /** Text matching shared by the database and the built-in page catalogue. */
final class SearchTerms 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> */ /** @return list<string> */
public function tokens(string $query): array public function tokens(string $query): array
{ {
@@ -24,13 +82,16 @@ final class SearchTerms
return $tokens; return $tokens;
} }
/** Known Portuguese spelling and number variants; do not infer semantic equivalence. */ /** Known Portuguese spelling and number variants; do not infer semantic equivalence.
* @return list<string>
*/
public function variants(string $token): array public function variants(string $token): array
{ {
foreach ([['sector', 'sectores', 'setor', 'setores'], ['projecto', 'projectos', 'projeto', 'projetos'], foreach ([['sector', 'sectores', 'setor', 'setores'], ['projecto', 'projectos', 'projeto', 'projetos'],
['investir', 'investimento', 'investimentos'], ['investidor', 'investidores'], ['oportunidade', 'oportunidades'], ['documento', 'documentos'], ['investir', 'investimento', 'investimentos'], ['investidor', 'investidores'], ['oportunidade', 'oportunidades'], ['documento', 'documentos'],
['empresa', 'empresas'], ['parceria', 'parcerias'], ['zona', 'zonas'], ['formulario', 'formularios'], ['empresa', 'empresas'], ['parceria', 'parcerias'], ['zona', 'zonas'], ['formulario', 'formularios'],
['provincia', 'provincias'], ['corredor', 'corredores'], ['cluster', 'clusters'], ['imposto', 'impostos'], ['licenca', 'licencas']] as $group) { ['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)) { if (in_array($token, $group, true)) {
return $group; return $group;
} }
@@ -44,7 +105,10 @@ final class SearchTerms
return '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $token).'%'; return '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $token).'%';
} }
/** Columns are trusted application constants, never user input. */ /** 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 public function orFields($query, array $columns, string $token): void
{ {
foreach ($columns as $column) { foreach ($columns as $column) {
@@ -54,6 +118,10 @@ final class SearchTerms
} }
} }
/** @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 public function constrain($query, array $columns, array $tokens): void
{ {
foreach ($tokens as $token) { foreach ($tokens as $token) {
@@ -61,6 +129,7 @@ final class SearchTerms
} }
} }
/** @param list<string> $tokens */
public function matches(string $text, array $tokens): bool public function matches(string $text, array $tokens): bool
{ {
$text = mb_strtolower(Str::ascii($text)); $text = mb_strtolower(Str::ascii($text));
@@ -73,11 +142,15 @@ final class SearchTerms
return true; return true;
} }
/** @param literal-string $column
* @return literal-string
*/
private function normalizedColumn(string $column): string private function normalizedColumn(string $column): string
{ {
return "translate(lower(coalesce({$column}::text, '')), 'áàâãäéèêëíìîïóòôõöúùûüçñ', 'aaaaaeeeeiiiiooooouuuucn')"; return "translate(lower(coalesce({$column}::text, '')), 'áàâãäéèêëíìîïóòôõöúùûüçñ', 'aaaaaeeeeiiiiooooouuuucn')";
} }
/** @param list<string> $tokens */
public function score(string $title, string $phrase, array $tokens): int public function score(string $title, string $phrase, array $tokens): int
{ {
$title = mb_strtolower(Str::ascii($title)); $title = mb_strtolower(Str::ascii($title));
@@ -92,7 +165,12 @@ final class SearchTerms
return $score; return $score;
} }
/** The same title score used for in-memory pages, with bound search terms. */ /** 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 public function orderByRelevance($query, string $titleColumn, string $phrase, array $tokens, string $tieColumn): void
{ {
$column = $this->normalizedColumn($titleColumn); $column = $this->normalizedColumn($titleColumn);
@@ -107,6 +185,12 @@ final class SearchTerms
} }
$sql .= ' + CASE WHEN ('.implode(' OR ', $conditions).') THEN 1 ELSE 0 END'; $sql .= ' + CASE WHEN ('.implode(' OR ', $conditions).') THEN 1 ELSE 0 END';
} }
$query->orderByRaw('('.$sql.') DESC', $bindings)->orderBy($titleColumn)->orderBy($tieColumn); $query->orderByRaw('('.$sql.') DESC', $bindings);
if (str_contains($titleColumn, '(')) {
$query->orderByRaw($titleColumn);
} else {
$query->orderBy($titleColumn);
}
$query->orderBy($tieColumn);
} }
} }
+58
View File
@@ -146,6 +146,9 @@ final class SimulationFormDrafts
} }
$this->requireMatch($request, $draft); $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; $sources[] = $sourceSnapshot;
$economic['simulation_sources'] = $sources; $economic['simulation_sources'] = $sources;
$economic['reuse_simulation'] = 'yes'; $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 */ /** @return array{id:string,title:string,updated_at:string|null,expires_at:string|null,version_no:int}|null */
public function claimPreview(Request $request): ?array public function claimPreview(Request $request): ?array
{ {
+302 -4
View File
@@ -3,9 +3,12 @@
namespace App\Services; namespace App\Services;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -36,8 +39,12 @@ final class TerritorialRegistry
'location_type' => ['required', 'in:'.implode(',', array_keys(self::PARENTS))], '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'], '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'], '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(); ])->validate();
$this->ensureWriteMode($id);
if ($this->editorialAvailable()) {
return $this->saveEditorial($actor, $data, $id);
}
return DB::transaction(function () use ($actor, $data, $id) { return DB::transaction(function () use ($actor, $data, $id) {
// Serialise hierarchy changes to prevent concurrent parent cycles and duplicate paths. // 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); '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), 'version_no' => $version, 'actor_id' => $actor->id, 'reason' => Crypt::encryptString($reason),
'snapshot' => Crypt::encryptString($snapshot), 'checksum' => hash('sha256', $snapshot)]); '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']];
});
} }
} }
+14 -1
View File
@@ -61,7 +61,11 @@ class VerifiedDocumentBinary
if ($table === 'opportunity_images') { if ($table === 'opportunity_images') {
$query->orWhereRaw("payload->'derivatives' @> ?::jsonb", [json_encode([['document_id' => $document->id]], JSON_THROW_ON_ERROR)]); $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); $privateOrigin = in_array($document->source_system ?? null, ['opportunity-resources', 'opportunity-image-derivatives'], true);
if (! $privateOrigin) { if (! $privateOrigin) {
@@ -72,6 +76,15 @@ class VerifiedDocumentBinary
} }
$actor = request()->user(); $actor = request()->user();
abort_unless($actor instanceof User, 403, 'Anexo privado: é necessário acesso autorizado à oportunidade.'); 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) { foreach ($links as $link) {
$opportunity = Opportunity::whereKey($link->opportunity_id)->first(); $opportunity = Opportunity::whereKey($link->opportunity_id)->first();
if ($link->deleted_at === null && $opportunity && $opportunity->status !== 'archived' if ($link->deleted_at === null && $opportunity && $opportunity->status !== 'archived'
+11
View File
@@ -0,0 +1,11 @@
<?php
return [
// Remote activation is explicit; local/QA behaviour remains unchanged.
'enabled' => env('DEMOGRAPHY_ENABLED', env('APP_URL') !== 'https://teste-investidor.tempo.co.mz'
&& in_array(env('APP_ENV', 'production'), ['local', 'testing'], true)),
'source_directory' => env('DEMOGRAPHY_SOURCE_DIRECTORY', 'C:/Mac/Home/Documents/TEMPO/Portal Tempo/Servicos Tempo/portal do investidor/demografia/Provincias'),
'prepared_directory' => env('DEMOGRAPHY_PREPARED_DIRECTORY'),
// An explicit interpreter avoids Windows Store execution aliases in web workers.
'python' => env('DEMOGRAPHY_PYTHON', 'python'),
];
+19
View File
@@ -0,0 +1,19 @@
<?php
$legalReviewerEmails = env('PORTAL_EDITORIAL_LEGAL_REVIEWERS', '');
return [
// Development/QA opt-in. Installation and activation in an operational target are separate decisions.
'enabled' => env('PORTAL_EDITORIAL_WORKFLOW_ENABLED', false),
'cluster_competitiveness' => env('PORTAL_EDITORIAL_CLUSTER_WORKFLOW_ENABLED', false),
'portfolio' => env('PORTAL_EDITORIAL_PORTFOLIO_ENABLED', false),
'demography' => env('PORTAL_EDITORIAL_DEMOGRAPHY_ENABLED', false),
'territorial_versions' => env('PORTAL_EDITORIAL_TERRITORIAL_ENABLED', false),
'structural_subjects' => env('PORTAL_EDITORIAL_STRUCTURES_ENABLED', false),
'zone_review' => env('PORTAL_EDITORIAL_ZONE_REVIEW_ENABLED', false),
// Legal designation is explicit; management authority alone is not a legal designation.
'legal_reviewers' => [],
'legal_reviewer_emails' => is_string($legalReviewerEmails) ? array_values(array_filter(array_map(static fn (string $email): string => strtolower(trim($email)), explode(',', $legalReviewerEmails)))) : [],
// Required separation is a policy, distinct from the legacy fact "independent_review".
'independent_approval' => ['resources' => [], 'kinds' => []],
];
+10
View File
@@ -0,0 +1,10 @@
<?php
return [
// No legal text or operational NDA is supplied/approved. Never infer it from an upload.
'nda' => null,
// No definitive publication lifetime was decided. Freeze an explicit policy on approval.
'validity_days' => null,
'validity_policy_reference' => null,
'expiry_scheduler_enabled' => false,
];
@@ -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,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');
}
};
@@ -0,0 +1,39 @@
<?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('Only isolated QA is authorised.');
}
Schema::table('portal_editorial_notifications', function (Blueprint $t): void {
$t->uuid('edition_id')->nullable()->change();
$t->foreignId('opportunity_id')->nullable()->constrained('opportunities')->restrictOnDelete();
$t->unsignedInteger('opportunity_version')->nullable();
$t->char('content_hash', 64)->nullable();
$t->unique(['user_id', 'opportunity_id', 'opportunity_version', 'action'], 'portal_editorial_op_notice_uq');
});
DB::statement('ALTER TABLE portal_editorial_notifications ADD CONSTRAINT portal_editorial_notice_subject CHECK ((edition_id IS NOT NULL AND opportunity_id IS NULL) OR (edition_id IS NULL AND opportunity_id IS NOT NULL AND opportunity_version IS NOT NULL))');
}
public function down(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('portal_editorial_notifications')->whereNotNull('opportunity_id')->exists()) {
throw new RuntimeException('Preserve opportunity notification history.');
}
DB::statement('ALTER TABLE portal_editorial_notifications DROP CONSTRAINT portal_editorial_notice_subject');
Schema::table('portal_editorial_notifications', function (Blueprint $t): void {
$t->dropUnique('portal_editorial_op_notice_uq');
$t->dropForeign(['opportunity_id']);
$t->dropColumn(['opportunity_id', 'opportunity_version', 'content_hash']);
$t->uuid('edition_id')->nullable(false)->change();
});
}
};
@@ -0,0 +1,37 @@
<?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('Only isolated QA is authorised.');
}
Schema::table('portal_editorial_editions', function (Blueprint $t): void {
$t->unsignedBigInteger('resource_id')->nullable()->change();
$t->foreignUuid('dossier_id')->nullable()->constrained('investment_dossiers')->restrictOnDelete();
$t->unique(['dossier_id', 'revision_no']);
});
DB::statement('ALTER TABLE portal_editorial_editions ADD CONSTRAINT portal_editorial_edition_subject CHECK (num_nonnulls(resource_id,dossier_id)=1)');
}
public function down(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('portal_editorial_editions')->whereNotNull('dossier_id')->exists()) {
throw new RuntimeException('Preserve portfolio editorial history.');
}
DB::statement('ALTER TABLE portal_editorial_editions DROP CONSTRAINT portal_editorial_edition_subject');
Schema::table('portal_editorial_editions', function (Blueprint $t): void {
$t->dropUnique(['dossier_id', 'revision_no']);
$t->dropForeign(['dossier_id']);
$t->dropColumn('dossier_id');
$t->unsignedBigInteger('resource_id')->nullable(false)->change();
});
}
};
@@ -0,0 +1,35 @@
<?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('Only isolated QA is authorised.');
}
Schema::table('portal_editorial_editions', function (Blueprint $table): void {
$table->foreignUuid('demographic_import_id')->nullable()->constrained('demographic_imports')->restrictOnDelete();
$table->unique(['demographic_import_id', 'revision_no'], 'editorial_demographic_revision_unique');
});
DB::statement('ALTER TABLE portal_editorial_editions DROP CONSTRAINT portal_editorial_edition_subject, ADD CONSTRAINT portal_editorial_edition_subject CHECK (num_nonnulls(resource_id,dossier_id,demographic_import_id)=1)');
}
public function down(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('portal_editorial_editions')->whereNotNull('demographic_import_id')->exists()) {
throw new RuntimeException('Preserve demographic editorial history.');
}
DB::statement('ALTER TABLE portal_editorial_editions DROP CONSTRAINT portal_editorial_edition_subject, ADD CONSTRAINT portal_editorial_edition_subject CHECK (num_nonnulls(resource_id,dossier_id)=1)');
Schema::table('portal_editorial_editions', function (Blueprint $table): void {
$table->dropUnique('editorial_demographic_revision_unique');
$table->dropForeign(['demographic_import_id']);
$table->dropColumn('demographic_import_id');
});
}
};
@@ -0,0 +1,35 @@
<?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('Only isolated QA is authorised.');
}
Schema::table('portal_editorial_notifications', function (Blueprint $table): void {
$table->foreignUuid('native_event_id')->nullable()->constrained('audit_events')->restrictOnDelete();
$table->unique(['user_id', 'native_event_id', 'action'], 'editorial_native_notice_unique');
});
DB::statement('ALTER TABLE portal_editorial_notifications DROP CONSTRAINT portal_editorial_notice_subject, ADD CONSTRAINT portal_editorial_notice_subject CHECK (num_nonnulls(edition_id,opportunity_id,native_event_id)=1 AND (opportunity_id IS NULL OR opportunity_version IS NOT NULL))');
}
public function down(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('portal_editorial_notifications')->whereNotNull('native_event_id')->exists()) {
throw new RuntimeException('Preserve native editorial notifications and history.');
}
DB::statement('ALTER TABLE portal_editorial_notifications DROP CONSTRAINT portal_editorial_notice_subject, ADD CONSTRAINT portal_editorial_notice_subject CHECK ((edition_id IS NOT NULL AND opportunity_id IS NULL) OR (edition_id IS NULL AND opportunity_id IS NOT NULL AND opportunity_version IS NOT NULL))');
Schema::table('portal_editorial_notifications', function (Blueprint $table): void {
$table->dropUnique('editorial_native_notice_unique');
$table->dropForeign(['native_event_id']);
$table->dropColumn('native_event_id');
});
}
};
@@ -0,0 +1,35 @@
<?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('Only isolated QA is authorised.');
}
Schema::table('portal_editorial_editions', function (Blueprint $table): void {
$table->string('subject_kind', 40)->nullable();
$table->uuid('subject_key')->nullable();
$table->unique(['subject_kind', 'subject_key', 'revision_no'], 'editorial_subject_revision_unique');
});
DB::statement("ALTER TABLE portal_editorial_editions DROP CONSTRAINT portal_editorial_edition_subject, ADD CONSTRAINT portal_editorial_edition_subject CHECK (num_nonnulls(resource_id,dossier_id,demographic_import_id,subject_key)=1 AND ((subject_kind IS NULL)=(subject_key IS NULL)) AND (subject_kind IS NULL OR subject_kind IN ('projects','clusters','corridors','cluster-project','corridor-project','corridor-cluster','value-chains','value-chain-links','investment-gaps','zone-relations')))");
}
public function down(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('portal_editorial_editions')->whereNotNull('subject_kind')->exists()) {
throw new RuntimeException('Preserve structural editions and decisions.');
}
DB::statement('ALTER TABLE portal_editorial_editions DROP CONSTRAINT portal_editorial_edition_subject, ADD CONSTRAINT portal_editorial_edition_subject CHECK (num_nonnulls(resource_id,dossier_id,demographic_import_id)=1)');
Schema::table('portal_editorial_editions', function (Blueprint $table): void {
$table->dropUnique('editorial_subject_revision_unique');
$table->dropColumn(['subject_kind', 'subject_key']);
});
}
};
@@ -1,3 +1,304 @@
## 14/11/2026 — hierarquia Administração/Dashboard corrigida e instalada em 96f51096
Revisão servida **`96f510965f70581a4b41dd94463f598b77dd7cf5`**, instalada pelo executor Git como correcção da organização expressamente pedida pela Tempo. **Administração** reúne os grupos Dashboard, Conteúdos e oportunidades, Cadastros e investimento, Pedidos e acompanhamento e Acessos e configuração. Os grupos sem destinos autorizados são omitidos. **Dashboard** contém as funções dos painéis, com `/dashboard` como visão geral; os cadastros administrativos permanecem nos outros grupos. O circuito editorial conserva nome e URL próprios. Operações administrativas foram retiradas de Mais/Área de trabalho sem perder destinos; funções pessoais mantêm o seu âmbito.
Verificação: hierarquia/cabeçalho/sidebar25 testes, apresentação/secções4, acesso4/35, HTML autenticado QA com build real1/11, tipos/análise estática/build aprovados. Executor: estrutura1/3106, unitários132/611, **1914 testes de interface**. No servidor, HTML completo dos dois painéis com conteúdo sintético e manifesto real passou200, assetHTTPS200 e destinos administrativos separados dos destinos de dashboard. A correcção do erro500 foi preservada. Conferência visual da sessão continua pendente pelo conector indisponível.
Novo backup/restauro195 tabelas conferido. Preservação final:192 tabelas e416 uploads inalterados; cache/reinício de workers/limites de pedidos,11 sessões novas (as65 anteriores preservadas) e1 resposta de pesquisa contabilizada foram reconciliados com a fonte anterior. Mantêm-se270 instituições,12 lotes e920.456 observações. Configuração protegida, designação jurídica e permissões anteriores preservadas; sem migrações ou publicação automática. Recibos em `outputs/administration-dashboard-hierarchy-20261114/destination-installation-verified.json`, `destination-html-verified.json`, `destination-navigation-summary.json`, `destination-runtime-reconciled.json`, `destination-ci-summary.json` e backup/restauro na mesma pasta.
Instalação concluída; não é necessária nova preparação. Conferir visualmente a navegação normal, incluindo janela estreita, sem reabrir as provas técnicas aceites.
## 14/11/2026 — correcção da hierarquia Administração → Dashboard
Clarificação expressa da Tempo: agrupar **todas as operações administrativas em Administração** e reservar **Dashboard** às funções dos painéis. A apresentação anterior misturava os cadastros do circuito editorial com o dashboard principal e conservava atalhos administrativos dentro de Mais. Esta correcção não acrescenta funcionalidades ou permissões.
Hierarquia comum ao cabeçalho e à navegação lateral, com grupos omitidos quando não têm destinos autorizados:
- **Administração → Dashboard:** Visão geral do painel `/dashboard`; O meu trabalho; manifestações, indicadores, pedidos recentes e acções rápidas quando o painel administrativo já os permite; circuito editorial, filas de revisão e notificações segundo os âmbitos existentes. Os investimentos próprios mantêm o contexto do painel aplicável. Os filtros e âncoras seleccionam a função actual.
- **Administração → Conteúdos e oportunidades:** gestão de conteúdos, documentos, oportunidades, preparação de análises e concursos autorizados.
- **Administração → Cadastros e investimento:** sectores, projectos, clusters, carteira, estruturas/relações, demografia, território, zonas, referências e educação, conforme a capacidade existente.
- **Administração → Pedidos e acompanhamento:** interesses, CRM e atribuições autorizadas.
- **Administração → Acessos e configuração:** gestão/auditoria de perfis e configurações já autorizadas.
Os cadastros deixam de figurar dentro de Dashboard; atalhos administrativos deixam de estar dispersos em Mais. Os destinos anteriores são preservados uma vez, incluindo funções de perfis antes em Área de trabalho. Funções pessoais e acessos públicos conservam a sua área. O painel `/dashboard` é a visão geral; `/management/editorial` volta a identificar-se como **Circuito editorial** e mantém os cartões/revisões existentes. Não se importam estados a partir de componentes de página; a correcção do manifesto permanece intacta.
QA: navegação/cabeçalho/sidebar **25 testes**, apresentação/secções **4**, acesso/inbox **4/35**, abertura HTML autenticada dos dois painéis com build real **1/11**, análise estática, tipos e compilação aprovados. Provas em `outputs/administration-dashboard-hierarchy-20261114`. Testa-se a hierarquia exacta, ausência de cadastros dentro de Dashboard, remoção das operações administrativas de Mais, preservação de destinos e âncoras reais. Instalação correctiva pelo processo Git existente com backup/restauro em curso; não declarar concluída até conferir a revisão servida. A conferência visual continua pendente pelo conector indisponível.
## 14/11/2026 — erro 500 corrigido e correcção instalada em dd20e747
A revisão `dd20e747036d45892faf3273a44e385132668ee4` foi promovida pelo executor Git para reparar a entrega do menu/dashboard. O manifesto servido volta a conter `resources/js/pages/management/editorial-workflow.tsx`. A geração do HTML completo no servidor com o template/manifesto reais e conteúdo exclusivamente sintético passou (200); o respectivo ficheiro compilado respondeu 200 por HTTPS. Não se simularam credenciais ou sessões privadas no destino. A abertura HTTP autenticada com o build real passou em QA (1/8), além dos 12 testes frontend focados. O executor aprovou **1911 testes de interface**, incluindo o novo controlo do manifesto, 132 unitários/611 asserções, estrutura 1/3106, tipos e compilação.
Novo backup/restauro: 195 tabelas, contagens/checksums coincidentes. Conferência posterior: **193 tabelas e 416 uploads inalterados**; diferenças apenas de cache e novas sessões, reconciliadas. Todas as 48 sessões anteriores preservadas, mais 11 novas; nenhuma sessão válida removida. Configuração protegida, permissões e designação jurídica anteriores mantidas. Mantêm-se 270 instituições, 12 lotes e 920.456 observações. Sem migrações ou publicações de conteúdo. Serviços activos e limites de autenticação conservados.
Recibos em `outputs/editorial-dashboard-500-20261114/destination-html-verified.json`, `destination-installation-verified.json`, `destination-runtime-reconciled.json`, `destination-ci-summary.json`, `destination-backup-summary.json`, `destination-restore-summary.json`, `http-build-test.log` e `reproduced-failure.log`. A anterior prova 302 sem sessão não fica apresentada como abertura HTML autenticada. **Erro do manifesto corrigido; instalação concluída.** A conferência visual da sessão do utilizador mantém-se distinta e pendente pelo conector indisponível. Não é necessária nova instalação para esta correcção.
## 14/11/2026 — correcção do erro 500 na abertura HTML do dashboard
Erro confirmado no registo de 14/11 às 10:00 UTC da revisão `73e1d7b9`: `Unable to locate file in Vite manifest: resources/js/pages/management/editorial-workflow.tsx`. O componente de página era também importado estaticamente por outras páginas para reutilizar os estados editoriais. O compilador gerou um chunk partilhado sem a chave de página exigida pelo `@vite` da resposta HTML. O login/302 e os dados JSON podiam funcionar; essas provas não demonstravam a abertura HTML autenticada.
Correcção delimitada: os mesmos estados passam para `resources/js/lib/editorial-states.ts`; as cinco páginas reutilizam esse módulo, sem importar estaticamente o dashboard. Sem mudança de rotas, permissões, configurações ou dados. Não se alterou o template global nem a segurança para contornar o erro.
Regressão reproduzida com o manifesto anterior. Com o build corrigido, o teste `EditorialDashboardBuildTest` usa o manifesto real e abre as duas páginas HTML autenticadas (visão geral e legislação), sem `withoutVite`: **1/8**. Os testes frontend afectados e o novo controlo de manifesto passaram (**12 casos**), com tipos/build aprovados. O controlo `editorial-dashboard-manifest.test.mjs` corre depois da compilação na bateria frontend obrigatória e impede promover uma candidata sem a entrada de página. Provas em `outputs/editorial-dashboard-500-20261114`.
Estado desta passagem: correcção em QA para reparar a entrega autorizada; instalação e conferência do build servido em curso pelo executor Git existente, com novo backup/restauro. Não declarar o 500 resolvido antes de conferir o manifesto e a geração HTML da revisão instalada. Conferência visual continua separada e pendente pelo conector indisponível.
## 14/11/2026 — menu Administração/Dashboard e apresentação instalados em 73e1d7b9
**Entrega autorizada concluída:** `73e1d7b96419873c6924e04e94f2a7374c18eb87`, confirmada em `https://teste-investidor.tempo.co.mz/build/revision.json`. Administração fica no menu principal para contas com destinos administrativos autorizados; Dashboard abre o submenu das actividades. A página `/management/editorial` apresenta os três grupos de cartões descritos abaixo, com fila e notificações preservadas. O painel operacional anterior, quando autorizado, fica também dentro de Dashboard. Visitantes mantêm os acessos públicos; não houve concessão de permissões.
Backup/restauro actualizado: **195 tabelas**, contagens e checksums coincidentes no restauro isolado; 417 entradas de ficheiros/configuração conferidas. O executor Git aprovou estrutura 1/3106, unitários 132/611, **1910 testes de interface**, tipos e compilação. A nova revisão foi promovida sem migrações ou alterações de configuração protegida.
Conferência final: **192 tabelas inalteradas** e **416 uploads com os mesmos bytes**. As três diferenças foram delimitadas: cache de apresentação/reinício de workers/limites de pedidos; mais uma resposta agregada de pesquisa, conservando as 21 linhas anteriores e os contadores monotónicos; limpeza de 16 sessões expiradas segundo a configuração existente de 120 minutos, sem remover sessões válidas, mais 12 novas sessões. Não se repuseram sessões expiradas nem se alterou a política de segurança. Mantêm-se **270 instituições**, **12 lotes**, **920.456 observações** e a designação jurídica anterior. Os endereços públicos responderam 200, a gestão conservou login e a API privada devolveu 401 sem sessão; serviços activos.
Recibos em `outputs/dashboard-navigation-20261114/destination-installation-verified.json`, `destination-runtime-reconciled.json`, `destination-navigation-summary.json`, `destination-ci-summary.json`, `destination-backup-summary.json`, `destination-restore-summary.json` e `installation-manifesto.json`. O pacote QA anterior mantém o hash; `outputs/recibo-menu-dashboard-73e1d7b9-20261114.zip` contém apenas os comprovativos sanitizados da instalação.
**Saldo:** instalação concluída. Resta a conferência visual autenticada, incluindo janela estreita, já identificada como pendente pela indisponibilidade do conector. Não é necessária nova preparação, reinstalação ou publicação de conteúdos. Os testes de renderização/compilação e a verificação dos destinos autorizados não substituem a conferência visual.
## 14/11/2026 — Administração principal e apresentação agrupada do dashboard em QA
Pedido expresso da Tempo: Administração fica no menu principal e Dashboard reúne as actividades num dropdown interno. Esta alteração posterior substitui, apenas para contas com destinos administrativos autorizados, a anterior opção de manter Administração dentro de Mais. Visitantes e contas sem esses destinos conservam os seis acessos públicos; não se apresenta um menu administrativo vazio.
O catálogo de navegação comum é reutilizado no cabeçalho e na navegação lateral. Administração contém Dashboard → Visão geral, Artigos, Legislação, Documentos, Oportunidades, Competitividade, Carteira, Demografia, Estruturas e relações, Conteúdos territoriais, Zonas e indicadores e Notificações, apenas conforme as autorizações e componentes existentes. O painel operacional anterior também passa para Dashboard quando a conta já tem esse destino autorizado. As restantes operações administrativas mantêm os seus destinos, sem duplicar ligações. O grupo activo acompanha a página actual. Em ecrãs pequenos os subgrupos expandem por toque/teclado, sem exigir abertura por hover.
A página `/management/editorial` passa a apresentar três grupos consistentes: **Conteúdos e oportunidades**, **Investimentos e competitividade**, **Território e dados**. Os acessos, contadores e estados existentes ficam alinhados em cartões, com adaptação de uma a três colunas, fila de conteúdos e notificações identificadas. Retiraram-se as ligações dispersas e o segundo bloco repetido de oportunidades. Guardas, filtros, decisões e histórico não mudam.
Provas em `outputs/dashboard-navigation-20261114`: 26 testes de navegação/apresentação, integração de avisos/acesso 4/32, análise estática, tipos e compilação aprovados. Testam-se ausência de administração para visitantes/contas sem âmbito, preservação de destinos, deduplicação, localização do grupo activo, dropdown desktop, expansão móvel, grupos/counters e omissão de áreas indisponíveis. Sem migrações, dados ou permissões alteradas. A conferência visual permanece pendente pelo bloqueio do conector já documentado; os testes de renderização não são apresentados como conferência visual.
**Estado:** implementado/verificado em QA, ainda sem instalação desta nova apresentação. A homologação mantém `a4541298`. Preparar uma única entrega pelo processo Git existente, com backup/restauro e autorização concreta; não reinstalar o complemento anterior nem republicar conteúdos.
## 14/11/2026 — complemento do dashboard instalado e activo em a4541298
**Instalação conjunta concluída**, após autorização expressa da Tempo. A revisão `a4541298c0a386ab8bfd8e3e531e12d432ec671b` está confirmada em `https://teste-investidor.tempo.co.mz/build/revision.json`. Utilização: `/management/editorial`, com login/MFA e os âmbitos existentes. Estruturas/relações/cadeias, revisão das zonas e indicadores documentais e notificações nativas estão activos. A designação jurídica nominal recebida foi aplicada à configuração protegida; a conta existe e tem correio verificado. Não se alteraram permissões gerais nem se publicaram conteúdos automaticamente.
Backup actualizado com **195 tabelas**, restauro isolado com contagens e checksums coincidentes, 417 entradas de ficheiros/configuração conferidas. As duas alterações aditivas e o contrato nominal foram instalados antes da entrega Git. O executor aprovou o ciclo de estrutura 1/3106, 132 testes unitários/611 asserções, **1906 testes de interface**, tipos, compilação e verificações de plataforma/implantação. O ramo `main` foi promovido pelo executor existente; não houve edição manual da pasta servida.
**Preservação final:** 192 tabelas com conteúdo inalterado, incluindo o conteúdo anterior das tabelas editoriais, e duas entradas nominais acrescentadas ao registo de migrações. As diferenças nas duas tabelas de funcionamento foram reconciliadas com o backup: cache apenas por reinício dos workers e limites de pedidos; os 33 identificadores de sessão anteriores preservados, sem alterar o seu conteúdo, e 11 novas sessões. Todos os **416 uploads** mantêm os bytes. Permanecem **270 instituições**, **12 lotes aprovados** e **920.456 observações**. Endereços públicos responderam 200; gestão conservou o redireccionamento de autenticação e a API privada devolveu 401 sem sessão. Nginx, PHP-FPM e fila de implantação activos.
Recibos: `outputs/editorial-completion-20261114/destination-installation-verified.json`, `destination-backup-summary.json`, `destination-restore-summary.json`, `destination-schema-summary.json`, `destination-framework-reconciled.json`, `destination-activation-summary.json`, `destination-ci-summary.json` e `installation-manifesto.json`. O pacote QA selado permanece intacto; `outputs/recibo-entrega-dashboard-a4541298-20261114.zip` acrescenta apenas os comprovativos sanitizados da instalação.
**Saldo deste complemento:** instalação e configuração jurídica deixam de ser pendências. Resta a conferência visual autenticada dos percursos e da janela estreita; o conector continua identificado como indisponível, sem reinícios repetidos ou alterações de segurança. Os testes de interface não substituem essa conferência. Não é necessária nova preparação ou reinstalação. O restante saldo funcional do portal mantém-se na mesma matriz, fora deste complemento do dashboard.
## 14/11/2026 — complemento único do Dashboard Colaborativo em QA
**Estado:** a homologação mantém a entrega confirmada `d5c960b6`. Este complemento está implementado e verificado em QA, sem instalação. A nomeação do validador jurídico foi recebida da Tempo; não permanece como decisão organizacional em falta. A configuração nominal será aplicada apenas na entrega conjunta, conservando os restantes âmbitos da conta.
- **Fila e notificações:** Competitividade, perfis territoriais e revisão de zonas reutilizam a caixa editorial comum. As notificações identificam o registo e a versão, não concedem acesso; a leitura volta a conferir o âmbito actual. Contadores excluem publicações/arquivos e não adoptam automaticamente fontes ou zonas antigas.
- **Estruturas e cadeias:** projectos, clusters e corredores sem ficha, relações entre eles e com zonas, etapas, ligações e lacunas usam as mesmas edições, decisões e auditoria. Não se cria outro dossier. Alterar conserva a publicação anterior; novos elementos de cadeia permanecem privados. Retirar exige proposta e publicação expressas. Conflitos de versão, relações repetidas, contexto territorial alterado, datas ou montantes incompatíveis não sobrescrevem os registos.
- **Outras estatísticas existentes:** observações e indicadores documentais das zonas reutilizam o rascunho encriptado, revisões e projecção pública nativos. Conservar valor decimal, unidade, período, âmbito e fonte; revisão → aprovação → publicação são operações distintas. Alterações no conteúdo, contexto, anexos ou autoridade invalidam a decisão. Consultar uma revisão recupera a descrição e observações exactas sem alterar a edição actual. Agregados privados de origem/análise proactiva continuam derivados dos dados autorizados; não se inventa um conjunto editorial público nem totais em falta.
- **Legislação:** `PORTAL_EDITORIAL_LEGAL_REVIEWERS` permite a designação nominal recebida, com correio verificado e âmbito documental/MFA existentes. Não concede gestão geral, não aprova versões anteriores e não atribui autoridade jurídica sobre zonas ou cálculos.
**Utilização após instalação:** abrir `/management/editorial`; as filas existentes continuam disponíveis. Aceder a **Estruturas e relações** em `/management/editorial/subjects`, escolher o registo e usar **Alterar proposta**; guardar → submeter → iniciar revisão → aprovar → publicar. O editor nativo continua a ser a entrada para criar os registos. Nas zonas, abrir `/management/investment/zones/{id}`, guardar a preparação, usar **Revisão da zona e dos indicadores** e publicar separadamente. **Histórico editorial → Conferir versão** é consulta privada, sem restauração automática. Todos os endpoints, escolhas de catálogo, pré-visualizações e decisões conferem permissões no servidor.
**Verificação do delta:** `outputs/editorial-completion-20261114` contém os recibos. Testes focados finais 28/361, regressão nativa afectada 35/540, frontend 13, tipos e compilação aprovados. A análise estática dos ficheiros alterados passou após correcções localizadas. Duas migrações QA adicionam três colunas e respectivas restrições/índices, sem backfill de conteúdos ou permissões. SQL nominal ensaiado numa cópia isolada no cluster 55441, comparado com o QA original; repetição recusada sem alterações. Operações no destino activo: zero neste complemento.
**Entrega única e recuperação:** preservar os pacotes anteriores. Instalar apenas a candidata conjunta após autorização concreta, pelo executor Git existente, com backup/restauro conferido, contrato nominal das duas migrações e activação de `PORTAL_EDITORIAL_STRUCTURES_ENABLED` e `PORTAL_EDITORIAL_ZONE_REVIEW_ENABLED`. Configurar o validador nominal protegido na mesma passagem. Não executar migrações indiscriminadamente, republicar fontes ou alterar permissões gerais. Após escritas nas novas versões, conservar colunas e histórico; usar reparação compatível em vez de remover as migrações. Restauro integral apenas antes de novas operações, com conferência de dados.
**Conferência visual:** em 14/11/2026 a enumeração identificou Edge/Firefox como aplicações, mas devolveu `Browsers: Error: The browser access host is unavailable`, sem abas nem URL verificáveis. Não se repetiram reinícios nem se alteraram portas, permissões ou segurança. Permanece por conferir, na candidata QA autenticada, fila → proposta → decisão → publicação, notificações/histórico e janela estreita. Não declarar esta conferência aprovada nem a instalação deste complemento concluída. O fecho deste dashboard não declara concluídas as restantes funcionalidades do portal.
## 14/11/2026 — pacote editorial instalado e activo na homologação
Instalação conjunta expressamente autorizada pelo proprietário, incluindo as quatro alterações aditivas de estrutura. Revisão efectivamente servida e conferida em `https://teste-investidor.tempo.co.mz/build/revision.json`: **d5c960b62f97f04fb41acf71197ad2e7abbdf099**. Integra a candidata editorial `b3d4b7ab` e preserva as alterações fiscais/mapa aceites até `e4735424`, mediante duas integrações sem conflitos de código editorial. O Git/webhook/executor existentes fizeram a compilação e a substituição atómica; nenhuma edição manual da release servida.
Os cinco componentes estão activos: circuito comum de artigos/legislação/documentos e ligação às oportunidades nativas; Competitividade; carteira/dados-base/localização vinculados; novos lotes demográficos; perfis territoriais nativos. Entrada da equipa: `/management/editorial`; território: `/management/territorial/editorial`. Mantêm os perfis, âmbito, autenticação e MFA existentes. Não foram atribuídas contas, funções ou permissões nem publicados conteúdos automaticamente. **A validação jurídica de novas versões que a exijam continua dependente de designação nominal explícita; a lista de validadores permanece vazia.** Isto não retira conteúdos já publicados nem bloqueia os restantes percursos.
Backup consistente de 193 tabelas e 417 entradas de ficheiros/configuração em `/var/backups/investmoz-editorial-20261114/backup`, com hashes conferidos. Restauro isolado integral comparado por contagens e checksums, seguido do ensaio do SQL exacto; 416 ficheiros carregados preservados. O SQL aditivo e o contrato nominal foram instalados separadamente do executor Git, conforme a autorização; as quatro migrations no código continuam com guarda QA. Contas, concessões e conteúdos não foram alterados. Após existirem edições, não efectuar downgrade para código que ignore o histórico nem eliminar as estruturas: pausar o circuito preservando código compatível e decisões.
Preservação confirmada: 270 instituições publicadas, 12 lotes demográficos aprovados e 920 456 observações; os hashes das tabelas de negócio, territórios e autorizações conferidas mantêm-se. A diferença inicial nos rascunhos de simulação resultava de leitura sem actor sob RLS. Repetida a comparação com a identidade do backup, os 12 registos e o checksum coincidem; nenhuma alteração de permissões ou dados foi necessária.
O executor aprovou compilação, tipos, requisitos de plataforma, diagnóstico C132, teste de ciclo de schema (3 106 asserções), 132 testes unitários/611 asserções e 1 902 testes frontend. Consulta pública, login, instituições, demografia, Mineração e Energia responderam 200; gestão manteve redirecção para login e API privada recusou consulta anónima (401). A conferência usa o cliente identificado do executor existente; não foram desactivadas verificações de segurança. Estes controlos não substituem a conferência visual autenticada.
Recibos finais locais em `outputs/editorial-agrupado-20261114/destination-installation-verified.json`, `destination-ci-summary.json`, `destination-draft-identity-summary.json` e recibos de backup/restauro/schema/activação na mesma pasta. O pacote QA de 13/11 e o pacote autorizado de 14/11 permanecem intactos. **Instalação deixa de ser pendência destes recortes.** Saldo: conferência visual, designação jurídica nominal, outras estatísticas, estruturas sem ficha, relações/cadeias e notificações próprias de Competitividade/território. A instalação não encerra o portal integral nem esses resíduos.
## 14/11/2026 — revisão territorial privada e preservação da publicação
O registo territorial existente guarda agora as alterações numa revisão privada encriptada, sem mudar `geo_locations` nem o perfil publicado. Reutiliza as revisões e os recibos idempotentes nativos, sem nova tabela ou motor. O percurso é guardar proposta → submeter → iniciar revisão → aprovar → publicar. Aprovar não publica. O editor não pode rever nenhum conteúdo para o qual contribuiu; o publicador é distinto dos contribuidores e do revisor, conservando a separação nativa. Fontes oficiais já aceites não recebem nova exigência de autenticação.
A publicação confere a versão, o conteúdo, a fonte e a hierarquia, bloqueia as linhas de contexto e aplica a alteração numa transacção. Alterações posteriores exigem nova revisão; conflitos não sobrescrevem a proposta. Arquivar uma proposta conserva a publicação anterior. Retirar expressamente a publicação mantém a proposta e a revisão pendentes. O estado anterior fica no histórico, sem fabricar aprovação. Fontes e referências sem perfil territorial nativo não são adoptadas nem modificadas por este recorte.
Gestão: `/management/territorial/editorial`, ligada ao circuito editorial, com lista paginada, proposta, comparação com o nome/estado público, fonte, validade, fundamento e histórico. As APIs e pré-visualizações mantêm autenticação, gestão, MFA, resposta privada e permissões no servidor. As escritas exigem MFA fresco antes de repetir um recibo; desligar o circuito não permite contornar versões privadas existentes. O formulário conserva edições durante erros e impede mudar de registo ou decidir enquanto há alterações por guardar. As fontes disponíveis vêm do catálogo existente; a selecção mostra até 100 fontes, com aviso no limite. Criação de fontes e alterações de hierarquia continuam nas APIs nativas; o ecrã novo edita os conteúdos dos perfis existentes.
Provas em `outputs/editorial-territorial-20261114/`: cinco percursos novos/67 asserções, cinco testes nativos/44 asserções, dois testes de apresentação do novo ecrã; testes frontend afectados, PHPStan, tipos e compilação registados na mesma pasta. Dados sintéticos, transacções revertidas e bases QA isoladas em 55441. Conferência visual humana pendente; nenhuma instalação ou alteração operacional.
Saldo do pacote conjunto: Fase 1, Competitividade, fichas e dados-base/localização vinculados à carteira, novos lotes demográficos e perfis territoriais nativos implementados em QA. Permanecem outras estatísticas, estruturas sem ficha, relações/cadeias e notificações próprias de Competitividade/território. Estes resíduos não são encerrados pelo pacote. A instalação conjunta exige contrato operacional das quatro alterações aditivas de schema, backup/restauro e autorização concreta; as migrações actuais continuam limitadas ao QA. Não executar `migrate` indiscriminadamente nem enviar este ramo a `main` antes dessa preparação.
## 14/11/2026 — publicação editorial dos novos lotes demográficos
O circuito comum integra agora os novos lotes demográficos já importados para consulta privada: preparar revisão → submeter → iniciar revisão → aprovar → publicar. Aprovar a edição não altera o estado nativo `imported`; só a publicação explícita disponibiliza a série às análises existentes. As séries anteriormente `approved` conservam o estado e as observações, sem nova aprovação. Uma fonte nova continua a ser um lote próprio; não é substituída a série anterior. Arquivar uma publicação desactiva esse lote, conservando valores, originais e eventos.
As decisões vinculam o SHA256 do original e da prévia, a proveniência, advertências, correspondências territoriais e a integridade de todas as observações. A impressão digital usa SHA256 dos registos em agregados particionados no PostgreSQL; o bloqueio de publicação percorre os registos em blocos, sem carregar a série inteira na memória PHP. Uma alteração nos originais, observações ou proveniência é recusada, inclusive ao tentar legitimá-la como nova revisão. Uma alteração do contexto territorial sinaliza revisão e permite nova edição sem alterar os números. Não foi criada outra tabela de observações, nem reexecutado o preparador dos exemplares aceites.
A gestão nativa liga à revisão comum e ao download protegido do original. O painel/fila mostra pendências e notificações apenas aos operadores com a autoridade existente. A aprovação independente é configurável por `demography`, sem inventar novas designações; as provas usam actores sintéticos separados. Escritas exigem MFA válido na sessão. A operação antiga de disponibilização não contorna o circuito, mesmo após desactivá-lo. Publicações versionadas são retiradas pelo arquivo explícito da edição. A resposta vazia de uma operação é tratada sem expor `JSON.parse` nem confirmar um resultado desconhecido.
Migração `2026_11_13_233000_extend_editorial_editions_to_demography` instalada apenas na base própria QA 55441: acrescenta a associação do lote à edição comum e mantém a exclusividade do sujeito. Recibo `outputs/editorial-demography-20261114/qa-installation.json`: contagens das 177 tabelas anteriores preservadas, excepto o livro de migrações, sem preenchimento retroactivo ou alteração de contas/permissões. Reversão recusa remover histórico.
Provas no mesmo directório: seis casos iniciais aprovados, incluindo o ensaio de **131400 observações com limite PHP de 256 MB** (`integration.log`); cinco casos funcionais reforçados aprovados/66 asserções (`integration-final.log`); regressão demográfica anterior 14/52; regressão Fase 1 11/223, carteira 5/199 e dados-base 6/178. Frontend 24 testes, PHPStan, tipos e compilação aprovados. As observações do ensaio grande são sintéticas, não dados INE reais; todos os dados de prova são revertidos por transacção.
Saldo: novos lotes têm circuito de publicação separado demonstrado em QA e integram a candidata agrupada. Sem instalação operacional, publicação real ou alterações aos doze lotes anteriormente disponibilizados. A entrega passa a incluir quatro migrações QA do circuito comum; o contrato/schema operacional, a autorização concreta e a instalação conjunta continuam pendentes. Conferência visual pendente. Outras estatísticas, registo territorial, estruturas sem ficha, relações/cadeias e notificações próprias de Competitividade não são encerrados por este recorte.
## 13/11/2026 — dados-base e localização ligados à mesma edição da carteira
O recorte de carteira inclui agora os dados-base de projectos, clusters e corredores com ficha existente. Nome, código, estado operacional, descrição/sector, orçamento e datas são guardados como alterações propostas na mesma edição privada. A localização reutiliza a selecção/hierarquia territorial existente e fica igualmente proposta, com contador próprio e contexto territorial por hash. A gestão recupera os campos deliberados; a consulta pública continua a ler os valores anteriores. Publicar a edição aprovada aplica ficha, dados-base e localização numa única transacção, reutilizando as auditorias de gestão e território.
Conflitos de edição ou de código não sobrescrevem dados. Um código ocupado depois da aprovação devolve 409 e reverte ficha/dados-base em conjunto. Alterações da referência territorial invalidam a decisão; a publicação bloqueia as linhas de contexto durante a reconferência. Todos os contributos privados continuam sujeitos à independência da carteira. Desactivar o circuito ou arquivar a ficha não permite contornar a revisão. Ao iniciar outro ciclo depois de publicar/arquivar, alterações propostas antigas não são reaplicadas silenciosamente. Cadastros internos sem ficha associada conservam o comportamento anterior: este recorte não encerra o versionamento de todas as estruturas nem das suas relações/cadeias de valor.
Provas em `outputs/editorial-structures-20261113/`: 6 testes novos/178 asserções; carteira 5/199; regressão de estruturas/território 19/289; Fase 1 11/223; frontend 21 testes. PHPStan, tipos e compilação aprovados. Nenhuma nova migração deste recorte, nenhuma alteração de contas/permissões ou instalação operacional. Alterações integradas na candidata agrupada; estatística, indicadores demográficos e conteúdos territoriais continuam no saldo da Fase 2, assim como as notificações próprias de Competitividade e as estruturas sem ficha. Conferência visual pendente.
## 13/11/2026 — fichas da carteira integradas no mesmo circuito e pacote editorial
**Lacuna corrigida em QA:** guardar uma ficha de projecto âncora, cluster ou corredor deixava de a disponibilizar ao público. Com `PORTAL_EDITORIAL_PORTFOLIO_ENABLED` activo, o editor existente guarda uma edição privada e conserva integralmente o registo público, incluindo título, conteúdo, fonte, versão e datas. A confirmação independente continua obrigatória; aprovar e publicar são operações distintas. A publicação anterior só é substituída pela publicação explícita da edição aprovada. Arquivar uma edição privada preserva-a; arquivar a edição pública actual retira-a da consulta, sem apagar dados ou históricos.
**Reutilização:** mesmas tabelas, decisões, transições, auditoria, responsáveis, notificações e ecrã do circuito Fase 1. A migração aditiva `2026_11_13_230000_extend_editorial_editions_to_portfolio` permite que uma edição pertença a um recurso documental OU a uma ficha da carteira, com chaves estrangeiras, unicidade da revisão e exclusividade do sujeito. Não foi criado outro motor nem um segundo dossier. A estrutura foi instalada exclusivamente em `editorial_phase1_20261113_test:55441`; recibo em `outputs/editorial-portfolio-20261113/qa-installation.json`, com as contagens das 177 tabelas anteriores preservadas, excepto o incremento do livro de migrações. A reversão recusa remover histórico da carteira existente.
**Percurso:** Gestão de conteúdos → Carteira → abrir ficha → guardar alteração → «Conferir versões, submeter, aprovar e publicar no circuito editorial». O ecrã mostra a edição exacta, a fonte e o contexto identificado, conteúdo, versões, decisão e histórico. Submeter com responsável, iniciar revisão, aprovar e publicar separadamente. O painel indica as fichas pendentes; as notificações ligam à edição concreta. Os gestores usam as autorizações existentes; todas as escritas exigem MFA válido. API/JSON e prévias verificam a autoridade no servidor. Leitura privada usa `private, no-store`; a gestão da carteira reutiliza também o histórico Inertia cifrado.
**Integridade:** o hash liga a edição ao seu conteúdo e contexto de projecto/cluster/corredor, incluindo dados relevantes da entidade. Alterações do contexto ou da edição exigem nova aprovação. Todos os colaboradores privados são excluídos da aprovação e publicação dessa edição, preservando a independência já exigida na carteira. A publicação reconfere a autoridade do aprovador. Tentativas pela antiga operação combinada são recusadas. Desactivar o módulo não permite contornar o circuito para editar fichas com histórico novo. Um contexto desactualizado impede aprovar/publicar, mas permite devolver/rejeitar para correcção, sem prender a revisão. Versões anteriores, razões e dados deliberados ficam conservados.
**Provas:** 5 testes novos/199 asserções; regressão Fase 1 11/223; carteira anterior 8/201; notificações nativas de oportunidades 1/20; frontend 20 testes. PHPStan focado, tipos e compilação aprovados. Provas em `outputs/editorial-portfolio-20261113/`, incluindo `integration-final.log`, `phase1-regression-final.log`, `native-regression.log`, `opportunity-notices-regression.log`, `frontend-final.log`, `analysis-final.log`, `types-final.log` e `build.log`. Os testes de carteira anteriores usam a base nativa própria e descartável; os novos usam transacções revertidas. Não foram submetidos processos reais, alteradas permissões ou publicados conteúdos reais.
**Entrega e saldo:** este avanço integra a candidata agrupada, preservando os pacotes anteriores. A instalação operacional continua por executar e inclui agora três migrações da Fase 1/carteira; as migrações só autorizam QA e o executor Git não executa SQL no destino. É necessário preparar/autorizar a aplicação nominal do schema e contrato, backup/restauro e instalação conjunta pelo processo existente. A conferência visual permanece pendente, sem invalidar as provas aceites. Este recorte versiona as fichas públicas da carteira; a edição dos dados-base de projectos/clusters, relações, estatística, indicadores demográficos e conteúdos territoriais ainda não fica encerrada, nem as notificações próprias de Competitividade. Nenhuma instalação ou activação em homologação/produção foi efectuada.
## 13/11/2026 — pacote agrupado do Dashboard Colaborativo e revisão de Competitividade
**Decisão do proprietário:** agrupar melhorias e funcionalidades relacionadas para uma instalação conjunta quando o conjunto estiver pronto. O pacote desta passagem reúne a Fase 1 e o primeiro recorte editorial da Fase 2: análise de Competitividade dos clusters. Não representa o fecho de toda a Fase 2 nem autorização para publicar conteúdos.
**Disponível em desenvolvimento/QA:** o editor existente dos clusters permite guardar → submeter → iniciar revisão → aprovar → publicar, com devolução, rejeição, arquivo e recuperação do histórico. A aprovação deixa de publicar quando `PORTAL_EDITORIAL_CLUSTER_WORKFLOW_ENABLED` estiver activo. Uma análise anteriormente pública continua disponível ao activar o novo circuito, guardar, rever, recuperar ou arquivar um rascunho. Publicar substitui-a explicitamente; retirar a publicação não cancela uma revisão privada em curso. Estados editoriais e estado operacional do cluster aparecem separados na lista existente, acessível pela Gestão de conteúdos/Circuito editorial.
**Versões e autoridade:** o hash vincula o conteúdo das cinco forças e o contexto identificado do cluster (nome, código, sector, estado e território). Alterações posteriores exigem guardar nova edição e obter nova aprovação. Conflitos devolvem 409 sem sobrescrever texto. A política independente já configurável aceita o tipo `cluster_competitiveness` e impede aprovação por qualquer colaborador da edição quando exigida. O publicador reconfere a autoridade actual do aprovador. As escritas verificam gestão existente, exclusão de auditor e MFA da sessão no servidor. O histórico continua no mecanismo cifrado e aditivo existente; não há migração nem novo motor de Competitividade. A exposição pública conserva apenas análise publicada, versão e data, sem decisões privadas/identidades. Desactivar o circuito conserva a publicação e impede retomar por uma operação legada que contorne as decisões.
**Provas aceites:** 14 testes de integração de clusters/252 asserções, incluindo os sete testes anteriores; regressão Fase 1 11/223; frontend 18 testes. PHPStan focado, tipos e compilação aprovados. Provas em `outputs/editorial-clusters-20261113/`. As provas anteriores de oportunidades e anexos são reutilizadas, sem repetição. Dados sintéticos em transacções revertidas na base própria de QA 55441; sem alteração operacional, novas concessões ou publicação real.
**Pacote/instalação:** bundle Git conjunto a partir da base `2f1ef2fd`, manifesto com hashes e provas, preservando o bundle anterior. O executor continua a ser `deploy/homologacao/pipeline.py`; não foi alterado. Antes de instalar: confirmar revisão/base e delta, backup recuperável e ensaio de restauro isolado; tratar expressamente as duas migrações aditivas da Fase 1 e o contrato nominal de schema do destino. O executor Git não executa SQL no destino e as migrações actuais só autorizam QA: não basta enviar este pacote a `main`. Após instalação autorizada, conferir a revisão efectivamente servida, acessos e os percursos afectados, sem republicar conteúdo. Reversão pelo executor existente preserva dados; não usar rollback destrutivo de tabelas com histórico. Não foi obtido novo backup remoto nem executada instalação nesta passagem.
**Saldo concreto:** Competitividade tem agora aprovação/publicação separadas demonstradas em QA. Conferência visual, preparação operacional do schema/contrato, configuração explícita de designações e instalação conjunta continuam pendentes. Versionamento editorial dos dados-base de projectos âncora/clusters, estatísticas, indicadores demográficos e conteúdos territoriais continua por implementar; notificações editoriais dos clusters não são encerradas por este recorte. A Fase 1 continua identificada separadamente no mesmo saldo. Sem alterações a dados demográficos, instituições ou restantes entregas aceites.
## 13/11/2026 — Dashboard Colaborativo, Fase 1 implementada em desenvolvimento/QA
**Âmbito:** artigos, legislação, documentos e integração do circuito existente de oportunidades. A versão pública de um artigo permanece integralmente inalterada ao guardar ou rever uma nova edição privada, incluindo metadados, identificador e contador públicos. Não houve intervenção na homologação ou produção nesta passagem.
**Utilização em QA:** depois de instalar as duas migrações aditivas em base isolada e activar `PORTAL_EDITORIAL_WORKFLOW_ENABLED`, entrar pelo Painel/Gestão de conteúdos → Circuito editorial (`/management/editorial`). Alterar no editor existente, guardar a edição, conferir conteúdo/anexos e submeter com fundamento e responsável. O revisor inicia a revisão, valida juridicamente quando exigido e aprova. Publicar é uma segunda operação explícita. Devolução, rejeição e arquivo exigem fundamento. Arquivar um rascunho preserva a publicação anterior; arquivar a edição pública actual retira-a explicitamente. A fila permite filtrar tipo/estado e consultar versões, responsáveis e histórico. As notificações internas ligam à edição concreta ou à oportunidade no circuito nativo e podem ser marcadas como lidas.
**Autorizações e versões:** verificações no servidor para HTML, JSON, prévias e anexos; MFA existente nas escritas; escopos e permissões existentes, incluindo revogação de acesso. Aprovação e validação jurídica vinculadas à edição, conteúdo e versões/hashes dos anexos. Alterações ou bytes diferentes impedem reaproveitar a decisão. Conflitos de edição devolvem 409. Auto-aprovação bloqueada quando a política independente a exige, abrangendo todos os colaboradores da edição. A configuração `editorial_workflow.independent_approval` distingue a obrigação de independência do campo legado que apenas regista a revisão efectuada. A designação jurídica em `legal_reviewers` é explícita; não foram criadas contas, perfis ou concessões. A fonte oficial já aceite não é novamente auditada por este circuito.
**Oportunidades:** reutilizam filas, atribuições, revisões, publicador independente, prévias e histórico existentes. Novas submissões com a Fase 1 activa vinculam a validação jurídica à versão exacta e à versão dos anexos. Submissões já aprovadas mantêm o contrato/hash anterior. Notificações próprias e resumo de pendências entram no painel sem conceder acesso a outra organização ou conteúdo. Não existe um segundo motor de oportunidades.
**Verificação:** testes de integração do novo circuito, 11 aprovados/223 asserções; regressão e aceitação nativa de oportunidades, 6 aprovados/116 asserções, mais o caso reforçado de notificações isolado, 1 aprovado/20 asserções; unidade jurídica, 1 aprovado/6 asserções; frontend, 12 aprovados. PHPStan focado e tipos/compilação aprovados. A tentativa inicial nativa sem activos de compilação e com armazenamento de fixtures partilhado foi descartada; as provas aceites usam activos e armazenamento QA separados. Provas locais: `outputs/editorial-phase1-20261113/` (`integration-final.log`, `opportunity-acceptance.log`, `opportunity-notices.log`, `legal-unit.log`, `frontend-final.log`, `analysis-final.log`, `types-final.log`, `build-delivery.log`).
**Estrutura e preservação:** base própria `editorial_phase1_20261113_test`, PostgreSQL de QA 55441. Primeira migração criou duas tabelas vazias; segunda acrescentou o vínculo de notificações às oportunidades. Recibos `qa-installation.json` e `qa-notification-installation.json`: contagens das 175/177 tabelas preexistentes preservadas em cada etapa, excepto incremento documentado do livro de migrações. Sem preenchimento retroactivo, publicação automática, alteração de contas/permissões, nem escrita em bases operacionais. Reversão das migrações recusa apagar histórico existente; desactivar o novo módulo preserva dados e exige planear qualquer retoma de edição legada.
**Saldo:** implementado e demonstrado por testes em QA; conferência visual no navegador pendente. Activação operacional, designações jurídicas/independência efectivas e instalação fora do QA não executadas, dependem de decisão concreta. As migrações desta fase recusam destinos operacionais. Fase 2 (projectos âncora, clusters, estatística, demografia e conteúdos territoriais) continua delimitada para trabalho posterior; não é encerrada por esta entrega. Preservadas as entregas anteriores e o trabalho paralelo na pasta original.
## 13/11/2026 — pesquisa rápida: intenções e categorias, implementada em QA
A pesquisa reconhece contagens do catálogo publicado (por exemplo, «quantos corredores existem»), sem misturar projectos ou oportunidades apenas por mencionarem corredores. Zero registos e indisponibilidade de uma fonte têm apresentação distinta. «Como licenciar uma empresa» conduz ao roteiro existente e à preparação de S08 no dossier, sem inventar requisitos oficiais.
Produtos físicos e financeiros aparecem em categorias separadas, com opções Todos / Produtos físicos / Produtos financeiros e preservação do filtro e paginação. Exemplos: «tipo de produtos e sua localização», «onde encontrar grafite em Cabo Delgado» e «produtos financeiros em Nampula». Reutiliza as referências aceites de Mineração/Energia, oportunidades agrícolas publicadas e versões publicadas das instituições. A ligação mineral conserva grupo/local seleccionado; a ligação financeira abre a instituição existente. Presença física e cobertura financeira aparecem separadas. Rascunhos e publicações futuras não são divulgados.
Limites: localização nestes exemplos é correspondência textual aos dados documentados; não representa geocodificação, elegibilidade ou garantia de oferta/financiamento. Só entram conteúdos publicados das fontes efectivamente disponíveis. Não é uma resposta aberta por IA. Erros 404/503 de fontes são identificados sem ocultar os outros resultados; 403 continua recusado.
Verificação focada em QA isolado 55441: integração 4 testes/118 asserções; regressão federada 3 aprovados e 1 ignorado por guarda de ambiente; unidade 8 testes/39 asserções; frontend 7 testes. Tipos TypeScript, PHPStan e compilação aprovados. Provas em `outputs/quick-search-20261113/`. Sem migrações, alterações de dados ou permissões. Conferência visual no navegador e instalação desta candidata ainda pendentes. Preservar as entregas e os doze lotes demográficos já aceites.
## 13/11/2026 — erro 500 corrigido e verificado no PHP web, revisão 4988ca89
A recusa de acesso anterior estava corrigida, mas a abertura do instalador revelou outra incompatibilidade: o PHP-FPM bloqueia `proc_open`; a criação de `Symfony Process` na pré-visualização provocava 500. Os registos de 13:49:58 e 13:50:10 identificaram esta causa. Conferência de leitura confirmou que não existiam tabelas demográficas nem recibos de importação no destino.
A revisão `4988ca89dc6dddbde4efdada24546767ffcdd411` permite consumir preparações CLI privadas, vinculadas ao SHA256 do original, ano de referência, versão do preparador, prévia e fluxo NDJSON. Conserva a cópia do original, análise antimalware actual, correspondência territorial, confirmação com MFA, gravação por blocos, verificação de contagens e histórico. O CLI existente ganhou a emissão do recibo; não recalcula fórmulas nem altera estatísticas. Sem preparação válida e sem suporte a processos, a interface informa o impedimento, em vez de provocar 500. Não foi alterada a configuração de segurança do PHP.
Doze originais foram preparados fora do servidor web e conferidos: 920456 observações esperadas, ainda privadas e não importadas. Passaram dois testes/20 asserções com `proc_open` desactivado, 14 testes/52 asserções dos consumidores, oito testes do preparador e análise PHP. Delta e reversão foram conferidos; pacote `outputs/entrega-demografia-cli-20261113.zip`, SHA256 `4b3be263f34d6b9675b77f1ad25534f25da927d4046f57798644dce9fde24244`, preservando os pacotes anteriores.
**Instalado e verificado:** revisão confirmada por HTTPS às 14:06:11 Africa/Johannesburg. A verificação privada pelo socket PHP-FPM existente confirmou às 14:05:27: SAPI `fpm-fcgi`, processos continuam bloqueados, preparações disponíveis, antimalware disponível e hashes de código/configuração correctos. Não criou actor/sessão, endpoint HTTP público, permissões, migrações ou importações. Provas `outputs/demography-20261113/homologacao/cli-installation-verified.json` e `fpm-runtime-verified.json`; prova anterior conservada antes da renovação.
**Pendente:** o proprietário abrir `/management/demography/install` e confirmar os lotes na sua sessão com MFA recente; depois conferir 12 lotes/920456 observações na homologação. A verificação final ainda encontrou esquema ausente e zero recibos autenticados, pelo que não se declara esta incorporação concluída. A conferência visual na sessão do proprietário mantém-se distinta da verificação técnica. Novos exemplares num PHP web sem processos requerem a preparação CLI correspondente, com o mesmo recibo e controlos, antes da confirmação no editor.
## 13/11/2026 — recusa de acesso demográfico corrigida em df543a44
A conta tinha o estatuto de superutilizador designado, email verificado e MFA configurado, mas não passava no gate legado `manage-portal`. Os registos confirmaram autenticação concluída seguida de403 em `/management/demography/install` às13:42:56(Africa/Johannesburg). A rota antiga aplicava o filtro de catálogo antes da autorização própria do instalador. A correcção `df543a4414d1641422a45262837435429ddae9d6` remove apenas esse filtro redundante das rotas demográficas e reconhece os superutilizadores existentes no importador; mantêm-se autenticação, email verificado, precedência das restrições do Auditor, autorização por acção e MFA de sessão para escrita. Não foram atribuídos papéis nem alteradas contas/permissões.
Revisão confirmada porHTTPS no servidor às13:48:56. Três testes focados/17asserções aprovados;pipeline Git concluída. Conferência de leitura confirmou ambos os titulares designados autorizados, destino correcto e hashes actuais do código/configuração. A prova anterior de instalação foi conservada e a prova actual renovada para esta revisão, evitando um bloqueio de botão por código desactualizado. Recibo `outputs/demography-20261113/homologacao/access-correction-verified.json`.
**Estado:** correcção instalada; resta verificar a abertura na sessão real do proprietário e executar as confirmações autenticadas dos lotes. O esquema demográfico remoto ainda não estava instalado nesta verificação; não se declaram os12lotes/920456observações disponibilizados na homologação. A operação local continua preservada. Não repetir preparação, atribuir permissões ou reinstalar os recortes anteriores.
## 13/11/2026 — revisão b20068d0 instalada em teste-investidor; dados por confirmação autenticada
Instalação Git concluída às **13:34:06 Africa/Johannesburg (11:34:06 UTC)** em `https://teste-investidor.tempo.co.mz`, revisão `b20068d04bc70e8fa94b90692e07afc60b406910`. `/build/revision.json`, `/localizacoes/demografia` e `/api/v1/investment/demography` responderam HTTP200, com TLS verificado. O código e a configuração estão disponíveis; **isto não equivale a ter os 12 lotes importados neste destino**.
Backup/restauro conferiu 190 tabelas e 369 ficheiros. Comparação posterior confirmou as onze tabelas de negócio protegidas sem alterações, incluindo instituições, versões e interesses, dossiers, oportunidades e projectos. Conferência territorial: 168 correspondências existentes, 15 acréscimos expressamente autorizados, nenhuma correspondência por resolver; 12 originais privados por SHA256, total esperado 920456 observações. Apenas três parâmetros próprios de demografia diferem da configuração anterior: activação, pasta privada de originais e interpretador Python. Nenhuma permissão, conta ou segredo foi substituído.
O instalador conserva MFA/autorização e confirmações por coorte. Falta o proprietário executar em `/management/demography/install`: «Instalar e disponibilizar» (três exemplares), regressar ao mesmo endereço para «Disponibilizar restantes províncias» (oito) e, finalmente, «Disponibilizar Zambézia». Conferir depois recibos autenticados, 12 lotes/920456 observações, consulta por localização e preservação dos dados. Não repetir etapas concluídas nem substituir o cadastro ou a base remota pela base local.
Recibo `outputs/demography-20261113/homologacao/installation-verified.json`; pacote imutável `outputs/entrega-demografia-homologacao-20261113.zip`, SHA256 `fa6ce1fc141cfec90d149c00946cea17330c09fd6bc5d8bbb1cb2abfa97f9520`, criado antes da instalação. Delta e reversão conferidos em índice temporário; índice original preservado. A conferência visual administrativa permanece separada. As menções históricas abaixo a instalação remota pendente estão substituídas por este recibo; a incorporação remota dos dados continua por confirmar.
## 13/11/2026 — Zambézia instalada; doze lotes demográficos verificados
A operação autenticada daZambézia terminou às **11:33:38Africa/Johannesburg (09:33:38UTC)**. Verificação posterior exclusivamente de leitura confirmou `Zambezia.xlsx`,SHA256 `8d502e25e1e1fec0cc381ba0aa47b35cfa1eea586de53cb69ffa201d3b5ffeb7`,lote `9c8f1c48-a29d-4ca7-9ae5-23bd8ca2d05b`,estado `approved` e126684observações de2017–2050. Recibo autenticado confirmado;nenhuma repetição da importação pelo agente.
**Disponível no portal operacional local `investemoz:5432`:12lotes aprovados,total920456observações.** Todos os ficheiros provinciais fornecidos foram incorporados,além do exemplar nacional. A consulta provincial daZambézia devolve dados e os cinco distritos autorizados(Dérre,Luabo,Mocubela,Molumbo,Mulevala) estão consultáveis. Onze lotes anteriores preservados por contagens/SHA de metadados,observações e eventos,assim como as oito tabelas de negócio protegidas. Não há pendência de importação desta província.
Provas `outputs/demography-20261113/zambezia/installation-status.json`,`verification.json` e `outputs/demography-20261113/operational-status.json`. As advertências/unidades ausentes dos exemplares anteriores continuam preservadas;nenhuma correcção de estatísticas,recálculo de fórmulas ou preenchimento de lacunas. O novo original não apresentou advertências detectadas. Permanecem separados a conferência visual administrativa,indicadores sem valores no acervo e a instalação em ambientes diferentes deste portal local. Não se declara o portal inteiro concluído.
Este registo substitui as pendências históricas de ficheiro em falta ou operação autenticada daZambézia descritas abaixo,sem apagar o histórico.
## 13/11/2026 — oito províncias concluídas; Zambézia fornecida e preparada separadamente
A extensão anterior foi executada comMFA às11:18:05Africa/Johannesburg (09:18:05UTC) e verificada em leitura:11lotes aprovados/793772observações,recibo autenticado único,oito séries provinciais2017–2050 disponíveis,oito distritos novos consultáveis e preservação integral das oito tabelas de negócio e dos três lotes originais(importações/observações/eventos). Não foi repetida a importação. Recibo `outputs/demography-20261113/remaining-provinces/installation-status.json`;essa extensão deixa de ser pendência.
O proprietário forneceu depois `Zambezia.xlsx`,SHA256 `8d502e25e1e1fec0cc381ba0aa47b35cfa1eea586de53cb69ffa201d3b5ffeb7`:126684observações de2017–2050,sem advertências detectadas. Autorizou expressamente Dérre,Luabo,Mocubela,Molumbo,Mulevala;referências sem códigos oficiais ou geometrias inferidos. Restantes correspondências únicas no cadastro existente,sem nova fusão ou renomeação.
Coorte separada `zambezia` no mesmo instalador:uma entrada,sem reimportar os onze lotes ou invalidar a recuperação dos recibos das coortes anteriores. O estado protegido abrange agora as793772observações anteriores,metadados e eventos,conferidos em páginas500. O backup/restauro mantém comparação exacta dos bytes com cursores de250linhas e ordenaçãoC,sem materializar tabelas inteiras emPHP. Novo backup/restauro299tabelas/1268ficheiros. Dois testes focados/32asserções com limite256MiB confirmaram acréscimo126684,total920456/12lotes,repetição sem duplicação,preservação dos onze lotes,MFA e os cinco distritos consultáveis;tipos,análisePHP e build aprovados. Provas anteriores de importação e integridade NDJSON reutilizadas,não reexecutadas globalmente.
**Estado:** código/interface e readiness disponibilizados. Falta a operação autenticada do proprietário no botão **Disponibilizar Zambézia**,em `/management/demography/install`,e conferir o recibo. Os onze lotes anteriores permanecem disponíveis. A indicação histórica de ficheiro daZambézia em falta está ultrapassada pela recepção deste original;não confundir preparação com importação concluída. Recibos `outputs/demography-20261113/zambezia/verification.json`, `release-readiness.json` e posterior `installation-status.json`. Conferência visual administrativa e ressalvas das séries anteriores permanecem separadas.
## 13/11/2026 — restantes províncias preparadas; correcção de memória verificada
Pedido expresso de instalar os oito ficheiros disponíveis:Cabo Delgado92808,Gaza82618,Maputo Província49504,Maputo Cidade44064,Nampula131400,Niassa93632,Sofala77108,Tete67932. Acréscimo previsto639066observações,total793772com os três lotes iniciais. Zambézia não tem ficheiro provincial no acervo fornecido;os dados nacionais2017 não são declarados cobertura anual dessa província. O proprietário autorizou explicitamente Chongoene/Limpopo/Mapai(Gaza),Larde/Liupo(Nampula),Chimbonila(Niassa),Doa/Marara(Tete),sem códigos oficiais ou geometria inferidos. Confirmou que os rótulos Nampula/Lichinga/Xai-Xai se ligam às cidades,preservando Rapale/Chimbonila/Limpopo separados. Correspondências históricas são apresentadas antes da confirmação autenticada;cadastro existente não é renomeado ou fundido.
Leitor adaptado aos títulos “Cidade da”/“Distrito Municipal”,conservando cabeçalhos e valores originais. Células com fórmulas usam somente a cache numérica do original,com advertência e fórmula conservada;nenhuma fórmula é executada/recalculada e cache ausente continua bloqueante. Valores em falta e divergências não são substituídos. Três lotes aprovados permanecem intactos e protegidos pela extensão,que não reexecuta a migração nem cria permissões.
A tentativa operacional às11:07foi revertida por esgotar256MiB na conferência de preservação. Corrigidas leitura/hash em páginas500 e importação porNDJSON vinculado à prévia porSHA256,com confirmação transaccional de contagens e compatibilidade com préviasJSON antigas. Relatórios completos permanecem guardados;consulta pública carrega contagens de advertências uma vez por lote. A interface passa a distinguir resposta sem reciboJSON e orienta conferência antes de repetir.
**Verificação:** oito testes do leitor;seis testes de instalação/50asserções incluindo639066novas observações,11lotes/793772total,preservação e replay com o mesmo limite256MiB do servidor;12testes de integração/42asserções,incluindo adulteração e contagemNDJSON;tipos,análisePHP focada,lint e build aprovados. Novo backup/restauro299tabelas/1157ficheiros. Código/interface e readiness actualizados;nenhum dado adicional aplicado pelo agente. Resta a operação autenticada no botão **Disponibilizar restantes províncias** em `/management/demography/install` e a conferência do recibo. Os três lotes originais continuam disponíveis. Provas `outputs/demography-20261113/remaining-provinces/verification.json`,`release-readiness.json` e`installation-status.json`.
## 13/11/2026 — instalação demográfica concluída e verificada no portal operacional local
A operação autenticada foi concluída em **13/11/2026 às10:39:19 Africa/Johannesburg (08:39:19UTC)** no destino confirmado `investemoz:5432`. Verificação posterior exclusivamente de leitura confirmou os três identificadores apresentados pelo operador,estado `approved`,hashes exactos dos originais e **154706observações**:Manica71604,Inhambane82620,nacional482. Recibo autenticado único,sem repetição da instalação/importação. A prova anterior desactualizada após a correcção do interpretador foi reconciliada com a versão actual;cinco testes focados das guardas/12asserções passaram emQA,sem alterar MFA ou dispensar conferências.
**Utilizável:** `https://portal-investidor.test/localizacoes/demografia`. GONDOLA eGURO,sectorIndústria,2017–2050 devolveram estado disponível,uma série e1836valores por distrito;Macate eVanduzi constam dos territórios consultáveis. As oito tabelas protegidas constantes do recibo mantêm contagens eSHA idênticos:projectos,dossiers/oportunidades,necessidades de competências,clusters,preparação e simulações. As seis advertências deManica,24valores ausentes e sete cabeçalhos sem unidade continuam preservados;nenhuma correcção estatística presumida.
**Estado actual:** instalação/importação e conferência técnica deste recorte concluídas. Restam conferência visual administrativa e ressalvas estatísticas já identificadas;outras províncias/indicadores e instalação noutros ambientes não estão concluídos por esta operação. A API sem selecção territorial devolve indisponível por ausência de contexto;a consulta comGONDOLA/GURO foi confirmada disponível. Não confundir esse estado inicial com falha da instalação.
Recibos `outputs/demography-20261113/installation-verified.json` e `operational-status.json`;este registo substitui as pendências históricas de execução abaixo,sem apagar as provas anteriores.
## 13/11/2026 — homologação demográfica e instalação operacional preparadas
Proprietário confirmou `investemoz:5432` no portal operacional local, incluindo três ficheiros e Macate/Vanduzi. Backup/restauro conferidos: 296 tabelas/872 ficheiros; cinco testes de instalação na cópia restaurada (21 asserções) e 11 regressões de integração (36 asserções) aprovados. Palavra-passe, TOTP e prévia administrativa QA passaram após corrigir apenas o formato do envelope da conta sintética. Controlos e credenciais operacionais preservados.
Frontend/instalador publicados, `.env` intacto. **Dados activos ainda não instalados**: abrir `https://portal-investidor.test/management/demography/install`, autenticar-se como gestor de instalação habitual com MFA de sessão, conferir destino/ficheiros/advertências e executar **Instalar e disponibilizar**. É o controlo autenticado existente, não uma nova autorização organizacional. A ferramenta de navegador está indisponível; não foi contornada a autenticação. Clientes CLI apresentaram impedimentos na conferência TLS da cadeia local, sem alterações de certificados ou redução da validação.
A acção instala apenas a migração demográfica, acrescenta quando ausentes os dois distritos autorizados e importa/aprova os três exemplares exactos com histórico, hashes, scans e contagens homologadas. Confere preservação de dossiers, simulações, instituições financeiras, oportunidades, competências e projectos/clusters. Repetição conserva contagens. Após executar: verificar três lotes/154 706 observações e comparação territorial no portal; manter as seis advertências de Manica/2031 e as unidades/edições ausentes identificadas.
Recibos `outputs/demography-20261113/release-readiness.json`, `installation-tests.log`, `qa-http-homologation.json`, `operational-status.json`. Relatório e reversão na adenda de `docs/investmoz/v2/demografia-ine-homologacao-2026-11-13.md`. Não declarar disponibilização dos dados concluída antes de existir o recibo autenticado.
## 13/11/2026 — demografia INE em desenvolvimento/QA
Entrega implementada na base nova `investmoz_demography_test:55441`, sem migração, importação, permissões ou publicação no activo. Inventário consultado: cadastro/caminhos territoriais, mapa, comparação, simulador, percurso e fontes SWOT existentes; provas e aceitações anteriores conservadas. O delta acrescenta consulta demográfica aos consumidores existentes, sem substituir cálculos, contexto, projectos ou fontes. Não foi feita uma nova aceitação global do portal nem declarada reposição de funcionalidade desaparecida.
**Utilização:** em `http://127.0.0.1:18813/localizacoes/demografia`, escolher até quatro províncias/distritos, sector e anos. GONDOLA/GURO permitem comparar a evolução 2017–2050, consultar originais por folha/célula e distinguir edições. Na oportunidade, comparação, simulador e localização documentada de projecto/cluster, abrir “Demografia na análise de viabilidade”; o acesso leva também à selecção temporal. Na preparação/SWOT do dossier, a fonte “Contexto demográfico INE” conserva a revisão e as conclusões manuais existentes. Não equiparar população a procura ou mão-de-obra efectiva.
**Administração QA:** `/management/demography`, usando a permissão de gestão e MFA existentes. Preparar → conferir correspondências/advertências → confirmar → aprovar utilização em QA. Desactivar retira a série das análises e conserva observações/eventos. Edições e hashes coexistem. Manica: 71 604 observações; Inhambane: 82 620; nacional: 482; total 154 706. Macate/Vanduzi existem apenas no cadastro QA por resposta expressa do proprietário. Seis advertências de Manica/2031, 24 valores urbanos/rurais ausentes e sete cabeçalhos vitais sem unidade permanecem visíveis; não foram preenchidos ou corrigidos por inferência.
**Verificação e saldo:** leitor (6 testes), integração (11 testes), regressão frontend de mapa/preparação/simulador (38 testes), TypeScript, análise PHP focada e build QA aprovados. Consulta pública conferida em Chromium a 1440 e 390 px, com dois distritos, filtros conservados e sem overflow da página. Conferência visual administrativa pendente: duas tentativas de navegação após desafio TOTP da conta sintética não avançaram; paragem dessa frente, sem contornar MFA. Permissões/MFA/importação continuam cobertas pelos testes HTTP. PHPStan global tem 19 erros em três ficheiros de pesquisa intactos e a formatação global tem falhas preexistentes; não declarar CI integral aprovado.
Relatório e procedimento de homologação: `docs/investmoz/v2/demografia-ine-homologacao-2026-11-13.md`. Recibos, hashes, reconciliação de 72 dimensões entre exemplares, screenshots e diagnóstico de navegação em `outputs/demography-20261113/`. Implementação QA disponível; homologação com ressalvas estatísticas e conferência visual administrativa pendentes. Incorporar outras províncias ou instalar no activo continua fora desta execução.
## 11/11/2026 — Mineração e Energia instaladas em f2767818
Entrega autorizada de542ac2ff instalada na revisão `f27678187669d18ec063a01473dc8788c713448d`. A revisão inclui a correcção técnica dos hashes e dos geradores para usar os mesmos bytesJSON emWindows eGit/Linux,detectada antes de alterar a instalação;conteúdo,interface e âmbito preservados. Os sete casos nativos/166asserções voltaram a passar sobre os bytes canónicos. Pacote original preservado;pacote canónico com aplicação/reversão exactas. Recibo `outputs/sector-resource-20261111/installation.json`,conferido em2026-11-11T12:19:59.901666+00:00.
**Disponível:** `/sectores/mineracao` —11grupos/10áreas de cadeia de valor/6projectos de referência; `/sectores/energia-especifica` —7segmentos/11áreas/10projectos/24fontes ligadas. Localização e grupo/segmento → área → dossier novo ou existente → gravação/recuperação → evidência paraSWOT. As duas referências coexistem no mesmo dossier sem substituir escolhas. Estados,períodos,divergências e locais por definir preservados;sem criar direitos,coordenadas,ofertas ou projectos reais.
**Instalação conferida:** backup consistente e restauro isolado190tabelas;369ficheiros restaurados e conferidos porhash;22ficheiros da entrega idênticos aoGit;revisão e nove entradasHTTP200,incluindo as duas páginas sectoriais. Configuração intacta,zero migrações/importações. Nove tabelas protegidas com totais/checksums idênticos ao backup,incluindo instituições/versões/interesses,dossiers,simulações,educação,oportunidades e sectores;270instituições publicadas preservadas.
**Saldo:** implementação e instalação deste recorte encerradas;conferência visual permanece pendente. Ofertas concretas,implantação mais precisa e dados adicionais dependem do respectivo investimento,não se presumem das referências. Restantes módulos e resíduos fiscaisL1–L7,L8,L9–L16 mantêm o saldo anterior;portal integralmente concluído não declarado.
## Energia — consulta e preparação no mesmo dossier (QA,11/11/2026) ## Energia — consulta e preparação no mesmo dossier (QA,11/11/2026)
1. Abra **Sectores de investimento → Energia** (`/sectores/energia-especifica`). Seleccione localização e segmento energético,ou pesquise uma actividade. 1. Abra **Sectores de investimento → Energia** (`/sectores/energia-especifica`). Seleccione localização e segmento energético,ou pesquise uma actividade.
@@ -9646,3 +9947,276 @@ S08 permanece por concluir, separada da revis?o visual; a especifica??o da janel
### 10/11/2026 ? S08: melhoria do percurso existente de licenciamento pr?vio ### 10/11/2026 ? S08: melhoria do percurso existente de licenciamento pr?vio
?mbito esclarecido pela Tempo; substitui o anterior saldo ?por identificar?. Implementa??o e sete provas frontend dirigidas em `67344418`: estado sem repeti??o, registo/ac??o principal vis?veis, redu??o dos blocos expans?veis e abertura/foco da etapa por `#S08`, preservando campos/contexto/vers?o/grava??o e guardas existentes. TypeScript, lint dirigido e build QA aprovados. Sem altera??o de backend/hist?rico/dados ou repeti??o das provas aceites n?o afectadas. Detalhes e limites em `docs/investmoz/v2/auditoria-portal/ajustes-por-ambito-2026-11-10.md`. Revis?o visual e instala??o mant?m-se pendentes e separadas. Pacote contador/aliases preservado; sem push e cluster 55441 mantido ligado. ?mbito esclarecido pela Tempo; substitui o anterior saldo ?por identificar?. Implementa??o e sete provas frontend dirigidas em `67344418`: estado sem repeti??o, registo/ac??o principal vis?veis, redu??o dos blocos expans?veis e abertura/foco da etapa por `#S08`, preservando campos/contexto/vers?o/grava??o e guardas existentes. TypeScript, lint dirigido e build QA aprovados. Sem altera??o de backend/hist?rico/dados ou repeti??o das provas aceites n?o afectadas. Detalhes e limites em `docs/investmoz/v2/auditoria-portal/ajustes-por-ambito-2026-11-10.md`. Revis?o visual e instala??o mant?m-se pendentes e separadas. Pacote contador/aliases preservado; sem push e cluster 55441 mantido ligado.
## 13/11/2026 — relatórios agrupados no dossier: implementação em QA
Pedido do proprietário: verificar e resolver a dispersão das saídas produzidas pelas etapas do investimento. No percurso existente `/investimentos/{id}#relatorios`, o índice reúne resultados financeiros, versões fiscais preservadas, comparação/preparação da empresa, guias guardados, análise SWOT, exportações APIEX, preparação de acesso à terra e documentos das etapas. Cada referência conserva dossier, oportunidade ou contexto comum/por confirmar, revisão, data quando registada e estado; etapas sem resultado associado são explicitadas. A mesma versão documental ligada a mais de um pedido aparece uma vez, sem fundir originais diferentes.
Exportação consolidada Word e HTML para impressão/PDF reutiliza os geradores existentes e os cálculos de viabilidade já aceites. Inclui contexto do investimento, etapas e pendências, financiamento/orçamento/empréstimo/ponto de equilíbrio recuperados das entradas guardadas, conclusões SWOT com fonte/data/classificação, preparação APIEX e referências aos documentos. Exportações APIEX históricas mantêm o original, sem acrescentar SWOT privada ao pacote destinado à entidade externa. O índice dá acesso aos originais pelas autorizações existentes; o relatório não envia mensagens, submete processos, concede crédito, executa pagamentos ou publica conteúdo.
A cópia fiscal preservada no rascunho é recuperável pelo titular em `/investimentos/{id}/relatorios/fiscais/{reportId}`, incluindo quando a simulação original era anónima ou deixou de existir. A posse de acesso administrativo ao dossier não concede acesso financeiro. Fontes fiscais novas conservam a oportunidade/revisão no momento da associação; versões antigas sem essa identificação permanecem disponíveis como âmbito por confirmar, sem serem atribuídas silenciosamente à oportunidade actual. Preparação de terra não é apresentada como título DUAT. Seleccionar oportunidade é necessário antes de consolidar um dossier com várias escolhas.
Provas: `outputs/dossier-reports-20261113/`. Vinte e um testes frontend aprovados; prova final do servidor com 44 asserções, em dados sintéticos e transacção revertida, além da regressão focada anterior de quatro testes/151 asserções. Sem migrações ou novas permissões. Estado: implementado em QA; instalação desta alteração e conferência visual no uso normal ainda pendentes. O saldo dos restantes módulos e resíduos fiscais permanece inalterado.
Relatórios do dossier — conferência final de 13/11/2026: regressão focada do servidor 4 testes/168 asserções; caso final de recuperação, descargas privadas, âmbito, repetição e bloqueio com módulo desactivado 45 asserções. Os 21 testes frontend passaram; tipos, análise PHP, lint e compilação QA aprovados. Delta próprio de relatórios preparado, sem instalação remota e sem interferir na incorporação demográfica em curso.
## 13/11/2026 — relatórios do dossier instalados na homologação em 07182c7c
Entrega autorizada pelo proprietário e instalada pelo executor Git em `https://teste-investidor.tempo.co.mz`. A revisão servida é `07182c7c735efa88d850551da71b671c3dadfa9d`. Entrada: `/investimentos`, abrir o dossier autorizado e a secção `#relatorios`. Índice por etapa e exportação consolidada Word/HTML disponíveis; cópias fiscais preservadas são recuperadas pelo titular a partir do próprio dossier. Continua sem envio ou publicação automática.
Backup/restauro fresco:193 tabelas e381ficheiros. Conferência pós-entrega:12ficheiros de fonte por SHA256,HTTPS e controlos de entrada privada,12tabelas de negócio/geometria sem alteração,mais os três lotes demográficos presentes no backup conservados por contagens/checksum de metadados,observações e eventos. Durante a entrega foram concluídas,por operações autenticadas independentes,as restantes incorporações demográficas:a verificação final encontrou12lotes aprovados/920456observações. Esta entrega de relatórios não fez importações ou migrações;actualizou o vínculo da readiness à nova revisão,preservando as preparações CLI aceites.
Prova final `outputs/dossier-reports-20261113/homologation-installed.json`;estado agregado `verification.json`. QA funcional,privacidade,recuperação,exportações,tipos,lint,análise PHP e compilação aprovados. A conferência visual no uso normal permanece pendente,sem reabrir a instalação nem as provas aceites. Este estado substitui a indicação histórica de QA sem instalação acima e não declara concluídos outros módulos ou relatórios ainda por implementar.
## 13/11/2026 — consulta e descarga de resumos das 19 etapas, correcção em QA
Conferência com conta sintética e MFA normal em `http://127.0.0.1:18814/investimentos/5245f06c-bdbd-4caf-bc37-900f10bf582c`, base isolada `investmoz_demography_test:55441`. Novo dossier, versão 21: os serviços existentes guardaram fundamentos sintéticos em S01–S19. Não foram alterados dossiers anteriores nem simuladas decisões/licenças externas. Na homologação foi criado apenas o dossier separado de ensaio `db85f087-0737-4164-892a-4fa4fe1313df`, com fundamento sintético S01; os três dossiers anteriores foram preservados.
Lacuna corrigida: o componente existente passa a permitir seleccionar a etapa, consultar o resumo dos registos recuperados e preparar Word ou HTML. A ligação de descarga permanece visível com indicação de ficheiro pronto; o conteúdo e as pendências não são confundidos com documentos oficiais em falta. As limitações do modelo passam a integrar ambos os formatos. Mudar de dossier, versão ou oportunidade reinicializa a selecção/ligação local para não reutilizar uma exportação de outro contexto.
Prova integrada: consulta das 19 etapas e 38 descargas reais pelo Edge (19 Word/19 HTML), conferidas em disco por identidade, etapa, versão, fundamento, limitações e ausência de registos de outra etapa. Actualizar a página recuperou os 19 registos e a versão 21. Evidência em `outputs/dossier-reports-20261113/stage-authenticated-browser-checks.json`, `stage-authenticated-download-verification.json`, `stage-recovery.json` e `visual/04-dossier-qa-S19-descarga.jpg`. Os ensaios isolados do componente estão identificados separadamente, sem os apresentar como prova de persistência.
Validação nova, limitada ao delta: dois testes frontend (incluem exportação das 19 etapas e escape das limitações), tipos, lint dos três ficheiros de produto afectados e compilação QA. Não foram repetidas as provas aceites de APIEX/SWOT, cálculo fiscal, permissões ou instalação. O perfil antigo de QA referia contas que já não existem no cluster actualmente ligado; foi localizado e usado o perfil documentado da demografia, sem criar permissões ou reiniciar o cluster. A serialização da chave MFA da conta recém-criada foi corrigida apenas nessa conta sintética.
Estado: correcção implementada e conferida em QA, ainda sem instalação. A homologação mantém `07182c7c` para o recorte de relatórios anteriormente instalado. A prova demonstra resumos de preparação por etapa; não declara produzidos estudos financeiros, licenças, decisões, pacotes APIEX ou documentos que não existam nos respectivos percursos. A instalação deste novo delta é a pendência de entrega; os saldos próprios dos outros módulos mantêm-se.
## 13/11/2026 — resumos por etapa instalados na homologação em 8854b292
Autorização expressa do proprietário recebida e executada. Revisão servida `8854b2928c6907b6742a1eb622ae70e6b6a9e998`, construída em 2026-11-13T13:16:06Z; troca concluída em 13:16:09Z. Entrada habitual: `https://teste-investidor.tempo.co.mz/investimentos`, abrir o dossier e a secção de relatórios, seleccionar a etapa, consultar o resumo, preparar Word ou HTML e descarregar o ficheiro pronto. O relatório consolidado permanece disponível.
Backup/restauro fresco: 193 tabelas/409 ficheiros, conferidos por contagens e hashes. Verificação pós-entrega: quatro ficheiros por SHA256; 18 tabelas protegidas inalteradas, incluindo instituições, dossiers, rascunhos, documentos, jornadas, etapas e as três tabelas demográficas. Mantidos 12 lotes aprovados/920456 observações. Sem migrações, importações, publicação ou alterações de permissões. Preparação demográfica e os controlos autenticados foram preservados e a readiness foi vinculada à revisão instalada, mantendo o exemplar anterior arquivado.
O executor Git existente e os seus controlos obrigatórios de CI foram reutilizados sem alteração de fonte ou da configuração partilhada. A entrega ficou fixada na branch aprovada `entrega/relatorios-etapas-20261113`, pois a main já continha alterações alheias de pesquisa/suporte com integração falhada. Essas alterações e a main não foram substituídas nem instaladas por esta autorização.
Prova pós-instalação no Edge, na sessão autorizada já existente: dossier sintético `db85f087-0737-4164-892a-4fa4fe1313df`, resumo S01 e fundamento guardado recuperados; Word e HTML efectivamente descarregados e conferidos. Nenhum dossier foi alterado nesta conferência. Não se repetiram as 19 consultas/38 descargas aceites em QA; o CI obrigatório do executor correu normalmente. Recibo: `outputs/dossier-reports-20261113/stage-homologation-installed.json`; captura `visual/06-homologacao-S01-descarga.jpg` no mesmo directório.
Estado deste delta: instalado e conferido. Substitui a pendência de instalação do registo anterior, preservando os seus limites: são resumos de preparação dos registos disponíveis; não se declaram produzidas licenças, decisões, estudos ou outros originais ainda em falta nos respectivos percursos. O pacote QA selado `outputs/entrega-relatorios-etapas-20261113.zip` permanece inalterado; o recibo posterior comprova a instalação.
## 13/11/2026 — ACT-02: confronto de observações quantitativas, QA
Primeiro recorte executável da continuidade documentada de ACT-20261029-02: a comparação das observações públicas já recebidas no mapa. Reutiliza `InvestmentMapIndicators` e os filtros existentes; não acrescenta API, cadastro, publicação, fonte externa ou acesso a dados privados. Duas selecções progressivas mostram território, descrição/natureza da grandeza, indicador, valor textual exacto, unidade, período, âmbito, população/base, metodologia, situação, fonte/data e versão.
A conferência assinala diferenças de território/indicador/unidade/âmbito/base/metodologia, campos em falta (incluindo descrição/natureza), datas inválidas, escolha repetida e informação desconhecida/em divergência. Metadados alinhados não são certificação de comparabilidade. Duração, sobreposição dos períodos e natureza prevista/efectiva exigem leitura da fonte. Não soma, calcula evolução, converte grandezas, cria pontuação ou índice regional. Zero, precisão decimal e fontes originais são preservados.
Selecções identificadas por território, observação e versão. Fora dos filtros deixam de entrar na comparação; mudar a versão exige nova selecção. Conferência no Edge com três observações inteiramente sintéticas, sem API/escritas na base, exercitando o componente real compilado. Capturas e recibo em `outputs/map-comparison-20261113/`. Três testes novos dirigidos ao delta, tipos, lint e compilação QA aprovados; os testes aceites dos filtros anteriores não foram repetidos.
Estado: implementado e conferido em QA, sem instalação. V2-07/V2-10 e ACT-02 permanecem parciais: cobertura factual nacional/regional, séries estatisticamente comparáveis, oferta laboral, natureza estruturada das grandezas e metodologia de potencial económico continuam com o seu saldo próprio. Este incremento encerra apenas a ferramenta de confronto das observações recebidas. Não altera dossiers, relatórios de 8854b292, pesquisa ou suporte aceites. A candidata incorpora também o recibo documental de 8854b292, sem repetir a instalação ou as provas dessa entrega.
## 13/11/2026 — ACT-02: comparação de observações instalada em 1263b4f6
Instalação expressamente autorizada e concluída pelo executor Git existente na main, em `https://teste-investidor.tempo.co.mz`. A revisão servida é `1263b4f6b7609c3d4d293c2b9f8a68abb9d3e3c4`. No mapa das oportunidades, o bloco de indicadores apresenta «Comparar duas observações» quando há pelo menos duas observações públicas nos filtros actuais. A comparação mantém valores, descrições, unidades, períodos, âmbito, base, metodologia, situação, fontes e versões; não infere evolução, conversão, elegibilidade ou potencial regional.
Backup/restauro fresco de 193 tabelas/410 ficheiros, com contagens e hashes conferidos; verificação pós-entrega de 21 tabelas protegidas sem diferenças, incluindo dados territoriais, instituições, dossiers, jornadas, etapas, documentos, permissões e demografia. Mantidos 12 lotes aprovados/920456 observações. Fontes entregues verificadas por SHA256; configuração/fonte do executor preservadas. Sem migrações, importações, publicação de conteúdo ou novas permissões. A readiness demográfica foi vinculada à revisão, com o exemplar anterior arquivado.
Reutilizadas as provas aceites: três testes novos e a conferência visual sintética do componente; o CI obrigatório do executor correu normalmente, sem nova ronda manual dos percursos aceites. HTTPS do mapa, instituições, demografia e entrada privada conferido. Os originais e a pesquisa, suporte e relatórios de 8854b292 foram preservados. Não foram criadas observações sintéticas na homologação para fazer aparecer a comparação. Recibo: `outputs/map-comparison-20261113/homologation-installed.json`; pacote QA selado inalterado.
Estado do incremento: instalado. ACT-02/V2-07/V2-10 permanecem parciais quanto à cobertura factual, séries estatisticamente comparáveis, oferta laboral, natureza estruturada das grandezas e metodologia económica. Esta entrega fecha a ferramenta de confronto das observações recebidas, não esses requisitos amplos.
## 13/11/2026 — ACT-02: natureza estruturada das observações, QA
Continuidade do confronto instalado em 1263b4f6. O editor existente permite declarar segundo a fonte se o valor é observado/efectivo, planeado/meta, projectado/estimado ou por confirmar. A classificação segue o envelope privado, a recuperação e o fluxo editorial existentes; preparar uma alteração não modifica a versão pública. Omissão por clientes anteriores preserva uma escolha deliberada. Sem indicação anterior, a consulta apresenta por confirmar; não infere natureza pela descrição nem reescreve registos. Fonte, valor textual exacto, zero, identidade, versão, confidencialidade e histórico mantidos. Não há migrações, novas permissões, publicação automática ou importação.
A consulta e a comparação mostram a natureza separada da situação documental; naturezas diferentes ou desconhecidas exigem conferência. Metadados alinhados não provam comparabilidade estatística. QA: três testes backend/21 asserções, cinco testes frontend afectados, análise PHP, Pint, tipos, lint e compilação aprovados. Edge: componentes reais compilados com dados sintéticos; selector e edição preservados, observado/projectado distintos, legado por confirmar, zero/precisão mantidos. Provas em outputs/observation-nature-20261113/. Reutilizadas as provas aceites dos restantes percursos.
Estado: implementado em QA, instalação pendente de autorização concreta; homologação mantém 1263b4f6. Encerra a capacidade estruturada de declarar a natureza, não a classificação factual de todo o acervo. ACT-02/V2-07/V2-10 continuam parciais: cobertura factual nacional/regional, séries estatisticamente comparáveis, oferta laboral, conferência documental dos valores antigos e metodologia de potencial económico. Relatórios, pesquisa, suporte e entregas aceites preservados.
## 13/11/2026 — ACT-02: natureza das observações instalada em 42b9324a
Instalação expressamente autorizada e concluída pelo executor Git existente na main, em `https://teste-investidor.tempo.co.mz`. A revisão servida é `42b9324acbf0a15418424e8a539a0740932b2377`. O editor existente permite declarar observado/efectivo, planeado/meta, projectado/estimado ou por confirmar; a escolha segue a preparação privada e o fluxo editorial existente. Consulta e comparação distinguem natureza e situação documental, sem inferir a classificação dos registos antigos ou reescrever o acervo.
Backup/restauro fresco de 193 tabelas/411 ficheiros, com contagens e hashes conferidos; verificação pós-entrega de 21 tabelas protegidas sem diferenças, incluindo dados territoriais, instituições, dossiers, jornadas, etapas, documentos, permissões e demografia. Mantidos 12 lotes aprovados/920456 observações. Fontes entregues verificadas por SHA256; configuração/fonte do executor preservadas. Sem migrações, importações, publicação de conteúdo ou novas permissões. A readiness demográfica foi vinculada à revisão, com o exemplar anterior arquivado.
Reutilizadas as provas aceites: três testes backend/21 asserções, cinco frontend afectados, análise/tipos/lint/build e conferência visual sintética do componente; o CI obrigatório do executor correu normalmente, sem nova ronda manual dos percursos aceites. HTTPS do mapa, instituições, demografia e entrada privada conferido. Os originais e a pesquisa, suporte e relatórios de 8854b292 foram preservados. Não foram criadas observações sintéticas na homologação para fazer aparecer a comparação. Recibo: `outputs/observation-nature-20261113/homologation-installed.json`; pacote QA selado inalterado.
Estado do incremento: instalado. ACT-02/V2-07/V2-10 permanecem parciais quanto à cobertura factual, séries estatisticamente comparáveis, oferta laboral, classificação factual do legado e metodologia económica. Esta entrega fecha a capacidade de declarar/recuperar/consultar a natureza das observações, não esses requisitos amplos.
## 13/11/2026 — ACT-02: conferência temporal da comparação, QA
Continuidade após a natureza das observações instalada em 42b9324a. A comparação existente mostra a duração dos períodos de referência em dias de calendário, com ambos os limites incluídos. Durações diferentes exigem conferência; períodos distintos com sobreposição, incluindo um único dia comum, mostram aviso próprio. Datas incompletas, inválidas ou invertidas continuam por confirmar, sem duração inferida da data da fonte. Não anualiza, calcula taxas/evolução, soma observações ou declara comparabilidade estatística. Valores textuais, zeros, fontes, versões, filtros e âmbito territorial preservados.
QA: dois testes novos de duração/ano bissexto/sobreposição/limites e datas inválidas; sete testes afectados passaram. Tipos, lint e compilação aprovados. Edge com componente real compilado e dados sintéticos: duração inclusiva de 366/365 dias e aviso de sobreposição de um dia conferidos. Sem API, base de dados, migração, importação ou publicação. Provas: outputs/observation-period-20261113/. Provas aceites dos restantes percursos reutilizadas.
Estado: implementado em QA, sem instalação. ACT-02 permanece parcial: cobertura factual, classificação documental do legado, séries estatisticamente comparáveis, oferta laboral e metodologia económica conservam o saldo próprio. Esta entrega encerra a conferência automática de duração e sobreposição das datas recebidas, não a comparabilidade factual das séries.
## 13/11/2026 — ACT-02: comparação temporal instalada em 2bad72c0
Instalação autorizada e concluída na homologação https://teste-investidor.tempo.co.mz pelo executor Git existente. Revisão servida `2bad72c042f1a779849bfacc8a73ccd842b27fe7`. A comparação de observações mostra duração em dias com limites incluídos e assinala durações diferentes e sobreposição; não infere taxas, evolução ou comparabilidade estatística.
Backup/restauro fresco de193tabelas/412ficheiros;15fontes verificadas por hash;21tabelas protegidas sem diferenças. Mantidos12lotes/920456observações demográficas, instituições, dossiers, relatórios, pesquisa, suporte e permissões. Sem migrações/importações ou publicação de dados. Readiness demográfica vinculada à revisão com original arquivado. Configuração e fonte do executor inalteradas. HTTPS e entrada privada conferidos. CI obrigatório executado normalmente; provas anteriores reutilizadas sem nova ronda manual. Recibo: outputs/observation-period-20261113/homologation-installed.json; pacote QA selado preservado.
Este delta deixa de ser pendência de instalação; ACT-02 continua parcial quanto à cobertura factual, classificação do legado, oferta laboral, séries estatisticamente comparáveis e metodologia económica. A ferramenta não cria séries factuais por si.
## 13/11/2026 — lote PEC ordinário: último montante e PPC acima da base, QA
Conforme a directiva de agrupar melhorias relacionadas, este lote reúne dois resíduos do percurso de viabilidade fiscal, sem instalação por cada correcção. Reutiliza a preparação PEC, livro anual, rascunho privado, histórico e relatório do mesmo dossier; não cria um motor ou módulo paralelo. Recorte:2026, residente geral comercial/industrial/agrícola, ano civil, MZN. Homologação mantém2bad72c0.
1. Sem volume anterior, permite indicar o último montante PEC já determinado com NUIT, exercício, valor, fonte/fundamento e confirmação de que é o último aplicável ao caso. A hipótese documental29(5) conserva esse montante; não o confunde com volume de negócios ou pagamentos somados, não aplica de novo0,5%/limites nem deduz PPC pela segunda vez. Ausência, incompatibilidade, exercício futuro/anterior ao início ou falta de conferência mantêm a estimativa indeterminada. A referência fica conservada quando deixa de ser usada; alterações exigem reconferência. Uma rectificação documental para volume zero usa a mesma referência. Isto implementa a hipótese documentada, não uma inferência automática da qualificação fiscal ou do último montante a partir de dados inexistentes.
2. No cálculo ordinário29(2), PPC documentados acima da base limitada produzem estimativa PEC zero e diferença documental explícita. Não criam valor negativo, reembolso, crédito, transporte ou dedução anual. Esta regra tem marcador de versão na chave do livro apenas quando afecta o caso; obriga a recalcular resultados antigos dependentes, preservando histórico e casos ordinários anteriores. Fonte reutilizada:BR16/I16-04-2008,p122/PDF24,original SHA256fc373ff5637edf75ec6a9595dec8763a8503d4ee786e3b7d8f1c742eae9d80ee.
Os campos têm rótulos e controlos separados de largura completa; referências vazias aparecem por confirmar. Consulta do suporte e apresentação dos efeitos são parte do mesmo formulário. Não executa pagamentos ou declarações. Fonte, escolhas, original e histórico conservados no rascunho e no relatório.
QA:21testes frontend afectados, incluindo seis novos; uma prova nativa/42asserções de POST/PUT/GET em transacção com rollback na base isolada investmoz_demography_test:55441. Quatro cenários:último montante42500,25, fonte ausente, volume positivo com referência conservada e PPC60000/base50000/estimativa0/diferença10000. Recuperação e relatório financeiro do dossier conferidos; saldo anual138MZN e histórico anterior inalterados. Tipos, lint focado, sintaxe PHP, Pint e build QA aprovados. Edge:componentes reais compilados com dados sintéticos, ambos os efeitos, confirmação retirada após edição e apresentação final conferidos; sem API/escrita na base pela prévia. A primeira prévia importava um auxiliar Node com filesystem; foi substituído por JSON sintético gerado, sem alterar o produto para contornar o erro. Provas em outputs/irpc-pec-last-amount-20261113/. Não repetidas as provas aceites dos percursos não afectados.
Estado:implementado e verificado em QA; pacote agrupado, ainda sem instalação e dependente de autorização concreta. Não encerra L8 nem o portal. Restam períodos/regimes especiais, qualificação documental de casos fora deste recorte e os demais resíduos L1–L7/restanteL8/L9–L16 e módulos. Execução de pagamentos não é requisito do portal. A instalação deverá usar o executor Git, com backup/restauro fresco e reversão de release; preservar snapshots/históricos produzidos após a entrega, sem restaurar uma base antiga sobre edições posteriores.
## 13/11/2026 — lote PEC ordinário instalado em f059c226
Entrega expressamente autorizada e instalada pelo executor Git existente na main em https://teste-investidor.tempo.co.mz. Revisão servida f059c226d26ce0e9f5fec30ecfed44a65d61d6e3. Inclui referência ao último montante PEC documentado, reconciliação de PPC acima da base e apresentação do formulário/relatório. São hipóteses para viabilidade; não há execução de pagamentos, declarações, reembolso ou dedução criada pelo portal.
Backup/restauro fresco de193tabelas/413ficheiros e conferência pós-instalação de21tabelas protegidas sem diferenças. Mantidos instituições, dossiers, documentos, simulações, histórico, Educação, mapa, relatórios, pesquisa, suporte, permissões e12lotes/920456observações demográficas.23fontes conferidas por hash; revisão pública e HTTPS verificados. Executor/configuração inalterados, zero migrações/importações; readiness demográfica vinculada à revisão com original arquivado. CI obrigatório executado normalmente; provas anteriores reutilizadas sem nova ronda manual. Recibo: outputs/irpc-pec-last-amount-20261113/homologation-installed.json. Pacote agrupado v2 selado preservado; a versão anterior do ZIP conserva-se como histórico de normalização de finais de linha, sem alteração do produto.
Este lote deixa de ser pendência de instalação. L8 continua parcial para períodos/regimes especiais e casos fora do recorte; os resíduos L1–L7/restanteL8/L9–L16 e restantes módulos mantêm o saldo próprio. Próximo lote compatível: projecção conjunta PPC/PEC no ecrã e relatório, separada dos comprovativos pagos e do fecho anual.
## 13/11/2026 — lote integrado de projecção mensal PPC/PEC, QA
Continuidade do saldo de integração e relatórios AUD-SIM-INT02/INT06, após f059c226 instalado. Antes deste delta, as estimativas PPC e a hipótese PEC eram apresentadas separadamente; faltava a vista mensal conjunta. O lote reúne a apresentação no ecrã L8, a mesma projecção no relatório do dossier e a separação entre estimativas e comprovativos documentados como pagos. Reutiliza os calculadores aceites, o livro, o rascunho, a recuperação e os exportadores existentes. Não acrescenta um motor fiscal, formulário de pagamento, API, tabela ou permissão.
A vista agrega cada componente uma única vez por mês, com fracções exactas. A opção PEC concentrada conserva o mês escolhido; a repartida conserva os três montantes explícitos. Uma componente desconhecida ou uma distribuição por reconciliar produz total por determinar e subtotais conhecidos identificados; não vira zero. Zero documentado é distinguido da falta de dados. São previsões PPC/PEC do recorte ordinário2026, não o saldo a executar ou todos os impostos do negócio. Valores pagos, deduções, saldo anual, fluxo financeiro e métricas do investimento não são acrescidos ou substituídos.
O relatório usa as opções guardadas na versão calculada, preservando as escolhas posteriores ainda por recalcular. Fontes e explicação aparecem no resumo da projecção; as linhas mensais conservam os montantes sem repetir extensamente as mesmas citações. A identificação de dossier/oportunidade e os exportadores existentes mantêm-se. Legados sem estas preparações não recebem uma projecção presumida.
QA:42testes frontend afectados, incluindo sete novos de agregação exacta, sobreposição, ausência, distribuição decimal, zero, recuperação, relatório e apresentação. Os sete testes do delta foram reconferidos após o ajuste de apresentação; o ensaio da vista L8 confirma os grupos separados, além da tabela. Prova nativa de POST/PUT/GET/recuperação em quatro cenários com42asserções e rollback na base isolada investmoz_demography_test:55441. Cenários:112000MZN totais com64000emMaio, distribuição por seis meses, PEC por completar sem total presumido, e zero explícito sem saída. Saldo anual sintético138MZN e histórico anterior preservados; relatório financeiro do mesmo dossier conferido. Tipos, lint focado sem reformatação dos ficheiros legados, Pint/sintaxe e build QA aprovados. Edge:componente real compilado e relatório, estados completo/parcial/zero e apresentação final sem explicações repetidas. Prévia sintética sem API/BD; provas aceites dos restantes percursos reutilizadas. Provas:outputs/irpc-cashflow-group-20261113/.
Estado:implementado e verificado em QA, ainda sem instalação. Homologação mantém f059c226. Pacote único compatível; instalação depende de autorização concreta, com backup/restauro fresco e reversão de release preservando snapshots e edições posteriores. Este lote encerra a integração visual e documental destas previsões mensais; não encerra L8 nem o portal. Períodos/regimes especiais, anualização PPC28(3), outros casos documentais e resíduos L1–L7/restanteL8/L9–L16 e módulos conservam o saldo próprio. Execução de pagamentos não é requisito do portal.
## 13/11/2026 — projecção fiscal agrupada instalada em 95b33275
Instalação expressamente autorizada, concluída pelo executor Git existente na homologação https://teste-investidor.tempo.co.mz. Revisão servida95b33275aae48a6209c67002788c177b3dd80fcb. Inclui vista mensal conjunta PPC/PEC, distinção completo/parcial/zero, separação de estimativas e comprovativos e o mesmo conteúdo no relatório do dossier, com fontes concentradas no resumo. Sem alteração dos totais do negócio, pagamentos, deduções ou histórico.
Backup/restauro fresco de193tabelas/414ficheiros,29fontes conferidas por hash e21tabelas protegidas sem diferenças. Mantidos instituições, dossiers, documentos, simulações, Educação, mapa, relatórios, pesquisa, suporte, permissões e12lotes/920456observações demográficas. Zero migrações/importações; executor e configuração intactos. Readiness demográfica vinculada à revisão com original arquivado. HTTPS e revisão pública conferidos; CI obrigatório executado normalmente. Provas aceites reutilizadas sem nova ronda manual de percursos. Recibo:outputs/irpc-cashflow-group-20261113/homologation-installed.json. Pacote agrupado selado inalterado.
Este lote deixa de ser pendência de instalação. Não encerra o simulador ou o portal: períodos/regimes especiais, anualização28(3), casos documentais e resíduos L1–L7/restanteL8/L9–L16 e módulos mantêm o saldo próprio. Próximo lote compatível: coerência do período L6 nas estimativas, projecção e relatórios, preservando a preparação e o fecho anual independente.
## 14/11/2026 — lote de coerência do período L6 em PPC/PEC, QA
Continuidade do saldo AUD-SIM-INT02/INT06 após95b33275 instalado. Conferência delimitada do código: PPC/PEC ordinários e a projecção recebiam titular/exercício/tipo, sem confrontar as datas L6 conhecidas. Podiam apresentar meses civis num dossier com período curto ou não civil. O lote liga o contexto já existente aos dois estimadores, à vista mensal e ao relatório da mesma versão, sem duplicar o motor fiscal, criar API, tabela ou permissão.
As datas preparadas em L6 prevalecem para a conferência da preparação, mesmo quando ainda diferem do último cálculo L6; não são substituídas silenciosamente pelo resultado anterior. O período usado fica conservado no modelo da versão anual calculada. O relatório lê esse exemplar, preservando edições posteriores ainda por recalcular. Uma divergência face ao recorte civil2026 mantém as estimativas dependentes por reconciliar, sem calendário ou total presumidos. As entradas e as hipóteses de caixa ficam guardadas, sem alterar o cálculo anual independente.
A dispensa PEC do ano de início documentado mantém zero nos períodos válidos contidos em2026, incluindo o período curto de início; datas inválidas, outro exercício ou período não civil fora deste recorte não recebem dispensa presumida. Os avisos identificam só a componente afectada; se PPC e PEC estiverem ambos por reconciliar, o resumo reúne a causa comum. A chave de cálculo recebe um marcador apenas para os casos afectados pela nova conferência, sem invalidar globalmente os livros ordinários aceites. Livros e históricos antigos conservados. A preparação independente antiga, sem metadados L6 disponíveis, mantém o seu âmbito documental explícito.
QA iniciada em13/11 e fechada em14/11:61testes frontend afectados, incluindo oito novos de período/edição/recuperação/consumidores. Prova nativa POST/PUT/GET com42asserções, quatro cenários e rollback na base isolada investmoz_demography_test:55441. Civil completo mantém112000MZN de projecção; não civil e curto ficam por reconciliar; início preserva PECzero e PPC separado. Nos quatro cenários, o resultado anual independente coincide com o exemplar anterior sem as preparações e o histórico mantém-se. Originais L6 e datas são produzidos pelo percurso normal existente, sem fabricar um calendário oficial. Relatório financeiro do mesmo dossier conferido. Tipos, lint/formatação focados, Pint/sintaxe PHP e build QA aprovados. Edge:componentes reais compilados, quatro contextos, dispensa e aviso específico PPC, ecrã/relatório concordantes. Prévia sintética sem API ou base de dados. Provas aceites dos percursos não afectados reutilizadas; dependências financeiras e exportadores conferidos idênticos a95b33275. Provas:outputs/irpc-period-consistency-20261113/.
Estado:implementado e verificado em QA, sem instalação. Homologação mantém95b33275. Lote único para estimadores, projecção, recuperação e relatório. Encerra a lacuna de coerência do período conhecido; não implementa ainda os cálculos/calendários dos períodos ou regimes especiais nem a anualização PPC28(3). L8 e portal permanecem parciais; resíduos L1–L7/restanteL8/L9–L16 e módulos mantidos. Sem pagamentos, novas deduções, migrações, importações ou permissões. Instalação exige autorização concreta e o executor Git existente, com backup/restauro fresco e reversão de release preservando snapshots/edições posteriores.
## 14/11/2026 — coerência dos períodos fiscais instalada em 078f770d
Instalação expressamente autorizada, concluída pelo executor Git existente na homologação https://teste-investidor.tempo.co.mz. Revisão servida078f770dea3d901624e2d9d8566a2d0f30f5acfe. Inclui coerência entre o período preparado em L6, estimativas PPC/PEC, projecção e relatório; contextos fora do recorte civil ficam por reconciliar, com a dispensa PEC inicial preservada. Sem alteração dos totais do negócio, pagamentos, deduções ou histórico.
Backup/restauro fresco de193tabelas/415ficheiros,37fontes conferidas por hash e21tabelas protegidas sem diferenças. Mantidos instituições, dossiers, documentos, simulações, Educação, mapa, relatórios, pesquisa, suporte, permissões e12lotes/920456observações demográficas. Zero migrações/importações; executor e configuração intactos. Readiness demográfica vinculada à revisão com original arquivado. HTTPS e revisão pública conferidos; CI obrigatório executado normalmente. Provas aceites reutilizadas sem nova ronda manual de percursos. Recibo:outputs/irpc-period-consistency-20261113/homologation-installed.json. Pacote agrupado selado inalterado.
Este lote deixa de ser pendência de instalação. Não encerra o simulador ou o portal: períodos/regimes especiais, anualização28(3), casos documentais e resíduos L1–L7/restanteL8/L9–L16 e módulos mantêm o saldo próprio. Próximo lote compatível: períodos anuais completos não civis documentados, preservando a preparação e o cálculo anual independente.
## 14/11/2026 — PPC/PEC em períodos anuais não civis documentados, QA
Continuidade executável de AUD-SIM-INT02/INT06 após078f770d instalado. Reutiliza L6, os calculadores PPC/PEC, o livro privado, o histórico, a projecção conjunta e os relatórios existentes. Acrescenta uma correspondência documental no mesmo livro anual: titular, exercício, datas e fundamento do período corrente, datas e fonte do período anterior e confirmação explícita dos montantes/fontes. As datas anteriores sugeridas não equivalem a comprovação. Nenhuma autorização fiscal é produzida pelo portal.
Recorte: residente geral/MZN, actividade comercial/industrial/agrícola, período corrente não civil com doze meses civis completos e termo2026, anterior igualmente completo, contíguo e com termo2025. Fonte aceite CIRPC7(2),Lei34/2007,BR52,original SHA256cdbef875ad7ccc1517b12faf5de0e893a70ed2ed81e33913cd6dc4a674474e6e; meses RIRPC27(1)(a)/29(1),BR16/I16-04-2008,original SHA256fc373ff5637edf75ec6a9595dec8763a8503d4ee786e3b7d8f1c742eae9d80ee. PPC nos5.º/7.º/9.ºmeses; PEC nos6.º/8.º/10.ºmeses. Fórmulas e aritmética racional reutilizadas, sem segundo motor ou novos parâmetros oficiais.
O PEC exige actividade documentada anterior ao início do período anterior neste recorte. Início não civil, volume anterior zero com histórico29(5), períodos curtos, meses incompletos, outros regimes e anualização PPC28(3) mantêm-se separados e pendentes. A opção de mês único só aceita um mês dentro do período documentado, como hipótese de tesouraria e sem certificar prazo legal.
Mudanças de datas/fontes não substituem conclusões ou valores. A correspondência financeira tem marcas independentes PPC/PEC: alterar PPC mantém PEC conhecido e vice-versa; o total fica parcial até à reconferência explícita. A distribuição PEC antiga é conservada sem deslocamento automático para meses não civis; exige vinculação expressa às novas datas, preservando montantes, opção e referência. O período usado e a declaração ficam congelados no resultado guardado, sem mudar o cálculo anual independente ou o histórico. Sem alteração global dos livros civis aceites.
QA:70testes frontend afectados distintos, incluindo nove novos de domínio/UI; sucesso, falta de fonte, divergência de titular/datas, período anterior curto/não contíguo, reconferência independente, preservação da distribuição, mês único e histórico. API nativa POST/PUT/GET:quatro cenários sintéticos,46asserções, rollback em investmoz_demography_test:55441, identidade PDO conferida. Cálculo→gravação→recuperação→relatório financeiro do mesmo dossier demonstrados. Tipos, lint focado, Pint e compilação aprovados. Edge com componentes reais compilados:112000MZN nos meses relativos correctos, concentração emJaneiro2026, total indeterminado sem fonte, PEC preservado quando PPC muda e193000MZN apenas após reconferência. Corrigido aviso contraditório de ano civil no caso documental incompleto; regressão acrescentada. Provas:outputs/irpc-noncivil-20261114/. Provas aceites não afectadas reutilizadas.
Estado:implementado e verificado em QA, ainda sem instalação. Homologação mantém078f770d. Este lote encerra apenas as estimativas PPC/PEC e a projecção deste recorte anual não civil documentado; não encerra L6/L8, a liquidação final não civil, o simulador ou o portal. Resíduos L1–L7/restanteL8/L9–L16 e outros módulos preservados na matriz. Sem migrações, permissões, rotas, importações, pagamentos, declarações submetidas ou novas deduções. Instalação agrupada depende de autorização concreta, executor Git, backup/restauro fresco e reversão de release preservando edições posteriores.
## 14/11/2026 — Ofertas, Fase 1: preparação privada em QA
Autorização condicionada expressa da Tempo nesta sessão: exclusivamente desenvolvimento da Fase 1. Oportunidades é o cadastro principal das novas Ofertas; negócios e restantes percursos mantêm os seus identificadores, contratos e dados. Sem mudanças no menu, esquema mne, dados operacionais ou instalação. As migrações existentes foram usadas apenas para preparar uma base sintética nova, offers_phase1_20261114_test:55441, preservando as outras bases QA.
Entrada privada em desenvolvimento: /minhas-oportunidades/ofertas. Requer autenticação, email verificado, perfis disponíveis e âmbito de Promotor já atribuído para criar/editar/submeter; não concede papéis. Criar → guardar → recuperar → editar → conferir → submeter em privado. Rascunhos incompletos são recuperáveis. A submissão exige título, descrição, categoria, contacto autorizado e preço MZN/USD ou «A negociar»; não converte moedas nem confunde preço pedido com investimento estimado. Localização usa os identificadores e a hierarquia existentes. Contactos usam o CRM e o seu controlo de MFA, sem novo cadastro ou publicação dos contactos.
Gravação usa OpportunityManagement, chave de criação, etag, versões, management_draft e auditoria existentes. Campos inesperados são recusados; permissões, representação e contacto são reconferidos no servidor. Conflitos não sobrescrevem a versão guardada. A submissão regista autor, instante UTC e revisão nos metadados privados, sem criar revisão editorial, notificações, aprovação ou publicação da Fase 2. A oferta submetida fica recuperável e sem edição nesta fase. A marca privada bloqueia conversão/publicação pelo modelo, workflow e divulgação existentes; o catálogo público exclui-a mesmo perante um estado incoerente introduzido por outro escritor.
Verificação: 14 testes novos. Bateria de regressão de Oportunidades/perfis/editorial: 35 testes e 548 asserções aprovados. Passagem final focada, após as últimas alterações, com 16 testes e 172 asserções aprovados; os grupos sobrepõem-se e não devem ser somados. Inclui criação idempotente, recuperação, conflito, contacto de outro titular/eliminado, perda de representação/permissão, preço incompleto/inválido, campos forjados, submissão confirmada, bloqueios de publicação e ausência nas consultas públicas. Duas páginas verificadas por HTML completo com entradas Vite reais. Pint, PHPStan, tipos, lint/formatação focados, build QA e revisão do delta aprovados. Recibo: outputs/offers-private-phase1-20261114/qa-result.json.
Limitações: conferência visual manual não efectuada. npm run check global acusa formatação preexistente em 61 ficheiros alheios ao delta; esses ficheiros não foram alterados e a falha não foi dispensada. A bateria canónica extensa foi interrompida, não contabilizada como aprovada e substituída pela verificação do cenário afectado. Uma prova antiga de divulgação exige outra base pelo seu próprio controlo de segurança; esse controlo foi preservado, sem tocar nessa base, e a compatibilidade do âmbito nativo foi testada na nova preparação sintética.
Saldo de RF-MN-003: Fase 1 de Ofertas implementada e verificada tecnicamente em QA, sem instalação. Não encerra o requisito integral nem Procuras. Fase 2 e seguintes permanecem por autorizar; NDA, comprovativos, fotografias, validade, moderação e publicação não são simulados nesta entrega. A autorização anterior de validador jurídico para legislação não é estendida a Ofertas. Instalação em homologação ou produção exige verificação e autorização expressa separada.
## 14/11/2026 — Ofertas Fase 2, circuito editorial privado em QA
Autorização expressa da Tempo, após encerramento técnico da F1 com ressalva visual. Implementação limitada ao plano 6e302db6, mantendo Oportunidades como cadastro principal. Dashboard existente → Ofertas/revisão privada → validador inicia ou coordenação atribui → pede correcção/rejeita com fundamento → promotor corrige/reenvia no mesmo identificador → aprovação interna independente. Preserva versões, rejeições, aprovação histórica e tarefas. Reatribuição guarda quem alterou, anterior e novo responsável, sem conceder papéis.
Snapshots/hash, revisão e ciclo vinculam cada decisão aos campos e referências conferidos. Mudança no contacto/localização bloqueia decisão ou assinala reconferência; não substitui a conclusão. Primeira revisão explícita de submissão F1 preserva autor/data/revisão e regista a vinculação actual sem inventar fingerprints históricos do CRM. Estados editoriais privados não tornam a oferta pública; status de publicação mantém-se privado, marca/exclusão pública e percursos alternativos bloqueados. Os percursos nativos conservam a versão pública anterior durante alterações pendentes. Desactivar o circuito não permite submeter v2 pelo fallback v1 nem apagar histórico.
Reutiliza perfis/MFA/independência, workflow, tarefas, auditoria e caixa de notificações. Email pelo transporte existente, depois do commit e com revalidação, activação/lista operacional nativas; em QA apenas memória, sem mensagens reais ou SMS. Configurações de entrega e designações operacionais não são inventadas. Sem migrações/tabelas novas, mudanças em mne, dados operacionais, menu principal ou instalação.
QA sintética offers_phase1_20261114_test:55441: regressão 35/499, final F2 11/160, complemento de contribuidores 1/14 e HTML final 1/10 aprovados; grupos sobrepostos, não somar. Pint, PHPStan, tipos, lint/formatação focados e build QA aprovados. Testa autoria/contribuidores/âmbito/MFA/revogação, snapshots, rejeição/correcção/reenvio, reatribuição, fila sem duplicação, rollback, notificações idempotentes, fonte alterada, recuperação e recusa de publicação. Conferência visual mantém a ressalva da ferramenta; formatação global continua nos 61 ficheiros preexistentes exteriores ao delta, sem dispensa. Entregabilidade real não demonstrada. Relatório: docs/investmoz/v2/auditoria-portal/ofertas-fase2-entrega-qa-20261114.md. Recibo: outputs/offers-private-phase2-20261114/qa-result.json.
Saldo RF-MN-003: F1/F2 implementadas e verificadas tecnicamente em QA, não instaladas; requisito integral/Procuras permanecem parciais. Fase 3 e instalação em homologação/produção dependem de autorização expressa própria. Não foram iniciados catálogo público, NDA, comprovativos, fotografias ou validade. Próximo passo é rever esta entrega e conferir visualmente quando o ambiente permitir, sem reabrir provas técnicas aceites.
# 14/11/2026 — Ofertas, Fase 3 em QA (RF-MN-003)
Base F2 `91c18573`, F1 `1f2990e4` e plano `6e302db6` preservados. Consulta `/ofertas`, detalhe, API e CSV reutilizam filtros, paginação, mapa e layout público existentes. Categoria, província/distrito e preço são aplicados à projecção pública aprovada/publicada, separada da edição privada; intervalos e ordenação exigem moeda, sem câmbio. Mapas apresentam limites administrativos, sem pontos dos negócios. Contactos, documentos e dados financeiros privados não são seleccionados pelo consumidor. Sem reorganização do menu, novas tabelas, alterações em `mne`, instalações ou dados operacionais.
A publicação efectiva permanece bloqueada, incluindo rotas alternativas. O catálogo normal fica vazio/detalhe 404. Apenas injecção interna de teste, com a guarda de ambiente testing/base isolada em 55441, permite demonstrar projecções sintéticas; parâmetros HTTP e JSON armazenado não abrem a barreira. Não se declarou uma publicação real nem se implementaram as Fases 4/5. Uma revisão privada pendente/rejeitada não substitui o snapshot público anterior.
QA final: 10 testes/133 asserções aprovados; inclui acessos directos, estados inválidos, isolamento, preservação, hierarquia, decimais/moedas, mapa sem localização exacta, CSV, HTML compilado e regressão nativa autocontida. Passagem anterior: 36/41 aprovados; cinco casos nativos impedidos por referência Inhambane ausente na base sintética. Tipos, lint focado, Pint, PHPStan e build aprovados. Formatação global: os mesmos 61 ficheiros exteriores ao delta. Conferência visual/móvel pendente: `The browser access host is unavailable`; não substituída por aprovação técnica. Email real F2 mantém pendência.
Estado: implementado em QA com barreira fechada, não instalado; módulo integral e Procuras parciais. Limites representados: 1.000 candidatos e contexto de 30 territórios, assinalados quando truncados. Relatório: `docs/investmoz/v2/auditoria-portal/ofertas-fase3-entrega-qa-20261114.md`. Recibo: `outputs/offers-public-phase3-20261114/qa-result.json`. Próximo passo: conferência desktop/móvel e apreciação da entrega QA; sem instalação ou avanço de fase por inferência.
## 14/11/2026 — Ofertas Fase 4, particularidades e confidencialidade em QA
Base F3 7691cab3 preservada, tal como F1/F2 e plano aceite. A Tempo confirmou os campos propostos como opcionais na preparação; confirmou que o NDA real continua pendente. Empresas: sectores do cadastro existente, receita/fluxo de caixa, moeda, motivo e disponibilidade declarada. Imóveis: modalidade, tipo existente, áreas/unidade, divisões, comodidades e disponibilidade. Reutiliza localização, rascunho, versões, submissão e revisão existentes.
Fotografias: máximo de seis activas, envio/alteração/retirada/restauro sob autorização e bloqueio do registo. Originais cifrados no disco privado; MIME, conteúdo, tamanho e quarentena no serviço existente. Declaração de edição digital conservada. Comprovativos imobiliários só podem ser lidos por validador interno com âmbito, MFA e sem conflito; dono/interessado/auditor/outro âmbito recusados. O controlo comum de binários impede atalhos; o envio não certifica titularidade.
Controlo empresarial exige aceitação do exemplar exacto do NDA e autorização expressa do vendedor para a mesma versão aprovada. Cada consulta revalida ambas, direitos e revisão de acesso. Retirada/revogação e alteração de versão fecham a consulta seguinte; nova aceitação exige nova autorização. História e auditoria preservam actores, instantes, hashes, versões e antes/depois; fundamentos cifrados. A leitura autorizada só entrega os campos empresariais conferidos, sem contactos, morada exacta ou comprovativos. NDA configurado nulo, sem texto inventado ou publicação. Fotografias originais continuam privadas.
Os cinco testes territoriais foram investigados: dependiam de Inhambane/turismo previamente importados numa base vazia. O fixture cria apenas os mínimos sintéticos quando faltam, com guarda testing/loopback/*_test:55441 e transacção de rollback. Os seis casos passaram; nenhum cadastro operacional foi alterado. Os snapshots F4 vinculam particularidades/recursos ao contrato v3; Ofertas comuns anteriores conservam v2.
Estado: implementado em QA, sem instalação, publicação efectiva, pagamentos, destaque comercial, migrações ou alterações em mne. Fase 5 não iniciada; módulo integral/Procuras parciais. Pendências: exemplar/versão/aprovação do NDA, identificação jurídica dos comprovativos admitidos, visual desktop/móvel e email real anteriores. GD ausente: derivadas do processador nativo continuam bloqueadas, sem simular processamento. Formatação global mantém 61 ficheiros exteriores ao delta. Relatório: docs/investmoz/v2/auditoria-portal/ofertas-fase4-entrega-qa-20261114.md. Recibo: outputs/offers-private-phase4-20261114/qa-result.json; contém resultados e hashes finais, sem somar execuções sobrepostas. Próximo passo: apreciação QA e disponibilização do NDA aprovado, mantendo o bloqueio de publicação.
## 14/11/2026 — Ofertas Fase 5, fecho operacional em QA
Base aceite com ressalvas e431de8b preservada, tal como 7691cab3, 91c18573, 1f2990e4 e plano6e302db6. F5 autorizada exclusivamente em QA. O dono pode retirar disponibilidade imediatamente, sem aprovação editorial ou bloqueio por etag antigo. Retirada/expiração excluem leituras públicas e fecham dados protegidos por NDA. Conteúdos, identificadores, originais e decisões não são apagados.
Reactivar exige pedido no mesmo cadastro, novo ciclo operacional e nova submissão/revisão/aprovação no motor existente; só depois o dono repõe disponibilidade privada. O snapshot v4 inclui o ciclo, preservando v2/v3 anteriores. Uma publicação antiga não reaparece e grants NDA antigos não reabrem por causa dessa reactivação. A reposição de disponibilidade não publica.
Validade independente do NDA: validity_days e validity_policy_reference inicialmente nulos, sem prazo definitivo presumido. Política congelada por hash; início na publicação da versão; configuração ou aprovação privada posterior não prolongam o exemplar público anterior. Expiração verificada em cada leitura, sem depender do agendador. API/cartão apresentam data apenas quando conhecida; CSV conserva o formato anterior e a mesma exclusão. Comando investmoz:offers-expire confere sem escrita; --confirm materializa com auditoria/idempotência. Agendamento permanece desligado.
Denúncias fundamentadas ficam cifradas com origem, actor e versão, ligadas a tarefas CRM e ao Dashboard Colaborativo existentes. A entrada Ofertas — acompanhamento usa a mesma listagem privada e filtro de denúncias pendentes. Validador no âmbito, com MFA e sem conflito, analisa e conclui com fundamento, com ou sem retirada. Etags e versão da denúncia protegem a decisão; o proprietário não recebe os dados originais do denunciante. Retomas devolvem o recibo sem outra tarefa. Planos administrativos de destaque têm versão/histórico e só rascunho/arquivo, sob Publicador autorizado; não alteram is_featured, cobram ou executam pagamentos.
Verificação: percurso novo inicialmente com 12 testes/151 asserções aprovados; regressão integrada final com 59 testes/780 asserções aprovados, com resultados no recibo outputs/offers-private-phase5-20261114/qa-result.json. Grupos sobrepostos não devem ser somados. Relatório técnico e matriz de aceitação das cinco fases: docs/investmoz/v2/auditoria-portal/ofertas-fase5-fecho-qa-20261114.md. A matriz RF-MN-003 existente foi actualizada, sem novo inventário geral.
Estado: cinco fases preparadas em QA; F1-F4 aceites tecnicamente com ressalvas, F5 para apreciação; nenhuma instalação ou publicação efectiva. Mantêm-se pendências NDA/exemplar/versão/aprovação, tipos jurídicos de comprovativos, prazo final, avisos jurídicos, visual desktop/móvel, GD/WebP e email real F2. Formatação global mantém61ficheiros preexistentes fora do delta. NDA mantém configuração nula; sem migrações, alterações em mne, dados operacionais, pagamentos ou promoção comercial. Procuras/restante portal não encerrados por este lote. Próximo passo: apreciação QA e decisões documentais; instalação/activação/publicação aguardam autorização expressa.
# Auditoria de aceitação Ofertas — 14/11/2026, antes de instalar
Referência: `../v2/auditoria-portal/ofertas-aceitacao-final-20261114.md`. Candidata `528a2597`; commits e hashes F1–F5 conferidos, JUnit aceite 59 testes/780 asserções confirmado sem o apresentar como nova execução. Nova regressão dos componentes nativos: 14/227 aprovada. Novo teste de integração: 2/11, um aprovado e uma falha na reutilização da descrição da Oferta pelo contexto de investimento; recuperação financeira privada e isolamento passaram. A aceitação integral permanece bloqueada: falta demonstrar Oferta → dossier → viabilidade em função da localização → relatório → recuperação. Publicação efectiva não implementada/activada; NDA real, visual/móvel, imagens e entrega real de email mantêm os estados específicos do relatório. Plano de instalação preparado, zero novas migrações no delta, schema do destino e reversão compatível por conferir. Nenhuma instalação ou alteração funcional efectuada nesta auditoria. RF-MN-003 actualizado sem alterar os restantes requisitos.
# Correcção Ofertas → contexto de investimento — QA, 14/11/2026
Após `ed2fd86f`, foi autorizada exclusivamente a correcção da continuidade. `InvestorContext` passa a recuperar a descrição da Oferta quando não existe resumo, mantendo a mesma Opportunity, os identificadores e a autorização privada. Resumo existente e edições deliberadas no dossier prevalecem. Não se copiam dados empresariais confidenciais, contactos ou preço privado; NDA/autorização do vendedor não concedem acesso ao contexto privado. A prova sintética percorre criação, submissão, revisão/aprovação, projecção pública isolada de teste, contexto/dossier, localização, pressupostos financeiros declarados, gravação/recuperação e relatórios HTML/Word. Não representa publicação real nem rentabilidade garantida. Recibo e resultados em `../v2/auditoria-portal/ofertas-correcao-contexto-qa-20261114.md` e `outputs/offers-investment-continuity-20261114/qa-result.json`; RF-MN-003 e matriz final actualizados. Mantêm-se pendências jurídicas, visuais, de imagens e email real. Sem instalação, activação pública, migrações ou alteração de dados operacionais; aguarda nova autorização para instalar.
# Preparação final de homologação privada das Ofertas — 14/11/2026
A Tempo aceitou tecnicamente `9a014268` e autorizou apenas preparação. Parecer em `../v2/auditoria-portal/ofertas-prontidao-homologacao-20261114.md`: integração privada em QA demonstrada, 48 hashes dos recibos F5/correcção conferidos e bundle Git local da candidata verificado. Sem diferenças de migrações/locks face ao recibo histórico `96f51096`; destino actual ainda por conferir. Homologação privada recomendável com publicação fechada, condicionada a revisão/schema/runtime reais, backup/restauro actual, delta e reversão compatível, e nova autorização expressa. GD/WebP ausente no PHP QA bloqueia derivadas, preservando originais; visual/móvel, email real e decisões jurídicas mantêm pendências próprias. Ensaio de correio preparado com dados sintéticos e caixa de teste a designar, sem fila/destinatários operacionais. Nenhuma instalação, migração, alteração operacional, envio de email ou activação pública nesta preparação. RF-MN-003 e matriz final distinguem aprovação técnica, instalação e publicação.
# Menus das Ofertas uniformizados na prévia QA — 14/11/2026
Por pedido expresso da Tempo durante a conferência, a navegação privada das Ofertas reutiliza o padrão horizontal com dropdown do portal. Área de trabalho → Ofertas agrupa consulta/preparação conforme os acessos existentes; o módulo agrupa Ofertas, estados e acompanhamento em dropdowns responsivos. Mudar de estado conserva os filtros de acompanhamento/denúncias. Todas as páginas Ofertas usam o mesmo enquadramento PortalNavigation. Não foram alteradas permissões, rotas, publicação ou dados. Passaram 29 testes de navegação, tipos, lint focado e build QA; um aviso de lint preexistente fora do delta foi preservado. A lista e os dois exemplos já existentes responderam HTTP 200 com os novos assets, mantendo a sessão e os dados da prévia. Conferência visual pelo utilizador pendente; sem instalação em homologação. Recibo: `outputs/offers-menu-qa-20261114/qa-result.json`. O bundle anterior `9a014268` permanece preservado e não inclui este novo delta visual.
# Verificação real pré-instalação das Ofertas — 14/11/2026
Por autorização específica, destino `teste-investidor.tempo.co.mz` confirmado em `96f51096`, sem instalar Ofertas ou alterar configuração/permissões/dados operacionais. Estruturas verificadas compatíveis; GD/WebP presente em CLI/FPM e codec em memória aprovado. Backup actual privado e restauro isolado passaram: 219 tabelas, 417 ficheiros, dois conjuntos de MFA; zero originais Laravel cifrados na fonte para ensaiar. Release anterior arquivada e 9.873 ficheiros reconferidos. Após a cópia, apenas uma sessão nova, com as 51 anteriores intactas; nenhuma outra tabela divergente. Parecer APTO COM CONDIÇÕES: autorização futura com SHA expresso (base `9a014268` ou candidata com menus `3558647e`), reconferência da salvaguarda e janela sem integrações concorrentes; o executor segue o último `main`. Publicação/NDA/agendamento/correio de Ofertas mantidos fechados; visual, galeria completa, email real e decisões jurídicas permanecem separados. Relatório: `../v2/auditoria-portal/ofertas-verificacao-preinstalacao-20261114.md`; recibos sanitizados em `outputs/offers-preinstall-verification-20261114`. Não houve instalação, migração operacional, alteração de `mne` ou envio real de email.
# Instalação autorizada das Ofertas — paragem de entrada, 14/11/2026
A Tempo autorizou expressamente `3558647e`, incluindo menus, mantendo fecho público. A verificação de entrada encontrou `main` e homologação em `96f51096`; o executor segue esse ramo. Em cumprimento da condição 4, a operação foi interrompida antes de qualquer push/pedido/instalação. Executor inactivo, zero pedidos; nenhuma migração, alteração operacional ou reversão. A instalação já está autorizada, mas a integração exacta e controlada de `3558647e` em `main` exige decisão após esta paragem; o fast-forward é possível sem força e pode accionar a instalação. Não reiniciar preparação/testes aceites para resolver esta diferença de ramo. Recibo: `outputs/offers-authorised-install-20261114/entry-blocked.json`.
# Instalação exacta das Ofertas — falha antes da promoção e correcção QA, 14/11/2026
O fast-forward autorizado `96f51096 → 3558647e` foi efectuado sem força ou commits posteriores. Janela com path automático pausado, lock exclusivo e chamada única ao executor existente, recusando qualquer outro SHA. Novo backup/restauro isolado 219 tabelas/417 ficheiros aprovado. O executor passou schema isolado 1/3106 e build, mas parou em tipos: colisão Wayfinder entre a acção GET `reportForm` e o formulário gerado para POST `report`. Sem promoção, migrações, alteração operacional ou reversão; homologação servida permanece `96f51096`, 219 tabelas/417 ficheiros reconferidos intactos. Lock libertado, path original restaurado, fila vazia. Correcção mínima em QA: acção GET `reportPage`, mesmo URL e corpo, com geração real/produção/tipos/PHPStan/Pint e 28 testes/403 asserções aprovados, numa base isolada separada da prévia. Nenhum novo push ou instalação. Relatório: `../v2/auditoria-portal/ofertas-instalacao-privada-incidente-wayfinder-20261114.md`; recibo `outputs/offers-exact-install-20261114/qa-result.json`. Nova revisão correctiva exige autorização expressa antes de integrar/instalar; publicação, correio, pagamentos e decisões jurídicas continuam nos limites anteriores.
@@ -1,3 +1,13 @@
## 11/11/2026 — Mineração e Energia instaladas em f2767818
Entrega autorizada de542ac2ff instalada na revisão `f27678187669d18ec063a01473dc8788c713448d`. A revisão inclui a correcção técnica dos hashes e dos geradores para usar os mesmos bytesJSON emWindows eGit/Linux,detectada antes de alterar a instalação;conteúdo,interface e âmbito preservados. Os sete casos nativos/166asserções voltaram a passar sobre os bytes canónicos. Pacote original preservado;pacote canónico com aplicação/reversão exactas. Recibo `outputs/sector-resource-20261111/installation.json`,conferido em2026-11-11T12:19:59.901666+00:00.
**Disponível:** `/sectores/mineracao` —11grupos/10áreas de cadeia de valor/6projectos de referência; `/sectores/energia-especifica` —7segmentos/11áreas/10projectos/24fontes ligadas. Localização e grupo/segmento → área → dossier novo ou existente → gravação/recuperação → evidência paraSWOT. As duas referências coexistem no mesmo dossier sem substituir escolhas. Estados,períodos,divergências e locais por definir preservados;sem criar direitos,coordenadas,ofertas ou projectos reais.
**Instalação conferida:** backup consistente e restauro isolado190tabelas;369ficheiros restaurados e conferidos porhash;22ficheiros da entrega idênticos aoGit;revisão e nove entradasHTTP200,incluindo as duas páginas sectoriais. Configuração intacta,zero migrações/importações. Nove tabelas protegidas com totais/checksums idênticos ao backup,incluindo instituições/versões/interesses,dossiers,simulações,educação,oportunidades e sectores;270instituições publicadas preservadas.
**Saldo:** implementação e instalação deste recorte encerradas;conferência visual permanece pendente. Ofertas concretas,implantação mais precisa e dados adicionais dependem do respectivo investimento,não se presumem das referências. Restantes módulos e resíduos fiscaisL1–L7,L8,L9–L16 mantêm o saldo anterior;portal integralmente concluído não declarado.
## 11/11/2026 — Energia incorporada pelo mesmo percurso de Mineração, em QA ## 11/11/2026 — Energia incorporada pelo mesmo percurso de Mineração, em QA
**Implementação:** o sector existente `/sectores/energia-especifica` passa a apresentar o documento fornecido com7segmentos,11áreas de cadeia de valor,10projectos de referência,4riscos e24fontes com as ligações originais. Gás/upstream/construção/midstream/industrialização/mercado final,redes/hídrica,solar/baterias/eólica,hidrogénio,biocombustíveis e parques verdes mantêm o seu contexto. As três centrais solares aparecem uma vez cada,com capacidade,promotores,financiamento e estado referidos. Metoro conserva56milhõesUSD e47,3milhõesEUR,atribuídos às fontes,sem conversão ou escolha silenciosa. Previsão,operação,desenvolvimento,estudo e proposta não são tratados como estados equivalentes. **Implementação:** o sector existente `/sectores/energia-especifica` passa a apresentar o documento fornecido com7segmentos,11áreas de cadeia de valor,10projectos de referência,4riscos e24fontes com as ligações originais. Gás/upstream/construção/midstream/industrialização/mercado final,redes/hídrica,solar/baterias/eólica,hidrogénio,biocombustíveis e parques verdes mantêm o seu contexto. As três centrais solares aparecem uma vez cada,com capacidade,promotores,financiamento e estado referidos. Metoro conserva56milhõesUSD e47,3milhõesEUR,atribuídos às fontes,sem conversão ou escolha silenciosa. Previsão,operação,desenvolvimento,estudo e proposta não são tratados como estados equivalentes.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,70 @@
# Ofertas — auditoria final antes da instalação
14/11/2026. Candidata auditada: `528a2597`. Auditoria exclusiva em QA, por instrução da Tempo. Nenhuma instalação, activação pública, migração operacional ou alteração funcional realizada nesta passagem.
**Decisão: aprovação técnica parcial com ressalvas; aceitação integral bloqueada.** As provas das cinco fases são válidas para os comportamentos que demonstram. Não demonstram uma publicação operacional nem a continuidade completa Oferta → dossier → viabilidade territorial. Foi reproduzida uma perda de reutilização da descrição nessa fronteira.
**Actualização posterior à auditoria `ed2fd86f`:** a correcção autorizada em QA está documentada em [Ofertas → Investimento](ofertas-correcao-contexto-qa-20261114.md). O saldo actualizado nas linhas abaixo substitui apenas o estado desses critérios; conserva-se a prova original da falha. O percurso controlado com hipóteses de localização e relatório foi demonstrado; publicação operacional e restantes dependências não foram activadas.
**Preparação final após aceitação técnica expressa de `9a014268`:** [parecer de homologação privada](ofertas-prontidao-homologacao-20261114.md). Integridade das fases/correcção reconfirmada por 48 hashes; bundle local verificado. Aprovação técnica em QA aceite; instalação ainda não autorizada/executada, condicionada à conferência real do destino e backup/restauro actual. Publicação operacional permanece bloqueada. Visual, GD/derivadas e email real são critérios de avaliação pendentes, sem invalidar a prova integrada privada aceite.
**Destino e salvaguarda posteriormente verificados por autorização específica:** [verificação pré-instalação](ofertas-verificacao-preinstalacao-20261114.md), **APTO COM CONDIÇÕES**. Schema/runtime compatíveis, GD/WebP do destino disponível, restauro isolado de 219 tabelas/417 ficheiros aprovado. Instalação e publicação continuam não autorizadas; a instalação exige SHA expresso e janela sem integrações concorrentes porque o executor segue o último `main`. Menus `3558647e` constituem delta QA posterior à candidata `9a014268`.
## Evidência e limites
- Confirmada a ancestralidade dos commits F1 `1f2990e4`, plano `6e302db6`, F2 `91c18573`, F3 `7691cab3`, F4 `e431de8b` e F5 `528a2597`.
- Conferidos todos os hashes de código e provas do recibo F5, antes das alterações documentais desta auditoria: todos correspondem. O JUnit original confirma **59 testes, 780 asserções, zero erros/falhas**. Não se apresenta esta conferência como nova execução dos 59 testes.
- Nova execução de `OpportunityManagementTest`, `OpportunitySearchTest` e `OpportunityResourcesTest`: **14 testes / 227 asserções aprovados**, na candidata, para compatibilidade com cadastro nativo, versões públicas, pesquisa/lista/mapa/exportação, geografia e recursos.
- Novo `OfferInvestmentAcceptanceAuditTest`: **2 testes / 11 asserções; 1 aprovado e 1 falhado**. Recuperação financeira privada e isolamento na selecção de contexto passaram; reutilização da descrição falhou. A primeira execução revelou a chave ausente; a segunda tornou a observação numa asserção explícita, sem alterar o produto.
- Base exclusiva `offers_phase1_20261114_test`, loopback:55441, utilizador de testes existente e dados sintéticos; guardas do repositório preservadas. Não se executaram suites fixadas noutra base nem se enfraqueceram as respectivas guardas.
- Recibo: `outputs/offers-final-acceptance-20261114/receipt-verification.json`; execuções adicionais: `native-integration.xml/log` e `investment-acceptance.xml/log`, na mesma pasta. Não somar as asserções das várias passagens como medida de conclusão funcional.
- A compilação, PHPStan, tipos e lint focado aceites são reutilizados: não se alterou código funcional. Os 61 ficheiros com formatação preexistente foram novamente comparados por caminho e conteúdo Git: todos permanecem inalterados e fora do delta das cinco fases. Prova adicional em `matrix-and-format-verification.json`; isto não constitui aprovação de formatação global. O novo teste passou na verificação sintáctica PHP e no Pint; `git diff --check` passou.
## Matriz final consolidada
| Percurso / requisito | Estado | Evidência e limite concreto |
| --- | --- | --- |
| F1: criação, gravação, recuperação, edição e submissão privada no cadastro existente | aprovado com ressalvas | Suite F1, 14/146; mesma identidade e etags. Conferência visual pendente. |
| F2: submissão, revisão, rejeição fundamentada, correcção, reenvio e aprovação independente | aprovado com ressalvas | Suite F2, 11/161; decisões por snapshot, conflitos, tarefas, auditoria, autor/contribuidor sem auto-aprovação. Email real e visual pendentes. |
| Separação aprovação/publicação e bloqueios dos percursos alternativos | aprovado | Testes F2 e barreira do modelo/consumidores; aprovação privada não publica. |
| F3: filtros, listagem, detalhe, API, mapa e CSV | aprovado com ressalvas | Suite F3, 10/133, projecções exclusivamente sintéticas; moeda sem conversão, coordenadas administrativas. Limites 1.000 candidatos/30 territórios e visual por conferir. |
| Publicação controlada operacional | bloqueado | Não existe publicador efectivo das Ofertas. `OfferPublicCatalogue` só aceita projecções de teste por injecção interna em base isolada. Não é uma configuração pública pronta a ligar. |
| F4: campos de empresas/imóveis, recuperação e vínculo à versão | aprovado com ressalvas | Suite F4, 11/160; campos opcionais confirmados. Não certifica titularidade nem demonstra cálculo de rentabilidade. |
| Seis fotografias, ficheiros, quarentena e comprovativos internos | aprovado com ressalvas | Upload/limites/autorização/scan/cifra testados; processamento e galeria com derivadas não demonstrados por ausência de GD/WebP no QA. |
| NDA + autorização do vendedor + revogação + mudança de versão | aprovado com ressalvas | Mecanismo técnico testado com documentos sintéticos e revalidação em cada leitura. NDA aprovado real ausente; configuração nula mantém fechado. |
| F5: retirada imediata, expiração, revalidação e reactivações | aprovado com ressalvas | Suite F5, 13/180; fronteira temporal, idempotência, ciclo novo e grants antigos recusados. Prazo definitivo ainda por decidir; não se inventou duração. |
| Denúncias e tratamento no Dashboard, auditoria e destaques administrativos | aprovado com ressalvas | F5 cobre isolamento, fundamento cifrado, tarefas existentes, versões e responsável. Destaque só rascunho/arquivo, sem promoção/pagamentos. Visual pendente. |
| Isolamento de utilizadores, páginas, APIs, anexos, mapas e CSV | aprovado com ressalvas | Suites F1–F5 e regressão nativa; leitura pública limitada à projecção, comprovativos internos, sem originais externos. Não equivale a teste de intrusão independente nem validação visual. |
| Preservação da versão pública durante edição | aprovado com ressalvas | Contrato sintético de Ofertas e nova regressão nativa aprovados; não existe ainda versão pública operacional de Ofertas para demonstrar o percurso real. |
| Escrever uma vez: recuperar a descrição no contexto de investimento | aprovado | Correcção QA específica da transformação; descrição recuperada, resumo existente e alterações deliberadas preservados, sem truncar a origem nem duplicar registos. Prova e recibo no relatório de correcção. |
| Contexto territorial e hipóteses económicas autorizadas até à viabilidade | aprovado com ressalvas | Localização persistida até ao rascunho, custos de renda/transporte declarados pelo investidor, cálculo e comparação sensível aos custos. Valores empresariais confidenciais não são transferidos; não constitui conclusão automática sobre rentabilidade nem valida todos os cenários fiscais. |
| Percurso controlado QA Oferta → mesmo dossier → simulação → relatório → recuperação | aprovado com ressalvas | Criação/submissão/revisão/aprovação reais no teste, projecção pública sintética isolada, referência privada autorizada, rascunho/resultados no dossier e exportadores HTML/Word existentes. Abertura pública operacional continua bloqueada e visual pendente. |
| Restantes oportunidades, pesquisa e recursos afectados | aprovado com ressalvas | Nova regressão 14/227 passou. Não constitui declaração de ausência de defeitos em todo o portal ou em serviços externos não ensaiados. |
| Desktop/móvel e acessibilidade funcional | não verificado | Última evidência do conector: `The browser access host is unavailable`; não foi repetido o ciclo de reinícios. HTML compilado não substitui conferência visual. |
| Entrega real de notificações por email | não verificado | Serviço existente e permissões foram testados com transporte de teste; SMTP/fila/recepção real não ensaiados. Não foram enviados emails operacionais. |
## Bloqueantes e ressalvas para decisão
1. **Integração funcional — corrigida em QA na passagem autorizada posterior:** o teste da descrição e a prova controlada contexto/dossier/viabilidade/relatório/recuperação passaram. A projecção limita-se a campos comuns autorizados; dados económicos confidenciais não são transferidos. Moeda e custos do investimento são hipóteses explícitas, sem confundir preço de aquisição com investimento total ou dados históricos com previsão. Qualquer novo contrato de transferência confidencial continua fora desta correcção.
2. **Publicação:** implementar e testar um publicador controlado, se autorizado em nova instrução. A aceitação do consumidor sintético não autoriza remover as barreiras existentes. Instalação privada de avaliação e abertura pública são decisões separadas.
3. **Jurídico:** faltam exemplar/versão/referência de aprovação do NDA, tipos admissíveis de comprovativo imobiliário, política final de validade e condições/avisos aplicáveis à divulgação. Não são motivos para invalidar as provas técnicas já aceites; bloqueiam os comportamentos dependentes.
4. **Imagens:** comprovar processamento seguro e derivadas numa instalação compatível, incluindo scan, metadados, acesso e limite de seis. Não instalar extensões nem declarar galeria pronta nesta auditoria.
5. **Visual e correio:** realizar conferência real desktop/móvel e entrega de email autorizada em ambiente de teste. Permanecem não verificados, sem solicitar que o utilizador repita autenticações nesta passagem.
6. **Reversão:** um retorno cego à F4 é inseguro como plano: a versão F4 de `PrivateOfferConfidentiality::approvedHash` não verifica `OfferAvailability`, enquanto a F5 o faz. Uma retirada de ciclo zero pode conservar o mesmo hash editorial. A reversão deve preservar a recusa de leitura de ofertas retiradas/expiradas e os históricos; não assumir que repor código antigo mantém as novas protecções.
Na auditoria original não foram corrigidas lacunas por estar autorizada exclusivamente a auditoria. A falha histórica foi conservada; a autorização posterior permitiu a correcção delimitada agora comprovada, sem alterar os restantes bloqueios.
## Plano de instalação controlada — não executado
1. **Antes de executar:** a Tempo aceitou tecnicamente a correcção `9a014268`. Para avaliação estritamente privada, existe parecer favorável condicionado e bundle local verificado; conservar NDA nulo, publicação indisponível, prazo pendente e agendamento desactivado. Conferir o destino, delta, backup/restauro e compatibilidade da reversão; obter autorização expressa para instalar. Não repetir provas aceites sem diferença que o justifique. Isto não constitui aceitação de publicação integral.
2. **Destino e processo:** confirmar `teste-investidor.tempo.co.mz`, revisão efectivamente instalada, ramo, árvore limpa, recibo do executor Git existente e diferença exacta desde o activo. Não usar o destino antigo do guia genérico como evidência do destino actual. Não inventar comandos/identidade de executor nem retomar uma sessão SSH histórica. Esta auditoria não consultou o servidor.
3. **Estrutura:** o delta completo `1f2990e4^..528a2597` contém **zero ficheiros de migração**. As fases reutilizam Opportunity/JSON, recursos/documentos, perfis, editorial, tarefas e auditoria. Antes de instalar, comparar em leitura o schema e o registo de migrações do destino com estas dependências. Ausência de nova migração no pacote não prova que o destino já possui todas as estruturas; qualquer falta exige plano aditivo concreto e autorização. Não executar `migrate:fresh`, seeders ou alterações a `mne`.
4. **Cópia e ensaio:** backup consistente da base, ficheiros privados, configuração e chaves existentes sob protecção; registar revisão e manifestos sem segredos. Ensaiar restauro isolado, integridade de anexos cifrados e leitura de histórico, comparando contagens/hash e identificadores com o backup. Preservar integralmente instituições, demografia, dossiers e integrações; não presumir contagens actuais a partir de recibos antigos.
5. **Instalação após autorização expressa:** executar apenas o manifesto revisto pelo processo Git existente; compilar com versões fixadas, preservar segredos/configuração, rever caches/workers segundo o executor aceite. Sem publicação automática, concessões de perfis, pagamento, destaque comercial ou activação do agendamento. Não copiar exemplares NDA sintéticos para configuração operacional.
6. **Verificações posteriores:** conferir SHA/schema/delta, prontidão e logs sem segredos; acessos válidos e inválidos, recuperação de oferta e versões, ausência no catálogo nativo/público, ficheiros reservados, denúncias e tarefas, regressão dos destinos afectados. Testes de escrita apenas em contexto sintético autorizado e identificado; não submeter processos reais. Conferência desktop/móvel e, se autorizado, email de teste com destinatário controlado. Registar quem verificou, versão e resultado.
7. **Reversão:** guardar revisão anterior e artefactos, mas condicionar reversão de código à compatibilidade com v2/v3/v4 e novas decisões operacionais. Usar uma revisão de recuperação que conserve as guardas F5; se indisponível, não abrir os percursos afectados. Não remover JSON, tarefas, denúncias, decisões ou grants para acomodar código antigo. Não restaurar indiscriminadamente a base sobre novos registos; reconciliação/restauro de dados exige decisão própria e preservação do histórico.
8. **Aprovação separada:** apresentar resultado e recibo pós-instalação. Qualquer futura publicação efectiva requer desenvolvimento/provas e autorização próprios. Este plano não é autorização para instalar nem para activar.
## Resultado e próxima acção
Auditoria original concluída com achados; a continuidade Oferta → contexto/dossier e a prova controlada de viabilidade/relatório foram corrigidas em QA por autorização posterior. **O módulo mantém ressalvas e bloqueios de publicação operacional, jurídicos e verificações pendentes.** Próxima acção: apreciar o recibo de correcção e as dependências restantes antes de qualquer autorização de homologação. Nenhuma instalação efectuada.
@@ -0,0 +1,72 @@
# Ofertas → Investimento — correcção da continuidade em QA
14/11/2026. Base: auditoria `ed2fd86f`, preservando F1–F5 até `528a2597`. Autorização exclusiva para investigar/corrigir a continuidade e demonstrá-la em QA. Sem instalação, publicação efectiva, alterações operacionais, migrações ou intervenção em `mne`.
## Causa e correcção
A reprodução antes da alteração confirmou o resultado anterior: dois testes, um aprovado e um falhado; o contexto não tinha `project_summary`. A descrição estava correctamente persistida em `Opportunity.description`, no mesmo identificador. A autorização já permitia ao dono consultar a Oferta. A perda ocorria na **transformação/recuperação**: `InvestorContext::reference('opportunity:…')` só projectava `canonical.executive_summary` ou `summary`, enquanto a preparação privada das Ofertas escreve `description`.
O adaptador existente passa a usar a descrição guardada como alternativa para `project_summary`, **apenas para Ofertas privadas já autorizadas**, conservando qualquer resumo existente não vazio. O título vem dos valores correntes da mesma preparação e a referência aponta para a rota específica da Oferta. Não se criam registos, fontes, relações ou contratos paralelos. Não se trunca a descrição, não se grava uma cópia na Oportunidade e não se altera a origem ao confirmar dados no dossier.
O reconciliador existente continua a apresentar prévia e confirmação, com versões, hash, proveniência e preservação de edições deliberadas. O teste integrado confirma que um resumo deliberadamente alterado no dossier não é substituído numa nova prévia da mesma Oferta.
## Campos e confidencialidade
| Informação | Continuidade autorizada |
| --- | --- |
| Identificador/chave da Oportunidade, versão, hash, título e referência | Contrato existente `opportunity:<id>`; mesmo cadastro e dossier, sem duplicação. |
| Descrição | Alternativa para resumo apenas no contexto privado autorizado; resumo existente prevalece. Descrição longa preservada. Não é uma autorização de divulgação pública. |
| Referências administrativas e sector comum já cadastrado | Contratos existentes preservados; a localização do ensaio mantém o mesmo identificador até ao contexto e ao rascunho financeiro. Não se deduz cobertura, disponibilidade ou benefício fiscal. |
| Moeda, custos e pressupostos do investimento | Declarações explícitas do investidor no percurso existente. A moeda/preço da Oferta não se convertem automaticamente em moeda/investimento total do projecto. |
| `private_offer.business`, receitas, fluxo de caixa, preço privado, contacto, razões e comprovativos | Não são copiados pelo adaptador para o contexto, relatórios ou exportações. Os campos de sector guardados dentro da informação empresarial confidencial também não são promovidos ao sector comum por inferência. |
| Informação pública sintética | Apenas projecção pública separada e expressamente construída no teste. A descrição privada e os valores empresariais não passam para essa projecção. |
| Leitura por NDA e autorização do vendedor | Mantém o âmbito próprio: não concede acesso à referência privada do contexto. Novo teste confirma recusa 404 mesmo depois das duas condições satisfeitas; revogação e demais testes F4 permanecem válidos. |
Não se introduziu um novo percurso de transferência de dados confidenciais. Qualquer futura reutilização desses dados precisa de autorização e contrato próprios; esta correcção não alarga direitos de consulta ou exportação.
## Prova integrada reproduzível
`OfferInvestmentAcceptanceAuditTest` usa exclusivamente `offers_phase1_20261114_test`, loopback:55441, com guardas e rollback existentes. Cria Oferta de empresa e referência provincial sintéticas, grava/recupera, associa contacto autorizado, submete, efectua revisão/aprovação por outra conta e conserva `status=draft`/`published_at=null`.
O mesmo teste comprova catálogo normal vazio e injecta `OfferPublicCatalogue(true)` exclusivamente no contentor do teste, sujeito à guarda real da base isolada. A projecção aprovada/publicada sintética tem resumo público próprio; não se altera qualquer interruptor de publicação operacional. A referência privada continua sujeita a `PortalProfileAccess`.
O dono selecciona a mesma Oportunidade num dossier, confere a prévia e confirma. Descrição e referência provincial chegam ao prefill existente do simulador. O investidor declara moeda e pressupostos de renda/transporte para esse local; guarda o rascunho financeiro, recupera-o e devolve os resultados com confirmação da revisão do contexto. Uma nova autenticação da mesma conta recupera o mesmo dossier e rascunho; outra conta é recusada. O teste confirma uma Oportunidade e um dossier, sem duplicações.
O teste frontend consome o JSON produzido pela prova PHP, recupera o snapshot e executa os calculadores existentes: receita mensal sintética 10.000, renda 2.000 e transporte 1.000 produzem saldo operacional estimado de 7.000 MZN; custos alternativos expressamente declarados de 5.000 e 3.000 produzem 2.000 MZN. Custos fixos 3.000, preço unitário 100 e custo variável 50 dão ponto de equilíbrio de 60 unidades. Estas hipóteses demonstram sensibilidade aos custos do local; **não atribuem uma tarifa provincial, uma margem real da empresa ou rentabilidade garantida**. Receita histórica confidencial da Oferta não é usada como previsão.
O mesmo teste gera o relatório privado HTML e Word pelo exportador existente, reabre o Word e verifica descrição/localização, recuperação do snapshot e ausência dos valores confidenciais e contacto. Os artefactos são sintéticos, não documentos de negócio nem uma aceitação visual.
Durante a preparação da prova, a referência provincial inicial não tinha a proveniência exigida pelo catálogo público; foi corrigida apenas a fixture isolada, reutilizando o contrato de `OpportunitySearchFixture`. O pedido do simulador foi ajustado à lista de campos já aceite e à confirmação da revisão de contexto. Nenhuma guarda foi desactivada para passar o ensaio.
## Ficheiros e verificação
- Produto: `app/Services/InvestorContext.php`, projecção delimitada de descrição/título/referência.
- Testes PHP: `OfferInvestmentAcceptanceAuditTest.php`, persistência, resumo existente, descrição longa, dossier/rascunho/resultados e isolamento; `PrivateOfferConfidentialityTest.php`, acesso por NDA sem concessão de acesso ao contexto privado.
- Teste frontend: `tests/frontend/offer-investment-continuity.test.mjs`, cálculo com pressupostos territoriais declarados e relatório real com reabertura do Word.
- Documentação: matriz RF-MN-003, manual e matriz final de aceitação existentes; este recibo explica a correcção sem apagar a prova histórica da falha.
Resultados e comandos finais: `outputs/offers-investment-continuity-20261114/qa-result.json`. Não somar passagens sobrepostas nem apresentar os 59/780 históricos como executados novamente nesta correcção.
| Verificação final | Resultado |
| --- | --- |
| Continuidade Oferta/contexto e percurso integrado | 4 testes / 62 asserções aprovados |
| Confidencialidade, incluindo NDA sem acesso ao contexto privado | 11 / 161 aprovados |
| Percursos existentes de investimento/dossiers | 4 / 192 aprovados |
| Gestão nativa de Oportunidades e versão pública | 3 / 46 aprovados |
| **Passagem PHP integrada** | **22 testes / 461 asserções; zero erros/falhas** |
| Regressão frontend de plano financeiro e relatórios | 41 testes aprovados |
| Prova frontend ligada ao JSON da passagem PHP final | 1 teste aprovado; HTML e Word gerados/reabertos |
| PHPStan completo, TypeScript e Pint | Aprovados; zero erros PHPStan |
| Lint/formatação do novo teste frontend; diferenças Git | Aprovados |
Comando PHP final: `php vendor/bin/phpunit tests/Feature/OfferInvestmentAcceptanceAuditTest.php tests/Feature/PrivateOfferConfidentialityTest.php tests/Feature/InvestorPathTest.php tests/Feature/OpportunityManagementTest.php --log-junit outputs/offers-investment-continuity-20261114/regression.xml`, com a configuração sintética/guardada acima. Frontend: `node --test tests/frontend/investor-dossier-report.test.mjs tests/frontend/business-financial-plan.test.mjs`, seguido de `node --test tests/frontend/offer-investment-continuity.test.mjs`. Análise: `php vendor/bin/phpstan analyse --no-progress --memory-limit=1G`, `npm.cmd run types:check`, Pint nos três ficheiros PHP e `vp fmt --check`/`vp lint` no novo teste. A compilação QA anterior é reutilizada porque não mudou código frontend do produto; os testes PHP preservaram os assets existentes.
## Saldo para aceitação
**Corrigido em QA:** descrição na fronteira Oferta/contexto, preservação de resumo e edições deliberadas, recuperação no mesmo dossier, ensaio de localização/pressupostos → cálculo → relatórios HTML/Word → recuperação, sem passagem de dados financeiros confidenciais.
**Mantém-se bloqueado:** publicação efectiva operacional; NDA real enquanto faltar o exemplar aprovado; decisões jurídicas/prazo definitivos identificados na auditoria. A injecção de projecção sintética não substitui esse fecho.
**Por verificar:** conferência visual desktop/móvel, processamento de derivadas de imagens/galeria no ambiente compatível e entrega real de email. A simulação demonstrada é uma análise controlada com hipóteses declaradas, não uma prova de todos os cenários fiscais e factores de rentabilidade do portal.
Nenhuma instalação efectuada ou autorizada por este recibo. A eventual instalação aguarda nova autorização expressa, com o plano de cópia/restauro e reversão compatível já documentado.
@@ -0,0 +1,168 @@
# InvesteMoz — encerramento técnico da Fase 1 e plano da Fase 2
Data: 14/11/2026. Referência funcional: RF-MN-003 da matriz existente.
**Revisão examinada: `1f2990e4`. Fase 1 verificada tecnicamente, com ressalva de conferência visual não realizada. Fase 2 apenas planeada, não autorizada para implementação. Nenhuma instalação em homologação ou produção.**
## 1. Resultado do encerramento técnico
A implementação recebida mantém Oportunidades como cadastro principal. Criar, guardar, recuperar, editar e submeter são operações privadas, com os perfis, contactos, localização, versões e auditoria existentes. A submissão não abre automaticamente revisão, aprovação ou publicação. Não foram encontradas alterações posteriores nos 16 ficheiros identificados pelos hashes do recibo, incluindo código, testes e documentação da entrega. Os 17 ficheiros do commit incluem também o próprio recibo.
O encerramento técnico não constitui aceite visual, aprovação da verificação global de formatação ou autorização de instalação. O módulo Ofertas integral e RF-MN-003, que também abrange Procuras, não ficam encerrados por esta fase.
### Evidências confirmadas
| Evidência | Resultado e limite |
| --- | --- |
| `outputs/offers-private-phase1-20261114/qa-result.json` | Recibo recebido conferido; 16 hashes correspondem aos ficheiros examinados |
| Regressão recebida | 35 testes / 548 asserções aprovados, segundo o recibo; prova anterior reutilizada, sem repetir a bateria nesta passagem |
| `tests-final.log` | Resultado bruto confere com o recibo: 16 testes / 172 asserções aprovados |
| Portabilidade do HTML compilado | Recibo regista 1 teste / 10 asserções aprovado; utiliza o directório do executor e `build` por defeito |
| Pint, PHPStan, tipos, lint focado e build QA | Aprovações do recibo reutilizadas, pois os ficheiros e dependências da candidata não mudaram |
| Verificação global de formatação | Executada novamente nesta passagem: 61 ficheiros; todos exteriores ao delta e com o mesmo blob Git da revisão anterior |
| Preservação | Nenhuma alteração de código, testes, configurações, permissões ou base de dados nesta passagem |
Os grupos de testes sobrepõem-se: não somar os totais nem tratar o número de testes como funcionalidades concluídas. A bateria canónica extensa interrompida na implementação anterior não é considerada aprovada. O teste antigo de divulgação vinculado a outra base QA conserva a sua guarda; essa base não foi alterada para repetir a prova.
### Cobertura de autorização e isolamento
| Comportamento | Evidência existente |
| --- | --- |
| Anónimo, email não verificado e conta sem atribuição não criam ofertas | `test_other_accounts_ungranted_unverified_and_revoked_actors_cannot_operate` |
| Outro promotor individual não lê, edita ou submete; a listagem não revela o registo | Mesmo teste, com pedidos HTTP aos três percursos e listagem vazia |
| Atribuição expirada deixa de autorizar acesso | Mesmo teste; a preparação específica usa expiração, não simula revogação administrativa |
| Revogação administrativa invalida a sessão; gestor legado não contorna o âmbito | `PortalProfileAccessTest::test_revocation_invalidates_existing_session_and_legacy_manager_cannot_bypass_scope`, na regressão recebida |
| Organização não pode ser trocada e perda da representação impede acesso/edição | `test_organization_representation_is_rechecked_and_other_organizations_are_not_assignable` |
| Categoria não atribuída e campos de proprietário/estado/contexto forjados são recusados | `test_contact_scope_and_malicious_fields_roll_back_without_parallel_records` |
| Contacto de outro titular é recusado; falha reverte a criação | Mesmo teste; sem oportunidade residual |
| Gravar contacto exige MFA; contacto eliminado impede a submissão | `test_existing_contacts_registration_still_requires_fresh_mfa_and_submission_rechecks_contact` |
| Edição concorrente não substitui dados; repetição da criação conserva a identidade | `test_private_creation_recovery_edit_conflict_and_idempotency_use_one_opportunity` |
| Submissão exige confirmação e versão actual; não cria edição ou notificação editorial | `test_submit_is_private_exact_version_confirmed_and_recoverable_without_editorial_actions` |
| Rotas nativas não permitem editar/divulgar/publicar a oferta; detalhe e pesquisa públicos não a mostram | `test_no_public_exposure_and_native_routes_cannot_edit_or_publish_offer` |
| Modelo impede publicar ou retirar a marca privada; filtro público exclui estado incoerente introduzido directamente em QA | Mesmo teste e `test_model_cannot_publish_or_remove_private_phase_marker` |
| Oportunidades anteriores não são convertidas pelo adaptador; âmbito nativo de divulgação mantém-se | `test_existing_opportunity_cannot_be_converted_through_offer_adapter` |
| HTML privado usa entradas compiladas reais e resposta `private, no-store` | `test_full_html_uses_real_compiled_private_page_entries` e teste de recuperação |
São provas com actores e dados sintéticos na base isolada `offers_phase1_20261114_test:55441`. Não comprovam autenticação real observada num navegador, configuração de entrega de email, revisão humana de conteúdo ou funcionamento de NDA. Esses resultados não são alegados.
### Conferência visual e incidente da ferramenta
Foi tentada a observação, preservando as sessões existentes. O inventário da ferramenta identifica Edge e Firefox como aplicações, mas devolve `browsers: []` e o erro exacto `Browsers: Error: The browser access host is unavailable`. Não foi possível identificar uma aba nem ler o seu URL pelo conector.
Foi também tentado o acesso nativo de Windows através de Computer Use. A enumeração de aplicações respondeu, mas a captura da janela Edge falhou: `encode latest capture frame failed: copy Direct3D surface to SoftwareBitmap failed: A instância de dispositivo GPU foi suspensa. Utilize GetDeviceRemovedReason para determinar a ação apropriada. (0x887A0005)`.
Não foram repetidos reinícios, alteradas configurações ou desactivadas verificações. Não se observou a candidata e não se atribui a causa ao Edge ou ao portal. A conferência visual continua pendente por impedimento da ferramenta. Quando o acesso estiver disponível, conferir apenas a candidata: lista/criação, recuperação, preço «A negociar»/montante, contacto, estado após submissão, erros preservando os campos e apresentação estreita, sem voltar a executar a bateria técnica aceite.
### Os 61 ficheiros de formatação
`outputs/offers-private-phase1-20261114/closure-confirmation.json` contém a lista completa, o blob Git de cada ficheiro e a comparação individual. Resultado: **61/61 fora do delta; 61/61 idênticos à revisão base `e8408c27`; intersecção com a entrega vazia**. Os ficheiros de dependências também não integram o delta.
Não foram corrigidos ou reformatados nesta instrução. A falha global mantém-se registada, sem ser atribuída à Fase 1 nem declarada dispensada. Uma regularização desses ficheiros seria uma alteração separada; não é incluída por conveniência neste plano.
## 2. Plano técnico proposto para a Fase 2 — aguarda autorização
### Resultado e fronteira
Permitir que uma oferta submetida entre nas filas do Dashboard Colaborativo existente, seja revista, devolvida ou rejeitada com fundamento, corrigida e reenviada, ou aprovada internamente na sua versão exacta. Conservar decisões, responsáveis, tarefas, notificações e histórico. **A Fase 2 proposta termina na aprovação privada; a publicação pública permanece bloqueada para Ofertas.** A aprovação não certifica o negócio, a titularidade, o financiamento ou uma validação jurídica que não tenha ocorrido.
Sem novo dashboard, cadastro, autenticação ou motor editorial. Sem alterar negócios existentes, `mne`, menu principal ou regras dos perfis completos de Oportunidades. Não inclui catálogo público, fotografias, NDA, comprovativos de propriedade, validade, destaque comercial ou SMS novo. As decisões desses ramos não bloqueiam a revisão dos cinco campos comuns em privado.
### Arquitectura e persistência
1. Manter `opportunities.id`, `owner_user_id`, `organization_id`, `management_version`, `management_draft`, a marca `workflow_context.private_offer_preparation` e o histórico da Fase 1. A marca permanece e o filtro público continua a excluí-la.
2. Acrescentar um adaptador delimitado ao percurso privado para fornecer validação comum, snapshot, hash e estado ao workflow nativo de Oportunidades. Reutilizar `OpportunityProfileWorkflow`, `PortalProfileAccess`, `OpportunityWorkflowTasks` e a integração de oportunidades em `PortalEditorialWorkflow`; não duplicar as transições num segundo motor.
3. Não encaminhar simplesmente o formulário reduzido para `OpportunityCanonicalContract::validateSubmission`: esse contrato exige os perfis completos, evidências e verificações especializadas. Não fabricar esses campos nem enfraquecer as validações dos restantes investimentos. O adaptador distingue explicitamente a preparação comercial comum da validação integral do projecto.
4. Separar estado editorial privado e disponibilidade pública. `working_state` pode representar a revisão; `status` mantém a barreira privada da oferta e `published_at` permanece nulo. O workflow existente actualmente escreve ambos: a implementação terá de delimitar esse comportamento às Ofertas, preservando-o nos restantes registos.
5. Evitar estados divergentes entre `private_offer_preparation.state` e `working_state`: definir uma projecção única de estado para apresentação, autorização e filas, com compatibilidade de leitura da Fase 1. Não converter todos os registos por uma escrita em lote nem criar tarefas ao abrir uma página. A vinculação inicial de uma submissão anterior deve ser explícita, idempotente e auditada, conservando autor, data e revisão originais.
6. Usar os registos existentes de `tasks`, `audit_events` e `portal_editorial_notifications`. Esta última já tem referência a oportunidade, versão e índice único por utilizador/oportunidade/versão/acção. Não se prevê uma nova tabela ou migração para o núcleo proposto. Se uma necessidade concreta de persistência não couber nos contratos existentes, apresentar o delta aditivo antes de o executar; não instalar estrutura por esta instrução.
### Estados e operações propostas
| Origem | Operação e responsável | Destino e garantia |
| --- | --- | --- |
| Rascunho | Promotor submete a versão conferida | Submetida; snapshot e tarefa de revisão vinculados à versão/ciclo |
| Submetida | Validador autorizado inicia/recebe revisão | Em revisão; responsável explícito, sem atribuição inventada |
| Submetida ou em revisão | Validador pede correcção, com fundamento e campos a rever | Correcções solicitadas; edição permitida apenas ao promotor autorizado |
| Submetida ou em revisão | Validador rejeita, com fundamento obrigatório | Rejeitada; decisão e versão conservadas |
| Correcções solicitadas ou rejeitada | Promotor escolhe «Corrigir e reenviar» | Nova preparação privada ligada à anterior; a rejeição não é apagada nem revertida em aprovação |
| Nova preparação corrigida | Promotor guarda e submete | Novo ciclo de revisão; sem duplicação de oportunidade, tarefa ou notificação por repetição do pedido |
| Em revisão | Validador independente aprova o snapshot actual | Aprovada internamente; nenhuma exposição pública |
| Aprovada internamente | Promotor prepara alteração | Nova revisão privada; aprovação anterior histórica, sem aplicação à nova versão |
| Qualquer estado de Ofertas nesta fase | Qualquer tentativa de publicar ou divulgar | Recusada no servidor, mesmo por Publicador ou através de rotas alternativas |
Reutilizar a regra nativa de fundamento de decisões (`reason`, 20–1000 caracteres), sem substituir fundamentação por um checkbox. Campos de correcção são referências aos dados da oferta, não exigências de um perfil especializado ainda por aprovar. A proposta de retoma após rejeição é própria das Ofertas; não alterar o recurso administrativo dos outros percursos.
### Autorizações e independência
| Actor existente | Âmbito proposto |
| --- | --- |
| Promotor | Criar/editar nos estados permitidos, submeter e preparar correcção, dentro das categorias e representação efectivas; nunca aprovar a sua oferta |
| Validador | Consultar/rever/devolver/rejeitar/aprovar dentro da atribuição actual; decisões com MFA recente e verificação de conflitos |
| Publicador | Não recebe aprovação por inferência e não publica Ofertas nesta fase; futura publicação continua operação e actor distintos da aprovação |
| Auditor | Consulta autorizada e histórico nos limites existentes; sem decisões operacionais |
| Gestor/Superutilizador | Conserva os controlos existentes; a condição administrativa não concede automaticamente acesso operacional, âmbito ou dispensa de independência |
Rever autorização no servidor em cada pedido, API, pré-visualização, tarefa e ligação de notificação; repetir dentro da transacção antes da decisão. Autor, contribuidores e representantes com conflito não aprovam a mesma oferta, mesmo acumulando papéis. Não estender a designação jurídica da legislação a Ofertas. Sem designação jurídica expressa e texto aprovado não se regista «validação jurídica» fictícia.
### Versões, auditoria e concorrência
Cada submissão congela um snapshot dos campos comuns, preço/modo/moeda, contacto referenciado, categoria, promotor e localização, com versão do contrato, revisão e hash determinístico. As referências de contacto e localização devem identificar também a versão/hash dos dados efectivamente conferidos; uma alteração posterior na fonte sinaliza reconferência, sem substituir silenciosamente a conclusão. Não criar outro cadastro de contactos ou territórios. Os recursos que futuramente entrem no âmbito terão identificador/versão/hash próprios; a aceitação deles não é antecipada nesta fase.
Decisões guardam actor, instante UTC, fundamento, transição, ciclo, versão e hash. Edição posterior exige novo ciclo e nova aprovação; a decisão anterior permanece consultável, associada ao snapshot anterior. Reutilizar snapshots cifrados da auditoria e restrições de leitura existentes; não copiar contactos ou documentos para registos públicos.
Usar `etag`, bloqueio da oportunidade e incremento de versão na mesma transacção. Decisão com versão desactualizada responde a conflito, sem sobrescrever o trabalho do promotor. Repetições da mesma operação não repetem decisões, tarefas ou notificações. Falha na operação reverte conjuntamente os seus efeitos de estado, tarefa e auditoria.
### Dashboard, tarefas e notificações
Integrar na secção de Oportunidades já acessível pelo Dashboard Colaborativo, com filtro para Ofertas e estados da revisão. Não criar outro item no menu principal. O controlador editorial actual lista artigos/legislação/documentos e liga Oportunidades separadamente; não assumir que acrescentar um tipo de conteúdo às tabelas editoriais resolve automaticamente esta integração.
As contagens devem considerar cada oportunidade uma vez: o contador actual já abrange rascunhos de Ofertas. Mostrar ofertas por rever sem duplicar o total entre uma nova vista filtrada e a vista geral. Tarefas sem responsável são visíveis na fila autorizada; não escolher um funcionário ou prazo por inferência. Reenvio cria novo ciclo e conserva tarefas/decisões encerradas.
Usar a caixa de notificações editorial existente, com ligações para o detalhe privado correcto e revalidação de acesso na listagem e no destino. Notificar autor e responsáveis efectivos sobre submissão, atribuição, correcção, rejeição, reenvio e aprovação, conforme o papel de cada destinatário; não enviar conteúdo confidencial a uma lista geral de gestores. O mecanismo actual de oportunidades cobre autor e responsáveis atribuídos: a escolha de coordenação inicial deve respeitar as designações existentes e o âmbito, deixando a fila por atribuir quando não houver destinatário elegível.
Para email, reutilizar o transporte e a fila existentes, após confirmação da transacção, com tentativa/erro rastreáveis e revalidação de destinatário. `NotifyInterestReceived` é de manifestações de interesse: reutiliza-se o padrão de transporte, não o seu texto ou evento como se fossem Ofertas. Não prometer entrega exactamente uma vez, leitura da mensagem ou SMS configurado. Em QA, manter mail fake/array e dados sintéticos; não enviar mensagens reais nesta preparação.
### Componentes existentes provavelmente afectados
| Componente | Alteração delimitada proposta |
| --- | --- |
| `app/Services/PrivateOfferPreparation.php` | Validação/snapshot comuns, compatibilidade da submissão F1 e preparação de correcção/reenvio |
| `app/Services/OpportunityProfileWorkflow.php` | Adaptar revisão, decisões, ciclos e persistência privada apenas quando a oportunidade for Oferta |
| `app/Services/PortalProfileAccess.php` | Estado único e operações/edição permitidas nos estados de correcção, conservando âmbito e conflito |
| `app/Services/OpportunityManagement.php` | Preservar versões e invalidação de aprovação da revisão alterada, sem converter registos anteriores |
| `app/Services/OpportunityWorkflowTasks.php` | Reutilizar vinculação idempotente por oportunidade/revisão/ciclo e tarefas anteriores |
| `app/Services/PortalEditorialWorkflow.php` | Filas/contagens, destinatários e ligações das notificações de Ofertas, sem duplicar o cadastro editorial |
| `app/Http/Controllers/PortalEditorialWorkflowController.php` e `OpportunityManagementController.php` | Vista filtrada e dados autorizados de revisão, reaproveitando a entrada de Oportunidades |
| `app/Http/Controllers/PrivateOfferPreparationController.php` e `PortalProfileController.php` | Pedidos privados e decisões encaminhados ao serviço autorizado; sem lógica de permissão apenas no cliente |
| `app/Http/Middleware/ProtectProfileOpportunity.php` | Permitir somente as operações F2 expressamente suportadas; manter recusados os restantes atalhos |
| `app/Models/Opportunity.php` e `app/Services/OpportunityPromotion.php` | Conservar exclusão pública, marca privada e recusa de divulgação; não remover as barreiras ao habilitar revisão |
| `resources/js/pages/offers/preparation.tsx`, `preparation-index.tsx` e `components/opportunity-profile-actions.tsx` | Estado, correcção, reenvio, fundamento e acções de cada papel, com campos preservados em conflito |
| `resources/js/pages/management/editorial-workflow.tsx` e `management/opportunities.tsx` | Reutilizar secção existente e filtros, sem reorganizar o menu |
| `routes/web.php` | Só as rotas privadas necessárias, com middleware de autenticação, perfis e resposta privada |
| Testes e os mesmos manual/matriz | Provas focadas, saldo de RF-MN-003 e distinção QA/instalado/pendente |
Os caminhos acima existem e foram inspeccionados. Não são uma autorização para modificar todos os ficheiros: usar apenas os que o delta demonstrar necessários. Novos auxiliares ou testes deverão ter nomes definidos na implementação, sem serem apresentados aqui como componentes já existentes.
### Ordem de implementação proposta e aceitação
1. **Estado e versão:** adaptar a submissão F1 sem perder os seus identificadores/datas; congelar snapshot, ciclo e permissões. Demonstrar que uma Oferta e uma oportunidade normal mantêm os respectivos contratos.
2. **Revisão privada:** integrar tarefas e fila existente; iniciar, devolver, rejeitar e aprovar com fundamento, MFA, âmbito e independência. Aprovação continua sem publicação.
3. **Correcção e reenvio:** demonstrar rejeição → nova revisão privada → guardar/recuperar → novo ciclo → aprovação, conservando rejeição, comentários, snapshots e tarefas antigos.
4. **Notificações:** emitir uma ocorrência por evento/destinatário na caixa existente; testar repetição, falha/rollback, permissão revogada e URL de destino. Email real não é condição para aprovar o transporte fake de QA nem é activado por ele.
5. **Aceitação:** testes HTTP de sucesso, erro e concorrência, autorização por organização/categoria/estado, perda de representação, auditor e acumulação de papéis, motivo ausente, hash desactualizado, reenvio sem duplicações e nenhuma exposição por catálogo/API/rotas de divulgação. Regressão focada dos percursos nativos afectados, tipos, análise PHP, lint e compilação conforme o executor existente. Conferência visual quando a ferramenta permitir.
Percurso de aceitação principal: promotor prepara/submete → validador consulta na fila → rejeita com fundamento → promotor corrige e reenvia a nova revisão → validador independente aprova → autor recupera decisão e histórico → tentativa de publicação permanece recusada.
### Decisões para a autorização da Fase 2
Solicitar aprovação do recorte acima, incluindo: aprovação exclusivamente interna; correcção/reenvio após rejeição como nova revisão; estado privado compatível com os dados F1; integração pela área existente de Oportunidades no Dashboard; notificações internas e email apenas simulado em QA. Não são necessárias novas contas ou permissões por defeito.
Uma designação operacional ausente bloqueia apenas a atribuição a essa pessoa; não justifica inventar destinatários nem suspender as restantes filas autorizadas. NDA, comprovativos, textos jurídicos, formatos/tamanhos das seis fotografias e validade continuam nas fases dependentes. A regra de separação aprovação/publicação mantém-se; publicação, instalação e qualquer alteração operacional exigem autorização expressa própria.
### Preservação e reversão da futura preparação QA
Preparar o delta F2 num commit/pacote próprio, após a sua autorização, conservando a referência F1. A revisão técnica deve mostrar alterações apenas nos componentes afectados e revalidar as barreiras de exposição. Uma reversão de código ou desactivação do percurso F2 deve deixar as ofertas e decisões existentes legíveis em privado e bloqueadas para publicação; não eliminar eventos, tarefas, notificações ou snapshots para regressar à versão anterior. Não executar rollback destrutivo de estrutura com histórico.
Qualquer futura entrega em homologação terá a sua autorização, conferência do delta e processo Git de backup/restauro e recuperação. Este plano não executa nem autoriza essas operações.
**Estado final desta instrução:** relatório e plano entregues para decisão. Nenhuma implementação F2, migração, instalação, publicação, envio de notificações ou modificação da implementação F1.
@@ -0,0 +1,75 @@
# Ofertas — Fase 2, circuito editorial privado em QA
14/11/2026. Autorização expressa da Tempo nesta sessão, limitada à Fase 2 do plano `6e302db6`. Base: Fase 1 `1f2990e4`, mantida no histórico Git. Referência: RF-MN-003 da matriz existente.
**Implementado e verificado tecnicamente em QA. Sem instalação em homologação ou produção. A aprovação é interna; a publicação de Ofertas permanece bloqueada. Fase 3 não iniciada.**
## Percurso que ficou utilizável
No Dashboard Colaborativo existente, a área de Oportunidades inclui a entrada «Ofertas — revisão privada». Abre a mesma lista privada da Fase 1, agora com filtros por estado. Não há outro dashboard, outra tabela de ofertas, outro login ou alteração ao menu principal. As contagens de Oportunidades continuam a contar cada registo uma vez; a entrada filtrada não acrescenta outro contador ao total.
O promotor cria/guarda/recupera/submete. Um validador autorizado inicia a revisão, pede correcções nos campos comuns ou rejeita com fundamento. O promotor prepara a revisão após rejeição ou aprovação, conserva a decisão anterior, corrige e reenvia no mesmo identificador. O validador independente aprova a nova versão em privado. A coordenação de validação existente pode atribuir/reassociar um revisor elegível, com fundamento, histórico do responsável anterior e do novo responsável; não são criadas designações ou atribuições por inferência.
Estados: rascunho → submetida → em revisão → correcções solicitadas/rejeitada/aprovada internamente. Correcção e reenvio abrem novo ciclo; não eliminam rejeições, aprovações, snapshots ou tarefas encerradas. Aprovação não publica nem certifica titularidade, licenciamento ou viabilidade do negócio.
## Integração e garantias
- O mesmo `OpportunityProfileWorkflow` executa as transições. O adaptador privado valida os campos comerciais comuns; não exige nem fabrica os perfis normativos completos e não enfraquece o contrato dos investimentos existentes.
- Oportunidades continua a ser o cadastro principal. `management_draft`, versões, etag, workflow_context, tarefas CRM, auditoria e notificações existentes são reutilizados. Não há novas migrações, tabelas ou mudanças em `mne`.
- O estado editorial da Oferta é projectado a partir de uma única origem privada e sincronizado com as filas. O estado de publicação conserva a barreira privada: `status=draft`, `published_at=null` e a marca de preparação permanece. Nenhuma operação F2 remove a exclusão do catálogo.
- Cada submissão regista versão do contrato, snapshot, hash determinístico, versão do registo submetido e ciclo de revisão. Cada decisão conserva actor, instante UTC, fundamento, hash e revisão a que respeita. O snapshot da decisão também fica cifrado na auditoria existente.
- Contacto e localização são referenciados pelos cadastros existentes. O hash inclui os dados efectivamente conferidos nas referências; uma mudança posterior impede a aprovação dessa submissão ou sinaliza reconferência se a aprovação já ocorreu. A leitura não substitui silenciosamente a decisão.
- Ofertas antigas da Fase 1 só são vinculadas na primeira operação explícita de revisão. Conservam autor, data e revisão originais. A vinculação regista quem/quando estabeleceu o fingerprint: a Fase 1 não tinha esse fingerprint de referências, pelo que não se inventa um estado histórico do CRM na data original.
- As permissões são verificadas no servidor, na transacção e nos destinos das notificações. Autor, contribuidores e representantes em conflito não aprovam; acumulação de papéis não remove os bloqueios. Revisão/decisões internas exigem MFA recente. O papel de auditor permanece sem escrita operacional.
- Tentativas de publicação são recusadas na rota privada, nas rotas alternativas nativas e no workflow chamado directamente. Os bloqueios anteriores do modelo e da divulgação e a exclusão da pesquisa pública permanecem. Não se implementou um publicador para Ofertas.
- A versão pública anterior dos percursos nativos mantém-se durante edições pendentes; o ramo nativo não foi convertido para a preparação comercial. A regressão dos registos publicados confirma a preservação dos valores públicos. Não existem Ofertas públicas aceites nesta fase para simular uma publicação nova.
## Notificações e recuperação
A caixa editorial existente recebe as ocorrências por oportunidade/versão/acção e destinatário. A restrição de unicidade existente evita ocorrências repetidas. Submissões notificam coordenação elegível já designada; sem designação, a fila fica por atribuir. Decisões notificam autor e responsáveis efectivos, e a reatribuição inclui o responsável anterior se continuar autorizado. As ligações abrem a Oferta privada correcta e revalidam o acesso.
O novo job é apenas o consumidor do transporte de email/fila já existente: usa a activação e a lista de destinatários operacionais actuais, consulta a notificação novamente, reconfirma acesso e não envia conteúdo da Oferta. É agendado após a confirmação da transacção. Falhas imediatas de despacho/entrega ficam auditadas sem converter a decisão já confirmada numa resposta incerta. O job usa as tentativas/backoff do padrão existente. Não existe transporte SMS novo, envio real em QA ou promessa de entrega exactamente uma vez.
Se o circuito editorial ficar indisponível, uma Oferta v2 não é submetida pelo fallback v1: responde com indisponibilidade e conserva versão e histórico. Ofertas genuinamente v1 continuam com a preparação anterior. Não se faz rollback destrutivo de dados para regressar ao código precedente.
## Ficheiros do delta
| Ficheiros | Finalidade |
| --- | --- |
| `app/Services/PrivateOfferPreparation.php` | Adaptador, snapshots, vinculação F1, apresentação e histórico privado |
| `app/Services/OpportunityProfileWorkflow.php` | Revisão, atribuição, decisões por versão e ciclos no motor existente |
| `app/Services/PortalProfileAccess.php` | Projecção do estado e edição nos estados permitidos |
| `app/Services/OpportunityManagement.php` | Guardar a correcção privada sem perder categoria/âmbito/localização actuais |
| `app/Services/PortalEditorialWorkflow.php` | Destinatários, unicidade, destino privado e agendamento de notificações |
| `app/Jobs/NotifyPrivateOfferDecision.php` | Email pelo transporte existente, com revalidação de acesso |
| `app/Http/Controllers/PrivateOfferPreparationController.php` | Filtros e endpoint privado de decisões |
| `app/Http/Controllers/PortalEditorialWorkflowController.php` | Entrada de Ofertas na área existente de Oportunidades |
| `routes/web.php` | Endpoint privado de revisão, sob autenticação/perfis/resposta privada |
| `resources/js/components/offer-editorial-review.tsx` | Fundamento, campos a corrigir, responsável, decisões e histórico |
| `resources/js/lib/offer-editorial.ts` | Tipos e rótulos partilhados, sem importar uma página como biblioteca |
| `resources/js/pages/offers/preparation.tsx` e `preparation-index.tsx` | Revisão, recuperação e filtros na preparação existente |
| `resources/js/pages/management/editorial-workflow.tsx` | Entrada integrada, sem duplicar contagens |
| `tests/Feature/PrivateOfferEditorialTest.php` | 11 testes novos do circuito e dos bloqueios |
| Matriz, manual, este relatório e recibo QA | Saldo e evidência da entrega, sem outro inventário funcional |
## Verificação
Base sintética isolada: `offers_phase1_20261114_test:55441`, com as guardas existentes. Não foram utilizados processos reais, contas operacionais ou notificações externas.
- Integração e regressão: **35 testes / 499 asserções aprovados**, incluindo Fase 1, permissões, gravação/publicação nativa e o cenário canónico/editorial afectado.
- Passagem final F2: **11 testes / 160 asserções aprovados**. O teste de conflito de contribuidores foi ainda reforçado e verificado separadamente: **1 teste / 14 asserções aprovado**.
- HTML completo das duas páginas privadas com o build final: **1 teste / 10 asserções aprovado**.
- Pint, PHPStan, tipos, lint/formatação focados e compilação QA aprovados. A análise PHP foi repetida para o motor após o ajuste final do ramo privado. Sem enfraquecer testes, baseline ou guardas.
- Os grupos sobrepõem-se; não somar os totais. Execuções interrompidas ou anteriores com falhas não são contadas como aprovadas.
Os testes cobrem fila existente sem contagem duplicada, rejeição/correcção/reenvio, aprovação vinculada ao snapshot, decisão desactualizada, âmbito/MFA/contribuidores/auto-aprovação, perda de acesso, contacto alterado, reatribuição restrita, rollback conjunto, notificação repetida, email no transporte local em memória, revogação antes da entrega, vinculação explícita F1, indisponibilidade sem downgrade e recusa de publicação por percursos alternativos. O histórico recuperado conserva o título/preço e a decisão de cada versão.
## Riscos e pendências
1. Conferência visual manual continua pendente: a última prova da ferramenta registou host de navegador indisponível e falha de captura nativa GPU `0x887A0005`. Não foram repetidos reinícios ou desactivadas verificações. HTML e compilação não são apresentados como aceite visual.
2. A verificação global de formatação continua a falhar nos 61 ficheiros preexistentes. A lista foi novamente confrontada com este delta, sem intersecção. Não foi reformatado conteúdo exterior ao âmbito nem dispensada a falha global.
3. Email real, configuração operacional da fila, destinatários e entregabilidade não foram activados nem demonstrados. Os testes utilizam transporte em memória e os papéis sintéticos existentes no cenário.
4. Atribuição/reassociação exige coordenação já designada e candidatos elegíveis. A inexistência de uma designação não autoriza criar papéis; bloqueia apenas essa atribuição e mantém a fila por tratar.
5. O recorte aprovado é moderação dos campos comerciais comuns. NDA, comprovativos, fotografias, validade, catálogo público, destaque e restantes fases não são declarados concluídos. Não se atribui a Ofertas a designação jurídica da legislação.
**Saldo:** Fase 2 implementada e verificada tecnicamente em QA; não instalada. O módulo integral/RF-MN-003 e Procuras continuam parciais. A Fase 3 e qualquer instalação exigem autorização expressa separada. Próximo passo: revisão desta entrega e conferência visual quando o ambiente permitir, sem reabrir as provas técnicas aceites.
@@ -0,0 +1,56 @@
# Ofertas — Fase 3: consulta e filtros em QA
14/11/2026. Desenvolvimento autorizado exclusivamente para a Fase 3. Base aceite: `91c18573` (Fase 2), preservando `1f2990e4` (Fase 1) e o plano `6e302db6`. Referência: RF-MN-003 da matriz existente.
**Consulta implementada em QA; publicação efectiva bloqueada. Sem instalação em homologação ou produção. Conferência visual/móvel pendente. Não foram iniciadas as Fases 4 ou 5.**
## Comportamento implementado
`/ofertas` reutiliza os filtros, paginação, estados de pesquisa, estrutura pública e mapa do catálogo existente. Apresenta grelha/lista e lista com mapa administrativo. `/ofertas/{id}` mostra o resumo comercial, categoria, localização geral, preço e data da versão publicada. Não houve alteração do menu nem criação de outro cadastro ou dashboard.
Filtros: texto do resumo público, categoria, província/distrito, montante ou preço a negociar, moeda e intervalo de preços. A hierarquia territorial é validada no servidor. Montantes são comparados como decimais exactos, incluindo valores grandes; ordenar ou filtrar por montante exige uma moeda. MZN e USD são as moedas actualmente suportadas na preparação de Ofertas. Não há conversão cambial. Os contratos de moeda/preço dos investimentos nativos mantêm-se.
A API de leitura tem `/api/v1/offers`, `/offers/{id}`, `/offers/map` e `/offers/export`. A exportação CSV usa a mesma projecção e filtros da lista, com protecção contra fórmulas em células. Não existe operação HTTP de publicação neste consumidor.
## Barreira de confidencialidade e limite desta entrega
Esta fase não autoriza divulgar as Ofertas privadas já existentes. O modelo, os workflows e as rotas alternativas conservam os bloqueios de publicação aceites na Fase 2. O catálogo e os detalhes novos continuam vazios/404 na configuração normal, mesmo que exista JSON com um alegado estado publicado.
O novo serviço só admite amostras sintéticas quando é explicitamente injectado pelo teste e a guarda existente confirma ambiente `testing`, ligação de loopback, porta `55441`, base terminada em `_test` e identidade PostgreSQL correspondente. Não há interruptor HTTP, variável de ambiente ou sinalizador em dados que active a consulta efectiva. O mesmo modo injectado fora desse ambiente é recusado.
O consumidor lê exclusivamente uma projecção pública independente, com contrato, revisão, resumo autorizado, hash e decisões distintas de aprovação e publicação. O hash tem de corresponder ao conteúdo exacto; actores iguais, estados incompletos, datas futuras, referências territoriais inacessíveis e propriedades de conteúdo não previstas invalidam a projecção. Os testes constroem estas amostras: não representam decisões reais nem publicações realizadas.
Contactos, anexos, fotografias, dados financeiros privados, localização exacta, rascunho e contexto editorial completo não são seleccionados nem serializados. Os mapas recebem apenas limites administrativos públicos; nunca marcadores, coordenadas do negócio ou geometrias privadas. Texto é apresentado como texto React, sem HTML introduzido pelo conteúdo.
Uma edição privada ou rejeição da nova revisão não substitui a projecção anterior aprovada/publicada. Filtros, detalhe e CSV continuam a usar essa projecção; uma Oferta sem publicação válida não aparece. Eliminação lógica e retirada de uma referência territorial pública impedem a leitura.
**A futura criação/autorização operacional dessa projecção, a classificação dos campos divulgáveis e o levantamento da barreira dependem dos mecanismos de confidencialidade aplicáveis e de autorização própria. Não foram implementados NDA nem os restantes percursos das Fases 4/5. Não declarar a publicação real concluída.**
## Ficheiros alterados
| Grupo | Finalidade |
| --- | --- |
| `app/Services/OfferPublicCatalogue.php` | Consumidor de projecções, guardas, filtros, ordenação decimal, paginação e mapas administrativos |
| `app/Http/Controllers/OfferPublicCatalogueController.php`, `routes/web.php`, `routes/api.php` | Páginas públicas e endpoints de leitura/CSV |
| `app/Services/InvestmentCatalogue.php` | Opção delimitada para preços decimais de Ofertas; comportamento nativo por defeito preservado |
| `resources/js/app.tsx`, `components/opportunity-search-filters.tsx` | Layout público e reutilização dos filtros existentes |
| `resources/js/pages/offers/catalogue.tsx`, `public-detail.tsx`, `components/public-offer-card.tsx`, `lib/public-offer.ts` | Consulta, detalhe e apresentação exacta do preço |
| `tests/Feature/OfferPublicCatalogueTest.php` | Isolamento, projecções, acesso directo, versões, filtros, mapas, CSV e HTML compilado |
| Matriz RF-MN-003 e manual existentes | Saldo actualizado, sem outro inventário |
Sem migrações, dependências instaladas, modificações em `mne`, permissões novas ou alterações de dados operacionais. Testes usam apenas dados sintéticos e rollback na base QA isolada.
## Verificação e pendências
O recibo `outputs/offers-public-phase3-20261114/qa-result.json` regista os resultados finais e os hashes do delta. Os grupos de testes sobrepostos não devem ser somados.
- Passagem final: **10 testes / 133 asserções aprovados**, incluindo a regressão dos filtros combinados, lista e exportação nativos com referências sintéticas autocontidas, sem alterar o ensaio anterior.
- Passagem de integração anterior: **41 testes, 36 aprovados e cinco impedidos**, 514 asserções. As Fases 1/2 e a gestão/preservação pública nativa passaram. Os cinco casos de `OpportunitySearchTest` exigem uma referência pública preexistente de Inhambane e não chegam às respectivas asserções nesta base vazia. A limitação foi registada, sem remover ou enfraquecer os casos; a nova prova autocontida cobre o contrato partilhado afectado.
- Pint, PHPStan sem erros, TypeScript, lint/formatação dos seis ficheiros frontend alterados e compilação QA aprovados. HTML real das duas páginas novas resolve os respectivos ficheiros compilados.
- A verificação global continua a acusar os **61 ficheiros preexistentes**, fora do delta; não foram reformatados. Permanecem os avisos não impeditivos do build sobre a dependência opcional `fontaine` e o tamanho de chunks.
A estrutura responsiva foi preparada com campos em uma coluna no ecrã estreito, grelha progressiva, quebra de texto e controlos que se ajustam à largura. **Não há aceite visual ou prova de apresentação móvel:** a tentativa desta fase devolveu `Browsers: Error: The browser access host is unavailable`. O Playwright do caminho utilizado pelo antigo ensaio do repositório também não está disponível. Não foram instaladas ferramentas, reiniciados navegadores ou desactivados controlos para contornar a limitação. Compilação e respostas HTML não substituem essa prova.
O consumidor limita a representação a 1.000 projecções candidatas; o CSV e a página assinalam resultados truncados. O mapa limita o contexto a 30 territórios. Estes limites não são uma demonstração de escala de produção e devem ser revistos antes de uma abertura efectiva com volume superior.
Mantêm-se a conferência visual e a entrega real de email da Fase 2. A Fase 3 não altera o transporte de email. O módulo integral e Procuras não estão concluídos. Próxima tarefa: conferir desktop/móvel quando o conector estiver disponível e submeter esta entrega QA à apreciação da Tempo, mantendo a publicação bloqueada e sem instalação.
@@ -0,0 +1,88 @@
# Ofertas — Fase 4: particularidades e confidencialidade em QA
14/11/2026. Base aceite `7691cab3`, preservando F1 `1f2990e4`, F2 `91c18573` e o plano `6e302db6`. Desenvolvimento exclusivo em QA autorizado pela Tempo. Os campos propostos no documento original foram confirmados nesta sessão como opcionais durante a preparação. A Tempo confirmou também que o NDA permanece pendente.
**Implementado em QA, com dependência documental para o NDA real. Publicação efectiva bloqueada. Sem instalação, alterações de dados operacionais, migrações ou modificações em `mne`. Fase 5 não iniciada.**
## Percursos implementados
Na mesma preparação privada de Ofertas, o promotor guarda, recupera e altera:
- **Venda de empresa:** sectores principal/secundário do cadastro existente; receita, lucro/fluxo de caixa, moeda independente, motivo da venda e disponibilidade declarada. Decimais são conservados como texto, incluindo fluxo de caixa negativo; não há conversão cambial. Estes dados não entram na projecção pública.
- **Imóvel:** venda/arrendamento, tipo do catálogo existente, áreas e unidade, quartos/casas de banho, características/comodidades e disponibilidade declarada. Os campos de quartos e casas de banho aparecem nos tipos habitacionais aplicáveis. A localização continua no selector existente. As categorias de direitos de terra e restantes produtos conservam os seus contratos; não se fundiu terreno com imóvel nem se inventou uma regra de titularidade.
- **Fotografias:** envio, legenda, declaração e descrição de edição digital, alteração de metadados/original, retirada e restauro lógico. Máximo de **seis fotografias activas**, verificado no servidor sob bloqueio da mesma Oferta, tanto na criação como no restauro. Os recursos nativos dos outros módulos mantêm o limite anterior.
- **Comprovativos imobiliários:** envio privado, estado de análise e consulta pelo validador interno autorizado. O anunciante recebe o estado do envio, mas não recebe uma ligação de leitura do comprovativo no editor. O suporte enviado não é apresentado como título certificado nem aprova o negócio.
Os ficheiros usam `OpportunityResources`, `documents`, ligações e histórico existentes, sem outro armazenamento documental. Originais são cifrados no disco privado `local`, com identificadores gerados pelo servidor, checksum e quarentena. Reutilizam-se validação MIME/conteúdo, limites de 10 MB para fotografia/20 MB para documento, mínimo de 400 × 400 píxeis e rejeição de imagens animadas/executáveis. Os formatos documentais são limites técnicos do serviço existente; não constituem uma lista jurídica de títulos admitidos. O scanner existente decide a disponibilidade. Nenhum envio passa automaticamente a limpo ou a publicado.
## Comprovativos e caminhos alternativos
A autorização comum de binários (`VerifiedDocumentBinary`) verifica também os comprovativos de Ofertas. Exige papel existente de validador com âmbito no registo, acesso efectivo, MFA recente e ausência de conflito; o promotor, auditor, outra categoria ou sessão sem MFA não obtêm os bytes. A verificação ocorre também nos consumidores gerais que reutilizam esse serviço, antes de qualquer entrega. Regista documento, hash, actor e versão da Oferta, sem incluir o original na auditoria.
As rotas nativas de edição de Ofertas que já estavam bloqueadas mantêm a recusa. A associação de originais públicos da biblioteca a uma Oferta é recusada neste percurso: um comprovativo reservado não é convertido em documento público por essa associação. Fotografias e localizações desta preparação só admitem visibilidade interna. Os comprovativos só admitem classificação restrita.
## Duas condições independentes para dados da empresa
O novo adaptador usa a mesma Oportunidade e a autorização existente. Não cria cadastro de empresas, utilizadores, permissões ou dashboard. A configuração `private_offers.nda` está **nula**; não contém texto jurídico nem exemplar fictício. A sua futura referência exige um documento existente com versão, SHA256 e referência da aprovação da Tempo. Estado, scan, versão e integridade dos bytes são reconferidos pelo serviço comum. Não se reutilizou a designação jurídica da legislação como aprovação automática de NDA.
Com um exemplar efectivamente aprovado, o percurso técnico é:
1. Interessado autenticado e com email verificado consulta o exemplar exacto e aceita-o expressamente para o hash da versão aprovada da empresa. Não é exigida uma justificação para aceitar ou retirar a aceitação.
2. O vendedor registado (dono da Oferta), com atribuição de promotor e representação ainda efectivas, autoriza expressamente aquele interessado e **aquela aceitação**. Esta autorização no portal não certifica titularidade sobre a empresa. Um validador não substitui o vendedor. Autorizações e revogações conservam fundamento.
3. Em cada consulta, o servidor reconfirma as duas condições, o exemplar/versão do NDA, a aprovação editorial actual, o hash dos dados, a identidade do vendedor, os direitos efectivos e a revisão de acesso do interessado.
4. O interessado recebe apenas os campos empresariais da submissão aprovada. Não recebe a descrição privada integral, contactos, morada exacta, comprovativos, caminhos de armazenamento ou ficheiros originais. As fotografias continuam no percurso privado do promotor e da equipa; não se acrescentou partilha externa de originais.
5. Revogar a autorização ou retirar a aceitação fecha o acesso seguinte. A retirada de uma aceitação anterior continua disponível enquanto a Oferta está em revisão, sem abrir os dados dessa revisão. Uma aceitação nova elimina a autorização anterior: exige nova decisão do vendedor. Mudar o NDA, a Oferta, os seus suportes, as referências abrangidas ou os direitos efectivos impede a reutilização silenciosa do acesso anterior.
Aceitações, autorizações, retiradas e revogações ficam na história da Oportunidade, com antes/depois, actor, instante, hash da Oferta e identidade/versão/checksum do NDA. Os fundamentos ficam cifrados. A auditoria existente regista operações e consultas autorizadas/recusadas, sem valores financeiros. O vendedor consulta as suas decisões; o interessado vê apenas as ocorrências que lhe respeitam. As respostas privadas mantêm `no-store`. Uma revogação não apaga informação que o interessado já tenha legitimamente recebido; não foi prometida recolha retroactiva de bytes.
## Continuidade editorial e protecção pública
Os campos específicos e os suportes activos são incorporados no snapshot editorial `private-offer-editorial-v3`. Hashes incluem identidades e versões dos recursos, conteúdo/metadados e checksums; alterações exigem nova submissão/decisão. Ofertas anteriores sem extensão mantêm o contrato v2. Identificadores, originais, rejeições, aprovações e histórico não são substituídos. Um suporte que volte a quarentena sinaliza aprovação desactualizada, conservando a possibilidade de recuperar e preparar nova revisão.
A projecção pública da Fase 3 e os bloqueios do modelo/workflow não foram abertos. Não existe publicador, pagamentos ou destaque comercial activo. Não se acrescentaram campos sensíveis a pesquisas, APIs públicas, mapas, CSV, notificações ou email. As provas anteriores da Fase 2 para notificações sem conteúdo privado são reutilizadas; a entrega real de email permanece pendente.
## Cinco testes territoriais antes bloqueados
A causa estava em `OpportunitySearchFixture`: exigia uma referência pública de Inhambane e o sector turismo previamente importados, apesar de o ensaio usar uma base de testes vazia. O produto não perdeu os dados territoriais. O fixture passa a criar os mínimos sintéticos apenas quando ausentes, **depois da guarda de ambiente testing/loopback/base `_test`/porta 55441 e dentro de uma transacção de rollback**. Fora dessa combinação recusa a criação. Não foi executada importação, reposição ou alteração no cadastro operacional.
Os seis casos de `OpportunitySearchTest`, incluindo os cinco impedidos, passaram. Não se alteraram as suas asserções ou os filtros do produto para obter aprovação.
## Ficheiros alterados
| Grupo | Finalidade |
| --- | --- |
| `PrivateOfferDetails`, `PrivateOfferPreparation` | Campos específicos e snapshots/versionamento existentes |
| `PrivateOfferConfidentiality`, `config/private_offers.php` | Duas condições, revogação, versões e auditoria; NDA inactivo |
| `OpportunityResources`, `OpportunityLibrarySupport`, `VerifiedDocumentBinary` | Limite de seis, metadados, visibilidade e protecção central dos comprovativos |
| `PrivateOfferPreparationController`, `routes/web.php` | Operações privadas no percurso existente |
| `offer-particulars`, `offer-private-resources`, `offer-confidential-access`, páginas `offers/preparation` e `offers/confidentiality` | Preparação progressiva, recursos e controlo privado de acesso |
| `PrivateOfferConfidentialityTest`, `OpportunitySearchFixture` | Segurança/isolamento e independência das importações operacionais |
| Matriz RF-MN-003, manual e recibo existentes | Estado, testes e dependências concretas |
## Verificação e saldo
Resultados finais, comandos, base isolada e hashes do delta: `outputs/offers-private-phase4-20261114/qa-result.json`. Grupos de testes sobrepostos não devem ser somados. A passagem alargada intermédia teve 48/50 aprovados; uma falha expôs uma referência de sessão desactualizada no cenário de revogação e a outra resultou de reconstruir assets durante a prova HTML. Foram corrigidas as causas e as verificações afectadas foram repetidas com o build estabilizado. Não se enfraqueceram asserções, permissões, scanner ou guardas.
| Verificação | Resultado |
| --- | --- |
| Passagem F4 e HTML das fases afectadas | 13 testes / 174 asserções aprovados |
| Delta final: retirada durante revisão, versões NDA e revogação de sessão | 1 teste / 35 asserções aprovados |
| HTML privado, público e de confidencialidade com o último build estabilizado | 3 testes / 29 asserções aprovados |
| Regressão da preparação privada F1 | 14 testes / 146 asserções aprovados |
| Regressão editorial F2 | 11 testes / 161 asserções aprovados |
| Pesquisa territorial, incluindo os cinco casos antes impedidos | 6 testes / 112 asserções aprovados |
| Recursos nativos: binários, uploads, autorização e processamento condicionado | 5 testes / 69 asserções aprovados |
| Pint, PHPStan, TypeScript, lint/formatação focados e build QA | Aprovados; PHPStan sem erros |
| Formatação global | Os mesmos 61 ficheiros, fora do delta e com conteúdo Git inalterado |
As regressões das Fases 1/2 e da pesquisa foram extraídas da passagem alargada; os cinco casos de recursos constam da passagem focada de 16 testes/217 asserções. O complemento final cobre a alteração de retirada sem repetir os restantes casos aprovados. O caso adicional de suporte novamente em quarentena teve a expectativa ajustada ao contrato **já existente** de reabertura (`needs_information`, editável), preservando o produto.
**Pendências concretas:**
- Exemplar, versão e referência da aprovação do NDA da Tempo. A ausência foi confirmada pelo proprietário; sem estes elementos o acesso real continua fechado. Os testes usam bytes explicitamente marcados como sintéticos e não jurídicos, por injecção local ao teste.
- Identificação jurídica dos comprovativos imobiliários admitidos. Implementaram-se segurança e revisão interna, sem fabricar um tipo de título ou regra de propriedade.
- Conferência visual desktop/móvel das fases aceites, ainda não concluída. O conector registou `The browser access host is unavailable`; não foram alterados navegadores ou controlos de segurança.
- GD não está instalado neste PHP QA. Uploads, validação estrutural, armazenamento e autorização foram provados com PNGs válidos sem GD. O processador nativo mantém a geração de derivadas bloqueada quando GD/WebP não está disponível; não se declarou processamento de galerias públicas demonstrado.
- A formatação global mantém os 61 ficheiros preexistentes fora do delta. Email real F2 e a abertura efectiva de publicação continuam com o seu estado anterior.
Próxima tarefa: apreciação desta entrega QA e disponibilização do exemplar aprovado do NDA, mantendo a publicação fechada. Qualquer instalação ou avanço para a Fase 5 exige autorização própria.
@@ -0,0 +1,110 @@
# Ofertas — Fase 5: fecho operacional em QA e aceitação das cinco fases
14/11/2026. Autorização expressa da Tempo para desenvolvimento exclusivo em QA. Base aceite com ressalvas: `e431de8b` (Fase 4). Mantêm-se `7691cab3`, `91c18573`, `1f2990e4` e o plano `6e302db6`. Referência funcional: RF-MN-003 da matriz existente.
**Fecho operacional implementado em QA. Publicação efectiva continua bloqueada. Nenhuma instalação em homologação/produção, alteração de dados operacionais, migração ou modificação em `mne`.** A aceitação desta entrega não é inferida da autorização para a desenvolver.
## Disponibilidade, retirada e reactivação
O dono registado da Oferta, com atribuição e representação ainda efectivas, pode retirar imediatamente a disponibilidade. A operação protectora não exige decisão editorial, MFA adicional do promotor ou uma versão de formulário actual: bloqueia o registo, reconfirma o dono e retira a versão corrente. Uma repetição não duplica a retirada. Não altera o conteúdo, apaga decisões ou converte o cadastro noutro produto.
Disponibilidade e estado editorial são distintos. Uma aprovação privada pode continuar registada, mas a retirada exclui a Oferta das consultas, detalhe, filtros, mapa e CSV e fecha a leitura empresarial protegida pelo NDA. Uma consulta anterior já entregue ao utilizador não pode ser recolhida retroactivamente.
Para voltar a disponibilizar, o proprietário pede nova validação no mesmo identificador. Abre-se um ciclo operacional que integra o hash do snapshot editorial (`private-offer-editorial-v4`); os contratos v2/v3 anteriores mantêm-se quando não há esse ciclo. O motor editorial existente conserva submissões, responsáveis, tarefas e decisões, reabre a preparação e exige submissão/revisão/aprovação independente. Só depois o proprietário pode repor a disponibilidade privada. Uma aprovação anterior ou um pedido de reactivação não bastam.
Uma retirada durante revisão também pode abrir esse novo ciclo, fechando as tarefas anteriores pelo mecanismo existente. Um primeiro rascunho retirado continua a exigir a sua primeira validação. Preparações F1 antigas sem snapshot seguem primeiro a vinculação editorial já existente, sem fabricar um histórico de referências anterior.
Reactivar disponibilidade **não publica**. O consumidor não reapresenta o snapshot público antigo: para um ciclo novo, exige a correspondência de `offer_cycle` e `editorial_hash` com o ciclo e a aprovação activados. Esses campos são o contrato de leitura futura, não um publicador. Nos testes, apenas projecções sintéticas são disponibilizadas por injecção interna na base isolada.
## Validade independente do NDA
`config/private_offers.php` acrescenta `validity_days` e `validity_policy_reference`, ambos inicialmente nulos. Não foi escolhido um prazo definitivo. Quando a Tempo definir um prazo, a configuração exige inteiro positivo e referência identificada; o limite técnico de 36.500 dias protege a aritmética e não é uma política comercial/jurídica.
A política é congelada por hash na aprovação, e a contagem começa no instante UTC da publicação dessa versão. A aprovação privada não inicia a contagem. Alterar a configuração ou aprovar uma edição privada não prolonga a validade do snapshot público anterior; o exemplar legado sem `editorial_hash` conserva a primeira política congelada. Uma política pendente não é substituída por zero ou por um prazo presumido.
No instante `agora >= publicação + duração`, a Oferta deixa de aparecer nas leituras. Essa exclusão não depende de cron, execução de um job, aceitação ou duração do NDA. A API acrescenta `expires_at` apenas quando a data é conhecida, e o cartão público mostra-a. O formato anterior do CSV foi preservado; os seus registos usam a mesma exclusão por disponibilidade/expiração. As respostas públicas de Ofertas usam `no-store` para não conservar uma versão retirada em caches HTTP.
O comando `investmoz:offers-expire` confere por defeito **sem escrita**. Com `--confirm`, materializa uma expiração já efectiva e regista o evento uma vez, com actor de sistema nulo, sem criar utilizadores. O agendamento é optativo por `expiry_scheduler_enabled`, que permanece **false**. Não foi activado no ambiente operacional. Os ensaios do comando ocorreram exclusivamente na base de testes guardada.
## Denúncias e Dashboard Colaborativo
A Oferta dispõe de formulário de denúncia fundamentada. O utilizador deve estar autenticado, com email verificado, e poder consultar a versão pública ou já ter acesso privado legítimo ao registo. Um estranho não usa este percurso para descobrir um rascunho. A fundamentação é obrigatória; limites técnicos de texto e o throttle existente limitam os pedidos.
Cada envio guarda identificador idempotente, autor, instante, versão do registo, hashes de referência e cópia cifrada do conteúdo referido. O fundamento também fica cifrado. Uma retoma com o mesmo actor/chave/conteúdo devolve o recibo sem criar outra tarefa, mesmo que entretanto a Oferta tenha sido retirada. Reutilizar a chave para outro conteúdo é conflito; outro actor não recebe o recibo.
O mesmo `OpportunityWorkflowTasks` cria uma tarefa CRM restrita, sem inventar oportunidade CRM, responsável ou transporte. A entrada **Ofertas — acompanhamento**, dentro do Dashboard Colaborativo existente, abre a listagem privada existente em modo operacional, com filtro de denúncias pendentes. Não há outro dashboard nem alteração do menu principal.
O validador interno com âmbito, MFA recente e sem conflito inicia a análise; fica associado à tarefa e à versão da denúncia. Conclui com fundamento, sem retirada ou com retirada da disponibilidade. Etag, versão da denúncia, estado e responsável são reconferidos no servidor sob bloqueio. O dono, auditor, outro âmbito ou outra conta não substituem essa autorização. A conclusão preserva os factos originais e o histórico; não é apresentada como decisão judicial ou certificação de fraude.
O proprietário não recebe o fundamento original ou os dados do denunciante pela API de acompanhamento. A leitura desses dados exige o papel interno referido. A origem cifrada permite conferir a versão denunciada, mesmo que a preparação corrente seja alterada. Notificações, tarefas, pesquisa pública, mapa e CSV não recebem o texto da denúncia.
## Preparação administrativa de destaques
O Publicador existente, com âmbito, MFA, autorização efectiva e sem conflito, pode guardar uma preparação administrativa com designação, datas previstas, fundamento e estado **rascunho/arquivado**. O plano tem versão, etag, histórico e auditoria próprios no contexto da mesma Oferta.
Não há estado comercial activo, alteração de `is_featured`, preço de destaque, cobrança, pagamento ou integração financeira. O proprietário e o público não recebem esse plano. A actualização de disponibilidade/denúncias preserva o plano local em edição; carregar o plano guardado exige uma acção explícita que identifica a substituição dos campos locais.
## Segurança, confidencialidade e compatibilidade
- Operações privadas reutilizam autenticação, perfis, representação, MFA, CSRF, limites de pedidos e respostas `no-store` existentes. As escritas usam transacções/bloqueio do registo; versões e etags impedem decisões sobre uma revisão desconhecida. A única dispensa de etag é a retirada protectora pelo dono, para não manter uma exposição por causa de um formulário antigo.
- A retirada/expiração fecha imediatamente a leitura protegida da empresa. Uma nova validação tem hash/ciclo novo; não reaproveita silenciosamente a aceitação/autorização NDA anterior. O interessado conserva a possibilidade de retirar uma aceitação antiga durante revisão. Não se apagaram os seus registos históricos.
- A Fase 4 mantém comprovativos apenas para validadores internos autorizados, originais cifrados, limite de seis fotografias, quarentena e análise de segurança. Não se acrescentou partilha externa de originais ou coordenadas exactas.
- A projecção pública continua separada da edição privada; a barreira normal continua fechada. APIs nativas dos outros produtos não passam a devolver Ofertas. Filtros, detalhe, mapas e CSV de Ofertas usam a mesma condição de disponibilidade e expiração. A propriedade opcional `expires_at` não altera as chaves dos exemplares antigos sem validade definida.
- Auditoria conserva actor, instante, versão, ciclo e identificadores de decisão. Fundamentos e origem das denúncias ficam cifrados; não há valores financeiros, contactos ou documentos nos títulos das tarefas ou nos eventos públicos.
## Verificação
Base: `offers_phase1_20261114_test`, loopback, porta **55441**, guardas existentes e dados exclusivamente sintéticos. Não foram utilizados processos reais, credenciais de negócio, publicação real ou entrega externa de email. O scanner sintético da Fase 4 mantém a identificação de teste e não foi registado no produto.
Os resultados e hashes finais encontram-se em `outputs/offers-private-phase5-20261114/qa-result.json`. A passagem inicial focada teve **12 testes/151 asserções aprovados**. A regressão integrada terminou com **59 testes/780 asserções aprovados**, F1–F5, com os assets estabilizados. Os grupos sobrepostos não devem ser somados.
| Grupo da passagem integrada | Testes | Asserções | Resultado |
| --- | ---: | ---: | --- |
| F1 — preparação privada | 14 | 146 | Aprovado |
| F2 — circuito editorial | 11 | 161 | Aprovado |
| F3 — consulta e filtros | 10 | 133 | Aprovado |
| F4 — particularidades/confidencialidade | 11 | 160 | Aprovado |
| F5 — fecho operacional | 13 | 180 | Aprovado |
| **Total desta passagem, sem somar a inicial** | **59** | **780** | **Aprovado** |
Pint, PHPStan sem erros, TypeScript, lint/formatação dos oito ficheiros frontend e compilação QA passaram. A formatação global falhou nos mesmos 61 ficheiros preexistentes, fora do delta e com conteúdo Git inalterado. Não foram enfraquecidos testes, adicionadas excepções de análise ou instaladas dependências. A primeira passagem delimitada revelou uma recusa da antiga lista de acções à reabertura durante revisão: foi corrigida exclusivamente para o ciclo operacional pedido pelo dono, mantendo as regras dos restantes percursos.
A prova cobre retirada com formulário antigo, repetição, actor/âmbito incorrectos, novo ciclo e nova validação, ausência de reexposição do snapshot antigo, primeira preparação, retirada durante revisão, preservação do histórico, fronteira exacta da expiração, política nula/configuração congelada, NDA anterior fechado, idempotência da denúncia, fonte cifrada, tarefa/filas nativas, tratamento e conflitos de versão, MFA expirado, auditor, destaque administrativo sem efeitos comerciais e HTML compilado das páginas afectadas.
## Matriz de aceitação das cinco fases
| Fase | Resultado demonstrado em QA | Referência aceite/prova | Instalação/publicação | Ressalvas e pendências |
| --- | --- | --- | --- | --- |
| 1 — Preparação privada | Criar, guardar, recuperar, editar e submeter; autorização/isolamento; cadastro de Oportunidades reutilizado | `1f2990e4`; `PrivateOfferPreparationTest` | Não instalada | Visual pendente |
| 2 — Circuito editorial | Dashboard existente, revisão, rejeição/correcção/reenvio, aprovação por versão, auditoria e notificações internas | `91c18573`, plano `6e302db6`; `PrivateOfferEditorialTest` | Não instalada; aprovação distinta de publicação | Visual e entrega real de email pendentes |
| 3 — Consulta e filtros | Projecção aprovada/publicada sintética, categorias/localização/preços sem câmbio, mapa administrativo, API/CSV e preservação da versão anterior | `7691cab3`; `OfferPublicCatalogueTest` e compatibilidade F5 | Publicação efectiva bloqueada | Visual/móvel; abertura real não autorizada; limite de representação de 1.000 candidatos/30 territórios preservado |
| 4 — Particularidades e confidencialidade | Campos confirmados, seis fotografias, comprovativos internos, duas condições NDA/vendedor, revogação/histórico e acessos por versão | `e431de8b`; `PrivateOfferConfidentialityTest` | Não instalada; NDA real inactivo | Exemplar/versão/aprovação do NDA; tipos jurídicos de comprovativo; GD/WebP; visual |
| 5 — Fecho operacional | Retirada imediata, revalidação/reativação, validade configurável/expiração, denúncias/tarefas/Dashboard, destaques administrativos e compatibilidade | Esta entrega; `PrivateOfferOperationsTest` e recibo QA | Sem instalação ou activação comercial/pública | Apreciação desta entrega; prazo/política final por definir; pendências das fases anteriores preservadas |
Esta matriz integra o relatório solicitado e actualiza RF-MN-003 e o manual existentes; não é um novo inventário geral. O conjunto de Ofertas fica preparado em QA, mas não é declarado operacional em público, instalado, juridicamente fechado ou visualmente aceite. Procuras e o restante portal não são encerrados por esta entrega.
## Ficheiros alterados
| Grupo | Finalidade |
| --- | --- |
| `app/Services/OfferAvailability.php`, `PrivateOfferOperations.php` | Disponibilidade/validade, operações do dono, denúncias, tarefas, auditoria e planos administrativos |
| `OpportunityProfileWorkflow.php`, `PrivateOfferPreparation.php`, `PrivateOfferConfidentiality.php` | Ciclo operacional no snapshot, reabertura/revalidação existentes e exclusão da leitura NDA |
| `OfferPublicCatalogue.php`, `OfferPublicCatalogueController.php` | Exclusão consistente, contrato anterior preservado, validade opcional e cache HTTP |
| `PrivateOfferOperationsController.php`, `PrivateOfferPreparationController.php`, `PortalEditorialWorkflowController.php`, `routes/web.php` | Endpoints privados, filas no âmbito e entrada no Dashboard existente |
| `app/Console/Commands/ExpirePrivateOffers.php`, `routes/console.php`, `config/private_offers.php` | Conferência/materialização da expiração; prazo e agendamento sem activação por defeito |
| `resources/js/components/offer-operations.tsx`, `public-offer-card.tsx`, `lib/public-offer.ts` | Acompanhamento privado e data pública opcional |
| Páginas `offers/report`, `offers/preparation`, `offers/preparation-index`, `offers/public-detail`, `management/editorial-workflow` | Denúncia, retirada/reativação, tratamento e preparação administrativa integrados |
| `tests/Feature/PrivateOfferOperationsTest.php` | Testes novos de segurança, percursos e compatibilidade |
| Matriz RF-MN-003, manual, este relatório e recibo QA | Saldo e evidência das cinco fases |
## Limitações e decisões pendentes
1. **NDA:** a Tempo confirmou que ainda falta o exemplar/versão e a referência da aprovação. `private_offers.nda` mantém-se nulo. Não foi inventado texto jurídico ou uma assinatura com validade presumida.
2. **Imóveis:** os tipos jurídicos de comprovativo admissíveis continuam por identificar. Upload/scan/revisão interna não certificam titularidade, licenciamento ou disponibilidade do bem.
3. **Validade e divulgação:** a Tempo ainda não definiu prazo definitivo. A configuração permanece nula, sem presumir uma duração. Avisos jurídicos e eventuais condições comerciais de uma publicação futura não foram fabricados.
4. **Visual:** a conferência desktop/móvel das fases anteriores permanece pendente. A última tentativa registada do conector devolveu `The browser access host is unavailable`. Não foram repetidos reinícios, desactivados controlos ou apresentados HTML/compilação como aceitação visual.
5. **Imagens:** GD/WebP não está disponível neste PHP QA. Os originais privados e as validações da F4 foram demonstrados; geração/inspecção de derivadas e galeria pública não são declaradas concluídas. Não foram instaladas extensões.
6. **Email:** a entrega real da F2 continua pendente. Não foram activados novos transportes, SMTP, SMS ou destinatários operacionais.
7. **Qualidade preexistente:** a formatação global mantém os mesmos 61 ficheiros fora do delta. Lint focado, tipos e análise estática são registados separadamente dessa falha anterior.
Próximo passo: apreciação desta entrega QA e resolução das dependências documentais/visuais indicadas. **Qualquer instalação, configuração activa ou publicação aguarda autorização expressa da Tempo.**
@@ -0,0 +1,71 @@
# Ofertas — tentativa exacta de instalação privada e correcção QA
14/11/2026. Autorização: fast-forward de `main` de `96f51096` para **`3558647e8d82092cd0e31eefbe8b9e5e9eacf573`**, sem força, sem revisões posteriores e exclusivamente para homologação privada.
**Resultado: instalação interrompida antes da promoção. A homologação mantém `96f510965f70581a4b41dd94463f598b77dd7cf5`.** O ramo remoto recebeu exactamente `3558647e`; o executor verificou e construiu essa candidata, mas recusou-a na verificação TypeScript. Nenhuma revisão posterior foi enviada ou instalada.
## Execução e salvaguarda
1. Ancestralidade confirmada e `main` inicialmente em `96f51096`; fast-forward possível, árvore local sem alterações inesperadas.
2. Janela reservada: path automático temporariamente pausado, serviço original inactivo e zero pedidos concorrentes. O lock existente `delivery.lock` foi adquirido sob o utilizador de implantação existente. O executor original não foi modificado: uma chamada única a `Delivery.deliver(3558647e)` reconfirmava o `main` real e lançava erro se encontrasse outro SHA, sem utilizar o ciclo que escolhe automaticamente revisões posteriores.
3. Backup **novo**, de 14/11/2026 às **19:51:40 Africa/Johannesburg (17:51:40 UTC)**, em `/var/backups/investmoz-offers-exact-install-3558647e-20261114/backup`. Restauro isolado aprovado para 219 tabelas, 417 ficheiros e dois conjuntos de MFA; nenhuma base operacional substituída. Cópia da release anterior preservada e integridade reconfirmada.
4. Push efectuado com referência exacta, sem força: `git push --porcelain origin 3558647e8d82092cd0e31eefbe8b9e5e9eacf573:refs/heads/main`. O remoto confirmou `96f51096..3558647e`; `ls-remote` reconfirmou o SHA integral. Os commits documentais posteriores não foram incluídos.
5. O pedido do webhook foi reservado fora da fila automática durante a janela. O build decorreu numa release separada e numa base CI isolada; a ligação `current` nunca mudou. A reserva temporária foi relançada antes do build para utilizar o mesmo UMask `0027` do serviço existente; não se modificaram as permissões/configuração do serviço original.
| Salvaguarda nova | Resultado |
| --- | --- |
| Dump de 219 tabelas | SHA256 `88152dc39367f5e59eb072f63b44d3a949e93d73ae855eccb33c3c0b9e37bf6c` |
| Arquivo de 417 ficheiros/configuração | SHA256 `aa6248e0eec3d27d58a4bbb3c16848a70dcb3524e6cafc6340b682984a8bbdb3` |
| Restauro | Rede `none`, base nova `offers_install_restore_20261114_test`, porta interna 55441; contagens/checksums e ficheiros iguais; container de ensaio removido |
| Integridade após a falha | 219/219 tabelas e 417/417 ficheiros sem diferenças face à cópia nova; sem alteração do código servido |
## Incidente concreto
O executor passou a preparação da candidata, Composer, guarda/schema isolado **1 teste / 3.106 asserções**, `npm ci` e build frontend. Parou em `npm run types:check`, antes dos unitários/frontend seguintes e antes de qualquer promoção ou `queue:restart` operacional.
O Wayfinder, com variantes de formulário, gerou em `resources/js/actions/App/Http/Controllers/PrivateOfferOperationsController.ts`:
- Linha 180: `export const reportForm` para a acção GET do formulário de denúncia;
- Linha 331: `const reportForm` como auxiliar do formulário da acção POST `report`.
O TypeScript recusou as duas declarações com **TS2395**. Não é falha de persistência, autorização, GD, estrutura ou dados. A verificação QA simplificada anterior reutilizava rotas geradas e não exercitou esta geração completa; por isso a colisão não tinha sido detectada antes da entrega. A verificação completa do executor funcionou e conservou a aplicação anterior.
Release falhada preservada para diagnóstico: `/var/www/investmoz-homologacao/releases/3558647e8d82092cd0e31eefbe8b9e5e9eacf573-a4916dfe`. Logs protegidos e recibos da chamada exacta em `/var/lib/investmoz-deploy/offers-exact-3558647e-20261114`. Não se editou essa release para contornar os tipos ou apresentar como instalado um código diferente de `3558647e`.
## Recuperação e estado real
- O manifesto servido e o recibo de instalação continuam em **`96f51096`**.
- Não houve promoção; **não foi necessário reverter código ou restaurar a base**. A release anterior permaneceu servida durante toda a tentativa.
- O lock foi libertado, o path automático voltou ao estado activo original e a fila ficou sem pedidos pendentes. O pedido reservado foi preservado no registo privado da operação, sem reexecutar automaticamente a revisão falhada.
- `main` permanece em **`3558647e`**, conforme o avanço autorizado. Não houve force push ou reversão implícita do ramo.
- Zero migrações operacionais, importações, alterações de permissões, alterações de `mne`, activações de pagamentos, emails ou publicação de Ofertas. As configurações protegidas e os dados operacionais foram reconferidos sem diferenças.
- As verificações pós-instalação do novo módulo em homologação não foram executadas: o módulo não chegou a ser instalado. Não se apresentam os testes QA como acesso autenticado real à nova versão no destino.
## Correcção mínima preparada apenas em QA
Dois pontos funcionais alterados:
| Ficheiro | Correcção |
| --- | --- |
| `app/Http/Controllers/PrivateOfferOperationsController.php` | Renomear a acção GET `reportForm` para `reportPage`; corpo e verificações intactos |
| `routes/web.php` | Apontar o mesmo GET `/{id}/denunciar` à acção `reportPage` |
Não se alteraram os URLs, verbos HTTP, acção POST `report`, permissões, serviços, dados, histórico, menu ou as cinco fases. O Wayfinder gera agora `reportPage`/`reportPageForm` para GET e `report`/`reportForm` para POST, sem colisão. Nenhuma nova funcionalidade foi iniciada.
| Verificação da correcção | Resultado |
| --- | --- |
| `php artisan wayfinder:generate --with-form` | Aprovado, geração real de acções/rotas/formulários |
| Build normal com `INVESTMOZ_QA_BUILD=0` | Aprovado; plugin Wayfinder activo, sem reutilização simplificada |
| `npm.cmd run types:check` depois desse build | Aprovado |
| PHPStan completo e Pint focado | Aprovados; zero erros PHPStan |
| Operações, denúncias, confidencialidade/NDA e continuidade | **28 testes / 403 asserções aprovados**, incluindo GET do formulário e POST das denúncias |
| Base do ensaio | `offers_wayfinder_fix_20261114_test`, loopback:55441, separada da prévia do utilizador |
| Preservação | Base da prévia não foi limpa; prova aceite de continuidade guardada/restaurada byte a byte, nova fixture retida separadamente |
Os comandos, resultados e hashes constam de `outputs/offers-exact-install-20261114/qa-result.json`. A correcção não foi enviada para `main` nem instalada: a autorização recebida limitava a instalação a `3558647e` e proibia revisões posteriores.
## Saldo e decisão necessária
**Instalação de `3558647e`: BLOQUEADA pelo build completo.** Integração técnica e dados das cinco fases preservados; correcção de compatibilidade do gerador pronta em QA nesta entrega. Nova autorização deverá identificar a revisão correctiva exacta e o fast-forward a partir do `main` actual `3558647e`, conservando instalação exclusivamente privada e as guardas de revisão/janela/salvaguarda.
Continuam pendentes, separadamente, a conferência visual/móvel autenticada no destino, processamento completo/galeria, email real restrito a caixa autorizada e as decisões jurídicas sobre NDA, comprovativos, avisos e validade. A geração corrigida não encerra essas pendências nem autoriza publicação pública.
@@ -0,0 +1,107 @@
# Ofertas — parecer de prontidão para homologação privada
14/11/2026. Candidata técnica aceite: **`9a01426864a47dacd97db2b4da363aba86a94df6`**. Esta passagem autoriza exclusivamente preparação; não houve instalação, alteração operacional, migração, activação pública ou envio de email.
**Actualização da verificação autorizada posterior:** [parecer real pré-instalação](ofertas-verificacao-preinstalacao-20261114.md), **APTO COM CONDIÇÕES**. Destino `96f51096` conferido; backup/restauro de 219 tabelas e 417 ficheiros aprovado; GD/WebP disponível. As referências abaixo a destino/backup ainda por conferir descrevem a preparação original. Mantêm-se nova autorização de instalação e condição de revisão exclusiva em `main`; nenhuma instalação executada.
## Parecer
**Existe validação integrada em QA suficiente para recomendar uma instalação controlada de avaliação privada, com condições de entrada. Não existe aceitação integral para operação/publicação pública.** A autorização para instalar continua separada e ainda não foi recebida nesta passagem.
O percurso demonstrado inclui criação/recuperação da Oferta, submissão, revisão/aprovação independente, projecção pública exclusivamente sintética, contexto privado autorizado no mesmo dossier, localização, hipóteses económicas declaradas, cálculo, relatório HTML/Word e recuperação. NDA e autorização do vendedor foram testados com documentos sintéticos; não conferem autorização para copiar a informação empresarial confidencial para o contexto. A prova não demonstra um investidor público a converter uma Oferta operacionalmente publicada num dossier: esse publicador não está disponível.
Condições de entrada para a futura instalação: confirmar revisão/schema/configuração/processos reais do destino; concluir backup actual e ensaio de restauro; rever o delta exacto e a reversão compatível; receber autorização expressa para a candidata e o destino. Se uma destas condições falhar, não se instala. Visual, correio real e derivadas de imagens ficam como verificações de homologação explicitamente pendentes, sem declarar o módulo concluído.
## Integridade e provas reutilizadas
Confirmada a ancestralidade F1 `1f2990e4`, plano `6e302db6`, F2 `91c18573`, F3 `7691cab3`, F4 `e431de8b`, F5 `528a2597`, auditoria `ed2fd86f` e correcção `9a014268`. **48 hashes** dos dois recibos de código/provas conferidos contra os respectivos blobs Git, todos correspondentes. Conferir na versão de cada recibo evita interpretar as actualizações documentais posteriores como corrupção das provas anteriores.
| Evidência aceite | Resultado / limite |
| --- | --- |
| F1–F5, passagem integrada original | 59 testes / 780 asserções, aprovados; não repetidos nesta preparação |
| Correcção e compatibilidade afectada | 22 / 461 aprovados; inclui 4/62 de continuidade, 11/161 de confidencialidade, 4/192 de dossiers e 3/46 de gestão nativa |
| Plano financeiro e relatórios frontend | 41 testes aprovados |
| Prova ligada ao JSON da execução PHP | 1 teste aprovado; cálculo por custos declarados e HTML/Word gerados/reabertos |
| PHPStan, tipos, lint/formatação focados | Aprovados no recibo aceite; não houve alteração funcional nesta preparação |
| Imagens sem GD | Provas aceites de upload, limite, quarentena e recusa 423 de processamento; não equivalem a validação de derivadas |
Recibo desta preparação: `outputs/offers-homologation-preparation-20261114/readiness-verification.json`. As provas anteriores mantêm-se nas pastas referidas pela matriz e pelo [recibo da correcção](ofertas-correcao-contexto-qa-20261114.md). Não somar passagens sobrepostas nem usar quantidade de testes como conclusão do portal inteiro.
## Estrutura, dependências e configuração
Foi comparada a candidata com a revisão **histórica** do recibo de homologação `96f51096`: **zero diferenças em `database/migrations`, `composer.lock` e `package-lock.json`**. Também não há migrações novas no delta das cinco fases/correcção. Não é necessário criar uma migração de Ofertas. Esta comparação de ficheiros não prova a aplicação das estruturas no servidor actual.
Pré-conferência em leitura no destino: registo de migrações e estrutura efectiva de `opportunities` (gestão, dono, organização, JSON editorial), perfis/permissões e revisão de acesso, recursos `opportunity_images/documents/locations`, `documents`, auditoria, tarefas, notificações editoriais com `opportunity_id/opportunity_version/content_hash`, dossiers/contexto e rascunhos de simulação. Conferir nome/schema das relações pela versão instalada, constraints, cifra e acesso ao armazenamento. As dependências de recursos e contexto identificam, entre outras, `2026_10_09_030000_extend_opportunity_resources` e `2026_10_16_150000_create_investor_navigation_preferences`; notificações dependem de `2026_11_13_223000_extend_editorial_notifications_to_opportunities`.
**Não executar indiscriminadamente `artisan migrate`:** existem migrações anteriores guardadas para QA isolado, incluindo a das notificações. Se faltar estrutura no destino, parar e apresentar o delta aditivo e autorização concreta; não remover guardas para aplicar um ficheiro. Não usar `migrate:fresh`, seeders, rollback estrutural destrutivo ou alterações a `mne`.
Manter versões e locks existentes. O PHP CLI QA é 8.5.11; a compatibilidade real deve ser conferida com as dependências bloqueadas, PHP-FPM/web e worker, PDO PostgreSQL/PostGIS, memória, discos privados, cifra/APP_KEY existente, scanner, fila e scheduler. Não instalar ferramentas/extensões nem actualizar versões nesta preparação. Confirmar manifesto/assets e ausência de `public/hot` no artefacto a instalar; não publicar `.env`, ficheiros privados, dependências locais ou fixtures.
Para avaliar o circuito privado, confirmar os mecanismos existentes `portal.profile_access_enabled` e `editorial_workflow.enabled` e respectivos papéis efectivos. Não os activar globalmente ou conceder permissões por inferência; diferenças para o destino têm de constar da autorização de instalação. Preservar autenticação, MFA, representação, CSRF, throttling, auditoria e transportes dos outros módulos.
## Publicação integralmente fechada
- `Opportunity` exige rascunho e `published_at=null` para Ofertas e impede remover o marcador privado; o scope público nativo exclui-as.
- Aprovar não publica; o workflow privado e percursos alternativos mantêm a recusa.
- `OfferPublicCatalogue` normal não devolve Ofertas. A injecção sintética exige ambiente de testes e identidade efectiva da base isolada; não há variável HTTP/ambiente de activação equivalente nem publicador operacional para ligar.
- Manter `private_offers.nda=null`, validade/política nulas e `expiry_scheduler_enabled=false`; não copiar documentos/snapshots sintéticos para o destino. O agendamento de expiração não é necessário para instalar em avaliação privada.
- Pós-instalação deve comprovar ausência em lista, detalhe directo, APIs, mapa, pesquisa nativa e CSV, incluindo após aprovação privada. Não basta ocultar um menu.
## GD e preservação de uploads
A conferência local nesta passagem confirmou PHP 8.5.11 **sem GD, `imagecreatetruecolor` ou `imagewebp`**. O destino actual não foi consultado: não se presume que possua a mesma configuração. `OpportunityImageProcessor::prepare` verifica a disponibilidade e devolve 423 **antes** da transacção/escrita; originais e metadados permanecem. Upload, cifra, quarentena e antimalware são percursos separados; não se deve substituir ficheiros existentes ou desactivar scan para gerar uma galeria.
Sem GD/WebP, é possível instalar para avaliação privada com esta limitação expressa, mas não aceitar processamento de derivadas/galeria. Depois de autorização adequada e num processo compatível, testar JPEG/PNG/WebP sintéticos, dimensões e memória, derivadas sem metadados sensíveis, scans individuais, hash, cifra, limite de seis e restauro lógico. Conferir os três runtimes (web/CLI/worker). Não reprocessar em lote os uploads existentes. Alteração de extensão/runtime fica fora desta preparação e exige delta próprio se necessária.
## Conferência visual e móvel autenticada preparada
Usar apenas contas e registos sintéticos identificados na homologação; autenticação e MFA normais, sem códigos na conversa. Preparar dono promotor, revisor independente com âmbito e MFA e interessado sem acesso privado; não criar/grantar contas no activo nesta passagem.
| Sessão / janela | Conferência e critério |
| --- | --- |
| Dono, desktop 1366×768 | Criar/guardar/recuperar, endereço e Oferta correctos, erros junto dos campos, descrição sem repetição/perda; subir recursos sintéticos até ao limite; botão visível; voltar sem perder edições |
| Revisor independente | Fila do Dashboard existente → Oferta exacta → decisão fundamentada por versão; conflitos e auto-aprovação recusados; nenhuma publicação |
| Dono, dossier/simulador | Mesma referência/localização, prévia e confirmação, manter resumo deliberado; rascunho/resultados e relatório recuperáveis |
| Janela 390×844 e 360×800 | Sem scroll horizontal impeditivo; menus, modais, campos, erros, anexos e acções acessíveis por toque/teclado; foco e retorno preservados |
| Conta sem âmbito/interessado | Rotas directas, contexto, ficheiros e APIs recusados; nenhum texto confidencial ou comprovativo no HTML/relatório; catálogo público vazio |
| Retirada/revisão | Estado visível e coerente, indisponibilidade imediata; reactivação exige nova validação; histórico preservado |
Registar verificador, instante, candidata servida, viewport, conta/papel sintético, rota e resultado, com capturas sem credenciais. Última evidência disponível do conector: `The browser access host is unavailable`; não foi feita nova tentativa nesta preparação. Quando o conector funcionar, identificar aba/URL antes de actuar; se continuar indisponível, usar conferência manual registada, sem ciclos de reinício ou alterações de segurança. Não converter HTML compilado em aceitação visual.
## Prova de email real preparada, não executada
O job existente `NotifyPrivateOfferDecision` requer notificações activas, destinatário na allowlist, Oferta válida e permissão efectiva reconferida antes do envio. O corpo contém referência e ligação protegida, sem descrição, contacto ou valores empresariais. Usa o transporte existente; não se acrescenta SMS ou outro motor.
Ensaiar preferencialmente numa cópia isolada com código/configuração SMTP equivalentes e dados exclusivamente sintéticos, saída de correio restrita a **uma caixa de teste indicada pela Tempo**. A caixa ainda não foi designada; não assumir um endereço operacional. Provisionar segredos pelo mecanismo existente, sem exportá-los no pacote. Não copiar utilizadores reais, consumir a fila partilhada nem mudar a allowlist/flag global da homologação afectando os restantes serviços.
Depois de autorização do ensaio, criar decisão sintética/notificação pelo serviço existente e processar apenas o job identificado. Conferir aceitação pelo transporte e recepção efectiva na caixa, referência/ligação correcta, remetente, ausência de dados reservados e comportamento após perda de permissão. Guardar ID técnico/horário e resultado, sem credenciais. A aceitação SMTP isolada não basta como entrega na caixa. Se houver reenvio por falha, registar tentativas; não prometer entrega exactamente uma vez, pois o job admite retries. Não activar notificações globais ou escoar filas acumuladas para efectuar esta prova. Nesta preparação não se enviou email.
## Decisões jurídicas ainda necessárias
1. **NDA:** exemplar e versão aprovados, referência da decisão e âmbito aplicável. O mecanismo técnico não inventa texto, assinatura válida ou concessão de transferência para relatórios. Mantém-se fechado enquanto ausente.
2. **Imóveis:** tipos de comprovativo admissíveis e tratamento a dar à titularidade/representação declarada; acesso continua interno, não se certifica direito ou licença por upload/scan.
3. **Avisos/validade:** texto aplicável à divulgação/interesse, responsabilidades e confidencialidade; prazo/política final de validade. Não escolher um prazo ou inventar exclusões jurídicas. São decisões para os comportamentos dependentes/publicação, não motivos para refazer as provas privadas aceites.
## Cópia, instalação futura e reversão
**Preparação material:** bundle Git local da candidata `9a014268`, com pré-requisito histórico `96f51096`, em `outputs/offers-homologation-preparation-20261114/offers-9a014268.bundle`; manifestos/hashes na mesma pasta. Não foi enviado ao servidor. Confirmar que o destino contém o pré-requisito e que o delta não sobrepõe alterações posteriores; não instalar por `reset --hard` nem substituir árvore com trabalho alheio.
1. **Antes de qualquer escrita:** após autorização concreta, identificar destino/revisão/schema/configuração e conferir delta completo. Qualquer diferença de estrutura, configuração partilhada, revisão ou permissões não abrangida interrompe a instalação para decisão específica.
2. **Backup actual:** cópia consistente da base, ficheiros privados e manifestos, configuração e chaves protegidas, revisão anterior e artefactos. Não imprimir segredos. Conferir hash/identificadores/contagens e ensaiar restauro isolado com leitura de documentos cifrados e histórico. O backup antigo de `96f51096` é referência histórica e não serve como backup actual. **Nenhum novo backup ou restauro do destino foi efectuado nesta passagem.**
3. **Executar pelo processo Git existente:** candidata e manifesto aprovados, dependências fixadas, assets próprios de produção e caches/workers conforme procedimento existente. Não executar migrações por rotina; não activar publicação, NDA, agendamento, destaques ou correio partilhado. Manter dados e integrações existentes.
4. **Conferir após instalar:** SHA servido/assets, HTTP e logs, autenticação/MFA/âmbito, circuitos privados sintéticos, permanência dos bloqueios públicos, integridade dos uploads e compatibilidade com oportunidades/dossiers, instituições e demografia existentes. Não reexecutar importações ou publicação de conteúdo para provar integridade. Registar diferenças de runtime/cache/sessões separadamente de dados de negócio.
5. **Reverter antes de uso:** se não existirem escritas novas de Ofertas e a revisão anterior for compatível, repor apenas código/assets/configuração alterados no âmbito aprovado e conferir novamente; conservar logs e cópias.
6. **Reverter depois de uso:** não regressar cegamente à F4 ou revisão que ignore retirada/expiração, hashes v4 ou confidencialidade. A F4 não testa `OfferAvailability` no leitor protegido. Usar recuperação compatível que preserve as guardas novas e todo o JSON/histórico/tarefas/notificações. Se não existir revisão de recuperação comprovada, suspender o acesso afectado pelo mecanismo administrativo já autorizado, sem enfraquecer autenticação e sem inventar um interruptor; preparar correcção em frente. Não apagar estados para acomodar código antigo.
7. **Restauro de dados:** não restaurar indiscriminadamente sobre actividade posterior. Delimitar o incidente, preservar as novas escritas e aprovar reconciliação/restauro próprio. Sem `down` destrutivo, perda de decisões ou alterações a `mne`.
## Matriz de decisão final
| Plano | Estado actual |
| --- | --- |
| Aprovação técnica F1–F5 e continuidade em QA | Aceite com as ressalvas documentadas; integridade reconfirmada |
| Instalação privada de avaliação em homologação | Tecnicamente recomendável, condicionada ao preflight real, backup/restauro, delta/reversão e nova autorização expressa |
| Instalação efectuada / ambiente actualmente comprovado | Não; revisão/schema/runtime actuais do destino não verificados nesta passagem |
| GD/derivadas, visual/móvel e email real | Pendentes; não impedem por si só instalar código fechado para avaliação, mas impedem declarar essas funções aceites |
| Publicação operacional / NDA real | Bloqueados; sem autorização e sem activação |
Próxima acção: decisão da Tempo sobre a instalação privada da candidata e condições acima. Esta preparação não executa essa decisão nem declara concluídas as verificações no destino.
@@ -0,0 +1,105 @@
# Ofertas — verificação real pré-instalação
14/11/2026. Autorização exclusiva de inspeção, cópia actual e restauro isolado; nenhuma autorização de instalação ou publicação. Referências: preparação `d954c83b`, integração tecnicamente aceite `9a014268`, menus QA `3558647e`.
## Parecer: APTO COM CONDIÇÕES
**Execução posterior autorizada e interrompida:** [incidente do build Wayfinder e correcção QA](ofertas-instalacao-privada-incidente-wayfinder-20261114.md). O fast-forward exacto para `3558647e` foi autorizado e efectuado; o executor recusou a candidata por TS2395 antes da promoção. Homologação preservada em `96f51096`, 219 tabelas/417 ficheiros intactos, sem migração ou reversão. Correcção mínima validada apenas em QA, dependente de nova autorização de revisão. Os estados de entrada abaixo descrevem verificações anteriores e não substituem este resultado de execução.
**Tentativa após autorização expressa de instalar `3558647e`: BLOQUEADA ANTES DE INICIAR.** A condição 4 da autorização determina interromper quando o executor usa o último `main` e este difere da candidata. `git ls-remote` confirmou `main=96f510965f70581a4b41dd94463f598b77dd7cf5`, enquanto a revisão autorizada é `3558647e8d82092cd0e31eefbe8b9e5e9eacf573`. A homologação continua em `96f51096`, executor inactivo, zero pedidos. Não se fez push, pedido, instalação, migração ou reversão. A autorização de instalação foi recebida; falta decidir a integração controlada exacta em `main` depois desta paragem. Existe fast-forward possível, sem força ou inclusão dos commits documentais posteriores, mas o push pode accionar o executor e não foi executado. Evidência: `outputs/offers-authorised-install-20261114/entry-blocked.json`. O parecer técnico e o restauro anteriormente comprovado mantêm-se; esta é uma precondição operacional concreta, não uma reprovação dos testes.
O destino é compatível com a **avaliação privada** das Ofertas. As estruturas verificadas estão presentes, não há migrações/dependências novas no delta, GD/WebP está disponível no destino e o backup/restauro actual passou. A conclusão não autoriza instalar nem declarar aceite a publicação operacional.
A instalação futura tem de identificar uma revisão exacta e ocorrer numa janela sem integrações concorrentes: o executor actual segue o último `main`, em vez de fixar o SHA indicado no pedido. Se essa condição não puder ser garantida, **não se cria o pedido de instalação nem se publica a candidata em `main`**. Nenhuma alteração ao executor ou às suas configurações foi feita.
## Destino e compatibilidade comprovados
| Item | Observado no destino real |
| --- | --- |
| Endereço / revisão instalada e servida | `https://teste-investidor.tempo.co.mz` — `96f510965f70581a4b41dd94463f598b77dd7cf5` |
| Release resolvida | `/var/www/investmoz-homologacao/releases/96f510965f70581a4b41dd94463f598b77dd7cf5-8ee72062` |
| Base real | `teste_investidor`, porta 5432; PostgreSQL 17.11; PostGIS 3.6.4; pgcrypto 1.3 |
| Aplicação | PHP CLI 8.4.27; Nginx, PHP-FPM 8.4, PostgreSQL, fila existente e ClamAV activos |
| Executor | `/var/www/deploy/investmoz-pipeline.py`, serviço inactivo, path activo, zero pedidos pendentes na observação |
| Configuração | Perfis e circuito editorial activos; notificações desactivadas; zero destinatários autorizados; nenhuma configuração alterada |
| Segurança observada | `APP_ENV=local`, debug falso; cookies Secure e HttpOnly, SameSite lax; URL HTTPS; sem overrides APP_ENV/APP_DEBUG encontrados no FPM/Nginx |
| Reserva global do ambiente | `homologation_only=false`, modo configurado `perimeter`; não se presume reserva global activa a partir do nome do modo. As Ofertas privadas continuam a exigir autenticação/perfis/âmbito próprios. Não foi alterada esta configuração. |
| Capacidade inicial | Cerca de 24,6 GB livres; condição de capacidade do executor a reconferir antes de instalar |
Foram verificadas 19 relações de suporte, sem ausências nas colunas requeridas: Oportunidades/gestão/JSON/dono/organização, utilizadores e revisões de acesso, perfis, pedidos/aprovações de segurança, recursos, documentos/cifra/scan, edições/notificações, auditoria, tarefas, dossiers/contexto e rascunhos. Confirmados a FK das notificações, `portal_editorial_notice_subject` e `portal_editorial_op_notice_uq`. O destino regista 112 migrações.
O delta `96f51096..3558647e` não contém diferenças em `database/migrations`, `composer.lock` ou `package-lock.json`. **Não se prevê executar qualquer migração operacional.** Não se aplicaram seeders, concessões de permissões, alterações a `mne` ou alterações de estrutura no destino.
Evidências sanitizadas em `outputs/offers-preinstall-verification-20261114/target-runtime-schema.json`, `constraints-automatics.json`, `security-runtime.json` e `executor-status.json`. A release não é um checkout Git: a revisão foi confirmada pelo recibo do executor e pelo manifesto servido, não por um `git status` vazio.
## Publicação e automatismos
O módulo ainda não está instalado: não existem classes de preparação/operações/catálogo de Ofertas no destino, não há rotas `ofertas/offers` registadas, preparações privadas, projecções públicas ou registos publicados de Ofertas. Não há jobs ou falhas de `NotifyPrivateOfferDecision` na fila observada.
Na candidata, mantêm-se as barreiras aceites em QA: o modelo conserva o marcador privado e exige `draft`/`published_at=null`; o scope nativo exclui Ofertas; aprovação não publica; os consumidores públicos normais permanecem fechados; listagem/detalhe/API/mapa/CSV só foram demonstrados com injecção sintética guardada pela identidade da base isolada. A base real 5432 não satisfaz essa guarda. Nenhuma rota ou variável de ambiente foi aberta para executar esta verificação.
NDA permanece sem exemplar configurado e o agendamento optativo de expiração está desactivado. A instalação privada deve preservar estes estados. As provas de bloqueio das cinco fases e a correcção integrada foram reutilizadas; não se criaram Ofertas ou ensaios de publicação no destino real para voltar a testar o que já foi aceite.
## Backup actual e restauro executados
Backup criado em **14/11/2026 às 19:34:37, Africa/Johannesburg (17:34:37 UTC)**, em `/var/backups/investmoz-offers-preinstall-20261114/backup`, directório privado com modo 0700. A fonte foi lida por transacções `REPEATABLE READ READ ONLY`, com snapshot exportado partilhado entre o dump e as contagens/checksums. O arquivo inclui todos os schemas de aplicação; não se limitou a `public`.
| Prova | Resultado |
| --- | --- |
| Dump consistente | 219 tabelas; SHA256 `662c5d9f1fe7e01a5ccf7529c8c8e8f27205907d60d2da75a580b7b6c37c252e` |
| Ficheiros persistentes e configuração | 417 ficheiros verificados; arquivo SHA256 `f438f31f212e516442526b609bf5ac02cfd2f630a6ac2a2b6fc12f1f88717a42` |
| Código, assets e dependências da release anterior | Arquivo de 9.873 ficheiros regulares, 25.862.122 bytes; SHA256 `914bc32627a51ec9001d07e5ac31d5717719a4a7f93e43dc26189865962735ca` |
| Restauro da base | Nova instância `offers_preinstall_restore_20261114_test`, porta interna 55441, imagem PostGIS já existente e fixada por digest, rede `none`, sem porta publicada |
| Comparação do restauro | 219/219 tabelas com contagens/checksums exactamente iguais ao snapshot |
| Recuperação dos ficheiros | 417/417 ficheiros extraídos apenas para directório privado de ensaio e verificados por tamanho/hash; nenhuma extracção sobre a aplicação |
| Chaves e envelopes | Dois conjuntos de MFA recuperados com as chaves preservadas; zero documentos activos com `storage_encryption=laravel` nesta fonte, pelo que não se inventa uma prova de decifragem de originais inexistentes |
| Limpeza do ensaio | Container/volume criado para a prova removido após validação do seu identificador e isolamento; sem tocar no cluster real ou no QA local da prévia |
Configuração, segredos e dados do backup permaneceram no directório privado do servidor; não foram colocados no repositório ou nas respostas. Os recibos locais contêm apenas metadados e resultados sanitizados. O restauro usa `pg_restore` para reproduzir o snapshot na instância nova; não executa migrações do produto nem altera o schema `mne` operacional.
Na verificação posterior, os 9.873 ficheiros da release e os ficheiros persistentes/configuração mantêm-se iguais. Das 219 tabelas, 218 permanecem iguais ao backup; a diferença é uma sessão adicional. A reconciliação confirmou **51 sessões anteriores integralmente preservadas e sem alteração de campos, zero removidas, 52 actuais**. Não se sobrescreveu esta actividade concorrente para obter igualdade artificial. As operações desta verificação não executaram escritas de dados operacionais.
Recibos: `backup-summary.json`, `restore-summary.json`, `target-final-preservation.json`, `data-preservation.json`, `session-reconciliation.json`. Os procedimentos revistos de backup/restauro estão na mesma pasta de evidências. A cópia é válida para o instante registado; se a instalação ocorrer posteriormente, reconferir actividade e actualizar a salvaguarda quando necessário.
## Imagens e email
**GD/WebP:** presentes no CLI e no PHP-FPM do destino. O ensaio em memória de PNG 400×400 → descodificação → WebP → conferência de dimensões passou, sem ler/alterar uploads ou criar documentos operacionais. A ausência de GD continua a ser uma característica do PHP QA local, não um bloqueante de dependências deste destino. Isto não substitui a futura aceitação do processamento completo das Ofertas, quarentena/scans, derivadas, metadados e galeria com exemplos autorizados.
**Email:** SMTP configurado, mas `portal.notifications_enabled=false` e lista autorizada vazia. Nenhum email foi enviado. O job de Ofertas reconfirma destinatário na allowlist e acesso à versão. Falta designar uma caixa de teste e autorizar o ensaio/configuração correspondente. Preferir instância isolada com dados sintéticos, destinatário controlado e apenas o job identificado; não activar a flag partilhada nem escoar a fila acumulada para provar entrega. As actuais condições impedem o envio de Ofertas a utilizadores operacionais. A recepção real continua por verificar.
## Operações exactas previstas — não executadas
1. **Decisão da Tempo:** autorizar um SHA concreto. A integração aceite é `9a014268`; a prévia com menus é `3558647e8d82092cd0e31eefbe8b9e5e9eacf573`. O bundle congelado da preparação anterior contém apenas `9a014268`. Para instalar o que foi mostrado na prévia, a autorização terá de incluir expressamente `3558647e`; não substituir silenciosamente uma candidata pela outra.
2. **Entrada controlada:** reconfirmar revisão instalada `96f51096`, ficheiros sem drift, estruturas/flags, capacidade, backup/restauro e fila vazia. Acordar janela sem integrações concorrentes em `main`. Se não houver garantia de revisão exclusiva, não iniciar o executor.
3. **Integração Git:** só depois da autorização, fazer chegar a revisão exacta ao ramo `main` de `nala/Investidor-V2` pelo processo existente; não publicar simplesmente o HEAD local, não criar pedidos nesta verificação. A actualização pode desencadear o path activo, pelo que o push faz parte da instalação autorizada, não desta preparação.
4. **Executor existente:** `fetch` do ramo, verificação de drift e capacidade; criar release nova sob `/var/www/investmoz-homologacao/releases`; usar base QA isolada e versões fixadas; executar Composer sem scripts, descoberta de pacotes, guarda da base, suite estrutural `phpunit.investmoz.xml`, `npm ci`, build, tipos, deployment guard, unitários e testes frontend. Depois instalar vendor de produção a partir do mesmo lock e conferir plataforma. Nenhum `artisan migrate` operacional está previsto.
5. **Preparação da release:** validar ausência de `public/hot`; ligar os caminhos persistentes `.env`, `storage` e `public/storage` existentes; gerar o manifesto da candidata e aplicar apenas as permissões técnicas já previstas pelo executor aos directórios novos. Não alterar papéis/permissões de negócio nem configuração funcional.
6. **Promoção:** reconfirmar SHA e ausência de drift, trocar `current` atomicamente e verificar manifesto/homepage; só depois reiniciar a fila pelo comando existente `queue:restart` e actualizar `previous.json`/`installed.json`. Estas escritas de runtime/cache devem constar do recibo, separadas dos dados de negócio. Não executar publicação, importações, agendamento ou ensaio de email.
7. **Pós-verificação:** confirmar revisão/assets, serviços, logs, portas e preservação; conferir fecho público por todas as vias, acesso privado autorizado/recusado, continuidade no dossier e uploads com exemplares sintéticos autorizados; reconciliar cache/sessões e contagens/hash de dados protegidos. Registar quem fez a conferência desktop/móvel, revisão e resultado. Não concluir visual ou entrega de correio por inferência.
**Risco concreto do executor:** nas linhas 263–266, um avanço do ramo durante o build cria pedido para a revisão mais recente; nas linhas 290–298, os pedidos são processados seguindo `latest()`, não fixando o SHA nominal do ficheiro. Não se apresenta isto como uma recusa automática de revisões não autorizadas. É condição de utilização garantir a janela exclusiva e a coincidência do único `main` com a candidata autorizada; sem isso, a instalação fica bloqueada. A correcção do executor não foi iniciada nem autorizada nesta tarefa.
## Interrupção e reversão
Interromper antes de qualquer promoção se mudar a revisão/ramo, aparecer pedido concorrente ou drift, faltar espaço, divergir estrutura ou backup/restauro, falhar um teste/asset/guarda, houver proposta de migração/permissão/configuração não autorizada ou não for possível conservar publicação/notificações fechadas. Uma revisão nova de `main` invalida o âmbito concreto; não a aceitar como parte implícita da autorização.
O executor existente preserva a release servida durante o build e verifica drift antes da troca. Dispõe de reversão por `previous.json` e troca atómica, com health-check e reinício da fila; **nenhuma reversão foi executada no destino nesta tarefa**. A inspecção da sua implementação não equivale a ensaio de comutação na homologação.
- **Antes de qualquer uso das Ofertas:** o destino tem zero Ofertas; se isso se mantiver e não houver jobs/estados novos, uma reposição da release/configuração anterior compatível pode ser autorizada com os artefactos preservados, sem restaurar toda a base.
- **Depois de utilização:** não regressar cegamente a `96f51096` ou F4: desconhecem parte dos estados/guardas novos. Uma retirada, expiração, denúncia, versão ou grant novo exige recuperação compatível que mantenha os bloqueios e históricos, preferindo correcção em frente. Não apagar dados ou jobs para acomodar código antigo.
- **Incidente de dados:** preservar a actividade posterior e delimitar uma reconciliação/restauro próprio. Não substituir a base por este dump sem autorização adicional. Não usar migrações `down` nem rollback destrutivo de schema.
## Saldo e autorização necessária
| Dimensão | Estado |
| --- | --- |
| Integração técnica em QA e menus | Provas aceites preservadas; menus QA verificados; sem nova ronda geral |
| Destino, estruturas, GD e capacidade | Conferidos, compatíveis para avaliação privada |
| Backup actual e restauro isolado | Aprovados, com reconciliação da sessão nova e recuperação das chaves |
| Instalação privada | **Não autorizada/não executada; APTO COM CONDIÇÕES** |
| Condição operacional do Git | SHA autorizado e janela sem integrações concorrentes obrigatórios; caso contrário bloquear |
| Publicação operacional | Bloqueada; não autorizada; publicador efectivo não implementado |
| Visual/móvel, galeria e email real | Pendentes de conferência/ensaio específicos; sem utilizadores operacionais notificados |
| NDA, comprovativos e avisos legais/validade | Decisões pendentes, mantidas fechadas nos comportamentos dependentes |
A decisão seguinte necessária é a autorização expressa de instalação privada do SHA escolhido, identificando se inclui os menus `3558647e`, mantendo publicação, NDA, notificações e agendamento nos estados acima e respeitando a janela exclusiva de Git. Esta verificação não concede essa autorização nem a solicita novamente para acções já executadas de backup/restauro.
@@ -1,3 +1,13 @@
## 11/11/2026 — Mineração e Energia instaladas em f2767818
Entrega autorizada de542ac2ff instalada na revisão `f27678187669d18ec063a01473dc8788c713448d`. A revisão inclui a correcção técnica dos hashes e dos geradores para usar os mesmos bytesJSON emWindows eGit/Linux,detectada antes de alterar a instalação;conteúdo,interface e âmbito preservados. Os sete casos nativos/166asserções voltaram a passar sobre os bytes canónicos. Pacote original preservado;pacote canónico com aplicação/reversão exactas. Recibo `outputs/sector-resource-20261111/installation.json`,conferido em2026-11-11T12:19:59.901666+00:00.
**Disponível:** `/sectores/mineracao` —11grupos/10áreas de cadeia de valor/6projectos de referência; `/sectores/energia-especifica` —7segmentos/11áreas/10projectos/24fontes ligadas. Localização e grupo/segmento → área → dossier novo ou existente → gravação/recuperação → evidência paraSWOT. As duas referências coexistem no mesmo dossier sem substituir escolhas. Estados,períodos,divergências e locais por definir preservados;sem criar direitos,coordenadas,ofertas ou projectos reais.
**Instalação conferida:** backup consistente e restauro isolado190tabelas;369ficheiros restaurados e conferidos porhash;22ficheiros da entrega idênticos aoGit;revisão e nove entradasHTTP200,incluindo as duas páginas sectoriais. Configuração intacta,zero migrações/importações. Nove tabelas protegidas com totais/checksums idênticos ao backup,incluindo instituições/versões/interesses,dossiers,simulações,educação,oportunidades e sectores;270instituições publicadas preservadas.
**Saldo:** implementação e instalação deste recorte encerradas;conferência visual permanece pendente. Ofertas concretas,implantação mais precisa e dados adicionais dependem do respectivo investimento,não se presumem das referências. Restantes módulos e resíduos fiscaisL1–L7,L8,L9–L16 mantêm o saldo anterior;portal integralmente concluído não declarado.
## 11/11/2026 — Energia incorporada pelo mesmo percurso de Mineração, em QA ## 11/11/2026 — Energia incorporada pelo mesmo percurso de Mineração, em QA
**Implementação:** o sector existente `/sectores/energia-especifica` passa a apresentar o documento fornecido com7segmentos,11áreas de cadeia de valor,10projectos de referência,4riscos e24fontes com as ligações originais. Gás/upstream/construção/midstream/industrialização/mercado final,redes/hídrica,solar/baterias/eólica,hidrogénio,biocombustíveis e parques verdes mantêm o seu contexto. As três centrais solares aparecem uma vez cada,com capacidade,promotores,financiamento e estado referidos. Metoro conserva56milhõesUSD e47,3milhõesEUR,atribuídos às fontes,sem conversão ou escolha silenciosa. Previsão,operação,desenvolvimento,estudo e proposta não são tratados como estados equivalentes. **Implementação:** o sector existente `/sectores/energia-especifica` passa a apresentar o documento fornecido com7segmentos,11áreas de cadeia de valor,10projectos de referência,4riscos e24fontes com as ligações originais. Gás/upstream/construção/midstream/industrialização/mercado final,redes/hídrica,solar/baterias/eólica,hidrogénio,biocombustíveis e parques verdes mantêm o seu contexto. As três centrais solares aparecem uma vez cada,com capacidade,promotores,financiamento e estado referidos. Metoro conserva56milhõesUSD e47,3milhõesEUR,atribuídos às fontes,sem conversão ou escolha silenciosa. Previsão,operação,desenvolvimento,estudo e proposta não são tratados como estados equivalentes.
@@ -0,0 +1,172 @@
## 13/11/2026 — erro 500 corrigido e verificado no PHP web, revisão 4988ca89
A recusa de acesso anterior estava corrigida, mas a abertura do instalador revelou outra incompatibilidade: o PHP-FPM bloqueia `proc_open`; a criação de `Symfony Process` na pré-visualização provocava 500. Os registos de 13:49:58 e 13:50:10 identificaram esta causa. Conferência de leitura confirmou que não existiam tabelas demográficas nem recibos de importação no destino.
A revisão `4988ca89dc6dddbde4efdada24546767ffcdd411` permite consumir preparações CLI privadas, vinculadas ao SHA256 do original, ano de referência, versão do preparador, prévia e fluxo NDJSON. Conserva a cópia do original, análise antimalware actual, correspondência territorial, confirmação com MFA, gravação por blocos, verificação de contagens e histórico. O CLI existente ganhou a emissão do recibo; não recalcula fórmulas nem altera estatísticas. Sem preparação válida e sem suporte a processos, a interface informa o impedimento, em vez de provocar 500. Não foi alterada a configuração de segurança do PHP.
Doze originais foram preparados fora do servidor web e conferidos: 920456 observações esperadas, ainda privadas e não importadas. Passaram dois testes/20 asserções com `proc_open` desactivado, 14 testes/52 asserções dos consumidores, oito testes do preparador e análise PHP. Delta e reversão foram conferidos; pacote `outputs/entrega-demografia-cli-20261113.zip`, SHA256 `4b3be263f34d6b9675b77f1ad25534f25da927d4046f57798644dce9fde24244`, preservando os pacotes anteriores.
**Instalado e verificado:** revisão confirmada por HTTPS às 14:06:11 Africa/Johannesburg. A verificação privada pelo socket PHP-FPM existente confirmou às 14:05:27: SAPI `fpm-fcgi`, processos continuam bloqueados, preparações disponíveis, antimalware disponível e hashes de código/configuração correctos. Não criou actor/sessão, endpoint HTTP público, permissões, migrações ou importações. Provas `outputs/demography-20261113/homologacao/cli-installation-verified.json` e `fpm-runtime-verified.json`; prova anterior conservada antes da renovação.
**Pendente:** o proprietário abrir `/management/demography/install` e confirmar os lotes na sua sessão com MFA recente; depois conferir 12 lotes/920456 observações na homologação. A verificação final ainda encontrou esquema ausente e zero recibos autenticados, pelo que não se declara esta incorporação concluída. A conferência visual na sessão do proprietário mantém-se distinta da verificação técnica. Novos exemplares num PHP web sem processos requerem a preparação CLI correspondente, com o mesmo recibo e controlos, antes da confirmação no editor.
## 13/11/2026 — recusa de acesso demográfico corrigida em df543a44
A conta tinha o estatuto de superutilizador designado, email verificado e MFA configurado, mas não passava no gate legado `manage-portal`. Os registos confirmaram autenticação concluída seguida de403 em `/management/demography/install` às13:42:56(Africa/Johannesburg). A rota antiga aplicava o filtro de catálogo antes da autorização própria do instalador. A correcção `df543a4414d1641422a45262837435429ddae9d6` remove apenas esse filtro redundante das rotas demográficas e reconhece os superutilizadores existentes no importador; mantêm-se autenticação, email verificado, precedência das restrições do Auditor, autorização por acção e MFA de sessão para escrita. Não foram atribuídos papéis nem alteradas contas/permissões.
Revisão confirmada porHTTPS no servidor às13:48:56. Três testes focados/17asserções aprovados;pipeline Git concluída. Conferência de leitura confirmou ambos os titulares designados autorizados, destino correcto e hashes actuais do código/configuração. A prova anterior de instalação foi conservada e a prova actual renovada para esta revisão, evitando um bloqueio de botão por código desactualizado. Recibo `outputs/demography-20261113/homologacao/access-correction-verified.json`.
**Estado:** correcção instalada; resta verificar a abertura na sessão real do proprietário e executar as confirmações autenticadas dos lotes. O esquema demográfico remoto ainda não estava instalado nesta verificação; não se declaram os12lotes/920456observações disponibilizados na homologação. A operação local continua preservada. Não repetir preparação, atribuir permissões ou reinstalar os recortes anteriores.
## 13/11/2026 — revisão b20068d0 instalada em teste-investidor; dados por confirmação autenticada
Instalação Git concluída às **13:34:06 Africa/Johannesburg (11:34:06 UTC)** em `https://teste-investidor.tempo.co.mz`, revisão `b20068d04bc70e8fa94b90692e07afc60b406910`. `/build/revision.json`, `/localizacoes/demografia` e `/api/v1/investment/demography` responderam HTTP200, com TLS verificado. O código e a configuração estão disponíveis; **isto não equivale a ter os 12 lotes importados neste destino**.
Backup/restauro conferiu 190 tabelas e 369 ficheiros. Comparação posterior confirmou as onze tabelas de negócio protegidas sem alterações, incluindo instituições, versões e interesses, dossiers, oportunidades e projectos. Conferência territorial: 168 correspondências existentes, 15 acréscimos expressamente autorizados, nenhuma correspondência por resolver; 12 originais privados por SHA256, total esperado 920456 observações. Apenas três parâmetros próprios de demografia diferem da configuração anterior: activação, pasta privada de originais e interpretador Python. Nenhuma permissão, conta ou segredo foi substituído.
O instalador conserva MFA/autorização e confirmações por coorte. Falta o proprietário executar em `/management/demography/install`: «Instalar e disponibilizar» (três exemplares), regressar ao mesmo endereço para «Disponibilizar restantes províncias» (oito) e, finalmente, «Disponibilizar Zambézia». Conferir depois recibos autenticados, 12 lotes/920456 observações, consulta por localização e preservação dos dados. Não repetir etapas concluídas nem substituir o cadastro ou a base remota pela base local.
Recibo `outputs/demography-20261113/homologacao/installation-verified.json`; pacote imutável `outputs/entrega-demografia-homologacao-20261113.zip`, SHA256 `fa6ce1fc141cfec90d149c00946cea17330c09fd6bc5d8bbb1cb2abfa97f9520`, criado antes da instalação. Delta e reversão conferidos em índice temporário; índice original preservado. A conferência visual administrativa permanece separada. As menções históricas abaixo a instalação remota pendente estão substituídas por este recibo; a incorporação remota dos dados continua por confirmar.
## 13/11/2026 — entrega remota autorizada, em execução
Destino autorizado pelo proprietário: `https://teste-investidor.tempo.co.mz`, base `teste_investidor:5432`. A revisão anterior confirmada é `f2767818`; a demografia ainda não estava instalada nesse servidor. Backup de 190 tabelas e 369 ficheiros com restauro isolado integral aprovado. Doze originais privados transferidos e conferidos por SHA256, sem copiar a base local. O exemplar partilhado actual de Inhambane diverge do aprovado; foi usada a cópia privada aceite, de hash `51a9b91dfb6e32e5b49def115cb007f21faa97b3d0584b16ef911b22b89bd818`, preservando ambas as versões.
Adaptação delimitada: activação explícita de consulta na homologação, identidade real da base conferida e pasta privada configurável para originais. Permanecem autenticação/MFA, backup/restauro, controlo de integridade, operações idempotentes e preservação dos dados. Testes focados: 13/45 asserções de consumidores, 5/12 dos controlos de instalação e 1/6 do destino remoto. A entrega Git não executa a importação nem a aprovação dos lotes: os dados continuam sujeitos à operação autenticada existente. Instalação remota e conferência de contagens ainda por confirmar neste ponto do histórico.
## 13/11/2026 — Zambézia instalada; doze lotes demográficos verificados
A operação autenticada daZambézia terminou às **11:33:38Africa/Johannesburg (09:33:38UTC)**. Verificação posterior exclusivamente de leitura confirmou `Zambezia.xlsx`,SHA256 `8d502e25e1e1fec0cc381ba0aa47b35cfa1eea586de53cb69ffa201d3b5ffeb7`,lote `9c8f1c48-a29d-4ca7-9ae5-23bd8ca2d05b`,estado `approved` e126684observações de2017–2050. Recibo autenticado confirmado;nenhuma repetição da importação pelo agente.
**Disponível no portal operacional local `investemoz:5432`:12lotes aprovados,total920456observações.** Todos os ficheiros provinciais fornecidos foram incorporados,além do exemplar nacional. A consulta provincial daZambézia devolve dados e os cinco distritos autorizados(Dérre,Luabo,Mocubela,Molumbo,Mulevala) estão consultáveis. Onze lotes anteriores preservados por contagens/SHA de metadados,observações e eventos,assim como as oito tabelas de negócio protegidas. Não há pendência de importação desta província.
Provas `outputs/demography-20261113/zambezia/installation-status.json`,`verification.json` e `outputs/demography-20261113/operational-status.json`. As advertências/unidades ausentes dos exemplares anteriores continuam preservadas;nenhuma correcção de estatísticas,recálculo de fórmulas ou preenchimento de lacunas. O novo original não apresentou advertências detectadas. Permanecem separados a conferência visual administrativa,indicadores sem valores no acervo e a instalação em ambientes diferentes deste portal local. Não se declara o portal inteiro concluído.
Este registo substitui as pendências históricas de ficheiro em falta ou operação autenticada daZambézia descritas abaixo,sem apagar o histórico.
## 13/11/2026 — oito províncias concluídas; Zambézia fornecida e preparada separadamente
A extensão anterior foi executada comMFA às11:18:05Africa/Johannesburg (09:18:05UTC) e verificada em leitura:11lotes aprovados/793772observações,recibo autenticado único,oito séries provinciais2017–2050 disponíveis,oito distritos novos consultáveis e preservação integral das oito tabelas de negócio e dos três lotes originais(importações/observações/eventos). Não foi repetida a importação. Recibo `outputs/demography-20261113/remaining-provinces/installation-status.json`;essa extensão deixa de ser pendência.
O proprietário forneceu depois `Zambezia.xlsx`,SHA256 `8d502e25e1e1fec0cc381ba0aa47b35cfa1eea586de53cb69ffa201d3b5ffeb7`:126684observações de2017–2050,sem advertências detectadas. Autorizou expressamente Dérre,Luabo,Mocubela,Molumbo,Mulevala;referências sem códigos oficiais ou geometrias inferidos. Restantes correspondências únicas no cadastro existente,sem nova fusão ou renomeação.
Coorte separada `zambezia` no mesmo instalador:uma entrada,sem reimportar os onze lotes ou invalidar a recuperação dos recibos das coortes anteriores. O estado protegido abrange agora as793772observações anteriores,metadados e eventos,conferidos em páginas500. O backup/restauro mantém comparação exacta dos bytes com cursores de250linhas e ordenaçãoC,sem materializar tabelas inteiras emPHP. Novo backup/restauro299tabelas/1268ficheiros. Dois testes focados/32asserções com limite256MiB confirmaram acréscimo126684,total920456/12lotes,repetição sem duplicação,preservação dos onze lotes,MFA e os cinco distritos consultáveis;tipos,análisePHP e build aprovados. Provas anteriores de importação e integridade NDJSON reutilizadas,não reexecutadas globalmente.
**Estado:** código/interface e readiness disponibilizados. Falta a operação autenticada do proprietário no botão **Disponibilizar Zambézia**,em `/management/demography/install`,e conferir o recibo. Os onze lotes anteriores permanecem disponíveis. A indicação histórica de ficheiro daZambézia em falta está ultrapassada pela recepção deste original;não confundir preparação com importação concluída. Recibos `outputs/demography-20261113/zambezia/verification.json`, `release-readiness.json` e posterior `installation-status.json`. Conferência visual administrativa e ressalvas das séries anteriores permanecem separadas.
## 13/11/2026 — restantes províncias preparadas; correcção de memória verificada
Pedido expresso de instalar os oito ficheiros disponíveis:Cabo Delgado92808,Gaza82618,Maputo Província49504,Maputo Cidade44064,Nampula131400,Niassa93632,Sofala77108,Tete67932. Acréscimo previsto639066observações,total793772com os três lotes iniciais. Zambézia não tem ficheiro provincial no acervo fornecido;os dados nacionais2017 não são declarados cobertura anual dessa província. O proprietário autorizou explicitamente Chongoene/Limpopo/Mapai(Gaza),Larde/Liupo(Nampula),Chimbonila(Niassa),Doa/Marara(Tete),sem códigos oficiais ou geometria inferidos. Confirmou que os rótulos Nampula/Lichinga/Xai-Xai se ligam às cidades,preservando Rapale/Chimbonila/Limpopo separados. Correspondências históricas são apresentadas antes da confirmação autenticada;cadastro existente não é renomeado ou fundido.
Leitor adaptado aos títulos “Cidade da”/“Distrito Municipal”,conservando cabeçalhos e valores originais. Células com fórmulas usam somente a cache numérica do original,com advertência e fórmula conservada;nenhuma fórmula é executada/recalculada e cache ausente continua bloqueante. Valores em falta e divergências não são substituídos. Três lotes aprovados permanecem intactos e protegidos pela extensão,que não reexecuta a migração nem cria permissões.
A tentativa operacional às11:07foi revertida por esgotar256MiB na conferência de preservação. Corrigidas leitura/hash em páginas500 e importação porNDJSON vinculado à prévia porSHA256,com confirmação transaccional de contagens e compatibilidade com préviasJSON antigas. Relatórios completos permanecem guardados;consulta pública carrega contagens de advertências uma vez por lote. A interface passa a distinguir resposta sem reciboJSON e orienta conferência antes de repetir.
**Verificação:** oito testes do leitor;seis testes de instalação/50asserções incluindo639066novas observações,11lotes/793772total,preservação e replay com o mesmo limite256MiB do servidor;12testes de integração/42asserções,incluindo adulteração e contagemNDJSON;tipos,análisePHP focada,lint e build aprovados. Novo backup/restauro299tabelas/1157ficheiros. Código/interface e readiness actualizados;nenhum dado adicional aplicado pelo agente. Resta a operação autenticada no botão **Disponibilizar restantes províncias** em `/management/demography/install` e a conferência do recibo. Os três lotes originais continuam disponíveis. Provas `outputs/demography-20261113/remaining-provinces/verification.json`,`release-readiness.json` e`installation-status.json`.
## 13/11/2026 — instalação demográfica concluída e verificada no portal operacional local
A operação autenticada foi concluída em **13/11/2026 às10:39:19 Africa/Johannesburg (08:39:19UTC)** no destino confirmado `investemoz:5432`. Verificação posterior exclusivamente de leitura confirmou os três identificadores apresentados pelo operador,estado `approved`,hashes exactos dos originais e **154706observações**:Manica71604,Inhambane82620,nacional482. Recibo autenticado único,sem repetição da instalação/importação. A prova anterior desactualizada após a correcção do interpretador foi reconciliada com a versão actual;cinco testes focados das guardas/12asserções passaram emQA,sem alterar MFA ou dispensar conferências.
**Utilizável:** `https://portal-investidor.test/localizacoes/demografia`. GONDOLA eGURO,sectorIndústria,2017–2050 devolveram estado disponível,uma série e1836valores por distrito;Macate eVanduzi constam dos territórios consultáveis. As oito tabelas protegidas constantes do recibo mantêm contagens eSHA idênticos:projectos,dossiers/oportunidades,necessidades de competências,clusters,preparação e simulações. As seis advertências deManica,24valores ausentes e sete cabeçalhos sem unidade continuam preservados;nenhuma correcção estatística presumida.
**Estado actual:** instalação/importação e conferência técnica deste recorte concluídas. Restam conferência visual administrativa e ressalvas estatísticas já identificadas;outras províncias/indicadores e instalação noutros ambientes não estão concluídos por esta operação. A API sem selecção territorial devolve indisponível por ausência de contexto;a consulta comGONDOLA/GURO foi confirmada disponível. Não confundir esse estado inicial com falha da instalação.
Recibos `outputs/demography-20261113/installation-verified.json` e `operational-status.json`;este registo substitui as pendências históricas de execução abaixo,sem apagar as provas anteriores.
# Demografia INE — entrega em desenvolvimento e QA, 13/11/2026
## Adenda posterior — homologação e disponibilização autorizadas em 13/11/2026
O proprietário pediu «homologar e disponibilizar» e confirmou o destino **portal operacional local, `investemoz` em `127.0.0.1:5432`, incluindo a instalação das tabelas, importação dos três ficheiros e Macate/Vanduzi**. Esta decisão substitui, para este recorte concreto, o limite anterior de QA. Não autoriza alterações noutros produtos ou concessão de permissões.
**Estado actual:** homologação funcional em QA aprovada; frontend e instalador publicados no portal operacional. As tabelas e os dados no activo aguardam a execução autenticada em `https://portal-investidor.test/management/demography/install`, com a identidade de instalação e MFA de sessão existentes. Não foi criada ou simulada uma sessão de utilizador operacional. Última conferência: zero observações demográficas no activo e nenhuma execução autenticada registada. A página pública responde, mas ainda comunica indisponibilidade de séries neste destino.
Preparação realizada:
- Backup consistente do activo e restauro numa base nova `demography_restore_20261113_075052_test:55441`: **296 tabelas e 872 ficheiros conferidos**, preservados em armazenamento privado. A primeira comparação diferiu apenas na ordenação das mesmas 14 sessões entre collations dos servidores; a comparação pelo multiconjunto exacto de bytes passou. Nenhuma linha foi omitida ou normalizada para obter igualdade.
- Cinco testes adicionais de instalação, 21 asserções, aprovados na cópia restaurada. Exercitaram migração exacta, importação real de 154 706 observações, novos distritos, repetição sem duplicação, fontes e preservação do estado protegido. As identidades de ensaio e todas as mutações do ensaio ficaram numa transacção QA revertida; não foram usadas identidades operacionais para simular autorização.
- Os 11 testes de regressão da integração voltaram a passar, agora com 36 asserções. Mantêm-se as seis verificações do leitor e os 38 testes frontend já aceites para o recorte inalterado. TypeScript, análise PHP focada e build de disponibilização aprovados. As falhas globais preexistentes continuam separadas.
- A pendência TOTP foi delimitada ao envelope criado para a **conta sintética QA**: `encryptString` era incompatível com o `decrypt` do Fortify. Corrigida apenas essa conta, com `encrypt` serializado; palavra-passe, TOTP real e acesso à prévia administrativa passaram por HTTP autenticado. Nenhuma credencial, autenticação ou permissão operacional foi alterada. Conferência visual administrativa continua dependente da sessão do operador, pois o acesso automatizado ao navegador está indisponível.
- Assets produzidos primeiro em `outputs/demography-20261113/release-build`, conferidos e copiados para `public/build`, mantendo os chunks anteriores para compatibilidade de páginas abertas. Configuração `.env` preservada. Recibo `release-readiness.json`; dump, ficheiros e readiness autenticada mantidos privados. A prova vincula código, manifest, hashes dos assets, destino, backup/restauro e ensaio.
- O instalador reutiliza `PortalProfileInstallation::authorize`, incluindo o MFA efectivamente concluído na sessão. A migração operacional só é permitida dentro dessa acção; não há comando CLI de migração operacional sem actor. A importação utiliza a prévia homologada, confere os hashes exactos e contagens, faz scan dos originais e regista actor, motivo e recibo. Reenvios são serializados e idempotentes. Não é executada qualquer migração pendente fora deste recorte.
- A autorização específica de Macate/Vanduzi é reflectida como origem `ine-demography`, com hash do exemplar e auditoria. Os dois identificadores territoriais ficam disponíveis nos selectores existentes, sem inventar código oficial, limites, geometria, vigência jurídica, licenças ou direitos. A disponibilidade territorial mantém-se independente da desactivação de uma série estatística.
- Smoke test do kernel operacional: instalador anónimo redirecciona para autenticação; página pública e API respondem 200. Esta prova é de aplicação, não de navegador/TLS. O cliente HTTPS Windows não conseguiu conferir revogação e o cliente Python recusou a cadeia por ausência de Authority Key Identifier; não foram desactivadas verificações TLS nem alterados certificados. A navegação real deve ser conferida no navegador habitual, sem ultrapassar avisos de segurança.
**Reversão preparada:** o backup privado contém a configuração, código, documentos e build anteriores. Se for necessário suspender a demografia depois da instalação, desactivar as séries pelo fluxo autenticado, conservando observações e eventos; não usar rollback destrutivo ou eliminar os novos distritos ligados a investimentos. Para reverter a apresentação, repor os manifests/assets do arquivo conferido. Recuperação integral da base exige intervenção delimitada com o dump privado, nunca reposição indiscriminada sobre actividade posterior.
**Acção final do operador:** abrir o instalador, autenticar-se com a conta habitual e MFA, conferir o destino/ficheiros/advertências, confirmar e executar «Instalar e disponibilizar». Depois conferir o recibo autenticado, três lotes aprovados, 154 706 observações, consulta de duas localizações e contagens/checksums protegidos. A decisão de autorização já está registada; esta etapa cumpre o controlo de execução autenticada existente.
Ficheiros adicionais desta adenda: `app/Services/DemographicInstallation.php`, extensão da guarda de `DemographicImport` e da migração, referências em `InvestmentGeography`, métodos/rotas do controlador, `resources/js/pages/management/demography-install.tsx`, rótulos administrativos, `tests/Feature/DemographicInstallationTest.php`, ferramentas de backup/preparação/estado da entrega. Não foi necessário alterar as regras de autenticação.
O restante texto abaixo conserva a entrega QA original e as suas evidências históricas; a adenda acima prevalece quanto à autorização e ao estado actual.
Implementação local, sem instalação ou importação no activo. Base nova `investmoz_demography_test`, PostgreSQL/PostGIS em `127.0.0.1:55441`; servidor QA `http://127.0.0.1:18813`. `.env` operacional não foi alterado. A configuração operacional aponta para `investemoz:5432`; foi usada exclusivamente uma transacção de leitura para inventariar referências territoriais públicas, encerrada com rollback. Não foram copiados investidores, dossiers, credenciais ou outros dados privados.
## Resultado e componentes reutilizados
- Modelo nacional com observações por território/ano/indicador/sexo/idade/residência, unidade, tipo estatístico, valor original exacto, exemplar/hash, folha/célula, importação, validação e histórico. Edições coexistem; não há eliminação ou substituição automática.
- Leitor XLSX por relações, títulos e cabeçalhos; valores decimais conservados como texto numérico exacto. Não executa fórmulas/macros. Duas etapas: preparação privada com scan ClamAV e prévia; confirmação transaccional. Aprovação para utilização e desactivação são acções separadas e auditadas. Hash único do original e chave única das dimensões impedem duplicação.
- Cadastro territorial, identidades e regras de acesso existentes reutilizados. Normalização de acentos/espaços e prefixos administrativos sem correspondência aproximada; ambiguidades ficam pendentes. Correspondências manuais verificam nível, província e fusões indevidas. Macate e Vanduzi acrescentados **somente em QA**, com referência ao hash de Manica, por resposta expressa do proprietário nesta sessão. Nenhuma alteração no cadastro operacional.
- Consulta pública `/localizacoes/demografia`, API `/api/v1/investment/demography`: até quatro territórios, sector, ano inicial/final, horizonte até 50 anos, edições separadas e anos em falta. Diferenças numéricas só para mesmo nível, ano, unidade, tipo e identificação de edição; explicação e fontes visíveis. Sem extrapolação ou percentagem de sucesso.
- Consulta demográfica incorporada no simulador existente, nas oportunidades, na comparação de oportunidades e nas localizações documentadas de projectos/clusters. A comparação existente conserva os seus factores económicos/sectoriais; a demografia acrescenta evidência, sem alterar regras fiscais. Localização principal, implantação e área de influência não são fundidas.
- `InvestmentPreparationWorkspace` acrescenta a evidência demográfica às fontes da SWOT do contexto escolhido; conclusões e revisão continuam no fluxo existente. O contexto deliberado e os dados fiscais não são sobrescritos.
- Administração em `/management/demography`: envio, prévia, correspondências, advertências, confirmação, aprovação QA, desactivação e eventos. Mantidos `manage-portal`, exclusão do auditor, MFA e guarda da identidade real da base QA. Não são concedidos novos papéis ou direitos.
Migração: `database/migrations/2026_11_13_100000_create_demographic_series.php`, com três tabelas: `demographic_imports`, `demographic_observations`, `demographic_events`. A migração só admite QA isolado e recusa rollback que apague histórico existente. O código de consulta está limitado a local/testing; esta entrega não autoriza activação em produção.
## Importações efectivas
| Original | Observações gravadas | Territórios | Anos | Rejeições numéricas |
| --- | ---: | --- | --- | ---: |
| Manica.xlsx | 71 604 | 1 província + 12 distritos | 2017–2050 | 0 |
| Inhambane.xlsx | 82 620 | 1 província + 14 distritos | 2017–2050 | 0 |
| Dados demograficos.xlsx | 482 | 11 províncias | 2017 | 0 |
| Total | 154 706 | 11 províncias + 26 distritos distintos | — | 0 |
Originais preservados, copiados apenas para armazenamento privado QA; scans reais ClamAV 1.5.4 com resultado `clean`. Hashes, correspondências individuais, folhas, anos, recibos e tempos em `outputs/demography-20261113/verification.json`. Preparar/importar/verificar repetição/aprovar demorou aproximadamente 14,1 s para Manica, 15,3 s para Inhambane e 1,5 s para o ficheiro nacional nesta máquina; não é um benchmark de capacidade nacional. Repetir a confirmação manteve as contagens.
Inventário integral da pasta demografia: 22 ficheiros, incluindo originais adicionais, PDFs, documentos e ficheiros temporários, com tamanho e SHA256 em `source-inventory.json`. Os restantes exemplares foram inventariados, sem importação ou activação automática. Não se pressupõe a mesma estrutura para todas as províncias.
## Resultados estatísticos e limitações concretas
Inhambane passou a reconciliação de sexo, residência, grupos etários e soma dos distritos. Em Manica há seis advertências em **2031**, preservadas:
| Quadro/célula | Verificação | Diferença (total menos parcelas) |
| --- | --- | ---: |
| Mossurize, E204 | Sexo | 200824.43870750687 |
| Mossurize, C204 | Residência, homens | 99260.016965195867 |
| Mossurize, D204 | Residência, mulheres | 101564.421742311014 |
| Mossurize, F204 | Soma etária, homens urbanos | -99260.01696519587193 |
| Mossurize, G204 | Soma etária, mulheres urbanas | -101564.42174231099699 |
| Manica, B4 | Total provincial versus distritos | -69384.494114521566 |
Não foram corrigidos valores por inferência. Tolerância de reconciliação: `0.000001` pessoa, exclusivamente para resíduos de representação numérica do Excel; não arredonda valores gravados. As advertências acompanham a série, inclusive quando aprovada em QA, e devem ser consideradas ao usar o ano afectado.
O ficheiro nacional contém população por sexo/residência, três grandes grupos etários e seis famílias de indicadores vitais: esperança de vida, mortalidade infantil, mortalidade bruta, natalidade, fecundidade e crescimento. Esperança de vida e mortalidade infantil estão discriminadas por sexo. Há **sete cabeçalhos de unidade ausente**, abrangendo cinco famílias: três colunas de mortalidade infantil e quatro outros indicadores. Esses valores mantêm unidade `unspecified`; a esperança de vida usa anos. Não são assumidas percentagens ou valores por mil.
Há 24 valores ausentes de população urbana/rural de Maputo Cidade, mantidos ausentes. A folha de população não imprime ano: 2017 foi fornecido expressamente nos metadados de importação, em coerência com a adenda; as folhas etária e vital imprimem 2017. A edição de publicação não consta dos exemplares: o rótulo declara essa ausência e o SHA256 identifica cada versão. As séries anuais dizem “projectada”; o ficheiro nacional não declara observado/projectado, pelo que fica `unspecified`.
Reconciliação independente de população total e grandes grupos etários de Manica/Inhambane em 2017: **72 comparações coincidentes dentro da precisão**, zero divergências/incompletudes nestas comparações. As somas das faixas etárias são evidência calculada de reconciliação, não novos valores oficiais importados. A igualdade numérica não funde exemplares nem prova igualdade de metodologia/edição.
`Outros indicadores` contém somente 11 títulos, sem valores. Escolaridade, pobreza, água, saneamento e electricidade podem usar o modelo por indicador/unidade/dimensões, mas nenhum valor foi inventado. População em idade activa não é mão-de-obra disponível/qualificada; população residente não é procura efectiva ou poder de compra. Densidade não é calculada sem área territorial aplicável.
## Verificação
- Leitor: seis testes automatizados de cabeçalhos deslocados, decimais exactos, duplicados, incompletude, totais e idade — aprovados.
- Integração: 11 testes — aprovados. Cobrem consulta pública, versões, anos ausentes, comparação exacta, confidencialidade territorial/ancestral, estados aprovados/desactivados, permissões, MFA, guarda QA, idempotência, fontes SWOT, hash, chave única e preservação do histórico no rollback.
- Regressão existente: 38 testes frontend de mapa, preparação/SWOT e formulário/cálculos do simulador — aprovados. Não foi executada a suite integral; não se declara aceitação integral do portal.
- TypeScript e análise PHP focada nos serviços/controlador alterados: aprovados. Compilação em `public/build-qa-opportunities`: aprovada; os assets operacionais não foram compilados/publicados.
- Verificações globais: `npm run check` encontrou problemas de formatação em 56 ficheiros, incluindo ficheiros intactos; os quatro componentes/páginas novos foram formatados e passaram lint focado. PHPStan global encontrou 19 erros em `GlobalSearchController`, `FederatedSearch` e `SearchTerms`, ficheiros não alterados por esta tarefa. Estes problemas preexistentes não foram ocultados ou corrigidos fora do âmbito.
- Consulta pública conferida visualmente em Chromium a 1440 e 390 px, com filtros mantidos, duas tabelas, comparação e sem transbordo da página. Conferência visual administrativa pendente: a conta sintética chegou ao desafio TOTP, mas duas tentativas não avançaram após o código. O diagnóstico conservou URL e apresentação; essa frente foi interrompida sem contornar MFA. Alternativa concreta: conferência manual com login/MFA QA, mantendo os testes HTTP já aprovados.
- Provas: `verification.json`, `source-reconciliation.json`, `frontend-regression.log`, `build.log`, `browser-public-verification.json`, `browser-admin-verification.json` e screenshots em `outputs/demography-20261113/`. Conferência visual e saldo final registados no manual existente.
## Homologação e próximas províncias
1. Usar exclusivamente o servidor/base QA identificados acima. As credenciais da conta sintética de ensaio permanecem privadas; não são contas operacionais.
2. Em Administração → Demografia INE, consultar os três lotes, as correspondências e as advertências. Repetir a confirmação de um lote deve conservar as contagens. Desactivar/aprovar em QA deve retirar/repor a série da consulta, mantendo os eventos e observações.
3. Em Localizações → demografia, seleccionar GONDOLA e GURO, sector Indústria, 2017–2050. Conferir anos, faixas etárias, exemplares e diferenças; seleccionar um ano fora da cobertura deve mostrar ausência de dados. Repetir para províncias em 2017 para consultar exemplares nacionais e provinciais separados.
4. Num dossier QA com território seleccionado, abrir preparação/SWOT e conferir `Contexto demográfico INE` como fonte. No simulador, consultar demografia e voltar ao rascunho sem alterar os valores fiscais. Em projectos/clusters, conferir a evidência dentro do papel territorial documentado.
5. Registar a decisão de homologação, incluindo o tratamento das divergências de 2031. Homologar esta entrega não implica instalar, publicar, migrar ou importar no activo.
Recomendação: o modelo/importador estão preparados para **pré-visualizar** os restantes XLSX, um exemplar de cada vez, conferindo cabeçalhos, nomes territoriais, anos e totais antes de confirmar. O inventário também contém XLS legado e indicadores de outras naturezas: exigem adaptador/âmbito próprio, sem converter ou sobrescrever originais. Não se declara já demonstrada a importação das restantes províncias nem se autoriza execução desse trabalho.
@@ -0,0 +1,32 @@
{
"state": "BLOCKED_BEFORE_INSTALLATION",
"reason": "Explicit authorisation condition 4: executor follows main, which differs from the authorised revision.",
"authorised_revision": "3558647e8d82092cd0e31eefbe8b9e5e9eacf573",
"main_revision": "96f510965f70581a4b41dd94463f598b77dd7cf5",
"target": {
"configured_branch": "main",
"installed_revision": "96f510965f70581a4b41dd94463f598b77dd7cf5",
"deploy_service": "inactive",
"deploy_path": "active",
"pending_requests": 0,
"backup_receipt_exists": true,
"restore_verified": true,
"installation_started": false
},
"installation_authorisation_received": true,
"branch_update_performed": false,
"request_created": false,
"installation_started": false,
"rollback_required": false,
"migrations_performed": false,
"email_sent": false,
"operational_data_or_configuration_writes": false,
"previous_backup_restore_evidence": "outputs/offers-preinstall-verification-20261114/qa-result.json",
"possible_next_step": {
"kind": "explicit decision to fast-forward main to authorised candidate; may trigger the deployment executor",
"fast_forward_possible": true,
"command": "git push origin 3558647e8d82092cd0e31eefbe8b9e5e9eacf573:refs/heads/main",
"force": false,
"other_local_documentation_commits_excluded": true
}
}
@@ -0,0 +1,13 @@
{
"stage": "/var/backups/investmoz-offers-exact-install-3558647e-20261114/backup",
"created_at": "2026-11-14T17:51:40Z",
"target": {
"database": "teste_investidor",
"port": 5432
},
"tables": 219,
"files_verified": 417,
"dump_sha256": "88152dc39367f5e59eb072f63b44d3a949e93d73ae855eccb33c3c0b9e37bf6c",
"files_sha256": "aa6248e0eec3d27d58a4bbb3c16848a70dcb3524e6cafc6340b682984a8bbdb3",
"restore_verified": false
}
@@ -0,0 +1,663 @@
{
"path": {
"funding_selections": [],
"preparation_action": null,
"private_investment": null,
"case": {
"id": "d51a3c8b-6210-4d2a-93ec-17ee61452e79",
"title": "Synthetic acquisition acceptance",
"version": 5,
"closed": false
},
"journey_id": "3da63c95-34ac-4f6f-9f97-9c35c261cb17",
"definition_id": "61000919-0000-4000-8000-000000000001",
"definition_version": 1,
"source_sha256": "aa13c53138ec15cd808de43d9dc2fc6808da86943d09d3352c9dd0944bdae364",
"phases": {
"1": "Ideia e viabilidade",
"2": "Formaliza\u00e7\u00e3o e dossier",
"3": "Licenciamento e terra",
"4": "Financiamento",
"5": "Execu\u00e7\u00e3o e acompanhamento"
},
"steps": [
{
"code": "S01",
"phase": 1,
"title": "Sistematizar a ideia",
"branch": null,
"sequence": 1,
"dependencies": [],
"source_paragraph": 22,
"id": "ad1b09ac-2082-454c-9fc9-dec3e94d61f7",
"version": 1,
"state": "not_started",
"saved_state": "not_started",
"applicability": "applicable",
"reason": "",
"detail": {
"state": "not_started"
},
"preparation_allowed": true,
"action_gaps": [],
"context_review_required": false
},
{
"code": "S02",
"phase": 1,
"title": "An\u00e1lise preliminar",
"branch": null,
"sequence": 2,
"dependencies": [],
"source_paragraph": 23,
"id": "1d17b451-b95a-4a29-a829-d75990f75961",
"version": 1,
"state": "not_started",
"saved_state": "not_started",
"applicability": "applicable",
"reason": "",
"detail": {
"state": "not_started"
},
"preparation_allowed": true,
"action_gaps": [],
"context_review_required": true
},
{
"code": "S03",
"phase": 1,
"title": "Decis\u00e3o sobre a viabilidade",
"branch": null,
"sequence": 3,
"dependencies": [],
"source_paragraph": 24,
"id": "32d9cd0f-e854-4129-8358-ed17a8574d95",
"version": 1,
"state": "not_started",
"saved_state": "not_started",
"applicability": "applicable",
"reason": "",
"detail": {
"state": "not_started"
},
"preparation_allowed": true,
"action_gaps": [],
"context_review_required": true
},
{
"code": "S04",
"phase": 1,
"title": "Estruturar o investimento",
"branch": null,
"sequence": 4,
"dependencies": [],
"source_paragraph": 25,
"id": "0a2005fe-2cdd-4d49-a8e9-052403c61095",
"version": 1,
"state": "not_started",
"saved_state": "not_started",
"applicability": "applicable",
"reason": "",
"detail": {
"state": "not_started"
},
"preparation_allowed": true,
"action_gaps": [],
"context_review_required": true
},
{
"code": "S05",
"phase": 2,
"title": "Constitui\u00e7\u00e3o e registo",
"branch": null,
"sequence": 5,
"dependencies": [],
"source_paragraph": 26,
"id": "cf6c4c1b-61a0-4c7f-85c7-b6a21c7adbb4",
"version": 1,
"state": "not_started",
"saved_state": "not_started",
"applicability": "applicable",
"reason": "",
"detail": {
"state": "not_started"
},
"preparation_allowed": true,
"action_gaps": [],
"context_review_required": false
},
{
"code": "S06",
"phase": 2,
"title": "Preparar o dossier",
"branch": null,
"sequence": 6,
"dependencies": [],
"source_paragraph": 27,
"id": "881cd5ed-81a1-48ce-8278-2d2cb568e27b",
"version": 1,
"state": "not_started",
"saved_state": "not_started",
"applicability": "applicable",
"reason": "",
"detail": {
"state": "not_started"
},
"preparation_allowed": true,
"action_gaps": [],
"context_review_required": false
},
{
"code": "S07",
"phase": 2,
"title": "Submeter quando aplic\u00e1vel",
"branch": null,
"sequence": 7,
"dependencies": {
"submit": [
"S06"
]
},
"source_paragraph": 28,
"id": "0d57e596-26f3-4ec1-a8fd-aae1cf3aecce",
"version": 1,
"state": "not_started",
"saved_state": "not_started",
"applicability": "applicable",
"reason": "",
"detail": {
"state": "not_started"
},
"preparation_allowed": true,
"action_gaps": {
"submit": [
"S06"
]
},
"context_review_required": false
},
{
"code": "S08",
"phase": 3,
"title": "Licenciamento pr\u00e9vio",
"branch": null,
"sequence": 8,
"dependencies": [],
"source_paragraph": 29,
"id": "51be4636-e771-4a90-8b02-a7e7e19799d2",
"version": 1,
"state": "not_started",
"saved_state": "not_started",
"applicability": "applicable",
"reason": "",
"detail": {
"state": "not_started"
},
"preparation_allowed": true,
"action_gaps": [],
"context_review_required": true
},
{
"code": "S09",
"phase": 3,
"title": "Necessidade de terra",
"branch": null,
"sequence": 9,
"dependencies": [],
"source_paragraph": 30,
"id": "8860d60b-557a-4cae-9e9a-ffce052f0ac1",
"version": 1,
"state": "not_started",
"saved_state": "not_started",
"applicability": "applicable",
"reason": "",
"detail": {
"state": "not_started"
},
"preparation_allowed": true,
"action_gaps": [],
"context_review_required": false
},
{
"code": "S10",
"phase": 3,
"title": "Regulariza\u00e7\u00e3o da terra",
"branch": "land_needed",
"sequence": 10,
"dependencies": [],
"source_paragraph": 31,
"id": "fefbb44c-0346-4309-a626-d64f89dc3ed8",
"version": 1,
"state": "not_started",
"saved_state": "not_started",
"applicability": "undetermined",
"reason": "",
"detail": {
"state": "not_started"
},
"preparation_allowed": true,
"action_gaps": [],
"context_review_required": false
},
{
"code": "S11",
"phase": 3,
"title": "Confirmar regulariza\u00e7\u00e3o aplic\u00e1vel",
"branch": null,
"sequence": 11,
"dependencies": [],
"source_paragraph": 32,
"id": "c91ca8c7-8075-4ddc-b2dc-d7f1fd820b54",
"version": 1,
"state": "not_started",
"saved_state": "not_started",
"applicability": "applicable",
"reason": "",
"detail": {
"state": "not_started"
},
"preparation_allowed": true,
"action_gaps": [],
"context_review_required": false
},
{
"code": "S12",
"phase": 4,
"title": "Necessidade e estrutura de financiamento",
"branch": null,
"sequence": 12,
"dependencies": [],
"source_paragraph": 33,
"id": "073bae02-69a6-463e-90b9-d85a3750c5c1",
"version": 1,
"state": "not_started",
"saved_state": "not_started",
"applicability": "applicable",
"reason": "",
"detail": {
"state": "not_started"
},
"preparation_allowed": true,
"action_gaps": [],
"context_review_required": true
},
{
"code": "S13",
"phase": 4,
"title": "Dilig\u00eancia e negocia\u00e7\u00e3o financeira",
"branch": "external_funding",
"sequence": 13,
"dependencies": [],
"source_paragraph": 34,
"id": "9610ed18-2b00-42b7-ba45-109d786fbfab",
"version": 1,
"state": "not_started",
"saved_state": "not_started",
"applicability": "undetermined",
"reason": "",
"detail": {
"state": "not_started"
},
"preparation_allowed": true,
"action_gaps": [],
"context_review_required": true
},
{
"code": "S14",
"phase": 4,
"title": "Decis\u00e3o do financiador",
"branch": "external_funding",
"sequence": 14,
"dependencies": [],
"source_paragraph": 35,
"id": "0d9db59f-8237-4541-a962-ef91d38431e2",
"version": 1,
"state": "not_started",
"saved_state": "not_started",
"applicability": "undetermined",
"reason": "",
"detail": {
"state": "not_started"
},
"preparation_allowed": true,
"action_gaps": [],
"context_review_required": true
},
{
"code": "S15",
"phase": 4,
"title": "Fecho e desembolso",
"branch": "external_funding",
"sequence": 15,
"dependencies": {
"execute": [
"S14"
]
},
"source_paragraph": 36,
"id": "071057b5-cae7-4bfe-abec-749ff219f46a",
"version": 1,
"state": "not_started",
"saved_state": "not_started",
"applicability": "undetermined",
"reason": "",
"detail": {
"state": "not_started"
},
"preparation_allowed": true,
"action_gaps": {
"execute": [
"S14"
]
},
"context_review_required": true
},
{
"code": "S16",
"phase": 5,
"title": "Implementa\u00e7\u00e3o",
"branch": null,
"sequence": 16,
"dependencies": [],
"source_paragraph": 37,
"id": "f6cdd881-7ceb-49e4-a092-c1b7677de0c9",
"version": 1,
"state": "not_started",
"saved_state": "not_started",
"applicability": "applicable",
"reason": "",
"detail": {
"state": "not_started"
},
"preparation_allowed": true,
"action_gaps": [],
"context_review_required": false
},
{
"code": "S17",
"phase": 5,
"title": "Inspec\u00e7\u00f5es, testes e licen\u00e7a de opera\u00e7\u00e3o",
"branch": null,
"sequence": 17,
"dependencies": [],
"source_paragraph": 38,
"id": "53e9c8ea-3fe2-4945-92d9-c46d6a053b21",
"version": 1,
"state": "not_started",
"saved_state": "not_started",
"applicability": "applicable",
"reason": "",
"detail": {
"state": "not_started"
},
"preparation_allowed": true,
"action_gaps": [],
"context_review_required": true
},
{
"code": "S18",
"phase": 5,
"title": "In\u00edcio de opera\u00e7\u00e3o",
"branch": null,
"sequence": 18,
"dependencies": {
"operate": [
"S17"
]
},
"source_paragraph": 39,
"id": "31728d61-5f97-41ce-ab31-17aa1c7712fe",
"version": 1,
"state": "not_started",
"saved_state": "not_started",
"applicability": "applicable",
"reason": "",
"detail": {
"state": "not_started"
},
"preparation_allowed": true,
"action_gaps": {
"operate": [
"S17"
]
},
"context_review_required": false
},
{
"code": "S19",
"phase": 5,
"title": "Acompanhamento recorrente",
"branch": null,
"sequence": 19,
"dependencies": [],
"source_paragraph": 40,
"id": "c90e77d5-55ae-4e94-a0bb-4aeed7a418c6",
"version": 1,
"state": "not_started",
"saved_state": "not_started",
"applicability": "applicable",
"reason": "",
"detail": {
"state": "not_started"
},
"preparation_allowed": true,
"action_gaps": [],
"context_review_required": false
}
],
"facts": {
"land_needed": "unknown",
"external_funding": "unknown",
"viability": "unknown"
},
"fact_reasons": [],
"context_opportunity": null,
"opportunity_options": [],
"fintech": null,
"canWrite": true,
"canReview": false,
"next": {
"code": "S01",
"title": "Sistematizar a ideia",
"action": "Preparar",
"href": "#S01"
},
"investment_context": {
"opportunity_choices": [],
"preparation_documents": [],
"preparation_snapshot": null,
"revision": 1,
"selection": {
"opportunity": {
"key": "opportunity:28",
"kind": "opportunity",
"id": "28",
"title": "Synthetic acquisition acceptance",
"version": 5,
"href": "\/minhas-oportunidades\/ofertas\/28",
"fields": {
"project_title": "Synthetic acquisition acceptance",
"project_summary": "Existing company description to reuse once in investment preparation.",
"province": "Synthetic province",
"province_id": "855412e5-27b2-4e0f-90e2-c690e2698ae3",
"location": "Synthetic province"
},
"documents": [],
"visibility": "private",
"state": "approved",
"sha256": "54f87633f70b4b840a9b5ed445e8b437f7dd6c21097119b0324d79b0322dfce3"
}
},
"fields": {
"project_title": "Synthetic acquisition acceptance",
"investment_regime": "general",
"special_zone_id": "",
"currency": "MZN",
"project_summary": "Existing company description to reuse once in investment preparation.",
"province": "Synthetic province",
"province_id": "855412e5-27b2-4e0f-90e2-c690e2698ae3",
"location": "Synthetic province"
},
"provenance": {
"project_title": {
"kind": "dossier",
"id": "d51a3c8b-6210-4d2a-93ec-17ee61452e79",
"version": 2
},
"currency": {
"kind": "process_declaration",
"actor_id": 126,
"context_revision": 1
},
"project_summary": {
"key": "opportunity:28",
"kind": "opportunity",
"id": "28",
"title": "Synthetic acquisition acceptance",
"version": 5,
"href": "\/minhas-oportunidades\/ofertas\/28",
"documents": [],
"visibility": "private",
"state": "approved",
"sha256": "54f87633f70b4b840a9b5ed445e8b437f7dd6c21097119b0324d79b0322dfce3"
},
"province": {
"key": "opportunity:28",
"kind": "opportunity",
"id": "28",
"title": "Synthetic acquisition acceptance",
"version": 5,
"href": "\/minhas-oportunidades\/ofertas\/28",
"documents": [],
"visibility": "private",
"state": "approved",
"sha256": "54f87633f70b4b840a9b5ed445e8b437f7dd6c21097119b0324d79b0322dfce3"
},
"province_id": {
"key": "opportunity:28",
"kind": "opportunity",
"id": "28",
"title": "Synthetic acquisition acceptance",
"version": 5,
"href": "\/minhas-oportunidades\/ofertas\/28",
"documents": [],
"visibility": "private",
"state": "approved",
"sha256": "54f87633f70b4b840a9b5ed445e8b437f7dd6c21097119b0324d79b0322dfce3"
},
"location": {
"key": "opportunity:28",
"kind": "opportunity",
"id": "28",
"title": "Synthetic acquisition acceptance",
"version": 5,
"href": "\/minhas-oportunidades\/ofertas\/28",
"documents": [],
"visibility": "private",
"state": "approved",
"sha256": "54f87633f70b4b840a9b5ed445e8b437f7dd6c21097119b0324d79b0322dfce3"
}
},
"review_steps": {
"S02": {
"revision": 1,
"reason": "O contexto mudou; reveja os pressupostos e a evid\u00eancia aplic\u00e1vel."
},
"S03": {
"revision": 1,
"reason": "O contexto mudou; reveja os pressupostos e a evid\u00eancia aplic\u00e1vel."
},
"S04": {
"revision": 1,
"reason": "O contexto mudou; reveja os pressupostos e a evid\u00eancia aplic\u00e1vel."
},
"S08": {
"revision": 1,
"reason": "O contexto mudou; reveja os pressupostos e a evid\u00eancia aplic\u00e1vel."
},
"S12": {
"revision": 1,
"reason": "O contexto mudou; reveja os pressupostos e a evid\u00eancia aplic\u00e1vel."
},
"S13": {
"revision": 1,
"reason": "O contexto mudou; reveja os pressupostos e a evid\u00eancia aplic\u00e1vel."
},
"S14": {
"revision": 1,
"reason": "O contexto mudou; reveja os pressupostos e a evid\u00eancia aplic\u00e1vel."
},
"S15": {
"revision": 1,
"reason": "O contexto mudou; reveja os pressupostos e a evid\u00eancia aplic\u00e1vel."
},
"S17": {
"revision": 1,
"reason": "O contexto mudou; reveja os pressupostos e a evid\u00eancia aplic\u00e1vel."
}
},
"dependency_hash": "7c4eca9a9377b1c42478becca10e833db253fe5abb566731a9d9bb0a6ba72baf",
"updated_at": "2026-11-14T18:07:17+00:00",
"updated_by": 126,
"requirements_assessment": {
"version": "context-requirements-20261023",
"requirements": [],
"fiscal_notice": "Localiza\u00e7\u00e3o, corredor, cluster ou projecto \u00e2ncora n\u00e3o activam benef\u00edcios fiscais. O simulador mant\u00e9m as condi\u00e7\u00f5es, fonte, per\u00edodo e \u00e2mbito pr\u00f3prios; condi\u00e7\u00f5es ausentes permanecem indeterminadas."
},
"complements": []
},
"results": [
{
"id": "2f494a79-71e0-4ed9-a2b1-51b6b60987c4",
"source_key": "f840ebc37a03761ad285d6c22abfc1a5383f2ec87433c0690472420abd21baea",
"draft_id": "b1c4e61e-1d8e-49bc-9f12-7bc1fea93724",
"draft_version": 1,
"snapshot_sha256": "47f89ac2f2882e4adc27a74e2cd0a986f612b0779f2f5f6a7774d3734ba70ea2",
"financial_snapshot": "{\"version\":1,\"data\":{\"budgetPeriod\":\"Mensal\",\"loan\":{\"principal\":\"100000\",\"rate\":\"0\",\"months\":\"24\",\"rateType\":\"nominal\",\"periodMonths\":\"1\",\"gracePeriods\":\"0\",\"upfrontFee\":\"0\",\"recurringFee\":\"0\"},\"breakEven\":{\"fixed\":\"3000\",\"price\":\"100\",\"variable\":\"50\"},\"funding\":{\"total\":\"100000\",\"secured\":\"30000\"},\"incomes\":[{\"label\":\"Synthetic scenario revenue\",\"amount\":\"10000\"}],\"expenses\":[{\"label\":\"Declared rent at synthetic province\",\"amount\":\"2000\"},{\"label\":\"Declared transport at synthetic province\",\"amount\":\"1000\"}]}}",
"context_opportunity": null,
"currency": "MZN",
"engine": "financial-calculators:v1",
"kind": "local_calculation_inputs",
"context_state": "current",
"context_revision": 1,
"context_dependency_hash": "7c4eca9a9377b1c42478becca10e833db253fe5abb566731a9d9bb0a6ba72baf",
"recorded_at": "2026-11-14T18:07:20+00:00"
}
],
"requests": [],
"documents": [],
"fiscal_reports": [],
"land_report": null,
"preparation_report": null,
"company_reports": [],
"zone_contexts": [],
"sector_contexts": [],
"sector_references": [],
"content_sources": [],
"tasks": [],
"services": [],
"tools": {
"funding": "\/fundos?kind=institution&location_mode=physical&context=d51a3c8b-6210-4d2a-93ec-17ee61452e79",
"simulator": "\/simulador-fiscal?context=d51a3c8b-6210-4d2a-93ec-17ee61452e79&draft=b1c4e61e-1d8e-49bc-9f12-7bc1fea93724",
"preparation": null,
"company": "\/investimentos\/d51a3c8b-6210-4d2a-93ec-17ee61452e79\/forma-juridica",
"desk": "\/balcao-virtual\/dossiers\/d51a3c8b-6210-4d2a-93ec-17ee61452e79",
"guidance": "\/balcao-virtual\/dossiers\/d51a3c8b-6210-4d2a-93ec-17ee61452e79\/orientacao",
"library": "\/documentos?context=d51a3c8b-6210-4d2a-93ec-17ee61452e79",
"opportunities": "\/oportunidades?context=d51a3c8b-6210-4d2a-93ec-17ee61452e79",
"territory": "\/zonas?context=d51a3c8b-6210-4d2a-93ec-17ee61452e79",
"partners": "\/parcerias\/painel?context=d51a3c8b-6210-4d2a-93ec-17ee61452e79",
"land": "\/investidor\/terra-duat\/novo?context=d51a3c8b-6210-4d2a-93ec-17ee61452e79"
}
},
"saved": {
"project_title": "Synthetic acquisition acceptance",
"currency": "MZN",
"project_summary": "Existing company description to reuse once in investment preparation.",
"province_id": "855412e5-27b2-4e0f-90e2-c690e2698ae3",
"journey_type": "investor",
"purpose": "simulate_only",
"context_applicability_snapshot": "{\"version\":1,\"context_revision\":1,\"consumer_opportunity\":null,\"portfolio_origin\":null,\"zone\":null,\"provenance\":{\"project_title\":{\"kind\":\"dossier\",\"id\":\"d51a3c8b-6210-4d2a-93ec-17ee61452e79\",\"version\":2},\"currency\":{\"kind\":\"process_declaration\",\"actor_id\":126,\"context_revision\":1},\"project_summary\":{\"key\":\"opportunity:28\",\"kind\":\"opportunity\",\"id\":\"28\",\"title\":\"Synthetic acquisition acceptance\",\"version\":5,\"href\":\"\\\/minhas-oportunidades\\\/ofertas\\\/28\",\"documents\":[],\"visibility\":\"private\",\"state\":\"approved\",\"sha256\":\"54f87633f70b4b840a9b5ed445e8b437f7dd6c21097119b0324d79b0322dfce3\"},\"province\":{\"key\":\"opportunity:28\",\"kind\":\"opportunity\",\"id\":\"28\",\"title\":\"Synthetic acquisition acceptance\",\"version\":5,\"href\":\"\\\/minhas-oportunidades\\\/ofertas\\\/28\",\"documents\":[],\"visibility\":\"private\",\"state\":\"approved\",\"sha256\":\"54f87633f70b4b840a9b5ed445e8b437f7dd6c21097119b0324d79b0322dfce3\"},\"province_id\":{\"key\":\"opportunity:28\",\"kind\":\"opportunity\",\"id\":\"28\",\"title\":\"Synthetic acquisition acceptance\",\"version\":5,\"href\":\"\\\/minhas-oportunidades\\\/ofertas\\\/28\",\"documents\":[],\"visibility\":\"private\",\"state\":\"approved\",\"sha256\":\"54f87633f70b4b840a9b5ed445e8b437f7dd6c21097119b0324d79b0322dfce3\"},\"location\":{\"key\":\"opportunity:28\",\"kind\":\"opportunity\",\"id\":\"28\",\"title\":\"Synthetic acquisition acceptance\",\"version\":5,\"href\":\"\\\/minhas-oportunidades\\\/ofertas\\\/28\",\"documents\":[],\"visibility\":\"private\",\"state\":\"approved\",\"sha256\":\"54f87633f70b4b840a9b5ed445e8b437f7dd6c21097119b0324d79b0322dfce3\"}},\"opportunity_choices\":[],\"complements\":[],\"requirements_assessment\":{\"version\":\"context-requirements-20261023\",\"requirements\":[],\"fiscal_notice\":\"Localiza\u00e7\u00e3o, corredor, cluster ou projecto \u00e2ncora n\u00e3o activam benef\u00edcios fiscais. O simulador mant\u00e9m as condi\u00e7\u00f5es, fonte, per\u00edodo e \u00e2mbito pr\u00f3prios; condi\u00e7\u00f5es ausentes permanecem indeterminadas.\"},\"path\":{\"definition_id\":\"61000919-0000-4000-8000-000000000001\",\"definition_version\":1,\"source_sha256\":\"aa13c53138ec15cd808de43d9dc2fc6808da86943d09d3352c9dd0944bdae364\",\"steps\":[{\"code\":\"S10\",\"title\":\"Regulariza\u00e7\u00e3o da terra\",\"state\":\"not_started\",\"applicability\":\"undetermined\",\"reason\":\"\",\"context_review_required\":false},{\"code\":\"S13\",\"title\":\"Dilig\u00eancia e negocia\u00e7\u00e3o financeira\",\"state\":\"not_started\",\"applicability\":\"undetermined\",\"reason\":\"\",\"context_review_required\":true},{\"code\":\"S14\",\"title\":\"Decis\u00e3o do financiador\",\"state\":\"not_started\",\"applicability\":\"undetermined\",\"reason\":\"\",\"context_review_required\":true},{\"code\":\"S15\",\"title\":\"Fecho e desembolso\",\"state\":\"not_started\",\"applicability\":\"undetermined\",\"reason\":\"\",\"context_review_required\":true}]},\"land_preparation\":{\"need\":\"unknown\",\"reason\":\"\"},\"field_provenance\":{\"project_title\":{\"kind\":\"dossier\",\"version\":2},\"currency\":{\"kind\":\"process_declaration\",\"context_revision\":1},\"project_summary\":{\"kind\":\"opportunity\",\"title\":\"Synthetic acquisition acceptance\",\"version\":5},\"province\":{\"kind\":\"opportunity\",\"title\":\"Synthetic acquisition acceptance\",\"version\":5},\"province_id\":{\"kind\":\"opportunity\",\"title\":\"Synthetic acquisition acceptance\",\"version\":5},\"location\":{\"kind\":\"opportunity\",\"title\":\"Synthetic acquisition acceptance\",\"version\":5}},\"preparation\":null,\"fields\":{\"investment_regime\":\"general\",\"currency\":\"MZN\"},\"notice\":\"Refer\u00eancias e declara\u00e7\u00f5es para prepara\u00e7\u00e3o; sem reconhecimento autom\u00e1tico de elegibilidade ou benef\u00edcio.\"}",
"financial_tools_snapshot": "{\"version\":1,\"data\":{\"budgetPeriod\":\"Mensal\",\"loan\":{\"principal\":\"100000\",\"rate\":\"0\",\"months\":\"24\",\"rateType\":\"nominal\",\"periodMonths\":\"1\",\"gracePeriods\":\"0\",\"upfrontFee\":\"0\",\"recurringFee\":\"0\"},\"breakEven\":{\"fixed\":\"3000\",\"price\":\"100\",\"variable\":\"50\"},\"funding\":{\"total\":\"100000\",\"secured\":\"30000\"},\"incomes\":[{\"label\":\"Synthetic scenario revenue\",\"amount\":\"10000\"}],\"expenses\":[{\"label\":\"Declared rent at synthetic province\",\"amount\":\"2000\"},{\"label\":\"Declared transport at synthetic province\",\"amount\":\"1000\"}]}}"
}
}
@@ -0,0 +1 @@
{"tool":"phpstan","result":"passed","errors":0}
@@ -0,0 +1,324 @@
> build
> vp build
[plugin @laravel/vite-plugin-wayfinder] Types generated for actions, routes, form variants
npm.cmd : [plugin laravel:fonts] Optimized font fallbacks require the optional "fontaine" package. Install it, or set
"optimizedFallbacks: false" on your fonts to disable the feature.
At line:9 char:444
+ ... _BUILD='0'; npm.cmd run build *> outputs/offers-exact-install-2026111 ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: ([plugin laravel...le the feature.:String) [], RemoteException
+ FullyQualifiedErrorId : NativeCommandError
transforming...
✓ 2849 modules transformed.
rendering chunks...
computing gzip size...
public/build/assets/layers-BWBAp2CZ.png 0.69 kB
public/build/assets/layers-2x-Bpkbi35X.png 1.25 kB
public/build/assets/marker-icon-hN30_KVU.png 1.46 kB
public/build/fonts-manifest.json 5.74 kB │ gzip: 0.71 kB
public/build/assets/instrument-sans-400-normal-DRC__1Mx.woff2 16.86 kB
public/build/assets/instrument-sans-500-normal-Dk9ku72i.woff2 17.23 kB
public/build/assets/instrument-sans-600-normal-B7fBEWYG.woff2 17.40 kB
public/build/assets/instrument-sans-400-normal-D1W7dsQl.woff 21.24 kB
public/build/assets/instrument-sans-500-normal-Z6ESRlEs.woff 21.65 kB
public/build/assets/instrument-sans-600-normal-B9e8oLYv.woff 21.67 kB
public/build/manifest.json 114.69 kB │ gzip: 12.01 kB
public/build/assets/fonts-C9MNnjVw.css 2.35 kB │ gzip: 0.38 kB
public/build/assets/investment-catalogue-WaRR19FV.css 8.15 kB │ gzip: 2.00 kB
public/build/assets/interactive-investment-map-1DTFk8zz.css 10.57 kB │ gzip: 2.63 kB
public/build/assets/app-UznX-FqS.css 157.42 kB │ gzip: 25.28 kB
public/build/assets/input-text-BlEFHnL-.js 0.09 kB │ gzip: 0.10 kB
public/build/assets/check-PcBNn_NG.js 0.12 kB │ gzip: 0.14 kB
public/build/assets/company-comparison-qLTgpXjz.js 0.12 kB │ gzip: 0.13 kB
public/build/assets/chevron-down-Bjkqe1aH.js 0.12 kB │ gzip: 0.14 kB
public/build/assets/chevron-right-K0fOEtTQ.js 0.12 kB │ gzip: 0.14 kB
public/build/assets/display-value-DRR1EAAK.js 0.13 kB │ gzip: 0.12 kB
public/build/assets/value-text-BztaYdGl.js 0.14 kB │ gzip: 0.12 kB
public/build/assets/loader-circle-DS8Tf4er.js 0.14 kB │ gzip: 0.15 kB
public/build/assets/shared-B-QpuOU2.js 0.15 kB │ gzip: 0.13 kB
public/build/assets/arrow-left-DMVHIw8Z.js 0.16 kB │ gzip: 0.16 kB
public/build/assets/arrow-up-right-BIVouzdL.js 0.16 kB │ gzip: 0.15 kB
public/build/assets/search-BRvPeo47.js 0.17 kB │ gzip: 0.16 kB
public/build/assets/circle-check-DckzuBoW.js 0.17 kB │ gzip: 0.16 kB
public/build/assets/product-usage-CHhCL93_.js 0.18 kB │ gzip: 0.17 kB
public/build/assets/editorial-states-Bc7NQzsj.js 0.19 kB │ gzip: 0.16 kB
public/build/assets/types-BoD_IJFv.js 0.19 kB │ gzip: 0.16 kB
public/build/assets/participation-identity-BWabb4yN.js 0.24 kB │ gzip: 0.18 kB
public/build/assets/circle-alert-uV7omYjr.js 0.24 kB │ gzip: 0.18 kB
public/build/assets/eye-CmcEBfxN.js 0.25 kB │ gzip: 0.20 kB
public/build/assets/log-in-Wgc8VLfq.js 0.25 kB │ gzip: 0.22 kB
public/build/assets/partnership-history-CfclSLAo.js 0.27 kB │ gzip: 0.19 kB
public/build/assets/compiler-runtime-sKsOXjDo.js 0.27 kB │ gzip: 0.23 kB
public/build/assets/file-text-CEJtuNfp.js 0.33 kB │ gzip: 0.21 kB
public/build/assets/confirm-Culw9Q-x.js 0.36 kB │ gzip: 0.22 kB
public/build/assets/text-link-BBvcP60s.js 0.40 kB │ gzip: 0.27 kB
public/build/assets/content-management-entry-C_oY4VyW.js 0.42 kB │ gzip: 0.31 kB
public/build/assets/handshake-YqyTTm3a.js 0.44 kB │ gzip: 0.28 kB
public/build/assets/investment-guides-DO58L8zr.js 0.45 kB │ gzip: 0.26 kB
public/build/assets/catalogue-context-1vQ0-xK7.js 0.47 kB │ gzip: 0.30 kB
public/build/assets/operation-key-Dr4VtGxa.js 0.51 kB │ gzip: 0.35 kB
public/build/assets/spinner-Dmx1j41V.js 0.52 kB │ gzip: 0.36 kB
public/build/assets/types-Cxp_Nskt.js 0.55 kB │ gzip: 0.32 kB
public/build/assets/sliders-horizontal-D-4FqAHA.js 0.58 kB │ gzip: 0.26 kB
public/build/assets/page-preparation-guard-B-OckhBt.js 0.59 kB │ gzip: 0.35 kB
public/build/assets/portal-presentation-fH7FShU8.js 0.62 kB │ gzip: 0.38 kB
public/build/assets/confidentiality-C2eqbRBb.js 0.64 kB │ gzip: 0.41 kB
public/build/assets/dist-RzQD0wUd.js 0.68 kB │ gzip: 0.43 kB
public/build/assets/dist-qw3FPjRO.js 0.74 kB │ gzip: 0.49 kB
public/build/assets/labels-DxqjesIG.js 0.74 kB │ gzip: 0.46 kB
public/build/assets/wayfinder-Dp7PqxvV.js 0.89 kB │ gzip: 0.53 kB
public/build/assets/createLucideIcon-oMeAlXbs.js 0.95 kB │ gzip: 0.56 kB
public/build/assets/use-interest-draft-waHUBAew.js 0.99 kB │ gzip: 0.51 kB
public/build/assets/context-documents-Bh7vrWBo.js 1.01 kB │ gzip: 0.57 kB
public/build/assets/label-Dpfg_IeY.js 1.02 kB │ gzip: 0.62 kB
public/build/assets/technical-incident-labels-DlL6bSeE.js 1.02 kB │ gzip: 0.51 kB
public/build/assets/investment-consumer-identity-Cxp_hGfX.js 1.07 kB │ gzip: 0.59 kB
public/build/assets/article-Bp95nIri.js 1.08 kB │ gzip: 0.65 kB
public/build/assets/search-state-RHLy6yI7.js 1.10 kB │ gzip: 0.68 kB
public/build/assets/rolldown-runtime-Dd_uD5pT.js 1.10 kB │ gzip: 0.62 kB
public/build/assets/catalogue-investment-map-C5u-ZS7q.js 1.12 kB │ gzip: 0.62 kB
public/build/assets/investment-contexts-BQGiwu9n.js 1.17 kB │ gzip: 0.60 kB
public/build/assets/profile-json-Bza-VQzw.js 1.20 kB │ gzip: 0.72 kB
public/build/assets/document-COw_wOdO.js 1.23 kB │ gzip: 0.72 kB
public/build/assets/shared-JTxA6RNb.js 1.24 kB │ gzip: 0.74 kB
public/build/assets/public-detail-3AU70Dv-.js 1.25 kB │ gzip: 0.72 kB
public/build/assets/demographic-context-BMdtlxjJ.js 1.27 kB │ gzip: 0.73 kB
public/build/assets/public-offer-card-BDRgf7-u.js 1.30 kB │ gzip: 0.70 kB
public/build/assets/input-BJ-Jg-jz.js 1.37 kB │ gzip: 0.75 kB
public/build/assets/cluster-competitiveness-hNV0FaaW.js 1.37 kB │ gzip: 0.73 kB
public/build/assets/verify-email-DLyeS-ky.js 1.40 kB │ gzip: 0.74 kB
public/build/assets/business-types-SGCXhH6X.js 1.47 kB │ gzip: 0.75 kB
public/build/assets/verification-BOcn3HIw.js 1.48 kB │ gzip: 0.52 kB
public/build/assets/country-select-4uP5BvMf.js 1.49 kB │ gzip: 0.78 kB
public/build/assets/territorial-source-evidence-eKB4ZB36.js 1.53 kB │ gzip: 0.83 kB
public/build/assets/manage-rHtSD5LS.js 1.56 kB │ gzip: 0.74 kB
public/build/assets/educational-article-card-0rCqZ1kX.js 1.59 kB │ gzip: 0.72 kB
public/build/assets/partnership-source-links-VfrXk2FZ.js 1.65 kB │ gzip: 0.91 kB
public/build/assets/catalogue-BtBt9fvW.js 1.78 kB │ gzip: 0.96 kB
public/build/assets/technical-incident-notice-CjziWA6m.js 1.79 kB │ gzip: 0.89 kB
public/build/assets/report-DRxtlCFu.js 1.80 kB │ gzip: 0.93 kB
public/build/assets/preparation-index-CdgPdpQ-.js 1.87 kB │ gzip: 0.89 kB
public/build/assets/opportunity-technical-incidents-C4aVbut7.js 1.88 kB │ gzip: 0.98 kB
public/build/assets/password-input-DdsNob7W.js 1.89 kB │ gzip: 1.06 kB
public/build/assets/passkey-verify-Xyqvu_s4.js 2.04 kB │ gzip: 1.15 kB
public/build/assets/public-record-offline-BmjvnSLE.js 2.07 kB │ gzip: 1.22 kB
public/build/assets/investors-Di6y94fY.js 2.09 kB │ gzip: 1.15 kB
public/build/assets/forgot-password-yYaDq9Uu.js 2.10 kB │ gzip: 0.98 kB
public/build/assets/sources-HCmnzcAX.js 2.13 kB │ gzip: 1.12 kB
public/build/assets/saved-wal8Mb1T.js 2.21 kB │ gzip: 1.11 kB
public/build/assets/zone-map-oV9KEwxY.js 2.26 kB │ gzip: 1.16 kB
public/build/assets/appearance-BP-b3aFc.js 2.36 kB │ gzip: 1.14 kB
public/build/assets/shared-CBJ3c_x-.js 2.37 kB │ gzip: 1.24 kB
public/build/assets/editorial-subjects-C6_MfRSK.js 2.38 kB │ gzip: 1.14 kB
public/build/assets/context-installation-D9SIQHPi.js 2.39 kB │ gzip: 1.23 kB
public/build/assets/workspace-4_R1CLYI.js 2.40 kB │ gzip: 1.20 kB
public/build/assets/reset-password-D-TXit1d.js 2.43 kB │ gzip: 0.98 kB
public/build/assets/public-document-content-BHvs9J7S.js 2.47 kB │ gzip: 0.80 kB
public/build/assets/manage-h1Vaw3Xs.js 2.47 kB │ gzip: 1.10 kB
public/build/assets/confirm-password-Bz70GAZg.js 2.47 kB │ gzip: 1.06 kB
public/build/assets/structures-Dr2nXdub.js 2.49 kB │ gzip: 1.35 kB
public/build/assets/bootstrap-confirmation-9RmA5-Yz.js 2.55 kB │ gzip: 1.19 kB
public/build/assets/territory-associations-ODVIHiZU.js 2.55 kB │ gzip: 1.11 kB
public/build/assets/installation-Dly8Xg6e.js 2.59 kB │ gzip: 1.28 kB
public/build/assets/technical-support-DlW3Jv7q.js 2.60 kB │ gzip: 1.18 kB
public/build/assets/management-CvdeRZYq.js 2.68 kB │ gzip: 1.18 kB
public/build/assets/experiments-DitFKzM4.js 2.73 kB │ gzip: 1.31 kB
public/build/assets/password-CB_NxgQN.js 2.73 kB │ gzip: 0.73 kB
public/build/assets/opportunity-install-B232n6lM.js 2.75 kB │ gzip: 1.34 kB
public/build/assets/dist-BobFCjoG.js 2.92 kB │ gzip: 1.29 kB
public/build/assets/offer-navigation-DLl-_mMp.js 2.98 kB │ gzip: 1.11 kB
public/build/assets/portal-api-CpQBRfcu.js 2.98 kB │ gzip: 1.50 kB
public/build/assets/two-factor-challenge-DXHJ-XjZ.js 3.24 kB │ gzip: 1.52 kB
public/build/assets/territory-geometry-preview-TlklAYvq.js 3.25 kB │ gzip: 1.47 kB
public/build/assets/workspace-D6eSwMPE.js 3.29 kB │ gzip: 1.44 kB
public/build/assets/partnership-agreement-instrument-CCnuZNBy.js 3.43 kB │ gzip: 1.49 kB
public/build/assets/funding-classification-DxK67Gr_.js 3.44 kB │ gzip: 1.64 kB
public/build/assets/guide-mndEuKFT.js 3.45 kB │ gzip: 1.43 kB
public/build/assets/history-DSPDM8gM.js 3.46 kB │ gzip: 1.70 kB
public/build/assets/audit-Xdo8qR4z.js 3.51 kB │ gzip: 1.69 kB
public/build/assets/show-D0mDuHqC.js 3.60 kB │ gzip: 1.82 kB
public/build/assets/demography-D_JHlax_.js 3.70 kB │ gzip: 1.79 kB
public/build/assets/corridor-agribusiness-BcIVf-hW.js 3.72 kB │ gzip: 1.53 kB
public/build/assets/business-opportunity-CNz7NAr0.js 3.73 kB │ gzip: 1.63 kB
public/build/assets/management-access-D4VE9LUB.js 3.75 kB │ gzip: 1.43 kB
public/build/assets/businesses-QQGbugDY.js 3.76 kB │ gzip: 1.57 kB
public/build/assets/editorial-reading-DUFQmJtD.js 3.89 kB │ gzip: 1.68 kB
public/build/assets/catalog-B6dpdVsW.js 3.95 kB │ gzip: 1.51 kB
public/build/assets/desk-assignments-CfWpiIpK.js 3.99 kB │ gzip: 1.95 kB
public/build/assets/dashboard-CxoTLkSg.js 3.99 kB │ gzip: 1.61 kB
public/build/assets/investment-context-cards-gJe_oyxw.js 4.05 kB │ gzip: 1.84 kB
public/build/assets/shared-B4FWNTYh.js 4.09 kB │ gzip: 2.05 kB
public/build/assets/opportunity-contact-form-Cb6iDQ1U.js 4.10 kB │ gzip: 1.67 kB
public/build/assets/demographic-evidence-DGwOnf5g.js 4.38 kB │ gzip: 1.84 kB
public/build/assets/conversation-BxjnUMLV.js 4.42 kB │ gzip: 1.95 kB
public/build/assets/portfolio-5GQ_KZiC.js 4.45 kB │ gzip: 1.74 kB
public/build/assets/reference-DUYC0k47.js 4.46 kB │ gzip: 2.00 kB
public/build/assets/territory-geometry-import-BnSl5jzG.js 4.49 kB │ gzip: 1.90 kB
public/build/assets/opportunity-origin-D3o6OTRg.js 4.64 kB │ gzip: 2.07 kB
public/build/assets/context-inheritance-dd0VgP8h.js 4.64 kB │ gzip: 2.00 kB
public/build/assets/installation-CnslW5oi.js 4.67 kB │ gzip: 1.95 kB
public/build/assets/opportunities-YsMGMCR_.js 4.73 kB │ gzip: 1.77 kB
public/build/assets/references-Dpnc1rzq.js 4.79 kB │ gzip: 2.22 kB
public/build/assets/offer-confidential-access-Bu9EgZ3d.js 4.83 kB │ gzip: 1.91 kB
public/build/assets/opportunity-curation-7Gt2cWer.js 4.88 kB │ gzip: 2.04 kB
public/build/assets/business-verifications-CrRulyQa.js 4.90 kB │ gzip: 1.72 kB
public/build/assets/crm-edit-comparison-Db53T7I7.js 4.91 kB │ gzip: 1.99 kB
public/build/assets/investment-complements-CqmFjvBN.js 5.00 kB │ gzip: 2.05 kB
public/build/assets/service-CbUB6_Ll.js 5.05 kB │ gzip: 2.33 kB
public/build/assets/agreement-history-BfSa8E7Q.js 5.13 kB │ gzip: 2.10 kB
public/build/assets/catalogue-DY6Wy5Cg.js 5.13 kB │ gzip: 2.54 kB
public/build/assets/workspace-BeyUwWtD.js 5.20 kB │ gzip: 2.18 kB
public/build/assets/article-editor-ByLxbPbJ.js 5.25 kB │ gzip: 2.20 kB
public/build/assets/context-start-hE9CWWCV.js 5.46 kB │ gzip: 2.10 kB
public/build/assets/economic-references-C7dNVUC6.js 5.61 kB │ gzip: 2.52 kB
public/build/assets/convert-B3LtI2n6.js 5.81 kB │ gzip: 2.40 kB
public/build/assets/editorial-subject-form-B2W8E2NJ.js 5.87 kB │ gzip: 2.66 kB
public/build/assets/public-sector-cards-Esb1aTm6.js 5.94 kB │ gzip: 2.55 kB
public/build/assets/demography-install-B57rikBv.js 6.05 kB │ gzip: 2.50 kB
public/build/assets/context-to-HP9u9.js 6.06 kB │ gzip: 2.65 kB
public/build/assets/request-BUGHIRJq.js 6.10 kB │ gzip: 2.41 kB
public/build/assets/structure-geography-lV-3bl6R.js 6.12 kB │ gzip: 2.22 kB
public/build/assets/editorial-workflow-DYYZzXOw.js 6.22 kB │ gzip: 2.29 kB
public/build/assets/investment-map-panel-BXYumzxQ.js 6.27 kB │ gzip: 2.88 kB
public/build/assets/procurement-B_QkirDu.js 6.35 kB │ gzip: 2.73 kB
public/build/assets/register-CSFi8mO-.js 6.43 kB │ gzip: 2.71 kB
public/build/assets/funding-BPHyNawC.js 6.49 kB │ gzip: 2.00 kB
public/build/assets/leads-Bu2UK8ON.js 6.56 kB │ gzip: 2.82 kB
public/build/assets/business-marketplace-DfUAwAmq.js 6.59 kB │ gzip: 2.51 kB
public/build/assets/interest-xqtiIFPC.js 6.63 kB │ gzip: 2.23 kB
public/build/assets/demography-BxaBdOBx.js 6.66 kB │ gzip: 2.51 kB
public/build/assets/fiscal-qa-review-CEzw2OQP.js 6.75 kB │ gzip: 2.70 kB
public/build/assets/business-interest-fAZE_Pbt.js 6.82 kB │ gzip: 2.60 kB
public/build/assets/interests-BcpPowrJ.js 6.83 kB │ gzip: 2.78 kB
public/build/assets/catalogue-j9LhZ6Oa.js 6.84 kB │ gzip: 2.82 kB
public/build/assets/company-comparison-BwxGUPs3.js 6.92 kB │ gzip: 2.60 kB
public/build/assets/business-classification-Bu0hQkg7.js 7.01 kB │ gzip: 2.57 kB
public/build/assets/dist-Ddn_hEUd.js 7.02 kB │ gzip: 2.71 kB
public/build/assets/blank-2jFZEe2q.js 7.07 kB │ gzip: 2.77 kB
public/build/assets/analysis--82QOZav.js 7.11 kB │ gzip: 2.96 kB
public/build/assets/profile-DWCNp6hP.js 7.19 kB │ gzip: 2.47 kB
public/build/assets/install-DfaXd1NZ.js 7.23 kB │ gzip: 3.03 kB
public/build/assets/investor-COYuuRY_.js 7.30 kB │ gzip: 2.89 kB
public/build/assets/investor-path-CSExMjAt.js 7.36 kB │ gzip: 3.02 kB
public/build/assets/proactive-supply-review-Bspf5LP7.js 7.55 kB │ gzip: 2.73 kB
public/build/assets/legacy-Cgz97_0u.js 7.67 kB │ gzip: 2.56 kB
public/build/assets/jsx-runtime-BpzPEenQ.js 7.89 kB │ gzip: 2.99 kB
public/build/assets/shared-BFCbvJo7.js 7.90 kB │ gzip: 3.08 kB
public/build/assets/business-objects-DfCgkko7.js 7.95 kB │ gzip: 2.57 kB
public/build/assets/assisted-BH08iYu-.js 8.06 kB │ gzip: 3.16 kB
public/build/assets/value-network-Cp8UYtMs.js 8.06 kB │ gzip: 3.08 kB
public/build/assets/editor-d2dUaWdK.js 8.21 kB │ gzip: 2.97 kB
public/build/assets/territorial-editorial-B0ni24WA.js 8.22 kB │ gzip: 3.03 kB
public/build/assets/opportunity-promotion-CyWpImc1.js 8.40 kB │ gzip: 2.96 kB
public/build/assets/publication-history-DfLO6jOc.js 8.42 kB │ gzip: 3.02 kB
public/build/assets/print-Tn3aXHlY.js 8.66 kB │ gzip: 3.55 kB
public/build/assets/dialog-BY5pP6XP.js 8.81 kB │ gzip: 2.76 kB
public/build/assets/shared-DXIXdF9G.js 8.92 kB │ gzip: 3.83 kB
public/build/assets/iva-modelo-a-CNylSdBc.js 8.94 kB │ gzip: 3.27 kB
public/build/assets/zone-relations-DYVN38G6.js 8.95 kB │ gzip: 2.99 kB
public/build/assets/investment-territory-CoaAvOfZ.js 8.96 kB │ gzip: 3.43 kB
public/build/assets/similar-businesses-HTwQGVGQ.js 9.11 kB │ gzip: 3.58 kB
public/build/assets/interactive-investment-map-C6RYkxx2.js 9.13 kB │ gzip: 3.60 kB
public/build/assets/investment-land-preparation-BHeC9B2U.js 9.15 kB │ gzip: 3.33 kB
public/build/assets/why-invest-BVa_hDq4.js 9.16 kB │ gzip: 3.49 kB
public/build/assets/interest-history-oHNykKmC.js 9.48 kB │ gzip: 3.45 kB
public/build/assets/content-hub-CdhSC7BG.js 9.95 kB │ gzip: 2.93 kB
public/build/assets/privacy-policy-LI1lpUCK.js 10.36 kB │ gzip: 3.07 kB
public/build/assets/editorial-edition-Zq39X7mf.js 10.36 kB │ gzip: 3.47 kB
public/build/assets/panel-la1QJ_S6.js 10.46 kB │ gzip: 4.30 kB
public/build/assets/result-yez5wOFo.js 10.57 kB │ gzip: 4.73 kB
public/build/assets/dashboard-TckpOQI6.js 10.92 kB │ gzip: 3.47 kB
public/build/assets/zone-attachments-DS2XtuU6.js 10.94 kB │ gzip: 2.75 kB
public/build/assets/business-DjxEhMj7.js 11.10 kB │ gzip: 3.09 kB
public/build/assets/detail-Y-uQOHvE.js 11.73 kB │ gzip: 4.31 kB
public/build/assets/login-DSqCNzQj.js 11.89 kB │ gzip: 4.92 kB
public/build/assets/support-installation-tsBpnQkP.js 12.36 kB │ gzip: 4.86 kB
public/build/assets/show-DOVW9TBF.js 12.37 kB │ gzip: 3.99 kB
public/build/assets/tempo-brand-BXcnRsyn.js 12.38 kB │ gzip: 5.01 kB
public/build/assets/show-cuRBkf3d.js 12.40 kB │ gzip: 4.39 kB
public/build/assets/guide-B9b6jazd.js 12.58 kB │ gzip: 4.03 kB
public/build/assets/contact-7Fur8Mlo.js 12.65 kB │ gzip: 4.33 kB
public/build/assets/project-dossier-document-BLKfe4tL.js 12.68 kB │ gzip: 5.20 kB
public/build/assets/dossier-prefill-CkDZdN8F.js 12.72 kB │ gzip: 4.64 kB
public/build/assets/technical-incident-GDB_x0wp.js 12.73 kB │ gzip: 4.96 kB
public/build/assets/competitiveness-60nqrOfg.js 12.74 kB │ gzip: 4.23 kB
public/build/assets/editor-BHONLvty.js 12.91 kB │ gzip: 3.75 kB
public/build/assets/fiscal-forms-CElK0P88.js 13.04 kB │ gzip: 4.75 kB
public/build/assets/opportunity-classification-Dm_W66ep.js 13.44 kB │ gzip: 4.55 kB
public/build/assets/react-CjVep8xp.js 13.90 kB │ gzip: 4.61 kB
public/build/assets/opportunity-search-filters-BwOuTqi6.js 14.74 kB │ gzip: 4.72 kB
public/build/assets/case-wwTeoOmv.js 14.77 kB │ gzip: 5.38 kB
public/build/assets/product-usage-BMGM5Fsp.js 14.81 kB │ gzip: 5.30 kB
public/build/assets/company-preparation-Cvt3Dsqm.js 15.17 kB │ gzip: 4.92 kB
public/build/assets/terms-of-use-CUQdwKes.js 15.27 kB │ gzip: 4.20 kB
public/build/assets/edit-C3UH5lGB.js 15.38 kB │ gzip: 5.15 kB
public/build/assets/relationship-repair-brZoqSge.js 16.29 kB │ gzip: 5.62 kB
public/build/assets/edit-DpqBs8fP.js 16.60 kB │ gzip: 4.88 kB
public/build/assets/edit-CUKYr1QN.js 16.93 kB │ gzip: 4.91 kB
public/build/assets/search-DHadj8Kw.js 17.16 kB │ gzip: 5.69 kB
public/build/assets/funding-institution-DsJ7OfOI.js 17.24 kB │ gzip: 5.83 kB
public/build/assets/use-two-factor-auth-D9w0KtSq.js 17.31 kB │ gzip: 6.30 kB
public/build/assets/administration-CgUGyaX7.js 17.41 kB │ gzip: 4.30 kB
public/build/assets/notifications-3_Mc-VMV.js 17.61 kB │ gzip: 5.83 kB
public/build/assets/ai-BjK1MByI.js 18.08 kB │ gzip: 6.92 kB
public/build/assets/project-dossier-report-BiY2DIQg.js 18.98 kB │ gzip: 6.95 kB
public/build/assets/submission-B_ilbHzj.js 19.99 kB │ gzip: 6.32 kB
public/build/assets/opportunity-resources-CHCOX5-_.js 20.20 kB │ gzip: 7.22 kB
public/build/assets/about-BtlawfeW.js 20.95 kB │ gzip: 7.03 kB
public/build/assets/zones-OjzXPNvW.js 21.16 kB │ gzip: 5.22 kB
public/build/assets/service-editor-DNfxjEmc.js 21.30 kB │ gzip: 6.58 kB
public/build/assets/context-DLdtN62b.js 21.56 kB │ gzip: 6.99 kB
public/build/assets/declaration-Bzl3lixx.js 22.15 kB │ gzip: 7.14 kB
public/build/assets/Combination-BBDI-RbM.js 23.03 kB │ gzip: 8.24 kB
public/build/assets/desk-internal-operations-DrgSmbnS.js 23.14 kB │ gzip: 6.54 kB
public/build/assets/education-BsVVdx6J.js 23.40 kB │ gzip: 8.19 kB
public/build/assets/investment-opportunity-choices-DI6ST39H.js 24.28 kB │ gzip: 8.06 kB
public/build/assets/preparation-CykIAPcD.js 24.56 kB │ gzip: 6.73 kB
public/build/assets/utils-CCDJ05Ll.js 27.31 kB │ gzip: 8.71 kB
public/build/assets/business-wizard-BTqJxN9S.js 28.60 kB │ gzip: 9.42 kB
public/build/assets/proactive-investment-DfIQuXvs.js 28.65 kB │ gzip: 9.16 kB
public/build/assets/lead-vUXgd2g8.js 29.03 kB │ gzip: 9.96 kB
public/build/assets/preparation-ChwKCAm0.js 29.25 kB │ gzip: 8.51 kB
public/build/assets/form-G3wES2VJ.js 29.80 kB │ gzip: 8.04 kB
public/build/assets/profile-MnSD-8CY.js 30.71 kB │ gzip: 9.33 kB
public/build/assets/welcome-DS4fmTKK.js 30.73 kB │ gzip: 6.49 kB
public/build/assets/manage-GV_WvYCX.js 31.17 kB │ gzip: 7.63 kB
public/build/assets/security-CY6Mu2UR.js 32.11 kB │ gzip: 10.06 kB
public/build/assets/editor-CWbYf7YK.js 35.03 kB │ gzip: 11.76 kB
public/build/assets/case-Bpi6HtJI.js 37.38 kB │ gzip: 10.33 kB
public/build/assets/room-Bg8k9V54.js 38.25 kB │ gzip: 12.14 kB
public/build/assets/simulation-form-CVV000gF.js 39.43 kB │ gzip: 10.27 kB
public/build/assets/request-B4WvIFJy.js 41.86 kB │ gzip: 11.29 kB
public/build/assets/detail-DtYLHmDW.js 46.42 kB │ gzip: 15.18 kB
public/build/assets/PortalInvestidor-BE-j5EhK.js 48.72 kB │ gzip: 14.86 kB
public/build/assets/business-financial-plan-s1uoiktr.js 49.19 kB │ gzip: 11.98 kB
public/build/assets/catalogue-bVIBH9Td.js 50.02 kB │ gzip: 16.10 kB
public/build/assets/dropdown-menu-DeBy_kF_.js 57.05 kB │ gzip: 19.02 kB
public/build/assets/zone-DkB12lZg.js 62.42 kB │ gzip: 15.63 kB
public/build/assets/show-Dzl53Tk_.js 68.49 kB │ gzip: 21.16 kB
public/build/assets/opportunity-draft-C9kNlVYV.js 70.65 kB │ gzip: 20.89 kB
public/build/assets/document-zip-bqFSTgG4.js 95.97 kB │ gzip: 28.50 kB
public/build/assets/app-wUAfzkDv.js 147.52 kB │ gzip: 45.59 kB
public/build/assets/leaflet-src-CUWrUTR2.js 148.82 kB │ gzip: 43.39 kB
public/build/assets/dist-BRfsww6z.js 307.99 kB │ gzip: 96.67 kB
public/build/assets/document-word-CqTGSyXu.js 403.66 kB │ gzip: 115.44 kB
public/build/assets/services-C4DSfyGy.js 965.96 kB │ gzip: 226.50 kB
public/build/assets/project-dossier-a3IvmE5B.js 1,009.94 kB │ gzip: 274.44 kB
✓ built in 32.12s
[plugin builtin:vite-reporter]
(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Use build.rolldownOptions.output.codeSplitting to improve chunking:
https://rolldown.rs/reference/OutputOptions.codeSplitting
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
[PLUGIN_TIMINGS] Your build spent 98% of 32.1s inside plugin hooks (31.4s).
Measured inside the callback, so queue time is excluded and time the callback itself awaited is not:
- @rolldown/plugin-babel transform (61%, 19.7s, 412 calls)
- @laravel/vite-plugin-wayfinder buildStart (20%, 6.6s, 1 call)
- @tailwindcss/vite:generate:build transform (14%, 4.4s, 3 calls)
- vite:css transform (11%, 3.4s, 3 calls)
See https://rolldown.rs/reference/InputOptions.checks#plugintimings for more details.
@@ -0,0 +1 @@
{"tool":"phpunit","result":"passed","tests":28,"passed":28,"assertions":403,"duration_ms":266357}
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="CLI Arguments" tests="28" assertions="403" errors="0" failures="0" skipped="0" time="266.318700">
<testsuite name="Tests\Feature\PrivateOfferOperationsTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" tests="13" assertions="180" errors="0" failures="0" skipped="0" time="134.671764">
<testcase name="test_withdrawal_is_immediate_with_a_stale_form_and_excludes_every_public_consumer" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="104" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="16" time="17.786787"/>
<testcase name="test_reactivation_requires_a_new_exact_validation_and_does_not_resurrect_old_publication" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="122" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="21" time="18.507967"/>
<testcase name="test_withdrawal_during_review_can_be_reopened_by_the_owner_without_erasing_history" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="145" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="7" time="7.055661"/>
<testcase name="test_expiry_is_immediate_at_the_boundary_frozen_and_independent_of_nda_and_scheduler" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="158" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="16" time="8.647696"/>
<testcase name="test_no_deadline_is_invented_when_the_policy_is_pending" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="184" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="7" time="6.434084"/>
<testcase name="test_strangers_reviewers_wrong_scopes_and_auditors_cannot_withdraw_or_reactivate" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="195" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="8" time="4.959006"/>
<testcase name="test_reports_are_private_idempotent_and_treated_in_the_existing_dashboard_and_tasks" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="207" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="24" time="11.035164"/>
<testcase name="test_drafts_cannot_be_reported_by_unrelated_users_and_report_read_requires_current_scope_and_mfa" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="237" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="7" time="4.831239"/>
<testcase name="test_highlights_are_versioned_administrative_only_and_cannot_set_commercial_flags" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="250" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="12" time="6.810183"/>
<testcase name="test_a_withdrawn_first_draft_still_requires_its_first_editorial_validation" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="268" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="10" time="9.624057"/>
<testcase name="test_withdrawal_and_revalidation_do_not_restore_previous_nda_grants" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="280" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="16" time="17.343348"/>
<testcase name="test_operational_and_report_screens_use_real_compiled_entries_and_private_cache_headers" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="309" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="23" time="6.368529"/>
<testcase name="test_an_edited_private_approval_does_not_extend_the_existing_public_deadline" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferOperationsTest.php" line="332" class="Tests\Feature\PrivateOfferOperationsTest" classname="Tests.Feature.PrivateOfferOperationsTest" assertions="13" time="15.268044"/>
</testsuite>
<testsuite name="Tests\Feature\PrivateOfferConfidentialityTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" tests="11" assertions="161" errors="0" failures="0" skipped="0" time="106.681963">
<testcase name="test_confirmed_specific_fields_save_recover_and_bind_the_editorial_version" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="109" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="9" time="7.920911"/>
<testcase name="test_property_fields_are_optional_and_categories_and_unknown_fields_are_enforced" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="120" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="7" time="6.171176"/>
<testcase name="test_six_photos_limit_includes_restoration_and_alternate_routes_and_cleans_failed_uploads" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="131" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="15" time="12.991090"/>
<testcase name="test_upload_validation_and_quarantine_do_not_grant_publication_or_read_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="173" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="9" time="8.173232"/>
<testcase name="test_proof_bytes_are_encrypted_and_only_a_scoped_internal_validator_can_read_them" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="187" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="14" time="5.222205"/>
<testcase name="test_nda_absence_keeps_access_closed_and_acceptance_alone_never_grants_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="209" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="13" time="8.975220"/>
<testcase name="test_both_conditions_exact_versions_revocation_and_audit_are_required_on_every_read" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="227" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="18" time="9.934931"/>
<testcase name="test_nda_version_changes_withdrawal_session_revocation_and_private_revision_close_access" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="259" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="35" time="19.377575"/>
<testcase name="test_nda_integrity_and_authority_loss_are_rechecked_without_exposing_the_company" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="299" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="14" time="10.601570"/>
<testcase name="test_compiled_confidential_page_has_no_company_data_before_both_conditions" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="323" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="11" time="6.396441"/>
<testcase name="test_photo_metadata_and_original_are_bound_to_the_editorial_fingerprint" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\PrivateOfferConfidentialityTest.php" line="337" class="Tests\Feature\PrivateOfferConfidentialityTest" classname="Tests.Feature.PrivateOfferConfidentialityTest" assertions="16" time="10.917612"/>
</testsuite>
<testsuite name="Tests\Feature\OfferInvestmentAcceptanceAuditTest" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferInvestmentAcceptanceAuditTest.php" tests="4" assertions="62" errors="0" failures="0" skipped="0" time="24.964973">
<testcase name="test_saved_offer_reuses_its_description_in_the_existing_investment_context" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferInvestmentAcceptanceAuditTest.php" line="41" class="Tests\Feature\OfferInvestmentAcceptanceAuditTest" classname="Tests.Feature.OfferInvestmentAcceptanceAuditTest" assertions="8" time="1.306170"/>
<testcase name="test_native_context_options_do_not_disclose_another_owners_private_offer" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferInvestmentAcceptanceAuditTest.php" line="55" class="Tests\Feature\OfferInvestmentAcceptanceAuditTest" classname="Tests.Feature.OfferInvestmentAcceptanceAuditTest" assertions="7" time="2.462590"/>
<testcase name="test_private_description_is_not_truncated_and_does_not_replace_an_existing_summary" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferInvestmentAcceptanceAuditTest.php" line="67" class="Tests\Feature\OfferInvestmentAcceptanceAuditTest" classname="Tests.Feature.OfferInvestmentAcceptanceAuditTest" assertions="5" time="2.079312"/>
<testcase name="test_approved_offer_continues_in_same_dossier_location_analysis_saved_result_and_report_input" file="C:\laragon\www\portal-investidor-qa-editorial-20261113\tests\Feature\OfferInvestmentAcceptanceAuditTest.php" line="80" class="Tests\Feature\OfferInvestmentAcceptanceAuditTest" classname="Tests.Feature.OfferInvestmentAcceptanceAuditTest" assertions="42" time="19.116900"/>
</testsuite>
</testsuite>
</testsuites>
@@ -0,0 +1,3 @@
> types:check
> tsc --noEmit
@@ -0,0 +1,3 @@
> types:check
> tsc --noEmit
@@ -0,0 +1,9 @@
{
"database_tables_rechecked": 219,
"tables_changed_since_backup": [],
"release_files_rechecked": 417,
"release_files_changed": [],
"installed_revision": "96f510965f70581a4b41dd94463f598b77dd7cf5",
"active_data_writes": 0,
"source_read_only_transactions": true
}
@@ -0,0 +1,7 @@
{
"remote_main_verified": "3558647e8d82092cd0e31eefbe8b9e5e9eacf573",
"guarded_executor_started_for_exact_revision": true,
"automatic_path": "paused",
"lock_held": true,
"other_revisions_forbidden": true
}

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