Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aa98be617 | ||
|
|
fde7343894 | ||
|
|
e0987afb0e | ||
|
|
bad05eb092 | ||
|
|
b2812e5925 | ||
|
|
fdadc8ace7 | ||
|
|
979f3f850e | ||
|
|
b81f477bf2 | ||
|
|
568a488ae7 | ||
|
|
d96f9c6bf5 | ||
|
|
4875c4beb9 |
@@ -4,6 +4,9 @@ namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\InvestmentCatalogue;
|
||||
use App\Services\OpportunityMaturity;
|
||||
use App\Services\SectorOrganization;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -15,6 +18,11 @@ class OpportunityController extends Controller
|
||||
'q' => ['nullable', 'string', 'max:120'],
|
||||
'sector' => ['nullable', 'string', 'max:80'],
|
||||
'province' => ['nullable', 'string', 'max:80'],
|
||||
'province_id' => ['nullable', 'uuid'],
|
||||
'district_id' => ['nullable', 'uuid'],
|
||||
'admin_post_id' => ['nullable', 'uuid'],
|
||||
'locality_id' => ['nullable', 'uuid'],
|
||||
'geo_location_id' => ['nullable', 'uuid'],
|
||||
'maturity_level' => ['nullable', 'integer', 'between:1,6'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||
'min_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999'],
|
||||
@@ -47,22 +55,24 @@ class OpportunityController extends Controller
|
||||
|
||||
if (! empty($validated['sector'])) {
|
||||
$sector = $validated['sector'];
|
||||
\App\Services\SectorOrganization::constrain($query, $sector);
|
||||
SectorOrganization::constrain($query, $sector);
|
||||
}
|
||||
|
||||
if (! empty($validated['province'])) {
|
||||
$query->where('province', $validated['province']);
|
||||
$territory = array_intersect_key($validated, array_flip(['province', 'province_id', 'district_id', 'admin_post_id', 'locality_id', 'geo_location_id']));
|
||||
if (array_filter($territory, fn ($value) => $value !== null && $value !== '') !== []) {
|
||||
// Keep the legacy search's sorting and non-territorial filters while sharing the public territorial contract.
|
||||
$query->whereIn('opportunities.id', app(InvestmentCatalogue::class)->query($territory)->reorder()->select('opportunities.id'));
|
||||
}
|
||||
|
||||
if (! empty($validated['maturity_level'])) {
|
||||
\App\Services\OpportunityMaturity::constrainSearch($query, 'historical:'.$validated['maturity_level']);
|
||||
OpportunityMaturity::constrainSearch($query, 'historical:'.$validated['maturity_level']);
|
||||
}
|
||||
|
||||
$results = $query->orderByDesc('is_featured')
|
||||
->orderByDesc('published_at')
|
||||
->paginate($validated['per_page'] ?? 12);
|
||||
|
||||
return response()->json($results);
|
||||
return response()->json($results->withQueryString()->through(fn ($record) => app(InvestmentCatalogue::class)->presentLocation($record)));
|
||||
}
|
||||
|
||||
public function show(string $slug): JsonResponse
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\EconomicCluster;
|
||||
use App\Services\ClusterCompetitiveness;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class ClusterCompetitivenessController extends Controller
|
||||
{
|
||||
public function show(EconomicCluster $cluster, ClusterCompetitiveness $service): JsonResponse
|
||||
{
|
||||
return response()->json($service->management($cluster->id))->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
|
||||
public function change(Request $request, EconomicCluster $cluster, string $action, ClusterCompetitiveness $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['expected_version' => 'required|integer|min:0',
|
||||
'draft' => $action === 'saved' ? 'required|array:version,clusterId,sector,region,forces' : 'prohibited',
|
||||
'revision' => $action === 'restored' ? 'required|integer|min:1' : 'prohibited',
|
||||
'reason' => in_array($action, ['approved', 'withdrawn'], true) ? 'required|string|min:10|max:3000' : 'nullable|string|max:3000']);
|
||||
|
||||
return response()->json($service->change($cluster, $request->user()->id, (int) $data['expected_version'], $action, $data))
|
||||
->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,7 @@ class InvestmentPortfolioController extends Controller
|
||||
|
||||
return Inertia::render('portfolio/show', ['complements' => $management ? [] : app(\App\Services\InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
|
||||
'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions,
|
||||
'competitiveness' => $record->cluster_id ? app(\App\Services\ClusterCompetitiveness::class)->publicData($record->cluster_id) : null,
|
||||
'geography' => app(InvestmentRelations::class)->dossier($record, $management),
|
||||
'origin' => $origin ? ['id' => $origin->id, 'title' => $origin->title] : null, 'opportunities' => $opportunities, 'chains' => $chains]);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ class InvestmentStructureController extends Controller
|
||||
->whereNull('origins.deleted_at')->whereNull('destinations.deleted_at')->whereNull('value_chain_links.deleted_at')
|
||||
->orderBy('origins.code')->orderBy('destinations.code')
|
||||
->get(['value_chain_links.id', 'origins.name as origin', 'destinations.name as destination', 'link_type']) : [],
|
||||
'competitiveness' => $structure === 'clusters' && $id ? app(\App\Services\ClusterCompetitiveness::class)->management($id) : null,
|
||||
'viewerId' => $request->user()->id,
|
||||
'memberships' => $structure === 'clusters' && $id ? DB::table('cluster_projects')
|
||||
->join('projects', 'projects.id', '=', 'cluster_projects.project_id')
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\EconomicCluster;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class ClusterCompetitiveness
|
||||
{
|
||||
public const FORCES = ['rivalry', 'entrants', 'substitutes', 'suppliers', 'buyers'];
|
||||
|
||||
public const FIELDS = ['evidence', 'source', 'period', 'rationale', 'limitations', 'opportunity'];
|
||||
|
||||
private const SOURCE = 'cluster-competitiveness';
|
||||
|
||||
private function events(string $cluster): Builder
|
||||
{
|
||||
return DB::table('audit_events')->where('source_system', self::SOURCE)
|
||||
->where('entity_type', 'economic_clusters')->where('entity_id', $cluster);
|
||||
}
|
||||
|
||||
private function decode(?object $event): array
|
||||
{
|
||||
return $event ? json_decode(Crypt::decryptString(json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['snapshot']), true, 512, JSON_THROW_ON_ERROR)
|
||||
: ['version' => 0, 'draft' => null, 'approved' => null];
|
||||
}
|
||||
|
||||
public function current(string $cluster): array
|
||||
{
|
||||
return $this->decode($this->events($cluster)->orderByRaw("(after_data->>'version')::integer DESC")->first());
|
||||
}
|
||||
|
||||
public function management(string $cluster): array
|
||||
{
|
||||
$state = $this->current($cluster);
|
||||
$state['history'] = $this->events($cluster)->orderByRaw("(after_data->>'version')::integer DESC")->limit(50)->get()
|
||||
->map(fn ($event) => ['version' => json_decode($event->after_data, true)['version'],
|
||||
'action' => $event->action, 'at' => $event->occurred_at, 'actor' => $event->actor_user_id])->all();
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
public function validateDraft(array $draft, string $cluster): array
|
||||
{
|
||||
$rules = ['version' => 'required|integer|in:1', 'clusterId' => 'required|string|in:'.$cluster,
|
||||
'sector' => 'present|nullable|string|max:200', 'region' => 'present|nullable|string|max:1000',
|
||||
'forces' => 'required|array:'.implode(',', self::FORCES)];
|
||||
foreach (self::FORCES as $force) {
|
||||
$rules["forces.$force"] = 'required|array:pressure,'.implode(',', self::FIELDS);
|
||||
$rules["forces.$force.pressure"] = 'present|nullable|in:low,medium,high';
|
||||
foreach (self::FIELDS as $field) {
|
||||
$rules["forces.$force.$field"] = 'present|nullable|string|max:5000';
|
||||
}
|
||||
}
|
||||
$clean = Validator::make($draft, $rules)->validate();
|
||||
$clean['version'] = 1;
|
||||
$clean['sector'] = trim($clean['sector'] ?? '');
|
||||
$clean['region'] = trim($clean['region'] ?? '');
|
||||
foreach (self::FORCES as $force) {
|
||||
foreach (['pressure', ...self::FIELDS] as $field) {
|
||||
$clean['forces'][$force][$field] = trim($clean['forces'][$force][$field] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
private function assertComplete(?array $draft): void
|
||||
{
|
||||
$missing = ! $draft || ! $draft['sector'] || ! $draft['region'];
|
||||
foreach (self::FORCES as $force) {
|
||||
$row = $draft['forces'][$force] ?? [];
|
||||
$missing = $missing || ! in_array($row['pressure'] ?? '', ['low', 'medium', 'high'], true);
|
||||
foreach (self::FIELDS as $field) {
|
||||
$missing = $missing || trim($row[$field] ?? '') === '';
|
||||
}
|
||||
}
|
||||
if ($missing) {
|
||||
throw ValidationException::withMessages(['approval' => 'Complete o âmbito e as evidências, fontes, períodos, justificação, limitações e oportunidades das cinco forças antes de aprovar.']);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reuse the portal's encrypted, append-only audit snapshots; no new schema or mutable publication fields. */
|
||||
public function change(EconomicCluster $cluster, int $actor, int $expected, string $action, array $input = []): array
|
||||
{
|
||||
return DB::transaction(function () use ($cluster, $actor, $expected, $action, $input) {
|
||||
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
|
||||
$before = $this->current($cluster->id);
|
||||
abort_if($before['version'] !== $expected, 409, 'A análise foi alterada. Conserve os campos actuais e compare a versão do servidor antes de voltar a guardar.');
|
||||
$state = $before;
|
||||
if ($action === 'saved') {
|
||||
$state['draft'] = $this->validateDraft($input['draft'], $cluster->id);
|
||||
} elseif ($action === 'restored') {
|
||||
$event = $this->events($cluster->id)->where('after_data->version', $input['revision'])->first();
|
||||
abort_unless($event, 404);
|
||||
$state['draft'] = $this->decode($event)['draft'];
|
||||
} elseif ($action === 'approved') {
|
||||
$this->assertComplete($state['draft']);
|
||||
$state['approved'] = ['draft' => $state['draft'], 'at' => now()->toISOString(), 'actor' => $actor,
|
||||
'version' => $expected + 1, 'evidence' => $input['reason']];
|
||||
} elseif ($action === 'withdrawn') {
|
||||
$state['approved'] = null;
|
||||
} else {
|
||||
throw new \LogicException('Unsupported competitiveness action');
|
||||
}
|
||||
$state['version']++;
|
||||
DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'economic_clusters',
|
||||
'entity_id' => $cluster->id, 'external_id' => $cluster->id.':'.$state['version'], 'actor_user_id' => $actor,
|
||||
'action' => $action, 'data_classification' => 'restricted',
|
||||
'before_data' => json_encode(['version' => $before['version']], JSON_THROW_ON_ERROR),
|
||||
'after_data' => json_encode(['version' => $state['version'], 'restored_from' => $input['revision'] ?? null,
|
||||
'reason' => Crypt::encryptString($input['reason'] ?? ''),
|
||||
'snapshot' => Crypt::encryptString(json_encode($state, JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR)]);
|
||||
|
||||
return $this->management($cluster->id);
|
||||
});
|
||||
}
|
||||
|
||||
public function publicData(string $cluster): ?array
|
||||
{
|
||||
$approved = $this->current($cluster)['approved'];
|
||||
|
||||
// Explicit projection: never return editor identity, approval evidence, history or the unapproved draft.
|
||||
return $approved ? ['draft' => $approved['draft'], 'version' => $approved['version'], 'at' => $approved['at']] : null;
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,14 @@ namespace App\Services;
|
||||
use App\Http\Controllers\InvestmentPortfolioController;
|
||||
use App\Models\InvestmentDossier;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Sector;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class InvestmentCatalogue
|
||||
{
|
||||
@@ -17,8 +22,8 @@ class InvestmentCatalogue
|
||||
/** @return array<string,mixed> */
|
||||
public function filters(Request $request): array
|
||||
{
|
||||
$filters = $request->validate(['perspective' => ['nullable', 'in:exists,needs,ideas,opportunities'],'q' => ['nullable', 'string', 'max:120'], 'sector' => ['nullable', 'string', 'max:80'],
|
||||
'preparation' => ['nullable', \Illuminate\Validation\Rule::in(array_column(OpportunityMaturity::searchOptions(), 'value'))],
|
||||
$filters = $request->validate(['perspective' => ['nullable', 'in:exists,needs,ideas,opportunities'], 'q' => ['nullable', 'string', 'max:120'], 'sector' => ['nullable', 'string', 'max:80'],
|
||||
'preparation' => ['nullable', Rule::in(array_column(OpportunityMaturity::searchOptions(), 'value'))],
|
||||
'opportunity_id' => ['nullable', 'integer', 'min:1'],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
|
||||
'include_national' => ['nullable', 'boolean'], 'territory_basis' => ['nullable', 'in:all,direct,context'],
|
||||
@@ -40,14 +45,15 @@ class InvestmentCatalogue
|
||||
'category' => ['nullable', 'in:'.implode(',', OpportunityManagement::CATEGORIES)],
|
||||
'publication_state' => ['nullable', 'in:published'],
|
||||
'per_page' => ['nullable', 'integer', 'between:1,50'], 'page' => ['nullable', 'integer', 'min:1']]);
|
||||
$filters = app(InvestmentGeography::class)->provinceSearch($filters);
|
||||
app(InvestmentGeography::class)->selection($filters);
|
||||
$aliases = ['park' => 'park_id', 'cluster' => 'cluster_id', 'corridor' => 'corridor_id', 'project' => 'project_id'];
|
||||
$alias = $aliases[$filters['association_type'] ?? ''] ?? null;
|
||||
if ($alias && ! empty($filters['association_target']) && empty($filters['association_purpose']) && empty($filters['association_state'])) {
|
||||
if (! empty($filters[$alias]) && $filters[$alias] !== $filters['association_target']) {
|
||||
throw \Illuminate\Validation\ValidationException::withMessages([$alias => 'Foram indicadas duas selecções diferentes para o mesmo filtro. Escolha apenas uma.']);
|
||||
throw ValidationException::withMessages([$alias => 'Foram indicadas duas selecções diferentes para o mesmo filtro. Escolha apenas uma.']);
|
||||
}
|
||||
if (\Illuminate\Support\Str::isUuid($filters['association_target'])) {
|
||||
if (Str::isUuid($filters['association_target'])) {
|
||||
$filters[$alias] = $filters['association_target'];
|
||||
unset($filters['association_type'], $filters['association_target']);
|
||||
}
|
||||
@@ -62,9 +68,13 @@ class InvestmentCatalogue
|
||||
*/
|
||||
public function query(array $filters): Builder
|
||||
{
|
||||
$filters = app(InvestmentGeography::class)->provinceSearch($filters);
|
||||
$query = Opportunity::published()->with('sector:id,name,slug');
|
||||
if (in_array($filters['perspective'] ?? '', ['exists','needs','ideas'], true)) $query->where('workflow_context->promotional_summary->projection->proactive->group', $filters['perspective']);
|
||||
elseif (($filters['perspective'] ?? '') === 'opportunities') $query->whereNull('workflow_context->promotional_summary->projection->proactive');
|
||||
if (in_array($filters['perspective'] ?? '', ['exists', 'needs', 'ideas'], true)) {
|
||||
$query->where('workflow_context->promotional_summary->projection->proactive->group', $filters['perspective']);
|
||||
} elseif (($filters['perspective'] ?? '') === 'opportunities') {
|
||||
$query->whereNull('workflow_context->promotional_summary->projection->proactive');
|
||||
}
|
||||
$associated = $this->publicAssociatedIds($filters);
|
||||
if (($filters['territory_basis'] ?? 'all') === 'direct') {
|
||||
$query->where(fn ($q) => $q->whereNotNull('opportunities.primary_territory_id')->orWhereExists(fn ($l) => $l->selectRaw('1')->from('opportunity_locations')->whereColumn('opportunity_id', 'opportunities.id')));
|
||||
@@ -86,9 +96,14 @@ class InvestmentCatalogue
|
||||
$territory->whereIn('opportunities.primary_territory_id', $ids)
|
||||
->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('opportunity_locations as territory_link')
|
||||
->whereColumn('territory_link.opportunity_id', 'opportunities.id')->whereIn('territory_link.geo_location_id', $ids));
|
||||
if (count($path) === 1) {
|
||||
$this->declaredProvince($territory, $path[0]->name);
|
||||
}
|
||||
if (($filters['territory_basis'] ?? 'all') !== 'direct') {
|
||||
app(InvestmentRelations::class)->opportunityTerritories($territory, $ids);
|
||||
if (count($path) === 1) $this->approvedCorridorProvince($territory, $path[0]->id);
|
||||
if (count($path) === 1) {
|
||||
$this->approvedCorridorProvince($territory, $path[0]->id);
|
||||
}
|
||||
}
|
||||
if ($includeNational) {
|
||||
$territory->orWhere('opportunities.geography_scope', 'national');
|
||||
@@ -163,6 +178,10 @@ class InvestmentCatalogue
|
||||
->join('geo_locations as target', 'target.id', '=', 'link.geo_location_id')
|
||||
->whereColumn('link.opportunity_id', 'opportunities.id')->whereIn('link.geo_location_id', $locationIds ?? [])
|
||||
->whereNull('target.deleted_at')->where('target.data_classification', 'public'));
|
||||
$province = app(InvestmentGeography::class)->publicReferences()->where('id', $filters['geo_location_id'])->where('location_type', 'province')->first(['name']);
|
||||
if ($province) {
|
||||
$this->declaredProvince($matches, $province->name);
|
||||
}
|
||||
if (($filters['territory_basis'] ?? 'all') !== 'direct') {
|
||||
app(InvestmentRelations::class)->opportunityTerritories($matches, $locationIds ?? []);
|
||||
if (app(InvestmentGeography::class)->publicReferences()->where('id', $filters['geo_location_id'])->where('location_type', 'province')->exists()) {
|
||||
@@ -206,7 +225,9 @@ class InvestmentCatalogue
|
||||
if (! empty($filters['before'])) {
|
||||
$query->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_profiles')->whereColumn('opportunity_id', 'opportunities.id')->where('target_date', '<=', $filters['before']));
|
||||
}
|
||||
if (! empty($filters['category'])) $query->where('opportunity_category', $filters['category']);
|
||||
if (! empty($filters['category'])) {
|
||||
$query->where('opportunity_category', $filters['category']);
|
||||
}
|
||||
app(OpportunityClassification::class)->constrain($query, $filters);
|
||||
$sort = $filters['sort'] ?? 'recent';
|
||||
|
||||
@@ -218,14 +239,28 @@ class InvestmentCatalogue
|
||||
};
|
||||
}
|
||||
|
||||
private function declaredProvince($query, string $name): void
|
||||
{
|
||||
// Read the published province declaration only when no more precise territory contradicts it.
|
||||
// This does not assign any canonical ID, district, post, locality or coordinates to the record.
|
||||
$query->orWhere(fn ($declared) => $declared->whereNull('opportunities.primary_territory_id')
|
||||
->whereNotExists(fn ($links) => $links->selectRaw('1')->from('opportunity_locations')->whereColumn('opportunity_id', 'opportunities.id'))
|
||||
->where('opportunities.geography_scope', '!=', 'national')
|
||||
->whereRaw('lower(btrim(opportunities.province)) = ?', [mb_strtolower(trim($name))]));
|
||||
}
|
||||
|
||||
private function approvedCorridorProvince($query, string $provinceId): void
|
||||
{
|
||||
$code = app(InvestmentGeography::class)->publicReferences()->where('id', $provinceId)->value('source_code');
|
||||
$names = [];
|
||||
foreach (config('investment_corridors', []) as $entry) {
|
||||
if (in_array($code, $entry['provinces'], true)) $names[] = $entry['name'];
|
||||
if (in_array($code, $entry['provinces'], true)) {
|
||||
$names[] = $entry['name'];
|
||||
}
|
||||
}
|
||||
if (! $names) {
|
||||
return;
|
||||
}
|
||||
if (!$names) return;
|
||||
$query->orWhere(fn ($context) => $context->whereNull('opportunities.primary_territory_id')
|
||||
->whereNotExists(fn ($link) => $link->selectRaw('1')->from('opportunity_locations')->whereColumn('opportunity_id', 'opportunities.id'))
|
||||
->where('workflow_context->corridor_agribusiness->source_sha256', CorridorAgribusinessOpportunities::SOURCE_HASH)
|
||||
@@ -240,13 +275,19 @@ class InvestmentCatalogue
|
||||
$ids = array_filter([$record->primary_territory_id, ...$record->additionalTerritories()->pluck('geo_locations.id')->all()]);
|
||||
foreach (array_unique($ids) as $id) {
|
||||
$path = $geo->path($id);
|
||||
if ($path) $paths[] = implode(' / ', array_column($path, 'name'));
|
||||
if ($path) {
|
||||
$paths[] = implode(' / ', array_column($path, 'name'));
|
||||
}
|
||||
}
|
||||
if ($paths) $label = implode('; ', array_unique($paths));
|
||||
elseif ($provinces = app(InvestmentCorridorContext::class)->catalogueProvinces($record)) {
|
||||
if ($paths) {
|
||||
$label = implode('; ', array_unique($paths));
|
||||
} elseif ($provinces = app(InvestmentCorridorContext::class)->catalogueProvinces($record)) {
|
||||
$label = implode(' / ', array_column($provinces, 'name')).' — contexto provincial do corredor; distrito e posto administrativo não indicados';
|
||||
} else $label = implode(' / ', array_filter([$record->province, $record->district])) ?: 'Localização não indicada';
|
||||
} else {
|
||||
$label = implode(' / ', array_filter([$record->province, $record->district])) ?: 'Localização não indicada';
|
||||
}
|
||||
$record->setAttribute('territorial_summary', $label);
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
@@ -260,9 +301,9 @@ class InvestmentCatalogue
|
||||
'authorities' => DB::table('authorities')->where('status', 'published')->get(['id', 'label_pt_mz']),
|
||||
'categories' => OpportunityManagement::CATEGORIES,
|
||||
'options' => DB::table('investment_reference_options')->when(! $management, fn ($q) => $q->where('active', true))->orderBy('sort_order')->orderBy('label')->get(),
|
||||
'sectors' => \App\Models\Sector::choices()->whereNotNull('published_at')->orderBy('name')->get(['id', 'name', 'slug']),
|
||||
'maturityCatalogue' => ['version'=>OpportunityMaturity::VERSION, 'stages'=>OpportunityMaturity::STAGES,
|
||||
'legacyVersion'=>OpportunityMaturity::LEGACY, 'legacyLabels'=>OpportunityMaturity::LEGACY_LABELS]];
|
||||
'sectors' => Sector::choices()->whereNotNull('published_at')->orderBy('name')->get(['id', 'name', 'slug']),
|
||||
'maturityCatalogue' => ['version' => OpportunityMaturity::VERSION, 'stages' => OpportunityMaturity::STAGES,
|
||||
'legacyVersion' => OpportunityMaturity::LEGACY, 'legacyLabels' => OpportunityMaturity::LEGACY_LABELS]];
|
||||
foreach (self::RELATIONS as $relation => [$foreign, $table]) {
|
||||
$query = DB::table($table)->whereNull('deleted_at');
|
||||
if (! $management) {
|
||||
@@ -295,7 +336,7 @@ class InvestmentCatalogue
|
||||
$zones = app(InvestmentZones::class)->visible();
|
||||
if (! $management && $territoryFilters !== []) {
|
||||
// Opportunity words/modalities are not names/categories of zones.
|
||||
$zones = app(InvestmentZones::class)->search(\Illuminate\Support\Arr::only($territoryFilters,
|
||||
$zones = app(InvestmentZones::class)->search(Arr::only($territoryFilters,
|
||||
['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'geo_location_id']));
|
||||
}
|
||||
$result['zones'] = $zones->orderBy('name')->get(['id', 'name', 'category']);
|
||||
@@ -318,24 +359,35 @@ class InvestmentCatalogue
|
||||
'cluster_id' => ['cluster'], 'corridor_id' => ['corridor'], 'project_id' => ['project']];
|
||||
$selected = array_filter(array_intersect_key($filters, $kinds));
|
||||
foreach (['zee_id' => 'zee', 'zfi_id' => 'zfi', 'park_id' => 'industrial_park'] as $key => $category) {
|
||||
if (isset($selected[$key]) && ! app(InvestmentZones::class)->visible()->whereKey($selected[$key])->where('category', $category)->exists()) unset($selected[$key]);
|
||||
if (isset($selected[$key]) && ! app(InvestmentZones::class)->visible()->whereKey($selected[$key])->where('category', $category)->exists()) {
|
||||
unset($selected[$key]);
|
||||
}
|
||||
}
|
||||
if ($selected === []) {
|
||||
return [];
|
||||
}
|
||||
if ($selected === []) return [];
|
||||
$ids = [];
|
||||
Opportunity::published()->select('opportunities.*')->chunkById(200, function ($records) use ($selected, $kinds, &$ids) {
|
||||
foreach ($records as $record) {
|
||||
foreach (app(OpportunityClassification::class)->publicRows($record) as $row) {
|
||||
if (in_array($row['purpose'], ['proximity', 'administrative_boundary'], true) || $row['state'] === 'rejected') continue;
|
||||
if (in_array($row['purpose'], ['proximity', 'administrative_boundary'], true) || $row['state'] === 'rejected') {
|
||||
continue;
|
||||
}
|
||||
foreach ($selected as $field => $target) {
|
||||
if ($row['target_id'] === $target && in_array($row['target_type'], $kinds[$field], true)) $ids[$field][] = $record->id;
|
||||
if ($row['target_id'] === $target && in_array($row['target_type'], $kinds[$field], true)) {
|
||||
$ids[$field][] = $record->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!empty($selected['corridor_id'])) {
|
||||
if (! empty($selected['corridor_id'])) {
|
||||
$dossier = InvestmentPortfolioController::visible()->where('corridor_id', $selected['corridor_id'])->first();
|
||||
if ($dossier) $ids['corridor_id'] = array_values(array_unique([...($ids['corridor_id'] ?? []), ...app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($dossier)]));
|
||||
if ($dossier) {
|
||||
$ids['corridor_id'] = array_values(array_unique([...($ids['corridor_id'] ?? []), ...app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($dossier)]));
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
@@ -359,9 +411,12 @@ class InvestmentCatalogue
|
||||
$public = ['profile' => null, 'facets' => collect(), 'zones' => collect(), 'classification' => [], 'source_evidence' => [],
|
||||
'primary_territory_path' => app(InvestmentGeography::class)->path($opportunity->primary_territory_id, true),
|
||||
'opportunity_locations' => app(InvestmentRelations::class)->opportunityLocations($id)];
|
||||
foreach (self::RELATIONS as $relation => $_) $public[$relation] = collect();
|
||||
foreach (self::RELATIONS as $relation => $_) {
|
||||
$public[$relation] = collect();
|
||||
}
|
||||
$public['locations'] = DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id')
|
||||
->where('l.opportunity_id', $id)->where('g.data_classification', 'public')->whereNull('g.deleted_at')->get(['g.id', 'g.name']);
|
||||
|
||||
return $public;
|
||||
}
|
||||
$result = ['profile' => DB::table('opportunity_profiles')->where('opportunity_id', $id)->first(),
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -1842,4 +1842,62 @@ Origem reconciliada: main remoto e revisão pública conferidos em 332c16eb em 2
|
||||
|
||||
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.
|
||||
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.
|
||||
### 29/10/2026 — filtros provinciais corrigidos e confirmados por API no portal: fde73438
|
||||
|
||||
**Estado deste recorte V2-07/V2-10 / ACT-02:** correcção de código/API implementada, instalada e confirmada em https://teste-investidor.tempo.co.mz/. Avanço normal de main e pipeline existente, versão anterior b81f477b preservada. A selecção provincial por nome público inequívoco ou identificador usa a mesma hierarquia nas APIs /api/v1/investment/search e /api/v1/opportunities/search. Inclui localizações dos níveis inferiores e localizações adicionais; preserva publicação/sector e exclui registos privados, eliminados e outras províncias. Declaração provincial legada não ganha localização distrital nem prevalece sobre território explícito contraditório. Nenhum cadastro foi alterado por esta correcção.
|
||||
|
||||
**Prova HTTP específica:** quatro testes/364 asserções na QA restaurada province_search_20261029_test, porta55441: província/distrito/posto/localidade, localizações adicionais, onze províncias, exclusões, invariância dos dados, catálogo/mapa/exportação. Candidato QA e0987afb; código instalado coincide por SHA-256 com o testado (delta posterior documental). Não substituir esta prova pelos 1.180 testes frontend do pipeline. Pipeline habitual passou schema1/3106, Unit59/320, frontend1180, tipos e compilação.
|
||||
|
||||
**Confirmação no instalado:** comparadas as quatro entradas (duas APIs × nome/ID) para cada uma das11 províncias, com resultados idênticos por província. Agronegócio/Maputo Província: todas devolvem agora5 oportunidades, IDs60–64, corrigindo0 por nome e34 na API antiga que ignorava o ID. Continuam identificadas como contexto provincial do corredor, com district e primary_territory_id nulos; mapa conserva5 sem ponto utilizável e não gera coordenadas. O export respondeu. A inclusão efectiva de níveis inferiores foi demonstrada com fixtures sintéticas exclusivamente na QA, não com dados fictícios no activo. Inhambane devolve0 neste recorte cadastral Agronegócio, de forma consistente nas quatro entradas; isto não é conclusão de ausência de potencial económico.
|
||||
|
||||
**Preservação:** 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. Os4 ficheiros do incremento e10 ficheiros das provas aceites de Competitividade foram conferidos por hash no instalado; C132 passou e catálogo privado anónimo permanece HTTP401. Zero migrações/importações, novas divulgações, mudanças de permissões ou escritas sintéticas no activo. Recibo completo: docs/investmoz/unification/province-search-qa-20261029.json. Autenticação SSH renovada após reabertura expressamente pedida pela Tempo; a recusa anterior do lançamento automático não é bloqueio corrente.
|
||||
|
||||
**Separação mantida:** este resultado encerra a lacuna técnica de compatibilidade/expansão provincial descrita acima, sem declarar conferência visual dos controlos e sem encerrar os requisitos amplos ACT-02/V2-07/V2-10. A conferência visual autenticada de Competitividade continua **pendente — execução e registo pela equipa**. As provas aceites dessa funcionalidade não foram reabertas nem substituídas pela correcção dos filtros. Diagnóstico da ligação Edge continua separado; leitura de URL não declarada recuperada. Nenhum processo local foi terminado nem porta libertada; InvesteMoz e SGF preservados.
|
||||
|
||||
Para o registo da equipa: no cluster autorizado, editar conteúdo legítimo → Guardar rascunho no servidor → sair e reabrir → comparar campos/versão recuperados → conferir na ficha pública exclusivamente a versão aprovada. Registar utilizador/âmbito autorizado, URL/cluster, versão instalada, versões guardada/aprovada, resultado e divergências. Uma nova gravação não deve substituir a aprovação pública existente; nova aprovação depende da decisão de conteúdo aplicável. Sem exemplar aprovado disponível, manter essa parte da conferência pendente em vez de publicar dados sintéticos.
|
||||
@@ -0,0 +1,177 @@
|
||||
{
|
||||
"checks": {
|
||||
"feature_tests": 7,
|
||||
"isolated_build": "passed",
|
||||
"feature_assertions": 93,
|
||||
"frontend_tests": 4,
|
||||
"types": "passed"
|
||||
},
|
||||
"tested_candidate": "568a488ae76c631714547614f52ca6be7a121e2d",
|
||||
"schema_changes": 0,
|
||||
"active_writes": 0,
|
||||
"source_sha256": {
|
||||
"tests/Feature/ClusterCompetitivenessTest.php": "3c4b27fbc2c00210bd6be78057d88d03f54676c82907fafcbe57843ea7d726cb",
|
||||
"app/Http/Controllers/InvestmentPortfolioController.php": "0793d06bf94b679b301f4aedbd54d73184ed7b27d3100036dfc81f4621c101d6",
|
||||
"resources/js/pages/portfolio/show.tsx": "6562798defc1546466b1ed3c66e0d51b1c6c1b08f03c8d5acc915877792ce7da",
|
||||
"routes/web.php": "76501452ad4ce187ce234127ef160a5a458f629d583d9ccb3c67c1c9d4a1a561",
|
||||
"resources/js/components/cluster-competitiveness-public.tsx": "85ab2f59de4c63faf6ea699855bbcf0c9efaea0ff9c39421d5640286e9df5910",
|
||||
"app/Http/Controllers/ClusterCompetitivenessController.php": "12ab13860da1acda02dd77f38d0ddd0d1b8fca5856f7fa8823e9e6193143368e",
|
||||
"app/Services/ClusterCompetitiveness.php": "24cae918c14a84470b7fc2e1485eacd9c4e2306a54928ca9de6a56576f5227df",
|
||||
"resources/js/pages/structures/editor.tsx": "c6d50445de3c1f18b266afe5898cd31198fc5946606770ac74a5f01e6e67b712",
|
||||
"resources/js/pages/structures/competitiveness.tsx": "8c4253cef8450b47245a918712bb81fe0ff2a333f54726a38239264a398f4fde",
|
||||
"app/Http/Controllers/InvestmentStructureController.php": "b0d11991240fa5f6fa0d56a4c66b63f68665d0687b3772b4f8ca632460499930"
|
||||
},
|
||||
"baseline": "23ecccfaad1053f55bf0591fd242cb5fa3406c38",
|
||||
"provincial_filters": "separate_and_open_not_changed_or_verified",
|
||||
"remaining": [
|
||||
"authenticated visual conference of the installed management workflow",
|
||||
"factual sector and regional analysis and explicit content approvals",
|
||||
"provincial filter issue remains separate and open",
|
||||
"remaining planned ACT activities"
|
||||
],
|
||||
"status": "installed_specific_persistence_QA_and_preservation_verified_visual_pending",
|
||||
"proof": {
|
||||
"candidate": "568a488ae76c631714547614f52ca6be7a121e2d",
|
||||
"status": "passed",
|
||||
"database": "competitiveness_persistence_20261029_test",
|
||||
"port": 55441,
|
||||
"snapshot_restored": true,
|
||||
"active_database_writes": 0,
|
||||
"schema_changes": 0,
|
||||
"log": "/var/lib/investmoz-deploy/competitiveness-persistence-qa-20261029/qa-568a488ae76c631714547614f52ca6be7a121e2d.log",
|
||||
"summary": "PHPUnit 12.5.34 by Sebastian Bergmann and contributors.\n\nRuntime: PHP 8.4.27\nConfiguration: /var/lib/investmoz-deploy/competitiveness-persistence-qa-20261029/568a488ae76c631714547614f52ca6be7a121e2d-1793273608890561258/phpunit.xml\n\n....... 7 / 7 (100%)\n\nTime: 00:04.973, Memory: 58.50 MB\n\n\u001b[30;42mOK (7 tests, 93 assertions)\u001b[0m\n"
|
||||
},
|
||||
"installed_candidate": "b81f477bf2385227ed3099868e80b1e3c7bcb46b",
|
||||
"backup": {
|
||||
"stage": "/var/backups/investmoz-competitiveness-persistence-20261029/backup",
|
||||
"created_at": "2026-10-29T11:32:49Z",
|
||||
"target": {
|
||||
"database": "teste_investidor",
|
||||
"port": 5432
|
||||
},
|
||||
"tables": 176,
|
||||
"files_verified": 369,
|
||||
"dump_sha256": "e92123a3b986781de20367a04462ed7b92958c47f232326da18506a79a43717c",
|
||||
"files_sha256": "bb615875347f7b27da593f148af973fc825555524a261f8f68c50cd4ce26962c",
|
||||
"restore_verified": false
|
||||
},
|
||||
"restore": {
|
||||
"restore_verified": true,
|
||||
"tables_verified": 176,
|
||||
"container_network": "none",
|
||||
"database": "competitiveness_persistence_restore_20261029_test",
|
||||
"port": 55441,
|
||||
"source_dump_sha256": "e92123a3b986781de20367a04462ed7b92958c47f232326da18506a79a43717c",
|
||||
"active_database_writes": 0
|
||||
},
|
||||
"code": {
|
||||
"candidate": "b81f477bf2385227ed3099868e80b1e3c7bcb46b",
|
||||
"source_files_verified": 10,
|
||||
"diagnostics_C132": {
|
||||
"result": "passed",
|
||||
"case": "C132",
|
||||
"source_explicit_constant_OFF": true,
|
||||
"effective_configuration_OFF": true,
|
||||
"configuration_cached": false,
|
||||
"database_connections_used": 0,
|
||||
"active_writes": 0
|
||||
},
|
||||
"active_writes": 0
|
||||
},
|
||||
"runtime": {
|
||||
"identity": {
|
||||
"database": "teste_investidor",
|
||||
"role": "teste_investidor_app",
|
||||
"port": 5432,
|
||||
"audit_read": true,
|
||||
"audit_append": true,
|
||||
"cluster_lock": true
|
||||
},
|
||||
"management_read_from_database": true,
|
||||
"server_version": 0,
|
||||
"history_items": 0,
|
||||
"approved_projection_available": false,
|
||||
"actor_impersonation": false,
|
||||
"active_domain_writes": 0,
|
||||
"candidate": "b81f477bf2385227ed3099868e80b1e3c7bcb46b",
|
||||
"anonymous_management_status": 401,
|
||||
"persistence_actions_compiled": true,
|
||||
"specific_write_recovery_and_public_projection_QA": "7 tests / 93 assertions; no synthetic writes on active database",
|
||||
"provincial_filters": "separate and open; not changed or confirmed here",
|
||||
"authenticated_visual_conference": "pending"
|
||||
},
|
||||
"preservation": {
|
||||
"navigation_asset_and_shared_props_verified": true,
|
||||
"at": "2026-10-29T11:42:15.258836+00:00",
|
||||
"served_commit": "b81f477bf2385227ed3099868e80b1e3c7bcb46b",
|
||||
"backup_dump_sha256": "e92123a3b986781de20367a04462ed7b92958c47f232326da18506a79a43717c",
|
||||
"prior_public_tables_checked": 176,
|
||||
"prior_domain_rows_preserved": true,
|
||||
"new_migration_registry_rows": 0,
|
||||
"operational_delta": {
|
||||
"cache": {
|
||||
"before": {
|
||||
"rows": 119,
|
||||
"checksum": "5ddea5ecf35f8dc04d275b189821b59a"
|
||||
},
|
||||
"after": {
|
||||
"rows": 119,
|
||||
"checksum": "8336a458d425e14c66b20c3da9aa01de"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"before": {
|
||||
"rows": 10,
|
||||
"checksum": "aa65128c0bafc9eab8419174754327d7"
|
||||
},
|
||||
"after": {
|
||||
"rows": 12,
|
||||
"checksum": "41f44e385b8a31b0af1f22e1a29680ce"
|
||||
}
|
||||
}
|
||||
},
|
||||
"persistent_files_and_configuration_identical": 369,
|
||||
"private_catalogue": {
|
||||
"observations": 361464,
|
||||
"distinct_administrative_observations": 214299,
|
||||
"preserved_repetitions": 147165,
|
||||
"unmapped_territory": 205608
|
||||
},
|
||||
"preservation_identity": "postgres, matching backup reader; no actor impersonation",
|
||||
"anonymous_query_status": 401,
|
||||
"public_revision_confirmed": true,
|
||||
"actual_team_UI_journey": "pending authenticated browser conference;accepted QA is not repeated",
|
||||
"unique_company_count": null,
|
||||
"publication": false,
|
||||
"national_coverage": "partial",
|
||||
"investment_conclusion": "hypothesis,not proved demand or viability",
|
||||
"L8": "unchanged,partial"
|
||||
},
|
||||
"pipeline": {
|
||||
"unit_tests": 59,
|
||||
"summaries": [
|
||||
"\u001b[30;42mOK (1 test, 3106 assertions)\u001b[0m",
|
||||
"✓ built in 33.96s",
|
||||
"\u001b[30;42mOK (59 tests, 320 assertions)\u001b[0m",
|
||||
"# tests 1180",
|
||||
"# pass 1180",
|
||||
"# fail 0"
|
||||
],
|
||||
"schema_assertions": 3106,
|
||||
"frontend_tests": 1180,
|
||||
"result": {
|
||||
"status": "installed",
|
||||
"commit": "b81f477bf2385227ed3099868e80b1e3c7bcb46b",
|
||||
"release": "/var/www/investmoz-homologacao/releases/b81f477bf2385227ed3099868e80b1e3c7bcb46b-5eed771e",
|
||||
"previous_release": "/var/www/investmoz-homologacao/releases/23ecccfaad1053f55bf0591fd242cb5fa3406c38-0e0b8a65",
|
||||
"database_migrations": 0,
|
||||
"content_imports": 0,
|
||||
"requested_commits": [
|
||||
"b81f477bf2385227ed3099868e80b1e3c7bcb46b"
|
||||
],
|
||||
"finished_at": "2026-10-29T11:39:35.927983+00:00"
|
||||
},
|
||||
"frontend_failures": 0,
|
||||
"unit_assertions": 320
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
{
|
||||
"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": "renewed_connected_for_delivery",
|
||||
"resolution": "Console reopened on explicit user request, authenticated; prior automatic rejection no longer blocks delivery"
|
||||
},
|
||||
"installed": "fde7343894964043fa633e3d4d07385a6cb7e039",
|
||||
"cause": "Legacy text province uses exact opportunity field comparison; legacy search ignores canonical province_id; canonical province filtering already expands descendants",
|
||||
"remaining": [
|
||||
"team execution and record of authenticated visual Competitiveness workflow",
|
||||
"broader V2-07/V2-10 and ACT-02 functionality outside this provincial search compatibility correction"
|
||||
],
|
||||
"visual_competitiveness": "pending direct execution and record by team; accepted proofs preserved",
|
||||
"schema_changes": 0,
|
||||
"status": "installed_specific_HTTP_QA_and_public_API_confirmed_preservation_verified",
|
||||
"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": "fde7343894964043fa633e3d4d07385a6cb7e039",
|
||||
"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"
|
||||
},
|
||||
"baseline": "b81f477bf2385227ed3099868e80b1e3c7bcb46b",
|
||||
"backup": {
|
||||
"stage": "/var/backups/investmoz-province-search-20261029/backup",
|
||||
"created_at": "2026-10-29T13:06:32Z",
|
||||
"target": {
|
||||
"database": "teste_investidor",
|
||||
"port": 5432
|
||||
},
|
||||
"tables": 176,
|
||||
"files_verified": 369,
|
||||
"dump_sha256": "7140e88c56f76835a4dc321b62ef13ce08050b0d59b9a69795a98c8eb3cb9337",
|
||||
"files_sha256": "ab3badc10980171b1e65b4286ee0b3e26aab33354de7fb6f76826d09ea141ffb",
|
||||
"restore_verified": false
|
||||
},
|
||||
"restore": {
|
||||
"restore_verified": true,
|
||||
"tables_verified": 176,
|
||||
"container_network": "none",
|
||||
"database": "province_search_restore_20261029_test",
|
||||
"port": 55441,
|
||||
"source_dump_sha256": "7140e88c56f76835a4dc321b62ef13ce08050b0d59b9a69795a98c8eb3cb9337",
|
||||
"active_database_writes": 0
|
||||
},
|
||||
"api": {
|
||||
"candidate": "fde7343894964043fa633e3d4d07385a6cb7e039",
|
||||
"public_revision_confirmed": true,
|
||||
"provincial_comparisons": [
|
||||
{
|
||||
"province": "Cabo Delgado",
|
||||
"id": "059b02ca-7095-577a-8705-4d4c46896747",
|
||||
"matching_results": 5,
|
||||
"all_four_entry_points_agree": true
|
||||
},
|
||||
{
|
||||
"province": "Gaza",
|
||||
"id": "8ed9aa8a-8815-5863-97ec-494e40a7ea81",
|
||||
"matching_results": 5,
|
||||
"all_four_entry_points_agree": true
|
||||
},
|
||||
{
|
||||
"province": "Inhambane",
|
||||
"id": "9e42dac8-fd9b-523f-bf59-71a7d3aa0fb8",
|
||||
"matching_results": 0,
|
||||
"all_four_entry_points_agree": true
|
||||
},
|
||||
{
|
||||
"province": "Manica",
|
||||
"id": "cf5e81f1-ffbc-535d-b1b6-b44b445c4421",
|
||||
"matching_results": 7,
|
||||
"all_four_entry_points_agree": true
|
||||
},
|
||||
{
|
||||
"province": "Maputo Cidade",
|
||||
"id": "4aa31470-997e-5c63-8fe0-ff2f62586f56",
|
||||
"matching_results": 5,
|
||||
"all_four_entry_points_agree": true
|
||||
},
|
||||
{
|
||||
"province": "Maputo Província",
|
||||
"id": "bfde2651-2b46-5d9c-a500-733fa55a8c5c",
|
||||
"matching_results": 5,
|
||||
"all_four_entry_points_agree": true
|
||||
},
|
||||
{
|
||||
"province": "Nampula",
|
||||
"id": "33a98ebf-f98c-53d1-a047-32ccd9c4cd98",
|
||||
"matching_results": 6,
|
||||
"all_four_entry_points_agree": true
|
||||
},
|
||||
{
|
||||
"province": "Niassa",
|
||||
"id": "7d908cf8-6e52-5141-8832-b1193985e39f",
|
||||
"matching_results": 5,
|
||||
"all_four_entry_points_agree": true
|
||||
},
|
||||
{
|
||||
"province": "Sofala",
|
||||
"id": "f57ff041-6275-57b9-8800-c35e46e0d475",
|
||||
"matching_results": 7,
|
||||
"all_four_entry_points_agree": true
|
||||
},
|
||||
{
|
||||
"province": "Tete",
|
||||
"id": "c0a78e45-ebaa-590e-847f-8ba68e4b2fbe",
|
||||
"matching_results": 6,
|
||||
"all_four_entry_points_agree": true
|
||||
},
|
||||
{
|
||||
"province": "Zambezia",
|
||||
"id": "c20efcce-8af8-588d-84b2-b4b138b6e066",
|
||||
"matching_results": 6,
|
||||
"all_four_entry_points_agree": true
|
||||
}
|
||||
],
|
||||
"Maputo": [
|
||||
{
|
||||
"endpoint": "/api/v1/investment/search",
|
||||
"selection": {
|
||||
"province_id": "bfde2651-2b46-5d9c-a500-733fa55a8c5c"
|
||||
},
|
||||
"ids": [
|
||||
60,
|
||||
61,
|
||||
62,
|
||||
63,
|
||||
64
|
||||
],
|
||||
"district_not_assigned": true
|
||||
},
|
||||
{
|
||||
"endpoint": "/api/v1/investment/search",
|
||||
"selection": {
|
||||
"province": "Maputo Província"
|
||||
},
|
||||
"ids": [
|
||||
60,
|
||||
61,
|
||||
62,
|
||||
63,
|
||||
64
|
||||
],
|
||||
"district_not_assigned": true
|
||||
},
|
||||
{
|
||||
"endpoint": "/api/v1/opportunities/search",
|
||||
"selection": {
|
||||
"province_id": "bfde2651-2b46-5d9c-a500-733fa55a8c5c"
|
||||
},
|
||||
"ids": [
|
||||
60,
|
||||
61,
|
||||
62,
|
||||
63,
|
||||
64
|
||||
],
|
||||
"district_not_assigned": true
|
||||
},
|
||||
{
|
||||
"endpoint": "/api/v1/opportunities/search",
|
||||
"selection": {
|
||||
"province": "Maputo Província"
|
||||
},
|
||||
"ids": [
|
||||
60,
|
||||
61,
|
||||
62,
|
||||
63,
|
||||
64
|
||||
],
|
||||
"district_not_assigned": true
|
||||
}
|
||||
],
|
||||
"map_unlocated": 5,
|
||||
"map_coordinates_invented": false,
|
||||
"export_responded": true,
|
||||
"active_domain_writes": 0,
|
||||
"scope": "Installed public API confirmed; deeper-level synthetic fixtures proved separately in isolated QA (4 tests/364 assertions)",
|
||||
"competitiveness_visual_conference": "pending team execution and record; accepted technical proofs preserved",
|
||||
"browser_URL_read": "not recovered; independent of this API conference"
|
||||
},
|
||||
"code": {
|
||||
"candidate": "fde7343894964043fa633e3d4d07385a6cb7e039",
|
||||
"source_files_verified": 14,
|
||||
"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
|
||||
},
|
||||
"preservation": {
|
||||
"navigation_asset_and_shared_props_verified": true,
|
||||
"at": "2026-10-29T13:17:10.135835+00:00",
|
||||
"served_commit": "fde7343894964043fa633e3d4d07385a6cb7e039",
|
||||
"backup_dump_sha256": "7140e88c56f76835a4dc321b62ef13ce08050b0d59b9a69795a98c8eb3cb9337",
|
||||
"prior_public_tables_checked": 176,
|
||||
"prior_domain_rows_preserved": true,
|
||||
"new_migration_registry_rows": 0,
|
||||
"operational_delta": {
|
||||
"cache": {
|
||||
"before": {
|
||||
"rows": 120,
|
||||
"checksum": "55ffaf3e179865765ecf047ad31a2c35"
|
||||
},
|
||||
"after": {
|
||||
"rows": 126,
|
||||
"checksum": "013841da1ac8a6ddd713bf8d12672fac"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"before": {
|
||||
"rows": 18,
|
||||
"checksum": "8c7f1af4213371d0022a6b2e0cc62bb6"
|
||||
},
|
||||
"after": {
|
||||
"rows": 37,
|
||||
"checksum": "b9a7610497fee6005c2bebc69a84d99c"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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.95s",
|
||||
"\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": "fde7343894964043fa633e3d4d07385a6cb7e039",
|
||||
"release": "/var/www/investmoz-homologacao/releases/fde7343894964043fa633e3d4d07385a6cb7e039-224377ca",
|
||||
"previous_release": "/var/www/investmoz-homologacao/releases/b81f477bf2385227ed3099868e80b1e3c7bcb46b-5eed771e",
|
||||
"database_migrations": 0,
|
||||
"content_imports": 0,
|
||||
"requested_commits": [
|
||||
"fde7343894964043fa633e3d4d07385a6cb7e039"
|
||||
],
|
||||
"finished_at": "2026-10-29T13:13:03.813678+00:00"
|
||||
},
|
||||
"unit_assertions": 320
|
||||
}
|
||||
}
|
||||
@@ -7400,4 +7400,62 @@ Origem reconciliada: main remoto e revisão pública conferidos em 332c16eb em 2
|
||||
|
||||
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.
|
||||
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.
|
||||
### 29/10/2026 — filtros provinciais corrigidos e confirmados por API no portal: fde73438
|
||||
|
||||
**Estado deste recorte V2-07/V2-10 / ACT-02:** correcção de código/API implementada, instalada e confirmada em https://teste-investidor.tempo.co.mz/. Avanço normal de main e pipeline existente, versão anterior b81f477b preservada. A selecção provincial por nome público inequívoco ou identificador usa a mesma hierarquia nas APIs /api/v1/investment/search e /api/v1/opportunities/search. Inclui localizações dos níveis inferiores e localizações adicionais; preserva publicação/sector e exclui registos privados, eliminados e outras províncias. Declaração provincial legada não ganha localização distrital nem prevalece sobre território explícito contraditório. Nenhum cadastro foi alterado por esta correcção.
|
||||
|
||||
**Prova HTTP específica:** quatro testes/364 asserções na QA restaurada province_search_20261029_test, porta55441: província/distrito/posto/localidade, localizações adicionais, onze províncias, exclusões, invariância dos dados, catálogo/mapa/exportação. Candidato QA e0987afb; código instalado coincide por SHA-256 com o testado (delta posterior documental). Não substituir esta prova pelos 1.180 testes frontend do pipeline. Pipeline habitual passou schema1/3106, Unit59/320, frontend1180, tipos e compilação.
|
||||
|
||||
**Confirmação no instalado:** comparadas as quatro entradas (duas APIs × nome/ID) para cada uma das11 províncias, com resultados idênticos por província. Agronegócio/Maputo Província: todas devolvem agora5 oportunidades, IDs60–64, corrigindo0 por nome e34 na API antiga que ignorava o ID. Continuam identificadas como contexto provincial do corredor, com district e primary_territory_id nulos; mapa conserva5 sem ponto utilizável e não gera coordenadas. O export respondeu. A inclusão efectiva de níveis inferiores foi demonstrada com fixtures sintéticas exclusivamente na QA, não com dados fictícios no activo. Inhambane devolve0 neste recorte cadastral Agronegócio, de forma consistente nas quatro entradas; isto não é conclusão de ausência de potencial económico.
|
||||
|
||||
**Preservação:** 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. Os4 ficheiros do incremento e10 ficheiros das provas aceites de Competitividade foram conferidos por hash no instalado; C132 passou e catálogo privado anónimo permanece HTTP401. Zero migrações/importações, novas divulgações, mudanças de permissões ou escritas sintéticas no activo. Recibo completo: docs/investmoz/unification/province-search-qa-20261029.json. Autenticação SSH renovada após reabertura expressamente pedida pela Tempo; a recusa anterior do lançamento automático não é bloqueio corrente.
|
||||
|
||||
**Separação mantida:** este resultado encerra a lacuna técnica de compatibilidade/expansão provincial descrita acima, sem declarar conferência visual dos controlos e sem encerrar os requisitos amplos ACT-02/V2-07/V2-10. A conferência visual autenticada de Competitividade continua **pendente — execução e registo pela equipa**. As provas aceites dessa funcionalidade não foram reabertas nem substituídas pela correcção dos filtros. Diagnóstico da ligação Edge continua separado; leitura de URL não declarada recuperada. Nenhum processo local foi terminado nem porta libertada; InvesteMoz e SGF preservados.
|
||||
|
||||
Para o registo da equipa: no cluster autorizado, editar conteúdo legítimo → Guardar rascunho no servidor → sair e reabrir → comparar campos/versão recuperados → conferir na ficha pública exclusivamente a versão aprovada. Registar utilizador/âmbito autorizado, URL/cluster, versão instalada, versões guardada/aprovada, resultado e divergências. Uma nova gravação não deve substituir a aprovação pública existente; nova aprovação depende da decisão de conteúdo aplicável. Sem exemplar aprovado disponível, manter essa parte da conferência pendente em vez de publicar dados sintéticos.
|
||||
@@ -3022,4 +3022,62 @@ Origem reconciliada: main remoto e revisão pública conferidos em 332c16eb em 2
|
||||
|
||||
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.
|
||||
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.
|
||||
### 29/10/2026 — filtros provinciais corrigidos e confirmados por API no portal: fde73438
|
||||
|
||||
**Estado deste recorte V2-07/V2-10 / ACT-02:** correcção de código/API implementada, instalada e confirmada em https://teste-investidor.tempo.co.mz/. Avanço normal de main e pipeline existente, versão anterior b81f477b preservada. A selecção provincial por nome público inequívoco ou identificador usa a mesma hierarquia nas APIs /api/v1/investment/search e /api/v1/opportunities/search. Inclui localizações dos níveis inferiores e localizações adicionais; preserva publicação/sector e exclui registos privados, eliminados e outras províncias. Declaração provincial legada não ganha localização distrital nem prevalece sobre território explícito contraditório. Nenhum cadastro foi alterado por esta correcção.
|
||||
|
||||
**Prova HTTP específica:** quatro testes/364 asserções na QA restaurada province_search_20261029_test, porta55441: província/distrito/posto/localidade, localizações adicionais, onze províncias, exclusões, invariância dos dados, catálogo/mapa/exportação. Candidato QA e0987afb; código instalado coincide por SHA-256 com o testado (delta posterior documental). Não substituir esta prova pelos 1.180 testes frontend do pipeline. Pipeline habitual passou schema1/3106, Unit59/320, frontend1180, tipos e compilação.
|
||||
|
||||
**Confirmação no instalado:** comparadas as quatro entradas (duas APIs × nome/ID) para cada uma das11 províncias, com resultados idênticos por província. Agronegócio/Maputo Província: todas devolvem agora5 oportunidades, IDs60–64, corrigindo0 por nome e34 na API antiga que ignorava o ID. Continuam identificadas como contexto provincial do corredor, com district e primary_territory_id nulos; mapa conserva5 sem ponto utilizável e não gera coordenadas. O export respondeu. A inclusão efectiva de níveis inferiores foi demonstrada com fixtures sintéticas exclusivamente na QA, não com dados fictícios no activo. Inhambane devolve0 neste recorte cadastral Agronegócio, de forma consistente nas quatro entradas; isto não é conclusão de ausência de potencial económico.
|
||||
|
||||
**Preservação:** 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. Os4 ficheiros do incremento e10 ficheiros das provas aceites de Competitividade foram conferidos por hash no instalado; C132 passou e catálogo privado anónimo permanece HTTP401. Zero migrações/importações, novas divulgações, mudanças de permissões ou escritas sintéticas no activo. Recibo completo: docs/investmoz/unification/province-search-qa-20261029.json. Autenticação SSH renovada após reabertura expressamente pedida pela Tempo; a recusa anterior do lançamento automático não é bloqueio corrente.
|
||||
|
||||
**Separação mantida:** este resultado encerra a lacuna técnica de compatibilidade/expansão provincial descrita acima, sem declarar conferência visual dos controlos e sem encerrar os requisitos amplos ACT-02/V2-07/V2-10. A conferência visual autenticada de Competitividade continua **pendente — execução e registo pela equipa**. As provas aceites dessa funcionalidade não foram reabertas nem substituídas pela correcção dos filtros. Diagnóstico da ligação Edge continua separado; leitura de URL não declarada recuperada. Nenhum processo local foi terminado nem porta libertada; InvesteMoz e SGF preservados.
|
||||
|
||||
Para o registo da equipa: no cluster autorizado, editar conteúdo legítimo → Guardar rascunho no servidor → sair e reabrir → comparar campos/versão recuperados → conferir na ficha pública exclusivamente a versão aprovada. Registar utilizador/âmbito autorizado, URL/cluster, versão instalada, versões guardada/aprovada, resultado e divergências. Uma nova gravação não deve substituir a aprovação pública existente; nova aprovação depende da decisão de conteúdo aplicável. Sem exemplar aprovado disponível, manter essa parte da conferência pendente em vez de publicar dados sintéticos.
|
||||
@@ -0,0 +1,72 @@
|
||||
import { forceLabels, forces } from '@/lib/cluster-competitiveness';
|
||||
import type { CompetitivenessDraft } from '@/lib/cluster-competitiveness';
|
||||
|
||||
export type ApprovedCompetitiveness = {
|
||||
draft: CompetitivenessDraft;
|
||||
version: number;
|
||||
at: string;
|
||||
};
|
||||
const pressures = {
|
||||
low: 'Baixa',
|
||||
medium: 'Média',
|
||||
high: 'Alta',
|
||||
'': 'Indeterminada',
|
||||
};
|
||||
|
||||
export default function ClusterCompetitivenessPublic({
|
||||
analysis,
|
||||
}: {
|
||||
analysis: ApprovedCompetitiveness;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="space-y-3 rounded border p-4"
|
||||
aria-label="Competitividade aprovada"
|
||||
>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Competitividade — cinco forças de Porter
|
||||
</h2>
|
||||
<p>
|
||||
{analysis.draft.sector} · {analysis.draft.region} · Versão
|
||||
aprovada {analysis.version} · {analysis.at}
|
||||
</p>
|
||||
<p>
|
||||
Apreciação qualitativa fundamentada nas fontes e períodos
|
||||
indicados. Não representa garantia de procura ou viabilidade.
|
||||
</p>
|
||||
{forces.map((force) => {
|
||||
const row = analysis.draft.forces[force];
|
||||
return (
|
||||
<details key={force} className="rounded border p-3">
|
||||
<summary>
|
||||
{forceLabels[force]} · Pressão{' '}
|
||||
{pressures[row.pressure]}
|
||||
</summary>
|
||||
<dl className="space-y-2">
|
||||
{(
|
||||
[
|
||||
['evidence', 'Evidências'],
|
||||
['source', 'Fonte e referência'],
|
||||
['period', 'Período'],
|
||||
['rationale', 'Justificação'],
|
||||
['limitations', 'Limitações'],
|
||||
[
|
||||
'opportunity',
|
||||
'Oportunidade e fundamento',
|
||||
],
|
||||
] as const
|
||||
).map(([field, label]) => (
|
||||
<div key={field}>
|
||||
<dt className="font-semibold">{label}</dt>
|
||||
<dd className="whitespace-pre-wrap">
|
||||
{row[field]}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { Management } from '../management/shared';
|
||||
import { Layout, Field, control, button, labels, kind } from './shared';
|
||||
import type { Dossier } from './shared';
|
||||
import InvestmentComplements, { type ComplementOption } from '@/components/investment-complements';
|
||||
import ClusterCompetitivenessPublic, { type ApprovedCompetitiveness } from '@/components/cluster-competitiveness-public';
|
||||
export default function Show(props: Props) {
|
||||
return (
|
||||
<Editor
|
||||
@@ -21,6 +22,7 @@ export default function Show(props: Props) {
|
||||
}
|
||||
|
||||
type Props = {
|
||||
competitiveness?: ApprovedCompetitiveness | null;
|
||||
complements?: ComplementOption[];
|
||||
geography: StructureGeography;
|
||||
existingSubmissions?: { id: string; version: number; updated_at: string }[];
|
||||
@@ -54,6 +56,7 @@ type Props = {
|
||||
};
|
||||
|
||||
function Editor({
|
||||
competitiveness,
|
||||
complements = [],
|
||||
record,
|
||||
related,
|
||||
@@ -94,6 +97,7 @@ function Editor({
|
||||
{kind(record)} · {record.content.code}
|
||||
</p>
|
||||
<p className="text-sm">Fonte: {record.source}</p>
|
||||
{competitiveness && <ClusterCompetitivenessPublic analysis={competitiveness} />}
|
||||
{!management && (
|
||||
<div className="space-y-2 rounded-xl border p-4">
|
||||
<Link className="underline" href={portfolioPreparationHref(record.id, pageUrl)}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { portalRequest, PortalApiError } from '@/lib/portal-api';
|
||||
import {
|
||||
assessmentReady,
|
||||
diagnosticReady,
|
||||
@@ -23,18 +24,99 @@ const labels = {
|
||||
opportunity: 'Oportunidade de investimento e fundamento',
|
||||
} as const;
|
||||
|
||||
export type CompetitivenessState = {
|
||||
version: number;
|
||||
draft: CompetitivenessDraft | null;
|
||||
approved: {
|
||||
draft: CompetitivenessDraft;
|
||||
version: number;
|
||||
at: string;
|
||||
} | null;
|
||||
history: { version: number; action: string; at: string; actor: number }[];
|
||||
};
|
||||
const actions: Record<string, string> = {
|
||||
saved: 'Gravação',
|
||||
approved: 'Aprovação pública',
|
||||
withdrawn: 'Retirada da aprovação',
|
||||
restored: 'Recuperação do histórico',
|
||||
};
|
||||
|
||||
export default function Competitiveness({
|
||||
clusterId,
|
||||
sector,
|
||||
initial,
|
||||
}: {
|
||||
clusterId: string;
|
||||
sector: string;
|
||||
initial?: CompetitivenessState | null;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(() =>
|
||||
newCompetitivenessDraft(clusterId, sector),
|
||||
const [draft, setDraft] = useState(
|
||||
() => initial?.draft ?? newCompetitivenessDraft(clusterId, sector),
|
||||
);
|
||||
const [server, setServer] = useState<CompetitivenessState>(
|
||||
initial ?? { version: 0, draft: null, approved: null, history: [] },
|
||||
);
|
||||
const [serverPending, setServerPending] =
|
||||
useState<CompetitivenessState | null>(null);
|
||||
const [restoreVersion, setRestoreVersion] = useState<number | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
const [pending, setPending] = useState<CompetitivenessDraft | null>(null);
|
||||
const [message, setMessage] = useState('');
|
||||
const dirty =
|
||||
JSON.stringify(draft) !==
|
||||
JSON.stringify(
|
||||
server.draft ?? newCompetitivenessDraft(clusterId, sector),
|
||||
);
|
||||
const endpoint = `/management/clusters/${clusterId}/competitiveness`;
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof PortalApiError) {
|
||||
if (error.status === 409)
|
||||
return 'Conflito de versão. Os campos actuais foram conservados. Transfira o rascunho e consulte a versão do servidor antes de reconciliar.';
|
||||
if ([401, 403, 419].includes(error.status))
|
||||
return 'Sessão ou permissão indisponível. Os campos foram conservados; transfira o rascunho antes de autenticar-se novamente.';
|
||||
if (error.status === 422)
|
||||
return (
|
||||
Object.values(error.errors).flat().join(' ') ||
|
||||
error.message
|
||||
);
|
||||
}
|
||||
return 'Não foi possível concluir a operação; os campos actuais foram conservados.';
|
||||
}
|
||||
function apply(state: CompetitivenessState) {
|
||||
setServer(state);
|
||||
setDraft(state.draft ?? newCompetitivenessDraft(clusterId, sector));
|
||||
setPending(null);
|
||||
setServerPending(null);
|
||||
setRestoreVersion(null);
|
||||
}
|
||||
async function command(
|
||||
action: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const state = await portalRequest<CompetitivenessState>(
|
||||
`${endpoint}/${action}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
expected_version: server.version,
|
||||
...extra,
|
||||
}),
|
||||
},
|
||||
);
|
||||
apply(state);
|
||||
setMessage(
|
||||
`${actions[action]} concluída no servidor. Versão ${state.version}.`,
|
||||
);
|
||||
} catch (error) {
|
||||
setMessage(errorMessage(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
function change(force: Force, field: keyof Assessment, value: string) {
|
||||
setDraft((previous) => ({
|
||||
...previous,
|
||||
@@ -73,165 +155,347 @@ export default function Competitiveness({
|
||||
pontuação automática nem média entre forças.
|
||||
</p>
|
||||
<p>
|
||||
Rascunho nesta página: transfira o ficheiro antes de sair. Não é
|
||||
gravado no servidor nem publicado. A classificação representa a
|
||||
Guarde o rascunho para o recuperar no servidor. A publicação
|
||||
exige aprovação expressa; a classificação representa a
|
||||
apreciação do autor.
|
||||
</p>
|
||||
<label className="block">
|
||||
Sector analisado
|
||||
<input
|
||||
className={control}
|
||||
maxLength={200}
|
||||
value={draft.sector}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, sector: e.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{draft.sector !== sector && (
|
||||
<p>
|
||||
O sector da análise difere do sector actual do cluster (
|
||||
{sector}). Confira o âmbito antes de reutilizar conclusões.
|
||||
</p>
|
||||
)}
|
||||
<label className="block">
|
||||
Região / província / distrito e abrangência
|
||||
<input
|
||||
className={control}
|
||||
maxLength={1000}
|
||||
value={draft.region}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, region: e.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<p role="status">
|
||||
{diagnosticReady(draft)
|
||||
? 'Cinco forças preenchidas para revisão; fontes e conclusões requerem leitura do autor.'
|
||||
: 'Diagnóstico incompleto: indique o âmbito e complete as evidências das cinco forças.'}
|
||||
Versão no servidor: {server.version}.{' '}
|
||||
{dirty
|
||||
? 'Existem alterações por guardar.'
|
||||
: 'Campos correspondem à versão recuperada.'}{' '}
|
||||
{server.approved
|
||||
? `Consulta pública: versão aprovada ${server.approved.version}; novas edições conservam essa versão até outra aprovação.`
|
||||
: 'Sem análise aprovada para consulta pública.'}
|
||||
</p>
|
||||
{forces.map((force) => (
|
||||
<details key={force} className="rounded border p-3">
|
||||
<summary className="cursor-pointer font-semibold">
|
||||
{forceLabels[force]} ·{' '}
|
||||
{assessmentReady(draft.forces[force])
|
||||
? 'Preenchida para revisão'
|
||||
: 'Indeterminada / incompleta'}
|
||||
</summary>
|
||||
<div className="space-y-3 pt-3">
|
||||
<label className="block">
|
||||
Pressão competitiva
|
||||
<select
|
||||
className={control}
|
||||
value={draft.forces[force].pressure}
|
||||
onChange={(e) =>
|
||||
change(force, 'pressure', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Indeterminada</option>
|
||||
<option value="low">Baixa</option>
|
||||
<option value="medium">Média</option>
|
||||
<option value="high">Alta</option>
|
||||
</select>
|
||||
</label>
|
||||
{Object.entries(labels).map(([field, label]) => (
|
||||
<label key={field} className="block">
|
||||
{label}
|
||||
<textarea
|
||||
<fieldset disabled={busy} className="space-y-4">
|
||||
<label className="block">
|
||||
Sector analisado
|
||||
<input
|
||||
className={control}
|
||||
maxLength={200}
|
||||
value={draft.sector}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, sector: e.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{draft.sector !== sector && (
|
||||
<p>
|
||||
O sector da análise difere do sector actual do cluster (
|
||||
{sector}). Confira o âmbito antes de reutilizar
|
||||
conclusões.
|
||||
</p>
|
||||
)}
|
||||
<label className="block">
|
||||
Região / província / distrito e abrangência
|
||||
<input
|
||||
className={control}
|
||||
maxLength={1000}
|
||||
value={draft.region}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, region: e.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<p role="status">
|
||||
{diagnosticReady(draft)
|
||||
? 'Cinco forças preenchidas para revisão; fontes e conclusões requerem leitura do autor.'
|
||||
: 'Diagnóstico incompleto: indique o âmbito e complete as evidências das cinco forças.'}
|
||||
</p>
|
||||
{forces.map((force) => (
|
||||
<details key={force} className="rounded border p-3">
|
||||
<summary className="cursor-pointer font-semibold">
|
||||
{forceLabels[force]} ·{' '}
|
||||
{assessmentReady(draft.forces[force])
|
||||
? 'Preenchida para revisão'
|
||||
: 'Indeterminada / incompleta'}
|
||||
</summary>
|
||||
<div className="space-y-3 pt-3">
|
||||
<label className="block">
|
||||
Pressão competitiva
|
||||
<select
|
||||
className={control}
|
||||
rows={3}
|
||||
maxLength={5000}
|
||||
value={
|
||||
draft.forces[force][
|
||||
field as keyof typeof labels
|
||||
]
|
||||
}
|
||||
value={draft.forces[force].pressure}
|
||||
onChange={(e) =>
|
||||
change(
|
||||
force,
|
||||
field as keyof typeof labels,
|
||||
'pressure',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
>
|
||||
<option value="">Indeterminada</option>
|
||||
<option value="low">Baixa</option>
|
||||
<option value="medium">Média</option>
|
||||
<option value="high">Alta</option>
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
<button type="button" className={button} onClick={download}>
|
||||
Transferir análise / rascunho
|
||||
</button>
|
||||
<label className="block">
|
||||
Recuperar ficheiro deste cluster
|
||||
<input
|
||||
className={control}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
setPending(null);
|
||||
try {
|
||||
if (file.size > 250_000)
|
||||
throw Error(
|
||||
'O ficheiro excede o limite de 250 KB.',
|
||||
{Object.entries(labels).map(([field, label]) => (
|
||||
<label key={field} className="block">
|
||||
{label}
|
||||
<textarea
|
||||
className={control}
|
||||
rows={3}
|
||||
maxLength={5000}
|
||||
value={
|
||||
draft.forces[force][
|
||||
field as keyof typeof labels
|
||||
]
|
||||
}
|
||||
onChange={(e) =>
|
||||
change(
|
||||
force,
|
||||
field as keyof typeof labels,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
<button type="button" className={button} onClick={download}>
|
||||
Transferir análise / rascunho
|
||||
</button>
|
||||
<label className="block">
|
||||
Recuperar ficheiro deste cluster
|
||||
<input
|
||||
className={control}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
setPending(null);
|
||||
try {
|
||||
if (file.size > 250_000)
|
||||
throw Error(
|
||||
'O ficheiro excede o limite de 250 KB.',
|
||||
);
|
||||
setPending(
|
||||
readCompetitivenessDraft(
|
||||
await file.text(),
|
||||
clusterId,
|
||||
),
|
||||
);
|
||||
setPending(
|
||||
readCompetitivenessDraft(
|
||||
await file.text(),
|
||||
clusterId,
|
||||
),
|
||||
);
|
||||
setMessage(
|
||||
'Ficheiro lido. Confira o âmbito e confirme a substituição dos campos actuais.',
|
||||
);
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Não foi possível ler o ficheiro.',
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{pending && (
|
||||
<div className="space-y-2 rounded border p-3">
|
||||
<p>
|
||||
Recuperar análise de{' '}
|
||||
{pending.sector || 'sector não indicado'} em{' '}
|
||||
{pending.region || 'região não indicada'}? Os campos
|
||||
actuais serão substituídos. Transfira primeiro o
|
||||
rascunho actual se o quiser conservar.
|
||||
</p>
|
||||
setMessage(
|
||||
'Ficheiro lido. Confira o âmbito e confirme a substituição dos campos actuais.',
|
||||
);
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Não foi possível ler o ficheiro.',
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{pending && (
|
||||
<div className="space-y-2 rounded border p-3">
|
||||
<p>
|
||||
Recuperar análise de{' '}
|
||||
{pending.sector || 'sector não indicado'} em{' '}
|
||||
{pending.region || 'região não indicada'}? Os campos
|
||||
actuais serão substituídos. Transfira primeiro o
|
||||
rascunho actual se o quiser conservar.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => {
|
||||
setDraft(pending);
|
||||
setPending(null);
|
||||
setMessage('Análise recuperada para edição.');
|
||||
}}
|
||||
>
|
||||
Confirmar recuperação
|
||||
</button>{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => {
|
||||
setPending(null);
|
||||
setMessage(
|
||||
'Recuperação cancelada; campos actuais preservados.',
|
||||
);
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{message && <p role="status">{message}</p>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => {
|
||||
setDraft(pending);
|
||||
setPending(null);
|
||||
setMessage('Análise recuperada para edição.');
|
||||
disabled={!dirty}
|
||||
onClick={() => command('saved', { draft })}
|
||||
>
|
||||
Guardar rascunho no servidor
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setServerPending(
|
||||
await portalRequest<CompetitivenessState>(
|
||||
endpoint,
|
||||
),
|
||||
);
|
||||
setMessage(
|
||||
'Versão do servidor lida. Confirme antes de substituir os campos.',
|
||||
);
|
||||
} catch (error) {
|
||||
setMessage(errorMessage(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Confirmar recuperação
|
||||
Consultar versão actual do servidor
|
||||
</button>
|
||||
</div>
|
||||
{serverPending && (
|
||||
<div className="rounded border p-3">
|
||||
<p>
|
||||
Versão {serverPending.version}:{' '}
|
||||
{serverPending.draft?.sector ||
|
||||
'Sector não indicado'}{' '}
|
||||
/{' '}
|
||||
{serverPending.draft?.region ||
|
||||
'Região não indicada'}
|
||||
. Recuperar substitui os campos actuais. Transfira
|
||||
primeiro alterações que queira conservar.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => {
|
||||
apply(serverPending);
|
||||
setMessage(
|
||||
'Versão actual recuperada do servidor.',
|
||||
);
|
||||
}}
|
||||
>
|
||||
Confirmar recuperação do servidor
|
||||
</button>{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => setServerPending(null)}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<details className="rounded border p-3">
|
||||
<summary>
|
||||
Histórico no servidor (últimas 50 versões)
|
||||
</summary>
|
||||
{!server.history.length && (
|
||||
<p>Ainda não existem gravações.</p>
|
||||
)}
|
||||
{server.history.map((entry) => (
|
||||
<p key={entry.version}>
|
||||
Versão {entry.version} ·{' '}
|
||||
{actions[entry.action] ?? entry.action} · {entry.at}{' '}
|
||||
· Utilizador {entry.actor}{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => setRestoreVersion(entry.version)}
|
||||
>
|
||||
Recuperar como rascunho
|
||||
</button>
|
||||
</p>
|
||||
))}
|
||||
{restoreVersion !== null && (
|
||||
<div>
|
||||
<p>
|
||||
Recuperar a versão {restoreVersion} cria uma
|
||||
nova versão no servidor e substitui os campos
|
||||
actuais. A versão pública aprovada mantém-se.
|
||||
Transfira primeiro alterações por guardar.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() =>
|
||||
command('restored', {
|
||||
revision: restoreVersion,
|
||||
})
|
||||
}
|
||||
>
|
||||
Confirmar recuperação do histórico
|
||||
</button>{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => setRestoreVersion(null)}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
<details className="rounded border p-3">
|
||||
<summary>Aprovação para consulta pública</summary>
|
||||
<p>
|
||||
Aprovar torna a versão guardada consultável na ficha
|
||||
pública deste cluster, quando essa ficha estiver
|
||||
publicada. Guardar um rascunho não o publica. A
|
||||
evidência de aprovação e as identidades do histórico
|
||||
ficam na gestão.
|
||||
</p>
|
||||
<label className="block">
|
||||
Evidência / motivo da aprovação ou retirada
|
||||
<textarea
|
||||
className={control}
|
||||
maxLength={3000}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={
|
||||
dirty ||
|
||||
!diagnosticReady(draft) ||
|
||||
server.version === 0 ||
|
||||
reason.trim().length < 10
|
||||
}
|
||||
onClick={() => command('approved', { reason })}
|
||||
>
|
||||
Aprovar versão guardada para consulta pública
|
||||
</button>{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => {
|
||||
setPending(null);
|
||||
setMessage(
|
||||
'Recuperação cancelada; campos actuais preservados.',
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
dirty ||
|
||||
!server.approved ||
|
||||
reason.trim().length < 10
|
||||
}
|
||||
onClick={() => command('withdrawn', { reason })}
|
||||
>
|
||||
Cancelar
|
||||
Retirar aprovação pública
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{message && <p role="status">{message}</p>}
|
||||
{dirty && (
|
||||
<p>
|
||||
Guarde primeiro as alterações ou recupere a versão
|
||||
pretendida antes de decidir a aprovação.
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
</fieldset>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { titles, statuses, stages, roles } from './types';
|
||||
import type { Structure, StructureRecord, Chain } from './types';
|
||||
import ValueNetwork from './value-network';
|
||||
import Competitiveness from './competitiveness';
|
||||
import type { CompetitivenessState } from './competitiveness';
|
||||
import type { ChainLink, Gap } from './value-network';
|
||||
|
||||
function ChainForm({ clusterId, chain }: { clusterId: string; chain?: Chain }) {
|
||||
@@ -76,6 +77,7 @@ export default function Editor({
|
||||
memberships,
|
||||
chainLinks,
|
||||
gaps,
|
||||
competitiveness,
|
||||
}: {
|
||||
structure: Structure;
|
||||
record: StructureRecord | null;
|
||||
@@ -84,6 +86,7 @@ export default function Editor({
|
||||
chains: Chain[];
|
||||
chainLinks: ChainLink[];
|
||||
gaps: Gap[];
|
||||
competitiveness?: CompetitivenessState | null;
|
||||
memberships: {
|
||||
id: string;
|
||||
project_id: string;
|
||||
@@ -299,7 +302,12 @@ export default function Editor({
|
||||
</form>
|
||||
{isCluster && record && (
|
||||
<>
|
||||
<Competitiveness key={record.id} clusterId={record.id} sector={record.sector ?? ''} />
|
||||
<Competitiveness
|
||||
key={record.id}
|
||||
clusterId={record.id}
|
||||
sector={record.sector ?? ''}
|
||||
initial={competitiveness}
|
||||
/>
|
||||
<section className="max-w-3xl space-y-4">
|
||||
<h2 className="text-xl font-semibold">
|
||||
Projectos associados
|
||||
|
||||
@@ -254,6 +254,8 @@ Route::middleware(['auth', 'verified', \App\Http\Middleware\EnsureScopedPortalAd
|
||||
Route::get('portfolio/{id}', [InvestmentPortfolioController::class, 'show'])->whereUuid('id')->name('portfolio.show');
|
||||
Route::put('portfolio/{dossier}', [InvestmentPortfolioController::class, 'save'])->name('portfolio.save');
|
||||
Route::post('portfolio/{dossier}/publish', [InvestmentPortfolioController::class, 'publish'])->name('portfolio.publish');
|
||||
Route::get('clusters/{cluster}/competitiveness', [\App\Http\Controllers\ClusterCompetitivenessController::class, 'show'])->name('clusters.competitiveness.show');
|
||||
Route::post('clusters/{cluster}/competitiveness/{action}', [\App\Http\Controllers\ClusterCompetitivenessController::class, 'change'])->whereIn('action', ['saved', 'approved', 'withdrawn', 'restored'])->name('clusters.competitiveness.change');
|
||||
Route::post('clusters/{cluster}/chain-links', [ClusterValueChainController::class, 'link'])->name('clusters.chain-links.store');
|
||||
Route::delete('clusters/{cluster}/chain-links/{link}', [ClusterValueChainController::class, 'removeLink'])->name('clusters.chain-links.delete');
|
||||
Route::post('clusters/{cluster}/gaps', [ClusterValueChainController::class, 'gap'])->name('clusters.gaps.store');
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\EconomicCluster;
|
||||
use App\Models\InvestmentDossier;
|
||||
use App\Models\User;
|
||||
use App\Services\ClusterCompetitiveness;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ClusterCompetitivenessTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
}
|
||||
|
||||
private function manager(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
config(['portal.managers' => [$user->email], 'portal.profile_access_enabled' => false]);
|
||||
$this->actingAs($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function cluster(): EconomicCluster
|
||||
{
|
||||
return EconomicCluster::create(['code' => 'COMP-'.bin2hex(random_bytes(6)), 'name' => 'Cluster de teste de persistência', 'sector' => 'Energia', 'status' => 'planned']);
|
||||
}
|
||||
|
||||
private function draft(EconomicCluster $cluster): array
|
||||
{
|
||||
$draft = ['version' => 1, 'clusterId' => $cluster->id, 'sector' => 'Energia', 'region' => 'Distrito de demonstração', 'forces' => []];
|
||||
foreach (ClusterCompetitiveness::FORCES as $force) {
|
||||
$draft['forces'][$force] = ['pressure' => 'medium', 'evidence' => 'Observação de teste, não publicável como facto real',
|
||||
'source' => 'Fonte de demonstração p. 2', 'period' => '2025', 'rationale' => 'Apreciação de teste',
|
||||
'limitations' => 'Dados sintéticos', 'opportunity' => 'Hipótese de demonstração'];
|
||||
}
|
||||
|
||||
return $draft;
|
||||
}
|
||||
|
||||
private function url(EconomicCluster $cluster): string
|
||||
{
|
||||
return '/management/clusters/'.$cluster->id.'/competitiveness';
|
||||
}
|
||||
|
||||
public function test_save_recovers_from_database_across_requests_and_records_encrypted_history(): void
|
||||
{
|
||||
$actor = $this->manager();
|
||||
$cluster = $this->cluster();
|
||||
$draft = $this->draft($cluster);
|
||||
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 0, 'draft' => $draft])
|
||||
->assertOk()->assertJsonPath('version', 1)->assertJsonPath('draft', $draft)->assertJsonPath('approved', null);
|
||||
$this->getJson($this->url($cluster))->assertOk()->assertHeader('Cache-Control', 'no-store, private')
|
||||
->assertJsonPath('draft', $draft)->assertJsonPath('history.0.actor', $actor->id)->assertJsonPath('history.0.action', 'saved');
|
||||
$this->get('/management/clusters/'.$cluster->id.'/edit')->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('structures/editor')->where('competitiveness.version', 1)->where('competitiveness.draft.region', $draft['region']));
|
||||
$event = DB::table('audit_events')->where('source_system', 'cluster-competitiveness')->where('entity_id', $cluster->id)->first();
|
||||
$this->assertStringNotContainsString($draft['forces']['rivalry']['evidence'], $event->after_data);
|
||||
$this->assertSame('restricted', $event->data_classification);
|
||||
$this->assertNull(app(ClusterCompetitiveness::class)->publicData($cluster->id));
|
||||
}
|
||||
|
||||
public function test_validation_conflict_and_server_error_do_not_overwrite_the_saved_version(): void
|
||||
{
|
||||
$this->manager();
|
||||
$cluster = $this->cluster();
|
||||
$draft = $this->draft($cluster);
|
||||
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 0, 'draft' => $draft])->assertOk();
|
||||
$changed = $draft;
|
||||
$changed['region'] = 'Outra região';
|
||||
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 0, 'draft' => $changed])->assertConflict();
|
||||
$changed['forces']['buyers']['pressure'] = 'zero';
|
||||
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 1, 'draft' => $changed])->assertUnprocessable();
|
||||
$changed = $draft;
|
||||
$changed['clusterId'] = $this->cluster()->id;
|
||||
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 1, 'draft' => $changed])->assertUnprocessable();
|
||||
$this->getJson($this->url($cluster))->assertJsonPath('version', 1)->assertJsonPath('draft', $draft)->assertJsonCount(1, 'history');
|
||||
}
|
||||
|
||||
public function test_approval_pins_content_and_later_drafts_and_restoration_do_not_publish(): void
|
||||
{
|
||||
$this->manager();
|
||||
$cluster = $this->cluster();
|
||||
$draft = $this->draft($cluster);
|
||||
$url = $this->url($cluster);
|
||||
$this->postJson($url.'/saved', ['expected_version' => 0, 'draft' => $draft])->assertOk();
|
||||
$this->postJson($url.'/approved', ['expected_version' => 1, 'reason' => 'Aprovação sintética em QA'])->assertOk()->assertJsonPath('version', 2);
|
||||
$changed = $draft;
|
||||
$changed['region'] = 'Rascunho privado posterior';
|
||||
$this->postJson($url.'/saved', ['expected_version' => 2, 'draft' => $changed])->assertOk();
|
||||
$public = app(ClusterCompetitiveness::class)->publicData($cluster->id);
|
||||
$this->assertSame($draft, $public['draft']);
|
||||
$this->assertSame(['draft', 'version', 'at'], array_keys($public));
|
||||
$this->postJson($url.'/restored', ['expected_version' => 3, 'revision' => 1])->assertOk()
|
||||
->assertJsonPath('version', 4)->assertJsonPath('draft', $draft)->assertJsonPath('approved.version', 2);
|
||||
$this->assertSame($public, app(ClusterCompetitiveness::class)->publicData($cluster->id));
|
||||
$this->postJson($url.'/withdrawn', ['expected_version' => 4, 'reason' => 'Retirada sintética em QA'])->assertOk()->assertJsonCount(5, 'history');
|
||||
$this->assertNull(app(ClusterCompetitiveness::class)->publicData($cluster->id));
|
||||
$this->getJson($url)->assertJsonPath('draft', $draft);
|
||||
}
|
||||
|
||||
public function test_incomplete_drafts_are_saved_but_not_approved_and_unknown_revisions_do_not_write(): void
|
||||
{
|
||||
$this->manager();
|
||||
$cluster = $this->cluster();
|
||||
$draft = $this->draft($cluster);
|
||||
$draft['forces']['buyers']['source'] = '';
|
||||
$draft['forces']['entrants']['pressure'] = '';
|
||||
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 0, 'draft' => $draft])->assertOk();
|
||||
$this->postJson($this->url($cluster).'/approved', ['expected_version' => 1, 'reason' => 'Pedido de aprovação QA'])->assertUnprocessable();
|
||||
$this->postJson($this->url($cluster).'/restored', ['expected_version' => 1, 'revision' => 99])->assertNotFound();
|
||||
$this->getJson($this->url($cluster))->assertJsonPath('version', 1)->assertJsonPath('draft.forces.buyers.source', '');
|
||||
}
|
||||
|
||||
public function test_saved_data_keeps_the_portable_file_contract_and_rejects_missing_forces(): void
|
||||
{
|
||||
$this->manager();
|
||||
$cluster = $this->cluster();
|
||||
$draft = $this->draft($cluster);
|
||||
$draft['version'] = '1';
|
||||
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 0, 'draft' => $draft])
|
||||
->assertOk()->assertJsonPath('draft.version', 1);
|
||||
unset($draft['forces']['suppliers']);
|
||||
$this->postJson($this->url($cluster).'/saved', ['expected_version' => 1, 'draft' => $draft])->assertUnprocessable();
|
||||
$this->getJson($this->url($cluster))->assertJsonPath('version', 1);
|
||||
}
|
||||
|
||||
public function test_anonymous_and_unauthorised_users_cannot_read_history_or_mutate(): void
|
||||
{
|
||||
$cluster = $this->cluster();
|
||||
$url = $this->url($cluster);
|
||||
$this->getJson($url)->assertUnauthorized();
|
||||
$this->postJson($url.'/saved', [])->assertUnauthorized();
|
||||
$this->actingAs(User::factory()->create());
|
||||
$this->getJson($url)->assertForbidden();
|
||||
foreach (['saved', 'approved', 'withdrawn', 'restored'] as $action) {
|
||||
$this->postJson($url.'/'.$action, [])->assertForbidden();
|
||||
}
|
||||
$this->assertSame(0, DB::table('audit_events')->where('source_system', 'cluster-competitiveness')->where('entity_id', $cluster->id)->count());
|
||||
}
|
||||
|
||||
public function test_public_dossier_returns_only_the_approved_projection_and_unpublished_dossiers_remain_hidden(): void
|
||||
{
|
||||
$actor = $this->manager();
|
||||
$cluster = $this->cluster();
|
||||
$draft = $this->draft($cluster);
|
||||
$dossier = InvestmentDossier::create(['cluster_id' => $cluster->id, 'title' => 'Ficha sintética QA', 'source' => 'Fonte sintética QA',
|
||||
'content' => ['sector' => 'Energia'], 'publication_status' => 'published', 'approval_basis' => 'document_owner',
|
||||
'approval_evidence' => 'Aprovação sintética só na base QA', 'published_at' => now()]);
|
||||
$service = app(ClusterCompetitiveness::class);
|
||||
$service->change($cluster, $actor->id, 0, 'saved', ['draft' => $draft]);
|
||||
$this->app['auth']->forgetGuards();
|
||||
$this->get('/carteira/'.$dossier->id)->assertInertia(fn (AssertableInertia $page) => $page->where('competitiveness', null));
|
||||
$service->change($cluster, $actor->id, 1, 'approved', ['reason' => 'Aprovação privada de teste']);
|
||||
$draft['region'] = 'CONTEÚDO PRIVADO NÃO APROVADO';
|
||||
$service->change($cluster, $actor->id, 2, 'saved', ['draft' => $draft]);
|
||||
$this->get('/carteira/'.$dossier->id)->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('competitiveness.version', 2)->where('competitiveness.draft.region', 'Distrito de demonstração')
|
||||
->missing('competitiveness.actor')->missing('competitiveness.evidence')->missing('competitiveness.history'));
|
||||
$dossier->update(['publication_status' => 'draft']);
|
||||
$this->get('/carteira/'.$dossier->id)->assertNotFound();
|
||||
$dossier->update(['publication_status' => 'published']);
|
||||
$service->change($cluster, $actor->id, 3, 'withdrawn', ['reason' => 'Retirada privada de teste']);
|
||||
$this->get('/carteira/'.$dossier->id)->assertInertia(fn (AssertableInertia $page) => $page->where('competitiveness', null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\CorridorAgribusinessOpportunities;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Support\IsolatedQaDatabase;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProvinceSearchCompatibilityTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
public function createApplication(): Application
|
||||
{
|
||||
$app = parent::createApplication();
|
||||
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
|
||||
throw new \LogicException('Isolated QA required');
|
||||
}
|
||||
|
||||
return $app;
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
}
|
||||
|
||||
private function createOpportunity(?string $territory, string $prefix, string $status = 'published', ?string $province = null): Opportunity
|
||||
{
|
||||
return Opportunity::create(['sector_id' => 1, 'reference' => 'SYNTHETIC-QA-'.Str::uuid(), 'slug' => 'synthetic-'.Str::uuid(),
|
||||
'title' => $prefix.' synthetic provincial search', 'status' => $status, 'published_at' => now()->subMinute(),
|
||||
'primary_territory_id' => $territory, 'province' => $province, 'district' => null, 'geography_scope' => 'specific']);
|
||||
}
|
||||
|
||||
private function requests(array $filters): array
|
||||
{
|
||||
$query = http_build_query([...$filters, 'per_page' => 50]);
|
||||
|
||||
return ['/api/v1/investment/search?'.$query, '/api/v1/opportunities/search?'.$query];
|
||||
}
|
||||
|
||||
public function test_a_single_province_includes_all_lower_levels_and_linked_locations_in_both_apis(): void
|
||||
{
|
||||
$geo = app(InvestmentGeography::class);
|
||||
$locality = $geo->publicReferences()->where('location_type', 'locality')->orderBy('id')->first();
|
||||
$this->assertNotNull($locality, 'Public locality fixture required');
|
||||
$path = $geo->path($locality->id);
|
||||
$this->assertCount(4, $path);
|
||||
$prefix = 'SYNTHETIC-'.Str::uuid();
|
||||
$records = array_map(fn ($node) => $this->createOpportunity($node->id, $prefix, 'published', 'Legacy label differs from canonical hierarchy'), $path);
|
||||
$linked = $this->createOpportunity(null, $prefix);
|
||||
$linked->additionalTerritories()->attach($locality->id);
|
||||
$records[] = $linked;
|
||||
$private = $this->createOpportunity($locality->id, $prefix, 'draft');
|
||||
$deleted = $this->createOpportunity($locality->id, $prefix);
|
||||
$deleted->delete();
|
||||
$otherProvince = $geo->publicReferences()->where('location_type', 'province')->where('id', '<>', $path[0]->id)->first();
|
||||
$outside = $this->createOpportunity($otherProvince->id, $prefix);
|
||||
foreach ([['province_id' => $path[0]->id], ['province' => $path[0]->name], ['geo_location_id' => $path[0]->id]] as $entry) {
|
||||
foreach ($this->requests([...$entry, 'q' => $prefix]) as $url) {
|
||||
$response = $this->getJson($url)->assertOk()->assertJsonPath('total', 5);
|
||||
$this->assertEqualsCanonicalizing(array_map(fn ($row) => $row->id, $records), array_column($response->json('data'), 'id'), $url);
|
||||
foreach ($response->json('data') as $row) {
|
||||
$this->assertNull($row['district']);
|
||||
}
|
||||
foreach ([$private, $deleted, $outside] as $row) {
|
||||
$this->assertNotContains($row->id, array_column($response->json('data'), 'id'));
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($records as $record) {
|
||||
$this->assertNull($record->fresh()->district);
|
||||
$this->assertSame($record->primary_territory_id, $record->fresh()->primary_territory_id);
|
||||
}
|
||||
$this->assertSame([$locality->id], $linked->additionalTerritories()->pluck('geo_locations.id')->all());
|
||||
foreach ($this->requests(['province_id' => $path[0]->id, 'district_id' => $path[1]->id, 'q' => $prefix]) as $url) {
|
||||
// Province-only records are not silently assigned to a district.
|
||||
$this->getJson($url)->assertOk()->assertJsonPath('total', 4);
|
||||
}
|
||||
foreach ($this->requests(['district_id' => $path[1]->id]) as $url) {
|
||||
$this->getJson($url)->assertUnprocessable();
|
||||
}
|
||||
}
|
||||
|
||||
public function test_published_province_declaration_is_found_without_inventing_a_district_or_overriding_an_explicit_other_territory(): void
|
||||
{
|
||||
$geo = app(InvestmentGeography::class);
|
||||
$province = $geo->publicReferences()->where('location_type', 'province')->first();
|
||||
$other = $geo->publicReferences()->where('location_type', 'province')->where('id', '<>', $province->id)->first();
|
||||
$district = $geo->publicReferences()->where('parent_id', $province->id)->where('location_type', 'district')->first();
|
||||
$prefix = 'SYNTHETIC-'.Str::uuid();
|
||||
$legacy = $this->createOpportunity(null, $prefix, 'published', $province->name);
|
||||
$contradictory = $this->createOpportunity($other->id, $prefix, 'published', $province->name);
|
||||
$linkedOutside = $this->createOpportunity(null, $prefix, 'published', $province->name);
|
||||
$linkedOutside->additionalTerritories()->attach($other->id);
|
||||
foreach ([['province' => $province->name], ['province_id' => $province->id], ['geo_location_id' => $province->id]] as $entry) {
|
||||
foreach ($this->requests([...$entry, 'q' => $prefix]) as $url) {
|
||||
$this->getJson($url)->assertOk()->assertJsonPath('total', 1)->assertJsonPath('data.0.id', $legacy->id)->assertJsonPath('data.0.district', null);
|
||||
}
|
||||
}
|
||||
foreach ($this->requests(['province_id' => $province->id, 'district_id' => $district->id, 'q' => $prefix]) as $url) {
|
||||
$this->getJson($url)->assertOk()->assertJsonPath('total', 0);
|
||||
}
|
||||
$this->assertNull($legacy->fresh()->primary_territory_id);
|
||||
$this->assertNull($legacy->fresh()->district);
|
||||
$this->assertSame($other->id, $contradictory->fresh()->primary_territory_id);
|
||||
}
|
||||
|
||||
public function test_provincial_only_search_covers_the_eleven_provinces_without_including_neighbouring_provinces(): void
|
||||
{
|
||||
$geo = app(InvestmentGeography::class);
|
||||
$provinces = $geo->publicReferences()->where('location_type', 'province')->get();
|
||||
$this->assertCount(11, $provinces);
|
||||
$prefix = 'SYNTHETIC-'.Str::uuid();
|
||||
$records = [];
|
||||
foreach ($provinces as $province) {
|
||||
$district = $geo->publicReferences()->where('parent_id', $province->id)->where('location_type', 'district')->first();
|
||||
$this->assertNotNull($district, $province->name);
|
||||
$records[$province->id] = $this->createOpportunity($district->id, $prefix);
|
||||
}
|
||||
foreach ($provinces as $province) {
|
||||
foreach ([['province_id' => $province->id], ['province' => $province->name]] as $entry) {
|
||||
foreach ($this->requests([...$entry, 'q' => $prefix]) as $url) {
|
||||
$this->getJson($url)->assertOk()->assertJsonPath('total', 1)->assertJsonPath('data.0.id', $records[$province->id]->id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_maputo_names_ids_page_map_and_export_share_the_existing_approved_corridor_coverage(): void
|
||||
{
|
||||
$provinceId = 'bfde2651-2b46-5d9c-a500-733fa55a8c5c';
|
||||
$expected = Opportunity::published()->where('workflow_context->corridor_agribusiness->source_sha256', CorridorAgribusinessOpportunities::SOURCE_HASH)
|
||||
->where('workflow_context->corridor_agribusiness->corridor', 'Corredor de Maputo')->pluck('id')->all();
|
||||
$this->assertCount(5, $expected);
|
||||
foreach ([['province' => 'Maputo Província'], ['province' => ' mApUtO Província '], ['province_id' => $provinceId]] as $entry) {
|
||||
foreach ($this->requests([...$entry, 'sector' => 'agronegocio']) as $url) {
|
||||
$response = $this->getJson($url)->assertOk()->assertJsonPath('total', 5);
|
||||
$this->assertEqualsCanonicalizing($expected, array_column($response->json('data'), 'id'));
|
||||
foreach ($response->json('data') as $row) {
|
||||
$this->assertNull($row['district']);
|
||||
$this->assertStringContainsString('contexto provincial do corredor', $row['territorial_summary']);
|
||||
}
|
||||
}
|
||||
}
|
||||
$query = http_build_query(['sector' => 'agronegocio', 'province' => 'Maputo Província']);
|
||||
$this->get('/oportunidades?'.$query)->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('investment/catalogue')->where('filters.province_id', $provinceId)->missing('filters.province')
|
||||
->has('territorySelection', 1)->where('results.total', 5));
|
||||
$this->getJson('/api/v1/investment/map?'.$query)->assertOk()->assertJsonCount(0, 'features')->assertJsonCount(5, 'unlocated');
|
||||
$csv = $this->get('/api/v1/investment/export?'.$query)->assertOk()->streamedContent();
|
||||
foreach (Opportunity::whereIn('id', $expected)->get() as $row) {
|
||||
$this->assertStringContainsString($row->title, $csv);
|
||||
}
|
||||
foreach ($this->requests(['sector' => 'turismo', 'province_id' => $provinceId]) as $url) {
|
||||
$response = $this->getJson($url)->assertOk();
|
||||
foreach ($response->json('data') as $row) {
|
||||
$this->assertNotContains($row['id'], $expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user