Compare commits

...
Author SHA1 Message Date
nala 98fc16a3fb docs: record installed stabilization reference and finite remaining scope 2026-10-24 11:10:51 +02:00
nala 276d833a4b test: consolidate stabilization reference and preserve accepted contour scope
tests / ci (push) Canceled after 0s
2026-10-24 11:08:35 +02:00
nala 5d45541a11 docs: receipt for installed shared map stabilization and accepted public contours 2026-10-24 10:51:31 +02:00
nala 066707ece6 fix: restore shared map access and publish accepted administrative boundary projection
tests / ci (push) Canceled after 0s
2026-10-24 10:47:13 +02:00
nala c1f4a71963 docs: record installed anchor integration and exact territorial recovery 2026-10-23 19:26:07 +02:00
nala cf2ddf21a1 fix: verify territorial recovery manifest with canonical Git line endings
tests / ci (push) Canceled after 0s
2026-10-23 19:20:25 +02:00
nala 3fd5128c62 fix: connect documentary anchors to investment preparation and recover accepted territories
tests / ci (push) Canceled after 0s
2026-10-23 19:17:15 +02:00
nala cdf8fcfba4 docs: record installed treatment scope and verified native PDF 2026-10-23 18:50:13 +02:00
nala 8036b9b071 docs: verify saved native PDF and release delivery condition
tests / ci (push) Canceled after 0s
2026-10-23 18:44:09 +02:00
nala 6985f82e9c docs: delimit anchor findings and pending native PDF delivery gate 2026-10-23 18:36:08 +02:00
nala 2856240cf4 fix: print selected requirements and translated opportunity stage states 2026-10-23 18:34:39 +02:00
nala 32144aa2f3 docs: record treatment scope QA and anchor search analysis boundary 2026-10-23 18:26:00 +02:00
nala 1afdfee8c0 fix: share selected opportunity across simulator stages and land shortcuts 2026-10-23 18:25:09 +02:00
nala bc84fbdb3e docs: delimit remaining transversal investment scope 2026-10-23 18:06:42 +02:00
49 changed files with 6956 additions and 156 deletions
@@ -61,8 +61,15 @@ class InvestmentPortfolioController extends Controller
{
$management = $request->routeIs('management.portfolio*');
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'kind' => ['nullable', 'in:corridor,cluster,project'],
'sector' => ['nullable', 'string', 'max:500'],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
$query = $management ? InvestmentDossier::query() : self::visible();
$sectorOptions = ($management ? InvestmentDossier::query() : self::visible())
->whereNotNull('content->sector')->get()->pluck('content.sector')
->filter(fn ($sector) => is_string($sector) && trim($sector) !== '')->unique()->sort()->values();
if (! empty($filters['sector'])) {
$query->where('content->sector', $filters['sector']);
}
if (! empty($filters['kind'])) {
$query->whereNotNull($filters['kind'].'_id');
}
@@ -76,7 +83,7 @@ class InvestmentPortfolioController extends Controller
}
return Inertia::render('portfolio/index', ['records' => $query->orderBy('title')->paginate(30)->withQueryString(),
'management' => $management, 'filters' => $filters, 'territorySelection' => $territorySelection]);
'management' => $management, 'filters' => $filters, 'sectors' => $sectorOptions, 'territorySelection' => $territorySelection]);
}
public function show(Request $request, string $id): Response
@@ -137,6 +144,13 @@ class InvestmentPortfolioController extends Controller
'origin' => $origin ? ['id' => $origin->id, 'title' => $origin->title] : null, 'opportunities' => $opportunities, 'chains' => $chains]);
}
public function resumeInterest(Request $request, string $id): RedirectResponse
{
self::visible()->findOrFail($id);
$data = $request->validate(['catalogue_query' => ['nullable', 'string', 'max:4000']]);
return redirect('/carteira/'.$id.'?'.http_build_query([...$data, 'prepare_interest' => '1']));
}
public function save(Request $request, InvestmentDossier $dossier): RedirectResponse
{
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'title' => ['required', 'string', 'max:255'], 'source' => ['required', 'string', 'max:2000'],
@@ -19,12 +19,21 @@ class InvestmentTerritoryController extends Controller
$data = $request->validate(['territory_id' => ['required', 'uuid']]);
$profile = $request->is('minhas-oportunidades/*');
$management = $request->is('management/*') || $profile;
$actor = $request->user();
$access = app(PortalProfileAccess::class);
// The general consumers use this same endpoint while authenticated.
// Reuse the read access already granted by the private cadastral routes.
$profileAccess = $actor && $actor->hasVerifiedEmail()
&& ($access->grants($actor) !== [] || $access->superuser($actor));
$internal = $profileAccess || ($actor?->hasVerifiedEmail() && $actor->can('manage-portal'));
if ($management) {
$access = app(PortalProfileAccess::class);
abort_unless($profile ? ($access->grants($request->user()) !== [] || $access->superuser($request->user())) : $request->user()?->can('manage-portal'), 403);
abort_unless($profile ? $profileAccess : $internal, 403);
}
return response()->json(app(TerritoryGeometry::class)->context($data['territory_id'], $management));
return response()->json([
...app(TerritoryGeometry::class)->context($data['territory_id'], $internal),
'geometry_scope' => $internal ? 'authorized_internal' : 'public',
])->header('Cache-Control', 'private, no-store');
}
private const TABLES = ['opportunities' => 'opportunities', 'projects' => 'projects', 'clusters' => 'economic_clusters', 'corridors' => 'development_corridors', 'zones' => 'investment_zones'];
@@ -103,12 +103,17 @@ final class InvestorContextController extends Controller
if ($kept && ($kept['territory']['key'] ?? null) !== ($entry['territory']['key'] ?? null)) $differences[] = ['title'=>$entry['opportunity']['title'],'saved'=>$kept['territory']['title']??'Por definir','requested'=>$entry['territory']['title']??'Por definir'];
}
$complements = isset($draft['complements']) ? app(\App\Services\InvestmentComplements::class)->resolve($draft['complements'], $data['context']['complements'] ?? []) : ($data['context']['complements'] ?? []);
$entryTerritory = null;
if (($source['kind'] ?? null) === 'portfolio') {
$path = app(InvestmentGeography::class)->selection($r->validate(['province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid']));
if ($path) $entryTerritory = $context->reference('territory:'.$path[array_key_last($path)]->id);
}
foreach ($this->entryComplements($r) as $entry) {
$tag = 'complement:'.hash('sha256', $entry['key'].'@'.($selectionKey ?? ''));
$entrySources[] = $tag;
if (!in_array($tag, $draft['entry_sources'] ?? [], true)) $complements = collect([...$complements, $entry])->unique('key')->values()->all();
}
return Inertia::render('investor-path/context', ['entryComplements' => $complements, 'entryLocationDifferences'=>$differences,'entrySources'=>$entrySources,'draft' => $draft, 'requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : []]);
return Inertia::render('investor-path/context', ['entryTerritory' => $entryTerritory, 'entryComplements' => $complements, 'entryLocationDifferences'=>$differences,'entrySources'=>$entrySources,'draft' => $draft, 'requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : ($entryTerritory ? app(InvestmentGeography::class)->path($entryTerritory['id']) : [])]);
}
public function preview(Request $r, string $id, InvestorContext $context)
@@ -125,9 +130,16 @@ final class InvestorContextController extends Controller
{
$d = $r->validate(['source' => 'nullable|string|max:100', 'step' => ['nullable', 'regex:/^S(0[1-9]|1[0-9])$/']]);
$ref = isset($d['source']) ? $context->reference($d['source']) : null;
$submissionId = $r->validate(['submission' => 'nullable|uuid'])['submission'] ?? null;
$entrySubmission = null;
if ($submissionId) {
$submission = \App\Models\InvestmentSubmission::where('id', $submissionId)->where('user_id', $r->user()->id)->where('status', 'draft')->firstOrFail();
abort_unless(($ref['kind'] ?? null) === 'portfolio' && $submission->dossier_id === $ref['id'], 422, 'Conserve a ficha de origem da manifestação.');
$entrySubmission = ['id' => $submission->id, 'version' => $submission->version];
}
$choices = $this->entryChoices($r, $context);
return Inertia::render('investor-path/context-start', ['entryComplements' => $this->entryComplements($r), 'entryQuery' => $r->only(['complement_origin', 'complement_code', 'complement_target', 'complement_territory', 'from_dossier', 'province_id', 'district_id', 'admin_post_id', 'locality_id']), 'opportunityChoices' => $choices, 'contexts' => $path->contexts($r->user()), 'source' => $ref, 'step' => $d['step'] ?? null]);
return Inertia::render('investor-path/context-start', ['entrySubmission' => $entrySubmission, 'entryComplements' => $this->entryComplements($r), 'entryQuery' => $r->only(['complement_origin', 'complement_code', 'complement_target', 'complement_territory', 'from_dossier', 'province_id', 'district_id', 'admin_post_id', 'locality_id']), 'opportunityChoices' => $choices, 'contexts' => $path->contexts($r->user()), 'source' => $ref, 'step' => $d['step'] ?? null]);
}
public function installation(Request $r, InvestorContextInstallation $install)
@@ -88,7 +88,8 @@ class InvestorPathController extends Controller
public function show(Request $request, string $id, InvestorPath $path): JsonResponse|Response
{
$data = $path->view($request->user(), $id);
$choice = $request->validate(['opportunity' => 'nullable|string|max:100'])['opportunity'] ?? null;
$data = $path->view($request->user(), $id, $choice);
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/show', $data);
}
@@ -14,12 +14,13 @@ final class EnsureScopedPortalAdministration
public function handle(Request $request, Closure $next): Response
{
$actor = $request->user();
abort_unless($actor, 403);
abort_unless($actor !== null, 403);
$scope = app(ScopedPortalAdministration::class);
$path = $request->path();
$interests = $request->is('management/interests', 'management/interests/*', 'api/v1/investment/admin/interests/*');
// Fiscal maintenance reuses authorised administration; unrelated gates remain unchanged.
$catalogue = $request->is('management/territory-geometry-import', 'management/territory-geometry-import/*',
$geometryRead = $request->isMethod('GET') && $path === 'management/territory-geometries';
$catalogue = $geometryRead || $request->is('management/territory-geometry-import', 'management/territory-geometry-import/*',
'management/economic-references/lot-01', 'management/economic-references/lot-01/*',
'management/education', 'management/education/*', 'management/investment/references', 'management/investment/references/*', 'api/v1/investment/admin/references', 'api/v1/investment/admin/references/*',
'management/investment/zones', 'management/investment/zones/*', 'api/v2/investment/admin/zones', 'api/v2/investment/admin/zones/*',
@@ -37,7 +37,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
'contact_province', 'contact_district', 'preferred_channel',
];
$economic = [
'context_applicability_snapshot', 'investment_regime', 'context_reuse', 'compatible_fields_snapshot', 'published_iva_base', 'published_iva_currency', 'published_iva_date', 'published_iva_scope', 'monthly_iva_settings', 'monthly_iva_snapshot', 'journey_type', 'detail_level', 'business_plan_snapshot', 'financial_tools_snapshot', 'cashflow_snapshot', 'project_team_snapshot', 'project_execution_snapshot', 'purpose', 'manifestation_type', 'preferred_language', 'project_title', 'project_summary', 'project_problem', 'target_customers', 'value_proposition', 'market_assumption', 'market_source', 'project_milestones', 'selection_labels', 'project_type',
'context_opportunity', 'context_applicability_snapshot', 'investment_regime', 'context_reuse', 'compatible_fields_snapshot', 'published_iva_base', 'published_iva_currency', 'published_iva_date', 'published_iva_scope', 'monthly_iva_settings', 'monthly_iva_snapshot', 'journey_type', 'detail_level', 'business_plan_snapshot', 'financial_tools_snapshot', 'cashflow_snapshot', 'project_team_snapshot', 'project_execution_snapshot', 'purpose', 'manifestation_type', 'preferred_language', 'project_title', 'project_summary', 'project_problem', 'target_customers', 'value_proposition', 'market_assumption', 'market_source', 'project_milestones', 'selection_labels', 'project_type',
'competitor_assumption', 'sales_channel_assumption', 'marketing_approach', 'location_rationale', 'project_risk_responses_snapshot', 'project_questionnaire_version',
'customer_validation_status', 'cost_estimate_status', 'location_assessment_status', 'funding_status', 'receivables_timing_status',
'project_type_other', 'sector_id', 'activity_id', 'products_services', 'maturity_stage', 'expected_start_date',
@@ -73,6 +73,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
'economic_snapshot.financial_tools_snapshot' => ['nullable', 'string', 'max:30000'],
'economic_snapshot.cashflow_snapshot' => ['nullable', 'string', 'max:20000'],
'economic_snapshot.context_applicability_snapshot' => ['nullable', 'string', 'max:1000000'],
'economic_snapshot.context_opportunity' => ['nullable', 'string', 'max:100'],
'economic_snapshot.investment_regime' => ['nullable', 'in:general,industrial_park,zee,zfi'],
'economic_snapshot.context_reuse' => ['nullable', 'string', 'max:10000'],
'economic_snapshot.compatible_fields_snapshot' => ['nullable', 'string', 'max:10000'],
@@ -0,0 +1,42 @@
<?php
namespace App\Services;
use RuntimeException;
/** Public read projection authorized by Tempo; source rows and originals stay private. */
class ApprovedAdministrativeBoundaries
{
public const HASH = '06ab905fcf9b19115c3521bf81de03f37873880f7765f229b9e1f79d99df7adc';
/** @var array<string,array<string,string>>|null */
private ?array $items = null;
/** @return array<string,array<string,string>> */
public function snapshots(): array
{
if ($this->items !== null) {
return $this->items;
}
$contents = file_get_contents(base_path('docs/investmoz/unification/approved-administrative-boundaries-20261024.json'));
if ($contents === false || ! hash_equals(self::HASH, hash('sha256', str_replace("\r\n", "\n", $contents)))) {
throw new RuntimeException('Approved administrative boundary manifest changed.');
}
$manifest = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
if (count($manifest['items']) !== 408) {
throw new RuntimeException('Exact 408-boundary scope required.');
}
$this->items = array_column($manifest['items'], null, 'id');
return $this->items;
}
public function allows(string $id, string $territoryId, string $geometryHash, string $propertiesHash): bool
{
$item = $this->snapshots()[$id] ?? null;
return $item !== null && $item['territory_id'] === $territoryId
&& hash_equals($item['geometry_sha256'], $geometryHash)
&& hash_equals($item['properties_sha256'], $propertiesHash);
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/** Exact replay of the 28 accepted links into the transported project identities. */
final class ApprovedAnchorTerritories
{
public const HASH = 'd2893d0a638d630435aca1890f79a8094dc58eb65bbdd65a470bb685fa075190';
/** @return array{manifest_sha256:string,projects:list<array<string,mixed>>,links:list<array<string,mixed>>} */
public function plan(): array
{
$file = base_path('docs/investmoz/unification/approved-anchor-territories-20261023.json');
$contents = is_file($file) ? file_get_contents($file) : false;
if ($contents !== false) {
$contents = str_replace("\r\n", "\n", $contents);
}
if ($contents === false || ! hash_equals(self::HASH, hash('sha256', $contents))) {
throw new RuntimeException('Reviewed territorial manifest changed.');
}
$manifest = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
if (count($manifest['items']) !== 28) {
throw new RuntimeException('Exact 28-link scope required.');
}
$projects = [];
$links = [];
foreach ($manifest['items'] as $item) {
$project = DB::table('projects')->where('id', $item['project_id'])->whereNull('deleted_at')->first();
$dossier = DB::table('investment_dossiers')->where('id', $item['dossier_id'])->whereNull('deleted_at')->first();
if (! $project || $project->code !== $item['code'] || ! $dossier || $dossier->project_id !== $project->id
|| ! hash_equals($item['content_sha256'], hash('sha256', $dossier->content))
|| ! hash_equals($item['source_sha256'], hash('sha256', $dossier->source))) {
throw new RuntimeException('Project identity or documentary source changed: '.$item['code']);
}
$original = $item['original_relation'];
$geo = DB::table('geo_locations')->where('id', $original['geo_location_id'])->whereNull('deleted_at')->first();
foreach (['location_type', 'source_code', 'parent_id', 'data_classification'] as $field) {
if (! $geo || $item['territory'][$field] !== $geo->$field) {
throw new RuntimeException('Territory identity changed: '.$original['geo_location_id']);
}
}
$metadata = $projects[$project->id]['after_metadata'] ?? json_decode($project->metadata, true, 512, JSON_THROW_ON_ERROR);
$key = $geo->id.':'.$original['role'];
$evidence = $item['evidence'];
// Preserve the original dossier id in evidence and explicitly map the destination.
$evidence['transported_dossier_id'] = $dossier->id;
if (isset($metadata['documented_location_sources'][$key]) && $metadata['documented_location_sources'][$key] != $evidence) {
throw new RuntimeException('Existing territorial evidence differs; no overwrite: '.$item['code']);
}
$projects[$project->id] ??= ['id' => $project->id, 'code' => $project->code, 'before_version' => (int) $project->version_no, 'before_metadata' => json_decode($project->metadata, true, 512, JSON_THROW_ON_ERROR)];
$metadata['documented_location_sources'][$key] = $evidence;
$projects[$project->id]['after_metadata'] = $metadata;
$existing = DB::table('project_locations')->where('project_id', $project->id)->where('geo_location_id', $geo->id)->where('role', $original['role'])->first();
if ($existing && ($existing->deleted_at || $existing->data_classification !== 'public')) {
throw new RuntimeException('Archived or restricted territorial relation must not be revived.');
}
$identity = DB::table('project_locations')->where('id', $original['id'])->first();
if ($identity && (! $existing || $identity->id !== $existing->id)) {
throw new RuntimeException('Historical relation identity is already occupied.');
}
$links[] = ['code' => $item['code'], 'already_present' => (bool) $existing, 'row' => [...$original, 'project_id' => $project->id]];
}
return ['manifest_sha256' => self::HASH, 'projects' => array_values($projects), 'links' => $links];
}
/** Caller supplies an authenticated, backed-up transaction; no source publication changes.
* @param array{manifest_sha256:string,projects:list<array<string,mixed>>,links:list<array<string,mixed>>} $plan
* @return array<string,int>
*/
public function apply(array $plan): array
{
if (DB::transactionLevel() < 1 || $this->plan() !== $plan) {
throw new RuntimeException('Reviewed transaction and unchanged preview required.');
}
$inserted = 0;
$updated = 0;
foreach ($plan['projects'] as $project) {
if ($project['before_metadata'] == $project['after_metadata']) {
continue;
}
$count = DB::table('projects')->where('id', $project['id'])->where('version_no', $project['before_version'])->update([
'metadata' => json_encode($project['after_metadata'], JSON_THROW_ON_ERROR), 'version_no' => $project['before_version'] + 1, 'updated_at' => now(),
]);
if ($count !== 1) {
throw new RuntimeException('Concurrent project change.');
}
DB::table('audit_events')->insert(['source_system' => 'approved-anchor-territories-recovery-v1', 'external_id' => self::HASH.':'.$project['id'], 'entity_type' => 'projects', 'entity_id' => $project['id'], 'action' => 'accepted_territories_restored',
'data_classification' => 'public', 'before_data' => json_encode(['version_no' => $project['before_version'], 'metadata' => $project['before_metadata']], JSON_THROW_ON_ERROR),
'after_data' => json_encode(['version_no' => $project['before_version'] + 1, 'manifest_sha256' => self::HASH, 'metadata' => $project['after_metadata'], 'new_approval' => false], JSON_THROW_ON_ERROR)]);
$updated++;
}
foreach ($plan['links'] as $link) {
if ($link['already_present']) {
continue;
}
DB::table('project_locations')->insert($link['row']);
$inserted++;
}
return ['relations_restored' => $inserted, 'projects_updated' => $updated, 'new_dossiers' => 0, 'publication_changes' => 0];
}
}
+22
View File
@@ -8,6 +8,27 @@ use Illuminate\Support\Arr;
/** An identified opportunity within the authorised dossier, never the account's last choice. */
final class InvestmentConsumerScope
{
public function key(array $context, ?string $key, bool $required = false): ?string
{
$choices = $context['opportunity_choices'] ?? [];
$key = $key ?: (count($choices) === 1 ? $choices[0]['opportunity']['key'] : null);
abort_if($key && !collect($choices)->contains('opportunity.key', $key), 422, 'Escolha uma oportunidade deste dossier.');
abort_if($required && count($choices) > 1 && !$key, 422, 'Escolha a oportunidade em tratamento.');
return $key;
}
public function facts(array $payload, array $context, ?string $key): array
{
$key = $this->key($context, $key);
$path = $payload['investor_path'] ?? [];
$defaults = ['land_needed' => 'unknown', 'external_funding' => 'unknown', 'viability' => 'unknown'];
// A dossier-wide dispensation cannot decide a distinct opportunity's needs.
if ($key && count($context['opportunity_choices'] ?? []) > 1) return [...$defaults, ...($path['opportunity_facts'][$key] ?? [])];
if ($key && isset($path['opportunity_facts'][$key])) return [...$defaults, ...$path['opportunity_facts'][$key]];
if (count($context['opportunity_choices'] ?? []) > 1) return $defaults;
return [...$defaults, ...($path['facts'] ?? [])];
}
public function context(array $context, ?string $key): array
{
$choices = $context['opportunity_choices'] ?? [];
@@ -44,6 +65,7 @@ final class InvestmentConsumerScope
$context['requirements_assessment']['requirements'] = array_values(array_filter($context['requirements_assessment']['requirements'] ?? [], fn($r) => $r['opportunity_key'] === $key));
$context['consumer_opportunity'] = $choice;
$context['complements'] = array_values(array_filter($context['complements'] ?? [], fn($c) => ($c['origin']['key'] ?? null) === $key || ($c['target']['key'] ?? null) === $key || in_array(substr($c['origin']['key'] ?? '',10),array_column($choice['portfolio_origins'] ?? [],'id'),true)));
$context['dependency_hash'] = app(InvestorContext::class)->dependencyHash(['opportunity_choices'=>[$choice], 'fields'=>Arr::except($context['fields'],['project_title','project_summary']), 'complements'=>$context['complements']]);
return $context;
}
+37 -12
View File
@@ -122,6 +122,7 @@ final class InvestorContext
public function dependencyHash(array $selection): string
{
unset($selection['portfolio'], $selection['selection']['portfolio']);
if (isset($selection['opportunity_choices'])) $selection['opportunity_choices'] = array_map(fn($choice)=>Arr::except($choice,['portfolio_origins']),$selection['opportunity_choices']);
return hash('sha256', json_encode(Arr::sortRecursive($selection), JSON_THROW_ON_ERROR));
}
@@ -168,6 +169,18 @@ final class InvestorContext
if ($o->primary_territory_id) {
$fields = [...$fields, ...$this->territoryFields($o->primary_territory_id)];
}
} elseif ($kind === 'portfolio') {
abort_unless(Str::isUuid($id), 422);
$dossier = \App\Http\Controllers\InvestmentPortfolioController::visible()->findOrFail($id);
$title = $dossier->title;
$href = '/carteira/'.$id;
// Documentary coverage is not the investor's chosen implantation.
$version = DB::table('investment_dossiers')->where('id', $id)->value('version_no');
$fields = ['project_title' => $title, 'project_summary' => $dossier->content['description'] ?? $dossier->content['focus'] ?? ''];
$state = 'documented_origin';
$zoneAnalysis = ['source' => $dossier->source, 'documented_sector' => $dossier->content['sector'] ?? null,
'documented_location' => $dossier->content['location'] ?? $dossier->content['region'] ?? null,
'geography' => app(InvestmentRelations::class)->dossier($dossier), 'available_opportunity' => false];
} elseif ($kind === 'business') {
abort_unless(config('existing-business.enabled') && Str::isUuid($id), 404);
$o = ExistingBusiness::published()->with('sector')->findOrFail($id);
@@ -354,7 +367,7 @@ final class InvestorContext
'opportunity_choices.*.park' => 'sometimes|nullable|string|max:100',
'opportunity_choices.*.source' => 'required|string|distinct|max:100', 'opportunity_choices.*.territory' => 'nullable|string|max:100',
'opportunity_choices.*.location_reason' => 'nullable|string|max:2000', 'opportunity_choices.*.confirm_location_change' => 'sometimes|boolean',
'version' => 'required|integer|min:1', 'selection' => 'present|array:opportunity,sector,territory,park',
'version' => 'required|integer|min:1', 'selection' => 'present|array:portfolio,opportunity,sector,territory,park',
'selection.*' => 'nullable|string|max:100', 'declarations' => 'nullable|array:project_title,project_summary,currency,expected_start_date,investor_origin,person_type,entity_stage,tax_residency_country,fiscal_residency_country,project_type,maturity_stage,'.implode(',', PrivateInvestmentPreparation::FIELDS), 'declarations.project_title' => 'nullable|string|max:200', 'declarations.project_summary' => 'nullable|string|max:5000',
'declarations.currency' => 'nullable|regex:/^[A-Z]{3}$/', 'declarations.expected_start_date' => 'nullable|date_format:Y-m-d', 'declarations.investor_origin' => 'nullable|in:national,foreign,mixed', 'declarations.person_type' => 'nullable|in:singular,collective,sole_trader,consortium', 'declarations.entity_stage' => 'nullable|in:existing,being_formed', 'declarations.tax_residency_country' => ['nullable', 'string', app(CountryCatalogue::class)->rule()], 'declarations.fiscal_residency_country' => ['nullable', 'string', app(CountryCatalogue::class)->rule()], 'declarations.project_type' => 'nullable|string|max:100', 'declarations.maturity_stage' => 'nullable|string|max:100',
'replace_fields' => 'nullable|array', 'replace_fields.*' => 'string|in:'.implode(',', self::FIELDS)])->validate();
@@ -432,7 +445,7 @@ final class InvestorContext
}
$proposed = [];
$origins = [];
foreach (['opportunity', 'sector', 'park', 'territory'] as $slot) {
foreach (['portfolio', 'opportunity', 'sector', 'park', 'territory'] as $slot) {
if (in_array($slot, ['park', 'territory'], true) && array_intersect(array_keys($selection[$slot]['fields'] ?? []), array_keys(InvestmentGeography::FIELDS))) {
foreach (['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location'] as $key) {
unset($proposed[$key],$origins[$key]);
@@ -484,7 +497,7 @@ final class InvestorContext
$replaced[] = ['field' => $key, 'before' => $old['fields'][$key] ?? null, 'after' => $fields[$key] ?? null];
}
}
foreach (['opportunity', 'sector', 'territory', 'park'] as $slot) {
foreach (['portfolio', 'opportunity', 'sector', 'territory', 'park'] as $slot) {
if (($old['selection'][$slot] ?? null) !== ($selection[$slot] ?? null)) {
$changed[] = $slot;
}
@@ -586,8 +599,8 @@ final class InvestorContext
$case = $this->desk->case($actor, $id);
abort_unless($this->desk->owns($actor, $case, false) || $actor->can('manage-portal'), 403);
$ctx = $this->state($case);
$ctx['land_preparation'] = $this->landPreparation($case);
$path = app(InvestorPath::class)->view($actor, $id);
$ctx['land_preparation'] = $this->landPreparation($case, $opportunity);
$path = app(InvestorPath::class)->view($actor, $id, $opportunity);
$ctx['path_applicability'] = ['definition_id' => $path['definition_id'], 'definition_version' => $path['definition_version'], 'source_sha256' => $path['source_sha256'],
'steps' => array_values(array_map(fn ($step) => Arr::only($step, ['code', 'title', 'applicability', 'reason', 'state', 'context_review_required']), array_filter($path['steps'], fn ($step) => ! empty($step['branch']))))];
@@ -602,12 +615,19 @@ final class InvestorContext
}
}
$preparation = app(PrivateInvestmentPreparation::class)->available() ? app(PrivateInvestmentPreparation::class)->assess($ctx, $this->preparationDocuments($id)) : null;
$preparation = app(PrivateInvestmentPreparation::class)->available() ? app(PrivateInvestmentPreparation::class)->assess(app(InvestmentConsumerScope::class)->context($ctx, $opportunity), $this->preparationDocuments($id)) : null;
$ctx['preparation_assessment'] = $preparation;
$prefill = $this->prefill($ctx, $opportunity);
$saved = $this->desk->decode($case->payload);
if (!empty($saved['reuse_consent'])) {
$contacts = array_filter(['email' => $saved['email'] ?? null, 'phone' => $saved['profile']['phone'] ?? null], fn($v) => is_string($v) && $v !== '');
$prefill['simulator'] = [...$contacts, ...$prefill['simulator']];
$prefill['land'] = [...$contacts, ...(!empty($saved['name']) ? ['full_name' => $saved['name']] : []), ...$prefill['land']];
}
return ['private_investment' => $preparation, 'preparation_documents' => $this->preparationDocuments($id), 'simulator_context_revision' => $simulatorRevision, 'case' => $path['case'], 'context' => $ctx, 'steps' => array_map(fn ($s) => Arr::only($s, ['code', 'title', 'state', 'context_review_required']), $path['steps']),
'can_write' => $path['canWrite'], 'next' => $path['next'], 'results' => $path['results'], 'requests' => $path['requests'],
'prefill' => $this->prefill($ctx, $opportunity), 'notice' => 'A seleção apoia a preparação e não confirma benefícios, licenças, direitos ou disponibilidade.'];
'prefill' => $prefill, 'notice' => 'A seleção apoia a preparação e não confirma benefícios, licenças, direitos ou disponibilidade.'];
}
public function preparationDocuments(string $id): array
@@ -615,18 +635,21 @@ final class InvestorContext
return DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->whereNull('d.deleted_at')->get(['l.id', 'l.document_id', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.version_no', 'd.sha256'])->map(fn ($row) => (array) $row)->all();
}
public function landPreparation(\stdClass $case): array
public function landPreparation(\stdClass $case, ?string $opportunity = null): array
{
$path = $this->desk->decode($case->payload)['investor_path'] ?? [];
$need = $path['facts']['land_needed'] ?? 'unknown';
$payload = $this->desk->decode($case->payload);
$ctx = $this->state($case);
$key = app(InvestmentConsumerScope::class)->key($ctx, $opportunity);
$path = $payload['investor_path'] ?? [];
$need = app(InvestmentConsumerScope::class)->facts($payload, $ctx, $key)['land_needed'];
return ['need' => in_array($need, ['yes', 'no'], true) ? $need : 'unknown', 'reason' => $path['fact_reasons']['land_needed'] ?? ''];
return ['need' => in_array($need, ['yes', 'no'], true) ? $need : 'unknown', 'reason' => $key ? ($path['opportunity_fact_reasons'][$key]['land_needed'] ?? '') : ($path['fact_reasons']['land_needed'] ?? '')];
}
public function prefill(array $ctx, ?string $opportunity = null): array
{
$original = $ctx;
if ($opportunity) $ctx = app(InvestmentConsumerScope::class)->context($ctx, $opportunity);
$ctx = app(InvestmentConsumerScope::class)->context($ctx, $opportunity);
$f = $ctx['fields'];
$sim = Arr::except(Arr::only($f, self::FIELDS), ['sector', 'province', 'district', 'location', ...array_diff(PrivateInvestmentPreparation::FIELDS, ['total_investment', 'area_required', 'area_unit'])]);
// Entering this tool from an investment dossier selects the existing investment-analysis route.
@@ -646,6 +669,8 @@ final class InvestorContext
$zone = $ctx['selection']['park'] ?? null;
$sim['context_applicability_snapshot'] = json_encode([
'version' => 1, 'context_revision' => $ctx['revision'],
'consumer_opportunity' => $ctx['consumer_opportunity']['opportunity']['key'] ?? null,
'portfolio_origin' => $ctx['selection']['portfolio'] ?? null,
'zone' => $zone ? Arr::only($zone, ['id', 'title', 'href', 'version', 'sha256', 'fields', 'analysis']) : null,
'provenance' => $ctx['provenance'],
'opportunity_choices' => $ctx['opportunity_choices'] ?? [],
+38 -16
View File
@@ -68,7 +68,7 @@ final class InvestorPath
}
/** @return array<string,mixed> */
public function view(User $actor, string $id): array
public function view(User $actor, string $id, ?string $opportunity = null): array
{
abort_unless($this->available(), 404);
$case = $this->desk->case($actor, $id);
@@ -79,18 +79,26 @@ final class InvestorPath
$payload = $this->desk->decode($case->payload);
$path = $payload['investor_path'] ?? [];
$context = app(InvestorContext::class)->state($case);
$facts = $path['facts'] ?? ['land_needed' => 'unknown', 'external_funding' => 'unknown', 'viability' => 'unknown'];
$scope = app(InvestmentConsumerScope::class);
$opportunity = $scope->key($context, $opportunity);
$allChoices = $context['opportunity_choices'] ?? [];
$facts = $scope->facts($payload, $context, $opportunity);
$factReasons = $opportunity ? ($path['opportunity_fact_reasons'][$opportunity] ?? []) : ($path['fact_reasons'] ?? []);
$context = $scope->context($context, $opportunity);
$records = DB::table('journey_steps')->where('journey_id', $journey->id)->whereNull('deleted_at')->get()->keyBy('step_code');
$steps = [];
foreach ($definition['steps'] as $step) {
$row = $records->get($step['code']);
abort_unless($row !== null, 409, 'Etapa em falta; reparar a instalação sem reconstruir o histórico.');
$detail = $this->desk->decode($row->path_detail);
if ($opportunity && (count($allChoices) > 1 || isset($detail['opportunities'][$opportunity]))) {
$detail = $detail['opportunities'][$opportunity] ?? ['state' => 'not_started'];
}
$value = $step['branch'] ? ($facts[$step['branch']] ?? 'unknown') : 'yes';
$applicable = $value === 'unknown' ? 'undetermined' : ($value === 'no' ? 'not_needed_for_preparation' : 'applicable');
$state = $value === 'no' ? 'dispensed' : $detail['state'];
$steps[] = [...$step, 'id' => $row->id, 'version' => $row->version_no, 'state' => $state, 'saved_state' => $detail['state'],
'applicability' => $applicable, 'reason' => $value === 'no' ? ($path['fact_reasons'][$step['branch']] ?? '') : ($detail['reason'] ?? ''),
'applicability' => $applicable, 'reason' => $value === 'no' ? ($factReasons[$step['branch']] ?? '') : ($detail['reason'] ?? ''),
'detail' => $detail, 'preparation_allowed' => $value !== 'no',
'action_gaps' => []];
}
@@ -143,7 +151,8 @@ final class InvestorPath
$landCase = (int) $case->user_id === $actor->id
? LandAssistanceCase::where('user_id', $actor->id)->latest('updated_at')->get()
->first(fn ($land) => ($land->payload['investor_path_case_id'] ?? null) === $id) : null;
->first(fn ($land) => ($land->payload['investor_path_case_id'] ?? null) === $id
&& (!$allChoices || ($opportunity && ($land->payload['context_opportunity'] ?? null) === $opportunity))) : null;
$preparation = ($owner || $manager) && app(PrivateInvestmentPreparation::class)->available() ? app(PrivateInvestmentPreparation::class)->assess($context, $documents->map(fn ($d) => (array) $d)->all()) : null;
if ($preparation && $preparation['enabled']) {
@@ -152,11 +161,12 @@ final class InvestorPath
return ['private_investment' => $preparation, 'case' => ['id' => $id, 'title' => $case->title, 'version' => $case->version, 'closed' => $case->closed_at !== null],
'journey_id' => $journey->id, 'definition_id' => $version->id, 'definition_version' => $version->version_no, 'source_sha256' => $definition['source_sha256'],
'phases' => $definition['phases'], 'steps' => $steps, 'facts' => $facts, 'fact_reasons' => $path['fact_reasons'] ?? [],
'phases' => $definition['phases'], 'steps' => $steps, 'facts' => $facts, 'fact_reasons' => $factReasons,
'context_opportunity' => $opportunity, 'opportunity_options' => $allChoices,
'fintech' => ($owner || $manager) && (isset($path['fintech']) || collect($path['sector_contexts'] ?? [])->contains(fn ($s) => in_array($s['slug'], ['fintech', 'tecnologias-de-informacao', 'mercado-financeiro'], true)) || collect($path['content_sources'] ?? [])->contains('return_href', '/tech-fintech')) ? app(FintechInvestment::class)->view($payload, $steps) : null,
'canWrite' => $owner && ! $case->closed_at, 'canReview' => $manager && (int) $case->user_id !== $actor->id && ! $case->closed_at,
'next' => $next ? ['code' => $next['code'], 'title' => $next['title'], 'action' => 'Preparar', 'href' => $next['href'] ?? '#'.$next['code']] : null,
'investment_context' => ($owner || $manager) ? $context : null,
'investment_context' => ($owner || $manager) ? [...$context, 'opportunity_choices' => $opportunity ? $context['opportunity_choices'] : $allChoices] : null,
'results' => $results, 'requests' => $requests, 'documents' => $documents,
'zone_contexts' => ($owner || $manager) ? ($path['zone_contexts'] ?? []) : [],
'sector_contexts' => ($owner || $manager) ? ($path['sector_contexts'] ?? []) : [],
@@ -167,25 +177,31 @@ final class InvestorPath
->get(['id', 'title', 'status', 'version_no', 'due_at', 'assignee_user_id']),
'services' => DB::table('desk_service_versions')->where('status', 'published')->orderByDesc('version')->get(['id', 'code', 'definition'])
->unique('code')->values()->map(fn ($s) => ['id' => $s->id, 'title' => json_decode($s->definition, true)['title']]),
'tools' => ['simulator' => (int) $case->user_id === $actor->id ? '/simulador-fiscal?context='.$id.($case->simulation_id ? '&draft='.$case->simulation_id : '') : null,
'desk' => '/balcao-virtual/dossiers/'.$id, 'guidance' => '/balcao-virtual/dossiers/'.$id.'/orientacao',
'tools' => ['simulator' => (int) $case->user_id === $actor->id ? '/simulador-fiscal?context='.$id.($case->simulation_id ? '&draft='.$case->simulation_id : '').($opportunity ? '&opportunity='.urlencode($opportunity) : '') : null,
'desk' => '/balcao-virtual/dossiers/'.$id.($opportunity ? '?opportunity='.urlencode($opportunity) : ''), 'guidance' => '/balcao-virtual/dossiers/'.$id.'/orientacao',
'library' => '/documentos?context='.$id, 'opportunities' => '/oportunidades?context='.$id, 'territory' => '/zonas?context='.$id, 'partners' => '/parcerias/painel?context='.$id,
'land' => ($facts['land_needed'] ?? 'unknown') !== 'no' && (int) $case->user_id === $actor->id ? ($landCase ? '/investidor/terra-duat/'.$landCase->id : '/investidor/terra-duat/novo?context='.$id) : null]];
'land' => ($facts['land_needed'] ?? 'unknown') !== 'no' && (int) $case->user_id === $actor->id ? ($landCase ? '/investidor/terra-duat/'.$landCase->id : '/investidor/terra-duat/novo?context='.$id.($opportunity ? '&opportunity='.urlencode($opportunity) : '')) : null]];
}
/** @param array<string,mixed> $input */
public function facts(User $actor, string $id, array $input): void
{
$data = Validator::make($input, ['version' => 'required|integer|min:1',
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'context_opportunity' => 'nullable|string|max:100',
'land_needed' => ['required', Rule::in(['yes', 'no', 'unknown'])], 'external_funding' => ['required', Rule::in(['yes', 'no', 'unknown'])],
'viability' => ['required', Rule::in(['unknown', 'proceed', 'reformulate', 'archive'])],
'reason' => 'required|string|min:3|max:3000'])->validate();
DB::transaction(function () use ($actor, $id, $data): void {
$case = $this->writable($actor, $id, $data['version']);
$payload = $this->desk->decode($case->payload);
$choice = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $data['context_opportunity'] ?? null, true);
foreach (['land_needed', 'external_funding', 'viability'] as $key) {
$payload['investor_path']['facts'][$key] = $data[$key];
$payload['investor_path']['fact_reasons'][$key] = $data['reason'];
if ($choice) {
$payload['investor_path']['opportunity_facts'][$choice][$key] = $data[$key];
$payload['investor_path']['opportunity_fact_reasons'][$choice][$key] = $data['reason'];
} else {
$payload['investor_path']['facts'][$key] = $data[$key];
$payload['investor_path']['fact_reasons'][$key] = $data['reason'];
}
}
$this->savePayload($case, $payload);
$this->desk->event($this->desk->case($actor, $id), $actor, 'path_applicability_declared', $data);
@@ -195,7 +211,7 @@ final class InvestorPath
/** @param array<string,mixed> $input */
public function stage(User $actor, string $id, string $code, array $input): void
{
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'state' => ['required', Rule::in(array_diff(InvestorPathDefinition::STATES, ['dispensed']))],
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'context_opportunity' => 'nullable|string|max:100', 'state' => ['required', Rule::in(array_diff(InvestorPathDefinition::STATES, ['dispensed']))],
'reason' => 'required|string|min:3|max:3000', 'action' => ['required', Rule::in(['prepare', 'submit', 'execute', 'operate', 'review', 'record_external'])],
'document_id' => 'nullable|uuid', 'authority' => 'nullable|string|max:200', 'reference' => 'nullable|string|max:300',
'context_revision' => 'nullable|integer|min:0', 'context_reviewed' => 'nullable|boolean', 'decision_date' => 'nullable|date_format:Y-m-d|before_or_equal:today'])->validate();
@@ -203,7 +219,8 @@ final class InvestorPath
$case = $this->desk->case($actor, $id, false, true);
abort_if($case->closed_at, 409);
abort_unless((int) $case->version === $data['version'], 409, 'O dossier mudou; recupere a versão actual.');
$view = $this->view($actor, $id);
$key = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), $data['context_opportunity'] ?? null, true);
$view = $this->view($actor, $id, $key);
$step = $this->findStep($view, $code);
abort_unless($step !== null && $step['preparation_allowed'], 422, 'Etapa dispensada de preparação; altere os pressupostos com fundamento.');
$state = $data['state'];
@@ -238,7 +255,10 @@ final class InvestorPath
}
abort_if($state === 'completed' && ($step['context_review_required'] ?? false) && (! (bool) ($data['context_reviewed'] ?? false) || ($data['context_revision'] ?? -1) !== ($view['investment_context']['revision'] ?? 0)), 422, 'O contexto desta etapa exige nova conferência fundamentada; o registo anterior permanece no histórico.');
$detail = [...$data, 'context_dependency_hash' => $view['investment_context']['dependency_hash'] ?? null, 'fintech_fingerprint' => $view['fintech']['review_fingerprints'][$code] ?? null, 'scope' => $scope, 'actor_id' => $actor->id, 'recorded_at' => now()->toIso8601String()];
DB::table('journey_steps')->where('id', $step['id'])->update(['path_detail' => $this->desk->encode($detail),
$stored = $this->desk->decode(DB::table('journey_steps')->where('id', $step['id'])->value('path_detail'));
if ($key) $stored['opportunities'][$key] = $detail;
else $stored = [...$stored, ...$detail];
DB::table('journey_steps')->where('id', $step['id'])->update(['path_detail' => $this->desk->encode($stored),
'status' => in_array($state, ['completed', 'approved'], true) ? 'completed' : ($state === 'not_started' ? 'pending' : 'active'),
'version_no' => $step['version'] + 1, 'updated_at' => now()]);
DB::table('desk_cases')->where('id', $id)->update(['version' => $case->version + 1, 'updated_at' => now()]);
@@ -267,11 +287,12 @@ final class InvestorPath
abort_unless(is_array($parsed) && ($parsed['version'] ?? null) === 1 && is_array($parsed['data'] ?? null), 422, 'Snapshot financeiro incompatível.');
$payload = $this->desk->decode($case->payload);
$context = app(InvestorContext::class)->state($case);
$opportunity = app(InvestmentConsumerScope::class)->key($context, $economic['context_opportunity'] ?? null, true);
if ($context['revision'] > 0) {
abort_unless(($data['context_revision'] ?? -1) === $context['revision'], 409, 'O contexto mudou; recupere e confira os pressupostos antes de devolver o resultado.');
abort_unless($data['context_reviewed'] ?? false, 422, 'Confirme a revisão dos pressupostos financeiros para este contexto.');
}
$resultKey = hash('sha256', $draft->id.':'.$draft->version_no.':'.$snapshot.':context:'.$context['revision']);
$resultKey = hash('sha256', $draft->id.':'.$draft->version_no.':'.$snapshot.':context:'.$context['revision'].($opportunity ? ':'.$opportunity : ''));
foreach ($payload['investor_path']['results'] ?? [] as $result) {
if ($result['source_key'] === $resultKey) {
return $result;
@@ -280,6 +301,7 @@ final class InvestorPath
abort_unless((int) $case->version === $data['version'], 409, 'Recupere a versão actual do dossier.');
$result = ['id' => (string) Str::uuid(), 'source_key' => $resultKey, 'draft_id' => $draft->id, 'draft_version' => $draft->version_no,
'snapshot_sha256' => $draft->snapshot_sha256, 'financial_snapshot' => $snapshot,
'context_opportunity' => $opportunity,
'currency' => $economic['currency'] ?? null, 'engine' => 'financial-calculators:v1',
'kind' => 'local_calculation_inputs', 'context_state' => 'current', 'context_revision' => $context['revision'], 'context_dependency_hash' => $context['dependency_hash'] ?? app(InvestorContext::class)->dependencyHash([]), 'recorded_at' => now()->toIso8601String()];
$payload['investor_path']['results'][] = $result;
+6 -1
View File
@@ -470,7 +470,10 @@ final class SimulationFormDrafts
private function landContext(array $economic, \stdClass $case, ?array $previous = null): array
{
$decision = app(InvestorContext::class)->landPreparation($case);
$snapshot = json_decode($economic['context_applicability_snapshot'] ?? $previous['context_applicability_snapshot'] ?? '', true);
$key = $economic['context_opportunity'] ?? $snapshot['consumer_opportunity'] ?? null;
$key = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), is_string($key) ? $key : null);
$decision = app(InvestorContext::class)->landPreparation($case, $key);
if ($decision['need'] !== 'yes' && $previous !== null) {
if (array_key_exists('land_status', $previous)) $economic['land_status'] = $previous['land_status'];
else unset($economic['land_status']);
@@ -482,6 +485,8 @@ final class SimulationFormDrafts
$snapshot = ['version' => 1, 'context_revision' => app(InvestorContext::class)->state($case)['revision']];
}
$snapshot['land_preparation'] = $decision;
$snapshot['consumer_opportunity'] = $key;
if ($key) $economic['context_opportunity'] = $key;
$economic['context_applicability_snapshot'] = json_encode($snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
return $economic;
+19 -4
View File
@@ -14,16 +14,31 @@ final class TerritoryGeometry
abort_unless($geography->references($management)->where('id', $id)->exists(), 404);
$path = $geography->path($id, $management);
$ids = array_map(fn ($node) => $node->id, $path);
$approved = app(ApprovedAdministrativeBoundaries::class);
$rows = DB::table('geo_geometries as g')->join('geo_locations as l', 'l.id', '=', 'g.geo_location_id')
->whereIn('g.geo_location_id', $ids)->whereNull('g.deleted_at')->whereNull('l.deleted_at')
->whereIn('g.data_classification', $management ? ['internal', 'public'] : ['public'])
->where(function ($query) use ($management, $approved) {
$query->where('g.data_classification', 'public');
$query->orWhere(function ($internal) use ($management, $approved) {
$internal->where('g.data_classification', 'internal');
if (! $management) {
$internal->whereIn('g.id', array_keys($approved->snapshots()));
}
});
})
->whereRaw("g.properties->>'purpose' = 'administrative_boundary'")
->whereIn('g.geometry_type', ['polygon', 'multipolygon'])
->whereRaw('ST_IsValid(g.geom) AND NOT ST_IsEmpty(g.geom) AND ST_SRID(g.geom)=4326')
->orderBy('g.geo_location_id')->orderBy('g.id')->limit(101)
->get(['g.id', 'g.geo_location_id', 'l.name', 'l.location_type', 'g.properties', 'g.data_classification',
DB::raw('ST_AsGeoJSON(ST_SimplifyPreserveTopology(g.geom,0.0001),6) as geometry')]);
$areas = $rows->take(100)->map(function ($row) use ($id) {
DB::raw('ST_AsGeoJSON(ST_SimplifyPreserveTopology(g.geom,0.0001),6) as geometry'),
DB::raw("encode(sha256(ST_AsEWKB(g.geom)),'hex') as geometry_sha256"),
DB::raw("encode(sha256(convert_to(g.properties::text,'UTF8')),'hex') as properties_sha256")]);
if (! $management) {
$rows = $rows->filter(fn ($row) => $row->data_classification === 'public'
|| $approved->allows($row->id, $row->geo_location_id, $row->geometry_sha256, $row->properties_sha256))->values();
}
$areas = $rows->take(100)->map(function ($row) use ($id, $management) {
$source = json_decode($row->properties, true, 512, JSON_THROW_ON_ERROR);
return ['id' => $row->id, 'territory_id' => $row->geo_location_id, 'location_type' => $row->location_type,
@@ -31,7 +46,7 @@ final class TerritoryGeometry
'title' => 'Limite administrativo: '.$row->name, 'purpose' => 'administrative_boundary',
'source_reference' => $source['authority'] ?? null, 'source_date' => $source['source_version'] ?? null,
'source_url' => $source['source_url'] ?? null, 'source_sha256' => $source['source_sha256'] ?? null,
'data_classification' => $row->data_classification, 'legal_currentness' => $source['legal_currentness'] ?? 'not_determined', 'url' => ''];
'data_classification' => $management ? $row->data_classification : 'public', 'legal_currentness' => $source['legal_currentness'] ?? 'not_determined', 'url' => ''];
})->all();
return ['territory_id' => $id, 'territory_registered' => true,
@@ -0,0 +1,333 @@
{
"status": "installed_in_homologation_territories_recovered_delta_verified",
"date": "2026-10-23",
"base": "8036b9b0",
"accepted_delivery_receipt": "cdf8fcfb",
"active_comparison": {
"database": {
"database": "teste_investidor",
"port": 5432,
"role": "teste_investidor_app"
},
"read_only": "on",
"published_fiches": 29,
"cluster_relations": 13,
"project_locations": 0,
"RLS": [
{
"relname": "projects",
"relrowsecurity": false,
"relforcerowsecurity": false
},
{
"relname": "project_locations",
"relrowsecurity": false,
"relforcerowsecurity": false
},
{
"relname": "cluster_projects",
"relrowsecurity": false,
"relforcerowsecurity": false
},
{
"relname": "investment_dossiers",
"relrowsecurity": false,
"relforcerowsecurity": false
},
{
"relname": "opportunity_projects",
"relrowsecurity": false,
"relforcerowsecurity": false
}
],
"missing_accepted_locations": 28,
"source_inventory": "inventory-20261007-172113.json",
"cause_scope": "Transported project identities omit accepted territorial rows and citation metadata; visibility recovery only restored publication status. Exact event/time not established."
},
"qa_recovery": {
"mode": "applied",
"database": "simulator_fiscal_delta_20261021_test",
"port": 55441,
"qa": true,
"manifest_sha256": "dc3ae2014da51005d95780e2e4ae87e55e000e66e0765f0671c6e44a958026a0",
"preview_sha256": "601a8603cf39f51a22cef9195e238a21c1552cb3303672b78ec52826c6342b57",
"relations": 28,
"projects": 13,
"relations_restored": 28,
"projects_updated": 13,
"new_dossiers": 0,
"publication_changes": 0
},
"tests": {
"php": {
"tests": 4,
"assertions": 116,
"passed": 4
},
"frontend": {
"tests": 2,
"passed": 2
},
"typescript": "passed",
"build": "passed",
"phpstan_new_recovery_service": "passed, zero errors"
},
"backup": {
"stage": "/var/backups/investmoz-anchor-integration-20261023/backup",
"target": {
"database": "teste_investidor",
"port": 5432
},
"tables": 174,
"files_verified": 369,
"dump_sha256": "207ff14c6af7c4b2dd280def898945cb58314e01930b51c50d502e95cbe6ac68",
"files_sha256": "8cd85ab7d5a772452111e4fe7feab48f7fc694ee3f0fbd264f50096254835fe2",
"restore_verified": false
},
"isolated_restore": {
"restore_verified": true,
"tables_verified": 174,
"container_network": "none",
"database": "anchor_restore_20261023",
"port": 55441,
"source_dump_sha256": "207ff14c6af7c4b2dd280def898945cb58314e01930b51c50d502e95cbe6ac68",
"active_database_writes": 0
},
"preserved_closures": [
"6b544548",
"d8224ba8",
"55136b44",
"8036b9b0"
],
"not_repeated": [
"native saved PDF",
"Word",
"ZIP",
"accepted portfolio via opportunity branch",
"accepted complements and four consumers"
],
"deferred": [
"statistics",
"AI"
],
"pending": [],
"screenshots": [
"outputs/anchor-integration-20261023/qa-sector-location.png",
"outputs/anchor-integration-20261023/qa-anchor-fiche.png",
"outputs/anchor-integration-20261023/qa-anchor-dossier.png",
"outputs/anchor-integration-20261023/qa-anchor-simulator.png",
"outputs/anchor-integration-20261023/homologation-sector-location.png",
"outputs/anchor-integration-20261023/homologation-anchor-fiche.png"
],
"visual_QA": {
"anchor_code": "A06",
"anchor_fiche": "57a4ee7a-4c07-4e26-b8f5-e07726e41512",
"case_id": "3fd1bac6-80cf-48c8-97f3-eb4bb3398a17",
"simulation_draft_id": "bbc9ae3d-8c0e-45d2-b93f-9eaa85bab4d4",
"sector": "Agronegócio",
"province": "Sofala",
"offer_invented": false,
"deliberate_title_preserved": true,
"source_link_visible_in_dossier_and_simulator": true,
"manifestation": "HTTP directed proof, including ownership, same-source identity and versioned binding; browser native confirmation cancelled after control timeout; no formal submission"
},
"served_commit": "cf2ddf21a1457f4852fe6aa10069ec3b854f1567",
"functional_commits": [
"3fd5128c",
"cf2ddf21"
],
"installation": {
"service": "ExecMainStatus=0\nActiveState=inactive\nSubState=dead\n",
"installed": {
"status": "installed",
"commit": "cf2ddf21a1457f4852fe6aa10069ec3b854f1567",
"release": "/var/www/investmoz-homologacao/releases/cf2ddf21a1457f4852fe6aa10069ec3b854f1567-7749f82e",
"previous_release": "/var/www/investmoz-homologacao/releases/3fd5128c62296bc5c85dcd19f1458a7949de5784-803cd41e",
"database_migrations": 0,
"content_imports": 0
},
"latest_report": {
"status": "installed",
"commit": "cf2ddf21a1457f4852fe6aa10069ec3b854f1567",
"release": "/var/www/investmoz-homologacao/releases/cf2ddf21a1457f4852fe6aa10069ec3b854f1567-7749f82e",
"previous_release": "/var/www/investmoz-homologacao/releases/3fd5128c62296bc5c85dcd19f1458a7949de5784-803cd41e",
"database_migrations": 0,
"content_imports": 0,
"requested_commits": [
"cf2ddf21a1457f4852fe6aa10069ec3b854f1567"
],
"finished_at": "2026-10-23T17:21:57.897758+00:00"
},
"log_file": "1792776037181186915.log",
"summaries": [
"OK (1 test, 3106 assertions)",
"built in 24.44s",
"OK (57 tests, 309 assertions)",
"# tests 216",
"# pass 216",
"# fail 0"
],
"log_bytes": 92267
},
"recovery_preview": {
"mode": "preview",
"database": "teste_investidor",
"port": 5432,
"qa": false,
"manifest_sha256": "d2893d0a638d630435aca1890f79a8094dc58eb65bbdd65a470bb685fa075190",
"preview_sha256": "f59e324222ea480d176f1e8e6ae164efa184904c8244873d3fcf6af8497e0dac",
"relations": 28,
"projects": 13
},
"recovery_applied": {
"mode": "applied",
"database": "teste_investidor",
"port": 5432,
"qa": false,
"manifest_sha256": "d2893d0a638d630435aca1890f79a8094dc58eb65bbdd65a470bb685fa075190",
"preview_sha256": "f59e324222ea480d176f1e8e6ae164efa184904c8244873d3fcf6af8497e0dac",
"relations": 28,
"projects": 13,
"relations_restored": 28,
"projects_updated": 13,
"new_dossiers": 0,
"publication_changes": 0
},
"installed_delta": {
"published_fiches": 29,
"territorial_links": 28,
"affected_projects": 13,
"cluster_relations": 13,
"dossier_rows_unchanged": true,
"clusters_corridors_opportunity_relations_unchanged": true,
"historical_territorial_link_identities_preserved": true,
"only_project_metadata_version_timestamp_changed": true,
"functional_file_hashes_match_Git": {
"app/Http/Controllers/InvestmentPortfolioController.php": true,
"app/Http/Controllers/InvestorContextController.php": true,
"app/Services/ApprovedAnchorTerritories.php": true,
"app/Services/InvestorContext.php": true,
"resources/js/lib/investor-context.ts": true,
"resources/js/lib/project-dossier-report.ts": true,
"resources/js/lib/search-territory.ts": true,
"resources/js/pages/investment/submission.tsx": true,
"resources/js/pages/investor-path/context-start.tsx": true,
"resources/js/pages/investor-path/context.tsx": true,
"resources/js/pages/investor-path/show.tsx": true,
"resources/js/pages/portfolio/index.tsx": true,
"resources/js/pages/portfolio/show.tsx": true,
"routes/web.php": true,
"tools/restore-approved-anchor-territories.php": true
},
"pipeline_summaries": [
"OK (1 test, 3106 assertions)",
"built in 24.44s",
"OK (57 tests, 309 assertions)",
"# tests 216",
"# pass 216",
"# fail 0"
],
"table_preservation": {
"tables": 174,
"changed_tables": {
"audit_events": {
"before": {
"rows": 632,
"checksum": "d4f2e8b30c014080b33a588eb0d8afe2"
},
"after": {
"rows": 645,
"checksum": "88c2047620eb3fad4d2a2837ed3daef5"
}
},
"cache": {
"before": {
"rows": 104,
"checksum": "463aff806806b9fa2e4dff3310f516ec"
},
"after": {
"rows": 104,
"checksum": "4963d5a25fecf3352cf0d7aee73a1e63"
}
},
"project_locations": {
"before": {
"rows": 0,
"checksum": "d41d8cd98f00b204e9800998ecf8427e"
},
"after": {
"rows": 28,
"checksum": "80b4e44b11208a023fcfb989e738b95a"
}
},
"projects": {
"before": {
"rows": 16,
"checksum": "af127d727cfc1050c26b99130e397109"
},
"after": {
"rows": 16,
"checksum": "17fb48a69b09e1e8ec81b0d30e29ce80"
}
},
"sessions": {
"before": {
"rows": 11,
"checksum": "912e728cc332bfc41a7e0f94c84e118d"
},
"after": {
"rows": 15,
"checksum": "713242c22c549258fab61d1b9cd993ef"
}
}
},
"unchanged_tables": 169
},
"exact_29_approved_fiche_identities_preserved": true,
"synthetic_fiches_transported": 0
},
"Git_preservation": {
"accepted_base_receipt": "cdf8fcfb",
"previous_installed": "8036b9b0",
"candidate": "cf2ddf21a1457f4852fe6aa10069ec3b854f1567",
"protected_files_unchanged": {
"app/Services/InvestmentComplements.php": true,
"app/Services/InvestmentConsumerScope.php": true,
"app/Services/InvestorPath.php": true,
"app/Services/SimulationFormDrafts.php": true,
"resources/js/components/investment-complements.tsx": true,
"resources/js/components/dossier-prefill.tsx": true,
"resources/js/components/investment-requirements.tsx": true
},
"matrix_changed_rows_since_accepted_receipt": [
"AUD-REL-02",
"AUD-LEG-06"
],
"other_matrix_records_preserved": 3421,
"accepted_receipt_updates_preserved": [
"AUD-SIM-INT03",
"AUD-SIM-INT06"
],
"schema_migrations_added": [],
"stats_AI_code_changed": false,
"force_push": false
},
"manifest_verification": {
"Git_LF_sha256": "d2893d0a638d630435aca1890f79a8094dc58eb65bbdd65a470bb685fa075190",
"initial_QA_Windows_bytes_sha256": "dc3ae2014da51005d95780e2e4ae87e55e000e66e0765f0671c6e44a958026a0",
"canonicalization": "CRLF to LF only; identical JSON and accepted relations",
"first_active_preview": "refused before writes; corrected canonical preview and apply succeeded"
},
"next": "Tempo final navigation and presentation evaluation",
"not_a_claim_of_complete_transversal_implementation": true,
"installed_public_delta": {
"catalogue": "https://teste-investidor.tempo.co.mz/carteira?kind=project&sector=Agro-ind%C3%BAstria%20e%20agricultura&province_id=f57ff041-6275-57b9-8800-c35e46e0d475",
"results": 1,
"fiche": "57a4ee7a-4c07-4e26-b8f5-e07726e41512",
"source": "portfolio:57a4ee7a-4c07-4e26-b8f5-e07726e41512",
"territories_visible": 4,
"search_return_preserves_filters": true,
"preparation_GET_same_identity_and_province": true,
"homologation_test_dossiers_or_submissions_created": 0
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,89 @@
{
"date": "2026-10-23",
"status": "installed_in_homologation_delta_verified",
"functional_commit": "1afdfee8",
"base": "bc84fbdb",
"accepted_deliveries_preserved": [
"6b544548",
"d8224ba8",
"55136b44"
],
"qa": {
"database": "simulator_fiscal_delta_20261021_test",
"port": 55441,
"guard": "asserted in tests and fixture preparation",
"case_id": "d279172b-8bd1-4074-b000-ee25f0d29778",
"actor": "synthetic existing QA actor 509",
"browser": "Edge http://127.0.0.1:18574",
"existing_accepted_cases_modified": false
},
"changes": [
"simulator explicit or inherited opportunity key saved and recovered",
"applicability facts reasons and stage details by opportunity without replacing common data",
"land shortcut filters dossier owner and chosen opportunity",
"requirement and return links preserve scope",
"authorised common contact proposals where duplication was found",
"Word/ZIP/print composition identifies selected scope and applicable stages"
],
"verification": {
"http_tests": 7,
"http_assertions": 170,
"http_suite": "tests/Feature/InvestmentTreatmentScopeTest.php",
"frontend_tests": 2,
"frontend_suite": "tests/frontend/treatment-scope-export.test.mjs",
"typescript": "passed",
"production_build": "passed",
"visual": "new QA dossier second Manica / first Sofala / second; general simulator selection and scoped return to dossier"
},
"remaining": [
{
"scope": "native PDF",
"status": "only saved native PDF pagination and legibility remain unverified; HTML is not that proof",
"limitation": "browser-only control does not expose system print/save dialogue; no failure attributed to application"
},
{
"scope": "anchor projects sector and location search with manifestation and investment preparation continuity",
"status": "to analyse; no inference of implementation or defect",
"matrix": "AUD-REL-02"
}
],
"limits": [
"no homologation deployment or active data/schema/config/permission change",
"no repeat of accepted complement or four-consumer tests",
"no new visual proof of every alternative entry or removal editor; directed HTTP proof only",
"no statistics or AI work",
"temporary fixture clone location confirmation and test version field corrected before passing results"
],
"artifacts": [
{
"path": "outputs/multi-opportunity-fix-20261023/treatment-dossier.png",
"sha256": "2b5f65064c80e376efdb2d12c87725c042027296f1e4ab6536f140462482562f"
},
{
"path": "outputs/multi-opportunity-fix-20261023/treatment-dossier.txt",
"sha256": "0887a3ab5da2c6eccd369c7ca691df9d5f8d92279f6202eddf15b1652c138c89"
},
{
"path": "outputs/multi-opportunity-fix-20261023/treatment-simulator.txt",
"sha256": "410f91d3afe220ec46f435c9de62d34671b2096d961527d0ce97a3edb9838b34"
},
{
"path": "outputs/multi-opportunity-fix-20261023/treatment-exports/second.docx",
"sha256": "7aeadecb3684d000d5fe0c3b93d339f380c434daf3890ae24bd79c2f723ae40b"
},
{
"path": "outputs/multi-opportunity-fix-20261023/treatment-exports/second.zip",
"sha256": "a3c1100d9c9ffc75fbda0f0489479c17275e8fa0b3025ad3ae2ecb293a9aa1f8"
},
{
"path": "outputs/multi-opportunity-fix-20261023/treatment-exports/second-print.html",
"sha256": "dffff89a9d4f186698bbc077a8a5031bd95290e12bcdba7b00ec7cd1a095bc4a"
}
],
"installation": {
"status": "installed_in_homologation_delta_verified",
"commit": "8036b9b071229dbf7e85ea748db70abcbeefc379",
"receipt": "treatment-scope-installation-receipt-20261023.json",
"delta_verified": true
}
}
@@ -0,0 +1,123 @@
{
"scope": "Shared administrative maps stabilization",
"source_revision": "cf2ddf21",
"candidate": "066707ec",
"owner_decision": "Tempo 2026-10-24: public consultation of the exact accepted administrative contours; originals private, no parcels or other internal layers",
"diagnosis": {
"data_loss": false,
"common_consumers": "Public-only filter excluded all 408 internal administrative boundaries",
"administration": "Legacy manage-portal gate excluded both approved superusers",
"comparison": "No change in common geometry service/controller/components between 30aed7cd and cf2ddf21",
"base_map": "Provider tiles and served assets respond; browser framing is checked after installation"
},
"qa": {
"database": "simulator_fiscal_delta_20261021_test",
"port": 55441,
"tests": 6,
"assertions": 64,
"result": "passed",
"phpstan": "passed",
"writes": "Only isolated QA transactions, rolled back"
},
"baseline": {
"published_fiches": 29,
"territorial_relations": 28,
"geometries": 408,
"geometry_rows_checksum": "7d8b71d3e65d89ffdc43c748b0572dbc"
},
"backup": {
"tables": 174,
"files_verified": 369,
"restore_tables_verified": 174,
"restore_network": "none",
"dump_sha256": "2fb5b4488799bb6072c38ee41ccf91fc2a55561363c19e280ab066363d9e0647"
},
"public_manifest_sha256": "06ab905fcf9b19115c3521bf81de03f37873880f7765f229b9e1f79d99df7adc",
"installed": {
"status": "installed",
"commit": "066707ece631f0a7c963031c211844fbdd68916b",
"release": "/var/www/investmoz-homologacao/releases/066707ece631f0a7c963031c211844fbdd68916b-6b7bb5bd",
"previous_release": "/var/www/investmoz-homologacao/releases/cf2ddf21a1457f4852fe6aa10069ec3b854f1567-7749f82e",
"database_migrations": 0,
"content_imports": 0
},
"deferred": [
"Statistics",
"AI"
],
"accepted_tests_repeated": false,
"public_projection": {
"available": 408,
"levels": {
"admin_post": 264,
"district": 133,
"province": 11
},
"failures": [],
"manifest_sha256": "06ab905fcf9b19115c3521bf81de03f37873880f7765f229b9e1f79d99df7adc"
},
"preservation": {
"tables": 174,
"changed_tables": {
"cache": {
"before": {
"rows": 104,
"checksum": "5d172b4d252c7b025f23ac70cf570cb6"
},
"after": {
"rows": 104,
"checksum": "a60f3e47e1ae28937404f05f45e33135"
}
},
"sessions": {
"before": {
"rows": 6,
"checksum": "dcdb7eba3f3e8f29f40ec3f7542be6ce"
},
"after": {
"rows": 7,
"checksum": "0059c0b0e16d532c6658fd420582f2e9"
}
}
},
"unchanged_tables": 172
},
"installed_file_sha256": {
"app/Services/ApprovedAdministrativeBoundaries.php": "401fd62795ca8f33106be6e07130d9207d6661695440bfc40f62f6f6b42bec64",
"app/Services/TerritoryGeometry.php": "00ce3e6dde92868b0d34b963b8e59a26e6663149cdc0398c645ef636ca382966",
"app/Http/Controllers/InvestmentTerritoryController.php": "3b184c6ea1b97f0abe86d01567d3d73444c49192c638478a6d53619e63d5d18d",
"app/Http/Middleware/EnsureScopedPortalAdministration.php": "1474db760f6bd6003a5325ae721698e90e2709a2354b51abde77f920a9dad81a",
"docs/investmoz/unification/approved-administrative-boundaries-20261024.json": "06ab905fcf9b19115c3521bf81de03f37873880f7765f229b9e1f79d99df7adc"
},
"pipeline_summaries": [
"OK (1 test, 3106 assertions)",
"built in 24.58s",
"OK (57 tests, 309 assertions)",
"# tests 216",
"# pass 216",
"# fail 0"
],
"proofs": {
"anonymous_http": {
"status": 200,
"territory_id": "dd219b89-cc17-5944-8c7d-d2766c82b526",
"selected": "DONDO",
"ancestor": "Sofala",
"areas": 2
},
"browser": {
"province_to_district": "passed",
"framing": "selected district with ancestor context",
"reload_preserves_selection": true,
"zoom": "passed",
"visible_tiles_loaded": 36,
"visible_tiles_failed": 0,
"screenshot": "outputs/map-stabilization-20261024/dondo-installed.png",
"screenshot_sha256": "7569f3b66aceb7ef9816c31bfaa172ebcdfa10e4911961b67a129a654b5cd349"
},
"business_rows_unchanged": true,
"active_synthetic_requests_created": 0
},
"status": "installed_delta_verified",
"remaining_in_scope": []
}
@@ -0,0 +1,63 @@
{
"date": "2026-10-23",
"candidate": "1afdfee8",
"native_report_fix": "2856240c",
"deployment_status": "installed_in_homologation_delta_verified",
"origin_main_checked": "55136b44",
"accepted_scopes_retested": false,
"new_test": "tests/frontend/native-pdf-scope.test.mjs: 1 passed",
"types": "passed",
"build": "passed",
"qa_report": {
"revision": 13,
"opportunity": "opportunity:309",
"location": "Manica",
"declared_funding_total": "123456 MZN",
"declared_secured": "45678 MZN",
"expected_gap": "77778.00 MZN",
"native_pdf_expected": "outputs/multi-opportunity-fix-20261023/candidata-1afdfee8.pdf",
"pdf_saved_verified": true,
"native_pdf_actual": "C:\\laragon\\www\\portal-investidor\\outputs\\multi-opportunity-fix-20261023\\Relatório — SYNTHETIC QA - escolha comum aos tres consumidores.pdf",
"native_pdf_sha256": "010eb4ea12015563c1b2f8bc255b1078c276f824c50690732541d5bffe34b8a6",
"native_pdf_pages": 10,
"visual_pagination": "passed pages1-10",
"file_discovery": {
"machine": "ONALA",
"user": "macbook",
"session": "Console",
"extension": ".pdf",
"size_bytes": 191637,
"requested_basename_not_found": "candidata-1afdfee8.pdf",
"actual_basename": "Relatório — SYNTHETIC QA - escolha comum aos tres consumidores.pdf",
"resolution": "existing file discovered by listing the exact requested directory; no rename, copy or PDF regeneration"
}
},
"anchor_analysis": {
"status": "analysed_not_implemented",
"confirmed_code_gaps": [
"sector filter absent from portfolio UI and query contract",
"no direct idea preparation from anchor fiche without an associated opportunity"
],
"directed_verification_remaining": [
"reconcile territorial data with demonstrated and destination state using equivalent access",
"anonymous interest authentication return and same-fiche identity"
],
"existing_accepted_branch": "portfolio origin via associated opportunity to dossier and simulator is preserved, not retested",
"qa_documentary_anchors": 15,
"qa_document_sector_location_region_present": true,
"qa_canonical_primary_territories": 0,
"qa_public_location_links": 0,
"qa_published_opportunity_links": 0,
"synthetic_anchor_excluded": true,
"inventory_sha256": "7d2c0c36fa51b05ce97dfd25c997b77eccf7136a99220ed8607fcd2436fd3d39",
"no_homologation_loss_inferred": true
},
"statistics_and_AI": "deferred",
"installation": {
"status": "installed_in_homologation_delta_verified",
"commit": "8036b9b071229dbf7e85ea748db70abcbeefc379",
"receipt": "treatment-scope-installation-receipt-20261023.json",
"delta_verified": true
},
"status": "installed_in_homologation_delta_verified"
}
@@ -35,8 +35,9 @@
"number": 3,
"component": "Navegação contextual e manifestação de interesse",
"historic_status": "Disponível",
"current_preservation_status": "Navegação/percurso preservados; carteira documental reposta em 23/10: 29 dossiers, incluindo 15 projectos âncora.",
"evidence": "[Abrir](https://teste-investidor.tempo.co.mz/investimentos/iniciar) · Início, contexto e navegação abrem; gravação/recuperação aceites em QA reutilizadas. Sem dossier real no destino."
"current_preservation_status": "Continuity and multi-opportunity consumers delivered: 55136b44, 8036b9b0, cf2ddf21. Accepted proofs preserved.",
"evidence": "[Abrir](https://teste-investidor.tempo.co.mz/investimentos/iniciar) · Início, contexto e navegação abrem; gravação/recuperação aceites em QA reutilizadas. Sem dossier real no destino.",
"status_before_stabilization_closure": "Navegação/percurso preservados; carteira documental reposta em 23/10: 29 dossiers, incluindo 15 projectos âncora."
},
{
"number": 4,
@@ -175,8 +176,9 @@
"number": 23,
"component": "Cartografia e consumidores territoriais restantes",
"historic_status": "Não demonstrado",
"current_preservation_status": "Demarcação reposta: 408 geometrias. Polígonos de localidades e correspondências já pendentes não são nova regressão.",
"evidence": "[Abrir](https://teste-investidor.tempo.co.mz/oportunidades) · Catálogos/mapas abrem. 1 921 referências geográficas no alvo versus 1 990 locais; diferença não reconciliada por item fora do manifesto."
"current_preservation_status": "Shared maps delivered 066707ec/5d45541a: 408 accepted contours publicly available; originals private, 29 fiches and 28 relations preserved. Missing locality polygons remain future work.",
"evidence": "[Abrir](https://teste-investidor.tempo.co.mz/oportunidades) · Catálogos/mapas abrem. 1 921 referências geográficas no alvo versus 1 990 locais; diferença não reconciliada por item fora do manifesto.",
"status_before_stabilization_closure": "Demarcação reposta: 408 geometrias. Polígonos de localidades e correspondências já pendentes não são nova regressão."
},
{
"number": 24,
@@ -210,8 +212,9 @@
"number": 28,
"component": "Pesquisa — estados do recorte R2",
"historic_status": "Disponível",
"current_preservation_status": "Pesquisa e selectores corrigidos em 22/10; a carteira vazia é tratada como perda separada.",
"evidence": "[Abrir](https://teste-investidor.tempo.co.mz/oportunidades) · Catálogos abrem; quatro estados e protecção contra respostas antigas preservados do R2, sem repetir ensaios aceites."
"current_preservation_status": "Accepted search/selectors preserved; portfolio visibility and anchor continuity restored and delivered cf2ddf21. No current portfolio-empty fault.",
"evidence": "[Abrir](https://teste-investidor.tempo.co.mz/oportunidades) · Catálogos abrem; quatro estados e protecção contra respostas antigas preservados do R2, sem repetir ensaios aceites.",
"status_before_stabilization_closure": "Pesquisa e selectores corrigidos em 22/10; a carteira vazia é tratada como perda separada."
},
{
"number": 29,
@@ -945,5 +948,79 @@
"new_roles_or_approvers": false,
"new_content_approval": false
},
"qa_recovery_preparation_note": "An initial QA audit-key collision rolled back atomically. Audit identity was scoped per original before the successful QA and destination recovery; no active attempt used the faulty key."
"qa_recovery_preparation_note": "An initial QA audit-key collision rolled back atomically. Audit identity was scoped per original before the successful QA and destination recovery; no active attempt used the faulty key.",
"stabilization_closure_20261024": {
"basis": "Tempo explicit sequence and technical preservation responsibility, 24/10/2026",
"installed_functional_baseline": "066707ece631f0a7c963031c211844fbdd68916b",
"map_receipt": "5d45541a",
"known_open_faults_in_delimited_stabilization_scope": [],
"does_not_close_all_43_components": true,
"prior_losses_do_not_share_a_proven_single_cause": true,
"accepted_closures": [
"6b544548/4098192a",
"d8224ba8/af926180",
"55136b44/b6a626c9",
"8036b9b0/cdf8fcfb",
"cf2ddf21/c1f4a719",
"066707ec/5d45541a"
],
"documentary_limits": [
"Six legacy opportunity/anchor-cluster pairs without documented identity remain indeterminate: AUD-REL-02",
"Two incomplete legacy originals retain their existing documentary limit"
],
"not_yet_proven_component_numbers": [
6,
18,
21,
22,
24,
25,
35
],
"future_requirement_ids": [
"AUD-MARKET-01",
"AUD-ESTABLISHMENT-01",
"AUD-FUND-01",
"AUD-COMPANY-01",
"AUD-COMPANY-02",
"AUD-CAPITAL-01",
"AUD-CAPITAL-02",
"AUD-FINTECH-01"
],
"territorial_count_reconciliation": {
"source": "outputs/preservation-inventory-20261023/local.json and homologation.json",
"public_active_local": 1921,
"public_active_target": 1921,
"local_archived_internal_groups": [
32,
32,
5
],
"individual_identity_comparison_of_archived_69_claimed": false,
"reactivation_authorized": false
},
"new_control": {
"test": "tests/Unit/ApprovedAdministrativeBoundariesTest.php",
"automatic_delivery_suite": "Unit",
"tests": 2,
"assertions": 8,
"result": "passed",
"scope": "Approved public snapshot identity; changed geometry/metadata, wrong territory and new internal identity rejected",
"http_access_contract": "tests/Feature/TerritoryGeometryConsumerAccessTest.php",
"http_contract_run_when": "Geometry/access/hierarchy changes, on isolated QA only; existing accepted result reused here"
},
"technical_preservation_owner": "Codex and programmers",
"Tempo_review": "Utility, clarity and presentation; no obligation to rediscover technical regressions",
"sequence": [
"Corrections and stabilization",
"Fiscal simulator with defined scope",
"Real estate",
"Consumer goods and franchises",
"Statistics and dashboards",
"AI"
],
"accepted_acceptance_tests_repeated": false,
"active_database_writes": 0,
"runtime_application_source_changes": 0
}
}
@@ -0,0 +1,80 @@
{
"reference_commit": "276d833a4b4f9071db328b653f48529b2dfac4f9",
"runtime_files_changed_since_066707ec": [],
"matrix_records": 3423,
"matrix_columns": 49,
"updated_existing_rows": [
"AUD-GEO-01",
"AUD-GEO-02",
"AUD-GEO-03",
"AUD-REL-01",
"AUD-REL-02",
"AUD-DESK-01",
"AUD-DESK-02",
"AUD-SIM-01",
"AUD-LEG-06"
],
"accepted_receipts_unchanged_sha256": {
"map-stabilization-receipt-20261024.json": "2b69216b644f2244897431a10f3e6828e822d1317fab326a457a5934261c1618",
"anchor-integration-receipt-20261023.json": "86b0d01aa2fb03be84c1b1c6254ea0932abe813375f00560aaf39ce78d199f3d",
"treatment-scope-installation-receipt-20261023.json": "a0904250e1ed201276fce0318b9cc131edf4c8ec0ab6d449536b1e1344906798",
"multi-opportunity-context-installation-receipt-20261023.json": "3e1030de9aafd916252926a33d18cf808a05e389ddce5d9a3cd5230f039bc8f2"
},
"new_tests": {
"tests": 2,
"assertions": 8,
"passed": true
},
"active_database_writes": 0,
"status": "installed_reference_verified",
"date_Africa_Johannesburg": "2026-10-24",
"git_tag": "reference/stabilization-20261024",
"served_revision": {
"commit": "276d833a4b4f9071db328b653f48529b2dfac4f9",
"branch": "main",
"built_at": "2026-10-24T09:10:08.359035+00:00",
"manifest_sha256": "949d1047867f5879e724e5ddc38d4865b06aeb92641337d3952ea7b5ad55bb7a"
},
"previous_functional_revision": "066707ece631f0a7c963031c211844fbdd68916b",
"verification_method": "New unit control tested; semantic matrix/inventory reconciliation; canonical accepted receipt hashes; empty Git diff for runtime/schema/config/permissions/deploy; public installed revision. Prior accepted technical and visual proofs reused.",
"accepted_tests_repeated_by_this_review": false,
"automatic_installer": "Normal existing pipeline; no extra manual acceptance round",
"fresh_authenticated_database_read_claimed": false,
"business_data_preservation": "Accepted map receipt 5d45541a reused: 29 fiches, 28 links, 408 geometries, 172 business/nonvolatile tables equal; no runtime or data mutation introduced by this reference delta",
"known_open_stabilization_faults": [],
"documentary_pending": [
"AUD-REL-02: six legacy opportunity/anchor-cluster pairs remain indeterminate without documented relationships",
"Two incomplete originals in the legacy collection retain the accepted documentary limit"
],
"not_yet_proven_existing_components": [
6,
18,
21,
22,
24,
25,
35
],
"future_requirements": [
"AUD-MARKET-01",
"AUD-ESTABLISHMENT-01",
"AUD-FUND-01",
"AUD-COMPANY-01",
"AUD-COMPANY-02",
"AUD-CAPITAL-01",
"AUD-CAPITAL-02",
"AUD-FINTECH-01"
],
"technical_preservation_owner": "Codex and programmers",
"Tempo_evaluation": "Usefulness, clarity, presentation; does not need to rediscover technical regressions",
"next": "Fiscal simulator scope definition; not started by this consolidation",
"reference_sequence": [
"Stabilization",
"Defined fiscal simulator scope",
"Real estate",
"Consumer goods and franchises",
"Statistics and dashboards",
"AI"
],
"not_complete_portal_acceptance": true
}
@@ -1,5 +1,161 @@
# Rastreabilidade, utilização e formação
## Fecho do lote de estabilização e sequência de referência — 24/10/2026
**Ordem expressa da Tempo:** (1) correcções e estabilização; (2) simulador fiscal com âmbito definido; (3) Imobiliário; (4) Bens de consumo e franquias; (5) Estatística e painéis; (6) IA. A preservação técnica é responsabilidade do Codex e dos programadores. A Tempo avalia utilidade, clareza e apresentação; não tem de redescobrir regressões para comprovar preservação.
Consolidação sobre a revisão instalada `066707ec`, recibo `5d45541a`, mantendo a mesma matriz e o inventário dos 43 componentes. Nesta passagem não foi encontrada nova falha executável nos recortes de estabilização já delimitados. A revisão de código confirma que a primeira oportunidade usada na migração do contexto comum não substitui o âmbito dos consumidores: `InvestmentConsumerScope` filtra a escolha antes do snapshot e dos relatórios. Não foram repetidas as provas aceites, impressões, submissões ou alterações de dados. A confirmação da Tempo anterior baseou-se no relatório comunicado, sem conferência do recibo local; não é registada como uma nova prova técnica ou visual.
| Situação no lote | Percursos e saldo concreto |
| --- | --- |
| Resolvido e entregue | Gravação/idioma, proveniência do relatório e Beira/sete oportunidades: `6b544548`/`4098192a`. Complementos: `d8224ba8`/`af926180`. Zonas/parques, Terra/DUAT, Balcão e manifestação por oportunidade: `55136b44`/`b6a626c9`. Selector do simulador, etapas/requisitos e pedido Terra correcto, incluindo PDF guardado: `8036b9b0`/`cdf8fcfb`. Âncoras, sector/localização e continuidade: `cf2ddf21`/`c1f4a719`. Mapas: `066707ec`/`5d45541a`. Pendências históricas destes trechos ficam superadas pelas respectivas entregas. |
| Falha conhecida ainda aberta neste lote | **Nenhuma identificada nos recortes enumerados.** Não equivale a aceitação integral dos 43 componentes ou de todas as variantes do portal. Não se inventa uma nova correcção para justificar outra ronda. |
| Pendência documental preservada | `AUD-REL-02`: os pares das seis oportunidades legadas com âncoras/clusters continuam indeterminados quando não há relação documentada. Não confundir com as 28 relações recuperadas dos projectos. Preservar a preparação independente; não associar por proximidade ou nome. Dois originais incompletos do acervo legado conservam o limite documental já registado, sem representar perda causada pelos mapas. |
| Ainda sem prova no âmbito próprio | Inventário existente: consolidação de duplicados distintos (componente 6); aplicação/editor e versões de checklists (18); partilha e variantes de sala (21/35); modalidades/SLA e fecho operacional/documental (22/25); acessibilidade além das provas aceites (24). Não foram identificadas novas regressões nesses itens; não os declarar concluídos nem exigir processos reais/sintéticos na homologação para fechar este lote. Mantêm os critérios próprios da matriz, sem atribuição de novo prazo ou prioridade. |
| Desenvolvimento futuro, separado de regressões | Negócios similares por raio (`AUD-MARKET-01`), importador de empresas/empreendimentos (`AUD-ESTABLISHMENT-01`), catálogo de fundos (`AUD-FUND-01`), comparador societário e modelos específicos (`AUD-COMPANY-01/02`), guiões de capitalização/Bolsa (`AUD-CAPITAL-01/02`), polígonos próprios ainda inexistentes e enquadramento Fintech específico. Variantes de Imobiliário/franquias, Estatística/painéis e IA seguem a ordem acima; SuiteCRM mantém a opção anterior de desactivação. Não se declara que todo o código destes módulos esteja ausente. |
**Causas independentes:** os mapas tinham filtro público e dependência de uma permissão antiga; a carteira sofreu perda de visibilidade de registos presentes; as relações de âncoras tinham omissão no transporte entre identidades documentais. As provas delimitam esses casos e não estabelecem a causa de todas as perdas anteriores. O inventário histórico territorial tinha 1 990 linhas locais contra 1 921 no destino: **1 921 públicas activas em ambos, mais 69 internas arquivadas apenas no local**, repartidas em 32+32+5. Isto explica a diferença agregada; não é prova de correspondência individual das 69 nem autoriza reactivá-las. Nenhuma reposição adicional decorre dessa contagem.
### Controlo técnico nas próximas entregas
Reutilizar o inventário/recibos e comparar apenas o delta e as dependências afectadas. O pipeline existente conserva detecção de alterações concorrentes na árvore instalada, contrato de schema, QA isolado, assets imutáveis, troca atómica e reversão por falha de saúde. A suite normal de entrega passa também a incluir `ApprovedAdministrativeBoundariesTest`: identidade aceite e recusa de novo contorno interno, território trocado, geometria ou metadados alterados. É controlo novo de âmbito público, não repetição da demonstração visual aceite. As duas provas novas passaram, com oito asserções e sem acesso à base.
Quando se alterar acesso, hierarquia ou serviço de mapas, executar o delta correspondente de `TerritoryGeometryConsumerAccessTest` em QA isolado; cobre o filtro público e os perfis aprovados sem `manage-portal`, incluindo MFA administrativo. O teste unitário isolado não substitui esse contrato HTTP. Para alterações de contexto, usar os casos afectados de `InvestmentTreatmentScopeTest`, `InvestmentAnchorIntegrationTest` e consumidores/complementos, sem repetir indiscriminadamente toda a suite de aceitação. No destino, comparar identidades/fontes/estado de publicação das 29 fichas, 28 relações e 408 geometrias com os manifestos aceites, configuração e permissões equivalentes; mudanças legítimas posteriores são reconciliadas explicitamente, não sobrescritas. Backup/restauro e acção autenticada precedem qualquer escrita de dados activos.
**Saldo de fecho:** zero falhas técnicas conhecidas abertas neste lote; limites documentais, itens sem prova e desenvolvimento futuro permanecem identificados acima e nas mesmas linhas da matriz. Não se declara a aplicação transversal integralmente concluída. O próximo lote é a delimitação do simulador fiscal; esta passagem não activa cálculos, fontes, estatísticas ou IA. A avaliação de navegação/apresentação da Tempo pode continuar pelo percurso normal e alimenta apenas falhas concretamente observadas.
**Referência consolidada instalada: `276d833a4b4f9071db328b653f48529b2dfac4f9`**, confirmada pelo `/build/revision.json` público. Etiqueta Git `reference/stabilization-20261024`. A comparação Git de `app`, `resources`, `routes`, `database`, `config`, `public` e `deploy` com `066707ec` é vazia; não há novo delta funcional a reensaiar. Os recibos aceites conservam os mesmos bytes canónicos. Mantidas 3 423 linhas lógicas e 49 colunas da matriz; só nove linhas existentes foram reconciliadas. A prova anterior de preservação das 29 fichas, 28 relações e 408 geometrias é reutilizada, não apresentada como nova consulta autenticada da base nesta passagem. [Recibo da referência](stabilization-reference-receipt-20261024.json).
## Mapas — estabilização comum e divulgação delimitada, 24/10/2026
Diagnóstico por leitura autenticada na homologação `cf2ddf21`: **29 fichas, 28 relações territoriais e 408 geometrias válidas**, sem eliminação. Os 408 limites INE/2019 conservam classificação interna, propósito administrativo e SRID 4326. O fingerprint integral das linhas é `7d8b71d3e65d89ffdc43c748b0572dbc`. Não há RLS nas tabelas comparadas. O serviço comum não mudou desde `30aed7cd`: os consumidores gerais pediam exclusivamente geometrias públicas; o cadastro usava ainda `manage-portal`, ausente nos dois superutilizadores aprovados. A prova anterior demonstrava o cadastro privado, não a divulgação pública. O mapa base, tiles e assets carregam; a resposta territorial vazia explica a ausência de contornos e de enquadramento do território escolhido.
**Decisão expressa da Tempo nesta passagem:** «Sim, disponibilizar os contornos administrativos aceites». Implementada uma projecção pública de leitura dos [408 snapshots exactos](approved-administrative-boundaries-20261024.json), conferindo identidade e hashes da geometria original e metadados. Não há reimportação, reclassificação de linhas, publicação de parcelas, acesso aos ficheiros originais ou abertura de outras camadas internas. Alterações posteriores não recebem divulgação automática. Os perfis autorizados reutilizam o acesso privado já aprovado; a leitura administrativa conserva MFA. Respostas privadas não são guardadas em cache partilhada.
QA isolado `simulator_fiscal_delta_20261021_test:55441`: seis testes dirigidos, 64 asserções; selecção, gravação/recuperação no dossier e prefill do simulador com a mesma identidade, leitura pública autorizada, bloqueio de camadas não autorizadas e de snapshots alterados, permissões/MFA e fingerprint original preservado. PHPStan dos quatro ficheiros funcionais passou. Backup fresco: 174 tabelas, 369 ficheiros conferidos; restauro isolado sem rede reproduziu as 174 tabelas.
**Entregue na homologação em `066707ece631f0a7c963031c211844fbdd68916b`**, por avanço normal do Git, preservando `cf2ddf21` e as alterações concorrentes. Instalador: contrato 1/3106, PHP 57/309, frontend 216/216 e build aprovados; zero migrações e zero importações. Os cinco ficheiros funcionais instalados coincidem com o Git. Consulta pública pelo serviço instalado: **408/408 disponíveis**, 11 províncias, 133 distritos e 264 postos, zero falhas. HTTP anónimo recupera Dondo e Sofala pela identidade canónica, com Dondo seleccionado e Sofala como contexto ancestral. Conferência visual: mudança de província para distrito actualiza o contorno e enquadramento; escolha preservada após recarregar, zoom operacional e 36 tiles carregados sem falha na observação final. Houve falhas transitórias de tiles na janela anterior; os mesmos URLs responderam 200 e o recarregamento recuperou a base, sem alteração de configuração ou fornecedor.
Comparação das 174 tabelas: **172 iguais por contagem/hash**, diferenças apenas em `cache` e `sessions`; tabelas de negócio, geometrias, permissões e fontes intactas. Conservados 29 fichas, 28 relações e o fingerprint integral das 408 geometrias. Nenhum dossier/pedido sintético criado na homologação. [Recibo da estabilização](map-stabilization-receipt-20261024.json); prova visual em `outputs/map-stabilization-20261024/dondo-installed.png`. O saldo técnico desta reposição está encerrado; a avaliação de navegação e apresentação pela Tempo permanece o próximo trabalho.
Preservados os fechos `d8224ba8`, `55136b44`, `8036b9b0` e `cf2ddf21`/`c1f4a719`, sem repetição das provas aceites. Localidades sem polígono próprio conservam apenas o contexto ancestral explicitamente identificado; não são inventadas coordenadas ou parcelas. Estatísticas e IA permanecem adiadas. Esta estabilização não declara concluída a aplicação transversal nem substitui a avaliação de navegação e apresentação pela Tempo.
## Projectos âncora — entregues na homologação em cf2ddf21, 23/10/2026
Reconciliado o saldo de `6985f82e` com as provas territoriais de 7 de Outubro e os recibos de reposição. A leitura autenticada da homologação, com o papel da aplicação e transacção apenas de leitura, confirmou **29 fichas publicadas, 13 relações de clusters e zero ligações em `project_locations`**. As cinco tabelas comparadas não usam RLS. Os projectos transportados têm identificadores diferentes dos originais e perderam as 28 ligações territoriais e os respectivos metadados de citação; não desapareceram as 29 fichas. A reposição de visibilidade anterior não abrangia estas relações. A comparação delimita a omissão no transporte; não identifica por si só o instante em que ocorreu.
O [manifesto das 28 relações aceites](approved-anchor-territories-20261023.json) conserva identidades históricas, fontes, citações e papéis primary/secondary/impact, mapeando os projectos e dossiers actuais por identidade documental verificada. A ferramenta `tools/restore-approved-anchor-territories.php` confere hashes, versões, território e prévia; recusa conflitos e exemplares arquivados/restritos; preserva metadados concorrentes, guarda estado anterior e produz auditoria. Não publica fichas nem cria ofertas. Em QA foram repostas 28 relações de 13 projectos; a repetição foi demonstrada sem duplicação. A01/A02 continuam sem implantação individual inventada; Norte da Zambézia conserva o papel de impacto parcial.
Incrementos: pesquisa explícita pelo sector documental em conjunto com a localização; entrada directa da ficha pública para preparar uma ideia, mesmo sem oportunidade publicada; origem `portfolio:<id>` guardada no dossier e no snapshot/relatório do simulador. A cobertura documental não é atribuída como implantação. A localização explicitamente escolhida na pesquisa acompanha a entrada; uma localização já guardada conserva-se, com divergência explicada. Títulos e dados deliberadamente editados mantêm-se na comparação existente. A retoma autenticada da manifestação conserva a mesma ficha e os filtros e não cria pedidos por GET. A preparação iniciada pela manifestação usa a associação já existente ao dossier, com controlo de proprietário e versões; não submete o pedido.
Provas dirigidas: `InvestmentAnchorIntegrationTest` (4 casos, 116 asserções) e `anchor-integration.test.mjs` (2 casos); ver [recibo de QA e reconciliação](anchor-integration-receipt-20261023.json). Mantidos os percursos já aceites de carteira → oportunidade associada → dossier/simulador, complementos `d8224ba8`, consumidores `55136b44` e tratamento `8036b9b0`/recibo `cdf8fcfb`. Nenhuma nova impressão de PDF, prova Word/ZIP ou ronda dos fechos aceites. Estatísticas e IA permanecem adiadas.
**Entregue e delta conferido** na revisão `cf2ddf21a1457f4852fe6aa10069ec3b854f1567`, após integração `3fd5128c` e correcção delimitada da verificação de finais de linha do manifesto entre Windows e Git. A primeira prévia recusou o hash divergente antes de qualquer escrita; a prévia canónica passou. A reposição autenticada recuperou **28 relações em 13 projectos**, com 13 eventos de auditoria, zero novas fichas e zero alterações de publicação. As **29 identidades aprovadas, conteúdos e fontes** permanecem exactamente iguais; preservadas as 13 relações de clusters e os corredores. A comparação das 174 tabelas encontrou 169 iguais por contagem/hash; as diferenças de domínio limitam-se a projectos/metadados, relações territoriais e auditoria; cache e sessões tiveram alterações técnicas. O backup restaurou 174 tabelas iguais numa base isolada sem rede e verificou 369 ficheiros.
Os 15 ficheiros funcionais instalados coincidem com o Git. Instalador: zero migrações e zero importações automáticas; controlos habituais aprovados (contrato 1/3106, PHP 57/309 e frontend 216/216). Conferência pública apenas do delta: pesquisa A06 por sector documental e Sofala encontra a ficha original; quatro localizações e respectivas fontes recuperadas; retorno conserva filtros; a entrada para preparar ideia usa `portfolio:<id>` e a província escolhida. Nenhum dossier, pedido ou manifestação de ensaio foi criado na homologação. Os ensaios sintéticos continuam exclusivamente em QA.
**Saldo técnico deste recorte: sem pendências de código, reposição ou disponibilização identificadas.** Segue a avaliação final de navegação e apresentação pela Tempo. Este fecho não declara implementadas funcionalidades futuras nem a conclusão integral da aplicação transversal; Estatísticas e IA continuam adiadas. O inventário e as provas anteriores permanecem preservados, sem nova ronda dos casos aceites.
## Oportunidade em tratamento — entregue na homologação em 8036b9b0, 23/10/2026
Concluída a disponibilização autorizada da candidata **`1afdfee8`**, incluindo a correcção delimitada do relatório **`2856240c`**, na revisão instalada **`8036b9b071229dbf7e85ea748db70abcbeefc379`**. Integração normal por fast-forward desde `55136b44`, sem force e sem alterações concorrentes em `main` na conferência prévia. [Recibo de instalação e delta](treatment-scope-installation-receipt-20261023.json).
O PDF original guardado foi encontrado pelo nome automático na pasta indicada, conferido em texto e nas **dez páginas renderizadas**, sem o regenerar. Conservados nome, bytes e SHA-256 `010eb4ea12015563c1b2f8bc255b1078c276f824c50690732541d5bffe34b8a6`. A revisão 13 contém a segunda oportunidade/Manica, os cinco requisitos específicos e os valores 123456/45678/77 778,00 MZN, com paginação legível. A diferença do nome literal pedido explica o erro anterior de localização; a máquina, conta, sessão e extensão foram confirmadas.
Instalador: **installed**, conclusão `2026-10-23T16:46:13.798467+00:00`, zero migrations/importações, observador activo e fila vazia. Revisão pública confirmou `8036b9b0`, build `16:46:11.203132 UTC`, manifesto SHA-256 `6021421c4ab12146c5fae9c8966e3dae5c8bd1361132e12184e65e00b7e8951e`. Os **15 ficheiros funcionais** afectados coincidem por hash com os blobs Git instalados. Os controlos automáticos do instalador passaram; não se repetiu a ronda manual de provas aceites.
**Delta instalado conferido por leitura autenticada:** ficha real **Florestas — Corredor da Beira**, oportunidade **53**, dossier existente `958df138-7980-4685-8389-fa90be02ed6b`. Novas ligações Simular/Etapas transportam a mesma chave; selector do dossier mostra 53/Sofala; atalhos preservam o âmbito. GET do contexto devolve `consumer_opportunity=opportunity:53`, snapshot apenas dessa oportunidade e dos seus requisitos. A decisão existente «Não precisa de terra» conserva a dispensa da acção no dossier observado. Versão do dossier **5**, igual ao recibo anterior; não foram criados, gravados ou submetidos pedidos no destino, nem aberta a edição do rascunho privado do simulador. As provas positivas de dois pedidos/duas oportunidades feitas em QA são reutilizadas, sem as reproduzir na homologação.
Complementos `d8224ba8`, fechos `6b544548`/`4098192a` e quatro consumidores `55136b44`/`b6a626c9` preservados; nenhuma fixture sintética, alteração de schema/configuração/permissões, Estatísticas ou IA integra este transporte. Mantidas todas as identidades da matriz e as linhas alheias. O recibo posterior fica na branch de trabalho, sem provocar outra instalação apenas documental.
**Saldo corrente:** esta frente está **entregue na homologação**, com PDF e delta instalados conferidos. A frente dos projectos âncora mantém a análise `6985f82e`: filtro sectorial e ideia directa sem oferta por implementar; reconciliação territorial entre ambientes e prova dirigida de interesse/autenticação por concluir. Não é declarado fecho global do percurso nem realizada antecipadamente a avaliação final pela Tempo. As indicações de «QA», «aguarda PDF» ou «não entregue» nas passagens históricas seguintes estão superadas apenas para este recorte.
## PDF nativo guardado — conferência concluída, 23/10/2026
Conferido o ficheiro **existente**, sem regenerar o PDF ou repetir Word/ZIP/provas aceites: `outputs/multi-opportunity-fix-20261023/Relatório — SYNTHETIC QA - escolha comum aos tres consumidores.pdf`. A pasta era a indicada; o nome foi o título automático do navegador, não `candidata-1afdfee8.pdf`. Leitura na máquina **ONALA**, utilizador **macbook**, sessão **Console**; extensão real `.pdf`, 191637 bytes. O erro anterior era «Cannot find path … candidata-1afdfee8.pdf … because it does not exist» para esse nome literal; a listagem da mesma pasta encontrou o original. Não houve renomeação, cópia nem mudança de máquina.
SHA-256 `010eb4ea12015563c1b2f8bc255b1078c276f824c50690732541d5bffe34b8a6`; PDF 1.4, produtor **Skia/PDF m155**, criado pelo Edge, revisão **13**. Texto e **dez páginas renderizadas do ficheiro guardado** conferidos: oportunidade `309`, Manica/parque correspondente; cinco requisitos próprios e fundamentos, estado **Por iniciar**; pressupostos **123456 MZN**, **45678 MZN**, diferença **77 778,00 MZN**; sem Sofala nem `not_started`. Paginação 1/10–10/10, A4, cabeçalhos de tabela repetidos, linhas completas, margens/rodapés legíveis e sem sobreposição ou corte visível. A última página contém as últimas referências e a indicação dos anexos; não está vazia. As quebras de linha do PDF foram normalizadas apenas para procurar texto, sem alterar o documento.
Prova: `native-pdf-inspection.json`, texto e páginas em `outputs/multi-opportunity-fix-20261023/`; [recibo actualizado](native-pdf-and-anchor-review-20261023.json). A condição de conferência do PDF está satisfeita para a candidata `1afdfee8` com a correcção delimitada `2856240c`. Disponibilização Git autorizada pela Tempo em execução; o fecho de entrega só será registado após confirmar a revisão e o delta instalados. As menções abaixo ao ficheiro em falta são históricas e superadas por esta descoberta/conferência. Análise dos projectos âncora conserva o saldo delimitado em `6985f82e`; Estatísticas e IA continuam adiadas.
## Duas frentes — PDF nativo e análise dos projectos âncora, 23/10/2026
Pedido expresso da Tempo após `32144aa2`: conferir o PDF nativo guardado da candidata `1afdfee8`, depois integrar/disponibilizar pelo Git e conferir só o delta; analisar projectos âncora por sector/localização e continuidade para manifestação, dossier e simulador. Word, ZIP e provas aceites não foram repetidos. Estatísticas e IA permanecem adiadas. Este registo sucede ao estado «por analisar» abaixo.
**Candidata:** o botão real de impressão foi aberto no novo dossier QA `d279172b-8bd1-4074-b000-ee25f0d29778`, oportunidade `309`, Manica. A saída revelou duas falhas delimitadas: estado de etapa exposto como `not_started` e ausência do detalhe dos requisitos específicos. Corrigidas na composição do relatório: estado humano existente, requisitos só da oportunidade seleccionada, com documentos/fundamento, e exclusão dos ramos dispensados. Novo teste exclusivo `native-pdf-scope.test.mjs` passou; TypeScript e build passaram. Não se voltou a testar Word/ZIP. A revisão **13**, aberta pelo botão do portal após a correcção, apresenta `Por iniciar`, os cinco requisitos da segunda oportunidade e os pressupostos QA **123456 MZN**, **45678 MZN**, diferença **77 778,00 MZN**. A primeira oportunidade/Sofala não integra esse âmbito. Estes números são uma fixture sintética para conferência da cópia, não nova aceitação dos cálculos.
**PDF guardado ainda pendente:** o controlo do navegador permite abrir e ler a saída, mas não operar o diálogo nativo do sistema. Foi pedida a gravação manual da janela já preparada em `outputs/multi-opportunity-fix-20261023/candidata-1afdfee8.pdf`. Até ler/renderizar o ficheiro e conferir paginação, requisitos, âmbito e valores, a candidata continua exclusivamente QA; não houve push de disponibilização. `origin/main` foi consultado por `git fetch`: continua `55136b44`, sem alterações concorrentes por integrar nessa consulta. Preparar o avanço normal, sem force, e voltar a consultar antes de integrar. Não é necessária nova autorização de disponibilização; a ordem já foi expressa pela Tempo, condicionada à conferência do PDF.
### Projectos âncora — análise concluída e correcções/verificações delimitadas
Reutilizados os registos de reposição (`7c1f5321`, recibo de preparação adaptativa: 29 fichas/15 projectos, IDs/títulos preservados) e de continuidade carteira/oportunidade (`InvestmentPortfolioContinuityTest`, instalado, sem repetir). **Não foi confirmada perda de fichas ou revogação da divulgação.** Revisão estática do código actual e inventário de leitura apenas na base QA isolada, sem escrita. O inventário QA encontrou 15 fichas documentais de projecto com **sector, localização e região em texto** em todas elas, mas sem `primary_territory_id`, ligações públicas em `project_locations` ou oportunidades públicas directamente associadas. Uma décima sexta ficha marcada `SYNTHETIC QA — associação escolhida`, com uma oportunidade, foi identificada e excluída da contagem documental. A diferença QA não é atribuída à homologação; qualquer reposição de dados depende de comparação com o último estado demonstrado e identidades de acesso equivalentes, conforme AGENTS.
| Trecho | Encontrado / classificação | Correcção ou verificação finita |
| --- | --- | --- |
| Pesquisa por sector e localização | **Lacuna de implementação do filtro sectorial.** `InvestmentPortfolioController::index` e `portfolio/index` aceitam pesquisa textual, tipo e hierarquia territorial; não recebem nem apresentam sector. Texto livre contendo um sector não equivale ao filtro combinado. A pesquisa territorial já tem implementação por IDs e relações documentadas em `InvestmentGeography::constrainDossiersToTerritories`. | Acrescentar filtro sectorial e combiná-lo com a localização no mesmo catálogo, conservando filtros ao abrir/regressar. Reutilizar o catálogo de sectores; uma âncora pode abranger vários sectores documentados. Não classificar por semelhança de palavras nem criar ofertas para obter resultados. |
| Correspondência dos dados documentais | **Verificação de dados/ambiente em falta**, distinta da ausência do filtro. Os 15 sectores/localizações existem nos textos QA; o schema de `projects` não tem `sector_id` e os campos/relações territoriais consultados estão vazios nesse ambiente. | Comparar schema, dados, configuração e acesso equivalente com o último estado demonstrado/destino. Reutilizar fonte, exemplar e correspondências já aceites; delimitar as correspondências canónicas em falta e ambiguidades. Não pedir outra validação genérica da fonte, inventar território, nem declarar regressão a partir do clone. |
| Ficha → manifestação | **Mecanismo existente a preservar:** o botão cria rascunho com o mesmo `dossier_id`; `InvestmentSubmissionController::create` conserva `project_ids`. A ficha recupera os rascunhos existentes. **Sem nova prova de execução deste ramo nesta análise.** Para anónimos, o componente apresenta ligação simples a `/login`; a retoma com a identidade/filtros requer prova dirigida. | Conferir apenas login/retoma e recuperação do interesse na mesma ficha, sem submissão externa. Não transformar a ficha documental em oportunidade nem alterar os estados de publicação. |
| Ficha sem oportunidade → preparação → simulador | **Integração directa ainda não implementada.** A ficha mostra oportunidades relacionadas quando existirem; essas usam `from_dossier`, território e `portfolio_origins` já aceites até ao simulador. Sem oferta associada não existe acção de ideia baseada na própria âncora, e `InvestorContext::reference` não suporta uma origem de carteira autónoma. O vínculo explícito manifestação/dossier já existe, mas não cria essa origem no contexto. | Reutilizar o arranque por ideia e o mesmo dossier, acrescentando a referência documental da âncora independente de uma oferta. Conservar identidade, versão, fonte e localização escolhida até ao simulador/relatório e à manifestação vinculada. Conferir este ramo novo e a transição interesse/preparação; reutilizar as provas aceites do ramo com oportunidade. |
O recorte âncora deixou de estar «por analisar»: há **duas lacunas de implementação** (filtro de sector e entrada directa por ideia de âncora sem oferta), **uma reconciliação de dados/ambiente** e **uma prova dirigida de interesse/autenticação**, com a continuidade do ramo novo incluída no seu critério de aceitação. Não se implementou esse recorte nesta análise nem se declarou homologação corrigida. As lacunas de implementação não são regressões demonstradas. Fontes locais: `InvestmentPortfolioController`, `InvestmentGeography`, `InvestmentRelations`, `InvestmentOpportunityChoices::portfolioOrigins`, `InvestorContext`, `InvestmentSubmissionController`, `portfolio/index`, `portfolio/show`, `search-territory`; inventário QA em `outputs/multi-opportunity-fix-20261023/anchor-analysis-data.json`. Actualização nas mesmas linhas `AUD-REL-02`/`AUD-LEG-06` da matriz existente, sem nova matriz/backlog.
## Oportunidade em tratamento — correcção conjunta do saldo em QA, 23/10/2026
Origem: pedido expresso da Tempo após `bc84fbdb`. Corrigir conjuntamente os três consumidores da escolha de oportunidade; conferir os quatro recortes delimitados, corrigindo apenas falhas encontradas. **Só QA; não entregue na homologação nesta passagem.** Permanecem encerrados `6b544548`, `d8224ba8` e `55136b44` com os respectivos recibos; não se repetiram os seus testes. A lista histórica de sete itens abaixo passa a ter os seguintes estados correntes: Correc??o funcional `1afdfee8`; [recibo QA](investment-treatment-scope-qa-receipt-20261023.json).
| Item | Resultado corrente e prova dirigida |
| --- | --- |
| 1 — Simulador | Selector disponível na entrada geral; entrada da ficha/atalho transporta a chave; gravação e recuperação conservam a escolha e o snapshot, e a devolução financeira identifica a oportunidade. A troca aguarda a recuperação do contexto antes de gravar a nova associação. Provas API novas e conferência Edge: segunda/Manica → primeira/Sofala → segunda, no novo dossier QA. Valores deliberados continuam sujeitos à reconciliação existente. |
| 2 — Etapas/requisitos | Decisões, motivos e estados específicos conservados por chave da oportunidade no payload cifrado existente; dados e decisões comuns anteriores preservados. Numa selecção múltipla, uma dispensa comum não dispensa automaticamente todas as fichas. Primeira sem necessidade de terra e segunda com necessidade produzem etapas/acções distintas. Os links dos requisitos conservam a oportunidade; a próxima acção e o snapshot seguem o âmbito escolhido. |
| 3 — Terra/DUAT | Atalho procura o pedido do titular com o mesmo dossier **e a mesma oportunidade**, não o último de outra ficha. Teste cria dois pedidos pela ordem inversa e recupera cada associação. Sem escolha numa selecção múltipla, abre preparação com escolha, sem abrir arbitrariamente um pedido existente. |
| 4 — Retirar a primeira | Prévia, confirmação e recuperação por HTTP dirigidas: segunda ficha exactamente preservada, título comum deliberado conservado e pedido da primeira continua consultável como histórico. Não constitui nova avaliação visual integral do editor. |
| 5 — Dados comuns | Detectada duplicação de contactos já autorizados no payload do dossier; proposta de email/telefone ao simulador e Terra e nome a Terra corrigida. Testes verificam proposta e conservação de contacto deliberado. Perfil/empresa e organização mantêm os mecanismos próprios aceites; não se escolhe empresa/representante automaticamente, nem se converte país de residência/constituição em residência fiscal ou território do projecto. |
| 6 — Entradas alternativas | Teste novo de ideia livre e fontes explícitas sector/território: confirmação e recuperação no mesmo dossier e propostas territoriais em Terra/simulador. Teste de login real do cliente HTTP conserva o URL pretendido e a fonte sectorial. Não se repetiu a prova territorial de reautenticação já aceite, nem carteira/oportunidade. Estas provas HTTP não são apresentadas como nova avaliação visual de todas as entradas. |
| 7 — Exportações | Word e ZIP efectivamente gerados e lidos: identidade da segunda oportunidade e parque correctos, sem Sofala; ZIP contém o Word idêntico. Saída HTML do percurso de impressão contém o mesmo âmbito e distingue etapas aplicáveis/dispensadas. **Permanece por conferir apenas o novo PDF nativo efectivamente guardado pelo navegador**, incluindo paginação/legibilidade; HTML não substitui essa prova. Nenhuma falha do PDF foi presumida. |
Verificações novas: `InvestmentTreatmentScopeTest` (sete casos dirigidos, 170 asser??es; guarda efectiva da base `simulator_fiscal_delta_20261021_test`, porta 55441; transacções), `treatment-scope-export.test.mjs` (dois casos; artefactos reais Word/ZIP e composição de impressão), TypeScript/build. Os erros de preparação dos ensaios (nome de campo de versão no teste e confirmação territorial do clone QA) foram corrigidos, sem alterar a funcionalidade aceite. Fixtures e artefactos permanecem em `outputs/multi-opportunity-fix-20261023/`; novo dossier `d279172b-8bd1-4074-b000-ee25f0d29778`, separado dos dossiers aceites. Sem migrations, fixtures publicadas, alterações em dados activos, permissões, Estatísticas ou IA.
### Projectos âncora por sector/localização — recorte por analisar
**Incluído por pedido expresso da Tempo nesta passagem.** Verificar pesquisa combinada por sector e localização, abertura pela identidade existente, continuidade para manifestação de interesse e preparação do investimento, e recuperação no mesmo dossier com fontes/associações documentadas. A reposição/consulta anteriormente aceite de fichas e relações não demonstra, por si só, este requisito completo. **Estado: por analisar; não se presume implementação nem falha.** Registado em `AUD-REL-02` e articulado com `AUD-LEG-06`, sem nova matriz/backlog. Este recorte permanece no saldo antes da avaliação final pela Tempo; Estatísticas e IA continuam adiadas.
O saldo corrente separa, portanto, a correcção executável QA dos três pontos, as provas dirigidas com os limites acima, a conferência restante do PDF nativo e o novo recorte âncora por analisar. Não há fecho global nem declaração de entrega desta candidata na homologação.
## Saldo finito da aplicação transversal — 23/10/2026
Pedido expresso da Tempo: substituir a qualificação genérica de cobertura parcial por pendências delimitadas, sem repetir provas aceites. Este é o inventário corrente dentro do manual e da matriz existentes; as avaliações anteriores abaixo ficam como histórico, subordinadas aos fechos posteriores. Base revista: `b6a626c9`, com código entregue em `55136b44`. Revisão de código, contratos, testes existentes e recibos; **não houve execução funcional nova**, alteração de dados ou instalação nesta passagem. Uma lacuna de código abaixo não é apresentada como novo incidente observado no navegador.
### Corrigido, demonstrado e entregue — manter encerrado
| Recorte | Entrega / prova reutilizada |
| --- | --- |
| Gravação/idioma, origem dos dados no relatório, sete ligações da Beira | `6b544548`; recibo `4098192a`. |
| Complementos com identidade, necessidade sem oferta, serviço e relação à oportunidade no dossier | `d8224ba8`; recibo `af926180`; serviço sintético exclusivamente QA, Fiação em preparação/localização por definir. |
| Zonas/parques, Terra/DUAT, Balcão e manifestação: tratar a oportunidade escolhida, alterar só a segunda e conservar a primeira, guardar/recuperar associação e relatórios cobertos | `55136b44`; recibo `b6a626c9`; QA `4a01e9e2`. Inclui escolha explícita quando há várias, edições deliberadas e associação bloqueada de pedido devolvido. |
| Resumos editáveis no simulador, nomes territoriais, terreno Sim/Não/Por definir, ocultação/reabertura de etapa global e continuidade carteira/dossier | Incrementos `7ca94967`, `f2150b19`, `5bda74ae`, entregues na linha `7c1f5321` e preservados pelas entregas seguintes; recibos de preparação adaptativa e continuidade existentes. Não voltar a exigir a instalação indicada como pendente em notas históricas. |
### Pendências actuais — sete recortes, sem expansão implícita
| N.º / situação | Ecrã ou consumidor e limite exacto | Resultado necessário / próxima acção delimitada |
| --- | --- | --- |
| 1 — Falha de integração identificada no código | **Simulador iniciado pelo dossier com várias oportunidades.** `InvestorContext::prefill()` só delimita o simulador quando recebe a chave; `DossierPrefill` exclui o simulador do selector de oportunidade. A entrada geral continua a propor os campos comuns derivados da primeira, embora mostre todas as fichas. | Permitir identificar explicitamente o âmbito da análise e recuperar a oportunidade em tratamento; tornar explícito o âmbito comum quando for essa a escolha. Manter rascunhos, edições e relatórios aceites. Não somar investimentos nem inventar uma regra de agregação financeira. Prova nova apenas: seleccionar a segunda neste consumidor e conferir a sua localização/sector/parque e a identificação do âmbito no resultado. |
| 2 — Falha de integração identificada no código | **Dossier, etapas e próxima acção.** `InvestorPath::view()` calcula aplicabilidade por `investor_path.facts` global; `land_needed=no` oculta a acção Terra para o dossier todo. `InvestorContext::landPreparation()` também lê esse único facto. Não há distinção de decisões de terra diferentes entre duas oportunidades. | Conservar a resposta global válida, mas impedir que dispense silenciosamente a necessidade específica de outra oportunidade. Ligar decisão, explicação e próxima acção ao âmbito tratado; desconhecido continua pendente. Prova nova: uma oportunidade sem necessidade e outra com necessidade, sem repetir o ciclo global Sim/Não já aceite. |
| 3 — Falha de integração identificada no código | **Atalho Terra/DUAT no resumo do dossier.** `InvestorPath::view()` procura o caso mais recentemente actualizado do titular com o mesmo dossier, sem filtrar `context_opportunity`; o atalho abre esse caso. A correcção dos formulários não torna esta escolha do resumo específica. | Com dois pedidos de oportunidades diferentes, apresentar escolha identificável ou atalho por oportunidade, recuperando o pedido correspondente. Preservar os pedidos e respectivos snapshots; não substituir pelo último pedido do dossier. A lista de pedidos/resultados deve permitir distinguir as associações. |
| 4 — Ainda sem prova delimitada | **Editor de contexto: retirar, substituir ou reordenar a primeira oportunidade.** A prova aceite altera apenas a segunda; o código ainda projecta a primeira em `selection`/campos comuns. Testes de mudança territorial e de conservação de várias fichas são reutilizados, mas não demonstram este impacto na interface. | Conferir apenas a prévia da retirada/substituição da primeira, confirmação e recuperação: campos comuns propostos, edições preservadas, segunda intacta e pedidos antigos mantidos como históricos. Não declarar perda nem pedir nova prova da alteração exclusiva da segunda. |
| 5 — Ainda sem prova delimitada | **Reutilização perfil/empresa/organização → contexto e ferramentas.** A manifestação já recebe conta e empresa em `InvestmentSubmissionController::create()`; o perfil já existe. Isso não comprova a herança dos dados equivalentes na preparação do dossier, Terra e simulador. | Traçar apenas campos equivalentes e autorizados (identificação/contacto, representação, país/origem quando semanticamente coincidentes), registar o que chega, o que volta a ser pedido e a prioridade de edições locais. Corrigir só duplicação demonstrada. País de constituição, residência fiscal e localização do projecto não são intercambiáveis. Não repetir o preenchimento da manifestação já coberto. |
| 6 — Ainda sem prova delimitada | **Entradas por ideia, sector ou localização com passagem para operação privada.** Há prova de rascunho/reauth territorial em `InvestmentCorridorContextTest` e de carteira/oportunidade nos recibos aceites. Não é prova de todas estas entradas alternativas até ao consumidor. | Conferir somente cada entrada alternativa ainda não coberta: fonte e escolhas no arranque, regresso/autenticação e recuperação no mesmo dossier; pedir apenas dados ausentes. Reutilizar o caso territorial e as entradas carteira/oportunidade existentes, sem os repetir. Não pressupor que falham. |
| 7 — Ainda sem prova delimitada | **Exportações nativas do dossier/plano para âmbito múltiplo.** Valores, idioma, proveniência, exportações financeiras e recuperação já têm provas próprias; relatório Terra e associação canónica dos consumidores também. Falta isolar o efeito da escolha de âmbito nos restantes artefactos nativos, após resolver 1–3. | Conferir apenas correspondência oportunidade/localização/parque, etapas aplicáveis e identidade de pedidos em Word/PDF/ZIP efectivamente gerados. Não repetir cálculos, idioma, sete ligações, anexos nem o relatório Terra aceites. Um formato que não inclua esse conteúdo deve ser registado como sem aplicação, não como falha. |
São **três lacunas de integração constatadas por leitura e quatro verificações delimitadas**, não sete falhas de execução. Os itens 1–3 determinam a correcção executável seguinte; 4–6 podem ser conferidos independentemente em QA isolado; 7 depende apenas do âmbito corrigido. A contagem refere-se a estes sete recortes, não a cada formato ou campo. Uma verificação que passe encerra o item; uma que falhe regista o defeito concreto na mesma linha da matriz. Não criar outra matriz/backlog nem converter ausência de prova em regressão.
### Nunca implementado / desenvolvimento futuro — separado do saldo de regressões
O editor unificado de actores, fornecedores, contactos e infraestruturas das fichas territoriais e a cartografia de linhas de corredores não existem no produto demonstrado. Candidaturas com procedimento autónomo, restauro automático de snapshots e entrega a CRM remoto sem conector configurado também não são funcionalidades perdidas. Permanecem nos respectivos requisitos de desenvolvimento, fora dos sete recortes acima; não são pré-condições para os corrigir. Ausência de oferta ou associação documental não autoriza criar uma relação, localização ou publicação. Estatísticas e IA continuam adiadas por decisão da Tempo.
### Rastreabilidade e limite desta revisão
Consumidores revistos: editor/arranques de contexto, resumo e etapas do dossier, simulador e reutilização, parques, Terra/DUAT, Balcão, manifestação, perfil/empresas e composição dos relatórios. Fontes: `InvestorContext`, `InvestorPath`, `InvestmentConsumerScope`, `InvestmentSubmissionController`, `dossier-prefill`, `investment-opportunity-choices`, `project-dossier`, testes `InvestmentCorridorContextTest`, `InvestmentConsumerScopeTest`, `InvestorPathTest` e `context-inheritance`, recibos de instalação existentes. Não se auditou novamente gestão editorial, publicação, RLS, legislação ou cálculos fiscais; mantêm os seus inventários e aceitações próprios. A avaliação final de navegação e apresentação pela Tempo vem depois do fecho desta lista. Referências na matriz existente: `AUD-LEG-06`, `AUD-SIM-INT03`, `AUD-SIM-INT06`.
## Oportunidade em tratamento — entregue na homologação, 23/10/2026
Por instrução expressa da Tempo, a correcção QA `4a01e9e2` e o recibo `55136b44` foram integrados no `main` existente por avanço directo, sem force ou conflitos. O destino serve **`55136b44`**, confirmado em `https://teste-investidor.tempo.co.mz/build/revision.json`, build `2026-10-23T15:55:10.921100+00:00`, manifest SHA-256 `b2dd6172516f220344ede5fc9d7876b7fc8417416d03842d737452709379a12c`. [Recibo da instalação](multi-opportunity-context-installation-receipt-20261023.json).
@@ -0,0 +1,174 @@
{
"status": "installed_in_homologation_delta_verified",
"date": "2026-10-23",
"target": "https://teste-investidor.tempo.co.mz",
"served_commit": "8036b9b071229dbf7e85ea748db70abcbeefc379",
"functional_commits": [
"1afdfee8",
"2856240c"
],
"previous_main": "55136b446deb710adf58ce2460e571bb19e94375",
"public_revision": {
"commit": "8036b9b071229dbf7e85ea748db70abcbeefc379",
"branch": "main",
"built_at": "2026-10-23T16:46:11.203132+00:00",
"manifest_sha256": "6021421c4ab12146c5fae9c8966e3dae5c8bd1361132e12184e65e00b7e8951e"
},
"installer": {
"status": "installed",
"commit": "8036b9b071229dbf7e85ea748db70abcbeefc379",
"release": "/var/www/investmoz-homologacao/releases/8036b9b071229dbf7e85ea748db70abcbeefc379-5e851af2",
"previous_release": "/var/www/investmoz-homologacao/releases/55136b446deb710adf58ce2460e571bb19e94375-cad6589e",
"database_migrations": 0,
"content_imports": 0,
"requested_commits": [
"8036b9b071229dbf7e85ea748db70abcbeefc379"
],
"finished_at": "2026-10-23T16:46:13.798467+00:00"
},
"pipeline_summaries": [
"OK (1 test, 3106 assertions)",
"built in 24.30s",
"OK (57 tests, 309 assertions)",
"# tests 214",
"# pass 214",
"# fail 0"
],
"preservation": {
"fast_forward": true,
"force": false,
"protected_files": {
"app/Services/InvestmentComplements.php": true,
"resources/js/components/investment-complements.tsx": true,
"docs/investmoz/unification/continuity-corrections-installation-receipt-20261023.json": true
},
"matrix_changed_rows": [
"AUD-REL-02",
"AUD-LEG-06",
"AUD-SIM-INT03",
"AUD-SIM-INT06"
],
"unrelated_matrix_records_preserved": 3419,
"schema_config_fixtures_shipped": false
},
"native_saved_pdf": {
"actual_file": "C:\\laragon\\www\\portal-investidor\\outputs\\multi-opportunity-fix-20261023\\Relatório — SYNTHETIC QA - escolha comum aos tres consumidores.pdf",
"sha256": "010eb4ea12015563c1b2f8bc255b1078c276f824c50690732541d5bffe34b8a6",
"pages": 10,
"revision": 13,
"checks": {
"second_opportunity": true,
"manica": true,
"land_requirement": true,
"sector_requirement": true,
"human_state": true,
"technical_state_absent": true,
"first_location_absent": true,
"total": true,
"secured": true,
"gap": true
},
"visual_review": {
"status": "passed",
"pages_inspected": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10
],
"pagination": "1/10 through 10/10; A4; repeated table headers; no clipped rows or visible overlaps",
"last_page": "sparse final page contains final source rows and annex statement; not blank"
},
"discovery": {
"machine": "ONALA",
"user": "macbook",
"session": "Console",
"extension": ".pdf",
"size_bytes": 191637,
"requested_basename_not_found": "candidata-1afdfee8.pdf",
"actual_basename": "Relatório — SYNTHETIC QA - escolha comum aos tres consumidores.pdf",
"resolution": "existing file discovered by listing the exact requested directory; no rename, copy or PDF regeneration"
},
"regenerated": false
},
"installed_delta": {
"real_opportunity": 53,
"scope": {
"case_id": "958df138-7980-4685-8389-fa90be02ed6b",
"case_version": 5,
"context_revision": 1,
"simulator_opportunity": "opportunity:53",
"snapshot_choices": [
"opportunity:53"
],
"snapshot_territory": "Sofala",
"land_need": "no",
"land_step": {
"code": "S10",
"title": "Regularização da terra",
"state": "dispensed",
"applicability": "not_needed_for_preparation",
"reason": "",
"context_review_required": true
},
"requirements_scopes": [
"opportunity:53"
]
},
"checks": [
"new fiche links preserve opportunity53",
"dossier explicitly selects same opportunity and keeps tool query scope",
"authenticated context GET returns canonical simulator opportunity53 and its own requirements",
"existing no-land decision hides land action in the inspected dossier",
"15 changed functional file hashes equal Git release sources"
],
"case_version_unchanged_from_prior_receipt": true,
"requests_created_updated_submitted": 0,
"accepted_business_tests_repeated": false,
"qa_second_opportunity_cases_reused": true
},
"remaining_scope": {
"anchors": "analysis6985f82e: two implementation gaps, territorial reconciliation and directed interest/authentication proof; not implemented by this delivery",
"statistics_and_AI": "deferred"
},
"evidence": [
{
"path": "outputs/treatment-install-20261023/integration-preservation.json",
"sha256": "c6fcae5f64986d13216948589a35d1fa2d9f62229f4bd7a8a8c9f559f463fd6b"
},
{
"path": "outputs/treatment-install-20261023/installer-status.json",
"sha256": "f7875ffe9fa47092e5f0288ecbd97f4f016413628b2faed6815977287c26461e"
},
{
"path": "outputs/treatment-install-20261023/public-revision.json",
"sha256": "23325ab4cce9fced625b237c9793e3af309d923f12abbd047de96670611c8cac"
},
{
"path": "outputs/treatment-install-20261023/installed-hashes.json",
"sha256": "91f9df68c0d3b9c0ace6948145362f303291c5698bb2594bff9bb9c3aabc159c"
},
{
"path": "outputs/treatment-install-20261023/installed-links.json",
"sha256": "bf73f59ecfaccf8d10f45a72c36bd684e1b12abb667a65e0d2cda4dd396eb324"
},
{
"path": "outputs/treatment-install-20261023/installed-scope-summary.json",
"sha256": "fd410fa10ee63b8c09c311530cab78ca4f1f2b961c3d31cc78c40b86bc7af255"
},
{
"path": "outputs/treatment-install-20261023/installed-dossier.txt",
"sha256": "b5577b82ad0bce03131ffddbc365f1d039229549d6b83227ad57baffd1037f07"
},
{
"path": "outputs/treatment-install-20261023/installed-dossier.png",
"sha256": "9a3133a9fb6ca11fc64e0681393d518676bb72bceb75bfc95950466271158509"
}
]
}
File diff suppressed because one or more lines are too long
@@ -153,7 +153,7 @@ export function NavigationContextProvider({
}
const view = focus
? await contextApi<NavigationView>(
`/investimentos/${focus.case_id}`,
`/investimentos/${focus.case_id}${direct===focus.case_id && new URL(url,'https://context.invalid').searchParams.get('opportunity') ? '?opportunity='+encodeURIComponent(new URL(url,'https://context.invalid').searchParams.get('opportunity')!) : ''}`,
)
: null;
if (!stopped) {
@@ -285,7 +285,7 @@ export function NavigationContextSummary() {
{view && (
<Link
className="font-semibold underline"
href={`/investimentos/${active}`}
href={`/investimentos/${active}${scopedChoice ? '?opportunity='+encodeURIComponent(scopedChoice.opportunity.key) : ''}`}
>
Abrir dossier atual
</Link>
@@ -293,7 +293,7 @@ export function NavigationContextSummary() {
{view?.next && (
<Link
className="underline"
href={`/investimentos/${active}#${view.next.code}`}
href={`/investimentos/${active}${scopedChoice ? '?opportunity='+encodeURIComponent(scopedChoice.opportunity.key) : ''}#${view.next.code}`}
>
Preparar {view.next.code}
</Link>
+18 -10
View File
@@ -32,6 +32,10 @@ const empty = (v: unknown) =>
(Array.isArray(v) && v.length === 0);
const text = (v: unknown) =>
typeof v === 'string' ? v : JSON.stringify(v ?? '');
function savedOpportunity(values: Record<string, unknown>): string {
if (values.context_opportunity) return String(values.context_opportunity);
try { return JSON.parse(String(values.context_applicability_snapshot ?? '')).consumer_opportunity ?? ''; } catch { return ''; }
}
const displayField = (values: Record<string, unknown>, key: string) =>
key.endsWith('_id')
? text(values[`${key}_label`]) || (empty(values[key]) ? 'não informado' : 'Referência escolhida; designação por recuperar')
@@ -52,7 +56,11 @@ export function DossierPrefill(props: Props) {
const [selected, setSelected] = useState<string[]>([]);
const [reviewed, setReviewed] = useState(false);
const [message, setMessage] = useState('');
const [opportunity, setOpportunity] = useState(() => String(props.values.context_opportunity || props.initialOpportunity || (typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('opportunity') || '' : '')));
const userChose = useRef(false);
const [opportunity, setOpportunity] = useState(() => String(props.initialOpportunity || (typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('opportunity') || '' : '') || savedOpportunity(props.values)));
useEffect(() => {
if (!userChose.current && !opportunity && enabled) { const saved = savedOpportunity(props.values); if (saved) setOpportunity(saved); }
}, [enabled, props.values, opportunity]);
const loadedOpportunity = useRef('');
useEffect(() => {
if (!contextId) return;
@@ -80,7 +88,7 @@ export function DossierPrefill(props: Props) {
const chosen = opportunity || (choices.length === 1 ? choices[0].opportunity.key : '');
const source = view.prefill[consumer] ?? {};
const now = latest.current;
if (consumer !== 'simulator' && chosen && now.values.context_opportunity !== chosen && (!now.allowedFields || now.allowedFields.includes('context_opportunity'))) now.onApply({context_opportunity:chosen},true);
if ((chosen || consumer === 'simulator') && now.values.context_opportunity !== chosen && (!now.allowedFields || now.allowedFields.includes('context_opportunity'))) now.onApply({context_opportunity:chosen},true);
const inherited = inheritCompatibleFields(`dossier:${contextId}:${consumer}`, now.values, source, now.values.context_reuse, now.allowedFields);
if (Object.keys(inherited.patch).length || (now.persistInheritance !== false && now.values.context_reuse !== inherited.metadata)) {
// The shared reconciliation has already selected safe fields; metadata travels with the same saved form.
@@ -139,19 +147,19 @@ export function DossierPrefill(props: Props) {
<p>
<Link
className="underline"
href={`/investimentos/${contextId}`}
href={`/investimentos/${contextId}${chosenKey ? '?opportunity='+encodeURIComponent(chosenKey) : ''}`}
>
{view.case.title}
</Link>
{' · '}<Link className="underline" href={`/investimentos/${contextId}`}>Voltar ao dossier</Link>
{' · '}<Link className="underline" href={`/investimentos/${contextId}${chosenKey ? '?opportunity='+encodeURIComponent(chosenKey) : ''}`}>Voltar ao dossier</Link>
</p>
{(consumer==='simulator'||opportunity||view.context.opportunity_choices?.length===1)&&<InvestmentContextAssessment contextId={contextId} raw={view.prefill.simulator?.context_applicability_snapshot} />}
{(opportunity||view.context.opportunity_choices?.length===1)&&<InvestmentContextAssessment contextId={contextId} raw={view.prefill.simulator?.context_applicability_snapshot} />}
<InvestmentConsumerIdentity raw={props.values.context_opportunity_snapshot}/>
{consumer!=='simulator'&&contextId&&<InvestmentRequirements contextId={contextId} assessment={view.context.requirements_assessment ? {...view.context.requirements_assessment,requirements:view.context.requirements_assessment.requirements.filter(r=>r.opportunity_key===(opportunity || (view.context.opportunity_choices?.length===1 ? view.context.opportunity_choices[0].opportunity.key : '')))} : null}/>}
{consumer !== 'simulator' && !!view.context.opportunity_choices?.length && <label className="block font-semibold">Oportunidade em tratamento
{contextId&&<InvestmentRequirements contextId={contextId} excludedSteps={view.steps.filter(step=>step.state==='dispensed').map(step=>step.code)} assessment={view.context.requirements_assessment ? {...view.context.requirements_assessment,requirements:view.context.requirements_assessment.requirements.filter(r=>r.opportunity_key===chosenKey)} : null}/>}
{!!view.context.opportunity_choices?.length && <label className="block font-semibold">Oportunidade em tratamento
<select className="ml-2 rounded border bg-white p-2" disabled={readOnly || !view.can_write || (!!props.allowedFields&&!props.allowedFields.includes('context_opportunity'))} value={opportunity || (view.context.opportunity_choices.length===1 ? view.context.opportunity_choices[0].opportunity.key : '')} onChange={e=>{
const key=e.target.value; setOpportunity(key); setSelected([]);setReviewed(false);
props.onApply({context_opportunity:key},true);
const key=e.target.value; userChose.current=true; setOpportunity(key); setSelected([]);setReviewed(false);
if (consumer !== 'simulator') props.onApply({context_opportunity:key},true);
props.onReview?.(view.context.revision,false);
}}><option value="">Escolha a oportunidade deste pedido</option>{view.context.opportunity_choices.map(c=><option key={c.opportunity.key} value={c.opportunity.key}>{c.opportunity.title} — {c.territory?.title??'Localização por definir'}</option>)}</select>
{!opportunity && view.context.opportunity_choices.length>1 && <p>Escolha a oportunidade para recuperar a localização e os requisitos específicos. Os dados comuns e as suas edições conservam-se.</p>}
@@ -166,7 +174,7 @@ export function DossierPrefill(props: Props) {
</p>
)}
{message && <p role="status">{message}</p>}
{!!view.context.opportunity_choices?.length && (consumer==='simulator'||!!chosenChoice) && <OpportunityChoices choices={consumer==='simulator' ? view.context.opportunity_choices : view.context.opportunity_choices.filter(c=>c.opportunity.key===chosenKey)}/>}
{!!chosenChoice && <OpportunityChoices choices={(view.context.opportunity_choices ?? []).filter(c=>c.opportunity.key===chosenKey)}/>}
{consumer!=='simulator'&&chosenChoice?.opportunity.fields.sector&&<p>Sector desta oportunidade: {chosenChoice.opportunity.fields.sector}</p>}
<SelectedComplements items={view.context.complements ?? []}/>
{Object.entries(view.context.selection)
@@ -72,6 +72,6 @@ export default function OpportunityChoices({choices, onChange, disabled = false,
return <section className="space-y-4" aria-label="Oportunidades e respectivas localizações">
<h2 className="text-xl font-bold">Oportunidades escolhidas e localização de cada ficha</h2>
<p>{onChange?'Escolha o corredor e confirme onde pretende realizar o investimento.':'Escolhas guardadas no dossier; cada oportunidade conserva a sua localização e associações.'}</p>
{choices.map((c,i)=><div key={c.opportunity.key}>{contextId&&!onChange&&<nav aria-label={"Preparar "+c.opportunity.title} className="flex gap-4"><Link className="underline" href={`/investidor/terra-duat/novo?context=${contextId}&opportunity=${encodeURIComponent(c.opportunity.key)}`}>Terra/DUAT desta oportunidade</Link><Link className="underline" href={`/balcao-virtual/dossiers/${contextId}?opportunity=${encodeURIComponent(c.opportunity.key)}`}>Balcão desta oportunidade</Link></nav>}<ChoiceCard contextId={contextId} key={c.opportunity.key} c={c} i={i} corridors={corridors} disabled={disabled} update={change=>update(i,change)} remove={onChange?()=>onChange(choices.filter((_,index)=>index!==i)):undefined}/></div>)}
{choices.map((c,i)=><div key={c.opportunity.key}>{contextId&&!onChange&&<nav aria-label={"Preparar "+c.opportunity.title} className="flex gap-4"><Link className="underline" href={`/simulador-fiscal?context=${contextId}&opportunity=${encodeURIComponent(c.opportunity.key)}`}>Simular esta oportunidade</Link><Link className="underline" href={`/investimentos/${contextId}?opportunity=${encodeURIComponent(c.opportunity.key)}`}>Etapas e pedidos desta oportunidade</Link><Link className="underline" href={`/investidor/terra-duat/novo?context=${contextId}&opportunity=${encodeURIComponent(c.opportunity.key)}`}>Terra/DUAT desta oportunidade</Link><Link className="underline" href={`/balcao-virtual/dossiers/${contextId}?opportunity=${encodeURIComponent(c.opportunity.key)}`}>Balcão desta oportunidade</Link></nav>}<ChoiceCard contextId={contextId} key={c.opportunity.key} c={c} i={i} corridors={corridors} disabled={disabled} update={change=>update(i,change)} remove={onChange?()=>onChange(choices.filter((_,index)=>index!==i)):undefined}/></div>)}
</section>;
}
@@ -15,7 +15,7 @@ export default function InvestmentRequirements({assessment, contextId, excludedS
{[...groups.entries()].map(([key, rows])=><details key={key} className={card}><summary className="cursor-pointer font-semibold">{rows[0].opportunity_title} · {rows.length} aspectos a conferir</summary>{rows.map(r=><article key={r.opportunity_key+':'+r.code} className="my-3 space-y-2 border-t pt-3">
<h3 className="font-semibold">{r.title} — {r.opportunity_title}</h3><p>{labels[r.state] ?? r.state}</p><p>Entidade: {r.authority}</p><p>{r.basis}</p>
<ul className="list-disc pl-5">{r.documents.map(d=><li key={d}>{d}</li>)}</ul>
<a className="underline" href={r.source_url}>Consultar fundamento e procedimento</a>{' · '}<Link className="underline" href={`/investimentos/${contextId}#${r.step}`}>Preparar e acompanhar em {r.step}</Link>
<a className="underline" href={r.source_url}>Consultar fundamento e procedimento</a>{' · '}<Link className="underline" href={`/investimentos/${contextId}?opportunity=${encodeURIComponent(r.opportunity_key)}#${r.step}`}>Preparar e acompanhar em {r.step}</Link>
<p>{r.decision}</p>
</article>)}</details>)}
</section>;
+3 -3
View File
@@ -1564,7 +1564,7 @@ export function SimulationForm() {
'Não foi possível devolver as entradas financeiras guardadas ao dossier.',
);
}
window.location.assign(`/investimentos/${contextId}`);
window.location.assign(`/investimentos/${contextId}${draft.context_opportunity ? "?opportunity="+encodeURIComponent(String(draft.context_opportunity)) : ""}`);
} catch (cause) {
setContinuityError(
cause instanceof Error
@@ -2522,7 +2522,7 @@ export function SimulationForm() {
)}
<a
className="ml-3 underline"
href={`/investimentos/${contextId}`}
href={`/investimentos/${contextId}${draft.context_opportunity ? "?opportunity="+encodeURIComponent(String(draft.context_opportunity)) : ""}`}
>
Ver dossier
</a>
@@ -3292,7 +3292,7 @@ export function SimulationForm() {
: investmentLandPreparation(draft.context_applicability_snapshot).need === 'yes'
? 'Precisa de terra neste investimento. Reveja a situação do terreno já preenchida ou indique-a quando disponível.'
: 'Falta definir se precisa de terra. Pode guardar o rascunho e continuar sem preencher a situação do terreno.'}</p>
<a className="underline" href={`/investimentos/${contextId}`}>Rever a necessidade de terra no dossier</a>
<a className="underline" href={`/investimentos/${contextId}${draft.context_opportunity ? "?opportunity="+encodeURIComponent(String(draft.context_opportunity)) : ""}`}>Rever a necessidade de terra no dossier</a>
</aside>
)}
<div className="grid gap-4 sm:grid-cols-2">
+2 -1
View File
@@ -34,6 +34,7 @@ export type ContextReference = {
version: number | string | null;
sha256: string;
fields: Record<string, string>;
analysis?: {documented_sector?: string | null; documented_location?: string | null; [key:string]:unknown};
documents?: ContextDocument[];
visibility?: 'private' | 'public';
state?: string | null;
@@ -63,7 +64,7 @@ export type DossierContext = {
revision: number;
selection: Partial<
Record<
'opportunity' | 'sector' | 'territory' | 'park',
'portfolio' | 'opportunity' | 'sector' | 'territory' | 'park',
ContextReference
>
>;
+1
View File
@@ -19,6 +19,7 @@ export const pathStates: Record<string, string> = {
expired: 'Acto externo expirado',
};
export type PathResult = {
context_opportunity?: string | null;
id: string;
financial_snapshot: string;
currency: string | null;
@@ -1,5 +1,6 @@
import { displayValue } from './display-value.ts';
import { fiscalQaRows } from './fiscal-qa-preview.ts';
import { pathStates } from './investor-path.ts';
import {
buildProjectDossierRows,
type ProjectDossierRow,
@@ -284,6 +285,18 @@ function makeSections(
);
if (context.version === 1) {
const section = sections.get('sources');
if (context.portfolio_origin) section?.rows.push({field:'Projecto ou carteira de origem', value:context.portfolio_origin.title, origin:`${context.portfolio_origin.key}; versão ${context.portfolio_origin.version}; ${context.portfolio_origin.href}`, classification:'Origem documental; localização e oferta não presumidas'});
if (context.consumer_opportunity) section?.rows.push({field:'Oportunidade em tratamento', value:context.consumer_opportunity, origin:context.opportunity_choices?.[0]?.opportunity?.title ?? 'Escolha guardada no dossier', classification:'Âmbito desta análise'});
for (const step of context.path?.steps ?? []) {
if (step.applicability === 'not_needed_for_preparation') section?.paragraphs.push(`${step.code} — preparação não necessária neste âmbito: ${step.reason || 'Decisão guardada do promotor'}. Histórico conservado; não constitui dispensa legal.`);
else section?.rows.push({field:`Etapa ${step.code}: ${step.title}`,value:step.applicability === 'undetermined' ? 'Aplicabilidade por determinar' : pathStates[step.state] ?? 'Estado por determinar',origin:context.consumer_opportunity ?? 'Contexto comum do dossier',classification:'Preparação no âmbito seleccionado'});
}
const dispensed = new Set((context.path?.steps ?? []).filter((step: {applicability:string})=>step.applicability === 'not_needed_for_preparation').map((step: {code:string})=>step.code));
const requirementStates: Record<string,string> = {selected:'Localização escolhida; direito não inferido',missing:'Informação em falta',conditional:'Conforme as condições do projecto',declared:'Actividades declaradas; enquadrar modalidade',undetermined:'Procedimento por determinar'};
for (const requirement of context.requirements_assessment?.requirements ?? []) {
if (requirement.opportunity_key !== context.consumer_opportunity || dispensed.has(requirement.step)) continue;
section?.rows.push({field:`Requisito: ${requirement.title}`,value:`${requirementStates[requirement.state] ?? 'Por determinar'}. ${(requirement.documents ?? []).join('; ')}`,origin:`${requirement.opportunity_title}; ${requirement.opportunity_key}; ${requirement.basis ?? ''} ${requirement.source_url ?? ''}`,classification:'Preparação específica; não constitui licença ou aprovação'});
}
const zone = context.zone;
section?.rows.push({
field: 'Contexto do investimento',
+9
View File
@@ -1,6 +1,15 @@
const levels = ['province_id', 'district_id', 'admin_post_id', 'locality_id'];
const searchRoutes = ['/carteira', '/oportunidades', '/zonas', '/educacao'];
/** Keep search geography explicit; a documentary origin is never an invented offer. */
export function portfolioPreparationHref(id: string, pageUrl: string): string {
const source = new URL(pageUrl, 'https://portal.invalid');
const inherited = source.searchParams.has('catalogue_query') ? new URLSearchParams(source.searchParams.get('catalogue_query')!) : source.searchParams;
const query = new URLSearchParams({source: `portfolio:${id}`});
for (const field of levels) if (inherited.get(field)) query.set(field, inherited.get(field)!);
return '/investimentos/iniciar?' + query.toString();
}
/** Preserve an explicit complementary selection while consulting its canonical public fiche. */
export function complementPreparationHref(pageUrl: string, target?: string): string | null {
const params = new URL(pageUrl, 'https://portal.invalid').searchParams;
+2
View File
@@ -45,6 +45,8 @@ export default function Detail({
</Link>
<PartnershipSourceLinks type="investment" id={r.id} />
{auth.user&&dossierContext&&<nav aria-label="Preparar esta oportunidade" className="flex flex-wrap gap-4">
<Link className="underline" href={`/simulador-fiscal?context=${encodeURIComponent(dossierContext)}&opportunity=opportunity%3A${r.id}`}>Simular esta oportunidade</Link>
<Link className="underline" href={`/investimentos/${encodeURIComponent(dossierContext)}?opportunity=opportunity%3A${r.id}`}>Etapas e pedidos desta oportunidade</Link>
<Link className="underline" href={`/investidor/terra-duat/novo?context=${encodeURIComponent(dossierContext)}&opportunity=opportunity%3A${r.id}`}>Terra/DUAT desta oportunidade</Link>
<Link className="underline" href={`/balcao-virtual/dossiers/${encodeURIComponent(dossierContext)}?opportunity=opportunity%3A${r.id}`}>Balcão desta oportunidade</Link>
</nav>}
@@ -82,6 +82,7 @@ type Props = {
version: number;
payload: Partial<Payload>;
opportunity_id?: number | null;
dossier_id?: string | null;
status: string;
};
references: References;
@@ -395,6 +396,7 @@ function Editor({
dossier. Associar reutiliza o contexto autorizado;
campos já editados conservam-se.
</p>
{s.dossier_id && <p><Link className="underline" href={'/investimentos/iniciar?source=' + encodeURIComponent('portfolio:' + s.dossier_id) + '&submission=' + encodeURIComponent(s.id)}>Preparar investimento com a ficha de origem</Link>. A manifestação ficará ligada ao dossier que escolher.</p>}
<label className="grid gap-1">
Dossier autorizado para esta manifestação
<select
@@ -12,6 +12,7 @@ export default function ContextStart({
step = null,
entryQuery = {},
entryComplements = [],
entrySubmission = null,
}: {
contexts: { id: string; title: string; version: number }[];
source: ContextReference | null;
@@ -19,6 +20,7 @@ export default function ContextStart({
step?: string | null;
entryQuery?: Record<string, string>;
entryComplements?: Complement[];
entrySubmission?: {id:string;version:number} | null;
}) {
const [chosen, setChosen] = useState('');
const [title, setTitle] = useState(entryComplements[0]?.kind === 'need' ? `Preparação de ${entryComplements[0].title} — ${entryComplements[0].origin.title}`.slice(0,200) : source?.title ?? opportunityChoices[0]?.opportunity.title ?? '');
@@ -28,6 +30,13 @@ export default function ContextStart({
const [key] = useState(createOperationKey);
const sourceSuffix = opportunityChoices.length ? '?' + opportunityChoices.map(c => `opportunities[]=${encodeURIComponent(c.opportunity.id)}`).join('&') : source ? '?source=' + encodeURIComponent(source.key) : '';
const suffix = sourceSuffix || Object.keys(entryQuery).length ? (sourceSuffix || '?')+(sourceSuffix ? '&' : '')+'selection_key='+encodeURIComponent(key)+'&'+new URLSearchParams(entryQuery).toString() : '';
const continueIn = async (id: string) => {
if (entrySubmission) {
const view = await contextApi<{case:{version:number}}>(`/investimentos/${id}/contexto`);
await contextApi(`/investidor/manifestacoes/${entrySubmission.id}/contexto`, {case_id:id,case_version:view.case.version,version:entrySubmission.version});
}
window.location.assign(`/investimentos/${id}/contexto/editar${suffix}`);
};
return (
<Frame title="Iniciar ou continuar um investimento">
<SelectedComplements items={entryComplements}/>
@@ -44,9 +53,11 @@ export default function ContextStart({
<Link className="underline" href={source.href}>
{source.title}
</Link>
{source.kind === 'portfolio' && <p>Sector documentado: {source.analysis?.documented_sector ?? 'Por definir'}. Cobertura documental: {source.analysis?.documented_location ?? 'Por definir'}. Confirme o enquadramento e a localização do seu investimento no dossier.</p>}
</section>
)}
<section className={card + ' space-y-3'}>
{entrySubmission && <p>Ao continuar, esta manifestação será associada ao dossier escolhido, conservando a ficha de origem. Nenhum pedido será submetido.</p>}
<h2 className="text-xl font-bold">
Continuar um dossier existente
</h2>
@@ -65,20 +76,23 @@ export default function ContextStart({
))}
</select>
</label>
{chosen && (
<Link
{chosen && entrySubmission && (
<button
className={button}
href={
step && !source && !opportunityChoices.length
? `/investimentos/${chosen}#${step}`
: `/investimentos/${chosen}/contexto/editar${suffix}`
}
disabled={busy}
onClick={() => {
if (step && !source && !opportunityChoices.length) {window.location.assign(`/investimentos/${chosen}#${step}`);return;}
setBusy(true);setError('');void continueIn(chosen).catch(e=>{setError(e.message);setBusy(false);});
}}
>
{step && !source && !opportunityChoices.length
? 'Abrir ' + step + ' no mesmo dossier'
: 'Preparar neste dossier'}
</Link>
</button>
)}
{chosen && !entrySubmission && <Link className={button} href={step && !source && !opportunityChoices.length ? `/investimentos/${chosen}#${step}` : `/investimentos/${chosen}/contexto/editar${suffix}`}>
{step && !source && !opportunityChoices.length ? 'Abrir ' + step + ' no mesmo dossier' : 'Preparar neste dossier'}
</Link>}
<p>
Se já preparou este investimento, continue nele. Não é
necessário criar outro dossier.
@@ -95,11 +109,7 @@ export default function ContextStart({
client_key: key,
reuse_consent: consent,
})
.then((r) =>
window.location.assign(
`/investimentos/${r.id}/contexto/editar${suffix}`,
),
)
.then((r) => continueIn(r.id))
.catch((e) => setError(e.message))
.finally(() => setBusy(false));
}}
+10 -3
View File
@@ -30,7 +30,7 @@ type Options = {
parks: { id: string; name: string; version_no: number }[];
search_limited: boolean;
};
type Slot = 'opportunity' | 'sector' | 'territory' | 'park';
type Slot = 'portfolio' | 'opportunity' | 'sector' | 'territory' | 'park';
export default function ContextEditor({
initial,
options: initialOptions,
@@ -41,6 +41,7 @@ export default function ContextEditor({
entrySources = [],
entryLocationDifferences = [],
entryComplements = [],
entryTerritory = null,
}: {
initial: ContextView;
options: Options;
@@ -49,6 +50,7 @@ export default function ContextEditor({
requestedChoices?: OpportunityChoice[] | null;
entrySources?: string[];
entryComplements?: Complement[];
entryTerritory?: ContextReference | null;
entryLocationDifferences?: {title:string;saved:string;requested:string}[];
draft?: {draft_sequence?:number;needs_review?:boolean;selection?:Partial<Record<Slot,string>>;declarations?:Record<string,string>;replace_fields?:string[];document_links?:Record<string,string|null>}|null;
}) {
@@ -81,12 +83,12 @@ export default function ContextEditor({
const [options, setOptions] = useState(initialOptions);
const [query, setQuery] = useState('');
const [changes, setChanges] = useState<Partial<Record<Slot, string>>>(() =>
draft?.selection ?? (source
({...(draft?.selection ?? {}), ...(entryTerritory && !draft?.selection?.territory && !initial.context.selection.territory ? {territory: entryTerritory.key} : {}), ...(source?.kind === 'portfolio' ? {portfolio: source.key} : draft?.selection ? {} : source
? {
[source.kind === 'business' ? 'opportunity' : source.kind]:
source.key,
}
: {}),
: {})}),
);
const [declarations, setDeclarations] = useState<Record<string, string>>(
draft?.declarations ?? {},
@@ -311,6 +313,11 @@ export default function ContextEditor({
{draft?.needs_review&&<p role="status">O rascunho foi recuperado, mas o dossier recebeu outras alterações. Reveja os dados antes de confirmar.</p>}
{draftStatus.startsWith('Rascunho não guardado')&&<button type="button" className="underline" onClick={()=>setDraftRetry(v=>v+1)}>Tentar guardar novamente</button>}
<OpportunityChoices corridors={corridors} choices={choices} disabled={busy||corridorLoading||!!corridorError} onChange={next=>{setChoices(next);setManageChoices(true);setPlan(null);}}/>
{(changes.portfolio || view.context.selection.portfolio) && <p className="rounded border p-3">
Origem documental: <a className="underline" href={source?.kind === 'portfolio' ? source.href : view.context.selection.portfolio?.href}>{source?.kind === 'portfolio' ? source.title : view.context.selection.portfolio?.title}</a>.
{' '}A ficha é conservada como origem. Escolha o sector e a localização do investimento abaixo; a cobertura documental não é uma implantação atribuída.
</p>}
{entryTerritory && view.context.selection.territory && entryTerritory.key !== view.context.selection.territory.key && <p role="status">A pesquisa indicava {entryTerritory.title}. Conservou-se a localização guardada, {view.context.selection.territory.title}; pode alterá-la explicitamente abaixo e conferir os impactos.</p>}
<ContextDocuments source={source ?? view.context.selection.opportunity} />
{view.context.revision > 0 && !Object.keys(changes).length && JSON.stringify(choices) === JSON.stringify(view.context.opportunity_choices ?? []) && <InvestmentRequirements assessment={view.context.requirements_assessment} contextId={view.case.id}/>}
<label className="block">Acrescentar uma oportunidade à escolha<select className={input} value="" onChange={e=>{const o=options.opportunities.find(o=>o.key===e.target.value);if(o && !choices.some(c=>c.opportunity.key===o.key)){setChoices([...choices,{opportunity:{...o,id:o.key.split(':')[1],href:'/oportunidades',version:null,sha256:'',fields:{}},territory:null,location_reason:'',location_state:'undetermined'}]);setManageChoices(true);setPlan(null);}}}><option value="">Seleccionar ficha</option>{options.opportunities.filter(o=>o.kind==='opportunity'&&!choices.some(c=>c.opportunity.key===o.key)).map(o=><option key={o.key} value={o.key}>{o.title}</option>)}</select></label>
+16
View File
@@ -34,6 +34,8 @@ type Step = {
source_paragraph: number;
};
type Path = {
context_opportunity: string | null;
opportunity_options: NonNullable<DossierContext['opportunity_choices']>;
private_investment?: Preparation | null;
fintech: FintechView | null;
investment_context: DossierContext | null;
@@ -94,6 +96,7 @@ function Result({ result }: { result: PathResult }) {
const values = recoverPathResult(result);
return (
<li className="space-y-1 rounded border p-3">
{result.context_opportunity && <p>Oportunidade desta análise: {result.context_opportunity}</p>}
{result.context_state === 'review_required' && (
<p
role="status"
@@ -139,6 +142,7 @@ function Result({ result }: { result: PathResult }) {
function Stage({ step, path }: { step: Step; path: Path }) {
const form = useForm({
version: path.case.version,
context_opportunity: path.context_opportunity,
context_revision: path.investment_context?.revision ?? 0,
context_reviewed: false,
state: path.canWrite ? 'preparing' : 'reviewing',
@@ -411,6 +415,7 @@ export default function InvestmentPath(path: Path) {
}>().props;
const facts = useForm({
version: path.case.version,
context_opportunity: path.context_opportunity,
...path.facts,
reason: '',
});
@@ -439,6 +444,17 @@ export default function InvestmentPath(path: Path) {
Os meus investimentos
</Link>
<h1 className="text-2xl font-semibold">{path.case.title}</h1>
{path.investment_context?.selection.portfolio && <p className="rounded border p-4">
Origem documental: <Link className="underline" href={path.investment_context.selection.portfolio.href}>{path.investment_context.selection.portfolio.title}</Link>.
{' '}A localização do investimento é escolhida no contexto.
</p>}
{!!path.opportunity_options?.length && <label className="block rounded border p-4">Oportunidade em tratamento
<select className="ml-2 rounded border p-2" value={path.context_opportunity ?? ''} onChange={event => router.get(`/investimentos/${path.case.id}`, event.target.value ? {opportunity:event.target.value} : {}, {preserveState:false})}>
<option value="">Escolha a oportunidade</option>
{path.opportunity_options.map(choice => <option key={choice.opportunity.key} value={choice.opportunity.key}>{choice.opportunity.title} — {choice.territory?.title ?? 'Localização por definir'}</option>)}
</select>
{!path.context_opportunity && path.opportunity_options.length > 1 && <p>Escolha para consultar as etapas, os requisitos e o pedido de terra correspondentes. Os dados comuns conservam-se.</p>}
</label>}
<ContextDocuments
source={path.investment_context?.selection.opportunity}
/>
+10
View File
@@ -14,12 +14,15 @@ export default function Index({
management,
filters,
territorySelection = [],
sectors = [],
}: {
records: Paginated<Dossier>;
management: boolean;
sectors?: string[];
filters: {
q?: string;
kind?: string;
sector?: string;
province_id?: string;
district_id?: string;
admin_post_id?: string;
@@ -30,6 +33,7 @@ export default function Index({
const form = useForm({
q: filters.q ?? '',
kind: filters.kind ?? '',
sector: filters.sector ?? '',
province_id: filters.province_id ?? '',
district_id: filters.district_id ?? '',
admin_post_id: filters.admin_post_id ?? '',
@@ -77,6 +81,12 @@ export default function Index({
<option value="project">Projectos âncora</option>
</select>
</Field>
<Field label="Sector documentado">
<select className={control} value={form.data.sector} onChange={(e) => form.setData('sector', e.target.value)}>
<option value="">Todos os sectores</option>
{sectors.map((sector) => <option key={sector} value={sector}>{sector}</option>)}
</select>
</Field>
<TerritoryFields
initial={territorySelection}
onChange={(path) =>
+10 -2
View File
@@ -1,7 +1,7 @@
import { Link, useForm, router, usePage } from '@inertiajs/react';
import { useState } from 'react';
import { catalogueReturnHref } from '@/lib/catalogue-context';
import { searchTerritoryHref } from '@/lib/search-territory';
import { searchTerritoryHref, portfolioPreparationHref } from '@/lib/search-territory';
import InvestmentMapPanel from '@/components/investment-map-panel';
import StructureGeographyPanel, {
type StructureGeography,
@@ -94,6 +94,14 @@ function Editor({
{kind(record)} · {record.content.code}
</p>
<p className="text-sm">Fonte: {record.source}</p>
{!management && (
<div className="space-y-2 rounded-xl border p-4">
<Link className="underline" href={portfolioPreparationHref(record.id, pageUrl)}>
Preparar investimento a partir desta ficha
</Link>
<p>A ficha acompanha o dossier como origem documental. A localização do investimento e as ofertas associadas são escolhas próprias.</p>
</div>
)}
{(record.project_id || record.cluster_id) && (
<PartnershipSourceLinks
type={record.project_id ? 'project' : 'cluster'}
@@ -270,7 +278,7 @@ function Editor({
Para associar esta referência a uma manifestação privada,
entre na sua conta. Esta ficha continuará pública e não será
copiada nem publicada no seu nome.{' '}
<Link className="underline" href="/login">
<Link className="underline" href={'/carteira/' + record.id + '/interesse' + (() => { const query = new URL(pageUrl, 'https://portal.invalid').searchParams.get('catalogue_query'); return query ? '?catalogue_query=' + encodeURIComponent(query) : ''; })()}>
Entrar
</Link>
</p>
+1
View File
@@ -92,6 +92,7 @@ Route::middleware(['auth', 'verified', \App\Http\Middleware\EnsureDocumentManage
Route::get('/artigos/{id}', [\App\Http\Controllers\EditorialArticleController::class, 'show'])->whereNumber('id')->name('editorial.show');
Route::get('/carteira', [InvestmentPortfolioController::class, 'index'])->name('portfolio.index');
Route::get('/carteira/{id}', [InvestmentPortfolioController::class, 'show'])->whereUuid('id')->name('portfolio.show');
Route::get('/carteira/{id}/interesse', [InvestmentPortfolioController::class, 'resumeInterest'])->whereUuid('id')->middleware(['auth', \App\Http\Middleware\VerifiedOrSimpleParticipation::class])->name('portfolio.interest');
Route::get('/oportunidades', [InvestmentDiscoveryController::class, 'index'])->name('portal.opportunities');
Route::get('/oportunidades/{slug}', [InvestmentDiscoveryController::class, 'detail'])->name('portal.opportunity');
Route::get('/negocios', [PublicBusinessOpportunityController::class, 'index'])->name('business-marketplace.index');
@@ -0,0 +1,127 @@
<?php
namespace Tests\Feature;
use App\Http\Controllers\InvestmentPortfolioController;
use App\Models\User;
use App\Services\ApprovedAnchorTerritories;
use App\Services\InvestmentRelations;
use App\Services\InvestorContext;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
final class InvestmentAnchorIntegrationTest extends TestCase
{
use DatabaseTransactions;
private User $actor;
protected function setUp(): void
{
parent::setUp();
$identity = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $identity->name);
$this->assertSame(55441, (int) $identity->port);
$this->withoutVite();
$this->withoutMiddleware(ThrottleRequests::class);
config(['investor-path.enabled' => true, 'investor-path.private_preparation' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
$this->actor = User::factory()->create();
}
public function test_documented_sector_filter_is_explicit_and_preserves_the_catalogue_scope(): void
{
$anchor = InvestmentPortfolioController::visible()->where('content->code', 'A06')->firstOrFail();
$sector = $anchor->content['sector'];
$this->get('/carteira?'.http_build_query(['kind' => 'project', 'sector' => $sector]))->assertOk()
->assertInertia(fn (Assert $p) => $p->where('filters.sector', $sector)->has('records.data', 1)->where('records.data.0.id', $anchor->id)->has('sectors'));
$this->get('/carteira?sector=sector-inexistente')->assertOk()->assertInertia(fn (Assert $p) => $p->has('records.data', 0));
$this->get('/carteira')->assertOk()->assertInertia(fn (Assert $p) => $p->where('records.total', InvestmentPortfolioController::visible()->count()));
}
public function test_accepted_territories_are_recovered_by_identity_with_citations_and_idempotence(): void
{
$service = app(ApprovedAnchorTerritories::class);
$before = DB::table('investment_dossiers')->orderBy('id')->get()->toJson();
$plan = $service->plan();
$this->assertCount(28, $plan['links']);
$this->assertCount(13, $plan['projects']);
$result = $service->apply($plan);
$this->assertSame(count(array_filter($plan['links'], fn ($link) => ! $link['already_present'])), $result['relations_restored']);
$this->assertSame(0, $result['publication_changes']);
$this->assertSame($before, DB::table('investment_dossiers')->orderBy('id')->get()->toJson());
$second = $service->apply($service->plan());
$this->assertSame(0, $second['relations_restored']);
$this->assertSame(0, $second['projects_updated']);
$anchor = InvestmentPortfolioController::visible()->where('content->code', 'A06')->firstOrFail();
$locations = app(InvestmentRelations::class)->projectLocations($anchor->project_id);
$this->assertCount(4, $locations);
$this->assertNotEmpty($locations[0]['source_quote']);
$this->assertSame('secondary', $locations[0]['role']);
$province = $locations[0]['id'];
$this->get('/carteira?'.http_build_query(['kind' => 'project', 'sector' => $anchor->content['sector'], 'province_id' => $province]))->assertOk()
->assertInertia(fn (Assert $p) => $p->has('records.data', 1)->where('records.data.0.id', $anchor->id));
$this->assertCount(0, app(InvestmentRelations::class)->projectLocations(InvestmentPortfolioController::visible()->where('content->code', 'A01')->firstOrFail()->project_id));
}
public function test_anchor_without_offer_survives_login_dossier_and_simulator_as_documentary_origin(): void
{
$anchor = InvestmentPortfolioController::visible()->where('content->code', 'A06')->firstOrFail();
$source = 'portfolio:'.$anchor->id;
$entry = '/investimentos/iniciar?'.http_build_query(['source' => $source]);
$this->get($entry)->assertRedirect(route('login'));
$this->assertStringEndsWith($entry, session('url.intended'));
$this->actingAs($this->actor);
$this->get($entry)->assertOk()->assertInertia(fn (Assert $p) => $p->where('source.key', $source)->has('opportunityChoices', 0));
$reference = app(InvestorContext::class)->reference($source);
$this->assertArrayNotHasKey('province_id', $reference['fields']);
$this->assertFalse($reference['analysis']['available_opportunity']);
$id = $this->postJson('/investimentos', ['title' => 'SYNTHETIC QA - ideia propria', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$body = ['version' => $this->getJson('/investimentos/'.$id.'/contexto')->json('case.version'), 'selection' => ['portfolio' => $source]];
$preview = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
$this->assertSame('SYNTHETIC QA - ideia propria', $preview['fields']['project_title']);
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$body, 'preview_hash' => $preview['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$this->assertSame($source, $view['context']['selection']['portfolio']['key']);
$this->assertSame([], $view['context']['opportunity_choices']);
$snapshot = json_decode($view['prefill']['simulator']['context_applicability_snapshot'], true);
$this->assertSame($source, $snapshot['portfolio_origin']['key']);
$draft = $this->postJson('/api/v1/simulation-drafts', ['context' => $id, 'personal_snapshot' => [], 'economic_snapshot' => ['purpose' => 'simulate_only', 'project_title' => 'SYNTHETIC QA - titulo proprio', 'context_applicability_snapshot' => json_encode($snapshot)]], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$saved = $this->getJson('/api/v1/simulation-drafts/'.$draft.'?context='.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($source, json_decode($saved['context_applicability_snapshot'], true)['portfolio_origin']['key']);
$this->assertSame('SYNTHETIC QA - titulo proprio', $saved['project_title']);
$anchor->publication_status = 'draft';
$anchor->save();
$this->get($entry)->assertNotFound();
}
public function test_interest_resumption_keeps_the_same_anchor_and_filters_without_creating_a_request_on_get(): void
{
$anchor = InvestmentPortfolioController::visible()->where('content->code', 'A06')->firstOrFail();
$query = http_build_query(['sector' => $anchor->content['sector'], 'kind' => 'project']);
$entry = '/carteira/'.$anchor->id.'/interesse?'.http_build_query(['catalogue_query' => $query]);
$before = DB::table('investment_submissions')->count();
$this->get($entry)->assertRedirect(route('login'));
$this->assertStringEndsWith($entry, session('url.intended'));
$this->actingAs($this->actor);
$this->get($entry)->assertRedirect('/carteira/'.$anchor->id.'?'.http_build_query(['catalogue_query' => $query, 'prepare_interest' => '1']));
$this->assertSame($before, DB::table('investment_submissions')->count());
$response = $this->postJson('/investidor/manifestacoes', ['dossier_id' => $anchor->id])->assertCreated();
$row = DB::table('investment_submissions')->where('id', $response->json('id'))->first();
$this->assertSame($anchor->id, $row->dossier_id);
$start = '/investimentos/iniciar?'.http_build_query(['source' => 'portfolio:'.$anchor->id, 'submission' => $row->id]);
$this->get($start)->assertOk()->assertInertia(fn (Assert $p) => $p->where('entrySubmission.id', $row->id)->where('entrySubmission.version', (int) $row->version));
$other = InvestmentPortfolioController::visible()->where('id', '!=', $anchor->id)->firstOrFail();
$this->get('/investimentos/iniciar?'.http_build_query(['source' => 'portfolio:'.$other->id, 'submission' => $row->id]))->assertUnprocessable();
$case = $this->postJson('/investimentos', ['title' => 'SYNTHETIC QA - manifesto e ancora', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$version = $this->getJson('/investimentos/'.$case.'/contexto')->json('case.version');
$this->postJson('/investidor/manifestacoes/'.$row->id.'/contexto', ['case_id' => $case, 'case_version' => $version, 'version' => (int) $row->version])->assertOk();
$this->assertSame($row->id, DB::table('desk_cases')->where('id', $case)->value('submission_id'));
$this->assertSame($anchor->id, DB::table('investment_submissions')->where('id', $row->id)->value('dossier_id'));
$this->actingAs(User::factory()->create());
$this->get($start)->assertNotFound();
}
}
@@ -0,0 +1,169 @@
<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\{CorridorAgribusinessOpportunities, InvestmentCorridorContext, InvestmentConsumerScope, InvestorContext, InvestmentZones, VirtualDesk};
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class InvestmentTreatmentScopeTest extends TestCase
{
use DatabaseTransactions;
private User $actor;
private string $id;
private array $choices;
private array $parks;
protected function setUp(): void
{
parent::setUp();$this->withoutVite();
$this->withoutMiddleware(\Illuminate\Routing\Middleware\ThrottleRequests::class);
$db=DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test',$db->name);$this->assertSame(55441,(int)$db->port);
config(['investor-path.enabled'=>true,'investor-path.private_preparation'=>true,'suitecrm.enabled'=>false,'investmoz_ai.enabled'=>false]);
$this->actor=User::factory()->create();$this->actingAs($this->actor);
$this->id=$this->postJson('/investimentos',['title'=>'SYNTHETIC QA — âmbito específico','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id');
$corridors=app(InvestmentCorridorContext::class);
$records=[];foreach(['beira','limpopo'] as $corridor){
$entry=collect(app(CorridorAgribusinessOpportunities::class)->entries())->firstWhere('corridor',config('investment_corridors.'.$corridor.'.name'));
$records[]=Opportunity::where('management_creation_key',$entry['key'])->firstOrFail();
}
$sofala=collect($corridors->provinces('beira'))->firstWhere('source_code','07')->id;
$gaza=$corridors->provinces('limpopo')[0]->id;
$manica=collect($corridors->provinces('beira'))->firstWhere('name','Manica')->id;
$template=app(InvestmentZones::class)->visible()->whereIn('category',['industrial_park','zee','zfi'])->firstOrFail();
foreach([$sofala,$gaza,$manica] as $territory){$p=$template->replicate();$p->code='SYNTHETIC-QA-'.Str::uuid();$p->name='SYNTHETIC QA — parque '.$territory;$p->primary_territory_id=$territory;$p->save();$this->parks[]=$p;}
$this->choices=[['source'=>'opportunity:'.$records[0]->id,'corridor_key'=>'beira','territory'=>'territory:'.$sofala,'park'=>'park:'.$this->parks[0]->id,'activities'=>['production']],['source'=>'opportunity:'.$records[1]->id,'corridor_key'=>'limpopo','territory'=>'territory:'.$gaza,'park'=>'park:'.$this->parks[1]->id,'activities'=>['trade']]];
$this->confirm();
}
private function confirm(): array
{
$v=$this->getJson('/investimentos/'.$this->id.'/contexto')->assertOk()->json();
$body=['version'=>$v['case']['version'],'selection'=>[],'opportunity_choices'=>$this->choices];
$plan=$this->postJson('/investimentos/'.$this->id.'/contexto/previa',$body)->assertOk()->json();
$this->postJson('/investimentos/'.$this->id.'/contexto/confirmar',[...$body,'preview_hash'=>$plan['preview_hash'],'operation_key'=>(string)Str::uuid(),'confirm'=>true])->assertOk();
return $this->getJson('/investimentos/'.$this->id.'/contexto')->assertOk()->json();
}
private function treatment(string $key): array
{
return $this->getJson('/investimentos/'.$this->id.'?opportunity='.urlencode($key))->assertOk()->json();
}
public function test_opportunity_decisions_steps_and_simulator_are_isolated_from_common_dossier(): void
{
$keys=array_column($this->choices,'source');
foreach ($keys as $index=>$key) {
$v=$this->treatment($key);
$this->postJson('/investimentos/'.$this->id.'/aplicabilidade',['version'=>$v['case']['version'],'context_opportunity'=>$key,'land_needed'=>$index?'yes':'no','external_funding'=>'unknown','viability'=>'proceed','reason'=>'QA decisão específica por oportunidade'])->assertOk();
}
$a=$this->treatment($keys[0]);$b=$this->treatment($keys[1]);
$this->assertSame('no',$a['facts']['land_needed']);$this->assertSame('yes',$b['facts']['land_needed']);
$this->assertNull($a['tools']['land']);$this->assertNotNull($b['tools']['land']);
$this->assertSame('not_needed_for_preparation',collect($a['steps'])->firstWhere('code','S10')['applicability']);
$this->postJson('/investimentos/'.$this->id.'/etapas/S10',['version'=>$b['case']['version'],'context_opportunity'=>$keys[1],'state'=>'preparing','action'=>'prepare','reason'=>'Preparação apenas da segunda oportunidade'])->assertOk();
$this->assertSame('preparing',collect($this->treatment($keys[1])['steps'])->firstWhere('code','S10')['saved_state']);
$this->assertSame('not_started',collect($this->treatment($keys[0])['steps'])->firstWhere('code','S10')['saved_state']);
$v=$this->getJson('/investimentos/'.$this->id.'/contexto?opportunity='.urlencode($keys[1]))->assertOk()->json();
$sim=$v['prefill']['simulator'];$snap=json_decode($sim['context_applicability_snapshot'],true);
$this->assertSame($keys[1],$snap['consumer_opportunity']);$this->assertSame('yes',$snap['land_preparation']['need']);
$this->assertSame($this->parks[1]->primary_territory_id,$sim['province_id']);
$this->assertStringContainsString('opportunity=', $b['tools']['simulator']);
$this->assertArrayNotHasKey('province_id',$this->getJson('/investimentos/'.$this->id.'/contexto')->json('prefill.simulator'));
$payload=app(VirtualDesk::class)->decode(DB::table('desk_cases')->where('id',$this->id)->value('payload'));
$this->assertArrayNotHasKey('facts',$payload['investor_path']);
}
public function test_land_shortcut_recovers_associated_request_even_when_other_request_is_newer(): void
{
$ids=[];foreach(array_reverse($this->choices) as $choice) {
$ids[$choice['source']]=$this->postJson('/investidor/terra-duat',['payload'=>['investor_path_case_id'=>$this->id,'context_opportunity'=>$choice['source'],'project_reference'=>'QA scope']])->assertCreated()->json('id');
}
foreach($ids as $key=>$id) $this->assertStringEndsWith($id,$this->treatment($key)['tools']['land']);
$unscoped=$this->getJson('/investimentos/'.$this->id)->assertOk()->json();
foreach($ids as $id) $this->assertStringNotContainsString($id,$unscoped['tools']['land']);
$this->getJson('/investimentos/'.$this->id.'?opportunity=opportunity:99999999')->assertUnprocessable();
}
public function test_simulator_saves_recovers_selected_scope_and_rejects_foreign_choice(): void
{
$key=$this->choices[1]['source'];
$sim=$this->getJson('/investimentos/'.$this->id.'/contexto?opportunity='.urlencode($key))->json('prefill.simulator');
$data=['context'=>$this->id,'personal_snapshot'=>[], 'economic_snapshot'=>['purpose'=>'simulate_only','project_title'=>'QA âmbito simulado','context_opportunity'=>$key,'context_applicability_snapshot'=>$sim['context_applicability_snapshot'],'financial_tools_snapshot'=>json_encode(['version'=>1,'data'=>[]])]];
$this->postJson('/api/v1/simulation-drafts',$data,['Idempotency-Key'=>(string)Str::uuid()])->assertCreated();
$this->getJson('/api/v1/simulation-drafts/current?context='.$this->id)->assertOk()->assertJsonPath('data.economic_snapshot.context_opportunity',$key);
$this->actingAs($this->actor->fresh());
$saved=$this->getJson('/api/v1/simulation-drafts/current?context='.$this->id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($key,json_decode($saved['context_applicability_snapshot'],true)['consumer_opportunity']);
$current=$this->getJson('/api/v1/simulation-drafts/current?context='.$this->id)->json('data');
$case=$this->treatment($key);
$this->postJson('/investimentos/'.$this->id.'/resultados',['version'=>$case['case']['version'],'draft_id'=>$current['id'],'draft_version'=>$current['version_no'],'context_revision'=>$case['investment_context']['revision'],'context_reviewed'=>true])->assertOk()->assertJsonPath('result.context_opportunity',$key);
$other=$this->postJson('/investimentos',['title'=>'QA identidade alheia ao dossier','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id');
$this->postJson('/api/v1/simulation-drafts',[...$data,'context'=>$other],['Idempotency-Key'=>(string)Str::uuid()])->assertUnprocessable();
}
public function test_authorised_common_contacts_are_reused_without_inventing_company_or_tax_identity(): void
{
$this->putJson('/investidor/profile',['version'=>0,'payload'=>['phone'=>'+258840001234','country'=>'Portugal','sector'=>'Sector de preferência']])->assertOk();
$id=$this->postJson('/investimentos',['title'=>'QA contactos autorizados','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id');
$v=$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json('prefill');
foreach(['land','simulator'] as $tool) {
$this->assertSame('+258840001234',$v[$tool]['phone']);$this->assertSame($this->actor->email,$v[$tool]['email']);
$this->assertArrayNotHasKey('tax_residency_country',$v[$tool]);$this->assertArrayNotHasKey('sector',$v[$tool]);
}
$this->assertSame($this->actor->name,$v['land']['full_name']);
}
public function test_idea_sector_and_territory_entries_keep_explicit_source_through_recovery(): void
{
$id=$this->postJson('/investimentos',['title'=>'QA ideia livre','client_key'=>(string)Str::uuid(),'reuse_consent'=>true])->assertCreated()->json('id');
$this->get('/investimentos/iniciar')->assertOk()->assertInertia(fn($p)=>$p->where('source',null));
$sector=DB::table('sectors')->where('is_active',true)->first();
foreach(['sector:'.$sector->id,'territory:'.$this->parks[1]->primary_territory_id] as $source) {
$this->get('/investimentos/'.$id.'/contexto/editar?source='.urlencode($source))->assertOk()->assertInertia(fn($p)=>$p->where('source.key',$source));
$slot=str_starts_with($source,'sector:')?'sector':'territory';
$v=$this->getJson('/investimentos/'.$id.'/contexto')->json();
$body=['version'=>$v['case']['version'],'selection'=>[$slot=>$source]];
$preview=$this->postJson('/investimentos/'.$id.'/contexto/previa',$body)->assertOk()->json();
$this->postJson('/investimentos/'.$id.'/contexto/confirmar',[...$body,'preview_hash'=>$preview['preview_hash'],'operation_key'=>(string)Str::uuid(),'confirm'=>true])->assertOk();
$this->actingAs($this->actor->fresh());
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('context.selection.'.$slot.'.key',$source);
}
$view=$this->getJson('/investimentos/'.$id.'/contexto')->json('prefill');
$this->assertSame($this->parks[1]->primary_territory_id,$view['simulator']['province_id']);
$this->assertSame($this->parks[1]->primary_territory_id,$view['land']['parcels'][0]['province_id']);
$this->assertSame($id,$this->getJson('/investimentos/'.$id)->json('case.id'));
}
public function test_sector_entry_survives_authentication_redirect_with_same_source(): void
{
$sector=DB::table('sectors')->where('is_active',true)->first();
$url='/investimentos/iniciar?source='.urlencode('sector:'.$sector->id);
\Illuminate\Support\Facades\Auth::logout();
$this->get($url)->assertRedirect('/login')->assertSessionHas('url.intended',url($url));
$this->post('/login',['email'=>$this->actor->email,'password'=>'password'])->assertRedirect(url($url));
$this->get($url)->assertOk()->assertInertia(fn($p)=>$p->where('source.key','sector:'.$sector->id));
}
public function test_removing_first_keeps_second_identity_common_edits_and_historical_requests(): void
{
$before=$this->confirm();$second=$before['context']['opportunity_choices'][1];
$key=$this->choices[1]['source'];
$land=$this->postJson('/investidor/terra-duat',['payload'=>['investor_path_case_id'=>$this->id,'context_opportunity'=>$this->choices[0]['source'],'project_reference'=>'QA histórico']])->assertCreated()->json('id');
$this->choices=[$this->choices[1]];
$v=$this->getJson('/investimentos/'.$this->id.'/contexto')->json();
$body=['version'=>$v['case']['version'],'selection'=>[],'opportunity_choices'=>$this->choices,'declarations'=>['project_title'=>'Título deliberado comum']];
$preview=$this->postJson('/investimentos/'.$this->id.'/contexto/previa',$body)->assertOk()->json();
$this->postJson('/investimentos/'.$this->id.'/contexto/confirmar',[...$body,'preview_hash'=>$preview['preview_hash'],'operation_key'=>(string)Str::uuid(),'confirm'=>true])->assertOk();
$after=$this->getJson('/investimentos/'.$this->id.'/contexto')->assertOk()->json();
$this->assertSame($second,$after['context']['opportunity_choices'][0]);
$this->assertSame('Título deliberado comum',$after['context']['fields']['project_title']);
$this->getJson('/investidor/terra-duat/'.$land)->assertOk();
$this->assertStringNotContainsString($land,$this->treatment($key)['tools']['land']);
}
}
@@ -0,0 +1,118 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\ApprovedAdministrativeBoundaries;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class TerritoryGeometryConsumerAccessTest extends TestCase
{
use DatabaseTransactions;
private User $actor;
private string $territory;
protected function setUp(): void
{
parent::setUp();
$identity = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $identity->name);
$this->assertSame(55441, (int) $identity->port);
$this->withoutVite();
$this->withoutMiddleware(ThrottleRequests::class);
config(['portal.profile_access_enabled' => true, 'portal.managers' => [], 'investor-path.enabled' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
$this->actor = User::factory()->create(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()]);
$other = User::factory()->create();
DB::table('portal_superuser_pair')->updateOrInsert(['id' => 1], ['first_user_id' => $this->actor->id, 'second_user_id' => $other->id, 'approved_by' => $other->id]);
$this->territory = DB::table('geo_geometries as g')->join('geo_locations as l', 'l.id', '=', 'g.geo_location_id')
->where('g.source_system', 'ine-cod-ab')->where('g.data_classification', 'internal')->where('l.location_type', 'province')->value('l.id');
$this->assertNotEmpty($this->territory);
}
public function test_general_consumer_uses_existing_authenticated_read_access_without_the_legacy_gate(): void
{
$this->assertFalse($this->actor->can('manage-portal'));
$before = $this->fingerprint();
$this->actingAs($this->actor)->getJson('/api/v1/investment/territory-geometries?territory_id='.$this->territory)
->assertOk()->assertJsonPath('selected_geometry_available', true)->assertJsonPath('geometry_scope', 'authorized_internal')
->assertJsonPath('areas.0.territory_id', $this->territory)->assertHeader('Cache-Control', 'no-store, private');
$this->assertSame($before, $this->fingerprint());
}
public function test_existing_administration_reaches_the_same_geometry_with_its_completed_mfa_session(): void
{
$this->actingAs($this->actor)->withSession(['portal.mfa_actor' => $this->actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $this->actor->portal_access_revision]);
$this->getJson('/management/territory-geometries?territory_id='.$this->territory)->assertOk()->assertJsonPath('selected_geometry_available', true);
session()->forget(['portal.mfa_actor', 'portal.mfa_at']);
$this->getJson('/management/territory-geometries?territory_id='.$this->territory)->assertForbidden();
}
public function test_anonymous_and_ordinary_accounts_do_not_receive_or_publish_internal_layers(): void
{
$before = $this->fingerprint();
$url = '/api/v1/investment/territory-geometries?territory_id='.$this->territory;
$this->getJson($url)->assertOk()->assertJsonPath('geometry_scope', 'public')->assertJsonPath('selected_geometry_available', false)->assertJsonCount(0, 'areas');
$this->actingAs(User::factory()->create())->getJson($url)->assertOk()->assertJsonPath('geometry_scope', 'public')->assertJsonCount(0, 'areas');
$this->getJson('/management/territory-geometries?territory_id='.$this->territory)->assertForbidden();
$this->assertSame($before, $this->fingerprint());
}
public function test_saved_territory_is_recovered_in_dossier_and_simulator_and_the_map_uses_that_identity(): void
{
$this->actingAs($this->actor);
$id = $this->postJson('/investimentos', ['title' => 'SYNTHETIC QA - mapa recuperado', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$body = ['version' => $this->getJson('/investimentos/'.$id.'/contexto')->json('case.version'), 'selection' => ['territory' => 'territory:'.$this->territory]];
$preview = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$body, 'preview_hash' => $preview['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$this->assertSame($this->territory, $view['context']['selection']['territory']['id']);
$this->assertSame($this->territory, $view['prefill']['simulator']['province_id']);
$this->getJson('/api/v1/investment/territory-geometries?territory_id='.$view['prefill']['simulator']['province_id'])
->assertOk()->assertJsonPath('selected_geometry_available', true)->assertJsonPath('areas.0.selected_territory', true);
$this->assertSame($this->territory, $this->getJson('/investimentos/'.$id.'/contexto')->json('context.selection.territory.id'));
}
public function test_only_the_approved_unchanged_snapshot_is_public_without_reclassifying_the_original(): void
{
$before = $this->fingerprint();
$row = DB::table('geo_geometries as g')->where('geo_location_id', $this->territory)->first([
'g.id', 'g.geo_location_id', DB::raw("encode(sha256(ST_AsEWKB(g.geom)),'hex') as geometry_sha256"),
DB::raw("encode(sha256(convert_to(g.properties::text,'UTF8')),'hex') as properties_sha256"),
]);
// The QA import has its own identities; never copy or replace production rows.
$approved = $this->partialMock(ApprovedAdministrativeBoundaries::class);
$approved->shouldReceive('snapshots')->andReturn([$row->id => [
'territory_id' => $row->geo_location_id, 'geometry_sha256' => $row->geometry_sha256,
'properties_sha256' => $row->properties_sha256,
]]);
$url = '/api/v1/investment/territory-geometries?territory_id='.$this->territory;
$this->getJson($url)->assertOk()->assertJsonPath('selected_geometry_available', true)
->assertJsonPath('areas.0.data_classification', 'public')->assertJsonPath('investment_site', false);
$this->actingAs(User::factory()->create())->getJson($url)->assertOk()->assertJsonPath('selected_geometry_available', true);
$this->assertSame($before, $this->fingerprint());
$this->assertSame('internal', DB::table('geo_geometries')->where('id', $row->id)->value('data_classification'));
DB::table('geo_geometries')->where('id', $row->id)->update(['properties' => DB::raw("properties || '{\"qa_changed\":true}'::jsonb")]);
$this->getJson($url)->assertOk()->assertJsonPath('selected_geometry_available', false)->assertJsonCount(0, 'areas');
$this->actingAs($this->actor)->getJson($url)->assertOk()->assertJsonPath('selected_geometry_available', true);
}
public function test_public_manifest_has_the_exact_approved_scope_and_immutable_hash(): void
{
$items = app(ApprovedAdministrativeBoundaries::class)->snapshots();
$this->assertCount(408, $items);
$this->assertCount(408, array_unique(array_column($items, 'territory_id')));
$this->assertSame(['2019-06-07'], array_values(array_unique(array_column($items, 'source_version'))));
}
private function fingerprint(): string
{
return DB::selectOne("select md5(string_agg(row_to_json(g)::text,E'\\n' order by id)) as hash from geo_geometries g")->hash;
}
}
@@ -0,0 +1,30 @@
<?php
namespace Tests\Unit;
use App\Services\ApprovedAdministrativeBoundaries;
use Tests\TestCase;
/** Runs in the normal delivery unit suite, without database or external services. */
final class ApprovedAdministrativeBoundariesTest extends TestCase
{
public function test_public_scope_keeps_the_accepted_identity_and_refuses_changed_or_new_internal_shapes(): void
{
$approved = new ApprovedAdministrativeBoundaries;
$items = $approved->snapshots();
$this->assertCount(408, $items);
$item = reset($items);
$this->assertTrue($approved->allows($item['id'], $item['territory_id'], $item['geometry_sha256'], $item['properties_sha256']));
$this->assertFalse($approved->allows('00000000-0000-0000-0000-000000000000', $item['territory_id'], $item['geometry_sha256'], $item['properties_sha256']));
$this->assertFalse($approved->allows($item['id'], '00000000-0000-0000-0000-000000000000', $item['geometry_sha256'], $item['properties_sha256']));
$this->assertFalse($approved->allows($item['id'], $item['territory_id'], str_repeat('0', 64), $item['properties_sha256']));
$this->assertFalse($approved->allows($item['id'], $item['territory_id'], $item['geometry_sha256'], str_repeat('0', 64)));
}
public function test_the_public_scope_keeps_the_accepted_edition_and_unique_territories(): void
{
$items = (new ApprovedAdministrativeBoundaries)->snapshots();
$this->assertCount(408, array_unique(array_column($items, 'territory_id')));
$this->assertSame(['2019-06-07'], array_values(array_unique(array_column($items, 'source_version'))));
}
}
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { portfolioPreparationHref } from '../../resources/js/lib/search-territory.ts';
import { createProjectDossierReport } from '../../resources/js/lib/project-dossier-report.ts';
test('anchor preparation keeps exact identity and explicit search geography', () => {
const query = new URLSearchParams({sector:'Agro-indústria e agricultura',province_id:'province-1',district_id:'district-1'});
const href = portfolioPreparationHref('anchor-1','/carteira/anchor-1?catalogue_query='+encodeURIComponent(query.toString()));
const params = new URL(href,'https://portal.invalid').searchParams;
assert.equal(params.get('source'),'portfolio:anchor-1');
assert.equal(params.get('province_id'),'province-1');
assert.equal(params.get('district_id'),'district-1');
assert.equal(params.has('opportunity'),false);
assert.equal(params.has('sector_id'),false);
});
test('report identifies the saved documentary anchor without asserting an available offer', () => {
const context = {version:1,portfolio_origin:{key:'portfolio:anchor-1',title:'Âncora documental',version:2,href:'/carteira/anchor-1'}};
const report = createProjectDossierReport({context_applicability_snapshot:JSON.stringify(context)},{selectedSections:new Set(['sources'])});
const row = report.sections.find(section=>section.id==='sources').rows.find(row=>row.field==='Projecto ou carteira de origem');
assert.equal(row.value,'Âncora documental');
assert.match(row.origin,/portfolio:anchor-1; versão 2/);
assert.match(row.classification,/localização e oferta não presumidas/);
});
+19
View File
@@ -0,0 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {createProjectDossierReport,createProjectDossierPrintHtml} from '../../resources/js/lib/project-dossier-report.ts';
test('native print source includes only selected applicable requirements and human stage states',async()=>{
const context={version:1,consumer_opportunity:'opportunity:309',context_revision:1,
opportunity_choices:[{opportunity:{key:'opportunity:309',title:'Segunda oportunidade Manica'}}],
path:{steps:[{code:'S10',title:'Terra',state:'not_started',applicability:'applicable'},{code:'S13',title:'Financiamento',applicability:'not_needed_for_preparation',reason:'Sem financiamento externo'}]},
requirements_assessment:{requirements:[
{opportunity_key:'opportunity:309',opportunity_title:'Segunda oportunidade Manica',step:'S10',title:'Acesso à terra e DUAT',state:'conditional',documents:['Planta do local'],basis:'Fonte documentada',source_url:'/documentos/fonte'},
{opportunity_key:'opportunity:286',step:'S10',title:'Requisito da primeira Sofala',documents:[]},
{opportunity_key:'opportunity:309',step:'S13',title:'Documento de financiamento dispensado',documents:[]},
]}};
const report=createProjectDossierReport({project_title:'QA PDF Manica',context_applicability_snapshot:JSON.stringify(context)},{selectedSections:new Set(['sources'])});
const html=await createProjectDossierPrintHtml(report,[],false);
assert.match(html,/Por iniciar/);assert.doesNotMatch(html,/not_started/);
assert.match(html,/Acesso à terra e DUAT/);assert.match(html,/Planta do local/);assert.match(html,/Fonte documentada/);
assert.doesNotMatch(html,/Requisito da primeira Sofala|Documento de financiamento dispensado/);
});
@@ -0,0 +1,32 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import JSZip from 'jszip';
import { mkdir, writeFile } from 'node:fs/promises';
import { createProjectDossierReport, createProjectDossierPrintHtml, generateProjectDossierDocx, generateProjectDossierPackage } from '../../resources/js/lib/project-dossier-report.ts';
import { inheritCompatibleFields } from '../../resources/js/lib/context-inheritance.ts';
test('selected second opportunity survives actual Word and ZIP generation and the native print document', async () => {
const snapshot=JSON.stringify({version:1,context_revision:12,consumer_opportunity:'opportunity:309',zone:{title:'Parque Manica',version:3,href:'/parques/manica'},opportunity_choices:[{opportunity:{key:'opportunity:309',title:'Segunda oportunidade Manica'}}],land_preparation:{need:'yes'},path:{steps:[{code:'S10',title:'Terra Manica',applicability:'applicable',state:'preparing'},{code:'S13',title:'Financiamento',applicability:'not_needed_for_preparation',reason:'Sem financiamento externo neste ambito'}]}});
const report=createProjectDossierReport({project_title:'QA segunda oportunidade',context_applicability_snapshot:snapshot},{selectedSections:new Set(['sources'])});
const html=await createProjectDossierPrintHtml(report,[],false);
assert.match(html,/Terra Manica/);assert.match(html,/Sem financiamento externo neste ambito/);assert.match(html,/opportunity:309/);assert.match(html,/Segunda oportunidade Manica/);assert.match(html,/Parque Manica/);assert.doesNotMatch(html,/Sofala/);
const docx=await generateProjectDossierDocx(report,[],true);
const bytes=await docx.arrayBuffer();const word=await JSZip.loadAsync(bytes);
const xml=await word.file('word/document.xml').async('string');
assert.match(xml,/opportunity:309/);assert.match(xml,/Parque Manica/);assert.doesNotMatch(xml,/Sofala/);
const bundle=await generateProjectDossierPackage(docx,[],true);const zip=await JSZip.loadAsync(await bundle.arrayBuffer());
const inner=Object.keys(zip.files).find(name=>name.endsWith('.docx'));assert.ok(inner);
assert.deepEqual(await zip.file(inner).async('uint8array'),new Uint8Array(bytes));
await mkdir('outputs/multi-opportunity-fix-20261023/treatment-exports',{recursive:true});
await writeFile('outputs/multi-opportunity-fix-20261023/treatment-exports/second.docx',new Uint8Array(bytes));
await writeFile('outputs/multi-opportunity-fix-20261023/treatment-exports/second.zip',new Uint8Array(await bundle.arrayBuffer()));
await writeFile('outputs/multi-opportunity-fix-20261023/treatment-exports/second-print.html',html);
});
test('new common contact reuse preserves a deliberate contact and follows inherited contact only',()=>{
const source={email:'qa@example.invalid',phone:'+258840001234'};
const a=inheritCompatibleFields('dossier:qa:land',{email:'edited@example.invalid'},source,'');
assert.equal(a.patch.phone,source.phone);assert.equal(a.patch.email,undefined);
const b=inheritCompatibleFields('dossier:qa:land',{email:'edited@example.invalid',phone:source.phone},{...source,phone:'+258840005678'},a.metadata);
assert.equal(b.patch.phone,'+258840005678');assert.equal(b.patch.email,undefined);
});
@@ -0,0 +1,71 @@
<?php
// Delimited replay of accepted territorial relations, not a new publication.
declare(strict_types=1);
use App\Services\ApprovedAnchorTerritories;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
ini_set('zend.exception_ignore_args', '1');
require getcwd().'/vendor/autoload.php';
$app = require getcwd().'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$args = getopt('', ['qa', 'apply', 'output:', 'preview-sha256:', 'backup:']);
$qa = isset($args['qa']);
$apply = isset($args['apply']);
$expected = $qa ? 'simulator_fiscal_delta_20261021_test' : 'teste_investidor';
$port = $qa ? 55441 : 5432;
$identity = DB::selectOne('select current_database() as name,inet_server_port() as port');
if ($identity->name !== $expected || (int) $identity->port !== $port) {
throw new RuntimeException('Named isolated QA or homologation required.');
}
if (! $qa && (! getenv('SSH_CONNECTION') || ! function_exists('posix_geteuid') || posix_geteuid() !== 0 || rtrim(config('app.url'), '/') !== 'https://teste-investidor.tempo.co.mz')) {
throw new RuntimeException('Authenticated destination operator required.');
}
$output = $args['output'] ?? '';
if (! $output || is_file($output) || str_contains(str_replace('\\', '/', $output), '/public/')) {
throw new RuntimeException('New private receipt path required.');
}
$service = app(ApprovedAnchorTerritories::class);
DB::beginTransaction();
try {
DB::statement($apply ? 'SET TRANSACTION ISOLATION LEVEL SERIALIZABLE' : 'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
if ($apply) {
DB::select('select pg_advisory_xact_lock(231026,28)');
}
$plan = $service->plan();
$hash = hash('sha256', json_encode([$expected, $plan], JSON_THROW_ON_ERROR));
$result = ['mode' => $apply ? 'applied' : 'preview', 'database' => $expected, 'port' => $port, 'qa' => $qa, 'manifest_sha256' => ApprovedAnchorTerritories::HASH, 'preview_sha256' => $hash, 'relations' => count($plan['links']), 'projects' => count($plan['projects'])];
if ($apply) {
if (! hash_equals($hash, $args['preview-sha256'] ?? '')) {
throw new RuntimeException('Destination changed after preview.');
}
if (! $qa) {
$directory = $args['backup'] ?? '';
$backup = json_decode(file_get_contents($directory.'/backup-receipt.json'), true, 512, JSON_THROW_ON_ERROR);
$restore = json_decode(file_get_contents($directory.'/restore-receipt.json'), true, 512, JSON_THROW_ON_ERROR);
if (! $restore['restore_verified'] || $backup['target']['database'] !== $expected || (int) $backup['target']['port'] !== $port
|| ! hash_equals($backup['sha256'], $restore['source_dump_sha256']) || ! hash_equals($backup['sha256'], hash_file('sha256', $backup['dump_path']))) {
throw new RuntimeException('Matching backup and isolated restoration required.');
}
}
file_put_contents($output.'.before.json', json_encode($plan, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
if (! $qa) {
chmod($output.'.before.json', 0600);
}
$result += $service->apply($plan);
DB::commit();
} else {
DB::rollBack();
}
file_put_contents($output, json_encode([...$result, 'plan' => $plan], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
if (! $qa) {
chmod($output, 0600);
}
echo json_encode($result, JSON_THROW_ON_ERROR);
} catch (Throwable $error) {
if (DB::transactionLevel()) {
DB::rollBack();
}
throw $error;
}