@@ -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,
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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)}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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]) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)});
|
||||
Reference in New Issue
Block a user