Compare commits

...
Author SHA1 Message Date
nala 3f7082a3e6 docs: record sequential competitiveness installation and preservation 2026-10-29 13:11:31 +02:00
nala 23ecccfaad feat(clusters): prepare evidence-based five forces analysis
tests / ci (push) Canceled after 0s
2026-10-29 13:03:02 +02:00
nala 332c16ebb5 feat: expose guided manual IVA and IRPC form preparation
tests / ci (push) Canceled after 0s
2026-10-28 21:54:25 +02:00
nala 17444aaaa5 docs(investor): record served release and grouped preservation receipts 2026-10-28 21:43:46 +02:00
nala 789fffd098 fix(sector): use effective creation access and preserve corridor presentation contract
tests / ci (push) Canceled after 0s
2026-10-28 21:35:21 +02:00
nala 502e3ec4d5 fix(investor): simplify preparation flow, profile requests and scoped zone search
tests / ci (push) Canceled after 0s
2026-10-28 21:26:59 +02:00
nala 54d1b2a543 fix(catalogue): keep filtered investment results separate from educational guides 2026-10-28 21:04:10 +02:00
nala 6520d303f7 fix(territory): combine search and selection in one dropdown field 2026-10-28 21:00:30 +02:00
nala 47dae1b77e docs(catalogue): register national verification in existing requirements 2026-10-28 20:54:48 +02:00
nala 2c8eb95e10 test(catalogue): verify provincial coverage nationally and across sectors 2026-10-28 20:54:30 +02:00
nala 1b2ede41e2 fix(catalogue): include approved provincial corridor coverage and show territorial detail 2026-10-28 20:49:30 +02:00
nala d7a9284d5f docs(navigation): record installed Git delivery and pending visual conference 2026-10-28 20:24:28 +02:00
nala 6e8de43ae7 fix(navigation): expose work area and preserve authorised menu destinations in QA
tests / ci (push) Canceled after 0s
2026-10-28 20:08:54 +02:00
nala 77d133ef0e docs(proactive): clarify connector outage and remaining visual conference 2026-10-28 19:33:27 +02:00
nala 930013f920 docs(proactive): record installed private supply delivery and scoped verification 2026-10-28 19:26:40 +02:00
nala 6d14769369 feat(proactive): prepare controlled private catalogue delivery and reject repeated comparisons
tests / ci (push) Canceled after 0s
2026-10-28 19:13:18 +02:00
nala a022ed9848 fix(proactive): retain unnamed administrative rows and preserve accepted import snapshot 2026-10-28 17:28:31 +02:00
nala 2f58b7d438 feat(proactive): integrate private supply observations and demonstrate Gurue comparison in QA 2026-10-28 17:19:55 +02:00
83 changed files with 5817 additions and 298 deletions
@@ -29,7 +29,7 @@ class InvestmentDiscoveryController extends Controller
'corridorGroups' => ($filters['view'] ?? '') === 'corridors' ? app(\App\Services\OpportunityCorridorExplorer::class)->groups($filters) : [],
'territorySelection' => app(InvestmentGeography::class)->selection($filters),
'guides' => app(\App\Services\SectorInvestment::class)->guides(\App\Models\Sector::where('slug', $filters['sector'] ?? '')->where('is_active', true)->whereNotNull('published_at')->first()),
'results' => $catalogue->query($filters)->paginate(12)->withQueryString(),
'results' => $catalogue->query($filters)->paginate(12)->withQueryString()->through(fn ($record) => $catalogue->presentLocation($record)),
'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [],
'territorial' => $this->territorial($filters)]);
}
@@ -74,7 +74,7 @@ class InvestmentDiscoveryController extends Controller
public function search(Request $request, InvestmentCatalogue $catalogue): JsonResponse
{
return response()->json($catalogue->query($catalogue->filters($request))->paginate($request->integer('per_page', 12))->withQueryString());
return response()->json($catalogue->query($catalogue->filters($request))->paginate($request->integer('per_page', 12))->withQueryString()->through(fn ($record) => $catalogue->presentLocation($record)));
}
public function references(Request $request, InvestmentCatalogue $catalogue): JsonResponse
@@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\ProactiveSupply;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
final class ProactiveSupplyController extends Controller
{
public function index(Request $request,ProactiveSupply $service): JsonResponse
{
$v=$request->validate(['record_id'=>'nullable|integer|min:1']);
$record=isset($v['record_id'])?Opportunity::findOrFail($v['record_id']):null;
return response()->json($service->search($request->user(),$request->all(),$record))->header('Cache-Control','private, no-store');
}
}
@@ -48,13 +48,15 @@ class HandleInertiaRequests extends Middleware
*/
public function share(Request $request): array
{
$contentManagementAccess=app(\App\Services\ContentManagement::class)->access($request->user());
return [
...parent::share($request),
'name' => config('app.name'),
'procurementEnabled' => app(\App\Services\Procurement::class)->available(),
'homologationReserved' => (bool) config('portal.homologation_only', false),
'profileAccess' => app(PortalProfileAccess::class)->navigation($request->user()),
'contentManagementHref' => app(\App\Services\ContentManagement::class)->access($request->user()) !== [] ? '/gestao-conteudos' : null,
'contentManagementHref' => $contentManagementAccess !== [] ? '/gestao-conteudos' : null,
'contentManagementAccess'=>$contentManagementAccess,
'portalOperator' => config('portal.operator_name'),
'portalInstitutionalContactEmail' => filter_var(config('portal.institutional_contact_email'), FILTER_VALIDATE_EMAIL) ?: null,
'legacyContentEnabled' => app(LegacyContent::class)->available(),
@@ -83,6 +85,7 @@ class HandleInertiaRequests extends Middleware
'auth' => [
'user' => $request->user(),
'canManagePortal' => $request->user()?->can('manage-portal') ?? false,
'legacyManagementAccess' => $request->user()?->hasVerifiedEmail() && app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()),
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
];
+41 -3
View File
@@ -71,7 +71,9 @@ class InvestmentCatalogue
} elseif (($filters['territory_basis'] ?? 'all') === 'context') {
$query->whereNull('opportunities.primary_territory_id')->whereNotExists(fn ($l) => $l->selectRaw('1')->from('opportunity_locations')->whereColumn('opportunity_id', 'opportunities.id'));
$query->where(fn ($q) => $q->whereExists(fn ($l) => $l->selectRaw('1')->from('opportunity_projects')->whereColumn('opportunity_id', 'opportunities.id')->whereIn('project_id', app(InvestmentRelations::class)->projectIds()))
->orWhereExists(fn ($l) => $l->selectRaw('1')->from('opportunity_clusters')->whereColumn('opportunity_id', 'opportunities.id')->whereIn('cluster_id', $this->publicStructureIds('clusters', 'cluster_id'))));
->orWhereExists(fn ($l) => $l->selectRaw('1')->from('opportunity_clusters')->whereColumn('opportunity_id', 'opportunities.id')->whereIn('cluster_id', $this->publicStructureIds('clusters', 'cluster_id')))
->orWhere(fn ($source) => $source->where('workflow_context->corridor_agribusiness->source_sha256', CorridorAgribusinessOpportunities::SOURCE_HASH)
->whereIn('workflow_context->corridor_agribusiness->corridor', array_column(config('investment_corridors', []), 'name'))));
}
if (! empty($filters['opportunity_id'])) {
$query->whereKey($filters['opportunity_id']);
@@ -80,12 +82,13 @@ class InvestmentCatalogue
$includeNational = filter_var($filters['include_national'] ?? false, FILTER_VALIDATE_BOOLEAN);
if ($path !== []) {
$ids = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
$query->where(function ($territory) use ($ids, $includeNational, $filters): void {
$query->where(function ($territory) use ($ids, $includeNational, $filters, $path): void {
$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 (($filters['territory_basis'] ?? 'all') !== 'direct') {
app(InvestmentRelations::class)->opportunityTerritories($territory, $ids);
if (count($path) === 1) $this->approvedCorridorProvince($territory, $path[0]->id);
}
if ($includeNational) {
$territory->orWhere('opportunities.geography_scope', 'national');
@@ -162,6 +165,9 @@ class InvestmentCatalogue
->whereNull('target.deleted_at')->where('target.data_classification', 'public'));
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()) {
$this->approvedCorridorProvince($matches, $filters['geo_location_id']);
}
}
});
@@ -212,6 +218,38 @@ class InvestmentCatalogue
};
}
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 (!$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)
->whereIn('workflow_context->corridor_agribusiness->corridor', $names));
}
/** Public result presentation only; no editorial or territorial data is saved. */
public function presentLocation(Opportunity $record): Opportunity
{
$geo = app(InvestmentGeography::class);
$paths = [];
$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 ($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';
$record->setAttribute('territorial_summary', $label);
return $record;
}
/** @param array<string,mixed> $territoryFilters
* @return array<string,mixed> */
public function references(bool $management = false, array $territoryFilters = []): array
@@ -429,7 +467,7 @@ class InvestmentCatalogue
$located = 0;
foreach ($records as $record) {
$properties = ['id' => $record->id, 'title' => $record->title, 'category' => $record->sector?->name,
'location' => trim($record->province.' '.$record->district), 'url' => '/oportunidades/'.$record->slug.($filters === [] ? '' : '?'.http_build_query($filters))];
'location' => $this->presentLocation($record)->getAttribute('territorial_summary'), 'url' => '/oportunidades/'.$record->slug.($filters === [] ? '' : '?'.http_build_query($filters))];
foreach ($relations as $relation => $groups) {
$properties[$relation] = ($groups->get($record->id) ?? collect())->pluck('name')->unique()->values()->all();
}
@@ -14,6 +14,15 @@ final class InvestmentCorridorContext
return null;
}
/** Reuse Tempo's approved provincial context; never infer a district or master link. */
public function catalogueProvinces(Opportunity $record): array
{
if (data_get($record->workflow_context, 'corridor_agribusiness.source_sha256') !== CorridorAgribusinessOpportunities::SOURCE_HASH) return [];
if ($record->primary_territory_id || $record->additionalTerritories()->exists()) return [];
$key = $this->key($record);
return $key ? $this->provinces($key) : [];
}
public function provinces(string $key): array
{
$entry = config('investment_corridors.'.$key);
+5 -2
View File
@@ -63,10 +63,13 @@ class InvestmentZones
$path = app(InvestmentGeography::class)->selection($filters);
if ($path !== []) {
$territoryIds = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
$query->where(function ($matches) use ($territoryIds, $management): void {
$ancestorIds = array_column($path, 'id');
$query->where(function ($matches) use ($territoryIds, $ancestorIds, $management): void {
$matches->whereIn('primary_territory_id', $territoryIds)
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('investment_zone_locations as zl')
->whereColumn('zl.zone_id', 'investment_zones.id')->whereIn('zl.geo_location_id', $territoryIds)
->whereColumn('zl.zone_id', 'investment_zones.id')->whereIn('zl.scope_type', ['site', 'administrative', 'legal_scope'])
->where(fn ($area) => $area->whereIn('zl.geo_location_id', $territoryIds)
->orWhere(fn ($coverage) => $coverage->where('zl.scope_type', 'legal_scope')->whereIn('zl.geo_location_id', $ancestorIds)))
->where('zl.validation_status', 'validated')->where(fn ($valid) => $valid->whereNull('zl.valid_from')->orWhereDate('zl.valid_from', '<=', today()))
->where(fn ($valid) => $valid->whereNull('zl.valid_until')->orWhereDate('zl.valid_until', '>=', today())));
if (! $management) {
+4 -1
View File
@@ -35,8 +35,11 @@ final class OpportunityCorridorExplorer
$path = collect($context['primary_territory_path'] ?? []);
$province = $path->firstWhere('location_type', 'province')->name ?? $record->province;
$district = $path->firstWhere('location_type', 'district')->name ?? $record->district;
$contextProvinces = app(InvestmentCorridorContext::class)->catalogueProvinces($record);
$provinceOptions = $province ? [$province] : array_column($contextProvinces, 'name');
$item = ['id' => $record->id, 'title' => $record->title, 'slug' => $record->slug, 'summary' => $record->summary,
'province' => $province, 'district' => $district, 'chains' => array_values(array_unique($chains)),
'province' => $province, 'district' => $district, 'provinces' => $provinceOptions,
'territorial_summary' => $catalogue->presentLocation($record)->getAttribute('territorial_summary'), 'chains' => array_values(array_unique($chains)),
'stages' => $stages, 'maturity_label' => $record->maturity_label];
foreach ($corridors ?: ['other' => 'Outras localizações'] as $key => $name) {
$groups[$key] ??= ['id' => $key, 'name' => $name, 'opportunities' => []];
+2 -2
View File
@@ -128,7 +128,7 @@ class OpportunityManagement
$this->check($record, $data['etag']);
abort_if($record->trashed() || $record->status === 'archived', 409, 'Restaure antes de editar.');
}
if (array_key_exists('proactive', $data)) $data['proactive'] = app(ProactiveInvestment::class)->draft($data['proactive'], $record);
if (array_key_exists('proactive', $data)) $data['proactive'] = app(ProactiveInvestment::class)->draft($data['proactive'], $record, $actor);
$before = $record->getAttributes();
$previousCanonical = $record->exists ? ($this->values($record)['canonical'] ?? []) : [];
$changes = array_intersect_key($data, array_flip([...self::FIELDS, ...self::PRIVATE_DRAFT_FIELDS]));
@@ -227,7 +227,7 @@ class OpportunityManagement
$record = Opportunity::withTrashed()->whereKey($id)->lockForUpdate()->firstOrFail();
abort_if($record->owner_user_id !== null, 403, 'A v1 exige pedido e decisão de arquivo; não concede exclusão ou restauro ao Promotor.');
$this->check($record, $data['etag']);
if (array_key_exists('proactive', $data)) $data['proactive'] = app(ProactiveInvestment::class)->draft($data['proactive'], $record);
if (array_key_exists('proactive', $data)) $data['proactive'] = app(ProactiveInvestment::class)->draft($data['proactive'], $record, $actor);
$before = $record->getAttributes();
if ($data['action'] === 'restore') {
abort_unless($record->trashed() || $record->status === 'archived', 409, 'Registo não está eliminado/arquivado.');
+18 -3
View File
@@ -16,12 +16,14 @@ final class ProactiveInvestment
public const NOTICE = 'A ausência de registo não comprova ausência de oferta. Proximidade não comprova contrato, participação, energia acessível, terra disponível ou benefício fiscal. Necessidade confirmada não comprova viabilidade comercial; hipótese de fornecimento não é encomenda.';
public const GROUPS = ['exists' => 'O que existe', 'needs' => 'Necessidades identificadas', 'ideas' => 'Ideias de investimento'];
public function draft(array $input, ?Opportunity $record = null): array
public function draft(array $input, ?Opportunity $record = null, ?\App\Models\User $actor = null): array
{
$d = Validator::make(['proactive' => $input], [
'proactive' => 'required|array:version,kind,evidence,need,idea,relations', 'proactive.version' => 'required|integer|in:1',
'proactive' => 'required|array:version,kind,evidence,need,idea,relations,supply_comparisons', 'proactive.version' => 'required|integer|in:1',
'proactive.kind' => 'required|in:exists,needs,ideas', 'proactive.evidence' => 'present|array|max:60',
'proactive.evidence.*' => 'array:id,category,purpose,statement,interpretation,source,locator,period,scope,coverage,verified_on,access,document_id',
'proactive.evidence.*' => 'array:id,category,purpose,statement,interpretation,source,locator,period,scope,coverage,verified_on,access,document_id,observation_id',
'proactive.evidence.*.observation_id'=>'sometimes|nullable|string|size:64',
'proactive.supply_comparisons'=>'sometimes|array|max:40',
'proactive.evidence.*.id' => 'required|uuid|distinct',
'proactive.evidence.*.category' => 'required|in:activity,resource,infrastructure,supply,demand',
'proactive.evidence.*.purpose' => 'required|in:local,methodology',
@@ -47,6 +49,19 @@ final class ProactiveInvestment
'proactive.relations.*.reference' => 'required|string|max:4000',
'proactive.relations.*.evidence_ids' => 'present|array|max:60', 'proactive.relations.*.evidence_ids.*' => 'uuid|distinct',
])->validate()['proactive'];
$actor??=auth()->user();
if (!empty($d['supply_comparisons'])) $d['supply_comparisons']=app(ProactiveSupply::class)->comparisons($actor,$d['supply_comparisons'],$record);
foreach($d['evidence'] as &$proof){
if(!empty($proof['observation_id'])){
$canonical=app(ProactiveSupply::class)->evidence($actor,$proof['observation_id'],$record);
if($proof['access']!=='internal') $this->fail('evidence','A observação da base interna não pode ser reclassificada como pública.');
$proof=array_replace($proof,$canonical);
}
}
unset($proof);
foreach($d['supply_comparisons']??[] as $comparison){
if(!in_array($comparison['observation_id'],array_column($d['evidence'],'observation_id'),true))$this->fail('supply_comparisons','A comparação deve conservar a evidência interna da observação seleccionada.');
}
$ids = array_column($d['evidence'], 'id');
foreach ([$d['need'], ...$d['relations']] as $row) {
if (array_diff($row['evidence_ids'], $ids)) $this->fail('evidence', 'Seleccione apenas evidências desta análise.');
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
/** Internal observations, never a public business directory or proof of demand. */
final class ProactiveSupply
{
public const LIMITATIONS = 'Informação administrativa histórica e incompleta. Actividade actual, capacidade, procura e compromissos por confirmar. Ausência de registos significa informação insuficiente, nunca ausência de oferta.';
public function authorize(User $actor, ?Opportunity $record = null): void
{
$access=app(PortalProfileAccess::class);
$internal=collect(OpportunityManagement::CATEGORIES)->contains(fn($type)=>$access->internalAssignment($actor,$type)!==null);
abort_unless($access->legacyWriteAllowed($actor)||$internal,403);
if ($record?->exists) $access->authorize($actor,'view',$record);
}
private function read(User $actor, callable $callback): mixed
{
return DB::transaction(function()use($actor,$callback){
DB::select("select set_config('app.actor_user_id',?,true),set_config('app.proactive_supply_read','internal',true)",[(string)$actor->id]);
return $callback();
});
}
public function search(User $actor,array $input,?Opportunity $record=null): array
{
$this->authorize($actor,$record);
$v=Validator::make($input,[
'territory_id'=>['required','uuid',Rule::exists('geo_locations','id')->whereNull('deleted_at')],
'activity'=>'nullable|string|max:150','sector'=>'nullable|string|max:250','page'=>'nullable|integer|min:1|max:100000',
])->validate();
if(!Schema::hasTable('proactive_supply_observations'))return ['installed'=>false,'rows'=>[],'total'=>null,'limitations'=>self::LIMITATIONS,'status'=>'Base interna ainda não carregada neste ambiente.'];
return $this->read($actor,function()use($v){
$ids=[$v['territory_id']];
// Canonical descendants, not a name-only cross-province match.
$front=$ids;
for($depth=0;$depth<5&&$front;$depth++){
$front=DB::table('geo_locations')->whereIn('parent_id',$front)->whereNull('deleted_at')->pluck('id')->all();
$ids=[...$ids,...$front];
}
$base=DB::table('proactive_supply_observations as o')->join('proactive_supply_sources as s','s.sha256','=','o.source_hash')->whereIn('o.territory_id',$ids);
$sectors=(clone $base)->where('o.sector','<>','')->distinct()->orderBy('o.sector')->pluck('o.sector')->all();
if(!empty($v['sector']))$base->where('o.sector',$v['sector']);
if(!empty($v['activity']))$base->where(fn($q)=>$q->where('o.cae','ilike','%'.$v['activity'].'%')->orWhereRaw("translate(lower(o.activity),'áàâãéêíóôõúç','aaaaeeiooouc') LIKE ?",['%'.strtolower(\Illuminate\Support\Str::ascii($v['activity'])).'%']));
$page=$v['page']??1;
// Exact repeated observations are retained in storage, counted separately.
$total=(clone $base)->whereNull('o.duplicate_of')->count();
$duplicates=(clone $base)->whereNotNull('o.duplicate_of')->count();
$rows=(clone $base)->whereNull('o.duplicate_of')->orderBy('o.name')->orderBy('o.id')->offset(($page-1)*25)->limit(25)->get(['o.id','o.entity_key','o.name','o.province','o.district','o.territory_id','o.territory_resolution','o.cae','o.activity','o.sector','o.source_date','o.sheet','o.row_number','o.source_hash','o.identity_conflict','s.name as source_name','s.period as source_period','s.imported_at']);
$coverage=DB::table('proactive_supply_observations')->selectRaw("count(*) FILTER (WHERE territory_id IS NULL) AS unmapped, count(*) FILTER (WHERE territory_resolution='province_only_district_unknown') AS province_only")->first();
return ['installed'=>true,'rows'=>$rows,'total'=>$total,'duplicate_sightings'=>$duplicates,'page'=>$page,'per_page'=>25,'sectors'=>$sectors,'territory_coverage'=>$coverage,'limitations'=>self::LIMITATIONS,'status'=>$total?'Registos documentais encontrados; não equivalem a empresas únicas ou oferta actual confirmada.':'Informação insuficiente nesta base e nestes filtros.'];
});
}
/** Resolve server-side source lineage; a caller cannot fabricate an internal observation. */
public function comparisons(User $actor,array $input,?Opportunity $record): array
{
$this->authorize($actor,$record);
$v=Validator::make(['rows'=>$input],[
'rows'=>'array|max:40','rows.*'=>'array:observation_id,relationship,rationale,limitations',
'rows.*.observation_id'=>'required|string|size:64|distinct',
'rows.*.relationship'=>'required|in:competition,complementarity,partner,uncertain',
'rows.*.rationale'=>'required|string|max:4000','rows.*.limitations'=>'required|string|max:4000',
])->validate()['rows'];
if(!$v)return [];
abort_unless(Schema::hasTable('proactive_supply_observations'),422,'Base interna indisponível.');
return $this->read($actor,function()use($v){
$fingerprints=[];
foreach($v as $row){
$o=DB::table('proactive_supply_observations')->where('id',$row['observation_id'])->first();
if(!$o)throw \Illuminate\Validation\ValidationException::withMessages(['proactive.supply_comparisons'=>'Registo da oferta indisponível; preserve a referência anterior e confira o ambiente.']);
if($o->duplicate_of!==null||isset($fingerprints[$o->fingerprint]))throw \Illuminate\Validation\ValidationException::withMessages(['proactive.supply_comparisons'=>'Esta observação administrativa é repetida. Compare a referência original uma única vez; a repetição e a sua fonte permanecem conservadas na base interna.']);
$fingerprints[$o->fingerprint]=true;
}
return $v;
});
}
public function evidence(User $actor,string $id,?Opportunity $record): array
{
$this->authorize($actor,$record);
return $this->read($actor,function()use($id){
$o=DB::table('proactive_supply_observations as o')->join('proactive_supply_sources as s','s.sha256','=','o.source_hash')->where('o.id',$id)->first(['o.*','s.name as source_name','s.period as source_period']);
if(!$o)throw \Illuminate\Validation\ValidationException::withMessages(['proactive.evidence'=>'Observação interna indisponível neste ambiente.']);
return ['observation_id'=>$o->id,'category'=>'supply','purpose'=>'local','access'=>'internal','document_id'=>null,
'statement'=>'Registo administrativo: '.$o->name.'; CAE '.$o->cae.'; '.($o->activity?:'actividade por classificar').'; '.$o->district.', '.$o->province.'. Não confirma actividade actual.',
'source'=>$o->source_name.'; SHA256 '.$o->source_hash,'locator'=>$o->sheet.'!linha '.$o->row_number,
'period'=>$o->source_date.'; '.$o->source_period,'scope'=>$o->district.', '.$o->province,
'coverage'=>self::LIMITATIONS.($o->identity_conflict?' Identificador partilhado por denominações divergentes: identidade por conferir.':''),
];
});
}
}
+3 -1
View File
@@ -47,6 +47,7 @@ final class SectorInvestment
public function publicView(Sector $sector): array
{
$creation = app(ContentManagement::class)->access(request()->user())['opportunities']['create'] ?? null;
$query = SectorOrganization::opportunities($sector);
$articles = $this->guides($sector);
$relations = [];
@@ -67,7 +68,8 @@ final class SectorInvestment
return ['articles' => $articles, 'relations' => collect($relations)->unique('id')->values()->all(), 'locations' => $locations,
'start_href' => app(InvestorPath::class)->available() ? '/investimentos/a-partir-do-setor/'.$sector->slug : null,
'start_notice' => app(InvestorPath::class)->available() ? null : 'O percurso para guardar a ideia está temporariamente indisponível. A consulta e o apoio continuam disponíveis.',
'register_href' => app(PortalProfileAccess::class)->available() ? '/minhas-oportunidades/create?sector_id='.$sector->id : null,
'register_href' => app(PortalProfileAccess::class)->available() ? ($creation ? $creation.'?sector_id='.$sector->id : '/perfil/promotor') : null,
'register_label' => $creation ? 'Cadastrar oportunidade — escolher modalidade' : 'Pedir acesso para cadastrar oportunidades',
'register_notice' => app(PortalProfileAccess::class)->available() ? null : 'O cadastro por perfis está temporariamente indisponível; pode preparar a ideia e contactar o apoio.',
'specialization_href' => in_array($sector->slug, ['tecnologias-de-informacao', 'mercado-financeiro'], true) ? SectorOrganization::href('fintech') : null,
'content_notice' => $sector->slug === 'mercado-financeiro' ? 'O artigo geral de Mercado Financeiro permanece vazio e não foi publicado. A preparação de negócios neste setor está disponível.' : null];
@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('proactive_supply_sources', function ($t) {
$t->char('sha256',64)->primary();
$t->text('name');
$t->text('period');
$t->text('limitations');
$t->jsonb('receipt');
$t->timestampTz('imported_at');
});
Schema::create('proactive_supply_observations', function ($t) {
$t->char('id',64)->primary();
$t->char('source_hash',64);
$t->foreign('source_hash')->references('sha256')->on('proactive_supply_sources')->restrictOnDelete();
$t->text('sheet');
$t->integer('row_number');
$t->char('entity_key',64)->index();
foreach (['name','province','district','cae','activity','sector','source_date'] as $field) $t->text($field);
$t->char('fingerprint',64)->index();
$t->char('duplicate_of',64)->nullable();
$t->boolean('identity_conflict')->default(false);
$t->uuid('territory_id')->nullable()->index();
$t->text('territory_resolution');
$t->index(['province','district']);
$t->unique(['source_hash','sheet','row_number']);
});
// Technical defence of the private-analysis scope, not a new business role.
foreach (['proactive_supply_sources','proactive_supply_observations'] as $table) {
DB::statement("ALTER TABLE {$table} ENABLE ROW LEVEL SECURITY");
DB::statement("ALTER TABLE {$table} FORCE ROW LEVEL SECURITY");
DB::statement("CREATE POLICY {$table}_private_read ON {$table} FOR SELECT USING (current_setting('app.proactive_supply_read',true)='internal' AND NULLIF(current_setting('app.actor_user_id',true),'')::bigint IS NOT NULL)");
}
}
public function down(): void
{
if (DB::table('proactive_supply_sources')->exists()) throw new RuntimeException('Preserve imported source history before rollback.');
Schema::dropIfExists('proactive_supply_observations');
Schema::dropIfExists('proactive_supply_sources');
}
};
@@ -1636,3 +1636,210 @@ A falta de prova do caso (titular/rendimento/imposto/inclusão/uso/regra do per
Recibo consolidado:`docs/investmoz/simulations/irpc-l8-grouped-delivery-20261028.json`. Cópia remota:`/var/backups/investmoz-irpc-l8-grouped-20261028/installed-receipt.json`. Provas locais:`outputs/irpc-l8-grouped-delivery-20261028/`. Actualização documental segue ramo próprio de recibos,sem alterar novamente a revisão servida.
Cópia remota do recibo conferida:SHA256`b70fe154f95162f029b5ca712b41962757360d73b0ff07f52b8ce14848d990df`;a matriz existente recebeu saldoL8 nos camposEstado,Disponibilidade activa,Evidências,Critérios restantes eDependências,só em `AUD-SIM-INT02`.
## 28/10/2026 — listas como base interna da oferta: uso não demonstrado
**Decisão expressa da Tempo e resultado da conferência: a utilização das listas de empresas,hotéis/restaurantes como base comparativa no portal é NÃO DEMONSTRADA.** A entrega do percurso proactivo,os seus campos de evidência e os pilotos privados anteriores mantêm-se aceites;isso não demonstra carregamento,classificação e ligação destas listas ao consumidor de análise. O âmbito é primeiro a análise interna,não a publicação automática de um directório.
**Fonte e âmbito conferidos:**13ficheiros inventariados nos dois directórios acessíveis de empresas/relatórios,antes da selecção;originais `EMPRESAS_LIST2014–2022`, `Empresas Licenciadas`,cadastro turístico30/10/2024 e `CAE eBAU` localizados e iguais aos hashes aceites. As148557/212632/460linhasXML não vazias dos recibos anteriores são observações dos ficheiros,não empresas únicas nem carga no portal. Empresas têm província/distrito/CAE;isso não prova o mapeamento no portal. O cadastro turístico observado é de alojamento,não um censo completo da restauração. Não foi identificada lista autónoma de restaurantes nestes dois directórios;isso não significa ausência de restaurantes ou de actividades de restauração nas listas empresariais. Contactos e outros campos privados não foram publicados.
**Destino entregue:**código `569a1cd7`,base `teste_investidor`,198tabelas não sistémicas(174public+24skills_edu),consultadas em transacções READ ONLY com identidade técnicapostgres explicitada. Zero correspondências dos hashes originais entre368ficheiros persistentes e nenhuma correspondência exacta dos nomes/hashes nas referências pesquisadas. Cadastros `existing_businesses` e `investment_company_declarations` têm0registos nesse destino;organizações1,actividades11,documentos60 e recursos59 não são convertidos em oferta empresarial das listas. Esta é uma fotografia técnica,não prova de visibilidade para uma conta operacional,perda de registos ou ausência de oferta no mercado. Registos antigos de `investemoz` e basesQA são ambientes distintos;nenhuma eliminação é declarada. Pesquisa por nomes/hashes em metadados não exclui ficheiros renomeados,arquivados,conteúdo cifrado ou outros acervos.
**Causa delimitada no consumidor instalado:**`ProactiveInvestment::options` devolve documentos/grupos e referências a projecto,cluster,corredor,parque,cadeia;os hashes dos ficheiros instalados coincidem comGit. O editor recebe `observed_supply` e fontes/localizadores/períodos/cobertura como texto manual;as relações proactivas não admitem entidade empresarial destas listas. Não foi encontrada consulta de oferta carregada por localização/sector/actividade nem ligação de linhas das listas a concorrentes,complementares ou parceiros. A possível existência de cadastros noutro módulo não demonstra consumo por este percurso.
| Percurso de conferência — Gurué,distrito canónico | Resultado |
| --- | --- |
| Escolher localização | Referência canónica localizada;prova anterior de selecção territorial preservada,não se declara nova prova de interface. |
| Consultar oferta existente das listas | Não demonstrado:consulta/importação/classificação consumida pelo percurso não identificada. A demonstração pára neste ponto. |
| Comparar com uma ideia | Não demonstrado sobre conjunto rastreável de registos dessas listas. Campos manuais não equivalem à integração. |
| Identificar necessidade | Não demonstrado por este fluxo;ausência de registo não confirma necessidade nem ausência de concorrência. Manter hipótese,período,cobertura e incertezas. |
| Preparar oportunidade | Mecanismo privado entregue preservado;nenhuma oportunidade nova foi criada para simular passos anteriores ainda não demonstrados. |
**Critério de retoma:**lotes/linhas privados rastreáveis e classificados,reutilizando os mecanismos/cadastros existentes;consulta autorizada da oferta por localização/sector/actividade;comparação da ideia com concorrência,complementares e possíveis parceiros,com fontes/datas/cobertura;necessidade fundamentada sem inferir ausência de oferta;e prévia/gravação/recuperação da oportunidade no percurso existente emQA isolada. Só essa conferência pode demonstrar o uso;nenhum novo aprovador organizacional ou directório público é criado.
Sem imports,normalização,alteração de código,permissões,publicação ou escrita em dados activos. Songo/Gurué privados,aprovações/históricos e entregaL8 preservados. Registo:`docs/investmoz/unification/existing-supply-lists-audit-20261028.json`. Cobertura actualizada nas linhas existentesRF-CLU-003,MASTER-471012ab-0010,M2-d0c06a75-0147/0151 eMASTER-1f0a4cde-0002,sem nova matriz/backlog.
A base interna de oferta não exige transformar cada empresa numa organização utilizadora,anúncio de venda/parceria,promotor ou cliente confirmado. O cadastro e a consulta analítica são distintos da operação comercial e da divulgação.
## 28/10/2026 — oferta existente ligada à análise proactiva, demonstrada em QA
Incremento sobre o código entregue `569a1cd7`, ramo `codex/proactive-supply-qa-20261028`: **demonstrado em QA isolada, sem instalação nem importação no activo**. O relatório de conferência anterior permanece histórico: no destino activo, o uso das listas continua não demonstrado. Reutilizam-se a aceitação do percurso, o inventário de 13 ficheiros, os originais, as fontes e as provas anteriores; não se reabre a decisão da Tempo nem se encerra o estudo de mercado nacional.
Carregadas privadamente **361.464 observações**: 148.556 da lista 2014–2022, 212.631 de empresas licenciadas e 277 do cadastro turístico (duas folhas com estruturas distintas). Os quatro hashes dos originais e do CAE foram conferidos. Pré-visualização, análise Microsoft Defender sem ameaças e repetição idempotente 343.041→343.041 demonstradas. Os 147.165 registos com a mesma projecção administrativa são conservados como repetições; não representam empresas únicas nem duplicados de ficheiros por bytes. As 113.880 observações com denominações divergentes para um identificador partilhado ficam assinaladas, sem fusão automática. Contactos, documentos de identidade e NUIT não são devolvidos pela consulta.
Consulta interna por localização canónica (incluindo descendentes), sector CAE e actividade; resultados paginados conservam fonte, folha/linha, hash e data original. O sector provém da hierarquia SELF_ID do CAE fornecido, sem inventar correspondência com os sectores de navegação. A selecção permite comparar possível concorrência, complementaridade, parceiro ou relação por determinar, com fundamento e incertezas. O servidor recupera a origem, impede reclassificar a evidência interna como pública e conserva-a no rascunho e no dossier privado. Não transforma a lista em anúncios, promotores, clientes confirmados ou publicação de directório.
**Gurué:** o operador QA sintético 1021 consultou 410 observações distintas na primeira demonstração (435 após o complemento de registos sem denominação), comparou dois registos reais (serviços agrícolas CAE 01610 e comércio de plantas/sementes/fertilizantes CAE 47732) com a hipótese já preparada de apoio à colheita e qualidade da folha verde. A oferta possível exige verificar sobreposição e colaboração, sem presumir mercado vazio. Rascunho 402 passa de v1 a v2, mantendo integralmente as cinco evidências anteriores, a ideia e o histórico. O mesmo dossier `883c8ec3-59a3-4074-b91c-b7d4a5d88ff0` passa de contexto1 a2 por pré-visualização e confirmação idempotente, preservando título e campos deliberados. A necessidade permanece **hipótese**, sem procura, falta de oferta ou viabilidade confirmadas. Gravação, recarregamento, acesso privado e resultado vazio como **informação insuficiente** demonstrados no navegador. Isto é prova técnica, sem substituir observação/aceitação da equipa.
**Cobertura e pendências:** 205.608 observações sem correspondência territorial canónica única (incluindo 19 ambiguidades), 336 apenas com província e 82.864 sem designação de actividade CAE correspondente. Mantêm-se os rótulos originais e a incerteza; não há atribuição territorial aproximada nem preenchimento presumido. O cadastro de alojamento não é um censo exaustivo de hotéis/restaurantes. Situação operacional actual, capacidade, preços, procura e compromissos continuam por demonstrar. O incremento nacional é parcial; não encerra os requisitos amplos de mercado, clusters ou liquidação anual. **L8 permanece parcial e inalterado.**
Sete testes dirigidos/62 asserções, TypeScript e compilação QA passaram. As duas tabelas internas têm RLS forçada; teste com identidade sem privilégios confirma ocultação sem contexto de actor e leitura com contexto autorizado. Conferidos os hashes de todas as linhas anteriores nos sete conjuntos do inventário: só o rascunho402 e o mesmo dossier sofreram o delta intencional; Songo401, projectos, territórios, sectores, documentos e cadeias foram conservados. Provas fiscais aceites reutilizadas, sem repetir as suas suites.
Actualizados os cinco requisitos existentes `RF-CLU-003`, `MASTER-471012ab-0010`, `M2-d0c06a75-0147`, `M2-d0c06a75-0151` e `MASTER-1f0a4cde-0002`, preservando o restante estado e pendências. Nenhuma matriz ou backlog novo. A entrega activa futura mantém conferência de capacidade/compatibilidade com main, backup/restauro fresco, operação autenticada e delta instalado. Relatório: `docs/investmoz/unification/proactive-existing-supply-qa-20261028.json`.
Complemento final: as 18.423 linhas empresariais sem denominação social também foram conservadas, com essa lacuna explícita e sem reutilizar o nome pessoal do requerente. As 343.041 observações da primeira fotografia permaneceram idênticas; extensão e repetição idempotente atingem 361.464 sem substituir registos, alterar as comparações ou rever as provas aceites. Os exemplares e as fotografias anteriores foram preservados.
## 28/10/2026 — base comparativa instalada pelo Git; conferência visual autenticada pendente
Instalada e servida a revisão **`6d147693`**, que inclui o candidato aceite `a022ed98`, por fast-forward normal em `main`, sem força. A revisão569a1cd7 e os backups anteriores foram preservados. O carregamento privado foi executado em transacção autenticada depois de backup novo, restauro isolado e ensaio idempotente; o instalador Git, as suas guardas e a CI habitual foram conservados. O contrato recebeu apenas o hash da migração nova, mantendo os90 anteriores.
**361.464 observações administrativas não são361.464 empresas únicas.** As147.165 repetições foram conservadas, mas excluídas das contagens/resultados; as214.299 projecções administrativas distintas também não equivalem a empresas únicas. Em Gurué, o serviço instalado recupera435 observações distintas e conserva237 repetições fora dessa contagem. A API também recusa gravar uma repetição como outra comparação: apenas esse delta recebeu teste dirigido1/2asserções. As7provas/62asserções, tipos, compilação e percurso QA aceites foram reutilizados.
A aplicação, com a sua conta real e um actor existente que satisfaz as autorizações anteriores, consultou Gurué e conservou fonte/linha/data/hash. O resultado vazio foi «Informação insuficiente nesta base e nestes filtros.» Sem actor/contexto, a RLS devolveu0; acesso HTTP anónimo recusado401. Isto é conferência técnica do consumidor instalado, **não uma nova aceitação da equipa no navegador**. A conferência visual autenticada permanece pendente por bloqueio do mecanismo Windows de confirmação segura do URL; não pedir nova aceitação das provas QA nem confundir essa pendência com perda da funcionalidade entregue.
Backup fresco de174tabelas públicas e369ficheiros/configuração; restauro sem rede/55441 e os174checksums passaram. As duas tabelas privadas também foram carregadas, repetidas e restauradas no ensaio. Medidos344.399.872bytes para as novas relações;capacidade conferida antes da instalação. No activo, as linhas anteriores foram preservadas e os369ficheiros/configuração ficaram idênticos; o delta esperado é de2tabelas privadas e1registo de migração. Cache queue:restart e2sessões anónimas de conferência foram reconciliados separadamente. Foi criado também backup da base privada já instalada, preservando GRANT/RLS. Não se criaram utilizadores, organizações, oportunidades ou dossiers QA no activo, nem se publicaram as listas.
CI habitual do instalador: PostgreSQL1/3106asserções, Unit59/317, frontend1146aprovados/0falhas, tipos e build31,30s. O script do instalador e as configurações/ficheiros anteriores mantiveram-se; apenas leitura nas duas tabelas novas foi concedida à conta de aplicação existente. Não houve alteração de papéis de funcionários.
**Pendências conservadas:** cobertura nacional parcial,205.608observações sem território canónico único,336só com província,82.864sem designação CAE,113.880com divergências de denominação/identificador e18.423sem denominação social, todas preservadas. A ausência de registos é informação insuficiente. A comparação fundamenta uma hipótese, sem comprovar procura ou viabilidade. O caso/dossier QA anterior mantém-se em QA e não foi transplantado para o activo. **L8 permanece parcial e inalterado.** A actualização continua nos cinco requisitos existentes, sem nova matriz ou backlog. Recibo:`docs/investmoz/unification/proactive-supply-git-delivery-20261028.json`.
## 28/10/2026 — correcção do bloqueio da conferência visual
`6d147693` está instalado, incluindo a base interna. A única pendência da conferência desta entrega é a validação visual autenticada: **Gurué → consultar oferta → comparar ideia → guardar → recuperar no mesmo dossier**, conservando fonte, data e incertezas. As435 observações de Gurué não são435 empresas únicas. Listas internas, cobertura nacional parcial e hipótese de investimento mantêm-se.
O erro actual, novamente observado, é **“The browser access host is unavailable”**: indisponibilidade do host do conector, distinta de deixar uma aba aberta. A orientação anterior para apenas manter a aba aberta era insuficiente. Restabelecer o conector por mecanismo suportado ou realizar a conferência pela equipa. Não reinstalar nem repetir as provas técnicas aceites; não declarar conferência visual concluída sem o seu resultado. O recibo remoto original e o historial do diagnóstico anterior ficam preservados; esta correcção é documental.
## 28/10/2026 — revisão de menus: inventário antes da verificação do delta
Origem: recomendação expressa da Tempo após constatar que as entradas indicadas não aparecem ou ficam escondidas. Página relatada:https://teste-investidor.tempo.co.mz/. Preservados6d147693 e a base privada instalada,as provas QA do percurso e da navegação sectorial,os originais,dados,perfis,permissões e históricos. Não se declara uma funcionalidade anterior desaparecida apenas pela ausência de um novo teste.
Confirmado no código: barra lateral substitui cada grupo pelo seu primeiro destino,omitindo os restantes;menu principal esconde gestão/perfis dentro do menu do nome do utilizador;entrada de gestão de conteúdos existente não é incorporada nessa navegação principal. A conta e os props efectivos da sessão real permanecem por conferir,porque o conector está indisponível;não se presume falta de autorização.
Incremento em QA: área de trabalho explícita no menu principal,inclusive entrada neutra de acesso;destinos privados apenas segundo capacidades anteriores;mesmo catálogo completo na barra lateral,sem perda dos subdestinos;entrada directa na análise proactiva existente,sem substituir rascunhos editados;preservação de Descoberta/Realização/Suporte e todos os destinos anteriores aplicáveis. Sem alteração activa,schema,regras de negócio ou papéis. Escolhas de apresentação não são exigências organizacionais. Verificação dirigida do delta pendente nesta fotografia;nenhuma nova matriz ou backlog.
## 28/10/2026 — reorganização dos menus implementada em QA, sem instalação
Recomendação expressa da Tempo após a página principal não apresentar as entradas sugeridas. Causa delimitada no código: a barra lateral omitia subdestinos ao seleccionar apenas o primeiro link de cada grupo; a navegação principal colocava gestão/perfis dentro do menu do nome do utilizador e não incorporava a entrada de gestão já calculada pelo servidor. Não se presume falta de permissões da sessão real, cujos props não foram observados. A entrega6d147693/base interna continua instalada e preservada; esta revisão não reinstala essa base nem repete as provas técnicas aceites.
**Proposta executável QA:** Área de trabalho visível ao lado de Descoberta,Realização,Suporte; entrada neutra Acesso à gestão explica o estado, mesmo sem acções privadas atribuídas. As acções autorizadas e Preparar análise e ideia vêm das capacidades existentes, sem criar papéis. A análise abre directamente no formulário existente; rascunhos guardados não são substituídos. A barra lateral põe trabalho primeiro e conserva todos os destinos e os filtros territoriais entre catálogos, em vez de reduzir grupos ao primeiro destino. O cabeçalho permite quebra de linha em largura intermédia; os controlos móveis e do teclado existentes são reutilizados.
**Verificação delimitada:**16 testes de navegação/renderização estrutural,0falhas;tipos,compilação QA e sintaxe PHP passaram. Quatro cenários de capacidades conservaram todos os destinos anteriores; não surgem operações privadas em cenário anónimo ou sem atribuições. SSR usa o componente real da barra lateral com wrappers semânticos de teste; não equivale a captura do navegador. A prévia HTML é um inventário interactivo de destinos, sem pedidos ao portal, identificado como proposta QA, não como sessão real.
**Pendente:** conferência visual desktop/móvel/teclado/toque e das capacidades efectivas da sessão relatada; o host do conector permanece indisponível no último diagnóstico. Não substituir essa conferência por alegação de acesso demonstrado. A instalação futura deve transportar só o delta de menus pelo processo Git/backup/restauro existente, preservando originais, dados, permissões, contexto,dossier e simulador.435 observações de Gurué não são435empresas únicas; listas internas/cobertura parcial/hipótese preservadas. L8 continua parcial.
Inventário anterior registado antes dos testes nesta matriz/manual. Actualização apenas nos requisitos existentesV2-01,V2-07,V2-10,MASTER-9dee08d0-0011,MASTER-9f6dd1d9-0001,MASTER-9f6dd1d9-0003,MASTER-02f44baf-0002;não há matriz ou backlog novos. Relatório:`navigation-reconciliation-qa-20261028.json`;prévia:`outputs/navigation-reconciliation-20261028/menu-proposta-qa.html`.
A apresentação da gestão antiga usa agora a decisão efectiva já existente (`legacyWriteAllowed`, com o requisito de email verificado das rotas), em vez de presumir que o indicador genérico de gestor basta. Não se alteraram regras, atribuições ou permissões. Preservada também a entrada de instalação de perfis anteriormente presente apenas na barra lateral autorizada. Os nomes longos quebram linha na barra lateral, sem reticências que escondam a função.
## 28/10/2026 — menus 6e8de43a instalados pelo Git; conferência visual pendente
A Tempo aprovou a proposta QA e autenticou o transporte SSH existente. `main` avançou normalmente de `6d147693` para `6e8de43a`, sem force. A entrega usou o pipeline existente, sem alterar o seu contrato de schema, sem migrações, importações, atribuições de papéis ou permissões. Os 16 testes dirigidos e as provas QA aceites foram reutilizados; as verificações automáticas obrigatórias do pipeline não representam nova aceitação funcional.
Capacidade conferida antes da entrega: cerca de 78,5 GiB livres. Backup novo em `/var/backups/investmoz-navigation-delivery-20261028/backup`; restauro em base isolada, sem rede, confirmou as 176 tabelas por contagens/checksums. Os 369 ficheiros persistentes/configuração mantêm-se idênticos. As tabelas de domínio, incluindo base interna, relações e permissões, foram comparadas com o snapshot do backup; apenas alterações operacionais de cache/sessão são registadas separadamente. A versão anterior está preservada para retorno pelo mecanismo existente.
O delta instalado contém Área de trabalho no nível principal, destinos subordinados completos na barra lateral e continuidade para os destinos existentes de investimento, conteúdos e acompanhamento. A exposição das acções respeita a decisão efectiva do servidor; não se infere nova autorização pelo nome genérico de gestor. A presença dos assets e props instalados foi conferida tecnicamente, sem a apresentar como prova da sessão real.
Pendente: conferência visual autenticada desktop/móvel/teclado/toque e percurso Gurué → consultar oferta → comparar ideia → guardar → recuperar no mesmo dossier. Último bloqueio conhecido: `The browser access host is unavailable`; uma aba aberta não restabelece o host. Não repetir as provas QA aceites nem reinstalar a base interna para resolver essa pendência. As 435 observações de Gurué não equivalem a 435 empresas únicas; listas internas, cobertura parcial e hipótese de investimento mantêm-se explícitas. L8 permanece parcial.
Evidência: `docs/investmoz/unification/navigation-git-delivery-20261028.json`. O relatório QA anterior permanece como histórico do candidato antes da instalação. Actualizados os mesmos sete requisitos existentes, preservando o seu histórico; não foi criada outra matriz.
# Inventário antes da verificação — catálogo territorial, 28/10/2026
Preservar menus `6e8de43a`, dados, base interna, aprovações, configuração de corredores e históricos já aceites. Caso Maputo Província / Agronegócio reproduzido por leitura QA: zero resultados apesar de cinco oportunidades públicas do Corredor de Maputo, cujo âmbito provincial está aprovado desde 23/10 em `corridor-adaptive-context-20261023.json`. A consulta não inclui esse contexto; os cartões também não mostram todos os níveis territoriais disponíveis. Correcção delimitada nos requisitos existentes V2-07/V2-10, sem inferir distrito/posto administrativo onde a fonte não os indica. Conferência visual autenticada continua pendente; não há nova matriz nem reabertura de provas aceites.
## 28/10/2026 — correcção provincial do catálogo em QA, ainda sem instalação
Caso da Tempo: Agronegócio / Maputo Província. A leitura QA reproduziu zero resultados para cinco oportunidades públicas do Corredor de Maputo. Reutilizada a decisão expressa de 23/10 que abrange Maputo Província e Maputo Cidade, antes disponível no percurso mas omitida na consulta. O filtro provincial passa a incluir esse contexto aprovado, mantendo sector e estado público, sem duplicar oportunidades nem atribuir distrito/posto administrativo ou coordenadas inexistentes. Uma localização explícita posterior prevalece sobre o contexto antigo.
Os resultados apresentam a hierarquia territorial disponível; o contexto de corredor é identificado e os níveis não indicados continuam explícitos. Pesquisa/API, vista por corredores e lista sem coordenadas do mapa concordam. O atalho «Ver toda a província» elimina a necessidade de limpar vários níveis manualmente. Não foram criadas publicações, relações a mestres ou permissões. Provas QA: 4 testes/70 verificações, zero falhas, transacções revertidas; tipos, compilação QA e sintaxe PHP passaram. O inventário anterior foi registado antes dos testes. Menus 6e8de43a, base interna, históricos e aceitação anterior preservados; L8 permanece parcial.
Relatório: `catalogue-province-coverage-qa-20261028.json`. Instalação pelo processo Git e conferência visual autenticada continuam pendentes. Não declarar auditados todos os outros menus por este delta. Cobertura actualizada nos requisitos existentes V2-07/V2-10; sem nova matriz.
28/10 — Âmbito nacional confirmado pela Tempo: antes da extensão dos testes, preservar a correcção QA 1b2ede41 e verificar os seis corredores aprovados nas onze unidades provinciais, hierarquia descendente e reutilização entre sectores/entradas de localização. Não presumir cobertura de corredor onde não está documentada. Nenhuma prova anterior é revogada.
28/10 - National coverage: 6 tests / 617 assertions passed across all 11 provincial units, 6 configured corridors and 2 sectors, including descendant hierarchy, sector separation and both province/location entry points. The runtime correction already used the common national hierarchy; Maputo was the reported reproduction, not a geographic limit. Existing publication decisions and unknown district/post/site information remain preserved. This is not a claim that every unrelated portal menu has been audited. No additional runtime change since 1b2ede41; types/build accepted evidence reused. Installation remains pending.
28/10 - Before selector delta checks: accepted national catalogue QA 47dae1b7, 6 tests/617 assertions, remains uninstalled. Existing TerritoryLevel and ProvinceSelect required two separate fields (search then select). Tempo requests one dropdown on the search field. Consolidate the shared controls while retaining saved values, hierarchy, endpoints, paging and permission decisions;browser visual proof remains separate.
## 28/10/2026 — selecção territorial num único campo de pesquisa, QA
Pedido expresso da Tempo: o dropdown deve abrir em «Pesquisar província», sem um segundo controlo «Seleccione». `ProvinceSelect` e `TerritoryLevel` passam a usar o mesmo combobox, aplicado a província, distrito, posto administrativo e localidade. Pesquisar e escolher fica num único campo; a paginação permanece na lista. Escrever ou sair com Escape não substitui a selecção guardada; escolher e limpar são acções expressas. Mantêm-se a hierarquia, os endpoints existentes e os controlos de acesso, sem novos dados ou publicações.
Quatro testes do contrato de eventos/estado passaram: abertura/escolha, preservação ao escrever/Escape, teclado e bloqueio de resultado antigo durante carregamento, limpeza e dependência do nível anterior. Tipos e compilação QA passaram. As 6 provas nacionais/617 verificações são reutilizadas porque o PHP territorial não mudou. Este delta continua em QA e não comprova apresentação ou sessão real no navegador. Será agrupado com a correcção provincial; falta autenticação SSH antes da entrega, sem nova aprovação organizacional.
28/10 - Before catalogue presentation checks: accepted province QA6/617 and unified selector QA4 remain preserved and uninstalled. Tempo reports Tourism/Cabo Delgado shows educational text and broad sector links. Read-only public API confirmed zero filtered records;unfiltered tourism returns16 records,all declared in Inhambane. This does not establish absence of tourism supply in Cabo Delgado. Correct presentation to retain filters and expose guides only as optional links;do not republish or reassign opportunities to manufacture results.
## 28/10/2026 — resultados do catálogo separados do apoio educativo, QA
A Tempo esclareceu que a combinação província/sector deve apresentar as oportunidades correspondentes, sem texto educativo ou atalhos que troquem a consulta por todas as oportunidades do sector. A consulta pública anónima conferiu Turismo/Cabo Delgado = zero oportunidades cadastradas; Turismo sem localização = 16, todas declaradas em Inhambane. Este snapshot não prova ausência de oferta turística em Cabo Delgado e não substitui inventário de fontes ou consulta autenticada.
Correcção de apresentação em QA: título dos resultados explicita sector e hierarquia escolhidos; removidos os cartões educativos e o botão sectorial sem localização da área de resultados. Os guias mantêm-se como títulos ligados em apoio opcional fechado por defeito. A remoção da localização é uma acção expressamente identificada como pesquisa noutras províncias. O estado vazio refere o cadastro consultado e a cobertura incompleta, sem fabricar oportunidades nem alargar automaticamente filtros.
Dois testes SSR do novo apoio opcional passaram, bem como tipos e compilação. As provas territoriais nacionais 6/617 e os quatro testes do selector são reutilizados; não mudou a consulta PHP ou o controlo territorial neste delta. Mantêm-se histórico, publicações, permissões, fontes e base interna. Este candidato continua sem instalação, dependente de autenticação SSH; a sessão real permanece sem prova visual. Relatório existente: `catalogue-province-coverage-qa-20261028.json`, campo `catalogue_result_presentation`. Requisitos V2-07/V2-10 actualizados na mesma matriz; L8 permanece parcial.
28/10 - Before investor usability checks: accepted catalogue candidate54d1b2a5 remains in QA,not installed;active menus6e8de43a and earlier supply proofs preserved. Two Tempo attachments identify duplicate profile navigation,an exposed long canonical form and ZEE/ZFI/park searches that keep prior results until manual submit. Prepare presentation-only adaptive field groups and safe save-before-resource navigation;verify territorial search/count/paging and documented scope without republishing records or changing permissions. Reuse prior proofs;existing V2-07/V2-10 scope retained.
## 28/10/2026 — usabilidade do investidor e consulta territorial, QA
Pedidos da Tempo e exemplos recebidos: menu/ficha de oportunidade, zonas/parques, pedido de Promotor, Os meus investimentos, documentos/formulários e recusa autenticada em Turismo. Inventário anterior registado antes dos testes: menus 6e8de43a e base interna instalados; candidato territorial 54d1b2a5 ainda em QA; originais, dados, relações, histórico, aprovações e provas anteriores preservados.
Delta QA: acções do investidor em primeiro plano, administração em detalhe consultável; campos do contrato existente agrupados por tarefa e modalidade, com navegação livre e todos os campos montados para conservar edições. Os grupos mostram as pendências da última gravação e os erros existentes; não criam seis ou oito formulários obrigatórios. Guardar usa os mesmos endpoints/ETags; abrir recursos pela preparação guarda primeiro e regressa à mesma oportunidade/grupo. Contactos e detalhes de disponibilidade ficam opcionais; códigos técnicos de categorias e campos conhecidos têm nomes em português. Só um contexto único de promotor é escolhido automaticamente.
Zonas: selecção territorial aplica pesquisa sem esperar por outro clique; a lista anterior deixa de aparecer durante carregamento/erro. Totais, intervalo visível e páginas identificados, sem apresentar a primeira página como o conjunto completo. «Todo o território» remove expressamente a localização, preservando os restantes critérios visíveis. Abrangência jurídica documentada e validada inclui os níveis descendentes; influência não se torna implantação. Não são criadas localizações, direitos, publicações ou certificados.
Pedido de Promotor: representação/modalidades com nomes claros; pesquisa de ficheiros e selecção expressa dos comprovativos, preservando os escolhidos ao pesquisar. Mantidos propriedade, análise antimalware e verificações existentes; legislação/imagem/teste não se torna comprovativo por estar disponível. Os meus investimentos destaca a próxima acção e permite abrir fases/etapas; fontes, códigos e acompanhamento interno ficam preservados em detalhes.
Formulários: entradas directas para os campos já existentes da preparação do pedido de investimento e Terra/DUAT; pedido de apoio identificado separadamente. Originais oficiais e guias continuam consultáveis, sem os confundir com um formulário preenchível ou converter leis em campos/cálculos. Não se declara executada a totalidade dos formulários oficiais do acervo.
Provas delimitadas: 8 testes frontend de contrato/SSR e 3 testes isolados/12 verificações, zero falhas; tipos, compilação e sintaxe PHP passaram. Dados sintéticos revertidos em QA. Provas nacionais/selector/catálogo anteriores reutilizadas. Turismo: leitura anónima activa HTTP200 e QA visitante/conta sem Promotor HTTP200; a recusa da sessão real relatada não foi reproduzida, nem atribuída a uma permissão sem prova. A conferência autenticada e visual continua pendente. Este registo não instala o candidato nem encerra a implementação transversal. L8 permanece parcial.
Evidência: `investor-usability-qa-20261028.json`. Actualizados V2-01,V2-07,V2-10 na matriz existente; sem nova matriz ou backlog.
### 28/10 — botão de cadastro e compatibilidade do pipeline
A Tempo esclareceu que a recusa em Turismo ocorreu depois de clicar num botão; não foi possível recuperar o nome. A rota pública continuou HTTP200. Conferência de forma pública/origem não identificou anexos privados nos documentos públicos de Turismo; não se atribui a recusa à perda de publicação. Encontrada uma falha delimitada no consumidor: o botão de cadastro dependia da disponibilidade do módulo, sem a capacidade efectiva da conta. Corrigido para reutilizar o destino autorizado de criação de ContentManagement; sem essa capacidade, mostra expressamente o pedido de acesso já existente. Não cria papéis nem restringe a consulta pública ou a preparação de ideias. A confirmação visual da acção real permanece pendente.
O candidato 502e3ec4 falhou na Unit de agrupamento de corredores antes da troca de release; 6e8de43a ficou servido. Causa: a simulação do catálogo não representava a nova apresentação de localização e uma consulta era feita antes de conferir se a fonte era aplicável. Correcção: conferir primeiro a fonte e simular a nova dependência no teste, conservando as verificações anteriores de identidade, grupos e contagens. Regressão Unit1/13 passou; não foi alterado o pipeline, o contrato de schema ou a exigência dos testes. Frontend9, Feature3/14, tipos e compilação passaram. Rótulos conhecidos de temas, categorias e idiomas do acervo traduzidos para apresentação; valores dos filtros preservados.
## 28/10/2026 — entrega agrupada de usabilidade e filtros instalada, 789fffd0
Entrega autorizada pela Tempo e autenticada pelo mecanismo SSH existente. `main` avançou normalmente e o pipeline existente instalou `789fffd0`, preservando a release anterior `6e8de43a`. O candidato inicial `502e3ec4` foi recusado antes da troca por uma dependência não representada no teste Unit do agrupamento de corredores; o activo ficou preservado. A consulta desnecessária foi corrigida e a nova dependência simulada, conservando as provas anteriores. O pipeline e o seu contrato de schema não foram enfraquecidos.
Backup/restauro de 176 tabelas confirmado numa instância isolada, sem rede. Os 369 ficheiros persistentes/configuração mantêm-se idênticos. A comparação do snapshot com o instalado preserva as tabelas de domínio, incluindo papéis, publicações, originais e base interna; diferenças de cache/sessões são registadas separadamente. Sem migrações, importações ou activação fiscal. A versão anterior permanece disponível para o mecanismo de retorno.
Incluídos: consulta provincial nacional/corredores e detalhe territorial; pesquisa/selecção num único controlo; resultados do catálogo separados do apoio educativo; grupos adaptáveis de preenchimento, navegação livre e gravação antes de abrir recursos; pedido de Promotor legível com comprovativos escolhidos expressamente; zonas filtradas ao seleccionar, contagens/paginação visíveis; fases/etapas do dossier e fontes em detalhes; entradas para formulários de preparação já existentes; CTA sectorial conforme capacidade efectiva; rótulos do acervo legíveis. Não são novas permissões, publicações ou formulários fiscais oficiais inventados.
Conferidos o commit servido, APIs filtradas e assets instalados. Reutilizadas as provas QA aceites; a bateria obrigatória do pipeline não reabre a sua aceitação. Mantém-se pendente a conferência visual autenticada, incluindo navegação, ida/volta a recursos e Gurué no mesmo dossier. A recusa relatada em Turismo ocorreu após um botão; a falha concreta do CTA de cadastro foi corrigida, mas não se declara reproduzida e resolvida a acção original sem a sessão real. A escolha do formulário fiscal concreto ainda precisa de ser esclarecida; os diplomas IVA mostrados não são formulários preenchíveis.
Estado actual: `investor-usability-git-delivery-20261028.json`. Os relatórios QA anteriores conservam o estado histórico antes da instalação. As 435 observações de Gurué não são 435 empresas; as listas são internas, a cobertura nacional é parcial e a comparação sustenta hipóteses. L8 permanece parcial. Actualizados V2-01/V2-07/V2-10 na matriz e manual existentes.
## 28/10/2026 — preparação de formulários fiscais, inventário antes das provas
Pedido expresso da Tempo: preencher formulário de IVA ou IRPC, em vez de abrir legislação. Base preservada: entrega instalada 789fffd0, Modelo A e aquisições/apuramento já aceites, L6 M20/M22 instalado em 5aaf1a56 e incrementos de L8. Nenhuma perda confirmada neste recorte; não se apagam as aceitações nem se repetem provas fiscais anteriores. Fontes reutilizadas do inventário de 20 exemplares acessíveis em outputs/irpc-l6-values-forms/accessible-source-inventory.json da QA anterior, incluindo temporários identificados; inventário do corpus e originais preservados. M22: três páginas conferidas visualmente nesta revisão, SHA256 4b61bd83d1fbcfd3b7b5c5523cea5923f7d0f65dc4c004eff58e5db38f763c88. Modelo A: correspondência e divergências aceites em modelo-a-reconciliacao-20261021.md; sem nova validação de proveniência.
Incremento em preparação QA: entrada directa em Documentos e Formulários, preenchimento manual dos campos correspondidos de IVA e subconjunto M22, identificação/período, revisão, impressão e ficheiro privado para recuperação mediante confirmação. Escolha técnica: sem persistência no servidor nem ligação automática ao dossier neste incremento; não altera schema, dados, permissões ou cálculos. Ficheiro não publicado. IRPC ajustamentos completos, outros regimes, anexos, assinatura e submissão à AT fora da cobertura; L8 permanece parcial. Ainda sem instalação deste incremento. Próximas provas apenas do delta: fidelidade da recuperação, rejeição de ficheiros incompatíveis sem perda, formatos/valores desconhecidos, acesso público e compilação.
## 29/10/2026 — ACT-20261029-01, inventário antes da verificação do incremento
Continuação sequencial solicitada pela Tempo. Correspondência: ACT-20261029-01 reutiliza D-02 §8, clusters/cadeias e a orientação metodológica documentada em 23/10. Concluído previamente e preservado: gestão de clusters, etapas, ligações, lacunas e relações auditadas; disponibilidade instalada de carteira/âncoras e formulários fiscais conforme recibos de 28/10. Nenhuma perda nova confirmada nesta inspecção de código; não foi feita nova comparação de dados ou alegada prova do servidor. A pasta principal está em fde644af, enquanto o último recibo identifica 332c16eb instalado; reconciliar o delta sobre essa origem antes de instalar, sem substituir o destino pela pasta principal.
Incremento em preparação: diagnóstico qualitativo das cinco forças na edição do cluster, sector/região editáveis, fonte/período/evidência/justificação/limites/oportunidade por força; indeterminação explícita, ficheiro privado transferível e recuperação com confirmação. Sem alteração de schema, dados, permissões, publicações ou motores fiscais. Persistência no servidor, ligação territorial canónica, dados sectoriais fundamentados e consulta pública permanecem por implementar; ACT-01 não está concluída. Verificação dirigida e instalação ainda pendentes neste registo inicial. Restantes ACT-02 a 16 mantêm o estado anterior e a sequência, sem trabalho SGF nesta passagem.
### ACT-20261029-01 — candidato sequencial preparado, 29/10/2026
Primeiro incremento implementado, **parcial e ainda não instalado**: gestão → clusters → editar → Competitividade. As cinco forças conservam fonte, período, evidência, apreciação, limites e oportunidade; sector e região explícitos, sem inferências automáticas. Transferência de rascunhos JSON e recuperação restrita ao mesmo cluster, com confirmação antes de substituir. Quatro testes frontend dirigidos, TypeScript e compilação QA passaram tanto na pasta de trabalho como no candidato baseado em 332c16eb. Guarda C132 local passou; não foram efectuados testes nem escritas na base activa. A edição existente recebe apenas a inclusão do componente; contratos e permissões existentes preservados.
Origem reconciliada: main remoto e revisão pública conferidos em 332c16eb em 29/10. Candidato em C:/laragon/www/portal-investidor-competitiveness-qa-20261029, ramo codex/competitiveness-sequential-20261029. Consulta do estado remoto não equivale a inventário novo da base. Sessão SSH anterior encerrada; autenticação por chave recusada. Instalação requer autenticação SSH, backup fresco, restauro isolado, guarda de versão, pipeline existente e conferência posterior. Não instalar a pasta principal sobre o destino. A sequência solicitada mantém ACT-02 a 16 por iniciar após a instalação e o saldo de ACT-01; persistência no servidor, cobertura factual e consulta pública continuam pendentes. Sem declaração de conferência visual autenticada.
### 29/10/2026 — ACT-20261029-01, primeiro incremento instalado: 23ecccfa
Estado corrente de ACT-01: **parcial — primeiro incremento instalado**, substituindo apenas o estado planeado/preparado deste recorte; ACT-02 a 16 não foram implementadas nesta entrega. Instalado por avanço normal de main e pipeline existente em https://teste-investidor.tempo.co.mz/. Entrada: Gestão → Clusters → Editar → Competitividade — cinco forças de Porter. Preparação qualitativa por sector/região, evidências/fontes/períodos/limites e oportunidades, transferência JSON e recuperação confirmada do mesmo cluster. Sem gravação no servidor nem publicação da análise. Persistência, cobertura factual, território canónico e consulta pública continuam pendentes; esta instalação não encerra o critério amplo ACT-01.
Provas: backup fresco, restauro isolado sem rede das 176 tabelas; pipeline com lifecycle de schema (3.106 asserções), 59 testes Unit/320 asserções e 1.180 testes frontend sem falhas, tipos e compilação; quatro ficheiros do incremento conferidos por SHA-256 no instalado; C132 no destino passou. Comparação após instalação: todas as 176 tabelas de domínio preservadas, diferenças operacionais apenas em cache/sessões; 369 ficheiros/configuração idênticos. Acesso anónimo ao catálogo privado recusado HTTP401. Zero migrações/importações, nenhuma nova divulgação nem mudança de permissões; versão anterior 332c16eb preservada. Conferência visual autenticada permanece pendente. Recibo: `docs/investmoz/unification/competitiveness-sequential-qa-20261029.json`, em docs/investmoz/unification. As provas aceites anteriores foram reutilizadas; não foram reabertas por esta entrega.
@@ -0,0 +1,131 @@
{
"date": "2026-10-28",
"status": "QA_corrected_and_verified_not_installed",
"installed_baseline": "6e8de43ae7fec29b0766d95e209a8cb8baf0d88c",
"user_case": "Agronegócio / Maputo Província",
"reproduction": {
"QA_public_agribusiness": 37,
"Maputo_corridor_public_opportunities": 5,
"province_results_before": 0,
"province_results_after": 5
},
"cause": "The provincial catalogue ignored the corridor context already approved by Tempo; no records were deleted.",
"reused_decision": [
"config/investment_corridors.php",
"docs/investmoz/unification/corridor-adaptive-context-20261023.json"
],
"correction": [
"Reuse approved corridor province context only at province level, with the accepted source hash and existing public publication decisions",
"Do not override an explicit primary/additional territory or infer district/post/coordinates",
"Display available province,district,administrative post,locality directly in results",
"Show both approved Maputo units as contextual coverage, not as two separate opportunities",
"Keep catalogue,API,corridor view and unlocated map list consistent",
"One action returns from district to whole province; no requirement to select every level"
],
"QA": {
"tests": 6,
"assertions": 617,
"failures": 0,
"database": "proactive_investment_20261027_test",
"port": 55441,
"transactions_rolled_back": true,
"types": "passed",
"build": "passed",
"PHP_syntax": "passed"
},
"preserved": [
"Installed menus and accepted supply proofs",
"Originals,source data,territory IDs,publication states,permissions,private lists and histories",
"L8 unchanged,partial"
],
"limitations": [
"No active installation in this report",
"Real authenticated visual conference remains pending;last connector error The browser access host is unavailable",
"This fixes the shared investment catalogue and corridor view;it is not proof that every menu in the portal has been audited",
"Provincial corridor coverage is not a documented district site;unknown levels stay unknown"
],
"existing_requirements": [
"V2-07",
"V2-10"
],
"restriction_origins": {
"territorial_behaviour": "Tempo current request",
"corridor_coverage": "Tempo express decision,23/10/2026",
"no_new_publication_or_roles": "existing approved scope",
"UI_summary_and_province_shortcut": "implementation choice"
},
"scope": "national;shared investment catalogue and similar location entry points",
"national_verification": {
"provincial_units": 11,
"approved_corridors": 6,
"sectors_for_descendant_fixtures": 2,
"province_and_legacy_location_entry_points": true,
"district_containment": true,
"unrelated_provinces_excluded": true,
"test_data": "synthetic fixtures rolled back;accepted published source reused"
},
"selector_delta": {
"Tempo_request": "Dropdown belongs to Pesquisar província;remove separate Seleccione control",
"scope": [
"ProvinceSelect",
"TerritoryLevel:province,district,administrative post,locality"
],
"behaviour": [
"One combobox input opens the list and searches the existing endpoint",
"Explicit choice commits the existing option;typing or Escape does not clear saved values",
"Arrow keys and Enter select;clear is explicit;dependent levels remain disabled without parent",
"Paging retained inside dropdown;loading cannot commit a stale option"
],
"QA": {
"event_state_contract_tests": 4,
"failures": 0,
"types": "passed",
"build": "passed",
"limit": "Controlled React component contract;not browser visual/session evidence"
},
"publication_data_permissions_and_territory_rules": "unchanged",
"national_province_evidence": "6 tests/617 assertions reused;unchanged PHP runtime",
"status": "QA_not_installed"
},
"catalogue_result_presentation": {
"Tempo_request": "Province+sector must show matching opportunities,not educational text and unfiltered national sector shortcuts",
"public_read_snapshot": {
"filtered": {
"total": 0,
"province": "Cabo Delgado",
"sector": "turismo",
"province_id": "059b02ca-7095-577a-8705-4d4c46896747"
},
"active_writes": 0,
"sector_only": {
"total": 16,
"declared_provinces": [
"Inhambane"
],
"sector": "turismo"
},
"method": "anonymous public API read; no authenticated session claimed",
"meaning": "public catalogue snapshot;not evidence of absence of market supply",
"checked_at": "2026-10-28T19:03:36.8285860Z",
"target": "https://teste-investidor.tempo.co.mz/"
},
"UI_changes": [
"Heading identifies applied sector and territorial path",
"Educational cards and their broad sector opportunity button removed from catalogue results",
"Guide titles remain available only inside closed optional support details",
"Removing location is explicitly labelled as searching other provinces",
"Empty state is limited to published records in the consulted catalogue;not absence of market supply"
],
"QA": {
"new_component_SSR_tests": 2,
"failures": 0,
"types": "passed",
"build": "passed",
"accepted_national_and_selector_proofs": "reused;backend and selector unchanged"
},
"status": "QA_not_installed",
"authenticated_visual_validation": "pending;public API read does not prove real session behaviour",
"active_writes": 0,
"new_publications": 0
}
}
@@ -0,0 +1,147 @@
{
"activity": "ACT-20261029-01",
"status": "partial_increment_installed_preservation_verified_visual_pending",
"baseline": "332c16ebb555e8d6b244fa519bc777d89c1a8b8f",
"branch": "codex/competitiveness-sequential-20261029",
"target": "https://teste-investidor.tempo.co.mz/",
"checks": {
"diff": "passed",
"types": "passed",
"diagnostics_C132": "passed_in_original_workspace",
"frontend_tests": 4,
"isolated_qa_build": "passed"
},
"active_database_writes": 0,
"schema_changes": false,
"permission_changes": false,
"coverage": [
"five qualitative forces in existing cluster management",
"sector and regional scope",
"evidence, source, period, rationale, limitations and investment opportunity",
"explicit uncertainty",
"JSON download and confirmed same-cluster recovery"
],
"remaining": [
"authenticated visual verification",
"server persistence and canonical territorial scope",
"factual sector and regional data",
"public consultation"
],
"source_sha256": {
"resources/js/lib/cluster-competitiveness.ts": "1ed496f51f020da9f0c02ab0aa324b58a9dd17fa34a10d8c1a90d9915800fecb",
"resources/js/pages/structures/competitiveness.tsx": "55338c144cefae4f91cef765b8e5e8b28ceb6c76375d46943c3c3430aeddc68e",
"resources/js/pages/structures/editor.tsx": "080a90ab8607f05ec493e560ed8280a6f7eec743ed1954f0057a0ce6caa09208",
"tests/frontend/cluster-competitiveness.test.mjs": "f48a9ed872f49ad900424daab4689cb0eeeb31cc93ebf1c608408c0a8c2b435b"
},
"candidate": "23ecccfaad1053f55bf0591fd242cb5fa3406c38",
"backup": {
"stage": "/var/backups/investmoz-competitiveness-delivery-20261029/backup",
"created_at": "2026-10-29T11:04:46Z",
"target": {
"database": "teste_investidor",
"port": 5432
},
"tables": 176,
"files_verified": 369,
"dump_sha256": "7979fa2b3fe1f69e62aad9fadf60a60d7b068e198cdc291a8fc79d409d67c7ca",
"files_sha256": "8e0022b73cd0074e36f8efef0b7c803a386e24e6e4cf85f567757ff67e311369",
"restore_verified": false
},
"restore": {
"restore_verified": true,
"tables_verified": 176,
"container_network": "none",
"database": "competitiveness_delivery_20261029_test",
"port": 55441,
"source_dump_sha256": "7979fa2b3fe1f69e62aad9fadf60a60d7b068e198cdc291a8fc79d409d67c7ca",
"active_database_writes": 0
},
"preservation": {
"navigation_asset_and_shared_props_verified": true,
"at": "2026-10-29T11:10:44.303271+00:00",
"served_commit": "23ecccfaad1053f55bf0591fd242cb5fa3406c38",
"backup_dump_sha256": "7979fa2b3fe1f69e62aad9fadf60a60d7b068e198cdc291a8fc79d409d67c7ca",
"prior_public_tables_checked": 176,
"prior_domain_rows_preserved": true,
"new_migration_registry_rows": 0,
"operational_delta": {
"cache": {
"before": {
"rows": 119,
"checksum": "5f3c36637fbf1e9dc4700066d4c45530"
},
"after": {
"rows": 119,
"checksum": "b1c9949cfacc34906c406e27e95cbd9b"
}
},
"sessions": {
"before": {
"rows": 37,
"checksum": "b0e409c4c77c58b48252d9847c3c61ef"
},
"after": {
"rows": 38,
"checksum": "f4d94db55488bc0bcf6f727f222473c1"
}
}
},
"persistent_files_and_configuration_identical": 369,
"private_catalogue": {
"observations": 361464,
"distinct_administrative_observations": 214299,
"preserved_repetitions": 147165,
"unmapped_territory": 205608
},
"preservation_identity": "postgres, matching backup reader; no actor impersonation",
"anonymous_query_status": 401,
"public_revision_confirmed": true,
"actual_team_UI_journey": "pending authenticated browser conference;accepted QA is not repeated",
"unique_company_count": null,
"publication": false,
"national_coverage": "partial",
"investment_conclusion": "hypothesis,not proved demand or viability",
"L8": "unchanged,partial"
},
"installed_code": {
"candidate": "23ecccfaad1053f55bf0591fd242cb5fa3406c38",
"source_files_verified": 4,
"diagnostics_C132": {
"result": "passed",
"case": "C132",
"source_explicit_constant_OFF": true,
"effective_configuration_OFF": true,
"configuration_cached": false,
"database_connections_used": 0,
"active_writes": 0
},
"active_writes": 0
},
"pipeline": {
"unit_tests": 59,
"frontend_failures": 0,
"schema_assertions": 3106,
"checks": [
"\u001b[30;42mOK (1 test, 3106 assertions)\u001b[0m",
"✓ built in 34.50s",
"\u001b[30;42mOK (59 tests, 320 assertions)\u001b[0m",
"# tests 1180",
"# pass 1180",
"# fail 0"
],
"frontend_tests": 1180,
"result": {
"status": "installed",
"commit": "23ecccfaad1053f55bf0591fd242cb5fa3406c38",
"release": "/var/www/investmoz-homologacao/releases/23ecccfaad1053f55bf0591fd242cb5fa3406c38-0e0b8a65",
"previous_release": "/var/www/investmoz-homologacao/releases/332c16ebb555e8d6b244fa519bc777d89c1a8b8f-f0dfc218",
"database_migrations": 0,
"content_imports": 0,
"requested_commits": [
"23ecccfaad1053f55bf0591fd242cb5fa3406c38"
],
"finished_at": "2026-10-29T11:10:02.884033+00:00"
},
"unit_assertions": 320
}
}
@@ -0,0 +1,198 @@
{
"date": "2026-10-28",
"status": "not_demonstrated",
"audited_use": "companies,hotels and restaurants lists as private supply-comparison basis inside proactive journey",
"Tempo_decision": "know existing supply by location/sector/activity;compare ideas for competition,complementarity and possible partners;identify evidenced needs and prepare opportunities;no absence-of-offer inference from absent records;no automatic public directory",
"installed_runtime": "569a1cd750f3d2a0b87f130e992e73e070bbd1ab",
"scope": {
"destination": "teste-investidor.tempo.co.mz",
"database": "teste_investidor",
"catalogue_tables": 198,
"schemas": {
"public": 174,
"skills_edu": 24
},
"identity": "postgres owner,read-only transactions;technical metadata inventory,not an authenticated team UI proof",
"read_only": true,
"RLS_note": "No permissions changed or RLS bypassed for a team operation. Owner inventory is explicitly separated from application/user visibility. Differences from earlier investemoz/QA environments are not declared deletion/loss.",
"source_matching_limit": "plaintext metadata/reference matches only; absence does not establish missing encrypted or external records or absence of real market supply"
},
"source_inventory": {
"accessible_files_in_two_source_roots": 13,
"all_inventoried_before_selection": true,
"known_originals": [
{
"alias": "companies.xlsx",
"file_name": "EMPRESAS_LIST 2014-2022.xlsx",
"sha256": "1ba4c4114b16b407e7746712d00d4f681246bbbc1b42351e96a31f7129acad30",
"bytes": 26586267,
"unchanged": true,
"sheet_names": [
"EMPRESAS 2014-2022"
],
"prior_nonempty_XML_rows": 148557,
"row_count_is_not_unique_business_count": true,
"period": "2014–2022 stated in file name"
},
{
"alias": "licensed.xlsx",
"file_name": "Empresas Licenciadas.xlsx",
"sha256": "1b5087b464e39e88c42def39655c2391aaaf684b4c832d6a77ff274b98889f00",
"bytes": 34940776,
"unchanged": true,
"sheet_names": [
"EMPRESAS"
],
"prior_nonempty_XML_rows": 212632,
"row_count_is_not_unique_business_count": true,
"period": "varies by record;reference date not inferred from file modification time"
},
{
"alias": "tourism.xlsx",
"file_name": "CADASTRO DE EMPREENDIMENTOS TURISTICOS 3, 4 E 5 ESTRELAS 30.10.24 18,00h.xlsx",
"sha256": "f02e086ed4973ef17234aa6cc54b98fbb7a6952ffb16ec4dc4f7d0c72af6400c",
"bytes": 73955,
"unchanged": true,
"sheet_names": [
"1. Alojamento",
"Folha1"
],
"prior_nonempty_XML_rows": 460,
"row_count_is_not_unique_business_count": true,
"period": "30/10/2024 stated in file name;current operating status unconfirmed"
},
{
"alias": "cae.xlsx",
"file_name": "CAE eBAU.xlsx",
"sha256": "83b4f1200988e99834c2ae3915346fbf237d128308554b67b5a080d4956bf506",
"bytes": 75018,
"unchanged": true,
"sheet_names": [
"CAE"
],
"prior_nonempty_XML_rows": null,
"row_count_is_not_unique_business_count": true,
"period": "code labels supplied;version/application require source correspondence"
}
],
"duplicates_detected_by_bytes": [],
"private_fields_exported": false,
"tourism_scope": "workbook labelled accommodation;not treated as an exhaustive restaurant or hotel market census",
"restaurant_scope": "separate restaurant dataset not identified in these two source roots;restaurant activities may exist within company registers;not a finding of absence in the market"
},
"findings": {
"originals_available_and_unchanged": true,
"loading_into_portal": "not demonstrated in audited destination;no exact known source hash matches among368persistent files;no exact known source references in audited metadata",
"normalisation_and_classification": "source company workbooks have province/district/CAE and a supplied CAE code table;mapping of their entities to portal location/sector/activity not demonstrated",
"journey_linkage": "not demonstrated;installed ProactiveInvestment.options only returns document links/groups/project-cluster-corridor-park-chain references;no list-backed offer query or company/hotel/restaurant relation consumer in that method",
"manual_evidence_path": "already delivered;acceptance preserved. Manual source/locator/period/coverage/observed_supply fields do not establish imported source records or catalogue-backed comparison.",
"active_counts_context_only": {
"existing_businesses": 0,
"organizations": 1,
"documents": 60,
"economic_activities": 11,
"portal_resources": 59
},
"no_market_absence_inference": true,
"no_loss_claim": true
},
"demonstration_attempt": {
"kind": "read-only trace and canonical-location check,not a browser or team end-to-end proof",
"location": {
"name": "Gurué",
"id": "87a8ba3d-b152-560c-a566-7db4531ad536",
"type": "district"
},
"steps": [
{
"step": 1,
"action": "choose location",
"observed": "canonical Gurué district exists;previous location-selection path acceptance reused;no new manual UI proof claimed",
"status": "location_reference_available"
},
{
"step": 2,
"action": "consult existing supply from these lists",
"observed": "no wired list-backed offer consumer or demonstrated imported/classified enterprise records for this location",
"status": "not_demonstrated",
"blocks_remaining_demo": true
},
{
"step": 3,
"action": "compare with an idea for competitors/complements/partners",
"observed": "manual analysis fields exist;no reproducible offer-result set/source-row linkage demonstrated",
"status": "not_demonstrated"
},
{
"step": 4,
"action": "identify a need with dates and uncertainties",
"observed": "cannot infer unmet need from missing catalogue records;hypothesis and unknown coverage remain explicit",
"status": "not_demonstrated_for_this_list_backed_flow"
},
{
"step": 5,
"action": "prepare opportunity retaining sources/period/coverage/uncertainty",
"observed": "existing private preparation mechanism preserved;no new opportunity created to simulate completion of missing steps2–4",
"status": "not_demonstrated_for_this_list_backed_flow"
}
],
"end_to_end_criterion_passed": false
},
"preservation": {
"proactive_delivery_preserved": true,
"previous_manual_real_source_QA_pilots_preserved": true,
"Songo_Gurue": "existing private QA hypotheses unchanged;their manual spreadsheet reading does not establish list integration",
"L8_delivery_569a1cd7": "unchanged,remains partial",
"application_code_changes": 0,
"new_tests": 0,
"new_imports_or_normalisation": 0,
"new_opportunities": 0,
"new_publication": 0,
"active_writes": 0,
"new_roles_or_grants": 0,
"no_new_matrix_or_backlog": true
},
"next_concrete_work": "Establish traceable private source batches/rows and mappings using existing data/inventory mechanisms;expose authorised location-sector-activity supply query in existing proactive editor;link selected supply records and source coverage to comparison/need/opportunity,then demonstrate save/recover end-to-end in isolated QA. Do not publish a directory or treat unknown coverage as zero supply. Analytical supply records must not require a user organisation,a business-for-sale listing,a real promoter or a confirmed commercial commitment just to be consulted internally.",
"matrix_ids": [
"RF-CLU-003",
"MASTER-471012ab-0010",
"M2-d0c06a75-0147",
"M2-d0c06a75-0151",
"MASTER-1f0a4cde-0002"
],
"evidence": {
"active_source_code_verified": [
{
"file": "app/Services/ProactiveInvestment.php",
"sha256": "957dc455f0153d615577fe182b51969e6d253b33e24ac78547fcccd06215a51c",
"installed_matches_Git": true
},
{
"file": "app/Services/OpportunityManagement.php",
"sha256": "ebe5535d94ad3c7d607bd70f2ff5dce75d810ab26924ac55ac08f036fd5eee33",
"installed_matches_Git": true
},
{
"file": "resources/js/components/proactive-investment.tsx",
"sha256": "9c6acadef8d29732efcfcc4bcdfb28ce3be4f19127a626c1e85c6df98fd91ecf",
"installed_matches_Git": true
},
{
"file": "app/Services/InvestorContext.php",
"sha256": "54be88361214d896640c808d8fe29f93c552a69870d6bb3ecce4de458a51ed48",
"installed_matches_Git": true
},
{
"file": "routes/web.php",
"sha256": "95930c76977d446b74a422acc4b60869ff6bae5d4567c21cc644484c5f09cbda",
"installed_matches_Git": true
}
],
"local_artifacts": {
"accessible-source-inventory.json": "2815b778b0a4d1d601fd85c46c9b5c3a19bd7adcdb46cf86f0f28f45717b2afa",
"accepted-source-check.json": "06b814ee4e322fb78f081788b061936a324b3edcf6d56a80a82eeabdf2906ec6",
"active-audit.json": "06e3cfba8c180e93a86a9dc5db86403220e7c5406229ef166b4e604cc2d8e71c",
"consumer-trace.json": "df1fa6fa8dc3c18485b6e83a4f6a688f2d5a2124e9a8f5c75a944ec859e420f3"
}
}
}
@@ -0,0 +1,213 @@
{
"date": "2026-10-28",
"status": "installed_delta_verified_authenticated_visual_conference_pending",
"served_commit": "789fffd098fa222c85e1283de07f23ee6f42bd04",
"previous_runtime": "6e8de43ae7fec29b0766d95e209a8cb8baf0d88c",
"target": "https://teste-investidor.tempo.co.mz/",
"normal_Git_fast_forward": true,
"force_used": false,
"existing_pipeline_unchanged": true,
"scope": [
"National provincial catalogue/corridor coverage and territorial summaries",
"One searchable territory control at all four levels",
"Filtered catalogue results separate from optional educational support",
"Adaptive opportunity preparation groups and save-before-resource navigation",
"Readable promoter request with explicit searchable proof selection",
"Immediate scoped zone search,total counts and complete pagination",
"My investments next action and expandable stages/source details",
"Direct entries to existing fillable investment/Terra-DUAT preparation;support form separately labelled",
"Sector registration CTA uses effective account creation capability;no new permissions",
"Readable known document topic/category/language labels"
],
"backup": {
"stage": "/var/backups/investmoz-catalogue-territory-delivery-20261028/backup",
"created_at": "2026-10-28T19:25:43Z",
"target": {
"database": "teste_investidor",
"port": 5432
},
"tables": 176,
"files_verified": 369,
"dump_sha256": "597f73cda14dd47be440136f2e2242d8d7a4e4f3d5d69cabcd44f8fde58c136d",
"files_sha256": "7fb639d8351e34ce220c5424c722c223512d139c82b7ff0a1a447136c62041ef",
"restore_verified": false
},
"restore": {
"restore_verified": true,
"tables_verified": 176,
"container_network": "none",
"database": "catalogue_territory_delivery_20261028_test",
"port": 55441,
"source_dump_sha256": "597f73cda14dd47be440136f2e2242d8d7a4e4f3d5d69cabcd44f8fde58c136d",
"active_database_writes": 0
},
"failed_candidate_preserved": {
"commit": "502e3ec4",
"reason": "Corridor explorer Unit mock missing the new public location presentation;unnecessary non-source relation read",
"active_runtime_preserved": true,
"resolution": "Short-circuit irrelevant source before relation access;represent new dependency and retain old assertions;Unit1/13 passed"
},
"pipeline": {
"reports": [
{
"status": "already_installed",
"commit": "789fffd098fa222c85e1283de07f23ee6f42bd04",
"requested_commits": [
"789fffd098fa222c85e1283de07f23ee6f42bd04"
],
"finished_at": "2026-10-28T19:39:46.948539+00:00"
},
{
"status": "installed",
"commit": "789fffd098fa222c85e1283de07f23ee6f42bd04",
"release": "/var/www/investmoz-homologacao/releases/789fffd098fa222c85e1283de07f23ee6f42bd04-29ab1f0d",
"previous_release": "/var/www/investmoz-homologacao/releases/6e8de43ae7fec29b0766d95e209a8cb8baf0d88c-877dabd3",
"database_migrations": 0,
"content_imports": 0,
"requested_commits": [
"789fffd098fa222c85e1283de07f23ee6f42bd04"
],
"finished_at": "2026-10-28T19:39:45.796038+00:00"
}
],
"build_logs": [
{
"file": "/var/lib/investmoz-deploy/logs/1793216167091522421.log",
"sha256": "af1c3db072b2b354cfef13538a8cb69d07a37ec4b1ddef63d8d68e77ab5d7196",
"summaries": [
"\u001b[30;42mOK (1 test, 3106 assertions)\u001b[0m",
"✓ built in 32.16s",
"\u001b[30;42mOK (59 tests, 320 assertions)\u001b[0m",
"# tests 1171",
"# pass 1171",
"# fail 0"
]
}
]
},
"installed_preservation_delta": {
"navigation_asset_and_shared_props_verified": true,
"at": "2026-10-28T19:40:32.494557+00:00",
"served_commit": "789fffd098fa222c85e1283de07f23ee6f42bd04",
"backup_dump_sha256": "597f73cda14dd47be440136f2e2242d8d7a4e4f3d5d69cabcd44f8fde58c136d",
"prior_public_tables_checked": 176,
"prior_domain_rows_preserved": true,
"new_migration_registry_rows": 0,
"operational_delta": {
"cache": {
"before": {
"rows": 111,
"checksum": "1912b955cff09094395336c2137d5ce5"
},
"after": {
"rows": 111,
"checksum": "c158d3e941f896ca3997dfe02850d427"
}
},
"sessions": {
"before": {
"rows": 9,
"checksum": "870b1c20d207a9db376633fc5514a3ed"
},
"after": {
"rows": 8,
"checksum": "f7737eac7d85ed501fb339d7faabbd8e"
}
}
},
"persistent_files_and_configuration_identical": 369,
"private_catalogue": {
"observations": 361464,
"distinct_administrative_observations": 214299,
"preserved_repetitions": 147165,
"unmapped_territory": 205608
},
"RLS_without_actor_rows": 0,
"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"
},
"public_installed_delta": {
"served_commit": "789fffd098fa222c85e1283de07f23ee6f42bd04",
"public_Maputo_agribusiness_total": 5,
"public_Tourism_Cabo_Delgado_total": 0,
"Cabo_Delgado_zones_total": 2,
"all_zones_total": 8,
"all_zones_pages": 1,
"filtered_scope_and_sector_confirmed": true,
"installed_UI_assets_verified": [
"Preencher formulários e preparar documentos",
"Pesquisar nos meus ficheiros",
"O meu acesso e administração",
"Consultar situação, documentos e fundamentos"
],
"public_Tourism_HTTP": 200,
"guest_Tourism_registration_action": "/perfil/promotor",
"guest_Tourism_registration_label": "Pedir acesso para cadastrar oportunidades",
"real_authenticated_Tourism_case": "pending;anonymous200 is not the real session",
"pipeline_reports": [
{
"status": "already_installed",
"commit": "789fffd098fa222c85e1283de07f23ee6f42bd04",
"requested_commits": [
"789fffd098fa222c85e1283de07f23ee6f42bd04"
],
"finished_at": "2026-10-28T19:39:46.948539+00:00"
},
{
"status": "installed",
"commit": "789fffd098fa222c85e1283de07f23ee6f42bd04",
"release": "/var/www/investmoz-homologacao/releases/789fffd098fa222c85e1283de07f23ee6f42bd04-29ab1f0d",
"previous_release": "/var/www/investmoz-homologacao/releases/6e8de43ae7fec29b0766d95e209a8cb8baf0d88c-877dabd3",
"database_migrations": 0,
"content_imports": 0,
"requested_commits": [
"789fffd098fa222c85e1283de07f23ee6f42bd04"
],
"finished_at": "2026-10-28T19:39:45.796038+00:00"
}
],
"no_schema_or_publication_changes": true,
"source_files_preserved": true
},
"accepted_QA_reused": {
"national_tests": 6,
"national_assertions": 617,
"selector_tests": 4,
"guide_tests": 2,
"usability_frontend_tests": 9,
"usability_feature_tests": 3,
"usability_feature_assertions": 14,
"unit_regression_tests": 1,
"unit_regression_assertions": 13,
"types_build_PHP": "passed"
},
"remaining": [
"Actual authenticated visual conference of responsive menus,free filling navigation,saved resource round-trip and Gurué same-dossier journey",
"Tempo clarified Tourism denial followed a button;exact original button unavailable. Concrete creation CTA mismatch corrected and tested;real session still needs confirmation",
"Clarify the particular official fiscal form sought;investment preparation adaptations are not a complete implementation of all IVA/IRPC official forms"
],
"preserved": {
"old_release": "retained for existing rollback mechanism",
"domain_data_permissions_publications": "unchanged",
"persistent_files_and_configuration": 369,
"private_supply_observations": 361464,
"Gurue_observations": 435,
"counts_are_not_unique_companies": true,
"lists": "internal",
"national_coverage": "partial",
"investment_comparison": "hypothesis,not demand or viability proof",
"L8": "partial,unchanged"
},
"proof_limits": "HTTP/API/asset checks do not prove the authenticated UI;prior QA/manual reports remain historical candidate evidence,not installation state",
"source_QA_reports": [
"catalogue-province-coverage-qa-20261028.json",
"investor-usability-qa-20261028.json"
],
"authorisation": "Tempo requested corrections,authorised normal delivery,and authenticated the existing SSH mechanism;no repeated organisational approval required"
}
@@ -0,0 +1,73 @@
{
"date": "2026-10-28",
"status": "QA_implemented_and_delta_verified_not_installed",
"runtime_baseline": "6e8de43ae7fec29b0766d95e209a8cb8baf0d88c",
"prepared_catalogue_parent": "54d1b2a5",
"Tempo_requests": [
"Readable investor work menu and adaptive filling flow",
"ZEE/ZFI/park results must match explicit territory selection;all means the available set",
"Promoter request must use readable representation/modality labels and usable evidence selection",
"Investigate authenticated denial of public Tourism sector",
"Simplify My investments and present useful next actions",
"Expose fillable forms rather than a guide or legislative catalogue"
],
"implemented": {
"work_menu": "Investor actions remain primary;existing access/audit/profile setup destinations move into optional administration details;no permission change",
"filling": "Schema fields grouped adaptively by task;free navigation,all fields stay mounted,unknown future keys retained;server missing paths and errors point to groups;not mandatory sequential forms",
"preservation": "Deliberate values retained between groups;save uses existing endpoints and ETags;resource navigation saves first and returns to the same opportunity/group;only unique promoter context is auto-selected",
"profile_request": "Portuguese visible representation/modality labels;owned clean-file criteria unchanged;file search and explicit selected proofs replace the unbounded list;availability does not prove evidentiary suitability",
"zones": "Territory and other filter changes apply search;old records hidden while loading/error;all and filtered totals plus pagination explicit;existing public visibility preserved;validated legal coverage includes its child area,while influence is not treated as a site",
"my_investments": "Next action remains prominent;other phases/stages expandable;source/code history and internal follow-up remain consultable rather than filling the main page",
"forms": "Direct entries to existing private investment preparation fields and Terra/DUAT;support request separately labelled;originals/guides optional;no law is converted into a form or executable calculation",
"document_taxonomy": "Known topic/category/language codes have Portuguese display labels;original query values preserved"
},
"QA": {
"frontend_contract_and_SSR_tests": 9,
"frontend_failures": 0,
"isolated_feature_tests": 3,
"feature_assertions": 14,
"feature_failures": 0,
"database": "proactive_investment_20261027_test",
"port": 55441,
"transactions_rolled_back": true,
"types": "passed",
"build": "passed",
"PHP_syntax": "passed",
"accepted_national_selector_and_catalogue_proofs": "reused",
"unit_regression_tests": 1,
"unit_regression_assertions": 13
},
"Tourism_incident": {
"reported_by_Tempo": "authenticated session saw access denial at /sectores/turismo",
"anonymous_live_HTTP": 200,
"isolated_guest_and_no_promoter_account_HTTP": 200,
"exact_real_session_cause": "not demonstrated;do not infer missing role or claim correction",
"private_permissions_changed": false,
"next": "Confer the actual authenticated session and related logs;public read is not real-session proof",
"reported_action": "Tempo clarified denial followed a button click;exact label could not be recalled",
"bounded_code_finding": "Sector registration action depended on module availability rather than effective account creation capability",
"QA_correction": "Reuse ContentManagement effective create destination;otherwise explicitly link to existing promoter request with a readable label;public consultation and idea preparation remain separate",
"real_session_reconference": "pending;the exact original button is not demonstrated"
},
"limits": [
"Not installed in this QA report",
"SSR and controlled event tests are not browser keyboard/touch/layout evidence",
"Existing form adaptations are exposed;this does not implement every official form in the document collection",
"No new roles,publications,source edits,schema changes or fiscal activation",
"L8 remains partial"
],
"restriction_origins": {
"UX_and_selected_scope": "Tempo current express instructions",
"data_and_permissions_preservation": "permanent project instructions",
"grouping_and_collapsed_details": "implementation choice,not new organisational requirement",
"document_ownership_scan_and_existing_workflows": "prior contracts preserved"
},
"delivery_gate": {
"candidate": "502e3ec4",
"result": "failed before runtime switch;6e8de43a preserved",
"reason": "Unit mock did not represent new public location presentation;unnecessary relation query on non-source records",
"correction": "Short-circuit source applicability before reading relations;mock the new presentation dependency and retain prior identity/count assertions",
"unit_regression": "1 test/13 assertions passed",
"pipeline_guardrails": "unchanged;no schema migration or test-gate weakening"
}
}
@@ -0,0 +1,161 @@
{
"date": "2026-10-28",
"status": "installed_technical_navigation_delta_verified_authenticated_visual_conference_pending",
"served_commit": "6e8de43ae7fec29b0766d95e209a8cb8baf0d88c",
"previous_runtime": "6d1476936920dc5715d4493b1f8b22a3d51935f4",
"target": "https://teste-investidor.tempo.co.mz/",
"authorisation": "Tempo approved the QA menu proposal and authenticated the existing SSH delivery mechanism",
"normal_git_fast_forward": true,
"force_used": false,
"backup": {
"stage": "/var/backups/investmoz-navigation-delivery-20261028/backup",
"created_at": "2026-10-28T18:18:14Z",
"target": {
"database": "teste_investidor",
"port": 5432
},
"tables": 176,
"files_verified": 369,
"dump_sha256": "748539aa92cf4a0e22698a3b7651faa7a4452db2517ddb811a924b761ea9dce5",
"files_sha256": "946dd8d33903332557639103b8f5ad5bca955fd3ec79f112e3649128fea8c499",
"restore_verified": false
},
"restore": {
"restore_verified": true,
"tables_verified": 176,
"container_network": "none",
"database": "navigation_delivery_20261028_test",
"port": 55441,
"source_dump_sha256": "748539aa92cf4a0e22698a3b7651faa7a4452db2517ddb811a924b761ea9dce5",
"active_database_writes": 0
},
"guard": {
"ready_for_normal_git_delivery": true,
"restore_verified": true,
"tables_verified": 176,
"pipeline_and_schema_contract_unchanged": true,
"candidate": "6e8de43ae7fec29b0766d95e209a8cb8baf0d88c"
},
"installed_delta": {
"navigation_asset_and_shared_props_verified": true,
"at": "2026-10-28T18:23:39.508498+00:00",
"served_commit": "6e8de43ae7fec29b0766d95e209a8cb8baf0d88c",
"backup_dump_sha256": "748539aa92cf4a0e22698a3b7651faa7a4452db2517ddb811a924b761ea9dce5",
"prior_public_tables_checked": 176,
"prior_domain_rows_preserved": true,
"new_migration_registry_rows": 0,
"operational_delta": {
"cache": {
"before": {
"rows": 111,
"checksum": "650ce50f1aa1af7e7025bcbaa7bcd6ba"
},
"after": {
"rows": 111,
"checksum": "ea8070b2c9a513485bfb27462e379703"
}
},
"sessions": {
"before": {
"rows": 4,
"checksum": "f0d8b267cdf55c86ba8223d9e1ff3c6a"
},
"after": {
"rows": 5,
"checksum": "99071034149a154c7e17acc47e058dae"
}
}
},
"persistent_files_and_configuration_identical": 369,
"private_catalogue": {
"observations": 361464,
"distinct_administrative_observations": 214299,
"preserved_repetitions": 147165,
"unmapped_territory": 205608
},
"RLS_without_actor_rows": 0,
"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"
},
"existing_pipeline_receipt": {
"reports": [
{
"status": "already_installed",
"commit": "6e8de43ae7fec29b0766d95e209a8cb8baf0d88c",
"requested_commits": [
"6e8de43ae7fec29b0766d95e209a8cb8baf0d88c"
],
"finished_at": "2026-10-28T18:23:07.685587+00:00"
},
{
"status": "installed",
"commit": "6e8de43ae7fec29b0766d95e209a8cb8baf0d88c",
"release": "/var/www/investmoz-homologacao/releases/6e8de43ae7fec29b0766d95e209a8cb8baf0d88c-877dabd3",
"previous_release": "/var/www/investmoz-homologacao/releases/6d1476936920dc5715d4493b1f8b22a3d51935f4-c9ebfad4",
"database_migrations": 0,
"content_imports": 0,
"requested_commits": [
"6e8de43ae7fec29b0766d95e209a8cb8baf0d88c"
],
"finished_at": "2026-10-28T18:23:06.355940+00:00"
}
],
"build_log": "/var/lib/investmoz-deploy/logs/1793211570315386207.log",
"build_log_sha256": "7b1d78a066ed829cbdabc67549baf4835ab8f911b8c4b178be09539ed434ddde",
"summaries": [
"\u001b[30;42mOK (1 test, 3106 assertions)\u001b[0m",
"✓ built in 31.30s",
"\u001b[30;42mOK (59 tests, 317 assertions)\u001b[0m",
"# tests 1156",
"# pass 1156",
"# fail 0"
],
"last_lines": [
"ext-pcre 8.4.27 success ",
"ext-session 8.4.27 success ",
"ext-tokenizer 8.4.27 success ",
"php 8.4.27 success ",
"6e8de43ae7fec29b0766d95e209a8cb8baf0d88c",
"",
" INFO Broadcasting queue restart signal. ",
""
]
},
"accepted_QA_reused": {
"directed_tests": 16,
"types": "passed",
"build": "passed",
"PHP_syntax": "passed"
},
"UI_delta": [
"Área de trabalho visible in principal navigation; operational entries follow existing effective capabilities",
"Sidebar preserves every nested destination and territorial context instead of exposing only the first link",
"Existing opportunity analysis form can be opened directly without replacing saved analysis",
"Existing account, content, investment and follow-up destinations retained; no new roles or grants"
],
"remaining": [
"Authenticated visual conference on the actual account, including responsive layout and keyboard/touch",
"Confirm Gurué → offer consultation → idea comparison → save → recover in the same dossier; technical/QA proofs remain accepted"
],
"connector_last_known_error": "The browser access host is unavailable",
"limitations": {
"technical_delivery_is_not_visual_session_evidence": true,
"435_Gurue_observations_are_not_435_unique_companies": true,
"lists_remain_internal": true,
"national_coverage": "partial",
"absence_of_records": "insufficient information",
"comparison": "investment hypothesis; does not establish demand or viability",
"L8": "unchanged,partial"
},
"restriction_origins": {
"delivery": "explicit Tempo authorisation",
"preservation_and_restore": "permanent project instructions",
"grouping_and_presentation": "implementation choice within the approved QA proposal; no new business authority"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,230 @@
{
"date": "2026-10-28",
"status": "internal_supply_integration_demonstrated_in_isolated_QA_not_installed",
"baseline": "150bef4b897de8d0711c1e2bbeedbcaf5ee241d2b",
"baseline_runtime": "569a1cd750f3d2a0b87f130e992e73e070bbd1ab",
"branch": "codex/proactive-supply-qa-20261028",
"active_runtime_unchanged": "569a1cd750f3d2a0b87f130e992e73e070bbd1ab",
"accepted_inventory_reused": "existing-supply-lists-audit-20261028.json;13 accessible originals/variants inventoried before source selection;prior proactive acceptance and fiscal delivery preserved",
"sources": [
{
"alias": "companies.xlsx",
"name": "EMPRESAS_LIST 2014-2022.xlsx",
"sha256": "1ba4c4114b16b407e7746712d00d4f681246bbbc1b42351e96a31f7129acad30",
"bytes": 26586267,
"period": "2014–2022; data por registo quando disponível"
},
{
"alias": "licensed.xlsx",
"name": "Empresas Licenciadas.xlsx",
"sha256": "1b5087b464e39e88c42def39655c2391aaaf684b4c832d6a77ff274b98889f00",
"bytes": 34940776,
"period": "Data por registo; cobertura temporal global não determinada"
},
{
"alias": "tourism.xlsx",
"name": "CADASTRO DE EMPREENDIMENTOS TURISTICOS 3, 4 E 5 ESTRELAS 30.10.24 18,00h.xlsx",
"sha256": "f02e086ed4973ef17234aa6cc54b98fbb7a6952ffb16ec4dc4f7d0c72af6400c",
"bytes": 73955,
"period": "30/10/2024 (nome do exemplar); situação actual não confirmada"
},
{
"alias": "cae.xlsx",
"name": "CAE eBAU.xlsx",
"sha256": "83b4f1200988e99834c2ae3915346fbf237d128308554b67b5a080d4956bf506",
"bytes": 75018,
"period": "Versão não indicada"
}
],
"source_rows": {
"companies.xlsx": 148556,
"licensed.xlsx": 212631,
"tourism.xlsx": 277
},
"catalog": {
"records": 361464,
"repeated_projection": 147165,
"identity_conflicts": 113880,
"activity_unclassified": 82864,
"territory_unmapped": 205608,
"province_only": 336,
"unnamed_observations": 18423
},
"duplicate_meaning": "same projected administrative fields including entity identifier, licence/process,date,place,activity,name;all source sightings retained,not a unique-company count or byte-identical-file claim",
"classification": {
"activity_and_sector": "supplied CAE labels and SELF_ID hierarchy;no invented correspondence to twelve portal navigation sectors",
"territory": "unique normalized province+district,otherwise exact unique canonical subordinate name within province;Maputo explicitly distinct from Maputo Cidade;no fuzzy matching;raw source labels retained",
"identity": "valid-shaped NUIT only hashed as a possible entity key;different names flagged,not silently merged;unidentified rows remain distinct",
"dates": "original date strings and Excel serials retained verbatim;file modification time not a reference date",
"tourism": "277 establishment observations across two differently structured sheets;explicit province section preserved;missing district remains unknown;not an exhaustive hotels/restaurants census"
},
"private_import": {
"preview": true,
"original_hashes_checked": true,
"malware": "Microsoft Defender custom scan on same-hash private copies,exit0,no threats detected,2026-10-28",
"idempotency": {
"before": 343041,
"after": 343041,
"sha256": "6a9c820c73a8c535ed3cdfc03db0311edf0300f1f97ade75f0fce3d3c84a02e1",
"publication": false,
"database": "proactive_investment_20261027_test",
"port": 55441
},
"tables": [
"proactive_supply_sources",
"proactive_supply_observations"
],
"RLS": "forced on both;non-owner QA policy proof:0 rows without actor/read context;authorized context recovers row",
"contacts_identity_documents_nuit_not_returned_by_query": true,
"no_automatic_directory_publication": true,
"no_promoters_or_user_organizations_created": true,
"extension_first": {
"before": 343041,
"after": 361464,
"sha256": "dec9db62a52a8bac5f1e2675aecbeb4239f2b1f4fa521db01693c32bdfeef01e",
"extension": true,
"previous_snapshot": "6a9c820c73a8c535ed3cdfc03db0311edf0300f1f97ade75f0fce3d3c84a02e1",
"publication": false,
"database": "proactive_investment_20261027_test",
"port": 55441
},
"extension_replay": {
"before": 361464,
"after": 361464,
"sha256": "dec9db62a52a8bac5f1e2675aecbeb4239f2b1f4fa521db01693c32bdfeef01e",
"extension": true,
"previous_snapshot": "6a9c820c73a8c535ed3cdfc03db0311edf0300f1f97ade75f0fce3d3c84a02e1",
"publication": false,
"database": "proactive_investment_20261027_test",
"port": 55441
}
},
"proactive_delta": "private authenticated territory query,CAE sector/activity filters,pagination,source/row/date/hash;select existing observations,compare competition/complementarity/possible partner/uncertain with rationale and limitations;server resolves source lineage,keeps list-derived evidence internal;save/reload and private dossier preserve references",
"gurue": {
"date": "2026-10-28",
"QA_only": true,
"operator": 1021,
"record_id": 402,
"dossier_id": "883c8ec3-59a3-4074-b91c-b7d4a5d88ff0",
"prior_version": 1,
"offer_query_total": 410,
"selected_source_records": 2,
"comparison_relationships": [
"competition",
"complementarity"
],
"existing_five_evidences_preserved": true,
"total_evidence": 7,
"idea_preserved": true,
"need": "hypothesis",
"empty_results": "insufficient_information",
"save_reload_recovered": true,
"existing_dossier_preview_confirm_replay": true,
"anonymous_internal_query_denied": true,
"no_publication": true,
"no_external_contacts": true,
"errors": [],
"source_version": 2,
"context_revision": 2,
"deliberate_dossier_title_and_fields_preserved": true,
"prior_source_v1_history_retained": true,
"songo_401_unchanged": true,
"refreshed_offer_query_total": 435,
"initial_browser_proof_retained": true,
"dossier_comparisons_visible": {
"existing_dossier_comparison_1_visible": true,
"existing_dossier_comparison_2_visible": true,
"read_only_after_commit": true,
"errors": []
}
},
"verification": {
"delta_tests": 7,
"assertions": 62,
"types": "passed",
"QA_build": "passed",
"accepted_prior_proofs": "reused;no fiscal suites repeated",
"browser": "authenticated synthetic operator1021,real supplied source rows,save/reload,existing dossier preview-confirm-replay;not team acceptance",
"preservation": {
"opportunities": {
"baseline_rows": 55,
"new_rows": 0,
"expected_changes": [
"402"
]
},
"desk_cases": {
"baseline_rows": 20,
"new_rows": 0,
"expected_changes": [
"883c8ec3-59a3-4074-b91c-b7d4a5d88ff0"
]
},
"projects": {
"baseline_rows": 19,
"new_rows": 0,
"expected_changes": []
},
"geo_locations": {
"baseline_rows": 1921,
"new_rows": 0,
"expected_changes": []
},
"sectors": {
"baseline_rows": 12,
"new_rows": 0,
"expected_changes": []
},
"documents": {
"baseline_rows": 64,
"new_rows": 0,
"expected_changes": []
},
"value_chains": {
"baseline_rows": 0,
"new_rows": 0,
"expected_changes": []
}
}
},
"remaining": [
"No installation or import in active portal;there the list-backed use remains not demonstrated until delivery.",
"205608 observations lack a unique canonical territorial match,including19 ambiguous names;336 have province only. Raw source territory remains available in private originals.",
"82864 observations lack an exact supplied CAE activity label;do not impute sector/activity.",
"113880 observations have differing names for a shared valid-shaped identifier;identity needs reconciliation,not automatic merger.",
"Historical administrative records do not establish current operating status,capacity,prices,demand,contracts or market vacancy.",
"National mapping/classification completeness and actual team use remain partial;Gurué proof does not close all market-study/cluster requirements.",
"Active delivery requires capability/compatibility review,fresh backup+restore,authenticated operation and installed delta check through the existing Git procedure."
],
"restriction_origins": {
"internal_analysis_no_auto_publication_no_absence_inference": "explicit Tempo task",
"preservation_QA_active_separation_backup_restore": "permanent AGENTS instructions",
"canonical_unique_matching_hashed_keys_query_limits_RLS": "implementation choices,not new organizational approval requirements"
},
"proof_files": [
"outputs/proactive-supply-20261028/gurue-ui-proof.json",
"outputs/proactive-supply-20261028/preservation-proof.json",
"outputs/proactive-supply-20261028/tests-receipt.json",
"outputs/proactive-supply-20261028/dossier-visual-proof.json",
"outputs/proactive-supply-20261028/supply-extension-preservation.json",
"outputs/proactive-supply-20261028/refreshed-gurue-query.json"
],
"L8": "unchanged,partial;this increment has no fiscal calculation change",
"supplement": {
"previous_snapshot_records": 343041,
"added_unnamed_source_rows": 18423,
"placeholder": "Denominação não indicada;never the personal applicant name",
"all_previous_observations_unchanged": {
"old_observations_unchanged": 343041,
"new_observations": 18423
},
"preview_both_hashes_bound": {
"previous_observations_unchanged": 343041,
"new_observations": 18423,
"no_replacement": true,
"previous_sha256": "6a9c820c73a8c535ed3cdfc03db0311edf0300f1f97ade75f0fce3d3c84a02e1",
"sha256": "dec9db62a52a8bac5f1e2675aecbeb4239f2b1f4fa521db01693c32bdfeef01e"
},
"original_source_rows_accounted_for": "148556 companies+212631 licensed nonempty data rows,excluding headers;277 tourism establishment sightings from two structures"
}
}
@@ -0,0 +1,28 @@
# Entrega controlada da base comparativa interna
A entrega inclui o candidato QA `a022ed98`. As sete provas dirigidas/62 asserções, tipos, compilação e percurso de Gurué continuam aceites. O delta de entrega acrescenta o carregador técnico e impede que a API grave uma repetição preservada como outra comparação; este último delta tem uma prova dirigida própria, sem repetir a suite anterior.
361.464 é o número de observações administrativas, incluindo 147.165 repetições preservadas. As 214.299 projecções distintas também **não** são empresas únicas. A consulta exclui `duplicate_of` das contagens e dos resultados; a gravação recusa repetições como comparações adicionais. A cobertura nacional é parcial. Ausência de resultados significa informação insuficiente. A comparação fundamenta uma hipótese e não confirma procura, capacidade actual, contrato ou viabilidade.
## Sequência da entrega
1. Conferir `main`, a revisão servida, ausência de deriva do código e capacidade. A base da entrega é `569a1cd7`; não usar force-push nem substituir alterações posteriores.
2. Criar backup novo de base e ficheiros/configuração. Preservar os backups anteriores. Restaurar numa instância isolada, sem rede e na porta55441, e comparar as174 tabelas públicas do inventário.
3. Ensaiar o SQL da migração exacta, o carregamento e a sua repetição. Conferir que as174 tabelas anteriores não mudam. Verificar a conta real da aplicação: sem contexto de actor não lê a base; com o contexto autorizado recupera435 observações distintas em Gurué e conserva237 repetições fora dessa contagem. Não criar utilizadores, organizações ou dossiers fictícios no activo.
4. Conferir também backup/restauro das duas novas tabelas, incluindo RLS forçada. Registar espaço efectivamente ocupado pelo ensaio e voltar a conferir capacidade antes da instalação.
5. Na sessão SSH autenticada do processo existente, aplicar a migração delimitada e registar uma linha de migração. Conceder apenas leitura nas duas tabelas à conta de aplicação já existente. Carregar o pacote privado em transacção, com hashes e contagens fixos, recusa de conflitos e sem sobrescrever observações. Não publicar listas nem criar anúncios, clientes confirmados ou promotores.
6. Acrescentar apenas o hash dessa migração ao contrato de schema do instalador. Conservar os90 hashes anteriores e o próprio programa do instalador. Este passo é necessário porque o processo Git não executa migrações ou importações de conteúdo automaticamente.
7. Fazer a entrega normal por Git/`main`, mantendo a construção isolada e as guardas habituais do instalador. As provas funcionais aceites são reutilizadas; a CI habitual do instalador permanece.
8. Conferir revisão pública, hashes do código, schema e permissões esperados, ficheiros/configuração e linhas anteriores, separando a nova linha de migração e o delta operacional de cache/sessões. Conferir negação de acesso anónimo e consulta autorizada de Gurué. A conferência visual autenticada no portal é distinta do inventário técnico por SSH.
## Carregador
`tools/load-proactive-supply.py` usa pré-visualização por omissão. Exige o pacote privado com `import-manifest.json`, `preview.json`, `scan.json`, `schema.sql` e `observations.jsonl.gz`. O JSONL aceite tem SHA256 `dec9db62a52a8bac5f1e2675aecbeb4239f2b1f4fa521db01693c32bdfeef01e`. Os quatro originais e as datas são os aceites em QA; não se pede nova validação genérica da sua origem.
O modo `rehearsal` exige container sem rede/portas publicadas e a identidade efectiva `proactive_supply_delivery_20261028_test:55441`. O modo `apply` exige a sessão técnica SSH, a identidade `teste_investidor:5432`, revisão esperada e backup/restauro verificado. A preparação e os recibos ficam privados em `/var/backups/investmoz-proactive-supply-delivery-20261028`, fora da área servida. O carregador não cria schema no activo, não publica dados e não apaga ou substitui registos. A tabela temporária é indexada antes de conferir a linhagem de duplicações.
## Preservação e recuo
Se uma conferência divergir, preservar os exemplares e delimitar a diferença antes de agir. Recuar código não autoriza apagar a base privada ou o seu histórico. A migração recusa `down` enquanto existirem fontes carregadas. Um restauro integral nunca deve sobrescrever intervenções posteriores sem reconciliação; os originais e o pacote privado permitem recuperar o catálogo separadamente. As duas novas tabelas e o único registo de migração são o delta de schema/dados previsto; não alterar papéis de funcionários ou permissões anteriores.
As restrições de análise interna, preservação, não duplicação, não publicação e separação QA/activo vêm das decisões expressas da Tempo e do `AGENTS.md`. Identidades exactas, hashes, limites técnicos, indexação temporária e contratos são escolhas de implementação, não novas exigências organizacionais. O incremento não fecha os requisitos nacionais amplos e não altera L8, que permanece parcial.
@@ -0,0 +1,412 @@
{
"date": "2026-10-28",
"status": "installed_technical_delta_verified_authenticated_visual_conference_pending",
"served_commit": "6d1476936920dc5715d4493b1f8b22a3d51935f4",
"accepted_QA_candidate": "a022ed9848b41ba342b4209414c2bbf2ebd0a7a3",
"previous_runtime": "569a1cd750f3d2a0b87f130e992e73e070bbd1ab",
"normal_Git_fast_forward": true,
"force_used": false,
"original_QA_and_history_preserved": true,
"scope": "private comparative supply base integrated into existing proactive journey;not automatic public directory",
"counts": {
"administrative_observations": 361464,
"repetitions_preserved": 147165,
"distinct_administrative_projections": 214299,
"unique_companies": null,
"Gurué_distinct_observations": 435,
"Gurué_repetitions_excluded": 237,
"comparison_API_rejects_preserved_duplicate_as_another_comparison": true
},
"limits": {
"national_coverage": "partial",
"missing_records": "insufficient information;never proof of absent supply",
"investment_conclusion": "hypothesis only;no automatic proof of demand,capacity,contracts or viability",
"unmapped_territory": 205608,
"province_only": 336,
"activity_unclassified": 82864,
"shared_identifier_name_conflicts": 113880,
"unnamed_observations_preserved": 18423
},
"backup": {
"stage": "/var/backups/investmoz-proactive-supply-delivery-20261028/backup-install",
"created_at": "2026-10-28T17:10:23Z",
"target": {
"database": "teste_investidor",
"port": 5432
},
"tables": 174,
"files_verified": 369,
"dump_sha256": "1a0bc3767062633ba35afe1308c7f29ef441a01dba35551534ed1eef9e8e8509",
"files_sha256": "e622cdd04a252740ad1d0c626a9de32aaf1e71f3c986591cdbd94bafd314a1d7",
"restore_verified": false
},
"backup_restore_rehearsal": {
"restore_verified": true,
"tables_verified": 174,
"container_network": "none",
"database": "proactive_supply_delivery_20261028_test",
"port": 55441,
"source_dump_sha256": "1a0bc3767062633ba35afe1308c7f29ef441a01dba35551534ed1eef9e8e8509",
"active_database_writes": 0,
"private_import_first": {
"candidate": "a022ed9848b41ba342b4209414c2bbf2ebd0a7a3",
"records": 361464,
"source_counts": {
"1ba4c4114b16b407e7746712d00d4f681246bbbc1b42351e96a31f7129acad30": 148556,
"1b5087b464e39e88c42def39655c2391aaaf684b4c832d6a77ff274b98889f00": 212631,
"f02e086ed4973ef17234aa6cc54b98fbb7a6952ffb16ec4dc4f7d0c72af6400c": 277,
"83b4f1200988e99834c2ae3915346fbf237d128308554b67b5a080d4956bf506": 0
},
"raw_sha256": "dec9db62a52a8bac5f1e2675aecbeb4239f2b1f4fa521db01693c32bdfeef01e",
"sql_bytes": 201634556,
"mode": "rehearsal",
"unique_company_count": null,
"duplicate_suppression": "WHERE duplicate_of IS NULL;retained administrative sightings do not count as separate offers",
"national_coverage": "partial",
"investment_conclusion": "hypothesis only;does not prove demand or viability",
"database": "proactive_supply_delivery_20261028_test|55441",
"before": 0,
"after": {
"records": 361464,
"distinct_observations": 214299,
"repeated_projections": 147165,
"not_unique_companies": true,
"published": false
},
"idempotent_no_overwrite": true,
"active_existing_business_data_touched": false
},
"private_import_replay": {
"candidate": "a022ed9848b41ba342b4209414c2bbf2ebd0a7a3",
"records": 361464,
"source_counts": {
"1ba4c4114b16b407e7746712d00d4f681246bbbc1b42351e96a31f7129acad30": 148556,
"1b5087b464e39e88c42def39655c2391aaaf684b4c832d6a77ff274b98889f00": 212631,
"f02e086ed4973ef17234aa6cc54b98fbb7a6952ffb16ec4dc4f7d0c72af6400c": 277,
"83b4f1200988e99834c2ae3915346fbf237d128308554b67b5a080d4956bf506": 0
},
"raw_sha256": "dec9db62a52a8bac5f1e2675aecbeb4239f2b1f4fa521db01693c32bdfeef01e",
"sql_bytes": 201634556,
"mode": "rehearsal",
"unique_company_count": null,
"duplicate_suppression": "WHERE duplicate_of IS NULL;retained administrative sightings do not count as separate offers",
"national_coverage": "partial",
"investment_conclusion": "hypothesis only;does not prove demand or viability",
"database": "proactive_supply_delivery_20261028_test|55441",
"before": 361464,
"after": {
"records": 361464,
"distinct_observations": 214299,
"repeated_projections": 147165,
"not_unique_companies": true,
"published": false
},
"idempotent_no_overwrite": true,
"active_existing_business_data_touched": false
},
"baseline_public_tables_unchanged_after_import": 174,
"actual_application_role": "teste_investidor_app",
"RLS_without_actor_rows": 0,
"Gurue": {
"observations": 435,
"retained_repetitions": 237,
"not_unique_companies": true
},
"storage": {
"private_relations_bytes": 344399872,
"database_bytes": 409070739
},
"catalogue_backup_restore": {
"verified": true,
"sha256": "7ebb8791d5871bb4752a3e6e964a398c0f8fc094a10aab0e33aaee202dfc62d3",
"rows": 361464,
"forced_RLS_tables": 2,
"row_checksum": "9f9773492aa45f498201ff791726004a"
},
"import_and_replay_seconds": 67.3371156482026,
"no_application_users_or_dossiers_created": true,
"no_publication": true,
"active_data_writes": 0
},
"private_base_load": {
"at": "2026-10-28T17:14:46.618843+00:00",
"baseline": "569a1cd750f3d2a0b87f130e992e73e070bbd1ab",
"private_schema_created": 2,
"new_migration_registry_rows": 1,
"new_grants": "SELECT only on the two new private tables to the existing application role;no employee assignment or public grant",
"import": {
"candidate": "a022ed9848b41ba342b4209414c2bbf2ebd0a7a3",
"records": 361464,
"source_counts": {
"1ba4c4114b16b407e7746712d00d4f681246bbbc1b42351e96a31f7129acad30": 148556,
"1b5087b464e39e88c42def39655c2391aaaf684b4c832d6a77ff274b98889f00": 212631,
"f02e086ed4973ef17234aa6cc54b98fbb7a6952ffb16ec4dc4f7d0c72af6400c": 277,
"83b4f1200988e99834c2ae3915346fbf237d128308554b67b5a080d4956bf506": 0
},
"raw_sha256": "dec9db62a52a8bac5f1e2675aecbeb4239f2b1f4fa521db01693c32bdfeef01e",
"sql_bytes": 201634556,
"mode": "apply",
"unique_company_count": null,
"duplicate_suppression": "WHERE duplicate_of IS NULL;retained administrative sightings do not count as separate offers",
"national_coverage": "partial",
"investment_conclusion": "hypothesis only;does not prove demand or viability",
"database": "teste_investidor|5432",
"before": 0,
"after": {
"records": 361464,
"distinct_observations": 214299,
"repeated_projections": 147165,
"not_unique_companies": true,
"published": false
},
"idempotent_no_overwrite": true,
"active_existing_business_data_touched": false
},
"schema_contract_delta": "one reviewed migration appended;all90 previous entries unchanged",
"pipeline_script_unchanged": true,
"publication": false,
"code_installation_pending": true,
"backup_dump_sha256": "1a0bc3767062633ba35afe1308c7f29ef441a01dba35551534ed1eef9e8e8509"
},
"capacity": {
"at": "2026-10-28T17:13:59.594789+00:00",
"volumes": [
{
"path": "/var/www/investmoz-homologacao/releases",
"free_bytes": 86519189504,
"free_inodes": 23490190
},
{
"path": "/var/lib/investmoz-deploy",
"free_bytes": 86519189504,
"free_inodes": 23490190
},
{
"path": "/var/backups",
"free_bytes": 86519189504,
"free_inodes": 23490190
},
{
"path": "/tmp",
"free_bytes": 86519189504,
"free_inodes": 23490190
},
{
"path": "/var/lib/docker",
"free_bytes": 86519189504,
"free_inodes": 23490190
}
],
"required_budget_bytes": 6259143420,
"budget_basis": "existing5GiB build guard+twice measured private relations+measured SQL staging;already-created backups/stages included in actual free space",
"backup_sha256": "1a0bc3767062633ba35afe1308c7f29ef441a01dba35551534ed1eef9e8e8509",
"backup_restore_verified": true,
"active_writes": 0
},
"installed_delta": {
"at": "2026-10-28T17:22:03.023596+00:00",
"served_commit": "6d1476936920dc5715d4493b1f8b22a3d51935f4",
"backup_dump_sha256": "1a0bc3767062633ba35afe1308c7f29ef441a01dba35551534ed1eef9e8e8509",
"prior_public_tables_checked": 174,
"prior_domain_rows_preserved": true,
"new_migration_registry_rows": 1,
"operational_delta": {
"cache": {
"before": {
"rows": 111,
"checksum": "a9dc6868205af2190cee2d2787ee8b66"
},
"after": {
"rows": 111,
"checksum": "08c098b3fde0052afa05aae647d18c5f"
}
},
"sessions": {
"before": {
"rows": 7,
"checksum": "ef549836d7a2e0549b1a32f1627c83f4"
},
"after": {
"rows": 8,
"checksum": "4a8b547e0552be4da65bcb97d3851a49"
}
}
},
"persistent_files_and_configuration_identical": 369,
"private_catalogue": {
"observations": 361464,
"distinct_administrative_observations": 214299,
"preserved_repetitions": 147165,
"unmapped_territory": 205608
},
"Gurue": {
"observations": 435,
"repetitions": 237
},
"RLS_without_actor_rows": 0,
"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"
},
"application_consumer": {
"installed_application_service_read": true,
"existing_actor_passes_existing_profile_authorization": true,
"application_database_identity": {
"database": "teste_investidor",
"role": "teste_investidor_app",
"port": 5432
},
"observations": 435,
"preserved_repetitions_excluded": 237,
"empty_results": "Informação insuficiente nesta base e nestes filtros.",
"source_metadata_present": true,
"no_new_actor_grant_or_record": true,
"read_only": true,
"proof_kind": "technical CLI consumer,not authenticated team browser acceptance"
},
"operational_delta": {
"status": "operational_delta_reconciled",
"comparison_identity": "postgres before/after;isolated restored baseline;no RLS visibility inference",
"cache": "queue:restart;requestthrottle;identical home-sector cached value with refreshed expiry(HomeController remember10min);no deletedcache",
"sessions": "anonymous sessions from delivery user-agent or exactUA/time correlated with nginx public request;deleted sessions individually expired beyond configured lifetime,normal database session garbage collection(config/session lottery2/100)",
"delta": {
"cache": {
"before_rows": 111,
"after_rows": 111,
"changes": [
{
"key_sha256": "7f7dd83f47f5bf404d55e6e9f1367948721579970b195680645e356031a070ff",
"kind": "updated",
"fields": [
"value",
"expiration"
],
"key_pattern": "investemoz-cache-illuminate:queue:restart",
"is_queue_restart_key": true,
"expiration_unchanged": false,
"throttle_timer_pair": false,
"same_value_home_cache_expiry_refresh": false
}
]
},
"sessions": {
"before_rows": 7,
"after_rows": 9,
"changes": [
{
"key_sha256": "5cb21cc6c5f36834d39f2aa929d2272b65358654155858bae027abe4ccdf5c4e",
"kind": "added",
"fields": [
"id",
"ip_address",
"user_agent",
"payload",
"last_activity"
],
"anonymous_user": true,
"delivery_user_agent": true,
"expired_before_comparison": false,
"session_lifetime_minutes": 120,
"anonymous_public_request_logged": true
},
{
"key_sha256": "6fa8f1e07be4cea4d35b9ee67f1e480f35baac9228f33c5161f24e2aac5e33f2",
"kind": "added",
"fields": [
"id",
"ip_address",
"user_agent",
"payload",
"last_activity"
],
"anonymous_user": true,
"delivery_user_agent": true,
"expired_before_comparison": false,
"session_lifetime_minutes": 120,
"anonymous_public_request_logged": true
}
]
}
},
"business_tables_to_confirm_separately": 172,
"active_data_writes_by_comparison": 0,
"restore_network": "none"
},
"installer_and_catalogue_backup": {
"at": "2026-10-28T17:24:41.602671+00:00",
"installed_commit": "6d1476936920dc5715d4493b1f8b22a3d51935f4",
"previous_release_preserved": "/var/www/investmoz-homologacao/releases/569a1cd750f3d2a0b87f130e992e73e070bbd1ab-53a57d6f",
"installer_CI": [
"\u001b[30;42mOK (1 test, 3106 assertions)\u001b[0m",
"✓ built in 31.30s",
"\u001b[30;42mOK (59 tests, 317 assertions)\u001b[0m",
"# tests 1146",
"# pass 1146",
"# fail 0"
],
"accepted_QA_reused": {
"tests": 7,
"assertions": 62,
"Gurue_browser_proof": "accepted earlier;not repeated as a prerequisite"
},
"delivery_delta_test": {
"tests": 1,
"assertions": 2,
"scope": "repeated source sightings cannot inflate saved comparisons"
},
"installed_private_catalogue_backup": {
"path": "/var/backups/investmoz-proactive-supply-delivery-20261028/catalogue-installed.dump",
"bytes": 63461920,
"sha256": "18f71d0ad72aae9645a6101c6388c4d7f481e56ef2d41dc64241245314e85d55",
"read_grants_and_RLS_in_dump": true
},
"existing_pipeline_script_sha256": "adec1c17654f3a8658b83b134f1e75f95391b235738552721906c3236e388604",
"code_branch": "main",
"forced_push": false,
"automatic_publication": false
},
"schema_and_access": "two new private tables,forced RLS,one migration registry row;SELECT only to existing application database role;no employee-role assignments changed;ninety earlier migration hashes preserved and one appended to the existing installer contract;installer program unchanged",
"accepted_proofs_reused": {
"QA_tests": 7,
"QA_assertions": 62,
"Gurue_saved_reload_dossier_chain": "accepted earlier in isolated QA;not falsely presented as new team acceptance"
},
"delivery_delta_test": {
"tests": 1,
"assertions": 2,
"reason": "explicit non-inflation requirement;reject a preserved repeated observation as another saved comparison"
},
"visual_conference": {
"status": "pending",
"known_blocker": "The browser access host is unavailable",
"technical_consumer_is_not_team_UI_acceptance": true,
"manual_remaining": "Gurué → consultar oferta → comparar ideia → guardar → recuperar no mesmo dossier,com fontes,datas e incertezas;435 observações,não empresas únicas;listas internas,cobertura parcial e hipótese preservadas.",
"blocker_history": [
"Windows Computer Use could not determine the current browser URL with enough confidence to enforce policy"
],
"mechanism": "Browser access connector host unavailable;opening a tab alone does not restore the connector. No documented host-restart operation is exposed in the available browser control API.",
"next_action": "Restabelecer o conector por mecanismo suportado ou receber conferência autenticada da equipa;nenhuma reinstalação ou repetição das provas técnicas aceites."
},
"preservation": "prior domain rows unchanged;369 persistent files/configuration identical;only intended migration row and new private base added;queue restart and2anonymous delivery sessions reconciled separately;no QA user,organisation,opportunity or dossier copied to active",
"publication": false,
"L8": "unchanged,partial;no fiscal code or calculation delta",
"remote_receipts_root": "/var/backups/investmoz-proactive-supply-delivery-20261028",
"local_proofs": "C:\\laragon\\www\\portal-investidor\\outputs\\proactive-supply-delivery-20261028",
"restriction_origins": {
"private_analysis_no_auto_publication_no_false_market_vacancy_no_duplicate_inflation": "explicit Tempo decisions",
"backup_restore_QA_isolation_preservation": "AGENTS.md",
"exact_hashes_temporary_index_database_identity_contract": "technical implementation choices;not new organisational approvals"
},
"documentation_correction": {
"date": "2026-10-28",
"origin": "explicit Tempo correction",
"installed_revision_unchanged": "6d147693",
"previous_remote_receipt_preserved_sha256": "2e306482a2dc4aea79acd77239309a3307950eddbbd9ea77db9c7f28c47d2110",
"scope": "documentation only;no active write,no reinstall,no repeated technical tests"
}
}
@@ -7194,3 +7194,210 @@ A falta de prova do caso (titular/rendimento/imposto/inclusão/uso/regra do per
Recibo consolidado:`docs/investmoz/simulations/irpc-l8-grouped-delivery-20261028.json`. Cópia remota:`/var/backups/investmoz-irpc-l8-grouped-20261028/installed-receipt.json`. Provas locais:`outputs/irpc-l8-grouped-delivery-20261028/`. Actualização documental segue ramo próprio de recibos,sem alterar novamente a revisão servida.
Cópia remota do recibo conferida:SHA256`b70fe154f95162f029b5ca712b41962757360d73b0ff07f52b8ce14848d990df`;a matriz existente recebeu saldoL8 nos camposEstado,Disponibilidade activa,Evidências,Critérios restantes eDependências,só em `AUD-SIM-INT02`.
## 28/10/2026 — listas como base interna da oferta: uso não demonstrado
**Decisão expressa da Tempo e resultado da conferência: a utilização das listas de empresas,hotéis/restaurantes como base comparativa no portal é NÃO DEMONSTRADA.** A entrega do percurso proactivo,os seus campos de evidência e os pilotos privados anteriores mantêm-se aceites;isso não demonstra carregamento,classificação e ligação destas listas ao consumidor de análise. O âmbito é primeiro a análise interna,não a publicação automática de um directório.
**Fonte e âmbito conferidos:**13ficheiros inventariados nos dois directórios acessíveis de empresas/relatórios,antes da selecção;originais `EMPRESAS_LIST2014–2022`, `Empresas Licenciadas`,cadastro turístico30/10/2024 e `CAE eBAU` localizados e iguais aos hashes aceites. As148557/212632/460linhasXML não vazias dos recibos anteriores são observações dos ficheiros,não empresas únicas nem carga no portal. Empresas têm província/distrito/CAE;isso não prova o mapeamento no portal. O cadastro turístico observado é de alojamento,não um censo completo da restauração. Não foi identificada lista autónoma de restaurantes nestes dois directórios;isso não significa ausência de restaurantes ou de actividades de restauração nas listas empresariais. Contactos e outros campos privados não foram publicados.
**Destino entregue:**código `569a1cd7`,base `teste_investidor`,198tabelas não sistémicas(174public+24skills_edu),consultadas em transacções READ ONLY com identidade técnicapostgres explicitada. Zero correspondências dos hashes originais entre368ficheiros persistentes e nenhuma correspondência exacta dos nomes/hashes nas referências pesquisadas. Cadastros `existing_businesses` e `investment_company_declarations` têm0registos nesse destino;organizações1,actividades11,documentos60 e recursos59 não são convertidos em oferta empresarial das listas. Esta é uma fotografia técnica,não prova de visibilidade para uma conta operacional,perda de registos ou ausência de oferta no mercado. Registos antigos de `investemoz` e basesQA são ambientes distintos;nenhuma eliminação é declarada. Pesquisa por nomes/hashes em metadados não exclui ficheiros renomeados,arquivados,conteúdo cifrado ou outros acervos.
**Causa delimitada no consumidor instalado:**`ProactiveInvestment::options` devolve documentos/grupos e referências a projecto,cluster,corredor,parque,cadeia;os hashes dos ficheiros instalados coincidem comGit. O editor recebe `observed_supply` e fontes/localizadores/períodos/cobertura como texto manual;as relações proactivas não admitem entidade empresarial destas listas. Não foi encontrada consulta de oferta carregada por localização/sector/actividade nem ligação de linhas das listas a concorrentes,complementares ou parceiros. A possível existência de cadastros noutro módulo não demonstra consumo por este percurso.
| Percurso de conferência — Gurué,distrito canónico | Resultado |
| --- | --- |
| Escolher localização | Referência canónica localizada;prova anterior de selecção territorial preservada,não se declara nova prova de interface. |
| Consultar oferta existente das listas | Não demonstrado:consulta/importação/classificação consumida pelo percurso não identificada. A demonstração pára neste ponto. |
| Comparar com uma ideia | Não demonstrado sobre conjunto rastreável de registos dessas listas. Campos manuais não equivalem à integração. |
| Identificar necessidade | Não demonstrado por este fluxo;ausência de registo não confirma necessidade nem ausência de concorrência. Manter hipótese,período,cobertura e incertezas. |
| Preparar oportunidade | Mecanismo privado entregue preservado;nenhuma oportunidade nova foi criada para simular passos anteriores ainda não demonstrados. |
**Critério de retoma:**lotes/linhas privados rastreáveis e classificados,reutilizando os mecanismos/cadastros existentes;consulta autorizada da oferta por localização/sector/actividade;comparação da ideia com concorrência,complementares e possíveis parceiros,com fontes/datas/cobertura;necessidade fundamentada sem inferir ausência de oferta;e prévia/gravação/recuperação da oportunidade no percurso existente emQA isolada. Só essa conferência pode demonstrar o uso;nenhum novo aprovador organizacional ou directório público é criado.
Sem imports,normalização,alteração de código,permissões,publicação ou escrita em dados activos. Songo/Gurué privados,aprovações/históricos e entregaL8 preservados. Registo:`docs/investmoz/unification/existing-supply-lists-audit-20261028.json`. Cobertura actualizada nas linhas existentesRF-CLU-003,MASTER-471012ab-0010,M2-d0c06a75-0147/0151 eMASTER-1f0a4cde-0002,sem nova matriz/backlog.
A base interna de oferta não exige transformar cada empresa numa organização utilizadora,anúncio de venda/parceria,promotor ou cliente confirmado. O cadastro e a consulta analítica são distintos da operação comercial e da divulgação.
## 28/10/2026 — oferta existente ligada à análise proactiva, demonstrada em QA
Incremento sobre o código entregue `569a1cd7`, ramo `codex/proactive-supply-qa-20261028`: **demonstrado em QA isolada, sem instalação nem importação no activo**. O relatório de conferência anterior permanece histórico: no destino activo, o uso das listas continua não demonstrado. Reutilizam-se a aceitação do percurso, o inventário de 13 ficheiros, os originais, as fontes e as provas anteriores; não se reabre a decisão da Tempo nem se encerra o estudo de mercado nacional.
Carregadas privadamente **361.464 observações**: 148.556 da lista 2014–2022, 212.631 de empresas licenciadas e 277 do cadastro turístico (duas folhas com estruturas distintas). Os quatro hashes dos originais e do CAE foram conferidos. Pré-visualização, análise Microsoft Defender sem ameaças e repetição idempotente 343.041→343.041 demonstradas. Os 147.165 registos com a mesma projecção administrativa são conservados como repetições; não representam empresas únicas nem duplicados de ficheiros por bytes. As 113.880 observações com denominações divergentes para um identificador partilhado ficam assinaladas, sem fusão automática. Contactos, documentos de identidade e NUIT não são devolvidos pela consulta.
Consulta interna por localização canónica (incluindo descendentes), sector CAE e actividade; resultados paginados conservam fonte, folha/linha, hash e data original. O sector provém da hierarquia SELF_ID do CAE fornecido, sem inventar correspondência com os sectores de navegação. A selecção permite comparar possível concorrência, complementaridade, parceiro ou relação por determinar, com fundamento e incertezas. O servidor recupera a origem, impede reclassificar a evidência interna como pública e conserva-a no rascunho e no dossier privado. Não transforma a lista em anúncios, promotores, clientes confirmados ou publicação de directório.
**Gurué:** o operador QA sintético 1021 consultou 410 observações distintas na primeira demonstração (435 após o complemento de registos sem denominação), comparou dois registos reais (serviços agrícolas CAE 01610 e comércio de plantas/sementes/fertilizantes CAE 47732) com a hipótese já preparada de apoio à colheita e qualidade da folha verde. A oferta possível exige verificar sobreposição e colaboração, sem presumir mercado vazio. Rascunho 402 passa de v1 a v2, mantendo integralmente as cinco evidências anteriores, a ideia e o histórico. O mesmo dossier `883c8ec3-59a3-4074-b91c-b7d4a5d88ff0` passa de contexto1 a2 por pré-visualização e confirmação idempotente, preservando título e campos deliberados. A necessidade permanece **hipótese**, sem procura, falta de oferta ou viabilidade confirmadas. Gravação, recarregamento, acesso privado e resultado vazio como **informação insuficiente** demonstrados no navegador. Isto é prova técnica, sem substituir observação/aceitação da equipa.
**Cobertura e pendências:** 205.608 observações sem correspondência territorial canónica única (incluindo 19 ambiguidades), 336 apenas com província e 82.864 sem designação de actividade CAE correspondente. Mantêm-se os rótulos originais e a incerteza; não há atribuição territorial aproximada nem preenchimento presumido. O cadastro de alojamento não é um censo exaustivo de hotéis/restaurantes. Situação operacional actual, capacidade, preços, procura e compromissos continuam por demonstrar. O incremento nacional é parcial; não encerra os requisitos amplos de mercado, clusters ou liquidação anual. **L8 permanece parcial e inalterado.**
Sete testes dirigidos/62 asserções, TypeScript e compilação QA passaram. As duas tabelas internas têm RLS forçada; teste com identidade sem privilégios confirma ocultação sem contexto de actor e leitura com contexto autorizado. Conferidos os hashes de todas as linhas anteriores nos sete conjuntos do inventário: só o rascunho402 e o mesmo dossier sofreram o delta intencional; Songo401, projectos, territórios, sectores, documentos e cadeias foram conservados. Provas fiscais aceites reutilizadas, sem repetir as suas suites.
Actualizados os cinco requisitos existentes `RF-CLU-003`, `MASTER-471012ab-0010`, `M2-d0c06a75-0147`, `M2-d0c06a75-0151` e `MASTER-1f0a4cde-0002`, preservando o restante estado e pendências. Nenhuma matriz ou backlog novo. A entrega activa futura mantém conferência de capacidade/compatibilidade com main, backup/restauro fresco, operação autenticada e delta instalado. Relatório: `docs/investmoz/unification/proactive-existing-supply-qa-20261028.json`.
Complemento final: as 18.423 linhas empresariais sem denominação social também foram conservadas, com essa lacuna explícita e sem reutilizar o nome pessoal do requerente. As 343.041 observações da primeira fotografia permaneceram idênticas; extensão e repetição idempotente atingem 361.464 sem substituir registos, alterar as comparações ou rever as provas aceites. Os exemplares e as fotografias anteriores foram preservados.
## 28/10/2026 — base comparativa instalada pelo Git; conferência visual autenticada pendente
Instalada e servida a revisão **`6d147693`**, que inclui o candidato aceite `a022ed98`, por fast-forward normal em `main`, sem força. A revisão569a1cd7 e os backups anteriores foram preservados. O carregamento privado foi executado em transacção autenticada depois de backup novo, restauro isolado e ensaio idempotente; o instalador Git, as suas guardas e a CI habitual foram conservados. O contrato recebeu apenas o hash da migração nova, mantendo os90 anteriores.
**361.464 observações administrativas não são361.464 empresas únicas.** As147.165 repetições foram conservadas, mas excluídas das contagens/resultados; as214.299 projecções administrativas distintas também não equivalem a empresas únicas. Em Gurué, o serviço instalado recupera435 observações distintas e conserva237 repetições fora dessa contagem. A API também recusa gravar uma repetição como outra comparação: apenas esse delta recebeu teste dirigido1/2asserções. As7provas/62asserções, tipos, compilação e percurso QA aceites foram reutilizados.
A aplicação, com a sua conta real e um actor existente que satisfaz as autorizações anteriores, consultou Gurué e conservou fonte/linha/data/hash. O resultado vazio foi «Informação insuficiente nesta base e nestes filtros.» Sem actor/contexto, a RLS devolveu0; acesso HTTP anónimo recusado401. Isto é conferência técnica do consumidor instalado, **não uma nova aceitação da equipa no navegador**. A conferência visual autenticada permanece pendente por bloqueio do mecanismo Windows de confirmação segura do URL; não pedir nova aceitação das provas QA nem confundir essa pendência com perda da funcionalidade entregue.
Backup fresco de174tabelas públicas e369ficheiros/configuração; restauro sem rede/55441 e os174checksums passaram. As duas tabelas privadas também foram carregadas, repetidas e restauradas no ensaio. Medidos344.399.872bytes para as novas relações;capacidade conferida antes da instalação. No activo, as linhas anteriores foram preservadas e os369ficheiros/configuração ficaram idênticos; o delta esperado é de2tabelas privadas e1registo de migração. Cache queue:restart e2sessões anónimas de conferência foram reconciliados separadamente. Foi criado também backup da base privada já instalada, preservando GRANT/RLS. Não se criaram utilizadores, organizações, oportunidades ou dossiers QA no activo, nem se publicaram as listas.
CI habitual do instalador: PostgreSQL1/3106asserções, Unit59/317, frontend1146aprovados/0falhas, tipos e build31,30s. O script do instalador e as configurações/ficheiros anteriores mantiveram-se; apenas leitura nas duas tabelas novas foi concedida à conta de aplicação existente. Não houve alteração de papéis de funcionários.
**Pendências conservadas:** cobertura nacional parcial,205.608observações sem território canónico único,336só com província,82.864sem designação CAE,113.880com divergências de denominação/identificador e18.423sem denominação social, todas preservadas. A ausência de registos é informação insuficiente. A comparação fundamenta uma hipótese, sem comprovar procura ou viabilidade. O caso/dossier QA anterior mantém-se em QA e não foi transplantado para o activo. **L8 permanece parcial e inalterado.** A actualização continua nos cinco requisitos existentes, sem nova matriz ou backlog. Recibo:`docs/investmoz/unification/proactive-supply-git-delivery-20261028.json`.
## 28/10/2026 — correcção do bloqueio da conferência visual
`6d147693` está instalado, incluindo a base interna. A única pendência da conferência desta entrega é a validação visual autenticada: **Gurué → consultar oferta → comparar ideia → guardar → recuperar no mesmo dossier**, conservando fonte, data e incertezas. As435 observações de Gurué não são435 empresas únicas. Listas internas, cobertura nacional parcial e hipótese de investimento mantêm-se.
O erro actual, novamente observado, é **“The browser access host is unavailable”**: indisponibilidade do host do conector, distinta de deixar uma aba aberta. A orientação anterior para apenas manter a aba aberta era insuficiente. Restabelecer o conector por mecanismo suportado ou realizar a conferência pela equipa. Não reinstalar nem repetir as provas técnicas aceites; não declarar conferência visual concluída sem o seu resultado. O recibo remoto original e o historial do diagnóstico anterior ficam preservados; esta correcção é documental.
## 28/10/2026 — revisão de menus: inventário antes da verificação do delta
Origem: recomendação expressa da Tempo após constatar que as entradas indicadas não aparecem ou ficam escondidas. Página relatada:https://teste-investidor.tempo.co.mz/. Preservados6d147693 e a base privada instalada,as provas QA do percurso e da navegação sectorial,os originais,dados,perfis,permissões e históricos. Não se declara uma funcionalidade anterior desaparecida apenas pela ausência de um novo teste.
Confirmado no código: barra lateral substitui cada grupo pelo seu primeiro destino,omitindo os restantes;menu principal esconde gestão/perfis dentro do menu do nome do utilizador;entrada de gestão de conteúdos existente não é incorporada nessa navegação principal. A conta e os props efectivos da sessão real permanecem por conferir,porque o conector está indisponível;não se presume falta de autorização.
Incremento em QA: área de trabalho explícita no menu principal,inclusive entrada neutra de acesso;destinos privados apenas segundo capacidades anteriores;mesmo catálogo completo na barra lateral,sem perda dos subdestinos;entrada directa na análise proactiva existente,sem substituir rascunhos editados;preservação de Descoberta/Realização/Suporte e todos os destinos anteriores aplicáveis. Sem alteração activa,schema,regras de negócio ou papéis. Escolhas de apresentação não são exigências organizacionais. Verificação dirigida do delta pendente nesta fotografia;nenhuma nova matriz ou backlog.
## 28/10/2026 — reorganização dos menus implementada em QA, sem instalação
Recomendação expressa da Tempo após a página principal não apresentar as entradas sugeridas. Causa delimitada no código: a barra lateral omitia subdestinos ao seleccionar apenas o primeiro link de cada grupo; a navegação principal colocava gestão/perfis dentro do menu do nome do utilizador e não incorporava a entrada de gestão já calculada pelo servidor. Não se presume falta de permissões da sessão real, cujos props não foram observados. A entrega6d147693/base interna continua instalada e preservada; esta revisão não reinstala essa base nem repete as provas técnicas aceites.
**Proposta executável QA:** Área de trabalho visível ao lado de Descoberta,Realização,Suporte; entrada neutra Acesso à gestão explica o estado, mesmo sem acções privadas atribuídas. As acções autorizadas e Preparar análise e ideia vêm das capacidades existentes, sem criar papéis. A análise abre directamente no formulário existente; rascunhos guardados não são substituídos. A barra lateral põe trabalho primeiro e conserva todos os destinos e os filtros territoriais entre catálogos, em vez de reduzir grupos ao primeiro destino. O cabeçalho permite quebra de linha em largura intermédia; os controlos móveis e do teclado existentes são reutilizados.
**Verificação delimitada:**16 testes de navegação/renderização estrutural,0falhas;tipos,compilação QA e sintaxe PHP passaram. Quatro cenários de capacidades conservaram todos os destinos anteriores; não surgem operações privadas em cenário anónimo ou sem atribuições. SSR usa o componente real da barra lateral com wrappers semânticos de teste; não equivale a captura do navegador. A prévia HTML é um inventário interactivo de destinos, sem pedidos ao portal, identificado como proposta QA, não como sessão real.
**Pendente:** conferência visual desktop/móvel/teclado/toque e das capacidades efectivas da sessão relatada; o host do conector permanece indisponível no último diagnóstico. Não substituir essa conferência por alegação de acesso demonstrado. A instalação futura deve transportar só o delta de menus pelo processo Git/backup/restauro existente, preservando originais, dados, permissões, contexto,dossier e simulador.435 observações de Gurué não são435empresas únicas; listas internas/cobertura parcial/hipótese preservadas. L8 continua parcial.
Inventário anterior registado antes dos testes nesta matriz/manual. Actualização apenas nos requisitos existentesV2-01,V2-07,V2-10,MASTER-9dee08d0-0011,MASTER-9f6dd1d9-0001,MASTER-9f6dd1d9-0003,MASTER-02f44baf-0002;não há matriz ou backlog novos. Relatório:`navigation-reconciliation-qa-20261028.json`;prévia:`outputs/navigation-reconciliation-20261028/menu-proposta-qa.html`.
A apresentação da gestão antiga usa agora a decisão efectiva já existente (`legacyWriteAllowed`, com o requisito de email verificado das rotas), em vez de presumir que o indicador genérico de gestor basta. Não se alteraram regras, atribuições ou permissões. Preservada também a entrada de instalação de perfis anteriormente presente apenas na barra lateral autorizada. Os nomes longos quebram linha na barra lateral, sem reticências que escondam a função.
## 28/10/2026 — menus 6e8de43a instalados pelo Git; conferência visual pendente
A Tempo aprovou a proposta QA e autenticou o transporte SSH existente. `main` avançou normalmente de `6d147693` para `6e8de43a`, sem force. A entrega usou o pipeline existente, sem alterar o seu contrato de schema, sem migrações, importações, atribuições de papéis ou permissões. Os 16 testes dirigidos e as provas QA aceites foram reutilizados; as verificações automáticas obrigatórias do pipeline não representam nova aceitação funcional.
Capacidade conferida antes da entrega: cerca de 78,5 GiB livres. Backup novo em `/var/backups/investmoz-navigation-delivery-20261028/backup`; restauro em base isolada, sem rede, confirmou as 176 tabelas por contagens/checksums. Os 369 ficheiros persistentes/configuração mantêm-se idênticos. As tabelas de domínio, incluindo base interna, relações e permissões, foram comparadas com o snapshot do backup; apenas alterações operacionais de cache/sessão são registadas separadamente. A versão anterior está preservada para retorno pelo mecanismo existente.
O delta instalado contém Área de trabalho no nível principal, destinos subordinados completos na barra lateral e continuidade para os destinos existentes de investimento, conteúdos e acompanhamento. A exposição das acções respeita a decisão efectiva do servidor; não se infere nova autorização pelo nome genérico de gestor. A presença dos assets e props instalados foi conferida tecnicamente, sem a apresentar como prova da sessão real.
Pendente: conferência visual autenticada desktop/móvel/teclado/toque e percurso Gurué → consultar oferta → comparar ideia → guardar → recuperar no mesmo dossier. Último bloqueio conhecido: `The browser access host is unavailable`; uma aba aberta não restabelece o host. Não repetir as provas QA aceites nem reinstalar a base interna para resolver essa pendência. As 435 observações de Gurué não equivalem a 435 empresas únicas; listas internas, cobertura parcial e hipótese de investimento mantêm-se explícitas. L8 permanece parcial.
Evidência: `docs/investmoz/unification/navigation-git-delivery-20261028.json`. O relatório QA anterior permanece como histórico do candidato antes da instalação. Actualizados os mesmos sete requisitos existentes, preservando o seu histórico; não foi criada outra matriz.
# Inventário de preservação — consulta territorial, 28/10/2026
Antes das novas verificações: menus `6e8de43a` instalados, base interna e percurso proactivo preservados; conferência visual autenticada continua pendente. Reutilizam-se as provas anteriores. O caso relatado é Agronegócio / Maputo Província. Em leitura da QA isolada, as 37 oportunidades públicas incluem cinco do Corredor de Maputo; o filtro provincial devolve zero. A correspondência aprovada do corredor para Maputo Província e Maputo Cidade já existe em `config/investment_corridors.php` e `corridor-adaptive-context-20261023.json`, mas a pesquisa não a utiliza. Não se trata de eliminação dos registos. Os cartões mostram apenas província e omitem o detalhe territorial disponível. A correcção é de consulta/apresentação, sem criar localizações distritais não documentadas, relações a mestres, permissões ou novas publicações. Referência aos requisitos existentes V2-07/V2-10; a implementação transversal e a sessão real não ficam declaradas concluídas por este inventário.
## 28/10/2026 — correcção provincial do catálogo em QA, ainda sem instalação
Caso da Tempo: Agronegócio / Maputo Província. A leitura QA reproduziu zero resultados para cinco oportunidades públicas do Corredor de Maputo. Reutilizada a decisão expressa de 23/10 que abrange Maputo Província e Maputo Cidade, antes disponível no percurso mas omitida na consulta. O filtro provincial passa a incluir esse contexto aprovado, mantendo sector e estado público, sem duplicar oportunidades nem atribuir distrito/posto administrativo ou coordenadas inexistentes. Uma localização explícita posterior prevalece sobre o contexto antigo.
Os resultados apresentam a hierarquia territorial disponível; o contexto de corredor é identificado e os níveis não indicados continuam explícitos. Pesquisa/API, vista por corredores e lista sem coordenadas do mapa concordam. O atalho «Ver toda a província» elimina a necessidade de limpar vários níveis manualmente. Não foram criadas publicações, relações a mestres ou permissões. Provas QA: 4 testes/70 verificações, zero falhas, transacções revertidas; tipos, compilação QA e sintaxe PHP passaram. O inventário anterior foi registado antes dos testes. Menus 6e8de43a, base interna, históricos e aceitação anterior preservados; L8 permanece parcial.
Relatório: `catalogue-province-coverage-qa-20261028.json`. Instalação pelo processo Git e conferência visual autenticada continuam pendentes. Não declarar auditados todos os outros menus por este delta. Cobertura actualizada nos requisitos existentes V2-07/V2-10; sem nova matriz.
28/10 — Âmbito nacional confirmado pela Tempo: antes da extensão dos testes, preservar a correcção QA 1b2ede41 e verificar os seis corredores aprovados nas onze unidades provinciais, hierarquia descendente e reutilização entre sectores/entradas de localização. Não presumir cobertura de corredor onde não está documentada. Nenhuma prova anterior é revogada.
28/10 - National coverage: 6 tests / 617 assertions passed across all 11 provincial units, 6 configured corridors and 2 sectors, including descendant hierarchy, sector separation and both province/location entry points. The runtime correction already used the common national hierarchy; Maputo was the reported reproduction, not a geographic limit. Existing publication decisions and unknown district/post/site information remain preserved. This is not a claim that every unrelated portal menu has been audited. No additional runtime change since 1b2ede41; types/build accepted evidence reused. Installation remains pending.
28/10 - Before selector delta checks: accepted national catalogue QA 47dae1b7, 6 tests/617 assertions, remains uninstalled. Existing TerritoryLevel and ProvinceSelect required two separate fields (search then select). Tempo requests one dropdown on the search field. Consolidate the shared controls while retaining saved values, hierarchy, endpoints, paging and permission decisions;browser visual proof remains separate.
## 28/10/2026 — selecção territorial num único campo de pesquisa, QA
Pedido expresso da Tempo: o dropdown deve abrir em «Pesquisar província», sem um segundo controlo «Seleccione». `ProvinceSelect` e `TerritoryLevel` passam a usar o mesmo combobox, aplicado a província, distrito, posto administrativo e localidade. Pesquisar e escolher fica num único campo; a paginação permanece na lista. Escrever ou sair com Escape não substitui a selecção guardada; escolher e limpar são acções expressas. Mantêm-se a hierarquia, os endpoints existentes e os controlos de acesso, sem novos dados ou publicações.
Quatro testes do contrato de eventos/estado passaram: abertura/escolha, preservação ao escrever/Escape, teclado e bloqueio de resultado antigo durante carregamento, limpeza e dependência do nível anterior. Tipos e compilação QA passaram. As 6 provas nacionais/617 verificações são reutilizadas porque o PHP territorial não mudou. Este delta continua em QA e não comprova apresentação ou sessão real no navegador. Será agrupado com a correcção provincial; falta autenticação SSH antes da entrega, sem nova aprovação organizacional.
28/10 - Before catalogue presentation checks: accepted province QA6/617 and unified selector QA4 remain preserved and uninstalled. Tempo reports Tourism/Cabo Delgado shows educational text and broad sector links. Read-only public API confirmed zero filtered records;unfiltered tourism returns16 records,all declared in Inhambane. This does not establish absence of tourism supply in Cabo Delgado. Correct presentation to retain filters and expose guides only as optional links;do not republish or reassign opportunities to manufacture results.
## 28/10/2026 — resultados do catálogo separados do apoio educativo, QA
A Tempo esclareceu que a combinação província/sector deve apresentar as oportunidades correspondentes, sem texto educativo ou atalhos que troquem a consulta por todas as oportunidades do sector. A consulta pública anónima conferiu Turismo/Cabo Delgado = zero oportunidades cadastradas; Turismo sem localização = 16, todas declaradas em Inhambane. Este snapshot não prova ausência de oferta turística em Cabo Delgado e não substitui inventário de fontes ou consulta autenticada.
Correcção de apresentação em QA: título dos resultados explicita sector e hierarquia escolhidos; removidos os cartões educativos e o botão sectorial sem localização da área de resultados. Os guias mantêm-se como títulos ligados em apoio opcional fechado por defeito. A remoção da localização é uma acção expressamente identificada como pesquisa noutras províncias. O estado vazio refere o cadastro consultado e a cobertura incompleta, sem fabricar oportunidades nem alargar automaticamente filtros.
Dois testes SSR do novo apoio opcional passaram, bem como tipos e compilação. As provas territoriais nacionais 6/617 e os quatro testes do selector são reutilizados; não mudou a consulta PHP ou o controlo territorial neste delta. Mantêm-se histórico, publicações, permissões, fontes e base interna. Este candidato continua sem instalação, dependente de autenticação SSH; a sessão real permanece sem prova visual. Relatório existente: `catalogue-province-coverage-qa-20261028.json`, campo `catalogue_result_presentation`. Requisitos V2-07/V2-10 actualizados na mesma matriz; L8 permanece parcial.
28/10 - Before investor usability checks: accepted catalogue candidate54d1b2a5 remains in QA,not installed;active menus6e8de43a and earlier supply proofs preserved. Two Tempo attachments identify duplicate profile navigation,an exposed long canonical form and ZEE/ZFI/park searches that keep prior results until manual submit. Prepare presentation-only adaptive field groups and safe save-before-resource navigation;verify territorial search/count/paging and documented scope without republishing records or changing permissions. Reuse prior proofs;existing V2-07/V2-10 scope retained.
## 28/10/2026 — usabilidade do investidor e consulta territorial, QA
Pedidos da Tempo e exemplos recebidos: menu/ficha de oportunidade, zonas/parques, pedido de Promotor, Os meus investimentos, documentos/formulários e recusa autenticada em Turismo. Inventário anterior registado antes dos testes: menus 6e8de43a e base interna instalados; candidato territorial 54d1b2a5 ainda em QA; originais, dados, relações, histórico, aprovações e provas anteriores preservados.
Delta QA: acções do investidor em primeiro plano, administração em detalhe consultável; campos do contrato existente agrupados por tarefa e modalidade, com navegação livre e todos os campos montados para conservar edições. Os grupos mostram as pendências da última gravação e os erros existentes; não criam seis ou oito formulários obrigatórios. Guardar usa os mesmos endpoints/ETags; abrir recursos pela preparação guarda primeiro e regressa à mesma oportunidade/grupo. Contactos e detalhes de disponibilidade ficam opcionais; códigos técnicos de categorias e campos conhecidos têm nomes em português. Só um contexto único de promotor é escolhido automaticamente.
Zonas: selecção territorial aplica pesquisa sem esperar por outro clique; a lista anterior deixa de aparecer durante carregamento/erro. Totais, intervalo visível e páginas identificados, sem apresentar a primeira página como o conjunto completo. «Todo o território» remove expressamente a localização, preservando os restantes critérios visíveis. Abrangência jurídica documentada e validada inclui os níveis descendentes; influência não se torna implantação. Não são criadas localizações, direitos, publicações ou certificados.
Pedido de Promotor: representação/modalidades com nomes claros; pesquisa de ficheiros e selecção expressa dos comprovativos, preservando os escolhidos ao pesquisar. Mantidos propriedade, análise antimalware e verificações existentes; legislação/imagem/teste não se torna comprovativo por estar disponível. Os meus investimentos destaca a próxima acção e permite abrir fases/etapas; fontes, códigos e acompanhamento interno ficam preservados em detalhes.
Formulários: entradas directas para os campos já existentes da preparação do pedido de investimento e Terra/DUAT; pedido de apoio identificado separadamente. Originais oficiais e guias continuam consultáveis, sem os confundir com um formulário preenchível ou converter leis em campos/cálculos. Não se declara executada a totalidade dos formulários oficiais do acervo.
Provas delimitadas: 8 testes frontend de contrato/SSR e 3 testes isolados/12 verificações, zero falhas; tipos, compilação e sintaxe PHP passaram. Dados sintéticos revertidos em QA. Provas nacionais/selector/catálogo anteriores reutilizadas. Turismo: leitura anónima activa HTTP200 e QA visitante/conta sem Promotor HTTP200; a recusa da sessão real relatada não foi reproduzida, nem atribuída a uma permissão sem prova. A conferência autenticada e visual continua pendente. Este registo não instala o candidato nem encerra a implementação transversal. L8 permanece parcial.
Evidência: `investor-usability-qa-20261028.json`. Actualizados V2-01,V2-07,V2-10 na matriz existente; sem nova matriz ou backlog.
### 28/10 — botão de cadastro e compatibilidade do pipeline
A Tempo esclareceu que a recusa em Turismo ocorreu depois de clicar num botão; não foi possível recuperar o nome. A rota pública continuou HTTP200. Conferência de forma pública/origem não identificou anexos privados nos documentos públicos de Turismo; não se atribui a recusa à perda de publicação. Encontrada uma falha delimitada no consumidor: o botão de cadastro dependia da disponibilidade do módulo, sem a capacidade efectiva da conta. Corrigido para reutilizar o destino autorizado de criação de ContentManagement; sem essa capacidade, mostra expressamente o pedido de acesso já existente. Não cria papéis nem restringe a consulta pública ou a preparação de ideias. A confirmação visual da acção real permanece pendente.
O candidato 502e3ec4 falhou na Unit de agrupamento de corredores antes da troca de release; 6e8de43a ficou servido. Causa: a simulação do catálogo não representava a nova apresentação de localização e uma consulta era feita antes de conferir se a fonte era aplicável. Correcção: conferir primeiro a fonte e simular a nova dependência no teste, conservando as verificações anteriores de identidade, grupos e contagens. Regressão Unit1/13 passou; não foi alterado o pipeline, o contrato de schema ou a exigência dos testes. Frontend9, Feature3/14, tipos e compilação passaram. Rótulos conhecidos de temas, categorias e idiomas do acervo traduzidos para apresentação; valores dos filtros preservados.
## 28/10/2026 — entrega agrupada de usabilidade e filtros instalada, 789fffd0
Entrega autorizada pela Tempo e autenticada pelo mecanismo SSH existente. `main` avançou normalmente e o pipeline existente instalou `789fffd0`, preservando a release anterior `6e8de43a`. O candidato inicial `502e3ec4` foi recusado antes da troca por uma dependência não representada no teste Unit do agrupamento de corredores; o activo ficou preservado. A consulta desnecessária foi corrigida e a nova dependência simulada, conservando as provas anteriores. O pipeline e o seu contrato de schema não foram enfraquecidos.
Backup/restauro de 176 tabelas confirmado numa instância isolada, sem rede. Os 369 ficheiros persistentes/configuração mantêm-se idênticos. A comparação do snapshot com o instalado preserva as tabelas de domínio, incluindo papéis, publicações, originais e base interna; diferenças de cache/sessões são registadas separadamente. Sem migrações, importações ou activação fiscal. A versão anterior permanece disponível para o mecanismo de retorno.
Incluídos: consulta provincial nacional/corredores e detalhe territorial; pesquisa/selecção num único controlo; resultados do catálogo separados do apoio educativo; grupos adaptáveis de preenchimento, navegação livre e gravação antes de abrir recursos; pedido de Promotor legível com comprovativos escolhidos expressamente; zonas filtradas ao seleccionar, contagens/paginação visíveis; fases/etapas do dossier e fontes em detalhes; entradas para formulários de preparação já existentes; CTA sectorial conforme capacidade efectiva; rótulos do acervo legíveis. Não são novas permissões, publicações ou formulários fiscais oficiais inventados.
Conferidos o commit servido, APIs filtradas e assets instalados. Reutilizadas as provas QA aceites; a bateria obrigatória do pipeline não reabre a sua aceitação. Mantém-se pendente a conferência visual autenticada, incluindo navegação, ida/volta a recursos e Gurué no mesmo dossier. A recusa relatada em Turismo ocorreu após um botão; a falha concreta do CTA de cadastro foi corrigida, mas não se declara reproduzida e resolvida a acção original sem a sessão real. A escolha do formulário fiscal concreto ainda precisa de ser esclarecida; os diplomas IVA mostrados não são formulários preenchíveis.
Estado actual: `investor-usability-git-delivery-20261028.json`. Os relatórios QA anteriores conservam o estado histórico antes da instalação. As 435 observações de Gurué não são 435 empresas; as listas são internas, a cobertura nacional é parcial e a comparação sustenta hipóteses. L8 permanece parcial. Actualizados V2-01/V2-07/V2-10 na matriz e manual existentes.
## 28/10/2026 — preparação de formulários fiscais, inventário antes das provas
Pedido expresso da Tempo: preencher formulário de IVA ou IRPC, em vez de abrir legislação. Base preservada: entrega instalada 789fffd0, Modelo A e aquisições/apuramento já aceites, L6 M20/M22 instalado em 5aaf1a56 e incrementos de L8. Nenhuma perda confirmada neste recorte; não se apagam as aceitações nem se repetem provas fiscais anteriores. Fontes reutilizadas do inventário de 20 exemplares acessíveis em outputs/irpc-l6-values-forms/accessible-source-inventory.json da QA anterior, incluindo temporários identificados; inventário do corpus e originais preservados. M22: três páginas conferidas visualmente nesta revisão, SHA256 4b61bd83d1fbcfd3b7b5c5523cea5923f7d0f65dc4c004eff58e5db38f763c88. Modelo A: correspondência e divergências aceites em modelo-a-reconciliacao-20261021.md; sem nova validação de proveniência.
Incremento em preparação QA: entrada directa em Documentos e Formulários, preenchimento manual dos campos correspondidos de IVA e subconjunto M22, identificação/período, revisão, impressão e ficheiro privado para recuperação mediante confirmação. Escolha técnica: sem persistência no servidor nem ligação automática ao dossier neste incremento; não altera schema, dados, permissões ou cálculos. Ficheiro não publicado. IRPC ajustamentos completos, outros regimes, anexos, assinatura e submissão à AT fora da cobertura; L8 permanece parcial. Ainda sem instalação deste incremento. Próximas provas apenas do delta: fidelidade da recuperação, rejeição de ficheiros incompatíveis sem perda, formatos/valores desconhecidos, acesso público e compilação.
## 29/10/2026 — ACT-20261029-01, inventário antes da verificação do incremento
Continuação sequencial solicitada pela Tempo. Correspondência: ACT-20261029-01 reutiliza D-02 §8, clusters/cadeias e a orientação metodológica documentada em 23/10. Concluído previamente e preservado: gestão de clusters, etapas, ligações, lacunas e relações auditadas; disponibilidade instalada de carteira/âncoras e formulários fiscais conforme recibos de 28/10. Nenhuma perda nova confirmada nesta inspecção de código; não foi feita nova comparação de dados ou alegada prova do servidor. A pasta principal está em fde644af, enquanto o último recibo identifica 332c16eb instalado; reconciliar o delta sobre essa origem antes de instalar, sem substituir o destino pela pasta principal.
Incremento em preparação: diagnóstico qualitativo das cinco forças na edição do cluster, sector/região editáveis, fonte/período/evidência/justificação/limites/oportunidade por força; indeterminação explícita, ficheiro privado transferível e recuperação com confirmação. Sem alteração de schema, dados, permissões, publicações ou motores fiscais. Persistência no servidor, ligação territorial canónica, dados sectoriais fundamentados e consulta pública permanecem por implementar; ACT-01 não está concluída. Verificação dirigida e instalação ainda pendentes neste registo inicial. Restantes ACT-02 a 16 mantêm o estado anterior e a sequência, sem trabalho SGF nesta passagem.
### ACT-20261029-01 — candidato sequencial preparado, 29/10/2026
Primeiro incremento implementado, **parcial e ainda não instalado**: gestão → clusters → editar → Competitividade. As cinco forças conservam fonte, período, evidência, apreciação, limites e oportunidade; sector e região explícitos, sem inferências automáticas. Transferência de rascunhos JSON e recuperação restrita ao mesmo cluster, com confirmação antes de substituir. Quatro testes frontend dirigidos, TypeScript e compilação QA passaram tanto na pasta de trabalho como no candidato baseado em 332c16eb. Guarda C132 local passou; não foram efectuados testes nem escritas na base activa. A edição existente recebe apenas a inclusão do componente; contratos e permissões existentes preservados.
Origem reconciliada: main remoto e revisão pública conferidos em 332c16eb em 29/10. Candidato em C:/laragon/www/portal-investidor-competitiveness-qa-20261029, ramo codex/competitiveness-sequential-20261029. Consulta do estado remoto não equivale a inventário novo da base. Sessão SSH anterior encerrada; autenticação por chave recusada. Instalação requer autenticação SSH, backup fresco, restauro isolado, guarda de versão, pipeline existente e conferência posterior. Não instalar a pasta principal sobre o destino. A sequência solicitada mantém ACT-02 a 16 por iniciar após a instalação e o saldo de ACT-01; persistência no servidor, cobertura factual e consulta pública continuam pendentes. Sem declaração de conferência visual autenticada.
### 29/10/2026 — ACT-20261029-01, primeiro incremento instalado: 23ecccfa
Estado corrente de ACT-01: **parcial — primeiro incremento instalado**, substituindo apenas o estado planeado/preparado deste recorte; ACT-02 a 16 não foram implementadas nesta entrega. Instalado por avanço normal de main e pipeline existente em https://teste-investidor.tempo.co.mz/. Entrada: Gestão → Clusters → Editar → Competitividade — cinco forças de Porter. Preparação qualitativa por sector/região, evidências/fontes/períodos/limites e oportunidades, transferência JSON e recuperação confirmada do mesmo cluster. Sem gravação no servidor nem publicação da análise. Persistência, cobertura factual, território canónico e consulta pública continuam pendentes; esta instalação não encerra o critério amplo ACT-01.
Provas: backup fresco, restauro isolado sem rede das 176 tabelas; pipeline com lifecycle de schema (3.106 asserções), 59 testes Unit/320 asserções e 1.180 testes frontend sem falhas, tipos e compilação; quatro ficheiros do incremento conferidos por SHA-256 no instalado; C132 no destino passou. Comparação após instalação: todas as 176 tabelas de domínio preservadas, diferenças operacionais apenas em cache/sessões; 369 ficheiros/configuração idênticos. Acesso anónimo ao catálogo privado recusado HTTP401. Zero migrações/importações, nenhuma nova divulgação nem mudança de permissões; versão anterior 332c16eb preservada. Conferência visual autenticada permanece pendente. Recibo: `docs/investmoz/unification/competitiveness-sequential-qa-20261029.json`, em docs/investmoz/unification. As provas aceites anteriores foram reutilizadas; não foram reabertas por esta entrega.
@@ -3006,3 +3006,20 @@ AUD-SIM-INT01 through AUD-SIM-INT08 are recorded in the same matrix and manual.
Actualização QA de 20/10/2026: os mesmos oito itens foram reconciliados no manual e nas duas tabelas existentes. Data com mensagem junto ao campo; inventário dos parâmetros efectivos (zero regras fiscais QA); controlos matemáticos independentes; aviso de recálculo; quatro relatórios reais do exportador com 6/6/6/5 páginas; consulta fiscal com erro/nova tentativa e traduções comuns verificados. O formulário personalizado já não exige contexto fiscal que não envia ao cálculo. Restam continuidade autenticada entre dois dossiers/nova sessão e histórico, mudança de parâmetros, controlos delimitados de cash-flow/plano, impressão nativa Edge/comparação pela interface e mensagens dinâmicas/falhas dirigidas de gravação/exportação. Estimativa restante **6–10 h**, distinta das 18–30 h iniciais. Sem main, servidor remoto, importação ou activação fiscal. Exemplos PDF são renderizações do DOCX; não certificam impressão do Edge. Ver o saldo detalhado AUD-SIM-INT01–08 no mesmo manual.
Continuação delimitada: dois dossiers e nova entrada conferidos no navegador; histórico agora visível no percurso de pequeno negócio, hashes preservados; Abrir cálculo encaminha para a etapa financeira; repetição de gravação após 503 corrigida e conferida; controlos independentes de tesouraria/plano passaram. CSV e Word descarregados pela interface coincidem nos valores comuns e apresentam a receita completa do crédito. Seis páginas do DOCX conferidas; PDF nativo ainda aguarda gravação pelo utilizador. QA tem zero regras com visibilidade completa, mas existem três originais fiscais versionados e análises/referências reutilizáveis; não se concluiu ausência na homologação sem autenticação SSH. Versões sintéticas testadas apenas em memória, sem cadastro/activação. Encontrada e corrigida perda dos pressupostos da tesouraria: snapshot no JSON existente, recuperação por dossier e relatório, sem migration; testes HTTP/frontend passaram, falta conferência visual deste novo delta. Saldo **2–4 h** após acessos/ficheiro, detalhado no mesmo manual; integração do motor fiscal permanece separada e não foi implementada. Sem main ou intervenção remota.
## 29/10/2026 — ACT-20261029-01, inventário antes da verificação do incremento
Continuação sequencial solicitada pela Tempo. Correspondência: ACT-20261029-01 reutiliza D-02 §8, clusters/cadeias e a orientação metodológica documentada em 23/10. Concluído previamente e preservado: gestão de clusters, etapas, ligações, lacunas e relações auditadas; disponibilidade instalada de carteira/âncoras e formulários fiscais conforme recibos de 28/10. Nenhuma perda nova confirmada nesta inspecção de código; não foi feita nova comparação de dados ou alegada prova do servidor. A pasta principal está em fde644af, enquanto o último recibo identifica 332c16eb instalado; reconciliar o delta sobre essa origem antes de instalar, sem substituir o destino pela pasta principal.
Incremento em preparação: diagnóstico qualitativo das cinco forças na edição do cluster, sector/região editáveis, fonte/período/evidência/justificação/limites/oportunidade por força; indeterminação explícita, ficheiro privado transferível e recuperação com confirmação. Sem alteração de schema, dados, permissões, publicações ou motores fiscais. Persistência no servidor, ligação territorial canónica, dados sectoriais fundamentados e consulta pública permanecem por implementar; ACT-01 não está concluída. Verificação dirigida e instalação ainda pendentes neste registo inicial. Restantes ACT-02 a 16 mantêm o estado anterior e a sequência, sem trabalho SGF nesta passagem.
### ACT-20261029-01 — candidato sequencial preparado, 29/10/2026
Primeiro incremento implementado, **parcial e ainda não instalado**: gestão → clusters → editar → Competitividade. As cinco forças conservam fonte, período, evidência, apreciação, limites e oportunidade; sector e região explícitos, sem inferências automáticas. Transferência de rascunhos JSON e recuperação restrita ao mesmo cluster, com confirmação antes de substituir. Quatro testes frontend dirigidos, TypeScript e compilação QA passaram tanto na pasta de trabalho como no candidato baseado em 332c16eb. Guarda C132 local passou; não foram efectuados testes nem escritas na base activa. A edição existente recebe apenas a inclusão do componente; contratos e permissões existentes preservados.
Origem reconciliada: main remoto e revisão pública conferidos em 332c16eb em 29/10. Candidato em C:/laragon/www/portal-investidor-competitiveness-qa-20261029, ramo codex/competitiveness-sequential-20261029. Consulta do estado remoto não equivale a inventário novo da base. Sessão SSH anterior encerrada; autenticação por chave recusada. Instalação requer autenticação SSH, backup fresco, restauro isolado, guarda de versão, pipeline existente e conferência posterior. Não instalar a pasta principal sobre o destino. A sequência solicitada mantém ACT-02 a 16 por iniciar após a instalação e o saldo de ACT-01; persistência no servidor, cobertura factual e consulta pública continuam pendentes. Sem declaração de conferência visual autenticada.
### 29/10/2026 — ACT-20261029-01, primeiro incremento instalado: 23ecccfa
Estado corrente de ACT-01: **parcial — primeiro incremento instalado**, substituindo apenas o estado planeado/preparado deste recorte; ACT-02 a 16 não foram implementadas nesta entrega. Instalado por avanço normal de main e pipeline existente em https://teste-investidor.tempo.co.mz/. Entrada: Gestão → Clusters → Editar → Competitividade — cinco forças de Porter. Preparação qualitativa por sector/região, evidências/fontes/períodos/limites e oportunidades, transferência JSON e recuperação confirmada do mesmo cluster. Sem gravação no servidor nem publicação da análise. Persistência, cobertura factual, território canónico e consulta pública continuam pendentes; esta instalação não encerra o critério amplo ACT-01.
Provas: backup fresco, restauro isolado sem rede das 176 tabelas; pipeline com lifecycle de schema (3.106 asserções), 59 testes Unit/320 asserções e 1.180 testes frontend sem falhas, tipos e compilação; quatro ficheiros do incremento conferidos por SHA-256 no instalado; C132 no destino passou. Comparação após instalação: todas as 176 tabelas de domínio preservadas, diferenças operacionais apenas em cache/sessões; 369 ficheiros/configuração idênticos. Acesso anónimo ao catálogo privado recusado HTTP401. Zero migrações/importações, nenhuma nova divulgação nem mudança de permissões; versão anterior 332c16eb preservada. Conferência visual autenticada permanece pendente. Recibo: `docs/investmoz/unification/competitiveness-sequential-qa-20261029.json`, em docs/investmoz/unification. As provas aceites anteriores foram reutilizadas; não foram reabertas por esta entrega.
File diff suppressed because one or more lines are too long
+2
View File
@@ -236,6 +236,7 @@
}
@media (min-width: 1280px) {
.tempo-header-inner {
grid-template-rows: minmax(71px, auto);
grid-template-columns: auto minmax(0, 1fr) auto auto auto;
column-gap: 0.25rem;
padding-inline: 1.25rem;
@@ -256,6 +257,7 @@
}
.tempo-primary-navigation > ul {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0;
}
+17 -35
View File
@@ -1,10 +1,8 @@
import { profileLinks } from '@/components/profile-navigation';
import {PortalSidebarNavigation} from '@/components/portal-sidebar-navigation';
import type { ProfileAccess } from '@/types/profile-access';
import { Link, usePage } from '@inertiajs/react';
import { LayoutGrid } from 'lucide-react';
import { portalNavigation } from '@/lib/portal-navigation';
import { portalNavigation,canShowLegacyManagement } from '@/lib/portal-navigation';
import AppLogo from '@/components/app-logo';
import { NavMain } from '@/components/nav-main';
import { NavUser } from '@/components/nav-user';
import {
Sidebar,
@@ -20,6 +18,7 @@ import type { Auth } from '@/types';
import type { OpportunityManagementAccess } from '@/types/opportunity-management';
export function AppSidebar() {
const currentPage=usePage();
const {
auth,
existingBusinessEnabled,
@@ -27,7 +26,9 @@ export function AppSidebar() {
aiEnabled,
opportunityManagement,
contentManagementHref,
contentManagementAccess,
profileAccess,
scopedAdministration,
} = usePage<{
auth: Auth;
existingBusinessEnabled?: boolean;
@@ -36,45 +37,26 @@ export function AppSidebar() {
profileAccess?: ProfileAccess | null;
opportunityManagement?: OpportunityManagementAccess | null;
contentManagementHref?: string | null;
scopedAdministration?: {label:string;href:string}[];
contentManagementAccess?: {opportunities?:{index:string;create?:string|null}};
}>().props;
const groups = portalNavigation({
authenticated: !!auth.user,
manager: auth.canManagePortal,
manager: canShowLegacyManagement(auth),
businesses: existingBusinessEnabled,
education: educationEnabled,
ai: aiEnabled,
opportunityManagement,
profileAccess,
contentManagementHref,
contentManagementAccess,
scopedAdministration,
legacyContent: currentPage.props.legacyContentEnabled as boolean|undefined,
procurement: currentPage.props.procurementEnabled as boolean|undefined,
businessReviewUrl: currentPage.props.businessReviewUrl as string|null|undefined,
});
const items = groups.map((group) => ({
title: group.label,
href: group.children?.find((item) => item.href)?.href ?? '/',
icon: LayoutGrid,
}));
if (contentManagementHref) {
items.unshift({ title: 'Gestão de conteúdos e oportunidades', href: contentManagementHref, icon: LayoutGrid });
}
if (auth.canManagePortal && opportunityManagement) {
items.push({
title: opportunityManagement.manage.label,
href: opportunityManagement.manage.href,
icon: LayoutGrid,
});
if (opportunityManagement.installed) {
items.push({
title: opportunityManagement.create.label,
href: opportunityManagement.create.href,
icon: LayoutGrid,
});
}
}
items.push(
...profileLinks(profileAccess).map((link) => ({
...link,
icon: LayoutGrid,
})),
);
return (
<Sidebar collapsible="icon" variant="inset">
<Sidebar collapsible="offcanvas" variant="inset">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
@@ -88,7 +70,7 @@ export function AppSidebar() {
</SidebarHeader>
<SidebarContent>
<NavMain items={items} />
<PortalSidebarNavigation groups={groups} url={currentPage.url}/>
</SidebarContent>
<SidebarFooter>
@@ -0,0 +1,15 @@
import { Link } from '@inertiajs/react';
import type { EducationalArticle } from './educational-article-card';
/** Guides remain available on request; they are not matching investment results. */
export default function CatalogueGuideLinks({ articles }: { articles: EducationalArticle[] }) {
if (!articles.length) return null;
return <details className="rounded border p-3">
<summary className="cursor-pointer font-medium">Guias do sector — apoio opcional</summary>
<ul className="mt-3 space-y-2">
{articles.map((article) => <li key={article.href}>
<Link className="underline" href={article.href}>Ler guia: {article.title}</Link>
</li>)}
</ul>
</details>;
}
@@ -2,5 +2,5 @@ import { Link, usePage } from '@inertiajs/react';
export default function ContentManagementEntry() {
const { contentManagementHref } = usePage<{ contentManagementHref?: string | null }>().props;
return contentManagementHref ? <Link className="inline-block rounded-lg border px-4 py-3 font-semibold underline" href={contentManagementHref}>Gestão de conteúdos e oportunidades</Link> : null;
return contentManagementHref ? <Link className="inline-block rounded-lg border px-4 py-3 font-semibold underline" href={contentManagementHref}>Área de trabalho</Link> : null;
}
@@ -3,6 +3,7 @@ import { useEffect, useId, useState } from 'react';
import { api } from '@/pages/investment/shared';
import { replaceTerritoryPath } from '@/lib/territory-path.js';
import TerritoryGeometryPreview from './territory-geometry-preview';
import TerritorySearchSelect from './territory-search-select';
export type TerritoryOption = {
id: string;
@@ -135,37 +136,11 @@ export function ProvinceSelect({
total={options.length}
retry={() => setRetry((value) => value + 1)}
/>
<input
className={className}
aria-label={`Pesquisar ${label}`}
maxLength={100}
placeholder="Pesquisar nome ou código"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<select
id={id}
aria-label={label}
className={className}
required={required}
value={value}
disabled={loading || Boolean(error)}
onChange={(event) => onChange(event.target.value)}
>
<option value="">
{loading ? 'A carregar províncias…' : placeholder}
</option>
{value && !options.some((option) => option.name === value) && (
<option value={value}>
{value} (seleção guardada; conferir cadastro)
</option>
)}
{options.map((option) => (
<option key={option.id} value={option.name}>
{option.name}
</option>
))}
</select>
<TerritorySearchSelect id={id} label={label} className={className} required={required}
placeholder={placeholder === 'Seleccione uma província' ? 'Pesquisar província' : placeholder}
search={search} onSearch={setSearch} options={options} loading={loading} error={error}
selected={value ? options.find((option) => option.name === value) ?? { id: value, name: value } : undefined}
onChange={(option) => onChange(option?.name ?? '')} />
{geometryFeedback && <p role="status">{geometryFeedback}</p>}
<TerritoryGeometryPreview
territoryId={
@@ -334,65 +309,15 @@ export function TerritoryLevel({
<label htmlFor={id} className="font-medium">
{labels[index]}
</label>
<input
className={control}
aria-label={`Pesquisar ${labels[index]}`}
disabled={!enabled}
maxLength={100}
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(1);
}}
placeholder={`Pesquisar ${labels[index].toLowerCase()}`}
/>
<select
id={id}
aria-invalid={Boolean(fieldError)}
aria-describedby={fieldError ? `${id}-error` : undefined}
className={control}
disabled={!enabled || loading || !!error}
value={selected?.id ?? ''}
onChange={(e) =>
onChange(options.find((o) => o.id === e.target.value))
}
>
<option value="">
{enabled ? 'Seleccione' : 'Seleccione o nível anterior'}
</option>
{selected && !options.some((o) => o.id === selected.id) && (
<option value={selected.id}>
{selected.name}
{selected.deleted_at
? ' (arquivada)'
: ' (selecção actual)'}
</option>
)}
{options.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
{o.source_code ? ` (${o.source_code})` : ''}
</option>
))}
</select>
<TerritorySearchSelect id={id} label={labels[index]} className={control}
disabled={!enabled} search={search} onSearch={(query) => { setSearch(query); setPage(1); }}
options={options} selected={selected} onChange={onChange} loading={loading} error={fieldError || error}>
<div className="flex gap-3 p-2 text-sm">
{page > 1 && <button type="button" onClick={() => setPage(page - 1)}>Anterior</button>}
{more && <button type="button" onClick={() => setPage(page + 1)}>Mais opções</button>}
</div>
</TerritorySearchSelect>
{loading && <p role="status">A carregar…</p>}
{fieldError && (
<p id={`${id}-error`} role="alert">
{fieldError}
</p>
)}
<div className="flex gap-3 text-sm">
{page > 1 && (
<button type="button" onClick={() => setPage(page - 1)}>
Anterior
</button>
)}
{more && (
<button type="button" onClick={() => setPage(page + 1)}>
Mais opções
</button>
)}
</div>
</div>
);
}
@@ -1,4 +1,4 @@
import { Link } from '@inertiajs/react';
import { Link, usePage } from '@inertiajs/react';
import { useState } from 'react';
import { fiscalPreparationGuide } from '@/lib/fiscal-preparation-guide';
@@ -8,6 +8,8 @@ export function JourneyDocumentResources({
stages?: string[];
}) {
const [stage, setStage] = useState('');
const context=new URL(usePage().url,'https://portal.invalid').searchParams.get('context');
const preparationHref=context&&/^[a-f0-9-]{36}$/i.test(context)?`/investimentos/${context}/contexto/editar#preparation-questions`:'/investimentos';
function destination(path: string, type?: string) {
const query = new URLSearchParams();
if (stage) query.set('stage[0]', stage);
@@ -20,8 +22,24 @@ export function JourneyDocumentResources({
className="space-y-3 rounded-xl border border-emerald-200 bg-emerald-50 p-4"
>
<h2 className="font-semibold text-emerald-950">
Fontes e guias para a jornada
Preencher formulários e preparar documentos
</h2>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded border bg-white p-3"><h3 className="font-semibold">Pedido de investimento</h3>
<p>Preencha a preparação digital no dossier existente, reutilizando os dados já guardados.</p>
<Link className="underline" href={preparationHref}>Abrir o formulário no meu dossier</Link>
</div>
<div className="rounded border bg-white p-3"><h3 className="font-semibold">Terra e DUAT</h3>
<p>Prepare os dados do pedido com os campos do formulário existente.</p>
<Link className="underline" href="/terra-duat">Preencher ou continuar pedido de terra</Link>
</div>
<div className="rounded border bg-white p-3"><h3 className="font-semibold">Pedido de apoio do investidor</h3>
<p>Identifique-se e preencha o pedido de apoio. Este formulário não é um pedido de autorização de investimento.</p>
<Link className="underline" href="/formularios/investidor">Preencher pedido de apoio</Link>
</div>
</div>
<p>Preparação digital e exemplar oficial são apresentados separadamente. Um guia ou diploma não é um formulário preenchível.</p>
<details><summary className="cursor-pointer font-medium">Consultar exemplares, fontes e guias</summary>
<p>
Prepare as etapas em paralelo. A disponibilidade para consulta,
a situação jurídica e a utilização numa regra ou cálculo são
@@ -143,6 +161,7 @@ export function JourneyDocumentResources({
<Link className="underline" href="/documentos">
Explorar todo o acervo autorizado
</Link>
</details>
</section>
);
}
@@ -7,6 +7,9 @@ import { valueText } from '@/lib/value-text';
import OpportunityParcelGeometry from './opportunity-parcel-geometry';
import TerritoryGeometryPreview from './territory-geometry-preview';
import { control } from '@/pages/management/shared';
import OpportunityFillFlow from './opportunity-fill-flow';
import { opportunityFillGroups } from '@/lib/opportunity-fill-flow';
import { modalityLabels } from './opportunity-search-filters';
type Data = Record<string, unknown>;
type Schema = {
@@ -241,6 +244,11 @@ const labels: Record<string, string> = {
assessment_status: 'Estado da avaliação',
environmental_category: 'Categoria ambiental',
direct_jobs: 'Empregos diretos',
indirect_jobs: 'Empregos indirectos',
female_percentage: 'Participação de mulheres (%)',
youth_percentage: 'Participação de jovens (%)',
social_risks: 'Riscos sociais',
mitigation_plan: 'Plano de mitigação',
employment_type: 'Tipo de emprego',
geometry: 'Geometria da parcela',
start_date: 'Data de início',
@@ -621,6 +629,8 @@ export default function OpportunityCanonicalFields({
sectors,
disabled = false,
errors = {},
onOpenResources,
missingPaths,
}: {
value: Data;
onChange: (v: Data) => void;
@@ -631,6 +641,8 @@ export default function OpportunityCanonicalFields({
sectors: { id: number; name: string }[];
disabled?: boolean;
errors?: Record<string, string>;
onOpenResources?: (group: string) => void;
missingPaths?: string[];
}) {
const initiative = (value.initiative ?? {}) as Data;
const isInitiative = category === 'investment_project' && ['program', 'portfolio'].includes(valueText(initiative.kind ?? ''));
@@ -815,7 +827,7 @@ export default function OpportunityCanonicalFields({
: ['classified_other'];
return (
<fieldset disabled={disabled} className="space-y-4 rounded border p-4">
<legend>Contrato da oportunidade — {category}</legend>
<legend>Preparar oportunidade — {modalityLabels[category] ?? 'Modalidade por escolher'}</legend>
<p>
O rascunho pode ficar incompleto. A submissão verifica os campos
aplicáveis e os recursos guardados. Validação interna não
@@ -870,7 +882,7 @@ export default function OpportunityCanonicalFields({
</>}
</section>
)}
{[...new Set([...common, ...specialized])].map((key) => {
<OpportunityFillFlow groups={opportunityFillGroups([...new Set([...common, ...specialized])].filter((key) => !ignoredFields.includes(key)))} errors={errors} missingPaths={missingPaths} onOpenResources={onOpenResources} renderField={(key) => {
if (ignoredFields.includes(key)) return null;
if (key === 'relations' && options.classification) {
@@ -1167,7 +1179,7 @@ export default function OpportunityCanonicalFields({
onChange={(v) => set(key, v)}
/>
) : null;
})}
}} />
</fieldset>
);
}
@@ -2,7 +2,7 @@ import { Link } from '@inertiajs/react';
import { useState } from 'react';
import { button, card, input } from '@/pages/investment/shared';
type Item = {id:number;title:string;slug:string;summary:string|null;province:string|null;district:string|null;chains:string[];stages:string[];maturity_label:string|null};
type Item = {id:number;title:string;slug:string;summary:string|null;province:string|null;district:string|null;provinces?:string[];territorial_summary?:string;chains:string[];stages:string[];maturity_label:string|null};
export type CorridorGroup = {id:string;name:string;count:number;locations:string[];opportunities:Item[]};
type Props = {groups:CorridorGroup[];selected:number[];onSelect:(id:number,checked:boolean)=>void;onCompare:(ids:number[])=>void;onClear:()=>void};
@@ -10,10 +10,11 @@ function Group({group,selected,onSelect}: {group:CorridorGroup} & Pick<Props,'se
const [province,setProvince]=useState('');
const [district,setDistrict]=useState('');
const [chain,setChain]=useState('');
const provinces=[...new Set(group.opportunities.map(r=>r.province).filter((s):s is string=>!!s))].sort();
const districts=[...new Set(group.opportunities.filter(r=>!province||r.province===province).map(r=>r.district).filter((s):s is string=>!!s))].sort();
const provinceNames=(r:Item)=>r.provinces??(r.province?[r.province]:[]);
const provinces=[...new Set(group.opportunities.flatMap(provinceNames))].sort();
const districts=[...new Set(group.opportunities.filter(r=>!province||provinceNames(r).includes(province)).map(r=>r.district).filter((s):s is string=>!!s))].sort();
const chains=[...new Set(group.opportunities.flatMap(r=>r.chains))].sort();
const rows=group.opportunities.filter(r=>(!province||r.province===province)&&(!district||r.district===district)&&(!chain||r.chains.includes(chain)));
const rows=group.opportunities.filter(r=>(!province||provinceNames(r).includes(province))&&(!district||r.district===district)&&(!chain||r.chains.includes(chain)));
return <section className={card+' space-y-4'} aria-label={group.name}>
<header><h2 className="text-2xl font-bold">{group.name}</h2><p>{group.count} oportunidades publicadas</p>
<p>{group.locations.length ? 'Localizações identificadas: '+group.locations.join(' · ') : 'Abrangência territorial por indicar.'}</p></header>
@@ -25,7 +26,7 @@ function Group({group,selected,onSelect}: {group:CorridorGroup} & Pick<Props,'se
<p role="status">{rows.length} oportunidades com estes filtros</p>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{rows.map(r=><article key={r.id} className="space-y-3 rounded-lg border p-4">
<h3 className="text-lg font-semibold"><Link href={'/oportunidades/'+r.slug}>{r.title}</Link></h3>
<p>{[r.province,r.district].filter(Boolean).join(' / ')||'Localização por escolher'}</p>
<p>{r.territorial_summary||[r.province,r.district].filter(Boolean).join(' / ')||'Localização por escolher'}</p>
<p>Cadeia de valor: {r.chains.join(' · ')||'Por indicar'}</p>
{r.summary && !r.stages.includes(r.summary) && <p>{r.summary}</p>}
<p>Etapas e actividades: {r.stages.join(' · ')||'Por indicar'}</p><p>{r.maturity_label}</p>
@@ -0,0 +1,51 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { opportunityErrorGroup, opportunityMissingGroups, type FillGroup } from '@/lib/opportunity-fill-flow';
export default function OpportunityFillFlow({groups,errors,renderField,onOpenResources,missingPaths=[]}:{
groups:FillGroup[];errors:Record<string,string>;renderField:(field:string)=>ReactNode;onOpenResources?:(group:string)=>void;
missingPaths?:string[];
}) {
const [active,setActive]=useState(groups[0]?.id??'');
const groupIds=groups.map(group=>group.id).join(',');
useEffect(()=>{
const saved=window.location.hash.replace('#fill-','');
if(groupIds.split(',').includes(saved))setActive(saved);
},[groupIds]);
const heading=useRef<HTMLHeadingElement|null>(null);
const errorGroup=opportunityErrorGroup(groups,errors);
const missing=opportunityMissingGroups(groups,missingPaths);
const errorKey=JSON.stringify(errors);
useEffect(()=>{if(errorGroup)setActive(errorGroup);},[errorGroup,errorKey]);
const index=Math.max(0,groups.findIndex(group=>group.id===active));
const current=groups[index];
if(!current)return null;
const go=(id:string)=>{
setActive(id);
window.history.replaceState(window.history.state,'',`#fill-${id}`);
requestAnimationFrame(()=>heading.current?.focus());
};
return <div className="space-y-4">
<p>Pode escolher qualquer grupo, guardar incompleto e retomar. As informações já preenchidas mantêm-se ao avançar e regressar.</p>
<nav aria-label="Preencher a oportunidade" className="flex flex-wrap gap-2">
{groups.map(group=><a key={group.id} href={`#fill-${group.id}`} aria-current={current.id===group.id?'step':undefined}
className={`rounded border px-3 py-2 text-left ${current.id===group.id?'bg-primary text-primary-foreground':'bg-background'}`}
onClick={event=>{event.preventDefault();go(group.id);}}>{group.title}{opportunityErrorGroup([group],errors)?' — verificar':missing.some(item=>item.id===group.id)?' — dados em falta':''}</a>)}
</nav>
{missing.length>0&&<p>Pendências da última gravação: {missing.map(group=>group.title).join(' · ')}. Guarde para actualizar esta indicação.</p>}
<h2 ref={heading} tabIndex={-1} className="text-xl font-semibold">{current.title}</h2>
<p>{current.hint}</p>
{groups.map(group=><section key={group.id} id={`fill-${group.id}`} hidden={current.id!==group.id}
aria-label={group.title} className="space-y-4">
{group.fields.map(field=><div key={field}>{renderField(field)}</div>)}
{onOpenResources&&group.fields.some(field=>['locations','documents','images'].includes(field))&&
<button type="button" className="rounded border px-4 py-2 font-medium" onClick={()=>onOpenResources(group.id)}>Guardar e abrir documentos, imagens e localizações</button>}
</section>)}
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-3">
<span className="text-sm">Grupo {index+1} de {groups.length} · navegação livre</span>
<div className="flex gap-3">
{index>0&&<a href={`#fill-${groups[index-1].id}`} className="rounded border px-4 py-2" onClick={event=>{event.preventDefault();go(groups[index-1].id);}}>Anterior</a>}
{index<groups.length-1&&<a href={`#fill-${groups[index+1].id}`} className="rounded border px-4 py-2" onClick={event=>{event.preventDefault();go(groups[index+1].id);}}>Continuar: {groups[index+1].title}</a>}
</div>
</div>
</div>;
}
@@ -222,6 +222,11 @@ export default function OpportunitySearchFilters({
</div>
<fieldset className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<legend className="mb-2 font-semibold">Localização</legend>
<p className="text-sm sm:col-span-2 lg:col-span-4">
A província inclui as oportunidades dos seus distritos, postos administrativos e localidades.
Não é necessário escolher todos os níveis. O contexto provincial de um corredor é identificado nos resultados; não confirma uma implantação distrital.
{path.length > 1 && <button type="button" className="ml-2 underline" onClick={() => territory(path.slice(0, 1))}>Ver toda a província de {path[0].name}</button>}
</p>
{(
[
'province_id',
+10 -12
View File
@@ -31,6 +31,7 @@ import {
} from '@/components/ui/dropdown-menu';
import {
portalNavigation,
canShowLegacyManagement,
currentNavigationGroup,
currentNavigationItem,
} from '@/lib/portal-navigation';
@@ -44,6 +45,7 @@ export function PortalGroupedMenu() {
auth: {
user: { name?: string; email?: string } | null;
canManagePortal: boolean;
legacyManagementAccess?:boolean;
};
existingBusinessEnabled?: boolean;
businessReviewUrl?: string | null;
@@ -54,6 +56,8 @@ export function PortalGroupedMenu() {
profileAccess?: ProfileAccess | null;
opportunityManagement?: OpportunityManagementAccess | null;
scopedAdministration?: { label: string; href: string }[];
contentManagementHref?: string | null;
contentManagementAccess?: {opportunities?:{index:string;create?:string|null}};
}>();
const { t, language, requestCount } = useTempoUi();
const mobile = useIsMobile();
@@ -63,7 +67,7 @@ export function PortalGroupedMenu() {
? { id: navigation.active, ...navigation.view.tools }
: undefined,
authenticated: !!props.auth?.user,
manager: !!props.auth?.canManagePortal,
manager: canShowLegacyManagement(props.auth),
businesses: props.existingBusinessEnabled,
businessReviewUrl: props.businessReviewUrl,
education: props.educationEnabled,
@@ -72,17 +76,10 @@ export function PortalGroupedMenu() {
legacyContent: props.legacyContentEnabled,
opportunityManagement: props.opportunityManagement,
profileAccess: props.profileAccess,
contentManagementHref: props.contentManagementHref,
contentManagementAccess: props.contentManagementAccess,
scopedAdministration: props.scopedAdministration,
});
if (props.scopedAdministration?.length)
groups.push({
id: 'scoped-administration',
label: 'Administração',
children: props.scopedAdministration.map((item, index) => ({
id: `scoped-${index}`,
label: item.label,
href: item.href,
})),
});
const current = currentNavigationGroup(groups, url);
const currentItem = currentNavigationItem(groups, url);
const [loggingOut, setLoggingOut] = useState(false);
@@ -134,7 +131,7 @@ export function PortalGroupedMenu() {
);
const accountGroups = groups.filter(
(group) =>
!['home', 'discovery', 'realization', 'support'].includes(group.id),
!['home', 'discovery', 'realization', 'support','work'].includes(group.id),
);
const panelLabel = `${t('O meu painel')} — ${requestCount} ${t(requestCount === 1 ? 'pedido recebido' : 'pedidos recebidos')}`;
const institutionalCurrent = url.split('?')[0] === '/contacto';
@@ -200,6 +197,7 @@ export function PortalGroupedMenu() {
'discovery',
'realization',
'support',
'work',
].includes(group.id),
)
.map((group) => (
@@ -0,0 +1,17 @@
import {Link} from '@inertiajs/react';
import {LayoutGrid} from 'lucide-react';
import type {ReactNode} from 'react';
import type {PortalNavItem} from '@/lib/portal-navigation';
import {currentNavigationItem} from '@/lib/portal-navigation';
import {searchTerritoryHref} from '@/lib/search-territory';
import {SidebarGroup,SidebarGroupLabel,SidebarMenu,SidebarMenuButton,SidebarMenuItem} from '@/components/ui/sidebar';
/** Render every authorised destination; a group is never reduced to its first link. */
export function PortalSidebarNavigation({groups,url}:{groups:PortalNavItem[];url:string}){
const active=currentNavigationItem(groups,url);
const ordered=[...groups.filter(group=>group.id==='work'),...groups.filter(group=>group.id!=='work')];
const links=(items:PortalNavItem[],depth=0):ReactNode=>items.map(item=>item.children?
<li key={item.id} className="list-none"><p className="px-2 pt-3 pb-1 text-xs font-medium text-muted-foreground">{item.label}</p><ul className="ml-2 border-l pl-2">{links(item.children,depth+1)}</ul></li>:
item.href?<SidebarMenuItem key={item.id}><SidebarMenuButton asChild isActive={item.id===active} className="h-auto min-h-8 whitespace-normal py-2"><Link href={searchTerritoryHref(item.href,url)} aria-current={item.id===active?'page':undefined}>{depth===0&&<LayoutGrid aria-hidden="true"/>}<span className="min-w-0 flex-1" style={{whiteSpace:'normal',overflow:'visible',textOverflow:'clip',overflowWrap:'anywhere'}}>{item.label}</span></Link></SidebarMenuButton></SidebarMenuItem>:null);
return <nav aria-label="Navegação completa do portal">{ordered.map(group=><SidebarGroup key={group.id}><SidebarGroupLabel>{group.label}</SidebarGroupLabel><SidebarMenu>{links(group.children??[])}</SidebarMenu></SidebarGroup>)}</nav>;
}
@@ -73,6 +73,8 @@ export function PreparationSummary({
<h2 className="text-xl font-bold">
Preparar o pedido de investimento privado
</h2>
{caseId&&<p><Link className="inline-block rounded border px-4 py-2 font-semibold" href={`/investimentos/${caseId}/contexto/editar#preparation-questions`}>Preencher ou continuar o formulário</Link></p>}
<details><summary className="cursor-pointer font-medium">Consultar situação, documentos e fundamentos</summary>
{!data.enabled ? (
<p>
Use os dados deste dossier para identificar o pedido e os
@@ -219,10 +221,11 @@ export function PreparationSummary({
className="underline"
href={`/investimentos/${caseId}/contexto/editar#preparation-questions`}
>
Completar apenas os dados em falta ou editar declarações
Editar os dados do formulário
</Link>
</p>
)}
</details>
</section>
);
}
@@ -1,30 +1,34 @@
import { createOperationKey } from '@/lib/operation-key';
import { Field, control, button } from '@/pages/management/shared';
import {ProactiveSupplyBrowser} from './proactive-supply';
export type ProactiveEvidence = {id:string;category:string;purpose:'local'|'methodology';statement:string;interpretation:string;source:string;locator:string;period:string;scope:string;coverage:string;verified_on:string;access:'internal'|'public';document_id:string};
export type ProactiveEvidence = {id:string;observation_id?:string;category:string;purpose:'local'|'methodology';statement:string;interpretation:string;source:string;locator:string;period:string;scope:string;coverage:string;verified_on:string;access:'internal'|'public';document_id:string};
export type ProactiveAnalysis = {
version:1;kind:'exists'|'needs'|'ideas';evidence:ProactiveEvidence[];
need:{state:'hypothesis'|'confirmed';problem:string;observed_supply:string;observed_demand:string;type:string;clients:string;evidence_ids:string[];limitations:string;missing:string;confirmation:string};
idea:{solution:string;activity:string;chain_id:string;stage:string;coverage:string;mode:string;resources:string;assumptions:string;conditions:string;pending:string;next_action:string};
relations:{id:string;kind:string;target_id:string;relation:string;reference:string;evidence_ids:string[]}[];
supply_comparisons?:{observation_id:string;relationship:'competition'|'complementarity'|'partner'|'uncertain';rationale:string;limitations:string}[];
};
export type ProactiveOptions = {documents?:{id:string;title:string}[];groups:Record<string,string>;references:Record<string,{id:string;name:string}[]>};
export type ProactivePublic = {version?:number;group:string;state:string;need:string;proposal:string;basis:string;limitations:string;next_action:string;evidence_ids:string[];chain_id?:string|null;stage?:string|null;notice?:string;resources?:string;assumptions?:string;conditions?:string;relation_ids?:string[];relations?:{kind:string;target_id:string;label:string;relationship:string;basis:string}[];evidence?:Partial<ProactiveEvidence>[]};
export type ProactivePublic = {version?:number;group:string;state:string;need:string;proposal:string;basis:string;limitations:string;next_action:string;evidence_ids:string[];chain_id?:string|null;stage?:string|null;notice?:string;resources?:string;assumptions?:string;conditions?:string;relation_ids?:string[];relations?:{kind:string;target_id:string;label:string;relationship:string;basis:string}[];evidence?:Partial<ProactiveEvidence>[];internal_comparisons?:{relationship:string;rationale:string;limitations:string;statement?:string;source?:string;locator?:string;period?:string}[]};
export const proactiveNotice = 'Ausência de registo não comprova ausência de oferta. Proximidade não comprova contrato, participação, energia acessível, terra disponível ou benefício fiscal. Necessidade confirmada não comprova viabilidade comercial; hipótese de fornecimento não é encomenda.';
export const newProactive = ():ProactiveAnalysis => ({version:1,kind:'ideas',evidence:[],need:{state:'hypothesis',problem:'',observed_supply:'',observed_demand:'',type:'',clients:'',evidence_ids:[],limitations:'',missing:'',confirmation:''},idea:{solution:'',activity:'',chain_id:'',stage:'',coverage:'',mode:'',resources:'',assumptions:'',conditions:'',pending:'',next_action:''},relations:[]});
export const newPublicProactive = ():ProactivePublic => ({group:'ideas',state:'hypothesis',need:'',proposal:'',basis:'',limitations:'',next_action:'',evidence_ids:[],chain_id:'',stage:'',resources:'',assumptions:'',conditions:'',relation_ids:[]});
export function ProactiveInvestmentEditor({value,onChange,options,history=[]}:{value?:ProactiveAnalysis;onChange:(value:ProactiveAnalysis)=>void;options?:ProactiveOptions;history?:{version:number;at:string;state:string;problem:string;confirmation:string}[]}){
export function ProactiveInvestmentEditor({value,onChange,options,history=[],territoryId,recordId}:{value?:ProactiveAnalysis;onChange:(value:ProactiveAnalysis)=>void;options?:ProactiveOptions;history?:{version:number;at:string;state:string;problem:string;confirmation:string}[];territoryId?:string;recordId?:number}){
if(!value)return <section className="rounded border p-4"><h2 className="font-semibold">Análise territorial e preparação de ideias</h2><p>Comece pelo território e sector existentes, mesmo sem oportunidade publicada, promotor, montante ou coordenadas. O rascunho e a publicação são independentes.</p><button type="button" className={button} onClick={()=>onChange(newProactive())}>Preparar análise e ideia</button></section>;
const patch=(p:Partial<ProactiveAnalysis>)=>onChange({...value,...p});
const removeEvidence=(id:string)=>{const proof=value.evidence.find(e=>e.id===id);patch({evidence:value.evidence.filter(e=>e.id!==id),need:{...value.need,evidence_ids:value.need.evidence_ids.filter(x=>x!==id)},relations:value.relations.map(r=>({...r,evidence_ids:r.evidence_ids.filter(x=>x!==id)})),...(proof?.observation_id?{supply_comparisons:(value.supply_comparisons??[]).filter(c=>c.observation_id!==proof.observation_id)}:{})});};
const text=(label:string,v:string,change:(v:string)=>void)=><Field label={label}><textarea className={control} aria-label={label} value={v??''} onChange={e=>change(e.target.value)}/></Field>;
const select=(label:string,v:string,choices:Record<string,string>,change:(v:string)=>void)=><Field label={label}><select className={control} aria-label={label} value={v??''} onChange={e=>change(e.target.value)}><option value="">Por determinar</option>{Object.entries(choices).map(([key,label])=><option key={key} value={key}>{label}</option>)}</select></Field>;
const evidenceChoice=(selected:string[],change:(v:string[])=>void)=><fieldset><legend>Evidências que fundamentam esta entrada</legend>{value.evidence.map((e,i)=><label key={e.id} className="block"><input type="checkbox" checked={selected.includes(e.id)} onChange={event=>change(event.target.checked?[...selected,e.id]:selected.filter(id=>id!==e.id))}/> Evidência {i+1}: {e.statement||'Por descrever'} ({e.access==='public'?'Pública autorizada':'Interna'})</label>)}</fieldset>;
return <section className="space-y-4 rounded border p-4" aria-label="Análise territorial e ideia">
<h2 className="text-xl font-semibold">Território e sector → evidências → necessidade → ideia</h2><details><summary>Como fundamentar esta análise</summary><p>Inventarie actividades, recursos, infra-estruturas e serviços de apoio. Confronte oferta observada com procura documentada e identifique os clientes ou beneficiários. Registe o período, cobertura e limitações de cada observação. Use referências internacionais para orientar as perguntas; a confirmação do caso depende de evidência local.</p></details><p>Reutiliza o sector e a localização principal escolhidos nesta ficha. Guarde pelo botão de rascunho existente. Nenhum montante, promotor ou ponto exacto é exigido.</p><p>{proactiveNotice}</p>
{select('Tipo de conteúdo em preparação',value.kind,{exists:'O que existe',needs:'Necessidade identificada',ideas:'Ideia de investimento'},v=>patch({kind:v as ProactiveAnalysis['kind']}))}
<ProactiveSupplyBrowser value={value} onChange={onChange} territoryId={territoryId} recordId={recordId}/>
<fieldset className="space-y-3 rounded border p-3"><legend>Evidências e proveniência</legend><p>Registe factos e interpretações em campos separados. Listas de empresas, hotéis, restaurantes, contactos e documentos internos permanecem internos; não são copiados para a divulgação.</p>
{value.evidence.map((e,i)=>{const change=(p:Partial<ProactiveEvidence>)=>patch({evidence:value.evidence.map(x=>x.id===e.id?{...x,...p}:x)});return <fieldset key={e.id} className="space-y-2 rounded border p-3"><legend>Evidência {i+1}</legend>
{value.evidence.map((e,i)=>{const change=(p:Partial<ProactiveEvidence>)=>patch({evidence:value.evidence.map(x=>x.id===e.id?{...x,...p}:x)});if(e.observation_id)return <fieldset key={e.id} className="space-y-2 rounded border p-3"><legend>Evidência interna da oferta {i+1}</legend><p>{e.statement}</p><p>Fonte: {e.source} · {e.locator}</p><p>Período: {e.period} · Abrangência: {e.scope}</p><p>{e.coverage}</p><p>A proveniência é recuperada do original. A data indica consulta documental, sem confirmação de actividade actual.</p>{text(`Interpretação ou hipótese ${i+1}`,e.interpretation,v=>change({interpretation:v}))}<button type="button" onClick={()=>removeEvidence(e.id)}>Retirar evidência {i+1} e a sua comparação deste rascunho</button></fieldset>;return <fieldset key={e.id} className="space-y-2 rounded border p-3"><legend>Evidência {i+1}</legend>
{select(`Finalidade da evidência ${i+1}`,e.purpose??'',{local:'Evidência local do caso',methodology:'Referência metodológica — não prova procura local'},v=>change({purpose:v as 'local'|'methodology'}))}
{select(`Categoria da evidência ${i+1}`,e.category,{activity:'Actividade',resource:'Recurso',infrastructure:'Infra-estrutura',supply:'Oferta',demand:'Procura'},v=>change({category:v}))}
{([['statement','Facto documentado'],['interpretation','Interpretação ou hipótese'],['source','Fonte e exemplar'],['locator','Localizador ou trecho'],['period','Período da evidência'],['scope','Abrangência'],['coverage','Cobertura e limitações']]as const).map(([key,label])=><div key={key}>{text(`${label} ${i+1}`,e[key],v=>change({[key]:v}))}</div>)}
@@ -64,7 +68,7 @@ export function ProactiveInvestmentEditor({value,onChange,options,history=[]}:{v
export function ProactiveSummary({value}:{value?:ProactivePublic|null}){
if(!value)return null;
return <section className="space-y-2 rounded border p-4" aria-label="Origem proactiva do investimento"><h2 className="text-xl font-semibold">{value.group==='ideas'?'Ideia em preparação':value.group==='needs'?'Necessidade identificada':'O que existe'}</h2><p>{value.group==='exists'?'Registo documental; consultar abrangência, cobertura e limitações das fontes.':value.state==='confirmed'?'Necessidade confirmada no âmbito documentado':'Hipótese analítica; conclusão económica por confirmar'}</p>{([['need','Necessidade'],['proposal','Proposta'],['basis','Fundamento'],['limitations','Limitações'],['next_action','Próximo passo'],['stage','Etapa da cadeia'],['resources','Recursos'],['assumptions','Pressupostos'],['conditions','Condições']]as const).map(([key,label])=>value[key]?<p key={key}><strong>{label}:</strong> {value[key]}</p>:null)}{value.relations?.map(r=><p key={r.kind+r.target_id}>Relação seleccionada: {r.label} · {r.relationship==='documented'?'Documentada':r.relationship==='proximity'?'Proximidade, sem pertença':'Hipótese de relação'}. Fundamento: {r.basis}. Não confirma contrato, acesso ou participação.</p>)}{value.evidence?.map((e,i)=><p key={e.id??i}>Evidência pública: {e.statement}; fonte: {e.source}; localizador: {e.locator}; período: {e.period}; abrangência: {e.scope}; cobertura: {e.coverage}; verificação: {e.verified_on}.</p>)}<p>{value.notice??proactiveNotice}</p><p>Sem disponibilidade de participação, viabilidade comercial ou benefício fiscal presumidos.</p></section>;
return <section className="space-y-2 rounded border p-4" aria-label="Origem proactiva do investimento"><h2 className="text-xl font-semibold">{value.group==='ideas'?'Ideia em preparação':value.group==='needs'?'Necessidade identificada':'O que existe'}</h2><p>{value.group==='exists'?'Registo documental; consultar abrangência, cobertura e limitações das fontes.':value.state==='confirmed'?'Necessidade confirmada no âmbito documentado':'Hipótese analítica; conclusão económica por confirmar'}</p>{([['need','Necessidade'],['proposal','Proposta'],['basis','Fundamento'],['limitations','Limitações'],['next_action','Próximo passo'],['stage','Etapa da cadeia'],['resources','Recursos'],['assumptions','Pressupostos'],['conditions','Condições']]as const).map(([key,label])=>value[key]?<p key={key}><strong>{label}:</strong> {value[key]}</p>:null)}{value.internal_comparisons?.map((c,i)=><div key={i} className="rounded border p-3"><strong>Comparação interna da oferta {i+1} · {({competition:'Possível concorrência',complementarity:'Possível complementaridade',partner:'Possível parceiro, sem compromisso',uncertain:'Relação por determinar'} as Record<string,string>)[c.relationship]}</strong><p>{c.statement}</p><p>Fonte: {c.source} · {c.locator} · {c.period}</p><p>{c.rationale}</p><p>Incertezas: {c.limitations}</p></div>)}{value.relations?.map(r=><p key={r.kind+r.target_id}>Relação seleccionada: {r.label} · {r.relationship==='documented'?'Documentada':r.relationship==='proximity'?'Proximidade, sem pertença':'Hipótese de relação'}. Fundamento: {r.basis}. Não confirma contrato, acesso ou participação.</p>)}{value.evidence?.map((e,i)=><p key={e.id??i}>Evidência pública: {e.statement}; fonte: {e.source}; localizador: {e.locator}; período: {e.period}; abrangência: {e.scope}; cobertura: {e.coverage}; verificação: {e.verified_on}.</p>)}<p>{value.notice??proactiveNotice}</p><p>Sem disponibilidade de participação, viabilidade comercial ou benefício fiscal presumidos.</p></section>;
}
export function ProactivePublicationFields({value,onChange,evidence,relations=[],options,reviewed,onReview}:{value:ProactivePublic;onChange:(v:ProactivePublic)=>void;evidence:ProactiveEvidence[];relations?:{id:string;label:string;relationship:string}[];options?:ProactiveOptions;reviewed:boolean;onReview:(v:boolean)=>void}){
@@ -86,5 +90,5 @@ export function proactiveView(raw:unknown):ProactivePublic|null{
if(typeof p.group==='string'&&typeof p.need==='string')return p as ProactivePublic;
const a=raw as ProactiveAnalysis;
if(!a.need||typeof a.need.problem!=='string'||!a.idea)return null;
return {version:1,group:a.kind,state:a.need.state,need:a.kind==='exists'?'':a.need.problem,proposal:a.kind==='ideas'?a.idea.solution:'',basis:a.need.confirmation||'Fundamento por completar',limitations:[a.need.limitations,a.need.missing,a.idea.assumptions,a.idea.conditions,a.idea.pending].filter(Boolean).join('; '),next_action:a.idea.next_action,evidence_ids:a.need.evidence_ids,chain_id:a.idea.chain_id,stage:a.kind==='ideas'?a.idea.stage:'',notice:'Origem privada autorizada deste dossier. '+proactiveNotice};
return {version:1,group:a.kind,state:a.need.state,need:a.kind==='exists'?'':a.need.problem,proposal:a.kind==='ideas'?a.idea.solution:'',basis:a.need.confirmation||'Fundamento por completar',limitations:[a.need.limitations,a.need.missing,a.idea.assumptions,a.idea.conditions,a.idea.pending].filter(Boolean).join('; '),next_action:a.idea.next_action,evidence_ids:a.need.evidence_ids,chain_id:a.idea.chain_id,stage:a.kind==='ideas'?a.idea.stage:'',notice:'Origem privada autorizada deste dossier. '+proactiveNotice,internal_comparisons:(a.supply_comparisons??[]).map(c=>{const e=a.evidence.find(e=>e.observation_id===c.observation_id);return {...c,statement:e?.statement,source:e?.source,locator:e?.locator,period:e?.period};})};
}
@@ -0,0 +1,47 @@
import {useEffect,useState} from 'react';
import {button,control,Field} from '@/pages/management/shared';
import {createOperationKey} from '@/lib/operation-key';
import type {ProactiveAnalysis} from './proactive-investment';
type Observation={id:string;name:string;province:string;district:string;territory_id:string;cae:string;activity:string;sector:string;source_date:string;sheet:string;row_number:number;source_hash:string;source_name:string;source_period:string;identity_conflict:boolean};
type Result={installed:boolean;rows:Observation[];total:number|null;duplicate_sightings?:number;page?:number;per_page?:number;sectors?:string[];territory_coverage?:{unmapped:number;province_only:number};limitations:string;status:string};
export function ProactiveSupplyBrowser({value,onChange,territoryId,recordId}:{value:ProactiveAnalysis;onChange:(v:ProactiveAnalysis)=>void;territoryId?:string;recordId?:number}){
const [activity,setActivity]=useState('');const [sector,setSector]=useState('');
const [result,setResult]=useState<Result|null>(null);const [busy,setBusy]=useState(false);const [error,setError]=useState('');
const [query,setQuery]=useState<{territory:string;activity:string;sector:string;page:number}|null>(null);
useEffect(()=>{setResult(null);setQuery(null);setSector('');setError('');setBusy(false);},[territoryId]);
useEffect(()=>{
if(!query)return;const controller=new AbortController();setBusy(true);setError('');
const p=new URLSearchParams({territory_id:query.territory,activity:query.activity,sector:query.sector,page:String(query.page)});if(recordId)p.set('record_id',String(recordId));
fetch('/minhas-oportunidades/oferta-interna?'+p,{headers:{Accept:'application/json'},credentials:'same-origin',signal:controller.signal})
.then(async r=>{if(!r.ok)throw new Error(r.status===403?'Consulta interna fora do âmbito de acesso desta sessão.':'Não foi possível consultar a base interna. A oferta permanece por determinar.');return r.json() as Promise<Result>;})
.then(setResult).catch(e=>{if(e.name!=='AbortError'){setResult(null);setError(e.message);}}).finally(()=>{if(!controller.signal.aborted)setBusy(false);});
return ()=>controller.abort();
},[query,recordId]);
const selected=value.supply_comparisons??[];
const compare=(row:Observation)=>{
if(selected.some(c=>c.observation_id===row.id))return;
const proof=value.evidence.find(e=>e.observation_id===row.id);
onChange({...value,supply_comparisons:[...selected,{observation_id:row.id,relationship:'uncertain',rationale:'',limitations:'Actividade actual, capacidade, procura e disponibilidade para parceria por confirmar.'}],evidence:proof?value.evidence:[...value.evidence,{
id:createOperationKey(),observation_id:row.id,category:'supply',purpose:'local',access:'internal',document_id:'',
statement:`Registo administrativo: ${row.name}; CAE ${row.cae}; ${row.activity||'actividade por classificar'}; ${row.district}, ${row.province}. Não confirma actividade actual.`,
interpretation:'',source:`${row.source_name}; SHA256 ${row.source_hash}`,locator:`${row.sheet}!linha ${row.row_number}`,
period:`${row.source_date}; ${row.source_period}`,scope:`${row.district}, ${row.province}`,
coverage:result?.limitations??'Cobertura incompleta; situação actual por confirmar.',verified_on:new Date().toISOString().slice(0,10),
}]});
};
return <fieldset className="space-y-3 rounded border p-3"><legend>Consultar oferta existente — base interna</legend>
<p>Consulta a localização escolhida nesta ficha e os seus territórios subordinados. Os filtros por sector e actividade CAE não limitam a análise ao sector da ideia: podem existir concorrentes, complementos e parceiros noutros sectores. Nenhuma empresa é publicada.</p>
<Field label="Sector da oferta (classificação CAE)"><select className={control} aria-label="Sector da oferta (classificação CAE)" value={sector} onChange={e=>setSector(e.target.value)}><option value="">Todos os sectores registados</option>{result?.sectors?.map(s=><option key={s}>{s}</option>)}</select></Field>
<Field label="Actividade ou código CAE da oferta"><input className={control} aria-label="Actividade ou código CAE da oferta" value={activity} onChange={e=>setActivity(e.target.value)}/></Field>
<button type="button" className={button} disabled={!territoryId||busy} onClick={()=>setQuery({territory:territoryId!,activity,sector,page:1})}>{busy?'A consultar…':'Consultar oferta da localização'}</button>
{!territoryId&&<p>Escolha a localização para consultar. Sem localização, a oferta permanece por determinar.</p>}
{error&&<p role="alert">{error}</p>}
{result&&<><p role="status">{result.status}</p><p>{result.limitations}</p>{result.territory_coverage&&<p>Cobertura territorial da base nacional: {result.territory_coverage.unmapped} observações sem correspondência territorial única; {result.territory_coverage.province_only} apenas com província. Estas últimas não comprovam oferta num distrito específico.</p>}{result.total!==null&&<p>{result.total} observações distintas; {result.duplicate_sightings??0} repetições dos mesmos campos administrativos conservadas na origem. Não é uma contagem de empresas únicas.</p>}
{result.rows.map(row=><article key={row.id} className="rounded border p-3"><h3 className="font-semibold">{row.name}</h3><p>{row.district}, {row.province} · {row.sector||'Sector por classificar'} · CAE {row.cae||'não indicado'} · {row.activity||'Actividade por classificar'}</p><p>Fonte: {row.source_name} · {row.sheet}, linha {row.row_number} · {row.source_date} · {row.source_period}</p><details><summary>Rastreabilidade do exemplar</summary><p>SHA256: {row.source_hash}</p><p>Observação: {row.id}</p></details>{row.identity_conflict&&<p>Identificador partilhado por denominações divergentes; identidade por conferir. Os registos não foram fundidos.</p>}<button type="button" className={button} disabled={busy||selected.some(c=>c.observation_id===row.id)||selected.length>=40||value.evidence.length>=60} onClick={()=>compare(row)}>Comparar com a ideia</button></article>)}
{query&&<div className="flex gap-3"><button type="button" disabled={busy||query.page<=1} onClick={()=>setQuery({...query,page:query.page-1})}>Página anterior</button><span>Página {result.page??1}</span><button type="button" disabled={busy||query.page*25>=(result.total??0)} onClick={()=>setQuery({...query,page:query.page+1})}>Página seguinte</button></div>}
</>}
{selected.map((c,i)=>{const proof=value.evidence.find(e=>e.observation_id===c.observation_id);const patch=(p:Partial<typeof c>)=>onChange({...value,supply_comparisons:selected.map(x=>x.observation_id===c.observation_id?{...x,...p}:x)});return <fieldset className="space-y-2 rounded border p-3" key={c.observation_id}><legend>Comparação {i+1}</legend><p>{proof?.statement??'Evidência retirada: volte a associar a observação antes de guardar.'}</p><p>{proof?.source} · {proof?.locator}</p><p>Ideia em preparação: {value.idea.solution||'Descreva a ideia no campo existente abaixo.'}</p><Field label={`Relação com a ideia ${i+1}`}><select className={control} value={c.relationship} onChange={e=>patch({relationship:e.target.value as typeof c.relationship})}><option value="uncertain">Relação por determinar</option><option value="competition">Possível concorrência</option><option value="complementarity">Possível complementaridade</option><option value="partner">Possível parceiro — sem compromisso</option></select></Field><Field label={`Fundamento da comparação ${i+1}`}><textarea className={control} value={c.rationale} onChange={e=>patch({rationale:e.target.value})}/></Field><Field label={`Incertezas da comparação ${i+1}`}><textarea className={control} value={c.limitations} onChange={e=>patch({limitations:e.target.value})}/></Field><button type="button" onClick={()=>onChange({...value,supply_comparisons:selected.filter(x=>x.observation_id!==c.observation_id)})}>Retirar comparação {i+1}</button></fieldset>;})}
{!!selected.length&&<p>Use estas comparações para fundamentar a necessidade abaixo e seleccione as evidências correspondentes. A fonte administrativa não confirma procura nem insuficiência de oferta. Guarde no rascunho existente; a continuidade para o dossier conserva o percurso já entregue.</p>}
</fieldset>;
}
+12 -2
View File
@@ -28,7 +28,7 @@ export function profileLinks(access: ProfileAccess | null | undefined) {
...(access.administration
? [
{
title: 'Instalação dos perfis 040000',
title: 'Configuração dos perfis',
href: '/management/profile-install',
},
]
@@ -41,16 +41,26 @@ export default function ProfileNavigation() {
profileAccess?: ProfileAccess | null;
}>().props;
if (!profileAccess) return null;
const links = profileLinks(profileAccess);
const workHrefs = [profileAccess.mine, profileAccess.create, profileAccess.review].filter(Boolean);
const work = links.filter((link) => workHrefs.includes(link.href));
const access = links.filter((link) => !workHrefs.includes(link.href));
return (
<nav
aria-label="Perfil e oportunidades"
className="flex flex-wrap gap-4 rounded border p-4"
>
{profileLinks(profileAccess).map((link) => (
{work.map((link) => (
<Link key={link.href} href={link.href} className="underline">
{link.title}
</Link>
))}
{access.length > 0 && <details className="basis-full">
<summary className="cursor-pointer font-medium">O meu acesso e administração</summary>
<div className="mt-3 flex flex-wrap gap-4">{access.map((link) =>
<Link key={link.href} href={link.href} className="underline">{link.title}</Link>
)}</div>
</details>}
</nav>
);
}
@@ -0,0 +1,33 @@
import { useState } from 'react';
export default function ProfileProofPicker({documents,value,onChange,disabled=false}:{
documents:{id:string;file_name:string}[];value:string[];onChange:(ids:string[])=>void;disabled?:boolean;
}) {
const [query,setQuery]=useState('');
const [showAll,setShowAll]=useState(false);
const [limit,setLimit]=useState(10);
const normalize=(text:string)=>text.normalize('NFD').replace(/\p{Diacritic}/gu,'').toLocaleLowerCase();
const matches=documents.filter(document=>normalize(document.file_name).includes(normalize(query)));
const toggle=(id:string)=>onChange(value.includes(id)?value.filter(item=>item!==id):[...value,id]);
return <fieldset disabled={disabled} className="space-y-3 rounded border p-4">
<legend>Escolher comprovativos</legend>
<p className="text-sm">Escolha os seus documentos que sustentam este pedido. A lista contém ficheiros próprios com análise antimalware concluída; não confirma a sua adequação como comprovativo.</p>
<p role="status">{value.length} comprovativos escolhidos</p>
{value.map(id=><div key={id} className="flex items-start justify-between gap-3 rounded border p-2">
<span className="min-w-0 break-words">{documents.find(document=>document.id===id)?.file_name??'Ficheiro seleccionado indisponível nesta consulta'}</span>
<button type="button" className="underline" onClick={()=>toggle(id)}>Retirar</button>
</div>)}
<label className="block">Pesquisar nos meus ficheiros
<input type="search" className="mt-1 w-full rounded border p-2" value={query} onChange={event=>{setQuery(event.target.value);setLimit(10);}} />
</label>
<button type="button" className="underline" onClick={()=>setShowAll(!showAll)}>{showAll?'Fechar lista':'Ver ficheiros disponíveis'}</button>
{(query||showAll)&&<div className="space-y-2">
<p role="status">{matches.length} ficheiros encontrados · a mostrar até {Math.min(limit,matches.length)}</p>
{matches.slice(0,limit).map(document=><label key={document.id} className="flex gap-2 rounded border p-2">
<input type="checkbox" checked={value.includes(document.id)} onChange={()=>toggle(document.id)} />
<span className="min-w-0 break-words">{document.file_name}</span>
</label>)}
{matches.length>limit&&<button type="button" className="underline" onClick={()=>setLimit(limit+10)}>Mostrar mais ficheiros</button>}
</div>}
</fieldset>;
}
+1 -1
View File
@@ -50,7 +50,7 @@ function count(props: Record<string, unknown>): number | null {
}
export default function SearchFeedback() {
const page = usePage();
const handledLocally = page.component === 'investment/catalogue';
const handledLocally = ['investment/catalogue', 'investment/zones'].includes(page.component);
const [state, setState] = useState<'idle' | 'loading' | 'error'>('idle');
const latest = useRef<{
url: string;
+2 -1
View File
@@ -19,6 +19,7 @@ export type SectorEntryData = {
}[];
start_href: string | null;
register_href: string | null;
register_label?: string;
start_notice: string | null;
register_notice: string | null;
specialization_href: string | null;
@@ -58,7 +59,7 @@ export default function SectorEntry({
className="rounded border px-4 py-3"
href={data.register_href}
>
Cadastrar oportunidade — escolher modalidade
{data.register_label ?? 'Cadastrar oportunidade — escolher modalidade'}
</Link>
) : (
<p role="status">{data.register_notice}</p>
@@ -0,0 +1,66 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
type Choice = { id: string; name: string; source_code?: string | null };
/** One search field owns the list and commits only an explicitly chosen option. */
export default function TerritorySearchSelect<T extends Choice>({
id, label, search, onSearch, options, selected, onChange, disabled = false,
loading = false, error, required = false, className, placeholder, children,
}: {
id: string; label: string; search: string; onSearch: (query: string) => void;
options: T[]; selected?: T; onChange: (option?: T) => void;
disabled?: boolean; loading?: boolean; error?: string; required?: boolean;
className?: string; placeholder?: string; children?: ReactNode;
}) {
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const activeRef = useRef<HTMLButtonElement | null>(null);
const expanded = open && !disabled;
const listId = `${id}-options`;
useEffect(() => { if (expanded) activeRef.current?.scrollIntoView({ block: 'nearest' }); }, [active, expanded]);
const close = () => { setOpen(false); setActive(-1); onSearch(''); };
const choose = (option?: T) => { onChange(option); close(); };
return <div className="relative" onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) close();
}}>
<input id={id} className={className} role="combobox" aria-label={`Pesquisar ${label}`}
aria-autocomplete="list" aria-expanded={expanded} aria-controls={expanded ? listId : undefined}
aria-activedescendant={expanded && !loading && options[active] ? `${listId}-${active}` : undefined}
aria-invalid={Boolean(error)} aria-describedby={error ? `${id}-error` : undefined}
aria-required={required || undefined} required={required} autoComplete="off" maxLength={100}
disabled={disabled} value={expanded ? search : selected?.name ?? ''}
placeholder={disabled ? 'Seleccione o nível anterior' : placeholder ?? `Pesquisar ${label.toLowerCase()}`}
onFocus={() => { setOpen(true); setActive(-1); onSearch(''); }}
onClick={() => setOpen(true)}
onChange={(event) => { setOpen(true); setActive(-1); onSearch(event.target.value); }}
onKeyDown={(event) => {
if (event.key === 'Escape') { event.preventDefault(); close(); }
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault(); setOpen(true);
setActive((current) => !options.length ? -1 : event.key === 'ArrowDown'
? Math.min(current + 1, options.length - 1) : current < 0 ? options.length - 1 : Math.max(0, current - 1));
}
if (event.key === 'Enter' && expanded) {
event.preventDefault();
if (!loading && options[active < 0 ? 0 : active]) choose(options[active < 0 ? 0 : active]);
}
}} />
{selected && !disabled && <button type="button" className="mt-1 text-sm underline"
aria-label={`Limpar ${label}`} onClick={() => choose()}>Limpar</button>}
{error && <p id={`${id}-error`} role="alert">{error}</p>}
{expanded && <div className="absolute z-50 mt-1 max-h-72 w-full overflow-y-auto rounded border bg-background p-1 text-foreground shadow-lg">
<div id={listId} role="listbox" aria-label={label} aria-busy={loading}>
{loading && <p role="status" className="p-2">A carregar…</p>}
{!loading && options.length === 0 && <p className="p-2">{error ? 'Não foi possível carregar as opções.' : 'Sem resultados para esta pesquisa.'}</p>}
{options.map((option, index) => <button key={option.id} id={`${listId}-${index}`} type="button"
ref={index === active ? activeRef : undefined} role="option" tabIndex={-1}
aria-selected={option.id === selected?.id} disabled={loading}
className={`block w-full rounded p-2 text-left hover:bg-accent ${index === active ? 'bg-accent' : ''}`}
onMouseDown={(event) => event.preventDefault()} onClick={() => choose(option)}>
{option.name}{option.source_code ? ` (${option.source_code})` : ''}
</button>)}
</div>
{children}
</div>}
</div>;
}
+107
View File
@@ -0,0 +1,107 @@
export const forceLabels = {
rivalry: 'Rivalidade entre concorrentes',
entrants: 'Ameaça de novos entrantes',
substitutes: 'Ameaça de produtos ou serviços substitutos',
suppliers: 'Poder negocial dos fornecedores',
buyers: 'Poder negocial dos clientes',
} as const;
export type Force = keyof typeof forceLabels;
export type Assessment = {
pressure: '' | 'low' | 'medium' | 'high';
evidence: string;
source: string;
period: string;
rationale: string;
limitations: string;
opportunity: string;
};
export type CompetitivenessDraft = {
version: 1;
clusterId: string;
sector: string;
region: string;
forces: Record<Force, Assessment>;
};
export const forces = Object.keys(forceLabels) as Force[];
const fields = [
'evidence',
'source',
'period',
'rationale',
'limitations',
'opportunity',
] as const;
export function newCompetitivenessDraft(
clusterId: string,
sector: string,
): CompetitivenessDraft {
return {
version: 1,
clusterId,
sector,
region: '',
forces: Object.fromEntries(
forces.map((force) => [
force,
{
pressure: '',
evidence: '',
source: '',
period: '',
rationale: '',
limitations: '',
opportunity: '',
},
]),
) as Record<Force, Assessment>,
};
}
// A pressure is an author's qualitative assessment, never inferred from missing data.
export function assessmentReady(row: Assessment): boolean {
return (
['low', 'medium', 'high'].includes(row.pressure) &&
fields.every((field) => row[field].trim().length > 0)
);
}
export function diagnosticReady(draft: CompetitivenessDraft): boolean {
return (
Boolean(draft.sector.trim() && draft.region.trim()) &&
forces.every((force) => assessmentReady(draft.forces[force]))
);
}
export function readCompetitivenessDraft(
raw: string,
clusterId: string,
): CompetitivenessDraft {
if (raw.length > 250_000)
throw Error('O ficheiro excede o limite de 250 KB.');
const input = JSON.parse(raw);
if (!input || input.version !== 1 || input.clusterId !== clusterId) {
throw Error(
'O ficheiro não corresponde a este cluster ou tem uma versão incompatível.',
);
}
const text = (value: unknown, max: number): string => {
if (typeof value !== 'string' || value.length > max)
throw Error('O ficheiro contém campos inválidos.');
return value;
};
const draft = newCompetitivenessDraft(clusterId, text(input.sector, 200));
draft.region = text(input.region, 1000);
for (const force of forces) {
const row = input.forces?.[force];
if (!row || !['', 'low', 'medium', 'high'].includes(row.pressure)) {
throw Error('O ficheiro não contém as cinco forças válidas.');
}
draft.forces[force].pressure = row.pressure;
for (const field of fields)
draft.forces[force][field] = text(row[field], 5000);
}
return draft;
}
+8
View File
@@ -0,0 +1,8 @@
/** Presentation only. Original taxonomy identifiers remain the form/query values. */
const names:Record<string,string>={
agronegocio:'Agronegócio','bens-de-consumo-e-franquias':'Bens de consumo e franquias',energia:'Energia e mineração (catálogo histórico)',
'energia-especifica':'Energia',fintech:'Fintech',fiscalidade:'Fiscalidade',imobiliario:'Imobiliário',infraestruturas:'Infraestruturas',
iva:'IVA',mineracao:'Mineração','território':'Território',territorio:'Território',turismo:'Turismo','mercado-financeiro':'Mercado financeiro',
education:'Formação e competências',media:'Imagens e conteúdos de apoio',pt:'Português',en:'Inglês',
};
export function documentFacetLabel(value:string):string{return names[value]??value;}
@@ -0,0 +1,66 @@
import { modeloAFields, modeloAReference } from './iva-modelo-a.ts';
export type FiscalFormKind = 'iva' | 'irpc';
export const fiscalFormVersion = 'investmoz-fiscal-preparation/1';
export const fiscalForms = {
iva: { title: 'IVA — Modelo A', source: modeloAReference.file, hash: modeloAReference.sha256, coverage: 'Campos 01–27 do exemplar, para preenchimento manual. A edição e as divergências já documentadas continuam identificadas.' },
irpc: { title: 'IRPC — Modelo 22', source: 'IRPC M-22- DECLARACAO DE RENDIMENTOS.pdf', hash: '4b61bd83d1fbcfd3b7b5c5523cea5923f7d0f65dc4c004eff58e5db38f763c88', coverage: 'Preparação parcial: resultado, lucro, matéria colectável e liquidação nos campos abaixo. Os restantes ajustamentos e anexos do exemplar não são preenchidos aqui. L8 permanece parcial.' },
} as const;
const irpcFields = [
['200', 'Resultado do exercício'], ['259', 'Prejuízo fiscal'], ['260', 'Lucro tributável'],
['269', 'Prejuízo fiscal — regime geral'], ['270', 'Lucro tributável — regime geral'],
['276', 'Prejuízos fiscais deduzidos'], ['277', 'Benefícios fiscais a deduzir'], ['278', 'Matéria colectável — regime geral'],
['300', 'Imposto à taxa normal'], ['301', 'Imposto à taxa reduzida'], ['302', 'Colecta total'],
['303', 'Dupla tributação económica'], ['304', 'Dupla tributação internacional'], ['305', 'Benefícios fiscais'],
['306', 'Pagamento especial por conta'], ['308', 'Total das deduções'], ['309', 'IRPC liquidado'],
['310', 'Retenções na fonte'], ['311', 'Pagamentos por conta'], ['312', 'IRPC a pagar antes de juros'],
['313', 'IRPC a recuperar antes de juros'], ['314', 'Juros compensatórios'], ['315', 'IRPC a pagar'], ['316', 'IRPC a recuperar'],
] as const;
export function fiscalFormFields(kind: FiscalFormKind) {
return kind === 'iva'
? modeloAFields.map(f => ({ code: f.field, label: f.purpose, guidance: f.basis }))
: irpcFields.map(([code, label]) => ({ code, label, guidance: Number(code) < 269 ? 'Quadro 10 do exemplar.' : Number(code) < 300 ? 'Quadro 11, regime geral. Outros regimes não incluídos nesta preparação.' : 'Quadro 12 do exemplar. Preenchimento manual, sem cálculo ou validação automática da liquidação.' }));
}
export type FiscalPreparation = {
version: typeof fiscalFormVersion;
kind: FiscalFormKind;
sourceHash: string;
name: string;
nuit: string;
period: string;
notes: string;
values: Record<string, string>;
};
export function newFiscalPreparation(kind: FiscalFormKind): FiscalPreparation {
return { version: fiscalFormVersion, kind, sourceHash: fiscalForms[kind].hash, name: '', nuit: '', period: '', notes: '', values: {} };
}
export function readFiscalPreparation(raw: string): FiscalPreparation {
if (raw.length > 100_000) throw new Error('O ficheiro excede o tamanho de uma preparação.');
let p: unknown;
try { p = JSON.parse(raw); } catch { throw new Error('Não foi possível ler este ficheiro de preparação. Os campos actuais foram preservados.'); }
if (!p || typeof p !== 'object' || Array.isArray(p)) throw new Error('Ficheiro de preparação inválido.');
const v = p as Record<string, unknown>;
if (v.version !== fiscalFormVersion || (v.kind !== 'iva' && v.kind !== 'irpc') || v.sourceHash !== fiscalForms[v.kind].hash) throw new Error('Versão ou exemplar diferente. A preparação actual foi preservada.');
for (const key of ['name', 'nuit', 'period', 'notes']) {
if (typeof v[key] !== 'string' || v[key].length > 5000) throw new Error('Identificação ou notas inválidas.');
}
if (!v.values || typeof v.values !== 'object' || Array.isArray(v.values)) throw new Error('Campos inválidos.');
const allowed = new Set(fiscalFormFields(v.kind).map(f => f.code));
const values: Record<string, string> = {};
for (const [code, value] of Object.entries(v.values)) {
if (!allowed.has(code) || typeof value !== 'string' || value.length > 100) throw new Error('Campo não reconhecido. A preparação actual foi preservada.');
values[code] = value;
}
return { ...newFiscalPreparation(v.kind), name: v.name as string, nuit: v.nuit as string, period: v.period as string, notes: v.notes as string, values };
}
export function fiscalPreparationIssues(p: FiscalPreparation): string[] {
const issues: string[] = [];
if (!p.name.trim()) issues.push('Indique o nome ou designação social.');
if (!/^\d{9}$/.test(p.nuit)) issues.push('Indique o NUIT com 9 algarismos.');
if (!(p.kind === 'iva' ? /^\d{4}-(0[1-9]|1[0-2])$/ : /^\d{4}$/).test(p.period)) issues.push(p.kind === 'iva' ? 'Indique ano e mês do período.' : 'Indique o ano do exercício.');
for (const f of fiscalFormFields(p.kind)) {
const value = p.values[f.code] ?? '';
if (value !== '' && !/^-?\d{1,24}(?:[.,]\d{1,2})?$/.test(value)) issues.push(`Campo ${f.code}: use um montante sem separador de milhares, com até duas casas decimais.`);
}
return issues;
}
+40
View File
@@ -0,0 +1,40 @@
export type FillGroup = { id: string; title: string; hint: string; fields: string[] };
const definitions: Omit<FillGroup, 'fields'>[] = [
{id:'idea',title:'Ideia e actividade',hint:'Identifique o investimento e descreva a necessidade que pretende responder.'},
{id:'people',title:'Promotor e contactos',hint:'Reutilize os contactos guardados e escolha o que pode ser divulgado.'},
{id:'territory',title:'Localização e enquadramento',hint:'Indique o território conhecido e as associações documentadas que se aplicam.'},
{id:'operation',title:'Actividade e impacto',hint:'Complete os produtos, recursos, mercados e impactos relevantes para esta oportunidade.'},
{id:'finance',title:'Investimento e financiamento',hint:'Preencha os valores conhecidos. O rascunho pode continuar incompleto.'},
{id:'evidence',title:'Documentos e imagens',hint:'Reutilize os recursos guardados na mesma oportunidade.'},
{id:'specific',title:'Dados específicos',hint:'Preencha os detalhes aplicáveis à modalidade escolhida.'},
{id:'review',title:'Revisão e divulgação',hint:'Reveja riscos, condições e visibilidade antes das acções autorizadas de submissão.'},
];
const fields: Record<string,string[]> = {
idea:['official_title','sector_id','subsector_id','economic_activity_id','executive_summary','demand_statement','opportunity_type','maturity_stage','enterprise_size'],
people:['promoter_type','internal_contact_id','publish_contact','public_contact_id'],
territory:['location_mode','geographic_scope','multi_district_rationale','locations','relations'],
operation:['investment_modalities','investor_profiles','products','assets','inputs','markets','competitive_advantage','employment','impact_indicators'],
finance:['total_investment','currency','investment_breakdown','secured_financing','funding_sought','financial_indicators'],
evidence:['documents','evidences','images'],
review:['publication_languages','visibility','review_due_date','truth_declaration','milestones','licenses','environmental_social','risks'],
};
/** Presentation groups only: retain every applicable field exactly once, including future schema keys. */
export function opportunityFillGroups(available: string[]): FillGroup[] {
const unique=[...new Set(available)];
return definitions.map(group=>({...group,fields:unique.filter(field=>{
const owner=Object.keys(fields).find(key=>fields[key].includes(field))??'specific';
return owner===group.id;
})})).filter(group=>group.fields.length>0);
}
export function opportunityErrorGroup(groups: FillGroup[], errors: Record<string,string>): string | undefined {
return groups.find(group=>group.fields.some(field=>Object.keys(errors).some(path=>path===`canonical.${field}`||path.startsWith(`canonical.${field}.`))))?.id;
}
export function opportunityMissingGroups(groups:FillGroup[], paths:string[]):FillGroup[] {
return groups.filter(group=>group.fields.some(field=>paths.some(path=>{
const normalized=path.replace(/^canonical\./,'');
return normalized===field||normalized.startsWith(`${field}.`);
})));
}
+26 -6
View File
@@ -8,6 +8,9 @@ export type PortalNavItem = {
children?: PortalNavItem[];
};
export type PortalNavAccess = {
contentManagementHref?: string | null;
contentManagementAccess?: {opportunities?:{index:string;create?:string|null}};
scopedAdministration?: { label: string; href: string }[];
procurement?: boolean;
authenticated: boolean;
manager: boolean;
@@ -31,6 +34,10 @@ const link = (id: string, label: string, href: string): PortalNavItem => ({
label,
href,
});
/** Display the existing server decision; a generic manager flag cannot override a scoped denial. */
export function canShowLegacyManagement(auth?:{user?:unknown;canManagePortal?:boolean;legacyManagementAccess?:boolean}):boolean {
return !!auth?.user && (auth.legacyManagementAccess??auth.canManagePortal??false);
}
/** One route catalogue for the public shell and authenticated pages. */
export function legacyPortalNavigation(
@@ -371,6 +378,7 @@ export function legacyPortalNavigation(
'Gestão de perfis',
profile.administration,
),
link('profile-installation','Instalação dos perfis 040000','/management/profile-install'),
]
: []),
],
@@ -439,7 +447,10 @@ export function currentNavigationItem(
/** Investor organization; existing capability filters still determine private destinations. */
export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
const previous = legacyPortalNavigation(access);
const previous = legacyPortalNavigation({...access,manager:access.authenticated&&access.manager,profileAccess:access.authenticated?access.profileAccess:null});
const workHub=access.authenticated?access.contentManagementHref:null;
const workOpportunities=access.authenticated?access.contentManagementAccess?.opportunities:undefined;
const workCreate=access.authenticated?(access.profileAccess?.create||workOpportunities?.create||(access.manager&&access.opportunityManagement?.installed?access.opportunityManagement.create.href:null)):null;
return uniqueNavigationDestinations([
{
id: 'discovery',
@@ -544,11 +555,20 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
),
],
},
...previous.filter((group) =>
['account', 'management', 'profile-opportunities'].includes(
group.id,
),
),
...[{
id: 'work',
label: 'Área de trabalho',
children: [
link('work-entry', workHub ? 'Gestão de conteúdos e oportunidades':'Acesso à gestão', workHub || '/acesso-gestao'),
...(workCreate ? [
link('prepare-analysis', 'Preparar análise e ideia', workCreate+'?analysis=proactive'),
] : []),
...(workOpportunities ? [link('work-opportunities','Oportunidades em preparação',workOpportunities.index)] : []),
...previous.filter(group=>['management','profile-opportunities'].includes(group.id)).flatMap(group=>group.children??[]),
...(access.authenticated&&access.scopedAdministration?.length ? [{id:'work-administration',label:'Administração',children:access.scopedAdministration.map((item,index)=>link('scoped-'+index,item.label,item.href))}] : []),
],
}],
...previous.filter((group) => group.id === 'account'),
...(access.authenticated &&
access.businesses &&
access.businessReviewUrl
@@ -0,0 +1,11 @@
const territoryKeys=['province_id','district_id','admin_post_id','locality_id'];
/** No fallback scope: an explicit empty path means all; a selected path replaces its old descendants. */
export function territorialSearchParams(entries: Iterable<[string,FormDataEntryValue]>, path?: {id:string;location_type:string}[]): URLSearchParams {
const params=new URLSearchParams();
for(const [key,value] of entries)if(typeof value==='string'&&value!==''&&key!=='page')params.set(key,value);
if(path){
territoryKeys.forEach(key=>params.delete(key));
for(const node of path){const key=`${node.location_type}_id`;if(territoryKeys.includes(key))params.set(key,node.id);}
}
return params;
}
+12 -7
View File
@@ -1,4 +1,4 @@
import EducationalGuides from '@/components/educational-guides';
import CatalogueGuideLinks from '@/components/catalogue-guide-links';
import OpportunityCorridorGroups, { type CorridorGroup } from '@/components/opportunity-corridor-groups';
import { searchTerritoryHref } from '@/lib/search-territory';
import type { EducationalArticle } from '@/components/educational-article-card';
@@ -381,6 +381,11 @@ export default function Catalogue({
);
}}
/>
<h2 className="text-xl font-semibold">
Oportunidades publicadas
{filters.sector && ` · ${references.sectors.find((sector) => sector.slug === filters.sector)?.name ?? filters.sector}`}
{territorySelection.length > 0 && ` · ${territorySelection.map((territory) => territory.name).join(' / ')}`}
</h2>
<SearchState
loading={searchLoading}
error={searchError}
@@ -423,7 +428,7 @@ export default function Catalogue({
}));
}}
>
Alargar pesquisa (remover localização)
Pesquisar noutras províncias (remover localização)
</button>
)}
{['grid', 'list', 'map', 'combined', 'corridors'].map((value, i) => (
@@ -591,7 +596,7 @@ export default function Catalogue({
<article key={r.id} className={card + ' space-y-3'}>
<p className="text-sm">
{r.sector?.name} ·{' '}
{r.province || 'Localização não indicada'}
{r.territorial_summary || [r.province, r.district].filter(Boolean).join(' / ') || 'Localização não indicada'}
</p>
<h2 className="text-xl font-bold">
<Link
@@ -646,10 +651,10 @@ export default function Catalogue({
))}
</div>
)}
{!results.total && (
{!searchLoading && !searchError && !results.total && (
<p className={card}>
Sem oportunidades com estes filtros. Explore as fichas
territoriais ou{' '}
Não há oportunidades publicadas com estes filtros no cadastro consultado.
A informação disponível pode ser incompleta. Pode{' '}
<Link href="/apoio" className="underline">
peça apoio
</Link>
@@ -757,7 +762,7 @@ export default function Catalogue({
</div>
</section>
)}
<EducationalGuides articles={guides} />
<CatalogueGuideLinks articles={guides} />
</Frame>
);
}
+1
View File
@@ -53,6 +53,7 @@ export type Opportunity = {
description: string | null;
province: string | null;
district: string | null;
territorial_summary?: string;
sector?: { name: string; slug: string };
estimated_investment_amount: string | null;
estimated_investment_currency: string;
+49 -19
View File
@@ -3,7 +3,9 @@ import InvestmentZoneInstallation, {
} from '@/components/investment-zone-installation';
import { Link, router, usePage } from '@inertiajs/react';
import { searchTerritoryHref } from '@/lib/search-territory';
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { territorialSearchParams } from '@/lib/territorial-search-params';
import CatalogueFilterContext from '@/components/catalogue-filter-context';
import { catalogueDetailHref } from '@/lib/catalogue-context';
import {
TerritoryFields,
@@ -77,6 +79,10 @@ export default function Zones({
total: number;
next_page_url: string | null;
prev_page_url: string | null;
from?: number | null;
to?: number | null;
current_page?: number;
last_page?: number;
};
filters: {
q?: string;
@@ -94,6 +100,30 @@ export default function Zones({
const pageUrl = usePage().url;
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const searchForm=useRef<HTMLFormElement|null>(null);
const searchTimer=useRef<ReturnType<typeof setTimeout>|undefined>(undefined);
const cancelSearch=useRef<(()=>void)|undefined>(undefined);
const searchSequence=useRef(0);
const [searching,setSearching]=useState(false);
const [searchError,setSearchError]=useState('');
const appliedKey=JSON.stringify([filters,territorySelection]);
useEffect(()=>{searchForm.current?.reset();},[appliedKey]);
useEffect(()=>()=>{clearTimeout(searchTimer.current);cancelSearch.current?.();},[]);
const runSearch=(path?:TerritoryOption[])=>{
clearTimeout(searchTimer.current);cancelSearch.current?.();
if(!searchForm.current)return;
const sequence=++searchSequence.current;
const params=territorialSearchParams(new FormData(searchForm.current),path);
setSearching(true);setSearchError('');
router.get(`${management?'/management/investment/zones':'/zonas'}?${params}`,{}, {
preserveState:true,preserveScroll:true,replace:true,
onCancelToken:token=>{cancelSearch.current=()=>token.cancel();},
onError:()=>{if(sequence===searchSequence.current)setSearchError('A pesquisa não foi concluída. Confira os filtros e tente novamente.');},
onNetworkError:()=>{if(sequence===searchSequence.current)setSearchError('Ligação indisponível. Os filtros escolhidos foram preservados.');return false;},
onHttpException:()=>{if(sequence===searchSequence.current)setSearchError('Não foi possível consultar esta selecção.');return false;},
onFinish:()=>{if(sequence===searchSequence.current)setSearching(false);},
});
};
const exportParams = new URLSearchParams();
['province_id', 'district_id', 'admin_post_id', 'locality_id'].forEach((field, index) => {
if (territorySelection[index]) exportParams.set(field, territorySelection[index].id);
@@ -160,22 +190,18 @@ export default function Zones({
</Link>
</nav>
</details>
<CatalogueFilterContext />
<form
ref={searchForm}
method="get"
onChange={(event)=>{
if(!event.target.getAttribute('name'))return;
clearTimeout(searchTimer.current);cancelSearch.current?.();searchSequence.current++;
setSearching(true);setSearchError('');searchTimer.current=setTimeout(()=>runSearch(),350);
}}
onSubmit={(event) => {
event.preventDefault();
const form = event.currentTarget;
const url = new URL(form.action);
const params = new URLSearchParams();
for (const [name, value] of new FormData(form))
if (typeof value === 'string')
params.append(name, value);
url.search = params.toString();
router.get(
url.toString(),
{},
{ preserveState: true, preserveScroll: true },
);
runSearch();
}}
className={card + ' grid gap-4 md:grid-cols-3'}
>
@@ -307,10 +333,14 @@ export default function Zones({
)}
</div>
</details>
<TerritoryFields initial={territorySelection} />
<TerritoryFields initial={territorySelection} onChange={path=>runSearch(path)} />
<button type="button" className="underline" onClick={()=>runSearch([])}>Todo o território (remover localização)</button>
<button className={button}>Pesquisar</button>
</form>
{!management && (
<h2 className="text-xl font-semibold">{searching?'A pesquisar a selecção…':searchError?'Pesquisa por concluir':`${records.total} registos disponíveis · ${territorySelection.length?territorySelection.map(node=>node.name).join(' / '):'Todo o território'}`}</h2>
{!searching&&!searchError&&records.total>0&&<p>A mostrar {records.from??1} a {records.to??records.data.length} de {records.total}. Página {records.current_page??1} de {records.last_page??1}.</p>}
{searchError&&<p role="alert">{searchError}</p>}
{!management && !searching && !searchError && (
<a
className="underline"
href={`/api/v2/investment/zones/export.csv?${exportParams.toString()}`}
@@ -318,12 +348,12 @@ export default function Zones({
Exportar resultados filtrados (CSV)
</a>
)}
{!records.total && (
{!searching && !searchError && !records.total && (
<p className={card}>
Não foram encontrados resultados para esta pesquisa.
</p>
)}
{records.data.map((zone) => (
{!searching && !searchError && records.data.map((zone) => (
<article className={card} key={zone.id}>
<h2 className="text-xl font-bold">
<Link
@@ -451,10 +481,10 @@ export default function Zones({
</article>
))}
<nav aria-label="Paginação" className="flex gap-4 underline">
{records.prev_page_url && (
{!searching && !searchError && records.prev_page_url && (
<Link href={records.prev_page_url}>Anterior</Link>
)}
{records.next_page_url && (
{!searching && !searchError && records.next_page_url && (
<Link href={records.next_page_url}>Seguinte</Link>
)}
</nav>
+15 -16
View File
@@ -171,10 +171,8 @@ function Stage({ step, path }: { step: Step; path: Path }) {
];
const states = path.canWrite ? ownerStates : reviewStates;
return (
<article id={step.code} className="space-y-3 rounded-xl border p-4">
<h3 className="font-semibold">
{step.code} · {step.title}
</h3>
<details id={step.code} open={path.next?.code===step.code} className="space-y-3 rounded-xl border p-4">
<summary className="cursor-pointer font-semibold">{step.title} · {pathStates[step.state]}</summary>
<p>{pathStates[step.state]}</p>
{step.context_review_required && (
<label className="block rounded border border-amber-600 bg-amber-50 p-3">
@@ -235,17 +233,18 @@ function Stage({ step, path }: { step: Step; path: Path }) {
: action === 'operate'
? 'Operação'
: 'Execução'}{' '}
aguarda {gaps.join(', ')}. Pode continuar a preparar.
aguarda {gaps.map(code=>path.steps.find(item=>item.code===code)?.title??code).join(', ')}. Pode continuar a preparar.
</p>
))}
<p className="text-xs">
<details className="text-xs"><summary className="cursor-pointer underline">Fonte e referência desta etapa</summary><p>
Referência interna {step.code}.{' '}
Documento original, parágrafo {step.source_paragraph}.{' '}
{step.detail.scope === 'internal_validation'
? 'Conferência interna; não equivale a decisão externa.'
: step.detail.scope === 'external_document_recorded'
? 'Acto externo registado a partir de documento.'
: 'Preparação declarada pelo promotor.'}
</p>
</p></details>
{step.preparation_allowed && (path.canWrite || path.canReview) && (
<details>
<summary className="cursor-pointer underline">
@@ -405,7 +404,7 @@ function Stage({ step, path }: { step: Step; path: Path }) {
</form>
</details>
)}
</article>
</details>
);
}
@@ -718,10 +717,10 @@ export default function InvestmentPath(path: Path) {
</section>
)}
{Object.entries(path.phases).filter(([number]) => path.steps.some((step) => step.phase === Number(number) && step.preparation_allowed)).map(([number, label]) => (
<section className="space-y-3" key={number}>
<h2 className="text-lg font-semibold">
<details open={path.steps.some(step=>step.phase===Number(number)&&step.code===path.next?.code)} className="space-y-3 rounded border p-4" key={number}>
<summary className="cursor-pointer text-lg font-semibold">
Fase {number} · {label}
</h2>
</summary>
<div className="grid items-start gap-3 md:grid-cols-2">
{path.steps
.filter((step) => step.phase === Number(number) && step.preparation_allowed)
@@ -733,7 +732,7 @@ export default function InvestmentPath(path: Path) {
/>
))}
</div>
</section>
</details>
))}
<section className="space-y-3">
<h2 className="text-lg font-semibold">
@@ -819,10 +818,10 @@ export default function InvestmentPath(path: Path) {
Anexar ou conferir no Balcão
</Link>
</section>
<section className="space-y-3 rounded-xl border p-4">
<h2 className="text-lg font-semibold">
<details className="space-y-3 rounded-xl border p-4">
<summary className="cursor-pointer text-lg font-semibold">
Acompanhamento interno
</h2>
</summary>
{path.canWrite && (
<form
className="grid min-w-0 gap-3 [&_input]:max-w-full [&_input]:min-w-0 [&_label]:min-w-0 [&_select]:max-w-full [&_select]:min-w-0"
@@ -983,7 +982,7 @@ export default function InvestmentPath(path: Path) {
</li>
))}
</ul>
</section>
</details>
</main>
);
}
@@ -1,4 +1,4 @@
import {ProactiveInvestmentEditor,type ProactiveAnalysis,type ProactiveOptions} from '@/components/proactive-investment';
import {ProactiveInvestmentEditor,newProactive,type ProactiveAnalysis,type ProactiveOptions} from '@/components/proactive-investment';
import { ClassificationReview } from '@/components/opportunity-classification';
import { createOperationKey } from '@/lib/operation-key';
import { valueText } from '@/lib/value-text';
@@ -140,18 +140,21 @@ export default function OpportunityDraft({
auth: { user: { id: number } };
canEditDraft?: boolean;
}>().props;
const proactiveEntryUrl=usePage().url;
const uniquePromoterContext=profileAccess?.contexts.length===1?profileAccess.contexts[0]:undefined;
const [data, setData] = useState<Values>({
...values,
...(!record && !values.proactive && new URLSearchParams(proactiveEntryUrl.split('?')[1]??'').get('analysis')==='proactive' ? {proactive:newProactive()} : {}),
...(profileMode
? {
organization_id: record
? (profileOrganization ?? null)
: profileAccess?.internalPromoter
? null
: (profileAccess?.contexts[0]?.organization_id ?? null),
: (uniquePromoterContext?.organization_id ?? null),
opportunity_category:
values.opportunity_category ??
profileAccess?.contexts[0]?.type ??
uniquePromoterContext?.type ??
'',
}
: {}),
@@ -171,10 +174,11 @@ export default function OpportunityDraft({
const unavailable = Boolean(
record?.deleted_at || record?.status === 'archived',
);
const hasCanonicalPreparation = profileMode && canonicalOptions && Boolean(data.opportunity_category) && !data.proactive;
const archiveAvailable = Boolean(usePage().props.opportunityManagement);
const set = (key: string, value: string) =>
setData((d) => ({ ...d, [key]: value }));
async function send(action: 'save' | 'delete' | 'archive' | 'restore') {
async function send(action: 'save' | 'delete' | 'archive' | 'restore', resourcesGroup?: string) {
if (busy) return;
setBusy(true);
setErrors({});
@@ -222,8 +226,10 @@ export default function OpportunityDraft({
}
setMessage(result.message);
if (result.etag) setToken(result.etag);
if (!record) router.visit(`${basePath}/${result.id}/edit`);
if (resourcesGroup) router.visit(`${basePath}/${record?.id ?? result.id}/resources?return_group=${encodeURIComponent(resourcesGroup)}`);
else if (!record) router.visit(`${basePath}/${result.id}/edit${window.location.hash.startsWith('#fill-') ? window.location.hash : ''}`);
else if (action !== 'save') router.reload();
else if (profileMode) router.reload({only:['canonicalResponse','etag','history','workflowState','profileContext']});
} catch {
setMessage(
'Resposta indisponível. Os dados foram preservados; confira o registo no servidor antes de repetir.',
@@ -235,10 +241,8 @@ export default function OpportunityDraft({
document.querySelector<HTMLSelectElement>(
'[data-association-index][aria-invalid="true"]',
);
(
invalidAssociation ??
document.getElementById('opportunity-result')
)?.focus();
if (invalidAssociation) invalidAssociation.focus();
else document.getElementById('opportunity-result')?.focus({preventScroll:true});
});
}
}
@@ -250,9 +254,7 @@ export default function OpportunityDraft({
: 'Nova oportunidade'
}
>
<Link href={basePath}>
{profileMode ? 'Minhas oportunidades' : 'Voltar à gestão'}
</Link>
{!profileMode && <Link href={basePath}>Voltar à gestão</Link>}
{promotionHref && <Link className="ml-3 underline" href={promotionHref}>Divulgação promocional — Em preparação</Link>}
{!!profileContext?.corridor_agribusiness && (
<section className="my-4 space-y-2 rounded border p-4">
@@ -271,11 +273,11 @@ export default function OpportunityDraft({
</Link>
)}
{profileMode && (
<section
aria-label="Âmbito desta entrega"
<details
aria-label="Disponibilidade por modalidade"
className="rounded border p-3"
>
<h2>Âmbito desta entrega</h2>
<summary className="cursor-pointer font-medium">Modalidades disponíveis e condições de submissão</summary>
<p>
Seis categorias base: projeto de investimento,
imobiliário, fundiário, venda de negócio, parceria e
@@ -304,7 +306,7 @@ export default function OpportunityDraft({
certifica a viabilidade, a titularidade ou a decisão
externa de cada caso.
</p>
</section>
</details>
)}
{profileMode && record && (
<ClassificationReview id={record.id} refreshToken={token} />
@@ -334,8 +336,8 @@ export default function OpportunityDraft({
}));
}}
>
{profileAccess?.internalPromoter && (
<option value="">
{(profileAccess?.internalPromoter || !uniquePromoterContext) && (
<option value="|">
Selecionar o promotor real da oportunidade
</option>
)}
@@ -485,7 +487,7 @@ export default function OpportunityDraft({
<p>Seleccione apenas o nível territorial conhecido; não exige coordenadas, montante ou promotor.</p>
{(['province_id','district_id','admin_post_id','locality_id']as const).map((key,index)=><TerritoryLevel key={`proactive-${key}-${path[index-1]?.id??''}`} index={index} parent={path[index-1]?.id??''} selected={path[index]} endpoint="/minhas-oportunidades/territory-options" fieldError={errors[key]} onChange={value=>{const next=[...path.slice(0,index),...(value?[value]:[])];setPath(next);setData(d=>({...d,province_id:next[0]?.id??'',district_id:next[1]?.id??'',admin_post_id:next[2]?.id??'',locality_id:next[3]?.id??''}));}}/>)}
</fieldset>}
<fieldset disabled={busy || readOnly || unavailable}><ProactiveInvestmentEditor value={data.proactive as ProactiveAnalysis|undefined} onChange={v=>setData(d=>({...d,proactive:v}))} options={proactiveOptions} history={proactiveHistory}/></fieldset>
<fieldset disabled={busy || readOnly || unavailable}><ProactiveInvestmentEditor value={data.proactive as ProactiveAnalysis|undefined} onChange={v=>setData(d=>({...d,proactive:v}))} options={proactiveOptions} history={proactiveHistory} territoryId={path.at(-1)?.id} recordId={record?.id}/></fieldset>
{!profileMode && (
<fieldset
disabled={busy || readOnly || unavailable}
@@ -821,7 +823,7 @@ export default function OpportunityDraft({
)}
{!readOnly && (
<>
<Field
{!hasCanonicalPreparation && <Field
label="Motivo da alteração"
error={errors.reason}
>
@@ -832,8 +834,8 @@ export default function OpportunityDraft({
minLength={20}
maxLength={1000}
/>
</Field>
{!unavailable && (
</Field>}
{!unavailable && !hasCanonicalPreparation && (
<button className={button} disabled={busy}>
Guardar rascunho
</button>
@@ -891,7 +893,7 @@ export default function OpportunityDraft({
</>
)}
</form>
{record && (
{record && (!profileMode || readOnly || unavailable || !canonicalOptions || !data.opportunity_category) && (
<Link
href={
resourcesInstalled
@@ -909,10 +911,10 @@ export default function OpportunityDraft({
Associações territoriais e produtivas existentes
</Link>
)}
{profileMode && canonicalOptions && (
{profileMode && canonicalOptions && Boolean(data.opportunity_category) && (
<section>
{record && !readOnly && !unavailable && (
<OpportunityContactForm
<details><summary className="cursor-pointer font-medium">Acrescentar um contacto à oportunidade</summary><OpportunityContactForm
id={record.id}
etag={token ?? ''}
organization={
@@ -938,7 +940,7 @@ export default function OpportunityDraft({
},
}));
}}
/>
/></details>
)}
<OpportunityCanonicalFields
value={
@@ -960,6 +962,7 @@ export default function OpportunityDraft({
],
}}
errors={errors}
missingPaths={canonicalResponse?.completion.missing_paths}
category={valueText(data.opportunity_category ?? '')}
organization={
(data.organization_id ?? null) as string | null
@@ -967,16 +970,23 @@ export default function OpportunityDraft({
actorId={auth.user.id}
sectors={sectors}
disabled={busy || readOnly || unavailable}
onOpenResources={!readOnly && !unavailable && resourcesInstalled ? (group) => void send('save',group) : undefined}
/>
{!readOnly && !unavailable && (
{!readOnly && !unavailable && hasCanonicalPreparation && (
<div className="sticky bottom-0 space-y-3 rounded border bg-background p-4 shadow-sm">
<Field label="O que alterou nesta preparação" error={errors.reason}>
<textarea className={control} value={reason} onChange={(event) => setReason(event.target.value)} minLength={20} maxLength={1000} />
</Field>
<button
type="button"
className={button}
disabled={busy}
onClick={() => void send('save')}
>
Guardar contrato no rascunho
Guardar preparação
</button>
<p className="text-sm">Pode guardar incompleto e continuar mais tarde em Minhas oportunidades.</p>
</div>
)}
{Object.entries(errors)
.filter(([key]) => key.startsWith('canonical'))
@@ -179,6 +179,8 @@ export default function OpportunityResources({
readOnly?: boolean;
countries?: { code: string; name: string }[];
}>().props;
const returnGroup = new URLSearchParams(usePage().url.split('?')[1] ?? '').get('return_group');
const returnHash = ['territory', 'evidence'].includes(returnGroup ?? '') ? `#fill-${returnGroup}` : '#fill-evidence';
const [kind, setKind] = useState<Kind>('locations');
const [editing, setEditing] = useState<Resource | null>(null);
const [payload, setPayload] = useState<Payload>({ country_code: 'MZ' });
@@ -307,7 +309,7 @@ export default function OpportunityResources({
!!opportunity.deleted_at || opportunity.status === 'archived';
return (
<Management title="Imagens, documentos e localizações">
<Link href={`${basePath}/${opportunity.id}/edit`}>
<Link href={`${basePath}/${opportunity.id}/edit${returnHash}`}>
Regressar ao rascunho #{opportunity.id}
</Link>
<p>
+100
View File
@@ -0,0 +1,100 @@
import { Head, Link, router } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { PortalMenu } from '@/layouts/portal-navigation';
import { fiscalForms, fiscalFormFields, fiscalPreparationIssues, newFiscalPreparation, readFiscalPreparation, type FiscalFormKind, type FiscalPreparation } from '@/lib/fiscal-form-preparation';
const input = 'mt-1 block w-full rounded border border-slate-400 bg-white p-3 text-slate-900';
const button = 'rounded border border-emerald-800 px-4 py-3 font-semibold text-emerald-900 disabled:opacity-50';
export default function FiscalForms({ initialTax = 'iva' }: { initialTax?: FiscalFormKind }) {
const [kind, setKind] = useState<FiscalFormKind>(initialTax);
const [drafts, setDrafts] = useState({ iva: newFiscalPreparation('iva'), irpc: newFiscalPreparation('irpc') });
const [step, setStep] = useState(0);
const [notice, setNotice] = useState('');
const [pending, setPending] = useState<FiscalPreparation | null>(null);
const [dirty, setDirty] = useState(false);
const p = drafts[kind], source = fiscalForms[kind], fields = fiscalFormFields(kind);
const issues = fiscalPreparationIssues(p);
useEffect(() => {
setKind(initialTax);
}, [initialTax]);
useEffect(() => {
const warn = (e: BeforeUnloadEvent) => { if (dirty) { e.preventDefault(); e.returnValue = ''; } };
window.addEventListener('beforeunload', warn);
const unsubscribe = router.on('before', e => {
if (dirty && !window.confirm('Os campos estão apenas nesta página. Guarde o ficheiro para continuar depois. Pretende sair?')) e.preventDefault();
});
return () => { window.removeEventListener('beforeunload', warn); unsubscribe(); };
}, [dirty]);
function patch(update: Partial<FiscalPreparation>) {
setDrafts(d => ({ ...d, [kind]: { ...d[kind], ...update } }));
setDirty(true); setNotice('');
}
function download() {
const url = URL.createObjectURL(new Blob([JSON.stringify(p)], { type: 'application/json' }));
const a = document.createElement('a'); a.href = url; a.download = `investmoz-preparacao-${kind}.json`; a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
setNotice('Ficheiro de preparação gerado. Guarde-o para recuperar os campos. Não foi enviado ao portal nem à AT.');
// Other tax preparation may still contain unsaved edits.
}
return <div className="min-h-screen bg-slate-50 text-slate-900">
<Head title="Preencher formulário fiscal" />
<div className="print:hidden"><PortalMenu /></div>
<main className="mx-auto max-w-5xl space-y-6 px-5 py-8">
<div className="print:hidden"><Link href="/documentos" className="underline">Documentos e Formulários</Link></div>
<h1 className="text-3xl font-bold">Preencher formulário fiscal</h1>
<p>Prepare os valores de IVA ou IRPC, confira e guarde um ficheiro para continuar depois. Os campos vazios significam informação em falta.</p>
<p className="rounded border border-amber-600 bg-amber-50 p-3">Preparação para conferência, sem submissão à Autoridade Tributária. Os valores são introduzidos por si; não há apuramento automático nem confirmação de elegibilidade de deduções.</p>
<label className="block max-w-lg print:hidden">Formulário
<select className={input} value={kind} onChange={e => { setKind(e.target.value as FiscalFormKind); setStep(0); setNotice(''); setPending(null); }}>
<option value="iva">IVA — Modelo A</option><option value="irpc">IRPC — Modelo 22</option>
</select>
</label>
<h2 className="text-xl font-bold">{source.title}</h2>
<p>{source.coverage}</p>
<nav aria-label="Etapas de preenchimento" className="flex flex-wrap gap-2 print:hidden">
{['Identificação e período', 'Preencher valores', 'Conferir e guardar'].map((label, n) => <button type="button" key={label} className={button} aria-current={step === n ? 'step' : undefined} onClick={() => setStep(n)}>{n + 1}. {label}</button>)}
</nav>
<form onSubmit={e => e.preventDefault()} className="space-y-5">
<fieldset className={`${step !== 0 ? 'hidden print:block' : ''} space-y-4 rounded border bg-white p-5`}>
<legend className="font-bold">Identificação e período</legend>
<label className="block">Nome ou designação social<input className={input} autoComplete="organization" maxLength={5000} value={p.name} onChange={e => patch({ name: e.target.value })} /></label>
<div className="grid gap-4 sm:grid-cols-2">
<label>NUIT<input className={input} inputMode="numeric" maxLength={9} value={p.nuit} onChange={e => patch({ nuit: e.target.value })} /></label>
<label>{kind === 'iva' ? 'Período — ano e mês' : 'Exercício — ano'}<input className={input} type={kind === 'iva' ? 'month' : 'text'} inputMode={kind === 'irpc' ? 'numeric' : undefined} placeholder={kind === 'iva' ? 'AAAA-MM' : 'AAAA'} maxLength={7} value={p.period} onChange={e => patch({ period: e.target.value })} /></label>
</div>
<p className="text-sm">Dados mantidos apenas nesta página até descarregar o ficheiro. Não há gravação automática. Guarde antes de sair ou actualizar.</p>
</fieldset>
<fieldset className={`${step !== 1 ? 'hidden print:block' : ''} space-y-4 rounded border bg-white p-5`}>
<legend className="font-bold">Valores em meticais (MZN)</legend>
<p>Sem separador de milhares; vírgula ou ponto para os cêntimos. Não preencha zero quando desconhece o valor.</p>
{kind === 'irpc' && <p>Crédito económico (303) e internacional (304) são distintos. Não utilize a mesma parcela nos dois campos. Esta preparação manual não verifica as parcelas; conserve o suporte para conferência.</p>}
<div className="grid gap-5 sm:grid-cols-2">{fields.map(f => <div key={f.code} className="break-inside-avoid">
<label className="block">{f.code} — {f.label}<input className={input} inputMode="decimal" maxLength={100} value={p.values[f.code] ?? ''} onChange={e => patch({ values: { ...p.values, [f.code]: e.target.value } })} /></label>
<details className="mt-1 text-sm print:hidden"><summary className="cursor-pointer underline">Orientação do campo</summary><p>{f.guidance}</p></details>
</div>)}</div>
</fieldset>
<section className={`${step !== 2 ? 'hidden print:block' : ''} space-y-4 rounded border bg-white p-5`} aria-label="Conferir e guardar">
<h3 className="font-bold">Conferência da preparação</h3>
<p>{fields.filter(f => (p.values[f.code] ?? '') !== '').length} de {fields.length} campos com valor. Campos vazios não foram convertidos em zero.</p>
{issues.length > 0 ? <div><p>Por conferir — pode guardar a preparação incompleta:</p><ul className="list-disc pl-6">{issues.map(i => <li key={i}>{i}</li>)}</ul></div> : <p>Formato dos campos conferido. Os montantes e a aplicação fiscal ainda devem ser conferidos.</p>}
<dl className="grid gap-3 sm:grid-cols-2 print:hidden">{fields.filter(f => (p.values[f.code] ?? '') !== '').map(f => <div key={f.code}><dt>{f.code} — {f.label}</dt><dd className="font-semibold">{p.values[f.code]} MZN</dd></div>)}</dl>
<label className="block">Notas e referências dos valores<textarea className={input} rows={4} maxLength={5000} value={p.notes} onChange={e => patch({ notes: e.target.value })} /></label>
<div className="flex flex-wrap gap-3 print:hidden"><button type="button" className={button} onClick={download}>Guardar ficheiro para continuar</button><button type="button" className={button} onClick={() => window.print()}>Imprimir preparação</button></div>
<p className="text-sm">O ficheiro contém os dados introduzidos. Guarde-o num local privado. Não fica associado automaticamente ao dossier.</p>
</section>
<div className="flex justify-between gap-3 print:hidden"><button type="button" className={button} disabled={step === 0} onClick={() => setStep(step - 1)}>Anterior</button><button type="button" className={button} disabled={step === 2} onClick={() => setStep(step + 1)}>Continuar</button></div>
</form>
<section className="space-y-3 rounded border p-4 print:hidden" aria-label="Recuperar preparação">
<label className="block">Recuperar ficheiro guardado<input className={input} type="file" accept=".json,application/json" onChange={async e => {
const file = e.target.files?.[0]; e.target.value = ''; if (!file) return;
try { if (file.size > 100_000) throw new Error('O ficheiro é demasiado grande.'); setPending(readFiscalPreparation(await file.text())); setNotice('Ficheiro lido. Confirme a recuperação abaixo.'); }
catch (error) { setPending(null); setNotice(error instanceof Error ? error.message : 'Não foi possível ler o ficheiro. Os campos actuais foram preservados.'); }
}} /></label>
{pending && <div className="space-y-2"><p>Recuperar {fiscalForms[pending.kind].title}, período {pending.period || 'por indicar'}? Substitui apenas os campos desse formulário nesta página.</p><button type="button" className={button} onClick={() => { setDrafts(d => ({ ...d, [pending.kind]: pending })); setKind(pending.kind); setStep(2); setPending(null); setDirty(true); setNotice('Preparação recuperada.'); }}>Confirmar recuperação</button> <button type="button" className={button} onClick={() => setPending(null)}>Cancelar</button></div>}
</section>
{notice && <p role="status" className="rounded border p-3">{notice}</p>}
<details className="rounded border p-4"><summary className="cursor-pointer font-semibold">Exemplar e cobertura</summary><p>{source.source}</p><p className="break-all text-sm">SHA256: {source.hash}</p><p>{source.coverage}</p><p>Os cálculos e correspondências já disponíveis no simulador permanecem separados desta introdução manual. Não se executam fórmulas nem se transportam valores entre edições automaticamente.</p></details>
<div className="flex flex-wrap gap-4 print:hidden"><Link href="/simulador-fiscal" className="underline">Consultar cálculos e preparação M20/M22 no simulador</Link><Link href="/legislacao" className="underline">Consultar legislação</Link></div>
</main>
</div>;
}
+15 -5
View File
@@ -1,4 +1,5 @@
import { PublicSectorCards } from '@/components/public-sector-cards';
import { documentFacetLabel } from '@/lib/document-facet-label';
import { PORTAL_MORE_LINK } from '@/lib/portal-presentation';
import {
PublicDocumentContent,
@@ -537,6 +538,15 @@ export default function Services(p: Props) {
opportunities={p.opportunities}
/>
)}
{p.section === 'documents' && <section aria-label="Preencher formulários fiscais" className="space-y-3 rounded-xl border bg-white p-5">
<h2 className="text-xl font-bold">Que formulário pretende preencher?</h2>
<p>Abra os campos do formulário fiscal. A legislação e os guias de consulta ficam abaixo.</p>
<div className="flex flex-wrap gap-3">
<Link className={button} href="/formularios/fiscais?imposto=iva">Preencher IVA — Modelo A</Link>
<Link className={button} href="/formularios/fiscais?imposto=irpc">Preparar IRPC — Modelo 22</Link>
</div>
<p className="text-sm">Preparação manual, com gravação em ficheiro. Cobertura parcial de IRPC; sem submissão à AT.</p>
</section>}
{['procedures', 'documents', 'legislation'].includes(
p.section,
) && <JourneyDocumentResources stages={p.stages} />}
@@ -579,7 +589,7 @@ export default function Services(p: Props) {
>
<option value="">Todos</option>
{p.topics?.map((topic) => (
<option key={topic}>{topic}</option>
<option key={topic} value={topic}>{documentFacetLabel(topic)}</option>
))}
</select>
</label>
@@ -606,7 +616,7 @@ export default function Services(p: Props) {
value={category}
>
{documentTypeLabels[category] ??
category}
documentFacetLabel(category)}
</option>
))}
</select>
@@ -809,8 +819,8 @@ export default function Services(p: Props) {
>
<option value="">Todos</option>
{p.languages?.map((language) => (
<option key={language}>
{language}
<option key={language} value={language}>
{documentFacetLabel(language)}
</option>
))}
</select>
@@ -917,7 +927,7 @@ export default function Services(p: Props) {
className="rounded-full border bg-white px-3 py-1 underline"
href={`${p.section === 'documents' ? '/documentos' : '/legislacao'}?${new URLSearchParams({ topic }).toString()}`}
>
{topic}
{documentFacetLabel(topic)}
</Link>
))}
</nav>
+17 -23
View File
@@ -4,6 +4,11 @@ import { useState } from 'react';
import ProfileNavigation from '@/components/profile-navigation';
import { profileJson } from '@/lib/profile-json';
import { button, control } from '@/pages/management/shared';
import { modalityLabels } from '@/components/opportunity-search-filters';
import ProfileProofPicker from '@/components/profile-proof-picker';
const representations: Record<string,string> = {individual:'Em nome próprio',company:'Empresa',public_entity:'Entidade pública',association:'Associação',partner:'Parceiro ou representante'};
const requestStates: Record<string,string> = {pending:'Em análise',approved:'Aprovado',rejected:'Devolvido ou recusado',cancelled:'Cancelado'};
type RequestEntry = {
id: string;
@@ -67,9 +72,8 @@ export default function RequestProfile({
Solicitar perfil de Promotor
</h1>
<p>
A aprovação confirma identidade, representação e comprovativos.
Só os tipos aprovados ficam disponíveis; o pedido não ativa o
perfil.
Indique em nome de quem vai actuar, as modalidades que pretende preparar e os comprovativos do pedido.
Pode acompanhar a decisão nesta página. A submissão do pedido não activa o perfil.
</p>
<form
className="max-w-2xl space-y-4"
@@ -79,7 +83,7 @@ export default function RequestProfile({
}}
>
<label className="block">
Representação
Em nome de quem vai actuar?
<select
className={control}
value={representation}
@@ -92,7 +96,7 @@ export default function RequestProfile({
'association',
'partner',
].map((v) => (
<option key={v}>{v}</option>
<option key={v} value={v}>{representations[v]}</option>
))}
</select>
</label>
@@ -115,9 +119,10 @@ export default function RequestProfile({
</label>
)}
<fieldset>
<legend>Tipos pretendidos</legend>
<legend>Que oportunidades pretende preparar?</legend>
<div className="grid gap-2 sm:grid-cols-2">
{types.map((type) => (
<label key={type} className="block">
<label key={type} className="flex gap-2 rounded border p-3">
<input
type="checkbox"
checked={selected.includes(type)}
@@ -125,23 +130,12 @@ export default function RequestProfile({
setSelected(toggle(selected, type))
}
/>{' '}
{type}
</label>
))}
</fieldset>
<fieldset>
<legend>Comprovativos próprios analisados</legend>
{documents.map((d) => (
<label key={d.id} className="block">
<input
type="checkbox"
checked={proofs.includes(d.id)}
onChange={() => setProofs(toggle(proofs, d.id))}
/>{' '}
{d.file_name}
{modalityLabels[type] ?? 'Outra modalidade'}
</label>
))}
</div>
</fieldset>
<ProfileProofPicker documents={documents} value={proofs} onChange={setProofs} disabled={busy} />
{!documents.length && (
<p>
Sem comprovativos próprios disponíveis. Pode anexar ao
@@ -156,7 +150,7 @@ export default function RequestProfile({
</p>
)}
<label className="block">
Fundamentação
Explique o seu pedido
<textarea
required
minLength={20}
@@ -180,7 +174,7 @@ export default function RequestProfile({
{requests.map((r) => (
<article key={r.id} className="border-b py-3">
<p>
{r.id} — {r.status}
Pedido de {new Date(r.created_at).toLocaleDateString('pt-MZ')} — {requestStates[r.status] ?? 'Estado por consultar'}
</p>
<p>
{r.decision_reason ??
@@ -0,0 +1,237 @@
import { useState } from 'react';
import {
assessmentReady,
diagnosticReady,
forceLabels,
forces,
newCompetitivenessDraft,
readCompetitivenessDraft,
} from '@/lib/cluster-competitiveness';
import type {
Assessment,
CompetitivenessDraft,
Force,
} from '@/lib/cluster-competitiveness';
import { button, control } from '../management/shared';
const labels = {
evidence: 'Observações e evidências locais',
source: 'Fonte e referência recuperável (documento, página ou endereço)',
period: 'Período dos dados e data da consulta',
rationale: 'Justificação da pressão atribuída',
limitations: 'Limitações, cobertura e pressupostos',
opportunity: 'Oportunidade de investimento e fundamento',
} as const;
export default function Competitiveness({
clusterId,
sector,
}: {
clusterId: string;
sector: string;
}) {
const [draft, setDraft] = useState(() =>
newCompetitivenessDraft(clusterId, sector),
);
const [pending, setPending] = useState<CompetitivenessDraft | null>(null);
const [message, setMessage] = useState('');
function change(force: Force, field: keyof Assessment, value: string) {
setDraft((previous) => ({
...previous,
forces: {
...previous.forces,
[force]: { ...previous.forces[force], [field]: value },
},
}));
}
function download() {
const url = URL.createObjectURL(
new Blob([JSON.stringify(draft, null, 2)], {
type: 'application/json',
}),
);
const link = document.createElement('a');
link.href = url;
link.download = `competitividade-${clusterId}.json`;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
setMessage(
'Ficheiro preparado para transferência. Conserve-o para recuperar a análise.',
);
}
return (
<section
className="max-w-3xl space-y-4 rounded border p-4"
aria-label="Diagnóstico de competitividade"
>
<h2 className="text-xl font-semibold">
Competitividade — cinco forças de Porter
</h2>
<p>
Análise qualitativa por sector e região ligada a este cluster.
Descreva as evidências e justifique cada pressão; não existe
pontuação automática nem média entre forças.
</p>
<p>
Rascunho nesta página: transfira o ficheiro antes de sair. Não é
gravado no servidor nem publicado. 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.'}
</p>
{forces.map((force) => (
<details key={force} className="rounded border p-3">
<summary className="cursor-pointer font-semibold">
{forceLabels[force]} ·{' '}
{assessmentReady(draft.forces[force])
? 'Preenchida para revisão'
: 'Indeterminada / incompleta'}
</summary>
<div className="space-y-3 pt-3">
<label className="block">
Pressão competitiva
<select
className={control}
value={draft.forces[force].pressure}
onChange={(e) =>
change(force, 'pressure', e.target.value)
}
>
<option value="">Indeterminada</option>
<option value="low">Baixa</option>
<option value="medium">Média</option>
<option value="high">Alta</option>
</select>
</label>
{Object.entries(labels).map(([field, label]) => (
<label key={field} className="block">
{label}
<textarea
className={control}
rows={3}
maxLength={5000}
value={
draft.forces[force][
field as keyof typeof labels
]
}
onChange={(e) =>
change(
force,
field as keyof typeof labels,
e.target.value,
)
}
/>
</label>
))}
</div>
</details>
))}
<button type="button" className={button} onClick={download}>
Transferir análise / rascunho
</button>
<label className="block">
Recuperar ficheiro deste cluster
<input
className={control}
type="file"
accept=".json,application/json"
onChange={async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setPending(null);
try {
if (file.size > 250_000)
throw Error(
'O ficheiro excede o limite de 250 KB.',
);
setPending(
readCompetitivenessDraft(
await file.text(),
clusterId,
),
);
setMessage(
'Ficheiro lido. Confira o âmbito e confirme a substituição dos campos actuais.',
);
} catch (error) {
setMessage(
error instanceof Error
? error.message
: 'Não foi possível ler o ficheiro.',
);
}
}}
/>
</label>
{pending && (
<div className="space-y-2 rounded border p-3">
<p>
Recuperar análise de{' '}
{pending.sector || 'sector não indicado'} em{' '}
{pending.region || 'região não indicada'}? Os campos
actuais serão substituídos. Transfira primeiro o
rascunho actual se o quiser conservar.
</p>
<button
type="button"
className={button}
onClick={() => {
setDraft(pending);
setPending(null);
setMessage('Análise recuperada para edição.');
}}
>
Confirmar recuperação
</button>{' '}
<button
type="button"
className={button}
onClick={() => {
setPending(null);
setMessage(
'Recuperação cancelada; campos actuais preservados.',
);
}}
>
Cancelar
</button>
</div>
)}
{message && <p role="status">{message}</p>}
</section>
);
}
+2
View File
@@ -6,6 +6,7 @@ import { Management, Field, button, control } from '../management/shared';
import { titles, statuses, stages, roles } from './types';
import type { Structure, StructureRecord, Chain } from './types';
import ValueNetwork from './value-network';
import Competitiveness from './competitiveness';
import type { ChainLink, Gap } from './value-network';
function ChainForm({ clusterId, chain }: { clusterId: string; chain?: Chain }) {
@@ -298,6 +299,7 @@ export default function Editor({
</form>
{isCluster && record && (
<>
<Competitiveness key={record.id} clusterId={record.id} sector={record.sector ?? ''} />
<section className="max-w-3xl space-y-4">
<h2 className="text-xl font-semibold">
Projectos associados
+6
View File
@@ -50,6 +50,11 @@ use Laravel\Fortify\Features;
// Route::inertia('/', 'welcome')->name('home');
Route::get('/', [PortalServicesController::class, 'home'])->name('home');
Route::inertia('/contextos-de-investimento', 'portal/investment-contexts')->name('portal.investment-contexts');
Route::get('/formularios/fiscais', function (\Illuminate\Http\Request $request) {
$input = $request->validate(['imposto' => ['nullable', 'in:iva,irpc']]);
return Inertia::render('portal/fiscal-forms', ['initialTax' => $input['imposto'] ?? 'iva']);
})->name('portal.fiscal-forms');
Route::inertia('/politica-de-privacidade', 'portal/privacy-policy')->name('legal.privacy');
Route::inertia('/termos-de-uso', 'portal/terms-of-use')->name('legal.terms');
foreach (['sectores' => 'sectors', 'como-investir' => 'procedures', 'legislacao' => 'legislation', 'documentos' => 'documents',
@@ -166,6 +171,7 @@ Route::middleware(['auth', 'verified', EnsurePortalProfileAdministration::class]
});
Route::middleware(['auth', 'verified', EnsurePortalProfiles::class])->group(function () {
Route::get('/minhas-oportunidades/oferta-interna', [\App\Http\Controllers\ProactiveSupplyController::class,'index'])->middleware('throttle:60,1');
$profiles = PortalProfileController::class;
Route::get('/minhas-oportunidades', [$profiles, 'opportunities']);
Route::get('/minhas-oportunidades/lotes/agronegocio', [\App\Http\Controllers\CorridorAgribusinessController::class, 'show']);
@@ -0,0 +1,126 @@
<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\CorridorAgribusinessOpportunities;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class CatalogueProvinceCoverageTest extends TestCase
{
use DatabaseTransactions;
private const PROVINCE = 'bfde2651-2b46-5d9c-a500-733fa55a8c5c';
protected function setUp(): void
{
parent::setUp();\Tests\Support\ProactiveInvestmentFixture::guard();$this->withoutVite();
}
public function test_accepted_maputo_source_is_found_in_both_approved_provinces_without_becoming_a_district_site(): void
{
$c=app(InvestmentCatalogue::class);
$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([self::PROVINCE,'4aa31470-997e-5c63-8fe0-ff2f62586f56']as$province){
foreach(['all','context']as$basis){
$ids=$c->query(['sector'=>'agronegocio','province_id'=>$province,'territory_basis'=>$basis])->pluck('id')->all();
foreach($expected as$id)$this->assertContains($id,$ids);
}
}
foreach($expected as$id)$this->assertNotContains($id,$c->query(['province_id'=>self::PROVINCE,'territory_basis'=>'direct'])->pluck('id')->all());
$district=app(InvestmentGeography::class)->publicReferences()->where('parent_id',self::PROVINCE)->where('location_type','district')->first();
$this->assertNotNull($district);
foreach($expected as$id)$this->assertNotContains($id,$c->query(['province_id'=>self::PROVINCE,'district_id'=>$district->id])->pluck('id')->all());
$label=$c->presentLocation(Opportunity::findOrFail($expected[0]))->territorial_summary;
$this->assertStringContainsString('contexto provincial do corredor',$label);
$this->assertStringContainsString('distrito e posto administrativo não indicados',$label);
foreach($expected as$id)$this->assertContains($id,$c->query(['geo_location_id'=>self::PROVINCE])->pluck('id')->all());
$this->assertSame([],array_intersect($expected,$c->query(['province_id'=>'059b02ca-7095-577a-8705-4d4c46896747'])->pluck('id')->all()));
}
public function test_province_includes_its_descendants_with_full_location_and_excludes_private_and_other_provinces(): void
{
$geo=app(InvestmentGeography::class);$c=app(InvestmentCatalogue::class);
$district=$geo->publicReferences()->where('parent_id',self::PROVINCE)->where('location_type','district')->first();
$post=$geo->publicReferences()->where('parent_id',$district->id)->where('location_type','admin_post')->first();
$this->assertNotNull($post);
$make=fn($territory,$status='published')=>Opportunity::create(['sector_id'=>1,'reference'=>'SYNTHETIC-QA-'.Str::uuid(),'slug'=>'synthetic-'.Str::uuid(),'title'=>'SYNTHETIC QA province coverage','status'=>$status,'published_at'=>now()->subMinute(),'primary_territory_id'=>$territory,'geography_scope'=>'specific']);
$record=$make($post->id);$private=$make($post->id,'draft');$other=$make('059b02ca-7095-577a-8705-4d4c46896747');
$ids=$c->query(['province_id'=>self::PROVINCE,'sector'=>'agronegocio'])->pluck('id')->all();
$this->assertContains($record->id,$ids);$this->assertNotContains($private->id,$ids);$this->assertNotContains($other->id,$ids);
$label=$c->presentLocation($record)->territorial_summary;
foreach(['Maputo Província',$district->name,$post->name]as$name)$this->assertStringContainsString($name,$label);
}
public function test_actual_territory_and_publication_decisions_override_old_corridor_context(): void
{
$c=app(InvestmentCatalogue::class);
$record=Opportunity::published()->where('workflow_context->corridor_agribusiness->corridor','Corredor de Maputo')->firstOrFail();
$record->primary_territory_id='059b02ca-7095-577a-8705-4d4c46896747';$record->save();
$this->assertNotContains($record->id,$c->query(['province_id'=>self::PROVINCE])->pluck('id')->all());
$record->primary_territory_id=null;$record->status='draft';$record->save();
$this->assertNotContains($record->id,$c->query(['province_id'=>self::PROVINCE])->pluck('id')->all());
}
public function test_public_search_and_corridor_view_agree_without_inventing_map_coordinates(): void
{
$filters=['province_id'=>self::PROVINCE,'sector'=>'agronegocio'];
$response=$this->getJson('/api/v1/investment/search?'.http_build_query($filters))->assertOk()->assertJsonPath('total',5);
foreach($response->json('data')as$row)$this->assertStringContainsString('contexto provincial do corredor',$row['territorial_summary']);
$groups=app(\App\Services\OpportunityCorridorExplorer::class)->groups($filters);
$this->assertCount(1,$groups);$this->assertSame(5,$groups[0]['count']);
foreach($groups[0]['opportunities']as$row){$this->assertContains('Maputo Província',$row['provinces']);$this->assertNull($row['district']);}
$map=app(InvestmentCatalogue::class)->map($filters);
$this->assertCount(5,$map['unlocated']);$this->assertSame([],$map['features']);
}
public function test_all_approved_corridors_are_found_in_their_provinces_and_not_in_unrelated_provinces(): void
{
$catalogue=app(InvestmentCatalogue::class);
$provinces=app(InvestmentGeography::class)->publicReferences()->where('location_type','province')->get();
$this->assertCount(11,$provinces);
foreach(config('investment_corridors')as$entry){
$records=Opportunity::published()->whereNull('primary_territory_id')
->where('workflow_context->corridor_agribusiness->source_sha256',CorridorAgribusinessOpportunities::SOURCE_HASH)
->where('workflow_context->corridor_agribusiness->corridor',$entry['name'])->pluck('id')->all();
$this->assertNotEmpty($records,$entry['name']);
foreach($provinces as$province){
$ids=$catalogue->query(['province_id'=>$province->id,'sector'=>'agronegocio'])->pluck('id')->all();
foreach($records as$id){
if(in_array($province->source_code,$entry['provinces'],true))$this->assertContains($id,$ids);
else $this->assertNotContains($id,$ids);
}
}
}
}
public function test_descendant_coverage_is_national_and_shared_by_sectors_and_location_entry_points(): void
{
$geo=app(InvestmentGeography::class);$catalogue=app(InvestmentCatalogue::class);
$provinces=$geo->publicReferences()->where('location_type','province')->get();
$sectors=DB::table('sectors')->where('is_active',true)->whereNotNull('published_at')->whereNull('deleted_at')->orderBy('id')->limit(2)->get();
$this->assertCount(2,$sectors);
foreach($provinces as$province){
$district=$geo->publicReferences()->where('parent_id',$province->id)->where('location_type','district')->first();
$this->assertNotNull($district,$province->name);
$post=$geo->publicReferences()->where('parent_id',$district->id)->where('location_type','admin_post')->first();
$leaf=$post?:$district;
foreach($sectors as$sector){
$record=Opportunity::create(['sector_id'=>$sector->id,'reference'=>'SYNTHETIC-QA-'.Str::uuid(),'slug'=>'synthetic-'.Str::uuid(),
'title'=>'SYNTHETIC QA national coverage','status'=>'published','published_at'=>now()->subMinute(),'primary_territory_id'=>$leaf->id,'geography_scope'=>'specific']);
foreach([['province_id'=>$province->id],['geo_location_id'=>$province->id],['province_id'=>$province->id,'district_id'=>$district->id]]as$entry){
$this->assertContains($record->id,$catalogue->query([...$entry,'sector'=>$sector->slug])->pluck('id')->all());
}
$other=$sectors->firstWhere('id','!=',$sector->id);
$this->assertNotContains($record->id,$catalogue->query(['province_id'=>$province->id,'sector'=>$other->slug])->pluck('id')->all());
$label=$catalogue->presentLocation($record)->territorial_summary;
$this->assertStringContainsString($province->name,$label);$this->assertStringContainsString($district->name,$label);
if($post)$this->assertStringContainsString($post->name,$label);
}
}
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
final class FiscalFormEntryTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
\Tests\Support\ProactiveInvestmentFixture::guard();
$this->withoutVite();
}
public function test_both_fiscal_forms_are_public_and_do_not_redirect_to_legal_documents(): void
{
config(['portal.homologation_only' => false]);
foreach (['iva', 'irpc'] as $tax) {
$this->get('/formularios/fiscais?imposto='.$tax)
->assertOk()
->assertInertia(fn (Assert $page) => $page->component('portal/fiscal-forms')->where('initialTax', $tax));
}
}
}
@@ -0,0 +1,57 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\InvestmentGeography;
use App\Services\InvestmentZones;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class InvestorUsabilityScopeTest extends TestCase
{
use DatabaseTransactions;
protected function setUp():void{parent::setUp();\Tests\Support\ProactiveInvestmentFixture::guard();$this->withoutVite();}
public function test_public_sector_is_consultable_by_guest_and_accounts_without_promoter_grants():void
{
config(['portal.homologation_only'=>false,'portal.legacy_content_enabled'=>false,'portal.legacy_content_qa_enabled'=>false]);
$this->get('/sectores/turismo')->assertOk();
$actor=User::factory()->create();
$this->actingAs($actor)->get('/sectores/turismo')->assertOk();
$this->assertSame(0,DB::table('portal_role_assignments')->where('user_id',$actor->id)->count());
config(['portal.profile_access_enabled'=>true]);
$entry=app(\App\Services\SectorInvestment::class)->publicView(\App\Models\Sector::where('slug','turismo')->firstOrFail());
$this->assertSame('/perfil/promotor',$entry['register_href']);
$this->assertSame('Pedir acesso para cadastrar oportunidades',$entry['register_label']);
}
public function test_zones_all_equals_visible_set_and_province_and_district_never_fall_back_to_all():void
{
$zones=app(InvestmentZones::class);$geo=app(InvestmentGeography::class);
$province='059b02ca-7095-577a-8705-4d4c46896747';
$district=$geo->publicReferences()->where('parent_id',$province)->where('location_type','district')->first();
$template=$zones->visible()->firstOrFail();
$make=function($territory,$published=true)use($template){$zone=$template->replicate();$zone->id=(string)Str::uuid();$zone->code='SYNTHETIC-'.Str::random(12);$zone->name='SYNTHETIC QA zone scope';$zone->primary_territory_id=$territory;$zone->publication_status=$published?'published':'draft';$zone->save();return$zone;};
$inside=$make($district->id);$outside=$make('9e42dac8-fd9b-523f-bf59-71a7d3aa0fb8');$private=$make($district->id,false);
$all=$zones->search([])->orderBy('id')->pluck('id')->all();
$this->assertSame($zones->visible()->orderBy('id')->pluck('id')->all(),$all);
foreach([['province_id'=>$province],['province_id'=>$province,'district_id'=>$district->id]]as$selection){
$ids=$zones->search($selection)->pluck('id')->all();
$this->assertContains($inside->id,$ids);$this->assertNotContains($outside->id,$ids);$this->assertNotContains($private->id,$ids);
}
}
public function test_documented_legal_scope_covers_child_area_but_influence_does_not_become_an_installation():void
{
$zones=app(InvestmentZones::class);$geo=app(InvestmentGeography::class);$province='059b02ca-7095-577a-8705-4d4c46896747';
$district=$geo->publicReferences()->where('parent_id',$province)->where('location_type','district')->first();
$editor=User::factory()->create();$reviewer=User::factory()->create();$template=$zones->visible()->firstOrFail();$ids=[];
foreach(['legal_scope','influence']as$scope){
$zone=$template->replicate();$zone->id=(string)Str::uuid();$zone->code='SYNTHETIC-'.Str::random(12);$zone->name='SYNTHETIC QA legal coverage';$zone->primary_territory_id='9e42dac8-fd9b-523f-bf59-71a7d3aa0fb8';$zone->save();$ids[$scope]=$zone->id;
DB::table('investment_zone_locations')->insert(['id'=>(string)Str::uuid(),'zone_id'=>$zone->id,'geo_location_id'=>$scope==='legal_scope'?$province:$district->id,'scope_type'=>$scope,'source_reference'=>'SYNTHETIC isolated QA scope fixture','validation_status'=>'validated','editor_id'=>$editor->id,'reviewer_id'=>$reviewer->id,'valid_from'=>now()->subDay(),'version_no'=>1,'created_at'=>now(),'updated_at'=>now()]);
}
$matches=$zones->search(['province_id'=>$province,'district_id'=>$district->id])->pluck('id')->all();
$this->assertContains($ids['legal_scope'],$matches);$this->assertNotContains($ids['influence'],$matches);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\OpportunityManagement;
use App\Services\ProactiveSupply;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Support\ProactiveInvestmentFixture as F;
use Tests\TestCase;
final class ProactiveSupplyTest extends TestCase
{
use DatabaseTransactions;
private User $actor;
private string $territory;
private string $observation;
protected function setUp():void
{
parent::setUp();F::guard();$this->withoutVite();
config(['portal.profile_access_enabled'=>true,'portal.opportunity_specialized_drafts_enabled'=>true,'portal.managers'=>[],'investor-path.enabled'=>true,'suitecrm.enabled'=>false,'investmoz_ai.enabled'=>false]);
$this->actor=F::operator();$this->login($this->actor);
$input=F::input('Gurué');$this->territory=$input['district_id'];
$hash=hash('sha256','SYNTHETIC-QA-SUPPLY-SOURCE');
DB::table('proactive_supply_sources')->insert(['sha256'=>$hash,'name'=>'SYNTHETIC-QA-SUPPLY.xlsx','period'=>'2020','limitations'=>'Synthetic, not a real market source','receipt'=>'{}','imported_at'=>now()]);
$this->observation=hash('sha256','SYNTHETIC-QA-OBSERVATION');
$row=['id'=>$this->observation,'source_hash'=>$hash,'sheet'=>'QA','row_number'=>2,'entity_key'=>hash('sha256','QA-ENTITY'),'name'=>'PRIVATE-UNIQUE-QA-SUPPLIER','province'=>'Zambézia','district'=>'Gurué','cae'=>'33120','activity'=>'SYNTHETIC QA repair','sector'=>'SYNTHETIC QA manufacturing','source_date'=>'2020','fingerprint'=>hash('sha256','QA-FINGERPRINT'),'duplicate_of'=>null,'identity_conflict'=>false,'territory_id'=>$this->territory,'territory_resolution'=>'exact_normalized_province_district'];
DB::table('proactive_supply_observations')->insert($row);
DB::table('proactive_supply_observations')->insert([...$row,'id'=>hash('sha256','QA-REPEATED-OBSERVATION'),'row_number'=>3,'duplicate_of'=>$this->observation]);
}
private function login(User $u):void{$this->actingAs($u)->withSession(['portal.mfa_actor'=>$u->id,'portal.mfa_at'=>time(),'portal.access_revision'=>(int)$u->portal_access_revision]);}
private function url(string $extra=''):string{return '/minhas-oportunidades/oferta-interna?territory_id='.$this->territory.$extra;}
public function test_private_query_filters_supply_and_preserves_source_and_duplicate_count():void
{
$r=$this->getJson($this->url('&activity=SYNTHETIC%20QA%20repair'))->assertOk()->assertJsonPath('total',1)->assertJsonPath('duplicate_sightings',1)->assertJsonPath('rows.0.source_name','SYNTHETIC-QA-SUPPLY.xlsx')->assertJsonPath('rows.0.row_number',2)->assertJsonPath('rows.0.source_date','2020');
$this->assertStringContainsString('no-store',$r->headers->get('Cache-Control'));
$this->assertArrayNotHasKey('contact',$r->json('rows.0'));
$this->assertArrayNotHasKey('nuit',$r->json('rows.0'));
}
public function test_no_match_means_insufficient_information_not_absent_market():void
{
$this->getJson($this->url('&activity=NO-SUCH-SYNTHETIC-ACTIVITY'))->assertOk()->assertJsonPath('total',0)->assertJsonPath('status','Informação insuficiente nesta base e nestes filtros.');
}
public function test_unassigned_user_cannot_read_internal_register():void
{
$this->login(User::factory()->create());$this->getJson($this->url())->assertForbidden();
$this->getJson('/minhas-oportunidades/oferta-interna?territory_id=not-a-uuid')->assertForbidden();
}
public function test_comparison_persists_source_lineage_and_rejects_forgery_and_public_reclassification():void
{
$input=F::input('Gurué');$a=$input['proactive'];$id=(string)\Illuminate\Support\Str::uuid();
$a['evidence'][]=['id'=>$id,'interpretation'=>'SYNTHETIC QA possible complement, not a contract','verified_on'=>'2026-10-28',...app(ProactiveSupply::class)->evidence($this->actor,$this->observation,null)];
$a['supply_comparisons']=[['observation_id'=>$this->observation,'relationship'=>'complementarity','rationale'=>'SYNTHETIC QA compare servicing with tea-chain idea','limitations'=>'No current capacity or demand proved']];
$input['proactive']=$a;
$oid=$this->postJson('/minhas-oportunidades/drafts',$input)->assertCreated()->json('id');$o=Opportunity::findOrFail($oid);
$saved=app(OpportunityManagement::class)->values($o)['proactive'];$this->assertSame($this->observation,$saved['supply_comparisons'][0]['observation_id']);
$this->assertSame('internal',$saved['evidence'][2]['access']);$this->assertStringContainsString('SYNTHETIC-QA-SUPPLY.xlsx',$saved['evidence'][2]['source']);
$a['evidence'][2]['source']='Forged source';$a['evidence'][2]['statement']='Forged supply fact';
$this->putJson('/minhas-oportunidades/'.$oid.'/draft',['etag'=>app(OpportunityManagement::class)->etag($o),'proactive'=>$a,'reason'=>'SYNTHETIC QA canonical source lineage is server-resolved'])->assertOk();
$saved=app(OpportunityManagement::class)->values($o->fresh())['proactive'];$this->assertStringContainsString('PRIVATE-UNIQUE-QA-SUPPLIER',$saved['evidence'][2]['statement']);
$a['evidence'][2]['access']='public';$this->putJson('/minhas-oportunidades/'.$oid.'/draft',['etag'=>app(OpportunityManagement::class)->etag($o),'proactive'=>$a,'reason'=>'SYNTHETIC QA internal list cannot become public'])->assertUnprocessable();
$a['evidence'][2]['access']='internal';$a['supply_comparisons'][0]['observation_id']=str_repeat('f',64);
$this->putJson('/minhas-oportunidades/'.$oid.'/draft',['etag'=>app(OpportunityManagement::class)->etag($o),'proactive'=>$a,'reason'=>'SYNTHETIC QA unknown supply observation rejected'])->assertUnprocessable();
}
public function test_sector_filter_does_not_infer_absence_and_comparison_requires_its_evidence():void
{
$this->getJson($this->url('&sector=NO-SUCH-SECTOR'))->assertOk()->assertJsonPath('total',0);
$input=F::input('Gurué');$input['proactive']['supply_comparisons']=[['observation_id'=>$this->observation,'relationship'=>'partner','rationale'=>'SYNTHETIC QA prospective partner','limitations'=>'Not contacted, no agreement']];
$this->postJson('/minhas-oportunidades/drafts',$input)->assertUnprocessable();
}
public function test_database_policy_hides_internal_rows_without_actor_context():void
{
$role='supply_qa_'.bin2hex(random_bytes(6));
DB::statement('CREATE ROLE '.$role.' NOLOGIN');
DB::statement('GRANT USAGE ON SCHEMA public TO '.$role);
DB::statement('GRANT SELECT ON proactive_supply_sources,proactive_supply_observations TO '.$role);
DB::select("select set_config('app.actor_user_id','',true),set_config('app.proactive_supply_read','',true)");
try{
DB::statement('SET LOCAL ROLE '.$role);
$this->assertSame(0,DB::table('proactive_supply_observations')->count());
$this->assertSame(0,DB::table('proactive_supply_sources')->count());
DB::select("select set_config('app.actor_user_id',?,true),set_config('app.proactive_supply_read','internal',true)",[(string)$this->actor->id]);
$this->assertNotNull(DB::table('proactive_supply_observations')->where('id',$this->observation)->first());
}finally{DB::statement('RESET ROLE');}
}
public function test_preserved_repetitions_cannot_inflate_saved_comparisons():void
{
$this->expectException(\Illuminate\Validation\ValidationException::class);
$this->expectExceptionMessage('Esta observação administrativa é repetida.');
$comparison=['relationship'=>'competition','rationale'=>'SYNTHETIC QA duplicate must not count as another competitor','limitations'=>'Historical observation,not a unique company'];
app(ProactiveSupply::class)->comparisons($this->actor,[
[...$comparison,'observation_id'=>$this->observation],
[...$comparison,'observation_id'=>hash('sha256','QA-REPEATED-OBSERVATION')],
],null);
}
}
@@ -24,6 +24,10 @@ class OpportunityCorridorExplorerTest extends TestCase
$catalogue->shouldReceive('query')->with($filters)->once()->andReturn($query);
$catalogue->shouldReceive('context')->with(101)->once()->andReturn(['corridors' => collect([(object)['id'=>'a','name'=>'Corredor A'],(object)['id'=>'b','name'=>'Corredor B']])]);
$catalogue->shouldReceive('context')->with(102)->once()->andReturn(['corridors'=>collect()]);
$first->setAttribute('territorial_summary','Sofala / Dondo');
$other->setAttribute('territorial_summary','Localização não indicada');
$catalogue->shouldReceive('presentLocation')->with($first)->once()->andReturn($first);
$catalogue->shouldReceive('presentLocation')->with($other)->once()->andReturn($other);
$this->app->instance(InvestmentCatalogue::class, $catalogue);
$groups = app(OpportunityCorridorExplorer::class)->groups($filters);
$this->assertSame(['Corredor A','Corredor B','Outras localizações'], array_column($groups,'name'));
@@ -32,5 +36,6 @@ class OpportunityCorridorExplorerTest extends TestCase
$this->assertSame(['Sofala / Dondo'], $groups[0]['locations']);
$this->assertSame([], $groups[2]['locations']);
$this->assertSame([], $groups[0]['opportunities'][0]['stages']);
$this->assertSame('Sofala / Dondo',$groups[0]['opportunities'][0]['territorial_summary']);
}
}
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import fs from 'node:fs';
import {createRequire} from 'node:module';
import React from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import ts from 'typescript';
const require=createRequire(import.meta.url);
const source=fs.readFileSync(new URL('../../resources/js/components/catalogue-guide-links.tsx',import.meta.url),'utf8');
const compiled=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,jsx:ts.JsxEmit.ReactJSX}}).outputText;
const module={exports:{}};
new Function('require','module','exports',compiled)(name=>name==='@inertiajs/react'?{Link:({href,children,...props})=>React.createElement('a',{href,...props},children)}:require(name),module,module.exports);
const render=articles=>renderToStaticMarkup(React.createElement(module.exports.default,{articles}));
test('catalogue guides are closed optional links, not educational bodies or nationwide opportunity shortcuts',()=>{
const html=render([{title:'Turismo',href:'/artigos/turismo',summary:'EDUCATIONAL-BODY-NOT-A-RESULT',image:{href:'/photo.jpg',alt:'ILLUSTRATION'},opportunities_href:'/oportunidades?sector=turismo',opportunities_label:'ALL-NATIONAL-OPPORTUNITIES',has_opportunities:true}]);
assert.match(html,/<details\b/);assert.doesNotMatch(html,/<details[^>]*\bopen\b/);
assert.match(html,/apoio opcional/);assert.match(html,/href="\/artigos\/turismo"/);
for(const forbidden of ['EDUCATIONAL-BODY-NOT-A-RESULT','ALL-NATIONAL-OPPORTUNITIES','/oportunidades?sector=turismo','photo.jpg','<img'])assert.ok(!html.includes(forbidden),forbidden);
});
test('no educational substitute is rendered when no guides are available',()=>{
assert.equal(render([]),'');
});
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
assessmentReady,
diagnosticReady,
forces,
newCompetitivenessDraft,
readCompetitivenessDraft,
} from '../../resources/js/lib/cluster-competitiveness.ts';
function complete() {
const draft = newCompetitivenessDraft('cluster-a', 'Energia');
draft.region = 'Distrito de demonstração';
for (const force of forces)
Object.assign(draft.forces[force], {
pressure: 'medium',
evidence: 'Observação de demonstração',
source: 'Fonte de demonstração, p. 2',
period: '2025',
rationale: 'Pressão fundamentada na observação',
limitations: 'Amostra parcial',
opportunity: 'Investigar capacidade de armazenamento',
});
return draft;
}
test('ausência de dados permanece indeterminada nas cinco forças independentes', () => {
const draft = newCompetitivenessDraft('cluster-a', 'Energia');
assert.equal(forces.length, 5);
assert.equal(diagnosticReady(draft), false);
draft.forces.buyers.evidence = 'Observação';
assert.equal(draft.forces.suppliers.evidence, '');
assert.ok(forces.every((force) => draft.forces[force].pressure === ''));
});
test('classificação exige todos os fundamentos; âmbito é necessário para o diagnóstico', () => {
const draft = complete();
assert.equal(diagnosticReady(draft), true);
for (const field of [
'evidence',
'source',
'period',
'rationale',
'limitations',
'opportunity',
]) {
const row = { ...draft.forces.buyers, [field]: ' ' };
assert.equal(assessmentReady(row), false, field);
}
draft.region = '';
assert.equal(diagnosticReady(draft), false);
});
test('ficheiro conserva rascunhos, dados e incertezas sem modificar o original', () => {
const draft = complete();
draft.forces.entrants.pressure = '';
const restored = readCompetitivenessDraft(
JSON.stringify(draft),
'cluster-a',
);
assert.deepEqual(restored, draft);
restored.forces.buyers.source = 'Outra fonte';
assert.notEqual(restored.forces.buyers.source, draft.forces.buyers.source);
assert.equal(diagnosticReady(restored), false);
});
test('recuperação rejeita outro cluster, versões, forças ausentes e campos inválidos', () => {
assert.throws(() =>
readCompetitivenessDraft(JSON.stringify(complete()), 'cluster-b'),
);
for (const mutate of [
(d) => {
d.version = 2;
},
(d) => {
delete d.forces.buyers;
},
(d) => {
d.forces.buyers.pressure = 'zero';
},
(d) => {
d.forces.buyers.source = 42;
},
(d) => {
d.forces.buyers.source = 'x'.repeat(5001);
},
(d) => {
d.region = null;
},
]) {
const draft = complete();
mutate(draft);
assert.throws(() =>
readCompetitivenessDraft(JSON.stringify(draft), 'cluster-a'),
);
}
assert.throws(() => readCompetitivenessDraft('null', 'cluster-a'));
assert.throws(() =>
readCompetitivenessDraft('x'.repeat(250001), 'cluster-a'),
);
});
+34
View File
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {createRequire} from 'node:module';
import React from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import ts from 'typescript';
import * as forms from '../../resources/js/lib/fiscal-form-preparation.ts';
const require=createRequire(import.meta.url);
const source=fs.readFileSync(new URL('../../resources/js/pages/portal/fiscal-forms.tsx',import.meta.url),'utf8');
const compiled=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,jsx:ts.JsxEmit.ReactJSX}}).outputText;
const module={exports:{}};
new Function('require','module','exports',compiled)(name=>name==='@inertiajs/react'?{Head:()=>null,Link:({href,children,...props})=>React.createElement('a',{href,...props},children),router:{on:()=>()=>{}}}:name==='@/layouts/portal-navigation'?{PortalMenu:()=>null}:name==='@/lib/fiscal-form-preparation'?forms:require(name),module,module.exports);
test('fiscal entry renders labelled editable fields and a guided preparation rather than legislation',()=>{
const html=renderToStaticMarkup(React.createElement(module.exports.default));
for(const label of ['Identificação e período','Nome ou designação social','NUIT','Período — ano e mês','Conferir e guardar','Guardar ficheiro para continuar','Recuperar ficheiro guardado','Confirmar recuperação']) {
// Confirmation appears only after a compatible file is selected.
if(label !== 'Confirmar recuperação') assert.ok(html.includes(label),label);
}
assert.equal((html.match(/inputMode="decimal"/g)??[]).length,27);
assert.match(html,/sem submissão à Autoridade Tributária/);
assert.match(html,/Não há gravação automática/);
assert.doesNotMatch(html,/value="0"/);
});
test('IRPC entry opens the requested form directly and keeps its partial coverage explicit',()=>{
const html=renderToStaticMarkup(React.createElement(module.exports.default,{initialTax:'irpc'}));
assert.match(html,/IRPC — Modelo 22/);
assert.match(html,/L8 permanece parcial/);
assert.match(html,/303 — Dupla tributação económica/);
assert.match(html,/304 — Dupla tributação internacional/);
assert.match(html,/Não utilize a mesma parcela/);
assert.equal((html.match(/inputMode="decimal"/g)??[]).length,24);
assert.doesNotMatch(html,/01 — Base das operações/);
});
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {newFiscalPreparation, readFiscalPreparation, fiscalPreparationIssues, fiscalFormFields} from '../../resources/js/lib/fiscal-form-preparation.ts';
test('IVA and IRPC preparations round trip deliberate entries and unknown amounts without zero defaults', () => {
for (const kind of ['iva', 'irpc']) {
const p = newFiscalPreparation(kind);
p.name = 'Investidor de teste'; p.nuit = '123456789'; p.period = kind === 'iva' ? '2026-10' : '2026';
const [first, second] = fiscalFormFields(kind);
p.values[first.code] = '123456789012345678901234,01';
p.values[second.code] = '';
const restored = readFiscalPreparation(JSON.stringify(p));
assert.deepEqual(restored, p);
assert.deepEqual(fiscalPreparationIssues(restored), []);
assert.equal(restored.values[second.code], '');
assert.equal(Object.values(restored.values).includes('0'), false);
}
});
test('foreign editions, unknown fields and malformed or oversized files cannot replace preparation', () => {
const p = newFiscalPreparation('iva');
for (const v of [{...p, sourceHash: 'different'}, {...p, values: {'999': '1'}}, {...p, values: []}, {...p, nuit: 123}, {...p, version: 'later'}]) assert.throws(() => readFiscalPreparation(JSON.stringify(v)));
assert.throws(() => readFiscalPreparation('{broken'));
assert.throws(() => readFiscalPreparation('x'.repeat(100001)));
assert.equal(p.kind, 'iva'); assert.deepEqual(p.values, {});
});
test('invalid format is identified without rounding or discarding an incomplete preparation', () => {
const p = newFiscalPreparation('irpc');
p.values['303'] = '1.234'; p.values['304'] = '25,00';
const restored = readFiscalPreparation(JSON.stringify(p));
assert.equal(restored.values['303'], '1.234');
assert.ok(fiscalPreparationIssues(restored).some(i => i.startsWith('Campo 303:')));
assert.equal(fiscalFormFields('irpc').find(f => f.code === '303').label, 'Dupla tributação económica');
assert.equal(fiscalFormFields('irpc').find(f => f.code === '304').label, 'Dupla tributação internacional');
});
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import fs from 'node:fs';
import {createRequire} from 'node:module';
import React from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import ts from 'typescript';
import * as flow from '../../resources/js/lib/opportunity-fill-flow.ts';
import { fiscalPreparationGuide } from '../../resources/js/lib/fiscal-preparation-guide.ts';
const require=createRequire(import.meta.url);
function load(path,mocks={},exportName='default',extra=''){
const source=fs.readFileSync(new URL(path,import.meta.url),'utf8');
const output=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,jsx:ts.JsxEmit.ReactJSX}}).outputText;
const module={exports:{}};new Function('require','module','exports',output+extra)(name=>mocks[name]??(name.startsWith('@/')?{default:()=>null}:require(name)),module,module.exports);return module.exports[exportName];
}
function stateHooks(){const memory=[];let cursor=0;return{hooks:{...React,useState(initial){const i=cursor++;if(!(i in memory))memory[i]=typeof initial==='function'?initial():initial;return[memory[i],next=>memory[i]=typeof next==='function'?next(memory[i]):next];},useRef:()=>({current:null}),useEffect:()=>{}},reset:()=>cursor=0};}
const flat=node=>!node||typeof node!=='object'?[]:[node,...React.Children.toArray(node.props?.children).flatMap(flat)];
test('free navigation keeps every field mounted and reports resource work in the current group',()=>{
const state=stateHooks();const Component=load('../../resources/js/components/opportunity-fill-flow.tsx',{'react':state.hooks,'@/lib/opportunity-fill-flow':flow});
const groups=flow.opportunityFillGroups(['official_title','locations','documents']);const saved={official_title:'Deliberate user title',locations:['saved-site']};
const opened=[];let tree;
const render=()=>{state.reset();tree=Component({groups,errors:{},renderField:field=>React.createElement('input',{name:field,value:JSON.stringify(saved[field]??''),readOnly:true}),onOpenResources:group=>opened.push(group)});};
const priorWindow=globalThis.window,priorFrame=globalThis.requestAnimationFrame;
globalThis.window={history:{state:{existing:'inertia-state'},replaceState(state,_title,url){assert.deepEqual(state,{existing:'inertia-state'});this.url=url;}}};globalThis.requestAnimationFrame=callback=>callback();
try{
render();assert.equal(flat(tree).filter(node=>node.type==='input').length,3);
flat(tree).find(node=>node.type==='a'&&node.props.href==='#fill-territory').props.onClick({preventDefault(){}});render();
const sections=flat(tree).filter(node=>node.type==='section');
assert.equal(sections.find(node=>node.props.id==='fill-territory').props.hidden,false);
assert.equal(sections.find(node=>node.props.id==='fill-idea').props.hidden,true);
assert.equal(flat(tree).filter(node=>node.type==='input').length,3);
flat(tree).find(node=>node.type==='button').props.onClick();assert.deepEqual(opened,['territory']);
assert.equal(saved.official_title,'Deliberate user title');assert.deepEqual(saved.locations,['saved-site']);
assert.equal(globalThis.window.history.url,'#fill-territory');
}finally{globalThis.window=priorWindow;globalThis.requestAnimationFrame=priorFrame;}
});
test('profile request renders readable labels without dumping every available document into the form',()=>{
const Picker=load('../../resources/js/components/profile-proof-picker.tsx');
const Page=load('../../resources/js/pages/profiles/request.tsx',{
'@/lib/operation-key':{createOperationKey:()=> 'synthetic-key'},
'@inertiajs/react':{Head:()=>null,Link:({href,children})=>React.createElement('a',{href},children),router:{}},
'@/components/profile-navigation':{default:()=>null},'@/lib/profile-json':{},
'@/pages/management/shared':{button:'button',control:'control'},
'@/components/opportunity-search-filters':{modalityLabels:{investment_project:'Projecto de investimento',land_rights:'Direitos fundiários'}},
'@/components/profile-proof-picker':{default:Picker},
});
const html=renderToStaticMarkup(React.createElement(Page,{requests:[],types:['investment_project','land_rights'],organizations:[],documents:Array.from({length:80},(_,i)=>({id:`doc-${i}`,file_name:`UNSELECTED-DOCUMENT-${i}.txt`}))}));
assert.match(html,/Em nome próprio/);assert.match(html,/Entidade pública/);assert.match(html,/Projecto de investimento/);assert.match(html,/Direitos fundiários/);
assert.doesNotMatch(html,/UNSELECTED-DOCUMENT/);assert.match(html,/Pesquisar nos meus ficheiros/);
const visible=html.replace(/<[^>]+>/g,'');assert.doesNotMatch(visible,/investment_project|land_rights|public_entity/);
});
test('proof selection is explicit and retained when the filename search changes',()=>{
const state=stateHooks();const Component=load('../../resources/js/components/profile-proof-picker.tsx',{'react':state.hooks});
const documents=[{id:'identity',file_name:'Documento próprio'},{id:'law',file_name:'Lei.pdf'}];const values=[];let tree;
const render=()=>{state.reset();tree=Component({documents,value:values,onChange:ids=>{values.splice(0,values.length,...ids);}});};
render();assert.equal(flat(tree).filter(node=>node.type==='input'&&node.props.type==='checkbox').length,0);
flat(tree).find(node=>node.type==='input').props.onChange({target:{value:'próprio'}});render();
flat(tree).find(node=>node.type==='input'&&node.props.type==='checkbox').props.onChange();render();assert.deepEqual(values,['identity']);
flat(tree).find(node=>node.type==='input'&&node.props.type==='search').props.onChange({target:{value:'Lei'}});render();
assert.deepEqual(values,['identity']);assert.equal(flat(tree).filter(node=>node.type==='input'&&node.props.type==='checkbox')[0].props.checked,false);
});
test('document entry leads to existing fillable preparation and keeps sources optional',()=>{
const context='10000000-0000-4000-8000-000000000001';
const inertia={Link:({href,children})=>React.createElement('a',{href},children),usePage:()=>({url:`/documentos?context=${context}`})};
const Hub=load('../../resources/js/components/journey-document-resources.tsx',{'@inertiajs/react':inertia,'@/lib/fiscal-preparation-guide':{fiscalPreparationGuide}},'JourneyDocumentResources');
const hub=renderToStaticMarkup(React.createElement(Hub,{}));
assert.match(hub,new RegExp(`/investimentos/${context}/contexto/editar#preparation-questions`));assert.match(hub,/href="\/terra-duat"/);
assert.match(hub,/Preparação digital e exemplar oficial/);assert.doesNotMatch(hub,/<details[^>]*\bopen\b/);
const Editor=load('../../resources/js/components/private-investment-preparation.tsx',{'@inertiajs/react':inertia,'@/pages/investment/shared':{card:'card',input:'input'}},'PreparationEditor');
const html=renderToStaticMarkup(React.createElement(Editor,{data:{questions:[{key:'investment_preparation',label:'Preparar pedido?',options:{yes:'Sim',no:'Não'},source:'SYNTHETIC QA'},{key:'declared_amount',label:'Valor do investimento',type:'number',source:'SYNTHETIC QA'}],requirements:[],enabled:true,mode:'authorization',missing_fields:['declared_amount']},fields:{investment_preparation:'yes'},changes:{},documents:[],links:{},onChange(){},onLink(){},disabled:false}));
assert.match(html,/<input/);assert.match(html,/Valor do investimento/);assert.match(html,/id="preparation-questions"/);
});
test('investment stages expose readable next actions while internal codes and sources remain consultable',()=>{
const useForm=data=>({data,errors:{},processing:false,setData(){},transform(){return this;},post(){}});
const Stage=load('../../resources/js/pages/investor-path/show.tsx',{'@inertiajs/react':{useForm,Link:({href,children})=>React.createElement('a',{href},children)},'@/lib/investor-path':{pathStates:{not_started:'Por iniciar'}}},'Stage','\nmodule.exports.Stage=Stage;');
const step={code:'S01',title:'Sistematizar a ideia',state:'not_started',detail:{scope:'promoter_preparation'},preparation_allowed:true,action_gaps:{},source_paragraph:22,applicability:'applicable'};
const html=renderToStaticMarkup(React.createElement(Stage,{step,path:{case:{id:'synthetic',version:1},next:{code:'S01'},canWrite:true,canReview:false,steps:[step],documents:[]}}));
assert.match(html,/<details[^>]*id="S01"[^>]*open/);assert.match(html,/<summary[^>]*>Sistematizar a ideia/);
assert.doesNotMatch(html,/<summary[^>]*>S01/);assert.match(html,/Fonte e referência desta etapa/);
});
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import fs from 'node:fs';
import {createRequire} from 'node:module';
import React from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import ts from 'typescript';
import {portalNavigation,currentNavigationItem} from '../../resources/js/lib/portal-navigation.ts';
import {searchTerritoryHref} from '../../resources/js/lib/search-territory.ts';
// Shallow SSR of the actual navigation component. Inertia and visual primitives
// are replaced by semantic wrappers; this is not a browser/viewport proof.
const require=createRequire(import.meta.url);
const source=fs.readFileSync(new URL('../../resources/js/components/portal-sidebar-navigation.tsx',import.meta.url),'utf8');
const output=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,jsx:ts.JsxEmit.ReactJSX}}).outputText;
const primitive=(tag)=>({children,asChild,...props})=>asChild?React.cloneElement(children,{}):React.createElement(tag,props,children);
const module={exports:{}};
new Function('require','module','exports',output)((name)=>{
if(name==='@inertiajs/react')return {Link:({href,children,...props})=>React.createElement('a',{href,...props},children)};
if(name==='@/lib/portal-navigation')return {currentNavigationItem};
if(name==='@/lib/search-territory')return {searchTerritoryHref};
if(name==='lucide-react')return {LayoutGrid:()=>null};
if(name==='@/components/ui/sidebar')return {SidebarGroup:primitive('section'),SidebarGroupLabel:primitive('h2'),SidebarMenu:primitive('ul'),SidebarMenuButton:primitive('div'),SidebarMenuItem:primitive('li')};
return require(name);
},module,module.exports);
const Component=module.exports.PortalSidebarNavigation;
const flatten=(rows)=>rows.flatMap(r=>[...(r.href?[r.href]:[]),...flatten(r.children??[])]);
const render=(access,url='/')=>{const groups=portalNavigation(access);return {groups,html:renderToStaticMarkup(React.createElement(Component,{groups,url}))};};
void test('sidebar renders every nested authorised destination instead of a first-child shortcut',()=>{
const {groups,html}=render({authenticated:true,manager:true,businesses:true,education:true,ai:true,contentManagementHref:'/gestao-conteudos',contentManagementAccess:{opportunities:{index:'/management/opportunities',create:'/management/opportunities/create'}},scopedAdministration:[{label:'Referências económicas',href:'/management/economic-references/lot-01'}]});
const hrefs=[...html.matchAll(/<a\b[^>]*href="([^"]+)"/g)].map(m=>m[1].replaceAll('&amp;','&'));
assert.deepEqual([...hrefs].sort(),flatten(groups).sort());
assert.equal(hrefs[0],'/gestao-conteudos');
for(const href of ['/carteira','/zonas','/documentos','/legislacao','/management/opportunities','/management/economic-references/lot-01'])assert.ok(hrefs.includes(href),href);
assert.match(html,/Área de trabalho/);
});
void test('nested current destination is selected without changing its URL or replacing it with a group root',()=>{
const {html}=render({authenticated:true,manager:false},'/zonas?category=zee');
const current=[...html.matchAll(/<a\b([^>]*aria-current="page"[^>]*)>/g)];
assert.equal(current.length,1);
assert.match(current[0][1],/href="\/zonas"/);
});
void test('rendered visitor sidebar explains access without private operation links',()=>{
const {html}=render({authenticated:false,manager:false});
assert.match(html,/href="\/acesso-gestao"/);
assert.doesNotMatch(html,/href="\/management\//);
assert.doesNotMatch(html,/analysis=proactive/);
});
void test('sidebar carries the existing search territory when moving between catalogues',()=>{
const {html}=render({authenticated:false,manager:false},'/oportunidades?province_id=QA-province&district_id=QA-district');
assert.match(html,/href="\/carteira\?province_id=QA-province&amp;district_id=QA-district"/);
assert.match(html,/href="\/zonas\?province_id=QA-province&amp;district_id=QA-district"/);
assert.match(html,/href="\/acesso-gestao"/);
});
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {opportunityFillGroups,opportunityErrorGroup,opportunityMissingGroups} from '../../resources/js/lib/opportunity-fill-flow.ts';
import {territorialSearchParams} from '../../resources/js/lib/territorial-search-params.ts';
import {documentFacetLabel} from '../../resources/js/lib/document-facet-label.ts';
test('adaptive groups keep every applicable schema field once, including new specialized fields',()=>{
const keys=['official_title','locations','sector_id','documents','currency','real_estate','future_schema_field','currency'];
const groups=opportunityFillGroups(keys);
assert.deepEqual(groups.flatMap(group=>group.fields).sort(),[...new Set(keys)].sort());
assert.ok(groups.find(group=>group.id==='specific').fields.includes('future_schema_field'));
assert.deepEqual(opportunityFillGroups(['official_title']).map(group=>group.id),['idea']);
assert.deepEqual(opportunityFillGroups([]),[]);
});
test('saved pending fields and validation errors point to the correct existing group without inventing applicability',()=>{
const groups=opportunityFillGroups(['official_title','locations','total_investment']);
assert.equal(opportunityErrorGroup(groups,{'canonical.total_investment':'Valor em falta'}),'finance');
assert.equal(opportunityErrorGroup(groups,{'canonical.locations.0':'Local em falta'}),'territory');
assert.deepEqual(opportunityMissingGroups(groups,['locations.0','canonical.total_investment']).map(group=>group.id),['territory','finance']);
assert.equal(opportunityErrorGroup(groups,{reason:'Motivo em falta'}),undefined);
});
test('territorial search replaces old descendants while retaining all selected non-territorial filters',()=>{
const entries=[['province_id','old'],['district_id','old-child'],['locality_id','old-leaf'],['category','zee'],['q','Nacala'],['page','5']];
const scoped=territorialSearchParams(entries,[{id:'new',location_type:'province'}]);
assert.equal(scoped.get('province_id'),'new');assert.equal(scoped.has('district_id'),false);assert.equal(scoped.has('locality_id'),false);
assert.equal(scoped.get('category'),'zee');assert.equal(scoped.get('q'),'Nacala');assert.equal(scoped.has('page'),false);
const all=territorialSearchParams(entries,[]);assert.equal(all.has('province_id'),false);assert.equal(all.get('category'),'zee');
const unchanged=territorialSearchParams(entries);assert.equal(unchanged.get('district_id'),'old-child');
});
test('document facets have readable labels while original query identifiers remain unchanged',()=>{
const original=['agronegocio','education','media','pt','turismo'];
assert.deepEqual(original.map(documentFacetLabel),['Agronegócio','Formação e competências','Imagens e conteúdos de apoio','Português','Turismo']);
assert.deepEqual(original,['agronegocio','education','media','pt','turismo']);
assert.equal(documentFacetLabel('Tema definido pela Tempo'),'Tema definido pela Tempo');
});
+47 -2
View File
@@ -9,6 +9,7 @@ import {
legacyPortalNavigation,
currentNavigationGroup,
currentNavigationItem,
canShowLegacyManagement,
} from '../../resources/js/lib/portal-navigation.ts';
void test('public and authenticated navigation share groups without revealing management', () => {
@@ -20,7 +21,7 @@ void test('public and authenticated navigation share groups without revealing ma
assert.deepEqual(publicGroups.slice(0, 3), investor.slice(0, 3));
assert.deepEqual(
publicGroups.map((g) => g.id),
['discovery', 'realization', 'support', 'account'],
['discovery', 'realization', 'support', 'work', 'account'],
);
const hrefs = JSON.stringify(investor);
assert.equal(hrefs.includes('/management/'), false);
@@ -37,7 +38,7 @@ void test('management and optional modules follow permissions and feature flags'
education: true,
ai: true,
});
assert.ok(groups.some((group) => group.id === 'management'));
assert.ok(groups.some((group) => group.id === 'work'));
assert.equal(JSON.stringify(groups).includes('/investidor/negocios'), true);
assert.equal(JSON.stringify(groups).includes('/management/ai'), true);
assert.equal(
@@ -151,3 +152,47 @@ void test('catalogue detail and return preserve the search inside the local cata
'/zonas?https%3A%2F%2Foutside.invalid=',
);
});
const destinations=(items)=>items.flatMap(item=>[...(item.href?[item.href]:[]),...destinations(item.children??[])]);
void test('both menu consumers respect the existing scoped server decision rather than generic manager status',()=>{
assert.equal(canShowLegacyManagement({user:{id:1},canManagePortal:true,legacyManagementAccess:false}),false);
assert.equal(canShowLegacyManagement({user:{id:1},canManagePortal:true,legacyManagementAccess:true}),true);
assert.equal(canShowLegacyManagement({user:null,canManagePortal:true,legacyManagementAccess:true}),false);
assert.equal(canShowLegacyManagement({user:{id:1},canManagePortal:true}),true);
});
void test('work entry is discoverable without exposing private capabilities to an anonymous visitor',()=>{
const groups=portalNavigation({authenticated:false,manager:true,contentManagementHref:'/gestao-conteudos',contentManagementAccess:{opportunities:{index:'/management/opportunities',create:'/management/opportunities/create'}},scopedAdministration:[{label:'Private QA administration',href:'/management/secret'}]});
assert.deepEqual(destinations(groups.find(g=>g.id==='work').children),['/acesso-gestao']);
assert.equal(JSON.stringify(groups).includes('/management/'),false);
assert.equal(JSON.stringify(groups).includes('/gestao-conteudos'),false);
});
void test('work area exposes existing content capability even when the old dashboard flag has no menu object',()=>{
const groups=portalNavigation({authenticated:true,manager:true,opportunityManagement:null,contentManagementHref:'/gestao-conteudos',contentManagementAccess:{opportunities:{index:'/management/opportunities',create:'/management/opportunities/create'}}});
const work=groups.find(g=>g.id==='work');
assert.equal(work.label,'Área de trabalho');
assert.ok(work.children.some(i=>i.label==='Preparar análise e ideia'&&i.href==='/management/opportunities/create?analysis=proactive'));
assert.ok(work.children.some(i=>i.href==='/gestao-conteudos'));
assert.ok(work.children.some(i=>i.href==='/management/opportunities'));
});
void test('every previously authorised management/profile destination survives the regrouping exactly once',()=>{
const access={authenticated:true,manager:true,businesses:true,education:true,ai:true,contentManagementHref:'/gestao-conteudos',profileAccess:{contexts:[],mine:'/minhas-oportunidades',create:'/minhas-oportunidades/create',request:'/perfil/promotor',review:null,audit:'/perfis/auditoria',administration:'/perfis/gestao'},scopedAdministration:[{label:'Referências económicas',href:'/management/economic-references/lot-01'}]};
const old=destinations(legacyPortalNavigation(access).filter(g=>['management','profile-opportunities'].includes(g.id)));
const groups=portalNavigation(access);const after=destinations(groups);
for(const href of old)assert.equal(after.filter(x=>x===href).length,1,href);
assert.equal(after.length,new Set(after).size);
assert.ok(destinations(groups.find(g=>g.id==='work').children).includes('/management/economic-references/lot-01'));
assert.equal(after.filter(href=>href==='/management/profile-install').length,1,'profile installation already present in the old authorised sidebar must survive');
});
void test('ordinary authenticated account has an explanation entry and does not acquire creation or management links',()=>{
const groups=portalNavigation({authenticated:true,manager:false});
assert.deepEqual(destinations(groups.find(g=>g.id==='work').children),['/acesso-gestao']);
assert.equal(JSON.stringify(groups).includes('analysis=proactive'),false);
assert.equal(JSON.stringify(groups).includes('/management/'),false);
});
void test('direct analysis and ordinary creation preserve distinct selection state in the same work group',()=>{
const access={authenticated:true,manager:false,profileAccess:{contexts:[],mine:'/minhas-oportunidades',create:'/minhas-oportunidades/create',request:'/perfil/promotor',review:null,administration:null}};
const groups=portalNavigation(access);
assert.equal(currentNavigationGroup(groups,'/minhas-oportunidades/create?analysis=proactive'),'work');
assert.equal(currentNavigationItem(groups,'/minhas-oportunidades/create?analysis=proactive'),'prepare-analysis');
assert.equal(currentNavigationItem(groups,'/minhas-oportunidades/create'),'new-my-opportunity');
});
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import fs from 'node:fs';
import {createRequire} from 'node:module';
import React from 'react';
import ts from 'typescript';
// Exercise the real component's event/state contract; this is not browser visual QA.
const require=createRequire(import.meta.url);
const source=fs.readFileSync(new URL('../../resources/js/components/territory-search-select.tsx',import.meta.url),'utf8');
const compiled=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,jsx:ts.JsxEmit.ReactJSX}}).outputText;
function harness(overrides={}){
const memory=[];let cursor=0;const chosen=[];
const hooks={...React,useState(initial){const i=cursor++;if(!(i in memory))memory[i]=initial;return[memory[i],value=>{memory[i]=typeof value==='function'?value(memory[i]):value;}];},useRef:()=>({current:null}),useEffect:()=>{}};
const module={exports:{}};
new Function('require','module','exports',compiled)(name=>name==='react'?hooks:require(name),module,module.exports);
const options=[{id:'province-a',name:'Nampula'},{id:'province-b',name:'Maputo Província'}];
const props={id:'territory',label:'Província',search:'',options,onSearch(query){props.search=query;},onChange(option){chosen.push(option);props.selected=option;},...overrides};
let tree;
const flat=node=>!node||typeof node!=='object'?[]:[node,...React.Children.toArray(node.props?.children).flatMap(flat)];
const render=()=>{cursor=0;tree=module.exports.default(props);return tree;};
const find=predicate=>flat(tree).find(predicate);
const input=()=>find(node=>node.type==='input');
const key=value=>input().props.onKeyDown({key:value,preventDefault(){}});
render();return{props,chosen,render,find,input,key,options};
}
test('one search input owns the dropdown; the separate select is absent',()=>{
const h=harness();assert.equal(h.find(node=>node.type==='select'),undefined);
assert.equal(h.input().props.role,'combobox');assert.equal(h.input().props['aria-expanded'],false);
h.input().props.onFocus();h.render();
assert.equal(h.input().props['aria-expanded'],true);
assert.ok(h.find(node=>node.props?.role==='listbox'));
h.find(node=>node.props?.role==='option'&&node.props.id==='territory-options-1').props.onClick();h.render();
assert.deepEqual(h.chosen,[h.options[1]]);assert.equal(h.input().props.value,'Maputo Província');
assert.equal(h.input().props['aria-expanded'],false);
});
test('typing and Escape preserve the existing selection until an option is explicitly chosen',()=>{
const selected={id:'saved',name:'Selecção guardada fora desta página'};const h=harness({selected});
h.input().props.onFocus();h.render();h.input().props.onChange({target:{value:'Map'}});h.render();
assert.equal(h.props.search,'Map');assert.deepEqual(h.chosen,[]);
h.key('Escape');h.render();assert.equal(h.input().props.value,selected.name);assert.deepEqual(h.chosen,[]);
});
test('arrow keys and Enter choose from the list; loading cannot commit an old result',()=>{
const h=harness();h.input().props.onFocus();h.render();h.key('ArrowDown');h.render();h.key('ArrowDown');h.render();
assert.equal(h.input().props['aria-activedescendant'],'territory-options-1');
h.key('Enter');h.render();assert.deepEqual(h.chosen,[h.options[1]]);
const busy=harness({loading:true});busy.input().props.onFocus();busy.render();busy.key('Enter');
assert.deepEqual(busy.chosen,[]);
});
test('clear is explicit; disabled dependent levels cannot expose the dropdown',()=>{
const h=harness({selected:{id:'saved',name:'Nampula'}});
h.find(node=>node.props?.['aria-label']==='Limpar Província').props.onClick();h.render();
assert.deepEqual(h.chosen,[undefined]);assert.equal(h.input().props.value,'');
const disabled=harness({disabled:true});assert.equal(disabled.input().props.disabled,true);
assert.equal(disabled.find(node=>node.props?.role==='listbox'),undefined);
});
+55
View File
@@ -0,0 +1,55 @@
"""Map only unique canonical names within the same province, then export privately."""
import argparse
import hashlib
import json
from pathlib import Path
import sqlite3
import unicodedata
def norm(v):
return ' '.join(''.join(c for c in unicodedata.normalize('NFKD',v).casefold() if not unicodedata.combining(c)).split())
p=argparse.ArgumentParser()
p.add_argument('--preview',required=True)
p.add_argument('--geography',required=True)
a=p.parse_args()
root=Path(a.preview)
geos=json.loads(Path(a.geography).read_text(encoding='utf-8'))
byid={r['id']:r for r in geos}
territories={}
provinces={}
subterritories={}
def province_name(v):
n=norm(v)
return 'maputo provincia' if n=='maputo' else n
for g in geos:
if g['location_type']=='province':provinces.setdefault(norm(g['name']),[]).append(g['id'])
if g['location_type'] not in ('district','admin_post','locality'):continue
parent=byid.get(g['parent_id']);visited=set()
while parent and parent['location_type']!='province' and parent['id'] not in visited:
visited.add(parent['id']);parent=byid.get(parent['parent_id'])
if not parent:continue
target=territories if g['location_type']=='district' else subterritories
target.setdefault((norm(parent['name']),norm(g['name'])),[]).append(g['id'])
db=sqlite3.connect(root/'supply-preview.sqlite')
db.row_factory=sqlite3.Row
resolution={}
with (root/'observations.jsonl').open('w',encoding='utf-8',newline='\n') as f:
for row in db.execute('select * from records order by source_hash,sheet,row_number'):
r=dict(row)
key=(province_name(r['province']),norm(r['district']))
hits=territories.get(key,[])
lower_level=False
if not hits:
hits=subterritories.get(key,[])
lower_level=bool(hits)
province_hits=provinces.get(province_name(r['province']),[])
province_only=not r['district'] and len(province_hits)==1
r['territory_id']=hits[0] if len(hits)==1 else (province_hits[0] if province_only else None)
r['territory_resolution']=('exact_normalized_province_subterritory' if lower_level else 'exact_normalized_province_district') if len(hits)==1 else ('province_only_district_unknown' if province_only else ('ambiguous' if hits else 'unresolved'))
r['identity_conflict']=bool(r['identity_conflict'])
resolution[r['territory_resolution']]=resolution.get(r['territory_resolution'],0)+1
f.write(json.dumps(r,ensure_ascii=False)+'\n')
result={'rows':db.execute('select count(*) from records').fetchone()[0],'territory_resolution':resolution,'sha256':hashlib.file_digest((root/'observations.jsonl').open('rb'),'sha256').hexdigest(),'mapping':'case/accent/whitespace; exact district in province, otherwise unique named subordinate territory within that province; source label Maputo distinct from Maputo Cidade maps explicitly to Maputo Província; city names retain canonical administrative level, no fuzzy matching','publication':False}
(root/'export.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
print(json.dumps(result,ensure_ascii=False))
+136
View File
@@ -0,0 +1,136 @@
"""Controlled delivery of the accepted internal snapshot. Never publishes entities.
Preview is the default. Apply is an authenticated deployment operation, guarded by
the exact destination, accepted source hashes and a fresh verified backup/restore.
The rehearsal uses a disposable network-isolated PostgreSQL container only.
"""
import argparse
import datetime as dt
import gzip
import hashlib
import json
import os
from pathlib import Path
import subprocess
BASELINE = '569a1cd750f3d2a0b87f130e992e73e070bbd1ab'
RAW_SHA256 = 'dec9db62a52a8bac5f1e2675aecbeb4239f2b1f4fa521db01693c32bdfeef01e'
ROWS = 361464
FIELDS = ['id','source_hash','sheet','row_number','entity_key','name','province','district','cae','activity','sector','source_date','fingerprint','duplicate_of','identity_conflict','territory_id','territory_resolution']
SOURCES = {
'1ba4c4114b16b407e7746712d00d4f681246bbbc1b42351e96a31f7129acad30':148556,
'1b5087b464e39e88c42def39655c2391aaaf684b4c832d6a77ff274b98889f00':212631,
'f02e086ed4973ef17234aa6cc54b98fbb7a6952ffb16ec4dc4f7d0c72af6400c':277,
'83b4f1200988e99834c2ae3915346fbf237d128308554b67b5a080d4956bf506':0,
}
def csv_value(value):
if value is None:return r'\N'
if isinstance(value,bool):value='t' if value else 'f'
value=str(value)
if any(c in value for c in ('\x00','\r','\n')):raise ValueError('Unexpected multiline/NUL source projection')
return '"'+value.replace('"','""')+'"'
def fail_unless(ok,message):
if not ok:raise RuntimeError(message)
def sql_command(container=None):
if container:return ['docker','exec','-i',container,'psql','-p','55441','-U','postgres','-d','proactive_supply_delivery_20261028_test','-XqAt','-v','ON_ERROR_STOP=1']
return ['runuser','-u','postgres','--','psql','-d','teste_investidor','-XqAt','-v','ON_ERROR_STOP=1']
def query(command,text):
return subprocess.check_output(command,input=text.encode(),stderr=subprocess.PIPE).decode().strip()
def prepare(package,output):
manifest=json.loads((package/'import-manifest.json').read_text())
preview=json.loads((package/'preview.json').read_text())
scan=json.loads((package/'scan.json').read_text())
schema=package/'schema.sql'
fail_unless(hashlib.file_digest(schema.open('rb'),'sha256').hexdigest()==manifest['schema_sql_sha256'],'Schema SQL differs from reviewed candidate migration')
fail_unless(manifest['candidate']=='a022ed9848b41ba342b4209414c2bbf2ebd0a7a3','Unexpected candidate')
fail_unless(set(s['sha256']for s in preview['sources'])==set(SOURCES),'Unexpected originals')
fail_unless(scan['exit_code']==0 and set(scan['source_hashes'])==set(SOURCES),'Hash-specific source scan receipt missing')
fail_unless(preview['records']==ROWS,'Incomplete preview')
fail_unless(hashlib.file_digest((package/'observations.jsonl.gz').open('rb'),'sha256').hexdigest()==manifest['compressed_sha256'],'Compressed snapshot changed')
rawhash=hashlib.sha256();counts=dict.fromkeys(SOURCES,0);n=0
seen=set()
with output.open('w',encoding='utf-8',newline='\n')as sql:
os.chmod(output,0o600)
sql.write("BEGIN;\nSET LOCAL lock_timeout='10s';\nSELECT pg_advisory_xact_lock(hashtext('proactive-supply-delivery-v1'));\n")
sql.write("CREATE TEMP TABLE supply_delivery_sources (LIKE public.proactive_supply_sources INCLUDING DEFAULTS) ON COMMIT DROP;\n")
sql.write("CREATE TEMP TABLE supply_delivery_observations (LIKE public.proactive_supply_observations INCLUDING DEFAULTS) ON COMMIT DROP;\n")
sql.write("COPY supply_delivery_sources (sha256,name,period,limitations,receipt,imported_at) FROM STDIN WITH (FORMAT csv,NULL '\\N');\n")
now=dt.datetime.now(dt.timezone.utc).isoformat()
for s in preview['sources']:
receipt={'original':s,'transport_sha256':RAW_SHA256,'candidate':manifest['candidate'],'scan':scan,'access':'internal','publication':False,'counting':'administrative observations;not unique companies;repeated projections retained but excluded from query total'}
sql.write(','.join(map(csv_value,[s['sha256'],s['name'],s['period'],preview['limitations'],json.dumps(receipt,ensure_ascii=False),now]))+'\n')
sql.write('\\.\n')
sql.write("COPY supply_delivery_observations ("+','.join(FIELDS)+") FROM STDIN WITH (FORMAT csv,NULL '\\N');\n")
with gzip.open(package/'observations.jsonl.gz','rb')as stream:
for line in stream:
rawhash.update(line);r=json.loads(line)
fail_unless(set(r)==set(FIELDS),'Unexpected observation fields')
fail_unless(r['source_hash']in SOURCES,'Unknown source')
expected=hashlib.sha256((r['source_hash']+'|'+r['sheet']+'|'+str(r['row_number'])).encode()).hexdigest()
fail_unless(r['id']==expected and r['id']not in seen,'Invalid or repeated row identity')
seen.add(r['id']);n+=1;counts[r['source_hash']]+=1
sql.write(','.join(csv_value(r[k])for k in FIELDS)+'\n')
sql.write('\\.\n')
fail_unless(rawhash.hexdigest()==RAW_SHA256 and n==ROWS and counts==SOURCES,'Snapshot checksum/count mismatch')
sql.write(r'''
CREATE UNIQUE INDEX supply_delivery_observations_id ON supply_delivery_observations(id);
ANALYZE supply_delivery_observations;
DO $$ BEGIN
IF (SELECT count(*) FROM supply_delivery_observations)<>361464 THEN RAISE EXCEPTION 'Incomplete internal base'; END IF;
IF (SELECT count(*) FROM supply_delivery_observations WHERE duplicate_of IS NOT NULL)<>147165 THEN RAISE EXCEPTION 'Repeated projection count differs'; END IF;
IF EXISTS(SELECT 1 FROM supply_delivery_observations x LEFT JOIN supply_delivery_observations y ON y.id=x.duplicate_of WHERE x.duplicate_of IS NOT NULL AND (y.id IS NULL OR x.id=y.id OR x.fingerprint<>y.fingerprint)) THEN RAISE EXCEPTION 'Invalid duplicate lineage'; END IF;
IF EXISTS(SELECT 1 FROM supply_delivery_observations x LEFT JOIN public.geo_locations g ON g.id=x.territory_id WHERE x.territory_id IS NOT NULL AND (g.id IS NULL OR g.deleted_at IS NOT NULL)) THEN RAISE EXCEPTION 'Destination geography differs'; END IF;
IF EXISTS(SELECT 1 FROM supply_delivery_observations x JOIN public.proactive_supply_observations y ON x.id=y.id WHERE to_jsonb(x)<>to_jsonb(y)) THEN RAISE EXCEPTION 'Previously installed observation differs;no overwrite'; END IF;
IF EXISTS(SELECT 1 FROM supply_delivery_sources x JOIN public.proactive_supply_sources y ON x.sha256=y.sha256 WHERE (x.name,x.period,x.limitations) IS DISTINCT FROM (y.name,y.period,y.limitations)) THEN RAISE EXCEPTION 'Existing original metadata differs'; END IF;
END $$;
INSERT INTO public.proactive_supply_sources SELECT * FROM supply_delivery_sources ON CONFLICT(sha256) DO NOTHING;
INSERT INTO public.proactive_supply_observations SELECT * FROM supply_delivery_observations ON CONFLICT(id) DO NOTHING;
SELECT json_build_object('records',count(*),'distinct_observations',count(*) FILTER(WHERE duplicate_of IS NULL),'repeated_projections',count(*) FILTER(WHERE duplicate_of IS NOT NULL),'not_unique_companies',true,'published',false) FROM public.proactive_supply_observations;
COMMIT;
''')
return {'candidate':manifest['candidate'],'records':n,'source_counts':counts,'raw_sha256':rawhash.hexdigest(),'sql_bytes':output.stat().st_size,'mode':'prepared;no database write','unique_company_count':None,'duplicate_suppression':'WHERE duplicate_of IS NULL;retained administrative sightings do not count as separate offers','national_coverage':'partial','investment_conclusion':'hypothesis only;does not prove demand or viability'}
def main():
p=argparse.ArgumentParser();p.add_argument('--package',type=Path,required=True);p.add_argument('--work',type=Path,required=True);p.add_argument('--mode',choices=['preview','rehearsal','apply'],default='preview');p.add_argument('--container');p.add_argument('--backup',type=Path)
a=p.parse_args();a.work.mkdir(mode=0o700,parents=True,exist_ok=True);output=a.work/'controlled-import.sql'
receipt=prepare(a.package,a.work/'controlled-import.sql')
if a.mode=='preview':print(json.dumps(receipt));return
fail_unless(os.geteuid()==0,'Authenticated technical deployment identity required')
if a.mode=='rehearsal':
fail_unless(a.container is not None,'Disposable QA container required')
info=json.loads(subprocess.check_output(['docker','inspect',a.container]))[0]
fail_unless(info['HostConfig']['NetworkMode']=='none' and not info['HostConfig'].get('PortBindings'),'QA must have no network or host ports')
else:
fail_unless(not a.container and os.environ.get('SSH_CONNECTION'),'Existing authenticated SSH deployment session required')
installed=json.loads(Path('/var/lib/investmoz-deploy/installed.json').read_text())
fail_unless(installed['commit']==BASELINE,'Reconcile installed code before preloading the new private base')
fail_unless(a.backup is not None,'Fresh backup/restore receipt required')
base=a.backup.resolve();fail_unless(base.is_relative_to('/var/backups/investmoz-proactive-supply-delivery-20261028'),'Unexpected backup root')
b=json.loads((base/'backup-receipt.json').read_text());r=json.loads((base/'restore-receipt.json').read_text())
stamp=dt.datetime.fromisoformat(b['created_at'].replace('Z','+00:00'))
fail_unless(dt.timedelta(0)<=dt.datetime.now(dt.timezone.utc)-stamp<=dt.timedelta(hours=24),'Backup is no longer fresh;take another before apply')
fail_unless(b['target']['database']=='teste_investidor' and int(b['target']['port'])==5432,'Backup identity mismatch')
fail_unless(r['restore_verified'] and r['source_dump_sha256']==b['sha256'],'Restore does not match backup')
fail_unless(hashlib.file_digest((base/'database.dump').open('rb'),'sha256').hexdigest()==b['sha256'],'Backup bytes changed')
command=sql_command(a.container)
identity=query(command,"SELECT current_database()||'|'||current_setting('port');")
expected='proactive_supply_delivery_20261028_test|55441'if a.mode=='rehearsal'else'teste_investidor|5432'
fail_unless(identity==expected,'Wrong actual database identity')
# Existing pipeline does not execute migrations. Schema installation is a
# separate, reviewed deployment step; this loader never creates it on active.
fail_unless(query(command,"SELECT count(*) FROM pg_class WHERE oid IN(to_regclass('public.proactive_supply_sources'),to_regclass('public.proactive_supply_observations')) AND relrowsecurity AND relforcerowsecurity;")=='2','Reviewed private schema/RLS not installed')
before=query(command,'SELECT count(*) FROM public.proactive_supply_observations;')
with output.open('rb')as stream:
result=subprocess.run(command,stdin=stream,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
fail_unless(result.returncode==0,'Import rolled back;'+result.stderr.decode(errors='replace')[-1000:])
lines=[line for line in result.stdout.decode().splitlines()if line.startswith('{')]
receipt.update(mode=a.mode,database=identity,before=int(before),after=json.loads(lines[-1]),idempotent_no_overwrite=True,active_existing_business_data_touched=False)
(a.work/'import-receipt.json').write_text(json.dumps(receipt,indent=2));os.chmod(a.work/'import-receipt.json',0o600)
print(json.dumps(receipt))
if __name__=='__main__':main()
+181
View File
@@ -0,0 +1,181 @@
"""Read-only XLSX ingestion into a private, indexed analysis snapshot. No publication.
Every source row remains addressable. Matching identifiers suggest the same entity;
they never imply a current trading status, contract, or an exhaustive census.
"""
import argparse
import hashlib
import json
from pathlib import Path
import sqlite3
import unicodedata
import xml.etree.ElementTree as ET
import zipfile
NS = '{http://schemas.openxmlformats.org/spreadsheetml/2006/main}'
SOURCES = {
'companies.xlsx': ('1ba4c4114b16b407e7746712d00d4f681246bbbc1b42351e96a31f7129acad30', 'EMPRESAS_LIST 2014-2022.xlsx', '2014–2022; data por registo quando disponível'),
'licensed.xlsx': ('1b5087b464e39e88c42def39655c2391aaaf684b4c832d6a77ff274b98889f00', 'Empresas Licenciadas.xlsx', 'Data por registo; cobertura temporal global não determinada'),
'tourism.xlsx': ('f02e086ed4973ef17234aa6cc54b98fbb7a6952ffb16ec4dc4f7d0c72af6400c', 'CADASTRO DE EMPREENDIMENTOS TURISTICOS 3, 4 E 5 ESTRELAS 30.10.24 18,00h.xlsx', '30/10/2024 (nome do exemplar); situação actual não confirmada'),
'cae.xlsx': ('83b4f1200988e99834c2ae3915346fbf237d128308554b67b5a080d4956bf506', 'CAE eBAU.xlsx', 'Versão não indicada'),
}
def norm(v):
return ''.join(c for c in unicodedata.normalize('NFKD', str(v)).casefold() if not unicodedata.combining(c)).strip()
def digest(v):
return hashlib.sha256(v.encode('utf-8')).hexdigest()
def rows(path):
# Shared strings are spooled to a temporary on-disk SQLite lookup, not a huge XML tree.
with zipfile.ZipFile(path) as z, sqlite3.connect('') as strings:
strings.execute('create table strings(id integer primary key,value text)')
if 'xl/sharedStrings.xml' in z.namelist():
with z.open('xl/sharedStrings.xml') as f:
i = 0
for _, e in ET.iterparse(f, events=('end',)):
if e.tag == NS+'si':
strings.execute('insert into strings values(?,?)', (i, ''.join(t.text or '' for t in e.iter(NS+'t'))))
i += 1
e.clear()
book = ET.fromstring(z.read('xl/workbook.xml'))
rels = {r.attrib['Id']: r.attrib['Target'] for r in ET.fromstring(z.read('xl/_rels/workbook.xml.rels'))}
for sheet in book.find(NS+'sheets'):
target = rels[sheet.attrib['{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id']]
target = target.lstrip('/') if target.startswith('/') else 'xl/'+target
with z.open(target) as f:
for _, e in ET.iterparse(f, events=('end',)):
if e.tag != NS+'row':
continue
result = {}
for c in e:
col = ''.join(ch for ch in c.attrib.get('r','') if ch.isalpha())
v = c.findtext(NS+'v', '')
if c.attrib.get('t') == 's':
hit = strings.execute('select value from strings where id=?', (int(v),)).fetchone()
v = hit[0] if hit else ''
elif c.attrib.get('t') == 'inlineStr':
v = ''.join(t.text or '' for t in c.iter(NS+'t'))
result[col] = ' '.join(v.split())
yield sheet.attrib['name'], int(e.attrib['r']), result
e.clear()
def main():
p = argparse.ArgumentParser()
p.add_argument('--sources', required=True)
p.add_argument('--output', required=True)
p.add_argument('--only-tourism', action='store_true', help='Rebuild only tourism in an existing private preview')
p.add_argument('--only-missing-names', action='store_true', help='Add nonempty company rows without a social denomination; never substitute the personal applicant name')
a = p.parse_args()
output = Path(a.output)
output.mkdir(parents=True, exist_ok=True)
manifest = {'version':1, 'access':'internal', 'sources':[], 'publication':False,
'limitations':'Registos administrativos históricos; cobertura incompleta, actividade actual, capacidade, procura e compromissos não confirmados. Ausência de registo significa informação insuficiente.'}
for alias, (expected, name, period) in SOURCES.items():
path = Path(a.sources)/alias
actual = hashlib.file_digest(path.open('rb'), 'sha256').hexdigest()
if actual != expected:
raise ValueError('Original changed: '+alias)
manifest['sources'].append({'alias':alias,'name':name,'sha256':actual,'bytes':path.stat().st_size,'period':period})
db = sqlite3.connect(output/'supply-preview.sqlite')
db.executescript('''create table if not exists records(id text primary key, source_hash text, sheet text, row_number integer, entity_key text, name text, province text, district text, cae text, activity text, sector text, source_date text, fingerprint text, duplicate_of text, identity_conflict integer default 0);
create index if not exists supply_place on records(province,district);
create index if not exists supply_entity on records(entity_key);
create index if not exists supply_fingerprint on records(fingerprint);''')
labels = {}
hierarchy = {}
for _, row, c in rows(Path(a.sources)/'cae.xlsx'):
if row > 1 and c.get('B') and c.get('F'):
labels[c['B']] = c['F']
hierarchy[c['A']] = c
# Supplied CAE hierarchy labels retained. Sector is the top-level CAE section,
# not an invented correspondence to the portal's twelve navigation sectors.
sector_by_code = {}
for node in hierarchy.values():
ancestor=node
visited=set()
while ancestor.get('D')!='1' and ancestor.get('C') in hierarchy and ancestor['A'] not in visited:
visited.add(ancestor['A'])
ancestor=hierarchy[ancestor['C']]
if ancestor.get('D')=='1':
sector_by_code[node['B']]=ancestor['F']
counts = {}
if a.only_tourism:
db.execute('delete from records where source_hash=?',(SOURCES['tourism.xlsx'][0],))
aliases=('companies.xlsx','licensed.xlsx') if a.only_missing_names else (('tourism.xlsx',) if a.only_tourism else ('companies.xlsx','licensed.xlsx','tourism.xlsx'))
for alias in aliases:
source_hash, _, period = SOURCES[alias]
count = 0
province = ''
previous_sheet=''
for sheet, row, c in rows(Path(a.sources)/alias):
if alias == 'tourism.xlsx':
if sheet!=previous_sheet:
province=''
previous_sheet=sheet
heading=norm(c.get('C','')).replace(' ','')
provinces={'cidadedemaputo':'MAPUTO CIDADE','maputocidade':'MAPUTO CIDADE','provinciademaputo':'MAPUTO PROVINCIA','maputoprovincia':'MAPUTO PROVINCIA'}
provinces.update({'provinciade'+norm(n).replace(' ',''):n for n in ['Gaza','Inhambane','Sofala','Manica','Tete','Zambézia','Nampula','Cabo Delgado','Niassa']})
if heading in provinces and not c.get('D'):
province=provinces[heading]
continue
# Explicit province section and ordinal identify an establishment.
# Missing licence/room/contact never excludes an authorised record.
name = c.get('C','')
if not name or not c.get('B','').isdigit():
continue
district = c.get('E','') if sheet=='Folha1' else ''
cae, activity, date, identifier = '', 'Alojamento (âmbito do exemplar; categoria por conferir)', period, ''
sector = labels.get('I','Alojamento e restauração — exemplar de alojamento')
else:
if row == 1:
continue
old = alias == 'companies.xlsx'
name = c.get('L','')
if a.only_missing_names and name:
continue
if not any(c.values()):
continue
# A missing social denomination is a data gap, not a reason to
# discard an authorised source row or publish an applicant's name.
name = name or 'Denominação não indicada'
province, district = c.get('Y' if old else 'V',''), c.get('X' if old else 'U','')
cae = c.get('S' if old else 'O','')
activity = labels.get(cae, '')
sector = sector_by_code.get(cae,'')
date = c.get('D','')
# Keep source serial verbatim; Excel origin must be demonstrated before conversion.
date = ('Serial Excel original: '+date) if date.replace('.','',1).isdigit() else (date or 'Não indicada')
nuit = c.get('K','')
identifier = ('nuit:'+nuit) if len(nuit)==9 and nuit.isdigit() and len(set(nuit))>1 else ''
rid = digest(source_hash+'|'+sheet+'|'+str(row))
entity = digest(identifier) if identifier else digest('unresolved:'+source_hash+'|'+sheet+'|'+str(row))
# Same projected administrative observation, not byte-identical rows:
# include licence and process identifiers; do not collapse distinct licences.
licence = (c.get('G','') if sheet!='Folha1' else '') if alias=='tourism.xlsx' else c.get('A','')
process = '' if alias=='tourism.xlsx' else c.get('B','')
fp = digest('|'.join(map(norm,[identifier,name,province,district,cae,date,licence,process])))
duplicate = db.execute('select id from records where fingerprint=? and id<>? limit 1',(fp,rid)).fetchone()
db.execute('insert or ignore into records values(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', (rid,source_hash,sheet,row,entity,name,province,district,cae,activity,sector,date,fp,duplicate[0] if duplicate else None,0))
count += 1
if count % 10000 == 0:
db.commit()
db.commit()
counts[alias] = count
print(json.dumps({'source':alias,'rows_processed':count}), flush=True)
db.execute('update records set identity_conflict=0')
db.execute("update records set identity_conflict=1 where entity_key in (select entity_key from records where name<>'Denominação não indicada' group by entity_key having count(distinct lower(name))>1)")
db.commit()
counts={alias:db.execute('select count(*) from records where source_hash=?',(SOURCES[alias][0],)).fetchone()[0] for alias in ('companies.xlsx','licensed.xlsx','tourism.xlsx')}
manifest.update(rows_processed=counts, records=db.execute('select count(*) from records').fetchone()[0],
duplicate_sightings=db.execute('select count(*) from records where duplicate_of is not null').fetchone()[0],
conflicting_identity_sightings=db.execute('select count(*) from records where identity_conflict=1').fetchone()[0],
unnamed_observations=db.execute("select count(*) from records where name='Denominação não indicada'").fetchone()[0],
unclassified_activity=db.execute("select count(*) from records where activity=''").fetchone()[0],
unresolved_territory=db.execute("select count(*) from records where district='' or province=''").fetchone()[0])
db.close()
(output/'preview.json').write_text(json.dumps(manifest,ensure_ascii=False,indent=2),encoding='utf-8')
print(json.dumps({k:v for k,v in manifest.items() if k not in ('sources',)},ensure_ascii=False))
if __name__ == '__main__':
main()
+22
View File
@@ -0,0 +1,22 @@
<?php
$root=dirname(__DIR__);
$operation='test';
if(!in_array($operation,['routes','test'],true)||basename($root)!=='portal-investidor-supply-qa-20261028')throw new RuntimeException('Explicit isolated QA workspace and routes/test operation required.');
foreach(['storage/framework/views','storage/framework/cache','storage/framework/sessions','bootstrap/cache','storage/logs','outputs/proactive-supply-20261028','outputs/proactive-investment-20261027']as$dir)if(!is_dir($root.'/'.$dir))mkdir($root.'/'.$dir,0700,true);
foreach(['APP_BASE_PATH'=>$root,'APP_ENV'=>'testing','APP_URL'=>'http://127.0.0.1:18768','APP_KEY'=>'base64:'.base64_encode(str_repeat('Q',32)),'DB_CONNECTION'=>'pgsql','DB_HOST'=>'127.0.0.1','DB_PORT'=>'55441','DB_DATABASE'=>'proactive_investment_20261027_test','DB_USERNAME'=>'postgres','DB_PASSWORD'=>'','DB_URL'=>'','CACHE_STORE'=>'array','SESSION_DRIVER'=>'array','QUEUE_CONNECTION'=>'sync','MAIL_MAILER'=>'array'] as $k=>$v){putenv($k.'='.$v);$_ENV[$k]=$_SERVER[$k]=$v;}
$loader=require $root.'/vendor/autoload.php';
spl_autoload_register(function($class)use($root){foreach(['App\\'=>'app/','Tests\\'=>'tests/','Database\\Factories\\'=>'database/factories/']as$prefix=>$dir){if(str_starts_with($class,$prefix)){$file=$root.'/'.$dir.str_replace('\\','/',substr($class,strlen($prefix))).'.php';if(is_file($file)){require $file;return;}}}},true,true);
$app=require $root.'/bootstrap/app.php';
(new Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables)->bootstrap($app);
(new Illuminate\Foundation\Bootstrap\LoadConfiguration)->bootstrap($app);
$c=config('database.connections.pgsql');
if($app->configurationIsCached()||$c['host']!=='127.0.0.1'||(int)$c['port']!==55441||$c['database']!=='proactive_investment_20261027_test'||!empty($c['url']))throw new RuntimeException('QA guard');
$pdo=new PDO('pgsql:host=127.0.0.1;port=55441;dbname='.$c['database'],$c['username'],$c['password']);
if($pdo->query('select current_database()')->fetchColumn()!==$c['database'])throw new RuntimeException('QA identity');
if(($argv[1]??'')==='test')return;
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
config(['portal.profile_access_enabled'=>true,'portal.opportunity_specialized_drafts_enabled'=>true,'investor-path.enabled'=>true,'investor-path.private_preparation'=>true,'suitecrm.enabled'=>false,'investmoz_ai.enabled'=>false,'session.driver'=>'file','session.secure'=>false]);
Illuminate\Support\Facades\Vite::useBuildDirectory('build-qa-opportunities');
config(['session.cookie'=>'proactive_qa_session','session.http_only'=>true]);
+68
View File
@@ -0,0 +1,68 @@
<?php
/** Explicit QA-only tooling: no active environment or .env fallback. */
require __DIR__.'/proactive-supply-qa-bootstrap.php';
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Artisan;
$operation=$argv[1]??'';
if($operation==='test'){
$argv=['phpunit','-c','phpunit.investmoz.xml','tests/Feature/ProactiveSupplyTest.php','tests/Feature/ProactiveInvestmentTest.php'];
if(getenv('PROACTIVE_SUPPLY_FILTER')){$argv[]='--filter';$argv[]=getenv('PROACTIVE_SUPPLY_FILTER');}
$argc=count($argv);$_SERVER['argv']=$argv;$_SERVER['argc']=$argc;
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';exit;
}
Tests\Support\ProactiveInvestmentFixture::guard();
$out=dirname(__DIR__).'/outputs/proactive-supply-20261028';
if($operation==='geography'){
file_put_contents($out.'/private/geography.json',json_encode(DB::table('geo_locations')->whereNull('deleted_at')->get(['id','name','parent_id','location_type']),JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR));
echo "QA geography exported privately.\n";exit;
}
if($operation==='schema'){
if(!Schema::hasTable('proactive_supply_sources')){
(require dirname(__DIR__).'/database/migrations/2026_10_28_180000_create_proactive_supply_observations.php')->up();
}
echo "QA schema available; no existing table altered.\n";exit;
}
if(in_array($operation,['import','extend'],true)){
$dir=$out.'/private-v2';
$manifest=json_decode(file_get_contents($dir.'/preview.json'),true,512,JSON_THROW_ON_ERROR);
$scan=json_decode(file_get_contents($out.'/private/scan.json'),true,512,JSON_THROW_ON_ERROR);
if(($scan['exit_code']??1)!==0||array_diff(array_column($manifest['sources'],'sha256'),$scan['source_hashes']??[]))throw new RuntimeException('Hash-specific malware scan required');
$file=$dir.'/observations.jsonl';$hash=hash_file('sha256',$file);
if(($argv[2]??'')!==$hash)throw new RuntimeException('Review preview and supply exact JSONL hash');
$before=DB::table('proactive_supply_observations')->count();
$extension=$operation==='extend';$previousHash=$argv[3]??null;
if($extension){
$delta=json_decode(file_get_contents($dir.'/extension-preview.json'),true,512,JSON_THROW_ON_ERROR);
if(($delta['sha256']??null)!==$hash||($delta['previous_sha256']??null)!==$previousHash||empty($delta['no_replacement'])||$delta['previous_observations_unchanged']+$delta['new_observations']!==$manifest['records'])throw new RuntimeException('Extension must prove all previously accepted observations unchanged and bind both snapshot hashes.');
}
DB::transaction(function()use($manifest,$file,$hash,$extension,$previousHash){
DB::select("select pg_advisory_xact_lock(hashtext('proactive-supply-import-v1'))");
foreach($manifest['sources']as$s){
$existing=DB::table('proactive_supply_sources')->where('sha256',$s['sha256'])->first();
if($existing && data_get(json_decode($existing->receipt,true),'transport_sha256')!==($extension?$previousHash:$hash))throw new RuntimeException('Existing source has a different prepared classification snapshot; preserve it and prepare an explicit mapping delta instead of silently replacing observations.');
if($extension){
if(!$existing)throw new RuntimeException('An extension must reference the already imported originals.');
$receipt=json_decode($existing->receipt,true);
$history=$receipt['extensions']??[];
if(!in_array($hash,array_column($history,'transport_sha256'),true)){
$history[]=['transport_sha256'=>$hash,'rows_in_extended_preview'=>$manifest['records'],'at'=>now()->toIso8601String(),'operation'=>'append previously unrepresented source rows;existing observations immutable'];
DB::table('proactive_supply_sources')->where('sha256',$s['sha256'])->update(['receipt'=>json_encode([...$receipt,'extensions'=>$history])]);
}
}
DB::table('proactive_supply_sources')->insertOrIgnore(['sha256'=>$s['sha256'],'name'=>$s['name'],'period'=>$s['period'],'limitations'=>$manifest['limitations'],'receipt'=>json_encode(['source'=>$s,'preview_rows'=>$manifest['records'],'transport_sha256'=>$hash,'scan'=>'Defender custom scan, no threats, hash-specific receipt','scope'=>'isolated QA, internal analysis only']),'imported_at'=>now()]);
}
$f=fopen($file,'rb');$batch=[];$n=0;
while(($line=fgets($f))!==false){
$r=json_decode($line,true,512,JSON_THROW_ON_ERROR);
if(!in_array($r['source_hash'],array_column($manifest['sources'],'sha256'),true))throw new RuntimeException('Unknown source');
$batch[]=$r;$n++;
if(count($batch)===500){DB::table('proactive_supply_observations')->insertOrIgnore($batch);$batch=[];}
}
fclose($f);if($batch)DB::table('proactive_supply_observations')->insertOrIgnore($batch);
if($n!==$manifest['records'])throw new RuntimeException('Incomplete candidate');
});
$receipt=['before'=>$before,'after'=>DB::table('proactive_supply_observations')->count(),'sha256'=>$hash,'extension'=>$extension,'previous_snapshot'=>$previousHash,'publication'=>false,'database'=>'proactive_investment_20261027_test','port'=>55441];
file_put_contents($out.'/import-'.($extension?'extension':($before?'replay':'first')).'.json',json_encode($receipt,JSON_PRETTY_PRINT));echo json_encode($receipt)."\n";exit;
}
throw new RuntimeException('Use geography,schema,import <reviewed hash>,extend <reviewed hash> <previous snapshot hash>,or test;exclusively isolated QA.');