Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c83ab90c68 | ||
|
|
8286d7504d | ||
|
|
aa2b3d05b0 | ||
|
|
97d51db60a | ||
|
|
a1b553372e | ||
|
|
f80d30f478 | ||
|
|
916819eb35 | ||
|
|
1d921c9d03 | ||
|
|
7b1c35793c | ||
|
|
1ec318561c | ||
|
|
9ac623a433 |
@@ -0,0 +1,16 @@
|
||||
.git
|
||||
.idea
|
||||
.agents
|
||||
.codex
|
||||
node_modules
|
||||
vendor
|
||||
public/build
|
||||
storage/logs
|
||||
storage/framework/cache
|
||||
storage/framework/sessions
|
||||
storage/framework/views
|
||||
tests
|
||||
Doc
|
||||
docs
|
||||
*.zip
|
||||
.phpunit.result.cache
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
+342
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -9,6 +9,7 @@ use App\Services\CountryCatalogue;
|
||||
use App\Services\IdempotencyConflict;
|
||||
use App\Services\InterestSubmission;
|
||||
use App\Services\LegacyContent;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use App\Services\Malware\MalwareScanner;
|
||||
use App\Services\VerifiedDocumentBinary;
|
||||
use App\Services\VirtualDesk;
|
||||
@@ -297,6 +298,7 @@ class PortalServicesController extends Controller
|
||||
|
||||
public function manage(Request $request, MalwareScanner $scanner): Response
|
||||
{
|
||||
$managementFilters = $request->validate(['q' => 'nullable|string|max:150', 'state' => 'nullable|in:draft,published,withdrawn', 'intent' => 'nullable|in:document,legislation']);
|
||||
$access = app(\App\Services\DocumentManagementAccess::class);
|
||||
if (! $access->full($request->user())) {
|
||||
$documents = $access->documents($request->user())->where('status', '!=', 'deleted');
|
||||
@@ -304,7 +306,7 @@ class PortalServicesController extends Controller
|
||||
$health['signatures_at'] = $health['signatures_at']?->format(DATE_ATOM);
|
||||
|
||||
$editorial = $access->editorial($request->user());
|
||||
$resources = $editorial ? $access->editorialResources($request->user())->orderByDesc('r.updated_at')
|
||||
$resources = $editorial ? $this->filterManagedResources($access->editorialResources($request->user()), $managementFilters)->orderByDesc('r.updated_at')
|
||||
->get(['r.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.status as file_status', 'd.data_classification', 'd.version_no as file_version', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_failure_reason']) : collect();
|
||||
$links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id');
|
||||
$revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id');
|
||||
@@ -313,6 +315,7 @@ class PortalServicesController extends Controller
|
||||
$resource->revisions = $revisions->get($resource->id, collect())->values();
|
||||
});
|
||||
return Inertia::render('portal/manage', [
|
||||
'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
|
||||
'documentAccessOnly' => ! $editorial, 'editorialScopeOnly' => $editorial, 'resources' => $resources, 'partnerships' => [], 'territories' => [],
|
||||
'requirementCatalogue' => [], 'requirementAssociationsEnabled' => false,
|
||||
'disclosureAuthorization' => null, 'incompleteCount' => $resources->filter(fn ($r) => ! $r->description || ! $r->issuer || ! $r->source_url || $r->source_confirmation === 'unconfirmed' || $r->legal_status === 'unknown' || $r->rights_status === 'unconfirmed')->count(), 'possibleDuplicates' => $resources->whereNotNull('duplicate_of_resource_id')->count(),
|
||||
@@ -331,7 +334,7 @@ class PortalServicesController extends Controller
|
||||
->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1')
|
||||
->where('action', 'public_disclosure_authorized')->first(['external_id', 'occurred_at', 'after_data']);
|
||||
$disclosureAuthorizationData = $disclosureAuthorization ? json_decode($disclosureAuthorization->after_data, true) : null;
|
||||
$resources = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->orderByDesc('r.updated_at')->limit(200)
|
||||
$resources = $this->filterManagedResources(DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at'), $managementFilters)->orderByDesc('r.updated_at')->limit(200)
|
||||
->get(['r.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.status as file_status', 'd.data_classification', 'd.version_no as file_version', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_failure_reason']);
|
||||
$links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id');
|
||||
$resources->each(fn ($resource) => $resource->links = $links->get($resource->id, collect())->values());
|
||||
@@ -342,6 +345,7 @@ class PortalServicesController extends Controller
|
||||
$malwareHealth['signatures_at'] = $malwareHealth['signatures_at']?->format(DATE_ATOM);
|
||||
|
||||
return Inertia::render('portal/manage', ['resources' => $resources,
|
||||
'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
|
||||
'requirementCatalogue' => $this->requirementEvidenceEnabled()
|
||||
? collect(is_array($catalogue = config('document-requirements.catalogue', [])) ? $catalogue : [])->map(fn ($item, $key) => ['key' => $key, ...$item])->values()
|
||||
: collect(),
|
||||
@@ -367,6 +371,39 @@ class PortalServicesController extends Controller
|
||||
'possibleDuplicates' => DB::table('portal_resources')->whereNotNull('duplicate_of_resource_id')->count()]);
|
||||
}
|
||||
|
||||
private function filterManagedResources(Builder $query, array $filters): Builder
|
||||
{
|
||||
if (! empty($filters['q'])) {
|
||||
$term = '%'.addcslashes($filters['q'], '%_\\').'%';
|
||||
$query->where(fn ($q) => $q->where('r.title', 'ilike', $term)->orWhere('r.document_number', 'ilike', $term)->orWhere('d.issuer', 'ilike', $term)->orWhere('d.file_name', 'ilike', $term));
|
||||
}
|
||||
if (! empty($filters['state'])) {
|
||||
$query->where('r.published', $filters['state'] === 'published');
|
||||
if ($filters['state'] !== 'published') {
|
||||
$method = $filters['state'] === 'withdrawn' ? 'whereExists' : 'whereNotExists';
|
||||
$query->$method(fn ($q) => $q->selectRaw('1')->from('portal_resource_revisions as history')->whereColumn('history.resource_id', 'r.id')->whereIn('history.change_type', ['unpublished', 'withdrawn']));
|
||||
}
|
||||
}
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function contentAssociations(Request $request): array
|
||||
{
|
||||
$access = app(\App\Services\DocumentManagementAccess::class);
|
||||
$resources = $access->full($request->user()) ? DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at')
|
||||
: ($access->editorial($request->user()) ? $access->editorialResources($request->user()) : null);
|
||||
$documents = $resources ? $resources->orderBy('r.title')->get(['r.id as value', 'r.title as label', 'r.document_type']) : collect();
|
||||
return [
|
||||
'sector' => \App\Models\Sector::choices()->orderBy('name')->get(['id as value', 'name as label']),
|
||||
'territory' => app(\App\Services\InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id as value', 'name as label']),
|
||||
'opportunity' => app(PortalProfileAccess::class)->scope(\App\Models\Opportunity::query(), $request->user())->orderBy('title')->get(['id as value', 'title as label']),
|
||||
'document' => $documents,
|
||||
'procedure' => $documents->where('document_type', 'procedure')->values(),
|
||||
'stage' => collect(config('virtual-desk.steps', []))->map(fn ($step, $key) => ['value' => $key, 'label' => $step['title']])->values(),
|
||||
'activity' => app(\App\Services\EconomicReferenceCatalog::class)->activities()->map(fn ($row) => ['value' => $row->id, 'label' => $row->display_label])->values(),
|
||||
];
|
||||
}
|
||||
|
||||
public function upload(Request $request): RedirectResponse
|
||||
{
|
||||
$this->validateManagement($request, ['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,text/csv,image/jpeg,image/png,image/webp,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.text'.(app(LegacyContent::class)->available() ? ',application/json' : ''), 'max:10240'],
|
||||
@@ -399,7 +436,7 @@ class PortalServicesController extends Controller
|
||||
|
||||
ScanDocumentForMalware::dispatch($id)->afterCommit();
|
||||
|
||||
return to_route('services.manage')->with('success', 'Documento recebido em armazenamento privado e quarentena. Não aparece na biblioteca pública até validação e publicação explícitas.');
|
||||
return to_route('services.manage', array_filter(['intent' => in_array($request->input('intent'), ['document', 'legislation'], true) ? $request->input('intent') : null]))->with('uploaded_document_id', $id)->with('success', 'Documento recebido em armazenamento privado e quarentena. Não aparece na biblioteca pública até validação e publicação explícitas.');
|
||||
}
|
||||
|
||||
public function resource(Request $request): RedirectResponse
|
||||
@@ -663,7 +700,7 @@ class PortalServicesController extends Controller
|
||||
'data_classification' => ['nullable', 'in:internal,public'],
|
||||
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
'legislation' => ['required', 'boolean'], 'essential' => ['required', 'boolean'],
|
||||
'associations' => ['nullable', 'array', 'max:40'], 'associations.*.target_type' => ['required', 'in:theme,module,activity,stage,procedure,entity,territory,document,investment_type,legal_source'.($this->requirementEvidenceEnabled() ? ',requirement' : '')],
|
||||
'associations' => ['nullable', 'array', 'max:40'], 'associations.*.target_type' => ['required', 'in:theme,module,activity,stage,procedure,entity,territory,document,investment_type,legal_source,sector,opportunity'.($this->requirementEvidenceEnabled() ? ',requirement' : '')],
|
||||
'associations.*.target_key' => ['required', 'string', 'max:180'],
|
||||
'associations.*.review_status' => ['nullable', 'in:pending,confirmed'],
|
||||
'associations.*.relationship' => ['required', 'in:applies_to,supports,version_of,translation_of,amends,repeals,implements,cites,related_to'],
|
||||
|
||||
@@ -53,6 +53,7 @@ class HandleInertiaRequests extends Middleware
|
||||
'name' => config('app.name'),
|
||||
'homologationReserved' => (bool) config('portal.homologation_only', false),
|
||||
'profileAccess' => app(PortalProfileAccess::class)->navigation($request->user()),
|
||||
'contentManagementHref' => app(\App\Services\ContentManagement::class)->access($request->user()) !== [] ? '/gestao-conteudos' : null,
|
||||
'portalOperator' => config('portal.operator_name'),
|
||||
'portalInstitutionalContactEmail' => filter_var(config('portal.institutional_contact_email'), FILTER_VALIDATE_EMAIL) ?: null,
|
||||
'legacyContentEnabled' => app(LegacyContent::class)->available(),
|
||||
@@ -68,7 +69,7 @@ class HandleInertiaRequests extends Middleware
|
||||
'create' => ['label' => 'Nova oportunidade', 'href' => '/management/opportunities/create'],
|
||||
'installationHref' => '/management/opportunity-management-install',
|
||||
] : null,
|
||||
'flash' => ['success' => fn () => $request->session()->get('success'), 'warning' => fn () => $request->session()->get('warning')],
|
||||
'flash' => ['success' => fn () => $request->session()->get('success'), 'warning' => fn () => $request->session()->get('warning'), 'uploaded_document_id' => fn () => $request->session()->get('uploaded_document_id')],
|
||||
'scopedAdministration' => $request->user() && app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()) ? [
|
||||
['label'=>'Zonas e parques','href'=>'/management/investment/zones'],
|
||||
['label'=>'Referências económicas','href'=>'/management/economic-references/lot-01'],
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Navigation over existing authorisations; never grants a role or publishes content. */
|
||||
final class ContentManagement
|
||||
{
|
||||
public function access(?User $actor): array
|
||||
{
|
||||
if (! $actor || ! $actor->hasVerifiedEmail()) return [];
|
||||
$profiles = app(PortalProfileAccess::class);
|
||||
$navigation = $profiles->navigation($actor);
|
||||
$legacy = $profiles->legacyWriteAllowed($actor);
|
||||
$documents = app(DocumentManagementAccess::class);
|
||||
$editor = $documents->full($actor) || $documents->editorial($actor);
|
||||
$upload = $editor || $documents->limited($actor);
|
||||
$result = [];
|
||||
if ($legacy || ($navigation['mine'] ?? null) || ($navigation['review'] ?? null)) {
|
||||
$result['opportunities'] = ['index' => $legacy ? '/management/opportunities' : '/minhas-oportunidades',
|
||||
'create' => $legacy ? '/management/opportunities/create' : ($navigation['create'] ?? null), 'profileWorkflow' => ! $legacy];
|
||||
}
|
||||
if ($upload) $result['documents'] = ['index' => '/management/portal-services', 'edit' => $editor];
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function view(User $actor): array
|
||||
{
|
||||
$access = $this->access($actor);
|
||||
abort_unless($access !== [], 403);
|
||||
$counts = [];
|
||||
if (isset($access['opportunities'])) {
|
||||
$counts['opportunities'] = app(PortalProfileAccess::class)->scope(Opportunity::query(), $actor)
|
||||
->selectRaw("COALESCE(workflow_context->>'working_state',status) as state, count(*) as total")
|
||||
->groupByRaw("COALESCE(workflow_context->>'working_state',status)")->pluck('total', 'state');
|
||||
}
|
||||
if (isset($access['documents'])) {
|
||||
$documents = app(DocumentManagementAccess::class);
|
||||
$query = $documents->full($actor) ? DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at')
|
||||
: ($access['documents']['edit'] ? $documents->editorialResources($actor) : null);
|
||||
$counts['documents'] = $query ? $query->select('r.published', DB::raw('count(*) as total'))->groupBy('r.published')->get()
|
||||
->mapWithKeys(fn ($row) => [$row->published ? 'published' : 'unpublished' => (int) $row->total]) : [];
|
||||
}
|
||||
return ['access' => $access, 'counts' => $counts];
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,9 @@ class DocumentManagementAccess
|
||||
/** Restore the approved editorial operation without a global management grant. */
|
||||
public function editorial(User $actor): bool
|
||||
{
|
||||
return config('portal.homologation_only', false)
|
||||
&& in_array(strtolower(trim($actor->email)), config('homologation_editorial.accounts', []), true)
|
||||
// Tempo's named editorial designation does not depend on whether the
|
||||
// site's technical reviewer reservation is enabled.
|
||||
return in_array(strtolower(trim($actor->email)), config('homologation_editorial.accounts', []), true)
|
||||
&& app(PortalProfileAccess::class)->superuser($actor)
|
||||
&& ! app(PortalProfileAccess::class)->has($actor, 'auditor')
|
||||
&& app(PortalProfileAccess::class)->mfa($actor);
|
||||
|
||||
@@ -231,16 +231,25 @@ class InvestmentCatalogue
|
||||
} elseif (in_array($relation, ['clusters', 'corridors'], true)) {
|
||||
$query->whereIn('id', $this->publicStructureIds($relation, $foreign));
|
||||
} else {
|
||||
$query->whereIn('id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->pluck($foreign));
|
||||
$query->whereIn('id', app(OpportunityClassification::class)->targets('project')->select('id'));
|
||||
}
|
||||
}
|
||||
if (! $management && $path !== [] && $relation !== 'locations') {
|
||||
$dossiers = InvestmentPortfolioController::visible();
|
||||
app(InvestmentGeography::class)->constrainDossiersToTerritories($dossiers,
|
||||
app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id));
|
||||
$query->whereIn('id', $dossiers->whereNotNull($foreign)->select($foreign));
|
||||
$territories = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
|
||||
$query->where(fn ($area) => $area->whereIn('primary_territory_id', $territories)
|
||||
->orWhereIn('id', $dossiers->whereNotNull($foreign)->select($foreign)));
|
||||
}
|
||||
$result[$relation] = $query->orderBy('name')->get(['id', 'name']);
|
||||
if (! $management && $relation !== 'locations') {
|
||||
$dossiers = InvestmentPortfolioController::visible()->whereIn($foreign, $result[$relation]->pluck('id'))
|
||||
->orderBy('id')->get(['id', $foreign])->unique($foreign)->keyBy($foreign);
|
||||
$result[$relation]->each(function ($record) use ($dossiers): void {
|
||||
$record->url = isset($dossiers[$record->id]) ? '/carteira/'.$dossiers[$record->id]->id : null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$zones = app(InvestmentZones::class)->visible();
|
||||
@@ -250,24 +259,15 @@ class InvestmentCatalogue
|
||||
['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'geo_location_id']));
|
||||
}
|
||||
$result['zones'] = $zones->orderBy('name')->get(['id', 'name', 'category']);
|
||||
if (! $management && ($path !== [] || ! empty($territoryFilters['sector']))) {
|
||||
$scope = \Illuminate\Support\Arr::only($territoryFilters, ['sector','province_id','district_id','admin_post_id','locality_id','include_national']);
|
||||
$available = ['chain' => [], 'activity' => []];
|
||||
$this->query($scope)->reorder()->chunkById(200, function ($records) use (&$available) {
|
||||
foreach ($records as $record) {
|
||||
foreach (app(OpportunityClassification::class)->publicRows($record) as $row) {
|
||||
if (isset($available[$row['target_type']])) $available[$row['target_type']][] = $row['target_id'];
|
||||
}
|
||||
foreach (['subsector_id','economic_activity_id'] as $key) {
|
||||
$id = $record->workflow_context['publication']['taxonomy'][$key] ?? null;
|
||||
if ($id) $available['activity'][] = $id;
|
||||
}
|
||||
}
|
||||
}, 'opportunities.id', 'id');
|
||||
foreach ($available as $kind => $ids) {
|
||||
$result['classification']['targets'][$kind] = collect($result['classification']['targets'][$kind])->whereIn('id', $ids)->values();
|
||||
}
|
||||
if (! $management && $path !== []) {
|
||||
// A chain's documented scope comes from its public cluster, never
|
||||
// from the opportunities surviving the current search.
|
||||
$ids = app(OpportunityClassification::class)->targets('chain')
|
||||
->whereIn('cluster_id', $result['clusters']->pluck('id'))->pluck('id');
|
||||
$result['classification']['targets']['chain'] = collect($result['classification']['targets']['chain'])->whereIn('id', $ids)->values();
|
||||
}
|
||||
// Activities are a public classification catalogue, not evidence of
|
||||
// investment availability in a territory or sector.
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -184,7 +184,9 @@ class PortalProfileAccess
|
||||
|
||||
public function canCreate(User $actor, ?string $organization, string $type): bool
|
||||
{
|
||||
$included = in_array($type, OpportunityCanonicalContract::BASE_CATEGORIES, true) || app(OpportunityModularProfiles::class)->enabled();
|
||||
// Draft preparation is allowed within the assigned modality. Executable
|
||||
// submission support is checked separately by the canonical contract.
|
||||
$included = in_array($type, OpportunityManagement::CATEGORIES, true);
|
||||
|
||||
// This is creation as an authorized Promotor, never an exceptional SU act.
|
||||
return $included && $this->available() && $this->scoped($actor, 'promoter', $organization, $type) && ! $this->has($actor, 'auditor');
|
||||
@@ -370,7 +372,8 @@ class PortalProfileAccess
|
||||
foreach (json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR) as $type) {
|
||||
$key = ($grant->organization_id ?? 'individual').'|'.$type;
|
||||
if (! isset($seen[$key]) && $this->canCreate($actor, $grant->organization_id, $type)) {
|
||||
$contexts[] = ['organization_id' => $grant->organization_id, 'type' => $type];
|
||||
$contexts[] = ['organization_id' => $grant->organization_id,
|
||||
'organization_name' => $grant->organization_id ? DB::table('organizations')->where('id', $grant->organization_id)->value('name') : null, 'type' => $type];
|
||||
$seen[$key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ return [
|
||||
'opportunity_other_qa_subtypes' => [],
|
||||
'operator_name' => env('PORTAL_OPERATOR_NAME'),
|
||||
// Tempo, 19/10/2026: institutional contact; commercial department remains separate.
|
||||
'institutional_contact_email' => env('PORTAL_INSTITUTIONAL_CONTACT_EMAIL', 'info@tempo.co.mz'),
|
||||
'institutional_contact_email' => env('PORTAL_INSTITUTIONAL_CONTACT_EMAIL', 'comercial@tempo.co.mz'),
|
||||
'support' => [
|
||||
'email' => env('PORTAL_SUPPORT_EMAIL'),
|
||||
'phone' => env('PORTAL_SUPPORT_PHONE'),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM node:22-bookworm AS node
|
||||
|
||||
FROM php:8.4-cli-bookworm
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libpq-dev libzip-dev unzip git curl \
|
||||
&& docker-php-ext-install -j"$(nproc)" pdo_pgsql pgsql zip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=node /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
||||
&& ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
|
||||
&& ln -s ../lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY . .
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM php:8.4-cli-bookworm
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libpq-dev libzip-dev unzip git curl \
|
||||
&& docker-php-ext-install -j"$(nproc)" pdo_pgsql pgsql zip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
WORKDIR /var/www/html
|
||||
COPY composer.json composer.lock ./
|
||||
RUN composer install --no-interaction --prefer-dist --no-progress --no-scripts
|
||||
COPY . .
|
||||
RUN composer dump-autoload --optimize
|
||||
@@ -0,0 +1,176 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>InvestMoz — Levantamento funcional e modelos</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 18mm 17mm 18mm 17mm; }
|
||||
body { font-family: Aptos, Calibri, Arial, sans-serif; color:#1d2939; font-size:10.5pt; line-height:1.42; }
|
||||
h1 { color:#123d68; font-size:26pt; margin:0 0 8pt; }
|
||||
h2 { color:#123d68; font-size:17pt; border-bottom:2px solid #13a789; padding-bottom:5pt; margin-top:24pt; }
|
||||
h3 { color:#145b82; font-size:13pt; margin:17pt 0 5pt; }
|
||||
p { margin:5pt 0 8pt; }
|
||||
.cover { padding-top:100pt; page-break-after:always; }
|
||||
.kicker { text-transform:uppercase; letter-spacing:2px; color:#138a77; font-weight:bold; }
|
||||
.subtitle { color:#52657a; font-size:15pt; }
|
||||
.meta { margin-top:35pt; padding:12pt; background:#eff6fb; border-left:4px solid #1d75bd; }
|
||||
.callout { background:#fff7e8; border-left:4px solid #ee9b20; padding:9pt 12pt; margin:12pt 0; }
|
||||
.note { color:#52657a; font-size:9pt; }
|
||||
table { border-collapse:collapse; width:100%; margin:8pt 0 14pt; font-size:9.2pt; }
|
||||
th { background:#123d68; color:white; text-align:left; padding:6pt; }
|
||||
td { border:1px solid #cbd5e1; padding:5pt 6pt; vertical-align:top; }
|
||||
tr:nth-child(even) td { background:#f5f8fb; }
|
||||
.diagram { text-align:center; margin:12pt 0; page-break-inside:avoid; }
|
||||
.diagram svg { width:100%; height:auto; }
|
||||
.legend { color:#52657a; font-size:9pt; }
|
||||
.small { font-size:9pt; }
|
||||
.pagebreak { page-break-before:always; }
|
||||
ul { margin-top:3pt; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="cover">
|
||||
<p class="kicker">Documento de análise funcional</p>
|
||||
<h1>InvestMoz</h1>
|
||||
<p class="subtitle">Levantamento profissional do projecto, casos de uso reais, actores e diagramas</p>
|
||||
<div class="meta"><b>Data:</b> 9 de Outubro de 2026<br><b>Âmbito:</b> aplicação Laravel/React e documentação do repositório Investidor-V2<br><b>Versão:</b> 1.0 · análise do código e dos documentos disponíveis</div>
|
||||
<p style="margin-top:42pt;font-size:13pt">Portal de descoberta de oportunidades e serviços de investimento, ligado a fluxos autenticados de candidatura, tratamento institucional, evidência documental e acompanhamento.</p>
|
||||
<p class="note" style="margin-top:55pt">Documento técnico-funcional. A presença de uma rota, tabela, job ou descrição não demonstra, por si só, activação em produção.</p>
|
||||
</section>
|
||||
|
||||
<h2>1. Sumário executivo</h2>
|
||||
<p>O InvestMoz é uma plataforma digital que aproxima investidores, promotores e instituições. A experiência começa no portal público, onde se consultam sectores, oportunidades, negócios existentes, território, zonas e serviços. A partir daí, a pessoa pode manifestar interesse, iniciar uma simulação ou pedir apoio. Os percursos que exigem continuidade passam para a área autenticada, onde se mantêm perfil, rascunhos, candidaturas, anexos e comunicação.</p>
|
||||
<p>Do lado institucional, os pedidos podem ser revistos e acompanhados por CRM local, tarefas, revisões e eventos. A plataforma também contém domínios de catálogo territorial, zonas de investimento, documentação, governação fiscal, educação e competências, assistência de terra, parcerias, balcão virtual e IA baseada em fontes.</p>
|
||||
<div class="callout"><b>Leitura funcional:</b> trata-se de uma plataforma de encaminhamento e gestão de jornadas de investimento, e não apenas de um catálogo de anúncios. A camada pública informa e capta pedidos; a camada autenticada conserva o trabalho; a camada de gestão analisa, valida e decide segundo permissões e evidência.</div>
|
||||
<p>O README descreve a base inicial de sectores, oportunidades e manifestações de interesse. Rotas, controladores, migrations e documentação de domínio mostram uma solução mais abrangente. O levantamento de casos detalhados tem 24 casos de uso; este relatório acrescenta cenários concretos, diagramas UML/ER e a leitura da frequência dos eventos.</p>
|
||||
|
||||
<h2>2. O que o projecto faz</h2>
|
||||
<table><tr><th>Capacidade</th><th>Valor para o utilizador</th><th>Exemplos de dados</th></tr>
|
||||
<tr><td>Descoberta pública</td><td>Encontrar oportunidades, negócios, sectores, serviços e referências territoriais.</td><td>sectors, opportunities, existing_businesses, investment_zones</td></tr>
|
||||
<tr><td>Captação de interesse</td><td>Enviar manifestação associada a uma oportunidade ou negócio, com consentimento.</td><td>interest_requests, consents, receipts</td></tr>
|
||||
<tr><td>Jornada do investidor</td><td>Guardar itens, criar dossier/submissão, anexar documentos e acompanhar mensagens.</td><td>investment_submissions, revisions, messages, attachments</td></tr>
|
||||
<tr><td>Tratamento institucional</td><td>Qualificar pedidos, atribuir responsável, criar tarefas e acompanhar a negociação.</td><td>leads, contacts, crm_opportunities, tasks, activities</td></tr>
|
||||
<tr><td>Planeamento territorial</td><td>Relacionar projectos, clusters, corredores, localizações e zonas.</td><td>projects, economic_clusters, development_corridors, geo_locations</td></tr>
|
||||
<tr><td>Documentos e evidência</td><td>Preservar origem, versões, hash, scan, classificação e autorização de divulgação.</td><td>documents, document_versions, malware_scans, associations</td></tr>
|
||||
<tr><td>Simulação e regras fiscais</td><td>Guardar rascunhos, executar simulações com parâmetros/fontes rastreáveis e governar versões.</td><td>simulations, scenarios, results, tax_rule_versions, legal_sources</td></tr>
|
||||
<tr><td>Serviços complementares</td><td>Registar educação, assistência de terra, parcerias, balcão virtual e apoio com IA.</td><td>training needs, land cases, partnerships, desk cases, ai runs</td></tr></table>
|
||||
|
||||
<h2>3. Actores e fronteiras de acesso</h2>
|
||||
<table><tr><th>Actor</th><th>O que pretende</th><th>Zona típica</th></tr>
|
||||
<tr><td>Visitante</td><td>Consultar conteúdos públicos e iniciar interesse, apoio ou simulação.</td><td>Portal público</td></tr>
|
||||
<tr><td>Investidor / utilizador registado</td><td>Manter perfil, favoritos, rascunhos, submissões, anexos e mensagens.</td><td>Área autenticada</td></tr>
|
||||
<tr><td>Promotor / proprietário de negócio</td><td>Registar ou actualizar negócio e responder a pedidos de informação.</td><td>Área autenticada e workflow de revisão</td></tr>
|
||||
<tr><td>Gestor de portal / CRM</td><td>Rever interesses, qualificar leads, atribuir tarefas e gerir conteúdos.</td><td>Gestão interna</td></tr>
|
||||
<tr><td>Gestor territorial</td><td>Manter projectos, relações, zonas, geometria e referências territoriais.</td><td>Gestão interna</td></tr>
|
||||
<tr><td>Revisor documental / gestor fiscal</td><td>Validar metadados, evidência, versões e regras sob as respectivas funções.</td><td>Gestão restrita</td></tr>
|
||||
<tr><td>Agente de serviço / parceiro institucional</td><td>Tratar casos ou colaborar em parcerias e salas.</td><td>Serviços e colaboração</td></tr>
|
||||
<tr><td>Administrador / worker</td><td>Gerir segurança/configuração ou executar trabalho assíncrono autorizado.</td><td>Administração / sistema</td></tr></table>
|
||||
<p class="note">Os nomes acima são papéis funcionais consolidados. A autorização efectiva depende das policies, middleware, MFA, configuração e permissões em vigor.</p>
|
||||
|
||||
<h2>4. Diagrama de casos de uso com actores</h2>
|
||||
<p>O diagrama destaca actores (“bonecos”) e os principais serviços que usam. As associações mostram interacções típicas; não indicam que todos os serviços estejam activos na mesma instalação.</p>
|
||||
<div class="diagram">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1050 610" role="img" aria-label="Diagrama UML de casos de uso InvestMoz com actores">
|
||||
<defs><style>.actor{stroke:#173b60;stroke-width:3;fill:none}.line{stroke:#728397;stroke-width:1.7}.uc{fill:#eef6fc;stroke:#2778b8;stroke-width:2}.txt{font:16px Arial;fill:#17324d}.label{font:bold 15px Arial;fill:#17324d}.boundary{fill:#fbfdff;stroke:#9bb0c3;stroke-width:2;stroke-dasharray:8 5}</style></defs>
|
||||
<rect x="220" y="22" width="795" height="558" rx="14" class="boundary"/><text x="245" y="52" class="label">Sistema InvestMoz</text>
|
||||
<!-- stick actors -->
|
||||
<g class="actor"><circle cx="95" cy="115" r="17"/><path d="M95 132v65m-30-38h60m-30 38-27 42m27-42 27 42"/></g><text x="50" y="245" class="label">Visitante</text>
|
||||
<g class="actor"><circle cx="95" cy="335" r="17"/><path d="M95 352v65m-30-38h60m-30 38-27 42m27-42 27 42"/></g><text x="37" y="465" class="label">Investidor</text>
|
||||
<g class="actor"><circle cx="95" cy="510" r="17"/><path d="M95 527v45m-30-25h60m-30 25-22 33m22-33 22 33"/></g><text x="15" y="606" class="label">Gestor</text>
|
||||
<g class="actor"><circle cx="1025" cy="165" r="15"/><path d="M1025 180v55m-25-33h50m-25 33-22 36m22-36 22 36"/></g><text x="979" y="267" class="label">Revisor</text>
|
||||
<g class="actor"><circle cx="1025" cy="400" r="15"/><path d="M1025 415v55m-25-33h50m-25 33-22 36m22-36 22 36"/></g><text x="967" y="502" class="label">Parceiro</text>
|
||||
<!-- use cases -->
|
||||
<g><ellipse cx="410" cy="112" rx="145" ry="31" class="uc"/><text x="410" y="118" text-anchor="middle" class="txt">Consultar catálogo e mapa</text>
|
||||
<ellipse cx="730" cy="112" rx="145" ry="31" class="uc"/><text x="730" y="118" text-anchor="middle" class="txt">Pesquisar oportunidade/negócio</text>
|
||||
<ellipse cx="410" cy="205" rx="145" ry="31" class="uc"/><text x="410" y="211" text-anchor="middle" class="txt">Manifestar interesse</text>
|
||||
<ellipse cx="730" cy="205" rx="145" ry="31" class="uc"/><text x="730" y="211" text-anchor="middle" class="txt">Executar simulação</text>
|
||||
<ellipse cx="410" cy="300" rx="145" ry="31" class="uc"/><text x="410" y="306" text-anchor="middle" class="txt">Criar candidatura / dossier</text>
|
||||
<ellipse cx="730" cy="300" rx="145" ry="31" class="uc"/><text x="730" y="306" text-anchor="middle" class="txt">Anexar e acompanhar documentos</text>
|
||||
<ellipse cx="410" cy="397" rx="145" ry="31" class="uc"/><text x="410" y="403" text-anchor="middle" class="txt">Tratar interesse no CRM</text>
|
||||
<ellipse cx="730" cy="397" rx="145" ry="31" class="uc"/><text x="730" y="403" text-anchor="middle" class="txt">Validar e publicar conteúdo</text>
|
||||
<ellipse cx="410" cy="495" rx="145" ry="31" class="uc"/><text x="410" y="501" text-anchor="middle" class="txt">Gerir território e zonas</text>
|
||||
<ellipse cx="730" cy="495" rx="145" ry="31" class="uc"/><text x="730" y="501" text-anchor="middle" class="txt">Colaborar em parceria/serviço</text></g>
|
||||
<!-- actor associations -->
|
||||
<g class="line"><path d="M112 115H264M112 115 585 112M112 145 265 195M112 355 265 300M112 370 265 300M112 388 585 300M112 515 265 397M112 525 265 495M112 540 585 495M1008 175 875 397M1008 190 875 300M1008 410 875 495"/></g>
|
||||
</svg></div>
|
||||
<p class="legend">Exemplo do percurso mais comum: visitante pesquisa → manifesta interesse → investidor cria/acompanha candidatura → gestão trata o pedido → revisão e decisão.</p>
|
||||
|
||||
<h2>5. Casos de uso reais — jornadas ilustrativas</h2>
|
||||
<h3>UC-A — Investidor encontra oportunidade e manifesta interesse</h3>
|
||||
<ol><li>O visitante consulta sectores, filtra oportunidades por localização e abre o detalhe.</li><li>Escolhe “manifestar interesse”, informa contacto, modalidade e mensagem, e aceita o consentimento aplicável.</li><li>O sistema valida o alvo, regista o pedido e, quando configurado, um recibo/idempotência.</li><li>O investidor pode autenticar-se para guardar a oportunidade ou avançar para uma submissão.</li><li>Um gestor consulta o pedido, verifica consentimentos, atribui responsável, qualifica e cria tarefa de seguimento.</li><li>As alterações relevantes ficam no histórico; notificações e integrações só são consideradas entregues quando o disparo e o serviço estiverem confirmados.</li></ol>
|
||||
<p><b>Resultado:</b> pedido rastreável ligado à oportunidade e encaminhado para tratamento. <b>Entidades:</b> Opportunity, Investor/User, InterestRequest, Consent, Lead, Task, AuditEvent.</p>
|
||||
<h3>UC-B — Proprietário submete um negócio para divulgação</h3>
|
||||
<ol><li>O proprietário cria rascunho e informa modalidade, objectos, localização e documentos de suporte.</li><li>Submete o registo; o estado passa para revisão conforme o workflow aplicável.</li><li>O gestor analisa e pode aprovar, rejeitar ou pedir informação complementar.</li><li>O revisor documental confere original, hash, versão, scan e autorização de divulgação.</li><li>Só após as validações necessárias o conteúdo pode ser publicado ao público; publicação não altera a situação jurídica do documento.</li></ol>
|
||||
<p><b>Resultado:</b> negócio com histórico e decisão rastreável. <b>Estados documentados:</b> draft → submitted → in_review → approved → published, com ramos needs_information, rejected, withdrawn, suspended e archived.</p>
|
||||
<h3>UC-C — Gestor territorial valida zona e relações</h3>
|
||||
<ol><li>O gestor regista a zona e a referência/fonte pertinente.</li><li>Associa localização, operador, categoria, geometria e relações com projectos, clusters ou corredores.</li><li>As relações sem evidência verificada permanecem pendentes e identificadas.</li><li>Um revisor valida a evidência e as regras de publicação; alterações ficam versionadas.</li><li>A zona só aparece no portal conforme estado e autorização de divulgação.</li></ol>
|
||||
<h3>UC-D — Utilizador guarda um rascunho e executa simulação</h3>
|
||||
<ol><li>O utilizador preenche o formulário; os dados incompletos podem ser guardados como rascunho recuperável.</li><li>Quando solicita o cálculo, o sistema selecciona questionário e parâmetros elegíveis.</li><li>O job de simulação executa após a transacção e grava cenários, resultados, obrigações e fontes usadas.</li><li>O relatório pode ser consultado; o rascunho continua distinto do cálculo executado.</li></ol>
|
||||
<div class="callout"><b>Regra de integridade:</b> importar ou publicar fonte documental não activa automaticamente uma regra fiscal. Parâmetros ausentes/ambíguos permanecem indeterminados.</div>
|
||||
<h3>UC-E — Parceiros colaboram numa parceria ou serviço</h3>
|
||||
<p>Um actor cria uma proposta; outro manifesta interesse; após aceitação, os participantes podem colaborar numa sala, trocar mensagens/documentos e acompanhar marcos. O balcão virtual segue padrão semelhante para pedidos de serviço: registo, análise, pedido de documentos, eventos, revisão e resolução.</p>
|
||||
|
||||
<h2>6. Diagrama de classes (visão lógica)</h2>
|
||||
<p>O diagrama apresenta as classes de domínio principais, atributos representativos e operações. É uma vista funcional; não reproduz todos os campos nem métodos Laravel.</p>
|
||||
<div class="diagram"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1100 690" role="img" aria-label="Diagrama UML de classes InvestMoz">
|
||||
<defs><style>.box{fill:#fff;stroke:#276c9b;stroke-width:2}.head{fill:#e8f2f9}.ct{font:14px Arial;fill:#17324d}.ch{font:bold 15px Arial;fill:#123d68}.rel{stroke:#60768b;stroke-width:1.8;fill:none}.mult{font:12px Arial;fill:#455b70}</style></defs>
|
||||
<!-- classes -->
|
||||
<g><rect x="30" y="30" width="220" height="120" rx="5" class="box"/><rect x="30" y="30" width="220" height="32" class="head"/><text x="140" y="52" text-anchor="middle" class="ch">User</text><text x="44" y="82" class="ct">id, email, verified_at</text><text x="44" y="105" class="ct">2FA / passkeys</text><text x="44" y="132" class="ct">+ authenticate()</text>
|
||||
<rect x="315" y="30" width="220" height="120" rx="5" class="box"/><rect x="315" y="30" width="220" height="32" class="head"/><text x="425" y="52" text-anchor="middle" class="ch">Investor</text><text x="329" y="82" class="ct">id, user_id, profile</text><text x="329" y="105" class="ct">company interests</text><text x="329" y="132" class="ct">+ manageProfile()</text>
|
||||
<rect x="600" y="30" width="220" height="120" rx="5" class="box"/><rect x="600" y="30" width="220" height="32" class="head"/><text x="710" y="52" text-anchor="middle" class="ch">Opportunity</text><text x="614" y="82" class="ct">sector, slug, status</text><text x="614" y="105" class="ct">location, investment</text><text x="614" y="132" class="ct">+ publish()</text>
|
||||
<rect x="865" y="30" width="205" height="120" rx="5" class="box"/><rect x="865" y="30" width="205" height="32" class="head"/><text x="967" y="52" text-anchor="middle" class="ch">Sector</text><text x="879" y="82" class="ct">id, name, slug</text><text x="879" y="105" class="ct">active / published</text><text x="879" y="132" class="ct">+ listOpportunities()</text>
|
||||
<rect x="30" y="230" width="235" height="130" rx="5" class="box"/><rect x="30" y="230" width="235" height="32" class="head"/><text x="147" y="252" text-anchor="middle" class="ch">InterestRequest</text><text x="44" y="282" class="ct">target, contact, consent</text><text x="44" y="305" class="ct">status, submitted_at</text><text x="44" y="338" class="ct">+ submit() / qualify()</text>
|
||||
<rect x="325" y="230" width="235" height="130" rx="5" class="box"/><rect x="325" y="230" width="235" height="32" class="head"/><text x="442" y="252" text-anchor="middle" class="ch">Lead / Task</text><text x="339" y="282" class="ct">owner, next_action</text><text x="339" y="305" class="ct">due_at, status</text><text x="339" y="338" class="ct">+ assign() / complete()</text>
|
||||
<rect x="620" y="230" width="235" height="130" rx="5" class="box"/><rect x="620" y="230" width="235" height="32" class="head"/><text x="737" y="252" text-anchor="middle" class="ch">Submission</text><text x="634" y="282" class="ct">owner, target, state</text><text x="634" y="305" class="ct">revisions, needs</text><text x="634" y="338" class="ct">+ save() / submit()</text>
|
||||
<rect x="910" y="230" width="160" height="130" rx="5" class="box"/><rect x="910" y="230" width="160" height="32" class="head"/><text x="990" y="252" text-anchor="middle" class="ch">Document</text><text x="924" y="282" class="ct">hash, source</text><text x="924" y="305" class="ct">version, status</text><text x="924" y="338" class="ct">+ scan()</text>
|
||||
<rect x="30" y="450" width="235" height="130" rx="5" class="box"/><rect x="30" y="450" width="235" height="32" class="head"/><text x="147" y="472" text-anchor="middle" class="ch">Project / Zone</text><text x="44" y="502" class="ct">territory, geometry</text><text x="44" y="525" class="ct">relations, evidence</text><text x="44" y="558" class="ct">+ validate() / publish()</text>
|
||||
<rect x="325" y="450" width="235" height="130" rx="5" class="box"/><rect x="325" y="450" width="235" height="32" class="head"/><text x="442" y="472" text-anchor="middle" class="ch">Simulation</text><text x="339" y="502" class="ct">questionnaire, input</text><text x="339" y="525" class="ct">scenario, result, sources</text><text x="339" y="558" class="ct">+ execute() / report()</text>
|
||||
<rect x="620" y="450" width="235" height="130" rx="5" class="box"/><rect x="620" y="450" width="235" height="32" class="head"/><text x="737" y="472" text-anchor="middle" class="ch">TaxRuleVersion</text><text x="634" y="502" class="ct">source, dates, parameters</text><text x="634" y="525" class="ct">review / approval state</text><text x="634" y="558" class="ct">+ resolveForPeriod()</text>
|
||||
<rect x="910" y="450" width="160" height="130" rx="5" class="box"/><rect x="910" y="450" width="160" height="32" class="head"/><text x="990" y="472" text-anchor="middle" class="ch">AuditEvent</text><text x="924" y="502" class="ct">actor, entity</text><text x="924" y="525" class="ct">action, time</text><text x="924" y="558" class="ct">+ record()</text></g>
|
||||
<!-- relations -->
|
||||
<g class="rel"><path d="M250 90H315M535 90H600M820 90H865M140 150V230M425 150V230M710 150V230M265 295H325M560 295H620M855 295H910M140 360V450M442 360V450M737 360V450M855 515H910M250 130 900 470"/></g>
|
||||
<g class="mult"><text x="270" y="82">1 : 0..1</text><text x="547" y="82">0..*</text><text x="834" y="82">1</text><text x="148" y="205">1 : 0..*</text><text x="273" y="286">0..1</text><text x="567" y="286">1..*</text><text x="861" y="286">0..*</text><text x="145" y="435">1 : 0..*</text><text x="450" y="435">1 : 0..*</text></g>
|
||||
</svg></div>
|
||||
<p class="note">A relação exacta Investor/User, a associação Submission/target e os vínculos Document/entidade podem ser opcionais ou implementados por tabelas polimórficas/de junção; confirme no schema antes de gerar código ou migrações.</p>
|
||||
|
||||
<h2>7. Diagrama de relacionamento (ER simplificado)</h2>
|
||||
<div class="diagram"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1080 560" role="img" aria-label="Diagrama de relacionamento das entidades InvestMoz">
|
||||
<defs><style>.e{fill:#edf5fb;stroke:#2477b6;stroke-width:2}.et{font:bold 15px Arial;fill:#123d68}.r{stroke:#61788d;stroke-width:2;fill:none}.rt{font:12px Arial;fill:#40566b}</style></defs>
|
||||
<g><rect x="35" y="45" width="155" height="60" rx="9" class="e"/><text x="112" y="81" text-anchor="middle" class="et">SECTOR</text><rect x="260" y="45" width="180" height="60" rx="9" class="e"/><text x="350" y="81" text-anchor="middle" class="et">OPPORTUNITY</text><rect x="515" y="45" width="190" height="60" rx="9" class="e"/><text x="610" y="81" text-anchor="middle" class="et">INTEREST_REQUEST</text><rect x="805" y="45" width="160" height="60" rx="9" class="e"/><text x="885" y="81" text-anchor="middle" class="et">INVESTOR</text>
|
||||
<rect x="35" y="205" width="155" height="60" rx="9" class="e"/><text x="112" y="241" text-anchor="middle" class="et">USER</text><rect x="260" y="205" width="180" height="60" rx="9" class="e"/><text x="350" y="241" text-anchor="middle" class="et">SUBMISSION</text><rect x="515" y="205" width="190" height="60" rx="9" class="e"/><text x="610" y="241" text-anchor="middle" class="et">DOCUMENT</text><rect x="805" y="205" width="160" height="60" rx="9" class="e"/><text x="885" y="241" text-anchor="middle" class="et">AUDIT_EVENT</text>
|
||||
<rect x="35" y="365" width="155" height="60" rx="9" class="e"/><text x="112" y="401" text-anchor="middle" class="et">PROJECT</text><rect x="260" y="365" width="180" height="60" rx="9" class="e"/><text x="350" y="401" text-anchor="middle" class="et">CLUSTER</text><rect x="515" y="365" width="190" height="60" rx="9" class="e"/><text x="610" y="401" text-anchor="middle" class="et">INVESTMENT_ZONE</text><rect x="805" y="365" width="160" height="60" rx="9" class="e"/><text x="885" y="401" text-anchor="middle" class="et">LEGAL_SOURCE</text>
|
||||
<rect x="145" y="490" width="180" height="55" rx="9" class="e"/><text x="235" y="523" text-anchor="middle" class="et">SIMULATION</text><rect x="450" y="490" width="180" height="55" rx="9" class="e"/><text x="540" y="523" text-anchor="middle" class="et">TAX_RULE_VERSION</text><rect x="755" y="490" width="180" height="55" rx="9" class="e"/><text x="845" y="523" text-anchor="middle" class="et">SIMULATION_RESULT</text></g>
|
||||
<g class="r"><path d="M190 75H260M440 75H515M705 75H805M190 235H260M440 235H515M705 235H805M190 395H260M440 395H515M705 395H805M235 425V490M325 518H450M630 518H755M350 105V205M610 105V205M610 265V365M885 105V205M885 425V455H540V490"/></g>
|
||||
<g class="rt"><text x="210" y="67">1 : N</text><text x="465" y="67">1 : N</text><text x="730" y="67">N : 0..1</text><text x="210" y="227">1 : N</text><text x="462" y="227">N : M* via links</text><text x="730" y="227">1 : N</text><text x="210" y="387">N : M*</text><text x="462" y="387">N : M*</text><text x="730" y="387">1 : N</text><text x="235" y="466">1 : N</text><text x="346" y="510">1 : N</text><text x="650" y="510">1 : N</text><text x="354" y="160">1 : N</text><text x="617" y="160">association</text><text x="894" y="160">actor</text><text x="670" y="448">source</text></g>
|
||||
</svg></div>
|
||||
<p class="note">N:M = muitos-para-muitos, normalmente resolvido por tabela de associação. Ligações genéricas de documentos/auditoria podem não ser FKs directas. Este mapa é uma vista funcional, não um substituto do ERD integral em docs/investmoz/erd.mmd e do dicionário de dados.</p>
|
||||
|
||||
<h2>8. Eventos e frequência</h2>
|
||||
<table><tr><th>Evento/processo</th><th>Gatilho</th><th>Frequência confirmada</th><th>Leitura operacional</th></tr>
|
||||
<tr><td>Escalar tarefas vencidas</td><td>Scheduler Laravel</td><td>A cada 15 minutos; sem sobreposição configurada</td><td>Cadência declarada em routes/console.php. O scheduler do servidor tem de estar activo.</td></tr>
|
||||
<tr><td>Executar simulação (RunSimulation)</td><td>Pedido de cálculo aceite</td><td>Por pedido, via fila, afterCommit</td><td>Assíncrono; volume depende da utilização e dos workers.</td></tr>
|
||||
<tr><td>Scan de malware (ScanDocumentForMalware)</td><td>Upload ou pedido de análise em fluxos suportados</td><td>Por documento, via fila; job único</td><td>Depende de worker e scanner. Upload não prova análise concluída.</td></tr>
|
||||
<tr><td>Comparação de compatibilidade MNE</td><td>Alteração aplicável do negócio</td><td>Por alteração aplicável, em fila de sombra</td><td>Job identificado; frequência de alterações não medida.</td></tr>
|
||||
<tr><td>Notificação de interesse</td><td>Recepção de interesse</td><td>Disparo não confirmado</td><td>Job NotifyInterestReceived existe, mas não foi localizada chamada dispatch().</td></tr>
|
||||
<tr><td>Outbox/inbox e sincronização externa</td><td>Evento/mensagem externa</td><td>Sem calendário operacional confirmado</td><td>Estrutura preparada; documentação indica sincronização real pendente.</td></tr>
|
||||
<tr><td>Pesquisas, submissões, revisões e auditoria</td><td>Acção do visitante/utilizador/gestor</td><td>Sob procura ou acção; contagem indisponível</td><td>Sem logs/analytics não se calculam ocorrências por período.</td></tr></table>
|
||||
<p><b>Frequência configurada não é frequência observada.</b> Não foram disponibilizados logs, contagens de eventos da base activa, filas, analytics nem tráfego HTTP. Assim, eventos por hora/dia/mês, sazonalidade, falhas e utilizadores activos permanecem indeterminados. Não se extrapolam estes valores a partir de testes, seeders, screenshots ou schema.</p>
|
||||
|
||||
<h2>9. Inventário dos 24 casos de uso</h2>
|
||||
<table><tr><th>Grupo</th><th>Casos</th></tr>
|
||||
<tr><td>Portal e catálogo</td><td>UC-01 Consultar página inicial e serviços; UC-02 Pesquisar sectores e oportunidades; UC-03 Consultar negócio existente.</td></tr>
|
||||
<tr><td>Conta e jornada</td><td>UC-04 Criar conta/autenticar/recuperar acesso; UC-05 Manifestar interesse numa oportunidade; UC-06 Manifestar interesse num negócio; UC-07 Guardar oportunidade; UC-08 Manter submissão; UC-09 Submeter candidatura.</td></tr>
|
||||
<tr><td>Negócios e CRM</td><td>UC-10 Gerir negócio existente; UC-11 Tratar interesses no CRM.</td></tr>
|
||||
<tr><td>Território e documentos</td><td>UC-12 Gerir catálogo territorial; UC-13 Gerir zonas; UC-14 Carregar, verificar e publicar documento.</td></tr>
|
||||
<tr><td>Simulação/fiscalidade</td><td>UC-15 Criar rascunho; UC-16 Executar simulação; UC-17 Gerir regra fiscal.</td></tr>
|
||||
<tr><td>Serviços especializados</td><td>UC-18 Gerir educação e competências; UC-19 Assistência de terra; UC-20 Gerir parceria; UC-21 Usar balcão virtual.</td></tr>
|
||||
<tr><td>Transversal</td><td>UC-22 Usar IA com evidência; UC-23 Sincronizar sistemas externos; UC-24 Auditar alterações.</td></tr></table>
|
||||
<p>Os pré-requisitos, fluxos alternativos e tabelas associadas por UC encontram-se no ficheiro <i>casos-de-uso-aplicacao-actual.md</i>.</p>
|
||||
|
||||
<h2>10. Integridade, segurança e limitações</h2>
|
||||
<ul><li>Disponibilidade para consulta, situação jurídica/documental e uso em regra/cálculo são estados independentes.</li><li>Respeitar autorização de divulgação e âmbito; relações sem evidência ficam pendentes.</li><li>Preservar original, proveniência, ID exacto, edição/data, hash e histórico. Não confirmar autenticidade, vigência, direitos ou scan sem evidência.</li><li>Importação não é publicação; publicação documental não activa cálculo fiscal; rascunho não é candidatura aprovada.</li><li>Integração desenhada não significa sincronização activa. Cobertura e estado de cada domínio devem ser verificados nos respectivos relatórios de validação.</li><li>Antes de alterações a dados activos, confirmar backup/restauro e acção autenticada. Testes e migrações usam exclusivamente base QA isolada.</li></ul>
|
||||
|
||||
<h2>11. Fontes de análise</h2>
|
||||
<ul><li>README.md do repositório;</li><li>docs/investmoz/casos-de-uso-aplicacao-actual.md;</li><li>docs/investmoz/erd.mmd, data-dictionary.csv e table-catalog.csv;</li><li>rotas, controladores, modelos, jobs, comando de escalada e migrations;</li><li>InvestMoz — Levantamento funcional, casos de uso e diagramas.docx, em Downloads.</li></ul>
|
||||
<p class="note">O inventário cobriu 981 caminhos listados por rg --files, excluindo vendor e node_modules. Isso descreve o repositório acessível, não anexos externos, conteúdo de armazenamento privado ou base de dados de produção.</p>
|
||||
</body></html>
|
||||
@@ -0,0 +1,526 @@
|
||||
# Casos de uso da aplicação actual
|
||||
|
||||
Este documento descreve os processos actualmente implementados ou previstos
|
||||
pelas rotas, controladores, serviços e migrations existentes no projecto.
|
||||
Não substitui regras legais nem autoriza publicação de dados.
|
||||
|
||||
## 1. Actores
|
||||
|
||||
| Actor | Responsabilidade |
|
||||
|---|---|
|
||||
| Visitante | Consulta o portal, oportunidades, negócios, zonas e documentos públicos. |
|
||||
| Utilizador registado | Mantém perfil, rascunhos, submissões, favoritos e mensagens. |
|
||||
| Investidor | Pesquisa oportunidades, manifesta interesse e acompanha candidaturas. |
|
||||
| Gestor | Trata interesses, leads, tarefas, oportunidades e catálogo. |
|
||||
| Revisor documental | Verifica documentos, malware, versões e autorização de divulgação. |
|
||||
| Gestor territorial | Gere projectos, clusters, corredores, geografia e zonas. |
|
||||
| Gestor fiscal | Gere fontes legais, versões de regras e parâmetros do simulador. |
|
||||
| Gestor de educação | Gere competências, necessidades, cursos e propostas. |
|
||||
| Parceiro institucional | Participa em parcerias, salas, mensagens, documentos e marcos. |
|
||||
| Administrador | Gere utilizadores, permissões, configurações e instalação de módulos. |
|
||||
| Sistema externo | Recebe/envia eventos através de outbox, inbox e sincronizações. |
|
||||
|
||||
## 2. Visão geral do ciclo da aplicação
|
||||
|
||||
```text
|
||||
Portal público
|
||||
-> consulta de catálogo
|
||||
-> interesse, apoio ou simulação
|
||||
-> autenticação/conta quando necessário
|
||||
-> candidatura ou submissão
|
||||
-> documentos e validação
|
||||
-> CRM, tarefas e comunicação
|
||||
-> decisão, parceria ou acompanhamento
|
||||
```
|
||||
|
||||
Os principais agregados são `opportunities`, `existing_businesses`,
|
||||
`interest_requests`, `investment_submissions`, `documents`, `investment_zones`
|
||||
e `simulations`.
|
||||
|
||||
## 3. UC-01 — Consultar a página inicial e serviços
|
||||
|
||||
**Actor:** Visitante.
|
||||
|
||||
**Pré-condições:** O portal está disponível.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. O visitante abre `/`.
|
||||
2. O sistema apresenta a página inicial e os serviços publicados.
|
||||
3. O visitante escolhe oportunidades, negócios, zonas, simulador, documentos,
|
||||
apoio ou parcerias.
|
||||
4. O sistema aplica apenas dados que estejam disponíveis para o público.
|
||||
|
||||
**Dados:** `portal_resources`, `documents`, `opportunities`, `sectors`,
|
||||
`existing_businesses`, `investment_zones`.
|
||||
|
||||
**Resultado:** O visitante é encaminhado para o processo escolhido.
|
||||
|
||||
## 4. UC-02 — Pesquisar sectores e oportunidades
|
||||
|
||||
**Actor:** Visitante ou investidor.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. O actor consulta `/oportunidades` ou a API de oportunidades.
|
||||
2. O sistema filtra por sector, localização, slug, estado e outros critérios.
|
||||
3. O detalhe da oportunidade mostra perfil, território, relações, documentos
|
||||
públicos e informação de investimento.
|
||||
4. O actor pode iniciar uma manifestação de interesse ou guardar a oportunidade.
|
||||
|
||||
**Dados:** `sectors`, `opportunities`, `opportunity_profiles`,
|
||||
`opportunity_facets`, `opportunity_clusters`, `opportunity_corridors`,
|
||||
`opportunity_projects`, `opportunity_locations`, `opportunity_favourites`.
|
||||
|
||||
**Resultado:** Consulta pública ou início de UC-05/UC-06.
|
||||
|
||||
## 5. UC-03 — Consultar um negócio existente
|
||||
|
||||
**Actor:** Visitante ou investidor.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. O actor consulta `/negocios`.
|
||||
2. O sistema lista negócios cujo estado e autorização permitem divulgação.
|
||||
3. O actor abre o detalhe pelo `slug`.
|
||||
4. O sistema apresenta apenas título, descrição, modalidade, localização,
|
||||
objectos e documentos autorizados.
|
||||
5. O actor pode iniciar uma manifestação de interesse.
|
||||
|
||||
**Dados:** `existing_businesses`, `existing_business_objects`,
|
||||
`existing_business_locations`, `existing_business_document_links`,
|
||||
`documents`, `interest_requests`.
|
||||
|
||||
**Regra:** Dados privados, mandatos e documentos não autorizados não são
|
||||
expostos pelo portal público.
|
||||
|
||||
## 6. UC-04 — Criar conta, autenticar e recuperar acesso
|
||||
|
||||
**Actor:** Visitante/utilizador.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. O visitante cria uma conta com nome, email e palavra-passe.
|
||||
2. O sistema cria `users`.
|
||||
3. O utilizador confirma o email quando aplicável.
|
||||
4. O utilizador inicia sessão.
|
||||
5. O sistema aplica as políticas de autenticação, confirmação de palavra-passe
|
||||
e, para operações protegidas, segundo factor.
|
||||
|
||||
**Dados:** `users`, `passkeys`, `sessions`, `password_reset_tokens`.
|
||||
|
||||
**Alternativas:** Registo rejeitado por email existente; palavra-passe
|
||||
incorrecta; recuperação por token; desafio de dois factores.
|
||||
|
||||
## 7. UC-05 — Manifestar interesse numa oportunidade
|
||||
|
||||
**Actor:** Visitante ou investidor.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. O actor abre o formulário de interesse.
|
||||
2. Informa contacto, oportunidade, modalidade, valor e mensagem.
|
||||
3. O sistema valida os dados, consentimentos e alvo.
|
||||
4. É criado `interest_requests`.
|
||||
5. O sistema regista recibo/idempotência quando aplicável.
|
||||
6. O sistema envia notificação ou evento de tratamento.
|
||||
|
||||
**Dados:** `interest_requests`, `consents`, `interest_submission_receipts`,
|
||||
`notifications`, `outbox_events`.
|
||||
|
||||
**Estado inicial:** `received`, `submitted` ou equivalente configurado.
|
||||
|
||||
**Resultado:** O interesse fica disponível para tratamento no CRM.
|
||||
|
||||
## 8. UC-06 — Manifestar interesse num negócio existente
|
||||
|
||||
**Actor:** Investidor.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. O investidor escolhe um `existing_business` publicado.
|
||||
2. Selecciona a modalidade e, se aplicável, percentagem e intervalo de valor.
|
||||
3. O sistema verifica que o negócio pode receber interesse.
|
||||
4. Cria `interest_requests.existing_business_id`.
|
||||
5. Regista consentimento para contacto e partilha da informação.
|
||||
6. O caso passa para tratamento interno.
|
||||
|
||||
**Dados:** `existing_businesses`, `interest_requests`, `investors`, `consents`.
|
||||
|
||||
**Regra:** Um pedido não pode apontar simultaneamente para uma oportunidade e
|
||||
para um negócio existente.
|
||||
|
||||
## 9. UC-07 — Guardar uma oportunidade e manter espaço do investidor
|
||||
|
||||
**Actor:** Utilizador autenticado.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. O utilizador abre uma oportunidade.
|
||||
2. Escolhe guardar/iniciar no espaço do investidor.
|
||||
3. O sistema regista a relação em `opportunity_favourites` ou inicia uma
|
||||
submissão/dossier.
|
||||
4. O utilizador consulta os seus itens em `/investidor` ou `/carteira`.
|
||||
|
||||
**Dados:** `users`, `opportunity_favourites`, `investment_dossiers`,
|
||||
`investor_accounts`, `investment_accounts`.
|
||||
|
||||
## 10. UC-08 — Criar e manter uma submissão de investimento
|
||||
|
||||
**Actor:** Investidor.
|
||||
|
||||
**Pré-condições:** Utilizador autenticado e verificado.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. O investidor cria uma submissão.
|
||||
2. O sistema cria `investment_submissions` com estado `draft`.
|
||||
3. O investidor grava o payload e as necessidades específicas.
|
||||
4. Pode adicionar anexos e mensagens.
|
||||
5. O sistema cria eventos e revisões para manter histórico.
|
||||
6. O investidor consulta ou actualiza a submissão.
|
||||
|
||||
**Dados:** `investment_submissions`, `investment_specific_needs`,
|
||||
`investment_submission_attachments`, `investment_submission_events`,
|
||||
`investment_submission_revisions`, `investment_messages`, `documents`.
|
||||
|
||||
## 11. UC-09 — Submeter candidatura
|
||||
|
||||
**Actor:** Investidor.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. O investidor confirma os dados da submissão.
|
||||
2. O sistema verifica campos obrigatórios, alvo e anexos.
|
||||
3. A submissão muda de `draft` para `submitted`.
|
||||
4. É associado ou criado o pedido de interesse, quando exigido.
|
||||
5. O sistema cria evento de submissão e notifica a gestão.
|
||||
|
||||
**Resultado:** A candidatura deixa de ser apenas rascunho e entra na fila de
|
||||
tratamento interno.
|
||||
|
||||
## 12. UC-10 — Gerir um negócio existente
|
||||
|
||||
**Actor:** Investidor, proprietário ou gestor.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. O actor cria ou actualiza o negócio.
|
||||
2. Regista modalidade, descrição, objectos, localização e documentos.
|
||||
3. O sistema cria revisões e mantém o snapshot anterior.
|
||||
4. Um gestor pode solicitar informação, rever ou rejeitar.
|
||||
5. O negócio percorre o workflow configurado.
|
||||
6. Apenas após validação e autorização poderá ser publicado.
|
||||
|
||||
**Dados:** `existing_businesses`, `existing_business_objects`,
|
||||
`existing_business_revisions`, `existing_business_reviews`,
|
||||
`business_workflow_transitions`, `existing_business_mandates`, `documents`.
|
||||
|
||||
**Estados possíveis:** `draft`, `submitted`, `in_review`,
|
||||
`needs_information`, `approved`, `published`, `suspended`, `rejected`,
|
||||
`withdrawn`, `archived`.
|
||||
|
||||
## 13. UC-11 — Tratar interesses no CRM
|
||||
|
||||
**Actor:** Gestor.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. O gestor abre a fila de interesses.
|
||||
2. Consulta o pedido, consentimentos e alvo.
|
||||
3. Atribui o caso a um responsável.
|
||||
4. Converte o interesse em `leads`, quando aplicável.
|
||||
5. Cria tarefas e actividades.
|
||||
6. Actualiza o estado e regista o resultado.
|
||||
7. Pode associar um `crm_opportunity` e continuar a negociação.
|
||||
|
||||
**Dados:** `interest_requests`, `leads`, `investors`, `contacts`,
|
||||
`crm_opportunities`, `tasks`, `activities`, `consents`.
|
||||
|
||||
**Estados de interesse:** recebimento, qualificação, contacto, diligência,
|
||||
negociação, conversão, recusa, cancelamento ou encerramento.
|
||||
|
||||
## 14. UC-12 — Gerir catálogo territorial
|
||||
|
||||
**Actor:** Gestor territorial.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. O gestor cria ou actualiza projectos, clusters e corredores.
|
||||
2. Liga projectos a locais.
|
||||
3. Liga projectos a clusters e corredores.
|
||||
4. Cria cadeias de valor e lacunas de investimento.
|
||||
5. Regista a fonte e o estado da relação.
|
||||
6. Outro gestor pode validar ou revogar a relação.
|
||||
|
||||
**Dados:** `projects`, `economic_clusters`, `value_chains`,
|
||||
`value_chain_links`, `development_corridors`, `cluster_projects`,
|
||||
`corridor_projects`, `corridor_clusters`, `investment_gaps`, `geo_locations`.
|
||||
|
||||
## 15. UC-13 — Gerir zonas de investimento
|
||||
|
||||
**Actor:** Gestor territorial com permissão e MFA quando exigido.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. Regista a fonte legal em `investment_legal_sources`.
|
||||
2. Cria a zona em `investment_zones`.
|
||||
3. Associa operador, categoria e localização.
|
||||
4. Adiciona geometria, anexos e relações com projectos, clusters ou corredores.
|
||||
5. Um revisor valida a informação.
|
||||
6. A zona é publicada, suspensa ou revogada.
|
||||
7. O sistema mantém revisões imutáveis.
|
||||
|
||||
**Estados:** `draft`, `published`, `suspended`; validação `pending`,
|
||||
`validated`, `historical`.
|
||||
|
||||
**Dados:** `investment_zones`, `investment_legal_sources`,
|
||||
`investment_zone_geometries`, `investment_zone_relations`,
|
||||
`investment_zone_links`, `investment_zone_attachments`, `investment_zone_revisions`.
|
||||
|
||||
## 16. UC-14 — Carregar, verificar e publicar documento
|
||||
|
||||
**Actor:** Gestor documental.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. O gestor carrega o ficheiro.
|
||||
2. O sistema preserva o original e calcula o hash.
|
||||
3. Detecta duplicados e conflitos de versão.
|
||||
4. Regista `documents` e, quando necessário, `document_versions`.
|
||||
5. Executa análise antimalware.
|
||||
6. O gestor revê classificação, datas, fonte e autorização.
|
||||
7. O documento é associado ao negócio, candidatura, zona, projecto ou portal.
|
||||
8. Só então pode ser disponibilizado publicamente.
|
||||
|
||||
**Dados:** `documents`, `document_versions`, `document_malware_scans`,
|
||||
`portal_resources`, `portal_resource_revisions` e tabelas `*_documents`.
|
||||
|
||||
**Estados documentais:** pendente, limpo, infectado, rejeitado, privado,
|
||||
publicável, publicado ou revogado.
|
||||
|
||||
## 17. UC-15 — Criar rascunho de simulação
|
||||
|
||||
**Actor:** Visitante ou utilizador.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. O actor abre o simulador.
|
||||
2. O sistema cria ou recupera um cookie de recuperação.
|
||||
3. Guarda os dados em `simulation_form_drafts`.
|
||||
4. O actor pode continuar mais tarde.
|
||||
5. Um utilizador autenticado pode reclamar o rascunho.
|
||||
6. O rascunho pode ser ligado a um negócio existente, mediante autorização.
|
||||
|
||||
**Resultado:** Dados incompletos permanecem como rascunho e não são tratados
|
||||
como cálculo fiscal executado.
|
||||
|
||||
## 18. UC-16 — Executar simulação
|
||||
|
||||
**Actor:** Visitante ou utilizador.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. O actor submete os dados do simulador.
|
||||
2. O sistema escolhe a versão do questionário.
|
||||
3. Resolve os parâmetros fiscais elegíveis.
|
||||
4. Cria `simulations` e `simulation_scenarios`.
|
||||
5. Executa o cálculo através de job.
|
||||
6. Guarda resultados, obrigações e fontes.
|
||||
7. Gera relatório quando solicitado.
|
||||
8. Regista auditoria e idempotência.
|
||||
|
||||
**Dados:** `questionnaire_versions`, `simulations`, `simulation_scenarios`,
|
||||
`simulation_results`, `simulation_obligations`, `simulation_reports`,
|
||||
`simulation_jobs`, `simulation_result_sources`, `simulation_audit_events`.
|
||||
|
||||
## 19. UC-17 — Gerir regra fiscal
|
||||
|
||||
**Actor:** Autor, revisor, aprovador ou publicador fiscal.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. O autor cria uma versão da regra.
|
||||
2. Associa a regra a uma fonte legal.
|
||||
3. O revisor verifica expressão, datas e parâmetros.
|
||||
4. O aprovador decide.
|
||||
5. O publicador disponibiliza a versão.
|
||||
6. O sistema regista cada transição.
|
||||
|
||||
**Dados:** `simulation_tax_rule_versions`, `simulation_tax_rule_events`,
|
||||
`investment_legal_sources`, `users`.
|
||||
|
||||
**Regra:** Importar ou publicar um documento não activa automaticamente um
|
||||
cálculo fiscal.
|
||||
|
||||
## 20. UC-18 — Gerir educação e competências
|
||||
|
||||
**Actor:** Gestor de educação.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. Mantém catálogo de competências, profissões, instituições e cursos.
|
||||
2. Regista necessidades de formação de projectos.
|
||||
3. Associa ocupações e competências.
|
||||
4. Regista capacidades e oportunidades educativas.
|
||||
5. Analisa lacunas.
|
||||
6. Cria proposta de investimento educativo.
|
||||
|
||||
**Dados:** `skills`, `occupations`, `training_needs`, `training_providers`,
|
||||
`courses`, `education_opportunities`, `investment_gaps`,
|
||||
`education_investment_proposals` e tabelas `skills_edu`.
|
||||
|
||||
## 21. UC-19 — Abrir e tratar assistência de terra
|
||||
|
||||
**Actor:** Investidor e gestor de assistência.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. O investidor cria um caso de assistência.
|
||||
2. Regista localização, necessidade e documentos.
|
||||
3. O gestor analisa o caso.
|
||||
4. São registadas revisões, eventos e pedidos adicionais.
|
||||
5. O caso é encaminhado ou encerrado.
|
||||
|
||||
**Dados:** `land_assistance_cases`, `land_assistance_revisions`,
|
||||
`land_assistance_events`, `land_assistance_documents`, `documents`, `users`.
|
||||
|
||||
## 22. UC-20 — Criar e gerir parceria
|
||||
|
||||
**Actor:** Investidor, organização ou parceiro institucional.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. O actor cria um rascunho de parceria.
|
||||
2. Regista origem, sector, local e objectivo.
|
||||
3. Outro actor manifesta interesse.
|
||||
4. A candidatura é aceite ou recusada.
|
||||
5. É aberta uma sala de parceria.
|
||||
6. Os participantes trocam mensagens e documentos.
|
||||
7. São definidos marcos, pagamentos ou acordo.
|
||||
8. O sistema regista eventos e notificações.
|
||||
|
||||
**Dados:** `portal_partnerships`, `partnership_origins`,
|
||||
`partnership_locations`, `partnership_interests`, `partnership_rooms`,
|
||||
`partnership_messages`, `partnership_documents`, `partnership_milestones`,
|
||||
`partnership_events`, `partnership_notifications`.
|
||||
|
||||
## 23. UC-21 — Usar o balcão virtual
|
||||
|
||||
**Actor:** Utilizador autenticado e agente de atendimento.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. O utilizador abre um pedido no balcão.
|
||||
2. O sistema cria um caso e associa o serviço.
|
||||
3. O agente recebe o pedido.
|
||||
4. São trocadas mensagens e solicitados documentos.
|
||||
5. O caso passa por estados e revisões.
|
||||
6. Eventos, notificações e pagamentos são registados.
|
||||
7. O caso é resolvido ou encerrado.
|
||||
|
||||
**Dados:** `desk_service_versions`, `desk_cases`, `desk_requests`,
|
||||
`desk_documents`, `desk_events`, `desk_revisions`, `desk_messages`,
|
||||
`desk_notifications`, `desk_institutional_links`, `desk_payments`.
|
||||
|
||||
## 24. UC-22 — Usar inteligência artificial com evidência
|
||||
|
||||
**Actor:** Gestor autorizado.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. O gestor activa a capacidade em `ai_controls`.
|
||||
2. Regista uma fonte ou documento.
|
||||
3. O sistema cria chunks da fonte.
|
||||
4. O gestor inicia uma execução de IA.
|
||||
5. O sistema grava `ai_runs`.
|
||||
6. O resultado pode ser revisto ou receber feedback.
|
||||
7. A aplicação mantém a fonte que suporta a resposta.
|
||||
|
||||
**Dados:** `ai_controls`, `ai_sources`, `ai_chunks`, `ai_runs`, `ai_feedback`,
|
||||
`documents`, `users`.
|
||||
|
||||
## 25. UC-23 — Sincronizar com sistemas externos
|
||||
|
||||
**Actor:** Sistema externo ou processo de integração.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. A aplicação gera um evento em `outbox_events`.
|
||||
2. Um worker publica o evento.
|
||||
3. O sistema externo responde ou envia evento para `inbox_events`.
|
||||
4. A aplicação cria `sync_runs`.
|
||||
5. Falhas ou divergências são registadas em `reconciliation_issues`.
|
||||
6. O processo pode ser repetido de forma idempotente.
|
||||
|
||||
## 26. UC-24 — Auditoria e controlo de alterações
|
||||
|
||||
**Actor:** Sistema, gestor ou auditor.
|
||||
|
||||
**Fluxo principal:**
|
||||
|
||||
1. Uma operação protegida é executada.
|
||||
2. O sistema identifica actor, entidade, acção e correlação.
|
||||
3. Regista evento de auditoria.
|
||||
4. Alterações de entidades versionadas geram revisão/snapshot.
|
||||
5. O auditor consulta o histórico sem alterar o registo original.
|
||||
|
||||
**Dados:** `audit_events`, `simulation_audit_events`, `investment_zone_revisions`,
|
||||
`existing_business_revisions`, `investment_submission_revisions`,
|
||||
`desk_revisions`, `outbox_events`.
|
||||
|
||||
## 27. Regras transversais da aplicação
|
||||
|
||||
- Dados privados exigem autenticação e autorização.
|
||||
- Divulgação pública depende do estado de publicação e autorização registada.
|
||||
- Documentos originais devem ser preservados.
|
||||
- Hash, versão e proveniência devem permanecer associados ao documento.
|
||||
- Relações jurídicas ou territoriais não validadas ficam pendentes.
|
||||
- Rascunho não é submissão aprovada.
|
||||
- Importação não é publicação.
|
||||
- Publicação de documento não é activação de regra fiscal.
|
||||
- Cálculos fiscais devem usar fonte e parâmetro identificados.
|
||||
- Histórico e auditoria não devem ser apagados por rollback destrutivo.
|
||||
- Operações de migração e testes devem usar base QA isolada.
|
||||
|
||||
## 28. Mapa resumido dos processos
|
||||
|
||||
```text
|
||||
Visitante
|
||||
-> consulta catálogo
|
||||
-> interesse / apoio / simulação
|
||||
|
||||
Investidor
|
||||
-> conta
|
||||
-> favoritos / dossier
|
||||
-> submissão
|
||||
-> documentos
|
||||
-> mensagens
|
||||
|
||||
Gestor
|
||||
-> interesse
|
||||
-> lead
|
||||
-> tarefas
|
||||
-> oportunidade CRM
|
||||
-> decisão
|
||||
|
||||
Gestor territorial
|
||||
-> projecto / cluster / corredor
|
||||
-> zona
|
||||
-> geometria
|
||||
-> fonte legal
|
||||
-> validação / publicação
|
||||
|
||||
Gestor fiscal
|
||||
-> regra
|
||||
-> revisão
|
||||
-> aprovação
|
||||
-> publicação
|
||||
-> parâmetro usado pelo simulador
|
||||
|
||||
Parceiros
|
||||
-> parceria
|
||||
-> candidatura
|
||||
-> sala
|
||||
-> mensagens / documentos / marcos
|
||||
```
|
||||
@@ -315,3 +315,8 @@ Situação comum observada: `publication_status=draft`, `validation_status=valid
|
||||
- Verificação: `php artisan test tests/Feature/PortalServicesTest.php` — 17 testes/497 asserções aprovados em QA; cobrem publicação autorizada com pesquisa textual e filtros combinados, ficha, download, associação ao módulo e links de requisitos com estados confirmados/pendentes. `npm.cmd run build` concluído. O build alerta sobre pacote opcional `fontaine` e chunk `services` acima de 500 KB; sem erro de compilação. Não houve browser autenticado disponível, portanto não se declara publicação, pesquisa ou download públicos concluídos.
|
||||
- A autorização documental não foi aplicada a zonas, parques, negócios privados ou oportunidades de demonstração. A regra de publicação de parque não exige área confirmada; divergência de área, isoladamente, não é um gate técnico. Se a ficha avançar por decisão própria, deve omitir o total disputado ou mostrar os valores reportados com fonte/escopo, sem alegar disponibilidade. Beluluane, Munhava e Topuito requerem revisão por registo para fonte/localização pública e para retirar ou qualificar a área; Topuito só tem província associada no mestre geográfico, pelo que não se pode anunciar uma associação administrativa a Larde enquanto essa referência não existir. ZEE/ZFI continuam dependentes de acto oficial estruturado e validado, regime e relações territoriais apropriadas; uma área sem confirmação não substitui essas evidências.
|
||||
- Proposta para as seis oportunidades de demonstração já públicas: preservar os registos e IDs, adicionar origem/tipo `demo` e identificá-los visivelmente como **Exemplo de demonstração**, separar a pesquisa de exemplos da pesquisa de oportunidades reais e excluir os exemplos dos resultados reais por defeito. Não os converter nem retirar em massa; cada registo real exigirá origem e proponente documentados. Nenhuma alteração foi feita nesta execução.
|
||||
|
||||
### Referência da entrada única de gestão — QA, 22/10/2026
|
||||
|
||||
Nos requisitos existentes `MASTER-9f6dd1d9-0001`, `MASTER-9f6dd1d9-0003` e `MASTER-02f44baf-0002`, acrescentar como evidência delimitada a secção «Entrada única de gestão — preparação em QA, 22/10/2026» do manual de rastreabilidade e `tests/Feature/ContentManagementEntryTest.php`: quatro testes/99 asserções, ClamAV real e reversão transaccional em QA. Origem do objectivo e das regras operacionais: decisão expressa da Tempo; navegação, filtros e recuperação do upload são escolhas de implementação. Reutilizados biblioteca, armazenamento, perfis e histórico. Não altera os critérios de aceitação nem fecha os requisitos integrais: integração/contas reais e categorias não demonstradas conservam o seu estado. Geometrias permanecem separadas.
|
||||
Conferência de integração de 22/10/2026 (requisitos documentais MASTER-9f6dd1d9-0001/0003 e reutilização MASTER-02f44baf-0002): 8286d750 servido, acesso real de Nala restituído removendo dependência indevida da reserva técnica do host; lista nominal, MFA e âmbito preservados. Ver secção «Entrada única — integração e conferência real» do manual. Não encerrar o fluxo integral das oportunidades: ausência de Publicador activo e publicação promocional ainda por executor privado são saldos explícitos; não condicionam os acessos já aprovados. Não foram criadas contas, matrizes ou papéis adicionais.
|
||||
@@ -0,0 +1,211 @@
|
||||
# Levantamento funcional, entidades e eventos — InvestMoz
|
||||
|
||||
**Data do levantamento:** 9 de Outubro de 2026
|
||||
**Âmbito:** aplicação Laravel/React presente neste repositório.
|
||||
**Base de evidência:** README, rotas, controladores, modelos, migrations e documentação em `docs/investmoz/`. A existência de uma tabela, rota ou documento não prova que a função esteja activa em produção.
|
||||
|
||||
## 1. Resumo executivo
|
||||
|
||||
O projecto é uma plataforma de investimento com área pública e área autenticada. Abrange descoberta de oportunidades e negócios, manifestações de interesse, gestão de candidaturas e carteira, CRM local, geografia e zonas de investimento, simulador e governação fiscal, catálogo documental, educação, assistência de terra, parcerias, balcão virtual, IA com fontes e integrações preparadas.
|
||||
|
||||
O README apresenta a base inicial do portal (sectores, oportunidades e manifestações). O código actual e os documentos de domínio registam a extensão funcional posterior. Para o inventário detalhado dos fluxos, consultar [casos-de-uso-aplicacao-actual.md](casos-de-uso-aplicacao-actual.md), que identifica 24 casos de uso. Este documento complementa-o com uma visão de domínio, um diagrama resumido de relações e a frequência observável dos eventos.
|
||||
|
||||
## 2. Âmbito e método
|
||||
|
||||
Foi feito inventário dos caminhos acessíveis no repositório: **981 ficheiros** por `rg --files`, excluindo dependências geradas `vendor` e `node_modules`. Dentro da aplicação foram consultados, em particular, `routes/`, `app/Models/`, `app/Http/Controllers/`, `database/migrations/`, `database/seeders/` e `docs/investmoz/`. O número é uma contagem do inventário local nessa data, não um inventário de anexos externos ou de dados de produção.
|
||||
|
||||
Classificação usada:
|
||||
|
||||
- **Implementado no código:** rota, controlador, job, comando ou schema identificável.
|
||||
- **Documentado/planeado:** descrito em documentação, sem prova suficiente de execução integrada.
|
||||
- **Frequência de negócio desconhecida:** não há amostra de eventos de produção nem telemetria para calcular volume por hora/dia/mês.
|
||||
- **Cadência técnica explícita:** periodicidade configurada no scheduler ou no mecanismo de fila.
|
||||
|
||||
## 3. Actores e sistemas
|
||||
|
||||
| Actor | Objectivo principal | Áreas usadas |
|
||||
|---|---|---|
|
||||
| Visitante | Consultar conteúdos públicos e iniciar pedidos | Portal, oportunidades, negócios, zonas, documentos, simulador |
|
||||
| Investidor/utilizador | Manter perfil, favoritos, rascunhos, submissões, documentos e mensagens | Espaço do investidor, carteira, assistência, parcerias |
|
||||
| Gestor de portal/CRM | Rever interesses, gerir leads, tarefas, catálogo e publicação | CRM local, gestão de portal |
|
||||
| Gestor territorial | Manter projectos, clusters, corredores, relações, geografia e zonas | Catálogo territorial, GIS, zonas |
|
||||
| Revisor documental | Conferir proveniência, versão, hash, autorização e análise antimalware | Biblioteca documental |
|
||||
| Gestor fiscal | Rever fontes, parâmetros e versões das regras fiscais | Governação fiscal, simulador |
|
||||
| Gestor de educação | Manter competências, necessidades, cursos, capacidades e propostas | Educação |
|
||||
| Gestor/agente de assistência | Tratar pedidos de terra e pedidos do balcão virtual | Assistência de terra, balcão |
|
||||
| Parceiro institucional | Participar em oportunidades/parcerias e colaborar em salas | Parcerias |
|
||||
| Administrador | Gerir autenticação, segurança, configurações e permissões | Administração |
|
||||
| Sistema externo/worker | Receber/enviar mensagens ou executar trabalho assíncrono | Outbox/inbox, filas, integrações |
|
||||
|
||||
Papéis são uma leitura funcional dos fluxos. A autorização efectiva depende das policies, middleware e permissões implementadas, não apenas desta tabela.
|
||||
|
||||
## 4. Catálogo de casos de uso
|
||||
|
||||
O documento [Casos de uso da aplicação actual](casos-de-uso-aplicacao-actual.md) detalha pré-condições, fluxos e dados para cada caso. Índice consolidado:
|
||||
|
||||
| ID | Caso de uso | Actor principal | Resultado |
|
||||
|---|---|---|---|
|
||||
| UC-01 | Consultar página inicial e serviços | Visitante | Entrada no serviço público autorizado |
|
||||
| UC-02 | Pesquisar sectores e oportunidades | Visitante/investidor | Lista/detalhe e possível favorito/interesse |
|
||||
| UC-03 | Consultar negócio existente | Visitante/investidor | Consulta da oferta divulgável |
|
||||
| UC-04 | Criar conta, autenticar e recuperar acesso | Utilizador | Sessão autenticada/verificada |
|
||||
| UC-05 | Manifestar interesse numa oportunidade | Visitante/investidor | Pedido recebido para tratamento |
|
||||
| UC-06 | Manifestar interesse num negócio existente | Investidor | Pedido ligado ao negócio e consentimento |
|
||||
| UC-07 | Guardar oportunidade e manter espaço do investidor | Utilizador | Favorito, dossier ou item na carteira |
|
||||
| UC-08 | Criar e manter submissão de investimento | Investidor | Submissão em rascunho com histórico |
|
||||
| UC-09 | Submeter candidatura | Investidor | Entrada em fila de tratamento |
|
||||
| UC-10 | Gerir negócio existente | Proprietário/gestor | Registo revisto e eventualmente publicável |
|
||||
| UC-11 | Tratar interesses no CRM | Gestor | Qualificação, lead, tarefa e seguimento |
|
||||
| UC-12 | Gerir catálogo territorial | Gestor territorial | Projectos, clusters, corredores e relações |
|
||||
| UC-13 | Gerir zonas de investimento | Gestor territorial/revisor | Zona validada, publicada ou suspensa |
|
||||
| UC-14 | Carregar, verificar e publicar documento | Revisor documental | Documento preservado com estados independentes |
|
||||
| UC-15 | Criar/retomar rascunho de simulação | Visitante/utilizador | Rascunho recuperável, sem cálculo implícito |
|
||||
| UC-16 | Executar simulação | Visitante/utilizador | Resultado, obrigações, fontes e relatório |
|
||||
| UC-17 | Gerir regra fiscal | Gestor fiscal | Versão de regra passa por revisão/aprovação |
|
||||
| UC-18 | Gerir educação e competências | Gestor de educação | Catálogo, análise de lacunas e proposta |
|
||||
| UC-19 | Abrir/tratar assistência de terra | Investidor/gestor | Caso acompanhado até encaminhamento/fecho |
|
||||
| UC-20 | Criar e gerir parceria | Investidor/parceiro | Interesse, sala, mensagens, documentos e marcos |
|
||||
| UC-21 | Usar balcão virtual | Utilizador/agente | Pedido de serviço acompanhado até resolução |
|
||||
| UC-22 | Usar IA com evidência | Gestor autorizado | Execução rastreável ligada a fontes e feedback |
|
||||
| UC-23 | Sincronizar sistemas externos | Worker/sistema externo | Eventos recebidos/enviados e divergências registadas |
|
||||
| UC-24 | Auditar alterações | Sistema/gestor/auditor | Histórico e correlação para operações relevantes |
|
||||
|
||||
## 5. Diagrama de entidades e relações
|
||||
|
||||
Diagrama lógico resumido por domínios. As cardinalidades representam a estrutura documentada; algumas relações polimórficas, opcionais ou em tabelas de junção estão simplificadas. Para o ERD de schema detalhado, consultar [erd.mmd](erd.mmd), [data-dictionary.csv](data-dictionary.csv) e [table-catalog.csv](table-catalog.csv).
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
USER ||--o{ INVESTOR : possui
|
||||
USER ||--o{ SUBMISSION : cria
|
||||
USER ||--o{ SIMULATION : executa
|
||||
USER ||--o{ AUDIT_EVENT : origina
|
||||
SECTOR ||--o{ OPPORTUNITY : classifica
|
||||
OPPORTUNITY ||--o{ INTEREST_REQUEST : recebe
|
||||
EXISTING_BUSINESS ||--o{ INTEREST_REQUEST : recebe
|
||||
INVESTOR ||--o{ INTEREST_REQUEST : manifesta
|
||||
INTEREST_REQUEST ||--o| LEAD : converte
|
||||
LEAD ||--o{ CRM_TASK : acompanha
|
||||
OPPORTUNITY ||--o{ SUBMISSION : referencia
|
||||
SUBMISSION ||--o{ SUBMISSION_REVISION : versiona
|
||||
SUBMISSION ||--o{ SUBMISSION_EVENT : regista
|
||||
SUBMISSION ||--o{ DOCUMENT_LINK : anexa
|
||||
PROJECT }o--o{ ECONOMIC_CLUSTER : associa
|
||||
PROJECT }o--o{ DEVELOPMENT_CORRIDOR : associa
|
||||
PROJECT ||--o{ INVESTMENT_GAP : identifica
|
||||
INVESTMENT_ZONE ||--o{ ZONE_REVISION : versiona
|
||||
INVESTMENT_ZONE ||--o{ ZONE_RELATION : liga
|
||||
INVESTMENT_ZONE ||--o{ ZONE_ATTACHMENT : documenta
|
||||
DOCUMENT ||--o{ DOCUMENT_VERSION : versiona
|
||||
DOCUMENT ||--o{ MALWARE_SCAN : analisa
|
||||
DOCUMENT }o--o{ PORTAL_RESOURCE : disponibiliza
|
||||
SIMULATION ||--o{ SIMULATION_SCENARIO : contém
|
||||
SIMULATION_SCENARIO ||--o{ SIMULATION_RESULT : produz
|
||||
SIMULATION_RESULT ||--o{ SIMULATION_OBLIGATION : discrimina
|
||||
SIMULATION_RESULT ||--o{ RESULT_SOURCE : fundamenta
|
||||
TAX_RULE_VERSION }o--o{ LEGAL_SOURCE : fundamenta
|
||||
PROJECT ||--o{ TRAINING_NEED : requer
|
||||
SKILL }o--o{ OCCUPATION : relacionada
|
||||
TRAINING_PROVIDER ||--o{ COURSE : oferece
|
||||
USER ||--o{ LAND_ASSISTANCE_CASE : requer
|
||||
PARTNERSHIP ||--o{ PARTNERSHIP_INTEREST : recebe
|
||||
PARTNERSHIP ||--o| PARTNERSHIP_ROOM : abre
|
||||
PARTNERSHIP_ROOM ||--o{ PARTNERSHIP_MESSAGE : contém
|
||||
DESK_CASE ||--o{ DESK_EVENT : regista
|
||||
AI_SOURCE ||--o{ AI_CHUNK : divide
|
||||
AI_SOURCE ||--o{ AI_RUN : suporta
|
||||
OUTBOX_EVENT }o..|| SYNC_RUN : processa
|
||||
SYNC_RUN ||--o{ RECONCILIATION_ISSUE : detecta
|
||||
```
|
||||
|
||||
### 5.1 Domínios e entidades nucleares
|
||||
|
||||
| Domínio | Entidades nucleares | Relações principais |
|
||||
|---|---|---|
|
||||
| Identidade e acesso | `users`, `passkeys`, sessões, organizações, memberships | Utilizador possui perfil e inicia operações protegidas |
|
||||
| Catálogo público | `sectors`, `opportunities`, catálogos de referência, recursos do portal | Sector classifica oportunidades; publicação controla exposição |
|
||||
| Negócios existentes | `existing_businesses`, objectos, locais, revisões, mandatos | Negócio tem objectos/locais/documentos e workflow próprio |
|
||||
| Investidor e CRM | `investors`, `interest_requests`, `leads`, `contacts`, oportunidades CRM, tarefas | Interesse pode converter-se em lead e gerar tarefas/actividades |
|
||||
| Carteira e submissões | dossiers, `investment_submissions`, necessidades, revisões, eventos, mensagens, anexos | Submissão pertence a utilizador e liga-se a oportunidade/negócio |
|
||||
| Território e GIS | `projects`, `economic_clusters`, `development_corridors`, `geo_locations`, `value_chains`, `investment_gaps` | Relações muitos-para-muitos documentam associação territorial/económica |
|
||||
| Zonas de investimento | `investment_zones`, fontes legais, geometrias, relações, anexos, revisões | Zona tem localização, evidência, relações e estado de validação/publicação |
|
||||
| Documentos | `documents`, versões, scans, associações por domínio, recursos do portal | Documento pode ser associado a várias entidades; disponibilidade, estado documental e uso em cálculo são independentes |
|
||||
| Simulação e fiscalidade | rascunhos, `simulations`, cenários, resultados, obrigações, fontes, regras versionadas | Execução usa versões/parâmetros elegíveis e preserva as fontes do resultado |
|
||||
| Educação | skills, occupations, providers, courses, training needs/capacity, propostas | Projectos expressam necessidades relacionadas com competências e formação |
|
||||
| Serviços e colaboração | casos de terra, balcão virtual, parcerias, salas, mensagens, documentos, marcos | Casos e salas mantêm eventos e participantes próprios |
|
||||
| IA e governação | controlos, fontes/chunks, execuções, feedback, auditoria | Execução de IA deve poder ser rastreada às fontes e ao actor |
|
||||
| Integrações | `outbox_events`, `inbox_events`, `sync_runs`, reconciliações | Eventos são idempotentes/correlacionáveis; desenho de schema não prova integração activa |
|
||||
|
||||
## 6. Eventos e frequência
|
||||
|
||||
### 6.1 Frequência explicitamente configurada
|
||||
|
||||
| Evento/processo | Gatilho | Frequência/cadência no código | Natureza e observação |
|
||||
|---|---|---|---|
|
||||
| Escalar tarefas vencidas (`investmoz:escalate-overdue-tasks`) | Scheduler Laravel | **A cada 15 minutos**, com `withoutOverlapping()` | Única periodicidade de scheduler encontrada em `routes/console.php`; execução real requer scheduler/cron activo |
|
||||
| Executar simulação (`RunSimulation`) | Pedido de cálculo aceite | Por submissão, via fila, `afterCommit` | Assíncrono; frequência depende da procura e de workers activos |
|
||||
| Analisar documento (`ScanDocumentForMalware`) | Upload/solicitação de scan em fluxos suportados | Por documento, via fila, `afterCommit`; job único por identidade configurada | Cadência sob procura; requer worker e scanner configurados. Upload/publicação não confirma scan limpo |
|
||||
| Comparar compatibilidade MNE (`CompareMneBusiness`) | Alteração de negócio que agenda comparação | Por alteração aplicável, em fila específica e `afterCommit` | Trabalho assíncrono/sombra; volume real desconhecido |
|
||||
| Notificar recepção de interesse (`NotifyInterestReceived`) | Disparo esperado após recepção de interesse | Job existe, mas não foi localizada chamada a `dispatch()` no código consultado; frequência de execução não confirmada | Tratar como capacidade prevista até localizar/activar o disparo; entrega efectiva também depende de configuração e worker |
|
||||
| Auditoria, revisões e eventos de domínio | Acção de utilizador/sistema | No momento da operação que os gera | Orientado a evento; não há taxa histórica neste levantamento |
|
||||
| Outbox/inbox e sincronização externa | Evento de domínio ou mensagem externa | Sem periodicidade operacional confirmada neste código/documentação consultada | Estrutura preparada; documentação aponta sincronização externa real como pendente/não activada |
|
||||
|
||||
### 6.2 Frequência por categoria de actividade
|
||||
|
||||
| Categoria | Frequência funcional esperada | O que é possível afirmar |
|
||||
|---|---|---|
|
||||
| Leituras públicas (home, catálogo, oportunidade, negócio, mapas/referências) | Sob procura do visitante | Há endpoints/páginas; sem logs ou analytics não se quantificam consultas |
|
||||
| Escritas de investidor (interesses, favoritos, submissões, mensagens, perfil) | Sob acção do utilizador | São iniciadas por pedido explícito; não há volume observado disponível |
|
||||
| Gestão (revisão, publicação, CRM, regras, zonas, catálogo) | Sob trabalho de gestores autorizados | Fluxos e protecções documentados; frequência por papel não medida |
|
||||
| Simulação e rascunhos | Sob utilização do simulador; rascunhos podem ser actualizados/retomados | Rota e jobs encontrados; não assumir execução periódica nem cálculo fiscal por guardar rascunho |
|
||||
| Uploads e análise documental | Sob envio/reanálise de documento | Job de scan é disparado por fluxos suportados; scan e publicação são estados distintos |
|
||||
| Auditoria | Sob operações auditadas | A frequência acompanha as operações instrumentadas; cobertura total precisa de confirmação por fluxo |
|
||||
| Regras fiscais e fontes | Sob processo de revisão/aprovação e alteração normativa | Não há calendário fixo; fonte publicada não activa automaticamente cálculo |
|
||||
| Integrações externas | Não determinável/activação pendente | Tabelas de integração e documentação não são prova de entrega ou sincronização real |
|
||||
|
||||
### 6.3 Frequência observada versus frequência de execução
|
||||
|
||||
Não foram disponibilizados logs, contagens de `audit_events`, filas, analytics, pedidos HTTP ou exportação da base activa. Por isso, **não é possível apresentar eventos por hora/dia/mês, sazonalidade, taxa de falhas ou utilizadores activos**. A cadência de 15 minutos é apenas o intervalo configurado para o comando agendado. Para apurar frequência real, recolher métricas autorizadas por tipo de evento, intervalo temporal e resultado, sem expor dados pessoais; documentar período da amostra, filtros, retenção e origem. Não inferir métricas a partir de seeders, testes, screenshots ou schema.
|
||||
|
||||
## 7. Sequência de alto nível
|
||||
|
||||
```text
|
||||
Visita pública
|
||||
-> pesquisa catálogo/oportunidades/negócios/território
|
||||
-> interesse, candidatura, assistência ou simulação
|
||||
-> autenticação e consentimento quando exigidos
|
||||
-> gravação do pedido/rascunho e histórico
|
||||
-> trabalho assíncrono quando aplicável (scan, cálculo, notificação)
|
||||
-> revisão por responsável autorizado
|
||||
-> decisão, comunicação ou publicação com autorização
|
||||
-> auditoria e eventual integração externa homologada
|
||||
```
|
||||
|
||||
## 8. Regras transversais que afectam os fluxos
|
||||
|
||||
- Disponibilidade para consulta, situação jurídica/documental e uso em regra/cálculo são estados independentes.
|
||||
- Importar ou publicar documento não activa regra nem cálculo fiscal.
|
||||
- Relações sem citação/evidência verificada devem ficar pendentes e identificadas.
|
||||
- Guardar um rascunho não equivale a submeter ou aprovar.
|
||||
- A divulgação obedece à autorização registada e ao seu âmbito.
|
||||
- Documento original, proveniência, identificador, edição/data, hash e histórico devem ser preservados; não marcar autenticidade, vigência, direitos ou scan como confirmados sem evidência.
|
||||
- Integração desenhada (outbox/inbox) não equivale a sincronização activa.
|
||||
- Alterações a dados activos exigem confirmação de backup/restauro e acção autenticada necessária; testes/migrações devem usar exclusivamente base QA isolada, conforme `AGENTS.md`.
|
||||
|
||||
## 9. Limitações e pontos para validação
|
||||
|
||||
1. A cobertura implementada de cada UC varia; alguns documentos descrevem trabalho parcial, proposto ou bloqueado. Consultar `docs/investmoz/validation.md`, `completion-audit-2026-10-04.md`, `delivery-gaps.md` e os README de cada domínio antes de declarar operação concluída.
|
||||
2. O ERD resumido não substitui constraints/FKs, cardinalidades exactas, escopos de acesso ou estados enumerados no schema e OpenAPI.
|
||||
3. A frequência real de utilização e dos eventos de negócio permanece **indeterminada** sem telemetria operacional.
|
||||
4. Execução do scheduler, workers, ClamAV, notificações, SuiteCRM e restantes conectores depende de configuração e operação externa verificável.
|
||||
5. O inventário desta tarefa é do repositório, não dos ficheiros/anexos em armazenamento externo nem da base de dados de produção.
|
||||
|
||||
## 10. Fontes locais principais
|
||||
|
||||
- [README do projecto](../../README.md)
|
||||
- [Casos de uso detalhados](casos-de-uso-aplicacao-actual.md)
|
||||
- [ERD do schema reconciliado](erd.mmd)
|
||||
- [Arquitectura e migrations](architecture-and-migrations.md)
|
||||
- [Validação](validation.md) e [auditoria de conclusão](completion-audit-2026-10-04.md)
|
||||
- Rotas: `routes/web.php`, `routes/api.php`, `routes/investment.php`, `routes/partnerships.php`, `routes/land-assistance.php`, `routes/investment-zones.php`, `routes/territorial-registry.php`
|
||||
- Cadência: `routes/console.php`; trabalhos assíncronos em `app/Jobs/`
|
||||
@@ -1,5 +1,19 @@
|
||||
# Rastreabilidade, utilização e formação
|
||||
|
||||
## Guia curto — entrada única de gestão
|
||||
|
||||
**Link da equipa:** https://teste-investidor.tempo.co.mz/gestao-conteudos. Entre com a conta habitual e o seu MFA. As funções e âmbitos aprovados determinam os botões apresentados; não é necessário acesso à linha de comandos.
|
||||
|
||||
| Trabalho | Percurso pela interface |
|
||||
| --- | --- |
|
||||
| Oportunidades | **Nova oportunidade** → escolher promotor real e modalidade → preencher → **Guardar rascunho**. Recuperar em **Gerir oportunidades e histórico**; abrir a ficha, completar **Imagens, documentos e localizações** e **Submeter para validação**. O Validador usa a fila de revisão e as acções **Iniciar validação**, **Guardar revisão especializada**, pedir correcções ou **Aprovar versão**, conforme a tarefa atribuída. O Publicador usa a fila de publicação e **Publicar no catálogo público** na versão aprovada com visibilidade pública. A equipa conclui as funções que já lhe foram atribuídas; a criação não concede revisão nem publicação. |
|
||||
| Documentos | **Novo documento** → escolher ficheiro e origem conhecida → **Guardar em quarentena** → conferir a análise indicada no ecrã → completar **Registar documento na biblioteca** → **Guardar como rascunho**. Recuperar pela pesquisa em **Gerir conteúdos, versões e histórico**; usar **Editar metadados** para rever; guardar as alterações e usar **Publicar**. Se o scanner comunicar falha, usar **Tentar novamente**; avaria persistente é manutenção técnica, não uma nova aprovação documental. |
|
||||
| Legislação | **Nova legislação** → mesmo percurso documental, com tipo Legislação pré-seleccionado → registar entidade, número, edição, fonte, datas e situação conhecida → guardar → pesquisar/reabrir → rever metadados/associações → **Publicar**. Fonte oficial identificada mantém a aceitação da Tempo; não exige segunda certificação. A publicação permite consulta e não activa regras fiscais. |
|
||||
|
||||
Para retirar um documento, abrir o registo e usar **Retirar da publicação**; original e histórico ficam preservados. Para oportunidades, usar as acções de suspensão/arquivo apresentadas à função responsável. Não se apagam registos para os retirar do público.
|
||||
|
||||
Este guia descreve os percursos existentes; a instalação de código é manutenção pelo processo Git automático. Contas ainda não criadas conservam as designações aprovadas e não suspendem os acessos das contas existentes. Conferência real desta instalação registada na secção de entrega, abaixo; o guia não substitui essa evidência.
|
||||
|
||||
## Parques — disponibilizados em18/10/2026
|
||||
|
||||
Revisão `parques-integral-reconciliado-639ed9624a48` disponibilizada em `https://portal-investidor.test`, com autorização expressa da Tempo. Sete fontes (cinco funcionais e dois auxiliares gerados) e170 ficheiros de build; nenhuma migration executada. L1/140000, navegação/150000 e editorial160000 preservados. Backup/restauro fresco e preservação de dados conferidos. Os dados reais desconhecidos mantêm-se desconhecidos; não se encerram todos os requisitos da ficha pelo nome integral.
|
||||
@@ -2151,3 +2165,106 @@ Autorização expressa da Tempo executada. Main passou de `57fc9148` por avanço
|
||||
**Estado fiscal preservado:** nenhuma publicação/activação de nova regra e nenhuma operação na base activa. O consumidor IVA geral previamente autorizado manteve a mesma disponibilidade, cobertura2026 e mensagem antes/depois. As exclusões sectoriais, forma indirecta/dimensão monetária não determinada, benefícios territoriais e apuramento fiscal completo continuam explícitos e não bloqueiam o lote procedimental concluído.
|
||||
|
||||
**Suspensão/recuperação:** desligar somente a chave do novo percurso e reconstruir cache/reiniciar graciosamente os workers mantém dossiers, documentos, respostas, versões, relatórios e históricos. Para reverter configuração, usar o valor anterior apenas depois de conferir o hash actual e preservar alterações posteriores; não substituir todo o .env sem conferir concorrência. A reversão de código/build segue o procedimento de releases já existente, sem restaurar dados antigos. A release anterior foi efectivamente conservada na primeira tentativa recusada. Recibo completo local em `outputs/investmoz-adaptive-path-delivery-20261022/delivery-receipt.json`; a presente actualização documental fica na branch de registo, sem novo push main ou nova disponibilização desnecessária.
|
||||
|
||||
|
||||
## Revis?o das 30 fichas de Inhambane e cat?logo vazio ? 22/10/2026
|
||||
|
||||
Leitura remota autenticada, transac??o READ ONLY, base teste_investidor: 30 oportunidades em draft; zero publicadas em toda a base. As consultas independentes de Cabo Delgado e Inhambane e a consulta exacta Inhambane/Vilankulo devolveram zero sem falha de consulta. A alega??o de oportunidades publicadas de Cabo Delgado n?o se confirma neste destino; n?o inventar associa??es para preencher a lista.
|
||||
|
||||
As 30 localiza??es permanecem nos recursos privados opportunity_location_drafts, referidos no cadastro can?nico; os campos e v?nculos p?blicos ainda n?o s?o a projec??o dessas fichas. A inexist?ncia desses v?nculos p?blicos em rascunhos n?o prova perda da localiza??o. Na publica??o, projectar somente a abrang?ncia documentada e conferir a pesquisa territorial.
|
||||
|
||||
Decis?o Tempo nesta conversa: incompletude e autoriza??es necess?rias ? execu??o n?o s?o impedimentos autom?ticos ? divulga??o como oportunidade em prepara??o. Proposta: divulgar resumos das 30 fichas em prepara??o com fonte, edi??o, data-base e lacunas vis?veis; n?o declarar financiamento, terreno, licen?as, promotor autorizado ou execu??o confirmados. Valores hist?ricos, agregados e divergentes devem ser identificados sem somas indevidas. Nenhum impedimento concreto de divulga??o do resumo foi demonstrado nesta leitura. Isto n?o declara o conte?do privado integral public?vel: originais, contactos e evid?ncias continuam separados. N?o houve publica??o, altera??o de pap?is ou escrita remota.
|
||||
|
||||
Prepara??o por ficha: outputs/investmoz-territory-recovery-20261022/publication-review.json (30 identidades reais, localiza??o privada, lacunas e valores). Evid?ncia: read-catalogue-remote.py e remote-read.log no mesmo direct?rio. A tabela hist?rica de lacunas de prepara??o ? reutilizada, sem a converter em bloqueios legais. A prepara??o t?cnica do fluxo de publica??o ainda deve reconciliar essas condi??es com o contrato existente; n?o marcar publica??o como pronta ou executada.
|
||||
|
||||
O URL recebido ? do cat?logo/iniciar por territ?rio, n?o do dossier afectado. N?o permite ler o contexto inicial, a altera??o e a ?ltima ac??o desse processo. A falha da pr?via territorial e do t?rmino sem resposta continua aberta e ainda n?o foi reproduzida no dossier real; n?o encerrar pela disponibiliza??o anterior ou por esta leitura de cat?logo.
|
||||
<!-- search-territory-recovery-20261022 -->
|
||||
## Continuidade territorial da pesquisa — correcção em QA, 22/10/2026
|
||||
|
||||
Esclarecimento da Tempo: o percurso reportado foi navegação pública entre carteira e catálogo, sem clicar em iniciar/continuar ideia. Não existe evidência de alteração de um dossier pessoal neste caso. Retira-se a exigência de URL do dossier e de confirmação de impacto; a alteração é de filtros de pesquisa.
|
||||
|
||||
Causa verificada: ligações da carteira para oportunidades e zonas e ligações dos menus públicos usavam destinos sem província/distrito. O contexto transportado para detalhes por `catalogue_query` também não acompanhava as ligações cruzadas. No ecrã de zonas, os parâmetros de exportação/detalhe omitiam a hierarquia territorial.
|
||||
|
||||
Correcção: `searchTerritoryHref` transporta exclusivamente os quatro níveis territoriais na URL entre carteira, oportunidades, zonas e educação, incluindo os respectivos detalhes. Reutiliza o `catalogue_query` existente e preserva os filtros próprios do destino. Uma selecção explícita de outro ascendente não herda descendentes incompatíveis. Sem armazenamento global, mistura de contas ou alteração de dossiers. Menus, navegação relacionada, breadcrumbs e ligações cruzadas usam a mesma função. O selector partilhado mantém a limpeza dos descendentes; não há confirmação adicional para pesquisar.
|
||||
|
||||
Verificação dirigida: seis testes Node (incluindo o selector partilhado), TypeScript e build isolado. No navegador QA: carteira Cabo Delgado → selecção Inhambane/Vilankulo → pesquisar → catálogo com ambos os campos preenchidos; recarregamento e regresso preservam a selecção; zonas herdam os dois níveis; mudar para Cabo Delgado limpa Vilankulo. Contagem zero, filtros activos e mensagem de ausência coerentes na base QA consultada. Não foram repetidos cálculos ou exportadores fiscais nem publicados registos para produzir resultados artificiais.
|
||||
|
||||
Provas: `outputs/investmoz-territory-recovery-20261022/before-carteira.png` e `after-catalogue.png` no workspace de entrega; testes em `tests/frontend/search-territory.test.mjs` e `territory-path.test.mjs`; logs QA `outputs/territory-types.log` e `territory-build.log`. O router privado QA selecciona o build isolado; não integra a candidata.
|
||||
|
||||
Entrega limitada a branch/QA. A homologação não foi alterada por esta correcção. Publicação das 30 fichas de Inhambane permanece separada: revisão por ficha preparada, nenhuma publicação executada; ainda por reconciliar a preparação técnica da divulgação em preparação com o fluxo existente. A correcção da pesquisa não depende dessa publicação.
|
||||
## Inhambane — divulgação dos 30 resumos executada, 22/10/2026
|
||||
|
||||
Decisão expressa da Tempo na conversa: «publicar» os 30 resumos como oportunidades em preparação; responsável confirmado `nala@tempo.co.mz`. A conta concluiu autenticação normal com MFA. A primeira ligação abriu indevidamente `/management/opportunities`, que exige administração geral; `/minhas-oportunidades` estava acessível e foi confirmado no navegador. A ligação SSH estava pronta; não foi necessário pedir outra autenticação.
|
||||
|
||||
Executada uma operação privada de dados, limitada às 30 identidades e aos hashes das versões autorizadas, sob a decisão de divulgação da Tempo e a sessão real de Nala. Não se atribuiu papel geral de Publicador, administração, revisões técnicas, aprovação jurídica ou autorização de execução. O fluxo genérico de aprovação de projectos completos permaneceu intacto. A publicação promocional tem finalidade própria registada em `workflow_context.publication.purpose=promotional_preparation`; não equivale à conclusão desse fluxo. Cada operação registou responsável, conteúdo anterior, hash do lote e finalidade no histórico restrito.
|
||||
|
||||
O executor preserva o cadastro canónico privado, proprietário e organização, originais e contactos. A projecção pública contém título, resumo, limitações, fonte/edição e indicação «Em preparação». Montantes não confirmados não são apresentados como investimento disponível; os dados técnicos/financeiros históricos são assinalados como tal. A conferência editorial retirou instruções internas dos resumos, usando as limitações da tabela aprovada na conversa.
|
||||
|
||||
Detectado e resolvido o vínculo territorial em falta: as localizações privadas mencionavam Vilankulos, mas só tinham província estruturada. O cadastro público confirma MUNICÍPIO DE VILANKULO → posto VILANKULO → distrito VILANKULO → Inhambane. Foram associados ao distrito apenas 15 registos cuja localização documental menciona expressamente Vilankulos, incluindo programas de abrangência múltipla. Não se inferiu implantação, coordenadas ou relação por proximidade. As restantes fichas conservam a abrangência documentada; não foram associadas indiscriminadamente ao distrito.
|
||||
|
||||
Verificação pública sem sessão da aplicação: **30 resultados em Inhambane, 15 em Vilankulo**, todos com indicação «Em preparação», limitações e sem `management_draft`/`workflow_context` na resposta pública. No navegador foram conferidos catálogo, contagem e a ficha do Hotel Boutique no Terreno INATUR, incluindo a ausência de confirmação da disponibilidade do terreno/concessão. Foram reutilizadas as provas anteriores; o ensaio novo em base QA isolada conferiu publicação, alteração editorial, repetição sem duplicados, preservação do cadastro privado, recusa de alteração concorrente e associação exacta das 15 referências. Todos os dados sintéticos foram revertidos por rollback em QA; nenhum actor QA foi transportado.
|
||||
|
||||
Backup fresco em formato PostgreSQL custom antes de cada revisão de dados, protegido fora da raiz pública; catálogo de restauro verificado e provas anteriores de restauro reutilizadas. A leitura final confirmou as mesmas 30 identidades, cadastro privado/proprietários preservados, zero novos papéis Publicador e administração geral de Nala mantida desligada. A reversão deve partir do backup e do `before_data` restrito, comparar a versão actual e restaurar apenas as projecções/vínculos afectados, preservando edições posteriores; não restaurar toda a base sobre alterações concorrentes. Uma suspensão deve retirar os resumos do público, mantendo cadastro, relações e histórico, como operação expressamente registada; não apagar as fichas.
|
||||
|
||||
Recibos no workspace de entrega: `outputs/investmoz-territory-recovery-20261022/public-verification.json`, `integrity-receipt.log`, `publication-final-result.log`, `published-vilankulo-results.png`; executores privados e manifestos no mesmo directório, arquivados fora da raiz pública no destino. Hash final do lote: `d0a1146a7113aabafc3c2d08c0bdc17d12fc8a51606ba9ff7e79b154556d6e08`.
|
||||
|
||||
Revisão efectivamente servida: `042ffbde44cac27704d6f8b7ec25b525a42e3575`. Nenhuma instalação Git, migration ou activação fiscal foi executada nesta publicação. A candidata `9ac623a4` de continuidade dos filtros permanece separada de `main` e pronta para revisão de integração. A divulgação das 30 fichas deixa de estar pendente; os estudos, financiamento e autorizações de execução continuam como limitações explícitas das oportunidades.
|
||||
## Integração territorial e contacto comercial — 22/10/2026
|
||||
|
||||
Tempo autorizou integrar `9ac623a4` e pediu conferir a revisão servida. `57fc9148` é antepassado de `042ffbde`, e não uma revisão posterior: os hashes não têm ordem cronológica. A conferência remota comparou 685 ficheiros de aplicação/configuração com `042ffbde`, sem diferenças; o manifesto de build também coincide com o identificador publicado. O histórico de instalação confirma a passagem de `57fc9148` para `042ffbde`. Não se identificou reposição de código/build ou desaparecimento da entrega do simulador.
|
||||
|
||||
Integração Git por avanço directo até `1ec31856`, incluindo `9ac623a4` e recibos documentais. A primeira tentativa automática preservou a revisão anterior por falha Node: importação sem extensão em `catalogue-context.ts`. Corrigida a referência explícita `.ts`; passaram os dez testes dirigidos do módulo afectado e da continuidade territorial. Não se repetiram os percursos aceites nem a publicação de oportunidades.
|
||||
|
||||
Decisão expressa da Tempo: o rodapé deve apresentar **comercial@tempo.co.mz**, porque o contacto comercial com a instituição é feito por esse endereço. Actualizado o valor por omissão de `portal.institutional_contact_email`. Na homologação existia uma sobreposição explícita `PORTAL_INSTITUTIONAL_CONTACT_EMAIL=info@tempo.co.mz`: alterada exclusivamente essa chave para `comercial@tempo.co.mz`, com backup privado íntegro, propriedade/permissões preservadas e reconstrução da cache de configuração. A apresentação e o `mailto:` utilizam a mesma configuração existente; não houve envio de email.
|
||||
|
||||
Recibos de destino e integração: `outputs/investmoz-territory-recovery-20261022/revision-before-integration.log`, `footer-email-receipt.log` e recibo final de instalação no mesmo directório do workspace de entrega. A confirmação final deve identificar a revisão efectivamente instalada pelo processo automático, mantendo os 30 resumos e as regras fiscais existentes.
|
||||
## Selectores especializados do catálogo — QA, 22/10/2026
|
||||
|
||||
Pedido da Tempo: explorar estruturas públicas independentemente da existência de oportunidades, sem inventar associações nem divulgar registos privados. A homologação em `7b1c3579` já devolve e apresenta **Parque Industrial de Balama** e **Parque Industrial de Montepuez** no selector de Cabo Delgado; “Todos” é o valor inicial, não uma prova de ausência das opções. A API devolve duas zonas nessa província e oito no âmbito nacional. A base QA local tem outro conjunto de dados: não continha parques públicos ligados a essa província. Esta diferença de dados não é uma regressão demonstrada do selector da homologação.
|
||||
|
||||
Falhas verificadas no código: actividades e cadeias eram reduzidas às oportunidades publicadas encontradas no território/sector; estruturas públicas podiam depender da existência adicional de uma ficha publicada da carteira; os selectores vazios só apresentavam “Todos”; a consulta da ficha de um parque sem oportunidades não era apresentada no resultado vazio da grelha.
|
||||
|
||||
Correcção na branch `codex/catalogue-specialised-selectors-QA`: opções provenientes dos cadastros públicos existentes, preservando os critérios de consulta e os vínculos territoriais. Estruturas com território principal documentado não dependem de uma oportunidade nem de uma ficha adicional da carteira. Mantêm-se os vínculos territoriais já resolvidos pelas fichas publicadas. Cadeias acompanham os clusters públicos do âmbito seleccionado. Actividades são o catálogo público de classificação, explicitamente separado da disponibilidade local de investimentos; não se inventa uma associação territorial ou sectorial. Os oito selectores apresentam contagem ou explicação de ausência. Uma selecção indisponível permanece identificada, com possibilidade de remoção, em vez de parecer “Todos”.
|
||||
|
||||
Quando a estrutura seleccionada não tem correspondências, o resultado informa “Sem oportunidades publicadas associadas com estes filtros” e mantém ligação à ficha pública existente, em qualquer vista. Um registo mestre sem ficha pública detalhada é identificado como tal; não se cria uma ficha ou publicação automaticamente. A pesquisa continua a combinar todos os filtros, sem alargamento automático ou atribuição de benefícios. A alteração territorial mantém a limpeza dos níveis e selecções incompatíveis já implementada.
|
||||
|
||||
Provas novas, sem repetir os percursos anteriormente aceites: Cabo Delgado com dois parques sintéticos identificados; selecção de Balama, zero oportunidades, abertura da ficha, regresso e recarregamento com selecção conservada; limpeza; Nampula com parque sintético Topuito; ausência de restrição territorial com ambos os conjuntos disponíveis e selecção anterior limpa. Fixtures limitadas à base `simulator_fiscal_delta_20261021_test`, porta 55441, excluídas do Git e da disponibilização. Testes `CatalogueSelectorReferencesTest`: três testes, 27 verificações, transacções revertidas, incluindo exclusão de privados/outro território, catálogo de actividades independente dos resultados, estruturas/cadeia sem oportunidades ou fichas adicionais. `npm run types:check` e build QA passaram. Provas locais: `outputs/catalogue-selectors-20261022/audit.json`, `qa-balama.png`, `qa-nampula.png`, `qa-national.png` e logs de build/tipos no mesmo directório.
|
||||
|
||||
Diagnóstico separado do mapa: API pública de Cabo Delgado devolve território cadastrado, zero limites e nenhuma geometria acessível. Leitura SSH em transacção **READ ONLY** confirmou zero linhas em `geo_geometries` para essa província; neste caso não é uma camada existente ocultada pelo perfil. Não foram criadas coordenadas, geometrias, vínculos ou permissões. Este diagnóstico não equivale à implementação dos limites administrativos.
|
||||
|
||||
Integração pendente da revisão prevista: comparar com `origin/main`, preservar alterações concorrentes e integrar pelo processo Git automático existente. Não há migrations, publicação de oportunidades, activação fiscal ou transporte das fixtures. Conferir apenas o delta e a revisão servida depois de autorizada a integração; não repetir as provas já aceites.
|
||||
### Revisão dirigida da interacção do selector — 22/10/2026
|
||||
|
||||
Na candidata `1d921c9d`, ensaio pelo navegador Edge em QA, sem usar `selectOption` como prova desta interacção: clique no campo/seta deu foco; teclado `Alt+Down`, seta para cima e Enter mudou a selecção de Montepuez para Balama, actualizando o filtro e a ligação à ficha. Prova local `outputs/catalogue-selectors-20261022/qa-keyboard-balama.png`. Dados sintéticos explicitamente identificados como QA.
|
||||
|
||||
A ferramenta não observou o menu nativo aberto pelo rato; a tentativa de clicar na opção nativa devolveu opção não visível. Assim, a escolha completa **apenas pelo rato permanece não demonstrada** nesta revisão. Trata-se de limitação de observação/interacção da ferramenta, sem evidência suficiente para atribuir falha ao portal. A presença das opções na API/DOM e o uso anterior de `selectOption` não encerram esse ponto. Sem alteração funcional, repetição dos restantes testes ou integração em `main`; geometrias continuam como pendência distinta.
|
||||
### Decisão de integração — 22/10/2026
|
||||
|
||||
A Tempo encerrou a revisão de `1d921c9d` com as provas existentes e autorizou integrar a candidata e o registo documental `916819eb`. A interacção exclusivamente pelo rato permanece não observada pela ferramenta, **sem carácter bloqueante**; não constitui falha comprovada do portal, não exige substituir o selector nem repetir ensaios. A ausência de geometrias administrativas de Cabo Delgado permanece independente. Executar o processo Git automático existente, preservando alterações concorrentes; não transportar fixtures QA, publicar dados ou activar regras fiscais.
|
||||
|
||||
## Entrada única de gestão — preparação em QA, 22/10/2026
|
||||
|
||||
Origem: prioridade expressa da Tempo para a equipa cadastrar oportunidades e carregar, editar e publicar documentos e legislação pela interface, sem depender do Codex. A decisão de 22/10 sobre fontes e execução mantém-se. Geometrias administrativas ficam no saldo separado.
|
||||
|
||||
Implementada `/gestao-conteudos`, acessível pelo painel, menu lateral, Balcão e editores. Reutiliza os cadastros, biblioteca, armazenamento, perfis e histórico existentes. Nova oportunidade abre o editor operacional para quem tem contexto de criação; acesso legado é identificado como tal, sem o apresentar como publicação operacional completa. A entrada não atribui papéis. Escolhas técnicas deste delta: navegação agregada, contagens no âmbito do utilizador, pesquisa documental e pré-selecção do ficheiro acabado de carregar. Não são novas regras organizacionais.
|
||||
|
||||
Percurso diário: **Gestão de conteúdos e oportunidades → Nova oportunidade**, escolher promotor/modalidade, preencher e guardar; recuperar em **Gerir oportunidades e histórico**. Para documentos: **Novo documento** ou **Nova legislação → Receber documento em privado → Guardar em quarentena → Registar documento na biblioteca**. O original recém-carregado fica seleccionado; título e emitente conhecidos são recuperados. Completar os metadados, guardar, pesquisar o registo e usar **Editar metadados**, **Publicar** ou **Retirar da publicação**. Associações apresentam nomes dos registos existentes. Campos ainda não conhecidos não são inventados. Publicação para consulta não activa cálculos.
|
||||
|
||||
Verificação nova: quatro testes, 99 asserções, com transacções revertidas exclusivamente em `simulator_fiscal_delta_20261021_test`, loopback 55441, guarda explícita. Demonstrados acesso de equipa sem administração geral, recusa a utilizador sem autorização e ciclo documental completo (upload, ClamAV real, recuperação por pesquisa, edição, publicação/download, retirada, preservação do hash e quatro revisões). Exemplo documental exclusivamente sintético, sem origem oficial ou valor jurídico. Corrigida a classificação de retirados: o histórico existente usa `unpublished`; o filtro e o rótulo reconhecem também `withdrawn` histórico. TypeScript e build QA passaram.
|
||||
|
||||
No navegador, login e MFA normais da conta sintética de equipa; entrada → legislação; entrada → nova oportunidade → guardar preparação incompleta → recarregar, com título recuperado e ficha QA 241. Não foi repetido o workflow integral de revisão/publicação de oportunidades já existente; a prova nova cobre a entrada e a gravação/recuperação. O ensaio documental completo foi HTTP transaccional, não uma prova visual de cada clique. A conta QA tem autorizações explicitamente sintéticas; não comprova as designações das contas reais. Não transportar fixtures, configurações QA ou concessões de acesso.
|
||||
|
||||
Provas locais: `outputs/content-management-20261022/entrada-final.png`, `build-final.log`, e `tests/Feature/ContentManagementEntryTest.php`. Estado: candidata em branch `codex/content-management-entry-QA`, ainda sem integração ou instalação na homologação. Não declarar encerrada a autonomia operacional de todas as contas/categorias apenas por esta entrada. Não houve migration, publicação remota, alteração de papéis activos ou activação fiscal.
|
||||
## Entrada única — integração e conferência real, 22/10/2026
|
||||
|
||||
A pedido expresso da Tempo, revista e integrada por avanço directo a candidata 97d51db6, seguida de aa2b3d05 (ligação da antiga página de acesso para a entrada autorizada) e 8286d750 (restituição do acesso editorial nominal). Push em main e instalação pelo processo Git automático existente; revisão efectivamente servida 8286d7504d9d368de81b4e8d095398764278bb95, build 22/10/2026 19:04:38 UTC. Nenhuma migration, publicação de conteúdo ou alteração de atribuições activas nesta entrega.
|
||||
|
||||
A leitura da sessão real de Osmane Nala após login/MFA confirmou a dupla 15/16, Promotor e Validador activos da conta 15, uma atribuição anterior revogada preservada e nenhuma atribuição Publicador na lista actual. Criação abre as seis categorias atribuídas e os promotores reais; a lista recupera os 30 resumos já publicados e a fila Validação e pareceres abre sem casos. Isto não afirma revisão de um caso inexistente nem publica para ensaio. Atribuições futuras de Aurora/Palmira/Mario conservam a decisão da Tempo e não são pré-condição dos acessos de Nala/onala. Não foi repetido login da outra titular; reutiliza-se a prova nominal anterior e a mesma regra editorial delimitada.
|
||||
|
||||
Falha concreta encontrada no destino: `portal.homologation_only=false` fazia `DocumentManagementAccess::editorial` recusar a designação já aprovada. A reserva do host era escolha técnica, não condição organizacional da edição. 8286d750 remove apenas essa dependência; preserva lista nominal Nala/onala, dupla existente, MFA recente, exclusão de Auditor e âmbito documental. Um teste novo, 24 asserções, exclusivamente em QA isolada com rollback, demonstrou independência da reserva e recusa sem designação/MFA. Não foram repetidos os quatro testes/99 asserções anteriores. TypeScript foi conferido para o delta da antiga página de acesso; build/testes obrigatórios pertencem ao processo automático.
|
||||
|
||||
Na mesma sessão real, após instalação, a entrada passou a mostrar Novo documento e Nova legislação e 29 publicados/2 não publicados. A biblioteca abriu com Receber documento em privado, Guardar em quarentena, Registar documento na biblioteca, pesquisa, Editar metadados, Publicar/Retirar e históricos. ClamAV real 1.4.3 disponível, 32 ficheiros limpos no âmbito. Nenhum conteúdo foi alterado ou publicado para repetir prova já aceite em QA.
|
||||
|
||||
**Dependências que permanecem explícitas:** (1) o fluxo normal de oportunidades tem interface, mas a lista actual não tem Publicador activo; falta a disponibilidade da conta/função já designada, não um comando do Codex. Nala não recebe outro papel por inferência. (2) A divulgação promocional de resumos em preparação, usada no lote Inhambane, foi operação por executor privado; ainda não existe acção genérica equivalente na interface. A candidata não encerra essa autonomia específica, nem as variantes de submissão que o editor já identifica como não implementadas. (3) manutenção de indisponibilidade persistente do scanner/worker continua técnica; as operações documentais normais não exigem comandos. Geometrias administrativas permanecem em saldo separado.
|
||||
|
||||
Link único e guia curto encontram-se no início deste manual: https://teste-investidor.tempo.co.mz/gestao-conteudos. Recibo e imagem da conferência: outputs/content-management-delivery-20261022/. Registo documental mantido na branch QA, sem novo push main/desencadeamento de instalação apenas para o recibo.
|
||||
File diff suppressed because one or more lines are too long
@@ -9,6 +9,220 @@
|
||||
|
||||
@import './tempo-header.css';
|
||||
|
||||
/* Institutional shell adapted from Tempo; page content keeps its own theme. */
|
||||
.tempo-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
background: #090909;
|
||||
color: #fff;
|
||||
}
|
||||
.tempo-header-inner {
|
||||
grid-template-rows: 84px;
|
||||
}
|
||||
.tempo-logo-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 140px;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.tempo-nav-trigger {
|
||||
display: flex;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: #eee;
|
||||
transition:
|
||||
color 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
.tempo-nav-trigger:hover,
|
||||
.tempo-nav-trigger[data-state='open'] {
|
||||
background: #ffffff0d;
|
||||
color: #fff;
|
||||
}
|
||||
.tempo-nav-trigger[data-current-group] {
|
||||
color: #fff;
|
||||
box-shadow: inset 0 -2px #ed1c24;
|
||||
}
|
||||
.tempo-nav-trigger[data-state='open'] > svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.tempo-nav-trigger {
|
||||
gap: 0.25rem;
|
||||
padding-inline: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.tempo-nav-trigger > svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
.tempo-utility-button {
|
||||
display: flex;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.tempo-utility-button:hover {
|
||||
background: #ffffff10;
|
||||
}
|
||||
.tempo-header-social {
|
||||
display: flex;
|
||||
width: 30px;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--social-color);
|
||||
transition:
|
||||
transform 0.2s,
|
||||
opacity 0.2s;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
.tempo-header-social:hover {
|
||||
transform: translateY(-2px);
|
||||
opacity: 0.8;
|
||||
}
|
||||
@media (max-width: 360px) {
|
||||
.tempo-header-social {
|
||||
width: 26px;
|
||||
}
|
||||
}
|
||||
.tempo-login-button {
|
||||
display: inline-flex;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid #ffffff50;
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.5rem 0.85rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
transition:
|
||||
background 0.2s,
|
||||
border-color 0.2s;
|
||||
}
|
||||
.tempo-login-button:hover {
|
||||
background: #ed1c24;
|
||||
border-color: #ed1c24;
|
||||
}
|
||||
.tempo-header-actions {
|
||||
order: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.tempo-interest-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #ed1c24;
|
||||
border-radius: 0.4rem;
|
||||
background: #ed1c24;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.tempo-interest-button:hover {
|
||||
background: #c9151c;
|
||||
}
|
||||
.tempo-panel-summary {
|
||||
order: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding-block: 0.5rem;
|
||||
border-top: 1px solid #ffffff1a;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.tempo-panel-summary a {
|
||||
color: #fff;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.tempo-panel-summary a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.tempo-panel-summary span {
|
||||
color: #cbd5e1;
|
||||
font-size: 0.6875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.tempo-header-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
padding-block: 0.65rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.tempo-header-inner {
|
||||
grid-template-rows: 90px;
|
||||
}
|
||||
.tempo-logo-link {
|
||||
width: 160px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1280px) {
|
||||
.tempo-header-inner {
|
||||
grid-template-rows: 96px;
|
||||
}
|
||||
.tempo-logo-link {
|
||||
width: 152px;
|
||||
}
|
||||
.tempo-header-actions {
|
||||
order: 6;
|
||||
margin-left: 0;
|
||||
}
|
||||
.tempo-header-inner .tempo-nav-trigger {
|
||||
padding-inline: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.tempo-panel-summary {
|
||||
order: 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 0.1rem;
|
||||
width: auto;
|
||||
min-width: 102px;
|
||||
border-top: 0;
|
||||
border-left: 1px solid #ffffff1a;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1440px) {
|
||||
.tempo-header-inner {
|
||||
grid-template-rows: 102px;
|
||||
}
|
||||
.tempo-logo-link {
|
||||
width: 168px;
|
||||
}
|
||||
.tempo-header-inner .tempo-nav-trigger {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
.tempo-header :is(a, button):focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
.tempo-dropdown {
|
||||
z-index: 60;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
@@ -26,6 +26,7 @@ export function AppSidebar() {
|
||||
educationEnabled,
|
||||
aiEnabled,
|
||||
opportunityManagement,
|
||||
contentManagementHref,
|
||||
profileAccess,
|
||||
} = usePage<{
|
||||
auth: Auth;
|
||||
@@ -34,6 +35,7 @@ export function AppSidebar() {
|
||||
aiEnabled?: boolean;
|
||||
profileAccess?: ProfileAccess | null;
|
||||
opportunityManagement?: OpportunityManagementAccess | null;
|
||||
contentManagementHref?: string | null;
|
||||
}>().props;
|
||||
const groups = portalNavigation({
|
||||
authenticated: !!auth.user,
|
||||
@@ -48,6 +50,9 @@ export function AppSidebar() {
|
||||
href: group.children?.find((item) => item.href)?.href ?? '/',
|
||||
icon: LayoutGrid,
|
||||
}));
|
||||
if (contentManagementHref) {
|
||||
items.unshift({ title: 'Gestão de conteúdos e oportunidades', href: contentManagementHref, icon: LayoutGrid });
|
||||
}
|
||||
if (auth.canManagePortal && opportunityManagement) {
|
||||
items.push({
|
||||
title: opportunityManagement.manage.label,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { searchTerritoryHref } from '@/lib/search-territory';
|
||||
export default function CatalogueNavigation({
|
||||
dimensionUrl,
|
||||
}: {
|
||||
@@ -21,7 +22,7 @@ export default function CatalogueNavigation({
|
||||
)
|
||||
return dimensionUrl(u.searchParams.get('dimension')!);
|
||||
if (context) u.searchParams.set('context', context);
|
||||
return u.pathname + u.search;
|
||||
return searchTerritoryHref(u.pathname + u.search, url);
|
||||
};
|
||||
const sections = {
|
||||
'Enquadramento e filtros': [
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
|
||||
export default function ContentManagementEntry() {
|
||||
const { contentManagementHref } = usePage<{ contentManagementHref?: string | null }>().props;
|
||||
return contentManagementHref ? <Link className="inline-block rounded-lg border px-4 py-3 font-semibold underline" href={contentManagementHref}>Gestão de conteúdos e oportunidades</Link> : null;
|
||||
}
|
||||
@@ -48,6 +48,16 @@ const labels: Record<string, string> = {
|
||||
authority_id: 'Entidade',
|
||||
publication_state: 'Critério herdado',
|
||||
};
|
||||
const specialisedEmpty: Record<string, string> = {
|
||||
corridor_id: 'Não existem corredores públicos cadastrados para esta localização.',
|
||||
cluster_id: 'Não existem clusters públicos cadastrados para esta localização.',
|
||||
zee_id: 'Não existem ZEE públicas cadastradas para esta localização.',
|
||||
zfi_id: 'Não existem ZFI públicas cadastradas para esta localização.',
|
||||
park_id: 'Não existem parques industriais públicos cadastrados para esta localização.',
|
||||
project_id: 'Não existem projectos âncora públicos cadastrados para esta localização.',
|
||||
subsector_id: 'Não existem actividades disponíveis no catálogo público.',
|
||||
chain_id: 'Não existem cadeias de valor públicas cadastradas para esta localização.',
|
||||
};
|
||||
export default function OpportunitySearchFilters({
|
||||
data,
|
||||
references,
|
||||
@@ -75,6 +85,9 @@ export default function OpportunitySearchFilters({
|
||||
<Field key={key} label={labels[key]}>
|
||||
<select
|
||||
className={input}
|
||||
aria-label={labels[key]}
|
||||
aria-describedby={specialisedEmpty[key] ? `filter-help-${key}` : undefined}
|
||||
disabled={Boolean(specialisedEmpty[key]) && choices.length === 0 && !data[key]}
|
||||
value={String(data[key] ?? '')}
|
||||
onChange={(e) =>
|
||||
change({
|
||||
@@ -86,12 +99,23 @@ export default function OpportunitySearchFilters({
|
||||
}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{data[key] && !choices.some((o) => o.value === String(data[key])) && (
|
||||
<option value={String(data[key])}>Selecção indisponível neste contexto — remova o filtro</option>
|
||||
)}
|
||||
{choices.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{specialisedEmpty[key] && (
|
||||
<p id={`filter-help-${key}`} className="text-sm text-slate-600">
|
||||
{choices.length === 0
|
||||
? specialisedEmpty[key]
|
||||
: `${choices.length} opções públicas disponíveis, mesmo sem oportunidades associadas.`}
|
||||
{key === 'subsector_id' && choices.length > 0 && ' Classificação de actividades; não confirma a existência de investimentos nesta localização.'}
|
||||
</p>
|
||||
)}
|
||||
{hint && <p className="text-sm text-slate-600">{hint}</p>}
|
||||
</Field>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import PortalProductIdentity from '@/components/portal-product-identity';
|
||||
import { searchTerritoryHref } from '@/lib/search-territory';
|
||||
import { Link, router, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
ChevronDown,
|
||||
@@ -110,7 +111,7 @@ export function PortalGroupedMenu() {
|
||||
className="tempo-menu-item"
|
||||
>
|
||||
<Link
|
||||
href={item.href!}
|
||||
href={searchTerritoryHref(item.href!, url)}
|
||||
aria-current={
|
||||
currentItem === item.id ? 'page' : undefined
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SearchFeedback from '@/components/search-feedback';
|
||||
import { searchTerritoryHref } from '@/lib/search-territory';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { createContext, useContext } from 'react';
|
||||
import { PortalGroupedMenu } from '@/components/portal-grouped-menu';
|
||||
@@ -148,7 +149,7 @@ export default function PortalNavigation({
|
||||
{parent && (
|
||||
<>
|
||||
<Link
|
||||
href={parent}
|
||||
href={searchTerritoryHref(parent, url)}
|
||||
className="underline"
|
||||
>
|
||||
{names[parent]}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { searchTerritoryHref } from './search-territory.ts';
|
||||
/** Only a query string is carried; the return destination is always local. */
|
||||
export function catalogueDetailHref(path: string, query: string): string {
|
||||
return query
|
||||
@@ -8,5 +9,5 @@ export function catalogueReturnHref(base: string, pageUrl: string): string {
|
||||
const query = new URL(pageUrl, 'https://portal.invalid').searchParams.get(
|
||||
'catalogue_query',
|
||||
);
|
||||
return base + (query ? `?${new URLSearchParams(query).toString()}` : '');
|
||||
return searchTerritoryHref(base + (query ? `?${new URLSearchParams(query).toString()}` : ''), pageUrl);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
const levels = ['province_id', 'district_id', 'admin_post_id', 'locality_id'];
|
||||
const searchRoutes = ['/carteira', '/oportunidades', '/zonas', '/educacao'];
|
||||
|
||||
/** URL-scoped search context, never a saved investment or another browser tab. */
|
||||
export function searchTerritoryHref(href: string, pageUrl: string): string {
|
||||
const base = 'https://portal.invalid';
|
||||
const source = new URL(pageUrl, base);
|
||||
const target = new URL(href, base);
|
||||
const supported = (path: string) => searchRoutes.some((route) => path === route || path.startsWith(`${route}/`));
|
||||
if (target.origin !== base || !supported(source.pathname) || !supported(target.pathname)) return href;
|
||||
const inherited = source.searchParams.has('catalogue_query')
|
||||
? new URLSearchParams(source.searchParams.get('catalogue_query')!)
|
||||
: source.searchParams;
|
||||
// An explicit ancestor, including an explicit empty value, overrides its descendants.
|
||||
let changed = false;
|
||||
for (const field of levels) {
|
||||
if (target.searchParams.has(field)) {
|
||||
changed ||= target.searchParams.get(field) !== inherited.get(field);
|
||||
} else if (!changed && inherited.get(field)) {
|
||||
target.searchParams.set(field, inherited.get(field)!);
|
||||
}
|
||||
}
|
||||
return target.pathname + target.search + target.hash;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import ProfileNavigation from '@/components/profile-navigation';
|
||||
import ContentManagementEntry from '@/components/content-management-entry';
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { dashboard } from '@/routes';
|
||||
import type { InvestmentContext } from '@/pages/investor-path/index';
|
||||
@@ -51,6 +52,7 @@ export default function Dashboard({
|
||||
<Head title="Painel" />
|
||||
<h1 className="text-2xl font-semibold">Painel do portal</h1>
|
||||
<ProfileNavigation />
|
||||
<ContentManagementEntry />
|
||||
{summary ? (
|
||||
<>
|
||||
{opportunityManagement && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createOperationKey } from '@/lib/operation-key';
|
||||
import ContentManagementEntry from '@/components/content-management-entry';
|
||||
import { Link, useForm, usePage } from '@inertiajs/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Frame, input, button, card } from '@/pages/investment/shared';
|
||||
@@ -84,6 +85,7 @@ export default function Panel({
|
||||
}, [contextTitle, form.data.title]);
|
||||
return (
|
||||
<Frame title="Balcão Virtual — acompanhamento">
|
||||
<ContentManagementEntry />
|
||||
<nav className="flex flex-wrap gap-4">
|
||||
<Link href="/balcao-virtual/servicos" className="underline">
|
||||
Serviços
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import EducationalGuides from '@/components/educational-guides';
|
||||
import { searchTerritoryHref } from '@/lib/search-territory';
|
||||
import type { EducationalArticle } from '@/components/educational-article-card';
|
||||
import OpportunitySearchFilters from '@/components/opportunity-search-filters';
|
||||
import SearchState from '@/components/search-state';
|
||||
@@ -46,7 +47,8 @@ export default function Catalogue({
|
||||
corridor_id: string | null;
|
||||
}[];
|
||||
}) {
|
||||
const { auth } = usePage<{ auth: { user: unknown } }>().props;
|
||||
const page = usePage<{ auth: { user: unknown } }>();
|
||||
const { auth } = page.props;
|
||||
const form = useForm({
|
||||
association_type: String(filters.association_type ?? ''),
|
||||
association_target: String(filters.association_target ?? ''),
|
||||
@@ -100,6 +102,16 @@ export default function Catalogue({
|
||||
),
|
||||
});
|
||||
const view = String(filters.view ?? 'grid');
|
||||
const selectedStructures = [
|
||||
...(references.zones ?? []).filter((zone) =>
|
||||
['park_id', 'zee_id', 'zfi_id'].some((key) => filters[key] === zone.id),
|
||||
).map((zone) => ({ ...zone, url: `/zonas/${zone.id}` })),
|
||||
...(['clusters', 'corridors', 'projects'] as const).flatMap((group) =>
|
||||
references[group].filter((record) => filters[
|
||||
{ clusters: 'cluster_id', corridors: 'corridor_id', projects: 'project_id' }[group]
|
||||
] === record.id),
|
||||
),
|
||||
];
|
||||
const [territoryPath, setTerritoryPath] = useState(territorySelection);
|
||||
const [saved, setSaved] = useState(favourites);
|
||||
const [selected, setSelected] = useState<number[]>([]);
|
||||
@@ -360,6 +372,17 @@ export default function Catalogue({
|
||||
total={results.total}
|
||||
retry={() => runSearch()}
|
||||
/>
|
||||
{!searchLoading && !searchError && results.total === 0 && selectedStructures.length > 0 && (
|
||||
<section className={card} aria-label="Estrutura seleccionada">
|
||||
<p className="font-semibold">Sem oportunidades publicadas associadas com estes filtros.</p>
|
||||
<p>A estrutura continua disponível para consulta. A sua selecção não confirma benefícios nem disponibilidade para investimento.</p>
|
||||
{selectedStructures.map((record) => (
|
||||
record.url ? <Link key={record.id} className="block underline" href={searchTerritoryHref(record.url, page.url)}>
|
||||
Consultar ficha: {record.name}
|
||||
</Link> : <p key={record.id}>{record.name} — sem ficha pública detalhada disponível.</p>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{!searchLoading &&
|
||||
!searchError &&
|
||||
@@ -696,7 +719,7 @@ export default function Catalogue({
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{territorial.map((d) => (
|
||||
<Link
|
||||
href={`/carteira/${d.id}`}
|
||||
href={searchTerritoryHref(`/carteira/${d.id}`, page.url)}
|
||||
key={d.id}
|
||||
className={card}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import EducationalGuides from '@/components/educational-guides';
|
||||
import { searchTerritoryHref } from '@/lib/search-territory';
|
||||
import type { EducationalArticle } from '@/components/educational-article-card';
|
||||
import { ClassificationSummary } from '@/components/opportunity-classification';
|
||||
import { Link, router, usePage } from '@inertiajs/react';
|
||||
@@ -45,7 +46,7 @@ export default function Detail({
|
||||
<p>
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/carteira/${dossierOrigin.id}`}
|
||||
href={searchTerritoryHref(`/carteira/${dossierOrigin.id}`, page.url)}
|
||||
>
|
||||
Voltar ao dossier: {dossierOrigin.title}
|
||||
</Link>
|
||||
@@ -253,7 +254,7 @@ export default function Detail({
|
||||
<span key={dossier.id}>
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/carteira/${dossier.id}`}
|
||||
href={searchTerritoryHref(`/carteira/${dossier.id}`, page.url)}
|
||||
>
|
||||
Consultar dossier: {dossier.title}
|
||||
</Link>
|
||||
|
||||
@@ -19,6 +19,7 @@ export type Option = {
|
||||
export type Entity = {
|
||||
id: string;
|
||||
name: string;
|
||||
url?: string | null;
|
||||
dossiers?: { id: string; title: string; source: string }[];
|
||||
geography?: StructureGeography | null;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import ParkDetailsEditor, {
|
||||
} from '@/components/investment-zone-operational-details';
|
||||
import { Link, router, useForm, usePage } from '@inertiajs/react';
|
||||
import { catalogueReturnHref } from '@/lib/catalogue-context';
|
||||
import { searchTerritoryHref } from '@/lib/search-territory';
|
||||
import InvestmentTerritory from '@/components/investment-territory';
|
||||
import InvestmentMapPanel from '@/components/investment-map-panel';
|
||||
import {
|
||||
@@ -1772,7 +1773,7 @@ function Editor({
|
||||
<p key={o.id}>
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/oportunidades/${o.slug}?zone_id=${z.id}`}
|
||||
href={searchTerritoryHref(`/oportunidades/${o.slug}?zone_id=${z.id}`, pageUrl)}
|
||||
>
|
||||
{o.title}
|
||||
</Link>
|
||||
@@ -1829,7 +1830,7 @@ function Editor({
|
||||
<p key={r.id}>
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/carteira/${r.id}`}
|
||||
href={searchTerritoryHref(`/carteira/${r.id}`, pageUrl)}
|
||||
>
|
||||
{r.title}
|
||||
</Link>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import InvestmentZoneInstallation, {
|
||||
type ParkInstallationPlan,
|
||||
} from '@/components/investment-zone-installation';
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { Link, router, usePage } from '@inertiajs/react';
|
||||
import { searchTerritoryHref } from '@/lib/search-territory';
|
||||
import { useState } from 'react';
|
||||
import { catalogueDetailHref } from '@/lib/catalogue-context';
|
||||
import {
|
||||
@@ -90,9 +91,13 @@ export default function Zones({
|
||||
zoneExtensionsAvailable?: boolean;
|
||||
territorySelection?: TerritoryOption[];
|
||||
}) {
|
||||
const pageUrl = usePage().url;
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const exportParams = new URLSearchParams();
|
||||
['province_id', 'district_id', 'admin_post_id', 'locality_id'].forEach((field, index) => {
|
||||
if (territorySelection[index]) exportParams.set(field, territorySelection[index].id);
|
||||
});
|
||||
for (const key of [
|
||||
'q',
|
||||
'category',
|
||||
@@ -148,9 +153,9 @@ export default function Zones({
|
||||
>
|
||||
<Link href="/clusters">Por Cluster</Link>
|
||||
<Link href="/corredores">Por Corredor</Link>
|
||||
<Link href="/zonas?category=zee">Por ZEE</Link>
|
||||
<Link href="/zonas?category=zfi">Por ZFI</Link>
|
||||
<Link href="/zonas?category=industrial_park">
|
||||
<Link href={searchTerritoryHref('/zonas?category=zee', pageUrl)}>Por ZEE</Link>
|
||||
<Link href={searchTerritoryHref('/zonas?category=zfi', pageUrl)}>Por ZFI</Link>
|
||||
<Link href={searchTerritoryHref('/zonas?category=industrial_park', pageUrl)}>
|
||||
Parques industriais
|
||||
</Link>
|
||||
</nav>
|
||||
@@ -343,7 +348,7 @@ export default function Zones({
|
||||
{!management && (
|
||||
<Link
|
||||
className="ml-4 underline"
|
||||
href={`/oportunidades?${new URLSearchParams({ [zone.category === 'zee' ? 'zee_id' : zone.category === 'zfi' ? 'zfi_id' : 'park_id']: zone.id })}`}
|
||||
href={searchTerritoryHref(`/oportunidades?${new URLSearchParams({ [zone.category === 'zee' ? 'zee_id' : zone.category === 'zfi' ? 'zfi_id' : 'park_id']: zone.id })}`, pageUrl)}
|
||||
>
|
||||
Ver oportunidades associadas
|
||||
</Link>
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function ManagementAccess({
|
||||
}: {
|
||||
registrationEnabled: boolean;
|
||||
}) {
|
||||
const { auth } = usePage<{ auth: Auth }>().props;
|
||||
const { auth, contentManagementHref } = usePage<{ auth: Auth; contentManagementHref?: string | null }>().props;
|
||||
const user = auth.user;
|
||||
const action =
|
||||
'inline-flex rounded-lg bg-slate-900 px-5 py-3 font-medium text-white';
|
||||
@@ -29,7 +29,7 @@ export default function ManagementAccess({
|
||||
<h2 className="text-xl font-semibold">
|
||||
{!user
|
||||
? 'Preparar a sua conta'
|
||||
: auth.canManagePortal
|
||||
: contentManagementHref || auth.canManagePortal
|
||||
? 'Acesso disponível'
|
||||
: !user.email_verified_at
|
||||
? 'Verificar o seu email'
|
||||
@@ -53,6 +53,11 @@ export default function ManagementAccess({
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : contentManagementHref ? (
|
||||
<>
|
||||
<p>Abra a entrada de gestão para cadastrar oportunidades e gerir documentos e legislação, conforme as funções já atribuídas à sua conta.</p>
|
||||
<Link className={action} href={contentManagementHref}>Abrir gestão de conteúdos e oportunidades</Link>
|
||||
</>
|
||||
) : auth.canManagePortal ? (
|
||||
<>
|
||||
<p>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { Frame, card, button } from '@/pages/investment/shared';
|
||||
import { states } from './shared';
|
||||
|
||||
type Access = {
|
||||
opportunities?: {index: string; create: string | null; profileWorkflow: boolean};
|
||||
documents?: {index: string; edit: boolean};
|
||||
};
|
||||
export default function ContentHub({access, counts}: {access: Access; counts: Record<string, Record<string, number>>}) {
|
||||
return <Frame title="Gestão de conteúdos e oportunidades">
|
||||
<p>Use os cadastros e a biblioteca existentes. Guardar um rascunho, analisar um ficheiro e publicar são operações distintas. Cada acção respeita a sua autorização actual.</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{access.opportunities && <section className={card}>
|
||||
<h2 className="text-xl font-semibold">Oportunidades</h2>
|
||||
<p>Escolha a modalidade, o sector e a localização; preencha, guarde o rascunho e siga as acções de submissão, revisão e publicação disponíveis no seu perfil.</p>
|
||||
<p>Sector económico e modalidade são escolhas distintas. Pode guardar preparação incompleta; o editor identifica os campos e variantes ainda sem suporte de submissão.</p>
|
||||
{access.opportunities.create && <Link className={button} href={access.opportunities.create}>Nova oportunidade</Link>}
|
||||
<Link className="block underline" href={access.opportunities.index}>Gerir oportunidades e histórico</Link>
|
||||
<ul>{Object.entries(counts.opportunities ?? {}).map(([state, total]) => <li key={state}><Link className="underline" href={`${access.opportunities!.index}?status=${state}`}>{states[state] ?? state}: {total}</Link></li>)}</ul>
|
||||
<p className="text-sm">As etapas e a independência já definidas para oportunidades mantêm-se. Esta entrada não atribui autorização para rever ou publicar.</p>
|
||||
{!access.opportunities.profileWorkflow && <p>O seu acesso actual abre o cadastro legado de rascunhos. A submissão, revisão e publicação de novas oportunidades utilizam os perfis operacionais já existentes; a administração de conteúdos não substitui essa designação. Esta entrada não apresenta o rascunho legado como percurso completo de publicação.</p>}
|
||||
</section>}
|
||||
{access.documents && <section className={card}>
|
||||
<h2 className="text-xl font-semibold">Documentos e legislação</h2>
|
||||
<p>Carregue o original ou reutilize um ficheiro existente; acompanhe a análise, complete os metadados e associações e defina o acesso. Depois publique explicitamente, se autorizado.</p>
|
||||
<div className="flex flex-wrap gap-3"><Link className={button} href={`${access.documents.index}?intent=document#receber`}>Novo documento</Link>
|
||||
{access.documents.edit && <Link className={button} href={`${access.documents.index}?intent=legislation#receber`}>Nova legislação</Link>}</div>
|
||||
<Link className="block underline" href={`${access.documents.index}#conteudos`}>Gerir conteúdos, versões e histórico</Link>
|
||||
{!access.documents.edit && <p>O seu acesso permite carregar e analisar ficheiros próprios; não inclui cadastro editorial nem publicação.</p>}
|
||||
<p>Publicados: {counts.documents?.published ?? 0} · Não publicados: {counts.documents?.unpublished ?? 0}. A lista distingue rascunhos de conteúdos retirados através do histórico.</p>
|
||||
<p className="text-sm">Publicar legislação permite consultá-la. Não a activa como regra executável no simulador.</p>
|
||||
</section>}
|
||||
</div>
|
||||
<section className={card}><h2 className="font-semibold">Como concluir sem apoio técnico</h2>
|
||||
<ol className="list-decimal space-y-2 pl-5"><li>Abra a acção pretendida e use os campos do editor existente.</li><li>Guarde a preparação. As mensagens indicam o estado e o que precisa de corrigir.</li><li>Reabra pela gestão, pesquise o título e consulte o histórico.</li><li>Use as acções autorizadas de publicação ou retirada. Os originais e as revisões são preservados.</li></ol>
|
||||
</section>
|
||||
</Frame>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ClassificationReview } from '@/components/opportunity-classification';
|
||||
import { createOperationKey } from '@/lib/operation-key';
|
||||
import { valueText } from '@/lib/value-text';
|
||||
import { modalityLabels } from '@/components/opportunity-search-filters';
|
||||
import OpportunityProfileActions from '@/components/opportunity-profile-actions';
|
||||
import OpportunityContactForm from '@/components/opportunity-contact-form';
|
||||
import OpportunityCanonicalFields, {
|
||||
@@ -330,7 +331,7 @@ export default function OpportunityDraft({
|
||||
{c.organization_name ??
|
||||
c.organization_id ??
|
||||
'Âmbito individual'}{' '}
|
||||
— {c.type}
|
||||
— {modalityLabels[c.type] ?? c.type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ProfileNavigation from '@/components/profile-navigation';
|
||||
import ContentManagementEntry from '@/components/content-management-entry';
|
||||
import { Head, Link, usePage } from '@inertiajs/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cloneElement, isValidElement, useId } from 'react';
|
||||
@@ -60,6 +61,7 @@ export function Management({
|
||||
return (
|
||||
<div className="space-y-6 p-4 md:p-8">
|
||||
<Head title={title} />
|
||||
<ContentManagementEntry />
|
||||
{auth?.canManagePortal && basePath !== '/minhas-oportunidades' ? (
|
||||
<nav
|
||||
className="flex flex-wrap gap-4 text-sm underline"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { router, useForm } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { router, useForm, usePage } from '@inertiajs/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Management, Field, control, button } from '../management/shared';
|
||||
|
||||
type DocumentFile = {
|
||||
@@ -164,7 +164,11 @@ export default function Manage({
|
||||
legacyContentEnabled = false,
|
||||
documentAccessOnly = false,
|
||||
editorialScopeOnly = false,
|
||||
managementFilters = {},
|
||||
associationOptions = {},
|
||||
}: {
|
||||
managementFilters?: {q?: string; state?: string; intent?: string};
|
||||
associationOptions?: Record<string, {value: string | number; label: string}[]>;
|
||||
legacyContentEnabled?: boolean;
|
||||
documentAccessOnly?: boolean;
|
||||
editorialScopeOnly?: boolean;
|
||||
@@ -206,12 +210,26 @@ export default function Manage({
|
||||
}) {
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [actionError, setActionError] = useState('');
|
||||
const [query, setQuery] = useState(managementFilters.q ?? '');
|
||||
const [stateFilter, setStateFilter] = useState(managementFilters.state ?? '');
|
||||
const { flash } = usePage<{flash?: {uploaded_document_id?: string}}>().props;
|
||||
const upload = useForm<{
|
||||
file: File | null;
|
||||
original_source: string;
|
||||
issuer: string;
|
||||
}>({ file: null, original_source: '', issuer: '' });
|
||||
const form = useForm<ResourceForm>(emptyResource());
|
||||
intent: string;
|
||||
}>({ file: null, original_source: '', issuer: '', intent: managementFilters.intent ?? 'document' });
|
||||
const form = useForm<ResourceForm>({...emptyResource(), ...(managementFilters.intent === 'legislation' ? {document_type: 'legislation', legislation: true} : {})});
|
||||
const chooseDocument = (id: string) => {
|
||||
const document = documents.find((item) => item.id === id);
|
||||
form.setData((current) => ({...current, document_id: id,
|
||||
issuer: current.issuer || document?.issuer || '',
|
||||
title: current.title || document?.file_name || '',
|
||||
}));
|
||||
};
|
||||
useEffect(() => {
|
||||
if (flash?.uploaded_document_id && !editingId && !form.data.document_id) chooseDocument(flash.uploaded_document_id);
|
||||
}, [flash?.uploaded_document_id]);
|
||||
const setAssociation = (
|
||||
index: number,
|
||||
key: keyof Association,
|
||||
@@ -361,6 +379,7 @@ export default function Manage({
|
||||
))}
|
||||
</section>
|
||||
<form
|
||||
id="receber"
|
||||
className="space-y-4 rounded border p-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -420,6 +439,7 @@ export default function Manage({
|
||||
</form>
|
||||
|
||||
{!documentAccessOnly && <>
|
||||
{managementFilters.intent === 'legislation' && <p className="rounded border p-4">Nova legislação: reutilize o original, complete diploma/tipo, entidade emitente, número, datas e âmbito. Registe alterações e revogações nas associações. Publicar para consulta não activa cálculos no simulador.</p>}
|
||||
<form
|
||||
className="space-y-4 rounded border p-5"
|
||||
onSubmit={(e) => {
|
||||
@@ -458,9 +478,7 @@ export default function Manage({
|
||||
required
|
||||
className={control}
|
||||
value={form.data.document_id}
|
||||
onChange={(e) =>
|
||||
form.setData('document_id', e.target.value)
|
||||
}
|
||||
onChange={(e) => chooseDocument(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
Seleccionar ficheiro ou registo existente
|
||||
@@ -925,6 +943,8 @@ export default function Manage({
|
||||
'document',
|
||||
'investment_type',
|
||||
'legal_source',
|
||||
'sector',
|
||||
'opportunity',
|
||||
'requirement',
|
||||
]
|
||||
.filter(
|
||||
@@ -934,8 +954,8 @@ export default function Manage({
|
||||
requirementAssociationsEnabled,
|
||||
)
|
||||
.map((v) => (
|
||||
<option key={v}>
|
||||
{v}
|
||||
<option key={v} value={v}>
|
||||
{({theme:'Tema', module:'Módulo',activity:'Actividade',stage:'Etapa',procedure:'Procedimento',entity:'Entidade',territory:'Território',document:'Documento / diploma',investment_type:'Modalidade de investimento',legal_source:'Fonte legal',requirement:'Requisito',sector:'Sector',opportunity:'Oportunidade'} as Record<string,string>)[v] ?? v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -974,6 +994,12 @@ export default function Manage({
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
) : associationOptions[association.target_type] ? (
|
||||
<select className={control} required value={association.target_key} onChange={(e) => setAssociation(index, 'target_key', e.target.value)}>
|
||||
<option value="">Seleccione um registo existente</option>
|
||||
{association.target_key && !associationOptions[association.target_type].some((o) => String(o.value) === association.target_key) && <option value={association.target_key}>Referência histórica guardada</option>}
|
||||
{associationOptions[association.target_type].map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
required
|
||||
@@ -1221,13 +1247,20 @@ export default function Manage({
|
||||
</section>
|
||||
)}
|
||||
|
||||
<h2 className="text-xl font-bold">
|
||||
<h2 id="conteudos" className="text-xl font-bold">
|
||||
Registos, pendências e duplicados por rever
|
||||
</h2>
|
||||
<form className="flex flex-wrap gap-3 rounded border p-4" aria-label="Pesquisar conteúdos" onSubmit={(e) => {e.preventDefault(); router.get('/management/portal-services', {q:query,state:stateFilter}, {preserveState:true,preserveScroll:true});}}>
|
||||
<Field label="Pesquisar título, ficheiro, número ou entidade"><input className={control} value={query} onChange={(e) => setQuery(e.target.value)} /></Field>
|
||||
<Field label="Estado do conteúdo"><select className={control} value={stateFilter} onChange={(e) => setStateFilter(e.target.value)}><option value="">Todos</option><option value="draft">Rascunhos</option><option value="published">Publicados</option><option value="withdrawn">Retirados</option></select></Field>
|
||||
<button className={button}>Pesquisar conteúdos</button>
|
||||
</form>
|
||||
<p>Mostram-se até 200 registos por pesquisa. Refine o título ou o estado para localizar conteúdos anteriores.</p>
|
||||
{!resources.length && <p role="status">Sem conteúdos correspondentes no seu âmbito de acesso.</p>}
|
||||
{resources.map((r) => (
|
||||
<article key={r.id} className="space-y-3 rounded border p-4">
|
||||
<h3 className="font-bold">
|
||||
{r.title} · {r.published ? 'Publicado' : 'Rascunho'}
|
||||
{r.title} · {r.published ? 'Publicado' : r.revisions.some((v) => ['unpublished', 'withdrawn'].includes(v.change_type)) ? 'Retirado' : 'Rascunho'}
|
||||
</h3>
|
||||
<p>
|
||||
Ficheiro: {r.file_name} · {r.file_status} ·{' '}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link, useForm } from '@inertiajs/react';
|
||||
import { Link, useForm, usePage } from '@inertiajs/react';
|
||||
import { searchTerritoryHref } from '@/lib/search-territory';
|
||||
import { catalogueDetailHref } from '@/lib/catalogue-context';
|
||||
import { Management } from '../management/shared';
|
||||
import type { Paginated } from '../management/shared';
|
||||
@@ -35,6 +36,7 @@ export default function Index({
|
||||
locality_id: filters.locality_id ?? '',
|
||||
});
|
||||
const base = management ? '/management/portfolio' : '/carteira';
|
||||
const { url } = usePage();
|
||||
const Frame = management ? Management : Layout;
|
||||
return (
|
||||
<Frame title="Corredores, clusters e projectos âncora">
|
||||
@@ -151,7 +153,7 @@ export default function Index({
|
||||
(r.corridor_id || r.cluster_id || r.project_id) && (
|
||||
<Link
|
||||
className="ml-4 underline"
|
||||
href={`/oportunidades?${new URLSearchParams(r.corridor_id ? { corridor_id: r.corridor_id } : r.cluster_id ? { cluster_id: r.cluster_id } : { project_id: r.project_id! })}`}
|
||||
href={searchTerritoryHref(`/oportunidades?${new URLSearchParams(r.corridor_id ? { corridor_id: r.corridor_id } : r.cluster_id ? { cluster_id: r.cluster_id } : { project_id: r.project_id! })}`, url)}
|
||||
>
|
||||
Ver oportunidades associadas
|
||||
</Link>
|
||||
@@ -174,10 +176,10 @@ export default function Index({
|
||||
aria-label="Alternativas de exploração"
|
||||
className="flex flex-wrap gap-4"
|
||||
>
|
||||
<Link className="underline" href="/oportunidades">
|
||||
<Link className="underline" href={searchTerritoryHref('/oportunidades', url)}>
|
||||
Ver oportunidades publicadas
|
||||
</Link>
|
||||
<Link className="underline" href="/zonas">
|
||||
<Link className="underline" href={searchTerritoryHref('/zonas', url)}>
|
||||
Consultar ZEE, ZFI e parques
|
||||
</Link>
|
||||
<Link className="underline" href="/Simulador">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link, useForm, router, usePage } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { catalogueReturnHref } from '@/lib/catalogue-context';
|
||||
import { searchTerritoryHref } from '@/lib/search-territory';
|
||||
import InvestmentMapPanel from '@/components/investment-map-panel';
|
||||
import StructureGeographyPanel, {
|
||||
type StructureGeography,
|
||||
@@ -190,7 +191,7 @@ function Editor({
|
||||
</h2>
|
||||
{zones.map((z) => (
|
||||
<p key={z.id}>
|
||||
<Link className="underline" href={`/zonas/${z.id}`}>
|
||||
<Link className="underline" href={searchTerritoryHref(`/zonas/${z.id}`, pageUrl)}>
|
||||
{z.name}
|
||||
</Link>{' '}
|
||||
· {z.association_type} · Fonte: {z.source_reference}
|
||||
@@ -297,7 +298,7 @@ function Editor({
|
||||
<article key={opportunity.id}>
|
||||
<Link
|
||||
className="font-semibold underline"
|
||||
href={`/oportunidades/${opportunity.slug}?from_dossier=${record.id}`}
|
||||
href={searchTerritoryHref(`/oportunidades/${opportunity.slug}?from_dossier=${record.id}`, pageUrl)}
|
||||
>
|
||||
{opportunity.title}
|
||||
</Link>
|
||||
@@ -485,7 +486,7 @@ function Editor({
|
||||
{kind(r)}:{' '}
|
||||
<Link
|
||||
className="underline"
|
||||
href={`${base}/${r.id}${management ? '' : `?from=${record.id}`}`}
|
||||
href={searchTerritoryHref(`${base}/${r.id}${management ? '' : `?from=${record.id}`}`, pageUrl)}
|
||||
>
|
||||
{r.title}
|
||||
</Link>
|
||||
|
||||
@@ -62,6 +62,7 @@ Route::get('/explorar/{sectorSlug}', [LegacyContentController::class, 'landing']
|
||||
Route::get('/{legacySlug}', [LegacyContentController::class, 'article'])->whereIn('legacySlug', array_keys(config('legacy_content.articles', [])));
|
||||
Route::post('/apoio', [PortalServicesController::class, 'submit'])->middleware('throttle:10,1')->name('services.submit');
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('/gestao-conteudos', fn (\Illuminate\Http\Request $request) => Inertia::render('management/content-hub', app(\App\Services\ContentManagement::class)->view($request->user())))->name('content-management');
|
||||
Route::get('/balcao-virtual', [PortalServicesController::class, 'page'])->defaults('page', 'desk')->name('services.desk');
|
||||
Route::post('/parcerias', [PortalServicesController::class, 'partnership'])->middleware('throttle:10,1')->name('services.partnership');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Services\InvestmentCatalogue;
|
||||
use App\Services\InvestmentZones;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CatalogueSelectorReferencesTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$db = DB::selectOne('select current_database() as name, inet_server_port() as port');
|
||||
$this->assertSame('simulator_fiscal_delta_20261021_test', $db->name);
|
||||
$this->assertSame(55441, (int) $db->port);
|
||||
$this->withoutVite();
|
||||
}
|
||||
|
||||
public function test_public_parks_remain_selectable_with_zero_results_without_exposing_private_or_other_territories(): void
|
||||
{
|
||||
$province = '059b02ca-7095-577a-8705-4d4c46896747';
|
||||
$template = app(InvestmentZones::class)->visible()->firstOrFail();
|
||||
$ids = [];
|
||||
foreach (['public', 'private', 'other'] as $case) {
|
||||
$zone = $template->replicate();
|
||||
$zone->id = (string) Str::uuid();
|
||||
$zone->code = 'SINT-SELECTOR-'.Str::random(12);
|
||||
$zone->name = 'SINTÉTICO QA '.$case;
|
||||
$zone->category = 'industrial_park';
|
||||
$zone->primary_territory_id = $case === 'other' ? '9e42dac8-fd9b-523f-bf59-71a7d3aa0fb8' : $province;
|
||||
$zone->publication_status = $case === 'private' ? 'draft' : 'published';
|
||||
$zone->source_evidence = [['source_type' => 'government', 'url' => 'https://example.test/qa-only', 'supports' => 'Synthetic QA fixture, not a legal source']];
|
||||
$zone->save();
|
||||
$ids[$case] = $zone->id;
|
||||
}
|
||||
$scope = ['province_id' => $province, 'park_id' => $ids['public'], 'q' => 'no-matching-opportunity-'.Str::uuid()];
|
||||
$this->assertSame(0, app(InvestmentCatalogue::class)->query($scope)->count());
|
||||
$response = $this->getJson('/api/v1/investment/references?'.http_build_query($scope))->assertOk();
|
||||
$available = array_column($response->json('zones'), 'id');
|
||||
$this->assertContains($ids['public'], $available);
|
||||
$this->assertNotContains($ids['private'], $available);
|
||||
$this->assertNotContains($ids['other'], $available);
|
||||
$this->assertContains($ids['other'], array_column($this->getJson('/api/v1/investment/references')->assertOk()->json('zones'), 'id'));
|
||||
$this->get('/zonas/'.$ids['public'])->assertOk();
|
||||
$this->get('/zonas/'.$ids['private'])->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_activity_catalogue_does_not_disappear_when_opportunity_search_has_no_matches(): void
|
||||
{
|
||||
$catalogue = app(InvestmentCatalogue::class);
|
||||
$global = collect($catalogue->references()['classification']['targets']['activity'])->pluck('id')->all();
|
||||
$this->assertNotEmpty($global);
|
||||
foreach (['059b02ca-7095-577a-8705-4d4c46896747', '9e42dac8-fd9b-523f-bf59-71a7d3aa0fb8'] as $province) {
|
||||
$scope = ['province_id' => $province, 'q' => 'no-matching-opportunity-'.Str::uuid(), 'sector' => 'tourism'];
|
||||
$this->assertSame(0, $catalogue->query($scope)->count());
|
||||
$this->assertSame($global, collect($catalogue->references(false, $scope)['classification']['targets']['activity'])->pluck('id')->all());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_public_master_structures_and_chain_are_available_without_opportunities_or_published_dossiers(): void
|
||||
{
|
||||
$province = '059b02ca-7095-577a-8705-4d4c46896747';
|
||||
$clusters = [];
|
||||
foreach (['public', 'internal'] as $visibility) {
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('economic_clusters')->insert(['id' => $id, 'code' => 'SINT-'.Str::random(12), 'name' => 'SINTÉTICO QA cluster',
|
||||
'sector' => 'QA', 'status' => 'active', 'data_classification' => $visibility, 'primary_territory_id' => $province]);
|
||||
$clusters[$visibility] = $id;
|
||||
}
|
||||
$corridor = (string) Str::uuid();
|
||||
DB::table('development_corridors')->insert(['id' => $corridor, 'code' => 'SINT-'.Str::random(12), 'name' => 'SINTÉTICO QA corredor',
|
||||
'status' => 'active', 'data_classification' => 'public', 'primary_territory_id' => $province]);
|
||||
$project = \App\Models\Project::factory()->create(['data_classification' => 'public', 'primary_territory_id' => $province]);
|
||||
$chain = (string) Str::uuid();
|
||||
DB::table('value_chains')->insert(['id' => $chain, 'cluster_id' => $clusters['public'], 'code' => 'SINT-'.Str::random(12),
|
||||
'name' => 'SINTÉTICO QA cadeia', 'stage' => 'production', 'data_classification' => 'public']);
|
||||
$refs = app(InvestmentCatalogue::class)->references(false, ['province_id' => $province, 'q' => 'no-matching-opportunity']);
|
||||
$this->assertContains($clusters['public'], $refs['clusters']->pluck('id')->all());
|
||||
$this->assertNotContains($clusters['internal'], $refs['clusters']->pluck('id')->all());
|
||||
$this->assertContains($corridor, $refs['corridors']->pluck('id')->all());
|
||||
$this->assertContains($project->id, $refs['projects']->pluck('id')->all());
|
||||
$this->assertContains($chain, collect($refs['classification']['targets']['chain'])->pluck('id')->all());
|
||||
$other = app(InvestmentCatalogue::class)->references(false, ['province_id' => '9e42dac8-fd9b-523f-bf59-71a7d3aa0fb8']);
|
||||
$this->assertNotContains($clusters['public'], $other['clusters']->pluck('id')->all());
|
||||
$this->assertNotContains($chain, collect($other['classification']['targets']['chain'])->pluck('id')->all());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\ContentManagement;
|
||||
use App\Services\PortalProfileAdministration;
|
||||
use App\Services\OpportunityManagement;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use App\Services\Malware\MalwareScanner;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ContentManagementEntryTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$db = DB::selectOne('select current_database() as name, inet_server_port() as port');
|
||||
$this->assertSame('simulator_fiscal_delta_20261021_test', $db->name);
|
||||
$this->assertSame(55441, (int) $db->port);
|
||||
$this->withoutVite();
|
||||
}
|
||||
|
||||
public function test_entry_does_not_grant_access_to_an_investor(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
config(['portal.managers' => [], 'portal.document_managers' => []]);
|
||||
$this->assertSame([], app(ContentManagement::class)->access($user));
|
||||
$this->actingAs($user)->get('/gestao-conteudos')->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_existing_manager_gets_reused_destinations_and_document_intent_and_search(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
config(['portal.managers' => [$user->email]]);
|
||||
$this->actingAs($user)->get('/gestao-conteudos')->assertOk()->assertInertia(fn (Assert $page) => $page->component('management/content-hub')
|
||||
->where('access.documents.edit', true)->where('access.opportunities.create', '/management/opportunities/create'));
|
||||
$this->get('/management/portal-services?intent=legislation&q=not-existing-qa-title')->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||
->where('managementFilters.intent', 'legislation')->has('associationOptions.sector')->has('associationOptions.stage')->has('resources', 0));
|
||||
$this->get('/management/portal-services?state=published')->assertOk();
|
||||
$this->get('/management/portal-services?state=withdrawn')->assertOk();
|
||||
}
|
||||
|
||||
public function test_team_promoter_uses_operational_editor_without_general_administration(): void
|
||||
{
|
||||
config(['portal.profile_access_enabled' => true, 'portal.opportunity_specialized_drafts_enabled' => true, 'portal.managers' => []]);
|
||||
$actor = User::factory()->create();
|
||||
$actor->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
|
||||
$org = (string) Str::uuid();
|
||||
DB::table('organizations')->insert(['id' => $org, 'name' => 'Synthetic QA content team', 'organization_type' => 'private']);
|
||||
DB::table('memberships')->insert(['id' => (string) Str::uuid(), 'user_id' => $actor->id, 'organization_id' => $org, 'role' => 'officer']);
|
||||
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), User::factory()->create(), [
|
||||
'user_id' => $actor->id, 'role' => 'promoter', 'organization_id' => $org,
|
||||
'opportunity_types' => OpportunityManagement::CATEGORIES, 'responsibilities' => [], 'source' => 'synthetic-content-entry-test',
|
||||
]);
|
||||
$before = DB::table('portal_role_assignments')->count();
|
||||
$this->actingAs($actor)->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->portal_access_revision]);
|
||||
$this->get('/gestao-conteudos')->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||
->where('access.opportunities.create', '/minhas-oportunidades/create')->where('access.opportunities.profileWorkflow', true)->missing('access.documents'));
|
||||
$this->get('/minhas-oportunidades/create')->assertOk();
|
||||
$this->get('/management/portal-services')->assertForbidden();
|
||||
$this->assertSame($before, DB::table('portal_role_assignments')->count());
|
||||
}
|
||||
|
||||
public function test_new_legislation_is_recovered_edited_published_and_withdrawn_with_history(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
config(['queue.default' => 'sync', 'services.malware.scanner' => 'clamd', 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 3310]);
|
||||
$this->assertTrue(app(MalwareScanner::class)->health()['available'], 'Real ClamD required for this QA demonstration');
|
||||
$user = User::factory()->create();
|
||||
config(['portal.managers' => [$user->email]]);
|
||||
$this->actingAs($user);
|
||||
$name = 'synthetic-entry-'.Str::uuid().'.txt';
|
||||
$this->post('/management/portal-services/upload', ['intent' => 'legislation', 'file' => UploadedFile::fake()->createWithContent($name, 'Synthetic QA legislation example, no legal value.')])
|
||||
->assertSessionHasNoErrors()->assertSessionHas('uploaded_document_id')->assertRedirect('/management/portal-services?intent=legislation');
|
||||
$document = DB::table('documents')->where('file_name', $name)->first();
|
||||
$this->assertSame('clean', $document->malware_scan_status);
|
||||
$payload = ['document_id' => $document->id, 'title' => $name, 'category' => 'Synthetic QA', 'topic' => 'Synthetic QA',
|
||||
'document_type' => 'legislation', 'issuer' => 'Synthetic QA only', 'source_url' => 'https://example.invalid/synthetic-qa', 'language' => 'pt', 'version_label' => 'QA-1',
|
||||
'territorial_scope' => 'national', 'source_confirmation' => 'identified', 'legal_status' => 'proposed',
|
||||
'rights_status' => 'authorized', 'data_classification' => 'public', 'source_reference' => 'Synthetic QA example, no official provenance or legal value.',
|
||||
'legislation' => true, 'essential' => false, 'independent_review' => false, 'associations' => []];
|
||||
$this->post('/management/portal-services/resources', $payload)->assertRedirect()->assertSessionHasNoErrors();
|
||||
$resource = DB::table('portal_resources')->where('document_id', $document->id)->first();
|
||||
$this->assertNotNull($resource);
|
||||
$this->assertSame($name, $resource->title);
|
||||
$this->get('/management/portal-services?q='.$name)->assertOk()->assertInertia(fn (Assert $page) => $page->has('resources', 1)->where('resources.0.title', $name));
|
||||
$this->put('/management/portal-services/resources/'.$resource->id, [...$payload, 'title' => $name.' edited', 'metadata_version' => $resource->metadata_version])->assertSessionHasNoErrors();
|
||||
$this->post('/management/portal-services/resources/'.$resource->id, ['published' => true])->assertSessionHasNoErrors();
|
||||
$this->get('/documentos/'.$resource->id.'/download')->assertOk()->assertDownload($name);
|
||||
$this->post('/management/portal-services/resources/'.$resource->id, ['published' => false])->assertSessionHasNoErrors();
|
||||
$this->get('/management/portal-services?state=withdrawn&q='.$name)->assertOk()->assertInertia(fn (Assert $page) => $page->has('resources', 1));
|
||||
$this->get('/management/portal-services?state=draft&q='.$name)->assertOk()->assertInertia(fn (Assert $page) => $page->has('resources', 0));
|
||||
$this->get('/documentos/'.$resource->id.'/download')->assertNotFound();
|
||||
$this->assertDatabaseHas('documents', ['id' => $document->id, 'sha256' => $document->sha256]);
|
||||
$this->assertSame(4, DB::table('portal_resource_revisions')->where('resource_id', $resource->id)->count());
|
||||
}
|
||||
|
||||
public function test_existing_named_editorial_designation_survives_removal_of_technical_reservation(): void
|
||||
{
|
||||
$actor = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
$actor->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()])->save();
|
||||
config(['portal.homologation_only' => false, 'portal.profile_access_enabled' => true,
|
||||
'portal.managers' => [], 'homologation_editorial.accounts' => [$actor->email]]);
|
||||
DB::table('portal_superuser_pair')->updateOrInsert(['id' => 1], ['first_user_id' => $actor->id, 'second_user_id' => $other->id, 'approved_by' => $other->id]);
|
||||
$this->actingAs($actor)->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->portal_access_revision]);
|
||||
$this->get('/gestao-conteudos')->assertOk()->assertInertia(fn (Assert $page) => $page->where('access.documents.edit', true));
|
||||
$this->get('/management/portal-services?intent=legislation')->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||
->where('editorialScopeOnly', true)->where('documentAccessOnly', false));
|
||||
config(['homologation_editorial.accounts' => []]);
|
||||
$this->get('/management/portal-services')->assertForbidden();
|
||||
config(['homologation_editorial.accounts' => [$actor->email]]);
|
||||
$this->withSession(['portal.mfa_at' => time() - 901]);
|
||||
$this->assertFalse(app(\App\Services\DocumentManagementAccess::class)->editorial($actor));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { searchTerritoryHref } from '../../resources/js/lib/search-territory.ts';
|
||||
|
||||
test('portfolio to catalogue to related screen retains only territorial search filters', () => {
|
||||
const start = '/carteira?province_id=inh&district_id=vil&q=airport&kind=project';
|
||||
const catalogue = searchTerritoryHref('/oportunidades?view=grid', start);
|
||||
assert.equal(catalogue, '/oportunidades?view=grid&province_id=inh&district_id=vil');
|
||||
const detail = searchTerritoryHref('/carteira/id', catalogue);
|
||||
assert.equal(searchTerritoryHref('/carteira', detail), '/carteira?province_id=inh&district_id=vil');
|
||||
assert.equal(searchTerritoryHref('/zonas?category=zee', start), '/zonas?category=zee&province_id=inh&district_id=vil');
|
||||
});
|
||||
test('nested historical return query restores territory without importing unrelated filters', () => {
|
||||
assert.equal(searchTerritoryHref('/oportunidades', '/carteira/id?catalogue_query='+encodeURIComponent('province_id=inh&district_id=vil&q=airport')), '/oportunidades?province_id=inh&district_id=vil');
|
||||
});
|
||||
test('explicit parent changes or clearing never import incompatible descendants', () => {
|
||||
const current = '/oportunidades?province_id=inh&district_id=vil&admin_post_id=post&locality_id=loc';
|
||||
assert.equal(searchTerritoryHref('/carteira?province_id=cabo', current), '/carteira?province_id=cabo');
|
||||
assert.equal(searchTerritoryHref('/carteira?district_id=other', current), '/carteira?district_id=other&province_id=inh');
|
||||
assert.equal(searchTerritoryHref('/carteira?province_id=', current), '/carteira?province_id=');
|
||||
});
|
||||
test('no cross-tab, project, account, external or unrelated route inheritance', () => {
|
||||
assert.equal(searchTerritoryHref('/oportunidades','/carteira'), '/oportunidades');
|
||||
assert.equal(searchTerritoryHref('/oportunidades','/investimentos/id?province_id=private'), '/oportunidades');
|
||||
assert.equal(searchTerritoryHref('/investimentos/id','/carteira?province_id=inh'), '/investimentos/id');
|
||||
assert.equal(searchTerritoryHref('https://other.test/carteira','/carteira?province_id=inh'), 'https://other.test/carteira');
|
||||
});
|
||||
Reference in New Issue
Block a user