fix: connect documentary anchors to investment preparation and recover accepted territories
tests / ci (push) Canceled after 0s
tests / ci (push) Canceled after 0s
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.`);
|
||||
|
||||
@@ -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));
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user