resolucoa de conflitos
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-30 14:58:36 +02:00
22 changed files with 1950 additions and 59 deletions
@@ -4,6 +4,9 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Opportunity;
use App\Services\InvestmentCatalogue;
use App\Services\OpportunityMaturity;
use App\Services\SectorOrganization;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -15,6 +18,11 @@ class OpportunityController extends Controller
'q' => ['nullable', 'string', 'max:120'],
'sector' => ['nullable', 'string', 'max:80'],
'province' => ['nullable', 'string', 'max:80'],
'province_id' => ['nullable', 'uuid'],
'district_id' => ['nullable', 'uuid'],
'admin_post_id' => ['nullable', 'uuid'],
'locality_id' => ['nullable', 'uuid'],
'geo_location_id' => ['nullable', 'uuid'],
'maturity_level' => ['nullable', 'integer', 'between:1,6'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
'min_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999'],
@@ -47,22 +55,24 @@ class OpportunityController extends Controller
if (! empty($validated['sector'])) {
$sector = $validated['sector'];
\App\Services\SectorOrganization::constrain($query, $sector);
SectorOrganization::constrain($query, $sector);
}
if (! empty($validated['province'])) {
$query->where('province', $validated['province']);
$territory = array_intersect_key($validated, array_flip(['province', 'province_id', 'district_id', 'admin_post_id', 'locality_id', 'geo_location_id']));
if (array_filter($territory, fn ($value) => $value !== null && $value !== '') !== []) {
// Keep the legacy search's sorting and non-territorial filters while sharing the public territorial contract.
$query->whereIn('opportunities.id', app(InvestmentCatalogue::class)->query($territory)->reorder()->select('opportunities.id'));
}
if (! empty($validated['maturity_level'])) {
\App\Services\OpportunityMaturity::constrainSearch($query, 'historical:'.$validated['maturity_level']);
OpportunityMaturity::constrainSearch($query, 'historical:'.$validated['maturity_level']);
}
$results = $query->orderByDesc('is_featured')
->orderByDesc('published_at')
->paginate($validated['per_page'] ?? 12);
return response()->json($results);
return response()->json($results->withQueryString()->through(fn ($record) => app(InvestmentCatalogue::class)->presentLocation($record)));
}
public function show(string $slug): JsonResponse
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers;
use App\Models\EconomicCluster;
use App\Services\ClusterCompetitiveness;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class ClusterCompetitivenessController extends Controller
{
public function show(EconomicCluster $cluster, ClusterCompetitiveness $service): JsonResponse
{
return response()->json($service->management($cluster->id))->header('Cache-Control', 'private, no-store');
}
public function change(Request $request, EconomicCluster $cluster, string $action, ClusterCompetitiveness $service): JsonResponse
{
$data = $request->validate(['expected_version' => 'required|integer|min:0',
'draft' => $action === 'saved' ? 'required|array:version,clusterId,sector,region,forces' : 'prohibited',
'revision' => $action === 'restored' ? 'required|integer|min:1' : 'prohibited',
'reason' => in_array($action, ['approved', 'withdrawn'], true) ? 'required|string|min:10|max:3000' : 'nullable|string|max:3000']);
return response()->json($service->change($cluster, $request->user()->id, (int) $data['expected_version'], $action, $data))
->header('Cache-Control', 'private, no-store');
}
}
@@ -140,6 +140,7 @@ class InvestmentPortfolioController extends Controller
return Inertia::render('portfolio/show', ['complements' => $management ? [] : app(\App\Services\InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions,
'competitiveness' => $record->cluster_id ? app(\App\Services\ClusterCompetitiveness::class)->publicData($record->cluster_id) : null,
'geography' => app(InvestmentRelations::class)->dossier($record, $management),
'origin' => $origin ? ['id' => $origin->id, 'title' => $origin->title] : null, 'opportunities' => $opportunities, 'chains' => $chains]);
}
@@ -50,6 +50,7 @@ class InvestmentStructureController extends Controller
->whereNull('origins.deleted_at')->whereNull('destinations.deleted_at')->whereNull('value_chain_links.deleted_at')
->orderBy('origins.code')->orderBy('destinations.code')
->get(['value_chain_links.id', 'origins.name as origin', 'destinations.name as destination', 'link_type']) : [],
'competitiveness' => $structure === 'clusters' && $id ? app(\App\Services\ClusterCompetitiveness::class)->management($id) : null,
'viewerId' => $request->user()->id,
'memberships' => $structure === 'clusters' && $id ? DB::table('cluster_projects')
->join('projects', 'projects.id', '=', 'cluster_projects.project_id')
+130
View File
@@ -0,0 +1,130 @@
<?php
namespace App\Services;
use App\Models\EconomicCluster;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
final class ClusterCompetitiveness
{
public const FORCES = ['rivalry', 'entrants', 'substitutes', 'suppliers', 'buyers'];
public const FIELDS = ['evidence', 'source', 'period', 'rationale', 'limitations', 'opportunity'];
private const SOURCE = 'cluster-competitiveness';
private function events(string $cluster): Builder
{
return DB::table('audit_events')->where('source_system', self::SOURCE)
->where('entity_type', 'economic_clusters')->where('entity_id', $cluster);
}
private function decode(?object $event): array
{
return $event ? json_decode(Crypt::decryptString(json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['snapshot']), true, 512, JSON_THROW_ON_ERROR)
: ['version' => 0, 'draft' => null, 'approved' => null];
}
public function current(string $cluster): array
{
return $this->decode($this->events($cluster)->orderByRaw("(after_data->>'version')::integer DESC")->first());
}
public function management(string $cluster): array
{
$state = $this->current($cluster);
$state['history'] = $this->events($cluster)->orderByRaw("(after_data->>'version')::integer DESC")->limit(50)->get()
->map(fn ($event) => ['version' => json_decode($event->after_data, true)['version'],
'action' => $event->action, 'at' => $event->occurred_at, 'actor' => $event->actor_user_id])->all();
return $state;
}
public function validateDraft(array $draft, string $cluster): array
{
$rules = ['version' => 'required|integer|in:1', 'clusterId' => 'required|string|in:'.$cluster,
'sector' => 'present|nullable|string|max:200', 'region' => 'present|nullable|string|max:1000',
'forces' => 'required|array:'.implode(',', self::FORCES)];
foreach (self::FORCES as $force) {
$rules["forces.$force"] = 'required|array:pressure,'.implode(',', self::FIELDS);
$rules["forces.$force.pressure"] = 'present|nullable|in:low,medium,high';
foreach (self::FIELDS as $field) {
$rules["forces.$force.$field"] = 'present|nullable|string|max:5000';
}
}
$clean = Validator::make($draft, $rules)->validate();
$clean['version'] = 1;
$clean['sector'] = trim($clean['sector'] ?? '');
$clean['region'] = trim($clean['region'] ?? '');
foreach (self::FORCES as $force) {
foreach (['pressure', ...self::FIELDS] as $field) {
$clean['forces'][$force][$field] = trim($clean['forces'][$force][$field] ?? '');
}
}
return $clean;
}
private function assertComplete(?array $draft): void
{
$missing = ! $draft || ! $draft['sector'] || ! $draft['region'];
foreach (self::FORCES as $force) {
$row = $draft['forces'][$force] ?? [];
$missing = $missing || ! in_array($row['pressure'] ?? '', ['low', 'medium', 'high'], true);
foreach (self::FIELDS as $field) {
$missing = $missing || trim($row[$field] ?? '') === '';
}
}
if ($missing) {
throw ValidationException::withMessages(['approval' => 'Complete o âmbito e as evidências, fontes, períodos, justificação, limitações e oportunidades das cinco forças antes de aprovar.']);
}
}
/** Reuse the portal's encrypted, append-only audit snapshots; no new schema or mutable publication fields. */
public function change(EconomicCluster $cluster, int $actor, int $expected, string $action, array $input = []): array
{
return DB::transaction(function () use ($cluster, $actor, $expected, $action, $input) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
$before = $this->current($cluster->id);
abort_if($before['version'] !== $expected, 409, 'A análise foi alterada. Conserve os campos actuais e compare a versão do servidor antes de voltar a guardar.');
$state = $before;
if ($action === 'saved') {
$state['draft'] = $this->validateDraft($input['draft'], $cluster->id);
} elseif ($action === 'restored') {
$event = $this->events($cluster->id)->where('after_data->version', $input['revision'])->first();
abort_unless($event, 404);
$state['draft'] = $this->decode($event)['draft'];
} elseif ($action === 'approved') {
$this->assertComplete($state['draft']);
$state['approved'] = ['draft' => $state['draft'], 'at' => now()->toISOString(), 'actor' => $actor,
'version' => $expected + 1, 'evidence' => $input['reason']];
} elseif ($action === 'withdrawn') {
$state['approved'] = null;
} else {
throw new \LogicException('Unsupported competitiveness action');
}
$state['version']++;
DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'economic_clusters',
'entity_id' => $cluster->id, 'external_id' => $cluster->id.':'.$state['version'], 'actor_user_id' => $actor,
'action' => $action, 'data_classification' => 'restricted',
'before_data' => json_encode(['version' => $before['version']], JSON_THROW_ON_ERROR),
'after_data' => json_encode(['version' => $state['version'], 'restored_from' => $input['revision'] ?? null,
'reason' => Crypt::encryptString($input['reason'] ?? ''),
'snapshot' => Crypt::encryptString(json_encode($state, JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR)]);
return $this->management($cluster->id);
});
}
public function publicData(string $cluster): ?array
{
$approved = $this->current($cluster)['approved'];
// Explicit projection: never return editor identity, approval evidence, history or the unapproved draft.
return $approved ? ['draft' => $approved['draft'], 'version' => $approved['version'], 'at' => $approved['at']] : null;
}
}
+80 -25
View File
@@ -5,9 +5,14 @@ namespace App\Services;
use App\Http\Controllers\InvestmentPortfolioController;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use App\Models\Sector;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class InvestmentCatalogue
{
@@ -17,8 +22,8 @@ class InvestmentCatalogue
/** @return array<string,mixed> */
public function filters(Request $request): array
{
$filters = $request->validate(['perspective' => ['nullable', 'in:exists,needs,ideas,opportunities'],'q' => ['nullable', 'string', 'max:120'], 'sector' => ['nullable', 'string', 'max:80'],
'preparation' => ['nullable', \Illuminate\Validation\Rule::in(array_column(OpportunityMaturity::searchOptions(), 'value'))],
$filters = $request->validate(['perspective' => ['nullable', 'in:exists,needs,ideas,opportunities'], 'q' => ['nullable', 'string', 'max:120'], 'sector' => ['nullable', 'string', 'max:80'],
'preparation' => ['nullable', Rule::in(array_column(OpportunityMaturity::searchOptions(), 'value'))],
'opportunity_id' => ['nullable', 'integer', 'min:1'],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
'include_national' => ['nullable', 'boolean'], 'territory_basis' => ['nullable', 'in:all,direct,context'],
@@ -40,14 +45,15 @@ class InvestmentCatalogue
'category' => ['nullable', 'in:'.implode(',', OpportunityManagement::CATEGORIES)],
'publication_state' => ['nullable', 'in:published'],
'per_page' => ['nullable', 'integer', 'between:1,50'], 'page' => ['nullable', 'integer', 'min:1']]);
$filters = app(InvestmentGeography::class)->provinceSearch($filters);
app(InvestmentGeography::class)->selection($filters);
$aliases = ['park' => 'park_id', 'cluster' => 'cluster_id', 'corridor' => 'corridor_id', 'project' => 'project_id'];
$alias = $aliases[$filters['association_type'] ?? ''] ?? null;
if ($alias && ! empty($filters['association_target']) && empty($filters['association_purpose']) && empty($filters['association_state'])) {
if (! empty($filters[$alias]) && $filters[$alias] !== $filters['association_target']) {
throw \Illuminate\Validation\ValidationException::withMessages([$alias => 'Foram indicadas duas selecções diferentes para o mesmo filtro. Escolha apenas uma.']);
throw ValidationException::withMessages([$alias => 'Foram indicadas duas selecções diferentes para o mesmo filtro. Escolha apenas uma.']);
}
if (\Illuminate\Support\Str::isUuid($filters['association_target'])) {
if (Str::isUuid($filters['association_target'])) {
$filters[$alias] = $filters['association_target'];
unset($filters['association_type'], $filters['association_target']);
}
@@ -62,9 +68,13 @@ class InvestmentCatalogue
*/
public function query(array $filters): Builder
{
$filters = app(InvestmentGeography::class)->provinceSearch($filters);
$query = Opportunity::published()->with('sector:id,name,slug');
if (in_array($filters['perspective'] ?? '', ['exists','needs','ideas'], true)) $query->where('workflow_context->promotional_summary->projection->proactive->group', $filters['perspective']);
elseif (($filters['perspective'] ?? '') === 'opportunities') $query->whereNull('workflow_context->promotional_summary->projection->proactive');
if (in_array($filters['perspective'] ?? '', ['exists', 'needs', 'ideas'], true)) {
$query->where('workflow_context->promotional_summary->projection->proactive->group', $filters['perspective']);
} elseif (($filters['perspective'] ?? '') === 'opportunities') {
$query->whereNull('workflow_context->promotional_summary->projection->proactive');
}
$associated = $this->publicAssociatedIds($filters);
if (($filters['territory_basis'] ?? 'all') === 'direct') {
$query->where(fn ($q) => $q->whereNotNull('opportunities.primary_territory_id')->orWhereExists(fn ($l) => $l->selectRaw('1')->from('opportunity_locations')->whereColumn('opportunity_id', 'opportunities.id')));
@@ -86,9 +96,14 @@ class InvestmentCatalogue
$territory->whereIn('opportunities.primary_territory_id', $ids)
->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('opportunity_locations as territory_link')
->whereColumn('territory_link.opportunity_id', 'opportunities.id')->whereIn('territory_link.geo_location_id', $ids));
if (count($path) === 1) {
$this->declaredProvince($territory, $path[0]->name);
}
if (($filters['territory_basis'] ?? 'all') !== 'direct') {
app(InvestmentRelations::class)->opportunityTerritories($territory, $ids);
if (count($path) === 1) $this->approvedCorridorProvince($territory, $path[0]->id);
if (count($path) === 1) {
$this->approvedCorridorProvince($territory, $path[0]->id);
}
}
if ($includeNational) {
$territory->orWhere('opportunities.geography_scope', 'national');
@@ -163,6 +178,10 @@ class InvestmentCatalogue
->join('geo_locations as target', 'target.id', '=', 'link.geo_location_id')
->whereColumn('link.opportunity_id', 'opportunities.id')->whereIn('link.geo_location_id', $locationIds ?? [])
->whereNull('target.deleted_at')->where('target.data_classification', 'public'));
$province = app(InvestmentGeography::class)->publicReferences()->where('id', $filters['geo_location_id'])->where('location_type', 'province')->first(['name']);
if ($province) {
$this->declaredProvince($matches, $province->name);
}
if (($filters['territory_basis'] ?? 'all') !== 'direct') {
app(InvestmentRelations::class)->opportunityTerritories($matches, $locationIds ?? []);
if (app(InvestmentGeography::class)->publicReferences()->where('id', $filters['geo_location_id'])->where('location_type', 'province')->exists()) {
@@ -206,7 +225,9 @@ class InvestmentCatalogue
if (! empty($filters['before'])) {
$query->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_profiles')->whereColumn('opportunity_id', 'opportunities.id')->where('target_date', '<=', $filters['before']));
}
if (! empty($filters['category'])) $query->where('opportunity_category', $filters['category']);
if (! empty($filters['category'])) {
$query->where('opportunity_category', $filters['category']);
}
app(OpportunityClassification::class)->constrain($query, $filters);
$sort = $filters['sort'] ?? 'recent';
@@ -218,14 +239,28 @@ class InvestmentCatalogue
};
}
private function declaredProvince($query, string $name): void
{
// Read the published province declaration only when no more precise territory contradicts it.
// This does not assign any canonical ID, district, post, locality or coordinates to the record.
$query->orWhere(fn ($declared) => $declared->whereNull('opportunities.primary_territory_id')
->whereNotExists(fn ($links) => $links->selectRaw('1')->from('opportunity_locations')->whereColumn('opportunity_id', 'opportunities.id'))
->where('opportunities.geography_scope', '!=', 'national')
->whereRaw('lower(btrim(opportunities.province)) = ?', [mb_strtolower(trim($name))]));
}
private function approvedCorridorProvince($query, string $provinceId): void
{
$code = app(InvestmentGeography::class)->publicReferences()->where('id', $provinceId)->value('source_code');
$names = [];
foreach (config('investment_corridors', []) as $entry) {
if (in_array($code, $entry['provinces'], true)) $names[] = $entry['name'];
if (in_array($code, $entry['provinces'], true)) {
$names[] = $entry['name'];
}
}
if (! $names) {
return;
}
if (!$names) return;
$query->orWhere(fn ($context) => $context->whereNull('opportunities.primary_territory_id')
->whereNotExists(fn ($link) => $link->selectRaw('1')->from('opportunity_locations')->whereColumn('opportunity_id', 'opportunities.id'))
->where('workflow_context->corridor_agribusiness->source_sha256', CorridorAgribusinessOpportunities::SOURCE_HASH)
@@ -240,13 +275,19 @@ class InvestmentCatalogue
$ids = array_filter([$record->primary_territory_id, ...$record->additionalTerritories()->pluck('geo_locations.id')->all()]);
foreach (array_unique($ids) as $id) {
$path = $geo->path($id);
if ($path) $paths[] = implode(' / ', array_column($path, 'name'));
if ($path) {
$paths[] = implode(' / ', array_column($path, 'name'));
}
}
if ($paths) $label = implode('; ', array_unique($paths));
elseif ($provinces = app(InvestmentCorridorContext::class)->catalogueProvinces($record)) {
if ($paths) {
$label = implode('; ', array_unique($paths));
} elseif ($provinces = app(InvestmentCorridorContext::class)->catalogueProvinces($record)) {
$label = implode(' / ', array_column($provinces, 'name')).' — contexto provincial do corredor; distrito e posto administrativo não indicados';
} else $label = implode(' / ', array_filter([$record->province, $record->district])) ?: 'Localização não indicada';
} else {
$label = implode(' / ', array_filter([$record->province, $record->district])) ?: 'Localização não indicada';
}
$record->setAttribute('territorial_summary', $label);
return $record;
}
@@ -260,9 +301,9 @@ class InvestmentCatalogue
'authorities' => DB::table('authorities')->where('status', 'published')->get(['id', 'label_pt_mz']),
'categories' => OpportunityManagement::CATEGORIES,
'options' => DB::table('investment_reference_options')->when(! $management, fn ($q) => $q->where('active', true))->orderBy('sort_order')->orderBy('label')->get(),
'sectors' => \App\Models\Sector::choices()->whereNotNull('published_at')->orderBy('name')->get(['id', 'name', 'slug']),
'maturityCatalogue' => ['version'=>OpportunityMaturity::VERSION, 'stages'=>OpportunityMaturity::STAGES,
'legacyVersion'=>OpportunityMaturity::LEGACY, 'legacyLabels'=>OpportunityMaturity::LEGACY_LABELS]];
'sectors' => Sector::choices()->whereNotNull('published_at')->orderBy('name')->get(['id', 'name', 'slug']),
'maturityCatalogue' => ['version' => OpportunityMaturity::VERSION, 'stages' => OpportunityMaturity::STAGES,
'legacyVersion' => OpportunityMaturity::LEGACY, 'legacyLabels' => OpportunityMaturity::LEGACY_LABELS]];
foreach (self::RELATIONS as $relation => [$foreign, $table]) {
$query = DB::table($table)->whereNull('deleted_at');
if (! $management) {
@@ -295,7 +336,7 @@ class InvestmentCatalogue
$zones = app(InvestmentZones::class)->visible();
if (! $management && $territoryFilters !== []) {
// Opportunity words/modalities are not names/categories of zones.
$zones = app(InvestmentZones::class)->search(\Illuminate\Support\Arr::only($territoryFilters,
$zones = app(InvestmentZones::class)->search(Arr::only($territoryFilters,
['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'geo_location_id']));
}
$result['zones'] = $zones->orderBy('name')->get(['id', 'name', 'category']);
@@ -318,24 +359,35 @@ class InvestmentCatalogue
'cluster_id' => ['cluster'], 'corridor_id' => ['corridor'], 'project_id' => ['project']];
$selected = array_filter(array_intersect_key($filters, $kinds));
foreach (['zee_id' => 'zee', 'zfi_id' => 'zfi', 'park_id' => 'industrial_park'] as $key => $category) {
if (isset($selected[$key]) && ! app(InvestmentZones::class)->visible()->whereKey($selected[$key])->where('category', $category)->exists()) unset($selected[$key]);
if (isset($selected[$key]) && ! app(InvestmentZones::class)->visible()->whereKey($selected[$key])->where('category', $category)->exists()) {
unset($selected[$key]);
}
}
if ($selected === []) {
return [];
}
if ($selected === []) return [];
$ids = [];
Opportunity::published()->select('opportunities.*')->chunkById(200, function ($records) use ($selected, $kinds, &$ids) {
foreach ($records as $record) {
foreach (app(OpportunityClassification::class)->publicRows($record) as $row) {
if (in_array($row['purpose'], ['proximity', 'administrative_boundary'], true) || $row['state'] === 'rejected') continue;
if (in_array($row['purpose'], ['proximity', 'administrative_boundary'], true) || $row['state'] === 'rejected') {
continue;
}
foreach ($selected as $field => $target) {
if ($row['target_id'] === $target && in_array($row['target_type'], $kinds[$field], true)) $ids[$field][] = $record->id;
if ($row['target_id'] === $target && in_array($row['target_type'], $kinds[$field], true)) {
$ids[$field][] = $record->id;
}
}
}
}
});
if (!empty($selected['corridor_id'])) {
if (! empty($selected['corridor_id'])) {
$dossier = InvestmentPortfolioController::visible()->where('corridor_id', $selected['corridor_id'])->first();
if ($dossier) $ids['corridor_id'] = array_values(array_unique([...($ids['corridor_id'] ?? []), ...app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($dossier)]));
if ($dossier) {
$ids['corridor_id'] = array_values(array_unique([...($ids['corridor_id'] ?? []), ...app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($dossier)]));
}
}
return $ids;
}
@@ -359,9 +411,12 @@ class InvestmentCatalogue
$public = ['profile' => null, 'facets' => collect(), 'zones' => collect(), 'classification' => [], 'source_evidence' => [],
'primary_territory_path' => app(InvestmentGeography::class)->path($opportunity->primary_territory_id, true),
'opportunity_locations' => app(InvestmentRelations::class)->opportunityLocations($id)];
foreach (self::RELATIONS as $relation => $_) $public[$relation] = collect();
foreach (self::RELATIONS as $relation => $_) {
$public[$relation] = collect();
}
$public['locations'] = DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id')
->where('l.opportunity_id', $id)->where('g.data_classification', 'public')->whereNull('g.deleted_at')->get(['g.id', 'g.name']);
return $public;
}
$result = ['profile' => DB::table('opportunity_profiles')->where('opportunity_id', $id)->first(),
+15
View File
@@ -12,6 +12,21 @@ final class InvestmentGeography
{
public const FIELDS = ['province_id' => 'province', 'district_id' => 'district', 'admin_post_id' => 'admin_post', 'locality_id' => 'locality'];
/** Resolve an old province-name search only when the public register has exactly one match. */
public function provinceSearch(array $filters): array
{
if (empty($filters['province_id']) && is_string($filters['province'] ?? null) && trim($filters['province']) !== '') {
$matches = $this->publicReferences()->where('location_type', 'province')
->whereRaw('lower(btrim(name)) = ?', [mb_strtolower(trim($filters['province']))])->limit(2)->get(['id']);
if ($matches->count() === 1) {
$filters['province_id'] = $matches[0]->id;
unset($filters['province']);
}
}
return $filters;
}
/**
* Restrict published dossier searches using direct territories and public, validated structure links.
*
@@ -1826,3 +1826,65 @@ Estado actual: `investor-usability-git-delivery-20261028.json`. Os relatórios Q
Pedido expresso da Tempo: preencher formulário de IVA ou IRPC, em vez de abrir legislação. Base preservada: entrega instalada 789fffd0, Modelo A e aquisições/apuramento já aceites, L6 M20/M22 instalado em 5aaf1a56 e incrementos de L8. Nenhuma perda confirmada neste recorte; não se apagam as aceitações nem se repetem provas fiscais anteriores. Fontes reutilizadas do inventário de 20 exemplares acessíveis em outputs/irpc-l6-values-forms/accessible-source-inventory.json da QA anterior, incluindo temporários identificados; inventário do corpus e originais preservados. M22: três páginas conferidas visualmente nesta revisão, SHA256 4b61bd83d1fbcfd3b7b5c5523cea5923f7d0f65dc4c004eff58e5db38f763c88. Modelo A: correspondência e divergências aceites em modelo-a-reconciliacao-20261021.md; sem nova validação de proveniência.
Incremento em preparação QA: entrada directa em Documentos e Formulários, preenchimento manual dos campos correspondidos de IVA e subconjunto M22, identificação/período, revisão, impressão e ficheiro privado para recuperação mediante confirmação. Escolha técnica: sem persistência no servidor nem ligação automática ao dossier neste incremento; não altera schema, dados, permissões ou cálculos. Ficheiro não publicado. IRPC ajustamentos completos, outros regimes, anexos, assinatura e submissão à AT fora da cobertura; L8 permanece parcial. Ainda sem instalação deste incremento. Próximas provas apenas do delta: fidelidade da recuperação, rejeição de ficheiros incompatíveis sem perda, formatos/valores desconhecidos, acesso público e compilação.
## 29/10/2026 — ACT-20261029-01, inventário antes da verificação do incremento
Continuação sequencial solicitada pela Tempo. Correspondência: ACT-20261029-01 reutiliza D-02 §8, clusters/cadeias e a orientação metodológica documentada em 23/10. Concluído previamente e preservado: gestão de clusters, etapas, ligações, lacunas e relações auditadas; disponibilidade instalada de carteira/âncoras e formulários fiscais conforme recibos de 28/10. Nenhuma perda nova confirmada nesta inspecção de código; não foi feita nova comparação de dados ou alegada prova do servidor. A pasta principal está em fde644af, enquanto o último recibo identifica 332c16eb instalado; reconciliar o delta sobre essa origem antes de instalar, sem substituir o destino pela pasta principal.
Incremento em preparação: diagnóstico qualitativo das cinco forças na edição do cluster, sector/região editáveis, fonte/período/evidência/justificação/limites/oportunidade por força; indeterminação explícita, ficheiro privado transferível e recuperação com confirmação. Sem alteração de schema, dados, permissões, publicações ou motores fiscais. Persistência no servidor, ligação territorial canónica, dados sectoriais fundamentados e consulta pública permanecem por implementar; ACT-01 não está concluída. Verificação dirigida e instalação ainda pendentes neste registo inicial. Restantes ACT-02 a 16 mantêm o estado anterior e a sequência, sem trabalho SGF nesta passagem.
### ACT-20261029-01 — candidato sequencial preparado, 29/10/2026
Primeiro incremento implementado, **parcial e ainda não instalado**: gestão → clusters → editar → Competitividade. As cinco forças conservam fonte, período, evidência, apreciação, limites e oportunidade; sector e região explícitos, sem inferências automáticas. Transferência de rascunhos JSON e recuperação restrita ao mesmo cluster, com confirmação antes de substituir. Quatro testes frontend dirigidos, TypeScript e compilação QA passaram tanto na pasta de trabalho como no candidato baseado em 332c16eb. Guarda C132 local passou; não foram efectuados testes nem escritas na base activa. A edição existente recebe apenas a inclusão do componente; contratos e permissões existentes preservados.
Origem reconciliada: main remoto e revisão pública conferidos em 332c16eb em 29/10. Candidato em C:/laragon/www/portal-investidor-competitiveness-qa-20261029, ramo codex/competitiveness-sequential-20261029. Consulta do estado remoto não equivale a inventário novo da base. Sessão SSH anterior encerrada; autenticação por chave recusada. Instalação requer autenticação SSH, backup fresco, restauro isolado, guarda de versão, pipeline existente e conferência posterior. Não instalar a pasta principal sobre o destino. A sequência solicitada mantém ACT-02 a 16 por iniciar após a instalação e o saldo de ACT-01; persistência no servidor, cobertura factual e consulta pública continuam pendentes. Sem declaração de conferência visual autenticada.
### 29/10/2026 — ACT-20261029-01, primeiro incremento instalado: 23ecccfa
Estado corrente de ACT-01: **parcial — primeiro incremento instalado**, substituindo apenas o estado planeado/preparado deste recorte; ACT-02 a 16 não foram implementadas nesta entrega. Instalado por avanço normal de main e pipeline existente em https://teste-investidor.tempo.co.mz/. Entrada: Gestão → Clusters → Editar → Competitividade — cinco forças de Porter. Preparação qualitativa por sector/região, evidências/fontes/períodos/limites e oportunidades, transferência JSON e recuperação confirmada do mesmo cluster. Sem gravação no servidor nem publicação da análise. Persistência, cobertura factual, território canónico e consulta pública continuam pendentes; esta instalação não encerra o critério amplo ACT-01.
Provas: backup fresco, restauro isolado sem rede das 176 tabelas; pipeline com lifecycle de schema (3.106 asserções), 59 testes Unit/320 asserções e 1.180 testes frontend sem falhas, tipos e compilação; quatro ficheiros do incremento conferidos por SHA-256 no instalado; C132 no destino passou. Comparação após instalação: todas as 176 tabelas de domínio preservadas, diferenças operacionais apenas em cache/sessões; 369 ficheiros/configuração idênticos. Acesso anónimo ao catálogo privado recusado HTTP401. Zero migrações/importações, nenhuma nova divulgação nem mudança de permissões; versão anterior 332c16eb preservada. Conferência visual autenticada permanece pendente. Recibo: `docs/investmoz/unification/competitiveness-sequential-qa-20261029.json`, em docs/investmoz/unification. As provas aceites anteriores foram reutilizadas; não foram reabertas por esta entrega.
### 29/10/2026 — ACT-01, persistência e aprovação: execução em curso
Ordem confirmada pela Tempo: 1) gravar/validar/recuperar na gestão com permissões e histórico, 2) consultar publicamente apenas conteúdo aprovado, 3) restantes alterações. A instalação da interface 23ecccfa e os 1.180 testes anteriores são provas desse incremento, não prova de persistência. Sem perda nova confirmada; preservar a versão pública anterior e os recibos de backup/restauro. O problema dos filtros provinciais permanece **separado e aberto**, sem declaração de correcção por estes trabalhos.
Escolha técnica: reutilizar snapshots cifrados nos eventos append-only de auditoria, já usados pelo portal; bloqueio transaccional do cluster, controlo de versão e histórico imutável. Sem alteração de schema/permissões. Guardar rascunho e recuperar histórico não publica; aprovação explícita fixa a versão consultável, edições seguintes preservam a aprovação anterior até nova decisão. Rascunhos parciais são guardáveis; aprovação exige os fundamentos das cinco forças. Recuperação/conflito não deve sobrescrever edições inadvertidamente. Acesso público continua limitado às fichas publicadas. Implementação e provas específicas de persistência/consulta ainda em preparação; restantes ACT mantêm o estado anterior.
### ACT-01 — prova específica antes da instalação, 29/10/2026
Sete testes Feature/93 asserções passaram na base restaurada competitiveness_persistence_20261029_test, porta 55441: gravação e leitura HTTP entre pedidos, retorno pela edição da gestão, histórico cifrado, validação de campos/cluster/forças, normalização do formato recuperável, conflitos sem sobrescrita, aprovação fixa, rascunhos posteriores sem divulgação, recuperação histórica como nova versão, retirada e exclusão do conteúdo não aprovado na consulta pública. Utilizadores sem permissão e pedidos anónimos foram recusados na gestão. O teste público percorreu a ficha publicada e confirmou 404 da ficha não publicada. Zero escritas na base activa. Esta prova não afirma conferência visual autenticada nem correcção dos filtros provinciais, que permanecem abertos. Recibo: docs/investmoz/unification/competitiveness-persistence-qa-20261029.json. Compilação QA, tipos e quatro testes frontend dirigidos passaram; instalação ainda pendente.
### 29/10/2026 — ACT-01, gravação/recuperação e consulta aprovada instaladas: b81f477b
Instalado pelo avanço normal de main e pipeline existente, preservando 23ecccfa para retorno. Gestão → Clusters → Editar → Competitividade agora recupera o rascunho do servidor e permite gravar, consultar a versão actual, confirmar recuperação e recuperar uma versão histórica como nova gravação. Versões cifradas e imutáveis na auditoria existente, com bloqueio transaccional do cluster e controlo de versão; conflitos conservam a versão guardada e os campos do editor. Reutilizada a permissão existente de gestão, sem novos papéis ou aprovadores. Rascunhos incompletos podem ser guardados; a aprovação exige âmbito e fundamentos das cinco forças.
**Percurso da gestão:** preencher → Guardar rascunho no servidor → voltar à edição para recuperar → consultar histórico quando necessário. Para comparar com outra gravação, usar Consultar versão actual do servidor e confirmar antes de substituir os campos; transferir primeiro edições que se queira conservar. Recuperar o histórico cria nova versão e mantém o histórico e a aprovação pública existente. A aprovação ou retirada é expressa, com evidência/motivo registado. A ficha pública de um cluster publicado mostra exclusivamente a análise aprovada; novas gravações privadas não alteram essa versão. Uma ficha não publicada continua indisponível ao público. Não houve aprovação nem divulgação automática de análises.
**Prova específica:** sete testes Feature/93 asserções na base restaurada e isolada competitiveness_persistence_20261029_test, porta55441. Abrange gravação/recuperação HTTP, retorno na gestão, validação e formato, histórico cifrado, conflito, acesso recusado, aprovação fixa, rascunho posterior privado, recuperação, retirada e consulta pública/404 de ficha não publicada. Os 1.180 testes frontend não são apresentados como prova substituta de persistência. Pipeline: tipos, compilação, lifecycle de schema1/3106, Unit59/320 e frontend1180 sem falhas. Dez ficheiros de código/testes do instalado conferidos por SHA-256 com os da QA; C132 no destino passou.
**Preservação e limites:** backup fresco/restauro isolado176; comparação posterior de176 tabelas sem alterações de domínio, apenas diferenças operacionais de cache/sessões; 369 ficheiros/configuração idênticos. Ligação real teste_investidor_app conferida em leitura, com privilégios existentes de SELECT/INSERT em auditoria e bloqueio do cluster; endpoint de gestão anónimo HTTP401. Nenhuma escrita sintética na base activa, migração, importação, mudança de permissões ou nova publicação. A conferência visual autenticada no instalado permanece pendente; não foi simulada uma decisão real de conteúdo no activo. ACT-01 tem agora persistência e consulta aprovada implementadas/instaladas, mantendo parcial a cobertura factual por sector/região. Recibo: docs/investmoz/unification/competitiveness-persistence-qa-20261029.json.
**Filtros provinciais: separado e aberto.** Esta entrega não alterou a lógica dos filtros nem demonstra a correcção relatada de Agronegócio/Maputo Província no portal. Preservam-se as provas e os estados históricos; não encerrar V2-07/V2-10 ou ACT-02 por causa da persistência de Competitividade. Restantes alterações permanecem na sequência posterior.
Origem de continuidade: C:/laragon/www/portal-investidor-competitiveness-qa-20261029, ramo codex/competitiveness-persistence-20261029, instalado b81f477b. A pasta principal anterior não deve substituir esta versão sem reconciliação do delta.
### 29/10/2026 — V2-07/V2-10 / ACT-02, diagnóstico dos filtros provinciais em curso
Separação confirmada pela Tempo: a equipa executará e registará a conferência visual autenticada de Competitividade (continua pendente); o Codex prossegue no código/API dos filtros. Provas aceites preservadas, sem repetição das suites fiscais ou de Competitividade. Nenhuma perda de dados confirmada nesta passagem.
Reprodução pública em b81f477b: Agronegócio/Maputo Província por province_id na API /api/v1/investment/search devolve5 oportunidades (IDs60–64). O mesmo nome em province devolve0, tanto nessa API como em /api/v1/opportunities/search. A API antiga ignora province_id e devolve34, incluindo outras províncias. São lacunas de normalização/compatibilidade entre entradas, não prova de eliminação nem falha demonstrada da expansão hierárquica já existente. Corrigir apenas os deltas: resolver nomes provinciais públicos inequívocos para a mesma hierarquia e aplicar o filtro territorial na API antiga, preservando outros filtros, versões públicas, localização real e dados. Demonstrar todos os níveis inferiores por pedidos HTTP em QA isolada; província apenas contextual ou declarada não deve ganhar distrito. O problema permanece aberto até às provas específicas e à conferência da API instalada; não encerrar a conferência visual da equipa por testes técnicos.
### 29/10/2026 — duas frentes e diagnóstico local Edge
A equipa executa directamente e regista a conferência visual autenticada de Competitividade (editar → guardar → reabrir → conferir versão aprovada); estado pendente até execução. O Codex prossegue com código/API dos filtros provinciais, independentemente do navegador. Provas aceites preservadas.
Ligação identificada: com.openai.codexextension, manifesto stdio válido, extensão Edge instalada e activa no perfil detectado, host executado como filho do Edge; named pipes do browser-use presentes e servidor registado nos logs do cliente. Inspeccionados os listeners/processos sem libertar portas. Não foi identificado conflito TCP nem um serviço de extensão parado. Não foi encontrado erro de permissões no recorte de logs consultado, sem excluir outros pontos. A descoberta desta sessão continua a indicar access host indisponível; o runtime exige caminho do pipe e identidade verificadora, ausentes na shell. Falha de passagem de contexto é uma hipótese delimitada, não conclusão de porta ocupada. O registo de erros do service worker não ficou acessível pela ligação de navegador indisponível. A recuperação suportada, limitada à abertura do perfil Edge detectado, não restabeleceu a leitura do URL. Não foram terminados processos, reinstalados hosts nem alteradas permissões/políticas. InvesteMoz e SGF continuam em execução. Relatório local: outputs/edge-bridge-diagnostic-20261029/report.json. Não declarar recuperada a ligação nem concluída a conferência visual.
Correcção provincial preparada no ramo codex/province-search-20261029; validação QA/instalação pendentes. A sessão SSH anterior expirou. O lançamento automático de nova consola foi recusado pela revisão automática com indicação genérica blocked by policy, sem razão adicional; foi pedido arranque/autenticação manual no helper existente. Nenhuma escrita no activo ou novo ensaio remoto ocorreu por essa tentativa.
### 29/10/2026 — filtros provinciais: correcção candidata, pendente de QA
Reproduzido no portal instalado b81f477b: /api/v1/investment/search por province_id de Maputo Província devolve5; por province textual devolve0 nas duas APIs; a API antiga com province_id ignora o parâmetro e devolve34 de Agronegócio. Preparada correcção no ramo codex/province-search-20261029, preservando a expansão de descendentes existente e aplicando a mesma selecção territorial à API antiga. Nomes provinciais só são resolvidos quando a correspondência pública exacta é inequívoca. Declarações provinciais antigas são consideradas sem atribuir distrito, posto, localidade ou coordenadas, e não prevalecem sobre territórios explícitos contraditórios. Nenhum dado cadastral é actualizado por esta correcção.
Quatro testes HTTP preparados para província/distrito/posto/localidade e localizações adicionais, onze províncias e exclusão de vizinhas/privados/eliminados, preservação das localizações e consistência de catálogo/mapa/exportação. Sintaxe PHP e diff conferidos; testes ainda não executados e candidato ainda não instalado. Recibo: docs/investmoz/unification/province-search-qa-20261029.json. A sessão SSH expirou e a nova consola automática foi recusada pela revisão de aprovação com blocked by policy (sem razão específica adicional); pedida autenticação manual no helper outputs/province-search-20261029/ssh-session.py. Não substituir ausência de ensaio por declaração de correcção concluída.
A conferência visual de Competitividade fica a cargo da equipa e permanece pendente até execução/registo; provas técnicas aceites preservadas. Ligação Edge diagnosticada separadamente: extensão/manifesto/host activos, stdio/named pipes, leitura do URL não restabelecida pela recuperação suportada do perfil. Nenhum processo terminado nem porta libertada/reconfigurada. InvesteMoz e SGF preservados. Relatório local: outputs/edge-bridge-diagnostic-20261029/report.json. Os filtros mantêm-se abertos até QA e confirmação da API instalada.
### 29/10/2026 — filtros provinciais, prova HTTP específica em QA
Quatro testes Feature/364 asserções passaram na base restaurada province_search_20261029_test, porta55441, candidato e0987afb. Seleccionar só a província, por nome público inequívoco ou ID, inclui registos provinciais, distritais, de posto e localidade e localizações adicionais, nas duas APIs; confirmada cobertura das11 províncias e exclusão de vizinhas. Privados/eliminados não entram; distrito seleccionado não recebe oportunidades apenas provinciais. Declaração provincial antiga não supera localização explícita noutro território. Maputo Província por nome/ID, página do catálogo, mapa e exportação concordam nas5 oportunidades do contexto aprovado; sem coordenadas ou distrito inventados. Campos e relações guardados permanecem inalterados. Zero escritas no activo; nenhuma migração nem alteração de permissões. O conjunto inclui provas novas da hierarquia HTTP, sem reabrir as provas aceites de Competitividade/fiscalidade.
Recibo: docs/investmoz/unification/province-search-qa-20261029.json. Instalação e confirmação da API no portal continuam pendentes; não declarar encerrado o problema por QA isolada. A conferência visual autenticada de Competitividade permanece separada, pendente de execução e registo pela equipa. A sessão SSH foi reaberta a pedido expresso da Tempo e ficou autenticada; a recusa anterior da abertura automática não constitui bloqueio actual.
@@ -0,0 +1,177 @@
{
"checks": {
"feature_tests": 7,
"isolated_build": "passed",
"feature_assertions": 93,
"frontend_tests": 4,
"types": "passed"
},
"tested_candidate": "568a488ae76c631714547614f52ca6be7a121e2d",
"schema_changes": 0,
"active_writes": 0,
"source_sha256": {
"tests/Feature/ClusterCompetitivenessTest.php": "3c4b27fbc2c00210bd6be78057d88d03f54676c82907fafcbe57843ea7d726cb",
"app/Http/Controllers/InvestmentPortfolioController.php": "0793d06bf94b679b301f4aedbd54d73184ed7b27d3100036dfc81f4621c101d6",
"resources/js/pages/portfolio/show.tsx": "6562798defc1546466b1ed3c66e0d51b1c6c1b08f03c8d5acc915877792ce7da",
"routes/web.php": "76501452ad4ce187ce234127ef160a5a458f629d583d9ccb3c67c1c9d4a1a561",
"resources/js/components/cluster-competitiveness-public.tsx": "85ab2f59de4c63faf6ea699855bbcf0c9efaea0ff9c39421d5640286e9df5910",
"app/Http/Controllers/ClusterCompetitivenessController.php": "12ab13860da1acda02dd77f38d0ddd0d1b8fca5856f7fa8823e9e6193143368e",
"app/Services/ClusterCompetitiveness.php": "24cae918c14a84470b7fc2e1485eacd9c4e2306a54928ca9de6a56576f5227df",
"resources/js/pages/structures/editor.tsx": "c6d50445de3c1f18b266afe5898cd31198fc5946606770ac74a5f01e6e67b712",
"resources/js/pages/structures/competitiveness.tsx": "8c4253cef8450b47245a918712bb81fe0ff2a333f54726a38239264a398f4fde",
"app/Http/Controllers/InvestmentStructureController.php": "b0d11991240fa5f6fa0d56a4c66b63f68665d0687b3772b4f8ca632460499930"
},
"baseline": "23ecccfaad1053f55bf0591fd242cb5fa3406c38",
"provincial_filters": "separate_and_open_not_changed_or_verified",
"remaining": [
"authenticated visual conference of the installed management workflow",
"factual sector and regional analysis and explicit content approvals",
"provincial filter issue remains separate and open",
"remaining planned ACT activities"
],
"status": "installed_specific_persistence_QA_and_preservation_verified_visual_pending",
"proof": {
"candidate": "568a488ae76c631714547614f52ca6be7a121e2d",
"status": "passed",
"database": "competitiveness_persistence_20261029_test",
"port": 55441,
"snapshot_restored": true,
"active_database_writes": 0,
"schema_changes": 0,
"log": "/var/lib/investmoz-deploy/competitiveness-persistence-qa-20261029/qa-568a488ae76c631714547614f52ca6be7a121e2d.log",
"summary": "PHPUnit 12.5.34 by Sebastian Bergmann and contributors.\n\nRuntime: PHP 8.4.27\nConfiguration: /var/lib/investmoz-deploy/competitiveness-persistence-qa-20261029/568a488ae76c631714547614f52ca6be7a121e2d-1793273608890561258/phpunit.xml\n\n....... 7 / 7 (100%)\n\nTime: 00:04.973, Memory: 58.50 MB\n\n\u001b[30;42mOK (7 tests, 93 assertions)\u001b[0m\n"
},
"installed_candidate": "b81f477bf2385227ed3099868e80b1e3c7bcb46b",
"backup": {
"stage": "/var/backups/investmoz-competitiveness-persistence-20261029/backup",
"created_at": "2026-10-29T11:32:49Z",
"target": {
"database": "teste_investidor",
"port": 5432
},
"tables": 176,
"files_verified": 369,
"dump_sha256": "e92123a3b986781de20367a04462ed7b92958c47f232326da18506a79a43717c",
"files_sha256": "bb615875347f7b27da593f148af973fc825555524a261f8f68c50cd4ce26962c",
"restore_verified": false
},
"restore": {
"restore_verified": true,
"tables_verified": 176,
"container_network": "none",
"database": "competitiveness_persistence_restore_20261029_test",
"port": 55441,
"source_dump_sha256": "e92123a3b986781de20367a04462ed7b92958c47f232326da18506a79a43717c",
"active_database_writes": 0
},
"code": {
"candidate": "b81f477bf2385227ed3099868e80b1e3c7bcb46b",
"source_files_verified": 10,
"diagnostics_C132": {
"result": "passed",
"case": "C132",
"source_explicit_constant_OFF": true,
"effective_configuration_OFF": true,
"configuration_cached": false,
"database_connections_used": 0,
"active_writes": 0
},
"active_writes": 0
},
"runtime": {
"identity": {
"database": "teste_investidor",
"role": "teste_investidor_app",
"port": 5432,
"audit_read": true,
"audit_append": true,
"cluster_lock": true
},
"management_read_from_database": true,
"server_version": 0,
"history_items": 0,
"approved_projection_available": false,
"actor_impersonation": false,
"active_domain_writes": 0,
"candidate": "b81f477bf2385227ed3099868e80b1e3c7bcb46b",
"anonymous_management_status": 401,
"persistence_actions_compiled": true,
"specific_write_recovery_and_public_projection_QA": "7 tests / 93 assertions; no synthetic writes on active database",
"provincial_filters": "separate and open; not changed or confirmed here",
"authenticated_visual_conference": "pending"
},
"preservation": {
"navigation_asset_and_shared_props_verified": true,
"at": "2026-10-29T11:42:15.258836+00:00",
"served_commit": "b81f477bf2385227ed3099868e80b1e3c7bcb46b",
"backup_dump_sha256": "e92123a3b986781de20367a04462ed7b92958c47f232326da18506a79a43717c",
"prior_public_tables_checked": 176,
"prior_domain_rows_preserved": true,
"new_migration_registry_rows": 0,
"operational_delta": {
"cache": {
"before": {
"rows": 119,
"checksum": "5ddea5ecf35f8dc04d275b189821b59a"
},
"after": {
"rows": 119,
"checksum": "8336a458d425e14c66b20c3da9aa01de"
}
},
"sessions": {
"before": {
"rows": 10,
"checksum": "aa65128c0bafc9eab8419174754327d7"
},
"after": {
"rows": 12,
"checksum": "41f44e385b8a31b0af1f22e1a29680ce"
}
}
},
"persistent_files_and_configuration_identical": 369,
"private_catalogue": {
"observations": 361464,
"distinct_administrative_observations": 214299,
"preserved_repetitions": 147165,
"unmapped_territory": 205608
},
"preservation_identity": "postgres, matching backup reader; no actor impersonation",
"anonymous_query_status": 401,
"public_revision_confirmed": true,
"actual_team_UI_journey": "pending authenticated browser conference;accepted QA is not repeated",
"unique_company_count": null,
"publication": false,
"national_coverage": "partial",
"investment_conclusion": "hypothesis,not proved demand or viability",
"L8": "unchanged,partial"
},
"pipeline": {
"unit_tests": 59,
"summaries": [
"\u001b[30;42mOK (1 test, 3106 assertions)\u001b[0m",
"✓ built in 33.96s",
"\u001b[30;42mOK (59 tests, 320 assertions)\u001b[0m",
"# tests 1180",
"# pass 1180",
"# fail 0"
],
"schema_assertions": 3106,
"frontend_tests": 1180,
"result": {
"status": "installed",
"commit": "b81f477bf2385227ed3099868e80b1e3c7bcb46b",
"release": "/var/www/investmoz-homologacao/releases/b81f477bf2385227ed3099868e80b1e3c7bcb46b-5eed771e",
"previous_release": "/var/www/investmoz-homologacao/releases/23ecccfaad1053f55bf0591fd242cb5fa3406c38-0e0b8a65",
"database_migrations": 0,
"content_imports": 0,
"requested_commits": [
"b81f477bf2385227ed3099868e80b1e3c7bcb46b"
],
"finished_at": "2026-10-29T11:39:35.927983+00:00"
},
"frontend_failures": 0,
"unit_assertions": 320
}
}
@@ -0,0 +1,147 @@
{
"activity": "ACT-20261029-01",
"status": "partial_increment_installed_preservation_verified_visual_pending",
"baseline": "332c16ebb555e8d6b244fa519bc777d89c1a8b8f",
"branch": "codex/competitiveness-sequential-20261029",
"target": "https://teste-investidor.tempo.co.mz/",
"checks": {
"diff": "passed",
"types": "passed",
"diagnostics_C132": "passed_in_original_workspace",
"frontend_tests": 4,
"isolated_qa_build": "passed"
},
"active_database_writes": 0,
"schema_changes": false,
"permission_changes": false,
"coverage": [
"five qualitative forces in existing cluster management",
"sector and regional scope",
"evidence, source, period, rationale, limitations and investment opportunity",
"explicit uncertainty",
"JSON download and confirmed same-cluster recovery"
],
"remaining": [
"authenticated visual verification",
"server persistence and canonical territorial scope",
"factual sector and regional data",
"public consultation"
],
"source_sha256": {
"resources/js/lib/cluster-competitiveness.ts": "1ed496f51f020da9f0c02ab0aa324b58a9dd17fa34a10d8c1a90d9915800fecb",
"resources/js/pages/structures/competitiveness.tsx": "55338c144cefae4f91cef765b8e5e8b28ceb6c76375d46943c3c3430aeddc68e",
"resources/js/pages/structures/editor.tsx": "080a90ab8607f05ec493e560ed8280a6f7eec743ed1954f0057a0ce6caa09208",
"tests/frontend/cluster-competitiveness.test.mjs": "f48a9ed872f49ad900424daab4689cb0eeeb31cc93ebf1c608408c0a8c2b435b"
},
"candidate": "23ecccfaad1053f55bf0591fd242cb5fa3406c38",
"backup": {
"stage": "/var/backups/investmoz-competitiveness-delivery-20261029/backup",
"created_at": "2026-10-29T11:04:46Z",
"target": {
"database": "teste_investidor",
"port": 5432
},
"tables": 176,
"files_verified": 369,
"dump_sha256": "7979fa2b3fe1f69e62aad9fadf60a60d7b068e198cdc291a8fc79d409d67c7ca",
"files_sha256": "8e0022b73cd0074e36f8efef0b7c803a386e24e6e4cf85f567757ff67e311369",
"restore_verified": false
},
"restore": {
"restore_verified": true,
"tables_verified": 176,
"container_network": "none",
"database": "competitiveness_delivery_20261029_test",
"port": 55441,
"source_dump_sha256": "7979fa2b3fe1f69e62aad9fadf60a60d7b068e198cdc291a8fc79d409d67c7ca",
"active_database_writes": 0
},
"preservation": {
"navigation_asset_and_shared_props_verified": true,
"at": "2026-10-29T11:10:44.303271+00:00",
"served_commit": "23ecccfaad1053f55bf0591fd242cb5fa3406c38",
"backup_dump_sha256": "7979fa2b3fe1f69e62aad9fadf60a60d7b068e198cdc291a8fc79d409d67c7ca",
"prior_public_tables_checked": 176,
"prior_domain_rows_preserved": true,
"new_migration_registry_rows": 0,
"operational_delta": {
"cache": {
"before": {
"rows": 119,
"checksum": "5f3c36637fbf1e9dc4700066d4c45530"
},
"after": {
"rows": 119,
"checksum": "b1c9949cfacc34906c406e27e95cbd9b"
}
},
"sessions": {
"before": {
"rows": 37,
"checksum": "b0e409c4c77c58b48252d9847c3c61ef"
},
"after": {
"rows": 38,
"checksum": "f4d94db55488bc0bcf6f727f222473c1"
}
}
},
"persistent_files_and_configuration_identical": 369,
"private_catalogue": {
"observations": 361464,
"distinct_administrative_observations": 214299,
"preserved_repetitions": 147165,
"unmapped_territory": 205608
},
"preservation_identity": "postgres, matching backup reader; no actor impersonation",
"anonymous_query_status": 401,
"public_revision_confirmed": true,
"actual_team_UI_journey": "pending authenticated browser conference;accepted QA is not repeated",
"unique_company_count": null,
"publication": false,
"national_coverage": "partial",
"investment_conclusion": "hypothesis,not proved demand or viability",
"L8": "unchanged,partial"
},
"installed_code": {
"candidate": "23ecccfaad1053f55bf0591fd242cb5fa3406c38",
"source_files_verified": 4,
"diagnostics_C132": {
"result": "passed",
"case": "C132",
"source_explicit_constant_OFF": true,
"effective_configuration_OFF": true,
"configuration_cached": false,
"database_connections_used": 0,
"active_writes": 0
},
"active_writes": 0
},
"pipeline": {
"unit_tests": 59,
"frontend_failures": 0,
"schema_assertions": 3106,
"checks": [
"\u001b[30;42mOK (1 test, 3106 assertions)\u001b[0m",
"✓ built in 34.50s",
"\u001b[30;42mOK (59 tests, 320 assertions)\u001b[0m",
"# tests 1180",
"# pass 1180",
"# fail 0"
],
"frontend_tests": 1180,
"result": {
"status": "installed",
"commit": "23ecccfaad1053f55bf0591fd242cb5fa3406c38",
"release": "/var/www/investmoz-homologacao/releases/23ecccfaad1053f55bf0591fd242cb5fa3406c38-0e0b8a65",
"previous_release": "/var/www/investmoz-homologacao/releases/332c16ebb555e8d6b244fa519bc777d89c1a8b8f-f0dfc218",
"database_migrations": 0,
"content_imports": 0,
"requested_commits": [
"23ecccfaad1053f55bf0591fd242cb5fa3406c38"
],
"finished_at": "2026-10-29T11:10:02.884033+00:00"
},
"unit_assertions": 320
}
}
@@ -0,0 +1,92 @@
{
"public_baseline": [
{
"first_page_ids": [
64,
63,
62,
61,
60
],
"path": "/api/v1/investment/search?sector=agronegocio\u0026province_id=bfde2651-2b46-5d9c-a500-733fa55a8c5c",
"total": 5
},
{
"first_page_ids": [
],
"path": "/api/v1/investment/search?sector=agronegocio\u0026province=Maputo%20Prov%C3%ADncia",
"total": 0
},
{
"first_page_ids": [
],
"path": "/api/v1/opportunities/search?sector=agronegocio\u0026province=Maputo%20Prov%C3%ADncia",
"total": 0
},
{
"first_page_ids": [
62,
63,
64,
60,
61,
49,
50,
55,
58,
56,
52,
48
],
"path": "/api/v1/opportunities/search?sector=agronegocio\u0026province_id=bfde2651-2b46-5d9c-a500-733fa55a8c5c",
"total": 34
}
],
"branch": "codex/province-search-20261029",
"data_writes": 0,
"authentication": {
"automatic_console_launch": "rejected by automatic approval review: blocked by policy; no specific reason supplied",
"manual_helper": "outputs/province-search-20261029/ssh-session.py",
"session": "closed"
},
"installed": "b81f477bf2385227ed3099868e80b1e3c7bcb46b",
"cause": "Legacy text province uses exact opportunity field comparison; legacy search ignores canonical province_id; canonical province filtering already expands descendants",
"remaining": [
"fresh backup and isolated restore",
"existing Git installation pipeline",
"installed public API confirmation"
],
"visual_competitiveness": "pending direct execution and record by team; accepted proofs preserved",
"schema_changes": 0,
"status": "specific_HTTP_QA_passed_not_installed",
"change": "Resolve a public, unique exact province name to the existing hierarchy; constrain legacy API by the same published catalogue territorial query; include declared province-only legacy records only without contradictory primary/additional territories",
"candidate": "b2812e5925d27f46cf97f94da17fd64d41747c6f",
"browser_bridge": "diagnosed separately; URL read not restored; outputs/edge-bridge-diagnostic-20261029/report.json",
"checks": {
"specific_HTTP_tests_run": 4,
"diff": "passed",
"specific_HTTP_tests_planned": 4,
"PHP_syntax": "passed",
"specific_HTTP_assertions": 364
},
"source_sha256": {
"app/Services/InvestmentCatalogue.php": "731091c7c754aaf40a7235c405869134523de1fef431e686a94a1d1b3d1e9fd9",
"app/Services/InvestmentGeography.php": "9815835078fe625031d795e3fda83b4af0fc1a0d18af6e8ce9fd2d3f8e535c72",
"tests/Feature/ProvinceSearchCompatibilityTest.php": "b0164154b6f08c624dcd81425f145c2fc2770a595f04c7917858c30b26049279",
"app/Http/Controllers/Api/V1/OpportunityController.php": "69a873a429e3510d5fd6691b58d825cf877274f6cd7d1303aa8029555680bb92"
},
"tested_candidate": "e0987afb0e2722300eeb0d50e8797b07fb53853a",
"QA": {
"candidate": "e0987afb0e2722300eeb0d50e8797b07fb53853a",
"status": "passed",
"database": "province_search_20261029_test",
"port": 55441,
"snapshot_restored": true,
"active_database_writes": 0,
"schema_changes": 0,
"log": "/var/lib/investmoz-deploy/province-search-qa-20261029/qa-e0987afb0e2722300eeb0d50e8797b07fb53853a.log",
"summary": "PHPUnit 12.5.34 by Sebastian Bergmann and contributors.\n\nRuntime: PHP 8.4.27\nConfiguration: /var/lib/investmoz-deploy/province-search-qa-20261029/e0987afb0e2722300eeb0d50e8797b07fb53853a-1793279039249452598/phpunit.xml\n\n.... 4 / 4 (100%)\n\nTime: 00:10.928, Memory: 60.50 MB\n\n\u001b[30;42mOK (4 tests, 364 assertions)\u001b[0m\n"
}
}
@@ -7384,32 +7384,3 @@ Estado actual: `investor-usability-git-delivery-20261028.json`. Os relatórios Q
Pedido expresso da Tempo: preencher formulário de IVA ou IRPC, em vez de abrir legislação. Base preservada: entrega instalada 789fffd0, Modelo A e aquisições/apuramento já aceites, L6 M20/M22 instalado em 5aaf1a56 e incrementos de L8. Nenhuma perda confirmada neste recorte; não se apagam as aceitações nem se repetem provas fiscais anteriores. Fontes reutilizadas do inventário de 20 exemplares acessíveis em outputs/irpc-l6-values-forms/accessible-source-inventory.json da QA anterior, incluindo temporários identificados; inventário do corpus e originais preservados. M22: três páginas conferidas visualmente nesta revisão, SHA256 4b61bd83d1fbcfd3b7b5c5523cea5923f7d0f65dc4c004eff58e5db38f763c88. Modelo A: correspondência e divergências aceites em modelo-a-reconciliacao-20261021.md; sem nova validação de proveniência.
Incremento em preparação QA: entrada directa em Documentos e Formulários, preenchimento manual dos campos correspondidos de IVA e subconjunto M22, identificação/período, revisão, impressão e ficheiro privado para recuperação mediante confirmação. Escolha técnica: sem persistência no servidor nem ligação automática ao dossier neste incremento; não altera schema, dados, permissões ou cálculos. Ficheiro não publicado. IRPC ajustamentos completos, outros regimes, anexos, assinatura e submissão à AT fora da cobertura; L8 permanece parcial. Ainda sem instalação deste incremento. Próximas provas apenas do delta: fidelidade da recuperação, rejeição de ficheiros incompatíveis sem perda, formatos/valores desconhecidos, acesso público e compilação.
## 29/10/2026 — ergonomia do catálogo: inventário antes da verificação
Origem: pedido expresso da Tempo para analisar alternativas e reorganizar a página sem alterar conteúdos. Escolha de implementação: painel lateral no computador, filtros expansíveis no móvel, grupos por finalidade e cartões com hierarquia visual; corredores, clusters e projectos âncora agrupados em secções expansíveis. Não introduz regra organizacional. Alternativas ponderadas: formulário integral no topo conserva o bloqueio vertical; janela sobreposta afasta o contexto dos resultados; painel responsivo permite consulta lado a lado e mantém os campos montados ao recolher.
Inventário do concluído consultado antes de nova ronda: selectores públicos independentes dos resultados e provas 22/10; agronegócio e escolha múltipla/dossier 23/10; vista por corredor e percurso proactivo 27/10; registos de reposição territorial e carteira mantidos. Preservar pesquisa combinada AND, filtros simples/avançados e geográficos, classificação histórica, montantes/moedas sem conversão, estados/erros, cinco vistas, CSV, favoritos/pedidos, comparação, escolha para investimento, paginação, guias e todas as fichas territoriais. Não existe eliminação funcional confirmada neste delta de código; não se infere disponibilidade activa pela apresentação local. O título dos resultados e o aviso provincial fornecidos no pedido estavam ausentes no componente local e são recuperados textualmente.
Comparação delimitada: só componentes de apresentação e CSS; schema, dados, configuração, fontes, relações e permissões sem alteração. Não se ligou à base activa nem se declarou perda por leitura sem actor. A candidata não modifica aceitação anterior ou limitações de outros incrementos. Matriz existente: RF-MN-010, RF-PII-003, RNF-EDU-008 e MASTER-471012ab-0124; sem nova matriz.
Verificação do delta por realizar: compilação/tipos, apresentação 320/390/768/1024/1440px, teclado, expansão sem perda de campos, vistas, conservação das acções/conteúdo e ausência de transbordo horizontal. Preparação local, sem instalação ou alteração de dados activos.
### Resultado do delta de apresentação — 29/10/2026
Concluído localmente. TypeScript e build isolado aprovados, sem instalação. Navegador Chromium com a aplicação React compilada e respostas HTTP de prévia locais, sem base de dados nem pedidos externos:320/390/768/1024/1440px sem transbordo horizontal; campos expandidos cabem no painel em320/390/1024px. Conferidas integralmente as descrições e avisos territoriais dos12cartões da amostra em cada largura, teclado para expandir, erro de moeda, recolher/reabrir/recarregar sem perder pesquisa, selectores hierárquicos, escolha persistente para dossier, limite/comparação de4, cinco vistas e acesso às29fichas(6corredores,8clusters,15âncoras). Zero erros JavaScript. Capturas efectivamente inspeccionadas:1440/1024/390/320px, filtros expandidos390px e lista1440px.
Comparação estrutural:33blocos preexistentes de lógica da página e5auxiliares dos filtros preservados; JSX/callbacks revistos no delta e nas interacções acima. Os avisos de ausência, a consulta pública e as ligações anteriores mantêm-se. Favoritos autenticados, backend de pesquisa/mapa, guias e permissões conservam os contratos/provas anteriores; esta prévia não é nova demonstração desses serviços nem da disponibilidade activa. Mapa exercitado com ausência explícita de coordenadas, sem criar pontos.
Recibo:docs/investmoz/unification/catalogue-layout-qa-20261029.json. Evidência local:outputs/catalogue-layout-20261029/. Nenhuma redução funcional pretendida; todas as fichas estão nos grupos expansíveis. Sem publicação, dados alterados, migrações, configuração ou permissões novas. Não encerra os critérios globais das quatro linhas da matriz.
Ajuste solicitado pela Tempo em29/10/2026: o controlo dos filtros avançados alterna entre “Mais filtros” (recolhido) e “Menos filtros” (aberto). O estado segue a abertura nativa da secção; os campos e escolhas continuam montados. Prova dirigida no componente React real em Chromium: clique/teclado, ambos os textos, valor editado e contagem conservados; sem errosJavaScript. Recibo local:outputs/catalogue-layout-20261029/toggle-receipt.json. Sem nova ronda global, instalação ou alteração de dados.
Ajuste visual solicitado pela Tempo em29/10/2026: títulos, cabeçalhos, rótulos de grupos e conteúdo do catálogo em negrito apresentados em vermelho; os botões com fundo colorido mantêm texto legível. CSS validado por análise sintáctica; alteração local de apresentação, sem instalação.
## 30/10/2026 — integração do catálogo main e front
Pedido expresso da Tempo: recuperar a estrutura visual da branch front combinando-a com as alterações de main. Base comum 838ad941; front 996b5b08; main 332c16eb. Inventário: a front demonstrou painel responsivo, filtros agrupados, cartões, cinco vistas, acções e fichas territoriais no recibo de 29/10; a main acrescentou resumo territorial, contexto por sector/localização, atalho para toda a província, estados de pesquisa e ligações de guias educativos. Integração local preserva ambos; o selector territorial e os serviços seguem main. As ausências de opções públicas não demonstram perda de dados.
Alteração de apresentação e composição local; sem modificação de schema, dados, configuração, permissões ou publicação. Evidências anteriores mantidas; as verificações desta integração e os seus limites constam de outputs/catalogue-integration-20261030/receipt.json quando disponíveis. Não substitui a prova de backend, autenticação ou disponibilidade no activo.
@@ -3006,3 +3006,65 @@ AUD-SIM-INT01 through AUD-SIM-INT08 are recorded in the same matrix and manual.
Actualização QA de 20/10/2026: os mesmos oito itens foram reconciliados no manual e nas duas tabelas existentes. Data com mensagem junto ao campo; inventário dos parâmetros efectivos (zero regras fiscais QA); controlos matemáticos independentes; aviso de recálculo; quatro relatórios reais do exportador com 6/6/6/5 páginas; consulta fiscal com erro/nova tentativa e traduções comuns verificados. O formulário personalizado já não exige contexto fiscal que não envia ao cálculo. Restam continuidade autenticada entre dois dossiers/nova sessão e histórico, mudança de parâmetros, controlos delimitados de cash-flow/plano, impressão nativa Edge/comparação pela interface e mensagens dinâmicas/falhas dirigidas de gravação/exportação. Estimativa restante **6–10 h**, distinta das 18–30 h iniciais. Sem main, servidor remoto, importação ou activação fiscal. Exemplos PDF são renderizações do DOCX; não certificam impressão do Edge. Ver o saldo detalhado AUD-SIM-INT01–08 no mesmo manual.
Continuação delimitada: dois dossiers e nova entrada conferidos no navegador; histórico agora visível no percurso de pequeno negócio, hashes preservados; Abrir cálculo encaminha para a etapa financeira; repetição de gravação após 503 corrigida e conferida; controlos independentes de tesouraria/plano passaram. CSV e Word descarregados pela interface coincidem nos valores comuns e apresentam a receita completa do crédito. Seis páginas do DOCX conferidas; PDF nativo ainda aguarda gravação pelo utilizador. QA tem zero regras com visibilidade completa, mas existem três originais fiscais versionados e análises/referências reutilizáveis; não se concluiu ausência na homologação sem autenticação SSH. Versões sintéticas testadas apenas em memória, sem cadastro/activação. Encontrada e corrigida perda dos pressupostos da tesouraria: snapshot no JSON existente, recuperação por dossier e relatório, sem migration; testes HTTP/frontend passaram, falta conferência visual deste novo delta. Saldo **2–4 h** após acessos/ficheiro, detalhado no mesmo manual; integração do motor fiscal permanece separada e não foi implementada. Sem main ou intervenção remota.
## 29/10/2026 — ACT-20261029-01, inventário antes da verificação do incremento
Continuação sequencial solicitada pela Tempo. Correspondência: ACT-20261029-01 reutiliza D-02 §8, clusters/cadeias e a orientação metodológica documentada em 23/10. Concluído previamente e preservado: gestão de clusters, etapas, ligações, lacunas e relações auditadas; disponibilidade instalada de carteira/âncoras e formulários fiscais conforme recibos de 28/10. Nenhuma perda nova confirmada nesta inspecção de código; não foi feita nova comparação de dados ou alegada prova do servidor. A pasta principal está em fde644af, enquanto o último recibo identifica 332c16eb instalado; reconciliar o delta sobre essa origem antes de instalar, sem substituir o destino pela pasta principal.
Incremento em preparação: diagnóstico qualitativo das cinco forças na edição do cluster, sector/região editáveis, fonte/período/evidência/justificação/limites/oportunidade por força; indeterminação explícita, ficheiro privado transferível e recuperação com confirmação. Sem alteração de schema, dados, permissões, publicações ou motores fiscais. Persistência no servidor, ligação territorial canónica, dados sectoriais fundamentados e consulta pública permanecem por implementar; ACT-01 não está concluída. Verificação dirigida e instalação ainda pendentes neste registo inicial. Restantes ACT-02 a 16 mantêm o estado anterior e a sequência, sem trabalho SGF nesta passagem.
### ACT-20261029-01 — candidato sequencial preparado, 29/10/2026
Primeiro incremento implementado, **parcial e ainda não instalado**: gestão → clusters → editar → Competitividade. As cinco forças conservam fonte, período, evidência, apreciação, limites e oportunidade; sector e região explícitos, sem inferências automáticas. Transferência de rascunhos JSON e recuperação restrita ao mesmo cluster, com confirmação antes de substituir. Quatro testes frontend dirigidos, TypeScript e compilação QA passaram tanto na pasta de trabalho como no candidato baseado em 332c16eb. Guarda C132 local passou; não foram efectuados testes nem escritas na base activa. A edição existente recebe apenas a inclusão do componente; contratos e permissões existentes preservados.
Origem reconciliada: main remoto e revisão pública conferidos em 332c16eb em 29/10. Candidato em C:/laragon/www/portal-investidor-competitiveness-qa-20261029, ramo codex/competitiveness-sequential-20261029. Consulta do estado remoto não equivale a inventário novo da base. Sessão SSH anterior encerrada; autenticação por chave recusada. Instalação requer autenticação SSH, backup fresco, restauro isolado, guarda de versão, pipeline existente e conferência posterior. Não instalar a pasta principal sobre o destino. A sequência solicitada mantém ACT-02 a 16 por iniciar após a instalação e o saldo de ACT-01; persistência no servidor, cobertura factual e consulta pública continuam pendentes. Sem declaração de conferência visual autenticada.
### 29/10/2026 — ACT-20261029-01, primeiro incremento instalado: 23ecccfa
Estado corrente de ACT-01: **parcial — primeiro incremento instalado**, substituindo apenas o estado planeado/preparado deste recorte; ACT-02 a 16 não foram implementadas nesta entrega. Instalado por avanço normal de main e pipeline existente em https://teste-investidor.tempo.co.mz/. Entrada: Gestão → Clusters → Editar → Competitividade — cinco forças de Porter. Preparação qualitativa por sector/região, evidências/fontes/períodos/limites e oportunidades, transferência JSON e recuperação confirmada do mesmo cluster. Sem gravação no servidor nem publicação da análise. Persistência, cobertura factual, território canónico e consulta pública continuam pendentes; esta instalação não encerra o critério amplo ACT-01.
Provas: backup fresco, restauro isolado sem rede das 176 tabelas; pipeline com lifecycle de schema (3.106 asserções), 59 testes Unit/320 asserções e 1.180 testes frontend sem falhas, tipos e compilação; quatro ficheiros do incremento conferidos por SHA-256 no instalado; C132 no destino passou. Comparação após instalação: todas as 176 tabelas de domínio preservadas, diferenças operacionais apenas em cache/sessões; 369 ficheiros/configuração idênticos. Acesso anónimo ao catálogo privado recusado HTTP401. Zero migrações/importações, nenhuma nova divulgação nem mudança de permissões; versão anterior 332c16eb preservada. Conferência visual autenticada permanece pendente. Recibo: `docs/investmoz/unification/competitiveness-sequential-qa-20261029.json`, em docs/investmoz/unification. As provas aceites anteriores foram reutilizadas; não foram reabertas por esta entrega.
### 29/10/2026 — ACT-01, persistência e aprovação: execução em curso
Ordem confirmada pela Tempo: 1) gravar/validar/recuperar na gestão com permissões e histórico, 2) consultar publicamente apenas conteúdo aprovado, 3) restantes alterações. A instalação da interface 23ecccfa e os 1.180 testes anteriores são provas desse incremento, não prova de persistência. Sem perda nova confirmada; preservar a versão pública anterior e os recibos de backup/restauro. O problema dos filtros provinciais permanece **separado e aberto**, sem declaração de correcção por estes trabalhos.
Escolha técnica: reutilizar snapshots cifrados nos eventos append-only de auditoria, já usados pelo portal; bloqueio transaccional do cluster, controlo de versão e histórico imutável. Sem alteração de schema/permissões. Guardar rascunho e recuperar histórico não publica; aprovação explícita fixa a versão consultável, edições seguintes preservam a aprovação anterior até nova decisão. Rascunhos parciais são guardáveis; aprovação exige os fundamentos das cinco forças. Recuperação/conflito não deve sobrescrever edições inadvertidamente. Acesso público continua limitado às fichas publicadas. Implementação e provas específicas de persistência/consulta ainda em preparação; restantes ACT mantêm o estado anterior.
### ACT-01 — prova específica antes da instalação, 29/10/2026
Sete testes Feature/93 asserções passaram na base restaurada competitiveness_persistence_20261029_test, porta 55441: gravação e leitura HTTP entre pedidos, retorno pela edição da gestão, histórico cifrado, validação de campos/cluster/forças, normalização do formato recuperável, conflitos sem sobrescrita, aprovação fixa, rascunhos posteriores sem divulgação, recuperação histórica como nova versão, retirada e exclusão do conteúdo não aprovado na consulta pública. Utilizadores sem permissão e pedidos anónimos foram recusados na gestão. O teste público percorreu a ficha publicada e confirmou 404 da ficha não publicada. Zero escritas na base activa. Esta prova não afirma conferência visual autenticada nem correcção dos filtros provinciais, que permanecem abertos. Recibo: docs/investmoz/unification/competitiveness-persistence-qa-20261029.json. Compilação QA, tipos e quatro testes frontend dirigidos passaram; instalação ainda pendente.
### 29/10/2026 — ACT-01, gravação/recuperação e consulta aprovada instaladas: b81f477b
Instalado pelo avanço normal de main e pipeline existente, preservando 23ecccfa para retorno. Gestão → Clusters → Editar → Competitividade agora recupera o rascunho do servidor e permite gravar, consultar a versão actual, confirmar recuperação e recuperar uma versão histórica como nova gravação. Versões cifradas e imutáveis na auditoria existente, com bloqueio transaccional do cluster e controlo de versão; conflitos conservam a versão guardada e os campos do editor. Reutilizada a permissão existente de gestão, sem novos papéis ou aprovadores. Rascunhos incompletos podem ser guardados; a aprovação exige âmbito e fundamentos das cinco forças.
**Percurso da gestão:** preencher → Guardar rascunho no servidor → voltar à edição para recuperar → consultar histórico quando necessário. Para comparar com outra gravação, usar Consultar versão actual do servidor e confirmar antes de substituir os campos; transferir primeiro edições que se queira conservar. Recuperar o histórico cria nova versão e mantém o histórico e a aprovação pública existente. A aprovação ou retirada é expressa, com evidência/motivo registado. A ficha pública de um cluster publicado mostra exclusivamente a análise aprovada; novas gravações privadas não alteram essa versão. Uma ficha não publicada continua indisponível ao público. Não houve aprovação nem divulgação automática de análises.
**Prova específica:** sete testes Feature/93 asserções na base restaurada e isolada competitiveness_persistence_20261029_test, porta55441. Abrange gravação/recuperação HTTP, retorno na gestão, validação e formato, histórico cifrado, conflito, acesso recusado, aprovação fixa, rascunho posterior privado, recuperação, retirada e consulta pública/404 de ficha não publicada. Os 1.180 testes frontend não são apresentados como prova substituta de persistência. Pipeline: tipos, compilação, lifecycle de schema1/3106, Unit59/320 e frontend1180 sem falhas. Dez ficheiros de código/testes do instalado conferidos por SHA-256 com os da QA; C132 no destino passou.
**Preservação e limites:** backup fresco/restauro isolado176; comparação posterior de176 tabelas sem alterações de domínio, apenas diferenças operacionais de cache/sessões; 369 ficheiros/configuração idênticos. Ligação real teste_investidor_app conferida em leitura, com privilégios existentes de SELECT/INSERT em auditoria e bloqueio do cluster; endpoint de gestão anónimo HTTP401. Nenhuma escrita sintética na base activa, migração, importação, mudança de permissões ou nova publicação. A conferência visual autenticada no instalado permanece pendente; não foi simulada uma decisão real de conteúdo no activo. ACT-01 tem agora persistência e consulta aprovada implementadas/instaladas, mantendo parcial a cobertura factual por sector/região. Recibo: docs/investmoz/unification/competitiveness-persistence-qa-20261029.json.
**Filtros provinciais: separado e aberto.** Esta entrega não alterou a lógica dos filtros nem demonstra a correcção relatada de Agronegócio/Maputo Província no portal. Preservam-se as provas e os estados históricos; não encerrar V2-07/V2-10 ou ACT-02 por causa da persistência de Competitividade. Restantes alterações permanecem na sequência posterior.
Origem de continuidade: C:/laragon/www/portal-investidor-competitiveness-qa-20261029, ramo codex/competitiveness-persistence-20261029, instalado b81f477b. A pasta principal anterior não deve substituir esta versão sem reconciliação do delta.
### 29/10/2026 — V2-07/V2-10 / ACT-02, diagnóstico dos filtros provinciais em curso
Separação confirmada pela Tempo: a equipa executará e registará a conferência visual autenticada de Competitividade (continua pendente); o Codex prossegue no código/API dos filtros. Provas aceites preservadas, sem repetição das suites fiscais ou de Competitividade. Nenhuma perda de dados confirmada nesta passagem.
Reprodução pública em b81f477b: Agronegócio/Maputo Província por province_id na API /api/v1/investment/search devolve5 oportunidades (IDs60–64). O mesmo nome em province devolve0, tanto nessa API como em /api/v1/opportunities/search. A API antiga ignora province_id e devolve34, incluindo outras províncias. São lacunas de normalização/compatibilidade entre entradas, não prova de eliminação nem falha demonstrada da expansão hierárquica já existente. Corrigir apenas os deltas: resolver nomes provinciais públicos inequívocos para a mesma hierarquia e aplicar o filtro territorial na API antiga, preservando outros filtros, versões públicas, localização real e dados. Demonstrar todos os níveis inferiores por pedidos HTTP em QA isolada; província apenas contextual ou declarada não deve ganhar distrito. O problema permanece aberto até às provas específicas e à conferência da API instalada; não encerrar a conferência visual da equipa por testes técnicos.
### 29/10/2026 — duas frentes e diagnóstico local Edge
A equipa executa directamente e regista a conferência visual autenticada de Competitividade (editar → guardar → reabrir → conferir versão aprovada); estado pendente até execução. O Codex prossegue com código/API dos filtros provinciais, independentemente do navegador. Provas aceites preservadas.
Ligação identificada: com.openai.codexextension, manifesto stdio válido, extensão Edge instalada e activa no perfil detectado, host executado como filho do Edge; named pipes do browser-use presentes e servidor registado nos logs do cliente. Inspeccionados os listeners/processos sem libertar portas. Não foi identificado conflito TCP nem um serviço de extensão parado. Não foi encontrado erro de permissões no recorte de logs consultado, sem excluir outros pontos. A descoberta desta sessão continua a indicar access host indisponível; o runtime exige caminho do pipe e identidade verificadora, ausentes na shell. Falha de passagem de contexto é uma hipótese delimitada, não conclusão de porta ocupada. O registo de erros do service worker não ficou acessível pela ligação de navegador indisponível. A recuperação suportada, limitada à abertura do perfil Edge detectado, não restabeleceu a leitura do URL. Não foram terminados processos, reinstalados hosts nem alteradas permissões/políticas. InvesteMoz e SGF continuam em execução. Relatório local: outputs/edge-bridge-diagnostic-20261029/report.json. Não declarar recuperada a ligação nem concluída a conferência visual.
Correcção provincial preparada no ramo codex/province-search-20261029; validação QA/instalação pendentes. A sessão SSH anterior expirou. O lançamento automático de nova consola foi recusado pela revisão automática com indicação genérica blocked by policy, sem razão adicional; foi pedido arranque/autenticação manual no helper existente. Nenhuma escrita no activo ou novo ensaio remoto ocorreu por essa tentativa.
### 29/10/2026 — filtros provinciais: correcção candidata, pendente de QA
Reproduzido no portal instalado b81f477b: /api/v1/investment/search por province_id de Maputo Província devolve5; por province textual devolve0 nas duas APIs; a API antiga com province_id ignora o parâmetro e devolve34 de Agronegócio. Preparada correcção no ramo codex/province-search-20261029, preservando a expansão de descendentes existente e aplicando a mesma selecção territorial à API antiga. Nomes provinciais só são resolvidos quando a correspondência pública exacta é inequívoca. Declarações provinciais antigas são consideradas sem atribuir distrito, posto, localidade ou coordenadas, e não prevalecem sobre territórios explícitos contraditórios. Nenhum dado cadastral é actualizado por esta correcção.
Quatro testes HTTP preparados para província/distrito/posto/localidade e localizações adicionais, onze províncias e exclusão de vizinhas/privados/eliminados, preservação das localizações e consistência de catálogo/mapa/exportação. Sintaxe PHP e diff conferidos; testes ainda não executados e candidato ainda não instalado. Recibo: docs/investmoz/unification/province-search-qa-20261029.json. A sessão SSH expirou e a nova consola automática foi recusada pela revisão de aprovação com blocked by policy (sem razão específica adicional); pedida autenticação manual no helper outputs/province-search-20261029/ssh-session.py. Não substituir ausência de ensaio por declaração de correcção concluída.
A conferência visual de Competitividade fica a cargo da equipa e permanece pendente até execução/registo; provas técnicas aceites preservadas. Ligação Edge diagnosticada separadamente: extensão/manifesto/host activos, stdio/named pipes, leitura do URL não restabelecida pela recuperação suportada do perfil. Nenhum processo terminado nem porta libertada/reconfigurada. InvesteMoz e SGF preservados. Relatório local: outputs/edge-bridge-diagnostic-20261029/report.json. Os filtros mantêm-se abertos até QA e confirmação da API instalada.
### 29/10/2026 — filtros provinciais, prova HTTP específica em QA
Quatro testes Feature/364 asserções passaram na base restaurada province_search_20261029_test, porta55441, candidato e0987afb. Seleccionar só a província, por nome público inequívoco ou ID, inclui registos provinciais, distritais, de posto e localidade e localizações adicionais, nas duas APIs; confirmada cobertura das11 províncias e exclusão de vizinhas. Privados/eliminados não entram; distrito seleccionado não recebe oportunidades apenas provinciais. Declaração provincial antiga não supera localização explícita noutro território. Maputo Província por nome/ID, página do catálogo, mapa e exportação concordam nas5 oportunidades do contexto aprovado; sem coordenadas ou distrito inventados. Campos e relações guardados permanecem inalterados. Zero escritas no activo; nenhuma migração nem alteração de permissões. O conjunto inclui provas novas da hierarquia HTTP, sem reabrir as provas aceites de Competitividade/fiscalidade.
Recibo: docs/investmoz/unification/province-search-qa-20261029.json. Instalação e confirmação da API no portal continuam pendentes; não declarar encerrado o problema por QA isolada. A conferência visual autenticada de Competitividade permanece separada, pendente de execução e registo pela equipa. A sessão SSH foi reaberta a pedido expresso da Tempo e ficou autenticada; a recusa anterior da abertura automática não constitui bloqueio actual.
@@ -0,0 +1,72 @@
import { forceLabels, forces } from '@/lib/cluster-competitiveness';
import type { CompetitivenessDraft } from '@/lib/cluster-competitiveness';
export type ApprovedCompetitiveness = {
draft: CompetitivenessDraft;
version: number;
at: string;
};
const pressures = {
low: 'Baixa',
medium: 'Média',
high: 'Alta',
'': 'Indeterminada',
};
export default function ClusterCompetitivenessPublic({
analysis,
}: {
analysis: ApprovedCompetitiveness;
}) {
return (
<section
className="space-y-3 rounded border p-4"
aria-label="Competitividade aprovada"
>
<h2 className="text-xl font-semibold">
Competitividade — cinco forças de Porter
</h2>
<p>
{analysis.draft.sector} · {analysis.draft.region} · Versão
aprovada {analysis.version} · {analysis.at}
</p>
<p>
Apreciação qualitativa fundamentada nas fontes e períodos
indicados. Não representa garantia de procura ou viabilidade.
</p>
{forces.map((force) => {
const row = analysis.draft.forces[force];
return (
<details key={force} className="rounded border p-3">
<summary>
{forceLabels[force]} · Pressão{' '}
{pressures[row.pressure]}
</summary>
<dl className="space-y-2">
{(
[
['evidence', 'Evidências'],
['source', 'Fonte e referência'],
['period', 'Período'],
['rationale', 'Justificação'],
['limitations', 'Limitações'],
[
'opportunity',
'Oportunidade e fundamento',
],
] as const
).map(([field, label]) => (
<div key={field}>
<dt className="font-semibold">{label}</dt>
<dd className="whitespace-pre-wrap">
{row[field]}
</dd>
</div>
))}
</dl>
</details>
);
})}
</section>
);
}
+107
View File
@@ -0,0 +1,107 @@
export const forceLabels = {
rivalry: 'Rivalidade entre concorrentes',
entrants: 'Ameaça de novos entrantes',
substitutes: 'Ameaça de produtos ou serviços substitutos',
suppliers: 'Poder negocial dos fornecedores',
buyers: 'Poder negocial dos clientes',
} as const;
export type Force = keyof typeof forceLabels;
export type Assessment = {
pressure: '' | 'low' | 'medium' | 'high';
evidence: string;
source: string;
period: string;
rationale: string;
limitations: string;
opportunity: string;
};
export type CompetitivenessDraft = {
version: 1;
clusterId: string;
sector: string;
region: string;
forces: Record<Force, Assessment>;
};
export const forces = Object.keys(forceLabels) as Force[];
const fields = [
'evidence',
'source',
'period',
'rationale',
'limitations',
'opportunity',
] as const;
export function newCompetitivenessDraft(
clusterId: string,
sector: string,
): CompetitivenessDraft {
return {
version: 1,
clusterId,
sector,
region: '',
forces: Object.fromEntries(
forces.map((force) => [
force,
{
pressure: '',
evidence: '',
source: '',
period: '',
rationale: '',
limitations: '',
opportunity: '',
},
]),
) as Record<Force, Assessment>,
};
}
// A pressure is an author's qualitative assessment, never inferred from missing data.
export function assessmentReady(row: Assessment): boolean {
return (
['low', 'medium', 'high'].includes(row.pressure) &&
fields.every((field) => row[field].trim().length > 0)
);
}
export function diagnosticReady(draft: CompetitivenessDraft): boolean {
return (
Boolean(draft.sector.trim() && draft.region.trim()) &&
forces.every((force) => assessmentReady(draft.forces[force]))
);
}
export function readCompetitivenessDraft(
raw: string,
clusterId: string,
): CompetitivenessDraft {
if (raw.length > 250_000)
throw Error('O ficheiro excede o limite de 250 KB.');
const input = JSON.parse(raw);
if (!input || input.version !== 1 || input.clusterId !== clusterId) {
throw Error(
'O ficheiro não corresponde a este cluster ou tem uma versão incompatível.',
);
}
const text = (value: unknown, max: number): string => {
if (typeof value !== 'string' || value.length > max)
throw Error('O ficheiro contém campos inválidos.');
return value;
};
const draft = newCompetitivenessDraft(clusterId, text(input.sector, 200));
draft.region = text(input.region, 1000);
for (const force of forces) {
const row = input.forces?.[force];
if (!row || !['', 'low', 'medium', 'high'].includes(row.pressure)) {
throw Error('O ficheiro não contém as cinco forças válidas.');
}
draft.forces[force].pressure = row.pressure;
for (const field of fields)
draft.forces[force][field] = text(row[field], 5000);
}
return draft;
}
+4
View File
@@ -11,6 +11,7 @@ import { Management } from '../management/shared';
import { Layout, Field, control, button, labels, kind } from './shared';
import type { Dossier } from './shared';
import InvestmentComplements, { type ComplementOption } from '@/components/investment-complements';
import ClusterCompetitivenessPublic, { type ApprovedCompetitiveness } from '@/components/cluster-competitiveness-public';
export default function Show(props: Props) {
return (
<Editor
@@ -21,6 +22,7 @@ export default function Show(props: Props) {
}
type Props = {
competitiveness?: ApprovedCompetitiveness | null;
complements?: ComplementOption[];
geography: StructureGeography;
existingSubmissions?: { id: string; version: number; updated_at: string }[];
@@ -54,6 +56,7 @@ type Props = {
};
function Editor({
competitiveness,
complements = [],
record,
related,
@@ -94,6 +97,7 @@ function Editor({
{kind(record)} · {record.content.code}
</p>
<p className="text-sm">Fonte: {record.source}</p>
{competitiveness && <ClusterCompetitivenessPublic analysis={competitiveness} />}
{!management && (
<div className="space-y-2 rounded-xl border p-4">
<Link className="underline" href={portfolioPreparationHref(record.id, pageUrl)}>
@@ -0,0 +1,501 @@
import { useState } from 'react';
import { portalRequest, PortalApiError } from '@/lib/portal-api';
import {
assessmentReady,
diagnosticReady,
forceLabels,
forces,
newCompetitivenessDraft,
readCompetitivenessDraft,
} from '@/lib/cluster-competitiveness';
import type {
Assessment,
CompetitivenessDraft,
Force,
} from '@/lib/cluster-competitiveness';
import { button, control } from '../management/shared';
const labels = {
evidence: 'Observações e evidências locais',
source: 'Fonte e referência recuperável (documento, página ou endereço)',
period: 'Período dos dados e data da consulta',
rationale: 'Justificação da pressão atribuída',
limitations: 'Limitações, cobertura e pressupostos',
opportunity: 'Oportunidade de investimento e fundamento',
} as const;
export type CompetitivenessState = {
version: number;
draft: CompetitivenessDraft | null;
approved: {
draft: CompetitivenessDraft;
version: number;
at: string;
} | null;
history: { version: number; action: string; at: string; actor: number }[];
};
const actions: Record<string, string> = {
saved: 'Gravação',
approved: 'Aprovação pública',
withdrawn: 'Retirada da aprovação',
restored: 'Recuperação do histórico',
};
export default function Competitiveness({
clusterId,
sector,
initial,
}: {
clusterId: string;
sector: string;
initial?: CompetitivenessState | null;
}) {
const [draft, setDraft] = useState(
() => initial?.draft ?? newCompetitivenessDraft(clusterId, sector),
);
const [server, setServer] = useState<CompetitivenessState>(
initial ?? { version: 0, draft: null, approved: null, history: [] },
);
const [serverPending, setServerPending] =
useState<CompetitivenessState | null>(null);
const [restoreVersion, setRestoreVersion] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
const [reason, setReason] = useState('');
const [pending, setPending] = useState<CompetitivenessDraft | null>(null);
const [message, setMessage] = useState('');
const dirty =
JSON.stringify(draft) !==
JSON.stringify(
server.draft ?? newCompetitivenessDraft(clusterId, sector),
);
const endpoint = `/management/clusters/${clusterId}/competitiveness`;
function errorMessage(error: unknown) {
if (error instanceof PortalApiError) {
if (error.status === 409)
return 'Conflito de versão. Os campos actuais foram conservados. Transfira o rascunho e consulte a versão do servidor antes de reconciliar.';
if ([401, 403, 419].includes(error.status))
return 'Sessão ou permissão indisponível. Os campos foram conservados; transfira o rascunho antes de autenticar-se novamente.';
if (error.status === 422)
return (
Object.values(error.errors).flat().join(' ') ||
error.message
);
}
return 'Não foi possível concluir a operação; os campos actuais foram conservados.';
}
function apply(state: CompetitivenessState) {
setServer(state);
setDraft(state.draft ?? newCompetitivenessDraft(clusterId, sector));
setPending(null);
setServerPending(null);
setRestoreVersion(null);
}
async function command(
action: string,
extra: Record<string, unknown> = {},
) {
setBusy(true);
try {
const state = await portalRequest<CompetitivenessState>(
`${endpoint}/${action}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
expected_version: server.version,
...extra,
}),
},
);
apply(state);
setMessage(
`${actions[action]} concluída no servidor. Versão ${state.version}.`,
);
} catch (error) {
setMessage(errorMessage(error));
} finally {
setBusy(false);
}
}
function change(force: Force, field: keyof Assessment, value: string) {
setDraft((previous) => ({
...previous,
forces: {
...previous.forces,
[force]: { ...previous.forces[force], [field]: value },
},
}));
}
function download() {
const url = URL.createObjectURL(
new Blob([JSON.stringify(draft, null, 2)], {
type: 'application/json',
}),
);
const link = document.createElement('a');
link.href = url;
link.download = `competitividade-${clusterId}.json`;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
setMessage(
'Ficheiro preparado para transferência. Conserve-o para recuperar a análise.',
);
}
return (
<section
className="max-w-3xl space-y-4 rounded border p-4"
aria-label="Diagnóstico de competitividade"
>
<h2 className="text-xl font-semibold">
Competitividade — cinco forças de Porter
</h2>
<p>
Análise qualitativa por sector e região ligada a este cluster.
Descreva as evidências e justifique cada pressão; não existe
pontuação automática nem média entre forças.
</p>
<p>
Guarde o rascunho para o recuperar no servidor. A publicação
exige aprovação expressa; a classificação representa a
apreciação do autor.
</p>
<p role="status">
Versão no servidor: {server.version}.{' '}
{dirty
? 'Existem alterações por guardar.'
: 'Campos correspondem à versão recuperada.'}{' '}
{server.approved
? `Consulta pública: versão aprovada ${server.approved.version}; novas edições conservam essa versão até outra aprovação.`
: 'Sem análise aprovada para consulta pública.'}
</p>
<fieldset disabled={busy} className="space-y-4">
<label className="block">
Sector analisado
<input
className={control}
maxLength={200}
value={draft.sector}
onChange={(e) =>
setDraft({ ...draft, sector: e.target.value })
}
/>
</label>
{draft.sector !== sector && (
<p>
O sector da análise difere do sector actual do cluster (
{sector}). Confira o âmbito antes de reutilizar
conclusões.
</p>
)}
<label className="block">
Região / província / distrito e abrangência
<input
className={control}
maxLength={1000}
value={draft.region}
onChange={(e) =>
setDraft({ ...draft, region: e.target.value })
}
/>
</label>
<p role="status">
{diagnosticReady(draft)
? 'Cinco forças preenchidas para revisão; fontes e conclusões requerem leitura do autor.'
: 'Diagnóstico incompleto: indique o âmbito e complete as evidências das cinco forças.'}
</p>
{forces.map((force) => (
<details key={force} className="rounded border p-3">
<summary className="cursor-pointer font-semibold">
{forceLabels[force]} ·{' '}
{assessmentReady(draft.forces[force])
? 'Preenchida para revisão'
: 'Indeterminada / incompleta'}
</summary>
<div className="space-y-3 pt-3">
<label className="block">
Pressão competitiva
<select
className={control}
value={draft.forces[force].pressure}
onChange={(e) =>
change(
force,
'pressure',
e.target.value,
)
}
>
<option value="">Indeterminada</option>
<option value="low">Baixa</option>
<option value="medium">Média</option>
<option value="high">Alta</option>
</select>
</label>
{Object.entries(labels).map(([field, label]) => (
<label key={field} className="block">
{label}
<textarea
className={control}
rows={3}
maxLength={5000}
value={
draft.forces[force][
field as keyof typeof labels
]
}
onChange={(e) =>
change(
force,
field as keyof typeof labels,
e.target.value,
)
}
/>
</label>
))}
</div>
</details>
))}
<button type="button" className={button} onClick={download}>
Transferir análise / rascunho
</button>
<label className="block">
Recuperar ficheiro deste cluster
<input
className={control}
type="file"
accept=".json,application/json"
onChange={async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setPending(null);
try {
if (file.size > 250_000)
throw Error(
'O ficheiro excede o limite de 250 KB.',
);
setPending(
readCompetitivenessDraft(
await file.text(),
clusterId,
),
);
setMessage(
'Ficheiro lido. Confira o âmbito e confirme a substituição dos campos actuais.',
);
} catch (error) {
setMessage(
error instanceof Error
? error.message
: 'Não foi possível ler o ficheiro.',
);
}
}}
/>
</label>
{pending && (
<div className="space-y-2 rounded border p-3">
<p>
Recuperar análise de{' '}
{pending.sector || 'sector não indicado'} em{' '}
{pending.region || 'região não indicada'}? Os campos
actuais serão substituídos. Transfira primeiro o
rascunho actual se o quiser conservar.
</p>
<button
type="button"
className={button}
onClick={() => {
setDraft(pending);
setPending(null);
setMessage('Análise recuperada para edição.');
}}
>
Confirmar recuperação
</button>{' '}
<button
type="button"
className={button}
onClick={() => {
setPending(null);
setMessage(
'Recuperação cancelada; campos actuais preservados.',
);
}}
>
Cancelar
</button>
</div>
)}
{message && <p role="status">{message}</p>}
<div className="flex flex-wrap gap-2">
<button
type="button"
className={button}
disabled={!dirty}
onClick={() => command('saved', { draft })}
>
Guardar rascunho no servidor
</button>
<button
type="button"
className={button}
onClick={async () => {
setBusy(true);
try {
setServerPending(
await portalRequest<CompetitivenessState>(
endpoint,
),
);
setMessage(
'Versão do servidor lida. Confirme antes de substituir os campos.',
);
} catch (error) {
setMessage(errorMessage(error));
} finally {
setBusy(false);
}
}}
>
Consultar versão actual do servidor
</button>
</div>
{serverPending && (
<div className="rounded border p-3">
<p>
Versão {serverPending.version}:{' '}
{serverPending.draft?.sector ||
'Sector não indicado'}{' '}
/{' '}
{serverPending.draft?.region ||
'Região não indicada'}
. Recuperar substitui os campos actuais. Transfira
primeiro alterações que queira conservar.
</p>
<button
type="button"
className={button}
onClick={() => {
apply(serverPending);
setMessage(
'Versão actual recuperada do servidor.',
);
}}
>
Confirmar recuperação do servidor
</button>{' '}
<button
type="button"
className={button}
onClick={() => setServerPending(null)}
>
Cancelar
</button>
</div>
)}
<details className="rounded border p-3">
<summary>
Histórico no servidor (últimas 50 versões)
</summary>
{!server.history.length && (
<p>Ainda não existem gravações.</p>
)}
{server.history.map((entry) => (
<p key={entry.version}>
Versão {entry.version} ·{' '}
{actions[entry.action] ?? entry.action} · {entry.at}{' '}
· Utilizador {entry.actor}{' '}
<button
type="button"
className="underline"
onClick={() => setRestoreVersion(entry.version)}
>
Recuperar como rascunho
</button>
</p>
))}
{restoreVersion !== null && (
<div>
<p>
Recuperar a versão {restoreVersion} cria uma
nova versão no servidor e substitui os campos
actuais. A versão pública aprovada mantém-se.
Transfira primeiro alterações por guardar.
</p>
<button
type="button"
className={button}
onClick={() =>
command('restored', {
revision: restoreVersion,
})
}
>
Confirmar recuperação do histórico
</button>{' '}
<button
type="button"
className={button}
onClick={() => setRestoreVersion(null)}
>
Cancelar
</button>
</div>
)}
</details>
<details className="rounded border p-3">
<summary>Aprovação para consulta pública</summary>
<p>
Aprovar torna a versão guardada consultável na ficha
pública deste cluster, quando essa ficha estiver
publicada. Guardar um rascunho não o publica. A
evidência de aprovação e as identidades do histórico
ficam na gestão.
</p>
<label className="block">
Evidência / motivo da aprovação ou retirada
<textarea
className={control}
maxLength={3000}
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</label>
<button
type="button"
className={button}
disabled={
dirty ||
!diagnosticReady(draft) ||
server.version === 0 ||
reason.trim().length < 10
}
onClick={() => command('approved', { reason })}
>
Aprovar versão guardada para consulta pública
</button>{' '}
<button
type="button"
className={button}
disabled={
dirty ||
!server.approved ||
reason.trim().length < 10
}
onClick={() => command('withdrawn', { reason })}
>
Retirar aprovação pública
</button>
{dirty && (
<p>
Guarde primeiro as alterações ou recupere a versão
pretendida antes de decidir a aprovação.
</p>
)}
</details>
</fieldset>
</section>
);
}
+10
View File
@@ -6,6 +6,8 @@ import { Management, Field, button, control } from '../management/shared';
import { titles, statuses, stages, roles } from './types';
import type { Structure, StructureRecord, Chain } from './types';
import ValueNetwork from './value-network';
import Competitiveness from './competitiveness';
import type { CompetitivenessState } from './competitiveness';
import type { ChainLink, Gap } from './value-network';
function ChainForm({ clusterId, chain }: { clusterId: string; chain?: Chain }) {
@@ -75,6 +77,7 @@ export default function Editor({
memberships,
chainLinks,
gaps,
competitiveness,
}: {
structure: Structure;
record: StructureRecord | null;
@@ -83,6 +86,7 @@ export default function Editor({
chains: Chain[];
chainLinks: ChainLink[];
gaps: Gap[];
competitiveness?: CompetitivenessState | null;
memberships: {
id: string;
project_id: string;
@@ -298,6 +302,12 @@ export default function Editor({
</form>
{isCluster && record && (
<>
<Competitiveness
key={record.id}
clusterId={record.id}
sector={record.sector ?? ''}
initial={competitiveness}
/>
<section className="max-w-3xl space-y-4">
<h2 className="text-xl font-semibold">
Projectos associados
+2
View File
@@ -254,6 +254,8 @@ Route::middleware(['auth', 'verified', \App\Http\Middleware\EnsureScopedPortalAd
Route::get('portfolio/{id}', [InvestmentPortfolioController::class, 'show'])->whereUuid('id')->name('portfolio.show');
Route::put('portfolio/{dossier}', [InvestmentPortfolioController::class, 'save'])->name('portfolio.save');
Route::post('portfolio/{dossier}/publish', [InvestmentPortfolioController::class, 'publish'])->name('portfolio.publish');
Route::get('clusters/{cluster}/competitiveness', [\App\Http\Controllers\ClusterCompetitivenessController::class, 'show'])->name('clusters.competitiveness.show');
Route::post('clusters/{cluster}/competitiveness/{action}', [\App\Http\Controllers\ClusterCompetitivenessController::class, 'change'])->whereIn('action', ['saved', 'approved', 'withdrawn', 'restored'])->name('clusters.competitiveness.change');
Route::post('clusters/{cluster}/chain-links', [ClusterValueChainController::class, 'link'])->name('clusters.chain-links.store');
Route::delete('clusters/{cluster}/chain-links/{link}', [ClusterValueChainController::class, 'removeLink'])->name('clusters.chain-links.delete');
Route::post('clusters/{cluster}/gaps', [ClusterValueChainController::class, 'gap'])->name('clusters.gaps.store');
@@ -0,0 +1,175 @@
<?php
namespace Tests\Feature;
use App\Models\EconomicCluster;
use App\Models\InvestmentDossier;
use App\Models\User;
use App\Services\ClusterCompetitiveness;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia;
use Tests\TestCase;
class ClusterCompetitivenessTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
}
private function manager(): User
{
$user = User::factory()->create();
config(['portal.managers' => [$user->email], 'portal.profile_access_enabled' => false]);
$this->actingAs($user);
return $user;
}
private function cluster(): EconomicCluster
{
return EconomicCluster::create(['code' => 'COMP-'.bin2hex(random_bytes(6)), 'name' => 'Cluster de teste de persistência', 'sector' => 'Energia', 'status' => 'planned']);
}
private function draft(EconomicCluster $cluster): array
{
$draft = ['version' => 1, 'clusterId' => $cluster->id, 'sector' => 'Energia', 'region' => 'Distrito de demonstração', 'forces' => []];
foreach (ClusterCompetitiveness::FORCES as $force) {
$draft['forces'][$force] = ['pressure' => 'medium', 'evidence' => 'Observação de teste, não publicável como facto real',
'source' => 'Fonte de demonstração p. 2', 'period' => '2025', 'rationale' => 'Apreciação de teste',
'limitations' => 'Dados sintéticos', 'opportunity' => 'Hipótese de demonstração'];
}
return $draft;
}
private function url(EconomicCluster $cluster): string
{
return '/management/clusters/'.$cluster->id.'/competitiveness';
}
public function test_save_recovers_from_database_across_requests_and_records_encrypted_history(): void
{
$actor = $this->manager();
$cluster = $this->cluster();
$draft = $this->draft($cluster);
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 0, 'draft' => $draft])
->assertOk()->assertJsonPath('version', 1)->assertJsonPath('draft', $draft)->assertJsonPath('approved', null);
$this->getJson($this->url($cluster))->assertOk()->assertHeader('Cache-Control', 'no-store, private')
->assertJsonPath('draft', $draft)->assertJsonPath('history.0.actor', $actor->id)->assertJsonPath('history.0.action', 'saved');
$this->get('/management/clusters/'.$cluster->id.'/edit')->assertInertia(fn (AssertableInertia $page) => $page
->component('structures/editor')->where('competitiveness.version', 1)->where('competitiveness.draft.region', $draft['region']));
$event = DB::table('audit_events')->where('source_system', 'cluster-competitiveness')->where('entity_id', $cluster->id)->first();
$this->assertStringNotContainsString($draft['forces']['rivalry']['evidence'], $event->after_data);
$this->assertSame('restricted', $event->data_classification);
$this->assertNull(app(ClusterCompetitiveness::class)->publicData($cluster->id));
}
public function test_validation_conflict_and_server_error_do_not_overwrite_the_saved_version(): void
{
$this->manager();
$cluster = $this->cluster();
$draft = $this->draft($cluster);
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 0, 'draft' => $draft])->assertOk();
$changed = $draft;
$changed['region'] = 'Outra região';
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 0, 'draft' => $changed])->assertConflict();
$changed['forces']['buyers']['pressure'] = 'zero';
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 1, 'draft' => $changed])->assertUnprocessable();
$changed = $draft;
$changed['clusterId'] = $this->cluster()->id;
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 1, 'draft' => $changed])->assertUnprocessable();
$this->getJson($this->url($cluster))->assertJsonPath('version', 1)->assertJsonPath('draft', $draft)->assertJsonCount(1, 'history');
}
public function test_approval_pins_content_and_later_drafts_and_restoration_do_not_publish(): void
{
$this->manager();
$cluster = $this->cluster();
$draft = $this->draft($cluster);
$url = $this->url($cluster);
$this->postJson($url.'/saved', ['expected_version' => 0, 'draft' => $draft])->assertOk();
$this->postJson($url.'/approved', ['expected_version' => 1, 'reason' => 'Aprovação sintética em QA'])->assertOk()->assertJsonPath('version', 2);
$changed = $draft;
$changed['region'] = 'Rascunho privado posterior';
$this->postJson($url.'/saved', ['expected_version' => 2, 'draft' => $changed])->assertOk();
$public = app(ClusterCompetitiveness::class)->publicData($cluster->id);
$this->assertSame($draft, $public['draft']);
$this->assertSame(['draft', 'version', 'at'], array_keys($public));
$this->postJson($url.'/restored', ['expected_version' => 3, 'revision' => 1])->assertOk()
->assertJsonPath('version', 4)->assertJsonPath('draft', $draft)->assertJsonPath('approved.version', 2);
$this->assertSame($public, app(ClusterCompetitiveness::class)->publicData($cluster->id));
$this->postJson($url.'/withdrawn', ['expected_version' => 4, 'reason' => 'Retirada sintética em QA'])->assertOk()->assertJsonCount(5, 'history');
$this->assertNull(app(ClusterCompetitiveness::class)->publicData($cluster->id));
$this->getJson($url)->assertJsonPath('draft', $draft);
}
public function test_incomplete_drafts_are_saved_but_not_approved_and_unknown_revisions_do_not_write(): void
{
$this->manager();
$cluster = $this->cluster();
$draft = $this->draft($cluster);
$draft['forces']['buyers']['source'] = '';
$draft['forces']['entrants']['pressure'] = '';
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 0, 'draft' => $draft])->assertOk();
$this->postJson($this->url($cluster).'/approved', ['expected_version' => 1, 'reason' => 'Pedido de aprovação QA'])->assertUnprocessable();
$this->postJson($this->url($cluster).'/restored', ['expected_version' => 1, 'revision' => 99])->assertNotFound();
$this->getJson($this->url($cluster))->assertJsonPath('version', 1)->assertJsonPath('draft.forces.buyers.source', '');
}
public function test_saved_data_keeps_the_portable_file_contract_and_rejects_missing_forces(): void
{
$this->manager();
$cluster = $this->cluster();
$draft = $this->draft($cluster);
$draft['version'] = '1';
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 0, 'draft' => $draft])
->assertOk()->assertJsonPath('draft.version', 1);
unset($draft['forces']['suppliers']);
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 1, 'draft' => $draft])->assertUnprocessable();
$this->getJson($this->url($cluster))->assertJsonPath('version', 1);
}
public function test_anonymous_and_unauthorised_users_cannot_read_history_or_mutate(): void
{
$cluster = $this->cluster();
$url = $this->url($cluster);
$this->getJson($url)->assertUnauthorized();
$this->postJson($url.'/saved', [])->assertUnauthorized();
$this->actingAs(User::factory()->create());
$this->getJson($url)->assertForbidden();
foreach (['saved', 'approved', 'withdrawn', 'restored'] as $action) {
$this->postJson($url.'/'.$action, [])->assertForbidden();
}
$this->assertSame(0, DB::table('audit_events')->where('source_system', 'cluster-competitiveness')->where('entity_id', $cluster->id)->count());
}
public function test_public_dossier_returns_only_the_approved_projection_and_unpublished_dossiers_remain_hidden(): void
{
$actor = $this->manager();
$cluster = $this->cluster();
$draft = $this->draft($cluster);
$dossier = InvestmentDossier::create(['cluster_id' => $cluster->id, 'title' => 'Ficha sintética QA', 'source' => 'Fonte sintética QA',
'content' => ['sector' => 'Energia'], 'publication_status' => 'published', 'approval_basis' => 'document_owner',
'approval_evidence' => 'Aprovação sintética só na base QA', 'published_at' => now()]);
$service = app(ClusterCompetitiveness::class);
$service->change($cluster, $actor->id, 0, 'saved', ['draft' => $draft]);
$this->app['auth']->forgetGuards();
$this->get('/carteira/'.$dossier->id)->assertInertia(fn (AssertableInertia $page) => $page->where('competitiveness', null));
$service->change($cluster, $actor->id, 1, 'approved', ['reason' => 'Aprovação privada de teste']);
$draft['region'] = 'CONTEÚDO PRIVADO NÃO APROVADO';
$service->change($cluster, $actor->id, 2, 'saved', ['draft' => $draft]);
$this->get('/carteira/'.$dossier->id)->assertInertia(fn (AssertableInertia $page) => $page
->where('competitiveness.version', 2)->where('competitiveness.draft.region', 'Distrito de demonstração')
->missing('competitiveness.actor')->missing('competitiveness.evidence')->missing('competitiveness.history'));
$dossier->update(['publication_status' => 'draft']);
$this->get('/carteira/'.$dossier->id)->assertNotFound();
$dossier->update(['publication_status' => 'published']);
$service->change($cluster, $actor->id, 3, 'withdrawn', ['reason' => 'Retirada privada de teste']);
$this->get('/carteira/'.$dossier->id)->assertInertia(fn (AssertableInertia $page) => $page->where('competitiveness', null));
}
}
@@ -0,0 +1,169 @@
<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\InvestmentGeography;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
use Tests\TestCase;
class ProvinceSearchCompatibilityTest extends TestCase
{
use DatabaseTransactions;
public function createApplication(): Application
{
$app = parent::createApplication();
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
throw new \LogicException('Isolated QA required');
}
return $app;
}
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
}
private function createOpportunity(?string $territory, string $prefix, string $status = 'published', ?string $province = null): Opportunity
{
return Opportunity::create(['sector_id' => 1, 'reference' => 'SYNTHETIC-QA-'.Str::uuid(), 'slug' => 'synthetic-'.Str::uuid(),
'title' => $prefix.' synthetic provincial search', 'status' => $status, 'published_at' => now()->subMinute(),
'primary_territory_id' => $territory, 'province' => $province, 'district' => null, 'geography_scope' => 'specific']);
}
private function requests(array $filters): array
{
$query = http_build_query([...$filters, 'per_page' => 50]);
return ['/api/v1/investment/search?'.$query, '/api/v1/opportunities/search?'.$query];
}
public function test_a_single_province_includes_all_lower_levels_and_linked_locations_in_both_apis(): void
{
$geo = app(InvestmentGeography::class);
$locality = $geo->publicReferences()->where('location_type', 'locality')->orderBy('id')->first();
$this->assertNotNull($locality, 'Public locality fixture required');
$path = $geo->path($locality->id);
$this->assertCount(4, $path);
$prefix = 'SYNTHETIC-'.Str::uuid();
$records = array_map(fn ($node) => $this->createOpportunity($node->id, $prefix, 'published', 'Legacy label differs from canonical hierarchy'), $path);
$linked = $this->createOpportunity(null, $prefix);
$linked->additionalTerritories()->attach($locality->id);
$records[] = $linked;
$private = $this->createOpportunity($locality->id, $prefix, 'draft');
$deleted = $this->createOpportunity($locality->id, $prefix);
$deleted->delete();
$otherProvince = $geo->publicReferences()->where('location_type', 'province')->where('id', '<>', $path[0]->id)->first();
$outside = $this->createOpportunity($otherProvince->id, $prefix);
foreach ([['province_id' => $path[0]->id], ['province' => $path[0]->name], ['geo_location_id' => $path[0]->id]] as $entry) {
foreach ($this->requests([...$entry, 'q' => $prefix]) as $url) {
$response = $this->getJson($url)->assertOk()->assertJsonPath('total', 5);
$this->assertEqualsCanonicalizing(array_map(fn ($row) => $row->id, $records), array_column($response->json('data'), 'id'), $url);
foreach ($response->json('data') as $row) {
$this->assertNull($row['district']);
}
foreach ([$private, $deleted, $outside] as $row) {
$this->assertNotContains($row->id, array_column($response->json('data'), 'id'));
}
}
}
foreach ($records as $record) {
$this->assertNull($record->fresh()->district);
$this->assertSame($record->primary_territory_id, $record->fresh()->primary_territory_id);
}
$this->assertSame([$locality->id], $linked->additionalTerritories()->pluck('geo_locations.id')->all());
foreach ($this->requests(['province_id' => $path[0]->id, 'district_id' => $path[1]->id, 'q' => $prefix]) as $url) {
// Province-only records are not silently assigned to a district.
$this->getJson($url)->assertOk()->assertJsonPath('total', 4);
}
foreach ($this->requests(['district_id' => $path[1]->id]) as $url) {
$this->getJson($url)->assertUnprocessable();
}
}
public function test_published_province_declaration_is_found_without_inventing_a_district_or_overriding_an_explicit_other_territory(): void
{
$geo = app(InvestmentGeography::class);
$province = $geo->publicReferences()->where('location_type', 'province')->first();
$other = $geo->publicReferences()->where('location_type', 'province')->where('id', '<>', $province->id)->first();
$district = $geo->publicReferences()->where('parent_id', $province->id)->where('location_type', 'district')->first();
$prefix = 'SYNTHETIC-'.Str::uuid();
$legacy = $this->createOpportunity(null, $prefix, 'published', $province->name);
$contradictory = $this->createOpportunity($other->id, $prefix, 'published', $province->name);
$linkedOutside = $this->createOpportunity(null, $prefix, 'published', $province->name);
$linkedOutside->additionalTerritories()->attach($other->id);
foreach ([['province' => $province->name], ['province_id' => $province->id], ['geo_location_id' => $province->id]] as $entry) {
foreach ($this->requests([...$entry, 'q' => $prefix]) as $url) {
$this->getJson($url)->assertOk()->assertJsonPath('total', 1)->assertJsonPath('data.0.id', $legacy->id)->assertJsonPath('data.0.district', null);
}
}
foreach ($this->requests(['province_id' => $province->id, 'district_id' => $district->id, 'q' => $prefix]) as $url) {
$this->getJson($url)->assertOk()->assertJsonPath('total', 0);
}
$this->assertNull($legacy->fresh()->primary_territory_id);
$this->assertNull($legacy->fresh()->district);
$this->assertSame($other->id, $contradictory->fresh()->primary_territory_id);
}
public function test_provincial_only_search_covers_the_eleven_provinces_without_including_neighbouring_provinces(): void
{
$geo = app(InvestmentGeography::class);
$provinces = $geo->publicReferences()->where('location_type', 'province')->get();
$this->assertCount(11, $provinces);
$prefix = 'SYNTHETIC-'.Str::uuid();
$records = [];
foreach ($provinces as $province) {
$district = $geo->publicReferences()->where('parent_id', $province->id)->where('location_type', 'district')->first();
$this->assertNotNull($district, $province->name);
$records[$province->id] = $this->createOpportunity($district->id, $prefix);
}
foreach ($provinces as $province) {
foreach ([['province_id' => $province->id], ['province' => $province->name]] as $entry) {
foreach ($this->requests([...$entry, 'q' => $prefix]) as $url) {
$this->getJson($url)->assertOk()->assertJsonPath('total', 1)->assertJsonPath('data.0.id', $records[$province->id]->id);
}
}
}
}
public function test_maputo_names_ids_page_map_and_export_share_the_existing_approved_corridor_coverage(): void
{
$provinceId = 'bfde2651-2b46-5d9c-a500-733fa55a8c5c';
$expected = Opportunity::published()->where('workflow_context->corridor_agribusiness->source_sha256', CorridorAgribusinessOpportunities::SOURCE_HASH)
->where('workflow_context->corridor_agribusiness->corridor', 'Corredor de Maputo')->pluck('id')->all();
$this->assertCount(5, $expected);
foreach ([['province' => 'Maputo Província'], ['province' => ' mApUtO Província '], ['province_id' => $provinceId]] as $entry) {
foreach ($this->requests([...$entry, 'sector' => 'agronegocio']) as $url) {
$response = $this->getJson($url)->assertOk()->assertJsonPath('total', 5);
$this->assertEqualsCanonicalizing($expected, array_column($response->json('data'), 'id'));
foreach ($response->json('data') as $row) {
$this->assertNull($row['district']);
$this->assertStringContainsString('contexto provincial do corredor', $row['territorial_summary']);
}
}
}
$query = http_build_query(['sector' => 'agronegocio', 'province' => 'Maputo Província']);
$this->get('/oportunidades?'.$query)->assertInertia(fn (AssertableInertia $page) => $page
->component('investment/catalogue')->where('filters.province_id', $provinceId)->missing('filters.province')
->has('territorySelection', 1)->where('results.total', 5));
$this->getJson('/api/v1/investment/map?'.$query)->assertOk()->assertJsonCount(0, 'features')->assertJsonCount(5, 'unlocated');
$csv = $this->get('/api/v1/investment/export?'.$query)->assertOk()->streamedContent();
foreach (Opportunity::whereIn('id', $expected)->get() as $row) {
$this->assertStringContainsString($row->title, $csv);
}
foreach ($this->requests(['sector' => 'turismo', 'province_id' => $provinceId]) as $url) {
$response = $this->getJson($url)->assertOk();
foreach ($response->json('data') as $row) {
$this->assertNotContains($row['id'], $expected);
}
}
}
}
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
assessmentReady,
diagnosticReady,
forces,
newCompetitivenessDraft,
readCompetitivenessDraft,
} from '../../resources/js/lib/cluster-competitiveness.ts';
function complete() {
const draft = newCompetitivenessDraft('cluster-a', 'Energia');
draft.region = 'Distrito de demonstração';
for (const force of forces)
Object.assign(draft.forces[force], {
pressure: 'medium',
evidence: 'Observação de demonstração',
source: 'Fonte de demonstração, p. 2',
period: '2025',
rationale: 'Pressão fundamentada na observação',
limitations: 'Amostra parcial',
opportunity: 'Investigar capacidade de armazenamento',
});
return draft;
}
test('ausência de dados permanece indeterminada nas cinco forças independentes', () => {
const draft = newCompetitivenessDraft('cluster-a', 'Energia');
assert.equal(forces.length, 5);
assert.equal(diagnosticReady(draft), false);
draft.forces.buyers.evidence = 'Observação';
assert.equal(draft.forces.suppliers.evidence, '');
assert.ok(forces.every((force) => draft.forces[force].pressure === ''));
});
test('classificação exige todos os fundamentos; âmbito é necessário para o diagnóstico', () => {
const draft = complete();
assert.equal(diagnosticReady(draft), true);
for (const field of [
'evidence',
'source',
'period',
'rationale',
'limitations',
'opportunity',
]) {
const row = { ...draft.forces.buyers, [field]: ' ' };
assert.equal(assessmentReady(row), false, field);
}
draft.region = '';
assert.equal(diagnosticReady(draft), false);
});
test('ficheiro conserva rascunhos, dados e incertezas sem modificar o original', () => {
const draft = complete();
draft.forces.entrants.pressure = '';
const restored = readCompetitivenessDraft(
JSON.stringify(draft),
'cluster-a',
);
assert.deepEqual(restored, draft);
restored.forces.buyers.source = 'Outra fonte';
assert.notEqual(restored.forces.buyers.source, draft.forces.buyers.source);
assert.equal(diagnosticReady(restored), false);
});
test('recuperação rejeita outro cluster, versões, forças ausentes e campos inválidos', () => {
assert.throws(() =>
readCompetitivenessDraft(JSON.stringify(complete()), 'cluster-b'),
);
for (const mutate of [
(d) => {
d.version = 2;
},
(d) => {
delete d.forces.buyers;
},
(d) => {
d.forces.buyers.pressure = 'zero';
},
(d) => {
d.forces.buyers.source = 42;
},
(d) => {
d.forces.buyers.source = 'x'.repeat(5001);
},
(d) => {
d.region = null;
},
]) {
const draft = complete();
mutate(draft);
assert.throws(() =>
readCompetitivenessDraft(JSON.stringify(draft), 'cluster-a'),
);
}
assert.throws(() => readCompetitivenessDraft('null', 'cluster-a'));
assert.throws(() =>
readCompetitivenessDraft('x'.repeat(250001), 'cluster-a'),
);
});