diff --git a/app/Http/Controllers/Api/V1/OpportunityController.php b/app/Http/Controllers/Api/V1/OpportunityController.php index bce21579..c62fee4b 100644 --- a/app/Http/Controllers/Api/V1/OpportunityController.php +++ b/app/Http/Controllers/Api/V1/OpportunityController.php @@ -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 diff --git a/app/Http/Controllers/ClusterCompetitivenessController.php b/app/Http/Controllers/ClusterCompetitivenessController.php new file mode 100644 index 00000000..92b859b2 --- /dev/null +++ b/app/Http/Controllers/ClusterCompetitivenessController.php @@ -0,0 +1,27 @@ +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'); + } +} diff --git a/app/Http/Controllers/InvestmentPortfolioController.php b/app/Http/Controllers/InvestmentPortfolioController.php index 3802d203..e9c32b67 100644 --- a/app/Http/Controllers/InvestmentPortfolioController.php +++ b/app/Http/Controllers/InvestmentPortfolioController.php @@ -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]); } diff --git a/app/Http/Controllers/InvestmentStructureController.php b/app/Http/Controllers/InvestmentStructureController.php index feaf6355..092adda7 100644 --- a/app/Http/Controllers/InvestmentStructureController.php +++ b/app/Http/Controllers/InvestmentStructureController.php @@ -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') diff --git a/app/Services/ClusterCompetitiveness.php b/app/Services/ClusterCompetitiveness.php new file mode 100644 index 00000000..44ac5916 --- /dev/null +++ b/app/Services/ClusterCompetitiveness.php @@ -0,0 +1,130 @@ +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; + } +} diff --git a/app/Services/InvestmentCatalogue.php b/app/Services/InvestmentCatalogue.php index 4db099fc..54cfbaed 100644 --- a/app/Services/InvestmentCatalogue.php +++ b/app/Services/InvestmentCatalogue.php @@ -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 */ 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(), diff --git a/app/Services/InvestmentGeography.php b/app/Services/InvestmentGeography.php index bf600145..6f61f07f 100644 --- a/app/Services/InvestmentGeography.php +++ b/app/Services/InvestmentGeography.php @@ -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. * diff --git a/docs/investmoz/consolidated-review-matrix-2026-10-03.md b/docs/investmoz/consolidated-review-matrix-2026-10-03.md index 1f1546e1..b9bc5c4e 100644 --- a/docs/investmoz/consolidated-review-matrix-2026-10-03.md +++ b/docs/investmoz/consolidated-review-matrix-2026-10-03.md @@ -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. \ No newline at end of file diff --git a/docs/investmoz/unification/competitiveness-persistence-qa-20261029.json b/docs/investmoz/unification/competitiveness-persistence-qa-20261029.json new file mode 100644 index 00000000..8e0e9712 --- /dev/null +++ b/docs/investmoz/unification/competitiveness-persistence-qa-20261029.json @@ -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 + } +} diff --git a/docs/investmoz/unification/competitiveness-sequential-qa-20261029.json b/docs/investmoz/unification/competitiveness-sequential-qa-20261029.json new file mode 100644 index 00000000..b937d50b --- /dev/null +++ b/docs/investmoz/unification/competitiveness-sequential-qa-20261029.json @@ -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 + } +} diff --git a/docs/investmoz/unification/province-search-qa-20261029.json b/docs/investmoz/unification/province-search-qa-20261029.json new file mode 100644 index 00000000..9baf0f0f --- /dev/null +++ b/docs/investmoz/unification/province-search-qa-20261029.json @@ -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" + } +} diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index c8c99a8b..a6f2752c 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -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. \ No newline at end of file diff --git a/docs/investmoz/v2/auditoria-portal/backlog.md b/docs/investmoz/v2/auditoria-portal/backlog.md index 747d0beb..b6141ffc 100644 --- a/docs/investmoz/v2/auditoria-portal/backlog.md +++ b/docs/investmoz/v2/auditoria-portal/backlog.md @@ -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. \ No newline at end of file diff --git a/resources/js/components/cluster-competitiveness-public.tsx b/resources/js/components/cluster-competitiveness-public.tsx new file mode 100644 index 00000000..d43f4a90 --- /dev/null +++ b/resources/js/components/cluster-competitiveness-public.tsx @@ -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 ( +
+

+ Competitividade — cinco forças de Porter +

+

+ {analysis.draft.sector} · {analysis.draft.region} · Versão + aprovada {analysis.version} · {analysis.at} +

+

+ Apreciação qualitativa fundamentada nas fontes e períodos + indicados. Não representa garantia de procura ou viabilidade. +

+ {forces.map((force) => { + const row = analysis.draft.forces[force]; + return ( +
+ + {forceLabels[force]} · Pressão{' '} + {pressures[row.pressure]} + +
+ {( + [ + ['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]) => ( +
+
{label}
+
+ {row[field]} +
+
+ ))} +
+
+ ); + })} +
+ ); +} diff --git a/resources/js/lib/cluster-competitiveness.ts b/resources/js/lib/cluster-competitiveness.ts new file mode 100644 index 00000000..6f5b4dd4 --- /dev/null +++ b/resources/js/lib/cluster-competitiveness.ts @@ -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; +}; + +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, + }; +} + +// 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; +} diff --git a/resources/js/pages/portfolio/show.tsx b/resources/js/pages/portfolio/show.tsx index 6d1a5104..d2a1c86f 100644 --- a/resources/js/pages/portfolio/show.tsx +++ b/resources/js/pages/portfolio/show.tsx @@ -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 (

Fonte: {record.source}

+ {competitiveness && } {!management && (
diff --git a/resources/js/pages/structures/competitiveness.tsx b/resources/js/pages/structures/competitiveness.tsx new file mode 100644 index 00000000..cc0cdef3 --- /dev/null +++ b/resources/js/pages/structures/competitiveness.tsx @@ -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 = { + 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( + initial ?? { version: 0, draft: null, approved: null, history: [] }, + ); + const [serverPending, setServerPending] = + useState(null); + const [restoreVersion, setRestoreVersion] = useState(null); + const [busy, setBusy] = useState(false); + const [reason, setReason] = useState(''); + const [pending, setPending] = useState(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 = {}, + ) { + setBusy(true); + try { + const state = await portalRequest( + `${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 ( +
+

+ Competitividade — cinco forças de Porter +

+

+ 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. +

+

+ Guarde o rascunho para o recuperar no servidor. A publicação + exige aprovação expressa; a classificação representa a + apreciação do autor. +

+

+ 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.'} +

+
+ + {draft.sector !== sector && ( +

+ O sector da análise difere do sector actual do cluster ( + {sector}). Confira o âmbito antes de reutilizar + conclusões. +

+ )} + +

+ {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.'} +

+ {forces.map((force) => ( +
+ + {forceLabels[force]} ·{' '} + {assessmentReady(draft.forces[force]) + ? 'Preenchida para revisão' + : 'Indeterminada / incompleta'} + +
+ + {Object.entries(labels).map(([field, label]) => ( +