oportunidades 3
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-09 15:00:37 +02:00
parent 9d6176f55b
commit e662230df7
32 changed files with 865 additions and 37 deletions
+7 -2
View File
@@ -9,6 +9,8 @@ use App\Models\Investor;
use App\Models\Lead;
use App\Models\User;
use App\Services\ConvertInterestToLead;
use App\Services\CountryCatalogue;
use App\Services\DeclaredOriginStatistics;
use App\Services\PortalAudit;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\RedirectResponse;
@@ -77,7 +79,8 @@ class CrmController extends Controller
{
$filters = $request->validate(['q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in(['new', 'qualified', 'disqualified', 'converted'])],
'owner_user_id' => ['nullable', 'integer', 'exists:users,id']]);
'owner_user_id' => ['nullable', 'integer', 'exists:users,id'],
'origin' => ['nullable', Rule::in(['unknown', ...array_column(app(CountryCatalogue::class)->all(), 'code')])]]);
$query = Lead::with(['investor:id,legal_name', 'owner:id,name'])
->withCount(['tasks as overdue_tasks_count' => fn ($q) => $q->whereIn('status', ['open', 'in_progress'])->where('due_at', '<', now())]);
if ($term = $filters['q'] ?? null) {
@@ -92,7 +95,9 @@ class CrmController extends Controller
}
return Inertia::render('crm/leads', ['leads' => $query->latest()->orderBy('id')->paginate(20)->withQueryString(),
'filters' => $filters, 'owners' => $this->owners()]);
'filters' => $filters, 'owners' => $this->owners(),
'countries' => app(CountryCatalogue::class)->all(),
'originStatistics' => app(DeclaredOriginStatistics::class)->forManager($request->user(), $filters['origin'] ?? null)]);
}
public function prepare(InterestRequest $interest): Response|RedirectResponse
@@ -128,7 +128,24 @@ class InvestmentDiscoveryController extends Controller
public function map(Request $request, InvestmentCatalogue $catalogue): JsonResponse
{
return response()->json($catalogue->map($catalogue->filters($request)));
$filters = $catalogue->filters($request);
$context = $this->territorial($filters)->map(fn ($d) => ['id' => 'dossier:'.$d->id, 'title' => $d->title, 'kind' => $d->cluster_id ? 'Cluster' : ($d->corridor_id ? 'Corredor' : 'Projeto âncora'), 'url' => '/carteira/'.$d->id]);
$opportunityOnlyFilters = array_filter(\Illuminate\Support\Arr::only($filters, ['education', 'sector', 'facets', 'project_id', 'cluster_id', 'corridor_id', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level']), fn ($value) => $value !== null && $value !== '' && $value !== false && $value !== []);
if ($opportunityOnlyFilters === [] && ! in_array($filters['dimension'] ?? null, ['cluster', 'corridor'], true)) {
$zoneFilters = $filters;
if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) {
$zoneFilters['category'] = $filters['dimension'];
}
$zones = app(InvestmentZones::class)->search($zoneFilters);
foreach (['zee_id', 'zfi_id', 'park_id'] as $key) {
if (! empty($filters[$key])) {
$zones->where('id', $filters[$key]);
}
}
$context = $context->concat($zones->orderBy('name')->limit(30)->get(['id', 'name', 'category'])->map(fn ($z) => ['id' => 'zone:'.$z->id, 'title' => $z->name, 'kind' => ['zee' => 'ZEE', 'zfi' => 'ZFI', 'industrial_park' => 'Parque industrial'][$z->category] ?? $z->category, 'url' => '/zonas/'.$z->id]));
}
return response()->json([...$catalogue->map($filters), 'territorial_context' => $context->values(), 'context_limit_per_collection' => 30]);
}
public function compare(Request $request, InvestmentCatalogue $catalogue): JsonResponse
@@ -49,7 +49,7 @@ class InvestmentSubmissionController extends Controller
$company = DB::table('investment_company_declarations')->where('id', $data['company_id'])->where('user_id', $request->user()->id)->first();
abort_unless($company !== null, 404);
$companyData = json_decode(Crypt::decryptString($company->payload), true, 512, JSON_THROW_ON_ERROR);
$payload = [...$payload, 'organisation' => $companyData['name'], 'representative_role' => $companyData['role'], 'country' => $companyData['country']];
$payload = [...$payload, 'organisation' => $companyData['name'], 'representative_role' => $companyData['role'], 'incorporation_country_code' => $companyData['incorporation_country_code'] ?? null];
}
unset($data['company_id']);
if (! empty($data['opportunity_id'])) {
@@ -111,7 +111,7 @@ class InvestmentSubmissionController extends Controller
$target = $row->opportunity_id ? Opportunity::published()->find($row->opportunity_id) : null;
$dossier = $row->dossier_id ? InvestmentPortfolioController::visible()->find($row->dossier_id) : null;
$zoneOrigin = DB::table('investment_submission_zones')->where('submission_id', $id)->first(['zone_id', 'zone_version', 'intent']);
$data = ['submission' => $row->only(['id', 'payload', 'version', 'status', 'opportunity_id', 'dossier_id']), 'zone_origin' => $zoneOrigin,
$data = ['countries' => app(\App\Services\CountryCatalogue::class)->all(), 'submission' => $row->only(['id', 'payload', 'version', 'status', 'opportunity_id', 'dossier_id']), 'zone_origin' => $zoneOrigin,
'target' => ($target ? $target->title : null) ?? ($dossier ? $dossier->title : null), 'references' => [
...$catalogue->references(), 'options' => DB::table('investment_reference_options')->orderBy('label')->get()],
'needs' => InvestmentSpecificNeed::where('submission_id', $id)->get(),
@@ -144,10 +144,13 @@ class InvestmentSubmissionController extends Controller
{
$required = $submitting ? 'required' : 'nullable';
$data = $request->validate(['version' => ['required', 'integer', 'min:1'],
'payload' => ['required', 'array:full_name,email,phone,country,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'],
'payload' => ['required', 'array:full_name,email,phone,country,residence_country_code,incorporation_country_code,declared_origin_country_code,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'],
'payload.residence_country_code' => ['nullable', 'string', app(\App\Services\CountryCatalogue::class)->rule()],
'payload.incorporation_country_code' => ['nullable', 'string', app(\App\Services\CountryCatalogue::class)->rule()],
'payload.declared_origin_country_code' => ['nullable', 'string', app(\App\Services\CountryCatalogue::class)->rule()],
'payload.other_details' => ['nullable', 'string', 'max:5000'],
'payload.full_name' => [$required, 'string', ...($submitting ? ['min:3'] : []), 'max:150'], 'payload.email' => [$required, $submitting ? 'email:rfc' : 'string', 'max:150'],
'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => [$required, 'string', 'max:100'],
'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => [$submitting ? 'required_without:payload.declared_origin_country_code' : 'nullable', 'nullable', 'string', 'max:100'],
'payload.organisation' => ['nullable', 'string', 'max:200'], 'payload.representative_role' => ['nullable', 'string', 'max:150'],
'payload.experience' => ['nullable', 'string', 'max:2000'], 'payload.investment_capacity' => ['nullable', 'string', 'max:1000'],
'payload.profile_id' => [$required, 'integer'], 'payload.sector' => [$required, 'string', 'max:150'],
@@ -257,10 +260,13 @@ class InvestmentSubmissionController extends Controller
$interest = $intake->receive(['email' => $p['email'], 'fullName' => $p['full_name'], 'message' => $p['objective'], 'submission_id' => $id], $id,
fn () => InterestRequest::create(['reference' => 'IR-'.now()->year.'-'.Str::ulid(), 'opportunity_id' => $opportunity?->id,
'opportunity_reference' => $opportunity?->reference, 'full_name' => $p['full_name'], 'email' => $p['email'], 'phone' => $p['phone'] ?? null,
'country' => $p['country'], 'investor_type' => empty($p['organisation']) ? 'individual' : 'empresa',
'country' => $p['country'] ?? 'Não informado', 'investor_type' => empty($p['organisation']) ? 'individual' : 'empresa',
'sector_interest' => $p['sector'], 'message' => $p['objective'], 'privacy_consent' => true, 'consented_at' => now(),
'status' => 'received', 'source_page' => '/investidor/manifestacoes/'.$id,
'metadata' => ['submission_id' => $id, 'portal_user_id' => $request->user()->id, 'dossier_id' => $row->dossier_id,
'declared_origin_country_code' => $p['declared_origin_country_code'] ?? null,
'residence_country_code' => $p['residence_country_code'] ?? null,
'incorporation_country_code' => $p['incorporation_country_code'] ?? null,
...($zoneOrigin ? ['zone_origin' => (array) $zoneOrigin] : [])]]));
$row->update(['payload' => $p, 'version' => $row->version + 1, 'interest_request_id' => $interest->id, 'status' => 'submitted']);
InvestmentSpecificNeed::where('submission_id', $id)->delete();
@@ -18,7 +18,7 @@ class InvestmentWorkspaceController extends Controller
{
public function profile(Request $request, InvestmentWorkspace $workspace): Response|JsonResponse
{
$data = ['account' => $workspace->account($request->user()->id), 'companies' => DB::table('investment_company_declarations')
$data = ['countries' => app(\App\Services\CountryCatalogue::class)->all(), 'account' => $workspace->account($request->user()->id), 'companies' => DB::table('investment_company_declarations')
->where('user_id', $request->user()->id)->orderByDesc('created_at')->get(['id', 'version', 'payload'])->map(function ($company) {
$company->payload = json_decode(Crypt::decryptString($company->payload), true, 512, JSON_THROW_ON_ERROR);
@@ -31,7 +31,8 @@ class InvestmentWorkspaceController extends Controller
public function saveProfile(Request $request): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => ['required', 'integer', 'min:0'],
'payload' => ['required', 'array:phone,country,sector,experience,investment_capacity'],
'payload' => ['required', 'array:phone,country,residence_country_code,sector,experience,investment_capacity'],
'payload.residence_country_code' => ['nullable', 'string', app(\App\Services\CountryCatalogue::class)->rule()],
'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => ['nullable', 'string', 'max:100'],
'payload.sector' => ['nullable', 'string', 'max:150'], 'payload.experience' => ['nullable', 'string', 'max:2000'],
'payload.investment_capacity' => ['nullable', 'string', 'max:1000']]);
@@ -53,8 +54,9 @@ class InvestmentWorkspaceController extends Controller
public function company(Request $request, ?string $id = null): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => ['required', 'integer', 'min:0'],
'payload' => ['required', 'array:name,country,registration,sector,role,description'],
'payload.name' => ['required', 'string', 'min:2', 'max:200'], 'payload.country' => ['required', 'string', 'max:100'],
'payload' => ['required', 'array:name,country,incorporation_country_code,registration,sector,role,description'],
'payload.incorporation_country_code' => ['nullable', 'string', app(\App\Services\CountryCatalogue::class)->rule()],
'payload.name' => ['required', 'string', 'min:2', 'max:200'], 'payload.country' => ['required_without:payload.incorporation_country_code', 'nullable', 'string', 'max:100'],
'payload.registration' => ['nullable', 'string', 'max:100'], 'payload.sector' => ['nullable', 'string', 'max:150'],
'payload.role' => ['required', 'string', 'max:150'], 'payload.description' => ['nullable', 'string', 'max:2000']]);
$result = DB::transaction(function () use ($request, $id, $data) {
@@ -23,7 +23,8 @@ class OpportunityResourceController extends Controller
{
public function installation(Request $request, OpportunityResourceInstallation $service): JsonResponse|Response
{
$preview = [...$service->preview(), 'actionUrl' => '/management/opportunity-resources-install'];
$preview = [...$service->preview(), 'imageProcessingAvailable' => app(OpportunityImageProcessor::class)->available(), 'actionUrl' => '/management/opportunity-resources-install'];
$preview['changes'] = [...$preview['changes'], 'Código reconciliado: catálogo ISO comum; residência e constituição distintas; origem declarada guardada no payload existente e estatísticas internas autorizadas. Não converte países históricos nem usa IP.', 'Seletores territoriais reutilizados com pesquisa; filtros contextuais do catálogo e fichas territoriais consultáveis no percurso do mapa. Zero geometrias não permite desenhar estruturas sem dados.', 'Não reinstala a gestão básica nem acrescenta associações territoriais. Revisão transversal, cadastro completo em todos os campos e percursos educativos continuam parciais; consultar a matriz por módulo.'];
return $request->expectsJson() ? response()->json($preview) : Inertia::render('management/opportunity-install', $preview);
}
@@ -41,6 +42,7 @@ class OpportunityResourceController extends Controller
$opportunity = Opportunity::withTrashed()->findOrFail($id);
abort_unless($service->installed(), 503, 'Recursos ainda não instalados; consulte o plano autenticado.');
$resources = [];
Inertia::share('imageProcessingAvailable', app(OpportunityImageProcessor::class)->available());
foreach (OpportunityResources::KINDS as $kind) {
$resources[$kind] = $service->listing($id, $kind);
}
@@ -48,8 +48,8 @@ class VirtualDeskController extends Controller
$data = ['cases' => $cases, 'queue' => $queue, 'notifications' => DB::table('desk_notifications')->where('user_id', $actor->id)->orderByDesc('id')->limit(100)->get(),
'legacyRequests' => DB::table('interest_requests')->where('metadata->portal_user_id', $actor->id)->whereNull('deleted_at')->orderByDesc('created_at')->get(['id', 'reference', 'status', 'created_at']),
'companies' => DB::table('investment_company_declarations')->where('user_id', $actor->id)->get(['id', 'payload'])->map(fn ($c) => ['id' => $c->id, 'name' => $desk->decode($c->payload)['name'] ?? 'Declaração empresarial']),
'submissions' => DB::table('investment_submissions')->where('user_id', $actor->id)->get(['id', 'status']),
'journeys' => DB::table('investor_journeys')->where('user_id', $actor->id)->whereNull('deleted_at')->get(['id', 'dossier_id', 'status']),
'submissions' => \App\Models\InvestmentSubmission::where('user_id', $actor->id)->get()->map(fn ($s) => ['id' => $s->id, 'status' => $s->status, 'name' => ($s->opportunity_id ? DB::table('opportunities')->where('id', $s->opportunity_id)->value('title') : null) ?? ($s->dossier_id ? DB::table('investment_dossiers')->where('id', $s->dossier_id)->value('title') : null) ?? ($s->payload['organisation'] ?? null)]),
'journeys' => DB::table('investor_journeys as j')->leftJoin('investment_dossiers as d', 'd.id', '=', 'j.dossier_id')->where('j.user_id', $actor->id)->whereNull('j.deleted_at')->get(['j.id', 'j.dossier_id', 'j.status', 'd.title as name']),
'simulationDrafts' => DB::table('simulation_form_drafts')->where('owner_user_id', $actor->id)->whereNull('deleted_at')->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))->get(['id', 'version_no']),
'guidanceTasks' => app(InvestorGuidance::class)->installed() ? DB::table('tasks as t')->join('desk_cases as c', 'c.id', '=', 't.desk_case_id')
->whereNull('t.deleted_at')->when(! $actor->can('manage-portal'), fn ($q) => $q->where(fn ($p) => $p->where('t.assignee_user_id', $actor->id)->orWhere(fn ($s) => $s->where('c.user_id', $actor->id)->whereNull('c.organization_id'))->orWhereIn('c.organization_id', $organizations)))
@@ -130,7 +130,7 @@ class VirtualDeskController extends Controller
}
}
$this->auditRead($request, 'desk_requests', $id);
$data = ['request' => [...(array) $row, 'payload' => $payload, 'submitted_snapshot' => $desk->decode($row->submitted_snapshot), 'editable_fields' => json_decode($row->editable_fields ?? '[]', true)],
$data = ['countries' => app(\App\Services\CountryCatalogue::class)->all(), 'request' => [...(array) $row, 'payload' => $payload, 'submitted_snapshot' => $desk->decode($row->submitted_snapshot), 'editable_fields' => json_decode($row->editable_fields ?? '[]', true)],
'case' => ['id' => $case->id, 'title' => $case->title], 'caseVersion' => $case->version, 'definition' => $desk->definition($row),
'serviceSource' => json_decode(DB::table('desk_service_versions')->where('id', $row->service_version_id)->value('source'), true),
'owner' => $desk->owns($request->user(), $case), 'manager' => $request->user()->can('manage-portal'), 'analyst' => (int) $row->analyst_id === $request->user()->id,
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Services;
use Illuminate\Validation\Rule;
/** Shared ISO identifiers; independent of administrative territories and IP data. */
final class CountryCatalogue
{
/** @return list<array{code: string, name: string, name_en: string}> */
public function all(): array
{
$countries = [];
$lines = file(resource_path('data/iso3166.tab'), FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
throw new \RuntimeException('Catálogo ISO local indisponível.');
}
foreach ($lines as $line) {
if (str_starts_with($line, '#')) {
continue;
}
[$code, $english] = explode("\t", $line, 2);
$name = class_exists(\Locale::class) ? \Locale::getDisplayRegion('und_'.$code, 'pt') : $english;
$countries[] = ['code' => $code, 'name' => is_string($name) && $name !== '' ? $name : $english, 'name_en' => $english];
}
usort($countries, fn ($a, $b) => strnatcasecmp($a['name'], $b['name']));
return $countries;
}
public function rule(): \Illuminate\Validation\Rules\In
{
return Rule::in(array_column($this->all(), 'code'));
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class DeclaredOriginStatistics
{
/** @return array<string, list<array{code: string|null, name: string, count: int}>> */
public function forManager(User $actor, ?string $origin = null): array
{
abort_unless($actor->can('manage-portal'), 403);
$names = array_column(app(CountryCatalogue::class)->all(), 'name', 'code');
$groups = [];
foreach (DB::table('interest_requests')->whereNull('deleted_at')->select('metadata')->cursor() as $row) {
$metadata = json_decode($row->metadata ?? '{}', true);
$code = $metadata['declared_origin_country_code'] ?? null;
$key = is_string($code) && isset($names[$code]) ? $code : '';
$groups[$key] = ($groups[$key] ?? 0) + 1;
}
$desk = [];
if (Schema::hasTable('desk_requests')) {
foreach (DB::table('desk_requests')->whereNotIn('status', ['draft', 'cancelled'])->select('payload')->cursor() as $row) {
$code = app(VirtualDesk::class)->decode($row->payload)['declared_origin_country_code'] ?? null;
$key = is_string($code) && isset($names[$code]) ? $code : '';
$desk[$key] = ($desk[$key] ?? 0) + 1;
}
}
// Distinct request collections; summing them could count the same investor twice.
if ($origin !== null && $origin !== '') {
$key = $origin === 'unknown' ? '' : $origin;
$groups = array_intersect_key($groups, [$key => true]);
$desk = array_intersect_key($desk, [$key => true]);
}
return ['interests' => $this->format($groups, $names), 'desk' => $this->format($desk, $names)];
}
/** @param array<string, int> $counts
* @param array<string, string> $names
* @return list<array{code: string|null, name: string, count: int}> */
private function format(array $counts, array $names): array
{
ksort($counts);
return array_map(fn (string $code, int $count) => ['code' => $code ?: null, 'name' => $names[$code] ?? 'Não informado', 'count' => $count], array_keys($counts), array_values($counts));
}
}
+6 -1
View File
@@ -12,11 +12,16 @@ use Illuminate\Support\Str;
class OpportunityImageProcessor
{
public function available(): bool
{
return function_exists('imagecreatefromstring') && function_exists('imagewebp');
}
/** @param array<string,mixed> $input */
public function prepare(User $actor, int $opportunityId, string $id, array $input): void
{
abort_unless($actor->can('manage-portal'), 403);
abort_unless(function_exists('imagecreatefromstring') && function_exists('imagewebp'), 423, 'GD/WebP não disponível neste processo. Original e metadados preservados; processamento pendente.');
abort_unless($this->available(), 423, 'GD/WebP não disponível neste processo. Original e metadados preservados; processamento pendente.');
$data = validator($input, ['etag' => 'required|string|size:64', 'version' => 'required|integer|min:1', 'reason' => 'required|string|min:20|max:1000'])->validate();
$stored = [];
$documents = [];
@@ -14,6 +14,8 @@ class OpportunityResourceInstallation
{
$files = [__FILE__, app_path('Services/OpportunityImageProcessor.php'), app_path('Services/OpportunityResources.php'), app_path('Http/Controllers/OpportunityResourceController.php'), app_path('Http/Controllers/OpportunityManagementController.php'), app_path('Jobs/ScanDocumentForMalware.php'), database_path('migrations/'.self::MIGRATION.'.php'), base_path('routes/web.php'), resource_path('js/pages/management/opportunity-resources.tsx'), resource_path('js/pages/management/opportunity-draft.tsx'), resource_path('js/pages/management/opportunity-install.tsx')];
$files = [...$files, app_path('Services/CountryCatalogue.php'), app_path('Services/DeclaredOriginStatistics.php'), app_path('Services/VirtualDesk.php'), app_path('Http/Controllers/CrmController.php'), app_path('Http/Controllers/InvestmentWorkspaceController.php'), app_path('Http/Controllers/InvestmentSubmissionController.php'), app_path('Http/Controllers/InvestmentDiscoveryController.php'), app_path('Http/Controllers/VirtualDeskController.php'), resource_path('data/iso3166.tab'), resource_path('js/components/country-select.tsx'), resource_path('js/components/investment-territory.tsx'), resource_path('js/pages/investment/profile.tsx'), resource_path('js/pages/investment/submission.tsx'), resource_path('js/pages/investment/catalogue.tsx'), resource_path('js/pages/desk/request.tsx'), resource_path('js/pages/desk/panel.tsx'), resource_path('js/pages/crm/leads.tsx')];
return hash('sha256', json_encode(array_map(fn ($file) => hash_file('sha256', $file), $files), JSON_THROW_ON_ERROR));
}
+2 -1
View File
@@ -205,7 +205,8 @@ class VirtualDesk
* @return array<string,mixed> */
public function validateFields(array $payload, array $definition, bool $submitting): array
{
$rules = ['payload' => ['required', 'array:'.implode(',', array_keys($definition['fields']))]];
$rules = ['payload' => ['required', 'array:'.implode(',', [...array_keys($definition['fields']), 'declared_origin_country_code'])],
'payload.declared_origin_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()]];
foreach ($definition['fields'] as $key => $field) {
$active = ! isset($field['when']) || ($payload[$field['when']['field']] ?? null) === $field['when']['equals'];
$required = $submitting && ($field['required'] ?? false) && $active;
+13
View File
@@ -229,3 +229,16 @@ Leitura em 09/10/2026 06:34:48 UTC: migration aplicada uma vez; evento `opportun
**QA:** 80 testes passaram, 1 252 asserções, três casos reais de ClamD saltados; percurso desktop/móvel e nova sessão sem armazenamento local passaram. Restauro/reversão de 288 tabelas/cinco ficheiros; 14 conjuntos preservados após navegador. **Ativo:** gestão básica confirmada; seis IDs reais e 124 eventos anteriores preservados, dois eventos posteriores; recursos ainda não instalados, zero eventos próprios. Plano `/management/opportunity-resources-install`, válido até 10/10/2026 11:09:13 UTC, exclusivamente autenticado.
**Lacunas mantidas:** publicação/revisão segregada, direitos/dados pessoais, editor cartográfico integral/limites oficiais em falta, catálogos pesquisáveis, evidências por campo, módulos especializados e critérios integrais da especificação. Cobertura do inventário, questões jurídicas, ClamD real, fontes institucionais específicas e primeiro pedido real continuam separados. Fontes oficiais já aceites não exigem segunda validação.
### Revisão transversal — países, cartografia e integração de `9d6176f`
[Matriz por módulo, percurso demonstrado e lacunas](revisao-funcional-transversal.md). O incremento anterior foi preservado e integrado; os seus 80 testes não certificam esta revisão.
- **Países, parcial:** catálogo local comum de 249 códigos ISO, pesquisa e traduções; residência, constituição e origem da solicitação distintas. Persistência e recuperação no perfil, entidade, manifestação e pedido do Balcão pelo armazenamento existente; origem no mesmo pedido submetido em QA. Estatísticas internas autorizadas conservam históricos como «não informado» e separam conjuntos de pedidos. Cadastro inicial, endpoints legados e restantes módulos/filtro estatístico ainda por completar.
- **Cartografia, parcial:** pesquisa no seletor reutilizado; consumidores públicos passam a consultar diretamente o cadastro aceite, eliminando dependência de publicação de uma versão intermediária. Fichas territoriais independentes consultáveis no percurso do mapa. Inventário dos campos registado; seletores próprios, campo plano do Balcão e cobertura gráfica ainda abertos. Leitura ativa: 1.921 territórios e zero geometrias; não inventar pontos/polígonos nem invalidar dados aceites.
- **Percursos:** navegador desktop/móvel demonstra perfil guardado com resultado visível, recuperação em nova sessão sem localStorage, filtros/pesquisa territorial e fichas no mapa; recursos demonstram preparação, anexos, versões, exclusão/restauro. Manifestações/Balcão/CRM têm evidência HTTP e persistência em QA; percursos integrais e educação continuam com lacunas expressas na matriz.
- **Limitações operacionais visíveis:** GD/WebP verificado no PHP web; ausência desativa apenas geração de miniaturas. Scan pendente restringe binário/preview, sem bloquear rascunhos/metadados/localizações. ClamD real indisponível; fixtures não verificam ficheiros ativos. Plano único de recursos atualizado; nenhuma repetição da gestão instalada.
**Estado ativo:** só leitura/backup pelo Codex. Seis IDs reais, relações e 124 eventos anteriores preservados; recursos ainda não instalados. Instalação autenticada continua necessária; cobertura transversal não é dada como concluída. Questões jurídicas específicas, diferença de cobertura documental, fontes institucionais, ClamD real e primeiro pedido real mantêm estados separados.
Continuação de 9 de outubro: filtro de país/«Não informado» nas estatísticas do CRM preparado, com conjuntos separados e sem inferir origem de campos históricos. GET autorizado e validado; a lista de leads mantém os seus filtros próprios. 25 testes/430 asserções em QA isolada, TypeScript, PHPStan e build passaram. Percurso deste filtro no navegador ainda pendente; sem migration nem escrita ativa. [Escopo e evidência](revisao-funcional-transversal.md#continuação--filtro-das-estatísticas-de-origem).
@@ -63,6 +63,10 @@ Os seis IDs continuam classificados real. Comparação do conteúdo original das
## Próximo incremento — recursos em QA
Continuação da revisão transversal: [filtro das estatísticas de origem no CRM e validação QA](revisao-funcional-transversal.md#continuação--filtro-das-estatísticas-de-origem). O filtro não altera oportunidades, classificações ou relações; a instalação de recursos e as lacunas da especificação conservam os estados abaixo.
Tentativa de conclusão às 14:19:42 locais de 9 de outubro: controlo de navegador sem browsers disponíveis; abertura do navegador interno devolveu indisponibilidade. Verificação visual do filtro não realizada. Leitura ativa em transação `READ ONLY` confirmou recursos não instalados e plano não pronto: hash atual `80eee708a85e18895eb2f51d17421ffcc86c69d8bb5560880ff9d9466ca0454d`, distinto do hash na prova de ativação `dcdfa8511b6b3554dd44ab236264822da8aaf2dd09eb8c66dcea78b0f2215e53`. É necessário renovar as provas correspondentes ao código antes de executar o fluxo autenticado. Leitor `tools/opportunity-resources-install-preview.php`; evidência privada `opportunity-resources-readiness.json`. Zero escritas ativas; instalação não tentada com prova desatualizada.
[Imagens, documentos e multilocalização: matriz, QA, lacunas e plano](recursos-oportunidades.md). Guardar/recuperar recursos, originais cifrados, versões e exclusão lógica/restauro implementados; coleção territorial até 50 itens com hierarquia/papéis, áreas/distribuição e GeoJSON validado. Relações existentes conservadas; novas associações são preparação privada até workflow explícito. Processador GD gera WebP/miniaturas sem EXIF e scan independente; GD ativo e ClamD real mantêm dependências concretas. Não publicar automaticamente.
Leitura ativa em 11:10:21 UTC confirmou novamente os seis IDs reais, dados/relações e 124 eventos anteriores intactos; dois eventos posteriores explicam diferença global do histórico. Novo esquema de recursos ausente, zero eventos deste incremento. QA: 80 testes passaram/1 252 asserções, três ClamD reais saltados; navegador desktop/móvel passou. Plano único `/management/opportunity-resources-install`, backup/restauro e reversão em cópia, validade até 10/10/2026 11:09:13 UTC. Não aplicado automaticamente.
@@ -0,0 +1,55 @@
# Revisão funcional transversal — integração de 9d6176f
9 de outubro de 2026. Incremento de correções, com cobertura por percurso; não é certificação de todos os módulos. Preserva a gestão básica instalada, classificações, relações e fontes oficiais aceites. A revisão não reabre a proveniência oficial.
## Resultados demonstrados e lacunas
| Módulo / rota | Ação e resultado demonstrado nesta revisão | Correção / persistência | Lacuna restante |
| --- | --- | --- | --- |
| Perfil `/investidor/profile` | Guardar residência, mensagem visível, HTTP 303, recarregar; nova sessão móvel sem localStorage recupera PT; pesquisar Portugal | ISO comum, país de residência explícito, payload privado cifrado e versão existentes | País no cadastro inicial e restantes perfis ainda por reconciliar |
| Entidades `/investidor/companies` | QA de servidor: guardar constituição MZ; recuperar no contexto da conta; negar acesso de outra conta | País de constituição distinto; valor histórico conservado; sem concessão de representação | Novo percurso completo de criação/edição de entidade no navegador ainda por demonstrar |
| Manifestações `/investidor/manifestacoes/{id}` | QA HTTP: guardar/recuperar três países diferentes; rejeitar ZZ com 422 sem alterar versão/payload; submissão só em QA conserva origem no pedido | A constituição da entidade deixa de substituir o país do perfil; origem explícita no payload, revisão e metadata do mesmo pedido | Teste de todos os passos da manifestação com os novos dropdowns no navegador; os endpoints públicos/legados ainda não recolhem ISO específico |
| Balcão `/balcao-virtual/painel` e `/pedidos/{id}` | QA HTTP: origem de rascunho recuperada pelo proprietário; outra conta negada; 422 preserva rascunho; nenhum pedido ativo submetido | Seletores mostram nomes dos investimentos/percursos da conta; título reutilizado quando disponível, sem substituir título corrigido; origem não inferida do perfil | Comprovar no navegador o percurso de reutilização de projeto, pedido e responsável; primeiro pedido real continua pendente |
| CRM `/management/crm/leads` | QA: estatísticas e filtro de origem autorizados, acesso não autorizado rejeitado; pedido antigo com país Moçambique e sem origem continua «não informado» | Contagens separadas de manifestações e pedidos em acompanhamento; filtro por país/não informado; sem somar como investidores únicos ou exportar contactos | Verificação das contagens e do filtro no navegador; cobertura dos endpoints legados |
| Catálogo `/oportunidades` | Navegador móvel: dimensão Cluster oculta filtros incompatíveis; pesquisa Maputo devolve opção do cadastro | Troca de dimensão limpa filtros incompatíveis; pesquisa no seletor reutilizado | Guião de preferências integrado e percurso completo oportunidade → localização → acompanhamento ainda por demonstrar |
| Mapa `/oportunidades?view=map` | Navegador: fichas de estruturas territoriais acessíveis dentro do percurso do mapa | API inclui contexto territorial independente das seis oportunidades, com links de ficheiro; não cria coordenadas nem associações | Desenhar corredores/âncoras/ZEE/ZFI/parques exige geometrias existentes; a base ativa tem zero. Lista consultável não equivale a cartografia completa |
| Recursos `/management/opportunities/{id}/resources` | Navegador desktop/móvel: múltiplos locais, PDF/imagem, recuperação, exclusão lógica/restauro; falta de GD visível, preparação desativada | Integra 9d6176f; metadados e rascunhos continuam guardáveis; originais conservados; scan por ficheiro independente | Instalação ativa pendente, ClamD real, GD no PHP web e revisão/publicação final |
| Educação `/educacao`, `/management/education` | Testes HTTP existentes executados com a revisão; regras de lacuna preservadas | `/educacao` é catálogo de investimentos educativos; a gestão de procura/capacidade é outra rota | Fluxo educativo integral solicitado, recuperação dos seus rascunhos, painéis por ator e aceitação funcional permanecem abertos; testes existentes não certificam a especificação inteira |
| Simulador | Correções do incremento anterior preservadas | Nenhum cálculo fiscal alterado ou ativado pela introdução de países | Nova revisão transversal do simulador ainda não demonstrada nesta frente; não conclui ClamD, fontes institucionais ou pedido real |
| Bolsa, serviços, carteira, negócios, fundiário e mapas de zonas | Inventário de consumidores territoriais abaixo | Componentes e entidades existentes preservados | Ações de criar/editar/recuperar por rota e cobertura completa dos países permanecem abertas |
## Continuação — filtro das estatísticas de origem
Em 9 de outubro de 2026, acrescentado o filtro de país e «Não informado» em `/management/crm/leads`. O filtro aplica-se apenas às estatísticas de solicitações; a lista de leads conserva os seus filtros próprios. Manifestações e pedidos do Balcão continuam separados. Seleção sem ocorrências mostra conjunto vazio; país histórico, residência e constituição não substituem origem declarada. GET validado pelo catálogo comum, protegido pela permissão existente, sem migration ou atualização de dados.
QA isolada definida em `phpunit.xml` (`investmoz_test`, porta 55441), com guarda antes da limpeza/migração: **25 testes, 430 asserções passaram** nas suites `InvestmentWorkspaceTest` e `InvestorGuidanceTest`. Cobrem país explícito, não informado, ausência de ocorrências, reposição de todas as origens, país inválido, acesso negado e igualdade dos registos antes/depois. TypeScript e PHPStan passaram. Percurso específico deste novo filtro no navegador ainda não demonstrado; não altera a aceitação funcional dos restantes percursos. Nenhuma escrita na base ativa.
## Países e dados históricos
Catálogo comum local de **249 códigos ISO alpha-2**, sem seed de substituição de cadastros. Fonte técnica: [tabela IANA](https://data.iana.org/time-zones/tzdb/iso3166.tab), consultada em 09/10/2026; declara códigos referentes a ISO/TC 46 N1127 de 29/02/2024. SHA-256 local `837c80785080c8433fd9d4ea87e78f161ac7a40389301c5153d4f90198baeb2a`. Nomes portugueses pelo ICU/Intl, nomes ingleses preservados e usados como alternativa. Consulta e pesquisa não dependem de serviço externo.
Três significados distintos: `residence_country_code`, `incorporation_country_code`, `declared_origin_country_code`. Os dois primeiros não preenchem o terceiro. O campo antigo `country` conserva o valor original sem conversão automática; dados ausentes continuam não informados. O IP técnico mantém-se separado. O uso do JSON/payload e metadata existentes evita nova tabela de perfil, novo pedido e migration de países nesta parcela. Estatísticas de origem usam apenas a declaração explícita, sem reclassificar históricos pelo campo antigo.
## Inventário territorial inicial — cobertura ainda parcial
O inventário de código identificou consumidores em `portal/services`, `portal/manage`, `education/index`, `education/analysis`, `portfolio/index`, `structures/editor`, `management/relationship-repair`, `management/editor`, `management/opportunity-draft`, `management/opportunity-resources`, `partnerships/catalogue`, `partnerships/editor`, `investment/business-marketplace`, `investment/business-wizard`, `investment/catalogue`, `investment/detail`, `investment/zone`, `investment/zones` e `land-assistance/form`. Acrescenta-se o campo territorial do pedido do Balcão e os mapas do catálogo e das zonas.
Os consumidores de `ProvinceSelect` e `TerritoryFields` públicos passam a usar `/api/v1/investment/territory-options`, que consulta o cadastro aceite, em vez de depender da publicação/validade de uma versão intermediária em `/api/v1/catalogs/jurisdictions`. Os seletores de gestão conservam `/management/territory-options` e a permissão existente. `TerritoryLevel` passa a pesquisar nome/código, preservando o pai, paginação e valor guardado.
**Não está concluído «todos os campos e mapas».** O Balcão ainda apresenta o seletor territorial plano; a Bolsa e serviços têm consumidores próprios; os seletores de gestão genéricos, campos livres de localização, filtros e cada mapa precisam de demonstração individual. Não substituir localidades ausentes nem inferir pontos pelo centro de província. A recuperação de referências mantém as validações existentes. Na leitura ativa de 11:42:46 UTC: 1.921 territórios, zero geometrias, 28 locais de projeto e 13 ligações cluster–projeto preservados.
## GD e ClamD — impacto mostrado antes da operação
GD/WebP é verificado no processo PHP que atende a página, não pelo resultado de outro CLI. Quando indisponível, o formulário conserva original/metadados, mostra a limitação e desativa apenas a preparação de WebP/miniaturas. O endpoint mantém HTTP 423 como guarda. Não foi alterado o PHP ativo.
ClamD real continua indisponível (`malware:health`: `scanner_unavailable`, versão/assinaturas desconhecidas). Scan pendente bloqueia acesso ao binário/preview/processamento que o exige; não impede preparar metadados, localizações ou rascunhos. A página de instalação também explica estas limitações. Scanner simulado verifica apenas fixtures identificadas de QA, nunca ficheiros reais. Direitos e publicação continuam independentes do scan.
## QA, preservação e plano consolidado
QA exclusivamente em `*_test:55441`, incluindo cópia restaurada e navegador restrito a `127.0.0.1:18096`. Provas privadas: `functional-country-browser.json`, `opportunity-resources-browser.json`, `opportunity-resources-preservation.json`, `opportunity-resources-compatibility.json` e `opportunity-resources-backup-proof.json`. Tests/TypeScript/PHPStan/build são verificações técnicas; a matriz acima delimita a aceitação funcional.
Plano autenticado único para o esquema ainda pendente: [recursos e revisão funcional](http://127.0.0.1:8000/management/opportunity-resources-install). Inclui apenas `2026_10_09_030000_extend_opportunity_resources`, versão atual do código, backup/restauro, reversão e limitações. Não repete `2026_10_09_020000_extend_opportunity_management`. Países desta parcela reutilizam armazenamento existente; não convertem históricos. Nenhuma localização, relação ou oportunidade ativa é criada pelo plano.
Reversão da migration apenas antes de novos recursos/documentos cifrados, preservando a guarda de dados. Depois de uso, exige reconciliação dos dados posteriores e ação autorizada; não restaurar automaticamente o backup por cima de novas escritas. Reversão de código deve preservar os novos campos no payload antes de voltar a formulários antigos que não os conheçam.
**Base ativa:** apenas leitura e backup nesta revisão; seis IDs reais e 124 eventos anteriores confirmados, zero eventos de recursos. Nenhuma instalação repetida, seed ativo, submissão ou alteração de configuração. Cobertura documental, questões jurídicas específicas, fontes institucionais, ClamD real e primeiro pedido real permanecem separados.
+279
View File
@@ -0,0 +1,279 @@
# ISO 3166 alpha-2 country codes
#
# This file is in the public domain, so clarified as of
# 2009-05-17 by Arthur David Olson.
#
# From Paul Eggert (2025-07-01):
# This file contains a table of two-letter country codes. Columns are
# separated by a single tab. Lines beginning with ‘#’ are comments.
# All text uses UTF-8 encoding. The columns of the table are as follows:
#
# 1. ISO 3166-1 alpha-2 country code, current as of
# ISO/TC 46 N1127 (2024-02-29). See: ISO/TC 46 Documents
# https://www.iso.org/committee/48750.html?view=documents
# 2. The usual English name for the coded region. This sometimes
# departs from ISO-listed names, sometimes so that sorted subsets
# of names are useful (e.g., “Samoa (American)” and “Samoa
# (western)” rather than “American Samoa” and “Samoa”),
# sometimes to avoid confusion among non-experts (e.g.,
# “Czech Republic” and “Turkey” rather than “Czechia” and “Türkiye”),
# and sometimes to omit needless detail or churn (e.g., “Netherlands”
# rather than “Netherlands (the)” or “Netherlands (Kingdom of the)”).
#
# The table is sorted by country code.
#
# This table is intended as an aid for users, to help them select time
# zone data appropriate for their practical needs. It is not intended
# to take or endorse any position on legal or territorial claims.
#
#country-
#code name of country, territory, area, or subdivision
AD Andorra
AE United Arab Emirates
AF Afghanistan
AG Antigua & Barbuda
AI Anguilla
AL Albania
AM Armenia
AO Angola
AQ Antarctica
AR Argentina
AS Samoa (American)
AT Austria
AU Australia
AW Aruba
AX Åland Islands
AZ Azerbaijan
BA Bosnia & Herzegovina
BB Barbados
BD Bangladesh
BE Belgium
BF Burkina Faso
BG Bulgaria
BH Bahrain
BI Burundi
BJ Benin
BL St Barthelemy
BM Bermuda
BN Brunei
BO Bolivia
BQ Caribbean NL
BR Brazil
BS Bahamas
BT Bhutan
BV Bouvet Island
BW Botswana
BY Belarus
BZ Belize
CA Canada
CC Cocos (Keeling) Islands
CD Congo (Dem. Rep.)
CF Central African Rep.
CG Congo (Rep.)
CH Switzerland
CI Côte d’Ivoire
CK Cook Islands
CL Chile
CM Cameroon
CN China
CO Colombia
CR Costa Rica
CU Cuba
CV Cape Verde
CW Curaçao
CX Christmas Island
CY Cyprus
CZ Czech Republic
DE Germany
DJ Djibouti
DK Denmark
DM Dominica
DO Dominican Republic
DZ Algeria
EC Ecuador
EE Estonia
EG Egypt
EH Western Sahara
ER Eritrea
ES Spain
ET Ethiopia
FI Finland
FJ Fiji
FK Falkland Islands
FM Micronesia
FO Faroe Islands
FR France
GA Gabon
GB Britain (UK)
GD Grenada
GE Georgia
GF French Guiana
GG Guernsey
GH Ghana
GI Gibraltar
GL Greenland
GM Gambia
GN Guinea
GP Guadeloupe
GQ Equatorial Guinea
GR Greece
GS South Georgia & the South Sandwich Islands
GT Guatemala
GU Guam
GW Guinea-Bissau
GY Guyana
HK Hong Kong
HM Heard Island & McDonald Islands
HN Honduras
HR Croatia
HT Haiti
HU Hungary
ID Indonesia
IE Ireland
IL Israel
IM Isle of Man
IN India
IO British Indian Ocean Territory
IQ Iraq
IR Iran
IS Iceland
IT Italy
JE Jersey
JM Jamaica
JO Jordan
JP Japan
KE Kenya
KG Kyrgyzstan
KH Cambodia
KI Kiribati
KM Comoros
KN St Kitts & Nevis
KP Korea (North)
KR Korea (South)
KW Kuwait
KY Cayman Islands
KZ Kazakhstan
LA Laos
LB Lebanon
LC St Lucia
LI Liechtenstein
LK Sri Lanka
LR Liberia
LS Lesotho
LT Lithuania
LU Luxembourg
LV Latvia
LY Libya
MA Morocco
MC Monaco
MD Moldova
ME Montenegro
MF St Martin (French)
MG Madagascar
MH Marshall Islands
MK North Macedonia
ML Mali
MM Myanmar (Burma)
MN Mongolia
MO Macau
MP Northern Mariana Islands
MQ Martinique
MR Mauritania
MS Montserrat
MT Malta
MU Mauritius
MV Maldives
MW Malawi
MX Mexico
MY Malaysia
MZ Mozambique
NA Namibia
NC New Caledonia
NE Niger
NF Norfolk Island
NG Nigeria
NI Nicaragua
NL Netherlands
NO Norway
NP Nepal
NR Nauru
NU Niue
NZ New Zealand
OM Oman
PA Panama
PE Peru
PF French Polynesia
PG Papua New Guinea
PH Philippines
PK Pakistan
PL Poland
PM St Pierre & Miquelon
PN Pitcairn
PR Puerto Rico
PS Palestine
PT Portugal
PW Palau
PY Paraguay
QA Qatar
RE Réunion
RO Romania
RS Serbia
RU Russia
RW Rwanda
SA Saudi Arabia
SB Solomon Islands
SC Seychelles
SD Sudan
SE Sweden
SG Singapore
SH St Helena
SI Slovenia
SJ Svalbard & Jan Mayen
SK Slovakia
SL Sierra Leone
SM San Marino
SN Senegal
SO Somalia
SR Suriname
SS South Sudan
ST Sao Tome & Principe
SV El Salvador
SX St Maarten (Dutch)
SY Syria
SZ Eswatini (Swaziland)
TC Turks & Caicos Is
TD Chad
TF French S. Terr.
TG Togo
TH Thailand
TJ Tajikistan
TK Tokelau
TL East Timor
TM Turkmenistan
TN Tunisia
TO Tonga
TR Turkey
TT Trinidad & Tobago
TV Tuvalu
TW Taiwan
TZ Tanzania
UA Ukraine
UG Uganda
UM US minor outlying islands
US United States
UY Uruguay
UZ Uzbekistan
VA Vatican City
VC St Vincent
VE Venezuela
VG Virgin Islands (UK)
VI Virgin Islands (US)
VN Vietnam
VU Vanuatu
WF Wallis & Futuna
WS Samoa (western)
YE Yemen
YT Mayotte
ZA South Africa
ZM Zambia
ZW Zimbabwe
@@ -0,0 +1,20 @@
import { useState } from 'react';
export type Country = { code: string; name: string; name_en: string };
export default function CountrySelect({ countries, value, onChange, id, disabled = false }: {
countries: Country[]; value: string; onChange: (value: string) => void; id: string; disabled?: boolean;
}) {
const [search, setSearch] = useState('');
const matches = countries.filter((country) => country.code === value ||
`${country.name} ${country.name_en} ${country.code}`.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase()
.includes(search.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase()));
return <div className="space-y-2">
<input disabled={disabled} className="w-full rounded border p-2" aria-label="Pesquisar país" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Pesquisar nome ou código do país" />
<select disabled={disabled} id={id} className="w-full rounded border p-2" value={value} onChange={(event) => onChange(event.target.value)}>
<option value="">Não informado</option>
{matches.map((country) => <option key={country.code} value={country.code}>{country.name} ({country.code})</option>)}
</select>
{matches.length === 0 && <p role="status">Nenhum país corresponde à pesquisa.</p>}
</div>;
}
@@ -19,7 +19,7 @@ const emptyTerritoryPath: TerritoryOption[] = [];
export function ProvinceSelect({
value,
onChange,
endpoint = '/api/v1/catalogs/jurisdictions',
endpoint = '/api/v1/investment/territory-options',
placeholder = 'Seleccione uma província',
className = control,
label = 'Província',
@@ -107,7 +107,7 @@ export function ProvinceSelect({
export function TerritoryFields({
initial = emptyTerritoryPath,
onChange,
endpoint = '/api/v1/catalogs/jurisdictions',
endpoint = '/api/v1/investment/territory-options',
}: {
initial?: TerritoryOption[];
onChange?: (path: TerritoryOption[]) => void;
@@ -174,7 +174,9 @@ export function TerritoryLevel({
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [retry, setRetry] = useState(0);
const [search, setSearch] = useState('');
const enabled = index === 0 || Boolean(parent);
useEffect(() => { setPage(1); setSearch(''); }, [parent, index]);
useEffect(() => {
setOptions([]);
setMore(false);
@@ -189,6 +191,7 @@ export function TerritoryLevel({
type: levels[index],
parent_id: parent,
page: String(page),
q: search,
});
const response = await fetch(`${endpoint}?${params}`, {
signal: controller.signal,
@@ -215,12 +218,13 @@ export function TerritoryLevel({
clearTimeout(timer);
controller.abort();
};
}, [index, parent, page, enabled, endpoint, retry]);
}, [index, parent, page, enabled, endpoint, retry, search]);
return (
<div className="space-y-2">
<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)}
+82 -1
View File
@@ -8,15 +8,28 @@ export default function Leads({
leads,
filters,
owners,
originStatistics,
countries,
}: {
leads: Paginated<Lead>;
filters: { q?: string; status?: string; owner_user_id?: number };
filters: {
q?: string;
status?: string;
owner_user_id?: number;
origin?: string;
};
owners: Owner[];
countries: { code: string; name: string }[];
originStatistics: {
interests: { code: string | null; name: string; count: number }[];
desk: { code: string | null; name: string; count: number }[];
};
}) {
const form = useForm({
q: filters.q ?? '',
status: filters.status ?? '',
owner_user_id: String(filters.owner_user_id ?? ''),
origin: filters.origin ?? '',
});
return (
<Management title="CRM — Leads">
@@ -31,6 +44,74 @@ export default function Leads({
Acompanhamento do investidor — pedidos, tarefas e respostas do
Balcão
</Link>
<details
open={Boolean(filters.origin)}
className="rounded border p-3"
>
<summary>Solicitações por origem declarada</summary>
<p>
Conta pedidos por origem declarada, sem inferir
nacionalidade. Manifestações e pedidos do Balcão são
conjuntos separados; não representam uma contagem de
investidores únicos.
</p>
<form
className="my-3 flex flex-wrap gap-3"
onSubmit={(e) => {
e.preventDefault();
form.get('/management/crm/leads', {
preserveState: false,
});
}}
>
<label className="max-w-full">
Origem das estatísticas
<select
aria-label="Origem das estatísticas"
className={`${control} max-w-full`}
value={form.data.origin}
onChange={(e) =>
form.setData('origin', e.target.value)
}
>
<option value="">Todas as origens</option>
<option value="unknown">Não informado</option>
{countries.map((country) => (
<option key={country.code} value={country.code}>
{country.name}
</option>
))}
</select>
</label>
<button className={button} disabled={form.processing}>
Filtrar estatísticas
</button>
</form>
<p className="text-sm">
Este filtro aplica-se apenas às contagens de solicitações. A
lista de leads mantém os filtros abaixo.
</p>
{(['interests', 'desk'] as const).map((group) => (
<section key={group}>
<h2>
{group === 'interests'
? 'Manifestações recebidas'
: 'Pedidos do Balcão em acompanhamento'}
</h2>
{originStatistics[group].length ? (
<ul>
{originStatistics[group].map((row) => (
<li key={row.code ?? 'unknown'}>
{row.name}: {row.count}
</li>
))}
</ul>
) : (
<p>Sem pedidos neste conjunto.</p>
)}
</section>
))}
</details>
<form
className="flex flex-wrap gap-3"
onSubmit={(e) => {
+10 -1
View File
@@ -1,5 +1,5 @@
import { Link, useForm, usePage } from '@inertiajs/react';
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Frame, input, button, card } from '@/pages/investment/shared';
type Option = {
id: string;
@@ -66,6 +66,14 @@ export default function Panel({
organization_id: ['Organização representada', organizations],
journey_id: ['Percurso territorial existente', journeys],
} as const;
const derivedTitle = useRef('');
const contextTitle = journeys.find((item) => item.id === form.data.journey_id)?.name ?? submissions.find((item) => item.id === form.data.submission_id)?.name ?? companies.find((item) => item.id === form.data.company_id)?.name ?? '';
useEffect(() => {
if ((!form.data.title || form.data.title === derivedTitle.current) && contextTitle) {
form.setData('title', contextTitle.slice(0, 200));
derivedTitle.current = contextTitle.slice(0, 200);
}
}, [contextTitle, form.data.title]);
return (
<Frame title="Balcão Virtual — acompanhamento">
<nav className="flex flex-wrap gap-4">
@@ -102,6 +110,7 @@ export default function Panel({
</section>
<details className={card}>
<summary>Preparar acompanhamento de um projecto</summary>
<p>Selecione um percurso ou investimento da sua conta. O título é recuperado do registo existente quando disponível; pode corrigi-lo antes de guardar. A associação requer a sua autorização abaixo.</p>
<form
className="mt-4 space-y-3"
onSubmit={(e) => {
+7
View File
@@ -1,5 +1,6 @@
import { Link, useForm } from '@inertiajs/react';
import { Frame, card, input, button } from '@/pages/investment/shared';
import CountrySelect, { type Country } from '@/components/country-select';
import {
DocumentReview,
RequestOperations,
@@ -32,6 +33,7 @@ export default function RequestPage({
documents,
payments,
caseVersion,
countries,
}: {
request: {
id: string;
@@ -73,6 +75,7 @@ export default function RequestPage({
documents: DeskDocument[];
payments: DeskPayment[];
caseVersion: number;
countries: Country[];
events: {
id: string;
action: string;
@@ -165,6 +168,10 @@ export default function RequestPage({
form.put(`/balcao-virtual/pedidos/${request.id}`);
}}
>
<label className="block" htmlFor="declared-origin-country">País de origem declarado desta solicitação</label>
<CountrySelect id="declared-origin-country" countries={countries} value={form.data.payload.declared_origin_country_code ?? ''} disabled={!canEdit || form.processing || (request.status === 'returned' && !request.editable_fields.includes('declared_origin_country_code'))} onChange={(value) => form.setData('payload', { ...form.data.payload, declared_origin_country_code: value })} />
<p>A origem não é deduzida do IP nem do país de residência. Pedidos antigos sem declaração mantêm “não informado”.</p>
{form.errors['payload.declared_origin_country_code'] && <p role="alert">{form.errors['payload.declared_origin_country_code']}</p>}
{Object.entries(definition.fields)
.filter(
([, f]) =>
+12 -2
View File
@@ -98,6 +98,7 @@ export default function Catalogue({
{ id: number; title: string; location: string; url: string }[]
>([]);
const [truncated, setTruncated] = useState(false);
const [mapContext, setMapContext] = useState<{ id: string; title: string; kind: string; url: string }[]>([]);
const [error, setError] = useState('');
const [suggestions, setSuggestions] = useState<
{ title: string; slug: string }[]
@@ -152,6 +153,7 @@ export default function Catalogue({
setPoints(null);
setAreas([]);
setUnlocated([]);
setMapContext([]);
setTruncated(false);
setError('');
api(`/api/v1/investment/map?${params}`)
@@ -160,6 +162,7 @@ export default function Catalogue({
setPoints(data.features);
setAreas(data.areas ?? []);
setUnlocated(data.unlocated ?? []);
setMapContext(data.territorial_context ?? []);
setTruncated(Boolean(data.truncated));
}
})
@@ -227,6 +230,7 @@ export default function Catalogue({
const dimensionUrl = (dimension: string) => {
const params = new URLSearchParams(query());
params.set('dimension', dimension);
['cluster_id', 'corridor_id', 'zee_id', 'zfi_id', 'park_id', 'project_id'].forEach((key) => params.delete(key));
params.delete('page');
return `${catalogueUrl}?${params}`;
};
@@ -329,7 +333,7 @@ export default function Catalogue({
index={index}
parent={territoryPath[index - 1]?.id ?? ''}
selected={territoryPath[index]}
endpoint="/api/v1/catalogs/jurisdictions"
endpoint="/api/v1/investment/territory-options"
onChange={(value) => {
const path = [
...territoryPath.slice(0, index),
@@ -387,7 +391,7 @@ export default function Catalogue({
['zfi_id', 'zfi', 'ZFI'],
['park_id', 'industrial_park', 'Parque industrial'],
] as const
).map(([field, category, label]) => (
).filter(([field]) => !form.data.dimension || field === `${form.data.dimension}_id` || !!form.data[field]).map(([field, category, label]) => (
<Field key={field} label={label}>
<select
className={input}
@@ -425,6 +429,7 @@ export default function Catalogue({
'Projecto âncora',
] as const
)[index];
if (form.data.dimension && key !== `${form.data.dimension}_id` && !form.data[key]) return null;
return (
<Field key={group} label={label}>
<select
@@ -1086,6 +1091,11 @@ export default function Catalogue({
</div>
{error && <p role="alert">{error}</p>}
{!error && <Map points={points} areas={areas} />}
{['map', 'combined'].includes(view) && !error && mapContext.length > 0 && <section className={card} aria-label="Estruturas territoriais consultáveis">
<h2 className="text-xl font-bold">Estruturas territoriais e projetos</h2>
<p>Estas fichas continuam consultáveis independentemente de terem coordenadas. A lista não atribui implantação às oportunidades nem cria pontos no mapa. Até 30 fichas por conjunto; refine a localização para pesquisar.</p>
<ul>{mapContext.map((record) => <li key={record.id}><Link className="underline" href={record.url}>{record.kind}: {record.title}</Link></li>)}</ul>
</section>}
{truncated && (
<p role="alert">
O mapa atingiu o limite de 1.000 registos; refine os
+19 -5
View File
@@ -1,12 +1,15 @@
import { Link, router, useForm } from '@inertiajs/react';
import { Frame, Field, input, button, card } from './shared';
import PartnershipSourceLinks from '@/components/partnership-source-links';
import CountrySelect, { type Country } from '@/components/country-select';
type RecordData = { version: number; payload: Record<string, string> };
type Company = RecordData & { id: string };
const labels: Record<string, string> = {
phone: 'Telefone',
country: 'País',
country: 'País anteriormente registado',
residence_country_code: 'País de residência do investidor',
incorporation_country_code: 'País de constituição da entidade',
sector: 'Sector de interesse',
experience: 'Experiência relevante',
investment_capacity: 'Capacidade de investimento declarada',
@@ -19,9 +22,11 @@ const labels: Record<string, string> = {
export default function Profile({
account,
companies,
countries,
}: {
account: RecordData;
companies: Company[];
countries: Country[];
}) {
return (
<Frame title="Perfil de investimento e empresas">
@@ -39,9 +44,10 @@ export default function Profile({
<Editor
key={'account-' + account.version}
record={account}
countries={countries}
fields={[
'phone',
'country',
'residence_country_code',
'sector',
'experience',
'investment_capacity',
@@ -62,9 +68,10 @@ export default function Profile({
<Editor
key={company.version}
record={company}
countries={countries}
fields={[
'name',
'country',
'incorporation_country_code',
'registration',
'sector',
'role',
@@ -90,9 +97,10 @@ export default function Profile({
key={'new-' + companies.length}
record={{ version: 0, payload: {} }}
company
countries={countries}
fields={[
'name',
'country',
'incorporation_country_code',
'registration',
'sector',
'role',
@@ -108,16 +116,18 @@ function Editor({
record,
fields,
company = false,
countries,
}: {
record: RecordData & { id?: string };
fields: string[];
company?: boolean;
countries: Country[];
}) {
const isCompany = company || !!record.id;
const form = useForm({
version: record.version,
payload: Object.fromEntries(
fields.map((key) => [key, record.payload[key] ?? '']),
[...fields, 'country'].map((key) => [key, record.payload[key] ?? '']),
),
});
return (
@@ -136,8 +146,10 @@ function Editor({
}}
>
<div className="grid gap-4 md:grid-cols-2">
{record.payload.country && <p>País anteriormente registado: {record.payload.country}. Conservado sem atribuir automaticamente residência, constituição ou origem da solicitação.</p>}
{fields.map((key) => (
<Field key={key} label={labels[key]}>
{key.endsWith('_country_code') ? <CountrySelect id={key + (record.id ?? 'account')} countries={countries} value={form.data.payload[key]} onChange={(value) => form.setData('payload', { ...form.data.payload, [key]: value })} /> :
<input
className={input}
value={form.data.payload[key]}
@@ -159,6 +171,8 @@ function Editor({
})
}
/>
}
{form.errors[`payload.${key}`] && <p role="alert" className="text-red-700">{form.errors[`payload.${key}`]}</p>}
</Field>
))}
</div>
+15 -1
View File
@@ -4,11 +4,15 @@ import { Frame, Field, input, button, card } from './shared';
import type { References } from './shared';
import { Conversation } from './conversation';
import type { Message } from './conversation';
import CountrySelect, { type Country } from '@/components/country-select';
type Payload = {
full_name: string;
email: string;
phone: string;
country: string;
residence_country_code: string;
incorporation_country_code: string;
declared_origin_country_code: string;
organisation: string;
representative_role: string;
experience: string;
@@ -51,6 +55,7 @@ type Need = {
};
};
type Props = {
countries: Country[];
zone_origin?: {
zone_id: string;
zone_version: number;
@@ -119,12 +124,16 @@ function Editor({
revisions,
step,
setStep,
countries,
}: Props & { step: number; setStep: (step: number) => void }) {
const initial: Payload = {
full_name: '',
email: '',
phone: '',
country: '',
residence_country_code: '',
incorporation_country_code: '',
declared_origin_country_code: '',
organisation: '',
representative_role: '',
experience: '',
@@ -349,7 +358,12 @@ function Editor({
{text('full_name', 'Nome completo', true)}
{text('email', 'Email', true, 'email')}
{text('phone', 'Telefone')}
{text('country', 'País', true)}
{form.data.payload.country && <p>País anteriormente registado: {form.data.payload.country}. Não foi convertido em residência, constituição ou origem.</p>}
{(['residence_country_code', 'incorporation_country_code', 'declared_origin_country_code'] as const).map((key) => <Field key={key} label={{ residence_country_code: 'País de residência', incorporation_country_code: 'País de constituição da entidade', declared_origin_country_code: 'País de origem declarado desta solicitação' }[key]}>
<CountrySelect disabled={readonly} id={key} countries={countries} value={form.data.payload[key] ?? ''} onChange={(value) => form.setData('payload', { ...form.data.payload, [key]: value })} />
{form.errors[`payload.${key}`] && <p role="alert">{form.errors[`payload.${key}`]}</p>}
</Field>)}
<p>A origem da solicitação é uma declaração sua; não é deduzida da residência, da entidade ou do endereço IP.</p>
{option(
'profile_id',
'investor_profile',
@@ -10,6 +10,7 @@ export default function Install({
backupDate,
rollback,
actionUrl = "/management/opportunity-management-install",
imageProcessingAvailable,
}: {
installed: boolean;
ready: boolean;
@@ -20,6 +21,7 @@ export default function Install({
backupDate: string | null;
rollback: string;
actionUrl?: string;
imageProcessingAvailable?: boolean;
}) {
const form = useForm({ hash, confirm: false });
return (
@@ -34,6 +36,10 @@ export default function Install({
))}
</ul>
<p>Migração: {migration}</p>
{imageProcessingAvailable !== undefined && <section className="rounded border border-amber-500 p-3" aria-label="Limitações dos anexos">
<p>{imageProcessingAvailable ? 'GD/WebP disponível neste servidor; o processamento continua a exigir scan concluído do original.' : 'GD/WebP indisponível neste servidor: guardar imagens é possível; gerar miniaturas permanece pendente.'}</p>
<p>Instalar recursos não confirma scans. Documentos e imagens com scan pendente ficam sem download ou pré-visualização; preparação de rascunhos e localizações continua disponível. ClamD real mantém uma pendência separada.</p>
</section>}
<p>Backup/restauro: {backupDate ?? "Prova ainda por preparar"}</p>
<p>{rollback}</p>
<p>
@@ -129,6 +129,7 @@ export default function OpportunityResources({
resources,
existingLocations,
assessment,
imageProcessingAvailable,
}: {
opportunity: {
id: number;
@@ -140,6 +141,7 @@ export default function OpportunityResources({
resources: Record<Kind, Resource[]>;
existingLocations: { id: string; name: string; location_type: string }[];
assessment: string[];
imageProcessingAvailable: boolean;
}) {
const [kind, setKind] = useState<Kind>("locations");
const [editing, setEditing] = useState<Resource | null>(null);
@@ -281,6 +283,8 @@ export default function OpportunityResources({
"Guardar é independente de validar, submeter e publicar."}
{errors.etag && <p>{errors.etag}</p>}
</div>
{kind === 'images' && !imageProcessingAvailable && <p role="status" className="rounded border border-amber-500 p-3">Processamento de imagens indisponível: GD/WebP não está carregado no servidor. Pode guardar o original e os metadados; miniaturas e imagens para divulgação ficam pendentes.</p>}
{kind !== 'locations' && <p className="rounded border p-3">Pode guardar anexos para preparação. Cada ficheiro permanece sem download ou pré-visualização enquanto o seu scan não estiver concluído. ClamD pendente não impede guardar metadados, locais ou rascunhos; testes com scanner simulado não verificam os seus ficheiros.</p>}
<section>
<h2>Conferência e pendências</h2>
<ul>
@@ -610,7 +614,7 @@ export default function OpportunityResources({
<button
type="button"
className={button}
disabled={busy || unavailable}
disabled={busy || unavailable || !imageProcessingAvailable}
onClick={() => void execute("prepare")}
>
Preparar WebP e miniaturas
+78
View File
@@ -4,11 +4,14 @@ namespace Tests\Feature;
use App\Models\InterestRequest;
use App\Models\User;
use App\Services\CountryCatalogue;
use App\Services\DeclaredOriginStatistics;
use Database\Seeders\InvestmentReferenceSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;
class InvestmentWorkspaceTest extends TestCase
@@ -20,6 +23,81 @@ class InvestmentWorkspaceTest extends TestCase
return $this->actingAs($user)->postJson('/api/v1/investment/submissions')->assertCreated()->json('id');
}
public function test_country_meanings_are_persisted_separately_without_inference_and_recovered_by_account(): void
{
$user = User::factory()->create();
$this->actingAs($user)->putJson('/api/v1/investment/profile', ['version' => 0, 'payload' => ['country' => 'Valor histórico', 'residence_country_code' => 'PT']])->assertOk();
$company = $this->postJson('/api/v1/investment/companies', ['version' => 0, 'payload' => ['name' => 'Entidade QA', 'role' => 'Representante', 'incorporation_country_code' => 'MZ']])->assertOk()->json('id');
$draft = $this->postJson('/api/v1/investment/submissions', ['company_id' => $company])->assertCreated()->json('id');
$this->getJson('/api/v1/investment/submissions/'.$draft)->assertJsonPath('submission.payload.residence_country_code', 'PT')->assertJsonPath('submission.payload.incorporation_country_code', 'MZ')->assertJsonPath('submission.payload.country', 'Valor histórico')->assertJsonMissingPath('submission.payload.declared_origin_country_code');
$this->putJson('/api/v1/investment/submissions/'.$draft, ['version' => 1, 'payload' => ['residence_country_code' => 'PT', 'incorporation_country_code' => 'MZ', 'declared_origin_country_code' => 'ZA']])->assertOk();
// Discard the original authenticated context: recovery uses server data, not local storage.
$this->actingAs(User::factory()->create())->getJson('/api/v1/investment/submissions/'.$draft)->assertNotFound();
$this->actingAs(User::findOrFail($user->id))->getJson('/api/v1/investment/submissions/'.$draft)->assertJsonPath('submission.payload.declared_origin_country_code', 'ZA');
$this->putJson('/api/v1/investment/submissions/'.$draft, ['version' => 2, 'payload' => ['declared_origin_country_code' => 'ZZ']])->assertUnprocessable()->assertJsonValidationErrors('payload.declared_origin_country_code');
$this->getJson('/api/v1/investment/submissions/'.$draft)->assertJsonPath('submission.version', 2)->assertJsonPath('submission.payload.declared_origin_country_code', 'ZA');
$countries = app(CountryCatalogue::class)->all();
$this->assertCount(249, $countries);
$this->assertCount(249, array_unique(array_column($countries, 'code')));
}
public function test_declared_origin_is_snapshotted_on_submission_and_statistics_do_not_infer_old_country(): void
{
$this->seed(InvestmentReferenceSeeder::class);
$user = User::factory()->create();
$id = $this->draft($user);
$data = $this->payload();
$data['payload']['declared_origin_country_code'] = 'ZA';
$this->postJson('/api/v1/investment/submissions/'.$id.'/submit', $data)->assertOk();
$interest = InterestRequest::firstOrFail();
$this->assertSame('ZA', $interest->metadata['declared_origin_country_code']);
$this->assertSame('Moçambique', $interest->country);
$this->expectException(HttpException::class);
app(DeclaredOriginStatistics::class)->forManager($user);
}
public function test_authorized_origin_statistics_keep_historical_missing_as_not_informed(): void
{
$this->seed(InvestmentReferenceSeeder::class);
$user = User::factory()->create();
$id = $this->draft($user);
$this->postJson('/api/v1/investment/submissions/'.$id.'/submit', $this->payload())->assertOk();
config(['portal.managers' => [$user->email]]);
$stats = app(DeclaredOriginStatistics::class)->forManager($user);
$this->assertSame([['code' => null, 'name' => 'Não informado', 'count' => 1]], $stats['interests']);
$this->assertSame([], $stats['desk']);
}
public function test_crm_origin_filter_separates_explicit_missing_and_empty_results_without_writing(): void
{
$this->withoutVite();
$this->seed(InvestmentReferenceSeeder::class);
$user = User::factory()->create();
foreach ([null, 'PT', 'ZA'] as $origin) {
$id = $this->draft($user);
$data = $this->payload();
if ($origin !== null) {
$data['payload']['declared_origin_country_code'] = $origin;
}
$this->postJson('/api/v1/investment/submissions/'.$id.'/submit', $data)->assertOk();
}
$before = InterestRequest::orderBy('id')->get()->toArray();
config(['portal.managers' => [$user->email]]);
$this->get('/management/crm/leads?origin=PT')->assertOk()->assertInertia(fn (AssertableInertia $page) => $page
->where('filters.origin', 'PT')->has('originStatistics.interests', 1)
->where('originStatistics.interests.0.code', 'PT')->where('originStatistics.interests.0.count', 1));
$this->get('/management/crm/leads?origin=unknown')->assertOk()->assertInertia(fn (AssertableInertia $page) => $page
->has('originStatistics.interests', 1)->where('originStatistics.interests.0.code', null)
->where('originStatistics.interests.0.count', 1));
$this->get('/management/crm/leads?origin=MZ')->assertOk()->assertInertia(fn (AssertableInertia $page) => $page
->has('originStatistics.interests', 0)->has('originStatistics.desk', 0));
$this->get('/management/crm/leads')->assertOk()->assertInertia(fn (AssertableInertia $page) => $page
->has('originStatistics.interests', 3));
$this->getJson('/management/crm/leads?origin=ZZ')->assertUnprocessable()->assertJsonValidationErrors('origin');
$this->assertSame($before, InterestRequest::orderBy('id')->get()->toArray());
$this->actingAs(User::factory()->create())->get('/management/crm/leads?origin=PT')->assertForbidden();
}
private function payload(int $version = 1): array
{
return ['version' => $version, 'payload' => ['full_name' => 'Investidor sintético', 'email' => 'qa@example.test', 'country' => 'Moçambique',
+21 -3
View File
@@ -52,6 +52,22 @@ class InvestorGuidanceTest extends TestCase
return $this->actingAs($this->manager)->postJson('/management/balcao/servicos', ['code' => 'project-preparation', 'confirm' => true])->assertCreated()->json('id');
}
public function test_desk_declared_origin_is_recovered_and_invalid_country_preserves_draft(): void
{
$case = $this->dossier();
$service = $this->service();
$id = $this->actingAs($this->investor)->postJson('/balcao-virtual/dossiers/'.$case.'/pedidos', ['service_version_id' => $service, 'client_key' => (string) Str::uuid()])->assertCreated()->json('id');
$url = '/balcao-virtual/pedidos/'.$id;
$this->getJson($url)->assertOk()->assertJsonMissingPath('request.payload.declared_origin_country_code');
$this->putJson($url, ['version' => 1, 'payload' => ['title' => 'Preparação QA', 'declared_origin_country_code' => 'PT']])->assertOk();
$this->actingAs(User::factory()->create())->getJson($url)->assertForbidden();
$this->actingAs($this->investor)->getJson($url)->assertOk()->assertJsonPath('request.payload.declared_origin_country_code', 'PT')->assertJsonPath('request.status', 'draft');
$this->putJson($url, ['version' => 2, 'payload' => ['title' => 'Preparação QA', 'declared_origin_country_code' => 'ZZ']])->assertUnprocessable()->assertJsonValidationErrors('payload.declared_origin_country_code');
$this->getJson($url)->assertJsonPath('request.payload.declared_origin_country_code', 'PT')->assertJsonPath('request.version', 2);
$this->assertDatabaseCount('interest_requests', 0);
Http::assertNothingSent();
}
public function test_panel_creation_redirect_confirms_persistence_and_never_submits_a_request(): void
{
$payload = ['title' => 'Dossier de teste QA do painel', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true];
@@ -76,9 +92,11 @@ class InvestorGuidanceTest extends TestCase
$this->actingAs($this->manager)->postJson('/balcao-virtual/pedidos/'.$requestId.'/mensagens', ['body' => 'Resposta original do responsável.', 'client_key' => (string) Str::uuid()])->assertOk();
config(['portal.managers' => []]);
$this->actingAs($this->investor)->getJson('/balcao-virtual/pedidos/'.$requestId)->assertOk()->assertJsonPath('messages.0.kind', 'responsible_response')->assertJsonPath('messages.0.name', $this->manager->name);
DB::table('desk_messages')->insert(['id' => (string) Str::uuid(), 'request_id' => $requestId, 'actor_id' => $this->investor->id,
$historicalId = (string) Str::uuid();
DB::table('desk_messages')->insert(['id' => $historicalId, 'request_id' => $requestId, 'actor_id' => $this->investor->id,
'client_key' => (string) Str::uuid(), 'body' => Crypt::encryptString('Mensagem histórica sem tipo original.'), 'created_at' => now()->addSecond()]);
$this->getJson('/balcao-virtual/pedidos/'.$requestId)->assertJsonPath('messages.1.kind', 'historical_unclassified');
$messages = $this->getJson('/balcao-virtual/pedidos/'.$requestId)->assertOk()->json('messages');
$this->assertSame('historical_unclassified', collect($messages)->firstWhere('id', $historicalId)['kind']);
$this->assertDatabaseCount('desk_messages', 2);
Http::assertNothingSent();
}
@@ -320,7 +338,7 @@ class InvestorGuidanceTest extends TestCase
$this->mock(VirtualDesk::class)->shouldReceive('installed')->andReturn(false);
$this->actingAs($this->investor)->getJson('/management/balcao-install')->assertForbidden();
$view = $this->actingAs($this->manager)->getJson('/management/balcao-install')->assertOk()
->assertJsonPath('target.database', 'investmoz_v2_20261008_test')->assertJsonPath('backupVerified', false)
->assertJsonPath('target.database', DB::connection()->getDatabaseName())->assertJsonPath('backupVerified', false)
->assertJsonStructure(['pendingMigrations', 'databaseReady', 'scanner', 'rollback', 'compatibilityVerified'])->json();
$before = DB::table('migrations')->count();
$this->postJson('/management/balcao-install', ['hash' => $view['hash'], 'confirm' => true])->assertStatus(422);
+2 -1
View File
@@ -95,6 +95,7 @@ foreach ($manifest as $name => $hash) {
$read->close();
$proof = ['restore_verified' => true, 'verified_at' => gmdate('c'), 'target' => $target, 'restored_target' => $restored,
'dump_path' => $dump, 'sha256' => hash_file('sha256', $dump), 'table_counts' => $counts, 'table_hashes' => $hashes,
'files_verified' => true, 'files_path' => $files, 'files_sha256' => hash_file('sha256', $files), 'file_hashes' => $manifest];
'files_verified' => true, 'files_path' => $files, 'files_sha256' => hash_file('sha256', $files), 'file_hashes' => $manifest,
'audit_event_ids' => $active->query('SELECT id FROM audit_events ORDER BY id')->fetchAll(PDO::FETCH_COLUMN)];
file_put_contents(storage_path('app/private/virtual-desk/backup-proof.json'), json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode(['restore_verified' => true, 'tables' => count($tables), 'files' => count($manifest), 'restore_target' => $restored], JSON_THROW_ON_ERROR);
+1 -1
View File
@@ -8,5 +8,5 @@ const desktop=await session({width:1280,height:900}),page=desktop.page;await pag
await page.getByLabel('Título',{exact:true}).fill('QA production location');await page.getByLabel('Papel da localização',{exact:true}).selectOption('production');const provinceSelect=page.getByLabel('Província',{exact:true});await provinceSelect.locator('option').nth(1).waitFor({state:'attached'});const province=await provinceSelect.locator('option').nth(1).getAttribute('value');await provinceSelect.selectOption(province);const districtSelect=page.getByLabel('Distrito',{exact:true});await districtSelect.locator('option').nth(1).waitFor({state:'attached'});const district=await districtSelect.locator('option').nth(1).getAttribute('value');await districtSelect.selectOption(district);await page.getByLabel('Distribuição (%)',{exact:true}).fill('60');await page.getByLabel('Localização principal',{exact:true}).check();await page.getByLabel('Motivo (20–1000 caracteres)').fill('Localização e distribuição declaradas em QA');await page.getByRole('button',{name:'Guardar recurso',exact:true}).click();await page.locator('article').filter({hasText:'QA production location'}).waitFor();
await page.getByLabel('Título',{exact:true}).fill('QA market location');await page.getByLabel('Papel da localização',{exact:true}).selectOption('market');await provinceSelect.selectOption(province);await districtSelect.locator('option').nth(1).waitFor({state:'attached'});await districtSelect.selectOption(district);await page.getByLabel('Distribuição (%)',{exact:true}).fill('40');await page.getByRole('button',{name:'Guardar recurso',exact:true}).click();await page.locator('article').filter({hasText:'QA market location'}).waitFor();assert.equal(await page.locator('article').count(),2);
await page.getByRole('button',{name:'Documentos',exact:true}).click();await page.getByLabel('Título',{exact:true}).fill('QA source document');await page.getByLabel('Original / substituir por nova versão').setInputFiles({name:'qa.pdf',mimeType:'application/pdf',buffer:Buffer.from('%PDF-1.7\nSynthetic QA document\n%%EOF')});await page.getByRole('button',{name:'Guardar recurso',exact:true}).click();await page.locator('article').filter({hasText:'QA source document'}).waitFor();
await page.getByRole('button',{name:'Imagens',exact:true}).click();await page.getByLabel('Título',{exact:true}).fill('QA synthetic image');await page.getByLabel('Legenda',{exact:true}).fill('Synthetic image only used in isolated QA');await page.getByLabel('Texto alternativo',{exact:true}).fill('Black square generated for isolated QA');await page.getByLabel('Direitos de utilização',{exact:true}).selectOption('owned');await page.getByLabel('Original / substituir por nova versão').setInputFiles({name:'qa.png',mimeType:'image/png',buffer:png()});await page.getByRole('button',{name:'Guardar recurso',exact:true}).click();const imageArticle=page.locator('article').filter({hasText:'QA synthetic image'});await imageArticle.waitFor();await imageArticle.getByRole('button',{name:'Ver / editar / restaurar'}).click();await page.getByRole('button',{name:'Preparar WebP e miniaturas'}).click();await page.getByText(/GD\/WebP não disponível neste processo|Scan do original pendente ou inseguro/).waitFor();assert.equal(await page.getByLabel('Título',{exact:true}).inputValue(),'QA synthetic image');await page.screenshot({path:dir+'opportunity-resources-desktop.png',fullPage:true});await desktop.context.close();
await page.getByRole('button',{name:'Imagens',exact:true}).click();await page.getByLabel('Título',{exact:true}).fill('QA synthetic image');await page.getByLabel('Legenda',{exact:true}).fill('Synthetic image only used in isolated QA');await page.getByLabel('Texto alternativo',{exact:true}).fill('Black square generated for isolated QA');await page.getByLabel('Direitos de utilização',{exact:true}).selectOption('owned');await page.getByLabel('Original / substituir por nova versão').setInputFiles({name:'qa.png',mimeType:'image/png',buffer:png()});await page.getByRole('button',{name:'Guardar recurso',exact:true}).click();const imageArticle=page.locator('article').filter({hasText:'QA synthetic image'});await imageArticle.waitFor();await imageArticle.getByRole('button',{name:'Ver / editar / restaurar'}).click();const prepareButton=page.getByRole('button',{name:'Preparar WebP e miniaturas'});if(await prepareButton.isDisabled()){await page.getByText(/Processamento de imagens indisponível/).waitFor();}else{await prepareButton.click();await page.getByText(/Scan do original pendente ou inseguro/).waitFor();}assert.equal(await page.getByLabel('Título',{exact:true}).inputValue(),'QA synthetic image');await page.screenshot({path:dir+'opportunity-resources-desktop.png',fullPage:true});await desktop.context.close();
const mobile=await session({width:390,height:844}),mp=mobile.page;await mp.goto(base+`/management/opportunities/${id}/resources`);await mp.evaluate(()=>localStorage.clear());await mp.reload();assert.equal(await mp.locator('article').count(),2);await mp.getByRole('button',{name:'Imagens',exact:true}).click();const mi=mp.locator('article').filter({hasText:'QA synthetic image'});await mi.getByRole('button',{name:'Ver / editar / restaurar'}).click();assert.equal(await mp.getByLabel('Texto alternativo',{exact:true}).inputValue(),'Black square generated for isolated QA');await mp.getByLabel('Motivo (20–1000 caracteres)').fill('Exclusão lógica de imagem somente no ensaio QA');await mp.getByLabel('Confirmar exclusão lógica / restauração').check();await mp.getByRole('button',{name:'Excluir recurso logicamente'}).click();await mp.getByText('eliminado logicamente',{exact:false}).first().waitFor();await mi.getByRole('button',{name:'Ver / editar / restaurar'}).click();await mp.getByLabel('Confirmar exclusão lógica / restauração').check();await mp.getByRole('button',{name:'Restaurar recurso'}).click();await mi.getByText(/rascunho/).waitFor();await mp.screenshot({path:dir+'opportunity-resources-mobile.png',fullPage:true});assert(await mp.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));assert.deepEqual(errors,[]);const proof={recorded_at:new Date().toISOString(),id,desktop:true,mobile:true,new_session_recovery:true,multiple_locations:true,documents:true,images:true,scan_failure_visible:true,soft_delete_restore:true,errors,active_writes:0};fs.writeFileSync(dir+'opportunity-resources-browser.json',JSON.stringify(proof,null,2));console.log(JSON.stringify(proof));await browser.close();})().catch(e=>{console.error(e);process.exit(1)});
@@ -0,0 +1,30 @@
<?php
// Read-only installation readiness; never applies migrations or authenticates an actor.
require dirname(__DIR__).'/vendor/autoload.php';
$app = require dirname(__DIR__).'/bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$db = $app->make('db')->connection();
$db->beginTransaction();
try {
$db->statement('SET TRANSACTION READ ONLY');
$preview = $app->make(\App\Services\OpportunityResourceInstallation::class)->preview();
$proofPath = storage_path('app/private/investmoz-v2/opportunity-resources-activation-proof.json');
$proof = is_file($proofPath) ? json_decode(file_get_contents($proofPath), true, 512, JSON_THROW_ON_ERROR) : [];
$result = [
'captured_at' => gmdate('c'),
'read_only' => $db->selectOne("SELECT current_setting('transaction_read_only') AS value")->value,
'installed' => $preview['installed'],
'ready' => $preview['ready'],
'target' => $preview['target'],
'current_code_hash' => $preview['code_hash'],
'proof_code_hash' => $proof['code_hash'] ?? null,
'code_matches_proof' => ($proof['code_hash'] ?? '') === $preview['code_hash'],
'backup_date' => $preview['backupDate'],
'active_writes' => 0,
];
file_put_contents(storage_path('app/private/investmoz-v2/opportunity-resources-readiness.json'), json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode($result, JSON_THROW_ON_ERROR).PHP_EOL;
} finally {
$db->rollBack();
}
+5
View File
@@ -66,6 +66,11 @@ if (PHP_SAPI === 'cli' && ($argv[1] ?? '') === 'verify') {
foreach (['opportunities', 'opportunity_projects', 'opportunity_clusters', 'opportunity_locations', 'geo_locations', 'documents', 'opportunity_favourites', 'interest_requests', 'interest_submission_receipts', 'investment_messages', 'tasks', 'project_locations', 'cluster_projects', 'audit_events'] as $table) {
$key = '"public"."'.$table.'"';
$query = $table === 'opportunities' ? '(SELECT '.$projection.' FROM opportunities WHERE management_creation_key IS NULL)' : ($table === 'audit_events' ? "(SELECT * FROM audit_events WHERE source_system IS DISTINCT FROM 'opportunity-resources' AND source_system IS DISTINCT FROM 'opportunity-management')" : $key);
if ($table === 'audit_events' && isset($backup['audit_event_ids'])) {
// Compare every original event by identity; new QA actions must not hide or replace history.
$originalIds = array_map(fn ($id) => DB::connection()->getPdo()->quote($id), $backup['audit_event_ids']);
$query = '(SELECT * FROM audit_events WHERE id IN ('.($originalIds ? implode(',', $originalIds) : 'NULL').'))';
}
if ($table === 'documents') {
$oldDocumentColumns = array_diff(array_keys((array) DB::selectOne('SELECT * FROM documents LIMIT 1')), ['storage_encryption']);
$documentProjection = implode(',', array_map(fn ($column) => '"'.$column.'"', $oldDocumentColumns));
@@ -0,0 +1,51 @@
const {chromium} = require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs = require('node:fs'), assert = require('node:assert/strict');
const base = 'http://127.0.0.1:18096';
(async () => {
const browser = await chromium.launch({headless:true, executablePath:'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe'});
const errors = [], results = [];
async function login(width) {
const context = await browser.newContext({viewport:{width,height:900}});
await context.route('**/*', route => route.request().url().startsWith(base) || route.request().url().startsWith('data:') ? route.continue() : route.abort());
const page = await context.newPage(); page.on('pageerror', e => errors.push(e.message));
await page.goto(base+'/login');
assert.match((await page.request.get(base+'/login')).headers()['x-investmoz-qa-target'], /_test:55441$/);
await page.getByLabel('Email address').fill('opportunity-manager@example.invalid');
await page.getByLabel('Password',{exact:true}).fill('Opportunity-QA-2026!');
await page.getByRole('button',{name:'Log in',exact:true}).click(); await page.waitForURL('**/dashboard');
return {page,context};
}
const desktop = await login(1280), p = desktop.page;
await p.goto(base+'/investidor/profile');
await p.locator('#residence_country_codeaccount').selectOption('PT');
const saved = p.waitForResponse(r => r.request().method() === 'PUT' && r.url().endsWith('/investidor/profile'));
await p.getByRole('button',{name:'Guardar perfil de investimento',exact:true}).click();
const response = await saved;
assert([200, 302, 303].includes(response.status()), `Guardar perfil: HTTP ${response.status()}`);
await p.getByText('Perfil de investimento guardado.', {exact:true}).waitFor();
await p.reload(); assert.equal(await p.locator('#residence_country_codeaccount').inputValue(),'PT');
results.push({route:'/investidor/profile',action:'guardar residência',http:response.status(),visibleResult:true,recovered:true});
await desktop.context.close();
const mobile = await login(390), m = mobile.page;
await m.goto(base+'/investidor/profile'); await m.evaluate(()=>localStorage.clear()); await m.reload();
assert.equal(await m.locator('#residence_country_codeaccount').inputValue(),'PT');
const search = m.getByRole('textbox',{name:'Pesquisar país'}).first(); await search.fill('portugal');
assert.equal(await m.locator('#residence_country_codeaccount option').count(),2);
assert(await m.evaluate(()=>document.documentElement.scrollWidth <= innerWidth+1));
await m.screenshot({path:'storage/app/private/investmoz-v2/functional-country-mobile.png',fullPage:true});
results.push({route:'/investidor/profile',action:'nova sessão móvel e pesquisa',serverRecovery:true,localStorage:false});
await m.goto(base+'/oportunidades?dimension=cluster');
assert.equal(await m.getByText('ZEE',{exact:true}).count(),0);
await m.getByRole('textbox',{name:'Pesquisar Província'}).fill('Maputo');
await m.getByLabel('Província',{exact:true}).locator('option').nth(1).waitFor({state:'attached'});
assert(await m.getByLabel('Província',{exact:true}).locator('option').count()>1);
results.push({route:'/oportunidades?dimension=cluster',action:'filtros contextuais e pesquisa provincial',visible:true});
await m.goto(base+'/oportunidades?view=map');
await m.getByRole('region', {name:'Estruturas territoriais consultáveis'}).waitFor();
assert(await m.getByRole('region', {name:'Estruturas territoriais consultáveis'}).getByRole('link').count() > 0);
results.push({route:'/oportunidades?view=map',action:'consultar estruturas sem inventar coordenadas',visible:true});
assert.deepEqual(errors,[]);
const proof={captured_at:new Date().toISOString(),results,errors,active_writes:0,scope:'country profile and catalogue filters only; not full portal acceptance'};
fs.writeFileSync('storage/app/private/investmoz-v2/functional-country-browser.json',JSON.stringify(proof,null,2));
console.log(JSON.stringify(proof)); await browser.close();
})().catch(e=>{console.error(e);process.exit(1)});