fix: connect documentary anchors to investment preparation and recover accepted territories
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-23 19:17:15 +02:00
parent cdf8fcfba4
commit 3fd5128c62
21 changed files with 1668 additions and 33 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'],
@@ -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)
+103
View File
@@ -0,0 +1,103 @@
<?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 = 'dc3ae2014da51005d95780e2e4ae87e55e000e66e0765f0671c6e44a958026a0';
/** @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 || ! 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];
}
}
+17 -3
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;
}
@@ -657,6 +670,7 @@ final class InvestorContext
$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'] ?? [],
@@ -0,0 +1,136 @@
{
"status": "implemented_and_demonstrated_in_isolated_QA_not_yet_delivered",
"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": [
"Git integration and installation",
"Exact territorial recovery in homologation",
"Installed delta check"
],
"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"
],
"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"
}
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,17 @@
# Rastreabilidade, utilização e formação
## Projectos âncora — integração executável em QA, 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.
Estado desta passagem: código e reposição demonstrados em QA; **a entrega na homologação e a aplicação das 28 relações no destino ainda não estão declaradas concluídas**. O saldo deste recorte é finito: instalar o incremento pelo Git, aplicar a reposição exacta após backup/restauro autenticado e conferir apenas esse delta. O backup já preparado do destino restaurou 174 tabelas iguais numa base isolada sem rede e verificou 369 ficheiros. A avaliação final de navegação e apresentação pela Tempo continua distinta deste fecho técnico.
## 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).
File diff suppressed because one or more lines are too long
+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
>
>;
@@ -285,6 +285,7 @@ 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.`);
+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;
@@ -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>
@@ -444,6 +444,10 @@ 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>
+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,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/);
});
@@ -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;
}